The Options row shipped last commit said the setting needed a relaunch.
It does not have to. The reference's "enabled upon restart" is about its
TEXTURES, not its CVars — both register with `flags = 1`, live — because
its sampler state was baked inside each `TextureCreate` (`0x449ae0`) and
it kept no list of live textures. Ours can be re-stamped in place.
Two things make it possible, and neither is new work:
- A governed sampler now wears `POLICY_SAMPLER_LABEL`, written by the one
constructor `TexFilterSetting::descriptor`. That is the `applyGlobal`
split the reference draws, made legible at runtime: terrain, WMO/M2 and
the liquid sheets take the policy; weather sprites, the minimap, glue,
Lua widgets, the sky and decals keep their own mode. All four governed
lanes went through that constructor, so a lane cannot forget the label
and silently opt itself out of every later change.
- Extracting a `RENDER_WORLD`-only `Image` takes its *pixels* and leaves
the asset behind (`take_gpu_data` is an `Option::take` on `image.data`).
So every resident texture's own descriptor is still readable in the main
world, for nothing — there is no ledger to keep and none to go stale.
The policy itself stops being a `OnceLock` and becomes one atomic word
plus a revision, so it can be published again on every write and both
halves of the refresh can key off the same number in the same frame:
`refresh_policy_samplers` (render world, `ExtractSchedule`, before every
Prepare) swaps `GpuImage::sampler`, and `invalidate_materials::<M>` raises
`AssetEvent::Modified` so bind groups that captured the OLD sampler are
built again. `static_gx` carries two samplers of its own and rebuilds its
cached cell bind groups in place rather than dropping them — clearing the
cache would be one line, but a region with no GPU state does not draw, so
every building in view would blink out and trickle back under the bake
budget.
Measured, not asserted. `WOW_CVAR_LATE=<name>=<value>` is new here: it
writes CVars *after* the scene has settled, then re-settles the image
around them. A latched lever and a live one give the same boot picture, so
no boot-time A/B can tell them apart. On the Elwynn vista:
anisotropic 16 at boot vs 16 written late:
0 of 1,440,000 pixels differ
and the control that proves the harness is not doing the work (boot 16,
written 16 again late, so no refresh runs but the re-settle is still paid)
is byte-identical to a plain boot. The cost of the change is 873 samplers,
75 material bind groups and 264 static_gx cell bind groups, once; nothing
is re-uploaded, since the pixels never move.
The first run of that A/B was NOT identical — 6.7% of the frame, a dapple
of stale tree shadow on a road that was otherwise correct. That is
`CutoutShadowCasterMaterial`, which deliberately reuses the forward pass's
own leaf handle and so holds a governed sampler, and which lives in
`benilla-app` rather than the engine. Hence the shape the invalidation
took: a public `invalidate_materials::<M>` each crate registers beside its
own `MaterialPlugin`, published through the world-API wall with the record
that says why (the engine naming every material type in the program is the
inversion that wall exists to stop). Forgetting one is silent in exactly
that way — a bind group built after the change is right and one already
cached is wrong — so the capture A/B is written into `docs/GRAPHICS.md` as
the check for this feature, not a unit test pretending to be one.
The Options row keeps its deferral to Apply, now for the real reason: the
commit is cheap once and silly sixteen times, which is what a drag across
the groove would ask for.
Verified: ALL GATES GREEN (520 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
176 lines
10 KiB
Markdown
176 lines
10 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 — *buys* main thread
|
||
|
||
The reference culls terrain against its own horizon (`0x686000`, `0x686180`, `0x681b50`), outdoors
|
||
and per portal window inside a WMO, and parks hidden rigs and drops shadow casters below it. We have
|
||
the portal/doorway half (`exterior_cull.rs`) and not this one. It is the rare item that makes the
|
||
frame **cheaper on the thread that is actually full**, and cheaper frames are what let the view
|
||
distance and the lanes below be afforded at all. Terrain chunk LOD (`0x6c51b0`, levels 2 and 3
|
||
stitched across cell and tile edges) is the same kind of win.
|
||
|
||
### 2. Auto-exposure
|
||
|
||
A screen-metered exposure, so interiors, night and a torchlit room read at their own level instead
|
||
of at the sun's. One GPU pass over a downsampled frame plus a smoothed histogram; our HDR path and
|
||
`ffx_glow`'s combine already carry an exposure slot. Small, GPU-side, and it changes every dark
|
||
scene in the game.
|
||
|
||
### 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) 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.
|