Skip to main content

oxiui_render_wgpu/gpu/
hdr.rs

1//! HDR and wide-gamut surface format selection for the wgpu backend.
2//!
3//! This module provides:
4//!
5//! - [`SurfaceColorFormat`] — an enumeration of supported colour formats with
6//!   their metadata (bits per channel, colour gamut, HDR capability).
7//! - [`HdrGpuContext`] — a headless GPU context that uses `Rgba16Float`
8//!   (wide-gamut / HDR) instead of the default `Rgba8Unorm`.
9//! - [`select_surface_format`] — a heuristic that picks the best available
10//!   format from a list of adapter-supported formats, preferring HDR variants
11//!   when available and the caller opts in.
12//!
13//! # Colour-space handling in the fragment shader
14//!
15//! `Rgba8Unorm` stores gamma-encoded sRGB values; `Rgba16Float` stores linear
16//! light values in the Rec.2020 or Display-P3 colour space (depending on the
17//! OS colour management layer).  When rendering into an `Rgba16Float` target
18//! the fragment shader should output linear light values; the display pipeline
19//! applies the appropriate OETF (transfer function) on presentation.
20//!
21//! For **headless / offscreen** rendering (`HdrGpuContext`) the application
22//! reads back raw `f16` values; it is the caller's responsibility to apply any
23//! tone-mapping required before displaying or saving the image.
24
25use oxiui_core::UiError;
26
27// ── SurfaceColorFormat ────────────────────────────────────────────────────────
28
29/// A supported colour surface format.
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
31pub enum SurfaceColorFormat {
32    /// 8 bits per channel, sRGB-encoded.  Standard SDR format.
33    #[default]
34    Rgba8Unorm,
35    /// 8 bits per channel, gamma-corrected sRGB (GPU applies sRGB encoding on
36    /// store / decoding on sample).
37    Rgba8UnormSrgb,
38    /// 8 bits per channel, BGRA byte order (common on Windows/Metal).
39    Bgra8Unorm,
40    /// 8 bits per channel, BGRA, sRGB.
41    Bgra8UnormSrgb,
42    /// 10 bits per colour channel + 2-bit alpha, sRGB.  Increases precision for
43    /// SDR content; may be used for extended-range (scRGB) content.
44    Rgb10a2Unorm,
45    /// 16 bits per channel, floating-point (linear light).  HDR-capable.
46    Rgba16Float,
47}
48
49impl SurfaceColorFormat {
50    /// Return the corresponding [`wgpu::TextureFormat`].
51    pub fn wgpu_format(self) -> wgpu::TextureFormat {
52        match self {
53            Self::Rgba8Unorm => wgpu::TextureFormat::Rgba8Unorm,
54            Self::Rgba8UnormSrgb => wgpu::TextureFormat::Rgba8UnormSrgb,
55            Self::Bgra8Unorm => wgpu::TextureFormat::Bgra8Unorm,
56            Self::Bgra8UnormSrgb => wgpu::TextureFormat::Bgra8UnormSrgb,
57            Self::Rgb10a2Unorm => wgpu::TextureFormat::Rgb10a2Unorm,
58            Self::Rgba16Float => wgpu::TextureFormat::Rgba16Float,
59        }
60    }
61
62    /// Return `true` if this format is capable of representing HDR values
63    /// (i.e. luminance > 1.0 in linear light).
64    pub fn is_hdr(self) -> bool {
65        matches!(self, Self::Rgba16Float)
66    }
67
68    /// Return the nominal bits per channel for this format.
69    pub fn bits_per_channel(self) -> u32 {
70        match self {
71            Self::Rgba8Unorm | Self::Rgba8UnormSrgb | Self::Bgra8Unorm | Self::Bgra8UnormSrgb => 8,
72            Self::Rgb10a2Unorm => 10,
73            Self::Rgba16Float => 16,
74        }
75    }
76
77    /// Return `true` if the GPU pipeline should output *linear* light values
78    /// when rendering into this format (as opposed to gamma-encoded sRGB).
79    ///
80    /// `Rgba16Float` expects linear light.  The `Unorm` variants are usually
81    /// treated as gamma-encoded sRGB in practice; the `UnormSrgb` variants
82    /// have explicit sRGB encoding built into the attachment load/store.
83    pub fn expects_linear_light(self) -> bool {
84        matches!(self, Self::Rgba16Float)
85    }
86}
87
88// ── select_surface_format ─────────────────────────────────────────────────────
89
90/// Choose the best format from `supported_formats` given a preference.
91///
92/// When `prefer_hdr` is `true`, `Rgba16Float` is preferred if available.
93/// Otherwise, sRGB variants are preferred over linear unorm (for correct gamma
94/// on standard SDR displays).  Falls back to the first format in the list, or
95/// [`SurfaceColorFormat::Rgba8Unorm`] if the list is empty.
96pub fn select_surface_format(
97    supported_formats: &[wgpu::TextureFormat],
98    prefer_hdr: bool,
99) -> SurfaceColorFormat {
100    // Map known wgpu formats to our enum.
101    let mapped: Vec<SurfaceColorFormat> = supported_formats
102        .iter()
103        .filter_map(|&f| match f {
104            wgpu::TextureFormat::Rgba8Unorm => Some(SurfaceColorFormat::Rgba8Unorm),
105            wgpu::TextureFormat::Rgba8UnormSrgb => Some(SurfaceColorFormat::Rgba8UnormSrgb),
106            wgpu::TextureFormat::Bgra8Unorm => Some(SurfaceColorFormat::Bgra8Unorm),
107            wgpu::TextureFormat::Bgra8UnormSrgb => Some(SurfaceColorFormat::Bgra8UnormSrgb),
108            wgpu::TextureFormat::Rgb10a2Unorm => Some(SurfaceColorFormat::Rgb10a2Unorm),
109            wgpu::TextureFormat::Rgba16Float => Some(SurfaceColorFormat::Rgba16Float),
110            _ => None,
111        })
112        .collect();
113
114    if mapped.is_empty() {
115        return SurfaceColorFormat::default();
116    }
117
118    if prefer_hdr {
119        // Prefer HDR formats in order of quality.
120        for candidate in &[
121            SurfaceColorFormat::Rgba16Float,
122            SurfaceColorFormat::Rgb10a2Unorm,
123        ] {
124            if mapped.contains(candidate) {
125                return *candidate;
126            }
127        }
128    }
129
130    // Prefer sRGB variants for correct SDR gamma.
131    for candidate in &[
132        SurfaceColorFormat::Bgra8UnormSrgb,
133        SurfaceColorFormat::Rgba8UnormSrgb,
134        SurfaceColorFormat::Bgra8Unorm,
135        SurfaceColorFormat::Rgba8Unorm,
136    ] {
137        if mapped.contains(candidate) {
138            return *candidate;
139        }
140    }
141
142    mapped[0]
143}
144
145// ── HdrGpuContext ─────────────────────────────────────────────────────────────
146
147/// A headless GPU context backed by an `Rgba16Float` offscreen texture.
148///
149/// Unlike [`crate::GpuContext`] (which uses `Rgba8Unorm` for byte-exact
150/// readback), `HdrGpuContext` uses `Rgba16Float` so fragment shaders can
151/// output linear-light values in `[0, +∞)`.  Readback returns raw `f16`
152/// bytes; use a half-to-float conversion to get the actual float values.
153///
154/// HDR capability requires the adapter to support `Rgba16Float` as a render
155/// attachment.  The `from_device` constructor validates this and returns
156/// [`UiError::Unsupported`] if the format is not supported.
157pub struct HdrGpuContext {
158    /// The logical GPU device.
159    pub device: wgpu::Device,
160    /// The command queue.
161    pub queue: wgpu::Queue,
162    /// The `Rgba16Float` offscreen texture.
163    pub color_texture: wgpu::Texture,
164    /// View over `color_texture`.
165    pub color_view: wgpu::TextureView,
166    /// Target width in physical pixels.
167    pub width: u32,
168    /// Target height in physical pixels.
169    pub height: u32,
170}
171
172/// The HDR offscreen format.
173pub const HDR_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
174
175impl HdrGpuContext {
176    /// Create a headless HDR context.
177    ///
178    /// # Errors
179    ///
180    /// - [`UiError::Unsupported`] if no GPU adapter is available, if the
181    ///   dimensions are zero, or if the adapter does not support `Rgba16Float`
182    ///   as a render attachment.
183    /// - [`UiError::Backend`] if device creation fails.
184    pub fn headless(width: u32, height: u32) -> Result<Self, UiError> {
185        if width == 0 || height == 0 {
186            return Err(UiError::Unsupported(
187                "HdrGpuContext dimensions must be non-zero".to_string(),
188            ));
189        }
190
191        let instance = wgpu::Instance::default();
192        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
193            power_preference: wgpu::PowerPreference::default(),
194            force_fallback_adapter: false,
195            compatible_surface: None,
196        }))
197        .map_err(|e| UiError::Unsupported(format!("no GPU adapter: {e}")))?;
198
199        // Verify Rgba16Float RENDER_ATTACHMENT support.
200        let fmt_features = adapter.get_texture_format_features(HDR_FORMAT);
201        if !fmt_features
202            .allowed_usages
203            .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
204        {
205            return Err(UiError::Unsupported(
206                "adapter does not support Rgba16Float as a render attachment".to_string(),
207            ));
208        }
209
210        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
211            label: Some("oxiui-render-wgpu hdr device"),
212            required_features: wgpu::Features::empty(),
213            required_limits: wgpu::Limits::downlevel_defaults(),
214            memory_hints: wgpu::MemoryHints::Performance,
215            experimental_features: wgpu::ExperimentalFeatures::disabled(),
216            trace: wgpu::Trace::Off,
217        }))
218        .map_err(|e| UiError::Backend(format!("HDR GPU device request failed: {e}")))?;
219
220        let color_texture = device.create_texture(&wgpu::TextureDescriptor {
221            label: Some("oxiui-render-wgpu hdr target"),
222            size: wgpu::Extent3d {
223                width,
224                height,
225                depth_or_array_layers: 1,
226            },
227            mip_level_count: 1,
228            sample_count: 1,
229            dimension: wgpu::TextureDimension::D2,
230            format: HDR_FORMAT,
231            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
232            view_formats: &[],
233        });
234        let color_view = color_texture.create_view(&wgpu::TextureViewDescriptor::default());
235
236        Ok(Self {
237            device,
238            queue,
239            color_texture,
240            color_view,
241            width,
242            height,
243        })
244    }
245
246    /// Read back the HDR texture as raw bytes.
247    ///
248    /// The returned buffer contains `width * height * 8` bytes (4 channels ×
249    /// 2 bytes per channel, f16 little-endian), tightly packed (row padding
250    /// stripped).
251    ///
252    /// # Errors
253    ///
254    /// Returns [`UiError::Render`] if the GPU poll or buffer mapping fails.
255    pub fn readback_f16(&self) -> Result<Vec<u8>, UiError> {
256        // f16 = 2 bytes per channel × 4 channels = 8 bytes per pixel.
257        let bytes_per_pixel = 8u32;
258        let unpadded_bytes_per_row = self.width * bytes_per_pixel;
259        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
260        let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align;
261        let buffer_size = (padded_bytes_per_row * self.height) as wgpu::BufferAddress;
262
263        let readback = self.device.create_buffer(&wgpu::BufferDescriptor {
264            label: Some("oxiui-render-wgpu hdr readback"),
265            size: buffer_size,
266            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
267            mapped_at_creation: false,
268        });
269
270        let mut encoder = self
271            .device
272            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
273                label: Some("oxiui-render-wgpu hdr readback encoder"),
274            });
275
276        encoder.copy_texture_to_buffer(
277            wgpu::TexelCopyTextureInfo {
278                texture: &self.color_texture,
279                mip_level: 0,
280                origin: wgpu::Origin3d::ZERO,
281                aspect: wgpu::TextureAspect::All,
282            },
283            wgpu::TexelCopyBufferInfo {
284                buffer: &readback,
285                layout: wgpu::TexelCopyBufferLayout {
286                    offset: 0,
287                    bytes_per_row: Some(padded_bytes_per_row),
288                    rows_per_image: Some(self.height),
289                },
290            },
291            wgpu::Extent3d {
292                width: self.width,
293                height: self.height,
294                depth_or_array_layers: 1,
295            },
296        );
297
298        self.queue.submit(Some(encoder.finish()));
299
300        let slice = readback.slice(..);
301        slice.map_async(wgpu::MapMode::Read, |_| {});
302        self.device
303            .poll(wgpu::PollType::wait_indefinitely())
304            .map_err(|e| UiError::Render(format!("HdrGpuContext GPU poll failed: {e:?}")))?;
305
306        let data = slice.get_mapped_range();
307        let mut out = Vec::with_capacity((unpadded_bytes_per_row * self.height) as usize);
308        for row in 0..self.height {
309            let start = (row * padded_bytes_per_row) as usize;
310            let end = start + unpadded_bytes_per_row as usize;
311            out.extend_from_slice(&data[start..end]);
312        }
313        drop(data);
314        readback.unmap();
315        Ok(out)
316    }
317}
318
319// ── Tests ─────────────────────────────────────────────────────────────────────
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn surface_color_format_is_hdr() {
327        assert!(SurfaceColorFormat::Rgba16Float.is_hdr());
328        assert!(!SurfaceColorFormat::Rgba8Unorm.is_hdr());
329        assert!(!SurfaceColorFormat::Bgra8UnormSrgb.is_hdr());
330    }
331
332    #[test]
333    fn surface_color_format_bits_per_channel() {
334        assert_eq!(SurfaceColorFormat::Rgba8Unorm.bits_per_channel(), 8);
335        assert_eq!(SurfaceColorFormat::Rgb10a2Unorm.bits_per_channel(), 10);
336        assert_eq!(SurfaceColorFormat::Rgba16Float.bits_per_channel(), 16);
337    }
338
339    #[test]
340    fn surface_color_format_expects_linear() {
341        assert!(SurfaceColorFormat::Rgba16Float.expects_linear_light());
342        assert!(!SurfaceColorFormat::Rgba8Unorm.expects_linear_light());
343        assert!(!SurfaceColorFormat::Rgba8UnormSrgb.expects_linear_light());
344    }
345
346    #[test]
347    fn select_surface_format_prefers_hdr_when_available() {
348        let fmts = &[
349            wgpu::TextureFormat::Bgra8UnormSrgb,
350            wgpu::TextureFormat::Rgba16Float,
351        ];
352        let chosen = select_surface_format(fmts, true);
353        assert_eq!(chosen, SurfaceColorFormat::Rgba16Float);
354    }
355
356    #[test]
357    fn select_surface_format_falls_back_to_srgb_when_no_hdr() {
358        let fmts = &[
359            wgpu::TextureFormat::Rgba8Unorm,
360            wgpu::TextureFormat::Bgra8UnormSrgb,
361        ];
362        let chosen = select_surface_format(fmts, true); // HDR not available
363                                                        // Should pick sRGB variant.
364        assert_eq!(chosen, SurfaceColorFormat::Bgra8UnormSrgb);
365    }
366
367    #[test]
368    fn select_surface_format_prefers_srgb_without_hdr_preference() {
369        let fmts = &[
370            wgpu::TextureFormat::Rgba8Unorm,
371            wgpu::TextureFormat::Bgra8UnormSrgb,
372            wgpu::TextureFormat::Rgba16Float,
373        ];
374        let chosen = select_surface_format(fmts, false);
375        // Without HDR preference, picks sRGB first.
376        assert_eq!(chosen, SurfaceColorFormat::Bgra8UnormSrgb);
377    }
378
379    #[test]
380    fn select_surface_format_empty_list_returns_default() {
381        let chosen = select_surface_format(&[], false);
382        assert_eq!(chosen, SurfaceColorFormat::default());
383    }
384
385    #[test]
386    fn hdr_gpu_context_creates_or_skips() {
387        // Either creates successfully or returns Unsupported (no GPU / no f16 support).
388        match HdrGpuContext::headless(32, 32) {
389            Ok(ctx) => {
390                assert_eq!(ctx.width, 32);
391                assert_eq!(ctx.height, 32);
392            }
393            Err(e @ oxiui_core::UiError::Unsupported(_)) => {
394                println!("skip: HDR not supported: {e}");
395            }
396            Err(e) => {
397                panic!("unexpected error creating HdrGpuContext: {e}");
398            }
399        }
400    }
401
402    #[test]
403    fn hdr_format_is_rgba16float() {
404        assert_eq!(HDR_FORMAT, wgpu::TextureFormat::Rgba16Float);
405    }
406}