How to Make an Object Follow a Path in Three.js

Published on 08 Oct, 2024 | ~6 min read | Demo

In this tutorial, we will explore how to make an object follow a path while maintaining the correct orientation throughout its movement.

Getting Ready

First, ensure you have a Three.js project set up. If you don't have one, you can use my Three.js boilerplate as a starting point.

Next, I will introduce a light source, add a box, and create a path for the box to follow.

const dLight = new THREE.DirectionalLight(0xffffff, 1);
scene.add(dLight);
dLight.position.set(0, 10, 2);
const boxGeometry = new THREE.BoxGeometry(1, 1, 1);
const boxMaterial = new THREE.MeshStandardMaterial({ color: 0x00ff00 });
const box = new THREE.Mesh(boxGeometry, boxMaterial);
scene.add(box);
const points = [
  new THREE.Vector3(-10, 0, 10),
  new THREE.Vector3(-5, 5, 5),
  new THREE.Vector3(0, 0, 0),
  new THREE.Vector3(5, -5, 5),
  new THREE.Vector3(10, 0, 10),
];

const path = new THREE.CatmullRomCurve3(points, true);

const pathGeometry = new THREE.BufferGeometry().setFromPoints(
  path.getPoints(50)
);
const pathMaterial = new THREE.LineBasicMaterial({ color: 0xff0000 });
const pathObject = new THREE.Line(pathGeometry, pathMaterial);
scene.add(pathObject);

Path Following Movement

We can't discuss animation without considering the time variable, so I'll set the time parameter in the animate() function.

The time parameter typically represents the elapsed time since the beginning of the animation loop.

function animate(time) {
  renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);

You might not be using the animate() function in the same way I'm demonstrating here; I mean you’re likely calling requestAnimationFrame(). The issue with this approach is that you cannot access the time variable.

In that case, simply create a time variable and assign it the value returned by Date.now().

function animate() {
  renderer.render(scene, camera);

  const time = Date.now();

  requestAnimationFrame(animate);
}
animate();

Now we'll incorporate the time in the following formula:

const t = ((time / 2000) % 6) / 6; // t should be between 0 and 1 for the CatmullRom curve

Next, we'll create a position variable that will store an object's location on the path at a given time t. We'll use this variable to continuously update the box's position.

const position = path.getPointAt(t);
box.position.copy(position);

In fact, path.getPointAt(value) isn't related to time. It returns a position based on the value of its argument, which must be between 0 and 1. At t = 0, you're at the starting point of the path, and at t = 1, you're at the endpoint.

That being said, in our case, we used the formula to make the value of that argument dependent on time.

Animated Demo.

Orientation

For the box's orientation, we need to calculate the normalized vector, specifically the direction of the tangent at a given point t.

Tangent direction
Tangent direction
const tangent = path.getTangentAt(t).normalize();

Then we'll use the lookAt() method to orient the box in the same tangent direction as the next point.

box.lookAt(position.clone().add(tangent));

Animated Demo.

This Applies to More Than Just Meshes

This method isn't limited to boxes; it also works with cameras, which can create stunning effects when combined with mouse scrolling.

Additionally, it can be used to make spaceships follow paths to other galaxies and planets.

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Path Following Object - Wael Yasmina</title>
    <style>
      body {
        margin: 0;
      }
    </style>
  </head>
  <body>
    <script id="vertexshader" type="vertex">
      varying vec2 vUv;

      void main() {

      	vUv = uv;

      	gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );

      }
    </script>
    <script id="fragmentshader" type="fragment">
      uniform sampler2D baseTexture;
      uniform sampler2D bloomTexture;

      varying vec2 vUv;

      void main() {

      	gl_FragColor = ( texture2D( baseTexture, vUv ) + vec4( 1.0 ) * texture2D( bloomTexture, vUv ) );

      }
    </script>
    <script src="/main.js" type="module"></script>
  </body>
</html>

main.js

