Skip to content

Commit 4c262b9

Browse files
committed
feat: add FabricStageCache service for shared hierarchy handles
Introduces FabricStageCache — a lightweight cache for the usdrt stage attachment and IFabricHierarchy handles, registered as a service on SimulationContext via the service locator (set_service/get_service). Multiple FabricFrameView instances now share a single hierarchy handle instead of each creating its own. The cache is automatically closed on SimulationContext.clear_instance(). Also replaces the assert device check with a proper RuntimeError and removes the now-unused isaaclab.sim import from fabric_frame_view.py.
1 parent 29db576 commit 4c262b9

4 files changed

Lines changed: 195 additions & 6 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Added
2+
^^^^^
3+
4+
* Added :class:`~isaaclab_physx.sim.FabricStageCache` — a lightweight cache for the
5+
``usdrt.Usd.Stage`` attachment and ``IFabricHierarchy`` handles, registered as a
6+
service on :class:`~isaaclab.sim.SimulationContext` via ``set_service()``.
7+
8+
Multiple :class:`~isaaclab_physx.sim.views.FabricFrameView` instances now share a
9+
single hierarchy handle instead of each creating its own. The cache is automatically
10+
closed on ``SimulationContext.clear_instance()``.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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+
"""Fabric stage and hierarchy cache, registered as a service on SimulationContext."""
7+
8+
from __future__ import annotations
9+
10+
from pxr import UsdUtils
11+
12+
13+
class FabricStageCache:
14+
"""Caches the usdrt stage attachment and IFabricHierarchy handles.
15+
16+
Registered as a singleton service on :class:`~isaaclab.sim.SimulationContext` via
17+
``set_service(FabricStageCache, ...)``. Multiple
18+
:class:`~isaaclab_physx.sim.views.FabricFrameView` instances share a single
19+
hierarchy handle per Fabric attachment.
20+
21+
The hierarchy cache is keyed by ``fabric_id_int`` (the stable ``.id`` integer from
22+
``FabricId``). Currently Isaac Lab always has exactly one Fabric attachment per
23+
stage, so this dict will hold at most one entry. A dict is used rather than a plain
24+
attribute so the design naturally extends to multi-Fabric scenarios (e.g. multi-GPU
25+
support, where each GPU gets its own Fabric attachment) without an API change.
26+
"""
27+
28+
def __init__(self, usd_stage) -> None:
29+
import usdrt # noqa: PLC0415
30+
31+
stage_id = UsdUtils.StageCache.Get().GetId(usd_stage).ToLongInt()
32+
self._stage = usdrt.Usd.Stage.Attach(stage_id)
33+
self._stage.SynchronizeToFabric()
34+
self._hierarchy_cache: dict[int, object] = {}
35+
36+
@property
37+
def stage(self):
38+
"""The usdrt stage (already attached and synchronized)."""
39+
return self._stage
40+
41+
def close(self) -> None:
42+
"""Release cached handles. Called by SimulationContext on teardown."""
43+
self._hierarchy_cache.clear()
44+
self._stage = None
45+
46+
def get_hierarchy(self):
47+
"""Return the IFabricHierarchy handle for the current Fabric attachment.
48+
49+
Creates and caches the handle on first call. Change-tracking is enabled
50+
for both local and world xforms.
51+
52+
Returns:
53+
A tuple of ``(hierarchy_handle, fabric_id_int)``.
54+
"""
55+
import usdrt # noqa: PLC0415
56+
57+
fabric_id = self._stage.GetFabricId()
58+
fabric_id_int = fabric_id.id
59+
60+
if fabric_id_int not in self._hierarchy_cache:
61+
hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy(
62+
fabric_id, self._stage.GetStageIdAsStageId()
63+
)
64+
hierarchy.track_local_xform_changes(True)
65+
hierarchy.track_world_xform_changes(True)
66+
self._hierarchy_cache[fabric_id_int] = hierarchy
67+
68+
return self._hierarchy_cache[fabric_id_int], fabric_id_int

source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414

1515
from pxr import Usd
1616

17-
import isaaclab.sim as sim_utils
1817
from isaaclab.app.settings_manager import SettingsManager
1918
from isaaclab.sim.views.base_frame_view import BaseFrameView
2019
from isaaclab.sim.views.usd_frame_view import UsdFrameView
@@ -366,8 +365,21 @@ def _initialize_fabric(self) -> None:
366365
import usdrt # noqa: PLC0415
367366
from usdrt import Rt # noqa: PLC0415
368367

369-
stage_id = sim_utils.get_current_stage_id()
370-
fabric_stage = usdrt.Usd.Stage.Attach(stage_id)
368+
from isaaclab.sim import SimulationContext # noqa: PLC0415
369+
370+
from isaaclab_physx.sim.fabric_stage_cache import FabricStageCache # noqa: PLC0415
371+
372+
sim_context = SimulationContext.instance()
373+
if sim_context is None:
374+
raise RuntimeError("SimulationContext must be initialized before FabricFrameView.")
375+
376+
# Get or create the FabricStageCache service.
377+
cache = sim_context.services[FabricStageCache]
378+
if cache is None:
379+
cache = FabricStageCache(sim_context.stage)
380+
sim_context.services[FabricStageCache] = cache
381+
382+
fabric_stage = cache.stage
371383

