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