Discover three.js has been updated to ES6!

You are viewing the new version of the book.

Click here to view the old version.

Word Count: 4375, reading time: ~21minutes

Introducing the World App

In this book our aim is to create simple yet fully functional three.js applications similar to those you might create in a professional setting. Once you’ve completed these chapters, you’ll be able to use what you’ve learned here to create beautiful customer-facing web apps of any size, whether that’s 3D product displays, stunning landing pages, video games or game engines, music videos, 3D or CAD software, journalistic visualizations, or just about anything else you can dream of. Not only that, you’ll be able to use the code from these chapters immediately as a template for building your own apps.

In the last chapter, we created our first three.js application, and we introduced lots of new three.js and computer graphics info along the way. However, we didn’t pay any attention to the quality or structure of the code we wrote. Here, we’ll refactor this simple, monolithic app to create a template we can use as a starting point for the rest of the examples in this book. To ensure our code remains accessible and easy to understand, we’ll split the app into small modules, each of which handles a small part of the overall complexity.

The HTML and CSS files will remain unchanged, it’s only the JavaScript we need to refactor here.

Modular Software Design

When writing modular JavaScript, each file is a module. So, we may refer to a module by its file name, for example, main.js, or simple as the main module. An important part of modular software design is choosing the structure and names of your modules. Open up the inline code editor and you’ll see that all the files required for this chapter have already been created, although they’re all empty to begin with. If you like, hit the comparison toggle to view the completed code, otherwise, try completing the modules yourself while you read.

There’s an entire chapter of the appendices dedicated to JavaScript modules. If this subject is new to you, now would be a good time to check it out.

The Web Page and the World App

Over the last two chapters, we created a basic webpage consisting of index.html and main.css, and then we wrote our three.js app in main.js. However, if you recall, back in 0.7: Using three.js with React, Vue.js, Angular, Svelte, TypeScript..., we said our goal is to create a component that can be dropped into any web app just as easily as it can be used with a simple web page like this one. For this to work, we need to add another small layer of abstraction. We’ll start by deleting everything from main.js. Now, we have a simple web app consisting of three files: index.html, main.css, and main.js (currently empty). We’ll make a rule: this web app cannot know about the existence of three.js. Once we build our three.js app, all the web app should know is that we have a component capable of generating 3D scenes, but not how that component does it. Out in the real world, this web app might be much more complicated and built using a framework such as React or Svelte. However, using our three.js component will not be any more complicated than it is here.

To accomplish this, we’ll move everything related to three.js into a separate app (or component), which we’ll place in the src/World folder. Within this folder, we are free to use three.js however we like, but outside this folder we are forbidden from using three.js. Also, the files in this folder should form a self-contained component that knows nothing about the web app on which it is being displayed. This means we can take the World/ folder, and drop it into any web app, whether it’s a simple HTML page like this one or an app made with a framework like React, Angular, or Vue. Think about it this way: you should be able to give your three.js component to another developer who knows nothing about three.js and explain how they can integrate it into their web app, in five minutes or less, without explaining how three.js works.

From here on, we’ll refer to this folder and its contents as the World app.

The World App

Currently, our three.js scene is relatively simple. To set it up, once again we need to follow the six-step program outlined in the last chapter:

  1. Initial Setup
  2. Create the scene
  3. Create a camera
  4. Create the cube and add it to the scene
  5. Create the renderer
  6. Render the scene

However, using the World app should look like this:

  1. Create an instance of the World app
  2. Render the scene

The first set of six tasks are the implementation details. The second set of two tasks are the interface we’ll provide to the containing web app.

The World Interface

The interface is very simple for now. Using it within main.js will look something like this:

main.js: creating a world
    

// 1. Create an instance of the World app
const world = new World(container);

// 2. Render the scene
world.render();


  

Everything else about the implementation of the World app should be hidden. From within main.js, we should not be able to access the scene, camera, renderer, or cube. If we later need to add additional functionality, we’ll do so by expanding the interface, not by exposing three.js functions to the outside world.

Note that we’re passing in a container to the World constructor, which will be our scene container once again. Within World, we’ll append the canvas to this container, just as we did in the last chapter.

