/ javascript

Three.js & 3D interactive animations: a tutorial

Following my previous post GameDev with three.js on the modern web 🚀🎆, where I gave an overview of my three months journey into gamedev with three.js I wanted to share how I kept my code clean and organized, starting with the render loop.

One of the common culprit of developing any non trivial software project is to avoid spaghetti code. You always start with a nice single and simple page of code but soon enough it grows and grows and you have to keep it under control, you need to modularize your code, you need to organize and structure your project into a meaningful and comprehensible one.

  1. How you render 3D things in three.js
  2. Here comes the render loop
  3. PubSubJS
  4. A flexible and simple render loop
  5. Final interactive demo

How you render 3D things in three.js

  1. Setup the scene

You can probably skip this first part if you already touched three.js, sorry for the hello world explanation ;)

You just describe the content of your scene, with code, what you want to show on the screen. Let's take a simple example

import * as THREE from 'three'

// create a three.js scene
var scene = new THREE.Scene()

// create a camera
var camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000)
camera.position.set(50, 30, 50)
camera.lookAt(0, 0, 0)

// create the renderer
var renderer = new THREE.WebGLRenderer()
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)

// create a simple cube
var geometry = new THREE.BoxGeometry(20, 20, 20)
var material = new THREE.MeshLambertMaterial({color: 0x10a315 })
var cube = new THREE.Mesh(geometry, material)
scene.add(cube)

// add a light so we can see something
var light = new THREE.PointLight(0xFFFF00)
light.position.set(25, 25, 25)
scene.add(light)
  1. Render the scene

We just have to call the renderer, passing it the 3D scene and the camera as arguments. It will compute the 2D representation of the 3D scene from the point of view of the camera and display that on the screen.

renderer.render(scene, camera);

You should now have rendered a cube! Here's a Codepen to show you the result.

See the Pen Hello world: a simple cube (1/3) by Maxime R. (@blaze33) on CodePen.

The catch is, it's a static image. To have an animated 3D environment at 60 fps you need to actually call renderer.render 60 times per second.

Here comes the render loop

If it's not your first three.js tutorial, you know that you have to code a loop that continuously calls renderer.render. And you know you need to use window.requestAnimationFrame() for that.

Here's the simplest example:

var mainLoop = () => {
  requestAnimationFrame(mainLoop)
  renderer.render(scene, camera)
}

mainLoop()

Now we could have a live animation of the static cube, not breathtaking, let's rotate the cube a little bit each time the loop is called to visualize that. Just add the following code in the mainLoop function

cube.rotation.x += Math.PI / 180
cube.rotation.y += Math.PI / 180
cube.rotation.z += Math.PI / 180

Pi being 180 degrees, it rotates the cube along each axis by one degree each frame. Try to add each of the three lines one by one to visualize how each axis of rotation is affected. Here's the demo:

See the Pen Hello world: a simple cube that rotates (2/3) by Maxime R. (@blaze33) on CodePen.

Maybe you see it coming now, you'll have dozens of animated 3D objects, some of them animated, others not, some appear, some disappear, some animations are fired upon user interaction, etc. The thing is: you cannot add all your logic in the mainLoop function!

The following is a presentation of how I structured my main loop function to keep it simple and flexible in droneWorld.

Let's take a detour to introduce the PubSubJS library I used to help me along the way.

PubSubJS

PubSubJS is a topic-based publish/subscribe library written in JavaScript.

The gist is, you declare a callback that will be executed when a message is published in PubSub. Why not just call the callback when needed then? Well you could have several different things happening when an event occurs, and they may not all be related. The PubSub architecture allows you to keep your code organized. Taking an example from droneWorld:

// in sounds/index.js
PubSub.subscribe('x.drones.gun.start', (msg, drone) => {
    // play sound
}

// in particles/index.js
PubSub.subscribe('x.drones.gun.start', (msg, drone) => {
    // send bullets
}

// in controls/index.js
// when mouse is clicked:
PubSub.publish('x.drones.gun.start', pilotDrone)

Now when we click the mouse, an event named x.drones.gun.start is fired with the pilotDrone object as a payload, the message subscribers are fired and a sound is played and bullets are drawn on the screen. This way the different parts of the code stay independent (e.g. you could still draw bullets without the sound system, easily) because the alternative would be to import every callback function in every module where it's needed and you quickly have a callback hell.

A flexible and simple render loop

Let's define a loops variable, it's an array containing animation functions. We initialize it with the animation we want at first.

As I implemented it, the loops array can contain functions or objects of the following form:

{
  id: 'myLoop',
  alive: true, // if false, the loop will be removed from the loops array
  loop: (timestamp, delta) => {doSomething(timestamp, delta)}
}
const rotateX = () => {
    cube.rotation.x += Math.PI / 180
}

const rotateY = () => {
    cube.rotation.y += Math.PI / 180
}

const rotateZ = () => {
    cube.rotation.z += Math.PI / 180
}

let loops = [
  rotateX,
  rotateY
]

Let's define some helpers to add or remove loops to the loops variable.

const removeLoop = (loop) => {
  loops = loops.filter(item => item.id !== loop.id)
}
// declare a subscriber to remove loops
PubSub.subscribe('x.loops.remove', (msg, loop) => removeLoop(loop))
// declare a subscriber to add a loop
PubSub.subscribe('x.loops.push', (msg, loop) => loops.push(loop))
// declare a subscriber to add a loop that will be executed first
PubSub.subscribe('x.loops.unshift', (msg, loop) => loops.unshift(loop))

const cleanLoops = () => {
  loops.forEach(loop => {
    if (loop.alive !== undefined && loop.alive === false && loop.object) {
      scene.remove(loop.object)
    }
  })
  loops = loops.filter(loop => loop.alive === undefined || loop.alive === true)
}

Let's declare stats.js here. It's a naive helper to show the FPS.

const stats = new Stats()
document.body.appendChild(stats.dom)

Let's declare a subscriber that will allow us to start and stop the animation at will.

let play = true
PubSub.subscribe('x.toggle.play', () => { play = !play })

Now we declare the mainLoop function.

let lastTimestamp = 0
var mainLoop = (timestamp) => {
  requestAnimationFrame(mainLoop)
  let delta = timestamp - lastTimestamp
  lastTimestamp = timestamp

  if (play) {
    loops.forEach(loop => {
      loop.loop ? loop.loop(timestamp, delta) : loop(timestamp, delta)
    })
    
    renderer.render(scene, camera)
  }

  cleanLoops()

  stats.update()
}

mainLoop(0)

And so we have a simple and flexible mainLoop function under 15 lines of code!

Final interactive demo

Look at the code to see how easy it is now to add or remove animations from the scene.

if (someCondition) {
  // starts the cube rotation
  PubSub.publish('x.loops.push', rotateX)
} else {
  // stops the cube rotation
  PubSub.publish('x.loops.remove', rotateX)
}

See the Pen Hello world: a simple interactive cube (3/3) by Maxime R. (@blaze33) on CodePen.

That's it! Thanks for reading!
Check droneWorld to see three.js in action:

Launch Demo Code on Github Star

Maxime Rouyrre

Maxime Rouyrre

I'm an Entrepreneur, UX Designer & Full-stack Web Engineer. Currently working a full time job but we can do some networking ! You can meet me in Paris, France.

Read More
>>>>>>> upstream/master