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