VR mode (WebXR)
Walk around Legaia's world in a VR headset.
The site's three WebGL2 scene pages - the world overview (the three kingdom continents), the assembled full-map view (asset viewer + game world), and the play page - can present their scene to a headset via WebXR: stereo rendering through the XRWebGLLayer, head tracking, and thumbstick locomotion through the world.
Plug in a headset, load a disc, and the VR button next to the canvas arms as Enter VR.
This is an enhancement retail never had, and it is opt-in - the 1998 game has no stereo path, and VR sits alongside the port's other explicit, default-off toggles (dynamic lighting, free-angle movement, the debug orbit camera) rather than reproducing anything on the disc.
Behavioural fidelity to retail stays the baseline for game logic; nothing here changes simulation.
Where the render path forks
Nothing about the flat (non-VR) path changes: a browser with no immersive-vr device runs exactly the render code it ran before - no global is shadowed, no GL context attribute is altered - but the button still renders, as VR unavailable, and hover / click explain the specific reason (see availability below).
The flat renderer stays the single source of geometry. TmdRenderer is untouched: VR uploads no mesh, compiles
no shader and reorders no draw. Exactly two things fork, and both fork around renderAssembled rather
than inside it.
The framebuffer + viewport. The XR frame loop binds the XRWebGLLayer's framebuffer, then
draws the scene once per XRView. renderAssembled sets its own full-canvas viewport and issues an
unconditional gl.clear, so per eye the VR loop scissors to that eye's layer.getViewport(view) rect
(which is what confines the clear, so the second eye does not wipe the first), shadows gl.viewport on
the context instance for the duration of the call, and then invokes the page's normal draw closure. Both shadows are removed
in a finally.
The view-projection matrix. renderAssembled builds its VP from the page's orbit camera by
calling the global buildWorldOrbitVp. Because that is a top-level function declaration in a classic script it
lives on the global object, and the renderer's reference to it resolves through the global scope at call time - so
VR mode replaces the property with a pass-through that returns the XR matrix while an eye is drawing and delegates to the
original otherwise. The shadow is installed on the first Enter VR press and is inert whenever no session is
running. Per eye:
vp = view.projectionMatrix · view.transform.inverse.matrix · W
The renderer multiplies that by each draw's model matrix exactly as it always has.
The world → XR transform
The renderer works in retail world units (+Y up, after the per-placement diag(1, -1, 1) flip of
the PSX +Y-down frame). WebXR works in metres. W is the bridge, for a viewer standing 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 when the device offers it, so the XR origin sits on the physical floor
and the headset reports itself ~1.6 m above it. That falls out of W as 1.6 · u world units
above the scene's floor - which is the whole trick: the only thing that decides 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 that mirror flips the handedness of the
screen-space derivatives it builds its shading normal from. An XR view-projection cannot mirror: the projection and
view matrices come from the device. So the mirror moves into the world transform instead. That keeps the shading correct (the
net world → clip determinant sign matches the flat path) and keeps the VR image oriented like the page the user just
pressed the button on. Stereo is unaffected: both eyes view the same mirrored scene from their true XR positions, so depth and
parallax are exactly as the device intends.
World scale, and what it means to "be there"
| Page | Units/metre | What that makes the scene |
|---|---|---|
| World overview | 2000 | A 16320-unit continent becomes an ~8 m diorama; the 1.6 m eye height puts you 3200 world units above the sea plane, looking down at the kingdom laid out around you. |
| Full-map view / play | 76 | Human scale. You stand in the town. |
The field figure is anchored on the character mesh: the field-form player model 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 1.7 m stride and a village house a believable 8 m.
Scale is not fixed. The grip buttons rescale the world live (clamped to 8..20000 units/metre), around the head rather than the feet so the scene does not lurch through you. Squeeze the world overview down to 76 and you walk onto the world map at human height; squeeze a town up and it becomes a model on a table.
Spawn placement
A field scene spawns at the component-wise median of the placed objects - not the framing AABB centre. A field map is a 128×128-tile grid whose town usually occupies a corner of it, so the AABB centre lands on empty ground with the buildings a hundred metres away; the median lands in the village. Y comes from the same set, because a placement's world Y is the floor tile it stands on (terrain tiles are excluded - they tile the whole grid and would drag the median back to the centre). The world overview spawns at the framing centre on the sea plane; the play page spawns where its third-person follow camera sits, so entering VR keeps the frame you were already looking at.
Locomotion + controls
Smooth locomotion on the left stick, comfort-first turning on the right. There is no teleport arc: the browser pages carry no collision or ground-height query (the walkability grid lives in the engine, not in the viewer's assembled draw list), so a teleport marker could not be validated against the floor - free flight is both honest about that and the right tool for a diorama.
| 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. |
| Trigger (either hand) | 4× speed. |
| Grip, left / right | Shrink / grow the world. |
| A / X (right hand) | Return to the entry pose. |
Availability + diagnostics
The VR button is always rendered on a 3D page's control bar - availability only changes what it says and what a click
does. With immersive-vr supported and a scene loaded it is the working Enter VR; while the
scene is still loading a click says so; and in every unavailable state it reads VR unavailable with the
specific diagnostic on hover and, on click, in a dismissible note (a click also re-runs the check). The probe re-runs on the
XRSystem's devicechange event, so starting SteamVR or plugging the headset in after page
load arms the button without a reload.
The diagnostics name the three real-world failure modes. No navigator.xr: WebXR only exists
in a secure context - a page served over plain http:// from a LAN IP (e.g. python3 -m http.server
browsed from another machine) has no WebXR at all; reach the site via http://localhost (an SSH port-forward
works) or https. In a secure context this instead means the browser has no WebXR (Firefox's is effectively dead) - use
Chrome or Edge. isSessionSupported answers no: the browser has WebXR but no usable VR runtime -
start SteamVR and set it as the active OpenXR runtime (SteamVR settings → OpenXR). The check never
settles: a browser can expose navigator.xr with no backing runtime and leave the promise pending
forever; the probe is raced against a timeout and reports "XR device check timed out".
Session lifecycle
navigator.xr.isSessionSupported('immersive-vr') is probed at script load and re-probed on
devicechange / an unavailable-button click. Only when it answers yes does the
module install its getContext hook, which adds xrCompatible: true to WebGL context creation from
then on - so contexts built after the probe never need the retro-fit makeXRCompatible() call, which is allowed to
drop the context when it has to move to another GPU adapter. Contexts that predate the probe still get the retro-fit call,
raced against a timeout: the promise is specified to settle, but a browser with navigator.xr and no backing XR
runtime can leave it pending forever, and a hung await would wedge the button.
The host pauses its own requestAnimationFrame loop on entry - 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 the character - and resumes it on exit with
the flat camera restored. A live session survives a scene swap on pages that keep one canvas (the game-world page's town
navigator, a door transition on the play page): same GL context, same renderer, so the viewer is simply re-placed in the new
map.