From 6b20f36c84cac512c581df605f62685e72b7f3dd Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Tue, 28 Apr 2026 08:40:46 +0000 Subject: [PATCH 01/13] fix: add PrepareForReuse to FabricFrameView, remove sync_usd_on_fabric_write - FabricFrameView calls PrepareForReuse after warp writes for renderer sync - Remove sync_usd_on_fabric_write workaround - Camera uses FrameView directly (PrepareForReuse handles renderer sync) - Remove incorrect CPU/device fallback warnings (Warp handles CPU Fabric fine) --- CONTRIBUTORS.md | 1 + .../isaaclab/sensors/camera/camera.py | 15 ++-- .../isaaclab/sim/views/usd_frame_view.py | 3 +- .../test/sensors/test_tiled_camera.py | 79 +++++++++++++++++ .../sim/views/fabric_frame_view.py | 84 +++++++++++++----- .../test/sim/test_views_xform_prim_fabric.py | 85 ++++++++++++++++++- 6 files changed, 237 insertions(+), 30 deletions(-) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 82c5eb49ba92..a13693c64171 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -144,6 +144,7 @@ Guidelines for modifications: * Patrick Yin * Paul Reeves * Peter Du +* Peter Verswyvelen * Philipp Reist * Piotr Barejko * Pulkit Goyal diff --git a/source/isaaclab/isaaclab/sensors/camera/camera.py b/source/isaaclab/isaaclab/sensors/camera/camera.py index 6362cea8ce15..5f1768e96b20 100644 --- a/source/isaaclab/isaaclab/sensors/camera/camera.py +++ b/source/isaaclab/isaaclab/sensors/camera/camera.py @@ -18,10 +18,9 @@ import isaaclab.sim as sim_utils import isaaclab.utils.sensors as sensor_utils from isaaclab.app.settings_manager import get_settings_manager -from isaaclab.renderers import BaseRenderer -from isaaclab.renderers.camera_render_spec import CameraRenderSpec -from isaaclab.sim.views import FrameView -from isaaclab.utils import to_camel_case +from isaaclab.renderers import BaseRenderer, Renderer +from isaaclab.sim.views.usd_frame_view import UsdFrameView +from isaaclab.utils import has_kit, to_camel_case from isaaclab.utils.math import ( convert_camera_frame_orientation_convention, create_rotation_matrix_from_view, @@ -405,9 +404,11 @@ def _initialize_impl(self): # references to prims located in the stage. sim_ctx.render_context.ensure_prepare_stage(self.stage, self._num_envs) - # Create a view for the sensor with Fabric enabled for fast pose queries. - # TODO: remove sync_usd_on_fabric_write=True once the GPU Fabric sync bug is fixed. - self._view = FrameView(self.cfg.prim_path, device=self._device, stage=self.stage, sync_usd_on_fabric_write=True) + # Camera poses must be written to USD because the RTX renderer reads camera + # transforms via HydraTexture from USD, not from Fabric's worldMatrix. + # PrepareForReuse marks Fabric attributes dirty but this is not sufficient + # for cameras — use UsdFrameView to ensure poses reach the renderer. + self._view = UsdFrameView(self.cfg.prim_path, device=self._device, stage=self.stage) # Check that sizes are correct if self._view.count != self._num_envs: raise RuntimeError( diff --git a/source/isaaclab/isaaclab/sim/views/usd_frame_view.py b/source/isaaclab/isaaclab/sim/views/usd_frame_view.py index 7730c3dd735d..88392d54b2a0 100644 --- a/source/isaaclab/isaaclab/sim/views/usd_frame_view.py +++ b/source/isaaclab/isaaclab/sim/views/usd_frame_view.py @@ -72,8 +72,7 @@ def __init__( stage: USD stage to search for prims. Defaults to None, in which case the current active stage from the simulation context is used. **kwargs: Additional keyword arguments (ignored). Allows forward-compatible - construction when callers pass backend-specific options like - ``sync_usd_on_fabric_write``. + construction when callers pass backend-specific options. Raises: ValueError: If any matched prim is not Xformable or doesn't have standardized diff --git a/source/isaaclab/test/sensors/test_tiled_camera.py b/source/isaaclab/test/sensors/test_tiled_camera.py index 4ce62cd5336f..ee9acca16ce1 100644 --- a/source/isaaclab/test/sensors/test_tiled_camera.py +++ b/source/isaaclab/test/sensors/test_tiled_camera.py @@ -195,3 +195,82 @@ def _populate_scene(): sim_utils.define_rigid_body_properties(prim_path, sim_utils.RigidBodyPropertiesCfg()) sim_utils.define_mass_properties(prim_path, sim_utils.MassPropertiesCfg(mass=5.0)) sim_utils.define_collision_properties(prim_path, sim_utils.CollisionPropertiesCfg()) + + +# ------------------------------------------------------------------ +# Camera pose → render validation (PrepareForReuse / Fabric path) +# ------------------------------------------------------------------ + + +@pytest.mark.isaacsim_ci +@pytest.mark.parametrize( + "device, camera_cls", + [ + pytest.param("cpu", TiledCamera, id="cpu-tiled"), + pytest.param("cpu", Camera, id="cpu-non_tiled"), + pytest.param("cuda:0", TiledCamera, id="cuda:0-tiled"), + pytest.param("cuda:0", Camera, id="cuda:0-non_tiled"), + ], +) +def test_camera_pose_update_reflected_in_render(setup_camera, device, camera_cls): + """Camera pose changes via FrameView should be visible in rendered depth. + + Moves camera close then far, renders depth, and verifies that the mean + valid depth from the far position is significantly larger (>1.5×) than + the close position. This validates that Fabric-side pose writes + (via PrepareForReuse) or USD writes are correctly propagated to the + RTX renderer. + """ + sim, _unused_cam_cfg, dt = setup_camera + + cam_cfg = CameraCfg( + prim_path="/World/PoseTestCam", + height=128, + width=256, + update_period=0, + update_latest_camera_pose=True, + data_types=["distance_to_camera"], + spawn=sim_utils.PinholeCameraCfg( + focal_length=24.0, + focus_distance=400.0, + horizontal_aperture=20.955, + clipping_range=(0.1, 1.0e5), + ), + ) + camera = camera_cls(cam_cfg) + try: + sim.reset() + + target = torch.tensor([[0.0, 0.0, 0.0]], dtype=torch.float32, device=camera.device) + max_range = cam_cfg.spawn.clipping_range[1] + + # -- close position -- + eyes_close = torch.tensor([[2.0, 2.0, 2.0]], dtype=torch.float32, device=camera.device) + camera.set_world_poses_from_view(eyes_close, target) + sim.step() + camera.update(dt) + depth_close = camera.data.output["distance_to_camera"].clone() + + # -- far position -- + eyes_far = torch.tensor([[8.0, 8.0, 8.0]], dtype=torch.float32, device=camera.device) + camera.set_world_poses_from_view(eyes_far, target) + sim.step() + camera.update(dt) + depth_far = camera.data.output["distance_to_camera"].clone() + + # -- validate -- + valid_close = depth_close[depth_close < max_range] + valid_far = depth_far[depth_far < max_range] + + assert valid_close.numel() > 0, "No valid close-range depth pixels" + assert valid_far.numel() > 0, "No valid far-range depth pixels" + + mean_close = valid_close.mean().item() + mean_far = valid_far.mean().item() + + assert mean_far > mean_close * 1.5, ( + f"Far depth ({mean_far:.2f}) should be > 1.5× close depth ({mean_close:.2f}). " + "Camera pose change may not be reaching the renderer." + ) + finally: + del camera diff --git a/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py b/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py index de65e8501793..68232656464f 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py @@ -47,9 +47,16 @@ class FabricFrameView(BaseFrameView): fallback and non-accelerated operations (local poses, visibility, scales when Fabric is disabled). - When Fabric is enabled, world-pose and scale operations use GPU-accelerated - Warp kernels operating on ``omni:fabric:worldMatrix``. All other operations - delegate to the internal USD view. + When Fabric is enabled, world-pose and scale operations use Warp kernels + operating on ``omni:fabric:worldMatrix``. All other operations delegate + to the internal USD view. + + After every Fabric write (``set_world_poses``, ``set_scales``), + :meth:`PrepareForReuse` is called on the ``PrimSelection`` to notify + the renderer (FSD/Storm) that Fabric data has changed and to detect + topology changes that require rebuilding internal mappings. Read + operations do not call PrepareForReuse to avoid unnecessary renderer + invalidation. Pose getters return :class:`~isaaclab.utils.warp.ProxyArray`. Setters accept ``wp.array``. """ @@ -59,23 +66,15 @@ def __init__( prim_path: str, device: str = "cpu", validate_xform_ops: bool = True, - sync_usd_on_fabric_write: bool = False, stage: Usd.Stage | None = None, + **kwargs, ): self._usd_view = UsdFrameView(prim_path, device=device, validate_xform_ops=validate_xform_ops, stage=stage) self._device = device - self._sync_usd_on_fabric_write = sync_usd_on_fabric_write settings = SettingsManager.instance() self._use_fabric = bool(settings.get("/physics/fabricEnabled", False)) - if self._use_fabric and self._device == "cpu": - logger.warning( - "Fabric mode with Warp fabric-array operations is not supported on CPU devices. " - "Falling back to standard USD operations on the CPU. This may impact performance." - ) - self._use_fabric = False - if self._use_fabric and self._device not in ("cuda", "cuda:0"): logger.warning( f"Fabric mode is not supported on device '{self._device}'. " @@ -136,6 +135,8 @@ def set_world_poses(self, positions=None, orientations=None, indices=None): if not self._fabric_initialized: self._initialize_fabric() + self._prepare_for_reuse() + indices_wp = self._resolve_indices_wp(indices) count = indices_wp.shape[0] @@ -167,8 +168,6 @@ def set_world_poses(self, positions=None, orientations=None, indices=None): self._fabric_hierarchy.update_world_xforms() self._fabric_usd_sync_done = True - if self._sync_usd_on_fabric_write: - self._usd_view.set_world_poses(positions, orientations, indices) def get_world_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: if not self._use_fabric: @@ -231,6 +230,8 @@ def set_scales(self, scales, indices=None): if not self._fabric_initialized: self._initialize_fabric() + self._prepare_for_reuse() + indices_wp = self._resolve_indices_wp(indices) count = indices_wp.shape[0] @@ -258,8 +259,6 @@ def set_scales(self, scales, indices=None): self._fabric_hierarchy.update_world_xforms() self._fabric_usd_sync_done = True - if self._sync_usd_on_fabric_write: - self._usd_view.set_scales(scales, indices) def get_scales(self, indices=None): if not self._use_fabric: @@ -297,6 +296,56 @@ def get_scales(self, indices=None): wp.synchronize() return scales_wp + # ------------------------------------------------------------------ + # Internal — PrepareForReuse (renderer notification + topology tracking) + # ------------------------------------------------------------------ + + def _prepare_for_reuse(self) -> None: + """Call PrepareForReuse on the PrimSelection to notify the renderer. + + PrepareForReuse serves two purposes: + + 1. **Renderer notification**: Tells FSD/Storm that Fabric data has + been (or will be) modified, so the next rendered frame reflects + the updated transforms. + 2. **Topology change detection**: Returns True when Fabric's + internal memory layout changed (e.g., prims added/removed). + In that case, view-to-fabric index mappings and fabricarrays + must be rebuilt. + """ + if self._fabric_selection is None: + return + + topology_changed = self._fabric_selection.PrepareForReuse() + if topology_changed: + logger.info("Fabric topology changed — rebuilding view-to-fabric index mapping.") + self._rebuild_fabric_arrays() + + def _rebuild_fabric_arrays(self) -> None: + """Rebuild fabricarray and view↔fabric mappings after a topology change. + + Note: Only index mappings and fabricarrays are rebuilt. Position/orientation/scale + buffers are *not* resized because ``self.count`` is derived from the USD prim-path + pattern (via ``_usd_view.count``) and does not change when Fabric rearranges its + internal memory layout. The assertion below guards this invariant. + """ + assert self.count == self._default_view_indices.shape[0], ( + f"Prim count changed ({self.count} vs {self._default_view_indices.shape[0]}). " + "Fabric topology change added/removed tracked prims — full re-initialization required." + ) + self._view_to_fabric = wp.zeros((self.count,), dtype=wp.uint32, device=self._fabric_device) + self._fabric_to_view = wp.fabricarray(self._fabric_selection, self._view_index_attr) + + wp.launch( + kernel=fabric_utils.set_view_to_fabric_array, + dim=self._fabric_to_view.shape[0], + inputs=[self._fabric_to_view, self._view_to_fabric], + device=self._fabric_device, + ) + wp.synchronize() + + self._fabric_world_matrices = wp.fabricarray(self._fabric_selection, "omni:fabric:worldMatrix") + # ------------------------------------------------------------------ # Internal — Fabric initialization # ------------------------------------------------------------------ @@ -391,11 +440,8 @@ def _sync_fabric_from_usd_once(self) -> None: orientations_usd = orientations_usd_ta.warp scales_usd = self._usd_view.get_scales() - prev_sync = self._sync_usd_on_fabric_write - self._sync_usd_on_fabric_write = False self.set_world_poses(positions_usd, orientations_usd) self.set_scales(scales_usd) - self._sync_usd_on_fabric_write = prev_sync self._fabric_usd_sync_done = True diff --git a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py index 4376c0e0b8ea..c9955b4097f4 100644 --- a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py +++ b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py @@ -21,8 +21,9 @@ import pytest # noqa: E402 import torch # noqa: E402 +import warp as wp # noqa: E402 from frame_view_contract_utils import * # noqa: F401, F403, E402 -from frame_view_contract_utils import CHILD_OFFSET, ViewBundle # noqa: E402 +from frame_view_contract_utils import CHILD_OFFSET, ViewBundle, test_set_world_updates_local # noqa: E402 from isaaclab_physx.sim.views import FabricFrameView as FrameView # noqa: E402 from pxr import Gf, UsdGeom # noqa: E402 @@ -95,7 +96,7 @@ def factory(num_envs: int, device: str) -> ViewBundle: sim_utils.create_prim(f"/World/Parent_{i}/Child", "Camera", translation=CHILD_OFFSET, stage=stage) sim_utils.SimulationContext(sim_utils.SimulationCfg(dt=0.01, device=device, use_fabric=True)) - view = FrameView("/World/Parent_.*/Child", device=device, sync_usd_on_fabric_write=True) + view = FrameView("/World/Parent_.*/Child", device=device) return ViewBundle( view=view, get_parent_pos=_get_parent_positions, @@ -104,3 +105,83 @@ def factory(num_envs: int, device: str) -> ViewBundle: ) return factory + + +# ------------------------------------------------------------------ +# Override shared contract test with expected failure for Fabric. +# FabricFrameView.set_world_poses writes to Fabric worldMatrix only; the local +# pose (read via USD) does not reflect the change because there is no +# Fabric → USD writeback for local poses. This is tracked as Issue #5 +# (localMatrix: set_local_poses falls back to USD). +# ------------------------------------------------------------------ + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.xfail( + reason=( + "Issue #5: FabricFrameView.set_world_poses writes to Fabric worldMatrix only. " + "get_local_poses reads from stale USD because there is no Fabric→USD " + "writeback for local poses." + ), + strict=True, +) +def test_set_world_updates_local(device, view_factory): # noqa: F811 + """Override the shared test to mark it as expected failure.""" + from frame_view_contract_utils import test_set_world_updates_local as _impl # noqa: PLC0415 + + _impl(device, view_factory) + + +# ------------------------------------------------------------------ +# Fabric-specific tests (not in shared contract) +# ------------------------------------------------------------------ + + +@wp.kernel +def _fill_position(out: wp.array(dtype=wp.float32, ndim=2), x: float, y: float, z: float): + i = wp.tid() + out[i, 0] = wp.float32(x) + out[i, 1] = wp.float32(y) + out[i, 2] = wp.float32(z) + + +@pytest.mark.parametrize("device", ["cuda:0"]) +def test_fabric_set_world_does_not_write_back_to_usd(device, view_factory): + """Verify that set_world_poses in Fabric mode does NOT sync back to USD. + + This confirms the removal of sync_usd_on_fabric_write. After calling + set_world_poses, the USD prim's xformOps should still contain the + original (stale) values. + """ + bundle = view_factory(1, device) + view = bundle.view + + # Capture the original USD world position BEFORE any Fabric write + stage = sim_utils.get_current_stage() + prim = stage.GetPrimAtPath(view.prim_paths[0]) + xform_cache = UsdGeom.XformCache() + usd_tf_before = xform_cache.GetLocalToWorldTransform(prim) + usd_t_before = usd_tf_before.ExtractTranslation() + orig_usd_pos = torch.tensor([float(usd_t_before[0]), float(usd_t_before[1]), float(usd_t_before[2])]) + + # Write to Fabric — move to (99, 99, 99) + new_pos = wp.zeros((1, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=1, inputs=[new_pos, 99.0, 99.0, 99.0], device=device) + view.set_world_poses(positions=new_pos) + + # Verify Fabric has the new position + fab_pos, _ = view.get_world_poses() + pos_torch = wp.to_torch(fab_pos) + assert torch.allclose(pos_torch, torch.tensor([[99.0, 99.0, 99.0]], device=device), atol=0.1), ( + f"Fabric should have new position, got {pos_torch}" + ) + + # Verify USD still has the ORIGINAL position (no writeback) + xform_cache_after = UsdGeom.XformCache() + usd_tf_after = xform_cache_after.GetLocalToWorldTransform(prim) + usd_t_after = usd_tf_after.ExtractTranslation() + usd_pos_after = torch.tensor([float(usd_t_after[0]), float(usd_t_after[1]), float(usd_t_after[2])]) + assert torch.allclose(usd_pos_after, orig_usd_pos, atol=0.1), ( + f"USD should still have original position {orig_usd_pos}, but got {usd_pos_after}. " + f"sync_usd_on_fabric_write may not have been fully removed." + ) From bc7f6a4e6a74ab82b8fb2dae6c79db9a9347c19e Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Tue, 28 Apr 2026 22:34:11 +0000 Subject: [PATCH 02/13] revert: camera uses FrameView instead of UsdFrameView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the special-case UsdFrameView for cameras. Camera now uses the same FrameView (→ FabricFrameView when Fabric is enabled) as all other prim types. --- source/isaaclab/isaaclab/sensors/camera/camera.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/source/isaaclab/isaaclab/sensors/camera/camera.py b/source/isaaclab/isaaclab/sensors/camera/camera.py index 5f1768e96b20..5d7eebe56e9b 100644 --- a/source/isaaclab/isaaclab/sensors/camera/camera.py +++ b/source/isaaclab/isaaclab/sensors/camera/camera.py @@ -19,7 +19,7 @@ import isaaclab.utils.sensors as sensor_utils from isaaclab.app.settings_manager import get_settings_manager from isaaclab.renderers import BaseRenderer, Renderer -from isaaclab.sim.views.usd_frame_view import UsdFrameView +from isaaclab.sim.views import FrameView from isaaclab.utils import has_kit, to_camel_case from isaaclab.utils.math import ( convert_camera_frame_orientation_convention, @@ -404,11 +404,7 @@ def _initialize_impl(self): # references to prims located in the stage. sim_ctx.render_context.ensure_prepare_stage(self.stage, self._num_envs) - # Camera poses must be written to USD because the RTX renderer reads camera - # transforms via HydraTexture from USD, not from Fabric's worldMatrix. - # PrepareForReuse marks Fabric attributes dirty but this is not sufficient - # for cameras — use UsdFrameView to ensure poses reach the renderer. - self._view = UsdFrameView(self.cfg.prim_path, device=self._device, stage=self.stage) + self._view = FrameView(self.cfg.prim_path, device=self._device, stage=self.stage) # Check that sizes are correct if self._view.count != self._num_envs: raise RuntimeError( From f7fac8e3dc3e287aa8577aac111fcfffaadc38f7 Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Thu, 30 Apr 2026 18:16:26 +0200 Subject: [PATCH 03/13] fix: adjust camera position in test to workaround degenerate pose --- source/isaaclab/test/sensors/test_ray_caster_camera.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/isaaclab/test/sensors/test_ray_caster_camera.py b/source/isaaclab/test/sensors/test_ray_caster_camera.py index a913d38dd833..752734936934 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_camera.py +++ b/source/isaaclab/test/sensors/test_ray_caster_camera.py @@ -898,11 +898,11 @@ def test_output_equal_to_usd_camera_when_intrinsics_set(setup_sim, focal_length_ # set camera position camera_warp.set_world_poses_from_view( - eyes=torch.tensor([[0.001, 0.0, 5.0]], device=camera_warp.device), + eyes=torch.tensor([[0.1, 0.0, 5.0]], device=camera_warp.device), targets=torch.tensor([[0.0, 0.0, 0.0]], device=camera_warp.device), ) camera_usd.set_world_poses_from_view( - eyes=torch.tensor([[0.001, 0.0, 5.0]], device=camera_usd.device), + eyes=torch.tensor([[0.1, 0.0, 5.0]], device=camera_usd.device), targets=torch.tensor([[0.0, 0.0, 0.0]], device=camera_usd.device), ) From 3521b772af9b7385a6e223820863a90dec9b42dd Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Thu, 30 Apr 2026 18:17:31 +0200 Subject: [PATCH 04/13] fix: remove CPU skip condition in _skip_if_unavailable for fabric tests --- source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py index c9955b4097f4..870be063aca2 100644 --- a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py +++ b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py @@ -46,8 +46,6 @@ def test_setup_teardown(): def _skip_if_unavailable(device: str): if device.startswith("cuda") and not torch.cuda.is_available(): pytest.skip("CUDA not available") - if device == "cpu": - pytest.skip("Warp fabricarray operations on CPU have known issues") # ------------------------------------------------------------------ From 38beedc3f94ae36e1f626163b1f315e8800daee3 Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Thu, 30 Apr 2026 18:27:24 +0200 Subject: [PATCH 05/13] fix: remove device fallback warning in FabricFrameView and update device handling for Fabric operations --- .../sim/views/fabric_frame_view.py | 39 +++++-------------- 1 file changed, 9 insertions(+), 30 deletions(-) diff --git a/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py b/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py index 68232656464f..07ddc8c2c0a1 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py @@ -75,14 +75,6 @@ def __init__( settings = SettingsManager.instance() self._use_fabric = bool(settings.get("/physics/fabricEnabled", False)) - if self._use_fabric and self._device not in ("cuda", "cuda:0"): - logger.warning( - f"Fabric mode is not supported on device '{self._device}'. " - "USDRT SelectPrims and Warp fabric arrays only support cuda:0. " - "Falling back to standard USD operations. This may impact performance." - ) - self._use_fabric = False - self._fabric_initialized = False self._fabric_usd_sync_done = False self._fabric_selection = None @@ -162,7 +154,7 @@ def set_world_poses(self, positions=None, orientations=None, indices=None): indices_wp, self._view_to_fabric, ], - device=self._fabric_device, + device=self._device, ) wp.synchronize() @@ -200,7 +192,7 @@ def get_world_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, indices_wp, self._view_to_fabric, ], - device=self._fabric_device, + device=self._device, ) if use_cached: @@ -253,7 +245,7 @@ def set_scales(self, scales, indices=None): indices_wp, self._view_to_fabric, ], - device=self._fabric_device, + device=self._device, ) wp.synchronize() @@ -289,7 +281,7 @@ def get_scales(self, indices=None): indices_wp, self._view_to_fabric, ], - device=self._fabric_device, + device=self._device, ) if use_cached: @@ -333,14 +325,14 @@ def _rebuild_fabric_arrays(self) -> None: f"Prim count changed ({self.count} vs {self._default_view_indices.shape[0]}). " "Fabric topology change added/removed tracked prims — full re-initialization required." ) - self._view_to_fabric = wp.zeros((self.count,), dtype=wp.uint32, device=self._fabric_device) + self._view_to_fabric = wp.zeros((self.count,), dtype=wp.uint32, device=self._device) self._fabric_to_view = wp.fabricarray(self._fabric_selection, self._view_index_attr) wp.launch( kernel=fabric_utils.set_view_to_fabric_array, dim=self._fabric_to_view.shape[0], inputs=[self._fabric_to_view, self._view_to_fabric], - device=self._fabric_device, + device=self._device, ) wp.synchronize() @@ -386,34 +378,22 @@ def _initialize_fabric(self) -> None: ) wp.synchronize() - fabric_device = self._device - if self._device == "cuda": - logger.warning("Fabric device is not specified, defaulting to 'cuda:0'.") - fabric_device = "cuda:0" - elif self._device.startswith("cuda:"): - if self._device != "cuda:0": - logger.debug( - f"SelectPrims only supports cuda:0. Using cuda:0 for SelectPrims " - f"even though simulation device is {self._device}." - ) - fabric_device = "cuda:0" - self._fabric_selection = fabric_stage.SelectPrims( require_attrs=[ (usdrt.Sdf.ValueTypeNames.UInt, self._view_index_attr, usdrt.Usd.Access.Read), (usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", usdrt.Usd.Access.ReadWrite), ], - device=fabric_device, + device=self._device, ) - self._view_to_fabric = wp.zeros((self.count,), dtype=wp.uint32, device=fabric_device) + self._view_to_fabric = wp.zeros((self.count,), dtype=wp.uint32, device=self._device) self._fabric_to_view = wp.fabricarray(self._fabric_selection, self._view_index_attr) wp.launch( kernel=fabric_utils.set_view_to_fabric_array, dim=self._fabric_to_view.shape[0], inputs=[self._fabric_to_view, self._view_to_fabric], - device=fabric_device, + device=self._device, ) wp.synchronize() @@ -425,7 +405,6 @@ def _initialize_fabric(self) -> None: self._fabric_dummy_buffer = wp.zeros((0, 3), dtype=wp.float32, device=self._device) self._fabric_world_matrices = wp.fabricarray(self._fabric_selection, "omni:fabric:worldMatrix") self._fabric_stage = fabric_stage - self._fabric_device = fabric_device self._fabric_initialized = True self._fabric_usd_sync_done = False From 0ebdb8689afd2edc11cb57d51c8131c0ea13f765 Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Thu, 30 Apr 2026 18:27:43 +0200 Subject: [PATCH 06/13] fix: adjust camera position in test to workaround degenerate pose --- .../test/sensors/test_multi_mesh_ray_caster_camera.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py b/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py index 8657c938c691..7e7efe16d091 100644 --- a/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py +++ b/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py @@ -752,11 +752,11 @@ def test_output_equal_to_usd_camera_when_intrinsics_set(setup_simulation): # set camera position camera_warp.set_world_poses_from_view( - eyes=torch.tensor([[0.0, 0.0, 5.0]], device=camera_warp.device), + eyes=torch.tensor([[0.1, 0.0, 5.0]], device=camera_warp.device), targets=torch.tensor([[0.0, 0.0, 0.0]], device=camera_warp.device), ) camera_usd.set_world_poses_from_view( - eyes=torch.tensor([[0.0, 0.0, 5.0]], device=camera_usd.device), + eyes=torch.tensor([[0.1, 0.0, 5.0]], device=camera_usd.device), targets=torch.tensor([[0.0, 0.0, 0.0]], device=camera_usd.device), ) From 5bd93886709b2fc8866da247663a548699d255d4 Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Thu, 30 Apr 2026 19:04:39 +0200 Subject: [PATCH 07/13] Revert "fix: remove device fallback warning in FabricFrameView and update device handling for Fabric operations" This reverts commit 4310d7a566602c44af3c911226158a3d0588e8d1. --- .../sim/views/fabric_frame_view.py | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py b/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py index 07ddc8c2c0a1..68232656464f 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py @@ -75,6 +75,14 @@ def __init__( settings = SettingsManager.instance() self._use_fabric = bool(settings.get("/physics/fabricEnabled", False)) + if self._use_fabric and self._device not in ("cuda", "cuda:0"): + logger.warning( + f"Fabric mode is not supported on device '{self._device}'. " + "USDRT SelectPrims and Warp fabric arrays only support cuda:0. " + "Falling back to standard USD operations. This may impact performance." + ) + self._use_fabric = False + self._fabric_initialized = False self._fabric_usd_sync_done = False self._fabric_selection = None @@ -154,7 +162,7 @@ def set_world_poses(self, positions=None, orientations=None, indices=None): indices_wp, self._view_to_fabric, ], - device=self._device, + device=self._fabric_device, ) wp.synchronize() @@ -192,7 +200,7 @@ def get_world_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, indices_wp, self._view_to_fabric, ], - device=self._device, + device=self._fabric_device, ) if use_cached: @@ -245,7 +253,7 @@ def set_scales(self, scales, indices=None): indices_wp, self._view_to_fabric, ], - device=self._device, + device=self._fabric_device, ) wp.synchronize() @@ -281,7 +289,7 @@ def get_scales(self, indices=None): indices_wp, self._view_to_fabric, ], - device=self._device, + device=self._fabric_device, ) if use_cached: @@ -325,14 +333,14 @@ def _rebuild_fabric_arrays(self) -> None: f"Prim count changed ({self.count} vs {self._default_view_indices.shape[0]}). " "Fabric topology change added/removed tracked prims — full re-initialization required." ) - self._view_to_fabric = wp.zeros((self.count,), dtype=wp.uint32, device=self._device) + self._view_to_fabric = wp.zeros((self.count,), dtype=wp.uint32, device=self._fabric_device) self._fabric_to_view = wp.fabricarray(self._fabric_selection, self._view_index_attr) wp.launch( kernel=fabric_utils.set_view_to_fabric_array, dim=self._fabric_to_view.shape[0], inputs=[self._fabric_to_view, self._view_to_fabric], - device=self._device, + device=self._fabric_device, ) wp.synchronize() @@ -378,22 +386,34 @@ def _initialize_fabric(self) -> None: ) wp.synchronize() + fabric_device = self._device + if self._device == "cuda": + logger.warning("Fabric device is not specified, defaulting to 'cuda:0'.") + fabric_device = "cuda:0" + elif self._device.startswith("cuda:"): + if self._device != "cuda:0": + logger.debug( + f"SelectPrims only supports cuda:0. Using cuda:0 for SelectPrims " + f"even though simulation device is {self._device}." + ) + fabric_device = "cuda:0" + self._fabric_selection = fabric_stage.SelectPrims( require_attrs=[ (usdrt.Sdf.ValueTypeNames.UInt, self._view_index_attr, usdrt.Usd.Access.Read), (usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", usdrt.Usd.Access.ReadWrite), ], - device=self._device, + device=fabric_device, ) - self._view_to_fabric = wp.zeros((self.count,), dtype=wp.uint32, device=self._device) + self._view_to_fabric = wp.zeros((self.count,), dtype=wp.uint32, device=fabric_device) self._fabric_to_view = wp.fabricarray(self._fabric_selection, self._view_index_attr) wp.launch( kernel=fabric_utils.set_view_to_fabric_array, dim=self._fabric_to_view.shape[0], inputs=[self._fabric_to_view, self._view_to_fabric], - device=self._device, + device=fabric_device, ) wp.synchronize() @@ -405,6 +425,7 @@ def _initialize_fabric(self) -> None: self._fabric_dummy_buffer = wp.zeros((0, 3), dtype=wp.float32, device=self._device) self._fabric_world_matrices = wp.fabricarray(self._fabric_selection, "omni:fabric:worldMatrix") self._fabric_stage = fabric_stage + self._fabric_device = fabric_device self._fabric_initialized = True self._fabric_usd_sync_done = False From ce5845aa0316cdca45fc7dc23e19fc30a39877c7 Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Thu, 30 Apr 2026 22:42:57 +0200 Subject: [PATCH 08/13] fix: update device compatibility for Fabric mode and simplify device handling --- .../sim/views/fabric_frame_view.py | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py b/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py index 68232656464f..5c6c19b6e03d 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py @@ -23,6 +23,10 @@ logger = logging.getLogger(__name__) +# TODO: Currently we don't support multiple GPUs for Fabric-accelerated views +# because USDRT SelectPrims only supported cuda:0 at the time of writing. +_fabric_supported_devices = ("cpu", "cuda", "cuda:0") + def _to_float32_2d(a: wp.array | torch.Tensor) -> wp.array | torch.Tensor: """Ensure array is compatible with Fabric kernels (2-D float32). @@ -75,10 +79,11 @@ def __init__( settings = SettingsManager.instance() self._use_fabric = bool(settings.get("/physics/fabricEnabled", False)) - if self._use_fabric and self._device not in ("cuda", "cuda:0"): + if self._use_fabric and self._device not in _fabric_supported_devices: logger.warning( f"Fabric mode is not supported on device '{self._device}'. " - "USDRT SelectPrims and Warp fabric arrays only support cuda:0. " + "USDRT SelectPrims and Warp fabric arrays are currently " + f"only supported on {', '.join(_fabric_supported_devices)}. " "Falling back to standard USD operations. This may impact performance." ) self._use_fabric = False @@ -386,17 +391,10 @@ def _initialize_fabric(self) -> None: ) wp.synchronize() + # The constructor should have taken care of this, but double check here to avoid regressions + assert self._device in _fabric_supported_devices + fabric_device = self._device - if self._device == "cuda": - logger.warning("Fabric device is not specified, defaulting to 'cuda:0'.") - fabric_device = "cuda:0" - elif self._device.startswith("cuda:"): - if self._device != "cuda:0": - logger.debug( - f"SelectPrims only supports cuda:0. Using cuda:0 for SelectPrims " - f"even though simulation device is {self._device}." - ) - fabric_device = "cuda:0" self._fabric_selection = fabric_stage.SelectPrims( require_attrs=[ From e51cbe08560948ca8d5e5a004bbe33dd3cc26642 Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Thu, 30 Apr 2026 23:25:57 +0200 Subject: [PATCH 09/13] fix: expand device parameterization for Fabric world pose test --- source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py index 870be063aca2..ab762c55acec 100644 --- a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py +++ b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py @@ -143,7 +143,7 @@ def _fill_position(out: wp.array(dtype=wp.float32, ndim=2), x: float, y: float, out[i, 2] = wp.float32(z) -@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) def test_fabric_set_world_does_not_write_back_to_usd(device, view_factory): """Verify that set_world_poses in Fabric mode does NOT sync back to USD. From 9a8b15a3d064d5bb01f9ebe6e7728e467a067c12 Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Mon, 4 May 2026 11:59:30 +0000 Subject: [PATCH 10/13] Use changelog fragment instead of editing CHANGELOG.rst directly The fragment-based changelog system landed in upstream develop (#5434) to avoid per-PR merge conflicts on the top of CHANGELOG.rst. Switch this PR to the new system: drop the in-rebase direct edits to CHANGELOG.rst and config/extension.toml, and ship a fragment under changelog.d/ instead. --- .../changelog.d/fix-fabric-prepare-for-reuse.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 source/isaaclab_physx/changelog.d/fix-fabric-prepare-for-reuse.rst diff --git a/source/isaaclab_physx/changelog.d/fix-fabric-prepare-for-reuse.rst b/source/isaaclab_physx/changelog.d/fix-fabric-prepare-for-reuse.rst new file mode 100644 index 000000000000..e7d842da72bd --- /dev/null +++ b/source/isaaclab_physx/changelog.d/fix-fabric-prepare-for-reuse.rst @@ -0,0 +1,12 @@ +Changed +^^^^^^^ + +* **Breaking:** Removed the ``sync_usd_on_fabric_write`` keyword argument from + :class:`~isaaclab_physx.sim.views.FabricFrameView`. Fabric writes + (``set_world_poses``, ``set_scales``) now notify the renderer via + ``PrepareForReuse()`` on the underlying ``PrimSelection`` instead of writing + back to USD, which is ~200x faster and avoids the stale USD shadow state the + old path produced. Callers passing ``sync_usd_on_fabric_write=True`` should + remove the argument; if they relied on USD reflecting Fabric writes, they + should now read Fabric poses directly via the view's getters or refresh USD + explicitly. From 1235be63be59da4ab67b1c831ba37921aaf03fbe Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Mon, 4 May 2026 11:59:42 +0000 Subject: [PATCH 11/13] Enhancements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Drop redundant `fabric_device` local in `_initialize_fabric` — every assignment was `fabric_device = self._device`, so just use `self._device` directly. `self._fabric_device` (the persistent attribute) is preserved. * Drop the explicit `_fabric_usd_sync_done = True` at the end of `_sync_fabric_from_usd_once`. Both `set_world_poses` and `set_scales` already set it on success, so the trailing assignment is dead under the happy path and would mask a partial failure if either write raised. * Tighten the no-writeback assertion in `test_fabric_set_world_does_not_write_back_to_usd` from `atol=0.1` to `atol=0.0`. USD literally must not have moved — any drift indicates a residual writeback path, and the loose tolerance would hide a 0.099-unit regression. * Add `test_fabric_rebuild_after_topology_change` covering the `_prepare_for_reuse` → `_rebuild_fabric_arrays` branch, which is the load-bearing replacement for the removed `sync_usd_on_fabric_write`. The test monkeypatches `_prepare_for_reuse` to always take the rebuild branch (real Fabric topology changes are hard to provoke from a unit test) and verifies that a subsequent write/read still produces correct data, proving `_view_to_fabric` and `_fabric_world_matrices` are still consistent after the rebuild. --- .../isaaclab/sensors/camera/camera.py | 4 +- .../sim/views/fabric_frame_view.py | 18 +++---- .../test/sim/test_views_xform_prim_fabric.py | 54 ++++++++++++++++++- 3 files changed, 63 insertions(+), 13 deletions(-) diff --git a/source/isaaclab/isaaclab/sensors/camera/camera.py b/source/isaaclab/isaaclab/sensors/camera/camera.py index 5d7eebe56e9b..a5fe040a3d74 100644 --- a/source/isaaclab/isaaclab/sensors/camera/camera.py +++ b/source/isaaclab/isaaclab/sensors/camera/camera.py @@ -18,9 +18,9 @@ import isaaclab.sim as sim_utils import isaaclab.utils.sensors as sensor_utils from isaaclab.app.settings_manager import get_settings_manager -from isaaclab.renderers import BaseRenderer, Renderer +from isaaclab.renderers import BaseRenderer, CameraRenderSpec from isaaclab.sim.views import FrameView -from isaaclab.utils import has_kit, to_camel_case +from isaaclab.utils import to_camel_case from isaaclab.utils.math import ( convert_camera_frame_orientation_convention, create_rotation_matrix_from_view, diff --git a/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py b/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py index 5c6c19b6e03d..0a18652927fa 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py @@ -394,24 +394,22 @@ def _initialize_fabric(self) -> None: # The constructor should have taken care of this, but double check here to avoid regressions assert self._device in _fabric_supported_devices - fabric_device = self._device - self._fabric_selection = fabric_stage.SelectPrims( require_attrs=[ (usdrt.Sdf.ValueTypeNames.UInt, self._view_index_attr, usdrt.Usd.Access.Read), (usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", usdrt.Usd.Access.ReadWrite), ], - device=fabric_device, + device=self._device, ) - self._view_to_fabric = wp.zeros((self.count,), dtype=wp.uint32, device=fabric_device) + self._view_to_fabric = wp.zeros((self.count,), dtype=wp.uint32, device=self._device) self._fabric_to_view = wp.fabricarray(self._fabric_selection, self._view_index_attr) wp.launch( kernel=fabric_utils.set_view_to_fabric_array, dim=self._fabric_to_view.shape[0], inputs=[self._fabric_to_view, self._view_to_fabric], - device=fabric_device, + device=self._device, ) wp.synchronize() @@ -423,13 +421,17 @@ def _initialize_fabric(self) -> None: self._fabric_dummy_buffer = wp.zeros((0, 3), dtype=wp.float32, device=self._device) self._fabric_world_matrices = wp.fabricarray(self._fabric_selection, "omni:fabric:worldMatrix") self._fabric_stage = fabric_stage - self._fabric_device = fabric_device + self._fabric_device = self._device self._fabric_initialized = True self._fabric_usd_sync_done = False def _sync_fabric_from_usd_once(self) -> None: - """Sync Fabric world matrices from USD once, on the first read.""" + """Sync Fabric world matrices from USD once, on the first read. + + ``set_world_poses`` and ``set_scales`` each set ``_fabric_usd_sync_done`` + themselves, so no explicit flag assignment is needed here. + """ if not self._fabric_initialized: self._initialize_fabric() @@ -441,8 +443,6 @@ def _sync_fabric_from_usd_once(self) -> None: self.set_world_poses(positions_usd, orientations_usd) self.set_scales(scales_usd) - self._fabric_usd_sync_done = True - def _resolve_indices_wp(self, indices: wp.array | None) -> wp.array: """Resolve view indices as a Warp uint32 array.""" if indices is None or indices == slice(None): diff --git a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py index ab762c55acec..f0c18ccb98c7 100644 --- a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py +++ b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py @@ -174,12 +174,62 @@ def test_fabric_set_world_does_not_write_back_to_usd(device, view_factory): f"Fabric should have new position, got {pos_torch}" ) - # Verify USD still has the ORIGINAL position (no writeback) + # Verify USD still has the ORIGINAL position (no writeback). Equality, not + # approximate — USD should literally not have moved, so any drift would + # indicate a residual writeback path. xform_cache_after = UsdGeom.XformCache() usd_tf_after = xform_cache_after.GetLocalToWorldTransform(prim) usd_t_after = usd_tf_after.ExtractTranslation() usd_pos_after = torch.tensor([float(usd_t_after[0]), float(usd_t_after[1]), float(usd_t_after[2])]) - assert torch.allclose(usd_pos_after, orig_usd_pos, atol=0.1), ( + assert torch.allclose(usd_pos_after, orig_usd_pos, atol=0.0), ( f"USD should still have original position {orig_usd_pos}, but got {usd_pos_after}. " f"sync_usd_on_fabric_write may not have been fully removed." ) + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_fabric_rebuild_after_topology_change(device, view_factory, monkeypatch): + """Forcing the topology-changed branch on a write triggers + :meth:`_rebuild_fabric_arrays` and leaves the view in a state where + subsequent writes/reads still produce correct data. + + Real ``PrimSelection.PrepareForReuse`` reports topology change only when + Fabric reallocates internally, which is hard to provoke from a unit test. + Instead we monkeypatch ``_prepare_for_reuse`` on the instance to always + take the rebuild branch and verify the view remains usable. + """ + bundle = view_factory(2, device) + view = bundle.view + + # First write — initializes Fabric and binds _fabric_selection. + initial = wp.zeros((2, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=2, inputs=[initial, 1.0, 2.0, 3.0], device=device) + view.set_world_poses(positions=initial) + + rebuild_calls = [] + real_rebuild = view._rebuild_fabric_arrays + + def spy_rebuild(): + rebuild_calls.append(True) + real_rebuild() + + def force_topology_changed(): + if view._fabric_selection is not None: + view._fabric_selection.PrepareForReuse() + spy_rebuild() + + monkeypatch.setattr(view, "_prepare_for_reuse", force_topology_changed) + + # Trigger another write — goes through the forced topology-change branch. + new = wp.zeros((2, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=2, inputs=[new, 4.0, 5.0, 6.0], device=device) + view.set_world_poses(positions=new) + + assert rebuild_calls, "Forced topology-change branch did not invoke _rebuild_fabric_arrays" + + # Read back — proves the rebuilt _view_to_fabric and _fabric_world_matrices + # are still consistent. + ret_pos, _ = view.get_world_poses() + pos_torch = wp.to_torch(ret_pos) + expected = torch.tensor([[4.0, 5.0, 6.0], [4.0, 5.0, 6.0]], device=device) + assert torch.allclose(pos_torch, expected, atol=1e-7), f"Read after rebuild failed on {device}: {pos_torch}" From 21efc012bd876cbc06299fb44be4f873f0218be0 Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Mon, 4 May 2026 14:49:33 +0000 Subject: [PATCH 12/13] Add changelog fragment for isaaclab.Camera call-site cleanup CI's "Verify changelog fragments" check flagged isaaclab as a touched package without a fragment. The change is the camera.py call site dropping the now-removed sync_usd_on_fabric_write=True kwarg from its FrameView construction (paired with the kwarg removal documented in the isaaclab_physx fragment). --- .../isaaclab/changelog.d/fix-fabric-prepare-for-reuse.rst | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 source/isaaclab/changelog.d/fix-fabric-prepare-for-reuse.rst diff --git a/source/isaaclab/changelog.d/fix-fabric-prepare-for-reuse.rst b/source/isaaclab/changelog.d/fix-fabric-prepare-for-reuse.rst new file mode 100644 index 000000000000..20a6d385c094 --- /dev/null +++ b/source/isaaclab/changelog.d/fix-fabric-prepare-for-reuse.rst @@ -0,0 +1,8 @@ +Changed +^^^^^^^ + +* Updated :class:`~isaaclab.sensors.camera.Camera` to construct its internal + :class:`~isaaclab.sim.views.FrameView` without the now-removed + ``sync_usd_on_fabric_write`` kwarg. USD attributes on camera prims are + no longer kept in sync with Fabric writes; read poses through the view's + getters instead. From 54e112a3624da9684fc65b935a204ae74d213561 Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Tue, 5 May 2026 15:44:25 +0000 Subject: [PATCH 13/13] Address Piotr's review feedback on PR #5380 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Move test_camera_pose_update_reflected_in_render from test_tiled_camera.py to test_camera.py and drop the TiledCamera variant. TiledCamera is a deprecated alias for Camera, so testing both was redundant. The two test files will eventually be consolidated. * Update the multi-GPU TODO comment on _fabric_supported_devices: recent Kit / USDRT releases support multi-GPU SelectPrims, but the rest of the view wiring still assumes one device — followup work. * Drop the "FSD/Storm" reference in the class docstring; we only target FSD. * Document **kwargs in FabricFrameView.__init__: it matches UsdFrameView's signature so the top-level FrameView factory can forward backend-agnostic kwargs without each backend having to know about every option. --- source/isaaclab/test/sensors/test_camera.py | 66 ++++++++++++++++ .../test/sensors/test_tiled_camera.py | 79 ------------------- .../sim/views/fabric_frame_view.py | 25 ++++-- 3 files changed, 85 insertions(+), 85 deletions(-) diff --git a/source/isaaclab/test/sensors/test_camera.py b/source/isaaclab/test/sensors/test_camera.py index e1178192ef63..30146961cc7f 100644 --- a/source/isaaclab/test/sensors/test_camera.py +++ b/source/isaaclab/test/sensors/test_camera.py @@ -1181,6 +1181,72 @@ def cleanup(self, render_data): Renderer._registry.pop(backend, None) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_camera_pose_update_reflected_in_render(setup_camera_device, device): + """Camera pose changes via FrameView should be visible in rendered depth. + + Moves the camera close then far, renders depth, and verifies that the mean + valid depth from the far position is significantly larger (>1.5×) than the + close position. This validates that Fabric-side pose writes (via + PrepareForReuse) and USD writes are correctly propagated to the RTX + renderer. + """ + sim, _unused_cam_cfg, dt = setup_camera_device + + cam_cfg = CameraCfg( + prim_path="/World/PoseTestCam", + height=128, + width=256, + update_period=0, + update_latest_camera_pose=True, + data_types=["distance_to_camera"], + spawn=sim_utils.PinholeCameraCfg( + focal_length=24.0, + focus_distance=400.0, + horizontal_aperture=20.955, + clipping_range=(0.1, 1.0e5), + ), + ) + camera = Camera(cam_cfg) + try: + sim.reset() + + target = torch.tensor([[0.0, 0.0, 0.0]], dtype=torch.float32, device=camera.device) + max_range = cam_cfg.spawn.clipping_range[1] + + # -- close position -- + eyes_close = torch.tensor([[2.0, 2.0, 2.0]], dtype=torch.float32, device=camera.device) + camera.set_world_poses_from_view(eyes_close, target) + sim.step() + camera.update(dt) + depth_close = camera.data.output["distance_to_camera"].clone() + + # -- far position -- + eyes_far = torch.tensor([[8.0, 8.0, 8.0]], dtype=torch.float32, device=camera.device) + camera.set_world_poses_from_view(eyes_far, target) + sim.step() + camera.update(dt) + depth_far = camera.data.output["distance_to_camera"].clone() + + # -- validate -- + valid_close = depth_close[depth_close < max_range] + valid_far = depth_far[depth_far < max_range] + + assert valid_close.numel() > 0, "No valid close-range depth pixels" + assert valid_far.numel() > 0, "No valid far-range depth pixels" + + mean_close = valid_close.mean().item() + mean_far = valid_far.mean().item() + + assert mean_far > mean_close * 1.5, ( + f"Far depth ({mean_far:.2f}) should be > 1.5× close depth ({mean_close:.2f}). " + "Camera pose change may not be reaching the renderer." + ) + finally: + del camera + + def _populate_scene(): """Add prims to the scene.""" # Ground-plane diff --git a/source/isaaclab/test/sensors/test_tiled_camera.py b/source/isaaclab/test/sensors/test_tiled_camera.py index ee9acca16ce1..4ce62cd5336f 100644 --- a/source/isaaclab/test/sensors/test_tiled_camera.py +++ b/source/isaaclab/test/sensors/test_tiled_camera.py @@ -195,82 +195,3 @@ def _populate_scene(): sim_utils.define_rigid_body_properties(prim_path, sim_utils.RigidBodyPropertiesCfg()) sim_utils.define_mass_properties(prim_path, sim_utils.MassPropertiesCfg(mass=5.0)) sim_utils.define_collision_properties(prim_path, sim_utils.CollisionPropertiesCfg()) - - -# ------------------------------------------------------------------ -# Camera pose → render validation (PrepareForReuse / Fabric path) -# ------------------------------------------------------------------ - - -@pytest.mark.isaacsim_ci -@pytest.mark.parametrize( - "device, camera_cls", - [ - pytest.param("cpu", TiledCamera, id="cpu-tiled"), - pytest.param("cpu", Camera, id="cpu-non_tiled"), - pytest.param("cuda:0", TiledCamera, id="cuda:0-tiled"), - pytest.param("cuda:0", Camera, id="cuda:0-non_tiled"), - ], -) -def test_camera_pose_update_reflected_in_render(setup_camera, device, camera_cls): - """Camera pose changes via FrameView should be visible in rendered depth. - - Moves camera close then far, renders depth, and verifies that the mean - valid depth from the far position is significantly larger (>1.5×) than - the close position. This validates that Fabric-side pose writes - (via PrepareForReuse) or USD writes are correctly propagated to the - RTX renderer. - """ - sim, _unused_cam_cfg, dt = setup_camera - - cam_cfg = CameraCfg( - prim_path="/World/PoseTestCam", - height=128, - width=256, - update_period=0, - update_latest_camera_pose=True, - data_types=["distance_to_camera"], - spawn=sim_utils.PinholeCameraCfg( - focal_length=24.0, - focus_distance=400.0, - horizontal_aperture=20.955, - clipping_range=(0.1, 1.0e5), - ), - ) - camera = camera_cls(cam_cfg) - try: - sim.reset() - - target = torch.tensor([[0.0, 0.0, 0.0]], dtype=torch.float32, device=camera.device) - max_range = cam_cfg.spawn.clipping_range[1] - - # -- close position -- - eyes_close = torch.tensor([[2.0, 2.0, 2.0]], dtype=torch.float32, device=camera.device) - camera.set_world_poses_from_view(eyes_close, target) - sim.step() - camera.update(dt) - depth_close = camera.data.output["distance_to_camera"].clone() - - # -- far position -- - eyes_far = torch.tensor([[8.0, 8.0, 8.0]], dtype=torch.float32, device=camera.device) - camera.set_world_poses_from_view(eyes_far, target) - sim.step() - camera.update(dt) - depth_far = camera.data.output["distance_to_camera"].clone() - - # -- validate -- - valid_close = depth_close[depth_close < max_range] - valid_far = depth_far[depth_far < max_range] - - assert valid_close.numel() > 0, "No valid close-range depth pixels" - assert valid_far.numel() > 0, "No valid far-range depth pixels" - - mean_close = valid_close.mean().item() - mean_far = valid_far.mean().item() - - assert mean_far > mean_close * 1.5, ( - f"Far depth ({mean_far:.2f}) should be > 1.5× close depth ({mean_close:.2f}). " - "Camera pose change may not be reaching the renderer." - ) - finally: - del camera diff --git a/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py b/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py index 0a18652927fa..1bcff86d57ac 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py @@ -23,8 +23,10 @@ logger = logging.getLogger(__name__) -# TODO: Currently we don't support multiple GPUs for Fabric-accelerated views -# because USDRT SelectPrims only supported cuda:0 at the time of writing. +# TODO: extend this to ``cuda:N`` once we wire up multi-GPU support for the view. +# Recent Kit / USDRT releases do support multi-GPU ``SelectPrims``, but the +# rest of the FabricFrameView wiring (selections, indexed arrays, etc.) still +# assumes a single device — to be tackled in a follow-up. _fabric_supported_devices = ("cpu", "cuda", "cuda:0") @@ -57,10 +59,9 @@ class FabricFrameView(BaseFrameView): After every Fabric write (``set_world_poses``, ``set_scales``), :meth:`PrepareForReuse` is called on the ``PrimSelection`` to notify - the renderer (FSD/Storm) that Fabric data has changed and to detect - topology changes that require rebuilding internal mappings. Read - operations do not call PrepareForReuse to avoid unnecessary renderer - invalidation. + the FSD renderer that Fabric data has changed and to detect topology + changes that require rebuilding internal mappings. Read operations + do not call PrepareForReuse to avoid unnecessary renderer invalidation. Pose getters return :class:`~isaaclab.utils.warp.ProxyArray`. Setters accept ``wp.array``. """ @@ -73,6 +74,18 @@ def __init__( stage: Usd.Stage | None = None, **kwargs, ): + """Initialize the view. + + Args: + prim_path: USD prim-path pattern to match. + device: Device for Warp arrays (``"cpu"`` or ``"cuda:0"``). + validate_xform_ops: Whether to validate prim xform-ops. + stage: USD stage; defaults to the current sim context's stage. + **kwargs: Additional keyword arguments (ignored). Matches the signature of + :class:`~isaaclab.sim.views.UsdFrameView` so that the top-level + :class:`~isaaclab.sim.views.FrameView` factory can forward backend-agnostic + kwargs without each backend having to know about every option. + """ self._usd_view = UsdFrameView(prim_path, device=device, validate_xform_ops=validate_xform_ops, stage=stage) self._device = device