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:
@@ -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",
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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