Source code for isaaclab_ov.physics.ovphysx_manager

# 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

"""OvPhysX Manager for Isaac Lab.

This module manages an ovphysx-based physics simulation lifecycle without Kit dependencies.
It serializes the current USD stage in memory, attaches it to ovphysx through OVStage, and
steps the simulation using the ovphysx C/Python API.
"""

from __future__ import annotations

import atexit
import contextlib
import logging
import math
import os
import re
import stat
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, ClassVar

import numpy as np
import warp as wp

from pxr import Sdf, UsdPhysics

from isaaclab.physics import PhysicsEvent, PhysicsManager
from isaaclab.scene_data import SceneDataBackend, SceneDataFormat
from isaaclab.scene_data.deformable_discovery import (
    deformable_geometry_batches,
    deformable_prototypes,
    expand_deformable_entries,
)
from isaaclab.sim.simulation_context import SimulationContext
from isaaclab.utils.buffers import TimestampedBuffer

from isaaclab_ov._clone import CloneTransform, clone_transforms_from_positions
from isaaclab_ov._runtime import import_ovphysx
from isaaclab_ov.cloner import OvPhysxReplicateContext
from isaaclab_ov.sim.views.ovphysx_view import OvPhysxView
from isaaclab_ov.stage import create_ovstage

from .ovphysx_compat import OVPHYSX_LIFECYCLE_ENTRY_POINTS
from .ovphysx_manager_cfg import DEFAULT_COOKED_COLLIDER_CACHE_DIR, OvPhysxBackendCfg

if TYPE_CHECKING:
    from isaaclab.scene_data.deformable_discovery import DeformableStageEntry

    from .ovphysx_manager_cfg import OvPhysxCfg

__all__ = ["OvPhysxManager", "OvPhysxSceneDataBackend"]


def _prepare_default_cache_dir(cache_dir: str) -> str:
    """Create the default cooked-collider cache directory and refuse one this user does not own.

    The default sits in the shared temporary directory, so any local user can pre-create the path;
    OVPhysX follows a symlink there and writes through it. A directory the caller configured is
    their own choice and is passed through untouched.

    Args:
        cache_dir: Default cache directory to create or validate.

    Returns:
        The validated directory.

    Raises:
        RuntimeError: If the path exists as a symlink, a non-directory, or another user's directory.
    """
    try:
        os.makedirs(cache_dir, mode=0o700)
        return cache_dir
    except FileExistsError:
        pass
    entry = os.lstat(cache_dir)
    if stat.S_ISLNK(entry.st_mode):
        raise RuntimeError(f"OVPhysX cache directory '{cache_dir}' is a symlink; refusing to write through it.")
    if not stat.S_ISDIR(entry.st_mode):
        raise RuntimeError(f"OVPhysX cache directory '{cache_dir}' exists and is not a directory.")
    if hasattr(os, "getuid") and entry.st_uid != os.getuid():
        raise RuntimeError(f"OVPhysX cache directory '{cache_dir}' is owned by another user; refusing to use it.")
    return cache_dir


logger = logging.getLogger(__name__)


