Skip to main content

oxiphysics_gpu/
gpu_sph_density.rs

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