Skip to main content

GPU-animated crowd usage

Why GPU-animated instancing matters

Ordinarily, cloning an animated character also clones its renderable models. One character with 19 mesh primitives therefore becomes 1,900 draw calls when rendered 100 times, even when every copy uses the same geometry and materials. Sharing an ordinary scenegraph avoids those extra draws, but also forces every copy into the same animation pose.

GLTFAnimatedCrowd separates private animation state from shared GPU rendering:

Scene contentsSeparate animated scenegraphsGLTFAnimatedCrowd
One character with one primitive1 draw per character1 instanced draw
Two Robot Expressive characters38 separate draws19 instanced draws
100 Robot Expressive characters1,900 separate draws19 instanced draws
Actor playbackIndependently controllableIndependently controllable
Geometry, materials, and GPU modelsRepeated per characterCreated once per source primitive

Robot Expressive has 19 reachable source primitives: four skinned draw groups and 15 rigid draw groups. The draw count remains 19 when actors walk, run, dance, or wave simultaneously. A multi-primitive asset does not become one draw; batching is per compatible source primitive.

Choose this API when many copies of one asset need independent node, skeletal, or morph animation while retaining the same geometry, materials, and render state. Use ordinary scenegraphs when each copy needs material variants or animated material properties.

Choose the appropriate instancing path

RequirementSeparate glTF scenegraphsEXT_mesh_gpu_instancingGLTFAnimatedCrowd
Independently posed skeletal actorsSupported, with one model and draw per actor.Not represented by the extension.Supported, with one draw per source primitive.
Distinct clips, speeds, phases, and crossfadesSupported with independent scenegraphs.Not represented by the extension.Supported without splitting draw groups.
Authored static copies in an interoperable glTF assetRequires separate source nodes.Source-defined instance transforms.Runtime-created actors, not an interchange extension.
Screen-size-dependent actor mesh detailApplication-managed per scenegraph.Not represented by the extension.Authored or generated mesh levels, one draw per occupied detail bucket.
Actor-specific material variantsSupported with separate scenegraphs.Source instances share render state.Not independently rendered.
Independently rendered morph targetsSupported with separate scenegraphs.Source instances share render state.Supported through packed weights or baked GPU clip sampling.
GPU model, geometry, and texture duplicationRepeated for each independently parsed scenegraph.Shared within the authored instance group.Shared across all actors in the crowd.

EXT_mesh_gpu_instancing and animated crowds solve different problems: the extension preserves authored static instance transforms, while the crowd API creates independently animated runtime actors. Nesting one instancing system inside the other is not currently supported.

Architecture and package ownership

One postprocessed glTF asset
├─ shared geometry, materials, morph deltas, and one GPU model per visible primitive
├─ CPU mode: actor clock → AnimationMixer → joint palette + morph weights → instance slot
└─ baked mode: actor clock → frame addresses + blend weight → vertex-shader clip sampling


one instanced GPU draw per source primitive

The asset is parsed once. Each actor receives its own lightweight CPU-side node hierarchy, existing GLTFAnimator, format-independent AnimationMixer, and source skin controller. Those actor nodes contain no renderable Model objects and allocate no duplicate geometry, textures, or materials.

@luma.gl/gltf owns glTF-specific crowd orchestration. Generic animation and scenegraph primitives stay in @luma.gl/engine, while the portable skinning implementation stays in @luma.gl/shadertools. WebGPU and WebGL use the same public crowd and actor APIs.

Create and render a crowd

import {load} from '@loaders.gl/core';
import {GLTFLoader, postProcessGLTF} from '@loaders.gl/gltf';
import {createGLTFAnimatedCrowd} from '@luma.gl/gltf';
import {Matrix4} from '@math.gl/core';

const asset = await load('/models/character.glb', GLTFLoader);
const gltf = postProcessGLTF(asset);
const crowd = createGLTFAnimatedCrowd(device, gltf, {capacity: 256});

const [walker, runner] = crowd.addActors([
{
id: 'walker',
clip: 'Walking',
phase: 0,
transform: new Matrix4().translate([-2, 0, 0])
},
{
id: 'runner',
clip: 'Running',
phase: 0.35,
speed: 1.5,
transform: new Matrix4().translate([2, 0, 0])
}
]);

function renderFrame(
deltaSeconds: number,
viewProjectionMatrix: Matrix4,
cameraPosition: [number, number, number]
): void {
crowd.update(deltaSeconds);

const modelMatrix = new Matrix4();
for (const model of crowd.models) {
model.shaderInputs.setProps({
pbrProjection: {
camera: cameraPosition,
modelViewProjectionMatrix: viewProjectionMatrix,
modelMatrix,
normalMatrix: modelMatrix
}
});
}

const renderPass = device.beginRenderPass({clearColor: [0, 0, 0, 1], clearDepth: 1});
const drawCount = crowd.draw(renderPass);
renderPass.end();
device.submit();

console.log({actors: crowd.actorCount, draws: drawCount});
}

