From c8398abebf3adf04dfdd0448af7850da7ac213db Mon Sep 17 00:00:00 2001 From: pepijn kooijmans Date: Fri, 17 Jul 2026 14:31:00 +0200 Subject: [PATCH 1/2] =?UTF-8?q?perf(rbd):=20flat=201-D=20narrow-phase=20di?= =?UTF-8?q?spatch=20=E2=80=94=20pack=20warps=20across=20batches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The narrow-phase kernels dispatched [max_len/64, num_batches, 1]: one 64-lane workgroup per batch rounded up from that batch's live pair count. A batched robot env has ~7 pairs, so at 2048+ envs the GPU ran thousands of workgroups at ~11% lane occupancy and narrow-phase scaled ~linearly with env count (0.02 ms at 1 env -> 18.6 ms at 4096). gpu_flatten_batches_dispatch (one thread, same style as the existing max-scan init kernels) now builds exclusive prefix offsets over the per-batch work-lists plus a flat [total/64, 1, 1] grid; the classify, deferred and PFM kernels walk 0..total and recover (batch, item) with a binary search over the offsets. Warps fill with real pairs from consecutive batches. Buffer layout is unchanged — only the dispatch shape and index math moved. RTX 5090, 12-DOF biped batch, dt=5ms, steady-state contacts: narrow-phase 2048 envs: 10.31 -> 5.09 ms 4096 envs: 18.57 -> 6.94 ms whole step 2048 envs: 22.45 -> 16.50 ms/step (91k -> 124k env-steps/s) Physics bit-identical (robot trajectory + cube settle unchanged to printed precision). Co-Authored-By: Claude Fable 5 --- src_rbd/broad_phase/narrow_phase.rs | 50 +++++-- src_rbd/pipeline/insertion_removal.rs | 6 + src_rbd/pipeline/rbd_state.rs | 6 + src_rbd/pipeline/rbd_state_from_rapier.rs | 6 + src_rbd/pipeline/rbd_step.rs | 6 +- src_rbd_shaders/broad_phase/narrow_phase.rs | 141 +++++++++++++++----- 6 files changed, 165 insertions(+), 50 deletions(-) diff --git a/src_rbd/broad_phase/narrow_phase.rs b/src_rbd/broad_phase/narrow_phase.rs index b37cfb5..53e0e8c 100644 --- a/src_rbd/broad_phase/narrow_phase.rs +++ b/src_rbd/broad_phase/narrow_phase.rs @@ -4,9 +4,9 @@ use crate::math::Pose; use crate::queries::GpuIndexedContact; use crate::shaders::PaddedVector; use crate::shaders::broad_phase::{ - CollisionPair, GpuInitPfmPfmDispatch, GpuNarrowPhaseInitContactsDispatch, GpuNarrowPhasePfmPfm, - GpuNarrowPhaseShapeShape, GpuNarrowPhaseShapeShapeDeferred, GpuResetNarrowPhase, - NarrowPhasePfmPair, + CollisionPair, GpuFlattenBatchesDispatch, GpuNarrowPhaseInitContactsDispatch, + GpuNarrowPhasePfmPfm, GpuNarrowPhaseShapeShape, GpuNarrowPhaseShapeShapeDeferred, + GpuResetNarrowPhase, NarrowPhasePfmPair, }; use crate::shaders::shapes::Shape; use khal::Shader; @@ -22,7 +22,11 @@ pub struct GpuNarrowPhase { /// `pfm_pairs` work-list. Split from `narrow_phase` to fit 8 storage buffers. narrow_phase_deferred: GpuNarrowPhaseShapeShapeDeferred, narrow_phase_pfm_pfm: GpuNarrowPhasePfmPfm, - init_pfm_pfm_indirect_args: GpuInitPfmPfmDispatch, + /// Builds the flat 1-D dispatch grid + prefix offsets for a per-batch + /// work-list (used for both the collision pairs and the PFM pairs), so the + /// kernels pack items from many batches into full warps instead of one + /// mostly-idle workgroup per batch. + flatten_batches: GpuFlattenBatchesDispatch, init_contacts_indirect_args: GpuNarrowPhaseInitContactsDispatch, } @@ -37,8 +41,8 @@ impl GpuNarrowPhase { vertices: &Tensor, indices: &Tensor, collision_pairs: &Tensor, - collision_pairs_len: &Tensor, - collision_pairs_indirect: &Tensor<[u32; 3]>, + collision_pairs_len: &mut Tensor, + collision_pairs_indirect: &mut Tensor<[u32; 3]>, contacts: &mut Tensor, contacts_len: &mut Tensor, contacts_indirect: &mut Tensor<[u32; 3]>, @@ -48,16 +52,30 @@ impl GpuNarrowPhase { batch_indices: &Tensor, collider_parent: &Tensor, collider_materials: &Tensor, + pairs_offsets: &mut Tensor, + pfm_offsets: &mut Tensor, ) -> Result<(), GpuBackendError> { let num_batches = contacts_len.len() as u32; self.reset_narrow_phase .call(pass, [1u32, num_batches, 1], contacts_len, pfm_pairs_len)?; - self.narrow_phase.call( + // The broad phase wrote a `[max/64, num_batches, 1]` grid into + // `collision_pairs_indirect`; rewrite it (and derive the offsets) for + // the flat layout. Nothing else consumes the batched form. + self.flatten_batches.call( pass, + 1u32, + collision_pairs_len, + pairs_offsets, collision_pairs_indirect, + batch_indices, + )?; + + self.narrow_phase.call( + pass, + &*collision_pairs_indirect, collision_pairs, - collision_pairs_len, + pairs_offsets, poses, shapes, contacts, @@ -71,9 +89,9 @@ impl GpuNarrowPhase { // separate dispatch so each pass fits 8 storage buffers). self.narrow_phase_deferred.call( pass, - collision_pairs_indirect, + &*collision_pairs_indirect, collision_pairs, - collision_pairs_len, + pairs_offsets, poses, shapes, pfm_pairs, @@ -83,15 +101,21 @@ impl GpuNarrowPhase { indices, )?; - self.init_pfm_pfm_indirect_args - .call(pass, 1u32, pfm_pairs_len, pfm_pairs_indirect)?; + self.flatten_batches.call( + pass, + 1u32, + pfm_pairs_len, + pfm_offsets, + pfm_pairs_indirect, + batch_indices, + )?; self.narrow_phase_pfm_pfm.call( pass, &*pfm_pairs_indirect, contacts, contacts_len, pfm_pairs, - pfm_pairs_len, + pfm_offsets, batch_indices, vertices, indices, diff --git a/src_rbd/pipeline/insertion_removal.rs b/src_rbd/pipeline/insertion_removal.rs index b5e1332..051891b 100644 --- a/src_rbd/pipeline/insertion_removal.rs +++ b/src_rbd/pipeline/insertion_removal.rs @@ -168,6 +168,10 @@ impl RbdState { BufferUsages::STORAGE | BufferUsages::COPY_SRC, ) .unwrap(); + let pairs_flat_offsets = + Tensor::vector_uninit(backend, num_batches + 1, BufferUsages::STORAGE).unwrap(); + let pfm_flat_offsets = + Tensor::vector_uninit(backend, num_batches + 1, BufferUsages::STORAGE).unwrap(); let old_constraints = Tensor::vector_uninit(backend, collisions_capacity * num_batches, storage).unwrap(); let old_constraint_builders = @@ -262,6 +266,8 @@ impl RbdState { pfm_pairs, pfm_pairs_len, pfm_pairs_indirect, + pairs_flat_offsets, + pfm_flat_offsets, old_constraints, old_constraint_builders, old_constraints_counts, diff --git a/src_rbd/pipeline/rbd_state.rs b/src_rbd/pipeline/rbd_state.rs index 73e0c92..0deea59 100644 --- a/src_rbd/pipeline/rbd_state.rs +++ b/src_rbd/pipeline/rbd_state.rs @@ -184,6 +184,12 @@ pub struct RbdState { pub(super) pfm_pairs: Tensor, pub(super) pfm_pairs_len: Tensor, pub(super) pfm_pairs_indirect: Tensor<[u32; 3]>, + /// Flat-dispatch prefix offsets (`num_batches + 1`) over the per-batch + /// collision-pair / PFM work-lists, rebuilt on the GPU each step by + /// `gpu_flatten_batches_dispatch` so the narrow-phase kernels can pack + /// items from many batches into full warps. + pub(super) pairs_flat_offsets: Tensor, + pub(super) pfm_flat_offsets: Tensor, pub(super) contacts: Tensor, pub(super) contacts_len: Tensor, pub(super) contacts_indirect: Tensor<[u32; 3]>, diff --git a/src_rbd/pipeline/rbd_state_from_rapier.rs b/src_rbd/pipeline/rbd_state_from_rapier.rs index 92bdd1e..84c3b50 100644 --- a/src_rbd/pipeline/rbd_state_from_rapier.rs +++ b/src_rbd/pipeline/rbd_state_from_rapier.rs @@ -595,6 +595,10 @@ impl RbdState { BufferUsages::STORAGE | BufferUsages::COPY_SRC, ) .unwrap(); + let pairs_flat_offsets = + Tensor::vector_uninit(backend, num_batches + 1, BufferUsages::STORAGE).unwrap(); + let pfm_flat_offsets = + Tensor::vector_uninit(backend, num_batches + 1, BufferUsages::STORAGE).unwrap(); let old_constraints = Tensor::vector_uninit( backend, capacities.collisions_capacity * num_batches, @@ -753,6 +757,8 @@ impl RbdState { pfm_pairs, pfm_pairs_len, pfm_pairs_indirect, + pairs_flat_offsets, + pfm_flat_offsets, old_constraints, old_constraint_builders, old_constraints_counts, diff --git a/src_rbd/pipeline/rbd_step.rs b/src_rbd/pipeline/rbd_step.rs index 64817d2..59352af 100644 --- a/src_rbd/pipeline/rbd_step.rs +++ b/src_rbd/pipeline/rbd_step.rs @@ -186,8 +186,8 @@ impl RbdPipeline { &state.vertex_buffers, &state.index_buffers, &state.collision_pairs, - &state.collision_pairs_len, - &state.collision_pairs_indirect, + &mut state.collision_pairs_len, + &mut state.collision_pairs_indirect, &mut state.contacts, &mut state.contacts_len, &mut state.contacts_indirect, @@ -197,6 +197,8 @@ impl RbdPipeline { &state.batch_indices, &state.collider_parent, &state.collider_materials, + &mut state.pairs_flat_offsets, + &mut state.pfm_flat_offsets, )?; drop(pass); diff --git a/src_rbd_shaders/broad_phase/narrow_phase.rs b/src_rbd_shaders/broad_phase/narrow_phase.rs index 20a4ead..9234176 100644 --- a/src_rbd_shaders/broad_phase/narrow_phase.rs +++ b/src_rbd_shaders/broad_phase/narrow_phase.rs @@ -70,6 +70,65 @@ pub fn gpu_narrow_phase_init_contacts_dispatch( *indirect_args.at_mut(2) = 1; } +/// Builds the flat-dispatch layout for a per-batch work-list: exclusive prefix +/// offsets (so item `t` of the flat range maps back to a batch via +/// [`find_batch`]) and the matching 1-D indirect grid. +/// +/// This replaces the max-over-batches indirect grids for the narrow-phase +/// kernels: with `[max/64, num_batches, 1]` every batch rounds its handful of +/// pairs up to a full 64-lane workgroup (a robot env has ~7 pairs → ~11% lane +/// occupancy, thousands of near-empty workgroups). The flat grid packs items +/// from consecutive batches into the same warps: `[total/64, 1, 1]`. +/// +/// Serial over batches in one thread — same pattern (and cost) as the existing +/// `gpu_narrow_phase_init_contacts_dispatch` max-scan. +#[spirv_bindgen] +#[spirv(compute(threads(1)))] +pub fn gpu_flatten_batches_dispatch( + // NOTE: `lens` is mutable only for `atomic_load_u32` (see the note on + // `gpu_narrow_phase_init_contacts_dispatch`). + #[spirv(storage_buffer, descriptor_set = 0, binding = 0)] lens: &mut [u32], + // `num_batches + 1` entries; `offsets[num_batches]` is the total. + #[spirv(storage_buffer, descriptor_set = 0, binding = 1)] offsets: &mut [u32], + #[spirv(storage_buffer, descriptor_set = 0, binding = 2)] indirect_args: &mut [u32; 3], + #[spirv(uniform, descriptor_set = 0, binding = 3)] batch_ids: &BatchIndices, +) { + let num_batches = lens.len(); + // Same clamp as the consuming kernels: a batch's list may overflow its + // capacity slot; the overflowing tail was never written and must not be + // walked. + let capacity = batch_ids.contacts_batch_capacity; + let mut total = 0u32; + for i in 0..num_batches { + offsets.write(i, total); + total += atomic_load_u32(lens.at_mut(i)).min(capacity); + } + offsets.write(num_batches, total); + *indirect_args.at_mut(0) = total.div_ceil(WORKGROUP_SIZE); + *indirect_args.at_mut(1) = 1; + *indirect_args.at_mut(2) = 1; +} + +/// Largest `b` with `offsets[b] <= t` — the batch owning flat item `t`. +/// Invariant: `offsets[0] == 0 <= t < offsets[num_batches]`. +fn find_batch(offsets: &[u32], num_batches: u32, t: u32) -> u32 { + let mut lo = 0u32; + let mut hi = num_batches; + // Bounded loop instead of `while` (see the trimesh BVH walk for why). + for _ in 0..32 { + if lo + 1 >= hi { + break; + } + let mid = (lo + hi) / 2; + if offsets.read(mid as usize) <= t { + lo = mid; + } else { + hi = mid; + } + } + lo +} + const PREDICTION: f32 = 2.0e-3; // TODO: make the prediction configurable. /// Narrow phase, pass 1 of 2: analytic shape-shape contacts for ball / cuboid @@ -83,7 +142,10 @@ pub fn gpu_narrow_phase_shape_shape( #[spirv(global_invocation_id)] invocation_id: UVec3, #[spirv(num_workgroups)] num_workgroups: UVec3, #[spirv(storage_buffer, descriptor_set = 0, binding = 0)] collision_pairs: &[CollisionPair], - #[spirv(storage_buffer, descriptor_set = 0, binding = 1)] collision_pairs_len: &[u32], + // Flat-dispatch prefix offsets from `gpu_flatten_batches_dispatch` + // (`num_batches + 1` entries; replaces the per-batch `collision_pairs_len`, + // which it already folds in, clamped to capacity). + #[spirv(storage_buffer, descriptor_set = 0, binding = 1)] pairs_offsets: &[u32], #[spirv(storage_buffer, descriptor_set = 0, binding = 2)] poses: &[Pose], #[spirv(storage_buffer, descriptor_set = 0, binding = 3)] shapes: &[Shape], #[spirv(storage_buffer, descriptor_set = 0, binding = 4)] contacts: &mut [IndexedManifold], @@ -97,23 +159,25 @@ pub fn gpu_narrow_phase_shape_shape( collider_materials: &[ColliderMaterial], ) { let num_threads = num_workgroups.x * WORKGROUP_SIZE; - let batch_id = invocation_id.y; let contacts_batch_capacity = batch_ids.contacts_batch_capacity as usize; - let collision_pairs = batch_ids.contact_batch(batch_id, collision_pairs); - let poses = batch_ids.coll_batch(batch_id, poses); - let shapes = batch_ids.coll_batch(batch_id, shapes); - let collider_materials = batch_ids.coll_batch(batch_id, collider_materials); - let mut contacts = batch_ids.contact_batch_mut(batch_id, contacts); - let contacts_len = contacts_len.at_mut(batch_id as usize); + // Flat over all batches' pairs: consecutive lanes take consecutive pairs + // regardless of which batch owns them, so warps stay packed even when each + // batch only has a handful. + let num_batches = pairs_offsets.len() - 1; + let total = pairs_offsets.read(num_batches); + + for t in StepRng::new(invocation_id.x..total, num_threads) { + let batch_id = find_batch(pairs_offsets, num_batches as u32, t); + let i = t - pairs_offsets.read(batch_id as usize); - // NOTE: `collision_pairs_len` might be greater than `contacts_batch_apacity` if the - // narrow-phase found more pairs than the buffer can contain. - let len = collision_pairs_len - .read(batch_id as usize) - .min(contacts_batch_capacity as u32); + let collision_pairs = batch_ids.contact_batch(batch_id, collision_pairs); + let poses = batch_ids.coll_batch(batch_id, poses); + let shapes = batch_ids.coll_batch(batch_id, shapes); + let collider_materials = batch_ids.coll_batch(batch_id, collider_materials); + let mut contacts = SliceMut(&mut *contacts, batch_ids.contacts_start(batch_id)); + let contacts_len = contacts_len.at_mut(batch_id as usize); - for i in StepRng::new(invocation_id.x..len, num_threads) { let pair = collision_pairs[i as usize]; // Resolve the parent rigid-bodies here (the broad phase no longer does) // and skip pairs whose colliders share the same body. @@ -200,7 +264,8 @@ pub fn gpu_narrow_phase_shape_shape_deferred( #[spirv(global_invocation_id)] invocation_id: UVec3, #[spirv(num_workgroups)] num_workgroups: UVec3, #[spirv(storage_buffer, descriptor_set = 0, binding = 0)] collision_pairs: &[CollisionPair], - #[spirv(storage_buffer, descriptor_set = 0, binding = 1)] collision_pairs_len: &[u32], + // Flat-dispatch prefix offsets (see `gpu_narrow_phase_shape_shape`). + #[spirv(storage_buffer, descriptor_set = 0, binding = 1)] pairs_offsets: &[u32], #[spirv(storage_buffer, descriptor_set = 0, binding = 2)] poses: &[Pose], #[spirv(storage_buffer, descriptor_set = 0, binding = 3)] shapes: &[Shape], #[spirv(storage_buffer, descriptor_set = 0, binding = 4)] @@ -214,25 +279,25 @@ pub fn gpu_narrow_phase_shape_shape_deferred( #[spirv(uniform, descriptor_set = 0, binding = 6)] batch_ids: &BatchIndices, ) { let num_threads = num_workgroups.x * WORKGROUP_SIZE; - let batch_id = invocation_id.y; - let contacts_batch_capacity = batch_ids.contacts_batch_capacity as usize; - - let collision_pairs = batch_ids.contact_batch(batch_id, collision_pairs); - let poses = batch_ids.coll_batch(batch_id, poses); - let shapes = batch_ids.coll_batch(batch_id, shapes); - let mut pfm_pairs = batch_ids.contact_batch_mut(batch_id, pfm_pairs); - let pfm_pairs_len = pfm_pairs_len.at_mut(batch_id as usize); - let len = collision_pairs_len - .read(batch_id as usize) - .min(contacts_batch_capacity as u32); + let num_batches = pairs_offsets.len() - 1; + let total = pairs_offsets.read(num_batches); // NOTE: same-body collider pairs are *not* filtered in this pass — it is // already at the 8-storage-buffer WebGPU limit and can't take the // `collider_parent` binding. The complex pairs it emits are filtered // downstream in `gpu_narrow_phase_pfm_pfm` (which has room) before any // contact is written. - for i in StepRng::new(invocation_id.x..len, num_threads) { + for t in StepRng::new(invocation_id.x..total, num_threads) { + let batch_id = find_batch(pairs_offsets, num_batches as u32, t); + let i = t - pairs_offsets.read(batch_id as usize); + + let collision_pairs = batch_ids.contact_batch(batch_id, collision_pairs); + let poses = batch_ids.coll_batch(batch_id, poses); + let shapes = batch_ids.coll_batch(batch_id, shapes); + let mut pfm_pairs = SliceMut(&mut *pfm_pairs, batch_ids.contacts_start(batch_id)); + let pfm_pairs_len = pfm_pairs_len.at_mut(batch_id as usize); + let pair = collision_pairs[i as usize]; let pose1 = poses[pair.colliders.x as usize]; let pose2 = poses[pair.colliders.y as usize]; @@ -541,7 +606,9 @@ pub fn gpu_narrow_phase_pfm_pfm( #[spirv(storage_buffer, descriptor_set = 0, binding = 0)] contacts: &mut [IndexedManifold], #[spirv(storage_buffer, descriptor_set = 0, binding = 1)] contacts_len: &mut [u32], #[spirv(storage_buffer, descriptor_set = 0, binding = 2)] pfm_pairs: &[NarrowPhasePfmPair], - #[spirv(storage_buffer, descriptor_set = 0, binding = 3)] pfm_pairs_len: &[u32], + // Flat-dispatch prefix offsets over the per-batch PFM work-lists (see + // `gpu_narrow_phase_shape_shape`; replaces the per-batch `pfm_pairs_len`). + #[spirv(storage_buffer, descriptor_set = 0, binding = 3)] pfm_offsets: &[u32], // NOTE: we assume that max_pfm_pairs == contacts_batch_capacity // And we assume all batch dimensions are given the same buffer allocation sizes // (i.e. the same `contacts_batch_capacity`). @@ -557,16 +624,20 @@ pub fn gpu_narrow_phase_pfm_pfm( collider_materials: &[ColliderMaterial], ) { let num_threads = num_workgroups.x * WORKGROUP_SIZE; - let batch_id = invocation_id.y; let contacts_batch_capacity = batch_ids.contacts_batch_capacity as usize; - let mut contacts = batch_ids.contact_batch_mut(batch_id, contacts); - let collider_materials = batch_ids.coll_batch(batch_id, collider_materials); - let pfm_pairs = batch_ids.contact_batch(batch_id, pfm_pairs); - let contacts_len = contacts_len.at_mut(batch_id as usize); - let pfm_pairs_len = pfm_pairs_len.read(batch_id as usize); + let num_batches = pfm_offsets.len() - 1; + let total = pfm_offsets.read(num_batches); + + for t in StepRng::new(invocation_id.x..total, num_threads) { + let batch_id = find_batch(pfm_offsets, num_batches as u32, t); + let i = t - pfm_offsets.read(batch_id as usize); + + let mut contacts = SliceMut(&mut *contacts, batch_ids.contacts_start(batch_id)); + let collider_materials = batch_ids.coll_batch(batch_id, collider_materials); + let pfm_pairs = batch_ids.contact_batch(batch_id, pfm_pairs); + let contacts_len = contacts_len.at_mut(batch_id as usize); - for i in StepRng::new(invocation_id.x..pfm_pairs_len, num_threads) { let pair = pfm_pairs[i as usize]; // Resolve the parent rigid-bodies and skip same-body collider pairs. This // is where the deferred (PFM / trimesh / polyline) pairs get the same-body From 59f34bb67aca53abe6fc2d105253371e5486911a Mon Sep 17 00:00:00 2001 From: pepijn kooijmans Date: Fri, 17 Jul 2026 18:05:41 +0200 Subject: [PATCH 2/2] fix(rbd): apply the per-batch stride to collider_parent reads in the narrow phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collision pairs carry env-local collider ids, and collider_parent is batch-strided like every other per-collider buffer (its construction comment says so: 'Env-local body slot; the kernels apply the per-batch stride') — but the classify and pfm_pfm kernels read it unsliced, resolving every batch's parents through batch 0's table. With identical environments the tables coincide and nothing observable goes wrong — which is why every bit-exactness check passed. With heterogeneous environments (the point of per-env MJCF insertion) contacts are silently mis-parented: a pair that is same-body in batch 0 gets skipped in batches where it isn't, and solved impulses can target the wrong bodies. Repro (now a stacking test): env0 = body with two glued boxes + a single box; env1 = single box dropped onto a two-glued-box body (equal body and collider counts, different parent tables). Pre-fix the falling box never rests on the stack; post-fix it settles on top (z=0.75). Identical-env scenes are bit-identical before/after, as the aliasing argument predicts. Co-Authored-By: Claude Fable 5 --- src_rbd_shaders/broad_phase/narrow_phase.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src_rbd_shaders/broad_phase/narrow_phase.rs b/src_rbd_shaders/broad_phase/narrow_phase.rs index 9234176..7610a0a 100644 --- a/src_rbd_shaders/broad_phase/narrow_phase.rs +++ b/src_rbd_shaders/broad_phase/narrow_phase.rs @@ -180,9 +180,14 @@ pub fn gpu_narrow_phase_shape_shape( let pair = collision_pairs[i as usize]; // Resolve the parent rigid-bodies here (the broad phase no longer does) - // and skip pairs whose colliders share the same body. - let body1 = collider_parent.read(pair.colliders.x as usize); - let body2 = collider_parent.read(pair.colliders.y as usize); + // and skip pairs whose colliders share the same body. Pair ids are + // env-local, and `collider_parent` is batch-strided like the other + // per-collider buffers — an unsliced read here silently returned + // batch 0's parents for every batch (masked whenever all envs are + // identical, wrong the moment they aren't). + let coll_base = batch_ids.coll_start(batch_id); + let body1 = collider_parent.read(coll_base + pair.colliders.x as usize); + let body2 = collider_parent.read(coll_base + pair.colliders.y as usize); if body1 == body2 { continue; } @@ -643,8 +648,9 @@ pub fn gpu_narrow_phase_pfm_pfm( // is where the deferred (PFM / trimesh / polyline) pairs get the same-body // filtering that the analytic pass does inline — the broad phase no longer // does it, and the deferred pass has no spare storage binding for it. - let body1 = collider_parent.read(pair.colliders.x as usize); - let body2 = collider_parent.read(pair.colliders.y as usize); + let coll_base = batch_ids.coll_start(batch_id); + let body1 = collider_parent.read(coll_base + pair.colliders.x as usize); + let body2 = collider_parent.read(coll_base + pair.colliders.y as usize); if body1 == body2 { continue; }