"""Default renderer — Vulkan forward path; implements the RendererBackend ABC."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
import numpy as np
import vulkan as vk
if TYPE_CHECKING:
from ..engine import CubemapHandle
from .._types import (
LIGHT_DTYPE,
MATERIAL_DTYPE,
MeshHandle,
)
from ..gpu.memory import upload_numpy
from ..scene.frustum import Frustum
from ._base import RendererBackend
from .buffer_manager import SHADOW_DATA_SIZE, BufferManager
from .custom_post_process import CustomPostProcessPass
from .draw2d_pass import Draw2DPass
from .environment_sync import EnvironmentSync
from .fog_pass import FogPass
from .gizmo_pass import GizmoRenderData
from .gpu_batch import GPUBatch
from .light2d_pass import Light2DPass
from .overlay_renderer import OverlayRenderer
from .pass_orchestrator import PassOrchestrator
from .pipeline_manager import PipelineManager
from .post_process import PostProcessPass
from .scene_renderer import SceneContentRenderer
from .shadow_renderer import ShadowRenderer
from .ssao_pass import SSAOPass
from .text_pass import TextPass
from .viewport_manager import ViewportManager
__all__ = ["Renderer"]
log = logging.getLogger(__name__)
# Default ambient colour — used by the IBL-only fallback path that bypasses
# the regular shadow-pass upload.
_DEFAULT_AMBIENT = np.array([0.15, 0.15, 0.2, 1.0], dtype=np.float32)
[docs]
class Renderer(RendererBackend):
"""Default Vulkan forward renderer — multi-draw indirect, per-viewport frustum culling."""
def __init__(self, engine: Any, max_objects: int = 10_000):
self._engine = engine
self._max_objects = max_objects
# Subsystems
self.viewport_manager = ViewportManager()
self._frustum = Frustum()
# Delegated renderers (created after __init__ state is set up)
self._scene_renderer = SceneContentRenderer(self)
self._shadow_renderer = ShadowRenderer(self)
self._overlay_renderer = OverlayRenderer(self)
self._env_sync = EnvironmentSync(self)
# Per-frame submission lists
self._instances: list[tuple[MeshHandle, np.ndarray, int, int]] = []
self._dynamic_draws: list[tuple[np.ndarray, np.ndarray, np.ndarray, int, int]] = []
# GPU resources (created in setup)
self._pipelines = PipelineManager(engine)
self._buffers = BufferManager(engine, max_objects)
self._passes = PassOrchestrator(engine)
self._batch: GPUBatch | None = None
# Material/light data (set externally via set_materials/set_lights)
self._materials: np.ndarray = np.zeros(1, dtype=MATERIAL_DTYPE)
self._lights: np.ndarray = np.zeros(1, dtype=LIGHT_DTYPE)
# Per-frame particle submissions (stored here; passes consume them)
self._particle_submissions: list[tuple[np.ndarray, int]] = [] # (data, count)
self._gpu_particle_submissions: list[dict] = [] # emitter_config dicts
# Per-frame ShaderMaterial-backed submissions. Each entry is
# (mesh_handle, transform, material_id, shader_material). SceneAdapter
# populates this when a MeshInstance3D carries a custom shader;
# ContentRenderer.render picks up the bucket and issues draws with
# the per-material pipeline (lazy-compiled + cached via the manager).
self._shader_material_submissions: list = []
self._shader_material_manager: Any = None
# IBL enabled once a skybox cubemap is set
self._ibl_enabled: bool = False
# Skinned mesh instances (mesh_handle, transform, material_id, joint_matrices)
self._skinned_instances: list[tuple[MeshHandle, np.ndarray, int, np.ndarray]] = []
# 2D overlay text renderer (shared across frames, set up lazily)
self._text_renderer: Any = None
# Debug line rendering (OverlayRenderer lazily populates these)
self._debug_pipeline: Any = None
self._debug_pipeline_layout: Any = None
self._debug_vb: Any = None
self._debug_vb_mem: Any = None
self._debug_vb_capacity: int = 0
self._debug_vert_module: Any = None
self._debug_frag_module: Any = None
# Track whether HDR was rendered this frame (guards tonemap)
self._hdr_rendered = False
self._ready = False
# -- Pipeline accessors (delegate to PipelineManager; keep attribute names
# stable so SceneContentRenderer and other consumers keep working).
@property
def _pipeline(self) -> Any:
return self._pipelines.opaque
@property
def _pipeline_layout(self) -> Any:
return self._pipelines.opaque_layout
@property
def _nocull_pipeline(self) -> Any:
return self._pipelines.nocull
@property
def _nocull_pipeline_layout(self) -> Any:
return self._pipelines.nocull_layout
@property
def _transparent_pipeline(self) -> Any:
return self._pipelines.transparent
@property
def _transparent_pipeline_layout(self) -> Any:
return self._pipelines.transparent_layout
@property
def _skinned_pipeline(self) -> Any:
return self._pipelines.skinned
@property
def _skinned_pipeline_layout(self) -> Any:
return self._pipelines.skinned_layout
@property
def _vert_module(self) -> Any:
return self._pipelines.vert_module
@property
def _frag_module(self) -> Any:
return self._pipelines.frag_module
@property
def _skinned_vert_module(self) -> Any:
return self._pipelines.skinned_vert_module
# -- Buffer accessors (delegate to BufferManager; keep attribute names
# stable so ShadowRenderer, SceneContentRenderer and OverlayRenderer
# continue to access `r._shadow_mem`, `r._transform_mem` etc. unchanged).
@property
def _ssbo_layout(self) -> Any:
return self._buffers.ssbo_layout
@property
def _ssbo_set(self) -> Any:
return self._buffers.ssbo_set
@property
def _transform_mem(self) -> Any:
return self._buffers.transform_mem
@property
def _material_mem(self) -> Any:
return self._buffers.material_mem
@property
def _light_mem(self) -> Any:
return self._buffers.light_mem
@property
def _shadow_buf(self) -> Any:
return self._buffers.shadow_buf
@property
def _shadow_mem(self) -> Any:
return self._buffers.shadow_mem
@property
def _joint_layout(self) -> Any:
return self._buffers.joint_layout
@property
def _joint_set(self) -> Any:
return self._buffers.joint_set
@property
def _joint_mem(self) -> Any:
return self._buffers.joint_mem
@property
def _max_materials(self) -> int:
return self._buffers.max_materials
# -- Pass accessors (delegate to PassOrchestrator; keep old names stable).
@property
def _post_process(self) -> PostProcessPass | None:
return self._passes.post_process
@property
def _custom_pp(self) -> CustomPostProcessPass | None:
return self._passes.custom_pp
@property
def _ssao_pass(self) -> SSAOPass | None:
return self._passes.ssao_pass
@property
def _fog_pass(self) -> FogPass | None:
return self._passes.fog_pass
@property
def _skybox_pass(self) -> Any:
return self._passes.skybox_pass
@property
def _shadow_pass(self) -> Any:
return self._passes.shadow_pass
@property
def _point_shadow_pass(self) -> Any:
return self._passes.point_shadow_pass
@property
def _particle_pass(self) -> Any:
return self._passes.particle_pass
@property
def _particle_compute(self) -> Any:
return self._passes.particle_compute
@_particle_compute.setter
def _particle_compute(self, value: Any) -> None:
"""OverlayRenderer lazily creates the compute pass on first GPU submission."""
self._passes.particle_compute = value
@property
def _tilemap_pass(self) -> Any:
return self._passes.tilemap_pass
@property
def _light2d_pass(self) -> Light2DPass | None:
return self._passes.light2d_pass
@property
def _text_pass(self) -> TextPass | None:
return self._passes.text_pass
@property
def _draw2d_pass(self) -> Draw2DPass | None:
return self._passes.draw2d_pass
@property
def _grid_pass(self) -> Any:
return self._passes.grid_pass
@property
def _gizmo_pass(self) -> Any:
return self._passes.gizmo_pass
@property
def _gizmo_render_data(self) -> GizmoRenderData | None:
return self._passes.gizmo_render_data
@_gizmo_render_data.setter
def _gizmo_render_data(self, value: GizmoRenderData | None) -> None:
self._passes.gizmo_render_data = value
[docs]
def setup(self) -> None:
"""Initialize GPU resources — called once after engine Vulkan init."""
e = self._engine
device = e.ctx.device
phys = e.ctx.physical_device
# SSBOs + descriptor sets + IBL cubemap placeholder
self._buffers.setup()
upload_numpy(device, self._buffers.material_mem, self._materials)
upload_numpy(device, self._buffers.light_mem, self._lights)
# Batch renderers — separate indirect buffers so opaque/transparent
# don't overwrite each other before the GPU executes draw commands.
use_mdi = getattr(e, "_has_mdi", True)
self._batch = GPUBatch(device, phys, max_draws=self._max_objects, use_mdi=use_mdi)
self._transparent_batch = GPUBatch(device, phys, max_draws=1000, use_mdi=use_mdi)
# 3D graphics pipelines (opaque, double-sided, transparent, skinned)
self._pipelines.setup(self._buffers.ssbo_layout, self._buffers.joint_layout)
# All render passes (shadow/particle/tilemap/2D/post-process/SSAO/fog/...)
self._passes.setup(self._buffers.ssbo_layout, self._pipelines)
# Text overlay renderer (shared across frames, caches atlases)
from ..text_renderer import get_shared_text_renderer
self._text_renderer = get_shared_text_renderer()
self._ready = True
[docs]
def set_skybox(self, cubemap: CubemapHandle) -> None:
"""Set a cubemap as the skybox and enable IBL.
Accepts a :class:`~simvx.graphics.engine.CubemapHandle` returned by
:meth:`Engine.load_cubemap`. The renderer takes ownership of the
underlying Vulkan resources and destroys them at shutdown.
"""
from ..engine import CubemapHandle
if not isinstance(cubemap, CubemapHandle):
raise TypeError(
f"set_skybox expects a CubemapHandle (from Engine.load_cubemap), "
f"got {type(cubemap).__name__}",
)
self._passes.set_skybox(
cubemap.view, cubemap.sampler, self._buffers, cubemap.image, cubemap.memory,
)
self._ibl_enabled = True
[docs]
@property
def post_processing(self) -> PostProcessPass | None:
"""Access post-processing pass for configuration."""
return self._post_process
[docs]
@property
def custom_post_processing(self) -> CustomPostProcessPass | None:
"""Access custom user post-process pass for configuration."""
return self._custom_pp
[docs]
def set_gizmo_data(self, data: GizmoRenderData | None) -> None:
"""Set gizmo render data for the current frame (or None to hide)."""
self._gizmo_render_data = data
[docs]
@property
def ssao(self) -> SSAOPass | None:
"""Access SSAO pass for configuration."""
return self._ssao_pass
[docs]
@property
def fog(self) -> FogPass | None:
"""Access fog pass for configuration."""
return self._fog_pass
[docs]
def set_materials(self, materials: np.ndarray) -> None:
"""Set material array and upload to GPU (skips if unchanged)."""
self._materials = self._buffers.set_materials(materials)
[docs]
def set_lights(self, lights: np.ndarray) -> None:
"""Set light array and upload to GPU (skips if unchanged).
Prepends uint32 light_count to match GLSL LightBuffer layout:
[uint32 count][Light[0]][Light[1]]...
"""
self._lights = lights
self._buffers.set_lights(lights)
[docs]
def submit_text(
self,
text: str,
x: float,
y: float,
font_path: str | None = None,
size: float = 24.0,
colour: tuple = (1.0, 1.0, 1.0, 1.0),
) -> None:
"""Submit text for 2D overlay rendering."""
if self._text_renderer:
self._text_renderer.draw_text(text, x, y, font_path=font_path, size=size, colour=colour)
# --- Renderer ABC ---
[docs]
def init(self, device: Any, swapchain: Any) -> None:
"""Initialize (called by ABC contract — use setup() instead)."""
self.setup()
[docs]
def begin_frame(self) -> Any:
"""Begin frame — clear submission lists."""
self._instances.clear()
self._dynamic_draws.clear()
self._particle_submissions.clear()
self._gpu_particle_submissions.clear()
self._skinned_instances.clear()
self._shader_material_submissions.clear()
self._passes.begin_frame()
if self._text_renderer:
self._text_renderer.begin_frame()
return None # Command buffer managed by engine
[docs]
def submit_instance(
self,
mesh_handle: MeshHandle,
transform: np.ndarray,
material_id: int = 0,
viewport_id: int = 0,
) -> None:
"""Submit a mesh instance for rendering this frame."""
self._instances.append((mesh_handle, transform, material_id, viewport_id))
[docs]
def submit_multimesh(
self,
mesh_handle: MeshHandle,
transforms: np.ndarray,
material_id: int = 0,
material_ids: np.ndarray | None = None,
viewport_id: int = 0,
) -> None:
"""Bulk-submit many instances of the same mesh — avoids per-instance Python loops.
Args:
mesh_handle: Shared mesh for all instances.
transforms: (N, 4, 4) float32 array of model matrices.
material_id: Material index for all instances (ignored if *material_ids* given).
material_ids: Optional (N,) uint32 array of per-instance material indices.
viewport_id: Viewport index.
"""
n = transforms.shape[0]
if material_ids is not None:
for i in range(n):
self._instances.append((mesh_handle, transforms[i], int(material_ids[i]), viewport_id))
else:
for i in range(n):
self._instances.append((mesh_handle, transforms[i], material_id, viewport_id))
[docs]
def submit_shader_instance(
self,
mesh_handle: MeshHandle,
transform: np.ndarray,
material_id: int,
shader_material: Any,
) -> None:
"""Submit a MeshInstance3D that carries a ShaderMaterial.
The per-material pipeline is lazy-compiled + cached on first use via
ShaderMaterialManager. During the forward draw, the renderer splits
these submissions into their own bucket, binds the custom pipeline,
updates the uniform buffer, and draws one mesh at a time — per-
material pipeline switching is the cost of the custom-shader path.
"""
self._shader_material_submissions.append(
(mesh_handle, transform, material_id, shader_material),
)
[docs]
def submit_particles(self, particle_data: np.ndarray) -> None:
"""Submit particle data for rendering this frame."""
self._particle_submissions.append((particle_data, len(particle_data)))
[docs]
def submit_gpu_particles(self, emitter_config: dict) -> None:
"""Submit a GPU particle emitter config for compute-shader simulation this frame."""
self._gpu_particle_submissions.append(emitter_config)
[docs]
def submit_light2d(self, **kwargs) -> None:
"""Submit a 2D light for this frame (forwarded to Light2DPass)."""
if self._light2d_pass:
self._light2d_pass.submit_light(**kwargs)
[docs]
def submit_occluder2d(self, polygon_vertices: list[tuple[float, float]]) -> None:
"""Submit a 2D occluder polygon for shadow casting this frame."""
if self._light2d_pass:
self._light2d_pass.submit_occluder(polygon_vertices)
[docs]
def submit_skinned_instance(
self,
mesh_handle: MeshHandle,
transform: np.ndarray,
material_id: int,
joint_matrices: np.ndarray,
) -> None:
"""Submit a skinned mesh instance with joint matrices for this frame."""
self._skinned_instances.append((mesh_handle, transform, material_id, joint_matrices))
[docs]
def submit_dynamic(
self,
vertices: np.ndarray,
indices: np.ndarray,
transform: np.ndarray,
material_id: int = 0,
viewport_id: int = 0,
) -> None:
"""Submit dynamic geometry (uploaded and drawn this frame only)."""
self._dynamic_draws.append((vertices, indices, transform, material_id, viewport_id))
def _upload_transforms(self) -> None:
"""Upload ALL instance transforms to the SSBO once per frame."""
self._buffers.upload_transforms(self._instances)
def _set_hdr_flag(self, enabled: bool) -> None:
"""Set hdr_output (byte offset 216) flag in shadow SSBO."""
self._buffers.set_hdr_flag(enabled)
[docs]
def pre_render(self, cmd: Any) -> None:
"""Record offscreen passes (shadow maps, HDR) before main render pass begins."""
if not self._ready:
return
# Sync WorldEnvironment properties to renderer
self._env_sync.sync_world_environment()
# GPU timing pool (None if device lacks timestamp queries).
pool = self._engine.current_timestamp_pool
# Dispatch GPU particle compute shaders (must happen outside render pass)
if self._gpu_particle_submissions:
if pool:
pool.begin(cmd, "particle_compute")
self._overlay_renderer.dispatch_gpu_particles(cmd)
if pool:
pool.end(cmd, "particle_compute")
# Upload all transforms ONCE — shared by shadow and main passes
self._upload_transforms()
# Upload MSDF atlas outside render pass (staging transfers not allowed inside)
# Single upload serves both TextPass (3D overlay) and Draw2DPass (2D UI text)
if self._text_pass:
self._text_pass.upload_atlas_if_dirty()
# Track whether HDR content was rendered this frame
self._hdr_rendered = False
# Update IBL flag in shadow buffer (even without shadow pass)
if self._ibl_enabled and not self._shadow_pass:
shadow_data = np.zeros(SHADOW_DATA_SIZE, dtype=np.uint8)
sentinel = np.array([0xFF, 0xFF, 0xFF, 0xFF], dtype=np.uint8)
shadow_data[208:212] = sentinel
shadow_data[220:224] = sentinel
shadow_data[224:228] = sentinel
shadow_data[212:216] = np.array([1], dtype=np.uint32).view(np.uint8)
pp = self._post_process
hdr_flag = 1 if (pp and pp.enabled) else 0
shadow_data[216:220] = np.array([hdr_flag], dtype=np.uint32).view(np.uint8)
shadow_data[336:352] = _DEFAULT_AMBIENT.view(np.uint8)
self._buffers.write_shadow_data(shadow_data)
# Render 2D lights to accumulation texture
if self._light2d_pass and self._light2d_pass.has_lights:
if pool:
pool.begin(cmd, "light2d")
self._light2d_pass.render(cmd, self._engine.extent)
if pool:
pool.end(cmd, "light2d")
if self._shadow_pass and self._instances:
if pool:
pool.begin(cmd, "shadow")
self._shadow_renderer.render_shadows(cmd, self._engine.mesh_registry)
if pool:
pool.end(cmd, "shadow")
if self._point_shadow_pass and self._instances:
if pool:
pool.begin(cmd, "point_shadow")
self._shadow_renderer.render_point_spot_shadows(cmd, self._engine.mesh_registry)
if pool:
pool.end(cmd, "point_shadow")
# When post-processing is enabled, render 3D scene to HDR target here.
# Also enter the HDR pass for scenes with no 3D mesh instances but that
# still have content that renders through render_scene_content — e.g.
# tilemap-only scenes or skybox + particles — otherwise nothing is
# ever drawn and the tonemap samples an undefined HDR target.
pp = self._post_process
has_scene_content = bool(
self._instances
or (self._tilemap_pass and self._tilemap_pass._submissions)
or (self._particle_pass and getattr(self._particle_pass, "_submissions", None))
or (self._skybox_pass and getattr(self._skybox_pass, "enabled", False))
)
if pp and pp.enabled and has_scene_content:
# Update camera matrices in UBO (needed by motion blur AND fog depth reconstruction)
viewports = self.viewport_manager.viewports
if viewports:
_, vp = viewports[0]
pp.update_motion_blur_matrices(vp.camera_view, vp.camera_proj)
# Set hdr_output flag so fragment shader skips tone mapping
self._set_hdr_flag(True)
if pool:
pool.begin(cmd, "forward")
pp.begin_hdr_pass(cmd)
self._scene_renderer.render_scene_content(cmd)
pp.end_hdr_pass(cmd)
if pool:
pool.end(cmd, "forward")
self._hdr_rendered = True
# Run bloom pass (extract + blur) between HDR and tonemap
if pp.bloom_enabled:
if pool:
pool.begin(cmd, "bloom")
pp.render_bloom(cmd)
if pool:
pool.end(cmd, "bloom")
# Run SSAO after HDR pass (needs depth from HDR target)
if self._ssao_pass:
pp.ssao_enabled = self._ssao_pass.enabled
if self._ssao_pass.enabled:
viewports = self.viewport_manager.viewports
if viewports:
_, vp = viewports[0]
if pool:
pool.begin(cmd, "ssao")
self._ssao_pass.render(cmd, vp.camera_proj)
if pool:
pool.end(cmd, "ssao")
# Fog is applied in tonemap.frag (post-tonemap fullscreen pass).
# FogPass is still instantiated to receive WorldEnvironment config syncs,
# but its compute pipeline is currently never dispatched (see TODO.md
# under Architectural follow-ups).
# Run custom user post-process effects (after bloom/SSAO, before tonemap)
if pool and self._custom_pp and getattr(self._custom_pp, "has_effects", False):
pool.begin(cmd, "custom_pp")
self._env_sync.run_custom_post_process(cmd, pp)
pool.end(cmd, "custom_pp")
else:
self._env_sync.run_custom_post_process(cmd, pp)
[docs]
def render(self, cmd: Any) -> None:
"""Record draw commands for all viewports."""
if not self._ready:
return
e = self._engine
pp = self._post_process
pool = e.current_timestamp_pool
# If post-processing rendered HDR content in pre_render, tonemap it now.
# Skip tonemap when no 3D content was rendered (e.g. editor with only UI nodes)
# to avoid sampling an undefined/stale HDR render target.
if pp and pp.enabled and self._hdr_rendered:
if pool:
pool.begin(cmd, "tonemap")
pp.render_tonemap(cmd, e.extent[0], e.extent[1])
if pool:
pool.end(cmd, "tonemap")
# Restore tonemap's HDR input to the original HDR target for next frame
if self._custom_pp and self._custom_pp.has_effects and pp.hdr_target:
self._env_sync.update_tonemap_hdr_input(pp.hdr_target.colour_view)
elif not (pp and pp.enabled):
# Direct rendering to swapchain (no post-processing)
if pool:
pool.begin(cmd, "forward")
self._scene_renderer.render_scene_content(cmd)
if pool:
pool.end(cmd, "forward")
# 2D overlays always go to swapchain
# Render 2D drawing overlay — pass window size for UI coordinate conversion
if self._draw2d_pass:
win = self._engine._window
ws = win.get_window_size() if win and hasattr(win, "get_window_size") else None
if pool:
pool.begin(cmd, "draw2d")
if ws:
self._draw2d_pass.render(cmd, e.extent[0], e.extent[1], ws[0], ws[1])
else:
self._draw2d_pass.render(cmd, e.extent[0], e.extent[1])
if pool:
pool.end(cmd, "draw2d")
# Render text overlay (2D)
if self._text_renderer and self._text_renderer.has_text and self._text_pass:
if pool:
pool.begin(cmd, "text")
self._overlay_renderer.render_text(cmd, e.extent)
if pool:
pool.end(cmd, "text")
[docs]
def resize(self, width: int, height: int) -> None:
"""Handle framebuffer resize — recreate post-process targets + 3D pipelines."""
if not self._ready:
return
# Resize post-process first so HDR render-pass exists when pipelines rebuild.
self._passes.resize(width, height)
self._pipelines.rebuild_for_resize(width, height, self._passes.pipeline_render_pass())
[docs]
def cleanup(self) -> None:
"""Release all GPU resources."""
if not self._ready:
return
device = self._engine.ctx.device
# All render passes + skybox cubemap (orchestrator owns lifecycle).
self._passes.cleanup()
# Batch objects
for batch in (self._batch, self._transparent_batch):
if batch:
batch.destroy()
# Debug line pipeline (lazily created by OverlayRenderer, not owned by PipelineManager)
if self._debug_pipeline:
vk.vkDestroyPipeline(device, self._debug_pipeline, None)
if self._debug_pipeline_layout:
vk.vkDestroyPipelineLayout(device, self._debug_pipeline_layout, None)
# 3D pipelines + shader modules
self._pipelines.cleanup()
# Debug shader modules (lazily created)
for mod in (self._debug_vert_module, self._debug_frag_module):
if mod:
vk.vkDestroyShaderModule(device, mod, None)
# Debug vertex buffer (lazily created by OverlayRenderer)
if self._debug_vb:
vk.vkDestroyBuffer(device, self._debug_vb, None)
if self._debug_vb_mem:
vk.vkFreeMemory(device, self._debug_vb_mem, None)
# SSBOs + descriptor sets + placeholder cubemap
self._buffers.cleanup()
self._ready = False
[docs]
def destroy(self) -> None:
"""ABC destroy — delegates to cleanup."""
self.cleanup()
# -- Resource management (delegate to engine) --
[docs]
def register_mesh(self, vertices: np.ndarray, indices: np.ndarray) -> MeshHandle:
"""Register mesh data on GPU via engine's mesh registry."""
return self._engine.mesh_registry.register(vertices, indices)
[docs]
def upload_texture_pixels(self, pixels: np.ndarray, width: int, height: int) -> int:
"""Upload RGBA pixel data to GPU, return bindless texture index."""
return self._engine.upload_texture_pixels(pixels, width, height)
# -- Frame capture --
[docs]
def capture_frame(self) -> np.ndarray:
"""Capture the last rendered frame as (H, W, 4) uint8 RGBA numpy array."""
return self._engine.capture_frame()