Expeditione: How I packed an Interactive 3D Encyclopedia into 1MB and 9 Draw Calls

Greetings everyone,

I’m currently solo-building Expeditione ( https://expeditione.fun ), an Interactive 3D Encyclopedia on the web. The mission is simple : I want to make learning and the web entirely immersive and memorable again.

To do that, it has to run completely flawlessly on any device. While building the landing page, I managed to fit the entire experience—3D geometry, textures, custom GLSL shaders, audio, code, all of it—into ~1MB.

image

But initially, my draw calls were sitting at a CPU-melting 168.

I refused to accept that. I completely tore down the rendering pipeline and managed to vaporize the workload down to exactly 9 draw calls without losing a single polygon of visual quality.

Here is exactly how I bypassed the exporter and split the architecture to achieve single digits :

Phase 1: The Known (Dynamic Instancing)

Result: 168 → 35 Draw Calls

The scene features dynamic foliage (vines, leaves) that use custom vertex shaders to sway in the wind. Initially, exporting these directly was killing the renderer. Instead of fixing it in code, I used the gltf-transform CLI to structurally re-engineer the .glb locally before it ever touched Three.js :

gltf-transform instance raw.glb temp.glb && gltf-transform draco temp.glb opt.glb

This collapsed dozens of identical meshes into instanced meshes. It saved the CPU, but my static terrain (the land, ship, and environment) was still heavily unoptimized.

Phase 2: Material Purging

Result: 35 → 9 Draw Calls

The deep engine trap that most devs fall into is that the glTF exporter slices your geometry based on Material Slots. 1 Baked Texture + 10 Old Blender Materials = 10 separate Primitives = 10 Draw Calls in Three.js. Even if you override the material in Three.js later, the geometry is already fractured.

To fix this, I developed what I call the Material Purge—a ruthless workflow for the Static Base of the scene :

  1. Bake: Bake the final scene to a single texture atlas (1k or 2k).

  2. The Purge: In Blender, delete every single individual material slot from your static meshes.

  3. The Dummy: Create one single “Master Material” and link it to everything you want merged (or have no materials).

  4. Join & Export: Join all the static objects (Ctrl + J) and export the .glb. Because the exporter only sees one material slot, it glues all vertices into one solid block.

  5. Three.js: Load the model, traverse the children, and apply your baked MeshBasicMaterial.

The math: The exporter sees 1 material slot → 1 primitive → 1 Mesh → exactly 1 Draw Call.

The same can be verified using Spector.js

Note : Texture Atlasing alone does NOT fix this. Most devs bake a single atlas but leave the original material slots on their meshes. When that happens, the glTF exporter still slices the geometry, ruining your draw calls. The Material Purge forces the exporter to actually respect the atlas by destroying the slots entirely.

The Numbers That Make Me Happy

  • Start: 168 draw calls

  • After instancing: 35 draw calls

  • After the Material Purging: 9 draw calls (~95% reduction)

By strictly dividing the architecture into Instanced Details (via CLI) and a Purged slot, the GPU is practically asleep.

You can explore the live 1MB world here: [ https://expeditione.fun/ ]
Hopefully, this helps anyone else out there who is wondering why their baked scene is somehow generating 100+ draw calls.

3 Likes

This is honestly one of the cleanest breakdowns of draw call optimization I’ve seen in a while.

The key insight here is something a lot of people miss: texture atlasing alone doesn’t solve anything if the material slots are still there. The exporter fragmentation is the real enemy, not just the texture count. Your “Aureon Method” basically forces the pipeline to behave the way we expect, instead of how Blender silently structures it.

Also really like that you split the problem into two clear systems instead of trying to brute force everything in Three.js:
dynamic elements handled via instancing at the GLTF level
static environment collapsed into a single primitive through material purging

That separation is what makes the 9 draw calls actually achievable without hacks.

The 1MB total package is just as impressive. Geometry, shaders, audio, all packed that tight while still looking polished is not easy at all. Feels like old school demoscene energy but applied to the web.

Curious though, how are you handling lighting across the baked static mesh vs the instanced foliage? Are you fully baked for the base and then shader-driven lighting for the animated parts, or mixing in any real-time lights?

Also would be interesting to know if you hit any limits with culling or bounding volumes after joining everything into a single mesh. That’s usually the tradeoff when going extreme on draw call reduction.

Really solid work, this is the kind of pipeline thinking more Three.js devs should pay attention to.

1 Like

Oh WOW! This posting is as tight as the process you documented. I need to reconsider portions of what I do. Most appreciated.

1 Like

Thanks. The lighting is fully baked. So, no real time lights are being used.
Regarding culling, I’ve left moving the parts as it is. Since it’s mostly static diorama, it’s quite easy to hit the low draw calls. In The current Ancient Egypt expedition I’m building, I’ve managed to fit about a similar diorama with much more life (including 7 rigged and animated characters) in about ~30 draw calls. And since the geometry is really light, the GPU crushes through most of it quite easily.

1 Like

Welcome and good luck!

Appreciate it :). I dont know if you post to the discord.com threejs channel. There are some pretty smart people who would probably be interested in your posting.

Ah, thanks, I’ve just joined.