Instancing with three.js — Part 2

Dusan Bosnjak
9 min readSep 13, 2018

--

In this part we’re going to take an example that could potentially benefit from instancing and apply it!

Well, let’s find one, and analyze it. This one seems like it will do.

We would clone the repo, spin up a local server, and edit this file:

https://github.com/mrdoob/three.js/blob/dev/examples/webgl_interactive_cubes.html

First, let’s see what we’re dealing with:

console.log(renderer.info.render.calls) //add right above the render call
renderer.render( scene, camera );
The output should be something like this

These are draw calls that we’re logging. Notice two things — the number is much smaller than the number of entities created in the demo (2000) and it changes between render calls. They are randomly scattered in space, and as the camera moves through it, majority of them gets culled. Different frames cull different objects.

Since these are cubes, in best case scenario we process 24 vertices with each mesh when our vertex shader runs. Because of culling, we only run it on roughly a quarter of the whole set. This is good on it’s own, but again, because of how GPUs work, it may be much more expensive to issue the draw call, than to have the device crunch some math. Let’s bring this number to 1.

We’re going to focus on this area of the code:

var geometry = new THREE.BoxBufferGeometry( 20, 20, 20 );for ( var i = 0; i < 2000; i ++ ) {var object = new THREE.Mesh( geometry, new THREE.MeshLambertMaterial( { color: Math.random() * 0xffffff } ) );object.position.x = Math.random() * 800 - 400;
object.position.y = Math.random() * 800 - 400;
object.position.z = Math.random() * 800 - 400;
object.rotation.x = Math.random() * 2 * Math.PI;
object.rotation.y = Math.random() * 2 * Math.PI;
object.rotation.z = Math.random() * 2 * Math.PI;
object.scale.x = Math.random() + 0.5;
object.scale.y = Math.random() + 0.5;
object.scale.z = Math.random() + 0.5;
scene.add( object );}

We declare a box geometry geometry. We don’t want to change this much, since we still want to keep just one instance of this data on the GPU.

2000 times, we create a new Mesh called object. These meshes all point to the same geometry (reusing it) but each have a unique material with a different color.

Next we place the object somewhere in space by setting the rotation,position,scale .

Finally the objects are directly added to the scene.

This last point is a bit tricky for the example, it would be better if we added the objects to another node thats not Scene so let’s just take a note of this.

First we’re going to create an instance of InstancedBufferGeometry and copy the geometry contents in it:

var geometry = new THREE.BoxBufferGeometry( 20, 20, 20 );var instancedGeometry = new THREE.InstancedBufferGeometry() //this is going to wrap both geometry and a bit of the scene graph//we have to copy the meat - geometry into this wrapper
Object.keys(geometry.attributes).forEach(attributeName=>{
instancedGeometry.attributes[attributeName] = geometry.attributes[attributeName]
})
//along with the index
instancedGeometry.index = geometry.index

Next we move onto the scene graph part, starting with the number of nodes 2000:

instancedGeometry.maxInstancedCount = 2000

We need to do some prep work before we dive into the loop. Since we can’t store the entire matrix, we’re going to store 4 x vec4 for the sake of simplicity.

const matArraySize = 2000 * 4
const matrixArray = [
new Float32Array(matArraySize),
new Float32Array(matArraySize),
new Float32Array(matArraySize),
new Float32Array(matArraySize),
]

This is the bucket that’s going to hold the matrices that would otherwise be referenced as uniforms from individual nodes. We can wire these to the instancedGeometry as such:

for( let i = 0 ; i < matrixArray.length ; i ++ ){
instancedGeometry.addAttribute(
`aInstanceMatrix${i}`,
new THREE.InstancedBufferAttribute( matrixArray[i], 4 )
)
}

This takes care of the position, scale, rotation that we will encounter in the loop. There is one missing though, the property of the Material. We make another attribute to account for this:

const instanceColorArray = new Uint8Array(2000*3)
instancedGeometry.addAttribute(
'aInstanceColor',
new THREE.InstancedBufferAttribute( instanceColorArray, 3, true )
)

Let’s dive into the loop:

