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_workspace = core::mem::take(&mut self.update_workspace);
99        update_workspace.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_workspace.extend(precomputed.into_iter().flatten());
135        }
136        #[cfg(not(feature = "parallel"))]
137        update_workspace.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_workspace.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_workspace.clear();
153            self.update_batch_workspace.extend(
154                update_workspace
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_workspace,
160                &mut self.update_batch_statuses,
161            );
162
163            for ((modified, _, _), status) in update_workspace
164                .iter()
165                .zip(self.update_batch_statuses.iter())
166            {
167                let leaf_index = modified.into_raw_parts().0;
168                match status {
169                    BvhLeafUpdateStatus::Unchanged => {}
170                    BvhLeafUpdateStatus::UpdatedInPlace | BvhLeafUpdateStatus::Inserted => {
171                        if *status == BvhLeafUpdateStatus::UpdatedInPlace {
172                            self.changes_since_optimize =
173                                self.changes_since_optimize.saturating_add(1);
174                        }
175                        self.updated_colliders.push(*modified);
176                        self.curr_updated_leaves.push(leaf_index);
177                    }
178                }
179            }
180        }
181
182        #[cfg(feature = "parallel")]
183        let sequential_leaf_updates = !parallel_leaf_updates;
184        #[cfg(not(feature = "parallel"))]
185        let sequential_leaf_updates = true;
186
187        #[allow(clippy::collapsible_if)]
188        if sequential_leaf_updates {
189            // Two passes, mirroring `insert_or_update_batch_partially_parallel`: every
190            // existing leaf is updated before any structural insertion, so an insertion's
191            // SAH descent (and the rotations it applies) sees all of this step's AABBs
192            // rather than a half-updated tree. The batch path cannot interleave the two
193            // (its updates run concurrently), so this one must not either — a step that
194            // mixes moved colliders with newly added ones would otherwise build a
195            // different tree here than it does there.
196            let mut deferred_inserts: Vec<usize> = Vec::new();
197            for (i, (modified, aabb, change_detection_skin)) in update_workspace.iter().enumerate()
198            {
199                let leaf_index = modified.into_raw_parts().0;
200                // `..._if_present` reports a missing leaf through the lookup it already
201                // performs, so deferring insertions costs no extra probe.
202                let status = if use_reinsert {
203                    self.tree.reinsert_or_update_if_present(
204                        *aabb,
205                        leaf_index,
206                        *change_detection_skin,
207                    )
208                } else {
209                    self.tree
210                        .update_partially_if_present(*aabb, leaf_index, *change_detection_skin)
211                };
212                let Some(status) = status else {
213                    deferred_inserts.push(i);
214                    continue;
215                };
216                match status {
217                    // New AABB still inside the leaf's fattened AABB: tree untouched. No
218                    // refit needed, no new pairs possible (traversal only visits changed
219                    // leaves), no pair invalidation (deletion requires a changed leaf).
220                    BvhLeafUpdateStatus::Unchanged => {}
221                    BvhLeafUpdateStatus::UpdatedInPlace | BvhLeafUpdateStatus::Inserted => {
222                        // Only in-place updates degrade the tree quality
223                        // (re-insertions self-optimize, fresh insertions pick their
224                        // spot by SAH descent).
225                        if !use_reinsert && status == BvhLeafUpdateStatus::UpdatedInPlace {
226                            self.changes_since_optimize =
227                                self.changes_since_optimize.saturating_add(1);
228                        }
229                        self.updated_colliders.push(*modified);
230                        self.curr_updated_leaves.push(leaf_index);
231                    }
232                }
233            }
234
235            // Pass 2: the structural insertions, in `update_workspace` order. Fresh
236            // insertions pick their spot by SAH descent, so they never count toward the
237            // optimizer's debt, and their status is always `Inserted`.
238            for i in deferred_inserts {
239                let (modified, aabb, change_detection_skin) = &update_workspace[i];
240                let leaf_index = modified.into_raw_parts().0;
241                let status =
242                    self.tree
243                        .insert_or_update_partially(*aabb, leaf_index, *change_detection_skin);
244                debug_assert_eq!(status, BvhLeafUpdateStatus::Inserted);
245                self.updated_colliders.push(*modified);
246                self.curr_updated_leaves.push(leaf_index);
247            }
248        }
249
250        self.update_workspace = update_workspace;
251
252        // The incremental optimizer (and its O(tree) full refit) only runs when enough quality-degrading
253        // changes accumulated: every frame under bulk volumes, every 8th for moderate, never for small
254        // ones (SAH re-insertion accrues no debt — mostly-static scenes stay O(moving set)).
255        let num_updated = self.updated_colliders.len();
256        // Hysteresis for the re-insertion regime: `set_aabb` calls arriving before
257        // the next `update` need the decision upfront, so it is based on this
258        // step's change volume.
259        self.reinsert_leaf_updates = num_updated * 16 < self.tree.leaf_count() as usize;
260        self.changes_since_optimize = self
261            .changes_since_optimize
262            .saturating_add(removed_colliders.len() as u32);
263        let run_optimizer = self.changes_since_optimize > 0
264            && (num_updated * 16 >= leaf_count
265                || (self.frame_index % 8 == 0
266                    && self.changes_since_optimize as usize * 64 >= leaf_count))
267            && self.optimization_strategy == BvhOptimizationStrategy::SubtreeOptimizer;
268
269        // The optimizer is quality-only: defer it (plus its flag-preserving refit) to overlap the narrow
270        // phase and solver; inline only when a full refit is needed anyway. Insertions need
271        // 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).
272        let must_full_refit = first_pass || !removed_colliders.is_empty() || forced_reinsertion;
273        let defer_optimize = run_optimizer && !must_full_refit;
274
275        if run_optimizer {
276            // The deferred pass is scheduled to run before the next update, so both
277            // cases leave the tree freshly optimized.
278            self.changes_since_optimize = 0;
279        }
280
281        if run_optimizer && !defer_optimize {
282            self.tree.optimize_incremental(&mut self.workspace);
283        }
284
285        // NOTE: refit runs after optimization (skips internal-node updates there; allows the depth-first
286        // cache-friendly reorder). Full refit is O(node count); with only leaf updates/insertions/relocations,
287        // a partial refit visits just the ancestors of BOTH frames' changed leaves (flag clearing) — serial, so full is cheaper when most leaves changed.
288        let partial_refit_too_expensive =
289            (num_updated + self.prev_updated_leaves.len()) * 16 >= self.tree.leaf_count() as usize;
290        let full_refit =
291            must_full_refit || (run_optimizer && !defer_optimize) || partial_refit_too_expensive;
292        if full_refit {
293            #[cfg(feature = "parallel")]
294            self.tree.refit_parallel(&mut self.workspace);
295            #[cfg(not(feature = "parallel"))]
296            self.tree.refit(&mut self.workspace);
297        } else {
298            self.tree
299                .refit_partial(&self.prev_updated_leaves, &self.curr_updated_leaves);
300        }
301        core::mem::swap(&mut self.prev_updated_leaves, &mut self.curr_updated_leaves);
302
303        self.deferred_optimize_pending |= defer_optimize;
304
305        // The tree walk dominates the pair traversal, so walk it in parallel when there are
306        // threads for it — parry pins the parallel walk to the sequential walk's exact pair
307        // order — then pre-filter the reported pairs with read-only map probes so the
308        // sequential tail only pays for genuinely new pairs.
309        //
310        // The probe is read-only in every build. The alternative (a sequential collector
311        // updating each visited pair's timestamp, so stale-pair detection could skip it
312        // with an integer compare) cannot run concurrently, and its map writes are part of
313        // the serialized broad-phase state: keeping it would make the two builds' snapshots
314        // differ even on an identical simulation.
315        #[cfg(feature = "parallel")]
316        let candidates = self
317            .tree
318            .traverse_bvtt_single_tree_parallel::<{ Self::CHANGE_DETECTION_ENABLED }>();
319
320        #[cfg(not(feature = "parallel"))]
321        let candidates = {
322            // Reused across steps: the sequential walk reports through a closure, so
323            // collecting it into the same shape the parallel walk returns costs nothing
324            // beyond the (amortized) buffer.
325            let mut candidates = core::mem::take(&mut self.candidates_workspace);
326            candidates.clear();
327            self.tree
328                .traverse_bvtt_single_tree::<{ Self::CHANGE_DETECTION_ENABLED }>(
329                    &mut self.workspace,
330                    &mut |co1, co2| candidates.push((co1, co2)),
331                );
332            candidates
333        };
334
335        {
336            let filter_new =
337                |&(co1, co2): &(u32, u32)| -> Option<(ColliderHandle, ColliderHandle)> {
338                    debug_assert_ne!(co1, co2);
339                    let (mut collider1, mut handle1) = colliders.get_unknown_gen(co1)?;
340                    let (mut collider2, mut handle2) = colliders.get_unknown_gen(co2)?;
341
342                    if co1 > co2 {
343                        core::mem::swap(&mut handle1, &mut handle2);
344                        core::mem::swap(&mut collider1, &mut collider2);
345                    }
346
347                    // Same-parent colliders never collide; keeping their pairs out of the
348                    // pair map and contact graph keeps bodies with many mutually-overlapping
349                    // colliders from flooding the narrow phase (issue #970). Reparenting
350                    // re-discovers via the forced re-insertion pre-pass (`PARENT` above),
351                    // and the narrow phase's own per-update same-parent check handles pairs
352                    // whose colliders become same-parent after creation.
353                    if let (Some(p1), Some(p2)) = (&collider1.parent, &collider2.parent) {
354                        if p1.handle == p2.handle {
355                            return None;
356                        }
357                    }
358
359                    if self.pairs.contains_key(&(handle1, handle2)) {
360                        return None;
361                    }
362
363                    // Never create a pair the narrow phase's `ActiveCollisionTypes` or
364                    // collision-groups filters would drop anyway (keeps big static environments
365                    // and dense group-filtered scenes from flooding the contact graph); later
366                    // filter-input changes re-discover via the forced re-insertion pre-pass.
367                    // NOTE: `solver_groups` must NOT be tested here: solver-filtered pairs
368                    // still produce contact events.
369                    let rb_type = |co: &Collider| {
370                        co.parent
371                            .and_then(|p| bodies.get(p.handle))
372                            .map(|rb| rb.body_type)
373                            .unwrap_or(RigidBodyType::Fixed)
374                    };
375                    let rb_type1 = rb_type(collider1);
376                    let rb_type2 = rb_type(collider2);
377
378                    if !collider1
379                        .flags
380                        .active_collision_types
381                        .test(rb_type1, rb_type2)
382                        && !collider2
383                            .flags
384                            .active_collision_types
385                            .test(rb_type1, rb_type2)
386                    {
387                        return None;
388                    }
389
390                    if !collider1
391                        .flags
392                        .collision_groups
393                        .test(collider2.flags.collision_groups)
394                    {
395                        return None;
396                    }
397
398                    Some((handle1, handle2))
399                };
400
401            // rayon's ordered collect keeps the new pairs in traversal order, so the pair
402            // set, adjacency lists and emitted events stay deterministic — and identical to
403            // the sequential filter below.
404            // TODO(perf): avoid systematic `Vec` allocation.
405            #[cfg(feature = "parallel")]
406            let new_pairs: Vec<(ColliderHandle, ColliderHandle)> = {
407                use rayon::prelude::*;
408                candidates
409                    .par_chunks(512)
410                    .flat_map_iter(|chunk| chunk.iter().filter_map(filter_new))
411                    .collect()
412            };
413            #[cfg(not(feature = "parallel"))]
414            let new_pairs: Vec<(ColliderHandle, ColliderHandle)> =
415                candidates.iter().filter_map(filter_new).collect();
416
417            for (handle1, handle2) in new_pairs {
418                let prev = self.pairs.insert((handle1, handle2), self.frame_index);
419                debug_assert!(prev.is_none());
420                self.pair_adjacency
421                    .ensure_element_exist(handle1.0, Vec::new())
422                    .push(handle2);
423                self.pair_adjacency
424                    .ensure_element_exist(handle2.0, Vec::new())
425                    .push(handle1);
426                events.push(BroadPhasePairEvent::AddPair(ColliderPair::new(
427                    handle1, handle2,
428                )));
429            }
430        }
431
432        #[cfg(not(feature = "parallel"))]
433        {
434            self.candidates_workspace = candidates;
435        }
436
437        /*
438         *
439         * Stale pairs handling (+ pairs removed events).
440         *
441         */
442        // TODO(refactor): looks more complex than it could be.
443
444        // Find outdated entries. A pair can only stop overlapping if one of its colliders
445        // changed in the tree, so only pairs adjacent to updated/removed colliders are
446        // checked. (A linear scan of the whole pair map used to be the fallback when most
447        // colliders moved, but it was only worth it thanks to a per-pair timestamp
448        // updated by the sequential pair collector: a map write the parallel collector
449        // cannot do, and part of the serialized broad-phase state. One scan for every
450        // build is both simpler and what the parallel build already did.)
451        self.stale_pairs.clear();
452
453        // Pairs involving a removed collider are always dropped (without emitting an
454        // event, matching the behavior of the narrow-phase which handles removed
455        // colliders on its own).
456        for handle in removed_colliders {
457            if let Some(mut others) = self.pair_adjacency.remove(handle.0, Vec::new()) {
458                for other in others.drain(..) {
459                    self.stale_pairs.push((*handle, other, false));
460                }
461            }
462        }
463
464        // Adjacency scan: pure read-only lookups, stale candidates flattened in `updated_colliders`
465        // order (deterministic). No map probe: adjacency membership implies map membership, and the
466        // sequential application tolerates duplicates via `pairs.remove`. No timestamp fast-path —
467        // the tree's geometry is the only input, which is what lets it run concurrently.
468        // Each pair sits in both its colliders' adjacency lists, so a pair whose two
469        // sides both moved would be examined twice — the common case in a dense scene,
470        // and the work the old timestamp fast-path used to hide. Visit those from the
471        // lower-index side only. Pairs with one static side keep being visited from their
472        // moving side. (Duplicates were harmless — the application loop dedups through
473        // `pairs.remove` — so dropping them changes no result.)
474        self.updated_mask.clear();
475        self.updated_mask.resize(
476            self.updated_colliders
477                .iter()
478                .map(|h| h.into_raw_parts().0 as usize + 1)
479                .max()
480                .unwrap_or(0),
481            false,
482        );
483        for handle in &self.updated_colliders {
484            self.updated_mask[handle.into_raw_parts().0 as usize] = true;
485        }
486
487        {
488            let tree = &self.tree;
489            let pair_adjacency = &self.pair_adjacency;
490            let updated_mask = &self.updated_mask;
491            let scan =
492                |handle: &ColliderHandle, out: &mut Vec<(ColliderHandle, ColliderHandle, bool)>| {
493                    let Some(others) = pair_adjacency.get(handle.0) else {
494                        return;
495                    };
496
497                    // Fetched once for the whole adjacency list: the per-pair lookups
498                    // are random accesses into the node array, and half of them are this
499                    // same leaf. Both tests below are symmetric, so pulling one side out
500                    // does not change the outcome.
501                    let node_self = tree.leaf_node(handle.into_raw_parts().0);
502
503                    let self_index = handle.into_raw_parts().0;
504
505                    for other in others {
506                        let other_index = other.into_raw_parts().0;
507                        if self_index > other_index
508                            && updated_mask
509                                .get(other_index as usize)
510                                .copied()
511                                .unwrap_or(false)
512                        {
513                            // Both sides moved: this pair is visited from `other`.
514                            continue;
515                        }
516
517                        let (h0, h1) = if self_index > other_index {
518                            (*other, *handle)
519                        } else {
520                            (*handle, *other)
521                        };
522
523                        let Some(node0) = node_self else {
524                            out.push((h0, h1, false));
525                            continue;
526                        };
527                        let Some(node1) = tree.leaf_node(other_index) else {
528                            out.push((h0, h1, false));
529                            continue;
530                        };
531
532                        if (!Self::CHANGE_DETECTION_ENABLED
533                            || node0.is_changed()
534                            || node1.is_changed())
535                            && !node0.intersects(node1)
536                        {
537                            out.push((h0, h1, true));
538                        }
539                    }
540                };
541
542            // rayon's ordered `par_extend` appends the chunks in order, so the flattened
543            // result matches the sequential scan below element for element.
544            #[cfg(feature = "parallel")]
545            {
546                use rayon::prelude::*;
547                let mut stale_pairs = core::mem::take(&mut self.stale_pairs);
548                stale_pairs.par_extend(self.updated_colliders.par_chunks(256).flat_map_iter(
549                    |chunk| {
550                        // TODO(perf): avoid these Vec allocations?
551                        let mut out = Vec::new();
552                        for handle in chunk {
553                            scan(handle, &mut out);
554                        }
555                        out
556                    },
557                ));
558                self.stale_pairs = stale_pairs;
559            }
560
561            #[cfg(not(feature = "parallel"))]
562            {
563                let mut stale_pairs = core::mem::take(&mut self.stale_pairs);
564                for handle in &self.updated_colliders {
565                    scan(handle, &mut stale_pairs);
566                }
567                self.stale_pairs = stale_pairs;
568            }
569        }
570
571        // Canonical order: which detection variant ran (and its iteration order —
572        // hash-map order for the full scan) must not leak into the `DeletePair`
573        // sequence, which decides contact-graph edge-id reuse.
574        self.stale_pairs
575            .sort_unstable_by_key(|&(h0, h1, emit_event)| {
576                let a = h0.into_raw_parts().0;
577                let b = h1.into_raw_parts().0;
578                (a.min(b), a.max(b), emit_event)
579            });
580
581        for i in 0..self.stale_pairs.len() {
582            let (h0, h1, emit_event) = self.stale_pairs[i];
583            let (h0, h1) = if h0.into_raw_parts().0 > h1.into_raw_parts().0 {
584                (h1, h0)
585            } else {
586                (h0, h1)
587            };
588
589            // The `remove` check also deduplicates: the same pair can be pushed twice
590            // if both its colliders changed this frame.
591            if crate::utils::hashmap_remove(&mut self.pairs, &(h0, h1)).is_some() {
592                for (ha, hb) in [(h0, h1), (h1, h0)] {
593                    if let Some(others) = self.pair_adjacency.get_mut(ha.0) {
594                        if let Some(pos) = others.iter().position(|h| *h == hb) {
595                            others.swap_remove(pos);
596                        }
597                    }
598                }
599
600                if emit_event {
601                    events.push(BroadPhasePairEvent::DeletePair(ColliderPair::new(h0, h1)));
602                }
603            }
604        }
605    }
606}