From a74a46594c8ea2542797b52936a064f79c30addc Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Thu, 24 Sep 2026 16:58:55 +0100
Subject: [PATCH] MONKEY: enhanced water module (Water Quality:
Classic/Enhanced/High) + lava glow
Water (Enhanced/High only; Classic stays byte-identical to upstream):
- scene depth resolved after the main opaque pass and bound to liquids
(liquid/scene_depth.rs, water_depth.rs)
- procedural multi-band waves with analytic normals; long ocean swell moves
vertices, mirrored on the CPU (liquid/waves.rs)
- separate ocean and inland profiles; depth-driven colour and soft edge
- Fresnel sky reflection, sun path, moon path, point-light glints
- beach surf driven by the shore wave phase: continuous front, dissolving
lace, swash sheet; only where the authored sea bed is shallow, so sunken
objects never foam; thin bed clutter (grass, reeds) ignored for contact foam
- swimmers: wake foam, treading rings, visual-only bob with the swell
- river flow effect tried and removed at the owner's request
Lava: magma surfaces emit warm point lights through the lighting module
(lighting/lava_light.rs, lavaLightGain).
Options: Water Quality + Lava Glow rows (EN, RU strings prepared).
Docs: WATER.md. Credits: design reference WarcraftXL wxl-experimental-water,
author iThorgrim (reuse permitted with attribution).
Co-Authored-By: Claude Opus 5.5
---
WATER.md | 73 +++
crates/benilla-app/assets/ui/OptionsFrame.xml | 70 +-
crates/benilla-app/src/capture/scenarios.rs | 161 +++++
crates/benilla-app/src/cvars.rs | 59 +-
.../src/debug_panel/lighting_controls.rs | 4 +
crates/benilla-app/src/dynamic_interior.rs | 48 +-
crates/benilla-app/src/game_plugins.rs | 1 +
.../src/ui_script/options_tests.rs | 46 +-
crates/benilla-app/src/video.rs | 14 +-
crates/benilla-assets/src/lib.rs | 2 +
crates/benilla-assets/src/materials.rs | 20 +-
crates/benilla-assets/src/shaders/liquid.wgsl | 467 ++++++++++++-
crates/benilla-assets/src/water_depth.rs | 27 +
crates/benilla-world/src/lighting/daylight.rs | 14 +-
.../src/lighting/global_light.rs | 4 +-
.../benilla-world/src/lighting/lava_light.rs | 617 ++++++++++++++++++
crates/benilla-world/src/lighting/mod.rs | 7 +
crates/benilla-world/src/liquid/mod.rs | 5 +
crates/benilla-world/src/liquid/query.rs | 17 +
.../benilla-world/src/liquid/scene_depth.rs | 255 ++++++++
crates/benilla-world/src/liquid/surface.rs | 31 +-
crates/benilla-world/src/liquid/waves.rs | 242 +++++++
crates/benilla-world/src/water_fx/bob.rs | 454 +++++++++++++
crates/benilla-world/src/water_fx/mod.rs | 222 ++++++-
crates/benilla-world/src/water_fx/params.rs | 262 +++++++-
25 files changed, 3042 insertions(+), 80 deletions(-)
create mode 100644 WATER.md
create mode 100644 crates/benilla-assets/src/water_depth.rs
create mode 100644 crates/benilla-world/src/lighting/lava_light.rs
create mode 100644 crates/benilla-world/src/liquid/scene_depth.rs
create mode 100644 crates/benilla-world/src/liquid/waves.rs
create mode 100644 crates/benilla-world/src/water_fx/bob.rs
diff --git a/WATER.md b/WATER.md
new file mode 100644
index 00000000..8713a44e
--- /dev/null
+++ b/WATER.md
@@ -0,0 +1,73 @@
+# benilla-water
+
+An optional enhanced water renderer for [benilla](https://github.com/samwhosung/benilla), the Rust +
+Bevy reimplementation of the 1.12.1 client. It needs no data changes: everything is derived from
+what a 1.12 install already carries (MCLQ / MLIQ liquid grids and their authored depth bytes, the
+`Light.dbc` water and sky rows, the opaque scene depth).
+
+It is a **module on top of the reference water, not a replacement for it**. The reference path
+(`Classic`) is untouched and stays byte-identical to upstream; the enhanced path only runs when the
+player picks it, and every part of it can be switched off.
+
+## Credits
+
+- **Original project: [WarcraftXL](https://github.com/WarcraftXL)** — the `wxl-experimental-water`
+ module for the 1.12 client.
+- **Author: iThorgrim.**
+
+The WarcraftXL water module is the design reference for this one, and its author has given
+permission to reuse its code here on the condition that the author and the original project are
+named. This section is that attribution; it must travel with the module (this file, and the
+`liquid.wgsl` header) wherever the module is copied or shared.
+
+Rule for contributors: a file that ports code or a technique from WarcraftXL says so in its header
+(`Portions derived from WarcraftXL wxl-experimental-water by iThorgrim, used with permission`) and
+the technique is listed in the table below. Do not remove either.
+
+| Technique | Status here | From WarcraftXL |
+|---|---|---|
+| Visual-only wave riding for swimmers (authoritative position never moves) | shipped (`water_fx/bob.rs`) | same principle as `world/Ride.cpp`, implemented independently |
+| 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 | planned | `shaders/Surface.ps.hlsl`, `render/Refraction.cpp` |
+| Caustics, two layers multiplied | planned | `shaders/Surface.ps.hlsl`, `render/Noise.cpp` |
+| One-probe screen-space reflection | planned (`High`) | `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` |
+
+## What it does
+
+| Lane | What you see | Main switch |
+|---|---|---|
+| Quality tiers | `Classic` = the reference water, `Enhanced`, `High` (currently = Enhanced) | Video options → Water Quality, cvar `waterQuality`, env `WOW_WATER=0\|1\|2` |
+| Procedural waves | multi-band analytic waves with exact normals; the long swell moves ocean vertices | tier |
+| Ocean / inland profiles | the sea and lakes/rivers have their own colour, energy and reflectivity | tier |
+| Depth look | soft shoreline, depth-driven colour and opacity from the real scene depth | tier |
+| Reflection and glints | Fresnel sky reflection from the zone's sky rows, sun path, moon path, point-light glints | tier |
+| Beach surf | a travelling wave front, a lace of foam that dissolves behind it, a swash sheet on the sand | tier |
+| Swimmers | wake foam, treading rings and a visual-only bob with the swell | tier |
+| Lava glow | magma surfaces emit warm point lights through the lighting module | Advanced Graphics → Lava Glow, `lavaLightGain` |
+
+Rivers deliberately have NO flow effect: a derived current was tried and removed at the owner's
+request. If it ever returns it should read the authored MCLQ flow records, not derive one.
+
+## Where the code lives
+
+- **Shader**: `crates/benilla-assets/src/shaders/liquid.wgsl` — `enhanced_water()` and its helpers;
+ the `fragment` entry point branches into it and otherwise runs the untouched reference path.
+- **Assets** (`crates/benilla-assets/src`): `water_depth.rs` (the scene-depth binding),
+ `materials.rs` (`LiquidExt`: quality, wave energy, sky rows, celestial), `lib.rs` (`WaterQuality`).
+- **World** (`crates/benilla-world/src`): `liquid/scene_depth.rs` (resolves the opaque depth after
+ the main opaque pass), `liquid/waves.rs` (the CPU mirror of the vertex swell), `liquid/surface.rs`
+ (per-kind material parameters), `water_fx/bob.rs` (swimmer bob), `lighting/lava_light.rs`.
+- **App** (`crates/benilla-app/src`): the setting in `cvars.rs` / `video.rs`, the options rows in
+ `assets/ui/OptionsFrame.xml`, the capture scenes `water-*` and `lava-*` in `capture/scenarios.rs`.
+
+## Invariants worth knowing before editing
+
+- `Classic` must stay byte-identical: capture `water-noon` with `WOW_WATER=0` and compare with the
+ baseline before and after any edit.
+- The CPU swell (`liquid/waves.rs`) and the shader's `WATER_WAVES` table are one contract.
+- Every screen derivative in `enhanced_water()` is taken at the top of the function (WGSL
+ uniformity); WGSL only fails at pipeline creation, so verify by running a `water-*` scene.
+- Never rotate a noise domain by a per-pixel angle at world coordinates (~1e4 yd): it shears.
+- Run one GPU capture at a time, and run `cargo test` in a shell WITHOUT `WOW_DATA` exported.
diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index 9e9cbae9..c283b662 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -835,6 +835,11 @@
enUS = {
page = "Advanced Graphics",
lightingQuality = "Lighting Quality",
+ waterQuality = "Water Quality",
+ waterClassic = "Classic",
+ waterEnhanced = "Enhanced",
+ waterHigh = "High",
+ lavaGlow = "Lava Glow",
qOff = "Off",
qLow = "Low",
qMedium = "Medium",
@@ -858,13 +863,20 @@
tips = {
LIGHTING_QUALITY =
"Sets character, world, indoor torch and outdoor torch shadows; dynamic "
- .. "interior lighting; moon shadows; fire and spell brightness; fire flicker; "
+ .. "interior lighting; moon shadows; water quality; lava glow; fire and spell brightness; fire flicker; "
.. "and night and interior darkness. Low, Medium and High also set shadow "
.. "resolution; Medium and High also set resident and moving torch shadow counts.\n\n"
- .. "Off restores the original client's look: no synthesised fire lights, "
+ .. "Off restores the original client's look: Classic water, no lava glow or synthesised fire lights, "
.. "night gain 1.0 and interior gain 1.0. All other settings, including "
.. "Shadow Distance and Torch Shadow Softness, stay as you set them. "
+ .. "Low, Medium and High use Enhanced water; mirror reflections stay opt-in. "
.. "Changing a preset member shows Custom when no preset matches.",
+ WATER_QUALITY =
+ "Classic is the original client's water. Enhanced adds soft shorelines, "
+ .. "animated foam around shores and objects, moving wave highlights, sky reflection "
+ .. "and light glints. High also adds mirror reflections and costs the most.",
+ LAVA_GLOW =
+ "How strongly lava lights its surroundings. 0 turns the glow off.",
CHARACTER_SHADOWS =
"Renders real dynamic silhouettes for players, NPCs, creatures, and mounts, replacing "
.. "the legacy oval blob. Matches Wrath's character-only shadow tier. This uses "
@@ -941,6 +953,11 @@
ruRU = {
page = "Расширенная графика",
lightingQuality = "Качество освещения",
+ waterQuality = "Качество воды",
+ waterClassic = "Классическое",
+ waterEnhanced = "Улучшенное",
+ waterHigh = "Высокое",
+ lavaGlow = "Свечение лавы",
qOff = "Выкл.",
qLow = "Низкое",
qMedium = "Среднее",
@@ -964,15 +981,22 @@
tips = {
LIGHTING_QUALITY =
"Задаёт тени персонажей, мира и огней внутри и снаружи помещений; "
- .. "динамическое освещение помещений; лунные тени; яркость огня и заклинаний; "
+ .. "динамическое освещение помещений; лунные тени; качество воды; свечение лавы; яркость огня и заклинаний; "
.. "мерцание огня; темноту ночи и помещений. «Низкое», «Среднее» и «Высокое» "
.. "также задают разрешение теней; «Среднее» и «Высокое» — число карт теней "
.. "от огней и число огней с тенями движущихся объектов.\n\n"
- .. "«Выкл.» возвращает вид оригинального клиента: без созданных из пламени "
+ .. "«Выкл.» возвращает вид оригинального клиента: классическая вода, без свечения лавы и созданных из пламени "
.. "источников света, с коэффициентами яркости ночи и помещений 1.0. "
.. "Все остальные настройки, включая дальность теней и мягкость теней от огня, "
- .. "сохраняют ваши значения. Изменение настройки набора показывает «Своё», "
+ .. "сохраняют ваши значения. «Низкое», «Среднее» и «Высокое» используют улучшенную воду; "
+ .. "зеркальные отражения включаются отдельно. Изменение настройки набора показывает «Своё», "
.. "если ни один набор больше не совпадает.",
+ WATER_QUALITY =
+ "«Классическое» — вода оригинального клиента. «Улучшенное» добавляет плавные переходы у берегов, "
+ .. "анимированную пену у берегов и вокруг объектов, подвижные блики на волнах, отражение неба "
+ .. "и блики от источников света. «Высокое» также добавляет зеркальные отражения и требует больше всего ресурсов.",
+ LAVA_GLOW =
+ "Насколько сильно лава освещает всё вокруг. При 0 свечение отключено.",
CHARACTER_SHADOWS =
"Рисует настоящие динамические силуэты игроков, НИП, существ и ездовых "
.. "животных вместо старого овального пятна. Соответствует уровню теней "
@@ -1112,11 +1136,11 @@
-- the rig they share, and the Graphics page had become the place where a player found
-- three of eighteen shadow settings and no hint that the other fifteen existed.
AdvancedGraphics = {
- "RowLightingQuality",
+ "RowLightingQuality", "RowWaterQuality",
"RowCharacterShadows", "RowWorldShadows", "RowShadowDistance",
"RowShadowResolution", "RowMoonShadows",
"RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", "RowTorchSoft",
- "RowFireLight", "RowFireFlicker", "RowSpellLights",
+ "RowFireLight", "RowFireFlicker", "RowSpellLights", "RowLavaGlow",
"RowNightDarkness", "RowInteriorDarkness",
},
-- Player Guild Names (2180) rides as a CHILD of Player Names, which is the
@@ -3584,11 +3608,27 @@
})
-
+
+
+ OptionsRow_OnLoad(self, "waterQuality",
+ BENILLA_ADVGFX.waterQuality,
+ "BENILLA_TOOLTIP_WATER_QUALITY")
+ OptionsDropdownRow_Setup(self, {
+ { text = BENILLA_ADVGFX.waterClassic, value = "0" },
+ { text = BENILLA_ADVGFX.waterEnhanced, value = "1" },
+ { text = BENILLA_ADVGFX.waterHigh, value = "2" },
+ })
+
+
+
+
+
+
+
OptionsRow_OnLoad(self, "characterShadows",
BENILLA_ADVGFX.characterShadows,
@@ -3760,11 +3800,23 @@
OptionsSliderRow_Setup(self, 0, 4, 0.05)
-
+
+
+ OptionsRow_OnLoad(self, "lavaLightGain",
+ BENILLA_ADVGFX.lavaGlow,
+ "BENILLA_TOOLTIP_LAVA_GLOW")
+ OptionsSliderRow_Setup(self, 0, 4, 0.05)
+
+
+
+
+
+
+
-- 0.2..1.5, the CVar's own clamp verbatim: the
-- floor is 0.2 rather than 0 because a true 0 is
diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs
index d77c394f..db116d71 100644
--- a/crates/benilla-app/src/capture/scenarios.rs
+++ b/crates/benilla-app/src/capture/scenarios.rs
@@ -474,6 +474,116 @@ pub(super) const SUBJECT_INDOOR: [f32; 3] = [-9469.4, 31.9, 57.9];
/// fixtures, the house-compass and street scenes. Capturable by name (`WOW_CAPTURE=`) for
/// debugging and look passes, but NOT part of the blessed baseline sweep.
pub(super) const ON_DEMAND: &[Scenario] = &[
+ // WOW_CAPTURE_WATER_T=: fixed Enhanced water phase (read once, default 0).
+ // Compare 0 and 1.5; Classic and non-water animation stay frozen.
+ // Above Lakeshire's broken docks, looking across Lake Everstill and its rocky shores.
+ Scenario {
+ name: "water-lake",
+ map: Some(MAP_AZEROTH),
+ eye: [-9350.0, -2340.0, 82.0],
+ look: [-9367.0, -2436.0, 57.1],
+ minute: 720,
+ ui: None,
+ },
+ // Westfall western coast: sand in the foreground, open sea to the west.
+ Scenario {
+ name: "water-ocean",
+ map: Some(MAP_AZEROTH),
+ eye: [-10500.0, 2112.0, 6.0],
+ look: [-10420.0, 2192.0, 0.0],
+ minute: 720,
+ ui: None,
+ },
+ Scenario {
+ name: "water-ocean-dusk",
+ map: Some(MAP_AZEROTH),
+ eye: [-10500.0, 2112.0, 6.0],
+ look: [-10420.0, 2192.0, 0.0],
+ minute: 1110,
+ ui: None,
+ },
+ // Owner's Westfall shore, facing 0.86 rad with the white moon above the sea.
+ Scenario {
+ name: "water-ocean-moon",
+ map: Some(MAP_AZEROTH),
+ eye: [-9754.7, 1714.9, 3.0],
+ look: [-9689.45, 1790.68, 7.0],
+ minute: 170,
+ ui: None,
+ },
+ // Elevated oblique beach view: compare phases 0, 2 and 4 seconds for swash run-up.
+ Scenario {
+ name: "water-beach-top",
+ map: Some(MAP_AZEROTH),
+ eye: [-9754.7, 1714.9, 30.7],
+ look: [-9739.8, 1700.0, 0.6],
+ minute: 400,
+ ui: None,
+ },
+ // Elwynn river from flight height; both banks and the river bed at dusk.
+ Scenario {
+ name: "water-river-dusk",
+ map: Some(MAP_AZEROTH),
+ eye: [-9500.0, -390.0, 97.7],
+ look: [-9500.0, -433.26, 61.4],
+ minute: 1140,
+ ui: None,
+ },
+ // The same river from well above: several chunks and a bend in one frame (flow continuity).
+ Scenario {
+ name: "water-river-high",
+ map: Some(MAP_AZEROTH),
+ eye: [-9440.0, -380.0, 150.0],
+ look: [-9500.0, -433.26, 57.6],
+ minute: 1080,
+ ui: None,
+ },
+ // Standing in the shallows at the bank: bed clutter seen through a foot of water.
+ Scenario {
+ name: "water-river-bank",
+ map: Some(MAP_AZEROTH),
+ eye: [-9500.0, -418.0, 60.2],
+ look: [-9500.0, -428.0, 57.2],
+ minute: 720,
+ ui: None,
+ },
+ // The sunken rowing boat off Longshore: an OBJECT in shallow scene depth over a deeper bed -
+ // the surf must not be painted on its hull (owner screenshot).
+ Scenario {
+ name: "water-wreck",
+ map: Some(MAP_AZEROTH),
+ eye: [-9606.0, 1257.0, 9.0],
+ look: [-9588.0, 1256.0, 0.0],
+ minute: 480,
+ ui: None,
+ },
+ // Canal water, wall, hull and dock-post contacts in one near view.
+ Scenario {
+ name: "stormwind-canal-near",
+ map: Some(MAP_AZEROTH),
+ eye: [-8850.0, 760.0, 102.0],
+ look: [-8779.29, 830.71, 78.91],
+ minute: 720,
+ ui: None,
+ },
+ // Sunset companion to the near canal contact probe, matching the rejected water reference.
+ Scenario {
+ name: "water-canal-dusk",
+ map: Some(MAP_AZEROTH),
+ eye: [-8850.0, 760.0, 102.0],
+ look: [-8779.29, 830.71, 78.91],
+ minute: 1110,
+ ui: None,
+ },
+ // Enhanced-water contact probe: lower, closer view across the Elwynn river bank.
+ Scenario {
+ name: "water-shore",
+ map: Some(MAP_AZEROTH),
+ eye: [-9520.0, -321.0, 65.5],
+ look: [-9501.0, -350.0, 61.4],
+ minute: 720,
+ ui: None,
+ },
// ---- The Deeprun Tram's undersea tube (map 369) ----
// The one shipped map with NO `Light.dbc` row at all — not even the falloff-0 global that maps
// 0/1 carry — so its whole atmosphere has to come from the building's own MFOG (record 2:
@@ -1200,8 +1310,59 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: Some(UiFixture::NameWater),
},
+ // ---- Searing Gorge, the MAGMA-LIGHT reproducers (report: "lava not really glowing") ----
+ // `lava-searing` is the owner's own spot and clock, server-less: their `.go xyz` pin lifted to
+ // eye height, looking down their reported facing of 1.53 rad, at MIDNIGHT — the only time of
+ // day at which a warm fixture over lava is separable from the sun. `-noon` is the control (the
+ // same frame with the sky on: if the rock reads in one and not the other, the defect is the
+ // fixture's and not the terrain's). `-river` stands ON the bank of a lava channel, because the
+ // owner's pin may simply be out of reach of any magma — 16 yd is a brazier, not a floodlight.
+ Scenario {
+ name: "lava-searing",
+ map: Some(MAP_AZEROTH),
+ eye: LAVA_SEARING_EYE,
+ look: LAVA_SEARING_LOOK,
+ minute: 0,
+ ui: None,
+ },
+ Scenario {
+ name: "lava-searing-noon",
+ map: Some(MAP_AZEROTH),
+ eye: LAVA_SEARING_EYE,
+ look: LAVA_SEARING_LOOK,
+ minute: 720,
+ ui: None,
+ },
+ Scenario {
+ name: "lava-searing-river",
+ map: Some(MAP_AZEROTH),
+ eye: LAVA_RIVER_EYE,
+ look: LAVA_RIVER_LOOK,
+ minute: 0,
+ ui: None,
+ },
];
+/// The owner's reported vantage for the lava-glow report: `(-7048.8, -1000.6, 242.0)` facing
+/// 1.53 rad, Searing Gorge, map 0. The eye takes the standard [`VISTA_EYE_HEIGHT`]-ish lift off
+/// the pin (a `.go` lands at the feet) and the look runs 60 yd down the reported facing,
+/// `(cos 1.53, sin 1.53) = (0.041, 0.999)`, pitched a little down so the GROUND — the surface the
+/// report says is only faintly tinted — fills the lower frame rather than the sky.
+pub(super) const LAVA_SEARING_EYE: [f32; 3] = [-7048.8, -1000.6, 244.0];
+pub(super) const LAVA_SEARING_LOOK: [f32; 3] = [-7046.3, -940.7, 236.0];
+
+/// An OPEN lava river in Searing Gorge, found from the run's own magma census (see
+/// `_fx/lava_findings.md`) — the shot that answers "does magma light the rock beside it", with the
+/// magma unambiguously inside a fixture's reach and the receivers on the EXTERIOR lane.
+///
+/// Picking this spot was the round's methodological lesson. The obvious choice — the nearest magma
+/// to the owner's pin — is a channel under a cave roof, where every visible surface is
+/// interior-class WMO. A "does the lava light the rock" instrument planted there measures the
+/// interior lane and says nothing about the open world, and it cost one wrong conclusion before
+/// the pixels caught it. This vantage is open sky over open terrain.
+pub(super) const LAVA_RIVER_EYE: [f32; 3] = [-7469.25, -848.17, 269.83];
+pub(super) const LAVA_RIVER_LOOK: [f32; 3] = [-7505.6, -861.4, 259.5];
+
/// The `name-close` instrument's subject: the `name-water` wolf's overhead NAME, orbited at an
/// arbitrary distance. **World text is the only consumer that draws the glyph sheet at anything
/// but 1:1** — the 2-D path rasterizes at the size it draws, texel for pixel, by construction — so
diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs
index 8f2ce5d4..5fee6b33 100644
--- a/crates/benilla-app/src/cvars.rs
+++ b/crates/benilla-app/src/cvars.rs
@@ -926,6 +926,16 @@ pub(crate) const REGISTERED: &[Registered] = &[
Medium / High, or Custom when the members match none of them; the reference has no \
realtime light or shadow system to preset",
),
+ ours(
+ "waterQuality",
+ "1",
+ "benilla's own: water quality, 0 Classic / 1 Enhanced / 2 High; mirror reflections are opt-in",
+ ),
+ ours(
+ "lavaLightGain",
+ "1",
+ "benilla's own: brightness of lava lighting its surroundings, 0..4 (0 = no glow)",
+ ),
// Benilla's opt-in realtime shadow-map path, split into two INDEPENDENT lanes over one shared
// shadow rig (one sun / one map). `worldShadows` = the static world (trees, buildings, foliage)
// casts realtime shadows and baked MCSH terrain shadows switch off; `characterShadows` =
@@ -1990,6 +2000,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
("interiorShadows", "0"),
("exteriorShadows", "0"),
("spellLightGain", "0"),
+ ("waterQuality", "0"),
+ ("lavaLightGain", "0"),
("fireLightGain", "0"),
("nightGain", "1.0"),
("interiorGain", "1.0"),
@@ -2009,6 +2021,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
("interiorShadows", "0"),
("exteriorShadows", "0"),
("spellLightGain", "1"),
+ ("waterQuality", "1"),
+ ("lavaLightGain", "1"),
("fireLightGain", "1"),
("nightGain", "0.45"),
("interiorGain", "0.5"),
@@ -2031,6 +2045,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
("interiorShadowDynamic", "2"),
("exteriorShadows", "0"),
("spellLightGain", "1"),
+ ("waterQuality", "1"),
+ ("lavaLightGain", "1"),
("fireLightGain", "1"),
("nightGain", "0.45"),
("interiorGain", "0.5"),
@@ -2053,6 +2069,9 @@ 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"),
+ ("lavaLightGain", "1"),
("fireLightGain", "1"),
("nightGain", "0.45"),
("interiorGain", "0.5"),
@@ -2856,7 +2875,7 @@ mod tests {
assert_eq!(d["gxVSync"] != 0.0, VideoConfig::default().vsync);
// ── MONKEY (lighting): the weld for the dynamic light + shadow system's whole row set ──
//
- // **All 29, as one census, with the count asserted.** Every one of them lands on
+ // **All 32, as one census, with the count asserted.** Every one of them lands on
// `VideoConfig` and is read from there per frame (`shadow_core::update_shadows`,
// `dynamic_interior::bridge`, `torch_shadow`), so a registered default that drifts from
// the struct's literal is a setting that reads one way in `config.toml` and renders
@@ -2865,7 +2884,9 @@ mod tests {
// a row, forgot its weld" fail HERE: the length check below is the gate.
let shadows = VideoConfig::default();
let flag = |b: bool| if b { 1.0 } else { 0.0 };
- let lighting: [(&str, f32); 30] = [
+ let lighting: [(&str, f32); 32] = [
+ ("waterQuality", shadows.water_quality as f32),
+ ("lavaLightGain", shadows.lava_light_gain),
// The two sun lanes and the cascade they share.
("worldShadows", flag(shadows.world_shadows)),
("characterShadows", flag(shadows.character_shadows)),
@@ -2917,9 +2938,9 @@ mod tests {
assert_eq!(d[name], want, "{name}: registered default left the knob");
}
// …and the census IS the row set. A name here that nothing registers would weld against a
- // row the client does not have; the length is the other half — 30 rows, 30 welds.
+ // row the client does not have; the length is the other half — 32 rows, 32 welds.
let welded: std::collections::BTreeSet<&str> = lighting.iter().map(|(n, _)| *n).collect();
- assert_eq!(welded.len(), 30, "the lighting lane welds 30 distinct rows");
+ assert_eq!(welded.len(), 32, "the lighting lane welds 32 distinct rows");
for name in &welded {
assert!(
REGISTERED.iter().any(|r| r.name == *name),
@@ -2934,7 +2955,9 @@ 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");
- // The 31st lighting row, `lightingQuality`, is deliberately NOT in that census: it is the
+ assert_eq!(d["waterQuality"], 1.0, "mirror reflections stay opt-in");
+ assert_eq!(d["lavaLightGain"], 1.0);
+ // The 33rd lighting row, `lightingQuality`, is deliberately NOT in that census: it is the
// only one with no `VideoConfig` knob to weld to, because it is a NAME for the rows above
// rather than a knob of its own. Its own weld is `the_high_preset_is_the_registered_
// defaults` — a fresh config must read "High", not "Custom".
@@ -2971,9 +2994,9 @@ mod tests {
assert_eq!(derive_lighting_quality(&cvars), *name, "{from} -> {name}");
assert_eq!(cvars.get("shadowDistance"), Some("120"));
assert_eq!(cvars.get("interiorShadowSoft"), Some("2.5"));
- for member in ["fireLightGain", "nightGain", "interiorGain"] {
+ for member in ["fireLightGain", "waterQuality", "lavaLightGain", "nightGain", "interiorGain"] {
let expected = if *name == "Off" {
- if member == "fireLightGain" { "0" } else { "1.0" }
+ if matches!(member, "fireLightGain" | "waterQuality" | "lavaLightGain") { "0" } else { "1.0" }
} else {
cvars.default_of(member).unwrap()
};
@@ -3089,6 +3112,8 @@ mod tests {
"interiorShadowCasters" => video.interior_shadow_casters as f32,
"interiorShadowDynamic" => video.interior_shadow_dynamic as f32,
"spellLightGain" => video.spell_light_gain,
+ "waterQuality" => video.water_quality as f32,
+ "lavaLightGain" => video.lava_light_gain,
"fireLightGain" => video.fire_light_gain,
"moonShadowStrength" => video.moon_shadow_strength,
"fireFlicker" => video.fire_flicker,
@@ -3135,17 +3160,35 @@ mod tests {
}
#[test]
- fn fire_and_spell_gains_above_two_reach_the_video_observer() {
+ fn fire_spell_and_lava_gains_above_two_reach_the_video_observer() {
let mut app = cvar_app();
for value in ["3.25", "4"] {
apply(&mut app, "fireLightGain", value);
apply(&mut app, "spellLightGain", value);
+ apply(&mut app, "lavaLightGain", value);
let expected = value.parse::().unwrap();
assert_eq!(res::(&app).fire_light_gain, expected);
assert_eq!(res::(&app).spell_light_gain, expected);
+ assert_eq!(res::(&app).lava_light_gain, expected);
}
}
+ #[test]
+ fn water_and_lava_observers_clamp_and_water_high_is_opt_in() {
+ let mut app = cvar_app();
+ for (value, water, lava) in [("-1", 0, 0.0), ("1.75", 1, 1.75), ("2", 2, 2.0), ("9", 2, 4.0)] {
+ apply(&mut app, "waterQuality", value);
+ apply(&mut app, "lavaLightGain", value);
+ assert_eq!(res::(&app).water_quality, water);
+ assert_eq!(res::(&app).lava_light_gain, lava);
+ }
+ let mut cvars = fresh_registry();
+ cvars.set("waterQuality", "2");
+ assert_eq!(derive_lighting_quality(&cvars), LIGHTING_CUSTOM);
+ apply_lighting_preset(&mut cvars, "High");
+ assert_eq!(cvars.get("waterQuality"), Some("1"));
+ }
+
/// **Every arm, through the registry and its observers.** The old central `apply_to_knobs`
/// match is gone (2303); each arm lives beside the knob it writes, and this drives the whole
/// set through a real `App` — a host write, the observers firing synchronously — so a knob
diff --git a/crates/benilla-app/src/debug_panel/lighting_controls.rs b/crates/benilla-app/src/debug_panel/lighting_controls.rs
index 579af128..12190dc3 100644
--- a/crates/benilla-app/src/debug_panel/lighting_controls.rs
+++ b/crates/benilla-app/src/debug_panel/lighting_controls.rs
@@ -60,6 +60,9 @@ 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."),
+ ]),
("Interior light", INTERIOR),
("Fire", &[
knob!("fireLightGain", fire_light_gain, Slider(0.0..=4.0), "Brightness of lights synthesized from flame emitters."),
@@ -67,6 +70,7 @@ const GROUPS: &[(&str, &[Knob])] = &[
// sources this group tunes — it is just the one whose brightness is a combat setting rather
// than a scenery one, which is exactly why it needs its own dial and not the one above it.
knob!("spellLightGain", spell_light_gain, Slider(0.0..=4.0), "Brightness of spell, missile and impact lights."),
+ knob!("lavaLightGain", lava_light_gain, Slider(0.0..=4.0), "How strongly lava lights its surroundings; 0 turns the glow off."),
knob!("fireFlicker", fire_flicker, Slider(0.0..=2.0), "0 steady, 1 default, 2 pronounced."),
]),
("Torch shadows", &[
diff --git a/crates/benilla-app/src/dynamic_interior.rs b/crates/benilla-app/src/dynamic_interior.rs
index e838b44f..e34976f6 100644
--- a/crates/benilla-app/src/dynamic_interior.rs
+++ b/crates/benilla-app/src/dynamic_interior.rs
@@ -22,7 +22,10 @@
//! liveness requirement — and `0` is that lane's faithful null the way `fireLightGain 0` is the
//! synthesised-fire lane's kill switch. It is what the Advanced Graphics page's Moon Shadows row
//! and the Off/Low presets reach.
-use benilla_world::lighting::{DynamicInteriors, FireLightGain, MoonShadowStrength, SpellLightGain};
+//!
+//! Water quality and lava glow use the same guarded bridge into their renderer resources.
+use benilla_assets::WaterQuality;
+use benilla_world::lighting::{DynamicInteriors, FireLightGain, LavaLightGain, MoonShadowStrength, SpellLightGain};
use bevy::prelude::*;
use crate::video::VideoConfig;
@@ -55,7 +58,15 @@ fn bridge(
// MONKEY (moon shadows): the night lane's shadow darkness, on the same bridge and for the same
// reasons as the two gains above it — one live `f32`, one benilla-world resource, one guard.
mut moon: ResMut,
+ mut water: ResMut,
+ mut lava: ResMut,
) {
+ if water.0 != video.water_quality {
+ water.0 = video.water_quality;
+ }
+ if lava.0 != video.lava_light_gain {
+ lava.0 = video.lava_light_gain;
+ }
if fire.0 != video.fire_light_gain {
fire.0 = video.fire_light_gain;
}
@@ -104,3 +115,38 @@ fn bridge(
*out = want;
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn water_and_lava_bridge_publishes_changes_without_dirtying_idle_frames() {
+ let mut app = App::new();
+ app.init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .add_plugins(DynamicInteriorPlugin);
+ assert_eq!(app.world().resource::().0, VideoConfig::default().water_quality);
+ assert_eq!(app.world().resource::().0, VideoConfig::default().lava_light_gain);
+ app.update();
+ for (water, lava) in [(0, 0.0), (2, 3.25), (1, 1.0)] {
+ {
+ let mut video = app.world_mut().resource_mut::();
+ video.water_quality = water;
+ video.lava_light_gain = lava;
+ }
+ app.world_mut().run_schedule(Update);
+ assert_eq!(app.world().resource::().0, water);
+ assert_eq!(app.world().resource::().0, lava);
+ app.world_mut().clear_trackers();
+ app.world_mut().run_schedule(Update);
+ assert!(!app.world().resource_ref::().is_changed());
+ assert!(!app.world().resource_ref::().is_changed());
+ }
+ }
+}
diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs
index 623a2a0c..eaafbc24 100644
--- a/crates/benilla-app/src/game_plugins.rs
+++ b/crates/benilla-app/src/game_plugins.rs
@@ -120,6 +120,7 @@ impl PluginGroup for GamePlugins {
// ask for one. The engine's seven register themselves inside `WorldPlugins`, which
// `run()` adds ahead of this group.
.add(crate::shaders::plugin)
+ .add(benilla_world::liquid::WaterDepthPlugin)
.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 56272f59..ea840d16 100644
--- a/crates/benilla-app/src/ui_script/options_tests.rs
+++ b/crates/benilla-app/src/ui_script/options_tests.rs
@@ -1974,23 +1974,50 @@ fn defaults_resets_the_graphics_page_to_registered_defaults() {
const ADVGFX: &str = "BenillaOptionsFrameContainerBodyAdvancedGraphics";
#[test]
-fn advanced_graphics_fire_and_spell_sliders_read_and_write_the_full_range() {
+fn water_quality_writes_numeric_tiers_with_localised_labels() {
+ for (locale, labels, lava_label) in [
+ ("enUS", ["Classic", "Enhanced", "High"], "Lava Glow"),
+ ("ruRU", ["Классическое", "Улучшенное", "Высокое"], "Свечение лавы"),
+ ] {
+ let s = audio_harness();
+ s.run(&format!("function GetLocale() return '{locale}' end")).unwrap();
+ let mut s = harness_on(s);
+ s.run("ShowUIPanel(BenillaOptionsFrame) BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()").unwrap();
+ assert_eq!(s.eval::("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)").unwrap(), 17);
+ assert_eq!(s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[2]").unwrap(), "RowWaterQuality");
+ assert_eq!(s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[15]").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());
+ let _ = s.take_cvar_changes();
+ for (tier, label) in labels.iter().enumerate() {
+ s.run(&format!("OptionsRow_Set({ADVGFX}RowWaterQuality, '{tier}') OptionsDropdown_ShowValue({ADVGFX}RowWaterQuality)")).unwrap();
+ assert_eq!(s.take_cvar_changes(), vec![("waterQuality".to_string(), tier.to_string())]);
+ assert_eq!(s.eval::(&format!("return {ADVGFX}RowWaterQualityDropdownText:GetText()")).unwrap(), *label);
+ }
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+ }
+}
+
+#[test]
+fn advanced_graphics_fire_spell_and_lava_sliders_read_and_write_the_full_range() {
let mut s = audio_harness();
s.set_cvar_host("fireLightGain", "3.25");
s.set_cvar_host("spellLightGain", "3.25");
+ s.set_cvar_host("lavaLightGain", "3.25");
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame) \
BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
.unwrap();
let _ = s.take_cvar_changes();
- for (row, cvar) in [("RowFireLight", "fireLightGain"), ("RowSpellLights", "spellLightGain")] {
+ for (row, cvar) in [("RowFireLight", "fireLightGain"), ("RowSpellLights", "spellLightGain"), ("RowLavaGlow", "lavaLightGain")] {
assert_eq!(s.eval::(&format!(
"return {ADVGFX}{row}ControlSlider:GetValue()"
)).unwrap(), 3.25);
assert_eq!(s.eval::(&format!(
"return {ADVGFX}{row}ControlValue:GetText()"
)).unwrap(), "325%");
- for value in ["4", "2.75"] {
+ for value in ["4", "2.75", "0", "0.05"] {
s.run(&format!("{ADVGFX}{row}ControlSlider:SetValue({value})")).unwrap();
assert!(s.take_cvar_changes().contains(&(cvar.to_string(), value.to_string())));
assert_eq!(s.cvar(cvar).as_deref(), Some(value));
@@ -2083,6 +2110,7 @@ fn the_advanced_graphics_page_reads_the_lighting_cvars_on_select() {
("RowMoonShadows", "35%"),
("RowFireFlicker", "100%"),
("RowSpellLights", "100%"),
+ ("RowLavaGlow", "100%"),
] {
assert_eq!(
s.eval::(&format!("return {ADVGFX}{row}ControlValue:GetText()"))
@@ -2257,6 +2285,8 @@ fn the_page_repaints_when_a_host_write_moves_a_row_under_it() {
// Lua write behind it.
s.set_cvar_host("exteriorShadows", "0");
s.set_cvar_host("moonShadowStrength", "0");
+ s.set_cvar_host("waterQuality", "2");
+ s.set_cvar_host("lavaLightGain", "0.5");
// Under the poll interval: nothing has happened yet.
s.tick(0.1);
assert!(s
@@ -2272,6 +2302,8 @@ fn the_page_repaints_when_a_host_write_moves_a_row_under_it() {
.unwrap(),
"0%"
);
+ assert_eq!(s.eval::(&format!("return {ADVGFX}RowWaterQualityDropdownText:GetText()")).unwrap(), "High");
+ assert_eq!(s.eval::(&format!("return {ADVGFX}RowLavaGlowControlValue:GetText()")).unwrap(), "50%");
// The refresh writes NOTHING back — it is a read of the table, not a round trip.
assert!(
s.take_cvar_changes().is_empty(),
@@ -2868,6 +2900,8 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// that silently resolves to nothing — is untouched: the pairing below is exact, so a
// `BENILLA_` key on the wrong row still fails.
const BENILLA_OWNED: &[(&str, &str)] = &[
+ ("BENILLA_TOOLTIP_WATER_QUALITY", "AdvancedGraphicsRowWaterQuality"),
+ ("BENILLA_TOOLTIP_LAVA_GLOW", "AdvancedGraphicsRowLavaGlow"),
("BENILLA_TOOLTIP_RENDER_SCALE", "GraphicsRowRenderScale"),
("BENILLA_TOOLTIP_DISPLAY_MODE", "GraphicsRowDisplayMode"),
(
@@ -3000,7 +3034,8 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// Torch Shadow Softness, Fire Light Brightness, Fire Flicker, Spell Lights, Night Darkness
// and Interior Darkness. The three rows that MOVED onto it are already in the 81: a row
// changing pages does not change this count, only its entry in BENILLA_OWNED. 81 -> 93.
- assert_eq!(checked, 93, "every tipped row carries a live key");
+ // Water Quality and Lava Glow add two more: 93 -> 95.
+ assert_eq!(checked, 95, "every tipped row carries a live key");
assert_eq!(
untipped,
vec![
@@ -3129,7 +3164,8 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
// already counted, on their old page). 81 -> 93 — and the page is where this test's own
// teeth bite hardest, since it is the first one to seat a dropdown, a checkbox and a slider
// whose descriptions are all benilla's.
- assert_eq!(raised, 93, "every row but Auto Loot raises a description");
+ // Water Quality and Lava Glow add two more: 93 -> 95.
+ assert_eq!(raised, 95, "every row but Auto Loot raises a description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs
index a642bc18..b88cc850 100644
--- a/crates/benilla-app/src/video.rs
+++ b/crates/benilla-app/src/video.rs
@@ -217,6 +217,12 @@ pub(crate) fn boot_windowed_size() -> UVec2 {
#[derive(Resource, Clone, Copy, PartialEq, Debug)]
pub(crate) struct VideoConfig {
pub(crate) vsync: bool,
+ /// 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,
+ /// Brightness of lava lighting its surroundings, 0..4; 0 disables the glow.
+ /// Published to `benilla_world::lighting::LavaLightGain` by `dynamic_interior::bridge`.
+ pub(crate) lava_light_gain: f32,
/// Whether the STATIC WORLD (trees, buildings, foliage) casts realtime shadows and baked MCSH
/// terrain shadows are suppressed. Independent of [`Self::character_shadows`] — either drives
/// the shared shadow rig (`character_shadow` / `world_shadow`).
@@ -456,6 +462,8 @@ impl Default for VideoConfig {
interior_bake_floor: 0.12,
fire_light_gain: 1.0,
spell_light_gain: 1.0,
+ water_quality: 1,
+ lava_light_gain: 1.0,
fire_flicker: 1.0,
display: if windowed_env() {
DisplayMode::Windowed
@@ -516,7 +524,7 @@ pub(crate) fn on_cvar(
// Display mode (1627) — the reference's own polarity: `1` is WINDOWED (the row is
// "Windowed Mode"). `apply_window_mode` pushes it to the window when this moves.
"gxwindow" => cfg.display = display_from_flag(v),
- // ── MONKEY (lighting): the dynamic light + shadow system's 30 rows ────────────────────
+ // ── MONKEY (lighting): the dynamic light + shadow system's 32 rows ────────────────────
// They live in THIS observer, and not in one of their own beside `shadow_core` /
// `dynamic_interior`, because of the law the arm above states: *each arm writes only its
// own resource*. Every one of these knobs IS a field of [`VideoConfig`] — the lanes read
@@ -532,7 +540,9 @@ pub(crate) fn on_cvar(
//
// Clamps are each row's own, stated beside it, exactly as for the reference rows above;
// the `ours(...)` entries in `cvars::REGISTERED` carry the matching defaults, and
- // `cvars::tests::registered_defaults_mirror_the_code_truths` welds all 30 pairs.
+ // `cvars::tests::registered_defaults_mirror_the_code_truths` welds all 32 pairs.
+ "waterquality" => cfg.water_quality = v.clamp(0.0, 2.0) as u8,
+ "lavalightgain" => cfg.lava_light_gain = v.clamp(0.0, 4.0),
"worldshadows" => cfg.world_shadows = ev.flag(),
"charactershadows" => cfg.character_shadows = ev.flag(),
"shadowdistance" => {
diff --git a/crates/benilla-assets/src/lib.rs b/crates/benilla-assets/src/lib.rs
index 16c7d356..9e48304e 100644
--- a/crates/benilla-assets/src/lib.rs
+++ b/crates/benilla-assets/src/lib.rs
@@ -23,6 +23,8 @@ use bevy::prelude::*;
pub mod column_grid;
pub mod coords;
pub mod materials;
+mod water_depth;
+pub use water_depth::{WaterDepthImage, WaterQuality};
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 2dbcd523..68e141e2 100644
--- a/crates/benilla-assets/src/materials.rs
+++ b/crates/benilla-assets/src/materials.rs
@@ -565,13 +565,16 @@ pub type LiquidMaterial = ExtendedMaterial;
/// match the field order.
#[derive(Asset, AsBindGroup, Clone, TypePath)]
pub struct LiquidExt {
+ /// Opaque world depth resolved after both retained and Bevy geometry. No sampler needed.
+ #[texture(103, sample_type = "float", filterable = false, visibility(fragment))]
+ pub scene_depth: 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))]
#[sampler(101, visibility(fragment))]
pub frames: Handle,
/// The per-material constants that pick which *lanes* of the shared light this surface reads.
- /// None of them is a light value; all four are fixed at material creation.
+ /// Kind is fixed at creation; quality and the Enhanced sky rows can update at runtime.
///
/// - `x` = **fullbright** (>0.5 ⇒ magma/slime): the animated texture IS the opaque body — skip
/// the depth swatch and the N·L term. Not "skip the fog"; see `liquid.wgsl` and decision 0691.
@@ -591,14 +594,25 @@ pub struct LiquidExt {
/// MCLQ (`ocean0_s.bls`), `1` = WMO exterior (`MapObjExtWater0.bls`), `2` = WMO interior
/// (fixed-function, unlit). The reference has three liquid renderers with genuinely different
/// combines, stage counts and opacity sources; this is which one `liquid.wgsl` runs.
- /// - `y`/`z`/`w` reserved.
+ /// - `y` = water quality (0 Classic, 1 Enhanced, 2 High).
+ /// - `z` = wave energy: ocean 1.0, ADT river/lake 0.18, WMO pools 0.12.
+ /// - `w` = unused (was the river-flow dial; the effect was removed at the owner request).
#[uniform(102)]
pub path: Vec4,
- /// `x` = reserved (frame 0), `y` = frame count, `z` = scroll flag, `w` = clock enable —
+ /// `x` = fixed Enhanced capture time, `y` = frame count, `z` = scroll flag, `w` = clock enable —
/// the shader derives frame index and scroll from `globals.time` (liquid.wgsl `anim_time`);
/// nothing mutates this uniform after build.
#[uniform(102)]
pub anim: Vec4,
+ /// Enhanced reflection endpoints, linear RGB from the dome's resolved LightIntBand 2/6.
+ #[uniform(102)]
+ pub sky_zenith: Vec4,
+ #[uniform(102)]
+ pub sky_horizon: Vec4,
+ /// xyz: toward the visible sun by day, white moon by night (not the fixed FFP light).
+ /// w: 0 sun, 1 moon; moon reflection is independent of darkness/shadow strength.
+ #[uniform(102)]
+ pub celestial: Vec4,
/// **The shared global light** (`lighting::global_light`): the one storage buffer terrain and the
/// models already read, now liquid's source too. `liquid.wgsl` reads rows 0-5 (light + scene fog +
/// farclip), 13-16 (the two water swatches) and 18/19 (the interior fog block). Read in BOTH
diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl
index 72c23cef..fed310ac 100644
--- a/crates/benilla-assets/src/shaders/liquid.wgsl
+++ b/crates/benilla-assets/src/shaders/liquid.wgsl
@@ -98,19 +98,23 @@
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var frames: texture_2d_array;
@group(#{MATERIAL_BIND_GROUP}) @binding(101) var frames_samp: sampler;
+@group(#{MATERIAL_BIND_GROUP}) @binding(103) var scene_depth: texture_2d;
struct LiquidParams {
// x = fullbright (magma/slime); y = ocean swatch; z = interior fog; w = sun-sheen shininess.
kind: vec4,
// x = WHICH RENDERER (see `LiquidPath`): 0 = ADT MCLQ, 1 = WMO exterior, 2 = WMO interior.
- // y/z/w reserved.
+ // y = quality: 0 Classic, 1 Enhanced, 2 High (stage 1 shares Enhanced); z = wave energy (0..1); w reserved.
path: vec4,
- // x = reserved (frame 0); y = frame count; z = the SCROLL FLAG (1 only on the nibble-6/7
+ // x = fixed Enhanced capture time; y = frame count; z = the SCROLL FLAG (1 only on the nibble-6/7
// WMO magma/slime lane — the reference's animated stage-0 texture matrix; see
// `liquid/surface.rs`'s `scrolls`, and `apply_scroll` below); w = the clock enable (0 on a
// deterministic run — the whole animation freezes at frame 0 / scroll 0, the 0600 capture
// pin, baked at material build).
anim: vec4,
+ sky_zenith: vec4, // linear RGB, LightIntBand 2
+ sky_horizon: vec4, // linear RGB, LightIntBand 6
+ celestial: vec4, // xyz toward visible body; w = 0 sun, 1 white moon
};
@group(#{MATERIAL_BIND_GROUP}) @binding(102) var w: LiquidParams;
@@ -132,6 +136,8 @@ struct WowLight {
_grade: vec4, // 17
wmo_fog_color: vec4, // 18 rgb = INTERIOR fog (block 2); w = enable
wmo_fog_params: vec4, // 19 x = start yd; y = end yd
+ point_count: vec4, // 20 live count + point-light controls
+ points: array, 512>, // 21+ position/range, colour/lane pairs
};
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var wow_light: WowLight;
@@ -209,6 +215,7 @@ fn sun_sheen(world_normal: vec3, world_pos: vec3) -> vec3 {
// fallout (uniform re-uploads, bind-group rebuilds, whole-population `AssetChanged` arming)
// measured 0.28 cpu_ms/frame at the SW pin (2026-08-18 bracket).
fn anim_time() -> f32 {
+ if w.kind.x < 0.5 && w.path.y > 0.5 && w.anim.w == 0.0 { return w.anim.x; }
return w.anim.w * globals.time;
}
@@ -269,6 +276,13 @@ fn vertex(in: Vertex) -> LiquidVsOut {
let world_from_local = mesh_functions::get_world_from_local(in.instance_index);
out.world_position =
mesh_functions::mesh_position_local_to_world(world_from_local, vec4(in.position, 1.0));
+ // Only mesh-resolvable long swell moves ocean vertices. Authored shallow depth pins beaches.
+ if w.kind.x < 0.5 && w.kind.y > 0.5 && w.path.x < 0.5 && w.path.y > 0.5 {
+ // The swell band is the owner-approved one:
+ // the explicit zero keeps this call byte-identical to what it has always been.
+ out.world_position.y += water_waves(out.world_position.xz, anim_time(), 0.0, 0.0,
+ swell_shore_fade(in.uv_b.x), true).x;
+ }
out.clip_position = position_world_to_clip(out.world_position.xyz);
out.world_normal = mesh_functions::mesh_normal_local_to_world(in.normal, in.instance_index);
out.uv = in.uv;
@@ -347,6 +361,444 @@ fn swatch_at(shallow: vec4, deep: vec4, v: f32, ocean: bool) -> vec4, 8> = array, 8>(
+ // direction, wavelength, amplitude, phase offset
+ vec4(0.35, 18.0, 0.200, 0.0),
+ vec4(0.80, 12.8, 0.140, 1.7),
+ vec4(-0.18, 9.5, 0.090, 3.1),
+ vec4(0.52, 4.8, 0.055, 0.8),
+ vec4(1.10, 3.3, 0.033, 2.4),
+ vec4(-0.45, 2.6, 0.018, 4.6),
+ vec4(0.15, 1.3, 0.006, 1.2),
+ vec4(0.95, 0.85, 0.003, 3.8),
+);
+
+// ── SHORE BREAK (item 1) + FOAM LEVELS: the tuned constants, all in one place ──
+//
+// The shore break is a SEPARATE band from `WATER_WAVES` and touches neither the long swell that
+// moves ocean vertices nor the inland colour profile. It exists only in the shoaling zone —
+// roughly 0.35 to 3.5 yd of water over the bed — and it is normals + foam, never displacement.
+//
+// **Its phase coordinate is the vertical depth itself**, not a world ruler. That is the whole
+// trick: an iso-depth line IS the shoreline's own contour, so crests run exactly parallel to the
+// beach on a curved coast, a headland and a cove alike, with no per-chunk seam and no phase jump
+// when the depth gradient rotates. The world direction only ever enters through the NORMAL, where
+// it arrives as the analytic derivative `d(phase)/d(depth) · grad(depth)` — so the picture and the
+// lighting agree by construction. Where the bed is flat enough that the gradient is unusable the
+// direction falls back to the wind (`SHORE_WIND_DIR`, the primary swell's bearing), which is also
+// the only place the fallback can show, because a flat bed has no shoaling band to draw.
+//
+// `shore_g(d) = (d + A·(1 − e^(−d/B))) / L` is a smooth, strictly increasing crest count. Its
+// derivative `(1 + (A/B)·e^(−d/B)) / L` is the local wavenumber in DEPTH space, so with A/B = 1.3
+// the crest spacing runs 0.5 yd of depth at the waterline out to ~1.07 yd offshore: the wave
+// shortens by better than 2× as it shoals, which is the shoaling law's visible half. Because the
+// coordinate is depth, that spacing turns into a WORLD wavelength through the bed slope — so a
+// gentle beach gets long rollers and a steep one short ones, and the number of visible lines
+// approaching the sand stays about four either way.
+const SHORE_LAMBDA_D: f32 = 1.15; // yards of depth per crest, offshore
+const SHORE_COMPRESS: f32 = 1.56; // A — shoaling compression (A/B = 1.3 ⇒ 2.3× at the edge)
+const SHORE_COMPRESS_D: f32 = 1.2; // B — yards of depth the compression decays over
+const SHORE_PERIOD: f32 = 3.6; // seconds between arrivals; the swash runs at 2× this
+const SHORE_TILT: f32 = 0.09; // peak crest steepness (tan of the tilt), slope-independent
+const SHORE_WARP_A: f32 = 0.22; // yards of depth — coarse crest wander (never ruler-straight)
+const SHORE_WARP_B: f32 = 0.10; // yards of depth — finer segmentation of the same crests
+const SHORE_WIND_DIR: f32 = 0.35; // = WATER_WAVES[0].x, the primary swell bearing
+
+// Foam alphas. The owner rejected BOTH a thick icing sheet and straight stripes before this, so
+// every one of these is gated behind a noise breakup and a depth window; the numbers are the
+// ceiling a fully-lit, fully-broken crest can reach, not what a typical pixel gets.
+const FOAM_WET_EDGE: f32 = 0.35; // the faint wet line where water meets anything solid
+const FOAM_SWASH: f32 = 0.62; // the sheet running up the sand and fading
+const FOAM_CREST: f32 = 0.88; // the white front of the last wave or two, and its lace
+const FOAM_MAX: f32 = 0.90; // hard ceiling on the sum
+
+fn swell_shore_fade(depth: f32) -> f32 {
+ // Ocean V = byte/255, about 148 yd at 1.0: fade in over ~0.15..3.7 yd.
+ return smoothstep(0.001, 0.025, depth);
+}
+
+fn water_waves(p: vec2, t: f32, distance: f32, footprint: f32,
+ shore: f32, long_only: bool) -> vec3 {
+ let energy = clamp(w.path.z, 0.0, 1.0);
+ let tempo = mix(0.4, 1.0, sqrt(energy));
+ let inland = w.kind.y < 0.5 || w.path.x > 0.5;
+ // Inland ADT water drifts gently (a constant vector: a rigid translation, never a shear).
+ // WMO pools have no drift at all.
+ var ripple_p = p;
+ if inland && w.path.x < 0.5 {
+ ripple_p -= t * vec2(0.06, 0.025);
+ }
+ var result = vec3(0.0);
+ for (var i = 0u; i < 8u; i += 1u) {
+ if long_only && i >= 2u { break; }
+ if inland && i < 3u { continue; }
+ let wave = WATER_WAVES[i];
+ let direction = vec2(cos(wave.x), sin(wave.x));
+ let k = 6.2831853 / wave.y;
+ let speed = sqrt(10.72 / k); // gravity in yd/s^2; phase speed in yd/s
+ let phase = k * (dot(direction, ripple_p) - speed * tempo * t) + wave.w;
+ // Suppress unresolved waves before Nyquist, and remove fine ripples beyond 35 yd.
+ var fade = 1.0 - smoothstep(0.10, 0.45, footprint / wave.y);
+ if i >= 6u { fade *= 1.0 - smoothstep(10.0, 35.0, distance); }
+ if i < 2u { fade *= shore; }
+ let amplitude = wave.z * energy * fade;
+ result += vec3(amplitude * sin(phase), amplitude * k * cos(phase) * direction);
+ }
+ return result;
+}
+
+fn foam_hash(p_in: vec2) -> f32 {
+ // MONKEY (foam noise): world coordinates here are ~1e4 yd and are scaled further before they
+ // arrive, so `p * 0.1031` sat near 2e3 where an f32 keeps only ~13 fraction bits - the hash
+ // degenerated into a visible regular tile grid inside the foam (capture it11-water-beach-top).
+ // Wrap the LATTICE CELL to a 512 period first: exact for integers, invisible at 512 cells, and it
+ // gives the hash its full precision back.
+ let p = p_in - 512.0 * floor(p_in / 512.0);
+ let q = fract(vec3(p.x, p.y, p.x) * 0.1031);
+ let r = q + dot(q, q.yzx + 33.33);
+ return fract((r.x + r.y) * r.z);
+}
+
+fn foam_value_noise(p: vec2) -> f32 {
+ let cell = floor(p);
+ let f = fract(p);
+ let u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
+ return mix(mix(foam_hash(cell), foam_hash(cell + vec2(1.0, 0.0)), u.x),
+ mix(foam_hash(cell + vec2(0.0, 1.0)),
+ foam_hash(cell + vec2(1.0, 1.0)), u.x), u.y);
+}
+
+// MONKEY (foam noise): thresholded VALUE noise shows its lattice - the foam and crest masks came out
+// as boxy slabs with stair-stepped edges aligned to the world axes (capture it9/it10-water-beach-top).
+// Two samples on differently ROTATED and scaled lattices, averaged, have no shared axis, so the
+// thresholded shapes turn into irregular blobs. Same range (0..1), same call sites, twice the taps.
+fn foam_noise(p: vec2) -> f32 {
+ let a = vec2(0.7986 * p.x - 0.6018 * p.y, 0.6018 * p.x + 0.7986 * p.y);
+ let q = p * 1.37 + vec2(17.3, -9.1);
+ let b = vec2(0.3584 * q.x + 0.9336 * q.y, -0.9336 * q.x + 0.3584 * q.y);
+ // Averaging narrows the distribution; re-expand it so the existing thresholds keep their cut.
+ return clamp((0.5 * (foam_value_noise(a) + foam_value_noise(b)) - 0.5) * 1.4 + 0.5, 0.0, 1.0);
+}
+// 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 {
+ return view.clip_from_view[3][2] / max(depth + view.clip_from_view[2][2], 1e-7);
+}
+
+// == ENHANCED WATER (the optional water module; see WATER.md at the repo root) ==================
+// Design reference and credit: the WarcraftXL project's `wxl-experimental-water` module for the
+// 1.12 client, author iThorgrim - https://github.com/WarcraftXL. Its author permits reuse of that
+// code here provided the author and the original project are named; this notice is that
+// attribution and must stay with the module. A block that ports WarcraftXL code says so where it
+// stands and is listed in WATER.md.
+fn enhanced_water(in: LiquidVsOut, shallow: vec4, deep: vec4) -> vec4 {
+ let pixel = clamp(vec2(in.clip_position.xy), vec2(0),
+ vec2(textureDimensions(scene_depth)) - vec2(1));
+ let own_depth = textureLoad(scene_depth, pixel, 0).r;
+ // MONKEY (bed clutter): grass blades and reeds standing in a stream are in the opaque depth, so
+ // every blade read as "something solid at the surface" - each one grew a white contact-foam
+ // outline and a paler, less-absorbed tint than the bed around it (owner screenshot, Stonefield
+ // Farm). Anything THIN is not a shore: take the FARTHEST of nine taps (reverse-Z: the smallest)
+ // over a ~0.7 % of the view height ring, so a blade a few pixels wide resolves to the bed behind
+ // it, while a bank, a rock or a hull - wider than the ring - is untouched bar a few pixels.
+ let ring = max(i32(view.viewport.w * 0.0065), 2);
+ let top = vec2(textureDimensions(scene_depth)) - vec2(1);
+ var far_depth = own_depth;
+ var offs = array, 8>(
+ vec2(1, 1), vec2(1, 0), vec2(-1, 1), vec2(0, 1),
+ vec2(-1, -1), vec2(-1, 0), vec2(1, -1), vec2(0, -1));
+ for (var k = 0; k < 8; k += 1) {
+ let reach = select(ring, 2 * ring, (k & 1) == 1);
+ let tap = textureLoad(scene_depth,
+ clamp(pixel + offs[k] * reach, vec2(0), top), 0).r;
+ // Reverse-Z: farther is SMALLER; 0 is the sky, which is not a bed.
+ if tap < far_depth && tap > 0.0 { far_depth = tap; }
+ }
+ // The open sea keeps its own pixel: its shore train's phase IS this depth, and sand has no reeds.
+ let sea = w.kind.y > 0.5 && w.path.x < 0.5;
+ let bed_depth = select(far_depth, own_depth, sea);
+ let to_view = normalize(view.world_position.xyz - in.world_position.xyz);
+ let eye_pos = (view.view_from_world * vec4(in.world_position.xyz, 1.0)).xyz;
+ // Convert the two eye-Z distances to a distance ALONG the pixel's view ray.
+ let ray_cos = max(abs(normalize(eye_pos).z), 0.001);
+ let thickness = min(max(water_view_distance(bed_depth)
+ - water_view_distance(in.clip_position.z), 0.0) / ray_cos, 1000.0);
+ // Reconstruct the opaque scene point, then measure height below the surface.
+ // Ray thickness is only the contact measure: opacity must not follow eye-Z.
+ let ndc_xy = ((in.clip_position.xy - view.viewport.xy) / view.viewport.zw)
+ * vec2(2.0, -2.0) + vec2(-1.0, 1.0);
+ let scene_h = view.world_from_clip * vec4(ndc_xy, bed_depth, 1.0);
+ var vertical_depth = 1000.0;
+ if bed_depth > 0.0 && abs(scene_h.w) > 1e-7 {
+ vertical_depth = clamp(in.world_position.y - scene_h.y / scene_h.w, 0.0, 1000.0);
+ }
+ let t = anim_time(); // WOW_CAPTURE_WATER_T pins the frozen water phase.
+ let p = in.world_position.xz;
+ let energy = clamp(w.path.z, 0.0, 1.0);
+ let ocean_mesh = w.kind.y > 0.5 && w.path.x < 0.5;
+
+ // ── EVERY screen derivative in this shader is taken HERE ────────────────────────────────
+ // Top level of the function, before any branch, loop or early return: WGSL's uniformity rule
+ // makes a derivative inside non-uniform control flow invalid, and this function is long enough
+ // that the only safe discipline is to have exactly one place they can live. Nothing below may
+ // reintroduce a `dpdx`/`dpdy` inside a conditional.
+ let ddx_p = dpdx(p);
+ let ddy_p = dpdy(p);
+ let ddx_depth = dpdx(vertical_depth);
+ let ddy_depth = dpdy(vertical_depth);
+ let footprint = max(length(ddx_p), length(ddy_p));
+
+ // The BED's slope in world yards, recovered by inverting the pixel→world Jacobian: solve
+ // `g·(dp/dx) = dd/dx` and `g·(dp/dy) = dd/dy` for the world-space gradient of the vertical
+ // depth field. `offshore` is the unit direction of INCREASING depth, so `−offshore` points at
+ // the beach and the shore train travels that way.
+ let jac_det = ddx_p.x * ddy_p.y - ddx_p.y * ddy_p.x;
+ var depth_grad = vec2(0.0, 0.0);
+ if abs(jac_det) > 1e-12 {
+ depth_grad = vec2(
+ (ddx_depth * ddy_p.y - ddy_depth * ddx_p.y) / jac_det,
+ (ddy_depth * ddx_p.x - ddx_depth * ddy_p.x) / jac_det,
+ );
+ }
+ let bed_slope = length(depth_grad);
+ // Flat bed, or the depth field broke across a silhouette (the ratio blows up there): fall back
+ // to the wind. A flat bed has no shoaling band to draw, so the fallback is mostly a guard.
+ var offshore = vec2(cos(SHORE_WIND_DIR), sin(SHORE_WIND_DIR));
+ if bed_slope > 0.02 && bed_slope < 12.0 {
+ offshore = depth_grad / bed_slope;
+ }
+
+ // ── The shore break: shoaling trains riding the bathymetry (see the constants block) ────
+ // Domain-warped in DEPTH units, so the crests wander and segment along the beach instead of
+ // running as ruler-straight bands.
+ let shore_warp =
+ SHORE_WARP_A * (2.0 * foam_noise(p * 0.075 + t * vec2(0.010, -0.007)) - 1.0)
+ + SHORE_WARP_B * (2.0 * foam_noise(p * 0.21 - t * vec2(0.006, 0.009)) - 1.0);
+ let dq = max(vertical_depth + shore_warp, 0.0);
+ let shore_g = (dq + SHORE_COMPRESS * (1.0 - exp(-dq / SHORE_COMPRESS_D))) / SHORE_LAMBDA_D;
+ // `+ t/period` with a crest-count that RISES with depth ⇒ a crest of fixed phase slides to
+ // shallower water as time runs: the train travels shoreward.
+ let shore_phase = 6.2831853 * (shore_g + t / SHORE_PERIOD);
+ let shore_phase2 = 6.2831853 * (1.9 * shore_g + t / (SHORE_PERIOD * 0.62)) + 2.1;
+ // Steepness rises as it shoals (3.5 → 0.75 yd), collapses into the break below ~0.35 yd, and
+ // is gone past the band's offshore edge.
+ let shoal = smoothstep(3.5, 0.75, vertical_depth);
+ let shore_collapse = smoothstep(0.12, 0.38, vertical_depth);
+ let shore_offshore_fade = 1.0 - smoothstep(2.4, 3.6, vertical_depth);
+ // MONKEY (surf on wrecks): the shore train reads the SCENE depth, and a sunken boat, a pier
+ // foot or a rock shelf is shallow scene depth in the middle of deep water - so the whole surf
+ // (crests, lace, swash) was painted over the hull of a wreck off Longshore. A shore is where
+ // the SEA BED is shallow. The mesh carries the authored bed depth (`in.depth`, byte/255 of about
+ // 148 yd): where the bed lies well below what the pixel sees, the pixel is an OBJECT, and an
+ // object gets the thin wet-edge line only. The byte is a FLOOR (1.72 per yd), so on a real beach the
+ // authored bed is never deeper than the scene by more than interpolation error: allow 0.5 yd.
+ let bed_yd = clamp(in.depth, 0.0, 1.0) * 148.0;
+ let on_bed = 1.0 - smoothstep(0.5, 1.0, bed_yd - vertical_depth);
+ let shore_gain = select(0.0, shoal * shore_collapse * shore_offshore_fade * on_bed, ocean_mesh)
+ * energy;
+ // Steepness is set DIRECTLY — the tangent of the crest tilt — rather than through an amplitude
+ // and a wavenumber, so a gentle beach and a steep one roll with the same visible strength
+ // instead of one washing out and the other exploding.
+ let shore_grad = SHORE_TILT * shore_gain
+ * (cos(shore_phase) + 0.45 * cos(shore_phase2)) * offshore;
+
+ let shore = select(1.0, swell_shore_fade(in.depth), ocean_mesh);
+ let wave = water_waves(p, t, length(eye_pos), footprint, shore, false);
+ // One surface gradient: the procedural bands and the shore break.
+ let surf_grad = wave.yz + shore_grad;
+ var n = normalize(vec3(-surf_grad.x, 1.0, -surf_grad.y));
+ if dot(n, to_view) < 0.0 { n = -n; }
+
+ // Far water settles into a calm sky sheet.
+ n = normalize(mix(n, vec3(0.0, sign(n.y), 0.0),
+ 0.6 * smoothstep(60.0, 120.0, length(eye_pos))));
+ // Clean translucent teal at the edge; zone tint remains in the deeper body.
+ let teal_shallow = mix(vec3(0.12, 0.42, 0.39), shallow.rgb, 0.18);
+ // MONKEY (water body): the zone deep row alone renders the open sea and lake middles a muddy
+ // grey-brown (Westfall ocean, Loch Modan) - it was authored to sit UNDER the reference ripple
+ // sheet, not to be a lit body colour. Pull it 70 % toward the zone zenith sky (deep water takes
+ // its colour from the sky it scatters) and keep it blue-dominant, so the body reads as water by
+ // day, follows dusk and night through the same sky row, and still differs zone to zone.
+ let zenith_gamma = pow(max(w.sky_zenith.rgb, vec3(0.0)), vec3(1.0 / 2.2));
+ let deep_sky = mix(deep.rgb, zenith_gamma * 1.1, 0.7);
+ let deep_body = vec3(min(deep_sky.r, deep_sky.g * 0.85), deep_sky.g,
+ max(deep_sky.b, deep_sky.g * 1.12));
+ var body = mix(teal_shallow, deep_body, smoothstep(0.0, 3.5, vertical_depth));
+ if !ocean_mesh {
+ let clear_row = mix(shallow.rgb, vec3(0.20, 0.40, 0.36), 0.35);
+ let clear_tint = vec3(min(clear_row.r, clear_row.g * 0.72), clear_row.g,
+ clamp(clear_row.b, clear_row.g * 0.82, clear_row.g * 1.15));
+ let inland_row = mix(deep.rgb, zenith_gamma * 1.1, 0.25);
+ let inland_deep = vec3(min(inland_row.r, inland_row.g * 0.72), inland_row.g,
+ clamp(inland_row.b, inland_row.g * 0.82, inland_row.g * 1.15));
+ body = mix(clear_tint, inland_deep, smoothstep(0.0, 5.0, vertical_depth));
+ }
+ let to_light = -normalize(wow_light.light_sun.xyz);
+ // MONKEY (water body): water is lit by the light it SCATTERS, not only by N.L on its skin, so
+ // the lit body keeps a high floor (ambient x 1.35, N.L floor 0.5). With the old 0.25 floor a
+ // noon lake rendered near-black navy where the owner reference is a bright teal-blue; night
+ // still darkens because both rows do.
+ let lighting = clamp(wow_light.light_ambient.rgb * 1.35 + wow_light.light_diffuse.rgb
+ * max(dot(n, to_light), 0.5), vec3(0.0), vec3(1.0));
+ var rgb = body * lighting;
+ // MONKEY (shore waves): the shore train is shown mainly as a CONTINUOUS crest brightening, not as
+ // a normal tilt. Its direction comes from the screen-space gradient of the bed depth, and the
+ // terrain is flat triangles, so that direction jumps at every triangle edge: at SHORE_TILT 0.30
+ // the highlights broke into blocky rectangular shards with staircase edges (capture
+ // it9-water-beach-top). The PHASE is a function of the depth itself and is continuous, so a term
+ // driven by the phase alone cannot facet. The tilt stays at 0.09 for a little specular life.
+ let shore_crest = pow(max(sin(shore_phase), 0.0), 2.0)
+ + 0.45 * pow(max(sin(shore_phase2), 0.0), 2.0);
+ rgb += shore_gain * shore_crest * 0.13 * lighting * vec3(0.72, 0.95, 0.90);
+ if !ocean_mesh {
+ // Keep the zone's green-blue absorption under warm dusk illumination.
+ rgb = body * dot(lighting, vec3(0.2126, 0.7152, 0.0722));
+ }
+ let celestial_dir = normalize(w.celestial.xyz);
+ let crest = smoothstep(0.35, 0.95, 0.5 + 0.5 * wave.x / max(0.545 * energy, 0.001));
+ let transmission = crest * pow(max(dot(to_view, -celestial_dir), 0.0), 3.0) * energy
+ * smoothstep(-0.02, 0.12, celestial_dir.y);
+ rgb += vec3(0.06, 0.30, 0.19) * transmission * lighting;
+ let reflection_n = normalize(mix(vec3(0.0, sign(n.y), 0.0), n, 0.45));
+ let reflected = reflect(-to_view, reflection_n);
+ let sky_linear = mix(w.sky_horizon.rgb, w.sky_zenith.rgb, clamp(reflected.y * 1.4, 0.0, 1.0));
+ // Linear reflection interpolation, then conversion to the world's gamma blend/fog lane.
+ let sky = select(12.92 * sky_linear,
+ 1.055 * pow(max(sky_linear, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055,
+ sky_linear > vec3(0.0031308));
+ var reflectivity = mix(0.06, 0.88, pow(1.0 - max(dot(n, to_view), 0.0), 4.0));
+ if !ocean_mesh { reflectivity = min(reflectivity, 0.55); }
+ rgb = mix(rgb, sky, reflectivity);
+ // Smooth crest glints; widen and conserve lobe energy as the footprint grows.
+ let half_v = normalize(celestial_dir + to_view);
+ let ndoth = max(dot(n, half_v), 0.0);
+ let normal_variance = dot(dpdx(n), dpdx(n)) + dot(dpdy(n), dpdy(n));
+ let spread = 1.0 + 400.0 * normal_variance + 2.0 * smoothstep(30.0, 160.0, length(eye_pos));
+ if w.path.x < 1.5 {
+ if w.celestial.w > 0.5 {
+ // Own peak intensity, never nightGain or the signed moon-shadow weight.
+ // sin(8 degrees): the reflection is exactly zero below the horizon.
+ rgb += vec3(0.80, 0.88, 1.0) * 0.45
+ * (pow(ndoth, 400.0 / spread) / sqrt(spread)
+ + 0.25 * pow(ndoth, 60.0 / sqrt(spread)) / sqrt(spread))
+ * smoothstep(0.0, 0.1391731, celestial_dir.y);
+ } else {
+ rgb += wow_light.light_diffuse.rgb
+ * (pow(ndoth, 400.0 / spread) / sqrt(spread)
+ + 0.25 * pow(ndoth, 60.0 / sqrt(spread)) / sqrt(spread))
+ * smoothstep(-0.02, 0.08, celestial_dir.y);
+ }
+ }
+
+ // Rank candidates by actual fragment distance, not table order. Only four lights are shaded.
+ var nearest = array(256u, 256u, 256u, 256u);
+ var distances = array(900.0, 900.0, 900.0, 900.0);
+ for (var i = 0u; i < min(u32(wow_light.point_count.x), 256u); i += 1u) {
+ let colour = wow_light.points[2u * i + 1u];
+ if colour.w >= 0.5 && w.path.x < 1.5 { continue; }
+ let delta = wow_light.points[2u * i].xyz - in.world_position.xyz;
+ let d2 = dot(delta, delta);
+ if d2 >= distances[3] { continue; }
+ var slot = 3u;
+ loop {
+ if slot == 0u { break; }
+ if d2 >= distances[slot - 1u] { break; }
+ distances[slot] = distances[slot - 1u];
+ nearest[slot] = nearest[slot - 1u];
+ slot -= 1u;
+ }
+ distances[slot] = d2;
+ nearest[slot] = i;
+ }
+ for (var j = 0u; j < 4u; j += 1u) {
+ if nearest[j] == 256u { continue; }
+ let pos = wow_light.points[nearest[j] * 2u];
+ let colour = wow_light.points[nearest[j] * 2u + 1u];
+ let delta = pos.xyz - in.world_position.xyz;
+ let distance = sqrt(max(distances[j], 0.0001));
+ let light_dir = delta / distance;
+ let reach = min(select(pos.w, 2.0 * colour.w, colour.w >= 0.5), 30.0);
+ let attenuation = pow(1.0 - smoothstep(0.0, max(reach, 0.01), distance), 2.0);
+ rgb += colour.rgb * pow(max(dot(n, normalize(light_dir + to_view)), 0.0), 64.0)
+ * max(dot(n, light_dir), 0.0) * attenuation * 0.7;
+ }
+
+ // Patchy, low-frequency coverage, never a texture-derived white outline.
+ let noise = 0.7 * foam_noise(p * 0.6 + t * vec2(0.025, -0.018))
+ + 0.3 * foam_noise(p * 1.7 - t * vec2(0.014, 0.021));
+ let breakup = smoothstep(0.40, 0.72, noise);
+ // The cached derivatives from the top of the function — same expression as before, one tap.
+ let depth_gradient = length(vec2(ddx_depth, ddy_depth))
+ / max(length(vec2(length(ddx_p), length(ddy_p))), 0.001);
+ let wall_suppression = mix(1.0, 0.3, smoothstep(0.8, 3.0, depth_gradient));
+ let contact = smoothstep(0.0, 0.025, thickness)
+ * (1.0 - smoothstep(0.055, 0.15, thickness));
+ let contact_alpha = FOAM_WET_EDGE * contact * wall_suppression * breakup;
+ // MONKEY (beach foam, third pass). The owner called the previous surf ugly: it was the crest
+ // line chopped into DASHES by a breakup noise, plus swash "crescents" stamped from an 8 yd patch
+ // grid - rows of white blobs. Surf is not dashes. A breaking wave is (1) a thin, nearly
+ // continuous bright FRONT, (2) a LACE of foam left behind it that thins out and dissolves, and
+ // (3) a sheet that runs up the sand after each arrival and fizzles. All three are driven by the
+ // shore train's own phase, so the foam sits on the wave the normals show.
+ //
+ // `surf_u` is the fraction of a wave spacing BEHIND the front (the phase rises with depth, so
+ // behind = seaward): 0 at the front, which sits just ahead of the crest the normals draw.
+ let surf_u = fract(shore_g + t / SHORE_PERIOD - 0.20);
+ // Web-like lace: ridged noise (bright along the zero set of two noise fields), two octaves.
+ let lace_a = 1.0 - abs(2.0 * foam_noise(p * 1.25 + t * vec2(0.030, -0.020)) - 1.0);
+ let lace_b = 1.0 - abs(2.0 * foam_noise(p * 3.30 - t * vec2(0.020, 0.035)) - 1.0);
+ let lace = 0.62 * lace_a + 0.38 * lace_b;
+ // The lace dissolves with age: the threshold climbs from "most of it" to "only the ridges".
+ let dissolve = mix(0.42, 0.93, smoothstep(0.02, 0.60, surf_u));
+ let lace_mask = smoothstep(dissolve, dissolve + 0.16, lace);
+ // The front itself: crisp on its shoreward side, solid for a few percent of a spacing.
+ let front = smoothstep(0.0, 0.018, surf_u) * (1.0 - smoothstep(0.035, 0.11, surf_u));
+ let trail = smoothstep(0.0, 0.03, surf_u) * (1.0 - smoothstep(0.25, 0.70, surf_u));
+ // Strength wanders along the beach, but never to nothing - no gaps, no dashes.
+ let along = mix(0.55, 1.0, foam_noise(p * 0.055 + t * vec2(0.008, 0.005)));
+ let break_zone = smoothstep(0.10, 0.30, vertical_depth)
+ * (1.0 - smoothstep(1.3, 2.6, vertical_depth)) * on_bed;
+ let crest_alpha = select(0.0,
+ FOAM_CREST * break_zone * along * max(front, 0.85 * trail * lace_mask), ocean_mesh)
+ * wall_suppression * energy;
+
+ // The swash: what is left of each wave runs up the last hand of water and fizzles. Its clock
+ // is the front's arrival at the break (0.22 yd of water): 0 when it lands, 1 as the next does.
+ let land_g = (0.22 + SHORE_COMPRESS * (1.0 - exp(-0.22 / SHORE_COMPRESS_D))) / SHORE_LAMBDA_D;
+ let swash_age = fract(land_g + t / SHORE_PERIOD - 0.20 + 0.10 * (along - 0.75));
+ let swash_life = smoothstep(0.0, 0.06, swash_age) * (1.0 - smoothstep(0.30, 0.95, swash_age));
+ let swash_band = (1.0 - smoothstep(0.10, 0.34, vertical_depth + 0.5 * shore_warp)) * on_bed;
+ let swash_thin = mix(0.30, 0.90, smoothstep(0.05, 0.85, swash_age));
+ let swash_lace = smoothstep(swash_thin, swash_thin + 0.18, lace);
+ // The very lip of the water keeps a thin bright line while the sheet is alive.
+ let lip = 1.0 - smoothstep(0.015, 0.07, vertical_depth);
+ let arcs_alpha = select(0.0,
+ FOAM_SWASH * swash_life * swash_band * max(swash_lace, 0.8 * lip), ocean_mesh)
+ * wall_suppression * energy;
+
+ let foam = min(FOAM_MAX, contact_alpha + arcs_alpha + crest_alpha);
+ let illumination = wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb;
+ let foam_luma = clamp(dot(illumination, vec3(0.2126, 0.7152, 0.0722)), 0.22, 1.0);
+ // Matte foam composites on top of reflection with its own coverage.
+ let soft_edge = smoothstep(0.0, 0.18, vertical_depth);
+ var body_alpha = soft_edge * mix(0.55, 0.97, smoothstep(0.0, 1.6, vertical_depth));
+ if !ocean_mesh {
+ body_alpha = soft_edge * mix(0.30, 0.86, smoothstep(0.0, 4.5, vertical_depth));
+ }
+ let foam_alpha = smoothstep(0.0, 0.045, vertical_depth) * foam;
+ let alpha = foam_alpha + body_alpha * (1.0 - foam_alpha);
+ // Foam is white UNDER the light it stands in: half the way to the light's own colour, so a
+ // dusk surf is warm and a moonlit one blue-grey, not a neutral paste.
+ let foam_tint = mix(vec3(foam_luma),
+ clamp(illumination, vec3(0.22), vec3(1.0)), 0.5);
+ rgb = (foam_tint * foam_alpha + rgb * body_alpha * (1.0 - foam_alpha))
+ / max(alpha, 0.0001);
+ return vec4(apply_fog(rgb, in.world_position.xyz, in.room_fog), alpha);
+}
+
@fragment
fn fragment(in: LiquidVsOut) -> @location(0) vec4 {
// HARD FAR-CLIP WALL (same as terrain/models, see terrain.wgsl): discard water beyond the
@@ -359,6 +811,17 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 {
}
}
+ // Enhanced never samples the classic ripple sheet. The 1x1 image retains the safe fallback.
+ if w.kind.x < 0.5 && w.path.y > 0.5 && textureDimensions(scene_depth).x > 1u {
+ var shallow_enhanced = wow_light.water_river[0];
+ var deep_enhanced = wow_light.water_river[1];
+ if w.kind.y > 0.5 && w.path.x < 0.5 {
+ shallow_enhanced = wow_light.water_ocean[0];
+ deep_enhanced = wow_light.water_ocean[1];
+ }
+ return enhanced_water(in, shallow_enhanced, deep_enhanced);
+ }
+
// Animated frame. For water/ocean this is the DETAIL ripple (RGB ≈ near-black, ALPHA = ripple);
// for magma/slime it is the OPAQUE BODY texture.
// `view.mip_bias`: the render-scale LOD compensation (1639), 0.0 at native and above.
diff --git a/crates/benilla-assets/src/water_depth.rs b/crates/benilla-assets/src/water_depth.rs
new file mode 100644
index 00000000..7d5cfc71
--- /dev/null
+++ b/crates/benilla-assets/src/water_depth.rs
@@ -0,0 +1,27 @@
+//! Shared water settings and image binding, independent of world streaming.
+use bevy::prelude::*;
+
+/// 0 = Classic, 1 = Enhanced, 2 = High (currently the same stage-1 effects).
+#[derive(Resource, Clone, Copy, PartialEq)]
+pub struct WaterQuality(pub u8);
+
+impl Default for WaterQuality {
+ fn default() -> Self { Self(1) }
+}
+
+/// The world view's opaque depth, resolved to a sampleable R32Float image.
+#[derive(Resource, Clone)]
+pub struct WaterDepthImage(pub Handle);
+
+impl FromWorld for WaterDepthImage {
+ fn from_world(world: &mut World) -> Self {
+ use bevy::{asset::RenderAssetUsages, render::render_resource::*};
+ let mut image = Image::new_fill(
+ Extent3d::default(), TextureDimension::D2, &0f32.to_le_bytes(),
+ TextureFormat::R32Float, RenderAssetUsages::default(),
+ );
+ image.texture_descriptor.usage = TextureUsages::TEXTURE_BINDING
+ | TextureUsages::RENDER_ATTACHMENT | TextureUsages::COPY_DST;
+ Self(world.resource_mut::>().add(image))
+ }
+}
diff --git a/crates/benilla-world/src/lighting/daylight.rs b/crates/benilla-world/src/lighting/daylight.rs
index 3e50cc0f..27ee4a3c 100644
--- a/crates/benilla-world/src/lighting/daylight.rs
+++ b/crates/benilla-world/src/lighting/daylight.rs
@@ -112,6 +112,8 @@ pub enum DaylightHow {
/// fixture is not the sun at all but the LIT room's own light carried across the threshold into
/// the dark one. See the PORTAL BLEED section near the bottom of this file.
Bleed,
+ /// MONKEY (lava light): reuses the caster exclusion; no daylight calibration or room claim.
+ Lava,
}
impl DaylightHow {
@@ -122,6 +124,7 @@ impl DaylightHow {
DaylightHow::Aperture => "aperture",
DaylightHow::Boundary => "boundary",
DaylightHow::Bleed => "bleed",
+ DaylightHow::Lava => "lava",
}
}
}
@@ -1015,7 +1018,7 @@ pub fn update_daylight_fixtures(
Option<&LightReach>,
Option<&mut WorldPointLight>,
),
- Without,
+ (Without, Without),
>,
time: Res