From 449f00fdbc1fa5d2a233a9e4c894f1062ad9f603 Mon Sep 17 00:00:00 2001 From: Kai Stevenson Date: Sat, 29 Aug 2026 14:46:37 -0700 Subject: [PATCH] Fix CCD fixed target cache invalidation --- .../ccd_fixed_target_cache_invalidation.rs | 67 +++++++++++++++++++ src/dynamics/ccd/ccd_solver.rs | 23 ++++--- src/pipeline/physics_pipeline/substep.rs | 14 ++-- 3 files changed, 86 insertions(+), 18 deletions(-) create mode 100644 crates/rapier2d/tests/ccd_fixed_target_cache_invalidation.rs diff --git a/crates/rapier2d/tests/ccd_fixed_target_cache_invalidation.rs b/crates/rapier2d/tests/ccd_fixed_target_cache_invalidation.rs new file mode 100644 index 000000000..9a2ab0a6d --- /dev/null +++ b/crates/rapier2d/tests/ccd_fixed_target_cache_invalidation.rs @@ -0,0 +1,67 @@ +//! The CCD fixed-target cache must not outlive the colliders it points at. +//! +//! The non-bullet continuous pass sweeps against a cached list of fixed targets, held as +//! `ColliderHandle`s from one step to the next. The scene-change signal that invalidates it +//! is rebuilt from the per-step user-change lists, which are drained whether or not the CCD +//! pass runs — so a removal on a step where nothing moved fast enough used to be forgotten, +//! leaving the cache holding a dead handle for the next fast body to resolve. + +use rapier2d::prelude::*; + +/// A fixed collider removed on a step without any CCD-active body must not linger in the +/// cached fixed-target list. +#[test] +fn fixed_collider_removed_on_a_ccd_less_step_leaves_no_stale_target() { + let mut world = PhysicsWorld::new(); + + // Two parentless (hence fixed-target) colliders. `stale` sits on the path `faller` + // takes down to the ground, so a leftover entry is guaranteed to be resolved rather + // than skipped by the swept-AABB test. + world.insert_collider( + ColliderBuilder::cuboid(100.0, 0.5).translation(Vector::new(0.0, 0.0)), + None, + ); + let stale = world.insert_collider( + ColliderBuilder::cuboid(2.0, 2.0).translation(Vector::new(10.0, 25.0)), + None, + ); + + // Moving fast from the first step: forces a continuous pass, which is what populates + // the fixed-target cache. + let fast = world.insert_body( + RigidBodyBuilder::dynamic() + .translation(Vector::new(0.0, 30.0)) + .linvel(Vector::new(0.0, -400.0)), + ); + world.insert_collider(ColliderBuilder::ball(0.2), Some(fast)); + + // Starts at rest and only becomes CCD-active later, from gravity alone — no user change + // marks the step it wakes the continuous pass back up. + let faller = + world.insert_body(RigidBodyBuilder::dynamic().translation(Vector::new(10.0, 60.0))); + world.insert_collider(ColliderBuilder::ball(0.05), Some(faller)); + + world.step(); + + // Take the fast body out so the next few steps skip the continuous pass entirely. + world.remove_body(fast); + world.step(); + + // The removal whose invalidation signal used to be dropped: `faller` is still slow, so + // no CCD pass runs this step to notice it. + world.remove_collider(stale); + world.step(); + + // Nothing is touched from here on: `faller` accelerates until it is CCD-active, and the + // pass it triggers resolves the cached fixed targets. + for _ in 0..400 { + world.step(); + } + + // It fell all the way through where `stale` used to be and settled on the ground. + let y = world.bodies[faller].translation().y; + assert!( + (y - 0.55).abs() < 0.1, + "faller should rest on the ground at y ≈ 0.55, got {y}" + ); +} diff --git a/src/dynamics/ccd/ccd_solver.rs b/src/dynamics/ccd/ccd_solver.rs index ee5cd9b07..c9ab3c781 100644 --- a/src/dynamics/ccd/ccd_solver.rs +++ b/src/dynamics/ccd/ccd_solver.rs @@ -43,6 +43,13 @@ impl CCDSolver { Self::default() } + /// Discards the cached fixed-target list used by the non-bullet continuous pass. + /// This must be called any time the fixed targets change. If using the physics pipeline, + /// it is called for you. If not, it must be called manually. + pub fn invalidate_fixed_targets_cache(&mut self) { + self.fixed_targets_cache = None; + } + /// Updates the set of bodies that needs CCD to be resolved. /// /// Returns `true` if any rigid-body must have CCD resolved. @@ -165,10 +172,6 @@ impl CCDSolver { narrow_phase: &NarrowPhase, hooks: &dyn PhysicsHooks, events: &dyn EventHandler, - // `true` when colliders/bodies were added, removed or modified by the - // user since the last step: the only ways a fixed target can appear, - // vanish or move, hence the fixed-target cache invalidation signal. - scene_changed: bool, ) { let dt = params.dt; let linear_slop = params.allowed_linear_error(); @@ -194,14 +197,12 @@ impl CCDSolver { ); let (bvh, dispatcher) = (query_pipeline.bvh, query_pipeline.dispatcher); // Non-bullet fast bodies only hit fixed targets: sweep against the (small) cached - // fixed-collider list instead of the full BVH. Rebuilt — a full collider scan — - // only on scene changes, since fixed targets can't move otherwise. + // fixed-collider list instead of the full BVH. let prediction = params.prediction_distance(); - let cache_valid = !scene_changed - && self - .fixed_targets_cache - .as_ref() - .is_some_and(|(p, _)| *p == prediction); + let cache_valid = self + .fixed_targets_cache + .as_ref() + .is_some_and(|(p, _)| *p == prediction); if !cache_valid { self.fixed_targets_cache = Some(( prediction, diff --git a/src/pipeline/physics_pipeline/substep.rs b/src/pipeline/physics_pipeline/substep.rs index ac8e8f094..4697be899 100644 --- a/src/pipeline/physics_pipeline/substep.rs +++ b/src/pipeline/physics_pipeline/substep.rs @@ -62,7 +62,6 @@ impl PhysicsPipeline { ccd_solver: &mut CCDSolver, hooks: &dyn PhysicsHooks, events: &dyn EventHandler, - scene_changed: bool, ) { self.counters.ccd.toi_computation_time.start(); // Handle CCD: sweep the fast bodies and clamp their `next_position` to their @@ -76,7 +75,6 @@ impl PhysicsPipeline { narrow_phase, hooks, events, - scene_changed, ); self.counters.ccd.toi_computation_time.pause(); } @@ -333,12 +331,15 @@ impl PhysicsPipeline { .filter(|h| colliders.get(*h).map(|c| !c.is_enabled()).unwrap_or(false)), ); - // Whether any user change could have added, removed or moved a FIXED - // collider this step — the CCD fixed-target cache invalidation signal + // If a user change could have added, removed, or moved a FIXED + // collider this step, invalidate the cache. // (internal motion never touches fixed colliders nor these lists). - let ccd_scene_changed = !modified_colliders.is_empty() + if !modified_colliders.is_empty() || !removed_colliders.is_empty() - || !modified_bodies.is_empty(); + || !modified_bodies.is_empty() + { + ccd_solver.invalidate_fixed_targets_cache(); + } // Join islands based on new joints. #[cfg(feature = "enhanced-determinism")] @@ -514,7 +515,6 @@ impl PhysicsPipeline { ccd_solver, hooks, events, - ccd_scene_changed, ); } }