alice_physics/spatial.rs
1//! Spatial Hash Grid
2//!
3//! A hash-based spatial acceleration structure for neighbor queries.
4//! Used by [`crate::fluid`] for SPH neighbor search and available
5//! for cloth self-collision or any particle-based simulation.
6//!
7//! # How It Works
8//!
9//! The grid divides space into uniform cells. Each particle is inserted
10//! into the cell corresponding to its position. Neighbor queries examine
11//! the 3x3x3 neighborhood of cells around the query point.
12//!
13//! Author: Moroya Sakamoto
14
15use crate::math::{Fix128, Vec3Fix};
16
17#[cfg(not(feature = "std"))]
18use alloc::vec;
19#[cfg(not(feature = "std"))]
20use alloc::vec::Vec;
21
22/// Spatial hash grid for O(n) neighbor queries in particle simulations.
23///
24/// Divides space into a uniform 3D grid of `grid_dim^3` cells. Uses a
25/// CSR (Compressed Sparse Row) flat-buffer layout: `indices` holds all
26/// particle indices packed contiguously, and `cell_offsets[h]..cell_offsets[h+1]`
27/// gives the slice for cell `h`. This eliminates per-cell heap allocations
28/// and improves cache locality for neighbor queries.
29///
30/// # Build flow
31///
32/// 1. Call `clear()` to reset counts.
33/// 2. Call `insert()` for each particle.
34/// 3. Call `build()` to finalize the CSR layout.
35/// 4. Call `query_neighbors_into()` for lookups.
36pub struct SpatialGrid {
37 inv_cell_size: Fix128,
38 /// Raw cell size (units of 2^-64) when `1 / cell_size` is not
39 /// representable (`|raw| <= 2`); the cell coordinate is then the exact
40 /// floor quotient `x_raw / cell_raw`.
41 tiny_cell_raw: Option<i128>,
42 /// Flat particle index buffer (CSR values).
43 indices: Vec<usize>,
44 /// Cell start offsets in `indices`; length = total_cells + 1.
45 cell_offsets: Vec<usize>,
46 /// Per-cell counts used during the two-pass build.
47 counts: Vec<usize>,
48 grid_dim: usize,
49 grid_half: i64,
50 total_cells: usize,
51}
52
53impl SpatialGrid {
54 /// Create a new spatial grid.
55 ///
56 /// - `cell_size`: Side length of each cubic cell (should match the
57 /// interaction radius for best performance).
58 /// - `grid_dim`: Number of cells along each axis. Total cells = `grid_dim^3`.
59 #[must_use]
60 pub fn new(cell_size: Fix128, grid_dim: usize) -> Self {
61 let inv_cell = if cell_size.is_zero() {
62 Fix128::ONE
63 } else {
64 Fix128::ONE / cell_size
65 };
66 let cell_raw = fix_raw(cell_size);
67 let tiny_cell_raw = (cell_raw != 0 && cell_raw.unsigned_abs() <= 2).then_some(cell_raw);
68 let grid_half = (grid_dim as i64) / 2;
69 let total_cells = grid_dim * grid_dim * grid_dim;
70 Self {
71 inv_cell_size: inv_cell,
72 tiny_cell_raw,
73 indices: Vec::new(),
74 cell_offsets: vec![0; total_cells + 1],
75 counts: vec![0; total_cells],
76 grid_dim,
77 grid_half,
78 total_cells,
79 }
80 }
81
82 /// Reset the grid for a fresh build pass.
83 pub fn clear(&mut self) {
84 self.counts.fill(0);
85 self.cell_offsets.fill(0);
86 self.indices.clear();
87 }
88
89 /// Cell coordinate along one axis:
90 /// `clamp(floor(x / cell_size) + grid_dim / 2, 0, grid_dim - 1)`.
91 ///
92 /// Total over the whole `Fix128` range, identical in debug and release:
93 ///
94 /// - `|x / cell_size| < 2^62` (every position that fits in a grid one can
95 /// allocate): `floor` is taken from `x * (1 / cell_size)` exactly as
96 /// before, so these cells are bit-unchanged.
97 /// - `|x / cell_size| >= 2^62`: the scaled coordinate (or the
98 /// `+ grid_dim / 2` offset) does not fit in `i64`; the cell is the
99 /// border cell on the side of the sign of `x / cell_size` (`0` or
100 /// `grid_dim - 1`), which is what the clamp gives for the exact value
101 /// because `grid_dim / 2 < 2^62` for any allocatable grid.
102 /// - `|cell_size| <= 2^-63` (`1 / cell_size` not representable): the cell
103 /// is the exact floor quotient of the raw values.
104 ///
105 /// Requires `grid_dim > 0`.
106 #[inline(always)]
107 fn axis_cell(&self, x: Fix128) -> usize {
108 let gd = self.grid_dim as i64;
109 let half = self.grid_half;
110 let x_raw = fix_raw(x);
111 if let Some(c_raw) = self.tiny_cell_raw {
112 // floor(x_raw / c_raw); `checked_div` fails only for i128::MIN / -1
113 // whose quotient is +2^127 (beyond every cell)
114 let q = match x_raw.checked_div(c_raw) {
115 Some(q) if x_raw % c_raw != 0 && ((x_raw < 0) != (c_raw < 0)) => q - 1,
116 Some(q) => q,
117 None => return self.grid_dim - 1,
118 };
119 return q
120 .saturating_add(i128::from(half))
121 .clamp(0, i128::from(gd - 1)) as usize;
122 }
123 let inv_raw = fix_raw(self.inv_cell_size);
124 // |x * inv| < 2^62 <=> |x_raw| * |inv_raw| < 2^(62 + 128)
125 if mul_u128_high(x_raw.unsigned_abs(), inv_raw.unsigned_abs()) < (1u128 << 62) {
126 return ((x * self.inv_cell_size).hi.saturating_add(half)).clamp(0, gd - 1) as usize;
127 }
128 if (x_raw < 0) != (inv_raw < 0) {
129 0
130 } else {
131 self.grid_dim - 1
132 }
133 }
134
135 /// Compute the cell index for a given position.
136 ///
137 /// Each axis is `clamp(floor(x / cell_size) + grid_dim / 2, 0, grid_dim - 1)`
138 /// for every `Fix128` input: positions far outside the grid (up to the
139 /// ends of the `Fix128` range, or with a cell size down to `2^-64`) land
140 /// in the border cell on their side, never panic and never wrap, in both
141 /// debug and release builds.
142 ///
143 /// A grid built with `grid_dim = 0` has no cells; every position hashes
144 /// to `0`, and callers already guard `h < total_cells` (which is also
145 /// `0`) before using the result.
146 #[inline(always)]
147 #[must_use]
148 pub fn hash(&self, pos: Vec3Fix) -> usize {
149 if self.grid_dim == 0 {
150 return 0;
151 }
152 let ix = self.axis_cell(pos.x);
153 let iy = self.axis_cell(pos.y);
154 let iz = self.axis_cell(pos.z);
155 ix + iy * self.grid_dim + iz * self.grid_dim * self.grid_dim
156 }
157
158 /// Record a particle insertion (pass 1: count only).
159 ///
160 /// Call `build()` after all insertions to finalize the CSR layout.
161 pub fn insert(&mut self, idx: usize, pos: Vec3Fix) {
162 let h = self.hash(pos);
163 if h < self.total_cells {
164 self.counts[h] += 1;
165 // Store (h, idx) temporarily in `indices` as interleaved pairs.
166 self.indices.push(h);
167 self.indices.push(idx);
168 }
169 }
170
171 /// Finalize the CSR layout after all `insert()` calls.
172 ///
173 /// Converts the temporary (cell, particle) pairs into a proper
174 /// prefix-sum offset table and sorted flat index buffer.
175 pub fn build(&mut self) {
176 // Prefix-sum counts → cell_offsets
177 let mut running = 0usize;
178 for h in 0..self.total_cells {
179 self.cell_offsets[h] = running;
180 running += self.counts[h];
181 self.counts[h] = 0; // reuse as write cursor below
182 }
183 self.cell_offsets[self.total_cells] = running;
184
185 // Scatter particle indices into a final sorted buffer
186 let n_pairs = self.indices.len() / 2;
187 let mut final_buf = vec![0usize; running];
188 for k in 0..n_pairs {
189 let h = self.indices[k * 2];
190 let idx = self.indices[k * 2 + 1];
191 let slot = self.cell_offsets[h] + self.counts[h];
192 final_buf[slot] = idx;
193 self.counts[h] += 1;
194 }
195 self.indices = final_buf;
196 }
197
198 /// Collect all particle indices in the 3x3x3 neighborhood of `pos`.
199 ///
200 /// Results are appended to `neighbors` (which is cleared first).
201 // LIMITATION(COV-PART-052): `_radius_sq` is reserved for future distance filtering.
202 /// `_radius_sq` is reserved for future distance filtering.
203 pub fn query_neighbors_into(
204 &self,
205 pos: Vec3Fix,
206 _radius_sq: Fix128,
207 neighbors: &mut Vec<usize>,
208 ) {
209 neighbors.clear();
210 if self.grid_dim == 0 {
211 return;
212 }
213 let cx = self.axis_cell(pos.x) as i32;
214 let cy = self.axis_cell(pos.y) as i32;
215 let cz = self.axis_cell(pos.z) as i32;
216
217 for dz in -1..=1 {
218 for dy in -1..=1 {
219 for dx in -1..=1 {
220 let nx = cx + dx;
221 let ny = cy + dy;
222 let nz = cz + dz;
223 if nx < 0 || ny < 0 || nz < 0 {
224 continue;
225 }
226 let nx = nx as usize;
227 let ny = ny as usize;
228 let nz = nz as usize;
229 if nx >= self.grid_dim || ny >= self.grid_dim || nz >= self.grid_dim {
230 continue;
231 }
232
233 let h = nx + ny * self.grid_dim + nz * self.grid_dim * self.grid_dim;
234 let start = self.cell_offsets[h];
235 let end = self.cell_offsets[h + 1];
236 for &idx in &self.indices[start..end] {
237 neighbors.push(idx);
238 }
239 }
240 }
241 }
242 }
243}
244
245/// The signed 128-bit raw value of a `Fix128` (units of 2^-64).
246#[inline(always)]
247fn fix_raw(v: Fix128) -> i128 {
248 (i128::from(v.hi) << 64) | i128::from(v.lo)
249}
250
251/// High 128 bits of the 256-bit product `a * b`.
252#[inline(always)]
253fn mul_u128_high(a: u128, b: u128) -> u128 {
254 const M: u128 = u64::MAX as u128;
255 let (a1, a0) = (a >> 64, a & M);
256 let (b1, b0) = (b >> 64, b & M);
257 let ll = a0 * b0;
258 let lh = a0 * b1;
259 let hl = a1 * b0;
260 let hh = a1 * b1;
261 let mid = (ll >> 64) + (lh & M) + (hl & M);
262 hh + (lh >> 64) + (hl >> 64) + (mid >> 64)
263}
264
265// ============================================================================
266// Tests
267// ============================================================================
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn test_spatial_grid_basic() {
275 let mut grid = SpatialGrid::new(Fix128::from_ratio(1, 5), 32);
276 grid.insert(0, Vec3Fix::ZERO);
277 grid.insert(1, Vec3Fix::from_f32(0.1, 0.0, 0.0));
278 grid.insert(2, Vec3Fix::from_f32(10.0, 0.0, 0.0));
279 grid.build();
280
281 let h_sq = Fix128::from_ratio(1, 5) * Fix128::from_ratio(1, 5);
282 let mut neighbors = Vec::new();
283 grid.query_neighbors_into(Vec3Fix::ZERO, h_sq, &mut neighbors);
284 assert!(neighbors.contains(&0), "Should find self");
285 assert!(neighbors.contains(&1), "Should find nearby particle");
286 }
287
288 #[test]
289 fn test_spatial_grid_clear() {
290 let mut grid = SpatialGrid::new(Fix128::ONE, 8);
291 grid.insert(0, Vec3Fix::ZERO);
292 grid.build();
293 grid.clear();
294
295 let mut neighbors = Vec::new();
296 grid.query_neighbors_into(Vec3Fix::ZERO, Fix128::ONE, &mut neighbors);
297 assert!(neighbors.is_empty(), "Grid should be empty after clear");
298 }
299
300 #[test]
301 fn test_spatial_grid_hash_determinism() {
302 let grid = SpatialGrid::new(Fix128::from_ratio(1, 5), 32);
303 let pos = Vec3Fix::from_f32(1.5, -2.3, 0.7);
304 let h1 = grid.hash(pos);
305 let h2 = grid.hash(pos);
306 assert_eq!(h1, h2, "Hash must be deterministic");
307 }
308
309 fn on_x(x: Fix128) -> Vec3Fix {
310 Vec3Fix::new(x, Fix128::ZERO, Fix128::ZERO)
311 }
312
313 /// oracle: a zero cell size is treated as 1, so on a grid of 4
314 /// (`grid_dim / 2 = 2`) `x = 3/2` falls in cell `floor(3/2) + 2 = 3` and
315 /// `x = −3/2` in `−2 + 2 = 0` (`y = z = 0` give cell 2 on those axes).
316 #[test]
317 fn zero_cell_size_hashes_as_unit_cells() {
318 let g = SpatialGrid::new(Fix128::ZERO, 4);
319 let base = 2 * 4 + 2 * 16;
320 assert_eq!(g.hash(on_x(Fix128::from_ratio(3, 2))), 3 + base);
321 assert_eq!(g.hash(on_x(Fix128::from_ratio(-3, 2))), base);
322 }
323
324 /// oracle: for a cell of 2 raw units (`2⁻⁶³`, its reciprocal is not
325 /// representable) the cell is the floor quotient of the raw values plus
326 /// 2: raw 3 → `1 + 2 = 3`, raw −1 → `−1 + 2 = 1`, raw −3 → `−2 + 2 = 0`.
327 /// A cell of −1 raw unit and the most negative position (whose quotient
328 /// `+2¹²⁷` does not fit) gives the last cell.
329 #[test]
330 fn tiny_cells_use_the_exact_raw_floor_quotient() {
331 let g = SpatialGrid::new(Fix128::from_raw(0, 2), 4);
332 let base = 2 * 4 + 2 * 16;
333 assert_eq!(g.hash(on_x(Fix128::from_raw(0, 3))), 3 + base);
334 assert_eq!(g.hash(on_x(Fix128::from_raw(-1, u64::MAX))), 1 + base);
335 assert_eq!(g.hash(on_x(Fix128::from_raw(-1, u64::MAX - 2))), base);
336 let neg = SpatialGrid::new(Fix128::from_raw(-1, u64::MAX), 4);
337 assert_eq!(neg.hash(on_x(Fix128::from_raw(i64::MIN, 0))), 3 + base);
338 }
339
340 /// oracle: with cell 1 a coordinate of `±2⁶²` (beyond `2⁶²` scaled) lands
341 /// in the border cell of its sign; with cell −1 the sign flips. A grid
342 /// of 0 cells hashes everything to 0 and finds no neighbours.
343 #[test]
344 fn far_coordinates_and_an_empty_grid() {
345 let big = Fix128::from_int(1 << 62);
346 let g = SpatialGrid::new(Fix128::ONE, 4);
347 let base = 2 * 4 + 2 * 16;
348 assert_eq!(g.hash(on_x(big)), 3 + base);
349 assert_eq!(g.hash(on_x(-big)), base);
350 let flipped = SpatialGrid::new(-Fix128::ONE, 4);
351 assert_eq!(flipped.hash(on_x(big)), base);
352 let empty = SpatialGrid::new(Fix128::ONE, 0);
353 assert_eq!(empty.hash(on_x(big)), 0);
354 let mut out = vec![7];
355 empty.query_neighbors_into(Vec3Fix::ZERO, Fix128::ONE, &mut out);
356 assert!(out.is_empty());
357 }
358}