Three.js Y-Axis Billboard Using Quaternions

This article explains how to implement a Y-axis billboard constraint in Three.js using quaternions. You will learn how to make a 3D object rotate to face the camera continuously while restricting its movement solely to the vertical Y-axis, preventing unwanted tilting when the camera moves vertically.

The Problem with Standard Look-At

In Three.js, the standard Object3D.lookAt() method rotates an object on all three axes so that it directly faces a target. While this works well for full spherical billboarding, it causes objects like trees, character sprites, or UI badges to tilt awkwardly backward or forward if the camera goes above or below them.

To keep the object standing upright, we must restrict the rotation to the Y-axis.

Implementation Steps

To constrain the rotation using quaternions, you need to calculate the angle between the object and the camera on the XZ plane, and then apply that angle to the object’s quaternion using an up-axis vector.

Here is the step-by-step implementation to place inside your animation loop:

1. Set Up Variables

Define reusable vectors to avoid garbage collection overhead during the render loop.

import * as THREE from 'three';

const objectPosition = new THREE.Vector3();
const cameraPosition = new THREE.Vector3();
const direction = new THREE.Vector3();
const yAxis = new THREE.Vector3(0, 1, 0);

2. Update Rotation in the Render Loop

Add the following logic inside your requestAnimationFrame loop to update the object’s rotation dynamically:

function animate() {
    requestAnimationFrame(animate);

    // 1. Get the world positions of the object and the camera
    mesh.getWorldPosition(objectPosition);
    camera.getWorldPosition(cameraPosition);

    // 2. Calculate the direction vector from the object to the camera
    direction.subVectors(cameraPosition, objectPosition);

    // 3. Project the direction onto the XZ plane by ignoring the Y difference
    direction.y = 0;
    direction.normalize();

    // 4. Calculate the angle on the XZ plane
    // Use Math.atan2 with x and z components
    const angle = Math.atan2(direction.x, direction.z);

    // 5. Apply the rotation to the object's quaternion around the Y-axis
    mesh.quaternion.setFromAxisAngle(yAxis, angle);

    renderer.render(scene, camera);
}

How It Works

  1. getWorldPosition: Retrieves the absolute positions of both the camera and the target mesh in world space, ensuring the calculation remains accurate even if the objects are nested inside offset parent groups.
  2. direction.y = 0: By flattening the Y component of the direction vector, we eliminate vertical pitch. The calculation only cares about where the camera is positioned horizontally relative to the object.
  3. Math.atan2(direction.x, direction.z): Computes the exact rotation angle in radians required to face the camera on the horizontal plane.
  4. setFromAxisAngle(yAxis, angle): Creates a rotation quaternion around the unit vector (0, 1, 0) by the calculated angle. Applying this directly to mesh.quaternion overrides any previous rotation and perfectly locks the object’s orientation to the Y-axis.