BatchedMesh for High-Performance Rendering in Three.js

Published on 23 Jan, 2025 | ~10 min read | Demo

If you're planning to create a game with Three.js, like an RPG where players explore a world filled with majestic trees and flowers, or maybe an app designed to wow users with stunning particle effects featuring thousands of colorful, shiny particles, then this article is for you!

In this tutorial, we'll learn how to add a large number of meshes to a Three.js scene without impacting the application's performance.

So What is a BatchedMesh?

A BatchedMesh is a special type of mesh that allows you to render a large number of meshes using a single render call.

"Yeah, sure... but what exactly is a render call?"

Well, I’ve already explained what a render call is in detail in this article, so I highly recommend you check it out!

Still, as a quick reminder, a render call is the instruction from the CPU to the GPU to draw an object on the screen.

That said, sometimes when you need to render the same object multiple times—like a piece of grass or a tree—repeating the same instruction over and over can negatively impact the application's performance.

Render calls and performance
Render calls and performance

A commonly known solution for this is instanced rendering, where the instruction is sent to the GPU only once.

Instanced rendering
Instanced rendering

As you can see, instancing is an excellent solution performance-wise, but it does come with a limitation.

The problem with instanced rendering is that you're limited to using a single geometry for all your instances.

Instancing limitation
Instancing limitation

And this is where batching comes in handy—it’s a technique that makes it possible to render multiple meshes, which share the same material but have different geometries, using a single render call.

Batching

I assume you have a Three.js project up and running, but if not, no worries. Just grab my Three.js boilerplate and copy-paste the following snippet:

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { GLTFLoader } from 'three/examples/jsm/Addons.js';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// Sets the color of the background.
renderer.setClearColor(0xfefefe);

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  45,
  window.innerWidth / window.innerHeight,
  0.1,
  2000
);

// Sets orbit control to move the camera around.
const orbit = new OrbitControls(camera, renderer.domElement);

// Camera positioning.
camera.position.set(0, 30, 400);
// Has to be done everytime we update the camera position.
orbit.update();

const ambientLight = new THREE.AmbientLight(0x333333);
scene.add(ambientLight);

const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(0, 10, 10);
scene.add(directionalLight);

const gridHelper = new THREE.GridHelper(1000, 500);
scene.add(gridHelper);

