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**