Tips & Tricks for speeding up Three.js

Posted 7 months ago - 6 min read

While writing the code for my 3D Portfolio I’ve collated a handful of of tips and tricks for improving the performance of three.js projects. Here’s a list of everything I’ve found so far. I’ll be updating this when I come across anything new so be sure to check back from time to time.

Disclaimer: There are plenty of variables when writing code so be sure to A/B test these suggestions thoroughly and see what works for you. These optimisations are currently tested for [three.js version r112](https://github.com/mrdoob/three.js/releases/tag/r112)

Measuring performance:


You can monitor high level performance improvements by implementing the stats module at https://github.com/mrdoob/stats.js/ to show:

0. FPS Frames rendered in the last second. The higher the number the better.
1. MS Milliseconds needed to render a frame. The lower the number the better.
2. MB MBytes of allocated memory.

For lower level stats you can log the following (most scenarios lower is better):
console.log("Scene polycount:", renderer.info.render.triangles)
console.log("Active Drawcalls:", renderer.info.render.calls)
console.log("Textures in Memory", renderer.info.memory.textures)
console.log("Geometries in Memory", renderer.info.memory.geometries)

Performance tips:

  1. Always try to reuse objects such as geometries, materials, textures etc. The following code snippet results in two new geometries and a single textures being added to the renderer. This count would remain the same if further elements were added to the array. If PlaneBufferGeometry was declared in the loop renderer.info.memory.geometries would show a linear growth of O(n) and performance would degrade at the same rate.
const markerArray = [
  { type: "MAIN", zAxis: 0 },
  { type: "SMALL", zAxis: 100 },
  { type: "MAIN", zAxis: 200 },
  { type: "MAIN", zAxis: 300 },
  { type: "SMALL", zAxis: 400 },
]

const material = new THREE.MeshBasicMaterial({
  color: 0xffffff,
  side: THREE.DoubleSide,
})

const geom1 = new THREE.PlaneBufferGeometry(35, 150)
const geom2 = new THREE.PlaneBufferGeometry(35, 100)

for (let h = 0; h < markerArray.length; h++) {
  if (markerArray[h].type === "MAIN") {
    const mainMesh = new THREE.Mesh(geom1, material)
    mainMesh.position.set(100, 100, markerArray[h].zAxis)
    this.scene.add(mainMesh)
  } else {
    const smallMesh = new THREE.Mesh(geom2, material)
    smallMesh.position.set(100, 100, markerArray[h].zAxis)
    this.scene.add(smallMesh)
  }
}

  1. Object creation in JavaScript is expensive! don’t create unnecessary objects in the render loop - make sure you are doing as little work as possible in your render loop to ensure everything runs smoothly @ 60fps. Creating objects in the render loop increases the MS needed to render each frame.

  2. Always use BufferGeometry instead of Geometry as it’s faster. The same goes for the pre-built objects, always use the buffer geometry version (BoxBufferGeometry rather than BoxGeometry). BufferGeometry is an efficient representation of mesh, line, or point geometry. Includes vertex positions, face indices, normals, colours, UVs, and custom attributes within buffers, reducing the cost of passing all this data to the GPU.

  3. Avoid using common text-based 3D data formats, such as Wavefront OBJ or COLLADA, for asset delivery. Instead, use formats optimised for the web, such as glTF using Draco mesh compression. blackthread.io  have a web based gltf conversion tool which you can use to convert various 3D formats into .gltf & .glb formats. You can use Analytical Graphics Inc’s gltf-pipeline tool to draco compress your .gltf or .glb model. You can install this globally onto your machine and then compress your model using the following command through the cli-tool.

// https://github.com/AnalyticalGraphicsInc/gltf-pipeline
// -d flag in the cli-tool turns on Googles Draco model compression.
$ gltf-pipeline -i inputModel.gltf -o outputModel.gltf -d

  1. Objects at the same exact same position cause flickering. Flickering may also occur when the objects at the same depth are being occluded by the cameras far plane. Offsetting values by a tiny amount like 0.000001 to make things look like they are in the same position, but keep your GPU happy.
 randomOffsetVal(value) {
    return value + Math.floor(Math.random() * 10000) * 0.000001;
  }

  1. Make your camera frustum as small as possible for better performance. Don’t put things right on the far clipping plane (especially if your far clipping plane is really big), this can cause flickering. You can set a smaller camera on load based on screen resolution the following code sets the camera for mobile, <= 1080px & > 1080px
let FOV
let FAR
let NEAR = 400

// Mobile camera
if (window.innerWidth <= 768) {
  FOV = 50
  FAR = 1200
  // 769px - 1080px screen width camera
} else if (window.innerWidth >= 769 && window.innerWidth <= 1080) {
  FOV = 50
  FAR = 1475
  // > 1080px screen width res camera
} else {
  FOV = 40
  FAR = 1800
}

this.camera = new THREE.PerspectiveCamera(
  FOV,
  window.innerWidth / window.innerHeight,
  NEAR,
  FAR
)

  1. Selecting powerPreference: “high-performance” when creating renderer. This may make a users system choose the high-performance GPU, in multi-GPU systems.

  2. I found in my use-case that disabling AA (antialiasing) resulted in a considerable performance bump on macs with retina displays. As retina / high end displays have such a high pixel density there is very little visual difference between having AA on/off. Below is a hacky solution for checking for a high end display and toggling AA.

let pixelRatio = window.devicePixelRatio
let AA = true
if (pixelRatio > 1) {
  AA = false
}

this.renderer = new THREE.WebGLRenderer({
  antialias: AA,
  powerPreference: "high-performance",
})

  1. Use as few lights as possible in the scene. Avoid adding and removing lights from your scene, since this requires the WebGLRenderer to recompile the shader programs. HemisphereLight & DirectionalLights are lightweight.

  2. The built-in three.js materials have performance/quality tradeoffs. Use the highest quality material you can afford, and switch to lower quality materials when you need to.

  • MeshStandardMaterial slowest/highest quality
  • MeshPhongMaterial
  • MeshLambertMaterial
  • MeshBasicMaterial fastest/lowest quality (can’t receive shadows)
  1. The following code can be used to render many SVGs into a scene as a single bufferGeometry
const svgArray = [
  { filename: "SVG1", xAxis: 150, yAxis: 125, zAxis: 100 },
  { filename: "SVG2", xAxis: 150, yAxis: 100, zAxis: 200 },
  { filename: "SVG3", xAxis: 150, yAxis: 100, zAxis: 300 },
  { filename: "SVG4", xAxis: 150, yAxis: 150, zAxis: 400 },
  { filename: "SVG5", xAxis: 150, yAxis: 125, zAxis: 500 },
]

const material = new THREE.MeshBasicMaterial({
  color: 0xffffff,
  side: THREE.DoubleSide,
})

let singleLogoGeometry = new THREE.Geometry()

for (let s = 0; s < svgArray.length; s++) {
  let index = s
  loaderSVG.load(`./assets/svg/${svgArray[s].filename}.svg`, function(data) {
    let paths = data.paths

    for (let i = 0; i < paths.length; i++) {
      let path = paths[i]
      let shapes = path.toShapes(true)

      for (let j = 0; j < shapes.length; j++) {
        let geometry = new THREE.ShapeGeometry(shapes[j])
        let mesh = new THREE.Mesh(geometry, material)
        mesh.position.set(
          svgLogoArray[index].xAxis,
          svgLogoArray[index].yAxis,
          svgLogoArray[index].zAxis
        )
        singleLogoGeometry.mergeMesh(mesh)
      }
    }

    let bufferGeometrySVG = new THREE.BufferGeometry().fromGeometry(
      singleLogoGeometry
    )

    let meshSVG = new THREE.Mesh(bufferGeometrySVG, material)
    game.scene.add(meshSVG)
  })
}

If you have any of your own performance optimisations or think these could be improved be sure to drop me a message or leave a comment.

Happy Coding!


Adam G Robinson

Adam G Robinson
Crafter. Explorer. Coder. 🇬🇧