import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass';
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass';
import { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass';
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

//renderer.setClearColor(0xFEFEFE);

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  45,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.enablePan = false;
controls.minDistance = 15;
controls.maxDistance = 35;

camera.position.set(0, 0, 26);

renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.5;
renderer.outputColorSpace = THREE.SRGBColorSpace;

const dLight = new THREE.DirectionalLight(0xffffff, 1);
scene.add(dLight);
dLight.position.set(0, 10, 0);

const renderScene = new RenderPass(scene, camera);
const bloomPass = new UnrealBloomPass(
  new THREE.Vector2(window.innerWidth, window.innerHeight),
  0.8,
  1
);
const bloomComposer = new EffectComposer(renderer);
bloomComposer.addPass(renderScene);
bloomComposer.addPass(bloomPass);
bloomComposer.renderToScreen = false;

const mixPass = new ShaderPass(
  new THREE.ShaderMaterial({
    uniforms: {
      baseTexture: { value: null },
      bloomTexture: { value: bloomComposer.renderTarget2.texture },
    },
    vertexShader: document.getElementById('vertexshader').textContent,
    fragmentShader: document.getElementById('fragmentshader').textContent,
  }),
  'baseTexture'
);

const finalComposer = new EffectComposer(renderer);
finalComposer.addPass(renderScene);
finalComposer.addPass(mixPass);

const outputPass = new OutputPass();
finalComposer.addPass(outputPass);

const BLOOM_SCENE = 1;
const bloomLayer = new THREE.Layers();
bloomLayer.set(BLOOM_SCENE);
const darkMaterial = new THREE.MeshBasicMaterial({ color: 0x000000 });
const materials = {};

function nonBloomed(obj) {
  if (obj.isMesh && bloomLayer.test(obj.layers) === false) {
    materials[obj.uuid] = obj.material;
    obj.material = darkMaterial;
  }
}

function restoreMaterial(obj) {
  if (materials[obj.uuid]) {
    obj.material = materials[obj.uuid];
    delete materials[obj.uuid];
  }
}

let ship;
let mixer;
const loader = new GLTFLoader();
// Check out the Credits and Resources section for the link to the model.
loader.load('/multi_universe_space_ship_3d_model.glb', function (glb) {
  const model = glb.scene;
  model.scale.set(0.2, 0.2, 0.2);
  ship = model;
  scene.add(model);

  model.traverse(function (node) {
    if (node.isMesh) node.layers.toggle(BLOOM_SCENE);
  });

  mixer = new THREE.AnimationMixer(model);
  const clips = glb.animations;
  const clip = THREE.AnimationClip.findByName(clips, 'Animation');
  const action = mixer.clipAction(clip);
  action.play();
});

// Check out the Credits and Resources section for the link to the model.
loader.load('/nebula_space_hdri_background_photosphere.glb', function (glb) {
  const model = glb.scene;
  scene.add(model);
});

const points = [
  new THREE.Vector3(-10, 0, 10),
  new THREE.Vector3(-5, 5, 5),
  new THREE.Vector3(0, 0, 0),
  new THREE.Vector3(5, -5, 5),
  new THREE.Vector3(10, 0, 10),
];

const path = new THREE.CatmullRomCurve3(points, true);

const pathGeometry = new THREE.BufferGeometry().setFromPoints(
  path.getPoints(50)
);
const pathMaterial = new THREE.LineBasicMaterial({ color: 0xff0000 });
const pathObject = new THREE.Line(pathGeometry, pathMaterial);
scene.add(pathObject);

const clock = new THREE.Clock();
function animate() {
  controls.update();

  const time = Date.now();
  const t = ((time / 2000) % 6) / 6; // t should be between 0 and 1 for the CatmullRom curve
  const position = path.getPointAt(t);

  const tangent = path.getTangentAt(t).normalize();

  if (ship) {
    ship.position.copy(position);
    ship.lookAt(position.clone().add(tangent));
  }

  if (mixer) mixer.update(clock.getDelta());

  //renderer.render(scene, camera);
  scene.traverse(nonBloomed);
  bloomComposer.render();
  scene.traverse(restoreMaterial);
  finalComposer.render();

  requestAnimationFrame(animate);
}
animate();
//renderer.setAnimationLoop(animate);

window.addEventListener('resize', function () {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
  bloomComposer.setSize(window.innerWidth, window.innerHeight);
  finalComposer.setSize(window.innerWidth, window.innerHeight);
});

Wrap Up

And with this, we've reached the end of this tutorial. I hope you enjoyed it!

Happy coding!

Credits and Resources

Related Content