I'm building a social media network and collaboration tool based on mind maps, documenting my work in this series of blog posts. Follow me if you're interested in what I've learned along the way about building web apps with React, Tailwind CSS, Firebase, Apollo/GraphQL, three.js and TypeScript.
Today's Goal
Let's figure out how we can make a 3D model we build with three.js interactive – when we click on it, the camera moves to put the clicked object to the center of the screen, like this:
I currently need this for a project I'm working on, Nuffshell, a social network and collaboration tool based on mind maps.
For this part of my series, however, I'm not going to work on my Nuffshell code, but build something from scratch, to make it easier for you to follow along.
If you do want to follow along, I recommend you use CodeSandbox and create a new project using the “Vanilla” template:
This already has everything set up for you to work with JavaScript modules.
Step 1: Creating the 3D Scene
As a first step, I'm going to set up my basic 3D scene that shows four colored cubes, for now, without interactivity or animation.
Functions, Functions, Everywhere
I find it good practice to factor every part of my program into functions that do one thing exactly. A function should not be longer than a screen length.
With this in mind, here are the JavaScript modules I create for setting up my 3D scene. Each module contains one function.
import * as THREE from "three";
export default function createRenderer() {
const app = document.getElementById("app");
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
app.appendChild(renderer.domElement);
return renderer;
}
My createRenderer sets up the 3D renderer and attaches it to the HTML page (through a DOM element called app).
I'm setting the size so that the 3D scene fills out the whole browser viewport.
import * as THREE from "three";
export default function createScene() {
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xffffff);
return scene;
}
In this function, I'm setting up a scene. Scenes in three.js are the top-level containers for all the 3D objects to be rendered.
import * as THREE from "three";
export default function createCamera() {
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
camera.position.z = 5;
return camera;
}
Making 3D models in three.js is similar to making real-life movies: you need cameras and light sources to “film” it.
This function sets up the camera.
Notice how I'm setting the Z-coordinate of the camera to 5. This means that the camera is some distance away from the object it is going to “film”.
import * as THREE from "three";
export default function createCube({ color, x, y }) {
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshLambertMaterial({ color });
const cube = new THREE.Mesh(geometry, material);
cube.position.set(x, y, 0);
return cube;
}
Next up is the function for the objects to be shown in my 3D scene. It creates simple 3D cubes. I can provide a color and X/Y coordinates. The Z coordinate is set to 0 for all cubes.
The 3D objects in three.js are made up of geometries and materials.
import * as THREE from "three";
export default function createLight() {
const light = new THREE.PointLight(0xffffff, 1, 1000);
light.position.set(0, 0, 10);
return light;
}
Like in real life, we need a light source to be able to see anything in our 3D scene, so this function is for creating that.
export default function animate(callback) {
function loop(time) {
callback(time);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
}
Since we want to have an animated 3D model, we need to render our model over and over again, 60 times per second, in a never-ending loop. My animate function accepts a callback argument, which is a function that gets executed as often as the browser can handle.
I use the browser's requestAnimationFrame function for this, which the standard way to do this in pretty much all browser games.
Putting It All Together
So now that we have functions to initialize renderer and scene, create a camera, a light source and some cubes and animate them, we can finally use these to create our 3D scene.
import "./styles.css";
import createCube from "./createCube";
import createLight from "./createLight";
import animate from "./animate";
import createCamera from "./createCamera";
import createRenderer from "./createRenderer";
import createScene from "./createScene";
const renderer = createRenderer();
const scene = createScene();
const camera = createCamera();
const cubes = {
pink: createCube({ color: 0xff00ce, x: -1, y: -1 }),
purple: createCube({ color: 0x9300fb, x: 1, y: -1 }),
blue: createCube({ color: 0x0065d9, x: 1, y: 1 }),
cyan: createCube({ color: 0x00d7d0, x: -1, y: 1 })
};
const light = createLight();
for (const object of Object.values(cubes)) {
scene.add(object);
}
scene.add(light);
animate(() => {
renderer.render(scene, camera);
});
Note how the camera, light and comes are all added to the scene.
Our animate function makes sure the scene gets rendered over and over again. For now, pretty useless, but it will become important in the next steps.
Here's the project so far:
Step 2: Adding a Click Handler
Me need to be able to click on 3D objects to tell the camera where to move to. To achieve this, I'm adding a dependency to the npm package three.interactive to my project.
This library allows us to add event listener to our 3D objects, just like with HTML DOM nodes.
At the beginning of my index.js, I'm adding an import statement to use three.interactive:
import { InteractionManager } from "three.interactive";
In addition to the renderer, scene and camera, I'm creating an interaction manager:
const interactionManager = new InteractionManager(
renderer,
camera,
renderer.domElement
);
As you can see, the interaction manager needs to be able to control the renderer, camera and the canvas DOM element that the scene is rendered to.
I change the for loop that creates the cube objects and adds them to the scene to write a log statement to the console when a cube is clicked, to see if it works:
for (const [name, object] of Object.entries(cubes)) {
object.addEventListener("click", (event) => {
event.stopPropagation();
console.log(`${name} cube was clicked`);
});
interactionManager.add(object);
scene.add(object);
}
Notice event.stopPropagation – this is necessary so that if objects are overlapping, only the top object handles the click. Again, this works just like click handlers on DOM nodes.
The one thing that we still need to do is edit the animate loop so the the interaction manager updates with every iteration:
animate(() => {
renderer.render(scene, camera);
interactionManager.update();
});
Here's the project so far:
When you open up the console on this sandbox (click on “console” in the lower left), then click on the 3D cubes, you'll see the log statements issued by the click handler I've added.
Step 3: Moving the Camera
Now let's actually move the camera to the position of the cube that was clicked.
This is actually pretty straightforward – I just have to update the camera's position to match the X/Y coordinates of the cube that was clicked.
Here's the updated for loop that creates the cubes:
for (const [name, object] of Object.entries(cubes)) {
object.addEventListener("click", (event) => {
event.stopPropagation();
console.log(`${name} cube was clicked`);
const cube = event.target;
camera.position.set(cube.position.x, cube.position.y, camera.position.z);
});
interactionManager.add(object);
scene.add(object);
}
Note that while the camera's X and Y coordinates change, the Z coordinate remains the same – the camera will still have 5 units distance from the objects it is “filming”.
Here's the updated sandbox:
Click on the cubes to try it out!
Step 4: Tweening Animation
So far, when a cube is clicked, the camera jumps to the cube's position immediately. This is a step in the right direction, but actually want the camera to move over to the cube's position in a smooth motion (technically, this is called “panning”).
In short, we want to add some proper animation magic!
What is Tweening?
For creating smooth motions in animations, we use a technique called inbetweening, or “tweeting”, in short.
This technique is as old as animating itself, it was used by the artists making Snow White in the 1930s, just as it is used by the artists making animations today.
The basic idea is that you have a start and end state or something to animate (also called “keyframes”), then draw all of the states in between to create the illusion of gradual change.
Consider this bouncing ball animation:
Here, we have 3 keyframes:
- Ball is on the upper left of the screen
- Ball is in the bottom in the middle
- Ball is on the upper right
By adding the tweens, it will look like the ball is bouncing on the floor in a smooth notion. Without them, the ball would just kind of jaggedly jump from one place to another.
JavaScript Library Tween.js
To make our camera move smoothly, we need to use tweening. Like interactivity, three.js does not provide this out of the box. We're going to have to add another npm package dependency to our project: @tweenjs/tween.js.
This library is not specifically for use with three.js. You can use it any time something should be changed over a period of time.
Let's add an import statement to index.js to use it:
import * as TWEEN from "@tweenjs/tween.js";
I create a tween in the for loop that creates my cubes, to the click handler that is fired when one of the cubes ist clicked:
for (const [name, object] of Object.entries(cubes)) {
object.addEventListener("click", (event) => {
event.stopPropagation();
console.log(`${name} cube was clicked`);
const cube = event.target;
const coords = { x: camera.position.x, y: camera.position.y };
new TWEEN.Tween(coords)
.to({ x: cube.position.x, y: cube.position.y })
.onUpdate(() =>
camera.position.set(coords.x, coords.y, camera.position.z)
)
.start();
});
interactionManager.add(object);
scene.add(object);
}
To add the tween, I just have to instantiate a Tween object. The argument I'm passing to the constructor is the data I want to have “tweened”. In my case, this is an object containing X and Y coordinates. At the beginning of the tween, these X/Y coords are the original camera position.
With the to method, I tell the tween library what the end state of the tweened data should be. This will be the position of the cube that was clicked.
With the onUpdate method, I determine how the data that is being tweened can be used to affect my animation. It is called for every tweening step. I use this to update the position of the camera.
Finally, the start method tells the library to start tweening right away.
One more thing – we now have to add a call to the update method of our tweening library to our animation loop:
animate((time) => {
renderer.render(scene, camera);
interactionManager.update();
TWEEN.update(time);
});
The End Result
Here's the final version of our project:
When you click on a cube, the camera pans smoothly over to its position – nice!
To Be Continued…
This tutorial is part of my project diary. I'm building social media network and collaboration tool, based on mind maps. I will continue to blog about my progress in follow-up articles. Stay tuned!
Top comments (2)
I've been having problem with animating the camera until I found Tween library in your post to fix it. Thanks :)
Amazing