Files
benilla-octo/docs/GRAPHICS.md
T
gromandClaude Opus 5 33fc0c2023 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
2026-10-28 06:40:48 -06:00

619 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Graphics: where the budget is, and where the picture is
This fork's renderer is its own (`LIGHTING.md`, `WATER.md` describe the lanes). This file is the
*plan*: what the frame actually costs, what is already shipped and unspent, what is missing, and
what each missing thing would cost on **this** engine rather than on someone else's.
## The budget, measured
**This client is main-thread CPU bound and the GPU is about two thirds idle.** Measured with
`WOW_FLASH_TRACE=1 WOW_GPU_MS=1`, in play and offline:
| | fps | wall | main | gpu |
|---|---|---|---|---|
| live, Dun Morogh, out of combat | 112 | 8.9 | 7.6 | **3.0** |
| live, in combat | 65–91 | 11.1–15.4 | 10.3–14.5 | **2.6–3.9** |
| offline Elwynn vista, vsync-capped | 60 | 16.6 | 4.2–8.4 | **0.8** |
`gpu` is flat at every framerate; `main` moves 1:1 with the drop. So the rule for anything in this
file:
> **A change that spends GPU is nearly free. A change that adds main-thread work is not.**
That single fact decides most of the ordering below, and it is why upscaling is near the bottom
rather than the top.
## The harness
Every claim here is checked the same way, offline, with no server and no account — about 60 s a run:
```bash
env WOW_DATA=/path/to/Data WOW_MAP=0 WOW_CAPTURE=vista \
WOW_VISTA_AT=-9460,70,58 WOW_VISTA_FACE=180 WOW_VISTA_PITCH=-8 WOW_VISTA_MIN=720 \
WOW_GPU_MS=1 WOW_FLASH_TRACE=1 WOW_CAPTURE_OUT=/tmp/shot.png \
cargo run -q --release -p benilla
```
Render it twice, change one knob, and diff the PNGs (`PIL.ImageChops.difference`): mean and max
absolute difference, and the count of pixels that moved by more than 2. `WOW_CVAR=a=1;b=2` sets any
registered row for the session; `WOW_ANISO`, `WOW_FARCLIP`, `WOW_RENDER_SCALE`, `WOW_MSAA` are
direct levers. Validated eye positions live in `benilla-app/src/capture/scenarios.rs`.
`WOW_CVAR_LATE=<name>=<value>` is the other half, and the only way to measure a setting that
claims to apply **in the session**: it writes the CVar *after* the scene has settled, then re-settles
the image around it. A latched lever and a live one produce the same boot picture, so a boot-time
A/B cannot tell them apart; `WOW_CVAR_LATE` can. Anisotropy's live path is verified exactly this way
— `WOW_CVAR=anisotropic=16` against `WOW_CVAR_LATE=anisotropic=16` is **byte-identical over all
1,440,000 pixels**, which says the refresh reaches every texture the loader does and misses nothing.
**Always run a starved control.** A knob that forces the bad path, or an identical pair of renders,
proves the harness is actually reaching the thing being measured. Five such pairs once matched byte
for byte before anyone checked whether the view read the table at all.
**And check the baseline against ITSELF before using it as one.** Not every scenario reproduces,
and the ones that do not are not obvious from the outside. Screened by rendering each twice with
the same binary and once more over six runs:
| scenario | byte-identical run to run? |
|---|---|
| `overlook-noon`, `water-lake`, `water-ocean`, `fence-shadowline-night` | **yes** |
| `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
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
own noise is 0.01% of the frame.
## Already shipped and worth spending
These need no code. They are the cheapest picture available today.
| Knob | Shipped | Worth | Measured cost | Measured change |
|---|---|---|---|---|
| `anisotropic` | 1 (off) at Classic/Low, **16 from High**; Anisotropic Filtering on the Graphics page | 16 | **+1.0 ms GPU, 0 main** | **34.1% of the frame**, peaking at 66.5% across the band where the ground recedes |
| `renderScale` | 1.0 (Render Scale slider in Options) | 1.25–1.5 | +1.5 ms GPU, 0 main | 19.4% of the frame |
| `farclip` | 777 at High, 1497 at Ultra | taste | streaming and draws, i.e. **main thread** — measure before raising | — |
The anisotropic row is the one to take first: the reference ships it *off*, so ground texture at a
grazing angle smears to mush a few yards out, which is most of an outdoor screen. It is a sampler
state, so it is free on the CPU and costs a milligram of the GPU time we are not using.
It applies **in the session**, which the reference's own options window could not do ("enabled upon
restart"). A sampler is still baked into each texture at load, but `benilla_world::tex_filter_live`
re-stamps the resident ones when the policy moves: the selection is the
`benilla_assets::POLICY_SAMPLER_LABEL` every governed sampler wears, and the two halves are one
fresh sampler per resident texture (render world, `ExtractSchedule`) plus an `AssetEvent::Modified`
on every material that binds one, because a bind group captured the old sampler and nothing in Bevy
invalidates one for an image that changed. `static_gx` carries its own pair and refreshes itself off
the same revision.
On the Elwynn vista above that is **873 samplers, 75 material bind groups and 264 static_gx cell
bind groups**, once. Nothing is re-uploaded — the pixels never move — so it is descriptor work, not
bandwidth. That is why the Options row commits on **Apply** rather than on the drag.
Every material that binds a governed image has to register
`tex_filter_live::invalidate_materials::<M>`, and forgetting one is silent in the worst way: the
texture is re-sampled, so a bind group built *after* the change is right and one already cached is
wrong — part of the frame moves and part does not. `CutoutShadowCasterMaterial` was exactly that,
and only the capture A/B found it: 6.7% of the frame, a dapple of stale tree shadow on a road that
was otherwise correct.
A player already on a **Custom** Graphics Preset is not seeded by the ladder and keeps their saved
value; the Graphics page slider, or `/console anisotropic 16`, is the whole fix for them, with no
relaunch.
## What the picture is missing
Ordered by (visual gain) ÷ (main-thread cost), not by how interesting they are.
### 1. Terrain horizon occlusion — **measured, and not worth building**
This was ranked first on the reasoning that the reference needs it (`0x686000`, `0x686180`,
`0x681b50`) and we have only the portal/doorway half (`exterior_cull.rs`), so it would be the rare
item that makes the frame *cheaper on the thread that is actually full*. **That premise does not
hold for our renderer.** Measured on the Elwynn vista, steady state, `WOW_FPS_PROBE=300`:
| farclip | mean ms | p50 ms | entities | submeshes |
|---|---|---|---|---|
| 177 (the reference's slider minimum) | 3.18 | 2.36 | 15,043 | 4,876 |
| 350 (the reference's default) | 3.22 | 2.45 | 16,103 | 5,114 |
| 777 (our High) | 3.62 | 2.84 | 19,581 | 6,016 |
| 1497 (our Ultra) | 4.42 | 4.14 | 40,643 | 10,916 |
Cutting the view distance from 777 yd to 177 — **removing four fifths of the visible world** —
saves 0.44 ms of a 3.6 ms frame, and p95 is flat at 6.4-6.5 ms at every distance. Horizon occlusion
can only remove the part of that which happens to sit behind a hill, so its ceiling is a fraction
of 0.44 ms. `WOW_SYSTEM_TIMING=1` agrees: no main-world system in a steady vista exceeds 0.12
ms/run, and no terrain system appears in the top twenty at all. Our model cull already takes 6,016
submeshes to **29 drawn**.
The reference needed this on a 2004 CPU with a fixed-function pipeline and no GPU-side culling. We
do not. Chunk LOD is the same story from the other side: it saves vertex work, and the GPU is the
resource we have in surplus.
Not "never" — it would matter if the draw submission ever became the wall — but it is not the first
thing, and it was not close.
### 2. Auto-exposure — **built**
A screen-metered gain, so interiors, night and a torchlit room read at their own level instead of
at the sun's. `crates/benilla-app/src/post/exposure.rs`, CVars `autoExposure` (Off / Subtle / Full)
and `autoExposureStrength`, on the Graphics Preset ladder from Medium.
The need, measured on this client's own captures before anything was written:
| scene | gamma median | pixels under 32/255 |
|---|---|---|
| Elwynn vista | 87.6 | 1.4% |
| Goldshire inn, noon | 31.9 | 50.1% |
| the inn's kitchen | **12.8** | **94.5%** |
That last row is not a dim room, it is a black screen — and the reference has the same problem,
because it has no metering either.
What it does, same measurements after:
| scene | Off | Subtle | Full |
|---|---|---|---|
| vista median | 87.6 | 87.5 | **87.5** |
| inn noon median | 31.9 | 49.4 | 74.5 |
| inn noon under 32 | 50.1% | 22.4% | **4.6%** |
| kitchen median | 12.8 | 22.4 | 35.8 |
| kitchen under 32 | 94.5% | 79.3% | **40.3%** |
**+0.08 ms of GPU** and nothing on the main thread.
Two departures from the textbook version, both forced by what this renderer's pixels mean — the
world is drawn in the reference's gamma convention, which `ffx_glow.wgsl`'s combine clamps to 1.0:
- The gain has a **floor of 1.0**. It only brightens. Aiming at the exterior's own measured level
is what lands it at 1.0 outdoors without a special case, which is why the vista's median moves
87.6 -> 87.5 and 0.12% of its pixels with it.
- It has a **ceiling**, and the ceiling is the tier. The kitchen meters to roughly 24x; left
uncapped it comes out as daylight, which erases the room rather than making it readable.
Calibrating that aim level is the one thing to get right if it is ever re-derived: it is the level
of the scene the PASS sees, not of the screenshot. The glow adds `w·blur²` downstream, so the
finished vista log-averages to 0.0931 linear and the pass's own input to 0.060. Calibrating against
the former aimed the lane 1.5x high and brightened the daylight exterior it exists to leave alone.
### 3. Water reflection — **the scenery reflection already existed; it was switched off**
The plan said "we have refraction and foam but no mirror". That was wrong: `waterQuality` 2 (High)
has carried a depth-marched screen-space reflection of the scenery all along
(`enhanced_water.wgsl`, "HIGH"), with bisection refinement against the real scene depth rather than
a fixed probe. What was missing was the switch — the registered default was Enhanced (sky only),
and only the Ultra lighting rung reached High.
The line holding it back said what it was waiting for: *"Mirror reflections stay opt-in until their
cost is measured."* Measured, `WOW_FPS_PROBE=300`:
| scene | Classic | Enhanced | High |
|---|---|---|---|
| Lake Everstill | 1.91 ms | 1.84 ms | 1.90 ms |
| open ocean | 1.89 ms | 1.88 ms | 1.89 ms |
All three tiers are inside the run-to-run noise — Enhanced even lands *faster* than Classic on the
lake, which is how you know you are reading noise. What it buys: the far bank and the mountains
appear in the water, **18.5% of the lake frame and 35.1% of the ocean frame**.
So High is now the registered default and the High rung, which is a weld of three places the tests
hold together (`REGISTERED`, `VideoConfig`, `WaterQuality::default`) plus the rung itself. Low and
Medium keep Enhanced deliberately: free on the machine it was measured on is an argument for the
default, not for the rung whose job is hardware that cannot afford what this one can.
**The planar mirror — working, and seated as `waterQuality` 3 (Ultra).**
`benilla_world::liquid::reflect` is a second camera on the far side of the water plane, rendering
into a half-viewport target that `enhanced_water.wgsl` reads *beneath* the screen-space march — so
SSR still wins wherever it finds something, and the mirror only fills the hole where the ray leaves
the screen.
It took three wrong answers to get there, and the instrument that ended it is worth more than the
feature: **`WOW_MIRROR_DUMP=1` writes the mirror's own render target beside the capture.** The
mirror is a second render of the world that nothing on screen shows directly — the water reads it
through a fresnel weight, a rippled UV, a plane-match band and an SSR march that paints over it —
so from the outside a broken mirror and a broken water shader look exactly alike. One look at the
target answered in one run what reasoning had got wrong twice. (Bevy's own `save_to_disk` cannot
write it: `try_into_dynamic` knows four 8-bit formats and the target is `Rgba16Float`, so it logs
and writes nothing.)
Three defects, in the order they were found:
- **The oblique row's scale.** Replacing the near plane with the water plane costs the FAR plane:
`z >= 0` stops meaning "nearer than `CAM_FAR`" and starts meaning `depth >= a*d`, a plane through
the eye tilted by `a`. Lengyel's own scale puts that plane exactly through the frustum's far
corner — and the sky dome is pinned just inside the far plane, so it straddles the boundary and
is sliced along its own tessellation. Scale by the supremum of `d/depth` over the frustum instead
(the same quantity with the far plane's term dropped), with 2% of margin.
- **The water surface lay ON its own clip plane.** This was the starburst, and it was fatal rather
than untidy: the oblique row makes depth mean "distance above the plane", so a fragment at `d=0`
reads `z/w = 1`, the nearest value reverse-Z has, and beats every other surface in the frame. The
mesh is wave-displaced, so its triangles straddle the plane rather than lying in it, and each is
clipped to a sliver that keeps the winning depth. Lift the clip plane a yard
(`SURFACE_CLEARANCE`): the swell sums to 0.34 yd and the probed height answers for the surface
under the eye rather than under each fragment.
- **The mirror was upside down.** A true reflection is what a camera on the far side sees with its
up vector reflected too — pointing down — which is an improper, handedness-flipping basis no
`Transform` can hold. The honest right-handed camera at the mirrored eye sees the same surfaces
through the same frustum and stores the image flipped, so the shader samples `1 - v`. That costs
no winding flip, so backface culling stays correct. It showed up as a lake reflecting the ridge
the RIGHT way up.
**Cost, measured.** Lake Everstill 2.87 → 3.36 ms mean over 600 frames (+0.49, +17%); the Stormwind
canal inside the noise; CPU +0.2 to +1.1 ms. It is the first lane here that spends **main thread**,
which is why it is a rung of its own above High rather than part of it, and why the default stays 2.
**Two things it could not be allowed to cost.** With the lane off, the build is **byte-identical**
to the one before it on `water-lake` — and getting there took two findings. (This was written up
as holding for `stormwind-canal-near` too. It does not, and cannot: the canal is **bimodal between
runs of one binary**, 7 of 15 pairs identical and the rest differing on 0.01% of pixels by up to
96/255 — see the harness section. The identity measured there was a coin flip that landed well.
The finding below still stands on its extent, not on its amplitude: 0.75% of the frame is 75x the
scene's own 0.01%, and the amplitude the two share — 96 — turns out to be the canal's own flicker
and to mean nothing at all.)
- The system could not live in `Update` at all. `&mut Transform` on a camera is shared with
everything that moves anything; declaring it put 59 undeclared orders into `Update` and tripped
the schedule ratchet. It belongs in `PostUpdate` with the skybox and the billboards, after
propagation and before visibility — where it also reads the eye's settled pose instead of a
frame-old one. (The comment that blamed `ResMut<Assets<Image>>` for those 59 was wrong; dropping
the asset handle changed the count by zero.)
- **A system that runs and returns on its first line is still not free.** Holding `&mut Camera` and
`&mut GlobalTransform` for a frame is enough to change which undeclared orders the executor
picks: the canal moved 0.75% of its pixels with the lane switched off, 75x the extent of its own
run-to-run noise. So the whole system sits behind a run condition, not an early return. (Read
the EXTENT: the 96/255 this was also quoted at is the canal's own flicker amplitude, which it
reaches with nothing changed at all.) The same effect killed a
`.before(UpdateDirectionalLightCascades)` order that was correct in itself — declaring it moved
the canal's lamp light with the feature off, and what it bought was a half-res rippled
reflection whose shadow cascades fit this frame's pose rather than last frame's.
Three lessons, all of them about believing a lever moved:
- **The pipeline failed to compile for most of a session and every A/B read a clean zero.** The
field is `clip_position`, not `position`, and `WaterFragment` has no `position` — so naga
refused the shader, the water fell back, and the measurements were all of the fallback. The
client logs pipeline errors rather than panicking (`gpu: wgpu errors are logged, not fatal`), so
**grep the capture log for `error:` before believing any shader A/B.**
- **A disabled lane is not automatically free.** Changing SSR's base from `sky` to the fogged
`reflection` moved 10.1% of a lake frame with the mirror switched off. The control that catches
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 — **built, and not the half that was expected**
`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**
`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
A toroidal voxel clipmap of the static world plus cascaded probe grids traced in compute, order-1 SH
with hysteresis. The biggest lighting change available and the most work; it belongs after the five
above, not before.
### 7. Upscaling (FSR) — **not now**
Worth stating plainly, because it is the obvious thing to ask for. FSR renders *fewer* pixels and
reconstructs the rest. We are not short of pixels — we are short of main thread, and the GPU is two
thirds idle. Upscaling would hand back time we are not spending and charge main-thread work for it:
a motion-vector prepass over terrain, static geometry and every skinned rig, plus the upscaler's own
dispatch.
The plumbing, for when it *is* the right lever:
* Bevy 0.18 already carries `MainPassResolutionOverride` (honoured by every main 3D pass) and
`TemporalJitter`, and ships a complete DLSS integration (`bevy_anti_alias::dlss`) built as a
**`ViewNode`** — the same render-graph shape this tree uses, so it is a working template rather
than a port.
* `renderScale` already decouples world resolution from the window (`world_backdrop.rs`), with its
own Options slider. The resolution half of the problem is solved.
* The real work is motion vectors for **our** skinning: `wow_model.wgsl` runs its own
`wow_skin_model` off our rig palette at vertex locations 10/11, not Bevy's `SkinnedMesh` path, so
Bevy's prepass would draw every animated model unposed. A prepass variant needs the palette
double-buffered to last frame's pose.
* **RCAS sharpening is built** — see §9. It was the separable piece and it needed none of the
above, which is the whole reason it could land years ahead of the rest.
* Frame generation is *not* reachable here: it needs a winit `AfterUpdate` hook that Bevy does not
expose, which is why the fork that has it vendors `bevy_winit`.
### 8. Field of view — **built**
The reference's lens is ~44.1° vertical at 16:9 and ours is 45°, so ~73° horizontal. A 90° Hor+
lens is a large perceptual change and nearly free to draw, but it alters how the game feels to
play — so it is a row a player moves, not a default and not a preset member.
`fieldOfView`, vertical degrees, 40 to 70, default **45**: Field of View on the Graphics page under
Render Scale, live as you drag. 40 reaches past the reference's own 44.1° so a player can sit
exactly on it; 70 is ~102° horizontal at 16:9, past which the edge stretch of a rectilinear
projection is the thing you see rather than the world.
Three things it had to get right:
- **Vertical degrees, not horizontal**, which is what keeps the reference's Hor+ framing: a fixed
vertical lens widens horizontally as the window does, so an ultrawide player gains picture at the
sides instead of losing it off the top. A row specified in horizontal degrees would be Vert- and
would quietly crop the taller aspect.
- **It is the base, not a fourth factor.** `player::scoped_view` already multiplied two things into
the lens and neither is a field of view: SuperWoW's `FoV` is the reference's `[camera+0x40]` as a
*ratio*, and the spyglass (aura 76) is a zoom off normal. Both answer "how much wider than
normal"; until this row nothing a player could move answered what normal *is*, and the answer was
`benilla-world`'s `CAM_FOVY` constant. So `fieldOfView` is the leftmost term — `base × FoV ×
zoom` — and an addon writing `FoV` still means the same fraction of the lens the player chose.
- **The default is `CAM_FOVY` to the bit.** `45f32.to_radians()` and `FRAC_PI_4` are the same f32
(`0x3f490fdb`), which the defaults-mirror test now asserts on `to_bits()` rather than against a
tolerance: an ulp of projection is a pixel in a capture diff, and a row that reframes the world
for a player who never opened Options is not a row, it is a regression. Verified from the other
side too — a capture with `fieldOfView=45` written explicitly is **byte-identical** to one with
the row never touched, and 40 and 65 move 97.6% and 99.3% of the frame.
### 9. Sharpening (RCAS) — **built**
`post::rcas`, cvar `sharpening` 0 Off / 1 Low / 2 Medium / 3 High, on the Graphics Preset ladder
from Medium, Sharpening on the Advanced Graphics page with the other screen effects.
RCAS is FSR 1.0's sharpening half taken on its own. It does not resample, so it wants none of the
upscaler's motion vectors, jitter or history — which is why it could land while §7 cannot. One
5-tap cross per pixel, and what makes it *robust* is that the lobe is not a constant: it is the
widest negative lobe that cannot push the result outside the ring's own range, solved per channel
against a peak of 1.0 and then taken as the most conservative of the three. Flat regions get
nothing, hard edges get as much as the signal carries, and nothing rings — which is the failure
every fixed unsharp mask has.
**The seat is most of the design.** It runs on the world view in `Core3d`, **after** `FfxGlowLabel`
and before `Node3d::Bloom`:
- **After the glow, not before.** `FfxGlowNode` builds the ¼-res blur chain the glow and the
underwater haze read, and a sharpened bloom source is exactly how an unsharp mask turns a lit
window into a ringed halo. Everything the blur needs is sampled before this pass runs.
- **On the world view only.** The world's last reader is the FFXGlow combine drawn inside the UI
camera's main pass, which binds `world_target.main_texture_view()` at node-run time, per frame,
precisely so a `post_process_write` upstream of it lands. The interface is drawn afterwards at
native resolution into a different target — and sharpening text is the one thing this must never
do.
- **Every tap saturates**, as the combine's own scene read does one pass later. The ring's limiter
is solved against a peak of 1.0, so a super-white tap would invert the bright-side limiter's
sign; and nothing is lost by it, because the blur chain already took its copy of the unclamped
image.
- **No denoise term** (FSR's own is optional): the deband dither whose grain it would exist to
suppress is added *downstream*, in the combine's exit, so there is nothing here to amplify.
**One stated limitation.** It sharpens at the world's RENDER resolution, which is output
resolution exactly at the default `renderScale` 1.0 and only there. Away from 1.0 the combine's
bilinear resample softens part of it back — the increased local contrast survives, the exact
texel-scale detail does not. FSR's own order is EASU then RCAS, i.e. sharpen in *output* space,
and the seat for that here is inside the FFXGlow combine itself, which is a reference-exact shader
this lane had no business perturbing for a first cut. Worth doing on the day §7 is: the combine
already samples the world texture to resample it, so the 5-tap cross would ride along for four
extra taps and no new pass at all.
**Measured.** `WOW_FPS_PROBE=600`, 600 frames each:
| scene | Off | Medium | High |
|---|---|---|---|
| Redridge overlook, noon | 2.95 ms | 3.02 | 3.01 |
| Goldshire inn, noon | 3.46 ms | 3.51 | **3.29** |
Inside the noise — the inn reads *faster* at High, which is how you know you are reading noise.
CPU is flat at 5.9-6.0 ms. What it buys, as the share of the frame that moved by more than 2/255:
**3.8% at Low, 19.9% at Medium, 48.6% at High**, peaking at a channel delta of 129.
**The first run caught the trap this file already warns about.** `max(4.0 * mx, 1e-5)` where `mx`
is a `vec3` is `error: inconsistent type passed as argument #2 to max`; naga refused the shader,
the pass drew nothing, and the capture came out looking perfectly normal. One
`grep -ciE 'error:'` on the capture log answered it before a single image was diffed.
**Lane off, byte-identical** on both deterministic scenarios (`overlook-noon`, `water-lake`)
against a `git stash`ed build of the same tree. `inn-noon` is **excluded, with proof**: two runs of
the *same* binary differ on 37.8% of their pixels by up to 199, because that scene's flame emitters
do not reproduce. Its before/after delta is 0.00% of pixels at a maximum of 1 — the bottom bit —
but a scenario that cannot reproduce itself cannot be a control, and the number to check first is
always the baseline against itself.
## What is not in scope
`benilla-bourbon` implements most of §2–§7 already, on **Bevy 0.19.1** with a vendored wgpu 29 and a
render graph expressed as systems. This tree is Bevy 0.18.1 with 14 files implementing render-graph
`Node`s. Those are incompatible architectures, and that fork is published as one squash commit with
no history to pick from — `docs/BOURBON_SYNC.md` has the measurement. So everything above is costed
as *work on this engine*, informed by what that fork proved is possible, not as a port.