feat(vr): play in a PC VR headset over OpenXR, with a VR tab in the Insert menu #204
No reviewers
Labels
No labels
area/accessibility
area/audio
area/input
area/install
area/menu
area/renderer
area/saves
area/upscaling
good first issue
gpu/amd
gpu/igpu
gpu/intel
gpu/nvidia
help wanted
kind
bug
kind
chore
kind
docs
kind
feature
kind
performance
kind
question
needs measurements
platform/linux
platform/steam-deck
platform/windows
preview
priority
critical
priority
high
priority
low
status
blocked
status
confirmed
status
duplicate
status
invalid
status
needs-info
status
wontfix
testing wanted
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Longshot!204
Loading…
Reference in a new issue
No description provided.
Delete branch "airdamien/Longshot:feat/vr-openxr"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Why
Longshot can only be played on a monitor. This lets it be played in a PC VR headset (tested with a Meta Quest 3 over Quest Link): either on a big flat screen in front of you, or in stereo 3D with the game's camera at your eyes.
What changed
gpu/vk/vk_xr.cpp/vk_xr.h(new). The renderer's instance and device are made through OpenXR (XR_KHR_vulkan_enable2) when VR is on. Flat-screen mode shows every presented picture on a quad layer. Stereo mode draws one eye a frame (xr_view), matches frames to eyes by the scene camera (xr_note_scene_draw), holds a pair's first eye until its second and gives both to the headset together (xr_copy_acquire,publish_held), one pair perxrWaitFrame(xr_thread). The HUD is redirected to a head-locked panel (xr_hud_draw), with its middle (the aiming reticle) shown where the game aims. A first-person view (left-stick tap or F9). The Touch controllers as a virtual SDL gamepad (pad_attach). On Linux the file is stubs (xr_built()false): it is Win32 only for now.vk_main.cpp(instance, device, shutdown),vk_replay.cpp(present_image: the copies to the headset, the window's mirror),vk_draw.cpp(HUD and scene-camera notes),vk_temporal.cpp(a DLSS/FSR history per eye),vk_framegen.cpp(no made frames with a headset).gpu/vk/vk_menu.cpp.Row::applyis new: a row kept outside the options file (these are kept inlongshot_vr.ini, written byxr_opt_changed).host/hos_main.chos_oot_free_viewhands the game the headset's view and field of view;host/nn_vi.crealtime_stepsteps the game in pairs of frames while stereo runs (both eyes one moment);host/rx_backend.clooks up the renderer's optionalmkwvk_xr_*exports.nx2/oot_wide.py(the projection's field of view at the five matrix-update sites),nx2/oot_patches.py(the step hook records the step a frame read).nx2/build.pyaddsthird_party/openxr/include, and copies Khronos'openxr_loader.dllbeside the game when the tools have it (LONGSHOT_OPENXR_LOADER);nx2/make_game.pycarries it into the game folder (BESIDE).quest/build_vk.ps1builds the renderer alone and installs it with the loader.tools.lockhas a line for Khronos' loader (openxr-loader, Khronos' own release archive, Apache-2.0, SHA-256 pinned), so the Builder downloads it with its other tools;builder/LongshotBuilder/Runner.cspasses its folder to the build. Nothing of it is committed or packaged in a release.--prepare-shaders,HOS_HEADLESS):xr_wanted()ingpu/vk/vk_xr.cpp.third_party/openxr(Khronos' headers, Apache-2.0, unmodified, withPROVENANCE.md).quest/README.md, the three switches indocs/development.md.Flags and settings
longshot_vr.inivr=/ VR tab "VR headset"stereo=/ VR tab "View"quest/longshot_vr.iniRX_VR=0/1,RX_VR_STEREO=0/1vr=andstereo=for one runLONGSHOT_OPENXR_LOADER=<dir>openxr_loader.dll, copied beside the gameopenxr-loadertool)RX_VR_TEST=1RX_VR_TRACE=1Measurements
main:
0c74253d(2026-11-13)this PR: feat/vr-openxr @ 7b2120c7. Rebased since onto main @
dd02c6cc, and the numbers still hold: main's two commits after0c74253dare docs only (the README, CONTRIBUTING.md), and this PR's commits after 7b2120c7 are the README and its check, docs, tools.lock and the build's copy of the loader, and one early return inxr_wanted()for runs with no game window, none of which a game with VR off runs. Rebased again onto main @514591a3(the head is now10d2a376) and not measured again: main's commits sincedd02c6ccare the website, docs, the forge script, a Damage taken row, an opt-in HDR route for FSR 4, occlusion query numbering, quick pipelines, two input fixes and the menu's layout presets; the one conflict wasrow_shown's line in the menu (the Damage taken row's condition beside the VR rows'), and with VR off none of this change's code runs in a frame. The rebased branch was built whole (nx2/make_game.py) and its checks run. Rebased again onto main @cf605c85(the head is now40fae344) and not measured again: main's 34 commits since514591a3are renderer, host and docs work none of which is in this change's path (sparse VRAM mirrors, late readbacks, the recorder thread, Intel fixes, hold-to-toggle, a Mono choice, the caches' and logs' folders); the conflicts wererow_shown's line in the menu (main's Intel row beside the VR rows'), the options the host reads (hold-to-toggle beside the headset's pads), and the measured runs, now two files underdocs/tested/.Hardware: NVIDIA GeForce RTX 5080 (Vulkan driver 0x93cec000), Windows 11
Scene: the save's load point, Link standing still; 3840x2160 output, DLSS, frame generation Auto (the display's rate), the game's frame rate cap 60
Method: VR off on both (
RX_VR=0: what this PR must not cost). 150 s runs, the first 60 s left out, two interleaved pairs (main, this PR, main, this PR). The numbers areRX_VK_FRAME_LOG's frame times, a line per present (made frames included); the game's own rate islongshot_log.txt'svi:line.How to reproduce: both builds from the same dump with
nx2/make_game.py, the samelongshot_options.iniand save,RX_VR=0 HOS_AUDIO_MUTE=1 HOS_INPUT_SCRIPT=12:A,15:A,...,42:A RX_VK_FRAME_LOG=<csv>.0c74253dAn earlier pair of runs, before the rebase (main @
7097361fagainst this PR @ 5c269eda, frame generation off): 60.00 fps on both, median 16.669 / 16.658 ms against 16.683 / 16.685 ms. With a headset on there is nomainto compare with.Testing
Tested:
python -I -B tests/run_all.pyon Windows 11 (llvm-mingw), at the rebased head: 477 passed, 7 failed, at the rebased head. The same 7 fail onmain@cf605c85on this PC (test_bake_portability,test_fpx_sticky,test_module_ids,test_oot_state_tap,test_pad_layout,test_spawn_emit,test_tested_configurations): local tools and packages, line endings in a pipe, and a fixture of main's that does not compile here, not this change.mainon this PC (test_bake_portability,test_fpx_sticky,test_spawn_emit,test_nup_paths,test_check_third_party,test_release, andtest_display_pointer, checked on main @0c74253d): missing local tools or packages, or failing onmainitself, not this change.test_bake_portability,test_bake_ds: nozstandard;test_check_third_party: the Windows prebuilt files copied intothird_partyfor the build).RX_VK_ONLY=1 nx2/build.py --standalonecompiles and linkslongshot_vk.sothere.vr=1and no headset connected, and withRX_VR=0. The VR tab opens, a row's change applies and is written tolongshot_vr.ini(xr: menu: fp_height=0.30), the options file is left alone.RX_VR_TEST=1: the game hands out a view per frame and each frame is matched to its eye, 76 s without a crash.tools.py install --only openxr-loaderwith an empty cache downloads Khronos' archive, checks it and unpacksopenxr_loader.dll(SHA-256 b0d0eed4...); amake_game.pybuild withLONGSHOT_OPENXR_LOADERset puts it besidelongshot.exe, and its shader preparation ends normally withvr=1(before thexr_wanted()change that run opened a headset session and died on exit, 0xc0000409).ultrawide menu sides online and the sides did not show. The session closed cleanly on exit (xr: the session and the instance closed). Before the port, on Hookshot v2.1.0, the flat screen, stereo, the HUD panel and reticle and the Touch controllers were played over several sessions.Not tested:
main; only the build and the game-free checks ran.Needs other testers:
mainwith the VR tab present (T-VZY2)main; the VR tab shows one row saying it is not in the Linux build (T-W3YQ)Screenshots
The Insert menu's VR tab, this PR @ 5c269eda (the tab is the same at the head;
mainhas no such tab):Risk and rollback
realtime_stepand the camera hook ask the renderer for a view each frame. They do nothing unless the renderer says a headset runs in stereo.nx2/oot_wide.py) reads host globals that are zero without a headset; it needs the game rebuilt.tools.lock's new line: see the reviewer notes (mirror it before a release).vr=0(orRX_VR=0). Reverting the commit is enough; nothing is migrated.Reviewer notes
python -I scripts/tools.py mirror openxr-loader.tools.lock's new line names its copies in Longshot's store first, astests/test_tools_lock.pyasks, and they are not there yet.tools.pyfalls through to Khronos' URL (checked here: a fresh cache installs it), but the Windows setup (builder/setup/setup.c) reads only a line's first two URLs, so a Builder release cut from an unmirrored lock could not install;tools.py check --requirerefuses such a release. If a 32 MB download for every Builder install is not wanted for a feature that is off by default, say so and I will make it a build option instead (as--fetch-fsr-runtimeis).MkwVkHost/MkwVkRendererare unchanged: the host finds the new calls as optional exports.#dev-workbeforehand; it started on Hookshot before the move.Issues
None.
Checklist
For everyone
type(scope): description).main.python -I -B tests/run_all.pyrun and passing (any failure that also fails onmainis named above).third_party/untouched (a new folder with aPROVENANCE.mdinstead).If applicable
Assisted-by:/Co-Authored-By:trailers.docs/tested-configurations.mdfor each measured run (now a file underdocs/tested/:2026-11-13-vr-openxr-airdamien-run01.mdand-run02.md).tests/that reads changed source updated in this PR, not weakened.🤖 Generated with Claude Code
Rebased onto main @
0c74253dand measured again there (VR off: 236.4 / 236.9 presents a second against main's 236.3 / 237.9, the same median frame time). Added: the Ultrawide menus fix found in a headset run (7b2120c7), the README's VR rows, two tested-configurations rows, and the three test requests (T-N5H4, T-VZY2, T-W3YQ). Still WIP: the head has not been run in a headset yet, and the screenshots are to attach.The README has a line for each VR row, and tests/test_readme_menu_rows.py now covers the VR tab (it failed on the grouped lines of the commit before). run_all on Windows: 443 passed, the 7 failures are main's on this PC.
Run in the headset at the head's code (the build of 7b2120c7, Quest 3 over Link, stereo 3D, Ultrawide menus at Centred, blurred sides): it looks fine, the sides no longer reach the headset, and the session closes cleanly. Screenshots of the VR tab attached. Marking it ready.
WIP: feat(vr): play in a PC VR headset over OpenXR, with a VR tab in the Insert menuto feat(vr): play in a PC VR headset over OpenXR, with a VR tab in the Insert menu843ab23c103f6d4ef86fThe loader no longer needs packaging or a script: tools.lock has a line for Khronos' release archive (openxr-loader), the Builder passes its folder to the build, and the build copies openxr_loader.dll beside the game. A maintainer needs to mirror it before a release (tools.py mirror openxr-loader): the reviewer notes say why. Also: a run with no game window (--prepare-shaders, HOS_HEADLESS) no longer opens a headset session; the preparation after a build died on exit with vr=1. Rebased onto main's head; run_all on Windows: 443 passed, the 7 failures are main's on this PC.
3f6d4ef86f10d2a376c5Rebased onto main @
514591a3(head10d2a376). One conflict,row_shown's line in the menu (main's Damage taken row beside the VR rows' condition);tests/test_damage_debug_menu.py, new on main, gets a stub forvr_row_shown. Built whole and the checks run (454 passed; the 7 that fail also fail on main here). Not measured again: the description says why.10d2a376c540fae344ceRebased onto main @
cf605c85(head40fae344). Conflicts:row_shownin the menu (main's Intel shader compiles row), the host's options (hold-to-toggle), and the measured runs, which are now two files underdocs/tested/as #96 asks.python -I -B tests/run_all.py: 477 passed; the 7 that fail also fail on main here. Built whole. Not measured again: the description says why.40fae344ce0d30abedefView command line instructions
Manual merge helper
Use this merge commit message when completing the merge manually.
Checkout
From your project repository, check out a new branch and test the changes.