For Three.js projects on this blog, I’ll be using the template described in this article, and I’ll assume that you are, too. The template is based on Lewy Blue’s excellent online book Discover Three.js.

I don’t intend to repeat that book here, but rather only to provide a high-level description of its approach to building a Three.js app, along with my own additions. You should refer to that book if you find the detail here is lacking, or if you need a more basic introduction to Three.js than I provide here. I have endeavored to make the code as self-documenting as possible, but some familiarity with Three.js will be assumed.

The motivation of this starter template is to modularize the Three.js app, imposing a degree of the single-responsibility and encapsulation principles that characterize object-oriented programming. The goal is to have a more navigable, manageable, and extensible project than is typically found in single-file Three.js examples. Of necessity, even a basic 3D scene requires a number of components, and attempting to realize one in a single file quickly becomes unwieldy.

Quick Start#

In case you don’t want or need the description of the template that follows, I’ve made the template available as a GitHub repository that you can clone or fork. You can also open the template on CodeSandbox and begin using it immediately.

Project Structure#

The folder and file structure of the project is as follows.

Markup and Stylesheet#

index.html simply establishes a container for the 3D world, div#scene-container, and the necessary imports for the scripts and styles. Three.js itself is loaded from a CDN.

Three.js and our own script are loaded as JavaScript modules. Current versions of all the major browsers support JavaScript modules, making this appropriate for a development environment, but transpiling and bundling the scripts with WebPack, Parcel, or a similar tool may be desirable in production.

 1<!DOCTYPE html>
 2<html>
 3  <head>
 4    <title>Three.js Starter Template by Philip Nichols</title>
 5    <meta charset="UTF-8" />
 6    <link href="./styles/main.css" rel="stylesheet" type="text/css">
 7    <script
 8      async
 9      src="https://unpkg.com/es-module-shims@1.3.6/dist/es-module-shims.js"
10    ></script>
11    <script type="importmap">
12      {
13        "imports": {
14          "three": "https://unpkg.com/three@0.143.0/build/three.module.js"
15        }
16      }
17    </script>
18    <script type="module" src="./src/main.js"></script>
19  </head>
20
21  <body>
22    <div id="scene-container"></div>
23  </body>
24</html>

main.css styles the document body and scene container to occupy the full width and height of the browser window.

 1body {
 2  /* remove margins and scroll bars */
 3  margin: 0;
 4  overflow: hidden;
 5
 6  /* style text */
 7  text-align: center;
 8  font-size: 12px;
 9  font-family: Sans-Serif;
10
11  /* color text */
12  color: #444;
13}
14
15h1 {
16  /* position the heading */
17  position: absolute;
18  width: 100%;
19
20  /* make sure that the heading is drawn on top */
21  z-index: 1;
22}
23
24#scene-container {
25  /* tell our scene container to take up the full page */
26  position: absolute;
27  width: 100%;
28  height: 100%;
29
30  /*
31    TODO: Set the container's background color to the same as the scene's
32    background to prevent flashing on load
33  */
34  background-color: skyblue;
35}

For more information, see Discover Three.js at Appendix A.1, “HTML and CSS Used in This Book.”

The JavaScript Entry Point#

main.js is the entry point for our app. Its principal job is to bootstrap an object of class World, which is where the central logic running our 3D world will reside. While main.js and its main() function have a limited role, their isolation from the World object ensures a separation of concerns; main.js knows about the DOM but not the internals of the 3D world, while the opposite is (mostly) true of World.

 1import { World } from "./world/world.js";
 2
 3/** Bootstraps a Three.js app */
 4async function main() {
 5
 6  // Get a reference to the canvas element
 7  const container = document.querySelector( "#scene-container" );
 8
 9  // Create an instance of the World app
10  const world = new World( container );
11
12  // Perform any asynchronous initialization
13  await world.init();
14
15  // Start the animation loop
16  world.start();
17
18}
19
20// Start the app
21main().catch( ( err ) => {
22
23  console.error( err );
24
25} );

The main() function is marked async, even though no asynchronous functionality is currently leveraged. Nonetheless, supporting asynchrony by default will be useful when we are loading models and textures, because Three.js’s file loaders generally run asynchronously.

For more information, see Discover Three.js at Chapter 1.3, “Introducing the World App.”

World#

An object of the World class defined in /src/world/world.js is principally a composition container for the components and systems that represent individual elements and behaviors of the 3D world.

 1import { createCamera } from "./components/camera.js";
 2import { createLights } from "./components/lights.js";
 3import { createScene } from "./components/scene.js";
 4import { createCube, createCubeGui } from "./components/cube.js";
 5
 6import { createRenderer } from "./systems/renderer.js";
 7import { createStats } from "./systems/stats.js";
 8import { createGui } from "./systems/gui.js";
 9import { Resizer } from "./systems/resizer.js";
