Source code for isaaclab.physics.physics_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

"""Base class for physics managers with unified callback system."""

from __future__ import annotations

import logging
import weakref
from abc import ABC, abstractmethod
from collections.abc import Callable
from enum import Enum
from typing import TYPE_CHECKING, Any, ClassVar

from isaaclab.sim.utils.stage import get_current_stage
from isaaclab.utils._device import set_cuda_device

if TYPE_CHECKING:
    from isaaclab.scene_data import SceneDataBackend
    from isaaclab.sim.simulation_context import SimulationContext

logger = logging.getLogger(__name__)


[docs] class PhysicsEvent(Enum): """Physics simulation lifecycle events. These are general events that apply across all physics backends. Backend-specific events (e.g., PhysX step events, timeline events) are handled by the respective manager classes via their own event enums (e.g., IsaacEvents). Lifecycle order: MODEL_INIT -> PHYSICS_READY -> STOP """ MODEL_INIT = "model_init" """Physics model is being constructed. Fired during scene building, before simulation can run. Use this to register physics representations (rigid bodies, joints, constraints) with the solver. """ PHYSICS_READY = "physics_ready" """Physics is initialized and queryable. Fired after all physics data structures are created and the simulation is ready to step. Assets can now read initial state (positions, velocities). """ STOP = "stop" """Simulation is stopping."""
[docs] class CallbackHandle: """Handle for a registered callback, allowing deregistration."""
[docs] def __init__(self, callback_id: int, manager: type[PhysicsManager]): self._id = callback_id self._manager = manager
@property def id(self) -> int: return self._id def deregister(self) -> None: """Remove this callback from the manager.""" self._manager.deregister_callback(self._id)
[docs] class PhysicsManager(ABC): """Abstract base class for physics simulation managers. Physics managers handle the lifecycle of a physics simulation backend, including initialization, stepping, and cleanup. This base class provides: - Unified callback management system - Common state variables (_sim, _cfg, _device) - Default accessor implementations Lifecycle: initialize() -> reset() -> step() (repeated) -> close() """ _sim: ClassVar[SimulationContext | None] = None _cfg: ClassVar[Any] = None _device: ClassVar[str] = "cuda:0" _sim_time: ClassVar[float] = 0.0 _callbacks: ClassVar[dict[int, tuple[Any, Callable, int, str | None, Any]]] = {} _callback_id: ClassVar[int] = 0 views: ClassVar[dict[tuple[type, str], Any]] = {} @classmethod def _prepare_stage_creation(cls) -> None: """Perform backend-specific setup required before the USD stage is created.""" pass @classmethod def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any: """Ensure that an articulation root has one enabled world fixed joint. The base implementation leaves the root in place. Backends whose parser requires a different root topology may relocate it and return the resulting root prim. Args: articulation_prim: The articulation-root prim to fix. stage: The stage containing the prim. Defaults to the current stage. Returns: The articulation-root prim after backend normalization. Raises: NotImplementedError: If a new joint is needed and the root is not a rigid body. """ # Keep these imports local. Hoisting the isaaclab.sim ones closes a real import cycle: # isaaclab.physics -> sim.schemas.schemas -> sim.utils.prims -> sim.utils.queries -> # sim.simulation_context -> isaaclab.physics (partially initialized). Keeping pxr local # also keeps USD out of the config-definition path, which env configs import through # managers.manager_base before the simulation app starts. from pxr import UsdPhysics # noqa: PLC0415 from isaaclab.sim.schemas.schemas import create_world_fixed_joint # noqa: PLC0415 from isaaclab.sim.utils import find_global_fixed_joint_prim # noqa: PLC0415 if stage is None: stage = get_current_stage() root_path = articulation_prim.GetPath().pathString joint = find_global_fixed_joint_prim(root_path, stage=stage) if joint is not None: joint.GetJointEnabledAttr().Set(True) return articulation_prim if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): raise NotImplementedError(f"Cannot fix non-rigid articulation root '{root_path}'.") create_world_fixed_joint(articulation_prim, stage) return articulation_prim @staticmethod def _relocate_articulation_root( articulation_prim: Any, companion_schema: str, companion_namespace: str, ) -> Any: """Move root-bearing schemas and authored properties to the root link's parent.""" # Keep pxr local: this module is imported while environment configs load (via the manager # classes), and config loading must not pull USD/omni modules before the simulation app # starts. from pxr import Usd, UsdPhysics # noqa: PLC0415 new_root = articulation_prim.GetParent() if new_root.HasAPI(UsdPhysics.ArticulationRootAPI): raise RuntimeError( f"Cannot relocate '{articulation_prim.GetPath()}' to existing articulation root '{new_root.GetPath()}'." ) # Keep this import local for the same reason as the pxr imports above. from isaaclab.sim.schemas._backend_hooks import _articulation_root_companion_namespace # noqa: PLC0415 registry = Usd.SchemaRegistry() root_schema = UsdPhysics.Tokens.PhysicsArticulationRootAPI schemas_to_move = [] for schema_name in articulation_prim.GetPrimTypeInfo().GetAppliedAPISchemas(): definition = registry.FindAppliedAPIPrimDefinition(schema_name) companion_namespace_override = _articulation_root_companion_namespace(schema_name) if schema_name == companion_schema: properties = list(articulation_prim.GetAuthoredPropertiesInNamespace(companion_namespace)) elif companion_namespace_override is not None: # a backend-registered schema, possibly an unregistered token the registry cannot # describe, so take the namespace the backend declared for it properties = list(articulation_prim.GetAuthoredPropertiesInNamespace(companion_namespace_override)) elif schema_name == root_schema or ( definition is not None and root_schema in definition.GetAppliedAPISchemas() ): properties = [] if definition is not None: for property_name in definition.GetPropertyNames(): prop = articulation_prim.GetProperty(property_name) if prop and prop.IsAuthored(): properties.append(prop) else: continue schemas_to_move.append((schema_name, properties)) for schema_name, properties in schemas_to_move: if not new_root.AddAppliedSchema(schema_name): raise RuntimeError(f"Failed to apply '{schema_name}' to '{new_root.GetPath()}'.") for prop in properties: if not prop.FlattenTo(new_root): raise RuntimeError(f"Failed to move '{prop.GetPath()}' to '{new_root.GetPath()}'.") for schema_name, _ in schemas_to_move: if not articulation_prim.RemoveAppliedSchema(schema_name): raise RuntimeError(f"Failed to remove '{schema_name}' from '{articulation_prim.GetPath()}'.") if articulation_prim.HasAPI(UsdPhysics.ArticulationRootAPI) or not new_root.HasAPI( UsdPhysics.ArticulationRootAPI ): raise RuntimeError( f"Failed to relocate articulation root '{articulation_prim.GetPath()}' to '{new_root.GetPath()}'." ) return new_root @classmethod def register_callback( cls, callback: Callable[[Any], None], event: PhysicsEvent, order: int = 0, name: str | None = None, wrap_weak_ref: bool = True, ) -> CallbackHandle: """Register a callback for a physics event. Args: callback: The callback function. Receives event payload as argument. event: The event to listen for. order: Priority order (lower = earlier). Default 0. name: Optional name for debugging. wrap_weak_ref: If True, wrap bound methods with weak references to prevent preventing garbage collection. Default True. Returns: CallbackHandle that can be used to deregister the callback. Example: >>> def on_physics_ready(payload): ... print("Physics is ready!") >>> handle = PhysxManager.register_callback(on_physics_ready, PhysicsEvent.PHYSICS_READY) >>> # Later, to remove: >>> handle.deregister() """ cid = cls._callback_id cls._callback_id += 1 if wrap_weak_ref: callback = cls._wrap_weak_ref(callback) subscription = cls._subscribe_to_event(cid, callback, event, order, name) cls._callbacks[cid] = (event, callback, order, name, subscription) return CallbackHandle(cid, cls) @classmethod def deregister_callback(cls, callback_id: int | CallbackHandle) -> None: """Remove a registered callback. Args: callback_id: The ID or CallbackHandle returned by register_callback(). """ cid = callback_id.id if isinstance(callback_id, CallbackHandle) else callback_id if cid not in cls._callbacks: return event, callback, order, name, subscription = cls._callbacks.pop(cid) cls._unsubscribe_from_event(cid, event, subscription) @classmethod def dispatch_event(cls, event: PhysicsEvent, payload: Any = None) -> None: """Dispatch an event to all registered callbacks. This is the default implementation using simple callback lists. Subclasses may override or extend with platform-specific dispatch. Args: event: The event to dispatch. payload: Optional data to pass to callbacks. """ matching = [(cid, cb, order) for cid, (ev, cb, order, name, sub) in cls._callbacks.items() if ev == event] matching.sort(key=lambda x: x[2]) for _, callback, _ in matching: callback(payload) @classmethod def clear_callbacks(cls) -> None: """Remove all registered callbacks. Do NOT reset ``_callback_id`` — handle IDs must remain monotonically unique across the lifetime of the process. Resetting the counter would let a future :meth:`register_callback` hand out an ID that an old, still-alive :class:`CallbackHandle` (e.g. on a sensor that has not been garbage-collected yet) holds, so when the old object eventually finalizes its ``__del__`` would deregister the new callback. This bit ovphysx's kitless multi-context tests where two ``InteractiveScene``s are created in sequence: the first scene's sensor would post-GC deregister the second scene's ``_initialize_callback`` by ID collision, leaving the second sensor forever uninitialized. """ for cid in list(cls._callbacks.keys()): cls.deregister_callback(cid) cls._callbacks.clear() @classmethod def _wrap_weak_ref(cls, callback: Callable) -> Callable: """Wrap bound methods with weak references to prevent leaks. Args: callback: The callback to wrap. Returns: Wrapped callback if it's a bound method, otherwise original. """ owner = getattr(callback, "__self__", None) if owner is not None: obj_ref = weakref.ref(owner) method_name = callback.__name__ def weak_callback(payload: Any) -> Any: obj = obj_ref() if obj is None: return None return getattr(obj, method_name)(payload) return weak_callback return callback @classmethod def _subscribe_to_event( cls, callback_id: int, callback: Callable, event: PhysicsEvent, order: int, name: str | None, ) -> Any: """Subscribe to a platform-specific event. Override in subclasses to integrate with platform event systems (e.g., Omniverse event bus, timeline events). Args: callback_id: Unique ID for this callback. callback: The callback function. event: The event to subscribe to. order: Priority order. name: Optional name. Returns: Platform-specific subscription object (stored for cleanup). """ return None @classmethod def _unsubscribe_from_event( cls, callback_id: int, event: PhysicsEvent, subscription: Any, ) -> None: """Unsubscribe from a platform-specific event. Override in subclasses to clean up platform subscriptions. Args: callback_id: The callback ID being removed. event: The event that was subscribed to. subscription: The subscription object from _subscribe_to_event(). """ pass @classmethod @abstractmethod def initialize(cls, sim_context: SimulationContext) -> None: """Initialize the physics manager with simulation context. Subclasses should call super().initialize() first, then do backend-specific setup. Args: sim_context: Parent simulation context. """ # Set on PhysicsManager explicitly so PhysicsManager.get_*() works # regardless of which subclass is active (Python class vars are per-class) PhysicsManager._sim = sim_context PhysicsManager._cfg = sim_context.cfg.physics PhysicsManager._device = sim_context.cfg.device PhysicsManager._sim_time = 0.0 # Synchronize the process-wide CUDA device before backend-specific # initialization allocates state. PyTorch must select the device before # Warp so that both runtimes retain the same primary CUDA context. if "cuda" in PhysicsManager._device: set_cuda_device(PhysicsManager._device) @classmethod @abstractmethod def reset(cls, soft: bool = False) -> None: """Reset physics simulation. Args: soft: If True, skip full reinitialization. """ pass @classmethod @abstractmethod def forward(cls) -> None: """Update kinematics without stepping physics (for rendering).""" pass @classmethod @abstractmethod def get_scene_data_backend(cls) -> SceneDataBackend: """Return the SceneDataBackend for the SceneDataProvider.""" pass @classmethod @abstractmethod def step(cls) -> None: """Step physics simulation by one timestep (physics only, no rendering).""" pass @classmethod def pre_render(cls) -> None: """Sync deferred physics state to the rendering backend. Called by :meth:`~isaaclab.sim.SimulationContext.render` before cameras and visualizers read scene data. The default implementation is a no-op. Backends that defer transform writes (e.g. Newton's dirty-flag pattern) should override this to flush pending updates. """ pass @classmethod def after_visualizers_render(cls) -> None: """Hook after visualizers have stepped during :meth:`~isaaclab.sim.SimulationContext.render`. Use for physics-backend sync (e.g. fabric) if needed. Default is a no-op. """ pass @classmethod def video_capture_backend(cls) -> str | None: """Return the video capture backend identifier for this physics manager. Used by :class:`~isaaclab.envs.utils.video_recorder.VideoRecorder` to select how perspective video frames are captured when no visualizer is active. Returns: ``"kit"`` for backends that use Kit/Replicator (e.g. :class:`~isaaclab_physx.physics.PhysxManager`), ``"newton_gl"`` for backends that use a headless Newton GL viewer (e.g. :class:`~isaaclab_newton.physics.NewtonManager`), or ``None`` if the backend does not support perspective video capture. """ return None @classmethod def close(cls) -> None: """Clean up physics resources. Subclasses whose STOP listeners own backend handles should call ``super().close()`` before backend-specific cleanup so those listeners can invalidate their handles while the backend is still live. All STOP listeners are given a chance to run. If one or more listeners fail, callback and shared simulation state is still cleared before an aggregate :class:`RuntimeError` is raised from the first failure. """ sim = PhysicsManager._sim # A config may declare its manager lazily as a ``"module:Class"`` string, which proxies # attribute access but is a ``str``, so compare against that form as well as the class. # The string must name the class's defining module; a config that pointed at a re-export # path would not match here. is_active_manager = sim is not None and ( sim.physics_manager is cls or sim.physics_manager == f"{cls.__module__}:{cls.__qualname__}" ) callback_errors = cls._dispatch_event_collect_errors(PhysicsEvent.STOP) if is_active_manager else [] try: cls.clear_callbacks() finally: if is_active_manager: PhysicsManager.views.clear() PhysicsManager._sim = None PhysicsManager._cfg = None PhysicsManager._sim_time = 0.0 if callback_errors: raise RuntimeError( f"{len(callback_errors)} callback(s) failed during PhysicsEvent.STOP dispatch." ) from callback_errors[0] @classmethod def _dispatch_event_collect_errors(cls, event: PhysicsEvent, payload: Any = None) -> list[Exception]: """Dispatch an event to every listener and collect direct or backend-stored failures.""" matching = [ (callback, order) for registered_event, callback, order, _name, _subscription in cls._callbacks.values() if registered_event == event ] matching.sort(key=lambda item: item[1]) callback_errors: list[Exception] = [] raise_stored = getattr(cls, "raise_callback_exception_if_any", None) def drain_stored_error() -> None: if callable(raise_stored): try: raise_stored() except Exception as exc: callback_errors.append(exc) for callback, _order in matching: try: callback(payload) except Exception as exc: callback_errors.append(exc) drain_stored_error() return callback_errors @classmethod def get_physics_dt(cls) -> float: """Get the physics timestep in seconds.""" return PhysicsManager._sim.cfg.dt if PhysicsManager._sim else 1.0 / 60.0 @classmethod def get_device(cls) -> str: """Get the physics simulation device.""" return PhysicsManager._device @classmethod def get_simulation_time(cls) -> float: """Get the current simulation time in seconds.""" return PhysicsManager._sim_time @classmethod def get_physics_sim_view(cls) -> Any: """Get the physics simulation view. Override in subclasses.""" return None @classmethod def play(cls) -> None: """Start or resume physics simulation. Default is no-op.""" pass @classmethod def pause(cls) -> None: """Pause physics simulation. Default is no-op.""" pass @classmethod def stop(cls) -> None: """Stop physics simulation. Default is no-op.""" pass @classmethod def wait_for_playing(cls) -> None: """Block until the timeline is playing. Default is no-op.""" pass @classmethod def set_decimation(cls, decimation: int) -> None: """Inform the physics backend how many substeps the environment runs per policy step. Backends that can fold the full decimation loop into a single :meth:`step` call (e.g. Newton with all-graphable actuators) use this to size their internal loop / CUDA graph. The default implementation is a no-op. Args: decimation: Number of physics steps per environment step. """ pass @classmethod def handles_decimation(cls) -> bool: """``True`` when :meth:`step` executes the full decimation loop internally. When this returns ``True`` the environment should call :meth:`step` once per policy step instead of looping ``decimation`` times. """ return False @classmethod def get_backend(cls) -> str: """Get the tensor backend being used ("numpy" or "torch").""" return "torch" if "cuda" in PhysicsManager._device else "numpy" @staticmethod def safe_callback_invoke(fn: Callable, *args, physics_manager: type[PhysicsManager] | None = None) -> None: """Invoke a callback, catching exceptions that would be swallowed by external event buses. Ignores ``ReferenceError`` (from garbage-collected weakref proxies). All other exceptions are forwarded to *physics_manager*.``store_callback_exception`` when available (see note below), or re-raised immediately otherwise. Note (Octi): The carb event bus used by PhysX/Omniverse silently swallows exceptions raised inside callbacks. ``PhysxManager`` works around this by storing the exception and re-raising it after event dispatch completes (in ``reset()`` / ``step()``). Backends that dispatch events directly (e.g. Newton) don't need this — exceptions propagate normally — so ``store_callback_exception`` is not called for them. This is a known wart; a cleaner solution is actively being explored. """ try: fn(*args) except ReferenceError: pass except Exception as e: store_fn = getattr(physics_manager, "store_callback_exception", None) if callable(store_fn): store_fn(e) else: raise