Skip to content

Commit f37a3ea

Browse files
Forward collision group and shape flags in Kamino's collision pipeline (#4089)
1 parent db1e02e commit f37a3ea

4 files changed

Lines changed: 143 additions & 83 deletions

File tree

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1272,7 +1272,13 @@ def collect_geometry_model_data():
12721272
geoms_params.append(shape.paramsvec)
12731273
geoms_offset.append(geom.offset)
12741274
geoms_material.append(geom.mid)
1275-
geoms_group.append(geom.group)
1275+
# `GeometriesModel.group` feeds Newton's broad-phase group test, which uses a
1276+
# single integer per shape (positive groups mutually collide, zero never
1277+
# collides). Kamino's own group/collides bitmask is strictly more expressive and
1278+
# is already fully resolved into `collidable_pairs`/`excluded_pairs` above, so
1279+
# here we only need to preserve collidability, collapsing every collidable group
1280+
# onto the same value.
1281+
geoms_group.append(1 if geom.group > 0 else 0)
12761282
geoms_collides.append(geom.collides)
12771283
geoms_gap.append(geom.gap)
12781284
geoms_margin.append(geom.margin)

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

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,23 @@ class GeometryDescriptor(Descriptor):
8989

9090
group: int = 1
9191
"""
92-
The collision group assigned to the collision geometry.
92+
The collision group assigned to the collision geometry. Together with this
93+
geometry's `collides` value, this determines whether the geometry collides
94+
with another geometry with a given `group` and `collides`:
95+
96+
``can_collide = ((geom1.group & geom2.collides) != 0) and ((geom2.group & geom1.collides) != 0)``
97+
9398
Defaults to the default group with value `1`.
9499
"""
95100

96101
collides: int = 1
97102
"""
98-
The collision groups with which the collision geometry can collide.
103+
The collision groups with which the collision geometry can collide. Together
104+
with this geometry's `group` value, this determines whether the geometry
105+
collides with another geometry with a given `group` and `collides`:
106+
107+
``can_collide = ((geom1.group & geom2.collides) != 0) and ((geom2.group & geom1.collides) != 0)``
108+
99109
Defaults to enabling collisions with the default group with value `1`.
100110
"""
101111

@@ -310,7 +320,15 @@ class GeometriesModel:
310320

