Skip to main content

Hand Gestures

XR Blocks gesture recognition is split into two explicit layers:

PoseEstimator -> HandContext -> GestureRecognizer -> gesture events

A PoseEstimator converts a source of hand pose data into the SDK's canonical HandContext. A GestureRecognizer reads that context and returns confidence scores for named gestures. GestureRecognition handles update timing, confidence thresholds, and gesturestart, gestureupdate, and gestureend events.

The default setup is:

WebXRHandPoseEstimator -> HandContext -> HeuristicGestureRecognizer

Quick Start

import * as xb from 'xrblocks';

const options = new xb.Options();
options.enableGestures();

// Built-in heuristic gestures are registered by default.
options.gestures.minimumConfidence = 0.6;
options.gestures.setGestureEnabled('point', true);
options.gestures.setGestureEnabled('spread', true);

await xb.init(options);

Listen for events from xb.core.gestureRecognition:

class GestureLogger extends xb.Script {
init() {
const gestures = xb.core.gestureRecognition;
if (!gestures) return;

gestures.addEventListener('gesturestart', (event) => {
const {hand, name, confidence} = event.detail;
console.log(`${hand} started ${name}: ${confidence.toFixed(2)}`);
});

gestures.addEventListener('gestureend', (event) => {
console.log(`${event.detail.hand} ended ${event.detail.name}`);
});
}
}

Core Interfaces

These are the shapes a custom implementation should follow.

interface HandContext {
handedness: xb.Handedness;
handLabel: 'left' | 'right';
globalTransform: THREE.Matrix4;
joints: Map<xb.JointName, THREE.Vector3>;

getLocalJointPositions(): Float32Array;
getGlobalJointPositions(): Float32Array;
getJoint(
jointName: xb.JointName,
global?: boolean
): THREE.Vector3 | undefined;
}

joints should contain canonical XR Blocks/WebXR-style joint names from xb.HAND_JOINT_NAMES. For the default WebXR estimator, joints aliases global positions. getJoint(name) defaults to global positions. Custom pose estimators may use the same local and global coordinates if they do not have a separate world transform.

interface PoseEstimator {
init?(dependencies?: {user?: xb.User}): Promise<void>;
getHandContext(handedness: xb.Handedness): HandContext | null;
getHandContexts(): Partial<Record<'left' | 'right', HandContext>>;
dispose?(): void;
}

type GestureScoreMap = Record<
string,
{confidence: number; data?: Record<string, unknown>} | undefined
>;

interface GestureRecognizer {
init?(): Promise<void>;
recognize(context: HandContext): GestureScoreMap | Promise<GestureScoreMap>;
getGestureConfigurations?(): Record<
string,
{enabled: boolean; threshold?: number}
>;
dispose?(): void;
}

GestureRecognition calls the configured pose estimator for each hand, passes each available HandContext to the configured gesture recognizer, and emits events for configured gestures whose confidence is at least options.gestures.minimumConfidence.

Gesture Options

const options = new xb.Options();
options.enableGestures();

options.gestures.minimumConfidence = 0.7;
options.gestures.updateIntervalMs = 33;

options.gestures.setPoseEstimator(new xb.WebXRHandPoseEstimator());
options.gestures.setGestureRecognizer(new xb.HeuristicGestureRecognizer());

options.gestures.setGestureEnabled('point', true);
options.gestures.setGestureConfig('pinch', {
enabled: true,
threshold: 0.025,
});

Gesture names are strings. They are not limited to built-ins. The gesture catalogue is initialized from gestureRecognizer.getGestureConfigurations?.(), then any explicit setGestureConfig or setGestureEnabled calls override those defaults.

Heuristic Gesture Registration

HeuristicGestureRecognizer is the simplest way to add a custom gesture. It accepts detector functions at initialization time:

const recognizer = new xb.HeuristicGestureRecognizer();

recognizer.registerGesture(
'victory',
(context, config) => {
const indexStraight = xb.getFingerStraightness(context, 'index');
const middleStraight = xb.getFingerStraightness(context, 'middle');
const ringCurl = xb.getFingerCurl(context, 'ring');
const pinkyCurl = xb.getFingerCurl(context, 'pinky');
const spread = xb.getFingerSpread(context, 'index', 'middle');

const confidence = xb.clamp01(
xb.average([indexStraight, middleStraight]) *
xb.average([ringCurl, pinkyCurl, spread])
);

return {
confidence,
data: {
indexStraight,
middleStraight,
ringCurl,
pinkyCurl,
spread,
},
};
},
{enabled: true}
);

