Skip to content

Commit ab34e8c

Browse files
authored
Add shared kinematic rigid-object renderer contract (#6308)
# Description Revives #6308 on current `develop` and supersedes #3728 with a single backend-neutral contract for kinematic rigid-object rendering. ## Architecture `source/isaaclab/test/renderers/rigid_object_rendering_contract.py` is the composition root. It owns the cloned scene, kinematic pose sequence, depth measurements, and assertions. Package-local adapters own only availability checks, simulation/renderer selection, and backend cleanup: - Isaac RTX + PhysX on CPU and CUDA, with and without a coexisting articulation; - Newton Warp + PhysX on CUDA; - OVRTX + OVPhysX on CUDA through both legacy and OVStage scene ownership (OVStage runs when installed). The dependency direction is adapter -> shared test contract -> public Isaac Lab APIs. An AST architecture gate rejects backend imports in the shared contract and rejects scene, asset, sensor, or class ownership in adapters. The contract creates two cloned instanceable DexCubes with root-level nonuniform scale, verifies their depth silhouettes, moves both kinematic bodies through the public rigid-object tensor API, verifies the physics poses, and requires opposite rendered centroid displacement. ## Current-develop audit Most production changes in the old PR have since landed through newer ownership boundaries: Isaac RTX render-product lifetime in #6729, Newton shadow-state copying in #6773, OVRTX scale-aware transform writes in #7010, and Newton Fabric scale preservation in #7481. This revival removes those stale patches rather than carrying duplicate implementations. The revived contract exposed one remaining OVRTX bug: composed scale was captured only for clone-plan source paths, while OVRTX creates non-source destinations after exporting the host USD stage. Those destinations therefore defaulted to unit scale. As a deliberately temporary bridge, this PR projects only captured non-unit scales through the existing `isaaclab.cloner.query.path_env_ids` and `path_to_clone` boundary using the already-validated `ClonePlan`; real destination scales take precedence. It adds no plan fields, query APIs, renderer configuration, or per-body fallback, and the bridge can be deleted as one unit when SDP supplies composed scale aligned with canonical rigid-body paths. Historical context: [Isaac Sim forum report](https://forums.developer.nvidia.com/t/rigidbody-prim-is-not-updated-in-rendering-pipeline-if-set-to-kinematic/346608). ## Type of change - Bug fix - Shared regression coverage ## Testing - Isaac RTX contract: 4 passed (CUDA/CPU x articulation absent/present). - Newton Warp contract: 1 passed. - OVRTX contract: 2 passed (legacy and OVStage). - OVRTX renderer unit surface: 167 passed. - Core architecture and Newton visualization suites: 25 passed. - OVRTX clone-plan suite: 17 passed. - Cloner query and rendering-contract architecture suites: 87 passed. - Controlled OVRTX regression: failed before the production fix with clone silhouettes of 264 vs. 36 pixels; passed after the fix. - Incoming #7462 NumPy-backed `ClonePlan` query-boundary smoke check: passed unchanged, including non-dense environment ids. - `uv run isaaclab -f`: all hooks passed against the exact upstream `develop` base, including changelog validation. ## Checklist - [x] I have read and understood the contribution guidelines - [x] I have run the pre-commit checks - [x] Documentation changes are not applicable - [x] I have added unit and integration regression coverage - [x] I have added changelog fragments for every touched package - [x] My name is already present in `CONTRIBUTORS.md`
1 parent eb842e3 commit ab34e8c

12 files changed

Lines changed: 542 additions & 5 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Tests only: no user-visible core package change.
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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+
"""Backend-neutral kinematic rigid-object rendering contract.
7+
8+
Backend test modules provide only a simulation context and renderer configuration.
9+
This module owns the scene, motion sequence, measurements, and assertions.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import gc
15+
from collections.abc import Callable
16+
from contextlib import AbstractContextManager
17+
from dataclasses import dataclass
18+
19+
import torch
20+
21+
import isaaclab.sim as sim_utils
22+
from isaaclab.actuators import ImplicitActuatorCfg
23+
from isaaclab.assets import ArticulationCfg, RigidObject, RigidObjectCfg
24+
from isaaclab.renderers import RendererCfg
25+
from isaaclab.scene import InteractiveScene, InteractiveSceneCfg
26+
from isaaclab.sensors.camera import Camera, CameraCfg
27+
from isaaclab.sim import SimulationContext
28+
from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg
29+
from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR
30+
from isaaclab.utils.configclass import configclass
31+
32+
__all__ = ["RigidObjectRenderingBackend", "run_rigid_object_scale_and_pose_rendering_contract"]
33+
34+
_NUM_ENVS = 2
35+
_ENV_SPACING = 4.0
36+
_CAMERA_DISTANCE = 2.0
37+
_CAMERA_HEIGHT = 120
38+
_CAMERA_WIDTH = 160
39+
_OBJECT_SCALE = (1.0, 1.0, 8.0)
40+
_OBJECT_SHIFT = 0.45
41+
_MIN_OBJECT_DEPTH = 0.05
42+
_MAX_OBJECT_DEPTH = 10.0
43+
_MIN_OBJECT_PIXELS = 50
44+
_MIN_CENTROID_SHIFT = 10.0
45+
46+
47+
@dataclass(frozen=True)
48+
class RigidObjectRenderingBackend:
49+
"""Backend-owned inputs to the shared rendering contract."""
50+
51+
name: str
52+
simulation_context_factory: Callable[[], AbstractContextManager[SimulationContext]]
53+
renderer_cfg: RendererCfg
54+
with_articulation: bool = False
55+
cleanup: Callable[[], None] | None = None
56+
57+
58+
def _make_scene_cfg(backend: RigidObjectRenderingBackend) -> InteractiveSceneCfg:
59+
"""Create the scene whose rendered behavior is shared by every backend."""
60+
61+
@configclass
62+
class _SceneCfg(InteractiveSceneCfg):
63+
rigid_object: RigidObjectCfg = RigidObjectCfg(
64+
prim_path="{ENV_REGEX_NS}/Object",
65+
spawn=sim_utils.UsdFileCfg(
66+
usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/DexCube/dex_cube_instanceable.usd",
67+
rigid_props=[UsdPhysicsRigidBodyCfg(rigid_body_enabled=True, kinematic_enabled=True)],
68+
scale=_OBJECT_SCALE,
69+
),
70+
)
71+
camera: CameraCfg = CameraCfg(
72+
prim_path="{ENV_REGEX_NS}/Camera",
73+
height=_CAMERA_HEIGHT,
74+
width=_CAMERA_WIDTH,
75+
update_period=0.0,
76+
update_latest_camera_pose=True,
77+
data_types=["depth"],
78+
renderer_cfg=backend.renderer_cfg,
79+
spawn=sim_utils.PinholeCameraCfg(
80+
focal_length=24.0,
81+
focus_distance=400.0,
82+
horizontal_aperture=20.955,
83+
clipping_range=(_MIN_OBJECT_DEPTH, 100.0),
84+
),
85+
)
86+
if backend.with_articulation:
87+
articulation: ArticulationCfg = ArticulationCfg(
88+
prim_path="{ENV_REGEX_NS}/Articulation",
89+
spawn=sim_utils.UsdFileCfg(
90+
usd_path=f"{ISAAC_NUCLEUS_DIR}/Robots/IsaacSim/SimpleArticulation/revolute_articulation.usd"
91+
),
92+
init_state=ArticulationCfg.InitialStateCfg(pos=(0.0, -20.0, 0.0)),
93+
actuators={
94+
"joint": ImplicitActuatorCfg(joint_names_expr=[".*"], stiffness=100.0, damping=1.0),
95+
},
96+
)
97+
98+
return _SceneCfg(num_envs=_NUM_ENVS, env_spacing=_ENV_SPACING, lazy_sensor_update=False)
99+
100+
101+
def _require(condition: torch.Tensor | bool, message: str) -> None:
102+
"""Raise a diagnostic assertion instead of relying on pytest rewriting this helper."""
103+
if not bool(condition):
104+
raise AssertionError(message)
105+
106+
107+
def _measure_depth_mask(
108+
depth: torch.Tensor, backend_name: str, camera: Camera
109+
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
110+
"""Return silhouette height, width, and horizontal centroid for every camera."""
111+
depth_image = depth[..., 0]
112+
valid = torch.isfinite(depth_image) & (depth_image > _MIN_OBJECT_DEPTH) & (depth_image < _MAX_OBJECT_DEPTH)
113+
pixel_counts = valid.sum(dim=(1, 2))
114+
finite = torch.where(torch.isfinite(depth_image), depth_image, torch.zeros_like(depth_image))
115+
diagnostics = (
116+
f"depth min={finite.amin(dim=(1, 2)).tolist()} max={finite.amax(dim=(1, 2)).tolist()} "
117+
f"finite%={(torch.isfinite(depth_image).float().mean(dim=(1, 2)) * 100).tolist()} "
118+
f"camera pos_w={camera.data.pos_w.torch.tolist()}"
119+
)
120+
_require(
121+
torch.all(pixel_counts >= _MIN_OBJECT_PIXELS),
122+
f"[{backend_name}] Expected at least {_MIN_OBJECT_PIXELS} object pixels per camera, "
123+
f"got {pixel_counts.tolist()}; {diagnostics}.",
124+
)
125+
126+
silhouette_heights = valid.any(dim=2).sum(dim=1)
127+
silhouette_widths = valid.any(dim=1).sum(dim=1)
128+
image_x = torch.arange(depth.shape[2], device=depth.device, dtype=torch.float32)
129+
centroids_x = (valid * image_x.view(1, 1, -1)).sum(dim=(1, 2)) / pixel_counts
130+
return silhouette_heights, silhouette_widths, centroids_x
131+
132+
133+
def _write_pose_and_render(
134+
sim: SimulationContext,
135+
scene: InteractiveScene,
136+
rigid_object: RigidObject,
137+
camera: Camera,
138+
root_poses: torch.Tensor,
139+
) -> torch.Tensor:
140+
"""Write a kinematic pose, settle renderer state, and return depth."""
141+
rigid_object.write_root_pose_to_sim_index(root_pose=root_poses)
142+
for _ in range(3):
143+
sim.step()
144+
scene.update(sim.cfg.dt)
145+
torch.testing.assert_close(rigid_object.data.root_link_pose_w.torch, root_poses, rtol=0.0, atol=1.0e-4)
146+
return camera.data.output["depth"].torch.clone()
147+
148+
149+
def run_rigid_object_scale_and_pose_rendering_contract(backend: RigidObjectRenderingBackend) -> None:
150+
"""Assert root-scale preservation and pose-to-pixel synchronization."""
151+
with backend.simulation_context_factory() as sim:
152+
sim._app_control_on_stop_handle = None
153+
scene = InteractiveScene(_make_scene_cfg(backend))
154+
sim.register_interactive_scene(scene)
155+
rigid_object = scene["rigid_object"]
156+
camera = scene["camera"]
157+
158+
try:
159+
sim.reset()
160+
scene.reset()
161+
_require(rigid_object.is_initialized, f"[{backend.name}] Rigid object did not initialize.")
162+
if backend.with_articulation:
163+
_require(scene["articulation"].is_initialized, f"[{backend.name}] Articulation did not initialize.")
164+
165+
camera_eyes = scene.env_origins.clone()
166+
camera_eyes[:, 1] -= _CAMERA_DISTANCE
167+
camera.set_world_poses_from_view(camera_eyes, scene.env_origins)
168+
169+
center_poses = torch.zeros((_NUM_ENVS, 7), device=rigid_object.device)
170+
center_poses[:, :3] = scene.env_origins
171+
center_poses[:, 6] = 1.0
172+
173+
center_depth = _write_pose_and_render(sim, scene, rigid_object, camera, center_poses)
174+
center_heights, center_widths, center_centroids = _measure_depth_mask(center_depth, backend.name, camera)
175+
_require(
176+
torch.all(center_heights > 3 * center_widths),
177+
f"[{backend.name}] Expected root-scaled cubes to render as tall silhouettes, got "
178+
f"heights={center_heights.tolist()} and widths={center_widths.tolist()}.",
179+
)
180+
_require(
181+
torch.all(center_heights > _CAMERA_HEIGHT // 4),
182+
f"[{backend.name}] Expected root-scaled silhouettes to span more than one quarter of the image, "
183+
f"got heights={center_heights.tolist()}.",
184+
)
185+
_require(
186+
torch.all(torch.abs(center_centroids - (_CAMERA_WIDTH - 1) / 2) < 8.0),
187+
f"[{backend.name}] Expected centered silhouettes, got centroids={center_centroids.tolist()}.",
188+
)
189+
190+
negative_poses = center_poses.clone()
191+
negative_poses[:, 0] -= _OBJECT_SHIFT
192+
negative_depth = _write_pose_and_render(sim, scene, rigid_object, camera, negative_poses)
193+
_, _, negative_centroids = _measure_depth_mask(negative_depth, backend.name, camera)
194+
195+
positive_poses = center_poses.clone()
196+
positive_poses[:, 0] += _OBJECT_SHIFT
197+
positive_depth = _write_pose_and_render(sim, scene, rigid_object, camera, positive_poses)
198+
_, _, positive_centroids = _measure_depth_mask(positive_depth, backend.name, camera)
199+
200+
negative_delta = negative_centroids - center_centroids
201+
positive_delta = positive_centroids - center_centroids
202+
_require(
203+
torch.all(negative_delta.abs() > _MIN_CENTROID_SHIFT),
204+
f"[{backend.name}] Negative-shift centroids moved too little: {negative_delta.tolist()}.",
205+
)
206+
_require(
207+
torch.all(positive_delta.abs() > _MIN_CENTROID_SHIFT),
208+
f"[{backend.name}] Positive-shift centroids moved too little: {positive_delta.tolist()}.",
209+
)
210+
_require(
211+
torch.all(negative_delta * positive_delta < 0.0),
212+
f"[{backend.name}] Opposite translations must move silhouettes in opposite directions, got "
213+
f"deltas {negative_delta.tolist()} and {positive_delta.tolist()}.",
214+
)
215+
finally:
216+
sim.register_interactive_scene(None)
217+
# Release camera-owned render products before another parametrized case creates a stage.
218+
camera._invalidate_initialize_callback(None) # noqa: SLF001
219+
del camera, rigid_object, scene
220+
gc.collect()
221+
if backend.cleanup is not None:
222+
backend.cleanup()
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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+
"""Architecture gates for the shared rigid-object rendering contract."""
7+
8+
import ast
9+
from pathlib import Path
10+
11+
_REPO_ROOT = Path(__file__).resolve().parents[4]
12+
_CONTRACT = Path(__file__).with_name("rigid_object_rendering_contract.py")
13+
_ADAPTERS = (
14+
_REPO_ROOT / "source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_rigid_object_rendering.py",
15+
_REPO_ROOT / "source/isaaclab_newton/test/renderers/test_newton_warp_renderer_rigid_object_rendering.py",
16+
_REPO_ROOT / "source/isaaclab_ov/test/test_ovrtx_renderer_rigid_object_rendering.py",
17+
)
18+
19+
20+
def _imported_modules(tree: ast.AST) -> set[str]:
21+
modules = set()
22+
for node in ast.walk(tree):
23+
if isinstance(node, ast.Import):
24+
modules.update(alias.name for alias in node.names)
25+
elif isinstance(node, ast.ImportFrom) and node.module is not None:
26+
modules.add(node.module)
27+
return modules
28+
29+
30+
def test_shared_contract_does_not_depend_on_backend_packages() -> None:
31+
"""Optional backends depend on the contract; the core contract never points back."""
32+
tree = ast.parse(_CONTRACT.read_text(encoding="utf-8"))
33+
forbidden_roots = {"isaaclab_newton", "isaaclab_ov", "isaaclab_ovphysx", "isaaclab_physx", "newton", "ovrtx"}
34+
imported_roots = {module.split(".", 1)[0] for module in _imported_modules(tree)}
35+
36+
assert imported_roots.isdisjoint(forbidden_roots), imported_roots & forbidden_roots
37+
38+
39+
def test_backend_adapters_do_not_duplicate_scene_ownership() -> None:
40+
"""Adapters may select backends, but scene construction and assertions stay shared."""
41+
for adapter in _ADAPTERS:
42+
tree = ast.parse(adapter.read_text(encoding="utf-8"))
43+
modules = _imported_modules(tree)
44+
forbidden = {
45+
module
46+
for module in modules
47+
if module == "isaaclab.assets"
48+
or module.startswith("isaaclab.assets.")
49+
or module == "isaaclab.scene"
50+
or module.startswith("isaaclab.scene.")
51+
or module == "isaaclab.sensors"
52+
or module.startswith("isaaclab.sensors.")
53+
}
54+
contract_calls = [
55+
node
56+
for node in ast.walk(tree)
57+
if isinstance(node, ast.Call)
58+
and isinstance(node.func, ast.Name)
59+
and node.func.id == "run_rigid_object_scale_and_pose_rendering_contract"
60+
]
61+
62+
assert not forbidden, f"{adapter.relative_to(_REPO_ROOT)} duplicates contract ownership: {forbidden}"
63+
assert len(contract_calls) == 1, f"{adapter.relative_to(_REPO_ROOT)} must compose the shared contract once"
64+
assert not any(isinstance(node, ast.ClassDef) for node in ast.walk(tree)), (
65+
f"{adapter.relative_to(_REPO_ROOT)} must not define backend-local scene classes"
66+
)

source/isaaclab/test/sim/test_newton_manager_visualization_state.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,49 @@ def test_resolve_scene_data_body_paths_uses_joint_body_targets():
561561
assert resolved_paths == ["/World/envs/env_0/Robot/robot0_forearm"]
562562

563563

564+
def test_update_visualization_state_copies_identity_mapped_transforms(monkeypatch):
565+
"""Identity-mapped transforms update the persistent Newton shadow buffer."""
566+
import numpy as np
567+
import warp as wp
568+
from isaaclab_newton.physics import NewtonManager
569+
570+
from isaaclab.scene_data import SceneDataFormat, SceneDataProvider
571+
572+
_reset_newton_manager_state()
573+
monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls, provider=None: False))
574+
575+
body_paths = ["/World/envs/env_0/Object", "/World/envs/env_1/Object"]
576+
source_transforms = wp.array(
577+
[
578+
[1.0, 2.0, 3.0, 0.0, 0.0, 0.0, 1.0],
579+
[4.0, 5.0, 6.0, 0.0, 0.0, 0.0, 1.0],
580+
],
581+
dtype=wp.transformf,
582+
device="cpu",
583+
)
584+
source_data = SceneDataFormat.Transform()
585+
source_data.transforms = source_transforms
586+
provider_impl = SceneDataProvider(
587+
SimpleNamespace(transforms=source_data, transform_paths=body_paths, transform_count=len(body_paths))
588+
)
589+
provider = SimpleNamespace(
590+
usd_stage=None,
591+
create_mapping=provider_impl.create_mapping,
592+
get_transforms=provider_impl.get_transforms,
593+
point_count=0,
594+
)
595+
596+
destination = wp.zeros(len(body_paths), dtype=wp.transformf, device="cpu")
597+
NewtonManager._model = SimpleNamespace(body_label=body_paths, body_count=len(body_paths))
598+
NewtonManager._state_0 = SimpleNamespace(body_q=destination, particle_q=None)
599+
600+
NewtonManager.update_visualization_state(provider)
601+
602+
assert NewtonManager._state_0.body_q is destination
603+
assert NewtonManager._scene_data.transforms is destination
604+
np.testing.assert_allclose(destination.numpy(), source_transforms.numpy())
605+
606+
564607
def test_update_visualization_state_syncs_shadow_particle_q(monkeypatch):
565608
"""PhysX/OVPhysX shadow sync copies backend points into ``state.particle_q``.
566609
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Tests only: no user-visible Newton package change.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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+
"""Newton Warp adapter for the shared rigid-object rendering contract."""
7+
8+
import sys
9+
from pathlib import Path
10+
11+
from isaaclab.app import AppLauncher
12+
13+
simulation_app = AppLauncher(headless=True, enable_cameras=True).app
14+
15+
"""Rest everything follows."""
16+
17+
import pytest
18+
from isaaclab_newton.physics import NewtonManager
19+
from isaaclab_newton.renderers import NewtonWarpRendererCfg
20+
21+
from isaaclab.sim import build_simulation_context
22+
23+
_CONTRACT_DIR = Path(__file__).resolve().parents[3] / "isaaclab" / "test" / "renderers"
24+
if str(_CONTRACT_DIR) not in sys.path:
25+
sys.path.insert(0, str(_CONTRACT_DIR))
26+
27+
from rigid_object_rendering_contract import ( # noqa: E402
28+
RigidObjectRenderingBackend,
29+
run_rigid_object_scale_and_pose_rendering_contract,
30+
)
31+
32+
pytestmark = [pytest.mark.integration, pytest.mark.rendering, pytest.mark.isaacsim_ci]
33+
34+
35+
def test_kinematic_rigid_object_scale_and_pose_are_rendered() -> None:
36+
"""Kinematic PhysX transforms and root scale must reach Newton Warp."""
37+
run_rigid_object_scale_and_pose_rendering_contract(
38+
RigidObjectRenderingBackend(
39+
name="newton_warp (PhysX)",
40+
simulation_context_factory=lambda: build_simulation_context(device="cuda:0", gravity_enabled=False),
41+
renderer_cfg=NewtonWarpRendererCfg(),
42+
cleanup=NewtonManager.clear,
43+
)
44+
)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Fixed
2+
^^^^^
3+
4+
* Fixed OVRTX transform synchronization dropping authored scale from clone-plan destinations.

0 commit comments

Comments
 (0)