Files
benilla-octo/docs/GRAPHICS.md
T
gromandClaude Opus 5 848c840ecf SSILVB: the AO lane stops only darkening and starts bouncing light
`ambientOcclusion` 3 (Indirect Light), the Ultra rung. Tier 3 of the AO row
rather than a row of its own, because it is one trace: the bitmask that
measures occlusion properly is what produces the bounce, so a player choosing
between them would be choosing between two halves of the same thing.

Classic SSAO sums a cosine term per tap, so two occluders covering the same
direction count twice and a cluttered corner over-darkens. A bitmask counts
DIRECTIONS: each slice owns a 32-bit word of angular sectors, a tap sets the
run it spans, overlap is a no-op. And the bits a tap sets that were NOT already
set are exactly the share of the hemisphere it is the nearest thing in — i.e.
the fraction of the receiver's light it alone is responsible for. Its own pixel
colour, weighted by that, IS the one-bounce indirect term. No second trace, no
probes, no extra pass.

## The lane was already shaped for it

- `STAGE_AO` already bound the resolved scene colour, for the bright-pixel
  protection mask. That buffer is exactly what a screen-space gather reads.
- The apply needed no new pass and no dual-source blending: visibility in
  `src.a`, bounce in `src.rgb`, blend `One, SrcAlpha` = `bounce + dst*vis`.
- Only the half-res stages grew, by one attachment each, and the uniform's two
  spare floats carried the strength and thickness, so its layout never moved.

## The one part that is ours and not the paper's

**The bounce is scaled BY THE OCCLUSION.** A physically-measured bounce is
wrong here: 1.12's lighting already carries an ambient term standing in for
indirect light, so adding a measured one double-counts it. Unscaled it lifted a
daylight exterior's median from 85.7 to 101.3 and washed the frame flat. Scaled
by `occ`, the lane puts back only the share of the ambient the occlusion just
took away, in the occluder's colour. An open field gets nothing, because
nothing was removed there; a corner gets the colour of the wall shading it.

  Redridge overlook noon   median 85.7 -> 86.7
  Goldshire inn noon       median 26.7 -> 29.0

Neutral in daylight, +8.6% in the shaded room — the same shape as the
auto-exposure lane's floor of 1.0, for the same reason.

The gather runs in LINEAR (`c*c` in, `sqrt` out): the scene is stored in gamma,
and 0.5 gamma is 0.22 of the light, not half of it.

## Cost

Frame time is unchanged (inn 2.22 -> 2.21 ms over 600 frames; overlook 2.25 ->
2.11). That is the frame being CPU-bound, not the lane being free. The GPU
meter is the honest instrument: whole-frame GPU 1.68 -> 1.81 ms, and the bucket
these passes land in 0.40 -> 0.54. So **+0.14 ms of GPU over High and nothing
on the main thread** — which is why the Options text says "a fraction of a
millisecond" rather than what I first wrote before reading the meter.

## Two bugs, both found in the intermediate rather than the frame

- **Screen +y is view -y.** `view_at` flips clip y, so a slice direction built
  as `vec3(omega, 0.0)` measures its taps against a mirrored normal. One slice
  of two was right and the other inverted, averaging to A FLAT FLOOR READING
  50% OCCLUDED. `WOW_AO_DEBUG=4` showed it in one run; the composited frame
  never would have.
- **`a / b` and `a * (1.0 / b)` are not the same number.** Factoring the blur's
  divisor into one reciprocal — a tidy-up, not a change — moved `water-lake` at
  tier **1** by up to 53/255 on 0.021% of its pixels, because the AO is
  multiplied into the colour the water lane's depth-march then refines by
  bisection, and one ulp picks a different step. Restored to two divides.

## Control

Tiers 0-2 byte-identical to the build before this, on three deterministic
scenarios at three tiers each, nine of nine. `fence-shadowline-night` had to be
dropped mid-control, and it sharpens the determinism table added last commit:
it screens as deterministic at the DEFAULT tier and is not at tier 1. Screen a
scenario at the setting you are about to measure it at, not at its default.

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

32 KiB
Raw Blame History

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:

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.

And check the baseline against ITSELF before using it as one. Not every scenario reproduces, and the ones that do not are not obvious from the outside. Screened by rendering each twice with the same binary and once more over six runs:

scenario byte-identical run to run?
overlook-noon, water-lake, water-ocean, fence-shadowline-night yes
stormwind-canal-near no — bimodal: 7 of 15 pairs identical, the rest differ on 0.01% of pixels by up to 96/255
inn-noon, volfog-inn no — usually the bottom bit, but one pair came back 37.8% of pixels at up to 199

And the table is per SETTING, not per scenario. fence-shadowline-night sits in the first row only at the default ambientOcclusion 0; at tier 1 it differs from itself by the bottom bit, which cost half a control before it was noticed. Screen the scenario at the setting you are about to measure it at.