function animate() {
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

window.addEventListener('resize', function () {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

Batching Meshes Created Using Three.js Built-In Geometries

Let's say we want to add 5,000 meshes to the scene using a BatchedMesh.

That said, just like a regular mesh, we’ll first need to create the geometry(ies) and the material that will be shared by the 5,000 instances.

const boxGeometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshPhongMaterial({ color: 0x00ff00 });

Next, we’ll create and initialize the BatchedMesh with 4 values.

const maxInstanceCount = 1;
const maxVertexCount = 24;
const maxIndexCount = 36;

const batchedMesh = new THREE.BatchedMesh(
  maxInstanceCount,
  maxVertexCount,
  maxIndexCount,
  material
);
  1. maxInstanceCount: limits the number of instances.
  2. maxVertexCount: caps the total number of vertices per geometry.
  3. maxIndexCount: caps the total number of indices for defining faces per geometry.
  4. material: the material used for the instances.

To understand what exactly an index is, you’ll need to learn a bit about the basics of raw WebGL.

"Are you suggesting I need to dive into WebGL books right now before continuing with this article?!"

No, you don't need to but it is recommended.

"Okay, I'll tackle that later. For now, can you explain how to get these values?"

maxInstanceCount is the number of instances you want in your scene, and material is the material you want them to use—both are entirely up to you to decide.

To determine the number of vertices in the geometry, we’ll use the following line of code:

// 24
const maxVertexCount = boxGeometry.attributes.position.count;

To determine the number of indices in the geometry, we’ll use this line:

// 36
const maxIndexCount = boxGeometry.index.count;

Note: You don’t need to set the exact number of indices and vertices. As long as the value is higher than the actual amount, it will work. However, it’s recommended to use the exact values for better performance. For example, setting 30,000 when your meshes only require 10,000 vertices wastes resources for no reason.

Next, we’ll need to assign the geometry to the BatchedMesh.

const boxGeometryId = batchedMesh.addGeometry(boxGeometry);

Now, to create an instance, we’ll use the addInstance() method, which takes the id of the geometry as its argument.

const boxInstanceId = batchedMesh.addInstance(boxGeometryId);

This will create a box instance.

To create a sphere instance, we’ll need to first create the sphere geometry, add it to the BatchedMesh, and then call addInstance() with that geometry.

const sphereGeometry = new THREE.SphereGeometry();
const sphereGeometryId = batchedMesh.addGeometry(sphereGeometry);
const sphereInstanceId = batchedMesh.addInstance(sphereGeometryId);

This increases the number of instances, hence the number of vertices and indices. If you figured this out on your own, know that I’m really proud of you!

const boxGeometry = new THREE.BoxGeometry();
const sphereGeometry = new THREE.SphereGeometry();
const material = new THREE.MeshPhongMaterial({ color: 0x00ff00 });

const maxInstanceCount = 2;
const maxVertexCount =
  boxGeometry.attributes.position.count +
  sphereGeometry.attributes.position.count;
const maxIndexCount = boxGeometry.index.count + sphereGeometry.index.count;

The final step is to add the BatchedMesh to the scene.

scene.add(batchedMesh);
BatchedMesh
BatchedMesh

Now, to move the instances—since they're all overlapping at the origin of the scene—we'll need to use a matrix.

Actually, the matrix won't just be used for positioning the instances—it will also set their scale and rotation.

const matrix = new THREE.Matrix4();

const position = new THREE.Vector3(2, 0, 0);

const rotation = new THREE.Euler(0, Math.PI / 3, 0);
const quaternion = new THREE.Quaternion();
quaternion.setFromEuler(rotation);

const scale = new THREE.Vector3(2, 1, 1);

The snippet above creates a matrix that is populated with the position vector to set the instance’s position (duh), the quaternion for its rotation, and the scale vector to define its size.

You may ask why should we use a Quaternion to set the rotation.

Well, that’s because the compose() method, which we use to assign values to the matrix, specifically requires a quaternion to set the rotation.

matrix.compose(position, quaternion, scale);

Next, we’ll assign this matrix to a specific instance by calling setMatrixAt(), which takes the instance's id and the matrix as arguments.

batchedMesh.setMatrixAt(boxInstanceId, matrix);
Applying geometric transformations to an instance
Applying geometric transformations to an instance

Full example.

Now, you’re probably not going to use a BatchedMesh just to create a couple of objects and manually apply the transforms to them, right?

That said, let’s create a function that takes a matrix as an argument and returns a matrix with randomized values.

const matrix = new THREE.Matrix4();
const position = new THREE.Vector3();
const rotation = new THREE.Euler();
const quaternion = new THREE.Quaternion();
const scale = new THREE.Vector3();

function randomizeMatrix(mat) {
  position.x = Math.random() * 400 - 200;
  position.y = Math.random() * 400 - 200;
  position.z = Math.random() * 400 - 200;

  rotation.x = Math.random() * 2 * Math.PI;
  rotation.y = Math.random() * 2 * Math.PI;
  rotation.z = Math.random() * 2 * Math.PI;

  quaternion.setFromEuler(rotation);

  scale.x = scale.y = scale.z = 0.5 + Math.random() * 0.5;

  return mat.compose(position, quaternion, scale);
}

Next, we’ll use a for loop to create the desired number of instances.

for (let i = 0; i < maxInstanceCount; i++) {
  let id;
  if (i % 2 === 0)
    id = batchedMesh.addInstance(boxGeometryId);
  else
    id = batchedMesh.addInstance(sphereGeometryId);

  batchedMesh.setMatrixAt(id, randomizeMatrix(matrix));
}

Now, we’ll update the maxInstanceCount constant with a fairly large number.

// 25k boxes + 25k spheres
const maxInstanceCount = 50000;

const maxVertexCount =
boxGeometry.attributes.position.count +
sphereGeometry.attributes.position.count;

const maxIndexCount =
boxGeometry.index.count +
sphereGeometry.index.count;

Finally, remove any instances added outside the loop. This is important because, if you don’t, you’ll exceed the maximum numbers we just set.

const boxGeometryId = batchedMesh.addGeometry(boxGeometry);
// const boxInstanceId = batchedMesh.addInstance(boxGeometryId);

const sphereGeometryId = batchedMesh.addGeometry(sphereGeometry);
// const sphereInstanceId = batchedMesh.addInstance(sphereGeometryId);
50k instances
50k instances

Full example.

Batching Models

Batching a model is quite similar to batching Three.js built-in geometries.

First, you need to ensure that the model you want to clone consists of a single geometry.

Alternatively, you can create clones of its individual parts, where each part is a single geometry.

In short, we can’t create a BatchedMesh from a complex model unless we do so for each individual geometry that makes up the model.

In the example below, I’ll target a tree from a model that contains multiple trees. The good news is that each tree is composed of a single geometry.

Model
Model
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { GLTFLoader } from 'three/examples/jsm/Addons.js';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// Sets the color of the background.
renderer.setClearColor(0xfefefe);

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  45,
  window.innerWidth / window.innerHeight,
  0.1,
  2000
);

// Sets orbit control to move the camera around.
const orbit = new OrbitControls(camera, renderer.domElement);

// Camera positioning.
camera.position.set(0, 30, 400);
// Has to be done everytime we update the camera position.
orbit.update();

const ambientLight = new THREE.AmbientLight(0x333333);
scene.add(ambientLight);

const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(0, 10, 10);
scene.add(directionalLight);

const gridHelper = new THREE.GridHelper(1000, 500);
scene.add(gridHelper);

const matrix = new THREE.Matrix4();
const position = new THREE.Vector3();
const rotation = new THREE.Euler();
const quaternion = new THREE.Quaternion();
const scale = new THREE.Vector3();

function randomizeMatrix(mat) {
  // position.x = Math.random() * 400 - 200;
  // position.y = Math.random() * 400 - 200;
  // position.z = Math.random() * 400 - 200;
  position.x = Math.random() * 1000 - 500;
  position.z = Math.random() * 1000 - 500;

  // rotation.x = Math.random() * 2 * Math.PI;
  // rotation.y = Math.random() * 2 * Math.PI;
  // rotation.z = Math.random() * 2 * Math.PI;
  rotation.x = -Math.PI / 2;
  quaternion.setFromEuler(rotation);

  scale.x = scale.y = scale.z = 0.5 + Math.random() * 0.5;

  return mat.compose(position, quaternion, scale);
}

const loader = new GLTFLoader();
loader.load('/low_poly_trees.glb', function (glb) {
  const model = glb.scene.getObjectByName('_6_tree__6_tree_0');

  const geometry = model.geometry;
  const material = model.material;

  const maxInstanceCount = 5000;
  const maxVertexCount = geometry.attributes.position.count;
  const maxIndexCount = geometry.index.count;

  const tbatchedMesh = new THREE.BatchedMesh(
    maxInstanceCount,
    maxVertexCount,
    maxIndexCount,
    material
  );

  const treeGeometryId = tbatchedMesh.addGeometry(geometry);

  for (let i = 0; i < maxInstanceCount; i++) {
    const id = tbatchedMesh.addInstance(treeGeometryId);
    tbatchedMesh.setMatrixAt(id, randomizeMatrix(matrix));
  }

  scene.add(tbatchedMesh);
});

function animate() {
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

window.addEventListener('resize', function () {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});
5k trees
5k trees

To create a collection of different trees, like in the demo, you’d need to create a new BatchedMesh instance for each type of tree.

Wrap Up

And with that, we’ve reached the end of this article.

You now have a powerful tool in your 3D web application development toolkit. Want to learn more? Check out the related content section below!

If you’d like to support the site and the YouTube channel, buying me a coffee would be super cool (but totally optional).

Happy coding!

Buy me a coffee

Credits and Resources

Related Content