Programming Patterns

MVC Pattern For Building Three.js Applications


In this post we’ll explore an MVC approach for building complex Three.js applications.

If you have ever worked on a medium-to-large javascript application using Three.js, you have probably experienced how quickly things can get out of control:

  • The codebase gets harder and harder to understand
  • Adding new features starts to take longer and longer
  • People become afraid of making changes for fear of breaking things.

You also probably know (or have heard) that some of the keys to preventing these things from happening is having loosely-coupled components, designing for testability and using high level abstractions. The approach that we’ll see below has those exact characteristics.

MVC

The benefit of using an approach like MVC for designing Three.js applications is that it encourages developers to create loosely-coupled, reusable and testable components to make their codebase easier to understand and modify. Instead of having a monolithic component that handles everything from user input and scene preparation, to rendering and business logic, best practise would be to have a hierarchy of abstraction layers, each one with very specific responsibilities.

Anything related to Three.js and UI will be encapsulated in the view layer. Within this view layer we’ll have different components, each one with very specific responsibilities: handling the render loop, receiving user input and rendering specific objects.

There are tons of resources explaining the basics of MVC, so we won’t do that here. If you’re looking for more information about MVC, my favorite explanation of MVC is in the book Head First Design Patterns. The Apple developer page also does a very good job at explaining how MVC works, the many design patterns that it involves and its different variations.

Galaxy Visualizer

To demonstrate the ideas in this post, I’ve created a running demo. It’s a simple application built using the MVC approach that can display galaxies, solar systems and planets and illustrates how these different components are connected. It’s going to accompany us for the rest of the post.

MVC Three.js demo


Screenshot of the Demo

View demo Download source

The entry point in the sample demo is app/main.js. We start by creating an empty Galaxy object and pass it to a new Galaxy Controller instance. Next we’ll call a mock API client to simulate a remote call or a database fetch, and get a json record, which we’ll use to populate our galaxy business model object.

...
const galaxy = new Galaxy('Milky Way');
const galaxyController = new GalaxyController(galaxy);

// add solar system to galaxy
const apiClient = new APIClient();
const galaxyRecord = apiClient.getRecord();

for (const solarSystemRecord of galaxyRecord.solarSystems) {
 const sunRecord = solarSystemRecord.sun;
 const sun = new Sun(sunRecord.name, sunRecord.props);
 const solarSystem = new SolarSystem(solarSystemRecord.name, sun, solarSystemRecord.props);

 galaxy.addSolarSystem(solarSystem);

 for (const planetRecord of solarSystemRecord.planets) {
  const planet = new Planet(planetRecord.name, planetRecord.props);

  if (planetRecord.satellites) {
   for (const satelliteRecord of planetRecord.satellites) {
    planet.addSatellite(new Planet(satelliteRecord.name, satelliteRecord.props));
   }
  }
  solarSystem.addPlanet(planet);
 }
}

You’ll see that we’ll be adding a fair amount of code, complexity and misdirection to what would have otherwise been a very straightforward application. If you’re working on a simple application or just need to create a quick prototype, then you won’t benefit so much from this approach.

However if you’re working on a complex application, perhaps with a few other developers, maybe in an agile environment, where you’re expected to deliver features very often, you’ll find this approach very useful. You and your team should prefer having code that is easy to maintain, even if it takes a bit longer to write. The additional complexity will pay off when you get an architecture that is easy to understand, modify, extend and test.

Model

The models will be our business logic entities. If you think of our system as a set of layers, then the model layer will be the highest-level most-abstract layer. Models have no references to the user interface, Three.js, dom elements, etc… This makes them very easy to test and extremely reusable. In this example, the models are Galaxy, Planet, Sun and Solar System, and they are all subclasses of a common class called AstronomicalBody.

The models will use the Observer pattern to notify interested objects of changes in their state. It will mainly be view objects that will be interested in receiving notifications when the state of a model has changed.

This is the code for the Planet class:

import AstronomicalBody from './AstronomicalBody';

export default class Planet extends AstronomicalBody {
 constructor(name, properties) {
  super(name, properties);
  this.satellites = [];
  this.className = 'Planet';
  this.isMoving = true;
 }

