The programme's item 1 was terrain horizon occlusion, ranked first on the reasoning that it would make the frame cheaper on the thread that is actually full. **I measured it before building it, and the premise does not hold for this renderer**, so this is item 2 instead. The numbers are in `docs/GRAPHICS.md`: 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, p95 is flat at 6.4 ms at every distance, no main-world system in a steady vista exceeds 0.12 ms/run, and the model cull already takes 6,016 submeshes to 29 drawn. Horizon occlusion can only remove the fraction of that which sits behind a hill. The reference needed it on a 2004 CPU with no GPU-side culling; we do not. What the same harness said about item 2 was the opposite. Before anything was written: the Elwynn vista renders at a gamma median of 87.6/255 with 1.4% of its pixels under 32, the Goldshire inn at noon at 31.9 with 50.1% under 32, and the inn's kitchen at **12.8 with 94.5% under 32**. That is not a dim room, it is a black screen — and the reference has the same problem, because it has no metering either. A histogram-and-reduce over the HDR view target, smoothed in the log domain, applied ahead of the shafts, bloom and glow chain so FFXGlow's blur — which is built from the scene — sees the same exposed image everything else does. 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 the combine clamps to 1.0): - The gain has a **floor of 1.0**: it only ever brightens. Aiming at the exterior's own measured level is what lands it at 1.0 outdoors with no special case — the vista's median moves 87.6 -> 87.5, and 0.12% of its pixels with it. Daylight is the one look this client is measured against and nothing here was worth moving it. - It has a **ceiling**, which is the tier. The kitchen meters to roughly 24x; uncapped it comes out as daylight, which erases the room instead of making it readable. Subtle and Full are 2x and 4x: "a dark room you can see in" and "one you can read in", neither "a lit room". Measured after: inn noon goes 50.1% -> 4.6% of pixels under 32, the kitchen 94.5% -> 40.3%, and the vista does not move. **+0.08 ms of GPU**, nothing on the main thread. The calibration is the one thing to get right if this is ever re-derived, and it is written down because I got it wrong first: the aim level is the level of the scene THIS 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 — which the vista capture caught immediately. Two rows on Advanced Graphics, both locales, the blend greyed while there is no gain to blend; on the Graphics Preset ladder from Medium, in the shape the other post lanes take. Classic keeps it off, as the reference has no metering at all. Verified: ALL GATES GREEN (550 s); `benilla-app --lib` against the install at the exact 269-failure baseline, set-identical by `comm`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
13 KiB
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:
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.
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. Planar water reflection
Our water has refraction, foam and a scene-depth copy (liquid/, water_fx/) but no mirror. The
reference-era trick is a second scene render with an oblique near clip, armed only for visible water
and thinned of small objects, under a screen-space trace. It is the single most recognisable item on
the "original art, modern presentation" axis, and it is pure GPU — which is what we have.
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)
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.
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) andTemporalJitter, and ships a complete DLSS integration (bevy_anti_alias::dlss) built as aViewNode— the same render-graph shape this tree uses, so it is a working template rather than a port. renderScalealready 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.wgslruns its ownwow_skin_modeloff our rig palette at vertex locations 10/11, not Bevy'sSkinnedMeshpath, 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 the separable piece: a single cheap post pass that improves perceived detail at any render scale, including above 1.0. It needs none of the above.
- Frame generation is not reachable here: it needs a winit
AfterUpdatehook that Bevy does not expose, which is why the fork that has it vendorsbevy_winit.
8. Field of view
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. It is deliberately not on this list as a
default change: it alters how the game feels to play, and the existing FoV row is SuperWoW's
camera ratio (crate::player::scoped_view::CameraFov), which is a different quantity. If it is
wanted it should be its own registered row with the reference's value as the default.
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
Nodes. 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.