Skip to main content

symtropy_physics/
ccd.rs

1// Copyright (C) 2024-2026 Tristan Stoltz / Luminous Dynamics
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// Commercial licensing: see COMMERCIAL_LICENSE.md at repository root
4//! Continuous Collision Detection (CCD): prevents fast-moving bodies from
5//! tunneling through surfaces between frames.
6//!
7//! Uses conservative advancement with analytical solutions for common cases:
8//! - **Sphere-sphere**: Quadratic solve for earliest contact time
9//! - **Sphere-plane**: Linear solve for earliest contact time
10//!
11//! # How It Works
12//!
13//! After integration and before collision resolution, the CCD pass checks
14//! fast-moving bodies (speed > threshold) against static/slow geometry.
15//! If a tunneling event is detected, the body is moved back to the
16//! time of impact (TOI) and given a corrected velocity.
17
18use nalgebra::SVector;
19
20/// Result of a continuous collision detection query.
21#[derive(Clone, Debug)]
22pub struct CcdHit<const D: usize> {
23    /// Time of impact [0, 1] within the current timestep.
24    /// 0 = start of step, 1 = end of step.
25    pub toi: f64,
26    /// Hit point in world space at time of impact.
27    pub point: SVector<f64, D>,
28    /// Contact normal at time of impact.
29    pub normal: SVector<f64, D>,
30}
31
32/// Speed threshold below which CCD is not applied (optimization).
33/// Bodies moving slower than this per step won't tunnel through typical geometry.
34pub const CCD_SPEED_THRESHOLD: f64 = 5.0;
35
36/// Swept sphere vs sphere: find the earliest time of impact.
37///
38/// Given two spheres with positions and velocities, solve for the time `t ∈ [0, dt]`
39/// when `|pos_a(t) - pos_b(t)| = radius_a + radius_b`.
40///
41/// This is a quadratic in t:
42/// `|Δp + Δv*t|² = r²` where Δp = pos_b - pos_a, Δv = vel_b - vel_a, r = ra + rb.
43pub fn sphere_sphere<const D: usize>(
44    pos_a: &SVector<f64, D>,
45    vel_a: &SVector<f64, D>,
46    radius_a: f64,
47    pos_b: &SVector<f64, D>,
48    vel_b: &SVector<f64, D>,
49    radius_b: f64,
50    dt: f64,
51) -> Option<CcdHit<D>> {
52    let dp = pos_b - pos_a;
53    let dv = vel_b - vel_a;
54    let r = radius_a + radius_b;
55
56    let a = dv.dot(&dv);
57    let b = 2.0 * dp.dot(&dv);
58    let c = dp.dot(&dp) - r * r;
59
60    // Already overlapping at t=0
61    if c <= 0.0 {
62        let normal = if dp.norm() > 1e-15 {
63            dp.normalize()
64        } else {
65            let mut n = SVector::zeros();
66            n[0] = 1.0;
67            n
68        };
69        return Some(CcdHit {
70            toi: 0.0,
71            point: pos_a + normal * radius_a,
72            normal,
73        });
74    }
75
76    // No relative motion
77    if a < 1e-20 {
78        return None;
79    }
80
81    let discriminant = b * b - 4.0 * a * c;
82    if discriminant < 0.0 {
83        return None; // No intersection
84    }
85
86    let sqrt_disc = discriminant.sqrt();
87    let t1 = (-b - sqrt_disc) / (2.0 * a);
88
89    // We want the earliest positive t within [0, dt]
90    if t1 >= 0.0 && t1 <= dt {
91        let pos_a_t = pos_a + vel_a * t1;
92        let pos_b_t = pos_b + vel_b * t1;
93        let delta = pos_b_t - pos_a_t;
94        let normal = if delta.norm() > 1e-15 {
95            delta.normalize()
96        } else {
97            let mut n = SVector::zeros();
98            n[0] = 1.0;
99            n
100        };
101        Some(CcdHit {
102            toi: t1,
103            point: pos_a_t + normal * radius_a,
104            normal,
105        })
106    } else {
107        let t2 = (-b + sqrt_disc) / (2.0 * a);
108        if t2 >= 0.0 && t2 <= dt {
109            let pos_a_t = pos_a + vel_a * t2;
110            let pos_b_t = pos_b + vel_b * t2;
111            let delta = pos_b_t - pos_a_t;
112            let normal = if delta.norm() > 1e-15 {
113                delta.normalize()
114            } else {
115                let mut n = SVector::zeros();
116                n[0] = 1.0;
117                n
118            };
119            Some(CcdHit {
120                toi: t2,
121                point: pos_a_t + normal * radius_a,
122                normal,
123            })
124        } else {
125            None // Intersection happens outside [0, dt]
126        }
127    }
128}
129
130/// Swept sphere vs half-space (infinite plane): find time of impact.
131///
132/// Solve for t where `normal · (pos + vel*t) - offset = radius`.
133/// This is linear: `t = (radius + offset - normal · pos) / (normal · vel)`.
134pub fn sphere_halfspace<const D: usize>(
135    pos: &SVector<f64, D>,
136    vel: &SVector<f64, D>,
137    radius: f64,
138    plane_normal: &SVector<f64, D>,
139    plane_offset: f64,
140    dt: f64,
141) -> Option<CcdHit<D>> {
142    let signed_dist = plane_normal.dot(pos) - plane_offset;
143
144    // Already penetrating
145    if signed_dist <= radius {
146        let contact = pos - plane_normal * signed_dist;
147        return Some(CcdHit {
148            toi: 0.0,
149            point: contact,
150            normal: *plane_normal,
151        });
152    }
153
154    let vel_toward_plane = -plane_normal.dot(vel);
155    if vel_toward_plane <= 0.0 {
156        return None; // Moving away from plane
157    }
158
159    // Time to reach distance = radius from plane
160    let t = (signed_dist - radius) / vel_toward_plane;
161
162    if t >= 0.0 && t <= dt {
163        let pos_t = pos + vel * t;
164        let contact = pos_t - plane_normal * radius;
165        Some(CcdHit {
166            toi: t,
167            point: contact,
168            normal: *plane_normal,
169        })
170    } else {
171        None
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn sphere_sphere_head_on() {
181        let pos_a = SVector::from([0.0, 0.0, 0.0]);
182        let vel_a = SVector::from([10.0, 0.0, 0.0]);
183        let pos_b = SVector::from([5.0, 0.0, 0.0]);
184        let vel_b = SVector::from([-10.0, 0.0, 0.0]);
185
186        let hit = sphere_sphere(&pos_a, &vel_a, 0.5, &pos_b, &vel_b, 0.5, 1.0).unwrap();
187
188        // Spheres start 5 apart (center-center), need to close 4.0 (5-2*0.5),
189        // closing at 20 units/s → t = 4.0/20 = 0.2
190        assert!(
191            (hit.toi - 0.2).abs() < 0.01,
192            "TOI = {}, expected ~0.2",
193            hit.toi
194        );
195    }
196
197    #[test]
198    fn sphere_sphere_no_collision() {
199        let pos_a = SVector::from([0.0, 0.0, 0.0]);
200        let vel_a = SVector::from([1.0, 0.0, 0.0]);
201        let pos_b = SVector::from([0.0, 10.0, 0.0]); // perpendicular
202        let vel_b = SVector::from([0.0, 0.0, 0.0]);
203
204        let hit = sphere_sphere(&pos_a, &vel_a, 0.5, &pos_b, &vel_b, 0.5, 1.0);
205        assert!(hit.is_none(), "spheres moving apart should not collide");
206    }
207
208    #[test]
209    fn sphere_sphere_already_overlapping() {
210        let pos_a = SVector::from([0.0, 0.0, 0.0]);
211        let vel_a = SVector::zeros();
212        let pos_b = SVector::from([0.5, 0.0, 0.0]);
213        let vel_b = SVector::zeros();
214
215        let hit = sphere_sphere(&pos_a, &vel_a, 1.0, &pos_b, &vel_b, 1.0, 1.0).unwrap();
216        assert!(
217            (hit.toi - 0.0).abs() < 1e-12,
218            "already overlapping: TOI should be 0"
219        );
220    }
221
222    #[test]
223    fn sphere_halfspace_falling_onto_ground() {
224        let pos = SVector::from([0.0, 10.0, 0.0]);
225        let vel = SVector::from([0.0, -100.0, 0.0]); // fast falling
226        let normal = SVector::from([0.0, 1.0, 0.0]);
227
228        let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 1.0).unwrap();
229
230        // Sphere at y=10, radius 0.5, needs to fall to y=0.5 → distance 9.5
231        // Speed 100 → t = 9.5/100 = 0.095
232        assert!(
233            (hit.toi - 0.095).abs() < 0.01,
234            "TOI = {}, expected ~0.095",
235            hit.toi
236        );
237        assert!(hit.normal[1] > 0.9, "normal should point up");
238    }
239
240    #[test]
241    fn sphere_halfspace_moving_away() {
242        let pos = SVector::from([0.0, 5.0, 0.0]);
243        let vel = SVector::from([0.0, 10.0, 0.0]); // moving up
244        let normal = SVector::from([0.0, 1.0, 0.0]);
245
246        let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 1.0);
247        assert!(hit.is_none(), "moving away from plane should not hit");
248    }
249
250    #[test]
251    fn sphere_halfspace_already_penetrating() {
252        let pos = SVector::from([0.0, 0.3, 0.0]);
253        let vel = SVector::from([0.0, -10.0, 0.0]);
254        let normal = SVector::from([0.0, 1.0, 0.0]);
255
256        let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 1.0).unwrap();
257        assert!(
258            (hit.toi - 0.0).abs() < 1e-12,
259            "already penetrating: TOI = 0"
260        );
261    }
262
263    #[test]
264    fn sphere_sphere_4d() {
265        let pos_a = SVector::from([0.0, 0.0, 0.0, 0.0]);
266        let vel_a = SVector::from([0.0, 0.0, 0.0, 20.0]); // moving along W
267        let pos_b = SVector::from([0.0, 0.0, 0.0, 10.0]);
268        let vel_b = SVector::zeros();
269
270        let hit = sphere_sphere(&pos_a, &vel_a, 1.0, &pos_b, &vel_b, 1.0, 1.0).unwrap();
271        // Need to close 8.0 (10 - 2*1.0) at speed 20 → t = 0.4
272        assert!(
273            (hit.toi - 0.4).abs() < 0.01,
274            "4D TOI = {}, expected ~0.4",
275            hit.toi
276        );
277    }
278
279    #[test]
280    fn sphere_halfspace_outside_dt() {
281        let pos = SVector::from([0.0, 100.0, 0.0]);
282        let vel = SVector::from([0.0, -1.0, 0.0]); // slow fall
283        let normal = SVector::from([0.0, 1.0, 0.0]);
284
285        // t = 99.5 / 1.0 = 99.5, way beyond dt=1.0
286        let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 1.0);
287        assert!(hit.is_none(), "impact outside dt should not be reported");
288    }
289
290    #[test]
291    fn ccd_prevents_tunneling_scenario() {
292        // Scenario: sphere at y=5, moving at -500 units/s toward y=0 plane
293        // In a single 16ms step: displacement = 8 units → would skip right through
294        let pos = SVector::from([0.0, 5.0, 0.0]);
295        let vel = SVector::from([0.0, -500.0, 0.0]);
296        let normal = SVector::from([0.0, 1.0, 0.0]);
297
298        let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 0.016).unwrap();
299        assert!(hit.toi < 0.016, "CCD should detect hit within dt");
300        assert!(hit.toi > 0.0, "CCD should detect hit after start");
301
302        // The corrected position: pos + vel * toi
303        let corrected_y = 5.0 + (-500.0) * hit.toi;
304        assert!(
305            corrected_y >= 0.4,
306            "corrected position should be above plane, y = {corrected_y}"
307        );
308    }
309}