Babylon.js Thin Instances: GPU-Driven Instancing for Massive Mesh Counts
Babylon.js Thin Instances move per-instance matrices into a single GPU buffer, enabling 50k–100k mesh copies with one draw call. This blog covers the API, a rain particle example, and the critical trade-offs: no per-instance culling, shared material, and all-or-nothing buffer updates.
03 Oct 2025, 06:02 UTC

The Problem: Thousands of Identical Meshes Kill Frame Rate
You need to render a forest of 50,000 trees, a rainstorm of 100,000 droplets, or a battlefield littered with spent casings. Creating 50,000 separate Mesh objects chokes the CPU with draw-call overhead. Standard InstancedMesh helps by batching draws, but each instance still incurs a separate world-matrix upload and frustum-culling check. When the instance count climbs into five figures, the CPU side becomes the bottleneck.
Thin Instances: One Buffer, One Draw Call
Babylon.js Thin Instances move per-instance data entirely onto the GPU. You enable the feature on a single source mesh, then write world matrices (and optional custom attributes) into a vertex buffer that lives in GPU memory. The engine issues a single drawElementsInstanced call with instanceCount equal to your instance count. No per-instance JavaScript objects, no per-frame matrix uploads unless you explicitly update the buffer.
The API is declarative:
const tree = new MeshBuilder.CreateBox("tree", {size: 1}, scene);
tree.enableThinInstances();
for (let i = 0; i < 50000; i++) {
const matrix = Matrix.Compose(
new Vector3(1, 1, 1),
Quaternion.Identity(),
new Vector3(Math.random() * 1000, 0, Math.random() * 1000)
);
tree.thinInstanceSetMatrixAt(i, matrix);
}
Call thinInstanceSetMatrixAt once during setup (or when positions change). The buffer grows automatically, but reallocating a 50k-instance buffer every frame will stall the pipeline — more on that later.
Worked Example: Rain Particle Field
Let's build a rain system with 80,000 drops. Each drop is a thin plane oriented toward the camera. We'll add per-instance color variation so drops aren't uniform.
// 1. Create a single drop geometry
const drop = MeshBuilder.CreatePlane("drop", {width: 0.02, height: 0.5}, scene);
drop.enableThinInstances();
// 2. Add a custom color buffer (4 floats per instance: RGBA)
drop.thinInstanceAddAttribute("instanceColor", 4);
// 3. Populate matrices and colors
const count = 80000;
const matrices = new Float32Array(count * 16);
const colors = new Float32Array(count * 4);
for (let i = 0; i < count; i++) {
const x = (Math.random() - 0.5) * 200;
const y = Math.random() * 100;
const z = (Math.random() - 0.5) * 200;
// Simple translation matrix (no rotation/scale)
const m = Matrix.Translation(x, y, z);
m.copyToArray(matrices, i * 16);
// Slight blue tint variation
colors[i * 4 + 0] = 0.4 + Math.random() * 0.2; // R
colors[i * 4 + 1] = 0.5 + Math.random() * 0.2; // G
colors[i * 4 + 2] = 0.9 + Math.random() * 0.1; // B
colors[i * 4 + 3] = 0.6; // A
}
// 4. Upload in one shot (avoids per-call overhead)
drop.thinInstanceSetMatrices(matrices);
drop.thinInstanceSetAttribute("instanceColor", colors);
// 5. Material that consumes instanceColor
const mat = new StandardMaterial("rainMat", scene);
mat.diffuseColor = new Color3(0.5, 0.6, 1.0);
mat.emissiveColor = new Color3(0.2, 0.3, 0.8);
mat.alpha = 0.6;
drop.material = mat;
// In a custom shader or Node Material, declare:
// attribute vec4 instanceColor;
// varying vec4 vInstanceColor;
// vInstanceColor = instanceColor;
// Then multiply fragment color by vInstanceColor.
Run this in the Playground (#THININSTANCESCOLOR is a close reference). You'll see a single draw call in Spector.js with instanceCount: 80000. Memory cost: ~64 bytes per matrix + 16 bytes per color = ~6.4 MB total — trivial for any modern GPU.
Trade-offs You Must Accept
- No per-instance frustum culling. The entire batch renders if the root mesh's bounding box intersects the view frustum. If your 80k drops cover a 200×200×100 meter volume but the camera only sees a 20-meter slice, you're shading 75k invisible drops. Mitigate by splitting into spatial chunks (e.g., a grid of thin-instance meshes) so each chunk's bounding box is tighter.
- One material per batch. You cannot swap textures or change shader uniforms per instance. If you need multiple leaf textures for a forest, create separate thin-instance meshes per texture atlas region.
- Buffer updates are all-or-nothing. Calling
thinInstanceSetMatrixAtin a loop each frame triggers a full buffer re-upload. For animated particles (falling rain, swaying grass), consider a compute shader or transform feedback to update positions on the GPU, then read back only the bounding box for culling. - Shadow casting quirks. In some configurations, thin instances won't cast shadows unless the shadow generator has
useVarianceShadowMap = trueor a non-zerofilteringQuality. Test your shadow setup early.
When to Choose Thin Instances vs. Alternatives
| Scenario | Recommended Approach |
|---|---|
| Static vegetation, props, casings (<100k, infrequent updates) | Thin Instances — simplest, lowest CPU overhead |
| Animated particles (rain, sparks, crowds) with per-frame motion | GPU particle system (compute shader / transform feedback) or SPS (Solid Particle System) |
| Instances needing different materials or LODs | Multiple thin-instance meshes + spatial partitioning |
| Few hundred instances, simple logic | Standard InstancedMesh — per-instance culling wins |
Verify Before You Ship
- Open the Babylon.js Playground, search "Thin Instances", and run
#THININSTANCESand#THININSTANCESCOLOR. - In Chrome DevTools Performance tab, compare frame time between 50k thin instances and 50k
InstancedMeshobjects in the same scene. - Capture a frame with Spector.js — confirm a single
drawElementsInstancedcall with the expectedinstanceCount. - Read the official docs: "Optimizing Instances" → "Thin Instances" for shader integration details (Node Material
Instancesblock or manualvertexDeclaration).
Thin Instances are a sharp tool for a specific job: massive counts of identical, mostly-static geometry sharing one material. Use them there, and you'll keep the CPU free for gameplay logic instead of draw-call bookkeeping.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.