Skip to main content

rapier2d/dynamics/ccd/
ccd_solver.rs

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