From e7b670b2cce6dea4f7161ee1b998106e607b31ae Mon Sep 17 00:00:00 2001 From: iChris4 Date: Mon, 21 Sep 2026 00:31:46 +0200 Subject: [PATCH] Added Vulkan PC OpenXR Support --- Launcher/Build-DawnVulkan.ps1 | 52 + Launcher/Build-Installer.ps1 | 2 +- Launcher/Prepare-Dependencies.ps1 | 25 +- .../InstalledLayout.cs | 2 +- OPENXR.md | 71 +- README.md | 6 +- aurora-main/cmake/aurora_core.cmake | 2 +- aurora-main/include/aurora/dawn_vulkan_abi.h | 33 + .../include/aurora/vulkan_win32_interop.h | 20 + .../lib/webgpu/vulkan_win32_interop.cpp | 197 +++ aurora-main/patches/dawn/apply.py | 38 + .../patches/dawn/aurora_vulkan_hooks.h | 9 + .../patches/dawn/aurora_vulkan_interop.inc | 118 ++ aurora-main/tests/CMakeLists.txt | 12 + .../tests/vulkan_native_bridge_smoke.cpp | 162 +++ runtime/CMakeLists.txt | 20 + runtime/include/vr/openxr_runtime.h | 19 + runtime/include/vr/openxr_vulkan_win32.h | 109 ++ runtime/include/vr/openxr_windows.h | 64 + runtime/src/vr/openxr_integration.cpp | 20 +- runtime/src/vr/openxr_runtime.cpp | 6 +- runtime/src/vr/openxr_vulkan_win32.cpp | 1128 +++++++++++++++++ runtime/tests/openxr_d3d12_replay_tests.cpp | 81 +- 23 files changed, 2174 insertions(+), 22 deletions(-) create mode 100644 Launcher/Build-DawnVulkan.ps1 create mode 100644 aurora-main/include/aurora/dawn_vulkan_abi.h create mode 100644 aurora-main/include/aurora/vulkan_win32_interop.h create mode 100644 aurora-main/lib/webgpu/vulkan_win32_interop.cpp create mode 100644 aurora-main/patches/dawn/apply.py create mode 100644 aurora-main/patches/dawn/aurora_vulkan_hooks.h create mode 100644 aurora-main/patches/dawn/aurora_vulkan_interop.inc create mode 100644 aurora-main/tests/vulkan_native_bridge_smoke.cpp create mode 100644 runtime/include/vr/openxr_vulkan_win32.h create mode 100644 runtime/include/vr/openxr_windows.h create mode 100644 runtime/src/vr/openxr_vulkan_win32.cpp diff --git a/Launcher/Build-DawnVulkan.ps1 b/Launcher/Build-DawnVulkan.ps1 new file mode 100644 index 0000000..8d831ed --- /dev/null +++ b/Launcher/Build-DawnVulkan.ps1 @@ -0,0 +1,52 @@ +# Builds the pinned Dawn DLL with Aurora's native Windows Vulkan/OpenXR bridge. +# Run on a maintainer machine with VS 2022 C++ tools, CMake, Git and Python 3. +[CmdletBinding()] +param( + [string]$WorkDirectory = (Join-Path $PSScriptRoot 'artifacts\dawn-vulkan-build'), + [string]$Destination = (Join-Path $PSScriptRoot 'artifacts\dawn-vulkan'), + [string]$Python = 'python', + [int]$Jobs = 8 +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 3.0 +$revision = '13abc3bc8ea2d3c2050f9e77a12d012108ceee24' +$archiveHash = '713bea5b92d4f6c5175752fd7cbf1c3c5ce36598ff5dd98685d8a1216614ebba' +$WorkDirectory = [IO.Path]::GetFullPath($WorkDirectory) +$Destination = [IO.Path]::GetFullPath($Destination) +[IO.Directory]::CreateDirectory($WorkDirectory) | Out-Null +$archive = Join-Path $WorkDirectory 'dawn-source.tar.gz' +if (-not (Test-Path -LiteralPath $archive)) { + Invoke-WebRequest "https://github.com/google/dawn/archive/$revision.tar.gz" -OutFile $archive -UseBasicParsing +} +if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $archiveHash) { + throw 'Dawn source archive does not match the pinned SHA-256.' +} +$source = Join-Path $WorkDirectory "dawn-$revision" +if (-not (Test-Path -LiteralPath $source)) { + & $Python -c 'import sys,tarfile; tarfile.open(sys.argv[1]).extractall(sys.argv[2], filter="data")' $archive $WorkDirectory + if ($LASTEXITCODE -ne 0) { throw 'Dawn source extraction failed (Python 3.12+ required).' } +} +$patch = Join-Path $PSScriptRoot '..\aurora-main\patches\dawn\apply.py' +& $Python $patch $source +if ($LASTEXITCODE -ne 0) { throw 'Applying the Aurora Dawn bridge failed.' } +$build = Join-Path $WorkDirectory 'build' +& cmake -S $source -B $build -G 'Visual Studio 17 2022' -A x64 ` + "-DPython3_EXECUTABLE=$Python" -DDAWN_FETCH_DEPENDENCIES=ON ` + -DDAWN_BUILD_MONOLITHIC_LIBRARY=SHARED -DDAWN_ENABLE_INSTALL=ON ` + -DDAWN_BUILD_SAMPLES=OFF -DDAWN_BUILD_TESTS=OFF -DDAWN_BUILD_BENCHMARKS=OFF ` + -DTINT_BUILD_TESTS=OFF -DTINT_BUILD_CMD_TOOLS=OFF -DDAWN_USE_GLFW=OFF ` + -DDAWN_ENABLE_D3D11=OFF -DDAWN_ENABLE_D3D12=ON -DDAWN_ENABLE_VULKAN=ON ` + -DDAWN_ENABLE_DESKTOP_GL=OFF -DDAWN_ENABLE_OPENGLES=OFF "-DCMAKE_INSTALL_PREFIX=$Destination" +if ($LASTEXITCODE -ne 0) { throw 'Dawn configuration failed.' } +& cmake --build $build --config Release --parallel $Jobs +if ($LASTEXITCODE -ne 0) { throw 'Dawn build failed.' } +& cmake --install $build --config Release +if ($LASTEXITCODE -ne 0) { throw 'Dawn installation failed.' } +$dll = Join-Path $Destination 'bin\webgpu_dawn.dll' +if (-not (Test-Path -LiteralPath $dll)) { throw "Dawn DLL missing: $dll" } +[ordered]@{ + SourceRevision = $revision + AuroraVulkanAbi = 1 + DllSha256 = (Get-FileHash -LiteralPath $dll -Algorithm SHA256).Hash.ToLowerInvariant() +} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Destination 'aurora-vulkan.json') -Encoding UTF8 +Write-Host "Custom Dawn package ready: $Destination" diff --git a/Launcher/Build-Installer.ps1 b/Launcher/Build-Installer.ps1 index b1d5b90..54ca774 100644 --- a/Launcher/Build-Installer.ps1 +++ b/Launcher/Build-Installer.ps1 @@ -99,7 +99,7 @@ Assert-File (Join-Path $portableTools 'Ninja\ninja.exe') 'Portable Ninja' # to compile (launcher/Prepare-NativePrebuilt.ps1). # Kept in step with InstalledLayout.DependencyNames by Test-PinnedFacts.ps1: the installed host # refuses to call a toolkit complete unless every one of these directories is present. -$requiredDependencies = @('abseil-cpp','cppwinrt','dawn_prebuilt','fmt','freetype','imgui','libusb','native_prebuilt','openxr','png','SDL','sqlite3','tracy','xxhash','zlib','zstd') +$requiredDependencies = @('abseil-cpp','cppwinrt','dawn_prebuilt','fmt','freetype','imgui','libusb','native_prebuilt','openxr','png','SDL','sqlite3','tracy','vulkan_headers','xxhash','zlib','zstd') $missingSources = @($requiredDependencies | Where-Object { $_ -ne 'native_prebuilt' } | Where-Object { -not (Test-Path -LiteralPath (Join-Path $dependencySources $_) -PathType Container) }) if ($missingSources.Count -gt 0) { diff --git a/Launcher/Prepare-Dependencies.ps1 b/Launcher/Prepare-Dependencies.ps1 index 9777ebf..a00a5c4 100644 --- a/Launcher/Prepare-Dependencies.ps1 +++ b/Launcher/Prepare-Dependencies.ps1 @@ -9,7 +9,9 @@ param( [string]$Destination, # Windows metadata source for cppwinrt.exe: 'local' (this machine's WinMetadata), 'sdk', or an # installed SDK version such as 10.0.26100.0. - [string]$CppWinRtInput = 'local' + [string]$CppWinRtInput = 'local', + # Output of Build-DawnVulkan.ps1. Rebuild native_prebuilt after changing Dawn. + [string]$DawnVulkanPackage ) $ErrorActionPreference = 'Stop' @@ -27,6 +29,11 @@ $runtimeCMake = Join-Path $repoRoot 'runtime\CMakeLists.txt' # FETCHCONTENT_SOURCE_DIR_ (NativeBuildFlags.ps1), so the names are the declared # FetchContent names, not the upstream project names. $packages = @( + [pscustomobject]@{ + Name = 'vulkan_headers'; File = 'vulkan-headers-015e25c3c91b70eb1a754d36fb14c4ba6ad9b0b9.tar.gz' + Uris = @('https://github.com/KhronosGroup/Vulkan-Headers/archive/015e25c3c91b70eb1a754d36fb14c4ba6ad9b0b9.tar.gz') + Pins = @(@{ File = $runtimeCMake; Text = 'Vulkan-Headers/archive/015e25c3c91b70eb1a754d36fb14c4ba6ad9b0b9.tar.gz' }) + }, [pscustomobject]@{ Name = 'SDL'; File = 'SDL3-3.4.4.tar.gz' Uris = @('https://github.com/libsdl-org/SDL/releases/download/release-3.4.4/SDL3-3.4.4.tar.gz') @@ -175,6 +182,22 @@ function Expand-Package([string]$Archive, [string]$Target) { foreach ($package in $packages) { Assert-Pinned $package $target = Join-Path $Destination $package.Name + if ($package.Name -eq 'dawn_prebuilt' -and $DawnVulkanPackage) { + $custom = [IO.Path]::GetFullPath($DawnVulkanPackage) + $manifest = Get-Content -LiteralPath (Join-Path $custom 'aurora-vulkan.json') -Raw | ConvertFrom-Json + $dll = Join-Path $custom 'bin\webgpu_dawn.dll' + if ($manifest.SourceRevision -ne '13abc3bc8ea2d3c2050f9e77a12d012108ceee24' -or + $manifest.AuroraVulkanAbi -ne 1 -or + (Get-FileHash -LiteralPath $dll -Algorithm SHA256).Hash.ToLowerInvariant() -ne $manifest.DllSha256) { + throw 'Custom Dawn package provenance or DLL hash does not match.' + } + if (Test-Path -LiteralPath $target) { + throw "Use a fresh dependency destination for custom Dawn: $target already exists." + } + Copy-Item -LiteralPath $custom -Destination $target -Recurse + Write-Host 'Prepared custom Dawn with Windows Vulkan OpenXR support' + continue + } if (Test-Path -LiteralPath $target -PathType Container) { continue } Expand-Package (Get-Archive $package) $target Write-Host "Prepared $($package.Name)" diff --git a/Launcher/WiiCompiled.Setup.Windows/InstalledLayout.cs b/Launcher/WiiCompiled.Setup.Windows/InstalledLayout.cs index 03521d6..bf562e2 100644 --- a/Launcher/WiiCompiled.Setup.Windows/InstalledLayout.cs +++ b/Launcher/WiiCompiled.Setup.Windows/InstalledLayout.cs @@ -27,7 +27,7 @@ internal static class InstalledLayout public static readonly string[] DependencyNames = [ "abseil-cpp", "cppwinrt", "dawn_prebuilt", "fmt", "freetype", "imgui", "libusb", "native_prebuilt", - "openxr", "png", "SDL", "sqlite3", "tracy", "xxhash", "zlib", "zstd" + "openxr", "png", "SDL", "sqlite3", "tracy", "vulkan_headers", "xxhash", "zlib", "zstd" ]; } diff --git a/OPENXR.md b/OPENXR.md index 14e35f6..4be506d 100644 --- a/OPENXR.md +++ b/OPENXR.md @@ -3,7 +3,10 @@ WiiCompiled has an opt-in OpenXR rendering path. The first functional backend is Windows D3D12. It asks the OpenXR runtime for the required GPU before Aurora creates Dawn, then copies each eye on that same D3D12 device and queue into the acquired OpenXR swapchain images. Eye submission -stays on the GPU; there is no CPU texture readback and no second graphics device. +stays on the GPU; there is no CPU texture readback and no second graphics device. Windows Vulkan is +an opt-in second binding built on the same design: the OpenXR runtime creates Dawn's Vulkan instance +and device (`XR_KHR_vulkan_enable2`) and eyes are copied on that same queue. It needs a custom Dawn +build; see [Windows Vulkan](#windows-vulkan). This is an experimental renderer, not yet a release-ready VR mode. @@ -11,14 +14,16 @@ This is an experimental renderer, not yet a release-ready VR mode. - A Windows OpenXR runtime selected as the system's active runtime. - A connected headset supported by that runtime. -- A D3D12-capable GPU and driver accepted by both OpenXR and Dawn. +- A D3D12-capable GPU and driver accepted by both OpenXR and Dawn, or for the opt-in Vulkan + binding a Vulkan 1.1+ driver plus the custom Dawn described under [Windows Vulkan](#windows-vulkan). - A build made with `MKW_ENABLE_OPENXR=ON`, which defaults on for Windows and off elsewhere while the Vulkan bridge remains capability-gated. For managed installation, use [WheelWizard VR](https://github.com/iChris4/WheelWizard_VR/releases/latest) and enable **Settings → Other → WiiCompiled (beta) → Enable WiiCompiled OpenXR VR (beta)**. The launcher sets `vr.enabled=true`, `vr.required=false`, and `video.graphics_api="d3d12"` before -each VR launch, preserving other preferences. Its portable configuration lives at +each VR launch, preserving other preferences. Setting `video.graphics_api = "vulkan"` in that +configuration selects the Vulkan binding instead. Its portable configuration lives at `RecompVR/UserData/Config.toml` beneath WheelWizard's data folder. Normal graphics settings remain in `Recomp/UserData/Config.toml`. Both backends use the normal installation's effective NAND. @@ -320,7 +325,7 @@ short-lived immutable stereo packet. Each sealed GX frame and immersive packet c policy-generation tag; a mismatch is rendered in mono and the acquired XR frame is canceled, so an asynchronous menu/race transition cannot replay race transforms over unsafe content. -With interpolation off, both PC (D3D12) and standalone (Vulkan) pace render-first: +With interpolation off, PC (D3D12 and Windows Vulkan) and standalone (Android Vulkan) pace render-first: the pacing thread locates views for an estimated display time (two periods past the last prediction), hands Aurora a packet without leaving a compositor frame open, and waits for rendering. A 50 ms stall repeats the retained layer; cancellation also advances a keep-alive @@ -328,16 +333,17 @@ cycle to refresh timing. Once rendering is submitted, the thread calls xrWaitFra xrBeginFrame, completes backend-specific copy/release work, and ends the frame using the packet's original render poses with the current compositor display time. -Vulkan renders into shared buffers and copies them into newly acquired XR images afterward. -D3D12 acquires images from its non-retained swapchain pair before rendering; Aurora queues the -copy on the session's D3D12 queue before reporting completion. PC therefore needs no additional +Android Vulkan renders into shared buffers and copies them into newly acquired XR images afterward. +Both PC bindings acquire images from their non-retained swapchain pair before rendering; Aurora +queues the copy on the session's queue before reporting completion. PC therefore needs no additional copy in the short compositor cycle. Pending images remain acquired and separate from the retained pair until completion or confirmed cancellation before encoding. GPU failure still requires the existing queue-drain teardown. Rendered poses keep the session/reference-space serials recorded when the packet was prepared, so changes during rendering invalidate them. VR interpolation keeps the frame-first order on both backends because it renders for the -frame's own predicted display time. The log announces `OpenXR D3D12 pacing: render-first` or +frame's own predicted display time. The log announces `OpenXR D3D12 pacing: render-first` (or +`OpenXR Vulkan pacing: …` on the Vulkan binding) or `frame-first (VR interpolation)` on each transition. For PC testing, disable **VR** frame interpolation for a race capture; changing desktop interpolation alone does not select this path. Menus use render-first even when VR interpolation is configured for races. Compare the @@ -456,7 +462,7 @@ When it is on, `console.log` receives lines tagged `[runtime] [xr-diag]` | `poll-events`, `begin-call`, `locate-views` | Event polling, the xrBeginFrame call itself, and xrLocateViews. | | `input-sync`, `sync-actions` | Complete input update and its xrSyncActions call. | | `publish`, `withdraw` | Packet construction/publication and withdrawal, including mutex waits. | -| `set-targets` | D3D12 bridge target registration, including its mutex wait. | +| `set-targets` | D3D12/Vulkan bridge target registration, including its mutex wait. | | `submission-wait` | Actual time waiting for a render result, including timeout paths; compare against the requested 50 ms. | | `cancel` | Bridge cancellation attempt, whether it succeeds or fails. | | `cancel-age` | Publication to cancellation, including packets never picked up. | @@ -491,11 +497,58 @@ The current `console.log` is copied through a shared-read stream while it is sti The copy runs on SDL's dialog thread (`runtime/src/log_export.cpp`), and the outcome is shown under the button. `mkw_openxr_diagnostics_tests` and `mkw_log_export_tests` cover both without a headset. +## Windows Vulkan + +`video.graphics_api = "vulkan"` selects a second Windows binding, `runtime/src/vr/openxr_vulkan_win32.cpp`, +with the same pacing thread, retained-layer protocol and policy as D3D12 +(`runtime/include/vr/openxr_windows.h` picks the backend at startup). It is opt-in. It has raced on +a headset (SteamVR/OpenXR with a PlayStation VR2): immersive projection held the headset's full +90 Hz with no skipped display slots, and a 646-second session recorded no rejected or discarded +layers and no failed submissions. Other runtimes are still unexercised. + +**Why a custom Dawn.** The pinned prebuilt Dawn DLL exposes no native Vulkan device, so +`aurora-main/patches/dawn` adds a small versioned C ABI to the pinned Dawn source +(`aurora_dawn_vulkan_abi.h`, `AURORA_DAWN_VULKAN_ABI = 1`): hooks that let the OpenXR runtime +create Dawn's `VkInstance`, choose the physical device and create the `VkDevice`; wrapping of a +borrowed `VkImage` as a Dawn texture; the release barrier back to `COLOR_ATTACHMENT_OPTIMAL`; a +device-guard lock; and a queue drain. `Launcher/Build-DawnVulkan.ps1` builds that DLL from the pinned +revision on a machine with Visual Studio 2022, Python 3.12+ and CMake, and writes `aurora-vulkan.json` +(revision, ABI, DLL hash). `Launcher/Prepare-Dependencies.ps1 -DawnVulkanPackage ` installs it as +`dawn_prebuilt` in a fresh dependency destination after checking that provenance; re-harvest +`native_prebuilt` afterwards because the archives are pinned to the Dawn DLL hash. The runtime +build also fetches Vulkan headers (`vulkan_headers` dependency). + +**Startup.** `QueryGraphicsRequirements` loads `xrGetVulkanGraphicsRequirements2KHR`, +`xrCreateVulkanInstanceKHR`, `xrCreateVulkanDeviceKHR` and `xrGetVulkanGraphicsDevice2KHR`, then +installs the hooks in Dawn. Without the custom DLL it fails with *"requires the custom Dawn library +with Aurora Vulkan ABI 1"* and the game continues on the desktop renderer. During +`aurora_initialize` the runtime creates Dawn's instance (Vulkan 1.2 is requested when the loader +and runtime allow it, so that timeline semaphores, which PC runtimes create on the application's +device, are a core feature the device hook can enable) and device on the runtime's physical GPU. +`BindAurora` confirms that Dawn's physical device is the one the runtime selected, creates the +session on Dawn's graphics queue, picks the sRGB sibling of Aurora's UNORM colour format (with +`XR_SWAPCHAIN_USAGE_MUTABLE_FORMAT_BIT`) so the compositor decodes the gamma-encoded bytes, and +enables the bridge. + +**Frames.** Each acquired XR image is wrapped once as a Dawn texture and reused. Aurora's frame +worker records the eye copies into its own command buffer; immediately after `queue.Submit`, still +under Aurora's submit mutex, the bridge appends the release barrier through Dawn's queue and +publishes the token that the pacing thread's `WaitForSubmission` consumes. The runtime may use +the VkQueue only inside `xrBeginFrame`, `xrEndFrame`, `xrAcquireSwapchainImage` and +`xrReleaseSwapchainImage`, so `OpenXRRuntime::LockGraphicsQueue` holds Dawn's device guard around +exactly those four calls and never across `xrWaitFrame` or `xrWaitSwapchainImage`. + +**Tests.** `mkw_openxr_vulkan_replay_tests` compiles the real backend against the deterministic +compositor of the D3D12 replay tests, including the queue-guard requirement on acquire and release. +`vulkan_native_bridge_smoke` (aurora, `AURORA_GPU_SMOKE_TESTS=ON`, real GPU, no headset) drives the +custom DLL's ABI through three borrowed-image copy/readback cycles; run it with that DLL beside it. + ## Backend status | Backend | Status | | --- | --- | | Windows D3D12 | Implemented: same-adapter, same-device asynchronous OpenXR submission. | +| Windows Vulkan | Implemented, opt-in (`video.graphics_api = "vulkan"`): the runtime creates Dawn's Vulkan instance and device through `XR_KHR_vulkan_enable2`, eyes are copied on the same queue, and Dawn's device guard is held around the four queue-touching OpenXR calls. Needs the custom Dawn from `Launcher/Build-DawnVulkan.ps1`. Raced on SteamVR/PSVR2 at the headset's full rate; other runtimes unexercised. See [Windows Vulkan](#windows-vulkan). | | Android Vulkan (Meta Quest) | Implemented and running on a Quest 3: the OpenXR side owns its own Vulkan device (`XR_KHR_vulkan_enable2`, `XR_KHR_vulkan_enable` fallback) and shares eyes with Dawn through `AHardwareBuffer`s ordered by sync-fd fences. Controllers arrive through OpenXR actions as a virtual SDL gamepad. See `docs/quest-port.md`. | | Linux Vulkan | Not wired. The pinned Dawn package does not expose a native Vulkan device, and the AHardwareBuffer bridge is Android-only; a dma-buf/opaque-fd variant of the same design would cover desktop Linux. | | Other platforms | Not wired yet. | diff --git a/README.md b/README.md index cbe5d91..a42fbfd 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,8 @@ The graphics layer is built on Play at several times the console's resolution. **Experimental OpenXR VR.** -Windows builds can render through a D3D12 OpenXR runtime without CPU readback. Menus and +Windows builds can render through an OpenXR runtime on D3D12, or on Vulkan with a custom Dawn +build, without CPU readback. Menus and unsupported scenes appear as a head-locked virtual screen; a validated single-camera race switches to immersive stereo rendering. VR is opt-in and falls back to the normal desktop renderer if the runtime or headset is unavailable. See [`OPENXR.md`](OPENXR.md) for setup, configuration, and the @@ -131,7 +132,8 @@ Saves and Miis use the normal installation's effective NAND; Retro Rewind retain XML-directed saves and ghosts. Graphics, VR preferences, caches, and compiled binaries stay separate. Uninstalling either backend in WheelWizard VR preserves configuration and shared progress. -Managed VR launches enable OpenXR with D3D12. If the runtime or headset is unavailable, the game +Managed VR launches enable OpenXR with D3D12; the Vulkan binding is opt-in through +`video.graphics_api` (see [OPENXR.md](OPENXR.md)). If the runtime or headset is unavailable, the game continues on the desktop and displays the failure briefly; **F10 → VR** retains the explanation. See [OpenXR configuration](OPENXR.md) and [distribution and validation](DISTRIBUTION.md). diff --git a/aurora-main/cmake/aurora_core.cmake b/aurora-main/cmake/aurora_core.cmake index 2f47945..aa4aa30 100644 --- a/aurora-main/cmake/aurora_core.cmake +++ b/aurora-main/cmake/aurora_core.cmake @@ -38,7 +38,7 @@ if (AURORA_ENABLE_GX) # OpenXR-enabled runtime can fail back to desktop mode at runtime instead # of producing unresolved interop references. if (CMAKE_SYSTEM_NAME STREQUAL Windows) - target_sources(aurora_core PRIVATE lib/webgpu/d3d12_interop.cpp) + target_sources(aurora_core PRIVATE lib/webgpu/d3d12_interop.cpp lib/webgpu/vulkan_win32_interop.cpp) endif () # Android/Vulkan counterpart: the AHardwareBuffer stereo bridge. The file # compiles to C ABI stubs on every other platform so the runtime's OpenXR diff --git a/aurora-main/include/aurora/dawn_vulkan_abi.h b/aurora-main/include/aurora/dawn_vulkan_abi.h new file mode 100644 index 0000000..fdc1285 --- /dev/null +++ b/aurora-main/include/aurora/dawn_vulkan_abi.h @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +// Versioned C ABI: no STL objects or compiler-specific C++ symbols cross the +// MSVC Dawn DLL / LLVM-MinGW runtime boundary. Vulkan handles are borrowed. +#define AURORA_DAWN_VULKAN_ABI 1 +#ifdef __cplusplus +extern "C" { +#endif +typedef struct { + void* userdata; + int32_t (*createInstance)(void*, void* getProc, const void* info, const void* allocator, void** instance); + int32_t (*createDevice)(void*, void* getProc, void* physical, const void* info, const void* allocator, void** device); + int32_t (*getPhysicalDevice)(void*, void* instance, void** physical); +} AuroraDawnVulkanHooks; +typedef struct { + void* instance; + void* physicalDevice; + void* device; + uint32_t queueFamily; + uint32_t queueIndex; +} AuroraDawnVulkanHandles; +typedef uint32_t (*AuroraDawnVulkanVersionFn)(void); +typedef int (*AuroraDawnVulkanConfigureFn)(const AuroraDawnVulkanHooks*); +typedef int (*AuroraDawnVulkanHandlesFn)(void* device, AuroraDawnVulkanHandles*); +typedef void* (*AuroraDawnVulkanWrapFn)(void* device, const void* textureDescriptor, uint64_t image); +typedef int (*AuroraDawnVulkanReleaseFn)(void* device, void* const* textures, uint32_t count); +typedef void* (*AuroraDawnVulkanLockFn)(void* device); +typedef void (*AuroraDawnVulkanUnlockFn)(void* guard); +typedef int (*AuroraDawnVulkanDrainFn)(void* device); +#ifdef __cplusplus +} +#endif diff --git a/aurora-main/include/aurora/vulkan_win32_interop.h b/aurora-main/include/aurora/vulkan_win32_interop.h new file mode 100644 index 0000000..82db145 --- /dev/null +++ b/aurora-main/include/aurora/vulkan_win32_interop.h @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include +#ifdef __cplusplus +extern "C" { +#endif +// Same pending-target/callback contract as D3D12. resource carries a VkImage +// encoded as a pointer-sized value; colorDxgiFormat carries a VkFormat. +bool aurora_vulkan_win32_configure(const AuroraDawnVulkanHooks* hooks); +bool aurora_vulkan_win32_get_handles(AuroraDawnVulkanHandles* handles, int64_t* colorFormat); +bool aurora_vulkan_win32_enable(AuroraD3D12StereoSubmittedCallback submitted, void* userdata); +bool aurora_vulkan_win32_set_targets(uint64_t token, const AuroraD3D12StereoTarget* targets, uint32_t count); +bool aurora_vulkan_win32_cancel(uint64_t token); +bool aurora_vulkan_win32_disable(); +void* aurora_vulkan_win32_lock_queue(); +void aurora_vulkan_win32_unlock_queue(void* guard); +#ifdef __cplusplus +} +#endif diff --git a/aurora-main/lib/webgpu/vulkan_win32_interop.cpp b/aurora-main/lib/webgpu/vulkan_win32_interop.cpp new file mode 100644 index 0000000..43cdacd --- /dev/null +++ b/aurora-main/lib/webgpu/vulkan_win32_interop.cpp @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include +#include "../internal.hpp" +#include "../stereo.hpp" +#include "gpu.hpp" +#if defined(_WIN32) && defined(WEBGPU_DAWN) && defined(DAWN_ENABLE_BACKEND_VULKAN) +#include +#include +#include +#include +#include +#include +namespace aurora::vulkan_win32 { +namespace { +Module Log("aurora::vulkan_win32"); +struct Api { + AuroraDawnVulkanConfigureFn configure = nullptr; + AuroraDawnVulkanHandlesFn handles = nullptr; + AuroraDawnVulkanWrapFn wrap = nullptr; + AuroraDawnVulkanReleaseFn release = nullptr; + AuroraDawnVulkanLockFn lock = nullptr; + AuroraDawnVulkanUnlockFn unlock = nullptr; + AuroraDawnVulkanDrainFn drain = nullptr; + bool Load() { + HMODULE module = GetModuleHandleW(L"webgpu_dawn.dll"); + if (!module) return false; + auto version = reinterpret_cast(GetProcAddress(module, "AuroraDawnVulkanVersion")); + if (!version || version() != AURORA_DAWN_VULKAN_ABI) return false; +#define LOAD(member, type, name) member = reinterpret_cast(GetProcAddress(module, name)); if (!member) return false + LOAD(configure, AuroraDawnVulkanConfigureFn, "AuroraDawnVulkanConfigure"); + LOAD(handles, AuroraDawnVulkanHandlesFn, "AuroraDawnVulkanGetHandles"); + LOAD(wrap, AuroraDawnVulkanWrapFn, "AuroraDawnVulkanWrap"); + LOAD(release, AuroraDawnVulkanReleaseFn, "AuroraDawnVulkanRelease"); + LOAD(lock, AuroraDawnVulkanLockFn, "AuroraDawnVulkanLock"); + LOAD(unlock, AuroraDawnVulkanUnlockFn, "AuroraDawnVulkanUnlock"); + LOAD(drain, AuroraDawnVulkanDrainFn, "AuroraDawnVulkanDrain"); +#undef LOAD + return true; + } +} api; +int64_t VkFormat(wgpu::TextureFormat format) { + switch (format) { + case wgpu::TextureFormat::RGBA8Unorm: return 37; + case wgpu::TextureFormat::RGBA8UnormSrgb: return 43; + case wgpu::TextureFormat::BGRA8Unorm: return 44; + case wgpu::TextureFormat::BGRA8UnormSrgb: return 50; + case wgpu::TextureFormat::RGBA16Float: return 97; + default: return 0; + } +} +bool CopyCompatible(int64_t a, int64_t b) { + return a == b || ((a == 37 || a == 43) && (b == 37 || b == 43)) || + ((a == 44 || a == 50) && (b == 44 || b == 50)); +} +struct Import { uint64_t image; uint32_t width, height; wgpu::TextureFormat format; wgpu::Texture texture; }; +class Bridge { +public: + std::mutex mutex; + std::vector imports; + std::array targets{}; + std::array active{}; + uint64_t token = 0; + uint32_t count = 0; + bool encoded = false; + AuroraD3D12StereoSubmittedCallback callback; + void* userdata; + Bridge(AuroraD3D12StereoSubmittedCallback cb, void* data) : callback(cb), userdata(data) {} + bool Set(uint64_t next, const AuroraD3D12StereoTarget* data, uint32_t n) { + if (!next || !data || !n || n > 2) return false; + std::lock_guard guard(mutex); + if (token) return false; + for (uint32_t i = 0; i < n; ++i) { + if (!data[i].resource || !data[i].width || !data[i].height) return false; + targets[i] = data[i]; + } + token = next; count = n; return true; + } + bool Cancel(uint64_t wanted) { + std::unique_lock guard(mutex, std::try_to_lock); + if (!guard.owns_lock() || encoded || !token || token != wanted) return false; + token = 0; return true; + } + bool Encode(wgpu::CommandEncoder& encoder, const stereo::SinkFrame& frame) { + std::lock_guard guard(mutex); + if (!token || encoded || frame.frameToken != token) return false; + // Validate/import every target before recording any copy. + for (uint32_t i = 0; i < count; ++i) { + const auto& eye = frame.eyes[i]; + const auto& target = targets[i]; + if (!eye.texture || eye.size.width != target.width || eye.size.height != target.height || + !CopyCompatible(VkFormat(eye.format), target.dxgiFormat)) return false; + const uint64_t image = reinterpret_cast(target.resource); + auto it = std::find_if(imports.begin(), imports.end(), [&](const Import& entry) { return entry.image == image; }); + if (it == imports.end()) { + wgpu::TextureDescriptor descriptor; + descriptor.usage = wgpu::TextureUsage::CopyDst | wgpu::TextureUsage::RenderAttachment; + descriptor.size = {target.width, target.height, 1}; + descriptor.format = eye.format; + void* wrapped = api.wrap(webgpu::g_device.Get(), &descriptor, image); + if (!wrapped) return false; + imports.push_back({image, target.width, target.height, eye.format, + wgpu::Texture::Acquire(static_cast(wrapped))}); + it = imports.end() - 1; + } + if (it->width != target.width || it->height != target.height || it->format != eye.format) { + // The runtime reuses VkImage handles across swapchain recreation, and + // Aurora's eye format can change with the surface. Re-wrap rather than + // rejecting every future frame for this image. + imports.erase(it); + --i; + continue; + } + active[i] = it->texture; + } + for (uint32_t i = 0; i < count; ++i) { + wgpu::TexelCopyTextureInfo source, destination; + source.texture = *frame.eyes[i].texture; + destination.texture = active[i]; + wgpu::Extent3D size{targets[i].width, targets[i].height, 1}; + encoder.CopyTextureToTexture(&source, &destination, &size); + } + encoded = true; + return true; + } + void Submitted(const stereo::SinkFrame& frame) { + std::lock_guard guard(mutex); + if (!token || !encoded || token != frame.frameToken) return; + std::array textures{}; + for (uint32_t i = 0; i < count; ++i) textures[i] = active[i].Get(); + // Append the COLOR_ATTACHMENT_OPTIMAL release barriers to Dawn's queue, + // flush them under its device guard, then allow the XR thread to release. + const bool success = api.release(webgpu::g_device.Get(), textures.data(), count) != 0; + const auto completed = token; + token = 0; encoded = false; + callback(completed, success, userdata); + } +}; +std::unique_ptr bridge; +} +} +bool aurora_vulkan_win32_configure(const AuroraDawnVulkanHooks* hooks) { + using namespace aurora::vulkan_win32; + return api.Load() && api.configure(hooks); +} +bool aurora_vulkan_win32_get_handles(AuroraDawnVulkanHandles* handles, int64_t* format) { + using namespace aurora; + if (!webgpu::g_device || webgpu::g_backendType != wgpu::BackendType::Vulkan || !vulkan_win32::api.Load()) return false; + *format = vulkan_win32::VkFormat(webgpu::g_graphicsConfig.surfaceConfiguration.format); + return *format && vulkan_win32::api.handles(webgpu::g_device.Get(), handles); +} +bool aurora_vulkan_win32_enable(AuroraD3D12StereoSubmittedCallback cb, void* data) { + using namespace aurora::vulkan_win32; + if (bridge || !cb || !api.Load()) return false; + bridge = std::make_unique(cb, data); + aurora::stereo::set_sink( + [](wgpu::CommandEncoder& encoder, const aurora::stereo::SinkFrame& frame, void* self) noexcept { + return static_cast(self)->Encode(encoder, frame); + }, [](const aurora::stereo::SinkFrame& frame, void* self) noexcept { static_cast(self)->Submitted(frame); }, bridge.get()); + return true; +} +bool aurora_vulkan_win32_set_targets(uint64_t token, const AuroraD3D12StereoTarget* targets, uint32_t count) { + using namespace aurora::vulkan_win32; + return bridge && bridge->Set(token, targets, count); +} +bool aurora_vulkan_win32_cancel(uint64_t token) { + using namespace aurora::vulkan_win32; + return bridge && bridge->Cancel(token); +} +bool aurora_vulkan_win32_disable() { + using namespace aurora::vulkan_win32; + if (!bridge) return true; + aurora::stereo::set_sink(nullptr, nullptr, nullptr); + if (!api.drain(aurora::webgpu::g_device.Get())) { bridge.release(); return false; } + bridge.reset(); + return true; +} +void* aurora_vulkan_win32_lock_queue() { + return aurora::vulkan_win32::api.lock(aurora::webgpu::g_device.Get()); +} +void aurora_vulkan_win32_unlock_queue(void* guard) { aurora::vulkan_win32::api.unlock(guard); } +#else +// C ABI stubs keep the runtime's OpenXR integration linkable on Windows GX +// builds whose Dawn has no Vulkan backend; the backend then reports that the +// bridge is unavailable and the game falls back to the desktop renderer. +bool aurora_vulkan_win32_configure(const AuroraDawnVulkanHooks*) { return false; } +bool aurora_vulkan_win32_get_handles(AuroraDawnVulkanHandles* handles, int64_t* format) { + if (handles) *handles = {}; + if (format) *format = 0; + return false; +} +bool aurora_vulkan_win32_enable(AuroraD3D12StereoSubmittedCallback, void*) { return false; } +bool aurora_vulkan_win32_set_targets(uint64_t, const AuroraD3D12StereoTarget*, uint32_t) { return false; } +bool aurora_vulkan_win32_cancel(uint64_t) { return false; } +bool aurora_vulkan_win32_disable() { return true; } +void* aurora_vulkan_win32_lock_queue() { return nullptr; } +void aurora_vulkan_win32_unlock_queue(void*) {} +#endif diff --git a/aurora-main/patches/dawn/apply.py b/aurora-main/patches/dawn/apply.py new file mode 100644 index 0000000..5fb999f --- /dev/null +++ b/aurora-main/patches/dawn/apply.py @@ -0,0 +1,38 @@ +"""Apply the versioned Aurora native Vulkan ABI to the pinned Dawn source tree.""" +from pathlib import Path +import shutil, sys +root = Path(sys.argv[1]).resolve() +here = Path(__file__).resolve().parent +for name in ("aurora_vulkan_hooks.h", "aurora_vulkan_interop.inc"): + shutil.copyfile(here / name, root / "src/dawn/native/vulkan" / name) +shutil.copyfile(here.parent.parent / "include/aurora/dawn_vulkan_abi.h", + root / "src/dawn/native/vulkan/aurora_dawn_vulkan_abi.h") +p = root / "src/dawn/native/vulkan/VulkanBackend.cpp" +s = p.read_text() +if '#include "aurora_vulkan_interop.inc"' not in s: + s += '\n#include "aurora_vulkan_interop.inc"\n' +p.write_text(s) +p = root / "src/dawn/common/DynamicLib.cpp" +s = p.read_text() +# LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR requires an absolute filename. Vulkan's +# system loader is also tried by bare name; preserve the restricted default +# search directories for that case instead of failing with ERROR_INVALID_PARAMETER. +old = 'LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS;' +new = '''((filename.size() > 2 && filename[1] == ':') || filename.starts_with("\\\\\\\\") + ? LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR : 0) | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS;''' +if old in s: + s = s.replace(old, new, 1) +p.write_text(s) +p = root / "src/dawn/native/vulkan/VulkanFunctions.cpp" +s = p.read_text() +if '#include "aurora_vulkan_hooks.h"' not in s: + marker = 'namespace dawn::native::vulkan {' + assert marker in s + s = s.replace(marker, '#include "aurora_vulkan_hooks.h"\n\n' + marker, 1) + for old, new in [ + ('GET_GLOBAL_PROC(CreateInstance);', 'CreateInstance = AuroraCreateInstance(GetInstanceProcAddr);'), + ('GET_INSTANCE_PROC(CreateDevice);', 'CreateDevice = AuroraCreateDevice(GetInstanceProcAddr, instance);'), + ('GET_INSTANCE_PROC(EnumeratePhysicalDevices);', 'EnumeratePhysicalDevices = AuroraEnumeratePhysicalDevices(GetInstanceProcAddr, instance);')]: + assert old in s, old + s = s.replace(old, old + '\n ' + new, 1) +p.write_text(s) diff --git a/aurora-main/patches/dawn/aurora_vulkan_hooks.h b/aurora-main/patches/dawn/aurora_vulkan_hooks.h new file mode 100644 index 0000000..953f2f6 --- /dev/null +++ b/aurora-main/patches/dawn/aurora_vulkan_hooks.h @@ -0,0 +1,9 @@ +// Included only by the pinned Dawn source build. +#pragma once +#include "dawn/native/DawnNative.h" +#include "aurora_dawn_vulkan_abi.h" +namespace dawn::native::vulkan { +PFN_vkCreateInstance AuroraCreateInstance(PFN_vkGetInstanceProcAddr proc); +PFN_vkCreateDevice AuroraCreateDevice(PFN_vkGetInstanceProcAddr proc, VkInstance instance); +PFN_vkEnumeratePhysicalDevices AuroraEnumeratePhysicalDevices(PFN_vkGetInstanceProcAddr proc, VkInstance instance); +} diff --git a/aurora-main/patches/dawn/aurora_vulkan_interop.inc b/aurora-main/patches/dawn/aurora_vulkan_interop.inc new file mode 100644 index 0000000..b085927 --- /dev/null +++ b/aurora-main/patches/dawn/aurora_vulkan_interop.inc @@ -0,0 +1,118 @@ +// Compiled inside VulkanBackend.cpp, using the pinned Dawn implementation. +#include "aurora_vulkan_hooks.h" +#include "src/dawn/native/ChainUtils.h" +#include "src/dawn/native/vulkan/PhysicalDeviceVk.h" +#include "src/dawn/native/vulkan/QueueVk.h" +#include +namespace dawn::native::vulkan { +namespace { +AuroraDawnVulkanHooks auroraHooks{}; +std::recursive_mutex auroraHooksMutex; +PFN_vkGetInstanceProcAddr auroraGetProc = nullptr; +VkInstance auroraInstance = VK_NULL_HANDLE; +::VkResult VKAPI_CALL AuroraCreateInstanceImpl(const VkInstanceCreateInfo* info, + const VkAllocationCallbacks* allocator, VkInstance* instance) { + std::lock_guard lock(auroraHooksMutex); + if (auroraHooks.createInstance) return static_cast<::VkResult>(auroraHooks.createInstance( + auroraHooks.userdata, reinterpret_cast(auroraGetProc), info, allocator, + reinterpret_cast(instance))); + return reinterpret_cast(auroraGetProc(nullptr, "vkCreateInstance"))(info, allocator, instance); +} +::VkResult VKAPI_CALL AuroraCreateDeviceImpl(VkPhysicalDevice physical, const VkDeviceCreateInfo* info, + const VkAllocationCallbacks* allocator, VkDevice* device) { + std::lock_guard lock(auroraHooksMutex); + if (auroraHooks.createDevice) return static_cast<::VkResult>(auroraHooks.createDevice( + auroraHooks.userdata, reinterpret_cast(auroraGetProc), physical, info, allocator, + reinterpret_cast(device))); + return reinterpret_cast(auroraGetProc(auroraInstance, "vkCreateDevice"))(physical, info, allocator, device); +} +::VkResult VKAPI_CALL AuroraEnumerateImpl(VkInstance instance, uint32_t* count, VkPhysicalDevice* devices) { + std::lock_guard lock(auroraHooksMutex); + if (!auroraHooks.getPhysicalDevice) return reinterpret_cast( + auroraGetProc(instance, "vkEnumeratePhysicalDevices"))(instance, count, devices); + void* physical = nullptr; + const auto result = static_cast<::VkResult>(auroraHooks.getPhysicalDevice(auroraHooks.userdata, instance, &physical)); + if (result != VK_SUCCESS) return result; + if (!devices) { *count = 1; return VK_SUCCESS; } + if (*count == 0) return VK_INCOMPLETE; + devices[0] = static_cast(physical); + *count = 1; + return VK_SUCCESS; +} +struct AuroraDeviceGuard { decltype(std::declval()->GetGuard()) guard; explicit AuroraDeviceGuard(Device* device) : guard(device->GetGuard()) {} }; +} +PFN_vkCreateInstance AuroraCreateInstance(PFN_vkGetInstanceProcAddr proc) { + std::lock_guard lock(auroraHooksMutex); + if (!auroraHooks.createInstance) return reinterpret_cast(proc(nullptr, "vkCreateInstance")); + auroraGetProc = proc; + return AuroraCreateInstanceImpl; +} +PFN_vkCreateDevice AuroraCreateDevice(PFN_vkGetInstanceProcAddr proc, VkInstance instance) { + std::lock_guard lock(auroraHooksMutex); + if (!auroraHooks.createDevice) return reinterpret_cast(proc(instance, "vkCreateDevice")); + auroraGetProc = proc; auroraInstance = instance; + return AuroraCreateDeviceImpl; +} +PFN_vkEnumeratePhysicalDevices AuroraEnumeratePhysicalDevices(PFN_vkGetInstanceProcAddr proc, VkInstance instance) { + std::lock_guard lock(auroraHooksMutex); + if (!auroraHooks.getPhysicalDevice) return reinterpret_cast(proc(instance, "vkEnumeratePhysicalDevices")); + auroraGetProc = proc; auroraInstance = instance; + return AuroraEnumerateImpl; +} +extern "C" DAWN_NATIVE_EXPORT uint32_t AuroraDawnVulkanVersion() { return AURORA_DAWN_VULKAN_ABI; } +extern "C" DAWN_NATIVE_EXPORT int AuroraDawnVulkanConfigure(const AuroraDawnVulkanHooks* hooks) { + std::lock_guard lock(auroraHooksMutex); + // Called before adapter discovery, or cleared after Aurora shutdown. The + // runtime and hook storage must outlive all device creation calls. + auroraHooks = hooks ? *hooks : AuroraDawnVulkanHooks{}; + return 1; +} +extern "C" DAWN_NATIVE_EXPORT int AuroraDawnVulkanGetHandles(void* handle, AuroraDawnVulkanHandles* out) { + auto* device = ToBackend(FromAPI(static_cast(handle))); + auto guard = device->GetGuard(); + if (!device->HasFeature(Feature::ImplicitDeviceSynchronization)) return 0; + *out = {device->GetVkInstance(), ToBackend(device->GetPhysicalDevice())->GetVkPhysicalDevice(), + device->GetVkDevice(), device->GetGraphicsQueueFamily(), 0}; + return 1; +} +extern "C" DAWN_NATIVE_EXPORT void* AuroraDawnVulkanWrap(void* handle, const void* desc, uint64_t image) { + auto* device = ToBackend(FromAPI(static_cast(handle))); + auto guard = device->GetGuard(); + // Mirror DeviceBase::CreateTexture: fill the trivial frontend defaults + // (dimension, mip and sample counts) and validate before the backend reads + // them. An Undefined dimension would otherwise abort the first copy. + TextureDescriptor raw = reinterpret_cast(desc)->WithTrivialFrontendDefaults(); + UnpackedPtr descriptor; + if (device->ConsumedError(ValidateAndUnpack(&raw), &descriptor)) return nullptr; + if (device->ConsumedError(ValidateTextureDescriptor(device, descriptor, AllowMultiPlanarTextureFormat::No))) return nullptr; + auto texture = SwapChainTexture::Create(device, descriptor, VkImage::CreateFromHandle(reinterpret_cast<::VkImage>(image))); + texture->SetIsSubresourceContentInitialized(true, texture->GetAllSubresources()); + texture->UpdateUsage(wgpu::TextureUsage::RenderAttachment, wgpu::ShaderStage::None, texture->GetAllSubresources()); + return ToAPI(ReturnToAPI(std::move(texture))); +} +extern "C" DAWN_NATIVE_EXPORT int AuroraDawnVulkanRelease(void* handle, void* const* textures, uint32_t count) { + auto* device = ToBackend(FromAPI(static_cast(handle))); + auto guard = device->GetGuard(); + auto* queue = ToBackend(device->GetQueue()); + auto* context = queue->GetPendingRecordingContext(); + for (uint32_t i = 0; i < count; ++i) { + auto* texture = ToBackend(FromAPI(static_cast(textures[i]))); + texture->TransitionUsageNow(context, wgpu::TextureUsage::RenderAttachment, + wgpu::ShaderStage::None, texture->GetAllSubresources()); + } + return !device->ConsumedError(queue->SubmitPendingCommands()); +} +extern "C" DAWN_NATIVE_EXPORT void* AuroraDawnVulkanLock(void* handle) { + auto* device = ToBackend(FromAPI(static_cast(handle))); + return new AuroraDeviceGuard(device); +} +extern "C" DAWN_NATIVE_EXPORT void AuroraDawnVulkanUnlock(void* guard) { + delete static_cast(guard); +} +extern "C" DAWN_NATIVE_EXPORT int AuroraDawnVulkanDrain(void* handle) { + auto* device = ToBackend(FromAPI(static_cast(handle))); + auto guard = device->GetGuard(); + if (device->ConsumedError(ToBackend(device->GetQueue())->SubmitPendingCommands())) return 0; + return device->fn.QueueWaitIdle(ToBackend(device->GetQueue())->GetVkQueue()) == VK_SUCCESS; +} +} diff --git a/aurora-main/tests/CMakeLists.txt b/aurora-main/tests/CMakeLists.txt index 0917cd6..8204a3b 100644 --- a/aurora-main/tests/CMakeLists.txt +++ b/aurora-main/tests/CMakeLists.txt @@ -3,6 +3,18 @@ include(GoogleTest) option(AURORA_GPU_SMOKE_TESTS "Build opt-in tests requiring a desktop GPU" OFF) if (AURORA_GPU_SMOKE_TESTS AND AURORA_ENABLE_GX AND WIN32) + # Exercises the custom Dawn DLL's Aurora Vulkan ABI (patches/dawn). Run it with that + # webgpu_dawn.dll beside the executable; the stock prebuilt Dawn lacks the exports and the + # test reports that at startup. Vulkan headers come from the SDK or any tree on + # AURORA_TEST_VULKAN_INCLUDE (the runtime's FetchContent copy works). + find_path(AURORA_TEST_VULKAN_INCLUDE vulkan/vulkan.h HINTS "$ENV{VULKAN_SDK}/Include") + if (AURORA_TEST_VULKAN_INCLUDE) + add_executable(vulkan_native_bridge_smoke vulkan_native_bridge_smoke.cpp) + target_include_directories(vulkan_native_bridge_smoke PRIVATE ../include "${AURORA_TEST_VULKAN_INCLUDE}") + target_link_libraries(vulkan_native_bridge_smoke PRIVATE dawn::webgpu_dawn dawn::dawncpp_headers) + else () + message(STATUS "vulkan_native_bridge_smoke skipped: no vulkan/vulkan.h (set VULKAN_SDK or AURORA_TEST_VULKAN_INCLUDE)") + endif () add_executable(stereo_frame_worker_smoke stereo_frame_worker_smoke.cpp) target_include_directories(stereo_frame_worker_smoke PRIVATE ../lib) target_link_libraries(stereo_frame_worker_smoke PRIVATE aurora::core aurora::gx aurora::main aurora::vi diff --git a/aurora-main/tests/vulkan_native_bridge_smoke.cpp b/aurora-main/tests/vulkan_native_bridge_smoke.cpp new file mode 100644 index 0000000..eca5702 --- /dev/null +++ b/aurora-main/tests/vulkan_native_bridge_smoke.cpp @@ -0,0 +1,162 @@ +// Opt-in real GPU test for the custom Dawn DLL. No headset required. +// Exercises the MinGW/MSVC C ABI, borrowed-image lifetime and repeated layout +// transitions. Run with VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation as well. +#define NOMINMAX +#include +#include +#include +#include +#include +#include +#include + +static void Check(bool ok) { if (!ok) { std::fputs("Native Vulkan bridge smoke failed\n", stderr); std::abort(); } } +static PFN_vkGetInstanceProcAddr hookProc; +static VkInstance hookInstance; +static int instances = 0, devices = 0, selections = 0; +static int32_t CreateInstance(void*, void* proc, const void* info, const void* allocator, void** out) { + ++instances; hookProc = reinterpret_cast(proc); + auto create = reinterpret_cast(hookProc(nullptr, "vkCreateInstance")); + auto result = create(static_cast(info), static_cast(allocator), &hookInstance); + *out = hookInstance; return result; +} +static int32_t CreateDevice(void*, void*, void* physical, const void* info, const void* allocator, void** out) { + ++devices; + auto create = reinterpret_cast(hookProc(hookInstance, "vkCreateDevice")); + return create(static_cast(physical), static_cast(info), + static_cast(allocator), reinterpret_cast(out)); +} +static int32_t SelectPhysical(void*, void* instance, void** out) { + ++selections; + auto enumerate = reinterpret_cast(hookProc(static_cast(instance), "vkEnumeratePhysicalDevices")); + uint32_t count = 1; + auto result = enumerate(static_cast(instance), &count, reinterpret_cast(out)); + return result == VK_INCOMPLETE ? VK_SUCCESS : result; +} +int main() { + auto dll = LoadLibraryW(L"webgpu_dawn.dll"); Check(dll != nullptr); +#define API(name, type) auto name = reinterpret_cast(GetProcAddress(dll, "AuroraDawnVulkan" #name)); Check(name != nullptr) + API(Version, AuroraDawnVulkanVersionFn); + API(Configure, AuroraDawnVulkanConfigureFn); + API(GetHandles, AuroraDawnVulkanHandlesFn); + API(Wrap, AuroraDawnVulkanWrapFn); + API(Release, AuroraDawnVulkanReleaseFn); + API(Lock, AuroraDawnVulkanLockFn); + API(Unlock, AuroraDawnVulkanUnlockFn); + API(Drain, AuroraDawnVulkanDrainFn); + Check(Version() == AURORA_DAWN_VULKAN_ABI); + AuroraDawnVulkanHooks hooks{nullptr, CreateInstance, CreateDevice, SelectPhysical}; + Check(Configure(&hooks)); + wgpu::InstanceFeatureName feature = wgpu::InstanceFeatureName::TimedWaitAny; + wgpu::InstanceDescriptor instanceDesc; + instanceDesc.requiredFeatureCount = 1; instanceDesc.requiredFeatures = &feature; + std::fputs("Creating WebGPU instance\n", stderr); + auto instance = wgpu::CreateInstance(&instanceDesc); + wgpu::RequestAdapterOptions options; options.backendType = wgpu::BackendType::Vulkan; + wgpu::Adapter adapter; + std::fputs("Requesting Vulkan adapter\n", stderr); + auto future = instance.RequestAdapter(&options, wgpu::CallbackMode::WaitAnyOnly, + [&](wgpu::RequestAdapterStatus status, wgpu::Adapter value, wgpu::StringView) { + Check(status == wgpu::RequestAdapterStatus::Success); adapter = std::move(value); + }); + Check(instance.WaitAny(future, 10'000'000'000) == wgpu::WaitStatus::Success); + wgpu::FeatureName sync = wgpu::FeatureName::ImplicitDeviceSynchronization; + wgpu::DeviceDescriptor deviceDesc; + deviceDesc.requiredFeatureCount = 1; deviceDesc.requiredFeatures = &sync; + deviceDesc.SetUncapturedErrorCallback([](const wgpu::Device&, wgpu::ErrorType, wgpu::StringView message) { + std::fprintf(stderr, "Dawn: %.*s\n", static_cast(message.length), message.data); Check(false); + }); + std::fputs("Creating Vulkan device\n", stderr); + auto device = adapter.CreateDevice(&deviceDesc); Check(!!device); + Check(instances > 0 && devices > 0 && selections > 0); + std::fputs("Getting native device\n", stderr); + AuroraDawnVulkanHandles handles{}; Check(GetHandles(device.Get(), &handles)); + VkDevice vkDevice = static_cast(handles.device); + VkPhysicalDevice physical = static_cast(handles.physicalDevice); + auto loader = LoadLibraryW(L"vulkan-1.dll"); Check(loader != nullptr); + auto getProc = reinterpret_cast(GetProcAddress(loader, "vkGetInstanceProcAddr")); +#define VK(name) auto name = reinterpret_cast(getProc(static_cast(handles.instance), #name)); Check(name != nullptr) + VK(vkCreateImage); VK(vkGetImageMemoryRequirements); VK(vkGetPhysicalDeviceMemoryProperties); + VK(vkAllocateMemory); VK(vkBindImageMemory); VK(vkCreateCommandPool); VK(vkAllocateCommandBuffers); + VK(vkBeginCommandBuffer); VK(vkCmdPipelineBarrier); VK(vkEndCommandBuffer); VK(vkGetDeviceQueue); + VK(vkQueueSubmit); VK(vkQueueWaitIdle); VK(vkDestroyCommandPool); VK(vkDestroyImage); VK(vkFreeMemory); + VkImageCreateInfo imageInfo{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO}; + imageInfo.imageType = VK_IMAGE_TYPE_2D; imageInfo.format = VK_FORMAT_R8G8B8A8_UNORM; + imageInfo.extent = {64, 16, 1}; imageInfo.mipLevels = 1; imageInfo.arrayLayers = 1; + imageInfo.samples = VK_SAMPLE_COUNT_1_BIT; imageInfo.tiling = VK_IMAGE_TILING_OPTIMAL; + imageInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT; + std::fputs("Creating borrowed Vulkan image\n", stderr); + VkImage image; Check(vkCreateImage(vkDevice, &imageInfo, nullptr, &image) == VK_SUCCESS); + VkMemoryRequirements requirements; vkGetImageMemoryRequirements(vkDevice, image, &requirements); + VkPhysicalDeviceMemoryProperties properties; vkGetPhysicalDeviceMemoryProperties(physical, &properties); + uint32_t memoryType = 0; + while (!(requirements.memoryTypeBits & (1u << memoryType))) ++memoryType; + VkMemoryAllocateInfo allocation{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO}; + allocation.allocationSize = requirements.size; allocation.memoryTypeIndex = memoryType; + VkDeviceMemory memory; Check(vkAllocateMemory(vkDevice, &allocation, nullptr, &memory) == VK_SUCCESS); + Check(vkBindImageMemory(vkDevice, image, memory, 0) == VK_SUCCESS); + VkCommandPoolCreateInfo poolInfo{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO}; poolInfo.queueFamilyIndex = handles.queueFamily; + VkCommandPool pool; Check(vkCreateCommandPool(vkDevice, &poolInfo, nullptr, &pool) == VK_SUCCESS); + VkCommandBufferAllocateInfo alloc{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO}; + alloc.commandPool = pool; alloc.commandBufferCount = 1; alloc.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + VkCommandBuffer commands; Check(vkAllocateCommandBuffers(vkDevice, &alloc, &commands) == VK_SUCCESS); + VkCommandBufferBeginInfo begin{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO}; + Check(vkBeginCommandBuffer(commands, &begin) == VK_SUCCESS); + VkImageMemoryBarrier barrier{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER}; + barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; barrier.newLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + barrier.srcQueueFamilyIndex = barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = image; barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + barrier.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; + vkCmdPipelineBarrier(commands, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, + 0, 0, nullptr, 0, nullptr, 1, &barrier); + Check(vkEndCommandBuffer(commands) == VK_SUCCESS); + VkQueue queue; vkGetDeviceQueue(vkDevice, handles.queueFamily, handles.queueIndex, &queue); + VkSubmitInfo submit{VK_STRUCTURE_TYPE_SUBMIT_INFO}; submit.commandBufferCount = 1; submit.pCommandBuffers = &commands; + std::fputs("Locking native queue\n", stderr); + auto guard = Lock(device.Get()); + Check(vkQueueSubmit(queue, 1, &submit, VK_NULL_HANDLE) == VK_SUCCESS); + Check(vkQueueWaitIdle(queue) == VK_SUCCESS); Unlock(guard); + wgpu::TextureDescriptor textureDesc; + textureDesc.size = {64, 16, 1}; textureDesc.format = wgpu::TextureFormat::RGBA8Unorm; + textureDesc.usage = wgpu::TextureUsage::CopyDst | wgpu::TextureUsage::CopySrc | wgpu::TextureUsage::RenderAttachment; + std::fputs("Wrapping borrowed image\n", stderr); + auto borrowed = wgpu::Texture::Acquire(static_cast(Wrap(device.Get(), &textureDesc, reinterpret_cast(image)))); + Check(!!borrowed); + std::fputs("Creating copy resources\n", stderr); + auto source = device.CreateTexture(&textureDesc); + wgpu::BufferDescriptor bufferDesc; bufferDesc.size = 4096; + bufferDesc.usage = wgpu::BufferUsage::CopyDst | wgpu::BufferUsage::MapRead; + auto readback = device.CreateBuffer(&bufferDesc); + std::fputs("Running GPU copy\n", stderr); + for (uint8_t value : {17, 99, 201}) { + std::array pixels; pixels.fill(value); + wgpu::TexelCopyTextureInfo sourceInfo; sourceInfo.texture = source; + wgpu::TexelCopyTextureInfo targetInfo; targetInfo.texture = borrowed; + wgpu::TexelCopyBufferLayout layout; layout.bytesPerRow = 256; layout.rowsPerImage = 16; + wgpu::Extent3D extent{64, 16, 1}; + device.GetQueue().WriteTexture(&sourceInfo, pixels.data(), pixels.size(), &layout, &extent); + std::fputs("WriteTexture done\n", stderr); + auto encoder = device.CreateCommandEncoder(); encoder.CopyTextureToTexture(&sourceInfo, &targetInfo, &extent); + auto copy = encoder.Finish(); device.GetQueue().Submit(1, ©); + std::fputs("Copy submitted\n", stderr); + void* textures[] = {borrowed.Get()}; Check(Release(device.Get(), textures, 1)); + std::fputs("Release transition done\n", stderr); + encoder = device.CreateCommandEncoder(); + wgpu::TexelCopyBufferInfo destination; destination.buffer = readback; destination.layout = layout; + encoder.CopyTextureToBuffer(&targetInfo, &destination, &extent); + copy = encoder.Finish(); device.GetQueue().Submit(1, ©); + Check(Release(device.Get(), textures, 1)); + auto mapped = readback.MapAsync(wgpu::MapMode::Read, 0, 4096, wgpu::CallbackMode::WaitAnyOnly, + [](wgpu::MapAsyncStatus status, wgpu::StringView) { Check(status == wgpu::MapAsyncStatus::Success); }); + Check(instance.WaitAny(mapped, 10'000'000'000) == wgpu::WaitStatus::Success); + const auto* bytes = static_cast(readback.GetConstMappedRange()); + for (size_t i = 0; i < pixels.size(); ++i) Check(bytes[i] == value); + readback.Unmap(); + } + Check(Drain(device.Get())); borrowed = nullptr; + // The wrapper must not free the runtime-owned image or its memory. + vkDestroyImage(vkDevice, image, nullptr); vkFreeMemory(vkDevice, memory, nullptr); + vkDestroyCommandPool(vkDevice, pool, nullptr); + Check(Configure(nullptr)); + std::puts("Native Vulkan bridge: three GPU copy/readback cycles passed"); +} diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 1fc18bf..851406d 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -286,6 +286,15 @@ if(MKW_PROJECT_COMPILE_DEFINITIONS) add_compile_definitions(${MKW_PROJECT_COMPILE_DEFINITIONS}) endif() +if(MKW_PLATFORM_WINDOWS AND MKW_ENABLE_OPENXR) + include(FetchContent) + FetchContent_Declare(vulkan_headers + URL "https://github.com/KhronosGroup/Vulkan-Headers/archive/015e25c3c91b70eb1a754d36fb14c4ba6ad9b0b9.tar.gz" + DOWNLOAD_EXTRACT_TIMESTAMP TRUE) + FetchContent_MakeAvailable(vulkan_headers) + include_directories("${vulkan_headers_SOURCE_DIR}/include") +endif() + set(MKW_OPENXR_TARGET "") if(MKW_ENABLE_OPENXR) if(NOT MKW_PLATFORM_WINDOWS AND NOT MKW_PLATFORM_LINUX AND NOT MKW_PLATFORM_ANDROID) @@ -340,6 +349,7 @@ endif() # a registration file is silently never compiled and never errors. The stale-glob # failure mode is worth far more than the milliseconds. file(GLOB_RECURSE SOURCES CONFIGURE_DEPENDS "src/*.cpp") +set_source_files_properties(src/vr/openxr_vulkan_win32.cpp PROPERTIES SKIP_UNITY_BUILD_INCLUSION ON) if(MKW_PLATFORM_MACOS) list(REMOVE_ITEM SOURCES "${CMAKE_CURRENT_LIST_DIR}/src/guest_flat_memory.cpp") # HostContext's Apple Silicon backend is implemented in a small assembly @@ -453,6 +463,16 @@ if(MKW_ENABLE_OPENXR AND MKW_PLATFORM_WINDOWS) target_compile_features(mkw_openxr_replay_tests PRIVATE cxx_std_17) set_target_properties(mkw_openxr_replay_tests PROPERTIES UNITY_BUILD OFF) add_test(NAME mkw_openxr_replay_tests COMMAND mkw_openxr_replay_tests) + add_executable(mkw_openxr_vulkan_replay_tests + tests/openxr_d3d12_replay_tests.cpp src/vr/openxr_vulkan_win32.cpp src/vr/openxr_diagnostics.cpp) + target_include_directories(mkw_openxr_vulkan_replay_tests PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/include" + "${CMAKE_CURRENT_LIST_DIR}/../aurora-main/include" + "$") + target_compile_definitions(mkw_openxr_vulkan_replay_tests PRIVATE MKW_ENABLE_OPENXR=1 TEST_WINDOWS_VULKAN=1) + target_compile_features(mkw_openxr_vulkan_replay_tests PRIVATE cxx_std_17) + set_target_properties(mkw_openxr_vulkan_replay_tests PROPERTIES UNITY_BUILD OFF) + add_test(NAME mkw_openxr_vulkan_replay_tests COMMAND mkw_openxr_vulkan_replay_tests) endif() add_executable(mkw_nand_save_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_save_tests.cpp") diff --git a/runtime/include/vr/openxr_runtime.h b/runtime/include/vr/openxr_runtime.h index df05d7c..a0ce732 100644 --- a/runtime/include/vr/openxr_runtime.h +++ b/runtime/include/vr/openxr_runtime.h @@ -106,6 +106,23 @@ public: OpenXRRuntime(OpenXRRuntime&&) = delete; OpenXRRuntime& operator=(OpenXRRuntime&&) = delete; + // Same-device Vulkan runtimes may touch Dawn's queue in frame/swapchain + // calls. Share Dawn's device guard; never hold it across xrWaitFrame. + struct GraphicsQueueGuard { + GraphicsQueueGuard(void* value, void (*release)(void*)) : token(value), unlock(release) {} + GraphicsQueueGuard(const GraphicsQueueGuard&) = delete; + GraphicsQueueGuard& operator=(const GraphicsQueueGuard&) = delete; + void* token; + void (*unlock)(void*); + ~GraphicsQueueGuard() { if (token && unlock) unlock(token); } + }; + void SetGraphicsQueueGuard(void* (*lock)(), void (*unlock)(void*)) { + m_queue_lock = lock; m_queue_unlock = unlock; + } + GraphicsQueueGuard LockGraphicsQueue() const { + return {m_queue_lock ? m_queue_lock() : nullptr, m_queue_unlock}; + } + // Creates the instance, resolves the HMD system, and enumerates the stereo // view configuration. Returns false without terminating the application; // the caller should continue in non-VR mode. @@ -214,6 +231,8 @@ public: const OpenXRError& LastError() const { return m_last_error; } private: + void* (*m_queue_lock)() = nullptr; + void (*m_queue_unlock)(void*) = nullptr; enum class FramePhase { Idle, Waited, diff --git a/runtime/include/vr/openxr_vulkan_win32.h b/runtime/include/vr/openxr_vulkan_win32.h new file mode 100644 index 0000000..4459a4c --- /dev/null +++ b/runtime/include/vr/openxr_vulkan_win32.h @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32) + +#include "vr/openxr_backend.h" +#include "vr/openxr_runtime.h" + +#include +#include +#include +#include + +namespace mkw::vr { + +// Shared frame vocabulary for the Windows pacing and replay paths. +using OpenXRWindowsVulkanFrameMode = OpenXRFrameMode; +using OpenXRWindowsVulkanBeginStatus = OpenXRBeginStatus; +using OpenXRWindowsVulkanSubmissionStatus = OpenXRSubmissionStatus; +using OpenXRWindowsVulkanPresentation = OpenXRPresentation; +using OpenXRWindowsVulkanFrame = OpenXRBackendFrame; + +struct OpenXRWindowsVulkanGraphicsRequirements { + XrVersion min_api_version = 0; + XrVersion max_api_version = 0; +}; + +// Same-device Dawn/OpenXR Vulkan backend. +// +// Startup is deliberately split in two. QueryGraphicsRequirements() runs +// after OpenXRRuntime::Initialize() but before aurora_initialize(), allowing +// OpenXR to create Dawn's Vulkan instance/device and select the physical GPU. +// BindAurora() runs afterwards and checks the device before creating a session. +// +// All methods from BeginFrame() through FinishFrame(), plus PollEvents on the +// associated OpenXRRuntime, belong to one XR pacing thread. Aurora's frame +// worker never calls OpenXR: its post-submit callback only publishes a token +// that WaitForSubmission() consumes. This is the synchronization boundary +// required by the asynchronous sealed-frame renderer. +class OpenXRWindowsVulkanBackend final { +public: + explicit OpenXRWindowsVulkanBackend(OpenXRLogCallback logger = {}); + ~OpenXRWindowsVulkanBackend(); + + OpenXRWindowsVulkanBackend(const OpenXRWindowsVulkanBackend&) = delete; + OpenXRWindowsVulkanBackend& operator=(const OpenXRWindowsVulkanBackend&) = delete; + OpenXRWindowsVulkanBackend(OpenXRWindowsVulkanBackend&&) = delete; + OpenXRWindowsVulkanBackend& operator=(OpenXRWindowsVulkanBackend&&) = delete; + + bool QueryGraphicsRequirements(OpenXRRuntime& runtime); + bool BindAurora(OpenXRRuntime& runtime); + + OpenXRWindowsVulkanBeginStatus BeginFrame(const OpenXRWindowsVulkanPresentation& presentation, + OpenXRWindowsVulkanFrame& frame); + + // timeout_ms == UINT32_MAX waits until Aurora publishes this token or + // Shutdown() interrupts the wait. A timeout does not release XR images; + // the caller may keep pacing with RepeatFrame while Aurora still owns them. + OpenXRWindowsVulkanSubmissionStatus WaitForSubmission(const OpenXRWindowsVulkanFrame& frame, + uint32_t timeout_ms = UINT32_MAX); + + // Withdraws this token only if Aurora has not encoded it. On success no GPU + // command can reference the acquired images, and FinishFrame(frame, false) + // is required to release them and close the compositor frame. + bool TryCancelPendingFrame(OpenXRWindowsVulkanFrame& frame); + + // Ends the current compositor cycle with the last completed layer and starts + // another, without releasing or changing Aurora's pending images/render token. + // The original render poses remain attached to the pending and retained images. + bool RepeatFrame(const OpenXRWindowsVulkanFrame& frame); + + // Releases acquired images and calls xrEndFrame. submit_layer must only be + // true after WaitForSubmission returned Success. Immersive frames submit + // XrCompositionLayerProjection; virtual-screen frames submit an + // XrCompositionLayerQuad using the single mono target, placed as the + // presentation's quad_anchored/quad_pose describe. If no new usable layer is + // available, resubmits the retained layer using the current display time. + bool FinishFrame(OpenXRWindowsVulkanFrame& frame, bool submit_layer); + + // Render-first path when interpolation is off. Aurora renders into acquired + // non-retained XR images while no compositor frame is open. BeginFrameForPacket + // accepts only a completed packet; CopyRenderedEyes verifies the already queued + // bridge copy. FinishFrame releases the images and submits their original poses. + OpenXRBeginStatus PreparePacket(const OpenXRPresentation& presentation, OpenXRBackendFrame& packet); + bool TryCancelPendingPacket(OpenXRBackendFrame& packet); + OpenXRBeginStatus BeginFrameForPacket(const OpenXRBackendFrame& packet, OpenXRBackendFrame& frame); + OpenXRSubmissionStatus CopyRenderedEyes(const OpenXRBackendFrame& frame); + OpenXRBeginStatus KeepAliveCycle(); + + // Call on the XR owner thread after Aurora's worker is idle and before + // aurora_shutdown(). Safe to repeat. False means a submitted Vulkan command + // could not be fenced; the caller must retain this backend and its runtime + // for the process lifetime instead of destroying possibly live resources. + bool Shutdown(); + + bool IsBound() const; + const OpenXRWindowsVulkanGraphicsRequirements& GraphicsRequirements() const; + int64_t SwapchainFormat() const; + const std::string& LastError() const; + +private: + class Impl; + std::unique_ptr m_impl; +}; + +} // namespace mkw::vr + +#endif // defined(MKW_ENABLE_OPENXR) && defined(_WIN32) diff --git a/runtime/include/vr/openxr_windows.h b/runtime/include/vr/openxr_windows.h new file mode 100644 index 0000000..5548d77 --- /dev/null +++ b/runtime/include/vr/openxr_windows.h @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include "vr/openxr_d3d12.h" +#include "vr/openxr_vulkan_win32.h" +#include +namespace mkw::vr { +// Runtime API selection; both backends expose the same pacing/ownership contract. +class OpenXRWindowsBackend { + std::variant, std::unique_ptr> backend_; +public: + OpenXRWindowsBackend(OpenXRLogCallback logger, bool vulkan) { + if (vulkan) backend_ = std::make_unique(logger); + else backend_ = std::make_unique(logger); + } + const OpenXRD3D12GraphicsRequirements& GraphicsRequirements() { + return std::get<0>(backend_)->GraphicsRequirements(); + } + bool QueryGraphicsRequirements(OpenXRRuntime& runtime) { + return std::visit([&](auto& backend) -> bool { return backend->QueryGraphicsRequirements(runtime); }, backend_); + } + bool BindAurora(OpenXRRuntime& runtime) { + return std::visit([&](auto& backend) -> bool { return backend->BindAurora(runtime); }, backend_); + } + OpenXRBeginStatus BeginFrame(const OpenXRPresentation& presentation, OpenXRBackendFrame& frame) { + return std::visit([&](auto& backend) -> OpenXRBeginStatus { return backend->BeginFrame(presentation, frame); }, backend_); + } + OpenXRSubmissionStatus WaitForSubmission(const OpenXRBackendFrame& frame, uint32_t timeout) { + return std::visit([&](auto& backend) -> OpenXRSubmissionStatus { return backend->WaitForSubmission(frame, timeout); }, backend_); + } + bool TryCancelPendingFrame(OpenXRBackendFrame& frame) { + return std::visit([&](auto& backend) -> bool { return backend->TryCancelPendingFrame(frame); }, backend_); + } + bool RepeatFrame(const OpenXRBackendFrame& frame) { + return std::visit([&](auto& backend) -> bool { return backend->RepeatFrame(frame); }, backend_); + } + bool FinishFrame(OpenXRBackendFrame& frame, bool submit) { + return std::visit([&](auto& backend) -> bool { return backend->FinishFrame(frame, submit); }, backend_); + } + OpenXRBeginStatus PreparePacket(const OpenXRPresentation& presentation, OpenXRBackendFrame& frame) { + return std::visit([&](auto& backend) -> OpenXRBeginStatus { return backend->PreparePacket(presentation, frame); }, backend_); + } + bool TryCancelPendingPacket(OpenXRBackendFrame& frame) { + return std::visit([&](auto& backend) -> bool { return backend->TryCancelPendingPacket(frame); }, backend_); + } + OpenXRBeginStatus BeginFrameForPacket(const OpenXRBackendFrame& packet, OpenXRBackendFrame& frame) { + return std::visit([&](auto& backend) -> OpenXRBeginStatus { return backend->BeginFrameForPacket(packet, frame); }, backend_); + } + OpenXRSubmissionStatus CopyRenderedEyes(const OpenXRBackendFrame& frame) { + return std::visit([&](auto& backend) -> OpenXRSubmissionStatus { return backend->CopyRenderedEyes(frame); }, backend_); + } + OpenXRBeginStatus KeepAliveCycle() { + return std::visit([&](auto& backend) -> OpenXRBeginStatus { return backend->KeepAliveCycle(); }, backend_); + } + bool Shutdown() { + return std::visit([&](auto& backend) -> bool { return backend->Shutdown(); }, backend_); + } + int64_t SwapchainFormat() { + return std::visit([&](auto& backend) -> int64_t { return backend->SwapchainFormat(); }, backend_); + } + const std::string& LastError() { + return std::visit([&](auto& backend) -> const std::string& { return backend->LastError(); }, backend_); + } +}; +} diff --git a/runtime/src/vr/openxr_integration.cpp b/runtime/src/vr/openxr_integration.cpp index b7176a1..9d92b44 100644 --- a/runtime/src/vr/openxr_integration.cpp +++ b/runtime/src/vr/openxr_integration.cpp @@ -34,7 +34,7 @@ #include "vr/openxr_input.h" #include "vr/openxr_runtime.h" #if defined(_WIN32) -#include "vr/openxr_d3d12.h" +#include "vr/openxr_windows.h" #define MKW_OPENXR_GRAPHICS_BACKEND 1 #elif defined(__ANDROID__) #include "vr/openxr_android.h" @@ -78,8 +78,8 @@ void ConfigurePolicy(bool enabled) noexcept { #if MKW_OPENXR_GRAPHICS_BACKEND #if defined(_WIN32) -using GraphicsBackend = OpenXRD3D12Backend; -inline constexpr const char* kGraphicsBackendName = "D3D12"; +using GraphicsBackend = OpenXRWindowsBackend; + #else using GraphicsBackend = OpenXRVulkanBackend; inline constexpr const char* kGraphicsBackendName = "Vulkan"; @@ -360,7 +360,11 @@ public: } #endif runtime_ = std::make_unique(logger_); +#if defined(_WIN32) + backend_ = std::make_unique(logger_, kRequiredAuroraBackend == BACKEND_VULKAN); +#else backend_ = std::make_unique(logger_); +#endif OpenXRConfig config{}; config.application_name = aurora_config.appName != nullptr ? aurora_config.appName @@ -368,7 +372,7 @@ public: config.engine_name = "Aurora"; config.resolution_scale = RuntimeConfigFile::VrRenderScale(); #if defined(_WIN32) - config.required_extensions = {"XR_KHR_D3D12_enable"}; + config.required_extensions = {kRequiredAuroraBackend == BACKEND_VULKAN ? "XR_KHR_vulkan_enable2" : "XR_KHR_D3D12_enable"}; config.optional_extensions = {"XR_KHR_win32_convert_performance_counter_time", "XR_FB_display_refresh_rate", "XR_EXT_performance_settings"}; #else @@ -567,7 +571,8 @@ private: }; #if defined(_WIN32) - static constexpr AuroraBackend kRequiredAuroraBackend = BACKEND_D3D12; + AuroraBackend kRequiredAuroraBackend = BACKEND_D3D12; + const char* kGraphicsBackendName = "D3D12"; #else static constexpr AuroraBackend kRequiredAuroraBackend = BACKEND_VULKAN; #endif @@ -576,6 +581,10 @@ private: static constexpr uint32_t kMaxConsecutiveSkips = 300; bool BackendMatchesConfiguredGraphicsApi(const AuroraConfig& aurora_config) { +#if defined(_WIN32) + kRequiredAuroraBackend = aurora_config.desiredBackend == BACKEND_VULKAN ? BACKEND_VULKAN : BACKEND_D3D12; + kGraphicsBackendName = kRequiredAuroraBackend == BACKEND_VULKAN ? "Vulkan" : "D3D12"; +#endif if (aurora_config.desiredBackend == BACKEND_AUTO || aurora_config.desiredBackend == kRequiredAuroraBackend) { return true; @@ -589,6 +598,7 @@ private: aurora_config.desiredBackend = kRequiredAuroraBackend; aurora_config.xrInterop = true; #if defined(_WIN32) + if (kRequiredAuroraBackend != BACKEND_D3D12) return; const auto& requirements = backend_->GraphicsRequirements(); aurora_config.hasD3D12AdapterLuid = true; aurora_config.d3d12AdapterLuidLow = requirements.adapter_luid_low; diff --git a/runtime/src/vr/openxr_runtime.cpp b/runtime/src/vr/openxr_runtime.cpp index 420e79a..09e0264 100644 --- a/runtime/src/vr/openxr_runtime.cpp +++ b/runtime/src/vr/openxr_runtime.cpp @@ -724,6 +724,7 @@ bool OpenXRRuntime::BeginFrame(const OpenXRFrame& frame) { XrFrameBeginInfo begin_info{XR_TYPE_FRAME_BEGIN_INFO}; const XrResult result = diagnostics::Measure(diagnostics::Stage::BeginCall, [&] { + const auto queue_guard = LockGraphicsQueue(); return xrBeginFrame(m_session, &begin_info); }); if (XR_FAILED(result)) { @@ -816,7 +817,10 @@ bool OpenXRRuntime::EndFrame( end_info.layerCount = layer_count; end_info.layers = layers; const diagnostics::Stopwatch end_timer; - const XrResult result = xrEndFrame(m_session, &end_info); + const XrResult result = [&] { + const auto queue_guard = LockGraphicsQueue(); + return xrEndFrame(m_session, &end_info); + }(); diagnostics::OnEndFrame(end_timer); m_frame_phase = FramePhase::Idle; m_active_frame_serial = 0; diff --git a/runtime/src/vr/openxr_vulkan_win32.cpp b/runtime/src/vr/openxr_vulkan_win32.cpp new file mode 100644 index 0000000..56839ba --- /dev/null +++ b/runtime/src/vr/openxr_vulkan_win32.cpp @@ -0,0 +1,1128 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32) + +// OpenXR's Vulkan structures are selected when openxr_platform.h is parsed. +#define XR_USE_GRAPHICS_API_VULKAN +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include "vr/openxr_vulkan_win32.h" +#include "vr/openxr_diagnostics.h" + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mkw::vr { +namespace { + +bool SameVkCopyFamily(VkFormat a, VkFormat b) { + return a == b || ((a == VK_FORMAT_R8G8B8A8_UNORM || a == VK_FORMAT_R8G8B8A8_SRGB) && + (b == VK_FORMAT_R8G8B8A8_UNORM || b == VK_FORMAT_R8G8B8A8_SRGB)) || + ((a == VK_FORMAT_B8G8R8A8_UNORM || a == VK_FORMAT_B8G8R8A8_SRGB) && + (b == VK_FORMAT_B8G8R8A8_UNORM || b == VK_FORMAT_B8G8R8A8_SRGB)); +} +VkFormat WindowsVkSrgbSibling(VkFormat f) { + if (f == VK_FORMAT_R8G8B8A8_UNORM || f == VK_FORMAT_R8G8B8A8_SRGB) return VK_FORMAT_R8G8B8A8_SRGB; + if (f == VK_FORMAT_B8G8R8A8_UNORM || f == VK_FORMAT_B8G8R8A8_SRGB) return VK_FORMAT_B8G8R8A8_SRGB; + return VK_FORMAT_UNDEFINED; +} +bool IsWindowsVkSrgbFormat(VkFormat f) { return f == VK_FORMAT_R8G8B8A8_SRGB || f == VK_FORMAT_B8G8R8A8_SRGB; } + +const char* WindowsVkBeginStatusOperation(OpenXRFrameStatus status) noexcept { + switch (status) { + case OpenXRFrameStatus::Ready: + return "ready"; + case OpenXRFrameStatus::SessionNotRunning: + return "session is not running"; + case OpenXRFrameStatus::ExitRequested: + return "runtime requested exit"; + case OpenXRFrameStatus::Error: + return "xrWaitFrame failed"; + } + return "unknown frame status"; +} + +} // namespace + +class OpenXRWindowsVulkanBackend::Impl final { +public: + explicit Impl(OpenXRLogCallback logger) : logger_(std::move(logger)) {} + + ~Impl() { Shutdown(); } + + struct EyeSwapchain { + XrSwapchain handle = XR_NULL_HANDLE; + uint32_t width = 0; + uint32_t height = 0; + std::vector images; + uint32_t acquired_index = 0; + bool acquired = false; + bool waited = false; + bool release_forbidden = false; + }; + + static uint32_t VkVersion(XrVersion v) { + return VK_MAKE_API_VERSION(0, XR_VERSION_MAJOR(v), XR_VERSION_MINOR(v), XR_VERSION_PATCH(v)); + } + // Dawn's hooks. proc may be null in the headless replay tests. + static int32_t CreateInstance(void* self, void* proc, const void* info, const void* allocator, void** out) { + auto& owner = *static_cast(self); + const auto get_proc = reinterpret_cast(proc); + XrVulkanInstanceCreateInfoKHR create{XR_TYPE_VULKAN_INSTANCE_CREATE_INFO_KHR}; + create.systemId = owner.runtime_->SystemId(); + create.pfnGetInstanceProcAddr = get_proc; + auto vk_info = *static_cast(info); + if (!vk_info.pApplicationInfo) return VK_ERROR_INITIALIZATION_FAILED; + auto app = *vk_info.pApplicationInfo; + // Dawn asks for Vulkan 1.1. Prefer 1.2 where the loader and runtime allow + // it: PC runtimes create timeline semaphores on this device, and from 1.2 + // that feature is core, so CreateDevice can enable it without knowing + // which extensions the runtime appends. + uint32_t loader_version = VK_API_VERSION_1_0; + const auto enumerate = get_proc ? reinterpret_cast( + get_proc(nullptr, "vkEnumerateInstanceVersion")) : nullptr; + if (!enumerate || enumerate(&loader_version) != VK_SUCCESS) loader_version = VK_API_VERSION_1_0; + const uint32_t preferred = std::min({static_cast(VK_API_VERSION_1_2), loader_version, + VkVersion(owner.requirements_.max_api_version)}); + app.apiVersion = std::max({app.apiVersion, VkVersion(owner.requirements_.min_api_version), preferred}); + if (app.apiVersion > VkVersion(owner.requirements_.max_api_version)) return VK_ERROR_INCOMPATIBLE_DRIVER; + vk_info.pApplicationInfo = &app; + create.vulkanCreateInfo = &vk_info; + create.vulkanAllocator = static_cast(allocator); + VkResult result = VK_ERROR_INITIALIZATION_FAILED; + const XrResult xr = owner.create_instance_(owner.runtime_->Instance(), &create, reinterpret_cast(out), &result); + if (XR_SUCCEEDED(xr) && result == VK_SUCCESS) { + owner.vk_instance_ = *reinterpret_cast(out); + owner.instance_api_version_ = app.apiVersion; + } + return XR_SUCCEEDED(xr) ? result : VK_ERROR_INITIALIZATION_FAILED; + } + static int32_t GetPhysical(void* self, void* instance, void** out) { + auto& owner = *static_cast(self); + XrVulkanGraphicsDeviceGetInfoKHR get{XR_TYPE_VULKAN_GRAPHICS_DEVICE_GET_INFO_KHR}; + get.systemId = owner.runtime_->SystemId(); + get.vulkanInstance = static_cast(instance); + return XR_SUCCEEDED(owner.get_device_(owner.runtime_->Instance(), &get, + reinterpret_cast(out))) ? VK_SUCCESS : VK_ERROR_INITIALIZATION_FAILED; + } + static int32_t CreateDevice(void* self, void* proc, void* physical, const void* info, const void* allocator, void** out) { + auto& owner = *static_cast(self); + const auto get_proc = reinterpret_cast(proc); + const auto vk_physical = static_cast(physical); + auto vk_info = *static_cast(info); + // PC runtimes (Virtual Desktop, SteamVR) create timeline semaphores on the + // application's device. The runtime appends the extension, but a feature + // that is declared and not enabled is undefined behaviour and was seen as + // VK_ERROR_DEVICE_LOST seconds into a session. From Vulkan 1.2 the + // feature is core, so enable it whenever the device offers it and Dawn's + // own chain does not already speak for it. + VkPhysicalDeviceTimelineSemaphoreFeatures timeline{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_TIMELINE_SEMAPHORE_FEATURES}; + if (get_proc && owner.vk_instance_ && owner.instance_api_version_ >= VK_API_VERSION_1_2) { + const auto properties_fn = reinterpret_cast( + get_proc(owner.vk_instance_, "vkGetPhysicalDeviceProperties")); + const auto features_fn = reinterpret_cast( + get_proc(owner.vk_instance_, "vkGetPhysicalDeviceFeatures2")); + VkPhysicalDeviceProperties properties{}; + if (properties_fn) properties_fn(vk_physical, &properties); + VkPhysicalDeviceFeatures2 features{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2, &timeline}; + if (features_fn && properties.apiVersion >= VK_API_VERSION_1_2) features_fn(vk_physical, &features); + bool chained = false; + for (auto* next = static_cast(vk_info.pNext); next; next = next->pNext) { + chained |= next->sType == VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_TIMELINE_SEMAPHORE_FEATURES || + next->sType == VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_2_FEATURES; + } + if (timeline.timelineSemaphore && !chained) { + timeline.pNext = const_cast(vk_info.pNext); + vk_info.pNext = &timeline; + } else { + timeline.timelineSemaphore = VK_FALSE; + } + } + XrVulkanDeviceCreateInfoKHR create{XR_TYPE_VULKAN_DEVICE_CREATE_INFO_KHR}; + create.systemId = owner.runtime_->SystemId(); + create.pfnGetInstanceProcAddr = get_proc; + create.vulkanPhysicalDevice = vk_physical; + create.vulkanCreateInfo = &vk_info; + create.vulkanAllocator = static_cast(allocator); + VkResult result = VK_ERROR_INITIALIZATION_FAILED; + const XrResult xr = owner.create_device_(owner.runtime_->Instance(), &create, reinterpret_cast(out), &result); + if (XR_SUCCEEDED(xr) && result == VK_SUCCESS) owner.timeline_semaphores_ = timeline.timelineSemaphore == VK_TRUE; + return XR_SUCCEEDED(xr) ? result : VK_ERROR_INITIALIZATION_FAILED; + } + bool QueryGraphicsRequirements(OpenXRRuntime& runtime) { + ClearError(); + if (!runtime.IsInitialized() || runtime.HasSession()) return Fail("Vulkan requirements need an initialized XR instance without a session"); + if (requirements_queried_ && runtime_ != &runtime) + return Fail("Vulkan graphics requirements were already queried from another OpenXR instance"); + runtime_ = &runtime; + PFN_xrGetVulkanGraphicsRequirements2KHR get = nullptr; + if (!runtime.LoadFunction("xrGetVulkanGraphicsRequirements2KHR", &get) || !get || + !runtime.LoadFunction("xrCreateVulkanInstanceKHR", &create_instance_) || !create_instance_ || + !runtime.LoadFunction("xrCreateVulkanDeviceKHR", &create_device_) || !create_device_ || + !runtime.LoadFunction("xrGetVulkanGraphicsDevice2KHR", &get_device_) || !get_device_) + return Fail("PC Vulkan requires XR_KHR_vulkan_enable2"); + XrGraphicsRequirementsVulkanKHR requirements{XR_TYPE_GRAPHICS_REQUIREMENTS_VULKAN_KHR}; + if (XR_FAILED(get(runtime.Instance(), runtime.SystemId(), &requirements))) return Fail("Vulkan requirements query failed"); + requirements_ = {requirements.minApiVersionSupported, requirements.maxApiVersionSupported}; + AuroraDawnVulkanHooks hooks{this, CreateInstance, CreateDevice, GetPhysical}; + if (!aurora_vulkan_win32_configure(&hooks)) return Fail("PC Vulkan OpenXR requires the custom Dawn library with Aurora Vulkan ABI 1; rebuild/install it with Launcher/Build-DawnVulkan.ps1"); + hooks_installed_ = true; + { + std::lock_guard lock(submission_mutex_); + shutting_down_ = false; + submission_unsafe_ = false; + } + requirements_queried_ = true; + std::ostringstream message; + message << "OpenXR Vulkan requirements: API " << XR_VERSION_MAJOR(requirements_.min_api_version) << '.' + << XR_VERSION_MINOR(requirements_.min_api_version) << " to " << XR_VERSION_MAJOR(requirements_.max_api_version) + << '.' << XR_VERSION_MINOR(requirements_.max_api_version) << "; Dawn will create its device through the runtime"; + Log(OpenXRLogLevel::Info, message.str()); + return true; + } + bool BindAurora(OpenXRRuntime& runtime) { + ClearError(); + if (!requirements_queried_ || runtime_ != &runtime || runtime.HasSession()) + return Fail("QueryGraphicsRequirements must succeed on this OpenXR instance before BindAurora"); + if (bound_) return Fail("OpenXR Vulkan backend is already bound"); + AuroraDawnVulkanHandles handles{}; + int64_t format = 0; + if (!aurora_vulkan_win32_get_handles(&handles, &format)) return Fail("Aurora did not expose its native Vulkan device"); + void* physical = nullptr; + if (GetPhysical(this, handles.instance, &physical) != VK_SUCCESS || physical != handles.physicalDevice) + return Fail("Dawn's Vulkan GPU does not match the OpenXR runtime"); + XrGraphicsBindingVulkanKHR binding{XR_TYPE_GRAPHICS_BINDING_VULKAN_KHR}; + binding.instance = static_cast(handles.instance); + binding.physicalDevice = static_cast(handles.physicalDevice); + binding.device = static_cast(handles.device); + binding.queueFamilyIndex = handles.queueFamily; + binding.queueIndex = handles.queueIndex; + runtime.SetGraphicsQueueGuard(aurora_vulkan_win32_lock_queue, aurora_vulkan_win32_unlock_queue); + if (!runtime.CreateSession(&binding)) return Fail("OpenXR rejected Dawn's Vulkan device binding"); + owns_session_ = true; + aurora_format_ = static_cast(format); + const auto abandon_session = [&] { + DestroySwapchains(); + runtime.DestroySession(); + runtime.SetGraphicsQueueGuard(nullptr, nullptr); + owns_session_ = false; + }; + if (!SelectSwapchainFormat() || !CreateSwapchains()) { + abandon_session(); + return false; + } + if (runtime.ShouldExit()) { + abandon_session(); + return Fail("OpenXR session became loss-pending while creating Vulkan swapchains"); + } + if (!aurora_vulkan_win32_enable(&Impl::OnAuroraSubmitted, this)) { + abandon_session(); + return Fail("Aurora could not enable its zero-readback Vulkan stereo bridge"); + } + bridge_enabled_ = true; + bound_ = true; + std::ostringstream message; + message << "OpenXR Vulkan swapchains ready: VkFormat " << static_cast(swapchain_format_) + << " (Aurora " << static_cast(aurora_format_) << "), eyes " + << eye_swapchains_[0].width << 'x' << eye_swapchains_[0].height << " / " + << eye_swapchains_[1].width << 'x' << eye_swapchains_[1].height << ", Vulkan " + << VK_API_VERSION_MAJOR(instance_api_version_) << '.' << VK_API_VERSION_MINOR(instance_api_version_) + << " instance, timeline semaphores " << (timeline_semaphores_ ? "enabled" : "not enabled") + << "; same-queue native eye copies"; + Log(OpenXRLogLevel::Info, message.str()); + return true; + } + + OpenXRWindowsVulkanBeginStatus BeginFrame(const OpenXRWindowsVulkanPresentation& presentation, + OpenXRWindowsVulkanFrame& frame) { + frame = {}; + frame.presentation = presentation; + if (!bound_ || runtime_ == nullptr) { + Fail("BeginFrame called before the Vulkan backend was bound"); + return OpenXRWindowsVulkanBeginStatus::Error; + } + if (frame_active_ || pending_packet_serial_ != 0) { + Fail("BeginFrame called while another OpenXR frame is active"); + return OpenXRWindowsVulkanBeginStatus::Error; + } + + const OpenXRFrameStatus status = runtime_->WaitFrame(frame.xr_frame); + if (status != OpenXRFrameStatus::Ready) { + if (status == OpenXRFrameStatus::Error) { + Fail(WindowsVkBeginStatusOperation(status)); + } + switch (status) { + case OpenXRFrameStatus::SessionNotRunning: + return OpenXRWindowsVulkanBeginStatus::SessionNotRunning; + case OpenXRFrameStatus::ExitRequested: + return OpenXRWindowsVulkanBeginStatus::ExitRequested; + case OpenXRFrameStatus::Error: + return OpenXRWindowsVulkanBeginStatus::Error; + case OpenXRFrameStatus::Ready: + break; + } + } + NoteDisplayTiming(frame.xr_frame); + if (!runtime_->BeginFrame(frame.xr_frame)) { + Fail("xrBeginFrame failed"); + return OpenXRWindowsVulkanBeginStatus::Error; + } + frame_active_ = true; + active_frame_serial_ = frame.xr_frame.serial; + active_frame_ = frame.xr_frame; + render_session_serial_ = runtime_->SessionRunSerial(); + render_space_serial_ = runtime_->LastReferenceSpaceChange().serial; + + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + frame.render_width[eye] = eye_swapchains_[eye].width; + frame.render_height[eye] = eye_swapchains_[eye].height; + } + + if (!frame.xr_frame.should_render) { + return OpenXRWindowsVulkanBeginStatus::Ready; + } + if (!runtime_->LocateViews(frame.xr_frame)) { + Fail("xrLocateViews failed"); + EndActiveFrameWithoutLayers(frame.xr_frame); + return OpenXRWindowsVulkanBeginStatus::Error; + } + active_frame_ = frame.xr_frame; + if (!frame.xr_frame.views_valid) { + return OpenXRWindowsVulkanBeginStatus::Ready; + } + + return PrepareTargets(frame); + } + + // Both pacing paths retain ownership until completion or cancellation. + OpenXRWindowsVulkanBeginStatus PrepareTargets(OpenXRWindowsVulkanFrame& frame) { + const uint32_t target_count = + frame.presentation.mode == OpenXRWindowsVulkanFrameMode::VirtualScreen ? 1u : kOpenXREyeCount; + if (target_count == 1) { + frame.render_width[1] = frame.render_width[0]; + frame.render_height[1] = frame.render_height[0]; + } + + std::array targets{}; + const diagnostics::Stopwatch acquire_timer; + for (uint32_t eye = 0; eye < target_count; ++eye) { + auto& swapchain = eye_swapchains_[eye]; + if (!AcquireSwapchain(swapchain)) { + ReleaseAcquiredSwapchains(); + EndActiveFrameWithoutLayers(frame.xr_frame); + return OpenXRWindowsVulkanBeginStatus::Error; + } + targets[eye] = { + reinterpret_cast(swapchain.images[swapchain.acquired_index].image), + swapchain.width, + swapchain.height, + static_cast(swapchain_format_), + }; + } + diagnostics::OnSwapchainAcquire(acquire_timer); + + { + std::lock_guard lock(submission_mutex_); + awaiting_token_ = frame.xr_frame.serial; + submitted_token_ = 0; + submission_arrived_ = false; + submission_success_ = false; + submission_unsafe_ = false; + } + if (!diagnostics::Measure(diagnostics::Stage::SetTargets, [&] { + return aurora_vulkan_win32_set_targets(frame.xr_frame.serial, targets.data(), target_count); + })) { + { + std::lock_guard lock(submission_mutex_); + awaiting_token_ = 0; + } + ReleaseAcquiredSwapchains(); + Fail("Aurora rejected the acquired OpenXR Vulkan swapchain target"); + EndActiveFrameWithoutLayers(frame.xr_frame); + return OpenXRWindowsVulkanBeginStatus::Error; + } + frame.expects_gpu_submission = true; + return OpenXRWindowsVulkanBeginStatus::Ready; + } + + // Vulkan renders straight into the non-retained XR swapchain pair. Acquiring + // images is independent of the compositor cycle; keep them acquired while + // Aurora owns them, and never expose them through the retained pair early. + OpenXRBeginStatus PreparePacket(const OpenXRPresentation& presentation, OpenXRBackendFrame& packet) { + packet = {}; + packet.presentation = presentation; + if (!bound_ || runtime_ == nullptr || frame_active_ || pending_packet_serial_ != 0) { + Fail("PreparePacket called before binding or with work pending"); + return OpenXRBeginStatus::Error; + } + if (runtime_->ShouldExit()) return OpenXRBeginStatus::ExitRequested; + if (!runtime_->IsSessionRunning()) return OpenXRBeginStatus::SessionNotRunning; + if (timing_session_serial_ != runtime_->SessionRunSerial() || last_display_period_ <= 0) { + const auto status = KeepAliveCycle(); + if (status != OpenXRBeginStatus::Ready) return status; + } + packet.xr_frame.serial = next_packet_serial_++; + packet.xr_frame.predicted_display_time = last_display_time_ + 2 * last_display_period_; + packet.xr_frame.predicted_display_period = last_display_period_; + packet.xr_frame.should_render = last_should_render_; + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + packet.render_width[eye] = eye_swapchains_[eye].width; + packet.render_height[eye] = eye_swapchains_[eye].height; + } + if (!packet.xr_frame.should_render) return OpenXRBeginStatus::Ready; + if (!runtime_->LocateViewsAt(packet.xr_frame.predicted_display_time, packet.xr_frame)) { + Fail("xrLocateViews failed for a Vulkan packet"); + return OpenXRBeginStatus::Error; + } + if (!packet.xr_frame.views_valid) return OpenXRBeginStatus::Ready; + render_session_serial_ = runtime_->SessionRunSerial(); + render_space_serial_ = runtime_->LastReferenceSpaceChange().serial; + const auto status = PrepareTargets(packet); + if (status == OpenXRBeginStatus::Ready) pending_packet_serial_ = packet.xr_frame.serial; + return status; + } + + bool TryCancelPendingPacket(OpenXRBackendFrame& packet) { + if (frame_active_ || pending_packet_serial_ == 0 || + packet.xr_frame.serial != pending_packet_serial_ || !packet.expects_gpu_submission || + !aurora_vulkan_win32_cancel(packet.xr_frame.serial)) return false; + { + std::lock_guard lock(submission_mutex_); + awaiting_token_ = submitted_token_ = 0; + submission_arrived_ = submission_success_ = submission_unsafe_ = false; + } + packet.expects_gpu_submission = false; + pending_packet_serial_ = 0; + const diagnostics::Stopwatch release_timer; + const bool released = ReleaseAcquiredSwapchains(); + diagnostics::OnSwapchainRelease(release_timer); + // A release error is fatal; keep it visible to the next prepare rather + // than letting it register new targets over still-acquired images. + if (!released) pending_packet_serial_ = packet.xr_frame.serial; + return true; // Encoding was canceled; a release error blocks the next prepare. + } + + void NoteDisplayTiming(const OpenXRFrame& frame) { + last_display_time_ = frame.predicted_display_time; + last_display_period_ = frame.predicted_display_period; + last_should_render_ = frame.should_render; + timing_session_serial_ = runtime_->SessionRunSerial(); + } + + OpenXRBeginStatus BeginCompositorCycle() { + const auto status = runtime_->WaitFrame(active_frame_); + if (status != OpenXRFrameStatus::Ready) { + if (status == OpenXRFrameStatus::Error) Fail(WindowsVkBeginStatusOperation(status)); + return status == OpenXRFrameStatus::SessionNotRunning ? OpenXRBeginStatus::SessionNotRunning + : status == OpenXRFrameStatus::ExitRequested ? OpenXRBeginStatus::ExitRequested + : OpenXRBeginStatus::Error; + } + NoteDisplayTiming(active_frame_); + if (!runtime_->BeginFrame(active_frame_)) { + Fail("xrBeginFrame failed for a Vulkan compositor cycle"); + return OpenXRBeginStatus::Error; + } + frame_active_ = true; + return OpenXRBeginStatus::Ready; + } + + OpenXRBeginStatus KeepAliveCycle() { + if (!bound_ || runtime_ == nullptr || frame_active_) { + Fail("KeepAliveCycle called before binding or with an active frame"); + return OpenXRBeginStatus::Error; + } + const auto status = BeginCompositorCycle(); + if (status != OpenXRBeginStatus::Ready) return status; + const bool ended = EndRetainedFrame(false); + frame_active_ = false; + active_frame_ = {}; + if (!ended) { + Fail("OpenXR could not resubmit the retained Vulkan frame"); + return OpenXRBeginStatus::Error; + } + return OpenXRBeginStatus::Ready; + } + + OpenXRBeginStatus BeginFrameForPacket(const OpenXRBackendFrame& packet, OpenXRBackendFrame& frame) { + frame = {}; + if (!bound_ || runtime_ == nullptr || frame_active_ || pending_packet_serial_ == 0 || + packet.xr_frame.serial != pending_packet_serial_ || !packet.expects_gpu_submission || + WaitForSubmission(packet, 0) != OpenXRSubmissionStatus::Success) { + Fail("BeginFrameForPacket requires the completed current Vulkan packet"); + return OpenXRBeginStatus::Error; + } + const auto status = BeginCompositorCycle(); + if (status != OpenXRBeginStatus::Ready) { + // Completion was already confirmed. A stopped session must not + // strand a packet and block preparation after the next READY event. + if (status == OpenXRBeginStatus::SessionNotRunning) { + const bool released = ReleaseAcquiredSwapchains(); + pending_packet_serial_ = 0; + std::lock_guard lock(submission_mutex_); + awaiting_token_ = submitted_token_ = 0; + submission_arrived_ = submission_success_ = submission_unsafe_ = false; + if (!released) return OpenXRBeginStatus::Error; + } + return status; + } + frame = packet; + // Use the current compositor token/time but the original render poses. + frame.xr_frame.serial = active_frame_.serial; + frame.xr_frame.predicted_display_time = active_frame_.predicted_display_time; + frame.xr_frame.predicted_display_period = active_frame_.predicted_display_period; + frame.xr_frame.should_render = active_frame_.should_render; + active_frame_serial_ = frame.xr_frame.serial; + { + std::lock_guard lock(submission_mutex_); + awaiting_token_ = submitted_token_ = frame.xr_frame.serial; + } + pending_packet_serial_ = 0; + return OpenXRBeginStatus::Ready; + } + + OpenXRSubmissionStatus CopyRenderedEyes(const OpenXRBackendFrame& frame) { + if (!frame_active_ || frame.xr_frame.serial != active_frame_serial_) { + Fail("CopyRenderedEyes received a stale Vulkan frame"); + return OpenXRSubmissionStatus::Failed; + } + // The native bridge already queued the copy on the session's Vulkan + // queue before publishing completion. There is no second copy on PC. + return WaitForSubmission(frame, 0); + } + + OpenXRWindowsVulkanSubmissionStatus WaitForSubmission(const OpenXRWindowsVulkanFrame& frame, + uint32_t timeout_ms) { + if (!frame.expects_gpu_submission) { + return OpenXRWindowsVulkanSubmissionStatus::Success; + } + std::unique_lock lock(submission_mutex_); + const auto ready = [&] { + return shutting_down_ || + (submission_arrived_ && submitted_token_ == frame.xr_frame.serial); + }; + if (timeout_ms == std::numeric_limits::max()) { + submission_cv_.wait(lock, ready); + } else if (!submission_cv_.wait_for(lock, std::chrono::milliseconds(timeout_ms), ready)) { + return OpenXRWindowsVulkanSubmissionStatus::Timeout; + } + if (shutting_down_) { + return OpenXRWindowsVulkanSubmissionStatus::ShuttingDown; + } + return submission_success_ ? OpenXRWindowsVulkanSubmissionStatus::Success + : OpenXRWindowsVulkanSubmissionStatus::Failed; + } + + bool TryCancelPendingFrame(OpenXRWindowsVulkanFrame& frame) { + if (!frame_active_ || !frame.expects_gpu_submission || + frame.xr_frame.serial != active_frame_serial_) { + return false; + } + if (!aurora_vulkan_win32_cancel(frame.xr_frame.serial)) { + return false; + } + + // A successful bridge cancellation is serialized against Encode and + // never generates a callback, so this token has no GPU ownership. + std::lock_guard lock(submission_mutex_); + awaiting_token_ = 0; + submitted_token_ = 0; + submission_arrived_ = false; + submission_success_ = false; + submission_unsafe_ = false; + frame.expects_gpu_submission = false; + return true; + } + + bool FinishFrame(OpenXRWindowsVulkanFrame& frame, bool submit_layer) { + if (!frame_active_ || runtime_ == nullptr || + frame.xr_frame.serial != active_frame_serial_) { + return Fail("FinishFrame received a stale or inactive OpenXR frame token"); + } + + bool submission_unsafe = false; + { + std::lock_guard lock(submission_mutex_); + submission_unsafe = submission_arrived_ && + submitted_token_ == frame.xr_frame.serial && + submission_unsafe_; + } + if (submission_unsafe) { + AbandonAcquiredSwapchains(); + Fail("Aurora's Vulkan stereo submission failed after GPU work may have been queued"); + } + const diagnostics::Stopwatch release_timer; + bool release_ok = ReleaseAcquiredSwapchains(); + if (frame.xr_frame.should_render && frame.xr_frame.views_valid) { + diagnostics::OnSwapchainRelease(release_timer); + } + const bool position_valid = + (frame.xr_frame.view_state_flags & XR_VIEW_STATE_POSITION_VALID_BIT) != 0; + const bool composition_pose_valid = + frame.presentation.mode == OpenXRWindowsVulkanFrameMode::VirtualScreen || position_valid; + const bool can_submit = submit_layer && release_ok && frame.xr_frame.should_render && + frame.xr_frame.views_valid && frame.expects_gpu_submission && + composition_pose_valid; + if (submit_layer && !can_submit) { + diagnostics::OnLayerRejected(diagnostics::ClassifyRejectedLayer( + release_ok, frame.xr_frame.should_render, frame.xr_frame.views_valid)); + } + if (can_submit) { + // xrEndFrame references the MOST RECENTLY RELEASED image of a + // swapchain, not an explicit image index. Keep the displayed pair + // separate from the pair Aurora can write or cancel next. + std::swap(eye_swapchains_, retained_swapchains_); + retained_frame_ = frame; + retained_session_serial_ = render_session_serial_; + retained_space_serial_ = render_space_serial_; + have_retained_frame_ = true; + } + const bool end_ok = EndRetainedFrame(can_submit); + + frame_active_ = false; + active_frame_serial_ = 0; + active_frame_ = {}; + frame.expects_gpu_submission = false; + { + std::lock_guard lock(submission_mutex_); + awaiting_token_ = 0; + submission_arrived_ = false; + submission_success_ = false; + submission_unsafe_ = false; + } + return release_ok && end_ok; + } + + bool RepeatFrame(const OpenXRWindowsVulkanFrame& frame) { + if (!frame_active_ || runtime_ == nullptr || + frame.xr_frame.serial != active_frame_serial_) { + return Fail("RepeatFrame received a stale or inactive render token"); + } + const bool end_ok = EndRetainedFrame(false); + // EndFrame consumes the compositor token even when submission fails. + // Teardown must not try to end that same token again. + frame_active_ = false; + if (!end_ok) { + return Fail("OpenXR could not resubmit the retained frame"); + } + // active_frame_serial_ continues to identify Aurora's pending render; + // active_frame_ identifies the independently advancing compositor cycle. + if (runtime_->PollEvents() != OpenXREventStatus::Continue || + !runtime_->IsSessionRunning() || runtime_->ShouldExit()) { + return Fail("OpenXR session stopped while waiting for stereo rendering"); + } + if (runtime_->WaitFrame(active_frame_) != OpenXRFrameStatus::Ready || + !runtime_->BeginFrame(active_frame_)) { + return Fail("OpenXR could not start a retained-frame compositor cycle"); + } + NoteDisplayTiming(active_frame_); + frame_active_ = true; + return true; + } + + // fresh: the retained layer was completed for this call rather than repeated. + bool EndRetainedFrame(bool fresh) { + if (!runtime_->IsSessionRunning()) { + // A session that is no longer running needs no compositor frame + // completion call. Preserve the original backend failure instead + // of replacing it with a stale-token error. + return true; + } + // Old poses cannot be reused after the runtime changes their coordinate + // system. Also discard content across session restarts. + const bool session_changed = retained_session_serial_ != runtime_->SessionRunSerial(); + if (session_changed || retained_space_serial_ != runtime_->LastReferenceSpaceChange().serial) { + if (have_retained_frame_) { + diagnostics::OnRetainedLayerDiscarded(session_changed + ? diagnostics::DiscardReason::SessionRestarted + : diagnostics::DiscardReason::ReferenceSpaceChanged); + } + have_retained_frame_ = false; + } + if (!have_retained_frame_ || !active_frame_.should_render) { + diagnostics::OnEmptyFrame(!active_frame_.should_render ? diagnostics::EmptyFrameReason::ShouldRenderOff + : diagnostics::EmptyFrameReason::NoRetainedLayer); + return runtime_->EndFrameWithoutLayers(active_frame_); + } + diagnostics::OnLayer(fresh); + const auto& frame = retained_frame_; + if (frame.presentation.mode == OpenXRWindowsVulkanFrameMode::VirtualScreen) { + XrCompositionLayerQuad quad{XR_TYPE_COMPOSITION_LAYER_QUAD}; + quad.layerFlags = 0; + quad.eyeVisibility = XR_EYE_VISIBILITY_BOTH; + quad.subImage.swapchain = retained_swapchains_[0].handle; + quad.subImage.imageRect = {{0, 0}, + {static_cast(retained_swapchains_[0].width), + static_cast(retained_swapchains_[0].height)}}; + quad.subImage.imageArrayIndex = 0; + if (frame.presentation.quad_anchored) { + // Placed in the application space, so the screen keeps its place + // in the room while the player looks around it. + quad.space = runtime_->AppSpace(); + quad.pose = frame.presentation.quad_pose; + } else { + // No head pose to anchor against yet: keep it in front of the + // player so the menus are never left stranded behind them. + quad.space = runtime_->ViewSpace(); + quad.pose.orientation = {0.0f, 0.0f, 0.0f, 1.0f}; + quad.pose.position = { + 0.0f, 0.0f, -std::max(0.25f, frame.presentation.quad_distance_meters)}; + } + quad.size.width = std::max(0.25f, frame.presentation.quad_width_meters); + quad.size.height = quad.size.width * static_cast(retained_swapchains_[0].height) / + static_cast(retained_swapchains_[0].width); + const XrCompositionLayerBaseHeader* layers[] = { + reinterpret_cast(&quad)}; + return runtime_->EndFrame(active_frame_, layers, 1); + } else { + std::array views{}; + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + views[eye] = {XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}; + views[eye].pose.orientation = frame.xr_frame.views[eye].pose.orientation; + views[eye].pose.position = frame.xr_frame.views[eye].pose.position; + views[eye].fov = frame.xr_frame.views[eye].fov; + views[eye].subImage.swapchain = retained_swapchains_[eye].handle; + views[eye].subImage.imageRect = { + {0, 0}, + {static_cast(retained_swapchains_[eye].width), + static_cast(retained_swapchains_[eye].height)}}; + views[eye].subImage.imageArrayIndex = 0; + } + XrCompositionLayerProjection projection{XR_TYPE_COMPOSITION_LAYER_PROJECTION}; + projection.layerFlags = 0; + projection.space = runtime_->AppSpace(); + projection.viewCount = kOpenXREyeCount; + projection.views = views.data(); + const XrCompositionLayerBaseHeader* layers[] = { + reinterpret_cast(&projection)}; + return runtime_->EndFrame(active_frame_, layers, 1); + } + } + + bool Shutdown() { + if (shutdown_unsafe_) { + return false; + } + { + std::lock_guard lock(submission_mutex_); + shutting_down_ = true; + } + submission_cv_.notify_all(); + + bool bridge_drained = true; + if (bridge_enabled_) { + bridge_drained = aurora_vulkan_win32_disable(); + bridge_enabled_ = false; + } + if (!bridge_drained) { + AbandonAcquiredSwapchains(); + shutdown_unsafe_ = true; + Fail("Vulkan queue completion is unknown; retaining the OpenXR session and graphics owners"); + return false; + } + + // A queue-tail fence proved that no bridge command can still reference + // an acquired image. This also makes a conservatively abandoned image + // releasable after an earlier submission failure. + AllowAcquiredSwapchainsAfterGpuDrain(); + ReleaseAcquiredSwapchains(); + if (frame_active_ && runtime_ != nullptr) { + // Shutdown is required to run on the XR owner thread after Aurora's + // worker is idle, so it is safe to close an abandoned frame here. + if (runtime_->IsSessionRunning()) { + runtime_->EndFrameWithoutLayers(active_frame_); + } + frame_active_ = false; + active_frame_serial_ = 0; + active_frame_ = {}; + } + DestroySwapchains(); + pending_packet_serial_ = 0; + last_display_period_ = 0; + if (owns_session_ && runtime_ != nullptr) { + runtime_->DestroySession(); + owns_session_ = false; + } + if (runtime_) runtime_->SetGraphicsQueueGuard(nullptr, nullptr); + if (hooks_installed_) { aurora_vulkan_win32_configure(nullptr); hooks_installed_ = false; } + vk_instance_ = VK_NULL_HANDLE; + instance_api_version_ = 0; + timeline_semaphores_ = false; + bound_ = false; + requirements_queried_ = false; + runtime_ = nullptr; + return true; + } + + bool IsBound() const { return bound_; } + const OpenXRWindowsVulkanGraphicsRequirements& GraphicsRequirements() const { return requirements_; } + int64_t SwapchainFormat() const { return static_cast(swapchain_format_); } + const std::string& LastError() const { return last_error_; } + +private: + bool SelectSwapchainFormat() { + const auto& formats = runtime_->SwapchainFormats(); + // Aurora's UNORM target contains the gamma-encoded bytes expected by + // the desktop compositor. OpenXR must declare the compatible sRGB + // sibling so the headset compositor decodes those raw bytes instead + // of treating them as linear light (which appears severely washed out). + const VkFormat srgb = WindowsVkSrgbSibling(aurora_format_); + if (srgb != VK_FORMAT_UNDEFINED && + std::find(formats.begin(), formats.end(), static_cast(srgb)) != + formats.end()) { + swapchain_format_ = srgb; + return true; + } + const auto exact = std::find(formats.begin(), formats.end(), static_cast(aurora_format_)); + if (exact != formats.end()) { + swapchain_format_ = aurora_format_; + return true; + } + const auto compatible = std::find_if(formats.begin(), formats.end(), [&](int64_t format) { + return SameVkCopyFamily(aurora_format_, static_cast(format)); + }); + if (compatible == formats.end()) { + return Fail("OpenXR offered no swapchain format copy-compatible with Aurora's Vulkan color format"); + } + swapchain_format_ = static_cast(*compatible); + return true; + } + + bool CreateSwapchains() { + return CreateSwapchainPair(eye_swapchains_) && CreateSwapchainPair(retained_swapchains_); + } + + bool CreateSwapchainPair(std::array& pair) { + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + const auto& view = runtime_->ViewConfiguration()[eye]; + auto& swapchain = pair[eye]; + swapchain.width = view.render_width; + swapchain.height = view.render_height; + + XrSwapchainCreateInfo create{XR_TYPE_SWAPCHAIN_CREATE_INFO}; + create.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | + XR_SWAPCHAIN_USAGE_TRANSFER_DST_BIT; + if (IsWindowsVkSrgbFormat(swapchain_format_)) { + // Permit a UNORM view for raw copies into the sRGB XR image. + create.usageFlags |= XR_SWAPCHAIN_USAGE_MUTABLE_FORMAT_BIT; + } + create.format = static_cast(swapchain_format_); + create.sampleCount = 1; + create.width = swapchain.width; + create.height = swapchain.height; + create.faceCount = 1; + create.arraySize = 1; + create.mipCount = 1; + XrResult result = xrCreateSwapchain(runtime_->Session(), &create, &swapchain.handle); + ObserveResult(result); + if (XR_FAILED(result)) { + std::ostringstream message; + message << "xrCreateSwapchain failed for Vulkan eye " << eye << " (" << result << ')'; + return Fail(message.str()); + } + + uint32_t count = 0; + result = xrEnumerateSwapchainImages(swapchain.handle, 0, &count, nullptr); + ObserveResult(result); + if (XR_FAILED(result) || count == 0) { + return Fail("OpenXR returned no Vulkan swapchain images"); + } + swapchain.images.resize(count); + for (auto& image : swapchain.images) { + image = {XR_TYPE_SWAPCHAIN_IMAGE_VULKAN_KHR}; + } + result = xrEnumerateSwapchainImages( + swapchain.handle, count, &count, + reinterpret_cast(swapchain.images.data())); + ObserveResult(result); + if (XR_FAILED(result)) { + return Fail("xrEnumerateSwapchainImages failed for a Vulkan eye swapchain"); + } + } + return true; + } + + bool AcquireSwapchain(EyeSwapchain& swapchain) { + XrSwapchainImageAcquireInfo acquire{XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO}; + XrResult result; + { + const auto queue_guard = runtime_->LockGraphicsQueue(); + result = xrAcquireSwapchainImage(swapchain.handle, &acquire, &swapchain.acquired_index); + } + ObserveResult(result); + if (XR_FAILED(result)) { + return Fail("xrAcquireSwapchainImage failed for a Vulkan eye swapchain"); + } + swapchain.acquired = true; + swapchain.waited = false; + swapchain.release_forbidden = false; + XrSwapchainImageWaitInfo wait{XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO}; + wait.timeout = XR_INFINITE_DURATION; + // The runtime cannot access VkQueue here; let Dawn keep submitting + // while the compositor waits for this image to become available. + result = xrWaitSwapchainImage(swapchain.handle, &wait); + ObserveResult(result); + if (result == XR_TIMEOUT_EXPIRED) { + return Fail("xrWaitSwapchainImage unexpectedly timed out for a Vulkan eye swapchain"); + } + if (XR_FAILED(result)) { + return Fail("xrWaitSwapchainImage failed for a Vulkan eye swapchain"); + } + swapchain.waited = true; + if (swapchain.acquired_index >= swapchain.images.size()) { + return Fail("OpenXR returned an out-of-range Vulkan swapchain image index"); + } + return true; + } + + bool ReleaseAcquiredSwapchains() { + const auto queue_guard = runtime_ ? runtime_->LockGraphicsQueue() : OpenXRRuntime::GraphicsQueueGuard{nullptr, nullptr}; + bool success = true; + for (auto& swapchain : eye_swapchains_) { + if (!swapchain.acquired || swapchain.handle == XR_NULL_HANDLE) { + continue; + } + if (!swapchain.waited) { + // OpenXR only permits release after a successful wait. Keep the + // image acquired and let session teardown destroy the child. + success = false; + Log(OpenXRLogLevel::Warning, + "cannot release an OpenXR Vulkan image whose wait did not complete"); + continue; + } + if (swapchain.release_forbidden) { + // Aurora reported a failed submission after it may already + // have queued native Vulkan work. Without a trustworthy fence, + // xrReleaseSwapchainImage could race that work. Leave the image + // acquired and let xrDestroySession reclaim the child instead. + success = false; + Log(OpenXRLogLevel::Warning, + "deferring an OpenXR Vulkan image after an unsafe GPU submission"); + continue; + } + XrSwapchainImageReleaseInfo release{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; + const XrResult result = xrReleaseSwapchainImage(swapchain.handle, &release); + ObserveResult(result); + if (XR_FAILED(result)) { + success = false; + Fail("xrReleaseSwapchainImage failed for a Vulkan eye swapchain"); + continue; + } + swapchain.acquired = false; + swapchain.waited = false; + } + return success; + } + + void AbandonAcquiredSwapchains() noexcept { + for (auto& swapchain : eye_swapchains_) { + if (swapchain.acquired) { + swapchain.release_forbidden = true; + } + } + } + + void AllowAcquiredSwapchainsAfterGpuDrain() noexcept { + for (auto& swapchain : eye_swapchains_) { + if (swapchain.acquired) { + swapchain.release_forbidden = false; + } + } + } + + void DestroySwapchains() { + DestroySwapchainPair(eye_swapchains_); + DestroySwapchainPair(retained_swapchains_); + have_retained_frame_ = false; + retained_frame_ = {}; + swapchain_format_ = VK_FORMAT_UNDEFINED; + } + + void DestroySwapchainPair(std::array& pair) { + for (auto& swapchain : pair) { + if (swapchain.handle != XR_NULL_HANDLE && !swapchain.acquired) { + xrDestroySwapchain(swapchain.handle); + } else if (swapchain.acquired) { + Log(OpenXRLogLevel::Warning, + "Vulkan swapchain still owns an acquired image; deferring its destruction to xrDestroySession"); + } + swapchain = {}; + } + } + + void EndActiveFrameWithoutLayers(const OpenXRFrame& frame) { + if (frame_active_ && runtime_ != nullptr && runtime_->IsSessionRunning()) { + runtime_->EndFrameWithoutLayers(frame); + } + frame_active_ = false; + active_frame_serial_ = 0; + active_frame_ = {}; + } + + void ObserveResult(XrResult result) noexcept { + if (runtime_ != nullptr) { + runtime_->ObserveResult(result); + } + } + + static void OnAuroraSubmitted(uint64_t token, bool success, void* userdata) { + auto* self = static_cast(userdata); + if (self == nullptr) { + return; + } + { + std::lock_guard lock(self->submission_mutex_); + if (token != self->awaiting_token_) { + return; + } + self->submitted_token_ = token; + self->submission_success_ = success; + self->submission_arrived_ = true; + self->submission_unsafe_ = !success; + } + self->submission_cv_.notify_all(); + } + + bool Fail(std::string message) { + last_error_ = std::move(message); + Log(OpenXRLogLevel::Error, last_error_); + return false; + } + + void ClearError() { last_error_.clear(); } + + void Log(OpenXRLogLevel level, std::string_view message) const noexcept { + if (!logger_) { + return; + } + try { + logger_(level, message); + } catch (...) { + } + } + + PFN_xrCreateVulkanInstanceKHR create_instance_ = nullptr; + PFN_xrCreateVulkanDeviceKHR create_device_ = nullptr; + PFN_xrGetVulkanGraphicsDevice2KHR get_device_ = nullptr; + VkInstance vk_instance_ = VK_NULL_HANDLE; + uint32_t instance_api_version_ = 0; + bool timeline_semaphores_ = false; + bool hooks_installed_ = false; + OpenXRRuntime* runtime_ = nullptr; + OpenXRLogCallback logger_; + OpenXRWindowsVulkanGraphicsRequirements requirements_{}; + std::array eye_swapchains_{}; + std::array retained_swapchains_{}; + OpenXRWindowsVulkanFrame retained_frame_{}; + uint64_t retained_session_serial_ = 0; + uint64_t retained_space_serial_ = 0; + bool have_retained_frame_ = false; + VkFormat aurora_format_ = VK_FORMAT_UNDEFINED; + VkFormat swapchain_format_ = VK_FORMAT_UNDEFINED; + std::string last_error_; + + std::mutex submission_mutex_; + std::condition_variable submission_cv_; + uint64_t awaiting_token_ = 0; + uint64_t submitted_token_ = 0; + bool submission_arrived_ = false; + bool submission_success_ = false; + bool submission_unsafe_ = false; + bool shutting_down_ = false; + + uint64_t pending_packet_serial_ = 0; + uint64_t next_packet_serial_ = 1ull << 40; + uint64_t timing_session_serial_ = 0; + XrTime last_display_time_ = 0; + XrDuration last_display_period_ = 0; + bool last_should_render_ = false; + uint64_t active_frame_serial_ = 0; + uint64_t render_session_serial_ = 0; + uint64_t render_space_serial_ = 0; + OpenXRFrame active_frame_{}; + bool requirements_queried_ = false; + bool owns_session_ = false; + bool bridge_enabled_ = false; + bool bound_ = false; + bool frame_active_ = false; + bool shutdown_unsafe_ = false; +}; + +OpenXRWindowsVulkanBackend::OpenXRWindowsVulkanBackend(OpenXRLogCallback logger) + : m_impl(std::make_unique(std::move(logger))) {} + +OpenXRWindowsVulkanBackend::~OpenXRWindowsVulkanBackend() = default; + +bool OpenXRWindowsVulkanBackend::QueryGraphicsRequirements(OpenXRRuntime& runtime) { + return m_impl->QueryGraphicsRequirements(runtime); +} + +bool OpenXRWindowsVulkanBackend::BindAurora(OpenXRRuntime& runtime) { + return m_impl->BindAurora(runtime); +} + +OpenXRWindowsVulkanBeginStatus OpenXRWindowsVulkanBackend::BeginFrame( + const OpenXRWindowsVulkanPresentation& presentation, OpenXRWindowsVulkanFrame& frame) { + return m_impl->BeginFrame(presentation, frame); +} + +OpenXRWindowsVulkanSubmissionStatus OpenXRWindowsVulkanBackend::WaitForSubmission( + const OpenXRWindowsVulkanFrame& frame, uint32_t timeout_ms) { + return m_impl->WaitForSubmission(frame, timeout_ms); +} + +bool OpenXRWindowsVulkanBackend::TryCancelPendingFrame(OpenXRWindowsVulkanFrame& frame) { + return m_impl->TryCancelPendingFrame(frame); +} + +bool OpenXRWindowsVulkanBackend::FinishFrame(OpenXRWindowsVulkanFrame& frame, bool submit_layer) { + return m_impl->FinishFrame(frame, submit_layer); +} + +bool OpenXRWindowsVulkanBackend::RepeatFrame(const OpenXRWindowsVulkanFrame& frame) { + return m_impl->RepeatFrame(frame); +} + +OpenXRBeginStatus OpenXRWindowsVulkanBackend::PreparePacket(const OpenXRPresentation& presentation, OpenXRBackendFrame& packet) { + return m_impl->PreparePacket(presentation, packet); +} +bool OpenXRWindowsVulkanBackend::TryCancelPendingPacket(OpenXRBackendFrame& packet) { + return m_impl->TryCancelPendingPacket(packet); +} +OpenXRBeginStatus OpenXRWindowsVulkanBackend::BeginFrameForPacket(const OpenXRBackendFrame& packet, OpenXRBackendFrame& frame) { + return m_impl->BeginFrameForPacket(packet, frame); +} +OpenXRSubmissionStatus OpenXRWindowsVulkanBackend::CopyRenderedEyes(const OpenXRBackendFrame& frame) { + return m_impl->CopyRenderedEyes(frame); +} +OpenXRBeginStatus OpenXRWindowsVulkanBackend::KeepAliveCycle() { return m_impl->KeepAliveCycle(); } + +bool OpenXRWindowsVulkanBackend::Shutdown() { return m_impl->Shutdown(); } + +bool OpenXRWindowsVulkanBackend::IsBound() const { return m_impl->IsBound(); } + +const OpenXRWindowsVulkanGraphicsRequirements& OpenXRWindowsVulkanBackend::GraphicsRequirements() const { + return m_impl->GraphicsRequirements(); +} + +int64_t OpenXRWindowsVulkanBackend::SwapchainFormat() const { return m_impl->SwapchainFormat(); } + +const std::string& OpenXRWindowsVulkanBackend::LastError() const { return m_impl->LastError(); } + +} // namespace mkw::vr + +#endif // defined(MKW_ENABLE_OPENXR) && defined(_WIN32) diff --git a/runtime/tests/openxr_d3d12_replay_tests.cpp b/runtime/tests/openxr_d3d12_replay_tests.cpp index a8a0ad3..57e9aad 100644 --- a/runtime/tests/openxr_d3d12_replay_tests.cpp +++ b/runtime/tests/openxr_d3d12_replay_tests.cpp @@ -1,6 +1,23 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Exercise the real backend against a deterministic compositor and Aurora sink. // No headset, graphics driver, OpenXR loader, or translated game is required. +#if defined(TEST_WINDOWS_VULKAN) +#define XR_USE_GRAPHICS_API_VULKAN +#include +#include +#include +#include "vr/openxr_vulkan_win32.h" +#define OpenXRD3D12Backend OpenXRWindowsVulkanBackend +#define OpenXRD3D12BeginStatus OpenXRWindowsVulkanBeginStatus +#define OpenXRD3D12SubmissionStatus OpenXRWindowsVulkanSubmissionStatus +#define OpenXRD3D12Frame OpenXRWindowsVulkanFrame +#define OpenXRD3D12Presentation OpenXRWindowsVulkanPresentation +#define OpenXRD3D12FrameMode OpenXRWindowsVulkanFrameMode +#define aurora_d3d12_enable_stereo_bridge aurora_vulkan_win32_enable +#define aurora_d3d12_set_stereo_targets aurora_vulkan_win32_set_targets +#define aurora_d3d12_cancel_stereo_targets aurora_vulkan_win32_cancel +#define aurora_d3d12_disable_stereo_bridge aurora_vulkan_win32_disable +#else #define CINTERFACE #define XR_USE_GRAPHICS_API_D3D12 #ifndef NOMINMAX @@ -10,8 +27,10 @@ #include #include #include "vr/openxr_d3d12.h" +#endif #include +#include #include #include @@ -65,6 +84,26 @@ void Complete(bool success = true) { encoded = false; } +#if defined(TEST_WINDOWS_VULKAN) +AuroraDawnVulkanHooks hooks{}; +bool queue_locked = false; +XrResult XRAPI_CALL Requirements(XrInstance, XrSystemId, XrGraphicsRequirementsVulkanKHR* out) { + out->minApiVersionSupported = XR_MAKE_VERSION(1, 1, 0); + out->maxApiVersionSupported = XR_MAKE_VERSION(1, 3, 0); + return XR_SUCCESS; +} +XrResult XRAPI_CALL Physical(XrInstance, const XrVulkanGraphicsDeviceGetInfoKHR*, VkPhysicalDevice* out) { + *out = reinterpret_cast(2); return XR_SUCCESS; +} +XrResult XRAPI_CALL CreateInstance(XrInstance, const XrVulkanInstanceCreateInfoKHR* info, VkInstance* out, VkResult* result) { + Require(info->vulkanCreateInfo->pApplicationInfo->apiVersion >= VK_API_VERSION_1_1); + *out = reinterpret_cast(1); *result = VK_SUCCESS; return XR_SUCCESS; +} +XrResult XRAPI_CALL CreateDevice(XrInstance, const XrVulkanDeviceCreateInfoKHR* info, VkDevice* out, VkResult* result) { + Require(info->vulkanPhysicalDevice == reinterpret_cast(2)); + *out = reinterpret_cast(3); *result = VK_SUCCESS; return XR_SUCCESS; +} +#else HRESULT STDMETHODCALLTYPE FeatureSupport(ID3D12Device*, D3D12_FEATURE, void* data, UINT) { static_cast(data)->MaxSupportedFeatureLevel = @@ -77,10 +116,28 @@ XrResult XRAPI_CALL Requirements(XrInstance, XrSystemId, requirements->minFeatureLevel = D3D_FEATURE_LEVEL_11_0; return XR_SUCCESS; } +#endif } // A COM vtable in its C representation supplies the single device operation // used by BindAurora. The production backend is compiled normally as C++. +#if defined(TEST_WINDOWS_VULKAN) +bool aurora_vulkan_win32_configure(const AuroraDawnVulkanHooks* value) { + hooks = value ? *value : AuroraDawnVulkanHooks{}; return true; +} +bool aurora_vulkan_win32_get_handles(AuroraDawnVulkanHandles* handles, int64_t* format) { + VkApplicationInfo app{VK_STRUCTURE_TYPE_APPLICATION_INFO}; + app.apiVersion = VK_API_VERSION_1_0; + VkInstanceCreateInfo instance{VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO}; instance.pApplicationInfo = &app; + Require(hooks.createInstance(hooks.userdata, nullptr, &instance, nullptr, &handles->instance) == VK_SUCCESS); + Require(hooks.getPhysicalDevice(hooks.userdata, handles->instance, &handles->physicalDevice) == VK_SUCCESS); + VkDeviceCreateInfo device{VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO}; + Require(hooks.createDevice(hooks.userdata, nullptr, handles->physicalDevice, &device, nullptr, &handles->device) == VK_SUCCESS); + *format = VK_FORMAT_R8G8B8A8_UNORM; return true; +} +void* aurora_vulkan_win32_lock_queue() { Require(!queue_locked); queue_locked = true; return &queue_locked; } +void aurora_vulkan_win32_unlock_queue(void*) { Require(queue_locked); queue_locked = false; } +#else bool aurora_d3d12_get_native_handles(AuroraD3D12NativeHandles* handles) { static ID3D12DeviceVtbl vtable{}; vtable.CheckFeatureSupport = FeatureSupport; @@ -88,6 +145,7 @@ bool aurora_d3d12_get_native_handles(AuroraD3D12NativeHandles* handles) { *handles = {&device, &device, DXGI_FORMAT_R8G8B8A8_UNORM, 0, 0}; return true; } +#endif bool aurora_d3d12_enable_stereo_bridge(AuroraD3D12StereoSubmittedCallback cb, void* data) { callback = cb; callback_data = data; @@ -119,13 +177,21 @@ XrResult XRAPI_CALL xrCreateSwapchain(XrSession, const XrSwapchainCreateInfo*, X XrResult XRAPI_CALL xrEnumerateSwapchainImages(XrSwapchain handle, uint32_t capacity, uint32_t* count, XrSwapchainImageBaseHeader* images) { *count = 1; // Single-image swapchains also require a separate retained pair. +#if defined(TEST_WINDOWS_VULKAN) + if (capacity) reinterpret_cast(images)->image = + reinterpret_cast(&reinterpret_cast(handle)->image); +#else if (capacity) reinterpret_cast(images)->texture = reinterpret_cast(&reinterpret_cast(handle)->image); +#endif return XR_SUCCESS; } XrResult XRAPI_CALL xrAcquireSwapchainImage(XrSwapchain handle, const XrSwapchainImageAcquireInfo*, uint32_t* index) { auto& chain = *reinterpret_cast(handle); +#if defined(TEST_WINDOWS_VULKAN) + Require(queue_locked); +#endif Require(!chain.acquired); chain.acquired = true; chain.waited = false; @@ -141,6 +207,9 @@ XrResult XRAPI_CALL xrWaitSwapchainImage(XrSwapchain handle, const XrSwapchainIm XrResult XRAPI_CALL xrReleaseSwapchainImage(XrSwapchain handle, const XrSwapchainImageReleaseInfo*) { auto& chain = *reinterpret_cast(handle); +#if defined(TEST_WINDOWS_VULKAN) + Require(queue_locked); // The runtime may touch Dawn's VkQueue here. +#endif Require(chain.acquired && chain.waited); chain.acquired = false; chain.released = true; @@ -158,14 +227,24 @@ XrResult XRAPI_CALL xrDestroySwapchain(XrSwapchain handle) { namespace mkw::vr { OpenXRRuntime::OpenXRRuntime(OpenXRLogCallback) { m_instance = reinterpret_cast(this); +#if defined(TEST_WINDOWS_VULKAN) + m_swapchain_formats = {VK_FORMAT_R8G8B8A8_SRGB}; +#else m_swapchain_formats = {DXGI_FORMAT_R8G8B8A8_UNORM_SRGB}; +#endif for (auto& view : m_view_configuration) { view.render_width = 100; view.render_height = 80; } } OpenXRRuntime::~OpenXRRuntime() = default; -bool OpenXRRuntime::GetInstanceProcAddress(const char*, PFN_xrVoidFunction* out) { +bool OpenXRRuntime::GetInstanceProcAddress(const char* name, PFN_xrVoidFunction* out) { +#if defined(TEST_WINDOWS_VULKAN) + if (std::strcmp(name, "xrCreateVulkanInstanceKHR") == 0) *out = reinterpret_cast(::CreateInstance); + else if (std::strcmp(name, "xrCreateVulkanDeviceKHR") == 0) *out = reinterpret_cast(CreateDevice); + else if (std::strcmp(name, "xrGetVulkanGraphicsDevice2KHR") == 0) *out = reinterpret_cast(Physical); + else +#endif *out = reinterpret_cast(Requirements); return true; }