From 2e486e3612dc1f36a4c52d10eb80d3ecf267ba0f Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Thu, 24 Sep 2026 23:15:33 +0100
Subject: [PATCH] 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
---
LIGHTING.md | 4 +
WATER.md | 28 +-
crates/benilla-app/assets/ui/OptionsFrame.xml | 41 +-
.../src/addon_harness/render_tests.rs | 25 +-
.../src/addon_harness/use_probe_tests.rs | 3 +-
crates/benilla-app/src/capture/fixtures.rs | 10 +
crates/benilla-app/src/capture/scenarios.rs | 38 +
crates/benilla-app/src/cvars.rs | 27 +-
crates/benilla-app/src/game_plugins.rs | 2 +
crates/benilla-app/src/lib.rs | 2 +
crates/benilla-app/src/shadow_core.rs | 3 +-
.../src/ui_script/options_tests.rs | 64 +-
crates/benilla-app/src/video.rs | 10 +-
crates/benilla-app/src/volumetric_fog.rs | 548 +++++++++++++
crates/benilla-app/src/world_shadow.rs | 11 +-
crates/benilla-assets/src/lib.rs | 2 +-
crates/benilla-assets/src/materials.rs | 33 +-
.../src/shaders/enhanced_water.wgsl | 730 ++++++++++++++++++
crates/benilla-assets/src/shaders/liquid.wgsl | 470 +----------
crates/benilla-assets/src/water_depth.rs | 54 +-
crates/benilla-world/src/liquid/mod.rs | 1 +
crates/benilla-world/src/liquid/query.rs | 8 +-
.../benilla-world/src/liquid/scene_depth.rs | 109 ++-
crates/benilla-world/src/liquid/surface.rs | 47 +-
crates/benilla-world/src/liquid/waves.rs | 40 +-
crates/benilla-world/src/water_fx/bob.rs | 2 +-
26 files changed, 1742 insertions(+), 570 deletions(-)
create mode 100644 crates/benilla-app/src/volumetric_fog.rs
create mode 100644 crates/benilla-assets/src/shaders/enhanced_water.wgsl
diff --git a/LIGHTING.md b/LIGHTING.md
index 12b32964..f2039a1e 100644
--- a/LIGHTING.md
+++ b/LIGHTING.md
@@ -23,11 +23,15 @@ behind each constant.
| Spell and firework lights | fire, holy and fel effects light their surroundings for their lifetime; frost, nature, arcane and shadow do not | `spellLightGain`, env `WOW_SPELL_LIGHT=0` |
| Moon shadows | at night the same shadow rig re-aims at the moon and casts a faint shadow; dims only the night sky term, never point lights | `moonShadowStrength` |
| Ground-effect spells | Flamestrike, Rain of Fire, Consecration, Flare and fire traps light the ground for their duration; frost and nature areas stay dark | `spellLightGain` |
+| Volumetric fog | near-field haze that converges on the zone fog colour (clear within 10 yd, full by 150 yd; mistier at dawn and in bad weather, faint indoors) and sun/moon light shafts through gaps, sampled from the shadow map; own fullscreen pass after the main pass (`benilla-app/src/volumetric_fog.rs`) | Advanced Graphics → Volumetric Fog (Off/Low/High), cvar `volumetricFog`, env `WOW_VOLFOG=0\|1\|2` |
| Night and interior level | global dimming of the night sky term and of interior ambient | `nightGain`, `interiorGain`, `interiorBakeFloor` |
Players reach all of it from **Options -> Advanced Graphics** (a Lighting Quality preset Off / Low / Medium / High plus the individual rows; Off is the original client look). The dev build has a panel for all of it: **Ctrl+Shift+D → Lighting & shadows**, with Dim / Default /
Bright presets.
+The optional enhanced water (Water Quality, refraction, caustics, High reflections, lava glow)
+is its own module, documented in `WATER.md`.
+
## Where the code lives
- **Shaders**: `crates/benilla-assets/src/shaders/shadow_hook.wgsl` (shared shadow sampling),
diff --git a/WATER.md b/WATER.md
index 8713a44e..9ee06550 100644
--- a/WATER.md
+++ b/WATER.md
@@ -18,7 +18,7 @@ player picks it, and every part of it can be switched off.
The WarcraftXL water module is the design reference for this one, and its author has given
permission to reuse its code here on the condition that the author and the original project are
named. This section is that attribution; it must travel with the module (this file, and the
-`liquid.wgsl` header) wherever the module is copied or shared.
+`enhanced_water.wgsl` header) wherever the module is copied or shared.
Rule for contributors: a file that ports code or a technique from WarcraftXL says so in its header
(`Portions derived from WarcraftXL wxl-experimental-water by iThorgrim, used with permission`) and
@@ -28,9 +28,9 @@ the technique is listed in the table below. Do not remove either.
|---|---|---|
| Visual-only wave riding for swimmers (authoritative position never moves) | shipped (`water_fx/bob.rs`) | same principle as `world/Ride.cpp`, implemented independently |
| Shore surf limited by the TERRAIN column, so objects in the water never foam | shipped (authored depth gate, `on_bed`) | principle from `sea/Shore.hpp` |
-| Per-channel extinction + scene-copy refraction | planned | `shaders/Surface.ps.hlsl`, `render/Refraction.cpp` |
-| Caustics, two layers multiplied | planned | `shaders/Surface.ps.hlsl`, `render/Noise.cpp` |
-| One-probe screen-space reflection | planned (`High`) | `shaders/Surface.ps.hlsl` |
+| Per-channel extinction + scene-copy refraction | shipped (`enhanced_water.wgsl` DEPTH LOOK; bent samples landing on anything in front of the water are refused via the scene depth) | ported from `shaders/Surface.ps.hlsl`, `render/Refraction.cpp` |
+| Caustics, two layers at incommensurate scales/rates | shipped (floor web; built from animated cell edges, not value noise) | ported from `shaders/Surface.ps.hlsl`, `render/Noise.cpp` |
+| Screen-space reflection of the scenery | shipped on `High` (a bisected march against the scene depth, not WXL's single probe) | principle from `shaders/Surface.ps.hlsl` |
| Gerstner trains + breaker index, crest-fold foam | planned | `sea/Spectrum.*`, `sea/Shore.hpp`, `shaders/Wave.hlsli` |
| Specular lobe widened by the normal's screen derivative, distance glints | shipped / planned | `shaders/Surface.ps.hlsl` |
@@ -38,10 +38,10 @@ the technique is listed in the table below. Do not remove either.
| Lane | What you see | Main switch |
|---|---|---|
-| Quality tiers | `Classic` = the reference water, `Enhanced`, `High` (currently = Enhanced) | Video options → Water Quality, cvar `waterQuality`, env `WOW_WATER=0\|1\|2` |
+| Quality tiers | `Classic` = the reference water, `Enhanced`, `High` (= Enhanced + scenery reflections) | Video options → Water Quality, cvar `waterQuality`, env `WOW_WATER=0\|1\|2` |
| Procedural waves | multi-band analytic waves with exact normals; the long swell moves ocean vertices | tier |
| Ocean / inland profiles | the sea and lakes/rivers have their own colour, energy and reflectivity | tier |
-| Depth look | soft shoreline, depth-driven colour and opacity from the real scene depth | tier |
+| Depth look | light fades per channel over the real path through the water; the bed is seen through it, bent by the waves; sunlit shallows carry a caustic web | tier |
| Reflection and glints | Fresnel sky reflection from the zone's sky rows, sun path, moon path, point-light glints | tier |
| Beach surf | a travelling wave front, a lace of foam that dissolves behind it, a swash sheet on the sand | tier |
| Swimmers | wake foam, treading rings and a visual-only bob with the swell | tier |
@@ -52,11 +52,17 @@ request. If it ever returns it should read the authored MCLQ flow records, not d
## Where the code lives
-- **Shader**: `crates/benilla-assets/src/shaders/liquid.wgsl` — `enhanced_water()` and its helpers;
- the `fragment` entry point branches into it and otherwise runs the untouched reference path.
-- **Assets** (`crates/benilla-assets/src`): `water_depth.rs` (the scene-depth binding),
- `materials.rs` (`LiquidExt`: quality, wave energy, sky rows, celestial), `lib.rs` (`WaterQuality`).
-- **World** (`crates/benilla-world/src`): `liquid/scene_depth.rs` (resolves the opaque depth after
+- **Shader**: `crates/benilla-assets/src/shaders/enhanced_water.wgsl` - the whole module on the GPU
+ side, an importable library (`benilla::enhanced_water`) with its own bindings: 103 scene depth,
+ 104 `WaterParams` (`benilla_assets::WaterUniform`), 105 the shared light buffer (a second
+ read-only view of upstream's binding 90), 106 the scene colour (for refraction and reflection). Upstream's `liquid.wgsl` carries three hooks only: the
+ `#import`, `water_swell` in its vertex stage, and the `water_active()` branch at the top of its
+ fragment stage, which returns the module's result as-is (the module fogs its own surface terms
+ only: the scene it refracts and reflects is already fogged). Keep it that way: new
+ water code goes in the module, not in `liquid.wgsl`.
+- **Assets** (`crates/benilla-assets/src`): `water_depth.rs` (`WaterUniform`, the scene-depth image),
+ `materials.rs` (`LiquidExt` fields 103-106, shader registration), `lib.rs` (`WaterQuality`).
+- **World** (`crates/benilla-world/src`): `liquid/scene_depth.rs` (copies the opaque depth AND colour after
the main opaque pass), `liquid/waves.rs` (the CPU mirror of the vertex swell), `liquid/surface.rs`
(per-kind material parameters), `water_fx/bob.rs` (swimmer bob), `lighting/lava_light.rs`.
- **App** (`crates/benilla-app/src`): the setting in `cvars.rs` / `video.rs`, the options rows in
diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index c283b662..7646d50d 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -835,6 +835,9 @@
enUS = {
page = "Advanced Graphics",
lightingQuality = "Lighting Quality",
+ -- MONKEY (volumetric fog): localised atmospheric quality row.
+ volumetricFog = "Volumetric Fog",
+ fogOff = "Off",
waterQuality = "Water Quality",
waterClassic = "Classic",
waterEnhanced = "Enhanced",
@@ -863,14 +866,17 @@
tips = {
LIGHTING_QUALITY =
"Sets character, world, indoor torch and outdoor torch shadows; dynamic "
- .. "interior lighting; moon shadows; water quality; lava glow; fire and spell brightness; fire flicker; "
+ .. "interior lighting; moon shadows; volumetric fog; water quality; lava glow; fire and spell brightness; fire flicker; "
.. "and night and interior darkness. Low, Medium and High also set shadow "
.. "resolution; Medium and High also set resident and moving torch shadow counts.\n\n"
- .. "Off restores the original client's look: Classic water, no lava glow or synthesised fire lights, "
+ .. "Off restores the original client's look: Classic water, no volumetric fog, lava glow or synthesised fire lights, "
.. "night gain 1.0 and interior gain 1.0. All other settings, including "
.. "Shadow Distance and Torch Shadow Softness, stay as you set them. "
.. "Low, Medium and High use Enhanced water; mirror reflections stay opt-in. "
.. "Changing a preset member shows Custom when no preset matches.",
+ -- MONKEY (volumetric fog): explain cost and the shadow-map dependency.
+ VOLUMETRIC_FOG =
+ "Off disables volumetric fog. Low adds nearby haze and sun or moon shafts with fewer steps; High uses more steps for smoother shafts. Mist is heavier at dawn and in bad weather, and subtle indoors. Shafts require shadows; with shadows off only haze remains.",
WATER_QUALITY =
"Classic is the original client's water. Enhanced adds soft shorelines, "
.. "animated foam around shores and objects, moving wave highlights, sky reflection "
@@ -953,6 +959,9 @@
ruRU = {
page = "Расширенная графика",
lightingQuality = "Качество освещения",
+ -- MONKEY (volumetric fog): Russian atmospheric quality row.
+ volumetricFog = "Объёмный туман",
+ fogOff = "Выкл",
waterQuality = "Качество воды",
waterClassic = "Классическое",
waterEnhanced = "Улучшенное",
@@ -981,16 +990,19 @@
tips = {
LIGHTING_QUALITY =
"Задаёт тени персонажей, мира и огней внутри и снаружи помещений; "
- .. "динамическое освещение помещений; лунные тени; качество воды; свечение лавы; яркость огня и заклинаний; "
+ .. "динамическое освещение помещений; лунные тени; объёмный туман; качество воды; свечение лавы; яркость огня и заклинаний; "
.. "мерцание огня; темноту ночи и помещений. «Низкое», «Среднее» и «Высокое» "
.. "также задают разрешение теней; «Среднее» и «Высокое» — число карт теней "
.. "от огней и число огней с тенями движущихся объектов.\n\n"
- .. "«Выкл.» возвращает вид оригинального клиента: классическая вода, без свечения лавы и созданных из пламени "
+ .. "«Выкл.» возвращает вид оригинального клиента: классическая вода, без объёмного тумана, свечения лавы и созданных из пламени "
.. "источников света, с коэффициентами яркости ночи и помещений 1.0. "
.. "Все остальные настройки, включая дальность теней и мягкость теней от огня, "
.. "сохраняют ваши значения. «Низкое», «Среднее» и «Высокое» используют улучшенную воду; "
.. "зеркальные отражения включаются отдельно. Изменение настройки набора показывает «Своё», "
.. "если ни один набор больше не совпадает.",
+ -- MONKEY (volumetric fog): localised effect and cost explanation.
+ VOLUMETRIC_FOG =
+ "Выкл отключает объёмный туман. Низкое добавляет дымку вблизи и лучи солнца или луны с меньшим числом шагов; Высокое использует больше шагов для более плавных лучей. Туман гуще на рассвете и в непогоду, а в помещениях слабее. Для лучей нужны тени; без теней остаётся только дымка.",
WATER_QUALITY =
"«Классическое» — вода оригинального клиента. «Улучшенное» добавляет плавные переходы у берегов, "
.. "анимированную пену у берегов и вокруг объектов, подвижные блики на волнах, отражение неба "
@@ -1136,7 +1148,8 @@
-- the rig they share, and the Graphics page had become the place where a player found
-- three of eighteen shadow settings and no hint that the other fifteen existed.
AdvancedGraphics = {
- "RowLightingQuality", "RowWaterQuality",
+ -- MONKEY (volumetric fog): include in refresh, defaults and search.
+ "RowLightingQuality", "RowWaterQuality", "RowVolumetricFog",
"RowCharacterShadows", "RowWorldShadows", "RowShadowDistance",
"RowShadowResolution", "RowMoonShadows",
"RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", "RowTorchSoft",
@@ -3624,11 +3637,27 @@
})
-
+
+
+
+ OptionsRow_OnLoad(self, "volumetricFog", BENILLA_ADVGFX.volumetricFog,
+ "BENILLA_TOOLTIP_VOLUMETRIC_FOG")
+ OptionsDropdownRow_Setup(self, {
+ { text = BENILLA_ADVGFX.fogOff, value = "0" },
+ { text = BENILLA_ADVGFX.qLow, value = "1" },
+ { text = BENILLA_ADVGFX.qHigh, value = "2" },
+ })
+
+
+
+
+
+
+
OptionsRow_OnLoad(self, "characterShadows",
BENILLA_ADVGFX.characterShadows,
diff --git a/crates/benilla-app/src/addon_harness/render_tests.rs b/crates/benilla-app/src/addon_harness/render_tests.rs
index 10d6b839..fa147c27 100644
--- a/crates/benilla-app/src/addon_harness/render_tests.rs
+++ b/crates/benilla-app/src/addon_harness/render_tests.rs
@@ -22,6 +22,28 @@ use std::path::{Path, PathBuf};
use super::{survey, Drew};
+// MONKEY (volumetric fog): the mandatory Windows test run cannot compile an
+// unconditional unix import. Keep Unix links; copy these small fixtures elsewhere
+// so the same oracle runs without administrator symlink privileges on Windows.
+pub(super) fn link_fixture(source: &Path, destination: &Path) -> std::io::Result<()> {
+ #[cfg(unix)]
+ { std::os::unix::fs::symlink(source, destination) }
+ #[cfg(not(unix))]
+ {
+ std::fs::create_dir_all(destination)?;
+ for entry in std::fs::read_dir(source)? {
+ let entry = entry?;
+ let target = destination.join(entry.file_name());
+ if entry.file_type()?.is_dir() {
+ link_fixture(&entry.path(), &target)?;
+ } else {
+ std::fs::copy(entry.path(), target)?;
+ }
+ }
+ Ok(())
+ }
+}
+
/// One throwaway AddOns root, cleaned up on drop even if a test panics.
struct Fixtures(PathBuf);
@@ -196,7 +218,8 @@ fn the_directors_two_verified_addons_come_out_on_opposite_sides() {
// `addon_harness` example's job, not a unit test's.
let fx = Fixtures::new("oracle");
for name in ["!OmniCC", "Bagnon", "Bagnon_Core", "Bagnon_Forever"] {
- std::os::unix::fs::symlink(corpus.join(name), fx.root().join(name)).unwrap();
+ // MONKEY (volumetric fog): use the portable corpus fixture for Windows verification.
+ link_fixture(&corpus.join(name), &fx.root().join(name)).unwrap();
}
let reports = survey(fx.root());
let row = |name: &str| {
diff --git a/crates/benilla-app/src/addon_harness/use_probe_tests.rs b/crates/benilla-app/src/addon_harness/use_probe_tests.rs
index f2d3fecf..50ffff56 100644
--- a/crates/benilla-app/src/addon_harness/use_probe_tests.rs
+++ b/crates/benilla-app/src/addon_harness/use_probe_tests.rs
@@ -284,7 +284,8 @@ fn the_directors_two_verified_addons_are_reachable_and_omnicc_is_not_broken() {
};
let fx = Fixtures::new("oracle");
for name in ["!OmniCC", "Bagnon", "Bagnon_Core", "Bagnon_Forever"] {
- std::os::unix::fs::symlink(corpus.join(name), fx.root().join(name)).unwrap();
+ // MONKEY (volumetric fog): share the portable fixture used by the render oracle.
+ super::render_tests::link_fixture(&corpus.join(name), &fx.root().join(name)).unwrap();
}
let reports = survey(fx.root());
let row = |name: &str| {
diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs
index ab191c6c..0e7e1315 100644
--- a/crates/benilla-app/src/capture/fixtures.rs
+++ b/crates/benilla-app/src/capture/fixtures.rs
@@ -1063,6 +1063,16 @@ pub(super) fn seed_ui_fixture(
warn!("capture: ui-options-graphics seed failed: {e}");
}
}
+ // MONKEY (volumetric fog): exercise the rendered Advanced Graphics dropdown and layout.
+ UiFixture::OptionsAdvancedGraphics => {
+ let Some(script) = script else { return; };
+ script.register_cvars(crate::cvars::registered_pairs());
+ if let Err(e) = script.run(
+ "ShowUIPanel(BenillaOptionsFrame); BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()",
+ ) {
+ warn!("capture: ui-options-advanced seed failed: {e}");
+ }
+ }
UiFixture::OptionsChat => {
let Some(script) = script else {
return;
diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs
index db116d71..3a86a536 100644
--- a/crates/benilla-app/src/capture/scenarios.rs
+++ b/crates/benilla-app/src/capture/scenarios.rs
@@ -180,6 +180,8 @@ pub(super) enum UiFixture {
/// read the CVar registration defaults (hermetic capture = no config file), so the pixels
/// move only with the window, the atlas seam, or a registered default.
OptionsGraphics,
+ // MONKEY (volumetric fog): photograph the new quality row on its actual page.
+ OptionsAdvancedGraphics,
/// The Options window ON THE CHAT PAGE (decision 1589 — B246's "no chat section in options").
/// The page 1.12 calls `CHAT_LABEL`: four checkbox rows over three stores at once (a saved
/// variable, three CVars), which is what makes it worth a baseline of its own — the row art is
@@ -474,6 +476,32 @@ pub(super) const SUBJECT_INDOOR: [f32; 3] = [-9469.4, 31.9, 57.9];
/// fixtures, the house-compass and street scenes. Capturable by name (`WOW_CAPTURE=`) for
/// debugging and look passes, but NOT part of the blessed baseline sweep.
pub(super) const ON_DEMAND: &[Scenario] = &[
+ // MONKEY (volumetric fog): UI proof uses the same live page selection as a player.
+ Scenario {
+ name: "ui-options-advanced", map: Some(MAP_AZEROTH),
+ eye: GROUND_EYE, look: GROUND_LOOK, minute: 720,
+ ui: Some(UiFixture::OptionsAdvancedGraphics),
+ },
+ // MONKEY (volumetric fog): reproducible dawn trees, moonlit lamps and the inn's common room.
+ Scenario {
+ name: "volfog-dawn", map: Some(MAP_AZEROTH),
+ eye: [-9460.0, 70.0, 58.0], look: [-9410.0, 120.0, 67.0], minute: 390, ui: None,
+ },
+ Scenario {
+ name: "volfog-night", map: Some(MAP_AZEROTH),
+ eye: [-9460.0, 70.0, 58.0], look: [-9450.0, 20.0, 61.0], minute: 0, ui: None,
+ },
+ // MONKEY (volumetric fog): celestial_sun_direction(420) has WoW azimuth
+ // 45 degrees and elevation 12.20778 degrees: dz = hypot(70,70)*tan(elevation).
+ // The Goldshire lake bank has sunlit air behind the gaps between the trees.
+ Scenario {
+ name: "volfog-sun", map: Some(MAP_AZEROTH),
+ eye: WATER_EYE, look: [-9457.0, -240.6, 92.21753], minute: 420, ui: None,
+ },
+ Scenario {
+ name: "volfog-inn", map: Some(MAP_AZEROTH),
+ eye: INN_EYE, look: INN_LOOK, minute: 390, ui: None,
+ },
// WOW_CAPTURE_WATER_T=: fixed Enhanced water phase (read once, default 0).
// Compare 0 and 1.5; Classic and non-water animation stay frozen.
// Above Lakeshire's broken docks, looking across Lake Everstill and its rocky shores.
@@ -557,6 +585,16 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 480,
ui: None,
},
+ // Longshore shallows at noon, looking down through 2-4 yd of sea: refraction and the caustic
+ // web on the sand (the morning scenes are too low-sun to focus).
+ Scenario {
+ name: "water-caustics",
+ map: Some(MAP_AZEROTH),
+ eye: [-9612.0, 1262.0, 7.0],
+ look: [-9598.0, 1262.0, -3.5],
+ minute: 720,
+ ui: None,
+ },
// Canal water, wall, hull and dock-post contacts in one near view.
Scenario {
name: "stormwind-canal-near",
diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs
index 5fee6b33..9ed05053 100644
--- a/crates/benilla-app/src/cvars.rs
+++ b/crates/benilla-app/src/cvars.rs
@@ -926,6 +926,8 @@ pub(crate) const REGISTERED: &[Registered] = &[
Medium / High, or Custom when the members match none of them; the reference has no \
realtime light or shadow system to preset",
),
+ // MONKEY (volumetric fog): saved live tier; capture override stays session-only.
+ ours("volumetricFog", "1", "benilla's own: near-field volumetric fog, 0 Off / 1 Low / 2 High"),
ours(
"waterQuality",
"1",
@@ -2001,6 +2003,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
("exteriorShadows", "0"),
("spellLightGain", "0"),
("waterQuality", "0"),
+ // MONKEY (volumetric fog): Off preset restores the original atmosphere.
+ ("volumetricFog", "0"),
("lavaLightGain", "0"),
("fireLightGain", "0"),
("nightGain", "1.0"),
@@ -2022,6 +2026,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
("exteriorShadows", "0"),
("spellLightGain", "1"),
("waterQuality", "1"),
+ // MONKEY (volumetric fog): preset atmosphere uses the default cheap tier.
+ ("volumetricFog", "1"),
("lavaLightGain", "1"),
("fireLightGain", "1"),
("nightGain", "0.45"),
@@ -2046,6 +2052,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
("exteriorShadows", "0"),
("spellLightGain", "1"),
("waterQuality", "1"),
+ // MONKEY (volumetric fog): preset atmosphere uses the default cheap tier.
+ ("volumetricFog", "1"),
("lavaLightGain", "1"),
("fireLightGain", "1"),
("nightGain", "0.45"),
@@ -2071,6 +2079,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
("spellLightGain", "1"),
// Mirror reflections stay opt-in until their cost is measured.
("waterQuality", "1"),
+ // MONKEY (volumetric fog): preset atmosphere uses the default cheap tier.
+ ("volumetricFog", "1"),
("lavaLightGain", "1"),
("fireLightGain", "1"),
("nightGain", "0.45"),
@@ -2884,8 +2894,11 @@ mod tests {
// a row, forgot its weld" fail HERE: the length check below is the gate.
let shadows = VideoConfig::default();
let flag = |b: bool| if b { 1.0 } else { 0.0 };
- let lighting: [(&str, f32); 32] = [
+ // MONKEY (volumetric fog): include the atmospheric tier in this fixed-size default table.
+ let lighting: [(&str, f32); 33] = [
("waterQuality", shadows.water_quality as f32),
+ // MONKEY (volumetric fog): weld registry and renderer defaults.
+ ("volumetricFog", shadows.volumetric_fog as f32),
("lavaLightGain", shadows.lava_light_gain),
// The two sun lanes and the cascade they share.
("worldShadows", flag(shadows.world_shadows)),
@@ -2938,9 +2951,10 @@ mod tests {
assert_eq!(d[name], want, "{name}: registered default left the knob");
}
// …and the census IS the row set. A name here that nothing registers would weld against a
- // row the client does not have; the length is the other half — 32 rows, 32 welds.
+ // row the client does not have; the length is the other half — 33 rows, 33 welds.
let welded: std::collections::BTreeSet<&str> = lighting.iter().map(|(n, _)| *n).collect();
- assert_eq!(welded.len(), 32, "the lighting lane welds 32 distinct rows");
+ // MONKEY (volumetric fog): the atmospheric tier joins the default-consumer weld.
+ assert_eq!(welded.len(), 33, "the lighting lane welds 33 distinct rows");
for name in &welded {
assert!(
REGISTERED.iter().any(|r| r.name == *name),
@@ -2994,9 +3008,10 @@ mod tests {
assert_eq!(derive_lighting_quality(&cvars), *name, "{from} -> {name}");
assert_eq!(cvars.get("shadowDistance"), Some("120"));
assert_eq!(cvars.get("interiorShadowSoft"), Some("2.5"));
- for member in ["fireLightGain", "waterQuality", "lavaLightGain", "nightGain", "interiorGain"] {
+ // MONKEY (volumetric fog): Off must remove atmosphere as well as enhanced water.
+ for member in ["fireLightGain", "waterQuality", "volumetricFog", "lavaLightGain", "nightGain", "interiorGain"] {
let expected = if *name == "Off" {
- if matches!(member, "fireLightGain" | "waterQuality" | "lavaLightGain") { "0" } else { "1.0" }
+ if matches!(member, "fireLightGain" | "waterQuality" | "volumetricFog" | "lavaLightGain") { "0" } else { "1.0" }
} else {
cvars.default_of(member).unwrap()
};
@@ -3113,6 +3128,8 @@ mod tests {
"interiorShadowDynamic" => video.interior_shadow_dynamic as f32,
"spellLightGain" => video.spell_light_gain,
"waterQuality" => video.water_quality as f32,
+ // MONKEY (volumetric fog): verify presets reach the renderer.
+ "volumetricFog" => video.volumetric_fog as f32,
"lavaLightGain" => video.lava_light_gain,
"fireLightGain" => video.fire_light_gain,
"moonShadowStrength" => video.moon_shadow_strength,
diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs
index eaafbc24..5491eeac 100644
--- a/crates/benilla-app/src/game_plugins.rs
+++ b/crates/benilla-app/src/game_plugins.rs
@@ -240,6 +240,8 @@ impl PluginGroup for GamePlugins {
// MONKEY (dynamic interiors): the fixture-lit interior lane's cvar bridge — independent
// of the shadow lanes, a plain drop-in.
.add(crate::dynamic_interior::DynamicInteriorPlugin)
+ // MONKEY (volumetric fog): bridge the live setting to Bevy.
+ .add(crate::volumetric_fog::VolumetricFogPlugin)
// The realmlist (decision 1667) — the logon address the login screen edits. Same reason as
// VideoPlugin above: it is a CVar knob, so its resource has to exist before `load_config`.
.add(crate::realmlist::RealmlistPlugin)
diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs
index 093dd2ce..879bd541 100644
--- a/crates/benilla-app/src/lib.rs
+++ b/crates/benilla-app/src/lib.rs
@@ -72,6 +72,8 @@ mod debug_panel;
mod dev;
mod doodad_events;
/// MONKEY (dynamic interiors): the cvar → benilla-world bridge for the fixture-lit interior lane.
+// MONKEY (volumetric fog): optional near-field atmosphere plugin.
+mod volumetric_fog;
mod dynamic_interior;
mod entities;
mod fishing_line;
diff --git a/crates/benilla-app/src/shadow_core.rs b/crates/benilla-app/src/shadow_core.rs
index 01d0ca8a..388affc4 100644
--- a/crates/benilla-app/src/shadow_core.rs
+++ b/crates/benilla-app/src/shadow_core.rs
@@ -266,7 +266,8 @@ fn shadow_sun_travel(to_sun: Vec3) -> Vec3 {
pub(crate) struct ShadowCaster;
#[derive(Component)]
-struct ShadowSun;
+// MONKEY (volumetric fog): identify the celestial map without touching other lights.
+pub(crate) struct ShadowSun;
#[derive(Component)]
struct ShadowCameraLayer {
diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs
index ea840d16..1cb724fb 100644
--- a/crates/benilla-app/src/ui_script/options_tests.rs
+++ b/crates/benilla-app/src/ui_script/options_tests.rs
@@ -1973,6 +1973,60 @@ fn defaults_resets_the_graphics_page_to_registered_defaults() {
/// The name every row on the new page is derived from.
const ADVGFX: &str = "BenillaOptionsFrameContainerBodyAdvancedGraphics";
+// MONKEY (volumetric fog): exercise translations and numeric writes through the real UI.
+#[test]
+fn volumetric_fog_dropdown_is_localised_and_live() {
+ // MONKEY (volumetric fog): validate the authored table and row even on a
+ // data-free checkout; the complete window additionally needs stock FrameXML.
+ let xml = include_str!("../../assets/ui/OptionsFrame.xml");
+ let strings = &xml[xml.find("BENILLA_ADVGFX_STRINGS = {").unwrap()
+ ..xml.find("OPTIONS_PAGE_ROWS = {").unwrap()];
+ let row = xml.split("").nth(1).unwrap().split("").next().unwrap();
+ for (locale, title, labels) in [
+ ("enUS", "Volumetric Fog", ["Off", "Low", "High"]),
+ ("ruRU", "Объёмный туман", ["Выкл", "Низкое", "Высокое"]),
+ ] {
+ let mut s = audio_harness();
+ s.run(&format!("function GetLocale() return '{locale}' end")).unwrap();
+ s.run(strings).unwrap();
+ s.run(r#"
+ self = {}
+ function OptionsRow_OnLoad(row, cvar, title, tip)
+ row.cvar, row.title, row.tip = cvar, title, tip
+ end
+ function OptionsDropdownRow_Setup(row, choices) row.choices = choices end
+ "#).unwrap();
+ s.run(on_load).unwrap();
+ assert_eq!(s.eval::("return self.title").unwrap(), title);
+ assert!(s.eval::("return getglobal(self.tip) == BENILLA_ADVGFX.tips.VOLUMETRIC_FOG and string.len(getglobal(self.tip)) > 80").unwrap());
+ for (tier, label) in labels.iter().enumerate() {
+ assert_eq!(s.eval::(&format!("return self.choices[{}].text", tier + 1)).unwrap(), *label);
+ let _ = s.take_cvar_changes();
+ s.run(&format!("SetCVar(self.cvar, self.choices[{}].value)", tier + 1)).unwrap();
+ assert_eq!(s.take_cvar_changes(), vec![("volumetricFog".to_string(), tier.to_string())]);
+ }
+ if benilla_formats::wow_data().is_none() {
+ continue;
+ }
+ // MONKEY (volumetric fog): with an install, also exercise the real widget kit.
+ let s = audio_harness();
+ s.run(&format!("function GetLocale() return '{locale}' end")).unwrap();
+ let mut s = harness_on(s);
+ s.run("ShowUIPanel(BenillaOptionsFrame) BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()").unwrap();
+ assert_eq!(s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[3]").unwrap(), "RowVolumetricFog");
+ assert_eq!(s.eval::(&format!("return {ADVGFX}RowVolumetricFogLabel:GetText()")).unwrap(), title);
+ assert_eq!(s.eval::(&format!("return {ADVGFX}RowVolumetricFogDropdownText:GetText()")).unwrap(), labels[1]);
+ let _ = s.take_cvar_changes();
+ for (tier, label) in labels.iter().enumerate() {
+ s.run(&format!("OptionsRow_Set({ADVGFX}RowVolumetricFog, '{tier}') OptionsDropdown_ShowValue({ADVGFX}RowVolumetricFog)")).unwrap();
+ assert_eq!(s.take_cvar_changes(), vec![("volumetricFog".to_string(), tier.to_string())]);
+ assert_eq!(s.eval::(&format!("return {ADVGFX}RowVolumetricFogDropdownText:GetText()")).unwrap(), *label);
+ }
+ assert!(s.errors().is_empty(), "{:?}", s.errors());
+ }
+}
+
#[test]
fn water_quality_writes_numeric_tiers_with_localised_labels() {
for (locale, labels, lava_label) in [
@@ -1983,9 +2037,10 @@ fn water_quality_writes_numeric_tiers_with_localised_labels() {
s.run(&format!("function GetLocale() return '{locale}' end")).unwrap();
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame) BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()").unwrap();
- assert_eq!(s.eval::("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)").unwrap(), 17);
+ // MONKEY (volumetric fog): account for the atmosphere row after water.
+ assert_eq!(s.eval::("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)").unwrap(), 18);
assert_eq!(s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[2]").unwrap(), "RowWaterQuality");
- assert_eq!(s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[15]").unwrap(), "RowLavaGlow");
+ assert_eq!(s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[16]").unwrap(), "RowLavaGlow");
assert_eq!(s.eval::(&format!("return {ADVGFX}RowWaterQualityDropdownText:GetText()")).unwrap(), labels[1]);
assert_eq!(s.eval::(&format!("return {ADVGFX}RowLavaGlowLabel:GetText()")).unwrap(), lava_label);
assert!(s.eval::("return BENILLA_TOOLTIP_WATER_QUALITY == BENILLA_ADVGFX.tips.WATER_QUALITY and BENILLA_TOOLTIP_LAVA_GLOW == BENILLA_ADVGFX.tips.LAVA_GLOW").unwrap());
@@ -2900,6 +2955,8 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// that silently resolves to nothing — is untouched: the pairing below is exact, so a
// `BENILLA_` key on the wrong row still fails.
const BENILLA_OWNED: &[(&str, &str)] = &[
+ // MONKEY (volumetric fog): the row owns a translated tooltip too.
+ ("BENILLA_TOOLTIP_VOLUMETRIC_FOG", "AdvancedGraphicsRowVolumetricFog"),
("BENILLA_TOOLTIP_WATER_QUALITY", "AdvancedGraphicsRowWaterQuality"),
("BENILLA_TOOLTIP_LAVA_GLOW", "AdvancedGraphicsRowLavaGlow"),
("BENILLA_TOOLTIP_RENDER_SCALE", "GraphicsRowRenderScale"),
@@ -3165,7 +3222,8 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
// teeth bite hardest, since it is the first one to seat a dropdown, a checkbox and a slider
// whose descriptions are all benilla's.
// Water Quality and Lava Glow add two more: 93 -> 95.
- assert_eq!(raised, 95, "every row but Auto Loot raises a description");
+ // MONKEY (volumetric fog): the atmosphere dropdown adds one more described row.
+ assert_eq!(raised, 96, "every row but Auto Loot raises a description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs
index b88cc850..fb48cee1 100644
--- a/crates/benilla-app/src/video.rs
+++ b/crates/benilla-app/src/video.rs
@@ -220,6 +220,8 @@ pub(crate) struct VideoConfig {
/// Water tier: 0 Classic, 1 Enhanced (default), 2 High with opt-in mirror reflections.
/// Published to the water renderer by `dynamic_interior::bridge`.
pub(crate) water_quality: u8,
+ // MONKEY (volumetric fog): live camera raymarch tier: 0 Off, 1 Low, 2 High.
+ pub(crate) volumetric_fog: u8,
/// Brightness of lava lighting its surroundings, 0..4; 0 disables the glow.
/// Published to `benilla_world::lighting::LavaLightGain` by `dynamic_interior::bridge`.
pub(crate) lava_light_gain: f32,
@@ -463,6 +465,8 @@ impl Default for VideoConfig {
fire_light_gain: 1.0,
spell_light_gain: 1.0,
water_quality: 1,
+ // MONKEY (volumetric fog): default to the inexpensive atmosphere.
+ volumetric_fog: 1,
lava_light_gain: 1.0,
fire_flicker: 1.0,
display: if windowed_env() {
@@ -524,7 +528,7 @@ pub(crate) fn on_cvar(
// Display mode (1627) — the reference's own polarity: `1` is WINDOWED (the row is
// "Windowed Mode"). `apply_window_mode` pushes it to the window when this moves.
"gxwindow" => cfg.display = display_from_flag(v),
- // ── MONKEY (lighting): the dynamic light + shadow system's 32 rows ────────────────────
+ // ── MONKEY (lighting): the dynamic light + shadow system's 33 rows ────────────────────
// They live in THIS observer, and not in one of their own beside `shadow_core` /
// `dynamic_interior`, because of the law the arm above states: *each arm writes only its
// own resource*. Every one of these knobs IS a field of [`VideoConfig`] — the lanes read
@@ -540,8 +544,10 @@ pub(crate) fn on_cvar(
//
// Clamps are each row's own, stated beside it, exactly as for the reference rows above;
// the `ours(...)` entries in `cvars::REGISTERED` carry the matching defaults, and
- // `cvars::tests::registered_defaults_mirror_the_code_truths` welds all 32 pairs.
+ // MONKEY (volumetric fog): the atmospheric tier brings the defaults weld to 33 pairs.
"waterquality" => cfg.water_quality = v.clamp(0.0, 2.0) as u8,
+ // MONKEY (volumetric fog): constrain UI/console writes to supported tiers.
+ "volumetricfog" => cfg.volumetric_fog = v.clamp(0.0, 2.0) as u8,
"lavalightgain" => cfg.lava_light_gain = v.clamp(0.0, 4.0),
"worldshadows" => cfg.world_shadows = ev.flag(),
"charactershadows" => cfg.character_shadows = ev.flag(),
diff --git a/crates/benilla-app/src/volumetric_fog.rs b/crates/benilla-app/src/volumetric_fog.rs
new file mode 100644
index 00000000..7c59b9f2
--- /dev/null
+++ b/crates/benilla-app/src/volumetric_fog.rs
@@ -0,0 +1,548 @@
+//! MONKEY (volumetric fog): gamma-space distance haze and shadow-map light shafts.
+//! Runs after world transparencies and before the world's gamma decode. Bevy's physical
+//! fog loses energy in shade; this artistic lane preserves luminance while converging
+//! toward the zone fog chromaticity. All shader code lives here with its pipeline.
+use crate::{
+ shadow_core::{ShadowSet, ShadowSun},
+ video::VideoConfig,
+ world_shadow::WorldLane,
+};
+use benilla_world::{
+ lighting::{WorldTime, WowLighting},
+ static_gx::StaticGx,
+ view::WorldCamera,
+ weather::WeatherState,
+ wmo_portal::CameraInteriorClaim,
+};
+use bevy::{
+ core_pipeline::{
+ core_3d::graph::{Core3d, Node3d},
+ FullscreenShader,
+ },
+ ecs::query::QueryItem,
+ light::VolumetricLight,
+ pbr::{
+ GpuLights, LightMeta, ViewLightsUniformOffset, ViewShadowBindings, MAX_CASCADES_PER_LIGHT,
+ MAX_DIRECTIONAL_LIGHTS,
+ },
+ prelude::*,
+ render::{
+ diagnostic::RecordDiagnostics,
+ extract_component::{ExtractComponent, ExtractComponentPlugin},
+ render_graph::{
+ NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner,
+ },
+ render_resource::{binding_types::*, *},
+ renderer::{RenderContext, RenderDevice, RenderQueue},
+ view::{Msaa, ViewDepthTexture, ViewTarget, ViewUniform, ViewUniformOffset, ViewUniforms},
+ Render, RenderApp, RenderStartup, RenderSystems,
+ },
+ shader::ShaderDefVal,
+};
+
+pub(crate) struct VolumetricFogPlugin;
+#[derive(Resource)]
+struct FogOverride(Option, f32);
+
+#[derive(Component, Clone, Copy, ExtractComponent, ShaderType)]
+struct FogView {
+ // Gamma byte-space colour and extinction per yard.
+ color_density: Vec4,
+ // Sun colour and strength, independent of PBR lux/exposure.
+ sun_strength: Vec4,
+ // Actual celestial direction and raymarch step count.
+ direction_steps: Vec4,
+}
+
+impl Plugin for VolumetricFogPlugin {
+ fn build(&self, app: &mut App) {
+ let value = std::env::var("WOW_VOLFOG")
+ .ok()
+ .and_then(|v| v.parse::().ok())
+ .filter(|v| *v <= 2);
+ // Dev capture instrument: isolate shafts without changing surface shadows or haze.
+ let shaft_gain = if cfg!(feature = "dev") && std::env::var_os("WOW_CAPTURE").is_some() {
+ std::env::var("WOW_VOLFOG_SHAFT_GAIN")
+ .ok()
+ .and_then(|v| v.parse::().ok())
+ .filter(|v| v.is_finite())
+ .unwrap_or(2.0)
+ .clamp(0.0, 8.0)
+ } else {
+ 2.0
+ };
+ app.insert_resource(FogOverride(value, shaft_gain))
+ .add_plugins(ExtractComponentPlugin::::default())
+ .add_systems(Last, refresh_streamed_shadows.before(ShadowSet::Lanes))
+ .add_systems(Last, update_fog.after(ShadowSet::Lanes));
+ // Keep headless policy tests independent of the renderer.
+ if !app.is_plugin_added::() {
+ return;
+ }
+ let shader = app
+ .world_mut()
+ .resource_mut::>()
+ .add(Shader::from_wgsl(FOG_SHADER, "volumetric_fog.rs"));
+ let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
+ return;
+ };
+ render_app
+ .insert_resource(FogShader(shader))
+ .init_resource::>()
+ .add_systems(RenderStartup, init_pipeline)
+ .add_systems(Render, prepare_pipelines.in_set(RenderSystems::Prepare))
+ .add_render_graph_node::>(Core3d, FogLabel)
+ .add_render_graph_edges(
+ Core3d,
+ (
+ Node3d::EndMainPass,
+ FogLabel,
+ Node3d::StartMainPassPostProcessing,
+ ),
+ );
+ }
+}
+
+// The legacy caster cache otherwise misses streamed trees at a stationary camera.
+// Off must leave even this old cache behaviour untouched for the exact baseline.
+fn refresh_streamed_shadows(
+ video: Res,
+ override_value: Res,
+ gx: Option>,
+ mut lane: ResMut,
+ mut seen: Local