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); }