Skip to main content

concinnity_core/render/
hdr_output.rs

1//! Backend-agnostic representation of the renderer's swapchain colour-output
2//! mode. Built from the world's `PostProcessConfig.hdr_display` request plus
3//! the active display's measured EDR capability (the backend supplies the
4//! capability; this module is pure CPU). The result drives:
5//!
6//!   1. the swapchain pixel format + colour space chosen at window setup
7//!      (BGRA8Unorm for SDR; RGBA16Float + extendedLinearDisplayP3 for HDR);
8//!   2. whether `PostProcessParams.hdr_output` ships to the shader as `1.0`
9//!      so the composite pass skips ACES + gamma + FXAA + ColorLut and emits
10//!      linear extended-range values directly.
11//!
12//! Those two flags only ever enter `PostProcessParams` through
13//! [`HdrOutputMode::post_process_params`], so nothing upstream of the display
14//! negotiation can set (or clear) them.
15
16use crate::gfx::render_types::{PostProcessParams, PostProcessTunables};
17
18// Threshold above which the OS-reported max-EDR multiplier is considered an
19// HDR display. macOS reports `1.0` on every panel including SDR ones; values
20// above that mean the panel can drive luminance past the SDR reference white,
21// so 1.0 + epsilon is the minimum useful HDR signal. Most HDR400 displays
22// report 2.0+; HDR1000 displays report 8.0+.
23pub(crate) const HDR_MAX_EDR_FLOOR: f32 = 1.001;
24
25/// HDR encoding the composite shader emits on the EDR path. Drives both
26/// the swapchain colour-space choice (CAMetalLayer on Metal,
27/// `SetColorSpace1` on DirectX) and the shader's per-pixel encode.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum HdrEncoding {
30    /// Pass linear extended-range values through. Swapchain colour space is
31    /// `kCGColorSpaceExtendedLinearDisplayP3`; the OS compositor handles the
32    /// final encode to whatever the panel needs. `1.0` = SDR reference white;
33    /// values above drive the panel's headroom.
34    ExtendedLinear,
35    /// PQ-encode (SMPTE ST 2084) the linear scene before write. Swapchain
36    /// colour space is `kCGColorSpaceDisplayP3_PQ`; the panel decodes via
37    /// the PQ EOTF. Suitable for HDR10 / HDR1000 monitors that prefer
38    /// PQ-encoded values directly. SDR reference white maps to 203 nits per
39    /// ITU-R BT.2408.
40    Pq,
41}
42
43/// Resolved swapchain colour-output mode. Threaded into Metal + DirectX at
44/// init (both honour `Hdr` end-to-end, including the PQ-encoded branch);
45/// Vulkan honours the `ExtendedLinear` `Hdr` arm but ignores the PQ
46/// encoding flag and falls back to SDR on a panel that reports no EDR
47/// headroom.
48#[derive(Debug, Clone, Copy, PartialEq)]
49pub enum HdrOutputMode {
50    /// Tone-map + gamma-encode the HDR scene into the standard BGRA8Unorm
51    /// swapchain. FXAA + ColorLut run.
52    Sdr,
53    /// Drive an EDR-capable swapchain (`RGBA16Float`, Display P3 family
54    /// colour space, `wantsExtendedDynamicRangeContent = true`). The
55    /// composite shader's `hdr_output` branch skips the tonemap, gamma
56    /// encode, FXAA, and LUT. `encoding` picks between scRGB-linear
57    /// passthrough and PQ-encoded output. `max_edr` is the panel-reported
58    /// headroom (e.g. 2.0 on an HDR400 panel, 8.0+ on HDR1000), surfaced
59    /// via the StatHud `EDR` chip.
60    Hdr {
61        /// Reported maximum extended-range colour-component multiplier; SDR
62        /// reference white is 1.0, so values above that drive HDR.
63        max_edr: f32,
64        /// Whether the composite shader emits PQ-encoded values or linear
65        /// extended-range values. Drives both the colour-space tag and the
66        /// shader branch.
67        encoding: HdrEncoding,
68    },
69}
70
71impl HdrOutputMode {
72    /// Build the mode from the world's authored request and the platform's
73    /// measured EDR multiplier. The asset toggle is the gate: even on a
74    /// capable display, no HDR unless `hdr_display = true`. The reverse
75    /// (`hdr_display = true` on an SDR panel) falls back to [`Self::Sdr`]
76    /// and is logged once by the backend. `pq_requested` is honoured only
77    /// when HDR resolves to on; off-by-default keeps the existing
78    /// extended-linear path as the safer fallback.
79    pub fn resolve(hdr_display_requested: bool, pq_requested: bool, max_edr: f32) -> Self {
80        if hdr_display_requested && max_edr.is_finite() && max_edr >= HDR_MAX_EDR_FLOOR {
81            let encoding = if pq_requested {
82                HdrEncoding::Pq
83            } else {
84                HdrEncoding::ExtendedLinear
85            };
86            Self::Hdr { max_edr, encoding }
87        } else {
88            Self::Sdr
89        }
90    }
91
92    /// Value to push into `PostProcessParams.hdr_output` so the composite
93    /// shader's `> 0.5` branch lights up on the HDR path and stays inert
94    /// on the SDR path.
95    pub fn shader_flag(&self) -> f32 {
96        match self {
97            Self::Sdr => 0.0,
98            Self::Hdr { .. } => 1.0,
99        }
100    }
101
102    /// PQ branch value pushed into `PostProcessParams.pq_output`. The shader
103    /// reads it inside its `hdr_output > 0.5` branch and switches between
104    /// linear-passthrough and PQ-encode. Always `0.0` on the SDR path.
105    pub fn pq_flag(&self) -> f32 {
106        matches!(
107            self,
108            Self::Hdr {
109                encoding: HdrEncoding::Pq,
110                ..
111            }
112        ) as i32 as f32
113    }
114
115    /// True when the renderer is on the HDR path. Cheap predicate for log
116    /// messages + the runtime's `hdr_display=on/off` summary line.
117    pub fn is_hdr(&self) -> bool {
118        matches!(self, Self::Hdr { .. })
119    }
120
121    /// Compose the GPU-facing composite uniform from the authored tunables and
122    /// this negotiated mode. The backends call it once at init; afterwards a
123    /// live tunable push goes through `PostProcessParams::set_tunables`, which
124    /// leaves the two flags stamped here alone.
125    pub fn post_process_params(&self, tunables: PostProcessTunables) -> PostProcessParams {
126        PostProcessParams {
127            bloom_intensity: tunables.bloom_intensity,
128            bloom_threshold: tunables.bloom_threshold,
129            bloom_knee: tunables.bloom_knee,
130            exposure: tunables.exposure,
131            vignette: tunables.vignette,
132            lut_strength: tunables.lut_strength,
133            hdr_output: self.shader_flag(),
134            pq_output: self.pq_flag(),
135            fxaa: tunables.fxaa,
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn sdr_request_always_resolves_to_sdr() {
146        assert_eq!(
147            HdrOutputMode::resolve(false, false, 1.0),
148            HdrOutputMode::Sdr
149        );
150        // An SDR request stays SDR even on a capable display.
151        assert_eq!(
152            HdrOutputMode::resolve(false, false, 8.0),
153            HdrOutputMode::Sdr
154        );
155        // The PQ flag is ignored when HDR itself is off.
156        assert_eq!(HdrOutputMode::resolve(false, true, 8.0), HdrOutputMode::Sdr);
157    }
158
159    #[test]
160    fn hdr_request_on_sdr_display_falls_back_to_sdr() {
161        // Apple panels report exactly 1.0 on SDR displays; clamp at floor.
162        assert_eq!(HdrOutputMode::resolve(true, false, 1.0), HdrOutputMode::Sdr);
163        assert_eq!(HdrOutputMode::resolve(true, false, 0.5), HdrOutputMode::Sdr);
164    }
165
166    #[test]
167    fn hdr_request_on_capable_display_defaults_to_extended_linear() {
168        match HdrOutputMode::resolve(true, false, 2.0) {
169            HdrOutputMode::Hdr { max_edr, encoding } => {
170                assert!((max_edr - 2.0).abs() < 1e-6);
171                assert_eq!(encoding, HdrEncoding::ExtendedLinear);
172            }
173            other => panic!("expected Hdr, got {:?}", other),
174        }
175        assert!(HdrOutputMode::resolve(true, false, 8.0).is_hdr());
176    }
177
178    #[test]
179    fn pq_request_on_capable_display_resolves_to_pq() {
180        match HdrOutputMode::resolve(true, true, 8.0) {
181            HdrOutputMode::Hdr { max_edr, encoding } => {
182                assert!((max_edr - 8.0).abs() < 1e-6);
183                assert_eq!(encoding, HdrEncoding::Pq);
184            }
185            other => panic!("expected Hdr/Pq, got {:?}", other),
186        }
187    }
188
189    #[test]
190    fn non_finite_max_edr_falls_back_to_sdr() {
191        assert_eq!(
192            HdrOutputMode::resolve(true, false, f32::NAN),
193            HdrOutputMode::Sdr
194        );
195        assert_eq!(
196            HdrOutputMode::resolve(true, false, f32::INFINITY),
197            HdrOutputMode::Sdr
198        );
199    }
200
201    #[test]
202    fn shader_flag_matches_mode() {
203        assert_eq!(HdrOutputMode::Sdr.shader_flag(), 0.0);
204        assert_eq!(
205            HdrOutputMode::Hdr {
206                max_edr: 4.0,
207                encoding: HdrEncoding::ExtendedLinear,
208            }
209            .shader_flag(),
210            1.0
211        );
212    }
213
214    #[test]
215    fn pq_flag_is_set_only_on_the_pq_branch() {
216        assert_eq!(HdrOutputMode::Sdr.pq_flag(), 0.0);
217        assert_eq!(
218            HdrOutputMode::Hdr {
219                max_edr: 4.0,
220                encoding: HdrEncoding::ExtendedLinear,
221            }
222            .pq_flag(),
223            0.0
224        );
225        assert_eq!(
226            HdrOutputMode::Hdr {
227                max_edr: 8.0,
228                encoding: HdrEncoding::Pq,
229            }
230            .pq_flag(),
231            1.0
232        );
233    }
234
235    // The one place the output flags enter the composite uniform: the tunables
236    // come through untouched, the two flags come from the negotiated mode.
237    #[test]
238    fn composed_params_carry_the_tunables_and_the_modes_flags() {
239        let tunables = PostProcessTunables {
240            exposure: 4.0,
241            vignette: 0.25,
242            ..PostProcessTunables::DEFAULT
243        };
244
245        let sdr = HdrOutputMode::Sdr.post_process_params(tunables);
246        assert_eq!(sdr.exposure, 4.0);
247        assert_eq!(sdr.vignette, 0.25);
248        assert_eq!(sdr.hdr_output, 0.0);
249        assert_eq!(sdr.pq_output, 0.0);
250
251        let hdr = HdrOutputMode::Hdr {
252            max_edr: 8.0,
253            encoding: HdrEncoding::Pq,
254        }
255        .post_process_params(tunables);
256        assert_eq!(hdr.exposure, 4.0);
257        assert_eq!(hdr.hdr_output, 1.0);
258        assert_eq!(hdr.pq_output, 1.0);
259    }
260
261    // The live-push path a settings slider drives: it may move any tunable, but
262    // it cannot drop the EDR path the display negotiation stamped in.
263    #[test]
264    fn a_tunable_push_leaves_the_negotiated_output_flags_standing() {
265        let mode = HdrOutputMode::Hdr {
266            max_edr: 8.0,
267            encoding: HdrEncoding::Pq,
268        };
269        let mut params = mode.post_process_params(PostProcessTunables::DEFAULT);
270
271        // What the engine resolves and pushes on a slider drag: no output flags
272        // to speak of, so the SDR fallback is not reachable from here.
273        params.set_tunables(PostProcessTunables {
274            exposure: 0.5,
275            bloom_intensity: 0.0,
276            fxaa: 0.0,
277            ..PostProcessTunables::DEFAULT
278        });
279
280        assert_eq!(params.exposure, 0.5);
281        assert_eq!(params.bloom_intensity, 0.0);
282        assert_eq!(params.fxaa, 0.0);
283        assert_eq!(params.hdr_output, 1.0);
284        assert_eq!(params.pq_output, 1.0);
285    }
286}