// Copyright 2024 Dolphin Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #ifdef ENABLE_VR // VulkanLoader.h must come first — it defines VK_NO_PROTOTYPES before vulkan.h. #include "VideoBackends/Vulkan/VulkanLoader.h" #define XR_USE_GRAPHICS_API_VULKAN #include "VideoBackends/Vulkan/VulkanOpenXR.h" #include #include #include #include #include #include #include #include "Common/Assert.h" #include "Common/Logging/Log.h" #include "Common/ScopeGuard.h" #include "Common/Timer.h" #include "Core/Config/GraphicsSettings.h" #include "VideoBackends/Vulkan/CommandBufferManager.h" #include "VideoBackends/Vulkan/StateTracker.h" #include "VideoBackends/Vulkan/VKTexture.h" #include "VideoBackends/Vulkan/VulkanContext.h" #include "VideoCommon/AbstractGfx.h" #include "VideoCommon/TextureConfig.h" #include "VideoCommon/VideoConfig.h" #include "VideoCommon/OnScreenDisplay.h" #include "VideoCommon/VR/OpenXRManager.h" #ifdef _WIN32 #include // for SEH __try/__except #endif #if defined(ANDROID) #include #endif namespace Vulkan { std::unique_ptr g_openxr_vk; namespace { uint64_t ElapsedUs(uint64_t start_us, uint64_t end_us) { if (start_us == 0 || end_us <= start_us) return 0; return end_us - start_us; } template void DestroySwapchainVulkanObjects(T& swapchain) { const VkDevice device = g_vulkan_context->GetDevice(); // VKFramebuffer/VKTexture normally defer destruction until a command buffer is recycled. // OpenXR owns the VkImages, however, and xrDestroySwapchain may destroy them immediately. // Destroy our dependent Vulkan objects synchronously before returning ownership to OpenXR. for (auto& framebuffer : swapchain.framebuffers) { if (framebuffer) { const VkFramebuffer handle = framebuffer->ReleaseHandle(); if (handle != VK_NULL_HANDLE) vkDestroyFramebuffer(device, handle, nullptr); } } swapchain.framebuffers.clear(); for (auto& texture : swapchain.textures) { if (texture) { const VkImageView view = texture->ReleaseView(); if (view != VK_NULL_HANDLE) vkDestroyImageView(device, view, nullptr); } } swapchain.textures.clear(); for (VkImageView view : swapchain.fdm_views) vkDestroyImageView(device, view, nullptr); swapchain.fdm_views.clear(); } static void AppendOptionalOpenXRExtensions(std::vector* extensions) { if (VR::OpenXRManager::IsRuntimeExtensionSupported(XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME)) { extensions->push_back(XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME); INFO_LOG_FMT(VIDEO, "OpenXR: Enabling XR_FB_display_refresh_rate."); } // Fixed foveated rendering: on Vulkan the runtime hands us fragment density map images // (XR_FB_foveation_vulkan) that our swapchain render passes read. const auto foveation_exts = VR::OpenXRManager::GetAvailableFoveationExtensions(true); if (!foveation_exts.empty()) { extensions->insert(extensions->end(), foveation_exts.begin(), foveation_exts.end()); INFO_LOG_FMT(VIDEO, "OpenXR: Enabling XR_FB_foveation (+configuration, vulkan, " "swapchain_update_state)."); } } } // namespace #if defined(ANDROID) static void AppendOptionalAndroidOpenXRExtensions(std::vector* extensions) { if (VR::OpenXRManager::IsRuntimeExtensionSupported( XR_KHR_ANDROID_THREAD_SETTINGS_EXTENSION_NAME)) { extensions->push_back(XR_KHR_ANDROID_THREAD_SETTINGS_EXTENSION_NAME); INFO_LOG_FMT(VIDEO, "OpenXR: Enabling XR_KHR_android_thread_settings."); } if (VR::OpenXRManager::IsRuntimeExtensionSupported(XR_EXT_PERFORMANCE_SETTINGS_EXTENSION_NAME)) { extensions->push_back(XR_EXT_PERFORMANCE_SETTINGS_EXTENSION_NAME); INFO_LOG_FMT(VIDEO, "OpenXR: Enabling XR_EXT_performance_settings."); } } #endif XRVkEyeSwapchain::XRVkEyeSwapchain() = default; XRVkEyeSwapchain::~XRVkEyeSwapchain() = default; XRVkEyeSwapchain::XRVkEyeSwapchain(XRVkEyeSwapchain&&) noexcept = default; XRVkEyeSwapchain& XRVkEyeSwapchain::operator=(XRVkEyeSwapchain&&) noexcept = default; XRVkLayeredSwapchain::XRVkLayeredSwapchain() = default; XRVkLayeredSwapchain::~XRVkLayeredSwapchain() = default; XRVkLayeredSwapchain::XRVkLayeredSwapchain(XRVkLayeredSwapchain&&) noexcept = default; XRVkLayeredSwapchain& XRVkLayeredSwapchain::operator=(XRVkLayeredSwapchain&&) noexcept = default; static const char* VkFormatToString(int64_t format) { switch (static_cast(format)) { case VK_FORMAT_R8G8B8A8_UNORM: return "VK_FORMAT_R8G8B8A8_UNORM"; case VK_FORMAT_R8G8B8A8_SRGB: return "VK_FORMAT_R8G8B8A8_SRGB"; case VK_FORMAT_B8G8R8A8_UNORM: return "VK_FORMAT_B8G8R8A8_UNORM"; case VK_FORMAT_B8G8R8A8_SRGB: return "VK_FORMAT_B8G8R8A8_SRGB"; case VK_FORMAT_A2B10G10R10_UNORM_PACK32: return "VK_FORMAT_A2B10G10R10_UNORM_PACK32"; case VK_FORMAT_R16G16B16A16_SFLOAT: return "VK_FORMAT_R16G16B16A16_SFLOAT"; default: return "UNKNOWN_VK_FORMAT"; } } static AbstractTextureFormat VkFormatToAbstractFormat(VkFormat format) { switch (format) { case VK_FORMAT_R8G8B8A8_UNORM: case VK_FORMAT_R8G8B8A8_SRGB: return AbstractTextureFormat::RGBA8; case VK_FORMAT_B8G8R8A8_UNORM: case VK_FORMAT_B8G8R8A8_SRGB: return AbstractTextureFormat::BGRA8; case VK_FORMAT_A2B10G10R10_UNORM_PACK32: return AbstractTextureFormat::RGB10_A2; case VK_FORMAT_R16G16B16A16_SFLOAT: return AbstractTextureFormat::RGBA16F; default: return AbstractTextureFormat::RGBA8; } } static bool SelectSwapchainFormat(XrSession session, int64_t* out_format) { uint32_t format_count = 0; XrResult result = xrEnumerateSwapchainFormats(session, 0, &format_count, nullptr); if (XR_FAILED(result) || format_count == 0) { ERROR_LOG_FMT(VIDEO, "OpenXR: xrEnumerateSwapchainFormats (count) failed ({}).", static_cast(result)); return false; } std::vector runtime_formats(format_count); result = xrEnumerateSwapchainFormats(session, format_count, &format_count, runtime_formats.data()); if (XR_FAILED(result)) { ERROR_LOG_FMT(VIDEO, "OpenXR: xrEnumerateSwapchainFormats failed ({}).", static_cast(result)); return false; } for (const int64_t format : runtime_formats) { INFO_LOG_FMT(VIDEO, "OpenXR: Runtime swapchain format {} ({})", static_cast(format), VkFormatToString(format)); } #if defined(ANDROID) // Quest's compositor expects sRGB swapchains for Dolphin's gamma-encoded XFB. We render through // a UNORM view alias below to avoid a double gamma encode. static 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}; #else // Prefer sRGB on PC so the OpenXR compositor decodes Dolphin's gamma-encoded XFB correctly. static 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}; #endif for (const VkFormat preferred : preferred_formats) { const int64_t wanted = static_cast(preferred); if (std::find(runtime_formats.begin(), runtime_formats.end(), wanted) != runtime_formats.end()) { *out_format = wanted; INFO_LOG_FMT(VIDEO, "OpenXR: Selected swapchain format {} ({}).", static_cast(*out_format), VkFormatToString(*out_format)); return true; } } *out_format = runtime_formats.front(); WARN_LOG_FMT(VIDEO, "OpenXR: No preferred Vulkan swapchain format found; falling back to runtime " "format {} ({}).", static_cast(*out_format), VkFormatToString(*out_format)); return true; } // Separate function for SEH protection — __try cannot be used in functions // that have C++ objects requiring unwinding. #ifdef _WIN32 static XrResult SafeCreateSession(XrInstance instance, const XrSessionCreateInfo* info, XrSession* session) { __try { return xrCreateSession(instance, info, session); } __except (EXCEPTION_EXECUTE_HANDLER) { ERROR_LOG_FMT(VIDEO, "OpenXR: xrCreateSession CRASHED (exception {:#010x}). " "The OpenXR runtime may require Vulkan extensions that BaijiSteamVR did not enable. " "Check the 'Required Vulkan instance/device extensions' log lines above.", static_cast(GetExceptionCode())); return XR_ERROR_RUNTIME_FAILURE; } } #else static XrResult SafeCreateSession(XrInstance instance, const XrSessionCreateInfo* info, XrSession* session) { return xrCreateSession(instance, info, session); } #endif VulkanOpenXR::VulkanOpenXR() = default; VulkanOpenXR::~VulkanOpenXR() { Shutdown(); } std::unique_lock VulkanOpenXR::AcquireGraphicsQueueLock() { if (g_command_buffer_mgr) return g_command_buffer_mgr->AcquireQueueLock(); return {}; } bool VulkanOpenXR::WaitForPendingFrameFinalization(std::string_view reason) { #if defined(ANDROID) if (m_async_frame_finalization_in_flight.load(std::memory_order_acquire) || m_stereo_finalization_in_flight.load(std::memory_order_acquire)) { static uint32_t s_async_wait_log_count = 0; if (!g_command_buffer_mgr) { ERROR_LOG_FMT(VIDEO, "OpenXR Vulkan: pending async final XR submit cannot be waited because the " "command buffer manager is gone."); return false; } const uint64_t wait_start_us = Common::Timer::NowUs(); g_command_buffer_mgr->WaitForWorkerThreadIdle(); const uint64_t wait_us = ElapsedUs(wait_start_us, Common::Timer::NowUs()); if (s_async_wait_log_count < 20 || wait_us >= 500) { INFO_LOG_FMT(VIDEO, "OpenXR Vulkan: waited {} us for pending async final XR submit ({}).", wait_us, reason.empty() ? "frame loop" : reason); __android_log_print(ANDROID_LOG_INFO, "DolphinXR", "OpenXR Vulkan: waited %llu us for pending async final XR submit (%.*s)", static_cast(wait_us), static_cast(reason.empty() ? std::string_view{"frame loop"}.size() : reason.size()), reason.empty() ? "frame loop" : reason.data()); s_async_wait_log_count++; } } if (m_async_frame_finalization_failed.exchange(false, std::memory_order_acq_rel)) { ERROR_LOG_FMT(VIDEO, "OpenXR Vulkan: previous async final XR submit failed."); return false; } #endif return true; } void VulkanOpenXR::FinalizePendingXRFrame(PendingXRFrame frame, VkResult submit_result) { const uint64_t finalize_start_us = Common::Timer::NowUs(); uint64_t release_total_us = 0; uint64_t end_frame_us = 0; bool success = true; XrSwapchainImageReleaseInfo release_info{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; const auto release_swapchain = [&](XrSwapchain swapchain, std::string_view name) { if (swapchain == XR_NULL_HANDLE) return; const uint64_t release_start_us = Common::Timer::NowUs(); XrResult result = XR_SUCCESS; { auto queue_lock = AcquireGraphicsQueueLock(); result = xrReleaseSwapchainImage(swapchain, &release_info); } const uint64_t release_us = ElapsedUs(release_start_us, Common::Timer::NowUs()); release_total_us += release_us; if (XR_FAILED(result)) { WARN_LOG_FMT(VIDEO, "OpenXR: async xrReleaseSwapchainImage failed for {} ({}).", name, static_cast(result)); success = false; } }; if (frame.layered_acquired) release_swapchain(frame.layered_swapchain, "layered swapchain"); for (uint32_t eye = 0; eye < 2; ++eye) { if (frame.eye_acquired[eye]) release_swapchain(frame.eye_swapchains[eye], eye == 0 ? "eye 0" : "eye 1"); } if (submit_result != VK_SUCCESS) { m_async_frame_finalization_failed.store(true, std::memory_order_release); success = false; } if (!success) { m_async_frame_finalization_in_flight.store(false, std::memory_order_release); return; } const uint64_t end_frame_start_us = Common::Timer::NowUs(); if (!VR::g_openxr) { success = false; } else if (VR::g_openxr->IsFrameThreadActive()) { // The pacing thread owns xrEndFrame — publish the released frame's views to it. // Publishing after the releases guarantees the compositor picks up the new image. VR::g_openxr->PublishFrame(frame.projection_views, frame.layer_flags); } else { XrCompositionLayerProjection projection_layer{XR_TYPE_COMPOSITION_LAYER_PROJECTION}; projection_layer.layerFlags = frame.layer_flags; projection_layer.space = frame.space; projection_layer.viewCount = static_cast(frame.projection_views.size()); projection_layer.views = frame.projection_views.data(); const std::vector layers = { reinterpret_cast(&projection_layer)}; if (!VR::g_openxr->EndFrameDetached(frame.display_time, frame.environment_blend_mode, frame.should_render, layers)) { success = false; } } end_frame_us = ElapsedUs(end_frame_start_us, Common::Timer::NowUs()); if (!success) m_async_frame_finalization_failed.store(true, std::memory_order_release); const uint64_t finalize_end_us = Common::Timer::NowUs(); const uint64_t queue_delay_us = ElapsedUs(frame.queued_time_us, finalize_start_us); const uint64_t finalize_us = ElapsedUs(finalize_start_us, finalize_end_us); if (frame.debug_frame_id <= 20 || frame.debug_frame_id % 300 == 0 || queue_delay_us >= 5000 || finalize_us >= 5000) { INFO_LOG_FMT(VIDEO, "OpenXR Vulkan: async final XR submit #{} timing queue_delay={}us " "release={}us end_frame={}us finalize={}us success={}.", frame.debug_frame_id, queue_delay_us, release_total_us, end_frame_us, finalize_us, success); #if defined(ANDROID) __android_log_print(ANDROID_LOG_INFO, "DolphinXR", "OpenXR Vulkan: async final XR submit #%llu timing queue_delay=%lluus " "release=%lluus end_frame=%lluus finalize=%lluus success=%d", static_cast(frame.debug_frame_id), static_cast(queue_delay_us), static_cast(release_total_us), static_cast(end_frame_us), static_cast(finalize_us), static_cast(success)); #endif } m_async_frame_finalization_in_flight.store(false, std::memory_order_release); } // static bool VulkanOpenXR::PreQueryVulkanExtensions(VulkanExtensionRequirements& out) { INFO_LOG_FMT(VIDEO, "OpenXR Vulkan: Pre-querying required Vulkan extensions..."); // Dolphin owns VkInstance/VkDevice creation and the runtime only binds to them at // xrCreateSession. The runtime-assisted XR_KHR_vulkan_enable2 path // (xrCreateVulkanInstanceKHR/xrCreateVulkanDeviceKHR) is deliberately not used: creating a // second device against the same XrInstance crashes inside SteamVR's Vulkan interop. INFO_LOG_FMT(VIDEO, "OpenXR Vulkan: Using {}.", XR_KHR_VULKAN_ENABLE_EXTENSION_NAME); auto mgr = std::make_unique(); std::vector extensions = {XR_KHR_VULKAN_ENABLE_EXTENSION_NAME}; AppendOptionalOpenXRExtensions(&extensions); #if defined(ANDROID) extensions.push_back(XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME); AppendOptionalAndroidOpenXRExtensions(&extensions); #endif // Required by the OpenXR spec when XR_SWAPCHAIN_USAGE_MUTABLE_FORMAT_BIT is used // on Vulkan. The sRGB swapchain path uses a UNORM view alias to avoid double gamma. if (VR::OpenXRManager::IsRuntimeExtensionSupported( XR_KHR_VULKAN_SWAPCHAIN_FORMAT_LIST_EXTENSION_NAME)) { extensions.push_back(XR_KHR_VULKAN_SWAPCHAIN_FORMAT_LIST_EXTENSION_NAME); } const auto controller_exts = VR::OpenXRManager::GetAvailableControllerExtensions(); extensions.insert(extensions.end(), controller_exts.begin(), controller_exts.end()); if (!mgr->CreateInstance(extensions)) return false; if (!mgr->InitializeSystem()) return false; if (!mgr->EnumerateViewConfigurations()) return false; const XrInstance xr_instance = mgr->GetInstance(); const XrSystemId xr_system = mgr->GetSystemId(); PFN_xrGetVulkanGraphicsRequirementsKHR pfnGetVulkanRequirements = nullptr; xrGetInstanceProcAddr(xr_instance, "xrGetVulkanGraphicsRequirementsKHR", reinterpret_cast(&pfnGetVulkanRequirements)); if (pfnGetVulkanRequirements) { XrGraphicsRequirementsVulkanKHR requirements{XR_TYPE_GRAPHICS_REQUIREMENTS_VULKAN_KHR}; const XrResult result = pfnGetVulkanRequirements(xr_instance, xr_system, &requirements); if (XR_SUCCEEDED(result)) { const u32 reported_max = VK_MAKE_API_VERSION(0, XR_VERSION_MAJOR(requirements.maxApiVersionSupported), XR_VERSION_MINOR(requirements.maxApiVersionSupported), XR_VERSION_PATCH(requirements.maxApiVersionSupported)); INFO_LOG_FMT(VIDEO, "OpenXR: Pre-query Vulkan API range min {}.{}.{}, max {}.{}.{}", XR_VERSION_MAJOR(requirements.minApiVersionSupported), XR_VERSION_MINOR(requirements.minApiVersionSupported), XR_VERSION_PATCH(requirements.minApiVersionSupported), XR_VERSION_MAJOR(requirements.maxApiVersionSupported), XR_VERSION_MINOR(requirements.maxApiVersionSupported), XR_VERSION_PATCH(requirements.maxApiVersionSupported)); // XR_KHR_vulkan_enable v1 always reports max=1.0.0 (Meta's Oculus runtime quirk), // even though the runtime actually supports newer Vulkan. Clamping the instance to // 1.0 there breaks BaijiSteamVR's multiview path (a 1.1 feature) and crashes vkCreateDevice. // Treat 1.0.0 as "unknown" so BaijiSteamVR keeps its negotiated 1.1/1.2 instance. if (reported_max <= VK_API_VERSION_1_0) { WARN_LOG_FMT(VIDEO, "OpenXR: Runtime reported max Vulkan 1.0.0 (v1 extension quirk); " "ignoring and using BaijiSteamVR's instance version."); out.max_api_version = 0; } else { out.max_api_version = reported_max; } } } // Query required Vulkan instance extensions. PFN_xrGetVulkanInstanceExtensionsKHR pfnGetInstanceExts = nullptr; xrGetInstanceProcAddr(xr_instance, "xrGetVulkanInstanceExtensionsKHR", reinterpret_cast(&pfnGetInstanceExts)); if (pfnGetInstanceExts) { uint32_t ext_len = 0; pfnGetInstanceExts(xr_instance, xr_system, 0, &ext_len, nullptr); if (ext_len > 0) { std::string ext_str(ext_len, '\0'); pfnGetInstanceExts(xr_instance, xr_system, ext_len, &ext_len, ext_str.data()); INFO_LOG_FMT(VIDEO, "OpenXR: Required Vulkan instance extensions: {}", ext_str); // Parse space-separated extension list. std::istringstream iss(ext_str); std::string ext; while (iss >> ext) out.instance_extensions.push_back(ext); } } // Query required Vulkan device extensions. PFN_xrGetVulkanDeviceExtensionsKHR pfnGetDeviceExts = nullptr; xrGetInstanceProcAddr(xr_instance, "xrGetVulkanDeviceExtensionsKHR", reinterpret_cast(&pfnGetDeviceExts)); if (pfnGetDeviceExts) { uint32_t ext_len = 0; pfnGetDeviceExts(xr_instance, xr_system, 0, &ext_len, nullptr); if (ext_len > 0) { std::string ext_str(ext_len, '\0'); pfnGetDeviceExts(xr_instance, xr_system, ext_len, &ext_len, ext_str.data()); INFO_LOG_FMT(VIDEO, "OpenXR: Required Vulkan device extensions: {}", ext_str); std::istringstream iss(ext_str); std::string ext; while (iss >> ext) out.device_extensions.push_back(ext); } } // Keep the OpenXRManager alive — Initialize() will reuse it. VR::g_openxr = std::move(mgr); INFO_LOG_FMT(VIDEO, "OpenXR Vulkan: Pre-query complete ({} instance, {} device extensions).", out.instance_extensions.size(), out.device_extensions.size()); return true; } bool VulkanOpenXR::Initialize() { INFO_LOG_FMT(VIDEO, "OpenXR Vulkan: Starting initialization..."); // If PreQueryVulkanExtensions() was called, VR::g_openxr already exists. if (!VR::g_openxr) { auto mgr = std::make_unique(); std::vector extensions = {XR_KHR_VULKAN_ENABLE_EXTENSION_NAME}; AppendOptionalOpenXRExtensions(&extensions); #if defined(ANDROID) extensions.push_back(XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME); AppendOptionalAndroidOpenXRExtensions(&extensions); #endif if (VR::OpenXRManager::IsRuntimeExtensionSupported( XR_KHR_VULKAN_SWAPCHAIN_FORMAT_LIST_EXTENSION_NAME)) { extensions.push_back(XR_KHR_VULKAN_SWAPCHAIN_FORMAT_LIST_EXTENSION_NAME); } const auto controller_exts = VR::OpenXRManager::GetAvailableControllerExtensions(); extensions.insert(extensions.end(), controller_exts.begin(), controller_exts.end()); if (!mgr->CreateInstance(extensions)) return false; if (!mgr->InitializeSystem()) return false; if (!mgr->EnumerateViewConfigurations()) return false; VR::g_openxr = std::move(mgr); } #if defined(_WIN32) || defined(ANDROID) // Windows desktop and Quest standalone: keep coverage only when the OpenXR runtime // can actually composite it (XR_FB_passthrough or an ALPHA_BLEND environment). g_backend_info.bSupportsVRPassthroughCoverage &= VR::g_openxr->SupportsPassthrough(); if (g_ActiveConfig.vr_passthrough && !g_ActiveConfig.vr_stereo_screen && !g_backend_info.bSupportsVRPassthroughCoverage) { OSD::AddMessage("Passthrough disabled: this OpenXR runtime does not expose a compatible " "passthrough mode.", OSD::Duration::VERY_LONG); Config::SetBaseOrCurrent(Config::GFX_VR_PASSTHROUGH, false); g_ActiveConfig.vr_passthrough = false; } #else g_backend_info.bSupportsVRPassthroughCoverage = false; #endif INFO_LOG_FMT(VIDEO, "OpenXR Vulkan: Creating session..."); if (!CreateSessionVulkan()) { ERROR_LOG_FMT(VIDEO, "OpenXR Vulkan: Session creation failed — disabling VR."); VR::g_openxr.reset(); return false; } INFO_LOG_FMT(VIDEO, "OpenXR Vulkan: Creating reference space..."); if (!VR::g_openxr->CreateReferenceSpace()) { ERROR_LOG_FMT(VIDEO, "OpenXR Vulkan: Reference space creation failed — disabling VR."); VR::g_openxr.reset(); return false; } INFO_LOG_FMT(VIDEO, "OpenXR Vulkan: Creating swapchains..."); if (!CreateSwapchains()) { ERROR_LOG_FMT(VIDEO, "OpenXR Vulkan: Swapchain creation failed — disabling VR."); VR::g_openxr.reset(); return false; } // Register this object as the swapchain provider so Presenter can acquire eye images. VR::g_openxr->SetSwapchain(this); INFO_LOG_FMT(VIDEO, "OpenXR Vulkan: Initialization complete."); return true; } void VulkanOpenXR::Shutdown() { WaitForPendingFrameFinalization("during shutdown"); // Clear swapchain pointer before destroying swapchains so no dangling use occurs. if (VR::g_openxr) VR::g_openxr->SetSwapchain(nullptr); DestroySwapchains(); // Fully tear down the runtime between games, mirroring the D3D11 backend. ~OpenXRManager() runs // DestroySession() (ordered session exit) before destroying the XrInstance, so the next game // starts from a clean runtime state instead of a retained instance bound to a dead device. if (VR::g_openxr) VR::g_openxr.reset(); // Wait for all GPU work to finish before the Vulkan device is destroyed. Once the device has // already been lost, another wait only re-enters the failing driver path. if (g_vulkan_context && (!g_command_buffer_mgr || !g_command_buffer_mgr->IsDeviceLost())) vkDeviceWaitIdle(g_vulkan_context->GetDevice()); INFO_LOG_FMT(VIDEO, "OpenXR Vulkan: Shut down."); } bool VulkanOpenXR::CreateSessionVulkan() { ASSERT(g_vulkan_context != nullptr); ASSERT(VR::g_openxr != nullptr); const XrInstance xr_instance = VR::g_openxr->GetInstance(); const XrSystemId xr_system = VR::g_openxr->GetSystemId(); // --- Query Vulkan graphics requirements (mandatory before session creation) --- INFO_LOG_FMT(VIDEO, "OpenXR Vulkan: Querying graphics requirements..."); PFN_xrGetVulkanGraphicsRequirementsKHR pfnGetVulkanRequirements = nullptr; const char* const requirements_function = "xrGetVulkanGraphicsRequirementsKHR"; XrResult result = xrGetInstanceProcAddr( xr_instance, requirements_function, reinterpret_cast(&pfnGetVulkanRequirements)); if (XR_FAILED(result) || pfnGetVulkanRequirements == nullptr) { ERROR_LOG_FMT(VIDEO, "OpenXR: Could not load {}.", requirements_function); return false; } XrGraphicsRequirementsVulkanKHR requirements{XR_TYPE_GRAPHICS_REQUIREMENTS_VULKAN_KHR}; result = pfnGetVulkanRequirements(xr_instance, xr_system, &requirements); if (XR_FAILED(result)) { ERROR_LOG_FMT(VIDEO, "OpenXR: xrGetVulkanGraphicsRequirementsKHR failed ({}).", static_cast(result)); return false; } INFO_LOG_FMT(VIDEO, "OpenXR: Vulkan requirements — min API {}.{}.{}, max API {}.{}.{}", XR_VERSION_MAJOR(requirements.minApiVersionSupported), XR_VERSION_MINOR(requirements.minApiVersionSupported), XR_VERSION_PATCH(requirements.minApiVersionSupported), XR_VERSION_MAJOR(requirements.maxApiVersionSupported), XR_VERSION_MINOR(requirements.maxApiVersionSupported), XR_VERSION_PATCH(requirements.maxApiVersionSupported)); // Log BaijiSteamVR's Vulkan API version for comparison. const u32 dolphin_api = g_vulkan_context->GetDeviceInfo().apiVersion; INFO_LOG_FMT(VIDEO, "OpenXR: BaijiSteamVR VkPhysicalDevice API version {}.{}.{}", VK_VERSION_MAJOR(dolphin_api), VK_VERSION_MINOR(dolphin_api), VK_VERSION_PATCH(dolphin_api)); // --- Query required Vulkan instance extensions --- INFO_LOG_FMT(VIDEO, "OpenXR Vulkan: Querying required Vulkan instance extensions..."); PFN_xrGetVulkanInstanceExtensionsKHR pfnGetInstanceExts = nullptr; result = xrGetInstanceProcAddr(xr_instance, "xrGetVulkanInstanceExtensionsKHR", reinterpret_cast(&pfnGetInstanceExts)); if (XR_SUCCEEDED(result) && pfnGetInstanceExts != nullptr) { uint32_t ext_len = 0; pfnGetInstanceExts(xr_instance, xr_system, 0, &ext_len, nullptr); if (ext_len > 0) { std::string ext_str(ext_len, '\0'); pfnGetInstanceExts(xr_instance, xr_system, ext_len, &ext_len, ext_str.data()); INFO_LOG_FMT(VIDEO, "OpenXR: Required Vulkan instance extensions: {}", ext_str); } } // --- Query required Vulkan device extensions --- INFO_LOG_FMT(VIDEO, "OpenXR Vulkan: Querying required Vulkan device extensions..."); PFN_xrGetVulkanDeviceExtensionsKHR pfnGetDeviceExts = nullptr; result = xrGetInstanceProcAddr(xr_instance, "xrGetVulkanDeviceExtensionsKHR", reinterpret_cast(&pfnGetDeviceExts)); if (XR_SUCCEEDED(result) && pfnGetDeviceExts != nullptr) { uint32_t ext_len = 0; pfnGetDeviceExts(xr_instance, xr_system, 0, &ext_len, nullptr); if (ext_len > 0) { std::string ext_str(ext_len, '\0'); pfnGetDeviceExts(xr_instance, xr_system, ext_len, &ext_len, ext_str.data()); INFO_LOG_FMT(VIDEO, "OpenXR: Required Vulkan device extensions: {}", ext_str); } } // --- Verify the physical device matches what the runtime expects --- INFO_LOG_FMT(VIDEO, "OpenXR Vulkan: Checking physical device..."); VkPhysicalDevice xr_physical_device = VK_NULL_HANDLE; PFN_xrGetVulkanGraphicsDeviceKHR pfnGetVulkanDevice = nullptr; result = xrGetInstanceProcAddr(xr_instance, "xrGetVulkanGraphicsDeviceKHR", reinterpret_cast(&pfnGetVulkanDevice)); if (XR_SUCCEEDED(result) && pfnGetVulkanDevice != nullptr) { pfnGetVulkanDevice(xr_instance, xr_system, g_vulkan_context->GetVulkanInstance(), &xr_physical_device); } if (xr_physical_device != VK_NULL_HANDLE && xr_physical_device != g_vulkan_context->GetPhysicalDevice()) { ERROR_LOG_FMT(VIDEO, "OpenXR: Active VkPhysicalDevice differs from the runtime selection."); return false; } INFO_LOG_FMT(VIDEO, "OpenXR: VkPhysicalDevice matches runtime expectation."); // --- Create XrSession bound to the active Vulkan device --- INFO_LOG_FMT(VIDEO, "OpenXR Vulkan: Creating XrSession with Vulkan binding..."); INFO_LOG_FMT(VIDEO, " VkInstance={}, VkPhysicalDevice={}, VkDevice={}, queueFamily={}, " "queueIndex=0", reinterpret_cast(g_vulkan_context->GetVulkanInstance()), reinterpret_cast(g_vulkan_context->GetPhysicalDevice()), reinterpret_cast(g_vulkan_context->GetDevice()), g_vulkan_context->GetGraphicsQueueFamilyIndex()); XrGraphicsBindingVulkanKHR vk_binding{XR_TYPE_GRAPHICS_BINDING_VULKAN_KHR}; vk_binding.instance = g_vulkan_context->GetVulkanInstance(); vk_binding.physicalDevice = g_vulkan_context->GetPhysicalDevice(); vk_binding.device = g_vulkan_context->GetDevice(); vk_binding.queueFamilyIndex = g_vulkan_context->GetGraphicsQueueFamilyIndex(); vk_binding.queueIndex = 0; XrSessionCreateInfo session_info{XR_TYPE_SESSION_CREATE_INFO}; session_info.next = &vk_binding; session_info.systemId = xr_system; XrSession session = XR_NULL_HANDLE; result = SafeCreateSession(xr_instance, &session_info, &session); if (XR_FAILED(result)) { ERROR_LOG_FMT(VIDEO, "OpenXR: xrCreateSession failed ({}).", static_cast(result)); return false; } VR::g_openxr->SetSession(session); INFO_LOG_FMT(VIDEO, "OpenXR Vulkan: Session created successfully."); return true; } // static bool VulkanOpenXR::ShouldUseFoveation() { // Foveate only the stereo projection path: the flat panel reuses eye swapchain #0, // and foveating a world-locked quad would just blur its edges for no gain. return g_ActiveConfig.stereo_mode == StereoMode::OpenXR && VR::g_openxr && VR::g_openxr->IsFoveationUsable() && g_vulkan_context->SupportsFragmentDensityMap(); } // static bool VulkanOpenXR::PrepareFoveationImages( const std::vector& fdm_images, std::vector* out_views) { out_views->clear(); out_views->reserve(fdm_images.size()); const auto cleanup = [out_views]() { for (VkImageView view : *out_views) vkDestroyImageView(g_vulkan_context->GetDevice(), view, nullptr); out_views->clear(); }; for (const auto& fdm : fdm_images) { if (fdm.image == VK_NULL_HANDLE) { WARN_LOG_FMT(VIDEO, "OpenXR: Runtime returned no fragment density map image."); cleanup(); return false; } VkImageViewCreateInfo view_info = {VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO}; view_info.image = fdm.image; // 2D_ARRAY covers both per-eye (1 layer) and multiview (2 layer) density maps. view_info.viewType = VK_IMAGE_VIEW_TYPE_2D_ARRAY; view_info.format = VK_FORMAT_R8G8_UNORM; view_info.components = {VK_COMPONENT_SWIZZLE_IDENTITY, VK_COMPONENT_SWIZZLE_IDENTITY, VK_COMPONENT_SWIZZLE_IDENTITY, VK_COMPONENT_SWIZZLE_IDENTITY}; view_info.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, VK_REMAINING_ARRAY_LAYERS}; VkImageView view = VK_NULL_HANDLE; const VkResult res = vkCreateImageView(g_vulkan_context->GetDevice(), &view_info, nullptr, &view); if (res != VK_SUCCESS) { LOG_VULKAN_ERROR(res, "vkCreateImageView (fragment density map) failed: "); cleanup(); return false; } out_views->push_back(view); // One-time transition into the fixed layout the render passes expect. Contents at // this point are only pre-profile defaults; the runtime rewrites the density values // once the foveation profile is applied via xrUpdateSwapchainFB. VkImageMemoryBarrier barrier = {VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER}; barrier.srcAccessMask = 0; barrier.dstAccessMask = VK_ACCESS_FRAGMENT_DENSITY_MAP_READ_BIT_EXT; barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; barrier.newLayout = VK_IMAGE_LAYOUT_FRAGMENT_DENSITY_MAP_OPTIMAL_EXT; barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; barrier.image = fdm.image; barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, VK_REMAINING_ARRAY_LAYERS}; vkCmdPipelineBarrier(g_command_buffer_mgr->GetCurrentInitCommandBuffer(), VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_FRAGMENT_DENSITY_PROCESS_BIT_EXT, 0, 0, nullptr, 0, nullptr, 1, &barrier); } return true; } bool VulkanOpenXR::CreateSwapchains() { ASSERT(VR::g_openxr != nullptr); int64_t swapchain_format = 0; if (!SelectSwapchainFormat(VR::g_openxr->GetSession(), &swapchain_format)) return false; m_use_layered_swapchain = false; m_frame_uses_layered_swapchain = false; m_foveated = false; #if defined(ANDROID) const auto& view_cfgs = VR::g_openxr->GetViewConfigViews(); const bool matching_eye_sizes = view_cfgs[0].recommendedImageRectWidth == view_cfgs[1].recommendedImageRectWidth && view_cfgs[0].recommendedImageRectHeight == view_cfgs[1].recommendedImageRectHeight; if (g_vulkan_context->SupportsMultiview() && g_ActiveConfig.vr_use_vulkan_multiview && g_ActiveConfig.stereo_mode == StereoMode::OpenXR) { if (matching_eye_sizes) { if (CreateLayeredSwapchain(swapchain_format)) { m_use_layered_swapchain = true; } else { WARN_LOG_FMT(VIDEO, "OpenXR: Layered Vulkan swapchain creation failed; falling back to " "two per-eye swapchains."); } } else { WARN_LOG_FMT(VIDEO, "OpenXR: Layered Vulkan swapchain disabled because eye sizes differ " "({}x{} vs {}x{}).", view_cfgs[0].recommendedImageRectWidth, view_cfgs[0].recommendedImageRectHeight, view_cfgs[1].recommendedImageRectWidth, view_cfgs[1].recommendedImageRectHeight); } } #endif if (!CreateEyeSwapchains(swapchain_format)) { DestroySwapchains(); return false; } return true; } bool VulkanOpenXR::CreateLayeredSwapchain(int64_t swapchain_format) { ASSERT(VR::g_openxr != nullptr); const auto& view_cfgs = VR::g_openxr->GetViewConfigViews(); const AbstractTextureFormat abstract_format = VkFormatToAbstractFormat(static_cast(swapchain_format)); auto& sc = m_layered_swapchain; sc.width = view_cfgs[0].recommendedImageRectWidth; sc.height = view_cfgs[0].recommendedImageRectHeight; auto cleanup = [&sc]() { DestroySwapchainVulkanObjects(sc); if (sc.swapchain != XR_NULL_HANDLE) { xrDestroySwapchain(sc.swapchain); sc.swapchain = XR_NULL_HANDLE; } sc.width = 0; sc.height = 0; }; XrSwapchainCreateInfo info{XR_TYPE_SWAPCHAIN_CREATE_INFO}; info.arraySize = 2; info.format = swapchain_format; info.width = sc.width; info.height = sc.height; info.mipCount = 1; info.faceCount = 1; info.sampleCount = 1; info.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_TRANSFER_DST_BIT; const VkFormat vk_sc_format = static_cast(swapchain_format); const VkFormat vk_view_format = VKTexture::GetLinearFormat(vk_sc_format); const std::array view_formats = {vk_sc_format, vk_view_format}; XrVulkanSwapchainFormatListCreateInfoKHR format_list{ XR_TYPE_VULKAN_SWAPCHAIN_FORMAT_LIST_CREATE_INFO_KHR}; if (vk_view_format != vk_sc_format) { info.usageFlags |= XR_SWAPCHAIN_USAGE_MUTABLE_FORMAT_BIT; if (VR::g_openxr->IsExtensionEnabled(XR_KHR_VULKAN_SWAPCHAIN_FORMAT_LIST_EXTENSION_NAME)) { format_list.viewFormatCount = static_cast(view_formats.size()); format_list.viewFormats = view_formats.data(); info.next = &format_list; } } // Fixed foveated rendering: ask the runtime to allocate fragment density maps. XrSwapchainCreateInfoFoveationFB foveation_info{XR_TYPE_SWAPCHAIN_CREATE_INFO_FOVEATION_FB}; bool foveated = ShouldUseFoveation(); if (foveated) { foveation_info.flags = XR_SWAPCHAIN_CREATE_FOVEATION_FRAGMENT_DENSITY_MAP_BIT_FB; // XrSwapchainCreateInfoFoveationFB::next is (non-const) void*. foveation_info.next = const_cast(info.next); info.next = &foveation_info; } XrResult result = xrCreateSwapchain(VR::g_openxr->GetSession(), &info, &sc.swapchain); if (XR_FAILED(result) && foveated) { WARN_LOG_FMT(VIDEO, "OpenXR: Foveated layered swapchain creation failed ({}); retrying without " "foveation.", static_cast(result)); info.next = foveation_info.next; foveated = false; result = xrCreateSwapchain(VR::g_openxr->GetSession(), &info, &sc.swapchain); } if (XR_FAILED(result)) { WARN_LOG_FMT(VIDEO, "OpenXR: xrCreateSwapchain failed for layered Vulkan swapchain ({}).", static_cast(result)); cleanup(); return false; } uint32_t image_count = 0; result = xrEnumerateSwapchainImages(sc.swapchain, 0, &image_count, nullptr); if (XR_FAILED(result) || image_count == 0) { WARN_LOG_FMT(VIDEO, "OpenXR: xrEnumerateSwapchainImages failed for layered Vulkan swapchain ({}).", static_cast(result)); cleanup(); return false; } std::vector images(image_count, {XR_TYPE_SWAPCHAIN_IMAGE_VULKAN_KHR}); std::vector fdm_images; if (foveated) { fdm_images.resize(image_count, {XR_TYPE_SWAPCHAIN_IMAGE_FOVEATION_VULKAN_FB}); for (uint32_t i = 0; i < image_count; ++i) images[i].next = &fdm_images[i]; } result = xrEnumerateSwapchainImages( sc.swapchain, image_count, &image_count, reinterpret_cast(images.data())); if (XR_FAILED(result)) { WARN_LOG_FMT(VIDEO, "OpenXR: xrEnumerateSwapchainImages data failed for layered Vulkan swapchain " "({}).", static_cast(result)); cleanup(); return false; } if (foveated && !PrepareFoveationImages(fdm_images, &sc.fdm_views)) { WARN_LOG_FMT(VIDEO, "OpenXR: Layered swapchain continues without foveation."); foveated = false; } sc.textures.resize(image_count); sc.framebuffers.resize(image_count); for (uint32_t i = 0; i < image_count; ++i) { TextureConfig tex_config(sc.width, sc.height, /*levels=*/1, /*layers=*/2, /*samples=*/1, abstract_format, AbstractTextureFlag_RenderTarget, AbstractTextureType::Texture_2DArray); sc.textures[i] = VKTexture::CreateAdopted(tex_config, images[i].image, VK_IMAGE_VIEW_TYPE_2D_ARRAY, VK_IMAGE_LAYOUT_UNDEFINED, vk_view_format); if (!sc.textures[i]) { WARN_LOG_FMT(VIDEO, "OpenXR: VKTexture::CreateAdopted failed for layered Vulkan image {}.", i); cleanup(); return false; } sc.framebuffers[i] = VKFramebuffer::CreateMultiview( sc.textures[i].get(), nullptr, {}, foveated ? sc.fdm_views[i] : VK_NULL_HANDLE); if (!sc.framebuffers[i]) { WARN_LOG_FMT(VIDEO, "OpenXR: VKFramebuffer::CreateMultiview failed for layered Vulkan image {}.", i); cleanup(); return false; } } if (foveated) { VR::g_openxr->ApplyFoveationToSwapchain(sc.swapchain); m_foveated = true; } INFO_LOG_FMT(VIDEO, "OpenXR: Layered Vulkan swapchain ready: {}x{}, {} images, arraySize=2{}.", sc.width, sc.height, image_count, foveated ? ", foveated" : ""); return true; } bool VulkanOpenXR::CreateEyeSwapchains(int64_t swapchain_format) { ASSERT(VR::g_openxr != nullptr); const auto& view_cfgs = VR::g_openxr->GetViewConfigViews(); const AbstractTextureFormat abstract_format = VkFormatToAbstractFormat(static_cast(swapchain_format)); for (uint32_t eye = 0; eye < 2; ++eye) { auto& sc = m_eye_swapchains[eye]; sc.width = view_cfgs[eye].recommendedImageRectWidth; sc.height = view_cfgs[eye].recommendedImageRectHeight; // Format must come from xrEnumerateSwapchainFormats. XrSwapchainCreateInfo info{XR_TYPE_SWAPCHAIN_CREATE_INFO}; info.arraySize = 1; info.format = swapchain_format; info.width = sc.width; info.height = sc.height; info.mipCount = 1; info.faceCount = 1; info.sampleCount = 1; info.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_TRANSFER_DST_BIT; const VkFormat vk_sc_format = static_cast(swapchain_format); const VkFormat vk_view_format = VKTexture::GetLinearFormat(vk_sc_format); const std::array view_formats = {vk_sc_format, vk_view_format}; XrVulkanSwapchainFormatListCreateInfoKHR format_list{ XR_TYPE_VULKAN_SWAPCHAIN_FORMAT_LIST_CREATE_INFO_KHR}; if (vk_view_format != vk_sc_format) { info.usageFlags |= XR_SWAPCHAIN_USAGE_MUTABLE_FORMAT_BIT; if (VR::g_openxr->IsExtensionEnabled(XR_KHR_VULKAN_SWAPCHAIN_FORMAT_LIST_EXTENSION_NAME)) { format_list.viewFormatCount = static_cast(view_formats.size()); format_list.viewFormats = view_formats.data(); info.next = &format_list; } } // Fixed foveated rendering: ask the runtime to allocate fragment density maps. XrSwapchainCreateInfoFoveationFB foveation_info{XR_TYPE_SWAPCHAIN_CREATE_INFO_FOVEATION_FB}; bool foveated = ShouldUseFoveation(); if (foveated) { foveation_info.flags = XR_SWAPCHAIN_CREATE_FOVEATION_FRAGMENT_DENSITY_MAP_BIT_FB; // XrSwapchainCreateInfoFoveationFB::next is (non-const) void*. foveation_info.next = const_cast(info.next); info.next = &foveation_info; } XrResult result = xrCreateSwapchain(VR::g_openxr->GetSession(), &info, &sc.swapchain); if (XR_FAILED(result) && foveated) { WARN_LOG_FMT(VIDEO, "OpenXR: Foveated swapchain creation failed for eye {} ({}); retrying " "without foveation.", eye, static_cast(result)); info.next = foveation_info.next; foveated = false; result = xrCreateSwapchain(VR::g_openxr->GetSession(), &info, &sc.swapchain); } if (XR_FAILED(result)) { ERROR_LOG_FMT(VIDEO, "OpenXR: xrCreateSwapchain failed for eye {} ({}).", eye, static_cast(result)); return false; } // Enumerate the Vulkan images backing this swapchain. uint32_t image_count = 0; xrEnumerateSwapchainImages(sc.swapchain, 0, &image_count, nullptr); std::vector images(image_count, {XR_TYPE_SWAPCHAIN_IMAGE_VULKAN_KHR}); std::vector fdm_images; if (foveated) { fdm_images.resize(image_count, {XR_TYPE_SWAPCHAIN_IMAGE_FOVEATION_VULKAN_FB}); for (uint32_t i = 0; i < image_count; ++i) images[i].next = &fdm_images[i]; } xrEnumerateSwapchainImages(sc.swapchain, image_count, &image_count, reinterpret_cast(images.data())); if (foveated && !PrepareFoveationImages(fdm_images, &sc.fdm_views)) { WARN_LOG_FMT(VIDEO, "OpenXR: Eye {} swapchain continues without foveation.", eye); foveated = false; } sc.textures.resize(image_count); sc.framebuffers.resize(image_count); for (uint32_t i = 0; i < image_count; ++i) { // Build a TextureConfig matching the swapchain image properties. TextureConfig tex_config(sc.width, sc.height, /*levels=*/1, /*layers=*/1, /*samples=*/1, abstract_format, AbstractTextureFlag_RenderTarget, AbstractTextureType::Texture_2D); // Adopt the runtime-owned VkImage. For sRGB swapchains, use a UNORM view alias so // BlitFromTexture writes raw sRGB-encoded bytes without a second sRGB encode. sc.textures[i] = VKTexture::CreateAdopted(tex_config, images[i].image, VK_IMAGE_VIEW_TYPE_2D, VK_IMAGE_LAYOUT_UNDEFINED, vk_view_format); if (!sc.textures[i]) { ERROR_LOG_FMT(VIDEO, "OpenXR: VKTexture::CreateAdopted failed for eye {}, image {}.", eye, i); return false; } sc.framebuffers[i] = VKFramebuffer::Create(sc.textures[i].get(), nullptr, {}, foveated ? sc.fdm_views[i] : VK_NULL_HANDLE); if (!sc.framebuffers[i]) { ERROR_LOG_FMT(VIDEO, "OpenXR: VKFramebuffer::Create failed for eye {}, image {}.", eye, i); return false; } } if (foveated) { VR::g_openxr->ApplyFoveationToSwapchain(sc.swapchain); m_foveated = true; } INFO_LOG_FMT(VIDEO, "OpenXR: Eye {} swapchain ready: {}x{}, {} images{}.", eye, sc.width, sc.height, image_count, foveated ? ", foveated" : ""); } return true; } void VulkanOpenXR::DestroySwapchains() { // Finish and submit the current command buffer as well as waiting for queued GPU work. A plain // vkDeviceWaitIdle does not make it safe to destroy objects referenced by an unsubmitted buffer. // Do not submit again after VK_ERROR_DEVICE_LOST; the command buffer manager has already queued // a clean emulation stop and further driver calls can make the failure cascade. if (g_gfx && (!g_command_buffer_mgr || !g_command_buffer_mgr->IsDeviceLost())) g_gfx->WaitForGPUIdle(); else if (g_vulkan_context && (!g_command_buffer_mgr || !g_command_buffer_mgr->IsDeviceLost())) vkDeviceWaitIdle(g_vulkan_context->GetDevice()); if (m_layered_image_acquired && m_layered_swapchain.swapchain != XR_NULL_HANDLE) { XrSwapchainImageReleaseInfo release_info{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; XrResult release_result = XR_SUCCESS; { auto queue_lock = AcquireGraphicsQueueLock(); release_result = xrReleaseSwapchainImage(m_layered_swapchain.swapchain, &release_info); } if (XR_FAILED(release_result)) { WARN_LOG_FMT(VIDEO, "OpenXR: xrReleaseSwapchainImage during shutdown failed for layered " "swapchain ({}).", static_cast(release_result)); } m_layered_image_acquired = false; } for (uint32_t eye = 0; eye < 2; ++eye) { auto& sc = m_eye_swapchains[eye]; if (m_image_acquired[eye] && sc.swapchain != XR_NULL_HANDLE) { XrSwapchainImageReleaseInfo release_info{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; XrResult release_result = XR_SUCCESS; { auto queue_lock = AcquireGraphicsQueueLock(); release_result = xrReleaseSwapchainImage(sc.swapchain, &release_info); } if (XR_FAILED(release_result)) { WARN_LOG_FMT(VIDEO, "OpenXR: xrReleaseSwapchainImage during shutdown failed for eye {} ({}).", eye, static_cast(release_result)); } m_image_acquired[eye] = false; } } // The compositor may still be consuming the last submitted layers while the session is // FOCUSED. Complete the requested STOPPING -> xrEndSession -> EXITING transition before // destroying the swapchains referenced by those layers. if (VR::g_openxr) VR::g_openxr->ShutdownSession(); DestroySwapchainVulkanObjects(m_layered_swapchain); if (m_layered_swapchain.swapchain != XR_NULL_HANDLE) { const XrResult destroy_result = xrDestroySwapchain(m_layered_swapchain.swapchain); if (XR_FAILED(destroy_result)) { WARN_LOG_FMT(VIDEO, "OpenXR: xrDestroySwapchain failed for layered swapchain ({}).", static_cast(destroy_result)); } m_layered_swapchain.swapchain = XR_NULL_HANDLE; } m_layered_swapchain.width = 0; m_layered_swapchain.height = 0; m_use_layered_swapchain = false; m_frame_uses_layered_swapchain = false; for (uint32_t eye = 0; eye < 2; ++eye) { auto& sc = m_eye_swapchains[eye]; // Release Dolphin framebuffers and views synchronously before the runtime destroys its images. DestroySwapchainVulkanObjects(sc); if (sc.swapchain != XR_NULL_HANDLE) { const XrResult destroy_result = xrDestroySwapchain(sc.swapchain); if (XR_FAILED(destroy_result)) { WARN_LOG_FMT(VIDEO, "OpenXR: xrDestroySwapchain failed for eye {} ({}).", eye, static_cast(destroy_result)); } sc.swapchain = XR_NULL_HANDLE; } } } AbstractFramebuffer* VulkanOpenXR::AcquireEyeFramebuffer(uint32_t eye_index) { ASSERT(eye_index < 2); #if defined(BAIJI_STEAMVR) // Do not acquire a second XR image pair while the previous pair is still being written. // The submit worker releases/publishes the pending pair after its fence signals; meanwhile // the pacing thread keeps reprojecting the last completed pair. if (m_stereo_finalization_in_flight.load(std::memory_order_acquire)) return nullptr; #endif static unsigned int s_openxr_vk_acquire_log_count = 0; const uint64_t perf_start_us = Common::Timer::NowUs(); Common::ScopeGuard perf_guard([perf_start_us] { g_vulkan_context->GetPerfCounters().xr_swapchain_us.fetch_add( Common::Timer::NowUs() - perf_start_us, std::memory_order_relaxed); }); auto& sc = m_eye_swapchains[eye_index]; m_frame_uses_layered_swapchain = false; XrSwapchainImageAcquireInfo acquire_info{XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO}; XrResult acquire_result = XR_SUCCESS; { auto queue_lock = AcquireGraphicsQueueLock(); acquire_result = xrAcquireSwapchainImage(sc.swapchain, &acquire_info, &m_acquired_image_index[eye_index]); } if (XR_FAILED(acquire_result)) { ERROR_LOG_FMT(VIDEO, "OpenXR: xrAcquireSwapchainImage failed for eye {}.", eye_index); return nullptr; } m_image_acquired[eye_index] = true; // Block until the acquired image is safe to write. XrSwapchainImageWaitInfo wait_info{XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO}; wait_info.timeout = XR_INFINITE_DURATION; if (XR_FAILED(xrWaitSwapchainImage(sc.swapchain, &wait_info))) { ERROR_LOG_FMT(VIDEO, "OpenXR: xrWaitSwapchainImage failed for eye {}.", eye_index); // Ensure we don't leak an acquired image if waiting fails. XrSwapchainImageReleaseInfo release_info{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; XrResult release_result = XR_SUCCESS; { auto queue_lock = AcquireGraphicsQueueLock(); release_result = xrReleaseSwapchainImage(sc.swapchain, &release_info); } if (XR_FAILED(release_result)) { WARN_LOG_FMT(VIDEO, "OpenXR: xrReleaseSwapchainImage after wait failure failed for eye {} ({}).", eye_index, static_cast(release_result)); } m_image_acquired[eye_index] = false; return nullptr; } // Reset the image layout to UNDEFINED — the runtime may have changed it since last release, // and we're about to clear the image anyway via SetAndClearFramebuffer. const uint32_t idx = m_acquired_image_index[eye_index]; sc.textures[idx]->OverrideImageLayout(VK_IMAGE_LAYOUT_UNDEFINED); if (s_openxr_vk_acquire_log_count < 12) { INFO_LOG_FMT(OPENXR, "OpenXR VK acquire #{}: eye={} image={} format={} size={}x{} layout={} " "gfx_queue_family={}", s_openxr_vk_acquire_log_count + 1, eye_index, idx, static_cast(sc.textures[idx]->GetFormat()), sc.width, sc.height, static_cast(sc.textures[idx]->GetLayout()), g_vulkan_context->GetGraphicsQueueFamilyIndex()); s_openxr_vk_acquire_log_count++; } return sc.framebuffers[idx].get(); } AbstractFramebuffer* VulkanOpenXR::AcquireLayeredFramebuffer() { static unsigned int s_openxr_vk_layered_acquire_log_count = 0; auto& sc = m_layered_swapchain; if (!m_use_layered_swapchain || sc.swapchain == XR_NULL_HANDLE) return nullptr; XrSwapchainImageAcquireInfo acquire_info{XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO}; XrResult acquire_result = XR_SUCCESS; { auto queue_lock = AcquireGraphicsQueueLock(); acquire_result = xrAcquireSwapchainImage(sc.swapchain, &acquire_info, &m_acquired_layered_image_index); } if (XR_FAILED(acquire_result)) { ERROR_LOG_FMT(VIDEO, "OpenXR: xrAcquireSwapchainImage failed for layered swapchain."); m_frame_uses_layered_swapchain = false; return nullptr; } m_layered_image_acquired = true; XrSwapchainImageWaitInfo wait_info{XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO}; wait_info.timeout = XR_INFINITE_DURATION; if (XR_FAILED(xrWaitSwapchainImage(sc.swapchain, &wait_info))) { ERROR_LOG_FMT(VIDEO, "OpenXR: xrWaitSwapchainImage failed for layered swapchain."); XrSwapchainImageReleaseInfo release_info{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; XrResult release_result = XR_SUCCESS; { auto queue_lock = AcquireGraphicsQueueLock(); release_result = xrReleaseSwapchainImage(sc.swapchain, &release_info); } if (XR_FAILED(release_result)) { WARN_LOG_FMT(VIDEO, "OpenXR: xrReleaseSwapchainImage after layered wait failure failed ({}).", static_cast(release_result)); } m_layered_image_acquired = false; m_frame_uses_layered_swapchain = false; return nullptr; } const uint32_t idx = m_acquired_layered_image_index; sc.textures[idx]->OverrideImageLayout(VK_IMAGE_LAYOUT_UNDEFINED); m_frame_uses_layered_swapchain = true; if (s_openxr_vk_layered_acquire_log_count < 12) { INFO_LOG_FMT(OPENXR, "OpenXR VK layered acquire #{}: image={} format={} size={}x{} layers={} " "layout={} gfx_queue_family={}", s_openxr_vk_layered_acquire_log_count + 1, idx, static_cast(sc.textures[idx]->GetFormat()), sc.width, sc.height, sc.textures[idx]->GetLayers(), static_cast(sc.textures[idx]->GetLayout()), g_vulkan_context->GetGraphicsQueueFamilyIndex()); s_openxr_vk_layered_acquire_log_count++; } return sc.framebuffers[idx].get(); } void VulkanOpenXR::ReleaseEyeTexture(uint32_t eye_index) { ASSERT(eye_index < 2); if (!m_image_acquired[eye_index]) return; #ifdef BAIJI_STEAMVR if (g_ActiveConfig.vr_stereo_screen && g_ActiveConfig.stereo_mode == StereoMode::SBS) { // Frame StereoScreen: render both eyes before giving either image back. // SubmitStereoFlatFrame finishes their shared GPU work and releases the pair. StateTracker::GetInstance()->EndRenderPass(); return; } #endif #if defined(ANDROID) // End any active render pass so the eye image is no longer bound for rendering. // We defer the Vulkan submit/xrReleaseSwapchainImage pairing until SubmitFrame() // so both eyes stay within one command-buffer lifetime. StateTracker::GetInstance()->EndRenderPass(); #else // PC OpenXR runtimes are sensitive to holding runtime-owned Vulkan swapchain images // across Dolphin's later frame-submit work. Preserve the original PC path: submit the // blit for this eye, then release the image back to the runtime. // // The spec only requires the image writes to be *submitted* before // xrReleaseSwapchainImage; GPU-side ordering is the runtime's job. Virtual Desktop // does that with a timeline semaphore it creates on our device at session creation — // which silently did nothing until the timelineSemaphore feature was enabled at // device creation, so this path used to drain the GPU per eye as a workaround // (serializing CPU/GPU/compositor every frame). Keep the drain only as a fallback for // VD/Quest-class runtimes when the feature could not be enabled — it is a property of // the runtime's strictness, not of which projection path is active. Frame's // completed-pair StereoScreen handoff is handled above instead. StateTracker::GetInstance()->EndRenderPass(); const bool wait_for_completion = !g_vulkan_context->SupportsTimelineSemaphores() && VR::g_openxr && VR::g_openxr->IsQuestOrVirtualDesktopRuntime(); g_command_buffer_mgr->SubmitCommandBuffer(false, wait_for_completion); StateTracker::GetInstance()->InvalidateCachedState(); XrSwapchainImageReleaseInfo release_info{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; XrResult result = XR_SUCCESS; const uint64_t perf_release_start_us = Common::Timer::NowUs(); { auto queue_lock = AcquireGraphicsQueueLock(); result = xrReleaseSwapchainImage(m_eye_swapchains[eye_index].swapchain, &release_info); } g_vulkan_context->GetPerfCounters().xr_swapchain_us.fetch_add( Common::Timer::NowUs() - perf_release_start_us, std::memory_order_relaxed); if (XR_FAILED(result)) { WARN_LOG_FMT(VIDEO, "OpenXR: xrReleaseSwapchainImage failed for eye {} ({}).", eye_index, static_cast(result)); } m_image_acquired[eye_index] = false; #endif } void VulkanOpenXR::ReleaseLayeredTexture() { if (!m_layered_image_acquired) return; #if defined(ANDROID) StateTracker::GetInstance()->EndRenderPass(); #else // Same contract as ReleaseEyeTexture: submit before release; only drain the GPU as a // fallback for VD/Quest-class runtimes when timeline semaphores could not be enabled. StateTracker::GetInstance()->EndRenderPass(); const bool wait_for_completion = !g_vulkan_context->SupportsTimelineSemaphores() && VR::g_openxr && VR::g_openxr->IsQuestOrVirtualDesktopRuntime(); g_command_buffer_mgr->SubmitCommandBuffer(false, wait_for_completion); StateTracker::GetInstance()->InvalidateCachedState(); XrSwapchainImageReleaseInfo release_info{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; XrResult result = XR_SUCCESS; { auto queue_lock = AcquireGraphicsQueueLock(); result = xrReleaseSwapchainImage(m_layered_swapchain.swapchain, &release_info); } if (XR_FAILED(result)) { WARN_LOG_FMT(VIDEO, "OpenXR: xrReleaseSwapchainImage failed for layered swapchain ({}).", static_cast(result)); } m_layered_image_acquired = false; #endif } bool VulkanOpenXR::SubmitFrame() { ASSERT(VR::g_openxr != nullptr); #if defined(ANDROID) static unsigned int s_openxr_vk_submit_frame_log_count = 0; static uint64_t s_openxr_vk_async_frame_id = 0; bool has_acquired_images = false; has_acquired_images |= m_layered_image_acquired; for (bool acquired : m_image_acquired) { has_acquired_images |= acquired; } if (has_acquired_images) { if (!WaitForPendingFrameFinalization("before queuing next async XR submit")) return false; PendingXRFrame pending_frame; pending_frame.debug_frame_id = ++s_openxr_vk_async_frame_id; pending_frame.queued_time_us = Common::Timer::NowUs(); pending_frame.display_time = VR::g_openxr->GetPredictedDisplayTime(); pending_frame.environment_blend_mode = VR::g_openxr->GetActiveBlendMode(); pending_frame.should_render = VR::g_openxr->ShouldRender(); pending_frame.space = VR::g_openxr->GetReferenceSpace(); pending_frame.layer_flags = VR::g_openxr->GetProjectionLayerExtraFlags(); // Pose the presented XFB was rendered with (stamped at its XFB copy, selected in // FetchXFB) — not the live snapshot, which can already belong to the next frame // when presentation is deferred to VI time. const auto& eye_views = VR::g_openxr->GetPresentEyeViews(); const bool submit_layered = m_frame_uses_layered_swapchain && m_use_layered_swapchain && m_layered_swapchain.swapchain != XR_NULL_HANDLE; for (uint32_t eye = 0; eye < 2; ++eye) { auto& pv = pending_frame.projection_views[eye]; pv = {XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}; pv.pose = eye_views[eye].pose; pv.fov = eye_views[eye].fov; if (submit_layered) { pv.subImage.swapchain = m_layered_swapchain.swapchain; pv.subImage.imageArrayIndex = eye; pv.subImage.imageRect = {{0, 0}, {static_cast(m_layered_swapchain.width), static_cast(m_layered_swapchain.height)}}; } else { pv.subImage.swapchain = m_eye_swapchains[eye].swapchain; pv.subImage.imageArrayIndex = 0; pv.subImage.imageRect = {{0, 0}, {static_cast(m_eye_swapchains[eye].width), static_cast(m_eye_swapchains[eye].height)}}; } } pending_frame.layered_acquired = m_layered_image_acquired; pending_frame.layered_swapchain = m_layered_swapchain.swapchain; m_layered_image_acquired = false; for (uint32_t eye = 0; eye < 2; ++eye) { pending_frame.eye_acquired[eye] = m_image_acquired[eye]; pending_frame.eye_swapchains[eye] = m_eye_swapchains[eye].swapchain; m_image_acquired[eye] = false; } m_frame_uses_layered_swapchain = false; if (s_openxr_vk_submit_frame_log_count < 60) { INFO_LOG_FMT(VIDEO, "OpenXR Vulkan: queued async final XR submit #{} " "(layered_acquired={} eye0_acquired={} eye1_acquired={} " "submit_layered={} layered_swapchain_enabled={}).", pending_frame.debug_frame_id, pending_frame.layered_acquired, pending_frame.eye_acquired[0], pending_frame.eye_acquired[1], submit_layered, m_use_layered_swapchain); #if defined(ANDROID) __android_log_print( ANDROID_LOG_INFO, "DolphinXR", "OpenXR Vulkan: queued async final XR submit #%llu " "(layered_acquired=%d eye0_acquired=%d eye1_acquired=%d submit_layered=%d " "layered_swapchain_enabled=%d)", static_cast(pending_frame.debug_frame_id), static_cast(pending_frame.layered_acquired), static_cast(pending_frame.eye_acquired[0]), static_cast(pending_frame.eye_acquired[1]), static_cast(submit_layered), static_cast(m_use_layered_swapchain)); #endif s_openxr_vk_submit_frame_log_count++; } // Submit the eye rendering work once per frame before releasing the swapchain // images back to the runtime. Do not wait for GPU completion here; waiting // serializes the emulator, GPU, and Quest compositor every frame. We still // advance the Vulkan frame resources because the Quest direct-to-HMD path // skips PresentBackbuffer(), which normally resets descriptor pools. StateTracker::GetInstance()->EndRenderPass(); m_async_frame_finalization_in_flight.store(true, std::memory_order_release); g_command_buffer_mgr->SubmitCommandBuffer( true, false, true, VK_NULL_HANDLE, 0xFFFFFFFF, [this, frame = std::move(pending_frame)](VkResult submit_result) mutable { FinalizePendingXRFrame(std::move(frame), submit_result); }); StateTracker::GetInstance()->InvalidateCachedState(); return true; } #endif // See above: stamped present pose, not the live snapshot. const auto& eye_views = VR::g_openxr->GetPresentEyeViews(); const bool submit_layered = m_frame_uses_layered_swapchain && m_use_layered_swapchain && m_layered_swapchain.swapchain != XR_NULL_HANDLE; for (uint32_t eye = 0; eye < 2; ++eye) { auto& pv = m_projection_views[eye]; pv = {XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}; pv.pose = eye_views[eye].pose; pv.fov = eye_views[eye].fov; if (submit_layered) { pv.subImage.swapchain = m_layered_swapchain.swapchain; pv.subImage.imageArrayIndex = eye; pv.subImage.imageRect = {{0, 0}, {static_cast(m_layered_swapchain.width), static_cast(m_layered_swapchain.height)}}; } else { pv.subImage.swapchain = m_eye_swapchains[eye].swapchain; pv.subImage.imageArrayIndex = 0; pv.subImage.imageRect = { {0, 0}, {static_cast(m_eye_swapchains[eye].width), static_cast(m_eye_swapchains[eye].height)}}; } } if (VR::g_openxr->IsFrameThreadActive()) { // The pacing thread owns xrEndFrame; hand it the rendered views (poses included). VR::g_openxr->PublishFrame(m_projection_views, VR::g_openxr->GetProjectionLayerExtraFlags()); m_frame_uses_layered_swapchain = false; return true; } m_projection_layer = {XR_TYPE_COMPOSITION_LAYER_PROJECTION}; m_projection_layer.space = VR::g_openxr->GetReferenceSpace(); m_projection_layer.viewCount = 2; m_projection_layer.views = m_projection_views.data(); m_projection_layer.layerFlags = VR::g_openxr->GetProjectionLayerExtraFlags(); const std::vector layers = { reinterpret_cast(&m_projection_layer)}; const bool result = VR::g_openxr->EndFrame(layers); m_frame_uses_layered_swapchain = false; return result; } bool VulkanOpenXR::SubmitFlatFrame() { ASSERT(VR::g_openxr != nullptr); #if defined(ANDROID) // On Android the per-eye ReleaseEyeTexture only ends the render pass; the command-buffer // submit and xrReleaseSwapchainImage are deferred to submit time. Flat mode has a single // image, so do that here synchronously. advance_to_next_frame recycles descriptor pools // because the Quest direct-to-HMD path skips PresentBackbuffer(). The spec only requires the // writes to be submitted (not completed) before release; the shared graphics queue preserves // ordering, so we do not wait for the GPU. if (m_image_acquired[0]) { StateTracker::GetInstance()->EndRenderPass(); g_command_buffer_mgr->SubmitCommandBuffer(false, false, true); StateTracker::GetInstance()->InvalidateCachedState(); XrSwapchainImageReleaseInfo release_info{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; XrResult result = XR_SUCCESS; { auto queue_lock = AcquireGraphicsQueueLock(); result = xrReleaseSwapchainImage(m_eye_swapchains[0].swapchain, &release_info); } if (XR_FAILED(result)) { WARN_LOG_FMT(VIDEO, "OpenXR: xrReleaseSwapchainImage failed for flat panel ({}).", static_cast(result)); } m_image_acquired[0] = false; } #endif return VR::g_openxr->SubmitFlatQuadFrame(m_eye_swapchains[0].swapchain, m_eye_swapchains[0].width, m_eye_swapchains[0].height); } void VulkanOpenXR::FinalizeStereoFlatFrameAfterGPU(VkFence fence, bool have_pair, VkResult submit_result) { Common::ScopeGuard completion_guard([this] { m_stereo_finalization_in_flight.store(false, std::memory_order_release); }); if (submit_result == VK_SUCCESS) { const VkResult wait_result = vkWaitForFences(g_vulkan_context->GetDevice(), 1, &fence, VK_TRUE, UINT64_MAX); if (wait_result != VK_SUCCESS) { LOG_VULKAN_ERROR(wait_result, "StereoScreen async completion fence wait failed: "); m_async_frame_finalization_failed.store(true, std::memory_order_release); return; } } else { LOG_VULKAN_ERROR(submit_result, "StereoScreen async queue submission failed: "); m_async_frame_finalization_failed.store(true, std::memory_order_release); } if (!VR::g_openxr) { ERROR_LOG_FMT(VIDEO, "OpenXR Vulkan: XR manager disappeared before stereo pair finalization."); return; } bool released = true; { // The image writes are now complete. Keep the existing handoff -> graphics queue -> publish // order for the short release/publish transaction, but do not hold either lock during the // GPU fence wait above. VR::OpenXRManager::ScopedVideoFrameHandoff handoff(VR::g_openxr.get()); { auto queue_lock = AcquireGraphicsQueueLock(); XrSwapchainImageReleaseInfo release_info{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; for (uint32_t eye = 0; eye < 2; ++eye) { if (!m_image_acquired[eye]) continue; const XrResult result = xrReleaseSwapchainImage(m_eye_swapchains[eye].swapchain, &release_info); if (XR_FAILED(result)) { WARN_LOG_FMT(VIDEO, "OpenXR: async completed stereo eye {} release failed ({}).", eye, static_cast(result)); released = false; } else { m_image_acquired[eye] = false; } } } if (!released || submit_result != VK_SUCCESS) return; if (have_pair && VR::g_openxr->IsFrameThreadActive()) { if (!VR::g_openxr->SubmitStereoQuadFrame({m_eye_swapchains[0].swapchain, m_eye_swapchains[1].swapchain}, m_eye_swapchains[0].width, m_eye_swapchains[0].height)) { m_async_frame_finalization_failed.store(true, std::memory_order_release); } } } } bool VulkanOpenXR::SubmitStereoFlatFrame() { ASSERT(VR::g_openxr != nullptr); #if defined(BAIJI_STEAMVR) // Keep displaying the last completed pair while the submit worker finishes the prior pair. if (m_stereo_finalization_in_flight.load(std::memory_order_acquire)) return true; if (m_image_acquired[0] || m_image_acquired[1]) { const bool have_pair = m_image_acquired[0] && m_image_acquired[1]; StateTracker::GetInstance()->EndRenderPass(); if (VR::g_openxr->IsFrameThreadActive() && g_command_buffer_mgr->UsesThreadedSubmission()) { // Let the Vulkan worker wait for GPU completion. Until its fence signals, the XR pacing // thread can safely reproject the last released pair and this video thread can continue // emulation; AcquireEyeFramebuffer skips another XR pair while finalization is pending. const VkFence completion_fence = g_command_buffer_mgr->GetCurrentFence(); m_stereo_finalization_in_flight.store(true, std::memory_order_release); const bool queued = g_command_buffer_mgr->SubmitCommandBuffer( true, false, false, VK_NULL_HANDLE, 0xFFFFFFFF, [this, completion_fence, have_pair](VkResult submit_result) { FinalizeStereoFlatFrameAfterGPU(completion_fence, have_pair, submit_result); }); StateTracker::GetInstance()->InvalidateCachedState(); if (!queued) { m_stereo_finalization_in_flight.store(false, std::memory_order_release); return false; } return have_pair; } // Submitted writes alone left visible doubled/stale images on Steam Frame. // Finish BOTH eye blits before releasing either image to the runtime. Wait // once for the pair, rather than draining the GPU separately for each eye. // Keep this completion wait even when timeline semaphores are enabled. // PresentBackbuffer already advances frame resources, so do not advance them // a second time here. const bool submitted = g_command_buffer_mgr->SubmitCommandBuffer(false, true); StateTracker::GetInstance()->InvalidateCachedState(); if (!submitted) return false; bool released = true; { auto queue_lock = AcquireGraphicsQueueLock(); XrSwapchainImageReleaseInfo release_info{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; for (uint32_t eye = 0; eye < 2; ++eye) { if (!m_image_acquired[eye]) continue; const XrResult result = xrReleaseSwapchainImage(m_eye_swapchains[eye].swapchain, &release_info); if (XR_FAILED(result)) { WARN_LOG_FMT(VIDEO, "OpenXR: completed stereo eye {} release failed ({}).", eye, static_cast(result)); released = false; } m_image_acquired[eye] = false; } } if (!have_pair || !released) return false; } #elif defined(ANDROID) // On Android the per-eye ReleaseEyeTexture only ends the render pass; the command-buffer // submit and xrReleaseSwapchainImage are deferred to submit time. End the render pass once, // submit the accumulated command buffer, then release both eye images synchronously. bool any_acquired = false; for (uint32_t eye = 0; eye < 2; ++eye) { if (m_image_acquired[eye]) { any_acquired = true; break; } } if (any_acquired) { StateTracker::GetInstance()->EndRenderPass(); g_command_buffer_mgr->SubmitCommandBuffer(false, false, true); StateTracker::GetInstance()->InvalidateCachedState(); for (uint32_t eye = 0; eye < 2; ++eye) { if (!m_image_acquired[eye]) continue; XrSwapchainImageReleaseInfo release_info{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; XrResult result = XR_SUCCESS; { auto queue_lock = AcquireGraphicsQueueLock(); result = xrReleaseSwapchainImage(m_eye_swapchains[eye].swapchain, &release_info); } if (XR_FAILED(result)) { WARN_LOG_FMT(VIDEO, "OpenXR: xrReleaseSwapchainImage failed for stereo screen eye {} ({}).", eye, static_cast(result)); } m_image_acquired[eye] = false; } } #endif return VR::g_openxr->SubmitStereoQuadFrame({m_eye_swapchains[0].swapchain, m_eye_swapchains[1].swapchain}, m_eye_swapchains[0].width, m_eye_swapchains[0].height); } } // namespace Vulkan #endif // ENABLE_VR