Source code for simvx.graphics.renderer.web3d

"""WebGPU 3D renderer — collects submissions and serializes to binary via Scene3DSerializer.

Implements the Renderer ABC without any Vulkan dependency. Instead of issuing GPU draw
calls, it groups instances per frame and serializes them for WebSocket transmission to a
browser-side WebGPU renderer.
"""


from __future__ import annotations

import logging
from typing import Any

import numpy as np

from .._types import ALPHA_OPAQUE, LIGHT_DTYPE, MATERIAL_DTYPE, VERTEX_DTYPE, MeshHandle
from ._base import Renderer
from .transparency import compute_sort_key, extract_camera_position
from .viewport_manager import ViewportManager

log = logging.getLogger(__name__)

__all__ = ["WebRenderer3D"]


[docs] class WebRenderer3D(Renderer): """WebGPU 3D renderer — collects submissions and serializes to binary.""" def __init__(self, width: int, height: int) -> None: self._width = width self._height = height self._instances: list[tuple[MeshHandle, np.ndarray, int, int]] = [] # (mesh, transform, mat_id, vp_id) self._materials: np.ndarray = np.empty(0, dtype=MATERIAL_DTYPE) self._lights: np.ndarray = np.empty(0, dtype=LIGHT_DTYPE) self._next_mesh_id = 0 self._next_tex_id = 1 # Start at 1: layer 0 is the default white pixel in the JS texture array self._mesh_data: dict[int, tuple[np.ndarray, np.ndarray]] = {} # mesh_id -> (vertices, indices) self._pending_meshes: list[tuple[int, np.ndarray, np.ndarray]] = [] # (mesh_id, verts, indices) self._pending_textures: list[tuple[int, int, int, np.ndarray]] = [] # (tex_id, w, h, pixels) self._pending_materials: bool = False self._frame_id = 0 self.viewport_manager = ViewportManager() # Post-process state (bloom) self._bloom_enabled = False self._bloom_threshold = 1.0 self._bloom_intensity = 0.8 self._bloom_soft_knee = 0.5 # -- Frame lifecycle --
[docs] def begin_frame(self) -> None: self._instances.clear()
[docs] def pre_render(self, cmd: Any) -> None: pass # No offscreen passes
[docs] def render(self, cmd: Any) -> None: pass # Serialization happens in serialize_frame()
[docs] def resize(self, width: int, height: int) -> None: self._width, self._height = width, height
[docs] def destroy(self) -> None: self._mesh_data.clear()
# -- Scene submissions --
[docs] def submit_instance(self, mesh_handle: MeshHandle, transform: np.ndarray, material_id: int = 0, viewport_id: int = 0) -> None: self._instances.append((mesh_handle, np.array(transform, dtype=np.float32), 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, count: int = 0) -> None: t = np.asarray(transforms, dtype=np.float32) n = count if count > 0 else len(t) for i in range(n): mid = int(material_ids[i]) if material_ids is not None and i < len(material_ids) else material_id self._instances.append((mesh_handle, t[i], mid, viewport_id))
[docs] def submit_skinned_instance(self, mesh_handle: MeshHandle, transform: np.ndarray, material_id: int, joint_matrices: np.ndarray) -> None: # Skinning not supported in web Phase 1 — render unskinned self._instances.append((mesh_handle, np.array(transform, dtype=np.float32), material_id, 0))
[docs] def set_materials(self, materials: np.ndarray) -> None: self._materials = np.asarray(materials, dtype=MATERIAL_DTYPE) self._pending_materials = True
[docs] def set_lights(self, lights: np.ndarray) -> None: self._lights = np.asarray(lights, dtype=LIGHT_DTYPE)
[docs] def submit_text(self, text: str, x: float, y: float, size: float, colour: tuple[float, float, float, float], **kwargs: Any) -> None: pass # Text rendered via Draw2D after reset in WebApp3D.tick()
[docs] def submit_particles(self, particle_data: np.ndarray) -> None: pass # Particles not yet supported in web
[docs] def submit_light2d(self, **kwargs: Any) -> None: pass # 2D lights not relevant to 3D renderer
[docs] def set_post_process(self, bloom_enabled: bool = False, bloom_threshold: float = 1.0, bloom_intensity: float = 0.8, bloom_soft_knee: float = 0.5) -> None: """Update post-processing settings (synced from WorldEnvironment).""" self._bloom_enabled = bloom_enabled self._bloom_threshold = bloom_threshold self._bloom_intensity = bloom_intensity self._bloom_soft_knee = bloom_soft_knee
# -- Resource management --
[docs] def register_mesh(self, vertices: np.ndarray, indices: np.ndarray) -> MeshHandle: verts = np.asarray(vertices, dtype=VERTEX_DTYPE) idxs = np.asarray(indices, dtype=np.uint32) mesh_id = self._next_mesh_id self._next_mesh_id += 1 self._mesh_data[mesh_id] = (verts, idxs) self._pending_meshes.append((mesh_id, verts, idxs)) radius = float(np.linalg.norm(verts["position"], axis=1).max()) if len(verts) > 0 else 0.0 return MeshHandle(id=mesh_id, vertex_count=len(verts), index_count=len(idxs), bounding_radius=radius)
[docs] def register_texture(self, pixels: np.ndarray, width: int, height: int) -> int: tex_id = self._next_tex_id self._next_tex_id += 1 self._pending_textures.append((tex_id, width, height, np.asarray(pixels, dtype=np.uint8))) return tex_id
# -- Frame capture --
[docs] def capture_frame(self) -> np.ndarray: return np.zeros((self._height, self._width, 4), dtype=np.uint8) # Not available in web
# -- Serialization --
[docs] def serialize_frame(self) -> bytes: """Group instances by mesh, build draw groups, serialize via Scene3DSerializer.""" from ..streaming.scene3d_serializer import Scene3DSerializer # Build viewports from viewport_manager viewports: list[dict[str, Any]] = [] for _vp_id, vp in self.viewport_manager.get_all(): viewports.append({ "x": vp.x, "y": vp.y, "width": vp.width, "height": vp.height, "view_matrix": vp.camera_view, "proj_matrix": vp.camera_proj, }) if not viewports: # Default full-screen viewport with identity matrices viewports.append({ "x": 0, "y": 0, "width": self._width, "height": self._height, "view_matrix": np.eye(4, dtype=np.float32), "proj_matrix": np.eye(4, dtype=np.float32), }) # Classify each instance into pass_type based on material properties: # 0 = OPAQUE, 1 = DOUBLE_SIDED (opaque), 2 = TRANSPARENT (alpha blend) mat_count = len(self._materials) groups: dict[tuple[int, int], tuple[MeshHandle, list[np.ndarray], list[int]]] = {} for mesh_handle, transform, mat_id, _vp_id in self._instances: if mat_id < mat_count and self._materials[mat_id]["alpha_mode"] != ALPHA_OPAQUE: pass_type = 2 elif mat_id < mat_count and self._materials[mat_id]["double_sided"]: pass_type = 1 else: pass_type = 0 key = (mesh_handle.id, pass_type) if key not in groups: groups[key] = (mesh_handle, [], []) groups[key][1].append(transform) groups[key][2].append(mat_id) # Build draw group dicts, then order: opaque(0) -> double-sided(1) -> transparent(2) opaque_groups: list[dict[str, Any]] = [] double_sided_groups: list[dict[str, Any]] = [] transparent_groups: list[dict[str, Any]] = [] for (mid, pass_type), (mesh_handle, transforms, mat_ids) in groups.items(): group = { "mesh_id": mid, "index_count": mesh_handle.index_count, "transforms": np.array(transforms, dtype=np.float32).reshape(-1, 4, 4), "material_ids": np.array(mat_ids, dtype=np.uint32), "pass_type": pass_type, } if pass_type == 2: transparent_groups.append(group) elif pass_type == 1: double_sided_groups.append(group) else: opaque_groups.append(group) # Sort transparent groups back-to-front relative to camera if transparent_groups and viewports: cam_pos = extract_camera_position(np.asarray(viewports[0]["view_matrix"], dtype=np.float32)) transparent_groups.sort(key=lambda g: compute_sort_key(g["transforms"][0], cam_pos)) draw_groups: list[dict[str, Any]] = opaque_groups + double_sided_groups + transparent_groups # Build resources if any pending resources = None if self._pending_meshes or self._pending_textures or self._pending_materials: resources = { "meshes": self._pending_meshes, "textures": self._pending_textures, "materials": self._materials if self._pending_materials else np.empty(0, dtype=MATERIAL_DTYPE), } self._pending_meshes = [] self._pending_textures = [] self._pending_materials = False post_process = None if self._bloom_enabled: post_process = { "bloom_enabled": self._bloom_enabled, "bloom_threshold": self._bloom_threshold, "bloom_intensity": self._bloom_intensity, "bloom_soft_knee": self._bloom_soft_knee, } data = Scene3DSerializer.serialize_frame( self._frame_id, viewports, self._lights, draw_groups, resources, post_process, ) self._frame_id += 1 return data