 addSatellite(satellite) {
  satellite.parent = this;
  this.satellites.push(satellite);
  this.emit('SatelliteAdded', { satellite });
 }

 removeSatellite(satellite) {
  const index = this.satellites.indexOf(satellite);

  if (index !== -1) {
   this.satellites.splice(index, 1);
   this.emit('SatelliteRemoved', { satellite });
  }
 }

 [Symbol.iterator]() {
  return this.satellites.values();
 }
}

View

Anything related to Three.js and HTML will be handled by the view layer. The responsibilities of the view layer are:

  • Render the models – in this case as a Three.js scene and Object3D objects.
  • Take the low-level user input and translate it into higher level commands that the controller can process.
  • Keep the scene objects always in sync with the latest states in the models.

That’s about it. The business logic will be split between the controller and model layers. The view layer won’t have any business logic – only UI-related functionality.

Since the View components will be communicating with the user interface, they’ll be a bit trickier to test, but it’s nothing that dependency injection and mocking cannot solve.

View Interacting with Controller


View Components Handling User Input

In this example, we have three types of view components:

Main View

This is the main view component. It’s where the render loop lives and where we add low-level UI controls (see below). This component receives the events from the UI controls and interprets them into higher-level commands that the controller can process.

The Main View and Controller components interact using the Strategy pattern. The view receives an instance of a controller in its constructor, which contains the strategy of how to react to the user input. This makes the view components very reusable as well as easy to unit test.

import DescriptionPanel from './controls/DescriptionPanel';
import ObjectPicker from './controls/ObjectPicker';
import GalaxyViewMediator from './mediator/GalaxyViewMediator';
import ViewMediatorFactory from './ViewMediatorFactory';
import RenderingContext from './RenderingContext';

export default class MainView {
 constructor(controller, galaxy) {
  this.controller = controller;
  this.galaxy = galaxy;
  this.renderingContext = this.createRenderingContext();
  this.galaxyViewMediator = new GalaxyViewMediator(galaxy, new ViewMediatorFactory());
  this.objectPicker = new ObjectPicker(this.galaxyViewMediator,   this.renderingContext);
  this.descriptionPanel = new DescriptionPanel();
 }

 createRenderingContext() {
  const domContainer = document.createElement('div');

  document.body.appendChild(domContainer);

  return RenderingContext.getDefault(domContainer);
 }

 initialize() {
  const scene = this.renderingContext.scene;
  const object3D = this.galaxyViewMediator.object3D;

  scene.add(object3D);

  this.objectPicker.initialize();
  this.objectPicker.addObserver('doubleclick', (e) =>   this.controller.onDoubleClick(e.astronomicalBody));
  this.objectPicker.addObserver('click', (e) =>   this.controller.onClick(e.astronomicalBody));
  this.objectPicker.addObserver('mousemove', (e) =>   this.controller.onMouseMove(e.astronomicalBody));

  window.addEventListener( 'resize', (e) => this.onWindowResize(), false );
  this.render();
 }

 render() {
  this.renderingContext.controls.update();
  requestAnimationFrame(() => this.render());

  this.galaxyViewMediator.onFrameRenderered();
  this.renderingContext.renderer.render(this.renderingContext.scene,   this.renderingContext.camera);
 }

 onWindowResize(){
  this.renderingContext.camera.aspect = window.innerWidth / window.innerHeight;
  this.renderingContext.camera.updateProjectionMatrix();

  this.renderingContext.renderer.setSize(window.innerWidth, window.innerHeight);
  this.objectPicker.notifyWindowResize();
 }
}

Controls

Controls are reusable UI components that can be added to the view. If you have worked with Three.js you know that selecting scene objects with the mouse is quite challenging. Even if you use the GPUPicker component, it’s still quite elaborate. In the galaxy demo, I’ve added a control called Object Picker, whose sole responsibility is to pick scene objects – it wraps all the complexity of picking scene objects using the GPUPicker and exposes higher-level events such as onClick, onMouseMove and onDoubleClick.

import 'bin/GPUPicker';
import Observable from '../../Observable';