The application owns its projection, camera, lighting, render pass, and submission. Configure the shared models' pbrProjection input before drawing; crowd.draw() does not create a camera or submit the device for you. If your renderer already manages a pass, draw the crowd into that existing pass instead of creating a second one.

Convert browser timestamps into frame deltas before calling update():

let previousFrameTime: number | undefined;

function animate(frameTimeMilliseconds: number): void {
const deltaSeconds =
previousFrameTime === undefined ? 0 : (frameTimeMilliseconds - previousFrameTime) / 1000;
previousFrameTime = frameTimeMilliseconds;

renderFrame(deltaSeconds, viewProjectionMatrix, cameraPosition);
requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

update() takes a frame delta in seconds, not the absolute millisecond value supplied by requestAnimationFrame(). addActors() prepares every actor first, then uploads the complete group in one refresh; prefer it over repeatedly calling addActor() when building large crowds. Provide initial placement matrices in the batched actor options to avoid a separate upload per transform. removeActors() similarly compacts many actor slots with one upload. Placement, clip-selection, and seek operations refresh their packed data immediately; use update() to advance every actor's independent playback clock.

Only active actor transforms and compact animation addresses are uploaded in baked mode. CPU mode uploads active joint-palette slots and morph weights instead. Unused fixed-capacity storage is not rewritten every frame.

Bake clips once and sample them in the vertex shader

Pass gpuAnimation to trade one-time construction work and a bounded frame atlas for much lower per-frame CPU animation work:

const crowd = createGLTFAnimatedCrowd(device, gltf, {
capacity: 256,
gpuAnimation: {
sampleRate: 30,
maxFrames: 8192
}
});

console.log(crowd.animationStats);
// {mode: 'gpu', sampleRate: 30, frameCount, clipCount, morphGroupCount}

The constructor evaluates every clip at the requested rate once, storing authored node matrices, skin palettes, and morph weights in one immutable frame atlas per primitive. Every frame then uploads only each visible actor's primary and optional crossfade frame addresses, interpolation fractions, blend weight, and placement transform. The vertex shader interpolates sampled frames, blends an optional second action, applies independent morph deltas, and skins the vertex.

WebGPU uses read-only storage buffers; WebGL 2 uses nearest-sampled rgba32float textures with texelFetch(). If requested clips exceed maxFrames, the crowd retains the ordinary CPU playback path instead of allocating an unbounded atlas. gpuAnimation is opt-in because the actor's private CPU nodes remain at their authored bind state while baked sampling is active; inspect actor.time, actions, and crowd.animationStats, not animated node matrices, in that mode.

The default capacity is 16 actors. Capacity is fixed so shared GPU allocations and binding layouts remain stable; creating more actors than the configured capacity is rejected.

Build and remove large groups efficiently

const actionNames = ['Walking', 'Running', 'Dance', 'Wave', 'Idle'];

const actors = crowd.addActors(
Array.from({length: 100}, (_, index) => ({
id: `character-${index}`,
clip: actionNames[index % actionNames.length],
phase: (index * 0.618) % 1,
speed: 0.85 + (index % 4) * 0.1,
loop: 'repeat' as const,
transform: new Matrix4().translate([(index % 10) * 2, 0, Math.floor(index / 10) * 2])
}))
);

console.log(actors.length, crowd.actorCount, crowd.capacity);

const removedCount = crowd.removeActors(['character-2', 'character-7', 'character-23']);
console.log(removedCount);

addActors() initializes all private actor states before refreshing shared GPU data once. removeActors() removes the requested IDs, compacts surviving instance slots, and also refreshes once. Repeated addActor(), removeActor(), actor.setTransform(), or actor.update() calls refresh immediately; avoid issuing hundreds of individual mutations in a single frame.

Actor IDs are unique within a crowd. Omit id to let the crowd assign one automatically. A batch that would exceed capacity is rejected before creating new actor state.

Independent playback

Every actor has its own lightweight node hierarchy, existing glTF animator, engine animation mixer, and joint-palette state:

walker.selectClip('Running', {crossFadeDuration: 0.4});
runner.selectClip('Idle', {phase: 0.5});

walker.pause();
runner.setSpeed(2);
crowd.update(0.25);

walker.play();
walker.seek(1.25);
runner.setPhase(0.75);
runner.setLoop('ping-pong', 3);
runner.setTransform(new Matrix4().translate([4, 0, 0]));

console.log(walker.activeClip, runner.activeClip);
console.log(walker.time, runner.speed, runner.playing);

Clip times, crossfade durations, and update deltas are measured in seconds. Normalized phase values select a position within the active clip. Loop modes are once, repeat, and ping-pong; negative playback speeds run the selected clip backward.

actor.root and actor.getNode(indexOrId) expose private CPU-side scenegraph nodes. Those nodes do not own duplicate Model objects. GPU models, source geometry, and runtime materials belong to the single shared crowd.scenegraphs bundle.

Initial actor options

OptionMeaning
idStable application-owned actor identifier; automatically generated when omitted.
clipName of the initial authored animation clip.
timeInitial clip-local position in seconds; takes precedence over phase.
phaseNormalized initial position within the selected clip.
speedIndependent playback multiplier; negative values play backward.
loop'once', 'repeat', or 'ping-pong'.
repetitionsOptional repetition count for the selected loop mode.
playingStarts paused when explicitly set to false.
transformInitial actor placement matrix, combined with authored animated node transforms.

Different clip selections and crossfades do not create additional GPU models or draw groups:

const [walkingActor, wavingActor] = crowd.addActors([
{id: 'walking', clip: 'Walking', phase: 0, speed: 1},
{id: 'waving', clip: 'Wave', phase: 0.35, speed: 1.2}
]);

walkingActor.selectClip('Running', {crossFadeDuration: 0.4, phase: 0.1});
wavingActor.pause();

console.log(walkingActor.activeClip, walkingActor.time, walkingActor.speed);
console.log(wavingActor.activeClip, wavingActor.playing);

Keep one crowd-level update(deltaSeconds) call in the frame loop. Calling actor.update() for every actor independently causes a shared-buffer refresh for every call and defeats batched updates.

Batching model

Each compatible source mesh primitive owns one shared luma.gl Model. Its draw uses the number of live crowd actors as its instance count. Different source primitives, materials, primitive topologies, or render-state requirements remain separate draw groups.

For example, a character containing 19 source primitives requires approximately 19 instanced draws whether the crowd contains two actors or 100. Rendering 100 independent scenegraphs would instead require approximately 1,900 draws. This API does not claim that arbitrary multi-primitive or multi-material models collapse into one universal draw call.

Animated rigid node transforms are uploaded as per-actor instance attributes. Authored source skins additionally read a palette selected by the GPU instance index, so actors playing different clips or phases deform differently while sharing the same vertex buffers.

The existing CPU AnimationMixer evaluates glTF keyframes and builds each actor's joint matrices. Vertex shaders then apply those actor-specific matrices on the GPU. This is GPU instanced skinning, not GPU sampling of baked animation clips or GPU-side interpolation. Batching reduces GPU draw calls, but large crowds can still be limited by CPU animation work.

Private actor state versus shared render state

Private to each actorShared across the crowd
Authored node transforms and local scenegraph hierarchy.One parsed source glTF document.
Active clip, elapsed time, phase, speed, loop mode, and crossfade.One GPU Model per reachable source primitive.
Existing animation mixer and source-skin controller.Source geometry, indices, textures, materials, and shader pipelines.
Joint matrices and CPU-side morph-weight values.Four instance-transform column buffers per primitive.
World placement and independently evaluated rigid-node motion.One packed GPU joint palette per skinned primitive.

Detached glTF nodes that are not reachable from a source scene do not produce crowd draw groups by default. When lod is explicitly configured, authored or generated MSFT_lod alternatives are mapped back to their reachable highest-detail animation node. Each occupied detail level then receives its own compact actor transforms and independently posed joint palettes.

Graphics backends

BackendActor joint-palette storageGPU access
WebGPUOne packed read-only storage buffer per skinned primitive draw group.Vertex shaders index the buffer with the instance and joint indices.
WebGL 2One nearest-sampled rgba32float palette texture per skinned primitive draw group.Vertex shaders retrieve four matrix columns using texelFetch() and gl_InstanceID.

A joint matrix occupies 64 bytes. One 43-joint palette for 100 actors therefore requires approximately 275 KB of packed GPU data per pose update. Each skinned primitive draw group owns its own packed palette; assets with multiple skinned primitives allocate one palette per group.

WebGPU capacity is constrained by storage-buffer and binding-size limits. WebGL 2 capacity is constrained by vertex-stage texture support and maximum texture dimensions: a palette texture is 4 × jointCount texels wide and one row per actor. Float linear filtering is unnecessary because palette data is read at exact texel coordinates.

If a backend cannot support the required storage or float-texture path, crowd rendering is not silently replaced with one ordinary draw per actor.

Joint-palette memory and upload estimates

Each joint matrix contains 16 float32 values:

palette bytes per skinned primitive = actor count × joints per actor × 16 × 4
= actor count × joint count × 64

For a 43-joint character:

Active actorsPalette upload per skinned primitiveFour skinned primitives
1027,520 bytes110,080 bytes
50137,600 bytes550,400 bytes
100275,200 bytes1,100,800 bytes

Those values describe joint palettes only. Every primitive also receives four packed instance-transform columns, totaling another 64 bytes per active actor. Source vertex data, textures, and materials are not reuploaded for each actor.

On WebGPU, the palette is a read-only storage buffer indexed by instanceIndex * jointCount + jointIndex. On WebGL 2, each matrix occupies four adjacent RGBA texels in its actor's texture row. The shader uses exact integer texelFetch() coordinates, so float linear filtering is not required.