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}