Skip to content

Commit fa42910

Browse files
committed
fix(ccd): honor filter_contact_pair PhysicsHook during CCD
Closes #754. The narrow phase already consulted the user's `filter_contact_pair` hook when deciding whether to compute contacts for a pair. CCD's `find_first_impact` and `predict_impacts_at_next_positions` did not, so fast-moving CCD-enabled bodies still got motion-clamped by pairs the user had filtered out. This thread `hooks: &dyn PhysicsHooks` through both CCD entry points and calls the filter at each pair-evaluation site (one in `find_first_impact`, two in `predict_impacts_at_next_positions` — the first-sweep loop and the resweep loop). The shared logic lives in a private `pair_filtered_out_by_hooks` helper in `ccd_solver.rs` that mirrors narrow-phase semantics exactly. Signature change: `CCDSolver::find_first_impact` and `CCDSolver::predict_impacts_at_next_positions` take an extra `hooks: &dyn PhysicsHooks` argument. `PhysicsPipeline::step` callers are unaffected; only code calling the CCD solver directly needs to update. Regression test added in `pipeline::physics_pipeline::test` that reproduces the bug: a 200 m/s CCD body rejected from a pair via `filter_contact_pair` passes through instead of clamping.
1 parent 87c8cf7 commit fa42910

3 files changed

Lines changed: 172 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
## Unreleased
2+
3+
### Fixed
4+
5+
- Continuous Collision Detection now consults the user's `PhysicsHooks::filter_contact_pair`
6+
hook, matching narrow-phase semantics. Previously, CCD would clamp a fast-moving body's
7+
motion at a predicted impact with a pair the user had filtered out. Closes #754.
8+
9+
### Modified
10+
11+
- **Breaking (CCD solver direct callers only):** `CCDSolver::find_first_impact` and
12+
`CCDSolver::predict_impacts_at_next_positions` now take an extra `hooks: &dyn PhysicsHooks`
13+
argument. `PhysicsPipeline::step` callers are unaffected.
14+
115
## v0.31.0 (09 Jan. 2026)
216

317
### Modified

src/dynamics/ccd/ccd_solver.rs

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,52 @@
11
use super::TOIEntry;
22
use crate::alloc_prelude::*;
33
use crate::dynamics::{IntegrationParameters, IslandManager, RigidBodyHandle, RigidBodySet};
4-
use crate::geometry::{BroadPhaseBvh, ColliderParent, ColliderSet, CollisionEvent, NarrowPhase};
4+
use crate::geometry::{
5+
BroadPhaseBvh, Collider, ColliderHandle, ColliderParent, ColliderSet, CollisionEvent,
6+
NarrowPhase,
7+
};
58
use crate::math::Real;
69
use crate::parry::utils::SortedPair;
7-
use crate::pipeline::{EventHandler, QueryFilter};
10+
use crate::pipeline::{ActiveHooks, EventHandler, PairFilterContext, PhysicsHooks, QueryFilter};
811
use crate::prelude::{ActiveEvents, CollisionEventFlags};
912
use alloc::collections::BinaryHeap;
1013
use parry::utils::hashmap::HashMap;
1114

