Source code for isaaclab_physx.sim.views.fabric_frame_view

# 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

"""PhysX FrameView with Fabric GPU acceleration."""

from __future__ import annotations

import contextlib
import itertools
import logging
import sys

import torch
import warp as wp

from pxr import Gf, Usd, UsdGeom

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
from isaaclab.sim.views.xform_space_writer import FrameViewLocalSpaceWriter, FrameViewWorldSpaceWriter
from isaaclab.utils.warp import ProxyArray
from isaaclab.utils.warp import fabric as fabric_utils

logger = logging.getLogger(__name__)


def _parent_path(prim_path: str) -> str:
    """Parent prim path of ``prim_path``.

    Args:
        prim_path: Absolute prim path, so it always contains a separator.

    Raises:
        RuntimeError: If the prim is directly under the stage root and thus has
            no non-pseudoroot parent to read Fabric matrices from.
    """
    parent = prim_path[: prim_path.rfind("/")]
    if not parent:
        raise RuntimeError(
            f"Child prim '{prim_path}' is at the stage root and has no parent prim. "
            "FabricFrameView requires every prim to have a non-pseudoroot parent "
            "with Fabric world+local matrices."
        )
    return parent


def _to_float32_2d(a: wp.array | torch.Tensor) -> wp.array | torch.Tensor:
    """Ensure array is compatible with Fabric kernels (2-D float32).

    For ``wp.array`` with vec dtypes (``vec3f``, ``vec4f``), uses
    :meth:`wp.array.view` for zero-copy reinterpretation.
    ``torch.Tensor`` and already-correct 2-D float32 arrays pass through.
    """
    if not isinstance(a, wp.array):
        return a
    if a.shape[0] == 0:
        return a
    if a.ndim == 2 and a.dtype == wp.float32:
        return a
    return a.view(dtype=wp.float32)


