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:
+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