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