Skip to main content

concinnity_core/gfx/
raster.rs

1//! Pure CPU rasterizer for small offline previews (asset thumbnails): an
2//! orthographic three-quarter view of a triangle mesh with a z-buffer and
3//! simple key + ambient shading. No GPU, no backend, no ECS; deterministic
4//! across platforms, so a baked image is identical everywhere.
5
6use alloc::vec;
7use alloc::vec::Vec;
8
9use crate::math::{ceil, floor, powf, sqrt};
10
11use crate::math::vec3::{cross, dot, length};
12
13use super::mesh_payload::Vertex;
14
15// The fixed camera direction (toward the subject) and key light, chosen so a
16// box shows three faces at distinct brightnesses.
17const VIEW_DIR: [f32; 3] = [-0.577, -0.577, -0.577];
18const LIGHT_DIR: [f32; 3] = [0.408, 0.816, 0.408];
19const AMBIENT: f32 = 0.30;
20const DIFFUSE: f32 = 0.70;
21// Fraction of the image the fitted subject spans (the rest is margin).
22const FIT: f32 = 0.9;
23
24/// An RGBA8 image buffer with a transparent background, the rasterizer's
25/// render target.
26pub struct RasterImage {
27    /// Width in pixels.
28    pub width: u32,
29    /// Height in pixels.
30    pub height: u32,
31    /// Row-major RGBA8 pixels.
32    pub rgba: Vec<u8>,
33}
34
35fn normalize(v: [f32; 3]) -> [f32; 3] {
36    let len = length(v);
37    if len <= 0.0 || !len.is_finite() {
38        return [0.0, 0.0, 1.0];
39    }
40    [v[0] / len, v[1] / len, v[2] / len]
41}
42
43// The orthographic camera basis for the fixed view: right / up in the image
44// plane, forward toward the subject.
45fn camera_basis() -> ([f32; 3], [f32; 3], [f32; 3]) {
46    let fwd = normalize(VIEW_DIR);
47    let right = normalize(cross([0.0, 1.0, 0.0], fwd));
48    let up = cross(fwd, right);
49    (right, up, fwd)
50}
51
52/// One shaded piece of a multi-part render: a triangle mesh and the color it
53/// shades with. Parts share one camera framing and one z-buffer, so they
54/// occlude each other like a composed model.
55pub struct MeshPart<'a> {
56    /// The mesh's vertices.
57    pub verts: &'a [Vertex],
58    /// Triangle indices into `verts`.
59    pub indices: &'a [u16],
60    /// Linear RGB colour.
61    pub color: [f32; 3],
62}
63
64/// Shade `verts`/`indices` into a `size` x `size` RGBA8 image: orthographic
65/// three-quarter view auto-framed to the mesh bounds, z-buffered, smooth
66/// N·L + ambient shading of `base_color`, transparent background. An empty or
67/// degenerate mesh returns a fully transparent image.
68pub fn shade_mesh(
69    verts: &[Vertex],
70    indices: &[u16],
71    size: u32,
72    base_color: [f32; 3],
73) -> RasterImage {
74    shade_parts(
75        &[MeshPart {
76            verts,
77            indices,
78            color: base_color,
79        }],
80        size,
81    )
82}
83
84/// [shade_mesh](#method.shade_mesh) over several parts at once, framed to
85/// their combined bounds (a Model's sub-meshes, each with its material color).
86pub fn shade_parts(parts: &[MeshPart], size: u32) -> RasterImage {
87    let mut img = RasterImage {
88        width: size,
89        height: size,
90        rgba: vec![0u8; (size * size * 4) as usize],
91    };
92    let mut positions = parts.iter().flat_map(|p| p.verts.iter().map(|v| v.pos));
93    let Some(first) = positions.next() else {
94        return img;
95    };
96    if size == 0 || parts.iter().all(|p| p.indices.len() < 3) {
97        return img;
98    }
99    let (right, up, fwd) = camera_basis();
100
101    // Frame the combined bounds, then project each part into the shared
102    // camera basis about that center.
103    let mut min = first;
104    let mut max = first;
105    for pos in positions {
106        for a in 0..3 {
107            min[a] = min[a].min(pos[a]);
108            max[a] = max[a].max(pos[a]);
109        }
110    }
111    let center = [
112        (min[0] + max[0]) * 0.5,
113        (min[1] + max[1]) * 0.5,
114        (min[2] + max[2]) * 0.5,
115    ];
116    let project = |v: &Vertex| {
117        let p = [
118            v.pos[0] - center[0],
119            v.pos[1] - center[1],
120            v.pos[2] - center[2],
121        ];
122        [dot(p, right), dot(p, up), dot(p, fwd)]
123    };
124    let extent = parts
125        .iter()
126        .flat_map(|p| p.verts.iter())
127        .map(|v| {
128            let p = project(v);
129            p[0].abs().max(p[1].abs())
130        })
131        .fold(0.0f32, f32::max);
132    if extent <= 0.0 || !extent.is_finite() {
133        return img;
134    }
135    let half = size as f32 * 0.5;
136    let scale = half * FIT / extent;
137    let light = normalize(LIGHT_DIR);
138    let mut depth = vec![f32::NEG_INFINITY; (size * size) as usize];
139    for part in parts {
140        // Image coordinates: x right, y down.
141        let screen: Vec<[f32; 3]> = part
142            .verts
143            .iter()
144            .map(|v| {
145                let p = project(v);
146                [half + p[0] * scale, half - p[1] * scale, p[2]]
147            })
148            .collect();
149        for tri in part.indices.chunks_exact(3) {
150            let (i0, i1, i2) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
151            if i0 >= screen.len() || i1 >= screen.len() || i2 >= screen.len() {
152                continue;
153            }
154            fill_triangle(
155                &mut img,
156                &mut depth,
157                [screen[i0], screen[i1], screen[i2]],
158                [
159                    part.verts[i0].normal,
160                    part.verts[i1].normal,
161                    part.verts[i2].normal,
162                ],
163                light,
164                part.color,
165            );
166        }
167    }
168    img
169}
170
171// Rasterize one triangle with barycentric depth + normal interpolation.
172fn fill_triangle(
173    img: &mut RasterImage,
174    depth: &mut [f32],
175    p: [[f32; 3]; 3],
176    n: [[f32; 3]; 3],
177    light: [f32; 3],
178    base_color: [f32; 3],
179) {
180    let area =
181        (p[1][0] - p[0][0]) * (p[2][1] - p[0][1]) - (p[1][1] - p[0][1]) * (p[2][0] - p[0][0]);
182    if area.abs() <= f32::EPSILON || !area.is_finite() {
183        return;
184    }
185    let min_x = floor(p.iter().map(|v| v[0]).fold(f32::INFINITY, f32::min));
186    let max_x = ceil(p.iter().map(|v| v[0]).fold(f32::NEG_INFINITY, f32::max));
187    let min_y = floor(p.iter().map(|v| v[1]).fold(f32::INFINITY, f32::min));
188    let max_y = ceil(p.iter().map(|v| v[1]).fold(f32::NEG_INFINITY, f32::max));
189    let x0 = (min_x.max(0.0)) as u32;
190    let x1 = (max_x.min(img.width as f32 - 1.0)).max(0.0) as u32;
191    let y0 = (min_y.max(0.0)) as u32;
192    let y1 = (max_y.min(img.height as f32 - 1.0)).max(0.0) as u32;
193    for y in y0..=y1 {
194        for x in x0..=x1 {
195            let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
196            // Barycentric weights via edge functions; either winding accepted.
197            let w0 = ((p[1][0] - px) * (p[2][1] - py) - (p[1][1] - py) * (p[2][0] - px)) / area;
198            let w1 = ((p[2][0] - px) * (p[0][1] - py) - (p[2][1] - py) * (p[0][0] - px)) / area;
199            let w2 = 1.0 - w0 - w1;
200            if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
201                continue;
202            }
203            let z = w0 * p[0][2] + w1 * p[1][2] + w2 * p[2][2];
204            let idx = (y * img.width + x) as usize;
205            // Camera forward points at the subject, so nearer surfaces have
206            // smaller forward depth: keep the largest -z.
207            if -z <= depth[idx] {
208                continue;
209            }
210            depth[idx] = -z;
211            let normal = normalize([
212                w0 * n[0][0] + w1 * n[1][0] + w2 * n[2][0],
213                w0 * n[0][1] + w1 * n[1][1] + w2 * n[2][1],
214                w0 * n[0][2] + w1 * n[1][2] + w2 * n[2][2],
215            ]);
216            // Two-sided: a flipped normal shades like its front face.
217            let diff = dot(normal, light).abs().clamp(0.0, 1.0);
218            let shade = AMBIENT + DIFFUSE * diff;
219            let o = idx * 4;
220            for (c, &b) in base_color.iter().enumerate() {
221                img.rgba[o + c] = ((b * shade).clamp(0.0, 1.0) * 255.0) as u8;
222            }
223            img.rgba[o + 3] = 255;
224        }
225    }
226}
227
228/// Shade a lit sphere swatch into a `size` x `size` RGBA8 image: `albedo`
229/// diffuse with a specular highlight whose width follows `roughness` and whose
230/// tint follows `metallic`. Transparent outside the sphere.
231pub fn shade_sphere(size: u32, albedo: [f32; 3], roughness: f32, metallic: f32) -> RasterImage {
232    let mut img = RasterImage {
233        width: size,
234        height: size,
235        rgba: vec![0u8; (size * size * 4) as usize],
236    };
237    if size == 0 {
238        return img;
239    }
240    let light = normalize([0.45, 0.65, 0.6]);
241    let view = [0.0, 0.0, 1.0];
242    let h = normalize([light[0] + view[0], light[1] + view[1], light[2] + view[2]]);
243    let rough = roughness.clamp(0.05, 1.0);
244    let metal = metallic.clamp(0.0, 1.0);
245    let shininess = 2.0 / (rough * rough) - 1.0;
246    let radius = size as f32 * 0.5 * FIT;
247    let half = size as f32 * 0.5;
248    for y in 0..size {
249        for x in 0..size {
250            let dx = (x as f32 + 0.5 - half) / radius;
251            let dy = (half - (y as f32 + 0.5)) / radius;
252            let d2 = dx * dx + dy * dy;
253            if d2 > 1.0 {
254                continue;
255            }
256            let normal = [dx, dy, sqrt(1.0 - d2)];
257            let diff = dot(normal, light).max(0.0);
258            let spec = powf(dot(normal, h).max(0.0), shininess) * (1.0 - rough * 0.6);
259            // Metals tint the highlight with the albedo and lose diffuse.
260            let spec_color = [
261                1.0 - metal + metal * albedo[0],
262                1.0 - metal + metal * albedo[1],
263                1.0 - metal + metal * albedo[2],
264            ];
265            let o = ((y * size + x) * 4) as usize;
266            for c in 0..3 {
267                let v = albedo[c] * (AMBIENT + DIFFUSE * diff) * (1.0 - metal * 0.7)
268                    + spec_color[c] * spec;
269                img.rgba[o + c] = (v.clamp(0.0, 1.0) * 255.0) as u8;
270            }
271            img.rgba[o + 3] = 255;
272        }
273    }
274    img
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    fn quad(normal: [f32; 3]) -> (Vec<Vertex>, Vec<u16>) {
282        let v = |pos: [f32; 3]| Vertex {
283            pos,
284            normal,
285            tangent: [1.0, 0.0, 0.0],
286            color: [1.0; 3],
287            uv: [0.0; 2],
288        };
289        (
290            vec![
291                v([-1.0, -1.0, 0.0]),
292                v([1.0, -1.0, 0.0]),
293                v([1.0, 1.0, 0.0]),
294                v([-1.0, 1.0, 0.0]),
295            ],
296            vec![0, 1, 2, 0, 2, 3],
297        )
298    }
299
300    fn coverage(img: &RasterImage) -> usize {
301        img.rgba.chunks_exact(4).filter(|p| p[3] > 0).count()
302    }
303
304    #[test]
305    fn a_mesh_fills_pixels_and_the_background_stays_transparent() {
306        let (verts, indices) = quad([0.0, 0.0, 1.0]);
307        let img = shade_mesh(&verts, &indices, 64, [0.8, 0.8, 0.8]);
308        let covered = coverage(&img);
309        assert!(covered > 500, "the quad covers a real area: {covered}");
310        assert!(
311            covered < (64 * 64),
312            "the margin stays transparent: {covered}"
313        );
314        // Corner pixel is background.
315        assert_eq!(img.rgba[3], 0);
316    }
317
318    #[test]
319    fn empty_or_degenerate_input_renders_transparent() {
320        assert_eq!(coverage(&shade_mesh(&[], &[], 32, [1.0; 3])), 0);
321        let (verts, _) = quad([0.0, 0.0, 1.0]);
322        assert_eq!(coverage(&shade_mesh(&verts, &[0, 1], 32, [1.0; 3])), 0);
323        // All vertices coincident: zero extent.
324        let point = vec![verts[0], verts[0], verts[0]];
325        assert_eq!(coverage(&shade_mesh(&point, &[0, 1, 2], 32, [1.0; 3])), 0);
326    }
327
328    #[test]
329    fn nearer_geometry_wins_the_depth_test() {
330        // Two stacked quads along the view direction; the nearer (toward the
331        // camera) is red, the farther green. The image center must be red.
332        let (mut near, mut idx) = quad([0.0, 0.0, 1.0]);
333        let (far, far_idx) = quad([0.0, 0.0, 1.0]);
334        // Push the "near" quad toward the camera (opposite VIEW_DIR).
335        for v in &mut near {
336            for (p, d) in v.pos.iter_mut().zip(VIEW_DIR) {
337                *p -= d * 2.0;
338            }
339        }
340        let base = near.len() as u16;
341        near.extend(far);
342        idx.extend(far_idx.iter().map(|i| i + base));
343        // Shade in one pass with a single color, then re-shade each quad alone
344        // to know which shade the near quad produces at the center.
345        let both = shade_mesh(&near, &idx, 64, [1.0, 1.0, 1.0]);
346        let alone = shade_mesh(&near[..4], &[0, 1, 2, 0, 2, 3], 64, [1.0, 1.0, 1.0]);
347        let center = ((32 * 64 + 32) * 4) as usize;
348        assert_eq!(
349            both.rgba[center..center + 3],
350            alone.rgba[center..center + 3],
351            "the nearer quad's shade wins at the center"
352        );
353    }
354
355    #[test]
356    fn deterministic_output() {
357        let (verts, indices) = quad([0.0, 0.0, 1.0]);
358        let a = shade_mesh(&verts, &indices, 48, [0.5, 0.6, 0.7]);
359        let b = shade_mesh(&verts, &indices, 48, [0.5, 0.6, 0.7]);
360        assert_eq!(a.rgba, b.rgba);
361    }
362
363    #[test]
364    fn parts_share_one_frame_and_depth_buffer() {
365        // A big far green quad behind a small near red quad, fully overlapped
366        // in the image. Both colors must show (the shared framing covers
367        // both), and swapping the part order must not change a pixel: the
368        // shared z-buffer decides the overlap, not paint order.
369        let (mut far, far_idx) = quad([0.0, 0.0, 1.0]);
370        let (mut near, near_idx) = quad([0.0, 0.0, 1.0]);
371        for v in &mut far {
372            v.pos[0] *= 4.0;
373            v.pos[1] *= 4.0;
374        }
375        for v in &mut near {
376            v.pos[0] *= 0.5;
377            v.pos[1] *= 0.5;
378            for (p, d) in v.pos.iter_mut().zip(VIEW_DIR) {
379                *p -= d * 2.0;
380            }
381        }
382        let parts = |a: bool| {
383            let far_part = MeshPart {
384                verts: &far,
385                indices: &far_idx,
386                color: [0.0, 1.0, 0.0],
387            };
388            let near_part = MeshPart {
389                verts: &near,
390                indices: &near_idx,
391                color: [1.0, 0.0, 0.0],
392            };
393            if a {
394                [far_part, near_part]
395            } else {
396                [near_part, far_part]
397            }
398        };
399        let img = shade_parts(&parts(true), 64);
400        let count = |channel: usize| {
401            img.rgba
402                .chunks_exact(4)
403                .filter(|p| p[3] > 0 && p[channel] > p[(channel + 1) % 3].max(p[(channel + 2) % 3]))
404                .count()
405        };
406        assert!(count(0) > 0, "the near red part shows");
407        assert!(count(1) > 0, "the far green part shows");
408        // The near quad projects inside the far quad's footprint, so if paint
409        // order (not depth) decided, swapping the parts would repaint the
410        // overlap green.
411        let swapped = shade_parts(&parts(false), 64);
412        assert_eq!(img.rgba, swapped.rgba, "depth decides, not paint order");
413    }
414
415    #[test]
416    fn sphere_swatch_is_round_lit_and_material_sensitive() {
417        let rough = shade_sphere(64, [0.8, 0.2, 0.2], 1.0, 0.0);
418        let covered = coverage(&rough);
419        let full = 64 * 64;
420        assert!(covered > full / 2 && covered < full, "a disc: {covered}");
421        assert_eq!(rough.rgba[3], 0, "corners transparent");
422        let shiny = shade_sphere(64, [0.8, 0.2, 0.2], 0.1, 0.0);
423        assert_ne!(rough.rgba, shiny.rgba, "roughness changes the highlight");
424        let metal = shade_sphere(64, [0.8, 0.2, 0.2], 0.1, 1.0);
425        assert_ne!(shiny.rgba, metal.rgba, "metallic changes the shading");
426    }
427}