diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 36ed1d2..ba3d582 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -105,10 +105,16 @@ set(MKW_TRANSLATED_COMPILE_JOBS 0 CACHE STRING Scheduling only - never affects output bytes, so it is deliberately outside the canonical flag fingerprint.") set(MKW_CPPWINRT_INCLUDE_DIR "" CACHE PATH "Optional self-contained C++/WinRT include directory") -# OpenXR is compiled into supported desktop builds, but remains opt-in at -# runtime through [vr].enabled. Keeping the loader available in every package -# lets one binary fall back to desktop mode when no active runtime is present. -option(MKW_ENABLE_OPENXR "Build OpenXR VR support" ON) +# The functional backend is currently Windows D3D12, so only Windows defaults +# to carrying the loader. Linux keeps the capability-gated Vulkan scaffold +# available to explicit developer builds without making a nonfunctional SDK +# fetch part of every normal build. +set(MKW_ENABLE_OPENXR_DEFAULT OFF) +if(WIN32) + set(MKW_ENABLE_OPENXR_DEFAULT ON) +endif() +option(MKW_ENABLE_OPENXR "Build OpenXR VR support" ${MKW_ENABLE_OPENXR_DEFAULT}) +unset(MKW_ENABLE_OPENXR_DEFAULT) set(MKW_OPENXR_SDK_DIR "" CACHE PATH "Optional OpenXR-SDK source tree; when empty, use a package or fetch the pinned SDK") @@ -251,11 +257,16 @@ if(MKW_ENABLE_OPENXR) add_subdirectory("${MKW_OPENXR_SDK_DIR}" "${CMAKE_BINARY_DIR}/openxr-sdk" EXCLUDE_FROM_ALL) else() - FetchContent_Declare(mkw_openxr + # Keep the FetchContent name aligned with the installer's + # BuildWorkspace/Dependencies/openxr directory. The bundled + # build passes FETCHCONTENT_SOURCE_DIR_OPENXR and runs fully + # disconnected, while developer builds may fetch this pin. + FetchContent_Declare(openxr URL https://github.com/KhronosGroup/OpenXR-SDK/archive/refs/tags/release-1.1.61.tar.gz + URL_HASH SHA256=d5e2773d282642e6e250bd10266f316fc3ff3b8602c83db009a1f434205998e6 DOWNLOAD_EXTRACT_TIMESTAMP TRUE EXCLUDE_FROM_ALL) - FetchContent_MakeAvailable(mkw_openxr) + FetchContent_MakeAvailable(openxr) endif() endif() if(TARGET OpenXR::openxr_loader) diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h index a8e6fdf..8bf90e4 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -49,6 +49,8 @@ struct RuntimeUserConfig { std::optional vrRenderScale; std::optional vrWorldUnitsPerMeter; std::optional vrHudDistanceMeters; + std::optional vrStopAtDisplayCopy; + std::optional vrSkipCopyClears; std::optional audioVolume; std::optional audioMusicVolume; std::optional audioSoundEffectsVolume; @@ -284,7 +286,14 @@ inline void EnsureConfigFile() { "required = false\n" "render_scale = 1.0\n" "world_units_per_meter = 500.0\n" - "hud_distance_meters = 2.0\n\n" + "hud_distance_meters = 2.0\n" + "# EFB replay controls for the per-eye views, changeable live\n" + "# from the F10 menu. stop_at_display_copy ends each eye at the\n" + "# frame's final GXCopyDisp; skip_copy_clears drops the EFB\n" + "# reset a GX copy performs afterwards. Both keep that reset\n" + "# from erasing the eye, and both are safe to turn off.\n" + "stop_at_display_copy = true\n" + "skip_copy_clears = true\n\n" "[audio]\n" "volume = 1.0\n" "music_volume = 1.0\n" @@ -431,6 +440,8 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) { value && *value >= 0.25f && *value <= 10.0f) { config.vrHudDistanceMeters = *value; } + config.vrStopAtDisplayCopy = FindConfigValue(document, "vr", "stop_at_display_copy"); + config.vrSkipCopyClears = FindConfigValue(document, "vr", "skip_copy_clears"); auto readVolume = [&](std::string_view key) -> std::optional { auto value = FindConfigFloat(document, "audio", key); @@ -650,6 +661,16 @@ inline bool SetVrEnabled(bool value) { return WriteSetting("vr", "enabled", value ? "true" : "false"); } +inline bool SetVrStopAtDisplayCopy(bool value) { + Mutable().vrStopAtDisplayCopy = value; + return WriteSetting("vr", "stop_at_display_copy", value ? "true" : "false"); +} + +inline bool SetVrSkipCopyClears(bool value) { + Mutable().vrSkipCopyClears = value; + return WriteSetting("vr", "skip_copy_clears", value ? "true" : "false"); +} + inline bool SetControllerButton(size_t index, std::string value) { if (index >= kControllerButtonKeys.size()) { return false; @@ -821,6 +842,14 @@ inline float VrHudDistanceMeters(float fallback = 2.0f) { return std::clamp(Get().vrHudDistanceMeters.value_or(fallback), 0.25f, 10.0f); } +inline bool VrStopAtDisplayCopy(bool fallback = true) { + return Get().vrStopAtDisplayCopy.value_or(fallback); +} + +inline bool VrSkipCopyClears(bool fallback = true) { + return Get().vrSkipCopyClears.value_or(fallback); +} + inline std::string GraphicsApi(std::string fallback = "auto") { return Get().graphicsApi.value_or(std::move(fallback)); } diff --git a/runtime/include/vr/mkw_vr_instrumentation.h b/runtime/include/vr/mkw_vr_instrumentation.h new file mode 100644 index 0000000..16fcae7 --- /dev/null +++ b/runtime/include/vr/mkw_vr_instrumentation.h @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +struct CpuContext; + +namespace mkw::vr { + +// Announces the capabilities supplied by the translated entry observers. It +// is harmless to call repeatedly and does not enable presentation by itself. +void MkwVRInstrumentationInitialize() noexcept; + +} // namespace mkw::vr + +// Called only from translator-generated PAL RMCP01 function entries. It is a +// read-only observer, not a native replacement: the original translated body +// still runs, and the const context contract is part of translator correctness. +extern "C" void MkwVRObserveTranslatedFunctionEntry(uint32_t address, + const CpuContext* context) noexcept; diff --git a/runtime/include/vr/mkw_vr_policy.h b/runtime/include/vr/mkw_vr_policy.h index 4ff1903..8d7f799 100644 --- a/runtime/include/vr/mkw_vr_policy.h +++ b/runtime/include/vr/mkw_vr_policy.h @@ -105,6 +105,13 @@ struct MkwVRPolicySnapshot { MkwVRCameraObservation camera{}; uint32_t available_bindings = MkwVRBindingNone; bool session_active = false; + // Changes whenever the stable presentation-safety state changes. Ordinary + // per-frame scene/camera publication does not advance it. + uint64_t safety_generation = 1; + // Opaque safety-state tag for the content being sealed. This also includes + // the current presentation mode, so a transient scene/camera mismatch + // cannot accept an immersive packet from an adjacent asynchronous frame. + uint64_t content_tag = 0; }; // All policy functions are thread-safe. Publishing functions are intended for diff --git a/runtime/include/vr/openxr_d3d12.h b/runtime/include/vr/openxr_d3d12.h new file mode 100644 index 0000000..9b22462 --- /dev/null +++ b/runtime/include/vr/openxr_d3d12.h @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32) + +#include "vr/openxr_runtime.h" + +#include +#include +#include +#include + +namespace mkw::vr { + +enum class OpenXRD3D12FrameMode { + ImmersiveProjection, + VirtualScreen, +}; + +enum class OpenXRD3D12BeginStatus { + Ready, + SessionNotRunning, + ExitRequested, + Error, +}; + +enum class OpenXRD3D12SubmissionStatus { + Success, + Failed, + Timeout, + ShuttingDown, +}; + +struct OpenXRD3D12GraphicsRequirements { + uint32_t adapter_luid_low = 0; + int32_t adapter_luid_high = 0; + uint32_t minimum_feature_level = 0; +}; + +struct OpenXRD3D12Presentation { + OpenXRD3D12FrameMode mode = OpenXRD3D12FrameMode::ImmersiveProjection; + + // Used only by VirtualScreen. The quad is head-locked in XR_VIEW_SPACE + // and centered straight ahead at -Z. + float quad_distance_meters = 2.0f; + float quad_width_meters = 2.4f; +}; + +struct OpenXRD3D12Frame { + OpenXRFrame xr_frame; + OpenXRD3D12Presentation presentation; + std::array render_width{}; + std::array render_height{}; + bool expects_gpu_submission = false; +}; + +// Same-device Dawn/OpenXR D3D12 backend. +// +// Startup is deliberately split in two. QueryGraphicsRequirements() runs +// after OpenXRRuntime::Initialize() but before aurora_initialize(), allowing +// its LUID to be placed in AuroraConfig. BindAurora() runs afterwards and +// rejects any device/queue that does not match the queried runtime adapter. +// +// 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 OpenXRD3D12Backend final { +public: + explicit OpenXRD3D12Backend(OpenXRLogCallback logger = {}); + ~OpenXRD3D12Backend(); + + OpenXRD3D12Backend(const OpenXRD3D12Backend&) = delete; + OpenXRD3D12Backend& operator=(const OpenXRD3D12Backend&) = delete; + OpenXRD3D12Backend(OpenXRD3D12Backend&&) = delete; + OpenXRD3D12Backend& operator=(OpenXRD3D12Backend&&) = delete; + + bool QueryGraphicsRequirements(OpenXRRuntime& runtime); + bool BindAurora(OpenXRRuntime& runtime); + + OpenXRD3D12BeginStatus BeginFrame(const OpenXRD3D12Presentation& presentation, + OpenXRD3D12Frame& 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 must first drain/cancel Aurora, then FinishFrame(false). + OpenXRD3D12SubmissionStatus WaitForSubmission(const OpenXRD3D12Frame& 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(OpenXRD3D12Frame& 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 a head-locked + // XrCompositionLayerQuad using the single mono target. + bool FinishFrame(OpenXRD3D12Frame& frame, bool submit_layer); + + // Call on the XR owner thread after Aurora's worker is idle and before + // aurora_shutdown(). Safe to repeat. False means a submitted D3D12 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 OpenXRD3D12GraphicsRequirements& 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_integration.h b/runtime/include/vr/openxr_integration.h new file mode 100644 index 0000000..c1ca29f --- /dev/null +++ b/runtime/include/vr/openxr_integration.h @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include + +namespace mkw::vr { + +enum class OpenXRStartupResult { + Disabled, + Prepared, + Unavailable, +}; + +// Performs the OpenXR instance/system and graphics-requirements work that must +// happen before Aurora selects an adapter. On success this may force the +// backend and populate AuroraConfig's XR interop fields. +OpenXRStartupResult OpenXRPrepareAurora(AuroraConfig& config); + +// Completes the graphics binding and starts the asynchronous XR pacing thread. +// Call after aurora_initialize(), while Aurora's frame worker is idle. +bool OpenXRStartAfterAurora(AuroraBackend active_backend); + +// Stops publishing stereo work, drains the pacing thread, and destroys the XR +// session before aurora_shutdown(). Safe to call after partial initialization. +void OpenXRShutdownBeforeAurora() noexcept; + +// Services an XR-owned teardown request at the producer's safe frame boundary: +// after aurora_begin_frame() has granted the worker's prepare phase and before +// aurora_end_frame_tagged() seals the current frame. No-op unless the pacing +// thread has fallen back to desktop rendering. +void OpenXRServiceProducerFrameBoundary() noexcept; + +bool OpenXRIsRunning() noexcept; +std::string OpenXRLastError(); + +} // namespace mkw::vr diff --git a/runtime/include/vr/openxr_runtime.h b/runtime/include/vr/openxr_runtime.h index 4aa42e1..896ca1d 100644 --- a/runtime/include/vr/openxr_runtime.h +++ b/runtime/include/vr/openxr_runtime.h @@ -142,6 +142,11 @@ public: OpenXREventStatus PollEvents(); bool RequestExitSession(); + // Graphics backends call OpenXR entry points directly for swapchain work. + // Feed their results back here so positive loss-pending and negative + // session/instance-loss results update the central lifecycle state. + void ObserveResult(XrResult result) noexcept; + // Explicit frame protocol. A successful WaitFrame must be followed by // BeginFrame and EndFrame using the same token. LocateViews is optional when // should_render is false; it is otherwise normally called after BeginFrame. @@ -171,7 +176,9 @@ public: return m_session_state == XR_SESSION_STATE_VISIBLE || m_session_state == XR_SESSION_STATE_FOCUSED; } - bool ShouldExit() const { return m_exit_requested || m_instance_loss_pending; } + bool ShouldExit() const { + return m_exit_requested || m_session_loss_pending || m_instance_loss_pending; + } XrInstance Instance() const { return m_instance; } XrSystemId SystemId() const { return m_system_id; } @@ -179,6 +186,7 @@ public: XrSpace AppSpace() const { return m_app_space; } XrSpace ViewSpace() const { return m_view_space; } XrSessionState SessionState() const { return m_session_state; } + uint64_t SessionRunSerial() const { return m_session_run_serial; } XrReferenceSpaceType AppSpaceType() const { return m_app_space_type; } XrEnvironmentBlendMode EnvironmentBlendMode() const { return m_blend_mode; } @@ -194,6 +202,10 @@ public: const OpenXRReferenceSpaceChange& LastReferenceSpaceChange() const { return m_reference_space_change; } + // Consumes every queued application-space change whose effective time is + // no later than display_time. Multiple future recenter events are retained + // independently rather than overwriting one another. + bool ConsumeAppSpaceChangesThrough(XrTime display_time); const OpenXRError& LastError() const { return m_last_error; } private: @@ -212,6 +224,7 @@ private: bool EnumerateSwapchainFormats(); bool HandleSessionStateChanged(const XrEventDataSessionStateChanged& event); bool IsFrameTokenCurrent(const OpenXRFrame& frame, FramePhase expected) const; + void ResetFrameState(); void DestroyReferenceSpaces(); void ResetSessionState(); void ResetInstanceState(); @@ -237,10 +250,12 @@ private: XrEnvironmentBlendMode m_blend_mode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE; bool m_session_running = false; bool m_exit_requested = false; + bool m_session_loss_pending = false; bool m_instance_loss_pending = false; bool m_shutting_down_session = false; FramePhase m_frame_phase = FramePhase::Idle; + uint64_t m_session_run_serial = 0; uint64_t m_next_frame_serial = 1; uint64_t m_active_frame_serial = 0; XrTime m_active_frame_display_time = 0; @@ -255,6 +270,7 @@ private: std::vector m_supported_blend_modes; std::vector m_swapchain_formats; OpenXRReferenceSpaceChange m_reference_space_change; + std::vector m_pending_app_space_changes; }; } // namespace mkw::vr diff --git a/runtime/include/vr/openxr_vulkan_backend.h b/runtime/include/vr/openxr_vulkan_backend.h new file mode 100644 index 0000000..52eaf1b --- /dev/null +++ b/runtime/include/vr/openxr_vulkan_backend.h @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#if defined(MKW_ENABLE_OPENXR) + +#include "vr/openxr_runtime.h" + +#include +#include +#include +#include +#include +#include + +namespace mkw::vr { + +// Keep Vulkan implementation types out of the runtime/Aurora boundary. Vulkan +// dispatchable and non-dispatchable handles both fit in 64 bits on every target +// supported by this project. The backend converts these values to the Vulkan +// header's actual handle types only in its implementation file. +using OpenXRVulkanHandle = uint64_t; + +inline constexpr uint32_t kOpenXRVulkanNativeContextVersion = 1; + +enum OpenXRVulkanNativeContextFlagBits : uint32_t { + // The handles are the exact objects used by Aurora's Dawn device. A second + // Vulkan device is not a valid substitute: it cannot present Dawn's work to + // an OpenXR swapchain without an explicit external-memory bridge. + OpenXRVulkanNativeContextDawnOwnedBit = 1u << 0, + + // The provider has queried the queue from device/queue_family_index/ + // queue_index and verified that it equals queue. Dawn's package API does not + // currently expose enough information for this backend to do that itself. + OpenXRVulkanNativeContextQueueVerifiedBit = 1u << 1, +}; + +using OpenXRVulkanQueueLockCallback = bool (*)(void* userdata); +using OpenXRVulkanQueueUnlockCallback = void (*)(void* userdata); + +// ABI-stable context a Dawn-native shim must provide. All handles are borrowed +// and must outlive OpenXRVulkanBackend. The paired callbacks externally +// synchronize Vulkan queue access with Dawn; they are mandatory because a +// Vulkan queue is not internally synchronized and XR may use the graphics queue +// during image hand-off. +struct OpenXRVulkanNativeContext { + uint32_t version = kOpenXRVulkanNativeContextVersion; + uint32_t struct_size = sizeof(OpenXRVulkanNativeContext); + uint32_t flags = 0; + + OpenXRVulkanHandle instance = 0; + OpenXRVulkanHandle physical_device = 0; + OpenXRVulkanHandle device = 0; + OpenXRVulkanHandle queue = 0; + uint32_t queue_family_index = 0; + uint32_t queue_index = 0; + + // A VK_MAKE_API_VERSION encoded value for the Vulkan instance/device. + uint32_t api_version = 0; + + OpenXRVulkanQueueLockCallback lock_queue = nullptr; + OpenXRVulkanQueueUnlockCallback unlock_queue = nullptr; + void* queue_userdata = nullptr; +}; + +enum class OpenXRVulkanCapability { + Available, + VulkanHeadersUnavailable, + DawnNativeHandlesUnavailable, + InvalidNativeContext, + MissingEnable2Extension, + RuntimeRejectedGraphicsDevice, + RuntimeApiVersionMismatch, +}; + +struct OpenXRVulkanCapabilityInfo { + OpenXRVulkanCapability capability = OpenXRVulkanCapability::Available; + std::string reason; + + explicit operator bool() const { + return capability == OpenXRVulkanCapability::Available; + } +}; + +struct OpenXRVulkanBackendConfig { + // Rendering directly into an XR image needs COLOR_ATTACHMENT. Aurora's + // intended zero-copy bridge renders to an imported/application-owned image + // and performs a GPU copy, which additionally requires TRANSFER_DST. + bool require_transfer_destination = true; + XrDuration image_wait_timeout = XR_INFINITE_DURATION; + XrCompositionLayerFlags projection_layer_flags = 0; +}; + +struct OpenXRVulkanEyeImage { + uint32_t eye = 0; + uint32_t image_index = 0; + uint32_t width = 0; + uint32_t height = 0; + int64_t format = 0; + OpenXRVulkanHandle image = 0; +}; + +// Owns the Vulkan-bound session and its per-eye OpenXR swapchains. It does not +// record Vulkan commands: Aurora's backend bridge is responsible for rendering +// or copying into the borrowed VkImage on the supplied Dawn queue, submitting +// that GPU work, then calling ReleaseEyeImage(). No CPU/readback fallback exists. +// +// All methods must be called on the XR owner thread. Shutdown() must run before +// the native Vulkan device is destroyed. +class OpenXRVulkanBackend final { +public: + explicit OpenXRVulkanBackend(OpenXRLogCallback logger = {}); + ~OpenXRVulkanBackend(); + + OpenXRVulkanBackend(const OpenXRVulkanBackend&) = delete; + OpenXRVulkanBackend& operator=(const OpenXRVulkanBackend&) = delete; + OpenXRVulkanBackend(OpenXRVulkanBackend&&) = delete; + OpenXRVulkanBackend& operator=(OpenXRVulkanBackend&&) = delete; + + // Reports whether this build's Aurora/Dawn provider has the native Vulkan + // handle shim required by the backend. The pinned prebuilt Dawn package does + // not expose VkPhysicalDevice, VkDevice, VkQueue, or the queue-family index, + // so this intentionally reports DawnNativeHandlesUnavailable until such a + // shim is compiled in. + static OpenXRVulkanCapabilityInfo DawnInteropCapability(); + + // runtime must already be initialized with XR_KHR_vulkan_enable2 in its + // required extension list and must not yet own a session. + bool Initialize(OpenXRRuntime& runtime, + const OpenXRVulkanNativeContext& native_context, + const OpenXRVulkanBackendConfig& config = {}); + void Shutdown(); + + bool AcquireEyeImage(uint32_t eye, OpenXRVulkanEyeImage& image); + bool ReleaseEyeImage(uint32_t eye); + + // Ends a begun OpenXR frame with a stereo projection layer. The pose/FOV + // come from the same OpenXRFrame token used for rendering. When + // should_render is false or views are invalid, the frame is ended without a + // layer as required by the OpenXR frame protocol. + bool SubmitProjection(const OpenXRFrame& frame); + + bool IsInitialized() const { return m_runtime != nullptr; } + int64_t SwapchainFormat() const { return m_swapchain_format; } + const std::array& + ViewConfiguration() const; + const OpenXRError& LastError() const { return m_last_error; } + +private: + 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 ValidateNativeContext(const OpenXRVulkanNativeContext& native_context); + bool ValidateRuntimeDevice(); + bool CreateSwapchains(); + bool CreateEyeSwapchain(uint32_t eye); + bool SelectSwapchainFormat(); + void DestroySwapchains(); + + bool LockQueue(); + void UnlockQueue(); + bool Check(XrResult result, std::string_view operation); + bool Fail(XrResult result, std::string_view operation, std::string_view detail); + void Log(OpenXRLogLevel level, std::string_view message) const noexcept; + std::string ResultString(XrResult result) const; + + OpenXRRuntime* m_runtime = nullptr; + OpenXRLogCallback m_logger; + OpenXRError m_last_error; + OpenXRVulkanNativeContext m_native_context{}; + OpenXRVulkanBackendConfig m_config{}; + std::array m_eye_swapchains{}; + int64_t m_swapchain_format = 0; + bool m_owns_session = false; +}; + +} // namespace mkw::vr + +#endif // defined(MKW_ENABLE_OPENXR) diff --git a/runtime/src/hle/vi.cpp b/runtime/src/hle/vi.cpp index 52fda5c..fc5ba7b 100644 --- a/runtime/src/hle/vi.cpp +++ b/runtime/src/hle/vi.cpp @@ -7,6 +7,8 @@ #include "settings_overlay.h" #include "fiber_manager.h" #include "runtime_log.h" +#include "vr/mkw_vr_policy.h" +#include "vr/openxr_integration.h" #include @@ -333,6 +335,10 @@ void AdvanceRetrace(CpuContext* ctx, Clock::time_point retraceStamp, bool servic g_auroraFrameActive.store(true, std::memory_order_release); } } + // XR runtime loss and encoded-work stalls request producer-owned + // teardown. Service it on every retrace, including startup, pause, and + // minimized-window paths that may never reach a successful present. + mkw::vr::OpenXRServiceProducerFrameBoundary(); } if (ctx) { @@ -579,7 +585,13 @@ void VI_HLE_PresentFrame(bool presentedXfb, bool paceToRetrace) { aurora_set_present_schedule(0, 0); } - aurora_end_frame(); + mkw::vr::OpenXRServiceProducerFrameBoundary(); + // Latch the current policy safety state into this exact Aurora job. The + // asynchronous worker may ask for an XR packet after the guest has already + // begun the next frame, so immersive replay is accepted only when both + // tags match. + const uint64_t vrContentTag = mkw::vr::MkwVRPolicyGetSnapshot().content_tag; + aurora_end_frame_tagged(vrContentTag); if (paceThisFrame) { PaceToRetraceBoundary(paceDeadline); std::lock_guard lock(g_viMutex); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 316220e..37e4859 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -55,6 +55,7 @@ #include "runtime_log.h" #include "runtime_product.h" #include "recomp_mod_loader.h" +#include "vr/openxr_integration.h" #include #include #include @@ -1362,6 +1363,16 @@ int RuntimeMain(int argc, char** argv) { break; } } + const mkw::vr::OpenXRStartupResult openxrStartup = + mkw::vr::OpenXRPrepareAurora(auroraConfig); + if (openxrStartup == mkw::vr::OpenXRStartupResult::Unavailable) { + const std::string error = mkw::vr::OpenXRLastError(); + if (RuntimeConfigFile::VrRequired(false)) { + throw std::runtime_error("OpenXR is required but unavailable: " + error); + } + RT_LOG(RT_TAG_RUNTIME) << "OpenXR unavailable; continuing with desktop rendering: " + << error << std::endl; + } const AuroraBackend requestedBackend = auroraConfig.desiredBackend; const AuroraInfo auroraInfo = aurora_initialize(0, nullptr, &auroraConfig); @@ -1391,6 +1402,17 @@ int RuntimeMain(int argc, char** argv) { << std::endl; g_auroraInitialized.store(true, std::memory_order_release); + if (openxrStartup == mkw::vr::OpenXRStartupResult::Prepared && + !mkw::vr::OpenXRStartAfterAurora(auroraInfo.backend)) { + const std::string error = mkw::vr::OpenXRLastError(); + if (RuntimeConfigFile::VrRequired(false)) { + throw std::runtime_error("OpenXR graphics binding failed: " + error); + } + RT_LOG(RT_TAG_RUNTIME) + << "OpenXR graphics binding failed; continuing with the desktop mirror: " + << error << std::endl; + } + auto entry = ResolveEntry(); InitializePersistentCpuContext(); auto& cpu = GetPersistentCpuContext(); @@ -1417,6 +1439,7 @@ int RuntimeMain(int argc, char** argv) { // Shutdown fiber system Fiber::GuestFiberManager::Shutdown(); WindowPlacementPersistence::Flush(true); + mkw::vr::OpenXRShutdownBeforeAurora(); aurora_shutdown(); SetRuntimeExitCodeImpl(0); ShutdownProcessTranscript(); @@ -1434,6 +1457,7 @@ int RuntimeMain(int argc, char** argv) { SetRuntimeExitCodeImpl(1); Fiber::GuestFiberManager::Shutdown(); WindowPlacementPersistence::Flush(true); + mkw::vr::OpenXRShutdownBeforeAurora(); aurora_shutdown(); ShutdownProcessTranscript(); return 1; @@ -1445,6 +1469,7 @@ int RuntimeMain(int argc, char** argv) { SetRuntimeExitCodeImpl(1); Fiber::GuestFiberManager::Shutdown(); WindowPlacementPersistence::Flush(true); + mkw::vr::OpenXRShutdownBeforeAurora(); aurora_shutdown(); ShutdownProcessTranscript(); return 1; diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index d88d206..8795ebd 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -99,6 +99,8 @@ bool g_skipUnreadyPipelines = RuntimeConfigFile::SkipUnreadyPipelines(true); bool g_disableCopyFilter = RuntimeConfigFile::DisableCopyFilter(true); bool g_showFps = RuntimeConfigFile::ShowFps(true); bool g_vrEnabled = RuntimeConfigFile::VrEnabled(false); +bool g_vrStopAtDisplayCopy = RuntimeConfigFile::VrStopAtDisplayCopy(true); +bool g_vrSkipCopyClears = RuntimeConfigFile::VrSkipCopyClears(true); uint32_t g_disabledPostProcessingPaths = RuntimeConfigFile::DisabledPostProcessingPaths(0); std::array g_configuredControllerIndices = [] { std::array indices{}; @@ -650,6 +652,35 @@ void DrawGraphicsSettings() { RuntimeConfigFile::SetVrEnabled(g_vrEnabled); } ImGui::TextDisabled("OpenXR mode changes take effect after restarting the game."); + + // Unlike the toggle above, these two apply to the very next frame, so they + // can be compared against each other from inside a running race. + ImGui::Separator(); + ImGui::Text("VR eye replay (EFB)"); + if (ImGui::Checkbox("Stop eye at display copy", &g_vrStopAtDisplayCopy)) { + aurora_set_stereo_stop_at_display_copy(g_vrStopAtDisplayCopy); + RuntimeConfigFile::SetVrStopAtDisplayCopy(g_vrStopAtDisplayCopy); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip( + "Ends each eye at the frame's final GXCopyDisp, so an eye holds exactly the " + "image the game presented. Turn off to replay the whole pass list."); + } + if (ImGui::Checkbox("Skip EFB copy clears", &g_vrSkipCopyClears)) { + aurora_set_stereo_skip_copy_clears(g_vrSkipCopyClears); + RuntimeConfigFile::SetVrSkipCopyClears(g_vrSkipCopyClears); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip( + "Drops the EFB reset a GX copy performs after copying. That reset prepares the " + "Wii's reused EFB for the next frame; an eye attachment is built fresh, so " + "replaying it only erases the eye."); + } + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f); + ImGui::TextDisabled( + "Both apply on the next frame. Turning either off restores the raw replay and is " + "expected to black out the eyes."); + ImGui::PopTextWrapPos(); } void DrawFpsOverlay() { @@ -867,6 +898,8 @@ void InitializeRuntimeSettings() noexcept { aurora_set_display_mode(static_cast(g_displayMode)); g_displayMode = static_cast(aurora_get_display_mode()); aurora_set_disable_copy_filter(g_disableCopyFilter); + aurora_set_stereo_stop_at_display_copy(g_vrStopAtDisplayCopy); + aurora_set_stereo_skip_copy_clears(g_vrSkipCopyClears); aurora_set_skip_unready_pipelines(g_skipUnreadyPipelines); g_strapInputAccepted.store(false, std::memory_order_relaxed); g_startupDismissFrame.store(UINT64_MAX, std::memory_order_relaxed); diff --git a/runtime/src/vr/mkw_vr_instrumentation.cpp b/runtime/src/vr/mkw_vr_instrumentation.cpp new file mode 100644 index 0000000..941bb2a --- /dev/null +++ b/runtime/src/vr/mkw_vr_instrumentation.cpp @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "vr/mkw_vr_instrumentation.h" + +#include "memory.h" +#include "ppc_runtime.h" +#include "runtime_log.h" +#include "vr/mkw_vr_policy.h" + +#include +#include +#include +#include + +extern "C" int g_gxFrameCount; + +namespace mkw::vr { +namespace { + +constexpr uint32_t kRaceSceneOnEnter = 0x80553C50u; +constexpr uint32_t kRaceSceneOnExit = 0x805549B0u; +constexpr uint32_t kRaceCameraUpdate = 0x805A21D0u; +constexpr uint32_t kScnMgrRaceDraw = 0x805B1CD8u; +// PAL RMCP01 RaceScene::GetScreenCount (0x80554F68) reads the active +// RaceScene pointer from this SDA-backed slot, then returns byte 0x25. Keep +// this synchronized with projects/mkwii/MAP.txt and the generated function. +constexpr uint32_t kRaceSceneInstanceAddress = 0x809BD728u; +constexpr uint32_t kRaceSceneScreenCountOffset = 0x25u; + +struct InstrumentationState { + uint64_t guest_frame = 0; + std::array cameras{}; + uint32_t camera_count = 0; + uint32_t last_reported_camera_count = UINT32_MAX; + uint32_t last_reported_screen_count = UINT32_MAX; +}; + +std::mutex g_instrumentation_mutex; +InstrumentationState g_instrumentation; + +uint64_t GuestFrame() noexcept { + return static_cast(static_cast(g_gxFrameCount)); +} + +void ResetCamerasForFrameLocked(uint64_t frame) noexcept { + if (g_instrumentation.guest_frame == frame) { + return; + } + g_instrumentation.guest_frame = frame; + g_instrumentation.cameras = {}; + g_instrumentation.camera_count = 0; +} + +uint32_t ObserveCamera(uint64_t frame, uint32_t camera) noexcept { + std::lock_guard lock(g_instrumentation_mutex); + ResetCamerasForFrameLocked(frame); + const auto first = g_instrumentation.cameras.begin(); + const auto last = first + g_instrumentation.camera_count; + if (camera != 0 && std::find(first, last, camera) == last && + g_instrumentation.camera_count < g_instrumentation.cameras.size()) { + g_instrumentation.cameras[g_instrumentation.camera_count++] = camera; + } + return g_instrumentation.camera_count; +} + +uint32_t CameraCount(uint64_t frame) noexcept { + std::lock_guard lock(g_instrumentation_mutex); + ResetCamerasForFrameLocked(frame); + return g_instrumentation.camera_count; +} + +uint32_t RaceScreenCount() noexcept { + uint32_t race_scene = 0; + if (!Memory::TryRead32(kRaceSceneInstanceAddress, race_scene) || race_scene == 0 || + !Memory::Contains(race_scene + kRaceSceneScreenCountOffset)) { + return 0; + } + + try { + const uint32_t screen_count = + Memory::Read8(race_scene + kRaceSceneScreenCountOffset); + return screen_count <= 4 ? screen_count : 0; + } catch (const Memory::AccessViolation&) { + return 0; + } +} + +void LogRaceEvidenceIfChanged(uint64_t frame, uint32_t screen_count, + uint32_t camera_count) noexcept { + bool changed = false; + { + std::lock_guard lock(g_instrumentation_mutex); + changed = g_instrumentation.last_reported_screen_count != screen_count || + g_instrumentation.last_reported_camera_count != camera_count; + g_instrumentation.last_reported_screen_count = screen_count; + g_instrumentation.last_reported_camera_count = camera_count; + } + if (changed) { + RT_LOG(RT_TAG_RUNTIME) << "[mkw-vr] race evidence: frame=" << frame + << ", screens=" << screen_count + << ", updated cameras=" << camera_count << std::endl; + } +} + +void PublishRaceScene(uint64_t frame, uint32_t local_players) noexcept { + MkwVRSceneObservation scene{}; + scene.mode = VRSceneMode::Race; + scene.local_player_count = local_players; + scene.guest_frame_index = frame; + MkwVRPolicyPublishScene(scene); +} + +void PublishObservedCamera(uint64_t frame, uint32_t address) noexcept { + // Aurora's stereo replay composes the OpenXR eye transform with the GX + // matrices recorded by the original game. The policy only needs evidence + // that the RaceCamera path ran; it never consumes this identity sample to + // replace the game's view matrix. + MkwVRCameraObservation camera{}; + camera.view_from_world = { + 1.0f, 0.0f, 0.0f, 0.0f, + 0.0f, 1.0f, 0.0f, 0.0f, + 0.0f, 0.0f, 1.0f, 0.0f, + }; + camera.guest_camera_address = address; + camera.guest_frame_index = frame; + camera.valid = address != 0; + MkwVRPolicyPublishRaceCamera(camera); +} + +} // namespace + +void MkwVRInstrumentationInitialize() noexcept { + MkwVRPolicySetAvailableBindings( + MkwVRBindingSceneState | MkwVRBindingRaceCamera | + MkwVRBindingDrawClassification); +} + +} // namespace mkw::vr + +extern "C" void MkwVRObserveTranslatedFunctionEntry(uint32_t address, + const CpuContext* context) noexcept { + using namespace mkw::vr; + const uint64_t frame = GuestFrame(); + switch (address) { + case kRaceSceneOnEnter: { + { + std::lock_guard lock(g_instrumentation_mutex); + g_instrumentation = {}; + g_instrumentation.guest_frame = frame; + } + PublishRaceScene(frame, 0); + MkwVRPolicyInvalidateRaceCamera(); + RT_LOG(RT_TAG_RUNTIME) << "[mkw-vr] entered RaceScene at frame " << frame + << std::endl; + break; + } + case kRaceCameraUpdate: { + const uint32_t camera_address = context != nullptr ? context->gpr[3] : 0; + ObserveCamera(frame, camera_address); + PublishObservedCamera(frame, camera_address); + break; + } + case kScnMgrRaceDraw: { + // RaceCamera objects are not a player-count source: the game may update + // additional cameras for transitions and effects. Use the same exact + // screen count as RaceScene::GetScreenCount, while retaining the camera + // hook as independent evidence that a usable race camera is current. + const uint32_t camera_count = CameraCount(frame); + const uint32_t screen_count = RaceScreenCount(); + LogRaceEvidenceIfChanged(frame, screen_count, camera_count); + PublishRaceScene(frame, screen_count); + break; + } + case kRaceSceneOnExit: { + MkwVRSceneObservation scene{}; + scene.mode = VRSceneMode::Other; + scene.guest_frame_index = frame; + MkwVRPolicyPublishScene(scene); + MkwVRPolicyInvalidateRaceCamera(); + RT_LOG(RT_TAG_RUNTIME) << "[mkw-vr] exited RaceScene at frame " << frame + << std::endl; + break; + } + default: + break; + } +} diff --git a/runtime/src/vr/mkw_vr_policy.cpp b/runtime/src/vr/mkw_vr_policy.cpp index 21542b7..d535935 100644 --- a/runtime/src/vr/mkw_vr_policy.cpp +++ b/runtime/src/vr/mkw_vr_policy.cpp @@ -17,6 +17,7 @@ struct PolicyState { MkwVRCameraObservation camera{}; uint32_t available_bindings = MkwVRBindingNone; bool session_active = false; + uint64_t safety_generation = 1; }; std::mutex g_policy_mutex; @@ -61,13 +62,45 @@ bool IsFiniteCamera(const MkwVRCameraObservation& camera) noexcept { [](const float& value) { return IsFiniteFloat(&value); }); } +bool ObservationsAreCoherent(const MkwVRSceneObservation& scene, + const MkwVRCameraObservation& camera) noexcept { + // RaceCamera::Update for guest frame N+1 can run before ScnMgrRace::Draw + // publishes the scene observation for that frame. The OpenXR pacing thread + // is independent and can legitimately sample that short interval. Accept + // adjacent frames from the same live RaceScene; larger gaps still fail + // closed, and scene transitions explicitly invalidate the camera. + const uint64_t newer = std::max(scene.guest_frame_index, camera.guest_frame_index); + const uint64_t older = std::min(scene.guest_frame_index, camera.guest_frame_index); + return newer - older <= 1; +} + VRPresentationMode SelectPresentation(const PolicyState& state) noexcept { if (!state.config.enabled || !state.session_active) { return VRPresentationMode::Desktop; } - // A virtual screen is the fail-safe for menus, replays, split-screen, and - // any incomplete instrumentation. It preserves the unmodified render path. + // A virtual screen is the fail-safe for menus, split-screen, and any + // incomplete instrumentation. It preserves the unmodified render path. + if ((state.available_bindings & kMkwVRRequiredImmersiveBindings) != + kMkwVRRequiredImmersiveBindings || + !state.config.immersive_races || state.scene.mode != VRSceneMode::Race || + state.scene.local_player_count != 1 || !IsFiniteCamera(state.camera) || + !ObservationsAreCoherent(state.scene, state.camera)) { + return VRPresentationMode::VirtualScreen; + } + + return VRPresentationMode::ImmersiveRace; +} + +VRPresentationMode SelectStablePresentation(const PolicyState& state) noexcept { + if (!state.config.enabled || !state.session_active) { + return VRPresentationMode::Desktop; + } + + // Deliberately omit the per-frame scene/camera index comparison here. + // Those observations are published by separate translated callbacks, so + // their temporary mismatch is represented in content_tag's mode bits + // without advancing the generation twice on every healthy race frame. if ((state.available_bindings & kMkwVRRequiredImmersiveBindings) != kMkwVRRequiredImmersiveBindings || !state.config.immersive_races || state.scene.mode != VRSceneMode::Race || @@ -78,6 +111,32 @@ VRPresentationMode SelectPresentation(const PolicyState& state) noexcept { return VRPresentationMode::ImmersiveRace; } +void AdvanceSafetyGeneration(PolicyState& state) noexcept { + // Two low bits are reserved for VRPresentationMode in MakeContentTag(). + // Keep UINT64_MAX reserved as Aurora's explicit unknown-tag sentinel. + constexpr uint64_t kMaxSafetyGeneration = (UINT64_MAX >> 2) - 1; + if (state.safety_generation >= kMaxSafetyGeneration) { + state.safety_generation = 1; + } else { + ++state.safety_generation; + } +} + +template +void ApplyPolicyMutation(Mutation&& mutation) noexcept { + const VRPresentationMode previous = SelectStablePresentation(g_policy); + mutation(); + if (SelectStablePresentation(g_policy) != previous) { + AdvanceSafetyGeneration(g_policy); + } +} + +uint64_t MakeContentTag(const PolicyState& state, VRPresentationMode presentation) noexcept { + static_assert(static_cast(VRPresentationMode::ImmersiveRace) < 4, + "VRPresentationMode must fit in the content tag's reserved bits"); + return (state.safety_generation << 2) | static_cast(presentation); +} + constexpr MkwVRHookPoint kHookPoints[] = { {0x80562B34u, "ScnMgr::UpdateCameras", MkwVRHookCapability::SceneState, "Observe the active scene camera update boundary."}, @@ -130,39 +189,43 @@ void MkwVRPolicyReset() noexcept { void MkwVRPolicyConfigure(const MkwVRPolicyConfig& config) noexcept { std::lock_guard lock(g_policy_mutex); - g_policy.config = SanitizeConfig(config); + ApplyPolicyMutation([&] { g_policy.config = SanitizeConfig(config); }); } void MkwVRPolicySetSessionActive(bool active) noexcept { std::lock_guard lock(g_policy_mutex); - g_policy.session_active = active; + ApplyPolicyMutation([&] { g_policy.session_active = active; }); } void MkwVRPolicySetAvailableBindings(uint32_t bindings) noexcept { std::lock_guard lock(g_policy_mutex); - g_policy.available_bindings = bindings; + ApplyPolicyMutation([&] { g_policy.available_bindings = bindings; }); } void MkwVRPolicyPublishScene(const MkwVRSceneObservation& scene) noexcept { std::lock_guard lock(g_policy_mutex); - if (scene.mode != VRSceneMode::Race || g_policy.scene.mode != VRSceneMode::Race) { - // Never carry a camera sample across a menu/replay-to-race transition. - // A fresh RaceCamera observation must arrive before immersive mode can - // become active again. - g_policy.camera.valid = false; - } - g_policy.scene = scene; + ApplyPolicyMutation([&] { + if (scene.mode != VRSceneMode::Race || g_policy.scene.mode != VRSceneMode::Race) { + // Never carry a camera sample across a menu/replay-to-race transition. + // A fresh RaceCamera observation must arrive before immersive mode can + // become active again. + g_policy.camera.valid = false; + } + g_policy.scene = scene; + }); } void MkwVRPolicyPublishRaceCamera(const MkwVRCameraObservation& camera) noexcept { std::lock_guard lock(g_policy_mutex); - g_policy.camera = camera; - g_policy.camera.valid = IsFiniteCamera(camera); + ApplyPolicyMutation([&] { + g_policy.camera = camera; + g_policy.camera.valid = IsFiniteCamera(camera); + }); } void MkwVRPolicyInvalidateRaceCamera() noexcept { std::lock_guard lock(g_policy_mutex); - g_policy.camera.valid = false; + ApplyPolicyMutation([&] { g_policy.camera.valid = false; }); } MkwVRPolicySnapshot MkwVRPolicyGetSnapshot() noexcept { @@ -174,6 +237,8 @@ MkwVRPolicySnapshot MkwVRPolicyGetSnapshot() noexcept { snapshot.camera = g_policy.camera; snapshot.available_bindings = g_policy.available_bindings; snapshot.session_active = g_policy.session_active; + snapshot.safety_generation = g_policy.safety_generation; + snapshot.content_tag = MakeContentTag(g_policy, snapshot.presentation); return snapshot; } diff --git a/runtime/src/vr/openxr_d3d12.cpp b/runtime/src/vr/openxr_d3d12.cpp new file mode 100644 index 0000000..93badea --- /dev/null +++ b/runtime/src/vr/openxr_d3d12.cpp @@ -0,0 +1,813 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32) + +// OpenXR's D3D12 structures are selected when openxr_platform.h is parsed. +#define XR_USE_GRAPHICS_API_D3D12 +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include "vr/openxr_d3d12.h" + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mkw::vr { +namespace { + +bool SameDxgiCopyFamily(DXGI_FORMAT left, DXGI_FORMAT right) noexcept { + const auto family = [](DXGI_FORMAT format) noexcept { + switch (format) { + case DXGI_FORMAT_R8G8B8A8_TYPELESS: + case DXGI_FORMAT_R8G8B8A8_UNORM: + case DXGI_FORMAT_R8G8B8A8_UNORM_SRGB: + return 1; + case DXGI_FORMAT_B8G8R8A8_TYPELESS: + case DXGI_FORMAT_B8G8R8A8_UNORM: + case DXGI_FORMAT_B8G8R8A8_UNORM_SRGB: + return 2; + case DXGI_FORMAT_R16G16B16A16_TYPELESS: + case DXGI_FORMAT_R16G16B16A16_FLOAT: + return 3; + default: + return 0; + } + }; + const int left_family = family(left); + return left_family != 0 && left_family == family(right); +} + +DXGI_FORMAT SrgbSibling(DXGI_FORMAT format) noexcept { + switch (format) { + case DXGI_FORMAT_R8G8B8A8_TYPELESS: + case DXGI_FORMAT_R8G8B8A8_UNORM: + case DXGI_FORMAT_R8G8B8A8_UNORM_SRGB: + return DXGI_FORMAT_R8G8B8A8_UNORM_SRGB; + case DXGI_FORMAT_B8G8R8A8_TYPELESS: + case DXGI_FORMAT_B8G8R8A8_UNORM: + case DXGI_FORMAT_B8G8R8A8_UNORM_SRGB: + return DXGI_FORMAT_B8G8R8A8_UNORM_SRGB; + default: + return DXGI_FORMAT_UNKNOWN; + } +} + +bool IsSrgbFormat(DXGI_FORMAT format) noexcept { + return format == DXGI_FORMAT_R8G8B8A8_UNORM_SRGB || + format == DXGI_FORMAT_B8G8R8A8_UNORM_SRGB; +} + +const char* BeginStatusOperation(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 OpenXRD3D12Backend::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; + }; + + bool QueryGraphicsRequirements(OpenXRRuntime& runtime) { + ClearError(); + if (!runtime.IsInitialized() || runtime.HasSession()) { + return Fail("OpenXR must own an instance, but no session, before querying D3D12 requirements"); + } + if (requirements_queried_ && runtime_ != &runtime) { + return Fail("D3D12 graphics requirements were already queried from another OpenXR instance"); + } + + PFN_xrGetD3D12GraphicsRequirementsKHR get_requirements = nullptr; + if (!runtime.LoadFunction("xrGetD3D12GraphicsRequirementsKHR", &get_requirements) || + get_requirements == nullptr) { + return Fail("OpenXR runtime did not expose xrGetD3D12GraphicsRequirementsKHR"); + } + + XrGraphicsRequirementsD3D12KHR requirements{XR_TYPE_GRAPHICS_REQUIREMENTS_D3D12_KHR}; + const XrResult result = get_requirements(runtime.Instance(), runtime.SystemId(), &requirements); + runtime.ObserveResult(result); + if (XR_FAILED(result)) { + std::ostringstream message; + message << "xrGetD3D12GraphicsRequirementsKHR failed (" << result << ')'; + return Fail(message.str()); + } + + runtime_ = &runtime; + { + std::lock_guard lock(submission_mutex_); + shutting_down_ = false; + submission_unsafe_ = false; + } + requirements_ = { + requirements.adapterLuid.LowPart, + requirements.adapterLuid.HighPart, + static_cast(requirements.minFeatureLevel), + }; + requirements_queried_ = true; + + std::ostringstream message; + message << "OpenXR D3D12 adapter LUID " << std::hex + << static_cast(requirements_.adapter_luid_high) << ':' + << requirements_.adapter_luid_low << ", minimum feature level 0x" + << requirements_.minimum_feature_level; + 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 D3D12 backend is already bound"); + } + + AuroraD3D12NativeHandles handles{}; + if (!aurora_d3d12_get_native_handles(&handles) || handles.device == nullptr || + handles.queue == nullptr) { + return Fail("Aurora did not expose a Dawn D3D12 device and command queue"); + } + if (handles.adapterLuidLow != requirements_.adapter_luid_low || + handles.adapterLuidHigh != requirements_.adapter_luid_high) { + std::ostringstream message; + message << "Aurora selected D3D12 adapter " << std::hex + << static_cast(handles.adapterLuidHigh) << ':' + << handles.adapterLuidLow << ", but OpenXR requires " + << static_cast(requirements_.adapter_luid_high) << ':' + << requirements_.adapter_luid_low; + return Fail(message.str()); + } + + auto* device = static_cast(handles.device); + const D3D_FEATURE_LEVEL minimum = + static_cast(requirements_.minimum_feature_level); + D3D12_FEATURE_DATA_FEATURE_LEVELS levels{1, &minimum, D3D_FEATURE_LEVEL_1_0_CORE}; + if (FAILED(device->CheckFeatureSupport(D3D12_FEATURE_FEATURE_LEVELS, &levels, + sizeof(levels))) || + levels.MaxSupportedFeatureLevel < minimum) { + return Fail("Aurora's D3D12 device does not satisfy the OpenXR minimum feature level"); + } + + XrGraphicsBindingD3D12KHR binding{XR_TYPE_GRAPHICS_BINDING_D3D12_KHR}; + binding.device = device; + binding.queue = static_cast(handles.queue); + if (!runtime.CreateSession(&binding)) { + return Fail("OpenXR rejected Aurora's D3D12 device/queue binding"); + } + owns_session_ = true; + aurora_format_ = static_cast(handles.colorDxgiFormat); + + if (!SelectSwapchainFormat() || !CreateSwapchains()) { + DestroySwapchains(); + runtime.DestroySession(); + owns_session_ = false; + return false; + } + if (runtime.ShouldExit()) { + DestroySwapchains(); + runtime.DestroySession(); + owns_session_ = false; + return Fail("OpenXR session became loss-pending while creating D3D12 swapchains"); + } + if (!aurora_d3d12_enable_stereo_bridge(&Impl::OnAuroraSubmitted, this)) { + DestroySwapchains(); + runtime.DestroySession(); + owns_session_ = false; + return Fail("Aurora could not enable its zero-readback D3D12 stereo bridge"); + } + bridge_enabled_ = true; + bound_ = true; + + std::ostringstream message; + message << "OpenXR D3D12 swapchains ready: DXGI format " + << static_cast(swapchain_format_) << ", eyes " + << eye_swapchains_[0].width << 'x' << eye_swapchains_[0].height << " / " + << eye_swapchains_[1].width << 'x' << eye_swapchains_[1].height; + Log(OpenXRLogLevel::Info, message.str()); + return true; + } + + OpenXRD3D12BeginStatus BeginFrame(const OpenXRD3D12Presentation& presentation, + OpenXRD3D12Frame& frame) { + frame = {}; + frame.presentation = presentation; + if (!bound_ || runtime_ == nullptr) { + Fail("BeginFrame called before the D3D12 backend was bound"); + return OpenXRD3D12BeginStatus::Error; + } + if (frame_active_) { + Fail("BeginFrame called while another OpenXR frame is active"); + return OpenXRD3D12BeginStatus::Error; + } + + const OpenXRFrameStatus status = runtime_->WaitFrame(frame.xr_frame); + if (status != OpenXRFrameStatus::Ready) { + if (status == OpenXRFrameStatus::Error) { + Fail(BeginStatusOperation(status)); + } + switch (status) { + case OpenXRFrameStatus::SessionNotRunning: + return OpenXRD3D12BeginStatus::SessionNotRunning; + case OpenXRFrameStatus::ExitRequested: + return OpenXRD3D12BeginStatus::ExitRequested; + case OpenXRFrameStatus::Error: + return OpenXRD3D12BeginStatus::Error; + case OpenXRFrameStatus::Ready: + break; + } + } + if (!runtime_->BeginFrame(frame.xr_frame)) { + Fail("xrBeginFrame failed"); + return OpenXRD3D12BeginStatus::Error; + } + frame_active_ = true; + active_frame_serial_ = frame.xr_frame.serial; + active_frame_ = frame.xr_frame; + + 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 OpenXRD3D12BeginStatus::Ready; + } + if (!runtime_->LocateViews(frame.xr_frame)) { + Fail("xrLocateViews failed"); + EndActiveFrameWithoutLayers(frame.xr_frame); + return OpenXRD3D12BeginStatus::Error; + } + active_frame_ = frame.xr_frame; + if (!frame.xr_frame.views_valid) { + return OpenXRD3D12BeginStatus::Ready; + } + + const uint32_t target_count = + presentation.mode == OpenXRD3D12FrameMode::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{}; + for (uint32_t eye = 0; eye < target_count; ++eye) { + auto& swapchain = eye_swapchains_[eye]; + if (!AcquireSwapchain(swapchain)) { + ReleaseAcquiredSwapchains(); + EndActiveFrameWithoutLayers(frame.xr_frame); + return OpenXRD3D12BeginStatus::Error; + } + targets[eye] = { + swapchain.images[swapchain.acquired_index].texture, + swapchain.width, + swapchain.height, + static_cast(swapchain_format_), + }; + } + + { + 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 (!aurora_d3d12_set_stereo_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 D3D12 swapchain target"); + EndActiveFrameWithoutLayers(frame.xr_frame); + return OpenXRD3D12BeginStatus::Error; + } + frame.expects_gpu_submission = true; + return OpenXRD3D12BeginStatus::Ready; + } + + OpenXRD3D12SubmissionStatus WaitForSubmission(const OpenXRD3D12Frame& frame, + uint32_t timeout_ms) { + if (!frame.expects_gpu_submission) { + return OpenXRD3D12SubmissionStatus::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 OpenXRD3D12SubmissionStatus::Timeout; + } + if (shutting_down_) { + return OpenXRD3D12SubmissionStatus::ShuttingDown; + } + return submission_success_ ? OpenXRD3D12SubmissionStatus::Success + : OpenXRD3D12SubmissionStatus::Failed; + } + + bool TryCancelPendingFrame(OpenXRD3D12Frame& frame) { + if (!frame_active_ || !frame.expects_gpu_submission || + frame.xr_frame.serial != active_frame_serial_) { + return false; + } + if (!aurora_d3d12_cancel_stereo_targets(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(OpenXRD3D12Frame& 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 D3D12 stereo submission failed after GPU work may have been queued"); + } + bool release_ok = ReleaseAcquiredSwapchains(); + 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 == OpenXRD3D12FrameMode::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; + bool end_ok = false; + 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. + end_ok = true; + } else if (!can_submit) { + end_ok = runtime_->EndFrameWithoutLayers(frame.xr_frame); + } else if (frame.presentation.mode == OpenXRD3D12FrameMode::VirtualScreen) { + XrCompositionLayerQuad quad{XR_TYPE_COMPOSITION_LAYER_QUAD}; + quad.layerFlags = 0; + quad.space = runtime_->ViewSpace(); + quad.eyeVisibility = XR_EYE_VISIBILITY_BOTH; + quad.subImage.swapchain = eye_swapchains_[0].handle; + quad.subImage.imageRect = {{0, 0}, + {static_cast(eye_swapchains_[0].width), + static_cast(eye_swapchains_[0].height)}}; + quad.subImage.imageArrayIndex = 0; + 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(eye_swapchains_[0].height) / + static_cast(eye_swapchains_[0].width); + const XrCompositionLayerBaseHeader* layers[] = { + reinterpret_cast(&quad)}; + end_ok = runtime_->EndFrame(frame.xr_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 = position_valid + ? frame.xr_frame.views[eye].pose.position + : XrVector3f{0.0f, 0.0f, 0.0f}; + views[eye].fov = frame.xr_frame.views[eye].fov; + views[eye].subImage.swapchain = eye_swapchains_[eye].handle; + views[eye].subImage.imageRect = { + {0, 0}, + {static_cast(eye_swapchains_[eye].width), + static_cast(eye_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)}; + end_ok = runtime_->EndFrame(frame.xr_frame, layers, 1); + } + + 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 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_d3d12_disable_stereo_bridge(); + bridge_enabled_ = false; + } + if (!bridge_drained) { + AbandonAcquiredSwapchains(); + shutdown_unsafe_ = true; + Fail("D3D12 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(); + if (owns_session_ && runtime_ != nullptr) { + runtime_->DestroySession(); + owns_session_ = false; + } + bound_ = false; + requirements_queried_ = false; + runtime_ = nullptr; + return true; + } + + bool IsBound() const { return bound_; } + const OpenXRD3D12GraphicsRequirements& 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 DXGI_FORMAT srgb = SrgbSibling(aurora_format_); + if (srgb != DXGI_FORMAT_UNKNOWN && + 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 SameDxgiCopyFamily(aurora_format_, static_cast(format)); + }); + if (compatible == formats.end()) { + return Fail("OpenXR offered no swapchain format copy-compatible with Aurora's D3D12 color format"); + } + swapchain_format_ = static_cast(*compatible); + return true; + } + + bool CreateSwapchains() { + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + const auto& view = runtime_->ViewConfiguration()[eye]; + auto& swapchain = eye_swapchains_[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 (IsSrgbFormat(swapchain_format_)) { + // Matches DolphinXR's raw-UNORM-write/sRGB-compositor path and + // asks D3D runtimes to expose a typeless-compatible resource. + 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 D3D12 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 D3D12 swapchain images"); + } + swapchain.images.resize(count); + for (auto& image : swapchain.images) { + image = {XR_TYPE_SWAPCHAIN_IMAGE_D3D12_KHR}; + } + result = xrEnumerateSwapchainImages( + swapchain.handle, count, &count, + reinterpret_cast(swapchain.images.data())); + ObserveResult(result); + if (XR_FAILED(result)) { + return Fail("xrEnumerateSwapchainImages failed for a D3D12 eye swapchain"); + } + } + return true; + } + + bool AcquireSwapchain(EyeSwapchain& swapchain) { + XrSwapchainImageAcquireInfo acquire{XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO}; + XrResult result = xrAcquireSwapchainImage(swapchain.handle, &acquire, + &swapchain.acquired_index); + ObserveResult(result); + if (XR_FAILED(result)) { + return Fail("xrAcquireSwapchainImage failed for a D3D12 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; + result = xrWaitSwapchainImage(swapchain.handle, &wait); + ObserveResult(result); + if (result == XR_TIMEOUT_EXPIRED) { + return Fail("xrWaitSwapchainImage unexpectedly timed out for a D3D12 eye swapchain"); + } + if (XR_FAILED(result)) { + return Fail("xrWaitSwapchainImage failed for a D3D12 eye swapchain"); + } + swapchain.waited = true; + if (swapchain.acquired_index >= swapchain.images.size()) { + return Fail("OpenXR returned an out-of-range D3D12 swapchain image index"); + } + return true; + } + + bool ReleaseAcquiredSwapchains() { + 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 D3D12 image whose wait did not complete"); + continue; + } + if (swapchain.release_forbidden) { + // Aurora reported a failed submission after it may already + // have queued native D3D12 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 D3D12 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 D3D12 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() { + for (auto& swapchain : eye_swapchains_) { + if (swapchain.handle != XR_NULL_HANDLE && !swapchain.acquired) { + xrDestroySwapchain(swapchain.handle); + } else if (swapchain.acquired) { + Log(OpenXRLogLevel::Warning, + "D3D12 swapchain still owns an acquired image; deferring its destruction to xrDestroySession"); + } + swapchain = {}; + } + swapchain_format_ = DXGI_FORMAT_UNKNOWN; + } + + void EndActiveFrameWithoutLayers(const OpenXRFrame& frame) { + if (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 (...) { + } + } + + OpenXRRuntime* runtime_ = nullptr; + OpenXRLogCallback logger_; + OpenXRD3D12GraphicsRequirements requirements_{}; + std::array eye_swapchains_{}; + DXGI_FORMAT aurora_format_ = DXGI_FORMAT_UNKNOWN; + DXGI_FORMAT swapchain_format_ = DXGI_FORMAT_UNKNOWN; + 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 active_frame_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; +}; + +OpenXRD3D12Backend::OpenXRD3D12Backend(OpenXRLogCallback logger) + : m_impl(std::make_unique(std::move(logger))) {} + +OpenXRD3D12Backend::~OpenXRD3D12Backend() = default; + +bool OpenXRD3D12Backend::QueryGraphicsRequirements(OpenXRRuntime& runtime) { + return m_impl->QueryGraphicsRequirements(runtime); +} + +bool OpenXRD3D12Backend::BindAurora(OpenXRRuntime& runtime) { + return m_impl->BindAurora(runtime); +} + +OpenXRD3D12BeginStatus OpenXRD3D12Backend::BeginFrame( + const OpenXRD3D12Presentation& presentation, OpenXRD3D12Frame& frame) { + return m_impl->BeginFrame(presentation, frame); +} + +OpenXRD3D12SubmissionStatus OpenXRD3D12Backend::WaitForSubmission( + const OpenXRD3D12Frame& frame, uint32_t timeout_ms) { + return m_impl->WaitForSubmission(frame, timeout_ms); +} + +bool OpenXRD3D12Backend::TryCancelPendingFrame(OpenXRD3D12Frame& frame) { + return m_impl->TryCancelPendingFrame(frame); +} + +bool OpenXRD3D12Backend::FinishFrame(OpenXRD3D12Frame& frame, bool submit_layer) { + return m_impl->FinishFrame(frame, submit_layer); +} + +bool OpenXRD3D12Backend::Shutdown() { return m_impl->Shutdown(); } + +bool OpenXRD3D12Backend::IsBound() const { return m_impl->IsBound(); } + +const OpenXRD3D12GraphicsRequirements& OpenXRD3D12Backend::GraphicsRequirements() const { + return m_impl->GraphicsRequirements(); +} + +int64_t OpenXRD3D12Backend::SwapchainFormat() const { return m_impl->SwapchainFormat(); } + +const std::string& OpenXRD3D12Backend::LastError() const { return m_impl->LastError(); } + +} // namespace mkw::vr + +#endif // defined(MKW_ENABLE_OPENXR) && defined(_WIN32) diff --git a/runtime/src/vr/openxr_integration.cpp b/runtime/src/vr/openxr_integration.cpp new file mode 100644 index 0000000..bdb0ffb --- /dev/null +++ b/runtime/src/vr/openxr_integration.cpp @@ -0,0 +1,731 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#if defined(_WIN32) && !defined(NOMINMAX) +#define NOMINMAX +#endif + +#include "vr/openxr_integration.h" + +#include "runtime_config.h" +#include "runtime_log.h" +#include "vr/mkw_vr_policy.h" +#include "vr/mkw_vr_instrumentation.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(MKW_ENABLE_OPENXR) +#include "vr/openxr_runtime.h" +#if defined(_WIN32) +#include "vr/openxr_d3d12.h" +#else +#include "vr/openxr_vulkan_backend.h" +#endif +#endif + +namespace mkw::vr { +namespace { + +void ConfigurePolicy(bool enabled) noexcept { + MkwVRPolicyReset(); + MkwVRPolicyConfig config{}; + config.enabled = enabled; + config.immersive_races = true; + config.world_units_per_meter = RuntimeConfigFile::VrWorldUnitsPerMeter(500.0f); + config.hud_distance_meters = RuntimeConfigFile::VrHudDistanceMeters(2.0f); + MkwVRPolicyConfigure(config); + MkwVRInstrumentationInitialize(); +} + +#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32) + +struct Quaternion { + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; + float w = 1.0f; +}; + +struct Pose { + Quaternion orientation{}; + std::array position{}; +}; + +Quaternion Normalize(Quaternion value) noexcept { + const float length_squared = value.x * value.x + value.y * value.y + + value.z * value.z + value.w * value.w; + if (!(length_squared > 1.0e-12f)) { + return {}; + } + const float inverse_length = 1.0f / std::sqrt(length_squared); + value.x *= inverse_length; + value.y *= inverse_length; + value.z *= inverse_length; + value.w *= inverse_length; + return value; +} + +Quaternion Conjugate(Quaternion value) noexcept { + return {-value.x, -value.y, -value.z, value.w}; +} + +Quaternion Multiply(const Quaternion& left, const Quaternion& right) noexcept { + return Normalize({ + left.w * right.x + left.x * right.w + left.y * right.z - left.z * right.y, + left.w * right.y - left.x * right.z + left.y * right.w + left.z * right.x, + left.w * right.z + left.x * right.y - left.y * right.x + left.z * right.w, + left.w * right.w - left.x * right.x - left.y * right.y - left.z * right.z, + }); +} + +std::array Rotate(const Quaternion& q, const std::array& value) noexcept { + // Expanded q * [v,0] * conjugate(q), avoiding two temporary normalizations. + const float tx = 2.0f * (q.y * value[2] - q.z * value[1]); + const float ty = 2.0f * (q.z * value[0] - q.x * value[2]); + const float tz = 2.0f * (q.x * value[1] - q.y * value[0]); + return { + value[0] + q.w * tx + (q.y * tz - q.z * ty), + value[1] + q.w * ty + (q.z * tx - q.x * tz), + value[2] + q.w * tz + (q.x * ty - q.y * tx), + }; +} + +void RotationMatrix(const Quaternion& value, float matrix[9]) noexcept { + const Quaternion q = Normalize(value); + const float xx = q.x * q.x; + const float yy = q.y * q.y; + const float zz = q.z * q.z; + const float xy = q.x * q.y; + const float xz = q.x * q.z; + const float yz = q.y * q.z; + const float wx = q.w * q.x; + const float wy = q.w * q.y; + const float wz = q.w * q.z; + matrix[0] = 1.0f - 2.0f * (yy + zz); + matrix[1] = 2.0f * (xy - wz); + matrix[2] = 2.0f * (xz + wy); + matrix[3] = 2.0f * (xy + wz); + matrix[4] = 1.0f - 2.0f * (xx + zz); + matrix[5] = 2.0f * (yz - wx); + matrix[6] = 2.0f * (xz - wy); + matrix[7] = 2.0f * (yz + wx); + matrix[8] = 1.0f - 2.0f * (xx + yy); +} + +Pose CenterPose(const OpenXRFrame& frame, bool position_valid) noexcept { + const auto& left = frame.views[0].pose; + const auto& right = frame.views[1].pose; + Quaternion l{left.orientation.x, left.orientation.y, left.orientation.z, + left.orientation.w}; + Quaternion r{right.orientation.x, right.orientation.y, right.orientation.z, + right.orientation.w}; + l = Normalize(l); + r = Normalize(r); + const float dot = l.x * r.x + l.y * r.y + l.z * r.z + l.w * r.w; + if (dot < 0.0f) { + r = {-r.x, -r.y, -r.z, -r.w}; + } + Pose center; + center.orientation = Normalize({l.x + r.x, l.y + r.y, l.z + r.z, l.w + r.w}); + if (position_valid) { + center.position = { + (left.position.x + right.position.x) * 0.5f, + (left.position.y + right.position.y) * 0.5f, + (left.position.z + right.position.z) * 0.5f, + }; + } + return center; +} + +void IdentityEye(AuroraStereoEye& eye) noexcept { + std::fill(std::begin(eye.projection), std::end(eye.projection), 0.0f); + eye.projection[0] = 1.0f; + eye.projection[5] = 1.0f; + eye.projection[10] = 1.0f; + eye.projection[15] = 1.0f; + std::fill(std::begin(eye.viewFromCenter), std::end(eye.viewFromCenter), 0.0f); + eye.viewFromCenter[0] = 1.0f; + eye.viewFromCenter[5] = 1.0f; + eye.viewFromCenter[10] = 1.0f; +} + +void ProjectionFromFov(const XrFovf& fov, float output[16]) noexcept { + const float left = std::tan(fov.angleLeft); + const float right = std::tan(fov.angleRight); + const float down = std::tan(fov.angleDown); + const float up = std::tan(fov.angleUp); + const float inverse_width = 1.0f / (right - left); + const float inverse_height = 1.0f / (up - down); + std::fill(output, output + 16, 0.0f); + output[0] = 2.0f * inverse_width; + output[2] = (right + left) * inverse_width; + output[5] = 2.0f * inverse_height; + output[6] = (up + down) * inverse_height; +} + +void ViewFromBase(const XrPosef& eye_pose, const Pose& base, bool position_valid, + float units_per_meter, float output[12]) noexcept { + const Quaternion eye = Normalize({eye_pose.orientation.x, eye_pose.orientation.y, + eye_pose.orientation.z, eye_pose.orientation.w}); + const Quaternion inverse_eye = Conjugate(eye); + const Quaternion delta = Multiply(inverse_eye, base.orientation); + float rotation[9]; + RotationMatrix(delta, rotation); + + std::array translation{}; + if (position_valid) { + const std::array base_to_eye{ + base.position[0] - eye_pose.position.x, + base.position[1] - eye_pose.position.y, + base.position[2] - eye_pose.position.z, + }; + translation = Rotate(inverse_eye, base_to_eye); + } + output[0] = rotation[0]; + output[1] = rotation[1]; + output[2] = rotation[2]; + output[3] = translation[0] * units_per_meter; + output[4] = rotation[3]; + output[5] = rotation[4]; + output[6] = rotation[5]; + output[7] = translation[1] * units_per_meter; + output[8] = rotation[6]; + output[9] = rotation[7]; + output[10] = rotation[8]; + output[11] = translation[2] * units_per_meter; +} + +class OpenXRIntegration final { +public: + static OpenXRIntegration& Get() { + static OpenXRIntegration integration; + return integration; + } + + OpenXRStartupResult Prepare(AuroraConfig& aurora_config) { + Shutdown(); + { + std::lock_guard lock(error_mutex_); + last_error_.clear(); + } + if (graphics_retained_) { + SetError("OpenXR cannot be restarted after an unfenceable D3D12 submission"); + return OpenXRStartupResult::Unavailable; + } + requested_ = RuntimeConfigFile::VrEnabled(false); + ConfigurePolicy(requested_); + if (!requested_) { + return OpenXRStartupResult::Disabled; + } + if (aurora_config.desiredBackend != BACKEND_AUTO && + aurora_config.desiredBackend != BACKEND_D3D12) { + SetError("OpenXR currently requires the D3D12 graphics backend on Windows"); + return OpenXRStartupResult::Unavailable; + } + + logger_ = [](OpenXRLogLevel level, std::string_view message) { + const char* name = level == OpenXRLogLevel::Error ? "error" : + level == OpenXRLogLevel::Warning ? "warning" : "info"; + RT_LOG(RT_TAG_RUNTIME) << "[openxr::" << name << "] " << message << std::endl; + }; + runtime_ = std::make_unique(logger_); + backend_ = std::make_unique(logger_); + + OpenXRConfig config{}; + config.application_name = aurora_config.appName != nullptr ? aurora_config.appName + : "WiiCompiled"; + config.engine_name = "Aurora"; + config.resolution_scale = RuntimeConfigFile::VrRenderScale(1.0f); + config.required_extensions = {"XR_KHR_D3D12_enable"}; + if (!runtime_->Initialize(config)) { + SetError("OpenXR instance initialization failed: " + runtime_->LastError().message); + ResetPreparedObjects(); + return OpenXRStartupResult::Unavailable; + } + if (!backend_->QueryGraphicsRequirements(*runtime_)) { + SetError(backend_->LastError()); + ResetPreparedObjects(); + return OpenXRStartupResult::Unavailable; + } + + const auto& requirements = backend_->GraphicsRequirements(); + aurora_config.desiredBackend = BACKEND_D3D12; + aurora_config.xrInterop = true; + aurora_config.hasD3D12AdapterLuid = true; + aurora_config.d3d12AdapterLuidLow = requirements.adapter_luid_low; + aurora_config.d3d12AdapterLuidHigh = requirements.adapter_luid_high; + prepared_ = true; + return OpenXRStartupResult::Prepared; + } + + bool Start(AuroraBackend active_backend) { + if (!prepared_ || runtime_ == nullptr || backend_ == nullptr) { + return !requested_; + } + if (active_backend != BACKEND_D3D12) { + SetError("Aurora could not create the OpenXR-required D3D12 backend"); + ResetPreparedObjects(); + return false; + } + if (!backend_->BindAurora(*runtime_)) { + SetError(backend_->LastError()); + ResetPreparedObjects(); + return false; + } + + stop_.store(false, std::memory_order_release); + teardown_requested_.store(false, std::memory_order_release); + WithdrawPublishedFrame(); + aurora_set_stereo_frame_provider(&OpenXRIntegration::ProvideStereoFrame, this); + provider_registered_ = true; + running_.store(true, std::memory_order_release); + try { + pacing_thread_ = std::thread([this] { PacingThread(); }); + } catch (const std::exception& exception) { + running_.store(false, std::memory_order_release); + aurora_set_stereo_frame_provider(nullptr, nullptr); + provider_registered_ = false; + SetError(std::string("could not start the OpenXR pacing thread: ") + exception.what()); + ResetPreparedObjects(); + return false; + } + RT_LOG(RT_TAG_RUNTIME) << "OpenXR asynchronous D3D12 presentation started" << std::endl; + return true; + } + + void Shutdown() noexcept { + teardown_requested_.store(false, std::memory_order_release); + if (pacing_thread_.joinable()) { + // Registration changes are only safe while no sealed frame is in + // flight. The caller invokes us before Aurora teardown. + aurora_quiesce_frame_worker(); + aurora_set_stereo_frame_provider(nullptr, nullptr); + provider_registered_ = false; + WithdrawPublishedFrame(); + { + // Pair the predicate update with the wait mutex. Otherwise a + // terminal pacing thread can observe false, miss the notify, + // and make join wait forever. + std::lock_guard lock(stop_mutex_); + stop_.store(true, std::memory_order_release); + } + stop_cv_.notify_all(); + pacing_thread_.join(); + } else { + if (provider_registered_) { + aurora_quiesce_frame_worker(); + aurora_set_stereo_frame_provider(nullptr, nullptr); + provider_registered_ = false; + } + ShutdownOrRetainGraphicsObjects(); + } + running_.store(false, std::memory_order_release); + MkwVRPolicySetSessionActive(false); + backend_.reset(); + runtime_.reset(); + prepared_ = false; + ResetTrackingOrigin(); + applied_session_run_serial_ = 0; + session_was_active_ = false; + } + + bool IsRunning() const noexcept { return running_.load(std::memory_order_acquire); } + + void ServiceProducerFrameBoundary() noexcept { + if (teardown_requested_.load(std::memory_order_acquire)) { + Shutdown(); + } + } + + std::string LastError() const { + std::lock_guard lock(error_mutex_); + return last_error_; + } + +private: + struct PublishedFrame { + AuroraStereoFrame frame{}; + }; + + void ResetPreparedObjects() { + ShutdownOrRetainGraphicsObjects(); + backend_.reset(); + runtime_.reset(); + prepared_ = false; + } + + bool ShutdownOrRetainGraphicsObjects() noexcept { + if (backend_ != nullptr && !backend_->Shutdown()) { + RT_LOG(RT_TAG_RUNTIME) + << "OpenXR D3D12 queue completion is unknown; retaining the backend, " + "runtime, session, and graphics resources until process exit" + << std::endl; + (void)backend_.release(); + (void)runtime_.release(); + graphics_retained_ = true; + return false; + } + if (runtime_ != nullptr) { + runtime_->Shutdown(); + } + return true; + } + + static bool ProvideStereoFrame(uint32_t, AuroraStereoFrame* output, void* userdata) { + auto* self = static_cast(userdata); + if (self == nullptr || output == nullptr) { + return false; + } + // The packet storage is reused by the XR thread. Claim and copy it + // under one short lock so cancellation cannot begin the next packet + // while this callback is preempted between exchange and copy. + std::lock_guard lock(self->published_mutex_); + PublishedFrame* frame = self->published_.exchange(nullptr, std::memory_order_acq_rel); + if (frame == nullptr) { + return false; + } + *output = frame->frame; + return true; + } + + void PacingThread() noexcept { + bool fatal = false; + bool presentation_logged = false; + VRPresentationMode logged_presentation = VRPresentationMode::Desktop; + uint32_t presentation_log_count = 0; + bool immersive_submission_logged = false; + while (!stop_.load(std::memory_order_acquire) && !fatal) { + const OpenXREventStatus events = runtime_->PollEvents(); + const bool session_active = runtime_->IsSessionRunning(); + MkwVRPolicySetSessionActive(session_active); + const uint64_t session_run_serial = runtime_->SessionRunSerial(); + if (session_run_serial != applied_session_run_serial_) { + applied_session_run_serial_ = session_run_serial; + ResetTrackingOrigin(); + } + if (session_active != session_was_active_) { + session_was_active_ = session_active; + if (!session_active) { + ResetTrackingOrigin(); + } + } + if (events == OpenXREventStatus::ExitRequested) { + SetError("OpenXR runtime requested session exit; continuing on the desktop mirror"); + break; + } + if (events == OpenXREventStatus::Error) { + SetError("OpenXR event processing failed: " + runtime_->LastError().message); + break; + } + if (!session_active) { + WaitForStopOrDelay(std::chrono::milliseconds(5)); + continue; + } + + const MkwVRPolicySnapshot policy = MkwVRPolicyGetSnapshot(); + if ((!presentation_logged || policy.presentation != logged_presentation) && + presentation_log_count < 16) { + presentation_logged = true; + logged_presentation = policy.presentation; + ++presentation_log_count; + RT_LOG(RT_TAG_RUNTIME) + << "[mkw-vr] presentation=" + << (policy.presentation == VRPresentationMode::ImmersiveRace + ? "immersive-race" + : policy.presentation == VRPresentationMode::VirtualScreen + ? "virtual-screen" + : "desktop") + << ", scene=" << static_cast(policy.scene.mode) + << ", screens=" << policy.scene.local_player_count + << ", camera-valid=" << policy.camera.valid + << ", scene-frame=" << policy.scene.guest_frame_index + << ", camera-frame=" << policy.camera.guest_frame_index + << ", bindings=0x" << std::hex << policy.available_bindings + << std::dec << std::endl; + } + OpenXRD3D12Presentation presentation{}; + const bool immersive = policy.presentation == VRPresentationMode::ImmersiveRace; + presentation.mode = immersive ? OpenXRD3D12FrameMode::ImmersiveProjection + : OpenXRD3D12FrameMode::VirtualScreen; + presentation.quad_distance_meters = policy.config.hud_distance_meters; + + OpenXRD3D12Frame frame{}; + const OpenXRD3D12BeginStatus begin = backend_->BeginFrame(presentation, frame); + if (begin == OpenXRD3D12BeginStatus::SessionNotRunning) { + MkwVRPolicySetSessionActive(false); + continue; + } + if (begin == OpenXRD3D12BeginStatus::ExitRequested) { + SetError("OpenXR runtime requested session exit; continuing on the desktop mirror"); + break; + } + if (begin == OpenXRD3D12BeginStatus::Error) { + SetError(backend_->LastError()); + fatal = true; + break; + } + + if (!frame.expects_gpu_submission) { + if (!backend_->FinishFrame(frame, false)) { + SetError(backend_->LastError()); + fatal = true; + } + continue; + } + + { + std::lock_guard lock(published_mutex_); + BuildPublishedFrame(frame, immersive, policy.config.world_units_per_meter, + policy.content_tag); + published_.store(&published_frame_, std::memory_order_release); + } + + OpenXRD3D12SubmissionStatus submission = OpenXRD3D12SubmissionStatus::Timeout; + bool canceled_before_encode = false; + bool submission_stalled = false; + const auto submission_deadline = + std::chrono::steady_clock::now() + std::chrono::milliseconds(250); + while (!stop_.load(std::memory_order_acquire) && + submission == OpenXRD3D12SubmissionStatus::Timeout) { + submission = backend_->WaitForSubmission(frame, 50); + if (submission == OpenXRD3D12SubmissionStatus::Timeout) { + // A pause, minimized window, or guest stall may leave no GX + // frame to consume this packet. Withdraw it, then cancel the + // matching bridge target only if Encode has not taken ownership. + WithdrawPublishedFrame(); + canceled_before_encode = backend_->TryCancelPendingFrame(frame); + if (canceled_before_encode) { + break; + } + if (std::chrono::steady_clock::now() >= submission_deadline) { + // Cancellation may lose a race to completion. Recheck + // the callback-published predicate under the backend + // mutex before declaring a stall at the deadline. + submission = backend_->WaitForSubmission(frame, 0); + submission_stalled = + submission == OpenXRD3D12SubmissionStatus::Timeout; + break; + } + } + } + WithdrawPublishedFrame(); + if (stop_.load(std::memory_order_acquire)) { + // Aurora has been drained by Shutdown(); backend shutdown below + // cancels its pending target, then either safely releases the + // XR image or retains the entire graph if GPU completion is unknown. + break; + } + if (canceled_before_encode) { + if (!backend_->FinishFrame(frame, false)) { + SetError(backend_->LastError()); + fatal = true; + } + continue; + } + if (submission_stalled) { + SetError("Aurora did not complete the OpenXR stereo submission; " + "requesting a safe desktop fallback"); + fatal = true; + break; + } + const bool submit = submission == OpenXRD3D12SubmissionStatus::Success; + if (!backend_->FinishFrame(frame, submit)) { + SetError(backend_->LastError()); + fatal = true; + } else if (!submit) { + SetError("Aurora's D3D12 stereo copy failed; continuing on the desktop mirror"); + fatal = true; + } else if (immersive && !immersive_submission_logged) { + immersive_submission_logged = true; + RT_LOG(RT_TAG_RUNTIME) + << "[mkw-vr] first immersive packet consumed and submitted as " + "an OpenXR projection layer" + << std::endl; + } + } + + running_.store(false, std::memory_order_release); + MkwVRPolicySetSessionActive(false); + if (!stop_.load(std::memory_order_acquire)) { + // A runtime/backend failure can happen while Aurora is submitting. + // Ask the producer to reach a safe frame boundary, drain Aurora, + // and unregister the provider before this XR owner destroys state. + teardown_requested_.store(true, std::memory_order_release); + std::unique_lock lock(stop_mutex_); + stop_cv_.wait(lock, [this] { return stop_.load(std::memory_order_acquire); }); + } + ShutdownOrRetainGraphicsObjects(); + } + + void BuildPublishedFrame(const OpenXRD3D12Frame& source, bool immersive, + float units_per_meter, uint64_t content_tag) noexcept { + ApplyPendingReferenceSpaceChange(source.xr_frame); + auto& destination = published_frame_.frame; + destination = {}; + destination.frameToken = source.xr_frame.serial; + destination.contentTag = content_tag; + destination.mode = immersive ? AURORA_STEREO_FRAME_IMMERSIVE_REPLAY + : AURORA_STEREO_FRAME_VIRTUAL_SCREEN; + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + destination.eyes[eye].width = source.render_width[eye]; + destination.eyes[eye].height = source.render_height[eye]; + IdentityEye(destination.eyes[eye]); + } + if (!immersive) { + last_immersive_ = false; + return; + } + + const bool position_valid = + (source.xr_frame.view_state_flags & XR_VIEW_STATE_POSITION_VALID_BIT) != 0; + if (!base_pose_valid_ || !last_immersive_) { + base_pose_ = CenterPose(source.xr_frame, position_valid); + base_pose_valid_ = true; + base_position_valid_ = position_valid; + } else if (position_valid && !base_position_valid_) { + base_pose_.position = CenterPose(source.xr_frame, true).position; + base_position_valid_ = true; + } + last_immersive_ = true; + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + ProjectionFromFov(source.xr_frame.views[eye].fov, + destination.eyes[eye].projection); + ViewFromBase(source.xr_frame.views[eye].pose, base_pose_, + position_valid && base_position_valid_, + units_per_meter, destination.eyes[eye].viewFromCenter); + } + } + + void ApplyPendingReferenceSpaceChange(const OpenXRFrame& frame) noexcept { + if (runtime_->ConsumeAppSpaceChangesThrough(frame.predicted_display_time)) { + ResetTrackingOrigin(); + } + } + + void ResetTrackingOrigin() noexcept { + base_pose_ = {}; + base_pose_valid_ = false; + base_position_valid_ = false; + last_immersive_ = false; + } + + void WaitForStopOrDelay(std::chrono::milliseconds delay) { + std::unique_lock lock(stop_mutex_); + stop_cv_.wait_for(lock, delay, + [this] { return stop_.load(std::memory_order_acquire); }); + } + + void WithdrawPublishedFrame() noexcept { + std::lock_guard lock(published_mutex_); + published_.store(nullptr, std::memory_order_release); + } + + void SetError(std::string message) { + { + std::lock_guard lock(error_mutex_); + last_error_ = std::move(message); + } + RT_LOG(RT_TAG_RUNTIME) << "OpenXR: " << LastError() << std::endl; + } + + OpenXRLogCallback logger_; + std::unique_ptr runtime_; + std::unique_ptr backend_; + std::thread pacing_thread_; + std::atomic_bool stop_{false}; + std::atomic_bool running_{false}; + std::atomic_bool teardown_requested_{false}; + std::atomic published_{nullptr}; + PublishedFrame published_frame_{}; + std::mutex published_mutex_; + std::mutex stop_mutex_; + std::condition_variable stop_cv_; + mutable std::mutex error_mutex_; + std::string last_error_; + Pose base_pose_{}; + bool base_pose_valid_ = false; + bool base_position_valid_ = false; + bool last_immersive_ = false; + uint64_t applied_session_run_serial_ = 0; + bool session_was_active_ = false; + bool requested_ = false; + bool prepared_ = false; + bool provider_registered_ = false; + bool graphics_retained_ = false; +}; + +#endif // defined(MKW_ENABLE_OPENXR) && defined(_WIN32) + +} // namespace + +OpenXRStartupResult OpenXRPrepareAurora(AuroraConfig& config) { +#if !defined(MKW_ENABLE_OPENXR) + (void)config; + ConfigurePolicy(false); + return RuntimeConfigFile::VrEnabled(false) ? OpenXRStartupResult::Unavailable + : OpenXRStartupResult::Disabled; +#elif defined(_WIN32) + return OpenXRIntegration::Get().Prepare(config); +#else + ConfigurePolicy(RuntimeConfigFile::VrEnabled(false)); + if (!RuntimeConfigFile::VrEnabled(false)) { + return OpenXRStartupResult::Disabled; + } + const OpenXRVulkanCapabilityInfo capability = OpenXRVulkanBackend::DawnInteropCapability(); + RT_LOG(RT_TAG_RUNTIME) << "OpenXR Vulkan unavailable: " << capability.reason << std::endl; + return OpenXRStartupResult::Unavailable; +#endif +} + +bool OpenXRStartAfterAurora(AuroraBackend active_backend) { +#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32) + return OpenXRIntegration::Get().Start(active_backend); +#else + (void)active_backend; + return !RuntimeConfigFile::VrEnabled(false); +#endif +} + +void OpenXRShutdownBeforeAurora() noexcept { +#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32) + OpenXRIntegration::Get().Shutdown(); +#else + MkwVRPolicySetSessionActive(false); +#endif +} + +void OpenXRServiceProducerFrameBoundary() noexcept { +#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32) + OpenXRIntegration::Get().ServiceProducerFrameBoundary(); +#endif +} + +bool OpenXRIsRunning() noexcept { +#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32) + return OpenXRIntegration::Get().IsRunning(); +#else + return false; +#endif +} + +std::string OpenXRLastError() { +#if !defined(MKW_ENABLE_OPENXR) + return "this build was compiled without OpenXR support"; +#elif defined(_WIN32) + return OpenXRIntegration::Get().LastError(); +#else + return OpenXRVulkanBackend::DawnInteropCapability().reason; +#endif +} + +} // namespace mkw::vr diff --git a/runtime/src/vr/openxr_runtime.cpp b/runtime/src/vr/openxr_runtime.cpp index a5eb254..b2364c8 100644 --- a/runtime/src/vr/openxr_runtime.cpp +++ b/runtime/src/vr/openxr_runtime.cpp @@ -37,6 +37,16 @@ XrPosef IdentityPose() { return {{0.0f, 0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 0.0f}}; } +bool IsFinitePositive(float value) noexcept { + // mkw_runtime_common uses -ffast-math, where std::isfinite may be folded + // away. Check the IEEE-754 representation before using the scale in + // rounding and integer conversion. + uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + return (bits & 0x80000000u) == 0 && (bits & 0x7fffffffu) != 0 && + (bits & 0x7f800000u) != 0x7f800000u; +} + uint32_t ScaledDimension(uint32_t recommended, uint32_t maximum, float scale) { const double scaled = std::round(static_cast(recommended) * static_cast(scale)); @@ -89,7 +99,7 @@ bool OpenXRRuntime::Initialize(const OpenXRConfig& config) { return Fail(XR_ERROR_VALIDATION_FAILURE, "Initialize", "application_name must not be empty"); } - if (!std::isfinite(config.resolution_scale) || config.resolution_scale <= 0.0f) { + if (!IsFinitePositive(config.resolution_scale)) { return Fail(XR_ERROR_VALIDATION_FAILURE, "Initialize", "resolution_scale must be finite and greater than zero"); } @@ -342,6 +352,10 @@ bool OpenXRRuntime::CreateSession(const void* graphics_binding) { return Fail(XR_ERROR_INSTANCE_LOST, "CreateSession", "the OpenXR instance is loss-pending"); } + if (m_session_loss_pending) { + return Fail(XR_ERROR_SESSION_LOST, "CreateSession", + "session loss requires a full OpenXR reinitialization"); + } if (HasSession()) { return Fail(XR_ERROR_CALL_ORDER_INVALID, "CreateSession", "a session already exists"); @@ -497,20 +511,29 @@ OpenXREventStatus OpenXRRuntime::PollEvents() { } case XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING: m_instance_loss_pending = true; + m_session_running = false; + ResetFrameState(); Log(OpenXRLogLevel::Warning, "OpenXR runtime reported instance loss pending"); break; case XR_TYPE_EVENT_DATA_REFERENCE_SPACE_CHANGE_PENDING: { const auto& space_event = *reinterpret_cast(&event); - if (space_event.session == m_session) { - ++m_reference_space_change.serial; + // This slot is consumed to invalidate transforms located in the + // application space. Events for VIEW or another supported type + // must not overwrite a pending LOCAL/STAGE change. + if (space_event.session == m_session && + space_event.referenceSpaceType == m_app_space_type) { + if (++m_reference_space_change.serial == 0) { + ++m_reference_space_change.serial; + } m_reference_space_change.type = space_event.referenceSpaceType; m_reference_space_change.change_time = space_event.changeTime; m_reference_space_change.pose_in_previous_space_valid = space_event.poseValid == XR_TRUE; m_reference_space_change.pose_in_previous_space = space_event.poseInPreviousSpace; + m_pending_app_space_changes.push_back(m_reference_space_change); } break; } @@ -546,24 +569,35 @@ bool OpenXRRuntime::HandleSessionStateChanged( if (!Check(xrBeginSession(m_session, &begin_info), "xrBeginSession")) { return false; } + ResetFrameState(); m_session_running = true; - return true; - } - case XR_SESSION_STATE_STOPPING: - if (m_session_running) { - if (!Check(xrEndSession(m_session), "xrEndSession")) { - return false; - } - m_session_running = false; + if (++m_session_run_serial == 0) { + ++m_session_run_serial; } return true; + } + case XR_SESSION_STATE_STOPPING: { + if (m_session_running) { + const XrResult result = xrEndSession(m_session); + // The OpenXR session is no longer running after any xrEndSession + // call, including one that returns an error. + m_session_running = false; + ResetFrameState(); + if (!Check(result, "xrEndSession")) { + return false; + } + } + return true; + } case XR_SESSION_STATE_EXITING: m_exit_requested = true; m_session_running = false; + ResetFrameState(); return true; case XR_SESSION_STATE_LOSS_PENDING: - m_instance_loss_pending = true; + m_session_loss_pending = true; m_session_running = false; + ResetFrameState(); return true; default: return true; @@ -578,6 +612,24 @@ bool OpenXRRuntime::RequestExitSession() { return Check(xrRequestExitSession(m_session), "xrRequestExitSession"); } +void OpenXRRuntime::ObserveResult(XrResult result) noexcept { + if (result == XR_SESSION_LOSS_PENDING) { + if (!m_session_loss_pending) { + Log(OpenXRLogLevel::Warning, + "OpenXR reported XR_SESSION_LOSS_PENDING"); + } + m_session_loss_pending = true; + } else if (result == XR_ERROR_SESSION_LOST) { + m_session_loss_pending = true; + m_session_running = false; + ResetFrameState(); + } else if (result == XR_ERROR_INSTANCE_LOST) { + m_instance_loss_pending = true; + m_session_running = false; + ResetFrameState(); + } +} + OpenXRFrameStatus OpenXRRuntime::WaitFrame(OpenXRFrame& frame) { ClearError(); if (ShouldExit()) { @@ -731,9 +783,29 @@ bool OpenXRRuntime::ResetAppSpace(const XrPosef& pose_in_reference_space) { xrDestroySpace(m_app_space); } m_app_space = replacement; + if (++m_reference_space_change.serial == 0) { + ++m_reference_space_change.serial; + } + m_reference_space_change.type = m_app_space_type; + m_reference_space_change.change_time = 0; + m_reference_space_change.pose_in_previous_space_valid = false; + m_reference_space_change.pose_in_previous_space = IdentityPose(); + m_pending_app_space_changes.push_back(m_reference_space_change); return true; } +bool OpenXRRuntime::ConsumeAppSpaceChangesThrough(XrTime display_time) { + bool consumed = false; + std::erase_if(m_pending_app_space_changes, + [&](const OpenXRReferenceSpaceChange& change) { + const bool due = change.change_time == 0 || + display_time >= change.change_time; + consumed = consumed || due; + return due; + }); + return consumed; +} + void OpenXRRuntime::DestroySession() { if (!HasSession()) { ResetSessionState(); @@ -766,9 +838,12 @@ void OpenXRRuntime::DestroySession() { const auto timeout = std::chrono::milliseconds(m_config.shutdown_timeout_ms); const auto deadline = std::chrono::steady_clock::now() + timeout; + bool event_error = false; while (m_session_running && std::chrono::steady_clock::now() < deadline) { - if (PollEvents() == OpenXREventStatus::Error) { + const OpenXREventStatus status = PollEvents(); + if (status != OpenXREventStatus::Continue) { + event_error = status == OpenXREventStatus::Error; break; } if (m_session_running) { @@ -776,8 +851,9 @@ void OpenXRRuntime::DestroySession() { } } if (m_session_running) { - Log(OpenXRLogLevel::Warning, - "OpenXR runtime did not finish session exit before timeout"); + Log(OpenXRLogLevel::Warning, event_error + ? "OpenXR event processing failed during session teardown" + : "OpenXR runtime did not finish session exit before timeout"); } } } @@ -819,18 +895,19 @@ void OpenXRRuntime::ResetSessionState() { m_session_running = false; m_exit_requested = false; m_shutting_down_session = false; - m_frame_phase = FramePhase::Idle; - m_active_frame_serial = 0; - m_active_frame_display_time = 0; + ResetFrameState(); m_supported_reference_spaces.clear(); m_swapchain_formats.clear(); m_reference_space_change = {}; + m_pending_app_space_changes.clear(); } void OpenXRRuntime::ResetInstanceState() { m_instance = XR_NULL_HANDLE; m_system_id = XR_NULL_SYSTEM_ID; + m_session_loss_pending = false; m_instance_loss_pending = false; + m_session_run_serial = 0; m_runtime_info = {}; m_view_configuration = {}; m_available_extensions.clear(); @@ -847,7 +924,14 @@ bool OpenXRRuntime::IsFrameTokenCurrent( frame.serial == m_active_frame_serial; } +void OpenXRRuntime::ResetFrameState() { + m_frame_phase = FramePhase::Idle; + m_active_frame_serial = 0; + m_active_frame_display_time = 0; +} + bool OpenXRRuntime::Check(XrResult result, std::string_view operation) { + ObserveResult(result); if (XR_SUCCEEDED(result)) { return true; } diff --git a/runtime/src/vr/openxr_vulkan_backend.cpp b/runtime/src/vr/openxr_vulkan_backend.cpp new file mode 100644 index 0000000..a652bce --- /dev/null +++ b/runtime/src/vr/openxr_vulkan_backend.cpp @@ -0,0 +1,630 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#if defined(MKW_ENABLE_OPENXR) + +#include "vr/openxr_vulkan_backend.h" + +#include +#include +#include +#include +#include +#include + +#if __has_include() +#define MKW_OPENXR_VULKAN_HEADERS_AVAILABLE 1 +#include +#define XR_USE_GRAPHICS_API_VULKAN +#include +#else +#define MKW_OPENXR_VULKAN_HEADERS_AVAILABLE 0 +#endif + +namespace mkw::vr { +namespace { + +constexpr std::array + kEmptyViewConfiguration{}; +constexpr std::string_view kVulkanEnable2Extension = + "XR_KHR_vulkan_enable2"; + +bool HasExtension(const OpenXRRuntime& runtime, std::string_view extension) { + const auto& extensions = runtime.EnabledExtensions(); + return std::find(extensions.begin(), extensions.end(), extension) != + extensions.end(); +} + +#if MKW_OPENXR_VULKAN_HEADERS_AVAILABLE +template +VulkanHandle FromOpaqueHandle(OpenXRVulkanHandle handle) { + if constexpr (std::is_pointer_v) { + return reinterpret_cast(static_cast(handle)); + } else { + return static_cast(handle); + } +} + +template +OpenXRVulkanHandle ToOpaqueHandle(VulkanHandle handle) { + if constexpr (std::is_pointer_v) { + return static_cast(reinterpret_cast(handle)); + } else { + return static_cast(handle); + } +} + +XrVersion VulkanApiVersionAsXrVersion(uint32_t version) { + return XR_MAKE_VERSION(VK_API_VERSION_MAJOR(version), + VK_API_VERSION_MINOR(version), + VK_API_VERSION_PATCH(version)); +} +#endif + +} // namespace + +OpenXRVulkanBackend::OpenXRVulkanBackend(OpenXRLogCallback logger) + : m_logger(std::move(logger)) {} + +OpenXRVulkanBackend::~OpenXRVulkanBackend() { + Shutdown(); +} + +OpenXRVulkanCapabilityInfo OpenXRVulkanBackend::DawnInteropCapability() { +#if !MKW_OPENXR_VULKAN_HEADERS_AVAILABLE + return {OpenXRVulkanCapability::VulkanHeadersUnavailable, + "Vulkan headers were not available when the runtime was built"}; +#elif !defined(MKW_AURORA_DAWN_VULKAN_NATIVE_HANDLES) + return { + OpenXRVulkanCapability::DawnNativeHandlesUnavailable, + "the pinned Dawn package exposes VkInstance only; same-device OpenXR " + "also requires Aurora to expose Dawn's VkPhysicalDevice, VkDevice, " + "VkQueue, queue-family index, and queue synchronization", + }; +#else + return {OpenXRVulkanCapability::Available, {}}; +#endif +} + +bool OpenXRVulkanBackend::Initialize( + OpenXRRuntime& runtime, + const OpenXRVulkanNativeContext& native_context, + const OpenXRVulkanBackendConfig& config) { + m_last_error = {}; + if (IsInitialized()) { + return Fail(XR_ERROR_CALL_ORDER_INVALID, "Initialize", + "the Vulkan OpenXR backend is already initialized"); + } + if (!runtime.IsInitialized()) { + return Fail(XR_ERROR_HANDLE_INVALID, "Initialize", + "OpenXRRuntime must be initialized first"); + } + if (runtime.HasSession()) { + return Fail(XR_ERROR_CALL_ORDER_INVALID, "Initialize", + "OpenXRRuntime already owns a graphics session"); + } + if (!HasExtension(runtime, kVulkanEnable2Extension)) { + return Fail(XR_ERROR_EXTENSION_NOT_PRESENT, "Initialize", + "XR_KHR_vulkan_enable2 was not enabled on the instance"); + } +#if !MKW_OPENXR_VULKAN_HEADERS_AVAILABLE + (void)native_context; + (void)config; + return Fail(XR_ERROR_GRAPHICS_DEVICE_INVALID, "Initialize", + "this build has no Vulkan headers"); +#else + m_runtime = &runtime; + m_config = config; + m_native_context = native_context; + + if (!ValidateNativeContext(native_context) || !ValidateRuntimeDevice()) { + m_runtime = nullptr; + return false; + } + + const XrGraphicsBindingVulkan2KHR graphics_binding{ + XR_TYPE_GRAPHICS_BINDING_VULKAN2_KHR, + nullptr, + FromOpaqueHandle(native_context.instance), + FromOpaqueHandle(native_context.physical_device), + FromOpaqueHandle(native_context.device), + native_context.queue_family_index, + native_context.queue_index, + }; + if (!runtime.CreateSession(&graphics_binding)) { + m_last_error = runtime.LastError(); + m_runtime = nullptr; + return false; + } + m_owns_session = true; + + if (!SelectSwapchainFormat() || !CreateSwapchains()) { + DestroySwapchains(); + runtime.DestroySession(); + m_owns_session = false; + m_runtime = nullptr; + return false; + } + + std::ostringstream message; + message << "OpenXR Vulkan swapchains ready: format " << m_swapchain_format + << ", left " << m_eye_swapchains[0].width << 'x' + << m_eye_swapchains[0].height << ", right " + << m_eye_swapchains[1].width << 'x' + << m_eye_swapchains[1].height; + Log(OpenXRLogLevel::Info, message.str()); + return true; +#endif +} + +bool OpenXRVulkanBackend::ValidateNativeContext( + const OpenXRVulkanNativeContext& native_context) { + if (native_context.version != kOpenXRVulkanNativeContextVersion || + native_context.struct_size < sizeof(OpenXRVulkanNativeContext)) { + return Fail(XR_ERROR_VALIDATION_FAILURE, "ValidateNativeContext", + "unsupported native context ABI version or size"); + } + if (native_context.instance == 0 || native_context.physical_device == 0 || + native_context.device == 0 || native_context.queue == 0 || + native_context.api_version == 0) { + return Fail(XR_ERROR_GRAPHICS_DEVICE_INVALID, "ValidateNativeContext", + "instance, physical device, device, queue, and API version " + "must all be supplied"); + } + constexpr uint32_t required_flags = + OpenXRVulkanNativeContextDawnOwnedBit | + OpenXRVulkanNativeContextQueueVerifiedBit; + if ((native_context.flags & required_flags) != required_flags) { + return Fail(XR_ERROR_GRAPHICS_DEVICE_INVALID, "ValidateNativeContext", + "native handles must be the verified Dawn device and queue"); + } + if (native_context.lock_queue == nullptr || + native_context.unlock_queue == nullptr) { + return Fail(XR_ERROR_VALIDATION_FAILURE, "ValidateNativeContext", + "paired Dawn graphics-queue lock callbacks are required"); + } + return true; +} + +bool OpenXRVulkanBackend::ValidateRuntimeDevice() { +#if !MKW_OPENXR_VULKAN_HEADERS_AVAILABLE + return false; +#else + PFN_xrGetVulkanGraphicsRequirements2KHR get_requirements = nullptr; + PFN_xrGetVulkanGraphicsDevice2KHR get_graphics_device = nullptr; + if (!m_runtime->LoadFunction("xrGetVulkanGraphicsRequirements2KHR", + &get_requirements) || + !m_runtime->LoadFunction("xrGetVulkanGraphicsDevice2KHR", + &get_graphics_device)) { + m_last_error = m_runtime->LastError(); + return false; + } + + XrGraphicsRequirementsVulkan2KHR requirements{ + XR_TYPE_GRAPHICS_REQUIREMENTS_VULKAN2_KHR}; + if (!Check(get_requirements(m_runtime->Instance(), m_runtime->SystemId(), + &requirements), + "xrGetVulkanGraphicsRequirements2KHR")) { + return false; + } + + const XrVersion supplied_version = + VulkanApiVersionAsXrVersion(m_native_context.api_version); + if (supplied_version < requirements.minApiVersionSupported || + supplied_version > requirements.maxApiVersionSupported) { + std::ostringstream detail; + detail << "Dawn Vulkan version " + << VK_API_VERSION_MAJOR(m_native_context.api_version) << '.' + << VK_API_VERSION_MINOR(m_native_context.api_version) << '.' + << VK_API_VERSION_PATCH(m_native_context.api_version) + << " is outside the OpenXR runtime range " + << XR_VERSION_MAJOR(requirements.minApiVersionSupported) << '.' + << XR_VERSION_MINOR(requirements.minApiVersionSupported) << '-' + << XR_VERSION_MAJOR(requirements.maxApiVersionSupported) << '.' + << XR_VERSION_MINOR(requirements.maxApiVersionSupported); + return Fail(XR_ERROR_GRAPHICS_DEVICE_INVALID, + "xrGetVulkanGraphicsRequirements2KHR", detail.str()); + } + + XrVulkanGraphicsDeviceGetInfoKHR device_get_info{ + XR_TYPE_VULKAN_GRAPHICS_DEVICE_GET_INFO_KHR}; + device_get_info.systemId = m_runtime->SystemId(); + device_get_info.vulkanInstance = + FromOpaqueHandle(m_native_context.instance); + + VkPhysicalDevice runtime_physical_device = VK_NULL_HANDLE; + if (!Check(get_graphics_device(m_runtime->Instance(), &device_get_info, + &runtime_physical_device), + "xrGetVulkanGraphicsDevice2KHR")) { + return false; + } + if (ToOpaqueHandle(runtime_physical_device) != + m_native_context.physical_device) { + return Fail(XR_ERROR_GRAPHICS_DEVICE_INVALID, + "xrGetVulkanGraphicsDevice2KHR", + "OpenXR requires a different physical device than Dawn selected"); + } + return true; +#endif +} + +bool OpenXRVulkanBackend::SelectSwapchainFormat() { +#if !MKW_OPENXR_VULKAN_HEADERS_AVAILABLE + return false; +#else + const auto& formats = m_runtime->SwapchainFormats(); + if (formats.empty()) { + return Fail(XR_ERROR_SWAPCHAIN_FORMAT_UNSUPPORTED, + "xrEnumerateSwapchainFormats", + "the runtime reported no Vulkan swapchain formats"); + } + + constexpr std::array preferred_formats{ + VK_FORMAT_R8G8B8A8_SRGB, + VK_FORMAT_B8G8R8A8_SRGB, + VK_FORMAT_R8G8B8A8_UNORM, + VK_FORMAT_B8G8R8A8_UNORM, + VK_FORMAT_A2B10G10R10_UNORM_PACK32, + VK_FORMAT_R16G16B16A16_SFLOAT, + }; + for (VkFormat preferred : preferred_formats) { + const int64_t candidate = static_cast(preferred); + if (std::find(formats.begin(), formats.end(), candidate) != formats.end()) { + m_swapchain_format = candidate; + return true; + } + } + + // A caller-provided GPU bridge can still support a format outside our + // preference list, so keep the runtime's first valid choice visible. + m_swapchain_format = formats.front(); + Log(OpenXRLogLevel::Warning, + "OpenXR Vulkan runtime offered no preferred RGBA/BGRA format; using " + "its first advertised format"); + return true; +#endif +} + +bool OpenXRVulkanBackend::CreateSwapchains() { + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + if (!CreateEyeSwapchain(eye)) { + return false; + } + } + return true; +} + +bool OpenXRVulkanBackend::CreateEyeSwapchain(uint32_t eye) { +#if !MKW_OPENXR_VULKAN_HEADERS_AVAILABLE + (void)eye; + return false; +#else + const auto& view_configuration = m_runtime->ViewConfiguration(); + EyeSwapchain& swapchain = m_eye_swapchains[eye]; + swapchain.width = view_configuration[eye].render_width; + swapchain.height = view_configuration[eye].render_height; + + XrSwapchainUsageFlags usage = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT; + if (m_config.require_transfer_destination) { + usage |= XR_SWAPCHAIN_USAGE_TRANSFER_DST_BIT; + } + const XrSwapchainCreateInfo create_info{ + XR_TYPE_SWAPCHAIN_CREATE_INFO, + nullptr, + 0, + usage, + m_swapchain_format, + 1, + swapchain.width, + swapchain.height, + 1, + 1, + 1, + }; + if (!Check(xrCreateSwapchain(m_runtime->Session(), &create_info, + &swapchain.handle), + eye == 0 ? "xrCreateSwapchain(left)" + : "xrCreateSwapchain(right)")) { + return false; + } + + uint32_t image_count = 0; + if (!Check(xrEnumerateSwapchainImages(swapchain.handle, 0, &image_count, + nullptr), + "xrEnumerateSwapchainImages(count)")) { + return false; + } + if (image_count == 0) { + return Fail(XR_ERROR_RUNTIME_FAILURE, "xrEnumerateSwapchainImages", + "the runtime returned an empty Vulkan swapchain"); + } + + std::vector images( + image_count, + XrSwapchainImageVulkan2KHR{XR_TYPE_SWAPCHAIN_IMAGE_VULKAN2_KHR}); + if (!Check(xrEnumerateSwapchainImages( + swapchain.handle, image_count, &image_count, + reinterpret_cast(images.data())), + "xrEnumerateSwapchainImages")) { + return false; + } + + swapchain.images.reserve(image_count); + for (const XrSwapchainImageVulkan2KHR& image : images) { + swapchain.images.push_back(ToOpaqueHandle(image.image)); + } + return true; +#endif +} + +bool OpenXRVulkanBackend::AcquireEyeImage(uint32_t eye, + OpenXRVulkanEyeImage& image) { + if (!IsInitialized() || eye >= kOpenXREyeCount) { + return Fail(XR_ERROR_VALIDATION_FAILURE, "AcquireEyeImage", + "backend is not initialized or eye index is invalid"); + } + EyeSwapchain& swapchain = m_eye_swapchains[eye]; + if (swapchain.acquired && swapchain.waited) { + return Fail(XR_ERROR_CALL_ORDER_INVALID, "AcquireEyeImage", + "this eye already has an acquired image"); + } + + if (!swapchain.acquired) { + XrSwapchainImageAcquireInfo acquire_info{ + XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO}; + if (!LockQueue()) { + return false; + } + const XrResult acquire_result = + xrAcquireSwapchainImage(swapchain.handle, &acquire_info, + &swapchain.acquired_index); + UnlockQueue(); + if (!Check(acquire_result, "xrAcquireSwapchainImage")) { + return false; + } + swapchain.acquired = true; + } + + const XrSwapchainImageWaitInfo wait_info{ + XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO, nullptr, + m_config.image_wait_timeout}; + const XrResult wait_result = xrWaitSwapchainImage(swapchain.handle, &wait_info); + if (wait_result == XR_TIMEOUT_EXPIRED) { + // XR_TIMEOUT_EXPIRED is a positive XrResult, but the image has not + // completed its wait and therefore cannot be used or released yet. + return Fail(wait_result, "xrWaitSwapchainImage", + "the image wait timed out; retry acquisition to resume the wait"); + } + if (!Check(wait_result, "xrWaitSwapchainImage")) { + // A timeout leaves the image acquired but not waited. The caller may + // retry AcquireEyeImage(), which resumes at xrWaitSwapchainImage rather + // than illegally acquiring a second image or releasing an unwaited one. + return false; + } + swapchain.waited = true; + if (swapchain.acquired_index >= swapchain.images.size()) { + ReleaseEyeImage(eye); + return Fail(XR_ERROR_RUNTIME_FAILURE, "xrAcquireSwapchainImage", + "the runtime returned an out-of-range image index"); + } + + image = { + eye, + swapchain.acquired_index, + swapchain.width, + swapchain.height, + m_swapchain_format, + swapchain.images[swapchain.acquired_index], + }; + return true; +} + +bool OpenXRVulkanBackend::ReleaseEyeImage(uint32_t eye) { + if (!IsInitialized() || eye >= kOpenXREyeCount) { + return Fail(XR_ERROR_VALIDATION_FAILURE, "ReleaseEyeImage", + "backend is not initialized or eye index is invalid"); + } + EyeSwapchain& swapchain = m_eye_swapchains[eye]; + if (!swapchain.acquired) { + return true; + } + if (!swapchain.waited) { + return Fail(XR_ERROR_CALL_ORDER_INVALID, "ReleaseEyeImage", + "xrWaitSwapchainImage has not completed for this image"); + } + + XrSwapchainImageReleaseInfo release_info{ + XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; + if (!LockQueue()) { + return false; + } + const XrResult release_result = + xrReleaseSwapchainImage(swapchain.handle, &release_info); + UnlockQueue(); + if (!Check(release_result, "xrReleaseSwapchainImage")) { + return false; + } + swapchain.acquired = false; + swapchain.waited = false; + return true; +} + +bool OpenXRVulkanBackend::SubmitProjection(const OpenXRFrame& frame) { + if (!IsInitialized()) { + return Fail(XR_ERROR_HANDLE_INVALID, "SubmitProjection", + "backend is not initialized"); + } + for (const EyeSwapchain& swapchain : m_eye_swapchains) { + if (swapchain.acquired) { + return Fail(XR_ERROR_CALL_ORDER_INVALID, "SubmitProjection", + "all eye images must be released before xrEndFrame"); + } + } + const bool position_valid = + (frame.view_state_flags & XR_VIEW_STATE_POSITION_VALID_BIT) != 0; + if (!frame.should_render || !frame.views_valid || !position_valid) { + return m_runtime->EndFrameWithoutLayers(frame); + } + + std::array views{}; + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + views[eye] = {XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}; + views[eye].pose = frame.views[eye].pose; + views[eye].fov = frame.views[eye].fov; + views[eye].subImage.swapchain = m_eye_swapchains[eye].handle; + views[eye].subImage.imageRect = { + {0, 0}, + {static_cast(m_eye_swapchains[eye].width), + static_cast(m_eye_swapchains[eye].height)}, + }; + views[eye].subImage.imageArrayIndex = 0; + } + + const XrCompositionLayerProjection layer{ + XR_TYPE_COMPOSITION_LAYER_PROJECTION, + nullptr, + m_config.projection_layer_flags, + m_runtime->AppSpace(), + kOpenXREyeCount, + views.data(), + }; + const XrCompositionLayerBaseHeader* layers[] = { + reinterpret_cast(&layer), + }; + if (!m_runtime->EndFrame(frame, layers, 1)) { + m_last_error = m_runtime->LastError(); + return false; + } + return true; +} + +void OpenXRVulkanBackend::DestroySwapchains() { + for (EyeSwapchain& swapchain : m_eye_swapchains) { + bool may_destroy_swapchain = true; + if (swapchain.acquired && swapchain.waited && + swapchain.handle != XR_NULL_HANDLE) { + XrSwapchainImageReleaseInfo release_info{ + XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; + if (LockQueue()) { + const XrResult result = + xrReleaseSwapchainImage(swapchain.handle, &release_info); + UnlockQueue(); + m_runtime->ObserveResult(result); + if (XR_FAILED(result)) { + may_destroy_swapchain = false; + Log(OpenXRLogLevel::Warning, + "xrReleaseSwapchainImage failed during Vulkan shutdown"); + } else { + swapchain.acquired = false; + swapchain.waited = false; + } + } else { + may_destroy_swapchain = false; + } + } else if (swapchain.acquired) { + // OpenXR does not permit releasing before a successful wait. Leave + // this child to xrDestroySession instead of violating the image + // call order with a best-effort release or destroy. + may_destroy_swapchain = false; + Log(OpenXRLogLevel::Warning, + "Vulkan swapchain still has an unwaited image; deferring its " + "destruction to xrDestroySession"); + } + if (swapchain.handle != XR_NULL_HANDLE && m_runtime != nullptr && + m_runtime->HasSession() && may_destroy_swapchain) { + const XrResult result = xrDestroySwapchain(swapchain.handle); + m_runtime->ObserveResult(result); + if (XR_FAILED(result)) { + Log(OpenXRLogLevel::Warning, + "xrDestroySwapchain failed during Vulkan shutdown"); + } + } + swapchain = {}; + } + m_swapchain_format = 0; +} + +void OpenXRVulkanBackend::Shutdown() { + if (m_runtime == nullptr) { + return; + } + DestroySwapchains(); + if (m_owns_session && m_runtime->HasSession()) { + m_runtime->DestroySession(); + } + m_owns_session = false; + m_runtime = nullptr; + m_native_context = {}; +} + +const std::array& +OpenXRVulkanBackend::ViewConfiguration() const { + return m_runtime != nullptr ? m_runtime->ViewConfiguration() + : kEmptyViewConfiguration; +} + +bool OpenXRVulkanBackend::LockQueue() { + if (m_native_context.lock_queue == nullptr) { + return Fail(XR_ERROR_VALIDATION_FAILURE, "LockQueue", + "no Vulkan queue lock callback is installed"); + } + if (!m_native_context.lock_queue(m_native_context.queue_userdata)) { + return Fail(XR_ERROR_RUNTIME_FAILURE, "LockQueue", + "Aurora refused the Vulkan graphics queue lock"); + } + return true; +} + +void OpenXRVulkanBackend::UnlockQueue() { + m_native_context.unlock_queue(m_native_context.queue_userdata); +} + +bool OpenXRVulkanBackend::Check(XrResult result, std::string_view operation) { + if (m_runtime != nullptr) { + m_runtime->ObserveResult(result); + } + if (XR_SUCCEEDED(result)) { + return true; + } + return Fail(result, operation, {}); +} + +bool OpenXRVulkanBackend::Fail(XrResult result, std::string_view operation, + std::string_view detail) { + m_last_error.result = result; + m_last_error.operation.assign(operation); + m_last_error.message = ResultString(result); + if (!detail.empty()) { + m_last_error.message.append(": "); + m_last_error.message.append(detail); + } + std::string message = m_last_error.operation; + message.append(" failed: "); + message.append(m_last_error.message); + Log(OpenXRLogLevel::Error, message); + return false; +} + +void OpenXRVulkanBackend::Log(OpenXRLogLevel level, + std::string_view message) const noexcept { + if (!m_logger) { + return; + } + try { + m_logger(level, message); + } catch (...) { + // Diagnostics must not escape a graphics/session teardown path. + } +} + +std::string OpenXRVulkanBackend::ResultString(XrResult result) const { + char buffer[XR_MAX_RESULT_STRING_SIZE]{}; + if (m_runtime != nullptr && m_runtime->IsInitialized() && + XR_SUCCEEDED(xrResultToString(m_runtime->Instance(), result, buffer))) { + return buffer; + } + return std::to_string(static_cast(result)); +} + +} // namespace mkw::vr + +#endif // defined(MKW_ENABLE_OPENXR)