Instancing with three.js — Part 1

Intro

Imagine we have a 3d world with lots of trees, or lamp posts. When we render such a world we issue a lot of draw calls. Draw calls have overhead and are expensive. For the sake of interactive frame rates, we want to remove them where possible.

const myGeom = new THREE.BoxGeometry()
const myMaterial = new THREE.MeshBasicMaterial()
const myGroup = new THREE.Group()
for ( let i = 0 ; i < 25 ; i ++ ) {
const myMesh = new THREE.Mesh(myGeom, myMaterial)
myGroup.add(myMesh)
myMesh.frustumCulled = false
myMesh.position.set(random(),random(),random())
}

The “brute force” way

One approach we can take to optimize the draw calls is to merge these meshes (geometries) into one.

const geom = new THREE.BoxGeometry()
const material = new THREE.MeshBasicMaterial()
const mergedGeometry = new THREE.BufferGeometry()for ( let i = 0 ; i < 25 ; i ++ ) {
const nodeGeometry = geom.clone()
nodeGeometry.translate(random(),random(),random())
mergedGeometry.merge(nodeGeometry)
}
const myCluster = new THREE.Mesh( mergedGeometry, material)

Drawbacks

This approach is a memory hog.

The clever way

GPUs and WebGL are all about managing memory and issuing commands. We have a feature called “instancing” that would allow us to perform the optimization we just did with merging, but in a much more efficient way.

const geometry = new THREE.PlaneGeometry()
attribute vec3 position;
const mesh = new THREE.Mesh(geometry)
uniform mat4 modelMatrix;
const camera = new THREE.PerspectiveCamera
uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelViewMatrix; //camera + mesh/line/point node

A simple shader

Let’s transform the mesh with a very simple vertex shader. THREE.ShaderMaterial actually injects all these uniforms for us so we don’t have to:

void main(){
gl_Position =
projectionMatrix * viewMatrix * modelMatrix * vec4(position,1.);
}
  • we cast the attribute from BufferGeometry to a vec4 since it comes in as vec3 .
  • We apply the world transformation derived from position,scale and rotation.
  • We project this into a camera

Compare the two

In the first example, where the scene graph holds a parent with 25 children, the engine will compute 25 different modelMatrix values. If you move the parent, three will do something along the lines of:

parentMatrix.multiply(childMatrix)
vec4 worldPosition = 
modelMatrix * //moves the instance into world space (parent+child)
vec4(position,1.); //model space
cluster.position.set(1,1,1)
cluster.rotation.set(Math.PI,0,0)
cluster.scale.set(2,1,2)
const geom = new THREE.BufferGeometry()for ( let i = 0 ; i < 25 ; i ++ ) {
const nodeGeometry = geometry.clone()
nodeGeometry.applyMatrix( myMatrix[i] )
geom.merge(nodeGeometry)
}
vec4 worldSpace = 
modelMatrix * //moves the entire cluster (parent)
vec4( position, 1.); //not really model space any more, since it has the transformation "baked in" from outside (child)

Instancing

Let’s take a step back and consider some of the elements we have after the lengthy overview so far:

  • some 3d world ( THREE.Scene )
  • some spatial entity, like a neighborhood, a village or a tile ( THREE.Group )
  • some asset, like a tree or a lamp post ( THREE.BufferGeometry )
  • some intent on how the asset fits the world, ie. 25 lamp scattered in the world in some pattern ( THREE.Mesh )
const asset = OBJLoader.load('lamp_post.obj') //load a small asset once//scatter asset
const tile = new THREE.Mesh(new THREE.PlaneGeometry)
myPositions.forEach( pos=>{
const mesh = new THREE.Mesh(asset, myMaterial)
mesh.position.copy(pos)
tile.add(mesh)
})

Low level

The only instancing referenced in the docs are classes InstancedBufferAttribute and InstancedBufferGeometry .

myLampPost.clone().position.copy(myPosition)
var offsets = new Float32Array( INSTANCES * 3 ); // xyz
var colors = new Float32Array( INSTANCES * 3 ); // rgb
var scales = new Float32Array( INSTANCES * 1 ); // s
for ( var i = 0, l = INSTANCES; i < l; i ++ ) {
var index = 3 * i;
// per-instance position offset
offsets[ index ] = positions[ i ].x;
offsets[ index + 1 ] = positions[ i ].y;
offsets[ index + 2 ] = positions[ i ].z;
// per-instance color tint - optional
colors[ index ] = 1;
colors[ index + 1 ] = 1;
colors[ index + 2 ] = 1;
// per-instance scale variation
scales[ i ] = 1 + 0.5 * Math.sin( 32 * Math.PI * i / INSTANCES );
}
geometry.addAttribute( 'instanceOffset', new THREE.InstancedBufferAttribute( offsets, 3 ) );
geometry.addAttribute( 'instanceColor', new THREE.InstancedBufferAttribute( colors, 3 ) );
geometry.addAttribute( 'instanceScale', new THREE.InstancedBufferAttribute( scales, 1 ) );
#ifdef INSTANCED       
attribute vec3 instanceOffset;
attribute float instanceScale;
#endif
attribute mat4 instanceMatrix; //instance attribute attribute vec3 position; //regular attributevoid main(){
gl_Position =
projectionMatrix * viewMatrix * //from THREE.Camera
modelMatrix * //from THREE.Mesh
instanceMatrix * //we add this to the chain,
vec4(position,1.) //from THREE.BufferGeometry
;
}
geometry.addAttribute( 'instanceOffset', new THREE.InstancedBufferAttribute( offsets, 3 ) );
uniform vec3 offset;
attribute vec3 offset; //this is actually 25 different values that will be referenced
void main(){
vec3 myPosition = position + offset; //offset will change value 25 times during the draw call
}
for ( var i = 0, l = INSTANCES; i < l; i ++ ) {    var index = 3 * i;    offsets[ index ] = positions[ i ].x;
offsets[ index + 1 ] = positions[ i ].y;
offsets[ index + 2 ] = positions[ i ].z;

Problems

As mentioned, this is all pretty low level, and it’s all three.js offers, but with a good reason. A game engine would guess how assets are being used, and try to optimize this under the hood. Three.js can be used to make a game engine, but it could be used for something else where the need for instancing would be vastly different.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store