VR
VR is a toggle inside a running client, not a launch flag. Press F8 — or type vr at the console — and the client you are already playing moves into the headset. Press it again and you are back at a flat window. There is no relaunch either way, and no separate VR build.
Turning it on
Section titled “Turning it on”| Action | Binding |
|---|---|
| Toggle VR | F8, vr at the console, or the Virtual reality switch on the options page |
| Report the session | vr status |
| Recenter the play space | vr recenter |
| Time a run of toggles | vr cycle [count] |
vr status names the machine’s active OpenXR runtime, where the session is, the last state the runtime reported, and whether the loader is mapped into this process at all.
Prerequisites
Section titled “Prerequisites”- A desktop OpenXR runtime installed and set active — SteamVR is what this was built against.
- A headset the runtime can see. The client asks for a head-mounted display and will not fall back to anything else.
- A desktop RID the engine ships an OpenXR loader for (table below).
| Platform | Loader shipped in the package | Notes |
|---|---|---|
| Windows x64 | ✅ openxr_loader.dll | Khronos release 1.1.63, from the vendored OpenXR.Loader package. |
| Linux x64 | ✅ libopenxr_loader.so.1 | Built from the same release’s source; asks the system for glibc only. |
| Linux arm64 (Steam Frame) | 🟡 libopenxr_loader.so.1 | Same build, made on the Frame. The loader loads and lists the SteamVR runtime’s 49 extensions headless; a live headset session is not yet confirmed. |
| macOS, iOS, Android | ❌ | Not wired up. |
The Linux loaders are built by Halcyon/Halcyon.Xr.OpenXr/native/build-posix.sh from the commit pinned in PINNED_VERSION, and sit beside the client under the name the bindings ask for, so nothing on the system has to provide one. The loader finds the active runtime itself: $XR_RUNTIME_JSON if set, otherwise $XDG_CONFIG_HOME/openxr/1/active_runtime.json (~/.config/... by default), then /etc/xdg/openxr/1/active_runtime.json. If SteamVR is installed but not registered there, point the variable at its manifest before launching the client; vr status prints which manifest was found. On the Frame SteamVR is already registered there, as a link to /opt/steamvr/steamxr_linuxarm64.json, so nothing needs setting.
If no runtime is installed, or nothing is set active, the press says so on one console line and the client carries on flat. A client that never presses F8 never touches a headset runtime at all — the OpenXR bindings are not even mapped into the process until the first press, which matters because creating an instance against SteamVR starts vrserver.exe.
What happens when you press it
Section titled “What happens when you press it”OpenXR on Vulkan names the device the headset is attached to and wants to create the VkInstance and VkDevice itself. So the first press rebuilds the client’s whole Vulkan stack onto what the runtime hands back: renderer, swapchain, object cache and UI device objects are all torn down and stood up again, on the same code path gpu.rebuild uses. Expect one visible hitch on that press.
The hitch is announced before it happens. The press does not rebuild anything itself — it stages the device the runtime handed back and asks the client for a rebuild, and the frame that took the ask paints the loading box as a graphics device card reading switching to VR, with a sweeping bar and no button, and then presents. Only the next frame tears the stack down, so the window is left holding a picture that says what it is waiting for instead of whatever was on screen when F8 was pressed. The card clears on the first frame after the rebuild finishes, and a rebuild that fails leaves the runtime’s own message on the card with a Close button. gpu.rebuild shares this exactly, counting its passes on the same step line, because the notice hangs on the rebuild rather than on VR.
After that the client is already running on a device the runtime made, so the next press tries to create a session against the device it still has and only falls back to a full rebuild if the runtime refuses. The console says which path ran, every time:
vr: session created on the retained device; no device objects were rebuilt.vr: the runtime refused the retained device; falling back to a full rebuild. (<result>)vr: session created on a device OpenXR made; every device object was rebuilt.Turning off walks the runtime’s own exit ladder — request the exit, wait for STOPPING, end the session, wait for IDLE or EXITING, destroy the session and the instance. A runtime that never reports STOPPING costs one console line and one slow toggle rather than a frozen client; see client.vr.exitTimeout below.
On SteamVR
Section titled “On SteamVR”SteamVR starts on the first press if it is not already running, which is the bulk of the first toggle’s cost. The SteamVR compositor takes the eye images from the moment the session reaches a rendering state; before that the headset shows SteamVR’s own environment, not the game. Ending a session hands the headset back to SteamVR rather than closing it.
What the frame does
Section titled “What the frame does”Once on, the headset — not the monitor — paces the client. Each frame waits on the runtime, locates both eyes at the predicted display time, renders each eye through the same full pass chain the window gets (so tonemapping, effects and UI all run per eye), and hands both back inside one projection layer. The window presents without vsync while a session is on, so nothing but the runtime is pacing the loop.
Where you stand is your pawn, every frame. Before either eye is recorded, the reference space is pinned to the viewpoint the flat camera renders from — the same predicted position, sub-tick interpolation, crouch smoothing and first-person neck pivot — so you walk, jump and ride around the level rather than watching it from wherever you happened to press F8. Turning your head turns you. Nothing here tracks a body, so the head and the body turn together rather than the head drifting off shoulders every other player still sees facing the old way: the headset’s yaw is spent on the pawn, and the pawn’s yaw is the mouse’s turning and the head’s summed. The eyes then render at exactly that yaw, so nothing is counted twice, and your avatar, the mirror and the yaw the server reads all face the same way. Pitch and roll stay the headset’s alone for as long as the session lasts, so the mouse turns you and never tilts the world under you. Walking is measured against where you are facing, which is where you are looking.
The desktop keeps a picture. The left eye is copied into the window every frame, so someone beside you can follow along.
The interface hangs in front of you. While a session is on, the frame’s user interface is recorded once into an offscreen target instead of straight onto the window, and that one picture is then used twice: as a world-space quad in both eyes, and composited back over the mirror so the window still shows the interface it always did. There is no second UI code path and no VR widget — the pause menu, the console, the options window and the toasts are the same tree, the same recording and the same draw list the monitor gets, so anything new that draws on the monitor is already in the headset.
The quad is drawn after the world with depth testing off, so it is never buried in geometry. It is as wide as client.vr.panelWidth and as tall as that width divided by the window’s aspect, so it keeps your screen’s shape rather than the headset’s, and it sits client.vr.panelDistance ahead of you at eye height. A frame with nothing on it hangs no quad at all.
A screen latches; the HUD follows. Every screen you are meant to read — the pause menu, the options window, the console and the loading box — hangs the panel level in front of your face the moment it opens, wherever you have wandered off to, and then holds it still while you read it (mltn, 2026-09-20). With no screen up the panel is the HUD, and it tags along with your head (mltn, 2026-09-22): it rides your head’s position every frame, holds its direction while you glance around inside client.vr.hudFollowDeadzone degrees of yaw or pitch, and eases back square in front of you once you look further than that, on a time constant of client.vr.hudFollowSeconds. The ease runs until the HUD is centered again rather than stopping at the deadzone’s edge. client.vr.hudFollowSeconds=0 is a rigid head lock. The screens and the HUD share one panel, so they never fight: while a screen is up the HUD is under it and holds still with it, and when the screen closes the HUD carries on following from where the menu hung. Starting a session and recentering hang the panel afresh.
The HUD can be hidden. client.vr.hideHud=true keeps every gameplay surface out of the headset — the crosshair, the speedometer, the frame stats, the effects and position readouts, the gizmos and the entity labels (the whole tooling, HUD and crosshair bands). The screens, the developer overlay’s own bar, windows and toasts, and the drawn pointer are kept, so the pause menu still opens and can be pointed at. It only applies while a headset session is live; flat play ignores it. The desktop mirror shows the same interface the headset does, so it drops the HUD too while the session runs.
The pointer is drawn. The mouse cursor is a band of the interface like any other, recorded at exactly the coordinates the widget tree uses for hover and click, so where the arrow points on the panel is where the click lands. It is drawn only while a headset session is live; the desktop keeps using the operating system’s own cursor.
The frame-stats card gains a row at its netcode level (client.showFps 3; see Debug Overlays) while on:
vr 1832x1920 11.11 ms FocusedThat is the per-eye resolution the runtime recommended, how long the runtime says one headset frame lasts, and the session state. The cadence is worth reading against the window’s own frame time right above it: once the headset is pacing the loop, the two are no longer the same number.
Room scale
Section titled “Room scale”Your head is the pawn’s head, always. Where you are standing in your room never offsets the view — it moves the pawn. Walk a meter forward in your room and the pawn walks a meter forward in the level, through the same collider, with the same walls, steps and slopes, and the same footsteps and surface sounds a key press would have made.
That has two consequences worth knowing before you put the headset on:
- A wall stops the pawn while your real head keeps going. The picture stops at the wall; you do not.
- Nothing is banked while it is stopped. Step back and the pawn steps straight back off the wall — there is no buffer to walk out of first, and no drift to unwind.
Crouching is real. Duck in your room and the view goes down with you, and once you are as low as the crouch key would put you, the pawn ducks too. The view never sinks below client.vr.minEyeHeight off the floor, so kneeling for real cannot put the camera through the ground.
Standing up is not a jump. Rise above the height you recentered at and the view rises with you, up to client.vr.maxRiseMeters — but the body stays on the ground. Space is the only jump. The crouch key and Space both still work, and stack with whatever your real head is doing.
Recentering
Section titled “Recentering”The play space snaps to you when you put the headset on: wherever you are standing becomes the pawn’s position, whatever height you are holding your head at becomes your standing height, and whichever way you are facing becomes the body’s facing. It is a snap, not a glide. Turn it off with client.vr.recenterOnWear=false and it happens only once, at session start. vr recenter does it by hand at any time.
The client reads “worn” from the session state the runtime reports — visible or focused is worn, and the idle states the runtime drops to when nobody is in the headset are not. While the headset is off your head it banks no movement at all, so one lying on a desk does not walk your pawn. If your headset’s sensor reports wrongly, client.vr.assumeWorn=true takes it as always worn.
The options page
Section titled “The options page”All of this has a page in the options window — VR, between Video and Audio. Headset leads with the Virtual reality switch, which does exactly what F8 and vr do, through the same toggle; with no OpenXR runtime installed it stays on the page, grayed, with the reason in its description, and a running session can always be switched off. Room holds recentering, always-worn, walk-where-I-look and the two height limits; HUD holds hide-the-HUD and the two follow settings; Frames holds the frames-in-flight and the frame-report interval.
Preferences
Section titled “Preferences”All are ordinary client.* preferences — set one from the console and it applies on the next frame. None of them is networked, and a process that never turns VR on never reads them.
| Preference | Default | What it does |
|---|---|---|
client.vr.exitTimeout | 5 | Seconds to wait for the runtime to retire a session before abandoning the handles and returning to desktop mode anyway. |
client.vr.toggleCycles | 20 | How many on/off pairs vr cycle presses through when no count is given. |
client.vr.recenterOnWear | true | Whether putting the headset on snaps the play space to where you are standing and facing. Off, only the session start recenters and vr recenter does. |
client.vr.assumeWorn | false | Treats the headset as worn whatever the runtime reports, for a presence sensor that reports wrongly. |
client.vr.maxRiseMeters | 0.12 | How far standing up for real may raise the view above your recentered standing height, in meters (the options row “Standing-up headroom”). The body never leaves the ground either way. The default is the value mltn settled on in a headset; higher, the eye hovers above the avatar. |
client.vr.minEyeHeight | 0.25 | How close to the floor crouching for real may bring the view, in meters. |
client.vr.framesInFlight | 1 | Frames the renderer keeps in flight while a session runs. One is the lowest-latency answer; two gives the desktop path’s throughput back on a device that needs it. |
client.vr.report | 1 | Seconds between headset frame-timing lines in the log. 0 is off. |
client.vr.panelWidth | 1.6 | How wide the interface panel is, in meters. Its height follows from the window’s aspect. Retunes a panel already hanging. |
client.vr.panelDistance | 1.5 | How far ahead of you the panel stands, in meters: taken once when a screen latches it, and every frame while it follows as the HUD. |
client.vr.hudFollowDeadzone | 20 | Degrees of yaw or pitch you may look away from the HUD before it follows. |
client.vr.hudFollowSeconds | 0.25 | Time constant, in seconds, the HUD eases back in front of you with. 0 locks it rigidly to your head. |
client.vr.hideHud | false | Keeps the gameplay HUD (tooling, HUD and crosshair bands) out of the headset while a session is live. Screens, the developer overlay and the pointer still show. |
client.vr.cursorSize | 22 | The pointer arrow’s height in logical pixels, multiplied by the interface scale like any other widget. |
Measuring a toggle
Section titled “Measuring a toggle”vr cycle [count] presses the toggle on and off, printing one row per press with the press’s wall time and the process’s private bytes after it, plus private bytes before and after the whole run. It is driven a press at a time from the frame loop — a press is issued on one frame and read as landed on a later one — so there is no thread, no sleep and no waiter anywhere in it. Use it to check that a session is not leaking a device across toggles.