Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
- Fix USD capsule, cylinder, and cone visuals and sites without authored `radius`/`height` to use the UsdGeom schema fallbacks, matching collision shapes.
- Fix `ViewerUSD` texture consumers observing partially written PNGs by publishing generated textures atomically (#3288)
- Fix loading of textures packaged inside `.usdz` archives; package-relative asset paths such as `scene.usdz[tex.png]` are resolved through USD's asset resolver instead of being treated as filesystem paths.
- Preserve cross-import collision pairs when `SolverMuJoCo` combines independently imported MJCF mask domains.
- Fix `ModelBuilder.add_usd()` raising `ValueError` when importing a mesh whose material subset binds a texture that decodes to an image array.
- Fix `ModelBuilder.add_usd()` dropping textures from full meshes and material subsets without recoverable UVs; preserve the texture for projected rendering.
- Fix textured USD visual meshes and material subsets rendering tinted by scalar or default per-shape colors; textured meshes now import with a white base color so their textures are shown untinted.
Expand All @@ -124,6 +125,7 @@
- Fix `ModelBuilder.add_usd()` treating explicitly authored USD `MassAPI` schema fallback values (zero mass, density, inertia, or principal axes; non-finite center of mass) as overrides; per the schema's value semantics they now behave like unauthored attributes, while negative or non-finite mass, density, and diagonal inertia values are ignored with a warning. (#3418)
- Report malformed MJCF free-joint and inertial inputs with deterministic validation errors, and ignore MJCF mesh geom `size` lengths consistently.
- Fix MJCF imports ignoring material and inline RGBA colors on primitive geoms.
- Preserve MJCF `contype`/`conaffinity` collision filtering when importing into Newton, and compose later Newton pair filters when using native MuJoCo contacts.
- Fix `SolverMuJoCo` site poses for offset batched worlds and site poses and sizes for runtime shape updates. (#3389)
- Fix `ModelBuilder.add_usd()` silently dropping a MuJoCo joint equality constraint when the asset supplies the leader joint and coefficients through `NewtonMimicAPI` instead of the deprecated `mjc:target`, `mjc:coef0`, and `mjc:coef1`. `MjcEqualityJointAPI` builds on `NewtonMimicAPI`, so both spellings are now accepted.
- Fix `ModelBuilder.add_usd()` ignoring `newton:mimicEnabled` on a joint with `MjcEqualityJointAPI` applied. Such a joint is now imported disabled rather than coupled, which also stops the default equality conversion from enforcing the coupling in every solver.
Expand Down
88 changes: 78 additions & 10 deletions docs/solvers/mujoco.rst
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,80 @@ Newton's pipeline supports non-convex meshes, SDF-based contacts, and
hydroelastic contacts, which are not available through MuJoCo's collision
detection.

Collision filtering
~~~~~~~~~~~~~~~~~~~

MuJoCo gives every geom two 32-bit masks, ``contype`` and ``conaffinity``.
Together, these masks decide whether two geoms are allowed to collide. For a
candidate pair ``a, b``, the mask test passes when
``(contype_a & conaffinity_b) != 0`` or
``(contype_b & conaffinity_a) != 0``. MuJoCo then applies other selection
rules, including same-body suppression and body-wide ``<exclude>`` elements;
explicit ``<pair>`` elements bypass the automatic mask test. See MuJoCo's
`collision selection documentation
<https://mujoco.readthedocs.io/en/stable/computation/index.html#selection>`__
and the `geom mask attributes
<https://mujoco.readthedocs.io/en/stable/XMLreference.html#body-geom>`__.

**Importing MJCF masks.**

:func:`~newton.utils.parse_mjcf` resolves inherited ``contype`` and
``conaffinity`` values and determines which shape pairs may collide. It stores
the same result in Newton collision groups and explicit excluded pairs. The
original 32-bit values are also retained as
``model.mujoco.contype`` and ``model.mujoco.conaffinity`` custom attributes
for a lossless round trip back to MuJoCo.

A *mask domain* is the set of shapes whose mask bits were authored together.
Each :func:`~newton.utils.parse_mjcf` call creates a new domain and records it
in the internal ``model.mujoco.collision_mask_domain`` attribute. The domain
is only a source label. It is not another collision mask and does not enable
or disable contacts. When :meth:`~newton.ModelBuilder.add_builder` combines
separately built models, it gives the copied domains new IDs so they remain
distinct from domains already in the destination builder.

**Choosing masks for a MuJoCo solver.**

With ``use_mujoco_contacts=True``, preserved source masks are forwarded
verbatim only when every selected collision shape has masks from the same
domain and those masks already enforce all active Newton pair filters.
Same-body filtering and imported body-wide ``<exclude>`` elements also count
as enforced. This path preserves the source MJCF exactly. For a single import,
the original masks therefore remain the source of truth even if its Newton
collision groups are later edited.

A native shape, shapes from more than one domain, or a new Newton pair filter
can make the original masks unsafe to reuse. The solver then lists the shape
pairs that Newton allows and creates new MuJoCo masks that reproduce that
list.

**Example: combining two MJCF files.**

Suppose file A and file B both use bit 0. In file A, bit 0 may control contacts
between its floor and spheres. File B may reuse bit 0 for its own shapes. The
files were authored independently, so that shared number says nothing about
how a shape from A should interact with a shape from B.

After both files are added to one builder, Newton's collision groups and
excluded pairs define those new cross-file interactions. Copying the original
masks would make MuJoCo treat bit 0 as one global rule and could allow or block
the wrong cross-file pairs. Because the shapes have different domains, the
solver instead creates new masks from Newton's final list of allowed pairs.

**Compiling Newton filtering to MuJoCo masks.**

MuJoCo provides only 32 mask bits. One bit can encode all collisions between
one set of shapes and another set. In graph terminology, that rule is a
complete bipartite graph, or biclique. The solver tries to reproduce Newton's
full list of allowed pairs using at most 32 such rules. It is guaranteed to
find an exact result for up to 33 selected shapes and often handles much larger
models whose collision groups have a regular structure.

If the rules do not fit in 32 bits, the solver uses the established legacy
graph-color approximation, which may allow extra contacts. Finding an exact
result requires checking every shape pair, so models above 256 selected shapes
or 1,024 explicit excluded pairs skip directly to that fallback.

.. _mujoco-margin-gap-mapping:

Margin and gap mapping
Expand Down Expand Up @@ -782,16 +856,10 @@ Caveats
prescribed. The user-supplied armature on those DOFs is silently
discarded. See `Kinematic links and fixed roots`_.

**Collision filtering bitmask fallback.**
Newton's :attr:`~newton.Model.shape_collision_group` (see
:ref:`Collision Groups`) is translated to MuJoCo's ``contype`` /
``conaffinity`` via graph coloring
(:github:`newton/_src/sim/graph_coloring.py`). Up to 32 colors are
supported (one per ``contype`` bit). If the filtering graph requires
more, shapes with color index ≥ 32 fall back to ``contype=1`` /
``conaffinity=1`` and silently collide with every other shape,
bypassing the intended filtering and adding extra contact pairs to
the broadphase.
**Collision filtering has a 32-bit capacity.**
The solver creates MuJoCo masks that reproduce Newton's allowed collision
pairs when they fit in 32 bits. See `Collision filtering`_ for the behavior
of imported masks and the warned fallback used when the rules do not fit.


.. _mujoco-kinematic-links-and-fixed-roots:
Expand Down
55 changes: 43 additions & 12 deletions newton/_src/sim/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -3961,6 +3961,29 @@ def _merge_builder_custom_attributes(
) -> None:
custom_frequency_offsets = dict(self._custom_frequency_counts)

# Builders allocate MJCF mask-domain IDs independently. Remap every
# incoming domain as one unit so its IDs cannot collide with domains
# already present in the destination builder.
collision_mask_domain_key = "mujoco:collision_mask_domain"
collision_mask_domain_remap: dict[int, int] = {}
source_domain_attr = builder.custom_attributes.get(collision_mask_domain_key)
if source_domain_attr is not None and source_domain_attr.values:
source_items = (
source_domain_attr.values.items()
if isinstance(source_domain_attr.values, dict)
else enumerate(source_domain_attr.values)
)
# Copied shape ranges never overlap, so the first destination shape
# in each source domain is already a unique, deterministic ID. This
# avoids rescanning the growing destination during replication.
shape_offset = entity_offsets["shape"]
for shape, value in source_items:
if value is None:
continue
source_domain = int(value)
if source_domain >= 0:
collision_mask_domain_remap.setdefault(source_domain, shape_offset + shape)

def get_offset(entity_or_key: str | None) -> int:
if entity_or_key is None:
return 0
Expand Down Expand Up @@ -3997,7 +4020,10 @@ def get_offset(entity_or_key: str | None) -> int:
use_current_world = attr.references == "world"
value_offset = 0 if use_current_world else get_offset(attr.references)
is_equality_target_attr = full_key == "mujoco:equality_constraint_target"
needs_remap = value_offset != 0 or use_current_world or is_equality_target_attr
is_collision_mask_domain_attr = full_key == collision_mask_domain_key and bool(collision_mask_domain_remap)
needs_remap = (
value_offset != 0 or use_current_world or is_equality_target_attr or is_collision_mask_domain_attr
)

if needs_remap:

Expand Down Expand Up @@ -4053,9 +4079,12 @@ def transform_enum_value(
entity_idx: int,
value: Any,
is_equality_target: bool = is_equality_target_attr,
is_collision_mask_domain: bool = is_collision_mask_domain_attr,
) -> Any:
if is_equality_target:
return transform_equality_target_value(entity_idx, value)
if is_collision_mask_domain:
return collision_mask_domain_remap.get(int(value), value)
return transform_value(value)

merged = self.custom_attributes.get(full_key)
Expand Down Expand Up @@ -12438,34 +12467,36 @@ def _find_shape_contact_pairs(self, model: Model, *, allow_filter_blocks: bool)
self._iter_validated_shape_collision_filter_pairs((*filter_pairs.explicit_pairs, *floating_block_pairs))
)

