Files
gromandClaude Opus 5 280c2e886f Planar water: the mirror works, and ladders in at waterQuality 3 (Ultra)
The starburst was three defects, and none of them was the oblique
projection I blamed it on in the last commit. What found them was looking
at the mirror's own render target instead of reasoning about the
composited frame — 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 identical.
WOW_MIRROR_DUMP=1 now writes that target beside the capture. (Bevy's own
save_to_disk cannot: try_into_dynamic knows four 8-bit formats and answers
UnsupportedFormat for Rgba16Float, logging and writing nothing.)

- 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
  becomes `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 straddled the
  boundary and was sliced along its own tessellation. Scale by the
  supremum of d/depth over the frustum instead, with 2% of margin.
- The water surface lay ON its own clip plane. This was the starburst and
  it was fatal rather than untidy: depth now means "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 straddled the plane instead of lying in
  it, and each was clipped to a sliver that kept the winning depth.
  SURFACE_CLEARANCE lifts the plane a yard: the swell sums to 0.34 and the
  probed height answers for the surface under the eye, not this fragment.
- The mirror was stored upside down. A true reflection is what a camera on
  the far side sees with its up vector reflected too — an improper basis
  no Transform can hold. The honest right-handed camera 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 read as a lake reflecting the ridge the RIGHT way up.

Measured: Lake Everstill 2.87 -> 3.36 ms mean over 600 frames (+17%), the
Stormwind canal inside the noise, CPU +0.2 to +1.1 ms. It is the first
lane in docs/GRAPHICS.md that spends main thread, so it is a rung of its
own above High and the default stays 2.

Seated as a setting, per the standing rule that no graphics lane hides
behind an env var: waterQuality gains tier 3, the Ultra preset takes it,
and the Advanced Graphics dropdown gains a fourth entry in both locales.

With the lane off, both scenarios are byte-identical to the build before
it — which took two findings of its own:

- 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, which is
  what kept this behind an env var. 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 blaming 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 by up to 96/255 with the lane switched off. So the whole
  system sits behind a run condition rather than an early return — and the
  same effect killed a .before(UpdateDirectionalLightCascades) order that
  was correct in itself.

PostUpdate's ambiguity ceiling goes 466 -> 495 with the reason (the pairs
are structurally impossible: nothing else in the schedule names the mirror
camera); Update's ratchets down 4973 -> 4952.

Also restores scripts/gates.sh's `where` example, which had gone missing.
Its absence was silent — cargo's error went to /dev/null and `|| true`
made it read as "no install" — so the memo key and the player-tests gate
saw none even when WOW_DATA was set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-27 09:35:02 -06:00

320 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 — 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, both scenarios are
**byte-identical** to the build before it — and getting there took two findings:
- 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 by up to 96/255 with the lane switched off. So the
whole system sits behind a run condition, not an early return. 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)
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.