When is it actually beneficial to use LOD in Three.js for performance?

Summary (TL;DR):

I’m trying to understand when it really makes sense to use THREE.LOD to improve performance in large industrial-style 3D scenes.


Context:

In my application, users can create an industrial plant layout — beams, pipes (L, U, C shapes, reducers, bends, etc.) — each with varying dimensions.

So, every element is a unique geometry, created dynamically using Three.js primitives (e.g., CylinderGeometry for pipes).

Users can add hundreds or thousands of such elements and modify them at any time (change length, diameter, etc.).

My goal is to improve performance when the scene grows large.


In which Scenario LOD should be used

  1. For Pre-created LOD models:

    Have multiple versions of each model (e.g., .glb or .obj) with different polygon counts and assign them as LOD levels via the THREE.LOD API.

  2. Dynamic simplification at runtime:

    When a new element is created, generate 2–3 simplified versions of it using:

    • SimplifyModifier
    • Or, when using primitives, by reducing geometry parameters (e.g., fewer radial segments for a cylinder).

    Then assign these to the LOD system.

    Whenever a user modifies the base geometry, the LOD meshes would also need to stay consistent.


Observation:

In my tests, LOD didn’t improve performance — in fact, it slightly worsened it.

From what I’ve learned, Three.js keeps all LOD meshes (for all levels) in GPU memory, even if they aren’t visible.


My Question:

Given this scenario — where many objects are created and modified dynamically —

is it practical to use LOD at all?

Or is the THREE.LOD approach meant more for predefined, static models (like buildings or large assets), not for scenes with thousands of dynamic elements?

I can share more details or test data if needed — I mainly want to know if LOD is conceptually the right fit for this type of use case, or if a different optimization approach would make more sense.

1 Like

Unless you have crazy detailed models and or really vast open spaces - LODs are probably the last optimisation to consider. And if you generate geometry at runtime, LODs indeed may just cause more trouble.

Instancing (ex. use 1 material for the entire scene and textures / shaders to adjust it’s looks on specific elements if needed) + batching + for cylindrical elements always a good idea to render them as just Lines if they are far away from the camera - there’s no way to distinguish between Line and cylinder far away, and the first one is way cheaper to render. If you’ve done all that, and removed all performance bugs / obvious bottle necks, then as a last resort you can use impostors (but it’s not trivial and an overkill in non-open-world cases, so make sure it’s just really needed :bow: )

2 Likes

If I’m not mistaken, it shouldn’t affect performance if the LOD meshes are just sitting in GPU memory. What matters is whether they are being rendered.

Are you able to see the number of draw calls in your scene with LOD meshes enabled versus disabled? I believe the number should be the same. If the draw calls go up when the LOD meshes are enabled, then you are actually rendering both the detailed mesh and the LOD mesh, which would degrade performance.

Please feel free to correct me on this. I’m making the assumption that LOD meshes should not increase draw calls, since they just “replace” meshes.

But to your question about whether LOD meshes should be used with dynamically created meshes, I think it should work fine. However, there is a per-mesh overhead that comes when you use LOD, since on each frame, three.js has to first measure the distance between the camera and the mesh, then actually do the “swapping” to the lower-poly version. This overhead grows as the number of meshes in your scene increases. Perhaps this overhead is what is causing the performance drop for you? Maybe you just have way too many objects using LOD, which is incurring overhead.

1 Like

@any_post

You mentioned having 1000s of elements. That most likely IS your issue. You simply cannot have 1000s of unique elements, each positioned, rotated, scaled uniquely, without doing some very specific optimizations.

There are many types of performance issues in the 3d app. aka “bottlenecks”.

The biggest/most common issue/bottleneck is “draw calls” and it sounds like you are performing 1000s of “draw calls” per frame.

In this case we would say your app is most likely “draw call bound”.. or bottlenecked by drawcalls.

The reason this is, is because each draw call forces a synchronization between your CPU and GPU, like a waiter delivering your dinner one bite at a time.

Instead you need to combine/merge multiple drawcalls into a single draw call. You can do this by merging their geometries into a single larger geometry. This usually requires them to share the same material, or at least be grouped into geometries by material. This reduces your 1000s of element drawcalls, to a drawcall per unique material type. If your materials are all unique, then you will have to find a way to avoid that.

Another useful approach to combat being draw call bound, is to use instancing, where instead of using a unique mesh for every pipe, you use the same mesh but repeated in different locations/rotations/scales/colors… (via a THREE.InstancedMesh)

This can turn 1000s of drawcalls into 1 draw call.

These 2 optimizations, merging / instancing alone will probably 10x-100x your performance.

If you do these, and performance is Still sluggish, then you may have “moved” the bottleneck elsewhere.

For instance, trying to render more than ~6 million triangles per frame. This is the bottleneck that Can be addressed by LODs.

