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