DevLog #10 - Three.js InstancedMesh Performance Optimizations

Welcome to VR Me Up developer log number 10. It’s been a little while since my last developer log, and that’s because I have been working hard on improving the performance of the WebXR Three.js client application and fixing some workarounds that I put in to try and improve the performance earlier. I can now render thousands more objects at almost twice the frame rate, allowing me to increase the complexity and viewing distance of the world. This developer log is about those performance improvements and some of the other things I discovered along the way.

InstanceMesh issues
InstanceMesh issues

The big change was moving from using individual Three.js objects to create the scene to using InstancedMesh objects instead. Before you think to yourself … “I know everything about instanced meshes”, I have some things I want you to consider.

  • Object Field Of View Culling
  • Level Of Detail.

On high end desk top machines, with dedicated GPU’s, these things may not have a large impact on performance, but on low end devices like mobile phones and embedded VR headsets, they can have an noticeable impact.

View Frustum Culling

Running the Three.js instanced mesh example on a Quest 2, and positioning the camera in about the centre of the instances, in naive mode we are getting around 85 frames per second for about 2600 instances. Each of Suzanne the monkeys heads is a separate draw call. Now, switching to instanced mode, the frame rate surprisingly drops to about 55 FPS, even though all the heads are now being drawn in a single draw call. Switching to merged mode, still does not improve the frames per second.

Triangle culling
Triangle culling

In native object mode, meshes are removed by the view frustum culling step in the Three.js render pipeline and never sent to the GPU. In instanced mode, however, all the meshes are sent to the GPU. Even though the GPU may not render the entire mesh, as it may be outside it’s triangle view frustum culling step, the mesh still has to go through the vertex shader. On devices with a low end GPU, this can actually decrease performance.

InstancedMesh instance array layout
InstancedMesh instance array layout

To solve this I allocate space for a maximum number of instances when creating the Instanced Mesh but I set the count to only render the number of instances that are visible.

var im = new THREE.InstancedMesh(geom, mat, 1000) ;
...
im.count = 700

On each frame I iterate over the instances and move the visible ones to the front of the instance array and the ones outside the view frustum to the hidden section and reset the count. I do this very efficiently using some array insert delete optimizations to reduce the number of reads and writes to the instance array.

// Setup the clipping frustum
var camera = this._camera = context.camera;
camera.updateWorldMatrix(true, true) ;
var mat = new THREE.Matrix4() ;
mat.multiplyMatrices( camera.projectionMatrix, camera.matrixWorldInverse )
frustum = new THREE.Frustum() ;
frustum.setFromProjectionMatrix(this.__mat4); 
var bs = geom.boundingSphere
var testSphere = new THREE.Sphere()

...

// For reach instance position
sphere.set(pos, testRadius) ;
var isVisible = frustum.intersectsSphere(this._testSphere) ;  

As a result I get the best of both techniques, I reduced the number of draw calls to draw multiple items at once and I only render the visible mesh instances.

Level Of Detail

Car LOD example
Car LOD example

The next issue I had with Instanced Meshes was that they do not support Level of Detail. This is a common scene optimization technique where distant objects are drawn using a less complex mesh in order to reduce the polygon and vertex numbers. All the Meshes in an Instanced Mesh are drawn at the same complexity, which can have an impact on performance.

InstancesMesh LOD layout
InstancesMesh LOD layout

Instead of using a single Instanced mesh, I create one for each mesh level of detail. Using a technique similar to the method I used for the “View Frustum Culling” I move the instances between the separate instanced meshes depending on the instances distance from the camera.

Using multiple InstancedMesh objects for each level of detail, combined with the View Frustum culling almost doubled my frame rate.

threejs-polygun NPM package

Polygun megacity screen shot
Polygun megacity screen shot

I decided to package all the code I wrote to do all this into an NPM package called threejs-polygun so that other people would be able to use it in their projects. I still have a few bugs to fix, so I have not released it yet. If your interested please follow me on YouTube or Twitter/X and I’ll let you know when it’s available. In the mean time, you can have a look at an example mega city project I created to get a feel of how it looks.

See you in the metaverse… bye for now!

VR Me Up.

Let's Get In Touch!


Join us on the journey creating an Open Metaverse