diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index 039b9e7c..b6f5186b 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -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
+
+
+
+
+
+
+ -- 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")
+
+
@@ -3648,11 +3715,31 @@
OptionsSliderRow_Setup(self, 0, 1, 0.05)
+
+
+
+
+
+
+
+ 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" },
+ })
+
+
-
-
+
+
OptionsRow_OnLoad(self, "characterShadows",
diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs
index 493807ff..c96eb746 100644
--- a/crates/benilla-app/src/cvars.rs
+++ b/crates/benilla-app/src/cvars.rs
@@ -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::(&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::().unwrap(), "{name}/{k}");
diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs
index e6343782..49d58aaa 100644
--- a/crates/benilla-app/src/player.rs
+++ b/crates/benilla-app/src/player.rs
@@ -238,6 +238,8 @@ impl Plugin for PlayerPlugin {
.init_resource::()
// SuperWoW's `FoV`, the same `[camera+0x40]` ratio the spyglass is expressed in.
.init_resource::()
+ // GFX (field of view): the player's own lens, which the two above are ratios OFF.
+ .init_resource::()
.add_observer(scoped_view::on_cvar)
.add_systems(
Update,
diff --git a/crates/benilla-app/src/player/scoped_view.rs b/crates/benilla-app/src/player/scoped_view.rs
index a86ba920..b93e9269 100644
--- a/crates/benilla-app/src/player/scoped_view.rs
+++ b/crates/benilla-app/src/player/scoped_view.rs
@@ -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, mut fov: ResMut) {
- 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 = 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,
+ mut fov: ResMut,
+ mut base: ResMut,
+) {
+ 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>,
mut scoped: ResMut,
fov: Res,
+ base: Res,
mut projections: Query<&mut Projection, With>,
) {
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;
diff --git a/crates/benilla-app/src/player/setup.rs b/crates/benilla-app/src/player/setup.rs
index 3b3e137f..fc75ca3b 100644
--- a/crates/benilla-app/src/player/setup.rs
+++ b/crates/benilla-app/src/player/setup.rs
@@ -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>,
// `gxMultisample` with `config.toml` folded in: this runs after [`crate::cvars::CvarLoad`].
msaa: Res,
+ // 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,
) {
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
diff --git a/crates/benilla-app/src/post/mod.rs b/crates/benilla-app/src/post/mod.rs
index d128cd82..69604a89 100644
--- a/crates/benilla-app/src/post/mod.rs
+++ b/crates/benilla-app/src/post/mod.rs
@@ -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);
}
}
diff --git a/crates/benilla-app/src/post/rcas.rs b/crates/benilla-app/src/post/rcas.rs
new file mode 100644
index 00000000..da5c052f
--- /dev/null
+++ b/crates/benilla-app/src/post/rcas.rs
@@ -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::::default())
+ .add_systems(Last, update_views);
+ if !app.is_plugin_added::() {
+ return;
+ }
+ let shader = app
+ .world_mut()
+ .resource_mut::>()
+ .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::>(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,
+ cameras: Query<(Entity, &Camera, Option<&SharpenView>), With>,
+) {
+ 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::();
+ }
+ } else if old.is_none_or(|old| old.tier != tier) {
+ commands.entity(entity).insert(SharpenView { tier });
+ }
+ }
+}
+
+#[derive(Resource)]
+struct SharpenShader(Handle);
+
+#[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,
+ fullscreen: Res,
+ shader: Res,
+ pipeline_cache: Res,
+) {
+ 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::();
+ let cache = world.resource::();
+ // 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::().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;
+ }
+ }
+}
diff --git a/crates/benilla-app/src/post/rcas.wgsl b/crates/benilla-app/src/post/rcas.wgsl
new file mode 100644
index 00000000..c03f9391
--- /dev/null
+++ b/crates/benilla-app/src/post/rcas.wgsl
@@ -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;
+struct Rcas {
+ // x = the attenuation, FSR's own `con.x` (`exp2(-stops)`, 1.0 being its maximum); yzw unused.
+ params: vec4,
+}
+@group(0) @binding(1) var 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, size: vec2) -> vec3 {
+ let texel = textureLoad(scene, clamp(p, vec2(0), size - vec2(1)), 0);
+ return clamp(texel.rgb, vec3(0.0), vec3(1.0));
+}
+
+@fragment
+fn fs_sharpen(in: FullscreenVertexOutput) -> @location(0) vec4 {
+ let p = vec2(in.position.xy);
+ let size = vec2(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(0), size - vec2(1)), 0);
+ let e = clamp(centre.rgb, vec3(0.0), vec3(1.0));
+ let b = tap(p + vec2(0, -1), size);
+ let d = tap(p + vec2(-1, 0), size);
+ let f = tap(p + vec2(1, 0), size);
+ let h = tap(p + vec2(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(1e-5));
+ let hit_max = (1.0 - mx) / min(4.0 * mn - 4.0, vec3(-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((lobe * (b + d + f + h) + e) / (4.0 * lobe + 1.0), centre.a);
+}
diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs
index eacc1d34..6f7433c4 100644
--- a/crates/benilla-app/src/ui_script/options_tests.rs
+++ b/crates/benilla-app/src/ui_script/options_tests.rs
@@ -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::("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)")
.unwrap(),
- 54
+ 55
);
assert_eq!(
s.eval::("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
diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs
index 5b8fd4ab..64275bd7 100644
--- a/crates/benilla-app/src/video.rs
+++ b/crates/benilla-app/src/video.rs
@@ -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),
diff --git a/docs/GRAPHICS.md b/docs/GRAPHICS.md
index a043926a..fa2c7ee6 100644
--- a/docs/GRAPHICS.md
+++ b/docs/GRAPHICS.md
@@ -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