diff --git a/aurora-main/include/aurora/aurora.h b/aurora-main/include/aurora/aurora.h index c3f70d2..53e8ae4 100644 --- a/aurora-main/include/aurora/aurora.h +++ b/aurora-main/include/aurora/aurora.h @@ -77,6 +77,43 @@ typedef struct AuroraEvent AuroraEvent; typedef void (*AuroraLogCallback)(AuroraLogLevel level, const char* module, const char* message, unsigned int len); typedef void (*AuroraImGuiInitCallback)(const AuroraWindowSize* size); +enum { AURORA_STEREO_EYE_COUNT = 2 }; + +/** + * One eye of a stereo frame supplied by the host application. + * + * projection is a row-major, renderer-ready 4x4 projection matrix. It uses + * the same convention as the matrix uploaded by GXSetProjection after + * Aurora's depth-range adjustment. + * + * viewFromCenter is a row-major affine 3x4 transform from the game's + * recorded center-eye view space into this eye's view space. Identity keeps + * the recorded view and is useful when the game has already applied the eye + * transform before issuing GX commands. + */ +typedef struct { + uint32_t width; + uint32_t height; + float projection[16]; + float viewFromCenter[12]; +} AuroraStereoEye; + +/** + * Stereo data for one sealed GX frame. frameToken is opaque to Aurora and is + * forwarded unchanged to the internal stereo output sink. + */ +typedef struct { + uint64_t frameToken; + AuroraStereoEye eyes[AURORA_STEREO_EYE_COUNT]; +} AuroraStereoFrame; + +/** + * Called on Aurora's frame worker immediately before a GX frame is sealed. + * Return false to render that logical frame in mono only. The callback must + * be non-blocking and must not call back into Aurora. + */ +typedef bool (*AuroraStereoFrameProvider)(uint32_t logicalFrame, AuroraStereoFrame* frame, void* userdata); + typedef struct { const char* appName; const char* userPath; @@ -125,6 +162,15 @@ typedef struct { // Optional directory for the portable GX pipeline database. When null, the // database is stored in cachePath with Dawn's machine-specific cache. const char* pipelineCachePath; + + // Enables renderer features needed by an external XR compositor. The normal + // desktop path is unchanged when false. + bool xrInterop; + // Optional OpenXR-selected D3D adapter. Supplying the runtime's LUID before + // device creation keeps Dawn and the compositor on the same physical GPU. + bool hasD3D12AdapterLuid; + uint32_t d3d12AdapterLuidLow; + int32_t d3d12AdapterLuidHigh; } AuroraConfig; typedef struct { @@ -144,6 +190,9 @@ typedef void (*AuroraFrameWorkerWaitCallback)(); // Called from the producer thread at bounded intervals while Aurora waits for // the asynchronous frame worker. The callback must not enter Aurora. void aurora_set_frame_worker_wait_callback(AuroraFrameWorkerWaitCallback callback); +// Registering nullptr restores the ordinary mono-only render path. Replace or +// unregister a provider only while Aurora's frame worker is idle. +void aurora_set_stereo_frame_provider(AuroraStereoFrameProvider provider, void* userdata); void aurora_wait_for_frame_worker(); bool aurora_wait_for_frame_worker_for(uint32_t timeoutMicros); // Absolute schedule for the next sealed frame, on steady_clock: baseNanos anchors the group and diff --git a/aurora-main/lib/gfx/common.hpp b/aurora-main/lib/gfx/common.hpp index e0a24a3..25f1fe6 100644 --- a/aurora-main/lib/gfx/common.hpp +++ b/aurora-main/lib/gfx/common.hpp @@ -281,7 +281,32 @@ void shutdown(); bool begin_frame(); bool resume_frame(); void abort_frame() noexcept; +struct ReplayTarget { + wgpu::TextureView colorView; + wgpu::TextureView resolveView; + wgpu::TextureView depthView; + wgpu::Texture copySourceTexture; + wgpu::TextureView copySourceView; + wgpu::TextureView copySourceDepthView; + wgpu::Extent3D size{}; + uint32_t msaaSamples = 1; +}; + +struct StereoReplayEye { + ReplayTarget target; + Mat4x4 projection; + Mat3x4 viewFromCenter; +}; + +struct StereoReplayFrame { + std::array eyes; +}; + void end_frame(const wgpu::CommandEncoder& cmd); +// Prepares eye-specific uniform copies before unmapping the staging buffer. +// Returns false without modifying the mono path when the uniform buffer has +// insufficient room for the additional copies. +bool end_frame(const wgpu::CommandEncoder& cmd, const StereoReplayFrame& stereoFrame); void end_batch(const wgpu::CommandEncoder& cmd); uint32_t current_frame() noexcept; @@ -314,6 +339,12 @@ void seal_frame(SealedFrame& out) noexcept; // so this may run concurrently with the producer's FIFO drains. void render(SealedFrame& frame, wgpu::CommandEncoder& cmd, int32_t interpolatedFrame = -1, bool finalize = true); +// Replays only main-EFB passes into one Aurora-owned eye target. Native +// offscreen/EFB-copy passes are consumed from the mono render and are not +// mutated by stereo replay. +void render_stereo_eye(SealedFrame& frame, wgpu::CommandEncoder& cmd, + const StereoReplayFrame& stereoFrame, uint32_t eye, bool finalize = false); + // Encode the frame that is still being recorded. Only for the synchronous // EFB-readback split path, which runs on the producer thread. void render(wgpu::CommandEncoder& cmd, int32_t interpolatedFrame = -1, bool finalize = true); diff --git a/aurora-main/lib/gfx/stereo_replay.hpp b/aurora-main/lib/gfx/stereo_replay.hpp new file mode 100644 index 0000000..36d0a05 --- /dev/null +++ b/aurora-main/lib/gfx/stereo_replay.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include + +namespace aurora::gfx::stereo_replay { + +// Aurora stores the GX 3x4 matrices row-major. The vertex shader consumes +// them as vec4 * mat3x4, which is equivalent to the original column-vector +// affine transform. Applying an eye-space delta therefore composes delta * +// objectToCenter in the ordinary row-major notation used below. +inline Mat3x4 compose_affine(const Mat3x4& viewFromCenter, + const Mat3x4& objectToCenter) noexcept { + Mat3x4 out{}; + for (size_t row = 0; row < 3; ++row) { + auto& dst = *(&out.m0 + row); + const auto& view = *(&viewFromCenter.m0 + row); + for (size_t column = 0; column < 3; ++column) { + dst[column] = view[0] * objectToCenter.m0[column] + view[1] * objectToCenter.m1[column] + + view[2] * objectToCenter.m2[column]; + } + dst[3] = view[3] + view[0] * objectToCenter.m0[3] + view[1] * objectToCenter.m1[3] + + view[2] * objectToCenter.m2[3]; + } + return out; +} + +// Normals receive only the eye transform's linear part. OpenXR view deltas +// are rigid transforms, so no inverse-transpose correction is needed here. +inline Mat3x4 compose_normal(const Mat3x4& viewFromCenter, + const Mat3x4& objectToCenter) noexcept { + Mat3x4 out{}; + for (size_t row = 0; row < 3; ++row) { + auto& dst = *(&out.m0 + row); + const auto& view = *(&viewFromCenter.m0 + row); + for (size_t column = 0; column < 3; ++column) { + dst[column] = view[0] * objectToCenter.m0[column] + view[1] * objectToCenter.m1[column] + + view[2] * objectToCenter.m2[column]; + } + dst[3] = 0.0f; + } + return out; +} + +} // namespace aurora::gfx::stereo_replay diff --git a/aurora-main/lib/gx/command_processor.cpp b/aurora-main/lib/gx/command_processor.cpp index 5f69705..c8846e9 100644 --- a/aurora-main/lib/gx/command_processor.cpp +++ b/aurora-main/lib/gx/command_processor.cpp @@ -2256,9 +2256,9 @@ static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, .matrixTopology = matrixTopologySignature, }; } + const bool perspective = g_gxState.projType == GX_PERSPECTIVE; const auto uniformRanges = - build_uniform(info, vertRange.offset, ranges, drawIdentity, interpolationIdentityActive, - usedPnMtxMask); + build_uniform(info, vertRange.offset, ranges, drawIdentity, perspective, usedPnMtxMask); s_lastDrawRecordedInterpolation = interpolationIdentityActive; uint32_t instanceCount = 1; @@ -2275,6 +2275,8 @@ static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, .idxRange = idxRange, .uniformRange = uniformRanges.current, .interpolatedUniformRanges = uniformRanges.interpolated, + .stereoUniformRanges = {}, + .uniformReplayLayout = uniformRanges.replayLayout, .vtxCount = vtxCount, .indexCount = numIndices, .instanceCount = instanceCount, diff --git a/aurora-main/lib/gx/pipeline.hpp b/aurora-main/lib/gx/pipeline.hpp index 92a9e4d..ce17f27 100644 --- a/aurora-main/lib/gx/pipeline.hpp +++ b/aurora-main/lib/gx/pipeline.hpp @@ -10,6 +10,8 @@ struct DrawData { gfx::Range idxRange; gfx::Range uniformRange; std::array interpolatedUniformRanges; + std::array stereoUniformRanges; + UniformReplayLayout uniformReplayLayout; uint32_t vtxCount; uint32_t indexCount; uint32_t instanceCount; diff --git a/aurora-main/lib/gx/shader_info.cpp b/aurora-main/lib/gx/shader_info.cpp index 3c21e4f..b09603d 100644 --- a/aurora-main/lib/gx/shader_info.cpp +++ b/aurora-main/lib/gx/shader_info.cpp @@ -613,10 +613,14 @@ UniformRanges build_uniform(const ShaderInfo& info, u32 vtxStart, const BindGrou stage(&effectiveProj, sizeof(effectiveProj)); const size_t positionOffset = stagedSize; + uint32_t positionMatrixMask = 0; for (u32 i = 0; i < layout.postexCount; ++i) { const u32 slot = layout.postexSlots[i] == UniformMatrixLayout::kCurrentPnMtx ? currentPostexSlot : layout.postexSlots[i]; + if (slot < MaxPnMtx) { + positionMatrixMask |= 1u << i; + } stage(slot < MaxPnMtx ? &g_gxState.pnMtx[slot].pos : &g_gxState.texMtxs[slot - MaxPnMtx], sizeof(Mat3x4)); } @@ -707,11 +711,22 @@ UniformRanges build_uniform(const ShaderInfo& info, u32 vtxStart, const BindGrou buf.append(texture_size_bias(tex)); } + const UniformReplayLayout replayLayout{ + .projectionOffset = static_cast(projectionOffset), + .positionOffset = static_cast(positionOffset), + .normalOffset = static_cast(normalOffset), + .positionMatrixMask = positionMatrixMask, + .positionMatrixCount = layout.postexCount, + .normalMatrixCount = layout.nrmCount, + .perspective = perspective, + }; + if (!perspective || frame_interpolation_fps() == 0) { g_gxState.stateDirty = false; return { .current = range, .interpolated = {}, + .replayLayout = replayLayout, }; } @@ -733,6 +748,7 @@ UniformRanges build_uniform(const ShaderInfo& info, u32 vtxStart, const BindGrou return { .current = range, .interpolated = interpolatedRanges, + .replayLayout = replayLayout, }; } } // namespace aurora::gx diff --git a/aurora-main/lib/gx/shader_info.hpp b/aurora-main/lib/gx/shader_info.hpp index 6b8e329..6b07cff 100644 --- a/aurora-main/lib/gx/shader_info.hpp +++ b/aurora-main/lib/gx/shader_info.hpp @@ -5,9 +5,20 @@ #include "frame_interpolation.hpp" namespace aurora::gx { +struct UniformReplayLayout { + uint32_t projectionOffset = 0; + uint32_t positionOffset = 0; + uint32_t normalOffset = 0; + uint32_t positionMatrixMask = 0; + uint8_t positionMatrixCount = 0; + uint8_t normalMatrixCount = 0; + bool perspective = false; +}; + struct UniformRanges { gfx::Range current; std::array interpolated; + UniformReplayLayout replayLayout; }; ShaderInfo build_shader_info(const ShaderConfig& config) noexcept; diff --git a/aurora-main/lib/stereo.hpp b/aurora-main/lib/stereo.hpp new file mode 100644 index 0000000..f67555a --- /dev/null +++ b/aurora-main/lib/stereo.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include "webgpu/gpu.hpp" + +#include +#include + +namespace aurora::stereo { + +struct EyeImage { + // Borrowed for the duration of the sink callback. A sink may encode work + // that reads the texture, but must not retain these pointers. + const wgpu::Texture* texture = nullptr; + const wgpu::TextureView* view = nullptr; + wgpu::Extent3D size{}; + wgpu::TextureFormat format = wgpu::TextureFormat::Undefined; +}; + +struct SinkFrame { + uint64_t frameToken = 0; + uint32_t logicalFrame = 0; + std::array eyes{}; +}; + +// Runs synchronously on the frame worker after both eye replays have been +// encoded and before its command buffer is submitted. Backend interop code +// can append copies/import transitions to the same encoder here. +using SinkCallback = void (*)(wgpu::CommandEncoder& encoder, const SinkFrame& frame, void* userdata) noexcept; + +// Internal Aurora hook: D3D12/Vulkan OpenXR interop owns this registration. +// Registration changes must happen while the frame worker is idle. +void set_sink(SinkCallback callback, void* userdata) noexcept; + +} // namespace aurora::stereo diff --git a/aurora-main/lib/webgpu/gpu.cpp b/aurora-main/lib/webgpu/gpu.cpp index e2fe351..74837ef 100644 --- a/aurora-main/lib/webgpu/gpu.cpp +++ b/aurora-main/lib/webgpu/gpu.cpp @@ -43,6 +43,18 @@ void clear_offscreen_cache(); namespace aurora::webgpu { static Module Log("aurora::gpu"); +#if defined(WEBGPU_DAWN) && defined(_WIN32) +// RequestAdapterOptionsLUID is a Dawn-native extension whose exported C++ +// constructor cannot be called safely by this project's LLVM-MinGW consumer. +// Its wire representation is deliberately just a WebGPU chained header plus +// the Win32 LUID, so describe that C ABI directly and let RequestAdapter +// consume it through the ordinary WebGPU entry point. +struct RequestAdapterOptionsLuidWire { + wgpu::ChainedStruct chain{}; + LUID adapterLuid{}; +}; +#endif + wgpu::Device g_device; wgpu::Queue g_queue; wgpu::Surface g_surface; @@ -558,11 +570,24 @@ bool initialize(AuroraBackend auroraBackend) { } } { - const wgpu::RequestAdapterOptions options{ + wgpu::RequestAdapterOptions options{ .powerPreference = wgpu::PowerPreference::HighPerformance, .backendType = backend, .compatibleSurface = g_surface, }; +#if defined(WEBGPU_DAWN) && defined(_WIN32) + RequestAdapterOptionsLuidWire luidOptions{}; + if (backend == wgpu::BackendType::D3D12 && g_config.xrInterop && + g_config.hasD3D12AdapterLuid) { + luidOptions.chain.sType = wgpu::SType::RequestAdapterOptionsLUID; + luidOptions.adapterLuid.LowPart = g_config.d3d12AdapterLuidLow; + luidOptions.adapterLuid.HighPart = g_config.d3d12AdapterLuidHigh; + options.nextInChain = &luidOptions.chain; + Log.info("Pinning D3D12 adapter to OpenXR LUID {:08x}:{:08x}", + static_cast(luidOptions.adapterLuid.HighPart), + luidOptions.adapterLuid.LowPart); + } +#endif const auto future = g_instance.RequestAdapter( &options, wgpu::CallbackMode::WaitAnyOnly, [](wgpu::RequestAdapterStatus status, wgpu::Adapter adapter, wgpu::StringView message) { @@ -653,6 +678,12 @@ bool initialize(AuroraBackend auroraBackend) { implicitDeviceSynchronizationSupported = true; requiredFeatures.push_back(feature); } +#if defined(WEBGPU_DAWN) && defined(_WIN32) + if (g_config.xrInterop && g_backendType == wgpu::BackendType::D3D12 && + feature == wgpu::FeatureName::SharedTextureMemoryD3D12Resource) { + requiredFeatures.push_back(feature); + } +#endif } if (!implicitDeviceSynchronizationSupported) { Log.warn( diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 7f811fd..99a0b8a 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -105,6 +105,13 @@ set(MKW_TRANSLATED_COMPILE_JOBS 0 CACHE STRING Scheduling only - never affects output bytes, so it is deliberately outside the canonical flag fingerprint.") set(MKW_CPPWINRT_INCLUDE_DIR "" CACHE PATH "Optional self-contained C++/WinRT include directory") +# OpenXR is compiled into supported desktop builds, but remains opt-in at +# runtime through [vr].enabled. Keeping the loader available in every package +# lets one binary fall back to desktop mode when no active runtime is present. +option(MKW_ENABLE_OPENXR "Build OpenXR VR support" ON) +set(MKW_OPENXR_SDK_DIR "" CACHE PATH + "Optional OpenXR-SDK source tree; when empty, use a package or fetch the pinned SDK") + # Precompiled aurora + third-party package (see launcher/Prepare-NativePrebuilt.ps1). # Empty - the default - keeps the from-source behaviour every developer build # uses. When set, aurora-main, its extern/ tree and the vendored Crypto++ are @@ -222,6 +229,46 @@ if(MKW_PROJECT_COMPILE_DEFINITIONS) add_compile_definitions(${MKW_PROJECT_COMPILE_DEFINITIONS}) endif() +set(MKW_OPENXR_TARGET "") +if(MKW_ENABLE_OPENXR) + if(NOT WIN32 AND NOT CMAKE_SYSTEM_NAME STREQUAL "Linux") + message(WARNING "OpenXR is not wired for this platform; disabling MKW_ENABLE_OPENXR") + set(MKW_ENABLE_OPENXR OFF) + else() + find_package(OpenXR CONFIG QUIET) + if(NOT TARGET OpenXR::openxr_loader AND NOT TARGET openxr_loader) + include(FetchContent) + set(BUILD_API_LAYERS OFF CACHE BOOL "" FORCE) + set(BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(BUILD_CONFORMANCE_TESTS OFF CACHE BOOL "" FORCE) + set(BUILD_SDK_TESTS OFF CACHE BOOL "" FORCE) + set(DYNAMIC_LOADER OFF CACHE BOOL "" FORCE) + if(MKW_OPENXR_SDK_DIR) + if(NOT EXISTS "${MKW_OPENXR_SDK_DIR}/CMakeLists.txt") + message(FATAL_ERROR + "MKW_OPENXR_SDK_DIR does not contain OpenXR-SDK: ${MKW_OPENXR_SDK_DIR}") + endif() + add_subdirectory("${MKW_OPENXR_SDK_DIR}" + "${CMAKE_BINARY_DIR}/openxr-sdk" EXCLUDE_FROM_ALL) + else() + FetchContent_Declare(mkw_openxr + URL https://github.com/KhronosGroup/OpenXR-SDK/archive/refs/tags/release-1.1.61.tar.gz + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + EXCLUDE_FROM_ALL) + FetchContent_MakeAvailable(mkw_openxr) + endif() + endif() + if(TARGET OpenXR::openxr_loader) + set(MKW_OPENXR_TARGET OpenXR::openxr_loader) + elseif(TARGET openxr_loader) + set(MKW_OPENXR_TARGET openxr_loader) + else() + message(FATAL_ERROR "OpenXR was enabled but no loader target was provided") + endif() + add_compile_definitions(MKW_ENABLE_OPENXR=1) + endif() +endif() + # Runtime sources. CONFIGURE_DEPENDS costs one glob re-check per build (measured # at ~55 ms across the 100+ globs this build already re-verifies for SDL), and it # buys correctness: without it, a newly added src/*.cpp that is not referenced by diff --git a/runtime/cmake/PublicProducts.cmake b/runtime/cmake/PublicProducts.cmake index 580ca77..bdc9e0f 100644 --- a/runtime/cmake/PublicProducts.cmake +++ b/runtime/cmake/PublicProducts.cmake @@ -77,6 +77,9 @@ target_compile_definitions(mkw_runtime_common PRIVATE target_link_libraries(mkw_runtime_common PRIVATE aurora::gx aurora::pad aurora::si aurora::vi aurora::mtx) target_link_libraries(mkw_runtime_common PRIVATE mkw::pugixml mkw::toml11 mkw::cryptopp) +if(MKW_ENABLE_OPENXR) + target_link_libraries(mkw_runtime_common PRIVATE ${MKW_OPENXR_TARGET}) +endif() if(WIN32) target_link_libraries(mkw_runtime_common PRIVATE shell32 windowsapp) else() @@ -194,6 +197,11 @@ function(mkw_configure_product target) target_link_libraries(${target} PRIVATE aurora::gx aurora::pad aurora::si aurora::vi aurora::mtx) + if(MKW_ENABLE_OPENXR) + # mkw_runtime_common is consumed as raw object files, so its private + # loader dependency must also be present on each final product link. + target_link_libraries(${target} PRIVATE ${MKW_OPENXR_TARGET}) + endif() if(EXISTS "${MKW_AURORA_DIR}/cmake/AuroraCopyRuntimeDLLs.cmake") include("${MKW_AURORA_DIR}/cmake/AuroraCopyRuntimeDLLs.cmake") aurora_copy_runtime_dlls(${target}) diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h index c028a2b..a8e6fdf 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -44,6 +44,11 @@ struct RuntimeUserConfig { std::optional textureDumps; std::optional showFps; std::optional disabledPostProcessingPaths; + std::optional vrEnabled; + std::optional vrRequired; + std::optional vrRenderScale; + std::optional vrWorldUnitsPerMeter; + std::optional vrHudDistanceMeters; std::optional audioVolume; std::optional audioMusicVolume; std::optional audioSoundEffectsVolume; @@ -272,6 +277,14 @@ inline void EnsureConfigFile() { "# replacement would need. Both are read once, at startup.\n" "texture_replacements = false\n" "texture_dumps = false\n\n" + "[vr]\n" + "# OpenXR is opt-in. If required is false, startup failures fall back\n" + "# to the ordinary desktop renderer. These values are read at launch.\n" + "enabled = false\n" + "required = false\n" + "render_scale = 1.0\n" + "world_units_per_meter = 500.0\n" + "hud_distance_meters = 2.0\n\n" "[audio]\n" "volume = 1.0\n" "music_volume = 1.0\n" @@ -404,6 +417,21 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) { config.disabledPostProcessingPaths = *value & 0x10u; } + config.vrEnabled = FindConfigValue(document, "vr", "enabled"); + config.vrRequired = FindConfigValue(document, "vr", "required"); + if (auto value = FindConfigFloat(document, "vr", "render_scale"); + value && *value >= 0.25f && *value <= 2.0f) { + config.vrRenderScale = *value; + } + if (auto value = FindConfigFloat(document, "vr", "world_units_per_meter"); + value && *value >= 1.0f && *value <= 10000.0f) { + config.vrWorldUnitsPerMeter = *value; + } + if (auto value = FindConfigFloat(document, "vr", "hud_distance_meters"); + value && *value >= 0.25f && *value <= 10.0f) { + config.vrHudDistanceMeters = *value; + } + auto readVolume = [&](std::string_view key) -> std::optional { auto value = FindConfigFloat(document, "audio", key); return value && *value >= 0.0f && *value <= 1.0f ? value : std::nullopt; @@ -617,6 +645,11 @@ inline bool SetDisabledPostProcessingPaths(uint32_t value) { return WriteSetting("video", "disabled_post_processing_paths", formatted.str()); } +inline bool SetVrEnabled(bool value) { + Mutable().vrEnabled = value; + return WriteSetting("vr", "enabled", value ? "true" : "false"); +} + inline bool SetControllerButton(size_t index, std::string value) { if (index >= kControllerButtonKeys.size()) { return false; @@ -768,6 +801,26 @@ inline uint32_t DisabledPostProcessingPaths(uint32_t fallback = 0) { return Get().disabledPostProcessingPaths.value_or(fallback) & 0x10u; } +inline bool VrEnabled(bool fallback = false) { + return Get().vrEnabled.value_or(fallback); +} + +inline bool VrRequired(bool fallback = false) { + return Get().vrRequired.value_or(fallback); +} + +inline float VrRenderScale(float fallback = 1.0f) { + return std::clamp(Get().vrRenderScale.value_or(fallback), 0.25f, 2.0f); +} + +inline float VrWorldUnitsPerMeter(float fallback = 500.0f) { + return std::clamp(Get().vrWorldUnitsPerMeter.value_or(fallback), 1.0f, 10000.0f); +} + +inline float VrHudDistanceMeters(float fallback = 2.0f) { + return std::clamp(Get().vrHudDistanceMeters.value_or(fallback), 0.25f, 10.0f); +} + inline std::string GraphicsApi(std::string fallback = "auto") { return Get().graphicsApi.value_or(std::move(fallback)); } @@ -862,6 +915,12 @@ inline void LogLoadedConfig() { if (config.textureDumps) { std::cout << " texture_dumps=" << (*config.textureDumps ? "true" : "false"); } + if (config.vrEnabled) { + std::cout << " vr_enabled=" << (*config.vrEnabled ? "true" : "false"); + } + if (config.vrRequired) { + std::cout << " vr_required=" << (*config.vrRequired ? "true" : "false"); + } if (config.audioVolume) { std::cout << " audio_volume=" << *config.audioVolume; } diff --git a/runtime/include/vr/mkw_vr_policy.h b/runtime/include/vr/mkw_vr_policy.h new file mode 100644 index 0000000..4ff1903 --- /dev/null +++ b/runtime/include/vr/mkw_vr_policy.h @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +namespace mkw::vr { + +// Game-facing presentation policy. The OpenXR implementation owns session and +// swapchain state; this module only decides how Mario Kart Wii content should +// be presented once the integration layer publishes game observations. +enum class VRPresentationMode : uint8_t { + Desktop, + VirtualScreen, + ImmersiveRace, +}; + +enum class VRSceneMode : uint8_t { + Unknown, + FrontEnd, + Race, + Replay, + Awards, + Other, +}; + +enum class VRProjectionKind : uint8_t { + Unknown, + Perspective, + Orthographic, +}; + +enum class VRDrawPass : uint8_t { + Scene, + PostProcess, +}; + +enum class VRDrawClass : uint8_t { + Unknown, + PerspectiveWorld, + OrthographicHud, + PostProcess, +}; + +enum class VRDrawRoute : uint8_t { + DesktopPassthrough, + VirtualScreen, + StereoWorld, + HeadLockedHud, + PerEyePostProcess, + Unclassified, +}; + +// Instrumentation capabilities are explicit so a partial binding can never +// accidentally enable immersive rendering. In particular, a camera sample by +// itself is insufficient because orthographic UI would otherwise be rendered +// with the stereo world path. +enum MkwVRBinding : uint32_t { + MkwVRBindingNone = 0, + MkwVRBindingSceneState = 1u << 0, + MkwVRBindingRaceCamera = 1u << 1, + MkwVRBindingDrawClassification = 1u << 2, + MkwVRBindingPostProcess = 1u << 3, + MkwVRBindingCulling = 1u << 4, +}; + +inline constexpr uint32_t kMkwVRRequiredImmersiveBindings = + MkwVRBindingSceneState | MkwVRBindingRaceCamera | MkwVRBindingDrawClassification; + +struct MkwVRPolicyConfig { + bool enabled = false; + bool immersive_races = true; + float world_units_per_meter = 500.0f; + float hud_distance_meters = 2.0f; + float hud_scale = 1.0f; +}; + +struct MkwVRSceneObservation { + VRSceneMode mode = VRSceneMode::Unknown; + uint32_t local_player_count = 0; + uint64_t guest_frame_index = 0; +}; + +struct MkwVRCameraObservation { + // Mario Kart and NW4R expose affine view matrices as row-major 3x4 Mtx + // values. The integration layer is responsible for reading/converting the + // guest value; this policy never assumes a guest object layout. + std::array view_from_world{}; + uint32_t guest_camera_address = 0; + uint64_t guest_frame_index = 0; + bool valid = false; +}; + +struct MkwVRDrawObservation { + VRProjectionKind projection = VRProjectionKind::Unknown; + VRDrawPass pass = VRDrawPass::Scene; +}; + +struct MkwVRPolicySnapshot { + VRPresentationMode presentation = VRPresentationMode::Desktop; + MkwVRPolicyConfig config{}; + MkwVRSceneObservation scene{}; + MkwVRCameraObservation camera{}; + uint32_t available_bindings = MkwVRBindingNone; + bool session_active = false; +}; + +// All policy functions are thread-safe. Publishing functions are intended for +// translated-game instrumentation on the guest thread; the renderer may take a +// snapshot without retaining references to mutable policy state. +void MkwVRPolicyReset() noexcept; +void MkwVRPolicyConfigure(const MkwVRPolicyConfig& config) noexcept; +void MkwVRPolicySetSessionActive(bool active) noexcept; +void MkwVRPolicySetAvailableBindings(uint32_t bindings) noexcept; +void MkwVRPolicyPublishScene(const MkwVRSceneObservation& scene) noexcept; +void MkwVRPolicyPublishRaceCamera(const MkwVRCameraObservation& camera) noexcept; +void MkwVRPolicyInvalidateRaceCamera() noexcept; +MkwVRPolicySnapshot MkwVRPolicyGetSnapshot() noexcept; + +// This classifier is deliberately structural rather than heuristic: future +// hooks report whether the active projection is perspective/orthographic and +// whether execution is inside a post-processing boundary. No matrix-value or +// guest-address guessing occurs here. +VRDrawClass MkwVRPolicyClassifyDraw(const MkwVRDrawObservation& draw) noexcept; +VRDrawRoute MkwVRPolicyRouteDraw(VRDrawClass draw_class, + const MkwVRPolicySnapshot& snapshot) noexcept; + +enum class MkwVRHookCapability : uint8_t { + SceneState, + RaceCamera, + DrawClassification, + PostProcess, + Culling, +}; + +// Validated PAL RMCP01 symbols from projects/mkwii/MAP.txt. These are future +// instrumentation candidates, not registered native replacements: the current +// PPC_NATIVE_OVERRIDE mechanism replaces a translated function outright and +// cannot safely observe it before/after its original body. +struct MkwVRHookPoint { + uint32_t address; + const char* symbol; + MkwVRHookCapability capability; + const char* purpose; +}; + +const MkwVRHookPoint* MkwVRPolicyHookPoints(std::size_t* count) noexcept; + +} // namespace mkw::vr diff --git a/runtime/include/vr/openxr_config.h b/runtime/include/vr/openxr_config.h new file mode 100644 index 0000000..fd5e455 --- /dev/null +++ b/runtime/include/vr/openxr_config.h @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +#include + +namespace mkw::vr { + +// Kept independent from RuntimeUserConfig so the OpenXR core can be initialized +// before Aurora chooses a graphics adapter. The application-facing TOML values +// should be translated into this structure by the runtime integration layer. +struct OpenXRConfig { + std::string application_name = "WiiCompiled"; + uint32_t application_version = 1; + std::string engine_name = "Aurora"; + uint32_t engine_version = 1; + + // OpenXR 1.0 is sufficient for the lifecycle implemented by this class and + // remains compatible with desktop runtimes that do not advertise 1.1 yet. + XrVersion api_version = XR_API_VERSION_1_0; + XrFormFactor form_factor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY; + XrViewConfigurationType view_configuration = + XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; + + // LOCAL is the natural default for a seated racing game. STAGE can be + // requested by the integration layer and falls back to LOCAL when absent. + XrReferenceSpaceType reference_space = XR_REFERENCE_SPACE_TYPE_LOCAL; + XrEnvironmentBlendMode preferred_blend_mode = + XR_ENVIRONMENT_BLEND_MODE_OPAQUE; + + // Applied to the runtime-recommended dimensions and clamped to the + // runtime-advertised maximum for each view. + float resolution_scale = 1.0f; + + // The graphics backend must put its binding extension in required_extensions + // (for example XR_KHR_D3D12_enable or XR_KHR_vulkan_enable2). Optional + // extensions/layers are enabled only when the active runtime advertises them. + std::vector required_extensions; + std::vector optional_extensions; + std::vector required_api_layers; + std::vector optional_api_layers; + + // Destruction never waits indefinitely for a runtime to acknowledge an exit. + uint32_t shutdown_timeout_ms = 500; +}; + +} // namespace mkw::vr diff --git a/runtime/include/vr/openxr_runtime.h b/runtime/include/vr/openxr_runtime.h new file mode 100644 index 0000000..4aa42e1 --- /dev/null +++ b/runtime/include/vr/openxr_runtime.h @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "vr/openxr_config.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace mkw::vr { + +inline constexpr uint32_t kOpenXREyeCount = 2; + +enum class OpenXRLogLevel { + Info, + Warning, + Error, +}; + +using OpenXRLogCallback = + std::function; + +struct OpenXRError { + XrResult result = XR_SUCCESS; + std::string operation; + std::string message; + + explicit operator bool() const { + return XR_FAILED(result) || !message.empty(); + } +}; + +struct OpenXRViewConfiguration { + XrViewConfigurationView properties{XR_TYPE_VIEW_CONFIGURATION_VIEW}; + uint32_t render_width = 0; + uint32_t render_height = 0; +}; + +struct OpenXRRuntimeInfo { + std::string runtime_name; + XrVersion runtime_version = 0; + std::string system_name; + uint32_t vendor_id = 0; + uint32_t max_layer_count = 0; + bool supports_orientation_tracking = false; + bool supports_position_tracking = false; +}; + +struct OpenXRReferenceSpaceChange { + uint64_t serial = 0; + XrReferenceSpaceType type = XR_REFERENCE_SPACE_TYPE_LOCAL; + XrTime change_time = 0; + bool pose_in_previous_space_valid = false; + XrPosef pose_in_previous_space{{0.0f, 0.0f, 0.0f, 1.0f}, + {0.0f, 0.0f, 0.0f}}; +}; + +// A frame token is produced by WaitFrame and must be passed back to the other +// frame functions. This prevents an old pose/timestamp from being paired with a +// newer compositor frame. +struct OpenXRFrame { + uint64_t serial = 0; + XrTime predicted_display_time = 0; + XrDuration predicted_display_period = 0; + bool should_render = false; + bool views_valid = false; + XrViewStateFlags view_state_flags = 0; + std::array views{}; +}; + +enum class OpenXREventStatus { + Continue, + ExitRequested, + Error, +}; + +enum class OpenXRFrameStatus { + Ready, + SessionNotRunning, + ExitRequested, + Error, +}; + +// Owns the backend-neutral OpenXR object graph and session state machine. +// +// A graphics backend performs its requirements/device work after Initialize(), +// then passes its XrGraphicsBinding* structure to CreateSession(). Swapchains are +// intentionally not owned here: D3D12 and Vulkan need different image wrapping +// and synchronization strategies. +// +// OpenXRRuntime is not internally synchronized. PollEvents and all frame calls +// must be serialized by one XR-owner thread. Read-only handles may be handed to +// the graphics integration only while the corresponding object is alive. +class OpenXRRuntime final { +public: + explicit OpenXRRuntime(OpenXRLogCallback logger = {}); + ~OpenXRRuntime(); + + OpenXRRuntime(const OpenXRRuntime&) = delete; + OpenXRRuntime& operator=(const OpenXRRuntime&) = delete; + OpenXRRuntime(OpenXRRuntime&&) = delete; + OpenXRRuntime& operator=(OpenXRRuntime&&) = delete; + + // Creates the instance, resolves the HMD system, and enumerates the stereo + // view configuration. Returns false without terminating the application; + // the caller should continue in non-VR mode. + bool Initialize(const OpenXRConfig& config); + + // graphics_binding is the address of an XrGraphicsBinding* structure and is + // forwarded through XrSessionCreateInfo::next. It must remain valid only for + // the duration of this call. + bool CreateSession(const void* graphics_binding); + + // Loads an extension entry point from this instance. Backends use this to + // query graphics requirements before Aurora creates/selects its device. + bool GetInstanceProcAddress(const char* name, PFN_xrVoidFunction* function); + + template + bool LoadFunction(const char* name, Function* function) { + static_assert(std::is_pointer_v, + "OpenXR PFN typedefs must be pointer types"); + return GetInstanceProcAddress( + name, reinterpret_cast(function)); + } + + // Requests an orderly exit when running, processes STOPPING for a bounded + // period, then releases spaces and the session. The instance/system remain + // available so a backend session can be recreated. + void DestroySession(); + + // Releases every owned object. Safe to call repeatedly. + void Shutdown(); + + // Polls every currently queued event and owns xrBeginSession/xrEndSession + // transitions for READY/STOPPING. EXITING and LOSS_PENDING are surfaced to + // the application rather than terminating it here. + OpenXREventStatus PollEvents(); + bool RequestExitSession(); + + // Explicit frame protocol. A successful WaitFrame must be followed by + // BeginFrame and EndFrame using the same token. LocateViews is optional when + // should_render is false; it is otherwise normally called after BeginFrame. + OpenXRFrameStatus WaitFrame(OpenXRFrame& frame); + bool BeginFrame(const OpenXRFrame& frame); + bool LocateViews(OpenXRFrame& frame); + bool EndFrame( + const OpenXRFrame& frame, + const XrCompositionLayerBaseHeader* const* layers, + uint32_t layer_count); + bool EndFrame( + const OpenXRFrame& frame, + const std::vector& layers); + bool EndFrameWithoutLayers(const OpenXRFrame& frame); + + // Recreates the application reference space with a caller-provided offset. + // This is the application-side recenter primitive; call only between frames. + bool ResetAppSpace(const XrPosef& pose_in_reference_space); + + bool IsInitialized() const { return m_instance != XR_NULL_HANDLE; } + bool HasSession() const { return m_session != XR_NULL_HANDLE; } + bool IsSessionRunning() const { return m_session_running; } + bool IsSessionFocused() const { + return m_session_state == XR_SESSION_STATE_FOCUSED; + } + bool IsSessionVisible() const { + return m_session_state == XR_SESSION_STATE_VISIBLE || + m_session_state == XR_SESSION_STATE_FOCUSED; + } + bool ShouldExit() const { return m_exit_requested || m_instance_loss_pending; } + + XrInstance Instance() const { return m_instance; } + XrSystemId SystemId() const { return m_system_id; } + XrSession Session() const { return m_session; } + XrSpace AppSpace() const { return m_app_space; } + XrSpace ViewSpace() const { return m_view_space; } + XrSessionState SessionState() const { return m_session_state; } + XrReferenceSpaceType AppSpaceType() const { return m_app_space_type; } + XrEnvironmentBlendMode EnvironmentBlendMode() const { return m_blend_mode; } + + const OpenXRConfig& Config() const { return m_config; } + const OpenXRRuntimeInfo& RuntimeInfo() const { return m_runtime_info; } + const std::array& ViewConfiguration() const { + return m_view_configuration; + } + const std::vector& EnabledExtensions() const { + return m_enabled_extensions; + } + const std::vector& SwapchainFormats() const { return m_swapchain_formats; } + const OpenXRReferenceSpaceChange& LastReferenceSpaceChange() const { + return m_reference_space_change; + } + const OpenXRError& LastError() const { return m_last_error; } + +private: + enum class FramePhase { + Idle, + Waited, + Begun, + }; + + bool EnumerateInstanceCapabilities(); + bool CreateInstance(); + bool InitializeSystem(); + bool EnumerateViewConfiguration(); + bool SelectEnvironmentBlendMode(); + bool CreateReferenceSpaces(); + bool EnumerateSwapchainFormats(); + bool HandleSessionStateChanged(const XrEventDataSessionStateChanged& event); + bool IsFrameTokenCurrent(const OpenXRFrame& frame, FramePhase expected) const; + void DestroyReferenceSpaces(); + void ResetSessionState(); + void ResetInstanceState(); + + bool Check(XrResult result, std::string_view operation); + bool Fail(XrResult result, std::string_view operation, std::string_view detail); + void ClearError(); + void Log(OpenXRLogLevel level, std::string_view message) const noexcept; + std::string ResultString(XrResult result) const; + + OpenXRLogCallback m_logger; + OpenXRConfig m_config; + OpenXRError m_last_error; + + XrInstance m_instance = XR_NULL_HANDLE; + XrSystemId m_system_id = XR_NULL_SYSTEM_ID; + XrSession m_session = XR_NULL_HANDLE; + XrSpace m_app_space = XR_NULL_HANDLE; + XrSpace m_view_space = XR_NULL_HANDLE; + + XrSessionState m_session_state = XR_SESSION_STATE_UNKNOWN; + XrReferenceSpaceType m_app_space_type = XR_REFERENCE_SPACE_TYPE_LOCAL; + XrEnvironmentBlendMode m_blend_mode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE; + bool m_session_running = false; + bool m_exit_requested = false; + bool m_instance_loss_pending = false; + bool m_shutting_down_session = false; + + FramePhase m_frame_phase = FramePhase::Idle; + uint64_t m_next_frame_serial = 1; + uint64_t m_active_frame_serial = 0; + XrTime m_active_frame_display_time = 0; + + OpenXRRuntimeInfo m_runtime_info; + std::array m_view_configuration{}; + std::vector m_available_extensions; + std::vector m_available_api_layers; + std::vector m_enabled_extensions; + std::vector m_enabled_api_layers; + std::vector m_supported_reference_spaces; + std::vector m_supported_blend_modes; + std::vector m_swapchain_formats; + OpenXRReferenceSpaceChange m_reference_space_change; +}; + +} // namespace mkw::vr diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 1f3895e..de34b83 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -98,6 +98,7 @@ int g_displayMode = [] { bool g_skipUnreadyPipelines = RuntimeConfigFile::SkipUnreadyPipelines(true); bool g_disableCopyFilter = RuntimeConfigFile::DisableCopyFilter(true); bool g_showFps = RuntimeConfigFile::ShowFps(true); +bool g_vrEnabled = RuntimeConfigFile::VrEnabled(false); uint32_t g_disabledPostProcessingPaths = RuntimeConfigFile::DisabledPostProcessingPaths(0); std::array g_configuredControllerIndices = [] { std::array indices{}; @@ -645,6 +646,10 @@ void DrawGraphicsSettings() { } ImGui::Separator(); ImGui::Text("Graphics API: %s", GraphicsApiDisplayName()); + if (ImGui::Checkbox("Enable OpenXR VR", &g_vrEnabled)) { + RuntimeConfigFile::SetVrEnabled(g_vrEnabled); + } + ImGui::TextDisabled("OpenXR mode changes take effect after restarting the game."); } void DrawFpsOverlay() { diff --git a/runtime/src/vr/mkw_vr_policy.cpp b/runtime/src/vr/mkw_vr_policy.cpp new file mode 100644 index 0000000..15a04c5 --- /dev/null +++ b/runtime/src/vr/mkw_vr_policy.cpp @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "vr/mkw_vr_policy.h" + +#include +#include +#include + +namespace mkw::vr { +namespace { + +constexpr MkwVRPolicyConfig kDefaultConfig{}; + +struct PolicyState { + MkwVRPolicyConfig config = kDefaultConfig; + MkwVRSceneObservation scene{}; + MkwVRCameraObservation camera{}; + uint32_t available_bindings = MkwVRBindingNone; + bool session_active = false; +}; + +std::mutex g_policy_mutex; +PolicyState g_policy; + +bool IsFinitePositive(float value) noexcept { + return std::isfinite(value) && value > 0.0f; +} + +MkwVRPolicyConfig SanitizeConfig(const MkwVRPolicyConfig& config) noexcept { + MkwVRPolicyConfig sanitized = config; + if (!IsFinitePositive(sanitized.world_units_per_meter)) { + sanitized.world_units_per_meter = kDefaultConfig.world_units_per_meter; + } + if (!IsFinitePositive(sanitized.hud_distance_meters)) { + sanitized.hud_distance_meters = kDefaultConfig.hud_distance_meters; + } + if (!IsFinitePositive(sanitized.hud_scale)) { + sanitized.hud_scale = kDefaultConfig.hud_scale; + } + return sanitized; +} + +bool IsFiniteCamera(const MkwVRCameraObservation& camera) noexcept { + return camera.valid && + std::all_of(camera.view_from_world.begin(), camera.view_from_world.end(), + [](float value) { return std::isfinite(value); }); +} + +VRPresentationMode SelectPresentation(const PolicyState& state) noexcept { + if (!state.config.enabled || !state.session_active) { + return VRPresentationMode::Desktop; + } + + // A virtual screen is the fail-safe for menus, replays, split-screen, and + // any incomplete instrumentation. It preserves the unmodified render path. + if ((state.available_bindings & kMkwVRRequiredImmersiveBindings) != + kMkwVRRequiredImmersiveBindings || + !state.config.immersive_races || state.scene.mode != VRSceneMode::Race || + state.scene.local_player_count != 1 || !IsFiniteCamera(state.camera)) { + return VRPresentationMode::VirtualScreen; + } + + return VRPresentationMode::ImmersiveRace; +} + +constexpr MkwVRHookPoint kHookPoints[] = { + {0x80562B34u, "ScnMgr::UpdateCameras", MkwVRHookCapability::SceneState, + "Observe the active scene camera update boundary."}, + {0x80562BF0u, "GameCamera::GetViewMatrix", MkwVRHookCapability::RaceCamera, + "Observe the common camera view matrix consumed by ScnMgr."}, + {0x805B2110u, "ScnMgrRace::UpdateCameras", MkwVRHookCapability::SceneState, + "Identify a race camera update without relying on a scene object layout."}, + {0x805B1CD8u, "ScnMgrRace::Draw", MkwVRHookCapability::DrawClassification, + "Bracket race-scene drawing for perspective-world classification."}, + {0x805A21D0u, "RaceCamera::Update", MkwVRHookCapability::RaceCamera, + "Observe the stable post-update race camera for the current guest frame."}, + {0x805A6C58u, "RaceCamera::GetViewMtx", MkwVRHookCapability::RaceCamera, + "Copy the returned 3x4 view matrix through a future translated observer."}, + {0x805A906Cu, "RaceCameraMgr::ApplyShaking", MkwVRHookCapability::RaceCamera, + "Separate game camera shake from headset motion when a comfort policy is added."}, + {0x80565DA0u, "GameScreen::SetAndLoadOrthoProj", + MkwVRHookCapability::DrawClassification, + "Mark orthographic GameScreen work as HUD or flat-screen content."}, + {0x80566020u, "GameScreen::SetAndLoadProjection", + MkwVRHookCapability::DrawClassification, + "Observe GameScreen projection changes used by race and menu UI."}, + {0x805661E8u, "GameScreen::SetProjection", MkwVRHookCapability::DrawClassification, + "Observe projection setup without assuming GameScreen member offsets."}, + {0x800640D0u, "nw4r::g3d::G3DState::SetCameraProjMtx", + MkwVRHookCapability::DrawClassification, + "Provide a renderer-level perspective/orthographic projection boundary."}, + {0x8006AA80u, "nw4r::g3d::Camera::GXSetProjection", + MkwVRHookCapability::DrawClassification, + "Observe the final NW4R camera projection submitted to GX."}, + {0x8054F41Cu, "GameScreenEffectsMgr::Draw", MkwVRHookCapability::PostProcess, + "Bracket screen effects that must be evaluated per eye or composed flat."}, + {0x8054F7A4u, "GameScreenEffectsMgr::DrawCourseFilterEffects", + MkwVRHookCapability::PostProcess, + "Classify full-screen course filters as post-processing."}, + {0x8054F8E0u, "GameScreenEffectsMgr::CopyEFBToLensFlareTextures", + MkwVRHookCapability::PostProcess, + "Track EFB-dependent lens-flare capture separately from world geometry."}, + {0x802278D0u, "EGG::Frustum::CalcMtxPerspective", MkwVRHookCapability::Culling, + "Future culling-frustum expansion point for head movement beyond the base camera."}, + {0x80228180u, "EGG::Frustum::CopyToG3D", MkwVRHookCapability::Culling, + "Observe the frustum handed to NW4R without guessing EGG::Frustum fields."}, +}; + +} // namespace + +void MkwVRPolicyReset() noexcept { + std::lock_guard lock(g_policy_mutex); + g_policy = PolicyState{}; +} + +void MkwVRPolicyConfigure(const MkwVRPolicyConfig& config) noexcept { + std::lock_guard lock(g_policy_mutex); + g_policy.config = SanitizeConfig(config); +} + +void MkwVRPolicySetSessionActive(bool active) noexcept { + std::lock_guard lock(g_policy_mutex); + g_policy.session_active = active; +} + +void MkwVRPolicySetAvailableBindings(uint32_t bindings) noexcept { + std::lock_guard lock(g_policy_mutex); + g_policy.available_bindings = bindings; +} + +void MkwVRPolicyPublishScene(const MkwVRSceneObservation& scene) noexcept { + std::lock_guard lock(g_policy_mutex); + if (scene.mode != VRSceneMode::Race || g_policy.scene.mode != VRSceneMode::Race) { + // Never carry a camera sample across a menu/replay-to-race transition. + // A fresh RaceCamera observation must arrive before immersive mode can + // become active again. + g_policy.camera.valid = false; + } + g_policy.scene = scene; +} + +void MkwVRPolicyPublishRaceCamera(const MkwVRCameraObservation& camera) noexcept { + std::lock_guard lock(g_policy_mutex); + g_policy.camera = camera; + g_policy.camera.valid = IsFiniteCamera(camera); +} + +void MkwVRPolicyInvalidateRaceCamera() noexcept { + std::lock_guard lock(g_policy_mutex); + g_policy.camera.valid = false; +} + +MkwVRPolicySnapshot MkwVRPolicyGetSnapshot() noexcept { + std::lock_guard lock(g_policy_mutex); + MkwVRPolicySnapshot snapshot; + snapshot.presentation = SelectPresentation(g_policy); + snapshot.config = g_policy.config; + snapshot.scene = g_policy.scene; + snapshot.camera = g_policy.camera; + snapshot.available_bindings = g_policy.available_bindings; + snapshot.session_active = g_policy.session_active; + return snapshot; +} + +VRDrawClass MkwVRPolicyClassifyDraw(const MkwVRDrawObservation& draw) noexcept { + if (draw.pass == VRDrawPass::PostProcess) { + return VRDrawClass::PostProcess; + } + switch (draw.projection) { + case VRProjectionKind::Perspective: + return VRDrawClass::PerspectiveWorld; + case VRProjectionKind::Orthographic: + return VRDrawClass::OrthographicHud; + case VRProjectionKind::Unknown: + return VRDrawClass::Unknown; + } + return VRDrawClass::Unknown; +} + +VRDrawRoute MkwVRPolicyRouteDraw(VRDrawClass draw_class, + const MkwVRPolicySnapshot& snapshot) noexcept { + switch (snapshot.presentation) { + case VRPresentationMode::Desktop: + return VRDrawRoute::DesktopPassthrough; + case VRPresentationMode::VirtualScreen: + return VRDrawRoute::VirtualScreen; + case VRPresentationMode::ImmersiveRace: + break; + } + + switch (draw_class) { + case VRDrawClass::PerspectiveWorld: + return VRDrawRoute::StereoWorld; + case VRDrawClass::OrthographicHud: + return VRDrawRoute::HeadLockedHud; + case VRDrawClass::PostProcess: + return VRDrawRoute::PerEyePostProcess; + case VRDrawClass::Unknown: + return VRDrawRoute::Unclassified; + } + return VRDrawRoute::Unclassified; +} + +const MkwVRHookPoint* MkwVRPolicyHookPoints(std::size_t* count) noexcept { + if (count != nullptr) { + *count = sizeof(kHookPoints) / sizeof(kHookPoints[0]); + } + return kHookPoints; +} + +} // namespace mkw::vr diff --git a/runtime/src/vr/openxr_runtime.cpp b/runtime/src/vr/openxr_runtime.cpp new file mode 100644 index 0000000..56e023c --- /dev/null +++ b/runtime/src/vr/openxr_runtime.cpp @@ -0,0 +1,889 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "vr/openxr_runtime.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mkw::vr { +namespace { + +template +bool Contains(const std::vector& values, const T& value) { + return std::find(values.begin(), values.end(), value) != values.end(); +} + +void CopyOpenXRName(char* destination, size_t destination_size, std::string_view source) { + if (destination_size == 0) { + return; + } + const size_t length = std::min(destination_size - 1, source.size()); + std::memcpy(destination, source.data(), length); + destination[length] = '\0'; +} + +XrPosef IdentityPose() { + return {{0.0f, 0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 0.0f}}; +} + +uint32_t ScaledDimension(uint32_t recommended, uint32_t maximum, float scale) { + const double scaled = std::round(static_cast(recommended) * + static_cast(scale)); + const double clamped = std::clamp( + scaled, 1.0, static_cast(std::max(maximum, 1u))); + return static_cast(clamped); +} + +const char* SessionStateName(XrSessionState state) { + switch (state) { + case XR_SESSION_STATE_UNKNOWN: + return "UNKNOWN"; + case XR_SESSION_STATE_IDLE: + return "IDLE"; + case XR_SESSION_STATE_READY: + return "READY"; + case XR_SESSION_STATE_SYNCHRONIZED: + return "SYNCHRONIZED"; + case XR_SESSION_STATE_VISIBLE: + return "VISIBLE"; + case XR_SESSION_STATE_FOCUSED: + return "FOCUSED"; + case XR_SESSION_STATE_STOPPING: + return "STOPPING"; + case XR_SESSION_STATE_LOSS_PENDING: + return "LOSS_PENDING"; + case XR_SESSION_STATE_EXITING: + return "EXITING"; + default: + return "INVALID"; + } +} + +} // namespace + +OpenXRRuntime::OpenXRRuntime(OpenXRLogCallback logger) + : m_logger(std::move(logger)) {} + +OpenXRRuntime::~OpenXRRuntime() { + Shutdown(); +} + +bool OpenXRRuntime::Initialize(const OpenXRConfig& config) { + ClearError(); + if (IsInitialized()) { + return Fail(XR_ERROR_CALL_ORDER_INVALID, "Initialize", + "OpenXR is already initialized"); + } + if (config.application_name.empty()) { + return Fail(XR_ERROR_VALIDATION_FAILURE, "Initialize", + "application_name must not be empty"); + } + if (!std::isfinite(config.resolution_scale) || config.resolution_scale <= 0.0f) { + return Fail(XR_ERROR_VALIDATION_FAILURE, "Initialize", + "resolution_scale must be finite and greater than zero"); + } + + m_config = config; + if (!EnumerateInstanceCapabilities() || !CreateInstance() || + !InitializeSystem() || !EnumerateViewConfiguration() || + !SelectEnvironmentBlendMode()) { + if (m_instance != XR_NULL_HANDLE) { + xrDestroyInstance(m_instance); + } + ResetInstanceState(); + return false; + } + + std::ostringstream message; + message << "OpenXR initialized: runtime '" << m_runtime_info.runtime_name + << "', system '" << m_runtime_info.system_name << "'"; + Log(OpenXRLogLevel::Info, message.str()); + return true; +} + +bool OpenXRRuntime::EnumerateInstanceCapabilities() { + uint32_t extension_count = 0; + if (!Check(xrEnumerateInstanceExtensionProperties( + nullptr, 0, &extension_count, nullptr), + "xrEnumerateInstanceExtensionProperties(count)")) { + return false; + } + + std::vector extension_properties( + extension_count, XrExtensionProperties{XR_TYPE_EXTENSION_PROPERTIES}); + if (extension_count != 0 && + !Check(xrEnumerateInstanceExtensionProperties( + nullptr, extension_count, &extension_count, + extension_properties.data()), + "xrEnumerateInstanceExtensionProperties")) { + return false; + } + + m_available_extensions.clear(); + m_available_extensions.reserve(extension_count); + for (const XrExtensionProperties& extension : extension_properties) { + m_available_extensions.emplace_back(extension.extensionName); + } + + uint32_t layer_count = 0; + if (!Check(xrEnumerateApiLayerProperties(0, &layer_count, nullptr), + "xrEnumerateApiLayerProperties(count)")) { + return false; + } + std::vector layer_properties( + layer_count, XrApiLayerProperties{XR_TYPE_API_LAYER_PROPERTIES}); + if (layer_count != 0 && + !Check(xrEnumerateApiLayerProperties( + layer_count, &layer_count, layer_properties.data()), + "xrEnumerateApiLayerProperties")) { + return false; + } + + m_available_api_layers.clear(); + m_available_api_layers.reserve(layer_count); + for (const XrApiLayerProperties& layer : layer_properties) { + m_available_api_layers.emplace_back(layer.layerName); + } + return true; +} + +bool OpenXRRuntime::CreateInstance() { + m_enabled_extensions.clear(); + for (const std::string& extension : m_config.required_extensions) { + if (!Contains(m_available_extensions, extension)) { + return Fail(XR_ERROR_EXTENSION_NOT_PRESENT, "xrCreateInstance", + "required extension is unavailable: " + extension); + } + if (!Contains(m_enabled_extensions, extension)) { + m_enabled_extensions.push_back(extension); + } + } + for (const std::string& extension : m_config.optional_extensions) { + if (Contains(m_available_extensions, extension) && + !Contains(m_enabled_extensions, extension)) { + m_enabled_extensions.push_back(extension); + } + } + + m_enabled_api_layers.clear(); + for (const std::string& layer : m_config.required_api_layers) { + if (!Contains(m_available_api_layers, layer)) { + return Fail(XR_ERROR_API_LAYER_NOT_PRESENT, "xrCreateInstance", + "required API layer is unavailable: " + layer); + } + if (!Contains(m_enabled_api_layers, layer)) { + m_enabled_api_layers.push_back(layer); + } + } + for (const std::string& layer : m_config.optional_api_layers) { + if (Contains(m_available_api_layers, layer) && + !Contains(m_enabled_api_layers, layer)) { + m_enabled_api_layers.push_back(layer); + } + } + + std::vector extension_names; + extension_names.reserve(m_enabled_extensions.size()); + for (const std::string& extension : m_enabled_extensions) { + extension_names.push_back(extension.c_str()); + } + std::vector layer_names; + layer_names.reserve(m_enabled_api_layers.size()); + for (const std::string& layer : m_enabled_api_layers) { + layer_names.push_back(layer.c_str()); + } + + XrInstanceCreateInfo create_info{XR_TYPE_INSTANCE_CREATE_INFO}; + CopyOpenXRName(create_info.applicationInfo.applicationName, + XR_MAX_APPLICATION_NAME_SIZE, m_config.application_name); + create_info.applicationInfo.applicationVersion = m_config.application_version; + CopyOpenXRName(create_info.applicationInfo.engineName, + XR_MAX_ENGINE_NAME_SIZE, m_config.engine_name); + create_info.applicationInfo.engineVersion = m_config.engine_version; + create_info.applicationInfo.apiVersion = m_config.api_version; + create_info.enabledExtensionCount = + static_cast(extension_names.size()); + create_info.enabledExtensionNames = extension_names.data(); + create_info.enabledApiLayerCount = static_cast(layer_names.size()); + create_info.enabledApiLayerNames = layer_names.data(); + + if (!Check(xrCreateInstance(&create_info, &m_instance), "xrCreateInstance")) { + return false; + } + + XrInstanceProperties instance_properties{XR_TYPE_INSTANCE_PROPERTIES}; + if (!Check(xrGetInstanceProperties(m_instance, &instance_properties), + "xrGetInstanceProperties")) { + return false; + } + m_runtime_info.runtime_name = instance_properties.runtimeName; + m_runtime_info.runtime_version = instance_properties.runtimeVersion; + return true; +} + +bool OpenXRRuntime::InitializeSystem() { + XrSystemGetInfo get_info{XR_TYPE_SYSTEM_GET_INFO}; + get_info.formFactor = m_config.form_factor; + if (!Check(xrGetSystem(m_instance, &get_info, &m_system_id), "xrGetSystem")) { + return false; + } + + XrSystemProperties properties{XR_TYPE_SYSTEM_PROPERTIES}; + if (!Check(xrGetSystemProperties(m_instance, m_system_id, &properties), + "xrGetSystemProperties")) { + return false; + } + m_runtime_info.system_name = properties.systemName; + m_runtime_info.vendor_id = properties.vendorId; + m_runtime_info.max_layer_count = properties.graphicsProperties.maxLayerCount; + m_runtime_info.supports_orientation_tracking = + properties.trackingProperties.orientationTracking == XR_TRUE; + m_runtime_info.supports_position_tracking = + properties.trackingProperties.positionTracking == XR_TRUE; + return true; +} + +bool OpenXRRuntime::EnumerateViewConfiguration() { + uint32_t view_count = 0; + if (!Check(xrEnumerateViewConfigurationViews( + m_instance, m_system_id, m_config.view_configuration, + 0, &view_count, nullptr), + "xrEnumerateViewConfigurationViews(count)")) { + return false; + } + if (view_count != kOpenXREyeCount) { + std::ostringstream detail; + detail << "PRIMARY_STEREO must expose exactly " << kOpenXREyeCount + << " views, runtime returned " << view_count; + return Fail(XR_ERROR_VIEW_CONFIGURATION_TYPE_UNSUPPORTED, + "xrEnumerateViewConfigurationViews", detail.str()); + } + + std::array properties{}; + for (XrViewConfigurationView& property : properties) { + property.type = XR_TYPE_VIEW_CONFIGURATION_VIEW; + } + if (!Check(xrEnumerateViewConfigurationViews( + m_instance, m_system_id, m_config.view_configuration, + view_count, &view_count, properties.data()), + "xrEnumerateViewConfigurationViews")) { + return false; + } + + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + OpenXRViewConfiguration& destination = m_view_configuration[eye]; + destination.properties = properties[eye]; + destination.render_width = ScaledDimension( + properties[eye].recommendedImageRectWidth, + properties[eye].maxImageRectWidth, m_config.resolution_scale); + destination.render_height = ScaledDimension( + properties[eye].recommendedImageRectHeight, + properties[eye].maxImageRectHeight, m_config.resolution_scale); + } + return true; +} + +bool OpenXRRuntime::SelectEnvironmentBlendMode() { + uint32_t blend_mode_count = 0; + if (!Check(xrEnumerateEnvironmentBlendModes( + m_instance, m_system_id, m_config.view_configuration, + 0, &blend_mode_count, nullptr), + "xrEnumerateEnvironmentBlendModes(count)")) { + return false; + } + if (blend_mode_count == 0) { + return Fail(XR_ERROR_ENVIRONMENT_BLEND_MODE_UNSUPPORTED, + "xrEnumerateEnvironmentBlendModes", + "runtime returned no environment blend modes"); + } + + m_supported_blend_modes.resize(blend_mode_count); + if (!Check(xrEnumerateEnvironmentBlendModes( + m_instance, m_system_id, m_config.view_configuration, + blend_mode_count, &blend_mode_count, + m_supported_blend_modes.data()), + "xrEnumerateEnvironmentBlendModes")) { + return false; + } + m_supported_blend_modes.resize(blend_mode_count); + + if (Contains(m_supported_blend_modes, m_config.preferred_blend_mode)) { + m_blend_mode = m_config.preferred_blend_mode; + return true; + } + if (Contains(m_supported_blend_modes, XR_ENVIRONMENT_BLEND_MODE_OPAQUE)) { + m_blend_mode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE; + } else { + m_blend_mode = m_supported_blend_modes.front(); + } + Log(OpenXRLogLevel::Warning, + "preferred environment blend mode is unavailable; using runtime fallback"); + return true; +} + +bool OpenXRRuntime::CreateSession(const void* graphics_binding) { + ClearError(); + if (!IsInitialized()) { + return Fail(XR_ERROR_CALL_ORDER_INVALID, "CreateSession", + "Initialize must succeed first"); + } + if (HasSession()) { + return Fail(XR_ERROR_CALL_ORDER_INVALID, "CreateSession", + "a session already exists"); + } + if (graphics_binding == nullptr) { + return Fail(XR_ERROR_GRAPHICS_DEVICE_INVALID, "CreateSession", + "graphics binding must not be null"); + } + + XrSessionCreateInfo create_info{XR_TYPE_SESSION_CREATE_INFO}; + create_info.next = graphics_binding; + create_info.systemId = m_system_id; + if (!Check(xrCreateSession(m_instance, &create_info, &m_session), + "xrCreateSession")) { + m_session = XR_NULL_HANDLE; + return false; + } + + if (!CreateReferenceSpaces() || !EnumerateSwapchainFormats()) { + DestroyReferenceSpaces(); + xrDestroySession(m_session); + m_session = XR_NULL_HANDLE; + ResetSessionState(); + return false; + } + + m_session_state = XR_SESSION_STATE_UNKNOWN; + m_exit_requested = false; + m_instance_loss_pending = false; + Log(OpenXRLogLevel::Info, + "OpenXR session created; waiting for the runtime READY event"); + return true; +} + +bool OpenXRRuntime::GetInstanceProcAddress( + const char* name, PFN_xrVoidFunction* function) { + ClearError(); + if (!IsInitialized()) { + return Fail(XR_ERROR_CALL_ORDER_INVALID, "xrGetInstanceProcAddr", + "OpenXR is not initialized"); + } + if (name == nullptr || name[0] == '\0' || function == nullptr) { + return Fail(XR_ERROR_VALIDATION_FAILURE, "xrGetInstanceProcAddr", + "function name and output pointer must be valid"); + } + *function = nullptr; + return Check(xrGetInstanceProcAddr(m_instance, name, function), + "xrGetInstanceProcAddr"); +} + +bool OpenXRRuntime::CreateReferenceSpaces() { + uint32_t space_count = 0; + if (!Check(xrEnumerateReferenceSpaces(m_session, 0, &space_count, nullptr), + "xrEnumerateReferenceSpaces(count)")) { + return false; + } + m_supported_reference_spaces.resize(space_count); + if (space_count != 0 && + !Check(xrEnumerateReferenceSpaces( + m_session, space_count, &space_count, + m_supported_reference_spaces.data()), + "xrEnumerateReferenceSpaces")) { + return false; + } + m_supported_reference_spaces.resize(space_count); + + XrReferenceSpaceCreateInfo view_info{XR_TYPE_REFERENCE_SPACE_CREATE_INFO}; + view_info.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW; + view_info.poseInReferenceSpace = IdentityPose(); + if (!Check(xrCreateReferenceSpace(m_session, &view_info, &m_view_space), + "xrCreateReferenceSpace(VIEW)")) { + return false; + } + + m_app_space_type = m_config.reference_space; + if (!Contains(m_supported_reference_spaces, m_app_space_type)) { + if (Contains(m_supported_reference_spaces, XR_REFERENCE_SPACE_TYPE_LOCAL)) { + m_app_space_type = XR_REFERENCE_SPACE_TYPE_LOCAL; + Log(OpenXRLogLevel::Warning, + "requested reference space is unavailable; using LOCAL"); + } else if (Contains(m_supported_reference_spaces, + XR_REFERENCE_SPACE_TYPE_STAGE)) { + m_app_space_type = XR_REFERENCE_SPACE_TYPE_STAGE; + Log(OpenXRLogLevel::Warning, + "requested reference space is unavailable; using STAGE"); + } else { + return Fail(XR_ERROR_REFERENCE_SPACE_UNSUPPORTED, + "xrCreateReferenceSpace", + "runtime exposes neither requested, LOCAL, nor STAGE space"); + } + } + + XrReferenceSpaceCreateInfo app_info{XR_TYPE_REFERENCE_SPACE_CREATE_INFO}; + app_info.referenceSpaceType = m_app_space_type; + app_info.poseInReferenceSpace = IdentityPose(); + return Check(xrCreateReferenceSpace(m_session, &app_info, &m_app_space), + "xrCreateReferenceSpace(application)"); +} + +bool OpenXRRuntime::EnumerateSwapchainFormats() { + uint32_t format_count = 0; + if (!Check(xrEnumerateSwapchainFormats( + m_session, 0, &format_count, nullptr), + "xrEnumerateSwapchainFormats(count)")) { + return false; + } + if (format_count == 0) { + return Fail(XR_ERROR_SWAPCHAIN_FORMAT_UNSUPPORTED, + "xrEnumerateSwapchainFormats", + "runtime returned no swapchain formats"); + } + m_swapchain_formats.resize(format_count); + if (!Check(xrEnumerateSwapchainFormats( + m_session, format_count, &format_count, + m_swapchain_formats.data()), + "xrEnumerateSwapchainFormats")) { + return false; + } + m_swapchain_formats.resize(format_count); + return true; +} + +OpenXREventStatus OpenXRRuntime::PollEvents() { + if (!IsInitialized()) { + Fail(XR_ERROR_CALL_ORDER_INVALID, "PollEvents", + "OpenXR is not initialized"); + return OpenXREventStatus::Error; + } + + for (;;) { + XrEventDataBuffer event{XR_TYPE_EVENT_DATA_BUFFER}; + const XrResult result = xrPollEvent(m_instance, &event); + if (result == XR_EVENT_UNAVAILABLE) { + return ShouldExit() ? OpenXREventStatus::ExitRequested + : OpenXREventStatus::Continue; + } + if (XR_FAILED(result)) { + Check(result, "xrPollEvent"); + if (result == XR_ERROR_INSTANCE_LOST) { + m_instance_loss_pending = true; + } + return OpenXREventStatus::Error; + } + + switch (event.type) { + case XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED: { + const auto& state_event = + *reinterpret_cast(&event); + if (state_event.session == m_session && + !HandleSessionStateChanged(state_event)) { + return OpenXREventStatus::Error; + } + break; + } + case XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING: + m_instance_loss_pending = true; + Log(OpenXRLogLevel::Warning, + "OpenXR runtime reported instance loss pending"); + break; + case XR_TYPE_EVENT_DATA_REFERENCE_SPACE_CHANGE_PENDING: { + const auto& space_event = + *reinterpret_cast(&event); + if (space_event.session == m_session) { + ++m_reference_space_change.serial; + m_reference_space_change.type = space_event.referenceSpaceType; + m_reference_space_change.change_time = space_event.changeTime; + m_reference_space_change.pose_in_previous_space_valid = + space_event.poseValid == XR_TRUE; + m_reference_space_change.pose_in_previous_space = + space_event.poseInPreviousSpace; + } + break; + } + case XR_TYPE_EVENT_DATA_EVENTS_LOST: { + const auto& lost_event = + *reinterpret_cast(&event); + std::ostringstream message; + message << "OpenXR runtime lost " << lost_event.lostEventCount + << " event(s)"; + Log(OpenXRLogLevel::Warning, message.str()); + break; + } + default: + break; + } + } +} + +bool OpenXRRuntime::HandleSessionStateChanged( + const XrEventDataSessionStateChanged& event) { + m_session_state = event.state; + std::ostringstream message; + message << "OpenXR session state -> " << SessionStateName(event.state); + Log(OpenXRLogLevel::Info, message.str()); + + switch (event.state) { + case XR_SESSION_STATE_READY: { + if (m_shutting_down_session || m_session_running) { + return true; + } + XrSessionBeginInfo begin_info{XR_TYPE_SESSION_BEGIN_INFO}; + begin_info.primaryViewConfigurationType = m_config.view_configuration; + if (!Check(xrBeginSession(m_session, &begin_info), "xrBeginSession")) { + return false; + } + m_session_running = true; + return true; + } + case XR_SESSION_STATE_STOPPING: + if (m_session_running) { + if (!Check(xrEndSession(m_session), "xrEndSession")) { + return false; + } + m_session_running = false; + } + return true; + case XR_SESSION_STATE_EXITING: + m_exit_requested = true; + m_session_running = false; + return true; + case XR_SESSION_STATE_LOSS_PENDING: + m_instance_loss_pending = true; + m_session_running = false; + return true; + default: + return true; + } +} + +bool OpenXRRuntime::RequestExitSession() { + ClearError(); + if (!HasSession() || !m_session_running) { + return true; + } + return Check(xrRequestExitSession(m_session), "xrRequestExitSession"); +} + +OpenXRFrameStatus OpenXRRuntime::WaitFrame(OpenXRFrame& frame) { + ClearError(); + if (ShouldExit()) { + return OpenXRFrameStatus::ExitRequested; + } + if (!HasSession() || !m_session_running) { + return OpenXRFrameStatus::SessionNotRunning; + } + if (m_frame_phase != FramePhase::Idle) { + Fail(XR_ERROR_CALL_ORDER_INVALID, "xrWaitFrame", + "the previous frame has not been ended"); + return OpenXRFrameStatus::Error; + } + + XrFrameWaitInfo wait_info{XR_TYPE_FRAME_WAIT_INFO}; + XrFrameState state{XR_TYPE_FRAME_STATE}; + if (!Check(xrWaitFrame(m_session, &wait_info, &state), "xrWaitFrame")) { + return OpenXRFrameStatus::Error; + } + + frame = {}; + frame.serial = m_next_frame_serial++; + frame.predicted_display_time = state.predictedDisplayTime; + frame.predicted_display_period = state.predictedDisplayPeriod; + frame.should_render = state.shouldRender == XR_TRUE; + m_active_frame_serial = frame.serial; + m_active_frame_display_time = frame.predicted_display_time; + m_frame_phase = FramePhase::Waited; + return OpenXRFrameStatus::Ready; +} + +bool OpenXRRuntime::BeginFrame(const OpenXRFrame& frame) { + ClearError(); + if (!IsFrameTokenCurrent(frame, FramePhase::Waited)) { + return Fail(XR_ERROR_CALL_ORDER_INVALID, "xrBeginFrame", + "frame token is stale or xrWaitFrame was not called"); + } + + XrFrameBeginInfo begin_info{XR_TYPE_FRAME_BEGIN_INFO}; + const XrResult result = xrBeginFrame(m_session, &begin_info); + if (XR_FAILED(result)) { + m_frame_phase = FramePhase::Idle; + m_active_frame_serial = 0; + m_active_frame_display_time = 0; + return Check(result, "xrBeginFrame"); + } + m_frame_phase = FramePhase::Begun; + return true; +} + +bool OpenXRRuntime::LocateViews(OpenXRFrame& frame) { + ClearError(); + if (!IsFrameTokenCurrent(frame, FramePhase::Begun)) { + return Fail(XR_ERROR_CALL_ORDER_INVALID, "xrLocateViews", + "frame token is stale or xrBeginFrame was not called"); + } + frame.views_valid = false; + frame.view_state_flags = 0; + if (!frame.should_render) { + return true; + } + + for (XrView& view : frame.views) { + view = {XR_TYPE_VIEW}; + } + XrViewLocateInfo locate_info{XR_TYPE_VIEW_LOCATE_INFO}; + locate_info.viewConfigurationType = m_config.view_configuration; + locate_info.displayTime = frame.predicted_display_time; + locate_info.space = m_app_space; + XrViewState view_state{XR_TYPE_VIEW_STATE}; + uint32_t view_count = 0; + if (!Check(xrLocateViews(m_session, &locate_info, &view_state, + kOpenXREyeCount, &view_count, frame.views.data()), + "xrLocateViews")) { + return false; + } + if (view_count != kOpenXREyeCount) { + return Fail(XR_ERROR_RUNTIME_FAILURE, "xrLocateViews", + "runtime returned an unexpected stereo view count"); + } + + frame.view_state_flags = view_state.viewStateFlags; + frame.views_valid = + (view_state.viewStateFlags & XR_VIEW_STATE_ORIENTATION_VALID_BIT) != 0; + return true; +} + +bool OpenXRRuntime::EndFrame( + const OpenXRFrame& frame, + const XrCompositionLayerBaseHeader* const* layers, + uint32_t layer_count) { + ClearError(); + if (!IsFrameTokenCurrent(frame, FramePhase::Begun)) { + return Fail(XR_ERROR_CALL_ORDER_INVALID, "xrEndFrame", + "frame token is stale or xrBeginFrame was not called"); + } + if (layer_count != 0 && layers == nullptr) { + return Fail(XR_ERROR_VALIDATION_FAILURE, "xrEndFrame", + "non-zero layer_count requires a layer array"); + } + + // The runtime explicitly requested no application rendering. Ending with an + // empty layer list preserves the frame protocol without presenting stale work. + if (!frame.should_render) { + layers = nullptr; + layer_count = 0; + } + + XrFrameEndInfo end_info{XR_TYPE_FRAME_END_INFO}; + end_info.displayTime = frame.predicted_display_time; + end_info.environmentBlendMode = m_blend_mode; + end_info.layerCount = layer_count; + end_info.layers = layers; + const XrResult result = xrEndFrame(m_session, &end_info); + m_frame_phase = FramePhase::Idle; + m_active_frame_serial = 0; + m_active_frame_display_time = 0; + return Check(result, "xrEndFrame"); +} + +bool OpenXRRuntime::EndFrame( + const OpenXRFrame& frame, + const std::vector& layers) { + return EndFrame(frame, layers.data(), static_cast(layers.size())); +} + +bool OpenXRRuntime::EndFrameWithoutLayers(const OpenXRFrame& frame) { + return EndFrame(frame, nullptr, 0); +} + +bool OpenXRRuntime::ResetAppSpace(const XrPosef& pose_in_reference_space) { + ClearError(); + if (!HasSession()) { + return Fail(XR_ERROR_CALL_ORDER_INVALID, "ResetAppSpace", + "no OpenXR session exists"); + } + if (m_frame_phase != FramePhase::Idle) { + return Fail(XR_ERROR_CALL_ORDER_INVALID, "ResetAppSpace", + "reference space cannot change during a frame"); + } + + XrReferenceSpaceCreateInfo create_info{XR_TYPE_REFERENCE_SPACE_CREATE_INFO}; + create_info.referenceSpaceType = m_app_space_type; + create_info.poseInReferenceSpace = pose_in_reference_space; + XrSpace replacement = XR_NULL_HANDLE; + if (!Check(xrCreateReferenceSpace(m_session, &create_info, &replacement), + "xrCreateReferenceSpace(recenter)")) { + return false; + } + if (m_app_space != XR_NULL_HANDLE) { + xrDestroySpace(m_app_space); + } + m_app_space = replacement; + return true; +} + +void OpenXRRuntime::DestroySession() { + if (!HasSession()) { + ResetSessionState(); + return; + } + + if (m_frame_phase == FramePhase::Begun) { + Log(OpenXRLogLevel::Warning, + "ending an active OpenXR frame without layers during teardown"); + XrFrameEndInfo end_info{XR_TYPE_FRAME_END_INFO}; + end_info.displayTime = m_active_frame_display_time; + end_info.environmentBlendMode = m_blend_mode; + const XrResult end_result = xrEndFrame(m_session, &end_info); + if (XR_FAILED(end_result)) { + Log(OpenXRLogLevel::Warning, + "xrEndFrame failed during session teardown"); + } + m_frame_phase = FramePhase::Idle; + m_active_frame_serial = 0; + m_active_frame_display_time = 0; + } + + m_shutting_down_session = true; + if (m_session_running) { + const XrResult request_result = xrRequestExitSession(m_session); + if (XR_FAILED(request_result)) { + Log(OpenXRLogLevel::Warning, + "xrRequestExitSession failed during bounded teardown"); + } else { + const auto timeout = + std::chrono::milliseconds(m_config.shutdown_timeout_ms); + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (m_session_running && + std::chrono::steady_clock::now() < deadline) { + if (PollEvents() == OpenXREventStatus::Error) { + break; + } + if (m_session_running) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + } + if (m_session_running) { + Log(OpenXRLogLevel::Warning, + "OpenXR runtime did not finish session exit before timeout"); + } + } + } + + DestroyReferenceSpaces(); + const XrResult result = xrDestroySession(m_session); + if (XR_FAILED(result)) { + Log(OpenXRLogLevel::Warning, "xrDestroySession failed"); + } + m_session = XR_NULL_HANDLE; + ResetSessionState(); +} + +void OpenXRRuntime::Shutdown() { + DestroySession(); + if (m_instance != XR_NULL_HANDLE) { + const XrResult result = xrDestroyInstance(m_instance); + if (XR_FAILED(result)) { + Log(OpenXRLogLevel::Warning, "xrDestroyInstance failed"); + } + } + ResetInstanceState(); +} + +void OpenXRRuntime::DestroyReferenceSpaces() { + if (m_view_space != XR_NULL_HANDLE) { + xrDestroySpace(m_view_space); + m_view_space = XR_NULL_HANDLE; + } + if (m_app_space != XR_NULL_HANDLE) { + xrDestroySpace(m_app_space); + m_app_space = XR_NULL_HANDLE; + } +} + +void OpenXRRuntime::ResetSessionState() { + m_session_state = XR_SESSION_STATE_UNKNOWN; + m_app_space_type = m_config.reference_space; + m_session_running = false; + m_exit_requested = false; + m_shutting_down_session = false; + m_frame_phase = FramePhase::Idle; + m_active_frame_serial = 0; + m_active_frame_display_time = 0; + m_supported_reference_spaces.clear(); + m_swapchain_formats.clear(); + m_reference_space_change = {}; +} + +void OpenXRRuntime::ResetInstanceState() { + m_instance = XR_NULL_HANDLE; + m_system_id = XR_NULL_SYSTEM_ID; + m_instance_loss_pending = false; + m_runtime_info = {}; + m_view_configuration = {}; + m_available_extensions.clear(); + m_available_api_layers.clear(); + m_enabled_extensions.clear(); + m_enabled_api_layers.clear(); + m_supported_blend_modes.clear(); + ResetSessionState(); +} + +bool OpenXRRuntime::IsFrameTokenCurrent( + const OpenXRFrame& frame, FramePhase expected) const { + return m_frame_phase == expected && frame.serial != 0 && + frame.serial == m_active_frame_serial; +} + +bool OpenXRRuntime::Check(XrResult result, std::string_view operation) { + if (XR_SUCCEEDED(result)) { + return true; + } + return Fail(result, operation, {}); +} + +bool OpenXRRuntime::Fail( + XrResult result, std::string_view operation, std::string_view detail) { + m_last_error.result = result; + m_last_error.operation.assign(operation); + std::ostringstream message; + message << operation << " failed: " << ResultString(result); + if (!detail.empty()) { + message << " (" << detail << ')'; + } + m_last_error.message = message.str(); + Log(OpenXRLogLevel::Error, m_last_error.message); + return false; +} + +void OpenXRRuntime::ClearError() { + m_last_error = {}; +} + +void OpenXRRuntime::Log( + OpenXRLogLevel level, std::string_view message) const noexcept { + if (!m_logger) { + return; + } + try { + m_logger(level, message); + } catch (...) { + // Diagnostic callbacks must never make XR teardown throw. + } +} + +std::string OpenXRRuntime::ResultString(XrResult result) const { + if (m_instance != XR_NULL_HANDLE) { + char text[XR_MAX_RESULT_STRING_SIZE]{}; + if (XR_SUCCEEDED(xrResultToString(m_instance, result, text))) { + return text; + } + } + return std::to_string(static_cast(result)); +} + +} // namespace mkw::vr