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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions source/isaaclab_physx/changelog.d/fabric-stage-cache.rst
Original file line number Diff line number Diff line change
@@ -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()``.
68 changes: 68 additions & 0 deletions source/isaaclab_physx/isaaclab_physx/sim/fabric_stage_cache.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand All @@ -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)
Expand Down
63 changes: 63 additions & 0 deletions source/isaaclab_physx/test/sim/test_fabric_stage_cache.py
Original file line number Diff line number Diff line change
@@ -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
Loading