Planar water: the mirror works, and ladders in at waterQuality 3 (Ultra)
The starburst was three defects, and none of them was the oblique projection I blamed it on in the last commit. What found them was looking at the mirror's own render target instead of reasoning about the composited frame — 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 identical. WOW_MIRROR_DUMP=1 now writes that target beside the capture. (Bevy's own save_to_disk cannot: try_into_dynamic knows four 8-bit formats and answers UnsupportedFormat for Rgba16Float, logging and writing nothing.) - 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 becomes `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 straddled the boundary and was sliced along its own tessellation. Scale by the supremum of d/depth over the frustum instead, with 2% of margin. - The water surface lay ON its own clip plane. This was the starburst and it was fatal rather than untidy: depth now means "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 straddled the plane instead of lying in it, and each was clipped to a sliver that kept the winning depth. SURFACE_CLEARANCE lifts the plane a yard: the swell sums to 0.34 and the probed height answers for the surface under the eye, not this fragment. - The mirror was stored upside down. A true reflection is what a camera on the far side sees with its up vector reflected too — an improper basis no Transform can hold. The honest right-handed camera 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 read as a lake reflecting the ridge the RIGHT way up. Measured: Lake Everstill 2.87 -> 3.36 ms mean over 600 frames (+17%), the Stormwind canal inside the noise, CPU +0.2 to +1.1 ms. It is the first lane in docs/GRAPHICS.md that spends main thread, so it is a rung of its own above High and the default stays 2. Seated as a setting, per the standing rule that no graphics lane hides behind an env var: waterQuality gains tier 3, the Ultra preset takes it, and the Advanced Graphics dropdown gains a fourth entry in both locales. With the lane off, both scenarios are byte-identical to the build before it — which took two findings of its own: - 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, which is what kept this behind an env var. 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 blaming ResMut<Assets<Image>> 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 rather than an early return — and the same effect killed a .before(UpdateDirectionalLightCascades) order that was correct in itself. PostUpdate's ambiguity ceiling goes 466 -> 495 with the reason (the pairs are structurally impossible: nothing else in the schedule names the mirror camera); Update's ratchets down 4973 -> 4952. Also restores scripts/gates.sh's `where` example, which had gone missing. Its absence was silent — cargo's error went to /dev/null and `|| true` made it read as "no install" — so the memo key and the player-tests gate saw none even when WOW_DATA was set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -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" },
|
||||
})
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
|
||||
@@ -256,6 +256,76 @@ fn late_cvar_script() -> Option<String> {
|
||||
/// 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 `<out>.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<bool> = 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<ScreenshotCaptured>) {
|
||||
move |shot: On<ScreenshotCaptured>| {
|
||||
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<CaptureCtx>,
|
||||
mut watch: ResMut<FrameWatch>,
|
||||
@@ -809,8 +889,7 @@ fn drive_capture(
|
||||
mut time_strategy: ResMut<TimeUpdateStrategy>,
|
||||
// `None` on a glue-screen capture, which builds no composite lane.
|
||||
backdrop: Option<Res<crate::world_backdrop::WorldBackdrop>>,
|
||||
// `$WOW_CVAR_LATE`, written at first settle: see [`late_cvar_script`].
|
||||
mut cvars: ResMut<crate::cvars::Cvars>,
|
||||
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 <name>=<value>"),
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<WorldCamera>`; 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<ObjectStore>` 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) {
|
||||
|
||||
@@ -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",
|
||||
["Классическое", "Улучшенное", "Высокое"],
|
||||
["Классическое", "Улучшенное", "Высокое", "Ультра"],
|
||||
"Свечение лавы",
|
||||
),
|
||||
] {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -810,7 +810,18 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> 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<f32>(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<f32>(n.x, n.z) * MIRROR_RIPPLE;
|
||||
let uv = clamp(base_uv + ripple, vec2<f32>(0.0), vec2<f32>(1.0));
|
||||
// SAMPLED HERE, unconditionally, and masked below — never inside a per-fragment branch.
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<f32> {
|
||||
// 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::<WaterReflection>()
|
||||
.init_resource::<WaterReflectionImage>()
|
||||
app.init_resource::<WaterReflectionImage>()
|
||||
.init_resource::<MirrorState>();
|
||||
// Off: register NOTHING, the same posture `static_gx::enabled` takes. A dormant system is
|
||||
// not free here — `drive_mirror` declares `ResMut<Assets<Image>>`, 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::<WaterReflection>().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<Assets<Image>>`; 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<WaterReflectionImage>,
|
||||
enabled: Res<WaterReflection>,
|
||||
) {
|
||||
// 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<WaterMirrorCamera>, Without<WorldCamera>),
|
||||
>;
|
||||
|
||||
/// 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<Image>,
|
||||
) {
|
||||
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<WaterQuality>, cameras: Query<(), With<WaterMirrorCamera>>) -> 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<WaterReflection>,
|
||||
mut commands: Commands,
|
||||
quality: Res<WaterQuality>,
|
||||
target: Res<WaterReflectionImage>,
|
||||
mut images: ResMut<Assets<Image>>,
|
||||
point: WorldPoint,
|
||||
world_cam: Query<(&GlobalTransform, &Projection, &Camera), With<WorldCamera>>,
|
||||
mut mirror: MirrorQuery,
|
||||
mut state: ResMut<MirrorState>,
|
||||
mut live: Local<bool>,
|
||||
) {
|
||||
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<WorldCamera>>,
|
||||
) -> 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,
|
||||
|
||||
@@ -44,7 +44,7 @@ impl Plugin for WaterDepthPlugin {
|
||||
let value = std::env::var("WOW_WATER")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u8>().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<WaterQuality>,
|
||||
// GFX (planar water): this frame's mirror verdict.
|
||||
mirror_state: Res<super::reflect::MirrorState>,
|
||||
reflection: Res<benilla_assets::WaterReflectionImage>,
|
||||
override_value: Res<WaterOverride>,
|
||||
light: Res<crate::lighting::WowLighting>,
|
||||
) {
|
||||
@@ -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| {
|
||||
|
||||
+58
-18
@@ -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<Assets<Image>>` 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user