Files
thejerf--baijiSteamVR/Source/Core/VideoCommon/VR/OpenXRManager.cpp
T
2026-07-07 05:42:37 +02:00

2502 lines
104 KiB
C++

// 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
#include <openxr/openxr_platform.h>
#endif
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
#if defined(ANDROID)
#include <android/log.h>
#include <mutex>
#include <sys/syscall.h>
#include <unistd.h>
#endif
#include <string_view>
#include "Common/Config/Config.h"
#include "Common/Logging/Log.h"
#include "Common/StringUtil.h"
#include "Common/VR/OpenXRInputState.h"
#include "Core/Config/GraphicsSettings.h"
#include "VideoCommon/OnScreenDisplay.h"
#include "VideoCommon/VideoConfig.h"
namespace VR
{
std::unique_ptr<OpenXRManager> 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<PFN_xrVoidFunction*>(&initialize_loader));
if (XR_FAILED(result) || initialize_loader == nullptr)
{
ERROR_LOG_FMT(OPENXR, "OpenXR: Could not load xrInitializeLoaderKHR ({}).",
static_cast<int>(result));
return false;
}
result = initialize_loader(reinterpret_cast<const XrLoaderInitInfoBaseHeaderKHR*>(&loader_init));
if (XR_FAILED(result))
{
ERROR_LOG_FMT(OPENXR, "OpenXR: xrInitializeLoaderKHR failed ({}).", static_cast<int>(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<uint32_t>(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<int>(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<int>(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<int>(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 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<const char*> OpenXRManager::GetAvailableControllerExtensions()
{
#if defined(ANDROID)
if (!EnsureAndroidOpenXRLoaderInitialized())
return {};
#endif
static const std::array<const char*, 3> s_optional = {
XR_FB_TOUCH_CONTROLLER_PRO_EXTENSION_NAME,
XR_META_TOUCH_CONTROLLER_PLUS_EXTENSION_NAME,
XR_BD_CONTROLLER_INTERACTION_EXTENSION_NAME,
};
uint32_t ext_count = 0;
xrEnumerateInstanceExtensionProperties(nullptr, 0, &ext_count, nullptr);
std::vector<XrExtensionProperties> exts(ext_count, {XR_TYPE_EXTENSION_PROPERTIES});
xrEnumerateInstanceExtensionProperties(nullptr, ext_count, &ext_count, exts.data());
std::vector<const char*> 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;
}
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<XrExtensionProperties> 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()
{
DestroyInputActions();
ResetInputActionsState();
DestroyFBPassthrough();
if (m_reference_space != XR_NULL_HANDLE)
xrDestroySpace(m_reference_space);
if (m_session != XR_NULL_HANDLE)
{
if (m_session_running)
xrEndSession(m_session);
xrDestroySession(m_session);
}
if (m_instance != XR_NULL_HANDLE)
xrDestroyInstance(m_instance);
}
bool OpenXRManager::CreateInstance(const std::vector<const char*>& 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<XrApiLayerProperties> 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<XrExtensionProperties> 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 is intentionally desktop-only. Meta Horizon Link exposes it on
// Windows when passthrough over Link is enabled.
std::vector<const char*> enabled_extensions(extra_extensions);
#if defined(_WIN32)
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
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{};
std::strncpy(app_info.applicationName, "Dolphin Emulator", XR_MAX_APPLICATION_NAME_SIZE - 1);
app_info.applicationVersion = 1;
std::strncpy(app_info.engineName, "Dolphin", XR_MAX_ENGINE_NAME_SIZE - 1);
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<uint32_t>(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<int>(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<PFN_xrVoidFunction*>(&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<int>(refresh_rate_result));
m_xrGetDisplayRefreshRateFB = nullptr;
}
refresh_rate_result = xrGetInstanceProcAddr(
m_instance, "xrRequestDisplayRefreshRateFB",
reinterpret_cast<PFN_xrVoidFunction*>(&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<int>(refresh_rate_result));
m_xrRequestDisplayRefreshRateFB = nullptr;
}
else
{
INFO_LOG_FMT(OPENXR, "OpenXR: XR_FB_display_refresh_rate enabled.");
}
}
#if defined(_WIN32)
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<PFN_xrVoidFunction*>(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<int>(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<int>(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<int>(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<PFN_xrSetAndroidApplicationThreadKHR>(
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<PendingAndroidThreadRegistration> 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<PFN_xrSetAndroidApplicationThreadKHR>(
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<PFN_xrPerfSettingsSetPerformanceLevelEXT>(
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<int>(result));
return false;
}
XrSystemProperties props{XR_TYPE_SYSTEM_PROPERTIES};
#if defined(_WIN32)
XrSystemPassthroughPropertiesFB passthrough_props{XR_TYPE_SYSTEM_PASSTHROUGH_PROPERTIES_FB};
if (m_xrCreatePassthroughFB != nullptr)
props.next = &passthrough_props;
#endif
xrGetSystemProperties(m_instance, m_system_id, &props);
m_system_name = props.systemName;
m_quest_or_vd_runtime.reset();
m_system_vendor_id = props.vendorId;
#if defined(_WIN32)
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()));
for (uint32_t i = 0; i < view_count; ++i)
{
INFO_LOG_FMT(OPENXR, "OpenXR: Eye {} recommended {}x{} (max {}x{})", i,
m_view_config_views[i].recommendedImageRectWidth,
m_view_config_views[i].recommendedImageRectHeight,
m_view_config_views[i].maxImageRectWidth,
m_view_config_views[i].maxImageRectHeight);
}
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)
{
m_requested_display_refresh_rate_hz = 0.0f;
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::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<int>(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();
}
void OpenXRManager::RequestConfiguredDisplayRefreshRate()
{
if (g_ActiveConfig.vr_opcode_replay_mode == OpenXROpcodeReplayMode::Off)
{
m_requested_display_refresh_rate_hz = 0.0f;
return;
}
const int configured_refresh_rate = Config::NormalizeVROpcodeReplayTargetRefreshRate(
g_ActiveConfig.vr_opcode_replay_target_refresh_rate);
if (configured_refresh_rate == Config::GFX_VR_OPCODE_REPLAY_TARGET_REFRESH_RATE_AUTO)
{
m_requested_display_refresh_rate_hz = 0.0f;
return;
}
if (m_session == XR_NULL_HANDLE || !m_session_running)
return;
const float target_refresh_rate_hz = static_cast<float>(configured_refresh_rate);
if (m_requested_display_refresh_rate_hz == target_refresh_rate_hz)
return;
if (m_xrRequestDisplayRefreshRateFB == nullptr)
{
if (!m_output_refresh_rate_unavailable_logged)
{
WARN_LOG_FMT(OPENXR,
"OpenXR: Opcode Replay target refresh rate is set to {:.0f} Hz, but this "
"runtime does not expose xrRequestDisplayRefreshRateFB.",
target_refresh_rate_hz);
m_output_refresh_rate_unavailable_logged = true;
}
return;
}
const XrResult result = m_xrRequestDisplayRefreshRateFB(m_session, target_refresh_rate_hz);
m_requested_display_refresh_rate_hz = target_refresh_rate_hz;
if (XR_FAILED(result))
{
WARN_LOG_FMT(OPENXR,
"OpenXR: xrRequestDisplayRefreshRateFB({:.0f} Hz) failed ({}).",
target_refresh_rate_hz, static_cast<int>(result));
return;
}
INFO_LOG_FMT(OPENXR, "OpenXR: Requested {:.0f} Hz output refresh rate for Opcode Replay.",
target_refresh_rate_hz);
}
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};
CopyOpenXRName(action_set_info.actionSetName, XR_MAX_ACTION_SET_NAME_SIZE, "dolphin_input");
CopyOpenXRName(action_set_info.localizedActionSetName, XR_MAX_LOCALIZED_ACTION_SET_NAME_SIZE,
"Dolphin Input");
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<int>(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<uint32_t>(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<int>(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_menu_click, "menu_click", "Menu Button",
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<BindingDef> defs) {
const XrPath profile_path = to_path(profile);
if (profile_path == XR_NULL_PATH)
return;
std::vector<XrActionSuggestedBinding> 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<uint32_t>(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<int>(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"},
});
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<int>(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<int>(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");
}
INFO_LOG_FMT(OPENXR, "OpenXR: Input action system initialized — "
"action set 'dolphin_input' with 14 actions, 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_menu_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::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<const XrHapticBaseHeader*>(&vibration));
m_haptics_active[hand] = true;
}
else if (m_haptics_active[hand])
{
xrStopHapticFeedback(m_session, &action_info);
m_haptics_active[hand] = false;
}
}
}
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.
return;
}
if (XR_FAILED(sync_result))
{
ResetInputActionsState();
return;
}
std::array<Common::VR::OpenXRControllerState, 2> controllers{};
const auto locate_space_state = [this](XrSpace space, 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, m_frame_state.predictedDisplayTime, &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() ? "<none>" : 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.menu_button = get_boolean(m_action_menu_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], &controller.aim_pose, nullptr);
locate_space_state(m_grip_spaces[hand], &controller.grip_pose, &controller.grip_velocity);
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;
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)
{
INFO_LOG_FMT(OPENXR,
"OpenXR input: sync={}, focused={}, left_connected={}, right_connected={}",
static_cast<int>(sync_result), m_session_focused,
controllers[0].connected, controllers[1].connected);
}
// Provide HMD head orientation for IR pointer reference direction.
// Use left eye orientation as a proxy for head center (negligible difference from averaged).
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[0].pose.position.y,
m_eye_views[0].pose.position.z};
}
std::array<std::string, 2> 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);
UpdateHaptics();
}
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<const XrEventDataSessionStateChanged*>(&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:
INFO_LOG_FMT(OPENXR, "OpenXR: Reference space change pending.");
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<int>(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.");
RequestConfiguredDisplayRefreshRate();
#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:
m_session_focused = false;
break;
case XR_SESSION_STATE_VISIBLE:
m_session_focused = false;
break;
case XR_SESSION_STATE_FOCUSED:
m_session_focused = true;
RequestConfiguredDisplayRefreshRate();
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:
xrEndSession(m_session);
m_session_running = false;
m_session_focused = false;
m_requested_display_refresh_rate_hz = 0.0f;
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_focused = 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));
RequestConfiguredDisplayRefreshRate();
if (m_startup_display_refresh_rate_hz <= 0.0f && m_frame_state.predictedDisplayPeriod > 0)
{
constexpr double ns_per_second = 1000000000.0;
SetStartupDisplayRefreshRate(
static_cast<float>(ns_per_second /
static_cast<double>(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<double>(delta) * ns_to_ms;
if (delta_ms > 0.5 && delta_ms < 50.0)
{
if (m_estimated_display_period_ms <= 0.0)
m_estimated_display_period_ms = delta_ms;
else
m_estimated_display_period_ms = (m_estimated_display_period_ms * 0.75) + (delta_ms * 0.25);
}
}
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<std::mutex> 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;
}
}
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.VRPassthroughEnabled() && !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<int>(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<int>(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<int>(start_result), static_cast<int>(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::EndFrame(const std::vector<XrCompositionLayerBaseHeader*>& 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<XrCompositionLayerBaseHeader*>& layers,
bool lock_graphics_queue)
{
// 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(g_ActiveConfig.VRPassthroughEnabled() && m_swapchain != nullptr);
// 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<XrCompositionLayerBaseHeader*> submit_layers;
submit_layers.reserve(layers.size() + 1);
if (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<XrCompositionLayerBaseHeader*>(&m_fb_passthrough_composition_layer));
}
submit_layers.insert(submit_layers.end(), layers.begin(), layers.end());
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; otherwise submit 0 layers
// (this handles the VISIBLE/SYNCHRONIZED states correctly).
if (should_render)
{
end_info.layerCount = static_cast<uint32_t>(submit_layers.size());
end_info.layers = submit_layers.data();
}
{
std::unique_lock<std::mutex> queue_lock;
if (lock_graphics_queue && m_swapchain)
queue_lock = m_swapchain->AcquireGraphicsQueueLock();
XR_CHECK(xrEndFrame(m_session, &end_info));
}
return true;
}
bool OpenXRManager::LocateViews()
{
XrViewLocateInfo locate_info{XR_TYPE_VIEW_LOCATE_INFO};
locate_info.viewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
locate_info.displayTime = m_frame_state.predictedDisplayTime;
locate_info.space = m_reference_space;
XrViewState view_state{XR_TYPE_VIEW_STATE};
uint32_t view_count = static_cast<uint32_t>(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<uint32_t>(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;
// Re-place the flat panel in front of the newly recentered head pose.
m_flat_screen_pose_valid = false;
INFO_LOG_FMT(OPENXR, "OpenXR: Recentered home position to ({:.4f},{:.4f},{:.4f})",
m_home_position.x, m_home_position.y, m_home_position.z);
}
return true;
}
void OpenXRManager::RequestRecenter()
{
m_recenter_requested.store(true, std::memory_order_release);
}
XrPosef OpenXRManager::GetFlatScreenPose() const
{
const float distance = g_ActiveConfig.vr_screen_distance;
// Fall back to a world-origin panel until a real head pose is available. A zero orientation
// means xrLocateViews has not produced a valid pose yet; don't cache that.
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)
return {{0.f, 0.f, 0.f, 1.f}, {0.f, 0.f, -distance}};
if (m_flat_screen_pose_valid)
return m_flat_screen_pose;
// 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;
return m_flat_screen_pose;
}
bool OpenXRManager::SubmitFlatQuadFrame(XrSwapchain swapchain, uint32_t width, uint32_t height)
{
if (swapchain == XR_NULL_HANDLE || width == 0 || height == 0)
return EndFrame({});
const float height_m = g_ActiveConfig.vr_screen_size;
const float aspect =
m_flat_screen_aspect > 0.f ? m_flat_screen_aspect : static_cast<float>(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<int32_t>(width),
static_cast<int32_t>(height)};
const std::vector<XrCompositionLayerBaseHeader*> layers = {
reinterpret_cast<XrCompositionLayerBaseHeader*>(&m_flat_quad_layer)};
return EndFrame(layers);
}
bool IOpenXRSwapchain::SubmitFlatFrame()
{
if (!g_openxr)
return false;
return g_openxr->SubmitFlatQuadFrame(GetFlatSwapchain(), 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::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<XREyeView, 2> OpenXRManager::GetTrackingAdjustedEyeViews() const
{
EnsureHomePositionFromCurrentViews();
std::array<XREyeView, 2> 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;
}
void OpenXRManager::GetEyeProjectionRows(
float units_per_meter,
std::array<std::array<float, 4>, 4>& out_proj_rows,
std::array<std::array<float, 4>, 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 = g_ActiveConfig.vr_lean_back_angle * DEG_TO_RAD;
// 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 =
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 =
g_ActiveConfig.vr_enable_camera_height ? g_ActiveConfig.vr_camera_height * s : 0.0f;
const std::array<XREyeView, 2> eye_views = GetTrackingAdjustedEyeViews();
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;
// --- 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 = R * proj_row ---
// combined_row0 = R * {p0x, 0, p0z}
const float c0x = r00 * p0x + r02 * p0z;
const float c0y = r10 * p0x + r12 * p0z;
const float c0z = r20 * p0x + r22 * p0z;
// combined_row1 = R * {0, p1y, p1z}
const float c1x = r01 * p1y + r02 * p1z;
const float c1y = r11 * p1y + r12 * p1z;
const float c1z = r21 * p1y + r22 * p1z;
// Eye position relative to home, in game units.
// Includes both IPD offset (per-eye) and head positional tracking (shared).
float ex = (eye_pos_xr.x - m_home_position.x) * s;
float ey = (eye_pos_xr.y - m_home_position.y) * s;
float ez = (eye_pos_xr.z - m_home_position.z) * s;
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;
}
// 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 = (R^T * (viewPos - eye_pos)).z = dot(R_col2, viewPos - eye_pos)
// R_col2 = {r02, r12, r22}
const float zw = -(r02 * ex + r12 * ey + r22 * ez);
out_z_rows[eye] = {r02, r12, r22, zw};
}
}
void OpenXRManager::GetRawEyeProjectionRows(
float units_per_meter,
std::array<std::array<float, 4>, 4>& out_proj_rows) const
{
const float s = std::max(units_per_meter, 0.0001f);
const std::array<XREyeView, 2> 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 local_ex = (rt00 * dx + rt01 * dy + rt02 * dz) * s;
const float local_ey = (rt10 * dx + rt11 * dy + rt12 * dz) * s;
// 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