Anisotropic filtering joins the Graphics Preset ladder from High

The reference ships `anisotropic` at 1, which is *off*, so ground texture at a
grazing angle smears to mush a few yards out — which is most of the screen in
an outdoor scene. The row was registered and wired (`benilla_assets::
TexFilterSetting`, the reference's own `[1, 16]` clamp at `0x689110`) but sat
outside the preset ladder, so no rung ever spent it and even Ultra left it off.

Measured on an Elwynn vista (`WOW_CAPTURE=vista`, the offline harness
`docs/GRAPHICS.md` now documents), one knob changed, 1600x900:

  * **34.1% of the frame moves** (490,600 of 1,440,000 pixels by more than 2),
    mean |d| 4.21, max 212. By band it peaks at **66.5%** across y 420-479 and
    runs 48-66% from y 300 to y 780 — exactly where the ground recedes to the
    horizon, which is what anisotropy is for. For scale, the interior
    prop-probe table's whole stake is 7.6% of pixels by at most 14.
  * **+1.0 ms of GPU and nothing on the main thread** (gpu 0.8 -> 1.8 ms, main
    unchanged at 4.2-8.4 ms). The filter policy confirms it lands: "mode 4
    (trilinear, aniso off)" -> "mode 5 (trilinear + aniso) anisotropic=16".

That ms is one this client is not spending. It is main-thread bound with the
GPU about two thirds idle — flat ~3 ms in play at every framerate, 0.8 ms in
this capture — so a sampler state is the cheapest picture on the table.

Ladder: Classic and Low keep the reference's 1, Medium takes 4, High and Ultra
16. Classic is the reference client, so it is judged against the registered
default the way `farclip` already is, not against zero — `anisotropic` is not a
0/1 lane, its "off" *is* 1. The High column differs from the reference, so it
is declared in `SEEDED_DEVIATIONS` with its reason, and
`graphics_rows_pass_their_observers_unclamped` now drives all five rungs
through the real setter and reads the level back off the sampler policy.

The sampler is baked into each texture at load, so a rung change lands on the
next launch, as the reference's own options window says ("enabled upon
restart"). A player already on a Custom preset is not seeded and keeps their
saved value; `/console anisotropic 16` and a restart is the whole fix there.

`docs/GRAPHICS.md` is new: where the frame's budget actually is, the offline
capture-and-diff harness every claim here uses, what is already shipped and
unspent, and the ordered list of what the picture is missing — costed on this
engine, with upscaling placed where the measurement puts it rather than where
it is usually asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-26 08:35:05 -06:00
co-authored by Claude Opus 5
parent ba909276a0
commit b6f3bb001c
2 changed files with 181 additions and 8 deletions
+32 -8
View File
@@ -2481,12 +2481,21 @@ pub(crate) const GRAPHICS_DEFAULT: &str = "High";
/// `seeded_column_deviates_only_where_declared` walks the whole column against the reference
/// rows, both ways.
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) const SEEDED_DEVIATIONS: &[(&str, &str, &str)] = &[(
"farclip",
"350",
"owner decision: the High preset is the first-boot default, and High's view distance is the \
reference's own slider maximum, 777 yd; Classic keeps 350",
)];
pub(crate) const SEEDED_DEVIATIONS: &[(&str, &str, &str)] = &[
(
"farclip",
"350",
"owner decision: the High preset is the first-boot default, and High's view distance is \
the reference's own slider maximum, 777 yd; Classic keeps 350",
),
(
"anisotropic",
"1",
"owner decision: the reference ships anisotropic filtering off, which smears ground \
texture at a grazing angle a few yards out — 34.1% of an outdoor frame, measured. High \
takes 16 for +1.0 ms of GPU and no main thread; Classic and Low keep the reference's 1",
),
];
/// **The one place the Graphics Preset is written down**: one line per governed row, one column
/// per rung of [`GRAPHICS_PRESET_NAMES`]. `lightingQuality` is itself a row — writing its rung
@@ -2521,6 +2530,16 @@ pub(crate) const GRAPHICS_PRESETS: &[(&str, [&str; 5])] = &[
// moonlight is a few ALU per fragment, so every rung past Classic takes it.
("volumetricLight", ["0", "0", "1", "2", "2"]),
("moonLight", ["0", "1", "1", "1", "1"]),
// GFX (anisotropic): the registered "1" is the reference's, and it is *off* — ground texture
// at a grazing angle smears to mush a few yards out, which is most of the screen in an
// outdoor scene. Measured on an Elwynn vista (`WOW_CAPTURE=vista`, the harness
// `docs/GRAPHICS.md` describes): 1 -> 16 moves **34.1% of the frame**, peaking at 66.5% of the
// band where the ground recedes, for **+1.0 ms of GPU and no main thread at all** (gpu 0.8 ->
// 1.8 ms, main unchanged). This client is main-thread bound with the GPU about two thirds idle
// (`docs/GRAPHICS.md`, "The budget"), so that ms is one we are not spending. Classic and
// Low keep the reference's value; the sampler is baked at load, so a rung change lands on the
// next launch, as the reference's own UI says ("enabled upon restart").
("anisotropic", ["1", "1", "4", "16", "16"]),
];
/// The column of a rung, matched case-insensitively; `None` for `Custom` or anything else.
@@ -3605,7 +3624,7 @@ mod tests {
/// disjointness that lets both labels be right at once.
#[test]
fn graphics_rows_are_registered_and_disjoint_from_the_lighting_ladder() {
assert_eq!(GRAPHICS_PRESETS.len(), 13, "add the row count with the row");
assert_eq!(GRAPHICS_PRESETS.len(), 14, "add the row count with the row");
let cvars = fresh_registry();
for (k, values) in GRAPHICS_PRESETS {
assert!(cvars.get(k).is_some(), "{k}: not registered");
@@ -3672,8 +3691,11 @@ mod tests {
assert!(apply_graphics_preset(&mut cvars, "Classic"));
assert_eq!(cvars.get("lightingQuality"), Some("Off"));
assert_eq!(cvars.get("farclip"), cvars.default_of("farclip"));
// `anisotropic` is not a 0/1 lane: its "off" is the reference's registered 1, so Classic
// is judged the way `farclip` is — against the registered default, not against zero.
assert_eq!(cvars.get("anisotropic"), cvars.default_of("anisotropic"));
for (k, values) in GRAPHICS_PRESETS {
if !matches!(*k, "lightingQuality" | "farclip") {
if !matches!(*k, "lightingQuality" | "farclip" | "anisotropic") {
assert!(same_value(values[0], "0"), "{k}: Classic leaves it on");
}
}
@@ -3917,6 +3939,8 @@ mod tests {
// GFX (volumetric light) / (moonlight)
"volumetricLight" => video.volumetric_light as f32,
"moonLight" => video.moon_light,
// GFX (anisotropic): the sampler policy, not a `VideoConfig` lane.
"anisotropic" => res::<benilla_assets::TexFilterSetting>(&app).aniso as f32,
_ => panic!("add the observer readback for {k}"),
};
assert_eq!(applied, value.parse::<f32>().unwrap(), "{name}/{k}");
+149
View File
@@ -0,0 +1,149 @@
# 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`.
**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** | 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. The sampler
is baked into each texture at load, so a change lands on the **next launch** — the reference's own
options window says the same ("enabled upon restart").
A player already on a **Custom** Graphics Preset is not seeded by the ladder and keeps their saved
value; `/console anisotropic 16` and a restart is the whole fix for them.
## 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.