diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index bae450ce..a35c7d22 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -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::(&app).aniso as f32, _ => panic!("add the observer readback for {k}"), }; assert_eq!(applied, value.parse::().unwrap(), "{name}/{k}"); diff --git a/docs/GRAPHICS.md b/docs/GRAPHICS.md new file mode 100644 index 00000000..5e503112 --- /dev/null +++ b/docs/GRAPHICS.md @@ -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.