From 33fc0c20239ec6bb1166f31c8fd156fb6aa8f27b Mon Sep 17 00:00:00 2001 From: grom Date: Wed, 28 Oct 2026 06:40:48 -0600 Subject: [PATCH] Froxel volumetrics: the haze stops painting shadowed air the colour of sunlit air MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `volumetricFog` gains tier 3, "Volume", with a "Shadowed Air" dial beside it; the Ultra rung of Lighting Quality takes it. A view-space 160x90x64 froxel grid — a fixed BUDGET, so every resolution costs the same — marched one compute thread per screen COLUMN, front to back, with a reprojected eight-frame history. Inject and integrate in one pass, because a froxel past the last shadow cascade has no evidence either way and the honest answer is the mean of what the rest of its column could see; a thread that owns the column can carry that and a froxel-per-invocation dispatch cannot. It publishes two things: - **the lit share** — how much of the air in front of each pixel the sun actually reaches, weighted by each slice's own contribution to what the eye sees through it. `volumetric_fog`'s haze multiplies its colour by it. The haze below this rung is the reference's: a zone colour mixed in by distance, the SAME colour whether the air it tints stands in open sun or a mountain's shadow, and no screen pass can tell it otherwise because a shaft march only ever ADDS light. On an Elwynn overlook at noon, 74% of the frame is air the sun does not fully reach. - **the lamps' in-scatter**, free once a volume exists: the phase lobe evaluated at every point of the air rather than once per ray at its closest approach, attenuated by the medium it crosses and strengthened when the medium is. `lampFog`'s analytic halos retire into this solve at tier 3, and that row still chooses which lamps and how many. The gain is derived, `0.9 / density(clear night)`, so the two lanes match on a clear night. At the Goldshire inn, isolating each by rendering lampFog 0 against 2 on both (the bases are byte-identical, which is what makes it a comparison): the volume's are 1.30x and wider. The sun's SHAFTS deliberately stay on `volumetric_light`'s per-pixel march, and that is a measurement. A volume bands in hard horizontal steps at 160x90, 320x180 and 534x300 alike, because one froxel column hands the same value to about a hundred pixels and a canopy's shadow is finer than any grid worth paying for. Spreading each column's samples across its own screen footprint (R2 down the column) turns the steps into a gradient and is worth knowing for any volume, but the march still wins on detail. docs/GRAPHICS.md has the sweep. Safety is one number: fully lit air multiplies by EXACTLY 1, so at Shadowed Air 0 the lane renders byte-identical to High over all 1,440,000 pixels with every pass still running. Getting that exact needed `1 - (1-floor)*(1-lit)` and not the `mix(floor, 1, lit)` it equals — `mix` is a fused multiply-add and lands a last ulp below 1.0, which moved five of nine tier 0-2 control pairs with the rung switched OFF. The shipped floor, 0.85, is deliberately half the depth of the first one tried, and the dial reaches twice it. Control: nine tier 0-2 pairs against the previous build, five byte-identical and four differing by one or two PIXELS out of 1,440,000 at 1-2 of 255 — every tier-0 pair identical, and tier 0 is the tier that switches the fog pass off, so the remainder is the fog shader's instruction schedule changing because the shader did. Cost is under the noise of both instruments: the frame probe puts Volume inside High's own run-to-run spread, and the GPU pass timer puts `gpu_other` at 0.75 -> 0.76 ms. Also recorded: `water-lake` leaves the deterministic row once shadows are forced on — it differs from a build of ITSELF by up to 88/255 on 0.03% of pixels. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/assets/ui/OptionsFrame.xml | 76 +- crates/benilla-app/src/cvars.rs | 38 +- crates/benilla-app/src/froxel.rs | 1102 +++++++++++++++++ crates/benilla-app/src/froxel.wgsl | 330 +++++ crates/benilla-app/src/game_plugins.rs | 2 + crates/benilla-app/src/lib.rs | 2 + crates/benilla-app/src/pipe_warm/menagerie.rs | 4 + .../src/ui_script/options_tests.rs | 45 +- crates/benilla-app/src/video.rs | 9 +- crates/benilla-app/src/volumetric_fog.rs | 173 ++- crates/benilla-app/src/volumetric_light.rs | 10 +- docs/GRAPHICS.md | 115 +- 12 files changed, 1853 insertions(+), 53 deletions(-) create mode 100644 crates/benilla-app/src/froxel.rs create mode 100644 crates/benilla-app/src/froxel.wgsl diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 153ceb7a..b55e05b9 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -522,6 +522,10 @@ qUltra = "Ultra", -- MONKEY (volumetric fog): localised atmospheric quality row. volumetricFog = "Volumetric Fog", + -- GFX (froxel volumetrics): the row's fourth tier. Named for what it is rather + -- than "Ultra", like `aoIndirect` below, because it is not more of the same thing. + fogVolume = "Volume", + volumetricFogShadow = "Shadowed Air", -- MONKEY (post): HDR excess + halo quality. bloom = "Bloom", sunShafts = "Sun Shafts", @@ -651,7 +655,28 @@ COLOR_GRADING = "Applies a subtle zone-specific colour palette to the 3D world and blends between its day and night looks. The interface is never graded.", 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.", + "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.\n\n" + .. "Volume keeps High's haze and solves the air itself in a grid in front of " + .. "you. The haze then knows where it stands: air in a hillside's or a " + .. "canopy's shadow stops being painted the same bright colour as air in open " + .. "sun, so distance reads as distance instead of as milk. Air the sun does " + .. "reach is left exactly as it was. Lamp Fog's halos move into the same " + .. "solve, where each one lights the air at every point of it rather than " + .. "along one line.\n\n" + .. "It needs shadows for the same reason the shafts do — without them " + .. "nothing knows where the air is shaded, and Volume draws exactly what " + .. "High draws.\n\n" + .. "This is the largest change to the original look of any setting here. If it " + .. "is too much, High is unchanged.", + -- GFX (froxel volumetrics): the Volume rung's own depth dial. + VOLUMETRIC_FOG_SHADOW = + "How far Volumetric Fog's Volume setting is allowed to take the haze down " + .. "where the air stands in a hillside's or a canopy's shadow. 100% is the " + .. "shipped look; 200% is twice as deep; 0 leaves the haze exactly as High " + .. "draws it, pixel for pixel.\n\n" + .. "Air the sun reaches is never touched at any setting, so this only ever " + .. "changes the shaded half of a view. Does nothing unless Volumetric Fog is " + .. "on Volume.", -- MONKEY (sky): what each sky tier adds. SKY_QUALITY = "Classic is the original client's sky. Enhanced smooths the sky's colour " @@ -958,6 +983,9 @@ colorGrading = "Цветокоррекция", -- MONKEY (volumetric fog): Russian atmospheric quality row. volumetricFog = "Объёмный туман", + -- GFX (froxel volumetrics) + fogVolume = "Объём", + volumetricFogShadow = "Затенённый воздух", -- MONKEY (sky): the sky tier row. skyQuality = "Качество неба", fogOff = "Выкл", @@ -1081,7 +1109,27 @@ "Применяет мягкую цветовую палитру зоны и плавно меняет дневной вариант на ночной. Интерфейс не изменяется.", -- MONKEY (volumetric fog): localised effect and cost explanation. VOLUMETRIC_FOG = - "Выкл отключает объёмный туман. Низкое добавляет дымку вблизи и лучи солнца или луны с меньшим числом шагов; Высокое использует больше шагов для более плавных лучей. Туман гуще на рассвете и в непогоду, а в помещениях слабее. Для лучей нужны тени; без теней остаётся только дымка.", + "Выкл отключает объёмный туман. Низкое добавляет дымку вблизи и лучи солнца или луны с меньшим числом шагов; Высокое использует больше шагов для более плавных лучей. Туман гуще на рассвете и в непогоду, а в помещениях слабее. Для лучей нужны тени; без теней остаётся только дымка.\n\n" + .. "«Объём» сохраняет дымку «Высокого» и рассчитывает сам воздух в сетке перед " + .. "вами. Дымка начинает знать, где она находится: воздух в тени склона или " + .. "кроны перестаёт краситься тем же ярким цветом, что и воздух на солнце, и " + .. "даль читается как даль, а не как молоко. Воздух, до которого солнце " + .. "достаёт, остаётся ровно таким же. Ореолы «Свечения фонарей в тумане» " + .. "переходят в тот же расчёт, где каждый фонарь светит воздуху в каждой его " + .. "точке, а не вдоль одной линии.\n\n" + .. "Нужны тени — по той же причине, что и лучам: без них ничто не знает, " + .. "где воздух затенён, и «Объём» рисует ровно то же, что «Высокое».\n\n" + .. "Это самое заметное изменение исходного вида среди всех настроек здесь. " + .. "Если его слишком много — «Высокое» не изменилось.", + -- GFX (froxel volumetrics) + VOLUMETRIC_FOG_SHADOW = + "Насколько сильно режим «Объём» в «Объёмном тумане» может притушить дымку " + .. "там, где воздух стоит в тени склона или кроны. 100% — поставляемый вид, " + .. "200% — вдвое глубже, 0 оставляет дымку ровно такой, какой её рисует " + .. "«Высокое», пиксель в пиксель.\n\n" + .. "Воздух, до которого достаёт солнце, не меняется ни при каком значении, " + .. "так что настройка влияет только на затенённую половину вида. Ничего не " + .. "делает, пока «Объёмный туман» не стоит на «Объёме».", -- MONKEY (sky): what each sky tier adds. SKY_QUALITY = "«Классическое» — небо оригинального клиента. «Улучшенное» сглаживает " @@ -1435,6 +1483,7 @@ "RowGraphicsQuality", "RowRenderDistance", -- MONKEY (volumetric fog): include in refresh, defaults and search. "RowLightingQuality", "RowWaterQuality", "RowVolumetricFog", + "RowVolumetricFogShadow", -- MONKEY (sky): the sky tier row. "RowSkyQuality", -- MONKEY (post): the world-only screen effects. @@ -2158,6 +2207,10 @@ -- GFX (auto-exposure): the blend only matters while there is a gain to blend. OptionsRow_SetEnabled(getglobal(base .. "RowAutoExposureStrength"), GetCVar("autoExposure") ~= "0") + -- GFX (froxel volumetrics): the depth dial only matters on the rung that solves + -- the air; the three below it have no shadowed-air term to take down. + OptionsRow_SetEnabled(getglobal(base .. "RowVolumetricFogShadow"), + GetCVar("volumetricFog") == "3") return end if key == "Graphics" then @@ -3638,14 +3691,29 @@ { text = BENILLA_ADVGFX.fogOff, value = "0" }, { text = BENILLA_ADVGFX.qLow, value = "1" }, { text = BENILLA_ADVGFX.qHigh, value = "2" }, + -- GFX (froxel volumetrics) + { text = BENILLA_ADVGFX.fogVolume, value = "3" }, }) + + + + + + + + OptionsRow_OnLoad(self, "volumetricFogShadow", + BENILLA_ADVGFX.volumetricFogShadow, + "BENILLA_TOOLTIP_VOLUMETRIC_FOG_SHADOW") + OptionsSliderRow_Setup(self, 0, 2, 0.25) + + - - + + OptionsRow_OnLoad(self, "skyQuality", BENILLA_ADVGFX.skyQuality, diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 09138dda..4127e4ae 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -707,7 +707,22 @@ pub(crate) const REGISTERED: &[Registered] = &[ such 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( + "volumetricFog", + "1", + "benilla's own: near-field volumetric fog, 0 Off / 1 Low / 2 High / 3 Volume (the air \ + solved in a froxel grid, so the haze darkens where it stands in shadow and the lamps' \ + halos move into the same solve)", + ), + // GFX (froxel volumetrics): how far the Volume rung is allowed to take the haze's colour + // down where the air stands in shadow. Its own row because that rung is the biggest look + // change of any setting here, and "take it or leave it" is not a dial. + ours( + "volumetricFogShadow", + "1", + "benilla's own: how much Volumetric Fog's Volume setting darkens haze standing in \ + shadow, 0..2 (1 = default, 0 = the haze High draws)", + ), // MONKEY (lampfog): opt-in point-light halos; High graphics preset value is 2. ours( "lampFog", @@ -2455,7 +2470,10 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ // GFX (planar water): the one rung the mirror turns on at — the only `waterQuality` // step that costs a second scene draw, which is what `Ultra` is for. ("waterQuality", "3"), - ("volumetricFog", "2"), + // GFX (froxel volumetrics): High's haze PLUS the volume that tells it where the air + // stands in shadow. The top rung is for the tier that costs real GPU time, and this + // one also retires `lampFog`'s analytic halos into the same solve. + ("volumetricFog", "3"), ("bloom", "2"), ("sunShafts", "1"), ("colorGrading", "1"), @@ -3378,7 +3396,7 @@ 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); 41] = [ + let lighting: [(&str, f32); 42] = [ // GFX (volumetric light) / (moonlight): the three new rows. ("volumetricLight", shadows.volumetric_light as f32), ("volumetricLightStrength", shadows.volumetric_light_strength), @@ -3386,6 +3404,8 @@ mod tests { ("waterQuality", shadows.water_quality as f32), // MONKEY (volumetric fog): weld registry and renderer defaults. ("volumetricFog", shadows.volumetric_fog as f32), + // GFX (froxel volumetrics): the Volume rung's own depth dial. + ("volumetricFogShadow", shadows.volumetric_fog_shadow), // MONKEY (lampfog): opt-in point-light fog. ("lampFog", shadows.lamp_fog as f32), // MONKEY (post): weld registry and renderer defaults. @@ -3450,7 +3470,8 @@ 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(), 41, "the lighting lane welds 41 distinct rows"); + // GFX (froxel volumetrics): and its Volume rung's own depth dial. 41 -> 42. + assert_eq!(welded.len(), 42, "the lighting lane welds 42 distinct rows"); for name in &welded { assert!( REGISTERED.iter().any(|r| r.name == *name), @@ -3637,7 +3658,9 @@ mod tests { // second scene draw, so it alone goes a step past High's default of 2. "3" } else if *name == "Ultra" && member == "volumetricFog" { - "2" // MONKEY (presets): Ultra's maximum over the default. + // GFX (froxel volumetrics): Ultra's maximum over the default, now the + // froxel volume rather than the High march. + "3" } else if member == "waterQuality" && matches!(*name, "Low" | "Medium") { // GFX (water): the first row that sits BELOW its default on the lower // rungs rather than at it. The scenery reflection measured free on the @@ -3920,7 +3943,8 @@ mod tests { assert_eq!(cvars.get("zoneSkyboxes"), Some("0"), "a saved row stays"); // The lighting members follow Ultra too. assert_eq!(cvars.get("shadowMapSize"), Some("4096")); - assert_eq!(cvars.get("volumetricFog"), Some("2")); + // GFX (froxel volumetrics): Ultra's haze rung is tier 3, the solved air volume. + assert_eq!(cvars.get("volumetricFog"), Some("3")); // Medium without a saved lighting rung: the Graphics rung's lighting rung applies. let mut medium = fresh_registry(); @@ -4101,6 +4125,8 @@ mod tests { "waterQuality" => video.water_quality as f32, // MONKEY (volumetric fog): verify presets reach the renderer. "volumetricFog" => video.volumetric_fog as f32, + // GFX (froxel volumetrics) + "volumetricFogShadow" => video.volumetric_fog_shadow, // MONKEY (post): the post lane's live tier. "bloom" => video.bloom as f32, "sunShafts" => video.sun_shafts as u32 as f32, diff --git a/crates/benilla-app/src/froxel.rs b/crates/benilla-app/src/froxel.rs new file mode 100644 index 00000000..d273e67b --- /dev/null +++ b/crates/benilla-app/src/froxel.rs @@ -0,0 +1,1102 @@ +//! GFX (froxel volumetrics): the air in front of the camera, solved once in a 3-D grid. +//! +//! Tier 3 of `volumetricFog` ("Volume"). The haze below it is the reference client's: a zone +//! colour mixed in by distance, the same colour whether the air it tints stands in full sun or in +//! a mountain's shadow. The two lanes that DO know about shadow — `volumetric_light`'s shaft march +//! and `volumetric_fog`'s own lamp halos — can only ever ADD light; neither can tell the haze that +//! it is lying. That is what this lane is for. +//! +//! A view-space grid of froxels is marched one screen column per thread, front to back. Each +//! slice asks the shadow cascade whether its air is in sunlight and sums what the nearby lamps +//! light it with, blends both into the reprojected history of the same froxel, and integrates +//! them into two things: +//! +//! - **the lit share**, the fraction of the air in front of a pixel that the sun reaches, weighted +//! by each slice's own contribution to what the eye sees through it. `volumetric_fog` reads it +//! and darkens the haze colour toward [`SHADOW_FLOOR`] where the air is shadowed. Measured on an +//! Elwynn overlook at noon, **74% of the frame has air the sun does not fully reach** — all of +//! which the haze currently paints the same bright zone colour. +//! - **the lamps' in-scatter**, which is free once a volume exists: the phase lobe is evaluated at +//! every point of the air instead of once per ray at its closest approach, the halo is +//! attenuated by the medium it crosses, and it strengthens when the medium does, which the +//! analytic halo cannot do because it integrates `1/r²` with no density in it at all. +//! +//! **The sun's SHAFTS are deliberately not here**, and that is a measurement, not an oversight. +//! Solving them in the volume and comparing against `volumetric_light`'s per-pixel march: the +//! volume loses at every grid this machine can afford. One froxel column hands the same value to +//! about a hundred pixels, and a tree canopy's shadow is far finer than that — at 160x90, 320x180 +//! and 534x300 alike the shafts came out in hard horizontal steps where the march draws rays. So +//! the shafts stay on the march, the volume keeps the two jobs it is better at, and `docs/GRAPHICS.md` +//! records the sweep. +//! +//! Two smaller decisions worth their lines: +//! - The jitter moves where the air is **measured inside a froxel**, never which texel it is +//! stored in, so the reprojection and both readers stay exactly on the grid and the history +//! converges to the froxel's own mean instead of smearing across its neighbours. And the +//! measurement moves **per slice**, not per column — see the shader; a column that samples one +//! ray is what makes a volume band. +//! - The grid is a fixed froxel BUDGET, not a fixed divisor, so 1080p and 4K both solve about +//! 160x90x64 froxels and the cost and the volume's memory are the same on every monitor. +use crate::{ + shadow_core::{ShadowSet, ShadowSun}, + video::VideoConfig, + volumetric_fog::{self, FogLabel, FogLamps, MAX_FOG_LAMPS}, + volumetric_light, +}; +use benilla_world::{ + lighting::{ShadowHandover, WorldTime, WowLighting}, + view::WorldCamera, + weather::WeatherState, + wmo_portal::CameraInteriorClaim, +}; +use bevy::{ + camera::primitives::Frustum, + core_pipeline::{ + core_3d::graph::{Core3d, Node3d}, + FullscreenShader, + }, + ecs::query::QueryItem, + pbr::{ + GpuLights, LightMeta, ViewLightsUniformOffset, ViewShadowBindings, MAX_CASCADES_PER_LIGHT, + MAX_DIRECTIONAL_LIGHTS, + }, + prelude::*, + render::{ + camera::ExtractedCamera, + diagnostic::RecordDiagnostics, + extract_component::{ExtractComponent, ExtractComponentPlugin}, + render_graph::{ + NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner, + }, + render_resource::{binding_types::*, *}, + renderer::{RenderContext, RenderDevice, RenderQueue}, + view::{ + ExtractedView, Msaa, ViewDepthTexture, ViewTarget, ViewUniform, ViewUniformOffset, + ViewUniforms, + }, + Render, RenderApp, RenderStartup, RenderSystems, + }, + shader::ShaderDefVal, +}; + +pub(crate) struct FroxelPlugin; + +/// The `volumetricFog` rung this lane owns. +pub(crate) const FROXEL_TIER: u8 = 3; + +/// How many froxels the screen plane is divided into, whatever the resolution: the step between +/// froxels is derived from this, not the other way round. 160x90 is one froxel per twelve pixels +/// at 1080p, and the same count — so the same cost and the same 22 MB of volume — at 4K. +const XY_BUDGET: u32 = 160 * 90; +/// Slices along the view ray, quadratically spaced over `near..far`. +const SLICES: u32 = 64; +/// The volume's near bound: the shaft march's own, inside which the near plane clips anyway. +const NEAR: f32 = volumetric_light::MARCH_START; +/// The volume's far bound (yd). Not the cascade's reach — the HAZE's, since the haze is what reads +/// the result, and `volumetric_fog::fog_path` stops integrating at 150. Past the last cascade the +/// column march has no shadow evidence and carries its own mean forward, which is the whole reason +/// this is a column march and not a froxel-per-invocation dispatch. +const FAR: f32 = 150.0; +/// How dark fully shadowed air leaves the haze colour. Fully LIT air is left at **exactly 1.0**, +/// which is the whole safety argument: at a floor of 1.0 this lane renders an Elwynn overlook +/// BYTE-IDENTICAL to the tier below it, so everything it changes is traceable to this one number. +/// Same shape of fit as the indirect-light lane's bounce being scaled by its own occlusion. +/// +/// Measured on that overlook at noon against High: 0.72 moves 64.7% of the frame by up to 31/255, +/// 0.85 moves 57.5% by up to 17. Physics argues for far lower — sky-only illuminance is a quarter +/// of sun-plus-sky — but the haze colour here is an artist's value that already stands for average +/// air, and this is the largest look change of any graphics lane so far. 0.85 is half the +/// amplitude of what the author of the number first tried, deliberately. +const SHADOW_FLOOR: f32 = 0.85; + +/// The floor for a given `volumetricFogShadow`: 1 is the shipped [`SHADOW_FLOOR`], 0 is exactly +/// 1.0 (the haze High draws, byte for byte), 2 is twice the shipped depth. The same 0..2 shape +/// every other look dial on that page uses, so "100% is the default" means the same thing here. +pub(crate) fn shadow_floor(strength: f32) -> f32 { + (1.0 - (1.0 - SHADOW_FLOOR) * strength.clamp(0.0, 2.0)).clamp(0.0, 1.0) +} +/// The current frame's weight in the history. 0.12 is about an eight-frame window, which is the +/// jitter's own period: each offset in the sequence contributes about once per cycle. +const BLEND: f32 = 0.12; +/// Frames in the jitter sequence. +const JITTER_PERIOD: u32 = 8; +/// An eye that moved further than this in one frame did not move, it teleported (a zone-in, a +/// `.tele`, a cinematic cut), and nothing in the previous volume belongs to this one. +const HISTORY_CUT: f32 = 10.0; + +/// The lamp gain. A froxel's in-scatter is `sigma · radiance`, but `volumetric_fog`'s analytic +/// halo integrates `1/r²` with no density in it at all — its night-and-weather ramp carries the +/// strength instead. Dividing by the clear-night extinction lands the two lanes at the same +/// brightness on a clear night, and lets a foggy one be brighter, which is both what a +/// participating medium does and what the Lamp Fog tooltip already promises. +fn lamp_gain() -> f32 { + 0.9 / volumetric_fog::density(0.0, 0.0, false) +} + +/// What `volumetric_fog`'s uniform carries about this lane: the bounds its haze maps a distance +/// into, the floor it darkens toward, and whether there is a solved volume to read at all. +pub(crate) fn haze_terms(live: bool, floor: f32) -> Vec4 { + Vec4::new(NEAR, FAR, floor, if live { 1.0 } else { 0.0 }) +} + +/// The 1x1x1 stand-in `volumetric_fog` binds when this lane is off. Its contents are never read — +/// the uniform's own flag decides that — but a bind group still needs something of the right +/// shape, and one pipeline beats two. +pub(crate) fn stub_volume(device: &RenderDevice) -> TextureView { + volume(device, "froxel_stub", Extent3d::default()) +} + +/// The debug view the shader draws, from the knob. 3 and 4 are "the lamps alone" and "the sun +/// alone", which [`update_views`]'s two gates have already arranged by zeroing the other term, so +/// both of them want the in-scatter view. +fn shader_debug(knob: u8) -> u8 { + match knob { + 0..=2 => knob, + _ => 1, + } +} + +/// Session-only dev knobs, behind the same door as the other lanes' (`run_mode::dev_affordances` +/// and a capture): `WOW_FROXEL_DEBUG` (1 the lamps' in-scatter, 2 the lit share, 3 the lamps +/// alone, 4 the sun alone), `WOW_FROXEL_BLEND`, `WOW_FROXEL_DENSITY`, `WOW_FROXEL_FLOOR`. +#[derive(Resource, Clone, Copy)] +pub(crate) struct FroxelTune { + debug: u8, + blend: f32, + density: f32, + /// A capture's direct override of the floor the `volumetricFogShadow` dial otherwise sets. + /// Read by `volumetric_fog`, which is the pass that applies it. + pub(crate) floor: Option, +} + +impl Default for FroxelTune { + fn default() -> Self { + Self { + debug: 0, + blend: BLEND, + density: 1.0, + floor: None, + } + } +} + +impl FroxelTune { + fn from_env() -> Self { + let default = Self::default(); + if !(crate::run_mode::dev_affordances() && std::env::var_os("WOW_CAPTURE").is_some()) { + return default; + } + let knob = |key: &str, fallback: f32| { + std::env::var(key) + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|v| v.is_finite()) + .unwrap_or(fallback) + }; + Self { + debug: knob("WOW_FROXEL_DEBUG", 0.0).clamp(0.0, 4.0) as u8, + blend: knob("WOW_FROXEL_BLEND", default.blend).clamp(0.01, 1.0), + density: knob("WOW_FROXEL_DENSITY", 1.0).clamp(0.0, 50.0), + floor: std::env::var("WOW_FROXEL_FLOOR") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|v| v.is_finite()) + .map(|v| v.clamp(0.0, 1.0)), + } + } +} + +/// The grid's own shape, shared with the render world because `prepare_resources` sizes the +/// volumes from it. +#[derive(Resource, Clone, Copy)] +struct FroxelGrid { + budget: u32, + slices: u32, +} + +impl Default for FroxelGrid { + fn default() -> Self { + Self { + budget: XY_BUDGET, + slices: SLICES, + } + } +} + +impl FroxelGrid { + /// `WOW_FROXEL_XY` and `WOW_FROXEL_SLICES`, behind the same dev-capture door: the grid's shape + /// is the one thing about this lane that cannot be judged from the code, so it is sweepable. + fn from_env() -> Self { + let default = Self::default(); + if !(crate::run_mode::dev_affordances() && std::env::var_os("WOW_CAPTURE").is_some()) { + return default; + } + let knob = |key: &str, fallback: u32, hi: u32| { + std::env::var(key) + .ok() + .and_then(|v| v.parse::().ok()) + .map_or(fallback, |v| v.clamp(16, hi)) + }; + Self { + budget: knob("WOW_FROXEL_XY", default.budget, 1 << 20), + slices: knob("WOW_FROXEL_SLICES", default.slices, 256), + } + } +} + +/// Froxel counts for a viewport: one step for both axes, chosen so the plane holds about `budget` +/// froxels however many pixels it has. +fn grid_for(size: UVec2, grid: FroxelGrid) -> UVec3 { + let pixels = (size.x.max(1) as f32) * (size.y.max(1) as f32); + let step = ((pixels / grid.budget.max(1) as f32).sqrt().ceil() as u32).max(1); + UVec3::new( + size.x.div_ceil(step).max(1), + size.y.div_ceil(step).max(1), + grid.slices, + ) +} + +/// The radical-inverse (Halton) sequence, the usual low-discrepancy pick for a jitter whose +/// consecutive frames must not fall near each other. +fn halton(mut index: u32, base: u32) -> f32 { + let mut scale = 1.0; + let mut result = 0.0; + while index > 0 { + scale /= base as f32; + result += scale * (index % base) as f32; + index /= base; + } + result +} + +/// Where inside its froxel this frame measures the air, as a fraction of one froxel on each axis. +fn jitter_for(frame: u32) -> Vec3 { + let index = frame % JITTER_PERIOD + 1; + Vec3::new(halton(index, 2), halton(index, 3), halton(index, 5)) - Vec3::splat(0.5) +} + +#[derive(Component, Clone, Copy, ExtractComponent, ShaderType)] +struct FroxelView { + /// x = near (yd), y = far (yd), z = extinction per yard, w unused. + range: Vec4, + /// x = lamp count, y = the night/weather strength, z = [`lamp_gain`], w = the history blend. + lamps: Vec4, + /// x = 1 when a body casts at all, which is what makes the lit share meaningful; y = the + /// debug view; zw unused. + flags: Vec4, + /// xyz = the froxel counts; w = 1 when the history holds a usable frame. Render world's. + grid: Vec4, + /// xyz = the sample offset inside the froxel. Render world's. + jitter: Vec4, + /// xyz = the previous frame's eye. Render world's. + prev_eye: Vec4, + /// The previous frame's `clip_from_world`. Render world's. + prev_clip_from_world: Mat4, + lamp_positions: [Vec4; MAX_FOG_LAMPS], + lamp_colors: [Vec4; MAX_FOG_LAMPS], +} + +impl Plugin for FroxelPlugin { + fn build(&self, app: &mut App) { + let tune = FroxelTune::from_env(); + app.insert_resource(tune) + .add_plugins(ExtractComponentPlugin::::default()) + .add_systems( + Last, + update_views.after(ShadowSet::Lanes).run_if(lane_wanted), + ); + if !app.is_plugin_added::() { + return; + } + let shader = app + .world_mut() + .resource_mut::>() + .add(Shader::from_wgsl( + include_str!("froxel.wgsl"), + "froxel.wgsl", + )); + let Some(render_app) = app.get_sub_app_mut(RenderApp) else { + return; + }; + render_app + .insert_resource(FroxelShader(shader)) + .insert_resource(FroxelGrid::from_env()) + .insert_resource(tune) + .init_resource::>() + .add_systems(RenderStartup, init_pipeline) + .add_systems( + Render, + ( + prepare_pipelines.in_set(RenderSystems::Prepare), + (prepare_resources, retire_volumes) + .chain() + .in_set(RenderSystems::PrepareResources) + .in_set(FroxelPrepare), + ), + ) + .add_render_graph_node::>(Core3d, FroxelSolveLabel) + .add_render_graph_node::>(Core3d, FroxelLabel) + // The solve runs BEFORE the haze, which is its reader. The lamp composite runs after + // the shaft lane's seat: the two are different light and never each other's, but both + // write the view target through `post_process_write`, so a chain is one order instead + // of one the graph has to pick. + .add_render_graph_edges( + Core3d, + ( + Node3d::EndMainPass, + FroxelSolveLabel, + FogLabel, + crate::volumetric_light::VolLightLabel, + FroxelLabel, + Node3d::StartMainPassPostProcessing, + ), + ); + } +} + +/// The lane's door. A system that runs and removes nothing is NOT free here — holding +/// `&mut Camera3d` for a frame changes which of the schedule's thousands of undeclared orders the +/// executor picks — so the lane asks before it reaches for the camera, and the condition itself +/// only reads. It was added chasing the last two bits of three tier 0-2 control frames and did +/// NOT fix them; `docs/GRAPHICS.md` says what did explain them. It stays because the rule it +/// follows is right whatever this particular frame was doing. +fn lane_wanted( + video: Res, + fog_override: Option>, + seated: Query<(), With>, +) -> bool { + // Seated but no longer wanted still needs one more run, to take the component off again. + !seated.is_empty() || live_tier(&video, fog_override.as_deref()) == FROXEL_TIER +} + +/// The `volumetricFog` tier this lane answers to: the session override, else the setting. +fn live_tier(video: &VideoConfig, fog_override: Option<&volumetric_fog::FogOverride>) -> u8 { + fog_override.map_or(video.volumetric_fog.min(FROXEL_TIER), |o| o.fog_tier(video)) +} + +#[allow(clippy::type_complexity)] // one Bevy system's full input set +fn update_views( + mut commands: Commands, + video: Res, + fog_override: Option>, + tune: Res, + lighting: Res, + handover: Res, + clock: Res, + weather: Res, + interior: Res, + point_lights: Res, + rendered: Option>, + suns: Query<&DirectionalLight, With>, + mut cameras: Query< + ( + Entity, + &Camera, + &mut Camera3d, + Option<&GlobalTransform>, + Option<&Frustum>, + ), + With, + >, +) { + let live = live_tier(&video, fog_override.as_deref()) == FROXEL_TIER; + let rig_live = suns.iter().any(|sun| sun.shadows_enabled); + let indoors = interior.0.is_some(); + // The lit share needs a body that actually casts, and the same hand-over policy the shaft lane + // uses, so the two can never disagree about whether it is the sun's hour or the moon's. In a + // room there is nothing for the sky's haze to stand in the shadow of. + let body = live + && rig_live + && !indoors + && tune.debug != 3 + && volumetric_light::light_body(&lighting, &handover, video.moon_light).is_some(); + let weather_amount = weather + .sky_density + .max(weather.effect_density) + .clamp(0.0, 1.0); + let minute = crate::post::grading::rendered_minute(&clock, rendered.as_deref()); + // The lamp half keeps `lampFog`'s policy exactly, including its hard zero in daylight. + let lamp_tier = fog_override + .as_ref() + .map_or(video.lamp_fog.min(2), |o| o.lamp_tier(&video)); + let lamp_strength = if !live || lamp_tier == 0 || tune.debug == 4 { + 0.0 + } else { + volumetric_fog::lamp_haze_strength(lighting.celestial_dir().y, weather_amount, indoors) + }; + // The medium is the shaft lane's: the same dawn/weather law and the same zone scaling, so a + // lamp's halo and a shaft's air agree about how thick the night is. Independent of the shaft + // row's own tier — the haze is lit whether or not anyone is marching it. + let sigma = + volumetric_light::extinction(minute, weather_amount, lighting.fog_end()) * tune.density; + for (entity, camera, mut camera3d, transform, frustum) in &mut cameras { + let lamps = if lamp_strength > 0.0 && camera.is_active { + volumetric_fog::select_fog_lamps( + point_lights.as_slice(), + transform.map_or(Vec3::ZERO, GlobalTransform::translation), + frustum, + volumetric_fog::lamp_limit(lamp_tier), + ) + } else { + FogLamps::default() + }; + let body = body && camera.is_active; + if !body && lamps.count == 0 { + commands.entity(entity).remove::(); + continue; + } + camera3d.depth_texture_usages.0 |= TextureUsages::TEXTURE_BINDING.bits(); + commands.entity(entity).insert(FroxelView { + range: Vec4::new(NEAR, FAR, sigma, 0.0), + lamps: Vec4::new(lamps.count as f32, lamp_strength, lamp_gain(), tune.blend), + flags: Vec4::new( + if body { 1.0 } else { 0.0 }, + f32::from(shader_debug(tune.debug)), + 0.0, + 0.0, + ), + grid: Vec4::ZERO, + jitter: Vec4::ZERO, + prev_eye: Vec4::ZERO, + prev_clip_from_world: Mat4::ZERO, + lamp_positions: lamps.positions, + lamp_colors: lamps.colors, + }); + } +} + +#[derive(Resource)] +struct FroxelShader(Handle); + +#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)] +struct FroxelSolveLabel; +#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)] +struct FroxelLabel; + +#[derive(Resource)] +struct FroxelPipeline { + solve_layout: BindGroupLayoutDescriptor, + /// `[single, multisampled]` depth. + composite_layouts: [BindGroupLayoutDescriptor; 2], + shader: Handle, + fullscreen: FullscreenShader, + comparison: Sampler, + linear: Sampler, + solve: CachedComputePipelineId, +} + +/// The composite's specialisation: the view target it writes and whether the depth it reads is +/// resolved. The solve stage has neither, so it is queued once at startup instead. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +struct FroxelKey { + format: TextureFormat, + multisampled: bool, +} + +#[derive(Component)] +struct ViewFroxelPipeline(CachedRenderPipelineId); + +fn light_defs() -> Vec { + 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), + ] +} + +fn init_pipeline( + mut commands: Commands, + device: Res, + shader: Res, + fullscreen: Res, + cache: Res, +) { + let solve_layout = BindGroupLayoutDescriptor::new( + "froxel_solve_layout", + &BindGroupLayoutEntries::sequential( + ShaderStages::COMPUTE, + ( + uniform_buffer::(false), + uniform_buffer::(true), + uniform_buffer::(true), + texture_2d_array(TextureSampleType::Depth), + sampler(SamplerBindingType::Comparison), + // The previous frame's volume, read through a trilinear sampler at a reprojected + // position that is never texel-aligned. + texture_3d(TextureSampleType::Float { filterable: true }), + sampler(SamplerBindingType::Filtering), + texture_storage_3d(TextureFormat::Rgba16Float, StorageTextureAccess::WriteOnly), + texture_storage_3d(TextureFormat::Rgba16Float, StorageTextureAccess::WriteOnly), + ), + ), + ); + let composite_layouts = [false, true].map(|multisampled| { + BindGroupLayoutDescriptor::new( + "froxel_composite_layout", + &BindGroupLayoutEntries::sequential( + ShaderStages::FRAGMENT, + ( + uniform_buffer::(false), + texture_2d(TextureSampleType::Float { filterable: false }), + if multisampled { + texture_depth_2d_multisampled() + } else { + texture_depth_2d() + }, + texture_3d(TextureSampleType::Float { filterable: true }), + sampler(SamplerBindingType::Filtering), + uniform_buffer::(true), + ), + ), + ) + }); + let mut defs = light_defs(); + defs.push("FROXEL_SOLVE".into()); + let solve = cache.queue_compute_pipeline(ComputePipelineDescriptor { + label: Some("froxel_solve".into()), + layout: vec![solve_layout.clone()], + push_constant_ranges: vec![], + shader: shader.0.clone(), + shader_defs: defs, + entry_point: Some("cs_solve".into()), + // No workgroup memory: every thread owns its own column and shares nothing. + zero_initialize_workgroup_memory: false, + }); + commands.insert_resource(FroxelPipeline { + solve_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() + }), + linear: device.create_sampler(&SamplerDescriptor { + label: Some("froxel_volume_sampler"), + min_filter: FilterMode::Linear, + mag_filter: FilterMode::Linear, + address_mode_u: AddressMode::ClampToEdge, + address_mode_v: AddressMode::ClampToEdge, + address_mode_w: AddressMode::ClampToEdge, + ..default() + }), + solve, + }); +} + +impl SpecializedRenderPipeline for FroxelPipeline { + type Key = FroxelKey; + + fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor { + let mut defs = light_defs(); + defs.push("FROXEL_COMPOSITE".into()); + if key.multisampled { + defs.push("MULTISAMPLED".into()); + } + RenderPipelineDescriptor { + label: Some("froxel_composite".into()), + layout: vec![self.composite_layouts[key.multisampled as usize].clone()], + vertex: self.fullscreen.to_vertex_state(), + fragment: Some(FragmentState { + shader: self.shader.clone(), + shader_defs: defs, + entry_point: Some("fs_composite".into()), + targets: vec![Some(ColorTargetState { + format: key.format, + blend: None, + write_mask: ColorWrites::ALL, + })], + }), + ..default() + } + } +} + +fn prepare_pipelines( + mut commands: Commands, + cache: Res, + pipeline: Res, + mut specialized: ResMut>, + views: Query<(Entity, &ViewTarget, &Msaa), With>, + all_views: Query<(&ViewTarget, &Msaa), With>, +) { + // Warm the composite on every 3-D view, tier on or off (the `pipe_warm` census contract): the + // solve stage is queued at startup, so the row going live hits the cache, never a compile. + let mut key = |target: &ViewTarget, msaa: &Msaa| { + specialized.specialize( + &cache, + &pipeline, + FroxelKey { + format: target.main_texture_format(), + multisampled: msaa.samples() > 1, + }, + ) + }; + for (target, msaa) in &all_views { + key(target, msaa); + } + for (entity, target, msaa) in &views { + let id = key(target, msaa); + commands.entity(entity).insert(ViewFroxelPipeline(id)); + } +} + +#[derive(Component)] +struct FroxelUniform(UniformBuffer); + +/// The three volumes and everything about them that only the render world knows: which history is +/// this frame's, how many frames have been written since they were made, and where the camera was +/// when the last one was. +#[derive(Component)] +pub(crate) struct FroxelVolumes { + history: [TextureView; 2], + solved: TextureView, + size: Extent3d, + frame: u32, + /// Index into `history` written this frame; the other one is the reprojection source. + current: usize, + previous: Option<(Mat4, Vec3)>, +} + +impl FroxelVolumes { + /// The accumulated volume, whose alpha is the lit share `volumetric_fog`'s haze reads. + pub(crate) fn solved(&self) -> &TextureView { + &self.solved + } + + /// Whether a completed solve stands in it. False for the frame the volumes are made in, and + /// for the frame after a resize remakes them. + pub(crate) fn ready(&self) -> bool { + self.frame >= 1 + } +} + +fn volume(device: &RenderDevice, label: &'static str, size: Extent3d) -> TextureView { + device + .create_texture(&TextureDescriptor { + label: Some(label), + size, + mip_level_count: 1, + sample_count: 1, + dimension: TextureDimension::D3, + format: TextureFormat::Rgba16Float, + usage: TextureUsages::STORAGE_BINDING | TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) + .create_view(&TextureViewDescriptor::default()) +} + +/// The set `prepare_resources` runs in, so `volumetric_fog`'s own prepare can declare that it +/// reads this frame's answer rather than last frame's. A set, not the system itself: the system's +/// parameters are private to this module and naming it would make them public. +#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)] +pub(crate) struct FroxelPrepare; + +#[allow(clippy::type_complexity)] // one Bevy system's full input set +fn prepare_resources( + mut commands: Commands, + device: Res, + queue: Res, + grid: Res, + mut views: Query<( + Entity, + &ExtractedCamera, + &ExtractedView, + &FroxelView, + Option<&mut FroxelUniform>, + Option<&mut FroxelVolumes>, + )>, +) { + for (entity, camera, extracted, view, uniform, volumes) in &mut views { + let Some(viewport) = camera.physical_viewport_size else { + continue; + }; + let counts = grid_for(viewport, *grid); + let size = Extent3d { + width: counts.x, + height: counts.y, + depth_or_array_layers: counts.z, + }; + // Mirrors Bevy's own `ViewUniform` construction, so the matrix a frame stores as the + // history's is the exact one the solve's froxel positions were built from. + let eye = extracted.world_from_view.translation(); + let clip_from_world = extracted.clip_from_world.unwrap_or_else(|| { + extracted.clip_from_view * extracted.world_from_view.to_matrix().inverse() + }); + + // The main world fills the lighting; these four are the frame's, and only the render world + // has them. + let mut patched = *view; + patched.grid = counts.as_vec3().extend(0.0); + patched.prev_clip_from_world = clip_from_world; + patched.prev_eye = eye.extend(0.0); + patched.jitter = jitter_for(0).extend(0.0); + + match volumes { + // The volumes HOLD the history, so they are made once and kept; a fresh pair every + // frame would leave nothing to reproject from. A resize is the one thing that + // invalidates them, and it resets the frame counter with them. + Some(mut volumes) if volumes.size == size => { + volumes.current = (volumes.frame % 2) as usize; + patched.jitter = jitter_for(volumes.frame).extend(0.0); + if let Some((matrix, was)) = volumes.previous { + // An eye that moved further than this in one frame teleported, and nothing in + // the previous volume belongs to this one: the reprojection would be in + // bounds and wrong, which is the one failure a bounds test cannot catch. + if volumes.ready() && eye.distance(was) < HISTORY_CUT { + patched.grid.w = 1.0; + patched.prev_clip_from_world = matrix; + patched.prev_eye = was.extend(0.0); + } + } + volumes.previous = Some((clip_from_world, eye)); + volumes.frame = volumes.frame.wrapping_add(1); + } + _ => { + commands.entity(entity).insert(FroxelVolumes { + history: [ + volume(&device, "froxel_history_a", size), + volume(&device, "froxel_history_b", size), + ], + solved: volume(&device, "froxel_solved", size), + size, + frame: 0, + current: 0, + previous: None, + }); + } + } + + match uniform { + Some(mut uniform) => { + uniform.0.set(patched); + uniform.0.write_buffer(&device, &queue); + } + None => { + let mut made = UniformBuffer::from(patched); + made.write_buffer(&device, &queue); + commands.entity(entity).insert(FroxelUniform(made)); + } + } + } +} + +/// A view whose lane has gone quiet — no body casting and no lamp in range — keeps its volumes, +/// and `volumetric_fog` would go on reading the last solve in them. Dropping them says "no volume" +/// instead, which is also the right answer: with nothing shading the air, the haze is neutral. +/// It gives back the 22 MB too, which matters on the rung's own OFF setting. +fn retire_volumes( + mut commands: Commands, + stale: Query, Without)>, +) { + for entity in &stale { + commands.entity(entity).remove::(); + } +} + +#[derive(Default)] +struct FroxelSolveNode; + +impl ViewNode for FroxelSolveNode { + type ViewQuery = ( + &'static ViewUniformOffset, + &'static ViewLightsUniformOffset, + &'static ViewShadowBindings, + // Gates the pass: the uniform and the volumes outlive a disabled frame. + &'static FroxelView, + &'static FroxelUniform, + &'static FroxelVolumes, + ); + + fn run<'w>( + &self, + _graph: &mut RenderGraphContext, + context: &mut RenderContext<'w>, + (view_offset, light_offset, shadows, _, uniform, volumes): QueryItem< + 'w, + '_, + Self::ViewQuery, + >, + world: &'w World, + ) -> Result<(), NodeRunError> { + let cache = world.resource::(); + let settings = world.resource::(); + let Some(solve) = cache.get_compute_pipeline(settings.solve) else { + return Ok(()); + }; + let (Some(view), Some(lights), Some(params)) = ( + world.resource::().uniforms.binding(), + world.resource::().view_gpu_lights.binding(), + uniform.0.binding(), + ) else { + return Ok(()); + }; + let current = volumes.current; + let bind = context.render_device().create_bind_group( + "froxel_solve", + &cache.get_bind_group_layout(&settings.solve_layout), + &BindGroupEntries::sequential(( + params, + view, + lights, + &shadows.directional_light_depth_texture_view, + &settings.comparison, + &volumes.history[1 - current], + &settings.linear, + &volumes.history[current], + &volumes.solved, + )), + ); + let diagnostics = context.diagnostic_recorder(); + let mut pass = context + .command_encoder() + .begin_compute_pass(&ComputePassDescriptor { + label: Some("froxel_solve"), + timestamp_writes: None, + }); + // A compute pass takes a span like a render pass does (`WriteTimestamp` is implemented for + // both), and this is the lane's whole cost — without it the rung reads as free. + let span = diagnostics.pass_span(&mut pass, "froxel_solve"); + pass.set_pipeline(solve); + pass.set_bind_group(0, &bind, &[view_offset.offset, light_offset.offset]); + // One thread per screen column of froxels; each walks every slice. + pass.dispatch_workgroups( + volumes.size.width.div_ceil(8), + volumes.size.height.div_ceil(8), + 1, + ); + span.end(&mut pass); + Ok(()) + } +} + +#[derive(Default)] +struct FroxelCompositeNode; + +impl ViewNode for FroxelCompositeNode { + type ViewQuery = ( + &'static ViewTarget, + &'static ViewDepthTexture, + &'static ViewUniformOffset, + &'static FroxelView, + &'static FroxelUniform, + &'static FroxelVolumes, + &'static ViewFroxelPipeline, + ); + + fn run<'w>( + &self, + _graph: &mut RenderGraphContext, + context: &mut RenderContext<'w>, + (target, depth, view_offset, froxel, uniform, volumes, pipeline_id): QueryItem< + 'w, + '_, + Self::ViewQuery, + >, + world: &'w World, + ) -> Result<(), NodeRunError> { + // The lamps are the only thing this pass draws. With none in range the volume's one + // product is the lit share, which `volumetric_fog` has already read: nothing to composite. + if froxel.lamps.x < 0.5 && froxel.flags.y < 0.5 { + return Ok(()); + } + let cache = world.resource::(); + let settings = world.resource::(); + let Some(composite) = cache.get_render_pipeline(pipeline_id.0) else { + return Ok(()); + }; + if !depth + .texture + .usage() + .contains(TextureUsages::TEXTURE_BINDING) + { + return Ok(()); + } + let (Some(view), Some(params)) = ( + world.resource::().uniforms.binding(), + uniform.0.binding(), + ) else { + return Ok(()); + }; + let multisampled = (depth.texture.sample_count() > 1) as usize; + let out = target.post_process_write(); + let bind = context.render_device().create_bind_group( + "froxel_composite", + &cache.get_bind_group_layout(&settings.composite_layouts[multisampled]), + &BindGroupEntries::sequential(( + params, + out.source, + depth.view(), + &volumes.solved, + &settings.linear, + view, + )), + ); + let diagnostics = context.diagnostic_recorder(); + let mut pass = context + .command_encoder() + .begin_render_pass(&RenderPassDescriptor { + label: Some("froxel_composite"), + color_attachments: &[Some(RenderPassColorAttachment { + view: out.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, "froxel_composite"); + pass.set_pipeline(composite); + pass.set_bind_group(0, &bind, &[view_offset.offset]); + pass.draw(0..3, 0..1); + span.end(&mut pass); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The grid is a budget, not a divisor: every common resolution solves about the same number + /// of froxels, which is what makes the cost and the volume's memory resolution-independent. + #[test] + fn the_froxel_count_barely_moves_across_resolutions() { + let grid = FroxelGrid::default(); + for (w, h) in [ + (1280, 720), + (1920, 1080), + (2560, 1440), + (3440, 1440), + (3840, 2160), + (1366, 768), + ] { + let counts = grid_for(UVec2::new(w, h), grid); + let plane = counts.x * counts.y; + assert_eq!(counts.z, SLICES); + assert!( + (XY_BUDGET / 2..=XY_BUDGET * 2).contains(&plane), + "{w}x{h} solves {plane} froxels per slice, outside half-to-double {XY_BUDGET}" + ); + // Both axes take the same step, so a froxel stays about as wide as it is tall. + let aspect = (counts.x as f32 / counts.y as f32) / (w as f32 / h as f32); + assert!( + (aspect - 1.0).abs() < 0.05, + "{w}x{h} froxels are not square" + ); + } + // A 1080p screen is the one this was tuned on: exactly one froxel per 12 pixels. + assert_eq!( + grid_for(UVec2::new(1920, 1080), grid), + UVec3::new(160, 90, 64) + ); + } + + /// The jitter has to cover the froxel without repeating inside the history's window, which is + /// what turns one shadow sample per froxel into eight. + #[test] + fn the_jitter_sweeps_the_froxel_and_never_repeats_inside_its_period() { + let mut seen = Vec::new(); + for frame in 0..JITTER_PERIOD { + let j = jitter_for(frame); + assert!( + j.x > -0.5 && j.x < 0.5 && j.y > -0.5 && j.y < 0.5 && j.z > -0.5 && j.z < 0.5, + "frame {frame} measures outside its own froxel: {j}" + ); + for other in &seen { + assert!( + j.distance(*other) > 0.05, + "frame {frame} measures where another frame of the period already did" + ); + } + seen.push(j); + } + assert_eq!(jitter_for(JITTER_PERIOD), jitter_for(0), "it is a cycle"); + // The sweep is centred enough that the mean is the froxel's own centre, not a corner. + let mean: Vec3 = seen.iter().copied().sum::() / JITTER_PERIOD as f32; + assert!(mean.length() < 0.1, "the sweep is biased: {mean}"); + } + + /// The lamp gain is the one number that makes this lane's halo land where the analytic one + /// sits, and it is derived from the clear-night extinction rather than picked. + #[test] + fn the_lamp_gain_matches_the_analytic_halo_on_a_clear_night() { + let clear = volumetric_fog::density(0.0, 0.0, false); + assert_eq!( + clear, 0.004, + "the clear-night extinction this is calibrated on" + ); + assert_eq!(lamp_gain(), 0.9 / clear); + // A foggy night scatters more, so the same lamp is brighter — the medium's own behaviour, + // not a second strength knob. + assert!(volumetric_fog::density(0.0, 1.0, false) > clear * 2.0); + } + + /// Fully lit air must leave the haze EXACTLY as it is drawn today: the daylight exterior is + /// the one look this client is measured against, and this lane only ever darkens air that + /// something is standing in front of. + #[test] + fn the_haze_terms_are_neutral_in_full_sun_and_inert_when_the_lane_is_off() { + let live = haze_terms(true, shadow_floor(1.0)); + assert_eq!(live.w, 1.0); + assert_eq!((live.x, live.y), (NEAR, FAR)); + assert!(live.z > 0.0 && live.z < 1.0, "a floor, not a switch"); + assert_eq!(haze_terms(false, shadow_floor(1.0)).w, 0.0); + // `mix(floor, 1, lit)` at lit = 1 is exactly 1: no multiply, no drift. + assert_eq!(live.z + (1.0 - live.z) * 1.0, 1.0); + // The volume reaches as far as the haze integrates, or the haze would be modulated by air + // nobody solved. + assert_eq!(FAR, 150.0, "volumetric_fog::fog_path stops here too"); + } + + /// The dial's ends are the two promises its tooltip makes: 0 is the haze High draws and 1 is + /// the shipped look, with 2 twice as deep and nothing past it. + #[test] + fn the_shadowed_air_dial_runs_from_untouched_to_twice_the_shipped_depth() { + assert_eq!(shadow_floor(0.0), 1.0, "0 is High's own haze, exactly"); + assert_eq!(shadow_floor(1.0), SHADOW_FLOOR); + assert!((shadow_floor(2.0) - (1.0 - 2.0 * (1.0 - SHADOW_FLOOR))).abs() < 1e-6); + assert_eq!(shadow_floor(9.0), shadow_floor(2.0), "the clamp holds"); + assert!(shadow_floor(2.0) > 0.0, "a floor, never a black haze"); + let mut last = 1.1; + for step in 0..=8 { + let floor = shadow_floor(step as f32 * 0.25); + assert!(floor < last, "the dial has to be monotone"); + last = floor; + } + } + + /// The two "one term alone" knobs are arranged CPU-side and both ask the shader for the same + /// view; getting that wrong is what made the first lamps-only capture a flat white frame. + #[test] + fn the_isolating_debug_knobs_ask_for_the_in_scatter_view() { + assert_eq!( + (0..=4).map(shader_debug).collect::>(), + [0, 1, 2, 1, 1] + ); + } + + /// The shader's bounds are mirrored in this file's dispatch and lamp table; a silent edit to + /// either side would make the pass skip columns or read uninitialised lamps. + #[test] + fn the_shader_agrees_with_the_dispatch_and_the_lamp_table() { + let src = include_str!("froxel.wgsl"); + assert!(src.contains("@workgroup_size(8, 8, 1)")); + assert!(src.contains("const COLUMN_LAMPS: u32 = 8u;")); + assert!( + src.contains("array, 32>"), + "the shader's lamp table must be MAX_FOG_LAMPS wide" + ); + assert_eq!(MAX_FOG_LAMPS, 32); + } +} diff --git a/crates/benilla-app/src/froxel.wgsl b/crates/benilla-app/src/froxel.wgsl new file mode 100644 index 00000000..9a6a27c3 --- /dev/null +++ b/crates/benilla-app/src/froxel.wgsl @@ -0,0 +1,330 @@ +// GFX (froxel volumetrics): the air in front of the camera solved once, in a view-space 3-D grid, +// so the distance haze can be told where it stands in shadow (see froxel.rs). +// +// FROXEL_SOLVE compute, one thread per screen COLUMN of froxels. It walks the column's +// slices front to back, asks the shadow cascade whether each one is in +// sunlight and sums what the lamps light it with, blends both into the +// reprojected history of the same froxel, and integrates them: the lamps into +// accumulated in-scatter, the sunlight into the share of the air in front of +// this column that the sun actually reaches. +// FROXEL_COMPOSITE fragment, full resolution. One trilinear read of the lamps' accumulated +// light at the pixel's own distance, added over the scene. +// +// The lit share is the volume's real product, and `volumetric_fog`'s haze reads it directly — the +// solve runs BEFORE that pass for exactly that reason. The lamps are the half that happens to be +// free once a volume exists. The sun's SHAFTS are not here: measured against the per-pixel march +// in `volumetric_light`, a volume loses, because one froxel column hands the same value to about +// a hundred pixels and a canopy's shadow is finer than any grid worth paying for. +// +// Why a column and not a three-dimensional dispatch, which is the usual shape: a froxel past the +// last shadow cascade has no evidence either way, and the honest answer is the mean of what the +// REST OF ITS COLUMN could see. A thread that owns the whole column can carry that; one froxel +// per invocation cannot. It also lets one pass both inject and integrate, so the history blend +// lands BEFORE the integral rather than a frame behind it. +// +// Identifiers carry no trailing digit (naga_oil). + +struct Froxel { + range: vec4, // near (yd), far (yd), extinction per yard, unused + lamps: vec4, // count, night/weather strength, gain, history blend (this frame's weight) + flags: vec4, // 1 when a body casts, debug view, unused, unused + grid: vec4, // froxel counts, 1 when the history holds a usable frame + jitter: vec4, // the sample offset inside the froxel, unused + prev_eye: vec4, + prev_clip_from_world: mat4x4, + lamp_positions: array, 32>, + lamp_colors: array, 32>, +} + +// Slice boundary (0..1) to distance along the view ray (yd), and back. Quadratic, the same law the +// shaft march spaces its samples by: dense near the eye, where the lamp cores are. The pair must +// stay exact inverses — the solve stores a froxel at the slice its boundaries say, and two other +// passes find it again from a pixel's distance alone. +fn slice_distance(near: f32, far: f32, w: f32) -> f32 { + return near + (far - near) * w * w; +} +fn slice_of(near: f32, far: f32, t: f32) -> f32 { + return sqrt(clamp((t - near) / max(far - near, 1e-4), 0.0, 1.0)); +} + +// The world-space ray through a screen uv. +fn ray_through(world_from_clip: mat4x4, eye: vec3, uv: vec2) -> vec3 { + let ndc = uv * vec2(2.0, -2.0) + vec2(-1.0, 1.0); + // Any depth on the ray gives the same direction; a mid-range reverse-Z value is the one + // furthest from both the near plane's precision and the infinite far plane. + let q = world_from_clip * vec4(ndc, 0.5, 1.0); + return normalize(q.xyz / q.w - eye); +} + +// 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. The twin of `volumetric_light.wgsl`'s, so the +// lanes key off the same quantity. +fn ray_distance(world_from_clip: mat4x4, eye: vec3, ndc_xy: vec2, z: f32, cap: f32) -> f32 { + if (z <= 0.0) { + return cap; + } + let q = world_from_clip * vec4(ndc_xy, z, 1.0); + return min(length(q.xyz / q.w - eye), cap); +} + +// Henyey-Greenstein with a quarter isotropic, so a lamp seen side-on still glows a little, as real +// haze does. `volumetric_fog`'s own lamp lobe, moved to where the air actually is. +fn phase_hg(g: f32, cosine: f32) -> f32 { + let denom = 1.0 + g * g - 2.0 * g * cosine; + let hg = 0.07957747 * (1.0 - g * g) / (denom * sqrt(denom)); + return mix(hg, 0.07957747, 0.25); +} + +#ifdef FROXEL_SOLVE + +#import bevy_render::view::View +#import bevy_pbr::mesh_view_types::Lights + +@group(0) @binding(0) var fx: Froxel; +@group(0) @binding(1) var view: View; +@group(0) @binding(2) var 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 history_in: texture_3d; +@group(0) @binding(6) var history_sampler: sampler; +@group(0) @binding(7) var history_out: texture_storage_3d; +@group(0) @binding(8) var solved: texture_storage_3d; + +// How many lamps one column carries. The CPU hands the table sorted by distance from the eye, so +// these are a column's nearest few; past them a lamp is both further away and behind the ones +// already summed. It is also register pressure, held by every thread for the whole march. +const COLUMN_LAMPS: u32 = 8u; +// The lamp's softened core, `volumetric_fog`'s `core_radius`: the same number, so a halo has the +// same shape whichever lane draws it. +const LAMP_CORE: f32 = 1.25; +// A safety net on one froxel's radiance, not a look control: a jittered sample landing inside a +// lamp's core would otherwise carry the whole halo by itself, and the history would hold that +// spike for a dozen frames. A lamp a yard away reaches about 50 of this. +const MAX_RADIANCE: f32 = 64.0; +// The R2 low-discrepancy sequence (Roberts 2018), the two-dimensional golden ratio: the cheapest +// way to walk a froxel's screen footprint down its own column without two consecutive slices +// landing near each other. +const R2: vec2 = vec2(0.7548777, 0.56984029); + +// 1 lit, 0 shadowed, -1 outside every cascade (unknown). `volumetric_light.wgsl`'s, verbatim: the +// cascade walk, the normal-less depth bias along the light, and the uv/ndc bounds test. +fn visibility(light_index: u32, p: vec3) -> 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; +} + +@compute @workgroup_size(8, 8, 1) +fn cs_solve(@builtin(global_invocation_id) id: vec3) { + let grid = fx.grid.xyz; + if (id.x >= u32(grid.x) || id.y >= u32(grid.y)) { return; } + let slices = u32(grid.z); + let near = fx.range.x; + let far = fx.range.y; + let sigma = fx.range.z; + let eye = view.world_position; + + // What this column MEANS: its own centre. Every sample below is taken somewhere else inside + // the froxel, which is the point — see `spread`. + let column_uv = (vec2(id.xy) + 0.5) / grid.xy; + let ray = ray_through(view.world_from_clip, eye, column_uv); + + // Column cull. Whether a lamp can reach this column at all is a per-column question, and the + // test is the analytic halo's own: the ray's perpendicular distance against the lamp's reach. + var near_lamps: array; + var near_count = 0u; + let lamp_count = u32(fx.lamps.x); + for (var i = 0u; i < 32u; i += 1u) { + if (i >= lamp_count || near_count >= COLUMN_LAMPS) { break; } + let position_range = fx.lamp_positions[i]; + let to_lamp = position_range.xyz - eye; + let along = dot(to_lamp, ray); + let perpendicular = to_lamp - ray * along; + if (dot(perpendicular, perpendicular) < position_range.w * position_range.w + && along > -position_range.w) { + near_lamps[near_count] = i; + near_count += 1u; + } + } + + let body = fx.flags.x > 0.5; + let blend = fx.lamps.w; + let history_usable = fx.grid.w > 0.5; + var known = 0.0; + var known_count = 0.0; + var accumulated = vec3(0.0); + var transmittance = 1.0; + // The share of the air in front of this column that the sun reaches, weighted by how much + // each slice contributes to what the eye sees through it. This is the quantity no screen pass + // can produce: the shaft march only ever ADDS the lit air, and nothing tells the distance haze + // that the air it is tinting stands in a hill's shadow. + var lit = 0.0; + var lit_weight = 0.0; + + for (var k = 0u; k < 256u; k += 1u) { + if (k >= slices) { break; } + let w_near = f32(k) / grid.z; + let w_mid = (f32(k) + 0.5) / grid.z; + let w_far = f32(k + 1u) / grid.z; + let t_near = slice_distance(near, far, w_near); + let t_mid = slice_distance(near, far, w_mid); + let t_far = slice_distance(near, far, w_far); + + // Where this slice measures the air: a DIFFERENT point of the froxel's screen footprint + // for every slice, not one ray for the whole column. A column that samples a single ray + // hands the same value to every pixel inside it, and that is what makes a volume band in + // hard steps no amount of resolution removes — measured at 160x90, 320x180 and 534x300 + // alike. Spread down the column, the slices average the froxel's own footprint, which is + // what the texel is supposed to mean. + let spread = fract(fx.jitter.xy + 0.5 + R2 * f32(k)) - 0.5; + let measured_uv = clamp(column_uv + spread / grid.xy, vec2(0.0), vec2(1.0)); + let measured_ray = ray_through(view.world_from_clip, eye, measured_uv); + let measured = eye + measured_ray + * slice_distance(near, far, clamp(w_mid + fx.jitter.z / grid.z, w_near, w_far)); + + // Air nobody is shading is air in full sun: with no body casting there is nothing for the + // haze to be in the shadow OF, and 1 leaves it exactly as it is drawn today. + var seen = 1.0; + if (body) { + seen = -1.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; } + seen = visibility(light_index, measured); + break; + } + if (seen >= 0.0) { + known += seen; + known_count += 1.0; + } else { + // Past the cascades: the mean of what this column could see. The volume reaches + // as far as the HAZE does, which is further than any cascade, so this carries the + // last third of most rays and a hard 0 or 1 there would draw a ring. + seen = select(1.0, known / known_count, known_count > 0.0); + } + } + + var radiance = vec3(0.0); + for (var j = 0u; j < COLUMN_LAMPS; j += 1u) { + if (j >= near_count) { break; } + let index = near_lamps[j]; + let position_range = fx.lamp_positions[index]; + let to_sample = measured - position_range.xyz; + let reach = sqrt(dot(to_sample, to_sample)); + if (reach >= position_range.w) { continue; } + // A smooth gate at the lamp's reach, so a fixture entering the column's cull does not + // pop, and the same mild forward lobe the analytic halo evaluates once per ray — here + // at the froxel's own position, which is the whole reason a lamp belongs in a volume. + let edge = 1.0 - smoothstep(position_range.w * 0.72, position_range.w, reach); + let lobe = phase_hg(0.2, dot(to_sample / max(reach, 1e-4), -measured_ray)); + radiance += max(fx.lamp_colors[index].rgb, vec3(0.0)) + * (edge * lobe * fx.lamps.y * fx.lamps.z + / (LAMP_CORE * LAMP_CORE + reach * reach)); + } + radiance = min(radiance, vec3(MAX_RADIANCE)); + + var current = vec4(radiance, seen); + if (history_usable) { + // This froxel's own centre, one frame ago. Outside the previous frustum, nearer than + // the near plane or past the far one, there is nothing to reproject from and the + // measurement stands alone. + let centre = eye + ray * t_mid; + let clip = fx.prev_clip_from_world * vec4(centre, 1.0); + if (clip.w > 0.0) { + let uv = (clip.xy / clip.w) * vec2(0.5, -0.5) + vec2(0.5, 0.5); + let was = length(centre - fx.prev_eye.xyz); + if (all(uv > vec2(0.0)) && all(uv < vec2(1.0)) + && was > near && was < far) { + let old = textureSampleLevel(history_in, history_sampler, + vec3(uv, slice_of(near, far, was)), 0.0); + current = mix(old, current, blend); + } + } + } + textureStore(history_out, vec3(i32(id.x), i32(id.y), i32(k)), current); + + // The integral, in two halves. The sampler reads the texel at the slice's own CENTRE, so + // that is where the accumulation has to be stored: one store at the far edge starts every + // halo half a slice late, and at the near end the froxels are thinnest and the eye most + // sensitive. The source term is `sigma·radiance`, so the analytic slice integral + // `S·(1-T)/sigma` is just `radiance·(1-T)` and needs no guarded divide. + var through = exp(-sigma * (t_mid - t_near)); + var share = transmittance * (1.0 - through); + accumulated += share * current.rgb; + lit += share * current.a; + lit_weight += share; + transmittance *= through; + textureStore(solved, vec3(i32(id.x), i32(id.y), i32(k)), + vec4(accumulated, lit / max(lit_weight, 1e-6))); + through = exp(-sigma * (t_far - t_mid)); + share = transmittance * (1.0 - through); + accumulated += share * current.rgb; + lit += share * current.a; + lit_weight += share; + transmittance *= through; + } +} + +#endif + +#ifdef FROXEL_COMPOSITE + +#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput +#import bevy_render::view::View + +@group(0) @binding(0) var fx: Froxel; +@group(0) @binding(1) var scene: texture_2d; +#ifdef MULTISAMPLED +@group(0) @binding(2) var depth: texture_depth_multisampled_2d; +#else +@group(0) @binding(2) var depth: texture_depth_2d; +#endif +@group(0) @binding(3) var volume: texture_3d; +@group(0) @binding(4) var volume_sampler: sampler; +@group(0) @binding(5) var view: View; + +@fragment +fn fs_composite(in: FullscreenVertexOutput) -> @location(0) vec4 { + let p = vec2(in.position.xy); + let source = textureLoad(scene, p, 0); + let dims = vec2(textureDimensions(depth)); + let z = textureLoad(depth, min(p, dims - vec2(1)), 0); + let uv = (vec2(p) + 0.5) / vec2(dims); + let ndc = uv * vec2(2.0, -2.0) + vec2(-1.0, 1.0); + // The pixel's own distance decides which slice it reads, so a wall cuts the lamplight in front + // of it exactly where it stands — no bilateral upsample, no depth tolerance, nothing to tune. + let t = ray_distance(view.world_from_clip, view.world_position, ndc, z, fx.range.y); + let read = textureSampleLevel(volume, volume_sampler, + vec3(uv, slice_of(fx.range.x, fx.range.y, t)), 0.0); + + // Debug 2 is the lit share itself, which is the volume's real product; `volumetric_fog` reads + // the same channel. Every debug view still passes through the glow, grade and dither chain + // below this one, so read it for WHERE a term is and not for its magnitude. + if (fx.flags.y > 1.5) { + return vec4(vec3(read.a), source.a); + } + let light = max(read.rgb, vec3(0.0)); + if (fx.flags.y > 0.5) { + return vec4(vec3(1.0) - exp(-light), source.a); + } + // The fog pass's soft screen curve: bright pixels saturate gently instead of clipping. The + // volume's transmittance is deliberately not applied to the scene — the distance haze above + // already does that job, in the reference's own artistic form, and doing it twice fogs every + // frame twice. What the volume tells that haze instead is the lit share in `read.a`. + let lifted = source.rgb + max(vec3(0.0), vec3(1.0) - source.rgb) + * (vec3(1.0) - exp(-light)); + return vec4(lifted, source.a); +} + +#endif diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index f63b5fc6..fab309f4 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -184,6 +184,8 @@ impl PluginGroup for GamePlugins { .add(crate::volumetric_fog::VolumetricFogPlugin) // GFX (volumetric light): after the fog plugin, whose graph node it follows. .add(crate::volumetric_light::VolumetricLightPlugin) + // GFX (froxel volumetrics): after the shaft plugin, whose graph node it follows. + .add(crate::froxel::FroxelPlugin) // MONKEY (p0 graphics programme): the programme's cvar bridges (skyDither, ...). .add(crate::monkey_gfx::MonkeyGfxPlugin) // A wgpu validation error logs instead of panicking: one bad pipeline is a missing diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 39cc4cb2..8078cdcc 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -62,6 +62,8 @@ mod ui_chr_races; mod volumetric_fog; // GFX (volumetric light): shadow-mapped sun/moon light shafts. mod volumetric_light; +// GFX (froxel volumetrics): the same light solved in a 3-D grid, the row's top rung. +mod froxel; // MONKEY (p0 graphics programme): the programme's cvar bridges (skyDither, ...). mod monkey_gfx; // MONKEY (post): optional world-only HDR post-processing. diff --git a/crates/benilla-app/src/pipe_warm/menagerie.rs b/crates/benilla-app/src/pipe_warm/menagerie.rs index a415a58e..8c6badfd 100644 --- a/crates/benilla-app/src/pipe_warm/menagerie.rs +++ b/crates/benilla-app/src/pipe_warm/menagerie.rs @@ -851,6 +851,10 @@ mod tests { // 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. + // - GFX (froxel volumetrics) FroxelPipeline (`froxel`): the same contract for its + // composite, the one stage keyed on a view's format and MSAA. Its solve stage is a + // compute pipeline with no key at all, queued once in `RenderStartup`, so it compiles + // before any view exists and needs no warming. 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() diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 6f7433c4..2f4a7006 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -2362,8 +2362,14 @@ fn volumetric_fog_dropdown_is_localised_and_live() { .next() .unwrap(); for (locale, title, labels) in [ - ("enUS", "Volumetric Fog", ["Off", "Low", "High"]), - ("ruRU", "Объёмный туман", ["Выкл", "Низкое", "Высокое"]), + // GFX (froxel volumetrics): the fourth tier is named, not numbered, and so is its + // localisation. + ("enUS", "Volumetric Fog", ["Off", "Low", "High", "Volume"]), + ( + "ruRU", + "Объёмный туман", + ["Выкл", "Низкое", "Высокое", "Объём"], + ), ] { let mut s = audio_harness(); s.run(&format!("function GetLocale() return '{locale}' end")) @@ -2463,8 +2469,17 @@ fn ambient_occlusion_dropdown_is_localised_and_live() { .next() .unwrap(); for (locale, title, labels) in [ - ("enUS", "Ambient Occlusion", ["Off", "Low", "High"]), - ("ruRU", "Затенение окружения", ["Выкл", "Низкое", "Высокое"]), + // GFX (ssilvb): the fourth tier is named, not numbered, and so is its localisation. + ( + "enUS", + "Ambient Occlusion", + ["Off", "Low", "High", "Indirect Light"], + ), + ( + "ruRU", + "Затенение окружения", + ["Выкл", "Низкое", "Высокое", "Непрямой свет"], + ), ] { let mut s = audio_harness(); s.run(&format!("function GetLocale() return '{locale}' end")) @@ -2603,18 +2618,26 @@ fn water_quality_writes_numeric_tiers_with_localised_labels() { // GFX (interior rig): the ten interior-lighting rows: 42 -> 52. // GFX (auto-exposure): the tier and its blend: 52 -> 54. // GFX (sharpening): the RCAS tier: 54 -> 55. + // GFX (froxel volumetrics): the shadowed-air dial beside the haze tier: 55 -> 56. assert_eq!( s.eval::("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)") .unwrap(), - 55 + 56 ); assert_eq!( s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[4]") .unwrap(), "RowWaterQuality" ); + // GFX (froxel volumetrics): the shadowed-air dial sits straight under its own tier, at + // index 6, so every row after it moved one down — 23 -> 24. assert_eq!( - s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[23]") + s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[6]") + .unwrap(), + "RowVolumetricFogShadow" + ); + assert_eq!( + s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[24]") .unwrap(), "RowLavaGlow" ); @@ -3587,6 +3610,11 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() { "BENILLA_TOOLTIP_VOLUMETRIC_FOG", "AdvancedGraphicsRowVolumetricFog", ), + // GFX (froxel volumetrics): the Volume rung's own depth dial. + ( + "BENILLA_TOOLTIP_VOLUMETRIC_FOG_SHADOW", + "AdvancedGraphicsRowVolumetricFogShadow", + ), // MONKEY (sky): the sky tier row owns its translated tooltip. ( "BENILLA_TOOLTIP_SKY_QUALITY", @@ -3858,7 +3886,8 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() { // GFX (interior rig): the ten interior-lighting rows. 124 -> 134. // GFX (auto-exposure): the tier and its blend. 134 -> 136. // GFX (sharpening) / (field of view): one row each, on the two different pages. 136 -> 138. - assert_eq!(checked, 138, "every tipped row carries a live key"); + // GFX (froxel volumetrics): the shadowed-air dial beside the haze tier. 138 -> 139. + assert_eq!(checked, 139, "every tipped row carries a live key"); assert_eq!( untipped, vec![ @@ -3967,7 +3996,7 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() { // GFX (shadow rig): the five Advanced Graphics shadow rows. 119 -> 124. // GFX (interior rig): the ten interior-lighting rows. 124 -> 134. // GFX (auto-exposure): the tier and its blend. 134 -> 136. - assert_eq!(raised, 138, "every row but Auto Loot raises a description"); + assert_eq!(raised, 139, "every row but Auto Loot raises a description"); } /// 1.12's AdvancedOptionsCombatText box as saved-global rows: a click writes the global, never a diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs index 0c07d98b..5655e90f 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -127,6 +127,9 @@ pub(crate) struct VideoConfig { pub(crate) water_quality: u8, // MONKEY (volumetric fog): live camera raymarch tier: 0 Off, 1 Low, 2 High. pub(crate) volumetric_fog: u8, + /// GFX (froxel volumetrics): how hard tier 3 darkens haze that stands in shadow. 1 is the + /// shipped look, 0 leaves the haze exactly as High draws it, 2 is twice the shipped depth. + pub(crate) volumetric_fog_shadow: f32, // MONKEY (post): emissive HDR + bloom quality, 0 Off / 1 Low / 2 High. pub(crate) bloom: u8, // MONKEY (post): depth-occluded radial sun shafts. @@ -413,6 +416,7 @@ impl Default for VideoConfig { water_quality: 2, // MONKEY (volumetric fog): default to the inexpensive atmosphere. volumetric_fog: 1, + volumetric_fog_shadow: 1.0, // MONKEY (post): the shipped High graphics preset uses the full-resolution tier. bloom: 2, // MONKEY (post): part of the shipped High graphics preset. @@ -527,7 +531,10 @@ pub(crate) fn on_cvar( // GFX (planar water): 3 is Ultra, the planar mirror (`liquid::reflect::MIRROR_TIER`). "waterquality" => cfg.water_quality = v.clamp(0.0, 3.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, + "volumetricfog" => { + cfg.volumetric_fog = v.clamp(0.0, f32::from(crate::froxel::FROXEL_TIER)) as u8 + } + "volumetricfogshadow" => cfg.volumetric_fog_shadow = v.clamp(0.0, 2.0), // MONKEY (post): constrain UI/console writes to the supported bloom tiers. "bloom" => cfg.bloom = v.clamp(0.0, 2.0) as u8, // MONKEY (post): the shafts lane is binary. diff --git a/crates/benilla-app/src/volumetric_fog.rs b/crates/benilla-app/src/volumetric_fog.rs index 0be5b234..0d9ba801 100644 --- a/crates/benilla-app/src/volumetric_fog.rs +++ b/crates/benilla-app/src/volumetric_fog.rs @@ -41,21 +41,47 @@ use bevy::{ pub(crate) struct VolumetricFogPlugin; #[derive(Resource)] -struct FogOverride { +pub(crate) struct FogOverride { fog: Option, lamp: Option, shaft_gain: f32, } -const MAX_FOG_LAMPS: usize = 32; +impl FogOverride { + /// The live haze tier: the session override, else the setting. 3 is GFX (froxel volumetrics), + /// which asks because the volume IS this lane's top rung. + pub(crate) fn fog_tier(&self, video: &VideoConfig) -> u8 { + self.fog + .unwrap_or(video.volumetric_fog) + .min(crate::froxel::FROXEL_TIER) + } + + /// The live lamp tier. The froxel lane asks so a capture's `WOW_LAMPFOG` lights both the + /// analytic halo and the volume — an A/B between them is otherwise an A/B against nothing. + pub(crate) fn lamp_tier(&self, video: &VideoConfig) -> u8 { + self.lamp.unwrap_or(video.lamp_fog).min(2) + } +} + +pub(crate) const MAX_FOG_LAMPS: usize = 32; const LOW_FOG_LAMPS: usize = 16; const FOG_LAMP_RADIUS: f32 = 100.0; +/// How many lamps a tier admits. GFX (froxel volumetrics) reads it too, so the two lanes pick the +/// SAME fixtures and the rung only changes how their air is solved, never which lamps glow. +pub(crate) fn lamp_limit(tier: u8) -> usize { + if tier == 1 { + LOW_FOG_LAMPS + } else { + MAX_FOG_LAMPS + } +} + #[derive(Clone, Copy)] -struct FogLamps { - positions: [Vec4; MAX_FOG_LAMPS], - colors: [Vec4; MAX_FOG_LAMPS], - count: usize, +pub(crate) struct FogLamps { + pub(crate) positions: [Vec4; MAX_FOG_LAMPS], + pub(crate) colors: [Vec4; MAX_FOG_LAMPS], + pub(crate) count: usize, } impl Default for FogLamps { @@ -84,6 +110,9 @@ struct FogView { // MONKEY (lampfog): count + strength, followed by 32 resolved point-table rows. Colour already // includes intensity, flame flicker and (for synthesised fire) fireLightGain. lamp_meta: Vec4, + // GFX (froxel volumetrics): x = the volume's near bound (yd), y = its far bound, z = how dark + // fully shadowed air leaves the haze colour, w = 1 when a volume is bound and solved. + air: Vec4, lamp_positions: [Vec4; MAX_FOG_LAMPS], lamp_colors: [Vec4; MAX_FOG_LAMPS], } @@ -141,7 +170,11 @@ impl Plugin for VolumetricFogPlugin { ( prepare_pipelines.in_set(RenderSystems::Prepare), // MONKEY (reviewfix-a): one uniform per view, rewritten, not allocated per frame. - prepare_fog_uniforms.in_set(RenderSystems::PrepareResources), + prepare_fog_uniforms + .in_set(RenderSystems::PrepareResources) + // GFX (froxel volumetrics): the volume's own prepare decides whether a + // solve stands in it, and this reads that answer. + .after(crate::froxel::FroxelPrepare), ), ) .add_render_graph_node::>(Core3d, FogLabel) @@ -169,7 +202,7 @@ pub(crate) fn density(minute: f32, weather: f32, indoors: bool) -> f32 { // MONKEY (lampfog): independent near-field haze floor. Full daylight is exactly zero; weather // only strengthens an already-night-time halo, and interiors keep a restrained local version. -fn lamp_haze_strength(sun_height: f32, weather: f32, indoors: bool) -> f32 { +pub(crate) fn lamp_haze_strength(sun_height: f32, weather: f32, indoors: bool) -> f32 { let daylight = ((sun_height + 0.02) / 0.15).clamp(0.0, 1.0); (1.0 - daylight) * (0.8 + 1.2 * weather.clamp(0.0, 1.0)) * if indoors { 0.55 } else { 1.0 } } @@ -179,7 +212,7 @@ fn lamp_haze_strength(sun_height: f32, weather: f32, indoors: bool) -> f32 { // eligible: a street camera can see facade/room fixtures, and scene depth—not a camera-lane guess— // cuts the view segment. This pass is deliberately unshadowed, so the remaining leakage is the // documented tradeoff until point-light cube shadowing exists. -fn select_fog_lamps( +pub(crate) fn select_fog_lamps( points: &[ResolvedPointLight], camera_position: Vec3, frustum: Option<&Frustum>, @@ -262,10 +295,19 @@ fn update_fog( 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>, + // GFX (froxel volumetrics): the shadowed-air floor is that lane's number, applied here. + air_tune: Option>, ) { - let tier = override_value.fog.unwrap_or(video.volumetric_fog).min(2); + let tier = override_value.fog_tier(&video); 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); + // GFX (froxel volumetrics): at the top rung the lamps' air is solved in the volume, where the + // lobe is evaluated at every point of it, so this pass's analytic halo would be that same + // light a second time. + let lamp_tier = if tier == crate::froxel::FROXEL_TIER { + 0 + } else { + override_value.lamp_tier(&video) + }; let enabled = (tier != 0 || lamp_tier != 0) && cameras.iter().any(|(_, camera, _, _, _)| camera.is_active); for (entity, sun) in &suns { @@ -301,11 +343,7 @@ fn update_fog( point_lights.as_slice(), camera_transform.map_or(Vec3::ZERO, GlobalTransform::translation), frustum, - if lamp_tier == 1 { - LOW_FOG_LAMPS - } else { - MAX_FOG_LAMPS - }, + lamp_limit(lamp_tier), ) } else { FogLamps::default() @@ -342,6 +380,14 @@ fn update_fog( mf_fog2: Vec4::from_array(rows[2]), mf_fog3: Vec4::from_array(rows[3]), lamp_meta: Vec4::new(lamps.count as f32, lamp_strength, 0.0, sea), + air: crate::froxel::haze_terms( + tier == crate::froxel::FROXEL_TIER, + // The player's dial, unless a dev capture is sweeping the floor directly. + air_tune + .as_deref() + .and_then(|t| t.floor) + .unwrap_or_else(|| crate::froxel::shadow_floor(video.volumetric_fog_shadow)), + ), lamp_positions: lamps.positions, lamp_colors: lamps.colors, color_density: fog.extend( @@ -349,12 +395,12 @@ fn update_fog( crate::post::grading::rendered_minute(&clock, rendered.as_deref()), weather_amount, interior.0.is_some(), - ) * if tier == 0 { - 0.0 - } else if tier == 2 { - 1.6 - } else { - 1.0 + ) * match tier { + 0 => 0.0, + // GFX (froxel volumetrics): the top rung is High's haze PLUS the volume that + // tells it where the air stands in shadow, never a thinner haze. + 2 | 3 => 1.6, + _ => 1.0, }, ), sun_strength: Vec3::from_array(lighting.diffuse) @@ -376,13 +422,27 @@ struct FogShader(Handle); #[derive(Component)] struct FogUniform(UniformBuffer); +#[allow(clippy::type_complexity)] // one Bevy system's full input set fn prepare_fog_uniforms( mut commands: Commands, device: Res, queue: Res, - mut views: Query<(Entity, &FogView, Option<&mut FogUniform>)>, + mut views: Query<( + Entity, + &FogView, + Option<&mut FogUniform>, + Option<&crate::froxel::FroxelVolumes>, + )>, ) { - for (entity, fog, uniform) in &mut views { + for (entity, fog, uniform, air) in &mut views { + // GFX (froxel volumetrics): the main world asks for the volume; whether one exists and + // holds a completed solve is a render-world fact, and only this knows it. Declared after + // that lane's own prepare so `ready()` is this frame's answer, not last frame's. + let mut fog = *fog; + if !air.is_some_and(crate::froxel::FroxelVolumes::ready) { + fog.air.w = 0.0; + } + let fog = &fog; match uniform { Some(mut uniform) => { uniform.0.set(*fog); @@ -402,6 +462,11 @@ struct FogPipeline { shader: Handle, fullscreen: FullscreenShader, comparison: Sampler, + /// GFX (froxel volumetrics): the air volume's trilinear sampler, and the 1x1x1 stand-in bound + /// in its place when the rung is off. Its contents are never read — `air.w` is 0 then — but a + /// bind group still needs something of the right shape, and one pipeline beats two. + air_sampler: Sampler, + air_stub: TextureView, } #[derive(Component)] struct ViewFogPipeline(CachedRenderPipelineId); @@ -431,6 +496,11 @@ fn init_pipeline( texture_2d_array(TextureSampleType::Depth), sampler(SamplerBindingType::Comparison), uniform_buffer::(false), + // GFX (froxel volumetrics): the solved air in front of this view. Always + // bound — a 1x1x1 stand-in when the rung is off — so this pass keeps one + // pipeline instead of two, and `air.w` alone decides whether it is read. + texture_3d(TextureSampleType::Float { filterable: true }), + sampler(SamplerBindingType::Filtering), ), ), ) @@ -445,6 +515,16 @@ fn init_pipeline( mag_filter: FilterMode::Linear, ..default() }), + air_sampler: device.create_sampler(&SamplerDescriptor { + label: Some("gamma_fog_air_sampler"), + min_filter: FilterMode::Linear, + mag_filter: FilterMode::Linear, + address_mode_u: AddressMode::ClampToEdge, + address_mode_v: AddressMode::ClampToEdge, + address_mode_w: AddressMode::ClampToEdge, + ..default() + }), + air_stub: crate::froxel::stub_volume(&device), }); } impl SpecializedRenderPipeline for FogPipeline { @@ -520,12 +600,13 @@ impl ViewNode for FogNode { &'static ViewShadowBindings, &'static FogUniform, &'static ViewFogPipeline, + Option<&'static crate::froxel::FroxelVolumes>, ); fn run<'w>( &self, _graph: &mut RenderGraphContext, context: &mut RenderContext<'w>, - (target, depth, view_offset, light_offset, shadows, fog, id): QueryItem< + (target, depth, view_offset, light_offset, shadows, fog, id, air): QueryItem< 'w, '_, Self::ViewQuery, @@ -565,6 +646,8 @@ impl ViewNode for FogNode { &shadows.directional_light_depth_texture_view, &settings.comparison, fog.0.binding().unwrap(), + air.map_or(&settings.air_stub, |air| air.solved()), + &settings.air_sampler, )), ); let diagnostics = context.diagnostic_recorder(); @@ -614,10 +697,31 @@ struct Fog { mf_fog1: vec4, mf_fog2: vec4, mf_fog3: vec4, // MONKEY (lampfog): x=count, y=night/weather strength; packed point-table rows follow. lamp_meta: vec4, + // GFX (froxel volumetrics): x=near, y=far, z=the shadowed-air floor, w=1 when a solve is bound. + air: vec4, lamp_positions: array, 32>, lamp_colors: array, 32>, } @group(0) @binding(6) var fog: Fog; +@group(0) @binding(7) var air: texture_3d; +@group(0) @binding(8) var air_sampler: sampler; + +// GFX (froxel volumetrics): how much of the air in front of this pixel the sun actually reaches, +// read out of the solved volume at the pixel's own distance. 1 where nothing shades it. +// +// This is the whole reason that lane exists. The haze below is the reference client's: a zone +// colour mixed in by distance, the SAME colour whether the air it tints stands in open sun or in +// a mountain's shadow. Neither screen pass can correct it — a shaft march only ever adds light — +// and on an Elwynn overlook at noon 74% of the frame is air the sun does not fully reach. +// +// Fully lit air multiplies by exactly 1, so a clear daylight exterior is left bit for bit as it is +// drawn today and only the shadowed air darkens. Only called where `fog.air.w` says a solve is +// bound; with the rung off this pass does not touch the haze colour at all. +fn air_lit(uv: vec2, distance: f32) -> f32 { + // The volume's own quadratic slice law, which `froxel.wgsl` stores against. + let w = sqrt(clamp((distance - fog.air.x) / max(fog.air.y - fog.air.x, 1e-4), 0.0, 1.0)); + return clamp(textureSampleLevel(air, air_sampler, vec3(uv, w), 0.0).a, 0.0, 1.0); +} // Integral of smoothstep(10, 30, distance): zero in the first ten yards, // continuous density and derivative at both ends, bounded at 150 yd. @@ -732,6 +836,25 @@ fn fragment(@builtin(position) pixel: vec4) -> @location(0) vec4 { haze_rgb = fog_hook::fog_modern_colour(fog.color_density.rgb, ray, length(delta), fog.mf_fog1, fog.mf_fog2, fog.mf_fog3); } + // GFX (froxel volumetrics): the air this pixel looks through, darkened where it is in + // shadow. Applied to the haze COLOUR and not to the opacity, so the amount of haze — the + // reference's own distance law — is untouched and only its colour learns about the sun. + // + // Written as `1 - (1-floor)·(1-lit)` and NOT as the `mix(floor, 1, lit)` it is algebraically + // equal to: `mix` compiles to a fused multiply-add, which lands a last ulp below 1.0 at + // lit = 1 instead of exactly on it. That is enough to move a frame — with the rung OFF it + // moved five of nine tier 0-2 control pairs, which is how it was found. Hence the gate as + // well: off, this pass does not touch the haze at all. + // + // This is the one thing allowed past the rule stated three lines below, that a darker zone + // colour may change hue and never luminance — because shadowed air really IS dimmer. The + // headroom step enforcing that rule then gives back what luminance it can, which is a + // useful accident: it bounds how far this term can pull a frame down, whatever floor it is + // given. Measured on an Elwynn overlook at noon, a floor of 0.85 lands as -2.6% of the + // frame's mean, not the -15% the multiply alone would suggest. + if (fog.air.w > 0.5) { + haze_rgb *= 1.0 - (1.0 - fog.air.z) * (1.0 - air_lit(uv, distance)); + } haze = mix(source.rgb, haze_rgb, opacity); // A darker zone colour may change hue, never the pixel's luminance. Add // neutral headroom proportionally so saturated channels cannot clip dark. diff --git a/crates/benilla-app/src/volumetric_light.rs b/crates/benilla-app/src/volumetric_light.rs index 95720808..b09a6d94 100644 --- a/crates/benilla-app/src/volumetric_light.rs +++ b/crates/benilla-app/src/volumetric_light.rs @@ -84,7 +84,7 @@ impl VolLightOverride { /// 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; +pub(crate) 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. @@ -196,7 +196,9 @@ impl Plugin for VolumetricLightPlugin { /// 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( +/// GFX (froxel volumetrics) asks too: it needs to know whether a body casts at all, and this +/// function IS the hand-over policy, so asking it is how the two lanes cannot disagree. +pub(crate) fn light_body( lighting: &WowLighting, handover: &ShadowHandover, moon_gain: f32, @@ -224,7 +226,7 @@ fn light_body( } /// 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 { +pub(crate) 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 { @@ -355,7 +357,7 @@ struct VolLightTextures { struct VolLightUniform(UniformBuffer); #[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)] -struct VolLightLabel; +pub(crate) struct VolLightLabel; fn depth_entry(multisampled: bool) -> BindGroupLayoutEntryBuilder { if multisampled { diff --git a/docs/GRAPHICS.md b/docs/GRAPHICS.md index 2217a595..2f11ad44 100644 --- a/docs/GRAPHICS.md +++ b/docs/GRAPHICS.md @@ -60,6 +60,18 @@ the same binary and once more over six runs: | `stormwind-canal-near` | **no** — bimodal: 7 of 15 pairs identical, the rest differ on **0.01%** of pixels by up to **96**/255 | | `inn-noon`, `volfog-inn` | **no** — usually the bottom bit, but one pair came back **37.8%** of pixels at up to **199** | +Screened again for this lane, at `volumetricFog` 2 and 3 and at `volumetricLight` 3: `volfog-sun` +and `volfog-inn` both reproduce **byte-identically**, and `volfog-night` to the bottom bit (max 1, +0.0% of pixels past 2) — so `volfog-inn` sits in the first row at these settings and in the third at +its own default, which is the point of the next paragraph. + +And in the other direction: **`water-lake` leaves the first row once shadows are forced on.** With +`worldShadows=1;characterShadows=1` it differs from a build of ITSELF by up to **88/255** on +**0.033%** of pixels — the water lane's bisection depth-march picking a different step, the same +signature the AO blur's reciprocal once produced there. Proved by diffing two builds whose rendering +path at that tier is identical. So on this scenario, with shadows on, read extent and not amplitude: +0.03% is its own noise, and 88 means nothing. + **And the table is per SETTING, not per scenario.** `fence-shadowline-night` sits in the first row only at the default `ambientOcclusion` 0; at tier 1 it differs from itself by the bottom bit, which cost half a control before it was noticed. Screen the scenario at the setting you are about to @@ -286,13 +298,106 @@ Three lessons, all of them about believing a lever moved: it is diffing the lane-off render against a `git stash`ed build, not against an older capture. - **Look at the thing itself before reasoning about it.** The projection was blamed for a session and was right all along in the case that mattered; the dump settled it in one run. +- **`mix(a, b, 1.0)` is not `b`.** It compiles to a fused multiply-add, `a + s·(b-a)`, which at + `s = 1` lands a last ulp below `b` for any `a != b` — `0.85 + (1.0-0.85)` is `0.99999998`. A + no-op term written that way is not a no-op: multiplying the haze colour by + `mix(floor, 1.0, lit)` with the froxel rung **switched off** moved **five of nine** tier 0-2 + control pairs. Written as `1 - (1-floor)·(1-lit)` it is exactly 1 at `lit = 1`, and all nine + come back byte-identical. The same trap as `a/b` against `a·(1/b)` in the AO blur, found by the + same control — an identity that holds in algebra has to be written in the form that holds in + floats, and the only thing that can tell you which form that is, is the diff. -### 4. Froxel volumetrics +### 4. Froxel volumetrics — **built, and not the half that was expected** -We have near-field volumetric fog and light shafts as screen passes. The modern form solves the DBC -fog into a participating medium in a 3D froxel volume with reprojected history, so fog lights -correctly, moves with the camera without swimming, and costs a fixed compute dispatch instead of -scaling with overdraw. +`crates/benilla-app/src/froxel.rs`, cvar `volumetricFog` **3 (Volume)**, the Ultra rung of Lighting +Quality, Volumetric Fog on the Advanced Graphics page. Tiers 0-2 are untouched — the control at +the end of this section says exactly how untouched, because "byte for byte" turns out to need a +qualifier. + +A view-space grid of froxels (160x90x64, a fixed BUDGET rather than a fixed divisor, so 1080p and +4K solve the same count for the same cost and the same 22 MB) is marched **one screen column per +compute thread**, front to back, with a reprojected eight-frame history. One pass injects and +integrates, which is unusual and deliberate: a froxel past the last shadow cascade has no evidence +either way, and the honest answer is the mean of what the rest of its column could see — a thread +that owns the whole column can carry that, a froxel-per-invocation dispatch cannot. + +**What it was built for, and what it actually delivered, are different halves, and the measurement +is the whole story.** + +The plan was to move the sun's shafts into the volume. That is a regression and the sweep says so. +Against `volumetric_light`'s per-pixel march on `volfog-sun`, the volume's shafts come out in hard +horizontal steps where the march draws rays, and **raising the grid does not fix it**: 160x90, +320x180 and 534x300 band identically, because one froxel column hands the same value to about a +hundred pixels and a canopy's shadow is finer than any grid worth paying for. Spreading each +column's 64 samples across the froxel's own screen footprint — an R2 sequence down the column +instead of one ray for the whole of it — turns the hard steps into a soft gradient, and is worth +knowing for any volume, but the march still wins on detail. **So the shafts stayed on the march.** + +What the volume *is* better at is the thing no screen pass can do at all. A shaft march only ever +ADDS light; nothing tells the reference's distance haze that the air it is tinting stands in a +mountain's shadow, so a shaded valley and an open field get the same bright zone colour. The volume +carries, per pixel, **the share of the air in front of it that the sun reaches**, weighted by each +slice's own contribution to what the eye sees through it, and `volumetric_fog`'s haze multiplies its +colour by `1 - (1-0.85)·(1-lit)`. On an Elwynn overlook at noon, **74% of the frame is air the sun +does not fully reach**. + +The safety argument is the floor and nothing else: fully lit air multiplies by **exactly 1.0** (see +the `mix` trap above — getting that exactness is one line and it is not the obvious one), so at a +floor of 1.0 the lane renders that overlook **byte-identical to the tier below it** while still +running every pass. Everything it changes is traceable to one number. Measured against High on that +overlook: floor 0.85 moves 57.5% of the frame by up to 17/255, floor 0.72 moves 64.7% by up to 31. +Physics argues for far lower — sky-only illuminance is about a quarter of sun-plus-sky — but the haze +colour here is an artist's value that already stands for average air, and this is the largest look +change of any graphics lane so far, so the shipped floor is the conservative half. + +Because it IS the largest, the floor is a player row and not a constant: **Shadowed Air** +(`volumetricFogShadow`, 0..2, 1 = the shipped 0.85), greyed unless the tier is on Volume, and 0 is +the byte-identical case above. Every other look-changing tier on that page has a strength dial +beside it (`volumetricLightStrength`, `autoExposureStrength`); this one needed it most. + +The lamps come along for free, because once a volume exists they belong in it: `volumetric_fog`'s +analytic halo is a line integral of `1/r²` with the phase lobe evaluated **once per ray** at its +closest approach and no density in it at all, while a froxel evaluates the lobe at every point of +the air, attenuates the halo by the medium it crosses, and strengthens it when the medium does. At +tier 3 that pass's halos are retired into this solve, and `lampFog` still chooses which lamps and +how many. The gain is derived, not picked: `0.9 / density(clear night)`, so the two lanes match on a +clear night and only a foggy one differs. Measured at the Goldshire inn, isolated by rendering +`lampFog` 0 against 2 on each: the volume's lamps are **1.30x** the analytic ones in mean and reach +further, and the bases are byte-identical, which is what makes the comparison a comparison. + +**The control, and what it says about "byte-identical".** Nine tier 0-2 pairs against the previous +build. Five come back byte-identical, and the other four differ by **one or two PIXELS** out of +1,440,000, by 1-2 of 255 — `overlook-noon` tier 1 moves exactly one pixel. (`water-lake` at tier 1 +lands in either column from run to run, because it differs from a build of ITSELF by more than +that; see above.) + +The dial's own end is cleaner still, and it is the one a player can reach: **Shadowed Air 0 against +High is byte-identical over all 1,440,000 pixels**, measured through the CVar rather than a dev +knob, with the solve, the history and the composite all still running. + +The split is the finding: **every pair at tier 0 is identical, and tier 0 is the tier that switches +the fog pass off entirely.** Where the pass runs, a single pixel sitting on a quantisation boundary +falls the other way. The cause is not arithmetic but **codegen** — the fog shader gained `air_lit` +and a branch, and a changed shader gets a changed instruction schedule whether or not the new branch +is ever taken. Chasing it with a `run_if` on the lane's main-world system (the documented remedy for +a lane-off frame moving, and worth keeping anyway) changed nothing, which is itself evidence. One +pixel at 1/255 is an order of magnitude below the deband dither this pipeline adds on purpose. + +**Cost: under the noise of both instruments.** The frame-time probe (900 frames, vsync off) on +`overlook-noon`: High p50 **2.03 / 2.06 / 2.16 ms** across three runs against Volume **2.09 / 2.13 / +2.13** — the rung lands inside High's own run-to-run spread, and in one pair came out faster. On the +lamp-heavy `volfog-night`, where the lamp loop actually runs, High **2.21 / 2.21** against Volume +**2.19 / 2.39** — one of those two is 0.18 above both High runs, which is the one result here that +is not plainly noise and is worth re-measuring if the lamp path ever grows. The GPU pass timer is +the finer instrument: `gpu_other` **0.75 -> 0.76 ms**, +whole-frame GPU unchanged. One compute dispatch of 921,600 froxels at one shadow sample each, on a +GPU this client already leaves about two thirds idle. The first probe pair alone read as +0.10 ms, +which is exactly why a single pair is not a measurement. + +The remaining froxel idea worth having is the one this did not touch: the haze's *opacity*, and +transparent surfaces reading the volume at their own depth. The transmittance is already solved and +stored, and deliberately not applied — the distance haze is that attenuation in the reference's own +artistic form, and doing it twice fogs every frame twice. ### 5. Screen-space indirect light (SSILVB) — **built**