Source code for simvx.graphics.web_app3d

"""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 process_input(self, events_json: str) -> None: """Process batched input events from JavaScript.""" try: events = json.loads(events_json) except (json.JSONDecodeError, TypeError): return tree = self._tree for evt in events: etype = evt.get("type") if etype == "key": code = evt.get("code", 0) pressed = evt.get("pressed", False) Input._on_key(code, pressed) key_name = _KEY_MAP.get(code) if key_name: if pressed: if not Input._keys.get(key_name): Input._keys_just_pressed[key_name] = True Input._keys[key_name] = True else: Input._keys[key_name] = False Input._keys_just_released[key_name] = True tree.ui_input(key=key_name, pressed=pressed) elif etype == "char": tree.ui_input(char=chr(evt.get("codepoint", 0))) elif etype == "mouse": button = evt.get("button", 0) pressed = evt.get("pressed", False) Input._on_mouse_button(button, pressed) btn = f"mouse_{button + 1}" if pressed: if not Input._keys.get(btn): Input._keys_just_pressed[btn] = True Input._keys[btn] = True else: Input._keys[btn] = False Input._keys_just_released[btn] = True tree.ui_input(mouse_pos=Input._mouse_pos, button=button + 1, pressed=pressed) elif etype == "mousemove": x, y = evt.get("x", 0.0), evt.get("y", 0.0) old = Input._mouse_pos Input._mouse_pos = (x, y) Input._mouse_delta = (x - old[0], y - old[1]) tree.ui_input(mouse_pos=(x, y), button=0, pressed=False) elif etype == "scroll": dx, dy = evt.get("dx", 0.0), evt.get("dy", 0.0) Input._scroll_delta = (Input._scroll_delta[0] + dx, Input._scroll_delta[1] + dy) if dy > 0: tree.ui_input(key="scroll_up", pressed=True) elif dy < 0: tree.ui_input(key="scroll_down", pressed=True) elif etype == "touch": Input._update_touch( evt.get("id", 0), evt.get("action", 0), evt.get("x", 0.0), evt.get("y", 0.0), evt.get("pressure", 1.0), )
[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)