MONKEY: water module split, depth look, High reflections, volumetric fog
Water (Enhanced/High; Classic stays byte-identical to upstream): - the enhanced water now lives in its own WGSL library, benilla::enhanced_water (enhanced_water.wgsl), with its own bindings 103-106; upstream liquid.wgsl keeps three small hooks only - depth look: per-channel extinction over the real path through the water, refraction of an opaque-colour copy bent by the waves (samples landing on anything in front of the water are refused via the scene depth), caustic web on sunlit shallow beds - High: screen-space reflection of the scenery (bisected march against the scene depth) - the module fogs its own surface terms only (the refracted/reflected scene is already fogged); depth+colour copies run both-or-neither - credits: portions derived from WarcraftXL wxl-experimental-water by iThorgrim, used with permission (WATER.md) Volumetric fog (Advanced Graphics -> Volumetric Fog: Off/Low/High, cvar volumetricFog, env WOW_VOLFOG): own fullscreen pass after the main pass, near-field haze converging on the zone fog colour, denser at dawn and in weather, faint indoors, plus sun/moon light shafts from the shadow map. Tests: addon-harness oracle fixtures no longer require unix symlinks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
18914f02f6
commit
2e486e3612
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
})
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowCharacterShadows" inherits="OptionsCheckboxRowTemplate">
|
||||
<!-- MONKEY (volumetric fog): live numeric tiers use the existing localised dropdown. -->
|
||||
<Frame name="$parentRowVolumetricFog" inherits="OptionsDropdownRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowWaterQuality" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowWaterQuality" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "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" },
|
||||
})
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowCharacterShadows" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowVolumetricFog" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowVolumetricFog" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "characterShadows",
|
||||
BENILLA_ADVGFX.characterShadows,
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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=<name>`) 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=<seconds>: fixed Enhanced water phase (read once, default 0).
|
||||
// Compare 0 and 1.5; Classic and non-water animation stay frozen.
|
||||
// Above Lakeshire's broken docks, looking across Lake Everstill and its rocky shores.
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("<Frame name=\"$parentRowVolumetricFog\"").nth(1).unwrap();
|
||||
let on_load = row.split("<OnLoad>").nth(1).unwrap().split("</OnLoad>").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::<String>("return self.title").unwrap(), title);
|
||||
assert!(s.eval::<bool>("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::<String>(&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::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[3]").unwrap(), "RowVolumetricFog");
|
||||
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowVolumetricFogLabel:GetText()")).unwrap(), title);
|
||||
assert_eq!(s.eval::<String>(&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::<String>(&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::<usize>("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)").unwrap(), 17);
|
||||
// MONKEY (volumetric fog): account for the atmosphere row after water.
|
||||
assert_eq!(s.eval::<usize>("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)").unwrap(), 18);
|
||||
assert_eq!(s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[2]").unwrap(), "RowWaterQuality");
|
||||
assert_eq!(s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[15]").unwrap(), "RowLavaGlow");
|
||||
assert_eq!(s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[16]").unwrap(), "RowLavaGlow");
|
||||
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowWaterQualityDropdownText:GetText()")).unwrap(), labels[1]);
|
||||
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowLavaGlowLabel:GetText()")).unwrap(), lava_label);
|
||||
assert!(s.eval::<bool>("return BENILLA_TOOLTIP_WATER_QUALITY == BENILLA_ADVGFX.tips.WATER_QUALITY and BENILLA_TOOLTIP_LAVA_GLOW == BENILLA_ADVGFX.tips.LAVA_GLOW").unwrap());
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<u8>, 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::<u8>().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::<f32>().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::<FogView>::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::<AssetPlugin>() {
|
||||
return;
|
||||
}
|
||||
let shader = app
|
||||
.world_mut()
|
||||
.resource_mut::<Assets<Shader>>()
|
||||
.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::<SpecializedRenderPipelines<FogPipeline>>()
|
||||
.add_systems(RenderStartup, init_pipeline)
|
||||
.add_systems(Render, prepare_pipelines.in_set(RenderSystems::Prepare))
|
||||
.add_render_graph_node::<ViewNodeRunner<FogNode>>(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<VideoConfig>,
|
||||
override_value: Res<FogOverride>,
|
||||
gx: Option<Res<StaticGx>>,
|
||||
mut lane: ResMut<WorldLane>,
|
||||
mut seen: Local<Option<u64>>,
|
||||
) {
|
||||
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<VideoConfig>,
|
||||
override_value: Res<FogOverride>,
|
||||
lighting: Res<WowLighting>,
|
||||
clock: Res<WorldTime>,
|
||||
weather: Res<WeatherState>,
|
||||
interior: Res<CameraInteriorClaim>,
|
||||
mut cameras: Query<(Entity, &Camera, &mut Camera3d), With<WorldCamera>>,
|
||||
suns: Query<(Entity, &DirectionalLight), With<ShadowSun>>,
|
||||
) {
|
||||
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::<VolumetricLight>();
|
||||
}
|
||||
}
|
||||
for (entity, camera, mut camera3d) in &mut cameras {
|
||||
if !enabled || !camera.is_active {
|
||||
commands.entity(entity).remove::<FogView>();
|
||||
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<Shader>);
|
||||
#[derive(Resource)]
|
||||
struct FogPipeline {
|
||||
layouts: [BindGroupLayoutDescriptor; 2],
|
||||
shader: Handle<Shader>,
|
||||
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<RenderDevice>,
|
||||
shader: Res<FogShader>,
|
||||
fullscreen: Res<FullscreenShader>,
|
||||
) {
|
||||
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::<ViewUniform>(true),
|
||||
uniform_buffer::<GpuLights>(true),
|
||||
texture_2d_array(TextureSampleType::Depth),
|
||||
sampler(SamplerBindingType::Comparison),
|
||||
uniform_buffer::<FogView>(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<PipelineCache>,
|
||||
pipeline: Res<FogPipeline>,
|
||||
mut specialized: ResMut<SpecializedRenderPipelines<FogPipeline>>,
|
||||
views: Query<(Entity, &ViewTarget, &Msaa), With<FogView>>,
|
||||
) {
|
||||
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::<PipelineCache>();
|
||||
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::<ViewUniforms>().uniforms.binding(),
|
||||
world.resource::<LightMeta>().view_gpu_lights.binding(),
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
let settings = world.resource::<FogPipeline>();
|
||||
let device = context.render_device();
|
||||
let mut uniform = UniformBuffer::from(*fog);
|
||||
uniform.write_buffer(device, world.resource::<RenderQueue>());
|
||||
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<f32>;
|
||||
#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<uniform> view: View;
|
||||
@group(0) @binding(3) var<uniform> 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<f32>, sun_strength: vec4<f32>, direction_steps: vec4<f32> }
|
||||
@group(0) @binding(6) var<uniform> 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>) -> 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<f32>) -> @location(0) vec4<f32> {
|
||||
let xy = vec2<i32>(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::<VideoConfig>()
|
||||
.init_resource::<StaticGx>()
|
||||
.init_resource::<WorldLane>()
|
||||
.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::<WorldLane>().is_changed());
|
||||
app.world_mut().resource_mut::<FogOverride>().0 = Some(1);
|
||||
app.world_mut().run_schedule(Update);
|
||||
assert!(app.world().resource_ref::<WorldLane>().is_changed());
|
||||
app.world_mut().clear_trackers();
|
||||
app.world_mut().run_schedule(Update);
|
||||
assert!(!app.world().resource_ref::<WorldLane>().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::<VideoConfig>()
|
||||
.init_resource::<WowLighting>()
|
||||
.init_resource::<WorldTime>()
|
||||
.init_resource::<WeatherState>()
|
||||
.init_resource::<CameraInteriorClaim>()
|
||||
.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::<FogView>(camera).unwrap();
|
||||
assert_eq!(low.direction_steps.w, 24.0);
|
||||
assert!(app.world().get::<FogView>(preview).is_none());
|
||||
assert!(app.world().get::<VolumetricLight>(sun).is_some());
|
||||
app.world_mut().resource_mut::<VideoConfig>().volumetric_fog = 2;
|
||||
app.world_mut()
|
||||
.get_mut::<DirectionalLight>(sun)
|
||||
.unwrap()
|
||||
.shadows_enabled = false;
|
||||
app.update();
|
||||
let high = *app.world().get::<FogView>(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::<VolumetricLight>(sun).is_none());
|
||||
assert_eq!(
|
||||
app.world()
|
||||
.get::<DirectionalLight>(sun)
|
||||
.unwrap()
|
||||
.illuminance,
|
||||
123.0
|
||||
);
|
||||
app.world_mut().get_mut::<Camera>(camera).unwrap().is_active = false;
|
||||
app.update();
|
||||
assert!(app.world().get::<FogView>(camera).is_none());
|
||||
app.world_mut().get_mut::<Camera>(camera).unwrap().is_active = true;
|
||||
app.world_mut().resource_mut::<FogOverride>().0 = Some(0);
|
||||
app.update();
|
||||
assert!(app.world().get::<FogView>(camera).is_none());
|
||||
app.world_mut().resource_mut::<FogOverride>().0 = None;
|
||||
app.update();
|
||||
assert!(app.world().get::<FogView>(camera).is_some());
|
||||
}
|
||||
}
|
||||
@@ -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<Entity>,
|
||||
static_mesh: Option<Handle<Mesh>>,
|
||||
@@ -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<VideoConfig>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<StandardMaterial, LiquidExt>;
|
||||
/// match the field order.
|
||||
#[derive(Asset, AsBindGroup, Clone, TypePath)]
|
||||
pub struct LiquidExt {
|
||||
/// Opaque world depth resolved after both retained and Bevy geometry. No sampler needed.
|
||||
/// 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<Image>,
|
||||
/// 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<Image>,
|
||||
/// The kind's animated frames (`lake_a`/`fast_a`/`ocean_h`), stacked as `2d_array` layers
|
||||
/// (`Rgba8Unorm`, repeat-sampled). RGB near-black; alpha = the ripple/wave → transparency.
|
||||
#[texture(100, dimension = "2d_array", visibility(fragment))]
|
||||
#[sampler(101, visibility(fragment))]
|
||||
pub frames: Handle<Image>,
|
||||
/// The per-material constants that pick which *lanes* of the shared light this surface reads.
|
||||
/// 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
|
||||
|
||||
@@ -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<f32>;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(106) var scene_colour: texture_2d<f32>;
|
||||
|
||||
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<f32>,
|
||||
// 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<f32>,
|
||||
sky_zenith: vec4<f32>, // linear RGB, LightIntBand 2
|
||||
sky_horizon: vec4<f32>, // linear RGB, LightIntBand 6
|
||||
celestial: vec4<f32>, // xyz toward the visible body; w = 0 sun, 1 white moon
|
||||
};
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(104) var<uniform> 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<f32>, // 0
|
||||
light_diffuse: vec4<f32>, // 1
|
||||
light_sun: vec4<f32>, // 2 xyz = sun TRAVEL dir (to-light = -xyz)
|
||||
light_spec: vec4<f32>, // 3
|
||||
fog_color: vec4<f32>, // 4
|
||||
fog_params: vec4<f32>, // 5
|
||||
_sh: array<vec4<f32>, 6>, // 6-11
|
||||
_sh_c16: vec4<f32>, // 12
|
||||
water_river: array<vec4<f32>, 2>, // 13-14
|
||||
water_ocean: array<vec4<f32>, 2>, // 15-16
|
||||
_grade: vec4<f32>, // 17
|
||||
wmo_fog_color: vec4<f32>, // 18
|
||||
wmo_fog_params: vec4<f32>, // 19
|
||||
point_count: vec4<f32>, // 20 live count + point-light controls
|
||||
points: array<vec4<f32>, 512>, // 21+ position/range, colour/lane pairs
|
||||
};
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(105) var<storage, read> water_light: WaterLight;
|
||||
|
||||
// What the fragment stage hands over: the three varyings this module reads.
|
||||
struct WaterFragment {
|
||||
clip_position: vec4<f32>,
|
||||
world_position: vec4<f32>,
|
||||
// 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<f32>, room_fog: u32) -> vec4<f32> {
|
||||
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<f32>(colour.rgb, 1.0); }
|
||||
let eye_z = -(view.view_from_world * vec4<f32>(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<f32>(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<f32>, 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<vec4<f32>, 8> = array<vec4<f32>, 8>(
|
||||
// direction, wavelength, amplitude, phase offset
|
||||
vec4<f32>(0.35, 18.0, 0.200, 0.0),
|
||||
vec4<f32>(0.80, 12.8, 0.140, 1.7),
|
||||
vec4<f32>(-0.18, 9.5, 0.090, 3.1),
|
||||
vec4<f32>(0.52, 4.8, 0.055, 0.8),
|
||||
vec4<f32>(1.10, 3.3, 0.033, 2.4),
|
||||
vec4<f32>(-0.45, 2.6, 0.018, 4.6),
|
||||
vec4<f32>(0.15, 1.3, 0.006, 1.2),
|
||||
vec4<f32>(0.95, 0.85, 0.003, 3.8),
|
||||
);
|
||||
|
||||
// ── SHORE BREAK (item 1) + FOAM LEVELS: the tuned constants, all in one place ──
|
||||
//
|
||||
// The shore break is a SEPARATE band from `WATER_WAVES` and touches neither the long swell that
|
||||
// moves ocean vertices nor the inland colour profile. It exists only in the shoaling zone —
|
||||
// roughly 0.35 to 3.5 yd of water over the bed — and it is normals + foam, never displacement.
|
||||
//
|
||||
// **Its phase coordinate is the vertical depth itself**, not a world ruler. That is the whole
|
||||
// trick: an iso-depth line IS the shoreline's own contour, so crests run exactly parallel to the
|
||||
// beach on a curved coast, a headland and a cove alike, with no per-chunk seam and no phase jump
|
||||
// when the depth gradient rotates. The world direction only ever enters through the NORMAL, where
|
||||
// it arrives as the analytic derivative `d(phase)/d(depth) · grad(depth)` — so the picture and the
|
||||
// lighting agree by construction. Where the bed is flat enough that the gradient is unusable the
|
||||
// direction falls back to the wind (`SHORE_WIND_DIR`, the primary swell's bearing), which is also
|
||||
// the only place the fallback can show, because a flat bed has no shoaling band to draw.
|
||||
//
|
||||
// `shore_g(d) = (d + A·(1 − e^(−d/B))) / L` is a smooth, strictly increasing crest count. Its
|
||||
// derivative `(1 + (A/B)·e^(−d/B)) / L` is the local wavenumber in DEPTH space, so with A/B = 1.3
|
||||
// the crest spacing runs 0.5 yd of depth at the waterline out to ~1.07 yd offshore: the wave
|
||||
// shortens by better than 2× as it shoals, which is the shoaling law's visible half. Because the
|
||||
// coordinate is depth, that spacing turns into a WORLD wavelength through the bed slope — so a
|
||||
// gentle beach gets long rollers and a steep one short ones, and the number of visible lines
|
||||
// approaching the sand stays about four either way.
|
||||
const SHORE_LAMBDA_D: f32 = 1.15; // yards of depth per crest, offshore
|
||||
const SHORE_COMPRESS: f32 = 1.56; // A — shoaling compression (A/B = 1.3 ⇒ 2.3× at the edge)
|
||||
const SHORE_COMPRESS_D: f32 = 1.2; // B — yards of depth the compression decays over
|
||||
const SHORE_PERIOD: f32 = 3.6; // seconds between arrivals; the swash runs at 2× this
|
||||
const SHORE_TILT: f32 = 0.09; // peak crest steepness (tan of the tilt), slope-independent
|
||||
const SHORE_WARP_A: f32 = 0.22; // yards of depth — coarse crest wander (never ruler-straight)
|
||||
const SHORE_WARP_B: f32 = 0.10; // yards of depth — finer segmentation of the same crests
|
||||
const SHORE_WIND_DIR: f32 = 0.35; // = WATER_WAVES[0].x, the primary swell bearing
|
||||
|
||||
// Foam alphas. The owner rejected BOTH a thick icing sheet and straight stripes before this, so
|
||||
// every one of these is gated behind a noise breakup and a depth window; the numbers are the
|
||||
// ceiling a fully-lit, fully-broken crest can reach, not what a typical pixel gets.
|
||||
const FOAM_WET_EDGE: f32 = 0.35; // the faint wet line where water meets anything solid
|
||||
const FOAM_SWASH: f32 = 0.62; // the sheet running up the sand and fading
|
||||
const FOAM_CREST: f32 = 0.88; // the white front of the last wave or two, and its lace
|
||||
const FOAM_MAX: f32 = 0.90; // hard ceiling on the sum
|
||||
|
||||
fn swell_shore_fade(depth: f32) -> f32 {
|
||||
// Ocean V = byte/255, about 148 yd at 1.0: fade in over ~0.15..3.7 yd.
|
||||
return smoothstep(0.001, 0.025, depth);
|
||||
}
|
||||
|
||||
fn water_waves(p: vec2<f32>, t: f32, distance: f32, footprint: f32,
|
||||
shore: f32, long_only: bool) -> vec3<f32> {
|
||||
let energy = clamp(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<f32>(0.06, 0.025);
|
||||
}
|
||||
var result = vec3<f32>(0.0);
|
||||
for (var i = 0u; i < 8u; i += 1u) {
|
||||
if long_only && i >= 2u { break; }
|
||||
if inland && i < 3u { continue; }
|
||||
let wave = WATER_WAVES[i];
|
||||
let direction = vec2<f32>(cos(wave.x), sin(wave.x));
|
||||
let k = 6.2831853 / wave.y;
|
||||
let speed = sqrt(10.72 / k); // gravity in yd/s^2; phase speed in yd/s
|
||||
let phase = k * (dot(direction, ripple_p) - speed * tempo * t) + wave.w;
|
||||
// Suppress unresolved waves before Nyquist, and remove fine ripples beyond 35 yd.
|
||||
var fade = 1.0 - smoothstep(0.10, 0.45, footprint / wave.y);
|
||||
if i >= 6u { fade *= 1.0 - smoothstep(10.0, 35.0, distance); }
|
||||
if i < 2u { fade *= shore; }
|
||||
let amplitude = wave.z * energy * fade;
|
||||
result += vec3<f32>(amplitude * sin(phase), amplitude * k * cos(phase) * direction);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
fn foam_hash(p_in: vec2<f32>) -> f32 {
|
||||
// MONKEY (foam noise): world coordinates here are ~1e4 yd and are scaled further before they
|
||||
// arrive, so `p * 0.1031` sat near 2e3 where an f32 keeps only ~13 fraction bits - the hash
|
||||
// degenerated into a visible regular tile grid inside the foam (capture it11-water-beach-top).
|
||||
// Wrap the LATTICE CELL to a 512 period first: exact for integers, invisible at 512 cells, and it
|
||||
// gives the hash its full precision back.
|
||||
let p = p_in - 512.0 * floor(p_in / 512.0);
|
||||
let q = fract(vec3<f32>(p.x, p.y, p.x) * 0.1031);
|
||||
let r = q + dot(q, q.yzx + 33.33);
|
||||
return fract((r.x + r.y) * r.z);
|
||||
}
|
||||
|
||||
fn foam_value_noise(p: vec2<f32>) -> f32 {
|
||||
let cell = floor(p);
|
||||
let f = fract(p);
|
||||
let u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
|
||||
return mix(mix(foam_hash(cell), foam_hash(cell + vec2<f32>(1.0, 0.0)), u.x),
|
||||
mix(foam_hash(cell + vec2<f32>(0.0, 1.0)),
|
||||
foam_hash(cell + vec2<f32>(1.0, 1.0)), u.x), u.y);
|
||||
}
|
||||
|
||||
// MONKEY (foam noise): thresholded VALUE noise shows its lattice - the foam and crest masks came out
|
||||
// as boxy slabs with stair-stepped edges aligned to the world axes (capture it9/it10-water-beach-top).
|
||||
// Two samples on differently ROTATED and scaled lattices, averaged, have no shared axis, so the
|
||||
// thresholded shapes turn into irregular blobs. Same range (0..1), same call sites, twice the taps.
|
||||
fn foam_noise(p: vec2<f32>) -> f32 {
|
||||
let a = vec2<f32>(0.7986 * p.x - 0.6018 * p.y, 0.6018 * p.x + 0.7986 * p.y);
|
||||
let q = p * 1.37 + vec2<f32>(17.3, -9.1);
|
||||
let b = vec2<f32>(0.3584 * q.x + 0.9336 * q.y, -0.9336 * q.x + 0.3584 * q.y);
|
||||
// Averaging narrows the distribution; re-expand it so the existing thresholds keep their cut.
|
||||
return clamp((0.5 * (foam_value_noise(a) + foam_value_noise(b)) - 0.5) * 1.4 + 0.5, 0.0, 1.0);
|
||||
}
|
||||
// ── 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<f32> = vec3<f32>(0.55, 0.19, 0.15);
|
||||
const EXTINCTION_INLAND: vec3<f32> = vec3<f32>(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<f32>) -> vec2<f32> {
|
||||
return vec2<f32>(foam_hash(cell), foam_hash(cell + vec2<f32>(37.0, 17.0)));
|
||||
}
|
||||
|
||||
fn caustic_edges(p: vec2<f32>, 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>(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<f32>(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<f32>, t: f32) -> f32 {
|
||||
let r = vec2<f32>(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<f32>(foam_noise(r * 0.62 + vec2<f32>(t * 0.05, 3.1)),
|
||||
foam_noise(r * 0.62 + vec2<f32>(11.7, -t * 0.04))) - vec2<f32>(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<f32>(5.3, 1.9), t * 0.77 + 2.0));
|
||||
let patchy = smoothstep(0.25, 0.75, foam_noise(r * 0.18 + vec2<f32>(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<f32>, dims: vec2<f32>) -> vec4<f32> {
|
||||
let clip = view.clip_from_world * vec4<f32>(pos, 1.0);
|
||||
if clip.w <= 0.0 { return vec4<f32>(0.0); }
|
||||
let ndc = clip.xyz / clip.w;
|
||||
if abs(ndc.x) > 1.0 || abs(ndc.y) > 1.0 { return vec4<f32>(0.0); }
|
||||
let uv = ndc.xy * vec2<f32>(0.5, -0.5) + vec2<f32>(0.5);
|
||||
let px = clamp(vec2<i32>(uv * dims), vec2<i32>(0), vec2<i32>(dims) - vec2<i32>(1));
|
||||
let scene = textureLoad(scene_depth, px, 0).r;
|
||||
if scene <= 0.0 { return vec4<f32>(1e9, water_view_distance(ndc.z), 1.0, 0.0); }
|
||||
return vec4<f32>(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<f32>, dir: vec3<f32>) -> vec4<f32> {
|
||||
let dims = vec2<f32>(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<f32>(origin + dir * hi, 1.0);
|
||||
let uv = (clip.xy / clip.w) * vec2<f32>(0.5, -0.5) + vec2<f32>(0.5);
|
||||
let px = clamp(vec2<i32>(uv * dims), vec2<i32>(0), vec2<i32>(dims) - vec2<i32>(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<f32>(textureLoad(scene_colour, px, 0).rgb, trust);
|
||||
}
|
||||
last_miss = along;
|
||||
step_len *= SSR_GROWTH;
|
||||
}
|
||||
return vec4<f32>(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<f32>, deep: vec4<f32>) -> vec4<f32> {
|
||||
let pixel = clamp(vec2<i32>(in.clip_position.xy), vec2<i32>(0),
|
||||
vec2<i32>(textureDimensions(scene_depth)) - vec2<i32>(1));
|
||||
let own_depth = textureLoad(scene_depth, pixel, 0).r;
|
||||
// MONKEY (bed clutter): grass blades and reeds standing in a stream are in the opaque depth, so
|
||||
// every blade read as "something solid at the surface" - each one grew a white contact-foam
|
||||
// outline and a paler, less-absorbed tint than the bed around it (owner screenshot, Stonefield
|
||||
// Farm). Anything THIN is not a shore: take the FARTHEST of nine taps (reverse-Z: the smallest)
|
||||
// over a ~0.7 % of the view height ring, so a blade a few pixels wide resolves to the bed behind
|
||||
// it, while a bank, a rock or a hull - wider than the ring - is untouched bar a few pixels.
|
||||
let ring = max(i32(view.viewport.w * 0.0065), 2);
|
||||
let top = vec2<i32>(textureDimensions(scene_depth)) - vec2<i32>(1);
|
||||
var far_depth = own_depth;
|
||||
var offs = array<vec2<i32>, 8>(
|
||||
vec2<i32>(1, 1), vec2<i32>(1, 0), vec2<i32>(-1, 1), vec2<i32>(0, 1),
|
||||
vec2<i32>(-1, -1), vec2<i32>(-1, 0), vec2<i32>(1, -1), vec2<i32>(0, -1));
|
||||
for (var k = 0; k < 8; k += 1) {
|
||||
let reach = select(ring, 2 * ring, (k & 1) == 1);
|
||||
let tap = textureLoad(scene_depth,
|
||||
clamp(pixel + offs[k] * reach, vec2<i32>(0), top), 0).r;
|
||||
// Reverse-Z: farther is SMALLER; 0 is the sky, which is not a bed.
|
||||
if tap < far_depth && tap > 0.0 { far_depth = tap; }
|
||||
}
|
||||
// The open sea keeps its own pixel: its shore train's phase IS this depth, and sand has no reeds.
|
||||
let sea = 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<f32>(in.world_position.xyz, 1.0)).xyz;
|
||||
// Convert the two eye-Z distances to a distance ALONG the pixel's view ray.
|
||||
let ray_cos = max(abs(normalize(eye_pos).z), 0.001);
|
||||
let thickness = min(max(water_view_distance(bed_depth)
|
||||
- water_view_distance(in.clip_position.z), 0.0) / ray_cos, 1000.0);
|
||||
// Reconstruct the opaque scene point, then measure height below the surface.
|
||||
// Ray thickness is only the contact measure: opacity must not follow eye-Z.
|
||||
let ndc_xy = ((in.clip_position.xy - view.viewport.xy) / view.viewport.zw)
|
||||
* vec2<f32>(2.0, -2.0) + vec2<f32>(-1.0, 1.0);
|
||||
let scene_h = view.world_from_clip * vec4<f32>(ndc_xy, bed_depth, 1.0);
|
||||
var vertical_depth = 1000.0;
|
||||
if bed_depth > 0.0 && abs(scene_h.w) > 1e-7 {
|
||||
vertical_depth = clamp(in.world_position.y - scene_h.y / scene_h.w, 0.0, 1000.0);
|
||||
}
|
||||
let t = 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<f32>(0.0, 0.0);
|
||||
if abs(jac_det) > 1e-12 {
|
||||
depth_grad = vec2<f32>(
|
||||
(ddx_depth * ddy_p.y - ddy_depth * ddx_p.y) / jac_det,
|
||||
(ddy_depth * ddx_p.x - ddx_depth * ddy_p.x) / jac_det,
|
||||
);
|
||||
}
|
||||
let bed_slope = length(depth_grad);
|
||||
// Flat bed, or the depth field broke across a silhouette (the ratio blows up there): fall back
|
||||
// to the wind. A flat bed has no shoaling band to draw, so the fallback is mostly a guard.
|
||||
var offshore = vec2<f32>(cos(SHORE_WIND_DIR), sin(SHORE_WIND_DIR));
|
||||
if bed_slope > 0.02 && bed_slope < 12.0 {
|
||||
offshore = depth_grad / bed_slope;
|
||||
}
|
||||
|
||||
// ── The shore break: shoaling trains riding the bathymetry (see the constants block) ────
|
||||
// Domain-warped in DEPTH units, so the crests wander and segment along the beach instead of
|
||||
// running as ruler-straight bands.
|
||||
let shore_warp =
|
||||
SHORE_WARP_A * (2.0 * foam_noise(p * 0.075 + t * vec2<f32>(0.010, -0.007)) - 1.0)
|
||||
+ SHORE_WARP_B * (2.0 * foam_noise(p * 0.21 - t * vec2<f32>(0.006, 0.009)) - 1.0);
|
||||
let dq = max(vertical_depth + shore_warp, 0.0);
|
||||
let shore_g = (dq + SHORE_COMPRESS * (1.0 - exp(-dq / SHORE_COMPRESS_D))) / SHORE_LAMBDA_D;
|
||||
// `+ t/period` with a crest-count that RISES with depth ⇒ a crest of fixed phase slides to
|
||||
// shallower water as time runs: the train travels shoreward.
|
||||
let shore_phase = 6.2831853 * (shore_g + t / SHORE_PERIOD);
|
||||
let shore_phase2 = 6.2831853 * (1.9 * shore_g + t / (SHORE_PERIOD * 0.62)) + 2.1;
|
||||
// Steepness rises as it shoals (3.5 → 0.75 yd), collapses into the break below ~0.35 yd, and
|
||||
// is gone past the band's offshore edge.
|
||||
let shoal = smoothstep(3.5, 0.75, vertical_depth);
|
||||
let shore_collapse = smoothstep(0.12, 0.38, vertical_depth);
|
||||
let shore_offshore_fade = 1.0 - smoothstep(2.4, 3.6, vertical_depth);
|
||||
// MONKEY (surf on wrecks): the shore train reads the SCENE depth, and a sunken boat, a pier
|
||||
// foot or a rock shelf is shallow scene depth in the middle of deep water - so the whole surf
|
||||
// (crests, lace, swash) was painted over the hull of a wreck off Longshore. A shore is where
|
||||
// the SEA BED is shallow. The mesh carries the authored bed depth (`in.depth`, byte/255 of about
|
||||
// 148 yd): where the bed lies well below what the pixel sees, the pixel is an OBJECT, and an
|
||||
// object gets the thin wet-edge line only. The byte is a FLOOR (1.72 per yd), so on a real beach the
|
||||
// authored bed is never deeper than the scene by more than interpolation error: allow 0.5 yd.
|
||||
let bed_yd = clamp(in.depth, 0.0, 1.0) * 148.0;
|
||||
let on_bed = 1.0 - smoothstep(0.5, 1.0, bed_yd - vertical_depth);
|
||||
let shore_gain = select(0.0, shoal * shore_collapse * shore_offshore_fade * on_bed, ocean_mesh)
|
||||
* energy;
|
||||
// Steepness is set DIRECTLY — the tangent of the crest tilt — rather than through an amplitude
|
||||
// and a wavenumber, so a gentle beach and a steep one roll with the same visible strength
|
||||
// instead of one washing out and the other exploding.
|
||||
let shore_grad = SHORE_TILT * shore_gain
|
||||
* (cos(shore_phase) + 0.45 * cos(shore_phase2)) * offshore;
|
||||
|
||||
let shore = select(1.0, swell_shore_fade(in.depth), ocean_mesh);
|
||||
let wave = water_waves(p, t, length(eye_pos), footprint, shore, false);
|
||||
// One surface gradient: the procedural bands and the shore break.
|
||||
let surf_grad = wave.yz + shore_grad;
|
||||
var n = normalize(vec3<f32>(-surf_grad.x, 1.0, -surf_grad.y));
|
||||
if dot(n, to_view) < 0.0 { n = -n; }
|
||||
|
||||
// Far water settles into a calm sky sheet.
|
||||
n = normalize(mix(n, vec3<f32>(0.0, sign(n.y), 0.0),
|
||||
0.6 * smoothstep(60.0, 120.0, length(eye_pos))));
|
||||
// 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<f32>(0.12, 0.42, 0.39), shallow.rgb, 0.18);
|
||||
// MONKEY (water body): the zone deep row alone renders the open sea and lake middles a muddy
|
||||
// grey-brown (Westfall ocean, Loch Modan) - it was authored to sit UNDER the reference ripple
|
||||
// sheet, not to be a lit body colour. Pull it 70 % toward the zone zenith sky (deep water takes
|
||||
// its colour from the sky it scatters) and keep it blue-dominant, so the body reads as water by
|
||||
// day, follows dusk and night through the same sky row, and still differs zone to zone.
|
||||
let zenith_gamma = pow(max(water.sky_zenith.rgb, vec3<f32>(0.0)), vec3<f32>(1.0 / 2.2));
|
||||
let deep_sky = mix(deep.rgb, zenith_gamma * 1.1, 0.7);
|
||||
let deep_body = vec3<f32>(min(deep_sky.r, deep_sky.g * 0.85), deep_sky.g,
|
||||
max(deep_sky.b, deep_sky.g * 1.12));
|
||||
var body = mix(teal_shallow, deep_body, smoothstep(0.0, 3.5, vertical_depth));
|
||||
if !ocean_mesh {
|
||||
let clear_row = mix(shallow.rgb, vec3<f32>(0.20, 0.40, 0.36), 0.35);
|
||||
let clear_tint = vec3<f32>(min(clear_row.r, clear_row.g * 0.72), clear_row.g,
|
||||
clamp(clear_row.b, clear_row.g * 0.82, clear_row.g * 1.15));
|
||||
let inland_row = mix(deep.rgb, zenith_gamma * 1.1, 0.25);
|
||||
let inland_deep = vec3<f32>(min(inland_row.r, inland_row.g * 0.72), inland_row.g,
|
||||
clamp(inland_row.b, inland_row.g * 0.82, inland_row.g * 1.15));
|
||||
body = mix(clear_tint, inland_deep, smoothstep(0.0, 5.0, vertical_depth));
|
||||
}
|
||||
let to_light = -normalize(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<f32>(0.0), vec3<f32>(1.0));
|
||||
var rgb = body * lighting;
|
||||
// MONKEY (shore waves): the shore train is shown mainly as a CONTINUOUS crest brightening, not as
|
||||
// a normal tilt. Its direction comes from the screen-space gradient of the bed depth, and the
|
||||
// terrain is flat triangles, so that direction jumps at every triangle edge: at SHORE_TILT 0.30
|
||||
// the highlights broke into blocky rectangular shards with staircase edges (capture
|
||||
// it9-water-beach-top). The PHASE is a function of the depth itself and is continuous, so a term
|
||||
// driven by the phase alone cannot facet. The tilt stays at 0.09 for a little specular life.
|
||||
let shore_crest = pow(max(sin(shore_phase), 0.0), 2.0)
|
||||
+ 0.45 * pow(max(sin(shore_phase2), 0.0), 2.0);
|
||||
rgb += shore_gain * shore_crest * 0.13 * lighting * vec3<f32>(0.72, 0.95, 0.90);
|
||||
if !ocean_mesh {
|
||||
// Keep the zone's green-blue absorption under warm dusk illumination.
|
||||
rgb = body * dot(lighting, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
}
|
||||
let celestial_dir = normalize(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<f32>(0.06, 0.30, 0.19) * transmission * lighting;
|
||||
let reflection_n = normalize(mix(vec3<f32>(0.0, sign(n.y), 0.0), n, 0.45));
|
||||
let reflected = reflect(-to_view, reflection_n);
|
||||
let sky_linear = mix(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<f32>(0.0)), vec3<f32>(1.0 / 2.4)) - 0.055,
|
||||
sky_linear > vec3<f32>(0.0031308));
|
||||
var reflectivity = mix(0.06, 0.88, pow(1.0 - max(dot(n, to_view), 0.0), 4.0));
|
||||
if !ocean_mesh { reflectivity = min(reflectivity, 0.55); }
|
||||
|
||||
// ── 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<f32>(n, 0.0)).xy;
|
||||
let bend_px = clamp(view_n * vec2<f32>(1.0, -1.0) * view.viewport.w
|
||||
* REFRACT_STRENGTH * (10.0 / max(length(eye_pos), 6.0)) * saturate(path * 0.5),
|
||||
vec2<f32>(-REFRACT_MAX * view.viewport.w), vec2<f32>(REFRACT_MAX * view.viewport.w));
|
||||
let colour_top = vec2<i32>(textureDimensions(scene_colour)) - vec2<i32>(1);
|
||||
let bent = clamp(pixel + vec2<i32>(round(bend_px)), vec2<i32>(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<i32>(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<f32>(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<f32>(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<f32>(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<f32>(0.80, 0.88, 1.0) * 0.45
|
||||
* (pow(ndoth, 400.0 / spread) / sqrt(spread)
|
||||
+ 0.25 * pow(ndoth, 60.0 / sqrt(spread)) / sqrt(spread))
|
||||
* smoothstep(0.0, 0.1391731, celestial_dir.y);
|
||||
} else {
|
||||
rgb += 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<u32, 4>(256u, 256u, 256u, 256u);
|
||||
var distances = array<f32, 4>(900.0, 900.0, 900.0, 900.0);
|
||||
for (var i = 0u; i < min(u32(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<f32>(0.025, -0.018))
|
||||
+ 0.3 * foam_noise(p * 1.7 - t * vec2<f32>(0.014, 0.021));
|
||||
let breakup = smoothstep(0.40, 0.72, noise);
|
||||
// The cached derivatives from the top of the function — same expression as before, one tap.
|
||||
let depth_gradient = length(vec2<f32>(ddx_depth, ddy_depth))
|
||||
/ max(length(vec2<f32>(length(ddx_p), length(ddy_p))), 0.001);
|
||||
let wall_suppression = mix(1.0, 0.3, smoothstep(0.8, 3.0, depth_gradient));
|
||||
let contact = smoothstep(0.0, 0.025, thickness)
|
||||
* (1.0 - smoothstep(0.055, 0.15, thickness));
|
||||
let contact_alpha = FOAM_WET_EDGE * contact * wall_suppression * breakup;
|
||||
// MONKEY (beach foam, third pass). The owner called the previous surf ugly: it was the crest
|
||||
// line chopped into DASHES by a breakup noise, plus swash "crescents" stamped from an 8 yd patch
|
||||
// grid - rows of white blobs. Surf is not dashes. A breaking wave is (1) a thin, nearly
|
||||
// continuous bright FRONT, (2) a LACE of foam left behind it that thins out and dissolves, and
|
||||
// (3) a sheet that runs up the sand after each arrival and fizzles. All three are driven by the
|
||||
// shore train's own phase, so the foam sits on the wave the normals show.
|
||||
//
|
||||
// `surf_u` is the fraction of a wave spacing BEHIND the front (the phase rises with depth, so
|
||||
// behind = seaward): 0 at the front, which sits just ahead of the crest the normals draw.
|
||||
let surf_u = fract(shore_g + t / SHORE_PERIOD - 0.20);
|
||||
// Web-like lace: ridged noise (bright along the zero set of two noise fields), two octaves.
|
||||
let lace_a = 1.0 - abs(2.0 * foam_noise(p * 1.25 + t * vec2<f32>(0.030, -0.020)) - 1.0);
|
||||
let lace_b = 1.0 - abs(2.0 * foam_noise(p * 3.30 - t * vec2<f32>(0.020, 0.035)) - 1.0);
|
||||
let lace = 0.62 * lace_a + 0.38 * lace_b;
|
||||
// The lace dissolves with age: the threshold climbs from "most of it" to "only the ridges".
|
||||
let dissolve = mix(0.42, 0.93, smoothstep(0.02, 0.60, surf_u));
|
||||
let lace_mask = smoothstep(dissolve, dissolve + 0.16, lace);
|
||||
// The front itself: crisp on its shoreward side, solid for a few percent of a spacing.
|
||||
let front = smoothstep(0.0, 0.018, surf_u) * (1.0 - smoothstep(0.035, 0.11, surf_u));
|
||||
let trail = smoothstep(0.0, 0.03, surf_u) * (1.0 - smoothstep(0.25, 0.70, surf_u));
|
||||
// Strength wanders along the beach, but never to nothing - no gaps, no dashes.
|
||||
let along = mix(0.55, 1.0, foam_noise(p * 0.055 + t * vec2<f32>(0.008, 0.005)));
|
||||
let break_zone = smoothstep(0.10, 0.30, vertical_depth)
|
||||
* (1.0 - smoothstep(1.3, 2.6, vertical_depth)) * on_bed;
|
||||
let crest_alpha = select(0.0,
|
||||
FOAM_CREST * break_zone * along * max(front, 0.85 * trail * lace_mask), ocean_mesh)
|
||||
* wall_suppression * energy;
|
||||
|
||||
// The swash: what is left of each wave runs up the last hand of water and fizzles. Its clock
|
||||
// is the front's arrival at the break (0.22 yd of water): 0 when it lands, 1 as the next does.
|
||||
let land_g = (0.22 + SHORE_COMPRESS * (1.0 - exp(-0.22 / SHORE_COMPRESS_D))) / SHORE_LAMBDA_D;
|
||||
let swash_age = fract(land_g + t / SHORE_PERIOD - 0.20 + 0.10 * (along - 0.75));
|
||||
let swash_life = smoothstep(0.0, 0.06, swash_age) * (1.0 - smoothstep(0.30, 0.95, swash_age));
|
||||
let swash_band = (1.0 - smoothstep(0.10, 0.34, vertical_depth + 0.5 * shore_warp)) * on_bed;
|
||||
let swash_thin = mix(0.30, 0.90, smoothstep(0.05, 0.85, swash_age));
|
||||
let swash_lace = smoothstep(swash_thin, swash_thin + 0.18, lace);
|
||||
// The very lip of the water keeps a thin bright line while the sheet is alive.
|
||||
let lip = 1.0 - smoothstep(0.015, 0.07, vertical_depth);
|
||||
let arcs_alpha = select(0.0,
|
||||
FOAM_SWASH * swash_life * swash_band * max(swash_lace, 0.8 * lip), ocean_mesh)
|
||||
* wall_suppression * energy;
|
||||
|
||||
let foam = min(FOAM_MAX, contact_alpha + arcs_alpha + crest_alpha);
|
||||
let illumination = water_light.light_ambient.rgb + water_light.light_diffuse.rgb;
|
||||
let foam_luma = clamp(dot(illumination, vec3<f32>(0.2126, 0.7152, 0.0722)), 0.22, 1.0);
|
||||
// Matte foam composites on top of reflection with its own coverage.
|
||||
// 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<f32>(foam_luma),
|
||||
clamp(illumination, vec3<f32>(0.22), vec3<f32>(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<f32>(rgb, alpha);
|
||||
}
|
||||
@@ -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<f32>;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(101) var frames_samp: sampler;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(103) var scene_depth: texture_2d<f32>;
|
||||
|
||||
struct LiquidParams {
|
||||
// x = fullbright (magma/slime); y = ocean swatch; z = interior fog; w = sun-sheen shininess.
|
||||
kind: vec4<f32>,
|
||||
// x = WHICH RENDERER (see `LiquidPath`): 0 = ADT MCLQ, 1 = WMO exterior, 2 = WMO interior.
|
||||
// y = quality: 0 Classic, 1 Enhanced, 2 High (stage 1 shares Enhanced); z = wave energy (0..1); w reserved.
|
||||
// y/z/w reserved.
|
||||
path: vec4<f32>,
|
||||
// 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<f32>,
|
||||
sky_zenith: vec4<f32>, // linear RGB, LightIntBand 2
|
||||
sky_horizon: vec4<f32>, // linear RGB, LightIntBand 6
|
||||
celestial: vec4<f32>, // xyz toward visible body; w = 0 sun, 1 white moon
|
||||
};
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(102) var<uniform> w: LiquidParams;
|
||||
|
||||
@@ -136,8 +134,6 @@ struct WowLight {
|
||||
_grade: vec4<f32>, // 17
|
||||
wmo_fog_color: vec4<f32>, // 18 rgb = INTERIOR fog (block 2); w = enable
|
||||
wmo_fog_params: vec4<f32>, // 19 x = start yd; y = end yd
|
||||
point_count: vec4<f32>, // 20 live count + point-light controls
|
||||
points: array<vec4<f32>, 512>, // 21+ position/range, colour/lane pairs
|
||||
};
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> wow_light: WowLight;
|
||||
|
||||
@@ -215,7 +211,6 @@ fn sun_sheen(world_normal: vec3<f32>, world_pos: vec3<f32>) -> vec3<f32> {
|
||||
// fallout (uniform re-uploads, bind-group rebuilds, whole-population `AssetChanged` arming)
|
||||
// measured 0.28 cpu_ms/frame at the SW pin (2026-08-18 bracket).
|
||||
fn anim_time() -> f32 {
|
||||
if w.kind.x < 0.5 && w.path.y > 0.5 && w.anim.w == 0.0 { return w.anim.x; }
|
||||
return w.anim.w * globals.time;
|
||||
}
|
||||
|
||||
@@ -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<f32>(in.position, 1.0));
|
||||
// Only mesh-resolvable long swell moves ocean vertices. Authored shallow depth pins beaches.
|
||||
if w.kind.x < 0.5 && w.kind.y > 0.5 && w.path.x < 0.5 && w.path.y > 0.5 {
|
||||
// The swell band is the owner-approved one:
|
||||
// the explicit zero keeps this call byte-identical to what it has always been.
|
||||
out.world_position.y += water_waves(out.world_position.xz, anim_time(), 0.0, 0.0,
|
||||
swell_shore_fade(in.uv_b.x), true).x;
|
||||
}
|
||||
// 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<f32>, deep: vec4<f32>, v: f32, ocean: bool) -> vec4<f
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced is entirely analytic: height and its exact x/z derivatives, in yards.
|
||||
// Direction is radians from world +X toward +Z; phase speed follows deep-water dispersion.
|
||||
// Two mesh-resolvable long waves sum to at most 0.34 yd before energy/shore attenuation.
|
||||
const WATER_WAVES: array<vec4<f32>, 8> = array<vec4<f32>, 8>(
|
||||
// direction, wavelength, amplitude, phase offset
|
||||
vec4<f32>(0.35, 18.0, 0.200, 0.0),
|
||||
vec4<f32>(0.80, 12.8, 0.140, 1.7),
|
||||
vec4<f32>(-0.18, 9.5, 0.090, 3.1),
|
||||
vec4<f32>(0.52, 4.8, 0.055, 0.8),
|
||||
vec4<f32>(1.10, 3.3, 0.033, 2.4),
|
||||
vec4<f32>(-0.45, 2.6, 0.018, 4.6),
|
||||
vec4<f32>(0.15, 1.3, 0.006, 1.2),
|
||||
vec4<f32>(0.95, 0.85, 0.003, 3.8),
|
||||
);
|
||||
|
||||
// ── SHORE BREAK (item 1) + FOAM LEVELS: the tuned constants, all in one place ──
|
||||
//
|
||||
// The shore break is a SEPARATE band from `WATER_WAVES` and touches neither the long swell that
|
||||
// moves ocean vertices nor the inland colour profile. It exists only in the shoaling zone —
|
||||
// roughly 0.35 to 3.5 yd of water over the bed — and it is normals + foam, never displacement.
|
||||
//
|
||||
// **Its phase coordinate is the vertical depth itself**, not a world ruler. That is the whole
|
||||
// trick: an iso-depth line IS the shoreline's own contour, so crests run exactly parallel to the
|
||||
// beach on a curved coast, a headland and a cove alike, with no per-chunk seam and no phase jump
|
||||
// when the depth gradient rotates. The world direction only ever enters through the NORMAL, where
|
||||
// it arrives as the analytic derivative `d(phase)/d(depth) · grad(depth)` — so the picture and the
|
||||
// lighting agree by construction. Where the bed is flat enough that the gradient is unusable the
|
||||
// direction falls back to the wind (`SHORE_WIND_DIR`, the primary swell's bearing), which is also
|
||||
// the only place the fallback can show, because a flat bed has no shoaling band to draw.
|
||||
//
|
||||
// `shore_g(d) = (d + A·(1 − e^(−d/B))) / L` is a smooth, strictly increasing crest count. Its
|
||||
// derivative `(1 + (A/B)·e^(−d/B)) / L` is the local wavenumber in DEPTH space, so with A/B = 1.3
|
||||
// the crest spacing runs 0.5 yd of depth at the waterline out to ~1.07 yd offshore: the wave
|
||||
// shortens by better than 2× as it shoals, which is the shoaling law's visible half. Because the
|
||||
// coordinate is depth, that spacing turns into a WORLD wavelength through the bed slope — so a
|
||||
// gentle beach gets long rollers and a steep one short ones, and the number of visible lines
|
||||
// approaching the sand stays about four either way.
|
||||
const SHORE_LAMBDA_D: f32 = 1.15; // yards of depth per crest, offshore
|
||||
const SHORE_COMPRESS: f32 = 1.56; // A — shoaling compression (A/B = 1.3 ⇒ 2.3× at the edge)
|
||||
const SHORE_COMPRESS_D: f32 = 1.2; // B — yards of depth the compression decays over
|
||||
const SHORE_PERIOD: f32 = 3.6; // seconds between arrivals; the swash runs at 2× this
|
||||
const SHORE_TILT: f32 = 0.09; // peak crest steepness (tan of the tilt), slope-independent
|
||||
const SHORE_WARP_A: f32 = 0.22; // yards of depth — coarse crest wander (never ruler-straight)
|
||||
const SHORE_WARP_B: f32 = 0.10; // yards of depth — finer segmentation of the same crests
|
||||
const SHORE_WIND_DIR: f32 = 0.35; // = WATER_WAVES[0].x, the primary swell bearing
|
||||
|
||||
// Foam alphas. The owner rejected BOTH a thick icing sheet and straight stripes before this, so
|
||||
// every one of these is gated behind a noise breakup and a depth window; the numbers are the
|
||||
// ceiling a fully-lit, fully-broken crest can reach, not what a typical pixel gets.
|
||||
const FOAM_WET_EDGE: f32 = 0.35; // the faint wet line where water meets anything solid
|
||||
const FOAM_SWASH: f32 = 0.62; // the sheet running up the sand and fading
|
||||
const FOAM_CREST: f32 = 0.88; // the white front of the last wave or two, and its lace
|
||||
const FOAM_MAX: f32 = 0.90; // hard ceiling on the sum
|
||||
|
||||
fn swell_shore_fade(depth: f32) -> f32 {
|
||||
// Ocean V = byte/255, about 148 yd at 1.0: fade in over ~0.15..3.7 yd.
|
||||
return smoothstep(0.001, 0.025, depth);
|
||||
}
|
||||
|
||||
fn water_waves(p: vec2<f32>, t: f32, distance: f32, footprint: f32,
|
||||
shore: f32, long_only: bool) -> vec3<f32> {
|
||||
let energy = clamp(w.path.z, 0.0, 1.0);
|
||||
let tempo = mix(0.4, 1.0, sqrt(energy));
|
||||
let inland = w.kind.y < 0.5 || w.path.x > 0.5;
|
||||
// Inland ADT water drifts gently (a constant vector: a rigid translation, never a shear).
|
||||
// WMO pools have no drift at all.
|
||||
var ripple_p = p;
|
||||
if inland && w.path.x < 0.5 {
|
||||
ripple_p -= t * vec2<f32>(0.06, 0.025);
|
||||
}
|
||||
var result = vec3<f32>(0.0);
|
||||
for (var i = 0u; i < 8u; i += 1u) {
|
||||
if long_only && i >= 2u { break; }
|
||||
if inland && i < 3u { continue; }
|
||||
let wave = WATER_WAVES[i];
|
||||
let direction = vec2<f32>(cos(wave.x), sin(wave.x));
|
||||
let k = 6.2831853 / wave.y;
|
||||
let speed = sqrt(10.72 / k); // gravity in yd/s^2; phase speed in yd/s
|
||||
let phase = k * (dot(direction, ripple_p) - speed * tempo * t) + wave.w;
|
||||
// Suppress unresolved waves before Nyquist, and remove fine ripples beyond 35 yd.
|
||||
var fade = 1.0 - smoothstep(0.10, 0.45, footprint / wave.y);
|
||||
if i >= 6u { fade *= 1.0 - smoothstep(10.0, 35.0, distance); }
|
||||
if i < 2u { fade *= shore; }
|
||||
let amplitude = wave.z * energy * fade;
|
||||
result += vec3<f32>(amplitude * sin(phase), amplitude * k * cos(phase) * direction);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
fn foam_hash(p_in: vec2<f32>) -> f32 {
|
||||
// MONKEY (foam noise): world coordinates here are ~1e4 yd and are scaled further before they
|
||||
// arrive, so `p * 0.1031` sat near 2e3 where an f32 keeps only ~13 fraction bits - the hash
|
||||
// degenerated into a visible regular tile grid inside the foam (capture it11-water-beach-top).
|
||||
// Wrap the LATTICE CELL to a 512 period first: exact for integers, invisible at 512 cells, and it
|
||||
// gives the hash its full precision back.
|
||||
let p = p_in - 512.0 * floor(p_in / 512.0);
|
||||
let q = fract(vec3<f32>(p.x, p.y, p.x) * 0.1031);
|
||||
let r = q + dot(q, q.yzx + 33.33);
|
||||
return fract((r.x + r.y) * r.z);
|
||||
}
|
||||
|
||||
fn foam_value_noise(p: vec2<f32>) -> f32 {
|
||||
let cell = floor(p);
|
||||
let f = fract(p);
|
||||
let u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
|
||||
return mix(mix(foam_hash(cell), foam_hash(cell + vec2<f32>(1.0, 0.0)), u.x),
|
||||
mix(foam_hash(cell + vec2<f32>(0.0, 1.0)),
|
||||
foam_hash(cell + vec2<f32>(1.0, 1.0)), u.x), u.y);
|
||||
}
|
||||
|
||||
// MONKEY (foam noise): thresholded VALUE noise shows its lattice - the foam and crest masks came out
|
||||
// as boxy slabs with stair-stepped edges aligned to the world axes (capture it9/it10-water-beach-top).
|
||||
// Two samples on differently ROTATED and scaled lattices, averaged, have no shared axis, so the
|
||||
// thresholded shapes turn into irregular blobs. Same range (0..1), same call sites, twice the taps.
|
||||
fn foam_noise(p: vec2<f32>) -> f32 {
|
||||
let a = vec2<f32>(0.7986 * p.x - 0.6018 * p.y, 0.6018 * p.x + 0.7986 * p.y);
|
||||
let q = p * 1.37 + vec2<f32>(17.3, -9.1);
|
||||
let b = vec2<f32>(0.3584 * q.x + 0.9336 * q.y, -0.9336 * q.x + 0.3584 * q.y);
|
||||
// Averaging narrows the distribution; re-expand it so the existing thresholds keep their cut.
|
||||
return clamp((0.5 * (foam_value_noise(a) + foam_value_noise(b)) - 0.5) * 1.4 + 0.5, 0.0, 1.0);
|
||||
}
|
||||
// Reverse-Z perspective: valid for both finite and infinite far planes. For z_view = -distance,
|
||||
// depth = -P22 + P32 / distance. Do not use a forward-Z near/far approximation.
|
||||
fn water_view_distance(depth: f32) -> f32 {
|
||||
return view.clip_from_view[3][2] / max(depth + view.clip_from_view[2][2], 1e-7);
|
||||
}
|
||||
|
||||
// == ENHANCED WATER (the optional water module; see WATER.md at the repo root) ==================
|
||||
// Design reference and credit: the WarcraftXL project's `wxl-experimental-water` module for the
|
||||
// 1.12 client, author iThorgrim - https://github.com/WarcraftXL. Its author permits reuse of that
|
||||
// code here provided the author and the original project are named; this notice is that
|
||||
// attribution and must stay with the module. A block that ports WarcraftXL code says so where it
|
||||
// stands and is listed in WATER.md.
|
||||
fn enhanced_water(in: LiquidVsOut, shallow: vec4<f32>, deep: vec4<f32>) -> vec4<f32> {
|
||||
let pixel = clamp(vec2<i32>(in.clip_position.xy), vec2<i32>(0),
|
||||
vec2<i32>(textureDimensions(scene_depth)) - vec2<i32>(1));
|
||||
let own_depth = textureLoad(scene_depth, pixel, 0).r;
|
||||
// MONKEY (bed clutter): grass blades and reeds standing in a stream are in the opaque depth, so
|
||||
// every blade read as "something solid at the surface" - each one grew a white contact-foam
|
||||
// outline and a paler, less-absorbed tint than the bed around it (owner screenshot, Stonefield
|
||||
// Farm). Anything THIN is not a shore: take the FARTHEST of nine taps (reverse-Z: the smallest)
|
||||
// over a ~0.7 % of the view height ring, so a blade a few pixels wide resolves to the bed behind
|
||||
// it, while a bank, a rock or a hull - wider than the ring - is untouched bar a few pixels.
|
||||
let ring = max(i32(view.viewport.w * 0.0065), 2);
|
||||
let top = vec2<i32>(textureDimensions(scene_depth)) - vec2<i32>(1);
|
||||
var far_depth = own_depth;
|
||||
var offs = array<vec2<i32>, 8>(
|
||||
vec2<i32>(1, 1), vec2<i32>(1, 0), vec2<i32>(-1, 1), vec2<i32>(0, 1),
|
||||
vec2<i32>(-1, -1), vec2<i32>(-1, 0), vec2<i32>(1, -1), vec2<i32>(0, -1));
|
||||
for (var k = 0; k < 8; k += 1) {
|
||||
let reach = select(ring, 2 * ring, (k & 1) == 1);
|
||||
let tap = textureLoad(scene_depth,
|
||||
clamp(pixel + offs[k] * reach, vec2<i32>(0), top), 0).r;
|
||||
// Reverse-Z: farther is SMALLER; 0 is the sky, which is not a bed.
|
||||
if tap < far_depth && tap > 0.0 { far_depth = tap; }
|
||||
}
|
||||
// The open sea keeps its own pixel: its shore train's phase IS this depth, and sand has no reeds.
|
||||
let sea = w.kind.y > 0.5 && w.path.x < 0.5;
|
||||
let bed_depth = select(far_depth, own_depth, sea);
|
||||
let to_view = normalize(view.world_position.xyz - in.world_position.xyz);
|
||||
let eye_pos = (view.view_from_world * vec4<f32>(in.world_position.xyz, 1.0)).xyz;
|
||||
// Convert the two eye-Z distances to a distance ALONG the pixel's view ray.
|
||||
let ray_cos = max(abs(normalize(eye_pos).z), 0.001);
|
||||
let thickness = min(max(water_view_distance(bed_depth)
|
||||
- water_view_distance(in.clip_position.z), 0.0) / ray_cos, 1000.0);
|
||||
// Reconstruct the opaque scene point, then measure height below the surface.
|
||||
// Ray thickness is only the contact measure: opacity must not follow eye-Z.
|
||||
let ndc_xy = ((in.clip_position.xy - view.viewport.xy) / view.viewport.zw)
|
||||
* vec2<f32>(2.0, -2.0) + vec2<f32>(-1.0, 1.0);
|
||||
let scene_h = view.world_from_clip * vec4<f32>(ndc_xy, bed_depth, 1.0);
|
||||
var vertical_depth = 1000.0;
|
||||
if bed_depth > 0.0 && abs(scene_h.w) > 1e-7 {
|
||||
vertical_depth = clamp(in.world_position.y - scene_h.y / scene_h.w, 0.0, 1000.0);
|
||||
}
|
||||
let t = anim_time(); // WOW_CAPTURE_WATER_T pins the frozen water phase.
|
||||
let p = in.world_position.xz;
|
||||
let energy = clamp(w.path.z, 0.0, 1.0);
|
||||
let ocean_mesh = w.kind.y > 0.5 && w.path.x < 0.5;
|
||||
|
||||
// ── EVERY screen derivative in this shader is taken HERE ────────────────────────────────
|
||||
// Top level of the function, before any branch, loop or early return: WGSL's uniformity rule
|
||||
// makes a derivative inside non-uniform control flow invalid, and this function is long enough
|
||||
// that the only safe discipline is to have exactly one place they can live. Nothing below may
|
||||
// reintroduce a `dpdx`/`dpdy` inside a conditional.
|
||||
let ddx_p = dpdx(p);
|
||||
let ddy_p = dpdy(p);
|
||||
let ddx_depth = dpdx(vertical_depth);
|
||||
let ddy_depth = dpdy(vertical_depth);
|
||||
let footprint = max(length(ddx_p), length(ddy_p));
|
||||
|
||||
// The BED's slope in world yards, recovered by inverting the pixel→world Jacobian: solve
|
||||
// `g·(dp/dx) = dd/dx` and `g·(dp/dy) = dd/dy` for the world-space gradient of the vertical
|
||||
// depth field. `offshore` is the unit direction of INCREASING depth, so `−offshore` points at
|
||||
// the beach and the shore train travels that way.
|
||||
let jac_det = ddx_p.x * ddy_p.y - ddx_p.y * ddy_p.x;
|
||||
var depth_grad = vec2<f32>(0.0, 0.0);
|
||||
if abs(jac_det) > 1e-12 {
|
||||
depth_grad = vec2<f32>(
|
||||
(ddx_depth * ddy_p.y - ddy_depth * ddx_p.y) / jac_det,
|
||||
(ddy_depth * ddx_p.x - ddx_depth * ddy_p.x) / jac_det,
|
||||
);
|
||||
}
|
||||
let bed_slope = length(depth_grad);
|
||||
// Flat bed, or the depth field broke across a silhouette (the ratio blows up there): fall back
|
||||
// to the wind. A flat bed has no shoaling band to draw, so the fallback is mostly a guard.
|
||||
var offshore = vec2<f32>(cos(SHORE_WIND_DIR), sin(SHORE_WIND_DIR));
|
||||
if bed_slope > 0.02 && bed_slope < 12.0 {
|
||||
offshore = depth_grad / bed_slope;
|
||||
}
|
||||
|
||||
// ── The shore break: shoaling trains riding the bathymetry (see the constants block) ────
|
||||
// Domain-warped in DEPTH units, so the crests wander and segment along the beach instead of
|
||||
// running as ruler-straight bands.
|
||||
let shore_warp =
|
||||
SHORE_WARP_A * (2.0 * foam_noise(p * 0.075 + t * vec2<f32>(0.010, -0.007)) - 1.0)
|
||||
+ SHORE_WARP_B * (2.0 * foam_noise(p * 0.21 - t * vec2<f32>(0.006, 0.009)) - 1.0);
|
||||
let dq = max(vertical_depth + shore_warp, 0.0);
|
||||
let shore_g = (dq + SHORE_COMPRESS * (1.0 - exp(-dq / SHORE_COMPRESS_D))) / SHORE_LAMBDA_D;
|
||||
// `+ t/period` with a crest-count that RISES with depth ⇒ a crest of fixed phase slides to
|
||||
// shallower water as time runs: the train travels shoreward.
|
||||
let shore_phase = 6.2831853 * (shore_g + t / SHORE_PERIOD);
|
||||
let shore_phase2 = 6.2831853 * (1.9 * shore_g + t / (SHORE_PERIOD * 0.62)) + 2.1;
|
||||
// Steepness rises as it shoals (3.5 → 0.75 yd), collapses into the break below ~0.35 yd, and
|
||||
// is gone past the band's offshore edge.
|
||||
let shoal = smoothstep(3.5, 0.75, vertical_depth);
|
||||
let shore_collapse = smoothstep(0.12, 0.38, vertical_depth);
|
||||
let shore_offshore_fade = 1.0 - smoothstep(2.4, 3.6, vertical_depth);
|
||||
// MONKEY (surf on wrecks): the shore train reads the SCENE depth, and a sunken boat, a pier
|
||||
// foot or a rock shelf is shallow scene depth in the middle of deep water - so the whole surf
|
||||
// (crests, lace, swash) was painted over the hull of a wreck off Longshore. A shore is where
|
||||
// the SEA BED is shallow. The mesh carries the authored bed depth (`in.depth`, byte/255 of about
|
||||
// 148 yd): where the bed lies well below what the pixel sees, the pixel is an OBJECT, and an
|
||||
// object gets the thin wet-edge line only. The byte is a FLOOR (1.72 per yd), so on a real beach the
|
||||
// authored bed is never deeper than the scene by more than interpolation error: allow 0.5 yd.
|
||||
let bed_yd = clamp(in.depth, 0.0, 1.0) * 148.0;
|
||||
let on_bed = 1.0 - smoothstep(0.5, 1.0, bed_yd - vertical_depth);
|
||||
let shore_gain = select(0.0, shoal * shore_collapse * shore_offshore_fade * on_bed, ocean_mesh)
|
||||
* energy;
|
||||
// Steepness is set DIRECTLY — the tangent of the crest tilt — rather than through an amplitude
|
||||
// and a wavenumber, so a gentle beach and a steep one roll with the same visible strength
|
||||
// instead of one washing out and the other exploding.
|
||||
let shore_grad = SHORE_TILT * shore_gain
|
||||
* (cos(shore_phase) + 0.45 * cos(shore_phase2)) * offshore;
|
||||
|
||||
let shore = select(1.0, swell_shore_fade(in.depth), ocean_mesh);
|
||||
let wave = water_waves(p, t, length(eye_pos), footprint, shore, false);
|
||||
// One surface gradient: the procedural bands and the shore break.
|
||||
let surf_grad = wave.yz + shore_grad;
|
||||
var n = normalize(vec3<f32>(-surf_grad.x, 1.0, -surf_grad.y));
|
||||
if dot(n, to_view) < 0.0 { n = -n; }
|
||||
|
||||
// Far water settles into a calm sky sheet.
|
||||
n = normalize(mix(n, vec3<f32>(0.0, sign(n.y), 0.0),
|
||||
0.6 * smoothstep(60.0, 120.0, length(eye_pos))));
|
||||
// Clean translucent teal at the edge; zone tint remains in the deeper body.
|
||||
let teal_shallow = mix(vec3<f32>(0.12, 0.42, 0.39), shallow.rgb, 0.18);
|
||||
// MONKEY (water body): the zone deep row alone renders the open sea and lake middles a muddy
|
||||
// grey-brown (Westfall ocean, Loch Modan) - it was authored to sit UNDER the reference ripple
|
||||
// sheet, not to be a lit body colour. Pull it 70 % toward the zone zenith sky (deep water takes
|
||||
// its colour from the sky it scatters) and keep it blue-dominant, so the body reads as water by
|
||||
// day, follows dusk and night through the same sky row, and still differs zone to zone.
|
||||
let zenith_gamma = pow(max(w.sky_zenith.rgb, vec3<f32>(0.0)), vec3<f32>(1.0 / 2.2));
|
||||
let deep_sky = mix(deep.rgb, zenith_gamma * 1.1, 0.7);
|
||||
let deep_body = vec3<f32>(min(deep_sky.r, deep_sky.g * 0.85), deep_sky.g,
|
||||
max(deep_sky.b, deep_sky.g * 1.12));
|
||||
var body = mix(teal_shallow, deep_body, smoothstep(0.0, 3.5, vertical_depth));
|
||||
if !ocean_mesh {
|
||||
let clear_row = mix(shallow.rgb, vec3<f32>(0.20, 0.40, 0.36), 0.35);
|
||||
let clear_tint = vec3<f32>(min(clear_row.r, clear_row.g * 0.72), clear_row.g,
|
||||
clamp(clear_row.b, clear_row.g * 0.82, clear_row.g * 1.15));
|
||||
let inland_row = mix(deep.rgb, zenith_gamma * 1.1, 0.25);
|
||||
let inland_deep = vec3<f32>(min(inland_row.r, inland_row.g * 0.72), inland_row.g,
|
||||
clamp(inland_row.b, inland_row.g * 0.82, inland_row.g * 1.15));
|
||||
body = mix(clear_tint, inland_deep, smoothstep(0.0, 5.0, vertical_depth));
|
||||
}
|
||||
let to_light = -normalize(wow_light.light_sun.xyz);
|
||||
// MONKEY (water body): water is lit by the light it SCATTERS, not only by N.L on its skin, so
|
||||
// the lit body keeps a high floor (ambient x 1.35, N.L floor 0.5). With the old 0.25 floor a
|
||||
// noon lake rendered near-black navy where the owner reference is a bright teal-blue; night
|
||||
// still darkens because both rows do.
|
||||
let lighting = clamp(wow_light.light_ambient.rgb * 1.35 + wow_light.light_diffuse.rgb
|
||||
* max(dot(n, to_light), 0.5), vec3<f32>(0.0), vec3<f32>(1.0));
|
||||
var rgb = body * lighting;
|
||||
// MONKEY (shore waves): the shore train is shown mainly as a CONTINUOUS crest brightening, not as
|
||||
// a normal tilt. Its direction comes from the screen-space gradient of the bed depth, and the
|
||||
// terrain is flat triangles, so that direction jumps at every triangle edge: at SHORE_TILT 0.30
|
||||
// the highlights broke into blocky rectangular shards with staircase edges (capture
|
||||
// it9-water-beach-top). The PHASE is a function of the depth itself and is continuous, so a term
|
||||
// driven by the phase alone cannot facet. The tilt stays at 0.09 for a little specular life.
|
||||
let shore_crest = pow(max(sin(shore_phase), 0.0), 2.0)
|
||||
+ 0.45 * pow(max(sin(shore_phase2), 0.0), 2.0);
|
||||
rgb += shore_gain * shore_crest * 0.13 * lighting * vec3<f32>(0.72, 0.95, 0.90);
|
||||
if !ocean_mesh {
|
||||
// Keep the zone's green-blue absorption under warm dusk illumination.
|
||||
rgb = body * dot(lighting, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
}
|
||||
let celestial_dir = normalize(w.celestial.xyz);
|
||||
let crest = smoothstep(0.35, 0.95, 0.5 + 0.5 * wave.x / max(0.545 * energy, 0.001));
|
||||
let transmission = crest * pow(max(dot(to_view, -celestial_dir), 0.0), 3.0) * energy
|
||||
* smoothstep(-0.02, 0.12, celestial_dir.y);
|
||||
rgb += vec3<f32>(0.06, 0.30, 0.19) * transmission * lighting;
|
||||
let reflection_n = normalize(mix(vec3<f32>(0.0, sign(n.y), 0.0), n, 0.45));
|
||||
let reflected = reflect(-to_view, reflection_n);
|
||||
let sky_linear = mix(w.sky_horizon.rgb, w.sky_zenith.rgb, clamp(reflected.y * 1.4, 0.0, 1.0));
|
||||
// Linear reflection interpolation, then conversion to the world's gamma blend/fog lane.
|
||||
let sky = select(12.92 * sky_linear,
|
||||
1.055 * pow(max(sky_linear, vec3<f32>(0.0)), vec3<f32>(1.0 / 2.4)) - 0.055,
|
||||
sky_linear > vec3<f32>(0.0031308));
|
||||
var reflectivity = mix(0.06, 0.88, pow(1.0 - max(dot(n, to_view), 0.0), 4.0));
|
||||
if !ocean_mesh { reflectivity = min(reflectivity, 0.55); }
|
||||
rgb = mix(rgb, sky, reflectivity);
|
||||
// Smooth crest glints; widen and conserve lobe energy as the footprint grows.
|
||||
let half_v = normalize(celestial_dir + to_view);
|
||||
let ndoth = max(dot(n, half_v), 0.0);
|
||||
let normal_variance = dot(dpdx(n), dpdx(n)) + dot(dpdy(n), dpdy(n));
|
||||
let spread = 1.0 + 400.0 * normal_variance + 2.0 * smoothstep(30.0, 160.0, length(eye_pos));
|
||||
if w.path.x < 1.5 {
|
||||
if w.celestial.w > 0.5 {
|
||||
// Own peak intensity, never nightGain or the signed moon-shadow weight.
|
||||
// sin(8 degrees): the reflection is exactly zero below the horizon.
|
||||
rgb += vec3<f32>(0.80, 0.88, 1.0) * 0.45
|
||||
* (pow(ndoth, 400.0 / spread) / sqrt(spread)
|
||||
+ 0.25 * pow(ndoth, 60.0 / sqrt(spread)) / sqrt(spread))
|
||||
* smoothstep(0.0, 0.1391731, celestial_dir.y);
|
||||
} else {
|
||||
rgb += wow_light.light_diffuse.rgb
|
||||
* (pow(ndoth, 400.0 / spread) / sqrt(spread)
|
||||
+ 0.25 * pow(ndoth, 60.0 / sqrt(spread)) / sqrt(spread))
|
||||
* smoothstep(-0.02, 0.08, celestial_dir.y);
|
||||
}
|
||||
}
|
||||
|
||||
// Rank candidates by actual fragment distance, not table order. Only four lights are shaded.
|
||||
var nearest = array<u32, 4>(256u, 256u, 256u, 256u);
|
||||
var distances = array<f32, 4>(900.0, 900.0, 900.0, 900.0);
|
||||
for (var i = 0u; i < min(u32(wow_light.point_count.x), 256u); i += 1u) {
|
||||
let colour = wow_light.points[2u * i + 1u];
|
||||
if colour.w >= 0.5 && w.path.x < 1.5 { continue; }
|
||||
let delta = wow_light.points[2u * i].xyz - in.world_position.xyz;
|
||||
let d2 = dot(delta, delta);
|
||||
if d2 >= distances[3] { continue; }
|
||||
var slot = 3u;
|
||||
loop {
|
||||
if slot == 0u { break; }
|
||||
if d2 >= distances[slot - 1u] { break; }
|
||||
distances[slot] = distances[slot - 1u];
|
||||
nearest[slot] = nearest[slot - 1u];
|
||||
slot -= 1u;
|
||||
}
|
||||
distances[slot] = d2;
|
||||
nearest[slot] = i;
|
||||
}
|
||||
for (var j = 0u; j < 4u; j += 1u) {
|
||||
if nearest[j] == 256u { continue; }
|
||||
let pos = wow_light.points[nearest[j] * 2u];
|
||||
let colour = wow_light.points[nearest[j] * 2u + 1u];
|
||||
let delta = pos.xyz - in.world_position.xyz;
|
||||
let distance = sqrt(max(distances[j], 0.0001));
|
||||
let light_dir = delta / distance;
|
||||
let reach = min(select(pos.w, 2.0 * colour.w, colour.w >= 0.5), 30.0);
|
||||
let attenuation = pow(1.0 - smoothstep(0.0, max(reach, 0.01), distance), 2.0);
|
||||
rgb += colour.rgb * pow(max(dot(n, normalize(light_dir + to_view)), 0.0), 64.0)
|
||||
* max(dot(n, light_dir), 0.0) * attenuation * 0.7;
|
||||
}
|
||||
|
||||
// Patchy, low-frequency coverage, never a texture-derived white outline.
|
||||
let noise = 0.7 * foam_noise(p * 0.6 + t * vec2<f32>(0.025, -0.018))
|
||||
+ 0.3 * foam_noise(p * 1.7 - t * vec2<f32>(0.014, 0.021));
|
||||
let breakup = smoothstep(0.40, 0.72, noise);
|
||||
// The cached derivatives from the top of the function — same expression as before, one tap.
|
||||
let depth_gradient = length(vec2<f32>(ddx_depth, ddy_depth))
|
||||
/ max(length(vec2<f32>(length(ddx_p), length(ddy_p))), 0.001);
|
||||
let wall_suppression = mix(1.0, 0.3, smoothstep(0.8, 3.0, depth_gradient));
|
||||
let contact = smoothstep(0.0, 0.025, thickness)
|
||||
* (1.0 - smoothstep(0.055, 0.15, thickness));
|
||||
let contact_alpha = FOAM_WET_EDGE * contact * wall_suppression * breakup;
|
||||
// MONKEY (beach foam, third pass). The owner called the previous surf ugly: it was the crest
|
||||
// line chopped into DASHES by a breakup noise, plus swash "crescents" stamped from an 8 yd patch
|
||||
// grid - rows of white blobs. Surf is not dashes. A breaking wave is (1) a thin, nearly
|
||||
// continuous bright FRONT, (2) a LACE of foam left behind it that thins out and dissolves, and
|
||||
// (3) a sheet that runs up the sand after each arrival and fizzles. All three are driven by the
|
||||
// shore train's own phase, so the foam sits on the wave the normals show.
|
||||
//
|
||||
// `surf_u` is the fraction of a wave spacing BEHIND the front (the phase rises with depth, so
|
||||
// behind = seaward): 0 at the front, which sits just ahead of the crest the normals draw.
|
||||
let surf_u = fract(shore_g + t / SHORE_PERIOD - 0.20);
|
||||
// Web-like lace: ridged noise (bright along the zero set of two noise fields), two octaves.
|
||||
let lace_a = 1.0 - abs(2.0 * foam_noise(p * 1.25 + t * vec2<f32>(0.030, -0.020)) - 1.0);
|
||||
let lace_b = 1.0 - abs(2.0 * foam_noise(p * 3.30 - t * vec2<f32>(0.020, 0.035)) - 1.0);
|
||||
let lace = 0.62 * lace_a + 0.38 * lace_b;
|
||||
// The lace dissolves with age: the threshold climbs from "most of it" to "only the ridges".
|
||||
let dissolve = mix(0.42, 0.93, smoothstep(0.02, 0.60, surf_u));
|
||||
let lace_mask = smoothstep(dissolve, dissolve + 0.16, lace);
|
||||
// The front itself: crisp on its shoreward side, solid for a few percent of a spacing.
|
||||
let front = smoothstep(0.0, 0.018, surf_u) * (1.0 - smoothstep(0.035, 0.11, surf_u));
|
||||
let trail = smoothstep(0.0, 0.03, surf_u) * (1.0 - smoothstep(0.25, 0.70, surf_u));
|
||||
// Strength wanders along the beach, but never to nothing - no gaps, no dashes.
|
||||
let along = mix(0.55, 1.0, foam_noise(p * 0.055 + t * vec2<f32>(0.008, 0.005)));
|
||||
let break_zone = smoothstep(0.10, 0.30, vertical_depth)
|
||||
* (1.0 - smoothstep(1.3, 2.6, vertical_depth)) * on_bed;
|
||||
let crest_alpha = select(0.0,
|
||||
FOAM_CREST * break_zone * along * max(front, 0.85 * trail * lace_mask), ocean_mesh)
|
||||
* wall_suppression * energy;
|
||||
|
||||
// The swash: what is left of each wave runs up the last hand of water and fizzles. Its clock
|
||||
// is the front's arrival at the break (0.22 yd of water): 0 when it lands, 1 as the next does.
|
||||
let land_g = (0.22 + SHORE_COMPRESS * (1.0 - exp(-0.22 / SHORE_COMPRESS_D))) / SHORE_LAMBDA_D;
|
||||
let swash_age = fract(land_g + t / SHORE_PERIOD - 0.20 + 0.10 * (along - 0.75));
|
||||
let swash_life = smoothstep(0.0, 0.06, swash_age) * (1.0 - smoothstep(0.30, 0.95, swash_age));
|
||||
let swash_band = (1.0 - smoothstep(0.10, 0.34, vertical_depth + 0.5 * shore_warp)) * on_bed;
|
||||
let swash_thin = mix(0.30, 0.90, smoothstep(0.05, 0.85, swash_age));
|
||||
let swash_lace = smoothstep(swash_thin, swash_thin + 0.18, lace);
|
||||
// The very lip of the water keeps a thin bright line while the sheet is alive.
|
||||
let lip = 1.0 - smoothstep(0.015, 0.07, vertical_depth);
|
||||
let arcs_alpha = select(0.0,
|
||||
FOAM_SWASH * swash_life * swash_band * max(swash_lace, 0.8 * lip), ocean_mesh)
|
||||
* wall_suppression * energy;
|
||||
|
||||
let foam = min(FOAM_MAX, contact_alpha + arcs_alpha + crest_alpha);
|
||||
let illumination = wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb;
|
||||
let foam_luma = clamp(dot(illumination, vec3<f32>(0.2126, 0.7152, 0.0722)), 0.22, 1.0);
|
||||
// Matte foam composites on top of reflection with its own coverage.
|
||||
let soft_edge = smoothstep(0.0, 0.18, vertical_depth);
|
||||
var body_alpha = soft_edge * mix(0.55, 0.97, smoothstep(0.0, 1.6, vertical_depth));
|
||||
if !ocean_mesh {
|
||||
body_alpha = soft_edge * mix(0.30, 0.86, smoothstep(0.0, 4.5, vertical_depth));
|
||||
}
|
||||
let foam_alpha = smoothstep(0.0, 0.045, vertical_depth) * foam;
|
||||
let alpha = foam_alpha + body_alpha * (1.0 - foam_alpha);
|
||||
// Foam is white UNDER the light it stands in: half the way to the light's own colour, so a
|
||||
// dusk surf is warm and a moonlit one blue-grey, not a neutral paste.
|
||||
let foam_tint = mix(vec3<f32>(foam_luma),
|
||||
clamp(illumination, vec3<f32>(0.22), vec3<f32>(1.0)), 0.5);
|
||||
rgb = (foam_tint * foam_alpha + rgb * body_alpha * (1.0 - foam_alpha))
|
||||
/ max(alpha, 0.0001);
|
||||
return vec4<f32>(apply_fog(rgb, in.world_position.xyz, in.room_fog), alpha);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
|
||||
// HARD FAR-CLIP WALL (same as terrain/models, see terrain.wgsl): discard water beyond the
|
||||
@@ -811,15 +363,19 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced never samples the classic ripple sheet. The 1x1 image retains the safe fallback.
|
||||
if w.kind.x < 0.5 && w.path.y > 0.5 && textureDimensions(scene_depth).x > 1u {
|
||||
// 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);
|
||||
|
||||
@@ -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::<Assets<Image>>().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<Image>);
|
||||
|
||||
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::<Assets<Image>>().add(image))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +181,7 @@ impl Plugin for LiquidPlugin {
|
||||
app.add_plugins(MaterialPlugin::<LiquidMaterial>::default())
|
||||
.init_resource::<benilla_assets::WaterQuality>()
|
||||
.init_resource::<benilla_assets::WaterDepthImage>()
|
||||
.init_resource::<benilla_assets::WaterColourImage>()
|
||||
.init_resource::<Underwater>()
|
||||
.init_resource::<SubmergedEye>()
|
||||
.init_resource::<WaterIndex>()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Image>,
|
||||
colour: Handle<Image>,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
@@ -34,13 +38,15 @@ struct WaterOverride(Option<u8>);
|
||||
|
||||
impl Plugin for WaterDepthPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<WaterQuality>().init_resource::<WaterDepthImage>();
|
||||
app.init_resource::<WaterQuality>().init_resource::<WaterDepthImage>()
|
||||
.init_resource::<WaterColourImage>();
|
||||
let value = std::env::var("WOW_WATER").ok()
|
||||
.and_then(|v| v.parse::<u8>().ok()).filter(|v| *v <= 2);
|
||||
if let Some(value) = value { app.insert_resource(WaterQuality(value)); }
|
||||
let image = app.world().resource::<WaterDepthImage>().0.clone();
|
||||
let colour = app.world().resource::<WaterColourImage>().0.clone();
|
||||
app.insert_resource(WaterOverride(value))
|
||||
.insert_resource(DepthSource { image, active: false })
|
||||
.insert_resource(DepthSource { image, colour, active: false })
|
||||
.add_plugins((ExtractComponentPlugin::<WaterView>::default(),
|
||||
ExtractResourcePlugin::<DepthSource>::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::<Mesh3d>()).iter().any(|entity| {
|
||||
liquids.get(*entity).ok().and_then(|m| materials.get(&m.0))
|
||||
.is_some_and(|m| m.extension.kind.x < 0.5)
|
||||
.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<Image>| 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<DepthShader>, cache: Res<Pi
|
||||
blend: None, write_mask: ColorWrites::ALL })],
|
||||
}), ..default()
|
||||
}));
|
||||
commands.insert_resource(DepthPipelines { layouts, pipelines });
|
||||
let colour_layout = BindGroupLayoutDescriptor::new(
|
||||
"water colour source", &[BindGroupLayoutEntry {
|
||||
binding: 0, visibility: ShaderStages::FRAGMENT,
|
||||
ty: BindingType::Texture { sample_type: TextureSampleType::Float { filterable: false },
|
||||
view_dimension: TextureViewDimension::D2, multisampled: false }, count: None,
|
||||
}],
|
||||
);
|
||||
let colour_pipeline = cache.queue_render_pipeline(RenderPipelineDescriptor {
|
||||
label: Some("water colour copy".into()), layout: vec![colour_layout.clone()],
|
||||
vertex: VertexState { shader: shader.clone(), entry_point: Some("vertex".into()), ..default() },
|
||||
fragment: Some(FragmentState {
|
||||
shader: shader.clone(), entry_point: Some("colour".into()),
|
||||
shader_defs: vec!["COLOUR".into()],
|
||||
targets: vec![Some(ColorTargetState { format: TextureFormat::Rgba16Float,
|
||||
blend: None, write_mask: ColorWrites::ALL })],
|
||||
}), ..default()
|
||||
});
|
||||
commands.insert_resource(DepthPipelines { layouts, pipelines, colour_layout, colour_pipeline });
|
||||
}
|
||||
|
||||
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
|
||||
@@ -159,9 +193,9 @@ struct WaterDepthLabel;
|
||||
struct DepthNode;
|
||||
|
||||
impl ViewNode for DepthNode {
|
||||
type ViewQuery = (&'static WaterView, &'static ViewDepthTexture);
|
||||
type ViewQuery = (&'static WaterView, &'static ViewDepthTexture, &'static ViewTarget);
|
||||
fn run<'w>(&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::<DepthSource>();
|
||||
if !source.active { return Ok(()); }
|
||||
let images = world.resource::<RenderAssets<GpuImage>>();
|
||||
@@ -171,6 +205,14 @@ impl ViewNode for DepthNode {
|
||||
let cache = world.resource::<PipelineCache>();
|
||||
let index = usize::from(depth.texture.sample_count() > 1);
|
||||
let Some(pipeline) = cache.get_render_pipeline(pipelines.pipelines[index]) else { return Ok(()) };
|
||||
// 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::<RenderDevice>();
|
||||
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<f32>;
|
||||
#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<f32>(x * 2.0 - 1.0, y * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
#ifdef COLOUR
|
||||
@fragment fn colour(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
return textureLoad(source, vec2<i32>(p.xy), 0);
|
||||
}
|
||||
#else
|
||||
@fragment fn fragment(@builtin(position) p: vec4<f32>) -> @location(0) f32 {
|
||||
return textureLoad(depth, vec2<i32>(p.xy), 0);
|
||||
}
|
||||
#endif
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -512,6 +512,7 @@ pub(super) fn setup_liquid(
|
||||
mut images: ResMut<Assets<Image>>,
|
||||
mut materials: ResMut<Assets<LiquidMaterial>>,
|
||||
water_depth: Res<benilla_assets::WaterDepthImage>,
|
||||
water_colour: Res<benilla_assets::WaterColourImage>,
|
||||
water_quality: Res<benilla_assets::WaterQuality>,
|
||||
) {
|
||||
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::<f32>().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(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user