Skip to content

Mesh Instancing Beta

Instancing renders many copies of one object in a single draw – thousands of them stay cheap, because the GPU draws the shared mesh once and stamps it at each transform. Use it for fields, crowds, particles, and any procedural scatter.

Scatter copies

Give one object the set of transforms to render it at with setInstances(Instances.transforms([...])). Each transform is a Vector3 position (or a full transform):

javascript
var base = await scene.createEntity(
    createBox(0.03, 0.03, 0.03).anchor(Anchor.position(0, 0, -1.5))
);

var spots = [];
var count = 1000;
var goldenAngle = Math.PI * (3 - Math.sqrt(5));
for (var i = 0; i < count; i++) {
    var r = 0.4 * Math.sqrt(i / count);
    var a = i * goldenAngle;
    spots.push(new Vector3(r * Math.cos(a), 0, r * Math.sin(a)));
}
base.setInstances(Instances.transforms(spots));

Call setInstances again with a new set to re-scatter – for example, from an On Render handler or a slider's valueChanged.

Moving instances from a kernel

Rebuilding the transform list in JavaScript every frame gets expensive once there are thousands of copies. entity.instances hands the transform buffer to a compute kernel instead, so the work happens on the GPU. It is null until setInstances(...) has run.

javascript
// Attach to: On Experience Start
var instances = base.instances;
var threads = 64;
var groups = Math.ceil(instances.count / threads);

// A `.write` buffer arrives uninitialised, so keep the authored placement to compute from.
var authored = Buffer.float32(instances.count * 16, 0);
var snapshot = await Kernel.fromSource({
    source: `
        #include <metal_stdlib>
        using namespace metal;
        struct Uniforms { float count; };

        kernel void instanceSnapshot(constant Uniforms& U   [[buffer(0)]],
                                     device const float4x4* src [[buffer(1)]],
                                     device float4x4* dst       [[buffer(2)]],
                                     uint i [[thread_position_in_grid]]) {
            if (i >= (uint)U.count) { return; }
            dst[i] = src[i];
        }
    `,
    functionName: 'instanceSnapshot'
});

snapshot.run({
    uniforms: new Float32Array([instances.count]),
    inputBuffers:  { src: instances.read },
    outputBuffers: { dst: authored },
    threadGroups: [groups, 1, 1],
    threadsPerThreadgroup: [threads, 1, 1]
});

A wave kernel then reads that snapshot and writes the live buffer each frame. Each entry is one float4x4, column-major, so column 3 holds the translation:

// Inside the wave kernel
float4x4 m = orig[i];
m[3].y = m[3].y + sin(U.time * 2.0 + length(m[3].xz) * U.spatialFreq) * U.amplitude;
out[i] = m;

The buffer is not in metres

It holds the instanced mesh's own units. On a primitive those are metres, but a model scaled to a fitting box can be hundreds to one – one real model measured 407 buffer units to the metre, so an 8 cm wave written as 0.08 moved each copy by 0.2 mm and looked frozen.

instances.localToMesh is the conversion. Its scale, element 0, is the factor:

javascript
var perMetre = instances.localToMesh[0];
var uniforms = new Float32Array([
    0,                                   // time
    0.08 * perMetre,                     // an 8 cm wave
    (Math.PI * 2 / 0.63) / perMetre,     // a 63 cm wavelength
    instances.count
]);

instances.meshToLocal goes the other way, for reading a distance back out. Both are identity on a primitive, so converting costs nothing and keeps the same script correct on a model.

Models need Flatten switched on

Instancing works on one mesh, the same way it does everywhere else: three.js instances one geometry, Godot's MultiMesh takes one mesh, and Apple's own example collapses a twelve-mesh robot before instancing it.

A primitive is always one mesh, so nothing is needed. A model keeps whatever node tree it was exported with, so turn on its Flatten adjustment, which loads it as a single mesh. Otherwise nothing is instanced, the console names the meshes it found, and rep.instances stays null.

To instance one node of an unflattened model instead, use rep.findChild(name).setInstances(...). That takes matrices in that node's own space, which can be far from metres – a node scaled by its exporter can be thousands of times smaller.

The bounds are worked out when setInstances runs and do not follow instances a kernel moves, so copies pushed outside the original extent are culled and lose their shadows.

Instancing vs cloning

Instances are rendering-only: they share the base object's mesh and material and are not individual entities. You can't tap one, script one, or give one its own physics body. When you need copies that behave independently – each collidable, tappable, or separately animated – use entity.clone() instead. Rule of thumb: dozens of interactive copies → clone; hundreds or thousands of visual copies → instance.

Shadows on dense fields

A large instanced field can alias the shared shadow map into streaks. Turn the projected shadow off for the base object so the field stays clean:

javascript
createBox(0.03, 0.03, 0.03).shadow({ directional: false });

Instancing needs iOS 26, iPadOS 26, macOS 26 or visionOS 26. Gate a portable script with environment.features.has('instancing') – see Feature Detection.