Skip to main content

concinnity_core/gfx/
image_decode.rs

1//! GPU-free pixel-decode math shared by the backends' frame-capture paths.
2//! Turns the raw bytes read back from a GPU texture into tightly-packed opaque
3//! RGBA8. The backend classifies its own format enum (MTLPixelFormat /
4//! vk::Format / DXGI_FORMAT) into a `PixelLayout` and calls `decode_to_rgba8`;
5//! the per-channel math here is identical across backends. PNG encoding + file
6//! I/O stay in the backends (debug-only, and would pull std::fs / the png crate
7//! into core).
8
9use crate::math::{powf, powi};
10use alloc::vec::Vec;
11
12/// The decode layout of the raw read-back bytes, classified from the backend's
13/// own swapchain / texture format. Carries everything the decoder needs so the
14/// math stays free of any backend format enum.
15#[derive(Clone, Copy, PartialEq, Eq, Debug)]
16pub enum PixelLayout {
17    /// 8-bit BGRA (the common SDR swapchain on Windows / macOS); channels are
18    /// swizzled to RGBA and alpha is forced opaque.
19    Bgra8,
20    /// 8-bit RGBA; passes through with alpha forced opaque.
21    Rgba8,
22    /// Four IEEE 754 halfs (8 B/px). `scrgb` true applies the sRGB OETF to the
23    /// linear extended-range values; false passes PQ code values through clamped.
24    Rgba16F {
25        /// Apply the sRGB OETF to the linear extended-range values.
26        scrgb: bool,
27    },
28    /// Packed 2-10-10-10 unorm (one little-endian u32 per texel, R in the low 10
29    /// bits); the PQ fallback swapchain. Not display-ready, but a valid image.
30    A2b10g10r10,
31}
32
33/// Convert the tightly-packed read-back bytes to opaque RGBA8, decoding per the
34/// classified layout. The alpha channel is forced to 255 so a saved image is
35/// opaque regardless of the composited alpha.
36pub fn decode_to_rgba8(raw: &[u8], layout: PixelLayout) -> Vec<u8> {
37    match layout {
38        PixelLayout::Bgra8 => decode_8bit(raw, true),
39        PixelLayout::Rgba8 => decode_8bit(raw, false),
40        PixelLayout::Rgba16F { scrgb } => decode_rgba16f(raw, scrgb),
41        PixelLayout::A2b10g10r10 => decode_a2b10g10r10(raw),
42    }
43}
44
45// 8-bit-per-channel formats (4 B/px). `bgra` swizzles B and R; alpha is forced
46// opaque.
47fn decode_8bit(raw: &[u8], bgra: bool) -> Vec<u8> {
48    let mut out = Vec::with_capacity(raw.len());
49    for px in raw.chunks_exact(4) {
50        if bgra {
51            out.extend_from_slice(&[px[2], px[1], px[0], 255]);
52        } else {
53            out.extend_from_slice(&[px[0], px[1], px[2], 255]);
54        }
55    }
56    out
57}
58
59// `RGBA16Float` HDR read-back (8 B/px, four halfs RGBA). On the scRGB-linear
60// path the stored values are linear extended-range (1.0 = SDR white), so apply
61// the sRGB OETF to get a valid (non-tonemapped) image. On the PQ path the stored
62// values are PQ code values already in [0, 1]; pass them through clamped.
63fn decode_rgba16f(raw: &[u8], scrgb: bool) -> Vec<u8> {
64    let mut out = Vec::with_capacity(raw.len() / 2);
65    for px in raw.chunks_exact(8) {
66        let r = f16_to_f32(u16::from_le_bytes([px[0], px[1]]));
67        let g = f16_to_f32(u16::from_le_bytes([px[2], px[3]]));
68        let b = f16_to_f32(u16::from_le_bytes([px[4], px[5]]));
69        if scrgb {
70            out.extend_from_slice(&[
71                linear_to_srgb8(r),
72                linear_to_srgb8(g),
73                linear_to_srgb8(b),
74                255,
75            ]);
76        } else {
77            out.extend_from_slice(&[unorm_to_u8(r), unorm_to_u8(g), unorm_to_u8(b), 255]);
78        }
79    }
80    out
81}
82
83// `A2B10G10R10_UNORM_PACK32` PQ fallback (4 B/px, one little-endian u32 per
84// texel: R in bits [9:0], G [19:10], B [29:20], A [31:30]). The values are PQ
85// code values, so this is not display-ready, but it is a valid image.
86fn decode_a2b10g10r10(raw: &[u8]) -> Vec<u8> {
87    let mut out = Vec::with_capacity(raw.len());
88    for px in raw.chunks_exact(4) {
89        let v = u32::from_le_bytes([px[0], px[1], px[2], px[3]]);
90        let r = v & 0x3ff;
91        let g = (v >> 10) & 0x3ff;
92        let b = (v >> 20) & 0x3ff;
93        out.extend_from_slice(&[u10_to_u8(r), u10_to_u8(g), u10_to_u8(b), 255]);
94    }
95    out
96}
97
98// Decode an IEEE 754 half (binary16) to f32. Handles zero, subnormals, normals,
99// and inf/NaN.
100fn f16_to_f32(h: u16) -> f32 {
101    let sign = if (h >> 15) & 1 == 1 { -1.0 } else { 1.0 };
102    let exp = (h >> 10) & 0x1f;
103    let mant = (h & 0x3ff) as f32;
104    let val = match exp {
105        0 => mant * powi(2.0, -24),
106        0x1f => {
107            if mant == 0.0 {
108                f32::INFINITY
109            } else {
110                f32::NAN
111            }
112        }
113        _ => (1.0 + mant / 1024.0) * powi(2.0, exp as i32 - 15),
114    };
115    sign * val
116}
117
118// sRGB OETF (linear -> display), clamped and quantised to 8-bit. NaN maps to 0.
119fn linear_to_srgb8(c: f32) -> u8 {
120    if c.is_nan() {
121        return 0;
122    }
123    let c = c.clamp(0.0, 1.0);
124    let s = if c <= 0.0031308 {
125        12.92 * c
126    } else {
127        1.055 * powf(c, 1.0 / 2.4) - 0.055
128    };
129    unorm_to_u8(s)
130}
131
132// Quantise a [0, 1] value to 8-bit with rounding. NaN maps to 0.
133fn unorm_to_u8(c: f32) -> u8 {
134    if c.is_nan() {
135        return 0;
136    }
137    (c.clamp(0.0, 1.0) * 255.0 + 0.5) as u8
138}
139
140// Scale a 10-bit unsigned value (0..=1023) to 8-bit with rounding.
141fn u10_to_u8(v: u32) -> u8 {
142    ((v * 255 + 511) / 1023) as u8
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use alloc::vec;
149
150    #[test]
151    fn f16_round_trips_reference_values() {
152        assert_eq!(f16_to_f32(0x0000), 0.0); // +0
153        assert_eq!(f16_to_f32(0x3c00), 1.0); // 1.0
154        assert_eq!(f16_to_f32(0x3800), 0.5); // 0.5
155        assert_eq!(f16_to_f32(0x4000), 2.0); // 2.0
156        assert_eq!(f16_to_f32(0xbc00), -1.0); // -1.0
157        assert!(f16_to_f32(0x7c00).is_infinite()); // +inf
158        assert!(f16_to_f32(0x7e00).is_nan()); // NaN
159    }
160
161    #[test]
162    fn bgra8_is_swizzled_and_made_opaque() {
163        // One BGRA pixel (B=10, G=20, R=30, A=40) -> RGBA (30, 20, 10, 255).
164        let raw = [10u8, 20, 30, 40];
165        let out = decode_to_rgba8(&raw, PixelLayout::Bgra8);
166        assert_eq!(out, vec![30, 20, 10, 255]);
167    }
168
169    #[test]
170    fn rgba8_passes_through_with_forced_alpha() {
171        let raw = [30u8, 20, 10, 40];
172        let out = decode_to_rgba8(&raw, PixelLayout::Rgba8);
173        assert_eq!(out, vec![30, 20, 10, 255]);
174    }
175
176    #[test]
177    fn scrgb_float_applies_srgb_oetf() {
178        // Linear 1.0 -> sRGB 255, 0.0 -> 0, 0.5 -> ~188 (1.055*0.5^(1/2.4)-0.055).
179        let mut raw = Vec::new();
180        for h in [0x3c00u16, 0x3800, 0x0000, 0x3c00] {
181            raw.extend_from_slice(&h.to_le_bytes());
182        }
183        let out = decode_to_rgba8(&raw, PixelLayout::Rgba16F { scrgb: true });
184        assert_eq!(out[0], 255); // r = linear 1.0
185        assert!((out[1] as i32 - 188).abs() <= 1); // g = linear 0.5
186        assert_eq!(out[2], 0); // b = linear 0.0
187        assert_eq!(out[3], 255); // forced opaque
188    }
189
190    #[test]
191    fn scrgb_float_clamps_out_of_range() {
192        // Extended-range > 1.0 and negative clamp to white / black.
193        let mut raw = Vec::new();
194        for h in [0x4000u16, 0xbc00, 0x0000, 0x3c00] {
195            raw.extend_from_slice(&h.to_le_bytes());
196        }
197        let out = decode_to_rgba8(&raw, PixelLayout::Rgba16F { scrgb: true });
198        assert_eq!(out[0], 255); // r = 2.0 clamps high
199        assert_eq!(out[1], 0); // g = -1.0 clamps low
200    }
201
202    #[test]
203    fn pq_float_passes_code_values_through() {
204        // PQ code values are already in [0, 1]; no sRGB OETF, just quantise.
205        let mut raw = Vec::new();
206        for h in [0x3c00u16, 0x3800, 0x0000, 0x3c00] {
207            raw.extend_from_slice(&h.to_le_bytes());
208        }
209        let out = decode_to_rgba8(&raw, PixelLayout::Rgba16F { scrgb: false });
210        assert_eq!(out[0], 255); // 1.0
211        assert_eq!(out[1], 128); // 0.5 -> round(127.5)
212        assert_eq!(out[2], 0); // 0.0
213        assert_eq!(out[3], 255);
214    }
215
216    #[test]
217    fn a2b10g10r10_unpacks_channels() {
218        // R=1023, G=0, B=1023, A=3 packed little-endian.
219        let v: u32 = 1023 | (1023 << 20) | (3 << 30);
220        let out = decode_to_rgba8(&v.to_le_bytes(), PixelLayout::A2b10g10r10);
221        assert_eq!(out, vec![255, 0, 255, 255]);
222    }
223}