Three.js WebGPU Client Against the Godot Zone Server

Superseded by: 20260425-godot-observer.md and 20260425-godot-player.md

  • Status: superseded (not being built)
  • Deciders: V-Sekai, fire
  • Tags: V-Sekai, Threejs, WebGPU, WebTransport, ZoneServer, Client, 20260425-threejs-webgpu-zone-client

The Context

The Godot web export path requires a wasm32/wasm64 build, COOP/COEP headers, SharedArrayBuffer, and a service worker. Three.js r171+ ships a production-ready WebGPU renderer (threejswebgpu2026?) supported on all major browsers including iOS Safari since September 2025. The zone server already speaks WebTransport. The CH_INTEREST packet format is fully specified in fabric_zone_types.h — 100 bytes per entity, fixed offsets, no Godot dependency.

The Problem Statement

The Godot web export client blocks on a multi-minute wasm build for every iteration. Three.js + WebTransport eliminates the build step entirely: connect, parse the 100-byte wire format, render. The operator camera overlay (load bars, dot clustering) becomes plain DOM/Canvas over a Three.js scene.

Design

Wire protocol — CH_INTEREST (read only, 100 bytes per entity)

Offset  Size  Field
  0       4   global_id (int32)
  4      24   cx, cy, cz (float64 × 3)
 28      12   vx, vy, vz (int16 × 3, scale V_SCALE)
 34       6   ax, ay, az (int16 × 3, scale A_SCALE)
 40       4   hlc = tick(24b) | counter(8b)
 44      56   payload[14] (uint32 × 14)

payload[0] bits:
  [31:24] entity_class   (0=NPC, 1=player, 2=prop, 3=effect, 4=humanoid bone)
  [23: 8] owner_id
  [ 7: 0] state_flags

For class=4 (V-Sekai humanoid bone):

payload[1] = axis_x int16 (lo) | axis_y int16 (hi)  — swing ±1 → ±32767
payload[2] = axis_z int16 (lo) | reserved            — twist axis

Bones are stored as swing/twist triplets in [-1, 1], consistent with TransformUtil.swing_twist in the humanoid project and the camera ADR 20260425-operator-camera-2-5d.md.

TypeScript packet parser

const V_SCALE = 1 / 100;   // int16 → m/s
const A_SCALE = 1 / 1000;  // int16 → m/s²

interface Entity {
  id: number;
  pos: [number, number, number];
  vel: [number, number, number];
  entityClass: number;
  ownerId: number;
  payload: Uint32Array;
}

function parseInterest(buf: ArrayBuffer): Entity[] {
  const view = new DataView(buf);
  const count = buf.byteLength / 100;
  const out: Entity[] = [];
  for (let i = 0; i < count; i++) {
    const o = i * 100;
    out.push({
      id:    view.getInt32(o, true),
      pos:   [view.getFloat64(o + 4,  true),
              view.getFloat64(o + 12, true),
              view.getFloat64(o + 20, true)],
      vel:   [view.getInt16(o + 28, true) * V_SCALE,
              view.getInt16(o + 30, true) * V_SCALE,
              view.getInt16(o + 32, true) * V_SCALE],
      entityClass: (view.getUint32(o + 44, true) >> 24) & 0xff,
      ownerId:     (view.getUint32(o + 44, true) >>  8) & 0xffff,
      payload:     new Uint32Array(buf, o + 44, 14),
    });
  }
  return out;
}

Three.js WebGPU scene

import * as THREE from "three";
import { WebGPURenderer } from "three/webgpu";

const renderer = new WebGPURenderer({ antialias: true });
await renderer.init();

const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(...);
camera.rotation.x = -(0.153 * Math.PI * 2);  // SWING_ELEVATION

// One InstancedMesh per entity class — 1800 entities, one draw call.
const jellyMesh = new THREE.InstancedMesh(jellyGeom, jellyMat, 1800);
scene.add(jellyMesh);

WebTransport connection

const wt = new WebTransport(`https://${host}/zone`);
await wt.ready;
const reader = wt.datagrams.readable.getReader();

(function pump(result: ReadableStreamReadResult<Uint8Array>) {
  if (result.done) return;
  const entities = parseInterest(result.value.buffer);
  updateScene(entities);
  reader.read().then(pump);
})(await reader.read());

The datagram reader is captured once and reused (the same exclusive-lock invariant proved in 20260425-jellyfish-game.md and the WebTransport audit).

Operator overlay

Load bars and dot clustering (see 20260425-operator-overlay.md) become a <canvas> element over the Three.js canvas, using the plain 2D Context API, with no CanvasLayer needed.

Client split

Two clients replace the previous Godot wasm web export:

Client Transport Use case
Godot native PCVR WebTransport (picoquic) High-fidelity VR, full entity control
Three.js WebGPU WebTransport (browser API) Browser: operator view, WebXR VR, godot-sandbox WASM scripts, taskweft WASM planner

The Godot wasm32/wasm64 build is dropped. The COOP/COEP service worker, SharedArrayBuffer build flags, and gescons wasm targets are no longer needed for the client path.

Extending the browser client

Two components can move into the browser without a Godot runtime:

godot-sandbox runs GDScript as RISC-V ELF guests via libriscv. The ELF is compiled once and stored in the CDN. libriscv is the VM:

GDScript  → RISC-V ELF   (compile once; CDN asset)
libriscv  → WASM          (Emscripten; browser loads VM, feeds it the ELF)

No binary equivalence is required. WasmEquiv.lean proves vm_deterministic: the VM is a pure function, so the same ELF + same state always yields the same result on any host.

taskweft standalone headers (standalone/tw_planner.hpp) are header-only C++20 with no BEAM dependency and no libriscv layer, and they compile directly to WASM via Emscripten. emcc standalone/tw_planner.hpp -o tw_planner.wasm produces a module that runs RECTGTN planning in the browser, using the same species domain JSON-LD files that drive the native zone server.

VR presence uses Three.js + WebXR Device API (renderer.xr.enabled = true, VRButton.createButton(renderer)). The Godot XR layer is not required.

The Downsides

Implementing CH_PLAYER datagrams in TypeScript to send entity input is a second scope of work; the initial Three.js client is observer-only.

The actual integration risk is the godot-sandbox guest ABI (syscall shim from RISC-V ecall to Emscripten host) and taskweft Emscripten build constraints. These are engineering work. WasmEquiv.lean proves vm_deterministic (the VM is a pure function); it does not prove the ABI shim is correct.

The packet format is internal and could change without versioning. A format version byte in the first octet of CH_INTEREST would protect against silent breakage; that change belongs in a separate ADR.

The Road Not Taken

Godot wasm32/wasm64 web export: dropped. Build friction (multi-minute compile, COOP/COEP, service worker, SharedArrayBuffer) exceeded the benefit once the native PCVR client covers VR and Three.js covers the browser.

Status

Status: Accepted

Decision Makers

  • iFire

Further Reading

(threejswebgpu2026?): “What’s New in Three.js (2026): WebGPU, New Workflows & Beyond.” utsubo.com. https://www.utsubo.com/blog/threejs-2026-what-changed

(threejswebgpudocs?): “WebGPU — three.js docs.” Three.js. https://threejs.org/docs/pages/WebGPU.html

(threejswebgpurenderer?): “WebGPURenderer — three.js docs.” Three.js. https://threejs.org/docs/pages/WebGPURenderer.html