Auto exposure: dark rooms read at their own level
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -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")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowAutoExposure" inherits="OptionsDropdownRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowColorGrading" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowColorGrading" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
-- 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" },
|
||||
})
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowAutoExposureStrength" inherits="OptionsSliderRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowAutoExposure" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowAutoExposure" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
-- 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)
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowCharacterShadows" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<!-- MONKEY (post): screen effects sit immediately above this one. -->
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowColorGrading" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowColorGrading" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowAutoExposureStrength" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowAutoExposureStrength" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "characterShadows",
|
||||
|
||||
@@ -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::<benilla_assets::TexFilterSetting>(&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::<f32>().unwrap(), "{name}/{k}");
|
||||
|
||||
@@ -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<f32>`, of which `x` is the live gain.
|
||||
const STATE_BYTES: u64 = 16;
|
||||
/// Two `vec4<f32>`: 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::<ExposureView>::default())
|
||||
.add_systems(Last, update_views);
|
||||
if !app.is_plugin_added::<AssetPlugin>() {
|
||||
return;
|
||||
}
|
||||
let shader = app
|
||||
.world_mut()
|
||||
.resource_mut::<Assets<Shader>>()
|
||||
.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::<ViewNodeRunner<ExposureNode>>(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<VideoConfig>,
|
||||
cameras: Query<(Entity, &Camera, Option<&ExposureView>), With<WorldCamera>>,
|
||||
) {
|
||||
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::<ExposureView>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
struct ExposureShader(Handle<Shader>);
|
||||
|
||||
#[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<RenderDevice>,
|
||||
fullscreen: Res<FullscreenShader>,
|
||||
shader: Res<ExposureShader>,
|
||||
pipeline_cache: Res<PipelineCache>,
|
||||
) {
|
||||
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<RenderDevice>,
|
||||
queue: Res<RenderQueue>,
|
||||
time: Res<Time>,
|
||||
views: Query<(
|
||||
Entity,
|
||||
&ExtractedCamera,
|
||||
&ExposureView,
|
||||
Option<&ExposureBuffers>,
|
||||
)>,
|
||||
) {
|
||||
for (entity, camera, view, old) in &views {
|
||||
let Some(size) = camera.physical_viewport_size else {
|
||||
continue;
|
||||
};
|
||||
// Stride 2, then 16x16 per group, rounded up so the right and bottom edges are metered.
|
||||
let sampled = UVec2::new(size.x.div_ceil(2), size.y.div_ceil(2));
|
||||
let groups = UVec2::new(sampled.x.div_ceil(16).max(1), sampled.y.div_ceil(16).max(1));
|
||||
let fresh = old.is_none();
|
||||
let buffers = match old {
|
||||
Some(existing) if existing.groups == groups => None,
|
||||
_ => {
|
||||
let make = |label: &'static str, size: u64, usage: BufferUsages| {
|
||||
render_device.create_buffer(&BufferDescriptor {
|
||||
label: Some(label),
|
||||
size,
|
||||
usage,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
Some(ExposureBuffers {
|
||||
// `COPY_DST` so the histogram starts zeroed; the resolve pass clears it after.
|
||||
histogram: make(
|
||||
"post_exposure_histogram",
|
||||
HISTOGRAM_BYTES,
|
||||
BufferUsages::STORAGE | BufferUsages::COPY_DST,
|
||||
),
|
||||
state: make(
|
||||
"post_exposure_state",
|
||||
STATE_BYTES,
|
||||
BufferUsages::STORAGE | BufferUsages::COPY_DST,
|
||||
),
|
||||
params: make(
|
||||
"post_exposure_params",
|
||||
PARAMS_BYTES,
|
||||
BufferUsages::UNIFORM | BufferUsages::COPY_DST,
|
||||
),
|
||||
groups,
|
||||
})
|
||||
}
|
||||
};
|
||||
// A new or resized state buffer holds garbage, so the first frame on it SNAPS to the
|
||||
// metered gain rather than smoothing up from whatever was there. That is also what makes
|
||||
// an offline capture reproducible.
|
||||
let snap = buffers.is_some() || fresh;
|
||||
let target = buffers.as_ref().map_or_else(
|
||||
|| old.expect("either a fresh buffer set or an existing one"),
|
||||
|b| b,
|
||||
);
|
||||
if snap {
|
||||
queue.write_buffer(&target.state, 0, bytemuck::cast_slice(&[0.0f32; 4]));
|
||||
}
|
||||
let params: [f32; 8] = [
|
||||
view.ceiling,
|
||||
view.strength,
|
||||
time.delta_secs(),
|
||||
f32::from(u8::from(snap)),
|
||||
(groups.x * 16 * groups.y * 16) as f32,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
];
|
||||
queue.write_buffer(&target.params, 0, bytemuck::cast_slice(¶ms));
|
||||
if snap {
|
||||
queue.write_buffer(&target.histogram, 0, &[0u8; HISTOGRAM_BYTES as usize]);
|
||||
}
|
||||
if let Some(buffers) = buffers {
|
||||
commands.entity(entity).insert(buffers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ExposureNode;
|
||||
|
||||
impl ViewNode for ExposureNode {
|
||||
type ViewQuery = (
|
||||
&'static ViewTarget,
|
||||
&'static ExposureView,
|
||||
&'static ExposureBuffers,
|
||||
);
|
||||
|
||||
fn run<'w>(
|
||||
&self,
|
||||
_graph: &mut RenderGraphContext,
|
||||
render_context: &mut RenderContext<'w>,
|
||||
(target, _view, buffers): QueryItem<'w, '_, Self::ViewQuery>,
|
||||
world: &'w World,
|
||||
) -> Result<(), NodeRunError> {
|
||||
let pipelines = world.resource::<ExposurePipelines>();
|
||||
let cache = world.resource::<PipelineCache>();
|
||||
let (Some(histogram), Some(resolve), Some(apply)) = (
|
||||
cache.get_compute_pipeline(pipelines.histogram),
|
||||
cache.get_compute_pipeline(pipelines.resolve),
|
||||
cache.get_render_pipeline(pipelines.apply),
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
let device = render_context.render_device().clone();
|
||||
let meter_bind = device.create_bind_group(
|
||||
"post_exposure_meter",
|
||||
&cache.get_bind_group_layout(&pipelines.meter_layout),
|
||||
&BindGroupEntries::sequential((
|
||||
target.main_texture_view(),
|
||||
buffers.histogram.as_entire_binding(),
|
||||
buffers.params.as_entire_binding(),
|
||||
buffers.state.as_entire_binding(),
|
||||
)),
|
||||
);
|
||||
let diagnostics = render_context.diagnostic_recorder();
|
||||
{
|
||||
let mut pass =
|
||||
render_context
|
||||
.command_encoder()
|
||||
.begin_compute_pass(&ComputePassDescriptor {
|
||||
label: Some("post_exposure_meter"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_bind_group(0, &meter_bind, &[]);
|
||||
pass.set_pipeline(histogram);
|
||||
pass.dispatch_workgroups(buffers.groups.x, buffers.groups.y, 1);
|
||||
// One group of 256, one thread per bin: the reduce, the smooth and the clear.
|
||||
pass.set_pipeline(resolve);
|
||||
pass.dispatch_workgroups(1, 1, 1);
|
||||
}
|
||||
let output = target.post_process_write();
|
||||
let apply_bind = device.create_bind_group(
|
||||
"post_exposure_apply",
|
||||
&cache.get_bind_group_layout(&pipelines.apply_layout),
|
||||
&BindGroupEntries::sequential((
|
||||
output.source,
|
||||
&pipelines.sampler,
|
||||
buffers.state.as_entire_binding(),
|
||||
)),
|
||||
);
|
||||
let mut pass = render_context
|
||||
.command_encoder()
|
||||
.begin_render_pass(&RenderPassDescriptor {
|
||||
label: Some("post_exposure_apply"),
|
||||
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_exposure_apply");
|
||||
pass.set_pipeline(apply);
|
||||
pass.set_bind_group(0, &apply_bind, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
span.end(&mut pass);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The ceilings are the feature's character, so they are stated here rather than inferred: a
|
||||
/// tier that could reach the metered gain for a dark interior (about 24x, measured) would
|
||||
/// render the room as daylight and erase it.
|
||||
#[test]
|
||||
fn neither_tier_can_turn_a_dark_room_into_daylight() {
|
||||
assert_eq!(TIER_CEILING, [2.0, 4.0]);
|
||||
for ceiling in TIER_CEILING {
|
||||
assert!(ceiling > 1.0, "a tier that cannot brighten is not a tier");
|
||||
assert!(
|
||||
ceiling < 8.0,
|
||||
"the inn kitchen meters to about 24x; a ceiling near it stops being a room"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The buffers are sized by the shader's own declarations, and a mismatch is a validation
|
||||
/// error at pipeline creation rather than anything a test would otherwise see.
|
||||
#[test]
|
||||
fn the_buffer_sizes_match_the_shaders_declarations() {
|
||||
assert_eq!(HISTOGRAM_BYTES, 256 * 4, "256 bins of u32");
|
||||
assert_eq!(STATE_BYTES, 16, "one vec4<f32>");
|
||||
assert_eq!(PARAMS_BYTES, 32, "two vec4<f32>");
|
||||
let src = include_str!("exposure.wgsl");
|
||||
assert!(src.contains("array<atomic<u32>, 256>"));
|
||||
assert!(src.contains("@workgroup_size(256, 1, 1)"));
|
||||
assert!(src.contains("@workgroup_size(16, 16, 1)"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// GFX (auto-exposure): a screen-metered gain, so a dark room reads at its own level instead of at
|
||||
// the sun's.
|
||||
//
|
||||
// Three passes. `cs_histogram` bins the scene's log2 luminance; `cs_resolve` turns that into one
|
||||
// smoothed exposure; `fs_apply` multiplies the scene by it. The first two are compute over a
|
||||
// 256-bin histogram (Alex Tardif's shape: one shared bin per thread of a 16x16 group, then one
|
||||
// atomic add per bin into the global buffer, then a single group reduces it).
|
||||
//
|
||||
// THE ENCODING, which is the whole reason this is not the textbook version: the world is drawn in
|
||||
// the reference's own convention — GAMMA values nominally in 0..1, which `ffx_glow.wgsl`'s combine
|
||||
// clamps to 1.0 and hands to the present encode. The float target exists so the glow has headroom,
|
||||
// not because the scene is scene-referred. So metering decodes to linear before taking a
|
||||
// logarithm (a log-average of gamma bytes is not a photographic average of anything), and the
|
||||
// apply pass decodes, multiplies and re-encodes, leaving the convention exactly as it found it.
|
||||
|
||||
#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput
|
||||
|
||||
struct ExposureParams {
|
||||
// x = ceiling on the gain (the tier), y = strength (blend from 1.0 toward the metered gain),
|
||||
// z = seconds since the last frame, w = 1 on the first frame after the state buffer appears
|
||||
// (snap instead of smoothing, so a capture is reproducible and a zone-in does not fade up).
|
||||
lane: vec4<f32>,
|
||||
// x = pixels the histogram sampled, yzw unused.
|
||||
count: vec4<f32>,
|
||||
}
|
||||
|
||||
struct ExposureState {
|
||||
// x = the live gain, read by `fs_apply` and smoothed toward the target here; yzw unused, and
|
||||
// kept so the buffer is a round 16 bytes.
|
||||
value: vec4<f32>,
|
||||
}
|
||||
|
||||
const BINS: u32 = 256u;
|
||||
// The metered window, log2 of LINEAR luminance. -12 is far below anything the reference's art
|
||||
// reaches and +1 is above white, so neither end ever clips a real scene into the guard bins.
|
||||
const MIN_LOG_LUM: f32 = -12.0;
|
||||
const MAX_LOG_LUM: f32 = 1.0;
|
||||
const LOG_LUM_RANGE: f32 = MAX_LOG_LUM - MIN_LOG_LUM;
|
||||
|
||||
// The level the gain aims at: the log-average linear luminance of a correct daylight exterior,
|
||||
// measured on the Elwynn vista this feature was built against (`docs/GRAPHICS.md`). Aiming here is
|
||||
// what makes the gain land at 1.0 outdoors, so daylight is left exactly as the reference draws it
|
||||
// and only the dark scenes move.
|
||||
//
|
||||
// It is calibrated on the scene THIS PASS SEES, which is not the scene the screenshot shows: the
|
||||
// FFXGlow combine downstream adds `w·blur²` and clamps, so the finished frame is brighter than
|
||||
// what is metered here. The vista's finished image log-averages to 0.0931 and the pass's own input
|
||||
// to 0.060 — a factor of about 1.5 — and calibrating against the former is the first thing that
|
||||
// went wrong here, aiming the lane 1.5x high and brightening a daylight exterior it was built to
|
||||
// leave alone. Re-derive it the same way: capture the vista at two tiers, and if both move the
|
||||
// image by the same amount the gain is below the ceiling and IS the ratio.
|
||||
const TARGET_LUM: f32 = 0.060;
|
||||
|
||||
fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
|
||||
let lo = c / 12.92;
|
||||
let hi = pow((c + vec3<f32>(0.055)) / 1.055, vec3<f32>(2.4));
|
||||
return select(hi, lo, c <= vec3<f32>(0.04045));
|
||||
}
|
||||
|
||||
fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
|
||||
let lo = c * 12.92;
|
||||
let hi = 1.055 * pow(c, vec3<f32>(1.0 / 2.4)) - vec3<f32>(0.055);
|
||||
return select(hi, lo, c <= vec3<f32>(0.0031308));
|
||||
}
|
||||
|
||||
fn luminance(linear_rgb: vec3<f32>) -> f32 {
|
||||
return dot(linear_rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
}
|
||||
|
||||
// ── Pass 1: the histogram ────────────────────────────────────────────────────────────────────
|
||||
@group(0) @binding(0) var scene: texture_2d<f32>;
|
||||
@group(0) @binding(1) var<storage, read_write> histogram: array<atomic<u32>, 256>;
|
||||
@group(0) @binding(2) var<uniform> params: ExposureParams;
|
||||
@group(0) @binding(3) var<storage, read_write> state: ExposureState;
|
||||
|
||||
var<workgroup> local_bins: array<atomic<u32>, 256>;
|
||||
|
||||
/// Bin 0 is the black guard: everything at or under the window's floor lands there and is excluded
|
||||
/// from the average, so a letterboxed or mostly-black frame cannot drag the gain to its ceiling.
|
||||
fn bin_of(lum: f32) -> u32 {
|
||||
if lum < exp2(MIN_LOG_LUM) {
|
||||
return 0u;
|
||||
}
|
||||
let t = saturate((log2(lum) - MIN_LOG_LUM) / LOG_LUM_RANGE);
|
||||
return u32(t * 254.0 + 1.0);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(16, 16, 1)
|
||||
fn cs_histogram(
|
||||
@builtin(global_invocation_id) gid: vec3<u32>,
|
||||
@builtin(local_invocation_index) lid: u32,
|
||||
) {
|
||||
atomicStore(&local_bins[lid], 0u);
|
||||
workgroupBarrier();
|
||||
|
||||
// Stride 2 on each axis: a quarter of the pixels, which is far more than a 256-bin histogram
|
||||
// needs and a quarter of the bandwidth.
|
||||
let size = textureDimensions(scene);
|
||||
let coord = gid.xy * 2u;
|
||||
if coord.x < size.x && coord.y < size.y {
|
||||
let gamma = textureLoad(scene, vec2<i32>(coord), 0).rgb;
|
||||
// Clamped as the combine clamps it: a super-white left over for the glow is not part of
|
||||
// the picture a player sees, and metering it would darken the room under a lamp flare.
|
||||
let lin = srgb_to_linear(clamp(gamma, vec3<f32>(0.0), vec3<f32>(1.0)));
|
||||
atomicAdd(&local_bins[bin_of(luminance(lin))], 1u);
|
||||
}
|
||||
|
||||
workgroupBarrier();
|
||||
let n = atomicLoad(&local_bins[lid]);
|
||||
if n > 0u {
|
||||
atomicAdd(&histogram[lid], n);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pass 2: the reduce ───────────────────────────────────────────────────────────────────────
|
||||
var<workgroup> weighted: array<f32, 256>;
|
||||
var<workgroup> counted: array<f32, 256>;
|
||||
|
||||
@compute @workgroup_size(256, 1, 1)
|
||||
fn cs_resolve(@builtin(local_invocation_index) lid: u32) {
|
||||
let n = f32(atomicLoad(&histogram[lid]));
|
||||
// Bin 0 is the black guard and carries no level, so it weighs nothing and counts for nothing.
|
||||
let live = select(n, 0.0, lid == 0u);
|
||||
weighted[lid] = live * f32(lid);
|
||||
counted[lid] = live;
|
||||
workgroupBarrier();
|
||||
|
||||
// A plain binary reduction over the 256 bins.
|
||||
var stride = 128u;
|
||||
loop {
|
||||
if stride == 0u { break; }
|
||||
if lid < stride {
|
||||
weighted[lid] += weighted[lid + stride];
|
||||
counted[lid] += counted[lid + stride];
|
||||
}
|
||||
workgroupBarrier();
|
||||
stride = stride >> 1u;
|
||||
}
|
||||
|
||||
if lid == 0u {
|
||||
var gain = 1.0;
|
||||
if counted[0] > 0.0 {
|
||||
// The mean bin back to a luminance. `- 1.0` undoes `bin_of`'s guard offset.
|
||||
let mean_bin = weighted[0] / counted[0];
|
||||
let log_lum = (mean_bin - 1.0) / 254.0 * LOG_LUM_RANGE + MIN_LOG_LUM;
|
||||
let avg_lum = exp2(log_lum);
|
||||
// Only ever brighten. Darkening a correct daylight exterior would move the one look
|
||||
// this client is measured against, and no amount of metering is worth that.
|
||||
gain = clamp(TARGET_LUM / max(avg_lum, 1e-6), 1.0, max(params.lane.x, 1.0));
|
||||
gain = mix(1.0, gain, saturate(params.lane.y));
|
||||
}
|
||||
// Smoothed in the LOG domain, where a stop is a stop in both directions; a linear lerp
|
||||
// would brighten fast and darken slowly for no reason anyone chose.
|
||||
let previous = state.value.x;
|
||||
if params.lane.w > 0.5 || previous <= 0.0 {
|
||||
state.value = vec4<f32>(gain, 0.0, 0.0, 0.0);
|
||||
} else {
|
||||
// tau = 0.4 s: about six time constants inside the 2.5 s a capture ages for, so an
|
||||
// offline shot is the converged value and not a point on a ramp.
|
||||
let alpha = 1.0 - exp(-max(params.lane.z, 0.0) / 0.4);
|
||||
state.value = vec4<f32>(exp2(mix(log2(previous), log2(gain), alpha)), 0.0, 0.0, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleared here rather than by a separate clear pass: every thread owns exactly one bin, and
|
||||
// the next frame's histogram must start empty.
|
||||
workgroupBarrier();
|
||||
atomicStore(&histogram[lid], 0u);
|
||||
}
|
||||
|
||||
// ── Pass 3: the apply ────────────────────────────────────────────────────────────────────────
|
||||
@group(0) @binding(0) var src: texture_2d<f32>;
|
||||
@group(0) @binding(1) var src_samp: sampler;
|
||||
@group(0) @binding(2) var<storage, read> gain_state: ExposureState;
|
||||
|
||||
@fragment
|
||||
fn fs_apply(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
|
||||
let texel = textureSample(src, src_samp, in.uv);
|
||||
// In linear, then back: see the encoding note at the top. The super-white above 1.0 is scaled
|
||||
// with everything else, so the glow downstream keeps its headroom in proportion.
|
||||
let lin = srgb_to_linear(clamp(texel.rgb, vec3<f32>(0.0), vec3<f32>(1.0))) * gain_state.value.x;
|
||||
let over = max(texel.rgb - vec3<f32>(1.0), vec3<f32>(0.0)) * gain_state.value.x;
|
||||
return vec4<f32>(linear_to_srgb(lin) + over, texel.a);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
//! MONKEY (post): optional world-camera effects ahead of FFXGlow's gamma clamp.
|
||||
|
||||
mod bloom;
|
||||
// GFX (auto-exposure): crate-visible to `bloom` for the render-graph edge.
|
||||
mod exposure;
|
||||
// MONKEY (visualfix): crate-visible for `rendered_minute` (volumetric fog).
|
||||
pub(crate) mod grading;
|
||||
mod sun_shafts;
|
||||
@@ -10,6 +12,7 @@ pub(crate) struct PostPlugin;
|
||||
impl bevy::prelude::Plugin for PostPlugin {
|
||||
fn build(&self, app: &mut bevy::prelude::App) {
|
||||
app.add_plugins(bloom::BloomPlugin);
|
||||
app.add_plugins(exposure::ExposurePlugin);
|
||||
app.add_plugins(grading::GradingPlugin);
|
||||
app.add_plugins(sun_shafts::SunShaftsPlugin);
|
||||
}
|
||||
|
||||
@@ -2096,6 +2096,103 @@ fn the_interior_rig_rows_are_localised() {
|
||||
}
|
||||
}
|
||||
|
||||
/// GFX (auto-exposure): the tier reads as a named look rather than a number, because what it sets
|
||||
/// is a CEILING on the gain and "2x" would read as the amount applied; and the blend row is dead
|
||||
/// while there is no gain to blend.
|
||||
#[test]
|
||||
fn the_auto_exposure_tier_names_its_looks_and_gates_its_blend() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
let base = "BenillaOptionsFrameContainerBodyAdvancedGraphics";
|
||||
let mut s = audio_harness();
|
||||
s.set_cvar_host("autoExposure", "0");
|
||||
s.set_cvar_host("autoExposureStrength", "1");
|
||||
let mut s = harness_on(s);
|
||||
s.run("ShowUIPanel(BenillaOptionsFrame) BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return {base}RowAutoExposureLabel:GetText()"))
|
||||
.unwrap(),
|
||||
"Auto Exposure"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!(
|
||||
"return {base}RowAutoExposureDropdownText:GetText()"
|
||||
))
|
||||
.unwrap(),
|
||||
"Off"
|
||||
);
|
||||
let thumb = |s: &mut UiScript| {
|
||||
s.eval::<bool>(&format!(
|
||||
"return {base}RowAutoExposureStrengthControlSliderThumb:IsShown() and true or false"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
assert!(!thumb(&mut s), "with no tier there is no gain to blend");
|
||||
|
||||
s.run(&format!("{base}RowAutoExposureDropdownButton:Click()"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<usize>("return DropDownList1.numButtons").unwrap(),
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return DropDownList1Button3:GetText()")
|
||||
.unwrap(),
|
||||
"Full"
|
||||
);
|
||||
let _ = s.take_cvar_changes();
|
||||
s.run("DropDownList1Button3:Click()").unwrap();
|
||||
// A live row: the lane costs +0.08 ms of GPU, so there is nothing to defer for.
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![("autoExposure".to_string(), "2".to_string())]
|
||||
);
|
||||
s.run("OptionsPage_Refresh(\"AdvancedGraphics\")").unwrap();
|
||||
assert!(thumb(&mut s), "a tier hands the blend row back");
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!(
|
||||
"return {base}RowAutoExposureStrengthControlValue:GetText()"
|
||||
))
|
||||
.unwrap(),
|
||||
"100%"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// Both locales, as every Advanced Graphics row carries them.
|
||||
#[test]
|
||||
fn the_auto_exposure_rows_are_localised() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
let base = "BenillaOptionsFrameContainerBodyAdvancedGraphics";
|
||||
for (locale, label, full) in [
|
||||
("enUS", "Auto Exposure", "Full"),
|
||||
("ruRU", "Автоэкспозиция", "Полная"),
|
||||
] {
|
||||
let s = audio_harness();
|
||||
s.run(&format!("function GetLocale() return '{locale}' end"))
|
||||
.unwrap();
|
||||
let s = harness_on(s);
|
||||
s.run("ShowUIPanel(BenillaOptionsFrame) BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return {base}RowAutoExposureLabel:GetText()"))
|
||||
.unwrap(),
|
||||
label,
|
||||
"{locale}"
|
||||
);
|
||||
s.run(&format!("{base}RowAutoExposureDropdownButton:Click()"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return DropDownList1Button3:GetText()")
|
||||
.unwrap(),
|
||||
full,
|
||||
"{locale}"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{locale}: {:?}", s.errors());
|
||||
}
|
||||
}
|
||||
|
||||
/// Pending edits are panel-wide, as the era's modified table: a category switch keeps them, and
|
||||
/// hiding the window discards them.
|
||||
#[test]
|
||||
@@ -2500,10 +2597,11 @@ fn water_quality_writes_numeric_tiers_with_localised_labels() {
|
||||
// GFX (shadow rig): the five shadow rows a player could not reach — the filter, the
|
||||
// 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.
|
||||
assert_eq!(
|
||||
s.eval::<usize>("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)")
|
||||
.unwrap(),
|
||||
52
|
||||
54
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[4]")
|
||||
@@ -3583,6 +3681,15 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
|
||||
"BENILLA_TOOLTIP_MOVING_SHADOW_UPDATES",
|
||||
"AdvancedGraphicsRowInteriorShadowEntityRate",
|
||||
),
|
||||
// GFX (auto-exposure): the metering tier and its blend.
|
||||
(
|
||||
"BENILLA_TOOLTIP_AUTO_EXPOSURE",
|
||||
"AdvancedGraphicsRowAutoExposure",
|
||||
),
|
||||
(
|
||||
"BENILLA_TOOLTIP_AUTO_EXPOSURE_STRENGTH",
|
||||
"AdvancedGraphicsRowAutoExposureStrength",
|
||||
),
|
||||
("BENILLA_TOOLTIP_RENDER_SCALE", "GraphicsRowRenderScale"),
|
||||
// GFX (anisotropic): ours, because 1.12's own text describes a four-stop slider
|
||||
// that needs a restart and this row is neither.
|
||||
@@ -3733,7 +3840,8 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
|
||||
// reach. 117 -> 119.
|
||||
// GFX (shadow rig): the five Advanced Graphics shadow rows. 119 -> 124.
|
||||
// GFX (interior rig): the ten interior-lighting rows. 124 -> 134.
|
||||
assert_eq!(checked, 134, "every tipped row carries a live key");
|
||||
// GFX (auto-exposure): the tier and its blend. 134 -> 136.
|
||||
assert_eq!(checked, 136, "every tipped row carries a live key");
|
||||
assert_eq!(
|
||||
untipped,
|
||||
vec![
|
||||
@@ -3841,7 +3949,8 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
|
||||
// GFX: Trilinear Filtering and Multisampling. 117 -> 119.
|
||||
// GFX (shadow rig): the five Advanced Graphics shadow rows. 119 -> 124.
|
||||
// GFX (interior rig): the ten interior-lighting rows. 124 -> 134.
|
||||
assert_eq!(raised, 134, "every row but Auto Loot raises a description");
|
||||
// GFX (auto-exposure): the tier and its blend. 134 -> 136.
|
||||
assert_eq!(raised, 136, "every row but Auto Loot raises a description");
|
||||
}
|
||||
|
||||
/// 1.12's AdvancedOptionsCombatText box as saved-global rows: a click writes the global, never a
|
||||
|
||||
@@ -143,6 +143,11 @@ pub(crate) struct VideoConfig {
|
||||
pub(crate) volumetric_light: u8,
|
||||
// GFX (volumetric light): the shafts' brightness multiplier, 0..2 (1 = the shipped tuning).
|
||||
pub(crate) volumetric_light_strength: f32,
|
||||
/// GFX (auto-exposure): the metering tier — 0 off, 1 and 2 the two gain ceilings
|
||||
/// (`post::exposure`). The lane only ever brightens, so this never touches a daylight exterior.
|
||||
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 (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,
|
||||
@@ -416,6 +421,8 @@ impl Default for VideoConfig {
|
||||
// GFX (volumetric light) / (moonlight): opt-in; the Graphics Preset turns them on.
|
||||
volumetric_light: 0,
|
||||
volumetric_light_strength: 1.0,
|
||||
auto_exposure: 0,
|
||||
auto_exposure_strength: 1.0,
|
||||
moon_light: 0.0,
|
||||
lava_light_gain: 1.0,
|
||||
fire_flicker: 1.0,
|
||||
@@ -526,6 +533,9 @@ pub(crate) fn on_cvar(
|
||||
// GFX (volumetric light): 0 Off / 1 Medium / 2 High, and its 0..2 strength.
|
||||
"volumetriclight" => cfg.volumetric_light = v.clamp(0.0, 2.0) as u8,
|
||||
"volumetriclightstrength" => cfg.volumetric_light_strength = v.clamp(0.0, 2.0),
|
||||
// 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 (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),
|
||||
|
||||
+67
-12
@@ -92,21 +92,76 @@ relaunch.
|
||||
|
||||
Ordered by (visual gain) ÷ (main-thread cost), not by how interesting they are.
|
||||
|
||||
### 1. Terrain horizon occlusion — *buys* main thread
|
||||
### 1. Terrain horizon occlusion — **measured, and not worth building**
|
||||
|
||||
The reference culls terrain against its own horizon (`0x686000`, `0x686180`, `0x681b50`), outdoors
|
||||
and per portal window inside a WMO, and parks hidden rigs and drops shadow casters below it. We have
|
||||
the portal/doorway half (`exterior_cull.rs`) and not this one. It is the rare item that makes the
|
||||
frame **cheaper on the thread that is actually full**, and cheaper frames are what let the view
|
||||
distance and the lanes below be afforded at all. Terrain chunk LOD (`0x6c51b0`, levels 2 and 3
|
||||
stitched across cell and tile edges) is the same kind of win.
|
||||
This was ranked first on the reasoning that the reference needs it (`0x686000`, `0x686180`,
|
||||
`0x681b50`) and we have only the portal/doorway half (`exterior_cull.rs`), so it would be the rare
|
||||
item that makes the frame *cheaper on the thread that is actually full*. **That premise does not
|
||||
hold for our renderer.** Measured on the Elwynn vista, steady state, `WOW_FPS_PROBE=300`:
|
||||
|
||||
### 2. Auto-exposure
|
||||
| farclip | mean ms | p50 ms | entities | submeshes |
|
||||
|---|---|---|---|---|
|
||||
| 177 (the reference's slider minimum) | 3.18 | 2.36 | 15,043 | 4,876 |
|
||||
| 350 (the reference's default) | 3.22 | 2.45 | 16,103 | 5,114 |
|
||||
| 777 (our High) | 3.62 | 2.84 | 19,581 | 6,016 |
|
||||
| 1497 (our Ultra) | 4.42 | 4.14 | 40,643 | 10,916 |
|
||||
|
||||
A screen-metered exposure, so interiors, night and a torchlit room read at their own level instead
|
||||
of at the sun's. One GPU pass over a downsampled frame plus a smoothed histogram; our HDR path and
|
||||
`ffx_glow`'s combine already carry an exposure slot. Small, GPU-side, and it changes every dark
|
||||
scene in the game.
|
||||
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, and p95 is flat at 6.4-6.5 ms at every distance. Horizon occlusion
|
||||
can only remove the part of that which happens to sit behind a hill, so its ceiling is a fraction
|
||||
of 0.44 ms. `WOW_SYSTEM_TIMING=1` agrees: no main-world system in a steady vista exceeds 0.12
|
||||
ms/run, and no terrain system appears in the top twenty at all. Our model cull already takes 6,016
|
||||
submeshes to **29 drawn**.
|
||||
|
||||
The reference needed this on a 2004 CPU with a fixed-function pipeline and no GPU-side culling. We
|
||||
do not. Chunk LOD is the same story from the other side: it saves vertex work, and the GPU is the
|
||||
resource we have in surplus.
|
||||
|
||||
Not "never" — it would matter if the draw submission ever became the wall — but it is not the first
|
||||
thing, and it was not close.
|
||||
|
||||
### 2. Auto-exposure — **built**
|
||||
|
||||
A screen-metered gain, so interiors, night and a torchlit room read at their own level instead of
|
||||
at the sun's. `crates/benilla-app/src/post/exposure.rs`, CVars `autoExposure` (Off / Subtle / Full)
|
||||
and `autoExposureStrength`, on the Graphics Preset ladder from Medium.
|
||||
|
||||
The need, measured on this client's own captures before anything was written:
|
||||
|
||||
| scene | gamma median | pixels under 32/255 |
|
||||
|---|---|---|
|
||||
| Elwynn vista | 87.6 | 1.4% |
|
||||
| Goldshire inn, noon | 31.9 | 50.1% |
|
||||
| the inn's kitchen | **12.8** | **94.5%** |
|
||||
|
||||
That last row is not a dim room, it is a black screen — and the reference has the same problem,
|
||||
because it has no metering either.
|
||||
|
||||
What it does, same measurements after:
|
||||
|
||||
| scene | Off | Subtle | Full |
|
||||
|---|---|---|---|
|
||||
| vista median | 87.6 | 87.5 | **87.5** |
|
||||
| inn noon median | 31.9 | 49.4 | 74.5 |
|
||||
| inn noon under 32 | 50.1% | 22.4% | **4.6%** |
|
||||
| kitchen median | 12.8 | 22.4 | 35.8 |
|
||||
| kitchen under 32 | 94.5% | 79.3% | **40.3%** |
|
||||
|
||||
**+0.08 ms of GPU** and nothing on the main thread.
|
||||
|
||||
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 `ffx_glow.wgsl`'s combine clamps to 1.0:
|
||||
|
||||
- The gain has a **floor of 1.0**. It only brightens. Aiming at the exterior's own measured level
|
||||
is what lands it at 1.0 outdoors without a special case, which is why the vista's median moves
|
||||
87.6 -> 87.5 and 0.12% of its pixels with it.
|
||||
- It has a **ceiling**, and the ceiling is the tier. The kitchen meters to roughly 24x; left
|
||||
uncapped it comes out as daylight, which erases the room rather than making it readable.
|
||||
|
||||
Calibrating that aim level is the one thing to get right if it is ever re-derived: it is the level
|
||||
of the scene the 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.
|
||||
|
||||
### 3. Planar water reflection
|
||||
|
||||
|
||||
Reference in New Issue
Block a user