# Builder-side compact blocks are valid only while they describe the
# model's filters exactly; otherwise the general path queries the model.
use_filter_blocks = bool(world_filter_blocks) and allow_filter_blocks
if use_filter_blocks:
# Builder-side storage is valid only while it describes the model's
# filters exactly; otherwise the general path queries the model.
use_world_templates = (
allow_filter_blocks and self.world_count > 0 and isinstance(filter_pairs, _BuilderShapeCollisionFilterPairs)
)
if use_world_templates:
shape_world_np = np.asarray(self.shape_world, dtype=np.int32)
starts = self.shape_world_start
if len(starts) != self.world_count + 2:
use_filter_blocks = False
use_world_templates = False
else:
segment_worlds = np.full(self.shape_count, -1, dtype=np.int32)
for world in range(self.world_count):
segment_worlds[starts[world] : starts[world + 1]] = world
use_filter_blocks = np.array_equal(segment_worlds, shape_world_np)
use_world_templates = np.array_equal(segment_worlds, shape_world_np)

if use_filter_blocks:
if use_world_templates:
blocks_by_world = {}
global_filter_pairs = set()
explicit_filters_by_world = {}
for block in world_filter_blocks:
world = block.world
if world < 0 or world >= self.world_count:
use_filter_blocks = False
use_world_templates = False
break

world_start = self.shape_world_start[world]
world_end = self.shape_world_start[world + 1]
if block.shape_start < world_start or block.shape_start + block.shape_count > world_end:
use_filter_blocks = False
use_world_templates = False
break

# Store block starts as world-local offsets for the template cache
Expand All @@ -12474,7 +12505,7 @@ def _find_shape_contact_pairs(self, model: Model, *, allow_filter_blocks: bool)
(block.shape_start - world_start, block.shape_count, block.local_pairs)
)

if use_filter_blocks:
if use_world_templates:
# Residual explicit filters may involve global shapes, so split
# them into globally keyed filters and per-world local filters.
for shape_a, shape_b in explicit_filter_pairs:
Expand All @@ -12498,7 +12529,7 @@ def _find_shape_contact_pairs(self, model: Model, *, allow_filter_blocks: bool)
)
# Cross-world pairs never collide, so filtering them is a no-op.

if use_filter_blocks:
if use_world_templates:
contact_pairs = []
shape_flags_np = np.asarray(self.shape_flags, dtype=np.int64)
colliding_np = (shape_flags_np & int(ShapeFlags.COLLIDE_SHAPES)) != 0
Expand Down
Loading
Loading