"""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