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.
- How you render 3D things in three.js
- Here comes the render loop
- PubSubJS
- A flexible and simple render loop
- Final interactive demo
How you render 3D things in three.js
- 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)
- 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: