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:
2026-10-27 18:48:00 -06:00
co-authored by Claude Opus 5
parent 4c3c128594
commit 848c840ecf
6 changed files with 679 additions and 123 deletions
+89 -3
View File
@@ -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