export default class ObjectPicker extends Observable {
 constructor(mediator, renderingContext) {
  super();
  this.mediator = mediator;
  this.renderingContext = renderingContext;
 }

 initialize() {
  this.raycaster = new THREE.Raycaster();
  this.gpuPicker = new THREE.GPUPicker({renderer: this.renderingContext.renderer, debug: false});
  this.gpuPicker.setScene(this.renderingContext.scene);
  this.gpuPicker.setCamera(this.renderingContext.camera);
  this.renderingContext.renderer.domElement.addEventListener('dblclick', (e) => this.onDoubleClick(e));
  this.renderingContext.renderer.domElement.addEventListener('click', (e) => this.onClick(e));
  this.renderingContext.renderer.domElement.addEventListener('mousemove', (e) => this.onMouseMove(e));
 }

 onDoubleClick(e) {
  const astronomicalBody = this.getIntersection(e);

  this.emit('doubleclick', { astronomicalBody });
 }

 onClick(e) {
  const astronomicalBody = this.getIntersection(e);

  this.emit('click', { astronomicalBody });
 }

 onMouseMove(e) {
  const astronomicalBody = this.getIntersection(e);

  this.emit('mousemove', { astronomicalBody });
 }

 notifyWindowResize() {
  this.gpuPicker.needUpdate = true;
  this.gpuPicker.resizeTexture(window.innerWidth, window.innerHeight);
 }

 getIntersection(e) {
  this.gpuPicker.setScene(this.renderingContext.scene);

  const mouse = new THREE.Vector2();

  mouse.x = e.clientX;
  mouse.y = e.clientY;

  const raymouse = new THREE.Vector2();

  raymouse.x = ( e.clientX / window.innerWidth ) * 2 - 1;
  raymouse.y = -( e.clientY / window.innerHeight ) * 2 + 1;
  this.raycaster.setFromCamera(raymouse, this.renderingContext.camera);

  const intersection = this.gpuPicker.pick(mouse, this.raycaster);
  let astronomicalBody = null;
  if (intersection) {
   const originalObject = intersection.object.parent.originalObject;

   if (originalObject.mediator) {
    astronomicalBody = originalObject.mediator.astronomicalBody;
   }
  }

  return astronomicalBody;
 }
}

Controls that use specific Three.js functionality, such as the GPUPicker, are tricky to test. However, the view components that interact with these controls can use a higher-level interface, which makes them much easier to test.

Controls also use the Observer pattern for the notification of user events. The main view component attaches itself as an observer and forwards the events to the controller.

Mediators

This is where things start to get interesting. Our Three.js scene will have an object3D instance for every instance of a model that needs be rendered. For example, for each planet instance, there is an object3D instance that contains a mesh (with a texture) representing the planet. Every time that the state of a model is changed, we need to make sure that the corresponding object3D is updated accordingly, so that it will reflect the latest changes.

We use the Mediator Pattern to encapsulate the interaction between each model instance and its corresponding object3D. Every time a new model instance is created, a new instance of a mediator class is also created, which will then use the data in the model to create a new Object3D instance. Each mediator object receives notifications when the state of its model has changed and is responsible for keeping the corresponding object3D in sync. This can be: adding or removing child objects, animation or changing the object3D appearance as a consequence of model changes.

View Mediators Keeping Models and Three.js Objects in Sync

View Mediators Keeping Models and Three.js Objects in Sync

Let’s look at the planet mediator class.

import ViewMediator from './ViewMediator';

export default class PlanetViewMediator extends ViewMediator {
 constructor(planet, mediatorFactory) {
  super(planet, mediatorFactory);
  this.astronomicalBody.addObserver("SatelliteAdded", (e) =>   this.onSatelliteAdded(e));
  this.astronomicalBody.addObserver("SatelliteRemoved", (e) =>   this.onSatelliteRemoved(e));
 }

 makeObject3D() {
  const container = new THREE.Object3D();
  const mesh = new THREE.Mesh(
   new THREE.SphereGeometry(this.astronomicalBody.properties.radius, PlanetViewMediator.SphereSegments, PlanetViewMediator.SphereSegments),
   new THREE.MeshPhongMaterial({
    map : THREE.ImageUtils.loadTexture(this.astronomicalBody.properties.texture)
   })
  );

  container.rotation.y = Math.random() * 360;
  container.add(mesh);

  mesh.position.setX(this.astronomicalBody.properties.distance);
  return container;
 }

