Hand Tracking

Hand Tracking and Controller Input

Each controller is a WebXRInputSource whose motionController lists components named by the WebXR Input Profiles registry, which also supplies its 3D model. The emulated right controller reported the meta-quest-touch-plus profile with six components, and its getMainComponent(), the trigger, fired onButtonStateChangedObservable when pulled. Hands arrive when the controllers go down; the hand-tracking feature poses a rigged hand from WebXR's 25 joints per hand:

Switching the emulator to hands and reading a fingertip, after enterXRAsyncJavaScript
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
quest.primaryInputMode = 'hand';                                // IWER: controllers down
await wait(1500);
const hand = xr.baseExperience.featuresManager.getEnabledFeature('xr-hand-tracking')
  .getHandByHandedness('right');
const tip = hand.getJointMesh(BABYLON.WebXRHandJoint.INDEX_FINGER_TIP);
console.log(`index tip at ${tip.position.asArray().map((v) => v.toFixed(2))}`);
Output
BJS - [18:53:17]: Babylon.js v9.28.0 - WebGL2 - Parallel shader compilation
index tip at 0.22,1.56,-1.77

The fingertip is 0.43 m ahead of the visitor. Selection rarely needs either: pointer selection turns controller and hand rays into ordinary pointer events, so Actions and Picking's code works in VR, and near interaction lets fingertips press The 3D GUI's 3D GUI. A design that needs the thumbstick locks out hand-only visitors.

The 25 WebXR hand joints drawn as spheres, curling into a point, with the index fingertip labelledHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
  canvas { width: 100%; max-width: 600px; height: 320px; display: block; touch-action: none; outline: none; }
  #info { max-width: 600px; padding: 4px 8px; font-size: 12px; }
</style>
<canvas id="view"></canvas>
<div id="info">Checking for WebXR…</div>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script src="https://cdn.jsdelivr.net/npm/babylonjs-gui@9.28.0/babylon.gui.min.js"></script>
<script>
// Offer real WebXR when the browser has it; otherwise say so and keep the desktop view.
// (A sandboxed preview frame is not allowed to start immersive sessions.)
async function offerXR(scene, options, status) {
  let supported = false;
  const policy = document.permissionsPolicy || document.featurePolicy;   // may this frame use XR at all?
  const allowed = policy?.allowsFeature ? policy.allowsFeature('xr-spatial-tracking') : true;
  if (navigator.xr && allowed) {
    try {
      supported = await BABYLON.WebXRSessionManager.IsSessionSupportedAsync('immersive-vr');
    } catch (error) { /* no immersive sessions here */ }
  }
  if (!supported) {
    status('immersive-vr is not available here, so this is a desktop stand-in');
    return null;
  }
  const xr = await scene.createDefaultXRExperienceAsync(options);
  status('immersive-vr supported: press the VR button in the corner');
  return xr;
}
// Two eye cameras side by side, as the WebXRCamera's rig renders a headset frame
function stereoRig(scene, position, target, ipd = 0.064) {
  const forward = target.subtract(position).normalize();
  const right = BABYLON.Vector3.Cross(BABYLON.Vector3.Up(), forward).normalize();
  return [-1, 1].map((side) => {
    const eye = new BABYLON.UniversalCamera(side < 0 ? 'leftEye' : 'rightEye', position.add(right.scale(side * ipd / 2)), scene);
    eye.setTarget(target.add(right.scale(side * ipd / 2)));
    eye.minZ = 0.05;
    eye.fov = 1.2;
    eye.viewport = new BABYLON.Viewport(side < 0 ? 0 : 0.5, 0, 0.5, 1);
    scene.activeCameras.push(eye);
    return eye;
  });
}

const canvas = document.getElementById('view');
const engine = new BABYLON.Engine(canvas, true);
const scene = new BABYLON.Scene(engine);
scene.clearColor = BABYLON.Color4.FromHexString('#f7f3ecff');
const camera = new BABYLON.ArcRotateCamera('cam', -Math.PI / 2 - 0.4, 1.1, 0.42, new BABYLON.Vector3(0, 0.08, 0), scene);
camera.minZ = 0.01;
camera.attachControl(canvas, true);
new BABYLON.HemisphericLight('sky', new BABYLON.Vector3(0.3, 1, -0.5), scene);
const ui = BABYLON.GUI.AdvancedDynamicTexture.CreateFullscreenUI('ui');

