Planar water: the mirror pass, built and inert behind a dev lever
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 SSR cannot reach: the ray leaving the screen, and the top-edge fade, which is exactly where a shallow-angle lake wants its far bank. **It does not work yet, and it is not offered to anyone.** 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 must clip at the water plane or it renders sand. The clip is implemented (Lengyel, adapted to this client's reverse-Z, around a finite far plane because the oblique row makes depth affine and an infinite far collapses every distant fragment onto one value) and it is WRONG: the mirror comes back a radial smear. So this is `WOW_WATER_MIRROR=1`, a dev lever — no CVar, no row in Options. It graduates to both, like every other lane, once a capture shows a reflection instead of a starburst. What IS established, and is why this lands rather than waiting: - **With the lane off the water is byte-identical** to before the pass existed, verified against a `git stash`ed build of the same tree. - With it on it reaches the water and moves 63% of a lake frame — the plumbing, the binding, the uniform and the composite all carry. - Off, the plugin **registers nothing at all**, the posture `static_gx::enabled` takes. That is not tidiness: `drive_mirror` declares `ResMut<Assets<Image>>`, and merely registering it dormant added 59 undeclared orders to `Update` and tripped the schedule ratchet. Three things this cost, all of them now written into `docs/GRAPHICS.md` and all of them my own: 1. **The pipeline failed to compile for most of a session while every A/B read a clean zero.** The field is `clip_position`; `WaterFragment` has no `position`. Naga refused the shader, the water fell back to another path, and I measured the fallback over and over — including twice mistaking the lake bed seen through clear water for a working mirror. The client logs pipeline errors rather than panicking, by design, so the answer was in the log the whole time. **Grep the capture log for `error:` before believing any shader A/B.** 2. **A disabled lane is not automatically free.** Starting SSR's march from the fogged `reflection` instead of `sky` looked equivalent and moved 10.1% of a lake frame with the mirror switched off. The control that catches that is diffing lane-off against a stashed build — not against an older capture, which is what I reached for first. 3. **A flag colour has to be one the scene cannot produce.** My first three "every gate passes" measurements were counting the orange lake bed as my red flag, which sent the hunt in the wrong direction for an hour. The pass's own cost is deliberately unmeasured: it draws the scene a second time, so it is the first item on the programme that spends main thread, and pricing a broken render against item 1's view-distance table would mean nothing. That happens when the projection does. Verified: ALL GATES GREEN (591 s); `benilla-app --lib` against the install at the exact 269-failure baseline, set-identical by `comm`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<Image>,
|
||||
/// 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,
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(103) var scene_depth: texture_2d<f32>;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(106) var scene_colour: texture_2d<f32>;
|
||||
// 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<f32>;
|
||||
@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<f32>, // linear RGB, LightIntBand 2
|
||||
sky_horizon: vec4<f32>, // linear RGB, LightIntBand 6
|
||||
celestial: vec4<f32>, // 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<f32>,
|
||||
};
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(104) var<uniform> 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<f32>, deep: vec4<f32>) -> 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<f32>(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<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.
|
||||
// `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<f32>(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<f32>, deep: vec4<f32>) -> 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;
|
||||
|
||||
@@ -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<Image>);
|
||||
|
||||
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::<Assets<Image>>().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.
|
||||
|
||||
@@ -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::<LiquidMaterial>::default())
|
||||
// GFX (planar water): the mirror pass, off unless `waterReflection` asks.
|
||||
.add_plugins(reflect::WaterReflectPlugin)
|
||||
.init_resource::<benilla_assets::WaterQuality>()
|
||||
.init_resource::<benilla_assets::WaterDepthImage>()
|
||||
.init_resource::<benilla_assets::WaterColourImage>()
|
||||
|
||||
@@ -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<bool> = 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::<WaterReflection>()
|
||||
.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));
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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<WaterMirrorCamera>, Without<WorldCamera>),
|
||||
>;
|
||||
|
||||
/// Mirror the eye about the plane, or stand the pass down.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn drive_mirror(
|
||||
enabled: Res<WaterReflection>,
|
||||
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 {
|
||||
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<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;
|
||||
}
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,8 @@ fn update_water_depth(
|
||||
mut images: ResMut<Assets<Image>>,
|
||||
mut source: ResMut<DepthSource>,
|
||||
mut quality: ResMut<WaterQuality>,
|
||||
// GFX (planar water): this frame's mirror verdict.
|
||||
mirror_state: Res<super::reflect::MirrorState>,
|
||||
override_value: Res<WaterOverride>,
|
||||
light: Res<crate::lighting::WowLighting>,
|
||||
) {
|
||||
@@ -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 {
|
||||
|
||||
@@ -367,6 +367,8 @@ pub(super) fn setup_liquid(
|
||||
mut images: ResMut<Assets<Image>>,
|
||||
mut materials: ResMut<Assets<LiquidMaterial>>,
|
||||
water_depth: Res<benilla_assets::WaterDepthImage>,
|
||||
// GFX (planar water): the mirror target, bound on every water material.
|
||||
water_mirror: Res<benilla_assets::WaterReflectionImage>,
|
||||
water_colour: Res<benilla_assets::WaterColourImage>,
|
||||
water_quality: Res<benilla_assets::WaterQuality>,
|
||||
) {
|
||||
@@ -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.
|
||||
|
||||
+30
-6
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user