diff --git a/WATER.md b/WATER.md
index fdf94e02..1ce7a795 100644
--- a/WATER.md
+++ b/WATER.md
@@ -33,7 +33,7 @@ fork. Do not remove any of them.
| Shore surf limited by the TERRAIN column, so objects in the water never foam | shipped (authored depth gate, `on_bed`) | principle from `sea/Shore.hpp` |
| Per-channel extinction + scene-copy refraction | shipped (`enhanced_water.wgsl` DEPTH LOOK; bent samples landing on anything in front of the water are refused via the scene depth) | ported from `shaders/Surface.ps.hlsl`, `render/Refraction.cpp` |
| Caustics, two layers at incommensurate scales/rates | shipped (floor web; built from animated cell edges, not value noise) | ported from `shaders/Surface.ps.hlsl`, `render/Noise.cpp` |
-| Screen-space reflection of the scenery | shipped on `High` (a bisected march against the scene depth, not WXL's single probe) | principle from `shaders/Surface.ps.hlsl` |
+| Screen-space reflection of the scenery | shipped on `High` with Water Reflections = Screen Space, and as the planar mirror's fallback (a bisected march against the scene depth, not WXL's single probe; a bisected hit still deep behind the depth is a ray passing behind an occluder and marches on) | principle from `shaders/Surface.ps.hlsl` |
| Gerstner trains + breaker index, crest-fold foam | planned | `sea/Spectrum.*`, `sea/Shore.hpp`, `shaders/Wave.hlsli` |
| Specular lobe widened by the normal's screen derivative, distance glints | shipped / planned | `shaders/Surface.ps.hlsl` |
@@ -42,6 +42,7 @@ fork. Do not remove any of them.
| Lane | What you see | Main switch |
|---|---|---|
| Quality tiers | `Classic` = the reference water, `Enhanced`, `High` (= Enhanced + scenery reflections) | Video options → Water Quality, cvar `waterQuality`, env `WOW_WATER=0\|1\|2` |
+| Planar reflections | High only: the scene drawn a second time from the eye mirrored in the water plane the view looks at (half resolution, clipped at the plane by an oblique near plane, no water, no sun shadow cascades), so docks reflect their undersides and anything above the frame reflects; water at another height falls back to the screen-space march. About +1 ms CPU per frame, GPU about even (the march is skipped where the mirror covers) | Advanced Graphics → Water Reflections (Screen Space / Planar; Planar on the High and Ultra presets), cvar `waterReflections`, env `WOW_WATER_REFLECT=0\|1` |
| Procedural waves | multi-band analytic waves with exact normals; the long swell moves ocean vertices | tier |
| Open-sea whitecaps | High adds Gerstner crest gathering and fold-thresholded foam; calm canals remain clear. The fold mask adds the next two wave bands, a slow domain-warped gust field moves the break threshold and calms patches, and ~6 yd segments break each crest into short runs, so the caps do not form a lattice (`whitecap_fold`, `enhanced_water.wgsl`) | tier |
| Near mesh refinement | High builds a transient 4x liquid lattice within 64 yd, with an 80 yd release ring | tier |
@@ -62,15 +63,17 @@ request. If it ever returns it should read the authored MCLQ flow records, not d
- **Shader**: `crates/benilla-assets/src/shaders/enhanced_water.wgsl` - the whole module on the GPU
side, an importable library (`benilla::enhanced_water`) with its own bindings: 103 scene depth,
104 `WaterParams` (`benilla_assets::WaterUniform`), 105 the shared light buffer (a second
- read-only view of upstream's binding 90), 106 the scene colour (for refraction and reflection). Upstream's `liquid.wgsl` carries three hooks only: the
+ read-only view of upstream's binding 90), 106 the scene colour (for refraction and reflection), 107 the planar mirror (`liquid/mirror.rs`). Upstream's `liquid.wgsl` carries three hooks only: the
`#import`, `water_swell` in its vertex stage, and the `water_active()` branch at the top of its
fragment stage, which returns the module's result as-is (the module fogs its own surface terms
only: the scene it refracts and reflects is already fogged). Keep it that way: new
water code goes in the module, not in `liquid.wgsl`.
- **Assets** (`crates/benilla-assets/src`): `water_depth.rs` (`WaterUniform`, the scene-depth image),
- `materials.rs` (`LiquidExt` fields 103-106, shader registration), `lib.rs` (`WaterQuality`).
+ `materials.rs` (`LiquidExt` fields 103-107, shader registration), `lib.rs` (`WaterQuality`,
+ `WaterReflections`).
- **World** (`crates/benilla-world/src`): `liquid/scene_depth.rs` (copies the opaque depth AND colour after
- the main opaque pass), `liquid/waves.rs` (the CPU mirror of the vertex swell), `liquid/surface.rs`
+ the main opaque pass), `liquid/mirror.rs` (the planar mirror camera, its plane vote and binding 107),
+ `liquid/waves.rs` (the CPU mirror of the vertex swell), `liquid/surface.rs`
(per-kind material parameters and the opt-in `WOW_WATER_PROBE` WMO classification/depth log),
`liquid/lod.rs` (High-only 4x near-grid swap),
`water_fx/bob.rs` (swimmer bob), `lighting/lava_light.rs`.
@@ -90,3 +93,8 @@ request. If it ever returns it should read the authored MCLQ flow records, not d
- The temporary water wind is `WATER_WIND_DIR`; all wind-aligned shader terms derive from it until
the weather lane supplies shared wind.
- Run one GPU capture at a time, and run `cargo test` in a shell WITHOUT `WOW_DATA` exported.
+- The planar mirror is read at the water pixel's own screen position, flipped vertically: the mirror
+ camera is the reflected eye rolled half a turn (a proper rotation, so culling and winding stay as they
+ are). It must keep the world camera's view key (HDR, MSAA, perspective, shadow filter) or every
+ pipeline compiles a second time; it never draws water (`drop_water_from_mirror`).
+- `WOW_WATER_MIRROR_LOG=1` prints the mirror's verdict once a second (plane, votes, active).
diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index 7bf8ed8d..fa0b177e 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -484,6 +484,10 @@
waterClassic = "Classic",
waterEnhanced = "Enhanced",
waterHigh = "High",
+ -- MONKEY (planar water): the reflection row.
+ waterReflections = "Water Reflections",
+ reflScreen = "Screen Space",
+ reflPlanar = "Planar",
lavaGlow = "Lava Glow",
qOff = "Off",
qLow = "Low",
@@ -584,6 +588,12 @@
"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.",
+ -- MONKEY (planar water): what the reflection row changes.
+ WATER_REFLECTIONS =
+ "How High water mirrors the scenery. Screen Space reflects only what is already "
+ .. "on screen, so the undersides of docks and boats and anything above the view are missing. "
+ .. "Planar draws the scene a second time, mirrored in the water: true reflections, "
+ .. "at a higher cost. Has no effect below High water quality.",
LAVA_GLOW =
"How strongly lava lights its surroundings. 0 turns the glow off.",
CHARACTER_SHADOWS =
@@ -748,6 +758,10 @@
waterClassic = "Классическое",
waterEnhanced = "Улучшенное",
waterHigh = "Высокое",
+ -- MONKEY (planar water): the reflection row.
+ waterReflections = "Отражения в воде",
+ reflScreen = "Экранные",
+ reflPlanar = "Плоскостные",
lavaGlow = "Свечение лавы",
qOff = "Выкл.",
qLow = "Низкое",
@@ -850,6 +864,12 @@
"«Классическое» — вода оригинального клиента. «Улучшенное» добавляет плавные переходы у берегов, "
.. "анимированную пену у берегов и вокруг объектов, подвижные блики на волнах, отражение неба "
.. "и блики от источников света. «Высокое» также добавляет зеркальные отражения и требует больше всего ресурсов.",
+ -- MONKEY (planar water): what the reflection row changes.
+ WATER_REFLECTIONS =
+ "Как вода «Высокого» качества отражает окружение. «Экранные» отражают только то, что уже "
+ .. "есть на экране, поэтому нижней стороны причалов и лодок и всего, что выше кадра, в них нет. "
+ .. "«Плоскостные» рисуют сцену ещё раз, зеркально в воде: настоящие отражения, "
+ .. "но дороже. Не действует ниже «Высокого» качества воды.",
LAVA_GLOW =
"Насколько сильно лава освещает всё вокруг. При 0 свечение отключено.",
CHARACTER_SHADOWS =
@@ -1049,7 +1069,7 @@
-- MONKEY (presets): the ladder over every row below, then the view distance.
"RowGraphicsQuality", "RowRenderDistance",
-- MONKEY (volumetric fog): include in refresh, defaults and search.
- "RowLightingQuality", "RowWaterQuality", "RowVolumetricFog",
+ "RowLightingQuality", "RowWaterQuality", "RowWaterReflections", "RowVolumetricFog",
-- MONKEY (sky): the sky tier row.
"RowSkyQuality",
-- MONKEY (post): the world-only screen effects.
@@ -2865,12 +2885,28 @@
})
-
-
+
+
+
+ BenillaOptionsRow_OnLoad(this, "waterReflections",
+ BENILLA_ADVGFX.waterReflections,
+ "BENILLA_TOOLTIP_WATER_REFLECTIONS")
+ BenillaOptionsDropdownRow_Setup(this, {
+ { text = BENILLA_ADVGFX.reflScreen, value = "0" },
+ { text = BENILLA_ADVGFX.reflPlanar, value = "1" },
+ })
+
+
+
+
+
+
+
+
BenillaOptionsRow_OnLoad(this, "volumetricFog", BENILLA_ADVGFX.volumetricFog,
"BENILLA_TOOLTIP_VOLUMETRIC_FOG")
diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs
index 9d254c9c..25421966 100644
--- a/crates/benilla-app/src/capture/scenarios.rs
+++ b/crates/benilla-app/src/capture/scenarios.rs
@@ -324,6 +324,17 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: None,
},
+ // MONKEY (planar water): Lakeshire's small dock from the boardwalk, looking down at the deck
+ // and its reflection (the owner's stretched-reflection screenshot); the `vista` instrument at
+ // feet -9297,-2170,67, facing 205, pitch -30.
+ Scenario {
+ name: "water-lakeshire-dock",
+ map: Some(MAP_AZEROTH),
+ eye: [-9297.0, -2170.0, 69.0],
+ look: [-9689.4, -2353.0, -181.0],
+ minute: 720,
+ ui: None,
+ },
// Westfall western coast: sand in the foreground, open sea to the west.
Scenario {
name: "water-ocean",
diff --git a/crates/benilla-app/src/cvars/mod.rs b/crates/benilla-app/src/cvars/mod.rs
index 7c137a48..58877819 100644
--- a/crates/benilla-app/src/cvars/mod.rs
+++ b/crates/benilla-app/src/cvars/mod.rs
@@ -704,6 +704,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
("exteriorShadows", "0"),
("spellLightGain", "1"),
("waterQuality", "1"),
+ // MONKEY (planar water): the mirrored view is the High/Ultra presets' alone.
+ ("waterReflections", "0"),
// MONKEY (volumetric fog): preset atmosphere uses the default cheap tier.
("volumetricFog", "1"),
// MONKEY (post): quarter-resolution halo.
@@ -736,6 +738,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
("exteriorShadows", "0"),
("spellLightGain", "1"),
("waterQuality", "1"),
+ // MONKEY (planar water): the mirrored view is the High/Ultra presets' alone.
+ ("waterReflections", "0"),
// MONKEY (volumetric fog): preset atmosphere uses the default cheap tier.
("volumetricFog", "1"),
// MONKEY (post): quarter-resolution halo.
@@ -767,8 +771,11 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
("interiorShadowDynamic", "4"),
("exteriorShadows", "1"),
("spellLightGain", "1"),
- // Mirror reflections stay opt-in until their cost is measured.
- ("waterQuality", "1"),
+ // MONKEY (planar water): High water on the High preset, measured (~1 ms with planar).
+ ("waterQuality", "2"),
+ // MONKEY (planar water): planar is the High and Ultra presets' reflection (it acts on the
+ // High water tier; measured cost in the gfx-1009 water report).
+ ("waterReflections", "1"),
// MONKEY (volumetric fog): preset atmosphere uses the default cheap tier.
("volumetricFog", "1"),
// MONKEY (post): half-resolution halo.
@@ -801,6 +808,7 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
("exteriorShadows", "1"),
("spellLightGain", "1"),
("waterQuality", "2"),
+ ("waterReflections", "1"),
("volumetricFog", "2"),
("bloom", "2"),
("sunShafts", "1"),
@@ -1602,12 +1610,14 @@ mod tests {
let shadows = VideoConfig::default();
let flag = |b: bool| if b { 1.0 } else { 0.0 };
// MONKEY (volumetric fog): include the atmospheric tier in this fixed-size default table.
- let lighting: [(&str, f32); 41] = [
+ let lighting: [(&str, f32); 42] = [
// GFX (volumetric light) / (moonlight): the three new rows.
("volumetricLight", shadows.volumetric_light as f32),
("volumetricLightStrength", shadows.volumetric_light_strength),
("moonLight", shadows.moon_light),
("waterQuality", shadows.water_quality as f32),
+ // MONKEY (planar water): weld registry and renderer defaults.
+ ("waterReflections", shadows.water_reflections as f32),
// MONKEY (volumetric fog): weld registry and renderer defaults.
("volumetricFog", shadows.volumetric_fog as f32),
// MONKEY (lampfog): opt-in point-light fog.
@@ -1674,7 +1684,7 @@ mod tests {
// VideoConfig rows, 38 welds (MONKEY lampfog: + lampFog). MONKEY (wind): `foliageWind` is a world resource bridge.
let welded: std::collections::BTreeSet<&str> = lighting.iter().map(|(n, _)| *n).collect();
// MONKEY (volumetric fog): the atmospheric tier joins the default-consumer weld.
- assert_eq!(welded.len(), 41, "the lighting lane welds 41 distinct rows");
+ assert_eq!(welded.len(), 42, "the lighting lane welds 42 distinct rows");
for name in &welded {
assert!(
REGISTERED.iter().any(|r| r.name == *name),
@@ -1689,7 +1699,8 @@ mod tests {
// candle-lit surfaces moving under +10 % — only true at this number.
assert_eq!(d["interiorBakeFloor"], 0.12);
assert_eq!(d["spellLightGain"], 1.0, "the spell lane ships neutral");
- assert_eq!(d["waterQuality"], 1.0, "mirror reflections stay opt-in");
+ // MONKEY (planar water): High water ships on (the High preset IS the defaults).
+ assert_eq!(d["waterQuality"], 2.0, "High water carries the planar mirror");
assert_eq!(d["lavaLightGain"], 1.0);
// MONKEY (ao): registry and renderer agree, and the lane ships Off.
assert_eq!(d["ambientOcclusion"], shadows.ambient_occlusion as f32);
@@ -1734,6 +1745,8 @@ mod tests {
if matches!(member, "fireLightGain" | "waterQuality" | "volumetricFog" | "lavaLightGain") { "0" } else { "1.0" }
} else if *name == "Ultra" && matches!(member, "waterQuality" | "volumetricFog") {
"2" // MONKEY (presets): Ultra's two maxima over the defaults.
+ } else if matches!(*name, "Low" | "Medium") && member == "waterQuality" {
+ "1" // MONKEY (planar water): below High, Enhanced water without the mirror.
} else {
cvars.default_of(member).unwrap()
};
@@ -2171,6 +2184,7 @@ mod tests {
"interiorShadowDynamic" => video.interior_shadow_dynamic as f32,
"spellLightGain" => video.spell_light_gain,
"waterQuality" => video.water_quality as f32,
+ "waterReflections" => video.water_reflections as f32,
// MONKEY (volumetric fog): verify presets reach the renderer.
"volumetricFog" => video.volumetric_fog as f32,
// MONKEY (post): the post lane's live tier.
@@ -2251,10 +2265,11 @@ mod tests {
assert_eq!(res::(&app).lava_light_gain, lava);
}
let mut cvars = fresh_registry();
- cvars.set("waterQuality", "2");
+ // MONKEY (planar water): High water is the High preset's; Enhanced makes the row Custom.
+ cvars.set("waterQuality", "1");
assert_eq!(derive_lighting_quality(&cvars), LIGHTING_CUSTOM);
apply_lighting_preset(&mut cvars, "High");
- assert_eq!(cvars.get("waterQuality"), Some("1"));
+ assert_eq!(cvars.get("waterQuality"), Some("2"));
}
/// The FFX pass's three switches reach it one by one (`ffx`, `ffxGlow`, `ffxDeath`).
diff --git a/crates/benilla-app/src/cvars/table.rs b/crates/benilla-app/src/cvars/table.rs
index 0cbf81de..ba77e607 100644
--- a/crates/benilla-app/src/cvars/table.rs
+++ b/crates/benilla-app/src/cvars/table.rs
@@ -600,8 +600,17 @@ pub(crate) const REGISTERED: &[Registered] = &[
),
ours(
"waterQuality",
+ // MONKEY (planar water): High is the shipped High preset's tier (planar mirror ~1 ms).
+ "2",
+ "benilla's own: water quality, 0 Classic / 1 Enhanced / 2 High; High adds mirror reflections",
+ ),
+ // MONKEY (planar water): how the High tier reflects the scenery; the High and Ultra presets
+ // set Planar, Low and Medium Screen Space.
+ ours(
+ "waterReflections",
"1",
- "benilla's own: water quality, 0 Classic / 1 Enhanced / 2 High; mirror reflections are opt-in",
+ "benilla's own: High water's scenery reflection, 0 Screen Space / 1 Planar (the scene drawn \
+ again, mirrored in the water: undersides and off-screen scenery reflect, costs more)",
),
ours(
"lavaLightGain",
diff --git a/crates/benilla-app/src/debug_panel/lighting_controls.rs b/crates/benilla-app/src/debug_panel/lighting_controls.rs
index 12190dc3..d61b3e5a 100644
--- a/crates/benilla-app/src/debug_panel/lighting_controls.rs
+++ b/crates/benilla-app/src/debug_panel/lighting_controls.rs
@@ -62,6 +62,8 @@ const INTERIOR: &[Knob] = &[
const GROUPS: &[(&str, &[Knob])] = &[
("Water", &[
knob!("waterQuality", water_quality, Choice(&[0, 1, 2]), "0 Classic, 1 Enhanced, 2 High; High adds mirror reflections and costs the most."),
+ // MONKEY (planar water): High water's scenery reflection.
+ knob!("waterReflections", water_reflections, Choice(&[0, 1]), "High water only: 0 Screen Space, 1 Planar (the scene drawn again, mirrored; about +1 ms CPU)."),
]),
("Interior light", INTERIOR),
("Fire", &[
diff --git a/crates/benilla-app/src/dynamic_interior.rs b/crates/benilla-app/src/dynamic_interior.rs
index 5b0a1e8b..0295215e 100644
--- a/crates/benilla-app/src/dynamic_interior.rs
+++ b/crates/benilla-app/src/dynamic_interior.rs
@@ -24,7 +24,7 @@
//! and the Off/Low presets reach.
//!
//! Water quality and lava glow use the same guarded bridge into their renderer resources.
-use benilla_assets::WaterQuality;
+use benilla_assets::{WaterQuality, WaterReflections};
use benilla_world::lighting::{
DynamicInteriors, EmissiveTier, FireLightGain, LavaLightGain, MoonShadowStrength, SpellLightGain,
};
@@ -61,6 +61,8 @@ fn bridge(
// reasons as the two gains above it — one live `f32`, one benilla-world resource, one guard.
mut moon: ResMut,
mut water: ResMut,
+ // MONKEY (planar water): Option, absent in the headless bridge test.
+ reflections: Option>,
mut lava: ResMut,
// MONKEY (post): bloom also arms the HDR source hooks through the shared light blob.
mut emissive: ResMut,
@@ -75,6 +77,11 @@ fn bridge(
if water.0 != video.water_quality {
water.0 = video.water_quality;
}
+ if let Some(mut reflections) = reflections {
+ if reflections.0 != video.water_reflections {
+ reflections.0 = video.water_reflections;
+ }
+ }
if lava.0 != video.lava_light_gain {
lava.0 = video.lava_light_gain;
}
diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs
index f794730d..879e716e 100644
--- a/crates/benilla-app/src/game_plugins.rs
+++ b/crates/benilla-app/src/game_plugins.rs
@@ -107,6 +107,8 @@ impl PluginGroup for GamePlugins {
// The game's own WGSL, compiled in, before anything that could ask for one.
.add(crate::shaders::plugin)
.add(benilla_world::liquid::WaterDepthPlugin)
+ // MONKEY (planar water): the High tier's mirrored view, rendered before the world.
+ .add(benilla_world::liquid::WaterMirrorPlugin)
.add(BowstringPlugin)
.add(crate::weapon_trail::WeaponTrailPlugin)
.add(FishingLinePlugin)
diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs
index 01e5150c..e0e307b3 100644
--- a/crates/benilla-app/src/ui_script/options_tests.rs
+++ b/crates/benilla-app/src/ui_script/options_tests.rs
@@ -1880,9 +1880,10 @@ fn water_quality_writes_numeric_tiers_with_localised_labels() {
s.run("ShowUIPanel(BenillaOptionsFrame) BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()").unwrap();
// MONKEY (volumetric fog): account for the atmosphere row after water.
// MONKEY (integration): Graphics Preset + Render Distance lead the page, then every programme row (sky, post, dither, fog, wet, wind, ao, lamp fog, window split, zone skyboxes): 33.
- assert_eq!(s.eval::("return table.getn(BENILLA_OPTIONS_PAGE_ROWS.AdvancedGraphics)").unwrap(), 33);
+ // MONKEY (planar water): + the Water Reflections row after Water Quality: 34.
+ assert_eq!(s.eval::("return table.getn(BENILLA_OPTIONS_PAGE_ROWS.AdvancedGraphics)").unwrap(), 34);
assert_eq!(s.eval::("return BENILLA_OPTIONS_PAGE_ROWS.AdvancedGraphics[4]").unwrap(), "RowWaterQuality");
- assert_eq!(s.eval::("return BENILLA_OPTIONS_PAGE_ROWS.AdvancedGraphics[23]").unwrap(), "RowLavaGlow");
+ assert_eq!(s.eval::("return BENILLA_OPTIONS_PAGE_ROWS.AdvancedGraphics[24]").unwrap(), "RowLavaGlow");
assert_eq!(s.eval::(&format!("return {ADVGFX}RowWaterQualityDropdownText:GetText()")).unwrap(), labels[1]);
assert_eq!(s.eval::(&format!("return {ADVGFX}RowLavaGlowLabel:GetText()")).unwrap(), lava_label);
assert!(s.eval::("return BENILLA_TOOLTIP_WATER_QUALITY == BENILLA_ADVGFX.tips.WATER_QUALITY and BENILLA_TOOLTIP_LAVA_GLOW == BENILLA_ADVGFX.tips.LAVA_GLOW").unwrap());
diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs
index 49cbe549..dabb381b 100644
--- a/crates/benilla-app/src/video.rs
+++ b/crates/benilla-app/src/video.rs
@@ -137,6 +137,9 @@ pub(crate) struct VideoConfig {
/// Water tier: 0 Classic, 1 Enhanced (default), 2 High with opt-in mirror reflections.
/// Published to the water renderer by `dynamic_interior::bridge`.
pub(crate) water_quality: u8,
+ /// MONKEY (planar water): High water's scenery reflection, 0 Screen Space / 1 Planar.
+ /// Published to `benilla_assets::WaterReflections` by `dynamic_interior::bridge`.
+ pub(crate) water_reflections: u8,
// MONKEY (volumetric fog): live camera raymarch tier: 0 Off, 1 Low, 2 High.
pub(crate) volumetric_fog: u8,
// MONKEY (post): emissive HDR + bloom quality, 0 Off / 1 Low / 2 High.
@@ -413,7 +416,11 @@ impl Default for VideoConfig {
interior_bake_floor: 0.12,
fire_light_gain: 1.0,
spell_light_gain: 1.0,
- water_quality: 1,
+ // MONKEY (planar water): High water (the planar mirror's tier) is the shipped High
+ // preset's, measured ~1 ms (gfx-1009 water report).
+ water_quality: 2,
+ // MONKEY (planar water): Planar, as the shipped High preset sets it.
+ water_reflections: 1,
// MONKEY (volumetric fog): default to the inexpensive atmosphere.
volumetric_fog: 1,
// MONKEY (post): the shipped High graphics preset uses the full-resolution tier.
@@ -503,6 +510,8 @@ pub(crate) fn on_cvar(
// 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,
+ // MONKEY (planar water): binary, Screen Space or Planar.
+ "waterreflections" => cfg.water_reflections = v.clamp(0.0, 1.0) as u8,
// MONKEY (volumetric fog): constrain UI/console writes to supported tiers.
"volumetricfog" => cfg.volumetric_fog = v.clamp(0.0, 2.0) as u8,
// MONKEY (post): constrain UI/console writes to the supported bloom tiers.
diff --git a/crates/benilla-assets/src/lib.rs b/crates/benilla-assets/src/lib.rs
index fe83b930..f5a1ee96 100644
--- a/crates/benilla-assets/src/lib.rs
+++ b/crates/benilla-assets/src/lib.rs
@@ -17,7 +17,9 @@ 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, WaterMirrorImage, WaterQuality, WaterReflections, 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 df4d34d2..c0a88d5b 100644
--- a/crates/benilla-assets/src/materials.rs
+++ b/crates/benilla-assets/src/materials.rs
@@ -436,6 +436,9 @@ pub struct LiquidExt {
/// refraction looks through. Loaded, never sampled, so no sampler.
#[texture(106, sample_type = "float", filterable = false, visibility(fragment))]
pub scene_colour: Handle,
+ /// MONKEY (planar water): the mirrored view (`liquid/mirror.rs`), loaded and filtered by hand.
+ #[texture(107, sample_type = "float", filterable = false, visibility(fragment))]
+ pub mirror_colour: Handle,
/// The kind's animated frames (`lake_a`/`fast_a`/`ocean_h`), stacked as `2d_array` layers
/// (`Rgba8Unorm`, repeat-sampled). RGB near-black; alpha = the ripple/wave → transparency.
#[texture(100, dimension = "2d_array", visibility(fragment))]
diff --git a/crates/benilla-assets/src/shaders/enhanced_water.wgsl b/crates/benilla-assets/src/shaders/enhanced_water.wgsl
index 695e0a0d..066fddbd 100644
--- a/crates/benilla-assets/src/shaders/enhanced_water.wgsl
+++ b/crates/benilla-assets/src/shaders/enhanced_water.wgsl
@@ -21,6 +21,7 @@
// 105 the shared global-light buffer again, as the rows this module reads (the same buffer
// upstream binds at 90 - read-only storage may be bound twice)
// 106 the opaque scene COLOUR, copied beside the depth: what the refraction looks through
+// 107 MONKEY (planar water): the MIRRORED view, rendered about the water plane (`liquid/mirror.rs`)
#import bevy_pbr::mesh_view_bindings::{view, globals}
// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table.
@@ -32,6 +33,7 @@
@group(#{MATERIAL_BIND_GROUP}) @binding(103) var scene_depth: texture_2d;
@group(#{MATERIAL_BIND_GROUP}) @binding(106) var scene_colour: texture_2d;
+@group(#{MATERIAL_BIND_GROUP}) @binding(107) var mirror_colour: texture_2d;
struct WaterParams {
// x = quality (0 Classic, 1 Enhanced, 2 High); y = wave energy (ocean 1.0, ADT inland 0.18,
@@ -43,6 +45,9 @@ 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
+ // MONKEY (planar water): x = 1 while binding 107 holds this frame's mirrored view; y = the
+ // mirror plane's height (Bevy Y). x = 0: High falls back to the screen-space march.
+ mirror: vec4,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(104) var water: WaterParams;
@@ -477,6 +482,15 @@ const SSR_FIRST_YD: f32 = 0.35; // first step; each next one is SSR_GROWTH t
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
const SSR_RIPPLE: f32 = 0.35; // share of the wave normal the reflected ray sees (WXL ssrRipple)
+// MONKEY (ssr stretch): how far a bisected hit may still sit BEHIND the depth it landed on. The
+// march's candidate test is loose (it must catch a surface between two long steps), so a ray that
+// merely passed behind a thin object - a dock deck, a plank, a post - converged onto that object's
+// silhouette, and every water pixel whose ray went under the deck read the same edge pixel: the
+// reflection smeared down the lake (owner screenshot, Lakeshire docks). A real hit converges onto
+// the surface itself, so after the bisection the ray is behind it by no more than the last
+// interval's own depth change; anything deeper is a ray that went behind an occluder and marches on.
+const SSR_HIT_SLACK_YD: f32 = 0.25;
+const SSR_HIT_SLACK_REL: f32 = 0.015;
// The scene's view distance under the point `pos`, and that point's own; x = scene, y = ray,
// z = 1 when `pos` is on screen. Sky pixels report no surface.
@@ -498,19 +512,33 @@ fn water_ssr(origin: vec3, dir: vec3) -> vec4 {
var step_len = SSR_FIRST_YD;
var along = 0.0;
var last_miss = 0.0;
+ // MONKEY (ssr stretch): the scene depth of the occluder the ray is passing behind, 0 when none.
+ var hidden_by = 0.0;
for (var i = 0; i < SSR_STEPS; i += 1) {
along += step_len;
let probe = ssr_probe(origin + dir * along, dims);
if probe.z < 0.5 { break; } // left the screen: nothing to show
let behind = probe.y - probe.x;
- if behind > 0.0 && behind < 2.0 + 0.1 * probe.x + step_len {
+ // Out from behind the occluder: in front of the depth again, or over another surface.
+ if hidden_by > 0.0 && (behind <= 0.0 || abs(probe.x - hidden_by) > 1.0 + 0.05 * hidden_by) {
+ hidden_by = 0.0;
+ }
+ if hidden_by == 0.0 && behind > 0.0 && behind < 2.0 + 0.1 * probe.x + step_len {
// Bisect back toward the last miss, so the reflection sits on the surface it hit.
var lo = last_miss;
var hi = along;
+ var at_hi = probe;
for (var k = 0; k < SSR_REFINE; k += 1) {
let mid = 0.5 * (lo + hi);
let m = ssr_probe(origin + dir * mid, dims);
- if m.y > m.x { hi = mid; } else { lo = mid; }
+ if m.y > m.x { hi = mid; at_hi = m; } else { lo = mid; }
+ }
+ // MONKEY (ssr stretch): a converged hit, or a ray that slipped behind an occluder?
+ if at_hi.y - at_hi.x > SSR_HIT_SLACK_YD + SSR_HIT_SLACK_REL * at_hi.x + (hi - lo) {
+ hidden_by = at_hi.x;
+ last_miss = along;
+ step_len *= SSR_GROWTH;
+ continue;
}
let clip = view.clip_from_world * vec4(origin + dir * hi, 1.0);
let uv = (clip.xy / clip.w) * vec2(0.5, -0.5) + vec2(0.5);
@@ -528,6 +556,48 @@ fn water_ssr(origin: vec3, dir: vec3) -> vec4 {
return vec4(0.0);
}
+// ── HIGH (planar): the mirrored view ──────────────────────────────────────────────────────────
+// MONKEY (planar water): `liquid/mirror.rs` renders the scene once more from the eye reflected in
+// the water plane near the camera (clipped at that plane, rolled half a turn so it stays a proper
+// rotation), into binding 107. Seen from that camera a point X lands where the world camera would
+// see X's mirror image, upside down: so the mirror is read at the flipped screen position of the
+// reflected point. On the plane that is the water pixel's own position; the ripple moves the read
+// by the bent ray's offset a few yards out, world-anchored like the refraction bend.
+const PLANAR_RIPPLE_YD: f32 = 2.5;
+// The reflectivity floor the mirror is shown at (the screen-space path's floor where it found a hit).
+const PLANAR_FLOOR: f32 = 0.42;
+
+// The mirror at a texture coordinate, bilinear by hand (it is bound unfiltered, as 103/106 are).
+fn planar_fetch(uv: vec2) -> vec3 {
+ let dims = vec2(textureDimensions(mirror_colour));
+ let p = clamp(uv * dims - vec2(0.5), vec2(0.0), dims - vec2(1.0));
+ let i = vec2(floor(p));
+ let f = p - floor(p);
+ let top = vec2(dims) - vec2(1);
+ let a = textureLoad(mirror_colour, i, 0).rgb;
+ let b = textureLoad(mirror_colour, min(i + vec2(1, 0), top), 0).rgb;
+ let c = textureLoad(mirror_colour, min(i + vec2(0, 1), top), 0).rgb;
+ let d = textureLoad(mirror_colour, min(i + vec2(1, 1), top), 0).rgb;
+ return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+// rgb = the reflected scene (gamma lane, already fogged over the whole path); w = how far this
+// surface lies on the mirrored plane (0: another sheet of water, keep the screen-space march).
+fn water_planar(pos: vec3, flat_dir: vec3, ripple_dir: vec3, tolerance: f32)
+ -> vec4 {
+ if water.mirror.x < 0.5 { return vec4(0.0); }
+ let plane = water.mirror.y;
+ let on_plane = 1.0 - smoothstep(tolerance, 3.0 * tolerance, abs(pos.y - plane));
+ if on_plane <= 0.0 { return vec4(0.0); }
+ let s = pos + (ripple_dir - flat_dir) * PLANAR_RIPPLE_YD;
+ let clip = view.clip_from_world * vec4(s.x, 2.0 * plane - s.y, s.z, 1.0);
+ if clip.w <= 0.0 { return vec4(0.0); }
+ let ndc = clip.xy / clip.w;
+ // The world view's uv is (ndc.x, -ndc.y) * 0.5 + 0.5; the mirror's is that, upside down.
+ let uv = clamp(ndc * 0.5 + vec2(0.5), vec2(0.0), vec2(1.0));
+ return vec4(planar_fetch(uv), on_plane);
+}
+
// Reverse-Z perspective: valid for both finite and infinite far planes. For z_view = -distance,
// depth = -P22 + P32 / distance. Do not use a forward-Z near/far approximation.
fn water_view_distance(depth: f32) -> f32 {
@@ -781,10 +851,15 @@ 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
- if water.mode.x > 1.5 && reflectivity > 0.1 {
+ if water.mode.x > 1.5 {
let ssr_n = normalize(mix(vec3(0.0, 1.0, 0.0), n, SSR_RIPPLE));
let ssr_dir = reflect(-to_view, ssr_n);
- if ssr_dir.y > 0.0 {
+ // MONKEY (planar water): the mirrored view first; it holds the sky too, so where it
+ // covers the surface whole the march is not run at all.
+ let planar = water_planar(in.world_position.xyz,
+ reflect(-to_view, vec3(0.0, 1.0, 0.0)), ssr_dir, select(0.3, 1.6, ocean_mesh));
+ let fresnel = reflectivity;
+ if reflectivity > 0.1 && ssr_dir.y > 0.0 && planar.w < 0.999 {
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));
@@ -793,6 +868,12 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec
// 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);
}
+ // The mirror is a whole reflection - banks, hulls, the undersides SSR cannot see, and the
+ // real sky with its clouds - so it takes the scenery floor wherever it is trusted, ramped
+ // in from the Fresnel minimum so a steep look down does not meet a hard reflectivity step.
+ reflection = mix(reflection, planar.rgb, planar.w);
+ reflectivity = mix(reflectivity,
+ max(reflectivity, PLANAR_FLOOR * smoothstep(0.06, 0.3, fresnel)), planar.w);
}
rgb = mix(rgb, reflection, reflectivity);
// Everything added from here on (glints, light speculars) is surface light: fog attenuates it.
diff --git a/crates/benilla-assets/src/water_depth.rs b/crates/benilla-assets/src/water_depth.rs
index 48255598..b2ce380b 100644
--- a/crates/benilla-assets/src/water_depth.rs
+++ b/crates/benilla-assets/src/water_depth.rs
@@ -16,6 +16,9 @@ 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,
+ /// MONKEY (planar water): x = 1 while [`WaterMirrorImage`] holds this frame's mirrored view;
+ /// y = the mirror plane's height (Bevy Y). Zero: the High tier marches its screen-space rays.
+ pub mirror: Vec4,
}
impl Default for WaterUniform {
@@ -26,6 +29,7 @@ impl Default for WaterUniform {
sky_zenith: Vec4::ZERO,
sky_horizon: Vec4::ZERO,
celestial: Vec3::Y.extend(0.0),
+ mirror: Vec4::ZERO,
}
}
}
@@ -38,6 +42,35 @@ impl Default for WaterQuality {
fn default() -> Self { Self(1) }
}
+/// MONKEY (planar water): how the High tier reflects the scenery. 0 = screen space (a march
+/// against the scene depth, which can only reflect what is on screen), 1 = planar (the scene
+/// rendered again, mirrored about the water plane near the camera, `liquid/mirror.rs`).
+#[derive(Resource, Clone, Copy, PartialEq, Debug)]
+pub struct WaterReflections(pub u8);
+
+impl Default for WaterReflections {
+ fn default() -> Self { Self(1) }
+}
+
+/// MONKEY (planar water): the mirrored view's colour, rendered by the mirror camera before the
+/// world camera; the water samples it at its own (flipped) screen position.
+#[derive(Resource, Clone)]
+pub struct WaterMirrorImage(pub Handle);
+
+impl FromWorld for WaterMirrorImage {
+ 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;
+ image.data = None;
+ Self(world.resource_mut::>().add(image))
+ }
+}
+
/// The world view's opaque depth, resolved to a sampleable R32Float image.
#[derive(Resource, Clone)]
pub struct WaterDepthImage(pub Handle);
diff --git a/crates/benilla-world/src/liquid/mirror.rs b/crates/benilla-world/src/liquid/mirror.rs
new file mode 100644
index 00000000..90ee18c4
--- /dev/null
+++ b/crates/benilla-world/src/liquid/mirror.rs
@@ -0,0 +1,553 @@
+//! MONKEY (planar water): real reflections for the High water tier. The scene is rendered a second
+//! time, from the world camera's eye reflected in the water plane near the camera, into
+//! [`WaterMirrorImage`] at a fraction of the screen's resolution; `enhanced_water.wgsl`
+//! (`water_planar`) reads it at the water pixel's own, flipped, screen position. Screen-space
+//! reflection can only show what is on screen, so the undersides of docks and hulls, the far side
+//! of a post and anything above the frame were missing or smeared; the mirror has them all, with
+//! the real sky and its clouds.
+//!
+//! The mirror camera is an ordinary Bevy `Camera3d` with the world camera's view key (HDR, MSAA,
+//! perspective, shadow filter), so it shares every specialised pipeline, and Bevy's oblique near
+//! plane ([`PerspectiveProjection::near_clip_plane`]) clips it at the water: nothing below the
+//! surface (the lake bed, the drowned half of a post) can stand between the mirrored eye and the
+//! scenery. It is cheaper than the world view by construction: no water (the mirror never reflects
+//! water), no sun shadow cascades (it is not on the shadow rig's render layer), none of the world
+//! camera's post passes, and it draws the static set the world camera's cull already admitted.
+//!
+//! The plane is the water most of the screen looks at: a fan of rays through the frame is marched
+//! against the liquid footprints and each votes, nearer hits weighing more, for the height it first
+//! meets ([`plane_votes`], [`choose_plane`]). Water at another height fades back to the
+//! screen-space march in the shader.
+
+use bevy::camera::primitives::{Frustum, HalfSpace};
+use bevy::camera::visibility::{VisibilitySystems, VisibleEntities};
+use bevy::camera::{CameraUpdateSystems, RenderTarget};
+use bevy::core_pipeline::tonemapping::Tonemapping;
+use bevy::image::ToExtents;
+use bevy::light::ShadowFilteringMethod;
+use bevy::prelude::*;
+use bevy::render::view::{Hdr, Msaa};
+
+use benilla_assets::coords::bevy_to_wow;
+use benilla_assets::materials::LiquidMaterial;
+use benilla_assets::{WaterMirrorImage, WaterQuality, WaterReflections};
+
+use super::query::WaterChunkInfo;
+use super::spatial::WaterIndex;
+use crate::view::WorldCamera;
+
+/// The mirror's resolution as a share of the world view's, per axis.
+const MIRROR_SCALE: f32 = 0.5;
+/// Below this eye height over the plane the mirror is not used: the oblique near plane passes
+/// almost through the eye, and its depth range collapses.
+const MIN_EYE_HEIGHT: f32 = 0.5;
+/// How far a ray is marched for the plane vote, in yards.
+const VOTE_REACH: f32 = 260.0;
+/// Two votes closer than this, in yards, are one plane.
+const PLANE_MERGE: f32 = 0.3;
+/// The current plane is kept while it holds this share of the leading plane's votes.
+const PLANE_HOLD: f32 = 0.6;
+/// The vote's screen fan: columns across the whole width, rows from the bottom edge to a little
+/// above the centre (water is below the horizon).
+const FAN_COLS: usize = 7;
+const FAN_ROWS: usize = 6;
+
+/// Marks the mirror camera.
+#[derive(Component)]
+pub struct WaterMirrorCamera;
+
+/// The mirror's state between frames.
+#[derive(Resource, Default)]
+struct MirrorState {
+ /// The plane height (Bevy Y) the mirror renders about, `None` with no water in view.
+ plane: Option,
+ /// Consecutive frames the mirror camera has been active: the image is trusted from the second.
+ live_frames: u32,
+ /// What the materials were last told, so they are only touched on a change.
+ published: Vec4,
+ /// Frames seen by the `WOW_WATER_MIRROR_LOG` line.
+ log_frames: u32,
+}
+
+/// `WOW_WATER_REFLECT=0|1` overrides the setting for this run, as `WOW_WATER` does the tier. The
+/// tier's own override is read here too: `scene_depth.rs` re-applies it only in `Last`, after the
+/// options bridge has put the saved tier back for the frame, and the mirror is decided before that.
+#[derive(Resource)]
+struct ReflectOverride {
+ reflections: Option,
+ tier: Option,
+}
+
+/// `WOW_WATER_MIRROR_LOG=1`: one line a second of the mirror's verdict (plane, votes, active).
+fn mirror_log() -> bool {
+ std::env::var_os("WOW_WATER_MIRROR_LOG").is_some()
+}
+
+/// The planar reflection lane. Needs the liquid plugin (the index and the footprints).
+pub struct WaterMirrorPlugin;
+
+impl Plugin for WaterMirrorPlugin {
+ fn build(&self, app: &mut App) {
+ let value = std::env::var("WOW_WATER_REFLECT")
+ .ok()
+ .and_then(|v| v.parse::().ok())
+ .filter(|v| *v <= 1);
+ if let Some(value) = value {
+ app.insert_resource(WaterReflections(value));
+ }
+ app.init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .insert_resource(ReflectOverride {
+ reflections: value,
+ tier: std::env::var("WOW_WATER")
+ .ok()
+ .and_then(|v| v.parse::().ok())
+ .filter(|v| *v <= 2),
+ })
+ .add_systems(Update, spawn_mirror_camera)
+ .add_systems(
+ PostUpdate,
+ (
+ // Before Bevy turns the projection into this frame's matrices.
+ drive_mirror.before(CameraUpdateSystems),
+ clip_mirror_frustum
+ .after(VisibilitySystems::UpdateFrusta)
+ .before(VisibilitySystems::CheckVisibility),
+ drop_water_from_mirror
+ .after(VisibilitySystems::CheckVisibility)
+ .before(VisibilitySystems::MarkNewlyHiddenEntitiesInvisible),
+ ),
+ );
+ }
+}
+
+/// The mirror camera, spawned once beside the world camera, inactive until water is in view.
+fn spawn_mirror_camera(
+ mut commands: Commands,
+ world: Query<(), With>,
+ mirror: Query<(), With>,
+ image: Res,
+) {
+ if world.is_empty() || !mirror.is_empty() {
+ return;
+ }
+ commands.spawn((
+ Name::new("water mirror"),
+ Camera3d::default(),
+ Camera {
+ // Before the world camera, whose water reads the image.
+ order: -1,
+ is_active: false,
+ clear_color: ClearColorConfig::Custom(Color::BLACK),
+ ..default()
+ },
+ RenderTarget::Image(image.0.clone().into()),
+ // The world camera's view shape, so every pipeline is shared (`player/setup.rs`).
+ Hdr,
+ Tonemapping::None,
+ Msaa::Off,
+ Projection::from(PerspectiveProjection::default()),
+ bevy::light::cluster::ClusterConfig::None,
+ crate::static_gx::StaticGxMirror,
+ WaterMirrorCamera,
+ ));
+}
+
+/// The plane, the mirrored pose and projection, the target size, and the materials' mirror lane.
+#[allow(clippy::too_many_arguments, clippy::type_complexity)]
+fn drive_mirror(
+ world: Query<
+ (
+ &Camera,
+ &Transform,
+ &GlobalTransform,
+ Has,
+ &Projection,
+ &Msaa,
+ Option<&ShadowFilteringMethod>,
+ ),
+ (With, Without),
+ >,
+ mut mirror: Query<
+ (
+ Entity,
+ &mut Camera,
+ &mut Transform,
+ &mut Projection,
+ &mut Msaa,
+ Option<&ShadowFilteringMethod>,
+ ),
+ With,
+ >,
+ mut commands: Commands,
+ quality: Res,
+ mut reflections: ResMut,
+ override_value: Res,
+ underwater: Res,
+ index: Res,
+ chunks: Query<&WaterChunkInfo>,
+ image: Res,
+ mut images: ResMut>,
+ mut materials: ResMut>,
+ mut state: ResMut,
+) {
+ if let Some(value) = override_value.reflections {
+ if reflections.0 != value {
+ reflections.0 = value;
+ }
+ }
+ let Ok((entity, mut cam, mut tf, mut proj, mut msaa, filter)) = mirror.single_mut() else {
+ return;
+ };
+ let main = world.iter().next();
+ let tier = override_value.tier.unwrap_or(quality.0);
+ let mut active = false;
+ let mut vote_count = 0;
+ if let Some((main_cam, main_tf, main_gt, parented, main_proj, main_msaa, main_filter)) = main {
+ // A root camera's `Transform` is this frame's pose; Bevy's propagation has not run yet.
+ let eye = if parented {
+ *main_gt
+ } else {
+ GlobalTransform::from(*main_tf)
+ };
+ let wanted = tier >= 2 && reflections.0 == 1 && main_cam.is_active && !underwater.0.any();
+ state.plane = if wanted {
+ let water_z = |x: f32, y: f32| {
+ index
+ .over(x, y)
+ .iter()
+ .filter_map(|e| chunks.get(*e).ok())
+ .filter_map(|c| c.water_z_at(x, y))
+ .reduce(f32::max)
+ };
+ let votes = match main_proj {
+ Projection::Perspective(p) => plane_votes(
+ eye.translation(),
+ fan_dirs(&eye, p.fov, p.aspect_ratio),
+ water_z,
+ ),
+ _ => Vec::new(),
+ };
+ vote_count = votes.len();
+ choose_plane(&votes, state.plane)
+ } else {
+ None
+ };
+ if let (Some(plane), Projection::Perspective(p)) = (state.plane, main_proj) {
+ if eye.translation().y - plane >= MIN_EYE_HEIGHT {
+ active = true;
+ let mirrored = mirror_pose(&eye, plane);
+ *tf = mirrored;
+ let near_clip_plane = view_plane(&GlobalTransform::from(mirrored), plane);
+ // Rewritten every frame: the clip plane moves in view space with the eye. Bevy
+ // re-derives the aspect from the mirror's own target, which is the world view's
+ // scaled (to a rounding pixel).
+ *proj = Projection::Perspective(PerspectiveProjection {
+ fov: p.fov,
+ near: p.near,
+ far: p.far,
+ aspect_ratio: p.aspect_ratio,
+ near_clip_plane,
+ });
+ if *msaa != *main_msaa {
+ *msaa = *main_msaa;
+ }
+ if filter != main_filter {
+ match main_filter {
+ Some(f) => {
+ commands.entity(entity).insert(*f);
+ }
+ None => {
+ commands.entity(entity).remove::();
+ }
+ }
+ }
+ if let Some(size) = main_cam.physical_target_size() {
+ let want = (size.as_vec2() * MIRROR_SCALE)
+ .round()
+ .as_uvec2()
+ .max(UVec2::ONE);
+ let stale = images.get(&image.0).is_some_and(|i| i.size() != want);
+ if stale {
+ if let Some(img) = images.get_mut(&image.0) {
+ img.resize(want.to_extents());
+ }
+ // A resize replaces the GPU view behind this stable handle: rebuild the
+ // water bind groups (as `scene_depth.rs` does for its own images).
+ let ids: Vec<_> = materials.ids().collect();
+ for id in ids {
+ let _ = materials.get_mut(id);
+ }
+ state.live_frames = 0;
+ }
+ }
+ }
+ }
+ }
+ if cam.is_active != active {
+ cam.is_active = active;
+ }
+ state.live_frames = if active {
+ state.live_frames.saturating_add(1)
+ } else {
+ 0
+ };
+ if mirror_log() {
+ state.log_frames += 1;
+ if state.log_frames % 60 == 1 {
+ info!(
+ "WATER_MIRROR tier={tier} reflections={} plane={:?} votes={vote_count} active={active} live={} cams={}",
+ reflections.0,
+ state.plane,
+ state.live_frames,
+ world.iter().count()
+ );
+ }
+ }
+ let publish = match state.plane {
+ Some(plane) if state.live_frames >= 2 => Vec4::new(1.0, plane, 0.0, 0.0),
+ _ => Vec4::ZERO,
+ };
+ if publish != state.published {
+ state.published = publish;
+ let ids: Vec<_> = materials
+ .iter()
+ .filter(|(_, m)| m.extension.water.lane.z < 0.5 && m.extension.water.mirror != publish)
+ .map(|(id, _)| id)
+ .collect();
+ for id in ids {
+ if let Some(m) = materials.get_mut(id) {
+ m.extension.water.mirror = publish;
+ }
+ }
+ }
+}
+
+/// Bevy's frustum near plane is not the oblique one: cull the mirror's view at the water itself, so
+/// nothing wholly below the surface is drawn only to be clipped.
+fn clip_mirror_frustum(
+ state: Res,
+ mut mirror: Query<(&Camera, &mut Frustum), With>,
+) {
+ let (Some(plane), Ok((cam, mut frustum))) = (state.plane, mirror.single_mut()) else {
+ return;
+ };
+ if cam.is_active {
+ frustum.half_spaces[Frustum::NEAR_PLANE_IDX] =
+ HalfSpace::new(Vec4::new(0.0, 1.0, 0.0, -plane));
+ }
+}
+
+/// The mirror never reflects water: drop every liquid surface from its visible list.
+fn drop_water_from_mirror(
+ mut mirror: Query<(&Camera, &mut VisibleEntities), With>,
+ liquids: Query<(), With>>,
+) {
+ let Ok((cam, mut visible)) = mirror.single_mut() else {
+ return;
+ };
+ if !cam.is_active {
+ return;
+ }
+ visible
+ .get_mut(std::any::TypeId::of::())
+ .retain(|e| !liquids.contains(*e));
+}
+
+/// The eye reflected in the plane `y = plane`, as a PROPER rotation: the mirror image of the basis
+/// `(right, up, back)` is left-handed, so up is negated, turning the view half a turn about its axis.
+/// Seen from this pose a point X lands where the world camera sees X's reflection, upside down
+/// (`enhanced_water.wgsl`, `water_planar`).
+pub(super) fn mirror_pose(eye: &GlobalTransform, plane: f32) -> Transform {
+ let m = |v: Vec3| Vec3::new(v.x, -v.y, v.z);
+ let p = eye.translation();
+ let basis = Mat3::from_cols(m(*eye.right()), -m(*eye.up()), m(*eye.back()));
+ Transform {
+ translation: Vec3::new(p.x, 2.0 * plane - p.y, p.z),
+ rotation: Quat::from_mat3(&basis).normalize(),
+ scale: Vec3::ONE,
+ }
+}
+
+/// The water plane `y = plane` in a camera's view space, positive above the water: the
+/// [`PerspectiveProjection::near_clip_plane`] that keeps only what is above it.
+pub(super) fn view_plane(camera: &GlobalTransform, plane: f32) -> Vec4 {
+ // Planes transform by the inverse transpose: view = world_from_view^T · world-plane.
+ camera.to_matrix().transpose() * Vec4::new(0.0, 1.0, 0.0, -plane)
+}
+
+/// World directions through a fan of points on the screen.
+fn fan_dirs(eye: &GlobalTransform, fov: f32, aspect: f32) -> Vec {
+ let tan_y = (fov * 0.5).tan();
+ let tan_x = tan_y * aspect;
+ let mut out = Vec::with_capacity(FAN_COLS * FAN_ROWS);
+ for r in 0..FAN_ROWS {
+ // NDC y from -0.95 (bottom edge) to 0.25.
+ let y = -0.95 + 1.2 * r as f32 / (FAN_ROWS - 1) as f32;
+ for c in 0..FAN_COLS {
+ let x = -0.9 + 1.8 * c as f32 / (FAN_COLS - 1) as f32;
+ let local = Vec3::new(x * tan_x, y * tan_y, -1.0).normalize();
+ out.push(eye.rotation() * local);
+ }
+ }
+ out
+}
+
+/// March each ray from `eye` until it first passes under a water surface (`water_z`, WoW XY to the
+/// surface's WoW Z, i.e. Bevy Y); each hit is a `(height, weight)` vote, nearer hits weighing more.
+pub(super) fn plane_votes(
+ eye: Vec3,
+ dirs: Vec,
+ water_z: impl Fn(f32, f32) -> Option,
+) -> Vec<(f32, f32)> {
+ let mut votes = Vec::new();
+ for d in dirs {
+ // Only rays heading down can meet water below the eye.
+ if d.y > -0.01 {
+ continue;
+ }
+ let mut t = 1.0;
+ let mut step = 1.0;
+ while t < VOTE_REACH {
+ let p = eye + d * t;
+ let wow = bevy_to_wow(p);
+ if let Some(z) = water_z(wow[0], wow[1]) {
+ if p.y <= z && z < eye.y {
+ votes.push((z, 1.0 / (1.0 + t / 40.0)));
+ break;
+ }
+ }
+ t += step;
+ step *= 1.12;
+ }
+ }
+ votes
+}
+
+/// The plane with the most weight, unless the current one still holds [`PLANE_HOLD`] of it.
+pub(super) fn choose_plane(votes: &[(f32, f32)], current: Option) -> Option {
+ // Merge the votes into planes: (weighted height sum, weight).
+ let mut planes: Vec<(f32, f32)> = Vec::new();
+ for &(h, w) in votes {
+ match planes
+ .iter_mut()
+ .find(|(sum, weight)| (sum / weight - h).abs() < PLANE_MERGE)
+ {
+ Some(p) => {
+ p.0 += h * w;
+ p.1 += w;
+ }
+ None => planes.push((h * w, w)),
+ }
+ }
+ let (best_sum, best_w) = planes.iter().copied().max_by(|a, b| a.1.total_cmp(&b.1))?;
+ if let Some(c) = current {
+ let held = planes
+ .iter()
+ .find(|(sum, weight)| (sum / weight - c).abs() < PLANE_MERGE)
+ .map_or(0.0, |p| p.1);
+ if held >= PLANE_HOLD * best_w {
+ return Some(c);
+ }
+ }
+ Some(best_sum / best_w)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn eye() -> GlobalTransform {
+ GlobalTransform::from(
+ Transform::from_xyz(3.0, 12.0, 7.0).looking_at(Vec3::new(20.0, 2.0, -30.0), Vec3::Y),
+ )
+ }
+
+ /// The mirrored camera sees a point where the world camera sees its reflection, flipped in y:
+ /// the contract `water_planar` reads the image by.
+ #[test]
+ fn the_mirror_sees_the_reflection_upside_down() {
+ let eye = eye();
+ let plane = 2.0;
+ let mirror = GlobalTransform::from(mirror_pose(&eye, plane));
+ let proj = Mat4::perspective_infinite_reverse_rh(0.8, 1.7, 0.1);
+ for x in [
+ Vec3::new(10.0, 6.0, -20.0),
+ Vec3::new(-4.0, 2.0, -9.0),
+ Vec3::new(30.0, 25.0, -60.0),
+ ] {
+ let reflected = Vec3::new(x.x, 2.0 * plane - x.y, x.z);
+ let a = proj * eye.to_matrix().inverse() * reflected.extend(1.0);
+ let b = proj * mirror.to_matrix().inverse() * x.extend(1.0);
+ let (a, b) = (a.truncate().truncate() / a.w, b.truncate().truncate() / b.w);
+ assert!(
+ (a.x - b.x).abs() < 1e-4 && (a.y + b.y).abs() < 1e-4,
+ "{a} vs {b}"
+ );
+ }
+ // A proper rotation: Bevy's culling and winding stay as they are.
+ assert!((mirror.to_matrix().determinant() - 1.0).abs() < 1e-4);
+ }
+
+ /// The oblique near plane keeps what is above the water and clips what is below it, and depth
+ /// still falls with distance along a ray (reverse-Z) above the plane.
+ #[test]
+ fn the_near_plane_is_the_water() {
+ let eye = eye();
+ let plane = 2.0;
+ let mirror = GlobalTransform::from(mirror_pose(&eye, plane));
+ let projection = PerspectiveProjection {
+ fov: 0.8,
+ aspect_ratio: 1.7,
+ near: 0.1,
+ far: 1000.0,
+ near_clip_plane: view_plane(&mirror, plane),
+ };
+ use bevy::camera::CameraProjection;
+ let clip_from_world = projection.get_clip_from_view() * mirror.to_matrix().inverse();
+ let depth = |p: Vec3| {
+ let c = clip_from_world * p.extend(1.0);
+ (c.z, c.w)
+ };
+ // The mirror eye looks up through the plane at the scenery.
+ let target = Vec3::new(20.0, 6.0, -30.0);
+ let (z, w) = depth(target);
+ assert!(z >= 0.0 && z <= w, "above water is kept: {z} {w}");
+ let under = Vec3::new(8.0, 1.0, -10.0);
+ let (z, w) = depth(under);
+ assert!(z > w, "below water is clipped: {z} {w}");
+ let m = mirror.translation();
+ let dir = (target - m).normalize();
+ let along = |t: f32| {
+ let (z, w) = depth(m + dir * t);
+ z / w
+ };
+ assert!(along(20.0) > along(40.0) && along(40.0) > along(400.0) && along(400.0) >= 0.0);
+ }
+
+ /// A lake under the camera wins the vote; a pond above the eye and an off-screen pool do not.
+ #[test]
+ fn the_plane_is_the_water_the_view_looks_at() {
+ let eye = eye();
+ let dirs = fan_dirs(&eye, 0.8, 1.7);
+ let lake = |_x: f32, _y: f32| Some(2.0);
+ let votes = plane_votes(eye.translation(), dirs.clone(), lake);
+ assert!(!votes.is_empty());
+ assert_eq!(choose_plane(&votes, None), Some(2.0));
+ // Water above the eye is never a mirror.
+ let high = |_x: f32, _y: f32| Some(40.0);
+ assert!(plane_votes(eye.translation(), dirs.clone(), high).is_empty());
+ }
+
+ /// The held plane survives a close second, and yields to a clear winner.
+ #[test]
+ fn the_plane_holds_against_a_close_second() {
+ let votes = [(2.0, 1.0), (2.1, 1.0), (5.0, 2.5)];
+ // 2.0-ish holds 2.0 of the leader's 2.5: kept.
+ assert_eq!(choose_plane(&votes, Some(2.0)), Some(2.0));
+ let votes = [(2.0, 1.0), (5.0, 4.0)];
+ assert_eq!(choose_plane(&votes, Some(2.0)), Some(5.0));
+ assert_eq!(choose_plane(&[], Some(2.0)), None);
+ }
+}
diff --git a/crates/benilla-world/src/liquid/mod.rs b/crates/benilla-world/src/liquid/mod.rs
index 4f52bada..a5a63800 100644
--- a/crates/benilla-world/src/liquid/mod.rs
+++ b/crates/benilla-world/src/liquid/mod.rs
@@ -32,6 +32,9 @@ use benilla_assets::AssetSet;
mod drift;
mod lod;
+// MONKEY (planar water): the High tier's mirrored view of the scene.
+mod mirror;
+pub use mirror::{WaterMirrorCamera, WaterMirrorPlugin};
mod scene_depth;
// MONKEY (ao): the label orders the AO pass.
pub use scene_depth::{WaterDepthLabel, WaterDepthPlugin};
@@ -92,6 +95,7 @@ impl Plugin for LiquidPlugin {
.init_resource::()
.init_resource::()
.init_resource::()
+ .init_resource::()
.init_resource::()
.init_resource::()
.init_resource::()
diff --git a/crates/benilla-world/src/liquid/query.rs b/crates/benilla-world/src/liquid/query.rs
index 9583613d..42abd641 100644
--- a/crates/benilla-world/src/liquid/query.rs
+++ b/crates/benilla-world/src/liquid/query.rs
@@ -423,6 +423,15 @@ impl WaterChunkInfo {
}
}
+ /// MONKEY (planar water): the surface height at a WoW XY for the water kinds only (the mirror's
+ /// plane vote); `None` where dry and on magma and slime, which do not reflect.
+ pub(crate) fn water_z_at(&self, x: f32, y: f32) -> Option {
+ if self.kind.is_fullbright() {
+ return None;
+ }
+ self.surface_z_at(x, y)
+ }
+
/// The ambient loop's emitter target: the XY clamped into the footprint's box, at the surface's
/// height there, or the highest wet vertex over a hole. The reference uses the nearest liquid
/// cell; this clamp approximates it.
diff --git a/crates/benilla-world/src/liquid/surface.rs b/crates/benilla-world/src/liquid/surface.rs
index 70614835..6c06ac0c 100644
--- a/crates/benilla-world/src/liquid/surface.rs
+++ b/crates/benilla-world/src/liquid/surface.rs
@@ -368,6 +368,7 @@ pub(super) fn setup_liquid(
mut materials: ResMut>,
water_depth: Res,
water_colour: Res,
+ water_mirror: Res,
water_quality: Res,
) {
let (Some(_config), Some(mut world_assets)) = (config, world_assets) else {
@@ -484,6 +485,7 @@ pub(super) fn setup_liquid(
},
water_light: world_assets.shared_light.clone(),
scene_colour: water_colour.0.clone(),
+ mirror_colour: water_mirror.0.clone(),
light_buf: world_assets.shared_light.clone(),
},
});
diff --git a/crates/benilla-world/src/static_gx/mod.rs b/crates/benilla-world/src/static_gx/mod.rs
index af7bfc1d..50b43362 100644
--- a/crates/benilla-world/src/static_gx/mod.rs
+++ b/crates/benilla-world/src/static_gx/mod.rs
@@ -26,6 +26,7 @@ mod shadow; // MONKEY (world shadows): CPU triangle collection for the static-wo
mod torch_depth; // MONKEY (torch shadows Phase 1): the per-fixture depth-map render + its targets
pub use shadow::caster_in_reach; // MONKEY (B035): bounds-based caster reach, shared with the entity lane
pub use shadow::CutoutBucket; // MONKEY (world shadows): per-leaf-texture alpha-cutout caster group
+pub use render::StaticGxMirror; // MONKEY (planar water): the water mirror's view draws the retained set too
pub use torch_depth::TorchShadowViews; // MONKEY (torch shadows Phase 1): the app→render publication
// MONKEY (torch owner exclusion): the light→caster ownership key, written at the light spawn
// sites (`terrain_stream::spawn`, `benilla_app::entities::carried_light`) and read by both
diff --git a/crates/benilla-world/src/static_gx/render.rs b/crates/benilla-world/src/static_gx/render.rs
index 8f46a1fe..fc22dd63 100644
--- a/crates/benilla-world/src/static_gx/render.rs
+++ b/crates/benilla-world/src/static_gx/render.rs
@@ -89,6 +89,13 @@ pub(crate) struct GxCellDraw {
#[derive(Component, Clone, Copy, Default, ExtractComponent)]
pub(crate) struct StaticGxView;
+/// MONKEY (planar water): the water mirror's view (`liquid/mirror.rs`) draws the same retained
+/// set as the world camera - the cull is the world camera's, a superset of what the mirror needs
+/// bar the rare object above the frame. Apart from [`StaticGxView`], so the per-view torch depth
+/// pass and the pipeline key stay the world camera's alone.
+#[derive(Component, Clone, Copy, Default, ExtractComponent)]
+pub struct StaticGxMirror;
+
/// Mark the world camera, again whenever it respawns.
fn mark_world_camera(
mut commands: Commands,
@@ -1003,8 +1010,9 @@ impl ViewNode for StaticGxNode {
&'static ViewEnvironmentMapUniformOffset,
Option<&'static OrderIndependentTransparencySettingsOffset>,
&'static MeshViewBindGroup,
- // The world camera only — a booth bake must never receive world cells (see the marker).
- &'static StaticGxView,
+ // The world camera only — a booth bake must never receive world cells (see the marker) —
+ // and MONKEY (planar water) the water mirror's view.
+ AnyOf<(&'static StaticGxView, &'static StaticGxMirror)>,
);
fn run<'w>(
@@ -1155,6 +1163,7 @@ pub(super) fn build(app: &mut App) {
app.add_plugins((
ExtractResourcePlugin::::default(),
ExtractComponentPlugin::::default(),
+ ExtractComponentPlugin::::default(),
));
app.init_resource::();
app.add_systems(Update, mark_world_camera);