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