Source code for isaaclab.renderers.render_context

# 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
import os
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__)

RENDER_PROFILE_SCOPE = "IsaacLab::Renderer::render"
"""Name of the timed scope bracketing :meth:`BaseRenderer.render`, emitted when render profiling is on.

Every backend renders through the same call, so a profile can compare them under one scope name
instead of one internal name per backend. ``wp.ScopedTimer`` prints one ``"<name> took X.XX ms"``
line per call, which ``scripts/benchmarks/benchmark_renderer.py`` parses back out of the run log.
"""

_RENDER_PROFILE_ENABLED = os.environ.get("ISAACLAB_RENDER_PROFILE", "0") != "0"
"""Whether to time and print :data:`RENDER_PROFILE_SCOPE`, read once from ``ISAACLAB_RENDER_PROFILE``.

Off by default because the timer synchronizes the device on entry and exit. That is what lets it
measure completed device work rather than submitted work, but it also removes CPU/GPU overlap, so
an enabled run is a profiling aid and not a throughput measurement.
"""


@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``. Only the render itself is bracketed by :data:`RENDER_PROFILE_SCOPE`, so a profile attributes neither the scene-state sync before it nor the output readback after it to rendering. See :data:`_RENDER_PROFILE_ENABLED` for how to turn the timer on. """ self.update_scene_state(physics_step_count) with wp.ScopedTimer( RENDER_PROFILE_SCOPE, active=_RENDER_PROFILE_ENABLED, print=True, synchronize=True, ): 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]