Skip to main content

rapier2d/geometry/broad_phase_bvh/
update.rs

1//! The broad-phase update pass: BVH leaf maintenance (refit-free re-inserts,
2//! partial/full refits, deferred optimization scheduling) plus the pair
3//! creation and stale-pair removal bookkeeping.
4
5use super::{BroadPhaseBvh, BvhOptimizationStrategy};
6use crate::alloc_prelude::*;
7use crate::dynamics::{IntegrationParameters, RigidBodySet, RigidBodyType};
8use crate::geometry::Collider;
9use crate::geometry::{
10    Aabb, BroadPhasePairEvent, ColliderChanges, ColliderHandle, ColliderPair, ColliderSet,
11};
12use crate::math::Real;
13use parry::partitioning::BvhLeafUpdateStatus;
14
15impl BroadPhaseBvh {
16    /// Updates the broad-phase.
17    ///
18    /// The results are output through the `events` struct. The broad-phase algorithm is only
19    /// required to generate new events (i.e. no need to re-send an `AddPair` event if it was already
20    /// sent previously and no `RemovePair` happened since then). Sending redundant events is allowed
21    /// but can result in a slight computational overhead.
22    ///
23    /// # Parameters
24    /// - `params`: the integration parameters governing the simulation.
25    /// - `colliders`: the set of colliders. Change detection with `collider.needs_broad_phase_update()`
26    ///   can be relied on at this stage.
27    /// - `modified_colliders`: colliders that are know to be modified since the last update.
28    /// - `removed_colliders`: colliders that got removed since the last update. Any associated data
29    ///   in the broad-phase should be removed by this call to `update`.
30    /// - `events`: the broad-phase’s output. They indicate what collision pairs need to be created
31    ///   and what pairs need to be removed. It is OK to create pairs for colliders that don’t
32    ///   actually collide (though this can increase computational overhead in the narrow-phase)
33    ///   but it is important not to indicate removal of a collision pair if the underlying colliders
34    ///   are still touching or closer than `prediction_distance`.
35    pub fn update(
36        &mut self,
37        params: &IntegrationParameters,
38        colliders: &ColliderSet,
39        bodies: &RigidBodySet,
40        modified_colliders: &[ColliderHandle],
41        removed_colliders: &[ColliderHandle],
42        events: &mut Vec<BroadPhasePairEvent>,
43    ) {
44        self.frame_index = self.frame_index.overflowing_add(1).0;
45
46        // If the previous update requested a deferred optimization but nothing ran it
47        // (e.g. the broad-phase is driven without the physics pipeline), run it now.
48        if self.deferred_optimize_pending {
49            self.deferred_optimize_pending = false;
50            super::run_bvh_optimize(&mut self.tree, &mut self.workspace);
51        }
52
53        // Removals must be handled first, in case another collider in
54        // `modified_colliders` shares the same index.
55        for handle in removed_colliders {
56            self.tree.remove(handle.into_raw_parts().0);
57        }
58
59        let first_pass = self.tree.is_empty();
60
61        self.updated_colliders.clear();
62        self.curr_updated_leaves.clear();
63
64        // Colliders updated through `set_aabb` since the last update already have an
65        // up-to-date tree leaf, but must still be taken into account for change-flag
66        // resolution and stale-pair detection.
67        for handle in self.pending_set_aabb.drain(..) {
68            if colliders.contains(handle) {
69                self.updated_colliders.push(handle);
70                self.curr_updated_leaves.push(handle.into_raw_parts().0);
71            }
72        }
73
74        // Colliders whose pair-filter inputs may have flipped (re-parent, parent type,
75        // collision groups) get their leaf removed and re-inserted as brand-new, so the
76        // traversal re-reports every pair the filter suppressed before. Skipped for leaves
77        // not in the tree yet.
78        let mut forced_reinsertion = false;
79        for handle in modified_colliders {
80            if let Some(co) = colliders.get(*handle) {
81                let leaf_index = handle.into_raw_parts().0;
82                if co.is_enabled()
83                    && co.changes.intersects(
84                        ColliderChanges::PARENT
85                            | ColliderChanges::PARENT_EFFECTIVE_DOMINANCE
86                            | ColliderChanges::GROUPS,
87                    )
88                    && self.tree.leaf_node(leaf_index).is_some()
89                {
90                    self.tree.remove(leaf_index);
91                    forced_reinsertion = true;
92                }
93            }
94        }
95
96        // The AABB (and margin) computation is the expensive part of the leaf-update
97        // loop; precompute it in parallel and keep only the tree writes sequential.
98        let mut update_scratch = core::mem::take(&mut self.update_scratch);
99        update_scratch.clear();
100
101        let compute_update = |modified: &ColliderHandle| -> Option<(ColliderHandle, Aabb, Real)> {
102            let collider = colliders.get(*modified)?;
103            // `PARENT_EFFECTIVE_DOMINANCE` and `GROUPS` are NF-only in general, but the
104            // forced leaf-removal pre-pass above targets exactly these colliders: they
105            // MUST be re-inserted here or their leaf would be lost.
106            if !collider.is_enabled()
107                || !(collider.changes.needs_broad_phase_update()
108                    || collider.changes.intersects(
109                        ColliderChanges::PARENT_EFFECTIVE_DOMINANCE | ColliderChanges::GROUPS,
110                    ))
111            {
112                return None;
113            }
114
115            let aabb = collider.compute_broad_phase_aabb(params, bodies);
116            // A non-finite AABB would corrupt the tree (NaN breaks the partitioning
117            // invariants); skip it and let the pipeline's end-of-step quarantine handle it.
118            if !(aabb.mins.is_finite() && aabb.maxs.is_finite()) {
119                return None;
120            }
121            let change_detection_skin = self.change_detection_skin(params, &aabb);
122
123            Some((*modified, aabb, change_detection_skin))
124        };
125
126        #[cfg(feature = "parallel")]
127        {
128            // TODO(PERF): avoid the systematic Vec<Vec<_>> allocation?
129            use rayon::prelude::*;
130            let precomputed: Vec<Vec<_>> = modified_colliders
131                .par_chunks(1024)
132                .map(|chunk| chunk.iter().filter_map(compute_update).collect())
133                .collect();
134            update_scratch.extend(precomputed.into_iter().flatten());
135        }
136        #[cfg(not(feature = "parallel"))]
137        update_scratch.extend(modified_colliders.iter().filter_map(compute_update));
138
139        // Small change volumes relocate moved leaves via SAH re-insertion (O(log n) per leaf):
140        // tree quality maintains itself and the periodic O(tree) optimizer never runs — what keeps
141        // huge mostly-static scenes free of multi-ms spikes. Bulk volumes keep O(1) in-place updates.
142        let leaf_count = self.tree.leaf_count() as usize;
143        let use_reinsert =
144            self.reinsert_leaf_updates && update_scratch.len() * 16 < leaf_count && !first_pass;
145
146        // In-place leaf updates apply in parallel; the change-flag bookkeeping
147        // below stays sequential (it's a cheap push per *changed* leaf).
148        #[cfg(feature = "parallel")]
149        let parallel_leaf_updates = !use_reinsert;
150        #[cfg(feature = "parallel")]
151        if parallel_leaf_updates {
152            self.update_batch_scratch.clear();
153            self.update_batch_scratch.extend(
154                update_scratch
155                    .iter()
156                    .map(|(handle, aabb, skin)| (*aabb, handle.into_raw_parts().0, *skin)),
157            );
158            self.tree.insert_or_update_batch_partially_parallel(
159                &self.update_batch_scratch,
160                &mut self.update_batch_statuses,
161            );
162
163            for ((modified, _, _), status) in
164                update_scratch.iter().zip(self.update_batch_statuses.iter())
165            {
166                let leaf_index = modified.into_raw_parts().0;
167                match status {
168                    BvhLeafUpdateStatus::Unchanged => {}
169                    BvhLeafUpdateStatus::UpdatedInPlace | BvhLeafUpdateStatus::Inserted => {
170                        if *status == BvhLeafUpdateStatus::UpdatedInPlace {
171                            self.changes_since_optimize =
172                                self.changes_since_optimize.saturating_add(1);
173                        }
174                        self.updated_colliders.push(*modified);
175                        self.curr_updated_leaves.push(leaf_index);
176                    }
177                }
178            }
179        }
180
181        #[cfg(feature = "parallel")]
182        let sequential_leaf_updates = !parallel_leaf_updates;
183        #[cfg(not(feature = "parallel"))]
184        let sequential_leaf_updates = true;
185
186        #[allow(clippy::collapsible_if)]
187        if sequential_leaf_updates {
188            // Two passes, mirroring `insert_or_update_batch_partially_parallel`: every
189            // existing leaf is updated before any structural insertion, so an insertion's
190            // SAH descent (and the rotations it applies) sees all of this step's AABBs
191            // rather than a half-updated tree. The batch path cannot interleave the two
192            // (its updates run concurrently), so this one must not either — a step that
193            // mixes moved colliders with newly added ones would otherwise build a
194            // different tree here than it does there.
195            let mut deferred_inserts: Vec<usize> = Vec::new();
196            for (i, (modified, aabb, change_detection_skin)) in update_scratch.iter().enumerate() {
197                let leaf_index = modified.into_raw_parts().0;
198                // `..._if_present` reports a missing leaf through the lookup it already
199                // performs, so deferring insertions costs no extra probe.
200                let status = if use_reinsert {
201                    self.tree.reinsert_or_update_if_present(
202                        *aabb,
203                        leaf_index,
204                        *change_detection_skin,
205                    )
206                } else {
207                    self.tree
208                        .update_partially_if_present(*aabb, leaf_index, *change_detection_skin)
209                };
210                let Some(status) = status else {
211                    deferred_inserts.push(i);
212                    continue;
213                };
214                match status {
215                    // New AABB still inside the leaf's fattened AABB: tree untouched. No
216                    // refit needed, no new pairs possible (traversal only visits changed
217                    // leaves), no pair invalidation (deletion requires a changed leaf).
218                    BvhLeafUpdateStatus::Unchanged => {}
219                    BvhLeafUpdateStatus::UpdatedInPlace | BvhLeafUpdateStatus::Inserted => {
220                        // Only in-place updates degrade the tree quality
221                        // (re-insertions self-optimize, fresh insertions pick their
222                        // spot by SAH descent).
223                        if !use_reinsert && status == BvhLeafUpdateStatus::UpdatedInPlace {
224                            self.changes_since_optimize =
225                                self.changes_since_optimize.saturating_add(1);
226                        }
227                        self.updated_colliders.push(*modified);
228                        self.curr_updated_leaves.push(leaf_index);
229                    }
230                }
231            }
232
233            // Pass 2: the structural insertions, in `update_scratch` order. Fresh
234            // insertions pick their spot by SAH descent, so they never count toward the
235            // optimizer's debt, and their status is always `Inserted`.
236            for i in deferred_inserts {
237                let (modified, aabb, change_detection_skin) = &update_scratch[i];
238                let leaf_index = modified.into_raw_parts().0;
239                let status =
240                    self.tree
241                        .insert_or_update_partially(*aabb, leaf_index, *change_detection_skin);
242                debug_assert_eq!(status, BvhLeafUpdateStatus::Inserted);
243                self.updated_colliders.push(*modified);
244                self.curr_updated_leaves.push(leaf_index);
245            }
246        }
247
248        self.update_scratch = update_scratch;
249
250        // The incremental optimizer (and its O(tree) full refit) only runs when enough quality-degrading
251        // changes accumulated: every frame under bulk volumes, every 8th for moderate, never for small
252        // ones (SAH re-insertion accrues no debt — mostly-static scenes stay O(moving set)).
253        let num_updated = self.updated_colliders.len();
254        // Hysteresis for the re-insertion regime: `set_aabb` calls arriving before
255        // the next `update` need the decision upfront, so it is based on this
256        // step's change volume.
257        self.reinsert_leaf_updates = num_updated * 16 < self.tree.leaf_count() as usize;
258        self.changes_since_optimize = self
259            .changes_since_optimize
260            .saturating_add(removed_colliders.len() as u32);
261        let run_optimizer = self.changes_since_optimize > 0
262            && (num_updated * 16 >= leaf_count
263                || (self.frame_index % 8 == 0
264                    && self.changes_since_optimize as usize * 64 >= leaf_count))
265            && self.optimization_strategy == BvhOptimizationStrategy::SubtreeOptimizer;
266
267        // The optimizer is quality-only: defer it (plus its flag-preserving refit) to overlap the narrow
268        // phase and solver; inline only when a full refit is needed anyway. Insertions need
269        // NO full refit (`Bvh` maintains ancestor AABBs/counts; `refit_partial` resolves their flags — vital for huge mostly-static scenes); removals do (flag raw-merge into ancestors + orphaned wide nodes).
270        let must_full_refit = first_pass || !removed_colliders.is_empty() || forced_reinsertion;
271        let defer_optimize = run_optimizer && !must_full_refit;
272
273        if run_optimizer {
274            // The deferred pass is scheduled to run before the next update, so both
275            // cases leave the tree freshly optimized.
276            self.changes_since_optimize = 0;
277        }
278
279        if run_optimizer && !defer_optimize {
280            self.tree.optimize_incremental(&mut self.workspace);
281        }
282
283        // NOTE: refit runs after optimization (skips internal-node updates there; allows the depth-first
284        // cache-friendly reorder). Full refit is O(node count); with only leaf updates/insertions/relocations,
285        // a partial refit visits just the ancestors of BOTH frames' changed leaves (flag clearing) — serial, so full is cheaper when most leaves changed.
286        let partial_refit_too_expensive =
287            (num_updated + self.prev_updated_leaves.len()) * 16 >= self.tree.leaf_count() as usize;
288        let full_refit =
289            must_full_refit || (run_optimizer && !defer_optimize) || partial_refit_too_expensive;
290        if full_refit {
291            #[cfg(feature = "parallel")]
292            self.tree.refit_parallel(&mut self.workspace);
293            #[cfg(not(feature = "parallel"))]
294            self.tree.refit(&mut self.workspace);
295        } else {
296            self.tree
297                .refit_partial(&self.prev_updated_leaves, &self.curr_updated_leaves);
298        }
299        core::mem::swap(&mut self.prev_updated_leaves, &mut self.curr_updated_leaves);
300
301        self.deferred_optimize_pending |= defer_optimize;
302
303        // The tree walk dominates the pair traversal, so walk it in parallel when there are
304        // threads for it — parry pins the parallel walk to the sequential walk's exact pair
305        // order — then pre-filter the reported pairs with read-only map probes so the
306        // sequential tail only pays for genuinely new pairs.
307        //
308        // The probe is read-only in every build. The alternative (a sequential collector
309        // refreshing each visited pair's timestamp, so stale-pair detection could skip it
310        // with an integer compare) cannot run concurrently, and its map writes are part of
311        // the serialized broad-phase state: keeping it would make the two builds' snapshots
312        // differ even on an identical simulation.
313        #[cfg(feature = "parallel")]
314        let candidates = self
315            .tree
316            .traverse_bvtt_single_tree_parallel::<{ Self::CHANGE_DETECTION_ENABLED }>();
317
318        #[cfg(not(feature = "parallel"))]
319        let candidates = {
320            // Reused across steps: the sequential walk reports through a closure, so
321            // collecting it into the same shape the parallel walk returns costs nothing
322            // beyond the (amortized) buffer.
323            let mut candidates = core::mem::take(&mut self.candidates_scratch);
324            candidates.clear();
325            self.tree
326                .traverse_bvtt_single_tree::<{ Self::CHANGE_DETECTION_ENABLED }>(
327                    &mut self.workspace,
328                    &mut |co1, co2| candidates.push((co1, co2)),
329                );
330            candidates
331        };
332
333        {
334            let filter_new =
335                |&(co1, co2): &(u32, u32)| -> Option<(ColliderHandle, ColliderHandle)> {
336                    debug_assert_ne!(co1, co2);
337                    let (mut collider1, mut handle1) = colliders.get_unknown_gen(co1)?;
338                    let (mut collider2, mut handle2) = colliders.get_unknown_gen(co2)?;
339
340                    if co1 > co2 {
341                        core::mem::swap(&mut handle1, &mut handle2);
342                        core::mem::swap(&mut collider1, &mut collider2);
343                    }
344
345                    // Same-parent colliders never collide; keeping their pairs out of the
346                    // pair map and contact graph keeps bodies with many mutually-overlapping
347                    // colliders from flooding the narrow phase (issue #970). Reparenting
348                    // re-discovers via the forced re-insertion pre-pass (`PARENT` above),
349                    // and the narrow phase's own per-update same-parent check handles pairs
350                    // whose colliders become same-parent after creation.
351                    if let (Some(p1), Some(p2)) = (&collider1.parent, &collider2.parent) {
352                        if p1.handle == p2.handle {
353                            return None;
354                        }
355                    }
356
357                    if self.pairs.contains_key(&(handle1, handle2)) {
358                        return None;
359                    }
360
361                    // Never create a pair the narrow phase's `ActiveCollisionTypes` or
362                    // collision-groups filters would drop anyway (keeps big static environments
363                    // and dense group-filtered scenes from flooding the contact graph); later
364                    // filter-input changes re-discover via the forced re-insertion pre-pass.
365                    // NOTE: `solver_groups` must NOT be tested here: solver-filtered pairs
366                    // still produce contact events.
367                    let rb_type = |co: &Collider| {
368                        co.parent
369                            .and_then(|p| bodies.get(p.handle))
370                            .map(|rb| rb.body_type)
371                            .unwrap_or(RigidBodyType::Fixed)
372                    };
373                    let rb_type1 = rb_type(collider1);
374                    let rb_type2 = rb_type(collider2);
375                    if !collider1
376                        .flags
377                        .active_collision_types
378                        .test(rb_type1, rb_type2)
379                        && !collider2
380                            .flags
381                            .active_collision_types
382                            .test(rb_type1, rb_type2)
383                    {
384                        return None;
385                    }
386
387                    if !collider1
388                        .flags
389                        .collision_groups
390                        .test(collider2.flags.collision_groups)
391                    {
392                        return None;
393                    }
394
395                    Some((handle1, handle2))
396                };
397
398            // rayon's ordered collect keeps the new pairs in traversal order, so the pair
399            // set, adjacency lists and emitted events stay deterministic — and identical to
400            // the sequential filter below.
401            // TODO(perf): avoid systematic `Vec` allocation.
402            #[cfg(feature = "parallel")]
403            let new_pairs: Vec<(ColliderHandle, ColliderHandle)> = {
404                use rayon::prelude::*;
405                candidates
406                    .par_chunks(512)
407                    .flat_map_iter(|chunk| chunk.iter().filter_map(filter_new))
408                    .collect()
409            };
410            #[cfg(not(feature = "parallel"))]
411            let new_pairs: Vec<(ColliderHandle, ColliderHandle)> =
412                candidates.iter().filter_map(filter_new).collect();
413
414            for (handle1, handle2) in new_pairs {
415                let prev = self.pairs.insert((handle1, handle2), self.frame_index);
416                debug_assert!(prev.is_none());
417                self.pair_adjacency
418                    .ensure_element_exist(handle1.0, Vec::new())
419                    .push(handle2);
420                self.pair_adjacency
421                    .ensure_element_exist(handle2.0, Vec::new())
422                    .push(handle1);
423                events.push(BroadPhasePairEvent::AddPair(ColliderPair::new(
424                    handle1, handle2,
425                )));
426            }
427        }
428
429        #[cfg(not(feature = "parallel"))]
430        {
431            self.candidates_scratch = candidates;
432        }
433
434        /*
435         *
436         * Stale pairs handling (+ pairs removed events).
437         *
438         */
439        // TODO(refactor): looks more complex than it could be.
440
441        // Find outdated entries. A pair can only stop overlapping if one of its colliders
442        // changed in the tree, so only pairs adjacent to updated/removed colliders are
443        // checked. (A linear scan of the whole pair map used to be the fallback when most
444        // colliders moved, but it was only worth it thanks to a per-pair timestamp
445        // refreshed by the sequential pair collector — a map write the parallel collector
446        // cannot do, and part of the serialized broad-phase state. One scan for every
447        // build is both simpler and what the parallel build already did.)
448        self.stale_pairs.clear();
449
450        // Pairs involving a removed collider are always dropped (without emitting an
451        // event, matching the behavior of the narrow-phase which handles removed
452        // colliders on its own).
453        for handle in removed_colliders {
454            if let Some(mut others) = self.pair_adjacency.remove(handle.0, Vec::new()) {
455                for other in others.drain(..) {
456                    self.stale_pairs.push((*handle, other, false));
457                }
458            }
459        }
460
461        // Adjacency scan: pure read-only lookups, stale candidates flattened in `updated_colliders`
462        // order (deterministic). No map probe: adjacency membership implies map membership, and the
463        // sequential application tolerates duplicates via `pairs.remove`. No timestamp fast-path —
464        // the tree's geometry is the only input, which is what lets it run concurrently.
465        // Each pair sits in both its colliders' adjacency lists, so a pair whose two
466        // sides both moved would be examined twice — the common case in a dense scene,
467        // and the work the old timestamp fast-path used to hide. Visit those from the
468        // lower-index side only. Pairs with one static side keep being visited from their
469        // moving side. (Duplicates were harmless — the application loop dedups through
470        // `pairs.remove` — so dropping them changes no result.)
471        self.updated_mask.clear();
472        self.updated_mask.resize(
473            self.updated_colliders
474                .iter()
475                .map(|h| h.into_raw_parts().0 as usize + 1)
476                .max()
477                .unwrap_or(0),
478            false,
479        );
480        for handle in &self.updated_colliders {
481            self.updated_mask[handle.into_raw_parts().0 as usize] = true;
482        }
483
484        {
485            let tree = &self.tree;
486            let pair_adjacency = &self.pair_adjacency;
487            let updated_mask = &self.updated_mask;
488            let scan =
489                |handle: &ColliderHandle, out: &mut Vec<(ColliderHandle, ColliderHandle, bool)>| {
490                    let Some(others) = pair_adjacency.get(handle.0) else {
491                        return;
492                    };
493
494                    // Fetched once for the whole adjacency list: the per-pair lookups
495                    // are random accesses into the node array, and half of them are this
496                    // same leaf. Both tests below are symmetric, so pulling one side out
497                    // does not change the outcome.
498                    let node_self = tree.leaf_node(handle.into_raw_parts().0);
499
500                    let self_index = handle.into_raw_parts().0;
501
502                    for other in others {
503                        let other_index = other.into_raw_parts().0;
504                        if self_index > other_index
505                            && updated_mask
506                                .get(other_index as usize)
507                                .copied()
508                                .unwrap_or(false)
509                        {
510                            // Both sides moved: this pair is visited from `other`.
511                            continue;
512                        }
513
514                        let (h0, h1) = if self_index > other_index {
515                            (*other, *handle)
516                        } else {
517                            (*handle, *other)
518                        };
519
520                        let Some(node0) = node_self else {
521                            out.push((h0, h1, false));
522                            continue;
523                        };
524                        let Some(node1) = tree.leaf_node(other_index) else {
525                            out.push((h0, h1, false));
526                            continue;
527                        };
528
529                        if (!Self::CHANGE_DETECTION_ENABLED
530                            || node0.is_changed()
531                            || node1.is_changed())
532                            && !node0.intersects(node1)
533                        {
534                            out.push((h0, h1, true));
535                        }
536                    }
537                };
538
539            // rayon's ordered `par_extend` appends the chunks in order, so the flattened
540            // result matches the sequential scan below element for element.
541            #[cfg(feature = "parallel")]
542            {
543                use rayon::prelude::*;
544                let mut stale_pairs = core::mem::take(&mut self.stale_pairs);
545                stale_pairs.par_extend(self.updated_colliders.par_chunks(256).flat_map_iter(
546                    |chunk| {
547                        // TODO(perf): avoid these Vec allocations?
548                        let mut out = Vec::new();
549                        for handle in chunk {
550                            scan(handle, &mut out);
551                        }
552                        out
553                    },
554                ));
555                self.stale_pairs = stale_pairs;
556            }
557
558            #[cfg(not(feature = "parallel"))]
559            {
560                let mut stale_pairs = core::mem::take(&mut self.stale_pairs);
561                for handle in &self.updated_colliders {
562                    scan(handle, &mut stale_pairs);
563                }
564                self.stale_pairs = stale_pairs;
565            }
566        }
567
568        // Canonical order: which detection variant ran (and its iteration order —
569        // hash-map order for the full scan) must not leak into the `DeletePair`
570        // sequence, which decides contact-graph edge-id reuse.
571        self.stale_pairs
572            .sort_unstable_by_key(|&(h0, h1, emit_event)| {
573                let a = h0.into_raw_parts().0;
574                let b = h1.into_raw_parts().0;
575                (a.min(b), a.max(b), emit_event)
576            });
577
578        for i in 0..self.stale_pairs.len() {
579            let (h0, h1, emit_event) = self.stale_pairs[i];
580            let (h0, h1) = if h0.into_raw_parts().0 > h1.into_raw_parts().0 {
581                (h1, h0)
582            } else {
583                (h0, h1)
584            };
585
586            // The `remove` check also deduplicates: the same pair can be pushed twice
587            // if both its colliders changed this frame.
588            if crate::utils::hashmap_remove(&mut self.pairs, &(h0, h1)).is_some() {
589                for (ha, hb) in [(h0, h1), (h1, h0)] {
590                    if let Some(others) = self.pair_adjacency.get_mut(ha.0) {
591                        if let Some(pos) = others.iter().position(|h| *h == hb) {
592                            others.swap_remove(pos);
593                        }
594                    }
595                }
596
597                if emit_event {
598                    events.push(BroadPhasePairEvent::DeletePair(ColliderPair::new(h0, h1)));
599                }
600            }
601        }
602    }
603}