Rendering Multiple Geometries

Introduction
In this article, we will introduce methods to optimize and reduce rendering load when rendering multiple geometries.
Abstract
1. The most basic way to create an object corresponding to a shape in three.js is to create a single mesh through geometry and material corresponding to the mesh.
2. At this time, due to the excessive number of draw calls, it was too slow, so we attempted the first optimization by merging geometries by material to reduce the number of meshes.
3. Additionally, for geometries that can be generated through transformations such as move, rotate, and scale from a reference geometry, we attempted to improve memory usage and reduce load during geometry creation by using instancedMesh instead of merging.
1. Basic Mesh Creation
Let's assume we are rendering a single apartment. There are many types of shapes that form an apartment, but here we will use a block as an example.
When rendering a single block as shown below, there is no need for much consideration.
...
const [geometry, material] = [new THREE.BoxGeometry(20, 10, 3), new THREE.MeshBasicMaterial({ color: 0x808080, transparent: true, opacity: 0.2 })];
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
...
Draw Calls: 0
Mesh Creation Time: 0
However, when rendering an entire apartment, the geometry of a single unit, such as windows and walls, often exceeds 100,
which means that if you want to render 100 units, the number of geometries can easily exceed five digits.
This is the case when creating one mesh per geometry for rendering.
Below is the result of rendering 10,000 shapes. Each geometry was created by cloning and translating a base shape.
// The code below is executed 5,000 times. The number of meshes added to the scene is 10,000.
...
const geometry_1 = base_geometry_1.clone().translate(i * x_interval, j * y_interval, k * z_interval);
const geometry_2 = base_geometry_2.clone().translate(i * x_interval, j * y_interval, k * z_interval + 3);
const cube_1 = new THREE.Mesh(geometry_1, material_1) ;
const cube_2 = new THREE.Mesh(geometry_2, material_2);
scene.add(cube_1);
scene.add(cube_2);
...
Draw Calls: 0
Mesh Creation Time: 0
2. Merge Geometries
Now, I will talk about one of the commonly used methods for rendering optimization, which is reducing the number of meshes.
In graphics, there is a concept called draw call.
The CPU finds the shapes to be rendered in the scene and requests the GPU to render them, and the number of these requests is called a draw call.
This can be understood as the number of requests to render different meshes with different materials.
Here, the CPU, which is not specialized in multitasking, may experience bottlenecks as it has to handle many calls simultaneously.

Source: https://joong-sunny.github.io/graphics/graphics/#%EF%B8%8Fdrawcall
By manipulating the scene with the mouse, you can check the difference in draw calls between this case and the previous case.
In the above case, due to reasons such as the max distance of the camera set or shapes going out of the screen, it is not always called 10,000 times,
but in most cases, a significant amount of calls occur.
Here, since two materials were used, we used a method to merge geometries.
Therefore, as you can see, the draw call here is fixed at a maximum of 2.
// The code below is executed once. The number of meshes added to the scene is 2.
...
const mesh_1 = new THREE.Mesh(BufferGeometryUtils.mergeBufferGeometries(all_geometries_1), material);
const mesh_2 = new THREE.Mesh(BufferGeometryUtils.mergeBufferGeometries(all_geometries_2), material_2);
scene.add(mesh_1);
scene.add(mesh_2);
...
Draw Calls: 0
Mesh Creation Time: 0
You can directly confirm that the rendering load has been improved.
3. InstancedMesh
Above, we confirmed a method to reduce the load from the perspective of draw calls by merging geometries.
These shapes are composed only of shapes using clone and translate, so the base form is the same.
This also occurs when rendering objects such as buildings or trees.
For example, when there is no need for different forms per floor, or when parts are copied to form the walls of a floor, or when a single type of tree is used with only size or direction changes.
It can also be applied when the same form of plane is generated into multiple buildings.
In this case, you can further optimize by using instancedMesh, which is efficient in terms of memory and time, reducing the number of geometry object creations.
There is a concept called Instancing in graphics.
Instancing allows you to render similar geometries multiple times by sending the data to the GPU once
and additionally sending the transformation information of each instance to the GPU, which means you only need to create the geometry once.
Game engines like Unity also achieve performance optimization using this concept through a feature called GPU instancing.

Rendering multiple similar shapes (Unity GPU Instancing)
Source: https://unity3d.college/2017/04/25/unity-gpu-instancing/
three.js also has a feature called InstancedMesh that corresponds to this.
The draw call is the same as the geometries merge case mentioned above, but there is a significant advantage in terms of memory and rendering time as there is no need to create each geometry separately.
The diagram below simplifies the meshes sent to the GPU in processes 1, 2, and 3.

Although only translation was used in the example below, you can also use transformations such as rotate and scale.
In addition to reducing the creation of separate meshes, you can also reduce the creation of separate geometries, confirming a significant difference in creation time.
// You need to add the number of shapes to be used as the last argument of instancedMesh.
const mesh_1 = new THREE.InstancedMesh(base_geometry_1, material_1, x_range * y_range * z_range);
const mesh_2 = new THREE.InstancedMesh(base_geometry_2, material_2, x_range * y_range * z_range);
let current_total_index = 0;
for (let i = 0; i < x_range; i++) {
for (let j = 0; j < y_range; j++) {
for (let k = 0; k < z_range; k++) {
mesh_1.setMatrixAt(current_total_index, new THREE.Matrix4().makeTranslation(i * x_interval, j * y_interval, k * z_interval));
mesh_2.setMatrixAt(current_total_index, new THREE.Matrix4().makeTranslation(i * x_interval, j * y_interval, k * z_interval + 3));
current_total_index++;
}
}
}
scene.add(mesh_1);
scene.add(mesh_2);
Draw Calls: 0
Mesh Creation Time: 0