Files
benilla-octo/crates/benilla-app/src/player/scoped_view.rs
T
gromandClaude Opus 5 11dda4f9cf 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
2026-10-27 16:56:54 -06:00

233 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The spyglass zoom, `SPELL_AURA_FAR_SIGHT` (aura 76): a client-local camera override, unlike the
//! server's `SPELL_AURA_BIND_SIGHT` far sight ([`super::view_subject`]), so no packet carries it.
//! For the local player only (`0x5fa6d0`), the aura watcher `0x604d00` routes to `0x5ff350` (add)
//! and `0x612320` (remove), which match each effect's `EffectApplyAuraName` (`SpellRec+0x16c`)
//! against 76 and call `0x50d320(camera, EffectMiscValue[i])`: first person, locked (camera flag
//! `0x8` makes `SetCameraView` return early), with `[camera+0x40] = n × π/180`; removal restores
//! `π/2` and unlocks. The Ornate Spyglass (item 5507, spell 12883) passes 15, a 6× zoom.
//!
//! The ratio is applied, not the degrees: `[camera+0x40]` defaults to 90°, while our [`CAM_FOVY`]
//! is the effective vertical field of view, 45° against the reference's measured 44.1°.
//!
//! **SuperWoW's `FoV` CVar rides the same ratio** ([`CameraFov`]), because it is the same field:
//! the mod's documented default is `1.57`, which is π/2 — the reference's own `[camera+0x40]`
//! default of 90° in radians. That identity is what this file's `REFERENCE_DEFAULT_DEGREES`
//! denominator already encodes, and it settles the question `docs/ADDON_COMPAT.md` held the row
//! back over: `FoV` is not a vertical field of view to be reconciled with 45°, it is the
//! reference's camera field, and shipping upstream's own default changes this client's lens by
//! exactly nothing. The two compose by multiplication, which is what the spyglass already means —
//! "a 6× zoom off whatever our normal FOV is", not a literal 15°.
use bevy::camera::{PerspectiveProjection, Projection};
use bevy::prelude::*;
use crate::net::{ObjectStore, SelfPlayer};
use crate::ui_action::Spells;
use benilla_world::view::{WorldCamera, CAM_FOVY};
/// `0x4c`, the aura name the reference's effect walk matches.
const SPELL_AURA_FAR_SIGHT: u32 = 76;
/// The reference's unzoomed `[camera+0x40]` in degrees (`π/2`, its constructor's value at
/// `0x50a706`), used only as a denominator.
const REFERENCE_DEFAULT_DEGREES: f32 = 90.0;
/// SuperWoW's `FoV`, as a fraction of the reference's own `[camera+0x40]` default: the CVar's
/// radians over π/2, so upstream's documented `1.57` is 1.0 and the lens does not move.
///
/// Clamped to SuperAPI's own slider bounds (0.1 to 3.14, `SuperAPIOptions.lua:103-105`), which is
/// also the widest range the mod's UI can produce. Upstream's panel says "Requires UI Reload";
/// this one is live, which is a strict improvement and the only deviation in the row.
#[derive(Resource)]
pub(crate) struct CameraFov(pub(crate) f32);
impl Default for CameraFov {
fn default() -> Self {
Self(1.0)
}
}
/// The player's own lens, in radians: the `fieldOfView` row, which is the BASE every factor in
/// this file multiplies rather than another factor beside them.
///
/// It exists because the two quantities already here are not a field of view. SuperWoW's `FoV` is
/// the reference's camera field as a ratio and the spyglass is a zoom off normal, so neither can
/// answer "how wide is the lens" — they answer "how much wider than normal", and `CAM_FOVY` was
/// the only thing that said what normal is. The reference's own lens follows the aspect (44.1° at
/// 16:9) where Bevy's `PerspectiveProjection` default, which this client inherited, is a flat 45°;
/// the row's own default is that 45°, to the bit, so seating it moves nothing.
///
/// VERTICAL degrees, not horizontal, which is the choice that 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. 45° vertical is ~73° horizontal at 16:9.
#[derive(Resource)]
pub(crate) struct BaseFov(pub(crate) f32);
impl Default for BaseFov {
/// [`CAM_FOVY`] exactly: `fieldOfView`'s registered default is this in degrees, and a
/// registered default never reaches an observer, so this value IS the live lens for a player
/// who never touched the row.
fn default() -> Self {
Self(CAM_FOVY)
}
}
/// The vertical degrees `fieldOfView` accepts, shared by the observer's clamp and the Options
/// slider. The low end reaches past the reference's own 44.1° so a player can sit exactly on it;
/// the high end is ~102° horizontal at 16:9, beyond which the edge stretch of a rectilinear
/// projection is the thing you see rather than the world.
pub(crate) const FIELD_OF_VIEW_DEGREES: std::ops::RangeInclusive<f32> = 40.0..=70.0;
/// The two camera-field rows: `FoV`'s radians over the reference's π/2, and `fieldOfView`'s
/// degrees as the base lens.
pub(crate) fn on_cvar(
ev: On<crate::cvars::CvarChanged>,
mut fov: ResMut<CameraFov>,
mut base: ResMut<BaseFov>,
) {
match ev.key().as_str() {
"fov" => fov.0 = ev.num().clamp(FOV_MIN, FOV_MAX) / std::f32::consts::FRAC_PI_2,
"fieldofview" => {
base.0 = ev
.num()
.clamp(*FIELD_OF_VIEW_DEGREES.start(), *FIELD_OF_VIEW_DEGREES.end())
.to_radians()
}
_ => {}
}
}
/// SuperAPI's slider bounds for `FoV`, in radians. Its own top is the literal `3.14`, which is
/// π to two places — written as the constant because clippy refuses the literal, and the
/// difference (0.0016 rad, a thousandth of the range) is below the slider's own 0.05 step.
const FOV_MIN: f32 = 0.1;
const FOV_MAX: f32 = std::f32::consts::PI;
/// The held scope as a fraction of the normal field of view. `None` is no override;
/// `Some(1.0)` would still lock first person.
#[derive(Resource, Default)]
pub(crate) struct ScopedView {
pub(crate) zoom: Option<f32>,
}
impl ScopedView {
/// While a scope is held the camera is locked in first person.
pub(crate) fn active(&self) -> bool {
self.zoom.is_some()
}
}
/// Drives the projection from aura 76 in our own aura slots only, as the reference gates it: the
/// aura field is public, so another player's spyglass must not zoom ours.
pub(super) fn apply_scoped_view(
self_q: Query<&ObjectStore, With<SelfPlayer>>,
spells: Option<Res<Spells>>,
mut scoped: ResMut<ScopedView>,
fov: Res<CameraFov>,
base: Res<BaseFov>,
mut projections: Query<&mut Projection, With<WorldCamera>>,
) {
let catalog = spells.as_ref().map(|s| &s.catalog);
scoped.zoom = self_q
.single()
.ok()
.zip(catalog)
.and_then(|(store, catalog)| {
store.0.unit_auras().find_map(|slot| {
let rec = catalog.get(slot.spell_id)?;
(0..3).find_map(|i| {
// The reference reads the misc value from the aura name's own effect index.
(rec.effect_apply_aura[i] == SPELL_AURA_FAR_SIGHT)
.then(|| rec.effect_misc_value[i] as f32)
})
})
})
// A zero misc value is the reference's own "restore" argument, not a zero-width view.
.filter(|&deg| deg > 0.0)
.map(|deg| deg / REFERENCE_DEFAULT_DEGREES);
let Ok(mut projection) = projections.single_mut() else {
return;
};
// The base lens times the scope, in that order: `FoV` moves what "normal" means and the
// spyglass is a ratio off normal, so they multiply. The player's own `fieldOfView` is what
// "normal" is measured FROM, which is why it is the leftmost term and not a fourth factor —
// an addon writing `FoV` still means the same fraction of the lens the player chose.
let want = base.0 * fov.0 * scoped.zoom.unwrap_or(1.0);
if let Projection::Perspective(PerspectiveProjection { fov, .. }) = &mut *projection {
if (*fov - want).abs() > f32::EPSILON {
*fov = want;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// SuperWoW's `FoV` is the reference's own `[camera+0x40]` as a ratio, so **upstream's own
/// documented default leaves the lens exactly where it is** — the fact that un-blocked the
/// row — and the spyglass still zooms by its 6× off whatever the base became.
#[test]
fn the_fov_cvar_is_a_ratio_whose_upstream_default_is_a_no_op() {
use std::f32::consts::FRAC_PI_2;
// The mod's documented default, through the observer's own arithmetic.
let ratio = |radians: f32| radians.clamp(FOV_MIN, FOV_MAX) / FRAC_PI_2;
assert!(
(ratio(1.57) - 1.0).abs() < 0.005,
"1.57 is the reference's 90 degrees in radians: a ratio of one"
);
assert_eq!(CameraFov::default().0, 1.0, "and the resource agrees");
assert!(
(CAM_FOVY * ratio(1.57) - CAM_FOVY).abs() < 1e-3,
"so an untouched client renders through exactly the lens it did before"
);
// A player who widens it gets a wider lens, clamped to SuperAPI's own slider bounds.
assert!(
ratio(FOV_MAX) > 1.99 && ratio(FOV_MAX) < 2.01,
"the slider's top is very nearly a doubling, since its own max is very nearly pi"
);
assert_eq!(ratio(99.0), ratio(FOV_MAX), "clamped, not unbounded");
assert_eq!(
ratio(0.0),
ratio(FOV_MIN),
"and a zero is not a zero-width view"
);
// The two compose: the spyglass is a ratio off whatever `FoV` made normal.
let scoped = 15.0 / REFERENCE_DEFAULT_DEGREES;
let wide = CAM_FOVY * ratio(FOV_MAX) * scoped;
assert!(
(wide - CAM_FOVY * ratio(FOV_MAX) / 6.0).abs() < 1e-6,
"a 6x zoom off the WIDENED base, not off 45 degrees"
);
}
#[test]
fn the_spyglass_narrows_our_own_field_of_view_six_fold() {
let scoped = ScopedView {
zoom: Some(15.0 / REFERENCE_DEFAULT_DEGREES),
};
let fov = CAM_FOVY * scoped.zoom.unwrap();
assert!(
(fov - CAM_FOVY / 6.0).abs() < 1e-6,
"the spyglass is a 6x zoom off whatever our normal FOV is, not a literal 15 degrees"
);
assert!(
(fov.to_degrees() - 7.5).abs() < 1e-4,
"…which lands at 7.5 degrees vertical for our 45 degree default, not 15"
);
assert!(scoped.active());
let none = ScopedView { zoom: None };
assert_eq!(
CAM_FOVY * none.zoom.unwrap_or(1.0),
CAM_FOVY,
"unscoped restores OUR default, never the reference's stored 90"
);
assert!(!none.active());
}
}