At a glance

Where
The browser's three WebGL2 scene pages; native play-window has 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: 2000 for the continent diorama, ~76 for 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
Per-frame VR fork: the page's draw closure is unchanged; each XR frame binds the XRWebGLLayer framebuffer, then for each eye scissors to that eye's viewport, swaps the view-projection builder for the eye's projection times inverse view times the world transform W, and calls the same draw closure XR frame bind framebuffer left eye scissor + viewport shadow right eye scissor + viewport shadow vp = P · V⁻¹ · W VP builder shadowed renderAssembled unchanged draw
The fork lives around the page's draw, not inside it: per eye, scissor the clear, hand the renderer the device's matrices, call the same closure the flat page calls.

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 XRWebGLLayer framebuffer 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 a finally.
  • 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⁻¹ · W while 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

PageModeUnits/mWhat it is
World overviewDiorama2000A 16320-unit continent becomes an ~8 m tabletop model; eye height puts you 3200 world units above the sea.
On the ground76Human 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.
PlaySpectator~76Free-flying camera in the running world, spawned where the follow camera sits.
First-personliveWhat 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

InputEffect
Left stickWalk / fly horizontally, relative to where you are looking.
Right stick XSnap turn, 30° per flick (latched - no continuous rotation).
Right stick YAltitude (disabled in the on-the-ground and first-person modes).
Trigger (either)4× speed.
Grip, left / rightShrink / 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.

SymptomCauseFix
No navigator.xrWebXR 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.

See also