diff --git a/source/isaaclab_physx/changelog.d/fabric-stage-cache.rst b/source/isaaclab_physx/changelog.d/fabric-stage-cache.rst new file mode 100644 index 000000000000..e93e9e370015 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/fabric-stage-cache.rst @@ -0,0 +1,10 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_physx.sim.FabricStageCache` — a lightweight cache for the + ``usdrt.Usd.Stage`` attachment and ``IFabricHierarchy`` handles, registered as a + service on :class:`~isaaclab.sim.SimulationContext` via ``set_service()``. + + Multiple :class:`~isaaclab_physx.sim.views.FabricFrameView` instances now share a + single hierarchy handle instead of each creating its own. The cache is automatically + closed on ``SimulationContext.clear_instance()``. diff --git a/source/isaaclab_physx/isaaclab_physx/sim/fabric_stage_cache.py b/source/isaaclab_physx/isaaclab_physx/sim/fabric_stage_cache.py new file mode 100644 index 000000000000..22386b858b5c --- /dev/null +++ b/source/isaaclab_physx/isaaclab_physx/sim/fabric_stage_cache.py @@ -0,0 +1,68 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Fabric stage and hierarchy cache, registered as a service on SimulationContext.""" + +from __future__ import annotations + +from pxr import UsdUtils + + +class FabricStageCache: + """Caches the usdrt stage attachment and IFabricHierarchy handles. + + Registered as a singleton service on :class:`~isaaclab.sim.SimulationContext` via + ``set_service(FabricStageCache, ...)``. Multiple + :class:`~isaaclab_physx.sim.views.FabricFrameView` instances share a single + hierarchy handle per Fabric attachment. + + The hierarchy cache is keyed by ``fabric_id_int`` (the stable ``.id`` integer from + ``FabricId``). Currently Isaac Lab always has exactly one Fabric attachment per + stage, so this dict will hold at most one entry. A dict is used rather than a plain + attribute so the design naturally extends to multi-Fabric scenarios (e.g. multi-GPU + support, where each GPU gets its own Fabric attachment) without an API change. + """ + + def __init__(self, usd_stage) -> None: + import usdrt # noqa: PLC0415 + + stage_id = UsdUtils.StageCache.Get().GetId(usd_stage).ToLongInt() + self._stage = usdrt.Usd.Stage.Attach(stage_id) + self._stage.SynchronizeToFabric() + self._hierarchy_cache: dict[int, object] = {} + + @property + def stage(self): + """The usdrt stage (already attached and synchronized).""" + return self._stage + + def close(self) -> None: + """Release cached handles. Called by SimulationContext on teardown.""" + self._hierarchy_cache.clear() + self._stage = None + + def get_hierarchy(self): + """Return the IFabricHierarchy handle for the current Fabric attachment. + + Creates and caches the handle on first call. Change-tracking is enabled + for both local and world xforms. + + Returns: + A tuple of ``(hierarchy_handle, fabric_id_int)``. + """ + import usdrt # noqa: PLC0415 + + fabric_id = self._stage.GetFabricId() + fabric_id_int = fabric_id.id + + if fabric_id_int not in self._hierarchy_cache: + hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy( + fabric_id, self._stage.GetStageIdAsStageId() + ) + hierarchy.track_local_xform_changes(True) + hierarchy.track_world_xform_changes(True) + self._hierarchy_cache[fabric_id_int] = hierarchy + + return self._hierarchy_cache[fabric_id_int], fabric_id_int 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 ee329c7976e6..dcb067bd6ab4 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 @@ -14,7 +14,6 @@ from pxr import Usd -import isaaclab.sim as sim_utils from isaaclab.app.settings_manager import SettingsManager from isaaclab.sim.views.base_frame_view import BaseFrameView from isaaclab.sim.views.usd_frame_view import UsdFrameView @@ -366,8 +365,21 @@ def _initialize_fabric(self) -> None: import usdrt # noqa: PLC0415 from usdrt import Rt # noqa: PLC0415 - stage_id = sim_utils.get_current_stage_id() - fabric_stage = usdrt.Usd.Stage.Attach(stage_id) + from isaaclab.sim import SimulationContext # noqa: PLC0415 + + from isaaclab_physx.sim.fabric_stage_cache import FabricStageCache # noqa: PLC0415 + + sim_context = SimulationContext.instance() + if sim_context is None: + raise RuntimeError("SimulationContext must be initialized before FabricFrameView.") + + # Get or create the FabricStageCache service. + cache = sim_context.services[FabricStageCache] + if cache is None: + cache = FabricStageCache(sim_context.stage) + sim_context.services[FabricStageCache] = cache + + fabric_stage = cache.stage for i in range(self.count): rt_prim = fabric_stage.GetPrimAtPath(self.prim_paths[i]) @@ -386,9 +398,7 @@ def _initialize_fabric(self) -> None: rt_prim.CreateAttribute(self._view_index_attr, usdrt.Sdf.ValueTypeNames.UInt, custom=True) rt_prim.GetAttribute(self._view_index_attr).Set(i) - self._fabric_hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy( - fabric_stage.GetFabricId(), fabric_stage.GetStageIdAsStageId() - ) + self._fabric_hierarchy, _ = cache.get_hierarchy() self._fabric_hierarchy.update_world_xforms() self._default_view_indices = wp.zeros((self.count,), dtype=wp.uint32, device=self._device) diff --git a/source/isaaclab_physx/test/sim/test_fabric_stage_cache.py b/source/isaaclab_physx/test/sim/test_fabric_stage_cache.py new file mode 100644 index 000000000000..f9acc87aa9d7 --- /dev/null +++ b/source/isaaclab_physx/test/sim/test_fabric_stage_cache.py @@ -0,0 +1,63 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Tests for FabricStageCache lifecycle.""" + +from isaaclab.app import AppLauncher + +simulation_app = AppLauncher(headless=True).app + +import pytest # noqa: E402 +from isaaclab_physx.sim.fabric_stage_cache import FabricStageCache # noqa: E402 + +import isaaclab.sim as sim_utils # noqa: E402 +from isaaclab.sim import SimulationContext # noqa: E402 + +pytestmark = pytest.mark.isaacsim_ci + + +@pytest.fixture(autouse=True) +def setup_teardown(): + """Create and clear stage for each test.""" + sim_utils.create_new_stage() + sim_utils.update_stage() + yield + if SimulationContext.instance() is not None: + sim_utils.clear_stage() + SimulationContext.clear_instance() + + +@pytest.fixture() +def cache(): + """Provide a FabricStageCache attached to the simulation stage.""" + sim = SimulationContext() + return FabricStageCache(sim.stage) + + +def test_stage_attached(cache): + """The cached usdrt stage is not None after construction.""" + assert cache.stage is not None + + +def test_get_hierarchy_returns_handle(cache): + """get_hierarchy returns a hierarchy handle and fabric id.""" + hierarchy, fabric_id_int = cache.get_hierarchy() + assert hierarchy is not None + assert isinstance(fabric_id_int, int) + + +def test_get_hierarchy_is_cached(cache): + """Repeated calls return the same hierarchy handle.""" + h1, id1 = cache.get_hierarchy() + h2, id2 = cache.get_hierarchy() + assert h1 is h2 + assert id1 == id2 + + +def test_close_clears_state(cache): + """close() clears internal caches.""" + cache.get_hierarchy() # populate cache + cache.close() + assert cache.stage is None