Skip to main content

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