Before reading through the next section, check out the appendices for a refresher on JavaScript classes, if you need one.

The World Class

Now, we can go ahead and start to build the World class. We’ll need a constructor method to handle setup (create the scene, renderer, cube, and camera, set the scene’s size, and add the canvas element to the container), and a render method to render the scene. Open or create the src/World/World.js module, and inside, create the World class with both of these methods. At the bottom of the file, export the class so we can use it from main.js.

World.js: initial setup
    
class World {
  // 1. Create an instance of the World app
  constructor(container) {}

  // 2. Render the scene
  render() {}
}

export { World };

  

With this, our interface is complete. Everything else is implementation. Although this interface doesn’t yet do anything, it’s already usable. In other words, we can go ahead and fully set up main.js, calling these functions in the appropriate places. Later, once we fill in the details, the app will magically start to work. This is a common approach to creating interfaces. First, decide how it should look and create stubs for each part of the interface, then worry about the details.

Set Up main.js

Inside main.js, which should currently be empty, we’ll start by importing the new World class, then we’ll create a main function and immediately call it to start the app:

main.js: initial setup
    
import { World } from './World/World.js';

// create the main function
function main() {
  // code to set up the World App will go here
}

// call main to start the app
main();

  

Set up the World App

Next, we’ll perform our two-step World app setup. First, just like in the last chapter, we need a reference to the container. Then we’ll create a new World, and finally, with everything set up, we can call world.render to draw the scene.

main.js: create a whole new World
    function main() {
  // Get a reference to the container element
  const container = document.querySelector('#scene-container');

  // 1. Create an instance of the World app
  const world = new World(container);

  // 2. Render the scene
  world.render();
}

  

With this, the main.js module is complete. Later, when we fill in the details of the World app, our scene will spring to life.

World App Implementation

Of course, building the interface was the easy part. Now we have to make it work. Fortunately, from here on it’s mostly a matter of copying code over from the previous chapter. Take a look at the setup tasks again.

  1. Initial Setup
  2. Create the scene
  3. Create a camera
  4. Create the cube and add it to the scene
  5. Create the renderer
  6. Render the scene

Number one is done and dusted. That leaves the final five. However, we’ll create an additional task that will go in between steps five and six:

  • Set the size of the scene.

We’ll create a new module for each of the remaining tasks. For now, these modules will be very simple, but as the app grows in size they can become more complex. Splitting them up like this means the complexity will never become overwhelming, and the World class will remain manageable rather than spiraling into a thousand line class of doom.

We’ll divide these modules into two categories: components, and systems. Components are anything that can be placed into the scene, like the cube, the camera, and the scene itself, while systems are things that operate on components or other systems. Here, that’s the renderer and the sizing function, which we’ll call a Resizer. Later you might want to add additional categories like utilities, stores, and so on.

This gives us the following new modules:

  • components/camera.js
  • components/cube.js
  • components/scene.js
  • systems/renderer.js
  • systems/Resizer.js

If you’re working locally, create these files now, otherwise, locate them in the editor. The Resizer gets a capital R because it will be a class. The other four modules will each contain a function following this basic pattern:

The basic pattern for most of our new modules
    
import { Item } from 'three';

function createItem() {
  const instance = new Item();

  return instance;
}

export { createItem }

  

…where createItem is replaced by createCamera, createCube, createRenderer, or createScene. If the code in any of these modules is unclear to you, refer back to the previous chapter where we explain it in detail.

Systems: the Renderer Module

First up is the renderer system:

systems/renderer.js
    import { WebGLRenderer } from 'three';

function createRenderer() {
  const renderer = new WebGLRenderer();

  return renderer;
}

export { createRenderer };


  

Later, we’ll tune some settings on the renderer to improve the quality of our renderings, but for now, a basic renderer with default settings is just fine.

Components: The Scene Module

Next up, the scene component:

components/scene.js
    import { Color, Scene } from 'three';

function createScene() {
  const scene = new Scene();

  scene.background = new Color('skyblue');

  return scene;
}

export { createScene };


  

Here, we’ve created an instance of the Scene class, and then used a Color to set the background to skyblue, exactly as we did in the last chapter.

