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:
+93
-8
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user