Two cheap lanes: RCAS sharpening, and a field of view a player can move

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
This commit is contained in:
2026-10-27 16:56:54 -06:00
co-authored by Claude Opus 5
parent 280c2e886f
commit 11dda4f9cf
11 changed files with 620 additions and 22 deletions
+93 -8
View File
@@ -297,18 +297,103 @@ The plumbing, for when it *is* the right lever:
`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.
* **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
### 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. 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.
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