So a byte-identity claim can only rest on the first row. The second row is the dangerous one: the canal's own flicker reaches a channel delta of 96, which is exactly the amplitude a real finding there was once read at — amplitude alone says nothing, only extent does, and the canal's own noise is 0.01% of the frame.

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, the build is byte-identical to the one before it on water-lake — and getting there took two findings. (This was written up as holding for stormwind-canal-near too. It does not, and cannot: the canal is bimodal between runs of one binary, 7 of 15 pairs identical and the rest differing on 0.01% of pixels by up to 96/255 — see the harness section. The identity measured there was a coin flip that landed well. The finding below still stands on its extent, not on its amplitude: 0.75% of the frame is 75x the scene's own 0.01%, and the amplitude the two share — 96 — turns out to be the canal's own flicker and to mean nothing at all.)

  • 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 with the lane switched off, 75x the extent of its own run-to-run noise. So the whole system sits behind a run condition, not an early return. (Read the EXTENT: the 96/255 this was also quoted at is the canal's own flicker amplitude, which it reaches with nothing changed at all.) 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 stashed 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) — built

crates/benilla-app/src/ssao.rs, cvar ambientOcclusion 3 (Indirect Light), the Ultra rung, Ambient Occlusion on the Advanced Graphics page. Tiers 1 and 2 are untouched, down to the pipeline key.

It is tier 3 of the AO row rather than a row of its own because it is one trace. Classic SSAO sums a cosine term per tap, so two occluders covering the same direction are counted twice and a cluttered corner over-darkens. A bitmask counts DIRECTIONS instead: each slice through the view ray owns a 32-bit word whose bits are angular sectors of the receiver's hemisphere, a tap sets the run it spans, and overlap is a no-op because the bit was already set. Occlusion is the population count.

And that is what makes the colour half nearly free. The bits a tap sets that were not already set are exactly the share of the hemisphere it is the nearest thing in — the fraction of the receiver's incoming light that this tap, and nothing else, is responsible for. So the tap's own pixel colour, weighted by that fraction, is the one-bounce indirect term. No second trace, no probes, no extra pass. (Therrien et al., 2023.)

The lane was already shaped for it, which is why this landed in one sitting:

  • STAGE_AO already bound the resolved opaque scene colour, for the bright-pixel protection mask. That buffer is exactly what a screen-space gather reads; the plumbing existed for an unrelated reason.
  • The apply needed no new pass and no dual-source blending. Put visibility in src.a and the bounce in src.rgb and blend One, SrcAlpha: bounce + dst · vis, one draw into the same attachment the multiply used.
  • Only the half-res stages grew, by one Rgba16Float attachment each (2.9 MB a side at 1600x900), and the two spare floats on the existing uniform carried the strength and the thickness, so the uniform layout never moved.

The one thing that is ours and not the paper's: the bounce is scaled BY THE OCCLUSION. A physically-measured bounce is wrong on this renderer, because 1.12's lighting already carries an ambient term that stands in for indirect light everywhere — adding a measured one on top double-counts it. Unscaled it lifted a daylight exterior's median from 85.7 to 101.3 and washed the whole frame flat. Scaled by occ, the lane puts back only the share of the ambient the occlusion just took away, in the occluder's own colour: an open field gets nothing, because nothing was removed there, and a corner gets the colour of the wall that is actually shading it. Measured after:

scene median, High median, Indirect Light
Redridge overlook, noon 85.7 86.7
Goldshire inn, noon 26.7 29.0

Neutral in daylight, +8.6% in the shaded room — the same shape as the auto-exposure lane's floor of 1.0, and arrived at for the same reason.

The gather runs in LINEAR. The scene is stored in the reference's gamma convention, and adding gamma values overstates every mid tone: 0.5 gamma is 0.22 of the light, not half of it. c*c in and sqrt out is the gamma-2.0 approximation — cheap, monotone, exact at both ends.

Cost. Frame time is unchanged: Goldshire inn 2.22 → 2.21 ms over 600 frames, Redridge overlook 2.25 → 2.11. That is not the lane being free, it is the frame being CPU-bound; the GPU meter (/console fpsJournal 1, the gpu_other column, which is where these passes land) is the honest instrument:

whole frame GPU the AO passes
Off 1.60 ms 0.29 ms (everything else in the bucket)
High 1.68 ms 0.40 ms → +0.11
Indirect Light 1.81 ms 0.54 ms → +0.25

So the bitmask plus the bounce is +0.14 ms of GPU over High and nothing on the main thread — which by this file's own rule is nearly free, and is why the Options text says "a fraction of a millisecond" rather than what it said before the meter was read.

Two bugs, both found by looking at the intermediate rather than the frame (WOW_AO_DEBUG=4 is the raw occlusion, and 6 is the new indirect-colour view):

  • Screen +y is view −y. view_at flips clip y, so a slice direction built as vec3(omega, 0.0) measures its taps against a mirrored normal. One slice of two was right and the other inverted, which averaged to a flat floor reading 50% occluded — a uniform grey over the whole foreground that no amount of staring at the composited frame would have explained.
  • a / b and a * (1.0 / b) are not the same number. Factoring the blur's shared divisor into one reciprocal — a tidy-up, not a change — moved water-lake at tier 1 by up to 53/255 on 0.021% of its pixels. The AO is multiplied into the scene colour that the water lane's depth-march then refines by bisection, and one ulp picks a different step. Restored to two divides.

Tiers 0-2 byte-identical to the build before this, on all three deterministic scenarios at all three tiers (nine of nine). fence-shadowline-night had to be dropped mid-control and is the reason this file's determinism table now needs a caveat: it screens as deterministic at the default tier and is not at tier 1, where it differs from itself by the bottom bit. Determinism is a property of a scenario AND the settings it is rendered with, not of the scenario alone.

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 stashed 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 Nodes. 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.