Back openDesk Edu for a sovereign, open-source education — every vote counts.
Vote nowSave products you love by clicking the heart icon.
End-to-End-Leitfaden zum Erstellen einer produktionsreifen WebXR-Anwendung mit Three.js und TypeScript — Projektsetup, Session-Lifecycle, Controller-Input, Teleportation, VR-UI und Deployment.
Eine einsteigerfreundliche Einführung in WebXR – Erstellen Sie Virtual- und Augmented-Reality-Erlebnisse, die direkt im Browser und ohne Plugins laufen.
Hand-Tracking ist eines der transformativsten Features moderner XR. Anstatt nach einem Controller zu greifen, greifen Sie direkt nach dem Objekt. In diesem Artikel untersuchen wir die WebXR Hand Tracking API von Grund auf – wie man auf Joint-Daten zugreift, Hände visuell rendert, Gesten erkennt und natürliche Interaktionsmuster aufbaut.
hand-tracking Feature unterstützt (Chrome für Android, Meta Quest Browser)| Gerät | Hand-Tracking | Joint-Anzahl | Max. Hände |
|---|---|---|---|
| Meta Quest 2 | ✅ | 25 pro Hand | 2 |
| Meta Quest 3 | ✅ | 26 (inkl. Handgelenk) | 2 |
| Meta Quest Pro | ✅ | 26 | 2 |
| PICO 4 | ✅ | 25 | 2 |
| Magic Leap 2 | ✅ controller-basiert | 25 | 2 |
Alle Geräte verwenden dasselbe XRHand Interface – der Unterschied liegt in der Tracking-Qualität, der Anzahl der Joints und dem Umgang mit Okklusionen.
Hand-Tracking ist ein optionales Feature – Sie müssen es bei der Erstellung der Session anfordern:
const session = await navigator.xr.requestSession("immersive-vr", {
requiredFeatures: ["hand-tracking"],
});
Sobald die Session aktiv ist, besitzt jeder XRInputSource (der eine Hand oder einen Controller repräsentiert) eine hand Eigenschaft, die ein XRHand Objekt enthält. Das XRHand ist eine Map-ähnliche Sammlung von XRHandJoint XRJointSpace Einträgen.
// In the animation frame callback:
function onXRFrame(time: number, frame: XRFrame) {
for (const inputSource of session.inputSources) {
const hand = inputSource.hand;
if (!hand) continue; // Not a hand-tracking input source
for (const [jointName, jointSpace] of hand) {
const pose = frame.getPose(jointSpace, referenceSpace);
if (pose) {
const pos = pose.transform.position;
const rot = pose.transform.orientation;
// pos.x, pos.y, pos.z are in meters relative to referenceSpace
}
}
}
}
Die WebXR-Spezifikation definiert 25 Joints pro Hand, die über das XRHandJoint Enum zugänglich sind:
thumb-tip, index-finger-tip, middle-finger-tip, ring-finger-tip, pinky-finger-tipthumb-phalanx-proximal, index-finger-phalanx-proximal, etc.index-finger-phalanx-distal, etc.index-finger-metacarpal, etc.wristBei Quest 3 und Quest Pro bringt ein zusätzlicher thumb-metacarpal Joint die Gesamtzahl auf 26.
Das Abfragen von Joints einzeln mit getPose() kann langsam sein – 25 Joints 2 Hände = 50 getPose() Aufrufe pro Frame. Verwenden Sie XRFrame.fillPoses() für Bulk-Abfragen:
// Set up once
const jointSpaces: XRSpace[] = [];
for (const [, space] of hand) {
jointSpaces.push(space);
}
const jointPoses = new Float32Array(jointSpaces.length * 16); // 4x4 matrices
// Each frame
frame.fillPoses(jointSpaces, referenceSpace, jointPoses);
Dies ist deutlich schneller, da die Runtime alle Joints in einer einzigen Operation verarbeitet.
Sobald Sie die Joint-Positionen haben, ist die einfachste Visualisierung eine Kugel pro Joint mit verbindenden Linien:
const jointGeometry = new THREE.SphereGeometry(0.01, 8, 8);
const jointMaterial = new THREE.MeshBasicMaterial({ color: 0x00aaff });
const jointMeshes: THREE.Mesh[] = [];
for (const [, jointSpace] of hand) {
const mesh = new THREE.Mesh(jointGeometry, jointMaterial);
scene.add(mesh);
jointMeshes.push(mesh);
}
// Each frame — update positions
let i = 0;
for (const [, jointSpace] of hand) {
const pose = frame.getPose(jointSpace, referenceSpace);
if (pose) {
jointMeshes[i].position.set(
pose.transform.position.x,
pose.transform.position.y,
pose.transform.position.z,
);
}
i++;
}
Für einen realistischeren Look verbinden Sie die Joints mit Zylindern oder Liniensegmenten, um ein Skelett zu bilden:
// Bone connections
const boneConnections: [XRHandJoint, XRHandJoint][] = [
["wrist", "thumb-metacarpal"],
["thumb-metacarpal", "thumb-phalanx-proximal"],
["thumb-phalanx-proximal", "thumb-phalanx-distal"],
["thumb-phalanx-distal", "thumb-tip"],
["wrist", "index-finger-metacarpal"],
// ... repeat for all fingers
];
Für Produktionsanwendungen sollten Sie Skinned Meshes in Betracht ziehen – ein einzelnes Handmodell mit Bones, die auf die WebXR-Joints gemappt werden. Bibliotheken wie three-mesh-bvh können bei der Kollisionserkennung auf dem Hand-Mesh helfen.
Die eigentliche Stärke des Hand-Trackings liegt in der Gestenerkennung. Hier sind die wichtigsten Muster:
Ein Pinch wird erkannt, indem der Abstand zwischen der Daumenspitze und der Zeigefingerspitze gemessen wird:
function getPinchStrength(hand: XRHand, frame: XRFrame, referenceSpace: XRReferenceSpace): number {
const thumbPose = frame.getPose(hand.get("thumb-tip")!, referenceSpace);
const indexPose = frame.getPose(hand.get("index-finger-tip")!, referenceSpace);
if (!thumbPose || !indexPose) return 0;
const dx = thumbPose.transform.position.x - indexPose.transform.position.x;
const dy = thumbPose.transform.position.y - indexPose.transform.position.y;
const dz = thumbPose.transform.position.z - indexPose.transform.position.z;
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
// 0.05 meters (~2 inches) = fully open, 0.01 meters = fully pinched
return Math.max(0, Math.min(1, 1 - (distance - 0.01) / 0.04));
}
Ein Wert von 1,0 bedeutet einen vollständigen Pinch. Verwenden Sie einen Schwellenwert (z. B. 0,8), um Pinch-Events auszulösen.
Erkennen Sie, wenn der Zeigefinger ausgestreckt und die anderen Finger gebeugt sind:
function isPointing(hand: XRHand, frame: XRFrame, refSpace: XRReferenceSpace): boolean {
const indexTip = frame.getPose(hand.get("index-finger-tip")!, refSpace)?.transform.position;
const indexBase = frame.getPose(hand.get("index-finger-metacarpal")!, refSpace)?.transform
.position;
const middleTip = frame.getPose(hand.get("middle-finger-tip")!, refSpace)?.transform.position;
const middleBase = frame.getPose(hand.get("middle-finger-metacarpal")!, refSpace)?.transform
.position;
if (!indexTip || !indexBase || !middleTip || !middleBase) return false;
// Index is extended (tip further from base than middle)
const indexLength = indexTip.distanceTo(indexBase);
const middleLength = middleTip.distanceTo(middleBase);
return indexLength > middleLength * 1.3;
}
Die rohe Gestenerkennung ist oft verrauscht. Implementieren Sie eine State Machine, um Übergänge zu glätten:
enum GestureState {
Idle,
Pinching,
Pointing,
Grab,
}
class GestureDetector {
private state: GestureState = GestureState.Idle;
private pinchHeld = false;
update(hand: XRHand, frame: XRFrame, refSpace: XRReferenceSpace) {
const pinch = getPinchStrength(hand, frame, refSpace);
const pointing = isPointing(hand, frame, refSpace);
switch (this.state) {
case GestureState.Idle:
if (pinch > 0.8) this.transitionTo(GestureState.Pinching);
else if (pointing) this.transitionTo(GestureState.Pointing);
break;
case GestureState.Pinching:
if (pinch < 0.3) this.transitionTo(GestureState.Idle);
break;
// ...
}
}
private transitionTo(newState: GestureState) {
// Fire onEnter/onExit callbacks
this.state = newState;
}
}
Sobald Sie eine zuverlässige Gestenerkennung haben, sind dies die drei gängigsten Interaktionsmuster:
Die Hand fungiert als 3D-Cursor. Ein Collider (Kugel oder Kapsel) an der Zeigefingerspitze oder der Handfläche prüft auf Überschneidungen mit interaktiven Objekten:
const handCollider = new THREE.Mesh(
new THREE.SphereGeometry(0.02),
new THREE.MeshBasicMaterial({ visible: false }),
);
scene.add(handCollider);
// In frame loop — position collider at index tip
const indexTip = frame.getPose(hand.get("index-finger-tip")!, refSpace);
if (indexTip) {
handCollider.position.copy(indexTip.transform.position);
// Check overlap with interactable objects
}
Bestens geeignet für: Greifen, Drücken, Betätigen von Knöpfen, Manipulieren kleiner Objekte.
Ein Strahl (Ray) geht zwischen Daumen und Zeigefinger (oder aus der Handfläche) aus. Pinch zum Auswählen, Bewegen und Loslassen:
const rayOrigin = new THREE.Vector3();
const rayDirection = new THREE.Vector3();
// Compute ray from midpoint of thumb-index web
const thumbBase = frame.getPose(hand.get("thumb-phalanx-proximal")!, refSpace);
const indexBase = frame.getPose(hand.get("index-finger-metacarpal")!, refSpace);
if (thumbBase && indexBase) {
rayOrigin.lerpVectors(thumbBase.transform.position, indexBase.transform.position, 0.5);
// Direction = forward from palm, computed from wrist/knuckle orientation
}
Bestens geeignet für: Auswahl entfernter Objekte, Menünavigation, Zeigen.
Binden Sie ein 2D- oder 3D-UI-Element an den Bereich der Handfläche:
const palmSpace = hand.get("wrist"); // or 'palm' on supported devices
// The UI element follows the palm position and orientation
Bestens geeignet für: Schnellzugriffsmenüs, Statusanzeigen, Werkzeugauswahl.
| Optimierung | Auswirkung |
|---|---|
fillPoses() statt einzelner getPose() pro Joint verwenden | 3-5x schneller bei 25 Joints |
| Hände nur rendern, wenn sie im Sichtfeld sind (Frustum Culling) | Spart GPU-Zeit |
| Joint-Detailgrad basierend auf Distanz zum Kopf reduzieren | LOD: 25 12 5 Joints |
| Okklusions-behandelte Joints (innerhalb der Geometrie) überspringen | Weniger Draw Calls |
| Joint-Meshes poolen statt pro Frame neu zu allozieren | Null GC-Druck |
Tipp zum Frame-Budget: Hand-Tracking-Daten kommen je nach Gerät mit 30-60 Hz an. Wenn Ihr Render-Loop mit 90 Hz läuft, müssen Sie die Hand-Visuals nicht in jedem Frame aktualisieren – eine Interpolation zwischen den Samples ist völlig ausreichend.