| title | XR Architecture (Godot 4 + godot-rust + OpenXR) | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| description | VisionClaw's XR client is a native Meta Quest 3 APK built on Godot 4.3 + godot-rust (gdext) + OpenXR. It decodes the existing V3 52 B/node binary graph stream unchanged, runs multi-user presence over a BIP-340-authenticated /ws/presence WebSocket, treats node drags as server-authoritative, and carries voice through a Whisper/Kokoro command plane and a LiveKit spatial plane. Supersedes the browser-hosted Babylon.js + Vircadia design. | |||||||||||||
| category | explanation | |||||||||||||
| tags |
|
VisionClaw's XR client is a native Meta Quest 3 APK built from a Godot 4.3
project, with the performance-critical paths — protocol decode, WebSocket
lifecycle, authentication, pose validation, LOD — written in Rust through
godot-rust (gdext), and runtime headset access through OpenXR. The client
decodes the existing V3 binary graph stream byte-for-byte, joins rooms over a
new BIP-340-authenticated /ws/presence WebSocket, treats node manipulation
as server-authoritative, and routes voice through two planes: a local
Whisper/Kokoro command-and-assistant loop and a LiveKit spatial voice
overlay.
This design is recorded in ADR-071; the transport and authentication completion is recorded in ADR-102.
Superseded predecessor. The prior browser-hosted WebXR client (Babylon.js render path, Three.js fallback, and a Vircadia world server with its own PostgreSQL entity store) is replaced wholesale. Babylon.js and Vircadia appear in this document only as the stack being retired. Quest 3 users side-load the APK; non-XR users continue to use the desktop React Three Fiber graph view (client-architecture.md), which is unchanged.
The browser-hosted WebXR client could not deliver the target experience on the target device. The native APK removes five structural ceilings:
- Single source of truth for position. The Vircadia world server kept its own entity store, duplicating state that is already canonical in the embedded graph store (Oxigraph + SQLite, ADR-11), RuVector, and the GPU physics actor mesh. The APK consumes the graph position stream directly — there is no second entity store to reconcile.
- Full OpenXR extension surface. Native OpenXR gives passthrough, scene mesh, spatial anchors, foveated rendering, and display-refresh control that the Meta browser's WebXR profile does not expose.
- No JS garbage collector in the render loop. The 90 Hz / 11.1 ms frame budget is met in Rust with zero steady-state allocation, not contended against a browser GC.
- One renderer. The browser stack carried two competing render trees (immersive and fallback) with duplicated identity, scene-graph, and input pipelines. The APK has a single Godot scene tree.
- Substrate alignment. The hot paths link the same Rust transport crate the server links, so wire semantics cannot drift between client and server.
The Rust substrate is unchanged. The only server-side additions are one WebSocket handler and one actor, plus a transport-agnostic crate shared with the client.
graph TB
User(["User<br/>(Quest 3 headset)"])
DesktopUser(["User<br/>(desktop browser, unchanged)"])
subgraph QuestDevice ["Meta Quest 3 (Horizon OS)"]
APK["Godot 4.3 APK<br/><b>NEW</b>"]
GDExt["gdext crate<br/>xr-client/rust/<br/><b>NEW</b>"]
OpenXR["OpenXR runtime (Meta)"]
APK --> GDExt
APK --> OpenXR
end
subgraph VisionclawContainer ["visionclaw container (Rust / Actix)"]
WSGraph["/wss<br/>graph stream + drag<br/>(unchanged)"]
WSPresence["/ws/presence<br/><b>NEW</b>"]
PresHandler["presence_handler.rs<br/><b>NEW</b>"]
PresActor["PresenceActor<br/><b>NEW</b>"]
Supervisor["GraphServiceSupervisor<br/>(unchanged)"]
Physics["PhysicsOrchestratorActor<br/>(unchanged)"]
ForceCompute["ForceComputeActor + CUDA<br/>(unchanged)"]
GraphState["GraphStateActor<br/>(unchanged)"]
Crate["crates/visionclaw-xr-presence<br/>transport-agnostic codec<br/><b>NEW</b>"]
WSGraph --> Physics
WSPresence --> PresHandler --> PresActor
Supervisor --> Physics
Supervisor --> GraphState
Supervisor --> PresActor
Physics --> ForceCompute --> WSGraph
PresActor -.links.-> Crate
end
subgraph Voice ["Voice"]
LiveKit["livekit :7880<br/>(spatial overlay)"]
Speech["speech_socket_handler.rs<br/>Whisper STT / Kokoro TTS"]
end
User --> APK
DesktopUser --> WSGraph
APK -- "TLS WSS :3001/wss" --> WSGraph
APK -- "TLS WSS :3001/ws/presence" --> WSPresence
APK -- "WebRTC" --> LiveKit
APK -- "TLS WSS" --> Speech
GDExt -.links.-> Crate
classDef new fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
classDef unchanged fill:#eceff1,stroke:#546e7a
classDef voice fill:#e0f7fa,stroke:#006064
class APK,GDExt,WSPresence,PresHandler,PresActor,Crate new
class WSGraph,Supervisor,Physics,ForceCompute,GraphState,DesktopUser unchanged
class LiveKit,Speech voice
The new server surface is three files —
src/handlers/presence_handler.rs,
src/actors/presence_actor.rs, and the
crates/visionclaw-xr-presence workspace
member — wired into the existing supervisor tree on the existing :4000 port
behind nginx :3001. No new containers and no new ports.
The split is deliberate. GDScript owns scene composition, signal wiring, UI state,
OpenXR feature toggles, and scene-graph manipulation in response to gdext signals.
It performs no wire-format parsing, no WebSocket state, and no pose
validation. The gdext crate owns protocol decode, WebSocket lifecycle, BIP-340
auth, pose validation, and LOD math, exposed to GDScript through
#[derive(GodotClass)] types that emit signals into the scene tree.
| Godot scene | Root node | gdext module | Responsibility |
|---|---|---|---|
XRBoot |
Node3D |
boot.rs |
OpenXR instance creation, capability probe, error overlay |
GraphScene |
Node3D |
graph_renderer.rs |
Instanced nodes (MultiMeshInstance3D) + edges, LOD |
AvatarRig |
Node3D |
avatar.rs |
Remote head + hands + nameplate, one per remote user |
LocalRig |
XROrigin3D |
interaction.rs |
Local pose, controller ray, grab, haptics |
HUD |
Control |
hud.rs |
Settings, room picker, mute, debug overlay |
The shared crates/visionclaw-xr-presence library is transport-agnostic: it
knows nothing about Actix actors, godot signals, or LiveKit. It is the single
source of truth for the avatar pose wire format (opcode 0x43), the
PresenceRoom aggregate invariants, and the pose validators. Both the client
gdext crate and the server presence_actor.rs link it, so wire-level pose
semantics cannot drift.
The client decodes the same binary position stream as the desktop client, on
the existing /wss endpoint. The V3 layout is a fixed 52 bytes per node behind
a 1-byte version header (0x03) — BINARY_NODE_SIZE_V3 — carrying id + flags,
position, velocity, SSSP distance and parent, cluster id, anomaly, community id,
and centrality. V4 delta encoding is the current default optimisation for the
desktop path; the canonical layout, the version registry, and the delta scheme are
documented in reference/binary-protocol.md
(ADR-061).
The frame loop drains pending position frames over an mpsc channel each
_process(dt) tick and writes directly into MultiMesh transform buffers — no
per-frame allocation. LOD buckets are recomputed every second frame to hold CPU
under 8 ms; bucket assignments are diffed against the previous frame, so only
changed instances incur a transform write. If the local head is stationary
(position delta < 1 cm, quaternion dot > 0.9999) the outbound pose frame is
dropped by the delta encoder, keeping AFK bandwidth near zero.
Multi-user presence rides a dedicated WebSocket served by
presence_handler.rs. Authentication is a Nostr-style challenge/response over
BIP-340 Schnorr signatures (secp256k1) — the same identity primitive the
graph socket's authenticate flow uses. The handshake binds the socket to a
verified did:nostr:<pubkey> so a client can never select its own avatar id or
impersonate another member.
sequenceDiagram
autonumber
participant APK as Godot APK
participant GDExt as gdext crate
participant XR as OpenXR (Meta)
participant Pres as presence_handler.rs
participant Room as PresenceActor
APK->>GDExt: boot::initialize()
GDExt->>XR: xrCreateInstance(required extensions)
XR-->>GDExt: XrInstance handle
GDExt->>GDExt: capability probe (fixed for process lifetime)
GDExt->>Pres: WSS /wss subscribe + authenticate (NIP-98)
Note over GDExt,Pres: graph stream unlocks server-authoritative drag
GDExt->>Pres: WSS /ws/presence (upgrade)
Pres-->>GDExt: challenge with nonce 32B and ts
GDExt->>GDExt: schnorr_sign(nonce + ts_le, did_priv)
GDExt->>Pres: auth with did, signature, room_id, metadata
Pres->>Pres: verify_signed_challenge BIP-340, did must equal verified pubkey
Pres->>Room: JoinRoom did, metadata, recipients
Room-->>Pres: JoinAck avatar_id, members
Pres-->>GDExt: joined with room_id, avatar_id, members
APK->>APK: load GraphScene.tscn
Pres-->>GDExt: V3 graph frames (0x03, 52 B/node)
APK->>XR: xrBeginSession() — first compositor frame
The graph WebSocket is connected before the presence WebSocket because graph state is the load-bearing context. Presence is allowed to fail without aborting boot: the user enters a single-user session and a yellow indicator appears in the HUD. The handshake has a 10 s deadline and a 15 s ping heartbeat; a failed or replayed signature closes the socket with code 4401.
Once joined, the client sends binary 0x43 avatar pose frames and the server
fans out coalesced sibling frames at a fixed 90 Hz room tick. The client→server
frame, encoded by crates/visionclaw-xr-presence/src/wire.rs, is variable-length
and little-endian:
[u8 opcode = 0x43]
[u16 frame_len_LE] bytes that follow this field
[u8;16 room_id_hash]
[u8 avatar_id_len][u8;N avatar_id_utf8]
[u64 timestamp_us_LE]
[u8 transform_mask] bit0=head bit1=left_hand bit2=right_hand
[{28 B} transforms...] present slots in head, left, right order
Hands are optional via the mask, so a head-only frame is far smaller than a full
head+hands frame. The server→client sibling frame prepends a
[u64 broadcast_seq][u32 room_id][u16 user_count] header and contains every
current member's latest pose, attributed by an opaque per-session local_id that
each avatar_joined event maps to a named DID.
presence_handler.rs rate-limits inbound frames to 120 per second (sliding
window; code 4429 on breach). PresenceActor then runs the shared validators
before re-broadcasting to every peer except the sender:
| Check | Bound | On failure |
|---|---|---|
| Velocity (head Δposition / Δt) | ≤ 20 m/s; NaN/∞ rejected | drop frame |
| World bounds (AABB) | default ±50 m symmetric | drop frame |
| Quaternion magnitude | within [0.99, 1.01] | drop frame |
| Timestamp monotonicity | strictly increasing, ≥ 8 ms apart | drop frame |
| Hand-to-head reach | ≤ 1.2 m anatomical | drop frame |
A session accumulating 10 violations in a 1 s window is kicked. Re-broadcast
respects the sovereign visibility-transition rules
(ADR-051) — invisible avatars are
dropped from each receiver's frame. A
PresenceActor self-stops when its room empties; the handler replaces any
disconnected actor address before the next joiner reuses the room, so a rejoin
never lands on a dead mailbox.
Node manipulation is server-authoritative. The headset never treats its own
local position as truth: grabbing a node with the controller ray emits
nodeDragStart / nodeDragUpdate / nodeDragEnd text messages on the /wss
graph socket. The server (socket_flow_handler) gates every drag:
- Authentication required — drags from a socket without a verified pubkey
(no completed
authenticatehandshake) are rejected. nodeIdmust fitu32— oversized ids are rejected rather than silently truncated.- Position sanitised — NaN, infinity, and out-of-bounds coordinates are rejected.
- Concurrent-drag cap — a per-client limit on simultaneous dragged nodes.
A validated drag pins the node in the GPU physics actor, freezing it against
the force-directed layout. The pinned position re-enters the V3 broadcast and
every client — the dragger, other headsets, and desktop browsers — renders the
same authoritative coordinate. Orphaned drags (a socket dropping before
nodeDragEnd) are released server-side after a 500 ms timeout, so a crashed
client can never leave a node permanently pinned.
Voice runs in two independent planes, multiplexed per user by
src/services/audio_router.rs and gated by push-to-talk (PTT). The local plane is
a Whisper / Kokoro command-and-assistant loop; the spatial plane is the
LiveKit overlay carrying positioned peer and agent audio.
flowchart TB
Mic["Quest mic<br/>(AudioStreamMicrophone)"]
PTT{"PTT held?"}
Mic --> PTT
subgraph Local ["Local plane (private, per user)"]
Whisper["Whisper STT<br/>speech_socket_handler.rs"]
Agent["Agent command<br/>voice_interface_actor.rs"]
Kokoro["Kokoro TTS"]
Whisper --> Agent --> Kokoro
end
subgraph Spatial ["Spatial plane (public, all users)"]
LKin["LiveKit SFU :7880"]
Panner["AudioStreamPlayer3D<br/>at remote AvatarRig head"]
LKin --> Panner
end
PTT -->|"held → command"| Whisper
PTT -->|"released → chat"| LKin
Kokoro -->|"confirmation to owner"| Earpiece(["Owner's ears"])
Panner -->|"HRTF bus"| Earpiece
classDef voice fill:#e0f7fa,stroke:#006064
class Whisper,Agent,Kokoro,LKin,Panner voice
- Plane 1 — command (private). With PTT held, mic audio routes to local
Whisper STT; the transcript drives graph/view configuration and agent
commands through
voice_interface_actor.rs. - Plane 2 — assistant (private). Agent and confirmation responses are spoken by local Kokoro TTS into the owner's ears only.
- Plane 3 — spatial chat (public). With PTT released, mic audio routes to the
LiveKit SFU; each remote track plays from an
AudioStreamPlayer3Dparented to that user's avatar head, spatialised on a dedicated HRTF bus (ATTENUATION_INVERSE_DISTANCE). The LiveKit room id equals the presence room id (1:1). - Plane 4 — spatial agent voice (public). Agent TTS can also be injected into the LiveKit room at the agent's graph position, so co-located users hear it spatially.
The local Whisper/Kokoro stack is the same one the desktop and elevation flows
use; the spatial plane reuses the existing LiveKit token path
(src/handlers/livekit_token_handler.rs) with no XR-specific changes. Opus at
32 kbps mono (≈ 64 kbps on the wire with redundancy) keeps a four-user room near
256 kbps of voice — comfortably inside the 100 KB/s per-user budget.
A missing required extension produces a fatal, user-visible error — there is no silent degraded mode. Required: hand tracking and hand interaction, passthrough, scene mesh and capture, spatial anchors, foveated rendering, composition-layer depth, performance settings, display-refresh-rate control, and local-floor reference. Optional: visibility mask and eye-gaze interaction (Pro variants only, PII-gated and opt-in). Scene-mesh and gaze data stay client-side; the gdext bindings expose no serialiser for them.
The XR client is more sensitive to transient failure than the desktop client — losing positional tracking for 200 ms in VR is nauseating. Each mode degrades gracefully rather than freezing the compositor.
| Failure | Behaviour |
|---|---|
| WebSocket disconnect | Exponential backoff 1 s → 30 s cap, 10 attempts. The last-received graph snapshot keeps rendering at 90 Hz so the compositor stays alive. After 5 failures, snapshot mode pauses pose tx; the HUD shows a reconnecting spinner. |
OpenXR runtime crash (XR_ERROR_INSTANCE_LOST) |
gdext flips to a 2D error overlay and tears down OpenXR. Restart requires a fresh launch — Meta's runtime owns process-wide GPU compositor resources. |
Voice failure (LiveKit RoomDisconnected) |
Non-fatal. Spatial voice indicators hide, a mic-off icon appears, pose continues; LiveKit reconnect every 30 s. The local Whisper/Kokoro plane is unaffected. |
| Packet-loss degradation | Per-remote-avatar pose ladder: 90 Hz → 30 Hz (loss > 5%) → 10 Hz (loss > 15%) → snapshot-on-significant-change (loss > 30%), recovering on the same thresholds in reverse. |
The 90 Hz target imposes an 11.1 ms hard frame budget. CI gates fail on any sustained breach.
| Resource | Budget |
|---|---|
| CPU per frame | 8 ms |
| GPU per frame | 8 ms |
| Draw calls | ≤ 50 |
| Triangles | ≤ 100 K |
| Allocations per frame | 0 in steady state |
| Network ingress | < 80 KB/s per user |
| Network egress | < 30 KB/s per user |
| Battery drain | < 12 %/hour |
Headline target: 90 fps stable on Quest 3 with 5 K visible nodes and 4 remote avatars, 99th-percentile frame time ≤ 12 ms, motion-to-photon < 20 ms, presence join < 500 ms p95.
- ADR-071 — Godot 4 + godot-rust + OpenXR XR replacement — governing decision (supersedes Babylon.js + Vircadia)
- ADR-102 — XR client / backend transport completion — shipped handshake, opcode
0x43,/ws/presence - ADR-061 — Binary protocol unification — single-wire authority
- reference/binary-protocol.md — V3 52 B/node layout and opcode registry
- how-to/xr-quest3-setup.md — build, side-load, and troubleshoot the Quest 3 APK
- security-model.md — BIP-340 identity, pose-injection gates, visibility filtering
- client-architecture.md — desktop React Three Fiber graph view (unchanged)