Components: The Camera Module

Third is the camera component:

components/camera.js
    import { PerspectiveCamera } from 'three';

function createCamera() {
  const camera = new PerspectiveCamera(
    35, // fov = Field Of View
    1, // aspect ratio (dummy value)
    0.1, // near clipping plane
    100, // far clipping plane
  );

  // move the camera back so we can view the scene
  camera.position.set(0, 0, 10);

  return camera;
}

export { createCamera };


  

This is almost the same code we used to set up the camera in the last chapter, except this time we’re using a dummy value of 1 for the aspect ratio since that relies on the dimensions of the container. We want to avoid passing things around unnecessarily, so we’ll defer setting the aspect until we create the Resizer system below.

One other difference: in the last chapter, we declared each of the camera’s four parameters as variables, then passed them into the constructor. Here, we’ve switched to declaring them inline to save some space. Compare this code to the previous chapter to see the difference.

Ch 1.2: Your First three.js Scene: creating the camera
    // Create a camera
const fov = 35; // AKA Field of View
const aspect = container.clientWidth / container.clientHeight;
const near = 0.1; // the near clipping plane
const far = 100; // the far clipping plane

const camera = new PerspectiveCamera(fov, aspect, near, far);

  

Components: The Cube Module

Fourth is the cube component, which comprises creating a geometry, a material, and then a mesh. Once again, the highlighted lines here are identical to the code from the last chapter.

components/cube.js
    import { BoxBufferGeometry, Mesh, MeshBasicMaterial } from 'three';

function createCube() {
  // create a geometry
  const geometry = new BoxBufferGeometry(2, 2, 2);

  // create a default (white) Basic material
  const material = new MeshBasicMaterial();

  // create a Mesh containing the geometry and material
  const cube = new Mesh(geometry, material);

  return cube;
}

export { createCube };


  

Later, we might add visible objects that are much more complicated than this simple cube, in which case we’ll split them up into sub-modules. For example, the playable character in a game is likely to be a complex component with many separate pieces, so we’ll put that into components/mainCharacter/, and inside there we’ll have sub-modules such as mainCharacter/geometry.js, mainCharacter/materials.js, mainCharacter/animations.js, and so on.

Systems: the Resizer Module

Finally, we’ll create a stub for the Resizer module. This one is a little different than the others since it’s a class rather than a function (note that the file name starts with a capital R to denote that it’s a class):

systems/Resizer.js: initial setup
    

class Resizer {
  constructor() {}
}

export { Resizer };


  

We’ll complete this class below.

Set Up the World Class

With that, most of our components and systems are ready and we can fill in the details of the World class. First, import the five modules we just created at the top of World.js:

World.js: imports
    import { createCamera } from './components/camera.js';
import { createCube } from './components/cube.js';
import { createScene } from './components/scene.js';

import { createRenderer } from './systems/renderer.js';
import { Resizer } from './systems/Resizer.js';

  

Set Up the Camera, Renderer, and Scene

Next, we’ll set up the camera, scene, and renderer, which all need to be created in the constructor, then accessed in the World.render method. Usually, this means we would save them as class member variables: this.camera, this.scene, and this.renderer:

Class member variables are accessible from outside the class
    

