Grab, hold and throw in VR

Implement controller grabbing, object attachment, release velocity and feedback in a Three.js scene.

By pavlin-dev

Use this skill

Three.js and TypeScript; headset support varies by WebXR browser.

Add the skill folder in Settings. The in-app library is coming in the next desktop update.

The skill

The source instructions, with their original wording. Read as Markdown.

VR grab interaction — pick up, hold, throw

Grabbing is the core verb of most VR games, and getting it to feel right comes down to three things: select the nearest object within hand reach, reparent it to the controller so it tracks the hand without jitter, and on release hand it back to the scene with the velocity the hand was moving — so throwing works. A short haptic pulse on grab confirms the action in the player's hand.

Core principle: reparent to the controller while held (controller.attach), restore to the scene on release with measured velocity.

When to use

  • Picking up, holding, and throwing objects with motion controllers.
  • Symptoms: grabbed object jumps to the controller origin, jitters while held, or drops straight down with no throw momentum.

Prerequisites: session/renderer from xr-session-setup; this builds on controller access from xr-controller-input. Hand-tracking pinch-grab is a variant — same attach/release logic, different trigger event.

Get the controllers and their events

import * as THREE from 'three';

// Two controllers; each is an Object3D that tracks a hand. selectstart/end map to trigger/grip.
const controllers = [0, 1].map((i) => {
  const c = renderer.xr.getController(i);
  scene.add(c);
  c.addEventListener('selectstart', () => onGrabStart(c));
  c.addEventListener('selectend', () => onGrabEnd(c));
  return c;
});

Mark which objects can be picked up, and give each a velocity tracker:

interface Grabbable extends THREE.Object3D { userData: { grabbable: true } }
const grabbables: THREE.Object3D[] = []; // push your pickup meshes here

// Per-controller hold state
const held = new Map<THREE.Object3D, THREE.Object3D>(); // controller -> object

Grab: select nearest in reach, then attach

Use a small hand-sphere proximity test (see collision) to pick the closest grabbable within reach (~0.1 m). Object3D.attach reparents while preserving world transform, so the object doesn't snap.

const GRAB_RADIUS = 0.1;
const _handPos = new THREE.Vector3();
const _objPos = new THREE.Vector3();

function nearestGrabbable(controller: THREE.Object3D): THREE.Object3D | null {
  _handPos.setFromMatrixPosition(controller.matrixWorld);
  let best: THREE.Object3D | null = null;
  let bestDist = GRAB_RADIUS * GRAB_RADIUS;
  for (const obj of grabbables) {
    if (held.has(controller) || isHeld(obj)) continue;
    _objPos.setFromMatrixPosition(obj.matrixWorld);
    const d = _handPos.distanceToSquared(_objPos);
    if (d < bestDist) { bestDist = d; best = obj; }
  }
  return best;
}

function onGrabStart(controller: THREE.Object3D) {
  const obj = nearestGrabbable(controller);
  if (!obj) return;
  controller.attach(obj);        // reparent, keep world position/rotation — no snap
  held.set(controller, obj);
  pulse(controller, 0.6, 40);    // haptic confirm
}

const isHeld = (obj: THREE.Object3D) => [...held.values()].includes(obj);

Track hand velocity while held

To throw, you need the hand's velocity at the moment of release. Sample the controller's world position each frame and keep the last delta.

const prevPos = new Map<THREE.Object3D, THREE.Vector3>();
const velocity = new Map<THREE.Object3D, THREE.Vector3>();

// Call from your renderUpdate(dt) — see game-loop.
function trackControllerVelocity(controller: THREE.Object3D, dt: number) {
  const now = new THREE.Vector3().setFromMatrixPosition(controller.matrixWorld);
  const prev = prevPos.get(controller);
  if (prev && dt > 0) {
    velocity.set(controller, now.clone().sub(prev).divideScalar(dt)); // m/s
  }
  prevPos.set(controller, now);
}

Release: restore to scene + throw

const _m = new THREE.Matrix4();

function onGrabEnd(controller: THREE.Object3D) {
  const obj = held.get(controller);
  if (!obj) return;
  scene.attach(obj);             // reparent back to scene, keep world transform
  held.delete(controller);

  // Hand off the hand's velocity so the object flies (your physics/integrator uses this).
  const v = velocity.get(controller);
  if (v) (obj.userData as any).vel = v.clone(); // e.g. consumed by collision/physics integrator
  pulse(controller, 0.3, 25);
}

The thrown object's motion is then integrated by your loop / collision / physics — this skill just supplies the release velocity.

Haptics — confirm the grab in the hand

Pulse the controller's haptic actuator via its WebXR input source gamepad.

function pulse(controller: THREE.Object3D, intensity = 0.5, ms = 40) {
  const src = controller.userData.inputSource as XRInputSource | undefined;
  const actuator = (src?.gamepad as any)?.hapticActuators?.[0];
  actuator?.pulse?.(intensity, ms);
}

// three.js stores the input source on the controller when it connects:
controllers.forEach((c) =>
  c.addEventListener('connected', (e: any) => { c.userData.inputSource = e.data; }),
);

Domain principles honored here

  • Input abstraction — uses the standard controller select events and the input-source gamepad, not a hardcoded device.
  • 1:1 scale — grab radius and velocity are in meters / m·s⁻¹, matching real reach and throw.
  • Comfort first — held objects track the hand exactly (no lag/jitter that fights the player); the world/head are never moved by a grab.

Common mistakes

Mistake Symptom Fix
controller.add(obj) instead of .attach Object snaps to controller origin use attach (preserves world transform)
scene.add(obj) on release Object jumps in world space use scene.attach(obj)
Computing velocity from object, not hand Throws feel weak/wrong sample the controller's world position
Reading velocity after re-parenting Zero / garbage velocity capture it before scene.attach, from the tracker
No haptic feedback Grab feels unconfirmed pulse() on grab
Allocating vectors every frame in the tracker GC churn reuse scratch vectors where possible

Verify

  • On-device: an object grabbed off-center stays where the hand holds it (no snap to origin).
  • Held object tracks the hand 1:1 with no jitter.
  • Releasing while swinging throws the object in the swing direction with believable speed.
  • A haptic pulse fires on grab.
  • Two-handed: each controller can hold a different object independently.

References

  • xr-controller-input — controller access and button/trigger state.
  • collision — hand-sphere proximity test and integrating the thrown object.
  • vr-polish-checklist — haptics + spatial sound on grab/impact for game feel.