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 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-24 23:15:57 +01:00
co-authored by Claude Opus 5.5
parent 3562f4bd5f
commit a74a46594c
25 changed files with 3042 additions and 80 deletions
+73
View File
@@ -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.
+61 -9
View File
@@ -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 @@
})
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowCharacterShadows" inherits="OptionsCheckboxRowTemplate">
<Frame name="$parentRowWaterQuality" inherits="OptionsDropdownRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowLightingQuality" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowLightingQuality" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
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" },
})
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowCharacterShadows" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowWaterQuality" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowWaterQuality" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "characterShadows",
BENILLA_ADVGFX.characterShadows,
@@ -3760,11 +3800,23 @@
OptionsSliderRow_Setup(self, 0, 4, 0.05)
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowNightDarkness" inherits="OptionsSliderRowTemplate">
<Frame name="$parentRowLavaGlow" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowSpellLights" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowSpellLights" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "lavaLightGain",
BENILLA_ADVGFX.lavaGlow,
"BENILLA_TOOLTIP_LAVA_GLOW")
OptionsSliderRow_Setup(self, 0, 4, 0.05)
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowNightDarkness" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowLavaGlow" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowLavaGlow" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- 0.2..1.5, the CVar's own clamp verbatim: the
-- floor is 0.2 rather than 0 because a true 0 is
+161
View File
@@ -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=<name>`) for
/// debugging and look passes, but NOT part of the blessed baseline sweep.
pub(super) const ON_DEMAND: &[Scenario] = &[
// WOW_CAPTURE_WATER_T=<seconds>: 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
+51 -8
View File
@@ -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::<f32>().unwrap();
assert_eq!(res::<VideoConfig>(&app).fire_light_gain, expected);
assert_eq!(res::<VideoConfig>(&app).spell_light_gain, expected);
assert_eq!(res::<VideoConfig>(&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::<VideoConfig>(&app).water_quality, water);
assert_eq!(res::<VideoConfig>(&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
@@ -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", &[
+47 -1
View File
@@ -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<MoonShadowStrength>,
mut water: ResMut<WaterQuality>,
mut lava: ResMut<LavaLightGain>,
) {
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::<VideoConfig>()
.init_resource::<DynamicInteriors>()
.init_resource::<FireLightGain>()
.init_resource::<SpellLightGain>()
.init_resource::<MoonShadowStrength>()
.init_resource::<WaterQuality>()
.init_resource::<LavaLightGain>()
.add_plugins(DynamicInteriorPlugin);
assert_eq!(app.world().resource::<WaterQuality>().0, VideoConfig::default().water_quality);
assert_eq!(app.world().resource::<LavaLightGain>().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::<VideoConfig>();
video.water_quality = water;
video.lava_light_gain = lava;
}
app.world_mut().run_schedule(Update);
assert_eq!(app.world().resource::<WaterQuality>().0, water);
assert_eq!(app.world().resource::<LavaLightGain>().0, lava);
app.world_mut().clear_trackers();
app.world_mut().run_schedule(Update);
assert!(!app.world().resource_ref::<WaterQuality>().is_changed());
assert!(!app.world().resource_ref::<LavaLightGain>().is_changed());
}
}
}
+1
View File
@@ -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)
@@ -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::<usize>("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)").unwrap(), 17);
assert_eq!(s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[2]").unwrap(), "RowWaterQuality");
assert_eq!(s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[15]").unwrap(), "RowLavaGlow");
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowWaterQualityDropdownText:GetText()")).unwrap(), labels[1]);
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowLavaGlowLabel:GetText()")).unwrap(), lava_label);
assert!(s.eval::<bool>("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::<String>(&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::<f32>(&format!(
"return {ADVGFX}{row}ControlSlider:GetValue()"
)).unwrap(), 3.25);
assert_eq!(s.eval::<String>(&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::<String>(&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::<String>(&format!("return {ADVGFX}RowWaterQualityDropdownText:GetText()")).unwrap(), "High");
assert_eq!(s.eval::<String>(&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
+12 -2
View File
@@ -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" => {
+2
View File
@@ -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;
+17 -3
View File
@@ -565,13 +565,16 @@ pub type LiquidMaterial = ExtendedMaterial<StandardMaterial, LiquidExt>;
/// 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<Image>,
/// 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<Image>,
/// 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
+465 -2
View File
@@ -98,19 +98,23 @@
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var frames: texture_2d_array<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(101) var frames_samp: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(103) var scene_depth: texture_2d<f32>;
struct LiquidParams {
// x = fullbright (magma/slime); y = ocean swatch; z = interior fog; w = sun-sheen shininess.
kind: vec4<f32>,
// 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<f32>,
// 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<f32>,
sky_zenith: vec4<f32>, // linear RGB, LightIntBand 2
sky_horizon: vec4<f32>, // linear RGB, LightIntBand 6
celestial: vec4<f32>, // xyz toward visible body; w = 0 sun, 1 white moon
};
@group(#{MATERIAL_BIND_GROUP}) @binding(102) var<uniform> w: LiquidParams;
@@ -132,6 +136,8 @@ struct WowLight {
_grade: vec4<f32>, // 17
wmo_fog_color: vec4<f32>, // 18 rgb = INTERIOR fog (block 2); w = enable
wmo_fog_params: vec4<f32>, // 19 x = start yd; y = end yd
point_count: vec4<f32>, // 20 live count + point-light controls
points: array<vec4<f32>, 512>, // 21+ position/range, colour/lane pairs
};
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> wow_light: WowLight;
@@ -209,6 +215,7 @@ fn sun_sheen(world_normal: vec3<f32>, world_pos: vec3<f32>) -> vec3<f32> {
// 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<f32>(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<f32>, deep: vec4<f32>, v: f32, ocean: bool) -> vec4<f
);
}
// Enhanced is entirely analytic: height and its exact x/z derivatives, in yards.
// Direction is radians from world +X toward +Z; phase speed follows deep-water dispersion.
// Two mesh-resolvable long waves sum to at most 0.34 yd before energy/shore attenuation.
const WATER_WAVES: array<vec4<f32>, 8> = array<vec4<f32>, 8>(
// direction, wavelength, amplitude, phase offset
vec4<f32>(0.35, 18.0, 0.200, 0.0),
vec4<f32>(0.80, 12.8, 0.140, 1.7),
vec4<f32>(-0.18, 9.5, 0.090, 3.1),
vec4<f32>(0.52, 4.8, 0.055, 0.8),
vec4<f32>(1.10, 3.3, 0.033, 2.4),
vec4<f32>(-0.45, 2.6, 0.018, 4.6),
vec4<f32>(0.15, 1.3, 0.006, 1.2),
vec4<f32>(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<f32>, t: f32, distance: f32, footprint: f32,
shore: f32, long_only: bool) -> vec3<f32> {
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<f32>(0.06, 0.025);
}
var result = vec3<f32>(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<f32>(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<f32>(amplitude * sin(phase), amplitude * k * cos(phase) * direction);
}
return result;
}
fn foam_hash(p_in: vec2<f32>) -> 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<f32>(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>) -> 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<f32>(1.0, 0.0)), u.x),
mix(foam_hash(cell + vec2<f32>(0.0, 1.0)),
foam_hash(cell + vec2<f32>(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>) -> f32 {
let a = vec2<f32>(0.7986 * p.x - 0.6018 * p.y, 0.6018 * p.x + 0.7986 * p.y);
let q = p * 1.37 + vec2<f32>(17.3, -9.1);
let b = vec2<f32>(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<f32>, deep: vec4<f32>) -> vec4<f32> {
let pixel = clamp(vec2<i32>(in.clip_position.xy), vec2<i32>(0),
vec2<i32>(textureDimensions(scene_depth)) - vec2<i32>(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<i32>(textureDimensions(scene_depth)) - vec2<i32>(1);
var far_depth = own_depth;
var offs = array<vec2<i32>, 8>(
vec2<i32>(1, 1), vec2<i32>(1, 0), vec2<i32>(-1, 1), vec2<i32>(0, 1),
vec2<i32>(-1, -1), vec2<i32>(-1, 0), vec2<i32>(1, -1), vec2<i32>(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<i32>(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<f32>(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<f32>(2.0, -2.0) + vec2<f32>(-1.0, 1.0);
let scene_h = view.world_from_clip * vec4<f32>(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<f32>(0.0, 0.0);
if abs(jac_det) > 1e-12 {
depth_grad = vec2<f32>(
(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<f32>(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<f32>(0.010, -0.007)) - 1.0)
+ SHORE_WARP_B * (2.0 * foam_noise(p * 0.21 - t * vec2<f32>(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<f32>(-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<f32>(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<f32>(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<f32>(0.0)), vec3<f32>(1.0 / 2.2));
let deep_sky = mix(deep.rgb, zenith_gamma * 1.1, 0.7);
let deep_body = vec3<f32>(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<f32>(0.20, 0.40, 0.36), 0.35);
let clear_tint = vec3<f32>(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<f32>(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<f32>(0.0), vec3<f32>(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<f32>(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<f32>(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<f32>(0.06, 0.30, 0.19) * transmission * lighting;
let reflection_n = normalize(mix(vec3<f32>(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<f32>(0.0)), vec3<f32>(1.0 / 2.4)) - 0.055,
sky_linear > vec3<f32>(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<f32>(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<u32, 4>(256u, 256u, 256u, 256u);
var distances = array<f32, 4>(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<f32>(0.025, -0.018))
+ 0.3 * foam_noise(p * 1.7 - t * vec2<f32>(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<f32>(ddx_depth, ddy_depth))
/ max(length(vec2<f32>(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<f32>(0.030, -0.020)) - 1.0);
let lace_b = 1.0 - abs(2.0 * foam_noise(p * 3.30 - t * vec2<f32>(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<f32>(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<f32>(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<f32>(foam_luma),
clamp(illumination, vec3<f32>(0.22), vec3<f32>(1.0)), 0.5);
rgb = (foam_tint * foam_alpha + rgb * body_alpha * (1.0 - foam_alpha))
/ max(alpha, 0.0001);
return vec4<f32>(apply_fog(rgb, in.world_position.xyz, in.room_fog), alpha);
}
@fragment
fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
// 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<f32> {
}
}
// 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.
+27
View File
@@ -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<Image>);
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::<Assets<Image>>().add(image))
}
}
+11 -3
View File
@@ -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<BleedFixture>,
(Without<BleedFixture>, Without<super::LavaLight>),
>,
time: Res<Time>,
mut last_dump: Local<f64>,
@@ -1768,7 +1771,7 @@ pub fn update_bleed_fixtures(
Option<&LightLane>,
Option<&super::LightRooms>,
Has<SyntheticFireLight>,
Has<DaylightFixture>,
Option<&DaylightFixture>,
// MONKEY (spellLightGain): last, and it OVERRIDES the synthetic bit below — every spell
// light also carries [`SyntheticFireLight`], so testing fire first would put a fireball
// on the hearth dial.
@@ -1828,7 +1831,12 @@ pub fn update_bleed_fixtures(
// `DaylightFixture`. Mirroring that exclusion here is what keeps the doorway from
// being dimmed twice: a bleed fixture IS a `DaylightFixture`, so the packer will not
// scale it, and the gain therefore has to be in the TARGET.
let g = if daylight { 1.0 } else { knobs.interior_gain };
// MONKEY (lava light): the shared caster exclusion does not make magma sunlight.
let g = if daylight.is_some_and(|f| f.how != DaylightHow::Lava) {
1.0
} else {
knobs.interior_gain
};
let c_norm = commit_norm([
(c[0] * s * g).max(0.0),
(c[1] * s * g).max(0.0),
@@ -1352,7 +1352,7 @@ fn build_light_data(
Option<&FlameFlicker>,
// MONKEY (darkness gains): the daylight-fixture marker — an interior-lane entry that
// is the SUN standing in a doorway, not a candle, so `interiorGain` must skip it.
Has<super::DaylightFixture>,
Option<&super::DaylightFixture>,
// MONKEY (spellLightGain): the spell-effect marker ([`SpellFxLight`]). Last in the
// tuple so every positional destructuring below keeps its index — and read for one
// thing only: WHICH live gain owns this row.
@@ -1594,6 +1594,8 @@ fn build_light_data(
// `nightGain` claim it instead — that one dims the night, and a daylight fixture is
// already scaled to nothing by its own day envelope (`daylight_target`'s `sun_w`,
// the same curve) by the time the night dim is at full strength.
// MONKEY (lava light): lava borrows the caster exclusion, not the sun's gain exemption.
let daylight = daylight.is_some_and(|f| f.how != super::DaylightHow::Lava);
let rgb = if interior && !daylight {
rgb.map(|c| c * dynamic_interiors.interior_gain)
} else {
@@ -0,0 +1,617 @@
//! MONKEY (lava light): warm, slowly breathing fixtures over streamed magma surfaces.
//!
//! Read the same world-space wet grid used by swimming: MCLQ and placed/rotated MLIQ already
//! publish it on their rendered entity. No bounds-only lights over a shore or a masked hole.
//! Sampling and nearest-first budgeting run on surface changes (or the gain's on/off edge),
//! not camera motion. Only the <=[`LAVA_LIGHTS_MAX`] slow intensity writes run each frame. Slime
//! stays dark; whether Felwood's green pools count as fel fire is deliberately left to the owner.
//!
//! **Two things in here are MEASURED against the "lava not really glowing" report** (Searing Gorge
//! at night, `_fx/lava_findings.md`) rather than chosen: the lane stamp at the spawn (an
//! open-world fixture the shared down-ray classifier called INTERIOR lights no terrain at all),
//! and the geometry constants below. See each for the numbers.
use benilla_assets::coords::wow_to_bevy;
use bevy::prelude::*;
use std::collections::HashMap;
use super::{DaylightFixture, DaylightHow, LightReach, WorldPointLight};
use crate::liquid::{LiquidSoundSource, LiquidSurface, WaterChunkInfo};
/// MONKEY (lava light: read): 6 was a GORGE-KILLER. A magma channel's footprint is one MCNK liquid
/// layer per 33.3 yd chunk, so a pool that fits in one chunk got six fixtures TOTAL — measured on
/// the Searing Gorge channel at `(-7200, -930, 139)`: six local fixtures and then a 289 yd gap to
/// the next one, i.e. 42 of the 48-fixture budget spent a quarter of a mile away where they lit
/// nothing. Twelve per surface lets a single-chunk pool actually use the budget it is next to.
pub const LAVA_LIGHTS_PER_SURFACE: usize = 12;
pub const LAVA_LIGHTS_MAX: usize = 64;
/// The sample lattice over a magma footprint, and the minimum spacing the budgeter keeps.
///
/// MEASURED against the terrain shader's own falloff, which is the thing that has to be satisfied:
/// `terrain.wgsl::point_light_eval` commits `rgb / (0.7 d + 0.03 d²) × max(N·L, 0)`. Ground beside
/// a river reads the SUM of the fixtures in its 12-deep per-chunk selection, so spacing is not
/// cosmetic — it is most of the amplitude. At the old 12 yd / +1 yd / 2.0, a ground patch 6 yd off
/// the nearest fixture collects `0.186 × 0.164 × 2.0 ≈ 0.06` red and its neighbours add ~0.013
/// each: **0.08**, which is exactly the "faint orange tint on some ground" the report describes.
/// At 8 yd / +2.5 yd / 3.0 the same patch collects ~0.4 from the nearest and ~0.2 from the ring
/// behind it. Cheap, too: the cost of a fixture is one packed row, and the per-chunk selection
/// keeps only the nearest twelve whatever we spawn.
const GRID_YD: f32 = 8.0;
const MERGE_YD: f32 = 7.0;
/// How far ABOVE the magma surface a fixture floats. The old +1 yd was the geometric reason the
/// pool could not light its own banks: for flat ground at the liquid's own level `N·L` degenerates
/// to `lift / d`, so a 1 yd lift throws away ~85 % of the term at 6 yd. +2.5 yd is still inside the
/// heat haze over the surface and nearly triples it.
const LIFT_YD: f32 = 2.5;
const COLOR: [f32; 3] = [1.0, 0.42, 0.10];
/// MONKEY (lava light): the authored rung on `fire_light.rs::fire_intensity`'s ladder. A river of
/// molten rock is a BONFIRE (3.0), not the brazier (2.0) it shipped as — the brazier rung is a
/// standing fire bowl, and a 30 yd channel of it does not read as one. `m2_light_reach(3.0)` is
/// 24 yd, so [`REACH_YD`] is that rung's own reach and not a second free parameter.
const AUTHORED: f32 = 3.0;
const REACH_YD: f32 = 24.0;
// `WorldPointLight` stores 4*pi times authored units (`spawn_point_light`'s premultiply).
const INTENSITY: f32 = AUTHORED * 4.0 * std::f32::consts::PI;
/// MONKEY (lava light): ownership is explicit rather than ChildOf: the shared down-ray lane
/// classifier deliberately skips children. Reconciliation removes fixtures with their surface
/// before that frame's light packing, and leaves retained entities (and their lane cache) alone.
#[derive(Component)]
pub struct LavaLight {
surface: Entity,
position: Vec3,
phase: f32,
}
#[derive(Resource, Default)]
struct LavaSurfaces {
candidates: HashMap<Entity, Vec<Vec3>>,
dirty: bool,
enabled: bool,
}
/// MONKEY (lava light): live settings dial; zero removes all magma fixtures.
#[derive(Resource, Clone, Copy)]
pub struct LavaLightGain(pub f32);
impl Default for LavaLightGain {
fn default() -> Self {
Self(1.0)
}
}
pub(super) fn register(app: &mut App) {
app.init_resource::<LavaLightGain>()
.init_resource::<LavaSurfaces>()
.add_systems(
PostUpdate,
(reconcile, breathe)
.chain()
.after(bevy::transform::TransformSystems::Propagate)
// Doorway bleed must read this frame's magma gain/breath, including removal.
.before(super::daylight::update_bleed_fixtures)
.before(super::global_light::classify_light_lanes),
)
// MONKEY (lava light): the LANE census, AFTER the classifier has answered this frame —
// "the module made 48 fixtures" and "48 fixtures light the rock" are different claims, and
// the interior lane is where the second one is lost. Rides `WOW_POINTS_DUMP` (the same
// knob the packer's table prints under) so a run log carries both halves.
.add_systems(
PostUpdate,
lane_census.after(super::global_light::classify_light_lanes),
);
}
// MONKEY (lava light): use the wet vertices for bounds, then the grid's own inverse/bilinear
// sampler for coverage and height. In particular, a rotated MLIQ's AABB is NOT its footprint.
// Partial edge cells use their own centre, allowing a pool smaller than 12 yd to contribute.
fn sample_grid(info: &WaterChunkInfo) -> Vec<Vec3> {
let mut lo = Vec2::splat(f32::INFINITY);
let mut hi = Vec2::splat(f32::NEG_INFINITY);
info.for_each_wet_cell(|corners| {
for [x, y, _] in corners {
lo = lo.min(Vec2::new(x, y));
hi = hi.max(Vec2::new(x, y));
}
});
if !lo.is_finite() || !hi.is_finite() {
return Vec::new();
}
let mut points = Vec::new();
for j in 0..((hi.y - lo.y) / GRID_YD).ceil() as usize {
for i in 0..((hi.x - lo.x) / GRID_YD).ceil() as usize {
let cell_lo = lo + Vec2::new(i as f32, j as f32) * GRID_YD;
let centre = (cell_lo + (cell_lo + Vec2::splat(GRID_YD)).min(hi)) * 0.5;
if let Some(z) = info.surface_z_at(centre.x, centre.y) {
points.push(wow_to_bevy([centre.x, centre.y, z + LIFT_YD]));
}
}
}
points
}
fn select(candidates: &HashMap<Entity, Vec<Vec3>>, camera: Vec3) -> Vec<(Entity, Vec3)> {
let mut ranked: Vec<_> = candidates
.iter()
.flat_map(|(&surface, points)| points.iter().map(move |&p| (surface, p)))
.collect();
ranked.sort_by(|(a, p), (b, q)| {
p.distance_squared(camera)
.total_cmp(&q.distance_squared(camera))
.then_with(|| a.to_bits().cmp(&b.to_bits()))
.then_with(|| p.x.total_cmp(&q.x))
.then_with(|| p.y.total_cmp(&q.y))
.then_with(|| p.z.total_cmp(&q.z))
});
let mut selected: Vec<(Entity, Vec3)> = Vec::new();
let mut counts = HashMap::<Entity, usize>::new();
for (surface, p) in ranked {
if counts.get(&surface).copied().unwrap_or(0) >= LAVA_LIGHTS_PER_SURFACE
|| selected
.iter()
.any(|(_, q)| p.distance_squared(*q) < MERGE_YD * MERGE_YD)
{
continue;
}
// Keep the nearer representative in place: averaging a cluster could put it on dry land.
selected.push((surface, p));
*counts.entry(surface).or_default() += 1;
if selected.len() == LAVA_LIGHTS_MAX {
break;
}
}
selected
}
fn reconcile(
mut commands: Commands,
gain: Res<LavaLightGain>,
mut cache: ResMut<LavaSurfaces>,
surfaces: Query<(Entity, Ref<WaterChunkInfo>, Ref<LiquidSoundSource>), With<LiquidSurface>>,
camera: Query<&GlobalTransform, With<crate::view::WorldCamera>>,
lights: Query<(Entity, &LavaLight)>,
) {
let old_len = cache.candidates.len();
cache.candidates.retain(|e, _| surfaces.contains(*e));
cache.dirty |= old_len != cache.candidates.len();
// MONKEY (lava light): the census the "lava not really glowing" report had no line for. One
// `info!` per REBUILD (a rare event — surfaces streaming or the gain's on/off edge), never per
// frame, so a run log answers "were there magma surfaces, how many candidates, how many kept"
// without a debugger. `nibbles` is the raw liquid sound class of every live surface: that is
// the number the `& 3 == 2` predicate is decided on.
let mut nibbles: HashMap<u8, usize> = HashMap::new();
for (_, _, sound) in &surfaces {
*nibbles.entry(sound.nibble).or_default() += 1;
}
let surfaces_seen = surfaces.iter().count();
for (surface, info, sound) in &surfaces {
// Both spawn paths publish the same sound class: nibble & 3 == 2 is magma.
// Water (0/1) and slime (3) NEVER enter the emitting set, including fullbright slime.
if sound.nibble & 3 != 2 {
cache.dirty |= cache.candidates.remove(&surface).is_some();
} else if info.is_changed()
|| sound.is_changed()
|| !cache.candidates.contains_key(&surface)
{
cache.candidates.insert(surface, sample_grid(&info));
cache.dirty = true;
}
}
let enabled = gain.0.is_finite() && gain.0 > 0.0;
cache.dirty |= enabled != cache.enabled;
cache.enabled = enabled;
if !cache.dirty {
return;
}
let mut selected = if enabled {
let Ok(camera) = camera.single() else {
// Retire orphaned fixtures even while the world camera is being replaced.
for (e, light) in &lights {
if !cache.candidates.contains_key(&light.surface) {
commands.entity(e).despawn();
}
}
return; // keep dirty until nearest-to-camera has a real camera
};
select(&cache.candidates, camera.translation())
} else {
Vec::new()
};
cache.dirty = false;
// MONKEY (lava light): the rebuild census — see the note by `nibbles` above.
{
let cam = camera.single().map(|t| t.translation()).ok();
let magma = cache.candidates.len();
let points: usize = cache.candidates.values().map(Vec::len).sum();
let mut census: Vec<_> = nibbles.iter().map(|(n, c)| (*n, *c)).collect();
census.sort_unstable();
let first: Vec<[f32; 3]> = selected
.iter()
.take(4)
.map(|(_, p)| benilla_assets::coords::bevy_to_wow(*p))
.collect();
info!(
"lava light: surfaces {surfaces_seen} (nibbles {census:?}) magma {magma} \
candidates {points} kept {} gain {} cam {:?} first {first:?}",
selected.len(),
gain.0,
cam.map(benilla_assets::coords::bevy_to_wow),
);
}
for (e, light) in &lights {
if let Some(i) = selected
.iter()
.position(|&(s, p)| s == light.surface && p == light.position)
{
selected.swap_remove(i);
} else {
commands.entity(e).despawn();
}
}
for (surface, position) in selected {
let hash = position.x.to_bits().wrapping_mul(0x9e3779b9)
^ position.y.to_bits().rotate_left(11)
^ position.z.to_bits().rotate_left(21);
let fixture = commands.spawn((
LavaLight {
surface,
position,
phase: hash as f32 / u32::MAX as f32 * std::f32::consts::TAU,
},
WorldPointLight {
color: COLOR,
intensity: INTENSITY * gain.0,
range: 48.0,
},
LightReach(REACH_YD),
Transform::from_translation(position),
GlobalTransform::from_translation(position),
// MONKEY (lava light): torch_shadow already excludes DaylightFixture. Borrow that
// marker without inventing a room claim; daylight's updater explicitly excludes us,
// and the packer/bleed source retain the normal interiorGain law for How::Lava.
//
// The "lava not really glowing" round CHECKED this borrow rather than assuming it, and
// it is sound — all four consumers carve `How::Lava` out correctly
// (`daylight.rs:~1021` `Without<LavaLight>`, `:~1835` the bleed gain,
// `global_light.rs:~1598` the `interiorGain` exemption, `torch_shadow.rs:~752` the
// caster refusal). So this is NOT a dedicated-marker job and nothing outside this file
// needed touching; the defect was the LANE, stamped below.
DaylightFixture {
instance: surface,
group: 0,
portal: None,
how: DaylightHow::Lava,
reach: REACH_YD,
cal_d: 0.0,
cal_ndl: 0.0,
},
));
// MONKEY (lava light: lane). A HYPOTHESIS THIS ROUND TESTED AND **REFUTED** — left here so
// the next reader does not spend the session re-deriving it.
//
// The shared classifier ([`super::global_light::classify_light_lanes`]) files 9 of 48
// Searing Gorge fixtures INTERIOR, and an interior row is skipped by `terrain.wgsl` before
// its 12-deep per-chunk ranking (colour `.w > 0.5`) and dimmed by `interiorGain` on top —
// which LOOKS exactly like the cause of "lava not really glowing". It is not. Those pools
// are under real cave roofs, their neighbours are interior-class WMO walls, and INTERIOR is
// the lane that lights them. Pinning ADT-sourced fixtures to the exterior lane was built,
// captured and MEASURED: the cave walls went from `(26,4,2)` to `(17,2,1)` — the fix made
// the rock DARKER, and the exterior tuning below moved those pixels by exactly zero
// because nothing in that frame reads the exterior lane at all
// (`_fx/lava-river-{probe,fixA,after}.png`). Reverted. The classifier is right; do not
// override it from here, in either direction.
let _ = fixture;
}
}
/// MONKEY (lava light): `WOW_POINTS_DUMP` — the fixtures this module owns, with the LANE the
/// shared classifier put each on and its distance from the camera. The packer's own table caps its
/// printout at the nearest eight, which in a zone full of braziers prints no lava row at all; this
/// one is lava-only, so "are there fixtures" and "are they on the lane terrain reads" stay separable.
fn lane_census(
time: Res<Time>,
gain: Res<LavaLightGain>,
lights: Query<(&LavaLight, &WorldPointLight, Option<&super::LightLane>)>,
camera: Query<&GlobalTransform, With<crate::view::WorldCamera>>,
mut last: Local<f64>,
) {
static DUMP: std::sync::OnceLock<Option<std::ffi::OsString>> = std::sync::OnceLock::new();
let Some(mode) = DUMP.get_or_init(|| std::env::var_os("WOW_POINTS_DUMP")) else {
return;
};
let every = if mode.as_os_str() == "frame" { 0.0 } else { 1.0 };
let now = time.elapsed_secs_f64();
if now - *last < every {
return;
}
*last = now;
let Ok(camera) = camera.single() else { return };
let cam = camera.translation();
let mut rows: Vec<(f32, bool, f32, Vec3)> = lights
.iter()
.map(|(lava, light, lane)| {
(
lava.position.distance(cam),
// No lane yet == the packer's own fallback, which for a room-less fixture is
// EXTERIOR. Printed as the classifier's answer would be, not as "unknown".
lane.is_some_and(|l| l.interior),
light.intensity / (4.0 * std::f32::consts::PI),
lava.position,
)
})
.collect();
rows.sort_by(|a, b| a.0.total_cmp(&b.0));
let interior = rows.iter().filter(|r| r.1).count();
eprintln!(
"[lava] {} fixture(s) ({interior} INT / {} EXT), gain {:.2}, cam {:?}",
rows.len(),
rows.len() - interior,
gain.0,
benilla_assets::coords::bevy_to_wow(cam),
);
for (d, interior, i, p) in rows.iter().take(8) {
eprintln!(
" d {d:6.2} at {:?} I {i:.2} {}",
benilla_assets::coords::bevy_to_wow(*p),
if *interior { "INT" } else { "EXT" },
);
}
}
fn breathe(
time: Res<Time>,
gain: Res<LavaLightGain>,
mut lights: Query<(&LavaLight, &mut WorldPointLight)>,
) {
for (lava, mut light) in &mut lights {
let wave = (time.elapsed_secs() * std::f32::consts::TAU * 0.4 + lava.phase).sin();
let intensity = INTENSITY * gain.0.max(0.0) * (1.0 + 0.1 * wave);
// MONKEY (lava light): stationary/paused clocks and sub-epsilon breathing do not churn
// change detection. No FlameFlicker or SyntheticFireLight: only LavaLightGain owns it.
if (light.intensity - intensity).abs() > 1e-4 {
light.intensity = intensity;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::liquid::LiquidSource;
use benilla_formats::LiquidKind;
fn grid(cols: usize, rows: usize, wet: Vec<bool>, kind: LiquidKind) -> WaterChunkInfo {
let positions = (0..rows)
.flat_map(|j| {
(0..cols)
.map(move |i| [i as f32 * 12.0, j as f32 * 12.0, i as f32 * 2.0 + j as f32])
})
.collect();
WaterChunkInfo::new(LiquidSource::AdtChunk, kind, [cols, rows], positions, wet)
}
// Denominated in the CONSTANTS rather than in the numbers they happened to have: the tuning
// pass that fixed the report moved `GRID_YD`/`LIFT_YD`, and a test pinned to "6.0, 6.0, 2.5"
// fails on a value change while saying nothing about the rule (mask + sampled height) it was
// written to protect. One exact case is still checked below, built from the constants.
#[test]
fn grid_sampling_respects_mask_and_surface_height() {
let info = grid(3, 3, vec![true, false, false, true], LiquidKind::Magma);
let points = sample_grid(&info);
assert!(!points.is_empty());
for point in &points {
let p = benilla_assets::coords::bevy_to_wow(*point);
// Every emitted point is over a WET cell, and stands `LIFT_YD` over the SAMPLED
// surface — never the chunk maximum, and never a masked hole.
assert_eq!(
info.surface_z_at(p[0], p[1]).map(|z| z + LIFT_YD),
Some(p[2]),
);
}
// The lattice is the one the constant names: the first cell's centre is half a cell in.
assert_eq!(
points[0],
wow_to_bevy([
GRID_YD * 0.5,
GRID_YD * 0.5,
// `z = i·2 + j` over a 12 yd vertex spacing ⇒ `x/6 + y/12` at the sample.
GRID_YD * 0.5 / 6.0 + GRID_YD * 0.5 / 12.0 + LIFT_YD,
]),
);
assert!(sample_grid(&grid(2, 2, vec![false], LiquidKind::Magma)).is_empty());
}
#[test]
fn rotated_grid_does_not_emit_over_aabb_corners_or_holes() {
let rot = Quat::from_rotation_z(0.37);
let positions = (0..4)
.flat_map(|j| {
(0..4).map(move |i| {
(rot * Vec3::new(i as f32 * 12.0, j as f32 * 12.0, 3.0)).to_array()
})
})
.collect();
let info = WaterChunkInfo::new(
LiquidSource::AdtChunk,
LiquidKind::Magma,
[4, 4],
positions,
vec![true, true, true, true, false, true, true, true, true],
);
let points = sample_grid(&info);
assert!(!points.is_empty());
for point in points {
let p = benilla_assets::coords::bevy_to_wow(point);
assert_eq!(info.surface_z_at(p[0], p[1]).map(|z| z + LIFT_YD), Some(p[2]));
let local = rot.inverse() * Vec3::from_array(p);
assert!(!(local.x > 12.0 && local.x < 24.0 && local.y > 12.0 && local.y < 24.0));
}
}
#[test]
fn merge_across_surfaces_keeps_nearest_and_accepts_the_spacing_exactly() {
let mut world = World::new();
let a = world.spawn_empty().id();
let b = world.spawn_empty().id();
// Inside the merge radius ⇒ dropped; exactly AT it ⇒ kept (the test is `< MERGE_YD²`).
let points = HashMap::from([
(a, vec![Vec3::ZERO]),
(b, vec![Vec3::X * (MERGE_YD - 1.0), Vec3::X * MERGE_YD]),
]);
assert_eq!(
select(&points, Vec3::ZERO),
vec![(a, Vec3::ZERO), (b, Vec3::X * MERGE_YD)]
);
}
#[test]
fn caps_are_global_and_per_surface_with_nearest_winning() {
let mut world = World::new();
// Enough per surface that the PER-SURFACE cap bites before the global one, and enough
// surfaces that the global cap bites too — both, or the test only proves one of them.
let per_surface = LAVA_LIGHTS_PER_SURFACE + 3;
let points: HashMap<_, _> = (0..10)
.map(|s| {
let e = world.spawn_empty().id();
(
e,
(0..per_surface)
.map(|i| Vec3::new(s as f32 * 200.0, 0.0, i as f32 * (MERGE_YD + 1.0)))
.collect(),
)
})
.collect();
let selected = select(&points, Vec3::ZERO);
assert_eq!(selected.len(), LAVA_LIGHTS_MAX);
let mut capped = 0;
for &e in points.keys() {
let n = selected.iter().filter(|(s, _)| *s == e).count();
assert!(n <= LAVA_LIGHTS_PER_SURFACE);
capped += usize::from(n == LAVA_LIGHTS_PER_SURFACE);
}
assert!(capped > 0, "the per-surface cap must actually bind here");
// Nearest-first: the budget is spent on the near surfaces, never scattered over the far
// ones. This is the property the Searing Gorge run failed — 42 of 48 fixtures 289 yd out.
let far = 200.0 * (LAVA_LIGHTS_MAX / LAVA_LIGHTS_PER_SURFACE + 1) as f32;
assert!(selected.iter().all(|(_, p)| p.x <= far));
assert_eq!(selected[0].1, Vec3::ZERO);
}
fn app() -> App {
let mut app = App::new();
app.init_resource::<Time>();
register(&mut app);
app.world_mut()
.spawn((crate::view::WorldCamera, GlobalTransform::IDENTITY));
app
}
fn surface(app: &mut App, kind: LiquidKind, nibble: u8) -> Entity {
app.world_mut()
.spawn((
LiquidSurface,
LiquidSoundSource { nibble },
grid(3, 3, vec![true; 4], kind),
))
.id()
}
fn lights(app: &mut App) -> usize {
app.world_mut()
.query_filtered::<Entity, With<LavaLight>>()
.iter(app.world())
.count()
}
/// How many fixtures one full test surface is worth, derived rather than written down: the
/// lattice constants move with the tuning and the count is not the subject of any test below.
fn per_surface() -> usize {
sample_grid(&grid(3, 3, vec![true; 4], LiquidKind::Magma)).len()
}
/// MONKEY (lava light: read) — THE REGRESSION TEST FOR THE DEFECT THE REPORT FOUND.
///
/// The budget must be spent on the magma the camera is STANDING BY, and a pool that fits in
/// one MCNK liquid layer must be able to take enough of it to read. The shipped
/// `LAVA_LIGHTS_PER_SURFACE = 6` could not: MEASURED in Searing Gorge, the pool under the
/// camera got six fixtures at ~12 yd spacing and the seventh-nearest fixture in the whole
/// world was **289 yd away** — 42 of the 48-fixture budget spent where nothing could see it.
///
/// The near surface here is worth more candidates than the cap, and the far one is a whole
/// zone away; every slot must go to the near one.
#[test]
fn the_budget_goes_to_the_pool_the_camera_is_standing_by() {
let mut world = World::new();
let near = world.spawn_empty().id();
let far = world.spawn_empty().id();
let line = |x: f32| {
(0..LAVA_LIGHTS_PER_SURFACE * 2)
.map(|i| Vec3::new(x, 0.0, i as f32 * (MERGE_YD + 1.0)))
.collect::<Vec<_>>()
};
let points = HashMap::from([(near, line(0.0)), (far, line(289.0))]);
let selected = select(&points, Vec3::ZERO);
assert_eq!(
selected.iter().filter(|(s, _)| *s == near).count(),
LAVA_LIGHTS_PER_SURFACE,
"the near pool must be allowed to fill its whole per-surface allowance",
);
// One MCNK chunk is 33.3 yd across, so a pool that fits in one must get fixtures close
// enough together to sum in the shader — `GRID_YD` apart, not a third of the chunk apart.
assert!(
LAVA_LIGHTS_PER_SURFACE as f32 * GRID_YD * GRID_YD >= 533.333 / 16.0 * 20.0,
"a single-chunk pool must be able to cover itself at the sample spacing",
);
}
#[test]
fn gain_zero_removes_all_and_positive_gain_restores_without_surface_churn() {
let mut app = app();
surface(&mut app, LiquidKind::Magma, 6);
app.update();
assert_eq!(lights(&mut app), per_surface());
app.world_mut().resource_mut::<LavaLightGain>().0 = 0.0;
app.update();
assert_eq!(lights(&mut app), 0);
app.world_mut().resource_mut::<LavaLightGain>().0 = 2.0;
app.update();
assert_eq!(lights(&mut app), per_surface());
for (light, reach) in app
.world_mut()
.query::<(&WorldPointLight, &LightReach)>()
.iter(app.world())
{
assert!((INTENSITY * 1.8..=INTENSITY * 2.2).contains(&light.intensity));
// The reach is the AUTHORED rung's own, not an independent number.
assert_eq!(reach.0, super::super::m2_light_reach(AUTHORED));
assert_eq!(reach.0, REACH_YD);
}
}
#[test]
fn streaming_removes_orphans_and_slime_never_emits() {
let mut app = app();
let magma = surface(&mut app, LiquidKind::Magma, 2);
surface(&mut app, LiquidKind::Slime, 3);
app.update();
assert_eq!(lights(&mut app), per_surface());
for (e, marker) in app
.world_mut()
.query::<(Entity, &LavaLight)>()
.iter(app.world())
{
assert_eq!(marker.surface, magma);
assert!(app.world().get::<DaylightFixture>(e).is_some());
assert!(app.world().get::<super::super::FlameFlicker>(e).is_none());
assert!(
app.world()
.get::<super::super::SyntheticFireLight>(e)
.is_none()
);
assert!(app.world().get::<ChildOf>(e).is_none());
}
app.world_mut().despawn(magma);
app.update();
assert_eq!(lights(&mut app), 0);
}
}
+7
View File
@@ -15,6 +15,8 @@ mod daylight; // MONKEY (daylight fixtures): the sun as an interior-lane light i
mod daynight; // the two sun directions + day/night interp + the dawn/dusk warp curve
mod flicker; // MONKEY (flame flicker): the per-light fire wobble folded in at pack time
mod global_light; // the one shared global-light storage buffer (replaces the per-material push)
mod lava_light; // MONKEY (lava light): magma surface fixtures and their independent gain
pub use lava_light::{LavaLight, LavaLightGain};
mod prop_probes; // the per-instance interior-prop SH probe table (slot ↔ MeshTag payload)
mod resolve; // the per-frame time-of-day sample into WowLighting + the WMO interior-fog crossfade
mod sh; // the model SH light-probe coefficient math
@@ -427,6 +429,7 @@ impl Plugin for LightingPlugin {
global_light::register(app);
// MONKEY (daylight fixtures): the per-frame re-aim, ordered before the packer's own set.
daylight::register(app);
lava_light::register(app);
}
}
@@ -508,6 +511,10 @@ mod ordering_tests {
"lighting/global_light.rs",
"build_light_data: PostUpdate, .after(update_time_lighting)",
),
(
"liquid/scene_depth.rs",
"MONKEY (water): update_water_depth feeds the sky rows to the water material in `Last`, after the PostUpdate resolve",
),
(
"sun/follow.rs",
"the celestial follows: PostUpdate, BillboardPlace",
+5
View File
@@ -101,11 +101,14 @@ use benilla_assets::materials::LiquidMaterial;
use benilla_assets::AssetSet;
mod drift;
mod scene_depth;
pub use scene_depth::WaterDepthPlugin;
mod query;
#[cfg(test)]
mod real_data;
mod spatial;
mod surface; // the against-real-client-files tests — they span both halves
pub mod waves;
// The submodules are private, so this list IS the subsystem's face: everything the rest of the
// client may name. Most of this list is now reached through `crate::world_point::WorldPoint`
@@ -176,6 +179,8 @@ pub(crate) struct LiquidPlugin;
impl Plugin for LiquidPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(MaterialPlugin::<LiquidMaterial>::default())
.init_resource::<benilla_assets::WaterQuality>()
.init_resource::<benilla_assets::WaterDepthImage>()
.init_resource::<Underwater>()
.init_resource::<SubmergedEye>()
.init_resource::<WaterIndex>()
+17
View File
@@ -483,6 +483,23 @@ impl WaterChunkInfo {
}
}
/// MONKEY (swim waves) — **does this surface's MESH heave?** i.e. is its rendered height the
/// grid height plus [`super::waves`]'s long swell, rather than the grid height flat.
///
/// The shader's vertex-displacement arm (`liquid.wgsl:280`) runs on exactly one combination:
/// not fullbright (`kind.x < 0.5`), the ocean swatch (`kind.y > 0.5`), and the ADT MCLQ
/// renderer (`path.x < 0.5`) — the three static halves of that gate, which is what this
/// answers. The fourth, `path.y > 0.5` (Enhanced or High), is a **resource**
/// ([`benilla_assets::WaterQuality`]) and not a property of a surface, so it stays the
/// caller's to apply; folding a global setting into a per-chunk predicate is how the two
/// would eventually disagree about the same frame.
///
/// A body is only lifted by water that is actually lifting: a river, a WMO pool and lava all
/// render flat, and bobbing on them would be a body floating over its own reflection.
pub(crate) fn has_vertex_swell(&self) -> bool {
matches!(self.source, LiquidSource::AdtChunk) && self.kind == LiquidKind::Ocean
}
/// Is this WoW-space XY inside the chunk's wet footprint?
pub(crate) fn contains(&self, x: f32, y: f32) -> bool {
x >= self.min_x && x <= self.max_x && y >= self.min_y && y <= self.max_y
@@ -0,0 +1,255 @@
//! Main-pass depth for water. The shared image fits ExtendedMaterial's existing bind group;
//! only the world camera writes it (portrait/preview views never overwrite it). This belongs
//! in world/liquid because visibility and the WorldCamera marker are world responsibilities.
use bevy::prelude::*;
use bevy::camera::visibility::VisibleEntities;
use bevy::core_pipeline::core_3d::graph::{Core3d, Node3d};
use bevy::ecs::query::QueryItem;
use bevy::image::ToExtents;
use bevy::render::{RenderApp, RenderStartup};
use bevy::render::extract_component::{ExtractComponent, ExtractComponentPlugin};
use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin};
use bevy::render::render_asset::RenderAssets;
use bevy::render::render_graph::*;
use bevy::render::render_resource::*;
use bevy::render::renderer::{RenderContext, RenderDevice};
use bevy::render::texture::GpuImage;
use bevy::render::view::ViewDepthTexture;
use benilla_assets::{WaterDepthImage, WaterQuality, materials::LiquidMaterial};
#[derive(Component, Clone, ExtractComponent)]
struct WaterView;
#[derive(Resource, Clone, ExtractResource)]
struct DepthSource {
image: Handle<Image>,
active: bool,
}
/// `WOW_WATER=0|1|2` overrides settings for this run, read once at plugin startup.
pub struct WaterDepthPlugin;
#[derive(Resource)]
struct WaterOverride(Option<u8>);
impl Plugin for WaterDepthPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<WaterQuality>().init_resource::<WaterDepthImage>();
let value = std::env::var("WOW_WATER").ok()
.and_then(|v| v.parse::<u8>().ok()).filter(|v| *v <= 2);
if let Some(value) = value { app.insert_resource(WaterQuality(value)); }
let image = app.world().resource::<WaterDepthImage>().0.clone();
app.insert_resource(WaterOverride(value))
.insert_resource(DepthSource { image, active: false })
.add_plugins((ExtractComponentPlugin::<WaterView>::default(),
ExtractResourcePlugin::<DepthSource>::default()))
// Camera sizes and the per-view visible list are final by Last, before extraction.
.add_systems(Last, update_water_depth);
let shader = app.world_mut().resource_mut::<Assets<Shader>>()
.add(Shader::from_wgsl(RESOLVE_SHADER, "water_depth_resolve.wgsl"));
let Some(render_app) = app.get_sub_app_mut(RenderApp) else { return };
render_app.insert_resource(DepthShader(shader)).add_systems(RenderStartup, init_pipeline)
.add_render_graph_node::<ViewNodeRunner<DepthNode>>(Core3d, WaterDepthLabel)
// static_gx precedes MainOpaquePass; this sees BOTH solid-geometry paths.
.add_render_graph_edges(Core3d, (
Node3d::MainOpaquePass, WaterDepthLabel, Node3d::MainTransparentPass,
));
}
}
fn update_water_depth(
mut commands: Commands,
mut cameras: Query<(Entity, &Camera, &mut Camera3d, &VisibleEntities), With<crate::view::WorldCamera>>,
liquids: Query<&MeshMaterial3d<LiquidMaterial>>,
mut materials: ResMut<Assets<LiquidMaterial>>,
mut images: ResMut<Assets<Image>>,
mut source: ResMut<DepthSource>,
mut quality: ResMut<WaterQuality>,
override_value: Res<WaterOverride>,
light: Res<crate::lighting::WowLighting>,
) {
if let Some(value) = override_value.0 {
if quality.0 != value { quality.0 = value; }
}
// The dome's resolved, byte-quantized sky rows, converted to linear RGB.
let linear = |row: [f32; 3]| {
let c = benilla_assets::quant255(row).map(|v| {
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
});
Vec4::new(c[0], c[1], c[2], 0.0)
};
let zenith = linear(light.sky[0]);
let horizon = linear(light.sky[4]);
let celestial = if light.celestial_dir.y > 0.0 {
light.celestial_dir.extend(0.0)
} else { light.moon_dir_white.extend(1.0) };
// Evaluated after lighting resolves each frame; mutate only on actual changes.
let changed: Vec<_> = materials.iter().filter(|(_, m)| m.extension.kind.x < 0.5
&& (m.extension.path.y != quality.0.min(2) as f32
|| (quality.0 > 0 && (m.extension.sky_zenith != zenith
|| m.extension.sky_horizon != horizon || m.extension.celestial != celestial))))
.map(|(id, _)| id).collect();
for id in changed {
let ext = &mut materials.get_mut(id).unwrap().extension;
ext.path.y = quality.0.min(2) as f32;
ext.sky_zenith = zenith;
ext.sky_horizon = horizon;
ext.celestial = celestial;
}
source.active = false;
for (entity, camera, mut camera3d, visible) in &mut cameras {
commands.entity(entity).insert(WaterView);
let usage = TextureUsages::from(camera3d.depth_texture_usages);
if !usage.contains(TextureUsages::TEXTURE_BINDING) {
camera3d.depth_texture_usages = (usage | TextureUsages::TEXTURE_BINDING).into();
}
if quality.0 == 0 || !camera.is_active { continue; }
source.active = visible.get(std::any::TypeId::of::<Mesh3d>()).iter().any(|entity| {
liquids.get(*entity).ok().and_then(|m| materials.get(&m.0))
.is_some_and(|m| m.extension.kind.x < 0.5)
});
if let Some(size) = camera.physical_target_size() {
if size.x > 0 && size.y > 0 {
if images.get(&source.image).is_some_and(|image| image.size() != size) {
images.get_mut(&source.image).unwrap().resize(size.to_extents());
// A resize replaces the GPU view behind this stable handle. Rebuild all
// material bindings, including opaque liquids sharing the fallback binding.
let ids: Vec<_> = materials.ids().collect();
for id in ids { let _ = materials.get_mut(id); }
}
}
}
}
}
#[derive(Resource)]
struct DepthPipelines {
layouts: [BindGroupLayoutDescriptor; 2],
pipelines: [CachedRenderPipelineId; 2],
}
#[derive(Resource)]
struct DepthShader(Handle<Shader>);
fn init_pipeline(mut commands: Commands, shader: Res<DepthShader>, cache: Res<PipelineCache>) {
let shader = &shader.0;
let layouts = [false, true].map(|multisampled| BindGroupLayoutDescriptor::new(
"water depth source", &[BindGroupLayoutEntry {
binding: 0, visibility: ShaderStages::FRAGMENT,
ty: BindingType::Texture { sample_type: TextureSampleType::Depth,
view_dimension: TextureViewDimension::D2, multisampled }, count: None,
}],
));
let pipelines = [0, 1].map(|i| cache.queue_render_pipeline(RenderPipelineDescriptor {
label: Some("water depth resolve".into()), layout: vec![layouts[i].clone()],
vertex: VertexState { shader: shader.clone(), entry_point: Some("vertex".into()), ..default() },
fragment: Some(FragmentState {
shader: shader.clone(), entry_point: Some("fragment".into()),
shader_defs: if i == 1 { vec!["MULTISAMPLED".into()] } else { vec![] },
targets: vec![Some(ColorTargetState { format: TextureFormat::R32Float,
blend: None, write_mask: ColorWrites::ALL })],
}), ..default()
}));
commands.insert_resource(DepthPipelines { layouts, pipelines });
}
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
struct WaterDepthLabel;
#[derive(Default)]
struct DepthNode;
impl ViewNode for DepthNode {
type ViewQuery = (&'static WaterView, &'static ViewDepthTexture);
fn run<'w>(&self, _: &mut RenderGraphContext, context: &mut RenderContext<'w>,
(_, depth): QueryItem<'w, '_, Self::ViewQuery>, world: &'w World) -> Result<(), NodeRunError> {
let source = world.resource::<DepthSource>();
if !source.active { return Ok(()); }
let images = world.resource::<RenderAssets<GpuImage>>();
let Some(image) = images.get(&source.image) else { return Ok(()) };
if image.texture.size() != depth.texture.size() { return Ok(()); }
let pipelines = world.resource::<DepthPipelines>();
let cache = world.resource::<PipelineCache>();
let index = usize::from(depth.texture.sample_count() > 1);
let Some(pipeline) = cache.get_render_pipeline(pipelines.pipelines[index]) else { return Ok(()) };
let device = world.resource::<RenderDevice>();
let bind = device.create_bind_group("water depth source",
&cache.get_bind_group_layout(&pipelines.layouts[index]),
&BindGroupEntries::single(depth.view()));
let mut pass = context.command_encoder().begin_render_pass(&RenderPassDescriptor {
label: Some("water opaque depth copy"),
color_attachments: &[Some(RenderPassColorAttachment {
view: &image.texture_view, resolve_target: None, depth_slice: None,
ops: Operations { load: LoadOp::Clear(Default::default()), store: StoreOp::Store },
})], ..default()
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &bind, &[]);
pass.draw(0..3, 0..1);
Ok(())
}
}
// Sample zero is intentional: averaging reverse-Z samples invents surfaces at silhouettes.
const RESOLVE_SHADER: &str = r#"
#ifdef MULTISAMPLED
@group(0) @binding(0) var depth: texture_depth_multisampled_2d;
#else
@group(0) @binding(0) var depth: texture_depth_2d;
#endif
@vertex fn vertex(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let x = f32((i << 1u) & 2u);
let y = f32(i & 2u);
return vec4<f32>(x * 2.0 - 1.0, y * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fragment(@builtin(position) p: vec4<f32>) -> @location(0) f32 {
return textureLoad(depth, vec2<i32>(p.xy), 0);
}
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reconstructed_water_depth_is_height_not_view_ray_length() {
let projection = Mat4::perspective_infinite_reverse_rh(1.0, 1.6, 0.1);
// Same submerged wall point from a low grazing view and an overhead view.
// Opacity must see the same 2 yd depth even though ray lengths differ greatly.
let wall = Vec3::new(0.0, 8.0, 0.0);
for eye in [Vec3::new(0.0, 12.0, 30.0), Vec3::new(0.0, 40.0, 3.0)] {
let world_from_view = Mat4::look_at_rh(eye, wall, Vec3::Y).inverse();
let clip_from_world = projection * world_from_view.inverse();
let clip = clip_from_world * wall.extend(1.0);
let ndc = clip / clip.w;
let viewport = Vec4::new(32.0, 16.0, 1600.0, 900.0);
let pixel = (ndc.truncate().truncate() * Vec2::new(0.5, -0.5) + Vec2::splat(0.5))
* Vec2::new(viewport.z, viewport.w) + Vec2::new(viewport.x, viewport.y);
let xy = ((pixel - Vec2::new(viewport.x, viewport.y))
/ Vec2::new(viewport.z, viewport.w)) * Vec2::new(2.0, -2.0) + Vec2::new(-1.0, 1.0);
let scene_h = clip_from_world.inverse() * Vec4::new(xy.x, xy.y, ndc.z, 1.0);
let vertical_depth = 10.0 - scene_h.y / scene_h.w;
assert!((vertical_depth - 2.0).abs() < 0.001);
}
}
#[test]
fn reverse_z_thickness_handles_finite_and_infinite_projections() {
// Project real view-space positions, then exercise the WGSL reconstruction law.
// Off-axis rays must return ray length, not merely the eye-Z gap.
for projection in [
Mat4::perspective_infinite_reverse_rh(1.0, 1.6, 0.1),
Mat4::perspective_rh(1.0, 1.6, 1000.0, 0.1),
] {
for ray in [Vec3::NEG_Z, Vec3::new(0.4, -0.3, -1.0).normalize()] {
for (water, bed) in [(2.0, 2.2), (40.0, 52.0), (200.0, 240.0)] {
let linearise = |distance: f32| {
let clip = projection * (ray * distance).extend(1.0);
projection.w_axis.z / (clip.z / clip.w + projection.z_axis.z)
};
let thickness = (linearise(bed) - linearise(water)) / ray.z.abs();
assert!((thickness - (bed - water)).abs() < 0.002);
}
}
}
}
}
+26 -5
View File
@@ -258,10 +258,11 @@ pub(crate) fn spawn_liquids<'a>(
// transform is a no-op round-trip).
let info = wet_footprint(lq, &Transform::IDENTITY, LiquidSource::AdtChunk);
let foam = !lq.kind.is_fullbright(); // white surf is a water thing
let mesh_handle = meshes.add(liquid_bevy_mesh(lq, None));
entities.push(
commands
.spawn((
Mesh3d(meshes.add(liquid_bevy_mesh(lq, None))),
Mesh3d(mesh_handle.clone()),
MeshMaterial3d(material),
Transform::IDENTITY,
LiquidSurface,
@@ -352,7 +353,8 @@ fn liquid_bevy_mesh(lq: &LiquidMesh, body_color: Option<[f32; 3]>) -> Mesh {
// world by the entity transform) + the sun.
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 1.0, 0.0]; n]);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, lq.uvs.clone());
// UV1.x carries the per-vertex swatch depth (0..1) for the shader's opacity ramp.
// UV1.x carries the per-vertex swatch depth (0..1) for the shader's opacity ramp; UV1.y is
// unused.
let uv1: Vec<[f32; 2]> = lq.depths.iter().map(|&d| [d, 0.0]).collect();
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, uv1);
// An INTERIOR WMO pool's body colour is its own `MOMT.diffColor`, and it rides the vertex colour
@@ -509,6 +511,8 @@ pub(super) fn setup_liquid(
world_assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
mut materials: ResMut<Assets<LiquidMaterial>>,
water_depth: Res<benilla_assets::WaterDepthImage>,
water_quality: Res<benilla_assets::WaterQuality>,
) {
let (Some(_config), Some(mut world_assets)) = (config, world_assets) else {
return; // no client data → no terrain, so no water either
@@ -516,6 +520,11 @@ pub(super) fn setup_liquid(
// No light seed and no per-frame push: light, fog and both water swatches come off the shared
// global-light buffer (`lighting::global_light`), which `build_light_data` has already packed by
// the time anything draws — the same path terrain and the models take.
// Read once at setup; only frozen water captures consume this override.
let capture_time = if std::env::var_os("WOW_CAPTURE").is_some() {
std::env::var("WOW_CAPTURE_WATER_T").ok().and_then(|v| v.parse::<f32>().ok())
.filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0)
} else { 0.0 };
let mut assets = LiquidAssets::default();
for &(kind, dir, stem, count) in FRAME_SETS {
let Some((frames, frame_count)) =
@@ -586,6 +595,7 @@ pub(super) fn setup_liquid(
..default()
},
extension: LiquidExt {
scene_depth: water_depth.0.clone(),
frames: frames.clone(),
// x = fullbright (magma/slime: the animated texture is the opaque body, skipping
// the swatch and N·L — but NOT the fog, which every liquid kind takes); y = read
@@ -598,14 +608,22 @@ pub(super) fn setup_liquid(
WATER_SHININESS,
),
// Which of the reference's three liquid renderers `liquid.wgsl` runs.
path: Vec4::new(path.shader_id(), 0.0, 0.0, 0.0),
// x = reserved (frame 0; the shader derives the live index from its own
// `w` is unused (it was the river-flow dial; the owner had the effect removed).
path: Vec4::new(
path.shader_id(), water_quality.0 as f32,
if kind.is_fullbright() { 0.0 }
else if path != LiquidPath::Adt { 0.12 }
else if kind == LiquidKind::Ocean { 1.0 }
else { 0.18 },
0.0,
),
// x = fixed Enhanced capture time (live frame index uses the shader
// clock); y = frame count; z = the SCROLL FLAG (1 = this lane takes the
// stage-0 v-scroll — [`scrolls`]' nibble-6/7 WMO lane); w = the clock
// enable (0 on a deterministic run bakes the 0600 capture freeze — frame 0,
// scroll 0 — with no tick left to skip).
anim: Vec4::new(
0.0,
capture_time,
frame_count as f32,
if scroll { 1.0 } else { 0.0 },
if crate::dev_state::deterministic_run() {
@@ -614,6 +632,9 @@ pub(super) fn setup_liquid(
1.0
},
),
sky_zenith: Vec4::ZERO,
sky_horizon: Vec4::ZERO,
celestial: Vec3::Y.extend(0.0),
light_buf: world_assets.shared_light.clone(),
},
});
+242
View File
@@ -0,0 +1,242 @@
//! MONKEY (swim waves) — **the CPU mirror of the ocean's vertex swell**.
//!
//! Enhanced water displaces the ocean mesh in the VERTEX stage
//! (`benilla-assets/src/shaders/liquid.wgsl`, the `@vertex fn vertex` swell arm at lines 279-283),
//! so the surface a swimmer floats on is not the flat MCLQ heightfield the CPU queries
//! ([`super::query::WaterChunkInfo::surface_z_at`]) — it is that heightfield plus an analytic
//! long-swell band the CPU never sees. A body positioned off the queried height therefore rides
//! *through* the crests instead of on them, which is the whole of the owner's report: the sea
//! visibly heaves and the swimmer in it does not.
//!
//! This module is the missing half: the SAME two wave components, the same dispersion, the same
//! clock, evaluated in Rust. It is a **mirror, not a second design** — every constant below is
//! copied from the shader with its line cited, and [`tests::the_table_mirrors_the_shader`] pins
//! them against the literal numbers so a drift in either direction shows up as a failing test
//! rather than as a swimmer sunk half a yard into a crest.
//!
//! **What it deliberately does not mirror.** The shader's fragment stage sums all EIGHT
//! components for its normal; the vertex stage takes `long_only` — components 0 and 1 only
//! (`liquid.wgsl:393`), because the rest are finer than the liquid lattice can resolve and exist
//! to shade, not to move geometry. A bob driven off the fragment band would chase ripples the mesh
//! under it never made. So: two components, the mesh's own.
//!
//! **Space.** The shader's `p` is `world_position.xz` — BEVY world XZ, because the liquid meshes
//! are baked through [`benilla_assets::coords::wow_to_bevy`]. Callers pass Bevy XZ, not WoW XY.
use bevy::math::Vec2;
/// The long-swell component table — `liquid.wgsl:365-375`'s `WATER_WAVES[0..2]`, verbatim:
/// `(direction_radians, wavelength_yd, amplitude_yd, phase_offset)`. Direction is radians from
/// world +X toward +Z; the two sum to at most 0.34 yd before energy and shore attenuation.
///
/// Components 2..8 are the shader's fragment-only ripple band and are NOT here — see the module
/// doc's `long_only` note. Adding them would be a second design, not a closer mirror.
pub const LONG_SWELL: [[f32; 4]; 2] = [
[0.35, 18.0, 0.200, 0.0],
[0.80, 12.8, 0.140, 1.7],
];
/// Gravity in yd/s², `liquid.wgsl:398` — deep-water dispersion, `c = sqrt(g/k)`.
const GRAVITY_YD: f32 = 10.72;
/// `6.2831853` exactly as the shader spells it (`liquid.wgsl:397`). Identical to `f32::TAU` once
/// rounded, spelled as the literal so the mirror reads against the line it copies.
const TWO_PI: f32 = 6.2831853;
/// The ADT **ocean**'s wave energy — `liquid/surface.rs:609-616` packs `path.z = 1.0` for
/// `LiquidPath::Adt` + [`benilla_formats::LiquidKind::Ocean`] (river/lake 0.18, WMO pools 0.12,
/// fullbright 0.0). The vertex swell arm only ever runs on that one combination
/// (`liquid.wgsl:280`), so this is the only energy a bob can ever be driven at — the parameter
/// stays open because the function is the shader's, not the bob's.
pub const OCEAN_WAVE_ENERGY: f32 = 1.0;
/// The height AND the surface gradient at one point — one evaluation answers both, because the
/// shader's own `water_waves` returns `vec3(height, dHeight/dpx, dHeight/dpz)` from a single
/// loop and the bob wants the pair (a float to lift by, a slope to lean into).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Swell {
/// Vertical displacement in yards, the shader's `result.x` — added to the mesh's world Y.
pub height: f32,
/// `(∂height/∂x, ∂height/∂z)` in BEVY world axes, the shader's `result.yz`. The shader builds
/// its normal from exactly this as `normalize(vec3(-grad.x, 1, -grad.y))` (`liquid.wgsl:457`).
pub grad: Vec2,
}
/// `liquid.wgsl:377-380`'s `swell_shore_fade`: the authored ocean depth `V` (byte/255, ≈148 yd at
/// 1.0) faded in over ~0.15..3.7 yd, so the swell dies at a beach and the waterline stays pinned.
///
/// Kept here for completeness of the mirror. **The CPU has no per-position `V`** — the depth bytes
/// are consumed by the mesh builder and never reach [`super::query::WaterChunkInfo`], which stores
/// positions and wet flags only — so the bob passes its own shore term instead (the swim ramp,
/// which is already zero everywhere a body can stand up). See `water_fx::bob`.
pub fn swell_shore_fade(depth_v: f32) -> f32 {
smoothstep(0.001, 0.025, depth_v)
}
/// The vertex stage's swell at one world point. A faithful transcription of `water_waves(p, t, 0,
/// 0, shore, true)` (`liquid.wgsl:382-408`) under the vertex arm's own arguments:
///
/// * `distance = 0` and `footprint = 0` — the vertex call passes both (`liquid.wgsl:281`), so the
/// Nyquist fade is `1 - smoothstep(0.10, 0.45, 0) = 1` and the 35-yd ripple cull never applies.
/// * `inland = false` — the arm requires the ocean swatch on the ADT renderer, which is exactly
/// the negation of the shader's `inland` (`liquid.wgsl:386`), so there is no drift term and no
/// `i < 3` skip.
/// * `long_only = true` — components 0 and 1, which are also the two the shore fade multiplies.
///
/// `world_xz` is BEVY world XZ. `shallow_fade` is the shore term (1 = open sea, 0 = beach).
pub fn swell(world_xz: Vec2, time: f32, wave_energy: f32, shallow_fade: f32) -> Swell {
let energy = wave_energy.clamp(0.0, 1.0);
// `mix(0.4, 1.0, sqrt(energy))` — a calm inland sheet also swells SLOWER, not merely lower.
let tempo = 0.4 + 0.6 * energy.sqrt();
let shore = shallow_fade.clamp(0.0, 1.0);
let mut out = Swell::default();
for w in LONG_SWELL {
let dir = Vec2::new(w[0].cos(), w[0].sin());
let k = TWO_PI / w[1];
let speed = (GRAVITY_YD / k).sqrt(); // phase speed, yd/s
let phase = k * (dir.dot(world_xz) - speed * tempo * time) + w[3];
// `fade` is 1 at zero footprint; `i < 2` ⇒ both components take the shore term.
let amplitude = w[2] * energy * shore;
out.height += amplitude * phase.sin();
out.grad += dir * (amplitude * k * phase.cos());
}
out
}
/// [`swell`]'s height alone — the shape the brief names, for callers that do not want the slope.
pub fn swell_height(world_xz: Vec2, time: f32, wave_energy: f32, shallow_fade: f32) -> f32 {
swell(world_xz, time, wave_energy, shallow_fade).height
}
/// **The clock the material runs on**, so CPU and GPU agree on which crest is where.
///
/// The shader reads `anim_time()` (`liquid.wgsl:217-220`): `w.anim.w * globals.time`, or the
/// frozen capture pin `w.anim.x` when the clock enable is 0 on a non-fullbright Enhanced
/// material. Both halves of that are reproduced here:
///
/// * `globals.time` is **`Time::elapsed_secs_wrapped`**, not `elapsed_secs` — bevy_render's
/// `prepare_globals_buffer` writes exactly that field (bevy_render 0.18.1 `globals.rs:72`), and
/// the two diverge by whole hours once the wrapping period (3600 s) rolls. Passing the unwrapped
/// clock would put the CPU an entire wrap out of phase with the mesh it is riding, silently,
/// after an hour of play — the kind of drift that only reproduces in a long session.
/// * the capture pin is `WOW_CAPTURE_WATER_T` under `WOW_CAPTURE`, read the same way
/// `liquid/surface.rs:521-525` reads it into `anim.x`; `anim.w` is 0 on precisely the same
/// condition ([`crate::dev_state::deterministic_run`]), so the two branches line up one to one.
pub fn water_anim_time(elapsed_secs_wrapped: f32) -> f32 {
if crate::dev_state::deterministic_run() {
capture_water_time()
} else {
elapsed_secs_wrapped
}
}
/// The frozen-capture water phase (`WOW_CAPTURE_WATER_T`, default 0) — the same parse
/// `liquid/surface.rs` bakes into the material, read once and cached: a deterministic run must not
/// pay an environment lookup per unit per frame, and the material's copy is read once at setup, so
/// re-reading a changed variable mid-run could only make the two disagree.
fn capture_water_time() -> f32 {
static T: std::sync::OnceLock<f32> = std::sync::OnceLock::new();
*T.get_or_init(|| {
std::env::var("WOW_CAPTURE_WATER_T")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.filter(|v| v.is_finite() && *v >= 0.0)
.unwrap_or(0.0)
})
}
/// WGSL's `smoothstep`, which Rust has no equivalent of.
fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
#[cfg(test)]
mod tests {
use super::*;
/// **The mirror pin.** The table against the literal numbers in `liquid.wgsl:365-375`, and the
/// two derived scalars against the lines that spell them. This test exists so that a change on
/// EITHER side is a build failure rather than a swimmer floating half a yard off the sea: the
/// shader is another agent's file, and nothing but this assertion couples the two.
#[test]
fn the_table_mirrors_the_shader() {
assert_eq!(
LONG_SWELL,
[[0.35, 18.0, 0.200, 0.0], [0.80, 12.8, 0.140, 1.7]],
"liquid.wgsl WATER_WAVES[0..2]"
);
assert_eq!(GRAVITY_YD, 10.72, "liquid.wgsl:398 gravity");
assert_eq!(TWO_PI, 6.2831853, "liquid.wgsl:397 k = 6.2831853/wavelength");
assert_eq!(OCEAN_WAVE_ENERGY, 1.0, "surface.rs path.z for ADT ocean");
// The band's ceiling, stated in the shader's own comment ("at most 0.34 yd").
let peak: f32 = LONG_SWELL.iter().map(|w| w[2]).sum();
assert!((peak - 0.34).abs() < 1e-6, "amplitude sum {peak}");
}
/// No energy ⇒ no swell, height and slope both, at any point and any time. The inland/WMO
/// lanes and the Classic path all reach this function at energy 0 (or never reach it), so a
/// nonzero answer here is a body bobbing on a mill pond.
#[test]
fn zero_energy_is_a_flat_sea() {
for t in [0.0_f32, 1.0, 37.5, 1234.5] {
let s = swell(Vec2::new(-9016.0, 226.0), t, 0.0, 1.0);
assert_eq!(s.height, 0.0, "height at t={t}");
assert_eq!(s.grad, Vec2::ZERO, "grad at t={t}");
}
// The shore term is the other kill switch: a beached vertex does not move either.
let beached = swell(Vec2::new(-9016.0, 226.0), 3.0, OCEAN_WAVE_ENERGY, 0.0);
assert_eq!(beached.height, 0.0);
assert_eq!(beached.grad, Vec2::ZERO);
}
/// The swell stays inside the band the shader advertises, and the gradient it reports really
/// is the height's derivative (a finite difference against the analytic pair). The second half
/// is what the tilt leans on: a sign error there rolls every floating body the wrong way.
#[test]
fn height_is_bounded_and_the_gradient_is_its_derivative() {
// The bound holds at real world magnitude (the Westfall sea is around −9000, 200).
for step in 0..40 {
let t = step as f32 * 0.37;
let s = swell(Vec2::new(-9016.0, 226.0), t, OCEAN_WAVE_ENERGY, 1.0);
assert!(s.height.abs() <= 0.341, "height {} at t={t}", s.height);
}
// The derivative is checked NEAR THE ORIGIN on purpose: at ±9000 yd the phase is ~3000
// rad and an f32 carries ~5e-4 rad of it, so a central difference there measures the
// cancellation, not the slope. The identity being checked is position-independent.
let p = Vec2::new(-16.0, 26.0);
let h = 1e-2;
for step in 0..40 {
let t = step as f32 * 0.37;
let s = swell(p, t, OCEAN_WAVE_ENERGY, 1.0);
let dx = (swell_height(p + Vec2::X * h, t, 1.0, 1.0)
- swell_height(p - Vec2::X * h, t, 1.0, 1.0))
/ (2.0 * h);
let dz = (swell_height(p + Vec2::Y * h, t, 1.0, 1.0)
- swell_height(p - Vec2::Y * h, t, 1.0, 1.0))
/ (2.0 * h);
assert!((dx - s.grad.x).abs() < 2e-3, "d/dx {dx} vs {}", s.grad.x);
assert!((dz - s.grad.y).abs() < 2e-3, "d/dz {dz} vs {}", s.grad.y);
}
}
/// The shore fade's two ends, as the shader's `smoothstep(0.001, 0.025, depth)` places them.
#[test]
fn shore_fade_matches_the_shader_band() {
assert_eq!(swell_shore_fade(0.0), 0.0);
assert_eq!(swell_shore_fade(1.0), 1.0, "open sea (V = 1) is unfaded");
assert!((swell_shore_fade(0.013) - 0.5).abs() < 0.02, "band midpoint");
}
/// The clock: a live run is the wrapped elapsed seconds verbatim (the GPU's `globals.time`),
/// so CPU and GPU sample the same phase. The capture branch is env-driven and is asserted by
/// construction rather than here — flipping `WOW_CAPTURE` inside a test process would leak
/// into every other test in the binary.
#[test]
fn the_live_clock_is_the_wrapped_elapsed_seconds() {
if !crate::dev_state::deterministic_run() {
assert_eq!(water_anim_time(123.5), 123.5);
assert_eq!(water_anim_time(0.0), 0.0);
}
}
}
+454
View File
@@ -0,0 +1,454 @@
//! MONKEY (swim waves) — **bodies ride the swell**: the visual-only vertical bob (and the small
//! lean into the wave slope) every swimming or floating unit takes on Enhanced water.
//!
//! The defect this closes is a mismatch, not a missing effect. Enhanced water heaves the ocean
//! MESH in the vertex stage ([`crate::liquid::waves`] mirrors the exact band), while every body in
//! it is placed at the height the CPU queries — the flat MCLQ heightfield, which knows nothing
//! about the swell. So the sea visibly rises and falls and the swimmer in it holds still, cutting
//! through crests. Here the two are put back on one surface.
//!
//! **Visual only, and structurally so.** The bob is written onto the unit's `Transform` in
//! `PostUpdate` *immediately before* [`TransformSystems::Propagate`] and taken off again
//! *immediately after* it — [`apply_swim_bob`] / [`clear_swim_bob`], with the pre-bob values
//! stored verbatim so the restore is exact rather than an inverse-multiply that would drift a
//! quaternion over a long float. The offset therefore exists only inside that one `PostUpdate`
//! window — nothing in `Update`, and nothing from the clear onward, can see it.
//!
//! Within the window it is visible to any *unordered* `PostUpdate` system that reads a unit's
//! `Transform` (the shadow, nameplate and billboard placements are the population), and that is
//! deliberate rather than merely tolerated: those consumers are the body's own presentation, so a
//! nameplate that rides the bob is the right answer and one that does not is the wrong one. What
//! matters is the set that must NEVER see it, and none of it is here:
//!
//! * movement, collision and the capsule run in `Update` off `player::Player::pos` and the wire —
//! none of them read a `Transform` inside this window, and all of them overwrite it next frame;
//! * the network position is `bevy_to_wow(player.pos)`, never the body transform;
//! * the camera seat rides the pivot channel off `Player`, so the eye is NOT dragged along — the
//! body heaves under a steady camera, which is the readable way round. (The brief allows the
//! camera to follow at 50 %; that lives in `player/camera.rs` and is deliberately left out of
//! this diff — nothing here can seasick-lag a view it never touches.)
//!
//! **Gates**, all four of which must hold or the offset is exactly zero:
//! 1. [`benilla_assets::WaterQuality`] ≥ 1 — Classic renders a flat sea, so a bobbing body there
//! would be floating over nothing. The Classic path is byte-identical with this module armed.
//! 2. The surface under the body actually carries the vertex swell
//! ([`WaterChunkInfo::has_vertex_swell`]: ADT + ocean). Rivers, WMO pools and lava are flat.
//! 3. The body is deep enough to be off the floor — the swim ramp
//! ([`super::params::swim_ramp`], the byte-verified `0.75 · collisionHeight` line, softened
//! into a band so a wader standing up does not pop).
//! 4. It is a body that displaces water at all ([`WorldUnit::wades`]) — which is precisely the
//! predicate that already excludes corpses, loot and ground anchors, so "corpses and floating
//! loot: skip" needs no second rule.
use bevy::ecs::entity::EntityHashMap;
use bevy::math::Vec2;
use bevy::prelude::*;
use bevy::transform::TransformSystems;
use benilla_assets::coords::bevy_to_wow;
use crate::liquid::{waves, WaterChunkInfo, WaterIndex};
use crate::world_unit::WorldUnit;
use super::params::swim_ramp;
/// How much of the swell a body takes — the brief's 0.85. Not 1.0 on purpose: a body sitting at
/// exactly the surface height reads as *welded* to the mesh, and the slight under-follow is what
/// makes it read as floating IN water rather than as part of it. (The low-pass below shaves
/// another ~10 % off the ~3 s swell period on top, and lags it ~0.23 s — that phase offset is a
/// feature at these amplitudes: the body answers the wave, it does not anticipate it.)
const BOB_GAIN: f32 = 0.85;
/// The low-pass time constant (s) — the brief's ~0.25 s. Its job is not the swell (which is
/// already smooth, 2.7–3.3 s per component) but everything that can step the INPUT: a unit
/// teleporting, a streamed body's extrapolation snapping back, the swim ramp latching, a surface
/// streaming in under a swimmer. Any of those is a one-frame jump of up to the full 0.34 yd band,
/// and unfiltered that is a visible twitch on every other body in the bay.
const BOB_TAU: f32 = 0.25;
/// The slope lean's ceiling — the brief's ≤ 4°, held as the tangent of the angle so the clamp is
/// one `min` on the gradient's length instead of a trigonometric round trip per body per frame.
/// `tan(4°)` = 0.06993.
const MAX_TILT_TAN: f32 = 0.069_927;
/// The lean is for a body **floating at rest**: above this planar speed (yd/s) it fades out. A
/// stroking swimmer is driven by its own swim pitch (`creature_anim::swim_body_rotation`) and a
/// second attitude term fighting it reads as a wobble, not as buoyancy.
const REST_SPEED: (f32, f32) = (0.8, 2.5);
/// One body's filtered ride. Kept per unit rather than recomputed, because the whole point of the
/// filter is that this frame's answer depends on the last one's.
#[derive(Default)]
struct BobState {
/// The low-passed vertical offset actually applied (yd).
lift: f32,
/// The low-passed surface gradient the lean is built from, already weighted by the rest term.
grad: Vec2,
/// Last frame's position, for the planar-speed rest test (the same velocity proxy the foam
/// emitter uses for streamed units — a body's own velocity is not published to this crate).
last_pos: Option<Vec3>,
/// Fed this frame; unfed entries are dropped (the unit despawned).
active: bool,
}
/// Every body's filtered ride, plus the exact pre-bob transforms this frame's apply pass must put
/// back after propagation.
#[derive(Resource, Default)]
struct SwimBob {
units: EntityHashMap<BobState>,
/// `(entity, translation.y, rotation)` as they were before the offset. A stored ORIGINAL, not
/// a delta to subtract: restoring by arithmetic would leave a float residue on a body that is
/// never rewritten (an idle streamed unit at rest), and that residue accumulates.
applied: Vec<(Entity, f32, Quat)>,
}
/// The visual lift + lean for one body, given its filtered state. Split out so the filter and the
/// clamp are testable without an ECS world (see [`tests`]).
fn ride(state: &BobState) -> (f32, Quat) {
let g = state.grad;
// Clamp the SLOPE, not the resulting quaternion: the wave normal is `(-∂x, 1, -∂z)`
// (`liquid.wgsl:457`), so a gradient of length `tan(θ)` is a lean of exactly θ.
let len = g.length();
let g = if len > MAX_TILT_TAN && len > 0.0 {
g * (MAX_TILT_TAN / len)
} else {
g
};
let normal = Vec3::new(-g.x, 1.0, -g.y).normalize_or(Vec3::Y);
(state.lift, Quat::from_rotation_arc(Vec3::Y, normal))
}
/// Exponential low-pass toward `target` over [`BOB_TAU`], frame-rate independent.
fn low_pass(current: f32, target: f32, dt: f32) -> f32 {
let a = 1.0 - (-dt / BOB_TAU).exp();
current + (target - current) * a
}
/// Write this frame's bob onto every eligible body, remembering what it overwrote.
///
/// Ordered before [`TransformSystems::Propagate`] so the rendered pose carries it, and paired with
/// [`clear_swim_bob`] after it so nothing else ever does. The water lookup is the foam emitter's
/// own shape — one [`WaterIndex`] hash per body, a dry body costing one miss — not a walk of the
/// ~2 k loaded surfaces per unit.
fn apply_swim_bob(
time: Res<Time>,
quality: Res<benilla_assets::WaterQuality>,
mut bob: ResMut<SwimBob>,
index: Res<WaterIndex>,
water: Query<&WaterChunkInfo>,
mut units: Query<(Entity, &mut Transform, &WorldUnit)>,
) {
bob.applied.clear();
for s in bob.units.values_mut() {
s.active = false;
}
// Gate 1: Classic water renders a flat sea. Everything below is skipped wholesale, so the
// Classic frame is the frame it was before this module existed.
let enhanced = quality.0 >= 1;
let dt = time.delta_secs().max(1.0e-4);
let t = waves::water_anim_time(time.elapsed_secs_wrapped());
for (entity, mut transform, unit) in &mut units {
if !unit.wades {
continue; // gate 4 — and the whole of "corpses and floating loot: skip"
}
let pos = transform.translation;
let state = bob.units.entry(entity).or_default();
state.active = true;
let prev = state.last_pos.replace(pos);
// The planar speed proxy, in Bevy's XZ (the horizontal plane) — only the rest test uses it.
let speed = prev.map_or(0.0, |p| (pos.xz() - p.xz()).length() / dt);
// Gates 2 + 3: a swelling surface over this body, and deep enough to be off the floor.
let wow = bevy_to_wow(pos);
let ramp = enhanced
.then(|| {
// First SWELLING surface over this XY — `has_vertex_swell` before `surface_z_at`,
// so a flat river lying over an ocean chunk cannot claim the body and then report
// a height nothing is heaving at.
let surface = index.over(wow[0], wow[1]).iter().find_map(|&e| {
let info = water.get(e).ok()?;
info.has_vertex_swell()
.then(|| info.surface_z_at(wow[0], wow[1]))
.flatten()
})?;
let ramp = swim_ramp(surface - wow[2], unit.height);
(ramp > 0.0).then_some(ramp)
})
.flatten();
let (lift_target, grad_target) = match ramp {
Some(ramp) => {
// The shore term is the swim ramp itself, not `waves::swell_shore_fade` — the
// authored depth `V` the shader fades by never reaches the CPU (see that
// function's doc). It is the better proxy anyway for this consumer: the band
// where the sea stops heaving is the band where a body can stand up, and the
// ramp is already zero there, so a swimmer never fades out mid-stroke.
let s = waves::swell(pos.xz(), t, waves::OCEAN_WAVE_ENERGY, ramp);
// The lean is for a body at rest; a stroking swimmer already has a swim pitch.
let rest = 1.0
- ((speed - REST_SPEED.0) / (REST_SPEED.1 - REST_SPEED.0)).clamp(0.0, 1.0);
(s.height * BOB_GAIN * ramp, s.grad * rest * ramp)
}
// Out of the water (or Classic, or over a flat liquid): the target is FLAT, and the
// filter walks down to it over ~τ rather than snapping — a swimmer wading out or
// jumping clear settles instead of dropping a quarter yard in one frame.
None => (0.0, Vec2::ZERO),
};
state.lift = low_pass(state.lift, lift_target, dt);
state.grad = Vec2::new(
low_pass(state.grad.x, grad_target.x, dt),
low_pass(state.grad.y, grad_target.y, dt),
);
let (lift, tilt) = ride(state);
// Below a tenth of a millimetre there is nothing to see and nothing to restore; skipping
// keeps every dry body's `Transform` genuinely untouched (and unchanged, so bevy's
// propagation has no reason to revisit it).
if lift.abs() < 1.0e-4 && state.grad.length_squared() < 1.0e-8 {
continue;
}
bob.applied
.push((entity, transform.translation.y, transform.rotation));
transform.translation.y += lift;
// Pre-multiply: the lean is a WORLD-space tilt toward the wave normal, so it must not be
// re-framed by the body's own yaw (post-multiplying would roll a north-facing swimmer
// where it should pitch one facing east).
transform.rotation = tilt * transform.rotation;
}
bob.units.retain(|_, s| s.active);
}
/// Put every bobbed `Transform` back exactly as it was, after propagation has read it.
///
/// This is what makes the offset visual: from here to the end of the frame, and through all of the
/// next one's `Update`, the ECS holds the unbobbed pose that movement, collision and the wire were
/// always written against.
fn clear_swim_bob(mut bob: ResMut<SwimBob>, mut units: Query<&mut Transform>) {
// `take` so a frame in which `apply` did not run (a run condition, a disabled schedule) cannot
// replay last frame's restore onto this frame's pose.
for (entity, y, rot) in std::mem::take(&mut bob.applied) {
if let Ok(mut t) = units.get_mut(entity) {
t.translation.y = y;
t.rotation = rot;
}
}
}
/// Register the bob pass — called from [`super::WaterFxPlugin`].
pub(super) fn register(app: &mut App) {
app.init_resource::<SwimBob>().add_systems(
PostUpdate,
(
apply_swim_bob.before(TransformSystems::Propagate),
clear_swim_bob.after(TransformSystems::Propagate),
),
);
}
#[cfg(test)]
mod tests {
use super::*;
/// The filter: a STEP input never appears as a step on the output, it approaches smoothly and
/// stays bounded by the step. This is the property the whole `BOB_TAU` term exists for — the
/// swell itself is smooth; what is not is a teleport, a snap-back or a ramp latch.
#[test]
fn the_filter_smooths_a_step_and_stays_bounded() {
let (dt, step) = (1.0 / 60.0, 0.34_f32);
let mut x = 0.0_f32;
let mut prev = 0.0_f32;
for frame in 0..240 {
x = low_pass(x, step, dt);
assert!((0.0..=step).contains(&x), "bounded at frame {frame}: {x}");
assert!(
x - prev < step * 0.1,
"no step at frame {frame}: {} yd in one frame",
x - prev
);
prev = x;
}
assert!((x - step).abs() < 1e-3, "converged: {x}");
// One τ reaches ~63 % of the step — the pinned time constant, not merely "some smoothing".
// (`round`, not `as i32`: 0.25/(1/60) is 14.999… in f32 and truncates to 14 frames.)
let mut y = 0.0_f32;
for _ in 0..(BOB_TAU / dt).round() as i32 {
y = low_pass(y, step, dt);
}
assert!((y / step - 0.632).abs() < 0.02, "one τ: {}", y / step);
}
/// The lean's ceiling holds however steep the sampled slope gets, and a flat sea leans not at
/// all. 4° is small enough to read as buoyancy; the clamp is what keeps a degenerate gradient
/// (a snapped surface, a filter transient) from cartwheeling a body.
#[test]
fn the_lean_is_capped_at_four_degrees() {
let flat = ride(&BobState::default());
assert_eq!(flat.0, 0.0);
assert!(flat.1.angle_between(Quat::IDENTITY) < 1e-6, "flat ⇒ level");
for g in [0.01_f32, 0.07, 0.5, 5.0, 100.0] {
let state = BobState {
grad: Vec2::new(g, -g),
..default()
};
let angle = ride(&state).1.angle_between(Quat::IDENTITY);
assert!(
angle <= 4.0_f32.to_radians() + 1e-4,
"grad {g} leaned {}°",
angle.to_degrees()
);
}
}
/// **The Classic gate, end to end.** With [`benilla_assets::WaterQuality`] at 0 a body sitting
/// well inside an ocean grid is never touched at all: the pose the propagation window sees is
/// the pose it was given, bit for bit, and no filter state accumulates behind it.
#[test]
fn classic_water_never_moves_a_body() {
let (mut app, body, placed) = swimming_world(0);
for _ in 0..90 {
app.update();
assert_eq!(
seen(&app),
placed.translation.y,
"Classic ⇒ the rendered pose is the placed pose"
);
assert_eq!(*app.world().get::<Transform>(body).expect("body"), placed);
}
assert_eq!(app.world().resource::<SwimBob>().units[&body].lift, 0.0);
}
/// Enhanced, the same world: the body is lifted inside the propagation window and handed back
/// its original pose afterwards. Both halves matter — the first is the feature, the second is
/// the promise that movement, collision and the wire never see it.
#[test]
fn enhanced_water_bobs_the_body_and_hands_the_pose_back() {
let (mut app, body, placed) = swimming_world(1);
// Several frames: the low-pass starts at zero, so one frame proves nothing about the ride.
let mut lifted = false;
for _ in 0..200 {
app.update();
lifted |= (seen(&app) - placed.translation.y).abs() > 1.0e-4;
assert_eq!(
*app.world().get::<Transform>(body).expect("body"),
placed,
"the ECS pose is restored exactly, every frame"
);
}
assert!(lifted, "an Enhanced swimmer takes a visual offset");
}
/// A body on DRY land inside the same world takes nothing — the depth gate, not merely the
/// quality one, and the reason a bob can never lift someone walking along a beach.
#[test]
fn a_dry_body_is_never_bobbed() {
let (mut app, swimmer, _) = swimming_world(1);
let dry = app
.world_mut()
.spawn((
Transform::from_translation(benilla_assets::coords::wow_to_bevy([
500.0, 500.0, 40.0,
])),
WorldUnit {
wades: true,
scale: 1.0,
height: 2.0,
bound: None,
},
))
.id();
for _ in 0..60 {
app.update();
}
let bob = app.world().resource::<SwimBob>();
assert_eq!(bob.units[&dry].lift, 0.0, "dry land ⇒ no lift");
assert_eq!(bob.units[&dry].grad, Vec2::ZERO, "dry land ⇒ no lean");
assert!(bob.units[&swimmer].lift != 0.0, "the control IS riding");
}
/// What the propagation window saw this frame — the probe system's capture (see
/// [`swimming_world`]), i.e. the body's rendered height.
fn seen(app: &App) -> f32 {
app.world().resource::<SeenY>().0
}
/// The probe's capture slot: the bobbed `translation.y`, read between apply and clear.
#[derive(Resource, Default)]
struct SeenY(f32);
/// An app with one all-wet ADT OCEAN grid and one body swimming in it, at the given water
/// quality.
///
/// The three schedule slots stand in for the real bracket: `Update` places and applies,
/// `PostUpdate` is where `TransformSystems::Propagate` would read the pose (the probe reads it
/// instead), `Last` clears. Placing the body every frame is not test scaffolding — it is what
/// a real frame does, and it is what proves the clear is not merely hiding behind a body that
/// nobody rewrites.
fn swimming_world(quality: u8) -> (App, Entity, Transform) {
// Feet at the verified swim rest line (`surface − 0.75·h`) — fully swimming.
let placed = Transform::from_translation(benilla_assets::coords::wow_to_bevy([
50.0,
50.0,
50.0 - 1.52,
]));
let mut app = App::new();
app.init_resource::<Time>()
.init_resource::<WaterIndex>()
.init_resource::<SwimBob>()
.init_resource::<SeenY>()
.insert_resource(benilla_assets::WaterQuality(quality))
.add_systems(
Update,
(
crate::liquid::maintain_water_index,
move |mut q: Query<&mut Transform, With<WorldUnit>>| {
for mut t in &mut q {
if t.translation.x == placed.translation.x {
*t = placed;
}
}
},
apply_swim_bob,
)
.chain(),
)
.add_systems(
PostUpdate,
|mut seen: ResMut<SeenY>, q: Query<(&Transform, &WorldUnit)>| {
for (t, _) in &q {
if (t.translation.x - -50.0).abs() < 1.0 {
seen.0 = t.translation.y;
}
}
},
)
.add_systems(Last, clear_swim_bob);
// A 3×3-vertex all-wet ocean grid over [0,100]², surface z = 50.
let mut positions = Vec::new();
for j in 0..3 {
for i in 0..3 {
positions.push([i as f32 * 50.0, j as f32 * 50.0, 50.0]);
}
}
app.world_mut().spawn(WaterChunkInfo::new(
crate::liquid::LiquidSource::AdtChunk,
benilla_formats::LiquidKind::Ocean,
[3, 3],
positions,
vec![true; 4],
));
let body = app
.world_mut()
.spawn((
placed,
WorldUnit {
wades: true,
scale: 1.0,
height: 2.031,
bound: None,
},
))
.id();
(app, body, placed)
}
}
+187 -35
View File
@@ -57,6 +57,7 @@
//! avatar + streamed units, the record pool, and one additive effect-stream draw per
//! (chunk, category) with live records (fog OFF — the reference's verified foam render state).
mod bob;
mod params;
use bevy::asset::RenderAssetUsages;
@@ -78,6 +79,7 @@ use crate::world_unit::{ViewerUnit, WorldUnit};
use benilla_assets::{AssetSet, WorldAssets};
use params::{foam_params, foam_uv, rand01, record_alpha, record_size, WadeState};
use params::{foam_gain, swim_ramp, tread_params, FoamLook, TREAD_INTERVAL};
use params::{wake_cooldown, RING_INTERVAL};
/// The two foam stencils (render categories: 0 = ring, 1 = wake). Loaded RAW — the alpha carries
@@ -149,6 +151,11 @@ struct UnitFoam {
ready: f32,
/// Step-in latch: currently deeper than the one-shot threshold.
wading: bool,
/// MONKEY (swim waves) — the treading ring's OWN deadline, deliberately not the shared cell
/// above. The reference's one cooldown is a faithful detail (a ring delays a wake and vice
/// versa); a record the reference does not have must not reach into it, or the Enhanced ring
/// would start eating the wakes the same pass exists to make more visible.
tread_ready: f32,
rng: u32,
/// Fed this frame (drives retiring state for despawned units).
active: bool,
@@ -161,6 +168,7 @@ impl UnitFoam {
last_yaw: None,
ready: 0.0,
wading: false,
tread_ready: 0.0,
rng: seed | 1,
active: false,
}
@@ -316,6 +324,49 @@ fn build_patch(
}
}
/// Build one record's static patch and hand it to the pool — the tail every emission shares.
///
/// Extracted whole (MONKEY (swim waves)) when the treading ring became a second emitter on the
/// same frame: two copies of the candidate walk is two places for the patch clip to drift apart,
/// and the clip is what keeps foam off dry sand.
fn emit_record(
alloc: &mut dyn FnMut(FoamRecord),
index: &WaterIndex,
water: &Query<(Entity, &WaterChunkInfo, &FoamPatch)>,
center: [f32; 2],
p: &params::FoamParams,
heading: f32,
now: f32,
) {
let final_size = p.size0 + p.growth * p.lifetime;
// Candidates for the patch box only — resolved here, on the EMISSION path (past the depth
// gate and the cooldown), never on the every-frame classify above. `over_box` unions the
// 1–4 cells a final-size box touches, so the set `build_patch` filters with `overlaps` is
// the same one the full walk offered it.
let candidates: Vec<_> = index
.over_box(
[center[0] - final_size, center[1] - final_size],
[center[0] + final_size, center[1] + final_size],
)
.into_iter()
.filter_map(|e| water.get(e).ok())
.collect();
if let Some((verts, chunk)) = build_patch(center, final_size, &candidates) {
alloc(FoamRecord {
center,
heading,
size0: p.size0,
growth: p.growth,
lifetime: p.lifetime,
peak: p.peak,
born: now,
ring: p.ring,
verts,
chunk,
});
}
}
/// Classify + gate + emit for one unit this frame (the driver `0x5fa760`, once per unit per
/// frame; emission paced by the unit's shared cooldown cell).
fn drive_unit(
@@ -329,6 +380,9 @@ fn drive_unit(
water: &Query<(Entity, &WaterChunkInfo, &FoamPatch)>,
index: &WaterIndex,
now: f32,
// MONKEY (swim waves) — `WaterQuality >= 1`. The ONE gate on the readability pass; at `false`
// every line it touches below is the reference's.
enhanced: bool,
) {
let gate = (GATE_DEPTH_FRAC * h).max(1.0);
foam_state.active = true;
@@ -356,44 +410,47 @@ fn drive_unit(
let oneshot = wading_now != foam_state.wading;
foam_state.wading = wading_now;
// MONKEY (swim waves) — how this record should READ (Enhanced only; `FoamLook::CLASSIC`
// reproduces the reference exactly). The swim half is the depth law evaluated here rather
// than a movement flag, because a streamed murloc's flags never reach this crate.
// Spelled as the constant on the Classic arm rather than as `enhanced: false`: Classic IS the
// reference look, and stating it that way is what makes "byte-identical" a thing the type
// says instead of a thing a comment claims. It also keeps the depth law off the Classic
// frame's hot path entirely — nothing reads it there.
let look = if enhanced {
FoamLook {
enhanced: true,
swimming: swim_ramp(depth, h) > 0.5,
}
} else {
FoamLook::CLASSIC
};
// The treading ring, BEFORE the shared cooldown's early return and on its own cell: a body
// hanging in deep water is the state the reference's driver renders almost invisible, and it
// is exactly the state the owner's murloc will be in. Classic touches nothing here — not the
// timer, not the RNG stream.
if look.enhanced && look.swimming && !matches!(state, WadeState::Translating { .. }) {
if now >= foam_state.tread_ready {
foam_state.tread_ready = now + TREAD_INTERVAL;
if let Some(p) = tread_params(look, scale, &mut foam_state.rng) {
let heading = rand01(&mut foam_state.rng) * std::f32::consts::TAU;
emit_record(alloc, index, water, [wow[0], wow[1]], &p, heading, now);
}
}
}
if !oneshot && now < foam_state.ready {
return;
}
let Some(p) = foam_params(state, oneshot, scale, gate, depth, &mut foam_state.rng) else {
let Some(p) = foam_params(state, oneshot, scale, gate, depth, look, &mut foam_state.rng) else {
return;
};
let heading = match (p.ring, state) {
(false, WadeState::Translating { heading, .. }) => heading,
_ => rand01(&mut foam_state.rng) * std::f32::consts::TAU,
};
let final_size = p.size0 + p.growth * p.lifetime;
let center = [wow[0], wow[1]];
// Candidates for the patch box only — resolved here, on the EMISSION path (past the depth
// gate and the cooldown), never on the every-frame classify above. `over_box` unions the
// 1–4 cells a final-size box touches, so the set `build_patch` filters with `overlaps` is
// the same one the full walk offered it.
let candidates: Vec<_> = index
.over_box(
[center[0] - final_size, center[1] - final_size],
[center[0] + final_size, center[1] + final_size],
)
.into_iter()
.filter_map(|e| water.get(e).ok())
.collect();
if let Some((verts, chunk)) = build_patch(center, final_size, &candidates) {
alloc(FoamRecord {
center,
heading,
size0: p.size0,
growth: p.growth,
lifetime: p.lifetime,
peak: p.peak,
born: now,
ring: p.ring,
verts,
chunk,
});
}
emit_record(alloc, index, water, [wow[0], wow[1]], &p, heading, now);
// The shared cooldown cell: the one-shot resets it; a ring pulses on the 400+U[0,50) ms law;
// a wake re-arms on the distance law (one decal per ~0.625 yd of travel).
let mut uni = |a: f32, b: f32| a + (b - a) * rand01(&mut foam_state.rng);
@@ -414,6 +471,7 @@ fn drive_unit(
/// selection bits) and for every streamed unit (the velocity/yaw proxy), emitting pool records.
fn emit_water_foam(
time: Res<Time>,
quality: Res<benilla_assets::WaterQuality>,
materials: Option<Res<FoamAssets>>,
mut foam: ResMut<WaterFoam>,
viewer: Res<crate::view::Viewer>,
@@ -427,6 +485,8 @@ fn emit_water_foam(
}
let now = time.elapsed_secs();
let dt = time.delta_secs().max(1.0e-4);
// MONKEY (swim waves): Enhanced water gets a readable wake; Classic keeps the reference's.
let enhanced = quality.0 >= 1;
for uf in foam.units.values_mut() {
uf.active = false;
@@ -467,7 +527,9 @@ fn emit_water_foam(
let mut alloc = |rec: FoamRecord| {
pool[alloc_slot(self_cursor, 0, SELF_SLOTS)] = Some(rec);
};
drive_unit(uf, &mut alloc, body, state, scale, h, &water, &index, now);
drive_unit(
uf, &mut alloc, body, state, scale, h, &water, &index, now, enhanced,
);
}
// Streamed units (the avatar's own wire ghost is excluded via `Without<SelfPlayer>`).
@@ -519,7 +581,7 @@ fn emit_water_foam(
pool[alloc_slot(other_cursor, SELF_SLOTS, POOL_SIZE - SELF_SLOTS)] = Some(rec);
};
drive_unit(
uf, &mut alloc, pos, state, unit.scale, h, &water, &index, now,
uf, &mut alloc, pos, state, unit.scale, h, &water, &index, now, enhanced,
);
}
}
@@ -535,6 +597,7 @@ fn emit_water_foam(
/// tie-break bias; a chunk that streamed out lets its records age out silently.
fn push_water_foam(
time: Res<Time>,
quality: Res<benilla_assets::WaterQuality>,
assets: Option<Res<FoamAssets>>,
cam: Query<Entity, With<WorldCamera>>,
mut foam: ResMut<WaterFoam>,
@@ -544,6 +607,11 @@ fn push_water_foam(
let Some(assets) = assets else { return };
let Ok(cam) = cam.single() else { return };
let now = time.elapsed_secs();
// MONKEY (swim waves) — the Enhanced readability gain, on the VERTEX COLOUR (see
// [`params::foam_gain`] for why it cannot be on the alpha). `1.0` in Classic, so that path's
// vertex stream is `[1, 1, 1, alpha]` exactly as it has always been.
let enhanced = quality.0 >= 1;
let gain = foam_gain(enhanced);
for slot in &mut foam.pool {
if slot.as_ref().is_some_and(|r| now - r.born >= r.lifetime) {
@@ -573,7 +641,7 @@ fn push_water_foam(
quads.verts.push(EffectVertex {
pos: v.to_array(),
uv: foam_uv(rec.center, rec.heading, size, [wow[0], wow[1]]),
color: [1.0, 1.0, 1.0, alpha],
color: [gain, gain, gain, alpha],
});
centroid += *v;
n += 1;
@@ -596,8 +664,20 @@ fn push_water_foam(
// `0x68fcd7`; the long-standing `0x68fcc1` citation was the blend setup — 1808).
fog: EffectFog::Off,
// The reference's foam render is its own additive path (`0x68fae0`), not the
// M2 batch state producer — no GL_LIGHTING on it.
lighting: crate::particles::buffer::EffectLighting::None,
// M2 batch state producer — no GL_LIGHTING on it. Classic keeps that verbatim.
//
// MONKEY (swim waves): Enhanced takes the SCENE term instead, and it is the gain
// above that makes it necessary. At the authored colour the foam is dark enough
// that an unlit draw is invisible at night anyway; at 3.5× it would be a bright
// white wake under a midnight sky — the environment-sheet failure mode this lane
// was built for (`EffectLighting::Scene`'s own doc: the Zul'Gurub waterfall foam
// as a full-white cutout against shaded terrain). So the two changes ship as one:
// brighter, and tinted by the world that lights everything around it.
lighting: if enhanced {
crate::particles::buffer::EffectLighting::Scene
} else {
crate::particles::buffer::EffectLighting::None
},
anchor: centroid / n as f32,
bias: FOAM_BIAS,
// A few ULPs of constant and **no slope term** (`Rung::FOAM_RASTER*`, whose docs
@@ -645,6 +725,10 @@ impl Plugin for WaterFxPlugin {
)
// The stream push: PostUpdate after the frame's clear (emission ran in Update).
.add_systems(PostUpdate, push_water_foam.after(begin_effect_frame));
// MONKEY (swim waves) — the bodies' half: bobbing on the ocean's vertex swell. It shares
// this module's water lookup shape and its depth law but nothing else (it emits no
// records and draws nothing), so it registers itself rather than being threaded in here.
bob::register(app);
}
}
@@ -659,7 +743,12 @@ mod tests {
#[test]
fn the_emitter_finds_its_water_through_the_index() {
let mut app = App::new();
app.init_resource::<Time>()
app
// CLASSIC deliberately: this fence pins the REFERENCE emitter's record count, and
// MONKEY (swim waves)'s Enhanced pass adds a second record (the treading ring) for
// exactly the body this fixture spawns. The Enhanced count has its own test below.
.insert_resource(benilla_assets::WaterQuality(0))
.init_resource::<Time>()
.init_resource::<WaterIndex>()
.init_resource::<WaterFoam>()
.init_resource::<crate::view::Viewer>()
@@ -721,6 +810,69 @@ mod tests {
);
}
/// MONKEY (swim waves) — **Enhanced adds the treading ring, and Classic does not.** Same
/// world, same body, same seed: the only difference is [`benilla_assets::WaterQuality`], and
/// the swimmer that emits one reference record at 0 emits that record plus a ring at 1.
///
/// The ring has to come out of a live emitter and not just [`params::tread_params`], because
/// what it actually has to clear is the shared-cooldown early return the reference's driver
/// takes on almost every frame — the whole reason it carries its own cadence cell.
#[test]
fn enhanced_adds_a_treading_ring_for_a_swimmer() {
let live = |quality: u8| {
let mut app = App::new();
app.insert_resource(benilla_assets::WaterQuality(quality))
.init_resource::<Time>()
.init_resource::<WaterIndex>()
.init_resource::<WaterFoam>()
.init_resource::<crate::view::Viewer>()
.insert_resource(FoamAssets {
ring: Handle::default(),
wake: Handle::default(),
})
.add_systems(
Update,
(crate::liquid::maintain_water_index, emit_water_foam).chain(),
);
let mut positions = Vec::new();
for j in 0..3 {
for i in 0..3 {
positions.push([i as f32 * 5.0, j as f32 * 5.0, 5.0]);
}
}
app.world_mut().spawn((
WaterChunkInfo::new(
crate::liquid::LiquidSource::AdtChunk,
benilla_formats::LiquidKind::Ocean,
[3, 3],
positions,
vec![true; 4],
),
FoamPatch,
));
// Feet 1.6 yd under a 2.0-yd body: past the 0.78·h band top, so it is SWIMMING, and
// standing still, so it is treading. Inside the 2·h = 4 yd emission gate either way.
app.world_mut().spawn((
Transform::from_translation(wow_to_bevy([2.0, 2.0, 3.4])),
WorldUnit {
wades: true,
scale: 1.0,
height: 2.0,
bound: None,
},
));
app.update();
app.world()
.resource::<WaterFoam>()
.pool
.iter()
.filter(|r| r.is_some())
.count()
};
assert_eq!(live(0), 1, "Classic: the reference's step-in ring, alone");
assert_eq!(live(1), 2, "Enhanced: + the treading ring");
}
/// Pool partitioning: the self cursor wraps within [0, 32), others within [32, 128) —
/// eviction replaces the oldest of the same partition, never crosses it.
#[test]
+256 -6
View File
@@ -24,6 +24,103 @@ pub(super) fn wake_cooldown(speed: f32, rng: &mut u32) -> f32 {
k * 0.625 / speed.clamp(0.1, 20.0)
}
/// MONKEY (swim waves) — **how swimming this body is**, `0` (wading, feet down) to `1` (off the
/// floor), from the feet-referenced depth and the unit's own collision height.
///
/// The latch itself is byte-verified and binary: `0x6030c0` sets `MOVEFLAG_SWIMMING` at
/// `depth > 0.75 · collisionHeight` (`player::swim::SWIM_DEPTH_FRAC`) with a 1/36-yd hysteresis.
/// Here it is deliberately a **band** rather than that edge, for two reasons. First, this crate
/// cannot see the flag at all: movement flags are the game's, and a streamed murloc's swim state
/// reaches `benilla-world` only as the depth under it — so this is the same law, evaluated where
/// the answer is needed. Second, the consumers are continuous: a bob and a foam gain that switch
/// on a hard edge pop at the exact depth a player spends the most time hovering around (the
/// shoreline), and a body crossing the line repeatedly would strobe.
///
/// The band's top is just above the latch so a genuinely swimming body is fully weighted; the
/// bottom is where the water is deep enough that a wader is mostly under it anyway.
pub(super) fn swim_ramp(depth: f32, h: f32) -> f32 {
let (lo, hi) = (0.60 * h, 0.78 * h);
let t = ((depth - lo) / (hi - lo).max(1.0e-4)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// MONKEY (swim waves) — **how loudly this record should read.** Classic is the reference and must
/// stay byte-identical, so every Enhanced multiplier in this file is reached through this one
/// struct and every one of them is inert at [`FoamLook::CLASSIC`].
///
/// The reference's foam is deliberately dim: its fragment is a full MODULATE against a stencil
/// whose RGB is near-black, and recolouring that stencil to white is what made the 20× "blown-out
/// white wedge" (see the module doc). So Enhanced does NOT touch the stencil either — the gain
/// rides the vertex colour, where 3.5 × a ~0.05 stencil is still ~0.18 and cannot blow out, and
/// the draw takes `EffectLighting::Scene` so the wake is tinted by the world's own light instead
/// of glowing at midnight.
#[derive(Clone, Copy, Default)]
pub(super) struct FoamLook {
/// [`benilla_assets::WaterQuality`] ≥ 1 — the one gate. `false` ⇒ every field below is unused.
pub(super) enhanced: bool,
/// Is this body SWIMMING (as opposed to wading)? Swimming wakes are the ones the owner is
/// looking at, so they get the length and the size; a wader's stay as they are.
pub(super) swimming: bool,
}
impl FoamLook {
/// The reference, unchanged — what every Classic frame and every test baseline takes.
pub(super) const CLASSIC: FoamLook = FoamLook {
enhanced: false,
swimming: false,
};
}
/// Enhanced readability multipliers, all inert in Classic. Sized to stay far under the reference's
/// own blow-out (the stencil's near-black RGB gives ~19× of headroom before white).
const ENHANCED_ALPHA_GAIN: f32 = 3.5;
/// A swimming body's wake lives ~1.4× longer and starts ~1.3× larger — a longer, wider V behind a
/// swimmer, where the reference's wade-tuned record dissolves almost before it is seen at swim
/// speed (a stroking swimmer moves at 4.7 yd/s, so the 0.625-yd distance law is re-arming every
/// ~130 ms and each record had 0.65 s to live).
const SWIM_LIFETIME_GAIN: f32 = 1.4;
const SWIM_SIZE_GAIN: f32 = 1.3;
/// The step-in/out splash gets a visibly bigger ring in Enhanced — it is the one record the eye is
/// guaranteed to be looking at, because the player just caused it.
const SPLASH_SIZE_GAIN: f32 = 1.8;
const SPLASH_GROWTH_GAIN: f32 = 1.3;
/// MONKEY (swim waves) — the **treading ring**: a soft, wide, slow ring a unit hanging in deep
/// water pushes out every [`TREAD_INTERVAL`] seconds, on its own cadence cell, in Enhanced only.
///
/// It is not the reference's standing ring with different numbers; it is a second, additive record
/// that exists because a swimmer treading water is the one state the reference's driver makes
/// almost invisible (the standing branch multiplies size by 0.6, growth by 0.25 and alpha by 0.8,
/// and then the depth attenuation halves all three again past half the gate — which surface
/// swimming always is). The ring below starts ~2.2× a standing one, grows slowly, and fades over
/// a long life: a slow expanding circle, not a pulse.
pub(super) const TREAD_INTERVAL: f32 = 0.9;
const TREAD_SIZE: f32 = 2.2;
const TREAD_GROWTH: f32 = 0.55;
const TREAD_LIFETIME: f32 = 1.6;
const TREAD_ALPHA: f32 = 0.5;
/// The treading ring's params for a body of this `scale`, or `None` when the look is Classic or
/// the body is not swimming. Independent of [`foam_params`] and of the shared cooldown cell — it
/// has its own timer, so it neither delays nor is delayed by the reference's cadence.
pub(super) fn tread_params(look: FoamLook, scale: f32, rng: &mut u32) -> Option<FoamParams> {
if !look.enhanced || !look.swimming {
return None;
}
let mut uni = |a: f32, b: f32| a + (b - a) * rand01(rng);
let size0 = (scale * (1.0 / 3.0) * uni(0.9, 1.1)).clamp(1.0 / 3.0, 5.0 / 3.0) * TREAD_SIZE;
Some(FoamParams {
size0,
growth: TREAD_GROWTH * uni(0.9, 1.1),
lifetime: TREAD_LIFETIME * uni(0.9, 1.1),
// Soft on its OWN terms: the Enhanced gain rides the vertex colour for every record
// alike ([`FoamLook`]), so folding it in here too would double-count it and make the
// "soft" ring the loudest thing in the bay.
peak: TREAD_ALPHA,
ring: true,
})
}
/// What a unit is doing in the water this frame — the reference's two selection bits
/// (`MOVEMENTFLAGS & 0xf` / `& 0x30`), resolved.
#[derive(Clone, Copy)]
@@ -51,12 +148,16 @@ pub(super) struct FoamParams {
/// — decision 0489); `depth` = surface − feet (yd, > 0 in water). `None` when the depth gate
/// rejects: not in water, or dived deeper than ~2 body heights — surface swimming (rest depth
/// ~0.75·h) stays well inside and emits.
/// `look` is MONKEY (swim waves)'s Enhanced readability pass — [`FoamLook::CLASSIC`] reproduces
/// the byte-verified reference exactly, and is what every Classic frame and every baseline test
/// passes.
pub(super) fn foam_params(
state: WadeState,
oneshot: bool,
scale: f32,
gate: f32,
depth: f32,
look: FoamLook,
rng: &mut u32,
) -> Option<FoamParams> {
if depth <= 0.0 || depth >= gate {
@@ -90,6 +191,18 @@ pub(super) fn foam_params(
lifetime *= k;
size0 *= k;
}
// MONKEY (swim waves) — the Enhanced readability pass, LAST so it scales the reference's own
// answer (including that depth attenuation, which surface swimming always takes: a swimmer
// sits past half the gate by construction, so without this a swim wake is the dimmest,
// shortest-lived record the driver can emit). Inert at `FoamLook::CLASSIC`.
if look.enhanced && look.swimming {
lifetime *= SWIM_LIFETIME_GAIN;
size0 *= SWIM_SIZE_GAIN;
}
if look.enhanced && oneshot {
size0 *= SPLASH_SIZE_GAIN;
growth *= SPLASH_GROWTH_GAIN;
}
Some(FoamParams {
size0,
growth,
@@ -99,6 +212,20 @@ pub(super) fn foam_params(
})
}
/// MONKEY (swim waves) — the **vertex-colour gain** an Enhanced foam draw carries, `1.0` in
/// Classic. The one brightness knob, and it is on the vertex colour rather than on `peak` for a
/// structural reason: a wake's `peak` is already `min(6 × 1/6, 1)` = **1.0**, so there is no
/// headroom left in the alpha at all — the reference's dimness lives entirely in the stencil's
/// near-black RGB, which the fragment MODULATEs against this. 3.5 × ~0.05 is ~0.18: brighter by a
/// factor the eye reads immediately, and still an order of magnitude short of the white wedge.
pub(super) fn foam_gain(enhanced: bool) -> f32 {
if enhanced {
ENHANCED_ALPHA_GAIN
} else {
1.0
}
}
/// A live record's size at `now`: `size0 + growth · age`. (The reference render integrates one
/// `dt·growth` step *before* a record's first draw — visible at a capture's ~20 fps, negligible
/// at ours; we draw `size0` on the first frame.)
@@ -184,14 +311,14 @@ mod tests {
speed: 7.0,
heading: 0.0,
};
let p = foam_params(translating, false, 1.0, 1.0, 0.3, &mut rng).unwrap();
let p = foam_params(translating, false, 1.0, 1.0, 0.3, FoamLook::CLASSIC, &mut rng).unwrap();
assert!(!p.ring);
assert!((0.3..=0.37).contains(&p.size0), "wake size0 {}", p.size0);
assert!((2.8..=4.2).contains(&p.growth), "wake growth {}", p.growth);
assert!((0.6..0.7).contains(&p.lifetime));
assert!((p.peak - 1.0).abs() < 1e-6);
let p = foam_params(WadeState::Standing, false, 1.0, 1.0, 0.3, &mut rng).unwrap();
let p = foam_params(WadeState::Standing, false, 1.0, 1.0, 0.3, FoamLook::CLASSIC, &mut rng).unwrap();
assert!(p.ring);
assert!((0.16..=0.23).contains(&p.size0), "ring size0 {}", p.size0);
assert!(
@@ -202,7 +329,7 @@ mod tests {
assert!((0.6..0.7).contains(&p.lifetime), "ring life {}", p.lifetime);
assert!((p.peak - 0.8).abs() < 1e-6);
let p = foam_params(translating, true, 1.0, 1.0, 0.3, &mut rng).unwrap();
let p = foam_params(translating, true, 1.0, 1.0, 0.3, FoamLook::CLASSIC, &mut rng).unwrap();
assert!(p.ring, "a one-shot is ring-category");
assert!(
(0.3..=0.377).contains(&p.size0),
@@ -236,13 +363,136 @@ mod tests {
#[test]
fn depth_gate_and_attenuation() {
let mut rng = 7u32;
assert!(foam_params(WadeState::Standing, false, 1.0, 1.0, 1.05, &mut rng).is_none());
assert!(foam_params(WadeState::Standing, false, 1.0, 1.0, -0.1, &mut rng).is_none());
assert!(foam_params(WadeState::Standing, false, 1.0, 1.0, 1.05, FoamLook::CLASSIC, &mut rng).is_none());
assert!(foam_params(WadeState::Standing, false, 1.0, 1.0, -0.1, FoamLook::CLASSIC, &mut rng).is_none());
// Near the gate depth, k → 0.5: a standing ring's peak → 6 × (0.8/6 × ~0.5) ≈ 0.4.
let deep = foam_params(WadeState::Standing, false, 1.0, 1.0, 0.99, &mut rng).unwrap();
let deep = foam_params(WadeState::Standing, false, 1.0, 1.0, 0.99, FoamLook::CLASSIC, &mut rng).unwrap();
assert!((deep.peak - 0.8 * 0.505).abs() < 0.02, "peak {}", deep.peak);
}
/// MONKEY (swim waves) — **the Classic path is untouched.** Every Enhanced knob, evaluated at
/// [`FoamLook::CLASSIC`], reproduces the reference's answer bit for bit: same seed, same
/// stream, same numbers. This is the gate the whole readability pass hangs on, so it is
/// asserted against the reference's own function rather than against remembered constants.
#[test]
fn classic_is_byte_identical_and_enhanced_only_scales() {
let call = |look, oneshot, state, seed| {
let mut rng = seed;
foam_params(state, oneshot, 1.0, 4.06, 1.52, look, &mut rng).expect("in the gate")
};
let swim = WadeState::Translating {
speed: 4.72,
heading: 0.0,
};
for seed in [1u32, 7, 99, 12345] {
// The Classic look draws the same RNG in the same order as the pre-`look` function.
let base = call(FoamLook::CLASSIC, false, swim, seed);
let also_classic = call(
FoamLook {
enhanced: false,
swimming: true, // swimming, but NOT Enhanced ⇒ still the reference
},
false,
swim,
seed,
);
assert_eq!(base.size0, also_classic.size0);
assert_eq!(base.lifetime, also_classic.lifetime);
assert_eq!(base.growth, also_classic.growth);
assert_eq!(base.peak, also_classic.peak);
let enhanced = call(
FoamLook {
enhanced: true,
swimming: true,
},
false,
swim,
seed,
);
assert!((enhanced.size0 / base.size0 - 1.3).abs() < 1e-5);
assert!((enhanced.lifetime / base.lifetime - 1.4).abs() < 1e-5);
assert_eq!(enhanced.growth, base.growth, "growth is never attenuated");
assert_eq!(enhanced.peak, base.peak, "the gain is not in the alpha");
// The step-in splash: bigger ring, faster growth, Enhanced only.
let splash_c = call(FoamLook::CLASSIC, true, swim, seed);
let splash_e = call(
FoamLook {
enhanced: true,
swimming: false,
},
true,
swim,
seed,
);
assert!((splash_e.size0 / splash_c.size0 - 1.8).abs() < 1e-5);
assert!((splash_e.growth / splash_c.growth - 1.3).abs() < 1e-5);
}
assert_eq!(foam_gain(false), 1.0, "Classic draws at the authored colour");
assert!((foam_gain(true) - 3.5).abs() < 1e-6);
}
/// The treading ring exists only in Enhanced, only for a swimmer, and stays soft — a wide slow
/// circle whose peak is well under a wake's.
#[test]
fn the_treading_ring_is_enhanced_and_swimming_only() {
let mut rng = 11u32;
assert!(tread_params(FoamLook::CLASSIC, 1.0, &mut rng).is_none());
assert!(tread_params(
FoamLook {
enhanced: true,
swimming: false
},
1.0,
&mut rng
)
.is_none());
assert!(tread_params(
FoamLook {
enhanced: false,
swimming: true
},
1.0,
&mut rng
)
.is_none());
let p = tread_params(
FoamLook {
enhanced: true,
swimming: true,
},
1.0,
&mut rng,
)
.expect("an Enhanced swimmer treads");
assert!(p.ring);
assert!((0.6..=0.9).contains(&p.size0), "wide: {}", p.size0);
assert!(p.growth < 0.7, "slow: {}", p.growth);
assert!(p.lifetime > 1.3, "long-lived: {}", p.lifetime);
assert!(p.peak <= 0.5, "soft: {}", p.peak);
}
/// The swim ramp: dry and wading are zero, a body at the verified `0.75·h` rest line is most
/// of the way up, and deeper saturates. Monotone throughout — a ramp that dipped would make a
/// body bob down as it sank.
#[test]
fn swim_ramp_spans_the_verified_latch() {
let h = 2.031_f32;
assert_eq!(swim_ramp(-1.0, h), 0.0, "dry");
assert_eq!(swim_ramp(0.0, h), 0.0);
assert_eq!(swim_ramp(0.5 * h, h), 0.0, "knee-deep is not swimming");
assert!(swim_ramp(0.75 * h, h) > 0.7, "the verified latch is mostly in");
assert_eq!(swim_ramp(0.9 * h, h), 1.0, "past the band, saturated");
assert_eq!(swim_ramp(10.0, h), 1.0);
let mut prev = 0.0;
for i in 0..=200 {
let r = swim_ramp(i as f32 * 0.02 * h, h);
assert!(r >= prev - 1e-6, "monotone at {i}: {r} < {prev}");
prev = r;
}
}
/// The lifecycle: 0.4/0.6 rise/decay to the peak, zero at the lifetime; linear size growth.
#[test]
fn alpha_ramp_and_size_growth() {