The point is, usually optimizing for performance goes in roughly this order..

  1. reduce drawcalls.. <— you are most likely here

  2. reduce triangle count… ← This is a bottleneck that can sometimes be solved with LOD

  3. if you’ve done the above and your app is still slow, then you are likely performing too much work each frame in your javascript. Take work out of the javascript animationLoop/RAF.
    Don’t do raycasting every frame (for mouse hover). Consider using some spatial data structures to optimize your scene interactions/raycasting, like the excellent three-mesh-bvh library.

  4. reduce overdraw…

  5. reduce/optimize shader complexity/efficiency…

  6. reduce/optimize post processing…

4 Likes

@mjurczyk

That’s what I was thinking as well. With basic primitive geometries, directly switching to LOD doesn’t really seem useful — but I was exploring it purely for potential performance gains.

As you mentioned, generating geometries (and their simplified versions) at runtime adds a significant overhead. I think LODs would make more sense in my case when exporting the entire scene as a GLTF, and then just importing for viewing purposes. At that stage, I can pre-generate LOD meshes from exported GLTF and load them instead of creating them dynamically.


Yes, I do plan to keep a shared material for most elements.
I’ve also experimented a bit with instancing, and it definitely provides a noticeable performance boost. The only reason I haven’t adopted it more widely yet is that my application requires individual mesh selection and modification, which seems tricky with instanced meshes.

That said, I’m planning to explore this further — I suspect there might be ways to handle selection and editing even with instancing, but I’m not sure how to approach that yet.

if you any lead on this that will be really great

BTW thanks by providing your POV. it does help. cheers :smile:

@manthrax
That makes a lot of sense — thanks for breaking it down so clearly.

Yes, in my case, every element (pipe, beam, reducer, etc.) is currently a unique geometry created at runtime, with its own transform and unique material. So it sounds like I’m exactly in the situation you described — draw call bound.

LOD indeed didn’t help because it doesn’t reduce the number of draw calls, and now I understand why.

Merging geometries is an interesting idea, but since users can select and modify individual elements (change dimensions, replace a reducer with an elbow, etc.), I’ll need to think carefully about how to manage updates if I go that route.

I did try THREE.InstancedMesh earlier and saw a big performance jump, but I stopped short because of the same challenge — users need to interact with each instance (select, highlight, edit).

Is there any recommended approach or pattern for handling selection and editing of instanced meshes efficiently?
For example, maintaining some mapping between instance IDs and editable metadata, or temporarily converting an instance into a standalone mesh when it’s being edited?

That’s probably the direction I’ll need to explore next.

It’s always best to use LOD, especially for large virtual environments. Using Three.js’s mesh simplifier and mipmapping helps seamlessly scale asset geometry (it doesn’t need to be as advanced as Nanite). I usually implement LOD functionality in most of my Three.js projects, it eases the browser’s workload when rendering 3D assets, resulting in smoother performance, even with smaller models.

Demo

Source

1 Like

The most appropriate built-in abstraction for what you’re describing is probably THREE.BatchedMesh, since that allows you to show/hide and translate/rotate/scale instances of many different geometries. If you need different materials for some cases (highlighting? outlines?) just hide the affected objects from the batch temporarily and render them separately.

That said, there’s nothing magic about THREE.BatchedMesh. You could instead build your own batching solution using a large THREE.Mesh to represent many objects in a batch, and manage the vertex attributes or uniforms as needed to handle interactivity for objects (subsets of vertices) within the batch. That level of control might be ideal for many applications.

2 Likes

That said, there’s nothing magic about THREE.BatchedMesh. You could instead build your own batching solution using a large THREE.Mesh

This is somewhat true - the BatchedMesh class uses the “multiDraw” API which allows for submitting a list of ordered draw requests at once (though I agree it’s not magic :grin:). This means that it supports per-geometry sorting, frustum culling, visibility toggling, and instancing so you can avoid geometry duplication in memory. None of these things are really possible if you just merge all your geometry into one mesh. If you just want to cut down on draw calls, though, and your geometry is unique and doesn’t need to be toggled or culled then merging into one big static mesh could be a fine solution.

The one caveat is that there seems to be a performance bottleneck with multidraw in WebGL that will hopefully be fixed soon. See this Chromium issue (and +1 if it’s affecting your work!).

7 Likes

Agreed that multidraw indirect is very useful, and not easily possible in three.js without THREE.BatchedMesh.

That said, managing a shared vertex buffer and index buffer carefully can get you a very long way, and I think that’s sometimes missed in the optimization tips that go straight for higher-level APIs like InstancedMesh. For example, by updating the index buffer — not the vertex buffer —to sort or instance objects. Or handling basic interactivity (highlight on hover, visibility toggle, pos/rot/scale) by temporarily drawing indices in two batches (geometry.groups) excluding the affected “object”, and drawing that object separately until later writing final changes back to the buffers.

4 Likes