Programming Patterns

Future Proofing Geometry Manipulation in Three.js


In this post we’ll use the Adapter Pattern to improve the way we interact with Three.js geometries and create a solution based on the Open/Closed Principle.

The Problem

Three.js is great. It’s a fantastic library that makes working with 3D graphics in the browser very accessible. However, very often when I’m working on complex mesh manipulation functionality, say implementing a computer graphics paper, I find that the geometry APIs are not high-level enough: they distract from the essence of what I’m trying to implement and the final code ends up being less readable. For example, take the following code that sets the value of the 10th vertex in the mesh to (20, 10, 50).

const positions = bufferGeometry.attributes.position.array;
const offsetPosition = 3 * 10;

positions[offsetPosition] = 20;
positions[offsetPosition + 1] = 10;
positions[offsetPosition + 2] = 50;

If you value code readability and want to avoid duplication, then you’ll appreciate how much of a problem code like that can be.

Another issue with working with Three.js geometries is that there isn’t a consistent API for dealing with all the available types of geometries. You have Geometry and BufferGeometry, which can either be indexed or not. Each of these three cases requires a different way of manipulating the geometries. One way to deal with this limitation would be to write code targeted to a specific type of geometry, e.g. indexed BufferGeometry, and whoever uses it will need to make sure to send the right type of geometry.

I’ve personally worked on projects in which changing the type of Geometry used in the system from Geometry to BufferGeometry triggered a cascading effect that ended up affecting practically the entire codebase.

Future Proof

I think that as developers, one of the most difficult decisions we have to make when working with external libraries is whether to use them directly or to create wrappers around them. The advantage of the first option is that it’s obviously simpler and more straightforward. The advantage of the second option is that your codebase is protected from potential changes in the external libraries. You can also switch libraries relatively easily if you decide to do so and unit testing becomes much easier.

When it comes to Three.js, it might not be practical to create a wrapper for all the places where it is used. However, it doesn’t have to be all or nothing. We can still create wrappers for specific Three.js functionality. The way I see it, Three.js functionality that is used in only one place, like scene creation or the render loop, doesn’t need to be wrapped. On the other hand, functionality that is scattered all over the system, such as geometry manipulation, should be wrapped. The idea is to reduce the number of places in the codebase that will be affected by a single change.

Adapter Pattern

By using the Adapter Pattern we’ll create a custom interface that we’ll use to interact with the Three.js geometry classes. This way:

  • We get to work with a higher-level more-abstract interface
  • We don’t need to worry about the type of geometry we’re working with
  • We can work with other geometry representations that are not necessarily Three.js
  • Future changes in the Three.js geometry API won’t affect our code – well, only the adapter

Download source

The idea is to define a custom implicit geometry interface and have different adapters, one for each geometry type, to implement this interface. In this case, I’ve defined the following interface:

interface GeometryAdapter {

    get numVertices();

    get numFaces();

    setVertex(index, x, y, z);

    setVertexX(index, x);

    setVertexY(index, y);

    setVertexZ(index, z);

    getVertex(index);

    getVertexX(index);

    getVertexY(index);

    getVertexZ(index);

    getFace(index);

    getFaceVertices(index);

    updateVertices();

    updateFaces();
}

This is only for illustration purposes. We’re working with ES6, which doesn’t have support for interfaces.

Then we implement the following adapter classes:

  • IndexedBufferGeometryAdapter
  • UnIndexedBufferGeometryAdapter
  • GeometryAdapter

The last piece of the puzzle is to use the Factory Pattern to create an adapter factory class, which will encapsulate the logic of which adapter to create depending on the type of geometry.

import IndexedBufferGeometryAdapter from './IndexedBufferGeometryAdapter';
import UnIndexedBufferGeometryAdapter from './UnIndexedBufferGeometryAdapter';
import GeometryAdapter from './GeometryAdapter';

export default class GeometryAdapterFactory {

    getAdapter(geometry) {
        const hasPositionAtrr = geometry.attributes && geometry.attributes.position;

        if (hasPositionAtrr) {
            if (geometry.index) {
                return new IndexedBufferGeometryAdapter(geometry);
            } else {
                return new UnIndexedBufferGeometryAdapter(geometry);
            }
        } else {
            return new GeometryAdapter(geometry);
        }
    }
}

Then we can have code like this

function averageVertices(geometryAdapter) {
    const result = new THREE.Vector3();
    
    for (let index = 0; index < geometryAdapter.numVertices; index++) {
        result.add(geometryAdapter.getVertex(index));
    }

    return result.divideScalar(geometryAdapter.numVertices);
}

const adapterFactory = new GeometryAdapterFactory();
const geometries = [];

// Geometry
geometries.push(new THREE.BoxGeometry( 5, 5, 5 ));

// Indexed BufferGeometry
geometries.push(new THREE.BoxBufferGeometry( 5, 5, 5 ));

// Unindexed BufferGeometry
geometries.push(new THREE.BufferGeometry().fromGeometry(new THREE.BoxGeometry( 5, 5, 5 )));

for (const geometry of geometries) {
    const geometryAdapter = adapterFactory.getAdapter(geometry);

    console.log('Vertices average is:', averageVertices(geometryAdapter));
}

By combining both Adapter and Factory patterns, we end up with a very clean and readable code. All interaction with Three.js geometry interfaces is encapsulated within the adapters. This implementation also follows the Open/Closed Principle: we can add support for more types of geometry representations by just creating new adapter classes and adding them to the factory adapter.

Programming Patterns
Efficient WebVR Development Using the Adapter Pattern in Three.js
Programming Patterns
Building Real-Time Collaboration Applications in Three.js
Programming Patterns
MVC Pattern For Building Three.js Applications
There are currently no comments.