concinnity_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::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}