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"

PageUnits/metreWhat that makes the scene
World overview2000A 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 / play76Human 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.

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.
Trigger (either hand)4× speed.
Grip, left / rightShrink / 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.