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
233 lines
10 KiB
Rust
233 lines
10 KiB
Rust
//! 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 > 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());
|
||
}
|
||
}
|