[docs] class FabricFrameView(BaseFrameView): """FrameView with Fabric GPU acceleration for the PhysX backend. Uses composition: holds a :class:`UsdFrameView` internally for USD fallback and non-accelerated operations (visibility, and all pose/scale operations when Fabric is disabled). When Fabric is enabled, world-pose, local-pose, and scale operations run on the GPU via Warp kernels that read and write ``omni:fabric:worldMatrix`` and ``omni:fabric:localMatrix`` directly. All other operations delegate to the internal USD view. All writes go through the writer-scope API (:meth:`xform_world_space_writer` / :meth:`xform_local_space_writer`, recommended) or the convenience :meth:`set_world_poses` / :meth:`set_local_poses` / etc. helpers inherited from :class:`BaseFrameView`. Behavior (Fabric path): * **Leaf-prim assumption.** This view manages a flat set of sibling prims (e.g. all cameras under ``/World/Env_*/Camera``). It does NOT propagate transforms to child prims. If a managed prim has children whose world matrices depend on the parent, those children must be updated via a separate view, a physics step, or ``IFabricHierarchy.update_world_xforms``. * **No write-back to USD.** Fabric writes update only ``omni:fabric:worldMatrix`` / ``omni:fabric:localMatrix``; the prim's USD ``xformOp:*`` attributes are unchanged. Downstream consumers that read the prim's USD attributes after a Fabric write will see stale values until the next USD-side sync. * **Eager dual-write inside a writer scope (no dirty tracking).** When a writer scope is open, all writes go to the primary attribute (``worldMatrix`` for the world writer, ``localMatrix`` for the local writer). On scope exit, a single Warp kernel derives the opposite attribute and a single ``wp.synchronize()`` runs. After the scope exits, both Fabric matrices are self-consistent. Getters launch their own decompose kernel and ``wp.synchronize()`` before returning, so a returned :class:`ProxyArray` is always immediately readable from either GPU or host code (no caller-side sync required). The opposite-space derive runs even when the scope unwinds via exception (including ``KeyboardInterrupt`` in interactive notebooks), as a best-effort to keep ``worldMatrix`` and ``localMatrix`` mutually consistent on whatever partial-write state Fabric holds. The partial write itself is not rolled back -- if you need transactional all-or-nothing semantics, snapshot the matrices yourself before entering the scope. * **Fabric Hierarchy listeners are paused while a writer scope is active when the hierarchy bindings are available.** On enter, the writer calls :meth:`IFabricHierarchy.track_local_xform_changes(False)` / :meth:`track_world_xform_changes(False)` (saving the prior state). Fabric itself is just a flat attribute store; the plugin that keeps ``omni:fabric:worldMatrix`` and ``omni:fabric:localMatrix`` mutually consistent across the prim hierarchy is :class:`usdrt.hierarchy.IFabricHierarchy` (a.k.a. Fabric Hierarchy). Its change tracking is pull-based: a per-attribute listener records writes into a private changelog, and the plugin drains and processes that changelog on the next call to ``IFabricHierarchy::update_world_xforms()`` (typically from the render path). "Tracking off" just stops the listener from recording new entries -- writes still land in Fabric storage; they are simply invisible to the next ``update_world_xforms()`` call. That is exactly what we want. Inside the scope we write one space (world or local) and, at scope exit, derive the other in a single batched kernel so both matrices are mutually consistent. If tracking were left on, our writes would be queued in the changelog and the next ``update_world_xforms()`` tick would process them -- choosing a canonical direction (e.g. "user authored local, recompute world from it") and potentially overwriting one half of our just-consistent pair. With tracking paused for the duration of the scope, the changelog stays empty for these prims and the next tick is a no-op for them. ``__exit__`` restores the prior tracking state (so we do not re-enable listeners the caller had previously paused). The Fabric Scene Delegate (FSD) reads ``omni:fabric:worldMatrix`` directly from Fabric storage on the render path; it observes our final writes unchanged. Headless experiences without FSD do not expose the hierarchy Python bindings and do not run hierarchy update ticks. In that case the writer skips listener pause/restore while retaining the same direct Fabric matrix writes. Note: the scope is synchronous Python code, so no simulation step and no render tick can run while it is open -- callers must not advance the simulation from inside the scope (see :mod:`isaaclab.sim.views.xform_space_writer` for the full contract). The "torn data" concern is what motivates that no-step rule; it is separate from why the tracking pause exists. * **Selections are scoped to the view, not the stage.** The view tags its own prims (and their parents) with private per-view index attributes and requires those attributes in every prim selection, so a selection resolves to exactly the prims the view manages however large the stage grows. Tag names are unique per view instance, so views never interfere with one another. The tags are authored on first use and removed again by :meth:`close` -- or, best-effort and with a warning, when the view is garbage collected. Call :meth:`close` when done with a view; collection timing is up to the interpreter, so relying on it can remove the tags at an arbitrary point in the frame (or, on a leaked reference, not at all). * **Topology changes are absorbed, with no cache to invalidate.** The view-to-Fabric mapping is re-derived from live Fabric data on every access, so prims moving between Fabric buckets can never leave a stale mapping behind. If a managed prim disappears (prim or attribute removed) the next access raises :class:`RuntimeError` and the view must be recreated. See ``_refresh_child_selection`` for how this is done. Pose getters return :class:`~isaaclab.utils.warp.ProxyArray`; the convenience :meth:`set_world_poses` / :meth:`set_local_poses` helpers accept :class:`wp.array`. Inside a writer scope, the writer's :meth:`~FrameViewSpaceWriterBase.set_poses` / :meth:`~FrameViewSpaceWriterBase.set_scales` accept :class:`wp.array`. """ _WORLD_MATRIX_NAME = "omni:fabric:worldMatrix" _LOCAL_MATRIX_NAME = "omni:fabric:localMatrix" # Process-wide uid source for per-view Fabric attribute names. A monotonic # counter (NOT ``id(self)``/``hash(self)``) guarantees a name is never # reused after a view is garbage-collected, so a dead view's leftover # attributes can never satisfy a live view's selection. _view_uid_counter = itertools.count()
[docs] def __init__( self, prim_path: str, device: str = "cpu", validate_xform_ops: bool = True, stage: Usd.Stage | None = None, **kwargs, ): """Initialize the view. Args: prim_path: USD prim-path pattern to match. device: Device for Warp arrays. Either ``"cpu"`` or any CUDA device string (``"cuda:0"``, ``"cuda:1"``, ...); Fabric acceleration is supported on every CUDA index. 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 settings = SettingsManager.instance() self._use_fabric = bool(settings.get("/physics/fabricEnabled", False)) # TODO(pv): Misleading abstraction -- FabricFrameView can fall back to USD internally; # the concrete class should be determined by the factory instead. (PR #5673 pv/fabric-view-no-fallback) self._fabric_initialized = False self._stage = None self._fabric_hierarchy = None # Per-view Fabric index attributes (authored once in ``_initialize_fabric``). self._child_index_attr: str | None = None self._parent_index_attr: str | None = None self._unique_parent_paths: list[str] = [] # Three persistent selections keyed on the index attributes: child RO # (steady state), child RW (active inside a writer scope; ``_is_rw`` # flips between them), and parent world (always read-only). self._sel_ro = None self._sel_rw = None self._sel_parent = None self._is_rw: bool = False # View-side indices array. self._view_indices: wp.array | None = None # Kernel-built view->fabric slot mappings, refreshed on every selection # access (see ``_refresh_child_selection``). ``_child_parent_map`` holds # view-side indices (uint32, like the Fabric ``UInt`` index attributes); # the ``*_slots_buf`` buffers hold Fabric slots and must be int32, the # only dtype ``wp.indexedfabricarray`` accepts for indices. self._child_parent_map: wp.array | None = None self._child_slots_buf: wp.array | None = None self._parent_slots_buf: wp.array | None = None self._parent_slot_of_child_buf: wp.array | None = None # Sentinel passed to compose/decompose kernels for unused slots. self._fabric_empty_2d_array_sentinel: wp.array | None = None # Index-attribute cleanup state (see ``close``): the ``(attribute, # prims)`` groups authored by ``_initialize_fabric``, and the flag that # makes ``close()`` idempotent and lets ``__del__`` warn when cleanup # had to happen via garbage collection. self._tagged_prims: list[tuple[str, list]] = [] self._is_closed: bool = False
def close(self) -> None: """Remove this view's Fabric index attributes. The view must not be used afterwards. Calling :meth:`close` again is a no-op. If :meth:`close` is never called, the same cleanup runs best-effort from ``__del__`` (with a warning, since collection timing is up to the interpreter) -- except at interpreter exit, where Fabric is being torn down anyway and the attributes die with it. """ if self._is_closed: return self._is_closed = True failed = total = 0 for attr, prims in self._tagged_prims: total += len(prims) for prim in prims: try: prim.RemoveProperty(attr) except Exception: # noqa: BLE001 -- one bad handle must not strand the remaining tags failed += 1 self._tagged_prims = [] if failed: logger.debug("FabricFrameView(%s): %d of %d tag removals failed", self._usd_view._prim_path, failed, total) def __del__(self, _sys=sys): """Best-effort cleanup when the view is collected without :meth:`close`. Follows the repo's shutdown-safe ``__del__`` idiom (see :meth:`~isaaclab.envs.ManagerBasedEnv.__del__`): ``sys`` is bound as a default argument so it survives module teardown, and nothing runs during interpreter finalization, when calling into Kit can crash and the attributes die with Fabric anyway. """ # getattr: __init__ may have raised before the flag existed if getattr(self, "_is_closed", True) or _sys.is_finalizing() or _sys.meta_path is None: return if self._tagged_prims: logger.warning( "FabricFrameView(%s) was garbage-collected without close(); its Fabric index " "attributes were removed best-effort at an arbitrary point in the frame. Call " "close() for deterministic cleanup.", self._usd_view._prim_path, ) with contextlib.suppress(Exception): # never propagate from __del__ self.close() # ------------------------------------------------------------------ # Delegated properties # ------------------------------------------------------------------ @property def count(self) -> int: return self._usd_view.count @property def device(self) -> str: """Device where arrays are allocated (cpu or cuda).""" return self._device @property def prims(self) -> list: return self._usd_view.prims @property def prim_paths(self) -> list[str]: return self._usd_view.prim_paths # ------------------------------------------------------------------ # Delegated operations (USD-only) # ------------------------------------------------------------------ def get_visibility(self, indices=None): return self._usd_view.get_visibility(indices) def set_visibility(self, visibility, indices=None): self._usd_view.set_visibility(visibility, indices) # ------------------------------------------------------------------ # Writer factory hooks # ------------------------------------------------------------------ def _make_world_space_writer(self) -> FrameViewWorldSpaceWriter: if not self._use_fabric: return _FabricFallbackWorldWriter(self) return _FabricWorldSpaceWriter(self) def _make_local_space_writer(self) -> FrameViewLocalSpaceWriter: if not self._use_fabric: return _FabricFallbackLocalWriter(self) return _FabricLocalSpaceWriter(self) # ------------------------------------------------------------------ # Getter hooks -- read directly from Fabric (no lazy sync) # ------------------------------------------------------------------ def _get_world_poses_impl(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: if not self._use_fabric: return self._usd_view._get_world_poses_impl(indices) if not self._fabric_initialized: self._initialize_fabric() indices_wp = self._resolve_indices_wp(indices) count = indices_wp.shape[0] use_cached = indices is None or indices == slice(None) if use_cached: positions_wp = self._fabric_positions_buf orientations_wp = self._fabric_orientations_buf else: positions_wp = wp.zeros((count, 3), dtype=wp.float32, device=self._device) orientations_wp = wp.zeros((count, 4), dtype=wp.float32, device=self._device) wp.launch( kernel=fabric_utils.decompose_indexed_fabric_transforms, dim=count, inputs=[ self._get_world_ifa(), positions_wp, orientations_wp, self._fabric_empty_2d_array_sentinel, indices_wp, ], device=self._device, ) # Sync before returning regardless of caching path: the cached buffers # are reused (an in-flight kernel from a prior call must finish before # the new write is observable) and the fresh-allocation path must also # complete before the caller can read the returned ProxyArray via any # host-visible accessor without observing zeros. wp.synchronize() if use_cached: return self._fabric_positions_ta, self._fabric_orientations_ta return ProxyArray(positions_wp), ProxyArray(orientations_wp) def _get_local_poses_impl(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: if not self._use_fabric: return self._usd_view._get_local_poses_impl(indices) if not self._fabric_initialized: self._initialize_fabric() indices_wp = self._resolve_indices_wp(indices) count = indices_wp.shape[0] use_cached = indices is None or indices == slice(None) if use_cached: translations_wp = self._fabric_local_translations_buf orientations_wp = self._fabric_local_orientations_buf else: translations_wp = wp.zeros((count, 3), dtype=wp.float32, device=self._device) orientations_wp = wp.zeros((count, 4), dtype=wp.float32, device=self._device) wp.launch( kernel=fabric_utils.decompose_indexed_fabric_transforms, dim=count, inputs=[ self._get_local_ifa(), translations_wp, orientations_wp, self._fabric_empty_2d_array_sentinel, indices_wp, ], device=self._device, ) # See note in _get_world_poses_impl: sync regardless of caching path. wp.synchronize() if use_cached: return self._fabric_local_translations_ta, self._fabric_local_orientations_ta return ProxyArray(translations_wp), ProxyArray(orientations_wp) def _get_world_scales_impl(self, indices=None) -> ProxyArray: if not self._use_fabric: return self._usd_view._get_world_scales_impl(indices) if not self._fabric_initialized: self._initialize_fabric() return self._decompose_scales(self._get_world_ifa(), indices) def _get_local_scales_impl(self, indices=None) -> ProxyArray: if not self._use_fabric: return self._usd_view._get_local_scales_impl(indices) if not self._fabric_initialized: self._initialize_fabric() return self._decompose_scales(self._get_local_ifa(), indices) def _decompose_scales(self, ro_array, indices) -> ProxyArray: """Shared scale-decompose path for world / local getters.""" indices_wp = self._resolve_indices_wp(indices) count = indices_wp.shape[0] use_cached = indices is None or indices == slice(None) if use_cached: scales_wp = self._fabric_scales_buf else: scales_wp = wp.zeros((count, 3), dtype=wp.float32, device=self._device) wp.launch( kernel=fabric_utils.decompose_indexed_fabric_transforms, dim=count, inputs=[ ro_array, self._fabric_empty_2d_array_sentinel, self._fabric_empty_2d_array_sentinel, scales_wp, indices_wp, ], device=self._device, ) # See note in _get_world_poses_impl: sync regardless of caching path. wp.synchronize() if use_cached: return self._fabric_scales_ta return ProxyArray(scales_wp) # ------------------------------------------------------------------ # Deprecated get_scales / set_scales hooks # ------------------------------------------------------------------ def _get_scales_impl(self, indices=None) -> ProxyArray: """Fabric: get_scales returns world-space scales (legacy behavior).""" return self._get_world_scales_impl(indices) def _set_scales_impl(self, scales, indices=None) -> None: """Fabric: set_scales writes world-space scales via a one-shot writer scope.""" with self.xform_world_space_writer() as writer: writer.set_scales(scales, indices) # ------------------------------------------------------------------ # Internal -- helpers shared by writers + initialization # ------------------------------------------------------------------ def _to_float32_2d_or_empty(self, data): return self._fabric_empty_2d_array_sentinel if data is None else _to_float32_2d(data) def _recompute_local_from_world_all(self) -> None: """Derive ``localMatrix = inv(parent) * worldMatrix`` for every prim in the view. Called from :class:`_FabricWorldSpaceWriter` ``__exit__`` to keep the (world, local) pair self-consistent after a world-space write. Storage convention: see :func:`isaaclab.utils.warp.fabric.update_indexed_local_matrix_from_world`. """ world_ifa, local_ifa = self._get_child_ifas() wp.launch( kernel=fabric_utils.update_indexed_local_matrix_from_world, dim=self.count, inputs=[ world_ifa, self._get_parent_world_ifa(), local_ifa, self._view_indices, ], device=self._device, ) def _recompute_world_from_local_all(self) -> None: """Derive ``worldMatrix = parent * localMatrix`` for every prim in the view. Called from :class:`_FabricLocalSpaceWriter` ``__exit__`` and from :meth:`_sync_fabric_from_usd_initial` after seeding local matrices. Storage convention: see :func:`isaaclab.utils.warp.fabric.update_indexed_world_matrix_from_local`. """ world_ifa, local_ifa = self._get_child_ifas() wp.launch( kernel=fabric_utils.update_indexed_world_matrix_from_local, dim=self.count, inputs=[ local_ifa, self._get_parent_world_ifa(), world_ifa, self._view_indices, ], device=self._device, ) # ------------------------------------------------------------------ # Internal -- selection accessors (kernel-built slot mappings) # ------------------------------------------------------------------ def _get_world_ifa(self) -> wp.indexedfabricarray: sel = self._refresh_child_selection() return wp.indexedfabricarray(fa=wp.fabricarray(sel, self._WORLD_MATRIX_NAME), indices=self._child_slots_buf) def _get_local_ifa(self) -> wp.indexedfabricarray: sel = self._refresh_child_selection() return wp.indexedfabricarray(fa=wp.fabricarray(sel, self._LOCAL_MATRIX_NAME), indices=self._child_slots_buf) def _get_child_ifas(self) -> tuple[wp.indexedfabricarray, wp.indexedfabricarray]: """Return ``(world, local)`` child arrays from a single selection refresh. Callers that need both spaces must use this instead of calling :meth:`_get_world_ifa` and :meth:`_get_local_ifa`, which would refresh the same selection -- and re-run its mapping kernel -- twice. """ sel = self._refresh_child_selection() return ( wp.indexedfabricarray(fa=wp.fabricarray(sel, self._WORLD_MATRIX_NAME), indices=self._child_slots_buf), wp.indexedfabricarray(fa=wp.fabricarray(sel, self._LOCAL_MATRIX_NAME), indices=self._child_slots_buf), ) def _get_parent_world_ifa(self) -> wp.indexedfabricarray: self._refresh_parent_selection() return wp.indexedfabricarray( fa=wp.fabricarray(self._sel_parent, self._WORLD_MATRIX_NAME), indices=self._parent_slot_of_child_buf, ) def _refresh_child_selection(self): """Refresh the active child selection and rebuild its slot mapping on device. Runs on every accessor call. ``PrepareForReuse`` lets the persistent selection absorb Fabric bucket changes (and notifies the renderer for the RW selection); a single Warp kernel launch over the selection's index attribute then rebuilds ``_child_slots_buf`` so that entry ``i`` is the fabric-side slot of view prim ``i``. Re-deriving the mapping from live Fabric data on each access means bucket reorders can never leave a stale mapping behind, with no host-side path resolution and no cache to invalidate. Returns: The active (RO or RW) child prim selection. """ sel = self._sel_rw if self._is_rw else self._sel_ro sel.PrepareForReuse() self._check_selection_count(sel.GetCount(), self.count, self._child_index_attr) wp.launch( kernel=fabric_utils.map_view_indices_to_fabric_slots, dim=self.count, inputs=[wp.fabricarray(sel, self._child_index_attr), self._child_slots_buf], device=self._device, ) return sel def _refresh_parent_selection(self) -> None: """Refresh the parent selection and rebuild the per-child parent-slot mapping. Two kernel launches: the first inverts the parent index attribute into per-ordinal fabric slots, the second gathers those slots per child through ``_child_parent_map`` (children sharing a parent read the same slot). """ num_parents = self._parent_slots_buf.shape[0] self._sel_parent.PrepareForReuse() self._check_selection_count(self._sel_parent.GetCount(), num_parents, self._parent_index_attr) wp.launch( kernel=fabric_utils.map_view_indices_to_fabric_slots, dim=num_parents, inputs=[wp.fabricarray(self._sel_parent, self._parent_index_attr), self._parent_slots_buf], device=self._device, ) wp.launch( kernel=fabric_utils.gather_fabric_slots, dim=self.count, inputs=[self._parent_slots_buf, self._child_parent_map, self._parent_slot_of_child_buf], device=self._device, ) def _check_selection_count(self, found: int, expected: int, index_attr: str) -> None: """Raise if a selection stopped matching exactly the view's tagged prims.""" if found != expected: raise RuntimeError( f"FabricFrameView: selection on '{index_attr}' matched {found} prims, expected {expected}. " "A prim managed by this view (or one of its Fabric matrix/index attributes) was removed " "from the Fabric stage; recreate the view." ) 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): if self._view_indices is None: raise RuntimeError("Fabric view indices are not initialized.") return self._view_indices if indices.dtype == wp.uint32: return indices if indices.dtype == wp.int32: # Zero-copy reinterpret: callers (e.g. Camera) pass non-negative int32 indices. # Device placement is not checked here; ``wp.launch`` validates it for every input. return indices.view(wp.uint32) return wp.array(indices.numpy().astype("uint32"), dtype=wp.uint32, device=self._device) # ------------------------------------------------------------------ # Internal -- Fabric initialization # ------------------------------------------------------------------ def _initialize_fabric(self) -> None: """One-time Fabric setup: hierarchy handle, per-view index tagging, selections, buffers.""" import usdrt # noqa: PLC0415 # The hierarchy bindings are a separate submodule and are not loaded by ``import usdrt``. from usdrt import Rt # noqa: PLC0415 try: from usdrt import hierarchy # noqa: PLC0415 except ImportError: hierarchy = None from isaaclab.sim.utils import get_current_stage_id # noqa: PLC0415 # Attach usdrt stage and create hierarchy handle. stage_id = get_current_stage_id() self._stage = usdrt.Usd.Stage.Attach(stage_id) fabric_id = self._stage.GetFabricId() self._fabric_id = fabric_id.id if hierarchy is not None: self._fabric_hierarchy = hierarchy.IFabricHierarchy().get_fabric_hierarchy( fabric_id, self._stage.GetStageIdAsStageId() ) # Per-view Fabric index attribute names (see ``_view_uid_counter``). uid = next(FabricFrameView._view_uid_counter) self._child_index_attr = f"isaaclab:fabricFrameView:{uid}:index" self._parent_index_attr = f"isaaclab:fabricFrameView:{uid}:parentIndex" # Per-child parent paths, computed once and reused for the ordinal map # below. Unique parents keep first-occurrence order; ``parent_ordinal`` # maps a parent path to its position in that order. child_parent_paths = [_parent_path(p) for p in self.prim_paths] self._unique_parent_paths = list(dict.fromkeys(child_parent_paths)) parent_ordinal = {path: i for i, path in enumerate(self._unique_parent_paths)} # Tag children and parents with their per-view index and ensure both # carry the Fabric world+local matrix attributes (``Create*Attr`` calls # are idempotent). The index attribute doubles as the selection filter: # the selections below match ONLY tagged prims, so their size is # O(view), not O(stage). A prim that is both a child and a parent of # this view receives both index attributes. tagged_prims: list[tuple[str, list]] = [] for paths, index_attr in ( (list(self.prim_paths), self._child_index_attr), (self._unique_parent_paths, self._parent_index_attr), ): group_prims: list = [] for i, path in enumerate(paths): rt_prim = self._stage.GetPrimAtPath(path) if not rt_prim.IsValid(): raise RuntimeError(f"FabricFrameView: prim '{path}' does not exist in the Fabric stage.") rt_xformable = Rt.Xformable(rt_prim) rt_xformable.CreateFabricHierarchyWorldMatrixAttr() rt_xformable.CreateFabricHierarchyLocalMatrixAttr() rt_xformable.SetLocalXformFromUsd() rt_xformable.SetWorldXformFromUsd() rt_prim.CreateAttribute(index_attr, usdrt.Sdf.ValueTypeNames.UInt, custom=True) rt_prim.GetAttribute(index_attr).Set(i) group_prims.append(rt_prim) tagged_prims.append((index_attr, group_prims)) # Remembered so ``close()`` / ``__del__`` can remove the tags again. self._tagged_prims = tagged_prims # Three persistent selections keyed on the per-view index attributes: # child RO (steady state), child RW (active only inside a writer # scope), and parent world (always read-only). matrix = usdrt.Sdf.ValueTypeNames.Matrix4d uint_type = usdrt.Sdf.ValueTypeNames.UInt ro = usdrt.Usd.Access.Read rw = usdrt.Usd.Access.ReadWrite child_tag = (uint_type, self._child_index_attr, ro) parent_tag = (uint_type, self._parent_index_attr, ro) wm_ro = (matrix, self._WORLD_MATRIX_NAME, ro) lm_ro = (matrix, self._LOCAL_MATRIX_NAME, ro) wm_rw = (matrix, self._WORLD_MATRIX_NAME, rw) lm_rw = (matrix, self._LOCAL_MATRIX_NAME, rw) self._sel_ro = self._stage.SelectPrims(require_attrs=[child_tag, wm_ro, lm_ro], device=self._device) self._sel_rw = self._stage.SelectPrims(require_attrs=[child_tag, wm_rw, lm_rw], device=self._device) self._sel_parent = self._stage.SelectPrims(require_attrs=[parent_tag, wm_ro], device=self._device) # View-side indices + kernel-built slot-mapping buffers. self._view_indices = wp.array(list(range(self.count)), dtype=wp.uint32, device=self._device) self._child_parent_map = wp.array( [parent_ordinal[p] for p in child_parent_paths], dtype=wp.uint32, device=self._device ) self._child_slots_buf = wp.empty((self.count,), dtype=wp.int32, device=self._device) self._parent_slots_buf = wp.empty((len(self._unique_parent_paths),), dtype=wp.int32, device=self._device) self._parent_slot_of_child_buf = wp.empty((self.count,), dtype=wp.int32, device=self._device) # Pre-allocated reusable output buffers (world + local + scales). self._fabric_positions_buf = wp.zeros((self.count, 3), dtype=wp.float32, device=self._device) self._fabric_orientations_buf = wp.zeros((self.count, 4), dtype=wp.float32, device=self._device) self._fabric_scales_buf = wp.zeros((self.count, 3), dtype=wp.float32, device=self._device) self._fabric_local_translations_buf = wp.zeros((self.count, 3), dtype=wp.float32, device=self._device) self._fabric_local_orientations_buf = wp.zeros((self.count, 4), dtype=wp.float32, device=self._device) self._fabric_empty_2d_array_sentinel = wp.zeros((0, 0), dtype=wp.float32, device=self._device) self._fabric_positions_ta = ProxyArray(self._fabric_positions_buf) self._fabric_orientations_ta = ProxyArray(self._fabric_orientations_buf) self._fabric_scales_ta = ProxyArray(self._fabric_scales_buf) self._fabric_local_translations_ta = ProxyArray(self._fabric_local_translations_buf) self._fabric_local_orientations_ta = ProxyArray(self._fabric_local_orientations_buf) self._fabric_initialized = True # Seed Fabric matrices from USD authoritatively. The seed writes, so # flip onto the RW selection for its duration; flip back afterwards so # steady-state getters use the RO selection. self._is_rw = True try: self._sync_fabric_from_usd_initial() finally: self._is_rw = False def _sync_fabric_from_usd_initial(self) -> None: """Populate Fabric world+local matrices for children and parents from USD. Performed once during ``_initialize_fabric``. Without this step Fabric's matrices are identity for stages that haven't been rendered yet, and our getters (which read from Fabric) would return wrong values. """ # --- Children: compose child localMatrix from USD-authored local transforms. scales_wp = _to_float32_2d(self._usd_view.get_local_scales().warp) local_pos_ta, local_ori_ta = self._usd_view.get_local_poses() wp.launch( kernel=fabric_utils.compose_indexed_fabric_transforms, dim=self.count, inputs=[ self._get_local_ifa(), # caller holds ``_is_rw=True``: init-time write, no scope yet _to_float32_2d(local_pos_ta.warp), _to_float32_2d(local_ori_ta.warp), _to_float32_2d(scales_wp), False, False, False, self._view_indices, ], device=self._device, ) # --- Parents (one entry per unique parent path) --- unique_parent_paths = self._unique_parent_paths if unique_parent_paths: import usdrt # noqa: PLC0415 from isaaclab.sim.utils import get_current_stage # noqa: PLC0415 usd_stage = get_current_stage() xform_cache = UsdGeom.XformCache(Usd.TimeCode.Default()) world_pos_rows: list[list[float]] = [] world_ori_rows: list[list[float]] = [] world_scale_rows: list[list[float]] = [] decomposer = Gf.Transform() warned_shear = False for path in unique_parent_paths: prim = usd_stage.GetPrimAtPath(path) tf = xform_cache.GetLocalToWorldTransform(prim) decomposer.SetMatrix(tf) s = decomposer.GetScale() if not warned_shear: row0 = Gf.Vec3d(tf[0][0], tf[0][1], tf[0][2]).GetNormalized() row1 = Gf.Vec3d(tf[1][0], tf[1][1], tf[1][2]).GetNormalized() row2 = Gf.Vec3d(tf[2][0], tf[2][1], tf[2][2]).GetNormalized() if ( abs(Gf.Dot(row0, row1)) > 1e-3 or abs(Gf.Dot(row0, row2)) > 1e-3 or abs(Gf.Dot(row1, row2)) > 1e-3 ): warned_shear = True logger.warning( "FabricFrameView: parent prim '%s' has a sheared/skewed world " "transform. TRS decomposition (used by scale getters and world<->local " "propagation) does not support shear -- extracted scales and rotations " "will be approximate. Avoid shear in parent transforms for correct results.", path, ) tf.Orthonormalize() t = tf.ExtractTranslation() q = tf.ExtractRotationQuat() img, real = q.GetImaginary(), q.GetReal() world_pos_rows.append([float(t[0]), float(t[1]), float(t[2])]) world_ori_rows.append([float(img[0]), float(img[1]), float(img[2]), float(real)]) world_scale_rows.append([float(s[0]), float(s[1]), float(s[2])]) parent_view_indices = wp.array(list(range(len(unique_parent_paths))), dtype=wp.uint32, device=self._device) parent_pos_wp = wp.array(world_pos_rows, dtype=wp.float32, device=self._device) parent_ori_wp = wp.array(world_ori_rows, dtype=wp.float32, device=self._device) parent_scale_wp = wp.array(world_scale_rows, dtype=wp.float32, device=self._device) # One-off RW selection on the parent tag for the initial seed; the # persistent ``_sel_parent`` stays read-only for steady-state reads. sel_parent_rw = self._stage.SelectPrims( require_attrs=[ (usdrt.Sdf.ValueTypeNames.UInt, self._parent_index_attr, usdrt.Usd.Access.Read), (usdrt.Sdf.ValueTypeNames.Matrix4d, self._WORLD_MATRIX_NAME, usdrt.Usd.Access.ReadWrite), ], device=self._device, ) self._check_selection_count(sel_parent_rw.GetCount(), len(unique_parent_paths), self._parent_index_attr) wp.launch( kernel=fabric_utils.map_view_indices_to_fabric_slots, dim=len(unique_parent_paths), inputs=[wp.fabricarray(sel_parent_rw, self._parent_index_attr), self._parent_slots_buf], device=self._device, ) parent_world_rw = wp.indexedfabricarray( fa=wp.fabricarray(sel_parent_rw, self._WORLD_MATRIX_NAME), indices=self._parent_slots_buf, ) wp.launch( kernel=fabric_utils.compose_indexed_fabric_transforms, dim=len(unique_parent_paths), inputs=[ parent_world_rw, parent_pos_wp, parent_ori_wp, parent_scale_wp, False, False, False, parent_view_indices, ], device=self._device, ) wp.synchronize() # After seeding local matrices from USD, recompute world matrices so # the view starts with consistent state. self._recompute_world_from_local_all() wp.synchronize()
# ---------------------------------------------------------------------- # Concrete writer classes for FabricFrameView # ---------------------------------------------------------------------- class _FabricWriterMixin: """Common ``__enter__`` / ``__exit__`` for the Fabric world / local writers. On enter: pauses ``track_local_xform_changes`` / ``track_world_xform_changes`` on the Fabric hierarchy (saving prior state) and flips the view's ``_is_rw`` so all get/set helpers resolve to the persistent RW selection (both selections are kept alive for the view's lifetime). On exit (normal or via exception): runs a best-effort opposite-space derive + ``wp.synchronize()`` whenever any write happened inside the scope, then flips ``_is_rw`` back to ``False`` (RO selection for steady-state reads) and restores hierarchy-tracking state. **Exception safety.** If the scope unwinds because of an exception (including ``KeyboardInterrupt`` from an interactive notebook), the opposite-space derive still runs so that ``worldMatrix`` and ``localMatrix`` are mutually consistent prim-by-prim on whatever partial-write state Fabric currently holds. The partial write itself is **not** rolled back -- some prims may carry the new value and the rest the pre-scope value -- so callers needing transactional all-or-nothing semantics should snapshot matrices themselves before entering the scope. If the recovery launch itself fails (typically because the original exception came from a poisoned CUDA stream), the failure is logged and the original exception propagates; the view should then be recreated. """ def _enter_impl(self) -> None: view: FabricFrameView = self._view # type: ignore[assignment] if not view._fabric_initialized: view._initialize_fabric() self._wrote_anything = False h = view._fabric_hierarchy self._was_tracking_local = h is not None and h.tracking_local_xform_changes self._was_tracking_world = h is not None and h.tracking_world_xform_changes if h is not None: if self._was_tracking_local: h.track_local_xform_changes(False) if self._was_tracking_world: h.track_world_xform_changes(False) view._is_rw = True def _exit_impl(self, exc_type, exc_val, exc_tb) -> None: view: FabricFrameView = self._view # type: ignore[assignment] try: if self._wrote_anything: try: self._derive_opposite() wp.synchronize() except Exception as recovery_exc: # Recovery itself failed (e.g. original exception came # from a device error and the CUDA stream is poisoned). # If we got here on the happy path, re-raise. If we are # already unwinding an exception, log and let the original # propagate -- masking it would hide the actual root cause. if exc_type is None: raise logger.error( "FabricFrameView writer scope: best-effort opposite-space sync " "failed during exception handling: %s. World/local matrices may " "be inconsistent prim-by-prim; recreate the view to recover.", recovery_exc, ) finally: # Flip back to RO before restoring hierarchy tracking so any # subsequent updateWorldXforms tick sees a fully-RO selection. view._is_rw = False h = view._fabric_hierarchy if h is not None: if self._was_tracking_world: h.track_world_xform_changes(True) if self._was_tracking_local: h.track_local_xform_changes(True) def _derive_opposite(self) -> None: raise NotImplementedError class _FabricWorldSpaceWriter(_FabricWriterMixin, FrameViewWorldSpaceWriter): """World-space writer for :class:`FabricFrameView`. Writes flow through the RW selection's ``worldMatrix`` indexed array; on exit ``localMatrix`` is derived from the just-written ``worldMatrix`` via :func:`update_indexed_local_matrix_from_world`. """ def _derive_opposite(self) -> None: self._view._recompute_local_from_world_all() # type: ignore[attr-defined] def set_poses(self, positions=None, orientations=None, indices=None) -> None: view: FabricFrameView = self._view # type: ignore[assignment] indices_wp = view._resolve_indices_wp(indices) positions_wp = view._to_float32_2d_or_empty(positions) orientations_wp = view._to_float32_2d_or_empty(orientations) wp.launch( kernel=fabric_utils.compose_indexed_fabric_transforms, dim=indices_wp.shape[0], inputs=[ view._get_world_ifa(), positions_wp, orientations_wp, view._fabric_empty_2d_array_sentinel, False, False, False, indices_wp, ], device=view._device, ) self._wrote_anything = True def set_scales(self, scales, indices=None) -> None: view: FabricFrameView = self._view # type: ignore[assignment] indices_wp = view._resolve_indices_wp(indices) scales_wp = view._to_float32_2d_or_empty(scales) wp.launch( kernel=fabric_utils.compose_indexed_fabric_transforms, dim=indices_wp.shape[0], inputs=[ view._get_world_ifa(), view._fabric_empty_2d_array_sentinel, view._fabric_empty_2d_array_sentinel, scales_wp, False, False, False, indices_wp, ], device=view._device, ) self._wrote_anything = True def get_poses(self, indices=None) -> tuple[ProxyArray, ProxyArray]: return self._view._get_world_poses_impl(indices) # type: ignore[attr-defined] def get_scales(self, indices=None) -> ProxyArray: return self._view._get_world_scales_impl(indices) # type: ignore[attr-defined] class _FabricLocalSpaceWriter(_FabricWriterMixin, FrameViewLocalSpaceWriter): """Local-space writer for :class:`FabricFrameView`. Writes flow through the RW selection's ``localMatrix`` indexed array; on exit ``worldMatrix`` is derived from the just-written ``localMatrix`` via :func:`update_indexed_world_matrix_from_local`. """ def _derive_opposite(self) -> None: self._view._recompute_world_from_local_all() # type: ignore[attr-defined] def set_poses(self, positions=None, orientations=None, indices=None) -> None: view: FabricFrameView = self._view # type: ignore[assignment] indices_wp = view._resolve_indices_wp(indices) translations_wp = view._to_float32_2d_or_empty(positions) orientations_wp = view._to_float32_2d_or_empty(orientations) wp.launch( kernel=fabric_utils.compose_indexed_fabric_transforms, dim=indices_wp.shape[0], inputs=[ view._get_local_ifa(), translations_wp, orientations_wp, view._fabric_empty_2d_array_sentinel, False, False, False, indices_wp, ], device=view._device, ) self._wrote_anything = True def set_scales(self, scales, indices=None) -> None: view: FabricFrameView = self._view # type: ignore[assignment] indices_wp = view._resolve_indices_wp(indices) scales_wp = view._to_float32_2d_or_empty(scales) wp.launch( kernel=fabric_utils.compose_indexed_fabric_transforms, dim=indices_wp.shape[0], inputs=[ view._get_local_ifa(), view._fabric_empty_2d_array_sentinel, view._fabric_empty_2d_array_sentinel, scales_wp, False, False, False, indices_wp, ], device=view._device, ) self._wrote_anything = True def get_poses(self, indices=None) -> tuple[ProxyArray, ProxyArray]: return self._view._get_local_poses_impl(indices) # type: ignore[attr-defined] def get_scales(self, indices=None) -> ProxyArray: return self._view._get_local_scales_impl(indices) # type: ignore[attr-defined] class _FabricFallbackWorldWriter(FrameViewWorldSpaceWriter): """Fallback world-space writer used when Fabric is disabled. Delegates set/get calls to the internal :class:`UsdFrameView`'s backend hooks directly. No batching, no listener pausing -- there's no Fabric to confuse. """ def set_poses(self, positions=None, orientations=None, indices=None) -> None: self._view._usd_view._apply_world_pose_write(positions, orientations, indices) # type: ignore[attr-defined] def set_scales(self, scales, indices=None) -> None: self._view._usd_view._apply_world_scale_write(scales, indices) # type: ignore[attr-defined] def get_poses(self, indices=None) -> tuple[ProxyArray, ProxyArray]: return self._view._usd_view._get_world_poses_impl(indices) # type: ignore[attr-defined] def get_scales(self, indices=None) -> ProxyArray: return self._view._usd_view._get_world_scales_impl(indices) # type: ignore[attr-defined] class _FabricFallbackLocalWriter(FrameViewLocalSpaceWriter): """Fallback local-space writer used when Fabric is disabled.""" def set_poses(self, positions=None, orientations=None, indices=None) -> None: self._view._usd_view._apply_local_pose_write(positions, orientations, indices) # type: ignore[attr-defined] def set_scales(self, scales, indices=None) -> None: self._view._usd_view._apply_local_scale_write(scales, indices) # type: ignore[attr-defined] def get_poses(self, indices=None) -> tuple[ProxyArray, ProxyArray]: return self._view._usd_view._get_local_poses_impl(indices) # type: ignore[attr-defined] def get_scales(self, indices=None) -> ProxyArray: return self._view._usd_view._get_local_scales_impl(indices) # type: ignore[attr-defined]