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}