Skip to content

Commit a31ae1a

Browse files
AntoineRichardisaaclab-bot[bot]
authored andcommitted
Support OVPhysX in randomize_rigid_body_collider_offsets MDP event (#7569)
# Description > **Stacked on #7563.** This branch needs the `REST_OFFSET` / `CONTACT_OFFSET` / `RIGID_BODY_*_OFFSET` aliases and the CPU-only routing for those tensor types that #7563 adds. Review/merge that first; this diff collapses to a single commit once it lands, at which point the base should be retargeted to `develop`. `randomize_rigid_body_collider_offsets` had no OVPhysX implementation. Its backend dispatch tested only for `"newton"` and fell through a bare `else` to `_RandomizeRigidBodyColliderOffsetsPhysx`, which calls `get_rest_offsets` / `get_contact_offsets` / `set_rest_offsets` / `set_contact_offsets` on `asset.root_view`. On OVPhysX that view is an `OvPhysxView`, which defines none of them, so adding the term to a config raised `AttributeError` during `OvPhysxManager.reset()` (via `PHYSICS_READY`) and the environment never built. The error surfaced from inside the PhysX implementation, so it did not name the unsupported backend either. Found by the OVQA agent harness on Isaac-Cartpole (release/3.0.0, ovphysx 0.5.11). No shipped task uses the term today, so this is latent rather than a live regression. **Fix** - New `_RandomizeRigidBodyColliderOffsetsOvPhysx`, mirroring the PhysX variant. OVPhysX runs the PhysX solver, so rest/contact offsets are written directly, per collision shape, through the asset's `OvPhysxView`: `REST_OFFSET` / `CONTACT_OFFSET` for articulations, `RIGID_BODY_REST_OFFSET` / `RIGID_BODY_CONTACT_OFFSET` for rigid objects. Both bindings are CPU-resident `[N, S]` buffers, so the full tensor is read-modify-written on the host with the selected envs as write indices. - Dispatch now matches `randomize_rigid_body_material` in the same file: `ovphysxmanager` first (it contains the substring `physx`), then Newton, then PhysX, and a `ValueError` naming the manager for anything else instead of silently selecting PhysX. - Docstring lists OVPhysX as a supported backend. **Note on write order** PhysX enforces `restOffset < contactOffset` and on OVPhysX a violating `setRestOffset` is only logged, not raised. The term writes rest before contact, same as the PhysX variant, so raising both above a small authored contact offset can silently skip the rest write. Kept for PhysX parity; flagging in case we want to reorder in both backends. **Verification** New `source/isaaclab_ov/test/test_randomize_rigid_body_collider_offsets_mdp.py` drives the public term (stubbed `cfg` / `env` / `asset_cfg`) against a real OVPhysX `RigidObject` and `Articulation`, so the dispatch itself is covered. It reproduced the reported `AttributeError` before the fix. Assertions: selected envs land in the sampled range, unselected envs are untouched, and omitting one distribution leaves that offset alone. Passes on `cuda:0` and `cpu` (separate processes, ovphysx device lock). PhysX and Newton paths are unchanged. Dependencies: #7563. Fixes # (no tracking issue; reported by the OVQA agent harness) ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Release backport - [x] <!-- backport-active-release --> Backport this pull request to the active release branch after it merges into `develop` ## Screenshots Not applicable: no visual change. ## Checklist Docker and GPU tests run on demand. Push the commits you want tested, then comment `run-ci` on the pull request. - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have added a changelog fragment under `source/<pkg>/changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Kelly Guo <kellyg@nvidia.com> (cherry picked from commit 1b182e7)
1 parent 01ca2ad commit a31ae1a

4 files changed

Lines changed: 281 additions & 5 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Added
2+
^^^^^
3+
4+
* Added OVPhysX backend support to :class:`~isaaclab.envs.mdp.events.randomize_rigid_body_collider_offsets`.
5+
The term previously fell through to the PhysX implementation on OVPhysX and raised ``AttributeError``
6+
during environment construction, because :class:`~isaaclab_ov.sim.views.OvPhysxView` has none of the
7+
PhysX ``root_view`` offset accessors. Rest and contact offsets are now written per collision shape through
8+
the asset's view. An unrecognised physics manager now raises ``ValueError`` naming the backend instead of
9+
silently selecting the PhysX implementation.

source/isaaclab/isaaclab/envs/mdp/events.py

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,6 +1045,78 @@ def __call__(
10451045
self.asset.root_view.set_contact_offsets(wp.from_torch(contact_offset), wp_env_ids)
10461046

10471047

1048+
class _RandomizeRigidBodyColliderOffsetsOvPhysx:
1049+
"""OVPhysX backend implementation for collider offset randomization.
1050+
1051+
OVPhysX runs the PhysX solver, so rest and contact offsets are written directly, per collision
1052+
shape, through the asset's :class:`~isaaclab_ov.sim.views.OvPhysxView`. Articulations use the
1053+
articulation offset bindings and rigid objects the rigid-body ones; both are CPU-resident
1054+
``[N, S]`` buffers, so the full tensor is read-modify-written on the host with the selected
1055+
environments as write indices.
1056+
"""
1057+
1058+
def __init__(self, asset: RigidObject | Articulation):
1059+
import isaaclab_ov.tensor_types as ovphysx_tt # noqa: PLC0415
1060+
1061+
from isaaclab.assets import BaseArticulation # noqa: PLC0415
1062+
1063+
self.asset = asset
1064+
if isinstance(asset, BaseArticulation):
1065+
self._rest_offset_type = ovphysx_tt.REST_OFFSET
1066+
self._contact_offset_type = ovphysx_tt.CONTACT_OFFSET
1067+
else:
1068+
self._rest_offset_type = ovphysx_tt.RIGID_BODY_REST_OFFSET
1069+
self._contact_offset_type = ovphysx_tt.RIGID_BODY_CONTACT_OFFSET
1070+
self.default_rest_offsets = wp.to_torch(asset.root_view.get_attribute(self._rest_offset_type)).clone()
1071+
self.default_contact_offsets = wp.to_torch(asset.root_view.get_attribute(self._contact_offset_type)).clone()
1072+
1073+
def __call__(
1074+
self,
1075+
env: ManagerBasedEnv,
1076+
env_ids: torch.Tensor | None,
1077+
asset_cfg: SceneEntityCfg,
1078+
rest_offset_distribution_params: tuple[float, float] | None = None,
1079+
contact_offset_distribution_params: tuple[float, float] | None = None,
1080+
distribution: Literal["uniform", "log_uniform", "gaussian"] = "uniform",
1081+
):
1082+
if env_ids is None:
1083+
env_ids = torch.arange(env.scene.num_envs, device="cpu", dtype=torch.int32)
1084+
else:
1085+
env_ids = env_ids.to(device="cpu", dtype=torch.int32)
1086+
wp_env_ids = wp.from_torch(env_ids, dtype=wp.int32)
1087+
1088+
if rest_offset_distribution_params is not None:
1089+
rest_offset = self.default_rest_offsets.clone()
1090+
rest_offset = _randomize_prop_by_op(
1091+
rest_offset,
1092+
rest_offset_distribution_params,
1093+
None,
1094+
slice(None),
1095+
operation="abs",
1096+
distribution=distribution,
1097+
)
1098+
# the wheel requires a full-shaped source buffer even for indexed writes
1099+
self.asset.root_view.set_attribute(
1100+
self._rest_offset_type, wp.from_torch(rest_offset.contiguous(), dtype=wp.float32), indices=wp_env_ids
1101+
)
1102+
1103+
if contact_offset_distribution_params is not None:
1104+
contact_offset = self.default_contact_offsets.clone()
1105+
contact_offset = _randomize_prop_by_op(
1106+
contact_offset,
1107+
contact_offset_distribution_params,
1108+
None,
1109+
slice(None),
1110+
operation="abs",
1111+
distribution=distribution,
1112+
)
1113+
self.asset.root_view.set_attribute(
1114+
self._contact_offset_type,
1115+
wp.from_torch(contact_offset.contiguous(), dtype=wp.float32),
1116+
indices=wp_env_ids,
1117+
)
1118+
1119+
10481120
class _RandomizeRigidBodyColliderOffsetsNewton:
10491121
"""Newton backend implementation for collider offset randomization.
10501122
@@ -1129,11 +1201,13 @@ class randomize_rigid_body_collider_offsets(ManagerTermBase):
11291201
This function allows randomizing the collider parameters of the asset, such as rest and contact offsets.
11301202
These correspond to the physics engine collider properties that affect collision checking.
11311203
1132-
Automatically detects the active physics backend (PhysX or Newton) and delegates to
1204+
Automatically detects the active physics backend (PhysX, OVPhysX or Newton) and delegates to
11331205
the appropriate backend-specific implementation:
11341206
11351207
- **PhysX**: Uses rest offset and contact offset directly via the PhysX tensor API
11361208
(``root_view.set_rest_offsets`` / ``root_view.set_contact_offsets``).
1209+
- **OVPhysX**: Uses rest offset and contact offset directly, written per collision shape
1210+
through the asset's :class:`~isaaclab_ov.sim.views.OvPhysxView`.
11371211
- **Newton**: Maps PhysX concepts to Newton's geometry properties. PhysX ``rest_offset``
11381212
maps to Newton ``shape_margin``, and PhysX ``contact_offset`` is converted to Newton
11391213
``shape_gap`` via ``gap = contact_offset - margin``.
@@ -1143,7 +1217,7 @@ class randomize_rigid_body_collider_offsets(ManagerTermBase):
11431217
provided for a particular property, the function does not modify it.
11441218
11451219
.. tip::
1146-
This function uses CPU tensors (PhysX) or GPU tensors (Newton) to assign the collision
1220+
This function uses CPU tensors (PhysX, OVPhysX) or GPU tensors (Newton) to assign the collision
11471221
properties. It is recommended to use this function only during the initialization of
11481222
the environment.
11491223
"""
@@ -1171,12 +1245,19 @@ def __init__(self, cfg: EventTermCfg, env: ManagerBasedEnv):
11711245
f" '{self.asset_cfg.name}' with type: '{type(self.asset)}'."
11721246
)
11731247

1174-
# detect physics backend and instantiate the appropriate implementation
1248+
# detect physics backend and instantiate the appropriate implementation.
1249+
# Check ``ovphysxmanager`` first: it contains the substring ``physx`` so would otherwise
1250+
# be routed to the PhysX impl, whose ``root_view`` offset accessors do not exist on
1251+
# OVPhysX's ``OvPhysxView`` (see ``randomize_rigid_body_material``).
11751252
manager_name = env.sim.physics_manager.__name__.lower()
1176-
if "newton" in manager_name:
1253+
if manager_name == "ovphysxmanager":
1254+
self._impl = _RandomizeRigidBodyColliderOffsetsOvPhysx(self.asset)
1255+
elif "newton" in manager_name:
11771256
self._impl = _RandomizeRigidBodyColliderOffsetsNewton(self.asset)
1178-
else:
1257+
elif "physx" in manager_name:
11791258
self._impl = _RandomizeRigidBodyColliderOffsetsPhysx(self.asset)
1259+
else:
1260+
raise ValueError(f"Unsupported physics manager for randomize_rigid_body_collider_offsets: {manager_name!r}")
11801261

11811262
def __call__(
11821263
self,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Test-only coverage for the OVPhysX package.
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: BSD-3-Clause
5+
6+
"""Real-backend test for the OVPhysX branch of the ``randomize_rigid_body_collider_offsets`` MDP term.
7+
8+
Drives the public :class:`isaaclab.envs.mdp.events.randomize_rigid_body_collider_offsets` term against
9+
a real OVPhysX :class:`~isaaclab_ov.assets.RigidObject` and :class:`~isaaclab_ov.assets.Articulation`, so
10+
the backend dispatch itself is exercised (it previously fell through to the PhysX implementation, whose
11+
``root_view`` accessors do not exist on :class:`~isaaclab_ov.sim.views.OvPhysxView`). The ``cfg`` /
12+
``env`` / ``asset_cfg`` inputs are stubbed: the term only reads ``cfg.params["asset_cfg"]``,
13+
``env.scene[...]``, ``env.scene.num_envs`` and ``env.sim.physics_manager``.
14+
15+
Kitless; run once per device (``-k cpu`` / ``-k 'cuda:0'``) -- the ovphysx runtime binds the
16+
device mode process-globally (see the asset tests' module docstring).
17+
"""
18+
19+
from __future__ import annotations
20+
21+
from types import SimpleNamespace
22+
23+
import pytest
24+
import torch
25+
import warp as wp
26+
27+
pytest.importorskip("ovphysx.types", reason="ovphysx wheel not installed")
28+
29+
from isaaclab_ov import tensor_types as TT # noqa: E402
30+
from isaaclab_ov.assets import Articulation, RigidObject # noqa: E402
31+
from isaaclab_ov.physics import OvPhysxCfg # noqa: E402
32+
33+
import isaaclab.sim as sim_utils # noqa: E402
34+
from isaaclab.assets import RigidObjectCfg # noqa: E402
35+
from isaaclab.envs.mdp.events import randomize_rigid_body_collider_offsets # noqa: E402
36+
from isaaclab.sim import SimulationCfg, build_simulation_context # noqa: E402
37+
from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR # noqa: E402
38+
39+
from isaaclab_assets import CARTPOLE_CFG # isort:skip # noqa: E402
40+
41+
wp.init()
42+
43+
pytestmark = pytest.mark.device_split
44+
45+
_LOCKED_DEVICE: list[str | None] = [None]
46+
47+
REST_RANGE = (0.0002, 0.0006) # below the 1 mm default contact offset: PhysX rejects rest >= contact
48+
CONTACT_RANGE = (0.03, 0.05)
49+
EPS = 1e-6
50+
51+
52+
@pytest.fixture(autouse=True)
53+
def _ovphysx_skip_other_device(request):
54+
"""Skip parametrized tests on the device the session is not pinned to (process-global lock)."""
55+
callspec = getattr(request.node, "callspec", None)
56+
device = callspec.params.get("device") if callspec is not None else None
57+
if device is None:
58+
return
59+
locked = _LOCKED_DEVICE[0]
60+
if locked is None:
61+
_LOCKED_DEVICE[0] = device
62+
return
63+
if device != locked:
64+
pytest.skip(
65+
f"ovphysx process-global device lock is held by '{locked}'; cannot run '{device}' "
66+
"tests in the same session. Run pytest twice (once per device) for full coverage."
67+
)
68+
69+
70+
def _ovphysx_sim_context(device: str, **kwargs):
71+
"""Build a simulation context that dispatches to the OVPhysX manager."""
72+
sim_cfg = SimulationCfg(physics=OvPhysxCfg(), device=device, dt=1.0 / 60.0, gravity=(0.0, 0.0, -9.81))
73+
return build_simulation_context(device=device, sim_cfg=sim_cfg, **kwargs)
74+
75+
76+
class _SceneStub(dict):
77+
"""Minimal ``InteractiveScene`` stand-in: name lookup plus ``num_envs``."""
78+
79+
def __init__(self, num_envs: int, **assets):
80+
super().__init__(**assets)
81+
self.num_envs = num_envs
82+
83+
84+
def _make_env(sim, num_envs: int, **assets) -> SimpleNamespace:
85+
return _make_env_from_scene(sim, _SceneStub(num_envs, **assets))
86+
87+
88+
def _make_env_from_scene(sim, scene: _SceneStub) -> SimpleNamespace:
89+
return SimpleNamespace(sim=sim, scene=scene, num_envs=scene.num_envs, device=sim.device)
90+
91+
92+
def _make_cubes(num_cubes: int) -> RigidObject:
93+
"""Spawn ``num_cubes`` rigid-body cubes as a single RigidObject."""
94+
for i in range(num_cubes):
95+
sim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=(i * 1.0, 0.0, 1.0))
96+
cfg = RigidObjectCfg(
97+
prim_path="/World/Env_[^/]+/Object",
98+
spawn=sim_utils.UsdFileCfg(usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/DexCube/dex_cube_instanceable.usd"),
99+
init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 1.0)),
100+
)
101+
return RigidObject(cfg=cfg)
102+
103+
104+
def _make_cartpoles(num_envs: int) -> Articulation:
105+
"""Spawn ``num_envs`` cartpoles as a single Articulation."""
106+
for i in range(num_envs):
107+
sim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=(i * 2.5, 0.0, 0.0))
108+
return Articulation(cfg=CARTPOLE_CFG.replace(prim_path="/World/Env_[^/]+/Robot"))
109+
110+
111+
def _read_offsets(asset, rest_type, contact_type) -> tuple[torch.Tensor, torch.Tensor]:
112+
rest = wp.to_torch(asset.root_view.get_attribute(rest_type)).clone()
113+
contact = wp.to_torch(asset.root_view.get_attribute(contact_type)).clone()
114+
return rest, contact
115+
116+
117+
def _assert_randomized_rows(before: torch.Tensor, after: torch.Tensor, rows: list[int], value_range) -> None:
118+
"""Selected rows land within ``value_range``; all other rows are untouched."""
119+
lo, hi = value_range
120+
selected = after[rows]
121+
assert (selected >= lo - EPS).all() and (selected <= hi + EPS).all(), selected
122+
untouched = [i for i in range(before.shape[0]) if i not in rows]
123+
torch.testing.assert_close(after[untouched], before[untouched])
124+
125+
126+
@pytest.mark.parametrize("device", ["cuda:0", "cpu"])
127+
def test_rigid_object_offsets_randomized_for_selected_envs(device):
128+
"""The term must dispatch to an OVPhysX implementation and write rigid-body rest/contact offsets."""
129+
num_cubes = 3
130+
with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim:
131+
cube_object = _make_cubes(num_cubes)
132+
sim.reset()
133+
134+
asset_cfg = SimpleNamespace(name="cube", body_ids=slice(None))
135+
env = _make_env(sim, num_cubes, cube=cube_object)
136+
term = randomize_rigid_body_collider_offsets(SimpleNamespace(params={"asset_cfg": asset_cfg}), env)
137+
138+
rest_before, contact_before = _read_offsets(
139+
cube_object, TT.RIGID_BODY_REST_OFFSET, TT.RIGID_BODY_CONTACT_OFFSET
140+
)
141+
assert rest_before.shape[0] == num_cubes
142+
# sanity: the sampling ranges must be disjoint from the authored defaults or the test proves nothing
143+
assert not ((rest_before >= REST_RANGE[0]) & (rest_before <= REST_RANGE[1])).any()
144+
assert not ((contact_before >= CONTACT_RANGE[0]) & (contact_before <= CONTACT_RANGE[1])).any()
145+
146+
env_ids = torch.tensor([0, 2], device=device)
147+
term(
148+
env,
149+
env_ids,
150+
asset_cfg,
151+
rest_offset_distribution_params=REST_RANGE,
152+
contact_offset_distribution_params=CONTACT_RANGE,
153+
)
154+
155+
rest_after, contact_after = _read_offsets(cube_object, TT.RIGID_BODY_REST_OFFSET, TT.RIGID_BODY_CONTACT_OFFSET)
156+
_assert_randomized_rows(rest_before, rest_after, [0, 2], REST_RANGE)
157+
_assert_randomized_rows(contact_before, contact_after, [0, 2], CONTACT_RANGE)
158+
159+
160+
@pytest.mark.parametrize("device", ["cuda:0", "cpu"])
161+
def test_articulation_offsets_randomized_for_all_envs(device):
162+
"""The articulation path writes per-shape rest/contact offsets on every env when ``env_ids`` is None."""
163+
num_envs = 2
164+
with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim:
165+
articulation = _make_cartpoles(num_envs)
166+
sim.reset()
167+
168+
asset_cfg = SimpleNamespace(name="robot", body_ids=slice(None))
169+
env = _make_env(sim, num_envs, robot=articulation)
170+
term = randomize_rigid_body_collider_offsets(SimpleNamespace(params={"asset_cfg": asset_cfg}), env)
171+
172+
rest_before, contact_before = _read_offsets(articulation, TT.REST_OFFSET, TT.CONTACT_OFFSET)
173+
assert rest_before.shape[0] == num_envs
174+
assert not ((rest_before >= REST_RANGE[0]) & (rest_before <= REST_RANGE[1])).any()
175+
assert not ((contact_before >= CONTACT_RANGE[0]) & (contact_before <= CONTACT_RANGE[1])).any()
176+
177+
# only rest offsets requested: contact offsets must stay untouched
178+
term(env, None, asset_cfg, rest_offset_distribution_params=REST_RANGE)
179+
rest_after, contact_after = _read_offsets(articulation, TT.REST_OFFSET, TT.CONTACT_OFFSET)
180+
_assert_randomized_rows(rest_before, rest_after, list(range(num_envs)), REST_RANGE)
181+
torch.testing.assert_close(contact_after, contact_before)
182+
183+
term(env, None, asset_cfg, contact_offset_distribution_params=CONTACT_RANGE)
184+
_, contact_after = _read_offsets(articulation, TT.REST_OFFSET, TT.CONTACT_OFFSET)
185+
_assert_randomized_rows(contact_before, contact_after, list(range(num_envs)), CONTACT_RANGE)

0 commit comments

Comments
 (0)