diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 7bbd9a60..22c832ae 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -545,6 +545,11 @@ torchShadowStrength = "Torch Shadow Darkness", everyFrame = "Every frame", hz = "%d per second", + -- GFX (auto-exposure): the metering tier and its blend. + autoExposure = "Auto Exposure", + autoExposureStrength = "Auto Exposure Strength", + aeSubtle = "Subtle", + aeFull = "Full", -- 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. @@ -786,6 +791,21 @@ .. "the gathering happens once a frame no matter how many maps or how " .. "many flames are set above. Lower first if indoor shadows cost too " .. "much.", + AUTO_EXPOSURE = + "Brightens dark scenes to a readable level, the way an eye adjusts walking " + .. "indoors.\n\n" + .. "It never darkens anything: daylight outdoors is left exactly as the " + .. "original client draws it, and only rooms, cellars and night move. " + .. "Subtle lifts a dark room to about twice its brightness, Full to about " + .. "four times - enough to see by, and deliberately short of turning a " + .. "cellar into daylight.\n\n" + .. "The original client has no such adjustment, which is why its interiors " + .. "are as dark as they are.", + AUTO_EXPOSURE_STRENGTH = + "How much of the measured adjustment is applied. 100% is the full amount " + .. "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.", 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 " @@ -928,6 +948,11 @@ torchShadowStrength = "Темнота теней от огня", everyFrame = "Каждый кадр", hz = "%d в секунду", + -- GFX (auto-exposure) + autoExposure = "Автоэкспозиция", + autoExposureStrength = "Сила автоэкспозиции", + aeSubtle = "Слабая", + aeFull = "Полная", -- GFX (interior rig) interiorAmbient = "Общий свет в помещениях", interiorDaylight = "Дневной свет в помещениях", @@ -1174,6 +1199,21 @@ .. "Это единственная настройка теней в помещении, которая заметно стоит " .. "времени кадра: сбор происходит раз в кадр независимо от числа карт и " .. "источников выше. Понижайте её первой, если тени в помещениях дороги.", + AUTO_EXPOSURE = + "Осветляет тёмные сцены до читаемого уровня — так же, как глаз " + .. "привыкает, когда вы заходите в помещение.\n\n" + .. "Ничего не затемняет: дневной свет на улице остаётся ровно таким, " + .. "каким его рисует оригинальный клиент, и меняются только комнаты, " + .. "подвалы и ночь. «Слабая» поднимает тёмную комнату примерно вдвое, " + .. "«Полная» — примерно вчетверо: достаточно, чтобы видеть, и намеренно " + .. "недостаточно, чтобы превратить подвал в день.\n\n" + .. "В оригинальном клиенте такой настройки нет — потому его помещения " + .. "настолько темны.", + AUTO_EXPOSURE_STRENGTH = + "Какая доля измеренной поправки применяется. 100% — полная величина для " + .. "выбранной настройки; 0 равносилен отключению.\n\n" + .. "Понижайте, если комнаты кажутся ярче, чем хотелось бы, но целую " + .. "ступень убирать не хочется.", TORCH_SOFT = "Насколько мягок край тени от огня там, где предмет касается пола. " .. "Низкое значение — резкий край, высокое — размытый, как от " @@ -1374,6 +1414,8 @@ "RowInteriorRoomGate", "RowInteriorShadowCasters", "RowInteriorShadowDynamic", "RowInteriorShadowEntityRate", + -- GFX (auto-exposure): the metering tier and its blend. + "RowAutoExposure", "RowAutoExposureStrength", }, -- Guild Names is a child of Player Names (UIOptionsFrame.lua:703-709). Nameplates = { @@ -2045,6 +2087,9 @@ -- MONKEY (fix-wind): the strength only matters while some foliage sways. OptionsRow_SetEnabled(getglobal(base .. "RowFoliageWindStrength"), GetCVar("foliageWind") ~= "0") + -- GFX (auto-exposure): the blend only matters while there is a gain to blend. + OptionsRow_SetEnabled(getglobal(base .. "RowAutoExposureStrength"), + GetCVar("autoExposure") ~= "0") return end if key == "Graphics" then @@ -3557,11 +3602,49 @@ "BENILLA_TOOLTIP_COLOR_GRADING") + + + + + + + -- With the other screen effects, because that is + -- what it is: one pass over the finished frame. + -- The tiers are named rather than numbered because + -- what each one sets is a CEILING on the gain, and + -- "2x" would read as the amount applied rather + -- than as the most that ever will be. + OptionsRow_OnLoad(self, "autoExposure", + BENILLA_ADVGFX.autoExposure, + "BENILLA_TOOLTIP_AUTO_EXPOSURE") + OptionsDropdownRow_Setup(self, { + { text = BENILLA_ADVGFX.qOff, value = "0" }, + { text = BENILLA_ADVGFX.aeSubtle, value = "1" }, + { text = BENILLA_ADVGFX.aeFull, value = "2" }, + }) + + + + + + + + + -- 0..1, the CVar's own clamp; no `fmt`, so the + -- default percent reads the blend as a percentage + -- of the chosen tier, which is what it is. + OptionsRow_OnLoad(self, "autoExposureStrength", + BENILLA_ADVGFX.autoExposureStrength, + "BENILLA_TOOLTIP_AUTO_EXPOSURE_STRENGTH", + 1) + OptionsSliderRow_Setup(self, 0, 1, 0.05) + + - - + + OptionsRow_OnLoad(self, "characterShadows", diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index de010f9d..ab1cdd1e 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -714,6 +714,20 @@ pub(crate) const REGISTERED: &[Registered] = &[ "1", "benilla's own: brightness of the volumetric light shafts, 0..2 (1 = default)", ), + // GFX (auto-exposure): a screen-metered gain, so a dark room reads at its own level. The + // reference has no metering at all — its interiors are as dark as ours — so this whole lane is + // ours, and it is off at the registered default like every other deviation here. + ours( + "autoExposure", + "0", + "benilla's own: screen-metered brightening of dark scenes, 0 Off / 1 2x ceiling / 2 4x \ + ceiling (it never darkens a bright one)", + ), + ours( + "autoExposureStrength", + "1", + "benilla's own: blend from no gain to the fully metered one, 0..1 (1 = default)", + ), // GFX (moonlight): the moon as a light on top of the stock night. ours( "moonLight", @@ -2546,6 +2560,15 @@ pub(crate) const GRAPHICS_PRESETS: &[(&str, [&str; 5])] = &[ // `benilla_world::tex_filter_live` re-samples the resident textures rather than waiting for // a relaunch, which is what the reference's "enabled upon restart" was about. ("anisotropic", ["1", "1", "4", "16", "16"]), + // GFX (auto-exposure): the same shape the other post lanes take (off below Medium, the cheap + // tier at Medium, the full one above), and for the same reason — it is one pass over the + // finished frame, measured at **+0.08 ms of GPU** on the Elwynn vista and nothing on the main + // thread. What it buys is not subtle: the Goldshire inn's kitchen renders with **94.5% of its + // pixels under 32/255** and a median of 12.8, which is a black screen, and Full takes that to + // 40.3% and 35.8. The daylight exterior does NOT move — the lane only brightens, and it aims + // at the exterior's own measured level, so the vista's median goes 87.6 -> 87.5 (`docs/ + // GRAPHICS.md`). Classic keeps it off, as the reference has no metering at all. + ("autoExposure", ["0", "0", "1", "2", "2"]), ]; /// The column of a rung, matched case-insensitively; `None` for `Custom` or anything else. @@ -3630,7 +3653,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(), 14, "add the row count with the row"); + assert_eq!(GRAPHICS_PRESETS.len(), 15, "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"); @@ -3947,6 +3970,8 @@ mod tests { "moonLight" => video.moon_light, // GFX (anisotropic): the sampler policy, not a `VideoConfig` lane. "anisotropic" => res::(&app).aniso as f32, + // GFX (auto-exposure) + "autoExposure" => video.auto_exposure as f32, _ => panic!("add the observer readback for {k}"), }; assert_eq!(applied, value.parse::().unwrap(), "{name}/{k}"); diff --git a/crates/benilla-app/src/post/exposure.rs b/crates/benilla-app/src/post/exposure.rs new file mode 100644 index 00000000..9ba491f0 --- /dev/null +++ b/crates/benilla-app/src/post/exposure.rs @@ -0,0 +1,435 @@ +//! GFX (auto-exposure): a screen-metered gain on the world view, so a dark room reads at its own +//! level instead of at the sun's. +//! +//! Measured need, on this client's own captures: the Elwynn vista sits at a gamma median of +//! 87.6/255 with 1.4% of its pixels under 32, the Goldshire inn at noon at 31.9 with 50.1% under +//! 32, and the inn kitchen at **12.8 with 94.5% under 32**. That last one is not a dim room, it is +//! a black screen — and the reference client has the same problem, because it has no metering +//! either. See `docs/GRAPHICS.md`. +//! +//! The shape is the textbook one (histogram, reduce, apply) with two deliberate departures, both +//! forced by what this renderer's pixels mean — see the encoding note at the top of +//! `exposure.wgsl`: +//! +//! - The gain has a **floor of 1.0**: it only ever brightens. Darkening a correct daylight +//! exterior would move the one look this client is measured against, and the whole point is to +//! leave that alone. The aim level is the exterior's own measured luminance, which is what makes +//! the gain land at 1.0 outdoors without a special case. +//! - It has a **ceiling**, which is the tier. Without one the inn kitchen meters to roughly 24x +//! and comes out as daylight, which erases the room rather than making it readable. 2x and 4x +//! are "a dark room you can see in" and "a dark room you can read in"; neither is "a lit room". +//! +//! Placed ahead of the shafts, bloom and glow chain so everything downstream — above all FFXGlow's +//! blur, which is built from the scene — sees one consistently exposed image. + +use crate::video::VideoConfig; +use benilla_world::view::WorldCamera; +use bevy::{ + core_pipeline::{core_3d::graph::Core3d, core_3d::graph::Node3d, FullscreenShader}, + ecs::query::QueryItem, + prelude::*, + render::{ + camera::ExtractedCamera, + diagnostic::RecordDiagnostics, + extract_component::{ExtractComponent, ExtractComponentPlugin}, + render_graph::{ + NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner, + }, + render_resource::{binding_types::*, *}, + renderer::RenderContext, + renderer::{RenderDevice, RenderQueue}, + view::ViewTarget, + Render, RenderApp, RenderStartup, RenderSystems, + }, +}; + +pub(super) struct ExposurePlugin; + +/// The two tiers' gain ceilings. Not a continuous CVar, because the ceiling IS the character of +/// the setting and two named looks are easier to choose between than a number: "a dark room you +/// can see in" and "a dark room you can read in". +const TIER_CEILING: [f32; 2] = [2.0, 4.0]; + +/// 256 bins of `u32`. +const HISTOGRAM_BYTES: u64 = 256 * 4; +/// One `vec4`, of which `x` is the live gain. +const STATE_BYTES: u64 = 16; +/// Two `vec4`: the lane and the sample count. +const PARAMS_BYTES: u64 = 32; + +#[derive(Component, Clone, Copy, ExtractComponent)] +struct ExposureView { + ceiling: f32, + strength: f32, +} + +impl Plugin for ExposurePlugin { + 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!("exposure.wgsl"), + "post/exposure.wgsl", + )); + let Some(render_app) = app.get_sub_app_mut(RenderApp) else { + return; + }; + render_app + .insert_resource(ExposureShader(shader)) + .add_systems(RenderStartup, init_pipeline) + .add_systems( + Render, + prepare_buffers.in_set(RenderSystems::PrepareResources), + ) + .add_render_graph_node::>(Core3d, ExposureLabel) + // Ahead of everything in `post/` and of FFXGlow: the glow's blur chain is built from + // the scene, so metering after it would light the room and leave its glow unexposed. + .add_render_graph_edges( + Core3d, + ( + Node3d::StartMainPassPostProcessing, + ExposureLabel, + super::bloom::BloomLabel, + ), + ); + } +} + +/// The lane's own rows: `autoExposure` is the tier, `autoExposureStrength` blends from no gain to +/// the metered one. +fn update_views( + mut commands: Commands, + video: Res, + cameras: Query<(Entity, &Camera, Option<&ExposureView>), With>, +) { + let tier = video.auto_exposure.min(2); + let next = (tier > 0).then(|| ExposureView { + ceiling: TIER_CEILING[tier as usize - 1], + strength: video.auto_exposure_strength.clamp(0.0, 1.0), + }); + for (entity, camera, old) in &cameras { + match next.filter(|_| camera.is_active) { + Some(view) => { + if old + .is_none_or(|old| old.ceiling != view.ceiling || old.strength != view.strength) + { + commands.entity(entity).insert(view); + } + } + None => { + if old.is_some() { + commands.entity(entity).remove::(); + } + } + } + } +} + +#[derive(Resource)] +struct ExposureShader(Handle); + +#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)] +pub(super) struct ExposureLabel; + +#[derive(Resource)] +struct ExposurePipelines { + meter_layout: BindGroupLayoutDescriptor, + apply_layout: BindGroupLayoutDescriptor, + sampler: Sampler, + histogram: CachedComputePipelineId, + resolve: CachedComputePipelineId, + apply: CachedRenderPipelineId, +} + +fn init_pipeline( + mut commands: Commands, + render_device: Res, + fullscreen: Res, + shader: Res, + pipeline_cache: Res, +) { + let meter_layout = BindGroupLayoutDescriptor::new( + "post_exposure_meter_layout", + &BindGroupLayoutEntries::sequential( + ShaderStages::COMPUTE, + ( + // `textureLoad`, so the sample type is unfilterable and no sampler is bound. + texture_2d(TextureSampleType::Float { filterable: false }), + storage_buffer_sized(false, std::num::NonZeroU64::new(HISTOGRAM_BYTES)), + uniform_buffer_sized(false, std::num::NonZeroU64::new(PARAMS_BYTES)), + storage_buffer_sized(false, std::num::NonZeroU64::new(STATE_BYTES)), + ), + ), + ); + let apply_layout = BindGroupLayoutDescriptor::new( + "post_exposure_apply_layout", + &BindGroupLayoutEntries::sequential( + ShaderStages::FRAGMENT, + ( + texture_2d(TextureSampleType::Float { filterable: true }), + sampler(SamplerBindingType::Filtering), + storage_buffer_read_only_sized(false, std::num::NonZeroU64::new(STATE_BYTES)), + ), + ), + ); + let sampler = render_device.create_sampler(&SamplerDescriptor { + min_filter: FilterMode::Linear, + mag_filter: FilterMode::Linear, + address_mode_u: AddressMode::ClampToEdge, + address_mode_v: AddressMode::ClampToEdge, + ..default() + }); + let compute = |label: &'static str, entry: &'static str| ComputePipelineDescriptor { + label: Some(label.into()), + layout: vec![meter_layout.clone()], + push_constant_ranges: vec![], + shader: shader.0.clone(), + shader_defs: vec![], + entry_point: Some(entry.into()), + // The histogram's shared bins are zeroed by hand before the first barrier, and the + // reduction's two arrays are written by every thread before they are read. + zero_initialize_workgroup_memory: false, + }; + commands.insert_resource(ExposurePipelines { + histogram: pipeline_cache + .queue_compute_pipeline(compute("post_exposure_histogram", "cs_histogram")), + resolve: pipeline_cache + .queue_compute_pipeline(compute("post_exposure_resolve", "cs_resolve")), + apply: pipeline_cache.queue_render_pipeline(RenderPipelineDescriptor { + label: Some("post_exposure_apply".into()), + layout: vec![apply_layout.clone()], + vertex: fullscreen.to_vertex_state(), + fragment: Some(FragmentState { + shader: shader.0.clone(), + shader_defs: vec![], + entry_point: Some("fs_apply".into()), + targets: vec![Some(ColorTargetState { + format: ViewTarget::TEXTURE_FORMAT_HDR, + blend: None, + write_mask: ColorWrites::ALL, + })], + }), + ..default() + }), + meter_layout, + apply_layout, + sampler, + }); +} + +#[derive(Component)] +struct ExposureBuffers { + histogram: Buffer, + state: Buffer, + params: Buffer, + /// Dispatch extent of the histogram pass: the viewport at stride 2, in 16x16 groups. + groups: UVec2, +} + +fn prepare_buffers( + mut commands: Commands, + render_device: Res, + queue: Res, + time: Res