"""Client-side 3D web app runtime -- runs inside Pyodide (browser WASM Python).
Like ``WebApp`` but for 3D games. Creates ``SceneTree`` + ``WebRenderer3D`` +
``EngineStub`` and drives the full 3D scene graph in Pyodide. The JavaScript
bridge calls ``tick(dt)`` each frame and renders the returned binary data with
WebGPU (3D scene) plus the 2D overlay renderer.
Combined binary format: ``<u32 length_3d> <3d_frame_bytes> <2d_frame_bytes>``.
"""
from __future__ import annotations
import json
import struct
import numpy as np
from simvx.core import Input, SceneTree
from simvx.core.text.font import GlyphMetrics
from simvx.core.text.msdf import GlyphRegion, MSDFAtlas
from .draw2d import Draw2D
from .renderer.web3d import WebRenderer3D
from .scene_adapter import SceneAdapter
from .streaming.draw_serializer import DrawSerializer
from .web.engine_stub import EngineStub
from .web_app import _KEY_MAP, _FontStub
__all__ = ["WebApp3D"]
[docs]
class WebApp3D:
"""Browser-side 3D app runtime for Pyodide.
Drives SceneTree + WebRenderer3D + Draw2D. Returns combined 2D+3D binary frames.
"""
def __init__(self, width: int, height: int, physics_fps: int = 60) -> None:
self.width = width
self.height = height
self._physics_fps = physics_fps
self._physics_dt = 1.0 / physics_fps
self._physics_accum = 0.0
self._frame_id = 0
self._renderer = WebRenderer3D(width, height)
self._engine_stub = EngineStub(self._renderer, width, height)
self._adapter: SceneAdapter | None = None
self._tree = SceneTree(screen_size=(width, height))
self._tree._app = self # type: ignore[attr-defined]
self._world_env_cache = None # Cached WorldEnvironment reference
@property
def title(self) -> str:
return ""
@property
def engine(self) -> EngineStub:
"""Provide engine interface for nodes that access ``self.app.engine``."""
return self._engine_stub
[docs]
def set_root(self, root_node) -> None:
"""Set the scene root node."""
self._tree.set_root(root_node)
self._adapter = SceneAdapter(self._engine_stub, self._renderer)
[docs]
def load_atlas(self, atlas_rgba: bytes, atlas_size: int, regions_json: str,
font_size: int, ascender: float, descender: float,
line_height: float, sdf_range: float, glyph_padding: int) -> None:
"""Reconstruct the MSDF atlas from pre-baked data (no freetype needed)."""
atlas_array = np.frombuffer(atlas_rgba, dtype=np.uint8).reshape(atlas_size, atlas_size, 4).copy()
regions_data = json.loads(regions_json)
metrics_cache: dict[str, GlyphMetrics] = {}
regions: dict[str, GlyphRegion] = {}
for rd in regions_data:
gm = GlyphMetrics(
char=rd["char"], advance_x=rd["advance_x"],
bearing_x=rd["bearing_x"], bearing_y=rd["bearing_y"],
width=rd["width"], height=rd["height"],
)
metrics_cache[rd["char"]] = gm
regions[rd["char"]] = GlyphRegion(
char=rd["char"], x=rd["x"], y=rd["y"], w=rd["w"], h=rd["h"],
metrics=gm, u0=rd["u0"], v0=rd["v0"], u1=rd["u1"], v1=rd["v1"],
)
font_stub = _FontStub(
size=font_size, ascender=ascender, descender=descender,
line_height=line_height, _metrics=metrics_cache,
)
atlas = object.__new__(MSDFAtlas)
atlas.font = font_stub # type: ignore[attr-defined]
atlas.atlas_size = atlas_size
atlas.glyph_padding = glyph_padding
atlas.sdf_range = sdf_range
atlas.atlas = atlas_array
atlas.regions = regions
atlas.version = 1
atlas.dirty = False
atlas._shelf_y = 0
atlas._shelf_h = 0
atlas._cursor_x = 0
Draw2D._font = atlas
Draw2D._font_obj = font_stub # type: ignore[assignment]
[docs]
def resize(self, width: int, height: int) -> None:
"""Update engine dimensions on viewport resize (called from JS)."""
self.width = width
self.height = height
self._tree.screen_size = (width, height)
self._renderer.resize(width, height)
self._engine_stub._width = width
self._engine_stub._height = height
[docs]
def tick(self, dt: float) -> bytes:
"""Advance one frame and return combined 2D+3D binary frame data.
Returns:
Combined binary: ``<u32 length_3d> <3d_frame_bytes> <2d_frame_bytes>``.
"""
dt = min(dt, 0.1)
# Fixed-timestep physics
self._physics_accum += dt
while self._physics_accum >= self._physics_dt:
self._tree.physics_process(self._physics_dt)
self._physics_accum -= self._physics_dt
self._tree.process(dt)
# 3D scene: begin frame, adapter submits to renderer, serialize
self._renderer.begin_frame()
if self._adapter:
self._adapter.submit_scene(self._tree)
self._sync_world_environment()
frame_3d = self._renderer.serialize_frame()
# 2D overlay — Draw2D._reset clears batches, then tree.draw adds 2D nodes.
# Text2D.draw() is a no-op (handled by SceneAdapter in Vulkan), so render
# Text2D nodes explicitly via Draw2D.draw_text().
Draw2D._reset()
self._tree.draw(Draw2D)
if Draw2D._font and self._tree.root:
self._draw_text_nodes(self._tree.root)
# Mouse picking on click
if Input._keys_just_pressed.get("mouse_1"):
self._tree.input_cast(Input._mouse_pos, button=1)
Input._end_frame()
# Serialize 2D
batches = Draw2D._get_batches()
atlas_ver = Draw2D._font.version if Draw2D._font else 0
frame_2d = DrawSerializer.serialize_frame(self._frame_id, batches, atlas_ver)
self._frame_id += 1
# Combined: u32 length of 3D data, then 3D bytes, then 2D bytes
return struct.pack("<I", len(frame_3d)) + frame_3d + frame_2d
def _resolve_world_environment(self):
"""Return cached WorldEnvironment, refreshing on miss or tree removal."""
from simvx.core.world_environment import WorldEnvironment
env = self._world_env_cache
if env is not None:
if env._tree is None:
self._world_env_cache = None
env = None
else:
return env
if self._tree.root is None:
return None
env = self._tree.root.find(WorldEnvironment)
self._world_env_cache = env
return env
def _sync_world_environment(self) -> None:
"""Sync WorldEnvironment bloom settings to the web renderer (dirty-flag gated)."""
env = self._resolve_world_environment()
if env:
if not env.env_dirty:
return
self._renderer.set_post_process(
bloom_enabled=env.bloom_enabled,
bloom_threshold=env.bloom_threshold,
bloom_intensity=env.bloom_intensity,
)
env.clear_env_dirty()
else:
self._renderer.set_post_process(bloom_enabled=False)
def _draw_text_nodes(self, node) -> None:
"""Walk tree and render Text2D nodes via Draw2D."""
from simvx.core import Text2D
if isinstance(node, Text2D) and node.text:
fc = tuple(node.font_colour)
# font_colour is always 0.0-1.0 floats (Property default); normalise any
# legacy 0-255 int values so Draw2D._norm_colour handles them consistently.
if len(fc) >= 3 and isinstance(fc[0], int):
fc = (fc[0] / 255, fc[1] / 255, fc[2] / 255, fc[3] / 255 if len(fc) > 3 else 1.0)
elif len(fc) == 3:
fc = (*fc, 1.0)
Draw2D.draw_text(node.text, (node.x, node.y), node.font_scale, fc)
for child in node.children:
self._draw_text_nodes(child)