class World {
  constructor() {
    this.camera = createCamera();
    this.scene = createScene();
    this.renderer = createRenderer();
  }


  

However, member variables are accessible within main.js, which we don’t want.

main.js: not what we want
    

const world = new World();

// We can access member variables from the instance
console.log(world.camera);
console.log(world.renderer);
console.log(world.scene);


  

Guard your Secrets Well

We want to interact with the World app using only the interface we’ve designed, and we want everything else to be hidden. Why? Imagine you have worked long and hard to create a beautiful, well structured three.js application, and you pass it on to your client for them to integrate into a larger application. They don’t know anything about three.js, but they are competent developers, so when they need to change something they start to hack around and figure out that they can access the camera and renderer. They open up the three.js docs and after five minutes of reading, change some settings. These are likely to break some other parts of the app, so they make more changes, and more changes, and eventually… chaos. Which you will be called in to fix.

By hiding the implementation behind a simple interface, you make your app foolproof and simple to use. It does what’s it’s supposed to do, and nothing else. By hiding the implementation, we are enforcing good coding style on the people using our code. The more of the implementation you make accessible, the more likely it will be used for complicated half-baked “fixes” that you have to deal with later.

Replace the word client with you in six months and everything still holds. If you later need to make some quick to the app, you won’t be tempted to do them in a hacky way if you can’t access anything except for the simple interface. Instead, you’ll have to open up the World app and fix things properly (in theory at least).

Of course, there will be times when you do legitimately want to expose the camera and other components to the outside world. However, hiding them should be the default. Guard your secrets well, and only expose them when you have a good reason for doing so.

But How?

Most languages have private class fields for this purpose, and they are coming soon to JavaScript too. Unfortunately, at the time of writing this chapter, support is not good, so for now we must look for an alternative.

Module Scoped Variables

We can create something similar to private variables by declaring the variables in module scope:

World.js: create the camera, renderer, and scene as module scoped variables
    import { createCamera } from './components/camera.js';
import { createCube } from './components/cube.js';
import { createScene } from './components/scene.js';

import { createRenderer } from './systems/renderer.js';
import { Resizer } from './systems/Resizer.js';

// These variables are module-scoped: we cannot access them
// from outside the module
let camera;
let renderer;
let scene;

class World {
  constructor(container) {
    camera = createCamera();
    scene = createScene();
    renderer = createRenderer();
  }
  

This way, we can access camera, renderer, and scene from anywhere in the World module, but not from main.js. Just what we want.

Important note: this solution will not work if we create two instances of the World class, since the module scoped variables will be shared between both instances, so the second instance will overwrite the variables of the first. However, we only ever intend to create one world at a time, so we’ll accept this limitation.

Add the Canvas to the Container

With that, most our of setup is complete. We now have a camera, scene, and renderer. If you recall from the last chapter, when we create the renderer a <canvas> element is also created and stored in renderer.domElement. The next step is to add this to the container.

World.js: append the canvas to the container
    

class World {
  constructor(container) {
    camera = createCamera();
    scene = createScene();
    renderer = createRenderer();
    container.append(renderer.domElement);
  }


  

Render the Scene

Next, we’ll set up World.render so that we can see the results. Once again the code is the same as the last chapter.

World.js: complete the render method
      render() {
    // draw a single frame
    renderer.render(scene, camera);
  }

  
The canvas is the red rectangle
The canvas is the red rectangle

Once you do this, if everything is set up correctly, your scene will be drawn into the canvas. However, the cavnas doesn’t take up the full size of the container since we haven’t completed the Resizer yet. Instead, it has been created at the default size for a <canvas> element, which is $300 \times 150$ pixels (in Chrome, at least).

This won’t be obvious since we’ve set the container background to the same color as the scene’s background - they are both “skyblue”. However, try temporarily making the canvas “red” and this will become obvious.

scene.js: temporarily make the canvas red to show that it doesn’t take up the full container yet
    

scene.background = new Color('red');


  

We’ll fix this in a few moments, but first, let’s add the cube to the scene.

Create the Cube

The cube doesn’t need to be a module scope variable since it’s only used in the constructor, so call createCube, save the result in a normal variable called cube, then add it to the scene.

World.js: Create the cube and add it to the scene
      constructor(container) {
    camera = createCamera();
    scene = createScene();
    renderer = createRenderer();
    container.append(renderer.domElement);

    const cube = createCube();

    scene.add(cube);
  }
  

Now, the white square will appear against the blue background. Still sized at $300 \times 150$ pixels though.

Systems: the Resizer Module

All that remains is to set up the Resizer class. Gathering up all the code we used to set the scene’s size from the last chapter, here’s what we get:

Everything we need to do in the Resizer class
    

// Set the camera's aspect ratio to match the container's proportions
camera.aspect = container.clientWidth / container.clientHeight;

// next, set the renderer to the same size as our container element
renderer.setSize(container.clientWidth, container.clientHeight);

// finally, set the pixel ratio to ensure our scene will look good on mobile devices
renderer.setPixelRatio(window.devicePixelRatio);


  

Here, we’ll move these lines into the Resizer class. Why a class (and why Re-sizer)? Later, this class will have more work to do, for example, in 1.6: Making Our Scenes Responsive (and also Dealing with Jaggies), we’ll set up automatic resizing whenever the browser window changes size. Creating it as a class gives us more scope to add functionality later without refactoring.

Looking through the above lines, we can see that Resizer needs the container, the camera, and the renderer (devicePixelRatio is on the global scope, which means it’s available everywhere). Over in World, make sure Resizer is in the list of imports:

World.js: imports
    import { createCamera } from './components/camera.js';
import { createCube } from './components/cube.js';
import { createScene } from './components/scene.js';

import { createRenderer } from './systems/renderer.js';
import { Resizer } from './systems/Resizer.js';

  

… and then create a resizer instance in the constructor:

World.js: create the resizer
      constructor(container) {
    camera = createCamera();
    scene = createScene();
    renderer = createRenderer();
    container.append(renderer.domElement);

    const cube = createCube();

    scene.add(cube);

    const resizer = new Resizer(container, camera, renderer);
  }

  

Next, copy the lines of code we gathered up the last chapter into the constructor, and also update the method’s signature to include the container, camera, and renderer.

Resizer.js: nearly complete!
    

class Resizer {
  constructor(container, camera, renderer) {
    // Set the camera's aspect ratio
    camera.aspect = container.clientWidth / container.clientHeight;

    // update the size of the renderer AND the canvas
    renderer.setSize(container.clientWidth, container.clientHeight);

    // set the pixel ratio (for mobile devices)
    renderer.setPixelRatio(window.devicePixelRatio);
  }
}


  
Perspective camera frustum

This is nearly complete, although there’s still one thing we need to do. If you recall from the last chapter, the camera uses the aspect ratio along with the field of view and the near and far clipping planes to calculate its viewing frustum. The frustum is not automatically recalculated, so when we change any of these settings, stored in camera.aspect, camera.fov, camera.near, and camera.far, we also need to update the frustum.

The camera stores its frustum in a mathematical object called a projection matrix, and, to update this, we need to call the camera’s .updateProjectionMatrix method. Adding this line gives us the final Resizer class:

Resizer.js: complete!
    class Resizer {
  constructor(container, camera, renderer) {
    // Set the camera's aspect ratio
    camera.aspect = container.clientWidth / container.clientHeight;

    // update the camera's frustum
    camera.updateProjectionMatrix();

    // update the size of the renderer AND the canvas
    renderer.setSize(container.clientWidth, container.clientHeight);

    // set the pixel ratio (for mobile devices)
    renderer.setPixelRatio(window.devicePixelRatio);
  }
}

  
Fullsize at last!
Fullsize at last!

With that, our refactor is complete, and the scene will expand to take up the full size of the window.

The Final World Class

With everything in place, here’s our complete code for the World.js module. As you can see, this class coordinates the setup of our 3D scene while offloading the complexity onto separate modules.

World.js: complete code
    import { createCamera } from './components/camera.js';
import { createCube } from './components/cube.js';
import { createScene } from './components/scene.js';

import { createRenderer } from './systems/renderer.js';
import { Resizer } from './systems/Resizer.js';

// These variables are module-scoped: we cannot access them
// from outside the module
let camera;
let renderer;
let scene;

class World {
  constructor(container) {
    camera = createCamera();
    scene = createScene();
    renderer = createRenderer();
    container.append(renderer.domElement);

    const cube = createCube();

    scene.add(cube);

    const resizer = new Resizer(container, camera, renderer);
  }

  render() {
    // draw a single frame
    renderer.render(scene, camera);
  }
}

export { World };


  

Whew! That was some refactor! If you’re used to structuring your code using modules, this chapter was probably a breeze. On the other hand, if this was all new to you, it can take some time to get used to the idea of splitting up an application like this. Hopefully, by going through this step by step, you now have a clearer understanding of why we would choose to do this.

Our application is now ready for liftoff. Over the next few chapters, we’ll add lighting, movement, user controls, animation, and even some shapes that are a little more interesting that our humble square. Are you ready?

Challenges

Import Style
Selected Texture