options.gestures.setGestureRecognizer(recognizer);

Use new xb.HeuristicGestureRecognizer(false) when you want only your custom registrations and no built-in gestures.

const recognizer = new xb.HeuristicGestureRecognizer(false)
.registerGesture('wave', detectWave)
.registerGesture('pinch-ish', detectPinchish);

The current built-in heuristic names are:

pinch
open-palm
fist
thumbs-up
thumbs-down
point
spread

point and spread are registered disabled by default; enable them by name if you want them emitted.

Hand Pose Helpers

All helpers are exported from xrblocks and operate on HandContext.

Joint access and palm pose:

getJoint(context, jointName)
getFingerJoint(context, finger, suffix)
estimateHandScale(context)
getPalmWidth(context)
getPalmNormal(context)
getPalmRight(context)
getPalmUp(context)
getPalmPose(context)

Finger and thumb features:

getFingerBendAngles(context, finger)
getFingerStraightness(context, finger)
getFingerCurl(context, finger)
getFingerDirection(context, finger)
getFingerPalmAlignment(context, finger)
getFingerSpread(context, fingerA, fingerB)
getAdjacentFingerSpreads(context)
getThumbBendAngles(context)
getThumbStraightness(context)
getThumbCurl(context)
getThumbDirection(context)
getThumbOpposition(context, finger)
getThumbVerticalDirection(context)
getFingertipDistance(context, digitA, digitB)
getFingertipPalmDistance(context, digit)

Feature-vector helpers for ML/custom models:

getBoneVectors(context, global = false)
getRelativeBoneAngles(context, global = false)

Utility helpers:

average(values)
clamp01(value)

Finger names are index, middle, ring, and pinky. Digit names are thumb, index, middle, ring, and pinky.

Custom Gesture Recognizer

Use a custom GestureRecognizer when your recognizer owns its own model or wants to score several gestures together.

class CustomGestureRecognizer {
async init() {
this.model = await loadMyModel();
}

getGestureConfigurations() {
return {
rock: {enabled: true},
shaka: {enabled: true},
victory: {enabled: true},
};
}

recognize(context) {
const features = xb.getRelativeBoneAngles(context);
const result = runModel(this.model, features);

return {
rock: {confidence: result.rock},
shaka: {confidence: result.shaka},
victory: {confidence: result.victory},
};
}
}

options.gestures.setGestureRecognizer(new CustomGestureRecognizer());

Recognizers may return a Promise<GestureScoreMap>. The SDK stores the latest completed async result for each hand and keeps update frames moving.

Custom Pose Estimator

Use a custom PoseEstimator when your pose data does not come from xb.user.hands. For example, a webcam or ML landmark model can be adapted into the canonical HandContext.

class WebcamPoseEstimator {
async init() {
this.video = document.querySelector('video');
this.detector = await createWebcamHandDetector();
}

getHandContext(handedness) {
if (handedness !== xb.Handedness.RIGHT) return null;

const landmarks = this.detector.latestLandmarks;
if (!landmarks) return null;

const joints = new Map();
joints.set('wrist', toVector3(landmarks[0]));
joints.set('thumb-metacarpal', toVector3(landmarks[1]));
joints.set('thumb-phalanx-proximal', toVector3(landmarks[2]));
joints.set('thumb-phalanx-distal', toVector3(landmarks[3]));
joints.set('thumb-tip', toVector3(landmarks[4]));
// Continue mapping to every name in xb.HAND_JOINT_NAMES.

return {
handedness,
handLabel: 'right',
globalTransform: new THREE.Matrix4(),
joints,
getLocalJointPositions: () => jointMapToArray(joints),
getGlobalJointPositions: () => jointMapToArray(joints),
getJoint: (jointName) => joints.get(jointName),
};
}

getHandContexts() {
return {
right: this.getHandContext(xb.Handedness.RIGHT) ?? undefined,
};
}
}

options.gestures.setPoseEstimator(new WebcamPoseEstimator());

When adapting MediaPipe-style 21-landmark hands, map source landmarks into the XR Blocks joint names. If the source model does not expose a joint exactly, estimate it consistently. For example, MediaPipe does not separately expose the four finger metacarpals in the same way WebXR does, so a demo can approximate them between the wrist and each finger's MCP landmark.