 onSatelliteAdded(e) {
  this.addChild(e.satellite);
 }

 onSatelliteRemoved(e) {
  this.removeChild(e.satellite);
 }

 onFrameRenderered() {
  super.onFrameRenderered();

  if (this.astronomicalBody.isMoving) {
   if (this.astronomicalBody.properties.orbitalSpeed) {
    this.object3D.rotation.y += this.astronomicalBody.properties.orbitalSpeed / 3;
   }

   if (this.astronomicalBody.properties.rotationSpeed) {
    this.object3D.children[0].rotation.y += this.astronomicalBody.properties.rotationSpeed / 3;
   }
  }
 }
}

PlanetViewMediator.SphereSegments = 32;

This class takes a planet and a mediator factory in its constructor. The mediator registers itself as an observer of the planet object. Whenever a satellite is added to or removed from the planet, the mediator will be notified and react accordingly.

The mediator class also has a makeObject3D method, which will take the model instance, in this case a planet, and create the corresponding object3D. The planet is rendered as a THREE.Mesh object with a THREE.SphereGeometry.

The mediator is also notified every time a new frame is rendered and can animate its corresponding object3D if needed. In the case of a planet, the mediator both rotates the model and moves it around the sun.

By using the Mediator pattern, we can avoid coupling models and scene objects. Mediators are also very easy to unit test, as each class has very specific responsibilities and minimal dependencies.

As you can see from the diagram above, for each model class we are creating a parallel mediator class. You might be thinking that we’ll end up with way too many classes and things will get very complicated. Well, yes and no. While it’s true that the number of classes will increase significantly, you mustn’t forget that the quality of a codebase is not measured by its number of files or classes, but by how easy it is to modify and extend. Don’t worry too much about splitting classes that do too much or have too many responsibilities into smaller and more dedicated components.

Controller

The controller’s main responsibility is to respond to the user input and update the models accordingly. In our case, we have a very simple Galaxy Controller class, in which:

  • We instantiate a new MainView class
  • Define how to react to mouse events
Controller Handling Commands from the View Component

Controller Handling Commands from the View Component

import MainView from '../view/MainView';

export default class GalaxyController {
 constructor(galaxy) {
  this.galaxy = galaxy;
  this.view = new MainView(this, galaxy);
  this.view.initialize();
 }

 setDescriptionPanelText(astronomicalBody, event) {
  if (astronomicalBody) {
   this.view.descriptionPanel.text = `${event}: ${astronomicalBody.name}`;
  } else {
   this.view.descriptionPanel.text = `${event}: ${this.galaxy.name}`;
  }
 }

 onClick(astronomicalBody) {
  this.setDescriptionPanelText(astronomicalBody, 'Clicked');

  if (astronomicalBody && astronomicalBody.className === 'Planet') {
   astronomicalBody.isMoving = !astronomicalBody.isMoving;
  }
 }

 onDoubleClick(astronomicalBody) {
  if (astronomicalBody) {
   const parentElement = astronomicalBody.parent;

   if (parentElement.className === 'Planet') {
    parentElement.removeSatellite(astronomicalBody);
   } else if (parentElement.className === 'SolarSystem') {
    parentElement.removePlanet(astronomicalBody);
   }
  }
 }

 onMouseMove(astronomicalBody) {
  this.setDescriptionPanelText(astronomicalBody, 'Hovered');
 }
}

Updates in the state of a model may trigger actions by its corresponding mediator. Controllers are also very easy to test, since they depend only on models and views. The view components can be mocked, in order to simulate user interaction.

Conclusions

If you look at the sample code, you’ll see that we ended up with a very organized codebase, where every class has a single responsibility and components are very loosely coupled. I could have probably written the same application without using this MVC approach in half the time, but if we assume that many new features will have to be added in the future, then this extra effort at the beginning will have paid off very soon.

