From 88b69984536eb1c8174fe950975d1a2bba57b84f Mon Sep 17 00:00:00 2001 From: grom Date: Mon, 26 Oct 2026 18:05:43 -0600 Subject: [PATCH] Auto exposure: dark rooms read at their own level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The programme's item 1 was terrain horizon occlusion, ranked first on the reasoning that it would make the frame cheaper on the thread that is actually full. **I measured it before building it, and the premise does not hold for this renderer**, so this is item 2 instead. The numbers are in `docs/GRAPHICS.md`: cutting the view distance from 777 yd to 177 — removing four fifths of the visible world — saves 0.44 ms of a 3.6 ms frame, p95 is flat at 6.4 ms at every distance, no main-world system in a steady vista exceeds 0.12 ms/run, and the model cull already takes 6,016 submeshes to 29 drawn. Horizon occlusion can only remove the fraction of that which sits behind a hill. The reference needed it on a 2004 CPU with no GPU-side culling; we do not. What the same harness said about item 2 was the opposite. Before anything was written: the Elwynn vista renders 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's kitchen at **12.8 with 94.5% under 32**. That is not a dim room, it is a black screen — and the reference has the same problem, because it has no metering either. A histogram-and-reduce over the HDR view target, smoothed in the log domain, applied ahead of the shafts, bloom and glow chain so FFXGlow's blur — which is built from the scene — sees the same exposed image everything else does. Two departures from the textbook version, both forced by what this renderer's pixels mean (the world is drawn in the reference's gamma convention, which the combine clamps to 1.0): - The gain has a **floor of 1.0**: it only ever brightens. Aiming at the exterior's own measured level is what lands it at 1.0 outdoors with no special case — the vista's median moves 87.6 -> 87.5, and 0.12% of its pixels with it. Daylight is the one look this client is measured against and nothing here was worth moving it. - It has a **ceiling**, which is the tier. The kitchen meters to roughly 24x; uncapped it comes out as daylight, which erases the room instead of making it readable. Subtle and Full are 2x and 4x: "a dark room you can see in" and "one you can read in", neither "a lit room". Measured after: inn noon goes 50.1% -> 4.6% of pixels under 32, the kitchen 94.5% -> 40.3%, and the vista does not move. **+0.08 ms of GPU**, nothing on the main thread. The calibration is the one thing to get right if this is ever re-derived, and it is written down because I got it wrong first: the aim level is the level of the scene THIS PASS SEES, not of the screenshot. The glow adds `w·blur²` downstream, so the finished vista log-averages to 0.0931 linear and the pass's own input to 0.060. Calibrating against the former aimed the lane 1.5x high and brightened the daylight exterior it exists to leave alone — which the vista capture caught immediately. Two rows on Advanced Graphics, both locales, the blend greyed while there is no gain to blend; on the Graphics Preset ladder from Medium, in the shape the other post lanes take. Classic keeps it off, as the reference has no metering at all. Verified: ALL GATES GREEN (550 s); `benilla-app --lib` against the install at the exact 269-failure baseline, set-identical by `comm`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/assets/ui/OptionsFrame.xml | 87 +++- crates/benilla-app/src/cvars.rs | 27 +- crates/benilla-app/src/post/exposure.rs | 435 ++++++++++++++++++ crates/benilla-app/src/post/exposure.wgsl | 184 ++++++++ crates/benilla-app/src/post/mod.rs | 3 + .../src/ui_script/options_tests.rs | 115 ++++- crates/benilla-app/src/video.rs | 10 + docs/GRAPHICS.md | 79 +++- 8 files changed, 922 insertions(+), 18 deletions(-) create mode 100644 crates/benilla-app/src/post/exposure.rs create mode 100644 crates/benilla-app/src/post/exposure.wgsl 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