Map Gamepad Buttons to WebXR Animations in Three.js

In WebXR applications built with Three.js, leveraging VR controllers can significantly enhance user immersion. This article provides a step-by-step guide on how to detect gamepad inputs, identify specific button presses, and map them to trigger custom 3D character or object animations using the Three.js AnimationMixer.

1. Access the WebXR Session and Gamepad API

To read inputs from VR controllers, you must access the active WebXR session’s inputSources within your Three.js render loop. Unlike standard 2D browser games, WebXR gamepads are accessed through the XR input sources.

function animate() {
  renderer.setAnimationLoop(render);
}

function render() {
  const session = renderer.xr.getSession();
  if (session) {
    processGamepadInput(session);
  }
  
  // Update your Three.js animation mixer
  const delta = clock.getDelta();
  if (mixer) mixer.update(delta);

  renderer.render(scene, camera);
}

2. Identify and Read Controller Buttons

Each controller in WebXR exposes a gamepad object containing an array of buttons. While button mapping can vary slightly between hardware manufacturers, the standard WebXR gamepad mapping for modern controllers (like Meta Quest or Valve Index) generally follows this layout:

Here is how to extract and monitor these inputs:

// Track previous frame states to detect single-click triggers
const prevButtonStates = new Map();

function processGamepadInput(session) {
  for (const source of session.inputSources) {
    if (source.gamepad) {
      const gamepad = source.gamepad;
      const handedness = source.handedness; // 'left' or 'right'
      
      // We will map Button 3 (A/X button)
      const actionButton = gamepad.buttons[3]; 

      if (actionButton) {
        const buttonKey = `${handedness}_button3`;
        const wasPressed = prevButtonStates.get(buttonKey) || false;

        if (actionButton.pressed && !wasPressed) {
          // Trigger the animation only on the initial press event
          triggerCustomAnimation(handedness);
        }

        // Save current state for the next frame comparison
        prevButtonStates.set(buttonKey, actionButton.pressed);
      }
    }
  }
}

3. Set Up and Trigger the Three.js Animation

To play a custom animation when the button is pressed, load your GLTF model with its animations and initialize a THREE.AnimationMixer. Store the desired animation clip as an AnimationAction.

let mixer;
let jumpAction;
let waveAction;

const loader = new GLTFLoader();
loader.load('path/to/model.gltf', (gltf) => {
  const model = gltf.scene;
  scene.add(model);

  mixer = new THREE.AnimationMixer(model);

  // Find animations by name
  const jumpClip = THREE.AnimationClip.findByName(gltf.animations, 'Jump');
  const waveClip = THREE.AnimationClip.findByName(gltf.animations, 'Wave');

  if (jumpClip) jumpAction = mixer.clipAction(jumpClip);
  if (waveClip) waveAction = mixer.clipAction(waveClip);
});

Finally, define the triggerCustomAnimation function to play the corresponding action based on which hand pressed the button.

function triggerCustomAnimation(hand) {
  let action;

  if (hand === 'right' && jumpAction) {
    action = jumpAction;
  } else if (hand === 'left' && waveAction) {
    action = waveAction;
  }

  if (action) {
    action.reset();
    action.setLoop(THREE.LoopOnce);
    action.clampWhenFinished = true; // Stops at the final frame
    action.play();
  }
}