Skip to main content

oxiphysics_gpu/
gpu_rigid.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! GPU-accelerated rigid body batch simulation (CPU mock).
5//!
6//! Provides structs and functions for simulating many rigid bodies in batch,
7//! broadphase collision detection (SAP), and constraint solving via sequential
8//! impulse. All computation is done on the CPU as a reference implementation.
9
10// ── Quaternion helpers ────────────────────────────────────────────────────────
11
12/// Rotate vector `v` by unit quaternion `q` (format: \[x, y, z, w\]).
13///
14/// Uses the sandwich product `q * v * q⁻¹` via the Rodrigues formula.
15pub fn quat_rotate(q: [f32; 4], v: [f32; 3]) -> [f32; 3] {
16    let (qx, qy, qz, qw) = (q[0], q[1], q[2], q[3]);
17    // t = 2 * cross(q.xyz, v)
18    let tx = 2.0 * (qy * v[2] - qz * v[1]);
19    let ty = 2.0 * (qz * v[0] - qx * v[2]);
20    let tz = 2.0 * (qx * v[1] - qy * v[0]);
21    // result = v + qw * t + cross(q.xyz, t)
22    [
23        v[0] + qw * tx + (qy * tz - qz * ty),
24        v[1] + qw * ty + (qz * tx - qx * tz),
25        v[2] + qw * tz + (qx * ty - qy * tx),
26    ]
27}
28
29/// Multiply two unit quaternions `a` and `b` (Hamilton product).
30///
31/// Format for both operands and the result: \[x, y, z, w\].
32pub fn quat_mul(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
33    let (ax, ay, az, aw) = (a[0], a[1], a[2], a[3]);
34    let (bx, by, bz, bw) = (b[0], b[1], b[2], b[3]);
35    [
36        aw * bx + ax * bw + ay * bz - az * by,
37        aw * by - ax * bz + ay * bw + az * bx,
38        aw * bz + ax * by - ay * bx + az * bw,
39        aw * bw - ax * bx - ay * by - az * bz,
40    ]
41}
42
43/// Normalise a quaternion to unit length.
44///
45/// Returns the identity quaternion `[0,0,0,1]` if the norm is nearly zero.
46pub fn quat_normalize(q: [f32; 4]) -> [f32; 4] {
47    let norm = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt();
48    if norm < 1e-9 {
49        return [0.0, 0.0, 0.0, 1.0];
50    }
51    [q[0] / norm, q[1] / norm, q[2] / norm, q[3] / norm]
52}
53
54/// Integrate a quaternion orientation `q` by angular velocity `omega` (rad/s)
55/// over time step `dt` (seconds).
56///
57/// Uses the first-order approximation `q' = normalize(q + 0.5 * dt * Ω⊗q)`.
58pub fn integrate_orientation(q: [f32; 4], omega: [f32; 3], dt: f32) -> [f32; 4] {
59    // omega as a pure quaternion [ox, oy, oz, 0]
60    let omega_q = [omega[0], omega[1], omega[2], 0.0_f32];
61    let dq = quat_mul(omega_q, q);
62    let q_new = [
63        q[0] + 0.5 * dt * dq[0],
64        q[1] + 0.5 * dt * dq[1],
65        q[2] + 0.5 * dt * dq[2],
66        q[3] + 0.5 * dt * dq[3],
67    ];
68    quat_normalize(q_new)
69}
70
71// ── Core rigid body ───────────────────────────────────────────────────────────
72
73/// A single rigid body stored in GPU-friendly packed arrays of `f32`.
74///
75/// Orientation is stored as a unit quaternion `[x, y, z, w]`.
76/// The inverse inertia tensor is stored in row-major order as a 3×3 matrix.
77#[derive(Debug, Clone)]
78pub struct GpuRigidBody {
79    /// World-space position (metres).
80    pub position: [f32; 3],
81    /// Linear velocity (m/s).
82    pub velocity: [f32; 3],
83    /// Orientation quaternion `[x, y, z, w]`.
84    pub orientation: [f32; 4],
85    /// Angular velocity in world space (rad/s).
86    pub angular_velocity: [f32; 3],
87    /// Mass in kilograms.
88    pub mass: f32,
89    /// Inverse of the body-space inertia tensor, row-major (3×3).
90    pub inv_inertia: [f32; 9],
91}
92
93impl GpuRigidBody {
94    /// Create a new rigid body at rest with the given mass and diagonal inertia.
95    ///
96    /// `ixx`, `iyy`, `izz` are the principal moments of inertia.
97    pub fn new(mass: f32, ixx: f32, iyy: f32, izz: f32) -> Self {
98        let safe_inv = |v: f32| if v.abs() > 1e-12 { 1.0 / v } else { 0.0 };
99        let inv_inertia = [
100            safe_inv(ixx),
101            0.0,
102            0.0,
103            0.0,
104            safe_inv(iyy),
105            0.0,
106            0.0,
107            0.0,
108            safe_inv(izz),
109        ];
110        Self {
111            position: [0.0; 3],
112            velocity: [0.0; 3],
113            orientation: [0.0, 0.0, 0.0, 1.0],
114            angular_velocity: [0.0; 3],
115            mass,
116            inv_inertia,
117        }
118    }
119}
120
121// ── Batch integration ─────────────────────────────────────────────────────────
122
123/// A batch of GPU rigid bodies supporting bulk integration.
124#[derive(Debug, Clone, Default)]
125pub struct GpuRigidBodyBatch {
126    /// The rigid bodies in this batch.
127    pub bodies: Vec<GpuRigidBody>,
128}
129
130impl GpuRigidBodyBatch {
131    /// Create an empty batch.
132    pub fn new() -> Self {
133        Self::default()
134    }
135
136    /// Add a body to the batch and return its index.
137    pub fn add(&mut self, body: GpuRigidBody) -> usize {
138        let idx = self.bodies.len();
139        self.bodies.push(body);
140        idx
141    }
142
143    /// Integrate all bodies by `dt` seconds under a uniform `gravity` (m/s²).
144    ///
145    /// Uses explicit Euler integration for linear dynamics and a first-order
146    /// quaternion update for rotational dynamics.
147    pub fn integrate_all(&mut self, dt: f32, gravity: [f32; 3]) {
148        for body in &mut self.bodies {
149            // linear: v += g*dt,  p += v*dt
150            body.velocity[0] += gravity[0] * dt;
151            body.velocity[1] += gravity[1] * dt;
152            body.velocity[2] += gravity[2] * dt;
153            body.position[0] += body.velocity[0] * dt;
154            body.position[1] += body.velocity[1] * dt;
155            body.position[2] += body.velocity[2] * dt;
156            // rotational
157            let new_q = integrate_orientation(body.orientation, body.angular_velocity, dt);
158            body.orientation = new_q;
159        }
160    }
161
162    /// Apply a linear impulse `impulse` (N·s) at world-space `point` to body
163    /// at `body_idx`.
164    ///
165    /// Updates both linear and angular velocity.
166    pub fn apply_impulse(&mut self, body_idx: usize, impulse: [f32; 3], point: [f32; 3]) {
167        let body = &mut self.bodies[body_idx];
168        let inv_mass = if body.mass > 1e-12 {
169            1.0 / body.mass
170        } else {
171            0.0
172        };
173        // linear impulse
174        body.velocity[0] += impulse[0] * inv_mass;
175        body.velocity[1] += impulse[1] * inv_mass;
176        body.velocity[2] += impulse[2] * inv_mass;
177        // torque arm: r = point - position
178        let r = [
179            point[0] - body.position[0],
180            point[1] - body.position[1],
181            point[2] - body.position[2],
182        ];
183        // angular impulse: omega += I⁻¹ * (r × impulse)
184        let torque_impulse = cross3f(r, impulse);
185        let delta_omega = apply_mat3(body.inv_inertia, torque_impulse);
186        body.angular_velocity[0] += delta_omega[0];
187        body.angular_velocity[1] += delta_omega[1];
188        body.angular_velocity[2] += delta_omega[2];
189    }
190}
191
192// ── Broadphase SAP ────────────────────────────────────────────────────────────
193
194/// A candidate collision pair from the broadphase, with AABB information.
195#[derive(Debug, Clone)]
196pub struct BroadphasePairGpu {
197    /// Index of the first body.
198    pub body_a: usize,
199    /// Index of the second body.
200    pub body_b: usize,
201    /// AABB centre of body A.
202    pub aabb_a_center: [f32; 3],
203    /// AABB half-extents of body A.
204    pub aabb_a_half: [f32; 3],
205    /// AABB centre of body B.
206    pub aabb_b_center: [f32; 3],
207    /// AABB half-extents of body B.
208    pub aabb_b_half: [f32; 3],
209}
210
211/// Broadphase collision detection for GPU rigid bodies using a sweep-and-prune
212/// (SAP) approach.
213///
214/// Each body is approximated by a sphere; overlapping sphere AABBs are reported
215/// as candidate pairs.
216#[derive(Debug, Clone, Default)]
217pub struct GpuBroadphase {
218    /// Bodies to test.
219    pub bodies: Vec<GpuRigidBody>,
220    /// Bounding sphere radii, one per body.
221    pub radii: Vec<f32>,
222}
223
224impl GpuBroadphase {
225    /// Create an empty broadphase structure.
226    pub fn new() -> Self {
227        Self::default()
228    }
229
230    /// Add a body with its bounding sphere radius.
231    pub fn add_body(&mut self, body: GpuRigidBody, radius: f32) {
232        self.bodies.push(body);
233        self.radii.push(radius);
234    }
235
236    /// Run a sort-and-sweep on the X axis and return overlapping pairs.
237    ///
238    /// Only pairs whose AABBs overlap on all three axes are returned.
239    pub fn compute_pairs_sap(&self) -> Vec<BroadphasePairGpu> {
240        let n = self.bodies.len();
241        let mut pairs = Vec::new();
242
243        // Sort by AABB min-x
244        let mut order: Vec<usize> = (0..n).collect();
245        order.sort_by(|&a, &b| {
246            let ax = self.bodies[a].position[0] - self.radii[a];
247            let bx = self.bodies[b].position[0] - self.radii[b];
248            ax.partial_cmp(&bx).unwrap_or(std::cmp::Ordering::Equal)
249        });
250
251        for (i, &ai) in order.iter().enumerate() {
252            let a_max_x = self.bodies[ai].position[0] + self.radii[ai];
253            for &bi in order.iter().skip(i + 1) {
254                let b_min_x = self.bodies[bi].position[0] - self.radii[bi];
255                if b_min_x > a_max_x {
256                    break; // sorted: no further pair can overlap on X
257                }
258                // Check all three axes
259                if self.aabb_overlap(ai, bi) {
260                    let ra = self.radii[ai];
261                    let rb = self.radii[bi];
262                    pairs.push(BroadphasePairGpu {
263                        body_a: ai,
264                        body_b: bi,
265                        aabb_a_center: self.bodies[ai].position,
266                        aabb_a_half: [ra, ra, ra],
267                        aabb_b_center: self.bodies[bi].position,
268                        aabb_b_half: [rb, rb, rb],
269                    });
270                }
271            }
272        }
273        pairs
274    }
275
276    /// Test whether two bodies' sphere AABBs overlap on all three axes.
277    fn aabb_overlap(&self, a: usize, b: usize) -> bool {
278        for k in 0..3 {
279            let a_min = self.bodies[a].position[k] - self.radii[a];
280            let a_max = self.bodies[a].position[k] + self.radii[a];
281            let b_min = self.bodies[b].position[k] - self.radii[b];
282            let b_max = self.bodies[b].position[k] + self.radii[b];
283            if a_max < b_min || b_max < a_min {
284                return false;
285            }
286        }
287        true
288    }
289}
290
291// ── Contact manifold ──────────────────────────────────────────────────────────
292
293/// Contact manifold between two bodies produced by the narrowphase.
294#[derive(Debug, Clone)]
295pub struct ContactManifoldGpu {
296    /// Index of the first body.
297    pub body_a: usize,
298    /// Index of the second body.
299    pub body_b: usize,
300    /// Contact point positions in world space.
301    pub contact_points: Vec<[f32; 3]>,
302    /// Outward contact normals (pointing from B to A), one per contact point.
303    pub normals: Vec<[f32; 3]>,
304    /// Penetration depths (positive means overlapping), one per contact point.
305    pub penetrations: Vec<f32>,
306}
307
308impl ContactManifoldGpu {
309    /// Create a new, empty contact manifold between two bodies.
310    pub fn new(body_a: usize, body_b: usize) -> Self {
311        Self {
312            body_a,
313            body_b,
314            contact_points: Vec::new(),
315            normals: Vec::new(),
316            penetrations: Vec::new(),
317        }
318    }
319
320    /// Add a single contact point to the manifold.
321    pub fn add_contact(&mut self, point: [f32; 3], normal: [f32; 3], penetration: f32) {
322        self.contact_points.push(point);
323        self.normals.push(normal);
324        self.penetrations.push(penetration);
325    }
326
327    /// Number of contact points in the manifold.
328    pub fn contact_count(&self) -> usize {
329        self.contact_points.len()
330    }
331}
332
333// ── Sequential impulse solver ─────────────────────────────────────────────────
334
335/// Iterative sequential-impulse constraint solver for rigid body contacts.
336#[derive(Debug, Clone, Default)]
337pub struct GpuConstraintSolver {
338    /// Broadphase candidate pairs.
339    pub pairs: Vec<BroadphasePairGpu>,
340    /// Contact manifolds, aligned with `pairs`.
341    pub manifolds: Vec<ContactManifoldGpu>,
342}
343
344impl GpuConstraintSolver {
345    /// Create a new solver with no constraints.
346    pub fn new() -> Self {
347        Self::default()
348    }
349
350    /// Add a manifold (and its corresponding broadphase pair) to the solver.
351    pub fn add_manifold(&mut self, pair: BroadphasePairGpu, manifold: ContactManifoldGpu) {
352        self.pairs.push(pair);
353        self.manifolds.push(manifold);
354    }
355
356    /// Solve all contact constraints via sequential impulses.
357    ///
358    /// Iterates `iterations` times over every contact point and applies a
359    /// non-penetration impulse. A restitution coefficient of 0.3 is used.
360    pub fn solve_sequential_impulse(
361        &self,
362        bodies: &mut [GpuRigidBody],
363        dt: f32,
364        iterations: usize,
365    ) {
366        let _ = dt; // reserved for future bias/baumgarte
367        let restitution = 0.3_f32;
368        for _ in 0..iterations {
369            for manifold in &self.manifolds {
370                let a = manifold.body_a;
371                let b = manifold.body_b;
372                for c in 0..manifold.contact_count() {
373                    let n = manifold.normals[c];
374                    // relative velocity at contact
375                    let va = bodies[a].velocity;
376                    let vb = bodies[b].velocity;
377                    let rv = [va[0] - vb[0], va[1] - vb[1], va[2] - vb[2]];
378                    let vn = dot3f(rv, n);
379                    if vn >= 0.0 {
380                        continue; // separating
381                    }
382                    let inv_ma = if bodies[a].mass > 1e-12 {
383                        1.0 / bodies[a].mass
384                    } else {
385                        0.0
386                    };
387                    let inv_mb = if bodies[b].mass > 1e-12 {
388                        1.0 / bodies[b].mass
389                    } else {
390                        0.0
391                    };
392                    let j = -(1.0 + restitution) * vn / (inv_ma + inv_mb);
393                    // apply
394                    bodies[a].velocity[0] += j * inv_ma * n[0];
395                    bodies[a].velocity[1] += j * inv_ma * n[1];
396                    bodies[a].velocity[2] += j * inv_ma * n[2];
397                    bodies[b].velocity[0] -= j * inv_mb * n[0];
398                    bodies[b].velocity[1] -= j * inv_mb * n[1];
399                    bodies[b].velocity[2] -= j * inv_mb * n[2];
400                }
401            }
402        }
403    }
404}
405
406// ── Internal math helpers ─────────────────────────────────────────────────────
407
408/// Cross product of two `[f32; 3]` vectors.
409fn cross3f(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
410    [
411        a[1] * b[2] - a[2] * b[1],
412        a[2] * b[0] - a[0] * b[2],
413        a[0] * b[1] - a[1] * b[0],
414    ]
415}
416
417/// Dot product of two `[f32; 3]` vectors.
418fn dot3f(a: [f32; 3], b: [f32; 3]) -> f32 {
419    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
420}
421
422/// Apply a row-major 3×3 matrix to a vector.
423fn apply_mat3(m: [f32; 9], v: [f32; 3]) -> [f32; 3] {
424    [
425        m[0] * v[0] + m[1] * v[1] + m[2] * v[2],
426        m[3] * v[0] + m[4] * v[1] + m[5] * v[2],
427        m[6] * v[0] + m[7] * v[1] + m[8] * v[2],
428    ]
429}
430
431// ── Tests ─────────────────────────────────────────────────────────────────────
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    const EPS: f32 = 1e-5;
438
439    fn approx_eq3(a: [f32; 3], b: [f32; 3]) -> bool {
440        (a[0] - b[0]).abs() < EPS && (a[1] - b[1]).abs() < EPS && (a[2] - b[2]).abs() < EPS
441    }
442
443    fn approx_eq4(a: [f32; 4], b: [f32; 4]) -> bool {
444        (a[0] - b[0]).abs() < EPS
445            && (a[1] - b[1]).abs() < EPS
446            && (a[2] - b[2]).abs() < EPS
447            && (a[3] - b[3]).abs() < EPS
448    }
449
450    fn quat_norm(q: [f32; 4]) -> f32 {
451        (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt()
452    }
453
454    // ── quat_rotate ────────────────────────────────────────────────────────
455
456    #[test]
457    fn test_quat_rotate_identity() {
458        let q = [0.0, 0.0, 0.0, 1.0_f32];
459        let v = [1.0, 2.0, 3.0_f32];
460        let r = quat_rotate(q, v);
461        assert!(approx_eq3(r, v), "identity quat should not rotate: {r:?}");
462    }
463
464    #[test]
465    fn test_quat_rotate_180_about_z() {
466        // 180° about Z: q = [0, 0, 1, 0]
467        let q = [0.0, 0.0, 1.0_f32, 0.0];
468        let v = [1.0, 0.0, 0.0_f32];
469        let r = quat_rotate(q, v);
470        assert!(approx_eq3(r, [-1.0, 0.0, 0.0]), "180 Z rotate: {r:?}");
471    }
472
473    #[test]
474    fn test_quat_rotate_90_about_y() {
475        // 90° about Y: q = [0, sin45, 0, cos45]
476        let half = std::f32::consts::FRAC_PI_4;
477        let q = [0.0, half.sin(), 0.0, half.cos()];
478        let v = [1.0, 0.0, 0.0_f32];
479        let r = quat_rotate(q, v);
480        // Should become ~[0, 0, -1]
481        assert!((r[0]).abs() < EPS, "x should be ~0: {}", r[0]);
482        assert!((r[1]).abs() < EPS, "y should be ~0: {}", r[1]);
483        assert!((r[2] + 1.0).abs() < EPS, "z should be ~-1: {}", r[2]);
484    }
485
486    #[test]
487    fn test_quat_rotate_preserves_length() {
488        let half = std::f32::consts::FRAC_PI_6;
489        let q = quat_normalize([half.sin(), 0.0, 0.0, half.cos()]);
490        let v = [3.0, 4.0, 0.0_f32];
491        let r = quat_rotate(q, v);
492        let len_v = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
493        let len_r = (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt();
494        assert!((len_v - len_r).abs() < EPS, "rotation must preserve length");
495    }
496
497    // ── quat_mul ───────────────────────────────────────────────────────────
498
499    #[test]
500    fn test_quat_mul_identity() {
501        let id = [0.0, 0.0, 0.0, 1.0_f32];
502        let q = [0.1, 0.2, 0.3_f32, 0.9];
503        let q = quat_normalize(q);
504        assert!(approx_eq4(quat_mul(id, q), q));
505        assert!(approx_eq4(quat_mul(q, id), q));
506    }
507
508    #[test]
509    fn test_quat_mul_unit_norm() {
510        let a = quat_normalize([1.0, 0.0, 0.0_f32, 1.0]);
511        let b = quat_normalize([0.0, 1.0, 0.0_f32, 1.0]);
512        let c = quat_mul(a, b);
513        assert!(
514            (quat_norm(c) - 1.0).abs() < EPS,
515            "product must be unit: {}",
516            quat_norm(c)
517        );
518    }
519
520    #[test]
521    fn test_quat_mul_double_rotation() {
522        // Two 90° rotations about Z => 180° about Z
523        let half = std::f32::consts::FRAC_PI_4;
524        let q90 = [0.0, 0.0, half.sin(), half.cos()];
525        let q180 = quat_mul(q90, q90);
526        let v = [1.0, 0.0, 0.0_f32];
527        let r = quat_rotate(q180, v);
528        assert!((r[0] + 1.0).abs() < EPS, "should be [-1,0,0]: {r:?}");
529    }
530
531    // ── integrate_orientation ──────────────────────────────────────────────
532
533    #[test]
534    fn test_integrate_orientation_no_rotation() {
535        let q = [0.0, 0.0, 0.0, 1.0_f32];
536        let q2 = integrate_orientation(q, [0.0; 3], 0.01);
537        assert!((quat_norm(q2) - 1.0).abs() < EPS);
538        assert!(approx_eq4(q2, q));
539    }
540
541    #[test]
542    fn test_integrate_orientation_stays_unit() {
543        let q = [0.0, 0.0, 0.0, 1.0_f32];
544        let omega = [0.0, 0.0, 1.0_f32]; // 1 rad/s about Z
545        let mut q_cur = q;
546        for _ in 0..100 {
547            q_cur = integrate_orientation(q_cur, omega, 0.01);
548        }
549        assert!((quat_norm(q_cur) - 1.0).abs() < 1e-4);
550    }
551
552    #[test]
553    fn test_integrate_orientation_direction() {
554        // Small rotation about X: after many steps orientation should shift
555        let q = [0.0, 0.0, 0.0, 1.0_f32];
556        let omega = [1.0, 0.0, 0.0_f32];
557        let q2 = integrate_orientation(q, omega, 0.1);
558        // x component should now be non-zero
559        assert!(
560            q2[0].abs() > 1e-4,
561            "qx should be > 0 after rotation: {}",
562            q2[0]
563        );
564    }
565
566    // ── quat_normalize ─────────────────────────────────────────────────────
567
568    #[test]
569    fn test_quat_normalize_unit() {
570        let q = [1.0_f32, 0.0, 0.0, 0.0];
571        assert!(approx_eq4(quat_normalize(q), q));
572    }
573
574    #[test]
575    fn test_quat_normalize_zero_returns_identity() {
576        let q = quat_normalize([0.0; 4]);
577        assert!(approx_eq4(q, [0.0, 0.0, 0.0, 1.0]));
578    }
579
580    #[test]
581    fn test_quat_normalize_scales() {
582        let q = [2.0_f32, 0.0, 0.0, 0.0];
583        let n = quat_normalize(q);
584        assert!((n[0] - 1.0).abs() < EPS);
585    }
586
587    // ── GpuRigidBody ──────────────────────────────────────────────────────
588
589    #[test]
590    fn test_rigid_body_new_defaults() {
591        let b = GpuRigidBody::new(1.0, 1.0, 1.0, 1.0);
592        assert_eq!(b.position, [0.0; 3]);
593        assert_eq!(b.velocity, [0.0; 3]);
594        assert_eq!(b.orientation, [0.0, 0.0, 0.0, 1.0]);
595        assert_eq!(b.angular_velocity, [0.0; 3]);
596        assert!((b.mass - 1.0).abs() < EPS);
597    }
598
599    #[test]
600    fn test_rigid_body_inv_inertia_diagonal() {
601        let b = GpuRigidBody::new(1.0, 2.0, 4.0, 8.0);
602        assert!((b.inv_inertia[0] - 0.5).abs() < EPS, "ixx");
603        assert!((b.inv_inertia[4] - 0.25).abs() < EPS, "iyy");
604        assert!((b.inv_inertia[8] - 0.125).abs() < EPS, "izz");
605    }
606
607    // ── GpuRigidBodyBatch::integrate_all ──────────────────────────────────
608
609    #[test]
610    fn test_batch_gravity_integration() {
611        let mut batch = GpuRigidBodyBatch::new();
612        batch.add(GpuRigidBody::new(1.0, 1.0, 1.0, 1.0));
613        batch.integrate_all(1.0, [0.0, -9.81, 0.0]);
614        assert!((batch.bodies[0].velocity[1] + 9.81).abs() < EPS);
615        assert!((batch.bodies[0].position[1] + 9.81).abs() < EPS);
616    }
617
618    #[test]
619    fn test_batch_no_gravity_no_motion() {
620        let mut batch = GpuRigidBodyBatch::new();
621        batch.add(GpuRigidBody::new(1.0, 1.0, 1.0, 1.0));
622        batch.integrate_all(1.0, [0.0; 3]);
623        assert_eq!(batch.bodies[0].position, [0.0; 3]);
624        assert_eq!(batch.bodies[0].velocity, [0.0; 3]);
625    }
626
627    #[test]
628    fn test_batch_multiple_bodies() {
629        let mut batch = GpuRigidBodyBatch::new();
630        for _ in 0..5 {
631            batch.add(GpuRigidBody::new(1.0, 1.0, 1.0, 1.0));
632        }
633        batch.integrate_all(0.5, [0.0, -9.81, 0.0]);
634        for b in &batch.bodies {
635            assert!((b.velocity[1] + 9.81 * 0.5).abs() < EPS);
636        }
637    }
638
639    #[test]
640    fn test_batch_add_returns_index() {
641        let mut batch = GpuRigidBodyBatch::new();
642        let i0 = batch.add(GpuRigidBody::new(1.0, 1.0, 1.0, 1.0));
643        let i1 = batch.add(GpuRigidBody::new(2.0, 1.0, 1.0, 1.0));
644        assert_eq!(i0, 0);
645        assert_eq!(i1, 1);
646    }
647
648    // ── GpuRigidBodyBatch::apply_impulse ──────────────────────────────────
649
650    #[test]
651    fn test_apply_impulse_linear() {
652        let mut batch = GpuRigidBodyBatch::new();
653        batch.add(GpuRigidBody::new(2.0, 1.0, 1.0, 1.0));
654        // impulse [2,0,0] on mass 2 => dv = [1,0,0]
655        batch.apply_impulse(0, [2.0, 0.0, 0.0], [0.0; 3]);
656        assert!((batch.bodies[0].velocity[0] - 1.0).abs() < EPS);
657    }
658
659    #[test]
660    fn test_apply_impulse_angular() {
661        let mut batch = GpuRigidBodyBatch::new();
662        let mut b = GpuRigidBody::new(1.0, 1.0, 1.0, 1.0);
663        b.position = [0.0; 3];
664        batch.add(b);
665        // Apply Z-axis impulse at offset on X axis → angular velocity change
666        batch.apply_impulse(0, [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]);
667        assert!(batch.bodies[0].angular_velocity[2].abs() > 1e-5);
668    }
669
670    #[test]
671    fn test_apply_impulse_zero_mass() {
672        let mut batch = GpuRigidBodyBatch::new();
673        batch.add(GpuRigidBody::new(0.0, 1.0, 1.0, 1.0));
674        batch.apply_impulse(0, [100.0, 0.0, 0.0], [0.0; 3]);
675        assert_eq!(batch.bodies[0].velocity, [0.0; 3]);
676    }
677
678    // ── GpuBroadphase ─────────────────────────────────────────────────────
679
680    #[test]
681    fn test_broadphase_no_bodies() {
682        let bp = GpuBroadphase::new();
683        assert!(bp.compute_pairs_sap().is_empty());
684    }
685
686    #[test]
687    fn test_broadphase_two_overlapping() {
688        let mut bp = GpuBroadphase::new();
689        let mut b1 = GpuRigidBody::new(1.0, 1.0, 1.0, 1.0);
690        b1.position = [0.0; 3];
691        let mut b2 = GpuRigidBody::new(1.0, 1.0, 1.0, 1.0);
692        b2.position = [0.5, 0.0, 0.0];
693        bp.add_body(b1, 1.0);
694        bp.add_body(b2, 1.0);
695        let pairs = bp.compute_pairs_sap();
696        assert_eq!(pairs.len(), 1);
697    }
698
699    #[test]
700    fn test_broadphase_two_separated() {
701        let mut bp = GpuBroadphase::new();
702        let mut b1 = GpuRigidBody::new(1.0, 1.0, 1.0, 1.0);
703        b1.position = [0.0; 3];
704        let mut b2 = GpuRigidBody::new(1.0, 1.0, 1.0, 1.0);
705        b2.position = [100.0, 0.0, 0.0];
706        bp.add_body(b1, 0.5);
707        bp.add_body(b2, 0.5);
708        let pairs = bp.compute_pairs_sap();
709        assert!(pairs.is_empty());
710    }
711
712    #[test]
713    fn test_broadphase_three_bodies_two_pairs() {
714        let mut bp = GpuBroadphase::new();
715        for x in [0.0_f32, 1.0, 2.0] {
716            let mut b = GpuRigidBody::new(1.0, 1.0, 1.0, 1.0);
717            b.position = [x, 0.0, 0.0];
718            bp.add_body(b, 0.8);
719        }
720        let pairs = bp.compute_pairs_sap();
721        // 0-1 and 1-2 overlap, 0-2 may or may not
722        assert!(pairs.len() >= 2, "expected >= 2 pairs, got {}", pairs.len());
723    }
724
725    #[test]
726    fn test_broadphase_pair_indices_valid() {
727        let mut bp = GpuBroadphase::new();
728        let mut b1 = GpuRigidBody::new(1.0, 1.0, 1.0, 1.0);
729        b1.position = [0.0; 3];
730        let mut b2 = GpuRigidBody::new(1.0, 1.0, 1.0, 1.0);
731        b2.position = [0.3, 0.0, 0.0];
732        bp.add_body(b1, 0.5);
733        bp.add_body(b2, 0.5);
734        let pairs = bp.compute_pairs_sap();
735        assert_eq!(pairs.len(), 1);
736        let p = &pairs[0];
737        assert!(p.body_a < 2 && p.body_b < 2 && p.body_a != p.body_b);
738    }
739
740    // ── ContactManifoldGpu ────────────────────────────────────────────────
741
742    #[test]
743    fn test_manifold_empty() {
744        let m = ContactManifoldGpu::new(0, 1);
745        assert_eq!(m.contact_count(), 0);
746    }
747
748    #[test]
749    fn test_manifold_add_contact() {
750        let mut m = ContactManifoldGpu::new(0, 1);
751        m.add_contact([0.0; 3], [0.0, 1.0, 0.0], 0.01);
752        assert_eq!(m.contact_count(), 1);
753        assert!((m.penetrations[0] - 0.01).abs() < EPS);
754    }
755
756    #[test]
757    fn test_manifold_multiple_contacts() {
758        let mut m = ContactManifoldGpu::new(0, 1);
759        for i in 0..4 {
760            m.add_contact([i as f32, 0.0, 0.0], [0.0, 1.0, 0.0], 0.01 * i as f32);
761        }
762        assert_eq!(m.contact_count(), 4);
763    }
764
765    // ── GpuConstraintSolver ───────────────────────────────────────────────
766
767    #[test]
768    fn test_solver_no_manifolds() {
769        let solver = GpuConstraintSolver::new();
770        let mut bodies = vec![GpuRigidBody::new(1.0, 1.0, 1.0, 1.0)];
771        solver.solve_sequential_impulse(&mut bodies, 0.01, 10);
772        assert_eq!(bodies[0].velocity, [0.0; 3]);
773    }
774
775    #[test]
776    fn test_solver_separates_colliding_bodies() {
777        let mut b1 = GpuRigidBody::new(1.0, 1.0, 1.0, 1.0);
778        b1.velocity = [1.0, 0.0, 0.0];
779        let mut b2 = GpuRigidBody::new(1.0, 1.0, 1.0, 1.0);
780        b2.velocity = [-1.0, 0.0, 0.0];
781        b2.position = [0.5, 0.0, 0.0];
782
783        let mut solver = GpuConstraintSolver::new();
784        let pair = BroadphasePairGpu {
785            body_a: 0,
786            body_b: 1,
787            aabb_a_center: [0.0; 3],
788            aabb_a_half: [0.5; 3],
789            aabb_b_center: [0.5; 3],
790            aabb_b_half: [0.5; 3],
791        };
792        let mut manifold = ContactManifoldGpu::new(0, 1);
793        manifold.add_contact([0.25, 0.0, 0.0], [1.0, 0.0, 0.0], 0.01);
794        solver.add_manifold(pair, manifold);
795
796        let mut bodies = vec![b1, b2];
797        solver.solve_sequential_impulse(&mut bodies, 0.01, 10);
798
799        // After impulse, relative velocity along normal should be >= 0
800        let rv_x = bodies[0].velocity[0] - bodies[1].velocity[0];
801        assert!(
802            rv_x >= -EPS,
803            "bodies should not penetrate further: rv_x={rv_x}"
804        );
805    }
806
807    #[test]
808    fn test_solver_static_body_kinematic() {
809        // body_b has zero mass => should not move
810        let mut b1 = GpuRigidBody::new(1.0, 1.0, 1.0, 1.0);
811        b1.velocity = [0.0, -1.0, 0.0];
812        let b2 = GpuRigidBody::new(0.0, 1.0, 1.0, 1.0); // static
813
814        let mut solver = GpuConstraintSolver::new();
815        let pair = BroadphasePairGpu {
816            body_a: 0,
817            body_b: 1,
818            aabb_a_center: [0.0; 3],
819            aabb_a_half: [0.5; 3],
820            aabb_b_center: [0.0, -0.9, 0.0],
821            aabb_b_half: [0.5; 3],
822        };
823        let mut manifold = ContactManifoldGpu::new(0, 1);
824        manifold.add_contact([0.0, -0.5, 0.0], [0.0, 1.0, 0.0], 0.1);
825        solver.add_manifold(pair, manifold);
826
827        let mut bodies = vec![b1, b2];
828        solver.solve_sequential_impulse(&mut bodies, 0.01, 10);
829
830        assert_eq!(bodies[1].velocity, [0.0; 3], "static body must not move");
831        assert!(
832            bodies[0].velocity[1] >= -EPS,
833            "dynamic body should bounce up"
834        );
835    }
836
837    // ── Internal helpers ──────────────────────────────────────────────────
838
839    #[test]
840    fn test_cross3f() {
841        let i = [1.0_f32, 0.0, 0.0];
842        let j = [0.0_f32, 1.0, 0.0];
843        let k = cross3f(i, j);
844        assert!(approx_eq3(k, [0.0, 0.0, 1.0]));
845    }
846
847    #[test]
848    fn test_dot3f() {
849        assert!((dot3f([1.0, 2.0, 3.0_f32], [4.0, 5.0, 6.0]) - 32.0).abs() < EPS);
850    }
851
852    #[test]
853    fn test_apply_mat3_identity() {
854        let id = [1.0_f32, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
855        let v = [3.0_f32, 4.0, 5.0];
856        assert!(approx_eq3(apply_mat3(id, v), v));
857    }
858
859    #[test]
860    fn test_apply_mat3_scale() {
861        let m = [2.0_f32, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 4.0];
862        let v = [1.0_f32, 1.0, 1.0];
863        assert!(approx_eq3(apply_mat3(m, v), [2.0, 3.0, 4.0]));
864    }
865}