for ( var i = 0; i < 2000; i ++ ) { 
var object = new THREE.Object3D() //we'll swap out the mesh with this
//we should keep the color though
const color = new THREE.Color(Math.random() * 0xffffff)
//we're going to piggy back of the scene graph and keep this as is
object.position.x = Math.random() * 800 - 400;
object.position.y = Math.random() * 800 - 400;
object.position.z = Math.random() * 800 - 400;
object.rotation.x = Math.random() * 2 * Math.PI;
object.rotation.y = Math.random() * 2 * Math.PI;
object.rotation.z = Math.random() * 2 * Math.PI;
object.scale.x = Math.random() + 0.5;
object.scale.y = Math.random() + 0.5;
object.scale.z = Math.random() + 0.5;
scene.add( object );object.updateMatrixWorld() //we compute the matrix based on position, rotation and scale//now that we have the matrix computed we need to transfer it to the attribute

for ( let r = 0 ; r < 4 ; r ++ )
for ( let c = 0 ; c < 4 ; c ++ ){
matrixArray[r][i*4 + c] = object.matrixWorld.elements[r*4 + c]
}
//same goes for color
const colorArray = color.toArray().map(c=>Math.floor(c*255))
for( let c = 0 ; c < 3 ; c ++ )
instanceColorArray[i*3+c] = colorArray[c]
}

Now for the gnarly part. The GLSL that needs to be injected into the material in order to apply this:

instanceMaterial.onBeforeCompile = shader=>{shader.vertexShader = `attribute vec4 aInstanceMatrix0;
attribute vec4 aInstanceMatrix1;
attribute vec4 aInstanceMatrix2;
attribute vec4 aInstanceMatrix3;
attribute vec3 aInstanceColor;${shader.vertexShader.replace(
'#include <begin_vertex>',
`
mat4 aInstanceMatrix = mat4(
aInstanceMatrix0,
aInstanceMatrix1,
aInstanceMatrix2,
aInstanceMatrix3
);
vec3 transformed = (aInstanceMatrix * vec4( position , 1. )).xyz;
`
)}
}

The result should be something like this:

Notice only one draw call

This is enough to position the elements, and reduce everything to a single draw call. We’re not using colors yet, and the lighting is actually off.

The lights still think that there is a single cube centered around the origin in model space, we need to also transform the normals in order for lighting to take effect.

The color is usually controlled through a uniform in the fragment shader, but since we cant load a uniform, and are instead working with an attribute, we have to read it in the vertex shader and pass it as a varying. We add another transformation to the shader (argh, curse ye onBeforeCompile):

shader.vertexShader = `varying vec3 vInstanceColor;${
shader.vertexShader.replace(
`#include <color_vertex>`,
`#include <color_vertex>
vInstanceColor = aInstanceColor;
`
)}
`

We should also read this in the fragment shader. Looking at the tamplate, we can swap out the uniform for the varying, with another transform:

shader.fragmentShader = `
varying vec3 vInstanceColor;
${
shader.fragmentShader.replace(
'vec4 diffuseColor = vec4( diffuse, opacity );',
'vec4 diffuseColor = vec4( vInstanceColor, opacity );'
)}
`

This should hook up the colors. The lighting is still off, and the demo may have complained so i commented out a bunch of the picking stuff.

The lighting is actually very involved to get right. This is because normalMatrix is a bit specific in how it’s computed. Still, we can take a stab at it, by just rotating with the local instance matrix:

shader.vertexShader = shader.vertexShader.replace(
`#include <beginnormal_vertex>`,
`
mat4 _aInstanceMatrix = mat4(
aInstanceMatrix0,
aInstanceMatrix1,
aInstanceMatrix2,
aInstanceMatrix3
);
vec3 objectNormal = (_aInstanceMatrix * vec4( normal, 0. ) ).xyz;
`
)

Notice we set the .w to 0, this is to distinguish the normal as a direction and not a point in space. This allows the matrix multiplication to just rotate the vector without translating it. There is also a race condition and if the matrix is defined in this chunk, it should be omitted from the begin_vertex but i just renamed it :)

Still at one draw call, the lighting is a bit more consistent

This takes care of making the rendering portion work again. The nodes are scattered, colored correctly, but rendering at one draw call.

CPU side stuff

Let’s rewire the intersection functionality. This approach would probably work better with GPU picking, but for the sake of the example, we’re still going to do things on the scene graph and CPU.

var camera, scene, raycaster, renderer;var intersectsScene

