Skip to main content

rapier2d/dynamics/ccd/
ccd_solver.rs

1use crate::alloc_prelude::*;
2use crate::dynamics::{IntegrationParameters, IslandManager, RigidBodySet, SoftBodySet};
3use crate::geometry::{
4    BroadPhaseBvh, Collider, ColliderHandle, ColliderSet, CollisionEvent, NarrowPhase,
5};
6use crate::math::Real;
7use crate::parry::bounding_volume::Aabb;
8use crate::pipeline::{EventHandler, PhysicsHooks, QueryFilter};
9use crate::prelude::{ActiveEvents, CollisionEventFlags};
10use parry::query::sweep_toi::Sweep;
11
12use super::sweeps::{
13    BodyContinuousResult, CcdTargets, PseudoHitMode, collect_fixed_targets, collect_soft_targets,
14    is_bullet, map_bodies_parallel, sweep_fast_body,
15};
16
17/// Continuous Collision Detection solver preventing fast objects from tunneling:
18/// after the solver, bodies that moved more than half their thinnest extent sweep their colliders
19/// and `next_position` is clamped to the earliest impact — velocities untouched, no re-solve; the
20/// residual approach resolves next step via speculative contacts.
21///
22/// Fast dynamic bodies sweep against fixed colliders and soft-body meshes; `ccd_enabled` makes a
23/// bullet that also sweeps kinematic/dynamic bodies (never other bullets); mesh-like colliders are
24/// never the moving shape; compounds sweep per convex child; `max_ccd_substeps = 0` disables CCD.
25#[derive(Clone, Default)]
26#[cfg_attr(feature = "serde-serialize", derive(Serialize, Deserialize))]
27pub struct CCDSolver {
28    /// Cached fixed-target list for the non-bullet sweep pass: the AABB loosening it was built
29    /// with; `None` past [`FIXED_TARGETS_LIST_MAX`] (sweep queries the full BVH). Invalidated by
30    /// the scene-change flag — re-scanning every collider each step dominated CCD on large scenes.
31    #[cfg_attr(feature = "serde-serialize", serde(skip))]
32    fixed_targets_cache: Option<FixedTargetsCache>,
33}
34
35/// The AABB loosening the cached fixed-target list was built with, paired with the list
36/// itself — `None` past [`FIXED_TARGETS_LIST_MAX`], where the sweep queries the full BVH.
37type FixedTargetsCache = (Real, Option<Vec<(ColliderHandle, Aabb)>>);
38
39impl CCDSolver {
40    /// Initializes a new CCD solver
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Discards the cached fixed-target list used by the non-bullet continuous pass.
46    /// This must be called any time the fixed targets change. If using the physics pipeline,
47    /// it is called for you. If not, it must be called manually.
48    pub fn invalidate_fixed_targets_cache(&mut self) {
49        self.fixed_targets_cache = None;
50    }
51
52    /// Updates the set of bodies that needs CCD to be resolved.
53    ///
54    /// Returns `true` if any rigid-body must have CCD resolved.
55    pub fn update_ccd_active_flags(
56        &self,
57        islands: &IslandManager,
58        bodies: &mut RigidBodySet,
59        dt: Real,
60        include_forces: bool,
61    ) -> bool {
62        let mut ccd_active = false;
63
64        for handle in islands.active_bodies() {
65            let rb = bodies.index_mut_internal(handle);
66
67            // Default tier: every fast dynamic body is a CCD origin. `ccd_enabled`
68            // no longer gates *activation*, only the sweep *scope* (fixed-only vs all bodies),
69            // applied later during pair selection.
70            if rb.is_soft_frame() {
71                // A cluster proxy's pose is derived from its particles: it is never swept.
72                rb.ccd.ccd_active = false;
73            } else if rb.is_dynamic() {
74                let moving_fast = if include_forces {
75                    // Pre-solve (substep splitter): `next_position` isn't solved yet, use the
76                    // current velocity with forces, like `find_first_impact`. The last solved
77                    // motion (`ccd_vels`) is stale, or zero on a body's first step.
78                    rb.ccd.is_moving_fast(
79                        dt,
80                        &rb.vels,
81                        Some((&rb.forces, &rb.mprops)),
82                        rb.mprops.max_extent(),
83                    )
84                } else {
85                    // Post-solve: the fast-body criterion on the actual solved motion.
86                    rb.ccd.is_moving_fast_with_next_position(
87                        dt,
88                        &rb.ccd_vels,
89                        &rb.pos,
90                        rb.mprops.local_mprops.local_com,
91                        rb.mprops.max_extent(),
92                    )
93                };
94                rb.ccd.ccd_active = moving_fast;
95                ccd_active = ccd_active || moving_fast;
96            }
97        }
98
99        ccd_active
100    }
101
102    /// Find the first time a CCD-active body has a non-sensor collider hitting another
103    /// non-sensor collider, for the multi-substep splitter.
104    ///
105    /// Returns the impact time in `[0, dt)` if any.
106    #[profiling::function]
107    #[allow(clippy::too_many_arguments)]
108    pub fn find_first_impact(
109        &mut self,
110        dt: Real, // NOTE: this doesn’t necessarily match the `params.dt`.
111        params: &IntegrationParameters,
112        islands: &IslandManager,
113        bodies: &RigidBodySet,
114        colliders: &ColliderSet,
115        broad_phase: &mut BroadPhaseBvh,
116        narrow_phase: &NarrowPhase,
117        hooks: &dyn PhysicsHooks,
118    ) -> Option<Real> {
119        // NOTE: broad-phase AABBs are NOT enlarged to the swept volumes: only the fast body's
120        // query box is swept (per collider, below); targets keep their regular fat AABBs.
121        // Swept AABBs written into the tree would leak into the next step (pair explosion).
122        let query_pipeline = broad_phase.as_query_pipeline(
123            narrow_phase.query_dispatcher(),
124            bodies,
125            colliders,
126            QueryFilter::default(),
127        );
128        let (bvh, dispatcher) = (query_pipeline.bvh, query_pipeline.dispatcher);
129
130        let linear_slop = params.allowed_linear_error();
131        let fast_bodies: Vec<_> = islands
132            .active_bodies()
133            .filter(|h| bodies[*h].ccd.ccd_active)
134            .collect();
135
136        let fractions = map_bodies_parallel(&fast_bodies, hooks, |handle, hooks| {
137            let rb1 = &bodies[handle];
138            // `next_position` isn't solved yet: sweep to the forces/velocities integration.
139            let predicted_body_pos =
140                rb1.pos
141                    .integrate_forces_and_velocities(dt, &rb1.forces, &rb1.vels, &rb1.mprops);
142            sweep_fast_body(
143                handle,
144                bodies,
145                colliders,
146                predicted_body_pos,
147                CcdTargets::FullBvh(bvh),
148                dispatcher,
149                hooks,
150                dt,
151                linear_slop,
152                PseudoHitMode::Ignore,
153            )
154            .fraction
155        });
156
157        let min_fraction = fractions.into_iter().fold(1.0, Real::min);
158        (min_fraction < 1.0).then_some(min_fraction * dt)
159    }
160
161    /// Runs the continuous-collision pass on all fast bodies and clamps their `next_position`
162    /// to their earliest time of impact: non-bullets sweep the automatic targets (fixed
163    /// colliders and soft-body collision meshes) first, then bullets sweep every (possibly
164    /// already clamped) body; velocities are never modified. Sensor
165    /// crossings the narrow phase would miss entirely emit paired `Started`/`Stopped`
166    /// intersection events.
167    #[profiling::function]
168    #[allow(clippy::too_many_arguments)]
169    pub fn solve_continuous(
170        &mut self,
171        params: &IntegrationParameters,
172        islands: &IslandManager,
173        bodies: &mut RigidBodySet,
174        colliders: &ColliderSet,
175        soft_bodies: &SoftBodySet,
176        broad_phase: &mut BroadPhaseBvh,
177        narrow_phase: &NarrowPhase,
178        hooks: &dyn PhysicsHooks,
179        events: &dyn EventHandler,
180    ) {
181        let dt = params.dt;
182        let linear_slop = params.allowed_linear_error();
183
184        // NOTE: broad-phase AABBs are NOT enlarged to the swept volumes: only the fast body's
185        // query box is swept; stationary targets keep their fat AABBs. Swept AABBs in
186        // the tree leak into the next step's broad phase (pair explosion, ~2x narrow-phase cost).
187        let (non_bullets, bullets): (Vec<_>, Vec<_>) = islands
188            .active_bodies()
189            .filter(|h| bodies[*h].ccd.ccd_active)
190            .partition(|h| !is_bullet(&bodies[*h]));
191
192        let mut all_results = Vec::new();
193
194        // Pass 1: fast non-bullet bodies vs fixed targets (all targets are stationary, so
195        // the bodies are independent and can run in parallel).
196        {
197            let query_pipeline = broad_phase.as_query_pipeline(
198                narrow_phase.query_dispatcher(),
199                bodies,
200                colliders,
201                QueryFilter::default(),
202            );
203            let (bvh, dispatcher) = (query_pipeline.bvh, query_pipeline.dispatcher);
204            // Non-bullet fast bodies only hit fixed targets: sweep against the (small) cached
205            // fixed-collider list instead of the full BVH.
206            let prediction = params.prediction_distance();
207            let cache_valid = self
208                .fixed_targets_cache
209                .as_ref()
210                .is_some_and(|(p, _)| *p == prediction);
211            if !cache_valid {
212                self.fixed_targets_cache = Some((
213                    prediction,
214                    collect_fixed_targets(bodies, colliders, prediction),
215                ));
216            }
217            // Soft-body meshes deform every step without any scene change, so they are
218            // gathered fresh (one AABB per mesh, not a collider scan).
219            let soft = collect_soft_targets(soft_bodies, colliders, prediction);
220            let targets = match &self.fixed_targets_cache.as_ref().unwrap().1 {
221                Some(fixed) => CcdTargets::Lists { fixed, soft: &soft },
222                None => CcdTargets::FullBvh(bvh),
223            };
224            let results = map_bodies_parallel(&non_bullets, hooks, |handle, hooks| {
225                sweep_fast_body(
226                    handle,
227                    bodies,
228                    colliders,
229                    bodies[handle].pos.next_position,
230                    targets,
231                    dispatcher,
232                    hooks,
233                    dt,
234                    linear_slop,
235                    PseudoHitMode::Record,
236                )
237            });
238            all_results.extend(results);
239        }
240        Self::apply_clamps(bodies, &all_results);
241
242        // Pass 2: bullets vs everything except other bullets. Targets read the (already
243        // clamped) `next_position` from pass 1 (deferred bullet stage).
244        if !bullets.is_empty() {
245            let bullet_results = {
246                let query_pipeline = broad_phase.as_query_pipeline(
247                    narrow_phase.query_dispatcher(),
248                    bodies,
249                    colliders,
250                    QueryFilter::default(),
251                );
252                let (bvh, dispatcher) = (query_pipeline.bvh, query_pipeline.dispatcher);
253                map_bodies_parallel(&bullets, hooks, |handle, hooks| {
254                    sweep_fast_body(
255                        handle,
256                        bodies,
257                        colliders,
258                        bodies[handle].pos.next_position,
259                        CcdTargets::FullBvh(bvh),
260                        dispatcher,
261                        hooks,
262                        dt,
263                        linear_slop,
264                        PseudoHitMode::Record,
265                    )
266                })
267            };
268            Self::apply_clamps(bodies, &bullet_results);
269            all_results.extend(bullet_results);
270        }
271
272        // Emit intersection events for sensor crossings that happened strictly before each
273        // body's final solid impact and that the narrow phase would never observe (no
274        // overlap at either the start or the clamped end pose).
275        for result in &all_results {
276            for hit in &result.pseudo_hits {
277                if hit.fraction >= result.fraction {
278                    // The body stops before reaching this sensor.
279                    continue;
280                }
281
282                let co1 = &colliders[hit.ch1];
283                let co2 = &colliders[hit.ch2];
284
285                if !co1.is_sensor() && !co2.is_sensor() {
286                    // TODO: this happens if we found a TOI between two non-sensor
287                    //       colliders with mismatching solver_flags. It is not clear
288                    //       what we should do in this case: we could report a
289                    //       contact started/contact stopped event for example. But in
290                    //       that case, what contact pair should be pass to these events?
291                    // For now we just ignore this special case. Let's wait for an actual
292                    // use-case to come up before we determine what we want to do here.
293                    continue;
294                }
295
296                let next_pose = |co: &Collider| match co.parent.as_ref() {
297                    Some(parent) => bodies[parent.handle].pos.next_position * parent.pos_wrt_parent,
298                    None => co.pos.0,
299                };
300
301                let prev_pos12 = co1.pos.inv_mul(&co2.pos);
302                let next_pos12 = next_pose(co1).inv_mul(&next_pose(co2));
303
304                let dispatcher = narrow_phase.query_dispatcher();
305                let intersect_before = dispatcher
306                    .intersection_test(&prev_pos12, co1.shape.as_ref(), co2.shape.as_ref())
307                    .is_ok_and(|hit| hit.intersecting);
308                let intersect_after = dispatcher
309                    .intersection_test(&next_pos12, co1.shape.as_ref(), co2.shape.as_ref())
310                    .is_ok_and(|hit| hit.intersecting);
311
312                if !intersect_before
313                    && !intersect_after
314                    && (co1.flags.active_events | co2.flags.active_events)
315                        .contains(ActiveEvents::COLLISION_EVENTS)
316                {
317                    // Emit one intersection-started and one intersection-stopped event.
318                    events.handle_collision_event(
319                        bodies,
320                        colliders,
321                        CollisionEvent::Started(hit.ch1, hit.ch2, CollisionEventFlags::SENSOR),
322                        None,
323                    );
324                    events.handle_collision_event(
325                        bodies,
326                        colliders,
327                        CollisionEvent::Stopped(hit.ch1, hit.ch2, CollisionEventFlags::SENSOR),
328                        None,
329                    );
330                }
331            }
332        }
333    }
334
335    /// Clamps each impacted body's `next_position` to the interpolated pose at its impact
336    /// fraction. Pose only — velocities are preserved.
337    fn apply_clamps(bodies: &mut RigidBodySet, results: &[BodyContinuousResult]) {
338        for result in results {
339            if result.fraction < 1.0 {
340                let rb = bodies.index_mut_internal(result.handle);
341                let sweep = Sweep::from_poses(
342                    &rb.pos.position,
343                    &rb.pos.next_position,
344                    rb.mprops.local_mprops.local_com,
345                );
346                rb.pos.next_position = sweep.transform_at(result.fraction);
347            }
348        }
349    }
350}