Source code for isaaclab_physx.physics.physx_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

"""PhysX Manager for Isaac Lab.

This module manages PhysX physics simulation lifecycle, configuration, callbacks, and physics views.
"""

from __future__ import annotations

import glob
import logging
import os
import re
import time
import warnings
from collections.abc import Callable
from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING, Any, ClassVar

import torch
import warp as wp

import carb
import omni.kit.app
import omni.physics.tensors
import omni.physx
import omni.timeline
import omni.usd
from pxr import Sdf, Usd, UsdPhysics, UsdUtils

import isaaclab.sim as sim_utils
from isaaclab.physics import CallbackHandle, PhysicsEvent, PhysicsManager
from isaaclab.scene_data import SceneDataBackend, SceneDataFormat
from isaaclab.scene_data.deformable_discovery import (
    build_deformable_root_path_lookup,
    build_deformable_vertex_count_lookup,
    discover_deformables_on_stage,
    group_deformable_root_paths_for_views,
    resolve_deformable_root_path,
    resolve_deformable_vertex_count,
)
from isaaclab.utils.string import to_camel_case

if TYPE_CHECKING:
    from isaaclab.sim.simulation_context import SimulationContext

    from .physx_cfg import PhysxCfg

__all__ = ["IsaacEvents", "PhysxManager"]

logger = logging.getLogger(__name__)


class IsaacEvents(Enum):
    """Events dispatched during simulation lifecycle.

    Note: This enum is kept for backward compatibility. New code should use
    PhysicsEvent from physics_manager for cross-backend compatibility.
    """

    PHYSICS_WARMUP = "isaac.physics_warmup"
    SIMULATION_VIEW_CREATED = "isaac.simulation_view_created"
    PHYSICS_READY = "isaac.physics_ready"
    POST_RESET = "isaac.post_reset"
    PRIM_DELETION = "isaac.prim_deletion"
    PRE_PHYSICS_STEP = "isaac.pre_physics_step"
    POST_PHYSICS_STEP = "isaac.post_physics_step"
    TIMELINE_STOP = "isaac.timeline_stop"


_PHYSICS_EVENT_TO_ISAAC_EVENT: dict[PhysicsEvent, IsaacEvents] = {
    PhysicsEvent.MODEL_INIT: IsaacEvents.PHYSICS_WARMUP,
    PhysicsEvent.PHYSICS_READY: IsaacEvents.PHYSICS_READY,
    PhysicsEvent.STOP: IsaacEvents.TIMELINE_STOP,
}
_PHYSICS_EVENT_VALUE_TO_ISAAC_EVENT: dict[str, IsaacEvents] = {
    event.value: isaac_event for event, isaac_event in _PHYSICS_EVENT_TO_ISAAC_EVENT.items()
}


class AnimationRecorder:
    """Handles animation recording using PhysX PVD interface."""

    def __init__(self, sim_context: SimulationContext):
        self._sim = sim_context
        self._enabled = bool(sim_context.get_setting("/isaaclab/anim_recording/enabled"))
        self._started_at: float | None = None
        self._physx_pvd = None

        if self._enabled:
            self._start_time = sim_context.get_setting("/isaaclab/anim_recording/start_time")
            self._stop_time = sim_context.get_setting("/isaaclab/anim_recording/stop_time")
            self._setup_output_dir()

    def _setup_output_dir(self) -> None:
        """Initialize recording directory and PVD interface."""
        from omni.physxpvd.bindings import _physxPvd

        repo_path = os.path.join(carb.tokens.get_tokens_interface().resolve("${app}"), "..")
        timestamp = datetime.now().strftime("%Y_%m_%d_%H%M%S")
        self._output_dir = os.path.join(repo_path, "anim_recordings", timestamp).replace("\\", "/").rstrip("/") + "/"
        os.makedirs(self._output_dir, exist_ok=True)

        self._physx_pvd = _physxPvd.acquire_physx_pvd_interface()
        self._sim.set_setting("/persistent/physics/omniPvdOvdRecordingDirectory", self._output_dir)
        self._sim.set_setting("/physics/omniPvdOutputEnabled", True)

    @property
    def enabled(self) -> bool:
        return self._enabled

    def update(self) -> bool:
        """Update recording state. Returns True if recording finished."""
        if not self._enabled:
            return False
        if self._started_at is None:
            self._started_at = time.time()
        if time.time() - self._started_at > self._stop_time:
            self._finish()
            return True
        return False

    def _finish(self) -> None:
        """Finalize and export the recording."""
        logger.warning("[AnimationRecorder] Finishing recording. This may take a few minutes.")

        physx = omni.physx.get_physx_simulation_interface()
        physx.detach_stage()

        stage_path = os.path.join(self._output_dir, "stage_simulation.usdc")
        sim_utils.save_stage(stage_path, save_and_reload_in_place=False)

        ovd_files = [f for f in glob.glob(os.path.join(self._output_dir, "*.ovd")) if not f.endswith("tmp.ovd")]
        if ovd_files and self._physx_pvd:
            input_ovd = max(ovd_files, key=os.path.getctime)
            self._physx_pvd.ovd_to_usd_over_with_layer_creation(
                input_ovd,
                stage_path,
                self._output_dir,
                "baked_animation_recording.usda",
                self._start_time,
                self._stop_time,
                True,
                False,
            )
            self._update_usda_start_time(os.path.join(self._output_dir, "baked_animation_recording.usda"))

        self._sim.set_setting("/physics/omniPvdOutputEnabled", False)

    def _update_usda_start_time(self, file_path: str) -> None:
        """Patch the start time in the exported USDA file."""
        with open(file_path) as f:
            content = f.read()
        match = re.search(r"timeCodesPerSecond\s*=\s*(\d+)", content)
        if match:
            fps = int(match.group(1))
            new_start = int(self._start_time * fps)
            content = re.sub(r"startTimeCode\s*=\s*\d+", f"startTimeCode = {new_start}", content)
            with open(file_path, "w") as f:
                f.write(content)


