Skip to main content

drmkit_display/
tone_mapper.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! CPU tone mapping, for when the hardware pipeline is not exposed.
5//!
6//! Mixing HDR and SDR layers is properly the display pipeline's job, but the
7//! kernel does not expose one on every driver. Without a fallback an
8//! application either refuses HDR content or shows it wrong, so this covers the
9//! three cases that matter: SDR up into HDR, and HDR or HLG back down to SDR.
10//!
11//! # Pixel layout
12//!
13//! A `u64` holding four `u16` channels, little-endian: R in bits 0..16, G in
14//! 16..32, B in 32..48, A in 48..64. Alpha passes through untouched — tone
15//! mapping is a color operation and has no opinion about coverage. Callers on
16//! 8-bit surfaces expand by 257 first and re-quantize after.
17//!
18//! # Why there are lookup tables
19//!
20//! Every transfer function here is a `powf` or an `exp`. Running one per
21//! channel per pixel is three transcendentals per pixel each way, which at
22//! 1080p60 is not a fallback, it is a stall. The curves are sampled once at
23//! construction into 1024 buckets plus a sentinel, and per-pixel cost drops to
24//! a lookup and a lerp.
25
26use crate::curves::{bt1886_oetf, hlg_oetf_inverse, pq_eotf, pq_oetf, srgb_eotf};
27
28/// Entries in each lookup table: 1024 buckets plus one sentinel.
29///
30/// The sentinel is what lets the lerp read `lut[i + 1]` without a bounds check
31/// on the last bucket.
32pub const LUT_SIZE: usize = 1025;
33
34const LUT_BUCKETS: usize = 1024;
35/// The same count as an `f64`, written out rather than cast: 1024 is exact in
36/// both, and a literal keeps the conversion out of the hot path. Kept beside
37/// `LUT_BUCKETS` so the pair is edited together.
38const LUT_BUCKETS_F: f64 = 1024.0;
39/// A `u16` sample's top 10 bits select the bucket, the bottom 6 interpolate.
40const INPUT_INDEX_SHIFT: u32 = 6;
41const INPUT_FRAC_DIV: f64 = 64.0;
42
43/// The curve applied in linear light when compressing HDR into SDR.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45#[non_exhaustive]
46pub enum ToneMapCurve {
47    /// Clamp at 1.0.
48    ///
49    /// For pipelines that want the matrix and transfer functions without a
50    /// subjective curve.
51    None,
52    /// `x / (1 + x)`. Cheap, keeps shadows, rolls highlights off smoothly.
53    #[default]
54    Reinhard,
55    /// The Uncharted 2 filmic curve. Holds contrast better, at some mid-tone
56    /// shift.
57    Hable,
58}
59
60/// Which transform a mapper performs.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62#[allow(
63    clippy::exhaustive_enums,
64    reason = "a closed set: there is no further variant to add"
65)]
66pub enum Direction {
67    /// SDR up into an HDR composition.
68    Bt709ToBt2020Pq,
69    /// HDR down to an SDR display.
70    Bt2020PqToBt709,
71    /// HLG down to an SDR display.
72    HlgToBt709,
73}
74
75/// BT.709 primaries to BT.2020.
76const BT709_TO_BT2020: [f64; 9] = [
77    0.6274, 0.3293, 0.0433, //
78    0.0691, 0.9195, 0.0114, //
79    0.0164, 0.0880, 0.8956,
80];
81
82/// BT.2020 primaries to BT.709.
83///
84/// Not the inverse of the above to full precision — both are the rounded
85/// matrices the reference carries, and they are reproduced digit for digit so
86/// the two implementations agree pixel for pixel.
87const BT2020_TO_BT709: [f64; 9] = [
88    1.6605, -0.5876, -0.0728, //
89    -0.1246, 1.1329, -0.0083, //
90    -0.0182, -0.1006, 1.1187,
91];
92
93/// BT.2020 luminance coefficients, for the HLG system-gamma factor.
94const BT2020_Y_R: f64 = 0.2627;
95const BT2020_Y_G: f64 = 0.6780;
96const BT2020_Y_B: f64 = 0.0593;
97
98fn reinhard(x: f64) -> f64 {
99    x / (1.0 + x)
100}
101
102fn hable_curve(x: f64) -> f64 {
103    const A: f64 = 0.15;
104    const B: f64 = 0.50;
105    const C: f64 = 0.10;
106    const D: f64 = 0.20;
107    const E: f64 = 0.02;
108    const F: f64 = 0.30;
109    (((x * ((A * x) + (C * B))) + (D * E)) / ((x * ((A * x) + B)) + (D * F))) - (E / F)
110}
111
112fn hable(x: f64) -> f64 {
113    // The curve is normalized against its own value at the reference white
114    // point, so that white maps to 1.0 rather than to wherever the raw curve
115    // happens to land.
116    const WHITE: f64 = 11.2;
117    (hable_curve(x) / hable_curve(WHITE)).clamp(0.0, 1.0)
118}
119
120fn apply_curve(x: f64, curve: ToneMapCurve) -> f64 {
121    match curve {
122        ToneMapCurve::None => x.clamp(0.0, 1.0),
123        ToneMapCurve::Reinhard => reinhard(x.max(0.0)),
124        ToneMapCurve::Hable => hable(x.max(0.0)),
125    }
126}
127
128fn matrix_mul(m: &[f64; 9], v: [f64; 3]) -> [f64; 3] {
129    [
130        (m[0] * v[0]) + (m[1] * v[1]) + (m[2] * v[2]),
131        (m[3] * v[0]) + (m[4] * v[1]) + (m[5] * v[2]),
132        (m[6] * v[0]) + (m[7] * v[1]) + (m[8] * v[2]),
133    ]
134}
135
136/// Sample the encoded-to-linear table for a `u16` channel.
137fn sample_input(lut: &[f32; LUT_SIZE], encoded: u16) -> f64 {
138    let idx = (encoded >> INPUT_INDEX_SHIFT) as usize;
139    let frac = f64::from(encoded & 0x3F) / INPUT_FRAC_DIV;
140    let lo = f64::from(lut[idx]);
141    let hi = f64::from(lut[idx + 1]);
142    lo + (frac * (hi - lo))
143}
144
145/// Sample the linear-to-encoded table.
146#[expect(
147    clippy::cast_possible_truncation,
148    clippy::cast_sign_loss,
149    reason = "the clamp on the line above bounds `scaled` to 0.0..=1024.0, so \
150              the conversion is exact and non-negative by construction"
151)]
152fn sample_output(lut: &[f32; LUT_SIZE], linear: f64) -> f64 {
153    let linear = linear.clamp(0.0, 1.0);
154    let scaled = linear * LUT_BUCKETS_F;
155    let idx = scaled as usize;
156    // `linear == 1.0` lands exactly on the last bucket, where there is nothing
157    // to interpolate towards; the sentinel is the answer.
158    if idx >= LUT_BUCKETS {
159        return f64::from(lut[LUT_BUCKETS]);
160    }
161    let frac = scaled - f64::from(u32::try_from(idx).unwrap_or(u32::MAX));
162    let lo = f64::from(lut[idx]);
163    let hi = f64::from(lut[idx + 1]);
164    lo + (frac * (hi - lo))
165}
166
167/// A configured tone-mapping transform.
168///
169/// Immutable: the tables are built once from the parameters, so changing a
170/// parameter means building another mapper.
171pub struct ToneMapper {
172    direction: Direction,
173    curve: ToneMapCurve,
174    sdr_white_nits: f32,
175    target_max_nits: f32,
176    input_lut: Box<[f32; LUT_SIZE]>,
177    output_lut: Box<[f32; LUT_SIZE]>,
178}
179
180impl std::fmt::Debug for ToneMapper {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        f.debug_struct("ToneMapper")
183            .field("direction", &self.direction)
184            .field("curve", &self.curve)
185            .field("sdr_white_nits", &self.sdr_white_nits)
186            .field("target_max_nits", &self.target_max_nits)
187            .finish_non_exhaustive()
188    }
189}
190
191impl ToneMapper {
192    /// SDR into an HDR composition.
193    ///
194    /// `sdr_white_nits` is where SDR's `1.0` lands on the PQ curve. 100 is the
195    /// conventional default and matches how most HDR displays render an SDR
196    /// signal; 203 is the common "diffuse white" reference and makes the SDR
197    /// part brighter within the composition.
198    #[must_use]
199    pub fn bt709_to_bt2020_pq(sdr_white_nits: f32) -> Self {
200        Self::new(
201            Direction::Bt709ToBt2020Pq,
202            sdr_white_nits,
203            100.0,
204            ToneMapCurve::None,
205        )
206    }
207
208    /// HDR down to an SDR display.
209    ///
210    /// `target_max_nits` is what SDR's `1.0` means in absolute terms; anything
211    /// above it goes through `curve` so highlights compress instead of
212    /// clipping.
213    #[must_use]
214    pub fn bt2020_pq_to_bt709(target_max_nits: f32, curve: ToneMapCurve) -> Self {
215        Self::new(Direction::Bt2020PqToBt709, 100.0, target_max_nits, curve)
216    }
217
218    /// HLG down to an SDR display.
219    ///
220    /// Always Reinhard, which only rolls off what lands above the target's
221    /// white. The curve is not selectable for HLG: its OOTF has already shaped
222    /// the highlights.
223    #[must_use]
224    pub fn hlg_to_bt709(target_max_nits: f32) -> Self {
225        Self::new(
226            Direction::HlgToBt709,
227            100.0,
228            target_max_nits,
229            ToneMapCurve::Reinhard,
230        )
231    }
232
233    #[expect(
234        clippy::cast_possible_truncation,
235        reason = "the tables are f32 on purpose: two 4KB tables stay in cache \
236                  where two 8KB ones do not, and the values are transfer-curve \
237                  samples in 0.0..=1.0 where f32 has far more precision than \
238                  the 16-bit channels being sampled"
239    )]
240    fn new(
241        direction: Direction,
242        sdr_white_nits: f32,
243        target_max_nits: f32,
244        curve: ToneMapCurve,
245    ) -> Self {
246        let mut input_lut = Box::new([0.0f32; LUT_SIZE]);
247        let mut output_lut = Box::new([0.0f32; LUT_SIZE]);
248        for i in 0..LUT_SIZE {
249            let t = f64::from(u32::try_from(i).unwrap_or(u32::MAX)) / LUT_BUCKETS_F;
250            input_lut[i] = match direction {
251                Direction::Bt709ToBt2020Pq => srgb_eotf(t),
252                Direction::Bt2020PqToBt709 => pq_eotf(t),
253                Direction::HlgToBt709 => hlg_oetf_inverse(t),
254            } as f32;
255            output_lut[i] = match direction {
256                Direction::Bt709ToBt2020Pq => pq_oetf(t),
257                Direction::Bt2020PqToBt709 | Direction::HlgToBt709 => bt1886_oetf(t),
258            } as f32;
259        }
260        Self {
261            direction,
262            curve,
263            sdr_white_nits,
264            target_max_nits,
265            input_lut,
266            output_lut,
267        }
268    }
269
270    /// The direction this mapper transforms.
271    #[must_use]
272    pub const fn direction(&self) -> Direction {
273        self.direction
274    }
275
276    /// The curve it applies.
277    #[must_use]
278    pub const fn curve(&self) -> ToneMapCurve {
279        self.curve
280    }
281
282    /// Where SDR white lands, in nits.
283    #[must_use]
284    pub const fn sdr_white_nits(&self) -> f32 {
285        self.sdr_white_nits
286    }
287
288    /// What SDR `1.0` represents, in nits.
289    #[must_use]
290    pub const fn target_max_nits(&self) -> f32 {
291        self.target_max_nits
292    }
293
294    /// Transform one pixel.
295    #[must_use]
296    #[expect(
297        clippy::cast_possible_truncation,
298        clippy::cast_sign_loss,
299        reason = "the quantizer clamps to 0.0..=1.0 before scaling by 65535, \
300                  so the rounded result is a whole number in 0..=65535 and \
301                  both the truncation and the sign are ruled out by the clamp"
302    )]
303    pub fn map(&self, pixel: u64) -> u64 {
304        let r_q = (pixel & 0xFFFF) as u16;
305        let g_q = ((pixel >> 16) & 0xFFFF) as u16;
306        let b_q = ((pixel >> 32) & 0xFFFF) as u16;
307        let a_q = (pixel >> 48) & 0xFFFF;
308
309        let mut r = sample_input(&self.input_lut, r_q);
310        let mut g = sample_input(&self.input_lut, g_q);
311        let mut b = sample_input(&self.input_lut, b_q);
312
313        match self.direction {
314            Direction::Bt709ToBt2020Pq => {
315                let rgb = matrix_mul(&BT709_TO_BT2020, [r, g, b]);
316                // PQ is absolute: its 1.0 is 10,000 nits, so SDR white has to
317                // be placed at its real luminance rather than reused as full
318                // scale, or an SDR layer would light up at 100x its intent.
319                let scale = f64::from(self.sdr_white_nits) / 10_000.0;
320                r = (rgb[0].max(0.0) * scale).clamp(0.0, 1.0);
321                g = (rgb[1].max(0.0) * scale).clamp(0.0, 1.0);
322                b = (rgb[2].max(0.0) * scale).clamp(0.0, 1.0);
323            }
324            Direction::Bt2020PqToBt709 => {
325                let rgb = matrix_mul(&BT2020_TO_BT709, [r, g, b]);
326                let scale = 10_000.0 / f64::from(self.target_max_nits);
327                r = apply_curve(rgb[0].max(0.0) * scale, self.curve);
328                g = apply_curve(rgb[1].max(0.0) * scale, self.curve);
329                b = apply_curve(rgb[2].max(0.0) * scale, self.curve);
330            }
331            Direction::HlgToBt709 => {
332                // The BT.2100 OOTF, which PQ does not have and HLG cannot skip.
333                // HLG encodes *scene* light; a display shows *display* light,
334                // and the system gamma between them is what stops HLG content
335                // looking flat and washed out. It is a function of overall
336                // luminance rather than of each channel, so it cannot be folded
337                // into the input table with the rest.
338                let y = (BT2020_Y_R * r) + (BT2020_Y_G * g) + (BT2020_Y_B * b);
339                let ootf = y.max(0.0).powf(0.2);
340                r = (r * ootf).max(0.0);
341                g = (g * ootf).max(0.0);
342                b = (b * ootf).max(0.0);
343
344                let rgb = matrix_mul(&BT2020_TO_BT709, [r, g, b]);
345                // HLG is relative, not absolute: its nominal peak is 12x
346                // diffuse white rather than a fixed 10,000 nits, so the scale
347                // is expressed against the target's own white point.
348                let scale = 12.0 / (f64::from(self.target_max_nits) / 100.0);
349                r = reinhard(rgb[0].max(0.0) * scale);
350                g = reinhard(rgb[1].max(0.0) * scale);
351                b = reinhard(rgb[2].max(0.0) * scale);
352            }
353        }
354
355        let to_u16 = |v: f64| (v.clamp(0.0, 1.0) * 65535.0).round() as u64;
356        to_u16(sample_output(&self.output_lut, r))
357            | (to_u16(sample_output(&self.output_lut, g)) << 16)
358            | (to_u16(sample_output(&self.output_lut, b)) << 32)
359            | (a_q << 48)
360    }
361
362    /// Transform a run of pixels.
363    ///
364    /// Equivalent to calling [`map`](Self::map) on each pixel. Processes
365    /// `min(src.len(), dst.len())` pixels; any excess in either slice is left
366    /// alone.
367    pub fn apply(&self, src: &[u64], dst: &mut [u64]) {
368        for (out, pixel) in dst.iter_mut().zip(src) {
369            *out = self.map(*pixel);
370        }
371    }
372}