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
+89 -2
View File
@@ -414,6 +414,16 @@
-- ── The setting pages: 1.12 rows in the era Settings grammar ──
-- Descriptions 1.12 lacks take a BENILLA_ prefix.
BENILLA_TOOLTIP_FIELD_OF_VIEW =
"How much of the world the camera takes in, measured top to bottom in degrees.\n\n"
.. "45 is what this client has always drawn - about 73 degrees across on a 16:9 "
.. "screen, where the original client's own lens is 44.1. Higher takes in more to "
.. "the sides and makes moving feel faster, at the cost of stretching the edges of "
.. "the screen; lower is a tighter, more zoomed view.\n\n"
.. "The width follows the height, so a wider monitor gains picture at the sides "
.. "rather than losing it off the top. Costs nothing to draw at any setting, and it "
.. "applies as you drag."
BENILLA_TOOLTIP_RENDER_SCALE =
"Draws the 3D world at a fraction of the window's resolution, or larger than it. "
.. "The interface is unaffected at any setting.\n\n"
@@ -551,6 +561,8 @@
autoExposureStrength = "Auto Exposure Strength",
aeSubtle = "Subtle",
aeFull = "Full",
-- GFX (sharpening): the tier reuses qOff/qLow/qMedium/qHigh above.
sharpening = "Sharpening",
-- GFX (interior rig): the ten rows of the interior lighting system a player
-- could not reach. Named for what they DO to a room, not for the quantity they
-- scale, since the quantity is the renderer's word and the room is the player's.
@@ -810,6 +822,18 @@
.. "for the chosen setting; 0 is the same as turning it off.\n\n"
.. "Lower it if rooms read brighter than you want them to without wanting "
.. "to drop a whole step.",
SHARPENING =
"Sharpens the 3D world. The interface is never sharpened, at any "
.. "setting.\n\n"
.. "It measures the surroundings of every pixel first and sharpens only "
.. "as hard as that spot can take, so edges come back crisp without the "
.. "pale outlines a plain sharpen filter leaves around everything. "
.. "Medium is the one to live on.\n\n"
.. "It is applied to the world at the size the world is drawn, so it is "
.. "at its crispest at 100% Render Scale. Away from 100% some of it is "
.. "softened back when the picture is resized to fit the window, but it "
.. "helps either way. Costs the graphics card a fraction of a "
.. "millisecond and the processor nothing.",
TORCH_SOFT =
"How soft the edge of a torch shadow is where the object meets the "
.. "ground. Low is hard and crisp; high spreads the edge the way a real "
@@ -958,6 +982,8 @@
autoExposureStrength = "Сила автоэкспозиции",
aeSubtle = "Слабая",
aeFull = "Полная",
-- GFX (sharpening)
sharpening = "Резкость",
-- GFX (interior rig)
interiorAmbient = "Общий свет в помещениях",
interiorDaylight = "Дневной свет в помещениях",
@@ -1221,6 +1247,19 @@
.. "выбранной настройки; 0 равносилен отключению.\n\n"
.. "Понижайте, если комнаты кажутся ярче, чем хотелось бы, но целую "
.. "ступень убирать не хочется.",
SHARPENING =
"Повышает резкость трёхмерного мира. Интерфейс не затрагивается ни при "
.. "одной настройке.\n\n"
.. "Сначала измеряется окружение каждой точки, и усиление идёт ровно "
.. "настолько, насколько это место выдерживает, — поэтому края "
.. "становятся чёткими без светлых контуров, которые обычный фильтр "
.. "резкости оставляет вокруг всего. «Среднее» — настройка для "
.. "постоянного использования.\n\n"
.. "Применяется к миру в том размере, в котором он нарисован, поэтому "
.. "самый чёткий результат — при масштабе отрисовки 100%. При других "
.. "значениях часть эффекта теряется при подгонке картинки под окно, но "
.. "польза остаётся в обе стороны. Видеокарте стоит доли миллисекунды, "
.. "процессору — ничего.",
TORCH_SOFT =
"Насколько мягок край тени от огня там, где предмет касается пола. "
.. "Низкое значение — резкий край, высокое — размытый, как от "
@@ -1361,6 +1400,8 @@
-- GFX (multisampling): with the other latched `gx*` rows it is committed beside.
"RowDisplayMode", "RowResolution", "RowVerticalSync", "RowMultisampling",
"RowRenderScale",
-- GFX (field of view): the lens, appended so the rows above keep their indices.
"RowFieldOfView",
},
-- MONKEY (advanced graphics): the dynamic light + shadow system's own page. Order is
-- the preset first (it writes the rest), then the SUN lanes, then the ROOM lane and
@@ -1423,6 +1464,8 @@
"RowInteriorShadowEntityRate",
-- GFX (auto-exposure): the metering tier and its blend.
"RowAutoExposure", "RowAutoExposureStrength",
-- GFX (sharpening): appended, as every lane above it was.
"RowSharpening",
},
-- Guild Names is a child of Player Names (UIOptionsFrame.lua:703-709).
Nameplates = {
@@ -3408,6 +3451,30 @@
self.deferred = 1
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowFieldOfView" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowRenderScale" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowRenderScale" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- Our own row, and not in the reference's slider
-- table, so it stays out of
-- BENILLA_GRAPHICS_SLIDER_ROWS as Render Scale
-- above it does. `raw`, because the groove's unit
-- IS the number: the default percent format would
-- read 45 degrees as "4500%".
--
-- NOT deferred: the lens is re-stamped onto the
-- projection every frame
-- (`player::scoped_view::apply_scoped_view`), so
-- a drag is the only way to pick a field of view
-- and nothing has to be rebuilt to honour it.
OptionsRow_OnLoad(self, "fieldOfView",
"Field of View",
"BENILLA_TOOLTIP_FIELD_OF_VIEW")
OptionsSliderRow_Setup(self, 40, 70, 1, "raw")
</OnLoad></Scripts>
</Frame>
</Frames>
<!-- Re-apply the bounds on show, as OptionsFrame_Load does, so
an addon's write lands. -->
@@ -3648,11 +3715,31 @@
OptionsSliderRow_Setup(self, 0, 1, 0.05)
</OnLoad></Scripts>
</Frame>
<!-- GFX (sharpening): RCAS tiers, seated with the other
screen effects rather than appended to the page's
bottom, which is where its list entry had to go. -->
<Frame name="$parentRowSharpening" inherits="OptionsDropdownRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowAutoExposureStrength" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowAutoExposureStrength" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "sharpening",
BENILLA_ADVGFX.sharpening,
"BENILLA_TOOLTIP_SHARPENING")
OptionsDropdownRow_Setup(self, {
{ text = BENILLA_ADVGFX.qOff, value = "0" },
{ text = BENILLA_ADVGFX.qLow, value = "1" },
{ text = BENILLA_ADVGFX.qMedium, value = "2" },
{ text = BENILLA_ADVGFX.qHigh, value = "3" },
})
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowCharacterShadows" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<!-- MONKEY (post): screen effects sit immediately above this one. -->
<Anchor point="TOPLEFT" relativeTo="$parentRowAutoExposureStrength" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowAutoExposureStrength" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPLEFT" relativeTo="$parentRowSharpening" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowSharpening" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "characterShadows",
+48 -1
View File
@@ -592,6 +592,17 @@ pub(crate) const REGISTERED: &[Registered] = &[
"1.57079",
"SuperWoW addon-compat, LIVE: the camera field as a ratio off the reference's own 90-degree default, so this value IS today's lens - see crate::player::scoped_view::CameraFov",
),
// GFX (field of view): the lens itself, which the row above is a RATIO off. Neither `FoV` nor
// the spyglass can express a field of view — they both answer "how much wider than normal" —
// so until this row there was nothing a player could move that said what normal is, and the
// answer was a `const` in `benilla-world`. Vertical degrees, which is what keeps the
// reference's Hor+ framing; the default is `CAM_FOVY` to the bit, so seating it moves nothing.
ours(
"fieldOfView",
"45",
"benilla's own: the vertical field of view in degrees, 40..70 (45 = this client's own \
lens, ~73 degrees horizontal at 16:9; the reference's is 44.1)",
),
// SuperWoW's other four bubble rows, read off the DLL's own string pool beside that one.
// Three are nampower's `NP_ChatBubbles*` under their unprefixed names and write the SAME
// `BubbleConfig` fields — two mods, one knob, last writer wins, as the nameplate reach
@@ -728,6 +739,16 @@ pub(crate) const REGISTERED: &[Registered] = &[
"1",
"benilla's own: blend from no gain to the fully metered one, 0..1 (1 = default)",
),
// GFX (sharpening): 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 or history. Off at the registered default,
// like every other deviation here: a player who never touched it keeps the reference's
// softness, and the Graphics Preset is what offers the change.
ours(
"sharpening",
"0",
"benilla's own: contrast-adaptive sharpening of the 3D world (never the interface), 0 Off \
/ 1 Low / 2 Medium / 3 High",
),
// GFX (moonlight): the moon as a light on top of the stock night.
ours(
"moonLight",
@@ -2561,6 +2582,12 @@ pub(crate) const GRAPHICS_PRESETS: &[(&str, [&str; 5])] = &[
// MONKEY (integration): the three rows that landed in round 3. Low keeps them off (AO and
// the lamp halos are GPU passes; the zone skybox is a look change), Medium takes the Low tiers.
("ambientOcclusion", ["0", "0", "1", "2", "2"]),
// GFX (sharpening): the same shape as the row above it, and for the same reason. Classic is
// the reference client, and Low's job is hardware that cannot afford what this machine can —
// which is not the argument here, since the pass is one 5-tap cross on a GPU two thirds idle,
// but sharpening is a LOOK change, and this file's convention is that a look change starts at
// Medium (`zoneSkyboxes` says so in as many words). High is left to the dropdown.
("sharpening", ["0", "0", "1", "2", "2"]),
("zoneSkyboxes", ["0", "0", "1", "1", "1"]),
("lampFog", ["0", "0", "1", "2", "2"]),
// GFX (volumetric light) / (moonlight): the shafts are a GPU pass (Medium = the cheap tier);
@@ -3277,6 +3304,24 @@ mod tests {
< 0.005,
"and the ratio the camera multiplies by is 1.0"
);
// GFX (field of view): the same argument one level down. `fieldOfView` is the lens the
// two ratios multiply, so its registered default has to BE the lens this client already
// drew — `benilla-world`'s `CAM_FOVY`, in degrees — or a player who never opened Options
// would have the picture reframed under them by the row's mere existence.
// Bit equality, not a tolerance: `45f32.to_radians()` and `FRAC_PI_4` are the same f32
// (`0x3f490fdb`), so the lens does not move even by an ulp when the row round-trips
// through `config.toml` — and an ulp of projection is a pixel in a capture diff.
assert_eq!(
d["fieldOfView"].to_radians().to_bits(),
benilla_world::view::CAM_FOVY.to_bits(),
"fieldOfView's default is CAM_FOVY in degrees, to the bit"
);
assert!(
(crate::player::scoped_view::BaseFov::default().0 - benilla_world::view::CAM_FOVY)
.abs()
< f32::EPSILON,
"and the resource a registered default never reaches agrees with it"
);
assert_eq!(
d["NameplateMotion"] != 0.0,
VPlateMode::default().spread,
@@ -3686,7 +3731,7 @@ mod tests {
/// disjointness that lets both labels be right at once.
#[test]
fn graphics_rows_are_registered_and_disjoint_from_the_lighting_ladder() {
assert_eq!(GRAPHICS_PRESETS.len(), 15, "add the row count with the row");
assert_eq!(GRAPHICS_PRESETS.len(), 16, "add the row count with the row");
let cvars = fresh_registry();
for (k, values) in GRAPHICS_PRESETS {
assert!(cvars.get(k).is_some(), "{k}: not registered");
@@ -4005,6 +4050,8 @@ mod tests {
"anisotropic" => res::<benilla_assets::TexFilterSetting>(&app).aniso as f32,
// GFX (auto-exposure)
"autoExposure" => video.auto_exposure as f32,
// GFX (sharpening)
"sharpening" => video.sharpening as f32,
_ => panic!("add the observer readback for {k}"),
};
assert_eq!(applied, value.parse::<f32>().unwrap(), "{name}/{k}");
+2
View File
@@ -238,6 +238,8 @@ impl Plugin for PlayerPlugin {
.init_resource::<scoped_view::ScopedView>()
// SuperWoW's `FoV`, the same `[camera+0x40]` ratio the spyglass is expressed in.
.init_resource::<scoped_view::CameraFov>()
// GFX (field of view): the player's own lens, which the two above are ratios OFF.
.init_resource::<scoped_view::BaseFov>()
.add_observer(scoped_view::on_cvar)
.add_systems(
Update,
+52 -6
View File
@@ -47,10 +47,53 @@ impl Default for CameraFov {
}
}
/// `FoV`'s own observer: the CVar's radians over the reference's π/2.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut fov: ResMut<CameraFov>) {
if ev.key() == "fov" {
fov.0 = ev.num().clamp(FOV_MIN, FOV_MAX) / std::f32::consts::FRAC_PI_2;
/// 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()
}
_ => {}
}
}
@@ -81,6 +124,7 @@ pub(super) fn apply_scoped_view(
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);
@@ -106,8 +150,10 @@ pub(super) fn apply_scoped_view(
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.
let want = CAM_FOVY * fov.0 * scoped.zoom.unwrap_or(1.0);
// 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;
+5 -2
View File
@@ -10,7 +10,7 @@ use benilla_assets::coords::wow_to_bevy;
use benilla_assets::{RenderConfig, WorldAssets};
use benilla_world::terrain_stream::SPAWN_XY;
use benilla_world::view::{WorldCamera, CAM_FAR, CAM_FOVY, NEARCLIP_DEFAULT};
use benilla_world::view::{WorldCamera, CAM_FAR, NEARCLIP_DEFAULT};
use super::{
CameraControl, FlyCam, MoveSpeed, Player, PlayerCapsule, CAM_DIST_DEFAULT, CAPSULE_HEIGHT,
@@ -57,6 +57,9 @@ pub(super) fn setup_player(
world_assets: Option<Res<WorldAssets>>,
// `gxMultisample` with `config.toml` folded in: this runs after [`crate::cvars::CvarLoad`].
msaa: Res<benilla_world::view::MsaaSetting>,
// GFX (field of view): likewise folded in by then. `apply_scoped_view` re-stamps the lens
// every frame, but only in world — a booth camera and the character screen keep this one.
base_fov: Res<super::scoped_view::BaseFov>,
) {
let env_speed = std::env::var("WOW_MOVE_SPEED")
.ok()
@@ -102,7 +105,7 @@ pub(super) fn setup_player(
// Frame zero only: `view::stamp_near_clip` re-stamps the live `nearclip` every frame,
// as `0x511bc0` overwrites the reference camera's `[cam+0x38]`.
near: NEARCLIP_DEFAULT,
fov: CAM_FOVY,
fov: base_fov.0,
..default()
}),
// A linear `Rgba16Float` target without tonemapping: the shaders light in clamped gamma
+3
View File
@@ -5,6 +5,8 @@ mod bloom;
mod exposure;
// MONKEY (visualfix): crate-visible for `rendered_minute` (volumetric fog).
pub(crate) mod grading;
// GFX (sharpening): RCAS, after the glow's filter passes.
mod rcas;
mod sun_shafts;
pub(crate) struct PostPlugin;
@@ -14,6 +16,7 @@ impl bevy::prelude::Plugin for PostPlugin {
app.add_plugins(bloom::BloomPlugin);
app.add_plugins(exposure::ExposurePlugin);
app.add_plugins(grading::GradingPlugin);
app.add_plugins(rcas::SharpenPlugin);
app.add_plugins(sun_shafts::SunShaftsPlugin);
}
}
+239
View File
@@ -0,0 +1,239 @@
//! GFX (sharpening): contrast-adaptive sharpening on the world view, after FFXGlow has built its
//! blur and before the backdrop combine reads the view's finished texture.
//!
//! The seat is the whole design. `renderScale` already decouples the world's resolution from the
//! window (`world_backdrop`), and 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, exactly so a `post_process_write` upstream of it lands. So a pass here reaches the image
//! a player sees and reaches nothing else: the interface is drawn afterwards at native resolution
//! into a different target, and sharpening text is the one thing this must never do.
//!
//! It runs AFTER [`FfxGlowLabel`] on purpose. That node builds the ¼-res blur chain the glow and
//! the underwater haze read, and a sharpened bloom source is how an unsharp mask turns a bright
//! window into a ringed halo. Everything the blur needs is sampled before this pass runs; the
//! combine then reads the sharpened colour as its `scene` term.
//!
//! cvar `sharpening`: 0 Off (no component, so the node never runs), 1 Low, 2 Medium, 3 High.
use crate::video::VideoConfig;
use benilla_world::{ffx_glow::FfxGlowLabel, view::WorldCamera};
use bevy::{
core_pipeline::{
core_3d::graph::{Core3d, Node3d},
FullscreenShader,
},
ecs::query::QueryItem,
prelude::*,
render::{
diagnostic::RecordDiagnostics,
extract_component::{ExtractComponent, ExtractComponentPlugin},
render_graph::{
NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner,
},
render_resource::{binding_types::*, *},
renderer::{RenderContext, RenderQueue},
view::ViewTarget,
RenderApp, RenderStartup,
},
};
pub(super) struct SharpenPlugin;
#[derive(Component, Clone, Copy, ExtractComponent)]
struct SharpenView {
tier: u8,
}
/// FSR's attenuation (`con.x`) per tier. Its own scale is in stops below maximum, so these are
/// `exp2(-2)`, `exp2(-1)` and `exp2(-1/3)`: Low is a hint, Medium is the one to live on, High is
/// as far as RCAS goes before its own limiter is the only thing holding the image together.
///
/// Nothing above `exp2(0)` = 1.0 exists to offer — the limiter, not the strength, is the ceiling.
const ATTENUATION: [f32; 3] = [0.25, 0.5, 0.7937];
impl Plugin for SharpenPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(ExtractComponentPlugin::<SharpenView>::default())
.add_systems(Last, update_views);
if !app.is_plugin_added::<AssetPlugin>() {
return;
}
let shader = app
.world_mut()
.resource_mut::<Assets<Shader>>()
.add(Shader::from_wgsl(
include_str!("rcas.wgsl"),
"post/rcas.wgsl",
));
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
return;
};
render_app
.insert_resource(SharpenShader(shader))
.add_systems(RenderStartup, init_pipeline)
.add_render_graph_node::<ViewNodeRunner<SharpenNode>>(Core3d, SharpenLabel)
// After the glow's filter passes, before anything Bevy still has to do with the
// target; the backdrop combine reads whatever is main by then.
.add_render_graph_edges(Core3d, (FfxGlowLabel, SharpenLabel, Node3d::Bloom));
}
}
fn update_views(
mut commands: Commands,
video: Res<VideoConfig>,
cameras: Query<(Entity, &Camera, Option<&SharpenView>), With<WorldCamera>>,
) {
let tier = video.sharpening.min(ATTENUATION.len() as u8);
for (entity, camera, old) in &cameras {
if tier == 0 || !camera.is_active {
if old.is_some() {
commands.entity(entity).remove::<SharpenView>();
}
} else if old.is_none_or(|old| old.tier != tier) {
commands.entity(entity).insert(SharpenView { tier });
}
}
}
#[derive(Resource)]
struct SharpenShader(Handle<Shader>);
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
struct SharpenLabel;
#[derive(Resource)]
struct SharpenPipeline {
layout: BindGroupLayoutDescriptor,
uniform: Buffer,
pipeline: CachedRenderPipelineId,
}
fn init_pipeline(
mut commands: Commands,
render_device: Res<bevy::render::renderer::RenderDevice>,
fullscreen: Res<FullscreenShader>,
shader: Res<SharpenShader>,
pipeline_cache: Res<PipelineCache>,
) {
let layout = BindGroupLayoutDescriptor::new(
"post_sharpen_layout",
&BindGroupLayoutEntries::sequential(
ShaderStages::FRAGMENT,
(
// Point-sampled through `textureLoad`: the cross is exactly one texel wide, and a
// filtered read would blend the neighbour it is supposed to be measured against.
texture_2d(TextureSampleType::Float { filterable: true }),
uniform_buffer_sized(false, std::num::NonZeroU64::new(16)),
),
),
);
let uniform = render_device.create_buffer(&BufferDescriptor {
label: Some("post_sharpen_uniform"),
size: 16,
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let pipeline = pipeline_cache.queue_render_pipeline(RenderPipelineDescriptor {
label: Some("post_sharpen".into()),
layout: vec![layout.clone()],
vertex: fullscreen.to_vertex_state(),
fragment: Some(FragmentState {
shader: shader.0.clone(),
shader_defs: vec![],
entry_point: Some("fs_sharpen".into()),
targets: vec![Some(ColorTargetState {
format: ViewTarget::TEXTURE_FORMAT_HDR,
blend: None,
write_mask: ColorWrites::ALL,
})],
}),
..default()
});
commands.insert_resource(SharpenPipeline {
layout,
uniform,
pipeline,
});
}
#[derive(Default)]
struct SharpenNode;
impl ViewNode for SharpenNode {
type ViewQuery = (&'static ViewTarget, &'static SharpenView);
fn run<'w>(
&self,
_graph: &mut RenderGraphContext,
render_context: &mut RenderContext<'w>,
(target, view): QueryItem<'w, '_, Self::ViewQuery>,
world: &'w World,
) -> Result<(), NodeRunError> {
let pipelines = world.resource::<SharpenPipeline>();
let cache = world.resource::<PipelineCache>();
// Still compiling: this frame goes through unsharpened rather than not at all.
let Some(pipeline) = cache.get_render_pipeline(pipelines.pipeline) else {
return Ok(());
};
let attenuation = ATTENUATION[usize::from(view.tier - 1).min(ATTENUATION.len() - 1)];
world.resource::<RenderQueue>().write_buffer(
&pipelines.uniform,
0,
bytemuck::cast_slice(&[attenuation, 0.0, 0.0, 0.0]),
);
let output = target.post_process_write();
let bind = render_context.render_device().create_bind_group(
"post_sharpen",
&cache.get_bind_group_layout(&pipelines.layout),
&BindGroupEntries::sequential((output.source, pipelines.uniform.as_entire_binding())),
);
let diagnostics = render_context.diagnostic_recorder();
let mut pass = render_context
.command_encoder()
.begin_render_pass(&RenderPassDescriptor {
label: Some("post_sharpen"),
color_attachments: &[Some(RenderPassColorAttachment {
view: output.destination,
depth_slice: None,
resolve_target: None,
ops: Operations::default(),
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
let span = diagnostics.pass_span(&mut pass, "post_sharpen");
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &bind, &[]);
pass.draw(0..3, 0..1);
span.end(&mut pass);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The CVar's own clamp is 0..3 and the table has three rows, so every reachable tier indexes
/// it — the lane's one arithmetic relationship between a registered bound and a `const` array.
#[test]
fn every_reachable_tier_indexes_the_attenuation_table() {
for tier in 1..=ATTENUATION.len() as u8 {
assert!(ATTENUATION[usize::from(tier - 1)] > 0.0);
}
assert_eq!(ATTENUATION.len(), 3, "Low, Medium, High");
}
/// FSR's scale is stops below maximum, and `exp2(0)` = 1.0 is that maximum. A tier past it
/// would not sharpen harder — the ring limiter is the ceiling — it would only be a lie.
#[test]
fn the_attenuations_rise_and_stay_inside_fsrs_own_scale() {
let mut last = 0.0;
for a in ATTENUATION {
assert!(a > last, "{a} does not rise above {last}");
assert!(a <= 1.0, "{a} is past FSR's own maximum");
last = a;
}
}
}
+68
View File
@@ -0,0 +1,68 @@
// GFX (sharpening): RCAS, AMD FidelityFX's Robust Contrast-Adaptive Sharpening, taken on its own.
//
// RCAS is the second half of FSR 1.0 and the only half that needs none of the first: it does not
// resample, so it wants no motion vectors, no jitter and no history. One 5-tap cross per pixel:
//
// b
// d e f out = (lobe·(b+d+f+h) + e) / (4·lobe + 1)
// h
//
// What makes it "robust" is that `lobe` is not a constant. It is the widest negative lobe that
// cannot push `out` outside the ring's own range, solved per channel from the ring's min and max
// against a peak of 1.0, then taken as the most conservative of the three. A flat region gets no
// sharpening, a hard edge gets the most the signal can carry, and nothing rings, which is the
// failure every fixed unsharp mask has.
#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput
@group(0) @binding(0) var scene: texture_2d<f32>;
struct Rcas {
// x = the attenuation, FSR's own `con.x` (`exp2(-stops)`, 1.0 being its maximum); yzw unused.
params: vec4<f32>,
}
@group(0) @binding(1) var<uniform> rcas: Rcas;
// FSR's `FSR_RCAS_LIMIT`, `0.25 - 1/16`: the widest lobe it will use at all. The resolve divides
// by `4·lobe + 1`, which at -0.25 is zero; the extra sixteenth is the guard band.
const RCAS_LIMIT: f32 = 0.1875;
// Every read saturates, as the FFXGlow combine's own scene read does one pass later
// (`sg = clamp(scene.rgb, 0, 1)`) — the float target does not clamp for us, and the ring's
// limiter is solved against a peak of 1.0, so a super-white tap would invert `hit_max`'s sign.
// Nothing is lost by clamping here: the glow's blur chain was built upstream of this pass, from
// the unclamped image, and is already in its own texture.
fn tap(p: vec2<i32>, size: vec2<i32>) -> vec3<f32> {
let texel = textureLoad(scene, clamp(p, vec2<i32>(0), size - vec2<i32>(1)), 0);
return clamp(texel.rgb, vec3<f32>(0.0), vec3<f32>(1.0));
}
@fragment
fn fs_sharpen(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
let p = vec2<i32>(in.position.xy);
let size = vec2<i32>(textureDimensions(scene));
// Scene alpha passes through untouched: the combine reads it for the create booth's
// transparent clear, and a sharpened coverage value would fringe the cut-out.
let centre = textureLoad(scene, clamp(p, vec2<i32>(0), size - vec2<i32>(1)), 0);
let e = clamp(centre.rgb, vec3<f32>(0.0), vec3<f32>(1.0));
let b = tap(p + vec2<i32>(0, -1), size);
let d = tap(p + vec2<i32>(-1, 0), size);
let f = tap(p + vec2<i32>(1, 0), size);
let h = tap(p + vec2<i32>(0, 1), size);
let mn = min(min(b, d), min(f, h));
let mx = max(max(b, d), max(f, h));
// The two limiters, each the lobe at which `out` would just reach an end of the range: the
// dark side against 0 and the bright side against the peak of 1. Both divisors are guarded
// rather than reciprocal-approximated — a black ring makes the first 0 and a white ring makes
// the second 0, and either would be a NaN that bloom then spreads across the frame.
let hit_min = mn / max(4.0 * mx, vec3<f32>(1e-5));
let hit_max = (1.0 - mx) / min(4.0 * mn - 4.0, vec3<f32>(-1e-5));
let per_channel = max(-hit_min, hit_max);
// FSR's `AMax3`: the LEAST negative channel wins, so the one channel nearest to clipping
// governs all three and the filter cannot pull a hue apart.
let widest = max(per_channel.r, max(per_channel.g, per_channel.b));
let lobe = max(-RCAS_LIMIT, min(widest, 0.0)) * rcas.params.x;
// No denoise term (FSR's is optional): the deband dither this lane's grain would come from is
// added downstream, in the combine's exit, so there is nothing here for it to amplify.
return vec4<f32>((lobe * (b + d + f + h) + e) / (4.0 * lobe + 1.0), centre.a);
}
@@ -2602,10 +2602,11 @@ fn water_quality_writes_numeric_tiers_with_localised_labels() {
// caster reach, the two update-rate caps and the torch darkness: 37 -> 42.
// GFX (interior rig): the ten interior-lighting rows: 42 -> 52.
// GFX (auto-exposure): the tier and its blend: 52 -> 54.
// GFX (sharpening): the RCAS tier: 54 -> 55.
assert_eq!(
s.eval::<usize>("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)")
.unwrap(),
54
55
);
assert_eq!(
s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[4]")
@@ -3696,7 +3697,16 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
"BENILLA_TOOLTIP_AUTO_EXPOSURE_STRENGTH",
"AdvancedGraphicsRowAutoExposureStrength",
),
// GFX (sharpening): RCAS, with the other screen effects.
(
"BENILLA_TOOLTIP_SHARPENING",
"AdvancedGraphicsRowSharpening",
),
("BENILLA_TOOLTIP_RENDER_SCALE", "GraphicsRowRenderScale"),
// GFX (field of view): the lens, under Render Scale on the Graphics page. Ours
// because 1.12 has no field-of-view setting at all — the reference's lens is a
// compiled-in constant.
("BENILLA_TOOLTIP_FIELD_OF_VIEW", "GraphicsRowFieldOfView"),
// GFX (anisotropic): ours, because 1.12's own text describes a four-stop slider
// that needs a restart and this row is neither.
("BENILLA_TOOLTIP_ANISOTROPIC", "GraphicsRowAnisotropic"),
@@ -3847,7 +3857,8 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// GFX (shadow rig): the five Advanced Graphics shadow rows. 119 -> 124.
// GFX (interior rig): the ten interior-lighting rows. 124 -> 134.
// GFX (auto-exposure): the tier and its blend. 134 -> 136.
assert_eq!(checked, 136, "every tipped row carries a live key");
// GFX (sharpening) / (field of view): one row each, on the two different pages. 136 -> 138.
assert_eq!(checked, 138, "every tipped row carries a live key");
assert_eq!(
untipped,
vec![
@@ -3956,7 +3967,7 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
// GFX (shadow rig): the five Advanced Graphics shadow rows. 119 -> 124.
// GFX (interior rig): the ten interior-lighting rows. 124 -> 134.
// GFX (auto-exposure): the tier and its blend. 134 -> 136.
assert_eq!(raised, 136, "every row but Auto Loot raises a description");
assert_eq!(raised, 138, "every row but Auto Loot raises a description");
}
/// 1.12's AdvancedOptionsCombatText box as saved-global rows: a click writes the global, never a
+7
View File
@@ -148,6 +148,9 @@ pub(crate) struct VideoConfig {
pub(crate) auto_exposure: u8,
/// GFX (auto-exposure): blend from no gain (0) to the fully metered one (1).
pub(crate) auto_exposure_strength: f32,
/// GFX (sharpening): RCAS tier — 0 off, 1 Low, 2 Medium, 3 High (`post::rcas`). A pure GPU
/// pass on the world view only; the interface is drawn afterwards and never sharpened.
pub(crate) sharpening: u8,
// GFX (moonlight): the additive moon term's multiplier, 0..2 (0 = the stock night exactly).
// Bridged to `benilla_world::lighting::MoonLight` by `dynamic_interior::bridge`.
pub(crate) moon_light: f32,
@@ -424,6 +427,8 @@ impl Default for VideoConfig {
volumetric_light_strength: 1.0,
auto_exposure: 0,
auto_exposure_strength: 1.0,
// GFX (sharpening): opt-in; the Graphics Preset takes it from Medium up.
sharpening: 0,
moon_light: 0.0,
lava_light_gain: 1.0,
fire_flicker: 1.0,
@@ -538,6 +543,8 @@ pub(crate) fn on_cvar(
// GFX (auto-exposure): the tier and its blend.
"autoexposure" => cfg.auto_exposure = v.clamp(0.0, 2.0) as u8,
"autoexposurestrength" => cfg.auto_exposure_strength = v.clamp(0.0, 1.0),
// GFX (sharpening): 0 Off / 1 Low / 2 Medium / 3 High.
"sharpening" => cfg.sharpening = v.clamp(0.0, 3.0) as u8,
// GFX (moonlight): 0 is meaningful (the stock night, bit for bit).
"moonlight" => cfg.moon_light = v.clamp(0.0, 2.0),
"lavalightgain" => cfg.lava_light_gain = v.clamp(0.0, 4.0),
+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