diff --git a/LIGHTING.md b/LIGHTING.md index fef07b37..18a1a6c4 100644 --- a/LIGHTING.md +++ b/LIGHTING.md @@ -38,6 +38,11 @@ behind each constant. | Zone skyboxes | the painted sky a zone's `LightParams` names (clear-weather slot, storm slot lerped by the storm blend) for the living, fading in by the Light sphere falloff with the modern client's crossfade (ghost > WMO > zone); extended `LightSkybox.dbc` flags `0x1` day cycle, `0x2` keep sun/moon/stars/clouds, `0x4` fog-colour horizon cone, plus a celestial second model; every skybox now plays its bones, texture transforms, colour and alpha tracks (`benilla-world/src/skybox.rs`, `skybox_anim.rs`, `benilla-formats/src/light/skybox.rs`) | Advanced Graphics → Zone Skyboxes, cvar `zoneSkyboxes` (0 Off default, 1 On; High = 1), env `WOW_ZONE_SKYBOXES=0\|1` | | Night and interior level | global dimming of the night sky term and of interior ambient | `nightGain`, `interiorGain`, `interiorBakeFloor` | +`zoneSkyboxes = 0` disables only the added living-player zone slot. WMO and ghost skyboxes are +the reference 1.12 slots, so their fidelity fixes remain active in Classic: authored colour and +alpha tracks affect the layer, non-white M2 colours reach the mesh, deterministic captures pose at +their pinned time, and the batch-order base reserves the lower celestial band and final fog cone. + Players reach all of it from **Options -> Advanced Graphics** (a Graphics Preset over everything, a Render Distance slider, a Lighting Quality preset Off / Low / Medium / High / Ultra plus the individual rows; Classic / Off is the original client look). The dev build has a panel for all of it: **Ctrl+Shift+D → Lighting & shadows**, with Dim / Default / Bright presets. diff --git a/crates/benilla-assets/src/shaders/enhanced_water.wgsl b/crates/benilla-assets/src/shaders/enhanced_water.wgsl index faf60bd9..a8906e63 100644 --- a/crates/benilla-assets/src/shaders/enhanced_water.wgsl +++ b/crates/benilla-assets/src/shaders/enhanced_water.wgsl @@ -91,6 +91,8 @@ fn water_rain_open(world_pos: vec3) -> f32 { struct WaterFragment { clip_position: vec4, world_position: vec4, + // MONKEY (reviewfix): world XZ before `water_swell`, so fragment waves share its source domain. + source_xz: vec2, // The authored per-vertex swatch depth (UV1.x): ocean byte/255, about 148 yd at 1.0. depth: f32, // MONKEY (water): an interior WMO pool's authored MOMT diffuse colour; white on other lanes. @@ -132,9 +134,25 @@ fn water_time() -> f32 { // Vertex Gerstner displacement for the ocean's long swell (zero everywhere else). // `liquid/waves.rs` mirrors it on the CPU for the swimmer bob; the two are one contract. -fn water_swell(xz: vec2, authored_depth: f32) -> vec3 { +fn water_swell(xz: vec2, authored_depth: f32, stitch: f32) -> vec3 { if water.lane.z < 0.5 && water.lane.y > 0.5 && water.lane.x < 0.5 && water.mode.x > 0.5 { - return water_gerstner(xz, water_time(), swell_shore_fade(authored_depth)).xyz; + let now = water_time(); + let shore = swell_shore_fade(authored_depth); + // MONKEY (reviewfix): a tagged fine boundary vertex follows the straight edge between the + // two displaced coarse endpoints. That closes the T-junction against a coarse neighbour. + if stitch >= 1.0 { + let lane = u32(floor(stitch)); + let along = fract(stitch); + var direction = vec2(1.0, 0.0); + if lane == 2u { direction = vec2(-1.0, 0.0); } + if lane == 3u { direction = vec2(0.0, 1.0); } + if lane == 4u { direction = vec2(0.0, -1.0); } + let lo = xz - direction * along * 4.1666665; + let hi = lo + direction * 4.1666665; + return mix(water_gerstner(lo, now, shore).xyz, + water_gerstner(hi, now, shore).xyz, along); + } + return water_gerstner(xz, now, shore).xyz; } return vec3(0.0); } @@ -539,7 +557,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec } let t = water_time(); // WOW_CAPTURE_WATER_T pins the frozen water phase. let fog = water_fog(in.world_position.xyz, in.room_fog); - let p = in.world_position.xz; + let p = in.source_xz; let energy = clamp(water.mode.y, 0.0, 1.0); let ocean_mesh = water.lane.y > 0.5 && water.lane.x < 0.5; diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl index c414754b..93f32ba6 100644 --- a/crates/benilla-assets/src/shaders/liquid.wgsl +++ b/crates/benilla-assets/src/shaders/liquid.wgsl @@ -85,6 +85,8 @@ struct LiquidVsOut { @location(5) vcolor: vec4, // `MeshTag` bit 30: the room's per-frame interior-fog gate, the reference's `[0xca7f00]`. @location(6) @interpolate(flat) room_fog: u32, + // MONKEY (reviewfix): the analytic water domain before the ocean's vertex displacement. + @location(7) source_xz: vec2, } // Sun sheen (`secondary`): the Blinn highlight `light_spec.rgb · (N·H)^shininess`. @@ -147,8 +149,9 @@ fn vertex(in: Vertex) -> LiquidVsOut { let world_from_local = mesh_functions::get_world_from_local(in.instance_index); out.world_position = mesh_functions::mesh_position_local_to_world(world_from_local, vec4(in.position, 1.0)); + out.source_xz = out.world_position.xz; // MONKEY (enhanced water): ocean Gerstner displacement; zero on Classic/other surfaces. - out.world_position += vec4(water_swell(out.world_position.xz, in.uv_b.x), 0.0); + out.world_position += vec4(water_swell(out.source_xz, in.uv_b.x, in.uv_b.y), 0.0); out.clip_position = position_world_to_clip(out.world_position.xyz); out.world_normal = mesh_functions::mesh_normal_local_to_world(in.normal, in.instance_index); out.uv = in.uv; @@ -221,7 +224,8 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 { return enhanced_water( // MONKEY (water): the enhanced interior arm consumes the MOMT colour already carried // by the reference fragment interface; exterior/ADT vertices supply white. - WaterFragment(in.clip_position, in.world_position, in.depth, in.vcolor, in.room_fog), + WaterFragment(in.clip_position, in.world_position, in.source_xz, in.depth, in.vcolor, + in.room_fog), shallow_enhanced, deep_enhanced); } diff --git a/crates/benilla-formats/src/light.rs b/crates/benilla-formats/src/light.rs index 6ffd1159..c9a016f5 100644 --- a/crates/benilla-formats/src/light.rs +++ b/crates/benilla-formats/src/light.rs @@ -181,6 +181,9 @@ pub struct LightCatalog { /// other rows are MOSB skyboxes. MONKEY (skybox): flags and the celestial model ride along /// ([`SkyboxDef`]), read from either table layout. skyboxes: HashMap, + /// MONKEY (reviewfix): normalised model path в†’ lowest matching `LightSkybox` id. Some patched + /// tables alias a model with different flags; path lookup must not depend on `HashMap` order. + skybox_by_path: HashMap, } fn light_schema() -> Schema { @@ -328,6 +331,7 @@ impl LightCatalog { }; // MONKEY (skybox): both layouts, keyed by the header's field count. let skyboxes = skybox::load_skyboxes(chain, LIGHT_SKYBOX)?; + let skybox_by_path = skybox::index_paths(&skyboxes); Ok(LightCatalog { lights, int_bands, @@ -337,6 +341,7 @@ impl LightCatalog { light_params_water_alpha, light_params_skybox, skyboxes, + skybox_by_path, }) } diff --git a/crates/benilla-formats/src/light/skybox.rs b/crates/benilla-formats/src/light/skybox.rs index 70322687..ab6c319a 100644 --- a/crates/benilla-formats/src/light/skybox.rs +++ b/crates/benilla-formats/src/light/skybox.rs @@ -94,6 +94,19 @@ pub(super) fn load_skyboxes(chain: &mut Chain, file: &str) -> Result) -> HashMap { + let mut by_path = HashMap::with_capacity(skyboxes.len()); + for (&id, def) in skyboxes { + by_path + .entry(def.path.to_ascii_lowercase()) + .and_modify(|old: &mut u32| *old = (*old).min(id)) + .or_insert(id); + } + by_path +} + /// The modern collector's step: dedupe by model with the larger alpha, then scale every other /// entry by `1 − alpha` (`addSkyBox` steps 1 and 4). pub(super) fn collect(list: &mut Vec, id: u32, alpha: f32) { @@ -126,9 +139,10 @@ impl LightCatalog { /// The row whose model is `path`, for a MOSB skybox the table also names. pub fn skybox_def_by_path(&self, path: &str) -> Option<&SkyboxDef> { - self.skyboxes - .values() - .find(|d| d.path.eq_ignore_ascii_case(path)) + let path = crate::models::model_path(path).to_ascii_lowercase(); + self.skybox_by_path + .get(&path) + .and_then(|id| self.skyboxes.get(id)) } /// The skybox the slot's param names; a weather slot without one falls back to the clear @@ -234,6 +248,23 @@ mod tests { assert_eq!(l, vec![ZoneSkybox { id: 1, weight: 1.0 }]); } + #[test] + fn duplicate_paths_resolve_to_the_lowest_id() { + let def = |path: &str| SkyboxDef { + path: path.into(), + flags: 0, + celestial: None, + }; + let skyboxes = HashMap::from([ + (17, def(r"environments\stars\shared.m2")), + (3, def(r"ENVIRONMENTS\STARS\SHARED.M2")), + (9, def(r"environments\stars\other.m2")), + ]); + let paths = index_paths(&skyboxes); + assert_eq!(paths.get(r"environments\stars\shared.m2"), Some(&3)); + assert_eq!(paths.get(r"environments\stars\other.m2"), Some(&9)); + } + /// The installed chain loads in either layout. #[test] fn the_installed_table_loads() { diff --git a/crates/benilla-formats/src/models/m2_batches.rs b/crates/benilla-formats/src/models/m2_batches.rs index 6cb13f03..eca42d5f 100644 --- a/crates/benilla-formats/src/models/m2_batches.rs +++ b/crates/benilla-formats/src/models/m2_batches.rs @@ -492,16 +492,20 @@ pub fn parse_m2_render_submeshes( .get(batch.texture_coord_combo_index as usize + 1) .copied() .unwrap_or(0); - let combo = batch.texture_transform_combo_index.wrapping_add(1); + // MONKEY (reviewfix): 0xffff is the no-transform sentinel. Advancing stage 1 must + // keep it absent rather than wrap it onto stage 0's transform at index zero. + let combo = batch.texture_transform_combo_index.checked_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) + combo.and_then(|combo| tex_anim::bake_uv_anim(model, combo, seq0_slot)), + combo + .and_then(|combo| 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) + combo + .and_then(|combo| tex_anim::bake_uv_scale_seqs(model, combo, &seq_slots)) .and_then(|s| s.seq(None).cloned()), )) }) diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs index c67291c7..258fe899 100644 --- a/crates/benilla-world/src/lighting/global_light.rs +++ b/crates/benilla-world/src/lighting/global_light.rs @@ -67,7 +67,7 @@ struct LightStd430 { rows: [[f32; 4]; LIGHT_HEADER_ROWS], points: [[f32; 4]; 2 * MAX_POINT_LIGHTS], // MONKEY (p0 MonkeyFrame): the programme block, appended AFTER the point table so no earlier - // offset moves (8528 -> 8784 B); packed by [`pack_monkey_frame`] from [`super::MonkeyFrame`]. + // offset moves (the per-frame prefix is now 8784 B); packed by [`pack_monkey_frame`]. monkey: [[f32; 4]; super::monkey_frame::MONKEY_FRAME_ROWS], } @@ -123,8 +123,8 @@ pub fn commit_raw(rgb: [f32; 3]) -> [f32; 3] { /// interior set; [`build_light_data`] packs the nearest-to-camera first when over capacity. /// /// This is the BUFFER shape (`LightStd430` and [`RoomClaimTable`] size against it) and it must not -/// move: the blob is 8528 B and is mirrored by three shaders plus the portrait booth. How many of -/// those slots may actually hold a light is [`MAX_LIVE_POINT_LIGHTS`], which is one less. +/// move: the per-frame blob is 8784 B and is mirrored by the shaders plus the portrait booth. How +/// many of those slots may actually hold a light is [`MAX_LIVE_POINT_LIGHTS`], which is one less. pub(super) const MAX_POINT_LIGHTS: usize = 256; /// MONKEY (ext light k8): how many of [`MAX_POINT_LIGHTS`] slots may hold a LIVE entry — **255**, @@ -140,7 +140,7 @@ pub(super) const MAX_POINT_LIGHTS: usize = 256; /// /// So the pack truncates one entry earlier. The lights are sorted nearest-camera-first before the /// truncation, so the one this drops is the farthest of a 256-strong set — sub-pixel and usually -/// fogged at that density. The BUFFER keeps all 256 slots, so nothing about the layout, the 8528 B +/// fogged at that density. The BUFFER keeps all 256 slots, so nothing about the layout, the 8784 B /// blob or the room-claim table changes. pub(super) const MAX_LIVE_POINT_LIGHTS: usize = MAX_POINT_LIGHTS - 1; @@ -478,7 +478,7 @@ impl Default for FireLightGain { } /// MONKEY (post): live `bloom` tier. The packer adds it to `light_diffuse.w`, whose old value 1 -/// was an unread clamp marker, so the 8528-byte shared buffer and every existing decode stay put. +/// was an unread clamp marker, so the 8784-byte per-frame prefix and every existing decode stay put. #[derive(Resource, Clone, Copy, Default, PartialEq, Eq, Debug)] pub struct EmissiveTier(pub u8); @@ -897,7 +897,7 @@ pub(super) struct ShadowLanes<'w> { /// MONKEY (moon shadows): the CPU half of the `fog_params.z` pack — ONE SIGNED lane carrying both /// directional-shadow weights. /// -/// **There is still no free f32** ([`LightStd430`] is 8528 B, pinned by tests and mirrored by three +/// **There is still no free f32** ([`LightStd430`] is 8784 B, pinned by tests and mirrored by three /// shaders plus the portrait booth — see [`DAYLIGHT_LANE_SCALE`] for the full accounting). The /// daylight floor took `wmo_fog_params.w`'s fraction and the bake floor took `sh_c16.w`'s, so the /// two lanes with spare RANGE are spent. This one needs neither: the hand-over law above guarantees @@ -931,7 +931,7 @@ fn unpack_shadow_lane(w: f32) -> (f32, f32) { /// MONKEY (enclosed day floor): how [`DynamicInteriors::daylight`] rides to the shader — as the /// FRACTIONAL part of the packed `wmo_fog_params.w` lane, `w = 1 + debug + daylight * this`. /// -/// **There was no free f32 left.** [`LightStd430`] is 8528 B, mirrored by three shaders plus the +/// **There was no free f32 left.** [`LightStd430`] is 8784 B, mirrored by three shaders plus the /// portrait booth, and must not grow; every `.w` in rows 0..=20 is spoken for (the Mod2x/clamp/SH /// enables, terrain shininess, fog enable, farclip, the ambient DC lanes, `sh_c13_*.w` which the /// SH `dot(row, quad)` reads as a real band, the world-shadow flag, the shadow distance, the @@ -963,7 +963,7 @@ pub const DAYLIGHT_LANE_SCALE: f32 = 0.49; /// MONKEY (bake floor): how [`DynamicInteriors::bake_floor`] rides to the shader — as the /// FRACTIONAL part of the packed `sh_c16.w` lane, `w = world_shadow_flag + bake × this`. /// -/// **There is still no free f32.** [`LightStd430`] is 8528 B and mirrored by three shaders plus the +/// **There is still no free f32.** [`LightStd430`] is 8784 B and mirrored by three shaders plus the /// portrait booth (see [`DAYLIGHT_LANE_SCALE`] for the full accounting of why it must not grow). /// `wmo_fog_params.w` — the lane the daylight floor rides — is now spent: its integer part is /// `1 + interiorDebug` and its fraction is `interiorDaylight × 0.49`, and two fractions cannot @@ -1360,7 +1360,7 @@ fn build_light_data( // MONKEY (darkness gains): `nightGain` — one live dim over everything the EXTERIOR law lights // (terrain, models, WMO exteriors, and the ext-class night blend, all of which derive from the // ambient/diffuse/specular rows below). Folded CPU-side into those packed rows rather than - // added as a shader uniform because `LightStd430` has no free lane left (8528 B, mirrored by + // added as a shader uniform because `LightStd430` has no free lane left (8784 B, mirrored by // three WGSL structs) — and a pack-time fold costs the GPU exactly nothing anyway. // // The ramp is the DUSK CLOCK every other night feature already fades on: `sun_w` is @@ -1437,7 +1437,7 @@ fn build_light_data( // the dim and the shadow fade can never drift onto two different dusk clocks. // MONKEY (moon shadows): pack the rig's ACKNOWLEDGED weight, not the clock's desired one. // The body may have changed abruptly this frame; the shared state holds zero until the new - // basis has propagated and then ramps. One signed float still keeps the buffer at 8528 bytes. + // basis has propagated and then ramps. One signed float still keeps the prefix at 8784 bytes. rows[5][2] = shadow.handover.weight; // MONKEY (distance slider): the realtime-shadow render distance (yd), packed into the free // `_wmo_fog[1].z` / `wmo_fog_params.z` lane (row 19). The receivers' edge fade reads it so the @@ -2717,14 +2717,48 @@ mod tests { assert_eq!(MAX_LIVE_POINT_LIGHTS, 255, "255 is EXT_SEL_EMPTY in the three shaders"); assert_eq!(MAX_LIVE_POINT_LIGHTS, MAX_POINT_LIGHTS - 1); // 21 header rows + 2 x 256 point rows, 16 B each. - // MONKEY (p0 MonkeyFrame): 8528 + the 256-byte programme block. + // MONKEY (p0 MonkeyFrame): the fixed rows plus the 256-byte programme block. assert_eq!(per_frame_blob_bytes(), 8784, "the mirrored blob must not change size"); } + /// MONKEY (reviewfix): the shader mirrors use literal storage-array lengths and water reads + /// the shared buffer as raw rows, so pin those literals to the Rust layout constants. + #[test] + fn programme_and_shelter_shader_literals_match_the_rust_layout() { + const WATER: &str = include_str!("../../../benilla-assets/src/shaders/enhanced_water.wgsl"); + const TERRAIN: &str = include_str!("../../../benilla-assets/src/shaders/terrain.wgsl"); + const MODEL: &str = include_str!("../../../benilla-assets/src/shaders/wow_model.wgsl"); + const STATIC_GX: &str = include_str!("../shaders/static_gx.wgsl"); + + let monkey_row = LIGHT_HEADER_ROWS + 2 * MAX_POINT_LIGHTS; + let shelter_row = monkey_row + super::super::monkey_frame::MONKEY_FRAME_ROWS; + assert!( + WATER.contains(&format!("const MONKEY_ROW: u32 = {monkey_row}u;")), + "enhanced_water.wgsl MONKEY_ROW must follow the header and point table" + ); + assert!( + WATER.contains(&format!("const SHELTER_ROW: u32 = {shelter_row}u;")), + "enhanced_water.wgsl SHELTER_ROW must follow MonkeyFrame" + ); + + let shelter_cells = crate::weather::shelter::GRID * crate::weather::shelter::GRID; + let declaration = format!("array"); + for (name, shader) in [ + ("terrain.wgsl", TERRAIN), + ("wow_model.wgsl", MODEL), + ("static_gx.wgsl", STATIC_GX), + ] { + assert!( + shader.contains(&declaration), + "{name} shelter length must equal shelter::GRID squared" + ); + } + } + /// MONKEY (enclosed day floor): `interiorDaylight` rides the FRACTION of the interior lane's /// on/off word, and every existing decode of that word must be blind to it. /// - /// This is the test the feature stands on: there was no free `f32` left in an 8528-byte layout + /// This is the test the feature stands on: there was no free `f32` left in the 8784-byte layout /// three shaders mirror, so the value shares a lane with two other facts. If the fraction ever /// grew past 0.5 — a wider `DAYLIGHT_LANE_SCALE`, a `daylight` that escaped its clamp — the /// debug decode `u32(max(w - 1, 0) + 0.5)` would round UP and every building in the frame would diff --git a/crates/benilla-world/src/liquid/lod.rs b/crates/benilla-world/src/liquid/lod.rs index 6ac8ad6d..e7c5e817 100644 --- a/crates/benilla-world/src/liquid/lod.rs +++ b/crates/benilla-world/src/liquid/lod.rs @@ -2,8 +2,10 @@ //! //! Wgpu has no tessellation stage. The coarse grid therefore remains the streamed source of //! truth, while a 4x bilinear copy is built only when the camera enters the surface's near ring. -//! Every original edge vertex survives bit-for-bit, so a fine chunk and its coarse neighbour share -//! the same boundary. Hysteresis avoids rebuilding while the camera hovers at the threshold. +//! Every original edge vertex survives bit-for-bit. Non-corner fine vertices on an outer edge carry +//! a compact stitch tag in UV1.y, so the vertex shader lerps the displaced coarse endpoints instead +//! of evaluating a new Gerstner point that can leave the neighbour's straight edge. Hysteresis +//! avoids rebuilding while the camera hovers at the threshold. //! A full MCLQ copy is 1,089 vertices + 6,144 indices (about 68 KiB without allocator overhead); //! the 64 yd ring bounds ordinary terrain residency to roughly 3.5 MiB, and a per-surface ceiling //! refuses pathological MLIQ grids rather than allowing one pool to consume an unbounded copy. @@ -135,6 +137,46 @@ fn bilerp( out } +/// MONKEY (reviewfix): encode an outer-edge fine vertex as `axis/sign + coarse-edge fraction`. +/// UV1.y is otherwise zero. Coarse corners and interior fine vertices need no stitch. +fn stitch_tag( + positions: &[[f32; 3]], + cols: usize, + rows: usize, + fine_cols: usize, + fine_rows: usize, + fx: usize, + fy: usize, + sx: usize, + sy: usize, + tx: f32, + ty: f32, +) -> f32 { + let (a, b, t) = if (fy == 0 || fy + 1 == fine_rows) && fx % SUBDIVISIONS != 0 { + let y = if fy == 0 { 0 } else { rows - 1 }; + (y * cols + sx, y * cols + sx + 1, tx) + } else if (fx == 0 || fx + 1 == fine_cols) && fy % SUBDIVISIONS != 0 { + let x = if fx == 0 { 0 } else { cols - 1 }; + (sy * cols + x, (sy + 1) * cols + x, ty) + } else { + return 0.0; + }; + let dx = positions[b][0] - positions[a][0]; + let dz = positions[b][2] - positions[a][2]; + let lane = if dx.abs() >= dz.abs() { + if dx >= 0.0 { + 1.0 + } else { + 2.0 + } + } else if dz >= 0.0 { + 3.0 + } else { + 4.0 + }; + lane + t +} + /// Four-way subdivision of every source cell. The output is still one indexed regular grid: there /// are no duplicated cell-edge vertices, and dry source cells emit no triangles. fn subdivide(coarse: &Mesh, grid: [u32; 2], wet: &[bool]) -> Option { @@ -185,7 +227,11 @@ fn subdivide(coarse: &Mesh, grid: [u32; 2], wet: &[bool]) -> Option { let tx = (fx - sx * SUBDIVISIONS) as f32 / SUBDIVISIONS as f32; fine_positions.push(bilerp(positions, cols, sx, sy, tx, ty)); fine_uv0.push(bilerp(uv0, cols, sx, sy, tx, ty)); - fine_uv1.push(bilerp(uv1, cols, sx, sy, tx, ty)); + let mut uv1 = bilerp(uv1, cols, sx, sy, tx, ty); + uv1[1] = stitch_tag( + positions, cols, rows, fine_cols, fine_rows, fx, fy, sx, sy, tx, ty, + ); + fine_uv1.push(uv1); if let (Some(source), Some(output)) = (colours, fine_colours.as_mut()) { output.push(bilerp(source, cols, sx, sy, tx, ty)); } @@ -265,6 +311,14 @@ mod tests { assert_eq!(positions[20], [0.0, 0.0, 1.0]); assert_eq!(positions[24], [1.0, 1.0, 1.0]); assert_eq!(positions[12], [0.5, 0.25, 0.5]); + let Some(VertexAttributeValues::Float32x2(uv1)) = fine.attribute(Mesh::ATTRIBUTE_UV_1) + else { + panic!("uv1"); + }; + assert_eq!(uv1[0][1], 0.0, "coarse corner"); + assert_eq!(uv1[2][1], 1.5, "top-edge midpoint, +x at t=0.5"); + assert_eq!(uv1[10][1], 3.5, "left-edge midpoint, +z at t=0.5"); + assert_eq!(uv1[12][1], 0.0, "interior fine vertex"); assert_eq!(fine.indices().expect("indices").len(), 16 * 6); } diff --git a/crates/benilla-world/src/skybox.rs b/crates/benilla-world/src/skybox.rs index f14b946b..225471bf 100644 --- a/crates/benilla-world/src/skybox.rs +++ b/crates/benilla-world/src/skybox.rs @@ -24,8 +24,15 @@ //! 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. +//! +//! MONKEY (reviewfix): those M2-fidelity repairs deliberately also apply when `zoneSkyboxes` is +//! off. The reference WMO and ghost slots animate their authored colour/alpha and texture tracks, +//! use non-white M2 colours, and draw batches in authored order; deterministic captures pose those +//! same tracks at their pinned time rather than substituting the bind pose. The batch-order layout +//! leaves room for a celestial under-layer and the final fog cone. The cvar gates the added +//! zone slot, not fixes to the two 1.12 slots. -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::sync::Arc; use bevy::asset::RenderAssetUsages; @@ -47,11 +54,9 @@ use benilla_formats::{SKYBOX_FOG_BLEND, SKYBOX_FULL_DAY, SKYBOX_KEEP_CELESTIAL}; /// `WmoGroupNav::flags`. const SHOW_SKYBOX: u32 = 0x40000; -/// 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 (reviewfix): the fog cone owns the skybox band's final distinct rung. Orders above this +/// clamp onto it in [`crate::model_render::skybox_sort_bias`], so model batches stop one rung below. +const FOG_CONE_ORDER: u16 = 58; /// 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. @@ -147,9 +152,10 @@ enum PartPose { #[derive(Component, Default)] struct SkyboxLocal(Affine3A); -/// Skybox paths built this session, failed loads included, so a failure is not retried per frame. +/// Skybox paths built this session в†’ their batch counts. Failed loads use zero, so a failure is not +/// retried per frame; successful celestial counts set the main model's dynamic order base. #[derive(Resource, Default)] -struct BuiltSkyboxes(HashSet); +struct BuiltSkyboxes(HashMap); /// MONKEY (skybox): each built model's rig, by path. #[derive(Resource, Default)] @@ -220,6 +226,15 @@ fn norm(path: &str) -> String { } } +/// MONKEY (reviewfix): celestial batches occupy the low rungs and a main model starts immediately +/// after the largest active celestial model. Oversized art compresses onto the last model rung but +/// can never collide with the fog cone. +fn skybox_batch_order(celestial: bool, celestial_batches: u16, batch: usize) -> u16 { + let base = if celestial { 0 } else { celestial_batches }; + base.saturating_add(u16::try_from(batch.saturating_add(1)).unwrap_or(u16::MAX)) + .min(FOG_CONE_ORDER - 1) +} + /// 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) { @@ -423,9 +438,13 @@ fn build_skybox( 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 { + // MONKEY (reviewfix): build celestial models first so main bases derive from their real batch + // counts instead of a fixed split that aliases sufficiently large models. + let mut pending: Vec<_> = want.0.iter().collect(); + pending.sort_by_key(|layer| !layer.celestial); + for layer in pending { let path = layer.path.as_str(); - if built.0.contains(path) { + if built.0.contains_key(path) { continue; } // MONKEY (skybox): the rig, off the same bytes the batches come from. @@ -445,16 +464,22 @@ fn build_skybox( 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()); + built.0.insert(path.to_string(), 0); continue; } Err(e) => { warn!("skybox '{path}' failed to load, keeping the gradient dome: {e:#}"); - built.0.insert(path.to_string()); + built.0.insert(path.to_string(), 0); continue; } }; - let base = if layer.celestial { 0 } else { MAIN_ORDER_BASE }; + let celestial_batches = want + .0 + .iter() + .filter(|layer| layer.celestial) + .filter_map(|layer| built.0.get(&layer.path).copied()) + .max() + .unwrap_or(0); for (i, sub) in subs.iter().enumerate() { let mut mesh = Mesh::new( PrimitiveTopology::TriangleList, @@ -498,7 +523,7 @@ fn build_skybox( 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 order = skybox_batch_order(layer.celestial, celestial_batches, i); 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 @@ -534,14 +559,16 @@ fn build_skybox( } } } + let mut lane_materials = [pair.steady.clone(), pair.fade_blend.clone()]; let lane = SkyMatLane::register( sub, uv, tint, &mut table, mats.materials(), - &[pair.steady.id(), pair.fade_blend.id()], + &mut lane_materials, ); + [pair.steady, pair.fade_blend] = lane_materials; let mesh = meshes.add(mesh); let pose = match sole_bone(sub) { Some(b) if rig.bone_moves(b) => PartPose::Rigid(b), @@ -581,7 +608,9 @@ fn build_skybox( } } rigs.0.insert(path.to_string(), rig); - built.0.insert(path.to_string()); + built + .0 + .insert(path.to_string(), u16::try_from(subs.len()).unwrap_or(u16::MAX)); } } @@ -714,7 +743,9 @@ fn animate_skyboxes( return; } let deterministic = crate::dev_state::deterministic_run(); - let now = time.elapsed_secs(); + // MONKEY (reviewfix): retain sub-frame precision for skybox loops across long sessions; only + // narrow the wrapped sequence clock that the f32 track sampler consumes. + let now = time.elapsed_secs_f64(); let day = clock.minute.min(1439) as f32 / 1440.0; clocks.clear(); for layer in want.0.iter().filter(|l| l.weight > 0.0) { @@ -724,12 +755,17 @@ fn animate_skyboxes( // A capture poses at `t = 0`, not the bind pose: a converted skybox places its layers with // bone keys, and every row at `t = 0` is its seed. let (band_t, gseq, live) = if layer.flags & SKYBOX_FULL_DAY != 0 { - let g = if deterministic { 0.0 } else { f64::from(now) }; + let g = if deterministic { 0.0 } else { now }; (rig.duration * day, g, true) } else if deterministic { (capture_t(), f64::from(capture_t()), true) } else { - (now, f64::from(now), true) + let band_t = if rig.duration > 0.0 { + (now % f64::from(rig.duration)) as f32 + } else { + 0.0 + }; + (band_t, now, true) }; let pose = if live && rig.animates() { rig.pose(rig.band_time(band_t), gseq) @@ -954,6 +990,15 @@ mod tests { } } + #[test] + fn skybox_order_bands_follow_batch_counts_and_reserve_the_cone() { + assert_eq!(skybox_batch_order(true, 0, 23), 24); + assert_eq!(skybox_batch_order(false, 24, 0), 25); + assert_eq!(skybox_batch_order(false, 0, 31), 32); + assert_eq!(skybox_batch_order(false, 40, usize::MAX), FOG_CONE_ORDER - 1); + assert_eq!(FOG_CONE_ORDER, 58); + } + #[test] fn a_wmo_sky_crossfades_the_zone_sky() { let mut layers = vec![layer("a.m2", 1.0)]; diff --git a/crates/benilla-world/src/skybox_anim.rs b/crates/benilla-world/src/skybox_anim.rs index 652fa163..408e4534 100644 --- a/crates/benilla-world/src/skybox_anim.rs +++ b/crates/benilla-world/src/skybox_anim.rs @@ -333,21 +333,38 @@ impl SkyMatLane { tint: Option>, table: &mut crate::mat_anim_table::MatAnimTable, materials: &mut Assets, - mats: &[AssetId], + mats: &mut [Handle], ) -> Self { - let mut lane = SkyMatLane::default(); - let write = |materials: &mut Assets, - f: &dyn Fn(&mut benilla_assets::materials::WowModelMaterial)| { - for id in mats { - crate::model_render::lazy::with_material_mut(materials, *id, |m| f(m)); + // MONKEY (reviewfix): `model_material` is a cache. Animation slots are per sky batch, so + // detach its handles before writing any slot (notably affine-only `.z`) into the material. + let originals = mats.to_vec(); + for i in 0..mats.len() { + if let Some(prior) = (0..i).find(|&j| originals[j].id() == originals[i].id()) { + mats[i] = mats[prior].clone(); + continue; } - }; + if let Some(material) = + crate::model_render::lazy::with_material_mut(materials, originals[i].id(), |m| { + m.clone() + }) + { + mats[i] = materials.add(material); + } + } + let mut lane = SkyMatLane::default(); + let write = + |materials: &mut Assets, + f: &dyn Fn(&mut benilla_assets::materials::WowModelMaterial)| { + for id in mats.iter() { + crate::model_render::lazy::with_material_mut(materials, id.id(), |m| f(m)); + } + }; if let Some(uv) = uv.filter(|a| a.period > 0.0) { if let Some(slot) = table.alloc() { let seed = mats .first() - .and_then(|id| { - crate::model_render::lazy::with_material_mut(materials, *id, |m| { + .and_then(|handle| { + crate::model_render::lazy::with_material_mut(materials, handle.id(), |m| { [m.extension.sun_scale.z, m.extension.sun_scale.w] }) }) @@ -376,8 +393,8 @@ impl SkyMatLane { if let Some(slot) = table.alloc() { let seed = mats .first() - .and_then(|id| { - crate::model_render::lazy::with_material_mut(materials, *id, |m| { + .and_then(|handle| { + crate::model_render::lazy::with_material_mut(materials, handle.id(), |m| { [m.extension.tint.x, m.extension.tint.y, m.extension.tint.z] }) })