Fix: auto exposure was manufacturing bloom on ordinary bright surfaces
grom's play test, in Ironforge: "some edges, and seems to be only in
interiors, are shining really bright, like flashing colors". His config
has `autoExposure = "1"`, so the lane I landed last commit was live, and
"interiors only" is its exact signature — the gain is 1.0 outdoors by
construction and only ever rises indoors.
The apply pass was a plain multiply, and a plain multiply pushes ordinary
bright surfaces past the white point. Everything downstream reads a value
above 1.0 as EMISSIVE: `post/bloom.wgsl`'s extract is literally
`max(c - 1, 0)`, and FFXGlow squares its blur. So a lamp surround, gold
trim or a window frame sitting just under white crossed the line and grew
a bloom halo it had no business having — and because the gain drifts with
what is on screen, the set of pixels across that line kept changing and
the haloes FLASHED. It also scaled the HDR headroom itself, doubling the
bloom of everything that genuinely was emissive.
Measured, pixels at or above 250/255 — which is the bloom's fuel:
off Subtle Full
vista 0.338% 0.338% 0.338% (gain is 1.0: untouched)
inn, noon 0.024% 1.930% 9.779% ( 80x / 407x)
inn kitchen 0.001% 0.312% 1.549% (312x / 1549x)
Interiors only, exactly as reported.
The fix is a tone curve instead of a multiply: extended Reinhard with its
white point AT the gain. Three properties, each load-bearing — it is the
exact identity at gain 1 (`e(1+e)/(1+e) = e`), so a daylight exterior is
untouched; it maps the white point to white at every gain, so it can NEVER
produce a value above 1.0 and cannot manufacture an emissive pixel; and it
is monotonic, so highlights roll off toward white instead of flattening
onto it, which a hard clamp would have done. The HDR headroom now passes
through unscaled: it belongs to whatever was actually emissive, and how
bright the room is has no bearing on it.
After, same measurement at his tier: the inn goes 1.930% -> 0.388% and the
kitchen 0.312% -> 0.061%, while the brightening is kept whole (kitchen
median 35.8 -> 35.4, pixels under 32 40.3% -> 41.0%). What remains is
genuinely bright pixels rather than manufactured ones — a surface that was
never emissive now cannot bloom at all, because the picture path cannot
reach 1.0 from below.
`the_tone_curve_cannot_manufacture_an_emissive_pixel` pins all three
properties over the tier range, and a second test pins the Rust mirror to
the shader text so they cannot drift.
The exterior is a no-op to within 0.12% of pixels at mean |d| 0.16 — a few
dozen shadowed-foliage pixels where the gain lands a hair over 1.0. Below
anything visible, and stated rather than rounded away.
Verified: ALL GATES GREEN (290 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:
@@ -420,6 +420,78 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The tone curve the apply pass runs, mirrored: extended Reinhard with the white point at
|
||||
/// the gain. Kept in step with `fs_apply` by `the_tone_curve_matches_the_shader`.
|
||||
fn toned(lin: f32, gain: f32) -> f32 {
|
||||
let e = lin * gain;
|
||||
e * (1.0 + e / (gain * gain)) / (1.0 + e)
|
||||
}
|
||||
|
||||
/// The three properties the curve is chosen FOR, and the reason it is a curve and not a
|
||||
/// multiply. The second is the one that matters most: everything downstream reads a value
|
||||
/// above 1.0 as emissive (`post/bloom.wgsl`'s extract is `max(c - 1, 0)`), so a pass that can
|
||||
/// push an ordinary bright surface past the white point grows bloom haloes on things that
|
||||
/// were never emissive — and since the gain drifts with the scene, those haloes flash.
|
||||
#[test]
|
||||
fn the_tone_curve_cannot_manufacture_an_emissive_pixel() {
|
||||
// 1. An exact identity at gain 1, so a daylight exterior is untouched.
|
||||
for i in 0..=100 {
|
||||
let lin = i as f32 / 100.0;
|
||||
assert!(
|
||||
(toned(lin, 1.0) - lin).abs() < 1e-6,
|
||||
"gain 1 must be the identity, but {lin} -> {}",
|
||||
toned(lin, 1.0)
|
||||
);
|
||||
}
|
||||
for &gain in &[1.0, 1.5, TIER_CEILING[0], 3.0, TIER_CEILING[1], 8.0] {
|
||||
// 2. It never exceeds the white point, at any gain, for any input in range.
|
||||
for i in 0..=200 {
|
||||
let lin = i as f32 / 200.0;
|
||||
let out = toned(lin, gain);
|
||||
assert!(
|
||||
out <= 1.0 + 1e-5,
|
||||
"gain {gain}: {lin} -> {out}, which the bloom would read as emissive"
|
||||
);
|
||||
assert!(out >= -1e-6, "gain {gain}: {lin} -> {out}");
|
||||
}
|
||||
// ...and white maps to white, so a highlight is not darkened either.
|
||||
assert!(
|
||||
(toned(1.0, gain) - 1.0).abs() < 1e-5,
|
||||
"gain {gain}: white must stay white"
|
||||
);
|
||||
// 3. Monotonic, so highlights roll off toward white instead of flattening onto it.
|
||||
let mut last = -1.0;
|
||||
for i in 0..=200 {
|
||||
let out = toned(i as f32 / 200.0, gain);
|
||||
assert!(out > last, "gain {gain} is not monotonic at {i}");
|
||||
last = out;
|
||||
}
|
||||
// And a dark room still gets the whole gain: in the bottom of the range the curve is
|
||||
// linear to within a few percent, which is the half that has to keep working.
|
||||
let dark = toned(0.004, gain);
|
||||
assert!(
|
||||
dark > 0.004 * gain * 0.95,
|
||||
"gain {gain}: a dark room must still get its lift, got {dark}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Rust mirror above is only worth anything while it matches the shader.
|
||||
#[test]
|
||||
fn the_tone_curve_matches_the_shader() {
|
||||
let src = include_str!("exposure.wgsl");
|
||||
assert!(
|
||||
src.contains(
|
||||
"e * (vec3<f32>(1.0) + e / vec3<f32>(gain * gain)) / (vec3<f32>(1.0) + e)"
|
||||
),
|
||||
"fs_apply's curve changed; update `toned` with it"
|
||||
);
|
||||
assert!(
|
||||
src.contains("let over = max(texel.rgb - vec3<f32>(1.0), vec3<f32>(0.0));"),
|
||||
"the HDR headroom must pass through unscaled — scaling it amplifies the bloom"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
@@ -176,9 +176,31 @@ fn cs_resolve(@builtin(local_invocation_index) lid: u32) {
|
||||
@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);
|
||||
let gain = max(gain_state.value.x, 1.0);
|
||||
let lin = srgb_to_linear(clamp(texel.rgb, vec3<f32>(0.0), vec3<f32>(1.0)));
|
||||
|
||||
// Extended Reinhard with its white point AT the gain. Three properties, and every one of them
|
||||
// is load-bearing:
|
||||
//
|
||||
// * at gain 1 it is the exact identity — `e(1+e)/(1+e) = e` — so a daylight exterior comes
|
||||
// out bit for bit as it went in;
|
||||
// * it maps the white point to white at every gain (`f(gain) == 1`), so it NEVER produces a
|
||||
// value above 1.0;
|
||||
// * it is monotonic, so highlights roll off toward white instead of flattening onto it.
|
||||
//
|
||||
// The second one is why this is a curve and not a multiply. Everything downstream reads a
|
||||
// value above 1.0 as "emissive": `post/bloom.wgsl`'s extract is literally `max(c - 1, 0)`, and
|
||||
// FFXGlow squares its blur. A plain gain pushes every ordinary bright surface — a lamp
|
||||
// surround, gold trim, a window frame — across that line, so the bloom grows haloes on things
|
||||
// that were never emissive, and because the gain drifts with the scene the set of them keeps
|
||||
// changing and the haloes FLASH. Measured on the Goldshire inn at the 4x tier: pixels at or
|
||||
// above 250/255 went from 0.001% of the frame to 1.549%, while the exterior stayed at 0.338%
|
||||
// — which is exactly the "bright flashing edges, interiors only" this replaced.
|
||||
let e = lin * gain;
|
||||
let toned = e * (vec3<f32>(1.0) + e / vec3<f32>(gain * gain)) / (vec3<f32>(1.0) + e);
|
||||
|
||||
// The HDR headroom passes through UNSCALED. It belongs to whatever was genuinely emissive —
|
||||
// it is the bloom's input and the glow's — and how bright the room is has no bearing on it.
|
||||
let over = max(texel.rgb - vec3<f32>(1.0), vec3<f32>(0.0));
|
||||
return vec4<f32>(linear_to_srgb(toned) + over, texel.a);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user