Add item throwing mechanics to VR configuration and input handling

- Introduced `vrCockpitItemThrow` configuration option to enable throwing items in VR.
- Implemented `ThrowDetector` and `ThrowSequence` classes to manage item throwing mechanics based on hand movements.
- Updated `OpenXRInput` to handle item throwing based on hand swings, integrating with existing VR input systems.
- Enhanced settings overlay to allow users to toggle item throwing functionality.
- Added tests for item throwing mechanics, ensuring correct behavior for various hand movements and conditions.
This commit is contained in:
iChris4 committed 2026-09-30 02:08:28 +02:00
1 parent db30945f4c
commit ebc97ef9ce
12 files changed
+701 -6

No files matched your search

+3 -3
View File
@@ -415,10 +415,10 @@ 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, and tracked hands (grasp, bare-hand
# buttons, flick). All header-only.
# ported from heurazy's mario-kart-wii-VR-port, tracked hands (grasp, bare-hand
# buttons, flick) and throwing the held item. All header-only.
foreach(test_name mkw_steering_wheel_tests mkw_vr_cockpit_tests mkw_vr_bullet_bill_tests mkw_vr_hand_steering_tests mkw_vr_camera_toggle_tests
mkw_vr_hand_tracking_tests mkw_vr_culling_tests)
mkw_vr_hand_tracking_tests mkw_vr_culling_tests mkw_vr_item_throw_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")
+15
View File
@@ -79,6 +79,7 @@ struct RuntimeUserConfig {
std::optional<bool> vrObjectCulling;
std::optional<bool> vrHandSteering;
std::optional<std::string> vrCockpitItemHand;
std::optional<bool> vrCockpitItemThrow;
std::optional<bool> vrHandTracking;
std::optional<float> vrWheelKartDegrees;
std::optional<float> vrWheelBikeDegrees;
@@ -239,6 +240,8 @@ inline constexpr bool kVrSteeringWheelDefault = true;
inline constexpr bool kVrNativeSteeringWheelDefault = true;
inline constexpr bool kVrHandSteeringDefault = true;
inline constexpr const char* kVrCockpitItemHandDefault = "left";
// A quick swing of the item hand forward or back throws the item that way.
inline constexpr bool kVrCockpitItemThrowDefault = true;
// The game hides karts and objects its own camera cannot see, which a head
// turn in VR reveals. object_culling false draws them anyway (see
// vr/mkw_vr_culling.h); it only takes effect while VR is enabled. The PC
@@ -609,6 +612,8 @@ inline void EnsureConfigFile() {
"hand_steering = true\n"
"# Show the settled inventory item in one cockpit hand: left, right, or off.\n"
"cockpit_item_hand = \"left\"\n"
"# Swing that hand forward or back to throw the item that way.\n"
"cockpit_item_throw = true\n"
"wheel_kart_degrees = 90.0\n"
"wheel_bike_degrees = 45.0\n"
"wheel_grab_distance = 0.35\n"
@@ -885,6 +890,7 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
config.vrObjectCulling = FindConfigValue<bool>(document, "vr", "object_culling");
config.vrHandSteering = FindConfigValue<bool>(document, "vr", "hand_steering");
config.vrCockpitItemHand = FindConfigValue<std::string>(document, "vr", "cockpit_item_hand");
config.vrCockpitItemThrow = FindConfigValue<bool>(document, "vr", "cockpit_item_throw");
config.vrHandTracking = FindConfigValue<bool>(document, "vr", "hand_tracking");
config.vrWheelKartDegrees = readRangedFloat("wheel_kart_degrees", kVrWheelDegreesMin, kVrWheelDegreesMax);
config.vrWheelBikeDegrees = readRangedFloat("wheel_bike_degrees", kVrWheelDegreesMin, kVrWheelDegreesMax);
@@ -1293,6 +1299,11 @@ inline bool SetVrCockpitItemHand(const std::string& value) {
return WriteSetting("vr", "cockpit_item_hand", "\"" + value + "\"");
}
inline bool SetVrCockpitItemThrow(bool value) {
Mutable().vrCockpitItemThrow = value;
return WriteSetting("vr", "cockpit_item_throw", value ? "true" : "false");
}
inline bool SetVrHandTracking(bool value) {
Mutable().vrHandTracking = value;
return WriteSetting("vr", "hand_tracking", value ? "true" : "false");
@@ -1789,6 +1800,10 @@ inline std::string VrCockpitItemHand() {
return value == "left" || value == "right" || value == "off" ? value : kVrCockpitItemHandDefault;
}
inline bool VrCockpitItemThrow(bool fallback = kVrCockpitItemThrowDefault) {
return Get().vrCockpitItemThrow.value_or(fallback);
}
inline bool VrHandTracking(bool fallback = kVrHandTrackingDefault) {
return Get().vrHandTracking.value_or(fallback);
}
+13
View File
@@ -7,6 +7,7 @@
#include "vr/camera_toggle.h"
#include "vr/openxr_driving.h"
#include "vr/openxr_hand_tracking.h"
#include "vr/openxr_item_throw.h"
#include "vr/openxr_runtime.h"
#include "vr/openxr_settings_panel.h"
#include "vr/openxr_wii_remote.h"
@@ -72,6 +73,11 @@ struct OpenXRPointerScreen {
// the game (a shoulder on the gamepad; as a Wii Remote the grips are unbound,
// so a hand on the wheel cannot hold down a button).
//
// Throwing the held item ([vr] cockpit_item_throw, openxr_item_throw.h): in the
// cockpit, a quick swing forward or back of the hand showing the item pushes the
// left stick that way while the left trigger (Z, or the GameCube's L) is pressed
// and released, which the game reads as an aimed throw.
//
// Tracked hands ([vr] hand_tracking, with hand steering): two hand trackers
// (XR_EXT_hand_tracking) located every frame give the cockpit the hands' own
// joints, from the controllers' touch sensors while they are held and from the
@@ -160,6 +166,9 @@ private:
void UpdateDriving(XrTime display_time, const driving::SeatFrame& seat,
std::array<wii_remote::HandInputs, kHands>& hands, bool withheld);
void ResetDriving();
// After the wheel and the bare hands: the item hand's swing, and the throw it
// plays on the left hand's stick and trigger.
void UpdateItemThrow(float dt_seconds, std::array<wii_remote::HandInputs, kHands>& hands, bool withheld);
// Tracked hands: the extension's functions at Create, the trackers as the
// settings ask for them, and both hands located for `time`, their joints
// in `seat` when it is valid.
@@ -255,6 +264,10 @@ private:
hand_tracking::FlickDetector m_flick;
XrTime m_flick_start = 0;
bool m_injected_flick_held = false;
// Throwing the held item.
item_throw::ThrowDetector m_throw;
item_throw::ThrowSequence m_throw_sequence;
bool m_injected_throw_held = false;
uint64_t m_profile_serial = 0;
bool m_created = false;
+274
View File
@@ -0,0 +1,274 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
// Throwing the held item ([vr] cockpit_item_throw). In the first-person cockpit,
// a quick swing forward or backward of the hand that shows the item plays what
// Mario Kart Wii reads as an aimed throw: the stick pushed forward or back while
// the item button is pressed and released. It goes through the left hand's
// controls, which both controller modes read as the stick and the item button
// (the Nunchuk's stick and Z, the GameCube's stick and L), so it works whichever
// hand shows the item and with bare hands too. Nothing here depends on OpenXR,
// so the rules are tested headlessly (tests/vr_item_throw_tests.cpp).
#include "vr/openxr_wii_remote.h"
#include <algorithm>
#include <array>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <cstring>
namespace mkw::vr::item_throw {
enum class Direction : uint8_t { None, Forward, Backward };
inline const char* DirectionLabel(Direction direction) noexcept {
switch (direction) {
case Direction::Forward: return "forward";
case Direction::Backward: return "backward";
default: return "none";
}
}
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 item hand this frame, in the seated frame (+X right, +Y up, -Z forward),
// in metres: the grip, or the palm joint when the hand is drawn from joints.
struct HandSample {
bool tracked = false;
bool held = false; // holding the wheel or handlebar
bool bare = false; // tracked by the headset's cameras, no controller in it
std::array<float, 3> position{};
};
// What counts as a throw for one kind of hand: the travel along the seated
// frame's forward axis within the window, how much of the window's displacement
// must be along that axis, the speed along it that means the swing goes on, and
// how long a loss of tracking is bridged by the positions either side of it.
struct Tuning {
float window_seconds;
float forward_travel; // m
float backward_travel; // m: throwing back is harder
float axis_share;
float moving_speed; // m/s
float max_gap_seconds;
size_t min_samples;
};
// A controller is located smoothly every frame: 1.5 m/s forward, 1.25 back.
inline constexpr Tuning kControllerTuning{0.12f, 0.18f, 0.15f, 0.7f, 0.5f, 0.1f, 3};
// The cameras lag and smooth a bare hand and often lose it for a few frames in
// the middle of a fast swing, so its swing reads slower and shorter: a longer
// window, less travel (1 m/s forward, 0.87 back), and the gap bridged.
inline constexpr Tuning kBareHandTuning{0.15f, 0.15f, 0.13f, 0.65f, 0.3f, 0.2f, 2};
// A throw is a swing along the seated frame's forward axis: within the last
// window the hand has covered at least the direction's travel along that axis,
// mostly along it (so a sideways sweep or the upward flick that does a trick
// never counts), and it is still moving that way. Reaching for the wheel or
// bringing a hand back to rest is too slow to cover the travel. The window is
// counted in tracked time, so a bridged gap does not use it up; across a gap
// the swing must still average most of the window's speed, so a slow drift the
// cameras briefly lost never counts. A hand fires once per swing and then waits
// out the cooldown; a hand that just let go of the wheel must be free a moment
// first; a tracking jump, faster than any hand, restarts the track.
class ThrowDetector {
public:
static constexpr float kJumpSpeed = 8.0f; // m/s: faster than a hand, a tracking jump
static constexpr float kGapSpeedShare = 0.7f;
static constexpr float kFreeSeconds = 0.25f;
static constexpr float kCooldownSeconds = 0.6f;
// What the last throw measured, for the log.
struct Measure {
float travel = 0.0f; // m along the forward axis, + forward
float seconds = 0.0f; // the window it covered, gaps included
bool bare = false;
bool bridged = false; // the window spans a loss of tracking
};
Direction Update(const HandSample& hand, float dt) noexcept {
if (!IsFinite(dt) || dt <= 0.0f) {
return Direction::None;
}
dt = std::min(dt, 0.1f);
m_cooldown = std::max(m_cooldown - dt, 0.0f);
m_free = std::min(m_free + dt, 1.0f);
m_time += dt;
const Tuning& tuning = hand.bare ? kBareHandTuning : kControllerTuning;
const auto& p = hand.position;
if (!hand.tracked || !IsFinite(p[0]) || !IsFinite(p[1]) || !IsFinite(p[2])) {
if (m_count > 0 && m_time - At(0).time > tuning.max_gap_seconds) {
ClearTrack();
}
return Direction::None;
}
if (hand.held) {
ClearTrack();
m_free = 0.0f;
return Direction::None;
}
bool bridged_now = false;
if (m_count > 0) {
const Sample& last = At(0);
const float elapsed = m_time - last.time;
if (elapsed > tuning.max_gap_seconds + dt ||
Distance(p, last.position) / std::max(elapsed, 1.0e-4f) > kJumpSpeed) {
ClearTrack();
} else {
bridged_now = elapsed > dt + 1.0e-4f;
}
}
m_tracked_time += dt;
Push({m_time, m_tracked_time, bridged_now, p});
if (m_count < 2) {
return Direction::None;
}
// The swing's speed along the axis now (+ forward), from the last step.
const Sample& previous = At(1);
const float forward_speed = -(p[2] - previous.position[2]) / std::max(m_time - previous.time, 1.0e-4f);
if (m_swing != Direction::None && Along(m_swing, forward_speed) < tuning.moving_speed) {
m_swing = Direction::None; // that swing is over
}
// The oldest sample still inside the window, in tracked time.
size_t oldest = 0;
bool bridged = false;
for (size_t i = 1; i < m_count && m_tracked_time - At(i).tracked <= tuning.window_seconds + 1.0e-4f; ++i) {
bridged = bridged || At(i - 1).bridged;
oldest = i;
}
if (oldest + 1 < tuning.min_samples) {
return Direction::None;
}
const Sample& start = At(oldest);
const std::array<float, 3> moved{p[0] - start.position[0], p[1] - start.position[1], p[2] - start.position[2]};
const float length = std::sqrt(moved[0] * moved[0] + moved[1] * moved[1] + moved[2] * moved[2]);
const float travel = -moved[2];
const float seconds = m_time - start.time;
const Direction direction = travel >= tuning.forward_travel ? Direction::Forward
: -travel >= tuning.backward_travel ? Direction::Backward
: Direction::None;
if (direction == Direction::None) {
return Direction::None;
}
const float needed = direction == Direction::Forward ? tuning.forward_travel : tuning.backward_travel;
if (std::fabs(travel) < tuning.axis_share * length || Along(direction, forward_speed) < tuning.moving_speed ||
std::fabs(travel) / std::max(seconds, 1.0e-4f) < kGapSpeedShare * needed / tuning.window_seconds) {
return Direction::None;
}
if (m_free < kFreeSeconds || m_cooldown > 0.0f || m_swing == direction) {
// Too soon: this whole swing is spent, however long it goes on.
m_swing = direction;
return Direction::None;
}
m_cooldown = kCooldownSeconds;
m_swing = direction;
m_last = {travel, seconds, hand.bare, bridged};
return direction;
}
const Measure& Last() const noexcept { return m_last; }
void Reset() noexcept { *this = ThrowDetector{}; }
private:
struct Sample {
float time = 0.0f; // since the detector started, gaps included
float tracked = 0.0f; // tracked time only
bool bridged = false; // follows a loss of tracking
std::array<float, 3> position{};
};
static constexpr size_t kCapacity = 32; // over either window at any headset's rate
static float Along(Direction direction, float forward_speed) noexcept {
return direction == Direction::Forward ? forward_speed : -forward_speed;
}
static float Distance(const std::array<float, 3>& a, const std::array<float, 3>& b) noexcept {
const float x = a[0] - b[0], y = a[1] - b[1], z = a[2] - b[2];
return std::sqrt(x * x + y * y + z * z);
}
// Newest first: At(0) is the latest sample.
const Sample& At(size_t age) const noexcept { return m_samples[(m_next + kCapacity - 1 - age) % kCapacity]; }
void Push(const Sample& sample) noexcept {
m_samples[m_next] = sample;
m_next = (m_next + 1) % kCapacity;
m_count = std::min(m_count + 1, kCapacity);
}
void ClearTrack() noexcept {
m_count = 0;
m_swing = Direction::None;
}
std::array<Sample, kCapacity> m_samples{};
size_t m_next = 0;
size_t m_count = 0;
float m_time = 0.0f;
float m_tracked_time = 0.0f;
float m_free = 1.0f; // seconds since the hand last held the wheel, capped
float m_cooldown = 0.0f;
Direction m_swing = Direction::None; // the swing that last fired, while it lasts
Measure m_last{};
};
// The input a throw plays on the left hand's controls. The stick goes first and
// stays until the item button has been pressed and released again, so an item
// used on the press (a Mushroom) and one thrown on the release (a trailed shell
// or banana) both see the aim. A button the player was already holding, to
// trail an item, is released by the throw and then ignored until the player
// lets go of it, so it does not use the next item of a triple.
class ThrowSequence {
public:
static constexpr float kAimSeconds = 0.05f; // the stick alone, a few game frames
static constexpr float kPressSeconds = 0.10f;
static constexpr float kReleaseSeconds = 0.10f; // the stick still aimed
void Start(Direction direction) noexcept {
if (direction == Direction::None) {
return;
}
m_direction = direction;
m_elapsed = 0.0f;
}
bool Active() const noexcept { return m_direction != Direction::None; }
void Apply(wii_remote::HandInputs& left, float dt) noexcept {
const bool held = left.trigger > wii_remote::kPressThreshold;
if (m_ignore_held) {
if (held) {
left.trigger = 0.0f;
} else {
m_ignore_held = false;
}
}
if (!Active()) {
return;
}
left.stick_y = m_direction == Direction::Forward ? 1.0f : -1.0f;
if (m_elapsed >= kAimSeconds + kPressSeconds) {
m_ignore_held = m_ignore_held || held;
left.trigger = 0.0f;
} else if (m_elapsed >= kAimSeconds) {
left.trigger = 1.0f;
}
m_elapsed += IsFinite(dt) ? std::clamp(dt, 0.0f, 0.1f) : 0.0f;
if (m_elapsed >= kAimSeconds + kPressSeconds + kReleaseSeconds) {
m_direction = Direction::None;
}
}
void Reset() noexcept { *this = ThrowSequence{}; }
private:
Direction m_direction = Direction::None;
float m_elapsed = 0.0f;
bool m_ignore_held = false;
};
} // namespace mkw::vr::item_throw
+14
View File
@@ -171,6 +171,7 @@ bool g_vrNativeSteeringWheel = RuntimeConfigFile::VrNativeSteeringWheel();
bool g_vrObjectCulling = RuntimeConfigFile::VrObjectCulling();
bool g_vrHandSteering = RuntimeConfigFile::VrHandSteering();
constexpr std::array<const char*, 3> kVrCockpitItemHands{"Left", "Right", "Off"};
bool g_vrCockpitItemThrow = RuntimeConfigFile::VrCockpitItemThrow();
int g_vrCockpitItemHand = RuntimeConfigFile::VrCockpitItemHand() == "right" ? 1 :
RuntimeConfigFile::VrCockpitItemHand() == "off" ? 2 : 0;
mkw::vr::WheelTuning g_vrWheelTuning = RuntimeConfigFile::VrWheelTuning();
@@ -1235,6 +1236,17 @@ void DrawVrSteeringWheelSettings() {
ImGui::SetTooltip("Show Player 1's settled inventory item above this palm. "
"Triple items show their remaining count. Using or losing the item hides it.");
}
ImGui::BeginDisabled(g_vrCockpitItemHand == 2);
if (ImGui::Checkbox("Throw the item by swinging that hand", &g_vrCockpitItemThrow)) {
RuntimeConfigFile::SetVrCockpitItemThrow(g_vrCockpitItemThrow);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("With the hand off the wheel, swing it quickly forward to throw the item ahead "
"of the kart, or back to throw it behind: the stick and item button the game "
"reads for an aimed throw. Swinging while holding the item button throws the "
"trailed item.");
}
ImGui::EndDisabled();
#if defined(__ANDROID__)
ImGui::BeginDisabled(!g_vrHandSteering && g_vrCockpitItemHand == 2);
if (ImGui::Checkbox("Tracked hands", &g_vrHandTracking)) {
@@ -1759,12 +1771,14 @@ void DrawVrCameraSettings() {
g_vrNativeSteeringWheel = RuntimeConfigFile::kVrNativeSteeringWheelDefault;
g_vrHandSteering = RuntimeConfigFile::kVrHandSteeringDefault;
g_vrCockpitItemHand = 0;
g_vrCockpitItemThrow = RuntimeConfigFile::kVrCockpitItemThrowDefault;
RuntimeConfigFile::SetVrFirstPersonSeat(RuntimeConfigFile::kVrFirstPersonSeatDefault);
RuntimeConfigFile::SetVrCockpitUnitsPerMeter(g_vrCockpitUnitsPerMeter);
RuntimeConfigFile::SetVrSteeringWheel(g_vrSteeringWheel);
RuntimeConfigFile::SetVrNativeSteeringWheel(g_vrNativeSteeringWheel);
RuntimeConfigFile::SetVrHandSteering(g_vrHandSteering);
RuntimeConfigFile::SetVrCockpitItemHand(RuntimeConfigFile::kVrCockpitItemHandDefault);
RuntimeConfigFile::SetVrCockpitItemThrow(g_vrCockpitItemThrow);
#if defined(__ANDROID__)
g_vrHandTracking = RuntimeConfigFile::kVrHandTrackingDefault;
RuntimeConfigFile::SetVrHandTracking(g_vrHandTracking);
+75 -2
View File
@@ -215,8 +215,9 @@ VirtualGamepadRelay& Relay() {
// directions push the Nunchuk stick). `panel` presses the settings panel's
// button (left Y, or both thumbsticks for a gamepad), opening or closing it,
// where `a` then selects. `flick` plays the bare hands' flick, one shake of the
// remote. The property is unset in normal use, so this costs one property read
// every few frames.
// remote; `throw_forward` and `throw_backward` play a throw of the held item.
// The property is unset in normal use, so this costs one property read every
// few frames.
constexpr uint32_t kInjectHoldFrames = 12;
constexpr uint32_t kInjectPollFrames = 4;
@@ -857,6 +858,7 @@ void OpenXRInput::Destroy() {
m_sources_logged_at = 0;
m_squeeze_active = m_hand_driven = m_pinch = {};
m_injected_flick_held = false;
m_injected_throw_held = false;
m_profile_serial = 0;
DestroyPoseSpaces();
if (m_action_set != XR_NULL_HANDLE) {
@@ -1133,6 +1135,10 @@ void OpenXRInput::Sync(XrTime predicted_display_time, const OpenXRPointerScreen&
m_injected_flick_held = injected_flick;
#endif
// Throwing the held item: a quick swing of the item hand forward or back
// plays an aimed throw on the stick and the item button.
UpdateItemThrow(dt_seconds, hands, withheld);
// What the game sees of the controllers: nothing held while they are withheld.
static const std::array<wii_remote::HandInputs, kHands> kIdleHands{};
const auto& game_hands = withheld ? kIdleHands : hands;
@@ -1319,9 +1325,76 @@ void OpenXRInput::ResetDriving() {
}
m_flick.Reset();
m_flick_start = 0;
m_throw.Reset();
m_throw_sequence.Reset();
OpenXRPublishDriving(m_driving);
}
void OpenXRInput::UpdateItemThrow(float dt_seconds, std::array<wii_remote::HandInputs, kHands>& hands,
bool withheld) {
const std::string item_hand = RuntimeConfigFile::VrCockpitItemHand();
const uint32_t hand = item_hand == "right" ? 1u : 0u;
item_throw::Direction direction = item_throw::Direction::None;
if (!withheld && m_driving.cockpit_active && item_hand != "off" && RuntimeConfigFile::VrCockpitItemThrow()) {
// The hand as the cockpit draws the item on it: the palm joint when the
// hand is drawn from joints, else the grip. The detector follows it with
// or without an item, so it always knows when the hand left the wheel.
const DrivingHand& located = m_driving.hands[hand];
const std::array<float, 12>& pose =
located.joints_valid ? m_joint_frame.seat_from_joint[hand][hand_tracking::kPalm] : located.seat_from_grip;
item_throw::HandSample sample{};
sample.tracked = located.tracked;
sample.held = located.held;
sample.bare = located.bare;
sample.position = {pose[3], pose[7], pose[11]};
direction = m_throw.Update(sample, dt_seconds);
// A swing with nothing in the hand throws nothing.
if (direction != item_throw::Direction::None) {
const HeldItem item = MkwVRFirstPersonGetHeldItem();
if (!item.valid || item.count == 0) {
direction = item_throw::Direction::None;
}
}
} else {
m_throw.Reset();
}
// `debug.wiicompiled.inject <n>:throw_forward` or `throw_backward` plays the
// same throw without the swing, to try it unattended.
const bool injected_forward = Injected("throw_forward");
const bool injected_backward = Injected("throw_backward");
bool injected = false;
if ((injected_forward || injected_backward) && !m_injected_throw_held && !withheld) {
direction = injected_forward ? item_throw::Direction::Forward : item_throw::Direction::Backward;
injected = true;
}
m_injected_throw_held = injected_forward || injected_backward;
if (direction != item_throw::Direction::None) {
m_throw_sequence.Start(direction);
if (RuntimeConfigFile::VrWheelTuning().haptics) {
constexpr XrDuration kThrowPulseNs = 40'000'000;
ApplyHaptic(hand, 0.5f, kThrowPulseNs);
}
// What the swing measured, to tune the thresholds from a run log.
std::ostringstream message;
message << "Held item thrown " << item_throw::DirectionLabel(direction);
if (injected) {
message << " (injected)";
} else {
const auto& measure = m_throw.Last();
message << " (" << (measure.bare ? "bare hand" : "controller") << ", "
<< std::lround(std::fabs(measure.travel) * 100.0f) << " cm in "
<< std::lround(measure.seconds * 1000.0f) << " ms"
<< (measure.bridged ? ", across a tracking gap" : "") << ')';
}
Log(OpenXRLogLevel::Info, message.str());
}
if (withheld) {
m_throw_sequence.Reset();
return;
}
m_throw_sequence.Apply(hands[0], dt_seconds);
}
void OpenXRInput::UpdateDriving(XrTime display_time, const driving::SeatFrame& seat,
std::array<wii_remote::HandInputs, kHands>& hands, bool withheld) {
const FirstPersonAnchor anchor = MkwVRFirstPersonGetAnchor();
+4
View File
@@ -22,6 +22,10 @@ int main() {
Require(std::string_view(RuntimeConfigFile::kVrCockpitItemHandDefault) == "left");
Require(!Parse("[vr]\n").vrCockpitItemHand.has_value());
Require(!Parse("[vr]\ncockpit_item_hand = 1\n").vrCockpitItemHand.has_value());
Require(RuntimeConfigFile::kVrCockpitItemThrowDefault);
Require(Parse("[vr]\ncockpit_item_throw = false\n").vrCockpitItemThrow == false);
Require(Parse("[vr]\ncockpit_item_throw = true\n").vrCockpitItemThrow == true);
Require(!Parse("[vr]\n").vrCockpitItemThrow.has_value());
// [vr] foveation: the Quest's foveated rendering level, index-matched to
// aurora_set_stereo_foveation.
for (std::string_view level : RuntimeConfigFile::kVrFoveationLevels) {
+268
View File
@@ -0,0 +1,268 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Throwing the held item: the swing detector and the stick and item button it
// plays, tested without a headset (vr/openxr_item_throw.h).
#include "vr/openxr_item_throw.h"
#include <cmath>
#include <functional>
#include <iostream>
#include <vector>
namespace {
using namespace mkw::vr;
using namespace mkw::vr::item_throw;
int g_failures = 0;
void Check(bool condition, const char* what) {
if (!condition) {
++g_failures;
std::cerr << "FAILED: " << what << '\n';
}
}
struct Fire {
Direction direction;
float time;
};
// Plays `duration` seconds of a hand moving with `velocity(t)` (m/s in the seated
// frame) from `start`, at `rate` samples a second, and returns every throw.
std::vector<Fire> Play(ThrowDetector& detector, const std::function<std::array<float, 3>(float)>& velocity,
float duration, float rate = 90.0f, std::array<float, 3> start = {-0.2f, -0.3f, -0.35f},
const std::function<bool(float)>& held = nullptr, bool bare = false,
const std::function<bool(float)>& tracked = nullptr) {
std::vector<Fire> fires;
const float dt = 1.0f / rate;
std::array<float, 3> position = start;
for (float t = 0.0f; t < duration; t += dt) {
const auto v = velocity(t);
for (int axis = 0; axis < 3; ++axis) {
position[axis] += v[axis] * dt;
}
HandSample sample{};
sample.tracked = tracked ? tracked(t) : true;
sample.held = held ? held(t) : false;
sample.bare = bare;
sample.position = position;
const Direction direction = detector.Update(sample, dt);
if (direction != Direction::None) {
fires.push_back({direction, t});
}
}
return fires;
}
// A flick: speeds up at 30 m/s^2 to `peak`, holds it briefly and stops.
std::function<std::array<float, 3>(float)> Flick(std::array<float, 3> direction, float peak, float start = 0.1f) {
return [=](float t) {
const float local = t - start;
float speed = 0.0f;
if (local >= 0.0f && local < 0.25f) {
speed = std::min(30.0f * local, peak);
}
return std::array<float, 3>{direction[0] * speed, direction[1] * speed, direction[2] * speed};
};
}
void TestThrows() {
for (float rate : {72.0f, 90.0f, 120.0f}) {
ThrowDetector forward;
const auto fires = Play(forward, Flick({0.0f, 0.0f, -1.0f}, 3.0f), 0.8f, rate);
Check(fires.size() == 1 && fires[0].direction == Direction::Forward, "a forward flick throws forward");
Check(!fires.empty() && fires[0].time < 0.35f, "the throw fires during the swing");
ThrowDetector backward;
const auto back = Play(backward, Flick({0.0f, 0.0f, 1.0f}, 2.5f), 0.8f, rate);
Check(back.size() == 1 && back[0].direction == Direction::Backward, "a backward flick throws backward");
}
// An overhand throw ends forward and down; an underhand toss forward and up.
ThrowDetector overhand;
Check(Play(overhand, Flick({0.0f, -0.45f, -0.89f}, 3.0f), 0.8f).size() == 1, "an overhand throw counts");
ThrowDetector underhand;
Check(Play(underhand, Flick({0.0f, 0.45f, -0.89f}, 3.0f), 0.8f).size() == 1, "an underhand toss counts");
}
void TestNotThrows() {
ThrowDetector reach;
// Reaching for the wheel, and bringing a hand back to rest.
Check(Play(reach, [](float t) { return std::array<float, 3>{0.0f, 0.0f, t < 0.4f ? -0.9f : 0.0f}; }, 1.0f).empty(),
"reaching for the wheel is no throw");
ThrowDetector rest;
Check(Play(rest, [](float t) { return std::array<float, 3>{0.0f, 0.0f, t < 0.35f ? 0.9f : 0.0f}; }, 1.0f).empty(),
"bringing a hand back is no throw");
ThrowDetector sideways;
Check(Play(sideways, Flick({1.0f, 0.0f, 0.0f}, 3.0f), 0.8f).empty(), "a sideways sweep is no throw");
ThrowDetector upward;
Check(Play(upward, Flick({0.0f, 1.0f, 0.0f}, 3.0f), 0.8f).empty(), "an upward flick (a trick) is no throw");
ThrowDetector steep;
Check(Play(steep, Flick({0.0f, 0.87f, -0.5f}, 3.0f), 0.8f).empty(), "a mostly upward swing is no throw");
ThrowDetector still;
Check(Play(still, [](float) { return std::array<float, 3>{}; }, 1.0f).empty(), "a still hand is no throw");
ThrowDetector untracked;
HandSample lost{};
lost.position = {0.0f, 0.0f, -5.0f};
const bool first = untracked.Update(lost, 0.011f) == Direction::None;
lost.position = {0.0f, 0.0f, 5.0f};
Check(first && untracked.Update(lost, 0.011f) == Direction::None, "an untracked hand is no throw");
}
void TestWheel() {
// A swing while holding the wheel, then right after letting go of it.
ThrowDetector on_wheel;
Check(Play(on_wheel, Flick({0.0f, 0.0f, -1.0f}, 3.0f), 0.8f, 90.0f, {-0.2f, -0.3f, -0.35f},
[](float) { return true; })
.empty(),
"a hand on the wheel does not throw");
ThrowDetector just_free;
Check(Play(just_free, Flick({0.0f, 0.0f, -1.0f}, 3.0f, 0.1f), 0.8f, 90.0f, {-0.2f, -0.3f, -0.35f},
[](float t) { return t < 0.05f; })
.empty(),
"a hand that just let go of the wheel does not throw");
ThrowDetector free;
Check(Play(free, Flick({0.0f, 0.0f, -1.0f}, 3.0f, 0.4f), 1.0f, 90.0f, {-0.2f, -0.3f, -0.35f},
[](float t) { return t < 0.05f; })
.size() == 1,
"a hand free for a moment throws");
}
void TestJumpAndRepeats() {
ThrowDetector jump;
HandSample sample{};
sample.tracked = true;
sample.position = {-0.2f, -0.3f, -0.35f};
bool fired = false;
for (int frame = 0; frame < 60; ++frame) {
if (frame == 30) {
sample.position[2] -= 0.5f; // the pose snaps half a metre ahead
}
fired = fired || jump.Update(sample, 1.0f / 90.0f) != Direction::None;
}
Check(!fired, "a tracking jump is no throw");
ThrowDetector long_swing;
Check(Play(long_swing, [](float t) { return std::array<float, 3>{0.0f, 0.0f, t > 0.1f ? -2.0f : 0.0f}; }, 1.2f)
.size() == 1,
"one long swing throws once");
const auto two = [](float gap) {
return [gap](float t) {
const bool first = t >= 0.1f && t < 0.3f, second = t >= 0.3f + gap && t < 0.5f + gap;
return std::array<float, 3>{0.0f, 0.0f, first || second ? -2.5f : 0.0f};
};
};
ThrowDetector close;
Check(Play(close, two(0.2f), 1.5f).size() == 1, "a second swing inside the cooldown does not throw");
ThrowDetector apart;
Check(Play(apart, two(0.6f), 2.0f).size() == 2, "a second swing after the cooldown throws again");
}
// Bare hands: the cameras lag, smooth and drop a hand in a fast swing.
void TestBareHands() {
const auto none = [](float) { return false; };
// A slower push than a controller needs: 1.4 m/s at its peak.
ThrowDetector controller;
Check(Play(controller, Flick({0.0f, 0.0f, -1.0f}, 1.4f), 0.8f).empty(), "a controller needs a faster swing");
ThrowDetector bare;
const auto push = Play(bare, Flick({0.0f, 0.0f, -1.0f}, 1.4f), 0.8f, 60.0f, {-0.2f, -0.3f, -0.35f}, none, true);
Check(push.size() == 1 && push[0].direction == Direction::Forward, "a bare hand's slower push throws");
Check(bare.Last().bare && bare.Last().travel >= kBareHandTuning.forward_travel, "the throw's measure");
ThrowDetector bare_back;
const auto back = Play(bare_back, Flick({0.0f, 0.0f, 1.0f}, 1.2f), 0.8f, 60.0f, {-0.2f, -0.3f, -0.35f}, none, true);
Check(back.size() == 1 && back[0].direction == Direction::Backward, "a bare hand throws backward");
// Tracking is lost for the fast part of the swing and comes back as the
// hand stops, so only the positions either side of the gap show the throw:
// 0.18 s for a bare hand, 0.09 s for a controller.
for (bool is_bare : {false, true}) {
const float speed = is_bare ? 2.0f : 3.0f, stop = is_bare ? 0.3f : 0.22f, found = is_bare ? 0.3f : 0.21f;
ThrowDetector gap;
const auto fires = Play(
gap, [=](float t) { return std::array<float, 3>{0.0f, 0.0f, t >= 0.1f && t < stop ? -speed : 0.0f}; },
0.8f, 72.0f, {-0.2f, -0.3f, -0.35f}, none, is_bare, [=](float t) { return t <= 0.12f || t > found; });
Check(fires.size() == 1 && fires[0].direction == Direction::Forward, "a swing across a short gap throws");
Check(gap.Last().bridged, "the throw's measure notes the gap");
}
// A hand the cameras lose for 0.3 s reappears 30 cm ahead: no throw.
ThrowDetector long_gap;
Check(Play(long_gap, [](float t) { return std::array<float, 3>{0.0f, 0.0f, t > 0.3f && t < 0.6f ? -1.0f : 0.0f}; },
1.2f, 72.0f, {-0.2f, -0.3f, -0.35f}, none, true, [](float t) { return t < 0.3f || t > 0.6f; })
.empty(),
"a hand lost for long is no throw");
// A slow drift the cameras lose briefly: 0.6 m/s across a 0.18 s gap.
ThrowDetector drift;
Check(Play(drift, [](float) { return std::array<float, 3>{0.0f, 0.0f, -0.6f}; }, 1.2f, 72.0f,
{-0.2f, -0.3f, 0.0f}, none, true, [](float t) { return t < 0.5f || t > 0.68f; })
.empty(),
"a slow drift across a gap is no throw");
// Reaching for the wheel with a bare hand.
ThrowDetector reach;
Check(Play(reach, [](float t) { return std::array<float, 3>{0.0f, 0.0f, t < 0.4f ? -0.8f : 0.0f}; }, 1.0f, 60.0f,
{-0.2f, -0.3f, -0.35f}, none, true)
.empty(),
"a bare hand reaching for the wheel is no throw");
}
struct Frame {
float stick_y;
float trigger;
};
// Runs the sequence at 90 Hz with the player's own trigger from `player(t)`.
std::vector<Frame> Sequence(Direction direction, const std::function<float(float)>& player, float duration,
ThrowSequence& sequence) {
std::vector<Frame> frames;
sequence.Start(direction);
const float dt = 1.0f / 90.0f;
for (float t = 0.0f; t < duration; t += dt) {
wii_remote::HandInputs left{};
left.stick_x = 0.6f;
left.trigger = player(t);
sequence.Apply(left, dt);
Check(left.stick_x == 0.6f, "the throw leaves the steering alone");
frames.push_back({left.stick_y, left.trigger});
}
return frames;
}
void TestSequence() {
ThrowSequence sequence;
const auto frames = Sequence(Direction::Forward, [](float) { return 0.0f; }, 0.4f, sequence);
// 90 Hz: aim for frames 0-4 (0.05 s), press 5-13, release 14-22, then done.
Check(frames[0].stick_y == 1.0f && frames[0].trigger == 0.0f, "the stick aims before the button");
Check(frames[4].trigger == 0.0f && frames[5].trigger == 1.0f, "then the button is pressed");
Check(frames[13].trigger == 1.0f && frames[14].trigger == 0.0f, "then released");
Check(frames[14].stick_y == 1.0f && frames[21].stick_y == 1.0f, "with the stick still aimed");
Check(frames[24].stick_y == 0.0f && !sequence.Active(), "then the stick is the player's again");
ThrowSequence back;
Check(Sequence(Direction::Backward, [](float) { return 0.0f; }, 0.1f, back)[0].stick_y == -1.0f,
"a backward throw pulls the stick back");
// The player was trailing the item with the button held: the throw lets it
// go, and the button stays released until the player lets go of it.
ThrowSequence trailed;
const auto held = Sequence(Direction::Forward, [](float t) { return t < 0.5f ? 1.0f : (t < 0.6f ? 0.0f : 1.0f); },
0.8f, trailed);
Check(held[2].trigger == 1.0f && held[10].trigger == 1.0f, "a held button stays pressed until the release");
Check(held[14].trigger == 0.0f && held[30].trigger == 0.0f, "the throw releases a held button");
Check(held[44].trigger == 0.0f, "a button still held after the throw is ignored");
Check(held[60].trigger == 1.0f, "a new press after letting go uses the next item");
}
} // namespace
int main() {
TestThrows();
TestNotThrows();
TestWheel();
TestJumpAndRepeats();
TestBareHands();
TestSequence();
if (g_failures != 0) {
std::cerr << g_failures << " check(s) failed\n";
return 1;
}
std::cout << "mkw_vr_item_throw_tests passed\n";
return 0;
}