Skip to main content

unlit3dx_examples/scene/
shapes.rs

1//! The shapes the scene cycles through: their meshes, colliders and textures.
2//!
3//! A shape kind decides three things at once — what the render world draws,
4//! what physics simulates, and what pattern the material wears — so the three
5//! live together and a new kind is one match arm per function.
6
7use glam::Vec3;
8use unlit3dx::prelude::*;
9
10use crate::render::{OwnedLitMeshDesc, WHITE_COLOR, cuboid_mesh, uv_sphere};
11
12/// Every ball's radius, in metres.
13pub const BALL_RADIUS: f32 = 0.8;
14
15/// The base colors the balls cycle through, as sRGB bytes.
16pub const BALL_COLORS: [[u8; 4]; 4] = [
17    [228, 92, 74, 255],
18    [96, 178, 92, 255],
19    [86, 132, 228, 255],
20    [232, 192, 76, 255],
21];
22
23/// The pattern textures the shape materials draw from, in pixels per
24/// side: big enough that a pattern reads across a metre-wide body,
25/// small enough that sixteen of them stay cheap.
26pub const PATTERN_SIZE: u32 = 128;
27
28/// One body shape of the scene: the mesh it draws, the collider physics
29/// simulates, and the texture pattern its material wears.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum ShapeKind {
32    /// A sphere: a dotted texture over the round mesh.
33    Ball,
34    /// A box: a checker across the six faces.
35    Cube,
36    /// A cylinder along y: vertical stripes wrap the wall.
37    Cylinder,
38    /// A cone pointing up y: horizontal rings stack down the wall.
39    Cone,
40}
41
42/// Every shape the scene cycles through, in spawn order.
43pub const SHAPE_KINDS: [ShapeKind; 4] = [
44    ShapeKind::Ball,
45    ShapeKind::Cube,
46    ShapeKind::Cylinder,
47    ShapeKind::Cone,
48];
49
50/// The shape kind an index draws from the palette.
51pub fn shape_kind(index: usize) -> ShapeKind {
52    SHAPE_KINDS[index % SHAPE_KINDS.len()]
53}
54
55/// Scale a color's bytes by a factor, for the pattern accents.
56fn tinted(color: [u8; 4], factor: f32) -> [u8; 4] {
57    [
58        (f32::from(color[0]) * factor).round().clamp(0.0, 255.0) as u8,
59        (f32::from(color[1]) * factor).round().clamp(0.0, 255.0) as u8,
60        (f32::from(color[2]) * factor).round().clamp(0.0, 255.0) as u8,
61        color[3],
62    ]
63}
64
65/// The texture pattern one shape kind wears, tinted with a base color:
66/// dots for the ball, a checker for the cube, stripes for the cylinder
67/// and rings for the cone. Every pattern puts the accents where the
68/// body's silhouette bends, so the tumbling reads as motion.
69#[must_use]
70pub fn pattern_texture(kind: ShapeKind, color: [u8; 4]) -> Vec<[u8; 4]> {
71    let accent = tinted(color, 0.72);
72    let size = PATTERN_SIZE;
73    let block = match kind {
74        // The checker blocks: four per side.
75        ShapeKind::Cube => size / 4,
76        // The stripe width: eight stripes wrap the wall.
77        ShapeKind::Cylinder | ShapeKind::Cone => size / 8,
78        // The dot pitch: a six by six grid of dots.
79        ShapeKind::Ball => size / 6,
80    };
81    let mut pixels = Vec::with_capacity((size * size) as usize);
82    for y in 0..size {
83        for x in 0..size {
84            let on = match kind {
85                // The cube's checker: blocks alternate both ways.
86                ShapeKind::Cube => (x / block + y / block).is_multiple_of(2),
87                // The cylinder's stripes: vertical, so they wrap the
88                // wall and read as rotation while the body rolls.
89                ShapeKind::Cylinder => (x / block).is_multiple_of(2),
90                // The cone's rings: horizontal, so they stack down the
91                // wall and read as height while the body rolls.
92                ShapeKind::Cone => (y / block).is_multiple_of(2),
93                ShapeKind::Ball => {
94                    // A dot at every block corner, a quarter block
95                    // across, so the sphere wears a polka grid.
96                    let cx = x % block;
97                    let cy = y % block;
98                    let half = block / 2;
99                    let dx = cx.abs_diff(half);
100                    let dy = cy.abs_diff(half);
101                    dx * dx + dy * dy < half * half / 2
102                }
103            };
104            pixels.push(if on { accent } else { color });
105        }
106    }
107    pixels
108}
109
110/// The mesh one shape kind draws. Every kind is about [`BALL_RADIUS`]
111/// across, so every shape shares one visual weight.
112#[must_use]
113pub fn shape_mesh(kind: ShapeKind) -> OwnedLitMeshDesc {
114    match kind {
115        ShapeKind::Ball => uv_sphere(BALL_RADIUS, 32, 22),
116        ShapeKind::Cube => cuboid_mesh(Vec3::splat(BALL_RADIUS * 0.8)),
117        ShapeKind::Cylinder => into_desc(uv_cylinder(BALL_RADIUS * 0.72, BALL_RADIUS, 24)),
118        ShapeKind::Cone => into_desc(uv_cone(BALL_RADIUS * 0.9, BALL_RADIUS, 24)),
119    }
120}
121
122/// Lift a raw mesh's arrays into the owned description the render server
123/// takes.
124fn into_desc(mesh: RawMesh) -> OwnedLitMeshDesc {
125    let (positions, uvs, colors, indices) = mesh;
126    OwnedLitMeshDesc {
127        positions,
128        uvs: Some(uvs),
129        colors: Some(colors),
130        indices: Some(indices),
131    }
132}
133
134/// The collider physics simulates for one shape kind, sized to match
135/// its mesh.
136#[must_use]
137pub fn shape_body(kind: ShapeKind) -> BodyShape {
138    match kind {
139        ShapeKind::Ball => BodyShape::Ball(BALL_RADIUS),
140        ShapeKind::Cube => BodyShape::Cuboid(Vec3::splat(BALL_RADIUS * 0.8)),
141        ShapeKind::Cylinder => BodyShape::Cylinder {
142            half_height: BALL_RADIUS,
143            radius: BALL_RADIUS * 0.72,
144        },
145        ShapeKind::Cone => BodyShape::Cone {
146            half_height: BALL_RADIUS,
147            radius: BALL_RADIUS * 0.9,
148        },
149    }
150}
151
152/// A mesh as raw arrays: positions, uvs, colors, indices.
153pub type RawMesh = (Vec<[f32; 3]>, Vec<[f32; 2]>, Vec<[u8; 4]>, Vec<u32>);
154
155/// A lathe along the y axis: the side wall walks the rings top to
156/// bottom, and the caps close whichever ends ask for it, so a cylinder
157/// and a cone share one winding story.
158fn lathe(rings: &[(f32, f32); 2], caps: (bool, bool), slices: usize) -> RawMesh {
159    let rows = rings.len();
160    let ring_len = slices + 1;
161    let vertices = ring_len * rows + ring_len * (usize::from(caps.0) + usize::from(caps.1));
162    let mut positions = Vec::with_capacity(vertices);
163    let mut uvs = Vec::with_capacity(vertices);
164    let mut colors = Vec::with_capacity(vertices);
165    let mut indices = Vec::with_capacity(
166        (rows - 1) * slices * 6 + slices * 3 * (usize::from(caps.0) + usize::from(caps.1)),
167    );
168    // The side wall: the same outward winding the sphere's quads read,
169    // (top, top-next, bottom) and (top-next, bottom-next, bottom)
170    // against the outward direction.
171    for (row, &(radius, y)) in rings.iter().enumerate() {
172        let v = row as f32 / (rows - 1).max(1) as f32;
173        for slice in 0..=slices {
174            let u = slice as f32 / slices as f32;
175            let (sin, cos) = (u * std::f32::consts::TAU).sin_cos();
176            positions.push([radius * cos, y, radius * sin]);
177            uvs.push([u, v]);
178            colors.push(WHITE_COLOR);
179        }
180    }
181    for row in 0..rows - 1 {
182        for slice in 0..slices {
183            let top = (row * ring_len + slice) as u32;
184            let bottom = top + ring_len as u32;
185            indices.extend_from_slice(&[top, top + 1, bottom, top + 1, bottom + 1, bottom]);
186        }
187    }
188    // A cap fans its ring around a centre point. The ring runs with
189    // growing angle, which reads clockwise from above, so the top cap
190    // (facing +y) walks its fan backwards and the bottom cap forwards.
191    let cap = |lathe: &mut RawMesh, y: f32, up: bool| {
192        let base = lathe.0.len() as u32;
193        let centre = base;
194        lathe.0.push([0.0, y, 0.0]);
195        lathe.1.push([0.5, 0.5]);
196        lathe.2.push(WHITE_COLOR);
197        let &(radius, ring_y) = if up {
198            rings.first().expect("a top ring")
199        } else {
200            rings.last().expect("a bottom ring")
201        };
202        for slice in 0..=slices {
203            let u = slice as f32 / slices as f32;
204            let (sin, cos) = (u * std::f32::consts::TAU).sin_cos();
205            lathe.0.push([radius * cos, ring_y, radius * sin]);
206            lathe.1.push([0.5 + 0.5 * cos, 0.5 + 0.5 * sin]);
207            lathe.2.push(WHITE_COLOR);
208        }
209        for slice in 0..slices {
210            let one = centre + 1 + slice as u32;
211            let two = one + 1;
212            let fan = if up {
213                [centre, two, one]
214            } else {
215                [centre, one, two]
216            };
217            lathe.3.extend_from_slice(&fan);
218        }
219    };
220    let mut lathe = (positions, uvs, colors, indices);
221    if caps.0 {
222        cap(&mut lathe, rings[0].1, true);
223    }
224    if caps.1 {
225        cap(&mut lathe, rings[rows - 1].1, false);
226    }
227    lathe
228}
229
230/// A cylinder along the y axis: flat caps top and bottom, uvs mapped so
231/// a texture wraps the wall and spots the caps.
232pub fn uv_cylinder(radius: f32, half_height: f32, slices: usize) -> RawMesh {
233    lathe(
234        &[(radius, half_height), (radius, -half_height)],
235        (true, true),
236        slices,
237    )
238}
239
240/// A cone pointing up the y axis: an apex ring collapsed to a point on
241/// top, a flat base cap below.
242pub fn uv_cone(radius: f32, half_height: f32, slices: usize) -> RawMesh {
243    lathe(
244        &[(0.0, half_height), (radius, -half_height)],
245        (false, true),
246        slices,
247    )
248}
249
250#[cfg(test)]
251pub(crate) mod tests {
252    use super::*;
253
254    /// Every triangle of every generated mesh faces outward: the unlit
255    /// pipeline culls back faces, so a single inward triangle shows as a
256    /// hole in the surface.
257    pub(crate) fn assert_faces_outward(name: &str, mesh: &OwnedLitMeshDesc) {
258        let indices = mesh.indices.as_deref().unwrap_or(&[]);
259        for triangle in indices.as_chunks::<3>().0 {
260            let [a, b, c] = triangle.map(|index| Vec3::from(mesh.positions[index as usize]));
261            let normal = (b - a).cross(c - a);
262            // The sphere's pole rows collapse to a point, so their
263            // triangles have zero area and no orientation to check.
264            if normal.length_squared() <= f32::EPSILON {
265                continue;
266            }
267            let center = (a + b + c) / 3.0;
268            assert!(
269                normal.dot(center) > 0.0,
270                "{name}: triangle {triangle:?} faces inward",
271            );
272        }
273    }
274
275    /// A test's name for one shape kind.
276    fn kind_name(kind: ShapeKind) -> &'static str {
277        match kind {
278            ShapeKind::Ball => "sphere",
279            ShapeKind::Cube => "cube",
280            ShapeKind::Cylinder => "cylinder",
281            ShapeKind::Cone => "cone",
282        }
283    }
284
285    #[test]
286    fn meshes_face_outward() {
287        for kind in SHAPE_KINDS {
288            assert_faces_outward(kind_name(kind), &shape_mesh(kind));
289        }
290    }
291
292    #[test]
293    fn patterns_cover_their_canvas() {
294        for kind in SHAPE_KINDS {
295            for color in BALL_COLORS {
296                let pixels = pattern_texture(kind, color);
297                assert_eq!(pixels.len(), (PATTERN_SIZE * PATTERN_SIZE) as usize);
298                let accents = pixels.iter().filter(|pixel| **pixel != color).count();
299                assert!(accents > 0, "{kind:?}: the pattern painted nothing");
300                assert!(
301                    accents < pixels.len(),
302                    "{kind:?}: the pattern left no base color",
303                );
304            }
305        }
306    }
307}