Skip to main content

symtropy_physics/
ccd.rs

1// Copyright (C) 2024-2026 Tristan Stoltz / Luminous Dynamics
2// SPDX-License-Identifier: AGPL-3.0-or-later
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!((hit.toi - 0.0).abs() < 1e-12, "already overlapping: TOI should be 0");
217    }
218
219    #[test]
220    fn sphere_halfspace_falling_onto_ground() {
221        let pos = SVector::from([0.0, 10.0, 0.0]);
222        let vel = SVector::from([0.0, -100.0, 0.0]); // fast falling
223        let normal = SVector::from([0.0, 1.0, 0.0]);
224
225        let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 1.0).unwrap();
226
227        // Sphere at y=10, radius 0.5, needs to fall to y=0.5 → distance 9.5
228        // Speed 100 → t = 9.5/100 = 0.095
229        assert!(
230            (hit.toi - 0.095).abs() < 0.01,
231            "TOI = {}, expected ~0.095",
232            hit.toi
233        );
234        assert!(hit.normal[1] > 0.9, "normal should point up");
235    }
236
237    #[test]
238    fn sphere_halfspace_moving_away() {
239        let pos = SVector::from([0.0, 5.0, 0.0]);
240        let vel = SVector::from([0.0, 10.0, 0.0]); // moving up
241        let normal = SVector::from([0.0, 1.0, 0.0]);
242
243        let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 1.0);
244        assert!(hit.is_none(), "moving away from plane should not hit");
245    }
246
247    #[test]
248    fn sphere_halfspace_already_penetrating() {
249        let pos = SVector::from([0.0, 0.3, 0.0]);
250        let vel = SVector::from([0.0, -10.0, 0.0]);
251        let normal = SVector::from([0.0, 1.0, 0.0]);
252
253        let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 1.0).unwrap();
254        assert!((hit.toi - 0.0).abs() < 1e-12, "already penetrating: TOI = 0");
255    }
256
257    #[test]
258    fn sphere_sphere_4d() {
259        let pos_a = SVector::from([0.0, 0.0, 0.0, 0.0]);
260        let vel_a = SVector::from([0.0, 0.0, 0.0, 20.0]); // moving along W
261        let pos_b = SVector::from([0.0, 0.0, 0.0, 10.0]);
262        let vel_b = SVector::zeros();
263
264        let hit = sphere_sphere(&pos_a, &vel_a, 1.0, &pos_b, &vel_b, 1.0, 1.0).unwrap();
265        // Need to close 8.0 (10 - 2*1.0) at speed 20 → t = 0.4
266        assert!(
267            (hit.toi - 0.4).abs() < 0.01,
268            "4D TOI = {}, expected ~0.4",
269            hit.toi
270        );
271    }
272
273    #[test]
274    fn sphere_halfspace_outside_dt() {
275        let pos = SVector::from([0.0, 100.0, 0.0]);
276        let vel = SVector::from([0.0, -1.0, 0.0]); // slow fall
277        let normal = SVector::from([0.0, 1.0, 0.0]);
278
279        // t = 99.5 / 1.0 = 99.5, way beyond dt=1.0
280        let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 1.0);
281        assert!(hit.is_none(), "impact outside dt should not be reported");
282    }
283
284    #[test]
285    fn ccd_prevents_tunneling_scenario() {
286        // Scenario: sphere at y=5, moving at -500 units/s toward y=0 plane
287        // In a single 16ms step: displacement = 8 units → would skip right through
288        let pos = SVector::from([0.0, 5.0, 0.0]);
289        let vel = SVector::from([0.0, -500.0, 0.0]);
290        let normal = SVector::from([0.0, 1.0, 0.0]);
291
292        let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 0.016).unwrap();
293        assert!(hit.toi < 0.016, "CCD should detect hit within dt");
294        assert!(hit.toi > 0.0, "CCD should detect hit after start");
295
296        // The corrected position: pos + vel * toi
297        let corrected_y = 5.0 + (-500.0) * hit.toi;
298        assert!(
299            corrected_y >= 0.4,
300            "corrected position should be above plane, y = {corrected_y}"
301        );
302    }
303}