class OvPhysxSceneDataBackend(SceneDataBackend):
    """Scene-data backend for the OVPhysX physics manager.

    The runtime's rigid-body view fills one pose buffer directly. Deformable views
    fill their native slices without an intermediate packing pass.
    """

    def __init__(self):
        self.backend: OvPhysxBackend | None = None
        self._transforms = TimestampedBuffer(SceneDataFormat.Transform())
        self.transforms_timestamp = 0
        self.geometry_timestamp = 0
        self._geometry = TimestampedBuffer()
        self._deformable_bindings: list[tuple[OvPhysxView, Any, wp.array]] = []

    @property
    def transform_count(self) -> int:
        """Number of poses in the native publication."""
        poses = self._transforms.data.transforms
        return 0 if poses is None else len(poses)

    @property
    def transform_paths(self) -> list[str]:
        """Native body paths in the same order as the published poses."""
        view = None if self.backend is None else self.backend.rigid_body_view
        return [] if view is None else view.prim_paths

    def setup(
        self, backend: OvPhysxBackend, device: str, entries: Sequence[DeformableStageEntry] | None = None
    ) -> None:
        """Bind the native body table and declared deformable geometry.

        Args:
            backend: Initialized native physics resource.
            device: Warp device string used to allocate the published buffers.
            entries: Declared deformables captured before native stage import. ``None`` leaves
                scene geometry uninitialized; an empty sequence declares no deformables.
        """
        self.backend = backend
        self._transforms.data.transforms = None
        self.transforms_timestamp += 1
        self._deformable_bindings = []
        self.geometry_timestamp += 1
        self._geometry = TimestampedBuffer()

        if backend.rigid_body_view is not None and backend.rigid_body_view.count:
            self._transforms.data.transforms = wp.empty(
                backend.rigid_body_view.count, dtype=wp.transformf, device=device
            )

        if entries is not None:
            self._setup_deformable_bindings(backend.physx, entries, device)

    def _setup_deformable_bindings(self, physx, entries: Sequence[DeformableStageEntry], device: str) -> None:
        """Bind exact planned deformables directly into one flat publication buffer."""
        from isaaclab_ov import tensor_types as TT

        groups = {}
        for entry in entries:
            groups.setdefault((entry.deformable_type, entry.vertex_count), []).append(entry)

        views, native_entries = [], []
        for (deformable_type, count), entries in groups.items():
            tensor_type = (
                TT.DEFORMABLE_SIM_NODAL_POSITION if deformable_type == "volume" else TT.SURFACE_DEFORMABLE_SIM_POSITION
            )
            view = OvPhysxView(
                physx,
                prim_paths=[entry.root_path for entry in entries],
                device=device,
                tensor_types=[tensor_type],
                eager=True,
            )
            by_path = {path: entry for entry in entries for path in (entry.root_path, entry.sim_mesh_path)}
            ordered = [by_path[path] for path in view.prim_paths]
            if sorted(entry.root_path for entry in ordered) != sorted(entry.root_path for entry in entries):
                raise RuntimeError("OVPhysX deformable binding does not cover the declared clone-plan entries.")
            if tuple(view.binding_for(tensor_type).shape) != (len(entries), count, 3):
                raise RuntimeError("OVPhysX deformable node counts disagree with the clone plan.")
            native_entries.extend(ordered)
            views.append((view, tensor_type, count))

        if not views:
            self._geometry.data = []
            return
        counts = [entry.vertex_count for entry in native_entries]
        points = wp.empty(sum(counts), dtype=wp.vec3f, device=device)
        offset = 0
        for view, tensor_type, count in views:
            buffer = wp.array(
                ptr=points.ptr + offset * wp.types.type_size_in_bytes(wp.vec3f),
                shape=(view.count, count),
                dtype=wp.vec3f,
                device=device,
                copy=False,
            )
            self._deformable_bindings.append((view, tensor_type, buffer))
            offset += view.count * count
        offsets = np.cumsum(np.r_[0, counts[:-1]])
        self._geometry.data = deformable_geometry_batches(native_entries, offsets, device=device)
        for publication, _ in self._geometry.data:
            publication.points = points

    def get_geometry_batches(self, output_format: Any = SceneDataFormat.Points) -> list:
        """Publish native positions with exact visual paths and interpolation metadata.

        Raises:
            RuntimeError: If scene geometry was not initialized from a clone plan.
        """
        if self._geometry.data is None:
            raise RuntimeError("Declare and replicate a ClonePlan before requesting scene geometry.")
        if self._geometry.timestamp != self.geometry_timestamp:
            for view, tensor_type, buffer in self._deformable_bindings:
                view.read_into(tensor_type, buffer)
            self._geometry.timestamp = self.geometry_timestamp
        return self._geometry.data

    @property
    def native_geometry_formats(self) -> tuple[Any, ...]:
        """Return the native geometry formats compiled from the declared prototypes.

        Raises:
            RuntimeError: If scene geometry was not initialized from a clone plan.
        """
        if self._geometry.data is None:
            raise RuntimeError("Declare and replicate a ClonePlan before requesting scene geometry.")
        return tuple(dict.fromkeys(publication._cls for publication, _ in self._geometry.data))

    @property
    def transforms(self) -> SceneDataFormat.Transform:
        """Publish native rigid-body poses [m, xyzw]."""
        if self._transforms.timestamp != self.transforms_timestamp:
            OvPhysxManager.pre_render()
            if self._transforms.data.transforms is not None:
                self.backend.rigid_body_view.read(self._transforms.data.transforms)
            self._transforms.timestamp = self.transforms_timestamp
        return self._transforms.data


class OvPhysxBackend:
    """Own the native OVPhysX runtime and its attached OVStage for one simulation."""

    def __init__(self, cfg: OvPhysxBackendCfg):
        ovphysx = import_ovphysx()
        ovphysx.bootstrap()
        is_gpu = cfg.device.startswith("cuda:")
        cache_dir = cfg.cooked_collider_cache_dir
        if cache_dir == DEFAULT_COOKED_COLLIDER_CACHE_DIR:
            cache_dir = _prepare_default_cache_dir(cache_dir)
        carbonite_overrides = {
            "/physics/physxDispatcher": True,
            "/physics/updateToUsd": False,
            "/physics/updateVelocitiesToUsd": False,
            "/physics/updateParticlesToUsd": False,
        }
        if is_gpu:
            carbonite_overrides.update({"/physics/suppressReadback": True, "/physics/suppressFabricUpdate": True})
        ovphysx.PhysX.set_cpu_mode(not is_gpu)
        self.physx = ovphysx.PhysX(
            config=ovphysx.PhysXConfig(
                num_threads=8, cooked_collider_cache_dir=cache_dir, carbonite_overrides=carbonite_overrides
            ),
            active_cuda_gpus=cfg.device.removeprefix("cuda:") if is_gpu else None,
        )
        self.stage: Any = None
        self.rigid_body_view: Any = None

    def close(self) -> None:
        """Release bindings, the runtime, and its stage in native teardown order."""
        physx = self.physx
        if physx is None:
            if self.stage is not None:
                self.stage.destroy()
                self.stage = None
            return

        # Legacy release errors are terminal; current destroy errors may be retryable.
        destroy_entry_point = OVPHYSX_LIFECYCLE_ENTRY_POINTS["destroy"]
        release_owners = destroy_entry_point == "release"
        try:
            try:
                if self.rigid_body_view is not None:
                    self.rigid_body_view.destroy()
                    self.rigid_body_view = None
                OvPhysxView._close_all_for(physx)
            finally:
                try:
                    physx.wait_op(physx.reset_stage())
                finally:
                    try:
                        destroy = getattr(physx, destroy_entry_point, None)
                        if destroy is None:
                            raise AttributeError(
                                f"OVPhysX does not expose the selected {destroy_entry_point}() lifecycle entry point"
                            )
                        destroy()
                    except Exception:
                        if destroy_entry_point == "destroy":
                            # Keep both owners if native teardown did not reach its terminal state.
                            try:
                                physx.handle
                            except RuntimeError:
                                release_owners = True
                            except Exception:
                                release_owners = False
                        raise
                    else:
                        release_owners = True
        finally:
            if release_owners:
                self.physx = None
                if self.stage is not None:
                    self.stage.destroy()
                    self.stage = None


