diff --git a/crates/benilla-world/src/lib.rs b/crates/benilla-world/src/lib.rs index 02aef8e8..38281004 100644 --- a/crates/benilla-world/src/lib.rs +++ b/crates/benilla-world/src/lib.rs @@ -146,6 +146,8 @@ mod shaders; pub mod sky; pub mod sky_order; pub mod skybox; +// MONKEY (skybox): the skybox rig and material loops. +mod skybox_anim; pub mod static_gx; pub mod static_merge; pub mod straddle; diff --git a/crates/benilla-world/src/model_render/batch.rs b/crates/benilla-world/src/model_render/batch.rs index 9adc718e..eeeded74 100644 --- a/crates/benilla-world/src/model_render/batch.rs +++ b/crates/benilla-world/src/model_render/batch.rs @@ -110,11 +110,16 @@ impl M2BatchMaterials<'_> { /// (`[CM2Model+0x180]`, from the interior crossfade `[0xce9bdc]`) scales every batch's alpha, /// and `0 < A < 1` promotes a batch to SRC_ALPHA blending (`0x70c190`): `fade_blend` is that /// twin, or `steady` when the batch already blends. + /// + /// MONKEY (skybox): `uv` and `tint` are the batch's texture-transform and colour loops, the + /// material's seed and identity; [`crate::skybox_anim`] drives their rows. pub fn skybox( &mut self, sub: &benilla_formats::RenderSubmesh, texture: Option>, order: u16, + uv: Option<&std::sync::Arc>, + tint: Option<&std::sync::Arc>, ) -> Option { let light = self.light.as_ref()?.0.clone(); let torch = self.torch.binds()?; @@ -136,10 +141,9 @@ impl M2BatchMaterials<'_> { sub.env_map, ShadeSel::Lit, // unread: every shipped skybox batch is UNLIT (0x01) order, - // No texture-transform or colour loop: neither shipped skybox authors one. Wiring - // one needs the `M2Model` lane's `Arc`, which the material key identifies it by. - None, - None, + // MONKEY (skybox): the loops' `Arc`s, which the material key identifies them by. + uv, + tint, None, // M2: no MOBA class, no SIDN, no WINDOW None, false, @@ -161,6 +165,40 @@ impl M2BatchMaterials<'_> { Some(SkyboxBatch { steady, fade_blend }) } + /// MONKEY (skybox): the flag `0x4` fog cone's material: an untextured, unlit, two-sided blend + /// on the sky lane, its colour the vertex colour times the tint row the skybox lane writes. + pub fn sky_fog_cone(&mut self, order: u16) -> Option> { + let light = self.light.as_ref()?.0.clone(); + let torch = self.torch.binds()?; + Some(model_material( + &mut self.cache.0, + &mut self.materials, + None, + ModelBlend::Blend, + true, + false, + false, + true, // unlit + false, + false, + true, // never writes depth + false, // …and always tests it + benilla_formats::FogPolicy::Off, + false, + ShadeSel::Lit, + order, + None, + None, + None, + None, + false, + true, // the sky lane + &light, + &torch, + None, + )) + } + /// One steady material lit by a render target's own light buffer (a portrait booth, the model /// pane, char-select), which [`super::MatKey::light`] keys apart from the world. `rig` lights /// by the scene's authored M2 rig ([`ShadeSel::Rig`]) and plays the UV animation. `order` must diff --git a/crates/benilla-world/src/skybox.rs b/crates/benilla-world/src/skybox.rs index d4995c09..c7ab7add 100644 --- a/crates/benilla-world/src/skybox.rs +++ b/crates/benilla-world/src/skybox.rs @@ -14,31 +14,88 @@ //! both moons, gradient band and cloud dome; below it they draw under the sky. The glare quads //! draw on their own path (`0x483740` → `0x6d48c0` → `0x7e57e0`), and the fog, ambient and //! diffuse are untouched. +//! +//! MONKEY (skybox): a third slot, the living player's zone skybox, behind the cvar +//! `zoneSkyboxes` ([`ZoneSkyboxes`]). The 1.12 engine never draws a `LightParams` skybox for the +//! living; the modern client does, weighted by the Light sphere falloff +//! ([`benilla_formats::LightCatalog::zone_skyboxes`]). The backdrop is a weighted list +//! ([`CameraSkybox`]): ghost > WMO > zone, the WMO sky crossfading the zone list by the modern +//! collector's rule. `LightSkybox.dbc` flags from the extended table: `0x1` plays sequence 0 over +//! the game day, `0x2` keeps the celestial pass, `0x4` draws a fog-colour cone over the horizon; +//! a celestial model draws as a layer under its main model. Every batch animates +//! ([`crate::skybox_anim`]): bones, texture transforms, colour and alpha tracks. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use bevy::asset::RenderAssetUsages; +use bevy::camera::visibility::NoFrustumCulling; +use bevy::math::Affine3A; use bevy::mesh::{Indices, MeshTag, PrimitiveTopology}; use bevy::prelude::*; use crate::model_render::M2BatchMaterials; +use crate::skybox_anim::{SkyMatLane, SkyRig}; use crate::view::WorldCamera; use benilla_assets::coords::{bevy_to_wow, wow_to_bevy}; use benilla_assets::materials::WowModelMaterial; use benilla_assets::WmoModel; use benilla_assets::{LockRecover, WorldAssets}; +use benilla_formats::{SKYBOX_FOG_BLEND, SKYBOX_FULL_DAY, SKYBOX_KEEP_CELESTIAL}; /// The MOGP/MOGI group flag asking for the root's MOSB sky; the loader keeps the MOGP copy in /// `WmoGroupNav::flags`. const SHOW_SKYBOX: u32 = 0x40000; -/// The skybox model this frame asks for; `None` leaves the [`crate::sky`] gradient dome. -#[derive(Resource, Default, PartialEq, Eq)] -pub struct CameraSkybox(pub Option); +/// MONKEY (skybox): batch-order base of a main model's batches; a celestial layer takes `1..`, so +/// it draws first, under the main model, and the fog cone takes [`FOG_CONE_ORDER`], last. +const MAIN_ORDER_BASE: u16 = 24; +/// MONKEY (skybox): the fog cone's batch order, the band's last distinct step. +const FOG_CONE_ORDER: u16 = 56; + +/// MONKEY (skybox): cvar `zoneSkyboxes` (0/1, default 0): draw the living player's zone skybox +/// from `LightParams`. Off leaves the reference's two slots only. +#[derive(Resource, Default, Clone, Copy, PartialEq, Eq, Debug)] +pub struct ZoneSkyboxes(pub bool); + +/// One skybox model the frame asks for, at its weight. +#[derive(Clone, Debug, PartialEq)] +pub struct SkyboxLayer { + /// The model path, as the slot named it. + pub path: String, + /// The slot weight, `[0, 1]`; 0 fills the slot without drawing (`0x6d4afe`). + pub weight: f32, + /// `LightSkybox.dbc` flags, 0 without a row or in the 1.12 table. + pub flags: u32, + /// The celestial layer of another layer's row, drawn under it. + pub celestial: bool, +} + +/// The skybox models this frame asks for; empty leaves the [`crate::sky`] gradient dome. +#[derive(Resource, Default, PartialEq)] +pub struct CameraSkybox(pub Vec); + +impl CameraSkybox { + /// The heaviest main layer, the one a readout names. + pub fn primary(&self) -> Option<&SkyboxLayer> { + self.0 + .iter() + .filter(|l| !l.celestial) + .fold(None, |best: Option<&SkyboxLayer>, l| match best { + Some(b) if b.weight >= l.weight => Some(b), + _ => Some(l), + }) + } + + fn layer(&self, path: &str) -> Option<&SkyboxLayer> { + self.0.iter().find(|l| l.path == path) + } +} /// The skybox slot's weight this frame: the interior crossfade [`crate::lighting::WmoCrossfade`] /// (±0.25/s, `[0x8115b0]`) for a WMO sky, 1.0 for the ghost sky (`0x6d2260`), and 0 when none -/// resolves. +/// resolves. MONKEY (skybox): the heaviest layer that does not keep the celestial pass (flag +/// `0x2`), so a combining zone sky never stands the procedural sky down. #[derive(Resource, Default, PartialEq)] pub struct SkyboxWeight(pub f32); @@ -60,22 +117,55 @@ struct SkyboxPart { /// The blend-promotion twin for `0 < weight < 1` (`0x811fe0`); `steady` itself when the batch /// already blends. fade_blend: Handle, + /// MONKEY (skybox): the batch's colour-alpha × transparency loops (`0x707680`). + alpha: Option, + /// MONKEY (skybox): the last sampled alpha factor. + anim_alpha: f32, + /// MONKEY (skybox): the batch's texture-transform and colour rows. + lane: SkyMatLane, + /// MONKEY (skybox): how the rig moves the batch. + pose: PartPose, } -/// A batch wholly weighted to one parentless rotation-only bone, so it spins rigidly about the -/// bone's pivot ([`benilla_formats::BoneSpin`]) with no joint children, which would lag the -/// post-propagation anchor a frame. Only `CavernsOfTimeSky.m2`'s four asteroid belts spin. -#[derive(Component)] -struct SkyboxSpin { - /// The bone's pivot, in the same (Bevy) space the batch's vertices were baked into. - pivot: Vec3, - spin: benilla_formats::BoneSpin, +/// MONKEY (skybox): how a batch follows its model's rig. +enum PartPose { + /// No moving bone under the batch. + Static, + /// Wholly weighted to one bone: the bone's matrix is the batch's transform. + Rigid(u16), + /// Spread over moving bones: skinned on the CPU into its mesh. + Skinned { + mesh: Handle, + base: Vec, + joints: Vec<[u16; 4]>, + weights: Vec<[f32; 4]>, + }, } +/// MONKEY (skybox): the batch's model-space pose this frame, composed with the eye in +/// [`follow_camera`]. +#[derive(Component, Default)] +struct SkyboxLocal(Affine3A); + /// Skybox paths built this session, failed loads included, so a failure is not retried per frame. #[derive(Resource, Default)] struct BuiltSkyboxes(HashSet); +/// MONKEY (skybox): each built model's rig, by path. +#[derive(Resource, Default)] +struct SkyRigs(HashMap>); + +/// MONKEY (skybox): the flag `0x4` fog cone: its entity and its tint row. +#[derive(Resource, Default)] +struct FogCone { + entity: Option, + slot: Option, +} + +/// MONKEY (skybox): marks the fog cone's entity. +#[derive(Component)] +struct FogConePart; + /// [`CameraSkybox`] is settled after this set; the dome's gate runs after it, so the two backdrops /// agree within a frame. #[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -89,9 +179,17 @@ impl Plugin for SkyboxPlugin { app.init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() .add_systems( Update, - (resolve_camera_skybox, build_skybox, apply_skybox_visibility) + ( + resolve_camera_skybox, + build_skybox, + animate_skyboxes, + apply_skybox_visibility, + ) .chain() // After the PVS pass, whose flood this reads the same frame. .after(crate::wmo_portal::WmoPvsSet) @@ -108,9 +206,49 @@ impl Plugin for SkyboxPlugin { } } +/// MONKEY (skybox): a path as `model_path` spells it, for comparing a MOSB name with a DBC one. +fn norm(path: &str) -> String { + let lower = path.to_ascii_lowercase(); + match lower + .strip_suffix(".mdx") + .or_else(|| lower.strip_suffix(".mdl")) + { + Some(stem) => format!("{stem}.m2"), + None => lower, + } +} + +/// MONKEY (skybox): the modern collector's step on layers (`addSkyBox`): the same model keeps the +/// larger weight and every other layer is scaled by `1 − weight`. +fn collect_layer(layers: &mut Vec, path: &str, weight: f32, flags: u32) { + let key = norm(path); + let at = match layers.iter().position(|l| norm(&l.path) == key) { + Some(i) => { + layers[i].weight = layers[i].weight.max(weight); + i + } + None => { + layers.push(SkyboxLayer { + path: path.to_owned(), + weight, + flags, + celestial: false, + }); + layers.len() - 1 + } + }; + let w = layers[at].weight; + for (i, l) in layers.iter_mut().enumerate() { + if i != at { + l.weight *= 1.0 - w; + } + } +} + /// Resolve the wanted skybox and its weight. A WMO sky needs a group of the placement's flood PVS, /// not the camera's own group, to carry [`SHOW_SKYBOX`] (`0x6b42e0`, `ebx` the group visited) and /// its root to name a MOSB; the weight rides the down-ray claim's crossfade, a separate resolver. +#[allow(clippy::too_many_arguments)] fn resolve_camera_skybox( instances: Query<&crate::wmo_portal::WmoPortalInstance>, wmos: Res>, @@ -119,71 +257,158 @@ fn resolve_camera_skybox( current_map: Option>, cam: Query<&GlobalTransform, With>, crossfade: Res, + zone: Res, + weather: Option>, + eye_liquid: crate::liquid::EyeLiquid, mut want: ResMut, mut weight: ResMut, ) { + let pos = cam.single().ok().map(|t| bevy_to_wow(t.translation())); + let map = current_map.as_ref().map_or(0, |m| m.0); + let catalog = sampler.as_ref().map(|s| &s.0); + let flags_of = |path: &str| { + catalog + .and_then(|c| c.skybox_def_by_path(&norm(path))) + .map_or(0, |d| d.flags) + }; + let mut layers: Vec = Vec::new(); + // The ghost sky first, as its slot skips the WMO one. Resolved from the atmosphere's map and // camera position, off the same ghost flag (`PLAYER_FLAGS` 0x10), so the two switch together. - if viewer.ghost { - let ghost_sky = sampler.as_ref().and_then(|s| { - let pos = bevy_to_wow(cam.single().ok()?.translation()); - let map = current_map.as_ref().map_or(0, |m| m.0); - s.0.ghost_skybox(map, pos) + let ghost_sky = viewer + .ghost + .then(|| catalog.zip(pos).and_then(|(c, p)| c.ghost_skybox(map, p))) + .flatten(); + if let Some(sky) = ghost_sky { + // The DBC slot's weight is 1.0 whenever filled (`0x6d26cb`/`0x6d26d0`): it pops in. + layers.push(SkyboxLayer { + path: sky.to_owned(), + weight: 1.0, + flags: flags_of(sky), + celestial: false, }); - if let Some(sky) = ghost_sky { - if want.0.as_deref() != Some(sky) { - want.0 = Some(sky.to_owned()); + } else { + // MONKEY (skybox): the zone list, under the WMO slot. Hidden submerged, as the celestial + // pass is; a storm lerps the storm slot's list over the clear one, as the atmosphere does. + if zone.0 && !eye_liquid.submersion().any() { + if let (Some(c), Some(p)) = (catalog, pos) { + let storm = weather + .as_ref() + .map_or(0.0, |w| crate::weather::storm_blend(w.sky_density)); + let mut acc: Vec<(u32, f32)> = c + .zone_skyboxes(map, p, false, benilla_formats::Submersion::Dry) + .into_iter() + .map(|e| (e.id, e.weight * (1.0 - storm))) + .collect(); + if storm > 0.0 { + for e in c.zone_skyboxes(map, p, true, benilla_formats::Submersion::Dry) { + match acc.iter_mut().find(|a| a.0 == e.id) { + Some(a) => a.1 += e.weight * storm, + None => acc.push((e.id, e.weight * storm)), + } + } + } + for (id, w) in acc { + let Some(def) = c.skybox_def(id) else { + continue; + }; + if w <= 0.0 { + continue; + } + match layers.iter_mut().find(|l| norm(&l.path) == def.path) { + Some(l) => l.weight = l.weight.max(w), + None => layers.push(SkyboxLayer { + path: def.path.clone(), + weight: w, + flags: def.flags, + celestial: false, + }), + } + } + } + } + // `min()`, not the first match: query order is unstable across frames, and two + // overlapping Caverns of Time shells both qualify. + let resolved = instances + .iter() + .filter_map(|inst| { + let model = wmos.get(&inst.handle)?; + // The MOSB test first: 810 of the game's 815 WMO roots name no skybox. + let sky = model.skybox.as_deref()?; + model + .group_nav + .iter() + .enumerate() + .any(|(i, nav)| { + // Fail closed, unlike the cull: a lookup miss here would paint a sky over + // the whole world. + nav.flags & SHOW_SKYBOX != 0 + && inst.visible.get(i).copied().unwrap_or(false) + }) + .then(|| sky.to_owned()) + }) + .min(); + // The WMO slot's weight is the interior crossfade `[0xce9bdc]`; a name seen through a + // doorway at weight 0 fills the slot, which `0x6d4afe` declines to draw. + if let Some(sky) = resolved { + let t = crossfade.t(); + let flags = flags_of(&sky); + if t > 0.0 { + collect_layer(&mut layers, &sky, t, flags); + } else if !layers.iter().any(|l| norm(&l.path) == norm(&sky)) { + layers.push(SkyboxLayer { + path: sky, + weight: 0.0, + flags, + celestial: false, + }); } - // The DBC slot's weight is 1.0 whenever filled (`0x6d26cb`/`0x6d26d0`): it pops in. - weight.set_if_neq(SkyboxWeight(1.0)); - return; } } - // `min()`, not the first match: query order is unstable across frames, and two overlapping - // Caverns of Time shells both qualify. - let resolved = instances + // MONKEY (skybox): each row's celestial model, a layer at its main model's weight and flags. + let celestial: Vec = layers .iter() - .filter_map(|inst| { - let model = wmos.get(&inst.handle)?; - // The MOSB test first: 810 of the game's 815 WMO roots name no skybox. - let sky = model.skybox.as_deref()?; - model - .group_nav - .iter() - .enumerate() - .any(|(i, nav)| { - // Fail closed, unlike the cull: a lookup miss here would paint a sky over the - // whole world. - nav.flags & SHOW_SKYBOX != 0 && inst.visible.get(i).copied().unwrap_or(false) - }) - .then(|| sky.to_owned()) + .filter_map(|l| { + let path = catalog? + .skybox_def_by_path(&norm(&l.path))? + .celestial + .clone()?; + Some(SkyboxLayer { + path, + weight: l.weight, + flags: l.flags, + celestial: true, + }) }) - .min(); - if want.0 != resolved { - want.0 = resolved; + .collect(); + layers.extend(celestial); + if want.0 != layers { + want.0 = layers; } - // The WMO slot's weight is the interior crossfade `[0xce9bdc]`; a name seen through a doorway - // at weight 0 fills the slot, which `0x6d4afe` declines to draw. - weight.set_if_neq(SkyboxWeight(match want.0 { - Some(_) => crossfade.t(), - None => 0.0, - })); + let stand_down = want + .0 + .iter() + .filter(|l| l.flags & SKYBOX_KEEP_CELESTIAL == 0) + .map(|l| l.weight) + .fold(0.0, f32::max); + weight.set_if_neq(SkyboxWeight(stand_down)); } -/// Build the wanted skybox on first request; the models are small and few, so it stays built. +/// Build the wanted skyboxes on first request; the models are small and few, so they stay built. +#[allow(clippy::too_many_arguments)] fn build_skybox( mut commands: Commands, want: Res, mut built: ResMut, + mut rigs: ResMut, + mut cone: ResMut, world_assets: Option>, mut meshes: ResMut>, mut images: ResMut>, + mut table: ResMut, mut mats: M2BatchMaterials, ) { - let Some(path) = want.0.as_deref() else { - return; - }; - if built.0.contains(path) { + if want.0.is_empty() { return; } // Before the shared light buffer exists: retry, without latching `built`. @@ -193,84 +418,213 @@ fn build_skybox( let Some(mut world_assets) = world_assets else { return; // assetless run: the gradient dome stays the backdrop }; - // The model's rigid spins by bone; none under a deterministic run, which keeps bind poses. - let spins = if crate::dev_state::deterministic_run() { - Default::default() - } else { - benilla_formats::load_m2_bone_spins(&mut world_assets.chain.lock_recover(), path) - .unwrap_or_default() - }; - let subs = benilla_formats::load_m2_mesh(&mut world_assets.chain.lock_recover(), path); - let subs = match subs { - Ok(subs) if !subs.is_empty() => subs, - Ok(_) => { - warn!("skybox '{path}' has no render batches — keeping the gradient dome"); - built.0.insert(path.to_string()); - return; + if cone.entity.is_none() && want.0.iter().any(|l| l.flags & SKYBOX_FOG_BLEND != 0) { + build_fog_cone(&mut commands, &mut cone, &mut meshes, &mut table, &mut mats); + } + for layer in &want.0 { + let path = layer.path.as_str(); + if built.0.contains(path) { + continue; } - Err(e) => { - warn!("skybox '{path}' failed to load, keeping the gradient dome: {e:#}"); - built.0.insert(path.to_string()); - return; - } - }; - for (i, sub) in subs.iter().enumerate() { - let mut mesh = Mesh::new( - PrimitiveTopology::TriangleList, - RenderAssetUsages::default(), - ); - let positions: Vec<[f32; 3]> = sub - .positions - .iter() - .map(|p| wow_to_bevy(*p).to_array()) - .collect(); - mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); - // Unread by the unlit sky, but the shared shader's vertex layout needs it. - mesh.insert_attribute( - Mesh::ATTRIBUTE_NORMAL, - sub.normals - .iter() - .map(|n| wow_to_bevy(*n).to_array()) - .collect::>(), + // MONKEY (skybox): the rig, off the same bytes the batches come from. + let bytes = world_assets + .chain + .lock_recover() + .read_file(&norm(path)) + .ok(); + let rig = Arc::new( + bytes + .as_deref() + .map(SkyRig::from_bytes) + .unwrap_or_default(), ); - mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, sub.uvs.clone()); - mesh.insert_indices(Indices::U32(sub.indices.clone())); - // The batch's authored address mode: Caverns of Time's belts wrap their UVs. - let texture = sub - .texture - .as_deref() - .and_then(|t| world_assets.texture(t, (sub.wrap_x, sub.wrap_y), &mut images)); - // `i + 1`, the authored batch order (0 is unordered): every batch shares one sort distance. - let Some(pair) = mats.skybox(sub, texture, u16::try_from(i + 1).unwrap_or(0)) else { - return; // light buffer vanished mid-build; `built` is unlatched, so we retry + let subs = benilla_formats::load_m2_mesh(&mut world_assets.chain.lock_recover(), path); + let subs = match subs { + Ok(subs) if !subs.is_empty() => subs, + Ok(_) => { + warn!("skybox '{path}' has no render batches — keeping the gradient dome"); + built.0.insert(path.to_string()); + continue; + } + Err(e) => { + warn!("skybox '{path}' failed to load, keeping the gradient dome: {e:#}"); + built.0.insert(path.to_string()); + continue; + } }; - let part = commands - .spawn(( - Mesh3d(meshes.add(mesh)), - MeshMaterial3d(pair.steady.clone()), - Transform::default(), - Visibility::Hidden, // `apply_skybox_visibility` turns on exactly the wanted one - // The crossfade's alpha (bits 0..=5), written only by `apply_skybox_visibility`. - MeshTag(crate::mesh_tag::spawn_tag(0, 1.0)), - SkyboxPart { - path: path.to_string(), - steady: pair.steady, - fade_blend: pair.fade_blend, + let base = if layer.celestial { 0 } else { MAIN_ORDER_BASE }; + for (i, sub) in subs.iter().enumerate() { + let mut mesh = Mesh::new( + PrimitiveTopology::TriangleList, + RenderAssetUsages::default(), + ); + let positions: Vec = sub.positions.iter().map(|p| wow_to_bevy(*p)).collect(); + mesh.insert_attribute( + Mesh::ATTRIBUTE_POSITION, + positions.iter().map(|p| p.to_array()).collect::>(), + ); + // Unread by the unlit sky, but the shared shader's vertex layout needs it. + mesh.insert_attribute( + Mesh::ATTRIBUTE_NORMAL, + sub.normals + .iter() + .map(|n| wow_to_bevy(*n).to_array()) + .collect::>(), + ); + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, sub.uvs.clone()); + // MONKEY (skybox): the constant M2Color, when the batch has one that is not white. + if sub.vertex_colors.len() == sub.positions.len() + && sub + .vertex_colors + .iter() + .any(|c| c.iter().any(|v| (v - 1.0).abs() > 1e-3)) + { + mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, sub.vertex_colors.clone()); + } + mesh.insert_indices(Indices::U32(sub.indices.clone())); + // The batch's authored address mode: Caverns of Time's belts wrap their UVs. + let texture = sub + .texture + .as_deref() + .and_then(|t| world_assets.texture(t, (sub.wrap_x, sub.wrap_y), &mut images)); + // MONKEY (skybox): the texture transform and colour loops as material identities. + let uv = sub.uv_anim.clone().map(Arc::new); + let tint = sub.rgb_anim.clone().map(Arc::new); + // The authored batch order after the layer's base (0 is unordered): every batch shares + // one sort distance. + let order = base + u16::try_from(i + 1).unwrap_or(0); + let Some(pair) = mats.skybox(sub, texture, order, uv.as_ref(), tint.as_ref()) else { + return; // light buffer vanished mid-build; `built` is unlatched, so we retry + }; + let lane = SkyMatLane::register( + sub, + uv, + tint, + &mut table, + mats.materials(), + &[pair.steady.id(), pair.fade_blend.id()], + ); + let mesh = meshes.add(mesh); + let pose = match sole_bone(sub) { + Some(b) if rig.bone_moves(b) => PartPose::Rigid(b), + Some(_) => PartPose::Static, + None if rig.animates() && !sub.joints.is_empty() => PartPose::Skinned { + mesh: mesh.clone(), + base: positions, + joints: sub.joints.clone(), + weights: sub.weights.clone(), }, - )) - .id(); - if let Some(spin) = sole_bone(sub).and_then(|b| spins.get(&b)) { - commands.entity(part).insert(SkyboxSpin { - pivot: wow_to_bevy(spin.pivot), - spin: spin.clone(), - }); + None => PartPose::Static, + }; + let skinned = matches!(pose, PartPose::Skinned { .. }); + let part = commands + .spawn(( + Mesh3d(mesh), + MeshMaterial3d(pair.steady.clone()), + Transform::default(), + Visibility::Hidden, // `apply_skybox_visibility` turns on exactly the wanted one + // The crossfade's alpha (bits 0..=5), written only by `apply_skybox_visibility`. + MeshTag(crate::mesh_tag::spawn_tag(0, 1.0)), + SkyboxLocal::default(), + SkyboxPart { + path: path.to_string(), + steady: pair.steady, + fade_blend: pair.fade_blend, + alpha: sub.alpha_anim.clone(), + anim_alpha: 1.0, + lane, + pose, + }, + )) + .id(); + if skinned { + // The mesh moves under its bounds; the shell surrounds the eye anyway. + commands.entity(part).insert(NoFrustumCulling); + } } + rigs.0.insert(path.to_string(), rig); + built.0.insert(path.to_string()); } - built.0.insert(path.to_string()); } -/// The bone every vertex of this batch is wholly weighted to, if any: the batch's half of -/// [`benilla_formats::BoneSpin`]'s rigid condition. +/// MONKEY (skybox): the flag `0x4` cone, the modern client's sky-cone draw in the final fog colour +/// (`skyMesh0x4Sky`): a band around the eye, opaque at and below the horizon, fading out by 24° +/// of elevation. White vertices carry the alpha; the fog colour rides its tint row. +fn build_fog_cone( + commands: &mut Commands, + cone: &mut FogCone, + meshes: &mut Assets, + table: &mut crate::mat_anim_table::MatAnimTable, + mats: &mut M2BatchMaterials, +) { + const SEGMENTS: usize = 32; + const RADIUS: f32 = 40.0; + // (elevation degrees, alpha), bottom to top. + const RINGS: [(f32, f32); 7] = [ + (-60.0, 1.0), + (0.0, 1.0), + (4.0, 0.85), + (8.0, 0.6), + (12.0, 0.35), + (18.0, 0.12), + (24.0, 0.0), + ]; + let mut positions = Vec::new(); + let mut colors = Vec::new(); + for (elev, alpha) in RINGS { + let (se, ce) = elev.to_radians().sin_cos(); + for s in 0..=SEGMENTS { + let az = s as f32 / SEGMENTS as f32 * std::f32::consts::TAU; + positions.push([RADIUS * ce * az.cos(), RADIUS * se, RADIUS * ce * az.sin()]); + colors.push([1.0, 1.0, 1.0, alpha]); + } + } + let row = SEGMENTS as u32 + 1; + let mut indices = Vec::new(); + for r in 0..(RINGS.len() as u32 - 1) { + for s in 0..SEGMENTS as u32 { + let a = r * row + s; + let b = a + row; + indices.extend_from_slice(&[a, b, a + 1, a + 1, b, b + 1]); + } + } + let n = positions.len(); + let mut mesh = Mesh::new( + PrimitiveTopology::TriangleList, + RenderAssetUsages::default(), + ); + mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); + mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 1.0, 0.0]; n]); + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, vec![[0.0, 0.0]; n]); + mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors); + mesh.insert_indices(Indices::U32(indices)); + let Some(material) = mats.sky_fog_cone(FOG_CONE_ORDER) else { + return; + }; + let slot = table.alloc(); + if let Some(slot) = slot { + crate::model_render::lazy::with_material_mut(mats.materials(), material.id(), |m| { + m.extension.anim_slots.y = f32::from(slot); + }); + } + let entity = commands + .spawn(( + Mesh3d(meshes.add(mesh)), + MeshMaterial3d(material), + Transform::default(), + Visibility::Hidden, + MeshTag(crate::mesh_tag::spawn_tag(0, 1.0)), + SkyboxLocal::default(), + FogConePart, + NoFrustumCulling, + )) + .id(); + cone.entity = Some(entity); + cone.slot = slot; +} + +/// The bone every vertex of this batch is wholly weighted to, if any: a rigid batch takes the +/// bone's matrix whole, with no skinning. fn sole_bone(sub: &benilla_formats::RenderSubmesh) -> Option { let bone = sub.joints.first()?[0]; (sub.weights.len() == sub.joints.len() @@ -283,22 +637,145 @@ fn sole_bone(sub: &benilla_formats::RenderSubmesh) -> Option { .then_some(bone) } -/// Show the wanted skybox at the slot weight and hide every other, the sole `Visibility`, material -/// and `MeshTag` writer for these entities. As the reference: hidden at weight 0 (`0x6d4afe`), the -/// weight in every batch's alpha (`0x710cb0` → `[CM2Model+0x180]`), and below 1 the batch -/// promoted to SRC_ALPHA blending whatever its mode (`0x811fe0`). +/// MONKEY (skybox): one model's clock and pose this frame. +struct ModelClock { + band_t: f32, + gseq: f64, + live: bool, + pose: Vec, +} + +/// MONKEY (skybox): pose every shown skybox and run its material loops on the model's clock: +/// sequence 0 at `duration × day fraction` under flag `0x1`, else the scene clock. A capture keeps +/// the bind pose and the seeds, as the shared lanes freeze, unless the day drives the clock. +#[allow(clippy::too_many_arguments)] +fn animate_skyboxes( + time: Res