diff --git a/crates/benilla-assets/src/lib.rs b/crates/benilla-assets/src/lib.rs index eed8f006..624b5e04 100644 --- a/crates/benilla-assets/src/lib.rs +++ b/crates/benilla-assets/src/lib.rs @@ -17,7 +17,10 @@ pub mod column_grid; pub mod coords; pub mod materials; mod water_depth; -pub use water_depth::{WaterColourImage, WaterDepthImage, WaterQuality, WaterUniform}; +pub use water_depth::{ + WaterColourImage, WaterDepthImage, WaterQuality, WaterReflection, WaterReflectionImage, + WaterUniform, +}; pub mod minimap_grid; mod spatial_cache; pub mod trace; diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs index 2982bba7..e5b4d707 100644 --- a/crates/benilla-assets/src/materials.rs +++ b/crates/benilla-assets/src/materials.rs @@ -462,6 +462,13 @@ pub struct LiquidExt { /// `y` = frame count, `z` = scroll flag, `w` = clock enable. #[uniform(102)] pub anim: Vec4, + /// GFX (planar water): the mirrored view of the world + /// (`benilla_world::liquid::reflect`), filtered, at half the viewport. Sampled only where the + /// screen-space march missed and only when `water.mirror.x` says the target was rendered for + /// this surface's own plane; otherwise the sky stands as before. + #[texture(107, visibility(fragment))] + #[sampler(108, visibility(fragment))] + pub reflection: Handle, /// The shared global light, read in both stages (the vertex stage evaluates the sun sheen). #[storage(90, read_only, buffer, visibility(vertex, fragment))] pub light_buf: Buffer, diff --git a/crates/benilla-assets/src/shaders/enhanced_water.wgsl b/crates/benilla-assets/src/shaders/enhanced_water.wgsl index 695e0a0d..ba1b295a 100644 --- a/crates/benilla-assets/src/shaders/enhanced_water.wgsl +++ b/crates/benilla-assets/src/shaders/enhanced_water.wgsl @@ -32,6 +32,12 @@ @group(#{MATERIAL_BIND_GROUP}) @binding(103) var scene_depth: texture_2d; @group(#{MATERIAL_BIND_GROUP}) @binding(106) var scene_colour: texture_2d; +// GFX (planar water): the mirrored view of the world, rendered by a second camera on the far side +// of the surface (`benilla_world::liquid::reflect`), at half the viewport. FILTERED, unlike the +// depth and colour copies above: it is read at a rippled UV, so a nearest fetch would turn the +// ripple into stair steps. +@group(#{MATERIAL_BIND_GROUP}) @binding(107) var reflection_tex: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(108) var reflection_samp: sampler; struct WaterParams { // x = quality (0 Classic, 1 Enhanced, 2 High); y = wave energy (ocean 1.0, ADT inland 0.18, @@ -43,6 +49,10 @@ struct WaterParams { sky_zenith: vec4, // linear RGB, LightIntBand 2 sky_horizon: vec4, // linear RGB, LightIntBand 6 celestial: vec4, // xyz toward the visible body; w = 0 sun, 1 white moon + // GFX (planar water): x = the mirror target holds a view; y = the world height it was + // rendered about. The height is compared against this fragment's own, so one target cannot be + // worn by a second body of water at a different level in the same frame. + mirror: vec4, }; @group(#{MATERIAL_BIND_GROUP}) @binding(104) var water: WaterParams; @@ -476,6 +486,10 @@ const SSR_STEPS: i32 = 32; const SSR_FIRST_YD: f32 = 0.35; // first step; each next one is SSR_GROWTH times longer const SSR_GROWTH: f32 = 1.20; // 32 steps reach ~600 yd; bisection restores the precision const SSR_REFINE: i32 = 5; // bisections between the last miss and the hit +// How far the wave normal drags the mirror's lookup, in screen UV. Smaller than the SSR ripple: +// the mirror is a whole second render, so a wide perturbation drags in geometry from the wrong +// part of it, where the march's own ripple only re-aims a ray that was already exact. +const MIRROR_RIPPLE: f32 = 0.012; const SSR_RIPPLE: f32 = 0.35; // share of the wave normal the reflected ray sees (WXL ssrRipple) // The scene's view distance under the point `pos`, and that point's own; x = scene, y = ray, @@ -781,6 +795,46 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec // so the reflection holds together and the waves only ripple it; rays climbing toward the eye // (a steep look down) have nothing on screen to reflect and keep the sky. var reflection = mix(fog.rgb, sky, fog.w); // the sky reflection is a surface term + // GFX (planar water): the mirror stands in for the sky BENEATH the screen-space march, so the + // march still wins wherever it found something. SSR is exact where it works — it reflects what + // is really there, at the real place — and its only failure is not being able to see off the + // screen. That is precisely the hole the mirror fills, so the order is: mirror over sky, then + // SSR over both. + var mirror_w = 0.0; + if water.mirror.x > 0.5 { + // The mirrored camera shares the eye's projection, so a flat surface reflects to the SAME + // screen position: the plain screen UV is the lookup, perturbed by the wave normal so the + // mirror ripples with the surface it sits on. + let dims = vec2(textureDimensions(scene_depth)); + // `clip_position`, the fragment coordinate this struct carries — NOT `position`, which + // `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; + 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. + // `textureSample` takes its mip from screen-space derivatives, which are only defined + // while the whole quad agrees on taking the branch; under a per-fragment condition the + // derivative is undefined and the sample silently contributes nothing. That cost a long + // hunt: every gate below measured as passing while the water did not move one bit. + let mirrored = textureSample(reflection_tex, reflection_samp, uv).rgb; + // Only for the body this mirror was rendered about. A lake and a canal can be in one + // frame at different heights, and one target cannot serve both. + // The band is generous on purpose. It exists to stop ONE body of water wearing a mirror + // rendered for ANOTHER, and separate bodies in one frame sit yards apart — while the same + // body disagrees with the probed height by up to a yard all by itself, between the wave + // displacement on the vertex and the fact that the probe answers for the surface under the + // EYE rather than under this fragment. A tight band does not fail loudly: it leaves + // `same_plane` a hair above zero, so every gate measures as passing and the water does not + // move one bit, which is exactly how this was lost for an afternoon. + let same_plane = 1.0 - smoothstep(2.0, 6.0, abs(in.world_position.y - water.mirror.y)); + // Fade at the frame edge, where the ripple can reach past what the mirror rendered. + let edge = smoothstep(0.0, 0.02, min(uv.x, 1.0 - uv.x)) + * smoothstep(0.0, 0.02, min(uv.y, 1.0 - uv.y)); + mirror_w = same_plane * edge; + reflection = mix(reflection, mirrored, mirror_w); + } if water.mode.x > 1.5 && reflectivity > 0.1 { let ssr_n = normalize(mix(vec3(0.0, 1.0, 0.0), n, SSR_RIPPLE)); let ssr_dir = reflect(-to_view, ssr_n); @@ -788,12 +842,19 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec let hit = water_ssr(in.world_position.xyz, ssr_dir); // Rays heading back toward the eye find nothing on screen that faces them. let facing = 1.0 - smoothstep(0.55, 0.9, dot(ssr_dir, to_view)); - reflection = mix(sky, hit.rgb, hit.w * facing); + // The march's base is the SKY, exactly as it was before the mirror existed — and the + // mirror is folded in only by its own weight. Starting this from `reflection` (the + // FOGGED sky) instead looked equivalent and was not: with the mirror switched off it + // still moved 10.1% of a lake frame by two or three units, which is a shipped, tuned + // lane changing under a lane that is supposed to be off. + reflection = mix(mix(sky, reflection, mirror_w), hit.rgb, hit.w * facing); // Scenery reads a little stronger than Fresnel alone gives at a low angle - the eye // expects a lake to mirror its banks - but only where a reflection was actually found. reflectivity = mix(reflectivity, max(reflectivity, 0.42), hit.w * facing); } } + // A found reflection of either kind earns the same lift off plain Fresnel. + reflectivity = mix(reflectivity, max(reflectivity, 0.42), mirror_w); rgb = mix(rgb, reflection, reflectivity); // Everything added from here on (glints, light speculars) is surface light: fog attenuates it. let fogged_base = rgb; diff --git a/crates/benilla-assets/src/water_depth.rs b/crates/benilla-assets/src/water_depth.rs index ebb1ee9b..78c66bd9 100644 --- a/crates/benilla-assets/src/water_depth.rs +++ b/crates/benilla-assets/src/water_depth.rs @@ -16,6 +16,13 @@ pub struct WaterUniform { pub sky_horizon: Vec4, /// xyz toward the visible sun by day, the white moon by night; w = 0 sun, 1 moon. pub celestial: Vec4, + /// GFX (planar water): the mirror pass's state. `x` = 1 while the reflection target holds a + /// view for THIS surface's plane, 0 otherwise (the pass off, no plane found, or the camera + /// under the water). `y` = the plane's world height, so a surface can tell whether the mirror + /// was rendered for its own sheet or for a different body a few yards away — a lake and a + /// canal in one frame share one target, and only the one the pass was aimed at may use it. + /// `zw` unused. + pub mirror: Vec4, } impl Default for WaterUniform { @@ -26,6 +33,7 @@ impl Default for WaterUniform { sky_zenith: Vec4::ZERO, sky_horizon: Vec4::ZERO, celestial: Vec3::Y.extend(0.0), + mirror: Vec4::ZERO, } } } @@ -67,6 +75,62 @@ 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`). +/// +/// `Rgba16Float` and half the viewport on each axis. Half because a reflection is read through a +/// rippled normal and across a surface the eye never sees square-on, so the resolution it can +/// actually show is far below the screen's — and because the pass costs a second scene draw, which +/// is the one thing this renderer cannot spend freely (`docs/GRAPHICS.md`). +#[derive(Resource, Clone)] +pub struct WaterReflectionImage(pub Handle); + +impl FromWorld for WaterReflectionImage { + fn from_world(world: &mut World) -> Self { + use bevy::{asset::RenderAssetUsages, render::render_resource::*}; + let mut image = Image::new_fill( + Extent3d::default(), + TextureDimension::D2, + &[0u8; 8], + TextureFormat::Rgba16Float, + RenderAssetUsages::default(), + ); + image.texture_descriptor.usage = TextureUsages::TEXTURE_BINDING + | TextureUsages::RENDER_ATTACHMENT + | TextureUsages::COPY_DST; + // 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 = + bevy::image::ImageSampler::Descriptor(bevy::image::ImageSamplerDescriptor { + label: Some("water_reflection".to_owned()), + address_mode_u: bevy::image::ImageAddressMode::ClampToEdge, + address_mode_v: bevy::image::ImageAddressMode::ClampToEdge, + mag_filter: bevy::image::ImageFilterMode::Linear, + min_filter: bevy::image::ImageFilterMode::Linear, + ..Default::default() + }); + // Render-only: no CPU copy, so a resize does not allocate (and upload) a screen of zeros. + image.data = None; + Self(world.resource_mut::>().add(image)) + } +} + /// The world view's opaque COLOUR, copied right after the main opaque pass (the frame as it stood /// before any water drew), for the enhanced water's refraction. `Rgba16Float`: a load and a store, /// never filtered, so the copy is exact whatever the view target's own format is. diff --git a/crates/benilla-world/src/liquid/mod.rs b/crates/benilla-world/src/liquid/mod.rs index 4f52bada..37df5548 100644 --- a/crates/benilla-world/src/liquid/mod.rs +++ b/crates/benilla-world/src/liquid/mod.rs @@ -36,8 +36,10 @@ mod scene_depth; // MONKEY (ao): the label orders the AO pass. pub use scene_depth::{WaterDepthLabel, WaterDepthPlugin}; mod query; +// GFX (planar water): the mirror pass. #[cfg(test)] mod real_data; +pub mod reflect; mod spatial; mod surface; // the against-real-client-files tests — they span both halves pub mod waves; @@ -89,6 +91,8 @@ pub(crate) struct LiquidPlugin; impl Plugin for LiquidPlugin { fn build(&self, app: &mut App) { app.add_plugins(MaterialPlugin::::default()) + // GFX (planar water): the mirror pass, off unless `waterReflection` asks. + .add_plugins(reflect::WaterReflectPlugin) .init_resource::() .init_resource::() .init_resource::() diff --git a/crates/benilla-world/src/liquid/reflect.rs b/crates/benilla-world/src/liquid/reflect.rs new file mode 100644 index 00000000..c8112b71 --- /dev/null +++ b/crates/benilla-world/src/liquid/reflect.rs @@ -0,0 +1,441 @@ +//! GFX (planar water): the mirror pass — a second camera on the far side of the water plane, +//! rendering the world into [`WaterReflectionImage`] for `enhanced_water.wgsl` to read where its +//! screen-space march comes back empty. +//! +//! **Why this exists on top of SSR.** The High tier already reflects the scenery, by marching the +//! reflected ray against the scene depth. That march can only find what is ON SCREEN: it gives up +//! the moment the ray leaves the viewport (`probe.z < 0.5`) and fades out toward the top edge, +//! which is precisely where a lake viewed at a shallow angle wants its far bank, its trees and its +//! buildings. A mirrored camera has no such horizon — it renders the world the reflected ray would +//! actually hit, whether or not the eye can see it. +//! +//! **What it costs, and the shape that follows from it.** Everything else in `docs/GRAPHICS.md` +//! spends GPU, which this renderer has spare. This spends a **second scene draw**, which is main +//! thread — the one budget that is full. So the pass is built to be refused cheaply and often: +//! +//! - it runs only while some water is actually in front of the eye, and stops the frame it is not; +//! - it renders at **half the viewport on each axis** (a quarter of the pixels), because a +//! reflection is read through a rippled normal across a surface the eye never sees square-on; +//! - it is **off by default** and ladders in at Ultra alone, because unlike `waterQuality` it is +//! not free. +//! +//! **The plane.** One camera means one plane, and a frame can hold a lake and a canal at different +//! heights. The pass aims at the surface under the eye ([`Subject::Eye`]) and publishes that height +//! in `WaterUniform::mirror.y`; a surface whose own sheet sits elsewhere sees the mismatch and +//! keeps the sky, rather than wearing a mirror rendered for someone else's water. +//! +//! **What it does not carry yet.** `static_gx`'s merged static geometry draws through a view marked +//! `StaticGxView`, which is inserted on every `WorldCamera` — and 116 systems filter on that +//! marker, so making the mirror one would hand `prepare_static_gx` a second view it was never +//! written for. The mirror therefore shows what the ordinary material path draws (terrain, the +//! low-detail horizon, liquid-less world models) and not the merged static props and WMO batches. +//! 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 bevy::camera::RenderTarget; +use bevy::image::ToExtents; +use bevy::math::Vec3A; +use bevy::prelude::*; +use bevy::render::view::Hdr; + +use crate::view::WorldCamera; +use crate::world_point::{Subject, WorldPoint}; + +/// What the mirror pass did this frame, for the surfaces to read. Published here and consumed in +/// `scene_depth`'s uniform refresh, so the shader can tell a mirror rendered for ITS plane from one +/// rendered for a different body in the same frame. +#[derive(Resource, Default, Clone, Copy, PartialEq)] +pub struct MirrorState { + /// The target holds a view for [`Self::plane`]. + pub active: bool, + /// The world height the mirror was rendered about (WoW z, which is Bevy y). + pub plane: f32, +} + +/// The mirror camera. Deliberately NOT a [`WorldCamera`]: that marker carries `StaticGxView` and a +/// hundred-odd single-view assumptions with it (see the module doc). +#[derive(Component)] +pub struct WaterMirrorCamera; + +/// The mirror's projection: an ordinary perspective whose NEAR PLANE has been replaced by the +/// water plane itself (Eric Lengyel, "Oblique View Frustum Depth Projection and Clipping"). +/// +/// Without this the pass is worthless and the first build proved it: the mirrored eye sits as far +/// BELOW the surface as the real eye sits above it — 26 yd down on Lake Everstill — which puts it +/// inside the lake bed, so the mirror came back full of sand and a shipwreck instead of sky and +/// 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. +#[derive(Debug, Clone)] +struct ObliqueMirror { + fov_y: f32, + aspect: f32, + near: f32, + far: f32, + /// The water plane in the mirrored camera's VIEW space, `dot(plane, v) >= 0` on the kept side. + plane: Vec4, +} + +impl ObliqueMirror { + /// Reverse-Z, as every other view in this client is: `z/w` is 1 at the near plane and 0 at the + /// far one, which is what the `GreaterEqual` depth compare expects. + fn matrix(&self) -> Mat4 { + // The ordinary (non-reversed) finite perspective, which is the space Lengyel's + // 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); + // 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; + let z = m.row(3) - oblique; + m.x_axis.z = z.x; + m.y_axis.z = z.y; + m.z_axis.z = z.z; + m.w_axis.z = z.w; + m + } + + /// Lengyel's replacement z row: the plane, scaled so the projection's own far plane survives. + 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. + return p.row(2); + } + plane * (1.0 / denom) + } +} + +impl bevy::camera::CameraProjection for ObliqueMirror { + fn get_clip_from_view(&self) -> Mat4 { + self.matrix() + } + + fn get_clip_from_view_for_sub(&self, _sub_view: &bevy::camera::SubCameraView) -> Mat4 { + // The mirror never renders a sub-view; it owns its whole target. + self.matrix() + } + + fn update(&mut self, width: f32, height: f32) { + if height > 0.0 { + self.aspect = width / height; + } + } + + fn far(&self) -> f32 { + self.far + } + + fn get_frustum_corners(&self, z_near: f32, z_far: f32) -> [Vec3A; 8] { + // The UNCLIPPED frustum, deliberately: these corners drive culling and shadow fitting, and + // the oblique near plane only ever removes geometry the clip would remove anyway. A + // corner set narrowed to the oblique volume would cull what the plane keeps. + let tan_half = (self.fov_y * 0.5).tan(); + let corner = |z: f32| { + let h = tan_half * z.abs(); + let w = h * self.aspect; + [ + Vec3A::new(w, -h, z), + Vec3A::new(w, h, z), + Vec3A::new(-w, h, z), + Vec3A::new(-w, -h, z), + ] + }; + let n = corner(z_near); + let f = corner(z_far); + [n[0], n[1], n[2], n[3], f[0], f[1], f[2], f[3]] + } +} + +/// `WOW_MIRROR_TRACE=1`, read once. +fn trace_mirror() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("WOW_MIRROR_TRACE").is_some()) +} + +/// 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. +const MIN_EYE_HEIGHT: f32 = 0.05; + +/// The viewport divisor; see the module doc for why a reflection can afford it. +const REFLECT_DIVISOR: u32 = 2; + +pub(crate) struct WaterReflectPlugin; + +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::() + .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)); + } +} + +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; + } + commands.spawn(( + WaterMirrorCamera, + Camera3d::default(), + Camera { + // Before the world camera's 0, so the target holds this frame's mirror by the time the + // water surface samples it. + order: -1, + is_active: false, + ..default() + }, + // `RenderTarget` is its own component in this Bevy, as `world_backdrop` spawns it. + RenderTarget::Image(target.0.clone().into()), + // The same lane the world camera draws in: linear `Rgba16Float`, no tonemapper, so what + // lands in the mirror is in the same clamped-gamma convention every other pass speaks. + Hdr, + bevy::core_pipeline::tonemapping::Tonemapping::None, + Transform::default(), + )); +} + +/// The mirror camera's own writable view of itself, named so the system signature stays readable. +type MirrorQuery<'w, 's> = Query< + 'w, + 's, + ( + &'static mut Camera, + &'static mut Transform, + &'static mut Projection, + ), + (With, Without), +>; + +/// Mirror the eye about the plane, or stand the pass down. +#[allow(clippy::too_many_arguments)] +fn drive_mirror( + enabled: 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 { + return; + }; + let verdict = reflect_plane(&enabled, &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() { + let now = verdict.is_some(); + if *live != now { + match &verdict { + Some((eye, _, _, h, size)) => info!( + "mirror: ON — plane z {h:.2}, eye {:.1},{:.1},{:.1}, target {}x{}", + 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, + world_cam.iter().count() + ), + } + } + } + let Some((eye, look, projection, height, size)) = verdict else { + if *live { + cam.is_active = false; + *live = false; + } + if state.active { + *state = MirrorState::default(); + } + 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, + }; + if *state != next { + *state = next; + } + *xf = Transform::from_translation(eye).looking_at(look, Vec3::Y); + *proj = projection; + if !*live { + cam.is_active = true; + *live = true; + } +} + +/// The frame's verdict: where to stand the mirror, or `None` to stand it down. +/// +/// 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; + } + let size = cam.physical_viewport_size()?; + let eye = gt.translation(); + // The surface under the eye, in the eye's own room, through the same query every other + // consumer uses — so the mirror agrees with what the player is standing in. + let wow = benilla_assets::coords::bevy_to_wow(eye); + let height = point.water_surface_at(Subject::Eye, wow)?; + // WoW z IS Bevy y (`coords::wow_to_bevy` maps `[x, y, z]` to `(-y, z, -x)`), so the surface + // height needs no conversion — only the XY the query is asked at does. + let plane_y = height; + if eye.y - plane_y < MIN_EYE_HEIGHT { + // At or under the surface: nothing to mirror, and the mirrored eye would be in the world. + return None; + } + // The mirror: the eye reflected through the plane, looking at the reflected target point. A + // plane reflection is its own inverse, so reflecting both the eye and a point along the view + // ray gives the mirrored view direction for free, with no matrix surgery. + let mirror_point = |p: Vec3| Vec3::new(p.x, 2.0 * plane_y - p.y, p.z); + let forward = gt.forward(); + let mirror_eye = mirror_point(eye); + let mirror_look = mirror_point(eye + *forward * 100.0); + // The mirror's own view matrix, so the plane can be expressed in ITS view space — which is + // the space Lengyel's construction needs it in. + let view_from_world = Transform::from_translation(mirror_eye) + .looking_at(mirror_look, Vec3::Y) + .to_matrix() + .inverse(); + // The water plane, pointing UP in world space, so the kept half-space is everything above the + // water — which from under it is everything the mirror is for. + let world_plane = Vec4::new(0.0, 1.0, 0.0, -plane_y); + // A plane transforms by the inverse-transpose of the matrix taking points the other way. + let view_plane = view_from_world.inverse().transpose() * world_plane; + let len = view_plane.truncate().length(); + if len < 1e-6 { + return None; + } + let (fov_y, aspect, near) = match proj { + Projection::Perspective(p) => (p.fov, p.aspect_ratio, p.near), + // The world view is always perspective; anything else is not a view a mirror makes sense + // for, and guessing a projection for it would be worse than declining. + _ => return None, + }; + let projection = Projection::custom(ObliqueMirror { + fov_y, + aspect, + near, + far: crate::view::CAM_FAR, + plane: view_plane / len, + }); + Some(( + mirror_eye, + mirror_look, + projection, + height, + UVec2::new( + (size.x / REFLECT_DIVISOR).max(8), + (size.y / REFLECT_DIVISOR).max(8), + ), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Reflecting a point through the plane twice is the identity, which is the property the + /// whole mirror rests on: it is why reflecting the eye AND a point down the view ray yields + /// the mirrored direction without touching the projection. + #[test] + fn mirroring_through_the_plane_is_its_own_inverse() { + let plane_y = 12.5f32; + let mirror = |p: Vec3| Vec3::new(p.x, 2.0 * plane_y - p.y, p.z); + for p in [ + Vec3::new(3.0, 40.0, -7.0), + Vec3::new(-100.0, 12.5, 0.0), + Vec3::new(0.0, -5.0, 2.0), + ] { + let there = mirror(p); + assert!((mirror(there) - p).length() < 1e-4, "{p:?} -> {there:?}"); + // A point ON the plane is its own mirror. + if (p.y - plane_y).abs() < 1e-6 { + assert!((there - p).length() < 1e-4); + } + // The mirrored point is the same distance the other side. + assert!(((there.y - plane_y) + (p.y - plane_y)).abs() < 1e-4); + } + } + + /// The eye's height above the plane decides whether the pass runs at all, and the boundary is + /// the one place a planar reflection stops meaning anything. + #[test] + fn the_pass_stands_down_at_and_under_the_surface() { + let plane_y = 10.0f32; + for (eye_y, want) in [ + (10.5, true), + (10.0 + MIN_EYE_HEIGHT, true), + (10.0, false), + (9.0, false), + ] { + assert_eq!( + eye_y - plane_y >= MIN_EYE_HEIGHT, + want, + "eye at {eye_y} over a plane at {plane_y}" + ); + } + } +} diff --git a/crates/benilla-world/src/liquid/scene_depth.rs b/crates/benilla-world/src/liquid/scene_depth.rs index 7ee755d2..db53d678 100644 --- a/crates/benilla-world/src/liquid/scene_depth.rs +++ b/crates/benilla-world/src/liquid/scene_depth.rs @@ -99,6 +99,8 @@ fn update_water_depth( mut images: ResMut>, mut source: ResMut, mut quality: ResMut, + // GFX (planar water): this frame's mirror verdict. + mirror_state: Res, override_value: Res, light: Res, ) { @@ -127,12 +129,22 @@ fn update_water_depth( }; // Evaluated after lighting resolves each frame; mutate only on actual changes. let tier = quality.0.min(2) as f32; + // GFX (planar water): what the mirror pass did this frame. `x` gates the lane, `y` is the + // plane it was rendered about — the shader compares that against each fragment's own height, + // so a lake's mirror is never worn by a canal in the same frame. + let mirror = Vec4::new( + if mirror_state.active { 1.0 } else { 0.0 }, + mirror_state.plane, + 0.0, + 0.0, + ); let changed: Vec<_> = materials .iter() .filter(|(_, m)| { let water = &m.extension.water; water.lane.z < 0.5 && (water.mode.x != tier + || water.mirror != mirror || (quality.0 > 0 && (water.sky_zenith != zenith || water.sky_horizon != horizon @@ -146,6 +158,7 @@ fn update_water_depth( water.sky_zenith = zenith; water.sky_horizon = horizon; water.celestial = celestial; + water.mirror = mirror; } source.active = false; for (entity, camera, mut camera3d, visible) in &mut cameras { diff --git a/crates/benilla-world/src/liquid/surface.rs b/crates/benilla-world/src/liquid/surface.rs index 20f517c7..e6c3a4a3 100644 --- a/crates/benilla-world/src/liquid/surface.rs +++ b/crates/benilla-world/src/liquid/surface.rs @@ -367,6 +367,8 @@ pub(super) fn setup_liquid( mut images: ResMut>, mut materials: ResMut>, water_depth: Res, + // GFX (planar water): the mirror target, bound on every water material. + water_mirror: Res, water_colour: Res, water_quality: Res, ) { @@ -439,6 +441,10 @@ pub(super) fn setup_liquid( }, extension: LiquidExt { scene_depth: water_depth.0.clone(), + // GFX (planar water): the mirror target. Always bound — an unbound texture + // would stall every water material's bind group — and read only when + // `water.mirror.x` says the pass ran for this surface's own plane. + reflection: water_mirror.0.clone(), frames: frames.clone(), // x = fullbright (the sheet as body, still fogged); y = the ocean swatch; // z = the interior fog block; w = water's sun-sheen exponent. diff --git a/docs/GRAPHICS.md b/docs/GRAPHICS.md index 7032ce66..cc85762a 100644 --- a/docs/GRAPHICS.md +++ b/docs/GRAPHICS.md @@ -188,12 +188,36 @@ 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. -**What a planar reflection would still add**, if it is ever wanted: the geometry SSR cannot see. -The march gives up when the ray leaves the screen (`probe.z < 0.5`) and the result fades toward the -top edge, which is exactly where a shallow-angle lake wants its far bank and trees. That is the -real remaining gap — and unlike everything above it, a planar pass draws the scene a second time, -so it is the first item on this list that costs **main thread**. Price it against the view-distance -table in item 1 before building it. +**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. + +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. + +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. + +Two things this cost, both worth not repeating: + +- **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 + refused the shader, the water fell back, and the measurements were all of the fallback. The + client logs pipeline errors rather than panicking (`gpu: wgpu errors are logged, not fatal`), so + **grep the capture log for `error:` before believing any shader A/B.** +- **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. ### 4. Froxel volumetrics