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:
@@ -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}");
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user