[docs] class OvPhysxManager(PhysicsManager): """Manages an ovphysx-backed physics simulation lifecycle. Unlike PhysxManager, this manager does not depend on a host Kit or Carbonite runtime, or on the Omniverse timeline. It drives the simulation through the OVPhysX Python wheel and its packaged runtime. Lifecycle: initialize() -> reset() -> step() (repeated) -> close() """ clone_context_type = OvPhysxReplicateContext _cfg: ClassVar[OvPhysxCfg | None] = None backend: ClassVar[OvPhysxBackend | None] = None """Native runtime borrowed from the simulation registry after warmup; the registry owns its lifetime.""" _stage_usda: ClassVar[str | None] = None _warmup_done: ClassVar[bool] = False _next_control_ordinal: ClassVar[int] = 2 _requires_full_stage: ClassVar[bool] = False # Device mode is process-wide; later contexts must reuse the first selected device. _locked_device: ClassVar[str | None] = None # Active clone recipes survive the consumable pending queue so a forced # re-warmup can rebuild serialized-stage or runtime-only clones. _active_clone_recipes: ClassVar[list[tuple[str, list[str], list[CloneTransform], list[int] | None]]] = [] # Consumable snapshot of the active recipes. Full-stage warmup materializes # these into serialized USDA; env-0-only warmup replays them with physx.clone(). _pending_clones: ClassVar[list[tuple[str, list[str], list[CloneTransform], list[int] | None]]] = [] _atexit_registered: ClassVar[bool] = False _scene_data_backend: ClassVar[OvPhysxSceneDataBackend | None] = None kinematics_dirty: ClassVar[bool] = False # Gravity currently applied to the running scene [m/s^2]. Seeded from ``SimulationCfg.gravity`` # in :meth:`initialize` and refreshed by :meth:`set_gravity`. ``cfg.gravity`` stays the nominal # value that randomization terms resample from, so live updates must not be written back to it. _gravity: ClassVar[tuple[float, float, float] | None] = None
[docs] @classmethod def get_dt(cls) -> float: """Get the physics timestep. Alias for get_physics_dt().""" return cls.get_physics_dt()
[docs] @classmethod def require_full_stage(cls) -> None: """Load every authored environment during the next stage warmup.""" cls._requires_full_stage = True
[docs] @classmethod def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any: """Fix and normalize an articulation root for the OVPhysX parser.""" root = super().fix_articulation_root(articulation_prim, stage) if root.HasAPI(UsdPhysics.RigidBodyAPI): return cls._relocate_articulation_root( root, companion_schema="PhysxArticulationAPI", companion_namespace="physxArticulation", ) return root
[docs] @classmethod def register_clone( cls, source: str, targets: list[str], parent_positions: list[tuple[float, float, float]] | None = None ) -> None: """Queue clones at the given world positions with identity rotations. Args: source: Source prim path (env_0 articulation root). targets: Target prim paths for env_1..N. parent_positions: Final world positions (x, y, z) [m] for whole-environment target roots. Each position uses an identity rotation. """ target_transforms = clone_transforms_from_positions(parent_positions or []) cls._register_clone_transforms(source, targets, target_transforms)
@classmethod def _register_clone_transforms( cls, source: str, targets: list[str], target_transforms: list[CloneTransform], env_ids: list[int] | None = None ) -> None: """Register final target-root world poses for the current simulation context.""" recipe = (source, list(targets), list(target_transforms), None if env_ids is None else list(env_ids)) cls._active_clone_recipes.append(recipe) cls._pending_clones.append(recipe) @classmethod def _rearm_pending_clones(cls) -> None: """Refresh the consumable clone queue from active context recipes.""" cls._pending_clones = cls._active_clone_recipes.copy() _physx_schemas_registered: ClassVar[bool] = False @classmethod def _prepare_stage_creation(cls) -> None: """Register OvPhysX USD schemas before creating the selected backend's stage.""" cls._ensure_physx_schemas_registered() @classmethod def _ensure_physx_schemas_registered(cls) -> None: """Register the codeless USD schemas published by the OVPhysX wheel. OVStage maintains its own USD schema registry, so register the wheel's schema root there even when the host USD runtime already provides the same plugins. For the host USD registry, only register providers that are not already available from a compiled plugin. """ if cls._physx_schemas_registered: return try: import ovphysx # noqa: PLC0415 from pxr import Plug # noqa: PLC0415 except ImportError: return try: import ovstage # noqa: PLC0415 except ImportError: pass # Host USD schemas can still be registered without OVStage. else: schema_root = getattr(ovphysx, "codeless_schema_root", None) register_ovstage_schemas = getattr(getattr(ovstage, "population", None), "register_usd_schemas", None) if callable(schema_root) and callable(register_ovstage_schemas): register_ovstage_schemas(str(schema_root())) registry = Plug.Registry() registered_names = {plugin.name.casefold() for plugin in registry.GetAllPlugins()} # The wheel documents ``<module>/resources`` as its stable layout and its # bundled plugin names match those module directory names case-insensitively. schema_paths = [ str(path) for path in ovphysx.codeless_schema_paths() if path.parent.name.casefold() not in registered_names ] if schema_paths: registry.RegisterPlugins(schema_paths) cls._physx_schemas_registered = True
[docs] @classmethod def initialize(cls, sim_context: SimulationContext) -> None: """Initialize the physics manager with simulation context. This stores the config and device but does not load the USD stage yet -- the stage may not be fully populated at this point. The actual load happens lazily in :meth:`reset`. The simulation registry retains its native resource across reinitialization. ``cls._locked_device`` carries the process-wide first-device policy. """ super().initialize(sim_context) cls._ensure_physx_schemas_registered() cls._gravity = tuple(sim_context.cfg.gravity) cls._warmup_done = False cls._requires_full_stage = False cls._stage_usda = None cls._pending_clones = [] cls._active_clone_recipes = [] # Construct the SceneDataBackend eagerly so :class:`SimulationContext` # captures a real instance (not ``None``) when it builds the central # :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` in # its own ``__init__``. Bindings stay empty until :meth:`_warmup_and_load` # calls :meth:`OvPhysxSceneDataBackend.setup`, at which point the wheel # and the USD stage are live. Matches PhysX's pattern of constructing # the backend during ``initialize()``. cls._scene_data_backend = OvPhysxSceneDataBackend() cls.kinematics_dirty = False
[docs] @classmethod def reset(cls, soft: bool = False) -> None: """Reset physics simulation. On the first (non-soft) reset the method: - Serializes the current USD stage in memory - Creates the ovphysx.PhysX instance - Populates and attaches an OVStage - Warms up GPU buffers (if on CUDA) - Dispatches PHYSICS_READY A forced re-warm dispatches :attr:`~isaaclab.physics.PhysicsEvent.STOP` before replacing the attached stage so listeners discard stale bindings. """ if not soft: if not cls._warmup_done: if cls.backend is not None and cls.backend.stage is not None: cls.dispatch_event(PhysicsEvent.STOP, payload={}) cls._warmup_and_load() cls.dispatch_event(PhysicsEvent.PHYSICS_READY, payload={}) cls.kinematics_dirty = True cls._scene_data_backend.transforms_timestamp += 1 cls._scene_data_backend.geometry_timestamp += 1
[docs] @classmethod def forward(cls) -> None: """Evaluate and publish state changes made without stepping physics.""" cls.update_kinematics() cls._scene_data_backend.transforms_timestamp += 1 cls._scene_data_backend.geometry_timestamp += 1
[docs] @classmethod def pre_render(cls) -> None: """Finish native kinematics before SDP publishes manually written joint poses.""" cls.update_kinematics()
[docs] @classmethod def update_kinematics(cls) -> None: """Update dirty articulation kinematics without publishing or rendering.""" if not cls.kinematics_dirty: return if cls.backend is not None and cls.backend.physx is not None: cls.backend.physx.update_articulations_kinematic() cls.kinematics_dirty = False
[docs] @classmethod def step(cls) -> None: """Step the simulation by one physics timestep.""" if cls.backend is None or cls.backend.physx is None: return dt = cls.get_physics_dt() cls.backend.physx.step_sync(dt=dt) cls.kinematics_dirty = True cls.update_kinematics() cls._scene_data_backend.transforms_timestamp += 1 cls._scene_data_backend.geometry_timestamp += 1 PhysicsManager._sim_time += dt
@staticmethod def _warmup_physx(physx: Any) -> None: """Warm a runtime through its version-selected API.""" entry_point = OVPHYSX_LIFECYCLE_ENTRY_POINTS["warmup"] warmup = getattr(physx, entry_point, None) if warmup is None: raise AttributeError(f"OVPhysX does not expose the selected {entry_point}() lifecycle entry point") warmup()
[docs] @classmethod def close(cls) -> None: """Release ovphysx resources and clean up.""" sim = SimulationContext.instance() # Dispatch STOP while the runtime is still live. Asset and sensor callbacks # invalidate raw native handles before the view registry drains the remaining # binding caches and the runtime is released. try: super().close() finally: try: if cls.backend is not None: sim.close_backend(cls.backend) cls.backend = None finally: cls._stage_usda = None cls._warmup_done = False cls._requires_full_stage = False cls._active_clone_recipes = [] cls._pending_clones = [] # Drop the SceneDataBackend singleton: its cached bindings and buffers # belong to the runtime instance just released. The next # SimulationContext re-creates it in initialize(). cls._scene_data_backend = None cls.kinematics_dirty = False cls._next_control_ordinal = 2
@classmethod def _attach_ovstage(cls, stage_usda: str) -> None: """Populate an OVStage from USDA text and attach it to the runtime.""" import ovstage # noqa: PLC0415 stage = create_ovstage("isaaclab") try: ovstage.population.open_usd_from_string( stage, stage_usda, ordinal=1, # FIXME: Use PHYSICS once OVStage includes native-instance collider # dependencies in physics-only population. domains=ovstage.PopulationDomain.ALL, ) # ovphysx reads sealed data only: population completes the writes but never # commits the ordinal, so attaching at an unsealed ordinal fails the parse # and silently yields an empty scene. stage.advance_write_floor(ordinal=1).wait() cls.backend.physx.attach_ovstage(stage, read_ordinal=1) except Exception: stage.destroy() raise cls.backend.stage = stage cls._next_control_ordinal = 2 @classmethod def _prepare_physx_for_stage_reuse(cls) -> None: """Drain stage-bound handles before reusing the active runtime for another stage.""" physx = cls.backend.physx if physx is None: return if cls.backend.rigid_body_view is not None: cls.backend.rigid_body_view.destroy() cls.backend.rigid_body_view = None OvPhysxView._close_all_for(physx) physx.wait_op(physx.reset_stage()) if cls.backend.stage is not None: cls.backend.stage.destroy() cls.backend.stage = None cls._next_control_ordinal = 2
[docs] @classmethod def get_physx_instance(cls) -> Any: """Return the underlying ovphysx.PhysX instance (or None if not yet created).""" return None if cls.backend is None else cls.backend.physx
[docs] @classmethod def get_gravity(cls) -> tuple[float, float, float]: """Return the world-frame gravity vector [m/s^2] currently applied to the scene. Mirrors PhysX's ``SimulationView.get_gravity()`` so backend-agnostic sensor code can read gravity through one classmethod. The value tracks :meth:`set_gravity`, falling back to the simulation cfg until the first live update. Raises: RuntimeError: If no simulation is active. Call :meth:`initialize` first. """ if cls._sim is None or not hasattr(cls._sim, "cfg"): raise RuntimeError("OvPhysxManager has not been initialized yet.") if cls._gravity is None: return tuple(cls._sim.cfg.gravity) return cls._gravity
[docs] @classmethod def set_gravity(cls, gravity: tuple[float, float, float]) -> None: """Set the scene-wide gravity vector through OvStage [m/s^2]. The OvPhysX runtime accepts live scene changes only as sealed OvStage control updates. This method authors the scene's gravity direction and magnitude at the next control ordinal, seals it, and applies that single ordinal to the running simulation. Args: gravity: World-frame gravity vector [m/s^2]. Raises: RuntimeError: If the OVPhysX simulation has not been initialized. ValueError: If gravity does not contain three finite values. """ if cls._sim is None or cls.get_physx_instance() is None or cls.backend.stage is None: raise RuntimeError("OvPhysxManager has not been initialized yet.") gravity_array = np.asarray(gravity, dtype=np.float32) if gravity_array.shape != (3,) or not np.all(np.isfinite(gravity_array)): raise ValueError("Gravity must contain three finite values.") magnitude = float(np.linalg.norm(gravity_array)) if math.isclose(magnitude, 0.0): direction = np.array([[0.0, 0.0, -1.0]], dtype=np.float32) else: direction = (gravity_array / magnitude).reshape(1, 3) ordinal = cls._next_control_ordinal cls._next_control_ordinal += 1 import ovstage # noqa: PLC0415 stage = cls.backend.stage with contextlib.ExitStack() as cleanup: paths = cleanup.enter_context(ovstage.PathDictionary(stage)) path_list = paths.create_path_list_from_strings([cls._sim.cfg.physics_prim_path]) cleanup.callback(paths.destroy_path_list, path_list) query = cleanup.enter_context(stage.query_from_path_list(path_list)) stage.write_attribute(query, "physics:gravityDirection", ordinal, direction, is_array=False).wait() stage.write_attribute( query, "physics:gravityMagnitude", ordinal, np.array([magnitude], dtype=np.float32), is_array=False ).wait() stage.advance_write_floor(ordinal=ordinal).wait() cls.backend.physx.update_from_ovstage(ordinal, ordinal) # Only publish once the ordinal has been applied, so a failed write leaves # :meth:`get_gravity` reporting the gravity the scene is still running with. cls._gravity = (float(gravity_array[0]), float(gravity_array[1]), float(gravity_array[2]))
[docs] @classmethod def get_scene_data_backend(cls) -> SceneDataBackend: """Return the SceneDataBackend for the central SceneDataProvider. Constructed eagerly in :meth:`initialize` so :class:`SimulationContext` captures a real instance (not ``None``) when wiring up the central :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider`. Bindings are empty until :meth:`_warmup_and_load` calls :meth:`OvPhysxSceneDataBackend.setup` against the live ovphysx ``PhysX`` and USD stage; reads against an unsetup backend return empty data rather than raising. """ return cls._scene_data_backend
# ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ @classmethod def _materialize_pending_clones_in_layer(cls, layer: Any) -> int: """Materialize queued clone targets into a flattened stage layer. OVPhysX runtime cloning is unsafe after a heterogeneous full-stage load: cloning one leaf can disturb tensor discovery for already loaded sibling assets. Missing targets are copied into the flattened layer. When another clone has already created a target ancestor, an internal reference overlays the source physics without replacing authored descendants. The live USD stage remains unchanged. Args: layer: Flattened stage layer to augment. Returns: Number of clone targets materialized in the layer. """ from pxr import Sdf, Usd # noqa: PLC0415 pending_clones = list(cls._pending_clones) cls._pending_clones.clear() if not pending_clones: return 0 exported_stage = Usd.Stage.Open(layer) if exported_stage is None: raise RuntimeError("OvPhysxManager: failed to open the flattened full-stage layer.") envs_path = Sdf.Path("/World/envs") operations: list[tuple[Sdf.Path, Sdf.Path, bool]] = [] processed_targets: set[Sdf.Path] = set() for source, targets, _, _ in pending_clones: source_path = Sdf.Path(source) if layer.GetPrimAtPath(source_path) is None: raise RuntimeError(f"OvPhysxManager: clone source {source!r} is absent from the full stage.") for target in targets: target_path = Sdf.Path(target) if target_path in processed_targets: continue boundary_path = target_path.GetParentPath() for prefix in target_path.GetPrefixes(): if prefix.GetParentPath() == envs_path: boundary_path = prefix break if layer.GetPrimAtPath(boundary_path) is None: raise RuntimeError(f"OvPhysxManager: clone target parent is absent for {target!r}.") operations.append((source_path, target_path, layer.GetPrimAtPath(target_path) is not None)) processed_targets.add(target_path) operations.sort(key=lambda operation: len(operation[1].GetPrefixes())) for source_path, target_path, target_exists in operations: parent_path = target_path.GetParentPath() parent_spec = layer.GetPrimAtPath(parent_path) if parent_spec is None: generated_paths: list[Sdf.Path] = [] # ``CreatePrimInLayer`` authors missing ancestors as ``over`` specs. Track only # paths absent before creation so generated ancestors become defined without # changing existing authored specs. ancestor_path = parent_path while layer.GetPrimAtPath(ancestor_path) is None: generated_paths.append(ancestor_path) ancestor_path = ancestor_path.GetParentPath() if Sdf.CreatePrimInLayer(layer, parent_path) is None: raise RuntimeError( f"OvPhysxManager: failed to materialize clone target parent {str(parent_path)!r}." ) for generated_path in generated_paths: generated_spec = layer.GetPrimAtPath(generated_path) if generated_spec is not None and generated_spec.specifier == Sdf.SpecifierOver: generated_spec.specifier = Sdf.SpecifierDef if target_exists: target_prim = exported_stage.GetPrimAtPath(target_path) if not target_prim.GetReferences().AddInternalReference(source_path): raise RuntimeError(f"OvPhysxManager: failed to overlay clone target {str(target_path)!r}.") elif not Sdf.CopySpec(layer, source_path, layer, target_path): raise RuntimeError(f"OvPhysxManager: failed to materialize clone target {str(target_path)!r}.") if operations: logger.info("OvPhysxManager: materialized %d clone targets in the full-stage layer", len(operations)) return len(operations) @staticmethod def _strip_nonzero_environments(layer: Any) -> int: """Strip authored ``env_<i>`` prims other than ``env_0`` from a stage layer.""" envs_spec = layer.GetPrimAtPath("/World/envs") if envs_spec is None or not envs_spec: return 0 env_name_re = re.compile(r"^env_(\d+)$") names_to_remove = [ child_name for child_name in list(envs_spec.nameChildren.keys()) if (match := env_name_re.match(child_name)) and match.group(1) != "0" ] for child_name in names_to_remove: del envs_spec.nameChildren[child_name] return len(names_to_remove) @classmethod def _serialize_selected_stage(cls, sim_stage: Any) -> str: """Serialize the selected stage representation for OVStage population.""" layer = sim_stage.Flatten() if cls._requires_full_stage: cls._materialize_pending_clones_in_layer(layer) logger.info("OvPhysxManager: serialized the full USD stage in memory") else: removed_count = cls._strip_nonzero_environments(layer) if removed_count: logger.info( "OvPhysxManager: stripped %d env_<i!=0> subtrees from in-memory USD (kept env_0 + globals)", removed_count, ) else: logger.debug("OvPhysxManager: no cloned environments to strip — serialized stage as-is.") return layer.ExportToString() @classmethod def _replay_pending_clones(cls, physx: Any, requires_full_stage: bool) -> None: pending_clones = list(cls._pending_clones) cls._pending_clones.clear() if requires_full_stage: return for source, targets, target_transforms, env_ids in pending_clones: if not targets: continue logger.info( "OvPhysxManager: cloning %s -> %d targets (%s ... %s)", source, len(targets), targets[0], targets[-1], ) # Assets cloned separately into the same world must still collide with each other. physx.wait_op(physx.clone(source, targets, target_transforms or None, env_ids=env_ids)) @classmethod def _warmup_and_load(cls) -> None: """Serialize the USD stage and attach it to the ovphysx runtime. When no runtime is active, constructs a new :class:`ovphysx.PhysX` instance. The first construction also records IsaacLab's process device choice and registers process-exit cleanup. On a forced re-warm before :meth:`close`, it reuses the active instance, attaches the new USD through OVStage, rebuilds active clone recipes through full-stage materialization or runtime replay, and re-runs the supported warmup entry point so the new stage's bodies are resident. Raises: RuntimeError: If ``SimulationContext`` is not set, or if a device different from IsaacLab's first device choice is requested. OVPhysX CPU-only mode is process-wide and cannot be reversed; IsaacLab applies the same conservative policy in both directions for a predictable lifecycle. """ sim = PhysicsManager._sim if sim is None: raise RuntimeError("OvPhysxManager: SimulationContext is not set.") entries = None if (plan := sim.get_clone_plan()) is not None: env_ids = np.arange(len(plan.topology.world_prototype_layout)) entries = expand_deformable_entries(deformable_prototypes(sim.stage, plan), plan, env_ids, plan.positions) ovphysx_device = "gpu" if "cuda" in PhysicsManager._device else "cpu" if cls._locked_device is not None and ovphysx_device != cls._locked_device: raise RuntimeError( f"OvPhysxManager is locked to device {cls._locked_device!r} for the lifetime of this process; " f"cannot switch to {ovphysx_device!r}. IsaacLab pins the first OVPhysX device choice because " "CPU-only mode cannot be reversed; restart the process to use a different device." ) scene_prim = sim.stage.GetPrimAtPath(sim.cfg.physics_prim_path) if scene_prim.IsValid(): if cls._active_clone_recipes: scene_prim.CreateAttribute("physxScene:envIdInBoundsBitCount", Sdf.ValueTypeNames.Int).Set(4) cls._configure_physx_scene_prim(scene_prim, PhysicsManager._cfg, ovphysx_device) # Flatten the current USD stage to USDA text so OVStage can populate it # without an intermediate file. # # When ``InteractiveScene`` runs with ``clone_usd=True``, the live USD # stage carries env_0..N's full asset subtrees as authored copies. # Handing that whole stage to OVStage would make the wheel ingest # all 4096 envs as independent USD-defined bodies, defeating the # ``physx.clone()`` fast path and turning every subsequent # ``create_tensor_binding`` call into an O(N) USD enumeration -- the # hang you'd see at large env counts. # # By default, strip ``/World/envs/env_<i>`` for i != 0 from the # flattened layer before handing it to the wheel. Sensors that read # USD directly (RayCaster, Camera, ContactSensor discovery) still see # the full N-env stage; only the wheel-side physics ingestion is # scoped to env_0, and ``physx.clone()`` re-populates env_1..N in # the physics runtime with proper clone lineage (which is what the # binding fast path expects). Features that need distinct authored # physics in every environment request the full stage; missing # heterogeneous clone targets are materialized in its flattened layer. cls._rearm_pending_clones() stage_usda = cls._serialize_selected_stage(sim.stage) cls._stage_usda = stage_usda previous_backend = cls.backend cls.backend = sim.get_or_create_backend( OvPhysxBackendCfg( device=PhysicsManager._device, cooked_collider_cache_dir=sim.cfg.physics.cooked_collider_cache_dir, ) ) cls._locked_device = ovphysx_device if not cls._atexit_registered: atexit.register(cls._close_at_exit) cls._atexit_registered = True if cls.backend is previous_backend or cls.backend.stage is not None: # Bindings are tied to the realized objects of one stage. Invalidate # asset/sensor handles and drain generic views before resetting the # cached runtime; PHYSICS_READY after this method rebuilds them. cls._prepare_physx_for_stage_reuse() cls._attach_ovstage(stage_usda) logger.info("OvPhysxManager: attached OVStage to ovphysx (device=%s)", ovphysx_device) cls._replay_pending_clones(cls.backend.physx, requires_full_stage=cls._requires_full_stage) # Native metadata and bindings must see the newly attached bodies, including on CPU. cls._warmup_physx(cls.backend.physx) # Bind SDP after the native runtime has realized the stage and clones. if cls._scene_data_backend is None: cls._scene_data_backend = OvPhysxSceneDataBackend() # Native output metadata names the actual bodies, not articulation-root aliases. # SDK imports follow runtime initialization so inactive backends remain optional. from ovphysx.types import SimObjectType from ovstage import PathDictionary body_paths = [] with PathDictionary() as paths: for kind in (SimObjectType.RIGID_BODY, SimObjectType.ARTICULATION_LINK): # Mass is host-resident: obtaining paths needs no GPU pose read or another step. with cls.backend.physx.read(kind, ["mass"]) as bodies: for group in bodies.groups: body_paths.extend(paths.get_path_strings(group.prim_list)) if body_paths: cls.backend.rigid_body_view = cls.backend.physx.create_tensor_binding(prim_paths=body_paths) cls._scene_data_backend.setup(cls.backend, PhysicsManager._device, entries) cls.dispatch_event(PhysicsEvent.MODEL_INIT, payload={}) cls._warmup_done = True @classmethod def _close_at_exit(cls) -> None: """Release a live OVPhysX runtime without leaking an atexit exception.""" if cls.backend is None or cls.backend.physx is None: return try: sim = PhysicsManager._sim is_active_manager = sim is not None and ( sim.physics_manager is cls or sim.physics_manager == f"{cls.__module__}:{cls.__qualname__}" ) if is_active_manager: cls.close() else: # Do not clear another backend's shared callbacks or simulation # state if this is only a stale OVPhysX runtime. cls.backend.close() except Exception: logger.exception("Failed to close OVPhysX during process exit.") @staticmethod def _configure_physx_scene_prim(scene_prim, cfg, device: str) -> None: """Apply PhysxSceneAPI schema and device-specific scene attributes to the scene prim. The PhysxSchema USD plugin may not be loaded in standalone ovphysx mode, so we write the apiSchemas list entry and scene attributes directly via raw Sdf metadata manipulation instead of using the high-level USD API. The schema, scene-query-support, and solver-determinism/accuracy attributes are applied regardless of device. The GPU-specific dynamics/broadphase/capacity attributes are applied only when ``device == "gpu"`` — without them PhysX defaults to CPU broadphase even when OVPhysX is configured for GPU execution. Args: scene_prim: The /World/PhysicsScene prim to configure. cfg: The :class:`OvPhysxCfg` carrying solver-determinism flags and GPU buffer-capacity values. The GPU buffer-capacity values are only consulted when ``device == "gpu"``. device: Resolved physics device — one of ``"cpu"`` or ``"gpu"``. """ schemas = Sdf.TokenListOp() current = scene_prim.GetMetadata("apiSchemas") or Sdf.TokenListOp() items = list(current.prependedItems) if current.prependedItems else [] if "PhysxSceneAPI" not in items: items.append("PhysxSceneAPI") schemas.prependedItems = items scene_prim.SetMetadata("apiSchemas", schemas) # Propagate scene query support from SimulationCfg so omni.physx creates # the scene with the correct query mode. OvPhysxCfg does not carry this field. sim_cfg = PhysicsManager._sim.cfg if PhysicsManager._sim is not None else None enable_sq = getattr(sim_cfg, "enable_scene_query_support", False) scene_prim.CreateAttribute("physxScene:enableSceneQuerySupport", Sdf.ValueTypeNames.Bool).Set(enable_sq) if cfg is not None: # OvPhysX answers the backend-agnostic determinism request with enhanced determinism. # This is best-effort: reproducibility is not verified end to end. scene_prim.CreateAttribute("physxScene:enableEnhancedDeterminism", Sdf.ValueTypeNames.Bool).Set( cfg.enable_enhanced_determinism or cfg.deterministic ) scene_prim.CreateAttribute("physxScene:enableExternalForcesEveryIteration", Sdf.ValueTypeNames.Bool).Set( cfg.enable_external_forces_every_iteration ) if device == "gpu": scene_prim.CreateAttribute("physxScene:enableGPUDynamics", Sdf.ValueTypeNames.Bool).Set(True) scene_prim.CreateAttribute("physxScene:broadphaseType", Sdf.ValueTypeNames.String).Set("GPU") if cfg is not None: for attr, val in [ ("gpuMaxRigidContactCount", cfg.gpu_max_rigid_contact_count), ("gpuMaxRigidPatchCount", cfg.gpu_max_rigid_patch_count), ("gpuFoundLostPairsCapacity", cfg.gpu_found_lost_pairs_capacity), ("gpuFoundLostAggregatePairsCapacity", cfg.gpu_found_lost_aggregate_pairs_capacity), ("gpuTotalAggregatePairsCapacity", cfg.gpu_total_aggregate_pairs_capacity), ("gpuCollisionStackSize", cfg.gpu_collision_stack_size), ]: scene_prim.CreateAttribute(f"physxScene:{attr}", Sdf.ValueTypeNames.UInt).Set(val)