class PhysxSceneDataBackend(SceneDataBackend):
    def __init__(self):
        self._simulation_view: omni.physics.tensors.SimulationView | None = None
        self._rigid_body_view: omni.physics.tensors.RigidBodyView | None = None
        self._volume_deformable_view: omni.physics.tensors.DeformableBodyView | None = None
        self._surface_deformable_view: omni.physics.tensors.DeformableBodyView | None = None
        self._scene_data = SceneDataFormat.Transform()
        self._points_data = SceneDataFormat.Points()
        self._geometry_paths: list[str] = []
        self._geometry_counts: list[int] = []
        self._merged_points: wp.array | None = None
        self._geometry_discovered: bool = False

    @property
    def simulation_view(self) -> omni.physics.tensors.SimulationView | None:
        return self._simulation_view

    @simulation_view.setter
    def simulation_view(self, simulation_view: omni.physics.tensors.SimulationView | None):
        self._simulation_view = simulation_view
        self._rigid_body_view = None
        self._volume_deformable_view = None
        self._surface_deformable_view = None
        self._geometry_discovered = False
        self._geometry_paths = []
        self._geometry_counts = []
        self._merged_points = None

    def get_rigid_body_view(self) -> omni.physics.tensors.RigidBodyView | None:
        """Lazily create a rigid body view covering all rigid bodies in the scene.

        Discovers exact rigid body prims by traversing USD, then compacts cloned
        environment paths into wildcard patterns. If a rigid body name is also
        used by a non-rigid prim, the exact path is kept to avoid PhysX resolving
        the wildcard to the non-rigid prim.
        """
        if self._rigid_body_view is not None:
            return self._rigid_body_view

        if self._simulation_view is None:
            return None

        stage: Usd.Stage = omni.usd.get_context().get_stage()
        if stage is None:
            return None

        rigid_body_paths: list[str] = []
        non_rigid_body_names: set[str] = set()
        for prim in stage.Traverse():
            if prim.IsA(UsdPhysics.Joint):
                continue
            prim_path = prim.GetPath().pathString
            if prim.HasAPI(UsdPhysics.RigidBodyAPI):
                rigid_body_paths.append(prim_path)
            elif re.search(r"/World/envs/env_\d+/", prim_path):
                non_rigid_body_names.add(prim_path.rsplit("/", 1)[-1])

        patterns: set[str] = set()
        exact_paths: list[str] = []
        for prim_path in rigid_body_paths:
            body_name = prim_path.rsplit("/", 1)[-1]
            if body_name in non_rigid_body_names:
                exact_paths.append(prim_path)
            else:
                patterns.add(re.sub(r"/World/envs/env_\d+", "/World/envs/env_*", prim_path))

        body_paths = [*sorted(patterns), *exact_paths]
        if not body_paths:
            return None

        self._rigid_body_view = self._simulation_view.create_rigid_body_view(body_paths)
        return self._rigid_body_view

    def _discover_deformable_geometry(self) -> None:
        """Discover stage deformables and create PhysX volume/surface views once."""
        if self._geometry_discovered:
            return
        self._geometry_discovered = True
        stage: Usd.Stage | None = omni.usd.get_context().get_stage()
        if stage is None or self._simulation_view is None:
            return

        entries = discover_deformables_on_stage(stage)
        if not entries:
            return

        path_to_count = build_deformable_vertex_count_lookup(entries)
        path_to_root = build_deformable_root_path_lookup(entries)
        path_to_type = {entry.root_path: entry.deformable_type for entry in entries}
        grouped_paths = group_deformable_root_paths_for_views(list(path_to_type.keys()), path_to_type)

        volume_patterns, exact_volume = grouped_paths["volume"]
        surface_patterns, exact_surface = grouped_paths["surface"]

        if volume_patterns or exact_volume:
            self._volume_deformable_view = self._simulation_view.create_volume_deformable_body_view(
                [*volume_patterns, *exact_volume]
            )
        if surface_patterns or exact_surface:
            self._surface_deformable_view = self._simulation_view.create_surface_deformable_body_view(
                [*surface_patterns, *exact_surface]
            )

        device = PhysicsManager._device or "cpu"
        self._geometry_paths = []
        self._geometry_counts = []
        for view in (self._volume_deformable_view, self._surface_deformable_view):
            if view is None or view._backend is None:
                continue
            max_nodes = int(view.max_simulation_nodes_per_body)
            for path in view.prim_paths:
                # Prefer USD-discovered unpadded counts over padded max_nodes so
                # SceneData ↔ shadow particle_q slices stay size-aligned.
                resolved = resolve_deformable_vertex_count(path, path_to_count, fallback=-1)
                if resolved < 0:
                    logger.warning(
                        "No USD vertex count for deformable path '%s'; using padded max_simulation_nodes_per_body=%d.",
                        path,
                        max_nodes,
                    )
                    count = max_nodes
                else:
                    count = min(int(resolved), max_nodes)
                # Views may report a child mesh; publish the discovered root so
                # create_geometry_mapping matches shadow entity root_path exactly.
                self._geometry_paths.append(resolve_deformable_root_path(path, path_to_root))
                self._geometry_counts.append(count)

        total_points = sum(self._geometry_counts)
        if total_points > 0:
            self._merged_points = wp.empty(total_points, dtype=wp.vec3f, device=device)

    def _refresh_merged_points(self) -> None:
        """Merge volume and surface deformable nodal positions into :attr:`points`."""
        from isaaclab.scene_data.geometry_points import pack_body_nodal_slices

        self._discover_deformable_geometry()
        if self._merged_points is None:
            self._points_data.points = None
            return

        write_offset = 0
        path_index = 0
        device = str(self._merged_points.device)
        for view in (self._volume_deformable_view, self._surface_deformable_view):
            if view is None or view._backend is None:
                continue
            nodal = view.get_simulation_nodal_positions().view(wp.vec3f).reshape((view.count, -1))
            view_counts = [self._geometry_counts[path_index + body_idx] for body_idx in range(view.count)]
            pack_body_nodal_slices(
                nodal,
                self._merged_points,
                view_counts,
                device=device,
                dest_base_offset=write_offset,
            )
            write_offset += sum(int(count) for count in view_counts)
            path_index += view.count
        self._points_data.points = self._merged_points

    @property
    def points(self) -> SceneDataFormat.Points:
        """Return flattened PhysX deformable nodal positions."""
        self._refresh_merged_points()
        return self._points_data

    @property
    def point_count(self) -> int:
        """Return the total unpadded PhysX deformable nodal count."""
        self._discover_deformable_geometry()
        return sum(self._geometry_counts)

    @property
    def geometry_paths(self) -> list[str]:
        """Return one USD prim path per PhysX deformable body instance."""
        self._discover_deformable_geometry()
        return self._geometry_paths

    @property
    def geometry_counts(self) -> list[int]:
        """Return the unpadded nodal count for each PhysX deformable body."""
        self._discover_deformable_geometry()
        return self._geometry_counts

    @property
    def transforms(self) -> SceneDataFormat.Transform:
        """Return the current PhysX rigid body transforms as :class:`SceneDataFormat.Transform`."""
        if view := self.get_rigid_body_view():
            self._scene_data.transforms = view.get_transforms().view(wp.transformf)
        return self._scene_data

    @property
    def transform_count(self) -> int:
        """Return the number of rigid body transforms in the PhysX sim."""
        if view := self.get_rigid_body_view():
            return view.count
        return 0

    @property
    def transform_paths(self) -> list[str]:
        """Return the prim paths for each rigid body transform."""
        if view := self.get_rigid_body_view():
            return list(view.prim_paths)
        return []


