🎉 Welcome to Axiome Blog, a blog about creative development and vulgarization of 3D theory and more.
Articles
Optimizing 3D Web Graphics: A Deep Dive into Indexed Geometry in Three.js

Indexed geometry cover image

Optimizing 3D Web Graphics: A Deep Dive into Indexed Geometry in Three.js

Abstract

Welcome to our deep dive into the fascinating world of three.js, your go-to for crafting stunning 3D web graphics! Today, we’re comparing two heroes of geometry: indexed and non-indexed. Have you ever wondered why some web scenes zip by smoothly while others stutter? It often boils down to how the scenes are built—specifically, whether they use indexed or non-indexed geometry. While non-indexed geometry keeps things simple and straightforward, it can be a bit of a memory hog, duplicating vertices that bulk up your scene. On the flip side, indexed geometry, though a tad complex, works wonders in boosting GPU efficiency and cutting down on unnecessary memory use. Join me as we unpack these concepts, explore handy techniques like mergeVertices(), and share tips on how to sleekly manage 3D objects in your web projects. Ready to optimize your scenes and enhance your user’s experience? Let’s get started!

What is Non-Indexed Geometry?

How is the geometry represented ?

Let's get back to the basics. In three.js, the BufferGeometry is what contains what structures the mesh. The BufferAttribute position contains all the vertices of the mesh inside of it. How does the computer knows which vertices goes with which ?

It takes it in order. 3 by 3. Why by 3 ? because it forms a triangle. Note, that the order of the 3 vertices matters. It gives the direction of the face, and enables us to do Face culling.

The winding order gives the direction of the face, either the triangles are given counter-clockwised or clock-wised.

Winding Order diagram

So everything is triangles for the GPU. Here is some position buffer to represent a triangle: Position buffer diagram

Duplicated vertices

Let's take the next simplest case after representing just a triangle on the GPU (3 vertices). The quad

It is composed of 4 points you will say. Not for the GPU with a non indexed geometry: It's 6 vertices. Oh snap...

A quad is made of two separated triangles with the duplication of vertices Here would be some representation in the position buffer of the quad.

Structure Quad diagram

It is where the indexed geometry because useful.

How to Index Geometry?

Face index buffer

When you index the geometry you are telling to the GPU, you need to add a new buffer that will give the faces of your geometry by giving the index of the vertices composing the face that are store in the position buffer.

That way, each duplicate from the previous step is stored one time in the position buffer, and its index is used multiple time in the index buffer for each face it belongs to.

Here is the two buffers for the quad:

Index buffer diagram

Effects of Indexed Geometry Over Non-Indexed

Normal smoothing (interpolation)

With a non-indexed geometry, the generated faces from the mesh are separated. The GPU does not know which faces are contiguous. The normal that are given by the face are not interpolated. Why does it matters ? The face 1 and the face 2 that are neighbors will have sharp edges because there will be a harsh transition between the normal to the face 1 and the normal to the face 2.

Non indexed normals diagram

When you index the geometry, you will get something really cool: normal interpolations. The faces are now contiguous for the GPU. So, it will interpolate the normals of the face 1 to the face 2. This way, the lighting will not get harsh change, and it will smooth out the edge for a more smooth geometry look, with the exact same mesh.

Interpolated normals diagram

Also, you can get better performance, because you store a lot less geometry in the position buffer. So it can be also in addition to the aesthetic-improved look a nice way to improve the performances of the application (network download speed, memory footprint, shader computation... )

Non-indexed vs Indexed Geometry | source - wikimedia source - wikimedia (opens in a new tab)

Drawbacks

Data complexity

The index buffer is an additional layer of abstraction that gives an additional complexity. If your mesh is something that changes dynamically and often, it can become challenging to maintain an index of the common vertices. It can be also harder to debug for the humain brain, because it is more difficult to navigate the concept. The non-indexed position attribute is much more simpler to debug due to a more naive implementation of the geometry. To modify a vertex it is way more direct and you need only to pay attention to one triangle at the time.

Potential memory overhead for indices

Although it reduces the amount of vertex data in the position buffer, it creates a new buffer to store the indices. In some edges cases, the indexation can take more room that the position buffer. In the case the mesh has not a lot of shared vertices, it is costly to maintain an index buffer for almost no benefits.

