"""
Camera sensors for rendering: Rasterizer, Raytracer, and Batch Renderer.
"""
import sys
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, NamedTuple, Optional, Type
import numpy as np
import torch
import genesis as gs
from genesis.options.renderers import BatchRenderer as BatchRendererOptions
from genesis.options.sensors import (
BatchRendererCameraOptions,
RasterizerCameraOptions,
RaytracerCameraOptions,
SensorOptions,
)
from genesis.options.vis import VisOptions
from genesis.utils.geom import (
T_to_quat,
T_to_trans,
pos_lookat_up_to_T,
trans_quat_to_T,
transform_by_quat,
transform_by_trans_quat,
)
from genesis.utils.misc import tensor_to_array
from genesis.vis.batch_renderer import BatchRenderer
from genesis.vis.rasterizer import Rasterizer
from genesis.vis.rasterizer_context import RasterizerContext
from .base_sensor import OptionsT, KinematicSensorMetadataMixin, KinematicSensorMixin, Sensor, SharedSensorMetadata
if TYPE_CHECKING:
from genesis.utils.ring_buffer import TensorRingBuffer
from genesis.vis.batch_renderer import BatchRenderer
from genesis.vis.rasterizer import Rasterizer
from genesis.vis.rasterizer_context import RasterizerContext
from genesis.vis.raytracer import Raytracer
from .sensor_manager import SensorManager
# ========================== Data Class ==========================
class CameraReturnType(NamedTuple):
"""Camera sensor return data."""
rgb: torch.Tensor
class MinimalVisualizerWrapper:
"""
Minimal visualizer wrapper for BatchRenderer camera sensors.
BatchRenderer requires a visualizer-like object to provide camera information and context, but camera sensors don't
need the full visualizer functionality (viewer, UI, etc.). This wrapper provides just the minimal interface expected
by BatchRenderer while avoiding the overhead of creating a full visualizer instance.
"""
def __init__(self, scene, sensors, vis_options):
self.scene = scene
self._cameras = [] # Will be populated with camera wrappers
self._sensors = sensors # Keep reference to sensors
# Create a minimal rasterizer context for camera frustum visualization (required by BatchRenderer even though
# cameras don't render frustums)
self._context = RasterizerContext(vis_options)
self._context.build(scene)
self._context.reset()
class BaseCameraWrapper:
"""Base class for camera wrappers to reduce code duplication."""
def __init__(self, sensor):
self.sensor = sensor
self.uid = sensor._idx
self.res = sensor._options.res
self.fov = sensor._options.fov
self.near = sensor._options.near
self.far = sensor._options.far
class RasterizerCameraWrapper(BaseCameraWrapper):
"""Lightweight wrapper object used by the rasterizer backend."""
def __init__(self, sensor: "RasterizerCameraSensor"):
super().__init__(sensor)
self.aspect_ratio = self.res[0] / self.res[1]
class BatchRendererCameraWrapper(BaseCameraWrapper):
"""Wrapper object used by the batch renderer backend."""
def __init__(self, sensor: "BatchRendererCameraSensor"):
super().__init__(sensor)
self.idx = len(sensor._shared_metadata.sensors) # Camera index in batch
self.model = sensor._options.model
# Initial pose
pos = torch.tensor(sensor._options.pos, dtype=gs.tc_float, device=gs.device)
lookat = torch.tensor(sensor._options.lookat, dtype=gs.tc_float, device=gs.device)
up = torch.tensor(sensor._options.up, dtype=gs.tc_float, device=gs.device)
# Store pos/lookat/up for later updates
self._pos = pos
self._lookat = lookat
self._up = up
self.transform = pos_lookat_up_to_T(pos, lookat, up)
def get_pos(self):
"""Get camera position (for batch renderer)."""
n_envs = self.sensor._manager._sim.n_envs
if self._pos.ndim > 1 or n_envs == 0:
return self._pos
return self._pos[None].expand((n_envs, -1))
def get_quat(self):
"""Get camera quaternion (for batch renderer)."""
quat = T_to_quat(self.transform)
n_envs = self.sensor._manager._sim.n_envs
if quat.ndim > 1 or n_envs == 0:
return quat
return quat[None].expand((n_envs, -1))
# ========================== Shared Metadata ==========================
@dataclass
class RasterizerCameraSharedMetadata(KinematicSensorMetadataMixin, SharedSensorMetadata):
"""Shared metadata for all Rasterizer cameras."""
# Rasterizer instance
renderer: Optional["Rasterizer"] = None
# RasterizerContext instance
context: Optional["RasterizerContext"] = None
# List of light dictionaries
lights: Optional[List[Dict[str, Any]]] = None
# List of RasterizerCameraSensor instances
sensors: Optional[List["RasterizerCameraSensor"]] = None
# {sensor_idx: np.ndarray with shape (B, H, W, 3)}
image_cache: Optional[Dict[int, np.ndarray]] = None
# Track when rasterizer cameras were last updated
last_render_timestep: int = -1
def destroy(self):
super().destroy()
if self.renderer is not None:
self.renderer.destroy()
self.renderer = None
if self.context is not None:
self.context.destroy()
self.context = None
self.lights = None
self.image_cache = None
self.sensors = None
@dataclass
class RaytracerCameraSharedMetadata(KinematicSensorMetadataMixin, SharedSensorMetadata):
"""Shared metadata for all Raytracer cameras."""
# Raytracer instance
renderer: Optional["Raytracer"] = None
# List of light objects
lights: Optional[List[Any]] = None
# List of RaytracerCameraSensor instances
sensors: Optional[List["RaytracerCameraSensor"]] = None
# {sensor_idx: np.ndarray with shape (B, H, W, 3)}
image_cache: Optional[Dict[int, np.ndarray]] = None
# Track when raytracer cameras were last updated
last_render_timestep: int = -1
def destroy(self):
super().destroy()
self.renderer = None
self.sensors = None
self.image_cache = None
@dataclass
class BatchRendererCameraSharedMetadata(KinematicSensorMetadataMixin, SharedSensorMetadata):
"""Shared metadata for all Batch Renderer cameras."""
# BatchRenderer instance
renderer: Optional["BatchRenderer"] = None
# gs.List of lights
lights: Optional[Any] = None
# List of BatchRendererCameraSensor instances
sensors: Optional[List["BatchRendererCameraSensor"]] = None
# {sensor_idx: np.ndarray with shape (B, H, W, 3)}
image_cache: Optional[Dict[int, np.ndarray]] = None
# Track when batch was last rendered
last_render_timestep: int = -1
# MinimalVisualizerWrapper instance
visualizer_wrapper: Optional["MinimalVisualizerWrapper"] = None
def destroy(self):
super().destroy()
self.renderer = None
self.sensors = None
self.image_cache = None
self.visualizer_wrapper = None
# ========================== Base Camera Sensor ==========================
class BaseCameraSensor(KinematicSensorMixin, Sensor[OptionsT, None, SharedSensorMetadata, CameraReturnType]):
"""
Base class for camera sensors that render RGB images into an internal image_cache.
This class centralizes:
- Attachment handling via KinematicSensorMixin
- The _stale flag used for auto-render-on-read
- Common Sensor cache integration (shape/dtype)
- Shared read() method returning torch tensors
"""
uses_ring_pipeline: ClassVar[bool] = False
def __init__(
self,
options: "SensorOptions",
idx: int,
shared_context,
shared_metadata,
manager: "SensorManager",
):
# `uses_ring_pipeline = False` triggers the generic delay / jitter / history rejection in `Sensor.__init__`.
super().__init__(options, idx, shared_context, shared_metadata, manager)
self._stale: bool = True
# ========================== Cache Integration (shared) ==========================
def _get_return_format(self) -> tuple[tuple[int, ...], ...]:
w, h = self._options.res
return ((h, w, 3),)
@classmethod
def _get_cache_dtype(cls) -> torch.dtype:
return torch.uint8
@classmethod
def _update_shared_cache(
cls,
shared_context: None,
shared_metadata: SharedSensorMetadata,
current_ground_truth_data_T: torch.Tensor,
ground_truth_data_timeline: "TensorRingBuffer | None",
measured_data_timeline: "TensorRingBuffer | None",
intermediate_cache: torch.Tensor,
):
# No per-step cache update for cameras (handled lazily on read()). `BaseCameraSensor` declares
# `uses_ring_pipeline = False`, so the manager passes both timeline rings as ``None`` here.
pass
def _draw_debug(self, context: "RasterizerContext"):
"""No debug drawing for cameras."""
pass
# ========================== Attachment handling ==========================
@gs.assert_built
def move_to_attach(self):
"""
Move the camera to follow the currently attached rigid link.
Uses a shared transform computation and delegates to _apply_camera_transform().
"""
if self._link is None:
gs.raise_exception("Camera not attached to any rigid link.")
if self._options.offset_T is not None:
offset_T = torch.tensor(self._options.offset_T, dtype=gs.tc_float, device=gs.device)
else:
pos = torch.tensor(self._options.pos, dtype=gs.tc_float, device=gs.device)
lookat = torch.tensor(self._options.lookat, dtype=gs.tc_float, device=gs.device)
up = torch.tensor(self._options.up, dtype=gs.tc_float, device=gs.device)
offset_T = pos_lookat_up_to_T(pos, lookat, up)
link_pos = self._link.get_pos(relative=False)
link_quat = self._link.get_quat(relative=False)
link_T = trans_quat_to_T(link_pos, link_quat)
camera_T = torch.matmul(link_T, offset_T)
self._apply_camera_transform(camera_T)
# ========================== Hooks for subclasses ==========================
def _apply_camera_transform(self, camera_T: torch.Tensor):
"""Apply the computed camera transform to the backend-specific camera representation."""
raise NotImplementedError
def _render_current_state(self):
"""Perform the actual render for the current state; subclasses must implement."""
raise NotImplementedError
# ========================== Shared read() ==========================
def _get_image_cache_entry(self):
"""Return this sensor's entry in the shared image cache."""
return self._shared_metadata.image_cache[self._idx]
def _ensure_rendered_for_current_state(self):
"""Ensure this camera has an up-to-date render before reading.
Base handles staleness and timestamps; subclasses implement _render_current_state().
"""
scene = self._manager._sim.scene
# If the scene time advanced, mark all cameras as stale
if self._shared_metadata.last_render_timestep != scene.t:
if self._shared_metadata.sensors is not None:
for sensor in self._shared_metadata.sensors:
sensor._stale = True
self._shared_metadata.last_render_timestep = scene.t
# If this camera is not stale, cache is considered fresh
if not self._stale:
return
# Update camera pose only when attached; detached cameras keep their last world pose
if self._link is not None:
self.move_to_attach()
# Call subclass-specific render
self._render_current_state()
# Mark as fresh
self._stale = False
def _sanitize_envs_idx(self, envs_idx):
"""Sanitize envs_idx to valid indices."""
if envs_idx is None:
return None
if isinstance(envs_idx, (int, np.integer)):
return envs_idx
return np.asarray(envs_idx)
@gs.assert_built
def read(self, envs_idx=None) -> CameraReturnType:
"""Render if needed, then read the cached image from the backend-specific cache."""
self._ensure_rendered_for_current_state()
cached_image = self._get_image_cache_entry()
return _camera_read_from_image_cache(self, cached_image, envs_idx, to_numpy=False)
# ========================== Camera Sensor Helpers ==========================
def _camera_read_from_image_cache(sensor, cached_image, envs_idx, *, to_numpy: bool) -> CameraReturnType:
"""
Shared helper to convert a cached RGB image array into CameraReturnType with correct env handling.
Parameters
----------
sensor : any camera sensor with _manager and _return_data_class
cached_image : np.ndarray | torch.Tensor
Image cache for this camera, shaped (B, H, W, 3) or (H, W, 3) depending on n_envs.
envs_idx : None | int | sequence
Environment index/indices to select.
to_numpy : bool
If True and cached_image is a torch Tensor, convert to numpy first.
"""
if to_numpy and isinstance(cached_image, torch.Tensor):
cached_image = tensor_to_array(cached_image)
if envs_idx is None:
if sensor._manager._sim.n_envs == 0:
return sensor._return_data_class(rgb=cached_image[0])
return sensor._return_data_class(rgb=cached_image)
if isinstance(envs_idx, (int, np.integer)):
return sensor._return_data_class(rgb=cached_image[envs_idx])
return sensor._return_data_class(rgb=cached_image[envs_idx])
# ========================== Rasterizer Camera Sensor ==========================
[docs]class RasterizerCameraSensor(
BaseCameraSensor, Sensor[RasterizerCameraOptions, None, RasterizerCameraSharedMetadata, CameraReturnType]
):
"""
Rasterizer camera sensor using OpenGL-based rendering.
This sensor renders RGB images using the existing Rasterizer backend, but operates independently from the scene
visualizer.
"""
def __init__(
self,
options: RasterizerCameraOptions,
idx: int,
shared_context,
shared_metadata,
manager: "SensorManager",
):
super().__init__(options, idx, shared_context, shared_metadata, manager)
self._options: RasterizerCameraOptions
self._camera_node = None
self._camera_target = None
self._camera_wrapper = None
self._is_camera_registered = False
# ========================== Sensor Lifecycle ==========================
[docs] def build(self):
"""Initialize the rasterizer and register this camera."""
super().build()
scene = self._manager._sim.scene
if self._shared_metadata.sensors is None:
self._shared_metadata.sensors = []
self._shared_metadata.lights = gs.List()
self._shared_metadata.image_cache = {}
# If a viewer is active, reuse its windowed OpenGL context for both offscreen and onscreen rendering, rather
# than creating a separate headless context which is fragile.
if scene.viewer is not None:
self._shared_metadata.context = scene.visualizer.context
self._shared_metadata.renderer = scene.visualizer.rasterizer
else:
# No viewer - create standalone rasterizer with offscreen context
self._shared_metadata.context = self._create_standalone_context(scene)
self._shared_metadata.renderer = Rasterizer(viewer=None, context=self._shared_metadata.context)
self._shared_metadata.renderer.build()
self._shared_metadata.sensors.append(self)
if self._manager._sim.n_envs > 1 and not self._shared_metadata.context.env_separate_rigid:
gs.raise_exception(
"RasterizerCameraSensor with n_envs > 1 requires 'env_separate_rigid=True' in VisOptions "
"for correct per-environment rendering."
)
# Register camera now if standalone (offscreen), or defer to first render if using visualizer's rasterizer
# (visualizer isn't built yet at sensor.build() time)
if self._shared_metadata.renderer.offscreen:
self._ensure_camera_registered()
_B = max(self._manager._sim.n_envs, 1)
w, h = self._options.res
self._shared_metadata.image_cache[self._idx] = torch.zeros((_B, h, w, 3), dtype=torch.uint8, device=gs.device)
def _ensure_camera_registered(self):
"""Register this camera with the renderer (no-op if already registered)."""
if self._is_camera_registered:
return
# Add lights from options to the context
for light_config in self._options.lights:
if self._shared_metadata.lights is not None:
light_dict = self._convert_light_config_to_rasterizer(light_config)
self._shared_metadata.context.add_light(light_dict)
if self._camera_wrapper is None:
self._camera_wrapper = RasterizerCameraWrapper(self)
self._shared_metadata.renderer.add_camera(self._camera_wrapper)
self._update_camera_pose()
self._is_camera_registered = True
def _create_standalone_context(self, scene):
"""Create a simplified RasterizerContext for camera sensors."""
if not scene.sim._rigid_only and scene.n_envs > 1:
gs.raise_exception("Rasterizer with n_envs > 1, does not work when using non rigid simulation")
if scene.n_envs > 1:
gs.logger.warning(
"Rasterizer with n_envs > 1 is slow as it doesn't do batched rendering consider using BatchRenderer instead."
)
env_separate_rigid = True
vis_options = VisOptions(
show_world_frame=False,
show_link_frame=False,
show_cameras=False,
rendered_envs_idx=range(max(self._manager._sim._B, 1)),
env_separate_rigid=env_separate_rigid,
)
context = RasterizerContext(vis_options)
context.build(scene)
context.reset()
return context
@staticmethod
def _convert_light_config_to_rasterizer(light_config):
"""Convert a light config dict to a typed light options object for the rasterizer."""
from genesis.options.vis import DirectionalLight, PointLight
light_type = light_config.get("type", "directional")
color = light_config.get("color", (1.0, 1.0, 1.0))
intensity = light_config.get("intensity", 1.0)
if light_type == "point":
pos = light_config.get("pos", (0.0, 0.0, 5.0))
return PointLight(pos=pos, color=color, intensity=intensity)
else:
dir = light_config.get("dir", (0.0, 0.0, -1.0))
return DirectionalLight(dir=dir, color=color, intensity=intensity)
def _update_camera_pose(self):
"""Update camera pose based on options."""
pos = torch.tensor(self._options.pos, dtype=gs.tc_float, device=gs.device)
lookat = torch.tensor(self._options.lookat, dtype=gs.tc_float, device=gs.device)
up = torch.tensor(self._options.up, dtype=gs.tc_float, device=gs.device)
# If attached to a link and the link is built, pos is relative to link frame
if self._link is not None and self._link.is_built:
# Convert pos from link-relative to world coordinates
link_pos = self._link.get_pos(relative=False)
link_quat = self._link.get_quat(relative=False)
# Apply pos directly as offset from link
pos_world = transform_by_quat(pos, link_quat) + link_pos
pos = pos_world
elif self._link is not None:
# Link exists but not built yet - use configured pose as-is (treat as world coordinates for now) This will
# be corrected when move_to_attach is called
pass
transform = pos_lookat_up_to_T(pos, lookat, up)
self._camera_wrapper.transform = tensor_to_array(transform)
self._shared_metadata.renderer.update_camera(self._camera_wrapper)
def _apply_camera_transform(self, camera_T: torch.Tensor):
"""Update rasterizer camera wrapper from a world transform."""
self._ensure_camera_registered()
self._camera_wrapper.transform = tensor_to_array(camera_T)
self._shared_metadata.renderer.update_camera(self._camera_wrapper)
def _render_current_state(self):
"""Perform the actual render for the current state."""
self._ensure_camera_registered()
context = self._shared_metadata.context
# When env_separate_rigid is enabled, geometry render transforms include env_spacing offsets (baked in by
# kernel_update_geoms_render_T). For per-env sensor rendering, these offsets must be temporarily removed so each
# env's geometry renders at local origin relative to the camera. The offsets are restored after rendering to
# preserve the correct layout for the interactive viewer which shares the same context.
context.update(force_render=True)
# When env_separate_rigid is enabled, geometry render transforms include env_spacing offsets (baked in by
# kernel_update_geoms_render_T). For per-env sensor rendering, these offsets must be temporarily removed so each
# env's geometry renders at local origin relative to the camera. The offsets are restored after rendering to
# preserve the correct layout for the interactive viewer which shares the same context.
envs_offset = context.scene.envs_offset
saved_poses = {}
if context.env_separate_rigid and (envs_offset != 0).any():
for node_uid, node in context.rigid_nodes.items():
poses = node.mesh.primitives[0].poses
if poses is not None and len(poses) > 1:
saved_poses[node_uid] = poses.copy()
poses[:, :3, 3] -= envs_offset[context.rendered_envs_idx]
context.jit.update_buffer(node, "model", poses.transpose((0, 2, 1)))
rgb_arr, _, _, _ = self._shared_metadata.renderer.render_camera(
self._camera_wrapper, rgb=True, depth=False, segmentation=False, normal=False
)
# Restore original geometry transforms with offsets for the interactive viewer
for node_uid, poses in saved_poses.items():
node = context.rigid_nodes[node_uid]
node.mesh.primitives[0].poses = poses
context.jit.update_buffer(node, "model", poses.transpose((0, 2, 1)))
# Ensure contiguous layout because the rendered array may have negative strides.
rgb_tensor = torch.from_numpy(np.ascontiguousarray(rgb_arr)).to(dtype=torch.uint8, device=gs.device)
if len(rgb_tensor.shape) == 3:
# Single environment rendered - add batch dimension.
rgb_tensor = rgb_tensor.unsqueeze(0)
self._shared_metadata.image_cache[self._idx][:] = rgb_tensor
# ========================== Raytracer Camera Sensor ==========================
[docs]class RaytracerCameraSensor(
BaseCameraSensor, Sensor[RaytracerCameraOptions, None, RaytracerCameraSharedMetadata, CameraReturnType]
):
"""
Raytracer camera sensor using LuisaRender path tracing.
"""
def __init__(
self,
options: RaytracerCameraOptions,
idx: int,
shared_context,
shared_metadata,
manager: "SensorManager",
):
super().__init__(options, idx, shared_context, shared_metadata, manager)
self._options: RaytracerCameraOptions
self._camera_obj = None
[docs] def build(self):
"""Register a raytracer camera that reuses the visualizer pipeline."""
super().build()
scene = self._manager._sim.scene
visualizer = scene.visualizer
renderer = getattr(visualizer, "raytracer", None)
if renderer is None:
gs.raise_exception(
"RaytracerCameraSensor requires the scene to be created with `renderer=gs.renderers.RayTracer(...)`."
)
# Multi-environment rendering is not yet supported for Raytracer cameras
n_envs = self._manager._sim.n_envs
if n_envs > 1:
gs.raise_exception(
f"Raytracer camera sensors do not support multi-environment rendering (n_envs={n_envs}). "
"Use BatchRenderer camera sensors for batched rendering."
)
if self._shared_metadata.sensors is None:
self._shared_metadata.sensors = []
self._shared_metadata.lights = []
self._shared_metadata.image_cache = {}
self._shared_metadata.renderer = renderer
self._shared_metadata.sensors.append(self)
# Add lights from options as mesh lights to the scene
scene = self._manager._sim.scene
for light_config in self._options.lights:
if not scene.is_built:
self._add_light_as_mesh_light(scene, light_config)
# Compute world pose for the camera
pos = torch.tensor(self._options.pos, dtype=gs.tc_float, device=gs.device)
lookat = torch.tensor(self._options.lookat, dtype=gs.tc_float, device=gs.device)
up = torch.tensor(self._options.up, dtype=gs.tc_float, device=gs.device)
# If attached to a link and the link is built, transform pos to world coordinates
if self._link is not None and self._link.is_built:
link_pos = self._link.get_pos(relative=False).squeeze(0)
link_quat = self._link.get_quat(relative=False).squeeze(0)
# Apply pos directly as offset from link
pos = transform_by_trans_quat(pos, link_pos, link_quat)
# Transform lookat and up (no rotation offset since rotation is defined by lookat/up)
lookat = transform_by_trans_quat(lookat, link_pos, link_quat)
up = transform_by_quat(up, link_quat)
elif self._link is not None:
# Link exists but not built yet - use configured pose as-is (treat as world coordinates for now) This will
# be corrected when move_to_attach is called
pass
self._camera_obj = visualizer.add_camera(
res=self._options.res,
pos=pos,
lookat=lookat,
up=up,
model=self._options.model,
fov=self._options.fov,
aperture=self._options.aperture,
focus_dist=self._options.focus_dist,
GUI=False,
spp=self._options.spp,
denoise=self._options.denoise,
near=0.05,
far=100.0,
env_idx=None if n_envs == 0 else 0,
debug=False,
)
# Attach the visualizer camera to the link if this sensor is attached
if self._link is not None:
if self._options.offset_T is not None:
offset_T = torch.tensor(self._options.offset_T, dtype=gs.tc_float, device=gs.device)
else:
pos = torch.tensor(self._options.pos, dtype=gs.tc_float, device=gs.device)
lookat = torch.tensor(self._options.lookat, dtype=gs.tc_float, device=gs.device)
up = torch.tensor(self._options.up, dtype=gs.tc_float, device=gs.device)
offset_T = pos_lookat_up_to_T(pos, lookat, up)
self._camera_obj.attach(self._link, offset_T)
_B = max(n_envs, 1)
w, h = self._options.res
self._shared_metadata.image_cache[self._idx] = torch.zeros((_B, h, w, 3), dtype=torch.uint8, device=gs.device)
[docs] @gs.assert_built
def move_to_attach(self):
# Bypass original implementation since it will be handled by visualizer
pass
def _add_light_as_mesh_light(self, scene, light_config):
"""Add a light as a mesh light to the scene."""
# Default values for raytracer mesh lights
color = light_config.get("color", (1.0, 1.0, 1.0))
intensity = light_config.get("intensity", 1.0)
radius = light_config.get("radius", 0.5)
pos = light_config.get("pos", (0.0, 0.0, 5.0))
revert_dir = light_config.get("revert_dir", False)
double_sided = light_config.get("double_sided", False)
cutoff = light_config.get("cutoff", 180.0)
morph = gs.morphs.Sphere(pos=pos, radius=radius)
scene.add_mesh_light(
morph=morph,
color=(*color, 1.0),
intensity=intensity,
revert_dir=revert_dir,
double_sided=double_sided,
cutoff=cutoff,
)
def _render_current_state(self):
"""Perform the actual render for the current state."""
if self._link is not None:
self._camera_obj.move_to_attach()
rgb_arr, _, _, _ = self._camera_obj.render(
rgb=True,
depth=False,
segmentation=False,
colorize_seg=False,
normal=False,
antialiasing=False,
force_render=True,
)
# Ensure contiguous layout because the rendered array may have negative strides.
rgb_tensor = torch.from_numpy(np.ascontiguousarray(rgb_arr)).to(dtype=torch.uint8, device=gs.device)
self._shared_metadata.image_cache[self._idx][0] = rgb_tensor
# ========================== Batch Renderer Camera Sensor ==========================
[docs]class BatchRendererCameraSensor(
BaseCameraSensor, Sensor[BatchRendererCameraOptions, None, BatchRendererCameraSharedMetadata, CameraReturnType]
):
"""
Batch renderer camera sensor using Madrona GPU batch rendering.
Note: All batch renderer cameras must have the same resolution.
"""
def __init__(
self,
options: BatchRendererCameraOptions,
idx: int,
shared_context,
shared_metadata,
manager: "SensorManager",
):
super().__init__(options, idx, shared_context, shared_metadata, manager)
self._options: BatchRendererCameraOptions
self._camera_obj = None
[docs] def build(self):
"""Initialize the batch renderer and register this camera."""
super().build()
if gs.backend != gs.cuda:
gs.raise_exception("BatchRendererCameraSensor requires CUDA backend.")
scene = self._manager._sim.scene
if self._shared_metadata.sensors is None:
self._shared_metadata.sensors = []
self._shared_metadata.lights = gs.List()
self._shared_metadata.image_cache = {}
self._shared_metadata.last_render_timestep = -1
all_sensors = self._manager._sensors_by_type[type(self)]
resolutions = [s._options.res for s in all_sensors]
if len(set(resolutions)) > 1:
gs.raise_exception(
f"All BatchRendererCameraSensor instances must have the same resolution. Found: {set(resolutions)}"
)
br_options = BatchRendererOptions(use_rasterizer=self._options.use_rasterizer)
vis_options = VisOptions(
show_world_frame=False,
show_link_frame=False,
show_cameras=False,
rendered_envs_idx=range(max(self._manager._sim._B, 1)),
)
self._shared_metadata.visualizer_wrapper = MinimalVisualizerWrapper(scene, all_sensors, vis_options)
self._shared_metadata.renderer = BatchRenderer(
self._shared_metadata.visualizer_wrapper, br_options, vis_options
)
self._shared_metadata.sensors.append(self)
# Add lights from options to the renderer
for light_config in self._options.lights:
if self._shared_metadata.renderer is not None:
self._add_light_to_batch_renderer(light_config)
self._camera_obj = BatchRendererCameraWrapper(self)
if len(self._shared_metadata.sensors) == len(self._manager._sensors_by_type[type(self)]):
self._shared_metadata.visualizer_wrapper._cameras = [s._camera_obj for s in self._shared_metadata.sensors]
self._shared_metadata.renderer.build()
_B = max(self._manager._sim.n_envs, 1)
w, h = self._options.res
self._shared_metadata.image_cache[self._idx] = torch.zeros((_B, h, w, 3), dtype=torch.uint8, device=gs.device)
def _render_current_state(self):
"""Perform the actual render for the current state."""
sensors = self._shared_metadata.sensors or [self]
for sensor in sensors:
if sensor._link is not None:
sensor.move_to_attach()
self._shared_metadata.renderer.update_scene(force_render=True)
rgb_arr, *_ = self._shared_metadata.renderer.render(
rgb=True, depth=False, segmentation=False, normal=False, antialiasing=False, force_render=True
)
# rgb_arr might be a tuple of arrays (one per camera) or a single array
if isinstance(rgb_arr, (tuple, list)):
rgb_arrs = [torch.as_tensor(arr).to(dtype=torch.uint8, device=gs.device) for arr in rgb_arr]
else:
rgb_arrs = torch.as_tensor(rgb_arr).to(dtype=torch.uint8, device=gs.device)
for sensor, rgb_arr in zip(sensors, rgb_arrs):
sensor._shared_metadata.image_cache[sensor._idx][:] = rgb_arr
sensor._stale = False
self._shared_metadata.last_render_timestep = self._manager._sim.scene.t
def _apply_camera_transform(self, camera_T: torch.Tensor):
"""Update batch renderer camera from a world transform."""
# Note: BatchRenderer will pick up the updated transform on next render
self._camera_obj.transform = camera_T
self._camera_obj._pos = T_to_trans(camera_T)
def _add_light_to_batch_renderer(self, light_config):
"""Add a light to the batch renderer."""
# Default values for batch renderer
pos = light_config.get("pos", (0.0, 0.0, 5.0))
dir = light_config.get("dir", (0.0, 0.0, -1.0))
color = light_config.get("color", (1.0, 1.0, 1.0))
intensity = light_config.get("intensity", 1.0)
directional = light_config.get("directional", True)
castshadow = light_config.get("castshadow", True)
cutoff = light_config.get("cutoff", 45.0)
attenuation = light_config.get("attenuation", (1.0, 0.0, 0.0))
self._shared_metadata.renderer.add_light(
pos=pos,
dir=dir,
color=color,
intensity=intensity,
directional=directional,
castshadow=castshadow,
cutoff=cutoff,
attenuation=attenuation,
)