A simple approach to writing clear and maintainable Three.js code

When I was starting out with Three.js, I didn’t know how to organize my projects in a way that was clear, consistent and easy to maintain. I didn’t know Javascript and had no experience with web development in general, which made things a bit confusing at first.

After some practice and thought about the process, I think I have been able to come up with a decent way to structure my projects. This solution works quite well for simple to medium complexity Three.js apps. In this post I’m going to share my approach.

Please, if you have a better solution or some improvements you’d like to suggest: let me know. This solution is not definitive by any means, I have been improving it constantly over time, and I intend to continue doing so.

To keep things simple, in this article I’m focusing only on the logical structure of the Three.js project, I won’t be using any tool or “advanced” feature of Javascript.

Going forward I will use a sample project as an example, you can find it here.

Overview

The goal of this solution is to separate as much as possible each component of the application. Different parts of the application shouldn’t know much about each other and, when possible, nothing at all.

  • Web-oriented code (relative to the DOM, events, etc), shouldn’t know anything about the Three.js world and, vice versa, Three.js code shouldn’t know anything about the DOM.
  • The Three.js side of the app should be modularized, it should be the composition of a bunch of independent components.

An high level component (or container) is used as the entry point of the Three.js application, it is responsible for initializing and managing the scene, but it doesn’t know anything about the actual content of the scene. The only thing it knows is that it contains a bunch of other components that should be updated at every frame.

Each entity of the 3D scene should have its own component and all the logic for that entity should be contained inside that component. Obviously, if needed, each entity can be a container itself and contain multiple other entities. Each entity doesn’t know anything about its container.

Higher level component contains multiple components. The entities don’t know anything about the higher level component.

Dependencies are going in only one direction, from the outermost to the innermost component.

Example

We want to build a solar system in our Three.js app.

The higher level component is going to be a class responsible for initializing the Three.js scene (canvas, renderer etc) and initializing and holding the scene entities.

The Solar System is a scene entity. It contains a bunch of planets. And maybe it moves in space. (When the Solar System is moved, all its Planets are also moved)

Each Planet is a scene entity.

A Planet may have People and Buildings in it. Those are other entities, contained in each Planet entity.

High level structure

Before we can talk about Three.js I need to explain the basic structure of the project.

The root level of the project contains the index and two folders.

As in most web apps the index is the entry point of the web page. The two folders are css and js. As you can infer from the names, the first folder contains the css of the app, the second folder contains the Javascript code.

The index is quite simple, it does one thing: it creates a <canvas> element.

(In my example project the index is also importing the Javascript <scripts>, but that’s not relevant. eg. the <script> tags wouldn’t be there if I was using js imports.)

You can see the index here.

Javascript folder

Let’s talk about the main and the SceneManager.

The main is the entry point to the Javascript side of the application, it has access to the DOM and contains the SceneManager.

The SceneManager is responsible for handling the Three.js side of the app, which is completely hidden from the main. It knows nothing about the DOM.
The SceneManager is our higher level component.

The main has three basic responsibilities:

  1. create the SceneManager, while passing a canvas to it (so that SceneManager won’t have to meddle with the DOM).
  2. attach listeners to the DOM events we care about (such as windowresize or mousemove).
  3. start the render loop, by calling requestAnimationFrame().
// main.jsconst canvas = document.getElementById('canvas');
const sceneManager = new SceneManager(canvas);
bindEventListeners();
render();

bindEventListeners() can be an empty function (if all DOM events are ignored).
render() can’t, because it has to start the render loop.

function render() {
requestAnimationFrame(render);
sceneManager.update();
}

Three.js

On the Three.js side of the app there are two simple high level concepts:

  1. SceneManager (High level component)
  2. SceneSubject (Lower level components)

As said before, the SceneManager is the component responsible for managing the scene. It works at high level, it shouldn’t know any detail about the content of the scene. His responsibilities are:

  1. create Scene, Renderer and Camera.
  2. initialize a bunch of SceneSubjects.
  3. update everything at every frame.

A SceneSubject represents one entity in the scene. The SceneManager usually contains multiple SceneSubjects (more on that later).

SceneManager

SceneManager has (at least) two public methods that are called by the main: update() and onWindowResize().

(In case you need to listen to other DOM events, SceneManager will have more public methods. For example onClick(x, y), that will be called by the main when an onclick event is registered.)

The function SceneManager.update():

  1. calls the update() function of every SceneSubject contained in the SceneManager.
  2. calls the render() method of the Three.js Renderer.

It is called by the main at every frame.

this.update = function() {
for(let i=0; i<sceneSubjects.length; i++)
sceneSubjects[i].update();
renderer.render(scene, camera);
}

The function SceneManager.onWindowResize() updates the aspect ratio of the camera and the size of the Renderer. It is called by the main each time the window is resized.

this.onWindowResize = function() {
const { width, height } = canvas;
screenDimensions.width = width;
screenDimensions.height = height;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
}

SceneManager has a bunch of private methods that are called just after the object is created.

buildScene();
buildRender();
buildCamera();
createSceneSubjects();

I won’t go in details explaining what each of these functions do, it’s quite obvious from the names.

The only non-trivial function is createSceneSubjects(): it creates an array of SceneSubjects. In order to add a new SceneSubject to your scene, you only need to add it to this array.

function createSceneSubjects(scene) {
const sceneSubjects = [
new GeneralLights(scene),
new SceneSubject(scene)
];
return sceneSubjects;
}

SceneSubject

SceneManager is responsible exclusively for setting up and updating the scene, it doesn’t know anything about its content.

Every logical component inside the scene should have its own separate component. For example, if I want to add a terrain to my scene, the code for the terrain should be in a Terrain component. In this case the Terrain would be a SceneSubject.

A SceneSubject has a basic interface:

  1. a constructor that takes a Scene object.
  2. a public method called update().

Here is the basic structure of a SceneSubject:

function SceneSubject(scene) {
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
this.update = function() {
// do something
}
}

Here you can find a complete example.

A scene can have many SceneSubjects and they can be as complex as needed. For example a SceneSubject may be the composition of multiple SceneSubjects. eg. the Solar System SceneSubject may contain multiple Planet SceneSubjects.

Communication between components

SceneSubjects don’t know each other and don’t know their container.

In many cases it may be needed to have different components communicating with each other. There are multiple ways to achieve this without breaking the structure of this project.

One way to have communication between decoupled components may involve the use of an Event Bus. I have written a separate post explaining how to build and use a simple event bus, you can find it here.

Conclusion

I think this is a nice way to separate the concerns of the components of an app and it helped me to simplify the way I build applications with Three.js.

Any feedback in the comments, or wherever you prefer, is more than welcomed.

Useful links

Read this post if you want to use this structure with React.js.


Where can you find me?

Follow me on Twitter: https://twitter.com/psoffritti
My website/portfolio: pierfrancescosoffritti.com
My GitHub account: https://github.com/PierfrancescoSoffritti
My LinkedIn account: linkedin.com/in/pierfrancescosoffritti/en

Pierfrancesco Soffritti

Written by

- Software engineer @Google - WebSite: https://pierfrancescosoffritti.com - GitHub: https://github.com/PierfrancescoSoffritti

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade