Skip to main content

primase_core/
collider.rs

1//! Collision as the source states it, before any triangles: shapes (`core.collider`) and heightfields
2//! (`core.heightfield`). Lowering them to `core.collision` triangles is a pass (`tessellate-colliders`) that only the
3//! outputs needing triangles run; the shapes follow Khronos `KHR_implicit_shapes`, so a glTF output can carry them.
4
5use serde::Serialize;
6use serde_json::{Value, json};
7
8use crate::{Component, Entity, JsonError, Surfaces, World};
9
10/// `core.collider`: a collision shape in the entity's own space, placed like `core.collision` (on a prototype, by its
11/// instances; else by the entity's transform). The geometry is exact under the entity's full transform: a sphere
12/// under a non-uniform scale is an ellipsoid. A source whose physics treats scale otherwise (PhysX scales a sphere's
13/// radius by its largest axis) has its lowering pass choose the shape and transform that collide as the game does.
14#[derive(Clone, Debug, PartialEq)]
15pub struct Collider {
16    pub shape: Shape,
17    /// Into the world's `core.surfaces` (a heightfield's per-cell surfaces override it).
18    pub surface: u16,
19}
20
21/// A collider's shape, in the entity's space, metres.
22#[derive(Clone, Debug, PartialEq, Serialize)]
23#[serde(rename_all = "snake_case")]
24pub enum Shape {
25    /// Centred on `center`, axis-aligned in the entity's space.
26    Box {
27        center: [f32; 3],
28        half_extents: [f32; 3],
29    },
30    Sphere {
31        center: [f32; 3],
32        radius: f32,
33    },
34    /// The segment `a`-`b` swept by `radius`: the caps are included in its length.
35    Capsule {
36        a: [f32; 3],
37        b: [f32; 3],
38        radius: f32,
39    },
40    /// A cylinder from `a` to `b` (a disc is a short one).
41    Cylinder {
42        a: [f32; 3],
43        b: [f32; 3],
44        radius: f32,
45    },
46    /// The convex hull of the points (no faces are stored: a consumer builds the hull).
47    Convex {
48        points: Vec<[f32; 3]>,
49    },
50    /// The entity's [`Heightfield`].
51    Heightfield,
52}
53
54/// Which corners a heightfield cell's two triangles share.
55#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
56#[serde(rename_all = "snake_case")]
57pub enum Diagonal {
58    /// The cell's (column, row) corner and its (column + 1, row + 1) corner.
59    Main,
60    /// The cell's (column + 1, row) corner and its (column, row + 1) corner.
61    Anti,
62}
63
64/// `core.heightfield`: a regular grid of heights in the entity's space: `columns` x `rows` samples along +X and +Z
65/// from the origin, `spacing` metres apart (x, z), each a height along +Y. Cells are split into two triangles along
66/// `diagonal`. A terrain's drawn surface and its collision are the same grid: drawing it is a later pass; colliding
67/// with it is a [`Collider`] of shape [`Shape::Heightfield`] on the same entity.
68#[derive(Clone, Debug, PartialEq, Serialize)]
69pub struct Heightfield {
70    pub columns: u32,
71    pub rows: u32,
72    pub spacing: [f32; 2],
73    /// Metres, row by row (a row along +X, rows along +Z).
74    pub heights: Vec<f32>,
75    pub diagonal: Diagonal,
76    /// One per cell (row by row, `(columns - 1) * (rows - 1)`), true where there is no surface.
77    pub holes: Option<Vec<bool>>,
78    /// One per cell into `core.surfaces`; `None`: the collider's surface.
79    pub surfaces: Option<Vec<u16>>,
80}
81
82impl Heightfield {
83    /// The number of cells, `(columns - 1) * (rows - 1)`.
84    pub fn cells(&self) -> usize {
85        (self.columns.saturating_sub(1) as usize) * (self.rows.saturating_sub(1) as usize)
86    }
87}
88
89fn finite(values: impl IntoIterator<Item = f32>, what: &str) -> Result<(), String> {
90    match values.into_iter().find(|v| !v.is_finite()) {
91        Some(v) => Err(format!("{what}: {v} is not finite")),
92        None => Ok(()),
93    }
94}
95
96fn positive(v: f32, what: &str) -> Result<(), String> {
97    if v.is_finite() && v > 0.0 {
98        Ok(())
99    } else {
100        Err(format!("{what} {v} is not positive"))
101    }
102}
103
104fn surface_known(w: &World, s: u16) -> Result<(), String> {
105    let known = w.resource::<Surfaces>().map_or(0, |t| t.0.len());
106    if usize::from(s) < known {
107        Ok(())
108    } else {
109        Err(format!("surface {s} past the world's {known} surfaces"))
110    }
111}
112
113impl Component for Collider {
114    const NAME: &'static str = "core.collider";
115    const GEOMETRY: crate::Geometry = crate::Geometry::None;
116    /// Exact or not at all: a convex shape's points mapped; a box under a map that keeps axes a box, under any other
117    /// the convex hull of its eight mapped corners; a sphere, capsule or cylinder under a similarity (a rotation or
118    /// mirror times a uniform scale) mapped with its radius scaled, under any other unsupported (a sheared sphere is
119    /// an ellipsoid, no shape: tessellate it first); a heightfield shape follows its `core.heightfield`.
120    fn apply_linear(&self, l: &crate::Linear) -> crate::world::Linearity<Self> {
121        use crate::world::Linearity;
122        let scaled = |r: f32, s: f64| (f64::from(r) * s) as f32;
123        let shape = match &self.shape {
124            Shape::Convex { points } => Shape::Convex {
125                points: points.iter().map(|&p| l.point(p)).collect(),
126            },
127            Shape::Box { center, half_extents } => {
128                let corners: Vec<[f32; 3]> = (0..8)
129                    .map(|k| {
130                        let sign = |bit: usize| if k & (1 << bit) == 0 { -1.0 } else { 1.0 };
131                        l.point(std::array::from_fn(|i| center[i] + sign(i) * half_extents[i]))
132                    })
133                    .collect();
134                if l.axis_map().is_some() {
135                    let lo: [f32; 3] =
136                        std::array::from_fn(|i| corners.iter().map(|c| c[i]).fold(f32::INFINITY, f32::min));
137                    let hi: [f32; 3] = std::array::from_fn(|i| {
138                        corners.iter().map(|c| c[i]).fold(f32::NEG_INFINITY, f32::max)
139                    });
140                    Shape::Box {
141                        center: l.point(*center),
142                        half_extents: std::array::from_fn(|i| (hi[i] - lo[i]) / 2.0),
143                    }
144                } else {
145                    Shape::Convex { points: corners }
146                }
147            }
148            Shape::Sphere { center, radius } => match l.similarity() {
149                Some(s) => Shape::Sphere {
150                    center: l.point(*center),
151                    radius: scaled(*radius, s),
152                },
153                None => return Linearity::Unsupported,
154            },
155            Shape::Capsule { a, b, radius } => match l.similarity() {
156                Some(s) => Shape::Capsule {
157                    a: l.point(*a),
158                    b: l.point(*b),
159                    radius: scaled(*radius, s),
160                },
161                None => return Linearity::Unsupported,
162            },
163            Shape::Cylinder { a, b, radius } => match l.similarity() {
164                Some(s) => Shape::Cylinder {
165                    a: l.point(*a),
166                    b: l.point(*b),
167                    radius: scaled(*radius, s),
168                },
169                None => return Linearity::Unsupported,
170            },
171            Shape::Heightfield => return Linearity::Invariant,
172        };
173        Linearity::Applied(Collider {
174            shape,
175            surface: self.surface,
176        })
177    }
178    /// The shape in full, a convex shape's points as a count.
179    fn to_json(&self) -> Result<Option<Value>, JsonError> {
180        let shape = match &self.shape {
181            Shape::Convex { points } => json!({"convex": {"points": points.len()}}),
182            other => crate::json::to_value(other)?,
183        };
184        Ok(Some(json!({"shape": shape, "surface": self.surface})))
185    }
186    /// Finite values, positive extents and radii, a known surface; a heightfield shape needs the entity's
187    /// `core.heightfield`, a convex one a point.
188    fn verify(&self, e: Entity, w: &World) -> Result<(), String> {
189        surface_known(w, self.surface)?;
190        match &self.shape {
191            Shape::Box { center, half_extents } => {
192                finite(*center, "box centre")?;
193                half_extents
194                    .iter()
195                    .try_for_each(|&h| positive(h, "box half extent"))
196            }
197            Shape::Sphere { center, radius } => {
198                finite(*center, "sphere centre")?;
199                positive(*radius, "sphere radius")
200            }
201            Shape::Capsule { a, b, radius } | Shape::Cylinder { a, b, radius } => {
202                finite(a.iter().chain(b).copied(), "axis ends")?;
203                positive(*radius, "radius")
204            }
205            Shape::Convex { points } => {
206                if points.is_empty() {
207                    return Err("a convex shape without points".into());
208                }
209                finite(points.iter().flatten().copied(), "convex points")
210            }
211            Shape::Heightfield => {
212                if w.has::<Heightfield>(e) {
213                    Ok(())
214                } else {
215                    Err("a heightfield shape without the entity's core.heightfield".into())
216                }
217            }
218        }
219    }
220}
221
222impl Component for Heightfield {
223    const NAME: &'static str = "core.heightfield";
224    const GEOMETRY: crate::Geometry = crate::Geometry::None;
225    /// A grid along +X and +Z with heights along +Y stays one exactly under a positive scale along each axis
226    /// (`diag(sx, sy, sz)`, all positive): spacing times (sx, sz), heights times sy. Under any other map (a mirror, a
227    /// rotation, a shear) unsupported: tessellate it first.
228    fn apply_linear(&self, l: &crate::Linear) -> crate::world::Linearity<Self> {
229        use crate::world::Linearity;
230        let col = |k: usize| l.point(std::array::from_fn(|i| if i == k { 1.0 } else { 0.0 }));
231        let (x, y, z) = (col(0), col(1), col(2));
232        let diagonal = x[1] == 0.0 && x[2] == 0.0 && y[0] == 0.0 && y[2] == 0.0 && z[0] == 0.0 && z[1] == 0.0;
233        if !(diagonal && x[0] > 0.0 && y[1] > 0.0 && z[2] > 0.0) {
234            return Linearity::Unsupported;
235        }
236        let scale = |v: f32, s: f32| (f64::from(v) * f64::from(s)) as f32;
237        let mut h = self.clone();
238        h.spacing = [scale(h.spacing[0], x[0]), scale(h.spacing[1], z[2])];
239        h.heights.iter_mut().for_each(|v| *v = scale(*v, y[1]));
240        Linearity::Applied(h)
241    }
242    /// The grid's shape and options, the heights, holes and surfaces as counts.
243    fn to_json(&self) -> Result<Option<Value>, JsonError> {
244        Ok(Some(json!({
245            "columns": self.columns,
246            "rows": self.rows,
247            "spacing": crate::json::to_value(&self.spacing)?,
248            "heights": self.heights.len(),
249            "diagonal": crate::json::to_value(&self.diagonal)?,
250            "holes": self.holes.as_ref().map(|h| h.iter().filter(|&&x| x).count()),
251            "surfaces": self.surfaces.as_ref().map(Vec::len),
252        })))
253    }
254    /// At least 2 x 2 samples, positive spacing, finite heights, one height per sample, and holes and surfaces (known
255    /// to the world) one per cell.
256    fn verify(&self, _e: Entity, w: &World) -> Result<(), String> {
257        if self.columns < 2 || self.rows < 2 {
258            return Err(format!(
259                "{} x {} samples: a heightfield has at least 2 x 2",
260                self.columns, self.rows
261            ));
262        }
263        self.spacing.iter().try_for_each(|&s| positive(s, "spacing"))?;
264        let samples = self.columns as usize * self.rows as usize;
265        if self.heights.len() != samples {
266            return Err(format!("{} heights for {samples} samples", self.heights.len()));
267        }
268        finite(self.heights.iter().copied(), "heights")?;
269        let cells = self.cells();
270        if let Some(h) = &self.holes
271            && h.len() != cells
272        {
273            return Err(format!("{} holes for {cells} cells", h.len()));
274        }
275        if let Some(s) = &self.surfaces {
276            if s.len() != cells {
277                return Err(format!("{} surfaces for {cells} cells", s.len()));
278            }
279            s.iter().try_for_each(|&s| surface_known(w, s))?;
280        }
281        Ok(())
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    fn world() -> World {
290        let mut w = World::new();
291        let mut s = Surfaces::default();
292        s.index("concrete").unwrap();
293        w.insert_resource(s);
294        w
295    }
296
297    fn field(columns: u32, rows: u32) -> Heightfield {
298        Heightfield {
299            columns,
300            rows,
301            spacing: [1.0, 2.0],
302            heights: vec![0.0; (columns * rows) as usize],
303            diagonal: Diagonal::Main,
304            holes: None,
305            surfaces: None,
306        }
307    }
308
309    #[test]
310    fn shapes_verify_their_sizes_surfaces_and_heightfield() {
311        let mut w = world();
312        let ok = [
313            Shape::Box {
314                center: [0.0; 3],
315                half_extents: [1.0, 2.0, 3.0],
316            },
317            Shape::Sphere {
318                center: [0.0; 3],
319                radius: 0.5,
320            },
321            Shape::Capsule {
322                a: [0.0; 3],
323                b: [0.0, 2.0, 0.0],
324                radius: 0.5,
325            },
326            Shape::Convex {
327                points: vec![[0.0; 3]],
328            },
329        ];
330        for shape in ok {
331            let e = w.spawn();
332            w.insert(e, Collider { shape, surface: 0 });
333        }
334        let terrain = w.spawn();
335        w.insert(terrain, field(3, 2));
336        w.insert(
337            terrain,
338            Collider {
339                shape: Shape::Heightfield,
340                surface: 0,
341            },
342        );
343        assert!(w.verify_components().is_empty(), "{:?}", w.verify_components());
344        let bad = [
345            (
346                Shape::Sphere {
347                    center: [0.0; 3],
348                    radius: 0.0,
349                },
350                0,
351                "sphere radius 0 is not positive",
352            ),
353            (
354                Shape::Box {
355                    center: [f32::NAN, 0.0, 0.0],
356                    half_extents: [1.0; 3],
357                },
358                0,
359                "box centre: NaN is not finite",
360            ),
361            (
362                Shape::Convex { points: Vec::new() },
363                0,
364                "a convex shape without points",
365            ),
366            (
367                Shape::Heightfield,
368                0,
369                "a heightfield shape without the entity's core.heightfield",
370            ),
371            (
372                Shape::Sphere {
373                    center: [0.0; 3],
374                    radius: 1.0,
375                },
376                1,
377                "surface 1 past the world's 1 surfaces",
378            ),
379        ];
380        for (shape, surface, message) in bad {
381            let mut w = world();
382            let e = w.spawn();
383            w.insert(e, Collider { shape, surface });
384            assert_eq!(w.verify_components(), [("core.collider", e, message.to_owned())]);
385        }
386    }
387
388    #[test]
389    fn a_heightfield_has_a_height_per_sample_and_a_hole_per_cell() {
390        for (f, message) in [
391            (field(1, 2), "1 x 2 samples: a heightfield has at least 2 x 2"),
392            (
393                Heightfield {
394                    heights: vec![0.0; 5],
395                    ..field(3, 2)
396                },
397                "5 heights for 6 samples",
398            ),
399            (
400                Heightfield {
401                    holes: Some(vec![false; 3]),
402                    ..field(3, 2)
403                },
404                "3 holes for 2 cells",
405            ),
406            (
407                Heightfield {
408                    surfaces: Some(vec![0, 4]),
409                    ..field(3, 2)
410                },
411                "surface 4 past the world's 1 surfaces",
412            ),
413        ] {
414            let mut w = world();
415            let e = w.spawn();
416            w.insert(e, f);
417            assert_eq!(
418                w.verify_components(),
419                [("core.heightfield", e, message.to_owned())]
420            );
421        }
422    }
423
424    #[test]
425    fn json_shows_shapes_and_counts() {
426        let c = Collider {
427            shape: Shape::Capsule {
428                a: [0.0; 3],
429                b: [0.0, 1.0, 0.0],
430                radius: 0.25,
431            },
432            surface: 0,
433        };
434        assert_eq!(
435            c.to_json().unwrap(),
436            Some(
437                json!({"shape": {"capsule": {"a": [0.0, 0.0, 0.0], "b": [0.0, 1.0, 0.0], "radius": 0.25}}, "surface": 0})
438            )
439        );
440        let c = Collider {
441            shape: Shape::Convex {
442                points: vec![[0.0; 3]; 4],
443            },
444            surface: 0,
445        };
446        assert_eq!(
447            c.to_json().unwrap(),
448            Some(json!({"shape": {"convex": {"points": 4}}, "surface": 0}))
449        );
450        let h = Heightfield {
451            holes: Some(vec![true, false]),
452            ..field(3, 2)
453        };
454        assert_eq!(
455            h.to_json().unwrap(),
456            Some(
457                json!({"columns": 3, "rows": 2, "spacing": [1.0, 2.0], "heights": 6, "diagonal": "main", "holes": 1, "surfaces": null})
458            )
459        );
460    }
461}