Skip to main content

combs_mesh/render/
cpu.rs

1//! Zero-dependency CPU renderer: frame slicing + src-over alpha blending
2//! in integer math (no floating point, deterministic across platforms).
3
4use crate::blocks::SpriteAtlas;
5use crate::engine::sprites;
6use crate::error::{MeshError, Result};
7use crate::render::Renderer;
8
9/// CPU [`Renderer`] implementation.
10#[derive(Debug, Default, Clone, Copy)]
11pub struct CpuRenderer;
12
13impl CpuRenderer {
14    /// Creates a renderer (stateless).
15    #[must_use]
16    pub fn new() -> Self {
17        CpuRenderer
18    }
19}
20
21impl Renderer for CpuRenderer {
22    fn render_frame(&self, atlas: &SpriteAtlas, frame_index: u32) -> Result<Vec<u8>> {
23        sprites::extract_frame(atlas, frame_index)
24    }
25
26    fn compose(
27        &self,
28        layers: &[(&SpriteAtlas, u32, i32, i32)],
29        width: u32,
30        height: u32,
31    ) -> Result<Vec<u8>> {
32        if width == 0 || height == 0 {
33            return Err(MeshError::InvalidBlock("canvas must be non-empty".into()));
34        }
35        let mut canvas = vec![0u8; width as usize * height as usize * 4];
36        for &(atlas, frame_index, x, y) in layers {
37            let frame = sprites::extract_frame(atlas, frame_index)?;
38            blit_src_over(
39                &mut canvas,
40                width,
41                height,
42                &frame,
43                atlas.frame_width,
44                atlas.frame_height,
45                x,
46                y,
47            );
48        }
49        Ok(canvas)
50    }
51}
52
53/// Paints `src` onto `dst` at `(ox, oy)` with src-over alpha blending.
54#[allow(clippy::too_many_arguments)]
55fn blit_src_over(
56    dst: &mut [u8],
57    dst_w: u32,
58    dst_h: u32,
59    src: &[u8],
60    src_w: u32,
61    src_h: u32,
62    ox: i32,
63    oy: i32,
64) {
65    for sy in 0..src_h as i32 {
66        let dy = oy + sy;
67        if dy < 0 || dy >= dst_h as i32 {
68            continue;
69        }
70        for sx in 0..src_w as i32 {
71            let dx = ox + sx;
72            if dx < 0 || dx >= dst_w as i32 {
73                continue;
74            }
75            let si = (sy as u32 * src_w + sx as u32) as usize * 4;
76            let di = (dy as u32 * dst_w + dx as u32) as usize * 4;
77            blend_pixel(&mut dst[di..di + 4], &src[si..si + 4]);
78        }
79    }
80}
81
82/// src-over: out_a = sa + da(1-sa); out_c = (sc·sa + dc·da(1-sa)) / out_a.
83/// Integer math with rounding; fully opaque/transparent fast paths.
84fn blend_pixel(dst: &mut [u8], src: &[u8]) {
85    let sa = src[3] as u16;
86    if sa == 0 {
87        return;
88    }
89    if sa == 255 {
90        dst.copy_from_slice(src);
91        return;
92    }
93    // u32 throughout: sc·sa·255 can reach ~8.3M (u16 wraps — found by the
94    // GPU parity test).
95    let da = dst[3] as u32;
96    let sa32 = sa as u32;
97    let inv = 255 - sa32;
98    let out_a = sa32 + (da * inv + 127) / 255;
99    if out_a == 0 {
100        dst.iter_mut().for_each(|b| *b = 0);
101        return;
102    }
103    for c in 0..3 {
104        let sc = src[c] as u32;
105        let dc = dst[c] as u32;
106        let premul = sc * sa32 * 255 + dc * da * inv;
107        dst[c] = ((premul / out_a + 127) / 255) as u8;
108    }
109    dst[3] = out_a as u8;
110}