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