10import { Loop, createLoopGui } from "./systems/loop.js";
11
12/** @type {import("three").PerspectiveCamera} */
13let camera;
14/** @type {import("three").Scene} */
15let scene;
16/** @type {import("three").WebGLRenderer} */
17let renderer;
18/** @type {import("./systems/loop.js").Loop} */
19let loop;
20
21/** Composes the objects that participate in a Three.js app */
22class World {
23
24  /**
25   * Initializes a new instance of the World class
26   * @param {Element} container The DOM element into which
27   * the rendering canvas should be injected
28   */
29  constructor( container ) {
30
31    camera = createCamera();
32    scene = createScene();
33    renderer = createRenderer();
34    loop = new Loop( camera, scene, renderer );
35
36    const resizer = new Resizer( container, camera, renderer );
37    const stats = createStats();
38    const gui = createGui();
39
40    container.append( renderer.domElement );
41    container.appendChild( gui.domElement );
42    container.appendChild( stats.dom );
43
44    const [ mainLight, ambientLight ] = createLights();
45    const cube = createCube();
46
47    //
48    // Create GUI for components that require it
49    //
50    createLoopGui( loop, gui );
51    createCubeGui( gui );
52
53    //
54    // Add objects to the scene
55    //
56    scene.add( mainLight, ambientLight, cube );
57
58    //
59    // Add updatable objects to the loop
60    //
61    loop.updatables.push( stats, cube );
62
63  }
64
65  /** Performs any ansynchronous initiliazation needed by
66   * a Three.js world
67   */
68  async init() {
69
70  }
71
72  /** Renders a single frame */
73  render() {
74
75    // Draw a single frame
76    renderer.render( scene, camera );
77
78  }
79
80  /** Starts an animation loop */
81  start() {
82
83    loop.start();
84
85  }
86
87  /** Stops an animation loop */
88  stop() {
89
90    loop.stop();
91
92  }
93
94}
95
96export { World };

During construction, World calls imported functions to create the basic components of a 3D world: a scene, camera, lights, and a 3D spinning cube.

World also calls imported functions to initialize renderer, statistics, GUI, animation loop, and responsive resizer systems.

References to these components and systems are stored as module-level variables, encapsulating them away from consumers of the World object and preventing the World object from becoming a catch-all global variable. We’ll keep our components as loosely-coupled and coarsely-grained as is practical, and in this sense, the World object can be thought of as a somewhat rough-and-ready dependency-injection container.

Each of these components and systems either is an imported class or is constructed by an imported (and therefore encapsulated) factory function of the form createObject(…), and each will be discussed separately in turn. For more information, see Discover Three.js at Chapter 1.3, “Introducing the World App.”

Systems#

The elements described as “systems” and contained in that branch of the folder structure are ones that provide services to our app, such as the animation loop and GUI, that relate to aspects of the 3D world other than describing the pixels that need to be put on the screen.

Renderer#

The createRenderer() function defined in /src/world/systems/renderer.js initializes a Three.js WebGLRenderer. That renderer is set up to use antialiasing and physically-correct lighting.

 1import { WebGLRenderer } from "three";
 2
 3/** Initiliazes a Three.js WebGLRenderer
 4 * @return {import("three").WebGLRenderer}
 5 */
 6function createRenderer() {
 7
 8  const renderer = new WebGLRenderer( {
 9    antialias: true
10  } );
11
12  renderer.physicallyCorrectLights = true;
13
14  return renderer;
15
16}
17
18export { createRenderer };

For more information, see Discover Three.js at the section “Systems: The Renderer Module” in Chapter 1.3, “Introducing the World App.”

Resizer#

The Resizer class defined in /src/world/systems/resizer.js listens for changes to the window size and resizes the Three.js renderer and its canvas context as needed.

 1const setSize = ( container, camera, renderer ) => {
 2
 3  camera.aspect
 4    = container.clientWidth / container.clientHeight;
 5  camera.updateProjectionMatrix();
 6
 7  renderer.setSize(
 8    container.clientWidth,
 9    container.clientHeight
10  );
11  renderer.setPixelRatio( window.devicePixelRatio );
12
13};
14
15class Resizer {
16
17  constructor( container, camera, renderer ) {
18
19    // Set initial size on load
20    setSize( container, camera, renderer );
21
22    window.addEventListener( "resize", () => {
23
24      // Set the size again if a resize occurs
25      setSize( container, camera, renderer );
26      // Perform any custom actions
27      this.onResize();
28
29    } );
30
31  }
32
33  onResize() {}
34
35}
36
37export { Resizer };

For more information, see Discover Three.js at the section “Systems: The Resizer Module” in Chapter 1.3, “Introducing the World App.”

Animation Loop#