We introduce a scene in parallel. We can instantiate it right before the loop:

intersectsScene = new THREE.Scene()for ( var i = 0; i < 2000; i ++ ) {

In the loop we revert back to the Mesh instead of Object3D and add it to the new scene. This is because we piggy backed off the Object3D and it’s convenience method to turn position and rotation into a Matrix4 . We could have just used the matrix without the object, but either way, now we need the actual geometry at some location in space to raycast against. We pass the reference to the same geometry, and wrap it in a scene graph node Mesh . Since the matrices get stored in attributes ordered, we keep the index so we can reference them later:

const object = new THREE.Mesh(geometry)object.userData.index = iintersectsScene.add( object )

We still let the logic populate the instancing attributes.

It would be wise to do something like this right after the loop. Since this scene is not rendered, the matrices never get computed:

intersectsScene.updateMatrixWorld(true)

Now we move onto the logic and we tell the raycaster to use the dummy intersectsScene instead of the scene which we draw:

var intersects = raycaster.intersectObjects( intersectsScene.children );

This should make the raycasting work, but the demo would complain when trying to set the color of an inappropriate material:

// if ( INTERSECTED ) INTERSECTED.material.emissive.setHex( INTERSECTED.currentHex );

If we comment that out and log the hits, we will hit the meshes from the scene graph, even though we don’t draw them. We could log the INTERSECTED.userData.index and see which instanced mesh we’re hitting.

To explain this a bit - we have another scene graph, but it might as well be a branch of the main one, containing nodes Mesh. These have their position set for example, but do not render. The node wrapper takes Geometry which has a boundingSphere and places it in the position of the node. The ray then has something to hit. This is completely decoupled from the rendering logic which we do through the instanced geometry and a single Mesh.

There are a few lines left that manage which cube should be highlighted. We’re going to drop the currentHex name and setHex interface since we no longer have access to the interface that takes the hex value.

This logic uses the emissive property, which we need to add to the shader.

At the very top of the script, we’re going to add some globals for convenience:

var container, stats;
var camera, scene, raycaster, renderer;
var intersectsScene
const red = [255,0,0] //a constant colorconst instanceEmissiveArray = new Uint8Array(2000*3) //the emissive arrayconst emissiveAttribute = new THREE.InstancedBufferAttribute( instanceEmissiveArray, 3, true )
emissiveAttribute.dynamic = true

We will also add a couple of functions:

function setEmissiveAtIndex( index, colorArray ) {
for( let i = 0 ; i < 3 ; i ++ ){
instanceEmissiveArray[index*3 + i] = colorArray[i]
}
}
function getEmissiveAtIndex( index ) {
const res = []
for( let i = 0 ; i < 3 ; i ++ ){
res.push(instanceEmissiveArray[index*3 + i])
}
return res
}

This gives us some access to the pretty low level instance attribute. We provide the color (already typed as an array but could be THREE.Color ) and we provide the index.

We need to add this attribute to the instancedGeometry as well:

instancedGeometry.addAttribute(
'aInstanceEmissive',
emissiveAttribute
)

Yet another unique transform is needed:

shader.fragmentShader = `
varying vec3 vInstanceEmissive;
${
shader.fragmentShader.replace(
'vec3 totalEmissiveRadiance = emissive;',
'vec3 totalEmissiveRadiance = vInstanceEmissive;'
)}
`

Note that we need to modify some of the old ones, but the pattern is obvious. We need to read the attribute, save it to a varying and read from it.

Finally we replace the logic calls:

// if ( INTERSECTED ) INTERSECTED.material.emissive.setHex( INTERSECTED.currentHex );if ( INTERSECTED ) 
setEmissiveAtIndex(
INTERSECTED.userData.index,
INTERSECTED.currentColorArray
)

The getHex gets swapped out with our function:

INTERSECTED.currentColorArray = getEmissiveAtIndex(INTERSECTED.userData.index)

At this point the demo should look more or less the same, and work as before.

Still at a single draw call

Result

If we up the number of cubes to 2¹⁶ we should still be at interactive rates (click here to see live). In fact this is nothing to render, the bottle neck may be the ray casts.

If we up the original demo to this number, it’s going to be pretty bad.

In the next part we are going to try some additional scene graph optimizations and finally use an abstracted module to simplify all of this.

--

--