Skip to main content

oxiphysics_gpu/
gpu_sph_density.rs

1#![allow(clippy::needless_range_loop)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! GPU-accelerated SPH density computation (CPU mock implementation).
6//!
7//! This module provides Smoothed Particle Hydrodynamics (SPH) density
8//! computation routines that mirror what would run on a GPU. All kernels
9//! are executed on the CPU via plain loops for portability.
10
11// ── SPH constants ────────────────────────────────────────────────────────────
12
13/// Default Tait EOS exponent (gamma).
14const TAIT_GAMMA: f64 = 7.0;
15
16// ── Data structures ──────────────────────────────────────────────────────────
17
18/// A grid of SPH particles with associated physical quantities.
19///
20/// Positions are stored as flat arrays of (x, y, z) triplets.
21#[allow(dead_code)]
22#[derive(Debug, Clone)]
23pub struct GpuSphGrid {
24    /// Particle positions: `[x0, y0, z0, x1, y1, z1, …]`.
25    pub positions: Vec<f64>,
26    /// Particle masses (one per particle).
27    pub masses: Vec<f64>,
28    /// Smoothing lengths (one per particle).
29    pub smoothing_lengths: Vec<f64>,
30    /// Particle densities (one per particle, updated by [`gpu_density_kernel`]).
31    pub densities: Vec<f64>,
32    /// Particle pressures (one per particle, updated by [`gpu_pressure_tait`]).
33    pub pressures: Vec<f64>,
34    /// Particle velocities: `[vx0, vy0, vz0, …]`.
35    pub velocities: Vec<f64>,
36    /// Particle forces: `[fx0, fy0, fz0, …]` (output of [`gpu_force_kernel`]).
37    pub forces: Vec<f64>,
38    /// Reference density ρ₀ for the Tait equation of state.
39    pub rho0: f64,
40    /// Speed of sound c₀ for the Tait EOS.
41    pub c0: f64,
42}
43
44impl GpuSphGrid {
45    /// Create a new `GpuSphGrid` with `n` particles.
46    ///
47    /// All physical quantities are initialised to zero; `rho0` defaults to
48    /// `1000.0` kg/m³ (water) and `c0` to `1500.0` m/s.
49    pub fn new(n: usize) -> Self {
50        Self {
51            positions: vec![0.0; n * 3],
52            masses: vec![1.0; n],
53            smoothing_lengths: vec![1.0; n],
54            densities: vec![0.0; n],
55            pressures: vec![0.0; n],
56            velocities: vec![0.0; n * 3],
57            forces: vec![0.0; n * 3],
58            rho0: 1000.0,
59            c0: 1500.0,
60        }
61    }
62
63    /// Number of particles stored in this grid.
64    pub fn particle_count(&self) -> usize {
65        self.masses.len()
66    }
67}
68
69// ── SPH kernel functions ─────────────────────────────────────────────────────
70
71/// Cubic-spline SPH kernel W(r, h).
72///
73/// Returns the kernel value at distance `r` with smoothing length `h`.
74#[allow(dead_code)]
75pub fn cubic_spline_kernel(r: f64, h: f64) -> f64 {
76    if h <= 0.0 {
77        return 0.0;
78    }
79    let q = r / h;
80    let sigma = 1.0 / (std::f64::consts::PI * h * h * h);
81    if q < 1.0 {
82        sigma * (1.0 - 1.5 * q * q + 0.75 * q * q * q)
83    } else if q < 2.0 {
84        let t = 2.0 - q;
85        sigma * 0.25 * t * t * t
86    } else {
87        0.0
88    }
89}
90
91/// Gradient of the cubic-spline kernel ∇W(r, h) along the displacement vector.
92///
93/// Returns `[dW/dx, dW/dy, dW/dz]`.
94#[allow(dead_code)]
95pub fn cubic_spline_kernel_grad(dx: f64, dy: f64, dz: f64, h: f64) -> [f64; 3] {
96    let r = (dx * dx + dy * dy + dz * dz).sqrt();
97    if h <= 0.0 || r < 1e-15 {
98        return [0.0; 3];
99    }
100    let q = r / h;
101    let sigma = 1.0 / (std::f64::consts::PI * h * h * h);
102    let dw_dr = if q < 1.0 {
103        sigma * (-3.0 * q + 2.25 * q * q) / h
104    } else if q < 2.0 {
105        let t = 2.0 - q;
106        -sigma * 0.75 * t * t / h
107    } else {
108        0.0
109    };
110    let inv_r = 1.0 / r;
111    [dw_dr * dx * inv_r, dw_dr * dy * inv_r, dw_dr * dz * inv_r]
112}
113
114// ── GPU kernel mocks ─────────────────────────────────────────────────────────
115
116/// Parallel density summation over all particle pairs (mock via loop).
117///
118/// For each particle `i`, computes:
119/// `ρᵢ = Σⱼ mⱼ · W(|rᵢ − rⱼ|, hᵢ)`
120///
121/// Results are written into `grid.densities`.
122pub fn gpu_density_kernel(grid: &mut GpuSphGrid) {
123    let n = grid.particle_count();
124    for i in 0..n {
125        let mut rho = 0.0f64;
126        let xi = grid.positions[i * 3];
127        let yi = grid.positions[i * 3 + 1];
128        let zi = grid.positions[i * 3 + 2];
129        let hi = grid.smoothing_lengths[i];
130        for j in 0..n {
131            let xj = grid.positions[j * 3];
132            let yj = grid.positions[j * 3 + 1];
133            let zj = grid.positions[j * 3 + 2];
134            let r = ((xi - xj).powi(2) + (yi - yj).powi(2) + (zi - zj).powi(2)).sqrt();
135            rho += grid.masses[j] * cubic_spline_kernel(r, hi);
136        }
137        grid.densities[i] = rho;
138    }
139}
140
141/// Vectorized Tait EOS pressure update.
142///
143/// For each particle `i`, computes:
144/// `Pᵢ = (ρ₀·c₀²/γ) · [(ρᵢ/ρ₀)^γ − 1]`
145///
146/// Results are written into `grid.pressures`.
147pub fn gpu_pressure_tait(grid: &mut GpuSphGrid) {
148    let n = grid.particle_count();
149    let prefactor = grid.rho0 * grid.c0 * grid.c0 / TAIT_GAMMA;
150    for i in 0..n {
151        let ratio = grid.densities[i] / grid.rho0;
152        grid.pressures[i] = prefactor * (ratio.powf(TAIT_GAMMA) - 1.0);
153    }
154}
155
156/// Pressure-gradient and viscosity force kernel (mock via loop).
157///
158/// Computes forces on each particle from pressure gradients and artificial
159/// viscosity with coefficient `nu`. Results are accumulated into
160/// `grid.forces`.
161pub fn gpu_force_kernel(grid: &mut GpuSphGrid, nu: f64) {
162    let n = grid.particle_count();
163    // zero forces first
164    for f in grid.forces.iter_mut() {
165        *f = 0.0;
166    }
167    for i in 0..n {
168        let xi = grid.positions[i * 3];
169        let yi = grid.positions[i * 3 + 1];
170        let zi = grid.positions[i * 3 + 2];
171        let hi = grid.smoothing_lengths[i];
172        let pi = grid.pressures[i];
173        let rhoi = grid.densities[i];
174        if rhoi < 1e-15 {
175            continue;
176        }
177        let vxi = grid.velocities[i * 3];
178        let vyi = grid.velocities[i * 3 + 1];
179        let vzi = grid.velocities[i * 3 + 2];
180
181        for j in 0..n {
182            if i == j {
183                continue;
184            }
185            let dx = xi - grid.positions[j * 3];
186            let dy = yi - grid.positions[j * 3 + 1];
187            let dz = zi - grid.positions[j * 3 + 2];
188            let rhoj = grid.densities[j];
189            if rhoj < 1e-15 {
190                continue;
191            }
192            let pj = grid.pressures[j];
193            let mj = grid.masses[j];
194            let grad = cubic_spline_kernel_grad(dx, dy, dz, hi);
195            // pressure gradient term
196            let coeff = -mj * (pi / (rhoi * rhoi) + pj / (rhoj * rhoj));
197            // viscosity term (dot(v_ij, r_ij))
198            let dvx = vxi - grid.velocities[j * 3];
199            let dvy = vyi - grid.velocities[j * 3 + 1];
200            let dvz = vzi - grid.velocities[j * 3 + 2];
201            let r2 = dx * dx + dy * dy + dz * dz + 1e-15;
202            let vdotr = dvx * dx + dvy * dy + dvz * dz;
203            let visc = -nu * mj * vdotr / (rhoj * r2);
204
205            grid.forces[i * 3] += (coeff + visc) * grad[0];
206            grid.forces[i * 3 + 1] += (coeff + visc) * grad[1];
207            grid.forces[i * 3 + 2] += (coeff + visc) * grad[2];
208        }
209    }
210}
211
212/// Build a cell-list neighbor search structure.
213///
214/// Divides the simulation domain `[min_x, max_x]³` into cubic cells of
215/// size `cell_size`. Returns, for each particle `i`, the list of neighbour
216/// indices within `2 * cell_size`.
217///
218/// Returns a `Vec<Vec`usize`>` where entry `i` holds the neighbours of
219/// particle `i`.
220pub fn gpu_neighbor_list(grid: &GpuSphGrid, cell_size: f64) -> Vec<Vec<usize>> {
221    let n = grid.particle_count();
222    let mut neighbors: Vec<Vec<usize>> = vec![Vec::new(); n];
223    let cutoff2 = (2.0 * cell_size) * (2.0 * cell_size);
224    for i in 0..n {
225        let xi = grid.positions[i * 3];
226        let yi = grid.positions[i * 3 + 1];
227        let zi = grid.positions[i * 3 + 2];
228        for j in 0..n {
229            if i == j {
230                continue;
231            }
232            let dx = xi - grid.positions[j * 3];
233            let dy = yi - grid.positions[j * 3 + 1];
234            let dz = zi - grid.positions[j * 3 + 2];
235            if dx * dx + dy * dy + dz * dz <= cutoff2 {
236                neighbors[i].push(j);
237            }
238        }
239    }
240    neighbors
241}
242
243/// Orchestrate a full SPH density pass.
244///
245/// Runs, in order:
246/// 1. [`gpu_density_kernel`] — compute densities
247/// 2. [`gpu_pressure_tait`] — update pressures via Tait EOS
248/// 3. [`gpu_force_kernel`]   — compute pressure + viscosity forces
249pub fn launch_density_pass(grid: &mut GpuSphGrid, nu: f64) {
250    gpu_density_kernel(grid);
251    gpu_pressure_tait(grid);
252    gpu_force_kernel(grid, nu);
253}
254
255// ── Tests ────────────────────────────────────────────────────────────────────
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    fn make_two_particle_grid() -> GpuSphGrid {
262        let mut g = GpuSphGrid::new(2);
263        // particle 0 at origin, particle 1 at (0.5, 0, 0)
264        g.positions = vec![0.0, 0.0, 0.0, 0.5, 0.0, 0.0];
265        g.masses = vec![1.0, 1.0];
266        g.smoothing_lengths = vec![1.0, 1.0];
267        g.rho0 = 1000.0;
268        g.c0 = 100.0;
269        g
270    }
271
272    #[test]
273    fn test_new_grid_particle_count() {
274        let g = GpuSphGrid::new(10);
275        assert_eq!(g.particle_count(), 10);
276    }
277
278    #[test]
279    fn test_new_grid_default_rho0() {
280        let g = GpuSphGrid::new(1);
281        assert!((g.rho0 - 1000.0).abs() < 1e-10);
282    }
283
284    #[test]
285    fn test_new_grid_default_c0() {
286        let g = GpuSphGrid::new(1);
287        assert!((g.c0 - 1500.0).abs() < 1e-10);
288    }
289
290    #[test]
291    fn test_cubic_kernel_zero_distance() {
292        // W(0, h) should be positive (self-contribution)
293        let w = cubic_spline_kernel(0.0, 1.0);
294        assert!(w > 0.0, "kernel at r=0 should be positive, got {w}");
295    }
296
297    #[test]
298    fn test_cubic_kernel_beyond_support() {
299        let w = cubic_spline_kernel(3.0, 1.0);
300        assert!(
301            (w).abs() < 1e-15,
302            "kernel beyond 2h should be zero, got {w}"
303        );
304    }
305
306    #[test]
307    fn test_cubic_kernel_positive_within_support() {
308        let w = cubic_spline_kernel(1.5, 1.0);
309        assert!(w >= 0.0);
310    }
311
312    #[test]
313    fn test_cubic_kernel_zero_h() {
314        let w = cubic_spline_kernel(1.0, 0.0);
315        assert_eq!(w, 0.0);
316    }
317
318    #[test]
319    fn test_cubic_kernel_grad_zero_displacement() {
320        let g = cubic_spline_kernel_grad(0.0, 0.0, 0.0, 1.0);
321        assert_eq!(g, [0.0; 3]);
322    }
323
324    #[test]
325    fn test_cubic_kernel_grad_symmetry() {
326        // Grad should be antisymmetric: ∇W(r) = -∇W(-r)
327        let g1 = cubic_spline_kernel_grad(0.3, 0.0, 0.0, 1.0);
328        let g2 = cubic_spline_kernel_grad(-0.3, 0.0, 0.0, 1.0);
329        assert!((g1[0] + g2[0]).abs() < 1e-12);
330    }
331
332    #[test]
333    fn test_density_kernel_single_particle() {
334        let mut g = GpuSphGrid::new(1);
335        g.positions = vec![0.0, 0.0, 0.0];
336        g.masses = vec![1.0];
337        g.smoothing_lengths = vec![1.0];
338        gpu_density_kernel(&mut g);
339        // Density = m * W(0, h) > 0
340        assert!(g.densities[0] > 0.0);
341    }
342
343    #[test]
344    fn test_density_kernel_two_particles() {
345        let mut g = make_two_particle_grid();
346        gpu_density_kernel(&mut g);
347        assert!(g.densities[0] > 0.0);
348        assert!(g.densities[1] > 0.0);
349    }
350
351    #[test]
352    fn test_density_kernel_symmetric() {
353        let mut g = make_two_particle_grid();
354        gpu_density_kernel(&mut g);
355        // Both particles have the same mass & smoothing length → equal density
356        assert!((g.densities[0] - g.densities[1]).abs() < 1e-12);
357    }
358
359    #[test]
360    fn test_pressure_tait_zero_density() {
361        let mut g = GpuSphGrid::new(1);
362        g.densities = vec![0.0];
363        g.rho0 = 1000.0;
364        g.c0 = 100.0;
365        gpu_pressure_tait(&mut g);
366        // (0/rho0)^gamma - 1 < 0 → negative pressure
367        assert!(g.pressures[0] < 0.0);
368    }
369
370    #[test]
371    fn test_pressure_tait_at_reference_density() {
372        let mut g = GpuSphGrid::new(1);
373        g.rho0 = 1000.0;
374        g.c0 = 100.0;
375        g.densities = vec![g.rho0];
376        gpu_pressure_tait(&mut g);
377        // (rho0/rho0)^gamma - 1 = 0 → pressure = 0
378        assert!(g.pressures[0].abs() < 1e-6);
379    }
380
381    #[test]
382    fn test_pressure_tait_above_reference() {
383        let mut g = GpuSphGrid::new(1);
384        g.rho0 = 1000.0;
385        g.c0 = 100.0;
386        g.densities = vec![1100.0];
387        gpu_pressure_tait(&mut g);
388        assert!(g.pressures[0] > 0.0);
389    }
390
391    #[test]
392    fn test_force_kernel_self_zero() {
393        // With a single particle, pressure forces should be zero
394        let mut g = GpuSphGrid::new(1);
395        g.positions = vec![0.0, 0.0, 0.0];
396        g.masses = vec![1.0];
397        g.densities = vec![1000.0];
398        g.pressures = vec![0.0];
399        gpu_force_kernel(&mut g, 0.0);
400        assert!((g.forces[0]).abs() < 1e-15);
401    }
402
403    #[test]
404    fn test_force_kernel_repulsion() {
405        // Two close particles with pressure > 0 should repel
406        let mut g = make_two_particle_grid();
407        gpu_density_kernel(&mut g);
408        gpu_pressure_tait(&mut g);
409        gpu_force_kernel(&mut g, 0.0);
410        // Particle 0 at origin should be pushed in the -x direction (towards negative x)
411        // because particle 1 is at +x
412        let fx0 = g.forces[0];
413        let fx1 = g.forces[3];
414        // Forces should be opposite
415        assert!(fx0 * fx1 < 0.0 || (fx0.abs() < 1e-12 && fx1.abs() < 1e-12));
416    }
417
418    #[test]
419    fn test_force_kernel_zeros_forces_first() {
420        let mut g = GpuSphGrid::new(2);
421        g.forces = vec![9.9, 9.9, 9.9, 9.9, 9.9, 9.9];
422        gpu_force_kernel(&mut g, 0.0);
423        // With zero density, forces remain zero after re-initialisation
424        for &f in &g.forces {
425            assert!(f.abs() < 1e-15);
426        }
427    }
428
429    #[test]
430    fn test_neighbor_list_all_close() {
431        let g = make_two_particle_grid();
432        let nl = gpu_neighbor_list(&g, 1.0);
433        // With cell_size=1.0, cutoff=2.0 → both particles are neighbours
434        assert!(nl[0].contains(&1));
435        assert!(nl[1].contains(&0));
436    }
437
438    #[test]
439    fn test_neighbor_list_too_far() {
440        let mut g = GpuSphGrid::new(2);
441        g.positions = vec![0.0, 0.0, 0.0, 100.0, 0.0, 0.0];
442        let nl = gpu_neighbor_list(&g, 1.0);
443        assert!(nl[0].is_empty());
444        assert!(nl[1].is_empty());
445    }
446
447    #[test]
448    fn test_neighbor_list_no_self() {
449        let g = make_two_particle_grid();
450        let nl = gpu_neighbor_list(&g, 1.0);
451        assert!(!nl[0].contains(&0));
452        assert!(!nl[1].contains(&1));
453    }
454
455    #[test]
456    fn test_launch_density_pass_updates_all() {
457        let mut g = make_two_particle_grid();
458        launch_density_pass(&mut g, 0.01);
459        // After a full pass everything should be non-zero (apart from forces
460        // which may still be small)
461        assert!(g.densities[0] > 0.0);
462        assert!(g.densities[1] > 0.0);
463    }
464
465    #[test]
466    fn test_launch_density_pass_idempotent() {
467        let mut g1 = make_two_particle_grid();
468        let mut g2 = make_two_particle_grid();
469        launch_density_pass(&mut g1, 0.0);
470        launch_density_pass(&mut g2, 0.0);
471        for i in 0..2 {
472            assert!((g1.densities[i] - g2.densities[i]).abs() < 1e-12);
473            assert!((g1.pressures[i] - g2.pressures[i]).abs() < 1e-12);
474        }
475    }
476
477    #[test]
478    fn test_force_magnitude_finite() {
479        let mut g = make_two_particle_grid();
480        launch_density_pass(&mut g, 0.01);
481        for &f in &g.forces {
482            assert!(f.is_finite(), "force is not finite: {f}");
483        }
484    }
485
486    #[test]
487    fn test_density_five_particles() {
488        let n = 5;
489        let mut g = GpuSphGrid::new(n);
490        // Place particles in a line
491        for i in 0..n {
492            g.positions[i * 3] = i as f64 * 0.4;
493        }
494        gpu_density_kernel(&mut g);
495        // Central particles should have higher density
496        assert!(g.densities[2] >= g.densities[0]);
497    }
498
499    #[test]
500    fn test_pressure_increases_with_density() {
501        let mut g = GpuSphGrid::new(2);
502        g.rho0 = 1000.0;
503        g.c0 = 100.0;
504        g.densities = vec![1000.0, 1200.0];
505        gpu_pressure_tait(&mut g);
506        assert!(g.pressures[1] > g.pressures[0]);
507    }
508
509    #[test]
510    fn test_gpu_sph_grid_clone() {
511        let g = GpuSphGrid::new(3);
512        let g2 = g.clone();
513        assert_eq!(g2.particle_count(), 3);
514    }
515
516    #[test]
517    fn test_gpu_sph_grid_debug() {
518        let g = GpuSphGrid::new(1);
519        let s = format!("{g:?}");
520        assert!(s.contains("GpuSphGrid"));
521    }
522}