GFX: volumetric light (sun/moon shafts) and moonlight

Volumetric light: the shadow rig's directional light (sun by day, moon at
night) is ray-marched through its cascades at half resolution (16 Medium /
32 High jittered samples from 0.35 yd, height-thinned air, HG g 0.6), then a
depth-aware blur and upsample composite it over the fog pass. Density and
colour follow the zone fog; no shafts in WMO interiors. The volumetric fog
pass retires its own midpoint shafts while this is on. Cvars volumetricLight
0/1/2 and volumetricLightStrength, env WOW_VOLLIGHT; Graphics Preset Medium 1,
High/Ultra 2.

Moonlight: a cool, dim directional term from the visible moon added to the
stock night on terrain, models, WMO exteriors and doodads, shadowed by the
moon's map (shadow_hook::realtime_shadow_moonlit .z), ramping with the moon's
elevation and handing back to the sun at dawn. MonkeyFrame grows one row
(per-frame blob 8800 B). Cvar moonLight 0..2 (0 = the reference night).

Advanced Graphics rows (EN + RU), capture scenarios vol-*, docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-29 17:31:51 +01:00
co-authored by Claude Opus 5.5
parent e1e21d0b29
commit 34d35881c2
26 changed files with 1467 additions and 29 deletions
+3 -1
View File
@@ -24,8 +24,10 @@ behind each constant.
| City day floor | `interiorDaylight`'s room floor also reaches Stormwind/Ironforge rooms that the portal graph connects to the sky (an exterior-facing portal, or one hop from one; `district_sky_rooms` in `daylight.rs`); before this no city room could take it | `interiorDaylight` (default 0, suggested High 0.10) |
| 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` |
| Moonlight | GFX (moonlight): the moon as a light. The stock 1.12 night (Light.dbc night colours on the never-setting lighting sun) stays the base; a cool (0.62, 0.74, 1.0), dim directional term from the visible white moon (`daynight::moon_direction`, the disc the sky draws) is ADDED per fragment with a soft-wrapped N·L on terrain, models, WMO exteriors and retained doodads, shadowed by the moon's own map (`shadow_hook::realtime_shadow_moonlit` `.z`, faded in with the rig's hand-over ramp). It ramps with the moon's elevation (0-12°) and out with the sun, dims under storms, and never reaches interiors or the glue booth. Packed as MonkeyFrame row 16 (`moon`: direction + intensity, base 0.16 gamma at `moonLight 1`) and `misc.w` (hand-over confidence); the per-frame blob is 8800 B (`lighting/moonlight.rs`, `moonlight_hook.wgsl`) | Advanced Graphics → Moonlight, cvar `moonLight` 0..2 (0 = the reference night, bit for bit; Graphics preset Low..Ultra = 1) |
| 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; the dawn curve follows the rendered clock, so a manual or capture time sets it; over an ocean a point below sea level is hazed at the sea surface, since water writes no depth) 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` |
| 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; the dawn curve follows the rendered clock, so a manual or capture time sets it; over an ocean a point below sea level is hazed at the sea surface, since water writes no depth) and daytime sun shafts through gaps beyond 10 yd, sampled from the shadow map (never moon shafts: they are gated by daylight; retired while Volumetric Light is on); 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` |
| Volumetric light | GFX (volumetric light): shadow-mapped sun and moon shafts. The directional light the shadow rig holds (sun by day, moon at night) is ray-marched along each view ray at HALF resolution: 16 (Medium) or 32 (High) samples, quadratic spacing from 0.35 yd (dense near the eye), a per-pixel interleaved-gradient-noise offset, cascade lookups with the covered-sample mean past the cascades, air thinning with height above the eye (10 yd e-fold, so shafts live under canopies and between buildings rather than in the open sky), a Henyey-Greenstein g = 0.6 phase with a quarter isotropic. A depth-aware 4x4 half-res blur removes the jitter and a depth-aware 2x2 upsample composites it with the fog pass's soft screen curve. Extinction = the volumetric fog density law (dawn mist, weather) x the zone's fog end (350 yd reference, x0.7-2.5); colour = sun diffuse (or the moon tint x moonlight intensity, faint) leaned 35 % toward the zone fog hue. No shafts in WMO interiors. While it is on, the volumetric fog pass retires its own midpoint shafts and keeps only its haze and lamp halos. Three passes after the fog node (`benilla-app/src/volumetric_light.rs` + `.wgsl`); no temporal history. Dev capture levers `WOW_VOLLIGHT_DENSITY/_HEIGHT/_GAIN/_DEBUG` (1 = the in-scatter alone) | Advanced Graphics → Volumetric Light (Off/Medium/High) + Volumetric Light Strength 0..2, cvars `volumetricLight`, `volumetricLightStrength`, env `WOW_VOLLIGHT=0\|1\|2`; Graphics preset Medium = 1, High/Ultra = 2 |
| Sky dithering | a faint screen-space dither in the FFXGlow combine so smooth sky and fog gradients do not band (MONKEY p0; was env-only) | Advanced Graphics → Sky Dithering, cvar `skyDither` 0/1 (default 0, Graphics preset High = 1), env `WOW_DITHER=1` still forces it on; bridge in `benilla-app/src/monkey_gfx.rs` |
| HDR emission and bloom | lit WMO windows, additive models/particles and magma can exceed display white; a soft-capped fullscreen halo is added before the faithful FFX clamp | Advanced Graphics → Bloom (Off/Low/High), cvar `bloom`; Off is the unchanged reference image, suggested High preset value 2 |
| Sky quality | Enhanced: the five Light.dbc sky stops through a smooth monotone curve in linear light (no bands at the rings), a soft sun glow tinted by sun and fog colour (fades at night and under cloud), a procedural star field with twinkle and a faint Milky Way over the stock `Stars.m2`. High adds domain-warped cloud detail and sun-lit clouds (self-shadow, silver lining; technique from WarcraftXL, see `THIRD-PARTY.md`). Classic is the reference sky unchanged | Advanced Graphics → Sky Quality, cvar `skyQuality` 0 Classic / 1 Enhanced / 2 High, env `WOW_SKY_QUALITY` |
+3 -1
View File
@@ -12,6 +12,7 @@ pull request.
**Lighting and shadows**
- Realtime sun shadows for characters and the world, including foliage; moon shadows at night
- Moonlight: at night the moon lights the world (cool, dim, from the moon's place in the sky) on top of the original night colours
- Dynamic building interiors lit by their own fixtures
- Torches, braziers and lamps emit flickering light and cast cube-map shadows; terrain blocks torch light
- Daylight through doors and windows (including Stormwind's rooms and cathedral windows)
@@ -26,7 +27,8 @@ pull request.
**Fog and atmosphere**
- Modern fog model: the world fades into the horizon, sun-coloured toward the sun
- Volumetric fog with sun and moon light shafts
- Volumetric fog (distance haze)
- Volumetric light: sun and moon shafts marched through the shadow map (god rays through canopies and between buildings, faint moon shafts at night)
- Lamps glowing through fog at night
- Screen-space sun shafts
- Render distance up to 1497 yards
+85 -3
View File
@@ -510,6 +510,10 @@
ambientOcclusion = "Ambient Occlusion",
-- MONKEY (lampfog)
lampFog = "Lamp Fog",
-- GFX (volumetric light) / (moonlight)
volumetricLight = "Volumetric Light",
volumetricLightStrength = "Volumetric Light Strength",
moonLight = "Moonlight",
-- MONKEY (integration): the daylight lane's district window split.
daylightWindowSplit = "City Window Daylight",
yards = "%d yd",
@@ -518,8 +522,8 @@
GRAPHICS_QUALITY =
"Sets every graphics setting at once: Lighting Quality and everything it sets, "
.. "Render Distance, Sky Quality, Sky Dithering, Modern Fog, Wet Surfaces in Rain, "
.. "Terrain Blocks Torchlight, the daylight through city windows, Ambient Occlusion, Lamp Fog "
.. "and Zone Skyboxes.\n\n"
.. "Terrain Blocks Torchlight, the daylight through city windows, Ambient Occlusion, Lamp Fog, "
.. "Zone Skyboxes, Volumetric Light and Moonlight.\n\n"
.. "Classic is the original client: every enhancement off and the original 350 yd "
.. "view distance. Low and Medium add the enhancements that cost almost nothing; High "
.. "is the recommended look; Ultra raises every quality to its maximum and the view "
@@ -694,6 +698,21 @@
.. "nearest 16 lights; High uses 32. Rain and fog strengthen the halos. "
.. "The effect is unshadowed, but stops at visible scene depth.\n\n"
.. "Off is unchanged, and daylight is unchanged at every setting.",
-- GFX (volumetric light)
VOLUMETRIC_LIGHT =
"Sunlight and moonlight shine through the air as visible shafts: rays through "
.. "tree canopies, between buildings and past your character, following the "
.. "real shadows. Thicker at dawn, in fog and in rain. Needs World Shadows or "
.. "Real Character Shadows; not drawn indoors.\n\n"
.. "Medium uses fewer samples; High is smoother. Off is unchanged.",
VOLUMETRIC_LIGHT_STRENGTH =
"How bright the volumetric light shafts are. 100% is the default; 0 hides them.",
-- GFX (moonlight)
MOONLIGHT =
"The moon lights the world at night: a cool, dim light from where the moon "
.. "really stands in the sky, added on top of the original night colours, with "
.. "moon shadows when Moon Shadows is above 0.\n\n"
.. "At 0 the night looks exactly as the original client drew it. Costs nothing by day.",
},
},
ruRU = {
@@ -755,6 +774,10 @@
ambientOcclusion = "Затенение окружения",
-- MONKEY (lampfog)
lampFog = "Свечение фонарей в тумане",
-- GFX (volumetric light) / (moonlight)
volumetricLight = "Объёмный свет",
volumetricLightStrength = "Сила объёмного света",
moonLight = "Лунный свет",
-- MONKEY (integration): the daylight lane's district window split.
daylightWindowSplit = "Дневной свет через окна города",
yards = "%d ярд.",
@@ -764,7 +787,7 @@
"Задаёт все настройки графики сразу: качество освещения и всё, что оно задаёт, "
.. "дальность прорисовки, качество неба, дизеринг неба, современный туман, мокрые "
.. "поверхности в дождь, заслонение огней рельефом, дневной свет в окнах городов, "
.. "затенение окружения, свечение фонарей в тумане и небосводы зон.\n\n"
.. "затенение окружения, свечение фонарей в тумане, небосводы зон, объёмный свет и лунный свет.\n\n"
.. "«Классика» — оригинальный клиент: все улучшения выключены, дальность обзора "
.. "прежние 350 ярдов. «Низкое» и «Среднее» добавляют почти бесплатные улучшения; "
.. "«Высокое» — рекомендуемый вид; «Ультра» поднимает всё качество до максимума, а "
@@ -949,6 +972,22 @@
.. "качество использует 16 ближайших источников, высокое — 32. Дождь и туман усиливают ореолы. "
.. "Эффект не рассчитывает тени, но обрывается на видимой глубине.\n\n"
.. "Выключено — без изменений; днём изображение не меняется при любом значении.",
-- GFX (volumetric light)
VOLUMETRIC_LIGHT =
"Солнечный и лунный свет виден в воздухе лучами: сквозь кроны деревьев, между "
.. "зданиями и рядом с персонажем, по настоящим теням. Гуще на рассвете, в тумане "
.. "и под дождём. Нужны тени мира или реальные тени персонажей; в помещениях не "
.. "рисуется.\n\n"
.. "«Среднее» использует меньше выборок, «Высокое» — более плавное. Выключено — "
.. "без изменений.",
VOLUMETRIC_LIGHT_STRENGTH =
"Яркость лучей объёмного света. 100% — по умолчанию; 0 скрывает их.",
-- GFX (moonlight)
MOONLIGHT =
"Луна освещает мир ночью: холодный тусклый свет с того места, где луна "
.. "действительно стоит на небе, поверх исходных ночных цветов, с лунными тенями, "
.. "если «Лунные тени» больше 0.\n\n"
.. "При 0 ночь выглядит точно так же, как в оригинальном клиенте. Днём не стоит ничего.",
},
},
}
@@ -1030,6 +1069,8 @@
"RowDaylightWindowSplit",
-- MONKEY (integration): the skybox lane's row (the lane left it out of this list).
"RowZoneSkyboxes",
-- GFX (volumetric light) / (moonlight): appended so the other rows keep their indices.
"RowVolumetricLight", "RowVolumetricLightStrength", "RowMoonLight",
},
-- Guild Names is a child of Player Names (UIOptionsFrame.lua:703-709).
Nameplates = {
@@ -3210,6 +3251,47 @@
"BENILLA_TOOLTIP_ZONE_SKYBOXES")
</OnLoad></Scripts>
</Frame>
<!-- GFX (volumetric light): the shaft tiers, 0 Off / 1 Medium / 2 High. -->
<Frame name="$parentRowVolumetricLight" inherits="OptionsDropdownRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowZoneSkyboxes" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowZoneSkyboxes" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "volumetricLight", BENILLA_ADVGFX.volumetricLight,
"BENILLA_TOOLTIP_VOLUMETRIC_LIGHT")
OptionsDropdownRow_Setup(self, {
{ text = BENILLA_ADVGFX.fogOff, value = "0" },
{ text = BENILLA_ADVGFX.qMedium, value = "1" },
{ text = BENILLA_ADVGFX.qHigh, value = "2" },
})
</OnLoad></Scripts>
</Frame>
<!-- GFX (volumetric light): the shafts' brightness, 0..2 (1 = shipped). -->
<Frame name="$parentRowVolumetricLightStrength" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowVolumetricLight" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowVolumetricLight" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "volumetricLightStrength",
BENILLA_ADVGFX.volumetricLightStrength,
"BENILLA_TOOLTIP_VOLUMETRIC_LIGHT_STRENGTH")
OptionsSliderRow_Setup(self, 0, 2, 0.25)
</OnLoad></Scripts>
</Frame>
<!-- GFX (moonlight): the additive moon term, 0..2 (0 = the reference night). -->
<Frame name="$parentRowMoonLight" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowVolumetricLightStrength" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowVolumetricLightStrength" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "moonLight", BENILLA_ADVGFX.moonLight,
"BENILLA_TOOLTIP_MOONLIGHT")
OptionsSliderRow_Setup(self, 0, 2, 0.25)
</OnLoad></Scripts>
</Frame>
</Frames>
<!-- The poll (see AdvGfx_OnUpdate): this is the one page whose
settings move from outside it — the Lighting Quality row
@@ -1528,8 +1528,53 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 1200,
ui: None,
},
// GFX (volumetric light) / (moonlight): the Goldshire lake-bank forest, facing the fixed 45°
// bearing the sun and the moon share. Afternoon shafts through the canopy; the same spot at
// midnight (moonlight + moon shadows + faint moon shafts) and across the dusk hand-over.
Scenario {
name: "vol-shafts-day",
map: Some(MAP_AZEROTH),
eye: VOL_FOREST_EYE,
look: [-9488.5, -258.5, 80.5], // face 45°, pitch 12°
minute: 1000,
ui: None,
},
Scenario {
name: "vol-moon-forest",
map: Some(MAP_AZEROTH),
eye: VOL_FOREST_EYE,
look: [-9489.0, -259.0, 83.5], // face 45°, pitch 15°
minute: 0,
ui: None,
},
Scenario {
name: "vol-dusk-2000",
map: Some(MAP_AZEROTH),
eye: VOL_FOREST_EYE,
look: [-9488.5, -258.5, 72.0],
minute: 1200,
ui: None,
},
Scenario {
name: "vol-dusk-2130",
map: Some(MAP_AZEROTH),
eye: VOL_FOREST_EYE,
look: [-9488.5, -258.5, 72.0],
minute: 1290,
ui: None,
},
Scenario {
name: "vol-moonrise-2300",
map: Some(MAP_AZEROTH),
eye: VOL_FOREST_EYE,
look: [-9488.5, -258.5, 72.0],
minute: 1380,
ui: None,
},
];
/// GFX (volumetric light): the Goldshire lake bank among the trees (eye 2 yd over the feet).
pub(super) const VOL_FOREST_EYE: [f32; 3] = [-9530.0, -300.0, 68.0];
/// MONKEY (p0 baseline): the Elwynn programme view, above the canopy east of Goldshire looking
/// south-west over the forest to the river and the hills (framed with the `vista` instrument).
pub(super) const GFX_ELWYNN_EYE: [f32; 3] = [-9400.0, -100.0, 122.0];
+31 -3
View File
@@ -544,6 +544,23 @@ pub(crate) const REGISTERED: &[Registered] = &[
"0",
"benilla's own: lamps scattering through night fog, 0 Off / 1 Low (16 lamps) / 2 High (32 lamps)",
),
// GFX (volumetric light): shadow-mapped light shafts for the sun and the moon.
ours(
"volumetricLight",
"0",
"benilla's own: sun and moon light shafts marched through the shadow map, 0 Off / 1 Medium / 2 High",
),
ours(
"volumetricLightStrength",
"1",
"benilla's own: brightness of the volumetric light shafts, 0..2 (1 = default)",
),
// GFX (moonlight): the moon as a light on top of the stock night.
ours(
"moonLight",
"0",
"benilla's own: moonlight on top of the stock night, 0..2 (0 = the reference night)",
),
// MONKEY (p0 skyDither): the FFXGlow combine's deband dither (was env WOW_DITHER only).
// Default 0 = the reference look; the Graphics preset's High sets 1.
ours(
@@ -1865,6 +1882,10 @@ pub(crate) const GRAPHICS_PRESETS: &[(&str, [&str; 5])] = &[
("ambientOcclusion", ["0", "0", "1", "2", "2"]),
("zoneSkyboxes", ["0", "0", "1", "1", "1"]),
("lampFog", ["0", "0", "1", "2", "2"]),
// GFX (volumetric light) / (moonlight): the shafts are a GPU pass (Medium = the cheap tier);
// moonlight is a few ALU per fragment, so every rung past Classic takes it.
("volumetricLight", ["0", "0", "1", "2", "2"]),
("moonLight", ["0", "1", "1", "1", "1"]),
];
/// The column of a rung, matched case-insensitively; `None` for `Custom` or anything else.
@@ -2536,7 +2557,11 @@ mod tests {
let shadows = VideoConfig::default();
let flag = |b: bool| if b { 1.0 } else { 0.0 };
// MONKEY (volumetric fog): include the atmospheric tier in this fixed-size default table.
let lighting: [(&str, f32); 38] = [
let lighting: [(&str, f32); 41] = [
// GFX (volumetric light) / (moonlight): the three new rows.
("volumetricLight", shadows.volumetric_light as f32),
("volumetricLightStrength", shadows.volumetric_light_strength),
("moonLight", shadows.moon_light),
("waterQuality", shadows.water_quality as f32),
// MONKEY (volumetric fog): weld registry and renderer defaults.
("volumetricFog", shadows.volumetric_fog as f32),
@@ -2604,7 +2629,7 @@ mod tests {
// VideoConfig rows, 38 welds (MONKEY lampfog: + lampFog). MONKEY (wind): `foliageWind` is a world resource bridge.
let welded: std::collections::BTreeSet<&str> = lighting.iter().map(|(n, _)| *n).collect();
// MONKEY (volumetric fog): the atmospheric tier joins the default-consumer weld.
assert_eq!(welded.len(), 38, "the lighting lane welds 38 distinct rows");
assert_eq!(welded.len(), 41, "the lighting lane welds 41 distinct rows");
for name in &welded {
assert!(
REGISTERED.iter().any(|r| r.name == *name),
@@ -2758,7 +2783,7 @@ mod tests {
/// disjointness that lets both labels be right at once.
#[test]
fn graphics_rows_are_registered_and_disjoint_from_the_lighting_ladder() {
assert_eq!(GRAPHICS_PRESETS.len(), 11, "add the row count with the row");
assert_eq!(GRAPHICS_PRESETS.len(), 13, "add the row count with the row");
let cvars = fresh_registry();
for (k, values) in GRAPHICS_PRESETS {
assert!(cvars.get(k).is_some(), "{k}: not registered");
@@ -3063,6 +3088,9 @@ mod tests {
"ambientOcclusion" => video.ambient_occlusion as f32,
"zoneSkyboxes" => flag(res::<benilla_world::skybox::ZoneSkyboxes>(&app).0),
"lampFog" => video.lamp_fog as f32,
// GFX (volumetric light) / (moonlight)
"volumetricLight" => video.volumetric_light as f32,
"moonLight" => video.moon_light,
_ => panic!("add the observer readback for {k}"),
};
assert_eq!(applied, value.parse::<f32>().unwrap(), "{name}/{k}");
@@ -64,7 +64,14 @@ fn bridge(
mut lava: ResMut<LavaLightGain>,
// MONKEY (post): bloom also arms the HDR source hooks through the shared light blob.
mut emissive: ResMut<EmissiveTier>,
// GFX (moonlight): the additive moon term's dial (Option: absent in headless tests).
moon_light: Option<ResMut<benilla_world::lighting::MoonLight>>,
) {
if let Some(mut moon_light) = moon_light {
if moon_light.0 != video.moon_light {
moon_light.0 = video.moon_light;
}
}
if water.0 != video.water_quality {
water.0 = video.water_quality;
}
+2
View File
@@ -176,6 +176,8 @@ impl PluginGroup for GamePlugins {
.add(crate::dynamic_interior::DynamicInteriorPlugin)
// MONKEY (volumetric fog): bridge the live setting to Bevy.
.add(crate::volumetric_fog::VolumetricFogPlugin)
// GFX (volumetric light): after the fog plugin, whose graph node it follows.
.add(crate::volumetric_light::VolumetricLightPlugin)
// MONKEY (p0 graphics programme): the programme's cvar bridges (skyDither, ...).
.add(crate::monkey_gfx::MonkeyGfxPlugin)
// MONKEY (post): world-only HDR effects, before the legacy FFX clamp/UI composite.
+2
View File
@@ -55,6 +55,8 @@ mod doodad_events;
mod dynamic_interior;
// MONKEY (volumetric fog): optional near-field atmosphere plugin.
mod volumetric_fog;
// GFX (volumetric light): shadow-mapped sun/moon light shafts.
mod volumetric_light;
// MONKEY (p0 graphics programme): the programme's cvar bridges (skyDither, ...).
mod monkey_gfx;
// MONKEY (post): optional world-only HDR post-processing.
@@ -846,6 +846,8 @@ mod tests {
// `prepare_pipelines` specialises every such key for EVERY `Camera3d` view on every
// frame, whether its cvar is on or not, so the variants compile behind the entry cover
// and a player enabling the row later hits the cache, not a live compile.
// - GFX (volumetric light) VolLightPipeline (`volumetric_light`): the same contract, its
// march/blur/composite keys warmed for every 3-D view every frame.
let exempt = ["UiGammaPipeline"];
let own_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let warm_src = std::fs::read_to_string(own_src.join("pipe_warm/mod.rs")).unwrap()
@@ -2804,6 +2804,10 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
("BENILLA_TOOLTIP_DAYLIGHT_WINDOW_SPLIT", "AdvancedGraphicsRowDaylightWindowSplit"),
// MONKEY (skybox): the zone skybox row.
("BENILLA_TOOLTIP_ZONE_SKYBOXES", "AdvancedGraphicsRowZoneSkyboxes"),
// GFX (volumetric light) / (moonlight)
("BENILLA_TOOLTIP_VOLUMETRIC_LIGHT", "AdvancedGraphicsRowVolumetricLight"),
("BENILLA_TOOLTIP_VOLUMETRIC_LIGHT_STRENGTH", "AdvancedGraphicsRowVolumetricLightStrength"),
("BENILLA_TOOLTIP_MOONLIGHT", "AdvancedGraphicsRowMoonLight"),
("BENILLA_TOOLTIP_WATER_QUALITY", "AdvancedGraphicsRowWaterQuality"),
("BENILLA_TOOLTIP_LAVA_GLOW", "AdvancedGraphicsRowLavaGlow"),
("BENILLA_TOOLTIP_RENDER_SCALE", "GraphicsRowRenderScale"),
+16
View File
@@ -139,6 +139,13 @@ pub(crate) struct VideoConfig {
pub(crate) ambient_occlusion: u8,
// MONKEY (lampfog): point-light fog tier: 0 Off, 1 nearest 16, 2 nearest 32.
pub(crate) lamp_fog: u8,
// GFX (volumetric light): shadow-mapped sun/moon light shafts, 0 Off / 1 Medium / 2 High.
pub(crate) volumetric_light: u8,
// GFX (volumetric light): the shafts' brightness multiplier, 0..2 (1 = the shipped tuning).
pub(crate) volumetric_light_strength: f32,
// GFX (moonlight): the additive moon term's multiplier, 0..2 (0 = the stock night exactly).
// Bridged to `benilla_world::lighting::MoonLight` by `dynamic_interior::bridge`.
pub(crate) moon_light: f32,
/// 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,
@@ -406,6 +413,10 @@ impl Default for VideoConfig {
ambient_occlusion: 0,
// MONKEY (lampfog): opt-in; zero is exactly the pre-lane render.
lamp_fog: 0,
// GFX (volumetric light) / (moonlight): opt-in; the Graphics Preset turns them on.
volumetric_light: 0,
volumetric_light_strength: 1.0,
moon_light: 0.0,
lava_light_gain: 1.0,
fire_flicker: 1.0,
display: if windowed_env() {
@@ -486,6 +497,11 @@ pub(crate) fn on_cvar(
"ambientocclusion" => cfg.ambient_occlusion = v.clamp(0.0, 2.0) as u8,
// MONKEY (lampfog): 0 Off / 1 nearest 16 / 2 nearest 32.
"lampfog" => cfg.lamp_fog = v.clamp(0.0, 2.0) as u8,
// GFX (volumetric light): 0 Off / 1 Medium / 2 High, and its 0..2 strength.
"volumetriclight" => cfg.volumetric_light = v.clamp(0.0, 2.0) as u8,
"volumetriclightstrength" => cfg.volumetric_light_strength = v.clamp(0.0, 2.0),
// GFX (moonlight): 0 is meaningful (the stock night, bit for bit).
"moonlight" => cfg.moon_light = v.clamp(0.0, 2.0),
"lavalightgain" => cfg.lava_light_gain = v.clamp(0.0, 4.0),
"worldshadows" => cfg.world_shadows = ev.flag(),
"charactershadows" => cfg.character_shadows = ev.flag(),
+7 -4
View File
@@ -160,7 +160,7 @@ const SEA_PLANE_REACH: f32 = 400.0;
/// MONKEY (visualfix): the eye must be this far above sea level (yd) for the plane to apply.
const SEA_LEVEL_MIN_EYE: f32 = 0.25;
fn density(minute: f32, weather: f32, indoors: bool) -> f32 {
pub(crate) 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 }
@@ -258,8 +258,11 @@ fn update_fog(
suns: Query<(Entity, &DirectionalLight), With<ShadowSun>>,
// MONKEY (visualfix): ocean surfaces, for the haze's sea-level plane.
water: Query<&benilla_world::liquid::WaterChunkInfo>,
// GFX (volumetric light): while that lane is live it owns the shafts; the haze keeps its own.
vol_light: Option<Res<crate::volumetric_light::VolLightOverride>>,
) {
let tier = override_value.fog.unwrap_or(video.volumetric_fog).min(2);
let shafts_retired = vol_light.is_some_and(|v| v.tier(&video) > 0);
let lamp_tier = override_value.lamp.unwrap_or(video.lamp_fog).min(2);
let enabled = (tier != 0 || lamp_tier != 0)
&& cameras.iter().any(|(_, camera, _, _, _)| camera.is_active);
@@ -324,7 +327,7 @@ fn update_fog(
if over && tier != 0 { 1.0 } else { 0.0 }
});
// MONKEY (fog): rows 1-3 as packed for the light buffer.
let rows = monkey.as_ref().map_or([[0.0; 4]; 16], |m| m.pack(0.0, 0.0));
let rows = monkey.as_ref().map_or([[0.0; 4]; benilla_world::lighting::MONKEY_FRAME_ROWS], |m| m.pack(0.0, 0.0));
commands.entity(entity).insert(FogView {
mf_fog1: Vec4::from_array(rows[1]),
mf_fog2: Vec4::from_array(rows[2]),
@@ -348,7 +351,7 @@ fn update_fog(
),
sun_strength: Vec3::from_array(lighting.diffuse)
.lerp(Vec3::ONE, 0.5)
.extend(if tier == 0 {
.extend(if tier == 0 || shafts_retired {
0.0
} else {
override_value.shaft_gain * daylight
@@ -395,7 +398,7 @@ struct FogPipeline {
#[derive(Component)]
struct ViewFogPipeline(CachedRenderPipelineId);
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
struct FogLabel;
pub(crate) struct FogLabel;
fn init_pipeline(
mut commands: Commands,
+735
View File
@@ -0,0 +1,735 @@
//! GFX (volumetric light): shadow-mapped light shafts for the SUN and the MOON.
//!
//! The directional light that holds the realtime shadow map (`shadow_core`'s one rig, aimed at
//! the sun by day and at the moon at night) is ray-marched through the air in front of every
//! pixel: a few jittered samples along the view ray each ask the cascade "is this point of air
//! lit?", and the lit air in-scatters the light toward the eye with a forward-peaked phase. The
//! result is real light shafts (god rays) through tree canopies, between buildings and past
//! the player, including right in front of the camera, which the screen-space `sunShafts` lane
//! (sky pixels only) and the old midpoint march in `volumetric_fog` (10 yd dead zone, fixed
//! samples) could not show.
//!
//! Passes, all after the world's transparencies and the gamma fog pass (so this light sits on
//! top of the haze it is scattering through):
//! 1. **march**, half resolution: `steps` samples per ray, quadratic spacing (dense near the eye,
//! where the shafts are), a per-pixel interleaved-gradient-noise start offset. Writes the
//! in-scatter scalar and the marched distance.
//! 2. **blur**, half resolution: a depth-aware 4x4 gather that averages the jitter pattern out
//! and never bleeds shafts across a depth edge.
//! 3. **composite**, full resolution: a depth-aware 2x2 upsample, coloured by the light (sun
//! colour, or the cool moon tint and intensity from `benilla_world::lighting::moonlight`) and
//! tinted toward the zone fog colour, added with the same soft screen curve the fog pass uses.
//!
//! Fog integration: the extinction per yard is the fog lane's own density (dawn mist, weather)
//! scaled by how short the zone's authored fog end is; the in-scatter colour leans toward the
//! zone fog colour. Interiors (the camera in a WMO room) take no shafts. The Graphics Preset turns
//! it off on Classic/Low, Medium on Medium, High on High/Ultra (`volumetricLight`), with a
//! `volumetricLightStrength` multiplier.
//!
//! No temporal accumulation: the jitter is fixed per pixel and removed by the blur, so a still
//! frame is stable and the pass has no history to invalidate on camera cuts.
use crate::{
shadow_core::{ShadowSet, ShadowSun},
video::VideoConfig,
volumetric_fog::FogLabel,
};
use benilla_world::{
lighting::{moon_light_intensity, ShadowHandover, WorldTime, WowLighting},
view::WorldCamera,
weather::WeatherState,
wmo_portal::CameraInteriorClaim,
};
use bevy::{
core_pipeline::{
FullscreenShader,
core_3d::graph::{Core3d, Node3d},
},
ecs::query::QueryItem,
pbr::{
GpuLights, LightMeta, MAX_CASCADES_PER_LIGHT, MAX_DIRECTIONAL_LIGHTS,
ViewLightsUniformOffset, ViewShadowBindings,
},
prelude::*,
render::{
Render, RenderApp, RenderStartup, RenderSystems,
camera::ExtractedCamera,
diagnostic::RecordDiagnostics,
extract_component::{ExtractComponent, ExtractComponentPlugin},
render_graph::{
NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner,
},
render_resource::{binding_types::*, *},
renderer::{RenderContext, RenderDevice, RenderQueue},
texture::{CachedTexture, TextureCache},
view::{Msaa, ViewDepthTexture, ViewTarget, ViewUniform, ViewUniformOffset, ViewUniforms},
},
shader::ShaderDefVal,
};
pub(crate) struct VolumetricLightPlugin;
/// Session-only capture/test override of the tier (`WOW_VOLLIGHT=0|1|2`); the persistent setting
/// is `volumetricLight`. Read by `volumetric_fog` too, which retires its own midpoint shafts
/// whenever this lane is live.
#[derive(Resource, Clone, Copy, Default)]
pub(crate) struct VolLightOverride(pub(crate) Option<u8>);
impl VolLightOverride {
/// The live tier: the session override, else the setting.
pub(crate) fn tier(&self, video: &VideoConfig) -> u8 {
self.0.unwrap_or(video.volumetric_light).min(2)
}
}
/// March samples per tier (index = tier).
const STEPS: [f32; 3] = [0.0, 16.0, 32.0];
/// The march starts this far from the eye (inside it the near plane clips geometry anyway).
const MARCH_START: f32 = 0.35;
/// Upper bound on the march length (yd); the cascade's own reach (`shadowDistance`) is lower.
const MARCH_MAX: f32 = 160.0;
/// Henyey-Greenstein anisotropy: forward-peaked, like air with a little haze.
const ANISOTROPY: f32 = 0.6;
/// The sun's shaft gain at `volumetricLightStrength 1` (gamma units per unit in-scatter).
const SUN_SHAFT_GAIN: f32 = 9.0;
/// The moon's shaft gain, applied on top of its (already dim) moonlight intensity: faint.
const MOON_SHAFT_GAIN: f32 = 30.0;
/// Cool moon tint; the same constant `benilla::moonlight_hook` lights surfaces with.
const MOON_TINT: Vec3 = Vec3::new(0.62, 0.74, 1.0);
/// How much the in-scatter colour leans toward the (luminance-normalised) zone fog colour.
const FOG_TINT: f32 = 0.35;
/// Reference fog end (yd): a zone authored foggier than this scatters more, clearer less.
const FOG_END_REFERENCE: f32 = 350.0;
/// Scattering air thins with height above the eye (e-folding yards): shafts live in the air
/// around and below the viewer (under canopies, between buildings), not in the open sky above.
const HEIGHT_FALLOFF: f32 = 10.0;
/// Dev/capture tuning levers (`WOW_VOLLIGHT_DENSITY`, `_HEIGHT`, `_GAIN`, `_DEBUG`), honoured only
/// in a dev capture, the same door `volumetric_fog`'s `WOW_VOLFOG_SHAFT_GAIN` uses.
#[derive(Resource, Clone, Copy)]
struct VolLightTune {
density: f32,
height: f32,
gain: f32,
debug: f32,
}
impl VolLightTune {
fn from_env() -> Self {
let dev = crate::run_mode::dev_affordances() && std::env::var_os("WOW_CAPTURE").is_some();
let knob = |k: &str, d: f32| {
if !dev {
return d;
}
std::env::var(k)
.ok()
.and_then(|v| v.parse::<f32>().ok())
.filter(|v| v.is_finite())
.unwrap_or(d)
};
Self {
density: knob("WOW_VOLLIGHT_DENSITY", 1.0).clamp(0.0, 50.0),
height: knob("WOW_VOLLIGHT_HEIGHT", HEIGHT_FALLOFF).clamp(1.0, 10000.0),
gain: knob("WOW_VOLLIGHT_GAIN", 1.0).clamp(0.0, 50.0),
debug: knob("WOW_VOLLIGHT_DEBUG", 0.0),
}
}
}
#[derive(Component, Clone, Copy, PartialEq, ExtractComponent, ShaderType)]
struct VolLightView {
/// xyz = unit direction toward the light body (sun or moon, Bevy space); w = march steps.
dir_steps: Vec4,
/// rgb = in-scatter colour (gain, strength and hand-over fade folded in); w = extinction/yd.
colour: Vec4,
/// x = march length cap (yd), y = march start (yd), z = anisotropy g, w = height falloff (yd).
range: Vec4,
/// x = debug view (1 = the in-scatter alone, as grey), yzw unused.
debug: Vec4,
}
impl Plugin for VolumetricLightPlugin {
fn build(&self, app: &mut App) {
let tier = std::env::var("WOW_VOLLIGHT")
.ok()
.and_then(|v| v.parse::<u8>().ok())
.filter(|v| *v <= 2);
app.insert_resource(VolLightOverride(tier))
.insert_resource(VolLightTune::from_env())
.add_plugins(ExtractComponentPlugin::<VolLightView>::default())
.add_systems(Last, update_views.after(ShadowSet::Lanes));
if !app.is_plugin_added::<AssetPlugin>() {
return;
}
let shader = app
.world_mut()
.resource_mut::<Assets<Shader>>()
.add(Shader::from_wgsl(
include_str!("volumetric_light.wgsl"),
"volumetric_light.wgsl",
));
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
return;
};
render_app
.insert_resource(VolLightShader(shader))
.init_resource::<SpecializedRenderPipelines<VolLightPipeline>>()
.add_systems(RenderStartup, init_pipeline)
.add_systems(
Render,
(
prepare_pipelines.in_set(RenderSystems::Prepare),
prepare_resources.in_set(RenderSystems::PrepareResources),
),
)
.add_render_graph_node::<ViewNodeRunner<VolLightNode>>(Core3d, VolLightLabel)
.add_render_graph_edges(
Core3d,
(
Node3d::EndMainPass,
FogLabel,
VolLightLabel,
Node3d::StartMainPassPostProcessing,
),
);
}
}
/// The in-scatter colour and fade of the body the shadow rig holds this frame, or `None` when
/// no body casts (daylight hand-over window, `moonShadowStrength 0` / `moonLight 0` at night).
fn light_body(
lighting: &WowLighting,
handover: &ShadowHandover,
moon_gain: f32,
) -> Option<(Vec3, Vec3)> {
if handover.weight > 0.0 {
// The sun: its lane weight already carries the elevation ramp and the hand-over ramp.
let dir = lighting.celestial_dir().normalize_or_zero();
let colour = Vec3::from_array(lighting.diffuse).lerp(Vec3::ONE, 0.5)
* (SUN_SHAFT_GAIN * handover.weight.clamp(0.0, 1.0));
return (dir != Vec3::ZERO).then_some((dir, colour));
}
let ramp = handover.moon_ramp();
if ramp <= 0.0 {
return None;
}
let dir = lighting.moon_dir().normalize_or_zero();
let intensity = moon_light_intensity(
moon_gain,
lighting.celestial_dir().y,
dir.y,
lighting.storm(),
);
(intensity > 0.0 && dir != Vec3::ZERO)
.then(|| (dir, MOON_TINT * (MOON_SHAFT_GAIN * intensity * ramp)))
}
/// Extinction per yard: the fog lane's density law, scaled by the zone's authored fog end.
fn extinction(minute: f32, weather: f32, fog_end: f32) -> f32 {
let zone = if fog_end > 1.0 {
(FOG_END_REFERENCE / fog_end).clamp(0.7, 2.5)
} else {
1.0
};
crate::volumetric_fog::density(minute, weather, false) * zone
}
/// The in-scatter colour leaned toward the zone fog's hue (luminance kept).
fn fog_tinted(colour: Vec3, fog: Vec3) -> Vec3 {
let luma = |c: Vec3| c.dot(Vec3::new(0.2126, 0.7152, 0.0722));
let l = luma(fog);
if l <= 1e-4 {
return colour;
}
colour * Vec3::ONE.lerp(fog / l, FOG_TINT)
}
fn update_views(
mut commands: Commands,
video: Res<VideoConfig>,
override_tier: Res<VolLightOverride>,
tune: Option<Res<VolLightTune>>,
lighting: Res<WowLighting>,
handover: Res<ShadowHandover>,
clock: Res<WorldTime>,
weather: Res<WeatherState>,
interior: Res<CameraInteriorClaim>,
rendered: Option<Res<benilla_world::lighting::GameClock>>,
suns: Query<&DirectionalLight, With<ShadowSun>>,
mut cameras: Query<(Entity, &Camera, &mut Camera3d, Option<&VolLightView>), With<WorldCamera>>,
) {
let tier = override_tier.tier(&video);
let tune = tune.map_or(
VolLightTune { density: 1.0, height: HEIGHT_FALLOFF, gain: 1.0, debug: 0.0 },
|t| *t,
);
let strength = video.volumetric_light_strength.clamp(0.0, 2.0);
let rig_live = suns.iter().any(|sun| sun.shadows_enabled);
let body = (tier > 0 && strength > 0.0 && rig_live && interior.0.is_none())
.then(|| light_body(&lighting, &handover, video.moon_light))
.flatten();
for (entity, camera, mut camera3d, old) in &mut cameras {
let Some((dir, colour)) = body.filter(|_| camera.is_active) else {
if old.is_some() {
commands.entity(entity).remove::<VolLightView>();
}
continue;
};
camera3d.depth_texture_usages.0 |= TextureUsages::TEXTURE_BINDING.bits();
let weather_amount = weather
.sky_density
.max(weather.effect_density)
.clamp(0.0, 1.0);
let sigma = extinction(
crate::post::grading::rendered_minute(&clock, rendered.as_deref()),
weather_amount,
lighting.fog_end(),
) * tune.density;
let colour =
fog_tinted(colour, Vec3::from_array(lighting.fog_color)) * strength * tune.gain;
let next = VolLightView {
dir_steps: dir.extend(STEPS[tier as usize]),
colour: colour.extend(sigma),
range: Vec4::new(
video.shadow_distance.min(MARCH_MAX),
MARCH_START,
ANISOTROPY,
tune.height,
),
debug: Vec4::new(tune.debug, 0.0, 0.0, 0.0),
};
if old != Some(&next) {
commands.entity(entity).insert(next);
}
}
}
#[derive(Resource)]
struct VolLightShader(Handle<Shader>);
/// Half-resolution march / blur target: `.r` in-scatter, `.g` marched distance (yd).
const HALF_FORMAT: TextureFormat = TextureFormat::Rgba16Float;
#[derive(Resource)]
struct VolLightPipeline {
/// `[single, multisampled]` depth: the march's and the composite's layouts.
march_layouts: [BindGroupLayoutDescriptor; 2],
blur_layout: BindGroupLayoutDescriptor,
composite_layouts: [BindGroupLayoutDescriptor; 2],
shader: Handle<Shader>,
fullscreen: FullscreenShader,
comparison: Sampler,
}
/// Which of the three stages, plus the keys each is specialised on.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum VolLightKey {
March { multisampled: bool },
Blur,
Composite { format: TextureFormat, multisampled: bool },
}
#[derive(Component)]
struct ViewVolLightPipelines {
march: CachedRenderPipelineId,
blur: CachedRenderPipelineId,
composite: CachedRenderPipelineId,
}
#[derive(Component)]
struct VolLightTextures {
march: CachedTexture,
blur: CachedTexture,
}
#[derive(Component)]
struct VolLightUniform(UniformBuffer<VolLightView>);
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
struct VolLightLabel;
fn depth_entry(multisampled: bool) -> BindGroupLayoutEntryBuilder {
if multisampled {
texture_depth_2d_multisampled()
} else {
texture_depth_2d()
}
}
fn init_pipeline(
mut commands: Commands,
device: Res<RenderDevice>,
shader: Res<VolLightShader>,
fullscreen: Res<FullscreenShader>,
) {
let march_layouts = [false, true].map(|multisampled| {
BindGroupLayoutDescriptor::new(
"vol_light_march_layout",
&BindGroupLayoutEntries::sequential(
ShaderStages::FRAGMENT,
(
depth_entry(multisampled),
uniform_buffer::<ViewUniform>(true),
uniform_buffer::<GpuLights>(true),
texture_2d_array(TextureSampleType::Depth),
sampler(SamplerBindingType::Comparison),
uniform_buffer::<VolLightView>(false),
),
),
)
});
let blur_layout = BindGroupLayoutDescriptor::new(
"vol_light_blur_layout",
&BindGroupLayoutEntries::sequential(
ShaderStages::FRAGMENT,
(texture_2d(TextureSampleType::Float { filterable: false }),),
),
);
let composite_layouts = [false, true].map(|multisampled| {
BindGroupLayoutDescriptor::new(
"vol_light_composite_layout",
&BindGroupLayoutEntries::sequential(
ShaderStages::FRAGMENT,
(
texture_2d(TextureSampleType::Float { filterable: false }),
depth_entry(multisampled),
texture_2d(TextureSampleType::Float { filterable: false }),
uniform_buffer::<ViewUniform>(true),
uniform_buffer::<VolLightView>(false),
),
),
)
});
commands.insert_resource(VolLightPipeline {
march_layouts,
blur_layout,
composite_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 VolLightPipeline {
type Key = VolLightKey;
fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor {
let mut defs: Vec<ShaderDefVal> = 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),
];
let (label, layout, entry, format) = match key {
VolLightKey::March { multisampled } => {
defs.push("VL_MARCH".into());
if multisampled {
defs.push("MULTISAMPLED".into());
}
(
"vol_light_march",
self.march_layouts[multisampled as usize].clone(),
"fs_march",
HALF_FORMAT,
)
}
VolLightKey::Blur => {
defs.push("VL_BLUR".into());
("vol_light_blur", self.blur_layout.clone(), "fs_blur", HALF_FORMAT)
}
VolLightKey::Composite {
format,
multisampled,
} => {
defs.push("VL_COMPOSITE".into());
if multisampled {
defs.push("MULTISAMPLED".into());
}
(
"vol_light_composite",
self.composite_layouts[multisampled as usize].clone(),
"fs_composite",
format,
)
}
};
RenderPipelineDescriptor {
label: Some(label.into()),
layout: vec![layout],
vertex: self.fullscreen.to_vertex_state(),
fragment: Some(FragmentState {
shader: self.shader.clone(),
shader_defs: defs,
entry_point: Some(entry.into()),
targets: vec![Some(ColorTargetState {
format,
blend: None,
write_mask: ColorWrites::ALL,
})],
}),
..default()
}
}
}
fn prepare_pipelines(
mut commands: Commands,
cache: Res<PipelineCache>,
pipeline: Res<VolLightPipeline>,
mut specialized: ResMut<SpecializedRenderPipelines<VolLightPipeline>>,
views: Query<(Entity, &ViewTarget, &Msaa), With<VolLightView>>,
all_views: Query<(&ViewTarget, &Msaa), With<Camera3d>>,
) {
// Warm every reachable key on every 3-D view, feature on or off (the `pipe_warm` census
// contract): enabling the row later hits the cache, never a live compile.
let mut keys = |format: TextureFormat, multisampled: bool| ViewVolLightPipelines {
march: specialized.specialize(&cache, &pipeline, VolLightKey::March { multisampled }),
blur: specialized.specialize(&cache, &pipeline, VolLightKey::Blur),
composite: specialized.specialize(
&cache,
&pipeline,
VolLightKey::Composite {
format,
multisampled,
},
),
};
for (target, msaa) in &all_views {
keys(target.main_texture_format(), msaa.samples() > 1);
}
for (entity, target, msaa) in &views {
let ids = keys(target.main_texture_format(), msaa.samples() > 1);
commands.entity(entity).insert(ids);
}
}
/// The two half-size targets (rounded up) and the view's uniform, rewritten in place.
fn prepare_resources(
mut commands: Commands,
mut textures: ResMut<TextureCache>,
device: Res<RenderDevice>,
queue: Res<RenderQueue>,
mut views: Query<(
Entity,
&ExtractedCamera,
&VolLightView,
Option<&mut VolLightUniform>,
)>,
) {
for (entity, camera, view, uniform) in &mut views {
let Some(size) = camera.physical_viewport_size else {
continue;
};
match uniform {
Some(mut uniform) => {
uniform.0.set(*view);
uniform.0.write_buffer(&device, &queue);
}
None => {
let mut uniform = UniformBuffer::from(*view);
uniform.write_buffer(&device, &queue);
commands.entity(entity).insert(VolLightUniform(uniform));
}
}
let mut half = |label: &'static str| {
textures.get(
&device,
TextureDescriptor {
label: Some(label),
size: Extent3d {
width: size.x.div_ceil(2).max(1),
height: size.y.div_ceil(2).max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format: HALF_FORMAT,
usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
)
};
let march = half("vol_light_march");
let blur = half("vol_light_blur");
commands
.entity(entity)
.insert(VolLightTextures { march, blur });
}
}
#[derive(Default)]
struct VolLightNode;
impl ViewNode for VolLightNode {
type ViewQuery = (
&'static ViewTarget,
&'static ViewDepthTexture,
&'static ViewUniformOffset,
&'static ViewLightsUniformOffset,
&'static ViewShadowBindings,
// Gates the pass: the uniform, textures and pipelines outlive a disabled frame.
&'static VolLightView,
&'static VolLightUniform,
&'static VolLightTextures,
&'static ViewVolLightPipelines,
);
fn run<'w>(
&self,
_graph: &mut RenderGraphContext,
context: &mut RenderContext<'w>,
(target, depth, view_offset, light_offset, shadows, _, uniform, half, ids): QueryItem<
'w,
'_,
Self::ViewQuery,
>,
world: &'w World,
) -> Result<(), NodeRunError> {
let cache = world.resource::<PipelineCache>();
let (Some(march), Some(blur), Some(composite)) = (
cache.get_render_pipeline(ids.march),
cache.get_render_pipeline(ids.blur),
cache.get_render_pipeline(ids.composite),
) else {
return Ok(());
};
if !depth
.texture
.usage()
.contains(TextureUsages::TEXTURE_BINDING)
{
return Ok(());
}
let (Some(view), Some(lights), Some(params)) = (
world.resource::<ViewUniforms>().uniforms.binding(),
world.resource::<LightMeta>().view_gpu_lights.binding(),
uniform.0.binding(),
) else {
return Ok(());
};
let settings = world.resource::<VolLightPipeline>();
let multisampled = (depth.texture.sample_count() > 1) as usize;
let device = context.render_device();
let march_bind = device.create_bind_group(
"vol_light_march",
&cache.get_bind_group_layout(&settings.march_layouts[multisampled]),
&BindGroupEntries::sequential((
depth.view(),
view.clone(),
lights,
&shadows.directional_light_depth_texture_view,
&settings.comparison,
params.clone(),
)),
);
let blur_bind = device.create_bind_group(
"vol_light_blur",
&cache.get_bind_group_layout(&settings.blur_layout),
&BindGroupEntries::sequential((&half.march.default_view,)),
);
let out = target.post_process_write();
let composite_bind = device.create_bind_group(
"vol_light_composite",
&cache.get_bind_group_layout(&settings.composite_layouts[multisampled]),
&BindGroupEntries::sequential((
out.source,
depth.view(),
&half.blur.default_view,
view,
params,
)),
);
let diagnostics = context.diagnostic_recorder();
let stages: [(&str, &TextureView, &RenderPipeline, &BindGroup, &[u32]); 3] = [
(
"vol_light_march",
&half.march.default_view,
march,
&march_bind,
&[view_offset.offset, light_offset.offset],
),
("vol_light_blur", &half.blur.default_view, blur, &blur_bind, &[]),
(
"vol_light_composite",
out.destination,
composite,
&composite_bind,
&[view_offset.offset],
),
];
for (label, destination, pipeline, bind, offsets) in stages {
let mut pass = context
.command_encoder()
.begin_render_pass(&RenderPassDescriptor {
label: Some(label),
color_attachments: &[Some(RenderPassColorAttachment {
view: destination,
depth_slice: None,
resolve_target: None,
ops: Operations::default(),
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
let span = diagnostics.pass_span(&mut pass, label);
pass.set_pipeline(pipeline);
pass.set_bind_group(0, bind, offsets);
pass.draw(0..3, 0..1);
span.end(&mut pass);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn handover_sun(weight: f32) -> ShadowHandover {
let mut h = ShadowHandover::default();
h.weight = weight;
h
}
#[test]
fn the_sun_body_fades_with_its_lane_weight_and_none_casts_nothing() {
let lighting = WowLighting::default();
// The default lighting has no celestial direction: nothing to scatter.
assert!(light_body(&lighting, &handover_sun(1.0), 1.0).is_none());
assert!(light_body(&lighting, &handover_sun(0.0), 1.0).is_none());
}
#[test]
fn foggier_zones_scatter_more_and_the_tint_keeps_luminance_order() {
let clear = extinction(720.0, 0.0, 1400.0);
let foggy = extinction(720.0, 0.0, 120.0);
assert!(foggy > clear * 2.0);
assert_eq!(extinction(720.0, 0.0, 0.0), crate::volumetric_fog::density(720.0, 0.0, false));
let white = fog_tinted(Vec3::ONE, Vec3::new(0.5, 0.5, 0.5));
assert!((white - Vec3::ONE).length() < 1e-5, "a grey fog does not tint");
let blue = fog_tinted(Vec3::ONE, Vec3::new(0.2, 0.3, 0.8));
assert!(blue.z > blue.x, "a blue fog leans the shafts blue");
}
#[test]
fn tiers_disable_classic_and_the_override_wins() {
let mut video = VideoConfig::default();
assert_eq!(VolLightOverride(None).tier(&video), 0, "registered default is off");
video.volumetric_light = 2;
assert_eq!(VolLightOverride(None).tier(&video), 2);
assert_eq!(VolLightOverride(Some(1)).tier(&video), 1);
assert_eq!(STEPS[0], 0.0);
assert!(STEPS[2] > STEPS[1]);
}
}
@@ -0,0 +1,208 @@
// GFX (volumetric light): shadow-mapped light shafts, three stages (see volumetric_light.rs).
// VL_MARCH half resolution: march the directional shadow map along the view ray.
// VL_BLUR half resolution: depth-aware 4x4 gather over the jitter pattern.
// VL_COMPOSITE full resolution: depth-aware upsample, colour, add over the scene.
// Identifiers carry no trailing digit (naga_oil).
#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput
struct VolLight {
dir_steps: vec4<f32>, // toward the light body (unit), march steps
colour: vec4<f32>, // in-scatter colour, extinction per yard
range: vec4<f32>, // march length cap, march start, anisotropy g, height falloff (yd)
debug: vec4<f32>, // x = 1: show the in-scatter alone
}
// Distance (yd) along the view ray to the scene at `ndc_xy` with reverse-Z depth `z`; the sky
// (depth 0 on infinite reverse-Z) is `cap` away. Shared by the march and the composite so the two
// bilateral keys are the same quantity.
fn ray_distance(world_from_clip: mat4x4<f32>, eye: vec3<f32>, ndc_xy: vec2<f32>, z: f32, cap: f32) -> f32 {
if (z <= 0.0) {
return cap;
}
let q = world_from_clip * vec4<f32>(ndc_xy, z, 1.0);
return min(length(q.xyz / q.w - eye), cap);
}
#ifdef VL_MARCH
#import bevy_render::view::View
#import bevy_pbr::mesh_view_types::Lights
#ifdef MULTISAMPLED
@group(0) @binding(0) var depth: texture_depth_multisampled_2d;
#else
@group(0) @binding(0) var depth: texture_depth_2d;
#endif
@group(0) @binding(1) var<uniform> view: View;
@group(0) @binding(2) var<uniform> lights: Lights;
@group(0) @binding(3) var shadows: texture_depth_2d_array;
@group(0) @binding(4) var shadow_sampler: sampler_comparison;
@group(0) @binding(5) var<uniform> vl: VolLight;
// 1 lit, 0 shadowed, -1 outside every cascade (unknown).
fn visibility(light_index: u32, p: vec3<f32>) -> f32 {
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);
}
return -1.0;
}
// Interleaved gradient noise (Jimenez 2014): a per-pixel offset whose 3x3/4x4 neighbourhoods
// cover the unit interval evenly, so a small blur removes it.
fn ign(p: vec2<f32>) -> f32 {
return fract(52.9829189 * fract(dot(p, vec2<f32>(0.06711056, 0.00583715))));
}
fn phase(cosine: f32) -> f32 {
let g = vl.range.z;
let denom = 1.0 + g * g - 2.0 * g * cosine;
let hg = 0.07957747 * (1.0 - g * g) / (denom * sqrt(denom));
// A quarter isotropic keeps side-on shafts faintly visible, as real haze does.
return mix(hg, 0.07957747, 0.25);
}
@fragment
fn fs_march(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
let dims = vec2<i32>(textureDimensions(depth));
let hp = vec2<i32>(in.position.xy);
let full = min(hp * 2, dims - vec2<i32>(1));
let z = textureLoad(depth, full, 0);
let uv = (vec2<f32>(full) + 0.5) / vec2<f32>(dims);
let ndc = uv * vec2(2.0, -2.0) + vec2(-1.0, 1.0);
let cap = vl.range.x;
let far = view.world_from_clip * vec4<f32>(ndc, max(z, 0.000001), 1.0);
let ray = normalize(far.xyz / far.w - view.world_position);
let dist = ray_distance(view.world_from_clip, view.world_position, ndc, z, cap);
let start = vl.range.y;
let span = dist - start;
if (span <= 0.0) {
return vec4<f32>(0.0, dist, 0.0, 1.0);
}
let count = u32(vl.dir_steps.w);
let n = f32(count);
let sigma = vl.colour.w;
let jitter = ign(vec2<f32>(hp));
var scatter = 0.0;
for (var light_index = 0u; light_index < lights.n_directional_lights; light_index += 1u) {
// The shadow rig's light is the one with a live map (SHADOWS_ENABLED).
if ((lights.directional_lights[light_index].flags & 1u) == 0u) { continue; }
var known = 0.0;
var known_count = 0.0;
for (var step = 0u; step < 64u; step += 1u) {
if (step >= count) { break; }
// Quadratic spacing: dense near the eye, where the shafts are.
let u = (f32(step) + jitter) / n;
let t = start + span * u * u;
let dt = span * 2.0 * u / n;
var seen = visibility(light_index, view.world_position + ray * t);
if (seen >= 0.0) {
known += seen;
known_count += 1.0;
} else {
// Past the cascades: the mean of what this ray could see, never a hard 0 ring.
seen = select(0.5, known / known_count, known_count > 0.0);
}
// Thinner air above the eye (down to 2 yd under it, roughly the ground).
let above = max(ray.y * t + 2.0, 0.0);
let local = sigma * exp(-above / vl.range.w);
scatter += seen * exp(-sigma * t) * local * dt;
}
break;
}
scatter *= phase(dot(ray, vl.dir_steps.xyz));
return vec4<f32>(scatter, dist, 0.0, 1.0);
}
#endif
#ifdef VL_BLUR
@group(0) @binding(0) var march: texture_2d<f32>;
@fragment
fn fs_blur(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
let dims = vec2<i32>(textureDimensions(march));
let p = vec2<i32>(in.position.xy);
let centre = textureLoad(march, p, 0);
let key = centre.y;
let tolerance = 0.08 * key + 0.75;
var sum = 0.0;
var weight = 0.0;
for (var dy = -2; dy <= 1; dy += 1) {
for (var dx = -2; dx <= 1; dx += 1) {
let q = clamp(p + vec2<i32>(dx, dy), vec2<i32>(0), dims - vec2<i32>(1));
let s = textureLoad(march, q, 0);
let w = exp(-abs(s.y - key) / tolerance);
sum += s.x * w;
weight += w;
}
}
return vec4<f32>(sum / max(weight, 1e-5), key, 0.0, 1.0);
}
#endif
#ifdef VL_COMPOSITE
#import bevy_render::view::View
@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 shafts: texture_2d<f32>;
@group(0) @binding(3) var<uniform> view: View;
@group(0) @binding(4) var<uniform> vl: VolLight;
@fragment
fn fs_composite(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
let p = vec2<i32>(in.position.xy);
let source = textureLoad(scene, p, 0);
let dims = vec2<i32>(textureDimensions(depth));
let z = textureLoad(depth, min(p, dims - vec2<i32>(1)), 0);
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(dims);
let ndc = uv * vec2(2.0, -2.0) + vec2(-1.0, 1.0);
let key = ray_distance(view.world_from_clip, view.world_position, ndc, z, vl.range.x);
// Bilinear footprint in the half-resolution grid, each tap weighted by depth agreement.
let half_dims = vec2<i32>(textureDimensions(shafts));
let h = (vec2<f32>(p) + 0.5) * 0.5 - 0.5;
let base = vec2<i32>(floor(h));
let f = h - floor(h);
let tolerance = 0.08 * key + 0.75;
var sum = 0.0;
var weight = 0.0;
for (var j = 0; j <= 1; j += 1) {
for (var i = 0; i <= 1; i += 1) {
let q = clamp(base + vec2<i32>(i, j), vec2<i32>(0), half_dims - vec2<i32>(1));
let s = textureLoad(shafts, q, 0);
let bilinear = select(1.0 - f.x, f.x, i == 1) * select(1.0 - f.y, f.y, j == 1);
let w = bilinear * exp(-abs(s.y - key) / tolerance) + 1e-4;
sum += s.x * w;
weight += w;
}
}
let scatter = max(sum / weight, 0.0);
let light = vl.colour.rgb * scatter;
if (vl.debug.x > 0.5) {
return vec4<f32>(vec3<f32>(1.0) - exp(-light), source.a);
}
// The fog pass's soft screen curve: bright pixels saturate gently instead of clipping.
let lifted = source.rgb + max(vec3<f32>(0.0), vec3<f32>(1.0) - source.rgb)
* (vec3<f32>(1.0) - exp(-light));
return vec4<f32>(lifted, source.a);
}
#endif
+2
View File
@@ -40,6 +40,8 @@ pub fn register_shaders(app: &mut App) {
load_shader_library!(app, "shaders/wind_hook.wgsl");
// MONKEY (wet): `benilla::wet_hook`, the rain-on-surfaces look terrain/model/static_gx call.
load_shader_library!(app, "shaders/wet_hook.wgsl");
// GFX (moonlight): `benilla::moonlight_hook`, the additive moon term the receivers call.
load_shader_library!(app, "shaders/moonlight_hook.wgsl");
// MONKEY (enhanced water): the optional water module, `benilla::enhanced_water` (WATER.md).
load_shader_library!(app, "shaders/enhanced_water.wgsl");
}
@@ -65,12 +65,14 @@ fn water_monkey() -> monkey_frame::MonkeyFrame {
m.wet_a = water_light[MONKEY_ROW + 6u];
m.misc = water_light[MONKEY_ROW + 7u];
for (var i = 0u; i < 8u; i++) { m.benders[i] = water_light[MONKEY_ROW + 8u + i]; }
// GFX (moonlight): row 16.
m.moon = water_light[MONKEY_ROW + 16u];
return m;
}
// MONKEY (rainshelter): the rain-occlusion grid right after the MonkeyFrame block
// (`weather/shelter.rs`): two header rows, then the packed cell words, four to a row.
const SHELTER_ROW: u32 = 549u;
const SHELTER_ROW: u32 = 550u;
fn water_shelter_word(i: u32) -> u32 {
return bitcast<u32>(water_light[SHELTER_ROW + 2u + i / 4u][i % 4u]);
}
@@ -1,6 +1,6 @@
#define_import_path benilla::monkey_frame
// MONKEY (p0 MonkeyFrame): the graphics programme's per-frame block, 16 rows (256 B) appended to
// MONKEY (p0 MonkeyFrame): the graphics programme's per-frame block, 17 rows (272 B) appended to
// the shared light buffer right after the point-light table (`lighting::global_light`, packed from
// the `MonkeyFrame` resource in `lighting/monkey_frame.rs`). Every receiver's `WowLight` mirror
// declares it as `monkey: monkey_frame::MonkeyFrame` after `points`, so the offsets of everything
@@ -18,6 +18,8 @@ struct MonkeyFrame {
wind_a: vec4<f32>, // dir_x, dir_y (unit, world XZ: .x = world x, .y = world z), base_heading (rad, fixed profile heading), gust 0..1
wind_b: vec4<f32>, // travel (yd, integrated speed, wrapped at 4096), sway_strength, grass_strength, tree_strength
wet_a: vec4<f32>, // rain_rate 0..1, wetness 0..1, ripple_time_s, snow 0..1
misc: vec4<f32>, // bender_count, time_of_day 0..1 (packer), night 0..1 (packer), 0
misc: vec4<f32>, // bender_count, time_of_day 0..1 (packer), night 0..1 (packer), moon shadow confidence 0..1
benders: array<vec4<f32>, 8>, // world xyz (Bevy space) + radius (yd); the first bender_count are live
// GFX (moonlight): unit direction toward the moon (Bevy space) + intensity (gamma; 0 = off).
moon: vec4<f32>,
}
@@ -0,0 +1,37 @@
#define_import_path benilla::moonlight_hook
// GFX (moonlight): the MOON as an additive night light. The stock 1.12 night (the `Light.dbc`
// night colours on the never-setting lighting sun) stays the base; this adds a cool, dim
// directional term from the visible white moon, per fragment, which the receivers (terrain,
// wow_model, static_gx) shadow with the moon's own map through `shadow_hook::realtime_shadow_moonlit`
// `.z`. The CPU half is `benilla_world::lighting::moonlight` (MonkeyFrame row 16 `moon`).
//
// `moon.w == 0` (moonLight 0, daylight, the moon below the horizon) returns exact zero, and every
// receiver guards its add on `moon.w > 0`, so the pre-feature render is untouched.
//
// Identifiers carry no trailing digit (naga_oil rejects them in composable modules).
// Cool moonlight in gamma space: blue-white, never saturated blue.
const MOON_TINT: vec3<f32> = vec3<f32>(0.62, 0.74, 1.0);
// A small wrap keeps the terminator soft (moonlight is a large, dim source seen through haze).
const MOON_WRAP: f32 = 0.2;
fn moon_light(normal: vec3<f32>, moon: vec4<f32>) -> vec3<f32> {
if (moon.w <= 0.0) {
return vec3<f32>(0.0);
}
let len_sq = dot(normal, normal);
if (len_sq < 1e-12) {
// The M2 corpus authors zero normals; give them the flat-lit share.
return MOON_TINT * (moon.w * 0.5);
}
let n = normal * inverseSqrt(len_sq);
let ndl = max((dot(n, moon.xyz) + MOON_WRAP) / (1.0 + MOON_WRAP), 0.0);
return MOON_TINT * (moon.w * ndl);
}
// The moon's own colour for the volumetric pass (same tint, intensity baked in).
fn moon_colour(moon: vec4<f32>) -> vec3<f32> {
return MOON_TINT * max(moon.w, 0.0);
}
@@ -120,6 +120,38 @@ fn realtime_shadow_terms(
);
}
// GFX (moonlight): `realtime_shadow_terms` plus `.z` = the MOONLIGHT visibility — how much of the
// additive moon term (`benilla::moonlight_hook`) reaches this fragment. Unlike `.y` (which darkens
// the stock night SKY by `moonShadowStrength`), `.z` shadows the moon's OWN light fully, faded by
// the rig's hand-over ramp `moon_conf` (MonkeyFrame `misc.w`) and the cascade edge. `.z` is 1.0
// whenever the map does not hold the moon (`moon <= 0` or `moon_conf <= 0`): an unshadowed moon
// term rather than the sun's map applied to the moon. `.x`/`.y` are exactly `realtime_shadow_terms`.
fn realtime_shadow_moonlit(
sample_pos: vec4<f32>,
normal: vec3<f32>,
view_z: f32,
cam_dist: f32,
shadow_range: f32,
night: f32,
moon: f32,
moon_conf: f32,
) -> vec3<f32> {
if (night <= 0.0 && moon <= 0.0) {
return vec3<f32>(1.0);
}
let shadow = shadow_fetch(sample_pos, normal, view_z);
let edge_fade = smoothstep(shadow_range - SHADOW_EDGE_BAND, shadow_range, cam_dist);
var lit = 1.0;
if (moon > 0.0 && moon_conf > 0.0) {
lit = 1.0 - (1.0 - shadow) * clamp(moon_conf, 0.0, 1.0) * (1.0 - edge_fade);
}
return vec3<f32>(
1.0 - (1.0 - shadow) * night * (1.0 - edge_fade),
1.0 - (1.0 - shadow) * moon * (1.0 - edge_fade),
lit,
);
}
// MONKEY (torch shadows Phase 1): the point/cluster-based torch shadow is DEAD. benilla's world
// camera sets `ClusterConfig::None`, which starves `clusterable_objects` AND the point-shadow prep,
// so `fetch_point_shadow`/the clusterable scan can never fire. The replacement is a from-scratch
+14 -1
View File
@@ -18,6 +18,8 @@
}
// MONKEY (shadow hook): the realtime directional-shadow term (fetch + edge/night fade) lives here.
#import benilla::shadow_hook
// GFX (moonlight): the additive moon term.
#import benilla::moonlight_hook
// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table.
#import benilla::monkey_frame
// MONKEY (p0 fog hook): the one distance-fog law every receiver calls.
@@ -670,7 +672,8 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4<f32> {
// Hoisted out of the `realtime_shadow` call (same expression, same bits) because the torch lane
// below needs the same normal for its normal-offset sample.
let n_lit = normalize(in.world_normal);
let shadow_terms = shadow_hook::realtime_shadow_terms(
// GFX (moonlight): the same fetch, plus `.z` = the moonlight's own visibility.
let shadow_terms = shadow_hook::realtime_shadow_moonlit(
in.world_position,
n_lit,
view_z,
@@ -678,6 +681,7 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4<f32> {
wow_light._wmo_fog[1].z,
sun_shadow_strength,
moon_shadow_strength,
wow_light.monkey.misc.w,
);
let world_shadow = shadow_terms.x;
// MONKEY (outdoor torch shadows: terrain): the exterior point term, CAST-SHADOWED at night.
@@ -721,6 +725,15 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4<f32> {
}
primary = clamp(in.base_lit * shadow_terms.y + points, vec3<f32>(0.0), vec3<f32>(1.0));
}
// GFX (moonlight): ADD the moon (cool, N·L, shadowed by the moon's own map) onto the stock
// night. `moon.w` is exactly 0 by day and with `moonLight 0`, so the branch is not entered.
if (wow_light.monkey.moon.w > 0.0) {
primary = clamp(
primary + moonlight_hook::moon_light(n_lit, wow_light.monkey.moon) * shadow_terms.z,
vec3<f32>(0.0),
vec3<f32>(1.0),
);
}
// MCSH baked shadow. With `pixelShaders` and `specular` on, terrain is one pass through
// `terrainp_s.bls`, which reads the MCSH bit from the blend texture's alpha (1 lit, 0 shadowed)
@@ -22,6 +22,8 @@
}
// MONKEY (shadow hook): the realtime directional-shadow term (fetch + edge/night fade) lives here.
#import benilla::shadow_hook
// GFX (moonlight): the additive moon term.
#import benilla::moonlight_hook
// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table.
#import benilla::monkey_frame
// MONKEY (wet): rain on surfaces (wet_hook.wgsl).
@@ -1499,6 +1501,8 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// and in the sun-down/moon-not-yet-up window. See `shadow_hook::realtime_shadow_terms`.
var player_shadow = 1.0;
var player_moon = 1.0;
// GFX (moonlight): the moonlight's own visibility (`realtime_shadow_moonlit` `.z`).
var player_moon_lit = 1.0;
let view_z = (view.view_from_world * in.world_position).z;
let shadow_cam_dist = distance(in.world_position.xyz, view.world_position.xyz);
let sun_lane_w = shadow_hook::sun_shadow_w(wow_light.fog_params.z);
@@ -1515,7 +1519,7 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
wow_light.rig_origin[(fade_tag >> 19u) & 0x7ffu].xyz - 2.5 * sun_ray,
1.0,
);
let terms_rig = shadow_hook::realtime_shadow_terms(
let terms_rig = shadow_hook::realtime_shadow_moonlit(
anchor,
vec3<f32>(0.0, 1.0, 0.0),
view_z,
@@ -1523,12 +1527,14 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
wow_light.wmo_fog_params.z,
sun_lane_w,
moon_lane_w,
wow_light.monkey.misc.w,
);
player_shadow = terms_rig.x;
player_moon = terms_rig.y;
player_moon_lit = terms_rig.z;
}
#else
let terms_frag = shadow_hook::realtime_shadow_terms(
let terms_frag = shadow_hook::realtime_shadow_moonlit(
in.world_position,
wow_normalize(in.world_normal),
view_z,
@@ -1536,9 +1542,11 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
wow_light.wmo_fog_params.z,
sun_lane_w,
moon_lane_w,
wow_light.monkey.misc.w,
);
player_shadow = terms_frag.x;
player_moon = terms_frag.y;
player_moon_lit = terms_frag.z;
#endif
// The realtime map blocks only the directional sun. Preserve the authored ambient/probe
// contribution instead of multiplying the whole lighting result; the latter makes interiors,
@@ -1568,6 +1576,11 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
sun_lobe, use_doodad_shade);
lit_with_shadow = sky * player_moon;
}
// GFX (moonlight): ADD the moon onto the exterior night (never interiors or the glue booth).
// `moon.w` is exactly 0 by day and with `moonLight 0`: the branch is not entered.
if (wow_light.monkey.moon.w > 0.0 && !is_interior && !is_rig) {
lit_with_shadow += moonlight_hook::moon_light(n_lit, wow_light.monkey.moon) * player_moon_lit;
}
// Gamma-space albedo: lighting runs on the authored byte values. `m.tint` plus its mat-anim
// delta is the animated M2Color tint.
@@ -67,7 +67,8 @@ struct LightStd430 {
rows: [[f32; 4]; LIGHT_HEADER_ROWS],
points: [[f32; 4]; 2 * MAX_POINT_LIGHTS],
// MONKEY (p0 MonkeyFrame): the programme block, appended AFTER the point table so no earlier
// offset moves (the per-frame prefix is now 8784 B); packed by [`pack_monkey_frame`].
// offset moves (the per-frame prefix is now 8800 B — GFX (moonlight) added row 16); packed by
// [`pack_monkey_frame`].
monkey: [[f32; 4]; super::monkey_frame::MONKEY_FRAME_ROWS],
}
@@ -668,6 +669,8 @@ pub(super) fn register(app: &mut App) {
// MONKEY (moon shadows): the night directional-shadow strength dial (0 = today's render).
.init_resource::<MoonShadowStrength>()
.init_resource::<ShadowHandover>()
// GFX (moonlight): the additive moon term's dial (0 = today's night, bit for bit).
.init_resource::<super::moonlight::MoonLight>()
// MONKEY (sun shadow perf): the live PCF choice, extracted for `static_gx`'s hand-rolled
// pipeline (every Bevy-material receiver keys off the view component instead).
.init_resource::<ShadowFilterGaussian>()
@@ -695,7 +698,8 @@ pub(super) fn register(app: &mut App) {
// through `Commands`, so a NEWLY spawned light is still packed on the fail-safe
// fallback for one frame — see that fallback's note in `build_light_data`.
// MONKEY (p0 MonkeyFrame): the programme block is packed right after the table.
(classify_light_lanes, build_light_data, pack_monkey_frame)
// GFX (moonlight): the moon row is written into the frame just before it is packed.
(classify_light_lanes, build_light_data, super::moonlight::update_moonlight, pack_monkey_frame)
.chain()
.after(bevy::transform::TransformSystems::Propagate)
.after(super::update_time_lighting),
@@ -864,6 +868,20 @@ impl ShadowHandover {
};
}
/// GFX (moonlight): how far the hand-over to the MOON has ramped, `0..1` — 0 whenever the map
/// does not (yet) hold the moon. The additive moonlight term shadows by this much, so a moon
/// shadow fades in with the rig instead of popping on the frame the basis lands.
pub fn moon_ramp(&self) -> f32 {
if self.wanted == Some(ShadowBody::Moon)
&& self.aimed == Some(ShadowBody::Moon)
&& self.weight < 0.0
{
self.ramp
} else {
0.0
}
}
/// Only the rig may acknowledge; choosing a body without writing a transform is not ready.
pub fn aim_written(&mut self, body: ShadowBody) {
if self.aimed != Some(body) {
@@ -2718,7 +2736,8 @@ mod tests {
assert_eq!(MAX_LIVE_POINT_LIGHTS, MAX_POINT_LIGHTS - 1);
// 21 header rows + 2 x 256 point rows, 16 B each.
// MONKEY (p0 MonkeyFrame): the fixed rows plus the 256-byte programme block.
assert_eq!(per_frame_blob_bytes(), 8784, "the mirrored blob must not change size");
// GFX (moonlight): + the moonlight row (17 programme rows, 272 B).
assert_eq!(per_frame_blob_bytes(), 8800, "the mirrored blob must not change size");
}
/// MONKEY (reviewfix): the shader mirrors use literal storage-array lengths and water reads
+18
View File
@@ -16,6 +16,8 @@ pub use monkey_frame::{FogModel, MonkeyFrame, MAX_BENDERS, MONKEY_FRAME_ROWS};
pub mod fog_model; // MONKEY (fog): the Modern fog model's CPU half (MonkeyFrame fog rows)
pub use fog_model::FogModelSetting;
mod lava_light; // MONKEY (lava light): magma surface fixtures and their independent gain
mod moonlight; // GFX (moonlight): the moon as an additive night light (MonkeyFrame row 16)
pub use moonlight::{moon_light_intensity, MoonLight, MOON_LIGHT_BASE};
pub use lava_light::{LavaLight, LavaLightGain};
mod prop_probes; // the per-instance interior-prop SH probe table (slot ↔ MeshTag payload)
mod resolve; // the per-frame time-of-day sample into WowLighting + the WMO interior-fog crossfade
@@ -157,6 +159,17 @@ impl WowLighting {
self.celestial_dir
}
/// GFX (volumetric light): the scene fog end (yd) the zone's light resolved to, so a post pass
/// can scale its density with how foggy the zone is authored to be.
pub fn fog_end(&self) -> f32 {
self.fog_end
}
/// GFX (volumetric light): the storm blend `0..1` (see the field), for the shafts' dimming.
pub fn storm(&self) -> f32 {
self.storm_bcc
}
/// MONKEY (moon shadows): the visible **white moon** direction (camera→moon, Bevy space) — the
/// body the one shadow rig re-aims at after dark.
///
@@ -424,6 +437,11 @@ mod ordering_tests {
"lighting/daylight.rs",
"update_daylight_fixtures + update_bleed_fixtures: PostUpdate",
),
// GFX (moonlight): chained in PostUpdate between build_light_data and the packer.
(
"lighting/moonlight.rs",
"update_moonlight: PostUpdate, chained before pack_monkey_frame",
),
(
"clouds/layer.rs",
"update_cloud_fx is registered in CloudsPlugin with LightingConsumeSet",
@@ -10,7 +10,7 @@
//! All zero (the default) is "every feature off": nothing reads a zero field as a change, so the
//! image is the same as before the block existed.
//!
//! Layout (16 × `vec4<f32>`, 256 B):
//! Layout (17 × `vec4<f32>`, 272 B):
//!
//! | row | xyz / x,y,z | w |
//! |---|---|---|
@@ -21,8 +21,12 @@
//! | 4 `wind_a` | dir_x, dir_y, base_heading (rad) | gust |
//! | 5 `wind_b` | travel (yd, wrapped), sway_strength, grass_strength | tree_strength |
//! | 6 `wet_a` | rain_rate, wetness, ripple_time_s | snow |
//! | 7 `misc` | bender_count, time_of_day 0..1, night 0..1 | 0 |
//! | 7 `misc` | bender_count, time_of_day 0..1, night 0..1 | moon shadow confidence 0..1 |
//! | 8-15 `benders` | world x, y, z | radius |
//! | 16 `moon` | moonlight direction (toward the moon, unit) | moonlight intensity (gamma, 0 = off) |
//!
//! GFX (moonlight): row 16 and `misc.w` belong to `lighting::moonlight`; the confidence is how far
//! the shadow rig's hand-over to the moon has ramped (0 = the map does not hold the moon).
//!
//! Coordinates are Bevy world space (Y up, 1 unit = 1 yd), the space the receivers'
//! `world_position` is in: WoW `(x, y, z)` → Bevy `(-y, z, -x)` ([`benilla_assets::coords`]).
@@ -33,7 +37,7 @@
use bevy::prelude::*;
/// Rows in the appended block.
pub const MONKEY_FRAME_ROWS: usize = 16;
pub const MONKEY_FRAME_ROWS: usize = 17;
/// How many benders (grass/foliage pushers: the player and nearby units) the block carries.
pub const MAX_BENDERS: usize = 8;
@@ -95,6 +99,13 @@ pub struct MonkeyFrame {
/// Bevy world space.
pub benders: [[f32; 4]; MAX_BENDERS],
pub bender_count: u32,
// ── GFX (moonlight): row 16 + `misc.w` ──
/// Unit direction toward the moon (Bevy space).
pub moon_light_dir: [f32; 3],
/// Moonlight intensity in gamma units at N·L = 1; 0 is exactly the pre-feature render.
pub moon_light: f32,
/// How far the shadow rig's hand-over to the moon has ramped (0..1).
pub moon_shadow_confidence: f32,
}
impl MonkeyFrame {
@@ -121,8 +132,10 @@ impl MonkeyFrame {
rows[5] = [self.wind_travel, self.sway_strength, self.grass_strength, self.tree_strength];
rows[6] = [self.rain_rate, self.wetness, self.ripple_time_s, self.snow];
let n = (self.bender_count as usize).min(MAX_BENDERS);
rows[7] = [n as f32, time_of_day, night, 0.0];
rows[7] = [n as f32, time_of_day, night, self.moon_shadow_confidence];
rows[8..8 + n].copy_from_slice(&self.benders[..n]);
let m = self.moon_light_dir;
rows[16] = [m[0], m[1], m[2], self.moon_light];
rows
}
}
@@ -167,6 +180,9 @@ mod tests {
snow: 25.0,
benders: [[26.0, 27.0, 28.0, 29.0]; MAX_BENDERS],
bender_count: 20, // clamped to MAX_BENDERS
moon_light_dir: [30.0, 31.0, 32.0],
moon_light: 33.0,
moon_shadow_confidence: 34.0,
};
let r = f.pack(0.25, 0.75);
assert_eq!(r[0], [1.0, 2.0, 3.0, 4.0]);
@@ -176,14 +192,15 @@ mod tests {
assert_eq!(r[4], [14.0, 15.0, 16.0, 17.0]);
assert_eq!(r[5], [18.0, 19.0, 20.0, 21.0]);
assert_eq!(r[6], [22.0, 23.0, 24.0, 25.0]);
assert_eq!(r[7], [8.0, 0.25, 0.75, 0.0]);
assert_eq!(r[7], [8.0, 0.25, 0.75, 34.0]);
assert_eq!(r[15], [26.0, 27.0, 28.0, 29.0]);
assert_eq!(r[16], [30.0, 31.0, 32.0, 33.0]);
}
/// The WGSL mirror declares the same 16 rows; a member added on one side only would shift
/// The WGSL mirror declares the same rows; a member added on one side only would shift
/// every bender.
#[test]
fn the_wgsl_mirror_has_sixteen_rows() {
fn the_wgsl_mirror_has_the_same_rows() {
let src = include_str!("../../../benilla-assets/src/shaders/monkey_frame.wgsl");
let body = src.split_once("struct MonkeyFrame {").unwrap().1;
let body = body.split_once("\n}").unwrap().0;
@@ -0,0 +1,129 @@
//! GFX (moonlight): the MOON as a light, on top of the stock night.
//!
//! 1.12 has no moon light. Its night is the `Light.dbc` night colours on the lighting sun, whose
//! direction never sets (`daynight::sun_direction`), so a night scene is lit from the same
//! compass point as noon, only darker and bluer. This module keeps that stock night as the base
//! and ADDS a cool, dim directional term from the visible white moon (`daynight::moon_direction`,
//! the disc the sky draws), which the receivers evaluate per fragment with N·L and shadow with the
//! moon's own shadow map when the shadow rig holds it (`ShadowHandover::moon_ramp`).
//!
//! Packed into the programme block (`MonkeyFrame` row 16 + `misc.w`, see `monkey_frame.rs`); the
//! WGSL half is `benilla::moonlight_hook`. [`MoonLight`] `0` is exactly the pre-feature render:
//! the intensity packs as 0 and every receiver's moon branch is skipped.
//!
//! Crossfades: the term ramps in with the moon's elevation over the same 0..12° smoothstep the
//! shadow hand-over uses ([`sun_shadow_strength`]) and out with the SUN's rise, so dusk (sun set,
//! moon not yet up) and dawn keep the stock night with no moon, and nothing ever steps.
use bevy::prelude::*;
use super::{sun_shadow_strength, MonkeyFrame, ShadowHandover, WowLighting};
/// The moonlight dial (`moonLight`, 0..2): a multiplier on [`MOON_LIGHT_BASE`]. 0 = off (the
/// pre-feature night, bit for bit), 1 = the shipped look. The settings bridge writes it.
#[derive(Resource, Clone, Copy, Debug, PartialEq)]
pub struct MoonLight(pub f32);
impl Default for MoonLight {
/// Off until the settings bridge says otherwise, so a headless test or an off-world scene
/// never grows a moon term it did not ask for.
fn default() -> Self {
Self(0.0)
}
}
/// The moon's N·L = 1 contribution in gamma units at `moonLight 1`, a high full moon, clear sky.
/// Chosen against the Elwynn night (`nightGain 0.45`): enough to model a lit hillside and throw a
/// readable moon shadow, well short of the dawn ambient.
pub const MOON_LIGHT_BASE: f32 = 0.16;
/// How much a full storm (`storm_bcc = 1`) takes away: an overcast moon still glows a little.
const MOON_STORM_DIM: f32 = 0.75;
/// The moonlight intensity for one frame (before the tint the shader applies).
///
/// `sun_height` / `moon_height` are the SINE of each body's elevation (`celestial_dir.y`,
/// `moon_dir().y`); `storm` is the resolved storm blend `0..1`.
pub fn moon_light_intensity(gain: f32, sun_height: f32, moon_height: f32, storm: f32) -> f32 {
if gain <= 0.0 {
return 0.0;
}
let night = 1.0 - sun_shadow_strength(sun_height);
let risen = sun_shadow_strength(moon_height);
let sky = 1.0 - MOON_STORM_DIM * storm.clamp(0.0, 1.0);
MOON_LIGHT_BASE * gain.clamp(0.0, 2.0) * night * risen * sky
}
/// Writes the moon row of [`MonkeyFrame`] (chained right before the packer).
pub(super) fn update_moonlight(
gain: Res<MoonLight>,
lighting: Res<WowLighting>,
handover: Res<ShadowHandover>,
mut frame: ResMut<MonkeyFrame>,
) {
let moon = lighting.moon_dir().normalize_or_zero();
let intensity = moon_light_intensity(
gain.0,
lighting.celestial_dir().y,
moon.y,
lighting.storm_bcc,
);
let (dir, intensity) = if intensity > 0.0 && moon != Vec3::ZERO {
(moon.to_array(), intensity)
} else {
([0.0; 3], 0.0)
};
let confidence = if intensity > 0.0 { handover.moon_ramp() } else { 0.0 };
// Write through `ResMut` only on a change, so the packer's own change check stays quiet.
if frame.moon_light_dir != dir
|| frame.moon_light != intensity
|| frame.moon_shadow_confidence != confidence
{
frame.moon_light_dir = dir;
frame.moon_light = intensity;
frame.moon_shadow_confidence = confidence;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn off_day_and_a_set_moon_are_exactly_zero() {
// Midnight: sun far below, moon high.
assert_eq!(moon_light_intensity(0.0, -0.9, 0.8, 0.0), 0.0, "moonLight 0 is the null");
assert_eq!(moon_light_intensity(1.0, 0.7, 0.8, 0.0), 0.0, "no moonlight by day");
assert_eq!(moon_light_intensity(1.0, -0.9, -0.1, 0.0), 0.0, "a set moon lights nothing");
let full = moon_light_intensity(1.0, -0.9, 0.8, 0.0);
assert!((full - MOON_LIGHT_BASE).abs() < 1e-6);
}
#[test]
fn the_moon_ramps_in_with_its_elevation_and_storms_dim_it() {
let mut prev = 0.0;
for step in 0..=40 {
let h = -0.05 + step as f32 * 0.01;
let v = moon_light_intensity(1.0, -0.9, h, 0.0);
assert!(v + 1e-7 >= prev, "moonrise must be monotonic");
prev = v;
}
let clear = moon_light_intensity(1.0, -0.9, 0.8, 0.0);
let storm = moon_light_intensity(1.0, -0.9, 0.8, 1.0);
assert!(storm < clear * 0.5 && storm > 0.0);
assert!(moon_light_intensity(2.0, -0.9, 0.8, 0.0) > clear * 1.9);
}
#[test]
fn dawn_hands_the_night_back_as_the_sun_rises() {
// The sun climbing through its ramp takes the moon term away continuously.
let mut prev = f32::INFINITY;
for step in 0..=25 {
let sun = step as f32 * 0.01;
let v = moon_light_intensity(1.0, sun, 0.3, 0.0);
assert!(v <= prev + 1e-7);
prev = v;
}
assert_eq!(prev, 0.0);
}
}
@@ -10,6 +10,8 @@
}
// MONKEY (shadow hook): the realtime directional-shadow term (fetch + edge/night fade) lives here.
#import benilla::shadow_hook
// GFX (moonlight): the additive moon term.
#import benilla::moonlight_hook
// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table.
#import benilla::monkey_frame
// MONKEY (p0 fog hook): the one distance-fog law every receiver calls.
@@ -1440,10 +1442,12 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
// untouched at both ends of the clock: a sealed room has no sky, so it has no moon either.
var world_shadow = 1.0;
var world_moon = 1.0;
// GFX (moonlight): the moon term for EXTERIOR surfaces (zero indoors, by day and when off).
var moon_add = vec3<f32>(0.0);
if ((in.word & WORD_INTERIOR) == 0u) {
let view_z = (view.view_from_world * in.world_position).z;
let cam_dist = distance(in.world_position.xyz, view.world_position.xyz);
let terms = shadow_hook::realtime_shadow_terms(
let terms = shadow_hook::realtime_shadow_moonlit(
in.world_position,
n_lit,
view_z,
@@ -1451,9 +1455,13 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
wow_light.wmo_fog_params.z,
shadow_hook::sun_shadow_w(wow_light.fog_params.z),
shadow_hook::moon_shadow_w(wow_light.fog_params.z),
wow_light.monkey.misc.w,
);
world_shadow = terms.x;
world_moon = terms.y;
if (wow_light.monkey.moon.w > 0.0) {
moon_add = moonlight_hook::moon_light(n_lit, wow_light.monkey.moon) * terms.z;
}
} else {
world_shadow = shadow_hook::torch_shadow(in.world_position, n_lit);
}
@@ -1479,6 +1487,10 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
lit_nl_shadowed = (wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * ndotl)
* world_moon;
}
// GFX (moonlight): the moon adds onto the exterior night; `moon_add` is exactly zero otherwise.
if (wow_light.monkey.moon.w > 0.0) {
lit_nl_shadowed += moon_add;
}
// The order-2 SH basis products over the fragment normal — shared by the exterior
// doodad lobe and the interior-prop probe lane (wow_model.wgsl computes them once too).
@@ -1986,6 +1998,10 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
if (world_moon < 1.0) {
lit_doodad = sun_lobe * world_moon;
}
// GFX (moonlight): and the retained doodads take the moon term too.
if (wow_light.monkey.moon.w > 0.0) {
lit_doodad += moon_add;
}
// Sun disabled (light_sun.w) falls back to the FFP matte, like the entity path.
let lit = select(lit_nl_shadowed, lit_doodad, wow_light.light_sun.w > 0.5);
// FFP combine: the light sum saturates FIRST, the texture (× the baked constant