Skip to main content

concinnity_render/
mipmap.rs

1//! Backend-agnostic mip-chain generation for streamed RGBA8 textures. Each
2//! backend's texture upload calls `generate_mip_chain` and uploads every level,
3//! so albedo and normal maps minify through a proper trilinear chain instead of
4//! aliasing from a single mip-0 sample at a distance.
5//!
6//! Levels are produced by a 2x2 box filter in stored (RGBA8) space, halving each
7//! axis with floor division so every level's dimensions match the GPU mip
8//! convention `max(1, base >> level)`. That keeps the CPU chain in lockstep with
9//! the image's allocated mip levels on all three backends.
10
11use alloc::vec;
12use alloc::vec::Vec;
13
14/// One level of a mip chain: dimensions plus tightly packed RGBA8 pixels
15/// (`width * height * 4` bytes, no row padding).
16pub struct MipLevel {
17    /// Width in pixels.
18    pub width: u32,
19    /// Height in pixels.
20    pub height: u32,
21    /// Row-major pixels of this level.
22    pub pixels: Vec<u8>,
23}
24
25// Number of mip levels for a `width` x `height` texture: the full chain down to
26// 1x1, i.e. floor(log2(max(w, h))) + 1.
27pub(crate) fn mip_level_count(width: u32, height: u32) -> u32 {
28    let max_dim = width.max(height).max(1);
29    32 - max_dim.leading_zeros()
30}
31
32/// Build the full mip chain for a `width` x `height` RGBA8 image. Level 0 is the
33/// input copied verbatim; each subsequent level halves both axes (floored, min 1)
34/// and box-filters the level above it. `rgba8` must hold at least
35/// `width * height * 4` bytes (the backend uploads validate this before calling).
36pub fn generate_mip_chain(width: u32, height: u32, rgba8: &[u8]) -> Vec<MipLevel> {
37    let count = mip_level_count(width, height);
38    let base_len = width as usize * height as usize * 4;
39    let mut levels: Vec<MipLevel> = Vec::with_capacity(count as usize);
40    levels.push(MipLevel {
41        width,
42        height,
43        pixels: rgba8[..base_len].to_vec(),
44    });
45    for _ in 1..count {
46        let prev = levels.last().unwrap();
47        let dw = (prev.width / 2).max(1);
48        let dh = (prev.height / 2).max(1);
49        let mut pixels = vec![0u8; dw as usize * dh as usize * 4];
50        downsample_box(prev, dw, dh, &mut pixels);
51        levels.push(MipLevel {
52            width: dw,
53            height: dh,
54            pixels,
55        });
56    }
57    levels
58}
59
60// Average each destination texel from the corresponding 2x2 block of `src`,
61// clamping source indices at the edge (so an odd source dimension reuses its
62// last row/column rather than reading out of bounds).
63fn downsample_box(src: &MipLevel, dw: u32, dh: u32, dst: &mut [u8]) {
64    let sw = src.width as usize;
65    let sh = src.height as usize;
66    for y in 0..dh as usize {
67        let sy0 = (2 * y).min(sh - 1);
68        let sy1 = (2 * y + 1).min(sh - 1);
69        for x in 0..dw as usize {
70            let sx0 = (2 * x).min(sw - 1);
71            let sx1 = (2 * x + 1).min(sw - 1);
72            let i00 = (sy0 * sw + sx0) * 4;
73            let i01 = (sy0 * sw + sx1) * 4;
74            let i10 = (sy1 * sw + sx0) * 4;
75            let i11 = (sy1 * sw + sx1) * 4;
76            let d = (y * dw as usize + x) * 4;
77            for c in 0..4 {
78                let sum = src.pixels[i00 + c] as u32
79                    + src.pixels[i01 + c] as u32
80                    + src.pixels[i10 + c] as u32
81                    + src.pixels[i11 + c] as u32;
82                // +2 rounds to nearest before the divide by 4.
83                dst[d + c] = ((sum + 2) / 4) as u8;
84            }
85        }
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn level_count_matches_floor_log2_plus_one() {
95        assert_eq!(mip_level_count(1, 1), 1);
96        assert_eq!(mip_level_count(2, 2), 2);
97        assert_eq!(mip_level_count(256, 256), 9);
98        assert_eq!(mip_level_count(512, 512), 10);
99        // Non-square / non-power-of-two key off the larger axis.
100        assert_eq!(mip_level_count(640, 384), 10); // 640 -> floor(log2)=9, +1
101        assert_eq!(mip_level_count(1, 8), 4); // 8 -> 3, +1
102    }
103
104    #[test]
105    fn chain_dimensions_halve_to_one() {
106        let px = vec![0u8; 4 * 4 * 4];
107        let chain = generate_mip_chain(4, 4, &px);
108        let dims: Vec<(u32, u32)> = chain.iter().map(|m| (m.width, m.height)).collect();
109        assert_eq!(dims, vec![(4, 4), (2, 2), (1, 1)]);
110        assert_eq!(chain.len() as u32, mip_level_count(4, 4));
111    }
112
113    #[test]
114    fn non_square_chain_floors_each_axis_independently() {
115        let px = vec![0u8; 4 * 2 * 4];
116        let chain = generate_mip_chain(4, 2, &px);
117        let dims: Vec<(u32, u32)> = chain.iter().map(|m| (m.width, m.height)).collect();
118        // Width halves to 1 in two steps; height bottoms out at 1 and stays.
119        assert_eq!(dims, vec![(4, 2), (2, 1), (1, 1)]);
120    }
121
122    #[test]
123    fn two_by_two_averages_to_single_texel() {
124        // Four grey texels 0, 4, 8, 12 -> mean 6 (rounded).
125        let px = vec![
126            0, 0, 0, 0, // (0,0)
127            4, 4, 4, 4, // (1,0)
128            8, 8, 8, 8, // (0,1)
129            12, 12, 12, 12, // (1,1)
130        ];
131        let chain = generate_mip_chain(2, 2, &px);
132        assert_eq!(chain.len(), 2);
133        let mip1 = &chain[1];
134        assert_eq!((mip1.width, mip1.height), (1, 1));
135        assert_eq!(mip1.pixels, vec![6, 6, 6, 6]);
136    }
137
138    #[test]
139    fn rounds_to_nearest() {
140        // 0, 0, 0, 1 -> mean 0.25 -> rounds to 0; 0,1,1,1 -> 0.75 -> rounds to 1.
141        let dark = vec![0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 1, 1, 1, 255];
142        let c = generate_mip_chain(2, 2, &dark);
143        assert_eq!(&c[1].pixels[0..3], &[0, 0, 0]);
144
145        let bright = vec![0, 0, 0, 255, 1, 1, 1, 255, 1, 1, 1, 255, 1, 1, 1, 255];
146        let c = generate_mip_chain(2, 2, &bright);
147        assert_eq!(&c[1].pixels[0..3], &[1, 1, 1]);
148    }
149}