`docs/GRAPHICS.md` said we had refraction and foam but no mirror. That was
wrong. `waterQuality` 2 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 the fixed
probe its WarcraftXL source took. What was missing was the switch: the
registered default was Enhanced (sky only), and of the five lighting rungs
only Ultra reached High.
The line holding it back said exactly what it was waiting for —
"Mirror reflections stay opt-in until their cost is measured." So I
measured it, `WOW_FPS_PROBE=300`:
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
Every tier is inside the run-to-run noise — Enhanced lands FASTER than
Classic on the lake, which is how you know you are reading noise rather
than a cost. What it buys is not noise: 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 becomes the default and the High rung. That is a weld across four
places the tests hold together, and each one bit in turn and was fixed as
it asked: `REGISTERED`, `VideoConfig`'s literal, `WaterQuality::default`,
and the rung itself — the High rung IS the registered defaults, which
`the_high_preset_is_the_registered_defaults` states and enforces.
Low and Medium keep Enhanced deliberately, and that needed saying in the
one test that assumed every rung between Off and Ultra sits AT its
default: free on the machine it was measured on is an argument for the
default, not for the rung whose whole job is hardware that cannot afford
what this one can. `water_and_lava_observers_clamp_and_water_high_is_opt_in`
asserted the old decision in its name and its body, so it is renamed and
now pins the pairing the other way: High reflects, Medium does not.
The honest remaining gap is written into the doc rather than claimed as
done: SSR gives up when the ray leaves the screen and fades toward the top
edge, which is where a shallow-angle lake wants its far bank. A planar
pass would fill that — and it is the first item on the list that draws the
scene twice, so it costs main thread and gets priced against item 1's
view-distance table before anyone builds it.
Verified: ALL GATES GREEN (492 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
256 lines
14 KiB
Markdown
256 lines
14 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.
|
||
|
||
## 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.
|
||
|
||
**What a planar reflection would still add**, if it is ever wanted: the geometry SSR cannot see.
|
||
The march gives up when the ray leaves the screen (`probe.z < 0.5`) and the result fades toward the
|
||
top edge, which is exactly where a shallow-angle lake wants its far bank and trees. That is the
|
||
real remaining gap — and unlike everything above it, a planar pass draws the scene a second time,
|
||
so it is the first item on this list that costs **main thread**. Price it against the view-distance
|
||
table in item 1 before building it.
|
||
|
||
### 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) 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 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 `AfterUpdate` hook that Bevy does not
|
||
expose, which is why the fork that has it vendors `bevy_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
|
||
`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.
|