Skip to main content

oximedia_gpu/ops/
tonemap.rs

1//! GPU tone mapping operations
2//!
3//! Pure-Rust (CPU-fallback) implementations of common HDR → SDR tone-mapping
4//! operators. The API mirrors what a real GPU shader dispatch would look like
5//! so that integration into the GPU pipeline is straightforward.
6
7#![allow(dead_code)]
8#![allow(clippy::cast_precision_loss)]
9
10/// Supported tone-mapping algorithms
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub enum TonemapAlgorithm {
13    /// Simple Reinhard global operator
14    Reinhard,
15    /// Hable / Uncharted-2 filmic operator
16    HableFilimic,
17    /// Academy Colour Encoding System (ACES) approximation
18    Aces,
19    /// Drago logarithmic operator
20    DragoLog,
21}
22
23/// Parameters for a tone-mapping pass
24#[derive(Debug, Clone)]
25pub struct TonemapParams {
26    /// Tone-mapping operator to use
27    pub algorithm: TonemapAlgorithm,
28    /// Exposure multiplier applied before mapping (positive, typically 1.0)
29    pub exposure: f32,
30    /// Output gamma exponent (typically 2.2)
31    pub gamma: f32,
32    /// Scene peak luminance in nits (used by some operators)
33    pub peak_luminance: f32,
34}
35
36impl Default for TonemapParams {
37    fn default() -> Self {
38        Self {
39            algorithm: TonemapAlgorithm::Reinhard,
40            exposure: 1.0,
41            gamma: 2.2,
42            peak_luminance: 1000.0,
43        }
44    }
45}
46
47// ──────────────────────────────────────────────────────────────────────────────
48// Per-pixel tone-mapping functions
49// ──────────────────────────────────────────────────────────────────────────────
50
51/// Reinhard global tone-mapping operator
52///
53/// Maps [0, ∞) → [0, 1) via `x / (1 + x)`.
54#[must_use]
55pub fn reinhard_tonemap(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
56    (r / (1.0 + r), g / (1.0 + g), b / (1.0 + b))
57}
58
59/// Hable / Uncharted-2 filmic tone-mapping operator
60///
61/// The `exposure` parameter is applied before the curve.
62#[must_use]
63pub fn hable_tonemap(r: f32, g: f32, b: f32, exposure: f32) -> (f32, f32, f32) {
64    let scale = exposure;
65    let hable = |x: f32| -> f32 {
66        // Standard Uncharted-2 coefficients
67        let (a, b_c, c, d, e, f) = (0.15_f32, 0.50, 0.10, 0.20, 0.02, 0.30);
68        (x * (a * x + c * b_c) + d * e) / (x * (a * x + b_c) + d * f) - e / f
69    };
70    let white = hable(11.2);
71    let map = |v: f32| hable(v * scale) / white;
72    (map(r), map(g), map(b))
73}
74
75/// ACES filmic tone-mapping approximation (Narkowicz 2015)
76#[must_use]
77pub fn aces_tonemap(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
78    let aces = |x: f32| -> f32 {
79        let (a, b_c, c, d, e) = (2.51_f32, 0.03, 2.43, 0.59, 0.14);
80        ((x * (a * x + b_c)) / (x * (c * x + d) + e)).clamp(0.0, 1.0)
81    };
82    (aces(r), aces(g), aces(b))
83}
84
85/// Drago logarithmic tone-mapping operator
86///
87/// Approximates log-encoded HDR using `peak_luminance` to anchor the white
88/// point.
89#[must_use]
90pub fn drago_log_tonemap(r: f32, g: f32, b: f32, peak_luminance: f32) -> (f32, f32, f32) {
91    let map = |v: f32| -> f32 {
92        if v <= 0.0 || peak_luminance <= 0.0 {
93            return 0.0;
94        }
95        let l = v / peak_luminance;
96        (1.0 + (l * std::f32::consts::E).ln()) / (1.0 + (std::f32::consts::E).ln())
97    };
98    (map(r), map(g), map(b))
99}
100
101// ──────────────────────────────────────────────────────────────────────────────
102// Frame-level helpers
103// ──────────────────────────────────────────────────────────────────────────────
104
105/// Apply gamma encoding: `value^(1/gamma)`, clamped to [0, 1]
106#[must_use]
107pub fn apply_gamma(value: f32, gamma: f32) -> f32 {
108    if gamma <= 0.0 {
109        return value.clamp(0.0, 1.0);
110    }
111    value.clamp(0.0, 1.0).powf(1.0 / gamma)
112}
113
114/// Apply tone-mapping and gamma correction to an interleaved RGB(A) `f32` frame
115///
116/// `pixels` must contain `width * height * 3` (RGB) or `width * height * 4`
117/// (RGBA) values. If the stride is 4, the alpha channel is passed through
118/// unchanged.
119///
120/// # Panics
121///
122/// Panics if `pixels.len()` is not `width * height * 3` or `width * height * 4`.
123pub fn apply_tonemap_frame(pixels: &mut [f32], width: u32, height: u32, params: &TonemapParams) {
124    let n = (width * height) as usize;
125    let stride = if pixels.len() == n * 4 { 4 } else { 3 };
126    assert_eq!(
127        pixels.len(),
128        n * stride,
129        "pixels.len() must equal width*height*stride"
130    );
131
132    for i in 0..n {
133        let base = i * stride;
134        let r = pixels[base] * params.exposure;
135        let g = pixels[base + 1] * params.exposure;
136        let b = pixels[base + 2] * params.exposure;
137
138        let (tr, tg, tb) = match params.algorithm {
139            TonemapAlgorithm::Reinhard => reinhard_tonemap(r, g, b),
140            TonemapAlgorithm::HableFilimic => hable_tonemap(r, g, b, 1.0),
141            TonemapAlgorithm::Aces => aces_tonemap(r, g, b),
142            TonemapAlgorithm::DragoLog => drago_log_tonemap(r, g, b, params.peak_luminance),
143        };
144
145        pixels[base] = apply_gamma(tr, params.gamma);
146        pixels[base + 1] = apply_gamma(tg, params.gamma);
147        pixels[base + 2] = apply_gamma(tb, params.gamma);
148        // alpha unchanged if stride == 4
149    }
150}
151
152// ──────────────────────────────────────────────────────────────────────────────
153// Unit tests
154// ──────────────────────────────────────────────────────────────────────────────
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    const EPS: f32 = 1e-5;
160
161    #[test]
162    fn test_reinhard_zero() {
163        let (r, g, b) = reinhard_tonemap(0.0, 0.0, 0.0);
164        assert!(r.abs() < EPS && g.abs() < EPS && b.abs() < EPS);
165    }
166
167    #[test]
168    fn test_reinhard_large_input_approaches_one() {
169        let (r, g, b) = reinhard_tonemap(1e6, 1e6, 1e6);
170        assert!((1.0 - r).abs() < 1e-4);
171        assert!((1.0 - g).abs() < 1e-4);
172        assert!((1.0 - b).abs() < 1e-4);
173    }
174
175    #[test]
176    fn test_reinhard_half_input() {
177        // 0.5 / 1.5 ≈ 0.333…
178        let (r, _, _) = reinhard_tonemap(0.5, 0.0, 0.0);
179        assert!((r - 1.0 / 3.0).abs() < EPS);
180    }
181
182    #[test]
183    fn test_hable_unity_exposure() {
184        let (r, g, b) = hable_tonemap(1.0, 1.0, 1.0, 1.0);
185        // Output must be in [0, 1]
186        assert!((0.0..=1.0).contains(&r));
187        assert!((0.0..=1.0).contains(&g));
188        assert!((0.0..=1.0).contains(&b));
189    }
190
191    #[test]
192    fn test_aces_zero() {
193        let (r, g, b) = aces_tonemap(0.0, 0.0, 0.0);
194        // ACES at 0 → ≈ 0
195        assert!(r < EPS && g < EPS && b < EPS);
196    }
197
198    #[test]
199    fn test_aces_output_clamped() {
200        let (r, g, b) = aces_tonemap(1e6, 1e6, 1e6);
201        assert!(r <= 1.0 && g <= 1.0 && b <= 1.0);
202    }
203
204    #[test]
205    fn test_drago_zero_input() {
206        let (r, g, b) = drago_log_tonemap(0.0, 0.0, 0.0, 1000.0);
207        assert!(r.abs() < EPS && g.abs() < EPS && b.abs() < EPS);
208    }
209
210    #[test]
211    fn test_drago_output_range() {
212        let (r, g, b) = drago_log_tonemap(500.0, 500.0, 500.0, 1000.0);
213        assert!(r >= 0.0 && r <= 1.0);
214        assert!(g >= 0.0 && g <= 1.0);
215        assert!(b >= 0.0 && b <= 1.0);
216    }
217
218    #[test]
219    fn test_apply_gamma_identity_at_one() {
220        // gamma=1 → value^1 = value
221        let v = apply_gamma(0.5, 1.0);
222        assert!((v - 0.5).abs() < EPS);
223    }
224
225    #[test]
226    fn test_apply_gamma_clamps_above_one() {
227        let v = apply_gamma(2.0, 2.2);
228        assert!((v - 1.0).abs() < EPS);
229    }
230
231    #[test]
232    fn test_apply_gamma_clamps_below_zero() {
233        let v = apply_gamma(-1.0, 2.2);
234        assert!(v.abs() < EPS);
235    }
236
237    #[test]
238    fn test_apply_tonemap_frame_reinhard_rgb() {
239        let mut pixels = vec![1.0_f32; 4 * 3]; // 4 pixels, RGB stride=3
240        let params = TonemapParams {
241            algorithm: TonemapAlgorithm::Reinhard,
242            exposure: 1.0,
243            gamma: 1.0,
244            peak_luminance: 1000.0,
245        };
246        apply_tonemap_frame(&mut pixels, 2, 2, &params);
247        // Reinhard(1.0) = 0.5; gamma=1 → 0.5
248        for i in 0..4 {
249            let base = i * 3;
250            assert!((pixels[base] - 0.5).abs() < EPS, "pixel {i} r");
251        }
252    }
253
254    #[test]
255    fn test_apply_tonemap_frame_rgba_alpha_preserved() {
256        // 1 pixel, RGBA
257        let mut pixels = vec![1.0_f32, 1.0, 1.0, 0.75];
258        let params = TonemapParams::default();
259        apply_tonemap_frame(&mut pixels, 1, 1, &params);
260        // Alpha (index 3) must be unchanged
261        assert!((pixels[3] - 0.75).abs() < EPS);
262    }
263}