VR mode (WebXR)
Walk around Legaia's world in a VR headset. The site's three WebGL2 scene pages - the world overview, the assembled full-map view and the play page - can present their scene to a headset via WebXR: stereo rendering, head tracking, thumbstick locomotion. Plug in a headset, load a disc, and the VR button arms as Enter VR. This is an opt-in enhancement retail never had; nothing here changes simulation.
At a glance
- Where
- The browser's three WebGL2 scene pages; native
play-windowhas no VR path - What forks
- Only the framebuffer / viewport and the view-projection matrix - geometry, shaders and draw order are the flat renderer's, untouched
- Scale
- One number, world units per metre:
2000for the continent diorama,~76for human scale in a town; grips rescale live - Modes
- Play page: Spectator / First-person (what Vahn sees). World overview: Diorama / On the ground
- Retail
- None of this exists on the disc - VR sits alongside the port's other explicit toggles
Where the render path forks
Putting on a headset does not load a different renderer; it asks the existing one to draw the same scene twice, once per eye, through matrices the device supplies. TmdRenderer is untouched: VR uploads no mesh, compiles no shader, reorders no draw. A browser with no VR device runs exactly the flat code it always ran. Two things fork, both around the page's draw closure:
- Framebuffer + viewport. The XR loop binds the
XRWebGLLayerframebuffer and draws once per eye, scissored to that eye's viewport rect - the scissor is what confines the page's unconditional clear, so the second eye does not wipe the first. The viewport is shadowed on the context for the duration of the call; both shadows are removed in afinally. - View-projection. The page's VP builder is a top-level function on the global object, resolved at call time - so VR replaces it with a pass-through that returns
P · V⁻¹ · Wwhile an eye is drawing and delegates to the original otherwise. The renderer multiplies by each draw's model matrix exactly as it always has.
The world → XR transform
Legaia measures its world in arbitrary units; a headset measures in metres. One matrix converts, for a viewer at world point p facing yaw with u world units per metre:
W = MirrorX · RotY(-yaw) · Scale(1/u) · Translate(-p)
The XR reference space is local-floor, so the headset reports itself ~1.6 m above the physical floor - which becomes 1.6 · u world units above the scene's floor. The only thing deciding whether you stand in a town or tower over a continent is u.
Why the world is mirrored
Both flat projections mirror screen X - the retail horizontal flip - and the fragment shader compensates with a hardcoded u_normal_sign = -1, because the mirror flips the handedness of the screen-space derivatives it builds its shading normal from. An XR view-projection cannot mirror (the matrices come from the device), so the mirror moves into the world transform. That keeps the net world→clip determinant sign matching the flat path, and keeps the VR image oriented like the page. Stereo is unaffected: both eyes view the same mirrored scene from their true positions, so depth and parallax are exactly as the device intends.
World scale and viewing modes
| Page | Mode | Units/m | What it is |
|---|---|---|---|
| World overview | Diorama | 2000 | A 16320-unit continent becomes an ~8 m tabletop model; eye height puts you 3200 world units above the sea. |
| On the ground | 76 | Human scale on the terrain: a bilinear sampler over the walk heightfield snaps the rig's floor to the surface every frame; you walk up hills and down to the coast. | |
| Play | Spectator | ~76 | Free-flying camera in the running world, spawned where the follow camera sits. |
| First-person | live | What Vahn sees. The rig's floor is pinned to the player's feet each frame, read back after the engine tick - the engine, not the headset, owns the walk. The left stick routes into the engine's collision-checked movement controller, aimed along your gaze; trigger / A map to Cross, B to Circle, so NPC dialogue works from inside the world. The player mesh is dropped from the eye draws. |
The human-scale figure is anchored on the character mesh: the field-form player stands ~130 world units tall, so a 1.7 m human puts a metre at ~76 units - which independently makes the 128-unit walkability tile a believable stride and a village house a believable 8 m. First-person derives it live from the posed mesh height. Modes are cycled by a VR: <mode> button, usable before entry and mid-session; grips rescale the world live (clamped 8..20000 units/m), around the head so the scene does not lurch through you.
Spawn placement: a field scene spawns at the component-wise median of the placed objects, not the bounding-box centre - a town usually occupies one corner of its 128×128-tile map, so the box centre lands on empty ground while the median lands in the village. Y comes from the same set, because a placement's world Y is its floor tile. The world overview spawns at the framing centre; the play page where its follow camera sits.
Locomotion + controls
| Input | Effect |
|---|---|
| Left stick | Walk / fly horizontally, relative to where you are looking. |
| Right stick X | Snap turn, 30° per flick (latched - no continuous rotation). |
| Right stick Y | Altitude (disabled in the on-the-ground and first-person modes). |
| Trigger (either) | 4× speed. |
| Grip, left / right | Shrink / grow the world. |
| A / X (right hand) | Return to the entry pose. |
There is no teleport arc: the browser pages carry no collision or ground-height query (the walkability grid lives in the engine, not the viewer's draw list), so a teleport marker could not be validated against the floor - free flight is honest about that and the right tool for a diorama.
Availability + session lifecycle
The VR button is always rendered - availability only changes what it says. With WebXR supported and a scene loaded it is the working Enter VR; otherwise it reads VR unavailable with the specific diagnostic on hover and click. The probe re-runs on the device-change event, so starting SteamVR after page load arms the button without a reload.
| Symptom | Cause | Fix |
|---|---|---|
No navigator.xr | WebXR needs a secure context - plain http:// from a LAN IP has none. In a secure context: the browser has no WebXR. | Browse via localhost (SSH port-forward) or https; use Chrome / Edge. |
| "Session not supported" | WebXR present, but no usable VR runtime. | Start SteamVR; set it as the active OpenXR runtime. |
| "XR device check timed out" | A browser can expose navigator.xr with no backing runtime and leave the probe pending forever; it is raced against a timeout. | Install / start a runtime, click to re-check. |
On entry the host pauses its own animation loop - the XR frame loop drives the draw, and on the play page also the engine tick, so NPCs keep moving and the keyboard still steers. On exit the flat camera is restored. A live session survives a scene swap on pages that keep one canvas (a door transition, the town navigator): same GL context, same renderer, the viewer is simply re-placed.
Deep dive: context compatibility
Only after the support probe answers yes does the module install its getContext hook, adding xrCompatible: true to WebGL context creation from then on - contexts built after the probe never need the retro-fit makeXRCompatible() call, which is allowed to drop the context when it must move to another GPU adapter. Contexts predating the probe get the retro-fit call raced against a timeout, since a runtime-less browser can leave the promise pending forever and a hung await would wedge the button.
How it is verified
Headless Chromium has no XR runtime, so the VR path is exercised against an injected mock navigator.xr that hands back synthetic frames with two eye views rasterising side-by-side into the page canvas. The mock pins:
- Both eye rects rasterise and the second clear does not wipe the first; the halves differ, and diverge hugely at an absurd IPD (each eye really uses its own view matrix).
- A stick push moves the viewer along the synthetic head's facing; a 90° head yaw rotates it 90°; the snap turn is exactly 30°.
- The engine keeps ticking under the XR loop; in first-person the rig sits at the player's feet, the scale equals mesh height over 1.7 m, and a stick push moves the engine's player (
window.__vrDebug()).
Full write-up: docs/subsystems/vr-mode.md.