From 22d75ea613a5b2dd7791ca3851263a1dba292880 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 17:50:38 +0100 Subject: [PATCH] MONKEY (skybox): second texture stage for two-texture skybox batches RenderSubmesh carries stage 1 (texture, UV set, transform, Mod/Mod2x) for textureCount 2 batches; M2Vertex reads UV1. WowModelExt gains a stage1 uniform + texture binding 94/95 and the WOW_STAGE1 shader def, so every one-texture batch compiles the old code. The skybox lane binds stage 1 on its own material copies and drives its transform rows. Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/pipe_warm/menagerie.rs | 1 + crates/benilla-assets/src/materials.rs | 18 +++++++ .../benilla-assets/src/shaders/wow_model.wgsl | 22 ++++++++ crates/benilla-assets/src/world_assets/mod.rs | 3 ++ crates/benilla-formats/src/lib.rs | 2 +- .../benilla-formats/src/models/m2_batches.rs | 49 ++++++++++++++++- crates/benilla-formats/src/models/mod.rs | 1 + crates/benilla-formats/src/models/types.rs | 21 ++++++++ crates/benilla-m2/src/lib.rs | 5 ++ crates/benilla-m2/src/model.rs | 4 +- crates/benilla-world/src/model_render.rs | 6 +++ crates/benilla-world/src/skybox.rs | 38 ++++++++++++- crates/benilla-world/src/skybox_anim.rs | 53 ++++++++++++++++++- 13 files changed, 218 insertions(+), 5 deletions(-) diff --git a/crates/benilla-app/src/pipe_warm/menagerie.rs b/crates/benilla-app/src/pipe_warm/menagerie.rs index 079ee8ed..93339a2c 100644 --- a/crates/benilla-app/src/pipe_warm/menagerie.rs +++ b/crates/benilla-app/src/pipe_warm/menagerie.rs @@ -889,6 +889,7 @@ fn warm_quad(colors: bool, skinned: bool) -> RenderSubmesh { rgb_seq: None, wmo_batch: None, section: None, + stage1: None, } } diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs index 18aa1682..ff171a45 100644 --- a/crates/benilla-assets/src/materials.rs +++ b/crates/benilla-assets/src/materials.rs @@ -71,6 +71,8 @@ pub struct WowModelKey { /// The WMO-skybox lane (`clutter_fade.z` bit 13, `model_render::SKY_DEPTH_MARKER`), a key axis /// because only this model's depth is pinned. sky_depth: bool, + /// MONKEY (skybox): a second texture stage (`stage1.x`), the `WOW_STAGE1` shader def. + stage1: bool, // The WMO batch order is deliberately not a key axis (a pipeline per batch index stalls a // city's first sight): the file-order layering (`0x6b4f10`/`0x6b5190`) rides `sun_scale.y`. } @@ -89,6 +91,7 @@ impl From<&WowModelExt> for WowModelKey { zfill: markers & 0x200 != 0, far_side: markers & 0x800 != 0, sky_depth: markers & 0x2000 != 0, + stage1: e.stage1.x > 0.5, } } } @@ -122,6 +125,14 @@ pub struct WowModelExt { /// tint, `z` = the texture-transform affine, `w` = the UI tile's cell clip. #[uniform(100)] pub anim_slots: Vec4, + /// MONKEY (skybox): a two-texture batch's stage 1: `x` = 0 none, 1 Mod, 2 Mod2x; `z`/`w` = the + /// `matanim` rows of its translation and affine, read on UV set B. `0` on every other batch. + #[uniform(100)] + pub stage1: Vec4, + /// MONKEY (skybox): stage 1's texture; `None` (the fallback image) unless `stage1.x > 0`. + #[texture(94)] + #[sampler(95)] + pub stage1_texture: Option>, /// The shared global light (`lighting::global_light`), updated in place once a frame; the /// vertex stage reads its point-light table, since Bevy's clusterable lights are fragment-only. #[storage(90, read_only, buffer, visibility(vertex, fragment))] @@ -254,6 +265,13 @@ impl MaterialExtension for WowModelExt { ds.bias.constant = 0; } } + // MONKEY (skybox): the second texture stage compiles only where a batch has one. + if key.bind_group_data.stage1 { + descriptor.vertex.shader_defs.push("WOW_STAGE1".into()); + if let Some(fragment) = descriptor.fragment.as_mut() { + fragment.shader_defs.push("WOW_STAGE1".into()); + } + } // The skybox pins clip z to 0, reverse-Z far, in the vertex stage; its bias is sort-only. if key.bind_group_data.sky_depth { descriptor.vertex.shader_defs.push("WOW_SKY_DEPTH".into()); diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl index 07705ba5..f5c8ea58 100644 --- a/crates/benilla-assets/src/shaders/wow_model.wgsl +++ b/crates/benilla-assets/src/shaders/wow_model.wgsl @@ -47,8 +47,14 @@ struct ModelParams { // Rows of `wow_light.matanim`, 0 = identity: x = UV scroll, y = tint, z = texture-transform // affine, w = the UI tile's cell clip. anim_slots: vec4, + // MONKEY (skybox): stage 1 of a two-texture batch: x = 0 none / 1 Mod / 2 Mod2x, z/w = the + // matanim rows of its translation and affine. + stage1: vec4, }; @group(#{MATERIAL_BIND_GROUP}) @binding(100) var m: ModelParams; +// MONKEY (skybox): stage 1's texture, the fallback image off `WOW_STAGE1`. +@group(#{MATERIAL_BIND_GROUP}) @binding(94) var stage1_texture: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(95) var stage1_sampler: sampler; // A fully covered character shadow retains 45% of the authored model lighting. const SHADOW_SUN_FLOOR: f32 = 0.45; @@ -1286,6 +1292,22 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut { base_color.a = base_color.a * ramp; } + // MONKEY (skybox): stage 1 multiplies in on UV set B, moved by its own texture transform + // (the stage-0 law above): colour ×1 (Mod) or ×2 (Mod2x), alpha ×1. +#ifdef WOW_STAGE1 +#ifdef VERTEX_UVS_B + { + let t1 = wow_light.matanim[u32(m.stage1.z)]; + let a1 = wow_light.matanim[u32(m.stage1.w)]; + let d1 = (in.uv_b + t1.xy - vec2(0.5, 0.5)) * vec2(1.0 + a1.z, 1.0 + a1.w); + let c1 = 1.0 + a1.x; + let uv1 = vec2(0.5 + d1.x * c1 - d1.y * a1.y, 0.5 + d1.x * a1.y + d1.y * c1); + let s1 = textureSampleBias(stage1_texture, stage1_sampler, uv1, view.mip_bias); + let k1 = select(1.0, 2.0, m.stage1.x > 1.5); + base_color = vec4(base_color.rgb * s1.rgb * k1, base_color.a * s1.a); + } +#endif +#endif // The MeshTag (mesh_tag.rs): bit 31 = highlight, bit 30 = interior fog; payload bits 0-5 = the // fade alpha (a zero payload is untagged, opaque), 19-29 = the rig slot, 6-13 = the ground // shade (0 lit, 255 MCSH-shadowed) or, on an interior-prop material, 6-18 = the SH-probe slot. diff --git a/crates/benilla-assets/src/world_assets/mod.rs b/crates/benilla-assets/src/world_assets/mod.rs index dba6e3e8..d94106c8 100644 --- a/crates/benilla-assets/src/world_assets/mod.rs +++ b/crates/benilla-assets/src/world_assets/mod.rs @@ -577,6 +577,9 @@ impl WorldAssets { tint: Vec4::ONE, // clutter has no animated M2Color tint and is not a WMO batch sidn: Vec4::ZERO, // clutter is never SIDN/WINDOW glass (WMO-only) anim_slots: Vec4::ZERO, + // MONKEY (skybox): no second stage. + stage1: Vec4::ZERO, + stage1_texture: None, light_buf: self.shared_light.clone(), // MONKEY (torch shadows Phase 3A): the shared torch receiver bindings. torch_depth: self.torch.depth.clone(), diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 83813eda..141c3183 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -275,7 +275,7 @@ pub use models::{ CoverageReader, EmitterBoneLink, EventMarker, FogPolicy, FootprintTris, GlobalSeqBone, GlobalSeqChannel, GroundQuad, KeyAnim, M2AnimSummary, M2Attachment, M2Bounds, M2CameraTracks, M2Light, M2PaneCamera, M2PortraitCamera, ModelAnimation, ModelBlend, ParentArm, ParentBasis, - PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim, SeqLoops, ShippedGlueScene, Skeleton, + PlayableAnim, RenderSubmesh, RgbAnim, StageTwo, ScalarAnim, SeqLoops, ShippedGlueScene, Skeleton, SkeletonBone, StringAnchors, UvAnim, UvRotAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog, WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot, ALPHA_KEY_REF, DEGENERATE_RING_FOOTPRINT, GLUE_AUTHORED_ASPECT, NO_GROUP_LIQUID, diff --git a/crates/benilla-formats/src/models/m2_batches.rs b/crates/benilla-formats/src/models/m2_batches.rs index 40043970..6cb13f03 100644 --- a/crates/benilla-formats/src/models/m2_batches.rs +++ b/crates/benilla-formats/src/models/m2_batches.rs @@ -17,7 +17,7 @@ use super::tex_anim; use super::{le_u16, le_u32, model_path, remap_submesh}; use super::{ AlphaAnim, Billboard, BillboardKind, BoneScaleAnim, CharSkinSlot, FogPolicy, ModelBlend, - RenderSubmesh, + RenderSubmesh, StageTwo, }; fn parent_dir(path: &str) -> &str { @@ -479,6 +479,33 @@ pub fn parse_m2_render_submeshes( let rgb_seq = rgb_track .and_then(|t| mat_anim::bake_rgb_seqs(t, &model.global_sequences, &seq_slots)) .filter(|set| set.uniform().is_none()); + // MONKEY (skybox): stage 1 of a two-texture batch; an env-mapped stage 1 is left out. + let stage1 = (batch.texture_count >= 2 && !model.stage_is_env_mapped(batch, 1)) + .then(|| { + let rec = model + .raw_data + .texture_lookup_table + .get(batch.texture_combo_index as usize + 1) + .and_then(|&ti| model.textures.get(ti as usize))?; + let set = model + .texture_unit_lookup + .get(batch.texture_coord_combo_index as usize + 1) + .copied() + .unwrap_or(0); + let combo = batch.texture_transform_combo_index.wrapping_add(1); + Some(( + resolve_texture(rec, dir, skins).0, + (rec.wrap_x, rec.wrap_y), + set == 1, + batch.shader_id & 0xf == 4, + tex_anim::bake_uv_anim(model, combo, seq0_slot), + tex_anim::bake_uv_rot_seqs(model, combo, &seq_slots) + .and_then(|s| s.seq(None).cloned()), + tex_anim::bake_uv_scale_seqs(model, combo, &seq_slots) + .and_then(|s| s.seq(None).cloned()), + )) + }) + .flatten(); // The reference turns each billboard bone to the camera about its own pivot, and a batch // can hold cards on several bones (a candelabra's glows): one submesh per billboard bone. let make_billboard = |bone_idx: usize| -> Option { @@ -591,6 +618,26 @@ pub fn parse_m2_render_submeshes( sub.uv_scale_seq = uv_scale_seq.clone(); sub.rgb_anim = rgb_anim.clone(); sub.rgb_seq = rgb_seq.clone(); + // MONKEY (skybox): stage 1's UVs follow the submesh's own vertex order. + sub.stage1 = stage1.as_ref().map( + |(texture, (wrap_x, wrap_y), uv1, mod2x, uv_anim, uv_rot, uv_scale)| StageTwo { + texture: texture.clone(), + wrap_x: *wrap_x, + wrap_y: *wrap_y, + uvs: globals + .iter() + .map(|&g| { + let v = &model.vertices[g as usize]; + let c = if *uv1 { &v.tex_coords2 } else { &v.tex_coords }; + [c.x, c.y] + }) + .collect(), + mod2x: *mod2x, + uv_anim: uv_anim.clone(), + uv_rot: uv_rot.clone(), + uv_scale: uv_scale.clone(), + }, + ); out.push(sub); } } diff --git a/crates/benilla-formats/src/models/mod.rs b/crates/benilla-formats/src/models/mod.rs index 636b1b3c..ea0c07bf 100644 --- a/crates/benilla-formats/src/models/mod.rs +++ b/crates/benilla-formats/src/models/mod.rs @@ -100,6 +100,7 @@ fn remap_submesh( wmo_batch: None, env_map: false, section: None, + stage1: None, }, globals, ) diff --git a/crates/benilla-formats/src/models/types.rs b/crates/benilla-formats/src/models/types.rs index 154eb3a3..47c31cda 100644 --- a/crates/benilla-formats/src/models/types.rs +++ b/crates/benilla-formats/src/models/types.rs @@ -262,6 +262,24 @@ pub enum FogPolicy { Off = 4, } +/// MONKEY (skybox): a two-texture batch's second stage (`textureCount 2`, which no stock 1.12 +/// model authors): texture `textureLookup[combo + 1]` on the UV set `texUnitLookup[coord + 1]`, +/// moved by `texAnimLookup[transform + 1]`, multiplied into stage 0 (`Mod`, or `Mod2x` on the +/// modern `0x4014` combiner). +#[derive(Debug, Clone, Default)] +pub struct StageTwo { + pub texture: Option, + pub wrap_x: bool, + pub wrap_y: bool, + /// Per vertex, parallel to [`RenderSubmesh::positions`], from the stage's own UV set. + pub uvs: Vec<[f32; 2]>, + /// The stage's colour doubles (`Mod2x`). + pub mod2x: bool, + pub uv_anim: Option, + pub uv_rot: Option>, + pub uv_scale: Option>, +} + /// One render batch: self-contained geometry and its material. #[derive(Debug, Clone)] pub struct RenderSubmesh { @@ -346,6 +364,8 @@ pub struct RenderSubmesh { /// over its base); the reference draws both from one vertex array under LEQUAL, the later /// winning exactly (`0x70c190`), so consolidators refuse a section they cannot take whole. pub section: Option, + /// MONKEY (skybox): the second texture stage, `None` on every one-texture batch. + pub stage1: Option, /// Texture coordinates generated as a view-space sphere map, not read from [`Self::uvs`] /// (`texture_unit_lookup[texCoordSet] > 2`): `uv = normalize(P − 2(P·N)N).xy · 0.5 + 0.5`. /// Such a mesh parks its UVs at one point (`GnomeSubwayGlass.m2`). @@ -416,6 +436,7 @@ impl Default for RenderSubmesh { wmo_batch: None, env_map: false, section: None, + stage1: None, } } } diff --git a/crates/benilla-m2/src/lib.rs b/crates/benilla-m2/src/lib.rs index bc0214c2..010bb5d7 100644 --- a/crates/benilla-m2/src/lib.rs +++ b/crates/benilla-m2/src/lib.rs @@ -173,6 +173,11 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result { x: v.f32_at(32).ok_or(Error::Truncated)?, y: v.f32_at(36).ok_or(Error::Truncated)?, }, + // MONKEY (skybox): the second UV set. + tex_coords2: C2 { + x: v.f32_at(40).ok_or(Error::Truncated)?, + y: v.f32_at(44).ok_or(Error::Truncated)?, + }, }); } diff --git a/crates/benilla-m2/src/model.rs b/crates/benilla-m2/src/model.rs index 6bc8a864..14f37477 100644 --- a/crates/benilla-m2/src/model.rs +++ b/crates/benilla-m2/src/model.rs @@ -106,7 +106,7 @@ impl M2Bone { } /// One M2 vertex, 48 bytes: position @0, weights @0x0c, indices @0x10, normal @0x14, UV @0x20, -/// and an unread second UV @0x28. +/// and a second UV @0x28. pub struct M2Vertex { pub position: C3, /// Per-vertex bone influence weights (`/255`), paired with [`Self::bone_indices`]. @@ -114,6 +114,8 @@ pub struct M2Vertex { pub bone_indices: [u8; 4], pub normal: C3, pub tex_coords: C2, + /// MONKEY (skybox): the second UV set, which a two-texture batch's stage 1 can read. + pub tex_coords2: C2, } /// The texture lookup table and the collision hull's raw bytes. diff --git a/crates/benilla-world/src/model_render.rs b/crates/benilla-world/src/model_render.rs index 7dc4bcfc..42b3951f 100644 --- a/crates/benilla-world/src/model_render.rs +++ b/crates/benilla-world/src/model_render.rs @@ -323,6 +323,9 @@ pub fn model_material( }, // Zero until a sampler registers this material and bakes its table slot in once. anim_slots: Vec4::ZERO, + // MONKEY (skybox): no second stage. + stage1: Vec4::ZERO, + stage1_texture: None, light_buf: light.clone(), // MONKEY (torch shadows Phase 3A): the shared torch receiver bindings. torch_depth: torch.depth.clone(), @@ -446,6 +449,9 @@ pub fn zfill_material( tint: Vec4::new(1.0, 1.0, 1.0, 0.0), sidn: Vec4::ZERO, anim_slots: Vec4::ZERO, + // MONKEY (skybox): no second stage. + stage1: Vec4::ZERO, + stage1_texture: None, light_buf: light.clone(), // MONKEY (torch shadows Phase 3A): the shared torch receiver bindings. torch_depth: torch.depth.clone(), diff --git a/crates/benilla-world/src/skybox.rs b/crates/benilla-world/src/skybox.rs index 8ff00863..bd750db4 100644 --- a/crates/benilla-world/src/skybox.rs +++ b/crates/benilla-world/src/skybox.rs @@ -481,6 +481,10 @@ fn build_skybox( { mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, sub.vertex_colors.clone()); } + // MONKEY (skybox): stage 1 reads UV set B. + if let Some(st) = sub.stage1.as_ref().filter(|s| s.uvs.len() == sub.positions.len()) { + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, st.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 @@ -493,9 +497,41 @@ fn build_skybox( // 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 { + let Some(mut 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 }; + // MONKEY (skybox): a two-texture batch takes its own copies with stage 1 bound, so + // the deduped one-texture materials stay as they were. + if let Some(st) = sub.stage1.as_ref().filter(|s| s.uvs.len() == sub.positions.len()) { + let tex1 = st + .texture + .as_deref() + .and_then(|t| world_assets.texture(t, (st.wrap_x, st.wrap_y), &mut images)); + let mode = if st.mod2x { 2.0 } else { 1.0 }; + let mut with_stage1 = |h: &Handle| { + let mut m = crate::model_render::lazy::with_material_mut( + mats.materials(), + h.id(), + |m| m.clone(), + )?; + m.extension.stage1 = Vec4::new(mode, 0.0, 0.0, 0.0); + m.extension.stage1_texture = tex1.clone(); + Some(mats.materials().add(m)) + }; + let shared = pair.fade_blend == pair.steady; + if let Some(steady) = with_stage1(&pair.steady) { + let fade = if shared { + Some(steady.clone()) + } else { + with_stage1(&pair.fade_blend) + }; + if let Some(fade) = fade { + pair.steady = steady; + pair.fade_blend = fade; + } + } + } let lane = SkyMatLane::register( sub, uv, diff --git a/crates/benilla-world/src/skybox_anim.rs b/crates/benilla-world/src/skybox_anim.rs index 0ea157b7..652fa163 100644 --- a/crates/benilla-world/src/skybox_anim.rs +++ b/crates/benilla-world/src/skybox_anim.rs @@ -313,6 +313,9 @@ pub(crate) struct SkyMatLane { pub(crate) uv: Option<(u16, [f32; 2], Arc)>, pub(crate) affine: Option<(u16, AffineLoops)>, pub(crate) tint: Option<(u16, [f32; 3], Arc)>, + /// Stage 1's translation row (absolute, no seed) and affine row. + pub(crate) stage1_uv: Option<(u16, benilla_formats::UvAnim)>, + pub(crate) stage1_affine: Option<(u16, AffineLoops)>, } /// The rotation and scaling channels of a texture transform, slot 0's loops. @@ -383,11 +386,36 @@ impl SkyMatLane { lane.tint = Some((slot, seed, tint)); } } + // Stage 1: rows only where it moves; row 0 is the identity. + if let Some(st) = &sub.stage1 { + if let Some(uv) = st.uv_anim.clone() { + if let Some(slot) = table.alloc() { + write(materials, &|m| m.extension.stage1.z = f32::from(slot)); + lane.stage1_uv = Some((slot, uv)); + } + } + if st.uv_rot.is_some() || st.uv_scale.is_some() { + if let Some(slot) = table.alloc() { + write(materials, &|m| m.extension.stage1.w = f32::from(slot)); + lane.stage1_affine = Some(( + slot, + AffineLoops { + rot: st.uv_rot.clone(), + scale: st.uv_scale.clone(), + }, + )); + } + } + } lane } pub(crate) fn any(&self) -> bool { - self.uv.is_some() || self.affine.is_some() || self.tint.is_some() + self.uv.is_some() + || self.affine.is_some() + || self.tint.is_some() + || self.stage1_uv.is_some() + || self.stage1_affine.is_some() } /// Write this frame's rows: the delta from each seed, quantized as the shared lane does. @@ -420,6 +448,29 @@ impl SkyMatLane { .map_or([1.0, 1.0], |l| l.sample(l.clock(band_t, gseq_now))); table.set(*slot, crate::mat_anim_table::affine_row(q, s)); } + if let Some((slot, a)) = &self.stage1_uv { + let v = a.sample(a.clock(band_t, gseq_now)); + table.set( + *slot, + [ + benilla_assets::quantize(v[0], 4096.0), + benilla_assets::quantize(v[1], 4096.0), + 0.0, + 0.0, + ], + ); + } + if let Some((slot, loops)) = &self.stage1_affine { + let q = loops + .rot + .as_ref() + .map_or([0.0, 0.0, 0.0, 1.0], |l| l.sample(l.clock(band_t, gseq_now))); + let s = loops + .scale + .as_ref() + .map_or([1.0, 1.0], |l| l.sample(l.clock(band_t, gseq_now))); + table.set(*slot, crate::mat_anim_table::affine_row(q, s)); + } if let Some((slot, seed, a)) = &self.tint { let c = benilla_assets::quant255(a.sample(a.clock(band_t, gseq_now))); table.set(