The Loop class defined in /src/world/systems/loop.js manages the animation loop, with the methods start(), stop(), and tick() being responsible for starting, stopping, and iterating the loop, respectively.

A Loop object maintains an array called updatables, which will be populated with objects that need to be updated each frame (for example, having their positions updated to simulate movement). These objects must be either defined or monkey-patched with a tick( delta ) method. That takes as a parameter the time between the previous frame and the current one and performs any computations and mutations required to update the object. This way, each object is kept responsible for its own per-frame updates, and the single-responsibility principle is respected.

Innovating on Discover Three.js’s approach, the Loop class that I present here descends from Three.js’s EventDispatcher class. That provides an addEventHandler(…) facility that allows Loop objects to raise start and stop events that support management through the GUI. To that end, loop.js defines and exports a createLoopGui(…) function that creates GUI controls for Play and Pause buttons, wires them to the Loop object’s start() and stop() buttons, and listens for the object’s start and stop events.

  1import { Clock, EventDispatcher } from "three";
  2import { gui } from "./gui.js";
  3
  4const clock = new Clock();
  5let startButton, stopButton;
  6
  7/**
  8 * Creates GUI for the animation loop
  9 * @param {Loop} loop
 10 * @param {import("https://unpkg.com/three@0.143.0/examples/jsm/libs/lil-gui.module.min.js").GUI} gui
 11 */
 12function createLoopGui( loop, gui ) {
 13
 14  const loopFolder = gui.addFolder( "Animation loop" );
 15  startButton = loopFolder.add( loop, "start" )
 16    .name( "&#x23f5;&#xfe0e;&ensp;Play" );
 17  if ( loop.isRunning ) {
 18
 19    startButton.disable();
 20
 21  }
 22
 23  stopButton = loopFolder.add( loop, "stop" )
 24    .name( "&#x23f8;&#xfe0e;&ensp;Pause" );
 25  if ( ! loop.isRunning ) {
 26
 27    stopButton.disable();
 28
 29  }
 30
 31  loop.addEventListener( "start", function () {
 32
 33    startButton.disable();
 34    stopButton.enable();
 35
 36  } );
 37
 38  loop.addEventListener( "stop", function () {
 39
 40    startButton.enable();
 41    stopButton.disable();
 42
 43  } );
 44
 45}
 46
 47/** Encapsulates the animation loop for a Three.js world */
 48class Loop extends EventDispatcher {
 49
 50  constructor( camera, scene, renderer ) {
 51
 52    super();
 53
 54    /** @type {import("three").PerspectiveCamera} */
 55    this.camera = camera;
 56    /** @type {import("three").Scene} */
 57    this.scene = scene;
 58    /** @type {import("three").WebGLRenderer} */
 59    this.renderer = renderer;
 60    this.updatables = [];
 61    /** @type {boolean} */
 62    this._isRunning = false;
 63
 64  }
 65
 66  start() {
 67
 68    if ( ! this.isRunning ) {
 69
 70      this.renderer.setAnimationLoop( () => {
 71
 72        this.tick();
 73
 74        // Render a frame
 75        this.renderer.render( this.scene, this.camera );
 76
 77      } );
 78      this._isRunning = true;
 79
 80      this.dispatchEvent( { type: "start", message: "" } );
 81
 82    }
 83
 84  }
 85
 86  stop() {
 87
 88    if ( this.isRunning ) {
 89
 90      this.renderer.setAnimationLoop( null );
 91
 92      this._isRunning = false;
 93      this.dispatchEvent( { type: "stop", message: "" } );
 94
 95    }
 96
 97  }
 98
 99  tick() {
100
101    const delta = clock.getDelta();
102
103    for ( const object of this.updatables ) {
104
105      object.tick( delta );
106
107    }
108
109  }
110
111  get isRunning() {
112
113    return this._isRunning;
114
115  }
116
117}
118
119export { Loop, createLoopGui };

For more information, see Discover Three.js at Chapter 1.3, “The Animation Loop.”

Performance Counter#

The createStats() method defined in /src/world/systems/stats.js creates a Stats object for viewing our app’s frame-per-second throughput. This object is monkey-patched with a tick(…) method to allow the Stats object to be updated each frame by participating in the Loop object’s updatables array.

 1import Stats from "https://unpkg.com/three@0.143.0/examples/jsm/libs/stats.module.js";
 2
 3/**
 4 * Creates a stats module for a Three.js world
 5 * @return {Stats} A stats object
 6 */
 7function createStats() {
 8
 9  const stats = new Stats();
10  // Monkey-patch a tick method onto stats to support its
11  // use in loop.updatables
12  stats.tick = stats.update;
13
14  return stats;
15
16}
17
18export { createStats };

Graphical User Interface#

