Skip to main content

sgl_core/collision/
collider_set.rs

1//! The world's colliders plus a uniform broadphase grid over them.
2
3use std::collections::HashMap;
4
5use super::aabb::Aabb;
6use crate::math::{IVec2, Vec2};
7
8/// How a collider behaves during a sweep.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct ColliderFlags {
11    /// Participates in solid collision response. A collider that is not solid
12    /// is skipped entirely by the sweep.
13    pub solid: bool,
14    /// One-way platform: passable except for motion into a floor face (one
15    /// within `normal_threshold` of the world's up axis).
16    pub one_way: bool,
17    /// Sensor: never blocks, whatever `solid` says. Callers detect sensors by
18    /// querying the set themselves.
19    pub sensor: bool,
20}
21
22impl ColliderFlags {
23    /// A plain solid collider.
24    pub const SOLID: Self = Self {
25        solid: true,
26        one_way: false,
27        sensor: false,
28    };
29
30    /// A one-way platform: solid only against a landing from above.
31    pub const ONE_WAY: Self = Self {
32        solid: true,
33        one_way: true,
34        sensor: false,
35    };
36
37    /// A sensor: reported by [`ColliderSet::query`] but never blocking.
38    pub const SENSOR: Self = Self {
39        solid: false,
40        one_way: false,
41        sensor: true,
42    };
43}
44
45/// One collider in the world: its box and its flags.
46#[derive(Debug, Clone, Copy, PartialEq)]
47pub struct Collider {
48    /// World-space box.
49    pub aabb: Aabb,
50    /// Behavior during a sweep.
51    pub flags: ColliderFlags,
52}
53
54/// Most grid cells one collider is bucketed into. A collider spanning more
55/// (a long floor at a small cell size) is kept apart and offered to every
56/// query whose cells it meets, so an insert never walks an unbounded cell
57/// range.
58const MAX_COLLIDER_CELLS: u64 = 1024;
59
60/// The world's colliders, bucketed into a uniform grid for broadphase.
61///
62/// The grid's cell size is the caller's choice: it is a spatial hash bucket,
63/// not a tile size. Cells near the typical collider size keep queries small;
64/// much larger cells simply return more candidates, and much smaller cells make
65/// each collider occupy more buckets. Either way the result is only a candidate
66/// list — [`query`](Self::query) reports colliders whose *cells* meet the
67/// region, not colliders that actually overlap it.
68///
69/// Work is bounded whatever the boxes' size: a collider spanning more than
70/// 1024 cells is not bucketed but checked by every query, and a query region
71/// spanning more cells than the set has colliders scans the colliders
72/// instead of the cells. A query visits at most about as many cells as the
73/// set holds colliders, plus the oversized colliders. An inverted box (a
74/// negative `half`) addresses no cells.
75#[derive(Debug)]
76pub struct ColliderSet {
77    cell_size: f32,
78    colliders: Vec<Collider>,
79    /// Grid cell -> indices of the colliders overlapping that cell.
80    grid: HashMap<IVec2, Vec<usize>>,
81    /// Colliders spanning more than [`MAX_COLLIDER_CELLS`] cells.
82    oversized: Vec<usize>,
83}
84
85impl ColliderSet {
86    /// An empty set bucketed at `cell_size` world units per grid cell.
87    ///
88    /// # Panics
89    /// If `cell_size` is not positive and finite — an authoring error, not a
90    /// runtime condition. A zero, negative, or non-finite cell size cannot
91    /// address a grid at all.
92    #[must_use]
93    pub fn new(cell_size: f32) -> Self {
94        assert!(
95            cell_size > 0.0 && cell_size.is_finite(),
96            "ColliderSet: cell size must be positive and finite, got {cell_size}"
97        );
98        Self {
99            cell_size,
100            colliders: Vec::new(),
101            grid: HashMap::new(),
102            oversized: Vec::new(),
103        }
104    }
105
106    /// World units per broadphase grid cell.
107    #[must_use]
108    pub fn cell_size(&self) -> f32 {
109        self.cell_size
110    }
111
112    /// Inserts a world-space collider, bucketing it into every grid cell its
113    /// box overlaps (or keeping it apart when that is more than 1024 cells),
114    /// and returns its index. A non-finite box addresses no cells and is never
115    /// a query candidate.
116    pub fn insert(&mut self, aabb: Aabb, flags: ColliderFlags) -> usize {
117        let index = self.colliders.len();
118        self.colliders.push(Collider { aabb, flags });
119        if let Some(cells) = CellRange::of(&aabb, self.cell_size) {
120            if cells.count() > MAX_COLLIDER_CELLS {
121                self.oversized.push(index);
122            } else {
123                for cell in cells.iter() {
124                    self.grid.entry(cell).or_default().push(index);
125                }
126            }
127        }
128        index
129    }
130
131    /// Borrows a collider by index, as returned by [`query`](Self::query).
132    #[must_use]
133    pub fn get(&self, index: usize) -> Option<&Collider> {
134        self.colliders.get(index)
135    }
136
137    /// Broadphase candidates: the indices of colliders bucketed into any grid
138    /// cell that `region` touches.
139    ///
140    /// Each index appears exactly once, in ascending order, so a caller that
141    /// breaks ties by "first candidate wins" behaves identically run to run.
142    ///
143    /// A region spanning more cells than the set has colliders is answered by
144    /// scanning the colliders' cell ranges, so a query visits at most about as
145    /// many cells as the set holds colliders, plus the oversized colliders.
146    #[must_use]
147    pub fn query(&self, region: &Aabb) -> Vec<usize> {
148        self.query_corners(region.min(), region.max())
149    }
150
151    /// Broadphase query without rounding corners through center/half-extents.
152    pub(super) fn query_corners(&self, min: Vec2, max: Vec2) -> Vec<usize> {
153        let Some(cells) = CellRange::of_corners(min, max, self.cell_size) else {
154            return Vec::new();
155        };
156        let meets = |index: &usize| {
157            CellRange::of(&self.colliders[*index].aabb, self.cell_size)
158                .is_some_and(|other| other.meets(&cells))
159        };
160        let mut found = Vec::new();
161        if cells.count() > self.colliders.len() as u64 {
162            found.extend((0..self.colliders.len()).filter(meets));
163            return found;
164        }
165        for cell in cells.iter() {
166            if let Some(bucket) = self.grid.get(&cell) {
167                found.extend_from_slice(bucket);
168            }
169        }
170        found.extend(self.oversized.iter().copied().filter(meets));
171        found.sort_unstable();
172        found.dedup();
173        found
174    }
175
176    /// Number of colliders in the set.
177    #[must_use]
178    pub fn len(&self) -> usize {
179        self.colliders.len()
180    }
181
182    /// Whether the set holds no colliders.
183    #[must_use]
184    pub fn is_empty(&self) -> bool {
185        self.colliders.is_empty()
186    }
187
188    /// Removes every collider and its grid buckets, keeping the cell size.
189    /// Indices start again from zero.
190    pub fn clear(&mut self) {
191        self.colliders.clear();
192        self.grid.clear();
193        self.oversized.clear();
194    }
195
196    /// Iterates the colliders in insertion order, so position `i` is the
197    /// collider at index `i`.
198    pub fn iter(&self) -> std::slice::Iter<'_, Collider> {
199        self.colliders.iter()
200    }
201}
202
203impl<'a> IntoIterator for &'a ColliderSet {
204    type Item = &'a Collider;
205    type IntoIter = std::slice::Iter<'a, Collider>;
206
207    fn into_iter(self) -> Self::IntoIter {
208        self.iter()
209    }
210}
211
212/// The inclusive range of grid cells a box overlaps.
213#[derive(Debug, Clone, Copy)]
214struct CellRange {
215    min: IVec2,
216    max: IVec2,
217}
218
219impl CellRange {
220    /// The cells `aabb` overlaps, or `None` for a non-finite box: float-to-int
221    /// casts saturate, so an infinite bound would address the whole `i32`
222    /// grid.
223    fn of(aabb: &Aabb, cell_size: f32) -> Option<Self> {
224        Self::of_corners(aabb.min(), aabb.max(), cell_size)
225    }
226
227    /// Keeps supplied bounds intact, including a sweep ending on a cell edge.
228    fn of_corners(min: Vec2, max: Vec2, cell_size: f32) -> Option<Self> {
229        if !(min.is_finite() && max.is_finite()) {
230            return None;
231        }
232        let range = Self {
233            min: IVec2::new(cell_of(min.x, cell_size), cell_of(min.y, cell_size)),
234            max: IVec2::new(cell_of(max.x, cell_size), cell_of(max.y, cell_size)),
235        };
236        // An inverted box (negative `half`) addresses no cells.
237        range.min.cmple(range.max).all().then_some(range)
238    }
239
240    /// How many cells the range holds.
241    fn count(&self) -> u64 {
242        let span = |lo: i32, hi: i32| u64::try_from(i64::from(hi) - i64::from(lo) + 1).unwrap_or(0);
243        span(self.min.x, self.max.x).saturating_mul(span(self.min.y, self.max.y))
244    }
245
246    /// Whether the two ranges share a cell.
247    fn meets(&self, other: &Self) -> bool {
248        self.min.cmple(other.max).all() && other.min.cmple(self.max).all()
249    }
250
251    /// Every cell in the range, row by row.
252    fn iter(self) -> impl Iterator<Item = IVec2> {
253        (self.min.y..=self.max.y)
254            .flat_map(move |y| (self.min.x..=self.max.x).map(move |x| IVec2::new(x, y)))
255    }
256}
257
258/// The grid coordinate a world coordinate falls in.
259// Coordinates beyond the `i32` grid saturate, which is the intended clamp.
260#[allow(clippy::cast_possible_truncation)]
261fn cell_of(value: f32, cell_size: f32) -> i32 {
262    (value / cell_size).floor() as i32
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::math::Vec2;
269    use wasm_bindgen_test::wasm_bindgen_test;
270
271    /// #249: the getters report what was configured and inserted.
272    #[wasm_bindgen_test(unsupported = test)]
273    fn getters_report_the_configured_size_and_count() {
274        let mut set = ColliderSet::new(2.5);
275        assert_eq!(set.cell_size().to_bits(), 2.5f32.to_bits());
276        assert!(set.is_empty());
277        assert_eq!(set.len(), 0);
278        set.insert(aabb(0.0, 0.0, 1.0, 1.0), ColliderFlags::SOLID);
279        set.insert(aabb(9.0, 9.0, 1.0, 1.0), ColliderFlags::SENSOR);
280        assert!(!set.is_empty());
281        assert_eq!(set.len(), 2);
282        assert_eq!(set.iter().count(), 2);
283        assert_eq!((&set).into_iter().count(), 2);
284    }
285
286    /// #249: colliders at negative, fractional coordinates with a cell size
287    /// that does not divide them are found by a query in their cells, and a
288    /// non-finite region finds nothing even with a collider at the origin.
289    #[wasm_bindgen_test(unsupported = test)]
290    fn negative_fractional_boxes_are_found_and_non_finite_regions_find_nothing() {
291        let mut set = ColliderSet::new(1.5);
292        let origin = set.insert(aabb(0.0, 0.0, 0.5, 0.5), ColliderFlags::SOLID);
293        let low = set.insert(aabb(-2.2, -3.7, 0.4, 0.4), ColliderFlags::SOLID);
294        assert_eq!(set.query(&aabb(-2.5, -4.0, 0.2, 0.2)), vec![low]);
295        assert_eq!(set.query(&aabb(-5.0, -5.0, 0.2, 0.2)), Vec::<usize>::new());
296        assert_eq!(set.query(&aabb(0.2, 0.2, 0.1, 0.1)), vec![origin]);
297        assert!(set.query(&aabb(f32::NAN, 0.0, 1.0, 1.0)).is_empty());
298        assert!(set.query(&aabb(0.0, 0.0, f32::INFINITY, 1.0)).is_empty());
299    }
300
301    fn aabb(cx: f32, cy: f32, hx: f32, hy: f32) -> Aabb {
302        Aabb::new(Vec2::new(cx, cy), Vec2::new(hx, hy))
303    }
304
305    /// The configured cell size must drive bucketing. With cells far larger
306    /// than the colliders, two boxes 50 units apart share a cell and both come
307    /// back as candidates; a hard-coded cell size would return only the near
308    /// one.
309    #[wasm_bindgen_test(unsupported = test)]
310    fn configured_cell_size_decides_bucketing() {
311        let mut coarse = ColliderSet::new(100.0);
312        coarse.insert(aabb(0.0, 0.0, 0.5, 0.5), ColliderFlags::SOLID);
313        coarse.insert(aabb(50.0, 0.0, 0.5, 0.5), ColliderFlags::SOLID);
314        assert_eq!(coarse.query(&aabb(0.0, 0.0, 1.0, 1.0)), vec![0, 1]);
315
316        let mut fine = ColliderSet::new(1.0);
317        fine.insert(aabb(0.0, 0.0, 0.5, 0.5), ColliderFlags::SOLID);
318        fine.insert(aabb(50.0, 0.0, 0.5, 0.5), ColliderFlags::SOLID);
319        assert_eq!(fine.query(&aabb(0.0, 0.0, 1.0, 1.0)), vec![0]);
320    }
321
322    /// A collider wider than one cell must be reachable from every cell it
323    /// covers and still appear exactly once, in ascending index order. At a
324    /// 0.25 cell size the wide floor occupies dozens of buckets, so a missing
325    /// dedupe repeats it and center-only bucketing loses it.
326    #[wasm_bindgen_test(unsupported = test)]
327    fn multi_cell_colliders_are_returned_once_in_index_order() {
328        let mut set = ColliderSet::new(0.25);
329        let floor = set.insert(aabb(0.0, 0.0, 1.0, 0.25), ColliderFlags::SOLID);
330        let far = set.insert(aabb(10.0, 10.0, 0.5, 0.5), ColliderFlags::SOLID);
331        let box_above = set.insert(aabb(0.5, 0.5, 0.3, 0.3), ColliderFlags::SOLID);
332
333        assert_eq!(set.query(&aabb(0.0, 0.0, 1.0, 1.0)), vec![floor, box_above]);
334        // Reachable from a region touching only its right end.
335        assert_eq!(set.query(&aabb(0.9, -0.2, 0.05, 0.02)), vec![floor]);
336        assert_eq!(set.query(&aabb(10.0, 10.0, 0.1, 0.1)), vec![far]);
337    }
338
339    /// Clearing must drop the grid too. Colliders alone would leave buckets
340    /// holding indices that no longer exist, and the next query would hand a
341    /// caller indices past the end of the set.
342    #[wasm_bindgen_test(unsupported = test)]
343    fn clear_drops_the_grid_with_the_colliders() {
344        let mut set = ColliderSet::new(1.0);
345        set.insert(aabb(0.0, 0.0, 0.5, 0.5), ColliderFlags::SOLID);
346        set.insert(aabb(1.0, 0.0, 0.5, 0.5), ColliderFlags::SOLID);
347        set.clear();
348
349        assert!(set.is_empty());
350        assert_eq!(set.len(), 0);
351        assert!(set.query(&aabb(0.0, 0.0, 2.0, 2.0)).is_empty());
352
353        let reused = set.insert(aabb(0.0, 0.0, 0.5, 0.5), ColliderFlags::SENSOR);
354        assert_eq!(reused, 0, "indices restart after a clear");
355        assert_eq!(set.query(&aabb(0.0, 0.0, 0.5, 0.5)), vec![0]);
356        assert_eq!(
357            set.iter().next().map(|c| c.flags),
358            Some(ColliderFlags::SENSOR)
359        );
360    }
361
362    /// #316: a query region spanning 4e12 cells returns the colliders in it
363    /// without walking its cells.
364    #[wasm_bindgen_test(unsupported = test)]
365    fn a_huge_query_region_is_answered_without_walking_its_cells() {
366        let mut set = ColliderSet::new(1.0);
367        let near = set.insert(aabb(0.0, 0.0, 0.5, 0.5), ColliderFlags::SOLID);
368        let far = set.insert(aabb(9e5, -9e5, 0.5, 0.5), ColliderFlags::SOLID);
369        set.insert(aabb(3e6, 0.0, 0.5, 0.5), ColliderFlags::SOLID);
370        assert_eq!(set.query(&aabb(0.0, 0.0, 1e6, 1e6)), vec![near, far]);
371    }
372
373    /// #316: an inverted box (negative half-extents spanning cells) addresses
374    /// no cells: as a collider it is never a candidate, and as a region it
375    /// finds nothing, whether the query walks the grid or scans.
376    #[wasm_bindgen_test(unsupported = test)]
377    fn inverted_boxes_address_no_cells() {
378        let mut set = ColliderSet::new(1.0);
379        let solid = set.insert(aabb(0.0, 0.0, 0.5, 0.5), ColliderFlags::SOLID);
380        set.insert(aabb(0.0, 0.0, -3.0, -3.0), ColliderFlags::SOLID);
381        for i in 0..8u8 {
382            set.insert(
383                aabb(-50.0, f32::from(i) * 4.0, 0.5, 0.5),
384                ColliderFlags::SOLID,
385            );
386        }
387        // Grid path (4 cells, 10 colliders) and scan path (1e6 cells).
388        assert_eq!(set.query(&aabb(0.0, 0.0, 0.5, 0.5)), vec![solid]);
389        assert_eq!(set.query(&aabb(0.0, 0.0, 500.0, 500.0)).len(), 9);
390        assert!(set.query(&aabb(0.0, 0.0, -2.0, -2.0)).is_empty());
391        assert!(set.query(&aabb(0.0, 0.0, -500.0, -500.0)).is_empty());
392    }
393
394    /// #316: a collider spanning 4e12 cells is inserted without walking its
395    /// cells and is still found by a grid query from inside it, and only
396    /// there.
397    #[wasm_bindgen_test(unsupported = test)]
398    fn a_huge_collider_is_found_from_the_cells_it_covers() {
399        let mut set = ColliderSet::new(1.0);
400        let small = set.insert(aabb(3e6, 0.0, 0.5, 0.5), ColliderFlags::SOLID);
401        let block = set.insert(aabb(0.0, 0.0, 1e6, 1e6), ColliderFlags::SOLID);
402        // More colliders than a small query's cells, so it walks the grid.
403        for i in 0..8u8 {
404            set.insert(
405                aabb(-3e6, f32::from(i) * 4.0, 0.5, 0.5),
406                ColliderFlags::SOLID,
407            );
408        }
409        assert_eq!(set.query(&aabb(5e5, -5e5, 0.5, 0.5)), vec![block]);
410        assert_eq!(set.query(&aabb(3e6, 0.0, 0.5, 0.5)), vec![small]);
411        set.clear();
412        assert!(set.query(&aabb(5e5, -5e5, 0.5, 0.5)).is_empty());
413    }
414
415    /// A non-finite box must address no cells. Saturating casts would otherwise
416    /// turn an infinite bound into a walk over four billion grid cells.
417    #[wasm_bindgen_test(unsupported = test)]
418    fn non_finite_regions_address_no_cells() {
419        let mut set = ColliderSet::new(1.0);
420        set.insert(aabb(0.0, 0.0, 0.5, 0.5), ColliderFlags::SOLID);
421        assert!(
422            set.query(&aabb(0.0, 0.0, f32::INFINITY, 1.0)).is_empty(),
423            "an infinite region must not be walked cell by cell"
424        );
425        assert!(set.query(&aabb(f32::NAN, 0.0, 1.0, 1.0)).is_empty());
426    }
427}