Skip to main content

rapier2d/pipeline/physics_pipeline/
quarantine.rs

1//! Containment of non-finite (NaN or infinite) simulation state.
2//!
3//! Non-finite state is detected at two chokepoints (user modifications at step start, the
4//! position advance at step end) before it can corrupt the broad-phase or spread to other
5//! bodies; the affected body or collider is disabled, rolled back to its last valid pose when
6//! one is known, and reported through [`Quarantine`].
7
8use crate::alloc_prelude::*;
9use crate::dynamics::{
10    RigidBody, RigidBodyHandle, RigidBodySet, RigidBodyVelocity, SoftBodyHandle,
11};
12use crate::geometry::{ColliderHandle, ColliderSet};
13use crate::math::{Pose, Vector};
14use crate::pipeline::PhysicsPipeline;
15
16/// Non-finite (NaN or infinite) state detected and neutralized during the last step; reports are
17/// cleared each step. Reported bodies are reset to their last valid pose (when known), zeroed
18/// and disabled (`set_enabled(true)` re-enables them); soft bodies are zeroed and disabled.
19#[derive(Default)]
20pub struct Quarantine {
21    /// Bodies disabled because their pose or velocity went non-finite.
22    bodies: Vec<RigidBodyHandle>,
23    /// Colliders disabled because their own geometry went non-finite.
24    colliders: Vec<ColliderHandle>,
25    /// Soft bodies disabled because a particle's position or velocity went non-finite.
26    pub(super) soft_bodies: Vec<SoftBodyHandle>,
27    /// Bodies flagged by the end-of-step advance with their last valid pose;
28    /// consumed by `apply_end_step`.
29    pub(super) body_workspace: Vec<(RigidBodyHandle, Pose)>,
30    /// Colliders flagged by the end-of-step advance with a non-finite AABB despite a finite
31    /// body pose; consumed by `apply_end_step`.
32    pub(super) collider_workspace: Vec<ColliderHandle>,
33}
34
35impl Quarantine {
36    /// The rigid-bodies quarantined during the last step.
37    pub fn bodies(&self) -> &[RigidBodyHandle] {
38        &self.bodies
39    }
40
41    /// The colliders quarantined during the last step.
42    pub fn colliders(&self) -> &[ColliderHandle] {
43        &self.colliders
44    }
45
46    /// The soft bodies quarantined during the last step: disabled, velocities zeroed, non-finite
47    /// particle positions left as is (fix them with `SoftBody::set_particle_position` before
48    /// `SoftBody::set_enabled`).
49    pub fn soft_bodies(&self) -> &[SoftBodyHandle] {
50        &self.soft_bodies
51    }
52
53    /// Was nothing quarantined during the last step?
54    pub fn is_empty(&self) -> bool {
55        self.bodies.is_empty() && self.colliders.is_empty() && self.soft_bodies.is_empty()
56    }
57
58    /// Clears the reports at the beginning of a step.
59    pub(super) fn clear(&mut self) {
60        self.bodies.clear();
61        self.colliders.clear();
62        self.soft_bodies.clear();
63    }
64
65    /// Neutralizes every non-finite value found in `rb`'s velocities and user forces.
66    fn sanitize_body_dynamics(rb: &mut RigidBody) {
67        rb.vels = RigidBodyVelocity::zero();
68        rb.ccd_vels = RigidBodyVelocity::zero();
69        rb.forces.force = Vector::ZERO;
70        rb.forces.torque = Default::default();
71        rb.forces.user_force = Vector::ZERO;
72        rb.forces.user_torque = Default::default();
73    }
74
75    /// Step-start chokepoint: quarantines user-modified bodies and colliders whose new state is
76    /// non-finite, before it reaches the broad-phase. Quarantined objects are already in the
77    /// modified lists, so their disable is processed by the same step's user-changes handling.
78    pub(super) fn detect_user_changes(
79        &mut self,
80        bodies: &mut RigidBodySet,
81        colliders: &mut ColliderSet,
82    ) {
83        for i in 0..bodies.modified_bodies.len() {
84            let handle = bodies.modified_bodies[i];
85            let Some(rb) = bodies.get_mut_internal(handle) else {
86                continue;
87            };
88            if !rb.is_enabled() {
89                continue;
90            }
91
92            let position_ok = rb.pos.position.is_finite();
93            let next_position_ok = rb.pos.next_position.is_finite();
94            if position_ok && next_position_ok && rb.vels.is_finite() {
95                continue;
96            }
97
98            // Repair the pose from its surviving finite half, if any (e.g. only the kinematic
99            // target was invalid).
100            if position_ok && !next_position_ok {
101                rb.pos.next_position = rb.pos.position;
102            } else if !position_ok && next_position_ok {
103                rb.pos.position = rb.pos.next_position;
104            }
105            Self::sanitize_body_dynamics(rb);
106            rb.set_enabled(false);
107            self.bodies.push(handle);
108        }
109
110        for i in 0..colliders.modified_colliders.len() {
111            let handle = colliders.modified_colliders[i];
112            let Some(co) = colliders.get_mut_internal(handle) else {
113                continue;
114            };
115            if !co.is_enabled() {
116                continue;
117            }
118
119            let local_aabb = co.shape.compute_local_aabb();
120            if co.pos.0.is_finite()
121                && local_aabb.mins.is_finite()
122                && local_aabb.maxs.is_finite()
123                && co
124                    .parent
125                    .as_ref()
126                    .is_none_or(|p| p.pos_wrt_parent.is_finite())
127            {
128                continue;
129            }
130
131            co.set_enabled(false);
132            self.colliders.push(handle);
133        }
134    }
135
136    /// Applies the quarantines detected by the last `advance_to_final_positions` call, which
137    /// left the flagged bodies un-advanced (pose, collider positions and mass-properties still
138    /// broad-phase-consistent), so rolling back only means discarding `next_position`.
139    /// Velocities are zeroed immediately so remaining CCD substeps can't spread them; the
140    /// disable is processed next step, like a user `set_enabled(false)` between steps.
141    pub(super) fn apply_end_step(
142        &mut self,
143        bodies: &mut RigidBodySet,
144        colliders: &mut ColliderSet,
145    ) {
146        if self.body_workspace.is_empty() && self.collider_workspace.is_empty() {
147            return;
148        }
149
150        let mut body_workspace = core::mem::take(&mut self.body_workspace);
151        for (handle, prev_pose) in body_workspace.drain(..) {
152            let Some(rb) = bodies.get_mut_internal_with_modification_tracking(handle) else {
153                continue;
154            };
155
156            if prev_pose.is_finite() {
157                rb.pos.position = prev_pose;
158                rb.pos.next_position = prev_pose;
159            } else if rb.pos.position.is_finite() {
160                rb.pos.next_position = rb.pos.position;
161            }
162            Self::sanitize_body_dynamics(rb);
163
164            // Later CCD substeps can re-detect the same body; only the first detection reports.
165            if rb.is_enabled() {
166                rb.set_enabled(false);
167                self.bodies.push(handle);
168            }
169        }
170        self.body_workspace = body_workspace;
171
172        let mut collider_workspace = core::mem::take(&mut self.collider_workspace);
173        for handle in collider_workspace.drain(..) {
174            let Some(co) = colliders.get_mut_internal_with_modification_tracking(handle) else {
175                continue;
176            };
177            if co.is_enabled() {
178                co.set_enabled(false);
179                self.colliders.push(handle);
180            }
181        }
182        self.collider_workspace = collider_workspace;
183    }
184}
185
186impl PhysicsPipeline {
187    /// The non-finite state detected and neutralized during the most recent call to
188    /// [`Self::step`].
189    pub fn quarantine(&self) -> &Quarantine {
190        &self.quarantine
191    }
192}
193
194#[cfg(test)]
195mod test {
196    use crate::math::{Real, Vector};
197    use crate::prelude::{ColliderBuilder, FixedJointBuilder, PhysicsWorld, RigidBodyBuilder};
198
199    fn world_with_ground() -> PhysicsWorld {
200        let mut world = PhysicsWorld::new();
201        // A big fixed ball whose top surface is at y = 0 (shape choice is dimension-agnostic).
202        world.insert(
203            RigidBodyBuilder::fixed().translation(Vector::Y * -10.0),
204            ColliderBuilder::ball(10.0),
205        );
206        world
207    }
208
209    fn assert_enabled_bodies_are_finite(world: &PhysicsWorld) {
210        for (handle, rb) in world.rigid_bodies() {
211            if rb.is_enabled() {
212                assert!(
213                    rb.position().is_finite() && rb.vels().is_finite(),
214                    "enabled body {handle:?} has non-finite state"
215                );
216            }
217        }
218    }
219
220    #[test]
221    fn healthy_sim_never_quarantines() {
222        let mut world = world_with_ground();
223        let (handle, _) = world.insert(
224            RigidBodyBuilder::dynamic().translation(Vector::Y * 3.0),
225            ColliderBuilder::ball(0.5),
226        );
227
228        for _ in 0..60 {
229            world.step();
230            assert!(world.quarantine().bodies().is_empty());
231            assert!(world.quarantine().colliders().is_empty());
232        }
233
234        assert!(world.bodies[handle].position().is_finite());
235    }
236
237    #[test]
238    fn user_set_nan_position_is_quarantined() {
239        let mut world = world_with_ground();
240        let (poisoned, _) = world.insert(
241            RigidBodyBuilder::dynamic().translation(Vector::Y * 3.0),
242            ColliderBuilder::ball(0.5),
243        );
244        let (healthy, _) = world.insert(
245            RigidBodyBuilder::dynamic().translation(Vector::X * 5.0 + Vector::Y * 3.0),
246            ColliderBuilder::ball(0.5),
247        );
248        world.step();
249
250        world.bodies[poisoned].set_translation(Vector::NAN, true);
251        world.step();
252
253        assert_eq!(world.quarantine().bodies(), &[poisoned]);
254        assert!(!world.bodies[poisoned].is_enabled());
255        assert!(world.bodies[healthy].is_enabled());
256
257        // The report only covers the last step, and the simulation keeps running.
258        for _ in 0..10 {
259            world.step();
260            assert!(world.quarantine().bodies().is_empty());
261            assert_enabled_bodies_are_finite(&world);
262        }
263    }
264
265    #[test]
266    fn user_set_infinite_position_is_quarantined() {
267        let mut world = world_with_ground();
268        let (poisoned, _) = world.insert(
269            RigidBodyBuilder::dynamic().translation(Vector::Y * 3.0),
270            ColliderBuilder::ball(0.5),
271        );
272        world.step();
273
274        world.bodies[poisoned].set_translation(Vector::Y * Real::INFINITY, true);
275        world.step();
276
277        assert_eq!(world.quarantine().bodies(), &[poisoned]);
278        assert!(!world.bodies[poisoned].is_enabled());
279        for _ in 0..10 {
280            world.step();
281            assert_enabled_bodies_are_finite(&world);
282        }
283    }
284
285    #[test]
286    fn user_set_nan_linvel_is_quarantined() {
287        let mut world = world_with_ground();
288        let (poisoned, _) = world.insert(
289            RigidBodyBuilder::dynamic().translation(Vector::Y * 3.0),
290            ColliderBuilder::ball(0.5),
291        );
292        world.step();
293        let pose_before = *world.bodies[poisoned].position();
294
295        world.bodies[poisoned].set_linvel(Vector::NAN, true);
296        world.step();
297
298        assert_eq!(world.quarantine().bodies(), &[poisoned]);
299        let rb = &world.bodies[poisoned];
300        assert!(!rb.is_enabled());
301        // The velocity was neutralized before it could corrupt the pose.
302        assert_eq!(rb.linvel(), Vector::ZERO);
303        assert_eq!(*rb.position(), pose_before);
304    }
305
306    #[test]
307    fn nan_force_is_quarantined_at_end_of_step() {
308        let mut world = world_with_ground();
309        let (poisoned, _) = world.insert(
310            RigidBodyBuilder::dynamic().translation(Vector::Y * 3.0),
311            ColliderBuilder::ball(0.5),
312        );
313        world.step();
314        let pose_before = *world.bodies[poisoned].position();
315
316        // A NaN force is invisible at the start of the step; integration turns it into a NaN
317        // velocity and pose mid-step, which the end-of-step chokepoint must catch and roll back.
318        world.bodies[poisoned].add_force(Vector::NAN, true);
319        world.step();
320
321        assert_eq!(world.quarantine().bodies(), &[poisoned]);
322        let rb = &world.bodies[poisoned];
323        assert!(!rb.is_enabled());
324        assert_eq!(rb.linvel(), Vector::ZERO);
325        // Rolled back to the pose it had at the beginning of the poisoned step.
326        assert_eq!(*rb.position(), pose_before);
327
328        for _ in 0..10 {
329            world.step();
330            assert_enabled_bodies_are_finite(&world);
331        }
332    }
333
334    #[test]
335    fn nan_spread_through_contacts_is_contained() {
336        let mut world = world_with_ground();
337        let (bottom, _) = world.insert(
338            RigidBodyBuilder::dynamic().translation(Vector::Y * 0.5),
339            ColliderBuilder::ball(0.5),
340        );
341        let (_top, _) = world.insert(
342            RigidBodyBuilder::dynamic().translation(Vector::Y * 1.5),
343            ColliderBuilder::ball(0.5),
344        );
345        // Let the stack settle into persistent contacts.
346        for _ in 0..30 {
347            world.step();
348            assert!(world.quarantine().bodies().is_empty());
349        }
350
351        world.bodies[bottom].add_force(Vector::NAN, true);
352        world.step();
353
354        // The invalid value may legitimately infect the whole island through the contact solver
355        // before the end-of-step catch; containment means every infected body is quarantined and
356        // nothing else is corrupted.
357        assert!(world.quarantine().bodies().contains(&bottom));
358        assert_enabled_bodies_are_finite(&world);
359
360        for _ in 0..10 {
361            world.step();
362            assert!(world.quarantine().bodies().is_empty());
363            assert_enabled_bodies_are_finite(&world);
364        }
365    }
366
367    #[test]
368    fn joint_partner_survives_user_set_nan_pose() {
369        let mut world = world_with_ground();
370        let (poisoned, _) = world.insert(
371            RigidBodyBuilder::dynamic().translation(Vector::Y * 3.0),
372            ColliderBuilder::ball(0.5),
373        );
374        let (partner, _) = world.insert(
375            RigidBodyBuilder::dynamic().translation(Vector::Y * 4.0),
376            ColliderBuilder::ball(0.5),
377        );
378        world.insert_impulse_joint(poisoned, partner, FixedJointBuilder::new());
379        world.step();
380
381        // Set between steps: the step-start chokepoint catches it before the joint solver can
382        // spread it to the partner.
383        world.bodies[poisoned].set_translation(Vector::NAN, true);
384        world.step();
385
386        assert_eq!(world.quarantine().bodies(), &[poisoned]);
387        assert!(world.bodies[partner].is_enabled());
388        for _ in 0..10 {
389            world.step();
390            assert!(world.quarantine().bodies().is_empty());
391            assert_enabled_bodies_are_finite(&world);
392        }
393    }
394
395    #[test]
396    fn nan_kinematic_target_is_quarantined() {
397        let mut world = world_with_ground();
398        let (poisoned, _) = world.insert(
399            RigidBodyBuilder::kinematic_position_based().translation(Vector::Y * 3.0),
400            ColliderBuilder::ball(0.5),
401        );
402        world.step();
403        let pose_before = *world.bodies[poisoned].position();
404
405        world.bodies[poisoned].set_next_kinematic_translation(Vector::NAN);
406        world.step();
407
408        assert_eq!(world.quarantine().bodies(), &[poisoned]);
409        let rb = &world.bodies[poisoned];
410        assert!(!rb.is_enabled());
411        // Only the kinematic target was invalid; the pose was repaired from its valid half.
412        assert_eq!(*rb.position(), pose_before);
413    }
414
415    #[test]
416    fn nan_shape_standalone_collider_is_quarantined() {
417        let mut world = world_with_ground();
418        let poisoned = world.insert_collider(ColliderBuilder::ball(Real::NAN), None);
419        let (body, _) = world.insert(
420            RigidBodyBuilder::dynamic().translation(Vector::Y * 3.0),
421            ColliderBuilder::ball(0.5),
422        );
423
424        for _ in 0..10 {
425            world.step();
426            assert_enabled_bodies_are_finite(&world);
427        }
428        assert!(!world.colliders[poisoned].is_enabled());
429        assert!(world.bodies[body].is_enabled());
430    }
431
432    #[test]
433    fn nan_shape_attached_collider_is_quarantined_but_body_survives() {
434        let mut world = world_with_ground();
435        let (body, poisoned) = world.insert(
436            RigidBodyBuilder::dynamic().translation(Vector::Y * 3.0),
437            ColliderBuilder::ball(Real::NAN),
438        );
439
440        for _ in 0..10 {
441            world.step();
442            assert_enabled_bodies_are_finite(&world);
443        }
444        // The collider is neutralized; the body itself keeps simulating (in free fall,
445        // since it lost its only collider).
446        assert!(!world.colliders[poisoned].is_enabled());
447        let rb = &world.bodies[body];
448        assert!(rb.is_enabled());
449        assert!(rb.position().is_finite());
450    }
451
452    #[test]
453    fn quarantined_body_can_be_resurrected() {
454        let mut world = world_with_ground();
455        let (poisoned, _) = world.insert(
456            RigidBodyBuilder::dynamic().translation(Vector::Y * 3.0),
457            ColliderBuilder::ball(0.5),
458        );
459        world.step();
460        world.bodies[poisoned].add_force(Vector::NAN, true);
461        world.step();
462        assert_eq!(world.quarantine().bodies(), &[poisoned]);
463
464        // The quarantine left the body with a finite pose and cleared the poisoned force, so
465        // re-enabling it resumes a healthy simulation.
466        let rb = &mut world.bodies[poisoned];
467        rb.set_translation(Vector::Y * 3.0, true);
468        rb.set_enabled(true);
469
470        for _ in 0..30 {
471            world.step();
472            assert!(world.quarantine().bodies().is_empty());
473            assert_enabled_bodies_are_finite(&world);
474        }
475        assert!(world.bodies[poisoned].is_enabled());
476    }
477}