Skip to content

Commit 4be1089

Browse files
Add culling of speculative contacts in Kamino (#3779)
1 parent 85f4c43 commit 4be1089

2 files changed

Lines changed: 168 additions & 0 deletions

File tree

newton/_src/solvers/kamino/_src/geometry/contacts.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@
112112
Set to `1e-5`.
113113
"""
114114

115+
DEFAULT_CULL_SPECULATIVE_CONTACTS: bool = True
116+
"""
117+
The global default for culling speculative contacts (with positive
118+
margin-shifted distance) during Newton->Kamino conversion.
119+
"""
120+
115121

116122
###
117123
# Types
@@ -856,13 +862,16 @@ def _assert_has_data(self):
856862
def make_convert_contacts_newton_to_kamino(
857863
friction_mix_mode: MaterialMixMode = MaterialMixMode.AVERAGE,
858864
restitution_mix_mode: MaterialMixMode = MaterialMixMode.MIN,
865+
cull_speculative: bool = DEFAULT_CULL_SPECULATIVE_CONTACTS,
859866
):
860867
"""
861868
Generates a kernel to convert Newton contacts to the Kamino format.
862869
863870
Args:
864871
friction_mix_mode: The mixing mode to use for friction.
865872
restitution_mix_mode: The mixing mode to use for restitution.
873+
cull_speculative: If ``True``, skip speculative contacts, i.e., contacts
874+
with positive margin-shifted distance.
866875
867876
Returns:
868877
A kernel function that converts Newton contacts to the Kamino format.
@@ -984,6 +993,12 @@ def _convert_contacts_newton_to_kamino(
984993
# and the per-shape surface thicknesses stored in rigid_contact_margin*.
985994
distance = contact_surface_separation(p0_world, p1_world, normal, margin_0, margin_1)
986995

996+
# Cull speculative contacts whose margin-shifted surfaces have not yet
997+
# made contact (positive gap distance).
998+
if wp.static(cull_speculative):
999+
if distance > 0.0:
1000+
return
1001+
9871002
# Ensure static body is always Kamino A, dynamic body is Kamino B
9881003
if bid_1 < 0:
9891004
# shape1 is world-static → make it Kamino A, shape0 becomes Kamino B.
@@ -1287,6 +1302,7 @@ def convert_contacts_newton_to_kamino(
12871302
convert_forces: bool = False,
12881303
friction_mix_mode: Literal["average", "multiply", "max", "min"] = "average",
12891304
restitution_mix_mode: Literal["average", "multiply", "max", "min"] = "min",
1305+
cull_speculative_contacts: bool = DEFAULT_CULL_SPECULATIVE_CONTACTS,
12901306
):
12911307
"""
12921308
Converts Newton's :class:`Contacts` to Kamino's :class:`ContactsKamino` format.
@@ -1328,6 +1344,9 @@ def convert_contacts_newton_to_kamino(
13281344
The mixing mode to use for contact friction. Defaults to `"average"`.
13291345
restitution_mix_mode:
13301346
The mixing mode to use for contact restitution. Defaults to `"min"`.
1347+
cull_speculative_contacts:
1348+
If ``True`` (the default), drop speculative contacts (contacts with
1349+
positive margin-shifted distance).
13311350
"""
13321351
# Skip conversion if there are no contacts to convert or no capacity to store them.
13331352
if contacts_out.model_max_contacts_host == 0 or contacts_in.rigid_contact_max == 0:
@@ -1373,6 +1392,7 @@ def convert_contacts_newton_to_kamino(
13731392
_convert_contacts_newton_to_kamino = make_convert_contacts_newton_to_kamino(
13741393
friction_mix_mode=MaterialMixMode.from_string(friction_mix_mode),
13751394
restitution_mix_mode=MaterialMixMode.from_string(restitution_mix_mode),
1395+
cull_speculative=cull_speculative_contacts,
13761396
)
13771397

13781398
# Launch the conversion kernel to convert Newton contacts to Kamino's format.
@@ -1569,6 +1589,12 @@ def convert_contacts_kamino_to_newton(
15691589
"construct `ContactsKamino` with `remappable=True`."
15701590
)
15711591

1592+
# Speculative-contact culling (and capacity truncation) can leave active
1593+
# Newton contacts without a matching Kamino contact, so the kernel below
1594+
# never writes their wrench. Zero the rigid force region first so those
1595+
# slots report zero instead of stale prior-frame data.
1596+
contacts_out_force[: contacts_out.rigid_contact_max].zero_()
1597+
15721598
# Launch the kernel to fill in solver-specific
15731599
# contact attributes for already populated contacts.
15741600
wp.launch(

newton/_src/solvers/kamino/tests/test_geometry_contacts.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,34 @@ def build_dynamic_static_sphere_scene(
189189
return builder
190190

191191

192+
def build_box_on_plane_scene(
193+
box_z: float,
194+
margin: float = 0.0,
195+
gap: float = 0.0,
196+
half_extent: float = 0.25,
197+
) -> ModelBuilder:
198+
"""Construct a single free box hovering above / resting on a static ground plane.
199+
200+
The box centre is placed at ``box_z`` so that its bottom face sits at
201+
``box_z - half_extent`` above the ground surface (``z = 0``).
202+
"""
203+
cfg = ModelBuilder.ShapeConfig(margin=margin, gap=gap)
204+
builder = ModelBuilder()
205+
builder.begin_world()
206+
body = builder.add_link(xform=wp.transform(p=wp.vec3(0.0, 0.0, box_z), q=wp.quat_identity()))
207+
builder.add_shape_box(body, hx=half_extent, hy=half_extent, hz=half_extent, cfg=cfg)
208+
joint = builder.add_joint_free(
209+
parent=-1,
210+
child=body,
211+
parent_xform=wp.transform_identity(),
212+
child_xform=wp.transform_identity(),
213+
)
214+
builder.add_articulation([joint])
215+
builder.add_ground_plane(cfg=cfg)
216+
builder.end_world()
217+
return builder
218+
219+
192220
###
193221
# Module configs
194222
###
@@ -1473,6 +1501,120 @@ def test_09_multi_world_per_world_capacity_not_starved(self):
14731501
bid_AB = kamino.bid_AB.numpy()[:2]
14741502
self.assertTrue(np.all(bid_AB[:, 1] >= 0))
14751503

1504+
def test_10_speculative_contacts_culled(self):
1505+
"""Speculative box-plane contacts are dropped by default.
1506+
1507+
Both configurations use a non-zero gap so that the collision detector
1508+
reports contacts while the box still hovers above the rest offset.
1509+
"""
1510+
half_extent = 0.25
1511+
1512+
for margin, gap, box_z in (
1513+
(0.0, 0.1, half_extent + 0.05),
1514+
(0.02, 0.1, half_extent + 0.08),
1515+
):
1516+
with self.subTest(margin=margin, gap=gap):
1517+
scene = build_box_on_plane_scene(box_z, margin=margin, gap=gap, half_extent=half_extent)
1518+
model = scene.finalize(self.default_device)
1519+
state = model.state()
1520+
collision_pipeline = newton.CollisionPipeline(model, rigid_contact_max=_NEWTON_CONTACT_CAPACITY)
1521+
contacts = collision_pipeline.contacts()
1522+
collision_pipeline.collide(state, contacts)
1523+
nc = int(contacts.rigid_contact_count.numpy()[0])
1524+
self.assertGreater(nc, 0, "Detector must report the hovering box as a (speculative) contact")
1525+
1526+
# Sanity: with culling disabled every converted contact is speculative.
1527+
kamino_off = ContactsKamino(capacity=contacts.rigid_contact_max, device=self.default_device)
1528+
convert_contacts_newton_to_kamino(model, state, contacts, kamino_off, cull_speculative_contacts=False)
1529+
n_off = int(kamino_off.model_active_contacts.numpy()[0])
1530+
self.assertEqual(n_off, nc, "Disabling culling must keep every detected contact")
1531+
w = kamino_off.gapfunc.numpy()[:n_off, 3]
1532+
self.assertTrue(np.all(w > 0.0), f"Expected speculative contacts (w > 0), got {w}")
1533+
1534+
# Default conversion culls all of them.
1535+
kamino_on = ContactsKamino(capacity=contacts.rigid_contact_max, device=self.default_device)
1536+
convert_contacts_newton_to_kamino(model, state, contacts, kamino_on)
1537+
self.assertEqual(
1538+
int(kamino_on.model_active_contacts.numpy()[0]),
1539+
0,
1540+
"Speculative contacts must be culled by default",
1541+
)
1542+
1543+
def test_11_genuine_contacts_kept(self):
1544+
"""Genuine box-plane contacts (gapfunc.w <= 0) survive culling.
1545+
1546+
Covers the full ``(gap, margin)`` matrix with the box placed at or below
1547+
the rest offset so no contact is speculative.
1548+
"""
1549+
half_extent = 0.25
1550+
1551+
for margin, gap, box_z in (
1552+
(0.0, 0.0, half_extent - 0.01),
1553+
(0.0, 0.1, half_extent - 0.01),
1554+
(0.02, 0.0, half_extent + 0.02),
1555+
(0.02, 0.1, half_extent + 0.02),
1556+
):
1557+
with self.subTest(margin=margin, gap=gap):
1558+
scene = build_box_on_plane_scene(box_z, margin=margin, gap=gap, half_extent=half_extent)
1559+
model = scene.finalize(self.default_device)
1560+
state = model.state()
1561+
collision_pipeline = newton.CollisionPipeline(model, rigid_contact_max=_NEWTON_CONTACT_CAPACITY)
1562+
contacts = collision_pipeline.contacts()
1563+
collision_pipeline.collide(state, contacts)
1564+
nc = int(contacts.rigid_contact_count.numpy()[0])
1565+
self.assertGreater(nc, 0, "Detector must report the penetrating box as a contact")
1566+
1567+
kamino = ContactsKamino(capacity=contacts.rigid_contact_max, device=self.default_device)
1568+
convert_contacts_newton_to_kamino(model, state, contacts, kamino)
1569+
n_kamino = int(kamino.model_active_contacts.numpy()[0])
1570+
self.assertEqual(n_kamino, nc, "Genuine contacts must not be culled")
1571+
w = kamino.gapfunc.numpy()[:n_kamino, 3]
1572+
self.assertTrue(np.all(w <= 1e-6), f"Expected genuine contacts (w <= 0), got {w}")
1573+
1574+
def test_12_culled_contacts_report_zero_force(self):
1575+
"""Culled contacts report zero force through the K->N existing path.
1576+
1577+
Regression: the existing-contacts write-back only touches surviving
1578+
(remapped) contacts, so culled slots must be zeroed rather than left
1579+
holding stale prior-frame force data.
1580+
"""
1581+
half_extent = 0.25
1582+
1583+
# Box hovering within the detection gap -> every contact is speculative.
1584+
scene = build_box_on_plane_scene(half_extent + 0.05, margin=0.0, gap=0.1, half_extent=half_extent)
1585+
scene.request_contact_attributes("force")
1586+
model = scene.finalize(self.default_device)
1587+
state = model.state()
1588+
collision_pipeline = newton.CollisionPipeline(model, rigid_contact_max=_NEWTON_CONTACT_CAPACITY)
1589+
contacts = collision_pipeline.contacts()
1590+
collision_pipeline.collide(state, contacts)
1591+
nc = int(contacts.rigid_contact_count.numpy()[0])
1592+
self.assertGreater(nc, 0)
1593+
1594+
# Seed stale prior-frame force data on every active contact slot.
1595+
force_np = contacts.force.numpy()
1596+
force_np[:nc] = 9.0
1597+
contacts.force.assign(force_np)
1598+
1599+
# N->K culls every (speculative) contact.
1600+
kamino = ContactsKamino(
1601+
capacity=contacts.rigid_contact_max,
1602+
device=self.default_device,
1603+
remappable=True,
1604+
)
1605+
convert_contacts_newton_to_kamino(model, state, contacts, kamino, convert_forces=True)
1606+
self.assertEqual(int(kamino.model_active_contacts.numpy()[0]), 0, "All contacts should be culled")
1607+
1608+
# K->N existing path must not leave stale force on the culled contacts.
1609+
convert_contacts_kamino_to_newton(model, state, kamino, contacts, clear_output=False, convert_forces=True)
1610+
1611+
force_after = contacts.force.numpy()[:nc]
1612+
np.testing.assert_array_equal(
1613+
force_after,
1614+
np.zeros_like(force_after),
1615+
err_msg="Culled contacts must not retain stale force data",
1616+
)
1617+
14761618

14771619
###
14781620
# Test execution

0 commit comments

Comments
 (0)