SSILVB: the AO lane stops only darkening and starts bouncing light
`ambientOcclusion` 3 (Indirect Light), the Ultra rung. Tier 3 of the AO row rather than a row of its own, because it is one trace: the bitmask that measures occlusion properly is what produces the bounce, so a player choosing between them would be choosing between two halves of the same thing. Classic SSAO sums a cosine term per tap, so two occluders covering the same direction count twice and a cluttered corner over-darkens. A bitmask counts DIRECTIONS: each slice owns a 32-bit word of angular sectors, a tap sets the run it spans, overlap is a no-op. And the bits a tap sets that were NOT already set are exactly the share of the hemisphere it is the nearest thing in — i.e. the fraction of the receiver's light it alone is responsible for. Its own pixel colour, weighted by that, IS the one-bounce indirect term. No second trace, no probes, no extra pass. ## The lane was already shaped for it - `STAGE_AO` already bound the resolved scene colour, for the bright-pixel protection mask. That buffer is exactly what a screen-space gather reads. - The apply needed no new pass and no dual-source blending: visibility in `src.a`, bounce in `src.rgb`, blend `One, SrcAlpha` = `bounce + dst*vis`. - Only the half-res stages grew, by one attachment each, and the uniform's two spare floats carried the strength and thickness, so its layout never moved. ## The one part that is ours and not the paper's **The bounce is scaled BY THE OCCLUSION.** A physically-measured bounce is wrong here: 1.12's lighting already carries an ambient term standing in for indirect light, so adding a measured one double-counts it. Unscaled it lifted a daylight exterior's median from 85.7 to 101.3 and washed the frame flat. Scaled by `occ`, the lane puts back only the share of the ambient the occlusion just took away, in the occluder's colour. An open field gets nothing, because nothing was removed there; a corner gets the colour of the wall shading it. Redridge overlook noon median 85.7 -> 86.7 Goldshire inn noon median 26.7 -> 29.0 Neutral in daylight, +8.6% in the shaded room — the same shape as the auto-exposure lane's floor of 1.0, for the same reason. The gather runs in LINEAR (`c*c` in, `sqrt` out): the scene is stored in gamma, and 0.5 gamma is 0.22 of the light, not half of it. ## Cost Frame time is unchanged (inn 2.22 -> 2.21 ms over 600 frames; overlook 2.25 -> 2.11). That is the frame being CPU-bound, not the lane being free. The GPU meter is the honest instrument: whole-frame GPU 1.68 -> 1.81 ms, and the bucket these passes land in 0.40 -> 0.54. So **+0.14 ms of GPU over High and nothing on the main thread** — which is why the Options text says "a fraction of a millisecond" rather than what I first wrote before reading the meter. ## Two bugs, both found in the intermediate rather than the frame - **Screen +y is view -y.** `view_at` flips clip y, so a slice direction built as `vec3(omega, 0.0)` measures its taps against a mirrored normal. One slice of two was right and the other inverted, averaging to A FLAT FLOOR READING 50% OCCLUDED. `WOW_AO_DEBUG=4` showed it in one run; the composited frame never would have. - **`a / b` and `a * (1.0 / b)` are not the same number.** Factoring the blur's divisor into one reciprocal — a tidy-up, not a change — moved `water-lake` at tier **1** by up to 53/255 on 0.021% of its pixels, because the AO is multiplied into the colour the water lane's depth-march then refines by bisection, and one ulp picks a different step. Restored to two divides. ## Control Tiers 0-2 byte-identical to the build before this, on three deterministic scenarios at three tiers each, nine of nine. `fence-shadowline-night` had to be dropped mid-control, and it sharpens the determinism table added last commit: it screens as deterministic at the DEFAULT tier and is not at tier 1. Screen a scenario at the setting you are about to measure it at, not at its default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
+89
-3
@@ -60,6 +60,11 @@ 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** |
|
||||
|
||||
**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
|
||||
measure it at.
|
||||
|
||||
So a byte-identity claim can only rest on the first row. The second row is the dangerous one: the
|
||||
canal's own flicker reaches a channel delta of **96**, which is exactly the amplitude a real
|
||||
finding there was once read at — amplitude alone says nothing, only extent does, and the canal's
|
||||
@@ -289,10 +294,91 @@ fog into a participating medium in a 3D froxel volume with reprojected history,
|
||||
correctly, moves with the camera without swimming, and costs a fixed compute dispatch instead of
|
||||
scaling with overdraw.
|
||||
|
||||
### 5. Screen-space indirect light (SSILVB)
|
||||
### 5. Screen-space indirect light (SSILVB) — **built**
|
||||
|
||||
An upgrade from classic SSAO to a visibility-bitmask trace that carries indirect *colour*, not only
|
||||
occlusion. GPU-side, and it is most of what makes a modern frame look lit rather than painted.
|
||||
`crates/benilla-app/src/ssao.rs`, cvar `ambientOcclusion` **3 (Indirect Light)**, the Ultra rung,
|
||||
Ambient Occlusion on the Advanced Graphics page. Tiers 1 and 2 are untouched, down to the pipeline
|
||||
key.
|
||||
|
||||
It is tier 3 of the AO row rather than a row of its own because it is one trace. Classic SSAO sums
|
||||
a cosine term per tap, so two occluders covering the same direction are counted twice and a
|
||||
cluttered corner over-darkens. A bitmask counts DIRECTIONS instead: each slice through the view ray
|
||||
owns a 32-bit word whose bits are angular sectors of the receiver's hemisphere, a tap sets the run
|
||||
it spans, and overlap is a no-op because the bit was already set. Occlusion is the population
|
||||
count.
|
||||
|
||||
**And that is what makes the colour half nearly free.** The bits a tap sets that were *not* already
|
||||
set are exactly the share of the hemisphere it is the nearest thing in — the fraction of the
|
||||
receiver's incoming light that this tap, and nothing else, is responsible for. So the tap's own
|
||||
pixel colour, weighted by that fraction, is the one-bounce indirect term. No second trace, no
|
||||
probes, no extra pass. (Therrien et al., 2023.)
|
||||
|
||||
**The lane was already shaped for it**, which is why this landed in one sitting:
|
||||
|
||||
- `STAGE_AO` already bound the resolved opaque scene colour, for the bright-pixel protection mask.
|
||||
That buffer is exactly what a screen-space gather reads; the plumbing existed for an unrelated
|
||||
reason.
|
||||
- The apply needed no new pass and no dual-source blending. Put visibility in `src.a` and the
|
||||
bounce in `src.rgb` and blend `One, SrcAlpha`: `bounce + dst · vis`, one draw into the same
|
||||
attachment the multiply used.
|
||||
- Only the half-res stages grew, by one `Rgba16Float` attachment each (2.9 MB a side at 1600x900),
|
||||
and the two spare floats on the existing uniform carried the strength and the thickness, so the
|
||||
uniform layout never moved.
|
||||
|
||||
**The one thing that is ours and not the paper's: the bounce is scaled BY THE OCCLUSION.** A
|
||||
physically-measured bounce is wrong on this renderer, because 1.12's lighting already carries an
|
||||
ambient term that stands in for indirect light everywhere — adding a measured one on top
|
||||
double-counts it. Unscaled it lifted a daylight exterior's median from **85.7 to 101.3** and washed
|
||||
the whole frame flat. Scaled by `occ`, the lane puts back only the share of the ambient the
|
||||
occlusion just took away, in the occluder's own colour: an open field gets nothing, because nothing
|
||||
was removed there, and a corner gets the colour of the wall that is actually shading it. Measured
|
||||
after:
|
||||
|
||||
| scene | median, High | median, Indirect Light |
|
||||
|---|---|---|
|
||||
| Redridge overlook, noon | 85.7 | **86.7** |
|
||||
| Goldshire inn, noon | 26.7 | **29.0** |
|
||||
|
||||
Neutral in daylight, +8.6% in the shaded room — the same shape as the auto-exposure lane's floor of
|
||||
1.0, and arrived at for the same reason.
|
||||
|
||||
**The gather runs in LINEAR.** The scene is stored in the reference's gamma convention, and adding
|
||||
gamma values overstates every mid tone: 0.5 gamma is 0.22 of the light, not half of it. `c*c` in
|
||||
and `sqrt` out is the gamma-2.0 approximation — cheap, monotone, exact at both ends.
|
||||
|
||||
**Cost.** Frame time is unchanged: Goldshire inn 2.22 → 2.21 ms over 600 frames, Redridge overlook
|
||||
2.25 → 2.11. That is not the lane being free, it is the frame being CPU-bound; the GPU meter
|
||||
(`/console fpsJournal 1`, the `gpu_other` column, which is where these passes land) is the honest
|
||||
instrument:
|
||||
|
||||
| | whole frame GPU | the AO passes |
|
||||
|---|---|---|
|
||||
| Off | 1.60 ms | 0.29 ms (everything else in the bucket) |
|
||||
| High | 1.68 ms | 0.40 ms → **+0.11** |
|
||||
| Indirect Light | 1.81 ms | 0.54 ms → **+0.25** |
|
||||
|
||||
So the bitmask plus the bounce is **+0.14 ms of GPU over High** and nothing on the main thread —
|
||||
which by this file's own rule is nearly free, and is why the Options text says "a fraction of a
|
||||
millisecond" rather than what it said before the meter was read.
|
||||
|
||||
**Two bugs, both found by looking at the intermediate rather than the frame** (`WOW_AO_DEBUG=4` is
|
||||
the raw occlusion, and `6` is the new indirect-colour view):
|
||||
|
||||
- **Screen +y is view −y.** `view_at` flips clip y, so a slice direction built as
|
||||
`vec3(omega, 0.0)` measures its taps against a mirrored normal. One slice of two was right and
|
||||
the other inverted, which averaged to **a flat floor reading 50% occluded** — a uniform grey over
|
||||
the whole foreground that no amount of staring at the composited frame would have explained.
|
||||
- **`a / b` and `a * (1.0 / b)` are not the same number.** Factoring the blur's shared divisor into
|
||||
one reciprocal — a tidy-up, not a change — moved `water-lake` at tier **1** by up to **53/255**
|
||||
on 0.021% of its pixels. The AO is multiplied into the scene colour that the water lane's
|
||||
depth-march then refines by bisection, and one ulp picks a different step. Restored to two
|
||||
divides.
|
||||
|
||||
**Tiers 0-2 byte-identical** to the build before this, on all three deterministic scenarios at all
|
||||
three tiers (nine of nine). `fence-shadowline-night` had to be dropped mid-control and is the
|
||||
reason this file's determinism table now needs a caveat: **it screens as deterministic at the
|
||||
default tier and is not at tier 1**, where it differs from *itself* by the bottom bit. Determinism
|
||||
is a property of a scenario AND the settings it is rendered with, not of the scenario alone.
|
||||
|
||||
### 6. Runtime probe GI
|
||||
|
||||
|
||||
Reference in New Issue
Block a user