Skip to main content

oxiphysics_gpu/
gpu_sph_solver.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! GPU-side SPH solver — CPU mock backend.
5//!
6//! Implements a complete Smoothed Particle Hydrodynamics (SPH) solver pipeline
7//! using plain Rust loops as a CPU fallback. The API mirrors what would run on
8//! a GPU kernel dispatch, making it straightforward to swap in a real GPU
9//! backend without changing call-sites.
10
11use std::f32::consts::PI;
12
13// ── Data structures ──────────────────────────────────────────────────────────
14
15/// A single SPH particle stored on the GPU buffer.
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub struct GpuSphParticle {
18    /// Particle position \[x, y, z\] in metres.
19    pub pos: [f32; 3],
20    /// Particle velocity \[vx, vy, vz\] in m/s.
21    pub vel: [f32; 3],
22    /// Particle density in kg/m³.
23    pub density: f32,
24    /// Particle pressure in Pa.
25    pub pressure: f32,
26    /// Particle mass in kg.
27    pub mass: f32,
28}
29
30/// Simulation parameters for the GPU SPH solver.
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct GpuSphParams {
33    /// SPH smoothing kernel radius h in metres.
34    pub kernel_radius: f32,
35    /// Tait EOS stiffness constant k.
36    pub eos_k: f32,
37    /// Tait EOS exponent gamma.
38    pub eos_gamma: f32,
39    /// Dynamic viscosity coefficient μ.
40    pub viscosity: f32,
41    /// Number of particles.
42    pub n_particles: usize,
43}
44
45/// GPU-side buffer holding all per-particle arrays.
46#[derive(Debug, Clone)]
47pub struct GpuSphBuffer {
48    /// Per-particle positions \[x, y, z\].
49    pub positions: Vec<[f32; 3]>,
50    /// Per-particle velocities \[vx, vy, vz\].
51    pub velocities: Vec<[f32; 3]>,
52    /// Per-particle densities (kg/m³).
53    pub densities: Vec<f32>,
54    /// Per-particle pressures (Pa).
55    pub pressures: Vec<f32>,
56}
57
58impl GpuSphBuffer {
59    /// Allocate a new buffer for `n` particles, all initialised to zero.
60    pub fn new(n: usize) -> Self {
61        Self {
62            positions: vec![[0.0; 3]; n],
63            velocities: vec![[0.0; 3]; n],
64            densities: vec![0.0; n],
65            pressures: vec![0.0; n],
66        }
67    }
68
69    /// Number of particles in this buffer.
70    pub fn len(&self) -> usize {
71        self.positions.len()
72    }
73
74    /// Returns `true` if the buffer is empty.
75    pub fn is_empty(&self) -> bool {
76        self.positions.is_empty()
77    }
78}
79
80// ── Kernel functions ─────────────────────────────────────────────────────────
81
82/// Wendland C2 SPH kernel W(r, h).
83///
84/// Returns the kernel value at distance `r` with smoothing length `h`.
85/// The kernel is zero for `r >= h`.
86pub fn sph_kernel_gpu(r: f32, h: f32) -> f32 {
87    if h <= 0.0 || r >= h {
88        return 0.0;
89    }
90    let q = r / h;
91    // 3-D Wendland C2: alpha_D * (1 - q/2)^4 * (2q + 1)
92    // alpha_D = 21 / (2 * pi * h^3)
93    let alpha = 21.0 / (2.0 * PI * h * h * h);
94    let t = 1.0 - 0.5 * q;
95    let t2 = t * t;
96    let t4 = t2 * t2;
97    alpha * t4 * (2.0 * q + 1.0)
98}
99
100/// Wendland C2 SPH kernel gradient magnitude dW/dr.
101///
102/// Returns the magnitude of the kernel gradient at distance `r`.
103pub fn sph_kernel_grad_gpu(r: f32, h: f32) -> f32 {
104    if h <= 0.0 || r >= h || r < 1e-12 {
105        return 0.0;
106    }
107    let q = r / h;
108    let alpha = 21.0 / (2.0 * PI * h * h * h);
109    let t = 1.0 - 0.5 * q;
110    let t2 = t * t;
111    let t3 = t2 * t;
112    // d/dr [ t^4 (2q+1) ] = [ -4*(1/2h)*t^3*(2q+1) + t^4*(2/h) ]
113    //                      = t^3 / h * [ -2*(2q+1) + 2*t ]
114    //                      = t^3 / h * [ -4q - 2 + 2 - q ] = t^3/h * (-5q)
115    // actually: d/dq [ t^4(2q+1) ] * (1/h)
116    // = [ 4*t^3*(-1/2)*(2q+1) + t^4*2 ] / h
117    // = t^3 [ -2(2q+1) + 2t ] / h
118    // = t^3 [ -4q-2 + 2 - q ] / h = t^3(-5q) / h
119    alpha * t3 * (-5.0 * q) / h
120}
121
122// ── Density computation ───────────────────────────────────────────────────────
123
124/// Compute per-particle densities via O(N²) neighbour sum.
125///
126/// `ρ_i = Σ_j m_j * W(|r_i - r_j|, h)`
127pub fn compute_density_gpu(buf: &GpuSphBuffer, params: &GpuSphParams) -> Vec<f32> {
128    let n = buf.positions.len();
129    let h = params.kernel_radius;
130    // Assume unit mass per particle (mass can be encoded separately)
131    let mass = 1.0_f32;
132    let mut densities = vec![0.0_f32; n];
133    for (rho, &pi) in densities.iter_mut().zip(buf.positions.iter()) {
134        let mut acc = 0.0_f32;
135        for j in 0..n {
136            let pj = buf.positions[j];
137            let dx = pi[0] - pj[0];
138            let dy = pi[1] - pj[1];
139            let dz = pi[2] - pj[2];
140            let r = (dx * dx + dy * dy + dz * dz).sqrt();
141            acc += mass * sph_kernel_gpu(r, h);
142        }
143        *rho = acc;
144    }
145    densities
146}
147
148// ── Pressure computation ──────────────────────────────────────────────────────
149
150/// Compute per-particle pressures using the Tait equation of state.
151///
152/// `P = k * (ρ/ρ₀)^γ - k` where `ρ₀ = 1000` kg/m³.
153pub fn compute_pressure_gpu(densities: &[f32], params: &GpuSphParams) -> Vec<f32> {
154    let rho0 = 1000.0_f32;
155    densities
156        .iter()
157        .map(|&rho| {
158            let ratio = rho / rho0;
159            params.eos_k * (ratio.powf(params.eos_gamma) - 1.0)
160        })
161        .collect()
162}
163
164// ── Force computation ─────────────────────────────────────────────────────────
165
166/// Compute pressure forces on all particles.
167///
168/// Uses the symmetric SPH momentum equation:
169/// `f_i = -Σ_j m_j (P_i/ρ_i² + P_j/ρ_j²) ∇W`
170pub fn compute_pressure_force_gpu(
171    buf: &GpuSphBuffer,
172    pressures: &[f32],
173    params: &GpuSphParams,
174) -> Vec<[f32; 3]> {
175    let n = buf.positions.len();
176    let h = params.kernel_radius;
177    let mass = 1.0_f32;
178    let mut forces = vec![[0.0_f32; 3]; n];
179
180    for (i, (force, &pi)) in forces.iter_mut().zip(buf.positions.iter()).enumerate() {
181        let rho_i = buf.densities[i].max(1e-6);
182        let p_i = pressures[i];
183        let mut fx = 0.0_f32;
184        let mut fy = 0.0_f32;
185        let mut fz = 0.0_f32;
186
187        for (j, &pj) in buf.positions.iter().enumerate() {
188            if i == j {
189                continue;
190            }
191            let dx = pi[0] - pj[0];
192            let dy = pi[1] - pj[1];
193            let dz = pi[2] - pj[2];
194            let r = (dx * dx + dy * dy + dz * dz).sqrt();
195            if r < 1e-12 {
196                continue;
197            }
198            let rho_j = buf.densities[j].max(1e-6);
199            let p_j = pressures[j];
200            let grad_mag = sph_kernel_grad_gpu(r, h);
201            let coeff = -mass * (p_i / (rho_i * rho_i) + p_j / (rho_j * rho_j)) * grad_mag / r;
202            fx += coeff * dx;
203            fy += coeff * dy;
204            fz += coeff * dz;
205        }
206        *force = [fx, fy, fz];
207    }
208    forces
209}
210
211/// Compute viscosity forces on all particles.
212///
213/// Uses the standard Monaghan viscosity formulation.
214pub fn compute_viscosity_force_gpu(buf: &GpuSphBuffer, params: &GpuSphParams) -> Vec<[f32; 3]> {
215    let n = buf.positions.len();
216    let h = params.kernel_radius;
217    let mu = params.viscosity;
218    let mass = 1.0_f32;
219    let mut forces = vec![[0.0_f32; 3]; n];
220
221    for (i, (force, (&pi, &vi))) in forces
222        .iter_mut()
223        .zip(buf.positions.iter().zip(buf.velocities.iter()))
224        .enumerate()
225    {
226        let rho_i = buf.densities[i].max(1e-6);
227        let mut fx = 0.0_f32;
228        let mut fy = 0.0_f32;
229        let mut fz = 0.0_f32;
230
231        for (j, (&pj, &vj)) in buf.positions.iter().zip(buf.velocities.iter()).enumerate() {
232            if i == j {
233                continue;
234            }
235            let dx = pi[0] - pj[0];
236            let dy = pi[1] - pj[1];
237            let dz = pi[2] - pj[2];
238            let r = (dx * dx + dy * dy + dz * dz).sqrt();
239            if r < 1e-12 {
240                continue;
241            }
242            let rho_j = buf.densities[j].max(1e-6);
243            let lap = sph_kernel_grad_gpu(r, h); // use gradient magnitude as Laplacian approx
244            let dvx = vj[0] - vi[0];
245            let dvy = vj[1] - vi[1];
246            let dvz = vj[2] - vi[2];
247            let coeff = mu * mass / rho_j * lap / r;
248            fx += coeff * dvx;
249            fy += coeff * dvy;
250            fz += coeff * dvz;
251        }
252        *force = [
253            force[0] + fx / rho_i,
254            force[1] + fy / rho_i,
255            force[2] + fz / rho_i,
256        ];
257    }
258    forces
259}
260
261// ── Integration ───────────────────────────────────────────────────────────────
262
263/// Semi-implicit Euler integration step.
264///
265/// Updates velocities and positions in-place:
266/// `v += f * dt`, `x += v * dt`.
267pub fn integrate_sph_gpu(buf: &mut GpuSphBuffer, forces: &[[f32; 3]], dt: f32) {
268    for (vel, (pos, &f)) in buf
269        .velocities
270        .iter_mut()
271        .zip(buf.positions.iter_mut().zip(forces.iter()))
272    {
273        vel[0] += f[0] * dt;
274        vel[1] += f[1] * dt;
275        vel[2] += f[2] * dt;
276        pos[0] += vel[0] * dt;
277        pos[1] += vel[1] * dt;
278        pos[2] += vel[2] * dt;
279    }
280}
281
282// ── Full step ─────────────────────────────────────────────────────────────────
283
284/// Perform one complete SPH time-step.
285///
286/// Executes density → pressure → force → integrate in sequence.
287pub fn gpu_sph_step(buf: &mut GpuSphBuffer, params: &GpuSphParams, dt: f32) {
288    let densities = compute_density_gpu(buf, params);
289    let pressures = compute_pressure_gpu(&densities, params);
290    buf.densities = densities;
291    buf.pressures = pressures.clone();
292    let pf = compute_pressure_force_gpu(buf, &pressures, params);
293    let vf = compute_viscosity_force_gpu(buf, params);
294    let n = buf.positions.len();
295    let mut total_forces = vec![[0.0_f32; 3]; n];
296    for i in 0..n {
297        total_forces[i][0] = pf[i][0] + vf[i][0];
298        total_forces[i][1] = pf[i][1] + vf[i][1];
299        total_forces[i][2] = pf[i][2] + vf[i][2];
300    }
301    integrate_sph_gpu(buf, &total_forces, dt);
302}
303
304// ── Tests ─────────────────────────────────────────────────────────────────────
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    fn make_buf(n: usize) -> GpuSphBuffer {
311        let mut buf = GpuSphBuffer::new(n);
312        for i in 0..n {
313            buf.positions[i] = [i as f32 * 0.1, 0.0, 0.0];
314            buf.velocities[i] = [0.0; 3];
315            buf.densities[i] = 1000.0;
316            buf.pressures[i] = 0.0;
317        }
318        buf
319    }
320
321    fn default_params(n: usize) -> GpuSphParams {
322        GpuSphParams {
323            kernel_radius: 0.5,
324            eos_k: 1.0,
325            eos_gamma: 7.0,
326            viscosity: 0.01,
327            n_particles: n,
328        }
329    }
330
331    #[test]
332    fn test_gpu_sph_particle_fields() {
333        let p = GpuSphParticle {
334            pos: [1.0, 2.0, 3.0],
335            vel: [0.1, 0.2, 0.3],
336            density: 1000.0,
337            pressure: 101325.0,
338            mass: 0.001,
339        };
340        assert_eq!(p.pos[0], 1.0);
341        assert_eq!(p.mass, 0.001);
342    }
343
344    #[test]
345    fn test_gpu_sph_params_fields() {
346        let params = default_params(10);
347        assert_eq!(params.n_particles, 10);
348        assert!(params.kernel_radius > 0.0);
349    }
350
351    #[test]
352    fn test_gpu_sph_buffer_new() {
353        let buf = GpuSphBuffer::new(5);
354        assert_eq!(buf.len(), 5);
355        assert!(!buf.is_empty());
356    }
357
358    #[test]
359    fn test_gpu_sph_buffer_empty() {
360        let buf = GpuSphBuffer::new(0);
361        assert!(buf.is_empty());
362    }
363
364    #[test]
365    fn test_sph_kernel_gpu_zero_at_boundary() {
366        let w = sph_kernel_gpu(0.5, 0.5);
367        assert_eq!(w, 0.0);
368    }
369
370    #[test]
371    fn test_sph_kernel_gpu_zero_beyond() {
372        let w = sph_kernel_gpu(1.0, 0.5);
373        assert_eq!(w, 0.0);
374    }
375
376    #[test]
377    fn test_sph_kernel_gpu_positive_inside() {
378        let w = sph_kernel_gpu(0.1, 0.5);
379        assert!(w > 0.0);
380    }
381
382    #[test]
383    fn test_sph_kernel_gpu_peak_at_zero() {
384        let w0 = sph_kernel_gpu(0.0, 0.5);
385        let w1 = sph_kernel_gpu(0.2, 0.5);
386        assert!(w0 > w1);
387    }
388
389    #[test]
390    fn test_sph_kernel_gpu_zero_h() {
391        assert_eq!(sph_kernel_gpu(0.1, 0.0), 0.0);
392    }
393
394    #[test]
395    fn test_sph_kernel_grad_gpu_zero_h() {
396        assert_eq!(sph_kernel_grad_gpu(0.1, 0.0), 0.0);
397    }
398
399    #[test]
400    fn test_sph_kernel_grad_gpu_zero_r() {
401        assert_eq!(sph_kernel_grad_gpu(0.0, 0.5), 0.0);
402    }
403
404    #[test]
405    fn test_sph_kernel_grad_gpu_negative_inside() {
406        // Gradient magnitude is negative (kernel decreases with r)
407        let g = sph_kernel_grad_gpu(0.2, 0.5);
408        assert!(g < 0.0);
409    }
410
411    #[test]
412    fn test_sph_kernel_grad_gpu_zero_at_boundary() {
413        let g = sph_kernel_grad_gpu(0.5, 0.5);
414        assert_eq!(g, 0.0);
415    }
416
417    #[test]
418    fn test_compute_density_gpu_nonzero() {
419        let buf = make_buf(4);
420        let params = default_params(4);
421        let densities = compute_density_gpu(&buf, &params);
422        assert_eq!(densities.len(), 4);
423        // Particles within kernel_radius should contribute
424        assert!(densities.iter().any(|&d| d > 0.0));
425    }
426
427    #[test]
428    fn test_compute_density_gpu_length() {
429        let buf = make_buf(6);
430        let params = default_params(6);
431        let d = compute_density_gpu(&buf, &params);
432        assert_eq!(d.len(), 6);
433    }
434
435    #[test]
436    fn test_compute_density_gpu_empty() {
437        let buf = GpuSphBuffer::new(0);
438        let params = default_params(0);
439        let d = compute_density_gpu(&buf, &params);
440        assert!(d.is_empty());
441    }
442
443    #[test]
444    fn test_compute_pressure_gpu_positive() {
445        let params = default_params(3);
446        let densities = vec![1100.0_f32, 1000.0_f32, 900.0_f32];
447        let pressures = compute_pressure_gpu(&densities, &params);
448        assert_eq!(pressures.len(), 3);
449        // Higher density => higher pressure
450        assert!(pressures[0] > pressures[1]);
451    }
452
453    #[test]
454    fn test_compute_pressure_gpu_rest_density() {
455        let params = default_params(1);
456        let d = vec![1000.0_f32]; // exactly rest density
457        let p = compute_pressure_gpu(&d, &params);
458        // (1.0)^gamma - 1 = 0 => P = 0
459        assert!(p[0].abs() < 1e-3);
460    }
461
462    #[test]
463    fn test_compute_pressure_force_gpu_length() {
464        let buf = make_buf(4);
465        let params = default_params(4);
466        let pressures = vec![100.0_f32; 4];
467        let forces = compute_pressure_force_gpu(&buf, &pressures, &params);
468        assert_eq!(forces.len(), 4);
469    }
470
471    #[test]
472    fn test_compute_viscosity_force_gpu_length() {
473        let buf = make_buf(4);
474        let params = default_params(4);
475        let forces = compute_viscosity_force_gpu(&buf, &params);
476        assert_eq!(forces.len(), 4);
477    }
478
479    #[test]
480    fn test_integrate_sph_gpu_position_update() {
481        let mut buf = GpuSphBuffer::new(2);
482        buf.velocities[0] = [1.0, 0.0, 0.0];
483        buf.velocities[1] = [0.0, 1.0, 0.0];
484        let forces = vec![[0.0_f32; 3]; 2];
485        integrate_sph_gpu(&mut buf, &forces, 0.1);
486        assert!((buf.positions[0][0] - 0.1).abs() < 1e-5);
487        assert!((buf.positions[1][1] - 0.1).abs() < 1e-5);
488    }
489
490    #[test]
491    fn test_integrate_sph_gpu_velocity_update() {
492        let mut buf = GpuSphBuffer::new(1);
493        let forces = vec![[2.0_f32, 0.0, 0.0]];
494        integrate_sph_gpu(&mut buf, &forces, 0.5);
495        assert!((buf.velocities[0][0] - 1.0).abs() < 1e-5);
496    }
497
498    #[test]
499    fn test_gpu_sph_step_runs() {
500        let mut buf = make_buf(5);
501        let params = default_params(5);
502        gpu_sph_step(&mut buf, &params, 0.001);
503        // After one step densities should be set
504        assert!(buf.densities.iter().any(|&d| d >= 0.0));
505    }
506
507    #[test]
508    fn test_gpu_sph_step_no_nan() {
509        let mut buf = make_buf(5);
510        let params = default_params(5);
511        gpu_sph_step(&mut buf, &params, 0.001);
512        for i in 0..5 {
513            assert!(!buf.positions[i][0].is_nan());
514            assert!(!buf.densities[i].is_nan());
515        }
516    }
517
518    #[test]
519    fn test_gpu_sph_step_pressure_set() {
520        let mut buf = make_buf(3);
521        let params = default_params(3);
522        gpu_sph_step(&mut buf, &params, 0.001);
523        assert_eq!(buf.pressures.len(), 3);
524    }
525
526    #[test]
527    fn test_sph_kernel_symmetry() {
528        let h = 0.5;
529        let w1 = sph_kernel_gpu(0.1, h);
530        let w2 = sph_kernel_gpu(0.1, h);
531        assert!((w1 - w2).abs() < 1e-8);
532    }
533
534    #[test]
535    fn test_sph_kernel_decreasing() {
536        let h = 1.0;
537        let mut prev = sph_kernel_gpu(0.0, h);
538        for i in 1..10 {
539            let r = i as f32 * 0.09;
540            let w = sph_kernel_gpu(r, h);
541            assert!(w <= prev + 1e-6);
542            prev = w;
543        }
544    }
545
546    #[test]
547    fn test_compute_density_uniform_grid() {
548        // All particles at same location -> all have same density
549        let n = 4;
550        let buf = GpuSphBuffer::new(n);
551        // All at origin
552        let params = default_params(n);
553        let d = compute_density_gpu(&buf, &params);
554        // All densities should be equal
555        for i in 1..n {
556            assert!((d[i] - d[0]).abs() < 1e-5);
557        }
558        let _ = buf.positions[0]; // suppress warning
559    }
560
561    #[test]
562    fn test_pressure_force_zero_pressure() {
563        let buf = make_buf(3);
564        let params = default_params(3);
565        let pressures = vec![0.0_f32; 3];
566        let forces = compute_pressure_force_gpu(&buf, &pressures, &params);
567        for f in &forces {
568            assert!(f[0].abs() < 1e-6 && f[1].abs() < 1e-6 && f[2].abs() < 1e-6);
569        }
570    }
571
572    #[test]
573    fn test_viscosity_force_same_velocity() {
574        // Particles with same velocity should have zero viscosity force
575        let mut buf = make_buf(3);
576        for i in 0..3 {
577            buf.velocities[i] = [1.0, 0.5, 0.0];
578        }
579        let params = default_params(3);
580        let forces = compute_viscosity_force_gpu(&buf, &params);
581        for f in &forces {
582            assert!(f[0].abs() < 1e-4 && f[1].abs() < 1e-4 && f[2].abs() < 1e-4);
583        }
584    }
585
586    #[test]
587    fn test_buf_clone() {
588        let buf = make_buf(3);
589        let buf2 = buf.clone();
590        assert_eq!(buf2.len(), 3);
591    }
592
593    #[test]
594    fn test_params_copy() {
595        let p = default_params(5);
596        let p2 = p;
597        assert_eq!(p2.n_particles, 5);
598    }
599
600    #[test]
601    fn test_integrate_multiple_steps() {
602        let mut buf = GpuSphBuffer::new(1);
603        buf.velocities[0] = [1.0, 0.0, 0.0];
604        let forces = vec![[0.0_f32; 3]];
605        for _ in 0..10 {
606            integrate_sph_gpu(&mut buf, &forces, 0.1);
607        }
608        assert!((buf.positions[0][0] - 1.0).abs() < 1e-4);
609    }
610}