From 50dc3541235e5d030ca49b6c21115251a9654105 Mon Sep 17 00:00:00 2001 From: iChris4 Date: Tue, 22 Sep 2026 01:53:22 +0200 Subject: [PATCH] Add the cockpit, steering-wheel and hand-steering building blocks The pure pieces of the first-person cockpit and hand steering, ported from heurazy's mario-kart-wii-VR-port: the SteeringWheel grab/turn model, the native wheel vertex rotation, the level seat stabiliser, the seated-eye and wheel/handlebar geometry, and the XR_FB_hand_tracking_mesh loader. Adds openxr_driving.h, the OpenXR-free snapshot the pacing thread will publish for the guest thread, with the hand-off rule (a held wheel replaces the left stick's X and releases that hand's grip for the game) and the wheel's displayed angle. New [vr] keys: first_person_seat (cockpit), cockpit_units_per_meter (100), steering_wheel (true), native_steering_wheel (true), hand_steering (false) and the seven wheel_* tuning keys. Nothing reads them yet. The fresh-config template now writes the first-person defaults the constants hold (50 / 1.5 / 0); d86dcb0 updated the constants but not the template. Tests: mkw_steering_wheel_tests (the fork's), mkw_vr_cockpit_tests, mkw_vr_hand_steering_tests. --- runtime/CMakeLists.txt | 11 + runtime/include/runtime_config.h | 201 ++++++++++++++++- runtime/include/vr/cockpit_stabilizer.h | 40 ++++ runtime/include/vr/mkw_vr_first_person.h | 261 +++++++++++++++++++++++ runtime/include/vr/native_wheel_mesh.h | 45 ++++ runtime/include/vr/openxr_driving.h | 164 ++++++++++++++ runtime/include/vr/openxr_hand_mesh.h | 75 +++++++ runtime/include/vr/steering_wheel.h | 163 ++++++++++++++ runtime/src/vr/openxr_driving.cpp | 35 +++ runtime/tests/steering_wheel_tests.cpp | 224 +++++++++++++++++++ runtime/tests/vr_cockpit_tests.cpp | 212 ++++++++++++++++++ runtime/tests/vr_hand_steering_tests.cpp | 124 +++++++++++ 12 files changed, 1547 insertions(+), 8 deletions(-) create mode 100644 runtime/include/vr/cockpit_stabilizer.h create mode 100644 runtime/include/vr/native_wheel_mesh.h create mode 100644 runtime/include/vr/openxr_driving.h create mode 100644 runtime/include/vr/openxr_hand_mesh.h create mode 100644 runtime/include/vr/steering_wheel.h create mode 100644 runtime/src/vr/openxr_driving.cpp create mode 100644 runtime/tests/steering_wheel_tests.cpp create mode 100644 runtime/tests/vr_cockpit_tests.cpp create mode 100644 runtime/tests/vr_hand_steering_tests.cpp diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 851406d..7b187fa 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -407,6 +407,17 @@ 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) +# The first-person cockpit (seated eye, wheel and handlebar geometry, level seat, +# native wheel vertices) and hand steering (grab, turn, hand-off to the game), +# ported from heurazy's mario-kart-wii-VR-port. All header-only. +foreach(test_name mkw_steering_wheel_tests mkw_vr_cockpit_tests mkw_vr_hand_steering_tests) + string(REGEX REPLACE "^mkw_" "" test_source "${test_name}") + add_executable(${test_name} "${CMAKE_CURRENT_LIST_DIR}/tests/${test_source}.cpp") + target_include_directories(${test_name} PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") + target_compile_features(${test_name} PRIVATE cxx_std_17) + add_test(NAME ${test_name} COMMAND ${test_name}) +endforeach() + # The VR controllers' Wii Remote presentation (accelerometer frame, pointer # raycast, debounce, button profile) is header-only for the same reason. add_executable(mkw_vr_wii_remote_tests "${CMAKE_CURRENT_LIST_DIR}/tests/vr_wii_remote_tests.cpp") diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h index 66401e8..fa68da7 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -21,6 +21,7 @@ #include #include "platform/host_platform.h" #include "vr/frame_interpolation_pacing.h" +#include "vr/steering_wheel.h" #ifdef _WIN32 #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN @@ -69,6 +70,18 @@ struct RuntimeUserConfig { std::optional vrFirstPersonHideDriver; std::optional vrFirstPersonHiddenModel; std::optional vrFirstPersonRotation; + std::optional vrFirstPersonSeat; + std::optional vrCockpitUnitsPerMeter; + std::optional vrSteeringWheel; + std::optional vrNativeSteeringWheel; + std::optional vrHandSteering; + std::optional vrWheelKartDegrees; + std::optional vrWheelBikeDegrees; + std::optional vrWheelGrabDistance; + std::optional vrWheelGrabAssist; + std::optional vrWheelResponse; + std::optional vrWheelTrackingGrace; + std::optional vrWheelHaptics; std::optional vrPerformanceLevel; std::optional vrRecenterKey; std::optional vrLeanBackDegrees; @@ -192,6 +205,31 @@ inline constexpr const char* kVrFirstPersonRotationDefault = "yaw"; inline bool IsSupportedVrFirstPersonRotation(std::string_view value) { return value == "yaw" || value == "yaw_pitch" || value == "full"; } +// Where the first-person head sits, matching FirstPersonSeat: "cockpit" at the +// driver's own eyes behind the wheel, "custom" at the head offsets above. +inline constexpr const char* kVrFirstPersonSeatDefault = "cockpit"; + +inline bool IsSupportedVrFirstPersonSeat(std::string_view value) { + return value == "cockpit" || value == "custom"; +} +// The cockpit seat's world scale before the character's height is allowed for. +inline constexpr float kVrCockpitUnitsPerMeterDefault = 100.0f; +inline constexpr float kVrCockpitUnitsPerMeterMin = 20.0f; +inline constexpr float kVrCockpitUnitsPerMeterMax = 400.0f; +// The vehicle's steering wheel or handlebar turns with the steering; the +// vehicle's own model is animated unless native_steering_wheel is off, which +// draws a separate VR wheel instead. Hand steering (grabbing that wheel with +// the tracked controllers, by heurazy) is opt-in. The WheelWizard VR launcher +// registers hand_steering with this same default. +inline constexpr bool kVrSteeringWheelDefault = true; +inline constexpr bool kVrNativeSteeringWheelDefault = true; +inline constexpr bool kVrHandSteeringDefault = false; +// Hand steering tuning ranges; the defaults are mkw::vr::WheelTuning's. +inline constexpr float kVrWheelDegreesMin = 20.0f, kVrWheelDegreesMax = 180.0f; +inline constexpr float kVrWheelGrabDistanceMin = 0.15f, kVrWheelGrabDistanceMax = 0.8f; +inline constexpr float kVrWheelGrabAssistMin = 0.7f, kVrWheelGrabAssistMax = 2.0f; +inline constexpr float kVrWheelResponseMin = 0.5f, kVrWheelResponseMax = 2.0f; +inline constexpr float kVrWheelTrackingGraceMin = 0.05f, kVrWheelTrackingGraceMax = 0.5f; // The performance level asked of the OpenXR runtime (XR_EXT_performance_settings) for its CPU and // GPU domains. Standalone headsets clock their cores by this request: a Quest 3 ran the game // thread at 1.92 GHz with the runtime's own choice while its fast cores reach 2.36 GHz. "default" @@ -462,14 +500,21 @@ inline void EnsureConfigFile() { "skip_copy_clears = true\n" "# Put the camera at the Player 1 driver's head instead of behind\n" "# the kart, with the horizon kept level. Changeable live from the\n" - "# F10 menu, and only during a single-screen race. The world scale\n" - "# below replaces world_units_per_meter while it is engaged: 10 is\n" - "# life-size, where the 500 above makes the race a small diorama.\n" + "# F10 menu, and only during a single-screen race.\n" "first_person = false\n" - "first_person_units_per_meter = 30.0\n" - "# Where the head sits in the kart's own frame, in metres.\n" - "first_person_head_up_meters = 3.0\n" - "first_person_head_forward_meters = 12.0\n" + "# Where the head sits: \"cockpit\" puts it at the driver's own eyes,\n" + "# behind the steering wheel, at a life-size scale that allows for\n" + "# the character's height, so the wheel is within reach.\n" + "# \"custom\" uses the world scale and head offsets below instead.\n" + "first_person_seat = \"cockpit\"\n" + "cockpit_units_per_meter = 100.0\n" + "# The custom seat's world scale, replacing world_units_per_meter\n" + "# while first person is engaged; the 500 above makes the race a\n" + "# small diorama.\n" + "first_person_units_per_meter = 50.0\n" + "# Where the custom seat's head sits in the kart's own frame, in metres.\n" + "first_person_head_up_meters = 1.5\n" + "first_person_head_forward_meters = 0.0\n" "first_person_head_right_meters = 0.0\n" "# In first person the driver sits where your eyes are. Hiding\n" "# the driver removes the head that would otherwise be in the\n" @@ -481,7 +526,28 @@ inline void EnsureConfigFile() { "# Where the view's orientation comes from: \"yaw\" levels the\n" "# horizon, \"yaw_pitch\" adds the kart's climb but no roll, and\n" "# \"full\" takes the kart's whole orientation so the view banks.\n" - "first_person_rotation = \"yaw\"\n\n" + "first_person_rotation = \"yaw\"\n" + "# In the cockpit the vehicle's steering wheel or handlebar turns\n" + "# with the steering. native_steering_wheel animates the vehicle's\n" + "# own model; false draws a separate VR wheel instead.\n" + "steering_wheel = true\n" + "native_steering_wheel = true\n" + "# Hand steering (by heurazy): squeeze a grip near the wheel or\n" + "# handlebar to take hold of it with the tracked controllers, and\n" + "# turn it to steer. Releasing both grips gives steering back to the\n" + "# stick. The tuning below: degrees of turn for full lock on karts\n" + "# and bikes, how far (metres) and how generously a grip reaches\n" + "# the wheel, how quickly the wheel follows the hands, how long\n" + "# (seconds) a hand that loses tracking keeps hold, and a short\n" + "# pulse on grab and release. All changeable live from the F10 menu.\n" + "hand_steering = false\n" + "wheel_kart_degrees = 90.0\n" + "wheel_bike_degrees = 45.0\n" + "wheel_grab_distance = 0.35\n" + "wheel_grab_assist = 1.0\n" + "wheel_response = 1.0\n" + "wheel_tracking_grace = 0.2\n" + "wheel_haptics = true\n\n" "# Performance level asked of the headset's runtime for its CPU and\n" "# GPU: \"boost\", \"sustained_high\", \"sustained_low\", \"power_savings\",\n" "# or \"default\" to leave the runtime's own choice. Standalone headsets\n" @@ -730,6 +796,28 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) { value && *value >= -1 && *value <= 31) { config.vrFirstPersonHiddenModel = static_cast(*value); } + if (auto value = FindConfigValue(document, "vr", "first_person_seat"); + value && IsSupportedVrFirstPersonSeat(*value)) { + config.vrFirstPersonSeat = *value; + } + const auto readRangedFloat = [&](std::string_view key, float low, float high) -> std::optional { + auto value = FindConfigFloat(document, "vr", key); + return value && *value >= low && *value <= high ? value : std::nullopt; + }; + config.vrCockpitUnitsPerMeter = + readRangedFloat("cockpit_units_per_meter", kVrCockpitUnitsPerMeterMin, kVrCockpitUnitsPerMeterMax); + config.vrSteeringWheel = FindConfigValue(document, "vr", "steering_wheel"); + config.vrNativeSteeringWheel = FindConfigValue(document, "vr", "native_steering_wheel"); + config.vrHandSteering = FindConfigValue(document, "vr", "hand_steering"); + config.vrWheelKartDegrees = readRangedFloat("wheel_kart_degrees", kVrWheelDegreesMin, kVrWheelDegreesMax); + config.vrWheelBikeDegrees = readRangedFloat("wheel_bike_degrees", kVrWheelDegreesMin, kVrWheelDegreesMax); + config.vrWheelGrabDistance = + readRangedFloat("wheel_grab_distance", kVrWheelGrabDistanceMin, kVrWheelGrabDistanceMax); + config.vrWheelGrabAssist = readRangedFloat("wheel_grab_assist", kVrWheelGrabAssistMin, kVrWheelGrabAssistMax); + config.vrWheelResponse = readRangedFloat("wheel_response", kVrWheelResponseMin, kVrWheelResponseMax); + config.vrWheelTrackingGrace = + readRangedFloat("wheel_tracking_grace", kVrWheelTrackingGraceMin, kVrWheelTrackingGraceMax); + config.vrWheelHaptics = FindConfigValue(document, "vr", "wheel_haptics"); config.diagnosticsOpenXRLogging = FindConfigValue(document, "diagnostics", "openxr_logging"); auto readVolume = [&](std::string_view key) -> std::optional { @@ -1058,6 +1146,62 @@ inline bool SetVrPerformanceLevel(std::string value) { return WriteSetting("vr", "performance_level", FormatString(value)); } +inline bool SetVrFirstPersonSeat(std::string value) { + if (!IsSupportedVrFirstPersonSeat(value)) { + return false; + } + Mutable().vrFirstPersonSeat = value; + return WriteSetting("vr", "first_person_seat", FormatString(value)); +} + +inline bool SetVrCockpitUnitsPerMeter(float value) { + value = std::clamp(value, kVrCockpitUnitsPerMeterMin, kVrCockpitUnitsPerMeterMax); + Mutable().vrCockpitUnitsPerMeter = value; + std::ostringstream formatted; + formatted << value; + return WriteSetting("vr", "cockpit_units_per_meter", formatted.str()); +} + +inline bool SetVrSteeringWheel(bool value) { + Mutable().vrSteeringWheel = value; + return WriteSetting("vr", "steering_wheel", value ? "true" : "false"); +} + +inline bool SetVrNativeSteeringWheel(bool value) { + Mutable().vrNativeSteeringWheel = value; + return WriteSetting("vr", "native_steering_wheel", value ? "true" : "false"); +} + +inline bool SetVrHandSteering(bool value) { + Mutable().vrHandSteering = value; + return WriteSetting("vr", "hand_steering", value ? "true" : "false"); +} + +inline bool SetVrWheelTuning(const mkw::vr::WheelTuning& tuning) { + auto& config = Mutable(); + const auto write = [](const char* key, std::optional& slot, float value, float low, float high) { + value = std::clamp(value, low, high); + slot = value; + std::ostringstream formatted; + formatted << value; + return WriteSetting("vr", key, formatted.str()); + }; + bool ok = write("wheel_kart_degrees", config.vrWheelKartDegrees, tuning.kartDegrees, kVrWheelDegreesMin, + kVrWheelDegreesMax); + ok = write("wheel_bike_degrees", config.vrWheelBikeDegrees, tuning.bikeDegrees, kVrWheelDegreesMin, + kVrWheelDegreesMax) && ok; + ok = write("wheel_grab_distance", config.vrWheelGrabDistance, tuning.grabDistance, kVrWheelGrabDistanceMin, + kVrWheelGrabDistanceMax) && ok; + ok = write("wheel_grab_assist", config.vrWheelGrabAssist, tuning.grabAssist, kVrWheelGrabAssistMin, + kVrWheelGrabAssistMax) && ok; + ok = write("wheel_response", config.vrWheelResponse, tuning.response, kVrWheelResponseMin, + kVrWheelResponseMax) && ok; + ok = write("wheel_tracking_grace", config.vrWheelTrackingGrace, tuning.trackingGrace, + kVrWheelTrackingGraceMin, kVrWheelTrackingGraceMax) && ok; + config.vrWheelHaptics = tuning.haptics; + return WriteSetting("vr", "wheel_haptics", tuning.haptics ? "true" : "false") && ok; +} + inline bool SetVrFirstPersonHiddenModel(int32_t value) { value = std::clamp(value, -1, 31); Mutable().vrFirstPersonHiddenModel = value; @@ -1435,6 +1579,47 @@ inline int32_t VrFirstPersonHiddenModel(int32_t fallback = kVrFirstPersonHiddenM return std::clamp(Get().vrFirstPersonHiddenModel.value_or(fallback), -1, 31); } +inline std::string VrFirstPersonSeat(std::string fallback = kVrFirstPersonSeatDefault) { + const auto& value = Get().vrFirstPersonSeat; + return value && IsSupportedVrFirstPersonSeat(*value) ? *value : std::move(fallback); +} + +inline float VrCockpitUnitsPerMeter(float fallback = kVrCockpitUnitsPerMeterDefault) { + return std::clamp(Get().vrCockpitUnitsPerMeter.value_or(fallback), kVrCockpitUnitsPerMeterMin, + kVrCockpitUnitsPerMeterMax); +} + +inline bool VrSteeringWheel(bool fallback = kVrSteeringWheelDefault) { + return Get().vrSteeringWheel.value_or(fallback); +} + +inline bool VrNativeSteeringWheel(bool fallback = kVrNativeSteeringWheelDefault) { + return Get().vrNativeSteeringWheel.value_or(fallback); +} + +inline bool VrHandSteering(bool fallback = kVrHandSteeringDefault) { + return Get().vrHandSteering.value_or(fallback); +} + +inline mkw::vr::WheelTuning VrWheelTuning() { + const auto& config = Get(); + mkw::vr::WheelTuning tuning{}; + tuning.kartDegrees = + std::clamp(config.vrWheelKartDegrees.value_or(tuning.kartDegrees), kVrWheelDegreesMin, kVrWheelDegreesMax); + tuning.bikeDegrees = + std::clamp(config.vrWheelBikeDegrees.value_or(tuning.bikeDegrees), kVrWheelDegreesMin, kVrWheelDegreesMax); + tuning.grabDistance = std::clamp(config.vrWheelGrabDistance.value_or(tuning.grabDistance), + kVrWheelGrabDistanceMin, kVrWheelGrabDistanceMax); + tuning.grabAssist = std::clamp(config.vrWheelGrabAssist.value_or(tuning.grabAssist), kVrWheelGrabAssistMin, + kVrWheelGrabAssistMax); + tuning.response = + std::clamp(config.vrWheelResponse.value_or(tuning.response), kVrWheelResponseMin, kVrWheelResponseMax); + tuning.trackingGrace = std::clamp(config.vrWheelTrackingGrace.value_or(tuning.trackingGrace), + kVrWheelTrackingGraceMin, kVrWheelTrackingGraceMax); + tuning.haptics = config.vrWheelHaptics.value_or(tuning.haptics); + return tuning; +} + inline std::string GraphicsApi(std::string fallback = "auto") { return Get().graphicsApi.value_or(std::move(fallback)); } diff --git a/runtime/include/vr/cockpit_stabilizer.h b/runtime/include/vr/cockpit_stabilizer.h new file mode 100644 index 0000000..d8b8486 --- /dev/null +++ b/runtime/include/vr/cockpit_stabilizer.h @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Ported from heurazy's mario-kart-wii-VR-port (GPL-3.0-or-later). +#pragma once +#include "vr/mkw_vr_first_person.h" +#include +#include + +namespace mkw::vr { +// Simulation position and driving direction, never the animated vehicle matrix. +// Follow the simulation position exactly; stabilize only impact orientation. +class CockpitStabilizer { +public: + Mtx34 Update(const Mtx34& simulation, bool damaged, float dt) { + const float yaw = std::atan2(simulation[2], simulation[10]); + const float dx = simulation[3]-position_[0], dy = simulation[7]-position_[1], dz = simulation[11]-position_[2]; + if (!valid_ || dx*dx+dy*dy+dz*dz > 1500.0f*1500.0f) { + position_ = {simulation[3],simulation[7],simulation[11]}; + yaw_ = yaw; valid_ = true; recovering_ = false; + } + if (damaged) recovering_ = true; + else if (recovering_) { + const float alpha = 1.0f-std::exp(-8.0f*std::clamp(dt,0.0f,0.05f)); + const float delta = std::remainder(yaw-yaw_,6.283185307f); + yaw_ += delta*alpha; + if (std::abs(delta)<0.002f) recovering_=false; + } else { + position_={simulation[3],simulation[7],simulation[11]}; yaw_=yaw; + } + // Freezing/blending translation left the seat behind after collisions. + // Dynamics excludes visual shake; retain its exact kart attachment. + position_={simulation[3],simulation[7],simulation[11]}; + const float c=std::cos(yaw_),s=std::sin(yaw_); + return {c,0,s,position_[0], 0,1,0,position_[1], -s,0,c,position_[2]}; + } +private: + std::array position_{}; + float yaw_=0; + bool valid_=false,recovering_=false; +}; +} diff --git a/runtime/include/vr/mkw_vr_first_person.h b/runtime/include/vr/mkw_vr_first_person.h index 0ed3c63..9d83519 100644 --- a/runtime/include/vr/mkw_vr_first_person.h +++ b/runtime/include/vr/mkw_vr_first_person.h @@ -2,6 +2,9 @@ #pragma once +#include "vr/steering_wheel.h" + +#include #include #include #include @@ -40,12 +43,35 @@ enum class FirstPersonRotation : uint8_t { Full, }; +// Where the first-person head is placed. +enum class FirstPersonSeat : uint8_t { + // At the driver's own eyes, measured from the character's model and kept + // behind the steering wheel, at a life-size cockpit scale. The wheel or + // handlebar is then within reach of the player's hands. + Cockpit, + // The free first_person_head_*_meters offsets at first_person_units_per_meter. + Custom, +}; + // The camera relocation published to Aurora for one guest frame: a transform // from the game's recorded view space into the space the headset renders from. struct FirstPersonAnchor { Mtx34 anchor_from_scene = kIdentityMtx34; bool valid = false; uint64_t guest_frame_index = 0; + // The rest describes the cockpit seat and is left empty by the custom seat. + bool cockpit = false; + // World units per metre the anchor was built with (character and player + // scale included). + float units_per_meter = 0.0f; + // The vehicle's steering wheel or handlebar, in metres in the seated frame + // (+X right, +Y up, -Z forward, origin at the head). + WheelGeometry native_wheel{}; + bool bike = false; + // The vehicle's own wheel is being animated in the scene this frame. + bool native_mesh_prepared = false; + // Changes whenever the player's vehicle object does. + uint64_t vehicle_identity = 0; }; // --------------------------------------------------------------------------- @@ -137,6 +163,241 @@ inline Vec3 TransformPoint(const Mtx34& matrix, float x, float y, float z) noexc } // namespace detail +// --------------------------------------------------------------------------- +// Cockpit seat and steering-wheel geometry. Ported from heurazy's +// mario-kart-wii-VR-port (GPL-3.0-or-later). +// --------------------------------------------------------------------------- + +inline Mtx34 ComposeMtx(const Mtx34& a, const Mtx34& b) noexcept { + Mtx34 out{}; + for (int row = 0; row < 3; ++row) { + for (int col = 0; col < 4; ++col) { + out[row * 4 + col] = col == 3 ? a[row * 4 + 3] : 0.0f; + for (int k = 0; k < 3; ++k) { + out[row * 4 + col] += a[row * 4 + k] * b[k * 4 + col]; + } + } + } + return out; +} + +inline bool InvertMtx(const Mtx34& m, Mtx34& out) noexcept { + if (!detail::IsFiniteMtx34(m)) { + return false; + } + const detail::Vec3 a{m[0], m[4], m[8]}, b{m[1], m[5], m[9]}, c{m[2], m[6], m[10]}; + const auto x = detail::Cross(b, c), y = detail::Cross(c, a), z = detail::Cross(a, b); + const float det = detail::Dot(a, x); + if (!detail::IsFiniteFloat(&det) || std::abs(det) < 1e-6f) { + return false; + } + out = {x.x / det, x.y / det, x.z / det, 0, y.x / det, y.y / det, y.z / det, 0, z.x / det, z.y / det, z.z / det, 0}; + for (int row = 0; row < 3; ++row) { + out[row * 4 + 3] = -(out[row * 4] * m[3] + out[row * 4 + 1] * m[7] + out[row * 4 + 2] * m[11]); + } + return detail::IsFiniteMtx34(out); +} + +// Keeps the eye behind the steering wheel or handlebar even when a long face or +// a leaned-forward riding animation puts the character's eyes over it. Units +// are the vehicle's; `radius` is the control's half width. +inline float EyeBehindControls(float eyeForward, float controlsForward, float units, float radius) noexcept { + const float clearance = std::clamp(0.40f + radius / units * 0.3f, 0.45f, 0.65f) * units; + return std::min(eyeForward, controlsForward - clearance); +} + +// Tall characters sit higher; normalise them to a comfortable perceived cockpit +// height by growing the world scale with the measured eye height. +inline float CharacterCockpitScale(float eyeHeight) noexcept { + if (!detail::IsFiniteFloat(&eyeHeight)) { + return 1.0f; + } + return std::clamp(eyeHeight / 100.0f, 1.0f, 2.5f); +} + +inline float ValidPlayerScale(float scale) noexcept { + return detail::IsFiniteFloat(&scale) && scale >= 0.1f && scale <= 4.0f ? scale : 1.0f; +} + +inline Mtx34 ScaleModelBasis(Mtx34 pose, const std::array& scale) noexcept { + for (int row = 0; row < 3; ++row) { + for (int col = 0; col < 3; ++col) { + pose[row * 4 + col] *= scale[col]; + } + } + return pose; +} + +inline bool NeutralPlayerScale(const std::array& scale) noexcept { + for (float value : scale) { + if (!detail::IsFiniteFloat(&value) || std::abs(value - 1.0f) > 0.001f) { + return false; + } + } + return true; +} + +// Eye position resources are in the face bone's local coordinates, whose axes +// differ between characters. Transform their centre through the complete bind +// matrix before applying the vehicle-specific driver placement. +inline bool ComputeDriverEyeFromBounds(const Mtx34& face, const Mtx34& placement, detail::Vec3 minimum, + detail::Vec3 maximum, std::array& eye) noexcept { + if (!detail::IsFiniteMtx34(face) || !detail::IsFiniteMtx34(placement)) { + return false; + } + const std::array bounds{minimum.x, minimum.y, minimum.z, maximum.x, maximum.y, maximum.z}; + for (const auto& value : bounds) { + if (!detail::IsFiniteFloat(&value) || std::abs(value) > 500.0f) { + return false; + } + } + if (minimum.x > maximum.x || minimum.y > maximum.y || minimum.z > maximum.z) { + return false; + } + const auto model = detail::TransformPoint(face, (minimum.x + maximum.x) * 0.5f, (minimum.y + maximum.y) * 0.5f, + (minimum.z + maximum.z) * 0.5f); + const auto seat = detail::TransformPoint(placement, model.x, model.y, model.z); + const std::array result{seat.x, seat.y, seat.z}; + for (const auto& value : result) { + if (!detail::IsFiniteFloat(&value) || std::abs(value) > 500.0f) { + return false; + } + } + if (seat.y < 5.0f) { + return false; + } + eye = result; + return true; +} + +// Removes the visible vehicle's world transform from the evaluated head pose. +// This retains the riding posture, but never imports kart motion into the seat. +inline bool ComputeSeatedEye(const Mtx34& faceWorld, const Mtx34& bodyWorld, detail::Vec3 eyeLocal, + std::array& eye) noexcept { + if (!detail::IsFiniteMtx34(faceWorld) || !detail::IsFiniteMtx34(bodyWorld)) { + return false; + } + const detail::Vec3 a{bodyWorld[0], bodyWorld[4], bodyWorld[8]}, b{bodyWorld[1], bodyWorld[5], bodyWorld[9]}, + c{bodyWorld[2], bodyWorld[6], bodyWorld[10]}; + const auto bc = detail::Cross(b, c), ca = detail::Cross(c, a), ab = detail::Cross(a, b); + const float det = detail::Dot(a, bc); + if (!detail::IsFiniteFloat(&det) || std::abs(det) < 1e-6f) { + return false; + } + const auto world = detail::TransformPoint(faceWorld, eyeLocal.x, eyeLocal.y, eyeLocal.z); + const detail::Vec3 delta{world.x - bodyWorld[3], world.y - bodyWorld[7], world.z - bodyWorld[11]}; + const std::array result{detail::Dot(bc, delta) / det, detail::Dot(ca, delta) / det, + detail::Dot(ab, delta) / det}; + for (const auto& value : result) { + if (!detail::IsFiniteFloat(&value) || std::abs(value) > 500.0f) { + return false; + } + } + if (result[1] < 5.0f) { + return false; + } + eye = result; + return true; +} + +// The neutral seated eye, accepted once eight consecutive safe samples agree +// within two units, then frozen until the driver or the race changes. +struct SeatedEyeReference { + std::array value{}, candidate{}; + unsigned stable = 0; + bool valid = false; + void Observe(const std::array& sample, bool safe, bool freeze) { + if (freeze && valid) { + return; + } + if (!safe) { + stable = 0; + return; + } + float delta = 0.0f; + for (int i = 0; i < 3; ++i) { + delta = std::max(delta, std::abs(sample[i] - candidate[i])); + } + stable = stable && delta < 2.0f ? stable + 1 : 1; + candidate = sample; + if (stable >= 8) { + value = sample; + valid = true; + stable = 8; + } + } +}; + +// Neutral authored hand targets, transformed by the stabilised cockpit body. Do +// not use the animated hand IK targets: feeding their steering rotation back +// into the controller angle would make the input chase its own animation. +// `seat_from_body` maps vehicle-local units into the seated frame in units; +// the result is in metres. +inline WheelGeometry ComputeNativeWheelGeometry(const Mtx34& seat_from_body, detail::Vec3 left, detail::Vec3 right, + float units) noexcept { + WheelGeometry out{}; + if (!detail::IsFiniteMtx34(seat_from_body) || !detail::IsFiniteFloat(&units) || units <= 0.0f) { + return out; + } + if (left.x > right.x) { + std::swap(left, right); + } + const auto a = detail::TransformPoint(seat_from_body, left.x, left.y, left.z); + const auto b = detail::TransformPoint(seat_from_body, right.x, right.y, right.z); + detail::Vec3 x{b.x - a.x, b.y - a.y, b.z - a.z}; + const float radius = std::sqrt(detail::Dot(x, x)) / (2.0f * units); + if (!detail::IsFiniteFloat(&radius) || radius < 0.04f || radius > 1.0f || !detail::Normalize(x)) { + return out; + } + // Kart +X points left when looking along its +Z driving direction. + // WheelHand uses headset +X (right), so reverse the authored lateral axis. + x = {-x.x, -x.y, -x.z}; + detail::Vec3 y{seat_from_body[1], seat_from_body[5], seat_from_body[9]}; + const float projection = detail::Dot(x, y); + y = {y.x - x.x * projection, y.y - x.y * projection, y.z - x.z * projection}; + if (!detail::Normalize(y)) { + return out; + } + const auto z = detail::Cross(x, y); + out.center = {(a.x + b.x) / (2.0f * units), (a.y + b.y) / (2.0f * units), (a.z + b.z) / (2.0f * units)}; + for (const auto& value : out.center) { + if (!detail::IsFiniteFloat(&value) || std::abs(value) > 5.0f) { + return {}; + } + } + out.right = {x.x, x.y, x.z}; + out.up = {y.x, y.y, y.z}; + out.normal = {z.x, z.y, z.z}; + out.radius = radius; + out.valid = true; + return out; +} + +inline WheelGeometry ComputeNativeHandlebarGeometry(const Mtx34& seatFromHandle, const Mtx34& seatFromBody, + detail::Vec3 left, detail::Vec3 right, float units) noexcept { + auto out = ComputeNativeWheelGeometry(seatFromHandle, left, right, units); + if (!out.valid || !detail::IsFiniteMtx34(seatFromBody)) { + return {}; + } + // Use the body's neutral axes, not the already-steered handle's axes. + // Otherwise the visual steering feeds back into the next input sample. + detail::Vec3 x{-seatFromBody[0], -seatFromBody[4], -seatFromBody[8]}, + forward{seatFromBody[2], seatFromBody[6], seatFromBody[10]}; + if (!detail::Normalize(x)) { + return {}; + } + const float along = detail::Dot(forward, x); + forward = {forward.x - along * x.x, forward.y - along * x.y, forward.z - along * x.z}; + if (!detail::Normalize(forward)) { + return {}; + } + const auto vertical = detail::Cross(x, forward); + out.right = {x.x, x.y, x.z}; + out.up = {forward.x, forward.y, forward.z}; + out.normal = {vertical.x, vertical.y, vertical.z}; + return out; +} + // Builds the anchor from the game's view matrix (world -> recorded view space), // the kart's pose (kart-local -> world), and head offsets already converted to // world units. diff --git a/runtime/include/vr/native_wheel_mesh.h b/runtime/include/vr/native_wheel_mesh.h new file mode 100644 index 0000000..262205c --- /dev/null +++ b/runtime/include/vr/native_wheel_mesh.h @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Ported from heurazy's mario-kart-wii-VR-port (GPL-3.0-or-later). +#pragma once +#include "vr/mkw_vr_first_person.h" +#include + +namespace mkw::vr { +// A number of MKW karts bake the steering wheel into their single body bone. +// Find its thin disc around the authored hand targets, including the hub and +// spokes, and rotate only that disc. Work on a render copy, never guest assets. +inline unsigned RotateNativeWheelVertices(std::vector& points, + detail::Vec3 center,float radius,float angle,const Mtx34* bodyCorrection=nullptr) { + if (!(radius>4 && radius<100) || !detail::IsFiniteFloat(&angle)) return 0; + if(bodyCorrection && !detail::IsFiniteMtx34(*bodyCorrection)) return 0; + float meanY=0,meanZ=0; unsigned count=0; + const auto candidate=[&](const detail::Vec3& p) { + return std::abs(p.x-center.x)radius*radius*2.25f || std::abs(z)>radius*0.30f) continue; + const float rx=c*x-s*y,ry=s*x+c*y; + p={center.x+rx,center.y+up.y*ry+normal.y*z,center.z+up.z*ry+normal.z*z}; + // The body may spin during tricks/damage while the seated reference + // stays level. Compensate only the wheel, leaving chassis animation intact. + if(bodyCorrection) p=detail::TransformPoint(*bodyCorrection,p.x,p.y,p.z); + ++changed; + } + return changed; +} +} // namespace mkw::vr diff --git a/runtime/include/vr/openxr_driving.h b/runtime/include/vr/openxr_driving.h new file mode 100644 index 0000000..8deb31e --- /dev/null +++ b/runtime/include/vr/openxr_driving.h @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +// Steering wheel and hand steering in the first-person cockpit. +// +// The OpenXR pacing thread locates the controllers in the seated frame, runs +// the SteeringWheel (steering_wheel.h, ported from heurazy's +// mario-kart-wii-VR-port) and publishes one DrivingSnapshot per XR frame. The +// guest thread reads the latest one to turn the vehicle's own wheel mesh, and +// the pacing thread hands the same state to Aurora's cockpit overlay. Nothing +// in this header depends on OpenXR, so the guest side builds without it and +// the rules below are tested headlessly (tests/vr_hand_steering_tests.cpp). +// +// The seated frame is the application space re-based on the immersive head +// position and turned by the lean-back angle, in metres: +X right, +Y up, -Z +// forward. It is the frame the first-person anchor places the vehicle in, so +// hands, wheel geometry and eye transforms all meet there. + +#include "vr/openxr_wii_remote.h" +#include "vr/steering_wheel.h" + +#include +#include +#include +#include +#include + +namespace mkw::vr { + +// One tracked hand in the seated frame. +struct DrivingHand { + bool tracked = false; + bool held = false; + float squeeze = 0.0f; + // Row-major 3x4 from the controller's grip space into the seated frame. + std::array seat_from_grip{1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f}; +}; + +struct DrivingSnapshot { + // The first-person cockpit is engaged and the controllers are mapped into it. + bool cockpit_active = false; + // Hand steering is on: a squeezed grip near the wheel takes hold of it. + bool hand_steering = false; + std::array held{}; + // The steering the game receives, -1..1: the wheel while a hand holds it, + // otherwise the left stick. + float steering_input = 0.0f; + // What the wheel or handlebar shows, in radians. Positive turns it + // clockwise as the driver sees it, i.e. to the right. + float visual_angle = 0.0f; + std::array hands{}; +}; + +// Pacing thread publishes; any thread reads the latest. A default snapshot +// (nothing held, centred) is returned before the first publication. +void OpenXRPublishDriving(const DrivingSnapshot& snapshot) noexcept; +DrivingSnapshot OpenXRReadDriving() noexcept; + +namespace driving { + +inline bool IsFinite(float value) noexcept { + // Bit test: the runtime may be built with -ffast-math. + uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + return (bits & 0x7F800000u) != 0x7F800000u; +} + +// The wheel angle at full steering lock, in radians. +inline float MaxWheelAngle(bool bike, const WheelTuning& tuning) noexcept { + const float degrees = bike ? tuning.bikeDegrees : tuning.kartDegrees; + const float clamped = IsFinite(degrees) ? std::clamp(degrees, 20.0f, 180.0f) : (bike ? 45.0f : 90.0f); + return clamped * 0.01745329252f; +} + +// Grips only grab. While a hand holds the wheel its squeeze is released for the +// game, where it would press C on the Nunchuk or a shoulder on the gamepad, and +// the wheel replaces the left stick's X axis, which both controller modes steer +// with. The stick's Y axis keeps aiming items forwards and backwards. +inline void ApplyHandSteering(std::array& hands, const WheelState& wheel) noexcept { + for (size_t hand = 0; hand < hands.size(); ++hand) { + if (wheel.held[hand]) { + hands[hand].squeeze = 0.0f; + } + } + if ((wheel.held[0] || wheel.held[1]) && IsFinite(wheel.steering)) { + hands[0].stick_x = std::clamp(wheel.steering, -1.0f, 1.0f); + } +} + +// The angle the wheel shows. A held wheel shows the hands' own angle; otherwise +// it follows the stick at the configured full-lock angle, eased so a flicked +// stick does not snap it round. +class WheelVisual { +public: + float Update(bool held, float held_angle, float stick_x, float max_angle, float dt) noexcept { + if (!IsFinite(dt)) { + dt = 0.0f; + } + if (held && IsFinite(held_angle)) { + angle_ = held_angle; + return angle_; + } + const float stick = IsFinite(stick_x) ? std::clamp(stick_x, -1.0f, 1.0f) : 0.0f; + const float target = stick * (IsFinite(max_angle) ? max_angle : 0.0f); + angle_ += (target - angle_) * (1.0f - std::exp(-15.0f * std::clamp(dt, 0.0f, 0.1f))); + return angle_; + } + void Reset() noexcept { angle_ = 0.0f; } + +private: + float angle_ = 0.0f; +}; + +// Where the seated frame is: the immersive head position in the application +// space, turned about +X by the lean-back angle. +struct SeatFrame { + bool valid = false; + std::array base{}; + float lean_back_radians = 0.0f; +}; + +// A pose in the application space (position, then a unit quaternion x, y, z, w) +// as a row-major 3x4 in the seated frame: R_lean^T * (p - base) for the +// position and R_lean^T * R for the orientation. The inverse of how the eye +// transforms place the seated frame (world = base + R_lean * seat). +inline std::array SeatFromApp(const SeatFrame& seat, const std::array& position, + const std::array& orientation) noexcept { + float x = orientation[0], y = orientation[1], z = orientation[2], w = orientation[3]; + const float length = std::sqrt(x * x + y * y + z * z + w * w); + if (IsFinite(length) && length > 1e-6f) { + x /= length; + y /= length; + z /= length; + w /= length; + } else { + x = y = z = 0.0f; + w = 1.0f; + } + const float r[9]{1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w), + 2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w), + 2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)}; + const float c = std::cos(seat.lean_back_radians), s = std::sin(seat.lean_back_radians); + // R_lean about +X is rows (1,0,0), (0,c,-s), (0,s,c); its transpose applied to v: + const auto unlean = [c, s](float vx, float vy, float vz) { + return std::array{vx, c * vy + s * vz, -s * vy + c * vz}; + }; + std::array out{}; + for (int col = 0; col < 3; ++col) { + const auto column = unlean(r[col], r[3 + col], r[6 + col]); + out[col] = column[0]; + out[4 + col] = column[1]; + out[8 + col] = column[2]; + } + const auto p = unlean(position[0] - seat.base[0], position[1] - seat.base[1], position[2] - seat.base[2]); + out[3] = p[0]; + out[7] = p[1]; + out[11] = p[2]; + return out; +} + +} // namespace driving + +} // namespace mkw::vr diff --git a/runtime/include/vr/openxr_hand_mesh.h b/runtime/include/vr/openxr_hand_mesh.h new file mode 100644 index 0000000..af956e5 --- /dev/null +++ b/runtime/include/vr/openxr_hand_mesh.h @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Ported from heurazy's mario-kart-wii-VR-port (GPL-3.0-or-later). +#pragma once +#include "vr/openxr_runtime.h" +#include + +#include +#include +#include +#include + +namespace mkw::vr { +// AnimalCrossing-VR-MR-Standalone obtains its white hands from this Meta +// runtime extension, not from a distributable model asset. Use the same API, +// with a procedural fallback on PC runtimes that do not expose Meta meshes. +inline bool LoadRuntimeHandMeshes(OpenXRRuntime& runtime) { + for (uint32_t h = 0; h < 2; ++h) + aurora_set_vr_hand_mesh(h, nullptr, 0, nullptr, 0, nullptr, nullptr, 0); + const auto& extensions = runtime.EnabledExtensions(); + if (std::find(extensions.begin(), extensions.end(), XR_FB_HAND_TRACKING_MESH_EXTENSION_NAME) == extensions.end()) + return false; + PFN_xrCreateHandTrackerEXT create = nullptr; + PFN_xrDestroyHandTrackerEXT destroy = nullptr; + PFN_xrGetHandMeshFB meshFn = nullptr; + xrGetInstanceProcAddr(runtime.Instance(), "xrCreateHandTrackerEXT", reinterpret_cast(&create)); + xrGetInstanceProcAddr(runtime.Instance(), "xrDestroyHandTrackerEXT", reinterpret_cast(&destroy)); + xrGetInstanceProcAddr(runtime.Instance(), "xrGetHandMeshFB", reinterpret_cast(&meshFn)); + if (!create || !destroy || !meshFn) return false; + bool any = false; + for (uint32_t h = 0; h < 2; ++h) { + XrHandTrackerCreateInfoEXT info{XR_TYPE_HAND_TRACKER_CREATE_INFO_EXT}; + info.hand = h ? XR_HAND_RIGHT_EXT : XR_HAND_LEFT_EXT; + info.handJointSet = XR_HAND_JOINT_SET_DEFAULT_EXT; + XrHandTrackerEXT tracker = XR_NULL_HANDLE; + if (XR_FAILED(create(runtime.Session(), &info, &tracker))) continue; + struct Guard { XrHandTrackerEXT tracker; PFN_xrDestroyHandTrackerEXT destroy; ~Guard() { destroy(tracker); } } guard{tracker, destroy}; + XrHandTrackingMeshFB mesh{XR_TYPE_HAND_TRACKING_MESH_FB}; + if (XR_FAILED(meshFn(tracker, &mesh)) || mesh.jointCountOutput != 26 || + !mesh.vertexCountOutput || mesh.vertexCountOutput > 65535 || !mesh.indexCountOutput || + mesh.indexCountOutput > 100000 || mesh.indexCountOutput % 3) continue; + std::vector poses(mesh.jointCountOutput); + std::vector radii(mesh.jointCountOutput); + std::vector parents(mesh.jointCountOutput); + std::vector positions(mesh.vertexCountOutput), normals(mesh.vertexCountOutput); + std::vector uv(mesh.vertexCountOutput); + std::vector joints(mesh.vertexCountOutput); + std::vector weights(mesh.vertexCountOutput); + std::vector indices(mesh.indexCountOutput); + mesh.jointCapacityInput = poses.size(); mesh.jointBindPoses = poses.data(); + mesh.jointRadii = radii.data(); mesh.jointParents = parents.data(); + mesh.vertexCapacityInput = positions.size(); mesh.vertexPositions = positions.data(); + mesh.vertexNormals = normals.data(); mesh.vertexUVs = uv.data(); + mesh.vertexBlendIndices = joints.data(); mesh.vertexBlendWeights = weights.data(); + mesh.indexCapacityInput = indices.size(); mesh.indices = indices.data(); + if (XR_FAILED(meshFn(tracker, &mesh))) continue; + std::vector vertices(positions.size()); + for (size_t i = 0; i < vertices.size(); ++i) { + auto& v = vertices[i]; + std::memcpy(v.position, &positions[i], sizeof(v.position)); + std::memcpy(v.joints, &joints[i], sizeof(v.joints)); + std::memcpy(v.weights, &weights[i], sizeof(v.weights)); + } + std::array bind{}; + std::array parentIds{}; + for (size_t j = 0; j < poses.size(); ++j) { + std::memcpy(bind.data() + j * 7, &poses[j], 7 * sizeof(float)); + parentIds[j] = static_cast(parents[j]); + } + aurora_set_vr_hand_mesh(h, vertices.data(), vertices.size(), + reinterpret_cast(indices.data()), indices.size(), bind.data(), parentIds.data(), poses.size()); + any = true; + } + return any; +} +} // namespace mkw::vr diff --git a/runtime/include/vr/steering_wheel.h b/runtime/include/vr/steering_wheel.h new file mode 100644 index 0000000..bef2147 --- /dev/null +++ b/runtime/include/vr/steering_wheel.h @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Ported from heurazy's mario-kart-wii-VR-port (GPL-3.0-or-later). +#pragma once +#include +#include +#include +#include +#include + +namespace mkw::vr { +// Metres in a fixed seated frame: +X right, +Y up, -Z forward. +struct WheelHand { float x=0, y=0, z=0, squeeze=0; bool tracked=false; }; +struct WheelGeometry { + std::array center{}, right{1,0,0}, up{0,1,0}, normal{0,0,1}; + float radius=0.18f; + bool valid=false; + WheelHand ToWheel(WheelHand hand) const { + const std::array p{hand.x-center[0],hand.y-center[1],hand.z-center[2]}; + const auto dot=[&](const auto& axis) { return p[0]*axis[0]+p[1]*axis[1]+p[2]*axis[2]; }; + hand.x=dot(right); hand.y=dot(up)-0.30f; hand.z=dot(normal)-0.42f; + return hand; + } +}; +struct WheelState { + float angle=0, steering=0, visualAngle=0; + std::array held{}; +}; +class WheelReferenceLatch { + WheelGeometry last_{}; + uint64_t identity_=0; + float missing_=0; + bool bike_=false; +public: + bool Resolve(WheelGeometry& geometry, bool enabled, bool available, bool held, + bool bike, uint64_t identity, float dt) { + if(!enabled || identity_!=identity || bike_!=bike) { last_={};missing_=0; } + identity_=identity;bike_=bike; + if(!enabled) return false; + if(available && geometry.valid) { last_=geometry;missing_=0;return true; } + missing_+=std::clamp(dt,0.0f,0.05f); + if(held && last_.valid && missing_<0.20f) { geometry=last_;return true; } + last_={};return false; + } +}; +struct WheelTuning { + float kartDegrees=90, bikeDegrees=45, grabDistance=0.35f, grabAssist=1, response=1, trackingGrace=0.20f; + bool haptics=true; +}; +class SteeringWheel { +public: + static constexpr float Radius=0.18f, Height=-0.30f, Depth=-0.42f; + WheelState Update(const std::array& hands, bool active, float dt, float radius=Radius, bool handlebars=false, + WheelTuning tuning={}) { + const auto finite=[](float value) { uint32_t bits; std::memcpy(&bits,&value,sizeof(bits)); return (bits&0x7f800000u)!=0x7f800000u; }; + if (!finite(dt)) dt=0; + if (!finite(radius) || radius<0.04f || radius>1.0f) { active=false; radius=Radius; } + dt=std::clamp(dt, 0.0f, 0.05f); + const bool previouslyHeld=state_.held[0]||state_.held[1]; + float deltaSum=0,weightSum=0; + std::array moving{}; + std::array validHands{}; + std::array delta{}; + const float degrees=handlebars?tuning.bikeDegrees:tuning.kartDegrees; + const float maxAngle=(finite(degrees)?std::clamp(degrees,20.0f,180.0f):(handlebars?45.0f:90.0f))*0.01745329252f; + const float assist=finite(tuning.grabAssist)?std::clamp(tuning.grabAssist,0.7f,2.0f):1.0f; + const float reach=finite(tuning.grabDistance)?std::clamp(tuning.grabDistance,0.15f,0.8f):0.35f; + const float grace=finite(tuning.trackingGrace)?std::clamp(tuning.trackingGrace,0.05f,0.5f):0.2f; + for (int h=0; h<2; ++h) { + const auto& p=hands[h]; + const bool valid=p.tracked&&finite(p.x)&&finite(p.y)&&finite(p.z)&&finite(p.squeeze); + validHands[h]=valid; + const bool down=finite(p.squeeze)&&p.squeeze > (pressed_[h] ? 0.15f : 0.55f); + if(!valid) { + lost_[h]+=dt; + if(!p.tracked && down && active && state_.held[h] && lost_[h](pairValid_?0.10f:0.14f); + const float pairAngle=pair ? -std::atan2(spanY,spanX):0; + float change=weightSum>0 ? deltaSum/weightSum:0; + // With both hands held, do not switch to angles about the hub when + // their span collapses: that changes the reference frame and creates + // false turns as the hands approach/cross each other. Hold the angle + // through that singularity and establish a fresh pair baseline on exit. + if(state_.held[0] && state_.held[1]) + change=pair && pairValid_ ? std::remainder(pairAngle-lastPair_,6.283185307f):0; + pairValid_=pair; lastPair_=pairAngle; + // Rebase a discontinuous tracking pose without sending a full-lock + // impulse. Normal fast arcade steering remains inside this envelope. + if(std::abs(change)>0.25f+8.0f*dt) change=0; + const bool held=state_.held[0]||state_.held[1]; + if(held && !previouslyHeld) target_=state_.angle; + // A single accumulated target avoids jumps when a second hand joins, + // leaves, passes through the hub or temporarily loses tracking. + // Keep physical overtravel. Clamping this accumulator discards motion + // past full lock, so retracing the gesture no longer returns to centre. + // Only the game's steering command is saturated, never the hand angle. + target_=held ? target_+change:0; + const float error=target_-state_.angle; + // Quiet near a steady heading, responsive during deliberate turns. + const float tuningResponse=finite(tuning.response)?std::clamp(tuning.response,0.5f,2.0f):1; + const float response=held ? std::clamp((18.0f+80.0f*std::abs(error))*tuningResponse,9.0f,90.0f):12.0f; + state_.angle += error*(1-std::exp(-dt*response)); + state_.steering=active ? std::clamp(state_.angle/maxAngle,-1.0f,1.0f) : 0; + if (!active) { state_.angle=0;target_=0;pairValid_=false; } + // Share the validated physical rotation with both renderer paths. + // Handlebars keep their limited travel; a kart wheel can turn freely. + state_.visualAngle=handlebars ? std::clamp(state_.angle,-maxAngle,maxAngle) + : std::remainder(state_.angle,6.283185307f); + return state_; + } +private: + WheelState state_{}; + std::array pressed_{}; + std::array center_{}; + std::array last_{}; + std::array lost_{}; + float target_=0,lastPair_=0; + bool pairValid_=false; +}; +} // namespace mkw::vr diff --git a/runtime/src/vr/openxr_driving.cpp b/runtime/src/vr/openxr_driving.cpp new file mode 100644 index 0000000..1be9704 --- /dev/null +++ b/runtime/src/vr/openxr_driving.cpp @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "vr/openxr_driving.h" + +#include + +namespace mkw::vr { +// Named rather than anonymous: runtime sources are unity-built in groups. +namespace driving_bridge { + +struct Published { + std::mutex mutex; + DrivingSnapshot snapshot{}; +}; + +Published& Get() { + static Published published; + return published; +} + +} // namespace driving_bridge + +void OpenXRPublishDriving(const DrivingSnapshot& snapshot) noexcept { + auto& published = driving_bridge::Get(); + std::lock_guard lock(published.mutex); + published.snapshot = snapshot; +} + +DrivingSnapshot OpenXRReadDriving() noexcept { + auto& published = driving_bridge::Get(); + std::lock_guard lock(published.mutex); + return published.snapshot; +} + +} // namespace mkw::vr diff --git a/runtime/tests/steering_wheel_tests.cpp b/runtime/tests/steering_wheel_tests.cpp new file mode 100644 index 0000000..b5625d1 --- /dev/null +++ b/runtime/tests/steering_wheel_tests.cpp @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Ported from heurazy's mario-kart-wii-VR-port (GPL-3.0-or-later): grab, release, +// one- and two-hand steering, tracking loss and overtravel of the SteeringWheel. +#include "vr/steering_wheel.h" +#include +#include +using namespace mkw::vr; +int failures=0; +void Check(bool ok,const char* message) { if(!ok) { std::cerr< hands{Rim(pi),Rim(0)}; + auto state=wheel.Update(hands,true,dt); + Check(state.held[0]&&state.held[1]&&state.steering==0,"grab both hands without a steering jump"); + for(int i=1;i<=90;++i) { + hands={Rim(pi-i*pi/180),Rim(-i*pi/180)}; + state=wheel.Update(hands,true,dt); + } + for(int i=0;i<30;++i) state=wheel.Update(hands,true,dt); + Check(state.steering>0.99f,"clockwise quarter turn produces full right steering"); + hands[1].squeeze=0; + state=wheel.Update(hands,true,dt); + Check(state.held[0]&&!state.held[1]&&state.steering>0.98f,"one hand can release without losing the other grip"); + hands[0].tracked=false; + state=wheel.Update(hands,true,dt); + Check(state.held[0],"brief tracking dropout keeps the grip"); + for(int i=0;i<20;++i) state=wheel.Update(hands,true,dt); + Check(!state.held[0]&&!state.held[1],"sustained tracking loss releases the wheel"); + hands[0].tracked=true; + state=wheel.Update(hands,true,dt); + Check(!state.held[0],"tracking recovery needs a fresh squeeze"); + hands[0].squeeze=0;wheel.Update(hands,true,dt);hands[0].squeeze=1; + Check(wheel.Update(hands,true,dt).held[0],"release and squeeze allows reacquisition"); + Check(wheel.Update(hands,false,dt).steering==0,"leaving cockpit clears steering"); + wheel={};hands={Rim(pi),Rim(0)};wheel.Update(hands,true,dt); + hands[0].z=SteeringWheel::Depth-0.70f;hands[1].z=SteeringWheel::Depth+0.70f; + state=wheel.Update(hands,true,dt); + Check(state.held[0]&&state.held[1],"arcade gestures allow 70cm forward and backward travel"); + hands[1].x=0;hands[1].y=SteeringWheel::Height; + for(int i=0;i<30;++i) state=wheel.Update(hands,true,dt); + Check(state.held[1],"hand at wheel center retains grip"); + const float before=state.angle; + hands[1]=Rim(pi); + state=wheel.Update(hands,true,dt); + Check(state.held[1]&&std::abs(state.angle-before)<0.001f,"crossing center cannot flip steering 180 degrees"); + hands[0].z=SteeringWheel::Depth-0.85f; + Check(wheel.Update(hands,true,dt).held[0],"large gestures no longer release a squeezed grip"); + hands[0].squeeze=0; + Check(!wheel.Update(hands,true,dt).held[0],"releasing the grip button still releases immediately"); + wheel={};hands={Rim(pi,0),Rim(0,0)}; + hands[0].z=0;hands[0].squeeze=1; + Check(!wheel.Update(hands,true,dt).held[0],"grip far from wheel does not grab it"); + hands[0]=Rim(pi);Check(!wheel.Update(hands,true,dt).held[0],"moving an already squeezed hand onto rim cannot grab"); + hands[0].squeeze=0;wheel.Update(hands,true,dt);hands[0].squeeze=1;wheel.Update(hands,true,dt); + for(int i=1;i<=90;++i) { hands[0]=Rim(pi+i*pi/180);state=wheel.Update(hands,true,dt); } + for(int i=0;i<30;++i) state=wheel.Update(hands,true,dt); + Check(state.steering < -0.99f,"counterclockwise wrap through pi produces full left steering"); + hands[0].x=std::numeric_limits::quiet_NaN(); + state=wheel.Update(hands,true,std::numeric_limits::quiet_NaN()); + Check(!state.held[0]&&std::isfinite(state.steering),"invalid tracking/time cannot poison wheel state"); + wheel={}; + hands={WheelHand{-0.45f,SteeringWheel::Height,SteeringWheel::Depth,1,true}, + WheelHand{0.45f,SteeringWheel::Height,SteeringWheel::Depth,1,true}}; + state=wheel.Update(hands,true,dt,0.45f); + Check(state.held[0]&&state.held[1],"wide native handlebars can be grabbed at their real radius"); + state=wheel.Update(hands,true,dt,std::numeric_limits::quiet_NaN()); + Check(!state.held[0]&&!state.held[1]&&state.steering==0,"invalid native radius releases safely"); + WheelGeometry bar; + bar.center={0,-0.4f,-0.6f};bar.up={0,0,-1};bar.normal={0,1,0};bar.radius=0.3f;bar.valid=true; + wheel={}; + for(int i=0;i<=45;++i) { + const float a=i*pi/180; + for(int hand=0;hand<2;++hand) { + const float side=hand?1.0f:-1.0f; + hands[hand]=bar.ToWheel({side*0.3f*std::cos(a),-0.4f,-0.6f+side*0.3f*std::sin(a),1,true}); + } + state=wheel.Update(hands,true,dt,0.3f,true); + } + for(int i=0;i<30;++i) state=wheel.Update(hands,true,dt,0.3f,true); + Check(state.held[0]&&state.held[1]&&state.steering>0.99f,"bike: right hand back and left hand forward steers right at 45 degrees"); + hands[0].squeeze=0; + Check(wheel.Update(hands,true,dt,0.3f,true).held[1],"bike can be steered with one hand"); + wheel={};hands={}; + hands[1]=bar.ToWheel({0,-0.4f,-0.9f,1,true}); + Check(!wheel.Update(hands,true,dt,0.3f,true).held[1],"bike acquisition uses handle ends, not an invisible circular rim"); + wheel={};hands={}; + hands[1]=bar.ToWheel({0.3f,-0.4f,-0.6f,1,true}); + wheel.Update(hands,true,dt,0.3f,true); + hands[1]=bar.ToWheel({0.3f,-0.1f,-0.6f,1,true}); + state=wheel.Update(hands,true,dt,0.3f,true); + Check(state.held[1]&&std::abs(state.steering)<0.001f,"bike vertical hand movement does not steer or lose grip"); + wheel={};hands={}; + for(int i=0;i<=45;++i) { + const float a=-i*pi/180; + hands[0]=bar.ToWheel({-0.3f*std::cos(a),-0.4f,-0.6f-0.3f*std::sin(a),1,true}); + state=wheel.Update(hands,true,dt,0.3f,true); + } + for(int i=0;i<30;++i) state=wheel.Update(hands,true,dt,0.3f,true); + Check(state.held[0]&&state.steering < -0.99f,"bike: left hand back steers left with one hand"); + wheel={};hands={}; + hands[1]={0.25f,SteeringWheel::Height,SteeringWheel::Depth,1,true}; + Check(wheel.Update(hands,true,dt,0.05f).held[1],"tiny kart wheel has a comfortable acquisition area independent of visual radius"); + // Common arm motion must not be interpreted as rotation, on either plane. + for(bool bike : {false,true}) { + wheel={};hands={Rim(pi),Rim(0)}; + wheel.Update(hands,true,dt,0.18f,bike); + wheel.Update(hands,true,dt,0.18f,bike); + for(int i=1;i<=90;++i) { + hands={Rim(pi),Rim(0)}; + for(auto& hand:hands) { hand.x+=0.12f*i/90;hand.y+=0.10f*i/90; } + state=wheel.Update(hands,true,dt,0.18f,bike); + } + Check(std::abs(state.steering)<0.001f,"two-hand translation does not steer kart or bike"); + } + // Joining at a different hand position must not dilute the existing turn. + wheel={};hands={WheelHand{},Rim(0)};wheel.Update(hands,true,dt); + for(int i=1;i<=45;++i) { hands[1]=Rim(-i*pi/180);wheel.Update(hands,true,dt); } + for(int i=0;i<90;++i) state=wheel.Update(hands,true,dt); + const float heldTurn=state.steering; + hands[0]=Rim(pi); + for(int i=0;i<60;++i) state=wheel.Update(hands,true,dt); + Check(std::abs(state.steering-heldTurn)<0.001f,"joining second hand preserves the steering target"); + hands[1].squeeze=0; + for(int i=0;i<60;++i) state=wheel.Update(hands,true,dt); + Check(std::abs(state.steering-heldTurn)<0.001f,"releasing original hand preserves the steering target"); + hands[0].tracked=false;hands[0].squeeze=0; + Check(!wheel.Update(hands,true,dt).held[0],"explicit release works even during tracking loss"); + wheel={};hands={WheelHand{},Rim(0)};wheel.Update(hands,true,dt); + hands[1]=Rim(pi/2); + state=wheel.Update(hands,true,dt); + Check(std::abs(state.steering)<0.001f && state.held[1],"tracking teleport does not jerk steering or drop the grip"); + // Identical continuous gestures at different headset rates have the same result. + float result72=0,result120=0; + for(int hz : {72,120}) { + wheel={};hands={WheelHand{},Rim(0)};wheel.Update(hands,true,1.0f/hz); + for(int i=1;i<=hz;++i) { hands[1]=Rim(-0.7f*i/hz);state=wheel.Update(hands,true,1.0f/hz); } + if(hz==72) result72=state.steering;else result120=state.steering; + } + Check(std::abs(result72-result120)<0.01f,"steering response is stable from 72 to 120 Hz"); + wheel={};hands={WheelHand{},Rim(0)};wheel.Update(hands,true,dt); + float peakNoise=0; + for(int i=0;i<180;++i) { + hands[1]=Rim(i%2?0.004f:-0.004f); + state=wheel.Update(hands,true,dt); + peakNoise=std::max(peakNoise,std::abs(state.steering)); + } + Check(peakNoise<0.001f,"small tracking tremors are damped around straight steering"); + for(int i=1;i<=45;++i) { hands[1]=Rim(-i*pi/180);state=wheel.Update(hands,true,dt); } + hands[1].squeeze=0;wheel.Update(hands,true,dt); + hands[1].squeeze=1;state=wheel.Update(hands,true,dt); + const float caughtAngle=state.angle; + for(int i=0;i<90;++i) state=wheel.Update(hands,true,dt); + Check(std::abs(state.angle-caughtAngle)<0.001f,"grabbing during return to center arrests the return immediately"); + WheelReferenceLatch reference; + WheelGeometry geometry;geometry.valid=true; + Check(reference.Resolve(geometry,true,true,true,false,1,dt),"valid native reference acquired"); + geometry={}; + Check(reference.Resolve(geometry,true,false,true,false,1,dt)&&geometry.valid,"brief mesh dropout retains held reference"); + for(int i=0;i<30;++i) { geometry={};reference.Resolve(geometry,true,false,true,false,1,dt); } + Check(!reference.Resolve(geometry,true,false,true,false,1,dt),"missing reference expires"); + geometry.valid=true;reference.Resolve(geometry,true,true,true,false,1,dt);geometry={}; + Check(!reference.Resolve(geometry,true,false,true,false,2,dt),"vehicle change never inherits old controls"); + geometry.valid=true;reference.Resolve(geometry,true,true,true,false,2,dt); + Check(!reference.Resolve(geometry,false,true,true,false,2,dt),"explicit disable overrides grace period"); + wheel={}; hands={};hands[1]=Rim(0); + WheelTuning tuning;tuning.kartDegrees=45; + wheel.Update(hands,true,dt,SteeringWheel::Radius,false,tuning); + for(int i=1;i<=45;++i) { hands[1]=Rim(-i*pi/180);wheel.Update(hands,true,dt,SteeringWheel::Radius,false,tuning); } + for(int i=0;i<90;++i) state=wheel.Update(hands,true,dt,SteeringWheel::Radius,false,tuning); + Check(state.steering>.99f,"custom 45 degree lock is used by input"); + tuning.kartDegrees=std::numeric_limits::quiet_NaN(); + state=wheel.Update(hands,true,dt,SteeringWheel::Radius,false,tuning); + Check(std::isfinite(state.steering),"invalid tuning cannot poison steering"); + + // Retracing motion beyond full lock must return to the original centre, + // including complete turns and atan2's +/-pi boundary in either direction. + for(bool bike : {false,true}) for(bool twoHands : {false,true}) for(float sign : {-1.0f,1.0f}) { + wheel={}; + const auto pose=[&](int degrees) { + const float angle=sign*degrees*pi/180; + return std::array{twoHands?Rim(pi-angle):WheelHand{},Rim(-angle)}; + }; + hands=pose(0);wheel.Update(hands,true,dt,0.18f,bike); + for(int i=1;i<=720;++i) { hands=pose(i);state=wheel.Update(hands,true,dt,0.18f,bike); } + for(int i=0;i<90;++i) state=wheel.Update(hands,true,dt,0.18f,bike); + Check(std::abs(state.angle-sign*4*pi)<0.003f,"physical wheel preserves two complete turns beyond full lock"); + Check(sign*state.steering>0.99f,"overtravel saturates game steering without reversing it"); + Check(bike ? std::abs(state.visualAngle-sign*pi/4)<0.003f : std::abs(state.visualAngle)<0.003f, + "kart visual follows complete turns while bike visual retains limited travel"); + for(int i=719;i>=0;--i) { hands=pose(i);state=wheel.Update(hands,true,dt,0.18f,bike); } + for(int i=0;i<90;++i) state=wheel.Update(hands,true,dt,0.18f,bike); + Check(std::abs(state.angle)<0.003f && std::abs(state.steering)<0.003f,"return from overtravel preserves original centre for kart/bike and one/two hands"); + } + // Bring both hands together away from the hub at full right lock. Once + // their span is too short to define a rigid control, jitter must not steer. + wheel={};hands={Rim(pi),Rim(0)};wheel.Update(hands,true,dt); + for(int i=1;i<=120;++i) { hands={Rim(pi-i*pi/180),Rim(-i*pi/180)};wheel.Update(hands,true,dt); } + for(int i=0;i<90;++i) state=wheel.Update(hands,true,dt); + const float lockAngle=state.angle; + const auto compressed=[&](float radius,float jitter=0) { + const float a=-120*pi/180+jitter; + return std::array{ + WheelHand{0.1f-radius*std::cos(a),SteeringWheel::Height+0.12f-radius*std::sin(a),SteeringWheel::Depth,1,true}, + WheelHand{0.1f+radius*std::cos(a),SteeringWheel::Height+0.12f+radius*std::sin(a),SteeringWheel::Depth,1,true}}; + }; + for(int i=0;i<=90;++i) { hands=compressed(0.18f-0.16f*i/90);state=wheel.Update(hands,true,dt); } + for(int i=0;i<90;++i) { hands=compressed(0.02f,0.7f*std::sin(i*0.1f));state=wheel.Update(hands,true,dt); } + Check(state.held[0]&&state.held[1]&&std::abs(state.angle-lockAngle)<0.003f,"close-hand motion retains grip and cannot change steering reference"); + for(int i=0;i<=90;++i) { hands=compressed(0.02f+0.16f*i/90);state=wheel.Update(hands,true,dt); } + for(int i=119;i>=0;--i) { + hands={Rim(pi-i*pi/180),Rim(-i*pi/180)}; + for(auto& hand:hands) { hand.x+=0.1f;hand.y+=0.12f; } + state=wheel.Update(hands,true,dt); + } + for(int i=0;i<90;++i) state=wheel.Update(hands,true,dt); + Check(std::abs(state.steering)<0.003f,"opening hands after full lock still returns to the same centre"); + std::cout<<(failures?"FAIL":"PASS")<<": steering wheel scenarios\n"; + return failures?1:0; +} diff --git a/runtime/tests/vr_cockpit_tests.cpp b/runtime/tests/vr_cockpit_tests.cpp new file mode 100644 index 0000000..f1c3d0e --- /dev/null +++ b/runtime/tests/vr_cockpit_tests.cpp @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// The first-person cockpit's pure geometry, tested without a guest: where the +// seated eye comes from, how the vehicle's wheel and handlebar land in the +// seated frame, the level seat through spins, and which of the vehicle's own +// vertices the wheel animation turns. The math is ported from heurazy's +// mario-kart-wii-VR-port. + +#include "vr/cockpit_stabilizer.h" +#include "vr/mkw_vr_first_person.h" +#include "vr/native_wheel_mesh.h" + +#include +#include +#include + +namespace { + +using namespace mkw::vr; + +int g_failures = 0; + +void Check(bool condition, const char* what) { + if (!condition) { + ++g_failures; + std::cerr << "FAILED: " << what << '\n'; + } +} + +void CheckNear(float actual, float expected, const char* what, float tolerance = 1.0e-3f) { + if (!(std::fabs(actual - expected) <= tolerance)) { + ++g_failures; + std::cerr << "FAILED: " << what << " (expected " << expected << ", got " << actual << ")\n"; + } +} + +Mtx34 Translation(float x, float y, float z) { + Mtx34 m = kIdentityMtx34; + m[3] = x; + m[7] = y; + m[11] = z; + return m; +} + +Mtx34 YawAt(float yaw, float x, float y, float z) { + const float c = std::cos(yaw), s = std::sin(yaw); + return {c, 0, s, x, 0, 1, 0, y, -s, 0, c, z}; +} + +void TestMatrixHelpers() { + const Mtx34 a = YawAt(0.7f, 1.0f, 2.0f, 3.0f); + Mtx34 inverse{}; + Check(InvertMtx(a, inverse), "a rigid transform inverts"); + const Mtx34 identity = ComposeMtx(a, inverse); + for (int i = 0; i < 12; ++i) { + CheckNear(identity[i], kIdentityMtx34[i], "a * inverse(a) is identity", 1e-5f); + } + Mtx34 singular{}; + Check(!InvertMtx(singular, inverse), "a singular matrix does not invert"); + const Mtx34 scaled = ScaleModelBasis(kIdentityMtx34, {2.0f, 3.0f, 4.0f}); + CheckNear(scaled[0], 2.0f, "basis X scaled"); + CheckNear(scaled[5], 3.0f, "basis Y scaled"); + CheckNear(scaled[10], 4.0f, "basis Z scaled"); + CheckNear(scaled[3], 0.0f, "translation untouched"); +} + +void TestSeatHelpers() { + CheckNear(CharacterCockpitScale(80.0f), 1.0f, "short characters keep the base scale"); + CheckNear(CharacterCockpitScale(150.0f), 1.5f, "tall characters grow the scale with eye height"); + CheckNear(CharacterCockpitScale(1000.0f), 2.5f, "the scale is capped"); + CheckNear(CharacterCockpitScale(std::nanf("")), 1.0f, "a bad eye height keeps the base scale"); + CheckNear(ValidPlayerScale(2.0f), 2.0f, "mega mushroom scale kept"); + CheckNear(ValidPlayerScale(0.0f), 1.0f, "an implausible scale is ignored"); + Check(NeutralPlayerScale({1.0f, 1.0f, 1.0f}), "unit scale is neutral"); + Check(!NeutralPlayerScale({0.5f, 0.5f, 0.5f}), "lightning scale is not neutral"); + // 100 units per metre, controls 60 units ahead: the eye stays at least 0.45 m behind. + CheckNear(EyeBehindControls(50.0f, 60.0f, 100.0f, 0.0f), 60.0f - 45.0f, "eye pulled behind the wheel"); + CheckNear(EyeBehindControls(50.0f, 60.0f, 100.0f, 50.0f), 60.0f - 55.0f, "a wider wheel keeps more clearance"); + CheckNear(EyeBehindControls(-10.0f, 60.0f, 100.0f, 18.0f), -10.0f, "an eye already behind stays put"); +} + +void TestDriverEye() { + std::array eye{}; + // Face bone at (0, 80, 10) in the character, placed 5 units up in the vehicle. + const Mtx34 face = Translation(0.0f, 80.0f, 10.0f); + const Mtx34 placement = Translation(0.0f, 5.0f, 0.0f); + Check(ComputeDriverEyeFromBounds(face, placement, {-2, 8, 0}, {2, 12, 4}, eye), "eye from bounds"); + CheckNear(eye[1], 95.0f, "bounds centre through bind and placement (up)"); + CheckNear(eye[2], 12.0f, "bounds centre through bind and placement (forward)"); + Check(!ComputeDriverEyeFromBounds(face, placement, {2, 8, 0}, {-2, 12, 4}, eye), "inverted bounds rejected"); + Check(!ComputeDriverEyeFromBounds(Translation(0, -50, 0), placement, {0, 0, 0}, {1, 1, 1}, eye), + "an eye below the seat is rejected"); + + // The same eye through the animated world matrices: the body's own motion + // must not leak into the seat. + const Mtx34 body = YawAt(1.2f, 500.0f, 20.0f, -300.0f); + const Mtx34 faceWorld = ComposeMtx(body, Translation(0.0f, 90.0f, 15.0f)); + Check(ComputeSeatedEye(faceWorld, body, {0, 0, 0}, eye), "seated eye from world matrices"); + CheckNear(eye[0], 0.0f, "seated eye right", 1e-3f); + CheckNear(eye[1], 90.0f, "seated eye up", 1e-3f); + CheckNear(eye[2], 15.0f, "seated eye forward", 1e-3f); + + SeatedEyeReference reference; + for (int i = 0; i < 7; ++i) { + reference.Observe({0, 90, 15}, true, true); + } + Check(!reference.valid, "seven samples are not enough"); + reference.Observe({0, 90, 15}, true, true); + Check(reference.valid, "eight stable samples calibrate the seat"); + reference.Observe({0, 200, 15}, true, true); + CheckNear(reference.value[1], 90.0f, "a calibrated seat is frozen"); + SeatedEyeReference interrupted; + for (int i = 0; i < 5; ++i) { + interrupted.Observe({0, 90, 15}, true, true); + } + interrupted.Observe({0, 90, 15}, false, true); + for (int i = 0; i < 5; ++i) { + interrupted.Observe({0, 90, 15}, true, true); + } + Check(!interrupted.valid, "an unsafe sample restarts calibration"); +} + +void TestWheelGeometry() { + // Grip targets 20 units either side of a wheel 60 units ahead and 50 up, + // 100 units per metre, seat frame = the vehicle frame turned to face -Z + // (vehicle +Z forward, +X to the driver's left). + const Mtx34 seatFromBody{-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, -1, 0}; + const WheelGeometry wheel = ComputeNativeWheelGeometry(seatFromBody, {20, 50, 60}, {-20, 50, 60}, 100.0f); + Check(wheel.valid, "wheel geometry from the grip targets"); + CheckNear(wheel.radius, 0.2f, "radius is half the grip span"); + CheckNear(wheel.center[1], 0.5f, "centre height in metres"); + CheckNear(wheel.center[2], -0.6f, "centre ahead in metres"); + CheckNear(wheel.right[0], 1.0f, "wheel right is the seated right"); + CheckNear(wheel.up[1], 1.0f, "wheel up is the vehicle's up"); + const auto swapped = ComputeNativeWheelGeometry(seatFromBody, {-20, 50, 60}, {20, 50, 60}, 100.0f); + CheckNear(swapped.right[0], wheel.right[0], "grip order does not flip the wheel"); + Check(!ComputeNativeWheelGeometry(seatFromBody, {1, 50, 60}, {-1, 50, 60}, 100.0f).valid, + "a wheel narrower than 4 cm is rejected"); + + // A hand on the right of the rim maps onto the wheel's rim at angle zero. + WheelHand hand{wheel.center[0] + 0.2f, wheel.center[1], wheel.center[2], 1.0f, true}; + const WheelHand local = wheel.ToWheel(hand); + CheckNear(local.x, 0.2f, "right rim point is +radius along the wheel"); + CheckNear(local.y, SteeringWheel::Height, "wheel-local height matches the synthetic wheel"); + CheckNear(local.z, SteeringWheel::Depth, "wheel-local depth matches the synthetic wheel"); + + // Handlebar: position from the (steered) handle, axes from the neutral body. + const float steer = 0.4f, c = std::cos(steer), s = std::sin(steer); + const Mtx34 steeredHandle{-c, 0, -s, 0, 0, 1, 0, 0, s, 0, -c, 0}; + const auto bar = ComputeNativeHandlebarGeometry(steeredHandle, seatFromBody, {20, 50, 60}, {-20, 50, 60}, 100.0f); + Check(bar.valid, "handlebar geometry"); + CheckNear(bar.right[0], 1.0f, "handlebar axes ignore the steering already applied"); +} + +void TestStabilizer() { + CockpitStabilizer stabilizer; + const Mtx34 start = YawAt(0.5f, 10, 0, 20); + auto seat = stabilizer.Update(start, false, 1.0f / 60.0f); + CheckNear(seat[3], 10.0f, "position followed"); + CheckNear(std::atan2(seat[2], seat[10]), 0.5f, "heading followed"); + // Damage spins the chassis; the seat holds its heading but keeps position. + seat = stabilizer.Update(YawAt(2.5f, 12, 0, 21), true, 1.0f / 60.0f); + CheckNear(seat[3], 12.0f, "position exact while damaged"); + CheckNear(std::atan2(seat[2], seat[10]), 0.5f, "heading held while damaged"); + // Recovery eases back onto the real heading. + for (int i = 0; i < 120; ++i) { + seat = stabilizer.Update(YawAt(0.8f, 12, 0, 21), false, 1.0f / 60.0f); + } + CheckNear(std::atan2(seat[2], seat[10]), 0.8f, "heading recovered after damage", 5e-3f); + CheckNear(seat[5], 1.0f, "the seat is always level"); +} + +void TestNativeWheelVertices() { + // A 64-point disc of radius 20 in the vehicle's X/Y plane at z = 60, centred + // at y = 50, plus two far vertices (the chassis) that must never move. + std::vector points; + for (int i = 0; i < 64; ++i) { + const float a = float(i) * 6.2831853f / 64.0f; + points.push_back({20.0f * std::cos(a), 50.0f + 20.0f * std::sin(a), 60.0f}); + } + points.push_back({100.0f, 0.0f, 0.0f}); + points.push_back({0.0f, 50.0f, 200.0f}); + const auto original = points; + const unsigned changed = RotateNativeWheelVertices(points, {0, 50, 60}, 20.0f, 0.5f); + Check(changed == 64, "every disc vertex turns"); + CheckNear(points[64].x, original[64].x, "chassis vertex untouched"); + CheckNear(points[65].z, original[65].z, "vertex off the disc plane untouched"); + // Rotation keeps each disc point on the rim. + for (int i = 0; i < 64; ++i) { + CheckNear(std::hypot(points[i].x, points[i].y - 50.0f), 20.0f, "disc vertex stays on the rim", 1e-2f); + } + auto sparse = std::vector(points.begin(), points.begin() + 4); + Check(RotateNativeWheelVertices(sparse, {0, 50, 60}, 20.0f, 0.5f) == 0, "too few candidates leaves the mesh"); + Check(RotateNativeWheelVertices(points, {0, 50, 60}, 2.0f, 0.5f) == 0, "an implausible radius leaves the mesh"); +} + +} // namespace + +int main() { + TestMatrixHelpers(); + TestSeatHelpers(); + TestDriverEye(); + TestWheelGeometry(); + TestStabilizer(); + TestNativeWheelVertices(); + if (g_failures != 0) { + std::cerr << g_failures << " check(s) failed\n"; + return 1; + } + std::cout << "vr cockpit tests passed\n"; + return 0; +} diff --git a/runtime/tests/vr_hand_steering_tests.cpp b/runtime/tests/vr_hand_steering_tests.cpp new file mode 100644 index 0000000..f2f2976 --- /dev/null +++ b/runtime/tests/vr_hand_steering_tests.cpp @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Hand steering's hand-off to the game and the wheel's displayed angle, tested +// without a headset (vr/openxr_driving.h). + +#include "vr/openxr_driving.h" + +#include +#include + +namespace { + +using namespace mkw::vr; + +int g_failures = 0; + +void Check(bool condition, const char* what) { + if (!condition) { + ++g_failures; + std::cerr << "FAILED: " << what << '\n'; + } +} + +void CheckNear(float actual, float expected, const char* what, float tolerance = 1.0e-3f) { + if (!(std::fabs(actual - expected) <= tolerance)) { + ++g_failures; + std::cerr << "FAILED: " << what << " (expected " << expected << ", got " << actual << ")\n"; + } +} + +std::array Hands() { + std::array hands{}; + hands[0].stick_x = -0.3f; + hands[0].stick_y = 0.8f; + hands[0].squeeze = 1.0f; + hands[1].squeeze = 1.0f; + return hands; +} + +void TestHandOff() { + WheelState wheel{}; + wheel.steering = 0.6f; + auto hands = Hands(); + driving::ApplyHandSteering(hands, wheel); + CheckNear(hands[0].stick_x, -0.3f, "an unheld wheel leaves the stick alone"); + CheckNear(hands[0].squeeze, 1.0f, "an unheld wheel leaves the grips alone"); + + wheel.held = {false, true}; + hands = Hands(); + driving::ApplyHandSteering(hands, wheel); + CheckNear(hands[0].stick_x, 0.6f, "a held wheel steers through the left stick"); + CheckNear(hands[0].stick_y, 0.8f, "the stick keeps aiming items"); + CheckNear(hands[0].squeeze, 1.0f, "a free hand's grip still reaches the game"); + CheckNear(hands[1].squeeze, 0.0f, "a holding grip does not press C or a shoulder"); + + wheel.held = {true, true}; + wheel.steering = 3.0f; + hands = Hands(); + driving::ApplyHandSteering(hands, wheel); + CheckNear(hands[0].stick_x, 1.0f, "steering is clamped to full lock"); + CheckNear(hands[0].squeeze, 0.0f, "both holding grips are released for the game"); + + wheel.steering = std::nanf(""); + hands = Hands(); + driving::ApplyHandSteering(hands, wheel); + CheckNear(hands[0].stick_x, -0.3f, "a non-finite wheel never reaches the game"); +} + +void TestMaxAngle() { + WheelTuning tuning{}; + CheckNear(driving::MaxWheelAngle(false, tuning), 90.0f * 0.01745329252f, "kart full lock"); + CheckNear(driving::MaxWheelAngle(true, tuning), 45.0f * 0.01745329252f, "bike full lock"); + tuning.kartDegrees = 1000.0f; + CheckNear(driving::MaxWheelAngle(false, tuning), 180.0f * 0.01745329252f, "full lock is capped"); +} + +void TestVisual() { + driving::WheelVisual visual; + const float max = driving::MaxWheelAngle(false, WheelTuning{}); + float angle = 0.0f; + for (int i = 0; i < 90; ++i) { + angle = visual.Update(false, 0.0f, 1.0f, max, 1.0f / 90.0f); + } + CheckNear(angle, max, "the wheel follows full right stick to full lock", 1e-3f); + angle = visual.Update(false, 0.0f, -1.0f, max, 1.0f / 90.0f); + Check(angle > 0.0f && angle < max, "a flicked stick eases the wheel rather than snapping it"); + angle = visual.Update(true, -2.5f, 1.0f, max, 1.0f / 90.0f); + CheckNear(angle, -2.5f, "a held wheel shows the hands' angle exactly"); +} + +void TestSeatFrame() { + driving::SeatFrame seat; + seat.valid = true; + seat.base = {1.0f, 1.5f, -0.5f}; + auto m = driving::SeatFromApp(seat, {1.2f, 1.2f, -0.9f}, {0, 0, 0, 1}); + CheckNear(m[3], 0.2f, "seat x is relative to the head"); + CheckNear(m[7], -0.3f, "seat y is relative to the head"); + CheckNear(m[11], -0.4f, "seat z is relative to the head"); + CheckNear(m[0], 1.0f, "unrotated grip keeps its axes"); + + // Leaning back by 90 degrees: the eye transforms place seat point P at + // base + R_lean * P, so an app-space point straight up from the head is + // straight ahead (-Z) in the seat. + seat.lean_back_radians = 1.5707963f; + m = driving::SeatFromApp(seat, {1.0f, 2.5f, -0.5f}, {0, 0, 0, 1}); + CheckNear(m[3], 0.0f, "lean keeps x"); + CheckNear(m[7], 0.0f, "lean moves up out of y", 1e-5f); + CheckNear(m[11], -1.0f, "lean turns up into forward", 1e-5f); +} + +} // namespace + +int main() { + TestHandOff(); + TestMaxAngle(); + TestVisual(); + TestSeatFrame(); + if (g_failures != 0) { + std::cerr << g_failures << " check(s) failed\n"; + return 1; + } + std::cout << "vr hand steering tests passed\n"; + return 0; +}