From 913eda2b3a3a8708283b86a02f3f529c0c9242ac Mon Sep 17 00:00:00 2001 From: iChris4 Date: Tue, 8 Sep 2026 20:58:49 +0200 Subject: [PATCH] Added "Lean back angle" option --- runtime/include/runtime_config.h | 29 +++++++++++++- runtime/include/vr/openxr_integration.h | 8 ++++ runtime/src/settings_overlay.cpp | 15 +++++++ runtime/src/vr/openxr_integration.cpp | 52 +++++++++++++++++++++++-- 4 files changed, 99 insertions(+), 5 deletions(-) diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h index 64e3dda..fb76afb 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -63,6 +63,7 @@ struct RuntimeUserConfig { std::optional vrFirstPersonHideDriver; std::optional vrFirstPersonHiddenModel; std::optional vrRecenterKey; + std::optional vrLeanBackDegrees; std::optional audioVolume; std::optional audioMusicVolume; std::optional audioSoundEffectsVolume; @@ -150,6 +151,10 @@ inline constexpr float kVrFirstPersonHeadOffsetLimit = 10.0f; // SDL scancode name, spelled the way SDL_GetScancodeName produces it. An // empty string leaves the recenter hotkey unbound, menu button only. inline constexpr std::string_view kVrRecenterKeyDefault = "F9"; +// Fixed pitch of the game camera for a player sitting reclined, in degrees. +// Positive leans the camera back with you; 0 disables it entirely. +inline constexpr float kVrLeanBackDegreesDefault = 0.0f; +inline constexpr float kVrLeanBackDegreesLimit = 45.0f; inline std::string Trim(std::string_view text) { size_t begin = 0; @@ -387,7 +392,12 @@ inline void EnsureConfigFile() { "# the game. Applies during an immersive race; the menu screen is\n" "# head-locked already. Rebindable from the F10 menu under VR. Leave\n" "# empty to unbind.\n" - "recenter_key = \"F9\"\n\n" + "recenter_key = \"F9\"\n" + "# Fixed pitch of the game camera, in degrees, for a player sitting\n" + "# reclined. Positive tilts the camera back with you, so 20 here cancels\n" + "# reclining about 20 degrees and puts the track back in front of you.\n" + "# 0 disables it. Changeable live from the F10 menu under VR.\n" + "lean_back_degrees = 0.0\n\n" "[audio]\n" "volume = 1.0\n" "music_volume = 1.0\n" @@ -573,6 +583,10 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) { if (auto value = FindConfigValue(document, "vr", "recenter_key")) { config.vrRecenterKey = *value; } + if (auto value = FindConfigFloat(document, "vr", "lean_back_degrees"); + value && *value >= -kVrLeanBackDegreesLimit && *value <= kVrLeanBackDegreesLimit) { + config.vrLeanBackDegrees = static_cast(*value); + } if (auto value = FindConfigInt(document, "vr", "first_person_hidden_model"); value && *value >= -1 && *value <= 31) { config.vrFirstPersonHiddenModel = static_cast(*value); @@ -853,6 +867,14 @@ inline bool SetVrRecenterKey(std::string value) { return WriteSetting("vr", "recenter_key", FormatString(value)); } +inline bool SetVrLeanBackDegrees(float value) { + value = std::clamp(value, -kVrLeanBackDegreesLimit, kVrLeanBackDegreesLimit); + Mutable().vrLeanBackDegrees = value; + std::ostringstream formatted; + formatted << value; + return WriteSetting("vr", "lean_back_degrees", formatted.str()); +} + inline bool SetVrFirstPersonHideDriver(bool value) { Mutable().vrFirstPersonHideDriver = value; return WriteSetting("vr", "first_person_hide_driver", value ? "true" : "false"); @@ -1149,6 +1171,11 @@ inline std::string VrRecenterKey(std::string fallback = std::string(kVrRecenterK return Get().vrRecenterKey.value_or(std::move(fallback)); } +inline float VrLeanBackDegrees(float fallback = kVrLeanBackDegreesDefault) { + return std::clamp(Get().vrLeanBackDegrees.value_or(fallback), + -kVrLeanBackDegreesLimit, kVrLeanBackDegreesLimit); +} + inline bool VrFirstPersonHideDriver(bool fallback = kVrFirstPersonHideDriverDefault) { return Get().vrFirstPersonHideDriver.value_or(fallback); } diff --git a/runtime/include/vr/openxr_integration.h b/runtime/include/vr/openxr_integration.h index e48073b..7132754 100644 --- a/runtime/include/vr/openxr_integration.h +++ b/runtime/include/vr/openxr_integration.h @@ -44,4 +44,12 @@ std::string OpenXRLastError(); // immersive race, where the head-locked menu screen has no origin to recenter. void OpenXRRequestRecenter() noexcept; +// Sets a fixed pitch of the game camera for a player sitting reclined, in +// degrees, clamped to RuntimeConfigFile::kVrLeanBackDegreesLimit. Positive +// tilts the camera back with the player, so an angle matching how far they are +// reclined puts the track back in front of them. 0 applies no pitch at all. +// Applies to the immersive race view only; callable from any thread and read +// once per published frame. +void OpenXRSetLeanBackDegrees(float degrees) noexcept; + } // namespace mkw::vr diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 84a2402..11d12d5 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -121,6 +121,7 @@ SDL_Scancode g_vrRecenterScancode = [] { : SDL_GetScancodeFromName(name.c_str()); }(); bool g_vrRecenterRebinding = false; +float g_vrLeanBackDegrees = RuntimeConfigFile::VrLeanBackDegrees(); uint32_t g_disabledPostProcessingPaths = RuntimeConfigFile::DisabledPostProcessingPaths(0); std::array g_configuredControllerIndices = [] { std::array indices{}; @@ -909,6 +910,20 @@ void DrawVrSettings() { ImGui::SetTooltip("Click to rebind. Esc cancels, Backspace unbinds."); } } + if (ImGui::SliderFloat("Lean back angle (deg)", &g_vrLeanBackDegrees, + -RuntimeConfigFile::kVrLeanBackDegreesLimit, + RuntimeConfigFile::kVrLeanBackDegreesLimit, "%.1f")) { + mkw::vr::OpenXRSetLeanBackDegrees(g_vrLeanBackDegrees); + RuntimeConfigFile::SetVrLeanBackDegrees(g_vrLeanBackDegrees); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip( + "Tilts the game camera back with you when you play reclined, so set it to " + "roughly how far back your seat is and the track comes back in front of you " + "instead of above. 0 applies no tilt. Unlike recentering, this deliberately " + "does pitch the view, and looking sideways while it is set will roll the " + "horizon the way a real recline would."); + } ImGui::Separator(); ImGui::Text("VR camera"); if (ImGui::Checkbox("First-person camera", &g_vrFirstPerson)) { diff --git a/runtime/src/vr/openxr_integration.cpp b/runtime/src/vr/openxr_integration.cpp index dbc1fb7..ad09968 100644 --- a/runtime/src/vr/openxr_integration.cpp +++ b/runtime/src/vr/openxr_integration.cpp @@ -37,6 +37,8 @@ namespace mkw::vr { namespace { +inline constexpr float kDegreesToRadians = 0.01745329252f; + void ConfigurePolicy(bool enabled) noexcept { MkwVRPolicyReset(); MkwVRPolicyConfig config{}; @@ -78,6 +80,15 @@ Quaternion Conjugate(Quaternion value) noexcept { return {-value.x, -value.y, -value.z, value.w}; } +Quaternion Multiply(const Quaternion& left, const Quaternion& right) noexcept { + return Normalize({ + left.w * right.x + left.x * right.w + left.y * right.z - left.z * right.y, + left.w * right.y - left.x * right.z + left.y * right.w + left.z * right.x, + left.w * right.z + left.x * right.y - left.y * right.x + left.z * right.w, + left.w * right.w - left.x * right.x - left.y * right.y - left.z * right.z, + }); +} + std::array Rotate(const Quaternion& q, const std::array& value) noexcept { // Expanded q * [v,0] * conjugate(q), avoiding two temporary normalizations. const float tx = 2.0f * (q.y * value[2] - q.z * value[1]); @@ -155,13 +166,26 @@ void ProjectionFromFov(const XrFovf& fov, float output[16]) noexcept { // leaves the game's horizon level and its forward fixed to the reference space. // Composing a latched head orientation in here instead would bake that instant's // pitch and roll into the neutral and tilt the horizon for the rest of the session. +// +// lean_back_radians is the one deliberate exception: a fixed pitch of the game +// camera about the reference space's right axis, for a player sitting reclined. +// It multiplies in on the right, so it turns the world before the head rotation +// rather than after it, which is what makes it cancel a reclined head exactly +// and, when you then look sideways, roll the view the way a real recline would. void ViewFromBase(const XrPosef& eye_pose, const std::array& base_position, - bool position_valid, float units_per_meter, float output[12]) noexcept { + bool position_valid, float units_per_meter, float lean_back_radians, + float output[12]) noexcept { const Quaternion eye = Normalize({eye_pose.orientation.x, eye_pose.orientation.y, eye_pose.orientation.z, eye_pose.orientation.w}); const Quaternion inverse_eye = Conjugate(eye); float rotation[9]; - RotationMatrix(inverse_eye, rotation); + if (lean_back_radians == 0.0f) { + RotationMatrix(inverse_eye, rotation); + } else { + const float half_angle = 0.5f * lean_back_radians; + const Quaternion lean{std::sin(half_angle), 0.0f, 0.0f, std::cos(half_angle)}; + RotationMatrix(Multiply(inverse_eye, lean), rotation); + } std::array translation{}; if (position_valid) { @@ -326,6 +350,13 @@ public: recenter_requested_.store(true, std::memory_order_release); } + void SetLeanBackDegrees(float degrees) noexcept { + lean_back_degrees_.store( + std::clamp(degrees, -RuntimeConfigFile::kVrLeanBackDegreesLimit, + RuntimeConfigFile::kVrLeanBackDegreesLimit), + std::memory_order_relaxed); + } + void ServiceProducerFrameBoundary() noexcept { if (teardown_requested_.load(std::memory_order_acquire)) { Shutdown(); @@ -596,12 +627,16 @@ private: base_position_valid_ = true; } last_immersive_ = true; + // Read once so both eyes are built from the same angle even if the + // settings slider moves between them. + const float lean_back_radians = + lean_back_degrees_.load(std::memory_order_relaxed) * kDegreesToRadians; for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { ProjectionFromFov(source.xr_frame.views[eye].fov, destination.eyes[eye].projection); ViewFromBase(source.xr_frame.views[eye].pose, base_position_, - position_valid && base_position_valid_, - units_per_meter, destination.eyes[eye].viewFromCenter); + position_valid && base_position_valid_, units_per_meter, + lean_back_radians, destination.eyes[eye].viewFromCenter); } } @@ -644,6 +679,7 @@ private: std::atomic_bool running_{false}; std::atomic_bool teardown_requested_{false}; std::atomic_bool recenter_requested_{false}; + std::atomic lean_back_degrees_{RuntimeConfigFile::VrLeanBackDegrees()}; std::atomic published_{nullptr}; PublishedFrame published_frame_{}; std::mutex published_mutex_; @@ -722,6 +758,14 @@ void OpenXRRequestRecenter() noexcept { #endif } +void OpenXRSetLeanBackDegrees(float degrees) noexcept { +#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32) + OpenXRIntegration::Get().SetLeanBackDegrees(degrees); +#else + (void)degrees; +#endif +} + std::string OpenXRLastError() { #if !defined(MKW_ENABLE_OPENXR) return "this build was compiled without OpenXR support";