Skip to main content

oxiphysics_gpu/
gpu_voxel.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! GPU voxel grid operations (CPU mock implementation).
5//!
6//! Provides a binary occupancy grid and a Signed Distance Field (SDF) built
7//! from it, plus morphological dilation/erosion and a marching-cubes triangle
8//! count estimate.  All routines are CPU references for the GPU backend.
9
10/// A 3-D voxel grid storing binary occupancy and an SDF.
11///
12/// Dimensions are `[nx, ny, nz]` with voxel size `voxel_size`.
13/// Data is stored in Z-major order: `index = x + nx*(y + ny*z)`.
14#[derive(Debug, Clone)]
15pub struct GpuVoxelGrid {
16    /// Number of voxels along X.
17    pub nx: usize,
18    /// Number of voxels along Y.
19    pub ny: usize,
20    /// Number of voxels along Z.
21    pub nz: usize,
22    /// Physical size of each voxel (assumed isotropic).
23    pub voxel_size: f64,
24    /// Binary occupancy: `true` = filled.
25    pub occupancy: Vec<bool>,
26    /// Signed Distance Field values (positive = outside, negative = inside).
27    pub sdf: Vec<f64>,
28}
29
30impl GpuVoxelGrid {
31    /// Allocate an empty voxel grid of size `nx × ny × nz`.
32    pub fn new(nx: usize, ny: usize, nz: usize, voxel_size: f64) -> Self {
33        let n = nx * ny * nz;
34        Self {
35            nx,
36            ny,
37            nz,
38            voxel_size,
39            occupancy: vec![false; n],
40            sdf: vec![f64::INFINITY; n],
41        }
42    }
43
44    /// Total number of voxels.
45    pub fn len(&self) -> usize {
46        self.nx * self.ny * self.nz
47    }
48
49    /// Returns `true` when the grid contains no voxels.
50    pub fn is_empty(&self) -> bool {
51        self.len() == 0
52    }
53
54    /// Flatten `(x, y, z)` grid coordinates to a linear index.
55    ///
56    /// # Panics
57    /// In debug builds, panics if any coordinate is out of range.
58    pub fn index(&self, x: usize, y: usize, z: usize) -> usize {
59        debug_assert!(x < self.nx && y < self.ny && z < self.nz);
60        x + self.nx * (y + self.ny * z)
61    }
62
63    /// Convert a linear index back to `(x, y, z)` coordinates.
64    pub fn coords(&self, idx: usize) -> (usize, usize, usize) {
65        let z = idx / (self.nx * self.ny);
66        let rem = idx % (self.nx * self.ny);
67        let y = rem / self.nx;
68        let x = rem % self.nx;
69        (x, y, z)
70    }
71
72    /// Count occupied voxels.
73    pub fn occupied_count(&self) -> usize {
74        self.occupancy.iter().filter(|&&v| v).count()
75    }
76}
77
78// ── GPU-mock operations ───────────────────────────────────────────────────────
79
80/// Rasterize a triangle soup into the voxel grid.
81///
82/// `triangles` is a flat list of triangle vertices:
83/// `[v0x, v0y, v0z, v1x, v1y, v1z, v2x, v2y, v2z, …]`.
84///
85/// Each voxel whose centre lies inside the axis-aligned bounding box of at
86/// least one triangle is marked as occupied.  This is an intentionally
87/// conservative (over-filling) approximation of a real GPU rasteriser.
88pub fn gpu_voxelize_mesh(grid: &mut GpuVoxelGrid, triangles: &[[f64; 9]]) {
89    for tri in triangles {
90        // Unpack vertices
91        let v0 = [tri[0], tri[1], tri[2]];
92        let v1 = [tri[3], tri[4], tri[5]];
93        let v2 = [tri[6], tri[7], tri[8]];
94
95        // AABB of the triangle
96        let min_x = v0[0].min(v1[0]).min(v2[0]);
97        let min_y = v0[1].min(v1[1]).min(v2[1]);
98        let min_z = v0[2].min(v1[2]).min(v2[2]);
99        let max_x = v0[0].max(v1[0]).max(v2[0]);
100        let max_y = v0[1].max(v1[1]).max(v2[1]);
101        let max_z = v0[2].max(v1[2]).max(v2[2]);
102
103        let vs = grid.voxel_size;
104        let ix0 = ((min_x / vs).floor() as isize).max(0) as usize;
105        let iy0 = ((min_y / vs).floor() as isize).max(0) as usize;
106        let iz0 = ((min_z / vs).floor() as isize).max(0) as usize;
107        let ix1 = ((max_x / vs).ceil() as usize).min(grid.nx.saturating_sub(1));
108        let iy1 = ((max_y / vs).ceil() as usize).min(grid.ny.saturating_sub(1));
109        let iz1 = ((max_z / vs).ceil() as usize).min(grid.nz.saturating_sub(1));
110
111        for iz in iz0..=iz1 {
112            for iy in iy0..=iy1 {
113                for ix in ix0..=ix1 {
114                    let idx = grid.index(ix, iy, iz);
115                    grid.occupancy[idx] = true;
116                }
117            }
118        }
119    }
120}
121
122/// Compute an approximate SDF from the binary occupancy grid using BFS.
123///
124/// After the call, `grid.sdf[i]` contains:
125/// - `0.0` for occupied voxels,
126/// - `k * voxel_size` for voxels at BFS distance `k` from any occupied voxel
127///   (positive = outside).
128///
129/// The SDF is purely unsigned / exterior; interior negation is not computed in
130/// this CPU reference implementation.
131pub fn gpu_sdf_from_voxels(grid: &mut GpuVoxelGrid) {
132    use std::collections::VecDeque;
133
134    let n = grid.len();
135    grid.sdf = vec![f64::INFINITY; n];
136
137    let mut queue: VecDeque<usize> = VecDeque::new();
138
139    // Seed: all occupied voxels have distance 0
140    for i in 0..n {
141        if grid.occupancy[i] {
142            grid.sdf[i] = 0.0;
143            queue.push_back(i);
144        }
145    }
146
147    let nx = grid.nx;
148    let ny = grid.ny;
149    let nz = grid.nz;
150    let vs = grid.voxel_size;
151
152    while let Some(idx) = queue.pop_front() {
153        let (x, y, z) = grid.coords(idx);
154        let current_dist = grid.sdf[idx];
155
156        // 6-connected neighbours
157        let neighbours: [(isize, isize, isize); 6] = [
158            (1, 0, 0),
159            (-1, 0, 0),
160            (0, 1, 0),
161            (0, -1, 0),
162            (0, 0, 1),
163            (0, 0, -1),
164        ];
165        for (dx, dy, dz) in neighbours {
166            let nx_ = x as isize + dx;
167            let ny_ = y as isize + dy;
168            let nz_ = z as isize + dz;
169            if nx_ < 0 || ny_ < 0 || nz_ < 0 {
170                continue;
171            }
172            let (nx_, ny_, nz_) = (nx_ as usize, ny_ as usize, nz_ as usize);
173            if nx_ >= nx || ny_ >= ny || nz_ >= nz {
174                continue;
175            }
176            let ni = nx_ + nx * (ny_ + ny * nz_);
177            let new_dist = current_dist + vs;
178            if new_dist < grid.sdf[ni] {
179                grid.sdf[ni] = new_dist;
180                queue.push_back(ni);
181            }
182        }
183    }
184}
185
186/// Morphological dilation: mark a voxel as occupied if any 6-connected
187/// neighbour is occupied.
188///
189/// The operation is applied once (one dilation pass).
190pub fn gpu_voxel_dilate(grid: &mut GpuVoxelGrid) {
191    let old = grid.occupancy.clone();
192    let nx = grid.nx;
193    let ny = grid.ny;
194    let nz = grid.nz;
195
196    for z in 0..nz {
197        for y in 0..ny {
198            for x in 0..nx {
199                if old[x + nx * (y + ny * z)] {
200                    continue; // already occupied
201                }
202                // Check 6-connected neighbours
203                let nbrs: [(isize, isize, isize); 6] = [
204                    (1, 0, 0),
205                    (-1, 0, 0),
206                    (0, 1, 0),
207                    (0, -1, 0),
208                    (0, 0, 1),
209                    (0, 0, -1),
210                ];
211                for (dx, dy, dz) in nbrs {
212                    let nx_ = x as isize + dx;
213                    let ny_ = y as isize + dy;
214                    let nz_ = z as isize + dz;
215                    if nx_ < 0 || ny_ < 0 || nz_ < 0 {
216                        continue;
217                    }
218                    let (nx_, ny_, nz_) = (nx_ as usize, ny_ as usize, nz_ as usize);
219                    if nx_ >= nx || ny_ >= ny || nz_ >= nz {
220                        continue;
221                    }
222                    if old[nx_ + nx * (ny_ + ny * nz_)] {
223                        grid.occupancy[x + nx * (y + ny * z)] = true;
224                        break;
225                    }
226                }
227            }
228        }
229    }
230}
231
232/// Morphological erosion: clear a voxel if any 6-connected neighbour is empty.
233///
234/// The operation is applied once (one erosion pass).
235pub fn gpu_voxel_erode(grid: &mut GpuVoxelGrid) {
236    let old = grid.occupancy.clone();
237    let nx = grid.nx;
238    let ny = grid.ny;
239    let nz = grid.nz;
240
241    for z in 0..nz {
242        for y in 0..ny {
243            for x in 0..nx {
244                if !old[x + nx * (y + ny * z)] {
245                    continue; // already empty
246                }
247                let nbrs: [(isize, isize, isize); 6] = [
248                    (1, 0, 0),
249                    (-1, 0, 0),
250                    (0, 1, 0),
251                    (0, -1, 0),
252                    (0, 0, 1),
253                    (0, 0, -1),
254                ];
255                for (dx, dy, dz) in nbrs {
256                    let nx_ = x as isize + dx;
257                    let ny_ = y as isize + dy;
258                    let nz_ = z as isize + dz;
259                    // Boundary voxels are treated as empty neighbours → erode them
260                    let is_empty = if nx_ < 0 || ny_ < 0 || nz_ < 0 {
261                        true
262                    } else {
263                        let (nx_, ny_, nz_) = (nx_ as usize, ny_ as usize, nz_ as usize);
264                        if nx_ >= nx || ny_ >= ny || nz_ >= nz {
265                            true
266                        } else {
267                            !old[nx_ + nx * (ny_ + ny * nz_)]
268                        }
269                    };
270                    if is_empty {
271                        grid.occupancy[x + nx * (y + ny * z)] = false;
272                        break;
273                    }
274                }
275            }
276        }
277    }
278}
279
280/// Count the estimated number of triangles that would be produced by marching
281/// cubes on the current occupancy field.
282///
283/// For each cube (formed by 8 adjacent voxel corners), the occupancy of the 8
284/// corners is inspected.  A cube with at least one occupied and one empty
285/// corner contributes to the surface.  This function returns the count of such
286/// "active" cubes; each active cube typically produces 1-5 triangles; this
287/// function returns the number of active cubes as a lower-bound triangle proxy.
288pub fn gpu_march_cubes_count(grid: &GpuVoxelGrid) -> usize {
289    let nx = grid.nx;
290    let ny = grid.ny;
291    let nz = grid.nz;
292
293    if nx < 2 || ny < 2 || nz < 2 {
294        return 0;
295    }
296
297    let mut count = 0usize;
298    for z in 0..nz - 1 {
299        for y in 0..ny - 1 {
300            for x in 0..nx - 1 {
301                // 8 corners of the cube
302                let corners = [
303                    grid.occupancy[grid.index(x, y, z)],
304                    grid.occupancy[grid.index(x + 1, y, z)],
305                    grid.occupancy[grid.index(x, y + 1, z)],
306                    grid.occupancy[grid.index(x + 1, y + 1, z)],
307                    grid.occupancy[grid.index(x, y, z + 1)],
308                    grid.occupancy[grid.index(x + 1, y, z + 1)],
309                    grid.occupancy[grid.index(x, y + 1, z + 1)],
310                    grid.occupancy[grid.index(x + 1, y + 1, z + 1)],
311                ];
312                let any_filled = corners.iter().any(|&v| v);
313                let any_empty = corners.iter().any(|&v| !v);
314                if any_filled && any_empty {
315                    count += 1;
316                }
317            }
318        }
319    }
320    count
321}
322
323// ── Tests ─────────────────────────────────────────────────────────────────────
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    fn small_grid() -> GpuVoxelGrid {
330        GpuVoxelGrid::new(4, 4, 4, 1.0)
331    }
332
333    // --- GpuVoxelGrid ---
334
335    #[test]
336    fn test_grid_len() {
337        let g = small_grid();
338        assert_eq!(g.len(), 64);
339    }
340
341    #[test]
342    fn test_grid_is_empty_false() {
343        let g = small_grid();
344        assert!(!g.is_empty());
345    }
346
347    #[test]
348    fn test_grid_zero_dim_is_empty() {
349        let g = GpuVoxelGrid::new(0, 4, 4, 1.0);
350        assert!(g.is_empty());
351    }
352
353    #[test]
354    fn test_grid_starts_unoccupied() {
355        let g = small_grid();
356        assert_eq!(g.occupied_count(), 0);
357    }
358
359    #[test]
360    fn test_grid_sdf_starts_infinity() {
361        let g = small_grid();
362        assert!(g.sdf.iter().all(|&v| v == f64::INFINITY));
363    }
364
365    #[test]
366    fn test_index_roundtrip() {
367        let g = small_grid();
368        for z in 0..4 {
369            for y in 0..4 {
370                for x in 0..4 {
371                    let idx = g.index(x, y, z);
372                    let (rx, ry, rz) = g.coords(idx);
373                    assert_eq!((rx, ry, rz), (x, y, z));
374                }
375            }
376        }
377    }
378
379    #[test]
380    fn test_occupied_count() {
381        let mut g = small_grid();
382        g.occupancy[0] = true;
383        g.occupancy[1] = true;
384        assert_eq!(g.occupied_count(), 2);
385    }
386
387    // --- gpu_voxelize_mesh ---
388
389    #[test]
390    fn test_voxelize_single_triangle_marks_voxels() {
391        let mut g = GpuVoxelGrid::new(8, 8, 8, 1.0);
392        let tri = [[0.5, 0.5, 0.5, 2.5, 0.5, 0.5, 0.5, 2.5, 0.5]];
393        gpu_voxelize_mesh(&mut g, &tri);
394        assert!(g.occupied_count() > 0);
395    }
396
397    #[test]
398    fn test_voxelize_empty_triangle_list() {
399        let mut g = small_grid();
400        gpu_voxelize_mesh(&mut g, &[]);
401        assert_eq!(g.occupied_count(), 0);
402    }
403
404    #[test]
405    fn test_voxelize_two_triangles_more_voxels() {
406        let mut g = GpuVoxelGrid::new(10, 10, 10, 1.0);
407        let tri1 = [[0.5, 0.5, 0.5, 2.5, 0.5, 0.5, 0.5, 2.5, 0.5]];
408        let tri2 = [[5.5, 5.5, 5.5, 7.5, 5.5, 5.5, 5.5, 7.5, 5.5]];
409        let mut g1 = g.clone();
410        gpu_voxelize_mesh(&mut g1, &tri1);
411        let c1 = g1.occupied_count();
412        gpu_voxelize_mesh(&mut g, &[tri1[0], tri2[0]]);
413        let c2 = g.occupied_count();
414        assert!(c2 >= c1);
415    }
416
417    #[test]
418    fn test_voxelize_triangle_outside_grid_no_panic() {
419        let mut g = GpuVoxelGrid::new(4, 4, 4, 1.0);
420        let tri = [[
421            100.0, 100.0, 100.0, 200.0, 100.0, 100.0, 100.0, 200.0, 100.0,
422        ]];
423        gpu_voxelize_mesh(&mut g, &tri);
424        // No panic, grid unchanged
425        assert_eq!(g.occupied_count(), 0);
426    }
427
428    // --- gpu_sdf_from_voxels ---
429
430    #[test]
431    fn test_sdf_occupied_voxel_is_zero() {
432        let mut g = small_grid();
433        let _vi = g.index(2, 2, 2);
434
435        g.occupancy[_vi] = true;
436        gpu_sdf_from_voxels(&mut g);
437        assert!((g.sdf[g.index(2, 2, 2)]).abs() < 1e-12);
438    }
439
440    #[test]
441    fn test_sdf_neighbour_is_one_voxel_size() {
442        let mut g = GpuVoxelGrid::new(5, 5, 5, 1.0);
443        let _vi = g.index(2, 2, 2);
444
445        g.occupancy[_vi] = true;
446        gpu_sdf_from_voxels(&mut g);
447        let d = g.sdf[g.index(3, 2, 2)];
448        assert!((d - 1.0).abs() < 1e-12, "d = {d}");
449    }
450
451    #[test]
452    fn test_sdf_all_unoccupied_stays_infinity() {
453        let mut g = small_grid();
454        gpu_sdf_from_voxels(&mut g);
455        assert!(g.sdf.iter().all(|&v| v == f64::INFINITY));
456    }
457
458    #[test]
459    fn test_sdf_monotone_with_distance() {
460        let mut g = GpuVoxelGrid::new(10, 1, 1, 1.0);
461        let _vi = g.index(0, 0, 0);
462
463        g.occupancy[_vi] = true;
464        gpu_sdf_from_voxels(&mut g);
465        for x in 1..10 {
466            let prev = g.sdf[g.index(x - 1, 0, 0)];
467            let curr = g.sdf[g.index(x, 0, 0)];
468            assert!(
469                curr >= prev,
470                "SDF not monotone at x={x}: prev={prev} curr={curr}"
471            );
472        }
473    }
474
475    // --- gpu_voxel_dilate ---
476
477    #[test]
478    fn test_dilate_expands_single_voxel() {
479        let mut g = small_grid();
480        let _vi = g.index(2, 2, 2);
481
482        g.occupancy[_vi] = true;
483        let before = g.occupied_count();
484        gpu_voxel_dilate(&mut g);
485        let after = g.occupied_count();
486        assert!(after > before);
487    }
488
489    #[test]
490    fn test_dilate_empty_grid_stays_empty() {
491        let mut g = small_grid();
492        gpu_voxel_dilate(&mut g);
493        assert_eq!(g.occupied_count(), 0);
494    }
495
496    #[test]
497    fn test_dilate_fully_filled_stays_full() {
498        let mut g = small_grid();
499        for v in &mut g.occupancy {
500            *v = true;
501        }
502        gpu_voxel_dilate(&mut g);
503        assert_eq!(g.occupied_count(), 64);
504    }
505
506    // --- gpu_voxel_erode ---
507
508    #[test]
509    fn test_erode_single_voxel_disappears() {
510        let mut g = small_grid();
511        let _vi = g.index(2, 2, 2);
512
513        g.occupancy[_vi] = true;
514        gpu_voxel_erode(&mut g);
515        // A single voxel always has empty neighbours → eroded away
516        assert_eq!(g.occupied_count(), 0);
517    }
518
519    #[test]
520    fn test_erode_empty_stays_empty() {
521        let mut g = small_grid();
522        gpu_voxel_erode(&mut g);
523        assert_eq!(g.occupied_count(), 0);
524    }
525
526    #[test]
527    fn test_dilate_then_erode_subset_of_original() {
528        let mut g = small_grid();
529        // Fill interior 2×2×2
530        for z in 1..3 {
531            for y in 1..3 {
532                for x in 1..3 {
533                    let _vi = g.index(x, y, z);
534
535                    g.occupancy[_vi] = true;
536                }
537            }
538        }
539        let original_count = g.occupied_count();
540        gpu_voxel_dilate(&mut g);
541        gpu_voxel_erode(&mut g);
542        // After dilate+erode the count should be <= original (boundary loss)
543        assert!(g.occupied_count() <= original_count);
544    }
545
546    // --- gpu_march_cubes_count ---
547
548    #[test]
549    fn test_march_cubes_empty_grid_zero() {
550        let g = small_grid();
551        assert_eq!(gpu_march_cubes_count(&g), 0);
552    }
553
554    #[test]
555    fn test_march_cubes_full_grid_zero() {
556        let mut g = small_grid();
557        for v in &mut g.occupancy {
558            *v = true;
559        }
560        assert_eq!(gpu_march_cubes_count(&g), 0);
561    }
562
563    #[test]
564    fn test_march_cubes_single_occupied_voxel_nonzero() {
565        let mut g = small_grid();
566        let _vi = g.index(1, 1, 1);
567
568        g.occupancy[_vi] = true;
569        let count = gpu_march_cubes_count(&g);
570        assert!(count > 0, "expected > 0 active cubes, got {count}");
571    }
572
573    #[test]
574    fn test_march_cubes_tiny_grid_no_panic() {
575        let g = GpuVoxelGrid::new(1, 1, 1, 1.0);
576        assert_eq!(gpu_march_cubes_count(&g), 0);
577    }
578
579    #[test]
580    fn test_march_cubes_increases_with_more_surface() {
581        let mut g1 = GpuVoxelGrid::new(6, 6, 6, 1.0);
582        let _vi = g1.index(2, 2, 2);
583
584        g1.occupancy[_vi] = true;
585        let c1 = gpu_march_cubes_count(&g1);
586
587        let mut g2 = GpuVoxelGrid::new(6, 6, 6, 1.0);
588        for z in 1..4 {
589            for y in 1..4 {
590                for x in 1..4 {
591                    let _vi = g2.index(x, y, z);
592
593                    g2.occupancy[_vi] = true;
594                }
595            }
596        }
597        let c2 = gpu_march_cubes_count(&g2);
598        assert!(c2 >= c1, "c2={c2} should be >= c1={c1}");
599    }
600}