GPU processing

Everytime you need to query a vertex, there is an additional lookup that the GPU has to perform in the index buffer to find the corresponding vertex in the position buffer. This could be a bottleneck on theoretical architecture that are not optimized to do so.

It can increase the number also of cache misses. Modern GPU are well-optimized to cache data during rendering (when you request some data, it loads more data and put it in some faster memory like cache registries. It enables the requests to go faster if the extra data that has been loaded is the data that is required). If the index buffer does not follow an optimal order, it can lead to jump in the memory that would inevitably create loading from slower memory and slower performances.

Algorithmic constraints

When a vertex is shared by multiple faces, it becomes harder to modify its position, because it will affect every face. For effects that you would want to make let's say with vertex shaders, independant verticees can become very helpful (for example, if you want to make a dissolve effect like if each triangle of the mesh is a particule, having shared vertices would make some distortion in the faces). You may need to convert your geometry to non indexed geometry, adding additional computation, memory footprints. It is easy to do in three.js thanks to the toNonIndexed() method that should be available on the BufferGeometry.

Practical way of indexing geometry

mergeVertices()

This function is available in three.js using the BufferGeometryUtils.mergeVertices() (opens in a new tab) function. It is indexing the geometry based on common vertices (relative to a tolerance). By giving a precision, you can merge more or less distant vertices that will be considered as common. It can be also a nice way to come to a low poly version of your mesh based on an higher tolerance.

Be aware that this function takes in account every attribute of the BufferGeometry. If you have computed normal buffer attribute associated to the vertices, it will compare the position buffer attribute in addition to the normal buffer attribute, which can lead to no merge at all, or holes in the geometry. Basically, you often need to remove all buffer attributes, except the position one, so the only comparison that will be done in the mergeVertices, is on the position and distance based on your tolerance.

Then you will recompute the normal buffer attribute, with computeVertexNormals() to get smooth lighting.

To avoid this troubles, I adapted a code I found online the typescript way from Fyrestar (opens in a new tab) that he put on a the three.js forum (opens in a new tab).

My library: three-geometry-welder

My library is available through npm installation through npm install three-geometry-welder and the source code is available here (opens in a new tab).

You just need to create a BufferGeometryWelder and pass your geometry into it.


_10
const geometryWelder = new BufferGeometryWelder(geometry, false, 6);

The first argument is the geometry, the second is full indexing or not (it means do you take only the position buffer as a reference of the indexing, or do you take the whole buffer attribute list) and the third one is the precision (to decide if we need to merge two vertices. The welder is more tolerant with a lower value and will simplify the geometry more roughly).

This is not touching to the Prototype of the BufferGeometry by adding a toIndexedGeometry() function. You would want to dispose the geometry once you call getMergedBufferGeometry() that will give you a completely new BufferGeometry. This way you avoid memory leaks. Call on geometry.dispose()

Here a complete example on how to use it:


_13
_13
import { BufferGeometryWelder } from "three-geometry-welder";
_13
_13
//...
_13
_13
const geometry = mesh.geometry;
_13
_13
const geometryWelder = new BufferGeometryWelder(geometry, false, 6);
_13
const indexedGeometry = geometryWelder.getMergedBufferGeometry();
_13
_13
const indexedMesh = new Mesh(indexedGeometry, mesh.material)
_13
_13
geometry.dispose();

Conclusion

Thanks for sticking with me through this exploration of indexed versus non-indexed geometry in three.js! It’s clear that embracing indexed geometry could be a game-changer for your 3D web applications. By minimizing vertex duplication and smoothing out transitions with normal smoothing, indexed geometry not only saves precious memory but also streamlines your GPU’s workload. If you're excited to dive even deeper and elevate your three.js skills, why not check out some of our other adventures? For instance, our journey through creating fog effects with depth buffers (opens in a new tab) can add that extra touch of realism to your scenes. Or, if you’re into pushing the creative envelope, our guide on integrating raymarching effects (opens in a new tab) will open up new vistas in visual storytelling. Together, let’s continue to transform complex code into captivating experiences!