Skip to main content

concinnity_core/build/
texture.rs

1//! Texture payload format helpers shared between the runtime and the build
2//! crate. The file -> pixels decoders (PNG / JPEG / DDS / TGA / KTX2 /
3//! glb-embedded images) live in `concinnity_cook::texture`; this module keeps
4//! only what a running engine needs with no image-decode dependencies: turning a
5//! compiled payload back into a [`TextureImage`] (`deserialise`) and the
6//! box-filter `downscale_rgba` the build pipeline uses to cap oversized source
7//! maps.
8//!
9//! One tagged format serves every 2D texture (little-endian):
10//!   u32  magic      = b"TEX2"
11//!   u32  format_id  (0 RGBA8, 1 BC1, 2 BC3, 3 BC5, 4 BC7)
12//!   u32  mip_count  (>= 1)
13//!   per mip, level 0 first (largest):
14//!     u32  width
15//!     u32  height
16//!     u32  byte_len
17//!     byte_len bytes of level data
18//!
19//! RGBA8 sources (PNG / JPEG / procedural generators) carry a single mip; the
20//! backend upload generates the minification chain (`concinnity_render::mipmap`).
21//! Block-compressed sources (KTX2 / DDS) carry the container's full mip chain and
22//! upload it verbatim, since no runtime BCn encoder exists.
23
24// Magic tagging every compiled 2D texture payload.
25use crate::decode::{ByteReader, checked_product};
26use crate::math::ceil;
27use alloc::format;
28use alloc::string::String;
29use alloc::vec;
30use alloc::vec::Vec;
31
32/// A u32 dimension bottoms out at 1x1 after at most this many halvings, so a
33/// file or payload declaring more levels than this is malformed rather than
34/// large. Decoders bound a declared mip count against it before reserving.
35pub const MAX_MIP_LEVELS: usize = 32;
36
37pub(crate) const TEXTURE_PAYLOAD_MAGIC: u32 = u32::from_le_bytes(*b"TEX2");
38const HEADER_BYTES: usize = 12;
39
40/// GPU pixel format of a compiled texture payload. RGBA8 is the uncompressed
41/// path (runtime mip generation); the rest are block-compressed formats uploaded
42/// with their container mip chains.
43#[derive(Clone, Copy, PartialEq, Eq, Debug)]
44pub enum TextureFormat {
45    /// Uncompressed 8-bit RGBA; mips are generated at upload.
46    Rgba8,
47    /// BC1 block compression.
48    Bc1,
49    /// BC3 block compression.
50    Bc3,
51    /// BC5 block compression, for normal maps.
52    Bc5,
53    /// BC7 block compression.
54    Bc7,
55}
56
57impl TextureFormat {
58    /// Stable on-disk identifier written into the payload header.
59    pub fn id(self) -> u32 {
60        match self {
61            TextureFormat::Rgba8 => 0,
62            TextureFormat::Bc1 => 1,
63            TextureFormat::Bc3 => 2,
64            TextureFormat::Bc5 => 3,
65            TextureFormat::Bc7 => 4,
66        }
67    }
68
69    pub(crate) fn from_id(id: u32) -> Option<Self> {
70        match id {
71            0 => Some(TextureFormat::Rgba8),
72            1 => Some(TextureFormat::Bc1),
73            2 => Some(TextureFormat::Bc3),
74            3 => Some(TextureFormat::Bc5),
75            4 => Some(TextureFormat::Bc7),
76            _ => None,
77        }
78    }
79
80    /// Bytes per 4x4 block for a compressed format. `None` for RGBA8, which is
81    /// sized per pixel rather than per block.
82    pub fn block_bytes(self) -> Option<usize> {
83        match self {
84            TextureFormat::Rgba8 => None,
85            TextureFormat::Bc1 => Some(8),
86            TextureFormat::Bc3 | TextureFormat::Bc5 | TextureFormat::Bc7 => Some(16),
87        }
88    }
89
90    /// Byte length one mip of `width` x `height` occupies in this format.
91    /// Dimensions are read out of the file being decoded, so a footprint that
92    /// overflows `usize` is reported rather than wrapped into a small length
93    /// that would pass the caller's bounds check.
94    pub fn mip_byte_len(self, width: u32, height: u32) -> Result<usize, String> {
95        match self.block_bytes() {
96            None => checked_product("texture mip", &[width as usize, height as usize, 4]),
97            Some(block) => checked_product(
98                "texture mip",
99                &[
100                    width.div_ceil(4) as usize,
101                    height.div_ceil(4) as usize,
102                    block,
103                ],
104            ),
105        }
106    }
107}
108
109/// One mip level: dimensions plus its tightly packed level bytes (RGBA8 pixels or
110/// block-compressed data, per the owning image's format).
111#[derive(Clone, Debug, PartialEq, Eq)]
112pub struct TextureMip {
113    /// Width in pixels.
114    pub width: u32,
115    /// Height in pixels.
116    pub height: u32,
117    /// Pixels or compressed blocks of this level.
118    pub data: Vec<u8>,
119}
120
121/// A decoded 2D texture: its GPU format plus one or more mip levels, level 0
122/// first. The backend uploads `mips` directly for compressed formats and
123/// generates the minification chain from `mips[0]` for RGBA8.
124#[derive(Clone, Debug, PartialEq, Eq)]
125pub struct TextureImage {
126    /// GPU pixel format of every mip.
127    pub format: TextureFormat,
128    /// Mip levels, level 0 first.
129    pub mips: Vec<TextureMip>,
130}
131
132impl TextureImage {
133    /// Wrap a single RGBA8 level (the PNG / JPEG / procedural path). The upload
134    /// path generates the mip chain.
135    pub fn rgba8(width: u32, height: u32, pixels: Vec<u8>) -> Self {
136        TextureImage {
137            format: TextureFormat::Rgba8,
138            mips: vec![TextureMip {
139                width,
140                height,
141                data: pixels,
142            }],
143        }
144    }
145
146    /// Base (level 0) dimensions.
147    pub fn width(&self) -> u32 {
148        self.mips.first().map(|m| m.width).unwrap_or(0)
149    }
150
151    /// Base (level 0) height in pixels.
152    pub fn height(&self) -> u32 {
153        self.mips.first().map(|m| m.height).unwrap_or(0)
154    }
155
156    /// Total resident bytes across every mip level (streaming budget accounting).
157    pub fn byte_len(&self) -> usize {
158        self.mips.iter().map(|m| m.data.len()).sum()
159    }
160
161    /// Recover the base RGBA8 pixels, or an error if the image is block
162    /// compressed. Used by the sprite / glyph-atlas paths, which upload RGBA8
163    /// only.
164    pub fn into_rgba8(self) -> Result<(u32, u32, Vec<u8>), String> {
165        if self.format != TextureFormat::Rgba8 {
166            return Err(format!(
167                "texture is {:?}, expected RGBA8 for this path",
168                self.format
169            ));
170        }
171        let mip = self
172            .mips
173            .into_iter()
174            .next()
175            .ok_or("RGBA8 texture has no mip level")?;
176        Ok((mip.width, mip.height, mip.data))
177    }
178}
179
180/// Serialise a [`TextureImage`] into the tagged payload the runtime reads. The
181/// build crate writes payloads through this so the reader and writer share one
182/// format definition.
183pub fn serialise(image: &TextureImage) -> Vec<u8> {
184    let total: usize = HEADER_BYTES + image.mips.iter().map(|m| 12 + m.data.len()).sum::<usize>();
185    let mut buf = Vec::with_capacity(total);
186    buf.extend_from_slice(&TEXTURE_PAYLOAD_MAGIC.to_le_bytes());
187    buf.extend_from_slice(&image.format.id().to_le_bytes());
188    buf.extend_from_slice(&(image.mips.len() as u32).to_le_bytes());
189    for mip in &image.mips {
190        buf.extend_from_slice(&mip.width.to_le_bytes());
191        buf.extend_from_slice(&mip.height.to_le_bytes());
192        buf.extend_from_slice(&(mip.data.len() as u32).to_le_bytes());
193        buf.extend_from_slice(&mip.data);
194    }
195    buf
196}
197
198/// Deserialise a tagged payload back into a [`TextureImage`].
199///
200/// Called by GraphicsSystem at runtime to recover texture format, dimensions,
201/// and mip data before uploading to the GPU.
202pub fn deserialise(bytes: &[u8]) -> Result<TextureImage, String> {
203    let mut r = ByteReader::open_payload(bytes, TEXTURE_PAYLOAD_MAGIC, HEADER_BYTES, "texture")?;
204    let format_id = r.u32()?;
205    let format = TextureFormat::from_id(format_id)
206        .ok_or_else(|| format!("texture payload has unknown format_id {}", format_id))?;
207    // Bounded before it reaches `with_capacity`: the count is payload-supplied
208    // and reserving for a u32 of them would abort on the allocation alone.
209    let mip_count = r.u32()? as usize;
210    if mip_count == 0 || mip_count > MAX_MIP_LEVELS {
211        return Err(format!(
212            "texture payload declares {} mip levels (expected 1..={})",
213            mip_count, MAX_MIP_LEVELS
214        ));
215    }
216
217    r.seek(HEADER_BYTES)?;
218    let mut mips = Vec::with_capacity(mip_count);
219    for level in 0..mip_count {
220        let width = r.u32()?;
221        let height = r.u32()?;
222        let byte_len = r.u32()? as usize;
223        let expected = format.mip_byte_len(width, height)?;
224        if byte_len != expected {
225            return Err(format!(
226                "texture payload mip {} ({}x{} {:?}) declares {} bytes, format needs {}",
227                level, width, height, format, byte_len, expected
228            ));
229        }
230        mips.push(TextureMip {
231            width,
232            height,
233            data: r.take(byte_len)?.to_vec(),
234        });
235    }
236
237    Ok(TextureImage { format, mips })
238}
239
240/// Box-filter an RGBA image down so its longest edge is at most `max_size`. A
241/// `max_size` of 0 (or an image already within budget) returns the input
242/// unchanged. Used to keep oversized source maps (4K+ DDS) from exploding the
243/// compiled blob, which stores raw RGBA8.
244pub fn downscale_rgba(
245    width: u32,
246    height: u32,
247    pixels: Vec<u8>,
248    max_size: u32,
249) -> (u32, u32, Vec<u8>) {
250    if max_size == 0 || (width <= max_size && height <= max_size) {
251        return (width, height, pixels);
252    }
253    let scale = ceil(width.max(height) as f32 / max_size as f32) as u32;
254    let scale = scale.max(2);
255    let dst_w = (width / scale).max(1);
256    let dst_h = (height / scale).max(1);
257
258    let mut out = vec![0u8; (dst_w * dst_h * 4) as usize];
259    for dy in 0..dst_h {
260        for dx in 0..dst_w {
261            let mut acc = [0u32; 4];
262            let mut n = 0u32;
263            for sy in 0..scale {
264                let src_y = dy * scale + sy;
265                if src_y >= height {
266                    break;
267                }
268                for sx in 0..scale {
269                    let src_x = dx * scale + sx;
270                    if src_x >= width {
271                        break;
272                    }
273                    let si = ((src_y * width + src_x) * 4) as usize;
274                    for c in 0..4 {
275                        acc[c] += pixels[si + c] as u32;
276                    }
277                    n += 1;
278                }
279            }
280            let di = ((dy * dst_w + dx) * 4) as usize;
281            for c in 0..4 {
282                out[di + c] = acc[c].checked_div(n).unwrap_or(0) as u8;
283            }
284        }
285    }
286    (dst_w, dst_h, out)
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    fn round_trip(image: &TextureImage) -> TextureImage {
294        let bytes = serialise(image);
295        deserialise(&bytes).expect("deserialise")
296    }
297
298    #[test]
299    fn rgba8_single_mip_round_trips() {
300        let image = TextureImage::rgba8(2, 1, vec![1, 2, 3, 4, 5, 6, 7, 8]);
301        let back = round_trip(&image);
302        assert_eq!(back, image);
303        assert_eq!(back.format, TextureFormat::Rgba8);
304        assert_eq!((back.width(), back.height()), (2, 1));
305    }
306
307    #[test]
308    fn compressed_multi_mip_round_trips() {
309        // BC1: 8 bytes per 4x4 block. A 4x4 mip is one block; a 2x2 mip clips to
310        // one block too (ceil(2/4) == 1).
311        let image = TextureImage {
312            format: TextureFormat::Bc1,
313            mips: vec![
314                TextureMip {
315                    width: 4,
316                    height: 4,
317                    data: vec![0xAB; 8],
318                },
319                TextureMip {
320                    width: 2,
321                    height: 2,
322                    data: vec![0xCD; 8],
323                },
324            ],
325        };
326        let back = round_trip(&image);
327        assert_eq!(back, image);
328        assert_eq!(back.byte_len(), 16);
329    }
330
331    #[test]
332    fn deserialise_rejects_bad_magic() {
333        let mut bytes = serialise(&TextureImage::rgba8(1, 1, vec![0, 0, 0, 0]));
334        bytes[0] ^= 0xFF;
335        let err = deserialise(&bytes).unwrap_err();
336        assert!(err.contains("magic"), "got: {err}");
337    }
338
339    #[test]
340    fn deserialise_rejects_unknown_format() {
341        let mut bytes = serialise(&TextureImage::rgba8(1, 1, vec![0, 0, 0, 0]));
342        bytes[4..8].copy_from_slice(&99u32.to_le_bytes());
343        let err = deserialise(&bytes).unwrap_err();
344        assert!(err.contains("unknown format_id"), "got: {err}");
345    }
346
347    #[test]
348    fn deserialise_rejects_wrong_mip_length() {
349        // Declare a BC7 4x4 mip (needs 16 bytes) but supply 8.
350        let mut bytes = Vec::new();
351        bytes.extend_from_slice(&TEXTURE_PAYLOAD_MAGIC.to_le_bytes());
352        bytes.extend_from_slice(&TextureFormat::Bc7.id().to_le_bytes());
353        bytes.extend_from_slice(&1u32.to_le_bytes());
354        bytes.extend_from_slice(&4u32.to_le_bytes());
355        bytes.extend_from_slice(&4u32.to_le_bytes());
356        bytes.extend_from_slice(&8u32.to_le_bytes());
357        bytes.extend_from_slice(&[0u8; 8]);
358        let err = deserialise(&bytes).unwrap_err();
359        assert!(err.contains("format needs 16"), "got: {err}");
360    }
361
362    // A one-mip header with caller-chosen fields and no mip body behind it.
363    fn header(format: TextureFormat, mip_count: u32, width: u32, height: u32, len: u32) -> Vec<u8> {
364        let mut bytes = TEXTURE_PAYLOAD_MAGIC.to_le_bytes().to_vec();
365        bytes.extend_from_slice(&format.id().to_le_bytes());
366        bytes.extend_from_slice(&mip_count.to_le_bytes());
367        bytes.extend_from_slice(&width.to_le_bytes());
368        bytes.extend_from_slice(&height.to_le_bytes());
369        bytes.extend_from_slice(&len.to_le_bytes());
370        bytes
371    }
372
373    #[test]
374    fn deserialise_rejects_a_payload_shorter_than_the_header() {
375        let full = serialise(&TextureImage::rgba8(1, 1, vec![0; 4]));
376        for len in 0..HEADER_BYTES {
377            assert!(deserialise(&full[..len]).is_err(), "len {} decoded", len);
378        }
379    }
380
381    #[test]
382    fn deserialise_rejects_a_truncated_mip_header() {
383        let mut bytes = header(TextureFormat::Rgba8, 1, 2, 2, 16);
384        bytes.truncate(HEADER_BYTES + 6);
385        let err = deserialise(&bytes).unwrap_err();
386        assert!(err.contains("unexpected end"), "got: {err}");
387    }
388
389    #[test]
390    fn deserialise_rejects_truncated_mip_data() {
391        let mut bytes = header(TextureFormat::Rgba8, 1, 2, 2, 16);
392        bytes.extend_from_slice(&[0u8; 8]);
393        let err = deserialise(&bytes).unwrap_err();
394        assert!(err.contains("unexpected end"), "got: {err}");
395    }
396
397    // `width * height * 4` wraps for these dimensions; the declared length
398    // must be rejected on the overflow rather than compared against a
399    // wrapped-around footprint.
400    #[test]
401    fn deserialise_rejects_dimensions_that_overflow_the_footprint() {
402        let bytes = header(TextureFormat::Rgba8, 1, u32::MAX, u32::MAX, 16);
403        let err = deserialise(&bytes).unwrap_err();
404        assert!(err.contains("overflow"), "got: {err}");
405    }
406
407    // A mip count near u32::MAX must be rejected on the declared value, not
408    // by attempting to reserve for it.
409    #[test]
410    fn deserialise_rejects_an_absurd_mip_count() {
411        let bytes = header(TextureFormat::Rgba8, u32::MAX, 1, 1, 4);
412        let err = deserialise(&bytes).unwrap_err();
413        assert!(err.contains("mip levels"), "got: {err}");
414    }
415
416    #[test]
417    fn deserialise_rejects_zero_mips() {
418        let bytes = header(TextureFormat::Rgba8, 0, 1, 1, 4);
419        assert!(deserialise(&bytes).is_err());
420    }
421
422    #[test]
423    fn mip_byte_len_reports_overflow_for_max_dimensions() {
424        assert!(
425            TextureFormat::Rgba8
426                .mip_byte_len(u32::MAX, u32::MAX)
427                .is_err()
428        );
429        assert!(TextureFormat::Bc7.mip_byte_len(u32::MAX, u32::MAX).is_err());
430        assert_eq!(TextureFormat::Rgba8.mip_byte_len(2, 2).unwrap(), 16);
431        assert_eq!(TextureFormat::Bc7.mip_byte_len(4, 4).unwrap(), 16);
432    }
433
434    #[test]
435    fn into_rgba8_rejects_compressed() {
436        let image = TextureImage {
437            format: TextureFormat::Bc3,
438            mips: vec![TextureMip {
439                width: 4,
440                height: 4,
441                data: vec![0; 16],
442            }],
443        };
444        assert!(image.into_rgba8().is_err());
445    }
446
447    #[test]
448    fn downscale_rgba_noop_within_budget() {
449        let px = vec![1u8; 8 * 8 * 4];
450        let (w, h, out) = downscale_rgba(8, 8, px.clone(), 16);
451        assert_eq!((w, h), (8, 8));
452        assert_eq!(out, px);
453    }
454
455    #[test]
456    fn downscale_rgba_halves_oversized() {
457        let px = vec![128u8; 8 * 8 * 4];
458        let (w, h, out) = downscale_rgba(8, 8, px, 4);
459        assert_eq!((w, h), (4, 4));
460        assert_eq!(out.len(), 4 * 4 * 4);
461        assert!(out.iter().all(|&v| v == 128));
462    }
463
464    #[test]
465    fn downscale_rgba_zero_max_is_noop() {
466        let px = vec![7u8; 4 * 4 * 4];
467        let (w, h, out) = downscale_rgba(4, 4, px.clone(), 0);
468        assert_eq!((w, h), (4, 4));
469        assert_eq!(out, px);
470    }
471}