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
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.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.Math.atan2(direction.x, direction.z): Computes the exact rotation angle in radians required to face the camera on the horizontal plane.setFromAxisAngle(yAxis, angle): Creates a rotation quaternion around the unit vector(0, 1, 0)by the calculated angle. Applying this directly tomesh.quaternionoverrides any previous rotation and perfectly locks the object’s orientation to the Y-axis.