// WebXR reports 25 joints per hand: the wrist, then 4 or 5 joints down each digit.
// The hand-tracking feature poses a rigged hand from them; here each joint is a sphere.
const J = BABYLON.WebXRHandJoint;
const FINGERS = [
  ['THUMB', [J.THUMB_METACARPAL, J.THUMB_PHALANX_PROXIMAL, J.THUMB_PHALANX_DISTAL, J.THUMB_TIP], -0.035],
  ['INDEX', [J.INDEX_FINGER_METACARPAL, J.INDEX_FINGER_PHALANX_PROXIMAL, J.INDEX_FINGER_PHALANX_INTERMEDIATE, J.INDEX_FINGER_PHALANX_DISTAL, J.INDEX_FINGER_TIP], -0.02],
  ['MIDDLE', [J.MIDDLE_FINGER_METACARPAL, J.MIDDLE_FINGER_PHALANX_PROXIMAL, J.MIDDLE_FINGER_PHALANX_INTERMEDIATE, J.MIDDLE_FINGER_PHALANX_DISTAL, J.MIDDLE_FINGER_TIP], 0],
  ['RING', [J.RING_FINGER_METACARPAL, J.RING_FINGER_PHALANX_PROXIMAL, J.RING_FINGER_PHALANX_INTERMEDIATE, J.RING_FINGER_PHALANX_DISTAL, J.RING_FINGER_TIP], 0.02],
  ['PINKY', [J.PINKY_FINGER_METACARPAL, J.PINKY_FINGER_PHALANX_PROXIMAL, J.PINKY_FINGER_PHALANX_INTERMEDIATE, J.PINKY_FINGER_PHALANX_DISTAL, J.PINKY_FINGER_TIP], 0.038],
];
const skin = new BABYLON.StandardMaterial('skin', scene);
skin.diffuseColor = BABYLON.Color3.FromHexString('#d9a37a');
const tipMat = new BABYLON.StandardMaterial('tip', scene);
tipMat.diffuseColor = BABYLON.Color3.FromHexString('#b5452f');
const joints = new Map();
const wrist = BABYLON.MeshBuilder.CreateSphere(J.WRIST, { diameter: 0.02 }, scene);
wrist.material = skin;
joints.set(J.WRIST, wrist);
for (const [, names] of FINGERS) for (const name of names) {
  const joint = BABYLON.MeshBuilder.CreateSphere(name, { diameter: name.endsWith('tip') ? 0.014 : 0.016 }, scene);
  joint.material = name === J.INDEX_FINGER_TIP ? tipMat : skin;
  joints.set(name, joint);
}
let boneLines = null;
const label = new BABYLON.GUI.TextBlock('', 'INDEX_FINGER_TIP');
Object.assign(label, { color: '#b5452f', fontSize: 12, resizeToFit: true });
ui.addControl(label);
label.linkWithMesh(joints.get(J.INDEX_FINGER_TIP));
label.linkOffsetY = -18;

scene.onBeforeRenderObservable.add(() => {
  const curl = 0.5 + 0.5 * Math.sin(performance.now() / 700);   // open hand <-> fist, index stays out
  const lines = [];
  for (const [finger, names, x] of FINGERS) {
    let position = new BABYLON.Vector3(x * 1.8, 0.03, 0), angle = finger === 'THUMB' ? -0.6 : 0;
    const bend = finger === 'INDEX' ? 0.05 : curl;
    let previous = wrist.position;
    names.forEach((name, k) => {
      const length = k === 0 ? 0.045 : 0.028 - k * 0.003;
      if (k > 0) angle += bend * 0.9;
      const direction = finger === 'THUMB' ? new BABYLON.Vector3(-Math.sin(0.8), Math.cos(angle) * 0.6, Math.sin(angle))
        : new BABYLON.Vector3(0, Math.cos(angle), -Math.sin(angle));
      position = (k === 0 ? new BABYLON.Vector3(x * 1.8, 0, 0) : position).add(direction.normalize().scale(length));
      joints.get(name).position.copyFrom(position);
      lines.push([previous.clone(), position.clone()]);
      previous = joints.get(name).position;
    });
  }
  boneLines?.dispose();
  boneLines = BABYLON.MeshBuilder.CreateLineSystem('bones', { lines }, scene);
  boneLines.color = BABYLON.Color3.FromHexString('#6d4c33');
});

const info = document.getElementById('info');
offerXR(scene, {}, (text) => { info.textContent = `${text}. ${joints.size} joints per hand.`; }).then((xr) => {
  if (!xr) return;
  xr.input.onControllerAddedObservable.add((source) => {   // controllers: profile components
    source.onMotionControllerInitObservable.add((motion) => {
      motion.getMainComponent().onButtonStateChangedObservable.add((c) => { if (c.pressed) info.textContent = `${motion.profileId}: trigger`; });
    });
  });
});
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>