From 113572b8e23714b66f41e895d98e5f3b5115b7e7 Mon Sep 17 00:00:00 2001 From: grom Date: Mon, 26 Oct 2026 19:05:34 -0600 Subject: [PATCH] Fix: auto exposure was manufacturing bloom on ordinary bright surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/src/post/exposure.rs | 72 +++++++++++++++++++++++ crates/benilla-app/src/post/exposure.wgsl | 32 ++++++++-- 2 files changed, 99 insertions(+), 5 deletions(-) diff --git a/crates/benilla-app/src/post/exposure.rs b/crates/benilla-app/src/post/exposure.rs index 9ba491f0..837a36c3 100644 --- a/crates/benilla-app/src/post/exposure.rs +++ b/crates/benilla-app/src/post/exposure.rs @@ -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(1.0) + e / vec3(gain * gain)) / (vec3(1.0) + e)" + ), + "fs_apply's curve changed; update `toned` with it" + ); + assert!( + src.contains("let over = max(texel.rgb - vec3(1.0), vec3(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] diff --git a/crates/benilla-app/src/post/exposure.wgsl b/crates/benilla-app/src/post/exposure.wgsl index f06fb2db..29e90899 100644 --- a/crates/benilla-app/src/post/exposure.wgsl +++ b/crates/benilla-app/src/post/exposure.wgsl @@ -176,9 +176,31 @@ fn cs_resolve(@builtin(local_invocation_index) lid: u32) { @fragment fn fs_apply(in: FullscreenVertexOutput) -> @location(0) vec4 { 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(0.0), vec3(1.0))) * gain_state.value.x; - let over = max(texel.rgb - vec3(1.0), vec3(0.0)) * gain_state.value.x; - return vec4(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(0.0), vec3(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(1.0) + e / vec3(gain * gain)) / (vec3(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(1.0), vec3(0.0)); + return vec4(linear_to_srgb(toned) + over, texel.a); }