Source code for simvx.graphics.web_app

"""Client-side web app runtime — runs inside Pyodide (browser WASM Python).

Replaces ``App`` for fully client-side web export. Drives the SceneTree, Draw2D,
and DrawSerializer in the browser without any server or Vulkan dependency.

The JavaScript ``web_runtime.js`` bridge calls ``tick(dt)`` each frame via
``requestAnimationFrame`` and renders the returned binary data with WebGPU.
"""

import json
import struct
from dataclasses import dataclass
from typing import Literal, overload

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 .materials.texture import TextureManager
from .web.engine_stub import prepare_2d_overlays
from .web.resource_kinds import encode_tlv

__all__ = ["WebApp"]


class _PendingTextureRenderer:
    """Minimal registrar shim for ``TextureManager`` in 2D-only web export.

    Mints monotonic tex_ids (id 0 is reserved to match ``WebRenderer3D``'s
    default-white slot); ``TextureManager`` retains pixels by id and the
    actual GPU upload happens on the JS side after
    ``WebApp.drain_resources()`` is drained.
    """

    def __init__(self) -> None:
        self._next_tex_id = 1

    def upload_texture_pixels(self, pixels: np.ndarray, width: int, height: int) -> int:  # noqa: ARG002
        tex_id = self._next_tex_id
        self._next_tex_id += 1
        return tex_id

# Key code → string name (same mapping as input_adapter._KEY_MAP,
# inlined to avoid importing the adapter which isn't available in Pyodide).
_KEY_MAP: dict[int, str] = {
    65: "a", 66: "b", 67: "c", 68: "d", 69: "e", 70: "f", 71: "g", 72: "h",
    73: "i", 74: "j", 75: "k", 76: "l", 77: "m", 78: "n", 79: "o", 80: "p",
    81: "q", 82: "r", 83: "s", 84: "t", 85: "u", 86: "v", 87: "w", 88: "x",
    89: "y", 90: "z",
    48: "0", 49: "1", 50: "2", 51: "3", 52: "4", 53: "5", 54: "6", 55: "7", 56: "8", 57: "9",
    32: "space", 257: "enter", 256: "escape", 258: "tab", 259: "backspace",
    261: "delete", 260: "insert", 268: "home", 269: "end", 266: "pageup", 267: "pagedown",
    265: "up", 264: "down", 263: "left", 262: "right",
    340: "shift", 344: "shift", 341: "ctrl", 345: "ctrl", 342: "alt", 346: "alt",
    47: "/", 92: "\\", 45: "-", 61: "=", 91: "[", 93: "]",
    59: ";", 39: "'", 44: ",", 46: ".", 96: "`",
    290: "f1", 291: "f2", 292: "f3", 293: "f4", 294: "f5", 295: "f6",
    296: "f7", 297: "f8", 298: "f9", 299: "f10", 300: "f11", 301: "f12",
}

@dataclass
class _FontStub:
    """Lightweight Font stand-in with cached metrics (no freetype dependency)."""

    size: int
    ascender: float
    descender: float
    line_height: float
    _metrics: dict[str, GlyphMetrics]

    def get_glyph(self, char: str) -> GlyphMetrics:
        if char in self._metrics:
            return self._metrics[char]
        # Fallback: return space-width glyph with zero dimensions
        space = self._metrics.get(" ")
        adv = space.advance_x if space else self.size * 0.5
        return GlyphMetrics(char=char, advance_x=adv, bearing_x=0, bearing_y=0, width=0, height=0)

    def has_glyph(self, char: str) -> bool:
        return char in self._metrics

