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 SSR cannot reach: the ray leaving the screen, and the top-edge fade, which is exactly where a shallow-angle lake wants its far bank. **It does not work yet, and it is not offered to anyone.** The mirrored eye sits as far below the surface as the real eye sits above it — 26 yd down on Lake Everstill — which is inside the lake bed, so the pass must clip at the water plane or it renders sand. The clip is implemented (Lengyel, adapted to this client's reverse-Z, around a finite far plane because the oblique row makes depth affine and an infinite far collapses every distant fragment onto one value) and it is WRONG: the mirror comes back a radial smear. So this is `WOW_WATER_MIRROR=1`, a dev lever — no CVar, no row in Options. It graduates to both, like every other lane, once a capture shows a reflection instead of a starburst. What IS established, and is why this lands rather than waiting: - **With the lane off the water is byte-identical** to before the pass existed, verified against a `git stash`ed build of the same tree. - With it on it reaches the water and moves 63% of a lake frame — the plumbing, the binding, the uniform and the composite all carry. - Off, the plugin **registers nothing at all**, the posture `static_gx::enabled` takes. That is not tidiness: `drive_mirror` declares `ResMut<Assets<Image>>`, and merely registering it dormant added 59 undeclared orders to `Update` and tripped the schedule ratchet. Three things this cost, all of them now written into `docs/GRAPHICS.md` and all of them my own: 1. **The pipeline failed to compile for most of a session while every A/B read a clean zero.** The field is `clip_position`; `WaterFragment` has no `position`. Naga refused the shader, the water fell back to another path, and I measured the fallback over and over — including twice mistaking the lake bed seen through clear water for a working mirror. The client logs pipeline errors rather than panicking, by design, so the answer was in the log the whole time. **Grep the capture log for `error:` before believing any shader A/B.** 2. **A disabled lane is not automatically free.** Starting SSR's march from the fogged `reflection` instead of `sky` looked equivalent and moved 10.1% of a lake frame with the mirror switched off. The control that catches that is diffing lane-off against a stashed build — not against an older capture, which is what I reached for first. 3. **A flag colour has to be one the scene cannot produce.** My first three "every gate passes" measurements were counting the orange lake bed as my red flag, which sent the hunt in the wrong direction for an hour. The pass's own cost is deliberately unmeasured: it draws the scene a second time, so it is the first item on the programme that spends main thread, and pricing a broken render against item 1's view-distance table would mean nothing. That happens when the projection does. Verified: ALL GATES GREEN (591 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
280 lines
16 KiB
Markdown
280 lines
16 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.
|
||
|
||
**The planar mirror — built, wired, and NOT yet working.** `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.
|
||
|
||
Everything is in place and measured: with the lane off the water is **byte-identical** to before
|
||
it existed, and with it on it visibly changes 63% of a lake frame. What is wrong is the **oblique
|
||
near-plane projection**. The mirrored eye sits as far below the surface as the real eye sits above
|
||
it — 26 yd down on Lake Everstill — which is inside the lake bed, so the pass has to clip at the
|
||
water plane or it renders sand. The clip is implemented (Lengyel, adapted to this client's
|
||
reverse-Z) and it is wrong: the mirror comes back a radial smear.
|
||
|
||
So it is a **dev lever, `WOW_WATER_MIRROR=1`, not a CVar and not a row in Options.** It graduates
|
||
to both, like every other lane here, once a capture shows a reflection instead of a starburst. The
|
||
next step is the projection and nothing else.
|
||
|
||
Two things this cost, both worth not repeating:
|
||
|
||
- **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.
|
||
|
||
The cost of the pass itself is unmeasured, because measuring a broken render would mean nothing.
|
||
It draws the scene a second time, so it is still the first item on this list that spends **main
|
||
thread**, and it gets priced against item 1's view-distance table before it is offered to anyone.
|
||
|
||
### 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.
|