Skip to main content

alpha2_render/
hdr.rs

1//! HDR sources brought into the SDR working space.
2//!
3//! The working space is display-referred linear BT.709 with `1.0` at SDR white. HDR is
4//! linearized to nits (PQ by its EOTF; HLG by inverse OETF plus the BT.2100 OOTF at its nominal
5//! peak), converted to BT.709, and tone-mapped to SDR white by the BT.2390 EETF applied to the
6//! largest component, preserving hue. Out-of-gamut components clip at zero.
7//!
8//! The YUV conversion shader implements these; GPU tests compare the two.
9
10// Curves run in f64 and return bounded luminances or codes as f32.
11#![allow(clippy::cast_possible_truncation)]
12
13use alpha2_media::{ColorPrimaries, TransferFunction};
14
15/// Luminance of SDR white (working-space `1.0`), in nits.
16pub const SDR_WHITE_NITS: f32 = 100.0;
17/// Assumed HDR source peak when a project does not specify one.
18pub const DEFAULT_HDR_PEAK_NITS: u32 = 1_000;
19/// Luminance of PQ code value `1.0`.
20const PQ_PEAK_NITS: f64 = 10_000.0;
21
22// SMPTE ST 2084 constants.
23const PQ_M1: f64 = 2_610.0 / 16_384.0;
24const PQ_M2: f64 = 2_523.0 / 4_096.0 * 128.0;
25const PQ_C1: f64 = 3_424.0 / 4_096.0;
26const PQ_C2: f64 = 2_413.0 / 4_096.0 * 32.0;
27const PQ_C3: f64 = 2_392.0 / 4_096.0 * 32.0;
28
29// ITU-R BT.2100 HLG constants.
30const HLG_A: f64 = 0.178_832_77;
31const HLG_B: f64 = 0.284_668_92;
32const HLG_C: f64 = 0.559_910_73;
33/// BT.2020 luma weights, which the HLG OOTF weighs scene light by.
34const BT2020_LUMA: [f64; 3] = [0.2627, 0.6780, 0.0593];
35/// Scene-luma floor of the HLG OOTF gain, matching the shader.
36const HLG_MIN_LUMA: f64 = 1.0e-10;
37
38/// How an HDR source is tone-mapped: from `source_peak_nits` to SDR white.
39#[derive(Copy, Clone, Debug, Eq, PartialEq)]
40pub struct HdrToneMap {
41    /// Brightest luminance assumed for the source. PQ above it clips; HLG is displayed at it.
42    pub source_peak_nits: u32,
43}
44
45impl Default for HdrToneMap {
46    fn default() -> Self {
47        Self {
48            source_peak_nits: DEFAULT_HDR_PEAK_NITS,
49        }
50    }
51}
52
53/// Display luminance, in nits, of a PQ code value in `0..=1`.
54#[must_use]
55pub fn pq_eotf(code: f32) -> f32 {
56    let e = libm::pow(f64::from(code.clamp(0.0, 1.0)), 1.0 / PQ_M2);
57    let y = libm::pow((e - PQ_C1).max(0.0) / (PQ_C2 - PQ_C3 * e), 1.0 / PQ_M1);
58    (y * PQ_PEAK_NITS) as f32
59}
60
61/// The PQ code value of a display luminance in nits.
62#[must_use]
63pub fn pq_inverse_eotf(nits: f32) -> f32 {
64    let y = libm::pow((f64::from(nits) / PQ_PEAK_NITS).clamp(0.0, 1.0), PQ_M1);
65    libm::pow((PQ_C1 + PQ_C2 * y) / (1.0 + PQ_C3 * y), PQ_M2) as f32
66}
67
68/// Scene light in `0..=1` of an HLG code value (the inverse OETF).
69#[must_use]
70pub fn hlg_inverse_oetf(code: f32) -> f32 {
71    let e = f64::from(code.clamp(0.0, 1.0));
72    let light = if e <= 0.5 {
73        e * e / 3.0
74    } else {
75        (libm::exp((e - HLG_C) / HLG_A) + HLG_B) / 12.0
76    };
77    light as f32
78}
79
80/// Display light in nits of HLG scene light shown at `peak_nits`: the BT.2100
81/// OOTF, with the system gamma for that peak.
82#[must_use]
83pub fn hlg_ootf(scene: [f32; 3], peak_nits: f32) -> [f32; 3] {
84    let gamma = 1.2 + 0.42 * libm::log10(f64::from(peak_nits) / 1_000.0);
85    let luma: f64 = scene
86        .iter()
87        .zip(BT2020_LUMA)
88        .map(|(component, weight)| f64::from(*component) * weight)
89        .sum();
90    // Below about 334 nits `gamma - 1` is negative, so zero luma would give NaN; floor it as
91    // yuv-to-linear.wesl does.
92    let gain = f64::from(peak_nits) * libm::pow(luma.max(HLG_MIN_LUMA), gamma - 1.0);
93    scene.map(|component| (gain * f64::from(component)) as f32)
94}
95
96/// ITU-R BT.2390 EETF mapping `nits` from `source_peak` to `target_peak`, black at zero.
97///
98/// Identity below the knee, then a Hermite roll-off to exactly `target_peak`. A source no
99/// brighter than the target passes unchanged.
100#[must_use]
101pub fn bt2390_eetf(nits: f32, source_peak: f32, target_peak: f32) -> f32 {
102    let source = f64::from(pq_inverse_eotf(source_peak));
103    if source <= 0.0 {
104        return 0.0;
105    }
106    let value = f64::from(pq_inverse_eotf(nits.min(source_peak))) / source;
107    let max_lum = f64::from(pq_inverse_eotf(target_peak)) / source;
108    let knee = 1.5 * max_lum - 0.5;
109    let mapped = if value < knee || knee >= 1.0 {
110        value
111    } else {
112        let t = (value - knee) / (1.0 - knee);
113        let (t2, t3) = (t * t, t * t * t);
114        (2.0 * t3 - 3.0 * t2 + 1.0) * knee
115            + (t3 - 2.0 * t2 + t) * (1.0 - knee)
116            + (-2.0 * t3 + 3.0 * t2) * max_lum
117    };
118    pq_eotf((mapped * source) as f32)
119}
120
121/// Linear RGB matrix (rows) from `from` to `to` primaries, D65 white. BT.601 is treated as
122/// BT.709, as the SDR path does.
123#[must_use]
124pub fn gamut_matrix(from: ColorPrimaries, to: ColorPrimaries) -> [[f32; 3]; 3] {
125    let from = rgb_to_xyz(chromaticities(from));
126    let to = invert(rgb_to_xyz(chromaticities(to)));
127    let product = multiply(to, from);
128    product.map(|row| row.map(|value| value as f32))
129}
130
131/// Working-space linear RGB of an HDR pixel's nonlinear components in its own primaries.
132#[must_use]
133pub fn hdr_to_working(
134    encoded: [f32; 3],
135    transfer: TransferFunction,
136    primaries: ColorPrimaries,
137    tone: HdrToneMap,
138) -> [f32; 3] {
139    #[allow(clippy::cast_precision_loss)]
140    let peak = tone.source_peak_nits as f32;
141    let nits = match transfer {
142        TransferFunction::Hlg => hlg_ootf(encoded.map(hlg_inverse_oetf), peak),
143        _ => encoded.map(pq_eotf),
144    };
145    let gamut = gamut_matrix(primaries, ColorPrimaries::Bt709);
146    let converted = gamut.map(|row| {
147        row.iter()
148            .zip(nits)
149            .map(|(weight, component)| weight * component)
150            .sum::<f32>()
151    });
152    let largest = converted[0].max(converted[1]).max(converted[2]);
153    let scale = if largest > 0.0 {
154        bt2390_eetf(largest, peak, SDR_WHITE_NITS) / largest
155    } else {
156        0.0
157    };
158    converted.map(|component| (component * scale / SDR_WHITE_NITS).clamp(0.0, 1.0))
159}
160
161/// CIE xy of red, green, blue and white.
162fn chromaticities(primaries: ColorPrimaries) -> [[f64; 2]; 4] {
163    const D65: [f64; 2] = [0.3127, 0.3290];
164    match primaries {
165        ColorPrimaries::Bt601 | ColorPrimaries::Bt709 => {
166            [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060], D65]
167        }
168        ColorPrimaries::Bt2020 => [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046], D65],
169    }
170}
171
172/// The matrix taking linear RGB with these chromaticities to CIE XYZ.
173fn rgb_to_xyz([red, green, blue, white]: [[f64; 2]; 4]) -> [[f64; 3]; 3] {
174    let xyz = |[x, y]: [f64; 2]| [x / y, 1.0, (1.0 - x - y) / y];
175    let columns = [xyz(red), xyz(green), xyz(blue)];
176    let primaries = [
177        [columns[0][0], columns[1][0], columns[2][0]],
178        [columns[0][1], columns[1][1], columns[2][1]],
179        [columns[0][2], columns[1][2], columns[2][2]],
180    ];
181    let white = xyz(white);
182    let inverse = invert(primaries);
183    let scale = inverse.map(|row| row.iter().zip(white).map(|(a, b)| a * b).sum::<f64>());
184    primaries.map(|row| [row[0] * scale[0], row[1] * scale[1], row[2] * scale[2]])
185}
186
187fn multiply(a: [[f64; 3]; 3], b: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
188    let mut product = [[0.0; 3]; 3];
189    for (row, out) in product.iter_mut().enumerate() {
190        for (column, cell) in out.iter_mut().enumerate() {
191            *cell = (0..3).map(|k| a[row][k] * b[k][column]).sum();
192        }
193    }
194    product
195}
196
197fn invert(m: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
198    let cofactor =
199        |r0: usize, r1: usize, c0: usize, c1: usize| m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0];
200    let adjugate = [
201        [
202            cofactor(1, 2, 1, 2),
203            -cofactor(0, 2, 1, 2),
204            cofactor(0, 1, 1, 2),
205        ],
206        [
207            -cofactor(1, 2, 0, 2),
208            cofactor(0, 2, 0, 2),
209            -cofactor(0, 1, 0, 2),
210        ],
211        [
212            cofactor(1, 2, 0, 1),
213            -cofactor(0, 2, 0, 1),
214            cofactor(0, 1, 0, 1),
215        ],
216    ];
217    let determinant =
218        m[0][0] * adjugate[0][0] + m[0][1] * adjugate[1][0] + m[0][2] * adjugate[2][0];
219    adjugate.map(|row| row.map(|value| value / determinant))
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn pq_round_trips_and_hits_its_reference_points() {
228        assert!((pq_eotf(1.0) - 10_000.0).abs() < 0.5);
229        assert!(pq_eotf(0.0).abs() < 1.0e-6);
230        // 100 nits sits at PQ code 0.5081 (BT.2100 table 4 region).
231        assert!((pq_inverse_eotf(100.0) - 0.5081).abs() < 1.0e-3);
232        for nits in [0.01_f32, 1.0, 100.0, 203.0, 1_000.0, 4_000.0] {
233            let back = pq_eotf(pq_inverse_eotf(nits));
234            assert!((back - nits).abs() / nits < 1.0e-4, "{nits} -> {back}");
235        }
236    }
237
238    #[test]
239    fn hlg_reference_white_is_203_nits_at_a_1000_nit_peak() {
240        // BT.2408: 75 % HLG signal is reference white, 203 nits on a 1000-nit display.
241        let scene = hlg_inverse_oetf(0.75);
242        let [white, ..] = hlg_ootf([scene; 3], 1_000.0);
243        assert!((white - 203.0).abs() < 1.5, "{white}");
244        assert!((hlg_inverse_oetf(0.5) * 3.0 - 0.25).abs() < 1.0e-6);
245    }
246
247    #[test]
248    fn the_eetf_keeps_the_knee_reaches_the_target_and_never_rises_past_it() {
249        assert!((bt2390_eetf(10.0, 1_000.0, 100.0) - 10.0).abs() < 0.01);
250        assert!((bt2390_eetf(1_000.0, 1_000.0, 100.0) - 100.0).abs() < 0.05);
251        let mut previous = 0.0;
252        for step in 0_u16..=200 {
253            let nits = f32::from(step) * 5.0;
254            let mapped = bt2390_eetf(nits, 1_000.0, 100.0);
255            assert!(mapped >= previous - 1.0e-4 && mapped <= 100.0 + 1.0e-3);
256            previous = mapped;
257        }
258        assert!((bt2390_eetf(80.0, 100.0, 100.0) - 80.0).abs() < 0.01);
259    }
260
261    #[test]
262    fn gamut_matrices_convert_white_to_white_and_bt2020_red_out_of_gamut() {
263        let m = gamut_matrix(ColorPrimaries::Bt2020, ColorPrimaries::Bt709);
264        for row in m {
265            assert!((row.iter().sum::<f32>() - 1.0).abs() < 1.0e-4);
266        }
267        // The published BT.2087 values.
268        assert!((m[0][0] - 1.6605).abs() < 1.0e-3 && (m[2][2] - 1.1187).abs() < 1.0e-3);
269        let identity = gamut_matrix(ColorPrimaries::Bt709, ColorPrimaries::Bt709);
270        for (r, row) in identity.iter().enumerate() {
271            for (c, value) in row.iter().enumerate() {
272                assert!((value - f32::from(u8::from(r == c))).abs() < 1.0e-6);
273            }
274        }
275    }
276
277    #[test]
278    fn hlg_black_is_black_at_every_peak_the_model_accepts() {
279        // Below about 334 nits the system gamma is under 1, so zero luma meets a negative power.
280        for peak in [100_u32, 200, 333, 334, 335, 1_000, 10_000] {
281            #[allow(clippy::cast_precision_loss)]
282            let display = hlg_ootf([0.0; 3], peak as f32);
283            assert!(
284                display.iter().all(|c| *c == 0.0),
285                "{peak} nits: {display:?}"
286            );
287            let tone = HdrToneMap {
288                source_peak_nits: peak,
289            };
290            // Code black, and a sub-black code that the inverse OETF clamps to zero light.
291            for code in [0.0, -0.05] {
292                let working = hdr_to_working(
293                    [code; 3],
294                    TransferFunction::Hlg,
295                    ColorPrimaries::Bt2020,
296                    tone,
297                );
298                assert!(
299                    working.iter().all(|c| *c == 0.0),
300                    "{peak} nits, code {code}: {working:?}"
301                );
302            }
303            let grey = hdr_to_working(
304                [0.1; 3],
305                TransferFunction::Hlg,
306                ColorPrimaries::Bt2020,
307                tone,
308            );
309            assert!(
310                grey.iter().all(|c| c.is_finite() && *c > 0.0),
311                "{peak} nits: {grey:?}"
312            );
313        }
314    }
315
316    #[test]
317    fn hdr_white_and_black_land_in_the_sdr_range_with_hue_kept() {
318        let tone = HdrToneMap::default();
319        let pq = |nits: f32| pq_inverse_eotf(nits);
320        let black = hdr_to_working([0.0; 3], TransferFunction::Pq, ColorPrimaries::Bt2020, tone);
321        assert!(black.iter().all(|component| *component == 0.0), "{black:?}");
322        let peak = hdr_to_working(
323            [pq(1_000.0); 3],
324            TransferFunction::Pq,
325            ColorPrimaries::Bt2020,
326            tone,
327        );
328        assert!(peak.iter().all(|c| (c - 1.0).abs() < 1.0e-3), "{peak:?}");
329        // Dim grey below the knee keeps its level: 10 nits is 0.1 of SDR white.
330        let grey = hdr_to_working(
331            [pq(10.0); 3],
332            TransferFunction::Pq,
333            ColorPrimaries::Bt2020,
334            tone,
335        );
336        assert!(grey.iter().all(|c| (c - 0.1).abs() < 2.0e-3), "{grey:?}");
337        let orange = hdr_to_working(
338            [pq(600.0), pq(300.0), pq(60.0)],
339            TransferFunction::Pq,
340            ColorPrimaries::Bt709,
341            tone,
342        );
343        assert!((orange[1] / orange[0] - 0.5).abs() < 1.0e-3, "{orange:?}");
344        assert!(orange[0] < 1.0 && orange[0] > 0.8);
345        // HLG reference white is a bright, not clipped, SDR white.
346        let hlg = hdr_to_working(
347            [0.75; 3],
348            TransferFunction::Hlg,
349            ColorPrimaries::Bt2020,
350            tone,
351        );
352        assert!(hlg.iter().all(|c| *c > 0.8 && *c < 1.0), "{hlg:?}");
353    }
354}