372384
for i in range(self.count):
373385
rt_prim = fabric_stage.GetPrimAtPath(self.prim_paths[i])
@@ -386,9 +398,7 @@ def _initialize_fabric(self) -> None:
386398
rt_prim.CreateAttribute(self._view_index_attr, usdrt.Sdf.ValueTypeNames.UInt, custom=True)
387399
rt_prim.GetAttribute(self._view_index_attr).Set(i)
388400

389-
self._fabric_hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy(
390-
fabric_stage.GetFabricId(), fabric_stage.GetStageIdAsStageId()
391-
)
401+
self._fabric_hierarchy, _ = cache.get_hierarchy()
392402
self._fabric_hierarchy.update_world_xforms()
393403

394404
self._default_view_indices = wp.zeros((self.count,), dtype=wp.uint32, device=self._device)
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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+
"""Tests for FabricStageCache service lifecycle."""
7+
8+
from isaaclab.app import AppLauncher
9+
10+
simulation_app = AppLauncher(headless=True).app
11+
12+
import pytest # noqa: E402
13+
from isaaclab_physx.sim.fabric_stage_cache import FabricStageCache # noqa: E402
14+
15+
import isaaclab.sim as sim_utils # noqa: E402
16+
from isaaclab.sim import SimulationContext # noqa: E402
17+
18+
pytestmark = pytest.mark.isaacsim_ci
19+
20+
21+
@pytest.fixture(autouse=True)
22+
def setup_teardown():
23+
"""Create and clear stage for each test."""
24+
sim_utils.create_new_stage()
25+
sim_utils.update_stage()
26+
yield
27+
if SimulationContext.instance() is not None:
28+
sim_utils.clear_stage()
29+
SimulationContext.clear_instance()
30+
31+
32+
class TestFabricStageCacheService:
33+
"""Test that FabricStageCache integrates with SimulationContext's service locator."""
34+
35+
def test_register_and_retrieve(self):
36+
"""Service can be registered and retrieved via services[]."""
37+
sim_context = SimulationContext()
38+
cache = FabricStageCache(sim_context.stage)
39+
sim_context.services[FabricStageCache] = cache
40+
41+
retrieved = sim_context.services[FabricStageCache]
42+
assert retrieved is cache
43+
44+
def test_stage_attached(self):
45+
"""The cached usdrt stage is not None after construction."""
46+
sim_context = SimulationContext()
47+
cache = FabricStageCache(sim_context.stage)
48+
assert cache.stage is not None
49+
50+
def test_get_hierarchy_returns_handle(self):
51+
"""get_hierarchy returns a hierarchy handle and fabric id."""
52+
sim_context = SimulationContext()
53+
cache = FabricStageCache(sim_context.stage)
54+
55+
hierarchy, fabric_id_int = cache.get_hierarchy()
56+
assert hierarchy is not None
57+
assert isinstance(fabric_id_int, int)
58+
59+
def test_get_hierarchy_is_cached(self):
60+
"""Repeated calls return the same hierarchy handle."""
61+
sim_context = SimulationContext()
62+
cache = FabricStageCache(sim_context.stage)
63+
64+
h1, id1 = cache.get_hierarchy()
65+
h2, id2 = cache.get_hierarchy()
66+
assert h1 is h2
67+
assert id1 == id2
68+
69+
def test_close_clears_state(self):
70+
"""close() clears internal caches."""
71+
sim_context = SimulationContext()
72+
cache = FabricStageCache(sim_context.stage)
73+
cache.get_hierarchy() # populate cache
74+
cache.close()
75+
assert cache.stage is None
76+
77+
def test_clear_instance_closes_service(self):
78+
"""SimulationContext.clear_instance() calls close() on registered services."""
79+
sim_context = SimulationContext()
80+
cache = FabricStageCache(sim_context.stage)
81+
sim_context.services[FabricStageCache] = cache
82+
83+
SimulationContext.clear_instance()
84+
85+
# After clear_instance, the cache should have been closed
86+
assert cache.stage is None
87+
88+
def test_replacement_caller_closes_old(self):
89+
"""Caller is responsible for closing old service before replacing."""
90+
sim_context = SimulationContext()
91+
cache1 = FabricStageCache(sim_context.stage)
92+
sim_context.services[FabricStageCache] = cache1
93+
94+
cache1.close()
95+
cache2 = FabricStageCache(sim_context.stage)
96+
sim_context.services[FabricStageCache] = cache2
97+
98+
# Old cache was closed by caller
99+
assert cache1.stage is None
100+
# New cache should be active
101+
assert cache2.stage is not None

0 commit comments

Comments
 (0)