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:
co-authored by
Claude Opus 5.5
parent
3562f4bd5f
commit
a74a46594c
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", &[
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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: ¶ms::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]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user