diff --git a/OPENXR.md b/OPENXR.md index 1c2cae0..920f0a7 100644 --- a/OPENXR.md +++ b/OPENXR.md @@ -23,6 +23,7 @@ configuration and is created with the following defaults: enabled = false required = false mirror_view = "normal" +eager_frame_heartbeat = false render_scale = 1.0 world_units_per_meter = 500.0 hud_distance_meters = 2.0 @@ -55,6 +56,15 @@ sits directly under the enable switch as *Desktop view*. Menus reach the headset screen carrying the desktop image itself, so there is no separate eye view to mirror there and the three eye choices show that same image; only `"none"` differs. The F10 bar is drawn over whichever image is chosen, so the setting can always be changed back. +Eye mirror modes retain the last eye image when a desktop frame has no new XR packet, so they +do not alternate with the normal camera. `"none"` also stays black between XR packets. + +`eager_frame_heartbeat` is live in **F10 > VR > Eager Frame Heartbeat** and defaults to `false`. +With it off, the XR thread waits for new game frames and repeats the last valid image during +stalls, with a 50 ms keep-alive interval. With it on, the thread repeats at headset display +deadlines while waiting for new rendering, matching the eager behavior. Compare both settings +during the same race to check smoothness with your OpenXR runtime. Both retain the protection +against black frames during pauses and window dragging. `render_scale` scales the per-eye size recommended by the OpenXR runtime. `world_units_per_meter` controls the scale of headset translation in the game world. @@ -135,10 +145,23 @@ receive asymmetric headset projections, while the game's 2D layer goes on a fixe Head pose is sampled by the OpenXR pacing thread, while Aurora's frame worker consumes a short-lived immutable stereo packet. Each sealed GX frame and immersive packet carry the same policy-generation tag; a mismatch is rendered in mono and the acquired XR frame is canceled, so an -asynchronous menu/race transition cannot replay race transforms over unsafe content. A pause or -minimized window can withdraw an unencoded packet and end that compositor frame without layers; an -encoded-work stall requests teardown at Aurora's next safe producer boundary. All OpenXR session -and swapchain calls remain on their owning thread. +asynchronous menu/race transition cannot replay race transforms over unsafe content. + +The D3D12 pacing thread retains the last completed projection or virtual-screen layer and +resubmits it during stalls (or missed display deadlines with eager heartbeat enabled), including while moving the desktop window, +pausing, or minimizing. The scene freezes until rendering resumes; the compositor can still +reproject the retained image for head movement. Repeated layers keep their original render poses +and field of view, paired with the new compositor display time. Two pairs of eye swapchains keep +the retained image separate from pending or canceled rendering (at the cost of additional GPU +memory). Unencoded packets can be withdrawn after 50 ms; encoded work retains its images while +the pacing thread continues submitting the last completed layer. A stall alone no longer requests +desktop fallback after 250 ms. + +Before the first valid image, when OpenXR requests no rendering, or after a session/reference-space +change invalidates the retained content, frames can still have no layers. Actual runtime or GPU +submission failures retain the safe teardown path. This does not detect black images rendered by +the game itself, and cannot keep submitting if the entire process or XR runtime is suspended. +All OpenXR session and swapchain calls remain on their owning thread. ## The race's 2D layer diff --git a/aurora-main/include/aurora/gfx.h b/aurora-main/include/aurora/gfx.h index ae52179..8c94666 100644 --- a/aurora-main/include/aurora/gfx.h +++ b/aurora-main/include/aurora/gfx.h @@ -110,6 +110,8 @@ bool aurora_get_stereo_hud_screen_enabled(); // ordinary mono presentation untouched, the eye views mirror what the headset is // actually displaying, and NONE presents a black window. Live, and only // consulted while a stereo frame provider is supplying frames. +// When a frame has no new XR packet, eye views retain the previous eye image +// instead of falling back to the ordinary desktop view. // // A menu frame reaches the headset as a virtual screen carrying the very mono // image the desktop already shows, so there is no distinct eye view to mirror: diff --git a/aurora-main/lib/aurora.cpp b/aurora-main/lib/aurora.cpp index d5ce72f..c9863bb 100644 --- a/aurora-main/lib/aurora.cpp +++ b/aurora-main/lib/aurora.cpp @@ -8,6 +8,7 @@ #include "gx/shader_info.hpp" #include "imgui.hpp" #include "stereo.hpp" +#include "stereo_mirror.hpp" #include "webgpu/gpu.hpp" #include #endif @@ -544,6 +545,7 @@ struct StereoEyeTarget { const webgpu::TextureWithSampler& output() const noexcept { return resolvedColor.texture ? resolvedColor : color; } }; std::array g_stereoEyeTargets; +stereo::MirrorState g_stereoMirrorState; // The eye targets outlive a frame, so the mirror samples them through a bind // group cached beside them rather than one built per presentation slot. @@ -1422,35 +1424,7 @@ void stop_presenter() noexcept { // view; the rest mirror the headset and are only ever chosen while a stereo // provider is feeding one. ImGui is drawn over all of them alike, so the // settings menu stays reachable even under Black. -enum class MirrorPlan { - Mono, - LeftEye, - RightEye, - BothEyes, - Black, -}; - -// A virtual-screen (menu) frame puts the desktop's own mono image on both eyes, -// so there is no separate eye view to mirror and the eye choices collapse onto -// Mono. Only Black still has something distinct to do there. -MirrorPlan resolve_mirror_plan(AuroraStereoMirrorView view, bool stereoOutput, bool immersiveReplay) noexcept { - if (!stereoOutput) { - return MirrorPlan::Mono; - } - switch (view) { - case AURORA_STEREO_MIRROR_NONE: - return MirrorPlan::Black; - case AURORA_STEREO_MIRROR_BOTH_EYES: - return immersiveReplay ? MirrorPlan::BothEyes : MirrorPlan::Mono; - case AURORA_STEREO_MIRROR_LEFT_EYE: - return immersiveReplay ? MirrorPlan::LeftEye : MirrorPlan::Mono; - case AURORA_STEREO_MIRROR_RIGHT_EYE: - return immersiveReplay ? MirrorPlan::RightEye : MirrorPlan::Mono; - case AURORA_STEREO_MIRROR_NORMAL: - break; - } - return MirrorPlan::Mono; -} +using stereo::MirrorPlan; // Places one eye inside `bounds`, keeping the eye's own aspect ratio rather // than the game's presented one: an eye is already the shape the headset asked @@ -1567,6 +1541,7 @@ void shutdown() noexcept { #ifdef AURORA_ENABLE_GX stop_presenter(); g_stereoEyeTargets = {}; + g_stereoMirrorState.Reset(); g_presentationImagePools = {}; imgui::shutdown(); gfx::shutdown(); @@ -1785,7 +1760,8 @@ std::vector encode_sealed_frame(gfx::SealedFrame& sealedFrame, const bool immersiveReplay = stereoOutput && ctx.immersiveStereoPrepared; // One choice for the whole group: a slot showing the mono view next to slots // mirroring an eye would strobe between two different images. - const MirrorPlan mirrorPlan = resolve_mirror_plan(gfx::get_stereo_mirror_view(), stereoOutput, immersiveReplay); + const MirrorPlan mirrorPlan = g_stereoMirrorState.Resolve( + gfx::get_stereo_mirror_view(), stereo_frame_provider_active(), stereoOutput, immersiveReplay); // Each slot is submitted as soon as it is encoded, so the GPU starts slot 0 while slot 1 is still // recording. Queue order preserves the ordering the single batched buffer gave. @@ -2238,6 +2214,11 @@ bool stereo_frame_provider_active() noexcept { return g_stereoProviderActive.loa void set_stereo_frame_provider(AuroraStereoFrameProvider provider, void* userdata) noexcept { std::lock_guard lock(g_stereoRegistrationMutex); +#ifdef AURORA_ENABLE_GX + // Registration changes require an idle frame worker, so its cached mirror + // state can be reset here without racing texture use. + g_stereoMirrorState.Reset(); +#endif g_stereoProvider = { .callback = provider, .userdata = userdata, diff --git a/aurora-main/lib/stereo_mirror.hpp b/aurora-main/lib/stereo_mirror.hpp new file mode 100644 index 0000000..d96c33b --- /dev/null +++ b/aurora-main/lib/stereo_mirror.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include + +namespace aurora::stereo { + +enum class MirrorPlan { Mono, LeftEye, RightEye, BothEyes, Black }; + +// Owned by the frame worker, alongside the persistent eye textures. An absent +// XR packet means the headset is retaining its image, not switching to mono. +class MirrorState { +public: + MirrorPlan Resolve(AuroraStereoMirrorView view, bool providerActive, + bool freshOutput, bool immersiveReplay) noexcept { + if (!providerActive) { + Reset(); + return MirrorPlan::Mono; + } + if (freshOutput) { + m_immersive = immersiveReplay; + } + switch (view) { + case AURORA_STEREO_MIRROR_NONE: + return MirrorPlan::Black; + case AURORA_STEREO_MIRROR_BOTH_EYES: + return m_immersive ? MirrorPlan::BothEyes : MirrorPlan::Mono; + case AURORA_STEREO_MIRROR_LEFT_EYE: + return m_immersive ? MirrorPlan::LeftEye : MirrorPlan::Mono; + case AURORA_STEREO_MIRROR_RIGHT_EYE: + return m_immersive ? MirrorPlan::RightEye : MirrorPlan::Mono; + default: + return MirrorPlan::Mono; + } + } + + void Reset() noexcept { m_immersive = false; } + +private: + bool m_immersive = false; +}; + +} // namespace aurora::stereo diff --git a/aurora-main/tests/CMakeLists.txt b/aurora-main/tests/CMakeLists.txt index d616106..842d4a6 100644 --- a/aurora-main/tests/CMakeLists.txt +++ b/aurora-main/tests/CMakeLists.txt @@ -18,6 +18,7 @@ if (AURORA_ENABLE_GX) gx_fifo_test.cpp gx_test_stubs.cpp stereo_replay_test.cpp + stereo_mirror_test.cpp texture_bind_group_cache_key_test.cpp ../lib/gfx/efb_ram_encoder.cpp # GX API implementations (encoders) diff --git a/aurora-main/tests/stereo_mirror_test.cpp b/aurora-main/tests/stereo_mirror_test.cpp new file mode 100644 index 0000000..842315a --- /dev/null +++ b/aurora-main/tests/stereo_mirror_test.cpp @@ -0,0 +1,43 @@ +#include "stereo_mirror.hpp" + +#include + +namespace aurora::stereo { + +TEST(StereoMirror, RetainsEyesAcrossMissingPackets) { + MirrorState state; + EXPECT_EQ(state.Resolve(AURORA_STEREO_MIRROR_LEFT_EYE, true, false, false), MirrorPlan::Mono); + EXPECT_EQ(state.Resolve(AURORA_STEREO_MIRROR_LEFT_EYE, true, true, true), MirrorPlan::LeftEye); + for (int frame = 0; frame < 120; ++frame) { + EXPECT_EQ(state.Resolve(AURORA_STEREO_MIRROR_LEFT_EYE, true, false, false), MirrorPlan::LeftEye); + } + // Live selection remains usable even without a new headset frame. + EXPECT_EQ(state.Resolve(AURORA_STEREO_MIRROR_RIGHT_EYE, true, false, false), MirrorPlan::RightEye); + EXPECT_EQ(state.Resolve(AURORA_STEREO_MIRROR_BOTH_EYES, true, false, false), MirrorPlan::BothEyes); + EXPECT_EQ(state.Resolve(AURORA_STEREO_MIRROR_NORMAL, true, false, false), MirrorPlan::Mono); + EXPECT_EQ(state.Resolve(AURORA_STEREO_MIRROR_LEFT_EYE, true, false, false), MirrorPlan::LeftEye); +} + +TEST(StereoMirror, MenusAndSessionEndReplaceRetainedRaceView) { + MirrorState state; + EXPECT_EQ(state.Resolve(AURORA_STEREO_MIRROR_BOTH_EYES, true, true, true), MirrorPlan::BothEyes); + EXPECT_EQ(state.Resolve(AURORA_STEREO_MIRROR_BOTH_EYES, true, true, false), MirrorPlan::Mono); + EXPECT_EQ(state.Resolve(AURORA_STEREO_MIRROR_BOTH_EYES, true, false, false), MirrorPlan::Mono); + EXPECT_EQ(state.Resolve(AURORA_STEREO_MIRROR_BOTH_EYES, true, true, true), MirrorPlan::BothEyes); + EXPECT_EQ(state.Resolve(AURORA_STEREO_MIRROR_BOTH_EYES, false, false, false), MirrorPlan::Mono); + EXPECT_EQ(state.Resolve(AURORA_STEREO_MIRROR_BOTH_EYES, true, false, false), MirrorPlan::Mono); + EXPECT_EQ(state.Resolve(AURORA_STEREO_MIRROR_LEFT_EYE, true, true, true), MirrorPlan::LeftEye); + state.Reset(); // Provider replacement must not reuse the previous session. + EXPECT_EQ(state.Resolve(AURORA_STEREO_MIRROR_LEFT_EYE, true, false, false), MirrorPlan::Mono); +} + +TEST(StereoMirror, NoneStaysBlackWithoutFreshFrames) { + MirrorState state; + EXPECT_EQ(state.Resolve(AURORA_STEREO_MIRROR_NONE, true, true, true), MirrorPlan::Black); + EXPECT_EQ(state.Resolve(AURORA_STEREO_MIRROR_NONE, true, false, false), MirrorPlan::Black); + EXPECT_EQ(state.Resolve(AURORA_STEREO_MIRROR_NONE, true, true, false), MirrorPlan::Black); + EXPECT_EQ(state.Resolve(AURORA_STEREO_MIRROR_NONE, true, false, false), MirrorPlan::Black); + EXPECT_EQ(state.Resolve(AURORA_STEREO_MIRROR_NONE, false, false, false), MirrorPlan::Mono); +} + +} // namespace aurora::stereo diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 5894a05..b44fda5 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -342,6 +342,19 @@ target_include_directories(mkw_vr_first_person_tests PRIVATE "${CMAKE_CURRENT_LI target_compile_features(mkw_vr_first_person_tests PRIVATE cxx_std_17) add_test(NAME mkw_vr_first_person_tests COMMAND mkw_vr_first_person_tests) +if(MKW_ENABLE_OPENXR AND MKW_PLATFORM_WINDOWS) + add_executable(mkw_openxr_replay_tests + tests/openxr_d3d12_replay_tests.cpp src/vr/openxr_d3d12.cpp) + target_include_directories(mkw_openxr_replay_tests PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/include" + "${CMAKE_CURRENT_LIST_DIR}/../aurora-main/include" + "$") + target_compile_definitions(mkw_openxr_replay_tests PRIVATE MKW_ENABLE_OPENXR=1) + target_compile_features(mkw_openxr_replay_tests PRIVATE cxx_std_17) + set_target_properties(mkw_openxr_replay_tests PROPERTIES UNITY_BUILD OFF) + add_test(NAME mkw_openxr_replay_tests COMMAND mkw_openxr_replay_tests) +endif() + add_executable(mkw_nand_save_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_save_tests.cpp") target_include_directories(mkw_nand_save_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") target_compile_features(mkw_nand_save_tests PRIVATE cxx_std_17) diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h index ab449da..f64ca2e 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -57,6 +57,7 @@ struct RuntimeUserConfig { std::optional vrStopAtDisplayCopy; std::optional vrSkipCopyClears; std::optional vrMirrorView; + std::optional vrEagerFrameHeartbeat; std::optional vrFirstPerson; std::optional vrFirstPersonUnitsPerMeter; std::optional vrFirstPersonHeadUpMeters; @@ -375,6 +376,8 @@ inline void EnsureConfigFile() { "# \"right\" mirror the headset's eyes, and \"none\" blacks the window\n" "# out. Changeable live from the F10 menu.\n" "mirror_view = \"normal\"\n" + "# Repeat at headset cadence (true), or only during stalls (false). Live.\n" + "eager_frame_heartbeat = false\n" "render_scale = 1.0\n" "world_units_per_meter = 500.0\n" "hud_distance_meters = 2.0\n" @@ -635,6 +638,7 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) { value && IsSupportedVrMirrorView(*value)) { config.vrMirrorView = *value; } + config.vrEagerFrameHeartbeat = FindConfigValue(document, "vr", "eager_frame_heartbeat"); if (auto value = FindConfigInt(document, "vr", "first_person_hidden_model"); value && *value >= -1 && *value <= 31) { config.vrFirstPersonHiddenModel = static_cast(*value); @@ -936,6 +940,11 @@ inline bool SetVrMirrorView(std::string value) { return WriteSetting("vr", "mirror_view", FormatString(value)); } +inline bool SetVrEagerFrameHeartbeat(bool value) { + Mutable().vrEagerFrameHeartbeat = value; + return WriteSetting("vr", "eager_frame_heartbeat", value ? "true" : "false"); +} + inline bool SetVrFirstPersonRotation(std::string value) { if (!IsSupportedVrFirstPersonRotation(value)) { return false; @@ -1268,6 +1277,10 @@ inline std::string VrMirrorView(std::string fallback = kVrMirrorViewDefault) { return value && IsSupportedVrMirrorView(*value) ? *value : std::move(fallback); } +inline bool VrEagerFrameHeartbeat() { + return Get().vrEagerFrameHeartbeat.value_or(false); +} + inline std::string VrFirstPersonRotation(std::string fallback = kVrFirstPersonRotationDefault) { const auto& value = Get().vrFirstPersonRotation; return value && IsSupportedVrFirstPersonRotation(*value) ? *value : std::move(fallback); diff --git a/runtime/include/vr/openxr_d3d12.h b/runtime/include/vr/openxr_d3d12.h index f58c52e..47a75ef 100644 --- a/runtime/include/vr/openxr_d3d12.h +++ b/runtime/include/vr/openxr_d3d12.h @@ -92,7 +92,7 @@ public: // timeout_ms == UINT32_MAX waits until Aurora publishes this token or // Shutdown() interrupts the wait. A timeout does not release XR images; - // the caller must first drain/cancel Aurora, then FinishFrame(false). + // the caller may keep pacing with RepeatFrame while Aurora still owns them. OpenXRD3D12SubmissionStatus WaitForSubmission(const OpenXRD3D12Frame& frame, uint32_t timeout_ms = UINT32_MAX); @@ -101,11 +101,17 @@ public: // is required to release them and close the compositor frame. bool TryCancelPendingFrame(OpenXRD3D12Frame& frame); + // Ends the current compositor cycle with the last completed layer and starts + // another, without releasing or changing Aurora's pending images/render token. + // The original render poses remain attached to the pending and retained images. + bool RepeatFrame(const OpenXRD3D12Frame& frame); + // Releases acquired images and calls xrEndFrame. submit_layer must only be // true after WaitForSubmission returned Success. Immersive frames submit // XrCompositionLayerProjection; virtual-screen frames submit an // XrCompositionLayerQuad using the single mono target, placed as the - // presentation's quad_anchored/quad_pose describe. + // presentation's quad_anchored/quad_pose describe. If no new usable layer is + // available, resubmits the retained layer using the current display time. bool FinishFrame(OpenXRD3D12Frame& frame, bool submit_layer); // Call on the XR owner thread after Aurora's worker is idle and before diff --git a/runtime/include/vr/openxr_integration.h b/runtime/include/vr/openxr_integration.h index d0c5a81..920eea2 100644 --- a/runtime/include/vr/openxr_integration.h +++ b/runtime/include/vr/openxr_integration.h @@ -53,4 +53,8 @@ void OpenXRRequestRecenter() noexcept; // once per published frame. void OpenXRSetLeanBackDegrees(float degrees) noexcept; +// Live pacing choice. Disabled: submit fresh frames promptly and repeat during +// stalls. Enabled: repeat at each headset display deadline while waiting. +void OpenXRSetEagerFrameHeartbeat(bool enabled) noexcept; + } // namespace mkw::vr diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index c30548e..8a0ae42 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -117,6 +117,7 @@ float g_vrFirstPersonHeadUp = RuntimeConfigFile::VrFirstPersonHeadUpMeters(); float g_vrFirstPersonHeadForward = RuntimeConfigFile::VrFirstPersonHeadForwardMeters(); float g_vrFirstPersonHeadRight = RuntimeConfigFile::VrFirstPersonHeadRightMeters(); bool g_vrFirstPersonHideDriver = RuntimeConfigFile::VrFirstPersonHideDriver(); +bool g_vrEagerFrameHeartbeat = RuntimeConfigFile::VrEagerFrameHeartbeat(); int g_vrFirstPersonHiddenModel = RuntimeConfigFile::VrFirstPersonHiddenModel(); // Config spellings and menu labels for the desktop mirror, index-matched to // AuroraStereoMirrorView so the combo selection converts to either directly. @@ -925,6 +926,17 @@ void DrawVrSettings() { "same desktop image, so the eye choices only differ from Normal during a race."); } + if (ImGui::Checkbox("Eager Frame Heartbeat", &g_vrEagerFrameHeartbeat)) { + mkw::vr::OpenXRSetEagerFrameHeartbeat(g_vrEagerFrameHeartbeat); + RuntimeConfigFile::SetVrEagerFrameHeartbeat(g_vrEagerFrameHeartbeat); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip( + "On: repeat the last frame at headset refresh rate while waiting for the game. " + "Off (default): follow the game's frame rate, with repeats during stalls. " + "Compare both during a race to check headset smoothness. Applies immediately."); + } + // Like the mirror above and unlike the enable toggle, these two apply to the // very next frame, so they can be compared from inside a running race. ImGui::Separator(); diff --git a/runtime/src/vr/openxr_d3d12.cpp b/runtime/src/vr/openxr_d3d12.cpp index 9f38ecf..4063a78 100644 --- a/runtime/src/vr/openxr_d3d12.cpp +++ b/runtime/src/vr/openxr_d3d12.cpp @@ -261,6 +261,8 @@ public: frame_active_ = true; active_frame_serial_ = frame.xr_frame.serial; active_frame_ = frame.xr_frame; + render_session_serial_ = runtime_->SessionRunSerial(); + render_space_serial_ = runtime_->LastReferenceSpaceChange().serial; for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { frame.render_width[eye] = eye_swapchains_[eye].width; @@ -393,22 +395,83 @@ public: const bool can_submit = submit_layer && release_ok && frame.xr_frame.should_render && frame.xr_frame.views_valid && frame.expects_gpu_submission && composition_pose_valid; - bool end_ok = false; + if (can_submit) { + // xrEndFrame references the MOST RECENTLY RELEASED image of a + // swapchain, not an explicit image index. Keep the displayed pair + // separate from the pair Aurora can write or cancel next. + std::swap(eye_swapchains_, retained_swapchains_); + retained_frame_ = frame; + retained_session_serial_ = render_session_serial_; + retained_space_serial_ = render_space_serial_; + have_retained_frame_ = true; + } + const bool end_ok = EndRetainedFrame(); + + frame_active_ = false; + active_frame_serial_ = 0; + active_frame_ = {}; + frame.expects_gpu_submission = false; + { + std::lock_guard lock(submission_mutex_); + awaiting_token_ = 0; + submission_arrived_ = false; + submission_success_ = false; + submission_unsafe_ = false; + } + return release_ok && end_ok; + } + + bool RepeatFrame(const OpenXRD3D12Frame& frame) { + if (!frame_active_ || runtime_ == nullptr || + frame.xr_frame.serial != active_frame_serial_) { + return Fail("RepeatFrame received a stale or inactive render token"); + } + const bool end_ok = EndRetainedFrame(); + // EndFrame consumes the compositor token even when submission fails. + // Teardown must not try to end that same token again. + frame_active_ = false; + if (!end_ok) { + return Fail("OpenXR could not resubmit the retained frame"); + } + // active_frame_serial_ continues to identify Aurora's pending render; + // active_frame_ identifies the independently advancing compositor cycle. + if (runtime_->PollEvents() != OpenXREventStatus::Continue || + !runtime_->IsSessionRunning() || runtime_->ShouldExit()) { + return Fail("OpenXR session stopped while waiting for stereo rendering"); + } + if (runtime_->WaitFrame(active_frame_) != OpenXRFrameStatus::Ready || + !runtime_->BeginFrame(active_frame_)) { + return Fail("OpenXR could not start a retained-frame compositor cycle"); + } + frame_active_ = true; + return true; + } + + bool EndRetainedFrame() { if (!runtime_->IsSessionRunning()) { // A session that is no longer running needs no compositor frame // completion call. Preserve the original backend failure instead // of replacing it with a stale-token error. - end_ok = true; - } else if (!can_submit) { - end_ok = runtime_->EndFrameWithoutLayers(frame.xr_frame); - } else if (frame.presentation.mode == OpenXRD3D12FrameMode::VirtualScreen) { + return true; + } + // Old poses cannot be reused after the runtime changes their coordinate + // system. Also discard content across session restarts. + if (retained_session_serial_ != runtime_->SessionRunSerial() || + retained_space_serial_ != runtime_->LastReferenceSpaceChange().serial) { + have_retained_frame_ = false; + } + if (!have_retained_frame_ || !active_frame_.should_render) { + return runtime_->EndFrameWithoutLayers(active_frame_); + } + const auto& frame = retained_frame_; + if (frame.presentation.mode == OpenXRD3D12FrameMode::VirtualScreen) { XrCompositionLayerQuad quad{XR_TYPE_COMPOSITION_LAYER_QUAD}; quad.layerFlags = 0; quad.eyeVisibility = XR_EYE_VISIBILITY_BOTH; - quad.subImage.swapchain = eye_swapchains_[0].handle; + quad.subImage.swapchain = retained_swapchains_[0].handle; quad.subImage.imageRect = {{0, 0}, - {static_cast(eye_swapchains_[0].width), - static_cast(eye_swapchains_[0].height)}}; + {static_cast(retained_swapchains_[0].width), + static_cast(retained_swapchains_[0].height)}}; quad.subImage.imageArrayIndex = 0; if (frame.presentation.quad_anchored) { // Placed in the application space, so the screen keeps its place @@ -424,25 +487,23 @@ public: 0.0f, 0.0f, -std::max(0.25f, frame.presentation.quad_distance_meters)}; } quad.size.width = std::max(0.25f, frame.presentation.quad_width_meters); - quad.size.height = quad.size.width * static_cast(eye_swapchains_[0].height) / - static_cast(eye_swapchains_[0].width); + quad.size.height = quad.size.width * static_cast(retained_swapchains_[0].height) / + static_cast(retained_swapchains_[0].width); const XrCompositionLayerBaseHeader* layers[] = { reinterpret_cast(&quad)}; - end_ok = runtime_->EndFrame(frame.xr_frame, layers, 1); + return runtime_->EndFrame(active_frame_, layers, 1); } else { std::array views{}; for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { views[eye] = {XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}; views[eye].pose.orientation = frame.xr_frame.views[eye].pose.orientation; - views[eye].pose.position = position_valid - ? frame.xr_frame.views[eye].pose.position - : XrVector3f{0.0f, 0.0f, 0.0f}; + views[eye].pose.position = frame.xr_frame.views[eye].pose.position; views[eye].fov = frame.xr_frame.views[eye].fov; - views[eye].subImage.swapchain = eye_swapchains_[eye].handle; + views[eye].subImage.swapchain = retained_swapchains_[eye].handle; views[eye].subImage.imageRect = { {0, 0}, - {static_cast(eye_swapchains_[eye].width), - static_cast(eye_swapchains_[eye].height)}}; + {static_cast(retained_swapchains_[eye].width), + static_cast(retained_swapchains_[eye].height)}}; views[eye].subImage.imageArrayIndex = 0; } XrCompositionLayerProjection projection{XR_TYPE_COMPOSITION_LAYER_PROJECTION}; @@ -452,21 +513,8 @@ public: projection.views = views.data(); const XrCompositionLayerBaseHeader* layers[] = { reinterpret_cast(&projection)}; - end_ok = runtime_->EndFrame(frame.xr_frame, layers, 1); + return runtime_->EndFrame(active_frame_, layers, 1); } - - frame_active_ = false; - active_frame_serial_ = 0; - active_frame_ = {}; - frame.expects_gpu_submission = false; - { - std::lock_guard lock(submission_mutex_); - awaiting_token_ = 0; - submission_arrived_ = false; - submission_success_ = false; - submission_unsafe_ = false; - } - return release_ok && end_ok; } bool Shutdown() { @@ -552,9 +600,13 @@ private: } bool CreateSwapchains() { + return CreateSwapchainPair(eye_swapchains_) && CreateSwapchainPair(retained_swapchains_); + } + + bool CreateSwapchainPair(std::array& pair) { for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { const auto& view = runtime_->ViewConfiguration()[eye]; - auto& swapchain = eye_swapchains_[eye]; + auto& swapchain = pair[eye]; swapchain.width = view.render_width; swapchain.height = view.render_height; @@ -685,7 +737,15 @@ private: } void DestroySwapchains() { - for (auto& swapchain : eye_swapchains_) { + DestroySwapchainPair(eye_swapchains_); + DestroySwapchainPair(retained_swapchains_); + have_retained_frame_ = false; + retained_frame_ = {}; + swapchain_format_ = DXGI_FORMAT_UNKNOWN; + } + + void DestroySwapchainPair(std::array& pair) { + for (auto& swapchain : pair) { if (swapchain.handle != XR_NULL_HANDLE && !swapchain.acquired) { xrDestroySwapchain(swapchain.handle); } else if (swapchain.acquired) { @@ -694,7 +754,6 @@ private: } swapchain = {}; } - swapchain_format_ = DXGI_FORMAT_UNKNOWN; } void EndActiveFrameWithoutLayers(const OpenXRFrame& frame) { @@ -752,6 +811,11 @@ private: OpenXRLogCallback logger_; OpenXRD3D12GraphicsRequirements requirements_{}; std::array eye_swapchains_{}; + std::array retained_swapchains_{}; + OpenXRD3D12Frame retained_frame_{}; + uint64_t retained_session_serial_ = 0; + uint64_t retained_space_serial_ = 0; + bool have_retained_frame_ = false; DXGI_FORMAT aurora_format_ = DXGI_FORMAT_UNKNOWN; DXGI_FORMAT swapchain_format_ = DXGI_FORMAT_UNKNOWN; std::string last_error_; @@ -766,6 +830,8 @@ private: bool shutting_down_ = false; uint64_t active_frame_serial_ = 0; + uint64_t render_session_serial_ = 0; + uint64_t render_space_serial_ = 0; OpenXRFrame active_frame_{}; bool requirements_queried_ = false; bool owns_session_ = false; @@ -806,6 +872,10 @@ bool OpenXRD3D12Backend::FinishFrame(OpenXRD3D12Frame& frame, bool submit_layer) return m_impl->FinishFrame(frame, submit_layer); } +bool OpenXRD3D12Backend::RepeatFrame(const OpenXRD3D12Frame& frame) { + return m_impl->RepeatFrame(frame); +} + bool OpenXRD3D12Backend::Shutdown() { return m_impl->Shutdown(); } bool OpenXRD3D12Backend::IsBound() const { return m_impl->IsBound(); } diff --git a/runtime/src/vr/openxr_integration.cpp b/runtime/src/vr/openxr_integration.cpp index 1e64d3f..b9fff26 100644 --- a/runtime/src/vr/openxr_integration.cpp +++ b/runtime/src/vr/openxr_integration.cpp @@ -365,6 +365,10 @@ public: recenter_requested_.store(true, std::memory_order_release); } + void SetEagerFrameHeartbeat(bool enabled) noexcept { + eager_frame_heartbeat_.store(enabled, std::memory_order_relaxed); + } + void SetLeanBackDegrees(float degrees) noexcept { lean_back_degrees_.store( std::clamp(degrees, -RuntimeConfigFile::kVrLeanBackDegreesLimit, @@ -533,28 +537,33 @@ private: OpenXRD3D12SubmissionStatus submission = OpenXRD3D12SubmissionStatus::Timeout; bool canceled_before_encode = false; - bool submission_stalled = false; - const auto submission_deadline = - std::chrono::steady_clock::now() + std::chrono::milliseconds(250); + const auto cancel_after = + std::chrono::steady_clock::now() + std::chrono::milliseconds(50); while (!stop_.load(std::memory_order_acquire) && submission == OpenXRD3D12SubmissionStatus::Timeout) { - submission = backend_->WaitForSubmission(frame, 50); + // Eager mode fills missed headset slots. With it off, completion + // wakes us immediately at the game's cadence, while a 50 ms + // keep-alive still protects pauses and window drags from black. + // Read each iteration so the toggle also works during a stall. + const auto wait_ms = eager_frame_heartbeat_.load(std::memory_order_relaxed) + ? static_cast(std::clamp( + frame.xr_frame.predicted_display_period / 1'000'000 - 4, 0, 12)) + : 50u; + submission = backend_->WaitForSubmission(frame, wait_ms); if (submission == OpenXRD3D12SubmissionStatus::Timeout) { // A pause, minimized window, or guest stall may leave no GX // frame to consume this packet. Withdraw it, then cancel the // matching bridge target only if Encode has not taken ownership. - WithdrawPublishedFrame(); - canceled_before_encode = backend_->TryCancelPendingFrame(frame); - if (canceled_before_encode) { - break; + if (std::chrono::steady_clock::now() >= cancel_after) { + WithdrawPublishedFrame(); + canceled_before_encode = backend_->TryCancelPendingFrame(frame); + if (canceled_before_encode) { + break; + } } - if (std::chrono::steady_clock::now() >= submission_deadline) { - // Cancellation may lose a race to completion. Recheck - // the callback-published predicate under the backend - // mutex before declaring a stall at the deadline. - submission = backend_->WaitForSubmission(frame, 0); - submission_stalled = - submission == OpenXRD3D12SubmissionStatus::Timeout; + if (!backend_->RepeatFrame(frame)) { + SetError(backend_->LastError()); + fatal = true; break; } } @@ -573,10 +582,7 @@ private: } continue; } - if (submission_stalled) { - SetError("Aurora did not complete the OpenXR stereo submission; " - "requesting a safe desktop fallback"); - fatal = true; + if (fatal) { break; } const bool submit = submission == OpenXRD3D12SubmissionStatus::Success; @@ -723,6 +729,7 @@ private: std::atomic_bool teardown_requested_{false}; std::atomic_bool recenter_requested_{false}; std::atomic lean_back_degrees_{RuntimeConfigFile::VrLeanBackDegrees()}; + std::atomic_bool eager_frame_heartbeat_{RuntimeConfigFile::VrEagerFrameHeartbeat()}; std::atomic published_{nullptr}; PublishedFrame published_frame_{}; std::mutex published_mutex_; @@ -811,6 +818,14 @@ void OpenXRSetLeanBackDegrees(float degrees) noexcept { #endif } +void OpenXRSetEagerFrameHeartbeat(bool enabled) noexcept { +#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32) + OpenXRIntegration::Get().SetEagerFrameHeartbeat(enabled); +#else + (void)enabled; +#endif +} + std::string OpenXRLastError() { #if !defined(MKW_ENABLE_OPENXR) return "this build was compiled without OpenXR support"; diff --git a/runtime/tests/openxr_d3d12_replay_tests.cpp b/runtime/tests/openxr_d3d12_replay_tests.cpp new file mode 100644 index 0000000..5fc1438 --- /dev/null +++ b/runtime/tests/openxr_d3d12_replay_tests.cpp @@ -0,0 +1,353 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Exercise the real backend against a deterministic compositor and Aurora sink. +// No headset, graphics driver, OpenXR loader, or translated game is required. +#define CINTERFACE +#define XR_USE_GRAPHICS_API_D3D12 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#include "vr/openxr_d3d12.h" + +#include +#include +#include + +using namespace mkw::vr; + +namespace { +void RequireAt(bool condition, int line, const char* expression) { + if (!condition) { + std::cerr << "OpenXR replay test failed at line " << line << ": " << expression << '\n'; + std::abort(); + } +} +#define Require(condition) RequireAt((condition), __LINE__, #condition) +struct Image { uint64_t content = 0; }; +struct Swapchain { + Image image; + bool acquired = false; + bool waited = false; + bool released = false; +}; +std::vector targets; +AuroraD3D12StereoSubmittedCallback callback = nullptr; +void* callback_data = nullptr; +uint64_t pending_token = 0; +bool encoded = false; +bool should_render = true; +bool tracking_valid = true; +bool change_space = false; +bool restart_session = false; +bool stop_session = false; +bool fail_end = false; +uint64_t displayed_content = 0; +uint32_t layer_count = 0; +uint32_t releases = 0; +uint32_t live_swapchains = 0; +XrTime display_time = 0; +XrStructureType layer_type = XR_TYPE_UNKNOWN; +XrPosef quad_pose{}; + +void Complete(bool success = true) { + Require(pending_token != 0); + if (success) { + for (const auto& target : targets) + reinterpret_cast(target.resource)->content = pending_token; + } + callback(pending_token, success, callback_data); + pending_token = 0; + encoded = false; +} + +HRESULT STDMETHODCALLTYPE FeatureSupport(ID3D12Device*, D3D12_FEATURE, + void* data, UINT) { + static_cast(data)->MaxSupportedFeatureLevel = + D3D_FEATURE_LEVEL_11_0; + return S_OK; +} +XrResult XRAPI_CALL Requirements(XrInstance, XrSystemId, + XrGraphicsRequirementsD3D12KHR* requirements) { + requirements->adapterLuid = {}; + requirements->minFeatureLevel = D3D_FEATURE_LEVEL_11_0; + return XR_SUCCESS; +} +} + +// A COM vtable in its C representation supplies the single device operation +// used by BindAurora. The production backend is compiled normally as C++. +bool aurora_d3d12_get_native_handles(AuroraD3D12NativeHandles* handles) { + static ID3D12DeviceVtbl vtable{}; + vtable.CheckFeatureSupport = FeatureSupport; + static ID3D12Device device{&vtable}; + *handles = {&device, &device, DXGI_FORMAT_R8G8B8A8_UNORM, 0, 0}; + return true; +} +bool aurora_d3d12_enable_stereo_bridge(AuroraD3D12StereoSubmittedCallback cb, void* data) { + callback = cb; + callback_data = data; + return true; +} +bool aurora_d3d12_set_stereo_targets(uint64_t token, const AuroraD3D12StereoTarget* data, + uint32_t count) { + Require(pending_token == 0); + pending_token = token; + targets.assign(data, data + count); + return true; +} +bool aurora_d3d12_cancel_stereo_targets(uint64_t token) { + if (encoded || token != pending_token) return false; + pending_token = 0; + return true; +} +bool aurora_d3d12_disable_stereo_bridge() { + pending_token = 0; + encoded = false; + return true; // Simulate a successful queue drain. +} + +XrResult XRAPI_CALL xrCreateSwapchain(XrSession, const XrSwapchainCreateInfo*, XrSwapchain* out) { + *out = reinterpret_cast(new Swapchain); + ++live_swapchains; + return XR_SUCCESS; +} +XrResult XRAPI_CALL xrEnumerateSwapchainImages(XrSwapchain handle, uint32_t capacity, + uint32_t* count, XrSwapchainImageBaseHeader* images) { + *count = 1; // Single-image swapchains also require a separate retained pair. + if (capacity) reinterpret_cast(images)->texture = + reinterpret_cast(&reinterpret_cast(handle)->image); + return XR_SUCCESS; +} +XrResult XRAPI_CALL xrAcquireSwapchainImage(XrSwapchain handle, + const XrSwapchainImageAcquireInfo*, uint32_t* index) { + auto& chain = *reinterpret_cast(handle); + Require(!chain.acquired); + chain.acquired = true; + chain.waited = false; + *index = 0; + return XR_SUCCESS; +} +XrResult XRAPI_CALL xrWaitSwapchainImage(XrSwapchain handle, const XrSwapchainImageWaitInfo*) { + auto& chain = *reinterpret_cast(handle); + Require(chain.acquired); + chain.waited = true; + return XR_SUCCESS; +} +XrResult XRAPI_CALL xrReleaseSwapchainImage(XrSwapchain handle, + const XrSwapchainImageReleaseInfo*) { + auto& chain = *reinterpret_cast(handle); + Require(chain.acquired && chain.waited); + chain.acquired = false; + chain.released = true; + ++releases; + return XR_SUCCESS; +} +XrResult XRAPI_CALL xrDestroySwapchain(XrSwapchain handle) { + auto* chain = reinterpret_cast(handle); + Require(!chain->acquired); + delete chain; + --live_swapchains; + return XR_SUCCESS; +} + +namespace mkw::vr { +OpenXRRuntime::OpenXRRuntime(OpenXRLogCallback) { + m_instance = reinterpret_cast(this); + m_swapchain_formats = {DXGI_FORMAT_R8G8B8A8_UNORM_SRGB}; + for (auto& view : m_view_configuration) { + view.render_width = 100; + view.render_height = 80; + } +} +OpenXRRuntime::~OpenXRRuntime() = default; +bool OpenXRRuntime::GetInstanceProcAddress(const char*, PFN_xrVoidFunction* out) { + *out = reinterpret_cast(Requirements); + return true; +} +bool OpenXRRuntime::CreateSession(const void*) { + m_session = reinterpret_cast(this); + m_session_running = true; + ++m_session_run_serial; + return true; +} +void OpenXRRuntime::DestroySession() { m_session_running = false; } +void OpenXRRuntime::ObserveResult(XrResult) noexcept {} +OpenXREventStatus OpenXRRuntime::PollEvents() { + if (change_space) { ++m_reference_space_change.serial; change_space = false; } + if (restart_session) { ++m_session_run_serial; restart_session = false; } + if (stop_session) { m_session_running = false; stop_session = false; } + return OpenXREventStatus::Continue; +} +OpenXRFrameStatus OpenXRRuntime::WaitFrame(OpenXRFrame& frame) { + Require(m_frame_phase == FramePhase::Idle); + frame = {}; + frame.serial = m_next_frame_serial++; + frame.predicted_display_time = frame.serial * 11'111'111; + frame.predicted_display_period = 11'111'111; + frame.should_render = should_render; + m_active_frame_serial = frame.serial; + m_frame_phase = FramePhase::Waited; + return OpenXRFrameStatus::Ready; +} +bool OpenXRRuntime::BeginFrame(const OpenXRFrame& frame) { + Require(m_frame_phase == FramePhase::Waited && frame.serial == m_active_frame_serial); + m_frame_phase = FramePhase::Begun; + return true; +} +bool OpenXRRuntime::LocateViews(OpenXRFrame& frame) { + frame.views_valid = tracking_valid; + frame.view_state_flags = XR_VIEW_STATE_POSITION_VALID_BIT | XR_VIEW_STATE_ORIENTATION_VALID_BIT; + for (auto& view : frame.views) { + view.pose.orientation.w = 1; + view.pose.position.x = static_cast(frame.serial); + view.fov.angleLeft = -0.75f; + } + return true; +} +bool OpenXRRuntime::EndFrame(const OpenXRFrame& frame, + const XrCompositionLayerBaseHeader* const* layers, uint32_t count) { + Require(m_frame_phase == FramePhase::Begun && frame.serial == m_active_frame_serial); + Require(frame.predicted_display_time > display_time); + display_time = frame.predicted_display_time; + layer_count = count; + if (count) { + Require(frame.should_render && count == 1); + layer_type = layers[0]->type; + const auto check_image = [](const XrSwapchainSubImage& subimage) { + const auto& chain = *reinterpret_cast(subimage.swapchain); + Require(chain.released && !chain.acquired && chain.image.content != 0); + return chain.image.content; + }; + if (layer_type == XR_TYPE_COMPOSITION_LAYER_PROJECTION) { + auto& projection = *reinterpret_cast(layers[0]); + Require(projection.viewCount == 2); + for (const auto& view : {projection.views[0], projection.views[1]}) { + displayed_content = check_image(view.subImage); + // Detect a new image paired with an old pose, or a repeated image + // falsely labelled with the latest compositor pose. + Require(view.pose.position.x == static_cast(displayed_content)); + Require(view.fov.angleLeft == -0.75f); + } + } else { + Require(layer_type == XR_TYPE_COMPOSITION_LAYER_QUAD); + const auto& quad = *reinterpret_cast(layers[0]); + displayed_content = check_image(quad.subImage); + quad_pose = quad.pose; + } + } + m_frame_phase = FramePhase::Idle; + if (fail_end) { + fail_end = false; + return false; + } + return true; +} +bool OpenXRRuntime::EndFrameWithoutLayers(const OpenXRFrame& frame) { + return EndFrame(frame, nullptr, 0); +} +} + +int main() { + OpenXRRuntime runtime; + OpenXRD3D12Backend backend; + Require(backend.QueryGraphicsRequirements(runtime) && backend.BindAurora(runtime)); + Require(live_swapchains == 4); + OpenXRD3D12Presentation presentation; + OpenXRD3D12Frame frame; + const auto begin = [&] { + Require(backend.BeginFrame(presentation, frame) == OpenXRD3D12BeginStatus::Ready); + }; + const auto finish = [&] { + Complete(); + Require(backend.WaitForSubmission(frame, 0) == OpenXRD3D12SubmissionStatus::Success); + Require(backend.FinishFrame(frame, true)); + }; + begin(); + Require(backend.RepeatFrame(frame) && layer_count == 0); // No valid image yet. + finish(); + const auto first = displayed_content; + begin(); + encoded = true; + const auto releases_before_stall = releases; + for (int i = 0; i < 300; ++i) { + Require(backend.WaitForSubmission(frame, 0) == OpenXRD3D12SubmissionStatus::Timeout); + Require(!backend.TryCancelPendingFrame(frame)); + Require(backend.RepeatFrame(frame)); + Require(layer_count == 1 && displayed_content == first); + Require(releases == releases_before_stall); + } + const auto second = frame.xr_frame.serial; + finish(); // A late completion keeps its original render token and poses. + Require(displayed_content == second); + begin(); + Require(backend.TryCancelPendingFrame(frame)); + Require(backend.FinishFrame(frame, false)); + Require(layer_count == 1 && displayed_content == second); + + should_render = false; + begin(); + Require(!frame.expects_gpu_submission && backend.FinishFrame(frame, false)); + Require(layer_count == 0); + should_render = true; + tracking_valid = false; + begin(); + Require(!frame.expects_gpu_submission && backend.FinishFrame(frame, false)); + Require(layer_count == 1 && displayed_content == second); + tracking_valid = true; + + presentation.mode = OpenXRD3D12FrameMode::VirtualScreen; + presentation.quad_anchored = true; + presentation.quad_pose.position.z = -3; + begin(); + Require(targets.size() == 1); + finish(); + const auto menu = displayed_content; + begin(); + Require(backend.RepeatFrame(frame)); + Require(displayed_content == menu && quad_pose.position.z == -3); + Require(backend.TryCancelPendingFrame(frame) && backend.FinishFrame(frame, false)); + + presentation.mode = OpenXRD3D12FrameMode::ImmersiveProjection; + begin(); + change_space = true; + Require(backend.RepeatFrame(frame)); + Require(backend.RepeatFrame(frame) && layer_count == 0); + finish(); // A render from before the space change must remain invalid. + Require(layer_count == 0); + begin(); + finish(); + Require(layer_count == 1); + restart_session = true; + runtime.PollEvents(); + begin(); + Require(backend.RepeatFrame(frame) && layer_count == 0); + finish(); + Require(layer_count == 1); + + begin(); + const auto before_failure = releases; + Complete(false); + Require(backend.WaitForSubmission(frame, 0) == OpenXRD3D12SubmissionStatus::Failed); + Require(!backend.FinishFrame(frame, false)); + Require(releases == before_failure); // Never release possibly in-flight GPU work. + Require(backend.Shutdown() && live_swapchains == 0); + + // A runtime stop or failed xrEndFrame during a stall must still drain the + // pending target without trying to end an already consumed frame token. + for (bool fail_submission : {false, true}) { + display_time = 0; + OpenXRRuntime next_runtime; + OpenXRD3D12Backend next_backend; + Require(next_backend.QueryGraphicsRequirements(next_runtime)); + Require(next_backend.BindAurora(next_runtime)); + Require(next_backend.BeginFrame(presentation, frame) == OpenXRD3D12BeginStatus::Ready); + encoded = true; + stop_session = !fail_submission; + fail_end = fail_submission; + Require(!next_backend.RepeatFrame(frame)); + Require(next_backend.Shutdown() && live_swapchains == 0); + } + std::cout << "OpenXR retained-frame tests passed\n"; +}