15+
/// Returns `true` if the user's `filter_contact_pair` hook rejected this
16+
/// pair. Mirrors the narrow-phase filter call in `NarrowPhase::compute_contacts`
17+
/// so CCD respects the same user-level contact filtering (see issue #754).
18+
///
19+
/// Note: the narrow phase may invoke this hook for sensor pairs too (it
20+
/// uses `solver_flags` to decide downstream). The CCD sweep sites skip
21+
/// sensors before calling this helper, which is intentional — CCD only
22+
/// resolves contact TOIs, and sensor intersections are reported elsewhere.
23+
#[inline]
24+
fn pair_filtered_out_by_hooks(
25+
hooks: &dyn PhysicsHooks,
26+
bodies: &RigidBodySet,
27+
colliders: &ColliderSet,
28+
co1: &Collider,
29+
co2: &Collider,
30+
ch1: ColliderHandle,
31+
ch2: ColliderHandle,
32+
bh1: Option<RigidBodyHandle>,
33+
bh2: Option<RigidBodyHandle>,
34+
) -> bool {
35+
let active_hooks = co1.flags.active_hooks | co2.flags.active_hooks;
36+
if !active_hooks.contains(ActiveHooks::FILTER_CONTACT_PAIRS) {
37+
return false;
38+
}
39+
let context = PairFilterContext {
40+
bodies,
41+
colliders,
42+
rigid_body1: bh1,
43+
rigid_body2: bh2,
44+
collider1: ch1,
45+
collider2: ch2,
46+
};
47+
hooks.filter_contact_pair(&context).is_none()
48+
}
49+
1250
pub enum PredictedImpacts {
1351
Impacts(HashMap<RigidBodyHandle, Real>),
1452
ImpactsAfterEndTime(Real),
@@ -117,6 +155,7 @@ impl CCDSolver {
117155
colliders: &ColliderSet,
118156
broad_phase: &mut BroadPhaseBvh,
119157
narrow_phase: &NarrowPhase,
158+
hooks: &dyn PhysicsHooks,
120159
) -> Option<Real> {
121160
// Update the query pipeline with the colliders’ predicted positions.
122161
for (handle, co) in colliders.iter_enabled() {
@@ -199,6 +238,12 @@ impl CCDSolver {
199238
continue;
200239
}
201240

241+
if pair_filtered_out_by_hooks(
242+
hooks, bodies, colliders, co1, co2, *ch1, ch2, bh1, bh2,
243+
) {
244+
continue;
245+
}
246+
202247
let smallest_dist = narrow_phase
203248
.contact_pair(*ch1, ch2)
204249
.and_then(|p| p.find_deepest_contact())
@@ -242,6 +287,7 @@ impl CCDSolver {
242287
colliders: &ColliderSet,
243288
broad_phase: &mut BroadPhaseBvh,
244289
narrow_phase: &NarrowPhase,
290+
hooks: &dyn PhysicsHooks,
245291
events: &dyn EventHandler,
246292
) -> PredictedImpacts {
247293
let dt = params.dt;
@@ -325,6 +371,12 @@ impl CCDSolver {
325371
continue;
326372
}
327373

374+
if pair_filtered_out_by_hooks(
375+
hooks, bodies, colliders, co1, co2, *ch1, ch2, bh1, bh2,
376+
) {
377+
continue;
378+
}
379+
328380
let smallest_dist = narrow_phase
329381
.contact_pair(*ch1, ch2)
330382
.and_then(|p| p.find_deepest_contact())
@@ -442,6 +494,12 @@ impl CCDSolver {
442494
continue;
443495
}
444496

497+
if pair_filtered_out_by_hooks(
498+
hooks, bodies, colliders, co1, co2, *ch1, ch2, bh1, bh2,
499+
) {
500+
continue;
501+
}
502+
445503
let frozen1 = bh1.and_then(|h| frozen.get(&h));
446504
let frozen2 = bh2.and_then(|h| frozen.get(&h));
447505

src/pipeline/physics_pipeline.rs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,7 @@ impl PhysicsPipeline {
374374
broad_phase: &mut BroadPhaseBvh,
375375
narrow_phase: &NarrowPhase,
376376
ccd_solver: &mut CCDSolver,
377+
hooks: &dyn PhysicsHooks,
377378
events: &dyn EventHandler,
378379
) {
379380
self.counters.ccd.toi_computation_time.start();
@@ -385,6 +386,7 @@ impl PhysicsPipeline {
385386
colliders,
386387
broad_phase,
387388
narrow_phase,
389+
hooks,
388390
events,
389391
);
390392
ccd_solver.clamp_motions(integration_parameters.dt, bodies, &impacts);
@@ -638,6 +640,7 @@ impl PhysicsPipeline {
638640
colliders,
639641
broad_phase,
640642
narrow_phase,
643+
hooks,
641644
)
642645
} else {
643646
None
@@ -709,6 +712,7 @@ impl PhysicsPipeline {
709712
broad_phase,
710713
narrow_phase,
711714
ccd_solver,
715+
hooks,
712716
events,
713717
);
714718
}
@@ -954,6 +958,100 @@ mod test {
954958
assert_eq!(h3a, h3b);
955959
}
956960

961+
// Regression test for https://github.com/dimforge/rapier/issues/754 —
962+
// CCD must consult `filter_contact_pair` just like the narrow phase, so
963+
// pairs the user filtered out don't clamp a fast CCD body's motion.
964+
#[test]
965+
#[cfg(feature = "dim3")]
966+
fn ccd_respects_filter_contact_pair_hook() {
967+
use crate::pipeline::{ActiveHooks, PairFilterContext, PhysicsHooks};
968+
use crate::prelude::{ColliderHandle, SolverFlags};
969+
use core::sync::atomic::{AtomicUsize, Ordering};
970+
971+
struct RejectAllHooks {
972+
calls: AtomicUsize,
973+
}
974+
impl PhysicsHooks for RejectAllHooks {
975+
fn filter_contact_pair(&self, _: &PairFilterContext) -> Option<SolverFlags> {
976+
self.calls.fetch_add(1, Ordering::Relaxed);
977+
None // reject every pair
978+
}
979+
}
980+
981+
let mut pipeline = PhysicsPipeline::new();
982+
let integration_parameters = IntegrationParameters::default();
983+
let mut broad_phase = BroadPhaseBvh::new();
984+
let mut narrow_phase = NarrowPhase::new();
985+
let mut bodies = RigidBodySet::new();
986+
let mut colliders = ColliderSet::new();
987+
let mut ccd = CCDSolver::new();
988+
let mut impulse_joints = ImpulseJointSet::new();
989+
let mut multibody_joints = MultibodyJointSet::new();
990+
let mut islands = IslandManager::new();
991+
let hooks = RejectAllHooks { calls: AtomicUsize::new(0) };
992+
let event_handler = ();
993+
994+
// Body A: fast-moving, CCD-enabled.
995+
let body_a = RigidBodyBuilder::dynamic()
996+
.translation(Vector::new(-5.0, 0.0, 0.0))
997+
.linvel(Vector::new(200.0, 0.0, 0.0))
998+
.ccd_enabled(true)
999+
.build();
1000+
let a_handle = bodies.insert(body_a);
1001+
let _: ColliderHandle = colliders.insert_with_parent(
1002+
ColliderBuilder::ball(0.5)
1003+
.active_hooks(ActiveHooks::FILTER_CONTACT_PAIRS)
1004+
.build(),
1005+
a_handle,
1006+
&mut bodies,
1007+
);
1008+
1009+
// Body B: stationary, CCD-enabled, at origin.
1010+
let body_b = RigidBodyBuilder::dynamic().ccd_enabled(true).build();
1011+
let b_handle = bodies.insert(body_b);
1012+
let _: ColliderHandle = colliders.insert_with_parent(
1013+
ColliderBuilder::ball(0.5)
1014+
.active_hooks(ActiveHooks::FILTER_CONTACT_PAIRS)
1015+
.build(),
1016+
b_handle,
1017+
&mut bodies,
1018+
);
1019+
1020+
for _ in 0..5 {
1021+
pipeline.step(
1022+
Vector::ZERO,
1023+
&integration_parameters,
1024+
&mut islands,
1025+
&mut broad_phase,
1026+
&mut narrow_phase,
1027+
&mut bodies,
1028+
&mut colliders,
1029+
&mut impulse_joints,
1030+
&mut multibody_joints,
1031+
&mut ccd,
1032+
&hooks,
1033+
&event_handler,
1034+
);
1035+
}
1036+
1037+
// Hook must be called at least once (from CCD, since they never
1038+
// reach narrow-phase contact in a single step at 200 m/s × 1/60s).
1039+
assert!(
1040+
hooks.calls.load(Ordering::Relaxed) > 0,
1041+
"filter_contact_pair was never called",
1042+
);
1043+
1044+
// Without the fix: CCD clamps A's motion at the predicted impact
1045+
// with B (hook ignored). A stalls near B.
1046+
// With the fix: A flies straight through at 200 m/s for 5 steps of
1047+
// dt=1/60s ≈ 16.67 units, so it ends near +11.67.
1048+
let a_pos = bodies[a_handle].translation().x;
1049+
assert!(
1050+
a_pos > 10.0,
1051+
"body A should have passed through filtered body B, but x={a_pos}",
1052+
);
1053+
}
1054+
9571055
#[test]
9581056
fn collider_removal_before_step() {
9591057
let mut pipeline = PhysicsPipeline::new();

0 commit comments

Comments
 (0)