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}