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.
This commit is contained in:
iChris4 committed 2026-09-22 03:55:40 +02:00
1 parent 21b8d209dd
commit 50dc354123
12 files changed
+1547 -8

No files matched your search

+11
View File
@@ -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")
+193 -8
View File
@@ -21,6 +21,7 @@
#include <toml.hpp>
#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<bool> vrFirstPersonHideDriver;
std::optional<int32_t> vrFirstPersonHiddenModel;
std::optional<std::string> vrFirstPersonRotation;
std::optional<std::string> vrFirstPersonSeat;
std::optional<float> vrCockpitUnitsPerMeter;
std::optional<bool> vrSteeringWheel;
std::optional<bool> vrNativeSteeringWheel;
std::optional<bool> vrHandSteering;
std::optional<float> vrWheelKartDegrees;
std::optional<float> vrWheelBikeDegrees;
std::optional<float> vrWheelGrabDistance;
std::optional<float> vrWheelGrabAssist;
std::optional<float> vrWheelResponse;
std::optional<float> vrWheelTrackingGrace;
std::optional<bool> vrWheelHaptics;
std::optional<std::string> vrPerformanceLevel;
std::optional<std::string> vrRecenterKey;
std::optional<float> 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<int32_t>(*value);
}
if (auto value = FindConfigValue<std::string>(document, "vr", "first_person_seat");
value && IsSupportedVrFirstPersonSeat(*value)) {
config.vrFirstPersonSeat = *value;
}
const auto readRangedFloat = [&](std::string_view key, float low, float high) -> std::optional<float> {
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<bool>(document, "vr", "steering_wheel");
config.vrNativeSteeringWheel = FindConfigValue<bool>(document, "vr", "native_steering_wheel");
config.vrHandSteering = FindConfigValue<bool>(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<bool>(document, "vr", "wheel_haptics");
config.diagnosticsOpenXRLogging = FindConfigValue<bool>(document, "diagnostics", "openxr_logging");
auto readVolume = [&](std::string_view key) -> std::optional<float> {
@@ -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<float>& 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));
}
+40
View File
@@ -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 <algorithm>
#include <cmath>
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<float,3> position_{};
float yaw_=0;
bool valid_=false,recovering_=false;
};
}
+261
View File
@@ -2,6 +2,9 @@
#pragma once
#include "vr/steering_wheel.h"
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
@@ -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<float, 3>& 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<float, 3>& 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<float, 3>& eye) noexcept {
if (!detail::IsFiniteMtx34(face) || !detail::IsFiniteMtx34(placement)) {
return false;
}
const std::array<float, 6> 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<float, 3> 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<float, 3>& 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<float, 3> 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<float, 3> value{}, candidate{};
unsigned stable = 0;
bool valid = false;
void Observe(const std::array<float, 3>& 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.
+45
View File
@@ -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 <vector>
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<detail::Vec3>& 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*1.5f && std::abs(p.y-center.y)<radius*1.5f &&
std::abs(p.z-center.z)<radius*0.9f;
};
for(const auto& p:points) if(candidate(p)) { meanY+=p.y; meanZ+=p.z; ++count; }
if(count<8) return 0;
meanY/=count; meanZ/=count;
float yy=0,yz=0;
for(const auto& p:points) if(candidate(p)) { yy+=(p.y-meanY)*(p.y-meanY); yz+=(p.y-meanY)*(p.z-meanZ); }
if(yy<radius*radius) return 0;
const float slope=std::clamp(yz/yy,-1.0f,1.0f);
center.z=meanZ+slope*(center.y-meanY);
const float inv=1/std::sqrt(1+slope*slope);
const detail::Vec3 up{0,inv,slope*inv},normal{0,-slope*inv,inv};
const float c=std::cos(angle),s=std::sin(angle);
unsigned changed=0;
for(auto& p:points) {
const detail::Vec3 delta{p.x-center.x,p.y-center.y,p.z-center.z};
const float x=delta.x,y=detail::Dot(delta,up),z=detail::Dot(delta,normal);
if(x*x+y*y>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
+164
View File
@@ -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 <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <cstring>
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<float, 12> 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<bool, 2> 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<DrivingHand, 2> 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<wii_remote::HandInputs, 2>& 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<float, 3> 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<float, 12> SeatFromApp(const SeatFrame& seat, const std::array<float, 3>& position,
const std::array<float, 4>& 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<float, 3>{vx, c * vy + s * vz, -s * vy + c * vz};
};
std::array<float, 12> 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
+75
View File
@@ -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 <aurora/aurora.h>
#include <algorithm>
#include <array>
#include <cstring>
#include <vector>
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<PFN_xrVoidFunction*>(&create));
xrGetInstanceProcAddr(runtime.Instance(), "xrDestroyHandTrackerEXT", reinterpret_cast<PFN_xrVoidFunction*>(&destroy));
xrGetInstanceProcAddr(runtime.Instance(), "xrGetHandMeshFB", reinterpret_cast<PFN_xrVoidFunction*>(&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<XrPosef> poses(mesh.jointCountOutput);
std::vector<float> radii(mesh.jointCountOutput);
std::vector<XrHandJointEXT> parents(mesh.jointCountOutput);
std::vector<XrVector3f> positions(mesh.vertexCountOutput), normals(mesh.vertexCountOutput);
std::vector<XrVector2f> uv(mesh.vertexCountOutput);
std::vector<XrVector4sFB> joints(mesh.vertexCountOutput);
std::vector<XrVector4f> weights(mesh.vertexCountOutput);
std::vector<int16_t> 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<AuroraVRHandVertex> 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<float, 26 * 7> bind{};
std::array<int32_t, 26> 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<int32_t>(parents[j]);
}
aurora_set_vr_hand_mesh(h, vertices.data(), vertices.size(),
reinterpret_cast<const uint16_t*>(indices.data()), indices.size(), bind.data(), parentIds.data(), poses.size());
any = true;
}
return any;
}
} // namespace mkw::vr
+163
View File
@@ -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 <algorithm>
#include <array>
#include <cmath>
#include <cstring>
#include <cstdint>
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<float,3> 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<float,3> 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<bool, 2> 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<WheelHand, 2>& 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<bool,2> moving{};
std::array<bool,2> validHands{};
std::array<float,2> 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]<grace) {
center_[h]=true;
} else { state_.held[h]=false; pressed_[h]=down; }
continue;
}
lost_[h]=0;
const float y=p.y-Height, z=p.z-Depth;
const float radial=std::hypot(p.x,y);
const float gripX=radius*std::cos(state_.angle),gripY=-radius*std::sin(state_.angle);
const bool near_rim=std::abs(z)<reach && (handlebars
? std::min(std::hypot(p.x-gripX,y-gripY),std::hypot(p.x+gripX,y+gripY))<std::max(0.22f,radius*0.55f)*assist
: radial<std::max(radius+0.16f,0.32f)*assist);
const float angle=-std::atan2(y,p.x);
// Arcade latch: distance only gates acquisition. Once grabbed,
// large gestures and vehicle animation cannot release ownership.
if (!active || !down)
state_.held[h]=false;
else if (!pressed_[h] && near_rim) {
state_.held[h]=true;
last_[h]=angle;
center_[h]=false;
}
if (state_.held[h]) {
// Angle is undefined at the hub. Keep ownership and the last
// steering value, then rebase on exit to avoid a 180-degree jump.
if (radial<(center_[h]?0.065f:0.045f)) center_[h]=true;
else {
moving[h]=!center_[h];
if (moving[h]) delta[h]=std::remainder(angle-last_[h], 6.283185307f);
last_[h]=angle;
center_[h]=false;
}
if(moving[h]) {
const float weight=std::clamp(radial/0.18f,0.15f,1.0f);
deltaSum+=delta[h]*weight; weightSum+=weight;
}
}
pressed_[h]=down;
}
// Two hands define one rigid control. Their relative angle ignores
// shared translations, so leaning or moving both arms does not steer.
const float spanX=hands[1].x-hands[0].x,spanY=hands[1].y-hands[0].y;
// Pair orientation is defined by the span, even when one hand is near
// the original hub after a common translation of both arms.
const bool pair=state_.held[0]&&state_.held[1]&&validHands[0]&&validHands[1]&&
std::hypot(spanX,spanY)>(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<bool,2> pressed_{};
std::array<bool,2> center_{};
std::array<float,2> last_{};
std::array<float,2> lost_{};
float target_=0,lastPair_=0;
bool pairValid_=false;
};
} // namespace mkw::vr
+35
View File
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "vr/openxr_driving.h"
#include <mutex>
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
+224
View File
@@ -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 <iostream>
#include <limits>
using namespace mkw::vr;
int failures=0;
void Check(bool ok,const char* message) { if(!ok) { std::cerr<<message<<'\n';++failures; } }
WheelHand Rim(float angle,float squeeze=1) {
return {SteeringWheel::Radius*std::cos(angle),SteeringWheel::Height+SteeringWheel::Radius*std::sin(angle),
SteeringWheel::Depth,squeeze,true};
}
int main() {
constexpr float pi=3.141592653589793f,dt=1.0f/90;
SteeringWheel wheel;
std::array<WheelHand,2> 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<float>::quiet_NaN();
state=wheel.Update(hands,true,std::numeric_limits<float>::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<float>::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<float>::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<WheelHand,2>{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,2>{
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;
}
+212
View File
@@ -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 <cmath>
#include <iostream>
#include <vector>
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<float, 3> 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<detail::Vec3> 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<detail::Vec3>(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;
}
+124
View File
@@ -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 <cmath>
#include <iostream>
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<wii_remote::HandInputs, 2> Hands() {
std::array<wii_remote::HandInputs, 2> 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;
}