`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
514 lines
32 KiB
Markdown
514 lines
32 KiB
Markdown
# 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** |
|
||
|
||
**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.
|
||
|
||
### 4. Froxel volumetrics
|
||
|
||
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.
|
||
|
||
### 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.
|