Both were named in docs/GRAPHICS.md as nearly free and neither had been built. They are unrelated to each other; they land together because each is small. ## Sharpening (`sharpening`, 0 Off / 1 Low / 2 Medium / 3 High) RCAS, FSR 1.0's sharpening half taken on its own. It does not resample, so it needs none of the upscaler's motion vectors, jitter or history — which is the whole reason it can land while §7 cannot. One 5-tap cross; the lobe is not a constant but the widest one that cannot push the result outside the ring's own range, solved per channel against a peak of 1.0 and taken as the most conservative of the three. Flat regions get nothing and nothing rings. The seat is most of the design. `Core3d`, after `FfxGlowLabel` and before `Node3d::Bloom`: - After the glow, not before. `FfxGlowNode` builds the blur chain the glow and the underwater haze read, and a sharpened bloom source is how an unsharp mask turns a lit window into a ringed halo. - The world view only. The world's last reader is the FFXGlow combine drawn inside the UI camera's main pass, which binds `main_texture_view()` at node-run time per frame, precisely so a `post_process_write` upstream lands. The interface is drawn afterwards into a different target — and sharpening text is the one thing this must never do. - Every tap saturates, as the combine's own scene read does one pass later: the limiter is solved against a peak of 1.0, so a super-white tap would invert the bright side's sign. Nothing is lost, the blur chain already took its copy. - No denoise term. The deband dither it would exist to suppress is added downstream, in the combine's exit. Measured, 600 frames: Redridge overlook 2.95 -> 3.02 (Medium) -> 3.01 (High); Goldshire inn 3.46 -> 3.51 -> 3.29. Inside the noise, the inn reading *faster* at High being how you know. CPU flat. What it buys: 3.8% / 19.9% / 48.6% of the frame moved by more than 2/255. **The first run hit the trap this repo already has a note about.** `max(4.0 * mx, 1e-5)` where `mx` is a `vec3` is `error: inconsistent type passed as argument #2 to max`. naga refused the shader, the pass drew nothing, and the capture came out looking perfectly normal. One `grep -ciE 'error:'` on the capture log answered it before a single image was diffed. ## Field of view (`fieldOfView`, vertical degrees, 40..70, default 45) Field of View on the Graphics page under Render Scale, live as you drag. It is the BASE lens, not a fourth factor. `player::scoped_view` already multiplied two things in and neither is a field of view: SuperWoW's `FoV` is the reference's `[camera+0x40]` as a ratio, the spyglass is a zoom off normal. Both answer "how much wider than normal"; nothing a player could move answered what normal IS, and the answer was a `const` in `benilla-world`. So the term order is `base × FoV × zoom`, and an addon writing `FoV` still means the same fraction of the lens the player chose. Vertical degrees, not horizontal, which keeps the reference's Hor+ framing: a fixed vertical lens widens horizontally as the window does, so an ultrawide player gains picture at the sides instead of losing it off the top. The default is `CAM_FOVY` to the bit — `45f32.to_radians()` and `FRAC_PI_4` are both `0x3f490fdb` — which the defaults-mirror test now asserts on `to_bits()` rather than against a tolerance. An ulp of projection is a pixel in a capture diff, and a row that reframes the world for a player who never opened Options is not a row, it is a regression. ## Both lanes off: byte-identical Against a `git stash`ed build of the same tree: `overlook-noon` and `water-lake` identical, and `fieldOfView=45` written explicitly identical to the row never touched (40 and 65 move 97.6% and 99.3% of the frame). `inn-noon` is excluded WITH PROOF rather than quietly: two runs of the *same* binary differ on 37.8% of their pixels by up to 199, because that scene's flame emitters do not reproduce. Its own before/after delta is 0.00% of pixels at a maximum of 1. A scenario that cannot reproduce itself cannot be a control, and the number to check first is always the baseline against itself. ## Seats Six Options edits each, per the recipe, including the silent one (`OPTIONS_PAGE_ROWS`). Sharpening is a dropdown on Advanced Graphics with the other screen effects and reuses the existing `qOff/qLow/qMedium/qHigh` labels; Field of View is a `raw`-format slider on Graphics, whose label is literal because that page is not localised. Preset ladder: `sharpening` takes `ambientOcclusion`'s shape (`0 0 1 2 2`) because a look change starts at Medium in this file's convention; `fieldOfView` is on no rung at all, because it changes how the game feels to play and that is the player's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
405 lines
25 KiB
Markdown
405 lines
25 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 — 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 built** — see §9. It was the separable piece and it needed none of the
|
||
above, which is the whole reason it could land years ahead of the rest.
|
||
* 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 — **built**
|
||
|
||
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, but it alters how the game feels to
|
||
play — so it is a row a player moves, not a default and not a preset member.
|
||
|
||
`fieldOfView`, vertical degrees, 40 to 70, default **45**: Field of View on the Graphics page under
|
||
Render Scale, live as you drag. 40 reaches past the reference's own 44.1° so a player can sit
|
||
exactly on it; 70 is ~102° horizontal at 16:9, past which the edge stretch of a rectilinear
|
||
projection is the thing you see rather than the world.
|
||
|
||
Three things it had to get right:
|
||
|
||
- **Vertical degrees, not horizontal**, which is what keeps the reference's Hor+ framing: a fixed
|
||
vertical lens widens horizontally as the window does, so an ultrawide player gains picture at the
|
||
sides instead of losing it off the top. A row specified in horizontal degrees would be Vert- and
|
||
would quietly crop the taller aspect.
|
||
- **It is the base, not a fourth factor.** `player::scoped_view` already multiplied two things into
|
||
the lens and neither is a field of view: SuperWoW's `FoV` is the reference's `[camera+0x40]` as a
|
||
*ratio*, and the spyglass (aura 76) is a zoom off normal. Both answer "how much wider than
|
||
normal"; until this row nothing a player could move answered what normal *is*, and the answer was
|
||
`benilla-world`'s `CAM_FOVY` constant. So `fieldOfView` is the leftmost term — `base × FoV ×
|
||
zoom` — and an addon writing `FoV` still means the same fraction of the lens the player chose.
|
||
- **The default is `CAM_FOVY` to the bit.** `45f32.to_radians()` and `FRAC_PI_4` are the same f32
|
||
(`0x3f490fdb`), which the defaults-mirror test now asserts on `to_bits()` rather than against a
|
||
tolerance: an ulp of projection is a pixel in a capture diff, and a row that reframes the world
|
||
for a player who never opened Options is not a row, it is a regression. Verified from the other
|
||
side too — a capture with `fieldOfView=45` written explicitly is **byte-identical** to one with
|
||
the row never touched, and 40 and 65 move 97.6% and 99.3% of the frame.
|
||
|
||
### 9. Sharpening (RCAS) — **built**
|
||
|
||
`post::rcas`, cvar `sharpening` 0 Off / 1 Low / 2 Medium / 3 High, on the Graphics Preset ladder
|
||
from Medium, Sharpening on the Advanced Graphics page with the other screen effects.
|
||
|
||
RCAS is FSR 1.0's sharpening half taken on its own. It does not resample, so it wants none of the
|
||
upscaler's motion vectors, jitter or history — which is why it could land while §7 cannot. One
|
||
5-tap cross per pixel, and what makes it *robust* is that the lobe is not a constant: it is the
|
||
widest negative lobe that cannot push the result outside the ring's own range, solved per channel
|
||
against a peak of 1.0 and then taken as the most conservative of the three. Flat regions get
|
||
nothing, hard edges get as much as the signal carries, and nothing rings — which is the failure
|
||
every fixed unsharp mask has.
|
||
|
||
**The seat is most of the design.** It runs on the world view in `Core3d`, **after** `FfxGlowLabel`
|
||
and before `Node3d::Bloom`:
|
||
|
||
- **After the glow, not before.** `FfxGlowNode` builds the ¼-res blur chain the glow and the
|
||
underwater haze read, and a sharpened bloom source is exactly how an unsharp mask turns a lit
|
||
window into a ringed halo. Everything the blur needs is sampled before this pass runs.
|
||
- **On the world view only.** The world's last reader is the FFXGlow combine drawn inside the UI
|
||
camera's main pass, which binds `world_target.main_texture_view()` at node-run time, per frame,
|
||
precisely so a `post_process_write` upstream of it lands. The interface is drawn afterwards at
|
||
native resolution into a different target — and sharpening text is the one thing this must never
|
||
do.
|
||
- **Every tap saturates**, as the combine's own scene read does one pass later. The ring's limiter
|
||
is solved against a peak of 1.0, so a super-white tap would invert the bright-side limiter's
|
||
sign; and nothing is lost by it, because the blur chain already took its copy of the unclamped
|
||
image.
|
||
- **No denoise term** (FSR's own is optional): the deband dither whose grain it would exist to
|
||
suppress is added *downstream*, in the combine's exit, so there is nothing here to amplify.
|
||
|
||
**One stated limitation.** It sharpens at the world's RENDER resolution, which is output
|
||
resolution exactly at the default `renderScale` 1.0 and only there. Away from 1.0 the combine's
|
||
bilinear resample softens part of it back — the increased local contrast survives, the exact
|
||
texel-scale detail does not. FSR's own order is EASU then RCAS, i.e. sharpen in *output* space,
|
||
and the seat for that here is inside the FFXGlow combine itself, which is a reference-exact shader
|
||
this lane had no business perturbing for a first cut. Worth doing on the day §7 is: the combine
|
||
already samples the world texture to resample it, so the 5-tap cross would ride along for four
|
||
extra taps and no new pass at all.
|
||
|
||
**Measured.** `WOW_FPS_PROBE=600`, 600 frames each:
|
||
|
||
| scene | Off | Medium | High |
|
||
|---|---|---|---|
|
||
| Redridge overlook, noon | 2.95 ms | 3.02 | 3.01 |
|
||
| Goldshire inn, noon | 3.46 ms | 3.51 | **3.29** |
|
||
|
||
Inside the noise — the inn reads *faster* at High, which is how you know you are reading noise.
|
||
CPU is flat at 5.9-6.0 ms. What it buys, as the share of the frame that moved by more than 2/255:
|
||
**3.8% at Low, 19.9% at Medium, 48.6% at High**, peaking at a channel delta of 129.
|
||
|
||
**The first run caught the trap this file already warns about.** `max(4.0 * mx, 1e-5)` where `mx`
|
||
is a `vec3` is `error: inconsistent type passed as argument #2 to max`; naga refused the shader,
|
||
the pass drew nothing, and the capture came out looking perfectly normal. One
|
||
`grep -ciE 'error:'` on the capture log answered it before a single image was diffed.
|
||
|
||
**Lane off, byte-identical** on both deterministic scenarios (`overlook-noon`, `water-lake`)
|
||
against a `git stash`ed build of the same tree. `inn-noon` is **excluded, with proof**: two runs of
|
||
the *same* binary differ on 37.8% of their pixels by up to 199, because that scene's flame emitters
|
||
do not reproduce. Its before/after delta is 0.00% of pixels at a maximum of 1 — the bottom bit —
|
||
but a scenario that cannot reproduce itself cannot be a control, and the number to check first is
|
||
always the baseline against itself.
|
||
|
||
## 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.
|