Skip to main content

concinnity_core/geometry/
voxel.rs

1// src/geometry/voxel.rs
2//
3// Hidden-face mesher for VoxelChunk assets.
4//
5// For each block whose palette entry has solid=true, emit a quad for any of
6// its six faces whose neighbour is either outside the chunk or non-solid.
7// Faces between two solid blocks are skipped entirely, so the interior of a
8// filled volume contributes no triangles.
9//
10// UVs come from the BlockType palette: per-face overrides (uv_top, uv_bottom,
11// uv_side) fall back to uv_min/uv_max when None.
12//
13// Greedy merging of adjacent same-block faces into larger quads is a future
14// optimisation; this pass only does hidden-face culling.
15
16use alloc::format;
17use alloc::string::String;
18use alloc::vec::Vec;
19
20/// One entry resolved from a VoxelChunk palette.  `None` slots are
21/// non-solid (air) and emit no geometry; their neighbours treat them as empty.
22/// Public so the cook crate's `compile_voxel_chunk_payload` (build-time mesher)
23/// can build the palette this crate's runtime `build_chunk_mesh` also consumes.
24pub struct PaletteSlot {
25    /// Atlas UV rect for the block's top face.
26    pub uv_top: [f32; 4],
27    /// Atlas UV rect for the block's bottom face.
28    pub uv_bottom: [f32; 4],
29    /// Atlas UV rect for the block's four side faces.
30    pub uv_side: [f32; 4],
31}
32
33// Output type for the geometry builders in this module (compatible with the
34// rest of the pipeline in src/geometry.rs).
35type Verts = Vec<([f32; 3], [f32; 3], [f32; 3], [f32; 2])>;
36
37/// Generate hidden-face-culled geometry for a voxel chunk.
38///
39/// `dim` is `[dx, dy, dz]`; `blocks.len()` must equal `dx*dy*dz`. Each block
40/// id either indexes a `Some(slot)` (solid) or `None` (air). The chunk origin
41/// is at the local-space origin (`0,0,0` corner); the far corner is at
42/// `(dx*block_size, dy*block_size, dz*block_size)`.
43pub fn build_voxel_mesh(
44    dim: [u32; 3],
45    block_size: f32,
46    blocks: &[u32],
47    palette: &[Option<PaletteSlot>],
48) -> Result<(Verts, Vec<u16>), String> {
49    let [dx, dy, dz] = [dim[0] as usize, dim[1] as usize, dim[2] as usize];
50    let expected = dx.saturating_mul(dy).saturating_mul(dz);
51    if blocks.len() != expected {
52        return Err(format!(
53            "VoxelChunk: blocks length {} does not match dim {}x{}x{} ({} expected)",
54            blocks.len(),
55            dim[0],
56            dim[1],
57            dim[2],
58            expected
59        ));
60    }
61    for (i, &id) in blocks.iter().enumerate() {
62        if (id as usize) >= palette.len() {
63            return Err(format!(
64                "VoxelChunk: blocks[{}] = {} out of palette range (len {})",
65                i,
66                id,
67                palette.len()
68            ));
69        }
70    }
71
72    let mut verts: Verts = Vec::new();
73    let mut idxs: Vec<u16> = Vec::new();
74    let color = [0.75f32, 0.74, 0.72];
75    let bs = block_size;
76
77    let at = |x: i32, y: i32, z: i32| -> Option<&PaletteSlot> {
78        if x < 0 || y < 0 || z < 0 || x >= dx as i32 || y >= dy as i32 || z >= dz as i32 {
79            return None;
80        }
81        let i = (x as usize) + (y as usize) * dx + (z as usize) * dx * dy;
82        palette[blocks[i] as usize].as_ref()
83    };
84
85    // 6 face emitters. Each writes a quad CCW from outside the block.
86    let mut emit_quad = |corners: [[f32; 3]; 4], normal: [f32; 3], uv_rect: [f32; 4]| {
87        if verts.len() + 4 > u16::MAX as usize {
88            return;
89        }
90        let base = verts.len() as u16;
91        let [u0, v0, u1, v1] = uv_rect;
92        // CCW from outside; UVs map a -> (u0,v0), b -> (u1,v0), c -> (u1,v1), d -> (u0,v1).
93        let uvs = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]];
94        for (i, p) in corners.iter().enumerate() {
95            verts.push((*p, normal, color, uvs[i]));
96        }
97        idxs.extend_from_slice(&[base, base + 1, base + 2, base + 2, base + 3, base]);
98    };
99
100    for z in 0..dz {
101        for y in 0..dy {
102            for x in 0..dx {
103                let slot = match at(x as i32, y as i32, z as i32) {
104                    Some(s) => s,
105                    None => continue,
106                };
107                let x0 = x as f32 * bs;
108                let y0 = y as f32 * bs;
109                let z0 = z as f32 * bs;
110                let x1 = x0 + bs;
111                let y1 = y0 + bs;
112                let z1 = z0 + bs;
113
114                // +X
115                if at(x as i32 + 1, y as i32, z as i32).is_none() {
116                    emit_quad(
117                        [[x1, y0, z1], [x1, y0, z0], [x1, y1, z0], [x1, y1, z1]],
118                        [1.0, 0.0, 0.0],
119                        slot.uv_side,
120                    );
121                }
122                // -X
123                if at(x as i32 - 1, y as i32, z as i32).is_none() {
124                    emit_quad(
125                        [[x0, y0, z0], [x0, y0, z1], [x0, y1, z1], [x0, y1, z0]],
126                        [-1.0, 0.0, 0.0],
127                        slot.uv_side,
128                    );
129                }
130                // +Y (top)
131                if at(x as i32, y as i32 + 1, z as i32).is_none() {
132                    emit_quad(
133                        [[x0, y1, z1], [x1, y1, z1], [x1, y1, z0], [x0, y1, z0]],
134                        [0.0, 1.0, 0.0],
135                        slot.uv_top,
136                    );
137                }
138                // -Y (bottom)
139                if at(x as i32, y as i32 - 1, z as i32).is_none() {
140                    emit_quad(
141                        [[x0, y0, z0], [x1, y0, z0], [x1, y0, z1], [x0, y0, z1]],
142                        [0.0, -1.0, 0.0],
143                        slot.uv_bottom,
144                    );
145                }
146                // +Z
147                if at(x as i32, y as i32, z as i32 + 1).is_none() {
148                    emit_quad(
149                        [[x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1]],
150                        [0.0, 0.0, 1.0],
151                        slot.uv_side,
152                    );
153                }
154                // -Z
155                if at(x as i32, y as i32, z as i32 - 1).is_none() {
156                    emit_quad(
157                        [[x1, y0, z0], [x0, y0, z0], [x0, y1, z0], [x1, y1, z0]],
158                        [0.0, 0.0, -1.0],
159                        slot.uv_side,
160                    );
161                }
162            }
163        }
164    }
165
166    Ok((verts, idxs))
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use alloc::vec;
173
174    fn solid_slot() -> PaletteSlot {
175        PaletteSlot {
176            uv_top: [0.0, 0.0, 1.0, 1.0],
177            uv_bottom: [0.0, 0.0, 1.0, 1.0],
178            uv_side: [0.0, 0.0, 1.0, 1.0],
179        }
180    }
181
182    #[test]
183    fn empty_chunk_produces_no_geometry() {
184        let palette = vec![None];
185        let (v, i) = build_voxel_mesh([2, 2, 2], 1.0, &[0; 8], &palette).unwrap();
186        assert!(v.is_empty());
187        assert!(i.is_empty());
188    }
189
190    #[test]
191    fn single_solid_block_has_six_faces() {
192        let palette = vec![None, Some(solid_slot())];
193        let (v, i) = build_voxel_mesh([1, 1, 1], 1.0, &[1], &palette).unwrap();
194        // 6 faces, 4 verts each, 6 indices each
195        assert_eq!(v.len(), 24);
196        assert_eq!(i.len(), 36);
197    }
198
199    #[test]
200    fn interior_faces_are_culled() {
201        // 2x1x1 of two solid blocks side by side: shared face hidden, 10 faces total.
202        let palette = vec![None, Some(solid_slot())];
203        let (v, i) = build_voxel_mesh([2, 1, 1], 1.0, &[1, 1], &palette).unwrap();
204        assert_eq!(v.len(), 10 * 4);
205        assert_eq!(i.len(), 10 * 6);
206    }
207
208    #[test]
209    fn fully_filled_cube_has_only_outer_shell() {
210        // 3x3x3 of one solid block: 27 blocks, only the 6 outer faces × 9 cells = 54 faces.
211        let palette = vec![None, Some(solid_slot())];
212        let blocks = vec![1u32; 27];
213        let (v, _) = build_voxel_mesh([3, 3, 3], 1.0, &blocks, &palette).unwrap();
214        assert_eq!(v.len(), 54 * 4);
215    }
216
217    #[test]
218    fn air_block_around_solid_emits_all_six_faces() {
219        // 3x3x3 with the center cell solid: only 6 faces.
220        let palette = vec![None, Some(solid_slot())];
221        let mut blocks = vec![0u32; 27];
222        let center = 1 + 3 + 9;
223        blocks[center] = 1;
224        let (v, _) = build_voxel_mesh([3, 3, 3], 1.0, &blocks, &palette).unwrap();
225        assert_eq!(v.len(), 6 * 4);
226    }
227
228    #[test]
229    fn mismatched_blocks_length_errors() {
230        let palette = vec![None];
231        let result = build_voxel_mesh([2, 2, 2], 1.0, &[0; 4], &palette);
232        assert!(result.is_err());
233    }
234
235    #[test]
236    fn block_index_out_of_palette_range_errors() {
237        let palette = vec![None];
238        let result = build_voxel_mesh([1, 1, 1], 1.0, &[7], &palette);
239        assert!(result.is_err());
240    }
241}