Frequently, we will want to be able to try out different parameter values in our Three.js apps without having to modify the code and reload the app. To that end, the createGui() function defined in /src/world/systems/gui.js creates a basic GUI. Initially, that GUI contains only a button to reset default values; it falls upon other components to provide their own specific GUI controls as needed, as we saw with the Loop class.

 1import { GUI } from "https://unpkg.com/three@0.143.0/examples/jsm/libs/lil-gui.module.min.js";
 2
 3/** @type {import("https://unpkg.com/three@0.143.0/examples/jsm/libs/lil-gui.module.min.js").GUI} */
 4function createGui() {
 5
 6  const gui = new GUI();
 7
 8  const resetButton = gui.add( gui, "reset" ).name( "&#x1f5d8;&ensp;Reset values" );
 9
10  return gui;
11
12}
13
14export { createGui };

Components#

The elements described as “components” and contained in that branch of the folder structure are the ones that directly affect the objects in the 3D world: the lights, camera, any rendered objects, and the scene that composites them.

Scene#

The createScene() function defined in src/world/components/scene.js constructs a Three.js Scene object.

 1import { Color, Scene } from "three";
 2
 3/** Initializes a Three.js Scene 
 4 * @return {import("three").Scene}
 5*/
 6function createScene() {
 7
 8  const scene = new Scene();
 9
10  scene.background = new Color( 0x191970 );
11
12  return scene;
13
14}
15
16export { createScene };

Lights#

The createLights() function defined in /src/world/components/lights.js creates a directional key light and an ambient hemisphere light.

 1import {
 2  DirectionalLight,
 3  HemisphereLight } from "three";
 4
 5/** Initializes a directional key light and an ambient
 6 * hemisphere light for a Three.js world
 7 * @return {Array<import("three").Light>}
 8*/
 9function createLights() {
10
11  // Create a directional light
12  const mainLight = new DirectionalLight( "white", 8 );
13
14  // Move the light right, up, and towards the camera
15  mainLight.position.set( 10, 10, 10 );
16
17  // Create ambient lighting
18  const ambientLight = new HemisphereLight(
19    "white", // bright sky color
20    "darkslategray", // dim ground color
21    5 // intensity
22  );
23
24  return [ mainLight, ambientLight ];
25
26}
27
28export { createLights };

Camera#

The createCamera function defined in /src/world/components/camera.js creates a Three.js PerspectiveCamera and gives it a position along the z axis at some distance from the world origin.

 1import { PerspectiveCamera } from 'three';
 2
 3function createCamera() {
 4
 5  const camera = new PerspectiveCamera(
 6    70, // fov = Field Of View
 7    1, // aspect ratio (dummy value)
 8    0.1, // near clipping plane
 9    250, // far clipping plane
10  );
11
12  // move the camera back so we can view the scene
13  camera.position.set( 0, 0, 10 );
14
15  return camera;
16
17}
18
19export { createCamera };

Cube#

The createCube function defined in /src/world/components/cube.js creates a green cube (the 3D equivalent of a “Hello, world” program). The cube itself is a Three.js Mesh created from a RoundedBoxGeometry and a MeshStandardMaterial. That mesh is monkey-patched with a tick(…) method so that the mesh can be animated by participating in the Loop object’s updatables array.

cube.js also defines a createCubeGui(…) function that provides a GUI control to control the speed at which the cube rotates, specified in degrees rotation per second.

 1import { Mesh, MeshStandardMaterial } from "three";
 2import { RoundedBoxGeometry } from "https://unpkg.com/three@0.143.0/examples/jsm/geometries/RoundedBoxGeometry.js";
 3import { params } from "../params.js";
 4
 5function createCubeGui( gui ) {
 6
 7  const cubeFolder = gui.addFolder( "Spinning cube" );
 8  cubeFolder.add( params.cube, "degreesPerSecond", 0, 180, 1 )
 9    .name( "Degrees per second" );
10
11}
12
13/** Creates a rounded cube and animates its rotation */
14function createCube() {
15
16  const geometry = new RoundedBoxGeometry( 2, 2, 2, 3, 0.1 );
17  const material = new MeshStandardMaterial(
18    {
19      color: "green",
20      metalness: 1.0,
21      roughness: 0.5,
22    } );
23  const cube = new Mesh( geometry, material );
24
25  cube.tick = ( delta ) => {
26
27    cube.rotation.z += delta * Math.PI * params.cube.degreesPerSecond / 180.;
28    cube.rotation.x += delta * Math.PI * params.cube.degreesPerSecond / 180.;
29    cube.rotation.y += delta * Math.PI * params.cube.degreesPerSecond / 180.;
30
31  };
32
33  return cube;
34
35}
36
37export { createCube, createCubeGui };

Conclusion#

With this framework in place, we have a ready-made template from which we can bootstrap diverse future Three.js projects.