diff --git a/OPENXR.md b/OPENXR.md
index 7de57ec..c3a6186 100644
--- a/OPENXR.md
+++ b/OPENXR.md
@@ -166,6 +166,9 @@ live, from the headset panel's VR tab or the launcher's Settings page. The app d
So that the room frames the picture rather than black bands, the Quest's menu quad shows only the
part of its eye-sized image Aurora draws into (the desktop snapshot, and the in-eye settings
panel's rectangle), at the same size per pixel, so nothing moves.
+`hand_tracking` (Quest only, default off) makes the first-person cockpit's hands follow the
+headset's hand tracking; see "Tracked hands" under
+[Steering wheel and hand steering](#steering-wheel-and-hand-steering).
`stop_at_display_copy` ends eye replay at the final `GXCopyDisp`, matching the frame shown on the
desktop. `skip_copy_clears` independently suppresses the EFB reset performed after a copy. Both
default on and can be changed live from the F10 settings bar for diagnostics.
@@ -496,11 +499,40 @@ player 1's GameCube controller. The cockpit's wheel follows its calibrated steer
full-lock angle as the stick (`wheel_kart_degrees`, `wheel_bike_degrees`), and hand steering steps
aside while it drives.
+**Tracked hands.** `hand_tracking` (Quest only for now, default off; the Quest launcher's
+Settings > VR and the headset panel's VR tab, under hand steering, which it needs) poses the
+cockpit hands from the headset's hand tracking instead of curling them with the grip. Two hand
+trackers (`XR_EXT_hand_tracking`) are located every XR frame at the display time. While the
+controllers are held the Quest builds the joints from their touch sensors
+(`XR_EXT_hand_tracking_data_source`'s controller source: the trigger finger, the thumb on its rest or
+a button, the grip); once they are put down, from its cameras. The runtime's hand mesh is then
+skinned with the joints themselves, each joint's tracked pose times its inverse bind pose (the bind
+poses are in the mesh's space, as `xrLocateHandJointsEXT` reports poses), with no curl and no grip;
+a runtime with joints but no mesh (SteamVR, Virtual Desktop) gets a skeleton along the joints. A
+hand whose joints are not located, or not finite, falls back to the curl at its grip. The trackers
+exist only while the option and hand steering are both on, and serve the mesh too; the extensions
+(with `XR_FB_hand_tracking_aim`) are asked for when either is on at launch, otherwise turning the
+option on applies after a restart. The log says `OpenXR tracked hands ready (controller-driven
+hands: yes|no)` and, at each change, `OpenXR tracked hands: left camera, right controller`; the
+headset panel shows each hand's source under the checkbox.
+
+The Quest app declares `horizonos.permission.HAND_TRACKING` (and the older
+`com.oculus.permission.HAND_TRACKING`), normal permissions granted at install with no prompt, and
+`oculus.software.handtracking` as optional: without that flag Horizon OS keeps the app
+controllers-only. With it, putting the controllers down drives `khr/simple_controller` from the
+hands (an index pinch is select, the left palm-up pinch the menu), and every change of interaction
+profile is logged (`OpenXR interaction profiles: left ..., right ...`). A hand driving that profile
+instead of the Touch one (its squeeze action inactive, its select active) is a bare hand; with
+tracked hands off it presses nothing but the menu gesture, which pauses, is not drawn, and feeds
+no Wii Remote motion, so the permission changes nothing for players who leave the option off. On
+PC none of this applies: a PC runtime can drive real controllers through `khr/simple_controller`
+and synthesize joints for them, so a hand-edited `hand_tracking = true` only changes the drawing.
+
**Hands and the separate wheel.** Hands are drawn while hand steering is on: the runtime's own hand
-mesh where it offers one (`XR_EXT_hand_tracking` and `XR_FB_hand_tracking_mesh`, requested only when
-hand steering is on at launch), otherwise procedural gloves that curl with the squeeze. A Quest 3
-offers that mesh without the app declaring hand tracking, and the log says which is drawn
-(`[mkw-vr] cockpit hands:`). Both close their fingers towards the palm: the mesh's joints point
+mesh where it offers one (`XR_EXT_hand_tracking` and `XR_FB_hand_tracking_mesh`, requested when
+hand steering or tracked hands are on at launch), otherwise procedural gloves that curl with the
+squeeze. A Quest 3 offers that mesh even without the app declaring hand tracking, and the log says
+which is drawn (`[mkw-vr] cockpit hands:`). Both close their fingers towards the palm: the mesh's joints point
-Z towards the fingertip and +Y out of the back of the hand, so flexion is negative about the
joint's own X, on both hands. They and the
separate VR wheel or handlebar travel with the stereo packet in metres in the seated frame, and each
diff --git a/README.md b/README.md
index a6a66bc..509c1a7 100644
--- a/README.md
+++ b/README.md
@@ -56,8 +56,8 @@ unsupported scenes appear as a head-locked virtual screen; a validated single-ca
to immersive stereo rendering. VR is opt-in and falls back to the normal desktop renderer if the
runtime or headset is unavailable. In first person you sit in the cockpit, where the steering wheel
or handlebar turns with your steering, and hand steering by heurazy lets you grab it with the
-tracked controllers and turn it. See [`OPENXR.md`](OPENXR.md) for setup, configuration, and the
-current limitations.
+tracked controllers and turn it. On a Quest the hands can follow the headset's own hand tracking.
+See [`OPENXR.md`](OPENXR.md) for setup, configuration, and the current limitations.
**Music ducking.**
Start playing something else, Spotify, a YouTube video, and
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index a9e4f2c..1496238 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -11,6 +11,11 @@
+
+
+
@@ -29,6 +34,11 @@
+
+
diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt
index 9ea09e3..f66d04a 100644
--- a/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt
+++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt
@@ -189,6 +189,14 @@ class SettingsPage(
write = { c, value -> c.setBool("vr", "hand_steering", value) },
enabledIf = cockpit,
)
+ // The hands follow the headset's hand tracking; kVrHandTrackingDefault is off. The
+ // hands are only drawn while they can steer, so it goes with hand steering.
+ toggle(
+ R.string.vr_hand_tracking, R.string.vr_hand_tracking_helper,
+ read = { it.bool("vr", "hand_tracking") ?: false },
+ write = { c, value -> c.setBool("vr", "hand_tracking", value) },
+ enabledIf = { c -> cockpit(c) && (c.bool("vr", "hand_steering") ?: true) },
+ )
slider(
R.string.vr_lean_back, R.string.vr_lean_back_helper, -45.0, 45.0, 1.0,
read = { number(it, "vr", "lean_back_degrees", -45.0, 45.0, 0.0) },
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index e38df9a..c91a937 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -533,6 +533,8 @@
Custom
Hand steering
In the cockpit, squeeze a grip near the steering wheel or handlebar to grab it, and turn it to steer. Releasing both grips gives steering back to the stick. Hand steering by heurazy.
+ Tracked hands
+ The cockpit hands follow your own. Holding the controllers, the fingers follow their touch sensors; put the controllers down and the headset\'s cameras track your hands. Needs hand tracking on in the headset\'s settings.
Lean back angle
Tilts the race view back for playing reclined. 0 applies no tilt.
diff --git a/aurora-main/include/aurora/aurora.h b/aurora-main/include/aurora/aurora.h
index c3441f0..cdd7143 100644
--- a/aurora-main/include/aurora/aurora.h
+++ b/aurora-main/include/aurora/aurora.h
@@ -123,11 +123,20 @@ typedef enum {
* immersive base position. Aurora draws it per eye after the scene, depth-tested
* against the scene with the scene's own depth mapping.
*/
+enum { AURORA_VR_HAND_JOINT_COUNT = 26 };
+
typedef struct {
bool tracked;
bool held;
+ // The hand-tracking joints below are valid: the hand is drawn from them
+ // instead of curling from squeeze at seatFromGrip.
+ bool jointsValid;
float squeeze;
float seatFromGrip[12];
+ // XR_EXT_hand_tracking joints in XR_HAND_JOINT_* order, each a row-major 3x4
+ // in the seated frame, and their radii in metres.
+ float seatFromJoint[AURORA_VR_HAND_JOINT_COUNT][12];
+ float jointRadii[AURORA_VR_HAND_JOINT_COUNT];
} AuroraCockpitHand;
typedef struct {
@@ -159,7 +168,9 @@ typedef struct {
void aurora_set_stereo_panel_layer(bool enabled);
// Copies optional runtime-provided hand meshes (XR_FB_hand_tracking_mesh, 26
-// joints). Null clears to the procedural glove. Bind poses: x,y,z,w,px,py,pz.
+// joints). Null clears to the procedural glove. Bind poses: x,y,z,w,px,py,pz,
+// in the space of the mesh's vertices, as xrLocateHandJointsEXT reports poses:
+// a hand with tracked joints is skinned with seatFromJoint * inverse(bind).
void aurora_set_vr_hand_mesh(uint32_t hand, const AuroraVRHandVertex* vertices, uint32_t vertexCount,
const uint16_t* indices, uint32_t indexCount, const float* bindPoses,
const int32_t* parents, uint32_t jointCount);
diff --git a/aurora-main/lib/aurora.cpp b/aurora-main/lib/aurora.cpp
index d6cdcec..d87e856 100644
--- a/aurora-main/lib/aurora.cpp
+++ b/aurora-main/lib/aurora.cpp
@@ -788,6 +788,20 @@ std::optional request_stereo_frame(uint32_t logicalFrame, uin
}
// The cockpit overlay is optional: a bad one is dropped, never the frame.
if (frame.cockpit.active) {
+ // A hand's tracked joints are optional too: non-finite ones only put that
+ // hand back on its grip.
+ for (auto& hand : frame.cockpit.hands) {
+ if (hand.jointsValid && !(finite(&hand.seatFromJoint[0][0], AURORA_VR_HAND_JOINT_COUNT * 12) &&
+ finite(hand.jointRadii, AURORA_VR_HAND_JOINT_COUNT))) {
+ hand.jointsValid = false;
+ static bool jointRejectionLogged = false;
+ if (!jointRejectionLogged) {
+ jointRejectionLogged = true;
+ Log.warn("Stereo frame {} carries non-finite VR hand joints; drawing that hand at its grip",
+ logicalFrame);
+ }
+ }
+ }
const auto& cockpit = frame.cockpit;
bool valid = finite(&cockpit.wheelAngle, 1) && finite(&cockpit.handlebarRadius, 1) &&
finite(&cockpit.unitsPerMeter, 1) && cockpit.unitsPerMeter > 0.f &&
diff --git a/aurora-main/lib/gfx/cockpit.hpp b/aurora-main/lib/gfx/cockpit.hpp
index e3a6672..5dc2ae7 100644
--- a/aurora-main/lib/gfx/cockpit.hpp
+++ b/aurora-main/lib/gfx/cockpit.hpp
@@ -128,6 +128,25 @@ inline void glove(std::vector& v, const AuroraCockpitHand& hand, int sid
tube(v,thumbKnuckle,{palm*(0.030f+0.014f*curl),-(0.052f-0.016f*curl),-0.020f},0.009f,white);
for(size_t i=start;i& out, const HandMesh& mesh, const std::array& skin,
+ const std::array& valid, V offset, const float* seatFromMesh) {
+ std::vector points(mesh.vertices.size());
+ for(size_t i=0;i=0&&v.joints[w]<26&&valid[v.joints[w]]&&v.weights[w]>0) {
+ p=add(p,mul(point(skin[v.joints[w]].data(),{v.position[0],v.position[1],v.position[2]}),v.weights[w]));
+ total+=v.weights[w];
+ }
+ if(total>0) p=mul(p,1/total);
+ p=add(p,offset);
+ points[i]=seatFromMesh?point(seatFromMesh,p):p;
+ }
+ for(size_t i=0;i+2& out, const AuroraCockpitHand& hand, const HandMesh& mesh) {
std::array posed{}, skin{};
std::array done{};
@@ -151,19 +170,52 @@ inline void runtime_hand(std::vector& out, const AuroraCockpitHand& hand
skin[j]=compose(mesh.inverseBind[1],compose(posed[j],mesh.inverseBind[j]));
done[j]=true;
}
- std::vector points(mesh.vertices.size());
- for(size_t i=0;i=0&&v.joints[w]<26&&done[v.joints[w]]&&v.weights[w]>0) {
- p=add(p,mul(point(skin[v.joints[w]].data(),{v.position[0],v.position[1],v.position[2]}),v.weights[w]));
- total+=v.weights[w];
- }
- if(total>0) p=mul(p,1/total);
- p=add(p,{0,0,0.04f}); // wrist behind the controller grip/palm origin.
- points[i]=point(hand.seatFromGrip,p);
+ // The wrist sits behind the controller grip/palm origin.
+ skin_mesh(out,mesh,skin,done,{0,0,0.04f},hand.seatFromGrip);
+}
+// The hand-tracking joints are all finite: the hand is drawn from them.
+inline bool joints_finite(const AuroraCockpitHand& hand) {
+ const auto finite=[](float value) {
+ uint32_t bits; std::memcpy(&bits,&value,sizeof(bits)); return (bits&0x7f800000u)!=0x7f800000u;
+ };
+ for(int j=0;j& out, const AuroraCockpitHand& hand, const HandMesh& mesh) {
+ std::array skin{};
+ std::array valid{};
+ for(int j=0;j<26;++j) { skin[j]=compose(joint_matrix(hand,j),mesh.inverseBind[j]); valid[j]=true; }
+ skin_mesh(out,mesh,skin,valid,{0,0,0},nullptr);
+}
+// Without a runtime mesh (PC runtimes report joints but no XR_FB mesh), the
+// tracked joints as a skeleton: each finger a chain of tubes from the wrist to
+// its tip, the joints' own radii, and a ball in the palm.
+inline void joint_skeleton(std::vector& v, const AuroraCockpitHand& hand) {
+ const V white{0.91f,0.95f,1.0f};
+ const auto at=[&](int joint) -> V {
+ return {hand.seatFromJoint[joint][3],hand.seatFromJoint[joint][7],hand.seatFromJoint[joint][11]};
+ };
+ const auto radius=[&](int joint) { return std::clamp(hand.jointRadii[joint],0.004f,0.02f); };
+ constexpr int metacarpal[5]{2,6,11,16,21}, tip[5]{5,10,15,20,25};
+ for(int finger=0;finger<5;++finger) {
+ tube(v,at(1),at(metacarpal[finger]),radius(metacarpal[finger]),white);
+ for(int joint=metacarpal[finger];joint& vertices) {
vertices.clear();vertices.reserve(12000);
@@ -192,8 +244,11 @@ inline void build_geometry(const AuroraCockpit& cockpit, std::vector& ve
std::array,2> current;
{ std::lock_guard lock(meshMutex);current=meshes; }
for(int side=0;side<2;++side) if(cockpit.hands[side].tracked) {
- if(current[side]) runtime_hand(vertices,cockpit.hands[side],*current[side]);
- else glove(vertices,cockpit.hands[side],side);
+ const auto& hand=cockpit.hands[side];
+ const bool joints=hand.jointsValid && joints_finite(hand);
+ if(current[side]) joints ? tracked_hand(vertices,hand,*current[side]) : runtime_hand(vertices,hand,*current[side]);
+ else if(joints) joint_skeleton(vertices,hand);
+ else glove(vertices,hand,side);
}
}
inline std::vector geometry(const AuroraCockpit& cockpit) {
diff --git a/aurora-main/tests/cockpit_geometry_test.cpp b/aurora-main/tests/cockpit_geometry_test.cpp
index 8ae48d8..260c952 100644
--- a/aurora-main/tests/cockpit_geometry_test.cpp
+++ b/aurora-main/tests/cockpit_geometry_test.cpp
@@ -3,7 +3,11 @@
// build in the seated frame, without a GPU.
#include
+#include
+#include
#include
+#include
+#include
#include "gfx/cockpit.hpp"
@@ -257,4 +261,145 @@ TEST_F(CockpitGeometry, RuntimeHandMeshIsSkinnedWithoutNans) {
EXPECT_TRUE(all_finite(vertices));
}
+// A 26-joint mesh with its bind poses turned and spread out, and a tiny
+// triangle rigidly on each joint, like the runtime's hand mesh.
+std::shared_ptr jointed_mesh() {
+ using namespace aurora::gfx::cockpit;
+ auto mesh = std::make_shared();
+ for (int j = 0; j < 26; ++j) {
+ const float angle = 0.2f * float(j);
+ const float pose[7]{0, std::sin(angle * 0.5f), 0, std::cos(angle * 0.5f), 0.01f * float(j % 5),
+ -0.02f * float(j / 5), -0.015f * float(j)};
+ mesh->bind[j] = from_pose(pose);
+ mesh->inverseBind[j] = inverse(mesh->bind[j]);
+ mesh->parents[j] = j - 1;
+ for (V offset : {V{0, 0, 0}, V{0.002f, 0, 0}, V{0, 0, 0.002f}}) {
+ AuroraVRHandVertex vertex{};
+ const V p = point(mesh->bind[j].data(), offset);
+ std::memcpy(vertex.position, p.data(), sizeof(vertex.position));
+ vertex.joints[0] = int16_t(j);
+ vertex.joints[1] = vertex.joints[2] = vertex.joints[3] = -1;
+ vertex.weights[0] = 1;
+ mesh->indices.push_back(uint16_t(mesh->vertices.size()));
+ mesh->vertices.push_back(vertex);
+ }
+ }
+ return mesh;
+}
+
+// Tracked joints: each at `seatFromBind` times its bind pose, so the whole
+// hand is that rigid motion of the mesh.
+void set_tracked_joints(AuroraCockpitHand& hand, const aurora::gfx::cockpit::HandMesh& mesh,
+ const aurora::gfx::cockpit::M& seatFromBind) {
+ using namespace aurora::gfx::cockpit;
+ hand.tracked = true;
+ hand.jointsValid = true;
+ for (int j = 0; j < 26; ++j) {
+ const M joint = compose(seatFromBind, mesh.bind[j]);
+ std::memcpy(hand.seatFromJoint[j], joint.data(), sizeof(hand.seatFromJoint[j]));
+ hand.jointRadii[j] = 0.008f;
+ }
+}
+
+aurora::gfx::cockpit::M rigid_motion() {
+ using namespace aurora::gfx::cockpit;
+ const float pose[7]{0.2f, -0.3f, 0.1f, 0.9273618f, 0.15f, -0.25f, -0.35f};
+ return from_pose(pose);
+}
+
+TEST_F(CockpitGeometry, TrackedJointsSkinTheRuntimeMeshRigidly) {
+ using namespace aurora::gfx::cockpit;
+ const auto mesh = jointed_mesh();
+ {
+ std::lock_guard lock(meshMutex);
+ meshes[1] = mesh;
+ }
+ AuroraCockpit cockpit{};
+ cockpit.nativeWheel = true;
+ const M motion = rigid_motion();
+ set_tracked_joints(cockpit.hands[1], *mesh, motion);
+ const auto vertices = geometry(cockpit);
+ ASSERT_EQ(vertices.size(), mesh->vertices.size());
+ for (size_t i = 0; i < vertices.size(); ++i) {
+ const auto& bindVertex = mesh->vertices[mesh->indices[i]].position;
+ const V expected = point(motion.data(), {bindVertex[0], bindVertex[1], bindVertex[2]});
+ for (int axis = 0; axis < 3; ++axis)
+ EXPECT_NEAR(vertices[i].position[axis], expected[axis], 1e-5f) << "vertex " << i << " axis " << axis;
+ }
+}
+
+TEST_F(CockpitGeometry, TrackedJointsIgnoreSqueezeHeldAndGrip) {
+ using namespace aurora::gfx::cockpit;
+ const auto mesh = jointed_mesh();
+ {
+ std::lock_guard lock(meshMutex);
+ meshes[0] = mesh;
+ }
+ AuroraCockpit cockpit{};
+ cockpit.nativeWheel = true;
+ set_tracked_joints(cockpit.hands[0], *mesh, rigid_motion());
+ set_identity(cockpit.hands[0].seatFromGrip, {0, 0, 0});
+ const auto reference = geometry(cockpit);
+ cockpit.hands[0].squeeze = 1.0f;
+ cockpit.hands[0].held = true;
+ set_identity(cockpit.hands[0].seatFromGrip, {0.5f, 0.5f, 0.5f});
+ const auto moved = geometry(cockpit);
+ ASSERT_EQ(moved.size(), reference.size());
+ for (size_t i = 0; i < moved.size(); ++i)
+ for (int axis = 0; axis < 3; ++axis)
+ EXPECT_EQ(moved[i].position[axis], reference[i].position[axis]);
+}
+
+float distance_to_segment(V p, V a, V b) {
+ using namespace aurora::gfx::cockpit;
+ const V ab = sub(b, a);
+ const float length = dot(ab, ab);
+ const float t = length > 0 ? std::clamp(dot(sub(p, a), ab) / length, 0.0f, 1.0f) : 0.0f;
+ const V d = sub(p, add(a, mul(ab, t)));
+ return std::sqrt(dot(d, d));
+}
+
+TEST_F(CockpitGeometry, TrackedJointsWithoutAMeshDrawASkeletonOnTheBones) {
+ using namespace aurora::gfx::cockpit;
+ const auto mesh = jointed_mesh(); // only for joint poses; no runtime mesh is set
+ AuroraCockpit cockpit{};
+ cockpit.nativeWheel = true;
+ set_tracked_joints(cockpit.hands[0], *mesh, rigid_motion());
+ const auto& hand = cockpit.hands[0];
+ const auto at = [&](int j) { return V{hand.seatFromJoint[j][3], hand.seatFromJoint[j][7], hand.seatFromJoint[j][11]}; };
+ const auto vertices = geometry(cockpit);
+ ASSERT_FALSE(vertices.empty());
+ ASSERT_TRUE(all_finite(vertices));
+ constexpr int metacarpal[5]{2, 6, 11, 16, 21}, tip[5]{5, 10, 15, 20, 25};
+ for (const auto& vertex : vertices) {
+ float nearest = 1e9f;
+ for (int finger = 0; finger < 5; ++finger) {
+ nearest = std::min(nearest, distance_to_segment(vertex.position, at(1), at(metacarpal[finger])));
+ for (int j = metacarpal[finger]; j < tip[finger]; ++j)
+ nearest = std::min(nearest, distance_to_segment(vertex.position, at(j), at(j + 1)));
+ }
+ const V fromPalm = sub(vertex.position, at(0));
+ nearest = std::min(nearest, std::sqrt(dot(fromPalm, fromPalm)));
+ EXPECT_LT(nearest, 0.031f) << "every skeleton vertex lies on a bone, a joint or the palm";
+ }
+}
+
+TEST_F(CockpitGeometry, NonFiniteJointsFallBackToTheGrip) {
+ using namespace aurora::gfx::cockpit;
+ const auto mesh = jointed_mesh();
+ AuroraCockpit cockpit{};
+ cockpit.nativeWheel = true;
+ set_tracked_joints(cockpit.hands[1], *mesh, rigid_motion());
+ set_identity(cockpit.hands[1].seatFromGrip, {0.2f, -0.3f, -0.4f});
+ cockpit.hands[1].seatFromJoint[7][5] = std::numeric_limits::quiet_NaN();
+ const auto broken = geometry(cockpit);
+ cockpit.hands[1].jointsValid = false;
+ const auto grip = geometry(cockpit);
+ ASSERT_TRUE(all_finite(broken));
+ ASSERT_EQ(broken.size(), grip.size()) << "a non-finite joint draws the glove at the grip";
+ for (size_t i = 0; i < broken.size(); ++i)
+ for (int axis = 0; axis < 3; ++axis)
+ EXPECT_EQ(broken[i].position[axis], grip[i].position[axis]);
+}
+
} // namespace
diff --git a/docs/quest-port.md b/docs/quest-port.md
index 3615217..7665866 100644
--- a/docs/quest-port.md
+++ b/docs/quest-port.md
@@ -196,6 +196,23 @@ shoulders, thumbsticks → sticks (clicks → stick buttons), left menu → Star
and every existing binding, dead zone and overlay setting applies. Bindings are
suggested for `oculus/touch_controller` and `khr/simple_controller`.
+The manifest declares hand tracking (`horizonos.permission.HAND_TRACKING`, the
+deprecated `com.oculus.permission.HAND_TRACKING` for older builds, and
+`oculus.software.handtracking` as optional). Both permissions are `normal` on a
+Quest 3 (`adb shell pm list permissions -g -f`), so there is no prompt. Without
+the feature flag Horizon OS keeps the app controllers-only: the game process logs
+`setting hand mode control settings to ControllersOnly` / `sethandmanifest 0`,
+and putting the controllers down logs `going to controller mode because hands are
+disabled by manifest flag`; with it, `Is hands or controller` /
+`sethandmanifest 2`. Bare hands then drive `khr/simple_controller` (a pinch is
+select, the left palm-up pinch the menu), which the runtime ignores apart from
+the menu gesture unless `[vr] hand_tracking` is on; see "Tracked hands" in
+`OPENXR.md`. The Quest 3 runtime (`libvrapiimpl.so` in the `com.meta.xr` APEX's
+VrDriver.apk) implements `XR_EXT_hand_tracking_data_source`,
+`XR_FB_hand_tracking_aim`, `XR_EXT_hand_interaction`, microgestures and the
+wide-motion modes; Meta's manifest filter skips the wide-motion modes unless the
+app also declares `com.oculus.software.body_tracking`.
+
### Android platform glue
- `runtime/src/vr/openxr_android.cpp`: `xrInitializeLoaderKHR` with the
@@ -273,8 +290,8 @@ palette. **Home** has the Play button and reports a missing or incomplete `DATA`
(the check is the runtime's own `IsDvdDataRoot`: `files/` and `sys/fst.bin`).
**Settings** edits `Config.toml` in tabs: VR (race view: immersive, immersive window or flat
screen, camera, rotation, driver hiding,
-seat, hand steering, lean back, render scale, VR interpolation, virtual screen
-size and distance),
+seat, hand steering, tracked hands, lean back, render scale, VR interpolation,
+virtual screen size and distance),
Graphics (resolution, widescreen, bloom, shader stutter), Controls (controller
mode, vibration, the Wii Remote mapping), Audio, and About (paths, OpenXR
logging). The launch-time geometry (`render_scale`, `hud_distance_meters`,
diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt
index 14c7a5c..284ff9f 100644
--- a/runtime/CMakeLists.txt
+++ b/runtime/CMakeLists.txt
@@ -415,8 +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. All header-only.
-foreach(test_name mkw_steering_wheel_tests mkw_vr_cockpit_tests mkw_vr_hand_steering_tests mkw_vr_camera_toggle_tests)
+# ported from heurazy's mario-kart-wii-VR-port, and tracked hands (grasp, bare-hand
+# buttons, flick). All header-only.
+foreach(test_name mkw_steering_wheel_tests mkw_vr_cockpit_tests mkw_vr_hand_steering_tests mkw_vr_camera_toggle_tests
+ mkw_vr_hand_tracking_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")
diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h
index 13bc546..6940099 100644
--- a/runtime/include/runtime_config.h
+++ b/runtime/include/runtime_config.h
@@ -80,6 +80,7 @@ struct RuntimeUserConfig {
std::optional vrSteeringWheel;
std::optional vrNativeSteeringWheel;
std::optional vrHandSteering;
+ std::optional vrHandTracking;
std::optional vrWheelKartDegrees;
std::optional vrWheelBikeDegrees;
std::optional vrWheelGrabDistance;
@@ -233,6 +234,11 @@ inline constexpr float kVrCockpitUnitsPerMeterMax = 400.0f;
inline constexpr bool kVrSteeringWheelDefault = true;
inline constexpr bool kVrNativeSteeringWheelDefault = true;
inline constexpr bool kVrHandSteeringDefault = true;
+// The cockpit hands follow the headset's hand tracking (the controllers' touch
+// sensors while they are held, the cameras once they are put down, when bare
+// hands also drive). Opt-in, and only offered on the Quest for now; the
+// launcher's Settings page shows the same default.
+inline constexpr bool kVrHandTrackingDefault = 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;
@@ -862,6 +868,7 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
config.vrSteeringWheel = FindConfigValue(document, "vr", "steering_wheel");
config.vrNativeSteeringWheel = FindConfigValue(document, "vr", "native_steering_wheel");
config.vrHandSteering = FindConfigValue(document, "vr", "hand_steering");
+ config.vrHandTracking = FindConfigValue(document, "vr", "hand_tracking");
config.vrWheelKartDegrees = readRangedFloat("wheel_kart_degrees", kVrWheelDegreesMin, kVrWheelDegreesMax);
config.vrWheelBikeDegrees = readRangedFloat("wheel_bike_degrees", kVrWheelDegreesMin, kVrWheelDegreesMax);
config.vrWheelGrabDistance =
@@ -1263,6 +1270,11 @@ inline bool SetVrHandSteering(bool value) {
return WriteSetting("vr", "hand_steering", value ? "true" : "false");
}
+inline bool SetVrHandTracking(bool value) {
+ Mutable().vrHandTracking = value;
+ return WriteSetting("vr", "hand_tracking", value ? "true" : "false");
+}
+
inline bool SetVrWheelTuning(const mkw::vr::WheelTuning& tuning) {
auto& config = Mutable();
const auto write = [](const char* key, std::optional& slot, float value, float low, float high) {
@@ -1746,6 +1758,10 @@ inline bool VrHandSteering(bool fallback = kVrHandSteeringDefault) {
return Get().vrHandSteering.value_or(fallback);
}
+inline bool VrHandTracking(bool fallback = kVrHandTrackingDefault) {
+ return Get().vrHandTracking.value_or(fallback);
+}
+
inline mkw::vr::WheelTuning VrWheelTuning() {
const auto& config = Get();
mkw::vr::WheelTuning tuning{};
diff --git a/runtime/include/vr/openxr_driving.h b/runtime/include/vr/openxr_driving.h
index a9ade09..e4bbec8 100644
--- a/runtime/include/vr/openxr_driving.h
+++ b/runtime/include/vr/openxr_driving.h
@@ -17,6 +17,7 @@
// 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_hand_tracking.h"
#include "vr/openxr_wii_remote.h"
#include "vr/steering_wheel.h"
@@ -35,6 +36,17 @@ struct DrivingHand {
float squeeze = 0.0f;
// Row-major 3x4 from the controller's grip space into the seated frame.
std::array seat_from_grip{1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f};
+ // Tracked hands ([vr] hand_tracking, openxr_hand_tracking.h). The joints
+ // themselves stay with OpenXRInput::HandJoints(), on the pacing thread;
+ // this says whether the cockpit draws the hand from them, and carries what
+ // the settings panel reads out: where they came from, whether the hand is
+ // bare (camera-tracked, no controller), how closed it is and whether it
+ // pinches.
+ bool joints_valid = false;
+ bool bare = false;
+ hand_tracking::Source source = hand_tracking::Source::None;
+ float grasp = 0.0f;
+ bool pinch = false;
};
struct DrivingSnapshot {
diff --git a/runtime/include/vr/openxr_hand_mesh.h b/runtime/include/vr/openxr_hand_mesh.h
index af956e5..4e0e63a 100644
--- a/runtime/include/vr/openxr_hand_mesh.h
+++ b/runtime/include/vr/openxr_hand_mesh.h
@@ -13,7 +13,9 @@ 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) {
+// `existing` are the tracked hands' trackers (OpenXRInput::HandTracker), used
+// where there is one; otherwise a tracker is made just for the mesh.
+inline bool LoadRuntimeHandMeshes(OpenXRRuntime& runtime, const XrHandTrackerEXT* existing = nullptr) {
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();
@@ -28,12 +30,18 @@ inline bool LoadRuntimeHandMeshes(OpenXRRuntime& runtime) {
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};
+ XrHandTrackerEXT tracker = existing != nullptr ? existing[h] : XR_NULL_HANDLE;
+ const bool owned = tracker == XR_NULL_HANDLE;
+ if (owned) {
+ 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;
+ if (XR_FAILED(create(runtime.Session(), &info, &tracker))) continue;
+ }
+ struct Guard {
+ XrHandTrackerEXT tracker; PFN_xrDestroyHandTrackerEXT destroy; bool owned;
+ ~Guard() { if (owned) destroy(tracker); }
+ } guard{tracker, destroy, owned};
XrHandTrackingMeshFB mesh{XR_TYPE_HAND_TRACKING_MESH_FB};
if (XR_FAILED(meshFn(tracker, &mesh)) || mesh.jointCountOutput != 26 ||
!mesh.vertexCountOutput || mesh.vertexCountOutput > 65535 || !mesh.indexCountOutput ||
diff --git a/runtime/include/vr/openxr_hand_tracking.h b/runtime/include/vr/openxr_hand_tracking.h
new file mode 100644
index 0000000..8ca0ce7
--- /dev/null
+++ b/runtime/include/vr/openxr_hand_tracking.h
@@ -0,0 +1,426 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+// The cockpit hands from the headset's hand tracking ([vr] hand_tracking), and
+// what bare hands do once the controllers are put down.
+//
+// With the option on, the OpenXR pacing thread locates both hands' 26 joints
+// (XR_EXT_hand_tracking) every XR frame. Where a hand holds a controller, the
+// Quest builds them from the controller's touch sensors
+// (XR_EXT_hand_tracking_data_source's controller source); once the controllers
+// are put down, from its cameras, and the runtime then drives
+// /interaction_profiles/khr/simple_controller from the hand: select is an index
+// pinch, the left menu the palm-up menu gesture. The rules that turn all that
+// into the game's input live here, free of OpenXR, so they are tested headlessly
+// (tests/vr_hand_tracking_tests.cpp):
+//
+// - A hand is hand-driven when its squeeze action is inactive and its select
+// action active: the Touch profile binds squeeze, simple_controller does not.
+// That decides what its buttons mean, and needs no tracker, so the manifest's
+// hand-tracking permission can never let resting hands press anything.
+// - It is bare while it is hand-driven with camera-tracked joints, latched
+// through the wheel's tracking grace. That decides the wheel grab (the palm's
+// position, a grasp from the fingers' flexion) and the flick.
+//
+// Only the Android build applies these: a PC runtime can drive real controllers
+// through simple_controller and synthesize joints for them.
+
+#include "vr/openxr_wii_remote.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace mkw::vr::hand_tracking {
+
+// XR_HAND_JOINT_*_EXT order.
+inline constexpr size_t kJointCount = 26;
+inline constexpr size_t kPalm = 0;
+inline constexpr size_t kWrist = 1;
+// Each finger's metacarpal joint, thumb first. A finger's chain runs from there
+// to its tip; the thumb's has no intermediate joint, so it is one shorter.
+inline constexpr std::array kMetacarpal{2, 6, 11, 16, 21};
+inline constexpr std::array kTip{5, 10, 15, 20, 25};
+
+using Vec3 = std::array;
+using JointPositions = std::array;
+
+// Where a hand's joints came from this frame.
+enum class Source : uint8_t {
+ None, // not located
+ Controller, // built from a held controller's touch sensors
+ Camera, // the headset's cameras
+ Unknown, // located, but the runtime does not say how
+};
+
+inline const char* SourceLabel(Source source) noexcept {
+ switch (source) {
+ case Source::Controller: return "controller";
+ case Source::Camera: return "camera";
+ case Source::Unknown: return "tracked";
+ default: return "none";
+ }
+}
+
+// Both hands' joints in the seated frame (openxr_driving.h), for the cockpit
+// overlay: row-major 3x4 per joint, and each joint's radius in metres.
+struct HandJointFrame {
+ std::array valid{};
+ std::array, kJointCount>, 2> seat_from_joint{};
+ std::array, 2> radius{};
+};
+
+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 fingers' summed flexion that reads as an open hand, and as a hand closed
+// on the wheel's rim. A relaxed hand bends its three finger joints by roughly
+// 1.0 to 1.6 radians in all, which must stay under the wheel's 0.15 release;
+// a grip round a rim bends them well past the 0.55 press. Starting values for
+// tuning with the headset panel's readout.
+inline constexpr float kGraspOpenRadians = 1.2f;
+inline constexpr float kGraspClosedRadians = 3.0f;
+
+// The angle between two successive bones a->b and b->c, 0 for a straight
+// finger. Negative when a bone is too short to have a direction.
+inline float BendRadians(const Vec3& a, const Vec3& b, const Vec3& c) noexcept {
+ const Vec3 u{b[0] - a[0], b[1] - a[1], b[2] - a[2]};
+ const Vec3 v{c[0] - b[0], c[1] - b[1], c[2] - b[2]};
+ const float uu = u[0] * u[0] + u[1] * u[1] + u[2] * u[2];
+ const float vv = v[0] * v[0] + v[1] * v[1] + v[2] * v[2];
+ if (!(uu > 1.0e-8f) || !(vv > 1.0e-8f)) {
+ return -1.0f;
+ }
+ const Vec3 cross{u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]};
+ const float sine = std::sqrt(cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2]);
+ const float cosine = u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
+ return std::atan2(sine, cosine);
+}
+
+// How far a hand is closed, 0 (open) to 1 (closed on a rim or in a fist): the
+// middle, ring and little fingers' flexion, each summed over their three
+// joints. The index finger and thumb are left out so pinching does not grab.
+// Angles only, so it is the same for either hand, at any size and in any
+// orientation. 0 for joints that are not finite or collapse onto each other.
+inline float GraspFromJoints(const JointPositions& joints) noexcept {
+ for (const Vec3& joint : joints) {
+ if (!IsFinite(joint[0]) || !IsFinite(joint[1]) || !IsFinite(joint[2])) {
+ return 0.0f;
+ }
+ }
+ float sum = 0.0f;
+ for (size_t finger = 2; finger < 5; ++finger) {
+ const size_t base = kMetacarpal[finger];
+ float flex = 0.0f;
+ for (size_t joint = base; joint + 2 <= base + 4; ++joint) {
+ const float bend = BendRadians(joints[joint], joints[joint + 1], joints[joint + 2]);
+ if (!(bend >= 0.0f)) {
+ return 0.0f;
+ }
+ flex += bend;
+ }
+ sum += std::clamp((flex - kGraspOpenRadians) / (kGraspClosedRadians - kGraspOpenRadians), 0.0f, 1.0f);
+ }
+ return sum / 3.0f;
+}
+
+// Keeps a camera-tracked hand bare, with its last grasp, through a short loss
+// of tracking (fingers hidden behind the other hand, a hand turned edge-on):
+// long enough for the wheel's own grace to keep hold. A controller's squeeze
+// clears it at once, since a hand that picked one up is no longer bare.
+class BareLatch {
+public:
+ // `hand_driven`: the hand drives simple_controller this frame.
+ // `camera_joints`: its joints were located this frame, not from a controller.
+ // `grace`: the wheel's tracking grace in seconds.
+ bool Update(bool hand_driven, bool squeeze_active, bool camera_joints, float grasp, float dt,
+ float grace) noexcept {
+ if (squeeze_active) {
+ Reset();
+ return false;
+ }
+ if (hand_driven && camera_joints) {
+ m_bare = true;
+ m_tracked = true;
+ m_lost = 0.0f;
+ m_grasp = IsFinite(grasp) ? std::clamp(grasp, 0.0f, 1.0f) : 0.0f;
+ return true;
+ }
+ if (m_bare) {
+ m_tracked = false;
+ m_lost += IsFinite(dt) ? std::clamp(dt, 0.0f, 0.1f) : 0.0f;
+ if (m_lost <= grace) {
+ return true;
+ }
+ }
+ Reset();
+ return false;
+ }
+
+ bool Bare() const noexcept { return m_bare; }
+ // False while the latch is only bridging a loss of tracking.
+ bool Tracked() const noexcept { return m_bare && m_tracked; }
+ float Grasp() const noexcept { return m_bare ? m_grasp : 0.0f; }
+ void Reset() noexcept { *this = BareLatch{}; }
+
+private:
+ bool m_bare = false;
+ bool m_tracked = false;
+ float m_lost = 0.0f;
+ float m_grasp = 0.0f;
+};
+
+// A pinch uses an item only when it starts on a hand that has been off the
+// wheel for a moment: a hand opening off the rim often reads as a pinch for a
+// frame or two. Holding the pinch holds the button, which trails an item.
+class PinchGate {
+public:
+ static constexpr float kFreeSeconds = 0.15f;
+
+ bool Update(bool pinch, bool held, float dt) noexcept {
+ dt = IsFinite(dt) ? std::clamp(dt, 0.0f, 0.1f) : 0.0f;
+ if (held) {
+ m_free = 0.0f;
+ m_firing = false;
+ m_blocked = pinch;
+ return false;
+ }
+ if (!pinch) {
+ m_firing = false;
+ m_blocked = false;
+ m_free = std::min(m_free + dt, 1.0f);
+ return false;
+ }
+ if (!m_firing && !m_blocked) {
+ (m_free >= kFreeSeconds ? m_firing : m_blocked) = true;
+ }
+ m_free = std::min(m_free + dt, 1.0f);
+ return m_firing;
+ }
+
+ void Reset() noexcept { *this = PinchGate{}; }
+
+private:
+ float m_free = 0.0f;
+ bool m_firing = false;
+ bool m_blocked = false;
+};
+
+// A hand's pinch and menu gesture this frame. The runtime's own recognition
+// (XR_FB_hand_tracking_aim) is preferred when it is valid, and a pinch made
+// while the hand is in the system gesture (palm towards the face) is the menu
+// gesture, not a pinch. Otherwise the select and menu actions, as
+// simple_controller delivers them.
+struct Gestures {
+ bool pinch = false;
+ bool menu = false;
+};
+
+inline Gestures GesturesOf(bool aim_valid, bool aim_pinching, bool aim_menu, bool aim_system_gesture,
+ bool action_select, bool action_menu) noexcept {
+ if (aim_valid) {
+ return {aim_pinching && !aim_system_gesture, aim_menu || action_menu};
+ }
+ return {action_select, action_menu};
+}
+
+// The select action as simple_controller delivers it: right select is bound to
+// the primary action, left select to the secondary one (openxr_input.cpp).
+inline bool SelectOf(const wii_remote::HandInputs& inputs, size_t hand) noexcept {
+ return hand == 1 ? inputs.primary : inputs.secondary;
+}
+
+// Before anything reads the hands (the settings panel, then the game). A
+// hand-driven hand's select is replaced: with tracked hands on, a right pinch
+// stays A, for the menus, and a left one no longer toggles the settings panel;
+// with them off, the hand presses nothing but the menu gesture, so resting hands
+// with the controllers put down never press anything. Controller hands are left
+// alone.
+inline void ApplyHandDrivenButtons(std::array& hands,
+ const std::array& hand_driven, const std::array& pinch,
+ bool option_on) noexcept {
+ for (size_t hand = 0; hand < hands.size(); ++hand) {
+ if (!hand_driven[hand]) {
+ continue;
+ }
+ hands[hand].primary = option_on && hand == 1 && pinch[hand];
+ hands[hand].secondary = false;
+ }
+}
+
+// In a cockpit race, after the wheel: a bare hand on the wheel holds the gas
+// (A: the right primary button), and an item pinch uses an item (the left
+// trigger: the Wii Remote's Z, the GameCube's L). A hand-driven right hand's
+// pinch stops meaning A there. The grasp itself never becomes a squeeze, which
+// would press the gamepad's shoulders (GameCube R drifts).
+inline void ApplyBareHandRace(std::array& hands,
+ const std::array& hand_driven, const std::array& bare_held,
+ const std::array& item_pinch) noexcept {
+ if (hand_driven[1]) {
+ hands[1].primary = false;
+ }
+ if (bare_held[0] || bare_held[1]) {
+ hands[1].primary = true;
+ }
+ if (item_pinch[0] || item_pinch[1]) {
+ hands[0].trigger = 1.0f;
+ }
+}
+
+// One bare hand for the flick detector: its palm's height in the seated frame.
+struct FlickHand {
+ bool tracked = false;
+ bool held = false;
+ float height = 0.0f;
+};
+
+// A quick upward flick of the hands, the bare-hand shake that does a trick off
+// a ramp or pulls a wheelie. Both hands on the wheel must rise together at a
+// similar speed, so a turn, where one hand rises as the other drops, never
+// counts; a free hand counts alone, but a lone hand on the wheel does not
+// (that is a turn too). A rise must last a few samples and cover some height
+// soon enough, so tracking noise and a pose jumping when tracking comes back
+// never count; the second is also caught as an impossible speed.
+class FlickDetector {
+public:
+ static constexpr float kRisingSpeed = 0.3f; // m/s: the hand is going up
+ static constexpr float kJumpSpeed = 5.0f; // m/s: faster than a hand, a tracking jump
+ static constexpr float kMinRise = 0.05f; // m
+ static constexpr float kWindowSeconds = 0.15f; // to cover kMinRise
+ static constexpr int kMinSamples = 3;
+ static constexpr float kPairSpeed = 1.2f; // mean of both hands, m/s
+ static constexpr float kPairEachSpeed = 0.6f;
+ static constexpr float kPairSpeedDifference = 0.6f;
+ static constexpr float kFreeSpeed = 1.5f;
+ static constexpr float kCooldownSeconds = 0.5f;
+
+ bool Update(const std::array& hands, float dt) noexcept {
+ if (!IsFinite(dt) || dt <= 0.0f) {
+ return false;
+ }
+ dt = std::min(dt, 0.1f);
+ m_cooldown = std::max(m_cooldown - dt, 0.0f);
+ std::array speed{};
+ std::array rising{};
+ for (size_t hand = 0; hand < 2; ++hand) {
+ rising[hand] = Step(m_tracks[hand], hands[hand], dt, speed[hand]);
+ }
+ if (m_cooldown > 0.0f) {
+ return false;
+ }
+ bool fire = false;
+ if (hands[0].held && hands[1].held) {
+ fire = rising[0] && rising[1] && speed[0] >= kPairEachSpeed && speed[1] >= kPairEachSpeed &&
+ 0.5f * (speed[0] + speed[1]) >= kPairSpeed &&
+ std::fabs(speed[0] - speed[1]) <= kPairSpeedDifference;
+ }
+ for (size_t hand = 0; hand < 2 && !fire; ++hand) {
+ fire = hands[hand].tracked && !hands[hand].held && rising[hand] && speed[hand] >= kFreeSpeed;
+ }
+ if (fire) {
+ m_cooldown = kCooldownSeconds;
+ for (Track& track : m_tracks) {
+ track.spent = true;
+ }
+ }
+ return fire;
+ }
+
+ void Reset() noexcept { *this = FlickDetector{}; }
+
+private:
+ struct Track {
+ bool has_last = false;
+ float last = 0.0f;
+ bool rising = false;
+ bool spent = false; // this rise already flicked, or took too long
+ float start = 0.0f;
+ float time = 0.0f;
+ int samples = 0;
+ };
+
+ // Whether the hand is in a rise that qualifies, and that rise's mean speed.
+ static bool Step(Track& track, const FlickHand& hand, float dt, float& speed) noexcept {
+ if (!hand.tracked || !IsFinite(hand.height)) {
+ track = {};
+ return false;
+ }
+ if (!track.has_last) {
+ track.has_last = true;
+ track.last = hand.height;
+ return false;
+ }
+ const float velocity = (hand.height - track.last) / dt;
+ if (std::fabs(velocity) > kJumpSpeed) {
+ track = {};
+ track.has_last = true;
+ track.last = hand.height;
+ return false;
+ }
+ if (velocity >= kRisingSpeed) {
+ if (!track.rising) {
+ track.rising = true;
+ track.spent = false;
+ track.start = track.last;
+ track.time = 0.0f;
+ track.samples = 0;
+ }
+ track.time += dt;
+ ++track.samples;
+ } else {
+ track.rising = false;
+ }
+ track.last = hand.height;
+ if (!track.rising || track.spent) {
+ return false;
+ }
+ const float rise = hand.height - track.start;
+ if (rise < kMinRise) {
+ if (track.time > kWindowSeconds) {
+ track.spent = true; // a slow lift, not a flick
+ }
+ return false;
+ }
+ if (track.samples < kMinSamples) {
+ return false;
+ }
+ speed = rise / track.time;
+ return true;
+ }
+
+ std::array