From bd106dd1fe4f590f6f96c1a2dd1aae1737bb56ce Mon Sep 17 00:00:00 2001 From: iChris4 Date: Tue, 8 Sep 2026 21:24:37 +0200 Subject: [PATCH] Anchored Main Menu Quad --- runtime/include/runtime_config.h | 10 ++-- runtime/include/vr/openxr_d3d12.h | 16 +++++-- runtime/include/vr/openxr_integration.h | 13 +++--- runtime/src/settings_overlay.cpp | 8 ++-- runtime/src/vr/openxr_d3d12.cpp | 16 +++++-- runtime/src/vr/openxr_integration.cpp | 61 +++++++++++++++++++++---- 6 files changed, 94 insertions(+), 30 deletions(-) diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h index fb76afb..07d5766 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -386,11 +386,11 @@ inline void EnsureConfigFile() { "# model of your kart, the vehicle included.\n" "first_person_hidden_model = 0\n" "# Keyboard shortcut that recenters the VR view, naming the key the\n" - "# way SDL does (F9, Home, Keypad 5, ...). It moves the view to where\n" - "# you are sitting now and nothing else: forward and the horizon come\n" - "# from the headset's own reference space, so recentering cannot tilt\n" - "# the game. Applies during an immersive race; the menu screen is\n" - "# head-locked already. Rebindable from the F10 menu under VR. Leave\n" + "# way SDL does (F9, Home, Keypad 5, ...). It moves the race view to\n" + "# where you are sitting now and brings the menu screen back upright in\n" + "# front of you. The race view moves in position only: forward and the\n" + "# horizon come from the headset's own reference space, so recentering\n" + "# cannot tilt the game. Rebindable from the F10 menu under VR. Leave\n" "# empty to unbind.\n" "recenter_key = \"F9\"\n" "# Fixed pitch of the game camera, in degrees, for a player sitting\n" diff --git a/runtime/include/vr/openxr_d3d12.h b/runtime/include/vr/openxr_d3d12.h index 9b22462..f58c52e 100644 --- a/runtime/include/vr/openxr_d3d12.h +++ b/runtime/include/vr/openxr_d3d12.h @@ -41,10 +41,17 @@ struct OpenXRD3D12GraphicsRequirements { struct OpenXRD3D12Presentation { OpenXRD3D12FrameMode mode = OpenXRD3D12FrameMode::ImmersiveProjection; - // Used only by VirtualScreen. The quad is head-locked in XR_VIEW_SPACE - // and centered straight ahead at -Z. + // Used only by VirtualScreen. float quad_distance_meters = 2.0f; float quad_width_meters = 2.4f; + + // When quad_anchored is set, the quad is placed at quad_pose in the + // application reference space and stays put as the player looks around. + // Otherwise it falls back to being head-locked in XR_VIEW_SPACE, centered + // straight ahead at -Z, which is what happens until tracking has produced a + // head pose good enough to anchor against. + bool quad_anchored = false; + XrPosef quad_pose{{0.0f, 0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 0.0f}}; }; struct OpenXRD3D12Frame { @@ -96,8 +103,9 @@ public: // Releases acquired images and calls xrEndFrame. submit_layer must only be // true after WaitForSubmission returned Success. Immersive frames submit - // XrCompositionLayerProjection; virtual-screen frames submit a head-locked - // XrCompositionLayerQuad using the single mono target. + // XrCompositionLayerProjection; virtual-screen frames submit an + // XrCompositionLayerQuad using the single mono target, placed as the + // presentation's quad_anchored/quad_pose describe. 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 7132754..d0c5a81 100644 --- a/runtime/include/vr/openxr_integration.h +++ b/runtime/include/vr/openxr_integration.h @@ -36,12 +36,13 @@ void OpenXRServiceProducerFrameBoundary() noexcept; bool OpenXRIsRunning() noexcept; std::string OpenXRLastError(); -// Recenters the view on where the player is sitting now. Position only: the -// forward direction and the horizon come from the OpenXR reference space and -// are never relatched from the headset, so recentering cannot tilt the game's -// horizon. Use the runtime's own recenter gesture to change forward. Callable -// from any thread; serviced on the XR pacing thread and ignored outside an -// immersive race, where the head-locked menu screen has no origin to recenter. +// Recenters on where the player is sitting now: it moves the immersive race +// view's origin, and re-places the anchored menu screen upright in front of +// them. Position only for the race view: the forward direction and the horizon +// come from the OpenXR reference space and are never relatched from the +// headset, so recentering cannot tilt the game's horizon. Use the runtime's own +// recenter gesture to change forward. Callable from any thread; serviced once +// per frame on the XR pacing thread. void OpenXRRequestRecenter() noexcept; // Sets a fixed pitch of the game camera for a player sitting reclined, in diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 11d12d5..490484c 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -885,10 +885,10 @@ void DrawVrSettings() { } if (ImGui::IsItemHovered()) { ImGui::SetTooltip( - "Makes where you are sitting right now the centre of the view. Position " - "only, so the horizon stays level and forward is unchanged; use your " - "headset's own recenter to change forward. Applies during an immersive " - "race, as the menu screen already follows your head."); + "Makes where you are sitting right now the centre of the view, and brings " + "the menu screen back upright in front of you. The race view moves in " + "position only, so the horizon stays level and forward is unchanged; use " + "your headset's own recenter to change forward."); } ImGui::SameLine(); // Click to arm, then the next key press is captured in HandleEvents. diff --git a/runtime/src/vr/openxr_d3d12.cpp b/runtime/src/vr/openxr_d3d12.cpp index 93badea..9f38ecf 100644 --- a/runtime/src/vr/openxr_d3d12.cpp +++ b/runtime/src/vr/openxr_d3d12.cpp @@ -404,15 +404,25 @@ public: } else if (frame.presentation.mode == OpenXRD3D12FrameMode::VirtualScreen) { XrCompositionLayerQuad quad{XR_TYPE_COMPOSITION_LAYER_QUAD}; quad.layerFlags = 0; - quad.space = runtime_->ViewSpace(); quad.eyeVisibility = XR_EYE_VISIBILITY_BOTH; quad.subImage.swapchain = eye_swapchains_[0].handle; quad.subImage.imageRect = {{0, 0}, {static_cast(eye_swapchains_[0].width), static_cast(eye_swapchains_[0].height)}}; quad.subImage.imageArrayIndex = 0; - quad.pose.orientation = {0.0f, 0.0f, 0.0f, 1.0f}; - quad.pose.position = {0.0f, 0.0f, -std::max(0.25f, frame.presentation.quad_distance_meters)}; + if (frame.presentation.quad_anchored) { + // Placed in the application space, so the screen keeps its place + // in the room while the player looks around it. + quad.space = runtime_->AppSpace(); + quad.pose = frame.presentation.quad_pose; + } else { + // No head pose to anchor against yet: keep it in front of the + // player so the menus are never left stranded behind them. + quad.space = runtime_->ViewSpace(); + quad.pose.orientation = {0.0f, 0.0f, 0.0f, 1.0f}; + quad.pose.position = { + 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); diff --git a/runtime/src/vr/openxr_integration.cpp b/runtime/src/vr/openxr_integration.cpp index ad09968..1e64d3f 100644 --- a/runtime/src/vr/openxr_integration.cpp +++ b/runtime/src/vr/openxr_integration.cpp @@ -135,6 +135,21 @@ std::array CenterPosition(const OpenXRFrame& frame) noexcept { }; } +// Places an upright screen `distance` metres ahead of the head. Only the +// head's yaw is used, so the screen is never pitched or rolled by whatever the +// player's head happened to be doing when it was anchored. +XrPosef ScreenPoseAhead(const OpenXRFrame& frame, float distance) noexcept { + const auto& q = frame.views[0].pose.orientation; + const float yaw = + std::atan2(2.0f * (q.x * q.z + q.w * q.y), 1.0f - 2.0f * (q.x * q.x + q.y * q.y)); + const std::array center = CenterPosition(frame); + XrPosef pose{}; + pose.orientation = {0.0f, std::sin(yaw * 0.5f), 0.0f, std::cos(yaw * 0.5f)}; + pose.position = {center[0] - std::sin(yaw) * distance, center[1], + center[2] - std::cos(yaw) * distance}; + return pose; +} + void IdentityEye(AuroraStereoEye& eye) noexcept { std::fill(std::begin(eye.projection), std::end(eye.projection), 0.0f); eye.projection[0] = 1.0f; @@ -492,6 +507,11 @@ private: break; } + // Both of these read this frame's located head pose and must run + // before FinishFrame submits a layer built from it. + ServiceRecenterRequest(); + UpdateVirtualScreenPose(frame); + if (!frame.expects_gpu_submission) { if (!backend_->FinishFrame(frame, false)) { SetError(backend_->LastError()); @@ -603,20 +623,12 @@ private: IdentityEye(destination.eyes[eye]); } if (!immersive) { - // The virtual screen is submitted in view space, so it is - // head-locked and has no origin to recenter. Drop the request rather - // than leaving it queued for a race start that latches the position - // on its own anyway. - recenter_requested_.store(false, std::memory_order_relaxed); last_immersive_ = false; return; } const bool position_valid = (source.xr_frame.view_state_flags & XR_VIEW_STATE_POSITION_VALID_BIT) != 0; - if (recenter_requested_.exchange(false, std::memory_order_acq_rel)) { - ResetTrackingOrigin(); - } // Latch on the first immersive frame, after a recenter or an origin // change, and on re-entry from the virtual screen so a race start // recenters a player who shifted during the menus. Position only: the @@ -640,6 +652,34 @@ private: } } + // Runs once per located frame, before the virtual screen is placed and + // before the immersive origin is latched, so a recenter reaches both from + // this frame's head pose rather than the next one's. + void ServiceRecenterRequest() noexcept { + if (recenter_requested_.exchange(false, std::memory_order_acq_rel)) { + ResetTrackingOrigin(); + } + } + + // Anchors the menu screen in the application space and holds it there. The + // pose is captured once, from the first frame whose head pose is good enough + // to place it, and released again by a recenter or an origin change. + void UpdateVirtualScreenPose(OpenXRD3D12Frame& frame) noexcept { + if (frame.presentation.mode != OpenXRD3D12FrameMode::VirtualScreen) { + return; + } + constexpr XrViewStateFlags kPoseUsable = + XR_VIEW_STATE_ORIENTATION_VALID_BIT | XR_VIEW_STATE_POSITION_VALID_BIT; + if (!virtual_screen_pose_valid_ && frame.xr_frame.views_valid && + (frame.xr_frame.view_state_flags & kPoseUsable) == kPoseUsable) { + virtual_screen_pose_ = ScreenPoseAhead( + frame.xr_frame, std::max(0.25f, frame.presentation.quad_distance_meters)); + virtual_screen_pose_valid_ = true; + } + frame.presentation.quad_anchored = virtual_screen_pose_valid_; + frame.presentation.quad_pose = virtual_screen_pose_; + } + void ApplyPendingReferenceSpaceChange(const OpenXRFrame& frame) noexcept { if (runtime_->ConsumeAppSpaceChangesThrough(frame.predicted_display_time)) { ResetTrackingOrigin(); @@ -650,6 +690,9 @@ private: base_position_ = {}; base_position_valid_ = false; last_immersive_ = false; + // The anchored menu screen is placed in the same space, so it is stale + // for exactly the same reasons and is re-placed on the next frame. + virtual_screen_pose_valid_ = false; } void WaitForStopOrDelay(std::chrono::milliseconds delay) { @@ -689,6 +732,8 @@ private: std::string last_error_; std::array base_position_{}; bool base_position_valid_ = false; + XrPosef virtual_screen_pose_{{0.0f, 0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 0.0f}}; + bool virtual_screen_pose_valid_ = false; bool last_immersive_ = false; uint64_t applied_session_run_serial_ = 0; bool session_was_active_ = false;