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}