// Copyright 2024 Dolphin Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #ifdef ENABLE_VR #include "VideoCommon/VR/OpenXRManager.h" #if defined(ANDROID) #ifndef XR_USE_PLATFORM_ANDROID #define XR_USE_PLATFORM_ANDROID #endif #ifndef XR_USE_TIMESPEC #define XR_USE_TIMESPEC #endif #include #include #elif defined(_WIN32) #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN #endif #ifndef NOMINMAX #define NOMINMAX #endif #include #include #ifndef XR_USE_PLATFORM_WIN32 #define XR_USE_PLATFORM_WIN32 #endif #include #endif #include #include #include #include #include #include #include #if defined(ANDROID) #include #include #include #include #endif #include #include "Common/Config/Config.h" #include "Common/Logging/Log.h" #include "Common/StringUtil.h" #include "Common/Thread.h" #include "Common/Timer.h" #include "Common/VR/OpenXRInputState.h" #include "Core/Config/GraphicsSettings.h" #include "Core/Config/MainSettings.h" #include "Core/Core.h" #include "Core/Host.h" #include "Core/System.h" #include "VideoCommon/OnScreenDisplay.h" #include "VideoCommon/VideoConfig.h" namespace VR { std::unique_ptr g_openxr; namespace { const char* ReferenceSpaceTypeName(XrReferenceSpaceType type) { switch (type) { case XR_REFERENCE_SPACE_TYPE_LOCAL: return "LOCAL"; case XR_REFERENCE_SPACE_TYPE_STAGE: return "STAGE"; case XR_REFERENCE_SPACE_TYPE_VIEW: return "VIEW"; default: return "UNKNOWN"; } } #if defined(ANDROID) std::mutex s_android_openxr_mutex; JavaVM* s_android_vm = nullptr; jobject s_android_activity = nullptr; jobject s_android_application_context = nullptr; bool s_android_loader_initialized = false; static bool EnsureAndroidOpenXRLoaderInitialized() { std::lock_guard guard{s_android_openxr_mutex}; if (s_android_loader_initialized) return true; if (!s_android_vm || (!s_android_activity && !s_android_application_context)) { ERROR_LOG_FMT(OPENXR, "OpenXR: Android VM/context not set before loader initialization."); return false; } XrLoaderInitInfoAndroidKHR loader_init{XR_TYPE_LOADER_INIT_INFO_ANDROID_KHR}; loader_init.applicationVM = s_android_vm; // Pass the ACTIVITY as the loader-init context (an Activity is a Context), matching // Meta's samples and known-working GLES OpenXR apps (Lambda1VR, Quake2Quest). Meta's // legacy GLES session path hooks this context for activity-readiness/launch-id // tracking; with a plain Application context the runtime skips the launch-id query, // assigns no volumetric-window token, and parks the session in IDLE forever. loader_init.applicationContext = s_android_activity ? s_android_activity : s_android_application_context; PFN_xrInitializeLoaderKHR initialize_loader = nullptr; XrResult result = xrGetInstanceProcAddr( XR_NULL_HANDLE, "xrInitializeLoaderKHR", reinterpret_cast(&initialize_loader)); if (XR_FAILED(result) || initialize_loader == nullptr) { ERROR_LOG_FMT(OPENXR, "OpenXR: Could not load xrInitializeLoaderKHR ({}).", static_cast(result)); return false; } result = initialize_loader(reinterpret_cast(&loader_init)); if (XR_FAILED(result)) { ERROR_LOG_FMT(OPENXR, "OpenXR: xrInitializeLoaderKHR failed ({}).", static_cast(result)); return false; } s_android_loader_initialized = true; INFO_LOG_FMT(OPENXR, "OpenXR: Android loader initialized."); return true; } static uint32_t GetCurrentAndroidThreadId() { #if defined(SYS_gettid) const long tid = syscall(SYS_gettid); #elif defined(__NR_gettid) const long tid = syscall(__NR_gettid); #else const long tid = gettid(); #endif return tid > 0 ? static_cast(tid) : 0; } static XrAndroidThreadTypeKHR ToXrAndroidThreadType(OpenXRManager::AndroidThreadType type) { switch (type) { case OpenXRManager::AndroidThreadType::ApplicationMain: return XR_ANDROID_THREAD_TYPE_APPLICATION_MAIN_KHR; case OpenXRManager::AndroidThreadType::ApplicationWorker: return XR_ANDROID_THREAD_TYPE_APPLICATION_WORKER_KHR; case OpenXRManager::AndroidThreadType::RendererMain: return XR_ANDROID_THREAD_TYPE_RENDERER_MAIN_KHR; case OpenXRManager::AndroidThreadType::RendererWorker: return XR_ANDROID_THREAD_TYPE_RENDERER_WORKER_KHR; } return XR_ANDROID_THREAD_TYPE_APPLICATION_WORKER_KHR; } static const char* AndroidThreadTypeName(OpenXRManager::AndroidThreadType type) { switch (type) { case OpenXRManager::AndroidThreadType::ApplicationMain: return "application-main"; case OpenXRManager::AndroidThreadType::ApplicationWorker: return "application-worker"; case OpenXRManager::AndroidThreadType::RendererMain: return "renderer-main"; case OpenXRManager::AndroidThreadType::RendererWorker: return "renderer-worker"; } return "unknown"; } static bool HasAndroidThreadTypeFallback(OpenXRManager::AndroidThreadType type) { return type == OpenXRManager::AndroidThreadType::RendererWorker; } static OpenXRManager::AndroidThreadType GetAndroidThreadTypeFallback( OpenXRManager::AndroidThreadType type) { // Some Quest runtime builds advertise XR_KHR_android_thread_settings but reject // XR_ANDROID_THREAD_TYPE_RENDERER_WORKER_KHR. The Vulkan submit thread still does // renderer work, so retry as renderer-main instead of leaving it untagged. if (type == OpenXRManager::AndroidThreadType::RendererWorker) return OpenXRManager::AndroidThreadType::RendererMain; return type; } static bool TrySetAndroidApplicationThread(XrInstance instance, XrSession session, PFN_xrSetAndroidApplicationThreadKHR set_thread, OpenXRManager::AndroidThreadType requested_type, OpenXRManager::AndroidThreadType type, uint32_t thread_id, std::string_view label, bool fallback) { const XrResult result = set_thread(session, ToXrAndroidThreadType(type), thread_id); const char* label_data = label.empty() ? "" : label.data(); if (XR_FAILED(result)) { char result_string[XR_MAX_RESULT_STRING_SIZE]{}; xrResultToString(instance, result, result_string); WARN_LOG_FMT(OPENXR, "OpenXR: xrSetAndroidApplicationThreadKHR failed for {} thread '{}' " "(requested {}, tid={}): {}", AndroidThreadTypeName(type), label, AndroidThreadTypeName(requested_type), thread_id, result_string); __android_log_print(ANDROID_LOG_WARN, "DolphinXR", "OpenXR: xrSetAndroidApplicationThreadKHR failed for %s thread '%.*s' " "(requested %s, tid=%u): %s", AndroidThreadTypeName(type), static_cast(label.size()), label_data, AndroidThreadTypeName(requested_type), thread_id, result_string); return false; } INFO_LOG_FMT(OPENXR, "OpenXR: Registered Android {} thread '{}' (requested {}, tid={}{}).", AndroidThreadTypeName(type), label, AndroidThreadTypeName(requested_type), thread_id, fallback ? ", fallback" : ""); __android_log_print(ANDROID_LOG_INFO, "DolphinXR", "OpenXR: registered Android %s thread '%.*s' (requested %s, tid=%u%s)", AndroidThreadTypeName(type), static_cast(label.size()), label_data, AndroidThreadTypeName(requested_type), thread_id, fallback ? ", fallback" : ""); return true; } static bool SetAndroidApplicationThreadWithFallback( XrInstance instance, XrSession session, PFN_xrSetAndroidApplicationThreadKHR set_thread, OpenXRManager::AndroidThreadType type, uint32_t thread_id, std::string_view label) { if (TrySetAndroidApplicationThread(instance, session, set_thread, type, type, thread_id, label, false)) { return true; } if (!HasAndroidThreadTypeFallback(type)) return false; const OpenXRManager::AndroidThreadType fallback_type = GetAndroidThreadTypeFallback(type); WARN_LOG_FMT(OPENXR, "OpenXR: Retrying Android thread '{}' registration as {}.", label, AndroidThreadTypeName(fallback_type)); __android_log_print(ANDROID_LOG_WARN, "DolphinXR", "OpenXR: retrying Android thread '%.*s' registration as %s", static_cast(label.size()), label.empty() ? "" : label.data(), AndroidThreadTypeName(fallback_type)); return TrySetAndroidApplicationThread(instance, session, set_thread, type, fallback_type, thread_id, label, true); } static const char* PerfSettingsDomainName(XrPerfSettingsDomainEXT domain) { switch (domain) { case XR_PERF_SETTINGS_DOMAIN_CPU_EXT: return "CPU"; case XR_PERF_SETTINGS_DOMAIN_GPU_EXT: return "GPU"; default: return "unknown"; } } #endif static XrQuaternionf MultiplyQuaternions(const XrQuaternionf& a, const XrQuaternionf& b) { return {a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y, a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x, a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w, a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z}; } static float QuaternionYawDegrees(const XrQuaternionf& q) { const float yaw = std::atan2(2.f * (q.x * q.z + q.w * q.y), 1.f - 2.f * (q.x * q.x + q.y * q.y)); return yaw * 180.f / 3.14159265358979323846f; } static void CopyOpenXRName(char* dst, size_t dst_size, std::string_view src) { std::memset(dst, 0, dst_size); const size_t copy_size = std::min(dst_size - 1, src.size()); std::memcpy(dst, src.data(), copy_size); } static std::string PathToString(XrInstance instance, XrPath path) { if (instance == XR_NULL_HANDLE || path == XR_NULL_PATH) return {}; uint32_t required_size = 0; if (XR_FAILED(xrPathToString(instance, path, 0, &required_size, nullptr)) || required_size == 0) return {}; std::string result(required_size, '\0'); if (XR_FAILED(xrPathToString(instance, path, required_size, &required_size, result.data()))) return {}; if (!result.empty() && result.back() == '\0') result.pop_back(); return result; } } // namespace // Checks an XrResult and returns false (with an error log) on failure. // Requires m_instance to be valid for error string lookup. #define XR_CHECK(expr) \ do \ { \ const XrResult _r = (expr); \ if (XR_FAILED(_r)) \ { \ char _buf[XR_MAX_RESULT_STRING_SIZE]{}; \ xrResultToString(m_instance, _r, _buf); \ ERROR_LOG_FMT(OPENXR, "OpenXR: {} failed: {}", #expr, _buf); \ return false; \ } \ } while (false) std::vector OpenXRManager::GetAvailableControllerExtensions() { #if defined(ANDROID) if (!EnsureAndroidOpenXRLoaderInitialized()) return {}; #endif // The Frame controller extension is newer than the pinned OpenXR headers in some // build environments, so keep its published extension name as a string literal. static constexpr const char* FRAME_CONTROLLER_EXTENSION = "XR_VALVE_frame_controller_interaction"; static const std::array s_optional = { XR_FB_TOUCH_CONTROLLER_PRO_EXTENSION_NAME, XR_META_TOUCH_CONTROLLER_PLUS_EXTENSION_NAME, XR_BD_CONTROLLER_INTERACTION_EXTENSION_NAME, FRAME_CONTROLLER_EXTENSION, }; uint32_t ext_count = 0; xrEnumerateInstanceExtensionProperties(nullptr, 0, &ext_count, nullptr); std::vector exts(ext_count, {XR_TYPE_EXTENSION_PROPERTIES}); xrEnumerateInstanceExtensionProperties(nullptr, ext_count, &ext_count, exts.data()); std::vector result; for (const char* wanted : s_optional) { if (std::any_of(exts.begin(), exts.end(), [wanted](const XrExtensionProperties& e) { return std::string_view{e.extensionName} == wanted; })) { result.push_back(wanted); } } return result; } std::vector OpenXRManager::GetAvailableFoveationExtensions(bool for_vulkan) { #if defined(ANDROID) if (!EnsureAndroidOpenXRLoaderInitialized()) return {}; #endif // All three are needed to configure and apply a foveation profile; foveation_vulkan // additionally exposes the runtime's fragment density map images to the app. if (!IsRuntimeExtensionSupported(XR_FB_FOVEATION_EXTENSION_NAME) || !IsRuntimeExtensionSupported(XR_FB_FOVEATION_CONFIGURATION_EXTENSION_NAME) || !IsRuntimeExtensionSupported(XR_FB_SWAPCHAIN_UPDATE_STATE_EXTENSION_NAME)) { return {}; } std::vector result = {XR_FB_FOVEATION_EXTENSION_NAME, XR_FB_FOVEATION_CONFIGURATION_EXTENSION_NAME, XR_FB_SWAPCHAIN_UPDATE_STATE_EXTENSION_NAME}; if (for_vulkan) { // Spelled out because the XR_FB_FOVEATION_VULKAN_EXTENSION_NAME macro lives in // openxr_platform.h behind XR_USE_GRAPHICS_API_VULKAN, which this file doesn't define. static constexpr const char* kFoveationVulkanExt = "XR_FB_foveation_vulkan"; if (!IsRuntimeExtensionSupported(kFoveationVulkanExt)) return {}; result.push_back(kFoveationVulkanExt); } return result; } bool OpenXRManager::IsFoveationUsable() const { return m_xrCreateFoveationProfileFB != nullptr && m_xrUpdateSwapchainFB != nullptr && Config::Get(Config::GFX_VR_FOVEATION_LEVEL) > Config::GFX_VR_FOVEATION_LEVEL_OFF; } bool OpenXRManager::ApplyFoveationToSwapchain(XrSwapchain swapchain) { if (!IsFoveationUsable() || m_session == XR_NULL_HANDLE || swapchain == XR_NULL_HANDLE) return false; const int level = std::clamp(Config::Get(Config::GFX_VR_FOVEATION_LEVEL), Config::GFX_VR_FOVEATION_LEVEL_OFF, Config::GFX_VR_FOVEATION_LEVEL_MAX); const bool dynamic = Config::Get(Config::GFX_VR_FOVEATION_DYNAMIC); XrFoveationLevelProfileCreateInfoFB level_info{XR_TYPE_FOVEATION_LEVEL_PROFILE_CREATE_INFO_FB}; level_info.level = static_cast(level); level_info.verticalOffset = 0.0f; level_info.dynamic = dynamic ? XR_FOVEATION_DYNAMIC_LEVEL_ENABLED_FB : XR_FOVEATION_DYNAMIC_DISABLED_FB; XrFoveationProfileCreateInfoFB profile_info{XR_TYPE_FOVEATION_PROFILE_CREATE_INFO_FB}; profile_info.next = &level_info; XrFoveationProfileFB profile = XR_NULL_HANDLE; XrResult result = m_xrCreateFoveationProfileFB(m_session, &profile_info, &profile); if (XR_FAILED(result)) { WARN_LOG_FMT(OPENXR, "OpenXR: xrCreateFoveationProfileFB failed ({}).", static_cast(result)); return false; } XrSwapchainStateFoveationFB foveation_state{XR_TYPE_SWAPCHAIN_STATE_FOVEATION_FB}; foveation_state.flags = 0; foveation_state.profile = profile; result = m_xrUpdateSwapchainFB( swapchain, reinterpret_cast(&foveation_state)); // The runtime snapshots the profile during the update; ours can go away immediately. if (m_xrDestroyFoveationProfileFB != nullptr) m_xrDestroyFoveationProfileFB(profile); if (XR_FAILED(result)) { WARN_LOG_FMT(OPENXR, "OpenXR: xrUpdateSwapchainFB (foveation) failed ({}).", static_cast(result)); return false; } INFO_LOG_FMT(OPENXR, "OpenXR: Foveation applied: level {} ({}dynamic).", level, dynamic ? "" : "non-"); return true; } bool OpenXRManager::IsExtensionEnabled(std::string_view ext_name) const { return std::any_of(m_enabled_extensions.begin(), m_enabled_extensions.end(), [ext_name](const std::string& e) { return e == ext_name; }); } bool OpenXRManager::IsRuntimeExtensionSupported(std::string_view ext_name) { #if defined(ANDROID) if (!EnsureAndroidOpenXRLoaderInitialized()) return false; #endif uint32_t ext_count = 0; xrEnumerateInstanceExtensionProperties(nullptr, 0, &ext_count, nullptr); std::vector exts(ext_count, {XR_TYPE_EXTENSION_PROPERTIES}); xrEnumerateInstanceExtensionProperties(nullptr, ext_count, &ext_count, exts.data()); return std::any_of(exts.begin(), exts.end(), [ext_name](const XrExtensionProperties& e) { return std::string_view{e.extensionName} == ext_name; }); } OpenXRManager::OpenXRManager() = default; OpenXRManager::~OpenXRManager() { DestroySession(); if (m_instance != XR_NULL_HANDLE) xrDestroyInstance(m_instance); } bool OpenXRManager::CreateInstance(const std::vector& extra_extensions) { #if defined(ANDROID) if (!EnsureAndroidOpenXRLoaderInitialized()) return false; #endif // Log available API layers. uint32_t layer_count = 0; xrEnumerateApiLayerProperties(0, &layer_count, nullptr); std::vector layers(layer_count, {XR_TYPE_API_LAYER_PROPERTIES}); xrEnumerateApiLayerProperties(layer_count, &layer_count, layers.data()); for (const auto& layer : layers) INFO_LOG_FMT(OPENXR, "OpenXR: Available API layer: {}", layer.layerName); // Enumerate and verify extensions. uint32_t ext_count = 0; xrEnumerateInstanceExtensionProperties(nullptr, 0, &ext_count, nullptr); std::vector exts(ext_count, {XR_TYPE_EXTENSION_PROPERTIES}); xrEnumerateInstanceExtensionProperties(nullptr, ext_count, &ext_count, exts.data()); for (const auto& ext : exts) INFO_LOG_FMT(OPENXR, "OpenXR: Available extension: {}", ext.extensionName); const auto runtime_has = [&exts](std::string_view name) { return std::any_of(exts.begin(), exts.end(), [name](const XrExtensionProperties& e) { return std::string_view{e.extensionName} == name; }); }; for (const char* required : extra_extensions) { if (!runtime_has(required)) { ERROR_LOG_FMT(OPENXR, "OpenXR: Required extension '{}' not available.", required); return false; } } // XR_FB_passthrough: Windows (Meta Horizon Link exposes it when passthrough over Link // is enabled) and Quest standalone. Vulkan-only — the dedicated coverage target that // produces the projection layer's alpha is a Vulkan feature. std::vector enabled_extensions(extra_extensions); #if defined(_WIN32) || defined(ANDROID) const bool is_vulkan_binding = std::find_if(extra_extensions.begin(), extra_extensions.end(), [](const char* extension) { return std::string_view{extension} == "XR_KHR_vulkan_enable" || std::string_view{extension} == "XR_KHR_vulkan_enable2"; }) != extra_extensions.end(); if (is_vulkan_binding && runtime_has(XR_FB_PASSTHROUGH_EXTENSION_NAME)) enabled_extensions.push_back(XR_FB_PASSTHROUGH_EXTENSION_NAME); #endif // Time-domain conversion: lets input sampling locate controller poses at measured "now" // instead of the predicted display time. Prediction extrapolates fast-moving controllers // several frames ahead, which sprays the aim ray during fast wrist motion. #if defined(_WIN32) if (runtime_has(XR_KHR_WIN32_CONVERT_PERFORMANCE_COUNTER_TIME_EXTENSION_NAME)) enabled_extensions.push_back(XR_KHR_WIN32_CONVERT_PERFORMANCE_COUNTER_TIME_EXTENSION_NAME); #elif defined(ANDROID) if (runtime_has(XR_KHR_CONVERT_TIMESPEC_TIME_EXTENSION_NAME)) enabled_extensions.push_back(XR_KHR_CONVERT_TIMESPEC_TIME_EXTENSION_NAME); #endif XrVersion requested_api_version = XR_CURRENT_API_VERSION; INFO_LOG_FMT(OPENXR, "OpenXR: Requesting API version {}.{}.{}.", XR_VERSION_MAJOR(requested_api_version), XR_VERSION_MINOR(requested_api_version), XR_VERSION_PATCH(requested_api_version)); XrApplicationInfo app_info{}; #ifdef BAIJI_STEAMVR std::strncpy(app_info.applicationName, "BaijiSteamVR", XR_MAX_APPLICATION_NAME_SIZE - 1); #else std::strncpy(app_info.applicationName, "Dolphin Emulator", XR_MAX_APPLICATION_NAME_SIZE - 1); #endif app_info.applicationVersion = 1; #ifdef BAIJI_STEAMVR std::strncpy(app_info.engineName, "BaijiSteamVR", XR_MAX_ENGINE_NAME_SIZE - 1); #else std::strncpy(app_info.engineName, "Dolphin", XR_MAX_ENGINE_NAME_SIZE - 1); #endif app_info.engineVersion = 1; app_info.apiVersion = requested_api_version; // Record which extensions we are enabling for later profile gating. m_enabled_extensions.clear(); for (const char* ext : enabled_extensions) m_enabled_extensions.emplace_back(ext); XrInstanceCreateInfo create_info{XR_TYPE_INSTANCE_CREATE_INFO}; create_info.applicationInfo = app_info; create_info.enabledExtensionCount = static_cast(enabled_extensions.size()); create_info.enabledExtensionNames = enabled_extensions.data(); #if defined(ANDROID) XrInstanceCreateInfoAndroidKHR android_create_info{XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR}; { std::lock_guard guard{s_android_openxr_mutex}; if (!s_android_vm || !s_android_activity) { ERROR_LOG_FMT(OPENXR, "OpenXR: Android VM/activity not set before xrCreateInstance."); return false; } android_create_info.applicationVM = s_android_vm; android_create_info.applicationActivity = s_android_activity; } create_info.next = &android_create_info; #endif XrResult result = xrCreateInstance(&create_info, &m_instance); if (result == XR_ERROR_API_VERSION_UNSUPPORTED && requested_api_version != XR_API_VERSION_1_0) { WARN_LOG_FMT(OPENXR, "OpenXR: Runtime rejected API version {}.{}.{}; retrying with 1.0.", XR_VERSION_MAJOR(requested_api_version), XR_VERSION_MINOR(requested_api_version), XR_VERSION_PATCH(requested_api_version)); app_info.apiVersion = XR_API_VERSION_1_0; create_info.applicationInfo = app_info; result = xrCreateInstance(&create_info, &m_instance); } if (XR_FAILED(result)) { ERROR_LOG_FMT(OPENXR, "OpenXR: xrCreateInstance failed ({}).", static_cast(result)); return false; } XrInstanceProperties props{XR_TYPE_INSTANCE_PROPERTIES}; xrGetInstanceProperties(m_instance, &props); m_runtime_name = props.runtimeName; m_quest_or_vd_runtime.reset(); INFO_LOG_FMT(OPENXR, "OpenXR: Runtime '{}' version {}.{}.{}", props.runtimeName, XR_VERSION_MAJOR(props.runtimeVersion), XR_VERSION_MINOR(props.runtimeVersion), XR_VERSION_PATCH(props.runtimeVersion)); if (IsExtensionEnabled(XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME)) { XrResult refresh_rate_result = xrGetInstanceProcAddr(m_instance, "xrGetDisplayRefreshRateFB", reinterpret_cast(&m_xrGetDisplayRefreshRateFB)); if (XR_FAILED(refresh_rate_result) || m_xrGetDisplayRefreshRateFB == nullptr) { WARN_LOG_FMT(OPENXR, "OpenXR: XR_FB_display_refresh_rate enabled but " "xrGetDisplayRefreshRateFB could not be loaded ({}).", static_cast(refresh_rate_result)); m_xrGetDisplayRefreshRateFB = nullptr; } refresh_rate_result = xrGetInstanceProcAddr(m_instance, "xrRequestDisplayRefreshRateFB", reinterpret_cast(&m_xrRequestDisplayRefreshRateFB)); if (XR_FAILED(refresh_rate_result) || m_xrRequestDisplayRefreshRateFB == nullptr) { WARN_LOG_FMT(OPENXR, "OpenXR: XR_FB_display_refresh_rate enabled but " "xrRequestDisplayRefreshRateFB could not be loaded ({}).", static_cast(refresh_rate_result)); m_xrRequestDisplayRefreshRateFB = nullptr; } if (m_xrGetDisplayRefreshRateFB != nullptr) { INFO_LOG_FMT(OPENXR, "OpenXR: XR_FB_display_refresh_rate enabled."); } } if (IsExtensionEnabled(XR_FB_FOVEATION_EXTENSION_NAME) && IsExtensionEnabled(XR_FB_SWAPCHAIN_UPDATE_STATE_EXTENSION_NAME)) { const auto load_foveation_pfn = [this](const char* name, auto* out_pfn) { const XrResult r = xrGetInstanceProcAddr(m_instance, name, reinterpret_cast(out_pfn)); if (XR_FAILED(r) || *out_pfn == nullptr) { WARN_LOG_FMT(OPENXR, "OpenXR: XR_FB_foveation enabled but {} could not be loaded ({}).", name, static_cast(r)); *out_pfn = nullptr; return false; } return true; }; if (load_foveation_pfn("xrCreateFoveationProfileFB", &m_xrCreateFoveationProfileFB) && load_foveation_pfn("xrDestroyFoveationProfileFB", &m_xrDestroyFoveationProfileFB) && load_foveation_pfn("xrUpdateSwapchainFB", &m_xrUpdateSwapchainFB)) { INFO_LOG_FMT(OPENXR, "OpenXR: XR_FB_foveation enabled."); } else { m_xrCreateFoveationProfileFB = nullptr; m_xrDestroyFoveationProfileFB = nullptr; m_xrUpdateSwapchainFB = nullptr; } } #if defined(_WIN32) || defined(ANDROID) if (IsExtensionEnabled(XR_FB_PASSTHROUGH_EXTENSION_NAME)) { const auto load_pfn = [this](const char* name, auto* out_pfn) { const XrResult r = xrGetInstanceProcAddr(m_instance, name, reinterpret_cast(out_pfn)); if (XR_FAILED(r) || *out_pfn == nullptr) { WARN_LOG_FMT(OPENXR, "OpenXR: XR_FB_passthrough enabled but {} could not be loaded ({}).", name, static_cast(r)); *out_pfn = nullptr; return false; } return true; }; const bool loaded = load_pfn("xrCreatePassthroughFB", &m_xrCreatePassthroughFB) && load_pfn("xrDestroyPassthroughFB", &m_xrDestroyPassthroughFB) && load_pfn("xrPassthroughStartFB", &m_xrPassthroughStartFB) && load_pfn("xrPassthroughPauseFB", &m_xrPassthroughPauseFB) && load_pfn("xrCreatePassthroughLayerFB", &m_xrCreatePassthroughLayerFB) && load_pfn("xrDestroyPassthroughLayerFB", &m_xrDestroyPassthroughLayerFB) && load_pfn("xrPassthroughLayerPauseFB", &m_xrPassthroughLayerPauseFB) && load_pfn("xrPassthroughLayerResumeFB", &m_xrPassthroughLayerResumeFB); if (loaded) { INFO_LOG_FMT(OPENXR, "OpenXR: XR_FB_passthrough enabled."); } else { m_xrCreatePassthroughFB = nullptr; m_xrDestroyPassthroughFB = nullptr; m_xrPassthroughStartFB = nullptr; m_xrPassthroughPauseFB = nullptr; m_xrCreatePassthroughLayerFB = nullptr; m_xrDestroyPassthroughLayerFB = nullptr; m_xrPassthroughLayerPauseFB = nullptr; m_xrPassthroughLayerResumeFB = nullptr; } } #endif #if defined(ANDROID) if (IsExtensionEnabled(XR_KHR_ANDROID_THREAD_SETTINGS_EXTENSION_NAME)) { XrResult thread_settings_result = xrGetInstanceProcAddr(m_instance, "xrSetAndroidApplicationThreadKHR", &m_xrSetAndroidApplicationThreadKHR); if (XR_FAILED(thread_settings_result) || m_xrSetAndroidApplicationThreadKHR == nullptr) { WARN_LOG_FMT(OPENXR, "OpenXR: XR_KHR_android_thread_settings enabled but " "xrSetAndroidApplicationThreadKHR could not be loaded ({}).", static_cast(thread_settings_result)); m_xrSetAndroidApplicationThreadKHR = nullptr; } else { INFO_LOG_FMT(OPENXR, "OpenXR: XR_KHR_android_thread_settings enabled."); } } if (IsExtensionEnabled(XR_EXT_PERFORMANCE_SETTINGS_EXTENSION_NAME)) { XrResult perf_settings_result = xrGetInstanceProcAddr(m_instance, "xrPerfSettingsSetPerformanceLevelEXT", &m_xrPerfSettingsSetPerformanceLevelEXT); if (XR_FAILED(perf_settings_result) || m_xrPerfSettingsSetPerformanceLevelEXT == nullptr) { WARN_LOG_FMT(OPENXR, "OpenXR: XR_EXT_performance_settings enabled but " "xrPerfSettingsSetPerformanceLevelEXT could not be loaded ({}).", static_cast(perf_settings_result)); m_xrPerfSettingsSetPerformanceLevelEXT = nullptr; } else { INFO_LOG_FMT(OPENXR, "OpenXR: XR_EXT_performance_settings enabled."); } } #endif return true; } #if defined(ANDROID) void OpenXRManager::SetAndroidAppInfo(JavaVM* vm, JNIEnv* env, jobject activity) { std::lock_guard guard{s_android_openxr_mutex}; if (s_android_activity) env->DeleteGlobalRef(s_android_activity); if (s_android_application_context) env->DeleteGlobalRef(s_android_application_context); s_android_vm = vm; s_android_loader_initialized = false; s_android_activity = activity ? env->NewGlobalRef(activity) : nullptr; s_android_application_context = nullptr; if (!activity) return; jclass activity_class = env->GetObjectClass(activity); jmethodID get_application_context = env->GetMethodID(activity_class, "getApplicationContext", "()Landroid/content/Context;"); jobject application_context = env->CallObjectMethod(activity, get_application_context); if (application_context) { s_android_application_context = env->NewGlobalRef(application_context); env->DeleteLocalRef(application_context); } env->DeleteLocalRef(activity_class); } void OpenXRManager::ClearAndroidAppInfo(JNIEnv* env) { std::lock_guard guard{s_android_openxr_mutex}; if (s_android_activity) { env->DeleteGlobalRef(s_android_activity); s_android_activity = nullptr; } if (s_android_application_context) { env->DeleteGlobalRef(s_android_application_context); s_android_application_context = nullptr; } s_android_vm = nullptr; s_android_loader_initialized = false; } bool OpenXRManager::RegisterCurrentAndroidThread(AndroidThreadType type, std::string_view label) { if (m_xrSetAndroidApplicationThreadKHR == nullptr) return false; const uint32_t thread_id = GetCurrentAndroidThreadId(); if (thread_id == 0) { WARN_LOG_FMT(OPENXR, "OpenXR: Could not determine Android thread id for '{}'.", label); return false; } { // Threads spawn before the video backend creates the XrSession (Core.cpp:344/506 // both fire from emu/GPU thread bootstrap, before SetSession runs). Queue and // replay from SetSession so Meta's runtime sees the thread tags it needs to // grant big.LITTLE scheduling and DVFS escalation. // // The m_session check and the push must be inside the same lock the flush uses, // otherwise SetSession's flush could run between our check and our push and leave // the entry orphaned. std::lock_guard guard(m_pending_thread_registrations_mutex); if (m_session == XR_NULL_HANDLE) { m_pending_thread_registrations.push_back({thread_id, type, std::string(label)}); INFO_LOG_FMT(OPENXR, "OpenXR: Deferring Android {} thread '{}' (tid={}) — session not yet " "created.", AndroidThreadTypeName(type), label, thread_id); return false; } } const auto set_thread = reinterpret_cast( m_xrSetAndroidApplicationThreadKHR); return SetAndroidApplicationThreadWithFallback(m_instance, m_session, set_thread, type, thread_id, label); } void OpenXRManager::FlushPendingAndroidThreadRegistrations() { if (m_session == XR_NULL_HANDLE || m_xrSetAndroidApplicationThreadKHR == nullptr) return; std::vector to_flush; { std::lock_guard guard(m_pending_thread_registrations_mutex); to_flush.swap(m_pending_thread_registrations); } if (to_flush.empty()) return; const auto set_thread = reinterpret_cast( m_xrSetAndroidApplicationThreadKHR); for (const auto& pending : to_flush) { SetAndroidApplicationThreadWithFallback(m_instance, m_session, set_thread, pending.type, pending.thread_id, pending.label); } } bool OpenXRManager::RequestAndroidHighPerformanceLevel() { if (m_session == XR_NULL_HANDLE || m_xrPerfSettingsSetPerformanceLevelEXT == nullptr) return false; if (!Config::Get(Config::GFX_VR_QUEST_CPU_LEVEL_5_HINT)) { INFO_LOG_FMT(OPENXR, "OpenXR: Quest CPU level 5 hint disabled; not requesting sustained high."); return false; } const auto set_performance_level = reinterpret_cast( m_xrPerfSettingsSetPerformanceLevelEXT); const auto request_domain = [&](XrPerfSettingsDomainEXT domain) { constexpr XrPerfSettingsLevelEXT level = XR_PERF_SETTINGS_LEVEL_SUSTAINED_HIGH_EXT; const XrResult result = set_performance_level(m_session, domain, level); if (XR_FAILED(result)) { char result_string[XR_MAX_RESULT_STRING_SIZE]{}; xrResultToString(m_instance, result, result_string); WARN_LOG_FMT(OPENXR, "OpenXR: xrPerfSettingsSetPerformanceLevelEXT failed for {}: {}", PerfSettingsDomainName(domain), result_string); return false; } INFO_LOG_FMT(OPENXR, "OpenXR: Requested {} performance level SUSTAINED_HIGH.", PerfSettingsDomainName(domain)); return true; }; const bool cpu_ok = request_domain(XR_PERF_SETTINGS_DOMAIN_CPU_EXT); const bool gpu_ok = request_domain(XR_PERF_SETTINGS_DOMAIN_GPU_EXT); return cpu_ok && gpu_ok; } #endif bool OpenXRManager::InitializeSystem() { XrSystemGetInfo system_info{XR_TYPE_SYSTEM_GET_INFO}; system_info.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY; const XrResult result = xrGetSystem(m_instance, &system_info, &m_system_id); if (XR_FAILED(result)) { ERROR_LOG_FMT(OPENXR, "OpenXR: xrGetSystem failed ({}). Is a headset connected?", static_cast(result)); return false; } XrSystemProperties props{XR_TYPE_SYSTEM_PROPERTIES}; #if defined(_WIN32) || defined(ANDROID) XrSystemPassthroughPropertiesFB passthrough_props{XR_TYPE_SYSTEM_PASSTHROUGH_PROPERTIES_FB}; if (m_xrCreatePassthroughFB != nullptr) props.next = &passthrough_props; #endif const XrResult properties_result = xrGetSystemProperties(m_instance, m_system_id, &props); if (XR_FAILED(properties_result)) { WARN_LOG_FMT(OPENXR, "OpenXR: xrGetSystemProperties failed ({}); runtime layer capacity is unknown.", static_cast(properties_result)); m_max_layer_count = 0; } else { m_max_layer_count = props.graphicsProperties.maxLayerCount; } INFO_LOG_FMT(OPENXR, "OpenXR: Runtime composition layer limit {}.", m_max_layer_count); m_system_name = props.systemName; m_quest_or_vd_runtime.reset(); m_system_vendor_id = props.vendorId; #if defined(_WIN32) || defined(ANDROID) m_system_supports_fb_passthrough = passthrough_props.supportsPassthrough == XR_TRUE; #else m_system_supports_fb_passthrough = false; #endif INFO_LOG_FMT(OPENXR, "OpenXR: System '{}' (vendor {:08x})", props.systemName, props.vendorId); if (m_xrCreatePassthroughFB != nullptr) { INFO_LOG_FMT(OPENXR, "OpenXR: XR_FB_passthrough system support: {}", m_system_supports_fb_passthrough ? "yes" : "no"); } return true; } bool OpenXRManager::EnumerateViewConfigurations() { uint32_t view_count = 0; XrResult result = xrEnumerateViewConfigurationViews( m_instance, m_system_id, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, 0, &view_count, nullptr); if (XR_FAILED(result) || view_count != 2) { ERROR_LOG_FMT(OPENXR, "OpenXR: Failed to enumerate view configs or unexpected count ({}). " "Expected 2 views for stereo.", view_count); return false; } m_view_config_views.fill({XR_TYPE_VIEW_CONFIGURATION_VIEW}); XR_CHECK(xrEnumerateViewConfigurationViews(m_instance, m_system_id, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, view_count, &view_count, m_view_config_views.data())); // Bake the user's resolution scale into the recommended sizes so every consumer // (swapchain creation, per-eye blit rects, GetEyeWidth/Height) stays consistent. // Read through Config::Get: this can run before g_ActiveConfig is populated. const float resolution_scale = std::clamp(Config::Get(Config::GFX_VR_RESOLUTION_SCALE), Config::GFX_VR_RESOLUTION_SCALE_MIN, Config::GFX_VR_RESOLUTION_SCALE_MAX); for (uint32_t i = 0; i < view_count; ++i) { auto& view = m_view_config_views[i]; const uint32_t recommended_w = view.recommendedImageRectWidth; const uint32_t recommended_h = view.recommendedImageRectHeight; if (std::abs(resolution_scale - 1.0f) > 0.001f) { // Round to a multiple of 4 and clamp to the runtime's limits. const auto scale_dim = [resolution_scale](uint32_t dim, uint32_t max_dim) { const auto scaled = static_cast(std::lround(dim * resolution_scale / 4.0)) * 4; return std::clamp(scaled, 64, max_dim); }; view.recommendedImageRectWidth = scale_dim(recommended_w, view.maxImageRectWidth); view.recommendedImageRectHeight = scale_dim(recommended_h, view.maxImageRectHeight); } INFO_LOG_FMT(OPENXR, "OpenXR: Eye {} recommended {}x{} (max {}x{}), using {}x{} (scale {})", i, recommended_w, recommended_h, view.maxImageRectWidth, view.maxImageRectHeight, view.recommendedImageRectWidth, view.recommendedImageRectHeight, resolution_scale); } uint32_t blend_count = 0; xrEnumerateEnvironmentBlendModes(m_instance, m_system_id, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, 0, &blend_count, nullptr); m_supported_blend_modes.resize(blend_count); xrEnumerateEnvironmentBlendModes(m_instance, m_system_id, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, blend_count, &blend_count, m_supported_blend_modes.data()); INFO_LOG_FMT(OPENXR, "OpenXR: {} environment blend mode(s) supported", blend_count); if (SupportsAlphaBlend()) INFO_LOG_FMT(OPENXR, "OpenXR: Alpha-blend passthrough (AR) is available"); return true; } void OpenXRManager::SetSession(XrSession session) { // Passthrough objects are children of the previous session; drop them before switching. DestroyFBPassthrough(); m_session = session; if (m_session == XR_NULL_HANDLE) { DestroyInputActions(); ResetInputActionsState(); return; } CaptureStartupDisplayRefreshRateFromExtension(); #if defined(ANDROID) // Replay any thread tags that were deferred while the session was being created. FlushPendingAndroidThreadRegistrations(); #endif if (!InitializeInputActions()) { WARN_LOG_FMT(OPENXR, "OpenXR: Controller input actions unavailable."); ResetInputActionsState(); } } void OpenXRManager::SetSwapchain(IOpenXRSwapchain* swapchain) { if (swapchain == nullptr) { // Stop before the backend destroys the swapchains: the pacing thread's heartbeat // layers reference swapchain handles, and EndFrame takes the graphics queue lock. StopFrameThread(); m_swapchain = nullptr; // XFB pose stamps hold poses in the (possibly outgoing) session's reference space. m_xfb_pose_stamps = {}; m_xfb_pose_stamp_next = 0; m_present_eye_views_valid = false; return; } m_swapchain = swapchain; if (!swapchain->SupportsDetachedFrameLoop()) { INFO_LOG_FMT(OPENXR, "OpenXR: backend requires the inline frame flow (no pacing thread)."); } else if (Config::Get(Config::GFX_VR_USE_XR_PACING_THREAD)) { StartFrameThread(); } else { INFO_LOG_FMT(OPENXR, "OpenXR: XR pacing thread disabled by config (legacy frame flow)."); } } void OpenXRManager::StartFrameThread() { if (m_frame_thread.joinable()) return; m_frame_thread_should_exit.store(false, std::memory_order_release); m_frame_thread_running.store(true, std::memory_order_release); m_frame_thread = std::thread(&OpenXRManager::FrameThreadLoop, this); INFO_LOG_FMT(OPENXR, "OpenXR: XR pacing thread started."); } void OpenXRManager::StopFrameThread() { if (!m_frame_thread.joinable()) return; m_frame_thread_should_exit.store(true, std::memory_order_release); { std::lock_guard lock(m_publish_mutex); m_publish_cv.notify_all(); } m_frame_thread.join(); m_frame_thread_running.store(false, std::memory_order_release); INFO_LOG_FMT(OPENXR, "OpenXR: XR pacing thread stopped."); } void OpenXRManager::ShutdownSession() { StopFrameThread(); if (m_session == XR_NULL_HANDLE || !m_session_running.load(std::memory_order_acquire)) return; INFO_LOG_FMT(OPENXR, "OpenXR: Requesting running session exit before teardown."); const XrResult request_result = xrRequestExitSession(m_session); if (XR_FAILED(request_result)) { WARN_LOG_FMT(OPENXR, "OpenXR: xrRequestExitSession failed during teardown ({}).", static_cast(request_result)); return; } // xrRequestExitSession is asynchronous. Give the runtime enough time to report STOPPING, // whose event handler performs the only spec-valid xrEndSession call, and then EXITING. Waiting // for the terminal event is important on PC runtimes: the compositor can still be consuming the // last submitted swapchain image after xrEndSession has merely made the session non-running. // Destruction remains legal from any state, so a misbehaving runtime cannot hang shutdown. constexpr auto shutdown_timeout = std::chrono::milliseconds(500); const auto deadline = std::chrono::steady_clock::now() + shutdown_timeout; while (m_session_state != XR_SESSION_STATE_EXITING && m_session_state != XR_SESSION_STATE_LOSS_PENDING && std::chrono::steady_clock::now() < deadline) { PollEvents(); if (m_session_state != XR_SESSION_STATE_EXITING && m_session_state != XR_SESSION_STATE_LOSS_PENDING) std::this_thread::sleep_for(std::chrono::milliseconds(1)); } if (m_session_state != XR_SESSION_STATE_EXITING && m_session_state != XR_SESSION_STATE_LOSS_PENDING) { WARN_LOG_FMT(OPENXR, "OpenXR: Runtime did not finish session exit within 500 ms (state={}); " "continuing bounded teardown.", static_cast(m_session_state)); } } void OpenXRManager::DestroySession() { SetSwapchain(nullptr); ShutdownSession(); DestroyInputActions(); ResetInputActionsState(); DestroyFBPassthrough(); if (m_reference_space != XR_NULL_HANDLE) { xrDestroySpace(m_reference_space); m_reference_space = XR_NULL_HANDLE; } if (m_session != XR_NULL_HANDLE) { xrDestroySession(m_session); m_session = XR_NULL_HANDLE; } // Everything below belongs to a session or its frame loop. Keep only the instance/system and // instance-level extension state so a subsequent game starts from the same state as a newly // constructed manager without forcing the runtime through VR_Shutdown/VR_Init again. SetSessionFocused(false, false); m_session_state = XR_SESSION_STATE_UNKNOWN; m_session_running.store(false, std::memory_order_release); m_frame_begun.store(false, std::memory_order_release); m_stop_confirmation_active.store(false, std::memory_order_release); m_stop_confirmation_result.store(OpenXRStopConfirmationResult::Idle, std::memory_order_release); m_max_layer_count = 0; m_exit_render_loop = false; m_frame_state = {XR_TYPE_FRAME_STATE}; m_view_state_flags = 0; m_predicted_display_time_snapshot.store(0, std::memory_order_release); m_predicted_display_period_snapshot.store(0, std::memory_order_release); m_should_render_snapshot.store(false, std::memory_order_release); m_last_predicted_display_time = 0; m_estimated_display_period_ms.store(0.0, std::memory_order_release); m_startup_display_refresh_rate_hz = 0.0f; { std::lock_guard lock(m_publish_mutex); m_published_frame = {}; m_published_frame.quad = {XR_TYPE_COMPOSITION_LAYER_QUAD}; m_publish_serial = 0; } m_video_handoff_active.store(0, std::memory_order_release); m_views = {}; m_eye_views = {}; m_rendered_eye_views = {}; m_submitted_eye_views = {}; m_present_eye_views = {}; m_present_eye_views_valid = false; m_xfb_pose_stamps = {}; m_xfb_pose_stamp_next = 0; m_xfb_pose_stamp_serial = 0; m_logged_interaction_profiles = {XR_NULL_PATH, XR_NULL_PATH}; m_home_set = false; m_home_position = {0.f, 0.f, 0.f}; m_recenter_requested.store(false, std::memory_order_release); m_flat_screen_reanchor_pending.store(false, std::memory_order_release); { std::lock_guard lock(m_flat_screen_pose_mutex); m_flat_screen_pose_valid = false; m_flat_screen_pose = {{0.f, 0.f, 0.f, 1.f}, {0.f, 0.f, 0.f}}; } m_flat_quad_layer = {XR_TYPE_COMPOSITION_LAYER_QUAD}; m_controller_anchor_cache_valid = {false, false}; } void OpenXRManager::PublishFrame(const std::array& views, XrCompositionLayerFlags layer_flags) { // Frames rendered before the first LocateViews of a session carry unseeded (all-zero // orientation) poses; submitting or heartbeating those gets XR_ERROR_POSE_INVALID. const auto valid_orientation = [](const XrQuaternionf& q) { return q.x != 0.0f || q.y != 0.0f || q.z != 0.0f || q.w != 0.0f; }; if (!valid_orientation(views[0].pose.orientation) || !valid_orientation(views[1].pose.orientation)) { return; } std::lock_guard lock(m_publish_mutex); m_published_frame = {}; m_published_frame.valid = true; m_published_frame.is_quad = false; m_published_frame.views = views; m_published_frame.layer_flags = layer_flags; m_publish_serial++; m_publish_cv.notify_all(); } void OpenXRManager::PublishQuadFrame(const XrCompositionLayerQuad& quad) { std::lock_guard lock(m_publish_mutex); m_published_frame = {}; m_published_frame.valid = true; m_published_frame.is_quad = true; m_published_frame.is_stereo_quad = false; m_published_frame.quad = quad; m_published_frame.stereo_quad_count = 0; m_publish_serial++; m_publish_cv.notify_all(); } void OpenXRManager::PublishStereoQuadFrame(const std::array& quads) { std::lock_guard lock(m_publish_mutex); m_published_frame = {}; m_published_frame.valid = true; m_published_frame.is_quad = false; m_published_frame.is_stereo_quad = true; m_published_frame.stereo_quads = quads; m_published_frame.stereo_quad_count = static_cast(quads.size()); m_publish_serial++; m_publish_cv.notify_all(); } void OpenXRManager::CacheSubmittedFrame( const std::vector& layers) { PublishedXRFrame frame; if (layers.size() == 1 && layers[0] != nullptr && layers[0]->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION) { const auto* projection = reinterpret_cast(layers[0]); if (projection->viewCount == frame.views.size() && projection->views != nullptr) { frame.valid = true; frame.layer_flags = projection->layerFlags; std::copy_n(projection->views, frame.views.size(), frame.views.begin()); } } else if (layers.size() == 1 && layers[0] != nullptr && layers[0]->type == XR_TYPE_COMPOSITION_LAYER_QUAD) { frame.valid = true; frame.is_quad = true; frame.quad = *reinterpret_cast(layers[0]); } else if (layers.size() == 2 && layers[0] != nullptr && layers[1] != nullptr && layers[0]->type == XR_TYPE_COMPOSITION_LAYER_QUAD && layers[1]->type == XR_TYPE_COMPOSITION_LAYER_QUAD) { frame.valid = true; frame.is_stereo_quad = true; frame.stereo_quad_count = 2; frame.stereo_quads[0] = *reinterpret_cast(layers[0]); frame.stereo_quads[1] = *reinterpret_cast(layers[1]); } std::lock_guard lock(m_publish_mutex); m_published_frame = frame; ++m_publish_serial; m_publish_cv.notify_all(); } void OpenXRManager::FrameThreadLoop() { Common::SetCurrentThreadName("OpenXR Pacing"); #if defined(ANDROID) // Meta's runtime applies big.LITTLE pinning / DVFS escalation to tagged threads. RegisterCurrentAndroidThread(AndroidThreadType::RendererWorker, "OpenXR Pacing"); // A fast core, but off the CPU/Video cores so the wakeup-heavy pacing loop doesn't // steal cycles from the emulator's hot threads. if (Config::Get(Config::GFX_VR_PIN_EMULATION_CORES)) { const int core = Common::PinCurrentThreadToPerformanceCore(Common::ThreadCoreRole::VRPacing); if (core >= 0) INFO_LOG_FMT(OPENXR, "OpenXR: Pinned pacing thread to performance core cpu{}.", core); } #endif // Last content handed over by the game; re-submitted every display period while no // fresh frame arrives ("heartbeat"). The compositor reprojects it with the current // head pose (ATW), which is what makes sub-refresh-rate games feel smooth — the job // the old Opcode Replay re-rendering used to do, at zero GPU cost. PublishedXRFrame last_frame; bool have_frame = false; uint64_t consumed_serial = 0; // Rolling 5s instrumentation window. u64 stats_start_us = Common::Timer::NowUs(); u32 stat_cycles = 0, stat_fresh = 0, stat_repeat = 0, stat_empty = 0; u32 stat_handoff_waited = 0; // 50us slices spent waiting out a video-thread handoff double stat_wait_ms = 0.0, stat_end_ms = 0.0, stat_content_wait_ms = 0.0; // Smoothed xrEndFrame cost. Cheap on Quest (~0.5ms) but 1–5ms on PC runtimes like // Virtual Desktop; the content-wait budget must shrink accordingly or a full pacing // cycle exceeds the display period and the thread can no longer hold refresh rate. double endframe_ema_us = 500.0; u64 last_cycle_us = 0; // Heartbeat mode (g_ActiveConfig.vr_eager_heartbeat, read live each cycle so it can be // toggled mid-session for A/B testing): // // Eager (standalone default): run one cycle per display period and re-submit the last // frame whenever the game is slower — there is no runtime motion smoothing on Quest, // so filling every compositor slot is a straight win (measured: 72/72, 0 stales). // // Lazy (PC default): wait for the game to publish BEFORE starting a frame cycle, so the // runtime sees the app's true cadence. PC runtimes (Virtual Desktop, SteamVR, Meta // Link) key their motion smoothing (SSW/ASW) off the app frame rate — a full-rate // heartbeat makes them disable it and exposes the raw sub-refresh cadence beat as // head-tracking judder. Heartbeat then fires only as a keep-alive when content stops // flowing entirely (loading screens, shader compilation). while (!m_frame_thread_should_exit.load(std::memory_order_acquire)) { const bool eager_heartbeat = g_ActiveConfig.vr_eager_heartbeat; const u64 cycle_start_us = Common::Timer::NowUs(); if (!PollEvents()) break; if (!m_session_running.load(std::memory_order_acquire)) { std::this_thread::sleep_for(std::chrono::milliseconds(5)); continue; } if (!eager_heartbeat) { // Pace to the game: block here (outside the XR frame protocol) until a frame is // published or the keep-alive interval elapses. std::unique_lock lock(m_publish_mutex); m_publish_cv.wait_for(lock, std::chrono::milliseconds(150), [&] { return m_publish_serial != consumed_serial || m_frame_thread_should_exit.load(std::memory_order_acquire); }); if (m_frame_thread_should_exit.load(std::memory_order_acquire)) break; } const u64 t0 = Common::Timer::NowUs(); if (!WaitFrame()) { std::this_thread::sleep_for(std::chrono::milliseconds(2)); continue; } const u64 t1 = Common::Timer::NowUs(); if (!BeginFrame()) continue; const bool runtime_should_render = m_should_render_snapshot.load(std::memory_order_acquire); #ifdef BAIJI_STEAMVR const bool should_render = runtime_should_render && Host_RendererHasFocus(); #else const bool should_render = runtime_should_render && m_session_focused.load(std::memory_order_acquire); #endif const int64_t period_ns = m_predicted_display_period_snapshot.load(std::memory_order_acquire); // Wait for the game to hand over a fresh frame for as long as this cycle can afford: // one display period minus the time already spent since xrWaitFrame returned, minus // the (smoothed) xrEndFrame cost on this runtime, minus a scheduling margin. This is // the longest pickup window that still keeps the full cycle within one period — a // shorter window makes frames slip a whole cycle (visible judder), a longer one // makes the pacing thread miss compositor deadlines (VDXR's xrEndFrame alone costs // 1–5 ms, vs ~0.5 ms on Quest). const u64 t2 = Common::Timer::NowUs(); bool fresh = false; if (should_render) { const int64_t since_wait_ns = static_cast(t2 - t1) * 1000; int64_t budget_ns = period_ns - since_wait_ns - static_cast(endframe_ema_us * 1000.0) - 1'000'000; budget_ns = std::clamp(budget_ns, 0, period_ns); // Catch-up: a healthy cycle equals one period (xrWaitFrame throttles), so only // treat >125% of a period as an overrun worth skipping the content wait for. if (static_cast(last_cycle_us) * 1000 > period_ns + period_ns / 4) budget_ns = 0; // Lazy mode already waited for content before the cycle started; just consume. if (!eager_heartbeat) budget_ns = 0; #ifdef BAIJI_STEAMVR // Steam Frame: let xrWaitFrame own the submission cadence. Waiting for // game content here couples heartbeat delivery to emulation jitter; the trace // showed 10-15 ms waits despite already having a released frame to submit. // Consume whatever is ready and otherwise reuse the last layers immediately. budget_ns = 0; #endif std::unique_lock lock(m_publish_mutex); if (budget_ns > 0 && m_publish_serial == consumed_serial) { m_publish_cv.wait_for(lock, std::chrono::nanoseconds(budget_ns), [&] { return m_publish_serial != consumed_serial || m_frame_thread_should_exit.load(std::memory_order_acquire); }); } if (m_publish_serial != consumed_serial) { last_frame = m_published_frame; consumed_serial = m_publish_serial; have_frame = true; fresh = true; } } #ifdef BAIJI_STEAMVR // Checking the flag below is not mutual exclusion: a video handoff can start // after that check but before xrEndFrame. Keep both eye releases and their // metadata publish indivisible with respect to the runtime's image selection. std::unique_lock handoff_lock(m_video_handoff_mutex); #endif // Never submit while the video thread is swapping the front swapchain image and // publishing its matching pose — landing in that window submits the new image with // the old pose (backwards ATW warp = a previously shown frame flashes). Bounded so // a stuck video thread can never hang the pacing loop; on the last iteration we // proceed anyway (a rare single-frame mismatch beats missing the compositor). for (int spins = 0; m_video_handoff_active.load(std::memory_order_acquire) > 0 && spins < 40; ++spins) { stat_handoff_waited++; std::this_thread::sleep_for(std::chrono::microseconds(50)); } // A publish may have completed during that wait; consume it so the pose we submit // matches the image the video thread just made front. { std::unique_lock lock(m_publish_mutex); if (m_publish_serial != consumed_serial) { last_frame = m_published_frame; consumed_serial = m_publish_serial; have_frame = true; fresh = true; } } const u64 t3 = Common::Timer::NowUs(); // Build the layer stack from the newest content we have. Passthrough layer // prepending, blend mode, and the graphics queue lock live in EndFrameDetached. XrCompositionLayerProjection projection_layer{XR_TYPE_COMPOSITION_LAYER_PROJECTION}; std::vector layers; const bool submit_content = should_render && have_frame; if (submit_content) { if (last_frame.is_quad) { layers.push_back(reinterpret_cast(&last_frame.quad)); } else if (last_frame.is_stereo_quad) { for (uint32_t i = 0; i < last_frame.stereo_quad_count; ++i) { layers.push_back( reinterpret_cast(&last_frame.stereo_quads[i])); } } else { projection_layer.layerFlags = last_frame.layer_flags; projection_layer.space = m_reference_space; projection_layer.viewCount = 2; projection_layer.views = last_frame.views.data(); layers.push_back(reinterpret_cast(&projection_layer)); } } EndFrameDetached(m_frame_state.predictedDisplayTime, GetActiveBlendMode(), should_render, layers); #ifdef BAIJI_STEAMVR handoff_lock.unlock(); #endif const u64 t4 = Common::Timer::NowUs(); endframe_ema_us = endframe_ema_us * 0.8 + static_cast(t4 - t3) * 0.2; last_cycle_us = t4 - cycle_start_us; stat_cycles++; if (fresh) stat_fresh++; else if (submit_content) stat_repeat++; else stat_empty++; stat_wait_ms += (t1 - t0) / 1000.0; stat_content_wait_ms += (t3 - t2) / 1000.0; stat_end_ms += (t4 - t3) / 1000.0; const u64 now_us = Common::Timer::NowUs(); if (now_us - stats_start_us >= 5'000'000 && stat_cycles > 0) { INFO_LOG_FMT(OPENXR, "XRPacing: {:.1f} cycles/s (fresh={} repeat={} empty={}) | per cycle: " "xrWaitFrame={:.2f}ms content_wait={:.2f}ms xrEndFrame={:.2f}ms | " "handoff_waits={} (x50us)", stat_cycles / ((now_us - stats_start_us) / 1'000'000.0), stat_fresh, stat_repeat, stat_empty, stat_wait_ms / stat_cycles, stat_content_wait_ms / stat_cycles, stat_end_ms / stat_cycles, stat_handoff_waited); stats_start_us = now_us; stat_cycles = stat_fresh = stat_repeat = stat_empty = 0; stat_handoff_waited = 0; stat_wait_ms = stat_end_ms = stat_content_wait_ms = 0.0; } } m_frame_thread_running.store(false, std::memory_order_release); INFO_LOG_FMT(OPENXR, "OpenXR: XR pacing thread exiting."); } void OpenXRManager::CaptureStartupDisplayRefreshRateFromExtension() { if (m_startup_display_refresh_rate_hz > 0.0f || m_session == XR_NULL_HANDLE || m_xrGetDisplayRefreshRateFB == nullptr) { return; } float refresh_rate_hz = 0.0f; const XrResult result = m_xrGetDisplayRefreshRateFB(m_session, &refresh_rate_hz); if (XR_FAILED(result)) { WARN_LOG_FMT(OPENXR, "OpenXR: xrGetDisplayRefreshRateFB failed ({}).", static_cast(result)); return; } SetStartupDisplayRefreshRate(refresh_rate_hz, "XR_FB_display_refresh_rate"); } void OpenXRManager::SetStartupDisplayRefreshRate(float refresh_rate_hz, std::string_view source) { if (m_startup_display_refresh_rate_hz > 0.0f || refresh_rate_hz <= 0.0f) return; m_startup_display_refresh_rate_hz = refresh_rate_hz; INFO_LOG_FMT(OPENXR, "OpenXR: Startup display refresh rate is {:.2f} Hz from {}.", m_startup_display_refresh_rate_hz, source); Config::OnConfigChanged(); } bool OpenXRManager::RequestDisplayRefreshRate(float refresh_rate_hz) { if (m_session == XR_NULL_HANDLE || m_xrRequestDisplayRefreshRateFB == nullptr) { WARN_LOG_FMT(OPENXR, "OpenXR: Cannot request refresh rate (session={} request_pfn={}).", m_session != XR_NULL_HANDLE, m_xrRequestDisplayRefreshRateFB != nullptr); return false; } refresh_rate_hz = std::max(refresh_rate_hz, 1.0f); const XrResult result = m_xrRequestDisplayRefreshRateFB(m_session, refresh_rate_hz); if (XR_FAILED(result)) { WARN_LOG_FMT(OPENXR, "OpenXR: xrRequestDisplayRefreshRateFB({:.2f} Hz) failed ({}).", refresh_rate_hz, static_cast(result)); return false; } INFO_LOG_FMT(OPENXR, "OpenXR: Requested display refresh rate {:.2f} Hz.", refresh_rate_hz); return true; } bool OpenXRManager::InitializeInputActions() { if (m_input_action_set != XR_NULL_HANDLE) return true; if (m_instance == XR_NULL_HANDLE || m_session == XR_NULL_HANDLE) return false; auto to_path = [this](const char* path) -> XrPath { XrPath xr_path = XR_NULL_PATH; if (XR_FAILED(xrStringToPath(m_instance, path, &xr_path))) return XR_NULL_PATH; return xr_path; }; m_input_hand_paths[0] = to_path("/user/hand/left"); m_input_hand_paths[1] = to_path("/user/hand/right"); if (m_input_hand_paths[0] == XR_NULL_PATH || m_input_hand_paths[1] == XR_NULL_PATH) { ERROR_LOG_FMT(OPENXR, "OpenXR: Failed to create hand subaction paths."); return false; } XrActionSetCreateInfo action_set_info{XR_TYPE_ACTION_SET_CREATE_INFO}; #ifdef BAIJI_STEAMVR CopyOpenXRName(action_set_info.actionSetName, XR_MAX_ACTION_SET_NAME_SIZE, "baiji_input"); #else CopyOpenXRName(action_set_info.actionSetName, XR_MAX_ACTION_SET_NAME_SIZE, "dolphin_input"); #endif CopyOpenXRName(action_set_info.localizedActionSetName, XR_MAX_LOCALIZED_ACTION_SET_NAME_SIZE, #ifdef BAIJI_STEAMVR "BaijiSteamVR Input"); #else "Dolphin Input"); #endif action_set_info.priority = 0; XrResult result = xrCreateActionSet(m_instance, &action_set_info, &m_input_action_set); if (XR_FAILED(result)) { ERROR_LOG_FMT(OPENXR, "OpenXR: xrCreateActionSet failed ({}).", static_cast(result)); return false; } const auto create_action = [this](XrAction* action, const char* name, const char* localized_name, XrActionType type) -> bool { XrActionCreateInfo action_info{XR_TYPE_ACTION_CREATE_INFO}; CopyOpenXRName(action_info.actionName, XR_MAX_ACTION_NAME_SIZE, name); CopyOpenXRName(action_info.localizedActionName, XR_MAX_LOCALIZED_ACTION_NAME_SIZE, localized_name); action_info.actionType = type; action_info.countSubactionPaths = static_cast(m_input_hand_paths.size()); action_info.subactionPaths = m_input_hand_paths.data(); const XrResult create_result = xrCreateAction(m_input_action_set, &action_info, action); if (XR_FAILED(create_result)) { ERROR_LOG_FMT(OPENXR, "OpenXR: xrCreateAction('{}') failed ({}).", name, static_cast(create_result)); return false; } return true; }; if (!create_action(&m_action_primary_click, "primary_click", "Primary Button", XR_ACTION_TYPE_BOOLEAN_INPUT) || !create_action(&m_action_secondary_click, "secondary_click", "Secondary Button", XR_ACTION_TYPE_BOOLEAN_INPUT) || !create_action(&m_action_frame_x_click, "frame_x_click", "X Button", XR_ACTION_TYPE_BOOLEAN_INPUT) || !create_action(&m_action_frame_y_click, "frame_y_click", "Y Button", XR_ACTION_TYPE_BOOLEAN_INPUT) || !create_action(&m_action_menu_click, "menu_click", "Menu Button", XR_ACTION_TYPE_BOOLEAN_INPUT) || !create_action(&m_action_system_click, "system_click", "System Button", XR_ACTION_TYPE_BOOLEAN_INPUT) || !create_action(&m_action_view_click, "view_click", "View Button", XR_ACTION_TYPE_BOOLEAN_INPUT) || !create_action(&m_action_bumper_click, "bumper_click", "Bumper", XR_ACTION_TYPE_BOOLEAN_INPUT) || !create_action(&m_action_dpad_up_click, "dpad_up_click", "D-Pad Up", XR_ACTION_TYPE_BOOLEAN_INPUT) || !create_action(&m_action_dpad_down_click, "dpad_down_click", "D-Pad Down", XR_ACTION_TYPE_BOOLEAN_INPUT) || !create_action(&m_action_dpad_left_click, "dpad_left_click", "D-Pad Left", XR_ACTION_TYPE_BOOLEAN_INPUT) || !create_action(&m_action_dpad_right_click, "dpad_right_click", "D-Pad Right", XR_ACTION_TYPE_BOOLEAN_INPUT) || !create_action(&m_action_thumbstick_click, "thumbstick_click", "Thumbstick Click", XR_ACTION_TYPE_BOOLEAN_INPUT) || !create_action(&m_action_trigger_click, "trigger_click", "Trigger Click", XR_ACTION_TYPE_BOOLEAN_INPUT) || !create_action(&m_action_squeeze_click, "squeeze_click", "Squeeze Click", XR_ACTION_TYPE_BOOLEAN_INPUT) || !create_action(&m_action_trigger_value, "trigger_value", "Trigger Value", XR_ACTION_TYPE_FLOAT_INPUT) || !create_action(&m_action_squeeze_value, "squeeze_value", "Squeeze Value", XR_ACTION_TYPE_FLOAT_INPUT) || !create_action(&m_action_squeeze_force, "squeeze_force", "Squeeze Force", XR_ACTION_TYPE_FLOAT_INPUT) || !create_action(&m_action_thumbstick_x, "thumbstick_x", "Thumbstick X", XR_ACTION_TYPE_FLOAT_INPUT) || !create_action(&m_action_thumbstick_y, "thumbstick_y", "Thumbstick Y", XR_ACTION_TYPE_FLOAT_INPUT) || !create_action(&m_action_aim_pose, "aim_pose", "Aim Pose", XR_ACTION_TYPE_POSE_INPUT) || !create_action(&m_action_grip_pose, "grip_pose", "Grip Pose", XR_ACTION_TYPE_POSE_INPUT) || !create_action(&m_action_haptic, "haptic", "Haptic Output", XR_ACTION_TYPE_VIBRATION_OUTPUT)) { DestroyInputActions(); return false; } struct BindingDef { XrAction action = XR_NULL_HANDLE; const char* path = nullptr; }; const auto suggest_bindings = [this, &to_path](const char* profile, std::initializer_list defs) { const XrPath profile_path = to_path(profile); if (profile_path == XR_NULL_PATH) return; std::vector bindings; bindings.reserve(defs.size()); for (const auto& def : defs) { if (def.action == XR_NULL_HANDLE || def.path == nullptr) continue; const XrPath binding_path = to_path(def.path); if (binding_path == XR_NULL_PATH) continue; bindings.push_back({def.action, binding_path}); } if (bindings.empty()) return; XrInteractionProfileSuggestedBinding suggested{ XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING}; suggested.interactionProfile = profile_path; suggested.countSuggestedBindings = static_cast(bindings.size()); suggested.suggestedBindings = bindings.data(); const XrResult suggest_result = xrSuggestInteractionProfileBindings(m_instance, &suggested); if (XR_FAILED(suggest_result)) { WARN_LOG_FMT(OPENXR, "OpenXR: xrSuggestInteractionProfileBindings('{}') failed ({}). " "This profile may not be supported by the active runtime.", profile, static_cast(suggest_result)); } else { INFO_LOG_FMT(OPENXR, "OpenXR: Suggested {} bindings for profile '{}'.", bindings.size(), profile); } }; suggest_bindings("/interaction_profiles/khr/simple_controller", { {m_action_primary_click, "/user/hand/left/input/select/click"}, {m_action_primary_click, "/user/hand/right/input/select/click"}, {m_action_menu_click, "/user/hand/left/input/menu/click"}, {m_action_menu_click, "/user/hand/right/input/menu/click"}, {m_action_aim_pose, "/user/hand/left/input/aim/pose"}, {m_action_aim_pose, "/user/hand/right/input/aim/pose"}, {m_action_grip_pose, "/user/hand/left/input/grip/pose"}, {m_action_grip_pose, "/user/hand/right/input/grip/pose"}, {m_action_haptic, "/user/hand/left/output/haptic"}, {m_action_haptic, "/user/hand/right/output/haptic"}, }); // Oculus Touch: NO trigger/click or squeeze/click in spec (only trigger/value + squeeze/value). suggest_bindings("/interaction_profiles/oculus/touch_controller", { {m_action_primary_click, "/user/hand/left/input/x/click"}, {m_action_secondary_click, "/user/hand/left/input/y/click"}, {m_action_menu_click, "/user/hand/left/input/menu/click"}, {m_action_thumbstick_click, "/user/hand/left/input/thumbstick/click"}, {m_action_thumbstick_x, "/user/hand/left/input/thumbstick/x"}, {m_action_thumbstick_y, "/user/hand/left/input/thumbstick/y"}, {m_action_trigger_value, "/user/hand/left/input/trigger/value"}, {m_action_squeeze_value, "/user/hand/left/input/squeeze/value"}, {m_action_aim_pose, "/user/hand/left/input/aim/pose"}, {m_action_grip_pose, "/user/hand/left/input/grip/pose"}, {m_action_primary_click, "/user/hand/right/input/a/click"}, {m_action_secondary_click, "/user/hand/right/input/b/click"}, {m_action_menu_click, "/user/hand/right/input/system/click"}, {m_action_thumbstick_click, "/user/hand/right/input/thumbstick/click"}, {m_action_thumbstick_x, "/user/hand/right/input/thumbstick/x"}, {m_action_thumbstick_y, "/user/hand/right/input/thumbstick/y"}, {m_action_trigger_value, "/user/hand/right/input/trigger/value"}, {m_action_squeeze_value, "/user/hand/right/input/squeeze/value"}, {m_action_aim_pose, "/user/hand/right/input/aim/pose"}, {m_action_grip_pose, "/user/hand/right/input/grip/pose"}, {m_action_haptic, "/user/hand/left/output/haptic"}, {m_action_haptic, "/user/hand/right/output/haptic"}, }); // Meta Touch Plus (promoted from touch_controller_plus in 1.1): NO trigger/click or squeeze/click. // Requires XR_META_touch_controller_plus extension (or 1.1 runtime). if (IsExtensionEnabled(XR_META_TOUCH_CONTROLLER_PLUS_EXTENSION_NAME)) { suggest_bindings("/interaction_profiles/meta/touch_plus_controller", { {m_action_primary_click, "/user/hand/left/input/x/click"}, {m_action_secondary_click, "/user/hand/left/input/y/click"}, {m_action_menu_click, "/user/hand/left/input/menu/click"}, {m_action_thumbstick_click, "/user/hand/left/input/thumbstick/click"}, {m_action_thumbstick_x, "/user/hand/left/input/thumbstick/x"}, {m_action_thumbstick_y, "/user/hand/left/input/thumbstick/y"}, {m_action_trigger_value, "/user/hand/left/input/trigger/value"}, {m_action_squeeze_value, "/user/hand/left/input/squeeze/value"}, {m_action_aim_pose, "/user/hand/left/input/aim/pose"}, {m_action_grip_pose, "/user/hand/left/input/grip/pose"}, {m_action_primary_click, "/user/hand/right/input/a/click"}, {m_action_secondary_click, "/user/hand/right/input/b/click"}, {m_action_menu_click, "/user/hand/right/input/system/click"}, {m_action_thumbstick_click, "/user/hand/right/input/thumbstick/click"}, {m_action_thumbstick_x, "/user/hand/right/input/thumbstick/x"}, {m_action_thumbstick_y, "/user/hand/right/input/thumbstick/y"}, {m_action_trigger_value, "/user/hand/right/input/trigger/value"}, {m_action_squeeze_value, "/user/hand/right/input/squeeze/value"}, {m_action_aim_pose, "/user/hand/right/input/aim/pose"}, {m_action_grip_pose, "/user/hand/right/input/grip/pose"}, {m_action_haptic, "/user/hand/left/output/haptic"}, {m_action_haptic, "/user/hand/right/output/haptic"}, }); // Meta Touch Controller Plus (Quest 3): NO trigger/click or squeeze/click. // Same extension gates both touch_plus_controller and touch_controller_plus. suggest_bindings("/interaction_profiles/meta/touch_controller_plus", { {m_action_primary_click, "/user/hand/left/input/x/click"}, {m_action_secondary_click, "/user/hand/left/input/y/click"}, {m_action_menu_click, "/user/hand/left/input/menu/click"}, {m_action_thumbstick_click, "/user/hand/left/input/thumbstick/click"}, {m_action_thumbstick_x, "/user/hand/left/input/thumbstick/x"}, {m_action_thumbstick_y, "/user/hand/left/input/thumbstick/y"}, {m_action_trigger_value, "/user/hand/left/input/trigger/value"}, {m_action_squeeze_value, "/user/hand/left/input/squeeze/value"}, {m_action_aim_pose, "/user/hand/left/input/aim/pose"}, {m_action_grip_pose, "/user/hand/left/input/grip/pose"}, {m_action_primary_click, "/user/hand/right/input/a/click"}, {m_action_secondary_click, "/user/hand/right/input/b/click"}, {m_action_menu_click, "/user/hand/right/input/system/click"}, {m_action_thumbstick_click, "/user/hand/right/input/thumbstick/click"}, {m_action_thumbstick_x, "/user/hand/right/input/thumbstick/x"}, {m_action_thumbstick_y, "/user/hand/right/input/thumbstick/y"}, {m_action_trigger_value, "/user/hand/right/input/trigger/value"}, {m_action_squeeze_value, "/user/hand/right/input/squeeze/value"}, {m_action_aim_pose, "/user/hand/right/input/aim/pose"}, {m_action_grip_pose, "/user/hand/right/input/grip/pose"}, {m_action_haptic, "/user/hand/left/output/haptic"}, {m_action_haptic, "/user/hand/right/output/haptic"}, }); } // XR_META_touch_controller_plus // Meta Quest 2 controller (1.1 core profile): NO trigger/click or squeeze/click. // Available on 1.1 runtimes without extension; will gracefully fail on 1.0. suggest_bindings("/interaction_profiles/meta/touch_controller_quest_2", { {m_action_primary_click, "/user/hand/left/input/x/click"}, {m_action_secondary_click, "/user/hand/left/input/y/click"}, {m_action_menu_click, "/user/hand/left/input/menu/click"}, {m_action_thumbstick_click, "/user/hand/left/input/thumbstick/click"}, {m_action_thumbstick_x, "/user/hand/left/input/thumbstick/x"}, {m_action_thumbstick_y, "/user/hand/left/input/thumbstick/y"}, {m_action_trigger_value, "/user/hand/left/input/trigger/value"}, {m_action_squeeze_value, "/user/hand/left/input/squeeze/value"}, {m_action_aim_pose, "/user/hand/left/input/aim/pose"}, {m_action_grip_pose, "/user/hand/left/input/grip/pose"}, {m_action_primary_click, "/user/hand/right/input/a/click"}, {m_action_secondary_click, "/user/hand/right/input/b/click"}, {m_action_menu_click, "/user/hand/right/input/system/click"}, {m_action_thumbstick_click, "/user/hand/right/input/thumbstick/click"}, {m_action_thumbstick_x, "/user/hand/right/input/thumbstick/x"}, {m_action_thumbstick_y, "/user/hand/right/input/thumbstick/y"}, {m_action_trigger_value, "/user/hand/right/input/trigger/value"}, {m_action_squeeze_value, "/user/hand/right/input/squeeze/value"}, {m_action_aim_pose, "/user/hand/right/input/aim/pose"}, {m_action_grip_pose, "/user/hand/right/input/grip/pose"}, {m_action_haptic, "/user/hand/left/output/haptic"}, {m_action_haptic, "/user/hand/right/output/haptic"}, }); // Valve Index: has trigger/click but NOT squeeze/click (has squeeze/value + squeeze/force). suggest_bindings("/interaction_profiles/valve/index_controller", { {m_action_primary_click, "/user/hand/left/input/a/click"}, {m_action_secondary_click, "/user/hand/left/input/b/click"}, {m_action_thumbstick_click, "/user/hand/left/input/thumbstick/click"}, {m_action_trigger_click, "/user/hand/left/input/trigger/click"}, {m_action_thumbstick_x, "/user/hand/left/input/thumbstick/x"}, {m_action_thumbstick_y, "/user/hand/left/input/thumbstick/y"}, {m_action_trigger_value, "/user/hand/left/input/trigger/value"}, {m_action_squeeze_value, "/user/hand/left/input/squeeze/value"}, {m_action_squeeze_force, "/user/hand/left/input/squeeze/force"}, {m_action_aim_pose, "/user/hand/left/input/aim/pose"}, {m_action_grip_pose, "/user/hand/left/input/grip/pose"}, {m_action_primary_click, "/user/hand/right/input/a/click"}, {m_action_secondary_click, "/user/hand/right/input/b/click"}, {m_action_thumbstick_click, "/user/hand/right/input/thumbstick/click"}, {m_action_trigger_click, "/user/hand/right/input/trigger/click"}, {m_action_thumbstick_x, "/user/hand/right/input/thumbstick/x"}, {m_action_thumbstick_y, "/user/hand/right/input/thumbstick/y"}, {m_action_trigger_value, "/user/hand/right/input/trigger/value"}, {m_action_squeeze_value, "/user/hand/right/input/squeeze/value"}, {m_action_squeeze_force, "/user/hand/right/input/squeeze/force"}, {m_action_aim_pose, "/user/hand/right/input/aim/pose"}, {m_action_grip_pose, "/user/hand/right/input/grip/pose"}, {m_action_haptic, "/user/hand/left/output/haptic"}, {m_action_haptic, "/user/hand/right/output/haptic"}, }); // ByteDance Pico controllers require XR_BD_controller_interaction extension. if (IsExtensionEnabled(XR_BD_CONTROLLER_INTERACTION_EXTENSION_NAME)) { suggest_bindings("/interaction_profiles/bytedance/pico4_controller", { {m_action_primary_click, "/user/hand/left/input/x/click"}, {m_action_secondary_click, "/user/hand/left/input/y/click"}, {m_action_menu_click, "/user/hand/left/input/menu/click"}, {m_action_thumbstick_click, "/user/hand/left/input/thumbstick/click"}, {m_action_trigger_click, "/user/hand/left/input/trigger/click"}, {m_action_thumbstick_x, "/user/hand/left/input/thumbstick/x"}, {m_action_thumbstick_y, "/user/hand/left/input/thumbstick/y"}, {m_action_trigger_value, "/user/hand/left/input/trigger/value"}, {m_action_squeeze_click, "/user/hand/left/input/squeeze/click"}, {m_action_squeeze_value, "/user/hand/left/input/squeeze/value"}, {m_action_aim_pose, "/user/hand/left/input/aim/pose"}, {m_action_grip_pose, "/user/hand/left/input/grip/pose"}, {m_action_primary_click, "/user/hand/right/input/a/click"}, {m_action_secondary_click, "/user/hand/right/input/b/click"}, {m_action_menu_click, "/user/hand/right/input/system/click"}, {m_action_thumbstick_click, "/user/hand/right/input/thumbstick/click"}, {m_action_trigger_click, "/user/hand/right/input/trigger/click"}, {m_action_thumbstick_x, "/user/hand/right/input/thumbstick/x"}, {m_action_thumbstick_y, "/user/hand/right/input/thumbstick/y"}, {m_action_trigger_value, "/user/hand/right/input/trigger/value"}, {m_action_squeeze_click, "/user/hand/right/input/squeeze/click"}, {m_action_squeeze_value, "/user/hand/right/input/squeeze/value"}, {m_action_aim_pose, "/user/hand/right/input/aim/pose"}, {m_action_grip_pose, "/user/hand/right/input/grip/pose"}, {m_action_haptic, "/user/hand/left/output/haptic"}, {m_action_haptic, "/user/hand/right/output/haptic"}, }); suggest_bindings("/interaction_profiles/bytedance/pico_neo3_controller", { {m_action_primary_click, "/user/hand/left/input/x/click"}, {m_action_secondary_click, "/user/hand/left/input/y/click"}, {m_action_menu_click, "/user/hand/left/input/menu/click"}, {m_action_thumbstick_click, "/user/hand/left/input/thumbstick/click"}, {m_action_trigger_click, "/user/hand/left/input/trigger/click"}, {m_action_thumbstick_x, "/user/hand/left/input/thumbstick/x"}, {m_action_thumbstick_y, "/user/hand/left/input/thumbstick/y"}, {m_action_trigger_value, "/user/hand/left/input/trigger/value"}, {m_action_squeeze_click, "/user/hand/left/input/squeeze/click"}, {m_action_squeeze_value, "/user/hand/left/input/squeeze/value"}, {m_action_aim_pose, "/user/hand/left/input/aim/pose"}, {m_action_grip_pose, "/user/hand/left/input/grip/pose"}, {m_action_primary_click, "/user/hand/right/input/a/click"}, {m_action_secondary_click, "/user/hand/right/input/b/click"}, {m_action_menu_click, "/user/hand/right/input/system/click"}, {m_action_thumbstick_click, "/user/hand/right/input/thumbstick/click"}, {m_action_trigger_click, "/user/hand/right/input/trigger/click"}, {m_action_thumbstick_x, "/user/hand/right/input/thumbstick/x"}, {m_action_thumbstick_y, "/user/hand/right/input/thumbstick/y"}, {m_action_trigger_value, "/user/hand/right/input/trigger/value"}, {m_action_squeeze_click, "/user/hand/right/input/squeeze/click"}, {m_action_squeeze_value, "/user/hand/right/input/squeeze/value"}, {m_action_aim_pose, "/user/hand/right/input/aim/pose"}, {m_action_grip_pose, "/user/hand/right/input/grip/pose"}, {m_action_haptic, "/user/hand/left/output/haptic"}, {m_action_haptic, "/user/hand/right/output/haptic"}, }); } // XR_BD_controller_interaction suggest_bindings("/interaction_profiles/microsoft/motion_controller", { {m_action_menu_click, "/user/hand/left/input/menu/click"}, {m_action_thumbstick_click, "/user/hand/left/input/thumbstick/click"}, {m_action_thumbstick_x, "/user/hand/left/input/thumbstick/x"}, {m_action_thumbstick_y, "/user/hand/left/input/thumbstick/y"}, {m_action_trigger_value, "/user/hand/left/input/trigger/value"}, {m_action_squeeze_click, "/user/hand/left/input/squeeze/click"}, {m_action_aim_pose, "/user/hand/left/input/aim/pose"}, {m_action_grip_pose, "/user/hand/left/input/grip/pose"}, {m_action_menu_click, "/user/hand/right/input/menu/click"}, {m_action_thumbstick_click, "/user/hand/right/input/thumbstick/click"}, {m_action_thumbstick_x, "/user/hand/right/input/thumbstick/x"}, {m_action_thumbstick_y, "/user/hand/right/input/thumbstick/y"}, {m_action_trigger_value, "/user/hand/right/input/trigger/value"}, {m_action_squeeze_click, "/user/hand/right/input/squeeze/click"}, {m_action_aim_pose, "/user/hand/right/input/aim/pose"}, {m_action_grip_pose, "/user/hand/right/input/grip/pose"}, {m_action_haptic, "/user/hand/left/output/haptic"}, {m_action_haptic, "/user/hand/right/output/haptic"}, }); suggest_bindings("/interaction_profiles/htc/vive_controller", { {m_action_menu_click, "/user/hand/left/input/menu/click"}, {m_action_thumbstick_click, "/user/hand/left/input/trackpad/click"}, {m_action_thumbstick_x, "/user/hand/left/input/trackpad/x"}, {m_action_thumbstick_y, "/user/hand/left/input/trackpad/y"}, {m_action_trigger_value, "/user/hand/left/input/trigger/value"}, {m_action_squeeze_click, "/user/hand/left/input/squeeze/click"}, {m_action_aim_pose, "/user/hand/left/input/aim/pose"}, {m_action_grip_pose, "/user/hand/left/input/grip/pose"}, {m_action_menu_click, "/user/hand/right/input/menu/click"}, {m_action_thumbstick_click, "/user/hand/right/input/trackpad/click"}, {m_action_thumbstick_x, "/user/hand/right/input/trackpad/x"}, {m_action_thumbstick_y, "/user/hand/right/input/trackpad/y"}, {m_action_trigger_value, "/user/hand/right/input/trigger/value"}, {m_action_squeeze_click, "/user/hand/right/input/squeeze/click"}, {m_action_aim_pose, "/user/hand/right/input/aim/pose"}, {m_action_grip_pose, "/user/hand/right/input/grip/pose"}, {m_action_haptic, "/user/hand/left/output/haptic"}, {m_action_haptic, "/user/hand/right/output/haptic"}, }); static constexpr const char* FRAME_CONTROLLER_PROFILE = "/interaction_profiles/valve/frame_controller_valve"; if (IsExtensionEnabled("XR_VALVE_frame_controller_interaction")) { // Steam Frame exposes its native button layout through this profile. Touch and grip-pose // inputs are intentionally not mapped into Dolphin's emulated controller UI. suggest_bindings(FRAME_CONTROLLER_PROFILE, { {m_action_primary_click, "/user/hand/right/input/a/click"}, {m_action_secondary_click, "/user/hand/right/input/b/click"}, {m_action_frame_x_click, "/user/hand/right/input/x/click"}, {m_action_frame_y_click, "/user/hand/right/input/y/click"}, {m_action_menu_click, "/user/hand/right/input/menu/click"}, {m_action_system_click, "/user/hand/left/input/system/click"}, {m_action_system_click, "/user/hand/right/input/system/click"}, {m_action_view_click, "/user/hand/left/input/view/click"}, {m_action_bumper_click, "/user/hand/left/input/bumper/click"}, {m_action_bumper_click, "/user/hand/right/input/bumper/click"}, {m_action_dpad_up_click, "/user/hand/left/input/dpad_up/click"}, {m_action_dpad_down_click, "/user/hand/left/input/dpad_down/click"}, {m_action_dpad_left_click, "/user/hand/left/input/dpad_left/click"}, {m_action_dpad_right_click, "/user/hand/left/input/dpad_right/click"}, {m_action_thumbstick_click, "/user/hand/left/input/thumbstick/click"}, {m_action_thumbstick_x, "/user/hand/left/input/thumbstick/x"}, {m_action_thumbstick_y, "/user/hand/left/input/thumbstick/y"}, {m_action_thumbstick_click, "/user/hand/right/input/thumbstick/click"}, {m_action_thumbstick_x, "/user/hand/right/input/thumbstick/x"}, {m_action_thumbstick_y, "/user/hand/right/input/thumbstick/y"}, {m_action_trigger_click, "/user/hand/left/input/trigger/click"}, {m_action_trigger_value, "/user/hand/left/input/trigger/value"}, {m_action_trigger_click, "/user/hand/right/input/trigger/click"}, {m_action_trigger_value, "/user/hand/right/input/trigger/value"}, {m_action_squeeze_click, "/user/hand/left/input/squeeze/click"}, {m_action_squeeze_click, "/user/hand/right/input/squeeze/click"}, {m_action_aim_pose, "/user/hand/left/input/aim/pose"}, {m_action_aim_pose, "/user/hand/right/input/aim/pose"}, {m_action_grip_pose, "/user/hand/left/input/grip/pose"}, {m_action_grip_pose, "/user/hand/right/input/grip/pose"}, {m_action_haptic, "/user/hand/left/output/haptic"}, {m_action_haptic, "/user/hand/right/output/haptic"}, }); } XrSessionActionSetsAttachInfo attach_info{XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO}; const XrActionSet action_set = m_input_action_set; attach_info.countActionSets = 1; attach_info.actionSets = &action_set; result = xrAttachSessionActionSets(m_session, &attach_info); if (XR_FAILED(result)) { ERROR_LOG_FMT(OPENXR, "OpenXR: xrAttachSessionActionSets failed ({}).", static_cast(result)); DestroyInputActions(); return false; } INFO_LOG_FMT(OPENXR, "OpenXR: xrAttachSessionActionSets succeeded."); auto create_action_space = [this](XrAction action, XrPath subaction_path, XrSpace* out_space, const char* label) { if (action == XR_NULL_HANDLE || subaction_path == XR_NULL_PATH) return; XrActionSpaceCreateInfo space_info{XR_TYPE_ACTION_SPACE_CREATE_INFO}; space_info.action = action; space_info.subactionPath = subaction_path; space_info.poseInActionSpace = {{0.f, 0.f, 0.f, 1.f}, {0.f, 0.f, 0.f}}; const XrResult space_result = xrCreateActionSpace(m_session, &space_info, out_space); if (XR_FAILED(space_result)) { WARN_LOG_FMT(OPENXR, "OpenXR: xrCreateActionSpace('{}') failed ({}).", label, static_cast(space_result)); } }; for (size_t hand = 0; hand < m_input_hand_paths.size(); ++hand) { create_action_space(m_action_aim_pose, m_input_hand_paths[hand], &m_aim_spaces[hand], "aim"); create_action_space(m_action_grip_pose, m_input_hand_paths[hand], &m_grip_spaces[hand], "grip"); } #ifdef BAIJI_STEAMVR INFO_LOG_FMT(OPENXR, "OpenXR: Input action system initialized — action set 'baiji_input', " #else INFO_LOG_FMT(OPENXR, "OpenXR: Input action system initialized — action set 'dolphin_input', " #endif "spaces created for both hands."); return true; } void OpenXRManager::DestroyInputActions() { for (auto& space : m_aim_spaces) { if (space != XR_NULL_HANDLE) xrDestroySpace(space); space = XR_NULL_HANDLE; } for (auto& space : m_grip_spaces) { if (space != XR_NULL_HANDLE) xrDestroySpace(space); space = XR_NULL_HANDLE; } if (m_input_action_set != XR_NULL_HANDLE) { xrDestroyActionSet(m_input_action_set); m_input_action_set = XR_NULL_HANDLE; } m_input_hand_paths = {XR_NULL_PATH, XR_NULL_PATH}; m_action_primary_click = XR_NULL_HANDLE; m_action_secondary_click = XR_NULL_HANDLE; m_action_frame_x_click = XR_NULL_HANDLE; m_action_frame_y_click = XR_NULL_HANDLE; m_action_menu_click = XR_NULL_HANDLE; m_action_system_click = XR_NULL_HANDLE; m_action_view_click = XR_NULL_HANDLE; m_action_bumper_click = XR_NULL_HANDLE; m_action_dpad_up_click = XR_NULL_HANDLE; m_action_dpad_down_click = XR_NULL_HANDLE; m_action_dpad_left_click = XR_NULL_HANDLE; m_action_dpad_right_click = XR_NULL_HANDLE; m_action_thumbstick_click = XR_NULL_HANDLE; m_action_trigger_click = XR_NULL_HANDLE; m_action_squeeze_click = XR_NULL_HANDLE; m_action_trigger_value = XR_NULL_HANDLE; m_action_squeeze_value = XR_NULL_HANDLE; m_action_squeeze_force = XR_NULL_HANDLE; m_action_thumbstick_x = XR_NULL_HANDLE; m_action_thumbstick_y = XR_NULL_HANDLE; m_action_aim_pose = XR_NULL_HANDLE; m_action_grip_pose = XR_NULL_HANDLE; m_action_haptic = XR_NULL_HANDLE; m_haptics_active = {false, false}; } void OpenXRManager::ResetInputActionsState() { m_haptics_active = {false, false}; Common::VR::OpenXRInputState::Reset(); } void OpenXRManager::SetSessionFocused(bool focused, bool pause_emulation_when_unfocused) { #ifdef BAIJI_STEAMVR // Use the same user preference for OpenXR session focus changes as for Qt window focus. pause_emulation_when_unfocused = pause_emulation_when_unfocused && Config::Get(Config::MAIN_PAUSE_ON_FOCUS_LOST); #endif Core::System& system = Core::System::GetInstance(); const Core::State core_state = Core::GetState(system); bool pause_emulation = false; bool resume_emulation = false; { std::lock_guard focus_lock(m_session_focus_mutex); m_session_focused.store(focused, std::memory_order_release); Common::VR::OpenXRInputState::SetSessionFocused(focused); if (focused) { m_focus_loss_pause_attempted = false; if (!m_focus_pause_pending && m_emulation_paused_for_focus_loss) { m_emulation_paused_for_focus_loss = false; resume_emulation = true; } } else if (!pause_emulation_when_unfocused) { m_focus_loss_pause_attempted = false; if (m_focus_pause_pending) { m_resume_after_pending_focus_pause = true; } else if (m_emulation_paused_for_focus_loss) { m_emulation_paused_for_focus_loss = false; resume_emulation = true; } } else if (!m_focus_loss_pause_attempted && !m_focus_pause_pending) { if (core_state == Core::State::Paused) { m_focus_loss_pause_attempted = true; } else if (core_state == Core::State::Running) { m_focus_loss_pause_attempted = true; m_focus_pause_pending = true; m_resume_after_pending_focus_pause = false; pause_emulation = true; } } } if (resume_emulation) { if (Core::GetState(system) == Core::State::Paused) { Core::SetState(system, Core::State::Running); } } if (!pause_emulation) return; // Do not hold the focus-state mutex while pausing the core. The video thread can // report XR_SESSION_NOT_FOCUSED while the XR pacing thread handles the matching // session event; Core::SetState may wait for that video thread to reach a safe point. Core::SetState(system, Core::State::Paused); const bool core_is_paused = Core::GetState(system) == Core::State::Paused; bool resume_after_pause = false; { std::lock_guard focus_lock(m_session_focus_mutex); m_focus_pause_pending = false; resume_after_pause = core_is_paused && (m_session_focused.load(std::memory_order_acquire) || m_resume_after_pending_focus_pause); m_resume_after_pending_focus_pause = false; m_emulation_paused_for_focus_loss = core_is_paused && !resume_after_pause; if (resume_after_pause) m_focus_loss_pause_attempted = false; } if (core_is_paused && resume_after_pause) Core::SetState(system, Core::State::Running); } void OpenXRManager::UpdateHaptics() { if (!m_session_running || m_session == XR_NULL_HANDLE || m_action_haptic == XR_NULL_HANDLE) return; const auto haptics = Common::VR::OpenXRInputState::GetHaptics(); // Re-send short pulses while active to approximate continuous rumble. constexpr XrDuration vibration_duration_ns = 50'000'000; for (size_t hand = 0; hand < m_input_hand_paths.size(); ++hand) { const XrPath hand_path = m_input_hand_paths[hand]; if (hand_path == XR_NULL_PATH) continue; const float amplitude = std::clamp(haptics.amplitude[hand], 0.0f, 1.0f); XrHapticActionInfo action_info{XR_TYPE_HAPTIC_ACTION_INFO}; action_info.action = m_action_haptic; action_info.subactionPath = hand_path; if (amplitude > 0.001f) { XrHapticVibration vibration{XR_TYPE_HAPTIC_VIBRATION}; vibration.amplitude = amplitude; vibration.frequency = XR_FREQUENCY_UNSPECIFIED; vibration.duration = vibration_duration_ns; xrApplyHapticFeedback( m_session, &action_info, reinterpret_cast(&vibration)); m_haptics_active[hand] = true; } else if (m_haptics_active[hand]) { xrStopHapticFeedback(m_session, &action_info); m_haptics_active[hand] = false; } } } XrTime OpenXRManager::GetInputSampleTime() { const XrTime display_time = m_frame_state.predictedDisplayTime; #if defined(_WIN32) if (!IsExtensionEnabled(XR_KHR_WIN32_CONVERT_PERFORMANCE_COUNTER_TIME_EXTENSION_NAME)) return display_time; if (!m_pfn_convert_now_to_time) { xrGetInstanceProcAddr(m_instance, "xrConvertWin32PerformanceCounterToTimeKHR", &m_pfn_convert_now_to_time); } if (m_pfn_convert_now_to_time) { LARGE_INTEGER qpc{}; QueryPerformanceCounter(&qpc); XrTime now_time = 0; if (XR_SUCCEEDED(reinterpret_cast( m_pfn_convert_now_to_time)(m_instance, &qpc, &now_time)) && now_time > 0) { return std::min(display_time, now_time); } } #elif defined(ANDROID) if (!IsExtensionEnabled(XR_KHR_CONVERT_TIMESPEC_TIME_EXTENSION_NAME)) return display_time; if (!m_pfn_convert_now_to_time) { xrGetInstanceProcAddr(m_instance, "xrConvertTimespecTimeToTimeKHR", &m_pfn_convert_now_to_time); } if (m_pfn_convert_now_to_time) { timespec ts{}; clock_gettime(CLOCK_MONOTONIC, &ts); XrTime now_time = 0; if (XR_SUCCEEDED(reinterpret_cast( m_pfn_convert_now_to_time)(m_instance, &ts, &now_time)) && now_time > 0) { return std::min(display_time, now_time); } } #endif return display_time; } void OpenXRManager::UpdateInputActions() { if (!m_session_running || m_session == XR_NULL_HANDLE || m_input_action_set == XR_NULL_HANDLE) { ResetInputActionsState(); return; } const XrActiveActionSet active_action_set{m_input_action_set, XR_NULL_PATH}; XrActionsSyncInfo sync_info{XR_TYPE_ACTIONS_SYNC_INFO}; sync_info.countActiveActionSets = 1; sync_info.activeActionSets = &active_action_set; const XrResult sync_result = xrSyncActions(m_session, &sync_info); if (sync_result == XR_SESSION_NOT_FOCUSED) { // XR_SESSION_NOT_FOCUSED is a success code but means input is inactive. // Don't reset state — keep previous connected status to avoid flicker in UI. SetSessionFocused(false); return; } if (XR_FAILED(sync_result)) { ResetInputActionsState(); return; } SetSessionFocused(true); std::array controllers{}; std::array actions_active{}; // Rendering-coupled consumers (Controller Anchor) need poses at the predicted display // time so anchored elements stay glued to the rendered frame; pure input consumers (the // Wii pointer, motion) want measured poses at "now" — prediction extrapolates fast // controller motion several frames ahead and sprays the aim ray. const XrTime input_time = GetInputSampleTime(); const auto locate_space_state = [this](XrSpace space, XrTime time, Common::VR::OpenXRPoseState* pose_state, Common::VR::OpenXRVelocityState* velocity_state) { if (space == XR_NULL_HANDLE || m_reference_space == XR_NULL_HANDLE) return; XrSpaceVelocity velocity{XR_TYPE_SPACE_VELOCITY}; XrSpaceLocation location{XR_TYPE_SPACE_LOCATION}; location.next = &velocity; if (XR_FAILED(xrLocateSpace(space, m_reference_space, time, &location))) { return; } constexpr XrSpaceLocationFlags required_flags = XR_SPACE_LOCATION_POSITION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_VALID_BIT; pose_state->valid = (location.locationFlags & required_flags) == required_flags; if (pose_state->valid) { pose_state->position = {location.pose.position.x, location.pose.position.y, location.pose.position.z}; pose_state->orientation = {location.pose.orientation.x, location.pose.orientation.y, location.pose.orientation.z, location.pose.orientation.w}; } if (velocity_state) { velocity_state->linear_valid = (velocity.velocityFlags & XR_SPACE_VELOCITY_LINEAR_VALID_BIT) != 0; if (velocity_state->linear_valid) { velocity_state->linear = {velocity.linearVelocity.x, velocity.linearVelocity.y, velocity.linearVelocity.z}; } velocity_state->angular_valid = (velocity.velocityFlags & XR_SPACE_VELOCITY_ANGULAR_VALID_BIT) != 0; if (velocity_state->angular_valid) { velocity_state->angular = {velocity.angularVelocity.x, velocity.angularVelocity.y, velocity.angularVelocity.z}; } } }; for (size_t hand = 0; hand < controllers.size(); ++hand) { const XrPath hand_path = m_input_hand_paths[hand]; auto& controller = controllers[hand]; XrInteractionProfileState interaction_profile_state{XR_TYPE_INTERACTION_PROFILE_STATE}; if (hand_path != XR_NULL_PATH && XR_SUCCEEDED(xrGetCurrentInteractionProfile(m_session, hand_path, &interaction_profile_state)) && interaction_profile_state.interactionProfile != m_logged_interaction_profiles[hand]) { m_logged_interaction_profiles[hand] = interaction_profile_state.interactionProfile; const std::string profile_string = PathToString(m_instance, interaction_profile_state.interactionProfile); INFO_LOG_FMT(OPENXR, "OpenXR: Hand {} interaction profile: {}", hand == 0 ? "left" : "right", profile_string.empty() ? "" : profile_string); } bool action_seen = false; const auto get_boolean = [this, hand_path, &action_seen](XrAction action) -> bool { XrActionStateGetInfo get_info{XR_TYPE_ACTION_STATE_GET_INFO}; get_info.action = action; get_info.subactionPath = hand_path; XrActionStateBoolean state{XR_TYPE_ACTION_STATE_BOOLEAN}; if (XR_FAILED(xrGetActionStateBoolean(m_session, &get_info, &state))) return false; action_seen |= (state.isActive == XR_TRUE); return state.currentState == XR_TRUE; }; const auto get_float = [this, hand_path, &action_seen](XrAction action) -> float { XrActionStateGetInfo get_info{XR_TYPE_ACTION_STATE_GET_INFO}; get_info.action = action; get_info.subactionPath = hand_path; XrActionStateFloat state{XR_TYPE_ACTION_STATE_FLOAT}; if (XR_FAILED(xrGetActionStateFloat(m_session, &get_info, &state))) return 0.0f; action_seen |= (state.isActive == XR_TRUE); return state.currentState; }; controller.primary_button = get_boolean(m_action_primary_click); controller.secondary_button = get_boolean(m_action_secondary_click); controller.frame_x_button = get_boolean(m_action_frame_x_click); controller.frame_y_button = get_boolean(m_action_frame_y_click); controller.menu_button = get_boolean(m_action_menu_click); controller.system_button = get_boolean(m_action_system_click); controller.view_button = get_boolean(m_action_view_click); controller.bumper_button = get_boolean(m_action_bumper_click); controller.dpad_up = get_boolean(m_action_dpad_up_click); controller.dpad_down = get_boolean(m_action_dpad_down_click); controller.dpad_left = get_boolean(m_action_dpad_left_click); controller.dpad_right = get_boolean(m_action_dpad_right_click); controller.thumbstick_button = get_boolean(m_action_thumbstick_click); const bool trigger_click = get_boolean(m_action_trigger_click); const bool squeeze_click = get_boolean(m_action_squeeze_click); controller.trigger_value = std::clamp(get_float(m_action_trigger_value), 0.0f, 1.0f); controller.squeeze_value = std::clamp(get_float(m_action_squeeze_value), 0.0f, 1.0f); controller.squeeze_force = std::clamp(get_float(m_action_squeeze_force), 0.0f, 1.0f); controller.thumbstick_x = std::clamp(get_float(m_action_thumbstick_x), -1.0f, 1.0f); controller.thumbstick_y = std::clamp(get_float(m_action_thumbstick_y), -1.0f, 1.0f); locate_space_state(m_aim_spaces[hand], m_frame_state.predictedDisplayTime, &controller.aim_pose, nullptr); locate_space_state(m_grip_spaces[hand], input_time, &controller.grip_pose, &controller.grip_velocity); // Absolute pointing target for the emulated Wii Remote IR: where the aim ray meets // the virtual screen the renderer is actually displaying. Uses a dedicated measured // "now" locate of the aim pose — the display-time aim above stays reserved for the // Controller Anchor, which must match the rendered frame. Common::VR::OpenXRPoseState input_aim; locate_space_state(m_aim_spaces[hand], input_time, &input_aim, nullptr); ComputeVirtualScreenHit(input_aim.valid ? input_aim : controller.aim_pose, &controller.screen_hit); controller.trigger_button = trigger_click || controller.trigger_value > 0.5f; controller.squeeze_button = squeeze_click || std::max(controller.squeeze_value, controller.squeeze_force) > 0.5f; actions_active[hand] = action_seen; controller.connected = action_seen || controller.aim_pose.valid || controller.grip_pose.valid; } // Throttled periodic diagnostic log (~every 5 seconds at 60fps). static uint64_t s_sync_log_counter = 0; if ((++s_sync_log_counter % 300) == 1) { const auto& left = controllers[0]; const auto& right = controllers[1]; INFO_LOG_FMT(OPENXR, "OpenXR input: sync={} focused={} " "L{{connected={} active={} profile={} buttons=[A={},B={},X={},Y={},menu={}," "system={},view={},bumper={},dpad={}/{}/{}/{}] trigger={:.2f} squeeze={:.2f} " "stick=({:.2f},{:.2f})}} " "R{{connected={} active={} profile={} buttons=[A={},B={},X={},Y={},menu={}," "system={}] trigger={:.2f} squeeze={:.2f} stick=({:.2f},{:.2f})}}", static_cast(sync_result), m_session_focused.load(), left.connected, actions_active[0], PathToString(m_instance, m_logged_interaction_profiles[0]), left.primary_button, left.secondary_button, left.frame_x_button, left.frame_y_button, left.menu_button, left.system_button, left.view_button, left.bumper_button, left.dpad_up, left.dpad_down, left.dpad_left, left.dpad_right, left.trigger_value, left.squeeze_value, left.thumbstick_x, left.thumbstick_y, right.connected, actions_active[1], PathToString(m_instance, m_logged_interaction_profiles[1]), right.primary_button, right.secondary_button, right.frame_x_button, right.frame_y_button, right.menu_button, right.system_button, right.trigger_value, right.squeeze_value, right.thumbstick_x, right.thumbstick_y); } // Provide the HMD center pose for IR pointing and world-locked UI placement. Eye orientations // are effectively identical; average the positions to remove the left-eye IPD offset. Common::VR::OpenXRPoseState head_pose; if (m_eye_views[0].pose.orientation.w != 0.0f || m_eye_views[0].pose.orientation.x != 0.0f || m_eye_views[0].pose.orientation.y != 0.0f || m_eye_views[0].pose.orientation.z != 0.0f) { head_pose.valid = true; head_pose.orientation = {m_eye_views[0].pose.orientation.x, m_eye_views[0].pose.orientation.y, m_eye_views[0].pose.orientation.z, m_eye_views[0].pose.orientation.w}; head_pose.position = {(m_eye_views[0].pose.position.x + m_eye_views[1].pose.position.x) * 0.5f, (m_eye_views[0].pose.position.y + m_eye_views[1].pose.position.y) * 0.5f, (m_eye_views[0].pose.position.z + m_eye_views[1].pose.position.z) * 0.5f}; } std::array profile_strings; for (size_t hand = 0; hand < 2; ++hand) profile_strings[hand] = PathToString(m_instance, m_logged_interaction_profiles[hand]); Common::VR::OpenXRInputState::SetControllers(controllers, true, head_pose, profile_strings, m_session_focused, input_time); UpdateStopConfirmationInput(controllers); UpdateHaptics(); } void OpenXRManager::UpdateStopConfirmationInput( const std::array& controllers) { if (!m_stop_confirmation_active.load(std::memory_order_acquire) || m_stop_confirmation_result.load(std::memory_order_acquire) != OpenXRStopConfirmationResult::Pending) return; const auto& right = controllers[1]; const bool primary = right.primary_button; const bool secondary = right.secondary_button; const bool neutral = std::ranges::all_of(controllers, [](const auto& controller) { return !controller.primary_button && !controller.secondary_button && !controller.frame_x_button && !controller.frame_y_button && !controller.menu_button && !controller.system_button && !controller.view_button && !controller.bumper_button && !controller.dpad_up && !controller.dpad_down && !controller.dpad_left && !controller.dpad_right && !controller.trigger_button && !controller.squeeze_button && !controller.thumbstick_button && controller.trigger_value <= 0.2f && controller.squeeze_value <= 0.2f && std::abs(controller.thumbstick_x) <= 0.2f && std::abs(controller.thumbstick_y) <= 0.2f; }); std::lock_guard lock(m_stop_confirmation_input_mutex); if (!m_stop_confirmation_active.load(std::memory_order_acquire) || m_stop_confirmation_result.load(std::memory_order_acquire) != OpenXRStopConfirmationResult::Pending) { return; } if (!m_stop_confirmation_armed) { if (!neutral) { m_stop_confirmation_neutral_since.reset(); } else if (!m_stop_confirmation_neutral_since) { m_stop_confirmation_neutral_since = std::chrono::steady_clock::now(); } else if (std::chrono::steady_clock::now() - *m_stop_confirmation_neutral_since >= std::chrono::milliseconds(150)) { m_stop_confirmation_armed = true; } m_stop_confirmation_previous_primary = primary; m_stop_confirmation_previous_secondary = secondary; return; } const bool primary_pressed = primary && !m_stop_confirmation_previous_primary; const bool secondary_pressed = secondary && !m_stop_confirmation_previous_secondary; m_stop_confirmation_previous_primary = primary; m_stop_confirmation_previous_secondary = secondary; if (primary_pressed && !secondary_pressed) m_stop_confirmation_result.store(OpenXRStopConfirmationResult::Stop, std::memory_order_release); else if (secondary_pressed && !primary_pressed) m_stop_confirmation_result.store(OpenXRStopConfirmationResult::Continue, std::memory_order_release); } bool OpenXRManager::CreateReferenceSpace() { XrReferenceSpaceCreateInfo space_info{XR_TYPE_REFERENCE_SPACE_CREATE_INFO}; // Identity pose: origin at (0,0,0), no rotation. space_info.poseInReferenceSpace = {{0.f, 0.f, 0.f, 1.f}, {0.f, 0.f, 0.f}}; const OpenXRReferenceSpaceMode reference_space_mode = g_ActiveConfig.vr_reference_space_mode; const XrReferenceSpaceType requested_type = reference_space_mode == OpenXRReferenceSpaceMode::Local ? XR_REFERENCE_SPACE_TYPE_LOCAL : XR_REFERENCE_SPACE_TYPE_STAGE; space_info.referenceSpaceType = requested_type; XrResult result = xrCreateReferenceSpace(m_session, &space_info, &m_reference_space); if (XR_FAILED(result) && requested_type == XR_REFERENCE_SPACE_TYPE_STAGE) { WARN_LOG_FMT(OPENXR, "OpenXR: STAGE reference space unavailable for default VR position mode; " "falling back to LOCAL."); space_info.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL; result = xrCreateReferenceSpace(m_session, &space_info, &m_reference_space); } if (XR_FAILED(result)) { char result_string[XR_MAX_RESULT_STRING_SIZE]{}; xrResultToString(m_instance, result, result_string); ERROR_LOG_FMT(OPENXR, "OpenXR: xrCreateReferenceSpace({}) failed: {}", ReferenceSpaceTypeName(space_info.referenceSpaceType), result_string); return false; } m_reference_space_type = space_info.referenceSpaceType; m_home_set = false; m_home_position = {0.f, 0.f, 0.f}; INFO_LOG_FMT(OPENXR, "OpenXR: Created {} reference space.", ReferenceSpaceTypeName(m_reference_space_type)); return true; } bool OpenXRManager::PollEvents() { XrEventDataBuffer event{XR_TYPE_EVENT_DATA_BUFFER}; while (xrPollEvent(m_instance, &event) == XR_SUCCESS) { switch (event.type) { case XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED: { const auto& ev = *reinterpret_cast(&event); HandleSessionStateChange(ev.state); break; } case XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING: WARN_LOG_FMT(OPENXR, "OpenXR: Instance loss pending — stopping VR."); m_exit_render_loop = true; ResetInputActionsState(); return false; case XR_TYPE_EVENT_DATA_REFERENCE_SPACE_CHANGE_PENDING: { const auto& ev = *reinterpret_cast(&event); const float delta_yaw = ev.poseValid ? QuaternionYawDegrees(ev.poseInPreviousSpace.orientation) : 0.0f; INFO_LOG_FMT(OPENXR, "OpenXR: Reference space change pending (type={}, pose_valid={}, delta " "yaw={:.2f}deg) — re-anchoring flat screen to current head pose.", ReferenceSpaceTypeName(ev.referenceSpaceType), ev.poseValid == XR_TRUE, delta_yaw); // The runtime rebased the reference space (system recenter, universe switch, // relocalization). Defer cache invalidation until LocateViews has refreshed m_eye_views // in the new reference-space frame; invalidating here lets the XR thread immediately // recache a pose from the old video-thread view snapshot. m_flat_screen_reanchor_pending.store(true, std::memory_order_release); break; } default: break; } // Reset for next poll. event = {XR_TYPE_EVENT_DATA_BUFFER}; } return !m_exit_render_loop; } void OpenXRManager::HandleSessionStateChange(XrSessionState new_state) { const char* state_name = "UNKNOWN"; switch (new_state) { case XR_SESSION_STATE_UNKNOWN: state_name = "UNKNOWN"; break; case XR_SESSION_STATE_IDLE: state_name = "IDLE"; break; case XR_SESSION_STATE_READY: state_name = "READY"; break; case XR_SESSION_STATE_SYNCHRONIZED: state_name = "SYNCHRONIZED"; break; case XR_SESSION_STATE_VISIBLE: state_name = "VISIBLE"; break; case XR_SESSION_STATE_FOCUSED: state_name = "FOCUSED"; break; case XR_SESSION_STATE_STOPPING: state_name = "STOPPING"; break; case XR_SESSION_STATE_LOSS_PENDING: state_name = "LOSS_PENDING"; break; case XR_SESSION_STATE_EXITING: state_name = "EXITING"; break; default: break; } INFO_LOG_FMT(OPENXR, "OpenXR: Session state -> {} ({})", state_name, static_cast(new_state)); m_session_state = new_state; switch (new_state) { case XR_SESSION_STATE_READY: { XrSessionBeginInfo begin_info{XR_TYPE_SESSION_BEGIN_INFO}; begin_info.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; if (XR_SUCCEEDED(xrBeginSession(m_session, &begin_info))) { m_session_running = true; INFO_LOG_FMT(OPENXR, "OpenXR: Session running."); if (g_ActiveConfig.vr_requested_refresh_rate > 0) { RequestDisplayRefreshRate(static_cast(g_ActiveConfig.vr_requested_refresh_rate)); } #if defined(ANDROID) // Quest 3/3S expose the extra sustained CPU level through the manifest // CPU-for-GPU trade hint. Request sustained high when the session starts, // then re-issue after FOCUSED below for runtimes that defer perf changes. RequestAndroidHighPerformanceLevel(); #endif } else { ERROR_LOG_FMT(OPENXR, "OpenXR: xrBeginSession failed."); } break; } case XR_SESSION_STATE_SYNCHRONIZED: SetSessionFocused(false); break; case XR_SESSION_STATE_VISIBLE: SetSessionFocused(false); break; case XR_SESSION_STATE_FOCUSED: SetSessionFocused(true); INFO_LOG_FMT(OPENXR, "OpenXR: Session FOCUSED — controller input is now active."); #if defined(ANDROID) // Meta Quest may ignore perf settings before the session is FOCUSED. Re-issue // here so the sustained-high request is applied to the active immersive app. RequestAndroidHighPerformanceLevel(); #endif break; case XR_SESSION_STATE_STOPPING: { const XrResult end_result = xrEndSession(m_session); if (XR_FAILED(end_result)) { WARN_LOG_FMT(OPENXR, "OpenXR: xrEndSession failed in STOPPING state ({}).", static_cast(end_result)); } m_session_running = false; SetSessionFocused(false, false); ResetInputActionsState(); INFO_LOG_FMT(OPENXR, "OpenXR: Session stopped."); break; } case XR_SESSION_STATE_LOSS_PENDING: case XR_SESSION_STATE_EXITING: m_exit_render_loop = true; m_session_running = false; SetSessionFocused(false, false); ResetInputActionsState(); break; default: break; } } bool OpenXRManager::WaitFrame() { XrFrameWaitInfo wait_info{XR_TYPE_FRAME_WAIT_INFO}; m_frame_state = {XR_TYPE_FRAME_STATE}; XR_CHECK(xrWaitFrame(m_session, &wait_info, &m_frame_state)); // Cross-thread snapshots: LocateViews/blit gating on the video thread read these // while the pacing thread owns m_frame_state itself. m_predicted_display_time_snapshot.store(m_frame_state.predictedDisplayTime, std::memory_order_release); m_predicted_display_period_snapshot.store(m_frame_state.predictedDisplayPeriod, std::memory_order_release); m_should_render_snapshot.store(m_frame_state.shouldRender == XR_TRUE, std::memory_order_release); if (m_startup_display_refresh_rate_hz <= 0.0f && m_frame_state.predictedDisplayPeriod > 0) { constexpr double ns_per_second = 1000000000.0; SetStartupDisplayRefreshRate( static_cast(ns_per_second / static_cast(m_frame_state.predictedDisplayPeriod)), "first xrWaitFrame predicted display period"); } if (m_last_predicted_display_time != 0 && m_frame_state.predictedDisplayTime > m_last_predicted_display_time) { const XrTime delta = m_frame_state.predictedDisplayTime - m_last_predicted_display_time; constexpr double ns_to_ms = 1.0 / 1000000.0; const double delta_ms = static_cast(delta) * ns_to_ms; if (delta_ms > 0.5 && delta_ms < 50.0) { const double prev = m_estimated_display_period_ms.load(std::memory_order_relaxed); m_estimated_display_period_ms.store( prev <= 0.0 ? delta_ms : (prev * 0.75) + (delta_ms * 0.25), std::memory_order_release); } } m_last_predicted_display_time = m_frame_state.predictedDisplayTime; UpdateInputActions(); return true; } bool OpenXRManager::BeginFrame() { if (m_swapchain && !m_swapchain->WaitForPendingFrameFinalization("before xrBeginFrame")) return false; XrFrameBeginInfo begin_info{XR_TYPE_FRAME_BEGIN_INFO}; { std::unique_lock queue_lock; if (m_swapchain) queue_lock = m_swapchain->AcquireGraphicsQueueLock(); const XrResult begin_result = xrBeginFrame(m_session, &begin_info); if (XR_FAILED(begin_result)) { char buf[XR_MAX_RESULT_STRING_SIZE]{}; xrResultToString(m_instance, begin_result, buf); ERROR_LOG_FMT(OPENXR, "OpenXR: xrBeginFrame failed: {}", buf); return false; } } m_frame_begun.store(true, std::memory_order_release); return true; } bool OpenXRManager::SupportsAlphaBlend() const { return std::find(m_supported_blend_modes.begin(), m_supported_blend_modes.end(), XR_ENVIRONMENT_BLEND_MODE_ALPHA_BLEND) != m_supported_blend_modes.end(); } bool OpenXRManager::IsFBPassthroughUsable() const { return m_xrCreatePassthroughFB != nullptr && m_system_supports_fb_passthrough; } bool OpenXRManager::SupportsPassthrough() const { return IsFBPassthroughUsable() || SupportsAlphaBlend(); } XrEnvironmentBlendMode OpenXRManager::GetActiveBlendMode() const { // XR_FB_passthrough composites its own layer behind an OPAQUE projection layer, so // ALPHA_BLEND is only the fallback for runtimes without the extension. if (g_ActiveConfig.VRPassthroughBackgroundEnabled() && !IsFBPassthroughUsable() && SupportsAlphaBlend()) return XR_ENVIRONMENT_BLEND_MODE_ALPHA_BLEND; return XR_ENVIRONMENT_BLEND_MODE_OPAQUE; } XrCompositionLayerFlags OpenXRManager::GetProjectionLayerExtraFlags() const { // Per-pixel alpha lets the compositor reveal whatever sits behind the projection layer // (the FB passthrough layer, or the real world in an ALPHA_BLEND environment). if (g_ActiveConfig.VRPassthroughEnabled() && SupportsPassthrough()) { return XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT | XR_COMPOSITION_LAYER_UNPREMULTIPLIED_ALPHA_BIT; } return 0; } void OpenXRManager::UpdateFBPassthrough(bool enable) { if (!IsFBPassthroughUsable() || m_session == XR_NULL_HANDLE) return; if (enable && m_fb_passthrough == XR_NULL_HANDLE) { if (m_fb_passthrough_create_failed) return; XrPassthroughCreateInfoFB create_info{XR_TYPE_PASSTHROUGH_CREATE_INFO_FB}; create_info.flags = XR_PASSTHROUGH_IS_RUNNING_AT_CREATION_BIT_FB; XrResult result = m_xrCreatePassthroughFB(m_session, &create_info, &m_fb_passthrough); if (XR_FAILED(result)) { ERROR_LOG_FMT(OPENXR, "OpenXR: xrCreatePassthroughFB failed ({}). Check that passthrough is " "allowed in the headset settings (and 'Passthrough over Meta Horizon " "Link' for Link).", static_cast(result)); m_fb_passthrough = XR_NULL_HANDLE; m_fb_passthrough_create_failed = true; OSD::AddMessage("Passthrough disabled: the OpenXR runtime rejected passthrough creation.", OSD::Duration::VERY_LONG); Config::SetBaseOrCurrent(Config::GFX_VR_PASSTHROUGH, false); g_ActiveConfig.vr_passthrough = false; return; } XrPassthroughLayerCreateInfoFB layer_info{XR_TYPE_PASSTHROUGH_LAYER_CREATE_INFO_FB}; layer_info.passthrough = m_fb_passthrough; layer_info.flags = XR_PASSTHROUGH_IS_RUNNING_AT_CREATION_BIT_FB; layer_info.purpose = XR_PASSTHROUGH_LAYER_PURPOSE_RECONSTRUCTION_FB; result = m_xrCreatePassthroughLayerFB(m_session, &layer_info, &m_fb_passthrough_layer); if (XR_FAILED(result)) { ERROR_LOG_FMT(OPENXR, "OpenXR: xrCreatePassthroughLayerFB failed ({}).", static_cast(result)); m_xrDestroyPassthroughFB(m_fb_passthrough); m_fb_passthrough = XR_NULL_HANDLE; m_fb_passthrough_layer = XR_NULL_HANDLE; m_fb_passthrough_create_failed = true; OSD::AddMessage("Passthrough disabled: the OpenXR runtime rejected the passthrough layer.", OSD::Duration::VERY_LONG); Config::SetBaseOrCurrent(Config::GFX_VR_PASSTHROUGH, false); g_ActiveConfig.vr_passthrough = false; return; } m_fb_passthrough_running = true; INFO_LOG_FMT(OPENXR, "OpenXR: XR_FB_passthrough feed started."); return; } if (m_fb_passthrough == XR_NULL_HANDLE || m_fb_passthrough_layer == XR_NULL_HANDLE) return; if (enable && !m_fb_passthrough_running) { const XrResult start_result = m_xrPassthroughStartFB(m_fb_passthrough); const XrResult resume_result = m_xrPassthroughLayerResumeFB(m_fb_passthrough_layer); m_fb_passthrough_running = XR_SUCCEEDED(start_result) && XR_SUCCEEDED(resume_result); if (m_fb_passthrough_running) { INFO_LOG_FMT(OPENXR, "OpenXR: XR_FB_passthrough feed resumed."); } else { WARN_LOG_FMT(OPENXR, "OpenXR: Failed to resume XR_FB_passthrough (start={} resume={}).", static_cast(start_result), static_cast(resume_result)); if (!m_fb_passthrough_create_failed) { m_fb_passthrough_create_failed = true; OSD::AddMessage("Passthrough disabled: the OpenXR runtime stopped passthrough support.", OSD::Duration::VERY_LONG); Config::SetBaseOrCurrent(Config::GFX_VR_PASSTHROUGH, false); g_ActiveConfig.vr_passthrough = false; } } } else if (!enable && m_fb_passthrough_running) { m_xrPassthroughLayerPauseFB(m_fb_passthrough_layer); m_xrPassthroughPauseFB(m_fb_passthrough); m_fb_passthrough_running = false; INFO_LOG_FMT(OPENXR, "OpenXR: XR_FB_passthrough feed paused."); } } void OpenXRManager::DestroyFBPassthrough() { if (m_fb_passthrough_layer != XR_NULL_HANDLE && m_xrDestroyPassthroughLayerFB != nullptr) m_xrDestroyPassthroughLayerFB(m_fb_passthrough_layer); m_fb_passthrough_layer = XR_NULL_HANDLE; if (m_fb_passthrough != XR_NULL_HANDLE && m_xrDestroyPassthroughFB != nullptr) m_xrDestroyPassthroughFB(m_fb_passthrough); m_fb_passthrough = XR_NULL_HANDLE; m_fb_passthrough_running = false; m_fb_passthrough_create_failed = false; } bool OpenXRManager::BeginStopConfirmation(std::string_view text) { if (!m_swapchain || !m_session_running.load(std::memory_order_acquire) || m_stop_confirmation_active.load(std::memory_order_acquire)) { return false; } const uint32_t game_layer_count = g_ActiveConfig.vr_stereo_screen ? 2 : 1; const bool passthrough_requested = m_fb_passthrough_layer != XR_NULL_HANDLE && g_ActiveConfig.vr_passthrough; const uint32_t required_layer_count = game_layer_count + (passthrough_requested ? 1 : 0) + 1; if (m_max_layer_count < required_layer_count) { WARN_LOG_FMT(OPENXR, "OpenXR: Stop confirmation requires {} composition layers, but the runtime " "supports {}.", required_layer_count, m_max_layer_count); return false; } if (!m_swapchain->PrepareStopConfirmationLayer(text)) return false; { std::lock_guard lock(m_stop_confirmation_input_mutex); m_stop_confirmation_neutral_since.reset(); m_stop_confirmation_armed = false; m_stop_confirmation_previous_primary = false; m_stop_confirmation_previous_secondary = false; } m_stop_confirmation_result.store(OpenXRStopConfirmationResult::Pending, std::memory_order_release); m_stop_confirmation_active.store(true, std::memory_order_release); { std::lock_guard lock(m_publish_mutex); m_publish_cv.notify_all(); } return true; } void OpenXRManager::EndStopConfirmation() { m_stop_confirmation_active.store(false, std::memory_order_release); m_stop_confirmation_result.store(OpenXRStopConfirmationResult::Idle, std::memory_order_release); { std::lock_guard lock(m_stop_confirmation_input_mutex); m_stop_confirmation_neutral_since.reset(); m_stop_confirmation_armed = false; m_stop_confirmation_previous_primary = false; m_stop_confirmation_previous_secondary = false; } } bool OpenXRManager::EndPreparedFrameForPacingThread() { if (!m_frame_begun.load(std::memory_order_acquire)) return true; PublishedXRFrame last_frame; { std::lock_guard lock(m_publish_mutex); last_frame = m_published_frame; } XrCompositionLayerProjection projection_layer{XR_TYPE_COMPOSITION_LAYER_PROJECTION}; std::vector layers; if (last_frame.valid) { if (last_frame.is_quad) { layers.push_back(reinterpret_cast(&last_frame.quad)); } else if (last_frame.is_stereo_quad) { for (uint32_t i = 0; i < last_frame.stereo_quad_count; ++i) { layers.push_back( reinterpret_cast(&last_frame.stereo_quads[i])); } } else { projection_layer.layerFlags = last_frame.layer_flags; projection_layer.space = m_reference_space; projection_layer.viewCount = static_cast(last_frame.views.size()); projection_layer.views = last_frame.views.data(); layers.push_back(reinterpret_cast(&projection_layer)); } } return EndFrameDetached(m_frame_state.predictedDisplayTime, GetActiveBlendMode(), m_frame_state.shouldRender == XR_TRUE, layers); } bool OpenXRManager::EndFrame(const std::vector& layers) { CacheSubmittedFrame(layers); return EndFrameDetached(m_frame_state.predictedDisplayTime, GetActiveBlendMode(), m_frame_state.shouldRender == XR_TRUE, layers); } bool OpenXRManager::EndFrameDetached(XrTime display_time, XrEnvironmentBlendMode environment_blend_mode, bool should_render, const std::vector& layers, bool lock_graphics_queue) { const bool session_focused = m_session_focused.load(std::memory_order_acquire); #ifdef BAIJI_STEAMVR const bool application_focused = Host_RendererHasFocus(); const bool can_submit_layers = should_render && application_focused; #else constexpr bool application_focused = true; const bool can_submit_layers = should_render && session_focused; #endif const bool passthrough_requested = g_ActiveConfig.VRPassthroughBackgroundEnabled() && m_swapchain != nullptr && session_focused && application_focused; // Keep the FB passthrough feed in sync with the Passthrough setting. Gated on a // registered swapchain so the controller-binding utility session never starts it. UpdateFBPassthrough(passthrough_requested); // The passthrough layer composites first (behind the projection layer), replacing the // black void with the camera feed wherever the projection layer's alpha is 0. std::vector submit_layers; submit_layers.reserve(layers.size() + 2); if (can_submit_layers && m_fb_passthrough_running && m_fb_passthrough_layer != XR_NULL_HANDLE) { m_fb_passthrough_composition_layer = {XR_TYPE_COMPOSITION_LAYER_PASSTHROUGH_FB}; m_fb_passthrough_composition_layer.flags = XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT; m_fb_passthrough_composition_layer.space = XR_NULL_HANDLE; m_fb_passthrough_composition_layer.layerHandle = m_fb_passthrough_layer; submit_layers.push_back( reinterpret_cast(&m_fb_passthrough_composition_layer)); } if (can_submit_layers) submit_layers.insert(submit_layers.end(), layers.begin(), layers.end()); if (can_submit_layers && m_stop_confirmation_active.load(std::memory_order_acquire) && m_swapchain != nullptr) { if (XrCompositionLayerQuad* confirmation_layer = m_swapchain->GetStopConfirmationLayer()) { if (submit_layers.size() < m_max_layer_count) { // Composition layers are submitted in stack order. Keeping this quad last makes the // prompt independent of the game's depth buffer and visible over the scene. submit_layers.push_back( reinterpret_cast(confirmation_layer)); } else { WARN_LOG_FMT(OPENXR, "OpenXR: stop-confirmation quad exceeds the runtime layer limit ({}); " "omitting it for this frame.", m_max_layer_count); } } } XrFrameEndInfo end_info{XR_TYPE_FRAME_END_INFO}; end_info.displayTime = display_time; end_info.environmentBlendMode = environment_blend_mode; // Only submit layers when the runtime requests rendering and the session is focused. // Submit 0 layers in VISIBLE/SYNCHRONIZED states, including system room view. if (can_submit_layers) { end_info.layerCount = static_cast(submit_layers.size()); end_info.layers = submit_layers.data(); } { std::unique_lock queue_lock; if (lock_graphics_queue && m_swapchain) queue_lock = m_swapchain->AcquireGraphicsQueueLock(); XR_CHECK(xrEndFrame(m_session, &end_info)); } m_frame_begun.store(false, std::memory_order_release); return true; } bool OpenXRManager::LocateViews() { // Runs on the video/emu thread. With the pacing thread active, m_frame_state belongs // to that thread — use the cross-thread snapshot instead, one display period ahead // (the frame being rendered now will reach the compositor no earlier than that). const XrTime snapshot_time = m_predicted_display_time_snapshot.load(std::memory_order_acquire); if (snapshot_time == 0) return false; // No xrWaitFrame yet this session. const XrTime display_time = IsFrameThreadActive() ? snapshot_time + m_predicted_display_period_snapshot.load(std::memory_order_acquire) : snapshot_time; XrViewLocateInfo locate_info{XR_TYPE_VIEW_LOCATE_INFO}; locate_info.viewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; locate_info.displayTime = display_time; locate_info.space = m_reference_space; XrViewState view_state{XR_TYPE_VIEW_STATE}; uint32_t view_count = static_cast(m_views.size()); m_views.fill({XR_TYPE_VIEW}); XR_CHECK(xrLocateViews(m_session, &locate_info, &view_state, view_count, &view_count, m_views.data())); m_view_state_flags = view_state.viewStateFlags; for (uint32_t i = 0; i < view_count; ++i) { m_eye_views[i].pose = m_views[i].pose; m_eye_views[i].fov = m_views[i].fov; } // Diagnostic: log raw xrLocateViews output every ~2 seconds. We want to know if the // OpenXR runtime is actually returning different positions per eye (real IPD) or if // it's collapsing both eyes to the same point. Real IPD ~ 0.060-0.070 m for adults. if (view_count >= 2) { static int s_locate_views_log_counter = 0; if ((s_locate_views_log_counter++ % 180) == 0) { const XrVector3f& p0 = m_views[0].pose.position; const XrVector3f& p1 = m_views[1].pose.position; const XrQuaternionf& o0 = m_views[0].pose.orientation; const XrQuaternionf& o1 = m_views[1].pose.orientation; const float dx = p1.x - p0.x; const float dy = p1.y - p0.y; const float dz = p1.z - p0.z; const float ipd = std::sqrt(dx * dx + dy * dy + dz * dz); const XrFovf& f0 = m_views[0].fov; const XrFovf& f1 = m_views[1].fov; INFO_LOG_FMT( OPENXR, "VR_IPD_DBG: view_flags=0x{:x} | L_pos=({:.5f},{:.5f},{:.5f}) " "R_pos=({:.5f},{:.5f},{:.5f}) delta=({:.5f},{:.5f},{:.5f}) ipd={:.5f} m | " "L_quat=({:.4f},{:.4f},{:.4f},{:.4f}) R_quat=({:.4f},{:.4f},{:.4f},{:.4f}) | " "L_fov(L={:.4f},R={:.4f},U={:.4f},D={:.4f}) " "R_fov(L={:.4f},R={:.4f},U={:.4f},D={:.4f})", static_cast(view_state.viewStateFlags), p0.x, p0.y, p0.z, p1.x, p1.y, p1.z, dx, dy, dz, ipd, o0.x, o0.y, o0.z, o0.w, o1.x, o1.y, o1.z, o1.w, f0.angleLeft, f0.angleRight, f0.angleUp, f0.angleDown, f1.angleLeft, f1.angleRight, f1.angleUp, f1.angleDown); } } // Seed the render/submit pose snapshots on the very first locate so SubmitFrame has // a valid pose even before any VR draw has refreshed the GS cache. if (m_rendered_eye_views[0].pose.orientation.w == 0.0f && m_rendered_eye_views[0].pose.orientation.x == 0.0f && m_rendered_eye_views[0].pose.orientation.y == 0.0f && m_rendered_eye_views[0].pose.orientation.z == 0.0f) { RecordRenderedEyeViews(); } if (m_recenter_requested.exchange(false, std::memory_order_acq_rel) && view_count >= 2) { m_home_position.x = 0.5f * (m_eye_views[0].pose.position.x + m_eye_views[1].pose.position.x); m_home_position.y = 0.5f * (m_eye_views[0].pose.position.y + m_eye_views[1].pose.position.y); m_home_position.z = 0.5f * (m_eye_views[0].pose.position.z + m_eye_views[1].pose.position.z); m_home_set = true; m_flat_screen_reanchor_pending.store(true, std::memory_order_release); const float head_yaw = QuaternionYawDegrees(m_eye_views[0].pose.orientation); INFO_LOG_FMT(OPENXR, "OpenXR: Recentered home position to ({:.4f},{:.4f},{:.4f}) head_yaw={:.2f}deg.", m_home_position.x, m_home_position.y, m_home_position.z, head_yaw); } // This runs on the video thread after m_eye_views has been refreshed. Reference-space // change events are polled on the XR pacing thread, so consume their re-anchor request // here rather than racing the screen-pose cache from that thread. RefreshFlatScreenPose(); return true; } void OpenXRManager::RequestRecenter() { m_recenter_requested.store(true, std::memory_order_release); } void OpenXRManager::RefreshFlatScreenPose() { const bool reanchor = m_flat_screen_reanchor_pending.exchange(false, std::memory_order_acq_rel); std::lock_guard lock(m_flat_screen_pose_mutex); if (!reanchor && m_flat_screen_pose_valid) return; const float distance = g_ActiveConfig.vr_screen_distance; // A zero orientation means xrLocateViews has not produced a valid pose yet; don't cache it. const XrQuaternionf& q = m_eye_views[0].pose.orientation; const bool have_pose = (q.x != 0.f || q.y != 0.f || q.z != 0.f || q.w != 0.f); if (!have_pose) { m_flat_screen_pose_valid = false; return; } // Head center and yaw-only heading, so the panel sits in front of the user, upright and // level (no pitch/roll), matching PPSSPP's flat-screen placement. const XrVector3f& p0 = m_eye_views[0].pose.position; const XrVector3f& p1 = m_eye_views[1].pose.position; const XrVector3f center{0.5f * (p0.x + p1.x), 0.5f * (p0.y + p1.y), 0.5f * (p0.z + p1.z)}; const float yaw = std::atan2(2.f * (q.x * q.z + q.w * q.y), 1.f - 2.f * (q.x * q.x + q.y * q.y)); XrPosef pose{}; pose.orientation = {0.f, std::sin(yaw * 0.5f), 0.f, std::cos(yaw * 0.5f)}; pose.position = {center.x - std::sin(yaw) * distance, center.y, center.z - std::cos(yaw) * distance}; m_flat_screen_pose = pose; m_flat_screen_pose_valid = true; } XrPosef OpenXRManager::GetFlatScreenPose() const { std::lock_guard lock(m_flat_screen_pose_mutex); if (m_flat_screen_pose_valid) return m_flat_screen_pose; return {{0.f, 0.f, 0.f, 1.f}, {0.f, 0.f, -g_ActiveConfig.vr_screen_distance}}; } bool OpenXRManager::SubmitFlatQuadFrame(XrSwapchain swapchain, uint32_t width, uint32_t height) { if (swapchain == XR_NULL_HANDLE || width == 0 || height == 0) return IsFrameThreadActive() ? true : EndFrame({}); const float height_m = g_ActiveConfig.vr_screen_size; const float configured_aspect = m_flat_screen_aspect.load(std::memory_order_relaxed); const float aspect = configured_aspect > 0.f ? configured_aspect : static_cast(width) / height; m_flat_quad_layer = {XR_TYPE_COMPOSITION_LAYER_QUAD}; m_flat_quad_layer.layerFlags = 0; m_flat_quad_layer.space = m_reference_space; m_flat_quad_layer.eyeVisibility = XR_EYE_VISIBILITY_BOTH; m_flat_quad_layer.pose = GetFlatScreenPose(); m_flat_quad_layer.size = {height_m * aspect, height_m}; m_flat_quad_layer.subImage.swapchain = swapchain; m_flat_quad_layer.subImage.imageArrayIndex = 0; m_flat_quad_layer.subImage.imageRect.offset = {0, 0}; m_flat_quad_layer.subImage.imageRect.extent = {static_cast(width), static_cast(height)}; if (IsFrameThreadActive()) { PublishQuadFrame(m_flat_quad_layer); return true; } const std::vector layers = { reinterpret_cast(&m_flat_quad_layer)}; return EndFrame(layers); } bool OpenXRManager::SubmitStereoQuadFrame(std::array swapchains, uint32_t width, uint32_t height) { if (swapchains[0] == XR_NULL_HANDLE || swapchains[1] == XR_NULL_HANDLE || width == 0 || height == 0) { return IsFrameThreadActive() ? true : EndFrame({}); } const float height_m = g_ActiveConfig.vr_screen_size; const float configured_aspect = m_flat_screen_aspect.load(std::memory_order_relaxed); const float aspect = configured_aspect > 0.f ? configured_aspect : static_cast(width) / height; const XrPosef pose = GetFlatScreenPose(); std::array quad_layers{}; for (uint32_t eye = 0; eye < 2; ++eye) { quad_layers[eye] = {XR_TYPE_COMPOSITION_LAYER_QUAD}; quad_layers[eye].layerFlags = 0; quad_layers[eye].space = m_reference_space; quad_layers[eye].eyeVisibility = eye == 0 ? XR_EYE_VISIBILITY_LEFT : XR_EYE_VISIBILITY_RIGHT; quad_layers[eye].pose = pose; quad_layers[eye].size = {height_m * aspect, height_m}; quad_layers[eye].subImage.swapchain = swapchains[eye]; quad_layers[eye].subImage.imageArrayIndex = 0; quad_layers[eye].subImage.imageRect.offset = {0, 0}; quad_layers[eye].subImage.imageRect.extent = {static_cast(width), static_cast(height)}; } if (IsFrameThreadActive()) { PublishStereoQuadFrame(quad_layers); return true; } const std::vector layers = { reinterpret_cast(&quad_layers[0]), reinterpret_cast(&quad_layers[1])}; return EndFrame(layers); } bool IOpenXRSwapchain::SubmitFlatFrame() { if (!g_openxr) return false; return g_openxr->SubmitFlatQuadFrame(GetFlatSwapchain(), GetEyeWidth(), GetEyeHeight()); } bool IOpenXRSwapchain::SubmitStereoFlatFrame() { if (!g_openxr) return false; return g_openxr->SubmitStereoQuadFrame({GetEyeSwapchainHandle(0), GetEyeSwapchainHandle(1)}, GetEyeWidth(), GetEyeHeight()); } void OpenXRManager::RecordRenderedEyeViews() { m_rendered_eye_views = GetTrackingAdjustedEyeViews(); if (g_ActiveConfig.vr_tracking_mode == OpenXRTrackingMode::None) m_submitted_eye_views = m_eye_views; else m_submitted_eye_views = m_rendered_eye_views; } void OpenXRManager::StampXFBPose(uint32_t xfb_addr) { // Runs at the XFB copy in FIFO order: the just-copied XFB is the completed frame, // and m_submitted_eye_views still holds the pose its draws used — the next frame's // first draw (RecordRenderedEyeViews via the GS cache refresh) is what overwrites it. XFBPoseStamp* slot = nullptr; for (auto& stamp : m_xfb_pose_stamps) { if (stamp.serial != 0 && stamp.xfb_addr == xfb_addr) { slot = &stamp; break; } } if (!slot) { slot = &m_xfb_pose_stamps[m_xfb_pose_stamp_next]; m_xfb_pose_stamp_next = (m_xfb_pose_stamp_next + 1) % m_xfb_pose_stamps.size(); } slot->xfb_addr = xfb_addr; slot->serial = ++m_xfb_pose_stamp_serial; slot->views = m_submitted_eye_views; } void OpenXRManager::SelectPresentPoseForXFB(uint32_t xfb_addr) { // Exact address match first: a VI duplicate present of an older XFB must keep that // XFB's own pose even after a newer copy stamped a different buffer. Fall back to // the newest stamp (stitched hybrid XFBs can present an address no single copy // used); with no stamps at all GetPresentEyeViews falls through to the live // snapshot, which is the pre-stamping behavior. const XFBPoseStamp* match = nullptr; const XFBPoseStamp* newest = nullptr; for (const auto& stamp : m_xfb_pose_stamps) { if (stamp.serial == 0) continue; if (stamp.xfb_addr == xfb_addr) match = &stamp; if (!newest || stamp.serial > newest->serial) newest = &stamp; } if (!match) match = newest; if (match) { m_present_eye_views = match->views; m_present_eye_views_valid = true; } } void OpenXRManager::EnsureHomePositionFromCurrentViews() const { if (m_home_set) return; const float center_x = 0.5f * (m_eye_views[0].pose.position.x + m_eye_views[1].pose.position.x); const float center_y = 0.5f * (m_eye_views[0].pose.position.y + m_eye_views[1].pose.position.y); const float center_z = 0.5f * (m_eye_views[0].pose.position.z + m_eye_views[1].pose.position.z); switch (g_ActiveConfig.vr_reference_space_mode) { case OpenXRReferenceSpaceMode::Stage: // In play-space center mode, Dolphin's VR origin follows the OpenXR reference-space origin. // With STAGE space this is the runtime's play area center; with fallback LOCAL space it is // the runtime-provided local origin. m_home_position = {0.f, 0.f, 0.f}; m_home_set = true; INFO_LOG_FMT(OPENXR, "OpenXR: Home position set to reference-space origin ({})", ReferenceSpaceTypeName(m_reference_space_type)); return; case OpenXRReferenceSpaceMode::StageHeight: if (!m_session_focused || (m_view_state_flags & XR_VIEW_STATE_POSITION_VALID_BIT) == 0 || (m_reference_space_type == XR_REFERENCE_SPACE_TYPE_STAGE && std::fabs(center_y) < 0.05f)) { return; } m_home_position = {0.f, center_y, 0.f}; m_home_set = true; INFO_LOG_FMT(OPENXR, "OpenXR: Home position set to reference-space origin with headset height " "({:.4f}) ({})", m_home_position.y, ReferenceSpaceTypeName(m_reference_space_type)); return; case OpenXRReferenceSpaceMode::Local: break; } if (!m_session_focused || (m_view_state_flags & XR_VIEW_STATE_POSITION_VALID_BIT) == 0) return; m_home_position = {center_x, center_y, center_z}; m_home_set = true; INFO_LOG_FMT(OPENXR, "OpenXR: Home position set to initial headset position ({:.4f},{:.4f},{:.4f})", m_home_position.x, m_home_position.y, m_home_position.z); } std::array OpenXRManager::GetTrackingAdjustedEyeViews() const { EnsureHomePositionFromCurrentViews(); std::array eye_views = m_eye_views; if (g_ActiveConfig.vr_tracking_mode == OpenXRTrackingMode::Full6DoF) return eye_views; const float center_x = 0.5f * (m_eye_views[0].pose.position.x + m_eye_views[1].pose.position.x); const float center_y = 0.5f * (m_eye_views[0].pose.position.y + m_eye_views[1].pose.position.y); const float center_z = 0.5f * (m_eye_views[0].pose.position.z + m_eye_views[1].pose.position.z); if (g_ActiveConfig.vr_tracking_mode == OpenXRTrackingMode::Rotation3DoF) { for (uint32_t eye = 0; eye < 2; ++eye) { eye_views[eye].pose.position.x = m_home_position.x + (m_eye_views[eye].pose.position.x - center_x); eye_views[eye].pose.position.y = m_home_position.y + (m_eye_views[eye].pose.position.y - center_y); eye_views[eye].pose.position.z = m_home_position.z + (m_eye_views[eye].pose.position.z - center_z); } return eye_views; } const float dx = m_eye_views[1].pose.position.x - m_eye_views[0].pose.position.x; const float dy = m_eye_views[1].pose.position.y - m_eye_views[0].pose.position.y; const float dz = m_eye_views[1].pose.position.z - m_eye_views[0].pose.position.z; const float half_ipd = 0.5f * std::sqrt(dx * dx + dy * dy + dz * dz); eye_views[0].pose.orientation = {0.0f, 0.0f, 0.0f, 1.0f}; eye_views[1].pose.orientation = {0.0f, 0.0f, 0.0f, 1.0f}; eye_views[0].pose.position = {m_home_position.x - half_ipd, m_home_position.y, m_home_position.z}; eye_views[1].pose.position = {m_home_position.x + half_ipd, m_home_position.y, m_home_position.z}; return eye_views; } namespace { constexpr std::array CAMERA_ANCHOR_IDENTITY{1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f}; // Rebuild a right-handed orthonormal basis from the columns of a row-major 3x3 // (Gram-Schmidt via cross products, X column authoritative). Removes the element // matrix's scale and repairs drift from per-entry smoothing. Returns false and // leaves the matrix untouched when a column is degenerate. bool OrthonormalizeAnchorRotation(std::array& m) { float xx = m[0], xy = m[3], xz = m[6]; const float yx = m[1], yy = m[4], yz = m[7]; const float x_len = std::sqrt(xx * xx + xy * xy + xz * xz); if (x_len < 1e-5f) return false; xx /= x_len; xy /= x_len; xz /= x_len; // z = x cross y, then y = z cross x. float zx = xy * yz - xz * yy; float zy = xz * yx - xx * yz; float zz = xx * yy - xy * yx; const float z_len = std::sqrt(zx * zx + zy * zy + zz * zz); if (z_len < 1e-5f) return false; zx /= z_len; zy /= z_len; zz /= z_len; const float nyx = zy * xz - zz * xy; const float nyy = zz * xx - zx * xz; const float nyz = zx * xy - zy * xx; m = {xx, nyx, zx, xy, nyy, zy, xz, nyz, zz}; return true; } } // namespace void OpenXRManager::SetPendingCameraAnchor(float x, float y, float z, const std::array& rotation, float units_per_meter) { // First anchor draw of the frame wins; later matches (e.g. a second character // sharing the element signature) are ignored for determinism. if (m_camera_anchor_pending_valid) return; m_camera_anchor_pending_valid = true; m_camera_anchor_pending = {x, y, z}; m_camera_anchor_pending_rotation = rotation; m_camera_anchor_pending_upm = units_per_meter > 0.0f ? units_per_meter : 0.0f; } float OpenXRManager::GetEffectiveUnitsPerMeter() const { return m_camera_anchor_upm > 0.0f ? m_camera_anchor_upm : g_ActiveConfig.vr_units_per_meter; } void OpenXRManager::CommitCameraAnchorFrame() { // Frames the last target survives without a matching draw before the camera // glides back to the default position (rides out transient culling). constexpr int HOLD_FRAMES = 15; if (m_camera_anchor_pending_valid) { m_camera_anchor_target = m_camera_anchor_pending; m_camera_anchor_target_rotation = m_camera_anchor_pending_rotation; if (!OrthonormalizeAnchorRotation(m_camera_anchor_target_rotation)) m_camera_anchor_target_rotation = CAMERA_ANCHOR_IDENTITY; m_camera_anchor_target_upm = m_camera_anchor_pending_upm; m_camera_anchor_has_target = true; m_camera_anchor_missing_frames = 0; } else if (m_camera_anchor_has_target && ++m_camera_anchor_missing_frames > HOLD_FRAMES) { m_camera_anchor_has_target = false; m_camera_anchor_target = {}; m_camera_anchor_target_rotation = CAMERA_ANCHOR_IDENTITY; m_camera_anchor_target_upm = 0.0f; } m_camera_anchor_pending_valid = false; // Exponential smoothing: fraction of the previous position kept each frame // (0 = hard lock to the element, which transmits every animation bob to the head). const float k = std::clamp(g_ActiveConfig.vr_camera_anchor_smoothing, 0.0f, 0.95f); for (size_t i = 0; i < 3; ++i) { m_camera_anchor_position[i] = k * m_camera_anchor_position[i] + (1.0f - k) * m_camera_anchor_target[i]; } // Rotation: per-entry blend toward the target, then re-orthonormalize. For the small // per-frame deltas smoothing produces this behaves like a quaternion nlerp; if the // blend passes through a degenerate configuration (target ~180 degrees away), snap. for (size_t i = 0; i < 9; ++i) { m_camera_anchor_rotation[i] = k * m_camera_anchor_rotation[i] + (1.0f - k) * m_camera_anchor_target_rotation[i]; } if (!OrthonormalizeAnchorRotation(m_camera_anchor_rotation)) m_camera_anchor_rotation = m_camera_anchor_target_rotation; // World scale: glide toward the anchor's own scale while engaged, and back to the global // setting on release. Once it lands back on the global value the scale disengages entirely // (m_camera_anchor_upm = 0) so the global slider stays immediate when no anchor scale is set. const float global_upm = std::max(g_ActiveConfig.vr_units_per_meter, 0.0001f); if (m_camera_anchor_target_upm > 0.0f) { if (m_camera_anchor_upm <= 0.0f) m_camera_anchor_upm = global_upm; // engage from the scale currently on screen m_camera_anchor_upm = k * m_camera_anchor_upm + (1.0f - k) * m_camera_anchor_target_upm; } else if (m_camera_anchor_upm > 0.0f) { m_camera_anchor_upm = k * m_camera_anchor_upm + (1.0f - k) * global_upm; if (std::abs(m_camera_anchor_upm - global_upm) <= 0.001f * global_upm) m_camera_anchor_upm = 0.0f; } bool rotation_active = false; for (size_t i = 0; i < 9; ++i) { if (std::abs(m_camera_anchor_rotation[i] - CAMERA_ANCHOR_IDENTITY[i]) > 1e-4f) { rotation_active = true; break; } } m_camera_anchor_rotation_active = rotation_active; if (!m_camera_anchor_has_target) { // Snap the tail of the return glide so a released anchor leaves exactly zero offset. const float d2 = m_camera_anchor_position[0] * m_camera_anchor_position[0] + m_camera_anchor_position[1] * m_camera_anchor_position[1] + m_camera_anchor_position[2] * m_camera_anchor_position[2]; if (d2 < 1e-6f) m_camera_anchor_position = {}; if (!rotation_active) m_camera_anchor_rotation = CAMERA_ANCHOR_IDENTITY; } } bool OpenXRManager::GetControllerAnchorViewPose(int hand, float units_per_meter, std::array* out_position, std::array* out_rotation) { if (hand < 0 || hand > 1 || !out_position) return false; // Under the head-pose lock the eye projection is frozen for the whole game frame, so // the hand pose must be too — a fresher hand than head would jitter against the // world. Without the lock the GS refetches per draw and so do we. const bool lock = g_ActiveConfig.VRLockHeadPosePerFrame(); if (lock && m_controller_anchor_cache_valid[hand] && std::abs(m_controller_anchor_cache_upm[hand] - units_per_meter) <= 0.0001f) { *out_position = m_controller_anchor_cache[hand]; if (out_rotation) *out_rotation = m_controller_anchor_cache_rot[hand]; return true; } const Common::VR::OpenXRInputSnapshot snapshot = Common::VR::OpenXRInputState::GetSnapshot(); const Common::VR::OpenXRPoseState& aim = snapshot.controllers[hand].aim_pose; std::array rot{}; if (!MapAimPoseToGameView(aim, units_per_meter, out_position, &rot)) return false; if (out_rotation) *out_rotation = rot; if (lock) { m_controller_anchor_cache_valid[hand] = true; m_controller_anchor_cache[hand] = *out_position; m_controller_anchor_cache_rot[hand] = rot; m_controller_anchor_cache_upm[hand] = units_per_meter; } return true; } bool OpenXRManager::MapAimPoseToGameView(const Common::VR::OpenXRPoseState& aim, float units_per_meter, std::array* out_position, std::array* out_rotation) const { if (!aim.valid || !out_position) return false; // Tracking-mode adjustment via the eye-center delta: adjusted_center + (raw − raw_center) // reproduces GetTrackingAdjustedEyeViews() for every tracking mode without duplicating // its switch (exact identity in Full6DoF) and keeps the hand attached to a pinned head // in the 3DoF/no-tracking modes. const std::array adjusted = GetTrackingAdjustedEyeViews(); const float raw_cx = 0.5f * (m_eye_views[0].pose.position.x + m_eye_views[1].pose.position.x); const float raw_cy = 0.5f * (m_eye_views[0].pose.position.y + m_eye_views[1].pose.position.y); const float raw_cz = 0.5f * (m_eye_views[0].pose.position.z + m_eye_views[1].pose.position.z); const float adj_cx = 0.5f * (adjusted[0].pose.position.x + adjusted[1].pose.position.x); const float adj_cy = 0.5f * (adjusted[0].pose.position.y + adjusted[1].pose.position.y); const float adj_cz = 0.5f * (adjusted[0].pose.position.z + adjusted[1].pose.position.z); const float cx = adj_cx + (aim.position[0] - raw_cx); const float cy = adj_cy + (aim.position[1] - raw_cy); const float cz = adj_cz + (aim.position[2] - raw_cz); // Replay of the GetEyeProjectionRows eye-position chain (home offset, camera // height/forward, anchor rotation, anchor position) with the controller substituted // for the eye. Any divergence between the two shows up as the element swimming // against the hand under head motion — keep them in lockstep. const float s = std::max(units_per_meter, 0.0001f); float px = (cx - m_home_position.x) * s; float py = (cy - m_home_position.y) * s; float pz = (cz - m_home_position.z) * s; if (g_ActiveConfig.vr_enable_camera_height) py += g_ActiveConfig.vr_camera_height * s; if (g_ActiveConfig.vr_enable_camera_forward) pz += -g_ActiveConfig.vr_camera_forward * s; if (m_camera_anchor_rotation_active) { const std::array& A = m_camera_anchor_rotation; const float rx = px, ry = py, rz = pz; px = A[0] * rx + A[1] * ry + A[2] * rz; py = A[3] * rx + A[4] * ry + A[5] * rz; pz = A[6] * rx + A[7] * ry + A[8] * rz; } px += m_camera_anchor_position[0]; py += m_camera_anchor_position[1]; pz += m_camera_anchor_position[2]; // Controller orientation in view space. Standard quaternion-to-matrix, columns = // controller-local axes in reference space (X right, Y up, -Z the aim direction); // directions carry over to view space unchanged — validated by the position path — // and the camera-anchor rig rotation applies exactly as it does to the eye offsets. const float qx = aim.orientation[0], qy = aim.orientation[1]; const float qz = aim.orientation[2], qw = aim.orientation[3]; const float x2 = 2.0f * qx * qx, y2 = 2.0f * qy * qy, z2 = 2.0f * qz * qz; const float xy = 2.0f * qx * qy, xz = 2.0f * qx * qz, yz = 2.0f * qy * qz; const float wx = 2.0f * qw * qx, wy = 2.0f * qw * qy, wz = 2.0f * qw * qz; std::array rot = {1.0f - y2 - z2, xy - wz, xz + wy, xy + wz, 1.0f - x2 - z2, yz - wx, xz - wy, yz + wx, 1.0f - x2 - y2}; if (m_camera_anchor_rotation_active) { const std::array& A = m_camera_anchor_rotation; const std::array c = rot; for (int r = 0; r < 3; ++r) { for (int col = 0; col < 3; ++col) { rot[r * 3 + col] = A[r * 3 + 0] * c[0 + col] + A[r * 3 + 1] * c[3 + col] + A[r * 3 + 2] * c[6 + col]; } } } *out_position = {px, py, pz}; if (out_rotation) *out_rotation = rot; return true; } void OpenXRManager::ComputeVirtualScreenHit(const Common::VR::OpenXRPoseState& aim, Common::VR::OpenXRScreenHit* out_hit) const { *out_hit = {}; if (!aim.valid) return; if (g_ActiveConfig.vr_flat_screen) { // Flat panel: a world-locked quad in reference space (meters), yaw-only orientation. // Bring the aim ray into quad-local space (quad plane is z=0, +z toward the viewer). const XrPosef quad = GetFlatScreenPose(); const float yaw = 2.0f * std::atan2(quad.orientation.y, quad.orientation.w); const float cy = std::cos(yaw), sy = std::sin(yaw); const auto to_quad_local = [&](float x, float y, float z) -> std::array { // Inverse yaw rotation about Y. return {cy * x - sy * z, y, sy * x + cy * z}; }; const std::array p = to_quad_local(aim.position[0] - quad.position.x, aim.position[1] - quad.position.y, aim.position[2] - quad.position.z); // Aim forward = orientation * (0,0,-1). const float qx = aim.orientation[0], qy = aim.orientation[1]; const float qz = aim.orientation[2], qw = aim.orientation[3]; const float fx = -(2.0f * (qx * qz + qw * qy)); const float fy = -(2.0f * (qy * qz - qw * qx)); const float fz = -(1.0f - 2.0f * (qx * qx + qy * qy)); const std::array d = to_quad_local(fx, fy, fz); if (std::abs(d[2]) < 1e-6f) return; const float t = -p[2] / d[2]; if (t <= 0.0f) return; const float half_h = std::max(g_ActiveConfig.vr_screen_size * 0.5f, 1e-4f); const float configured_aspect = m_flat_screen_aspect.load(std::memory_order_relaxed); const float aspect = configured_aspect > 0.0f ? configured_aspect : (16.0f / 9.0f); const float half_w = half_h * aspect; out_hit->valid = true; out_hit->u = (p[0] + t * d[0]) / half_w; out_hit->v = (p[1] + t * d[1]) / half_h; // Perpendicular distance, not ray length: rotating the controller must not change // the emulated sensor-bar distance (ray length grows toward the screen corners). out_hit->distance_m = p[2]; return; } // Immersive mode: the ortho virtual screen lives in game view space at z = -distance, // extents {half_h * 16/9, half_h} (see GeometryShaderManager's cvr_screen constants). // Map the aim pose through the same chain the eye takes so the hit matches what is drawn. const float upm = std::max(GetEffectiveUnitsPerMeter(), 0.0001f); std::array pos{}; std::array rot{}; if (!MapAimPoseToGameView(aim, upm, &pos, &rot)) return; // Aim forward in view space = -third column of the rotation. const float dx = -rot[2], dy = -rot[5], dz = -rot[8]; if (std::abs(dz) < 1e-6f) return; const float dist = upm * g_ActiveConfig.vr_screen_distance; const float half_h = std::max(upm * g_ActiveConfig.vr_screen_size * 0.5f, 1e-4f); const float half_w = half_h * (16.0f / 9.0f); const float t = (-dist - pos[2]) / dz; if (t <= 0.0f) return; out_hit->valid = true; out_hit->u = (pos[0] + t * dx) / half_w; out_hit->v = (pos[1] + t * dy) / half_h; // Perpendicular distance to the screen plane (see the flat-mode note above). out_hit->distance_m = (pos[2] + dist) / upm; } void OpenXRManager::InvalidateControllerAnchorCache() { m_controller_anchor_cache_valid = {false, false}; } void OpenXRManager::GetEyeProjectionRows( float units_per_meter, std::array, 4>& out_proj_rows, std::array, 2>& out_z_rows) const { GetEyeProjectionRowsImpl(units_per_meter, true, out_proj_rows, out_z_rows); } void OpenXRManager::GetTrackedEyeProjectionRows( float units_per_meter, std::array, 4>& out_proj_rows, std::array, 2>& out_z_rows) const { GetEyeProjectionRowsImpl(units_per_meter, false, out_proj_rows, out_z_rows); } void OpenXRManager::GetEyeProjectionRowsImpl( float units_per_meter, bool apply_game_camera, std::array, 4>& out_proj_rows, std::array, 2>& out_z_rows) const { const float s = std::max(units_per_meter, 0.0001f); constexpr float DEG_TO_RAD = 0.01745329252f; const float lean_back_rad = apply_game_camera && g_ActiveConfig.vr_enable_lean_back_angle ? g_ActiveConfig.vr_lean_back_angle * DEG_TO_RAD : 0.0f; // Positive UI values should move the camera forward. // In this projection path, decreasing eye-space Z corresponds to moving forward. const float camera_forward_units = apply_game_camera && g_ActiveConfig.vr_enable_camera_forward ? -g_ActiveConfig.vr_camera_forward * s : 0.0f; // Positive UI values should move the camera upward. const float camera_height_units = apply_game_camera && g_ActiveConfig.vr_enable_camera_height ? g_ActiveConfig.vr_camera_height * s : 0.0f; const std::array eye_views = GetTrackingAdjustedEyeViews(); const float eye_center_x = 0.5f * (eye_views[0].pose.position.x + eye_views[1].pose.position.x); const float eye_center_y = 0.5f * (eye_views[0].pose.position.y + eye_views[1].pose.position.y); const float eye_center_z = 0.5f * (eye_views[0].pose.position.z + eye_views[1].pose.position.z); const float eye_offset_scale = s * g_ActiveConfig.vr_stereo_separation; for (uint32_t eye = 0; eye < 2; ++eye) { const XrFovf& fov = eye_views[eye].fov; const XrQuaternionf& q_xr = eye_views[eye].pose.orientation; const XrVector3f& eye_pos_xr = eye_views[eye].pose.position; XrQuaternionf q = {-q_xr.x, -q_xr.y, -q_xr.z, q_xr.w}; if (lean_back_rad != 0.0f) { const float half_angle = 0.5f * lean_back_rad; const XrQuaternionf lean_back_quat = {std::sin(half_angle), 0.0f, 0.0f, std::cos(half_angle)}; q = MultiplyQuaternions(q, lean_back_quat); } // --- Quaternion to 3x3 rotation matrix R --- // R transforms from eye-local frame to reference space (standard quaternion convention). // Columns of R are the eye's local axes expressed in reference-space coordinates. // To go reference→local we use R^T, but it's never needed explicitly: see below. const float x2 = 2.0f * q.x * q.x, y2 = 2.0f * q.y * q.y, z2 = 2.0f * q.z * q.z; const float xy = 2.0f * q.x * q.y, xz = 2.0f * q.x * q.z, yz = 2.0f * q.y * q.z; const float wx = 2.0f * q.w * q.x, wy = 2.0f * q.w * q.y, wz = 2.0f * q.w * q.z; // R rows (R[row][col]): // R[0] = { 1-y2-z2, xy+wz, xz-wy } // R[1] = { xy-wz, 1-x2-z2, yz+wx } // R[2] = { xz+wy, yz-wx, 1-x2-y2 } // // R^T maps reference-space positions into eye-local coords: // eye_local = R^T * (viewPos - eye_pos_game) // // clip_x = P_row0 · eye_local = P_row0 · R^T · d // = Σ_j d_j · (Σ_i P_i · R[j][i]) // = (R * P_col0) · d // // So combined_row = R * proj_col (NOT R^T * proj_col — that would be wrong). // R matrix elements (row, col) const float r00 = 1.0f - y2 - z2, r01 = xy + wz, r02 = xz - wy; const float r10 = xy - wz, r11 = 1.0f - x2 - z2, r12 = yz + wx; const float r20 = xz + wy, r21 = yz - wx, r22 = 1.0f - x2 - y2; // Camera anchor rotation: attach the camera rig to the anchor element's frame. // A's columns are the rig axes in view space, so the full eye-local -> view // rotation is A*R (HMD rotation composes inside the rig frame, and the user can // still look around freely from the element-oriented base). float m00 = r00, m01 = r01, m02 = r02; float m10 = r10, m11 = r11, m12 = r12; float m20 = r20, m21 = r21, m22 = r22; if (apply_game_camera && m_camera_anchor_rotation_active) { const std::array& A = m_camera_anchor_rotation; m00 = A[0] * r00 + A[1] * r10 + A[2] * r20; m01 = A[0] * r01 + A[1] * r11 + A[2] * r21; m02 = A[0] * r02 + A[1] * r12 + A[2] * r22; m10 = A[3] * r00 + A[4] * r10 + A[5] * r20; m11 = A[3] * r01 + A[4] * r11 + A[5] * r21; m12 = A[3] * r02 + A[4] * r12 + A[5] * r22; m20 = A[6] * r00 + A[7] * r10 + A[8] * r20; m21 = A[6] * r01 + A[7] * r11 + A[8] * r21; m22 = A[6] * r02 + A[7] * r12 + A[8] * r22; } // --- Asymmetric projection from FOV tangent angles --- const float tanL = tanf(fov.angleLeft); // negative const float tanR_val = tanf(fov.angleRight); // positive const float tanU = tanf(fov.angleUp); // positive const float tanD = tanf(fov.angleDown); // negative const float inv_w = 1.0f / (tanR_val - tanL); const float inv_h = 1.0f / (tanU - tanD); // Raw projection rows (before head rotation): // proj_row0 = { 2*inv_w, 0, (tanR+tanL)*inv_w } // proj_row1 = { 0, 2*inv_h, (tanU+tanD)*inv_h } const float p0x = 2.0f * inv_w; const float p0z = (tanR_val + tanL) * inv_w; const float p1y = 2.0f * inv_h; const float p1z = (tanU + tanD) * inv_h; // --- Bake rotation: combined = (A*R) * proj_row --- // combined_row0 = (A*R) * {p0x, 0, p0z} const float c0x = m00 * p0x + m02 * p0z; const float c0y = m10 * p0x + m12 * p0z; const float c0z = m20 * p0x + m22 * p0z; // combined_row1 = (A*R) * {0, p1y, p1z} const float c1x = m01 * p1y + m02 * p1z; const float c1y = m11 * p1y + m12 * p1z; const float c1z = m21 * p1y + m22 * p1z; // Keep head translation and world scale unchanged while independently scaling only the // per-eye offset around the head center. float ex = (eye_center_x - m_home_position.x) * s + (eye_pos_xr.x - eye_center_x) * eye_offset_scale; float ey = (eye_center_y - m_home_position.y) * s + (eye_pos_xr.y - eye_center_y) * eye_offset_scale; float ez = (eye_center_z - m_home_position.z) * s + (eye_pos_xr.z - eye_center_z) * eye_offset_scale; if (camera_height_units != 0.0f) ey += camera_height_units; if (camera_forward_units != 0.0f) { // Apply a fixed camera-space forward offset without coupling it to current // head orientation. This keeps head tracking anchored like freelook offsets. ez += camera_forward_units; } if (apply_game_camera && m_camera_anchor_rotation_active) { // The offsets above are rig-space; rotate them into view space so IPD, room // tracking and the fixed camera offsets stay aligned with the turned rig. const std::array& A = m_camera_anchor_rotation; const float rx = ex, ry = ey, rz = ez; ex = A[0] * rx + A[1] * ry + A[2] * rz; ey = A[3] * rx + A[4] * ry + A[5] * rz; ez = A[6] * rx + A[7] * ry + A[8] * rz; } // Camera anchor: committed element position, already in game units and game view // space (see CommitCameraAnchorFrame). Zero when no anchor override is active; // capture is gated on the enable toggle, so disabling glides the camera home. if (apply_game_camera) { ex += m_camera_anchor_position[0]; ey += m_camera_anchor_position[1]; ez += m_camera_anchor_position[2]; } // W component: -dot(combined_xyz, eye_pos) using the ROTATED projection rows. // This gives the correct full view transform: P · R^T · (viewPos - eye_pos). const float c0w = -(c0x * ex + c0y * ey + c0z * ez); const float c1w = -(c1x * ex + c1y * ey + c1z * ez); out_proj_rows[eye * 2 + 0] = {c0x, c0y, c0z, c0w}; out_proj_rows[eye * 2 + 1] = {c1x, c1y, c1z, c1w}; // --- Z-axis row for depth/w computation --- // z_eye = ((A*R)^T * (viewPos - eye_pos)).z = dot((A*R)_col2, viewPos - eye_pos) // (A*R)_col2 = {m02, m12, m22} const float zw = -(m02 * ex + m12 * ey + m22 * ez); out_z_rows[eye] = {m02, m12, m22, zw}; } } void OpenXRManager::GetRawEyeProjectionRows( float units_per_meter, std::array, 4>& out_proj_rows) const { const float s = std::max(units_per_meter, 0.0001f); const std::array eye_views = GetTrackingAdjustedEyeViews(); // Compute head center (average of both eyes) for extracting per-eye local offset. const float hx = 0.5f * (eye_views[0].pose.position.x + eye_views[1].pose.position.x); const float hy = 0.5f * (eye_views[0].pose.position.y + eye_views[1].pose.position.y); const float hz = 0.5f * (eye_views[0].pose.position.z + eye_views[1].pose.position.z); // Get head rotation to compute R^T * (eye_world - head_center) = local eye offset. const XrQuaternionf& q_xr = eye_views[0].pose.orientation; const XrQuaternionf q = {-q_xr.x, -q_xr.y, -q_xr.z, q_xr.w}; const float x2 = 2.0f * q.x * q.x, y2 = 2.0f * q.y * q.y, z2 = 2.0f * q.z * q.z; const float xy = 2.0f * q.x * q.y, xz = 2.0f * q.x * q.z, yz = 2.0f * q.y * q.z; const float wx = 2.0f * q.w * q.x, wy = 2.0f * q.w * q.y, wz = 2.0f * q.w * q.z; // R^T rows (= R columns) for transforming world offset to head-local space const float rt00 = 1.0f - y2 - z2, rt01 = xy - wz, rt02 = xz + wy; const float rt10 = xy + wz, rt11 = 1.0f - x2 - z2, rt12 = yz - wx; for (uint32_t eye = 0; eye < 2; ++eye) { const XrFovf& fov = eye_views[eye].fov; const XrVector3f& eye_pos_xr = eye_views[eye].pose.position; // --- Asymmetric projection from FOV tangent angles (same as rotated version) --- const float tanL = tanf(fov.angleLeft); const float tanR_val = tanf(fov.angleRight); const float tanU = tanf(fov.angleUp); const float tanD = tanf(fov.angleDown); const float inv_w = 1.0f / (tanR_val - tanL); const float inv_h = 1.0f / (tanU - tanD); // Raw (unrotated) projection rows const float p0x = 2.0f * inv_w; const float p0z = (tanR_val + tanL) * inv_w; const float p1y = 2.0f * inv_h; const float p1z = (tanU + tanD) * inv_h; // Per-eye offset in head-local space: R^T * (eye_world - head_center) const float dx = eye_pos_xr.x - hx; const float dy = eye_pos_xr.y - hy; const float dz = eye_pos_xr.z - hz; const float eye_offset_scale = s * g_ActiveConfig.vr_stereo_separation; const float local_ex = (rt00 * dx + rt01 * dy + rt02 * dz) * eye_offset_scale; const float local_ey = (rt10 * dx + rt11 * dy + rt12 * dz) * eye_offset_scale; // W component using raw (unrotated) P rows and head-local eye offset const float pw0 = -(p0x * local_ex + p0z * 0.0f); // p0z * local_ez ≈ 0 const float pw1 = -(p1y * local_ey + p1z * 0.0f); // p1z * local_ez ≈ 0 out_proj_rows[eye * 2 + 0] = {p0x, 0.0f, p0z, pw0}; out_proj_rows[eye * 2 + 1] = {0.0f, p1y, p1z, pw1}; } } bool OpenXRManager::IsQuestOrVirtualDesktopRuntime() const { // Cached: queried per draw on the Vulkan path; the case-insensitive name scans must not // run hundreds of thousands of times per frame. Names are fixed after instance/system // init (the cache is reset where they are assigned). if (!m_quest_or_vd_runtime.has_value()) { const bool virtual_desktop_runtime = Common::CaseInsensitiveContains(m_runtime_name, "virtualdesktop") || Common::CaseInsensitiveContains(m_runtime_name, "virtual desktop"); const bool quest_class_system = Common::CaseInsensitiveContains(m_system_name, "quest") || Common::CaseInsensitiveContains(m_system_name, "oculus") || Common::CaseInsensitiveContains(m_system_name, "meta"); m_quest_or_vd_runtime = virtual_desktop_runtime || quest_class_system; } return *m_quest_or_vd_runtime; } } // namespace VR #endif // ENABLE_VR