Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
386f76a
Add Policy Ghosts to compare checkpoints in the Newton GL viewer
matthewtrepte Oct 2, 2026
180e85a
Describe the empty-slot simulation cost of Policy Ghosts accurately
matthewtrepte Oct 2, 2026
ab09f79
Fix Policy Ghosts freezing when more worlds become visible and add ti…
matthewtrepte Oct 2, 2026
8c3506b
Reorganize the Newton GL HUD into Playback, Environments, and use-ord…
matthewtrepte Oct 2, 2026
f0f0f4f
Add per-checkpoint toggles, full-override tint, eight Policy Ghost sl…
matthewtrepte Oct 2, 2026
90bc60e
Correct the Policy Ghosts cost note in the RL changelog fragment
matthewtrepte Oct 2, 2026
d9a629f
Give a measured range for the Policy Ghosts step-rate cost
matthewtrepte Oct 2, 2026
76de27f
Offer Policy Ghosts from the Newton GL HUD by restarting playback
matthewtrepte Oct 6, 2026
b68dedf
Merge remote-tracking branch 'origin/develop' into mtrepte/newton-gl-…
matthewtrepte Oct 7, 2026
b5b6b10
Fix empty Viser recordings when no browser is connected
matthewtrepte Oct 7, 2026
5205d68
Merge remote-tracking branch 'origin/main' into mtrepte/newton-gl-pol…
matthewtrepte Oct 19, 2026
5e9f5f7
Update the Viser recording test for the reworked visualizer step
matthewtrepte Oct 19, 2026
c76b59c
Read the Policy Ghosts visualizer requirement from the resolved config
matthewtrepte Oct 19, 2026
87241a7
Add an OVRTX viewer camera and a Renderer toggle to the Newton GL window
matthewtrepte Oct 19, 2026
3502ee3
Support picking, markers, and window fill in the Newton GL OVRTX view
matthewtrepte Oct 19, 2026
3566994
Add a Recording section, View section, and visible envs to the Newton…
matthewtrepte Oct 20, 2026
7f7d184
Apply USD spawn visual materials without Kit so OVRTX markers keep th…
matthewtrepte Oct 20, 2026
7efcc20
Add shared output fields, video FPS, and video length to the Newton G…
matthewtrepte Oct 22, 2026
00ce40e
Add an always-visible HUD header and group the scene controls in the …
matthewtrepte Oct 22, 2026
9df147d
Merge main into mtrepte/newton-gl-policy-ghosting
matthewtrepte Oct 22, 2026
53f9355
List scene cameras in the Newton GL window that declares an OVRTX vie…
matthewtrepte Oct 22, 2026
4044aec
Play .viser animations next to the simulation from the Newton GL Reco…
matthewtrepte Oct 22, 2026
09a4c0c
Remove the window FPS and renderer lines from the Newton GL HUD header
matthewtrepte Oct 22, 2026
b646cc5
Add Start, Stop, and Save for video, reorder the Recording section, a…
matthewtrepte Oct 22, 2026
b677585
Add a Replay mode to the Newton GL window for loaded animations
matthewtrepte Oct 22, 2026
2dd6a8c
Remove the environment count line from the Newton GL HUD header
matthewtrepte Oct 22, 2026
0e461d3
Rename the Newton GL window to Newton Visualizer, add a Visualization…
matthewtrepte Oct 22, 2026
64db569
Show recording status in the Recording section and add a VSync tooltip
matthewtrepte Oct 22, 2026
cb08b07
Split the Recording section drawing into one method per group
matthewtrepte Oct 22, 2026
8d1c232
Add tooltips to the playback, replay, and stop controls of the Newton…
matthewtrepte Oct 22, 2026
50d27a7
Remove the up axis and gravity header line, add more HUD tooltips, an…
matthewtrepte Oct 22, 2026
de9af14
Rename View to Camera View, group markers under Scene, and add sectio…
matthewtrepte Oct 22, 2026
ab3c859
Rename the Selection API panel to Articulation Inspector in the Newto…
matthewtrepte Oct 22, 2026
6b7a1a0
Shorten the Articulation Inspector tooltip to satisfy the line length…
matthewtrepte Oct 22, 2026
1226f20
Rename Model Overlays to Visual Overlays, move Keyboard & Mouse to th…
matthewtrepte Oct 22, 2026
a71374e
Add per-marker and per-plot tooltips to the Newton GL HUD
matthewtrepte Oct 22, 2026
a3d733b
Add the task Tracking Camera to the Newton GL Camera Source list
matthewtrepte Oct 22, 2026
20930bc
Add editable Tracking Camera fields and keep Kit runs free of the OVR…
matthewtrepte Oct 22, 2026
06c926f
Shorten the Tracking Camera field labels and wrap its error text
matthewtrepte Oct 22, 2026
205a4c3
Remove a recorded state snapshot that was committed by mistake
matthewtrepte Oct 23, 2026
caed4bb
Merge remote-tracking branch 'origin/main' into mtrepte/newton-gl-pol…
matthewtrepte Oct 23, 2026
0fd2d6a
Order Camera Source as Perspective, Tracking Camera, then sensors, wi…
matthewtrepte Oct 23, 2026
f0a2f1d
Add an RTX Appearance section for the OVRTX view's background and lig…
matthewtrepte Oct 23, 2026
06f7205
Add dome and distant lighting choices to the RTX Appearance section
matthewtrepte Oct 23, 2026
ba2046d
Give the RTX Appearance section independent scene, dome, and distant …
matthewtrepte Oct 23, 2026
c5a1b4d
Fix Policy Ghosts validation and configs without curriculum, and grey…
matthewtrepte Oct 23, 2026
e15c1de
Rebase replay times so scrubbing a recording does not stay on the fir…
matthewtrepte Oct 23, 2026
b25400a
Ignore a replay view choice until an animation is loaded
matthewtrepte Oct 23, 2026
015d00e
Order the Playback buttons, drop VSync, keep RTX following the Tracki…
matthewtrepte Oct 23, 2026
d8facb7
Add a focal length control to Camera View, move Policy Ghosts below L…
matthewtrepte Oct 23, 2026
4ff3356
Fix the HUD freezing when Save video unbalanced a disabled scope
matthewtrepte Oct 23, 2026
b0abbb5
Record and replay visualization markers, limit replay views to overla…
matthewtrepte Oct 23, 2026
8b782c4
Read the Viser recording path from the backend config when deciding t…
matthewtrepte Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ _build
**/runs/*
**/logs/*
**/recordings/*
**/recording/*

# Local development tools and generated media.
/tools/local/
Expand Down
20 changes: 20 additions & 0 deletions docs/source/api/lab/isaaclab.visualizers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ The following classes are part of the public :mod:`isaaclab.visualizers` API.
:nosignatures:

BaseVisualizer
PolicyGhostCheckpoint
PolicyGhostController
PolicyGhostLayout
PolicyGhostSlot
VisualizerCfg
VisualizerBackendCfg
WindowCfg
Expand All @@ -32,6 +36,22 @@ The following classes are part of the public :mod:`isaaclab.visualizers` API.
.. autoclass:: BaseVisualizer
:show-inheritance:

.. autoclass:: PolicyGhostCheckpoint
:members:
:show-inheritance:

.. autoclass:: PolicyGhostController
:members:
:show-inheritance:

.. autoclass:: PolicyGhostLayout
:members:
:show-inheritance:

.. autoclass:: PolicyGhostSlot
:members:
:show-inheritance:

.. autoclass:: VisualizerCfg
:show-inheritance:

Expand Down
40 changes: 40 additions & 0 deletions docs/source/concepts/visualization.rst
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,46 @@ without screen-sharing, running with ``--viz viser``:
The share URL is logged on startup. Rerun has no equivalent config field, but exposes its own
share button in the native UI.

**Comparing checkpoints with Policy Ghosts**

Newton GL can overlay several checkpoints of one task in a single viewport. The ``--checkpoint``
drives the reference robots; ghosts of other checkpoints are tinted, translucent copies that start
from the same terrain, spawn pose, and commands, so differences in behavior show up as the robots
drift apart:

.. code-block:: bash

uv run isaaclab play --rl_library rsl_rl --task Isaac-Velocity-Rough-AnymalD \
--num_envs 4 --checkpoint latest --viz newton_gl --policy_ghosts

You can also start from a normal ``play`` run: open **Policy Ghosts** in the viewer's left panel and choose
**Enable Policy Ghosts (restarts)**. The command restarts with ``--policy_ghosts`` added, because the simulation
fixes its environment count at startup; **Disable Policy Ghosts (restarts)** reverses it. Policy Ghosts stay off
unless you ask for them, since they simulate several times the environments.

Open **Policy Ghosts** in the viewer's left panel and choose **Add Checkpoint...** to list the
checkpoints of runs of this task that were written by training (those with a ``run.json``) under the
experiment's log directory; pass ``--policy_ghost_root`` to search another directory. Each loaded checkpoint has a checkbox that
shows or hides it. Under **Settings**, each ghost has opacity and tint sliders; a tint of 1.0 replaces all of
the robot's native colors and textures with the ghost color, and lower values blend it with the native colors.
**Remove** frees the slot. ``--policy_ghost_slots`` sets the number of slots, including the reference, up to eight;
the default is eight.

* Every slot simulates ``--num_envs`` environments, even an empty one. With a few environments per
slot the extra cost is small, since the step time is dominated by fixed overhead; it grows with the
total environment count (slots times ``--num_envs``).
* Randomization that cannot be mirrored between slots is disabled: startup and interval events,
curriculum terms, and observation noise. Reset events and commands are copied from the reference.
* Identical checkpoints do not stay exactly on top of each other; physics differences grow
to centimeters over a few seconds on rough terrain.
* Policy Ghosts support RSL-RL and manager-based environments only.

**Choosing how many environments are drawn**

Every Newton GL viewer has a **Visible Envs** slider in its left panel that sets how many
environments are drawn. It changes only what is rendered, not the simulation. With Policy Ghosts, it
counts environments per slot.

Resolution Rules
~~~~~~~~~~~~~~~~

Expand Down
3 changes: 3 additions & 0 deletions source/isaaclab/changelog.d/policy-ghost-sync.added.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
* Added :class:`~isaaclab.visualizers.PolicyGhostController` and its slot and checkpoint types, and
:class:`~isaaclab.envs.utils.policy_ghost_sync.PolicyGhostSync`, which keep ghost slices of one manager-based
environment on the reference slice's terrain, spawn state, and commands.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
* Added an OVRTX viewer camera that the launcher declares in the scene for Newton GL windows, so the window can switch
its main view to OVRTX rendering. The window size is fitted to the display so the camera and the window share an
aspect ratio.
6 changes: 6 additions & 0 deletions source/isaaclab/changelog.d/tracking-camera-views.added.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
* Added ``env_index``, ``track_path``, ``follow_heading``, and ``heading_smoothing_time_constant`` to
:class:`~isaaclab.visualizers.PerspectiveCameraCfg`, and :class:`~isaaclab.visualizers.camera_follower.CameraFollower`,
so a perspective view can frame an environment or follow an asset with smoothed yaw. Only the Newton GL window
renders these views; other visualizers ignore them.
* Added :meth:`~isaaclab.visualizers.camera_follower.CameraFollower.set_track_path`, ``asset_names``, and
``body_names`` so a view can switch the asset or body it follows while running.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
* Fixed :class:`~isaaclab.sim.UsdFileCfg` skipping its ``visual_material`` when Kit is not running, which left
visualization markers in kitless renderers such as OVRTX with the asset's own color instead of the configured one.
70 changes: 69 additions & 1 deletion source/isaaclab/isaaclab/app/sim_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

import argparse
import importlib.util
import logging
import os
import sys
Expand Down Expand Up @@ -158,6 +159,39 @@ def visit(node):
return tuple(configs)


def _fit_window_to_display(size: tuple[int, int], margin: float = 0.92) -> tuple[int, int]:
"""Shrink a window size, keeping its aspect ratio, so the window fits the display with room for decorations.

Args:
size: Requested window width and height [px].
margin: Fraction of the display the window may occupy.

Returns:
The requested size, or a proportionally smaller one; unchanged when the display size is unknown.
"""
if sys.platform not in ("win32", "darwin") and not os.environ.get("DISPLAY"):
return size
try:
import pyglet # noqa: PLC0415

screen = pyglet.display.get_display().get_default_screen()
scale = min(1.0, margin * screen.width / size[0], margin * screen.height / size[1])
except Exception:
return size
return (max(1, int(size[0] * scale)), max(1, int(size[1] * scale)))


def _wants_rtx_viewer_camera(visualizer: VisualizerCfg, simulations: list[SimulationCfg]) -> bool:
"""Return whether a Newton GL window should get an OVRTX viewer camera in its scene."""
backend = visualizer.backend
if not isinstance(backend, NewtonGLBackendCfg) or visualizer.headless or visualizer.view is not None:
return False
if backend.rtx_camera is not None:
return backend.rtx_camera
on_physx = any(isinstance(sim.physics, (PhysxCfg, PhysxAutoCfg, OvPhysxCfg)) for sim in simulations)
return importlib.util.find_spec("ovrtx") is not None and not on_physx


def validate_launch( # noqa: C901
cfg, launcher_args: argparse.Namespace | dict | None = None
) -> tuple[tuple[Any, ...], tuple[str, ...]]:
Expand Down Expand Up @@ -310,11 +344,40 @@ def validate_launch( # noqa: C901

# Declare RTX perspective cameras before scene construction; runtime viewers only borrow their output.
scene_cfg = next((node for node in configs if isinstance(node, InteractiveSceneCfg)), None)
# A Newton GL window can switch its main view to an OVRTX camera, which must already exist in the scene.
implicit_viewer_cameras: list[str] = []
for viewer_index, visualizer in enumerate(viewers):
if scene_cfg is None or not _wants_rtx_viewer_camera(visualizer, simulations):
continue
name = f"viewer_camera_{viewer_index}_rtx"
# The window must open at the camera's aspect ratio; an oversized window is clamped by the window manager.
visualizer.window.size = _fit_window_to_display(visualizer.window.size)
width, height = visualizer.window.size
rotation = quat_from_matrix(create_rotation_matrix_from_view(
torch.tensor([visualizer.eye]), torch.tensor([visualizer.lookat]), "Z", device="cpu"
))[0].tolist() # fmt: skip
implicit_viewer_cameras.append(name)
vars(scene_cfg)[name] = CameraCfg(
prim_path=f"/World/{name}", width=width, height=height, data_types=["rgba"],
spawn=PinholeCameraCfg(
focal_length=visualizer.focal_length, vertical_aperture=15.2908,
horizontal_aperture=15.2908 * width / height,
),
offset=CameraCfg.OffsetCfg(pos=visualizer.eye, rot=tuple(rotation), convention="opengl"),
renderer_cfg=visualizer.backend.rtx_renderer_cfg, background_color=visualizer.background_color,
render_settings={"omni:rtx:quality": ("Int", 0), **visualizer.backend.rtx_render_settings},
) # fmt: skip
visualizer.streaming_view = True # The window lists scene cameras, including this one, as view sources.
for viewer_index, visualizer in enumerate(viewers):
if not isinstance(visualizer.backend, NewtonRTXBackendCfg) or scene_cfg is None:
continue
# Task views belong to the Newton GL window; an RTX window gets an ordinary perspective camera instead.
declared = [
camera for camera in visualizer.cameras or []
if not (isinstance(camera, PerspectiveCameraCfg) and camera.is_task_view)
] # fmt: skip
sources = (
[visualizer.view.source] if visualizer.view is not None else list(visualizer.cameras or [
[visualizer.view.source] if visualizer.view is not None else list(declared or [
PerspectiveCameraCfg(eye=visualizer.eye, lookat=visualizer.lookat, focal_length=visualizer.focal_length)
])
) # fmt: skip
Expand Down Expand Up @@ -385,6 +448,11 @@ def validate_launch( # noqa: C901
node.max_visible_envs = max_visible

configs = _walk_runtime_configs(cfg, replacements)
if kit_sources and implicit_viewer_cameras:
# The OVRTX runtime cannot share a process with Kit, so a Kit run keeps the OpenGL view only.
for name in implicit_viewer_cameras:
vars(scene_cfg).pop(name, None)
configs = _walk_runtime_configs(cfg, replacements)
if kit_sources:
for node in configs:
if isinstance(node, OvPhysxCfg):
Expand Down
159 changes: 159 additions & 0 deletions source/isaaclab/isaaclab/envs/utils/policy_ghost_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# 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

"""Keeps ghost slices of one environment on the reference slice's terrain, spawn state and commands."""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Any

import numpy as np
import torch

if TYPE_CHECKING:
from ..manager_based_rl_env import ManagerBasedRLEnv
from ..manager_based_rl_env_cfg import ManagerBasedRLEnvCfg

logger = logging.getLogger(__name__)


class PolicyGhostSync:
"""Synchronizes the slices of a single environment so that several policies can be compared.

The environment is simulated with ``num_slices * slice_size`` worlds. Slice 0 is the reference; environment
``i`` of every other slice mirrors environment ``i`` of the reference. Whenever a reference environment resets,
its ghost counterparts reset with it and receive the reference's spawn state. Velocity or pose commands are
copied from the reference after every command update. Ghost environments that terminate on their own wait
for the reference to reset.

Only :class:`~isaaclab.envs.ManagerBasedRLEnv` is supported. Call :meth:`configure_env_cfg` on the env config
before creating the environment, because per-environment randomization other than the reset events cannot
be mirrored.
"""

def __init__(self, env: ManagerBasedRLEnv, slice_size: int, num_slices: int):
"""Attach to an environment and place every slice on the reference slice's terrain.

Args:
env: Environment simulated with ``slice_size * num_slices`` worlds.
slice_size: Number of environments per slice.
num_slices: Number of slices, including the reference.

Raises:
TypeError: If the environment does not use managers.
ValueError: If the sizes do not match the environment.
"""
for manager in ("command_manager", "event_manager", "scene"):
if not hasattr(env, manager):
raise TypeError("Policy Ghosts only support manager-based environments.")
if slice_size < 1 or num_slices < 2 or env.num_envs != slice_size * num_slices:
raise ValueError(f"Environment has {env.num_envs} envs, expected {num_slices} slices of {slice_size} envs.")
self._env = env
self._slice_size = slice_size
self._num_slices = num_slices
self._device = env.device
self._reference_ids = torch.arange(slice_size, device=self._device)
self._ghost_offsets = torch.arange(1, num_slices, device=self._device) * slice_size
self._sync_terrain()
self._reset_idx_original = env._reset_idx
self._compute_commands_original = env.command_manager.compute
env._reset_idx = self._reset_idx
env.command_manager.compute = self._compute_commands

@staticmethod
def configure_env_cfg(env_cfg: ManagerBasedRLEnvCfg) -> list[str]:
"""Remove per-environment randomization that cannot be mirrored between slices.

Startup and interval events, curriculum terms, and observation corruption are disabled. Reset events
are kept because the reference's reset state is copied to the ghosts.

Args:
env_cfg: Environment config to modify in place.

Returns:
Names of the disabled terms.
"""
removed = []
# A task may declare no events, curriculum, or observation groups at all.
events, curriculum, observations = (
getattr(env_cfg, name, None) for name in ("events", "curriculum", "observations")
)
for name, term in vars(events).items() if events is not None else ():
if term is not None and getattr(term, "mode", None) in ("startup", "interval"):
setattr(events, name, None)
removed.append(f"events.{name}")
for name, term in vars(curriculum).items() if curriculum is not None else ():
if term is not None:
setattr(curriculum, name, None)
removed.append(f"curriculum.{name}")
for group_name, group in vars(observations).items() if observations is not None else ():
if getattr(group, "enable_corruption", False):
group.enable_corruption = False
removed.append(f"observations.{group_name}.enable_corruption")
if removed:
logger.warning("Policy Ghosts disabled per-environment randomization: %s", ", ".join(removed))
return removed

def env_origin_offsets(self) -> np.ndarray:
"""Return per-environment offsets [m], shape ``[num_envs, 3]``, that move each ghost onto the reference."""
origins = self._env.scene.env_origins
offsets = torch.zeros_like(origins)
for ghost_start in self._ghost_offsets.tolist():
offsets[ghost_start : ghost_start + self._slice_size] = (
origins[: self._slice_size] - origins[ghost_start : ghost_start + self._slice_size]
)
return offsets.cpu().numpy().astype(np.float32)

def close(self) -> None:
"""Restore the environment's original reset and command update."""
self._env._reset_idx = self._reset_idx_original
self._env.command_manager.compute = self._compute_commands_original

def _sync_terrain(self) -> None:
"""Place every ghost environment on the terrain tile of its reference environment."""
terrain = self._env.scene.terrain
if terrain is None or getattr(terrain, "terrain_origins", None) is None:
return
for name in ("terrain_levels", "terrain_types", "env_origins"):
values = getattr(terrain, name)
for ghost_start in self._ghost_offsets.tolist():
values[ghost_start : ghost_start + self._slice_size] = values[: self._slice_size]

def _reset_idx(self, env_ids: Any) -> None:
"""Reset reference environments together with their ghosts, then copy the reference's spawn state."""
if isinstance(env_ids, slice):
env_ids = torch.arange(self._env.num_envs, device=self._device)[env_ids]
env_ids = torch.as_tensor(env_ids, device=self._device, dtype=torch.long)
reference = env_ids[env_ids < self._slice_size]
if reference.numel() == 0:
# ghost-only terminations wait for the reference to reset
return
ghosts = (reference[None, :] + self._ghost_offsets[:, None]).reshape(-1)
self._reset_idx_original(torch.cat([reference, ghosts]))
scene = self._env.scene
state = scene.get_state(is_relative=True)
scene.reset_to(_gather(state, reference.repeat(self._num_slices - 1)), env_ids=ghosts, is_relative=True)
self._copy_command_state(reference)

def _compute_commands(self, *args: Any, **kwargs: Any) -> None:
"""Update commands, then copy the reference's commands to the ghosts."""
self._compute_commands_original(*args, **kwargs)
self._copy_command_state(self._reference_ids)

def _copy_command_state(self, reference: torch.Tensor) -> None:
"""Copy every per-environment tensor of each command term from the reference to its ghosts."""
ghosts = (reference[None, :] + self._ghost_offsets[:, None]).reshape(-1)
source = reference.repeat(self._num_slices - 1)
manager = self._env.command_manager
for name in manager.active_terms:
for value in vars(manager.get_term(name)).values():
if isinstance(value, torch.Tensor) and value.ndim >= 1 and value.shape[0] == self._env.num_envs:
value[ghosts] = value[source]


def _gather(state: dict[str, Any], ids: torch.Tensor) -> dict[str, Any]:
"""Index every tensor of a nested scene-state dictionary by environment id."""
return {key: _gather(value, ids) if isinstance(value, dict) else value[ids] for key, value in state.items()}
Loading
Loading