[docs] class WebApp: """Browser-side app runtime for Pyodide. Usage from JavaScript:: const app = pyodide.runPython(` from simvx.graphics.web_app import WebApp app = WebApp(800, 600) app `) app.set_root(pyodide.runPython('from game import GameScene; GameScene()')) # In rAF loop: app.process_input(events_json) frame_bytes = app.tick(dt) """ def __init__(self, width: int, height: int, physics_fps: int = 60, *, include_3d: bool = False) -> 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._primary_finger: int | None = None self._running = True self._include_3d = include_3d self._tree = SceneTree(screen_size=(width, height)) # Provide a stub _app reference so nodes can access .app self._tree._app = self # type: ignore[attr-defined] # 3D runtime (opt-in) — adds WebRenderer3D + EngineStub + SceneAdapter, # reuses the engine stub's TextureManager so 2D overlays on top of the # 3D scene share one texture namespace. self._renderer = None self._engine_stub = None self._adapter = None self._world_env_cache = None if include_3d: from .renderer.web3d import WebRenderer3D from .web.engine_stub import EngineStub self._renderer = WebRenderer3D(width, height) self._engine_stub = EngineStub(self._renderer, width, height) self._texture_manager = self._engine_stub.texture_manager else: self._texture_manager = TextureManager(_PendingTextureRenderer(), retain_pixels=True) # Unified resource drain channel — mirrors TextureManager on desktop. # Populated by prepare_2d_overlays() each tick as (kind, id, payload) # entries; drained by drain_resources() as a TLV-encoded blob. self._pending_resources: list[tuple[int, int, bytes]] = [] self._sent_resource_ids: set[tuple[int, int]] = set()
[docs] def quit(self) -> None: """Stop the scene from processing. Canvas freezes on the last rendered frame. Mirrors ``App.quit`` for web runtime. Games invoke ``self.app.quit()`` to stop their own simulation; the browser tab itself is closed by the user. No navigation, no clear — just pause physics/process/draw. """ self._running = False
@property def title(self) -> str: return "" @property def engine(self): """Engine interface for 3D nodes that access ``self.app.engine``. Returns ``None`` in 2D-only mode — 2D nodes don't need it. """ return self._engine_stub
[docs] def set_root(self, root_node) -> None: """Set the scene root node.""" self._tree.set_root(root_node) if self._include_3d: from .scene_adapter import SceneAdapter 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). Args: atlas_rgba: Raw RGBA pixel bytes (atlas_size * atlas_size * 4). atlas_size: Width/height of the square atlas texture. regions_json: JSON-serialized glyph regions. font_size: Original font pixel size used to generate the atlas. ascender, descender, line_height: Font metrics. sdf_range: SDF range used during generation. glyph_padding: Glyph padding used during generation. """ from simvx.graphics.draw2d import Draw2D # Rebuild atlas numpy array atlas_array = np.frombuffer(atlas_rgba, dtype=np.uint8).reshape(atlas_size, atlas_size, 4).copy() # Rebuild glyph metrics and regions 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"], ) # Build font stub font_stub = _FontStub( size=font_size, ascender=ascender, descender=descender, line_height=line_height, _metrics=metrics_cache, ) # Build MSDFAtlas stub (bypass __init__ which needs a real Font) 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 # Inject into Draw2D Draw2D._font = atlas Draw2D._font_obj = font_stub # type: ignore[assignment]
[docs] def process_input(self, events_json: str) -> None: """Process batched input events from JavaScript. Args: events_json: JSON array of input event objects. """ 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": codepoint = evt.get("codepoint", 0) tree.ui_input(char=chr(codepoint)) 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": finger_id = evt.get("id", 0) action = evt.get("action", 0) x, y = evt.get("x", 0.0), evt.get("y", 0.0) pressure = evt.get("pressure", 1.0) Input._update_touch(finger_id, action, x, y, pressure) # Primary finger emulates mouse for UI and game input if action == 0 and self._primary_finger is None: self._primary_finger = finger_id Input._mouse_pos = (x, y) Input._mouse_delta = (0.0, 0.0) Input._on_mouse_button(0, True) # LEFT button press btn = "mouse_1" if not Input._keys.get(btn): Input._keys_just_pressed[btn] = True Input._keys[btn] = True tree.ui_input(mouse_pos=(x, y), button=0, pressed=False) tree.ui_input(mouse_pos=(x, y), button=1, pressed=True) elif action == 1 and finger_id == self._primary_finger: self._primary_finger = None Input._mouse_pos = (x, y) Input._on_mouse_button(0, False) # LEFT button release Input._keys["mouse_1"] = False Input._keys_just_released["mouse_1"] = True tree.ui_input(mouse_pos=(x, y), button=0, pressed=False) tree.ui_input(mouse_pos=(x, y), button=1, pressed=False) elif action == 2 and finger_id == self._primary_finger: 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)
[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) if self._include_3d: assert self._renderer is not None and self._engine_stub is not None 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 serialized draw commands. Args: dt: Delta time in seconds since last frame. Returns: In 2D-only mode: binary frame data for the 2D renderer. In 3D mode: combined ``<u32 len3d> <3d bytes> <2d bytes>`` consumed by the 2D+3D composited renderer. """ from simvx.graphics.draw2d import Draw2D from simvx.graphics.streaming.draw_serializer import DrawSerializer if not self._running: # After quit() the scene freezes; return an empty frame so the # browser keeps showing the last rendered state. return b"" dt = min(dt, 0.1) # Clamp to avoid spiral of death # 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 pipeline: begin frame, adapter submits nodes + resolves textures frame_3d = b"" if self._include_3d: assert self._renderer is not None self._renderer.begin_frame() if self._adapter is not None: self._adapter.submit_scene(self._tree) self._sync_world_environment() # Resolve 2D overlay textures. In 3D mode SceneAdapter already populated # _texture_id; this walk captures pixels for Renderer2D's cache so the # overlays render on top of the 3D scene. root = self._tree.root if root is not None: prepare_2d_overlays(root, self._texture_manager, self._pending_resources, self._sent_resource_ids) if self._include_3d: assert self._renderer is not None # Pull any meshes/mesh textures registered this tick onto the unified # drain stream, keyed by (kind, id) so retries stay idempotent. for kind, entry_id, payload in self._renderer.drain_resources(): key = (kind, entry_id) if key in self._sent_resource_ids: continue self._pending_resources.append((kind, entry_id, payload)) self._sent_resource_ids.add(key) frame_3d = self._renderer.serialize_frame() # 2D draw pass Draw2D._reset() self._tree.draw(Draw2D) # Text2D.draw() is intentionally a no-op (SceneAdapter owns it on # desktop), so the web runtime must walk the tree and emit Draw2D # text calls itself in both 2D-only and 3D modes. 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 if self._include_3d: return struct.pack("<I", len(frame_3d)) + frame_3d + frame_2d return frame_2d
def _sync_world_environment(self) -> None: """Sync WorldEnvironment bloom settings to the web renderer (dirty-flag gated).""" from simvx.core.world_environment import WorldEnvironment assert self._renderer is not None env = self._world_env_cache if env is not None and env._tree is None: self._world_env_cache = None env = None if env is None and self._tree.root is not None: env = self._tree.root.find(WorldEnvironment) self._world_env_cache = env if env is not None: if not env.env_dirty: return mode_map = {"linear": 0, "exponential": 1, "exponential_squared": 2} self._renderer.set_post_process( bloom_enabled=env.bloom_enabled, bloom_threshold=env.bloom_threshold, bloom_intensity=env.bloom_intensity, fog_enabled=env.fog_enabled, fog_mode=mode_map.get(env.fog_mode, 1), fog_density=env.fog_density, fog_start=env.fog_start, fog_end=env.fog_end, fog_colour=tuple(env.fog_colour), fog_height=env.fog_height, fog_height_density=env.fog_height_density, ) env.clear_env_dirty() else: self._renderer.set_post_process(bloom_enabled=False, fog_enabled=False) def _draw_text_nodes(self, node) -> None: """Walk tree and render Text2D nodes via Draw2D (3D mode only).""" from simvx.core import Text2D from simvx.graphics.draw2d import Draw2D if isinstance(node, Text2D) and node.text: fc = tuple(node.font_colour) 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) @overload def drain_resources(self) -> bytes: ... @overload def drain_resources(self, *, debug_log: Literal[False]) -> bytes: ... @overload def drain_resources(self, *, debug_log: Literal[True]) -> tuple[bytes, list[tuple[int, int, int]]]: ...
[docs] def drain_resources(self, *, debug_log: bool = False): """Drain newly-registered resources as a TLV-encoded binary payload. Wire format:: count(u32) [ kind(u8) + id(u32) + length(u32) + payload(length bytes) ] × count Each ``(kind, id)`` pair is emitted exactly once across the app's lifetime. Returns ``b""`` when nothing is pending so the JS boot loop can skip the per-frame Pyodide crossing in steady state. When ``debug_log=True``, returns ``(blob, summary)`` where ``summary`` is a list of ``(kind, id, payload_len)`` tuples — cheap to inspect without parsing the TLV bytes. """ if not self._pending_resources: return (b"", []) if debug_log else b"" summary = [(k, i, len(p)) for k, i, p in self._pending_resources] if debug_log else None blob = encode_tlv(self._pending_resources) self._pending_resources.clear() return (blob, summary) if debug_log else blob