diff --git a/LIGHTING.md b/LIGHTING.md index 12b32964..f2039a1e 100644 --- a/LIGHTING.md +++ b/LIGHTING.md @@ -23,11 +23,15 @@ behind each constant. | Spell and firework lights | fire, holy and fel effects light their surroundings for their lifetime; frost, nature, arcane and shadow do not | `spellLightGain`, env `WOW_SPELL_LIGHT=0` | | Moon shadows | at night the same shadow rig re-aims at the moon and casts a faint shadow; dims only the night sky term, never point lights | `moonShadowStrength` | | Ground-effect spells | Flamestrike, Rain of Fire, Consecration, Flare and fire traps light the ground for their duration; frost and nature areas stay dark | `spellLightGain` | +| Volumetric fog | near-field haze that converges on the zone fog colour (clear within 10 yd, full by 150 yd; mistier at dawn and in bad weather, faint indoors) and sun/moon light shafts through gaps, sampled from the shadow map; own fullscreen pass after the main pass (`benilla-app/src/volumetric_fog.rs`) | Advanced Graphics → Volumetric Fog (Off/Low/High), cvar `volumetricFog`, env `WOW_VOLFOG=0\|1\|2` | | Night and interior level | global dimming of the night sky term and of interior ambient | `nightGain`, `interiorGain`, `interiorBakeFloor` | Players reach all of it from **Options -> Advanced Graphics** (a Lighting Quality preset Off / Low / Medium / High plus the individual rows; Off is the original client look). The dev build has a panel for all of it: **Ctrl+Shift+D → Lighting & shadows**, with Dim / Default / Bright presets. +The optional enhanced water (Water Quality, refraction, caustics, High reflections, lava glow) +is its own module, documented in `WATER.md`. + ## Where the code lives - **Shaders**: `crates/benilla-assets/src/shaders/shadow_hook.wgsl` (shared shadow sampling), diff --git a/WATER.md b/WATER.md index 8713a44e..9ee06550 100644 --- a/WATER.md +++ b/WATER.md @@ -18,7 +18,7 @@ player picks it, and every part of it can be switched off. 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. +`enhanced_water.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 @@ -28,9 +28,9 @@ the technique is listed in the table below. Do not remove either. |---|---|---| | 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` | +| Per-channel extinction + scene-copy refraction | shipped (`enhanced_water.wgsl` DEPTH LOOK; bent samples landing on anything in front of the water are refused via the scene depth) | ported from `shaders/Surface.ps.hlsl`, `render/Refraction.cpp` | +| Caustics, two layers at incommensurate scales/rates | shipped (floor web; built from animated cell edges, not value noise) | ported from `shaders/Surface.ps.hlsl`, `render/Noise.cpp` | +| Screen-space reflection of the scenery | shipped on `High` (a bisected march against the scene depth, not WXL's single probe) | principle from `shaders/Surface.ps.hlsl` | | 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` | @@ -38,10 +38,10 @@ the technique is listed in the table below. Do not remove either. | 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` | +| Quality tiers | `Classic` = the reference water, `Enhanced`, `High` (= Enhanced + scenery reflections) | 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 | +| Depth look | light fades per channel over the real path through the water; the bed is seen through it, bent by the waves; sunlit shallows carry a caustic web | 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 | @@ -52,11 +52,17 @@ request. If it ever returns it should read the authored MCLQ flow records, not d ## 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 +- **Shader**: `crates/benilla-assets/src/shaders/enhanced_water.wgsl` - the whole module on the GPU + side, an importable library (`benilla::enhanced_water`) with its own bindings: 103 scene depth, + 104 `WaterParams` (`benilla_assets::WaterUniform`), 105 the shared light buffer (a second + read-only view of upstream's binding 90), 106 the scene colour (for refraction and reflection). Upstream's `liquid.wgsl` carries three hooks only: the + `#import`, `water_swell` in its vertex stage, and the `water_active()` branch at the top of its + fragment stage, which returns the module's result as-is (the module fogs its own surface terms + only: the scene it refracts and reflects is already fogged). Keep it that way: new + water code goes in the module, not in `liquid.wgsl`. +- **Assets** (`crates/benilla-assets/src`): `water_depth.rs` (`WaterUniform`, the scene-depth image), + `materials.rs` (`LiquidExt` fields 103-106, shader registration), `lib.rs` (`WaterQuality`). +- **World** (`crates/benilla-world/src`): `liquid/scene_depth.rs` (copies the opaque depth AND colour after the main opaque pass), `liquid/waves.rs` (the CPU mirror of the vertex swell), `liquid/surface.rs` (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 diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index c283b662..7646d50d 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -835,6 +835,9 @@ enUS = { page = "Advanced Graphics", lightingQuality = "Lighting Quality", + -- MONKEY (volumetric fog): localised atmospheric quality row. + volumetricFog = "Volumetric Fog", + fogOff = "Off", waterQuality = "Water Quality", waterClassic = "Classic", waterEnhanced = "Enhanced", @@ -863,14 +866,17 @@ tips = { LIGHTING_QUALITY = "Sets character, world, indoor torch and outdoor torch shadows; dynamic " - .. "interior lighting; moon shadows; water quality; lava glow; fire and spell brightness; fire flicker; " + .. "interior lighting; moon shadows; volumetric fog; 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: Classic water, no lava glow or synthesised fire lights, " + .. "Off restores the original client's look: Classic water, no volumetric fog, 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.", + -- MONKEY (volumetric fog): explain cost and the shadow-map dependency. + VOLUMETRIC_FOG = + "Off disables volumetric fog. Low adds nearby haze and sun or moon shafts with fewer steps; High uses more steps for smoother shafts. Mist is heavier at dawn and in bad weather, and subtle indoors. Shafts require shadows; with shadows off only haze remains.", WATER_QUALITY = "Classic is the original client's water. Enhanced adds soft shorelines, " .. "animated foam around shores and objects, moving wave highlights, sky reflection " @@ -953,6 +959,9 @@ ruRU = { page = "Расширенная графика", lightingQuality = "Качество освещения", + -- MONKEY (volumetric fog): Russian atmospheric quality row. + volumetricFog = "Объёмный туман", + fogOff = "Выкл", waterQuality = "Качество воды", waterClassic = "Классическое", waterEnhanced = "Улучшенное", @@ -981,16 +990,19 @@ tips = { LIGHTING_QUALITY = "Задаёт тени персонажей, мира и огней внутри и снаружи помещений; " - .. "динамическое освещение помещений; лунные тени; качество воды; свечение лавы; яркость огня и заклинаний; " + .. "динамическое освещение помещений; лунные тени; объёмный туман; качество воды; свечение лавы; яркость огня и заклинаний; " .. "мерцание огня; темноту ночи и помещений. «Низкое», «Среднее» и «Высокое» " .. "также задают разрешение теней; «Среднее» и «Высокое» — число карт теней " .. "от огней и число огней с тенями движущихся объектов.\n\n" - .. "«Выкл.» возвращает вид оригинального клиента: классическая вода, без свечения лавы и созданных из пламени " + .. "«Выкл.» возвращает вид оригинального клиента: классическая вода, без объёмного тумана, свечения лавы и созданных из пламени " .. "источников света, с коэффициентами яркости ночи и помещений 1.0. " .. "Все остальные настройки, включая дальность теней и мягкость теней от огня, " .. "сохраняют ваши значения. «Низкое», «Среднее» и «Высокое» используют улучшенную воду; " .. "зеркальные отражения включаются отдельно. Изменение настройки набора показывает «Своё», " .. "если ни один набор больше не совпадает.", + -- MONKEY (volumetric fog): localised effect and cost explanation. + VOLUMETRIC_FOG = + "Выкл отключает объёмный туман. Низкое добавляет дымку вблизи и лучи солнца или луны с меньшим числом шагов; Высокое использует больше шагов для более плавных лучей. Туман гуще на рассвете и в непогоду, а в помещениях слабее. Для лучей нужны тени; без теней остаётся только дымка.", WATER_QUALITY = "«Классическое» — вода оригинального клиента. «Улучшенное» добавляет плавные переходы у берегов, " .. "анимированную пену у берегов и вокруг объектов, подвижные блики на волнах, отражение неба " @@ -1136,7 +1148,8 @@ -- 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", "RowWaterQuality", + -- MONKEY (volumetric fog): include in refresh, defaults and search. + "RowLightingQuality", "RowWaterQuality", "RowVolumetricFog", "RowCharacterShadows", "RowWorldShadows", "RowShadowDistance", "RowShadowResolution", "RowMoonShadows", "RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", "RowTorchSoft", @@ -3624,11 +3637,27 @@ }) - + + + + OptionsRow_OnLoad(self, "volumetricFog", BENILLA_ADVGFX.volumetricFog, + "BENILLA_TOOLTIP_VOLUMETRIC_FOG") + OptionsDropdownRow_Setup(self, { + { text = BENILLA_ADVGFX.fogOff, value = "0" }, + { text = BENILLA_ADVGFX.qLow, value = "1" }, + { text = BENILLA_ADVGFX.qHigh, value = "2" }, + }) + + + + + + + OptionsRow_OnLoad(self, "characterShadows", BENILLA_ADVGFX.characterShadows, diff --git a/crates/benilla-app/src/addon_harness/render_tests.rs b/crates/benilla-app/src/addon_harness/render_tests.rs index 10d6b839..fa147c27 100644 --- a/crates/benilla-app/src/addon_harness/render_tests.rs +++ b/crates/benilla-app/src/addon_harness/render_tests.rs @@ -22,6 +22,28 @@ use std::path::{Path, PathBuf}; use super::{survey, Drew}; +// MONKEY (volumetric fog): the mandatory Windows test run cannot compile an +// unconditional unix import. Keep Unix links; copy these small fixtures elsewhere +// so the same oracle runs without administrator symlink privileges on Windows. +pub(super) fn link_fixture(source: &Path, destination: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { std::os::unix::fs::symlink(source, destination) } + #[cfg(not(unix))] + { + std::fs::create_dir_all(destination)?; + for entry in std::fs::read_dir(source)? { + let entry = entry?; + let target = destination.join(entry.file_name()); + if entry.file_type()?.is_dir() { + link_fixture(&entry.path(), &target)?; + } else { + std::fs::copy(entry.path(), target)?; + } + } + Ok(()) + } +} + /// One throwaway AddOns root, cleaned up on drop even if a test panics. struct Fixtures(PathBuf); @@ -196,7 +218,8 @@ fn the_directors_two_verified_addons_come_out_on_opposite_sides() { // `addon_harness` example's job, not a unit test's. let fx = Fixtures::new("oracle"); for name in ["!OmniCC", "Bagnon", "Bagnon_Core", "Bagnon_Forever"] { - std::os::unix::fs::symlink(corpus.join(name), fx.root().join(name)).unwrap(); + // MONKEY (volumetric fog): use the portable corpus fixture for Windows verification. + link_fixture(&corpus.join(name), &fx.root().join(name)).unwrap(); } let reports = survey(fx.root()); let row = |name: &str| { diff --git a/crates/benilla-app/src/addon_harness/use_probe_tests.rs b/crates/benilla-app/src/addon_harness/use_probe_tests.rs index f2d3fecf..50ffff56 100644 --- a/crates/benilla-app/src/addon_harness/use_probe_tests.rs +++ b/crates/benilla-app/src/addon_harness/use_probe_tests.rs @@ -284,7 +284,8 @@ fn the_directors_two_verified_addons_are_reachable_and_omnicc_is_not_broken() { }; let fx = Fixtures::new("oracle"); for name in ["!OmniCC", "Bagnon", "Bagnon_Core", "Bagnon_Forever"] { - std::os::unix::fs::symlink(corpus.join(name), fx.root().join(name)).unwrap(); + // MONKEY (volumetric fog): share the portable fixture used by the render oracle. + super::render_tests::link_fixture(&corpus.join(name), &fx.root().join(name)).unwrap(); } let reports = survey(fx.root()); let row = |name: &str| { diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index ab191c6c..0e7e1315 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -1063,6 +1063,16 @@ pub(super) fn seed_ui_fixture( warn!("capture: ui-options-graphics seed failed: {e}"); } } + // MONKEY (volumetric fog): exercise the rendered Advanced Graphics dropdown and layout. + UiFixture::OptionsAdvancedGraphics => { + let Some(script) = script else { return; }; + script.register_cvars(crate::cvars::registered_pairs()); + if let Err(e) = script.run( + "ShowUIPanel(BenillaOptionsFrame); BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()", + ) { + warn!("capture: ui-options-advanced seed failed: {e}"); + } + } UiFixture::OptionsChat => { let Some(script) = script else { return; diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index db116d71..3a86a536 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -180,6 +180,8 @@ pub(super) enum UiFixture { /// read the CVar registration defaults (hermetic capture = no config file), so the pixels /// move only with the window, the atlas seam, or a registered default. OptionsGraphics, + // MONKEY (volumetric fog): photograph the new quality row on its actual page. + OptionsAdvancedGraphics, /// The Options window ON THE CHAT PAGE (decision 1589 — B246's "no chat section in options"). /// The page 1.12 calls `CHAT_LABEL`: four checkbox rows over three stores at once (a saved /// variable, three CVars), which is what makes it worth a baseline of its own — the row art is @@ -474,6 +476,32 @@ 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] = &[ + // MONKEY (volumetric fog): UI proof uses the same live page selection as a player. + Scenario { + name: "ui-options-advanced", map: Some(MAP_AZEROTH), + eye: GROUND_EYE, look: GROUND_LOOK, minute: 720, + ui: Some(UiFixture::OptionsAdvancedGraphics), + }, + // MONKEY (volumetric fog): reproducible dawn trees, moonlit lamps and the inn's common room. + Scenario { + name: "volfog-dawn", map: Some(MAP_AZEROTH), + eye: [-9460.0, 70.0, 58.0], look: [-9410.0, 120.0, 67.0], minute: 390, ui: None, + }, + Scenario { + name: "volfog-night", map: Some(MAP_AZEROTH), + eye: [-9460.0, 70.0, 58.0], look: [-9450.0, 20.0, 61.0], minute: 0, ui: None, + }, + // MONKEY (volumetric fog): celestial_sun_direction(420) has WoW azimuth + // 45 degrees and elevation 12.20778 degrees: dz = hypot(70,70)*tan(elevation). + // The Goldshire lake bank has sunlit air behind the gaps between the trees. + Scenario { + name: "volfog-sun", map: Some(MAP_AZEROTH), + eye: WATER_EYE, look: [-9457.0, -240.6, 92.21753], minute: 420, ui: None, + }, + Scenario { + name: "volfog-inn", map: Some(MAP_AZEROTH), + eye: INN_EYE, look: INN_LOOK, minute: 390, ui: None, + }, // 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. @@ -557,6 +585,16 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ minute: 480, ui: None, }, + // Longshore shallows at noon, looking down through 2-4 yd of sea: refraction and the caustic + // web on the sand (the morning scenes are too low-sun to focus). + Scenario { + name: "water-caustics", + map: Some(MAP_AZEROTH), + eye: [-9612.0, 1262.0, 7.0], + look: [-9598.0, 1262.0, -3.5], + minute: 720, + ui: None, + }, // Canal water, wall, hull and dock-post contacts in one near view. Scenario { name: "stormwind-canal-near", diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 5fee6b33..9ed05053 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -926,6 +926,8 @@ 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", ), + // MONKEY (volumetric fog): saved live tier; capture override stays session-only. + ours("volumetricFog", "1", "benilla's own: near-field volumetric fog, 0 Off / 1 Low / 2 High"), ours( "waterQuality", "1", @@ -2001,6 +2003,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ ("exteriorShadows", "0"), ("spellLightGain", "0"), ("waterQuality", "0"), + // MONKEY (volumetric fog): Off preset restores the original atmosphere. + ("volumetricFog", "0"), ("lavaLightGain", "0"), ("fireLightGain", "0"), ("nightGain", "1.0"), @@ -2022,6 +2026,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ ("exteriorShadows", "0"), ("spellLightGain", "1"), ("waterQuality", "1"), + // MONKEY (volumetric fog): preset atmosphere uses the default cheap tier. + ("volumetricFog", "1"), ("lavaLightGain", "1"), ("fireLightGain", "1"), ("nightGain", "0.45"), @@ -2046,6 +2052,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ ("exteriorShadows", "0"), ("spellLightGain", "1"), ("waterQuality", "1"), + // MONKEY (volumetric fog): preset atmosphere uses the default cheap tier. + ("volumetricFog", "1"), ("lavaLightGain", "1"), ("fireLightGain", "1"), ("nightGain", "0.45"), @@ -2071,6 +2079,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ ("spellLightGain", "1"), // Mirror reflections stay opt-in until their cost is measured. ("waterQuality", "1"), + // MONKEY (volumetric fog): preset atmosphere uses the default cheap tier. + ("volumetricFog", "1"), ("lavaLightGain", "1"), ("fireLightGain", "1"), ("nightGain", "0.45"), @@ -2884,8 +2894,11 @@ 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); 32] = [ + // MONKEY (volumetric fog): include the atmospheric tier in this fixed-size default table. + let lighting: [(&str, f32); 33] = [ ("waterQuality", shadows.water_quality as f32), + // MONKEY (volumetric fog): weld registry and renderer defaults. + ("volumetricFog", shadows.volumetric_fog as f32), ("lavaLightGain", shadows.lava_light_gain), // The two sun lanes and the cascade they share. ("worldShadows", flag(shadows.world_shadows)), @@ -2938,9 +2951,10 @@ 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 — 32 rows, 32 welds. + // row the client does not have; the length is the other half — 33 rows, 33 welds. let welded: std::collections::BTreeSet<&str> = lighting.iter().map(|(n, _)| *n).collect(); - assert_eq!(welded.len(), 32, "the lighting lane welds 32 distinct rows"); + // MONKEY (volumetric fog): the atmospheric tier joins the default-consumer weld. + assert_eq!(welded.len(), 33, "the lighting lane welds 33 distinct rows"); for name in &welded { assert!( REGISTERED.iter().any(|r| r.name == *name), @@ -2994,9 +3008,10 @@ 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", "waterQuality", "lavaLightGain", "nightGain", "interiorGain"] { + // MONKEY (volumetric fog): Off must remove atmosphere as well as enhanced water. + for member in ["fireLightGain", "waterQuality", "volumetricFog", "lavaLightGain", "nightGain", "interiorGain"] { let expected = if *name == "Off" { - if matches!(member, "fireLightGain" | "waterQuality" | "lavaLightGain") { "0" } else { "1.0" } + if matches!(member, "fireLightGain" | "waterQuality" | "volumetricFog" | "lavaLightGain") { "0" } else { "1.0" } } else { cvars.default_of(member).unwrap() }; @@ -3113,6 +3128,8 @@ mod tests { "interiorShadowDynamic" => video.interior_shadow_dynamic as f32, "spellLightGain" => video.spell_light_gain, "waterQuality" => video.water_quality as f32, + // MONKEY (volumetric fog): verify presets reach the renderer. + "volumetricFog" => video.volumetric_fog as f32, "lavaLightGain" => video.lava_light_gain, "fireLightGain" => video.fire_light_gain, "moonShadowStrength" => video.moon_shadow_strength, diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index eaafbc24..5491eeac 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -240,6 +240,8 @@ impl PluginGroup for GamePlugins { // MONKEY (dynamic interiors): the fixture-lit interior lane's cvar bridge — independent // of the shadow lanes, a plain drop-in. .add(crate::dynamic_interior::DynamicInteriorPlugin) + // MONKEY (volumetric fog): bridge the live setting to Bevy. + .add(crate::volumetric_fog::VolumetricFogPlugin) // The realmlist (decision 1667) — the logon address the login screen edits. Same reason as // VideoPlugin above: it is a CVar knob, so its resource has to exist before `load_config`. .add(crate::realmlist::RealmlistPlugin) diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 093dd2ce..879bd541 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -72,6 +72,8 @@ mod debug_panel; mod dev; mod doodad_events; /// MONKEY (dynamic interiors): the cvar → benilla-world bridge for the fixture-lit interior lane. +// MONKEY (volumetric fog): optional near-field atmosphere plugin. +mod volumetric_fog; mod dynamic_interior; mod entities; mod fishing_line; diff --git a/crates/benilla-app/src/shadow_core.rs b/crates/benilla-app/src/shadow_core.rs index 01d0ca8a..388affc4 100644 --- a/crates/benilla-app/src/shadow_core.rs +++ b/crates/benilla-app/src/shadow_core.rs @@ -266,7 +266,8 @@ fn shadow_sun_travel(to_sun: Vec3) -> Vec3 { pub(crate) struct ShadowCaster; #[derive(Component)] -struct ShadowSun; +// MONKEY (volumetric fog): identify the celestial map without touching other lights. +pub(crate) struct ShadowSun; #[derive(Component)] struct ShadowCameraLayer { diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index ea840d16..1cb724fb 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -1973,6 +1973,60 @@ fn defaults_resets_the_graphics_page_to_registered_defaults() { /// The name every row on the new page is derived from. const ADVGFX: &str = "BenillaOptionsFrameContainerBodyAdvancedGraphics"; +// MONKEY (volumetric fog): exercise translations and numeric writes through the real UI. +#[test] +fn volumetric_fog_dropdown_is_localised_and_live() { + // MONKEY (volumetric fog): validate the authored table and row even on a + // data-free checkout; the complete window additionally needs stock FrameXML. + let xml = include_str!("../../assets/ui/OptionsFrame.xml"); + let strings = &xml[xml.find("BENILLA_ADVGFX_STRINGS = {").unwrap() + ..xml.find("OPTIONS_PAGE_ROWS = {").unwrap()]; + let row = xml.split("").nth(1).unwrap().split("").next().unwrap(); + for (locale, title, labels) in [ + ("enUS", "Volumetric Fog", ["Off", "Low", "High"]), + ("ruRU", "Объёмный туман", ["Выкл", "Низкое", "Высокое"]), + ] { + let mut s = audio_harness(); + s.run(&format!("function GetLocale() return '{locale}' end")).unwrap(); + s.run(strings).unwrap(); + s.run(r#" + self = {} + function OptionsRow_OnLoad(row, cvar, title, tip) + row.cvar, row.title, row.tip = cvar, title, tip + end + function OptionsDropdownRow_Setup(row, choices) row.choices = choices end + "#).unwrap(); + s.run(on_load).unwrap(); + assert_eq!(s.eval::("return self.title").unwrap(), title); + assert!(s.eval::("return getglobal(self.tip) == BENILLA_ADVGFX.tips.VOLUMETRIC_FOG and string.len(getglobal(self.tip)) > 80").unwrap()); + for (tier, label) in labels.iter().enumerate() { + assert_eq!(s.eval::(&format!("return self.choices[{}].text", tier + 1)).unwrap(), *label); + let _ = s.take_cvar_changes(); + s.run(&format!("SetCVar(self.cvar, self.choices[{}].value)", tier + 1)).unwrap(); + assert_eq!(s.take_cvar_changes(), vec![("volumetricFog".to_string(), tier.to_string())]); + } + if benilla_formats::wow_data().is_none() { + continue; + } + // MONKEY (volumetric fog): with an install, also exercise the real widget kit. + 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 OPTIONS_PAGE_ROWS.AdvancedGraphics[3]").unwrap(), "RowVolumetricFog"); + assert_eq!(s.eval::(&format!("return {ADVGFX}RowVolumetricFogLabel:GetText()")).unwrap(), title); + assert_eq!(s.eval::(&format!("return {ADVGFX}RowVolumetricFogDropdownText:GetText()")).unwrap(), labels[1]); + let _ = s.take_cvar_changes(); + for (tier, label) in labels.iter().enumerate() { + s.run(&format!("OptionsRow_Set({ADVGFX}RowVolumetricFog, '{tier}') OptionsDropdown_ShowValue({ADVGFX}RowVolumetricFog)")).unwrap(); + assert_eq!(s.take_cvar_changes(), vec![("volumetricFog".to_string(), tier.to_string())]); + assert_eq!(s.eval::(&format!("return {ADVGFX}RowVolumetricFogDropdownText:GetText()")).unwrap(), *label); + } + assert!(s.errors().is_empty(), "{:?}", s.errors()); + } +} + #[test] fn water_quality_writes_numeric_tiers_with_localised_labels() { for (locale, labels, lava_label) in [ @@ -1983,9 +2037,10 @@ fn water_quality_writes_numeric_tiers_with_localised_labels() { 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); + // MONKEY (volumetric fog): account for the atmosphere row after water. + assert_eq!(s.eval::("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)").unwrap(), 18); 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::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[16]").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()); @@ -2900,6 +2955,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)] = &[ + // MONKEY (volumetric fog): the row owns a translated tooltip too. + ("BENILLA_TOOLTIP_VOLUMETRIC_FOG", "AdvancedGraphicsRowVolumetricFog"), ("BENILLA_TOOLTIP_WATER_QUALITY", "AdvancedGraphicsRowWaterQuality"), ("BENILLA_TOOLTIP_LAVA_GLOW", "AdvancedGraphicsRowLavaGlow"), ("BENILLA_TOOLTIP_RENDER_SCALE", "GraphicsRowRenderScale"), @@ -3165,7 +3222,8 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() { // teeth bite hardest, since it is the first one to seat a dropdown, a checkbox and a slider // whose descriptions are all benilla's. // Water Quality and Lava Glow add two more: 93 -> 95. - assert_eq!(raised, 95, "every row but Auto Loot raises a description"); + // MONKEY (volumetric fog): the atmosphere dropdown adds one more described row. + assert_eq!(raised, 96, "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 b88cc850..fb48cee1 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -220,6 +220,8 @@ pub(crate) struct VideoConfig { /// Water tier: 0 Classic, 1 Enhanced (default), 2 High with opt-in mirror reflections. /// Published to the water renderer by `dynamic_interior::bridge`. pub(crate) water_quality: u8, + // MONKEY (volumetric fog): live camera raymarch tier: 0 Off, 1 Low, 2 High. + pub(crate) volumetric_fog: 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, @@ -463,6 +465,8 @@ impl Default for VideoConfig { fire_light_gain: 1.0, spell_light_gain: 1.0, water_quality: 1, + // MONKEY (volumetric fog): default to the inexpensive atmosphere. + volumetric_fog: 1, lava_light_gain: 1.0, fire_flicker: 1.0, display: if windowed_env() { @@ -524,7 +528,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 32 rows ──────────────────── + // ── MONKEY (lighting): the dynamic light + shadow system's 33 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 @@ -540,8 +544,10 @@ 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 32 pairs. + // MONKEY (volumetric fog): the atmospheric tier brings the defaults weld to 33 pairs. "waterquality" => cfg.water_quality = v.clamp(0.0, 2.0) as u8, + // MONKEY (volumetric fog): constrain UI/console writes to supported tiers. + "volumetricfog" => cfg.volumetric_fog = v.clamp(0.0, 2.0) as u8, "lavalightgain" => cfg.lava_light_gain = v.clamp(0.0, 4.0), "worldshadows" => cfg.world_shadows = ev.flag(), "charactershadows" => cfg.character_shadows = ev.flag(), diff --git a/crates/benilla-app/src/volumetric_fog.rs b/crates/benilla-app/src/volumetric_fog.rs new file mode 100644 index 00000000..7c59b9f2 --- /dev/null +++ b/crates/benilla-app/src/volumetric_fog.rs @@ -0,0 +1,548 @@ +//! MONKEY (volumetric fog): gamma-space distance haze and shadow-map light shafts. +//! Runs after world transparencies and before the world's gamma decode. Bevy's physical +//! fog loses energy in shade; this artistic lane preserves luminance while converging +//! toward the zone fog chromaticity. All shader code lives here with its pipeline. +use crate::{ + shadow_core::{ShadowSet, ShadowSun}, + video::VideoConfig, + world_shadow::WorldLane, +}; +use benilla_world::{ + lighting::{WorldTime, WowLighting}, + static_gx::StaticGx, + view::WorldCamera, + weather::WeatherState, + wmo_portal::CameraInteriorClaim, +}; +use bevy::{ + core_pipeline::{ + core_3d::graph::{Core3d, Node3d}, + FullscreenShader, + }, + ecs::query::QueryItem, + light::VolumetricLight, + pbr::{ + GpuLights, LightMeta, ViewLightsUniformOffset, ViewShadowBindings, MAX_CASCADES_PER_LIGHT, + MAX_DIRECTIONAL_LIGHTS, + }, + prelude::*, + render::{ + diagnostic::RecordDiagnostics, + extract_component::{ExtractComponent, ExtractComponentPlugin}, + render_graph::{ + NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner, + }, + render_resource::{binding_types::*, *}, + renderer::{RenderContext, RenderDevice, RenderQueue}, + view::{Msaa, ViewDepthTexture, ViewTarget, ViewUniform, ViewUniformOffset, ViewUniforms}, + Render, RenderApp, RenderStartup, RenderSystems, + }, + shader::ShaderDefVal, +}; + +pub(crate) struct VolumetricFogPlugin; +#[derive(Resource)] +struct FogOverride(Option, f32); + +#[derive(Component, Clone, Copy, ExtractComponent, ShaderType)] +struct FogView { + // Gamma byte-space colour and extinction per yard. + color_density: Vec4, + // Sun colour and strength, independent of PBR lux/exposure. + sun_strength: Vec4, + // Actual celestial direction and raymarch step count. + direction_steps: Vec4, +} + +impl Plugin for VolumetricFogPlugin { + fn build(&self, app: &mut App) { + let value = std::env::var("WOW_VOLFOG") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|v| *v <= 2); + // Dev capture instrument: isolate shafts without changing surface shadows or haze. + let shaft_gain = if cfg!(feature = "dev") && std::env::var_os("WOW_CAPTURE").is_some() { + std::env::var("WOW_VOLFOG_SHAFT_GAIN") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|v| v.is_finite()) + .unwrap_or(2.0) + .clamp(0.0, 8.0) + } else { + 2.0 + }; + app.insert_resource(FogOverride(value, shaft_gain)) + .add_plugins(ExtractComponentPlugin::::default()) + .add_systems(Last, refresh_streamed_shadows.before(ShadowSet::Lanes)) + .add_systems(Last, update_fog.after(ShadowSet::Lanes)); + // Keep headless policy tests independent of the renderer. + if !app.is_plugin_added::() { + return; + } + let shader = app + .world_mut() + .resource_mut::>() + .add(Shader::from_wgsl(FOG_SHADER, "volumetric_fog.rs")); + let Some(render_app) = app.get_sub_app_mut(RenderApp) else { + return; + }; + render_app + .insert_resource(FogShader(shader)) + .init_resource::>() + .add_systems(RenderStartup, init_pipeline) + .add_systems(Render, prepare_pipelines.in_set(RenderSystems::Prepare)) + .add_render_graph_node::>(Core3d, FogLabel) + .add_render_graph_edges( + Core3d, + ( + Node3d::EndMainPass, + FogLabel, + Node3d::StartMainPassPostProcessing, + ), + ); + } +} + +// The legacy caster cache otherwise misses streamed trees at a stationary camera. +// Off must leave even this old cache behaviour untouched for the exact baseline. +fn refresh_streamed_shadows( + video: Res, + override_value: Res, + gx: Option>, + mut lane: ResMut, + mut seen: Local>, +) { + if override_value.0.unwrap_or(video.volumetric_fog) == 0 || !video.world_shadows { + *seen = None; + return; + } + let Some(gx) = gx else { return }; + let generation = gx.torch_residency_generation(); + if *seen != Some(generation) { + lane.invalidate_static(); + *seen = Some(generation); + } +} + +fn density(minute: f32, weather: f32, indoors: bool) -> f32 { + let dawn = (1.0 - ((minute - 390.0) / 90.0).abs()).clamp(0.0, 1.0); + let dawn = dawn * dawn * (3.0 - 2.0 * dawn); + 0.004 * (1.0 + dawn * 1.5 + weather.clamp(0.0, 1.0) * 2.0) * if indoors { 0.18 } else { 1.0 } +} + +fn update_fog( + mut commands: Commands, + video: Res, + override_value: Res, + lighting: Res, + clock: Res, + weather: Res, + interior: Res, + mut cameras: Query<(Entity, &Camera, &mut Camera3d), With>, + suns: Query<(Entity, &DirectionalLight), With>, +) { + let tier = override_value.0.unwrap_or(video.volumetric_fog).min(2); + let enabled = tier != 0 && cameras.iter().any(|(_, c, _)| c.is_active); + for (entity, sun) in &suns { + // Only a tag for selecting this light in our shader. No Bevy VolumetricFog + // camera/volume is created, and the rig's lux and surface lighting stay intact. + if enabled && sun.shadows_enabled { + commands.entity(entity).insert(VolumetricLight); + } else { + commands.entity(entity).remove::(); + } + } + for (entity, camera, mut camera3d) in &mut cameras { + if !enabled || !camera.is_active { + commands.entity(entity).remove::(); + continue; + } + camera3d.depth_texture_usages.0 |= TextureUsages::TEXTURE_BINDING.bits(); + let sun = lighting.celestial_dir(); + let daylight = ((sun.y + 0.02) / 0.15).clamp(0.0, 1.0); + let fog = Vec3::from_array(lighting.fog_color); + commands.entity(entity).insert(FogView { + color_density: fog.extend( + density( + clock.minute_f, + weather.sky_density.max(weather.effect_density), + interior.0.is_some(), + ) * if tier == 2 { 1.6 } else { 1.0 }, + ), + sun_strength: Vec3::from_array(lighting.diffuse) + .lerp(Vec3::ONE, 0.5) + .extend(override_value.1 * daylight), + direction_steps: sun.extend(if tier == 1 { 24.0 } else { 64.0 }), + }); + } +} + +#[derive(Resource)] +struct FogShader(Handle); +#[derive(Resource)] +struct FogPipeline { + layouts: [BindGroupLayoutDescriptor; 2], + shader: Handle, + fullscreen: FullscreenShader, + comparison: Sampler, +} +#[derive(Component)] +struct ViewFogPipeline(CachedRenderPipelineId); +#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)] +struct FogLabel; + +fn init_pipeline( + mut commands: Commands, + device: Res, + shader: Res, + fullscreen: Res, +) { + let layouts = [false, true].map(|multisampled| { + BindGroupLayoutDescriptor::new( + "gamma_fog_layout", + &BindGroupLayoutEntries::sequential( + ShaderStages::FRAGMENT, + ( + texture_2d(TextureSampleType::Float { filterable: false }), + if multisampled { + texture_depth_2d_multisampled() + } else { + texture_depth_2d() + }, + uniform_buffer::(true), + uniform_buffer::(true), + texture_2d_array(TextureSampleType::Depth), + sampler(SamplerBindingType::Comparison), + uniform_buffer::(false), + ), + ), + ) + }); + commands.insert_resource(FogPipeline { + layouts, + shader: shader.0.clone(), + fullscreen: fullscreen.clone(), + comparison: device.create_sampler(&SamplerDescriptor { + compare: Some(CompareFunction::GreaterEqual), + min_filter: FilterMode::Linear, + mag_filter: FilterMode::Linear, + ..default() + }), + }); +} +impl SpecializedRenderPipeline for FogPipeline { + type Key = (TextureFormat, bool); + fn specialize(&self, (format, multisampled): Self::Key) -> RenderPipelineDescriptor { + let mut defs = vec![ + ShaderDefVal::UInt( + "MAX_DIRECTIONAL_LIGHTS".into(), + MAX_DIRECTIONAL_LIGHTS as u32, + ), + ShaderDefVal::UInt( + "MAX_CASCADES_PER_LIGHT".into(), + MAX_CASCADES_PER_LIGHT as u32, + ), + ShaderDefVal::UInt("AVAILABLE_STORAGE_BUFFER_BINDINGS".into(), 0), + ]; + if multisampled { + defs.push("MULTISAMPLED".into()); + } + RenderPipelineDescriptor { + label: Some("gamma_volumetric_fog".into()), + layout: vec![self.layouts[multisampled as usize].clone()], + vertex: self.fullscreen.to_vertex_state(), + fragment: Some(FragmentState { + shader: self.shader.clone(), + shader_defs: defs, + entry_point: Some("fragment".into()), + targets: vec![Some(ColorTargetState { + format, + blend: None, + write_mask: ColorWrites::ALL, + })], + }), + ..default() + } + } +} +fn prepare_pipelines( + mut commands: Commands, + cache: Res, + pipeline: Res, + mut specialized: ResMut>, + views: Query<(Entity, &ViewTarget, &Msaa), With>, +) { + for (entity, target, msaa) in &views { + let id = specialized.specialize( + &cache, + &pipeline, + (target.main_texture_format(), msaa.samples() > 1), + ); + commands.entity(entity).insert(ViewFogPipeline(id)); + } +} +#[derive(Default)] +struct FogNode; +impl ViewNode for FogNode { + type ViewQuery = ( + &'static ViewTarget, + &'static ViewDepthTexture, + &'static ViewUniformOffset, + &'static ViewLightsUniformOffset, + &'static ViewShadowBindings, + &'static FogView, + &'static ViewFogPipeline, + ); + fn run<'w>( + &self, + _graph: &mut RenderGraphContext, + context: &mut RenderContext<'w>, + (target, depth, view_offset, light_offset, shadows, fog, id): QueryItem< + 'w, + '_, + Self::ViewQuery, + >, + world: &'w World, + ) -> Result<(), NodeRunError> { + let cache = world.resource::(); + let Some(pipeline) = cache.get_render_pipeline(id.0) else { + return Ok(()); + }; + if !depth + .texture + .usage() + .contains(TextureUsages::TEXTURE_BINDING) + { + return Ok(()); + } + let (Some(view), Some(lights)) = ( + world.resource::().uniforms.binding(), + world.resource::().view_gpu_lights.binding(), + ) else { + return Ok(()); + }; + let settings = world.resource::(); + let device = context.render_device(); + let mut uniform = UniformBuffer::from(*fog); + uniform.write_buffer(device, world.resource::()); + let out = target.post_process_write(); + let bind = device.create_bind_group( + "gamma_fog", + &cache.get_bind_group_layout( + &settings.layouts[(depth.texture.sample_count() > 1) as usize], + ), + &BindGroupEntries::sequential(( + out.source, + depth.view(), + view, + lights, + &shadows.directional_light_depth_texture_view, + &settings.comparison, + uniform.binding().unwrap(), + )), + ); + let diagnostics = context.diagnostic_recorder(); + let mut pass = context + .command_encoder() + .begin_render_pass(&RenderPassDescriptor { + label: Some("gamma_volumetric_fog"), + color_attachments: &[Some(RenderPassColorAttachment { + view: out.destination, + depth_slice: None, + resolve_target: None, + ops: Operations { + load: LoadOp::Load, + store: StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + let span = diagnostics.pass_span(&mut pass, "gamma_volumetric_fog"); + pass.set_pipeline(pipeline); + pass.set_bind_group(0, &bind, &[view_offset.offset, light_offset.offset]); + pass.draw(0..3, 0..1); + span.end(&mut pass); + Ok(()) + } +} + +const FOG_SHADER: &str = r#" +#import bevy_render::view::View +#import bevy_pbr::mesh_view_types::Lights +@group(0) @binding(0) var scene: texture_2d; +#ifdef MULTISAMPLED +@group(0) @binding(1) var depth: texture_depth_multisampled_2d; +#else +@group(0) @binding(1) var depth: texture_depth_2d; +#endif +@group(0) @binding(2) var view: View; +@group(0) @binding(3) var lights: Lights; +@group(0) @binding(4) var shadows: texture_depth_2d_array; +@group(0) @binding(5) var shadow_sampler: sampler_comparison; +struct Fog { color_density: vec4, sun_strength: vec4, direction_steps: vec4 } +@group(0) @binding(6) var fog: Fog; + +// Integral of smoothstep(10, 30, distance): zero in the first ten yards, +// continuous density and derivative at both ends, bounded at 150 yd. +fn fog_path(distance: f32) -> f32 { + let d = clamp(distance, 10.0, 150.0); + let u = clamp((d - 10.0) / 20.0, 0.0, 1.0); + return 20.0 * (u*u*u - 0.5*u*u*u*u) + max(d - 30.0, 0.0); +} +fn visibility(light_index: u32, p: vec3) -> f32 { + // Borrow uniform records instead of copying all cascade matrices per sample. + let light = &lights.directional_lights[light_index]; + let view_distance = -(view.view_from_world * vec4(p, 1.0)).z; + for (var cascade = 0u; cascade < (*light).num_cascades; cascade += 1u) { + let shadow_cascade = &(*light).cascades[cascade]; + if (view_distance > (*shadow_cascade).far_bound) { continue; } + let q = (*shadow_cascade).clip_from_world * vec4(p + (*light).direction_to_light * (*light).shadow_depth_bias, 1.0); + let ndc = q.xyz / q.w; + let uv = ndc.xy * vec2(0.5, -0.5) + 0.5; + if (any(uv < vec2(0.0)) || any(uv > vec2(1.0)) || ndc.z < 0.0 || ndc.z > 1.0) { continue; } + return textureSampleCompareLevel(shadows, shadow_sampler, uv, + i32((*light).depth_texture_base_index + cascade), ndc.z); + } + // Unknown outside caster coverage is not evidence of direct sunlight. + return 0.0; +} +@fragment +fn fragment(@builtin(position) pixel: vec4) -> @location(0) vec4 { + let xy = vec2(pixel.xy); + let source = textureLoad(scene, xy, 0); + let z = textureLoad(depth, xy, 0); + let uv = (pixel.xy - view.viewport.xy) / view.viewport.zw; + let q = view.world_from_clip * vec4(uv * vec2(2.0, -2.0) + vec2(-1.0, 1.0), max(z, 0.000001), 1.0); + let delta = q.xyz / q.w - view.world_position; + let distance = min(length(delta), 150.0); + if (distance <= 10.0) { return source; } + let ray = normalize(delta); + let optical_depth = fog.color_density.w * fog_path(distance); + let opacity = 1.0 - exp(-optical_depth); + let luma = vec3(0.2126, 0.7152, 0.0722); + var haze = mix(source.rgb, fog.color_density.rgb, opacity); + // A darker zone colour may change hue, never the pixel's luminance. Add + // neutral headroom proportionally so saturated channels cannot clip dark. + let missing = max(0.0, dot(source.rgb - haze, luma)); + let headroom = max(vec3(0.0), vec3(1.0) - haze); + haze += headroom * clamp(missing / max(dot(headroom, luma), 0.00001), 0.0, 1.0); + if (fog.sun_strength.w > 0.0) { + let g = 0.65; + let cosine = dot(ray, fog.direction_steps.xyz); + let denom = 1.0 + g*g - 2.0*g*cosine; + let phase = 0.07957747 * (1.0-g*g) / (denom * sqrt(denom)); + let count = u32(fog.direction_steps.w); + let step_size = (distance - 10.0) / f32(count); + var scatter = 0.0; + for (var light_index = 0u; light_index < lights.n_directional_lights; light_index += 1u) { + if ((lights.directional_lights[light_index].flags & 3u) != 3u) { continue; } + for (var step = 0u; step < count; step += 1u) { + // Fixed midpoint samples: no temporal noise in this gamma lane. + let t = 10.0 + (f32(step) + 0.5) * step_size; + let density = fog.color_density.w * smoothstep(10.0, 30.0, t); + scatter += visibility(light_index, view.world_position + ray*t) + * exp(-fog.color_density.w * fog_path(t)) * density * step_size; + } + } + let shafts = fog.sun_strength.rgb * fog.sun_strength.w * phase * scatter; + haze += max(vec3(0.0), vec3(1.0) - haze) * (vec3(1.0) - exp(-shafts)); + } + return vec4(haze, source.a); +} +"#; + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn shadow_cache_refresh_is_gated_and_quiet_when_unchanged() { + let mut app = App::new(); + app.init_resource::() + .init_resource::() + .init_resource::() + .insert_resource(FogOverride(Some(0), 2.0)) + .add_systems(Update, refresh_streamed_shadows); + app.world_mut().clear_trackers(); + app.world_mut().run_schedule(Update); + assert!(!app.world().resource_ref::().is_changed()); + app.world_mut().resource_mut::().0 = Some(1); + app.world_mut().run_schedule(Update); + assert!(app.world().resource_ref::().is_changed()); + app.world_mut().clear_trackers(); + app.world_mut().run_schedule(Update); + assert!(!app.world().resource_ref::().is_changed()); + } + #[test] + fn dawn_weather_and_rooms_control_density() { + let noon = density(720.0, 0.0, false); + assert!(density(390.0, 0.0, false) > noon * 2.0); + assert_eq!(density(300.0, 0.0, false), noon); + assert_eq!(density(480.0, 0.0, false), noon); + assert!(density(720.0, 1.0, false) > noon * 2.0); + assert!(density(390.0, 1.0, true) < noon); + } + #[test] + fn live_tiers_shadow_loss_inactive_camera_and_off() { + let mut app = App::new(); + app.init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .insert_resource(FogOverride(None, 2.0)) + .add_systems(Update, update_fog); + let camera = app + .world_mut() + .spawn((WorldCamera, Camera::default(), Camera3d::default())) + .id(); + let preview = app + .world_mut() + .spawn((Camera::default(), Camera3d::default())) + .id(); + let sun = app + .world_mut() + .spawn(( + ShadowSun, + DirectionalLight { + illuminance: 123.0, + shadows_enabled: true, + ..default() + }, + )) + .id(); + app.update(); + let low = *app.world().get::(camera).unwrap(); + assert_eq!(low.direction_steps.w, 24.0); + assert!(app.world().get::(preview).is_none()); + assert!(app.world().get::(sun).is_some()); + app.world_mut().resource_mut::().volumetric_fog = 2; + app.world_mut() + .get_mut::(sun) + .unwrap() + .shadows_enabled = false; + app.update(); + let high = *app.world().get::(camera).unwrap(); + assert_eq!(high.direction_steps.w, 64.0); + assert_eq!(high.color_density.w, low.color_density.w * 1.6); + assert!(app.world().get::(sun).is_none()); + assert_eq!( + app.world() + .get::(sun) + .unwrap() + .illuminance, + 123.0 + ); + app.world_mut().get_mut::(camera).unwrap().is_active = false; + app.update(); + assert!(app.world().get::(camera).is_none()); + app.world_mut().get_mut::(camera).unwrap().is_active = true; + app.world_mut().resource_mut::().0 = Some(0); + app.update(); + assert!(app.world().get::(camera).is_none()); + app.world_mut().resource_mut::().0 = None; + app.update(); + assert!(app.world().get::(camera).is_some()); + } +} diff --git a/crates/benilla-app/src/world_shadow.rs b/crates/benilla-app/src/world_shadow.rs index 68775ec6..8e787dce 100644 --- a/crates/benilla-app/src/world_shadow.rs +++ b/crates/benilla-app/src/world_shadow.rs @@ -106,7 +106,8 @@ struct CutoutCaster { /// The world lane's retained casters. #[derive(Resource, Default)] -struct WorldLane { +// MONKEY (volumetric fog): allow the fog plugin to invalidate a stale streamed caster cache. +pub(crate) struct WorldLane { /// The cached static caster — the retained `static_gx` world (trees + buildings), rebuilt on drift. static_caster: Option, static_mesh: Option>, @@ -122,6 +123,14 @@ struct WorldLane { env_rate: RebuildRate, } +// MONKEY (volumetric fog): retain ownership of the cache here; the fog plugin decides +// when streamed geometry needs a fresh map, without changing the legacy Off path. +impl WorldLane { + pub(crate) fn invalidate_static(&mut self) { + self.static_rebuilt_at = None; + } +} + #[allow(clippy::too_many_arguments)] fn update_world_shadows( video: Res, diff --git a/crates/benilla-assets/src/lib.rs b/crates/benilla-assets/src/lib.rs index 9e48304e..dd1a202c 100644 --- a/crates/benilla-assets/src/lib.rs +++ b/crates/benilla-assets/src/lib.rs @@ -24,7 +24,7 @@ pub mod column_grid; pub mod coords; pub mod materials; mod water_depth; -pub use water_depth::{WaterDepthImage, WaterQuality}; +pub use water_depth::{WaterColourImage, WaterDepthImage, WaterQuality, WaterUniform}; pub mod minimap_grid; mod spatial_cache; pub mod trace; diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs index 68e141e2..a34085d1 100644 --- a/crates/benilla-assets/src/materials.rs +++ b/crates/benilla-assets/src/materials.rs @@ -50,6 +50,8 @@ pub fn register_shaders(app: &mut App) { // (loaded eagerly so `#import benilla::shadow_hook` resolves in terrain/model/static_gx). This is // the ONE place the realtime directional-shadow term lives; the receivers just call into it. load_shader_library!(app, "shaders/shadow_hook.wgsl"); + // MONKEY (enhanced water): the optional water module, `benilla::enhanced_water` (WATER.md). + load_shader_library!(app, "shaders/enhanced_water.wgsl"); } /// The WDL far-band shader's source, for the law tests that live beside the renderer rather than @@ -565,16 +567,28 @@ 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. + /// MONKEY (enhanced water): the water module's own bindings (103-106, `enhanced_water.wgsl`). + /// The opaque world depth, resolved after the main opaque pass (`liquid/scene_depth.rs`). #[texture(103, sample_type = "float", filterable = false, visibility(fragment))] pub scene_depth: Handle, + /// The module's uniform: quality, wave energy, clock, lane, sky rows, celestial body. + #[uniform(104)] + pub water: crate::WaterUniform, + /// The shared light buffer again (the same `Buffer` as `light_buf`), for the module's own + /// view of it: read-only storage may be bound twice in one group. + #[storage(105, read_only, buffer, visibility(fragment))] + pub water_light: Buffer, + /// The opaque scene colour, copied beside the depth (`liquid/scene_depth.rs`): what the + /// refraction looks through. Loaded, never sampled, so no sampler. + #[texture(106, sample_type = "float", filterable = false, visibility(fragment))] + pub scene_colour: Handle, /// The kind's animated frames (`lake_a`/`fast_a`/`ocean_h`), stacked as `2d_array` layers /// (`Rgba8Unorm`, repeat-sampled). RGB near-black; alpha = the ripple/wave → transparency. #[texture(100, dimension = "2d_array", visibility(fragment))] #[sampler(101, visibility(fragment))] pub frames: Handle, /// The per-material constants that pick which *lanes* of the shared light this surface reads. - /// Kind is fixed at creation; quality and the Enhanced sky rows can update at runtime. + /// None of them is a light value; all four are fixed at material creation. /// /// - `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. @@ -594,25 +608,14 @@ 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` = 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). + /// - `y`/`z`/`w` reserved. #[uniform(102)] pub path: Vec4, - /// `x` = fixed Enhanced capture time, `y` = frame count, `z` = scroll flag, `w` = clock enable — + /// `x` = reserved (frame 0), `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/enhanced_water.wgsl b/crates/benilla-assets/src/shaders/enhanced_water.wgsl new file mode 100644 index 00000000..667e7459 --- /dev/null +++ b/crates/benilla-assets/src/shaders/enhanced_water.wgsl @@ -0,0 +1,730 @@ +#define_import_path benilla::enhanced_water + +// ENHANCED WATER - the optional water module (Video options -> Water Quality; see WATER.md). +// +// Credits: original project WarcraftXL (`wxl-experimental-water`, https://github.com/WarcraftXL), +// author iThorgrim. Its author permits reuse of that code in this module 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. +// +// This file is the whole module on the shader side. `liquid.wgsl` (upstream's liquid renderer) +// carries three hooks only: the import, `water_swell` in its vertex stage, and a branch at the top +// of its fragment stage that returns `enhanced_water` when `water_active()` (already fogged). +// Classic never enters this file, and must stay byte-identical to upstream. +// +// It owns its own bindings in the liquid material's group, next to upstream's (90, 100-102): +// 103 the opaque scene depth, resolved after the main opaque pass (`liquid/scene_depth.rs`) +// 104 `WaterParams`, the module's own uniform (`benilla_assets::WaterUniform`) +// 105 the shared global-light buffer again, as the rows this module reads (the same buffer +// upstream binds at 90 - read-only storage may be bound twice) +// 106 the opaque scene COLOUR, copied beside the depth: what the refraction looks through + +#import bevy_pbr::mesh_view_bindings::{view, globals} + +@group(#{MATERIAL_BIND_GROUP}) @binding(103) var scene_depth: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(106) var scene_colour: texture_2d; + +struct WaterParams { + // x = quality (0 Classic, 1 Enhanced, 2 High); y = wave energy (ocean 1.0, ADT inland 0.18, + // WMO pools 0.12); z = the pinned capture time (WOW_CAPTURE_WATER_T); w = clock enable. + mode: vec4, + // x = which renderer (0 ADT MCLQ, 1 WMO exterior, 2 WMO interior); y = ocean; z = fullbright; + // w = this surface may take the WMO INTERIOR fog block (upstream's kind.z). + lane: vec4, + sky_zenith: vec4, // linear RGB, LightIntBand 2 + sky_horizon: vec4, // linear RGB, LightIntBand 6 + celestial: vec4, // xyz toward the visible body; w = 0 sun, 1 white moon +}; +@group(#{MATERIAL_BIND_GROUP}) @binding(104) var water: WaterParams; + +// The shared global light (`lighting::global_light`), rows 0-20 + the point-light table. Mirrors +// the buffer layout the terrain/model shaders read; keep it in step with theirs. +struct WaterLight { + light_ambient: vec4, // 0 + light_diffuse: vec4, // 1 + light_sun: vec4, // 2 xyz = sun TRAVEL dir (to-light = -xyz) + light_spec: vec4, // 3 + fog_color: vec4, // 4 + fog_params: vec4, // 5 + _sh: array, 6>, // 6-11 + _sh_c16: vec4, // 12 + water_river: array, 2>, // 13-14 + water_ocean: array, 2>, // 15-16 + _grade: vec4, // 17 + wmo_fog_color: vec4, // 18 + wmo_fog_params: vec4, // 19 + point_count: vec4, // 20 live count + point-light controls + points: array, 512>, // 21+ position/range, colour/lane pairs +}; +@group(#{MATERIAL_BIND_GROUP}) @binding(105) var water_light: WaterLight; + +// What the fragment stage hands over: the three varyings this module reads. +struct WaterFragment { + clip_position: vec4, + world_position: vec4, + // The authored per-vertex swatch depth (UV1.x): ocean byte/255, about 148 yd at 1.0. + depth: f32, + // Upstream's per-surface room-fog lane (MeshTag bit 30), to pick the same fog block it would. + room_fog: u32, +}; + +// The fog this surface stands in: rgb = the fog colour, w = how much of the surface survives it +// (1 = no fog). The same law as upstream's `apply_fog`, returned as a factor instead of applied, +// because the module must fog ONLY its own surface terms: the refracted bed and the reflected +// scenery come out of the scene copy, which the terrain and model shaders have already fogged +// (fogging them again made shallow water foggier than the dry sand beside it). +fn water_fog(world_pos: vec3, room_fog: u32) -> vec4 { + var colour = water_light.fog_color; + var span = water_light.fog_params.xy; + if water.lane.w > 0.5 && room_fog != 0u { + colour = water_light.wmo_fog_color; + span = water_light.wmo_fog_params.xy; + } + if colour.w <= 0.5 { return vec4(colour.rgb, 1.0); } + let eye_z = -(view.view_from_world * vec4(world_pos, 1.0)).z; + let factor = clamp((span.y - eye_z) / max(span.y - span.x, 0.001), 0.0, 1.0); + return vec4(colour.rgb, factor); +} + +// True where this surface takes the Enhanced path: water (not magma/slime), a tier above +// Classic, and a live scene-depth image (the 1x1 placeholder means "not resolved this frame"). +fn water_active() -> bool { + return water.lane.z < 0.5 && water.mode.x > 0.5 && textureDimensions(scene_depth).x > 1u; +} + +// The module's clock: the shader time, or the pinned capture phase on a deterministic run. +fn water_time() -> f32 { + if water.mode.w == 0.0 { return water.mode.z; } + return globals.time; +} + +// Vertex lift for the ocean's long swell (0 everywhere else). `liquid/waves.rs` mirrors it on +// the CPU for the swimmer bob; the two are one contract. +fn water_swell(xz: vec2, authored_depth: f32) -> f32 { + if water.lane.z < 0.5 && water.lane.y > 0.5 && water.lane.x < 0.5 && water.mode.x > 0.5 { + return water_waves(xz, water_time(), 0.0, 0.0, swell_shore_fade(authored_depth), true).x; + } + return 0.0; +} + +// 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, 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(water.mode.y, 0.0, 1.0); + let tempo = mix(0.4, 1.0, sqrt(energy)); + let inland = water.lane.y < 0.5 || water.lane.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 && water.lane.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); +} +// ── DEPTH LOOK: extinction, refraction, caustics ───────────────────────────────────────────── +// Portions derived from WarcraftXL wxl-experimental-water by iThorgrim, used with permission +// (`shaders/Surface.ps.hlsl`: the view path through the column, per-channel Beer-Lambert +// extinction, the body as a lerp from the scattered colour to what is behind by the transmittance, +// the normal-bent scene copy scaled by the water there is, and the two-layer multiplied caustic +// web; `render/Noise.cpp`: the caustic web as ridged noise raised to a power). Ours differs where +// we have more to go on: the column is the MEASURED scene depth, so an object in the water is seen +// as one, and a bent sample that lands on something in front of the water is refused outright +// instead of kept short. +// +// Per-yard attenuation, R G B. Red goes first, so a deepening sea turns teal, then blue; inland +// water loses blue faster than green and reads green-brown, as a lake over a muddy bed does. +const EXTINCTION_OCEAN: vec3 = vec3(0.55, 0.19, 0.15); +const EXTINCTION_INLAND: vec3 = vec3(0.75, 0.42, 0.58); +const EXTINCTION_MAX_PATH: f32 = 40.0; // yards; past this nothing comes back anyway +const REFRACT_STRENGTH: f32 = 0.30; // screen bend per unit of normal tilt, at 10 yd +const REFRACT_MAX: f32 = 0.018; // cap, in viewport heights (WXL's clamp) +const CAUSTIC_GAIN: f32 = 2.6; +const CAUSTIC_DEPTH: f32 = 6.0; // yards of water the floor web survives +const CAUSTIC_SCALE: f32 = 0.65; // web cells per yard (fine layer) + +// The web itself: animated cell EDGES (the gap between the nearest and second-nearest of a set of +// drifting points), which are thin, connected and curved - the network a wavy surface focuses +// sunlight into. Smooth value noise thresholded into "creases" showed its lattice as blocky crosses +// on the sand (capture r3-caustics), so the pattern is built from points, not a grid of values. +fn caustic_point(cell: vec2) -> vec2 { + return vec2(foam_hash(cell), foam_hash(cell + vec2(37.0, 17.0))); +} + +fn caustic_edges(p: vec2, t: f32) -> f32 { + let cell = floor(p); + let f = fract(p); + var d1 = 8.0; + var d2 = 8.0; + for (var j = -1; j <= 1; j += 1) { + for (var i = -1; i <= 1; i += 1) { + let g = vec2(f32(i), f32(j)); + let h = caustic_point(cell + g); + // Each point orbits its cell on its own phase: the web swims instead of sliding. + let o = 0.5 + 0.38 * sin(vec2(t * 0.83, t * 0.71) + 6.2831853 * h); + let r = g + o - f; + let d = dot(r, r); + if d < d1 { d2 = d1; d1 = d; } else if d < d2 { d2 = d; } + } + } + return sqrt(d2) - sqrt(d1); +} + +// Two webs at incommensurate scales and rates: the fine one draws the lines, the coarse one sets +// where they burn bright, so the pattern never repeats on a visible period (WXL's two layers). +fn caustic_web(xz: vec2, t: f32) -> f32 { + let r = vec2(0.7501 * xz.x - 0.6613 * xz.y, 0.6613 * xz.x + 0.7501 * xz.y) * CAUSTIC_SCALE; + // A low-frequency warp bends the straight cell edges into curves. + let warp = vec2(foam_noise(r * 0.62 + vec2(t * 0.05, 3.1)), + foam_noise(r * 0.62 + vec2(11.7, -t * 0.04))) - vec2(0.5); + let q = r + warp * 1.7; + // A thin bright core and a soft glow around it, as a focused line has. + let e = caustic_edges(q, t); + let fine = (1.0 - smoothstep(0.0, 0.06, e)) + 0.35 * (1.0 - smoothstep(0.0, 0.22, e)); + // The coarse web decides where the fine one burns and where it fades out altogether, so the + // network breaks up instead of tiling the whole bed evenly. + let coarse = 1.0 - smoothstep(0.0, 0.45, caustic_edges(q * 0.41 + vec2(5.3, 1.9), t * 0.77 + 2.0)); + let patchy = smoothstep(0.25, 0.75, foam_noise(r * 0.18 + vec2(t * 0.02, -7.0))); + return fine * (0.28 + 0.72 * coarse) * mix(0.45, 1.0, patchy) * 1.25; +} + +// ── HIGH: screen-space reflection of the scenery ───────────────────────────────────────────── +// Portions derived from WarcraftXL wxl-experimental-water by iThorgrim, used with permission +// (`shaders/Surface.ps.hlsl`: reflect the scene copy along an almost-planar normal carrying only a +// fraction of the waves, masked by the screen edge and by rays that turn back toward the eye). +// WXL takes one probe a fixed distance down the ray; with the scene depth already in hand, High +// marches the ray against it instead, so a bank reflects at the right place, not at a guess. +const SSR_STEPS: i32 = 32; +const SSR_FIRST_YD: f32 = 0.35; // first step; each next one is SSR_GROWTH times longer +const SSR_GROWTH: f32 = 1.20; // 32 steps reach ~600 yd; bisection restores the precision +const SSR_REFINE: i32 = 5; // bisections between the last miss and the hit +const SSR_RIPPLE: f32 = 0.35; // share of the wave normal the reflected ray sees (WXL ssrRipple) + +// The scene's view distance under the point `pos`, and that point's own; x = scene, y = ray, +// z = 1 when `pos` is on screen. Sky pixels report no surface. +fn ssr_probe(pos: vec3, dims: vec2) -> vec4 { + let clip = view.clip_from_world * vec4(pos, 1.0); + if clip.w <= 0.0 { return vec4(0.0); } + let ndc = clip.xyz / clip.w; + if abs(ndc.x) > 1.0 || abs(ndc.y) > 1.0 { return vec4(0.0); } + let uv = ndc.xy * vec2(0.5, -0.5) + vec2(0.5); + let px = clamp(vec2(uv * dims), vec2(0), vec2(dims) - vec2(1)); + let scene = textureLoad(scene_depth, px, 0).r; + if scene <= 0.0 { return vec4(1e9, water_view_distance(ndc.z), 1.0, 0.0); } + return vec4(water_view_distance(scene), water_view_distance(ndc.z), 1.0, 0.0); +} + +// x/y/z = the reflected scene colour (gamma lane), w = how much of it to trust (0 = use the sky). +fn water_ssr(origin: vec3, dir: vec3) -> vec4 { + let dims = vec2(textureDimensions(scene_depth)); + var step_len = SSR_FIRST_YD; + var along = 0.0; + var last_miss = 0.0; + for (var i = 0; i < SSR_STEPS; i += 1) { + along += step_len; + let probe = ssr_probe(origin + dir * along, dims); + if probe.z < 0.5 { break; } // left the screen: nothing to show + let behind = probe.y - probe.x; + if behind > 0.0 && behind < 2.0 + 0.1 * probe.x + step_len { + // Bisect back toward the last miss, so the reflection sits on the surface it hit. + var lo = last_miss; + var hi = along; + for (var k = 0; k < SSR_REFINE; k += 1) { + let mid = 0.5 * (lo + hi); + let m = ssr_probe(origin + dir * mid, dims); + if m.y > m.x { hi = mid; } else { lo = mid; } + } + let clip = view.clip_from_world * vec4(origin + dir * hi, 1.0); + let uv = (clip.xy / clip.w) * vec2(0.5, -0.5) + vec2(0.5); + let px = clamp(vec2(uv * dims), vec2(0), vec2(dims) - vec2(1)); + // Fade toward the screen edges (the top one widest: reflections run off it) and with + // distance, so a missing reflection hands over to the sky instead of cutting off. + let edge = smoothstep(0.0, 0.06, min(uv.x, 1.0 - uv.x)) + * smoothstep(0.0, 0.22, uv.y) * smoothstep(0.0, 0.06, 1.0 - uv.y); + let trust = edge * (1.0 - smoothstep(450.0, 600.0, hi)); + return vec4(textureLoad(scene_colour, px, 0).rgb, trust); + } + last_miss = along; + step_len *= SSR_GROWTH; + } + return vec4(0.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); +} + +fn enhanced_water(in: WaterFragment, 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 = water.lane.y > 0.5 && water.lane.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 = water_time(); // WOW_CAPTURE_WATER_T pins the frozen water phase. + let fog = water_fog(in.world_position.xyz, in.room_fog); + let p = in.world_position.xz; + let energy = clamp(water.mode.y, 0.0, 1.0); + let ocean_mesh = water.lane.y > 0.5 && water.lane.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)))); + // Taken here, where `n` is final and control flow has reconverged (the derivative rule above). + let normal_variance = dot(dpdx(n), dpdx(n)) + dot(dpdy(n), dpdy(n)); + // 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(water.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(water_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(water_light.light_ambient.rgb * 1.35 + water_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(water.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(water.sky_horizon.rgb, water.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); } + + // ── What is behind the surface, and how much of it survives the trip ──────────────────── + // The path is the measured vertical column opened out by the view angle, capped at grazing + // (WXL: ten times the column) and in absolute length. + let ndv_flat = max(abs(to_view.y), 0.10); + let path = min(vertical_depth / ndv_flat, EXTINCTION_MAX_PATH); + let transmit = exp(-select(EXTINCTION_INLAND, EXTINCTION_OCEAN, ocean_mesh) * path); + // The frame behind, bent by the surface normal. The bend is world-anchored (it shrinks with + // distance), scaled by how much water stands there (a film over wet sand bends nothing, or + // the shoreline crawls) and capped. A bent sample that lands on something IN FRONT of the + // water - a hull, a leg, the bank - is refused: the scene depth there says so. + let view_n = (view.view_from_world * vec4(n, 0.0)).xy; + let bend_px = clamp(view_n * vec2(1.0, -1.0) * view.viewport.w + * REFRACT_STRENGTH * (10.0 / max(length(eye_pos), 6.0)) * saturate(path * 0.5), + vec2(-REFRACT_MAX * view.viewport.w), vec2(REFRACT_MAX * view.viewport.w)); + let colour_top = vec2(textureDimensions(scene_colour)) - vec2(1); + let bent = clamp(pixel + vec2(round(bend_px)), vec2(0), colour_top); + let bent_depth = textureLoad(scene_depth, bent, 0).r; + let use_bent = bent_depth < in.clip_position.z; // reverse-Z: smaller = farther than the water + var behind = textureLoad(scene_colour, select(clamp(pixel, vec2(0), colour_top), bent, + use_bent), 0).rgb; + // Caustics on the bed: shallow, sunlit water only; the moon focuses too, faintly. + let bed_world = scene_h.xyz / scene_h.w; + let sun_focus = smoothstep(0.05, 0.45, celestial_dir.y) * select(1.0, 0.12, water.celestial.w > 0.5); + let caustic_fade = saturate(1.0 - vertical_depth / CAUSTIC_DEPTH) * smoothstep(0.02, 0.25, vertical_depth); + if sun_focus * caustic_fade > 0.001 { + // Faded out where a web cell would span only a few pixels, which would alias into shimmer. + let web = caustic_web(bed_world.xz + n.xz * 0.35, t) + * (1.0 - smoothstep(0.12, 0.40, footprint * CAUSTIC_SCALE * 4.0)); + behind *= 1.0 + web * CAUSTIC_GAIN * sun_focus * caustic_fade + * dot(water_light.light_diffuse.rgb, vec3(0.2126, 0.7152, 0.0722)); + } + // Light that reaches the eye through water has been scattered on the way, so it carries the + // water's own tint as well as the colour extinction leaves: without this, yellow sand under a + // clear sea reads olive rather than turquoise. Half the hue is handed to the shallow tint. + let tint_norm = teal_shallow / max(max(teal_shallow.r, teal_shallow.g), teal_shallow.b); + let behind_luma = dot(behind, vec3(0.2126, 0.7152, 0.0722)); + behind = mix(behind, behind_luma * tint_norm * 1.25, select(0.30, 0.45, ocean_mesh)); + // The body: from the scattered colour to what is behind, by what survives the path. + // The scattered body is a surface term and takes the fog; `behind` is already fogged. + rgb = mix(mix(fog.rgb, rgb, fog.w), behind, transmit); + // High: the scenery reflects, not only the sky. The ray leaves along an almost-planar normal + // so the reflection holds together and the waves only ripple it; rays climbing toward the eye + // (a steep look down) have nothing on screen to reflect and keep the sky. + var reflection = mix(fog.rgb, sky, fog.w); // the sky reflection is a surface term + if water.mode.x > 1.5 && reflectivity > 0.1 { + let ssr_n = normalize(mix(vec3(0.0, 1.0, 0.0), n, SSR_RIPPLE)); + let ssr_dir = reflect(-to_view, ssr_n); + if ssr_dir.y > 0.0 { + let hit = water_ssr(in.world_position.xyz, ssr_dir); + // Rays heading back toward the eye find nothing on screen that faces them. + let facing = 1.0 - smoothstep(0.55, 0.9, dot(ssr_dir, to_view)); + reflection = mix(sky, hit.rgb, hit.w * facing); + // Scenery reads a little stronger than Fresnel alone gives at a low angle - the eye + // expects a lake to mirror its banks - but only where a reflection was actually found. + reflectivity = mix(reflectivity, max(reflectivity, 0.42), hit.w * facing); + } + } + rgb = mix(rgb, reflection, reflectivity); + // Everything added from here on (glints, light speculars) is surface light: fog attenuates it. + let fogged_base = rgb; + // 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 spread = 1.0 + 400.0 * normal_variance + 2.0 * smoothstep(30.0, 160.0, length(eye_pos)); + if water.lane.x < 1.5 { + if water.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 += water_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(water_light.point_count.x), 256u); i += 1u) { + let colour = water_light.points[2u * i + 1u]; + if colour.w >= 0.5 && water.lane.x < 1.5 { continue; } + let delta = water_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 = water_light.points[nearest[j] * 2u]; + let colour = water_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 = water_light.light_ambient.rgb + water_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. + // The body now carries its own transmission (above), so the surface is opaque bar the very + // edge, where it hands over to the real frame to keep the waterline free of a seam. + let body_alpha = smoothstep(0.0, 0.18, 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. + rgb = fogged_base + (rgb - fogged_base) * fog.w; + let foam_tint = mix(fog.rgb, mix(vec3(foam_luma), + clamp(illumination, vec3(0.22), vec3(1.0)), 0.5), fog.w); + rgb = (foam_tint * foam_alpha + rgb * body_alpha * (1.0 - foam_alpha)) + / max(alpha, 0.0001); + // Already fogged, term by term (see `water_fog`): the caller must not fog it again. + return vec4(rgb, alpha); +} diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl index fed310ac..e93833e3 100644 --- a/crates/benilla-assets/src/shaders/liquid.wgsl +++ b/crates/benilla-assets/src/shaders/liquid.wgsl @@ -95,26 +95,24 @@ view_transformations::position_world_to_clip, mesh_view_bindings::{view, globals}, } +// MONKEY (enhanced water): the optional water module - see enhanced_water.wgsl and WATER.md. +#import benilla::enhanced_water::{water_active, water_swell, enhanced_water, WaterFragment} @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 = quality: 0 Classic, 1 Enhanced, 2 High (stage 1 shares Enhanced); z = wave energy (0..1); w reserved. + // y/z/w reserved. path: vec4, - // x = fixed Enhanced capture time; y = frame count; z = the SCROLL FLAG (1 only on the nibble-6/7 + // x = reserved (frame 0); 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; @@ -136,8 +134,6 @@ 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; @@ -215,7 +211,6 @@ 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; } @@ -276,13 +271,8 @@ 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; - } + // MONKEY (enhanced water): the ocean's long swell; 0 on Classic and on every other surface. + out.world_position.y += water_swell(out.world_position.xz, in.uv_b.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; @@ -361,444 +351,6 @@ 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 @@ -811,15 +363,19 @@ 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 { + // MONKEY (enhanced water): the optional module takes the whole surface; Classic falls through. + if water_active() { 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); + // Returned as-is: the module fogs its own surface terms (the scene it shows through is + // already fogged). + return enhanced_water( + WaterFragment(in.clip_position, in.world_position, in.depth, in.room_fog), + shallow_enhanced, deep_enhanced); } // Animated frame. For water/ocean this is the DETAIL ripple (RGB ≈ near-black, ALPHA = ripple); diff --git a/crates/benilla-assets/src/water_depth.rs b/crates/benilla-assets/src/water_depth.rs index 7d5cfc71..48255598 100644 --- a/crates/benilla-assets/src/water_depth.rs +++ b/crates/benilla-assets/src/water_depth.rs @@ -1,7 +1,36 @@ //! Shared water settings and image binding, independent of world streaming. use bevy::prelude::*; +use bevy::render::render_resource::ShaderType; -/// 0 = Classic, 1 = Enhanced, 2 = High (currently the same stage-1 effects). +/// The enhanced water module's uniform (binding 104, `WaterParams` in `enhanced_water.wgsl`). +/// Field order is the shader's; change the two together. +#[derive(ShaderType, Clone, Copy, Debug, PartialEq)] +pub struct WaterUniform { + /// x = quality (0 Classic, 1 Enhanced, 2 High); y = wave energy (ocean 1.0, ADT inland 0.18, + /// WMO pools 0.12); z = the pinned capture time; w = clock enable (0 on a deterministic run). + pub mode: Vec4, + /// x = which renderer (0 ADT MCLQ, 1 WMO exterior, 2 WMO interior); y = ocean; z = fullbright. + pub lane: Vec4, + /// Reflection endpoints, linear RGB from the dome's resolved LightIntBand 2 / 6. + pub sky_zenith: Vec4, + pub sky_horizon: Vec4, + /// xyz toward the visible sun by day, the white moon by night; w = 0 sun, 1 moon. + pub celestial: Vec4, +} + +impl Default for WaterUniform { + fn default() -> Self { + Self { + mode: Vec4::new(0.0, 0.0, 0.0, 1.0), + lane: Vec4::ZERO, + sky_zenith: Vec4::ZERO, + sky_horizon: Vec4::ZERO, + celestial: Vec3::Y.extend(0.0), + } + } +} + +/// 0 = Classic, 1 = Enhanced, 2 = High (Enhanced + screen-space reflection of the scenery). #[derive(Resource, Clone, Copy, PartialEq)] pub struct WaterQuality(pub u8); @@ -22,6 +51,29 @@ impl FromWorld for WaterDepthImage { ); image.texture_descriptor.usage = TextureUsages::TEXTURE_BINDING | TextureUsages::RENDER_ATTACHMENT | TextureUsages::COPY_DST; + // Render-only: no CPU copy, so a resize does not allocate (and upload) a screen of zeros. + image.data = None; + Self(world.resource_mut::>().add(image)) + } +} + +/// The world view's opaque COLOUR, copied right after the main opaque pass (the frame as it stood +/// before any water drew), for the enhanced water's refraction. `Rgba16Float`: a load and a store, +/// never filtered, so the copy is exact whatever the view target's own format is. +#[derive(Resource, Clone)] +pub struct WaterColourImage(pub Handle); + +impl FromWorld for WaterColourImage { + fn from_world(world: &mut World) -> Self { + use bevy::{asset::RenderAssetUsages, render::render_resource::*}; + let mut image = Image::new_fill( + Extent3d::default(), TextureDimension::D2, &[0u8; 8], + TextureFormat::Rgba16Float, RenderAssetUsages::default(), + ); + image.texture_descriptor.usage = TextureUsages::TEXTURE_BINDING + | TextureUsages::RENDER_ATTACHMENT | TextureUsages::COPY_DST; + // Render-only: no CPU copy, so a resize does not allocate (and upload) a screen of zeros. + image.data = None; Self(world.resource_mut::>().add(image)) } } diff --git a/crates/benilla-world/src/liquid/mod.rs b/crates/benilla-world/src/liquid/mod.rs index fc7c27fd..17a6dd76 100644 --- a/crates/benilla-world/src/liquid/mod.rs +++ b/crates/benilla-world/src/liquid/mod.rs @@ -181,6 +181,7 @@ impl Plugin for LiquidPlugin { app.add_plugins(MaterialPlugin::::default()) .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() diff --git a/crates/benilla-world/src/liquid/query.rs b/crates/benilla-world/src/liquid/query.rs index b3857977..2d7467ca 100644 --- a/crates/benilla-world/src/liquid/query.rs +++ b/crates/benilla-world/src/liquid/query.rs @@ -486,10 +486,10 @@ 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** + /// The shader's vertex-displacement arm (`enhanced_water.wgsl` (`water_swell`)) runs on exactly one combination: + /// not fullbright (`water.lane.z < 0.5`), the ocean swatch (`water.lane.y > 0.5`), and the ADT MCLQ + /// renderer (`water.lane.x < 0.5`) — the three static halves of that gate, which is what this + /// answers. The fourth, `water.mode.x > 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. diff --git a/crates/benilla-world/src/liquid/scene_depth.rs b/crates/benilla-world/src/liquid/scene_depth.rs index da29073f..15acf484 100644 --- a/crates/benilla-world/src/liquid/scene_depth.rs +++ b/crates/benilla-world/src/liquid/scene_depth.rs @@ -1,6 +1,9 @@ //! 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. +//! +//! MONKEY (enhanced water: refraction): the same node also copies the opaque COLOUR, the frame +//! as it stood before any water drew, into `WaterColourImage` for the module to look through. use bevy::prelude::*; use bevy::camera::visibility::VisibleEntities; use bevy::core_pipeline::core_3d::graph::{Core3d, Node3d}; @@ -14,8 +17,8 @@ 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}; +use bevy::render::view::{ViewDepthTexture, ViewTarget}; +use benilla_assets::{WaterColourImage, WaterDepthImage, WaterQuality, materials::LiquidMaterial}; #[derive(Component, Clone, ExtractComponent)] struct WaterView; @@ -23,6 +26,7 @@ struct WaterView; #[derive(Resource, Clone, ExtractResource)] struct DepthSource { image: Handle, + colour: Handle, active: bool, } @@ -34,13 +38,15 @@ struct WaterOverride(Option); impl Plugin for WaterDepthPlugin { fn build(&self, app: &mut App) { - app.init_resource::().init_resource::(); + app.init_resource::().init_resource::() + .init_resource::(); let value = std::env::var("WOW_WATER").ok() .and_then(|v| v.parse::().ok()).filter(|v| *v <= 2); if let Some(value) = value { app.insert_resource(WaterQuality(value)); } let image = app.world().resource::().0.clone(); + let colour = app.world().resource::().0.clone(); app.insert_resource(WaterOverride(value)) - .insert_resource(DepthSource { image, active: false }) + .insert_resource(DepthSource { image, colour, active: false }) .add_plugins((ExtractComponentPlugin::::default(), ExtractResourcePlugin::::default())) // Camera sizes and the per-view visible list are final by Last, before extraction. @@ -84,17 +90,20 @@ fn update_water_depth( 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(); + let tier = quality.0.min(2) as f32; + let changed: Vec<_> = materials.iter().filter(|(_, m)| { + let water = &m.extension.water; + water.lane.z < 0.5 + && (water.mode.x != tier + || (quality.0 > 0 && (water.sky_zenith != zenith + || water.sky_horizon != horizon || water.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; + let water = &mut materials.get_mut(id).unwrap().extension.water; + water.mode.x = tier; + water.sky_zenith = zenith; + water.sky_horizon = horizon; + water.celestial = celestial; } source.active = false; for (entity, camera, mut camera3d, visible) in &mut cameras { @@ -106,12 +115,18 @@ fn update_water_depth( if quality.0 == 0 || !camera.is_active { continue; } source.active = visible.get(std::any::TypeId::of::()).iter().any(|entity| { liquids.get(*entity).ok().and_then(|m| materials.get(&m.0)) - .is_some_and(|m| m.extension.kind.x < 0.5) + .is_some_and(|m| m.extension.water.lane.z < 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()); + let stale = |handle: &Handle| images.get(handle) + .is_some_and(|image| image.size() != size); + if stale(&source.image) || stale(&source.colour) { + for handle in [source.image.clone(), source.colour.clone()] { + if let Some(image) = images.get_mut(&handle) { + if image.size() != size { image.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(); @@ -126,6 +141,8 @@ fn update_water_depth( struct DepthPipelines { layouts: [BindGroupLayoutDescriptor; 2], pipelines: [CachedRenderPipelineId; 2], + colour_layout: BindGroupLayoutDescriptor, + colour_pipeline: CachedRenderPipelineId, } #[derive(Resource)] @@ -150,7 +167,24 @@ fn init_pipeline(mut commands: Commands, shader: Res, cache: Res(&self, _: &mut RenderGraphContext, context: &mut RenderContext<'w>, - (_, depth): QueryItem<'w, '_, Self::ViewQuery>, world: &'w World) -> Result<(), NodeRunError> { + (_, depth, target): QueryItem<'w, '_, Self::ViewQuery>, world: &'w World) -> Result<(), NodeRunError> { let source = world.resource::(); if !source.active { return Ok(()); } let images = world.resource::>(); @@ -171,6 +205,14 @@ impl ViewNode for DepthNode { let cache = world.resource::(); let index = usize::from(depth.texture.sample_count() > 1); let Some(pipeline) = cache.get_render_pipeline(pipelines.pipelines[index]) else { return Ok(()) }; + // Both copies or neither: a depth copy without its colour would show the module a real bed + // through a black frame (shallows flash black while the colour pipeline compiles). With + // neither, the cleared depth reads as sky, the water is opaque, and nothing shows. + let Some(colour_pipeline) = cache.get_render_pipeline(pipelines.colour_pipeline) else { + return Ok(()); + }; + let Some(colour) = images.get(&source.colour) else { return Ok(()) }; + if colour.texture.size() != depth.texture.size() { return Ok(()); } let device = world.resource::(); let bind = device.create_bind_group("water depth source", &cache.get_bind_group_layout(&pipelines.layouts[index]), @@ -185,13 +227,32 @@ impl ViewNode for DepthNode { pass.set_pipeline(pipeline); pass.set_bind_group(0, &bind, &[]); pass.draw(0..3, 0..1); + drop(pass); + + // The opaque colour, for the refraction. `main_texture_view` is the single-sample texture + // the opaque pass resolved into, so no MSAA variant is needed here. + let bind = device.create_bind_group("water colour source", + &cache.get_bind_group_layout(&pipelines.colour_layout), + &BindGroupEntries::single(target.main_texture_view())); + let mut pass = context.command_encoder().begin_render_pass(&RenderPassDescriptor { + label: Some("water opaque colour copy"), + color_attachments: &[Some(RenderPassColorAttachment { + view: &colour.texture_view, resolve_target: None, depth_slice: None, + ops: Operations { load: LoadOp::Clear(Default::default()), store: StoreOp::Store }, + })], ..default() + }); + pass.set_pipeline(colour_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 +#ifdef COLOUR +@group(0) @binding(0) var source: texture_2d; +#else ifdef MULTISAMPLED @group(0) @binding(0) var depth: texture_depth_multisampled_2d; #else @group(0) @binding(0) var depth: texture_depth_2d; @@ -201,9 +262,15 @@ const RESOLVE_SHADER: &str = r#" let y = f32(i & 2u); return vec4(x * 2.0 - 1.0, y * 2.0 - 1.0, 0.0, 1.0); } +#ifdef COLOUR +@fragment fn colour(@builtin(position) p: vec4) -> @location(0) vec4 { + return textureLoad(source, vec2(p.xy), 0); +} +#else @fragment fn fragment(@builtin(position) p: vec4) -> @location(0) f32 { return textureLoad(depth, vec2(p.xy), 0); } +#endif "#; #[cfg(test)] diff --git a/crates/benilla-world/src/liquid/surface.rs b/crates/benilla-world/src/liquid/surface.rs index eae315a8..3cd26394 100644 --- a/crates/benilla-world/src/liquid/surface.rs +++ b/crates/benilla-world/src/liquid/surface.rs @@ -512,6 +512,7 @@ pub(super) fn setup_liquid( mut images: ResMut>, mut materials: ResMut>, water_depth: Res, + water_colour: Res, water_quality: Res, ) { let (Some(_config), Some(mut world_assets)) = (config, world_assets) else { @@ -525,6 +526,7 @@ pub(super) fn setup_liquid( std::env::var("WOW_CAPTURE_WATER_T").ok().and_then(|v| v.parse::().ok()) .filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0) } else { 0.0 }; + let deterministic = crate::dev_state::deterministic_run(); let mut assets = LiquidAssets::default(); for &(kind, dir, stem, count) in FRAME_SETS { let Some((frames, frame_count)) = @@ -608,33 +610,40 @@ pub(super) fn setup_liquid( WATER_SHININESS, ), // Which of the reference's three liquid renderers `liquid.wgsl` runs. - // `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 + 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 // 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( - capture_time, + 0.0, frame_count as f32, if scroll { 1.0 } else { 0.0 }, - if crate::dev_state::deterministic_run() { - 0.0 - } else { - 1.0 - }, + if deterministic { 0.0 } else { 1.0 }, ), - sky_zenith: Vec4::ZERO, - sky_horizon: Vec4::ZERO, - celestial: Vec3::Y.extend(0.0), + // MONKEY (enhanced water): the module's uniform; the sky rows and the + // quality follow at runtime (`liquid/scene_depth.rs`). + water: benilla_assets::WaterUniform { + mode: Vec4::new( + 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 }, + capture_time, + if deterministic { 0.0 } else { 1.0 }, + ), + lane: Vec4::new( + path.shader_id(), + if kind == LiquidKind::Ocean { 1.0 } else { 0.0 }, + if kind.is_fullbright() { 1.0 } else { 0.0 }, + if path.interior_fog() { 1.0 } else { 0.0 }, + ), + ..default() + }, + water_light: world_assets.shared_light.clone(), + scene_colour: water_colour.0.clone(), light_buf: world_assets.shared_light.clone(), }, }); diff --git a/crates/benilla-world/src/liquid/waves.rs b/crates/benilla-world/src/liquid/waves.rs index a9b9fc85..aad4d247 100644 --- a/crates/benilla-world/src/liquid/waves.rs +++ b/crates/benilla-world/src/liquid/waves.rs @@ -1,7 +1,7 @@ //! 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), +//! (`benilla-assets/src/shaders/enhanced_water.wgsl`, `water_swell`, called from `liquid.wgsl`'s vertex stage), //! 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 @@ -16,7 +16,7 @@ //! //! **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 +//! (`enhanced_water.wgsl`), 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. //! @@ -25,7 +25,7 @@ use bevy::math::Vec2; -/// The long-swell component table — `liquid.wgsl:365-375`'s `WATER_WAVES[0..2]`, verbatim: +/// The long-swell component table — `enhanced_water.wgsl`'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. /// @@ -36,17 +36,17 @@ pub const LONG_SWELL: [[f32; 4]; 2] = [ [0.80, 12.8, 0.140, 1.7], ]; -/// Gravity in yd/s², `liquid.wgsl:398` — deep-water dispersion, `c = sqrt(g/k)`. +/// Gravity in yd/s², `enhanced_water.wgsl` — 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 +/// `6.2831853` exactly as the shader spells it (`enhanced_water.wgsl`). 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 +/// The ADT **ocean**'s wave energy — `liquid/surface.rs` packs `water.mode.y = 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 +/// (`enhanced_water.wgsl`), 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; @@ -58,11 +58,11 @@ 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`). + /// its normal from exactly this as `normalize(vec3(-grad.x, 1, -grad.y))` (`enhanced_water.wgsl`). pub grad: Vec2, } -/// `liquid.wgsl:377-380`'s `swell_shore_fade`: the authored ocean depth `V` (byte/255, ≈148 yd at +/// `enhanced_water.wgsl`'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 @@ -74,12 +74,12 @@ pub fn swell_shore_fade(depth_v: f32) -> f32 { } /// 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: +/// 0, shore, true)` (`enhanced_water.wgsl`) under the vertex arm's own arguments: /// -/// * `distance = 0` and `footprint = 0` — the vertex call passes both (`liquid.wgsl:281`), so the +/// * `distance = 0` and `footprint = 0` — the vertex call passes both (`enhanced_water.wgsl`), 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 +/// the negation of the shader's `inland` (`enhanced_water.wgsl`), 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. /// @@ -110,8 +110,8 @@ pub fn swell_height(world_xz: Vec2, time: f32, wave_energy: f32, shallow_fade: f /// **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 +/// The shader reads `water_time()` (`enhanced_water.wgsl`): `globals.time`, or the +/// frozen capture pin `water.mode.z` when the clock enable `water.mode.w` is 0 on an Enhanced /// material. Both halves of that are reproduced here: /// /// * `globals.time` is **`Time::elapsed_secs_wrapped`**, not `elapsed_secs` — bevy_render's @@ -120,7 +120,7 @@ pub fn swell_height(world_xz: Vec2, time: f32, wave_energy: f32, shallow_fade: f /// 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 +/// `liquid/surface.rs` reads it into `water.mode.z`; `water.mode.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() { @@ -155,7 +155,7 @@ fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 { mod tests { use super::*; - /// **The mirror pin.** The table against the literal numbers in `liquid.wgsl:365-375`, and the + /// **The mirror pin.** The table against the literal numbers in `enhanced_water.wgsl`, 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. @@ -164,11 +164,11 @@ mod tests { 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]" + "enhanced_water.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"); + assert_eq!(GRAVITY_YD, 10.72, "enhanced_water.wgsl gravity"); + assert_eq!(TWO_PI, 6.2831853, "enhanced_water.wgsl k = 6.2831853/wavelength"); + assert_eq!(OCEAN_WAVE_ENERGY, 1.0, "surface.rs water.mode.y 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}"); diff --git a/crates/benilla-world/src/water_fx/bob.rs b/crates/benilla-world/src/water_fx/bob.rs index 5a2fa6c6..4255aae8 100644 --- a/crates/benilla-world/src/water_fx/bob.rs +++ b/crates/benilla-world/src/water_fx/bob.rs @@ -107,7 +107,7 @@ struct SwimBob { 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 θ. + // (`enhanced_water.wgsl`), 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)