Fix : XR thread resubmits the last valid frame during stall + Eager Frame Heartbeat option

This commit is contained in:
iChris4 committed 2026-09-09 01:08:04 +02:00
1 parent 4cf9f60ee7
commit f525b9dd75
14 files changed
+667 -89

No files matched your search

+27 -4
View File
@@ -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
+2
View File
@@ -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:
+11 -30
View File
@@ -8,6 +8,7 @@
#include "gx/shader_info.hpp"
#include "imgui.hpp"
#include "stereo.hpp"
#include "stereo_mirror.hpp"
#include "webgpu/gpu.hpp"
#include <webgpu/webgpu_cpp.h>
#endif
@@ -544,6 +545,7 @@ struct StereoEyeTarget {
const webgpu::TextureWithSampler& output() const noexcept { return resolvedColor.texture ? resolvedColor : color; }
};
std::array<StereoEyeTarget, AURORA_STEREO_EYE_COUNT> 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<PresentationJob> 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,
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include <aurora/gfx.h>
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
+1
View File
@@ -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)
+43
View File
@@ -0,0 +1,43 @@
#include "stereo_mirror.hpp"
#include <gtest/gtest.h>
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
+13
View File
@@ -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_PROPERTY:${MKW_OPENXR_TARGET},INTERFACE_INCLUDE_DIRECTORIES>")
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)
+13
View File
@@ -57,6 +57,7 @@ struct RuntimeUserConfig {
std::optional<bool> vrStopAtDisplayCopy;
std::optional<bool> vrSkipCopyClears;
std::optional<std::string> vrMirrorView;
std::optional<bool> vrEagerFrameHeartbeat;
std::optional<bool> vrFirstPerson;
std::optional<float> vrFirstPersonUnitsPerMeter;
std::optional<float> 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<bool>(document, "vr", "eager_frame_heartbeat");
if (auto value = FindConfigInt(document, "vr", "first_person_hidden_model");
value && *value >= -1 && *value <= 31) {
config.vrFirstPersonHiddenModel = static_cast<int32_t>(*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);
+8 -2
View File
@@ -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
+4
View File
@@ -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
+12
View File
@@ -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();
+104 -34
View File
@@ -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<int32_t>(eye_swapchains_[0].width),
static_cast<int32_t>(eye_swapchains_[0].height)}};
{static_cast<int32_t>(retained_swapchains_[0].width),
static_cast<int32_t>(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<float>(eye_swapchains_[0].height) /
static_cast<float>(eye_swapchains_[0].width);
quad.size.height = quad.size.width * static_cast<float>(retained_swapchains_[0].height) /
static_cast<float>(retained_swapchains_[0].width);
const XrCompositionLayerBaseHeader* layers[] = {
reinterpret_cast<const XrCompositionLayerBaseHeader*>(&quad)};
end_ok = runtime_->EndFrame(frame.xr_frame, layers, 1);
return runtime_->EndFrame(active_frame_, layers, 1);
} else {
std::array<XrCompositionLayerProjectionView, kOpenXREyeCount> 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<int32_t>(eye_swapchains_[eye].width),
static_cast<int32_t>(eye_swapchains_[eye].height)}};
{static_cast<int32_t>(retained_swapchains_[eye].width),
static_cast<int32_t>(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<const XrCompositionLayerBaseHeader*>(&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<EyeSwapchain, kOpenXREyeCount>& 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<EyeSwapchain, kOpenXREyeCount>& 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<EyeSwapchain, kOpenXREyeCount> eye_swapchains_{};
std::array<EyeSwapchain, kOpenXREyeCount> 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(); }
+34 -19
View File
@@ -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<uint32_t>(std::clamp<XrDuration>(
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<float> lean_back_degrees_{RuntimeConfigFile::VrLeanBackDegrees()};
std::atomic_bool eager_frame_heartbeat_{RuntimeConfigFile::VrEagerFrameHeartbeat()};
std::atomic<PublishedFrame*> 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";
+353
View File
@@ -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 <d3d12.h>
#include <openxr/openxr_platform.h>
#include <aurora/d3d12_interop.h>
#include "vr/openxr_d3d12.h"
#include <cstdlib>
#include <iostream>
#include <vector>
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<AuroraD3D12StereoTarget> 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<Image*>(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<D3D12_FEATURE_DATA_FEATURE_LEVELS*>(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<XrSwapchain>(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<XrSwapchainImageD3D12KHR*>(images)->texture =
reinterpret_cast<ID3D12Resource*>(&reinterpret_cast<Swapchain*>(handle)->image);
return XR_SUCCESS;
}
XrResult XRAPI_CALL xrAcquireSwapchainImage(XrSwapchain handle,
const XrSwapchainImageAcquireInfo*, uint32_t* index) {
auto& chain = *reinterpret_cast<Swapchain*>(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<Swapchain*>(handle);
Require(chain.acquired);
chain.waited = true;
return XR_SUCCESS;
}
XrResult XRAPI_CALL xrReleaseSwapchainImage(XrSwapchain handle,
const XrSwapchainImageReleaseInfo*) {
auto& chain = *reinterpret_cast<Swapchain*>(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<Swapchain*>(handle);
Require(!chain->acquired);
delete chain;
--live_swapchains;
return XR_SUCCESS;
}
namespace mkw::vr {
OpenXRRuntime::OpenXRRuntime(OpenXRLogCallback) {
m_instance = reinterpret_cast<XrInstance>(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<PFN_xrVoidFunction>(Requirements);
return true;
}
bool OpenXRRuntime::CreateSession(const void*) {
m_session = reinterpret_cast<XrSession>(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<float>(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<Swapchain*>(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<const XrCompositionLayerProjection*>(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<float>(displayed_content));
Require(view.fov.angleLeft == -0.75f);
}
} else {
Require(layer_type == XR_TYPE_COMPOSITION_LAYER_QUAD);
const auto& quad = *reinterpret_cast<const XrCompositionLayerQuad*>(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";
}