Skip to content

Commit 7de4692

Browse files
[Kamino] fix kamino contact capacity (#3732)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 01d43e0 commit 7de4692

15 files changed

Lines changed: 448 additions & 141 deletions

newton/_src/solvers/kamino/_src/core/builder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1757,7 +1757,7 @@ def compute_required_contact_capacity(
17571757
else:
17581758
world_max_contacts[geom1.wid] += num_contacts
17591759

1760-
# Override the per-world maximum contacts if specified in the settings
1760+
# Cap per-world totals when a per-world maximum is specified
17611761
if max_contacts_per_world is not None:
17621762
for w in range(self.num_worlds):
17631763
world_max_contacts[w] = min(world_max_contacts[w], max_contacts_per_world)

newton/_src/solvers/kamino/_src/core/conversions.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -826,8 +826,8 @@ def compute_required_contact_capacity(
826826
max_contacts_per_pair: Optional maximum number of contacts to allocate per shape pair.
827827
If `None`, no per-pair limit is applied.
828828
max_contacts_per_world: Optional maximum number of contacts to allocate per world.
829-
If `None`, no per-world limit is applied, otherwise it will
830-
override the computed per-world requirements if it is larger.
829+
If `None`, no per-world limit is applied, otherwise caps the computed
830+
per-world requirements at this value.
831831
832832
Returns:
833833
(model_required_contacts, world_required_contacts):
@@ -860,7 +860,7 @@ def compute_required_contact_capacity(
860860
)
861861
world_max_contacts = world_max_contacts_wp.numpy()
862862

863-
# Override the per-world maximum contacts if specified in the settings
863+
# Cap per-world totals when a per-world maximum is specified
864864
if max_contacts_per_world is not None:
865865
world_max_contacts = np.minimum(world_max_contacts, max_contacts_per_world)
866866

newton/_src/solvers/kamino/_src/core/shapes.py

Lines changed: 68 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -620,96 +620,125 @@ def max_contacts_for_shape_pair(type_a: int, type_b: int) -> tuple[int, int]:
620620
Count the number of potential contact points for a collision pair in both
621621
directions of the collision pair (collisions from A to B and from B to A).
622622
623-
Inputs must be canonicalized such that the type of shape A is less than or equal to the type of shape B.
623+
Shape types are canonicalized such that the type of shape A is less than or equal to the type of shape B.
624624
625625
Args:
626626
type_a: First shape type as :class:`GeoType` integer value.
627627
type_b: Second shape type as :class:`GeoType` integer value.
628628
629629
Returns:
630630
Number of contact points for collisions between A->B and B->A.
631+
Each component independently bounds the contacts generated in its
632+
direction. The reverse capacity is zero only when the pair's
633+
narrow-phase implementation emits contacts in canonical order alone;
634+
otherwise it reserves capacity for the reverse pass.
631635
"""
632636
# Ensure the shape types are ordered canonically
633637
if type_a > type_b:
634638
type_a, type_b = type_b, type_a
635639

636-
if type_a == GeoType.SPHERE:
637-
return 1, 0
640+
return _max_contacts_for_shape_pair_impl(type_a, type_b)
641+
642+
643+
@wp.func
644+
def _max_contacts_for_shape_pair_impl(type_a: int, type_b: int) -> tuple[int, int]:
645+
"""
646+
Return the contact capacity for a canonical shape pair without reordering.
647+
648+
This is used for testing purposes, asserting that type_a > type_b always returns (0, 0).
649+
This enforces that the implementation doesn't accidentally specify an unreachable pair.
650+
"""
651+
if type_a == GeoType.PLANE:
652+
if type_b == GeoType.HFIELD:
653+
return _MESH_CONVEX_MAX, 0
654+
elif type_b == GeoType.SPHERE:
655+
return 1, 0
656+
elif type_b == GeoType.CAPSULE:
657+
return 2, 0
658+
elif type_b == GeoType.ELLIPSOID:
659+
return 1, 0
660+
elif type_b == GeoType.CYLINDER:
661+
return 4, 0
662+
elif type_b == GeoType.BOX:
663+
return 4, 0
664+
elif type_b == GeoType.MESH or type_b == GeoType.CONVEX_MESH:
665+
return _MESH_CONVEX_MAX, 0
666+
elif type_b == GeoType.CONE:
667+
return 5, 0
668+
669+
elif type_a == GeoType.HFIELD:
670+
if type_b == GeoType.MESH:
671+
return _MESH_MESH_MAX, 0
672+
elif type_b >= GeoType.HFIELD:
673+
return _MESH_CONVEX_MAX, 0
674+
675+
elif type_a == GeoType.SPHERE:
676+
if type_b == GeoType.MESH or type_b == GeoType.CONVEX_MESH:
677+
return _MESH_CONVEX_MAX, 0
678+
elif type_b >= GeoType.SPHERE:
679+
return 1, 0
638680

639681
elif type_a == GeoType.CAPSULE:
640682
if type_b == GeoType.CAPSULE:
641-
return 2, 2
683+
return 2, 0
642684
elif type_b == GeoType.ELLIPSOID:
643-
return 8, 8
685+
return 1, 0
644686
elif type_b == GeoType.CYLINDER:
645-
return 4, 4
687+
return 5, 0
646688
elif type_b == GeoType.BOX:
647-
return 8, 8
689+
return 5, 0
648690
elif type_b == GeoType.MESH or type_b == GeoType.CONVEX_MESH:
649691
return _MESH_CONVEX_MAX, 0
650692
elif type_b == GeoType.CONE:
651-
return 4, 4
652-
elif type_b == GeoType.PLANE:
653-
return 8, 8
693+
return 5, 0
654694

655695
elif type_a == GeoType.ELLIPSOID:
656696
if type_b == GeoType.ELLIPSOID:
657-
return 4, 4
697+
return 1, 0
658698
elif type_b == GeoType.CYLINDER:
659-
return 4, 4
699+
return 1, 0
660700
elif type_b == GeoType.BOX:
661-
return 8, 8
701+
return 1, 0
662702
elif type_b == GeoType.MESH or type_b == GeoType.CONVEX_MESH:
663703
return _MESH_CONVEX_MAX, 0
664704
elif type_b == GeoType.CONE:
665-
return 8, 8
666-
elif type_b == GeoType.PLANE:
667-
return 4, 4
705+
return 1, 0
668706

669707
elif type_a == GeoType.CYLINDER:
670708
if type_b == GeoType.CYLINDER:
671-
return 4, 4
709+
return 5, 0
672710
elif type_b == GeoType.BOX:
673-
return 8, 8
711+
return 5, 0
674712
elif type_b == GeoType.MESH or type_b == GeoType.CONVEX_MESH:
675713
return _MESH_CONVEX_MAX, 0
676714
elif type_b == GeoType.CONE:
677-
return 4, 4
678-
elif type_b == GeoType.PLANE:
679-
return 6, 6
715+
return 5, 0
680716

681717
elif type_a == GeoType.BOX:
682718
if type_b == GeoType.BOX:
683-
return 12, 12
719+
return 8, 0
684720
elif type_b == GeoType.MESH or type_b == GeoType.CONVEX_MESH:
685721
return _MESH_CONVEX_MAX, 0
686722
elif type_b == GeoType.CONE:
687-
return 8, 8
688-
elif type_b == GeoType.PLANE:
689-
return 12, 12
723+
return 5, 0
690724

691-
elif type_a == GeoType.MESH or type_a == GeoType.CONVEX_MESH:
692-
if type_b == GeoType.HFIELD:
725+
elif type_a == GeoType.MESH:
726+
if type_b == GeoType.MESH:
693727
return _MESH_MESH_MAX, 0
694728
elif type_b == GeoType.CONE:
695729
return _MESH_CONVEX_MAX, 0
696-
elif type_b == GeoType.PLANE:
697-
return _MESH_CONVEX_MAX, 0
698-
else:
730+
elif type_b == GeoType.CONVEX_MESH:
699731
return _MESH_MESH_MAX, 0
700732

701-
elif type_a == GeoType.HFIELD:
702-
# Heightfield vs convex primitives
703-
return _MESH_CONVEX_MAX, 0
704-
705733
elif type_a == GeoType.CONE:
706734
if type_b == GeoType.CONE:
707-
return 4, 4
708-
elif type_b == GeoType.PLANE:
709-
return 8, 8
735+
return 5, 0
736+
elif type_b == GeoType.CONVEX_MESH:
737+
return _MESH_CONVEX_MAX, 0
710738

711-
elif type_a == GeoType.PLANE:
712-
pass
739+
elif type_a == GeoType.CONVEX_MESH:
740+
if type_b == GeoType.CONVEX_MESH:
741+
return _MESH_MESH_MAX, 0
713742

714743
# unsupported type combination
715744
return 0, 0

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

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1352,17 +1352,18 @@ def convert_contacts_newton_to_kamino(
13521352
# convert exceeds the capacity of the output contacts.
13531353
if contacts_in.rigid_contact_max > contacts_out.model_max_contacts_host:
13541354
msg.warning(
1355-
"Newton `rigid_contact_max` (%d) exceeds Kamino `model_max_contacts_host` (%d); contacts will be truncated.",
1355+
"Newton `rigid_contact_max` (%d) exceeds Kamino `model_max_contacts_host` (%d); active contacts may be truncated.",
13561356
contacts_in.rigid_contact_max,
13571357
contacts_out.model_max_contacts_host,
13581358
)
13591359

13601360
# Skip conversion of contact forces if not requested
13611361
contacts_in_force = contacts_in.force if convert_forces else None
13621362

1363-
# Set the maximum number of contacts to convert to the smallest of the
1364-
# number of contacts detected and the maximum capacity of the output contacts.
1365-
max_converted_contacts = min(contacts_in.rigid_contact_max, contacts_out.model_max_contacts_host)
1363+
# Scan every Newton contact slot so saturated worlds cannot prevent later
1364+
# worlds from filling their own capacity. The kernel skips inactive slots
1365+
# and enforces per-world and model limits.
1366+
max_converted_contacts = contacts_in.rigid_contact_max
13661367

13671368
# Clear the output contacts to reset the active contact
13681369
# counts and reset contact data to sentinel values.
@@ -1374,9 +1375,7 @@ def convert_contacts_newton_to_kamino(
13741375
restitution_mix_mode=MaterialMixMode.from_string(restitution_mix_mode),
13751376
)
13761377

1377-
# Launch the conversion kernel to convert Newton contacts to Kamino's format
1378-
# NOTE: To reduce overhead, the total thread count is set to the smallest of
1379-
# the number of contacts detected and the maximum capacity of the output contacts.
1378+
# Launch the conversion kernel to convert Newton contacts to Kamino's format.
13801379
wp.launch(
13811380
kernel=_convert_contacts_newton_to_kamino,
13821381
dim=max_converted_contacts,
@@ -1494,11 +1493,10 @@ def convert_contacts_kamino_to_newton(
14941493
f"contacts_out.device={contacts_out.device}"
14951494
)
14961495

1497-
# Issue warning to the user if the number of contacts to
1498-
# convert exceeds the capacity of the output contacts.
1499-
if contacts_in.model_max_contacts_host > contacts_out.rigid_contact_max:
1496+
# Only Kamino-generated contacts can be truncated during export.
1497+
if clear_output and contacts_in.model_max_contacts_host > contacts_out.rigid_contact_max:
15001498
msg.warning(
1501-
"Kamino `model_max_contacts_host` (%d) exceeds Newton `rigid_contact_max` (%d); contacts will be truncated.",
1499+
"Kamino `model_max_contacts_host` (%d) exceeds Newton `rigid_contact_max` (%d); active contacts may be truncated.",
15021500
contacts_in.model_max_contacts_host,
15031501
contacts_out.rigid_contact_max,
15041502
)

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

Lines changed: 87 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,86 @@ def __repr__(self):
147147
return self.__str__()
148148

149149

150+
###
151+
# Contact capacity helpers
152+
###
153+
154+
# Conservative heuristics for the fallback allocation path when pair-based
155+
# capacity metadata is unavailable (``model_minimum_contacts == 0``).
156+
_EXPLICIT_CONTACTS_PER_PAIR = 10
157+
_DYNAMIC_CONTACTS_PER_COLLIDABLE = 20
158+
159+
160+
def _cap_world_contacts_at_total(world_max_contacts: list[int], max_total: int) -> list[int]:
161+
"""Scale per-world contact budgets down so their sum does not exceed ``max_total``."""
162+
total = sum(world_max_contacts)
163+
if total <= max_total:
164+
return list(world_max_contacts)
165+
if max_total <= 0:
166+
return [0] * len(world_max_contacts)
167+
168+
capped = [0] * len(world_max_contacts)
169+
remainders: list[tuple[float, int]] = []
170+
assigned = 0
171+
for i, count in enumerate(world_max_contacts):
172+
scaled = count * max_total / total
173+
floor = int(scaled)
174+
capped[i] = floor
175+
assigned += floor
176+
remainders.append((scaled - floor, i))
177+
for _, i in sorted(remainders, key=lambda item: item[0], reverse=True):
178+
if assigned >= max_total:
179+
break
180+
capped[i] += 1
181+
assigned += 1
182+
return capped
183+
184+
185+
def _estimate_fallback_world_max_contacts(
186+
model: ModelKamino,
187+
config: CollisionDetectorConfig,
188+
) -> list[int]:
189+
"""Estimate per-world contact capacity from geometry when pair metadata is unavailable."""
190+
num_worlds = model.size.num_worlds
191+
world_max_contacts = [0] * num_worlds
192+
193+
if config.broadphase == "explicit" and model.geoms.collidable_pairs is not None:
194+
pairs = model.geoms.collidable_pairs.numpy()
195+
wid = model.geoms.wid.numpy()
196+
for pair in pairs:
197+
g0, g1 = int(pair[0]), int(pair[1])
198+
world_id = int(wid[g0]) if wid[g0] >= 0 else int(wid[g1])
199+
if 0 <= world_id < num_worlds:
200+
world_max_contacts[world_id] += _EXPLICIT_CONTACTS_PER_PAIR
201+
else:
202+
wid = model.geoms.wid.numpy()
203+
group = model.geoms.group.numpy()
204+
for geom_id in range(len(wid)):
205+
world_id = int(wid[geom_id])
206+
if 0 <= world_id < num_worlds and group[geom_id] > 0:
207+
world_max_contacts[world_id] += _DYNAMIC_CONTACTS_PER_COLLIDABLE
208+
209+
return world_max_contacts
210+
211+
212+
def _resolve_contact_capacity(
213+
model: ModelKamino,
214+
config: CollisionDetectorConfig,
215+
) -> tuple[int, list[int]]:
216+
"""Resolve model- and per-world contact budgets from geometry and config caps."""
217+
if model.geoms.model_minimum_contacts > 0:
218+
world_max_contacts = list(model.geoms.world_minimum_contacts)
219+
else:
220+
world_max_contacts = _estimate_fallback_world_max_contacts(model, config)
221+
222+
model_max_contacts = sum(world_max_contacts)
223+
if model_max_contacts > config.max_contacts:
224+
world_max_contacts = _cap_world_contacts_at_total(world_max_contacts, config.max_contacts)
225+
model_max_contacts = sum(world_max_contacts)
226+
227+
return model_max_contacts, world_max_contacts
228+
229+
150230
###
151231
# Interfaces
152232
###
@@ -303,41 +383,22 @@ def finalize(
303383
# Configure the collision detection pipeline type based on the config
304384
self._pipeline_type = CollisionPipelineType.from_string(self._config.pipeline)
305385

306-
# TODO: FIX THIS SO THAT PER-WORLD MAX IS ACTUALLY BASED ON THE NUM OF COLLIDABLE
307-
# GOEMS IN EACH WORLD, INSTEAD OF JUST DIVIDING THE MODEL MAX BY THE NUM WORLDS
308-
# For collision pipeline, we don't multiply by per-pair factors since broad phase
309-
# discovers pairs dynamically. Users can provide rigid_contact_max explicitly,
310-
# otherwise it is estimated from shape count and broad phase mode.
311-
if self._model.geoms.model_minimum_contacts > 0:
312-
self._model_max_contacts = self._model.geoms.model_minimum_contacts
313-
self._world_max_contacts = self._model.geoms.world_minimum_contacts
314-
else:
315-
# Estimate based on broad phase mode and available information
316-
if self._config.broadphase == "explicit" and self._model.geoms.collidable_pairs is not None:
317-
# For EXPLICIT mode, we know the maximum possible pairs
318-
# Estimate ~10 contacts per shape pair (conservative for mesh-mesh contacts)
319-
self._model_max_contacts = max(self._config.max_contacts, self._model.geoms.num_collidable_pairs * 10)
320-
else:
321-
# For NXN/SAP dynamic broad phase, estimate based on shape count
322-
# Assume each shape contacts ~20 others on average (conservative estimate)
323-
# This scales much better than O(N²) while still being safe
324-
self._model_max_contacts = max(self._config.max_contacts, self._model.geoms.num_collidable * 20)
325-
326-
# Set the world max contacts to be the same for all worlds in the model
327-
num_worlds = self._model.size.num_worlds
328-
self._world_max_contacts = [self._model_max_contacts // num_worlds] * num_worlds
329-
330-
# Override per-world max contacts if config specifies it.
386+
# Resolve contact capacity.
331387
if self._config.max_contacts_per_world is not None:
388+
# Use the explicit per-world override when available.
332389
num_worlds = self._model.size.num_worlds
333390
per_world = self._config.max_contacts_per_world
334391
self._world_max_contacts = [per_world] * num_worlds
335392
self._model_max_contacts = per_world * num_worlds
393+
else:
394+
# Otherwise estimate per world from geometry.
395+
# ``max_contacts`` caps the model total.
396+
self._model_max_contacts, self._world_max_contacts = _resolve_contact_capacity(self._model, self._config)
336397

337398
# Create the contacts interface which will allocate all contacts data arrays
338399
# NOTE: If internal allocations happen, then they will contain
339400
# the contacts generated by the collision detection pipelines
340-
self._contacts = ContactsKamino(capacity=self._world_max_contacts, device=self._device)
401+
self._contacts = ContactsKamino(capacity=list(self._world_max_contacts), device=self._device)
341402

342403
# Proceed with allocations only if the model admits contacts, which
343404
# occurs when collision geometries defined in the builder and model

0 commit comments

Comments
 (0)