# 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
"""Simulation-scoped rendering state."""
from __future__ import annotations
import logging
from typing import Any
import torch
import warp as wp
from isaaclab.app.logging_utils import force_log_level
from isaaclab.sensors.camera.camera_data import CameraData
from .base_renderer import BaseRenderer, VisualMaterialBatch
from .renderer_cfg import RendererCfg
logger = logging.getLogger(__name__)
@wp.kernel(enable_backward=False)
def _write_material(
values: wp.array(dtype=Any, ndim=2),
offsets: wp.array(dtype=wp.int32),
env_ids: wp.array(dtype=wp.int32),
output: wp.array(dtype=Any),
):
material, env = wp.tid()
row = offsets[material] + env_ids[env]
output[row] = values[material, env]
_MATERIAL_WRITES = {
(): wp.float32,
(2,): wp.vec2f,
(3,): wp.vec3f,
}
[docs]
class RenderContext:
"""Own camera renderers and flat runtime material buffers for one simulation.
A camera reuses a backend when a prior camera registered a config equal under ``==`` (value
equality) and the same concrete ``RendererCfg`` subclass. A distinct ``RendererCfg`` that
maps to a different implementation (e.g. Isaac RTX vs Newton) produces another backend; each
has :meth:`BaseRenderer.prepare_stage` run before use.
:meth:`update_scene_state` is invoked at most once per :meth:`get_physics_step_count` for the
context;
"""
__slots__ = (
"_renderer_entries",
"_physics_initialized",
"_prepared_renderer_ids",
"_prepared_num_envs",
"_last_scene_state_step",
"_visual_materials",
"_visual_material_batches",
"_visual_material_batches_by_channel",
"_visual_material_batch_views",
"_visual_material_writers",
"_visual_material_selections",
"_visual_material_env_ids",
"_consumers_finalized",
)
[docs]
def __init__(self) -> None:
self._renderer_entries: list[tuple[RendererCfg, BaseRenderer]] = []
self._physics_initialized: bool = False # Set to True after the first PHYSICS_READY callback fires.
self._prepared_renderer_ids: set[int] = set()
self._prepared_num_envs: int | None = None
self._last_scene_state_step: int | None = None
self._visual_materials: list[Any] = []
self._visual_material_batches: tuple[VisualMaterialBatch, ...] = ()
self._visual_material_batches_by_channel: dict[str, VisualMaterialBatch] = {}
self._visual_material_batch_views: dict[str, wp.array] = {}
self._visual_material_writers: tuple[Any, ...] = ()
self._visual_material_selections: dict[tuple[str, tuple[int, ...]], tuple[torch.Tensor, wp.array]] = {}
self._visual_material_env_ids: dict[tuple[torch.device, int], tuple[torch.Tensor, wp.array]] = {}
self._consumers_finalized = False
def _check_global_settings_compatible(self, cfg: RendererCfg) -> None:
"""Reject conflicting process-global renderer settings."""
if getattr(cfg, "renderer_type", None) != "isaac_rtx" or not hasattr(cfg, "global_settings"):
return
for stored_cfg, _renderer in self._renderer_entries:
if getattr(stored_cfg, "renderer_type", None) != "isaac_rtx" or not hasattr(stored_cfg, "global_settings"):
continue
if stored_cfg.global_settings != cfg.global_settings:
raise ValueError(
"Isaac RTX global settings differ across camera renderer configs. "
"These settings are process-global; configure the same "
"IsaacRtxRendererCfg.global_settings for every Isaac RTX camera."
)
@property
def renderer_types(self) -> tuple[str, ...]:
"""Return the registered camera renderer types."""
return tuple(cfg.renderer_type for cfg, _renderer in self._renderer_entries)
def get_renderer(self, cfg: RendererCfg) -> BaseRenderer:
"""Return a backend for this configuration, reusing a matching instance if present.
Lookups use ``==`` and concrete ``RendererCfg`` type, so :func:`hash` is not used (configs
are typically not hashable).
Args:
cfg: Renderer configuration from the initializing camera.
Returns:
A shared or newly created renderer backend.
"""
self._check_global_settings_compatible(cfg)
for stored_cfg, r in self._renderer_entries:
if type(stored_cfg) is type(cfg) and stored_cfg == cfg:
return r
if self._consumers_finalized and self._visual_material_batches:
raise RuntimeError("Renderers must be registered before rendering consumers are finalized.")
new_renderer = cfg.class_type(cfg)
self._renderer_entries.append((cfg, new_renderer))
with force_log_level(logging.INFO):
logger.info("Created new renderer for simulation: %s", type(new_renderer).__name__)
if self._physics_initialized:
new_renderer.initialize()
return new_renderer
def ensure_initialize(self) -> None:
"""Idempotent call fired after PHYSICS_READY callback."""
if self._physics_initialized:
return
self._physics_initialized = True
for _cfg, renderer in self._renderer_entries:
renderer.initialize()
def register_visual_material(self, material: Any) -> None:
"""Register one initialized material asset for flat channel composition."""
if any(registered is material for registered in self._visual_materials):
return
if self._consumers_finalized:
raise RuntimeError("Visual materials must initialize before rendering consumers are finalized.")
self._visual_materials.append(material)
def finalize_consumers(self, visualizers: list[Any], *, rebuild: bool = False) -> None:
"""Compose material buffers and create backend writers at the post-reset lifecycle point."""
if self._consumers_finalized and not rebuild:
return
old_writers, self._visual_material_writers = self._visual_material_writers, ()
self._consumers_finalized = False
close_error = None
for writer in old_writers:
try:
writer.close()
except Exception as exc: # noqa: BLE001 - close every writer before reporting failure
close_error = close_error or exc
if close_error is not None:
raise RuntimeError("Failed to close a visual-material writer during rebuild.") from close_error
batches = []
channels = {channel for material in self._visual_materials for channel in material.channels}
for channel in sorted(channels):
rows = sorted(
(
(
material,
material._material_paths,
material._shader_paths,
material._input_names[channel],
material._values[channel],
)
for material in self._visual_materials
if channel in material.channels
),
key=lambda row: row[3],
)
values = torch.cat([row[4] for row in rows])
material_paths = tuple(path for row in rows for path in row[1])
shader_paths = tuple(path for row in rows for path in row[2])
input_names = tuple(row[3] for row in rows for _ in row[1])
batches.append(VisualMaterialBatch(channel, material_paths, shader_paths, input_names, values))
offset = 0
for material, paths, _shader_paths, _input_name, _material_values in rows:
end = offset + len(paths)
material._values[channel] = values[offset:end]
material._offsets[channel] = offset
offset = end
self._visual_material_batches = tuple(batches)
self._visual_material_batches_by_channel = {batch.channel: batch for batch in batches}
self._visual_material_batch_views = {
batch.channel: wp.from_torch(batch.values, dtype=_MATERIAL_WRITES[tuple(batch.values.shape[1:])])
for batch in batches
}
self._visual_material_selections.clear()
self._visual_material_env_ids.clear()
factories = []
consumers = (*visualizers, *(renderer for _cfg, renderer in self._renderer_entries))
for consumer in consumers:
factory = consumer.visual_material_writer
if factory is not None and factory not in factories:
factories.append(factory)
writers = []
try:
if batches:
device = batches[0].values.device
stream = wp.stream_from_torch(torch.cuda.current_stream(device)) if device.type == "cuda" else None
with wp.ScopedStream(stream, sync_enter=False):
for factory in factories:
writers.append(factory(self._visual_material_batches))
for writer in writers:
writer()
except Exception:
for writer in writers:
writer.close()
raise
self._visual_material_writers = tuple(writers)
self._consumers_finalized = True
def write_visual_materials(
self, materials: list[Any], channels: dict[str, torch.Tensor], env_ids: torch.Tensor | None = None
) -> None:
"""Update selected rows and dispatch the already-compiled backend writers."""
if not materials or not channels:
return
if not self._consumers_finalized:
raise RuntimeError("Visual materials can only be written after simulation reset.")
per_env = materials[0].is_per_env
if not per_env and env_ids is not None:
raise ValueError("env_ids is only valid for per-environment materials.")
device = next(iter(self._visual_material_batches_by_channel.values())).values.device
count = materials[0].num_instances if per_env else 1
if env_ids is None:
env_key = (device, count)
selected = self._visual_material_env_ids.get(env_key)
if selected is None:
env_tensor = torch.arange(count, dtype=torch.int32, device=device)
selected = (env_tensor, wp.from_torch(env_tensor, dtype=wp.int32))
self._visual_material_env_ids[env_key] = selected
else:
env_tensor = env_ids.to(device=device, dtype=torch.int32)
selected = (env_tensor, wp.from_torch(env_tensor, dtype=wp.int32))
stream = wp.stream_from_torch(torch.cuda.current_stream(device)) if device.type == "cuda" else None
with wp.ScopedStream(stream, sync_enter=False):
material_offsets = {}
material_key = tuple(id(material) for material in materials)
for channel, values in channels.items():
batch = self._visual_material_batches_by_channel[channel]
key = (channel, material_key)
offsets = self._visual_material_selections.get(key)
if offsets is None:
offset_tensor = torch.tensor(
[material._offsets[channel] for material in materials],
dtype=torch.int32,
device=batch.values.device,
)
offsets = (offset_tensor, wp.from_torch(offset_tensor, dtype=wp.int32))
self._visual_material_selections[key] = offsets
trailing = tuple(batch.values.shape[1:])
expected = (len(materials), len(selected[0]), *trailing)
values = values.detach().to(device=batch.values.device, dtype=torch.float32)
if not per_env:
values = values.unsqueeze(1)
if tuple(values.shape) != expected:
raise ValueError(
f"Channel {channel!r} values must have shape {expected}; got {tuple(values.shape)}."
)
dtype = _MATERIAL_WRITES[trailing]
wp.launch(
_write_material,
dim=(len(materials), len(selected[0])),
inputs=[
wp.from_torch(values, dtype=dtype),
offsets[1],
selected[1],
self._visual_material_batch_views[channel],
],
device=str(batch.values.device),
)
material_offsets[channel] = offsets[1]
for writer in self._visual_material_writers:
writer(material_offsets, selected[1])
def ensure_prepare_stage(self, stage: Any, num_envs: int) -> None:
"""Call :meth:`BaseRenderer.prepare_stage` for each registered backend (once per backend).
If a new backend is added after the first :meth:`prepare_stage` call, this method ensures
that new backend is prepared for the same ``stage`` and ``num_envs`` when the camera
that owns it is initialized.
Args:
stage: USD stage passed to each backend.
num_envs: Environment count.
Raises:
RuntimeError: If :meth:`get_renderer` was never called, or ``num_envs`` disagrees with
a value already used for a prepared backend in this context.
"""
if not self._renderer_entries:
raise RuntimeError("get_renderer must be called at least once before ensure_prepare_stage.")
if self._prepared_num_envs is not None and self._prepared_num_envs != num_envs:
raise RuntimeError(
"RenderContext prepare_stage was used with a different num_envs "
f"({self._prepared_num_envs} vs {num_envs})."
)
for _cfg, renderer in self._renderer_entries:
rid = id(renderer)
if rid not in self._prepared_renderer_ids:
renderer.prepare_stage(stage, num_envs)
self._prepared_renderer_ids.add(rid)
if self._prepared_num_envs is None:
self._prepared_num_envs = num_envs
def update_scene_state(self, physics_step_count: int) -> None:
"""Update scene state on all backends (at most once per step).
Invokes :meth:`BaseRenderer.update_transforms` and then
:meth:`BaseRenderer.update_geometries` on each registered renderer.
"""
if not self._renderer_entries:
return
if self._last_scene_state_step == physics_step_count:
return
for _cfg, renderer in self._renderer_entries:
renderer.update_transforms()
renderer.update_geometries()
self._last_scene_state_step = physics_step_count
def render_into_camera(
self,
renderer: BaseRenderer,
render_data: Any,
camera_data: CameraData,
physics_step_count: int,
) -> None:
"""Sync scene state, render, and read outputs into ``camera_data``."""
self.update_scene_state(physics_step_count)
renderer.render(render_data)
renderer.read_output(render_data, camera_data)
def reset_stage_prepare_flag(self) -> None:
"""Allow :meth:`ensure_prepare_stage` to run ``prepare_stage`` again (e.g. a new USD stage)."""
self._prepared_renderer_ids.clear()
self._prepared_num_envs = None
def reset_scene_state_cadence(self) -> None:
"""Clear per-step scene state update dedupe (e.g. a long pause with no physics)."""
self._last_scene_state_step = None
def close(self) -> None:
"""Close every registered backend and drop it from this context.
Called from :meth:`~isaaclab.sim.simulation_context.SimulationContext.clear_instance` after
cameras have released their render data and before the stage is torn down, so
:meth:`BaseRenderer.close` runs while the stage is still alive. A backend that raises does
not prevent the others from closing; the failure is reported once every backend has been
given the chance. Idempotent.
Raises:
RuntimeError: If any backend's :meth:`BaseRenderer.close` raised.
"""
errors: list[Exception] = []
for writer in self._visual_material_writers:
try:
writer.close()
except Exception as exc: # noqa: BLE001 - reported after every resource is closed
logger.error("Error closing visual-material writer: %s", exc)
errors.append(exc)
for _cfg, renderer in self._renderer_entries:
try:
renderer.close()
except Exception as exc: # noqa: BLE001 - re-raised below once every backend is closed
logger.error("Error closing renderer %s: %s", type(renderer).__name__, exc)
errors.append(exc)
self._renderer_entries.clear()
self._prepared_renderer_ids.clear()
self._prepared_num_envs = None
self._last_scene_state_step = None
self._physics_initialized = False
self._visual_materials.clear()
self._visual_material_batches = ()
self._visual_material_batches_by_channel.clear()
self._visual_material_batch_views.clear()
self._visual_material_writers = ()
self._visual_material_selections.clear()
self._visual_material_env_ids.clear()
self._consumers_finalized = False
if errors:
# TODO: Use ExceptionGroup when ruff target-version is bumped to py311+
raise RuntimeError(f"{len(errors)} renderer(s) failed to close") from errors[0]