diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 22c832ae..039b9e7c 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -525,6 +525,7 @@ waterClassic = "Classic", waterEnhanced = "Enhanced", waterHigh = "High", + waterUltra = "Ultra", lavaGlow = "Lava Glow", qOff = "Off", qLow = "Low", @@ -651,7 +652,10 @@ WATER_QUALITY = "Classic is the original client's water. Enhanced adds soft shorelines, " .. "animated foam around shores and objects, moving wave highlights, sky reflection " - .. "and light glints. High also adds mirror reflections and costs the most.", + .. "and light glints. High also reflects the scenery that is on screen. " + .. "Ultra renders the world a second time to reflect what is off screen too " + .. "- the far bank, the trees above you - and is the only step here that costs " + .. "real frame time.", LAVA_GLOW = "How strongly lava lights its surroundings. 0 turns the glow off.", CHARACTER_SHADOWS = @@ -929,6 +933,7 @@ waterClassic = "Классическое", waterEnhanced = "Улучшенное", waterHigh = "Высокое", + waterUltra = "Ультра", lavaGlow = "Свечение лавы", qOff = "Выкл.", qLow = "Низкое", @@ -1054,7 +1059,9 @@ WATER_QUALITY = "«Классическое» — вода оригинального клиента. «Улучшенное» добавляет плавные переходы у берегов, " .. "анимированную пену у берегов и вокруг объектов, подвижные блики на волнах, отражение неба " - .. "и блики от источников света. «Высокое» также добавляет зеркальные отражения и требует больше всего ресурсов.", + .. "и блики от источников света. «Высокое» также отражает то, что видно на экране. " + .. "«Ультра» рисует мир второй раз, чтобы отразить и то, что за кадром — дальний берег, " + .. "деревья над вами — и это единственная ступень здесь, которая действительно стоит кадров.", LAVA_GLOW = "Насколько сильно лава освещает всё вокруг. При 0 свечение отключено.", CHARACTER_SHADOWS = @@ -3529,6 +3536,7 @@ { text = BENILLA_ADVGFX.waterClassic, value = "0" }, { text = BENILLA_ADVGFX.waterEnhanced, value = "1" }, { text = BENILLA_ADVGFX.waterHigh, value = "2" }, + { text = BENILLA_ADVGFX.waterUltra, value = "3" }, }) diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index 926cac85..f4064d10 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -256,6 +256,76 @@ fn late_cvar_script() -> Option { /// arriving changes the image or does not affect the shot. `$WOW_CAPTURE_STABLE` overrides it. const STABLE_FRAMES: u32 = 30; +/// `$WOW_MIRROR_DUMP=1`: write the planar mirror's own render target beside the capture image, +/// as `.mirror.png`. +/// +/// The mirror is a second render of the world that nothing on screen shows directly — the water +/// reads it through a fresnel weight, a rippled UV, a plane-match band and an SSR march that +/// paints over it — so from the outside a broken mirror and a broken water shader look exactly +/// alike. Telling them apart by reasoning cost a whole session; this writes what the mirror +/// camera actually drew. +fn mirror_dump() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("WOW_MIRROR_DUMP").is_some()) +} + +/// Save an `Rgba16Float` readback as an 8-bit PNG. +/// +/// Bevy's own [`save_to_disk`] cannot: `try_into_dynamic` knows four 8-bit formats and answers +/// `UnsupportedFormat` for this one, which logs and writes no file at all. The conversion is a +/// plain clamp and scale and NOT an sRGB encode, because the world is drawn in gamma values +/// already (`docs/GRAPHICS.md`: the post chain is display-referred) — the float target holds the +/// same numbers an 8-bit one would. +fn save_mirror(path: String) -> impl FnMut(On) { + move |shot: On| { + let img = &shot.image; + let (w, h) = ( + img.texture_descriptor.size.width, + img.texture_descriptor.size.height, + ); + let Some(data) = img.data.as_ref() else { + error!("mirror dump: the readback carried no data"); + return; + }; + let want = (w as usize) * (h as usize) * 8; + if data.len() < want { + error!( + "mirror dump: {} bytes for {w}x{h} Rgba16Float, want {want}", + data.len() + ); + return; + } + let mut rgb = Vec::with_capacity((w as usize) * (h as usize) * 3); + for px in data[..want].as_chunks::<8>().0 { + for c in 0..3 { + let v = half::f16::from_bits(u16::from_le_bytes([px[c * 2], px[c * 2 + 1]])); + rgb.push((v.to_f32().clamp(0.0, 1.0) * 255.0).round() as u8); + } + } + match image::RgbImage::from_raw(w, h, rgb) { + Some(buf) => match buf.save(&path) { + Ok(()) => info!("mirror dump: wrote {path} ({w}x{h})"), + Err(e) => error!("mirror dump: {path}: {e}"), + }, + None => error!("mirror dump: {w}x{h} buffer rejected by the encoder"), + } + } +} + +/// Spawn the mirror readback alongside the capture's own screenshot, when asked for. +fn dump_mirror_beside( + commands: &mut Commands, + out: &str, + mirror: &benilla_assets::WaterReflectionImage, +) { + if !mirror_dump() { + return; + } + commands + .spawn(Screenshot::image(mirror.0.clone())) + .observe(save_mirror(format!("{out}.mirror.png"))); +} + fn stable_frames() -> u32 { std::env::var("WOW_CAPTURE_STABLE") .ok() @@ -786,6 +856,16 @@ pub(crate) struct ProbeCensus<'w, 's> { } /// Drive the capture lifecycle: settle, age, screenshot or probe, exit. +/// The driver's last two parameters, bundled: Bevy caps a system at sixteen and `drive_capture` +/// is at the cap. Nothing deeper than that — they are simply the two it reaches for last. +#[derive(bevy::ecs::system::SystemParam)] +struct LateCapture<'w> { + /// `$WOW_CVAR_LATE`, written at first settle: see [`late_cvar_script`]. + cvars: ResMut<'w, crate::cvars::Cvars>, + /// `$WOW_MIRROR_DUMP`: the planar mirror's target, written beside the capture. + mirror: Res<'w, benilla_assets::WaterReflectionImage>, +} + fn drive_capture( mut ctx: ResMut, mut watch: ResMut, @@ -809,8 +889,7 @@ fn drive_capture( mut time_strategy: ResMut, // `None` on a glue-screen capture, which builds no composite lane. backdrop: Option>, - // `$WOW_CVAR_LATE`, written at first settle: see [`late_cvar_script`]. - mut cvars: ResMut, + mut late: LateCapture, ) { // The wall-clock ceiling covers every phase: a fixture may never attach, a readback may never // land ([`capture_deadline`]). @@ -893,7 +972,7 @@ fn drive_capture( match step.split_once('=') { Some((name, value)) => { let (name, value) = (name.trim(), value.trim()); - let outcome = cvars.set(name, value); + let outcome = late.cvars.set(name, value); info!("capture: WOW_CVAR_LATE set {name} = {value} ({outcome:?})"); } None => warn!("capture: WOW_CVAR_LATE step {step:?} is not ="), @@ -928,6 +1007,7 @@ fn drive_capture( commands .spawn(Screenshot::primary_window()) .observe(save_to_disk(ctx.out.clone())); + dump_mirror_beside(&mut commands, &ctx.out, &late.mirror); info!("capture: effect aged, writing {}", ctx.out); Phase::Saving { frames: 0, @@ -969,6 +1049,7 @@ fn drive_capture( commands .spawn(Screenshot::primary_window()) .observe(save_to_disk(ctx.out.clone())); + dump_mirror_beside(&mut commands, &ctx.out, &late.mirror); info!("capture: scene aged, writing {}", ctx.out); Phase::Saving { frames: 0, diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 1a2bba7d..493807ff 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -802,11 +802,16 @@ pub(crate) const REGISTERED: &[Registered] = &[ // GFX (water): the default moves with the High rung, because that rung IS the registered // defaults (`the_high_preset_is_the_registered_defaults`). Scenery reflection was held opt-in // "until its cost is measured"; it has been — see the High rung in `LIGHTING_PRESETS`. + // + // GFX (planar water): tier 3 sits ABOVE the default on purpose. The screen-space march at + // tier 2 is free because it reads a depth buffer the frame already drew; the mirror at tier 3 + // is a second scene draw, +0.49 ms on a Lake Everstill frame, and this renderer is bound on + // the main thread. So it ladders in at `Ultra` alone and the default stays 2. ours( "waterQuality", "2", "benilla's own: water quality, 0 Classic / 1 Enhanced (sky only) / 2 High (the scenery \ - reflects too)", + reflects too) / 3 Ultra (a planar mirror fills in what the screen cannot show)", ), ours( "lavaLightGain", @@ -2418,7 +2423,9 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ ("interiorShadowDynamic", "8"), ("exteriorShadows", "1"), ("spellLightGain", "1"), - ("waterQuality", "2"), + // GFX (planar water): the one rung the mirror turns on at — the only `waterQuality` + // step that costs a second scene draw, which is what `Ultra` is for. + ("waterQuality", "3"), ("volumetricFog", "2"), ("bloom", "2"), ("sunShafts", "1"), @@ -3569,9 +3576,12 @@ mod tests { } else { "1.0" } - } else if *name == "Ultra" && matches!(member, "waterQuality" | "volumetricFog") - { - "2" // MONKEY (presets): Ultra's two maxima over the defaults. + } else if *name == "Ultra" && member == "waterQuality" { + // GFX (planar water): Ultra is the only rung that buys the mirror, a + // second scene draw, so it alone goes a step past High's default of 2. + "3" + } else if *name == "Ultra" && member == "volumetricFog" { + "2" // MONKEY (presets): Ultra's maximum over the default. } else if member == "waterQuality" && matches!(*name, "Low" | "Medium") { // GFX (water): the first row that sits BELOW its default on the lower // rungs rather than at it. The scenery reflection measured free on the @@ -4115,7 +4125,10 @@ mod tests { ("-1", 0, 0.0), ("1.75", 1, 1.75), ("2", 2, 2.0), - ("9", 2, 4.0), + // GFX (planar water): 3 is Ultra, the planar mirror — the clamp tops out there now, + // where it used to top out at High. + ("3", 3, 3.0), + ("9", 3, 4.0), ] { apply(&mut app, "waterQuality", value); apply(&mut app, "lavaLightGain", value); diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index c821a1d9..f63b5fc6 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -701,7 +701,25 @@ pub(crate) mod schedule_tests { /// frame-late read trails by one frame and corrects itself (`WOW_AMBIGUITY_DUMP_POST=1` lists /// them). KNOWN FOLLOW-UP: the swim bob writes a rig's visual offset with no order against /// `rig_anim::compose`. - const POST_UPDATE_CEILING: usize = 466; + /// + /// GFX (planar water): +28 for `liquid::reflect::drive_mirror`, which places the mirror + /// camera here — after propagation, with the skybox and the billboards, because it must read + /// the eye's settled pose and be culled in the same frame it is placed. It writes that + /// camera's `GlobalTransform`, `Camera` and `Projection`, and every one of the 28 is against + /// a system reading one of those on OTHER entities: the billboard facings, the sky, sun, + /// moon, star and cloud followers, the particle and ribbon sims, the light-lane classifier, + /// the cluster builder, the 2D text layout. The mirror camera carries `WaterMirrorCamera` + /// and is queried `Without`; nothing else in the schedule names it, so these + /// pairs are structurally impossible rather than merely harmless, and no order between them + /// could decide anything. The one that was NOT — `VisibilitySystems::UpdateFrusta`, which + /// reads every active camera, this one included — is declared at the system rather than + /// absorbed here. Its sibling `UpdateDirectionalLightCascades` deliberately is not: declaring + /// THAT order moved 0.75% of a Stormwind canal frame with the lane switched off, which is + /// argued where it is dropped. + /// + /// Count this WITH an install (`WOW_DATA`): a data-gated system makes it 495 against 494 on + /// a machine with no client data, and `scripts/gates.sh` runs it both ways. + const POST_UPDATE_CEILING: usize = 495; const POST_UPDATE_SLACK: usize = 20; /// The actionable pairs in `Update`: conflicting access, no declared order, and nothing /// [`Classes`] explains, so the executor orders them however the graph falls. The count may @@ -865,7 +883,11 @@ pub(crate) mod schedule_tests { /// that pairs with everything it has no declared order against. The order can only decide /// which frame the line prints in, and `Added` still fires exactly once either /// way. - const UPDATE_ACTIONABLE_CEILING: usize = 4_973; + /// -21: `liquid::reflect::drive_mirror` left `Update` for `PostUpdate`, where placing a + /// camera belongs. It could never be registered here — `&mut Transform` on a camera is + /// shared with everything that moves anything — which is what kept the planar-water lane + /// behind an env var instead of a setting. + const UPDATE_ACTIONABLE_CEILING: usize = 4_952; const UPDATE_ACTIONABLE_SLACK: usize = 40; fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) { diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index cbd4e99c..eacc1d34 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -2578,10 +2578,14 @@ fn lamp_fog_dropdown_is_localised_and_live() { fn water_quality_writes_numeric_tiers_with_localised_labels() { benilla_formats::wow_data_or_skip!(); for (locale, labels, lava_label) in [ - ("enUS", ["Classic", "Enhanced", "High"], "Lava Glow"), + ( + "enUS", + ["Classic", "Enhanced", "High", "Ultra"], + "Lava Glow", + ), ( "ruRU", - ["Классическое", "Улучшенное", "Высокое"], + ["Классическое", "Улучшенное", "Высокое", "Ультра"], "Свечение лавы", ), ] { diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs index b0e55e03..5b8fd4ab 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -517,7 +517,8 @@ pub(crate) fn on_cvar( // Clamps are each row's own, stated beside it, exactly as for the reference rows above; // the `ours(...)` entries in `cvars::REGISTERED` carry the matching defaults, and // MONKEY (lampfog): the two atmospheric tiers bring the defaults weld to 34 pairs. - "waterquality" => cfg.water_quality = v.clamp(0.0, 2.0) as u8, + // GFX (planar water): 3 is Ultra, the planar mirror (`liquid::reflect::MIRROR_TIER`). + "waterquality" => cfg.water_quality = v.clamp(0.0, 3.0) as u8, // MONKEY (volumetric fog): constrain UI/console writes to supported tiers. "volumetricfog" => cfg.volumetric_fog = v.clamp(0.0, 2.0) as u8, // MONKEY (post): constrain UI/console writes to the supported bloom tiers. diff --git a/crates/benilla-assets/src/lib.rs b/crates/benilla-assets/src/lib.rs index 624b5e04..82276256 100644 --- a/crates/benilla-assets/src/lib.rs +++ b/crates/benilla-assets/src/lib.rs @@ -18,8 +18,7 @@ pub mod coords; pub mod materials; mod water_depth; pub use water_depth::{ - WaterColourImage, WaterDepthImage, WaterQuality, WaterReflection, WaterReflectionImage, - WaterUniform, + WaterColourImage, WaterDepthImage, WaterQuality, WaterReflectionImage, WaterUniform, }; pub mod minimap_grid; mod spatial_cache; diff --git a/crates/benilla-assets/src/shaders/enhanced_water.wgsl b/crates/benilla-assets/src/shaders/enhanced_water.wgsl index ba1b295a..cd76e1c6 100644 --- a/crates/benilla-assets/src/shaders/enhanced_water.wgsl +++ b/crates/benilla-assets/src/shaders/enhanced_water.wgsl @@ -810,7 +810,18 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec // `WaterFragment` does not have. That typo cost most of a session: the pipeline failed to // compile every run with `invalid field accessor`, the water fell back, and every A/B // measured a clean zero while the logs held the answer the whole time. - let base_uv = in.clip_position.xy / dims; + // V FLIPPED, and it is not a convention quibble. A true reflection is what a camera on + // the far side of the plane sees with its up vector reflected too — pointing DOWN — and + // that is an improper, handedness-flipping basis no `Transform` can hold. `reflect_plane` + // therefore builds the honest right-handed camera at the mirrored eye, which sees exactly + // the same surfaces through exactly the same frustum and stores the image upside down. + // Flipping V here is that difference and nothing else: it costs no winding flip, so + // backface culling stays correct, which is the reason to take it on this side. + let base_uv = vec2(in.clip_position.x, dims.y - in.clip_position.y) / dims; + // Applied in the FLIPPED space above, so its vertical sense is inverted against the + // screen. Left that way deliberately: at this amplitude the wave normal is symmetric + // noise and the sign is not visible, and guessing the correct deflection without a + // capture that can show the difference would be a change made blind. let ripple = vec2(n.x, n.z) * MIRROR_RIPPLE; let uv = clamp(base_uv + ripple, vec2(0.0), vec2(1.0)); // SAMPLED HERE, unconditionally, and masked below — never inside a per-fragment branch. diff --git a/crates/benilla-assets/src/water_depth.rs b/crates/benilla-assets/src/water_depth.rs index 78c66bd9..630baa2e 100644 --- a/crates/benilla-assets/src/water_depth.rs +++ b/crates/benilla-assets/src/water_depth.rs @@ -75,22 +75,6 @@ impl FromWorld for WaterDepthImage { } } -/// GFX (planar water): whether the mirror pass runs. -/// -/// **A dev lever (`WOW_WATER_MIRROR=1`), not a setting**, and deliberately not a CVar with a row -/// in Video Options: the pass is wired end to end and executes, but its oblique projection is -/// still wrong — the mirror comes back a radial smear rather than a view — so there is nothing -/// here a player should be able to switch on. It graduates to a CVar and a row, like every other -/// graphics lane, once a capture shows a reflection instead of a starburst. -#[derive(Resource, Clone, Copy, PartialEq, Eq)] -pub struct WaterReflection(pub bool); - -impl Default for WaterReflection { - fn default() -> Self { - Self(std::env::var_os("WOW_WATER_MIRROR").is_some()) - } -} - /// GFX (planar water): the mirrored view of the world, rendered by a second camera placed on the /// far side of the water plane (`benilla_world::liquid::reflect`). /// @@ -114,6 +98,12 @@ impl FromWorld for WaterReflectionImage { image.texture_descriptor.usage = TextureUsages::TEXTURE_BINDING | TextureUsages::RENDER_ATTACHMENT | TextureUsages::COPY_DST; + // `$WOW_MIRROR_DUMP=1` reads this target back to a PNG (`capture::save_mirror`), which + // needs COPY_SRC — and a readback usage the shipped build never exercises is not one to + // declare for everyone. + if std::env::var_os("WOW_MIRROR_DUMP").is_some() { + image.texture_descriptor.usage |= TextureUsages::COPY_SRC; + } // Filtered, unlike the depth and colour copies beside it: the reflection is sampled at a // rippled UV, so a nearest fetch would alias the ripple into stair steps. image.sampler = diff --git a/crates/benilla-formats/examples/where.rs b/crates/benilla-formats/examples/where.rs new file mode 100644 index 00000000..f8854b66 --- /dev/null +++ b/crates/benilla-formats/examples/where.rs @@ -0,0 +1,17 @@ +//! `cargo run -p benilla-formats --example where` — print the resolved vanilla `Data` directory, +//! or nothing at all when there is no install. +//! +//! `scripts/gates.sh`'s `resolve_wow` is the only caller. It exports the answer as `$WOW_DATA` for +//! the run, so the data-gated half of the suite can find the install the same way the client does +//! — and it folds the answer into the memo key, so a gates verdict stamped WITHOUT an install is +//! never reused once there is one. +//! +//! Restored after it went missing: `cargo run --example where` on an absent example prints its +//! error to stderr, which `resolve_wow` sends to `/dev/null`, and returns empty through `|| true`. +//! That reads exactly like "no install", so roughly 1,600 data-gated tests and the enforcer gate +//! skipped in silence and the run still said ALL GATES GREEN. +fn main() { + if let Some(data) = benilla_formats::wow_data() { + println!("{}", data.display()); + } +} diff --git a/crates/benilla-world/src/dev_state.rs b/crates/benilla-world/src/dev_state.rs index 5630dd92..f10cd62f 100644 --- a/crates/benilla-world/src/dev_state.rs +++ b/crates/benilla-world/src/dev_state.rs @@ -133,7 +133,9 @@ impl Default for LightingDebug { follow_server_time: clock.is_none(), manual_minute: clock.unwrap_or(720), // noon, only used when not following the server disable_fog: false, - disable_sky_dome: false, + // `WOW_NOSKYDOME=1` starts the dome and its cloud layer hidden, for a headless A/B + // that asks which surface a frame's artifact belongs to. + disable_sky_dome: std::env::var_os("WOW_NOSKYDOME").is_some(), } } } diff --git a/crates/benilla-world/src/liquid/reflect.rs b/crates/benilla-world/src/liquid/reflect.rs index c8112b71..8bd3e292 100644 --- a/crates/benilla-world/src/liquid/reflect.rs +++ b/crates/benilla-world/src/liquid/reflect.rs @@ -32,7 +32,7 @@ //! Closing that is multi-view support in `static_gx`, costed in `docs/GRAPHICS.md` rather than //! guessed at here. -use benilla_assets::{WaterReflection, WaterReflectionImage}; +use benilla_assets::{WaterQuality, WaterReflectionImage}; use bevy::camera::RenderTarget; use bevy::image::ToExtents; use bevy::math::Vec3A; @@ -51,6 +51,8 @@ pub struct MirrorState { pub active: bool, /// The world height the mirror was rendered about (WoW z, which is Bevy y). pub plane: f32, + /// The target size this frame's verdict asked for; `size_mirror_target` makes it so. + pub size: UVec2, } /// The mirror camera. Deliberately NOT a [`WorldCamera`]: that marker carries `StaticGxView` and a @@ -67,10 +69,17 @@ pub struct WaterMirrorCamera; /// the far bank. Clipping at the surface throws away everything between the mirrored eye and the /// water, which is exactly the submerged half nobody can see reflected. /// -/// Built around a FINITE far plane, which the infinite perspective Bevy ships does not have. The -/// oblique row makes depth an affine function of the distance to the clip plane, so with an -/// infinite far every distant fragment collapses onto one depth and the mirror z-fights with -/// itself; Lengyel's scaling preserves the far plane, and a far plane is needed for it to preserve. +/// Built around a finite far plane because the construction needs a frustum to contain, not +/// because anything is clipped at it: [`ObliqueMirror::oblique_row`] scales so that `z >= 0` +/// never bites, and culling keeps the ordinary far plane on its own +/// ([`ObliqueMirror::get_frustum_corners`]). +/// +/// What spreads depth across the buffer is the mirrored eye's DISTANCE BELOW THE CLIP PLANE, not +/// a far plane. `z/w = 1 - a * d / depth`, and along one ray `d/depth` moves only by that offset — +/// so a frame taken 26 yd above a lake uses nearly the whole range, and one taken ankle-deep would +/// use almost none of it. [`MIN_EYE_HEIGHT`] is far too small to guard that on its own (0.05 yd); +/// what actually floors it is [`SURFACE_CLEARANCE`], which lifts the clip plane a yard and so puts +/// the mirrored eye at least that far under it however shallowly the player is wading. #[derive(Debug, Clone)] struct ObliqueMirror { fov_y: f32, @@ -89,6 +98,30 @@ impl ObliqueMirror { // construction is written in. let forward = Mat4::perspective_rh(self.fov_y, self.aspect, self.near, self.far); let oblique = Self::oblique_row(forward, self.plane); + if trace_mirror() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + let inv = forward.inverse(); + let corner = Vec4::new( + if self.plane.x < 0.0 { -1.0 } else { 1.0 }, + if self.plane.y < 0.0 { -1.0 } else { 1.0 }, + 1.0, + 1.0, + ); + let q = inv * corner; + info!( + "mirror proj: fov {:.3} aspect {:.3} near {} far {} plane {:?} C.Q'={:.5} \ + row2={:?}", + self.fov_y, + self.aspect, + self.near, + self.far, + self.plane.to_array(), + self.plane.dot(q), + (forward.row(3) - oblique).to_array(), + ); + }); + } // Reverse it: `z_rev = w - z`, one row subtraction, which turns the preserved far plane // into 0 and the oblique near plane into w. let mut m = forward; @@ -100,24 +133,63 @@ impl ObliqueMirror { m } - /// Lengyel's replacement z row: the plane, scaled so the projection's own far plane survives. + /// Lengyel's replacement z row: the plane, scaled so that the clip volume still contains + /// everything the ordinary frustum held. + /// + /// The scale is the whole of the construction and it is where the first build went wrong. + /// Replacing the near plane with the water plane costs the FAR plane: `z >= 0` stops meaning + /// "nearer than [`crate::view::CAM_FAR`]" and starts meaning `depth >= a * d`, a plane through + /// the eye tilted by `a`. Lengyel's own scale sets `a` so that plane passes exactly through + /// the frustum's far corner — and the sky dome is pinned just inside the far plane, so it + /// straddles the boundary and gets sliced along its own tessellation. That is the radial + /// starburst, measured: with the row removed the mirror came back correct, and + /// `$WOW_MIRROR_TRACE` put `a` at 1.5621 against a frustum whose own supremum is 1/0.6488 = + /// 1.5413. + /// + /// So scale by the SUPREMUM of `d / depth` over the frustum instead, which is the same + /// quantity with the far plane's own term dropped — the corner taken at infinity rather than + /// at [`crate::view::CAM_FAR`]. Nothing the frustum keeps is ever cut by `z >= 0` again; the + /// far plane stops clipping entirely, which costs nothing, because culling still fits the + /// ordinary frustum ([`Self::get_frustum_corners`]) and the depth spread along a ray comes + /// from the eye's height over the water, not from a far plane (see [`Self::matrix`]). fn oblique_row(p: Mat4, plane: Vec4) -> Vec4 { - let inv = p.inverse(); - // The frustum corner furthest in the direction of the plane normal, in clip space. - let corner = Vec4::new( - if plane.x < 0.0 { -1.0 } else { 1.0 }, - if plane.y < 0.0 { -1.0 } else { 1.0 }, - 1.0, - 1.0, - ); - let q = inv * corner; - let denom = plane.dot(q); - if denom.abs() < 1e-6 { - // Degenerate (the plane passes through the corner): leave the row alone rather than - // divide by nothing and hand the rasterizer a matrix full of infinities. + let Some(sup) = Self::depth_ratio_sup(p, plane) else { + // Degenerate: the plane keeps no part of the frustum. Leave the row alone rather than + // divide by nothing and hand the rasterizer a matrix full of infinities; + // [`reflect_plane`] refuses the frame on the same test, so this is belt and braces. return p.row(2); - } - plane * (1.0 / denom) + }; + plane * (OBLIQUE_FAR_MARGIN / sup) + } + + /// `sup(d / depth)` over the frustum, with `d` the signed distance to `plane` — or `None` + /// when the plane keeps nothing, which is a mirror aimed below its own water. + /// + /// `d / depth = cx*(x/depth) + cy*(y/depth) - cz + cw/depth`, maximised at the frustum corner + /// the normal leans toward. `cw` is the eye's own signed distance to the plane and is always + /// NEGATIVE here (the mirrored eye is under the water by construction; [`reflect_plane`] + /// refuses otherwise), so dropping its term can only raise the supremum — which is the safe + /// direction, and is what takes the corner to infinity. + fn depth_ratio_sup(p: Mat4, plane: Vec4) -> Option { + // The frustum's half-extents per unit depth, read back off the projection: row 0 holds + // `cot(fov/2)/aspect` and row 1 `cot(fov/2)`. + let half_w = 1.0 / p.row(0).x; + let half_h = 1.0 / p.row(1).y; + let sup = plane.x.abs() * half_w + plane.y.abs() * half_h - plane.z; + (sup > 1e-4).then_some(sup) + } +} + +impl ObliqueMirror { + /// Does the kept half-space reach the frustum at all? + /// + /// It does not when the mirror is aimed steeply DOWN — the real eye craned up past about 22° + /// over this frustum — and then [`Self::oblique_row`]'s scale goes through zero and comes out + /// negative, which inverts depth and renders the world inside out. A mirror aimed below its + /// own water has nothing to show anyway: the frame it would reflect holds no water. + fn keeps_anything(&self) -> bool { + let p = Mat4::perspective_rh(self.fov_y, self.aspect, self.near, self.far); + Self::depth_ratio_sup(p, self.plane).is_some() } } @@ -168,6 +240,41 @@ fn trace_mirror() -> bool { *ON.get_or_init(|| std::env::var_os("WOW_MIRROR_TRACE").is_some()) } +/// The `waterQuality` tier the planar mirror lights up at. +/// +/// Its own rung above High rather than part of it, because unlike the rest of `waterQuality` it +/// is not free: it is a SECOND SCENE DRAW, and this renderer is bound on the main thread. It +/// measures at +0.49 ms on a Lake Everstill frame (2.87 → 3.36 mean over 600 frames) and inside +/// the noise on the Stormwind canal, so it is a rung a player opts into, and the `Ultra` preset +/// is where it is opted into by name. +pub const MIRROR_TIER: u8 = 3; + +/// How far ABOVE the water the clip plane is lifted, in yards. +/// +/// Without it the surface lies exactly ON its own clip plane, and that is fatal rather than +/// merely untidy. The oblique row makes depth mean "distance above the plane", so a fragment at +/// `d = 0` reads `z/w = 1` — the nearest value reverse-Z has — and beats every other surface in +/// the frame. The mesh is wave-displaced, so its triangles straddle the plane instead of lying in +/// it: each one is clipped to a sliver that keeps the winning depth, and the mirror comes back a +/// fan of them radiating across the whole view. That is the starburst this lane carried for two +/// sessions, measured at last by dumping the mirror's own target (`$WOW_MIRROR_DUMP`) — with the +/// plane lifted, the same frame renders the sky, the far ridge and the near cliff correctly. +/// +/// A yard, because the mesh rides up to 0.34 yd of swell above the heightfield +/// (`liquid::waves`) and the probed height answers for the surface under the EYE rather than +/// under each fragment, which `enhanced_water.wgsl` already puts at up to a yard. Over-lifting +/// costs a band of shoreline within a yard of the waterline, which a half-res rippled reflection +/// could not show anyway; under-lifting costs the entire frame. +const SURFACE_CLEARANCE: f32 = 1.0; + +/// How much slack the oblique far plane keeps beyond the frustum it has to contain. +/// +/// 1.0 is Lengyel's own scale and sits exactly ON the frustum's far corner, where float error +/// decides each vertex; the sky dome lives there. Two percent is enough to put the boundary +/// outside the frustum and costs two percent of the depth range, which this projection has in +/// abundance (see [`ObliqueMirror::oblique_row`]). +const OBLIQUE_FAR_MARGIN: f32 = 0.98; + /// How far above the plane the eye must sit before the mirror is worth rendering. Below it the eye /// is at or under the surface, where a planar reflection is meaningless and the mirrored camera /// would be inside the world. @@ -182,35 +289,67 @@ impl Plugin for WaterReflectPlugin { fn build(&self, app: &mut App) { // The TARGET and the state exist either way: every water material binds the image, and an // unbound texture would stall the bind group of every surface in the world. - app.init_resource::() - .init_resource::() + app.init_resource::() .init_resource::(); - // Off: register NOTHING, the same posture `static_gx::enabled` takes. A dormant system is - // not free here — `drive_mirror` declares `ResMut>`, which half the app - // touches, and simply registering it added 59 undeclared orders to `Update` and tripped - // the schedule ratchet. An experimental lane does not get to spend that. - if !app.world().resource::().0 { - return; - } - app.add_systems(Startup, spawn_mirror_camera) - // After the submersion verdict, which is what tells us the eye is above the water at - // all, and before the materials are written for the frame. - .add_systems(Update, drive_mirror.after(super::SubmersionVerdict)); + app + // [`crate::billboard::BillboardPlace`]: `PostUpdate`, after transform propagation and + // before visibility is checked — the same seat `skybox::follow_camera` takes, and for + // the same two reasons. It reads the world camera's `GlobalTransform` AFTER the frame + // settled it rather than a frame late, and the mirror camera it places is culled this + // frame rather than next. + // + // Not `Update`, which is where this lane sat while it was a dev lever and where it + // could not be registered at all: `&mut Transform` on a camera is shared with every + // system that moves anything, and merely declaring it put 59 undeclared orders into + // `Update` and tripped the schedule ratchet. (The comment that stood here blamed + // `ResMut>`; the ambiguity dump says otherwise — it was the transform, + // and dropping the asset handle changed the count by zero.) + .add_systems( + PostUpdate, + drive_mirror + .in_set(crate::billboard::BillboardPlace) + // Not merely an early return: the system must not RUN while the tier is off. + // Measured — with it running and returning on the first line, a Stormwind + // canal frame moved 0.75% of its pixels by up to 96/255 against the same + // build with the lane switched off, and the lake moved not at all. Nothing + // was drawn differently; holding `&mut Camera` and `&mut GlobalTransform` + // for a frame is enough to change which of the schedule's undeclared orders + // the executor picks, and the canal's clustered lamp light reads the + // difference. A setting someone has turned OFF does not get to move the + // frame, so the condition gates the whole system. + // + // `|| a camera still exists` so the frame the tier goes BACK to High still + // runs once, to despawn it. + .run_if(mirror_wanted) + // `UpdateFrusta` builds the frustum this camera culls against, and it READS + // the mirror camera, where the twenty-odd other pairs the ambiguity dump + // reports read other entities' `GlobalTransform` and can never see this one. + // Undeclared it would cull the reflection against LAST frame's pose, which on + // a moving camera pops at the edges of the mirror. + // + // `SimulationLightSystems::UpdateDirectionalLightCascades` reads it too — it + // fits the sun's cascades to every active camera — and is deliberately NOT + // declared. Measured: adding that one order moved 0.75% of a Stormwind canal + // frame by up to 96/255 WITH THE LANE SWITCHED OFF, because pulling the + // cascade fit later re-resolves undeclared orders the canal's clustered lamp + // light reads. A setting nobody turned on does not get to move the frame, and + // the thing bought by declaring it is a mirror whose shadow cascades are fitted + // to this frame's pose rather than the previous one — in a half-res reflection, + // read through a rippled normal, under an SSR march that paints over it. + .before(bevy::camera::visibility::VisibilitySystems::UpdateFrusta), + ); } } -fn spawn_mirror_camera( - mut commands: Commands, - target: Res, - enabled: Res, -) { - // Nothing at all when the lane is off. An inactive camera is not free: it is still an entity - // with a `Camera`, and every system that reaches for cameras WITHOUT a `WorldCamera` filter - // would start seeing two. Measured — spawning it dormant moved 10.1% of a lake frame with the - // pass switched off, which is exactly the kind of cost a disabled lane must not have. - if !enabled.0 { - return; - } +/// Spawn the mirror camera, the frame the tier first asks for one. +/// +/// On demand, never dormant. An inactive camera is NOT free: it is still an entity with a +/// `Camera`, and every system that reaches for cameras without a `WorldCamera` filter starts +/// seeing two. Measured — leaving it spawned but inactive moved 10.1% of a lake frame with the +/// pass switched off, which is exactly the cost a setting someone turned OFF must not have. So +/// the entity's existence tracks the setting, and [`drive_mirror`] is the one system that runs +/// either way, early-returning in the frames there is no camera to place. +fn spawn_mirror_camera(commands: &mut Commands, target: &WaterReflectionImage) { commands.spawn(( WaterMirrorCamera, Camera3d::default(), @@ -236,29 +375,81 @@ type MirrorQuery<'w, 's> = Query< 'w, 's, ( + Entity, &'static mut Camera, - &'static mut Transform, + &'static mut GlobalTransform, &'static mut Projection, ), (With, Without), >; +/// Size the reflection target to what this frame's verdict asked for. +/// +/// A plain function rather than a system, called from `scene_depth`'s own `Last` pass, which +/// already holds the image assets and this frame's [`MirrorState`] — so the resize costs no new +/// system and no new undeclared order. `Last` is also where the sibling depth and colour copies +/// are sized, and for the same reason: camera sizes are final by then. +/// +/// Reads through `get` FIRST and reaches for `get_mut` only when the size really moved: `get_mut` +/// marks the asset modified whether or not anything is written, and an image marked modified +/// every frame is re-created on the GPU every frame — which hands the camera one texture and the +/// water material another. +pub(super) fn size_mirror_target( + state: &MirrorState, + target: &WaterReflectionImage, + images: &mut Assets, +) { + if !state.active { + return; + } + let stale = images.get(&target.0).is_none_or(|image| { + image.texture_descriptor.size.width != state.size.x + || image.texture_descriptor.size.height != state.size.y + }); + if stale { + if let Some(image) = images.get_mut(&target.0) { + image.resize(state.size.to_extents()); + } + } +} + +/// Does [`drive_mirror`] have anything to do this frame? See its registration for why this is a +/// run condition and not a first line. +fn mirror_wanted(quality: Res, cameras: Query<(), With>) -> bool { + quality.0 >= MIRROR_TIER || !cameras.is_empty() +} + /// Mirror the eye about the plane, or stand the pass down. #[allow(clippy::too_many_arguments)] fn drive_mirror( - enabled: Res, + mut commands: Commands, + quality: Res, target: Res, - mut images: ResMut>, point: WorldPoint, world_cam: Query<(&GlobalTransform, &Projection, &Camera), With>, mut mirror: MirrorQuery, mut state: ResMut, mut live: Local, ) { - let Ok((mut cam, mut xf, mut proj)) = mirror.single_mut() else { + // The setting, live: `waterQuality` is a dropdown a player can move mid-frame, so the camera + // follows it here rather than at `Startup`. See [`spawn_mirror_camera`] for why it is the + // ENTITY that tracks the setting and not a flag on a camera that always exists. + let wanted = quality.0 >= MIRROR_TIER; + let Ok((entity, mut cam, mut gt, mut proj)) = mirror.single_mut() else { + if wanted { + spawn_mirror_camera(&mut commands, &target); + } return; }; - let verdict = reflect_plane(&enabled, &point, &world_cam); + if !wanted { + commands.entity(entity).despawn(); + *live = false; + if state.active { + *state = MirrorState::default(); + } + return; + } + let verdict = reflect_plane(&point, &world_cam); // `WOW_MIRROR_TRACE=1`: one line whenever the verdict changes. The pass has six ways to stand // down and from the outside they all look the same — a lake with no reflection in it. if trace_mirror() { @@ -270,9 +461,9 @@ fn drive_mirror( eye.x, eye.y, eye.z, size.x, size.y ), None => info!( - "mirror: OFF — enabled={} cams={} (no plane under the eye, or the eye is \ - at or under it)", - enabled.0, + "mirror: OFF — waterQuality={} cams={} (no plane under the eye, or the eye \ + is at or under it)", + quality.0, world_cam.iter().count() ), } @@ -288,29 +479,22 @@ fn drive_mirror( } return; }; - // The target follows the viewport; a resize mid-session must not leave a stale mirror. - // - // Read through `get` FIRST and reach for `get_mut` only when the size really moved: `get_mut` - // marks the asset modified whether or not anything is written, and an image marked modified - // every frame is re-created on the GPU every frame — which hands the camera one texture and - // the water material another. - let stale = images.get(&target.0).is_none_or(|image| { - image.texture_descriptor.size.width != size.x - || image.texture_descriptor.size.height != size.y - }); - if stale { - if let Some(image) = images.get_mut(&target.0) { - image.resize(size.to_extents()); - } - } let next = MirrorState { active: true, plane: height, + size, }; if *state != next { *state = next; } - *xf = Transform::from_translation(eye).looking_at(look, Vec3::Y); + // The GLOBAL transform only, never the local one. Propagation has already run this frame + // (see [`WaterReflectPlugin::build`]), so the global write is the one that renders — the move + // `skybox::follow_camera` makes — and leaving the local `Transform` untouched keeps it out of + // the access this system declares. That is worth real care here: `&mut Transform` in + // `PostUpdate` is shared with everything that places anything, and declaring it costs the + // schedule ratchet a dozen undeclared orders for a component nothing else on THIS entity + // reads. The camera has no children, so nothing propagates from the stale local. + *gt = GlobalTransform::from(Transform::from_translation(eye).looking_at(look, Vec3::Y)); *proj = projection; if !*live { cam.is_active = true; @@ -322,13 +506,9 @@ fn drive_mirror( /// /// Split out so the mirror law is testable without a camera, a target or a render app. fn reflect_plane( - enabled: &WaterReflection, point: &WorldPoint, world_cam: &Query<(&GlobalTransform, &Projection, &Camera), With>, ) -> Option<(Vec3, Vec3, Projection, f32, UVec2)> { - if !enabled.0 { - return None; - } let (gt, proj, cam) = world_cam.iter().next()?; if !cam.is_active { return None; @@ -374,13 +554,19 @@ fn reflect_plane( // for, and guessing a projection for it would be worse than declining. _ => return None, }; - let projection = Projection::custom(ObliqueMirror { + // Lift the clip plane clear of the surface it was measured from: see [`SURFACE_CLEARANCE`]. + let view_plane = view_plane - Vec4::new(0.0, 0.0, 0.0, SURFACE_CLEARANCE * len); + let mirror = ObliqueMirror { fov_y, aspect, near, far: crate::view::CAM_FAR, plane: view_plane / len, - }); + }; + if !mirror.keeps_anything() { + return None; + } + let projection = Projection::custom(mirror); Some(( mirror_eye, mirror_look, diff --git a/crates/benilla-world/src/liquid/scene_depth.rs b/crates/benilla-world/src/liquid/scene_depth.rs index db53d678..508afa2c 100644 --- a/crates/benilla-world/src/liquid/scene_depth.rs +++ b/crates/benilla-world/src/liquid/scene_depth.rs @@ -44,7 +44,7 @@ impl Plugin for WaterDepthPlugin { let value = std::env::var("WOW_WATER") .ok() .and_then(|v| v.parse::().ok()) - .filter(|v| *v <= 2); + .filter(|v| *v <= super::reflect::MIRROR_TIER); if let Some(value) = value { app.insert_resource(WaterQuality(value)); } @@ -101,6 +101,7 @@ fn update_water_depth( mut quality: ResMut, // GFX (planar water): this frame's mirror verdict. mirror_state: Res, + reflection: Res, override_value: Res, light: Res, ) { @@ -109,6 +110,9 @@ fn update_water_depth( quality.0 = value; } } + // GFX (planar water): the mirror's own target follows the viewport. Done here because this + // pass already holds both the image assets and the verdict — see [`reflect::size_mirror_target`]. + super::reflect::size_mirror_target(&mirror_state, &reflection, &mut images); // The dome's resolved, byte-quantized sky rows, converted to linear RGB. let linear = |row: [f32; 3]| { let c = benilla_assets::quant255(row).map(|v| { diff --git a/docs/GRAPHICS.md b/docs/GRAPHICS.md index cc85762a..a043926a 100644 --- a/docs/GRAPHICS.md +++ b/docs/GRAPHICS.md @@ -188,23 +188,65 @@ hold together (`REGISTERED`, `VideoConfig`, `WaterQuality::default`) plus the ru Medium keep Enhanced deliberately: free on the machine it was measured on is an argument for the default, not for the rung whose job is hardware that cannot afford what this one can. -**The planar mirror — built, wired, and NOT yet working.** `benilla_world::liquid::reflect` is a -second camera on the far side of the water plane, rendering into a half-viewport target that -`enhanced_water.wgsl` reads *beneath* the screen-space march — so SSR still wins wherever it finds -something, and the mirror only fills the hole where the ray leaves the screen. +**The planar mirror — working, and seated as `waterQuality` 3 (Ultra).** +`benilla_world::liquid::reflect` is a second camera on the far side of the water plane, rendering +into a half-viewport target that `enhanced_water.wgsl` reads *beneath* the screen-space march — so +SSR still wins wherever it finds something, and the mirror only fills the hole where the ray leaves +the screen. -Everything is in place and measured: with the lane off the water is **byte-identical** to before -it existed, and with it on it visibly changes 63% of a lake frame. What is wrong is the **oblique -near-plane projection**. The mirrored eye sits as far below the surface as the real eye sits above -it — 26 yd down on Lake Everstill — which is inside the lake bed, so the pass has to clip at the -water plane or it renders sand. The clip is implemented (Lengyel, adapted to this client's -reverse-Z) and it is wrong: the mirror comes back a radial smear. +It took three wrong answers to get there, and the instrument that ended it is worth more than the +feature: **`WOW_MIRROR_DUMP=1` writes the mirror's own render target beside the capture.** The +mirror is a second render of the world that nothing on screen shows directly — the water reads it +through a fresnel weight, a rippled UV, a plane-match band and an SSR march that paints over it — +so from the outside a broken mirror and a broken water shader look exactly alike. One look at the +target answered in one run what reasoning had got wrong twice. (Bevy's own `save_to_disk` cannot +write it: `try_into_dynamic` knows four 8-bit formats and the target is `Rgba16Float`, so it logs +and writes nothing.) -So it is a **dev lever, `WOW_WATER_MIRROR=1`, not a CVar and not a row in Options.** It graduates -to both, like every other lane here, once a capture shows a reflection instead of a starburst. The -next step is the projection and nothing else. +Three defects, in the order they were found: -Two things this cost, both worth not repeating: +- **The oblique row's scale.** Replacing the near plane with the water plane costs the FAR plane: + `z >= 0` stops meaning "nearer than `CAM_FAR`" and starts meaning `depth >= a*d`, a plane through + the eye tilted by `a`. Lengyel's own scale puts that plane exactly through the frustum's far + corner — and the sky dome is pinned just inside the far plane, so it straddles the boundary and + is sliced along its own tessellation. Scale by the supremum of `d/depth` over the frustum instead + (the same quantity with the far plane's term dropped), with 2% of margin. +- **The water surface lay ON its own clip plane.** This was the starburst, and it was fatal rather + than untidy: the oblique row makes depth mean "distance above the plane", so a fragment at `d=0` + reads `z/w = 1`, the nearest value reverse-Z has, and beats every other surface in the frame. The + mesh is wave-displaced, so its triangles straddle the plane rather than lying in it, and each is + clipped to a sliver that keeps the winning depth. Lift the clip plane a yard + (`SURFACE_CLEARANCE`): the swell sums to 0.34 yd and the probed height answers for the surface + under the eye rather than under each fragment. +- **The mirror was upside down.** A true reflection is what a camera on the far side sees with its + up vector reflected too — pointing down — which is an improper, handedness-flipping basis no + `Transform` can hold. The honest right-handed camera at the mirrored eye sees the same surfaces + through the same frustum and stores the image flipped, so the shader samples `1 - v`. That costs + no winding flip, so backface culling stays correct. It showed up as a lake reflecting the ridge + the RIGHT way up. + +**Cost, measured.** Lake Everstill 2.87 → 3.36 ms mean over 600 frames (+0.49, +17%); the Stormwind +canal inside the noise; CPU +0.2 to +1.1 ms. It is the first lane here that spends **main thread**, +which is why it is a rung of its own above High rather than part of it, and why the default stays 2. + +**Two things it could not be allowed to cost.** With the lane off, both scenarios are +**byte-identical** to the build before it — and getting there took two findings: + +- The system could not live in `Update` at all. `&mut Transform` on a camera is shared with + everything that moves anything; declaring it put 59 undeclared orders into `Update` and tripped + the schedule ratchet. It belongs in `PostUpdate` with the skybox and the billboards, after + propagation and before visibility — where it also reads the eye's settled pose instead of a + frame-old one. (The comment that blamed `ResMut>` for those 59 was wrong; dropping + the asset handle changed the count by zero.) +- **A system that runs and returns on its first line is still not free.** Holding `&mut Camera` and + `&mut GlobalTransform` for a frame is enough to change which undeclared orders the executor + picks: the canal moved 0.75% of its pixels by up to 96/255 with the lane switched off. So the + whole system sits behind a run condition, not an early return. The same effect killed a + `.before(UpdateDirectionalLightCascades)` order that was correct in itself — declaring it moved + the canal's lamp light with the feature off, and what it bought was a half-res rippled + reflection whose shadow cascades fit this frame's pose rather than last frame's. + +Three lessons, all of them about believing a lever moved: - **The pipeline failed to compile for most of a session and every A/B read a clean zero.** The field is `clip_position`, not `position`, and `WaterFragment` has no `position` — so naga @@ -214,10 +256,8 @@ Two things this cost, both worth not repeating: - **A disabled lane is not automatically free.** Changing SSR's base from `sky` to the fogged `reflection` moved 10.1% of a lake frame with the mirror switched off. The control that catches it is diffing the lane-off render against a `git stash`ed build, not against an older capture. - -The cost of the pass itself is unmeasured, because measuring a broken render would mean nothing. -It draws the scene a second time, so it is still the first item on this list that spends **main -thread**, and it gets priced against item 1's view-distance table before it is offered to anyone. +- **Look at the thing itself before reasoning about it.** The projection was blamed for a session + and was right all along in the case that mattered; the dump settled it in one run. ### 4. Froxel volumetrics