# 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
"""Utilities for detecting and launching the appropriate simulation backend.
The flow is intentionally simple: walk the config tree **once** to collect every
signal we care about (physics backend, OVRTX renderer, Kit cameras, visualizer
intent) into a :class:`Scan`, then decide how to launch from that scan.
"""
from __future__ import annotations
import argparse
import logging
import os
import sys
from collections.abc import Generator
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Any
from isaaclab_newton.physics import NewtonCfg
from isaaclab_ov.renderers import OVRTXRendererCfg
from isaaclab_ovphysx.physics import OvPhysxCfg
from isaaclab_physx.physics import PhysxCfg
from isaaclab_physx.renderers import IsaacRtxRendererCfg
from isaaclab.app.logging_utils import apply_python_logging_level, resolve_python_logging_level
from isaaclab.physics.physics_manager_cfg import PhysicsCfg
from isaaclab.renderers.renderer_cfg import RendererCfg
from isaaclab.sensors.camera.camera_cfg import CameraCfg
from isaaclab.utils._device import set_cuda_device
logger = logging.getLogger(__name__)
# Class names of the kitless physics backends (Newton, OvPhysX). Matched by exact
# name so subclasses with distinct names opt out.
_KITLESS_PHYSICS_CFGS = ("NewtonCfg", "OvPhysxCfg")
def add_launcher_args(parser: argparse.ArgumentParser) -> None:
"""Add simulation-launcher CLI arguments (``--headless``, ``--device``, etc.) to *parser*.
Delegates to :meth:`AppLauncher.add_app_launcher_args` so that user scripts
do not need to import ``AppLauncher`` directly.
"""
from isaaclab.app import AppLauncher
AppLauncher.add_app_launcher_args(parser)
[docs]
def make_physics_cfg(physics_cfg_str: str) -> PhysicsCfg:
"""Build a concrete physics config for the requested backend.
Args:
physics_cfg_str: Backend selector: ``"physx"``, ``"newton_mjwarp"``, ``"newton_vbd"``, or ``"ovphysx"``.
Returns:
A new physics config instance for the requested backend.
Raises:
ValueError: If *physics_cfg_str* does not name a known backend.
"""
if physics_cfg_str == "physx":
return PhysxCfg()
if physics_cfg_str == "newton_mjwarp":
return NewtonCfg()
if physics_cfg_str == "newton_vbd":
# lazy import: core depends on isaaclab_contrib only when VBD is requested
try:
from isaaclab_contrib.deformable.newton_manager_cfg import VBDSolverCfg
except ImportError as err:
raise ImportError(
"The 'newton_vbd' physics backend requires the isaaclab_contrib package."
" Install it with `./isaaclab.sh -i contrib`."
) from err
return NewtonCfg(solver_cfg=VBDSolverCfg())
if physics_cfg_str == "ovphysx":
return OvPhysxCfg()
raise ValueError(
f"Invalid physics config: {physics_cfg_str!r} (expected 'physx', 'newton_mjwarp', 'newton_vbd', or 'ovphysx')."
)
"""
Node Predicates.
"""
def _is_ovrtx_renderer(node) -> bool:
"""True when the node is an OVRTX renderer config."""
return isinstance(node, RendererCfg) and getattr(node, "renderer_type", None) == "ovrtx"
def _is_auto_rtx_renderer(node) -> bool:
"""True when the node is an automatic RTX renderer placeholder."""
return getattr(node, "renderer_type", None) == "auto_rtx"
def _is_kit_camera(node) -> bool:
"""True for a CameraCfg whose renderer requires Kit (not Newton)."""
if not isinstance(node, CameraCfg):
return False
renderer_cfg = getattr(node, "renderer_cfg", None)
if renderer_cfg is None:
return True
if _is_auto_rtx_renderer(renderer_cfg):
# ``auto_rtx`` is resolved after the initial scan once physics and
# visualizer intent are known; ie. it may become OVRTX for a kitless run.
return False
if isinstance(renderer_cfg, RendererCfg):
return renderer_cfg.renderer_type in ("default", "isaac_rtx")
# PresetCfg renderers (e.g. MultiBackendRendererCfg) are resolved during
# environment construction once the physics backend is known; assume they
# match the backend, so not necessarily Kit.
from isaaclab_tasks.utils import PresetCfg
return not isinstance(renderer_cfg, PresetCfg)
"""
Launcher Argument Helpers.
"""
def _get_arg(launcher_args: argparse.Namespace | dict | None, key: str, default: Any = None) -> Any:
"""Read *key* from launcher args, whether a namespace, dict, or ``None``."""
if isinstance(launcher_args, argparse.Namespace):
return getattr(launcher_args, key, default)
if isinstance(launcher_args, dict):
return launcher_args.get(key, default)
return default
def _set_arg(launcher_args: argparse.Namespace | dict | None, key: str, value: Any) -> None:
"""Write *key* on launcher args when it is a namespace or dict."""
if isinstance(launcher_args, argparse.Namespace):
setattr(launcher_args, key, value)
elif isinstance(launcher_args, dict):
launcher_args[key] = value
def _get_visualizer_types(launcher_args: argparse.Namespace | dict | None) -> set[str]:
"""Extract requested visualizer type names (``--visualizer kit,newton,...``)."""
visualizers = _get_arg(launcher_args, "visualizer")
if not visualizers:
return set()
if isinstance(visualizers, str):
visualizers = visualizers.split(",")
return {str(v).strip().lower() for v in visualizers if str(v).strip()}
def _get_livestream_mode(launcher_args: argparse.Namespace | dict | None) -> int:
"""Return effective livestream mode using the same CLI-over-env precedence as AppLauncher."""
livestream_arg = _get_arg(launcher_args, "livestream", -1)
if livestream_arg is not None and int(livestream_arg) >= 0:
return int(livestream_arg)
return int(os.environ.get("LIVESTREAM", 0))
def _ensure_livestream_kit_visualizer(launcher_args: argparse.Namespace | dict | None) -> None:
"""Request the Kit visualizer when livestreaming needs a video-producing viewport."""
if launcher_args is None or _get_livestream_mode(launcher_args) == 0:
return
visualizer_explicit = bool(_get_arg(launcher_args, "visualizer_explicit", False))
visualizers = _get_arg(launcher_args, "visualizer")
if visualizer_explicit and (visualizers is None or "none" in _get_visualizer_types(launcher_args)):
raise ValueError("Livestreaming requires the Kit visualizer. Remove '--viz none' or pass '--viz kit'.")
visualizer_types = _get_visualizer_types(launcher_args)
if "kit" in visualizer_types:
return
requested_visualizers = []
if visualizers:
requested_visualizers = (
[visualizer.strip() for visualizer in visualizers.split(",")]
if isinstance(visualizers, str)
else [str(visualizer).strip() for visualizer in visualizers if str(visualizer).strip()]
)
requested_visualizers.append("kit")
_set_arg(launcher_args, "visualizer", requested_visualizers)
def _get_visualizer_intent(cfg) -> dict[str, bool]:
"""Compute upstream visualizer intent from ``cfg.sim.visualizer_cfgs``."""
visualizer_cfgs = getattr(getattr(cfg, "sim", None), "visualizer_cfgs", None)
if visualizer_cfgs is None:
return {"has_any_visualizers": False, "has_kit_visualizer": False}
cfgs = visualizer_cfgs if isinstance(visualizer_cfgs, list) else [visualizer_cfgs]
cfgs = [c for c in cfgs if c is not None]
return {
"has_any_visualizers": len(cfgs) > 0,
"has_kit_visualizer": any(getattr(c, "visualizer_type", None) == "kit" for c in cfgs),
}
"""
The Single Scan.
"""
[docs]
@dataclass
class Scan:
"""Signals gathered from one walk of the config tree (see :func:`scan`).
Every field starts as a plain snapshot computed during that single walk.
Automatic RTX renderer placeholders are also recorded so launch-time resolution
can update the renderer-related fields without traversing the config tree again.
``needs_kit`` is the headline decision: a Kit-renderer camera or non-kitless
physics requires Kit (the launcher additionally forces Kit when ``--visualizer
kit`` is requested).
"""
resolved_physics_cfg: PhysicsCfg | None # first physics config in walk order (post --physics override)
effective_cfg: Any # the input config, or its replacement when the config itself was an overridden physics config
visualizer_intent: dict[str, bool]
has_ovrtx: bool
has_kit_camera: bool
has_kit_physics: bool # PhysX (Kit-based)
has_kitless_physics: bool # Newton or OvPhysX
has_ovphysx_physics: bool
needs_kit: bool
[docs]
def scan(cfg, launcher_args: argparse.Namespace | dict | None = None) -> Scan:
"""Walk *cfg* once, collecting all launch signals and applying ``--physics``.
When the ``physics`` key is present in *launcher_args*, every physics config is
replaced by the requested backend (see :func:`make_physics_cfg`): nested configs
in place, a root config via :attr:`Scan.effective_cfg` (it cannot be mutated in
place). Automatic RTX renderer placeholders (``renderer_type="auto_rtx"``) are
also resolved at this stage using the full *launcher_args* context.
"""
physics_str = _get_arg(launcher_args, "physics", None)
physics_cfgs: list[PhysicsCfg] = []
effective_cfg: Any = cfg
has_ovrtx = False
has_auto_rtx = False
has_kit_camera = False
auto_rtx_locations: list[tuple[Any, Any, bool]] = [] # (parent, key, is_cam_renderer) for each auto RTX placeholder
visited: set[int] = set()
def visit(node, parent, key):
nonlocal effective_cfg, has_ovrtx, has_auto_rtx, has_kit_camera
if _is_auto_rtx_renderer(node):
has_auto_rtx = True
auto_rtx_locations.append((parent, key, isinstance(parent, CameraCfg) and key == "renderer_cfg"))
if id(node) in visited:
return
visited.add(id(node))
if isinstance(node, PhysicsCfg):
if physics_str:
node = make_physics_cfg(physics_str)
if parent is not None:
setattr(parent, key, node)
else:
effective_cfg = node
physics_cfgs.append(node)
elif _is_ovrtx_renderer(node):
has_ovrtx = True
elif _is_kit_camera(node):
has_kit_camera = True
try:
children = vars(node)
except TypeError:
return
for name, child in children.items():
if child is None or isinstance(child, (int, float, str, bool)):
continue
visit(child, node, name)
visit(cfg, None, None)
names = [type(pcfg).__name__ for pcfg in physics_cfgs]
has_kitless_physics = any(name in _KITLESS_PHYSICS_CFGS for name in names)
config_scan = Scan(
resolved_physics_cfg=physics_cfgs[0] if physics_cfgs else None,
effective_cfg=effective_cfg,
visualizer_intent=_get_visualizer_intent(cfg),
has_ovrtx=has_ovrtx,
has_kit_camera=has_kit_camera,
has_kit_physics="PhysxCfg" in names,
has_kitless_physics=has_kitless_physics,
has_ovphysx_physics="OvPhysxCfg" in names,
needs_kit=has_kit_camera or not has_kitless_physics,
)
# Resolve recorded auto RTX renderer placeholders
if not has_auto_rtx:
return config_scan
use_isaac_sim = _uses_isaac_sim_runtime(config_scan, launcher_args)
renderer_factory = IsaacRtxRendererCfg if use_isaac_sim else OVRTXRendererCfg
# Resolve every auto RTX placeholder in place, tracking camera renderers that may require Kit.
has_auto_camera = False
for location in auto_rtx_locations:
if location[0] is None:
raise ValueError("Automatic RTX renderer placeholders cannot be resolved as the root config.")
setattr(location[0], location[1], renderer_factory())
has_auto_camera = has_auto_camera or location[2]
# Update the scan with the resolved auto RTX renderer type and Kit-camera status.
if use_isaac_sim:
config_scan.has_kit_camera = config_scan.has_kit_camera or has_auto_camera
else:
config_scan.has_ovrtx = True
config_scan.needs_kit = config_scan.has_kit_camera or not config_scan.has_kitless_physics
return config_scan
"""
Launch Decisions (derived purely from a scan).
"""
def _has_kit_visualizer(config_scan: Scan, launcher_args: argparse.Namespace | dict | None) -> bool:
"""Return whether the run requests the Kit visualizer through config or CLI."""
visualizer_types = _get_visualizer_types(launcher_args)
return "kit" in visualizer_types or config_scan.visualizer_intent["has_kit_visualizer"]
def _uses_isaac_sim_runtime(config_scan: Scan, launcher_args: argparse.Namespace | dict | None) -> bool:
"""Return whether the scanned config or launcher arguments require Isaac Sim / Kit."""
explicit_experience = bool(_get_arg(launcher_args, "experience", ""))
return explicit_experience or config_scan.needs_kit or _has_kit_visualizer(config_scan, launcher_args)
def _validate_runtime(scan: Scan, launcher_args: argparse.Namespace | dict | None) -> None:
"""Raise if *scan*'s physics/renderer/visualizer combination is unsupported.
OVRTX is kitless and cannot share a process with Kit-based runtimes (PhysX physics
or the Kit visualizer); OvPhysX physics likewise cannot run with the Kit visualizer.
"""
has_kit_visualizer = _has_kit_visualizer(scan, launcher_args)
if scan.has_ovphysx_physics and has_kit_visualizer:
raise ValueError(
"Invalid backend combination: OvPhysX physics (`OvPhysxCfg`) is kitless and cannot be used together "
'with the Kit visualizer (`--visualizer kit` / `visualizer_type="kit"`). Use a kitless visualizer '
"such as `--visualizer newton`, `--visualizer rerun`, or `--visualizer viser`, or omit the visualizer "
"argument for headless execution."
)
if not scan.has_ovrtx or (not scan.has_kit_physics and not has_kit_visualizer):
return
sources = []
if scan.has_kit_physics:
sources.append("Isaac Sim PhysX physics (`PhysxCfg`)")
if has_kit_visualizer:
sources.append('the Kit visualizer (`--visualizer kit` / `visualizer_type="kit"`)')
raise ValueError(
"Invalid backend combination: the OVRTX renderer (`OVRTXRendererCfg`,"
' `renderer_type="ovrtx"`) is a kitless renderer and cannot be used together'
f" with Isaac Sim / Kit ({' and '.join(sources)}).\n"
"\n"
"To fix this, pick one of the following supported combinations:\n"
" * Keep Isaac Sim / Kit and switch the renderer:\n"
" presets=isaacsim_rtx\n"
" (uses `IsaacRtxRendererCfg`, the Kit-compatible renderer.)\n"
" * Keep the OVRTX renderer and switch to a kitless physics backend\n"
" (and avoid `--visualizer kit`):\n"
" presets=newton_mjwarp,ovrtx\n"
)
def _resolve_distributed_device(cfg, launcher_args: argparse.Namespace | dict | None) -> None:
"""Set ``cfg.sim.device`` for distributed training, mirroring AppLauncher's fallback.
When ``--distributed`` restricts each process to one GPU, ``local_rank`` may exceed
the visible device count. The Kit path later overwrites this with ``AppLauncher.device``;
the kitless path relies solely on this value.
"""
if not _get_arg(launcher_args, "distributed", False):
return
import torch
local_rank = int(os.getenv("LOCAL_RANK", "0")) + int(os.getenv("JAX_LOCAL_RANK", "0"))
num_visible_gpus = torch.cuda.device_count()
# Compare against the local device count (not WORLD_SIZE) so multi-node runs work.
device_str = f"cuda:{local_rank}" if local_rank < num_visible_gpus else "cuda:0"
sim_cfg = getattr(cfg, "sim", None)
if sim_cfg is not None:
sim_cfg.device = device_str
set_cuda_device(device_str)
logger.info(
"Distributed device resolved to %s (local_rank=%d, visible_gpus=%d)",
device_str,
local_rank,
num_visible_gpus,
)
[docs]
@contextmanager
def launch_simulation(
cfg,
launcher_args: argparse.Namespace | dict | None = None,
) -> Generator[PhysicsCfg | None, None, None]:
"""Context manager that launches the appropriate simulation runtime for *cfg*.
Walks the config tree once (resolving ``--physics``, validating the
physics/renderer/visualizer combination, and deciding whether Isaac Sim Kit is
needed), then launches ``AppLauncher`` for Kit-based backends (closed on exit) or
does nothing for kitless ones. Cameras are auto-enabled for Kit-renderer sensors.
Yields the resolved physics config, so a script can pass a bare placeholder and
pick the backend from the command line::
with launch_simulation(PhysicsCfg(), args_cli) as physics_cfg:
sim = SimulationContext(SimulationCfg(physics=physics_cfg))
Callers that do not need the value simply omit ``as``.
"""
# Livestreaming implies a Kit visualizer; make that visible to auto RTX
# resolution during the single scan.
_ensure_livestream_kit_visualizer(launcher_args)
# The single walk: collect every signal and apply the --physics override.
config_scan = scan(cfg, launcher_args)
effective_cfg = config_scan.effective_cfg
physics_cfg = config_scan.resolved_physics_cfg
visualizer_types = _get_visualizer_types(launcher_args)
# ovrtx + Kit visualizer share conflicting RTX hydra libraries under different USD
# namespaces; loading both in one process crashes the dynamic linker. Fail early
# with a targeted hint (_validate_runtime covers the broader ovrtx-vs-Kit cases).
if "kit" in visualizer_types and config_scan.has_ovrtx:
raise ValueError(
"[launch_simulation] '--visualizer kit' is incompatible with 'ovrtx'. "
"Both Kit (Isaac Sim) and ovrtx ship conflicting RTX hydra libraries "
"(librtx.hydra.so, liblegacy.hydra.so) compiled against different USD namespaces, "
"which causes a dynamic-linker crash when loaded into the same process. "
"Use '--visualizer newton' instead, which is fully compatible with ovrtx presets."
)
_validate_runtime(config_scan, launcher_args)
needs_kit = _uses_isaac_sim_runtime(config_scan, launcher_args)
_set_arg(launcher_args, "visualizer_intent", config_scan.visualizer_intent)
# Kit-based backends apply the Python logging level inside AppLauncher; kitless backends
# never construct it, so honor --verbose / --info here to keep behavior consistent.
if not needs_kit:
apply_python_logging_level(resolve_python_logging_level(launcher_args))
if needs_kit and config_scan.has_kit_camera and launcher_args is not None:
if not _get_arg(launcher_args, "enable_cameras", False):
logger.info("Auto-enabling cameras: scene contains camera sensors with a Kit renderer.")
_set_arg(launcher_args, "enable_cameras", True)
# Resolve distributed device early, before AppLauncher or physics init.
_resolve_distributed_device(effective_cfg, launcher_args)
visualizer_explicit_none = _get_arg(launcher_args, "visualizer") is None and _get_arg(
launcher_args, "visualizer_explicit", False
)
close_fn: Any = None
if needs_kit:
_ensure_isaac_sim_available()
from isaaclab.utils import has_kit
if not has_kit():
from isaaclab.app import AppLauncher
app_launcher = AppLauncher(launcher_args)
# AppLauncher may refine the device choice; propagate its final value,
# intentionally overwriting the value set by _resolve_distributed_device.
sim_cfg = getattr(effective_cfg, "sim", None)
if sim_cfg is not None and hasattr(app_launcher, "device"):
sim_cfg.device = app_launcher.device
close_fn = app_launcher.app.close
elif visualizer_types or visualizer_explicit_none:
# Kitless path: AppLauncher is skipped, so persist the visualizer selection in
# SettingsManager so SimulationContext._get_cli_visualizer_types() can find it.
from isaaclab.app import AppLauncher
disable_all = visualizer_explicit_none or "none" in visualizer_types
base = vars(launcher_args) if isinstance(launcher_args, argparse.Namespace) else launcher_args
if base is not None:
AppLauncher.sync_visualizer_cli_settings_to_carb(
{**base, "visualizer_explicit": True, "visualizer_disable_all": disable_all}
)
exit_code = 0
try:
yield physics_cfg
except Exception:
exit_code = 1
import traceback
traceback.print_exc()
raise
finally:
if close_fn is not None:
if exit_code:
close_fn(exit_code=exit_code)
else:
close_fn()
def _ensure_isaac_sim_available() -> None:
"""Raise ``SystemExit`` with an actionable hint when Isaac Sim / Kit is missing."""
import importlib.util
if importlib.util.find_spec("omni.kit") is not None:
return
isaaclab_path = os.environ.get("ISAACLAB_PATH")
local_sim = os.path.join(isaaclab_path, "_isaac_sim") if isaaclab_path else None
extra_hint = ""
if local_sim and os.path.isdir(local_sim):
launcher, source = ("isaaclab.bat", f'call "{local_sim}\\setup_conda_env.bat"')
if sys.platform != "win32":
launcher, source = ("./isaaclab.sh", f'source "{local_sim}/setup_conda_env.sh"')
extra_hint = (
f" Found a local Isaac Sim at {local_sim} but its environment is not active.\n"
f" Either run via `{launcher} ...` (which sources the Isaac Sim env automatically),\n"
f" or in your current shell run:\n"
f" {source}\n"
)
logger.error(
"\n[ERROR] Isaac Sim is not installed or not found on PYTHONPATH.\n"
"\n"
" This environment requires Isaac Sim and Omniverse Kit.\n"
" PhysX backend and Kit visualizer currently requires Isaac Sim.\n"
"\n"
f"{extra_hint}"
" To fix this, ensure Isaac Sim is installed and available in the current environment.\n"
"\n"
" See https://isaac-sim.github.io/IsaacLab/main/source/setup/installation for details.\n"
)
raise SystemExit(1)