Froxel volumetrics: the haze stops painting shadowed air the colour of sunlit air
`volumetricFog` gains tier 3, "Volume", with a "Shadowed Air" dial beside it; the Ultra rung of Lighting Quality takes it. A view-space 160x90x64 froxel grid — a fixed BUDGET, so every resolution costs the same — marched one compute thread per screen COLUMN, front to back, with a reprojected eight-frame history. Inject and integrate in one pass, because a froxel past the last shadow cascade has no evidence either way and the honest answer is the mean of what the rest of its column could see; a thread that owns the column can carry that and a froxel-per-invocation dispatch cannot. It publishes two things: - **the lit share** — how much of the air in front of each pixel the sun actually reaches, weighted by each slice's own contribution to what the eye sees through it. `volumetric_fog`'s haze multiplies its colour by it. The haze below this rung is the reference's: a zone colour mixed in by distance, the SAME colour whether the air it tints stands in open sun or a mountain's shadow, and no screen pass can tell it otherwise because a shaft march only ever ADDS light. On an Elwynn overlook at noon, 74% of the frame is air the sun does not fully reach. - **the lamps' in-scatter**, free once a volume exists: the phase lobe evaluated at every point of the air rather than once per ray at its closest approach, attenuated by the medium it crosses and strengthened when the medium is. `lampFog`'s analytic halos retire into this solve at tier 3, and that row still chooses which lamps and how many. The gain is derived, `0.9 / density(clear night)`, so the two lanes match on a clear night. At the Goldshire inn, isolating each by rendering lampFog 0 against 2 on both (the bases are byte-identical, which is what makes it a comparison): the volume's are 1.30x and wider. The sun's SHAFTS deliberately stay on `volumetric_light`'s per-pixel march, and that is a measurement. A volume bands in hard horizontal steps at 160x90, 320x180 and 534x300 alike, because one froxel column hands the same value to about a hundred pixels and a canopy's shadow is finer than any grid worth paying for. Spreading each column's samples across its own screen footprint (R2 down the column) turns the steps into a gradient and is worth knowing for any volume, but the march still wins on detail. docs/GRAPHICS.md has the sweep. Safety is one number: fully lit air multiplies by EXACTLY 1, so at Shadowed Air 0 the lane renders byte-identical to High over all 1,440,000 pixels with every pass still running. Getting that exact needed `1 - (1-floor)*(1-lit)` and not the `mix(floor, 1, lit)` it equals — `mix` is a fused multiply-add and lands a last ulp below 1.0, which moved five of nine tier 0-2 control pairs with the rung switched OFF. The shipped floor, 0.85, is deliberately half the depth of the first one tried, and the dial reaches twice it. Control: nine tier 0-2 pairs against the previous build, five byte-identical and four differing by one or two PIXELS out of 1,440,000 at 1-2 of 255 — every tier-0 pair identical, and tier 0 is the tier that switches the fog pass off, so the remainder is the fog shader's instruction schedule changing because the shader did. Cost is under the noise of both instruments: the frame probe puts Volume inside High's own run-to-run spread, and the GPU pass timer puts `gpu_other` at 0.75 -> 0.76 ms. Also recorded: `water-lake` leaves the deterministic row once shadows are forced on — it differs from a build of ITSELF by up to 88/255 on 0.03% of pixels. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -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" },
|
||||
})
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<!-- GFX (froxel volumetrics): how deep the Volume rung takes shadowed haze. -->
|
||||
<Frame name="$parentRowVolumetricFogShadow" inherits="OptionsSliderRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowVolumetricFog" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowVolumetricFog" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "volumetricFogShadow",
|
||||
BENILLA_ADVGFX.volumetricFogShadow,
|
||||
"BENILLA_TOOLTIP_VOLUMETRIC_FOG_SHADOW")
|
||||
OptionsSliderRow_Setup(self, 0, 2, 0.25)
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<!-- MONKEY (sky): the sky tier, Classic / Enhanced / High. -->
|
||||
<Frame name="$parentRowSkyQuality" inherits="OptionsDropdownRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowVolumetricFog" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowVolumetricFog" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowVolumetricFogShadow" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowVolumetricFogShadow" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "skyQuality", BENILLA_ADVGFX.skyQuality,
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<f32>, // near (yd), far (yd), extinction per yard, unused
|
||||
lamps: vec4<f32>, // count, night/weather strength, gain, history blend (this frame's weight)
|
||||
flags: vec4<f32>, // 1 when a body casts, debug view, unused, unused
|
||||
grid: vec4<f32>, // froxel counts, 1 when the history holds a usable frame
|
||||
jitter: vec4<f32>, // the sample offset inside the froxel, unused
|
||||
prev_eye: vec4<f32>,
|
||||
prev_clip_from_world: mat4x4<f32>,
|
||||
lamp_positions: array<vec4<f32>, 32>,
|
||||
lamp_colors: array<vec4<f32>, 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<f32>, eye: vec3<f32>, uv: vec2<f32>) -> vec3<f32> {
|
||||
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<f32>(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<f32>, eye: vec3<f32>, ndc_xy: vec2<f32>, z: f32, cap: f32) -> f32 {
|
||||
if (z <= 0.0) {
|
||||
return cap;
|
||||
}
|
||||
let q = world_from_clip * vec4<f32>(ndc_xy, z, 1.0);
|
||||
return min(length(q.xyz / q.w - eye), cap);
|
||||
}
|
||||
|
||||
// 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<uniform> fx: Froxel;
|
||||
@group(0) @binding(1) var<uniform> view: View;
|
||||
@group(0) @binding(2) var<uniform> lights: Lights;
|
||||
@group(0) @binding(3) var shadows: texture_depth_2d_array;
|
||||
@group(0) @binding(4) var shadow_sampler: sampler_comparison;
|
||||
@group(0) @binding(5) var history_in: texture_3d<f32>;
|
||||
@group(0) @binding(6) var history_sampler: sampler;
|
||||
@group(0) @binding(7) var history_out: texture_storage_3d<rgba16float, write>;
|
||||
@group(0) @binding(8) var solved: texture_storage_3d<rgba16float, write>;
|
||||
|
||||
// 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<f32> = vec2<f32>(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>) -> 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<u32>) {
|
||||
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<f32>(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<u32, COLUMN_LAMPS>;
|
||||
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<f32>(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<f32>(0.0), vec2<f32>(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<f32>(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<f32>(0.0))
|
||||
* (edge * lobe * fx.lamps.y * fx.lamps.z
|
||||
/ (LAMP_CORE * LAMP_CORE + reach * reach));
|
||||
}
|
||||
radiance = min(radiance, vec3<f32>(MAX_RADIANCE));
|
||||
|
||||
var current = vec4<f32>(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<f32>(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<f32>(0.0)) && all(uv < vec2<f32>(1.0))
|
||||
&& was > near && was < far) {
|
||||
let old = textureSampleLevel(history_in, history_sampler,
|
||||
vec3<f32>(uv, slice_of(near, far, was)), 0.0);
|
||||
current = mix(old, current, blend);
|
||||
}
|
||||
}
|
||||
}
|
||||
textureStore(history_out, vec3<i32>(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>(i32(id.x), i32(id.y), i32(k)),
|
||||
vec4<f32>(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<uniform> fx: Froxel;
|
||||
@group(0) @binding(1) var scene: texture_2d<f32>;
|
||||
#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<f32>;
|
||||
@group(0) @binding(4) var volume_sampler: sampler;
|
||||
@group(0) @binding(5) var<uniform> view: View;
|
||||
|
||||
@fragment
|
||||
fn fs_composite(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
|
||||
let p = vec2<i32>(in.position.xy);
|
||||
let source = textureLoad(scene, p, 0);
|
||||
let dims = vec2<i32>(textureDimensions(depth));
|
||||
let z = textureLoad(depth, min(p, dims - vec2<i32>(1)), 0);
|
||||
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(dims);
|
||||
let ndc = uv * vec2(2.0, -2.0) + vec2(-1.0, 1.0);
|
||||
// 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<f32>(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<f32>(vec3<f32>(read.a), source.a);
|
||||
}
|
||||
let light = max(read.rgb, vec3<f32>(0.0));
|
||||
if (fx.flags.y > 0.5) {
|
||||
return vec4<f32>(vec3<f32>(1.0) - exp(-light), source.a);
|
||||
}
|
||||
// The fog pass's soft screen curve: bright pixels saturate gently instead of clipping. 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<f32>(0.0), vec3<f32>(1.0) - source.rgb)
|
||||
* (vec3<f32>(1.0) - exp(-light));
|
||||
return vec4<f32>(lifted, source.a);
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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::<usize>("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)")
|
||||
.unwrap(),
|
||||
55
|
||||
56
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("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::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[23]")
|
||||
s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[6]")
|
||||
.unwrap(),
|
||||
"RowVolumetricFogShadow"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -41,21 +41,47 @@ use bevy::{
|
||||
|
||||
pub(crate) struct VolumetricFogPlugin;
|
||||
#[derive(Resource)]
|
||||
struct FogOverride {
|
||||
pub(crate) struct FogOverride {
|
||||
fog: Option<u8>,
|
||||
lamp: Option<u8>,
|
||||
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::<ViewNodeRunner<FogNode>>(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<Res<crate::volumetric_light::VolLightOverride>>,
|
||||
// GFX (froxel volumetrics): the shadowed-air floor is that lane's number, applied here.
|
||||
air_tune: Option<Res<crate::froxel::FroxelTune>>,
|
||||
) {
|
||||
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<Shader>);
|
||||
#[derive(Component)]
|
||||
struct FogUniform(UniformBuffer<FogView>);
|
||||
|
||||
#[allow(clippy::type_complexity)] // one Bevy system's full input set
|
||||
fn prepare_fog_uniforms(
|
||||
mut commands: Commands,
|
||||
device: Res<RenderDevice>,
|
||||
queue: Res<RenderQueue>,
|
||||
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<Shader>,
|
||||
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::<FogView>(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<f32>, mf_fog2: vec4<f32>, mf_fog3: vec4<f32>,
|
||||
// MONKEY (lampfog): x=count, y=night/weather strength; packed point-table rows follow.
|
||||
lamp_meta: vec4<f32>,
|
||||
// GFX (froxel volumetrics): x=near, y=far, z=the shadowed-air floor, w=1 when a solve is bound.
|
||||
air: vec4<f32>,
|
||||
lamp_positions: array<vec4<f32>, 32>,
|
||||
lamp_colors: array<vec4<f32>, 32>,
|
||||
}
|
||||
@group(0) @binding(6) var<uniform> fog: Fog;
|
||||
@group(0) @binding(7) var air: texture_3d<f32>;
|
||||
@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<f32>, 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<f32>(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<f32>) -> @location(0) vec4<f32> {
|
||||
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.
|
||||
|
||||
@@ -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<VolLightView>);
|
||||
|
||||
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
|
||||
struct VolLightLabel;
|
||||
pub(crate) struct VolLightLabel;
|
||||
|
||||
fn depth_entry(multisampled: bool) -> BindGroupLayoutEntryBuilder {
|
||||
if multisampled {
|
||||
|
||||
+110
-5
@@ -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**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user