ferravex_render/
mesh.rs

1//! Reusable geometry with face normals for lit 3D scenes.
2use crate::prelude::*;
3
4/// Construct once and draw many times. Mesh dimensions are baked into vertices;
5/// draw transforms use only rotation/translation to keep normals correct.
6pub struct Mesh3d {
7    pub mesh: Mesh,
8    cached: std::cell::OnceCell<crate::retained::ResidentMesh>,
9}
10
11impl Mesh3d {
12    pub fn from_mesh(mesh: Mesh) -> Self {
13        Self {
14            mesh,
15            cached: Default::default(),
16        }
17    }
18    /// Invalidate the GPU snapshot after editing the public source mesh.
19    pub fn invalidate_gpu(&mut self) {
20        self.cached.take();
21    }
22    pub fn resident(&self) -> &crate::retained::ResidentMesh {
23        self.cached.get_or_init(|| {
24            crate::retained::ResidentMesh::new(&self.mesh).expect("valid retained Mesh3d")
25        })
26    }
27    pub fn draw_transformed(&self, model: Mat4) {
28        crate::draw_resident(self.resident(), model);
29    }
30    /// CPU path for geometry whose exact submission order must be preserved.
31    pub fn draw_immediate(&self, model: Mat4) {
32        crate::draw_mesh_transformed(&self.mesh, model);
33    }
34
35    /// Tessellated XZ ground plane centered at the origin, facing positive Y.
36    /// Subdivision provides nearby vertices for vertex lighting/distance fog on large surfaces.
37    /// Bounded to 1..=28 cells per side to keep low-poly scenes inexpensive.
38    pub fn plane(size: Vec2, color: Color, subdivisions: u16) -> Self {
39        assert!(
40            size.is_finite() && size.min_element() > 0.0,
41            "plane size must be finite and positive"
42        );
43        let n = subdivisions.clamp(1, 28);
44        let stride = n + 1;
45        let mut vertices = Vec::with_capacity(stride as usize * stride as usize);
46        let mut indices = Vec::with_capacity(n as usize * n as usize * 6);
47        for z in 0..=n {
48            for x in 0..=n {
49                let uv = vec2(x as f32, z as f32) / n as f32;
50                let position = (uv - Vec2::splat(0.5)) * size;
51                let mut vertex = Vertex::new2(vec3(position.x, 0.0, position.y), uv, color);
52                vertex.normal = Vec3::Y.extend(0.0);
53                vertices.push(vertex);
54            }
55        }
56        for z in 0..n {
57            for x in 0..n {
58                let a = z * stride + x;
59                let b = a + stride;
60                indices.extend([a, b, a + 1, a + 1, b, b + 1]);
61            }
62        }
63        Self {
64            cached: Default::default(),
65            mesh: Mesh {
66                vertices,
67                indices,
68                texture: None,
69            },
70        }
71    }
72
73    /// Flat-shaded, capped cylinder along Y. Construct once and reuse for props.
74    pub fn cylinder(radius: f32, height: f32, color: Color, segments: u16) -> Self {
75        Self::frustum(radius, radius, height, color, segments)
76    }
77
78    /// Flat-shaded cone along Y, with its tip at positive Y.
79    pub fn cone(radius: f32, height: f32, color: Color, segments: u16) -> Self {
80        Self::frustum(radius, 0.0, height, color, segments)
81    }
82
83    /// Capped tapered prism; radii are at negative/positive Y respectively.
84    /// Zero-radius caps are omitted. Tessellation is bounded to 3..=128 sides.
85    pub fn frustum(bottom: f32, top: f32, height: f32, color: Color, segments: u16) -> Self {
86        assert!(
87            bottom.is_finite()
88                && top.is_finite()
89                && bottom >= 0.0
90                && top >= 0.0
91                && bottom.max(top) > 0.0
92                && height.is_finite()
93                && height > 0.0,
94            "frustum dimensions must be finite with positive height and at least one positive radius"
95        );
96        let segments = segments.clamp(3, 128);
97        let mut vertices = Vec::with_capacity(segments as usize * 12);
98        let mut triangle = |a: Vec3, b: Vec3, c: Vec3| {
99            let cross = (b - a).cross(c - a);
100            if cross.length_squared() < 1e-16 {
101                return;
102            }
103            let normal = cross.normalize().extend(0.0);
104            for position in [a, b, c] {
105                let mut vertex = Vertex::new2(position, Vec2::ZERO, color);
106                vertex.normal = normal;
107                vertices.push(vertex);
108            }
109        };
110        let y = height * 0.5;
111        for i in 0..segments {
112            let angle = |n: u16| n as f32 / segments as f32 * std::f32::consts::TAU;
113            let ring = |theta: f32, radius: f32, y: f32| {
114                vec3(theta.cos() * radius, y, theta.sin() * radius)
115            };
116            let a = ring(angle(i), bottom, -y);
117            let b = ring(angle(i + 1), bottom, -y);
118            let c = ring(angle(i), top, y);
119            let d = ring(angle(i + 1), top, y);
120            triangle(a, c, b);
121            triangle(b, c, d);
122            triangle(vec3(0.0, -y, 0.0), a, b);
123            triangle(vec3(0.0, y, 0.0), d, c);
124        }
125        let indices = (0..vertices.len() as u16).collect();
126        Self {
127            cached: Default::default(),
128            mesh: Mesh {
129                vertices,
130                indices,
131                texture: None,
132            },
133        }
134    }
135
136    pub fn cuboid(size: Vec3, color: Color) -> Self {
137        assert!(
138            size.is_finite() && size.min_element() > 0.0,
139            "cuboid size must be finite and positive"
140        );
141        let mut vertices = Vec::with_capacity(24);
142        let mut indices = Vec::with_capacity(36);
143        let faces = [
144            (Vec3::X, Vec3::Y, Vec3::Z),
145            (-Vec3::X, Vec3::Y, -Vec3::Z),
146            (Vec3::Y, Vec3::Z, Vec3::X),
147            (-Vec3::Y, Vec3::Z, -Vec3::X),
148            (Vec3::Z, Vec3::X, Vec3::Y),
149            (-Vec3::Z, Vec3::X, -Vec3::Y),
150        ];
151        for (normal, u, v) in faces {
152            let base = vertices.len() as u16;
153            for (a, b) in [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)] {
154                let mut vertex = Vertex::new2(
155                    (normal + u * a + v * b) * size * 0.5,
156                    vec2((a + 1.0) * 0.5, (b + 1.0) * 0.5),
157                    color,
158                );
159                vertex.normal = normal.extend(0.0);
160                vertices.push(vertex);
161            }
162            indices.extend([base, base + 1, base + 2, base, base + 2, base + 3]);
163        }
164        Self {
165            cached: Default::default(),
166            mesh: Mesh {
167                vertices,
168                indices,
169                texture: None,
170            },
171        }
172    }
173
174    /// Faceted sphere. Latitude rings are bounded to retain a low-poly budget.
175    pub fn sphere(radius: f32, color: Color, segments: u16, rings: u16) -> Self {
176        assert!(
177            radius.is_finite() && radius > 0.0,
178            "sphere radius must be finite and positive"
179        );
180        let segments = segments.clamp(3, 64);
181        // A flat sphere has 6 * segments * (rings - 1) nondegenerate vertices/indices.
182        // Retain the original examples' 5,000-index budget even at maximum detail.
183        let rings = rings.clamp(2, 64).min(1 + 4998 / (6 * segments));
184        let mut vertices = Vec::with_capacity((segments as usize + 1) * (rings as usize + 1));
185        let mut indices = Vec::with_capacity(segments as usize * rings as usize * 6);
186        for y in 0..=rings {
187            let v = y as f32 / rings as f32;
188            let phi = v * std::f32::consts::PI;
189            for x in 0..=segments {
190                let u = x as f32 / segments as f32;
191                let theta = u * std::f32::consts::TAU;
192                let normal = if y == 0 {
193                    Vec3::Y
194                } else if y == rings {
195                    -Vec3::Y
196                } else {
197                    vec3(phi.sin() * theta.cos(), phi.cos(), phi.sin() * theta.sin())
198                };
199                let mut vertex = Vertex::new2(normal * radius, vec2(u, v), color);
200                vertex.normal = normal.extend(0.0);
201                vertices.push(vertex);
202            }
203        }
204        for y in 0..rings {
205            for x in 0..segments {
206                let a = y * (segments + 1) + x;
207                let b = a + segments + 1;
208                indices.extend([a, b, a + 1, a + 1, b, b + 1]);
209            }
210        }
211        // Duplicate triangle vertices so each facet receives its own normal.
212        // This intentionally preserves a low-poly silhouette and flat lighting.
213        let mut flat_vertices = Vec::with_capacity(indices.len());
214        for triangle in indices.as_chunks::<3>().0 {
215            // Calculate on the unit sphere so degeneracy tests and face normals
216            // remain stable for both small projectiles and large scenery.
217            let a = vertices[triangle[0] as usize].normal.truncate();
218            let b = vertices[triangle[1] as usize].normal.truncate();
219            let c = vertices[triangle[2] as usize].normal.truncate();
220            let cross = (b - a).cross(c - a);
221            if cross.length_squared() < 1e-12 {
222                continue;
223            }
224            let outward = cross.dot(a + b + c) >= 0.0;
225            let normal = if outward { cross } else { -cross }.normalize();
226            // Winding agrees with the face normal, including on back-face-culling hosts.
227            let order = if outward {
228                *triangle
229            } else {
230                [triangle[0], triangle[2], triangle[1]]
231            };
232            for i in order {
233                let mut vertex = vertices[i as usize];
234                vertex.normal = normal.extend(0.0);
235                flat_vertices.push(vertex);
236            }
237        }
238        // Also catches unexpected extra triangles at the poles from numeric drift.
239        assert!(
240            flat_vertices.len() < 5000,
241            "sphere tessellation exceeds the low-poly index budget"
242        );
243        let flat_indices = (0..flat_vertices.len() as u16).collect();
244        Self {
245            cached: Default::default(),
246            mesh: Mesh {
247                vertices: flat_vertices,
248                indices: flat_indices,
249                texture: None,
250            },
251        }
252    }
253
254    pub fn draw(&self, position: Vec3, rotation: Quat) {
255        self.draw_transformed(Mat4::from_rotation_translation(rotation, position));
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn maximum_sphere_requests_remain_complete_within_renderer_limits() {
265        for (segments, rings) in [(64, 64), (u16::MAX, u16::MAX), (3, 64), (16, 64)] {
266            let sphere = Mesh3d::sphere(1.0, WHITE, segments, rings);
267            assert!(sphere.mesh.indices.len() < 5000);
268            assert!(sphere.mesh.vertices.len() < 10000);
269            for axis in [Vec3::X, Vec3::Y, Vec3::Z, -Vec3::X, -Vec3::Y, -Vec3::Z] {
270                assert!(
271                    sphere
272                        .mesh
273                        .vertices
274                        .iter()
275                        .any(|v| v.position.dot(axis) > 0.4)
276                );
277            }
278        }
279        for radius in [0.001, 1.0, 1000.0] {
280            let sphere = Mesh3d::sphere(radius, WHITE, 64, 64);
281            assert_eq!(sphere.mesh.indices.len(), 4992);
282            assert!(sphere.mesh.vertices.iter().all(|v| v.normal.is_finite()));
283        }
284    }
285
286    #[test]
287    fn mesh_indices_and_normals_are_valid() {
288        for shape in [
289            Mesh3d::cuboid(vec3(1.0, 2.0, 3.0), WHITE),
290            Mesh3d::sphere(1.0, WHITE, 16, 12),
291            Mesh3d::cylinder(1.0, 2.0, WHITE, 12),
292            Mesh3d::cone(1.0, 2.0, WHITE, 6),
293            Mesh3d::plane(vec2(100.0, 100.0), WHITE, 20),
294        ] {
295            assert!(
296                shape
297                    .mesh
298                    .indices
299                    .iter()
300                    .all(|&i| (i as usize) < shape.mesh.vertices.len())
301            );
302            assert!(
303                shape
304                    .mesh
305                    .vertices
306                    .iter()
307                    .all(|v| (v.normal.truncate().length() - 1.0).abs() < 0.0001)
308            );
309        }
310    }
311
312    #[test]
313    fn triangle_winding_matches_outward_flat_normals() {
314        for shape in [
315            Mesh3d::cuboid(vec3(1.0, 2.0, 3.0), WHITE),
316            Mesh3d::sphere(1.0, WHITE, 12, 8),
317            Mesh3d::sphere(0.001, WHITE, 64, 64),
318            Mesh3d::cylinder(1.0, 2.0, WHITE, 12),
319            Mesh3d::cone(1.0, 2.0, WHITE, 6),
320            Mesh3d::frustum(0.0, 1.0, 2.0, WHITE, 6),
321            Mesh3d::plane(vec2(20.0, 10.0), WHITE, 8),
322        ] {
323            for triangle in shape.mesh.indices.as_chunks::<3>().0 {
324                let [a, b, c] = triangle.map(|i| shape.mesh.vertices[i as usize]);
325                let winding = (b.position - a.position).cross(c.position - a.position);
326                assert!(winding.dot(a.normal.truncate()) > 0.0);
327                assert!(a.normal.abs_diff_eq(b.normal, 0.0001));
328                assert!(a.normal.abs_diff_eq(c.normal, 0.0001));
329            }
330        }
331    }
332
333    #[test]
334    fn cylinder_and_cone_segments_are_bounded_without_broken_caps() {
335        for segments in [0, 1, 2, 128, u16::MAX] {
336            let expected = usize::from(segments.clamp(3, 128));
337            for (shape, per_segment) in [
338                (Mesh3d::cylinder(1.0, 2.0, WHITE, segments), 12),
339                (Mesh3d::cone(1.0, 2.0, WHITE, segments), 6),
340            ] {
341                assert_eq!(shape.mesh.indices.len(), expected * per_segment);
342                assert_eq!(shape.mesh.indices.len(), shape.mesh.vertices.len());
343                assert!(shape.mesh.vertices.iter().all(|v| v.normal.is_finite()));
344            }
345        }
346    }
347
348    #[test]
349    fn invalid_mesh_dimensions_are_rejected_before_recording() {
350        for invalid in [0.0, -1.0, f32::INFINITY, f32::NAN] {
351            assert!(std::panic::catch_unwind(|| Mesh3d::sphere(invalid, WHITE, 8, 6)).is_err());
352            assert!(
353                std::panic::catch_unwind(|| Mesh3d::cuboid(Vec3::splat(invalid), WHITE)).is_err()
354            );
355            assert!(
356                std::panic::catch_unwind(|| Mesh3d::plane(Vec2::splat(invalid), WHITE, 4)).is_err()
357            );
358            assert!(std::panic::catch_unwind(|| Mesh3d::cylinder(1.0, invalid, WHITE, 8)).is_err());
359        }
360    }
361
362    #[test]
363    fn tapered_mesh_faces_point_outward_and_caps_close() {
364        for shape in [
365            Mesh3d::cylinder(1.0, 2.0, WHITE, 8),
366            Mesh3d::cone(1.0, 2.0, WHITE, 8),
367        ] {
368            for face in shape.mesh.vertices.as_chunks::<3>().0 {
369                let center = (face[0].position + face[1].position + face[2].position) / 3.0;
370                let normal = face[0].normal.truncate();
371                assert!(normal.dot(center) > 0.0);
372            }
373        }
374        assert_eq!(
375            Mesh3d::cylinder(1.0, 2.0, WHITE, 8).mesh.indices.len(),
376            8 * 12
377        );
378        assert_eq!(Mesh3d::cone(1.0, 2.0, WHITE, 8).mesh.indices.len(), 8 * 6);
379    }
380
381    #[test]
382    fn ground_plane_has_nearby_vertices_correct_winding_and_bounded_batches() {
383        let plane = Mesh3d::plane(vec2(440.0, 440.0), WHITE, 22);
384        assert!(
385            plane
386                .mesh
387                .vertices
388                .iter()
389                .any(|v| v.position.length() < 0.001)
390        );
391        for triangle in plane.mesh.indices.as_chunks::<3>().0 {
392            let [a, b, c] = [triangle[0], triangle[1], triangle[2]]
393                .map(|i| plane.mesh.vertices[i as usize].position);
394            assert!((b - a).cross(c - a).y > 0.0);
395        }
396        assert!(Mesh3d::plane(Vec2::ONE, WHITE, u16::MAX).mesh.indices.len() < 5000);
397    }
398}