Pros

  • Using MVC with Three.js promotes loosely-coupled components
  • Changes are isolated: you can change the way things are rendered and know that your business logic components won’t be affected, and vice versa. This is also known as Separation of Concerns and is described very clearly in the book Growing Object-Oriented Software Guided by Tests
  • Unit testing becomes very easy (which is always a sign of a good design)
  • It takes less time for new members of the team to become productive, since the codebase is easier to understand and modify
  • It uses standard design patterns that everyone knows: strategy, observer, mediator, etc

Cons

  • It adds a bit of extra code and complexity, which might not always be necessary, depending on the project.

All in all, if you’re working on a non-trivial long-term Three.js project I encourage you to give this design a try. Also feel free to take these ideas and adapt them to your needs.

I’d also like to know how your Three.js projects are structured, how you unit test each component, etc…

Programming Patterns
Efficient WebVR Development Using the Adapter Pattern in Three.js
Programming Patterns
Future Proofing Geometry Manipulation in Three.js
Software Engineering
Welcoming change: how decoupling can make your application more flexible
  • Jizzus

    Hello,
    I really like this article and I find it very helpful. There is one thing I would like to ask. Let’s say I am getting position of every planet via websocket from some other server (eg. X, Y, Z). Where would you put this “data loader” for downloading positions and changing it in planet models, without destroying the whole MVC pattern?

    Thank you very much!


    • Lucas Majerowicz

      Hi!

      I would implement that in three steps:

      1.
      Add a position field to the Planet model class and a corresponding setter that emits a notification event to the planet’s observers. A getter would be nice too ;). Something like this:

      ...
      set position(position) {
      this._position = position;
      this.emit('PositionChanged', { position });
      }

      get position() {
      return this._position.
      }

      2.
      In the PlanetViewMediator you can use the planet’s position to render the model acordingly. In my example I used this.astronomicalBody.properties.orbitalSpeed and this.astronomicalBody.properties.rotationSpeed for animating the model. In your case, assuming that you’ll be constantly receving the position from the websocket, you can just use this.astronomicalBody.position. If you need to add extra rendering logic the moment that the planet’s position has been updated, you can add a new PositionChanged event listener, similar to here.

      3.
      After the two steps above, any changes to the models’ position should be reflected in the three.js scene. The remaining step is to create some class or service that receives the updates from the websocket and updates the corresponding models. It probably makes sense to pass the galaxy model as a parameter in its constructor. The important thing is that this new service works only with the models and doesn’t have any references to the view components.

      Hope this is helpful. Let me know if you have other questions.

      Lucas


  • Jizzus

    Hi,
    It’s me again. I did it like you said and it works! Thanks again.

    However I have this little problem. When I use this pattern in my application and I create a bigger amount of objects the RAM consumption gets really high and my application crashes.

    Have you tried this approach with a bigger number of objects (planets)? Is it because of the all things around MVC and dependencies (30 Sphere objects and 47 MB of RAM [much more than in Three.js performance examples]), or is it because I misunderstood some part of this tutorial and messed it up 🙂 ?

    Thank you.


    • Lucas Majerowicz

      Hi,

      I’m very glad it worked.

      I just tried my demo, this time adding 300+ objects to the scene and it works ok.

      Basically, this MVC approach doesn’t really add overhead in terms of three.js. We’re not duplicating geometries, materials or meshes.

      We do use a few extra objects (like mediator and model instances), but since we’re talking about very small objects, their impact in performance is practically 0.

      Also, 30 sphere objects doesn’t seem like much. I’ve worked with very large and complicated models in Three.js without any problem.

      It’s a bit hard to guess what the problem is, but I’m pretty sure it doesn’t have to do with this MVC approach. If you’d like, send me the code and I’ll be happy to take a look 😉

      Lucas


  • Jizzus

    Hello,

    reusing geometries (if it is possible) is a great way of saving memory and gaining performance. (e.g. one general planet geometry). With this approach everything works great and fast with MVC. Thanks again for this amazing tutorial.

    Best wishes,
    Jizzus


  • Ben

    Thanks Lucas!

    This helped me out a lot in creating a larger THREE.js application.

    Cheers!