diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index 9eaf2f91..3a84ac3d 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -638,10 +638,11 @@ impl Plugin for CapturePlugin { ui: None, } } else if name == "waterfx" { - // The water-foam viewer (see `water_fx::view`): a synthetic wading unit over a - // synthetic wet lattice, framed by a fixed orbit around the rig centre. Knobs: - // WOW_WFX_MODE (ring|wake|turn), WOW_WFX_SPEED (yd/s), WOW_WFX_AGE (s), - // WOW_WFX_DEPTH (yd), camera WOW_WFX_AZ/EL/DIST. Not a golden scenario. + // The water-foam viewer (see `capture::waterfx`): a wading unit over a synthetic + // wet lattice — or, with WOW_WFX_AT=x,y,z, in the real streamed liquid at that pin + // — framed by a fixed orbit around the rig centre. Knobs: WOW_WFX_MODE + // (ring|wake|turn), WOW_WFX_SPEED (yd/s), WOW_WFX_AGE (s), WOW_WFX_DEPTH (yd), + // camera WOW_WFX_AZ/EL/DIST. Not a golden scenario. let knob = |k: &str, d: f32| { std::env::var(k) .ok() @@ -653,9 +654,17 @@ impl Plugin for CapturePlugin { Ok("turn") => waterfx::WfxMode::Turn, _ => waterfx::WfxMode::Ring, }; - // Rig centre in raw WoW coords: over the Northshire ground scene, the synthetic - // surface a few yards above the terrain so the backdrop plane reads clean. - let center = [-8961.0_f32, -145.0, 95.0]; + // Rig centre in raw WoW coords. Default: over the Northshire ground scene, the + // synthetic surface a few yards above the terrain so the backdrop plane reads + // clean. `WOW_WFX_AT=x,y,z` moves it anywhere and switches the rig to the REAL + // streamed liquid there (`z` = that water's surface height) — a synthetic square + // of water has no bank to clip against and no neighbour to be sorted against. + let at = std::env::var("WOW_WFX_AT").ok().and_then(|v| { + let c: Vec = v.split(',').filter_map(|p| p.trim().parse().ok()).collect(); + (c.len() == 3).then(|| [c[0], c[1], c[2]]) + }); + let live = at.is_some(); + let center = at.unwrap_or([-8961.0_f32, -145.0, 95.0]); let az = knob("WOW_WFX_AZ", 180.0).to_radians(); let el = knob("WOW_WFX_EL", 35.0).to_radians(); let dist = knob("WOW_WFX_DIST", 14.0); @@ -670,11 +679,17 @@ impl Plugin for CapturePlugin { age: knob("WOW_WFX_AGE", 1.5), center, depth: knob("WOW_WFX_DEPTH", 0.5), + live, }) .init_resource::(); Scenario { name: "waterfx", - map: scenarios::MAP_AZEROTH, // the synthetic lattice sits over the Northshire slope + // The synthetic lattice sits over the Northshire slope; a live rig + // (`WOW_WFX_AT`) goes wherever its pin is, so its map is a knob like `vista`'s. + map: std::env::var("WOW_MAP") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(scenarios::MAP_AZEROTH), eye, look: center, minute: 720, diff --git a/crates/benilla-app/src/capture/waterfx.rs b/crates/benilla-app/src/capture/waterfx.rs index e429a473..8dc3f869 100644 --- a/crates/benilla-app/src/capture/waterfx.rs +++ b/crates/benilla-app/src/capture/waterfx.rs @@ -8,6 +8,13 @@ //! `WOW_WFX_AGE` (s of motion before the shot), `WOW_WFX_DEPTH` (yd below the surface; > ~0.8 //! also exercises the step-in one-shot), camera `WOW_WFX_AZ`/`EL`/`DIST`. Not a golden scenario — //! output depends on the knobs. +//! +//! **`WOW_WFX_AT=x,y,z` wades the dummy in the REAL world instead** (`z` = the liquid surface +//! height there, which `benilla-formats --example water_here` prints). The synthetic lattice and +//! backdrop stand down and the rig wades in the streamed ADT/WMO liquid at that pin — which is the +//! only way to see the two things a synthetic square of water cannot show: how a patch **clips at a +//! real bank**, and how it **sorts against the neighbouring water chunks** (B348 — one square of +//! water has no neighbour to be painted over by). `WOW_MAP` picks the map. use bevy::prelude::*; @@ -42,6 +49,10 @@ pub(crate) struct WaterFxView { pub(crate) center: [f32; 3], /// Feet depth below the surface (yd) — must land inside the wading gate. pub(crate) depth: f32, + /// Wade in the **real** streamed liquid at [`Self::center`] (`WOW_WFX_AT`) rather than over the + /// synthetic lattice: no backdrop, no fixture water, just the dummy. A real shoreline is the + /// only rig that exercises bank clipping and multi-chunk sorting. + pub(crate) live: bool, } /// Marks the rig's entities (spawn-once guard). @@ -71,6 +82,13 @@ pub(crate) fn spawn( } let [cx, cy, surf] = view.center; + if view.live { + spawn_dummy(&mut commands, &view, cx, cy, surf); + state.attached_at = Some(time.elapsed_secs()); + info!("waterfx: rig armed in LIVE water at ({cx}, {cy}, {surf})"); + return; + } + // A big flat mid-gray "water body" backdrop 0.15 yd under the surface, so the additive foam // reads against a stable tone (the real liquid shader is beside the point here). commands.spawn(( @@ -111,8 +129,25 @@ pub(crate) fn spawn( Transform::IDENTITY, )); - // The wading dummy: a plain streamed-unit shape (no display id, visual pre-attached so the - // entity subsystem never gives it a fallback cube over the foam). + spawn_dummy(&mut commands, &view, cx, cy, surf); + state.attached_at = Some(time.elapsed_secs()); + info!( + "waterfx: rig armed (mode {}, speed {}, depth {}, age {})", + match view.mode { + WfxMode::Ring => "ring", + WfxMode::Wake => "wake", + WfxMode::Turn => "turn", + }, + view.speed, + view.depth, + view.age + ); +} + +/// The wading dummy: a plain streamed-unit shape (no display id, visual pre-attached so the entity +/// subsystem never gives it a fallback cube over the foam). A wake starts far enough back that its +/// trail ENDS at the rig centre after `age` seconds. +fn spawn_dummy(commands: &mut Commands, view: &WaterFxView, cx: f32, cy: f32, surf: f32) { let start_x = if view.mode == WfxMode::Wake { cx - view.speed * view.age } else { @@ -140,18 +175,6 @@ pub(crate) fn spawn( crate::entities::VisualAttached, Transform::from_translation(wow_to_bevy([start_x, cy, surf - view.depth])), )); - state.attached_at = Some(time.elapsed_secs()); - info!( - "waterfx: rig armed (mode {}, speed {}, depth {}, age {})", - match view.mode { - WfxMode::Ring => "ring", - WfxMode::Wake => "wake", - WfxMode::Turn => "turn", - }, - view.speed, - view.depth, - view.age - ); } /// Drive the dummy each frame: translate (wake) or spin (turn) until the age elapses; the foam diff --git a/crates/benilla-app/src/pipe_warm/mod.rs b/crates/benilla-app/src/pipe_warm/mod.rs index b6bca0b9..724134fe 100644 --- a/crates/benilla-app/src/pipe_warm/mod.rs +++ b/crates/benilla-app/src/pipe_warm/mod.rs @@ -613,10 +613,19 @@ fn warm_effect_lane( EffectBlend::Multiply, EffectBlend::Mod2x, ] { - for raster_bias in [ - 0, - benilla_world::sky_order::Rung::DECAL_RASTER, - benilla_world::sky_order::Rung::SHADOW_RASTER, + // The rasterizer settle is a PAIR — constant and slope-scale — and both halves are + // pipeline-key axes, so they are warmed as the pairs that actually ship, not as a + // cross product. The foam's is the reference's own (`Rung::FOAM_RASTER`, 1809) and is + // the only one carrying a slope term; a hole here is a live compile the first time + // anyone wades. + for (raster_bias, raster_slope) in [ + (0, 0.0), + (benilla_world::sky_order::Rung::DECAL_RASTER, 0.0), + (benilla_world::sky_order::Rung::SHADOW_RASTER, 0.0), + ( + benilla_world::sky_order::Rung::FOAM_RASTER, + benilla_world::sky_order::Rung::FOAM_RASTER_SLOPE, + ), ] { // `lit` is a pipeline-key axis (a shader def), so BOTH arms are warmed — a hole // here is a first-lit-emitter compile mid-play, which is the whole failure this @@ -646,6 +655,7 @@ fn warm_effect_lane( anchor: Vec3::ZERO, bias: 0.0, raster_bias, + raster_slope, cam_relative: false, main_entity: cam, light: None, diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs index 383ec187..663e2a31 100644 --- a/crates/benilla-assets/src/materials.rs +++ b/crates/benilla-assets/src/materials.rs @@ -542,6 +542,30 @@ impl MaterialExtension for LiquidExt { fn fragment_shader() -> ShaderRef { "embedded://benilla_assets/shaders/liquid.wgsl".into() } + + /// **`WATER_BIAS` is a SORT rung; keep it out of the rasterizer** — the same split the far-side + /// twin and the WMO skybox make above, for the one surface that most needs it. + /// + /// The water pass has a fixed frame slot (`sky_order::WATER_BIAS`, −2e4), and the base + /// `StandardMaterial::specialize` packs that same f32 into `depth_stencil.bias.constant` (bevy + /// 0.18 `pbr_material.rs`). At −2e4 ULPs that shoves every water fragment ~0.24 % of its view + /// distance *away* from the eye — and the water plane is the one world surface that meets the + /// ground at a grazing angle over tens of yards, so a relative depth pull there does not settle + /// a tie, it moves the waterline. Worse, a float depth buffer's bias unit is the ULP at the + /// primitive's own depth, so the pull **doubles at every exponent boundary**: two water + /// triangles either side of one land in different buckets and their shorelines stop agreeing. + /// The ordering job needs none of it — the rung exists for the transparent phase's sort key. + fn specialize( + _pipeline: &MaterialExtensionPipeline, + descriptor: &mut RenderPipelineDescriptor, + _layout: &MeshVertexBufferLayoutRef, + _key: MaterialExtensionKey, + ) -> Result<(), SpecializedMeshPipelineError> { + if let Some(ds) = descriptor.depth_stencil.as_mut() { + ds.bias.constant = 0; + } + Ok(()) + } } /// Per-tile splat inputs: a `texture_2d_array` of the tile's ground textures, a `texture_2d_array` diff --git a/crates/benilla-formats/examples/water_here.rs b/crates/benilla-formats/examples/water_here.rs new file mode 100644 index 00000000..46bf444c --- /dev/null +++ b/crates/benilla-formats/examples/water_here.rs @@ -0,0 +1,319 @@ +//! What the ADT liquid actually **is** under a pin — the shoreline instrument: +//! `cargo run -p benilla-formats --example water_here -- [radius_yd]` +//! e.g. `water_here Azeroth -12512.69 -180.21 40`. +//! +//! A water-at-the-shore report ("there's a step in it", "the foam runs past the waterline") is a +//! question about three surfaces that only ever agree in the middle of a lake: the MCLQ **wet-cell +//! lattice** (4.167-yd cells, the granularity everything on the water path clips to), the liquid +//! **surface height** on those cells, and the **terrain** underneath. This prints all three over one +//! neighbourhood so the geometry can be read instead of guessed: +//! +//! - the **block census** — one line per MCLQ block: kind, wet cells, surface-height span, depth-byte +//! span. Where a step would be *authored*, this is where it shows: two blocks over one chunk, or a +//! block whose own plane has relief; +//! - the **overlap** scan — cells claimed by two blocks at once, which would composite the +//! translucent water twice; +//! - the **seam** scan — every pair of adjacent wet cells whose planes disagree, so an authored step +//! can be told apart from a rendered one before anyone opens the renderer; +//! - the **lattice-vs-waterline** scan — dry cells the ground still runs under. Whole wet cells are +//! the only thing the renderer draws, so wherever the lattice runs out before the ground climbs +//! through the plane, the water ends on a 4.167-yd straight edge in open water: a shape no +//! shoreline has; +//! - the **shoreline slope** at the pin, and what a coplanarity *lift* costs there. A decal held `l` +//! yards above the water plane keeps painting for `l / slope` yards past the waterline, on dry +//! ground — the arithmetic behind B348's foam-on-the-sand, and the reason that settle is a depth +//! bias now and not a lift. +//! +//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. + +use std::collections::BTreeMap; + +use benilla_formats::{terrain_height_at, ChunkMesh, CHUNK_SIZE}; + +/// One MCLQ cell edge, in yards — the granularity of `wet`, and of every clip on the water path. +const CELL: f32 = CHUNK_SIZE / 8.0; +/// How far past the reported radius the block census still enumerates, so that a cell at the edge +/// of the answer has its real neighbours to be judged against (one chunk clears it). +const MARGIN: f32 = CHUNK_SIZE; + +/// One seam between two adjacent wet cells whose surface planes disagree. +struct Seam { + /// `to.surface − from.surface`, in yards. + dz: f32, + from: (i32, i32), + to: (i32, i32), +} + +/// One MCLQ cell of one block, resolved into world terms. +struct Cell { + /// Cell-centre world XY. + x: f32, + y: f32, + /// The block's surface height over the cell (bilinear at the centre, as the queries sample it). + surface: f32, + /// Terrain height at the same point, `None` where the tile has a hole. + ground: Option, + /// Which chunk/block this cell came from — `(chunk index, block index)`. + owner: (usize, usize), + kind: benilla_formats::LiquidKind, +} + +fn main() -> anyhow::Result<()> { + let mut args = std::env::args().skip(1); + let usage = || anyhow::anyhow!("usage: water_here [radius_yd]"); + let map = args.next().ok_or_else(usage)?; + let x: f32 = args.next().ok_or_else(usage)?.parse()?; + let y: f32 = args.next().ok_or_else(usage)?.parse()?; + let radius: f32 = args.next().map_or(Ok(30.0), |r| r.parse())?; + + let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); + let mut chain = benilla_formats::open_chain(&data)?; + // One tile of slack: a shoreline pin sits on a tile seam as often as not. + let tiles = benilla_formats::load_tiles_around(&mut chain, &map, x, y, 1)?; + let chunks: Vec = tiles.into_iter().flat_map(|(_, t)| t.chunks).collect(); + println!( + "{map}: pin ({x}, {y}) r={radius} yd — {} chunks loaded", + chunks.len() + ); + + // ── The block census, and the cells ────────────────────────────────────────────────────── + let mut cells: Vec = Vec::new(); + for (ci, chunk) in chunks.iter().enumerate() { + let Some(nw) = chunk.positions.first() else { + continue; + }; + // The chunk's own footprint against the pin box (chunk spans south/east of its NW corner), + // widened by MARGIN so every cell inside the radius has its real neighbours loaded — a cell + // at the box edge would otherwise read as "the lattice stops here" when it is only the + // enumeration that stopped. + if nw[0] - CHUNK_SIZE > x + radius + MARGIN + || nw[0] < x - radius - MARGIN + || nw[1] - CHUNK_SIZE > y + radius + MARGIN + || nw[1] < y - radius - MARGIN + { + continue; + } + for (bi, liq) in chunk.liquids.iter().enumerate() { + let [cols, rows] = liq.grid; + let (cols, rows) = (cols as usize, rows as usize); + let wet_n = liq.wet.iter().filter(|w| **w).count(); + let (mut zlo, mut zhi) = (f32::MAX, f32::MIN); + let (mut dlo, mut dhi) = (f32::MAX, f32::MIN); + for (n, p) in liq.positions.iter().enumerate() { + let cell_touched = touching_cells(n, cols, rows) + .into_iter() + .any(|c| liq.wet.get(c).copied().unwrap_or(false)); + if !cell_touched { + continue; + } + zlo = zlo.min(p[2]); + zhi = zhi.max(p[2]); + dlo = dlo.min(liq.depths[n]); + dhi = dhi.max(liq.depths[n]); + } + println!( + " chunk[{ci:3}] {:>2}_{:<2} block[{bi}] {:?} nibble {} — {wet_n:2}/{} wet, \ + z {zlo:.3}..{zhi:.3} (span {:.3}), depthV {dlo:.3}..{dhi:.3}", + chunk.index_x, + chunk.index_y, + liq.kind, + liq.sound_nibble, + (cols - 1) * (rows - 1), + zhi - zlo, + ); + + for j in 0..rows - 1 { + for i in 0..cols - 1 { + if !liq.wet[j * (cols - 1) + i] { + continue; + } + let corners = [ + liq.positions[j * cols + i], + liq.positions[j * cols + i + 1], + liq.positions[(j + 1) * cols + i], + liq.positions[(j + 1) * cols + i + 1], + ]; + let cx = corners.iter().map(|p| p[0]).sum::() / 4.0; + let cy = corners.iter().map(|p| p[1]).sum::() / 4.0; + let surface = corners.iter().map(|p| p[2]).sum::() / 4.0; + cells.push(Cell { + x: cx, + y: cy, + surface, + ground: terrain_height_at(&chunks, [cx, cy, 0.0]), + owner: (ci, bi), + kind: liq.kind, + }); + } + } + } + } + if cells.is_empty() { + println!(" (no MCLQ liquid within the box)"); + return Ok(()); + } + + // ── Overlap: two blocks claiming one patch of world, at two heights ────────────────────── + // Key a cell by its lattice index, floored — two DIFFERENT cells of one block must never + // collide, or the overlap report cries wolf on its own rounding. + let key = |c: &Cell| ((c.x / CELL).floor() as i32, (c.y / CELL).floor() as i32); + let mut by_key: BTreeMap<(i32, i32), Vec> = BTreeMap::new(); + for (n, c) in cells.iter().enumerate() { + by_key.entry(key(c)).or_default().push(n); + } + let mut overlaps = 0; + for (k, ns) in &by_key { + if ns.len() < 2 { + continue; + } + overlaps += 1; + if overlaps <= 12 { + let list: Vec = ns + .iter() + .map(|&n| { + format!( + "chunk[{}]blk[{}] {:?} z {:.3}", + cells[n].owner.0, cells[n].owner.1, cells[n].kind, cells[n].surface + ) + }) + .collect(); + println!(" OVERLAP at cell {k:?}: {}", list.join(" | ")); + } + } + println!(" overlapping cells: {overlaps}"); + + // ── The seam scan: adjacent wet cells whose planes disagree ────────────────────────────── + let mut steps: Vec = Vec::new(); + for (k, ns) in &by_key { + let z = cells[ns[0]].surface; + for nb in [(k.0 + 1, k.1), (k.0, k.1 + 1)] { + if let Some(other) = by_key.get(&nb) { + let dz = cells[other[0]].surface - z; + if dz.abs() > 0.01 { + steps.push(Seam { + dz, + from: *k, + to: nb, + }); + } + } + } + } + steps.sort_by(|a, b| b.dz.abs().partial_cmp(&a.dz.abs()).expect("finite")); + println!( + " wet-cell seams with a height step > 0.01 yd: {} (largest first)", + steps.len() + ); + for s in steps.iter().take(10) { + println!(" {:?} -> {:?} dz {:+.3} yd", s.from, s.to, s.dz); + } + + // ── Where the LATTICE stops vs where the WATERLINE is ──────────────────────────────────── + // The renderer draws whole wet cells and nothing else, so the water's visible boundary is the + // *lattice* edge wherever the lattice runs out before the ground climbs through the plane. That + // edge is a 4.167-yd straight line in open water — the shape a shoreline never has. Sample every + // dry cell that touches a wet one on a 0.25-yd grid and report how deep the water would have + // been there. + let mut short_cells: Vec<(f32, (i32, i32))> = Vec::new(); + for k in by_key.keys().copied().collect::>() { + for nb in [ + (k.0 + 1, k.1), + (k.0 - 1, k.1), + (k.0, k.1 + 1), + (k.0, k.1 - 1), + ] { + if by_key.contains_key(&nb) { + continue; + } + let surface = cells[by_key[&k][0]].surface; + let (bx, by) = ((nb.0 as f32 + 0.5) * CELL, (nb.1 as f32 + 0.5) * CELL); + let mut deepest = f32::MIN; + for si in 0..=16 { + for sj in 0..=16 { + let px = bx - CELL / 2.0 + CELL * si as f32 / 16.0; + let py = by - CELL / 2.0 + CELL * sj as f32 / 16.0; + if let Some(g) = terrain_height_at(&chunks, [px, py, 0.0]) { + deepest = deepest.max(surface - g); + } + } + } + if deepest > 0.02 && (bx - x).hypot(by - y) <= radius { + short_cells.push((deepest, nb)); + } + } + } + short_cells.sort_by(|a, b| b.0.partial_cmp(&a.0).expect("finite")); + short_cells.dedup_by_key(|(_, k)| *k); + println!( + "\n DRY cells the ground still runs under (the lattice stops before the waterline does): {}", + short_cells.len() + ); + for (d, k) in short_cells.iter().take(10) { + println!( + " cell {k:?} at ({:.1}, {:.1}) — up to {d:.3} yd of water missing", + (k.0 as f32 + 0.5) * CELL, + (k.1 as f32 + 0.5) * CELL + ); + } + + // ── The waterline's slope, and what a coplanarity lift costs horizontally ──────────────── + // A decal on the water plane, lifted `l` yards to win the coplanar tie, keeps painting for + // `l / slope` yards past the waterline — up the beach, on dry ground. Measure the slope where + // the plane actually meets the ground nearest the pin. + let mut best: Option<(f32, f32, f32, f32)> = None; // (dist to pin, x, y, slope) + for c in &cells { + let Some(g) = c.ground else { continue }; + if (c.surface - g).abs() > 0.5 { + continue; + } + let h = 1.0_f32; + let sample = |dx: f32, dy: f32| terrain_height_at(&chunks, [c.x + dx, c.y + dy, 0.0]); + let (Some(px), Some(mx), Some(py), Some(my)) = ( + sample(h, 0.0), + sample(-h, 0.0), + sample(0.0, h), + sample(0.0, -h), + ) else { + continue; + }; + let slope = (((px - mx) / (2.0 * h)).powi(2) + ((py - my) / (2.0 * h)).powi(2)).sqrt(); + let d = (c.x - x).hypot(c.y - y); + if best.as_ref().is_none_or(|b| d < b.0) { + best = Some((d, c.x, c.y, slope)); + } + } + if let Some((d, wx, wy, slope)) = best { + println!( + "\n waterline nearest the pin: ({wx:.2}, {wy:.2}), {d:.1} yd away — ground slope {slope:.4} \ + ({:.1}%)", + slope * 100.0 + ); + for lift in [0.03_f32, 0.01, 0.0] { + println!( + " a {lift:.2}-yd lift paints {:.2} yd of dry beach", + if slope > 0.0 { + lift / slope + } else { + f32::INFINITY + } + ); + } + } + + Ok(()) +} + +/// The (up to four) cell indices a grid vertex `n` is a corner of, row-major over `(cols−1)` cells. +fn touching_cells(n: usize, cols: usize, rows: usize) -> Vec { + let (i, j) = (n % cols, n / cols); + let mut out = Vec::new(); + for dj in [0isize, -1] { + for di in [0isize, -1] { + let (ci, cj) = (i as isize + di, j as isize + dj); + if ci >= 0 && cj >= 0 && (ci as usize) < cols - 1 && (cj as usize) < rows - 1 { + out.push(cj as usize * (cols - 1) + ci as usize); + } + } + } + out +} diff --git a/crates/benilla-world/src/ground_fx.rs b/crates/benilla-world/src/ground_fx.rs index e99fb152..582dd7c0 100644 --- a/crates/benilla-world/src/ground_fx.rs +++ b/crates/benilla-world/src/ground_fx.rs @@ -260,6 +260,7 @@ pub(crate) fn update_ground_fx_decals( anchor: decal.center, bias: crate::sky_order::Rung::GROUND_FX, raster_bias: crate::sky_order::Rung::GROUND_FX as i32, + raster_slope: 0.0, cam_relative: false, main_entity: entity, light: None, diff --git a/crates/benilla-world/src/interior.rs b/crates/benilla-world/src/interior.rs index d39e51b5..5cbc4ba8 100644 --- a/crates/benilla-world/src/interior.rs +++ b/crates/benilla-world/src/interior.rs @@ -241,6 +241,12 @@ pub struct ParticleLight(pub [f32; 3]); #[derive(Component)] pub struct InteriorAnchor { law: AppliedLaw, + /// The room the law's own attach ray claimed — `None` on the exterior law. It is the key the + /// per-frame interior-FOG gate is asked at, which is why a settled anchor keeps it: the gate's + /// answer moves with the CAMERA, so an anchor that never rays again still needs its room. + room: Option, + /// The standing fog verdict for that room, re-read every frame (see [`anchor_room_fogged`]). + fog: bool, /// Anchor position at the last down-ray + the residency generation then — the re-test gate. last_pos: Vec3, generation: u32, @@ -290,6 +296,11 @@ pub struct InteriorLit { /// Last applied law — the part's last-written record (the anchor's [`InteriorAnchor`] is the /// authority): the write gate, and [`Self::is_bake`]'s source. `None` until first written. applied: Option, + /// The [`crate::mesh_tag::INTERIOR_FOG_BIT`] as last written, beside the law rather than inside + /// it: the two are decided by different questions (the law by where this part STANDS, the fog + /// by whether that room is on the camera's chain this frame — decision 1792 §4), so a part can + /// need a rewrite for one with the other unchanged. Meaningless while `applied` is `None`. + fogged: bool, } impl InteriorLit { @@ -343,6 +354,7 @@ impl InteriorLit { kind, exterior, applied: None, + fogged: false, } } @@ -359,6 +371,7 @@ impl InteriorLit { kind, exterior, applied: Some(AppliedLaw::Bake(0)), + fogged: true, } } } @@ -398,7 +411,15 @@ impl Plugin for InteriorPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() - .add_systems(Update, classify_entity_interior) + // **After the flood**, like every other reader of the per-group gate + // (`model_render::ModelVisSet`): since the interior-FOG half of a part's channel is + // `[0xca7f00]` (decision 1792 §4), classifying before the flood would answer a + // camera-move with last frame's rooms. The ray half wants the same order anyway — the + // placements it rays are the ones this frame's residency published. + .add_systems( + Update, + classify_entity_interior.after(crate::wmo_portal::WmoPvsSet), + ) .add_observer(enqueue_on_fade_latch); } } @@ -434,7 +455,7 @@ pub fn classify_entity_interior( time: Res