Skip to content

Commit 8b0800e

Browse files
authored
Harvest VBD proxy wrenches from the capped per-body contact list (#4004)
1 parent 9d755a3 commit 8b0800e

5 files changed

Lines changed: 162 additions & 44 deletions

File tree

changelog/3795.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Cap the `SolverVBD` proxy-body wrench harvest at the per-body soft-contact list capacity, `rigid_body_particle_contact_buffer_size`. The harvest walked the whole soft-contact stream while the destination solve applied only the records the per-body list kept, so a proxy-coupled body carrying more soft contacts than the list holds fed the source solver a reaction it never applied, injecting momentum that grew with the overflow. Both now read the same list. Below the cap nothing changes.

newton/_src/solvers/vbd/solver_vbd.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1190,9 +1190,12 @@ def coupling_harvest_proxy_wrenches(
11901190
)
11911191

11921192
if contacts.soft_contact_max > 0 and self.body_particle_contact_penalty_k.shape[0] >= contacts.soft_contact_max:
1193+
# Per-body, mirroring accumulate_body_particle_contacts_per_body: the harvest reads the
1194+
# same truncated adjacency list the destination solve consumed, so the reaction reported
1195+
# to the source is by construction the one the solve applied.
11931196
wp.launch(
11941197
_harvest_vbd_body_particle_contact_forces_on_proxy_bodies_kernel,
1195-
dim=contacts.soft_contact_max,
1198+
dim=self.model.body_count * _NUM_CONTACT_THREADS_PER_BODY,
11961199
inputs=[
11971200
float(dt),
11981201
body_local_to_proxy_global,
@@ -1216,6 +1219,9 @@ def coupling_harvest_proxy_wrenches(
12161219
contacts.soft_contact_normal,
12171220
self.model.shape_margin,
12181221
self.model.shape_body,
1222+
self.body_particle_contact_buffer_pre_alloc,
1223+
self.body_particle_contact_counts,
1224+
self.body_particle_contact_indices,
12191225
out_body_f,
12201226
],
12211227
device=self.device,

newton/_src/solvers/vbd/vbd_coupling_kernels.py

Lines changed: 68 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
evaluate_edge_edge_contact_2_vertices,
1414
evaluate_vertex_triangle_collision_force_hessian_4_vertices,
1515
)
16-
from .rigid_vbd_kernels import _eval_body_particle_contact, _eval_soft_ef_contact
16+
from .rigid_vbd_kernels import _NUM_CONTACT_THREADS_PER_BODY, _eval_body_particle_contact, _eval_soft_ef_contact
1717
from .tri_mesh_collision import TriMeshCollisionInfo
1818

1919
wp.set_module_options({"enable_backward": False})
@@ -150,58 +150,84 @@ def _harvest_vbd_body_particle_contact_forces_on_proxy_bodies_kernel(
150150
body_particle_contact_normal: wp.array[wp.vec3],
151151
shape_margin: wp.array[float],
152152
shape_body: wp.array[wp.int32],
153+
body_particle_contact_buffer_pre_alloc: int,
154+
body_particle_contact_counts: wp.array[wp.int32],
155+
body_particle_contact_indices: wp.array[wp.int32],
153156
out_body_f: wp.array[wp.spatial_vector],
154157
):
155-
contact_idx = wp.tid()
156-
if contact_idx >= body_particle_contact_count[0]:
157-
return
158-
159-
shape_idx = body_particle_contact_shape[contact_idx]
160-
if shape_idx < 0 or shape_idx >= shape_body.shape[0]:
161-
return
162-
163-
body_idx = shape_body[shape_idx]
164-
if body_idx < 0 or body_idx >= body_local_to_proxy_global.shape[0]:
158+
"""Sum the body-side soft-contact reaction the destination solve applied to each proxy body.
159+
160+
Walks the per-body adjacency list, and truncates it at the same per-body capacity as
161+
``accumulate_body_particle_contacts_per_body``. A body carrying more soft contacts than the
162+
list holds is pushed by only the records the list kept, so harvesting the whole contact
163+
stream would hand the source solver a reaction the destination never applied -- momentum the
164+
coupled pair would then disagree about, growing with the size of the overflow.
165+
"""
166+
tid = wp.tid()
167+
body_idx = tid // _NUM_CONTACT_THREADS_PER_BODY
168+
thread_id_within_body = tid % _NUM_CONTACT_THREADS_PER_BODY
169+
170+
if body_idx >= body_local_to_proxy_global.shape[0]:
165171
return
166172

167173
proxy_global = body_local_to_proxy_global[body_idx]
168174
if proxy_global < 0 or proxy_global >= out_body_f.shape[0]:
169175
return
170176

171-
corners = soft_contact_indices[contact_idx]
172-
if corners[0] < 0 or corners[0] >= particle_q.shape[0]:
177+
num_contacts = body_particle_contact_counts[body_idx]
178+
if num_contacts > body_particle_contact_buffer_pre_alloc:
179+
num_contacts = body_particle_contact_buffer_pre_alloc
180+
if num_contacts == 0:
173181
return
174182

175-
bary = soft_contact_barycentric[contact_idx]
176-
177-
force_on_particle, _hess, cp_world = _eval_soft_ef_contact(
178-
contact_idx,
179-
corners,
180-
bary,
181-
particle_q,
182-
particle_q_prev,
183-
particle_radius,
184-
body_particle_contact_penalty_k[contact_idx],
185-
body_particle_contact_material_kd[contact_idx],
186-
body_particle_contact_material_mu[contact_idx],
187-
friction_epsilon,
188-
shape_body,
189-
body_q,
190-
body_q_prev,
191-
body_qd,
192-
body_com,
193-
body_particle_contact_shape,
194-
body_particle_contact_body_pos,
195-
body_particle_contact_body_vel,
196-
body_particle_contact_normal,
197-
shape_margin,
198-
dt,
199-
)
200-
201-
force_on_body = -force_on_particle
183+
max_contacts = body_particle_contact_count[0] # single total soft-contact count
202184
com_world = wp.transform_point(body_q[body_idx], body_com[body_idx])
203-
torque_on_body = wp.cross(cp_world - com_world, force_on_body)
204-
wp.atomic_add(out_body_f, proxy_global, wp.spatial_vector(force_on_body, torque_on_body))
185+
186+
force_acc = wp.vec3(0.0)
187+
torque_acc = wp.vec3(0.0)
188+
189+
i = thread_id_within_body
190+
while i < num_contacts:
191+
contact_idx = body_particle_contact_indices[body_idx * body_particle_contact_buffer_pre_alloc + i]
192+
i += _NUM_CONTACT_THREADS_PER_BODY
193+
if contact_idx >= max_contacts:
194+
continue
195+
196+
corners = soft_contact_indices[contact_idx]
197+
if corners[0] < 0 or corners[0] >= particle_q.shape[0]:
198+
continue
199+
200+
bary = soft_contact_barycentric[contact_idx]
201+
202+
force_on_particle, _hess, cp_world = _eval_soft_ef_contact(
203+
contact_idx,
204+
corners,
205+
bary,
206+
particle_q,
207+
particle_q_prev,
208+
particle_radius,
209+
body_particle_contact_penalty_k[contact_idx],
210+
body_particle_contact_material_kd[contact_idx],
211+
body_particle_contact_material_mu[contact_idx],
212+
friction_epsilon,
213+
shape_body,
214+
body_q,
215+
body_q_prev,
216+
body_qd,
217+
body_com,
218+
body_particle_contact_shape,
219+
body_particle_contact_body_pos,
220+
body_particle_contact_body_vel,
221+
body_particle_contact_normal,
222+
shape_margin,
223+
dt,
224+
)
225+
226+
force_on_body = -force_on_particle
227+
force_acc += force_on_body
228+
torque_acc += wp.cross(cp_world - com_world, force_on_body)
229+
230+
wp.atomic_add(out_body_f, proxy_global, wp.spatial_vector(force_acc, torque_acc))
205231

206232

207233
@wp.func

newton/tests/test_coupled_solver.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2341,9 +2341,87 @@ def add_free_body(*, is_kinematic=False):
23412341
np.testing.assert_allclose(state_out.body_qd.numpy()[source_bodies], model_qd[source_bodies], atol=1.0e-5)
23422342

23432343

2344+
def _harvest_wrench_on_one_proxy_body(buffer_size, particle_count, depth=0.01, radius=0.05, dt=1.0 / 60.0):
2345+
"""Harvest the proxy wrench for one body pressed by ``particle_count`` identical soft contacts.
2346+
2347+
The particles sit at the same depth on a flat, uniform face, so every contact carries the same
2348+
force and any subset of them of a given size sums to the same linear reaction. The body is
2349+
heavy enough that a single step leaves that geometry intact.
2350+
"""
2351+
builder = newton.ModelBuilder(gravity=(0.0, 0.0, 0.0))
2352+
body = builder.add_body(mass=1.0e6, inertia=wp.mat33(np.eye(3) * 1.0e6))
2353+
builder.add_shape_box(
2354+
body=body,
2355+
hx=5.0,
2356+
hy=5.0,
2357+
hz=0.5,
2358+
xform=wp.transform(wp.vec3(0.0, 0.0, -0.5), wp.quat_identity()),
2359+
)
2360+
for i in range(particle_count):
2361+
builder.add_particle(
2362+
pos=(2.0 * radius * (i - 0.5 * (particle_count - 1)), 0.0, radius - depth),
2363+
vel=(0.0, 0.0, 0.0),
2364+
mass=1.0,
2365+
radius=radius,
2366+
)
2367+
builder.color()
2368+
model = builder.finalize(device="cpu")
2369+
2370+
solver = SolverVBD(
2371+
model=model,
2372+
iterations=1,
2373+
integrate_with_external_rigid_solver=False,
2374+
rigid_body_particle_contact_buffer_size=buffer_size,
2375+
rigid_compliant_alm=True,
2376+
)
2377+
state_in = model.state()
2378+
state_out = model.state()
2379+
collision_pipeline = newton.CollisionPipeline(model)
2380+
contacts = collision_pipeline.contacts()
2381+
collision_pipeline.collide(state_in, contacts)
2382+
solver.step(state_in, state_out, control=None, contacts=contacts, dt=dt)
2383+
2384+
out_body_f = wp.zeros(1, dtype=wp.spatial_vector, device=model.device)
2385+
solver.coupling_harvest_proxy_wrenches(
2386+
wp.array([0], dtype=int, device=model.device),
2387+
out_body_f,
2388+
body_qd_before=state_in.body_qd,
2389+
state=state_in,
2390+
state_out=state_out,
2391+
contacts=contacts,
2392+
dt=dt,
2393+
)
2394+
return {
2395+
"soft_contact_count": int(contacts.soft_contact_count.numpy()[0]),
2396+
"listed": int(solver.body_particle_contact_counts.numpy()[0]),
2397+
"overflow_max": int(solver.body_particle_contact_overflow_max.numpy()[0]),
2398+
"wrench": out_body_f.numpy()[0],
2399+
}
2400+
2401+
23442402
class TestSolverVBDCouplingHooks(unittest.TestCase):
23452403
"""VBD-specific coupling hook behavior."""
23462404

2405+
def test_proxy_wrench_harvest_respects_body_particle_contact_buffer_cap(self):
2406+
particle_count = 12
2407+
cap = 4
2408+
2409+
full = _harvest_wrench_on_one_proxy_body(particle_count, particle_count)
2410+
self.assertEqual(full["soft_contact_count"], particle_count)
2411+
self.assertEqual(full["listed"], particle_count)
2412+
self.assertEqual(full["overflow_max"], 0, "the uncapped reference must not overflow")
2413+
force_full = full["wrench"][:3]
2414+
self.assertGreater(abs(force_full[2]), 0.0, "the contacts must actually push the body")
2415+
2416+
capped = _harvest_wrench_on_one_proxy_body(cap, particle_count)
2417+
self.assertEqual(capped["soft_contact_count"], particle_count)
2418+
self.assertGreater(capped["overflow_max"], cap, "the small buffer must actually overflow")
2419+
2420+
# The solve applied only the `cap` records its per-body list kept, so the reaction fed back
2421+
# to the source is that same fraction of the whole stream -- not all of it.
2422+
expected = force_full * (cap / particle_count)
2423+
np.testing.assert_allclose(capped["wrench"][:3], expected, rtol=1.0e-5, atol=1.0e-6)
2424+
23472425
def test_external_rigid_solver_harvests_particle_soft_contacts(self):
23482426
builder = newton.ModelBuilder(gravity=(0.0, 0.0, 0.0))
23492427
body = builder.add_body(mass=1.0, inertia=wp.mat33(np.eye(3)))

newton/tests/test_solver_vbd_proxy_full_surface.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import warp as wp
1010

1111
import newton
12+
from newton._src.solvers.vbd.rigid_vbd_kernels import _NUM_CONTACT_THREADS_PER_BODY
1213
from newton._src.solvers.vbd.vbd_coupling_kernels import (
1314
_harvest_vbd_body_particle_contact_forces_on_proxy_bodies_kernel,
1415
)
@@ -59,9 +60,12 @@ def _set(arr, value):
5960
_set(contacts.soft_contact_normal, list(_NORMAL))
6061

6162
out_body_f = wp.zeros(1, dtype=wp.spatial_vector, device=device)
63+
# The harvest walks a per-body adjacency list, so seed body 0 with the one contact.
64+
contact_counts = wp.array([1], dtype=wp.int32, device=device)
65+
contact_indices = wp.zeros(smax, dtype=wp.int32, device=device)
6266
wp.launch(
6367
_harvest_vbd_body_particle_contact_forces_on_proxy_bodies_kernel,
64-
dim=smax,
68+
dim=_NUM_CONTACT_THREADS_PER_BODY,
6569
inputs=[
6670
0.01, # dt
6771
wp.array([0], dtype=int, device=device), # body 0 -> proxy global 0
@@ -85,6 +89,9 @@ def _set(arr, value):
8589
contacts.soft_contact_normal,
8690
model.shape_margin,
8791
model.shape_body,
92+
smax,
93+
contact_counts,
94+
contact_indices,
8895
],
8996
outputs=[out_body_f],
9097
device=device,

0 commit comments

Comments
 (0)