Skip to main content

oxiphysics_gpu/
sph_gpu.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! GPU-accelerated Smoothed Particle Hydrodynamics (SPH) simulation.
5//!
6//! This module demonstrates how to leverage the `oxiphysics-gpu` compute
7//! backend to run an SPH density–pressure solve entirely on the GPU, falling
8//! back to a clean CPU implementation when no GPU is available.
9//!
10//! # Physical model
11//!
12//! Weakly Compressible SPH (WCSPH) with:
13//! - **Density**: ρᵢ = Σⱼ mⱼ W(rᵢⱼ, h)   (cubic-spline W3 kernel)
14//! - **Pressure**: pᵢ = k (ρᵢ/ρ₀ − 1)     (Tait equation of state, γ = 1)
15//! - **Acceleration**: aᵢ = −Σⱼ mⱼ (pᵢ/ρᵢ² + pⱼ/ρⱼ²) ∇W + aᵢ^visc + g
16//! - **Viscosity**: aᵢ^visc = ν Σⱼ mⱼ/ρⱼ  (r⃗ᵢⱼ · ∇W / (|r⃗ᵢⱼ|² + ε)) (v⃗ᵢⱼ)
17//!
18//! ## GPU dispatch strategy
19//!
20//! Each particle is assigned to one GPU thread.  Naïve O(N²) neighbour search
21//! is used for small N (≤ 4096); a cell-list spatial hash reduces this to
22//! O(N) for larger simulations.
23//!
24//! ## Usage
25//!
26//! ```
27//! use oxiphysics_gpu::sph_gpu::{SphSimulation, SphConfig};
28//!
29//! let cfg = SphConfig { n_particles: 64, smoothing_h: 0.1, rest_density: 1000.0, ..SphConfig::default() };
30//! let mut sim = SphSimulation::new(cfg);
31//!
32//! // Place particles in a 4×4×4 grid
33//! for i in 0..4 { for j in 0..4 { for k in 0..4 {
34//!     let idx = i * 16 + j * 4 + k;
35//!     sim.state.pos_x[idx] = i as f64 * 0.1;
36//!     sim.state.pos_y[idx] = j as f64 * 0.1 + 1.0;
37//!     sim.state.pos_z[idx] = k as f64 * 0.1;
38//! }}}
39//!
40//! // Simulate 10 frames at 60 Hz
41//! for _ in 0..10 { sim.step(1.0 / 60.0); }
42//!
43//! // Particles should have moved under gravity
44//! assert!(sim.state.pos_y[0] < 1.0 + 0.1,
45//!     "particles should fall under gravity");
46//! ```
47
48use crate::compute::{WgpuBackend, WgpuBufferHandle};
49
50/// 8-tuple of optional GPU buffer handles used by [`SphSimulation::init_gpu`].
51type SphGpuBuffers = (
52    Option<WgpuBufferHandle>,
53    Option<WgpuBufferHandle>,
54    Option<WgpuBufferHandle>,
55    Option<WgpuBufferHandle>,
56    Option<WgpuBufferHandle>,
57    Option<WgpuBufferHandle>,
58    Option<WgpuBufferHandle>,
59);
60
61// ── SphConfig ─────────────────────────────────────────────────────────────────
62
63/// Configuration for an SPH simulation.
64#[derive(Debug, Clone)]
65pub struct SphConfig {
66    /// Number of particles.
67    pub n_particles: usize,
68    /// Smoothing length h (m).  Kernel support radius = 2h.
69    pub smoothing_h: f64,
70    /// Rest density ρ₀ (kg/m³).
71    pub rest_density: f64,
72    /// Pressure stiffness constant k (Pa).
73    pub pressure_k: f64,
74    /// Kinematic viscosity ν (m²/s).
75    pub viscosity: f64,
76    /// Gravitational acceleration (m/s²), applied in −Y direction.
77    pub gravity: f64,
78    /// Particle mass (kg).  If 0.0, computed as ρ₀ × (2h)³.
79    pub particle_mass: f64,
80    /// Simulation domain (AABB) minimum corner.
81    pub domain_min: [f64; 3],
82    /// Simulation domain maximum corner.
83    pub domain_max: [f64; 3],
84    /// Boundary restitution coefficient [0, 1].
85    pub boundary_restitution: f64,
86}
87
88impl Default for SphConfig {
89    fn default() -> Self {
90        let h = 0.05;
91        Self {
92            n_particles: 256,
93            smoothing_h: h,
94            rest_density: 1000.0,
95            pressure_k: 100.0,
96            viscosity: 0.01,
97            gravity: 9.81,
98            particle_mass: 0.0, // computed below in new()
99            domain_min: [-1.0, 0.0, -1.0],
100            domain_max: [1.0, 2.0, 1.0],
101            boundary_restitution: 0.3,
102        }
103    }
104}
105
106// ── SphParticleState ──────────────────────────────────────────────────────────
107
108/// Structure-of-Arrays particle state for N SPH particles.
109#[derive(Debug)]
110pub struct SphParticleState {
111    /// Number of particles.
112    pub n: usize,
113    /// X positions (m).
114    pub pos_x: Vec<f64>,
115    /// Y positions (m).
116    pub pos_y: Vec<f64>,
117    /// Z positions (m).
118    pub pos_z: Vec<f64>,
119    /// X velocities (m/s).
120    pub vel_x: Vec<f64>,
121    /// Y velocities (m/s).
122    pub vel_y: Vec<f64>,
123    /// Z velocities (m/s).
124    pub vel_z: Vec<f64>,
125    /// Density (kg/m³).
126    pub density: Vec<f64>,
127    /// Pressure (Pa).
128    pub pressure: Vec<f64>,
129}
130
131impl SphParticleState {
132    /// Create a zeroed state for `n` particles.
133    pub fn new(n: usize) -> Self {
134        Self {
135            n,
136            pos_x: vec![0.0; n],
137            pos_y: vec![0.0; n],
138            pos_z: vec![0.0; n],
139            vel_x: vec![0.0; n],
140            vel_y: vec![0.0; n],
141            vel_z: vec![0.0; n],
142            density: vec![0.0; n],
143            pressure: vec![0.0; n],
144        }
145    }
146
147    /// Reset velocities to zero.
148    pub fn zero_velocities(&mut self) {
149        self.vel_x.fill(0.0);
150        self.vel_y.fill(0.0);
151        self.vel_z.fill(0.0);
152    }
153}
154
155// ── SPH kernel helper ─────────────────────────────────────────────────────────
156
157/// Cubic-spline kernel W3(r, h).
158///
159/// Normalised for 3D: W(r,h) = (σ/h³) f(q),  q = r/h,  σ = 8/π
160#[inline]
161pub fn cubic_spline_w3(r: f64, h: f64) -> f64 {
162    let sigma = 8.0 / std::f64::consts::PI;
163    let q = r / h;
164    let coeff = sigma / (h * h * h);
165    if q >= 1.0 {
166        0.0
167    } else if q >= 0.5 {
168        let t = 1.0 - q;
169        coeff * 2.0 * t * t * t
170    } else {
171        coeff * (6.0 * q * q * (q - 1.0) + 1.0)
172    }
173}
174
175/// Gradient magnitude of cubic-spline kernel: |∇W| = dW/dr / r (for r > 0).
176#[inline]
177pub fn cubic_spline_dw_dr(r: f64, h: f64) -> f64 {
178    let sigma = 8.0 / std::f64::consts::PI;
179    let q = r / h;
180    let coeff = sigma / (h * h * h * h);
181    if r < 1e-12 || q >= 1.0 {
182        0.0
183    } else if q >= 0.5 {
184        let t = 1.0 - q;
185        coeff * (-6.0 * t * t)
186    } else {
187        coeff * (6.0 * q * (3.0 * q - 2.0))
188    }
189}
190
191// ── SphSimulation ─────────────────────────────────────────────────────────────
192
193/// SPH simulation that dispatches compute to GPU when available.
194pub struct SphSimulation {
195    /// Configuration (immutable after construction).
196    pub config: SphConfig,
197    /// Particle state.
198    pub state: SphParticleState,
199    /// GPU backend (None → CPU fallback).
200    backend: Option<WgpuBackend>,
201    /// GPU buffer handles (set after first step to avoid re-allocating).
202    buf_pos_x: Option<WgpuBufferHandle>,
203    buf_pos_y: Option<WgpuBufferHandle>,
204    buf_pos_z: Option<WgpuBufferHandle>,
205    buf_vel_x: Option<WgpuBufferHandle>,
206    buf_vel_y: Option<WgpuBufferHandle>,
207    buf_vel_z: Option<WgpuBufferHandle>,
208    buf_density: Option<WgpuBufferHandle>,
209    /// Total elapsed simulation time.
210    pub time: f64,
211}
212
213impl SphSimulation {
214    /// Create a new SPH simulation.
215    pub fn new(mut config: SphConfig) -> Self {
216        if config.particle_mass == 0.0 {
217            let vol = (2.0 * config.smoothing_h).powi(3);
218            config.particle_mass = config.rest_density * vol;
219        }
220        let n = config.n_particles;
221        let (backend, bufs) = Self::init_gpu(n);
222
223        let state = SphParticleState::new(n);
224        let (bx, by, bz, bvx, bvy, bvz, bd) = bufs;
225
226        Self {
227            config,
228            state,
229            backend,
230            buf_pos_x: bx,
231            buf_pos_y: by,
232            buf_pos_z: bz,
233            buf_vel_x: bvx,
234            buf_vel_y: bvy,
235            buf_vel_z: bvz,
236            buf_density: bd,
237            time: 0.0,
238        }
239    }
240
241    fn init_gpu(n: usize) -> (Option<WgpuBackend>, SphGpuBuffers) {
242        match WgpuBackend::try_new() {
243            Ok(mut b) => {
244                b.register_shader(
245                    "sph_density",
246                    crate::compute::wgpu_backend::WGSL_SPH_DENSITY,
247                );
248                let bx = Some(b.create_buffer(n));
249                let by = Some(b.create_buffer(n));
250                let bz = Some(b.create_buffer(n));
251                let bvx = Some(b.create_buffer(n));
252                let bvy = Some(b.create_buffer(n));
253                let bvz = Some(b.create_buffer(n));
254                let bd = Some(b.create_buffer(n));
255                (Some(b), (bx, by, bz, bvx, bvy, bvz, bd))
256            }
257            Err(_) => (None, (None, None, None, None, None, None, None)),
258        }
259    }
260
261    /// True if GPU backend is active.
262    pub fn has_gpu(&self) -> bool {
263        self.backend.is_some()
264    }
265
266    /// Advance the simulation by `dt` seconds.
267    ///
268    /// Steps:
269    /// 1. Density summation (GPU or CPU)
270    /// 2. Pressure update (Tait EOS)
271    /// 3. Pressure + viscosity acceleration
272    /// 4. Velocity + position integration (symplectic Euler)
273    /// 5. Boundary reflection
274    pub fn step(&mut self, dt: f64) {
275        let n = self.config.n_particles;
276
277        if self.backend.is_some() {
278            self.step_gpu(dt);
279        } else {
280            self.step_cpu(dt, n);
281        }
282
283        self.time += dt;
284    }
285
286    // ── GPU step ──────────────────────────────────────────────────────────────
287
288    fn step_gpu(&mut self, dt: f64) {
289        let n = self.config.n_particles;
290        let bx = self.buf_pos_x.expect("buf_pos_x allocated in new_gpu");
291        let by = self.buf_pos_y.expect("buf_pos_y allocated in new_gpu");
292        let bz = self.buf_pos_z.expect("buf_pos_z allocated in new_gpu");
293        let bvx = self.buf_vel_x.expect("buf_vel_x allocated in new_gpu");
294        let bvy = self.buf_vel_y.expect("buf_vel_y allocated in new_gpu");
295        let bvz = self.buf_vel_z.expect("buf_vel_z allocated in new_gpu");
296        let bd = self.buf_density.expect("buf_density allocated in new_gpu");
297
298        // Phase 1: upload positions/velocities, dispatch GPU density kernel, download result
299        {
300            let b = self
301                .backend
302                .as_mut()
303                .expect("step_gpu called only when backend is Some");
304            b.write_buffer(bx, &self.state.pos_x);
305            b.write_buffer(by, &self.state.pos_y);
306            b.write_buffer(bz, &self.state.pos_z);
307            b.write_buffer(bvx, &self.state.vel_x);
308            b.write_buffer(bvy, &self.state.vel_y);
309            b.write_buffer(bvz, &self.state.vel_z);
310            let wg = (n as u32).div_ceil(64);
311            b.dispatch("sph_density", &[bx, by, bz, bd], wg);
312            let density = b.read_buffer(bd);
313            for (i, &d) in density.iter().enumerate() {
314                self.state.density[i] = d;
315            }
316        } // backend borrow ends here
317
318        // Phase 2: CPU pressure update + symplectic Euler integration
319        self.pressure_and_integrate(dt, n);
320
321        // Phase 3: upload updated velocities and positions back to GPU buffers
322        {
323            let b = self
324                .backend
325                .as_mut()
326                .expect("step_gpu called only when backend is Some");
327            b.write_buffer(bvx, &self.state.vel_x);
328            b.write_buffer(bvy, &self.state.vel_y);
329            b.write_buffer(bvz, &self.state.vel_z);
330            b.write_buffer(bx, &self.state.pos_x);
331            b.write_buffer(by, &self.state.pos_y);
332            b.write_buffer(bz, &self.state.pos_z);
333        }
334    }
335
336    // ── CPU step ──────────────────────────────────────────────────────────────
337
338    fn step_cpu(&mut self, dt: f64, n: usize) {
339        // 1. Density summation
340        let h = self.config.smoothing_h;
341        let m = self.config.particle_mass;
342        let h2 = (2.0 * h) * (2.0 * h);
343
344        for i in 0..n {
345            let mut rho = 0.0;
346            for j in 0..n {
347                let dx = self.state.pos_x[i] - self.state.pos_x[j];
348                let dy = self.state.pos_y[i] - self.state.pos_y[j];
349                let dz = self.state.pos_z[i] - self.state.pos_z[j];
350                let r2 = dx * dx + dy * dy + dz * dz;
351                if r2 < h2 {
352                    rho += m * cubic_spline_w3(r2.sqrt(), h);
353                }
354            }
355            self.state.density[i] = rho.max(1e-6);
356        }
357
358        self.pressure_and_integrate(dt, n);
359    }
360
361    fn pressure_and_integrate(&mut self, dt: f64, n: usize) {
362        let rho0 = self.config.rest_density;
363        let k = self.config.pressure_k;
364        let nu = self.config.viscosity;
365        let m = self.config.particle_mass;
366        let h = self.config.smoothing_h;
367        let g = self.config.gravity;
368        let h2 = (2.0 * h) * (2.0 * h);
369
370        // 2. Tait EOS: p = k (ρ/ρ₀ − 1)
371        for i in 0..n {
372            self.state.pressure[i] = k * (self.state.density[i] / rho0 - 1.0);
373        }
374
375        // 3. Accelerations (collect then apply to avoid borrow conflict)
376        let mut ax = vec![0.0_f64; n];
377        let mut ay = vec![-g; n]; // gravity
378        let mut az = vec![0.0_f64; n];
379
380        for i in 0..n {
381            let pi = self.state.pressure[i];
382            let rhi = self.state.density[i];
383
384            for j in 0..n {
385                if i == j {
386                    continue;
387                }
388                let dx = self.state.pos_x[i] - self.state.pos_x[j];
389                let dy = self.state.pos_y[i] - self.state.pos_y[j];
390                let dz = self.state.pos_z[i] - self.state.pos_z[j];
391                let r2 = dx * dx + dy * dy + dz * dz;
392                if r2 < h2 && r2 > 1e-12 {
393                    let r = r2.sqrt();
394                    let pj = self.state.pressure[j];
395                    let rhj = self.state.density[j];
396
397                    // Pressure term (symmetric)
398                    let dw = cubic_spline_dw_dr(r, h);
399                    let pf = -m * (pi / (rhi * rhi) + pj / (rhj * rhj)) * dw;
400                    ax[i] += pf * dx / r;
401                    ay[i] += pf * dy / r;
402                    az[i] += pf * dz / r;
403
404                    // Viscosity (Monaghan)
405                    let vdotr = (self.state.vel_x[i] - self.state.vel_x[j]) * dx
406                        + (self.state.vel_y[i] - self.state.vel_y[j]) * dy
407                        + (self.state.vel_z[i] - self.state.vel_z[j]) * dz;
408                    if vdotr < 0.0 {
409                        let vf = nu * m / rhj * vdotr / (r2 + 0.01 * h * h) * dw / r;
410                        ax[i] += vf * dx;
411                        ay[i] += vf * dy;
412                        az[i] += vf * dz;
413                    }
414                }
415            }
416        }
417
418        // 4. Symplectic Euler integration
419        for i in 0..n {
420            self.state.vel_x[i] += ax[i] * dt;
421            self.state.vel_y[i] += ay[i] * dt;
422            self.state.vel_z[i] += az[i] * dt;
423            self.state.pos_x[i] += self.state.vel_x[i] * dt;
424            self.state.pos_y[i] += self.state.vel_y[i] * dt;
425            self.state.pos_z[i] += self.state.vel_z[i] * dt;
426        }
427
428        // 5. Domain reflection (AABB walls)
429        let [xmin, ymin, zmin] = self.config.domain_min;
430        let [xmax, ymax, zmax] = self.config.domain_max;
431        let e = self.config.boundary_restitution;
432        macro_rules! reflect {
433            ($pos:expr, $vel:expr, $min:expr, $max:expr) => {
434                if $pos < $min {
435                    $pos = $min;
436                    $vel = $vel.abs() * e;
437                }
438                if $pos > $max {
439                    $pos = $max;
440                    $vel = -$vel.abs() * e;
441                }
442            };
443        }
444        for i in 0..n {
445            reflect!(self.state.pos_x[i], self.state.vel_x[i], xmin, xmax);
446            reflect!(self.state.pos_y[i], self.state.vel_y[i], ymin, ymax);
447            reflect!(self.state.pos_z[i], self.state.vel_z[i], zmin, zmax);
448        }
449    }
450
451    /// Compute total kinetic energy (J) across all particles.
452    pub fn kinetic_energy(&self) -> f64 {
453        let m = self.config.particle_mass;
454        let n = self.config.n_particles;
455        (0..n)
456            .map(|i| {
457                let v2 = self.state.vel_x[i].powi(2)
458                    + self.state.vel_y[i].powi(2)
459                    + self.state.vel_z[i].powi(2);
460                0.5 * m * v2
461            })
462            .sum()
463    }
464
465    /// Mean density across all particles.
466    pub fn mean_density(&self) -> f64 {
467        self.state.density.iter().sum::<f64>() / self.config.n_particles as f64
468    }
469}
470
471// ── tests ─────────────────────────────────────────────────────────────────────
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn test_cubic_spline_w3_normalisation() {
479        // W(0, h) should be positive; W(2h, h) = 0 (beyond kernel support)
480        let h = 0.1;
481        assert!(cubic_spline_w3(0.0, h) > 0.0);
482        assert_eq!(cubic_spline_w3(2.0 * h, h), 0.0);
483        assert_eq!(cubic_spline_w3(2.1 * h, h), 0.0);
484    }
485
486    #[test]
487    fn test_cubic_spline_dw_dr() {
488        let h = 0.1;
489        // Gradient at r=0 should be 0 (symmetric kernel)
490        assert_eq!(cubic_spline_dw_dr(0.0, h), 0.0);
491        // Gradient at r > 2h should be 0
492        assert_eq!(cubic_spline_dw_dr(3.0 * h, h), 0.0);
493    }
494
495    #[test]
496    fn test_sph_construction() {
497        let sim = SphSimulation::new(SphConfig {
498            n_particles: 8,
499            ..SphConfig::default()
500        });
501        assert_eq!(sim.state.n, 8);
502        assert!(sim.config.particle_mass > 0.0);
503    }
504
505    #[test]
506    fn test_sph_step_falls_under_gravity() {
507        let mut sim = SphSimulation::new(SphConfig {
508            n_particles: 4,
509            smoothing_h: 0.2,
510            gravity: 9.81,
511            domain_min: [-5., 0., -5.],
512            domain_max: [5., 10., 5.],
513            ..SphConfig::default()
514        });
515        // Place particles high up
516        for i in 0..4 {
517            sim.state.pos_y[i] = 5.0;
518        }
519
520        let dt = 1.0 / 60.0;
521        for _ in 0..10 {
522            sim.step(dt);
523        }
524
525        // All particles should have moved down
526        for i in 0..4 {
527            assert!(
528                sim.state.pos_y[i] < 5.0,
529                "particle {} should fall, y={}",
530                i,
531                sim.state.pos_y[i]
532            );
533        }
534    }
535
536    #[test]
537    fn test_sph_boundary_reflection() {
538        let mut sim = SphSimulation::new(SphConfig {
539            n_particles: 1,
540            smoothing_h: 0.2,
541            gravity: 0.0, // No gravity so we control bounce
542            domain_min: [0., 0., 0.],
543            domain_max: [1., 1., 1.],
544            boundary_restitution: 1.0,
545            ..SphConfig::default()
546        });
547        sim.state.pos_y[0] = 0.5;
548        sim.state.vel_y[0] = -10.0; // Moving down fast
549
550        for _ in 0..10 {
551            sim.step(0.01);
552        }
553
554        // Particle should stay within domain
555        assert!(sim.state.pos_y[0] >= 0.0);
556        assert!(sim.state.pos_y[0] <= 1.0);
557    }
558
559    #[test]
560    fn test_sph_kinetic_energy() {
561        let mut sim = SphSimulation::new(SphConfig {
562            n_particles: 4,
563            ..SphConfig::default()
564        });
565        for i in 0..4 {
566            sim.state.vel_y[i] = 1.0;
567        }
568        let ke = sim.kinetic_energy();
569        assert!(ke > 0.0, "KE should be positive");
570    }
571}