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