[docs] class PhysxManager(PhysicsManager): """Manages PhysX physics simulation lifecycle. Lifecycle: initialize() -> reset() -> step() (repeated) -> close() """ _cfg: ClassVar[PhysxCfg | None] = None _timeline: ClassVar[omni.timeline.ITimeline] = omni.timeline.get_timeline_interface() _event_bus: ClassVar[carb.eventdispatcher.IEventDispatcher] = carb.eventdispatcher.get_eventdispatcher() _scene_data_backend: ClassVar[PhysxSceneDataBackend | None] = None _view: ClassVar[omni.physics.tensors.SimulationView | None] = None _view_warp: ClassVar[omni.physics.tensors.SimulationView | None] = None _warmup_needed: ClassVar[bool] = True _view_created: ClassVar[bool] = False _assets_loaded: ClassVar[bool] = True _stage_id: ClassVar[int] = -1 _subscriptions: ClassVar[dict[str, Any]] = {} _fabric: ClassVar[Any] = None _update_fabric: ClassVar[Callable[[float, float], None] | None] = None _anim_recorder: ClassVar[AnimationRecorder | None] = None _callback_exception: ClassVar[Exception | None] = None class _SimManagerStub: """No-op stub for Isaac Sim APIs expecting simulation_manager_interface.""" def reset(self) -> None: pass def get_simulation_time(self) -> float: return omni.physx.get_physx_interface().get_simulation_time() def is_simulating(self) -> bool: return omni.physx.get_physx_interface().is_simulating() def __getattr__(self, name: str) -> Callable[..., Any]: return lambda *a, **kw: None # field stubs for Isaac Sim APIs expecting simulation_manager_interface _simulation_manager_interface: ClassVar[_SimManagerStub] = _SimManagerStub() _physics_scene_apis: ClassVar[dict[str, Any]] = {} _message_bus = _event_bus @classmethod def initialize(cls, sim_context: SimulationContext) -> None: """Initialize the physics manager.""" from isaaclab_physx import _patch_isaacsim_simulation_manager, _subscribe_to_simulation_manager_enable _subscribe_to_simulation_manager_enable() _patch_isaacsim_simulation_manager() from isaaclab.sim.utils.stage import get_current_stage_id super().initialize(sim_context) cls._stage_id = get_current_stage_id() cls._setup_subscriptions() cls._configure_physics() cls._load_fabric() cls._anim_recorder = AnimationRecorder(sim_context) cls._scene_data_backend = PhysxSceneDataBackend() # force update cycle to apply dt sim = PhysicsManager._sim sim.set_setting("/app/player/playSimulations", False) # type: ignore[union-attr] omni.kit.app.get_app().update() sim.set_setting("/app/player/playSimulations", True) # type: ignore[union-attr] # Register the headless video pump as a render callback so it fires after each # visualizer step without depending on the now-deleted recording_hooks module. from isaaclab_physx.renderers.isaac_rtx_renderer_utils import ( # noqa: PLC0415 pump_kit_app_for_headless_video_render_if_needed, ) _sim = PhysicsManager._sim _sim.add_render_callback( "physx_headless_video_pump", lambda _: pump_kit_app_for_headless_video_render_if_needed(_sim), order=-10, ) @classmethod def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any: """Fix and normalize an articulation root for the PhysX 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 @classmethod def reset(cls, soft: bool = False) -> None: """Reset the physics simulation.""" if not soft: # Ensure views are created (warmup only happens once per stage) if cls._view is None: cls._warmup_and_create_views() # Deterministic lifecycle dispatch for backend-agnostic callbacks. # This avoids relying on asynchronous event-bus ordering during env construction. cls.dispatch_event(PhysicsEvent.PHYSICS_READY, payload={}) # Legacy IsaacEvents dispatch for callbacks registered directly on IsaacEvents. cls._event_bus.dispatch_event(IsaacEvents.PHYSICS_READY.value, payload={}) device = PhysicsManager._device if "cuda" in device: torch.cuda.set_device(device) if cls._view is not None: cls._view._backend.initialize_kinematic_bodies() cls.raise_callback_exception_if_any() @classmethod def forward(cls) -> None: """Update articulation kinematics and fabric for rendering.""" sim = PhysicsManager._sim if cls._fabric is not None and cls._update_fabric is not None: if cls._view is not None and sim is not None and sim.is_playing(): cls._view.update_articulations_kinematic() cls._update_fabric(0.0, 0.0) @classmethod def get_scene_data_backend(cls) -> SceneDataBackend: """Return the SceneDataBackend for the SceneDataProvider.""" return cls._scene_data_backend @classmethod def video_capture_backend(cls) -> str: """Kit/Replicator perspective video capture.""" return "kit" @classmethod def step(cls) -> None: """Step the physics simulation.""" sim = PhysicsManager._sim if sim is None: return if cls._anim_recorder and cls._anim_recorder.enabled and cls._anim_recorder.update(): logger.warning("Animation recording finished. Shutting down.") omni.kit.app.get_app().shutdown() return physx_sim = omni.physx.get_physx_simulation_interface() physx_sim.simulate(sim.cfg.dt, 0.0) physx_sim.fetch_results() device = PhysicsManager._device if "cuda" in device: torch.cuda.set_device(device) cls.raise_callback_exception_if_any() @classmethod def play(cls) -> None: """Start or resume the timeline.""" cls._timeline.play() # Pump events so timeline callbacks fire synchronously omni.kit.app.get_app().update() cls._sync_fabric_after_resume() @classmethod def pause(cls) -> None: """Pause the timeline.""" cls._timeline.pause() # Pump events so timeline callbacks fire synchronously omni.kit.app.get_app().update() @classmethod def stop(cls) -> None: """Stop the timeline.""" cls._timeline.stop() # Pump events so timeline callbacks fire synchronously omni.kit.app.get_app().update() @classmethod def wait_for_playing(cls) -> None: """Block until the timeline is playing, keeping the GUI responsive. After resume, forces a fabric re-sync so articulation meshes unfreeze. See: https://github.com/isaac-sim/IsaacLab/issues/4279 """ if cls._timeline.is_playing(): return app = omni.kit.app.get_app() while not cls._timeline.is_playing(): app.update() if cls._timeline.is_stopped(): break cls._sync_fabric_after_resume() @classmethod def _sync_fabric_after_resume(cls) -> None: """Force Fabric to show current articulation transforms after timeline resume.""" if cls._timeline.is_stopped(): return # detach/attach resets the FabricManager, then immediately push current # poses so the first render after resume shows correct state. cls._re_sync_fabric() if cls._view is not None: cls._view.update_articulations_kinematic() if cls._update_fabric is not None: cls._update_fabric(0.0, 0.0) @classmethod def close(cls) -> None: """Clean up physics resources.""" # Detach PhysX from the stage FIRST to prevent shape/actor cleanup errors # This disconnects PhysX from USD before any deletion events are fired if physx_sim := omni.physx.get_physx_simulation_interface(): physx_sim.detach_stage() # Pump the app to flush pending PhysX cleanup operations omni.kit.app.get_app().update() # Now invalidate views (they're already disconnected from PhysX) cls._invalidate_views() cls._subscriptions.clear() # Notify listeners that prims are being deleted (safe now since PhysX is detached) cls._event_bus.dispatch_event(IsaacEvents.PRIM_DELETION.value, payload={"prim_path": "/"}) cls._fabric = None cls._update_fabric = None cls._anim_recorder = None cls._warmup_needed = True cls._view_created = False cls._assets_loaded = True cls._callback_exception = None super().close()
[docs] @classmethod def get_physics_sim_view(cls) -> omni.physics.tensors.SimulationView | None: return cls._view
@classmethod def get_physics_sim_device(cls) -> str: """Get the physics simulation device (Isaac Sim compatibility alias).""" return PhysicsManager.get_device() @classmethod def assets_loading(cls) -> bool: return not cls._assets_loaded @classmethod def store_callback_exception(cls, exception: Exception) -> None: """Store an exception from a callback to be raised later. Omniverse event systems catch exceptions internally. Use this to store exceptions that should be surfaced after the event dispatch completes. """ if cls._callback_exception is None: cls._callback_exception = exception @classmethod def raise_callback_exception_if_any(cls) -> None: """Raise any stored callback exception and clear it. Call this after operations that may trigger callbacks (reset, step, etc.) to propagate exceptions from Omniverse event callbacks. """ if cls._callback_exception is not None: exc = cls._callback_exception cls._callback_exception = None raise exc @classmethod def register_callback( cls, callback: Callable, event: PhysicsEvent | IsaacEvents, order: int = 0, name: str | None = None, wrap_weak_ref: bool = True, ) -> CallbackHandle: """Register a callback. Accepts both PhysicsEvent and IsaacEvents.""" if isinstance(event, IsaacEvents): cid = cls._callback_id cls._callback_id += 1 cb = cls._wrap_weak_ref(callback) if wrap_weak_ref else callback sub = cls._subscribe_isaac(cb, event, order, name) cls._callbacks[cid] = (event, cb, order, name, sub) return CallbackHandle(cid, cls) return super().register_callback(callback, event, order, name, wrap_weak_ref) @classmethod def _subscribe_to_event( cls, callback_id: int, callback: Callable, event: PhysicsEvent, order: int, name: str | None ) -> Any: """Subscribe to PhysX events. Maps PhysicsEvent → IsaacEvents.""" isaac_event = _PHYSICS_EVENT_TO_ISAAC_EVENT.get(event) if isaac_event is None: isaac_event = _PHYSICS_EVENT_VALUE_TO_ISAAC_EVENT.get(getattr(event, "value", event)) return cls._subscribe_isaac(callback, isaac_event, order, name) if isaac_event else None @classmethod def _unsubscribe_from_event(cls, callback_id: int, event: PhysicsEvent | IsaacEvents, subscription: Any) -> None: """Unsubscribe from PhysX/Isaac events.""" if subscription is not None and hasattr(subscription, "unsubscribe"): subscription.unsubscribe() @classmethod def _subscribe_isaac(cls, callback: Callable, event: IsaacEvents, order: int, name: str | None) -> Any: """Subscribe to an IsaacEvents event.""" def guarded(cb: Callable) -> Callable: def wrapper(dt: float) -> Any: return cb(dt) if cls._view_created else None return wrapper if event in ( IsaacEvents.PHYSICS_WARMUP, IsaacEvents.PHYSICS_READY, IsaacEvents.POST_RESET, IsaacEvents.SIMULATION_VIEW_CREATED, IsaacEvents.PRIM_DELETION, ): return cls._event_bus.observe_event(event_name=event.value, order=order, on_event=callback) elif event == IsaacEvents.POST_PHYSICS_STEP: return omni.physx.get_physx_interface().subscribe_physics_on_step_events( guarded(callback), pre_step=False, order=order ) elif event == IsaacEvents.PRE_PHYSICS_STEP: return omni.physx.get_physx_interface().subscribe_physics_on_step_events( guarded(callback), pre_step=True, order=order ) elif event == IsaacEvents.TIMELINE_STOP: return cls._timeline.get_timeline_event_stream().create_subscription_to_pop_by_type( int(omni.timeline.TimelineEventType.STOP), callback, order=order, name=name ) return None @classmethod def _setup_subscriptions(cls) -> None: """Subscribe to timeline events.""" if "play" in cls._subscriptions: return stream = cls._timeline.get_timeline_event_stream() cls._subscriptions["play"] = stream.create_subscription_to_pop_by_type( int(omni.timeline.TimelineEventType.PLAY), cls._on_play ) cls._subscriptions["stop"] = stream.create_subscription_to_pop_by_type( int(omni.timeline.TimelineEventType.STOP), cls._on_stop ) if "stage_open" not in cls._subscriptions: ctx = omni.usd.get_context() cls._subscriptions["stage_open"] = cls._event_bus.observe_event( event_name=ctx.stage_event_name(omni.usd.StageEventType.OPENED), on_event=cls._on_stage_open ) @classmethod def _configure_physics(cls) -> None: """Apply all physics settings.""" # Access base class variables since that's where initialize() sets them sim = PhysicsManager._sim cfg = PhysicsManager._cfg if sim is None or cfg is None: return device = sim.device # global settings (via SettingsManager) sim.set_setting("/persistent/omnihydra/useSceneGraphInstancing", True) # type: ignore[union-attr] sim.set_setting("/physics/physxDispatcher", True) # type: ignore[union-attr] sim.set_setting("/physics/disableContactProcessing", True) # type: ignore[union-attr] sim.set_setting("/physics/collisionConeCustomGeometry", False) # type: ignore[union-attr] sim.set_setting("/physics/collisionCylinderCustomGeometry", False) # type: ignore[union-attr] sim.set_setting("/physics/autoPopupSimulationOutputWindow", False) # type: ignore[union-attr] # device setup (set on PhysicsManager so PhysicsManager.get_device() works) is_gpu = "cuda" in device if is_gpu: parts = device.split(":") cuda_device = sim.get_setting("/physics/cudaDevice") # type: ignore[union-attr] device_id = int(parts[1]) if len(parts) > 1 else max(0, int(cuda_device) if cuda_device is not None else 0) sim.set_setting("/physics/cudaDevice", device_id) # type: ignore[union-attr] sim.set_setting("/physics/suppressReadback", True) # type: ignore[union-attr] PhysicsManager._device = f"cuda:{device_id}" else: sim.set_setting("/physics/cudaDevice", -1) # type: ignore[union-attr] sim.set_setting("/physics/suppressReadback", False) # type: ignore[union-attr] PhysicsManager._device = "cpu" # physx scene api (use sim.cfg for shared parameters like physics_prim_path, dt, physics_material) # apply schema and set attributes by name sim_cfg = sim.cfg stage = sim.stage scene_prim = stage.GetPrimAtPath(sim_cfg.physics_prim_path) if "PhysxSceneAPI" not in scene_prim.GetAppliedSchemas(): scene_prim.AddAppliedSchema("PhysxSceneAPI") # timestep and frame rate steps_per_sec = int(1.0 / sim_cfg.dt) sim_utils.safe_set_attribute_on_usd_prim( scene_prim, "physxScene:timeStepsPerSecond", steps_per_sec, camel_case=False ) render_interval = max(sim_cfg.render_interval, 1) sim.set_setting("/persistent/simulation/minFrameRate", steps_per_sec // render_interval) # type: ignore[union-attr] # gpu dynamics sim_utils.safe_set_attribute_on_usd_prim( scene_prim, "physxScene:broadphaseType", "GPU" if is_gpu else "MBP", camel_case=False ) sim_utils.safe_set_attribute_on_usd_prim(scene_prim, "physxScene:enableGPUDynamics", is_gpu, camel_case=False) # ccd (not supported on gpu) enable_ccd = cfg.enable_ccd and not is_gpu if cfg.enable_ccd and is_gpu: logger.warning("CCD disabled when GPU dynamics is enabled.") sim_utils.safe_set_attribute_on_usd_prim(scene_prim, "physxScene:enableCCD", enable_ccd, camel_case=False) # solver sim_utils.safe_set_attribute_on_usd_prim( scene_prim, "physxScene:solverType", "TGS" if cfg.solver_type == 1 else "PGS", camel_case=False ) scene_prim.CreateAttribute("physxScene:solveArticulationContactLast", Sdf.ValueTypeNames.Bool).Set( cfg.solve_articulation_contact_last ) # scene query support: forward SimulationCfg value and override for GUI if hasattr(sim_cfg, "enable_scene_query_support"): cfg.enable_scene_query_support = sim_cfg.enable_scene_query_support if bool(sim.get_setting("/isaaclab/has_gui")): cfg.enable_scene_query_support = True # apply remaining cfg attributes to scene (physxScene:*) skip = { "solver_type", "enable_ccd", "solve_articulation_contact_last", "dt", "device", "render_interval", "gravity", "physics_prim_path", "use_fabric", "physics_material", "class_type", } for key, value in cfg.to_dict().items(): # type: ignore if key not in skip: attr_name = "bounce_threshold" if key == "bounce_threshold_velocity" else key sim_utils.safe_set_attribute_on_usd_prim( scene_prim, f"physxScene:{to_camel_case(attr_name, 'cC')}", value, camel_case=False, ) # default physics material (from SimulationCfg, or create default if None) physics_material = sim_cfg.physics_material if physics_material is None: from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialBaseCfg physics_material = RigidBodyMaterialBaseCfg() mat_path = f"{sim_cfg.physics_prim_path}/defaultMaterial" physics_material.func(mat_path, physics_material) sim_utils.bind_physics_material(sim_cfg.physics_prim_path, mat_path) # warnings if not cfg.enable_external_forces_every_iteration: warning_message = ( "PhysxCfg.enable_external_forces_every_iteration is deprecated and will be removed in a future " "PhysX release. External forces are applied every iteration by default; remove this override." ) if cfg.solver_type == 1: warning_message += " Disabling this behavior with the TGS solver may cause noisy velocities." warnings.warn( warning_message, DeprecationWarning, stacklevel=2, ) if not cfg.enable_stabilization and sim_cfg.dt > 0.0333: logger.warning("Large timestep without stabilization may cause physics issues.") @classmethod def _load_fabric(cls) -> None: """Load fabric interface if enabled.""" sim = PhysicsManager._sim cfg = PhysicsManager._cfg if sim is None or cfg is None: return use_fabric = sim.cfg.use_fabric ext_mgr = omni.kit.app.get_app().get_extension_manager() # enable/disable fabric extension if use_fabric: if not ext_mgr.is_extension_enabled("omni.physx.fabric"): ext_mgr.set_extension_enabled_immediate("omni.physx.fabric", True) from omni.physxfabric import get_physx_fabric_interface cls._fabric = get_physx_fabric_interface() cls._update_fabric = getattr(cls._fabric, "force_update", cls._fabric.update) else: if ext_mgr.is_extension_enabled("omni.physx.fabric"): ext_mgr.set_extension_enabled_immediate("omni.physx.fabric", False) cls._fabric = None cls._update_fabric = None # disable usd sync when fabric is enabled (via SettingsManager) for key in [ "updateToUsd", "updateParticlesToUsd", "updateVelocitiesToUsd", "updateForceSensorsToUsd", "updateResidualsToUsd", ]: sim.set_setting(f"/physics/{key}", not use_fabric) # type: ignore[union-attr] sim.set_setting("/isaaclab/fabric_enabled", use_fabric) # type: ignore[union-attr] sim.set_setting("/physics/visualizationDisplaySimulationOutput", False) # type: ignore[union-attr] @classmethod def _re_sync_fabric(cls) -> None: """Force the PhysX fabric extension to re-synchronize after a pause/resume transition. Starting with PhysX fabric 107.3.21 (Isaac Sim 5.1), the FabricManager skips writing initial articulation poses to fabric on subsequent resumes, causing articulation meshes to freeze visually while physics continues to run. The workaround detaches and re-attaches the USD stage on the fabric interface, forcing the FabricManager to fully reinitialize and write transforms into fabric so that Hydra picks them up. """ if cls._fabric is None: return sim = PhysicsManager._sim if sim is None: return stage = sim.stage if stage is None: return stage_id = UsdUtils.StageCache.Get().GetId(stage).ToLongInt() if stage_id <= 0: return try: cls._fabric.detach_stage() except Exception: logger.warning("Failed to detach fabric stage during re-sync. Articulation visuals may be stale.") return try: cls._fabric.attach_stage(stage_id) except Exception: logger.error( "Could not re-attach fabric stage. Articulation visuals will be broken until next reset.", exc_info=True, ) @classmethod def _warmup_and_create_views(cls) -> None: """Warm-start physics and create simulation views.""" if not cls._warmup_needed: return # Get stage ID first (needed for both warmup and view creation) from isaaclab.sim.utils.stage import get_current_stage_id stage_id = get_current_stage_id() is_gpu = "cuda" in PhysicsManager.get_device() physx = omni.physx.get_physx_interface() physx_sim = omni.physx.get_physx_simulation_interface() # Attach stage to PhysX BEFORE loading/starting - only needed for GPU pipeline. # For CPU, the old SimulationManager never called attach_stage() explicitly. # Calling attach_stage() + force_load_physics_from_usd() together causes a # double-initialization that corrupts the CPU broadphase (MBP) collision setup, # causing objects to fall through surfaces non-deterministically. if is_gpu: physx_sim.attach_stage(stage_id) # warmup physx physx.force_load_physics_from_usd() physx.start_simulation() physx.update_simulation(cls.get_physics_dt(), 0.0) physx_sim.fetch_results() cls._event_bus.dispatch_event(IsaacEvents.PHYSICS_WARMUP.value, payload={}) cls._warmup_needed = False if cls._view_created: return # Create tensor views cls._view = omni.physics.tensors.create_simulation_view("warp", stage_id=stage_id) cls._view_warp = omni.physics.tensors.create_simulation_view("warp", stage_id=stage_id) if cls._view: cls._view.set_subspace_roots("/") if cls._view_warp: cls._view_warp.set_subspace_roots("/") # Final update after view creation physx.update_simulation(cls.get_physics_dt(), 0.0) cls._view_created = True cls._scene_data_backend.simulation_view = cls._view cls._event_bus.dispatch_event(IsaacEvents.SIMULATION_VIEW_CREATED.value, payload={}) cls.dispatch_event(PhysicsEvent.PHYSICS_READY, payload={}) cls._event_bus.dispatch_event(IsaacEvents.PHYSICS_READY.value, payload={}) @classmethod def _invalidate_views(cls) -> None: """Invalidate and clear simulation views.""" for view in (cls._view, cls._view_warp): if view: view.invalidate() cls._view = None cls._view_warp = None cls._view_created = False @classmethod def _on_play(cls, event: Any) -> None: sim = PhysicsManager._sim if sim is not None and sim.get_setting("/app/player/playSimulations"): # type: ignore[union-attr] cls._warmup_and_create_views() @classmethod def _on_stop(cls, event: Any) -> None: cls._warmup_needed = True cls._invalidate_views() @classmethod def _on_stage_open(cls, event: Any) -> None: from isaaclab.sim.utils.stage import get_current_stage, get_current_stage_id # Guard against stage open events when stage is not yet valid stage = get_current_stage() if stage is None or not stage.GetRootLayer(): return try: new_stage_id = get_current_stage_id() except Exception: # Stage may not be ready for caching yet return if new_stage_id == cls._stage_id: return cls._stage_id = new_stage_id cls._callbacks.clear() cls._assets_loaded = True def on_loading(e: Any) -> None: cls._assets_loaded = False def on_loaded(e: Any) -> None: cls._assets_loaded = True ctx = omni.usd.get_context() cls._subscriptions["assets_loading"] = cls._event_bus.observe_event( event_name=ctx.stage_event_name(omni.usd.StageEventType.ASSETS_LOADING), on_event=on_loading ) cls._subscriptions["assets_loaded"] = cls._event_bus.observe_event( event_name=ctx.stage_event_name(omni.usd.StageEventType.ASSETS_LOADED), on_event=on_loaded )