Skip to main content

concinnity_core/gfx/auto_exposure/
state.rs

1// The per-frame exponential-moving-average update: it takes a GPU-measured
2// average log-luminance and produces the next frame's exposure multiplier. The
3// histogram itself is built in the backend's compute shader; only the reduction
4// and the EMA live here, so both can be unit-tested without a GPU.
5
6use super::AutoExposureSettings;
7use crate::math::exp;
8
9/// Lowest log2(luminance) the histogram bins span. Pixels darker than this fall
10/// in bin 0. Roughly matches a moonlit interior at the dim end.
11pub const LUM_LOG2_MIN: f32 = -10.0;
12
13/// Highest log2(luminance) the histogram bins span. Pixels brighter than this
14/// fall in the last bin. Roughly matches a direct sun reflection at the bright
15/// end. The shader uses `(LUM_LOG2_MAX - LUM_LOG2_MIN)` to convert a bin index
16/// back to a log-luminance value during the average pass.
17pub const LUM_LOG2_MAX: f32 = 12.0;
18
19/// Number of histogram bins the build kernel writes into and the average kernel
20/// reduces. 256 is small enough to fit in threadgroup memory on every Apple GPU
21/// (1 KiB at u32) and big enough that the per-bin log-luminance step is fine.
22pub const HISTOGRAM_BINS: usize = 256;
23
24/// Running auto-exposure state. The current adapted EV moves toward the target
25/// EV (derived from the GPU-measured average log-luminance) via an exponential
26/// moving average so a sudden brightness change ramps in over a fraction of a
27/// second rather than snapping. One instance lives on each backend that runs
28/// auto-exposure.
29#[derive(Debug, Clone, Copy)]
30pub struct AutoExposureState {
31    /// EV currently applied to the scene. Updated each frame by
32    /// [`AutoExposureState::update`]; the backend reads it back out to set the
33    /// exposure multiplier the post passes push to the GPU.
34    pub current_ev: f32,
35}
36
37impl AutoExposureState {
38    /// Initial state. The current EV is the midpoint of the settings' clamp
39    /// range: a neutral starting point before the first GPU measurement
40    /// arrives. The first `update` call snaps it toward the real scene EV.
41    pub fn new(settings: &AutoExposureSettings) -> Self {
42        Self {
43            current_ev: (settings.min_ev + settings.max_ev) * 0.5,
44        }
45    }
46
47    /// Step the EMA one frame: take the GPU-measured average log-luminance
48    /// (base-2), turn it into a target EV (the offset that maps the scene
49    /// mean onto `settings.target_log_lum` plus the authored `ev_bias`),
50    /// then move `current_ev` toward it by the clamped EMA rate. Returns
51    /// the new clamped EV. `dt` is the frame time in seconds; non-finite or
52    /// non-positive values short-circuit (the EV stays where it was, no
53    /// NaN propagation into the post pass).
54    pub fn update(
55        &mut self,
56        avg_log_lum: f32,
57        ev_bias: f32,
58        settings: &AutoExposureSettings,
59        dt: f32,
60    ) -> f32 {
61        // The target EV shifts the scene's geometric-mean luminance onto the
62        // configured pivot: scene-white on the SDR path (target_log_lum=0,
63        // ACES then compresses), perceptual middle-grey on the HDR path
64        // (target_log_lum=log2(0.18), no ACES). `exposure = 2^target_ev`
65        // then satisfies `avg_lum * exposure = 2^target_log_lum` modulo bias.
66        let target = (settings.target_log_lum - avg_log_lum + ev_bias)
67            .clamp(settings.min_ev, settings.max_ev);
68        if !dt.is_finite() || dt <= 0.0 || !target.is_finite() {
69            self.current_ev = self.current_ev.clamp(settings.min_ev, settings.max_ev);
70            return self.current_ev;
71        }
72        let blend = 1.0 - exp(-settings.speed * dt);
73        self.current_ev = (self.current_ev + (target - self.current_ev) * blend)
74            .clamp(settings.min_ev, settings.max_ev);
75        self.current_ev
76    }
77}
78
79// Convert a histogram (256 bin counts) into the weighted-average log-luminance
80// the EMA consumes. Mirrors what the average-pass compute kernel does on GPU,
81// kept in pure Rust so the math is unit-testable without a device. Bins are
82// weighted by their centre log-luminance: bin `i` covers
83// `[LUM_LOG2_MIN + i*step, LUM_LOG2_MIN + (i+1)*step)`. Bin 0 is treated as
84// "below sensor floor" and weighted-in only when every other bin is empty,
85// so a mostly-black frame still produces a finite EV.
86#[cfg(test)]
87pub(crate) fn average_log_luminance(histogram: &[u32; HISTOGRAM_BINS]) -> f32 {
88    let step = (LUM_LOG2_MAX - LUM_LOG2_MIN) / HISTOGRAM_BINS as f32;
89    let mut weighted_sum = 0.0f64;
90    let mut count = 0u64;
91    for (i, &n) in histogram.iter().enumerate().skip(1) {
92        if n == 0 {
93            continue;
94        }
95        let centre = LUM_LOG2_MIN + (i as f32 + 0.5) * step;
96        weighted_sum += centre as f64 * n as f64;
97        count += n as u64;
98    }
99    if count == 0 {
100        LUM_LOG2_MIN
101    } else {
102        (weighted_sum / count as f64) as f32
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::gfx::auto_exposure::HDR_MIDDLE_GREY_LOG2;
110
111    #[test]
112    fn update_pulls_current_ev_toward_target() {
113        let settings = AutoExposureSettings::resolve(-8.0, 8.0, 4.0, false);
114        let mut state = AutoExposureState { current_ev: 0.0 };
115        // avg_log_lum = 2.0 -> target_ev = -2.0 (dim the scene by two stops).
116        let ev = state.update(2.0, 0.0, &settings, 1.0);
117        assert!(ev < 0.0, "ev should move toward target_ev = -2.0, got {ev}");
118        assert!(ev > -2.0, "ev should not overshoot in one step, got {ev}");
119
120        // Many steps converge.
121        for _ in 0..200 {
122            state.update(2.0, 0.0, &settings, 1.0 / 60.0);
123        }
124        assert!((state.current_ev + 2.0).abs() < 1.0e-3);
125    }
126
127    #[test]
128    fn update_clamps_to_settings_bounds() {
129        let settings = AutoExposureSettings::resolve(-1.0, 1.0, 5.0, false);
130        let mut state = AutoExposureState { current_ev: 0.0 };
131        // avg_log_lum = -10 would ask for target_ev = +10, clamped to +1.
132        for _ in 0..100 {
133            state.update(-10.0, 0.0, &settings, 1.0 / 60.0);
134        }
135        assert!((state.current_ev - 1.0).abs() < 1.0e-3);
136    }
137
138    #[test]
139    fn update_short_circuits_non_finite_dt() {
140        let settings = AutoExposureSettings::resolve(-2.0, 2.0, 1.0, false);
141        let mut state = AutoExposureState { current_ev: 0.5 };
142        let ev = state.update(5.0, 0.0, &settings, f32::NAN);
143        assert_eq!(ev, 0.5);
144        let ev = state.update(5.0, 0.0, &settings, -1.0);
145        assert_eq!(ev, 0.5);
146    }
147
148    #[test]
149    fn update_honours_ev_bias() {
150        let settings = AutoExposureSettings::resolve(-8.0, 8.0, 10.0, false);
151        let mut state = AutoExposureState { current_ev: 0.0 };
152        // avg_log_lum = 0, bias = +1 -> target_ev = +1 (over-expose by one stop).
153        for _ in 0..200 {
154            state.update(0.0, 1.0, &settings, 1.0 / 60.0);
155        }
156        assert!((state.current_ev - 1.0).abs() < 1.0e-3);
157    }
158
159    #[test]
160    fn resolve_defaults_to_scene_white_target_on_sdr() {
161        // SDR worlds keep the legacy "average → 1.0 linear" pivot so existing
162        // exposure authoring stays unchanged. ACES then squishes 1.0 down to
163        // the display mid-tone band.
164        let s = AutoExposureSettings::resolve(-8.0, 8.0, 1.5, false);
165        assert_eq!(s.target_log_lum, 0.0);
166    }
167
168    #[test]
169    fn resolve_shifts_to_middle_grey_on_hdr() {
170        // HDR worlds shift AE's pivot to perceptual middle-grey (0.18 linear)
171        // because there is no ACES tonemap to compress scene-white down. The
172        // pivot is `log2(0.18) ≈ -2.473`.
173        let s = AutoExposureSettings::resolve(-8.0, 8.0, 1.5, true);
174        assert!((s.target_log_lum - HDR_MIDDLE_GREY_LOG2).abs() < 1.0e-6);
175    }
176
177    #[test]
178    fn update_shifts_target_by_target_log_lum_on_hdr() {
179        // With HDR-aware settings, the EV the EMA converges on is shifted
180        // ~2.47 stops DOWN compared to the SDR default, i.e. the scene gets
181        // darker post-exposure so the same input renders at middle-grey
182        // instead of scene-white.
183        let sdr = AutoExposureSettings::resolve(-8.0, 8.0, 10.0, false);
184        let hdr = AutoExposureSettings::resolve(-8.0, 8.0, 10.0, true);
185        let mut state_sdr = AutoExposureState { current_ev: 0.0 };
186        let mut state_hdr = AutoExposureState { current_ev: 0.0 };
187        for _ in 0..500 {
188            state_sdr.update(0.0, 0.0, &sdr, 1.0 / 60.0);
189            state_hdr.update(0.0, 0.0, &hdr, 1.0 / 60.0);
190        }
191        // SDR settles at 0.0; HDR at log2(0.18) ≈ -2.47.
192        assert!(state_sdr.current_ev.abs() < 1.0e-3);
193        assert!((state_hdr.current_ev - HDR_MIDDLE_GREY_LOG2).abs() < 1.0e-3);
194    }
195
196    #[test]
197    fn average_log_luminance_empties_to_floor() {
198        let histogram = [0u32; HISTOGRAM_BINS];
199        assert_eq!(average_log_luminance(&histogram), LUM_LOG2_MIN);
200    }
201
202    #[test]
203    fn average_log_luminance_single_bin_returns_bin_centre() {
204        let mut histogram = [0u32; HISTOGRAM_BINS];
205        histogram[128] = 10;
206        let avg = average_log_luminance(&histogram);
207        let step = (LUM_LOG2_MAX - LUM_LOG2_MIN) / HISTOGRAM_BINS as f32;
208        let expected = LUM_LOG2_MIN + (128.5) * step;
209        assert!(
210            (avg - expected).abs() < 1.0e-3,
211            "avg={avg} expected={expected}"
212        );
213    }
214
215    #[test]
216    fn average_log_luminance_ignores_underflow_bin() {
217        // Pixels in bin 0 are below the sensor floor; they should not pull the
218        // average down. The result should equal the centre of bin 100.
219        let mut histogram = [0u32; HISTOGRAM_BINS];
220        histogram[0] = 10_000;
221        histogram[100] = 1;
222        let avg = average_log_luminance(&histogram);
223        let step = (LUM_LOG2_MAX - LUM_LOG2_MIN) / HISTOGRAM_BINS as f32;
224        let expected = LUM_LOG2_MIN + (100.5) * step;
225        assert!(
226            (avg - expected).abs() < 1.0e-3,
227            "avg={avg} expected={expected}"
228        );
229    }
230}