311321
group: wp.array[wp.int32] | None = None
312322
"""
313-
Collision group assigned to each collision geometry.
323+
Collision group assigned to each collision geometry. These groups are based
324+
on Newton's collision group semantics, not the group/collides semantics used
325+
by `ModelBuilderKamino`.
326+
327+
Group `0` will not collide with anything. Any positive group N will collide
328+
with the same group as well as any negative group. Any negative group -M
329+
will collide with all groups except -M. See docs/concepts/collisions.rst
330+
for details.
331+
314332
Shape of ``(num_geoms,)``.
315333
"""
316334

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

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
from .....geometry.broad_phase_sap import BroadPhaseSAP
2020
from .....geometry.collision_core import compute_tight_aabb_from_support
2121
from .....geometry.contact_data import ContactData
22-
from .....geometry.flags import ShapeFlags
2322
from .....geometry.narrow_phase import NarrowPhase
2423
from .....geometry.sdf_texture import TextureSDFData
2524
from .....geometry.support_function import GenericShapeData, SupportMapDataProvider, pack_mesh_ptr
@@ -29,9 +28,6 @@
2928
from ..core.data import DataKamino
3029
from ..core.materials import DEFAULT_FRICTION, DEFAULT_RESTITUTION, make_get_material_pair_properties
3130
from ..core.model import ModelKamino
32-
from ..core.types import (
33-
to_warp_int32_array,
34-
)
3531
from ..geometry.contacts import (
3632
DEFAULT_GEOM_PAIR_CONTACT_GAP,
3733
DEFAULT_GEOM_PAIR_MAX_CONTACTS,
@@ -471,27 +467,15 @@ def __init__(
471467
self._max_contacts = self._model.geoms.model_minimum_contacts
472468

473469
# Build excluded pairs for NXN/SAP broadphase filtering.
474-
# Kamino uses a bitmask group/collides system that is more expressive than
475-
# Newton's integer collision groups. We keep all broadphase groups at 1
476-
# (same-group, all pairs pass group check) and instead supply an explicit
477-
# list of excluded pairs that encodes same-body, group/collides, and
478-
# neighbor-joint filtering.
479-
geom_collision_group_list = [1] * self._num_geoms
480470
self._excluded_pairs: wp.array[wp.vec2i] | None = None
481471
self._num_excluded_pairs: int = 0
482472
if broadphase in ("nxn", "sap"):
483473
self._excluded_pairs = self._model.geoms.excluded_pairs
484474
self._num_excluded_pairs = self._model.geoms.num_excluded_pairs
485475

486-
# Capture a reference to per-geometry world indices already present in the model
476+
# Capture a reference to per-geometry world indices and flags already present in the model
487477
self.geom_wid: wp.array[wp.int32] = self._model.geoms.wid
488-
489-
# Define default shape flags for all geometries
490-
default_shape_flag: int = (
491-
ShapeFlags.VISIBLE # Mark as visible for debugging/visualization
492-
| ShapeFlags.COLLIDE_SHAPES # Enable shape-shape collision
493-
| ShapeFlags.COLLIDE_PARTICLES # Enable shape-particle collision
494-
)
478+
self.shape_flags: wp.array[wp.int32] = self._model.geoms.flags
495479

496480
# Detect whether the model contains mesh, convex mesh, or heightfield shapes.
497481
# Keep mesh and heightfield flags separate: heightfield-only scenes should not
@@ -505,9 +489,8 @@ def __init__(
505489
# the Kamino model and data do not yet provide
506490
with wp.ScopedDevice(self._device):
507491
self.geom_data = wp.zeros(self._num_geoms, dtype=wp.vec4f)
508-
self.geom_collision_group = to_warp_int32_array(geom_collision_group_list)
492+
self.geom_collision_group = self._model.geoms.group
509493
self.collision_radius = wp.zeros(self._num_geoms, dtype=wp.float32)
510-
self.shape_flags = wp.full(self._num_geoms, default_shape_flag, dtype=wp.int32)
511494
self.shape_aabb_lower = wp.zeros(self._num_geoms, dtype=wp.vec3)
512495
self.shape_aabb_upper = wp.zeros(self._num_geoms, dtype=wp.vec3)
513496
self.broad_phase_pairs = wp.zeros(self._max_shape_pairs, dtype=wp.vec2i)
@@ -539,9 +522,11 @@ def __init__(
539522
# Initialize the broad-phase backend depending on the selected mode
540523
match self._broadphase:
541524
case "nxn":
542-
self.nxn_broadphase = BroadPhaseAllPairs(self.geom_wid, shape_flags=None, device=self._device)
525+
self.nxn_broadphase = BroadPhaseAllPairs(
526+
self.geom_wid, shape_flags=self.shape_flags, device=self._device
527+
)
543528
case "sap":
544-
self.sap_broadphase = BroadPhaseSAP(self.geom_wid, shape_flags=None, device=self._device)
529+
self.sap_broadphase = BroadPhaseSAP(self.geom_wid, shape_flags=self.shape_flags, device=self._device)
545530
case "explicit":
546531
self.explicit_broadphase = BroadPhaseExplicit()
547532
case _:

newton/tests/kamino/test_kamino_geometry_unified.py

Lines changed: 108 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,10 @@
1212
import numpy as np
1313
import warp as wp
1414

15+
from newton import ModelBuilder
1516
from newton._src.solvers.kamino._src.core.builder import ModelBuilderKamino
1617
from newton._src.solvers.kamino._src.core.data import DataKamino
17-
from newton._src.solvers.kamino._src.core.math import I_3
1818
from newton._src.solvers.kamino._src.core.model import ModelKamino
19-
from newton._src.solvers.kamino._src.core.shapes import SphereShape
2019
from newton._src.solvers.kamino._src.geometry.contacts import ContactsKamino
2120
from newton._src.solvers.kamino._src.geometry.unified import CollisionPipelineUnifiedKamino
2221
from newton._src.solvers.kamino._src.models.builders import basics, testing
@@ -630,7 +629,11 @@ def test_03_gap_rejects_distant_contact(self):
630629

631630

632631
class TestUnifiedPipelineNxnBroadphase(unittest.TestCase):
633-
"""Tests verifying NXN broadphase correctness with collision radii and filter pairs."""
632+
"""Tests verifying NXN broadphase correctness with collision radii and filter pairs.
633+
634+
These tests are Kamino-specific and ensure that the combination of model
635+
creation/conversion and pipeline setup reproduce the desired behavior.
636+
"""
634637

635638
def setUp(self):
636639
if not test_context.setup_done:
@@ -640,29 +643,66 @@ def setUp(self):
640643
def tearDown(self):
641644
self.default_device = None
642645

643-
def _make_two_sphere_builder(self, group_a=1, collides_a=1, group_b=1, collides_b=1, same_body=False):
646+
def _make_two_sphere_builder(
647+
self,
648+
group_a=1,
649+
group_b=1,
650+
same_body=False,
651+
collidable_a=True,
652+
collidable_b=True,
653+
):
644654
"""Helper: build a single-world scene with two spheres near each other."""
645-
builder = ModelBuilderKamino()
646-
builder.add_world()
647-
bid_a = builder.add_rigid_body(
648-
m_i=1.0,
649-
i_I_i=I_3,
650-
q_i_0=wp.transformf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0),
651-
u_i_0=wp.spatial_vectorf(0.0),
655+
builder = ModelBuilder()
656+
builder.begin_world()
657+
bid_a = builder.add_body(
658+
mass=1.0,
659+
inertia=wp.mat33(np.eye(3, dtype=np.float32)),
660+
xform=wp.transformf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0),
652661
)
653662
if same_body:
654663
bid_b = bid_a
655664
else:
656-
bid_b = builder.add_rigid_body(
657-
m_i=1.0,
658-
i_I_i=I_3,
659-
q_i_0=wp.transformf(0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0),
660-
u_i_0=wp.spatial_vectorf(0.0),
665+
bid_b = builder.add_body(
666+
mass=1.0,
667+
inertia=wp.mat33(np.eye(3, dtype=np.float32)),
668+
xform=wp.transformf(0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0),
661669
)
662-
builder.add_geometry(body=bid_a, shape=SphereShape(radius=0.5), group=group_a, collides=collides_a)
663-
builder.add_geometry(body=bid_b, shape=SphereShape(radius=0.5), group=group_b, collides=collides_b)
670+
builder.add_shape_sphere(
671+
body=bid_a,
672+
radius=0.5,
673+
cfg=ModelBuilder.ShapeConfig(collision_group=group_a, has_shape_collision=collidable_a),
674+
)
675+
builder.add_shape_sphere(
676+
body=bid_b,
677+
radius=0.5,
678+
cfg=ModelBuilder.ShapeConfig(collision_group=group_b, has_shape_collision=collidable_b),
679+
)
680+
builder.end_world()
664681
return builder
665682

683+
def _run_two_sphere_pipeline(self, builder: ModelBuilder) -> int:
684+
"""Helper: convert a Newton builder to `ModelKamino` and run the NXN broadphase.
685+
686+
Returns the number of active contacts produced.
687+
"""
688+
model = ModelKamino.from_newton(builder.finalize(self.default_device))
689+
data = model.data()
690+
691+
pipeline = CollisionPipelineUnifiedKamino(
692+
model=model,
693+
broadphase="nxn",
694+
default_gap=1.0,
695+
)
696+
697+
n_geoms = builder.shape_count
698+
capacity = 12 * ((n_geoms * (n_geoms - 1)) // 2)
699+
contacts = ContactsKamino(capacity=max(capacity, 12), device=self.default_device)
700+
contacts.clear()
701+
702+
pipeline.collide(data, contacts)
703+
704+
return int(contacts.model_active_contacts.numpy()[0])
705+
666706
def test_00_nxn_sphere_on_plane_generates_contacts(self):
667707
"""Sphere resting on a plane via NXN broadphase must produce contacts.
668708
@@ -710,32 +750,41 @@ def test_01_nxn_box_on_plane_generates_contacts(self):
710750
device=self.default_device,
711751
)
712752

713-
def test_02_nxn_excludes_non_collidable_pairs(self):
714-
"""NXN broadphase must exclude pairs whose group/collides bitmasks do not overlap.
715-
716-
Creates two spheres in the same world but with non-overlapping
717-
collision groups so that they should never collide.
718-
"""
719-
builder = self._make_two_sphere_builder(group_a=0b01, collides_a=0b01, group_b=0b10, collides_b=0b10)
720-
721-
model = builder.finalize(self.default_device)
722-
data = model.data()
723-
724-
pipeline = CollisionPipelineUnifiedKamino(
725-
model=model,
726-
broadphase="nxn",
727-
default_gap=1.0,
728-
)
753+
def test_02_nxn_newton_collision_group_semantics(self):
754+
"""NXN broadphase must follow Newton's collision-group semantics.
729755
730-
n_geoms = builder.num_geoms
731-
capacity = 12 * ((n_geoms * (n_geoms - 1)) // 2)
732-
contacts = ContactsKamino(capacity=max(capacity, 12), device=self.default_device)
733-
contacts.clear()
756+
Mirrors `newton._src.geometry.broad_phase_common.test_group_pair`:
757+
- group 0 never collides with anything, including another group 0.
758+
- A positive group collides only with the same positive group, or
759+
with any negative group.
760+
- A negative group collides with everything except the same
761+
negative group.
734762
735-
pipeline.collide(data, contacts)
763+
Creates two touching spheres on different bodies in the same world
764+
and checks every representative combination of group signs/values.
765+
"""
766+
cases = {
767+
"both_positive_equal": (1, 1, True),
768+
"both_positive_different": (1, 2, False),
769+
"positive_vs_zero": (1, 0, False),
770+
"both_zero": (0, 0, False),
771+
"positive_vs_negative_different": (1, -1, True),
772+
"both_negative_equal": (-2, -2, False),
773+
"both_negative_different": (-1, -2, True),
774+
"negative_vs_zero": (-1, 0, False),
775+
}
736776

737-
active = contacts.model_active_contacts.numpy()[0]
738-
self.assertEqual(active, 0, "Non-collidable groups must produce zero contacts via NXN")
777+
for case, (group_a, group_b, should_collide) in cases.items():
778+
with self.subTest(case=case, group_a=group_a, group_b=group_b):
779+
builder = self._make_two_sphere_builder(group_a=group_a, group_b=group_b)
780+
active = self._run_two_sphere_pipeline(builder)
781+
expected_active = 1 if should_collide else 0
782+
self.assertEqual(
783+
active,
784+
expected_active,
785+
f"groups ({group_a}, {group_b}) via nxn broadphase: "
786+
f"expected {'a contact' if should_collide else 'no contacts'}",
787+
)
739788

740789
def test_03_nxn_same_body_excluded(self):
741790
"""NXN broadphase must exclude same-body shape pairs.
@@ -744,25 +793,27 @@ def test_03_nxn_same_body_excluded(self):
744793
that no self-collision contacts are produced.
745794
"""
746795
builder = self._make_two_sphere_builder(same_body=True)
796+
active = self._run_two_sphere_pipeline(builder)
797+
self.assertEqual(active, 0, "Same-body shapes must not collide via NXN broadphase")
747798

748-
model = builder.finalize(self.default_device)
749-
data = model.data()
750-
751-
pipeline = CollisionPipelineUnifiedKamino(
752-
model=model,
753-
broadphase="nxn",
754-
default_gap=1.0,
755-
)
756-
757-
n_geoms = builder.num_geoms
758-
capacity = 12 * ((n_geoms * (n_geoms - 1)) // 2)
759-
contacts = ContactsKamino(capacity=max(capacity, 12), device=self.default_device)
760-
contacts.clear()
799+
def test_04_nxn_excludes_non_collidable_shapes(self):
800+
"""NXN broadphase must exclude shapes without the `COLLIDE_SHAPES` flag.
761801
762-
pipeline.collide(data, contacts)
802+
A shape built with `ShapeConfig(has_shape_collision=False)` (e.g. a
803+
sensor or visual-only shape) must never generate a contact, even
804+
though its collision group would otherwise allow it to collide.
805+
"""
806+
cases = {
807+
"shape_a_non_collidable": (False, True),
808+
"shape_b_non_collidable": (True, False),
809+
"both_non_collidable": (False, False),
810+
}
763811

764-
active = contacts.model_active_contacts.numpy()[0]
765-
self.assertEqual(active, 0, "Same-body shapes must not collide via NXN broadphase")
812+
for case, (collidable_a, collidable_b) in cases.items():
813+
with self.subTest(case=case):
814+
builder = self._make_two_sphere_builder(collidable_a=collidable_a, collidable_b=collidable_b)
815+
active = self._run_two_sphere_pipeline(builder)
816+
self.assertEqual(active, 0, "Non-collidable shapes must not collide via NXN broadphase")
766817

767818

768819
###

0 commit comments

Comments
 (0)