Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/api-reference/gltf/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,11 @@ bundle contains:
| --- | --- |
| `scenes` | One `@luma.gl/engine` `GroupNode` root per source scene. |
| `materials` | Shared engine materials in source glTF material order. |
| `variants` | Source-aware runtime controller for authored material variants. |
| `cameras` | Runtime camera projections updated by supported animation pointers. |
| `animator` | A `GLTFAnimator` backed by the shared engine animation mixer. |
| `animations` | Decoded source clips, including supported animation-pointer channels. |
| `skins` | Automatically updated source skin bindings and reusable joint palettes. |
| `lights` | World-space directional, point, and spot lights from `KHR_lights_punctual`. |
| `extensionSupport` | A map describing support for extensions reported by the asset. |
| `sceneBounds` | World-space bounds and camera-framing recommendations for each scene. |
Expand Down Expand Up @@ -149,6 +152,7 @@ and `CUBICSPLINE` tracks through the format-independent engine mixer. Existing s
and morph-target helpers preserve authored joint attributes, target deltas, and per-node weights.

See [glTF animation and deformation](/docs/api-reference/gltf/gltf-animation), the
[GPU-animated crowd reference](/docs/api-reference/gltf/gltf-animated-crowd), the
[engine animation guide](/docs/api-guide/engine/animation), and
[glTF extension support](/docs/api-reference/gltf/gltf-extensions) for details and limitations.

Expand Down
217 changes: 217 additions & 0 deletions docs/api-reference/gltf/gltf-animated-crowd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import {GltfDocsTabs} from '@site/src/components/docs/gltf-docs-tabs';

# GPU-Animated glTF Crowds

<GltfDocsTabs active="animated-crowd" />

`GLTFAnimatedCrowd` renders independently animated characters using one shared GPU model and one
instanced draw per compatible source primitive. Actors retain independent animation clocks,
hierarchies, joint palettes, and placement transforms without duplicating geometry, materials, or
draw calls for every character.

## Create and render a crowd

```ts
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});
}
```

`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 joint-palette slots are uploaded; unused fixed-capacity storage
is not rewritten every frame.

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.

The glTF Asset Studio exposes this path through its **GPU Crowd Actors** control, supporting
1–100 actors and reporting the actual number of shared GPU draws.
Its default CC0 Robot Expressive model provides 14 named actions, including walking, running,
dancing, waving, and idling. Neighboring actors can play different actions without splitting a
shared primitive into separate draw calls.

## Independent playback

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

```ts
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.

## 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.

## Graphics backends

| Backend | Actor joint-palette storage | GPU access |
| --- | --- | --- |
| WebGPU | One packed read-only storage buffer per skinned primitive draw group. | Vertex shaders index the buffer with the instance and joint indices. |
| WebGL 2 | One 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.

## Current boundaries

- Source geometry and materials are shared; per-actor material factors, material variants,
texture-transform pointers, camera/light pointers, and renderer state are not isolated.
- Actor morph weights can advance independently on their CPU-side nodes, but independently
deformed morph-target vertex data is not yet evaluated or drawn per actor.
- Per-actor visibility, culling, transparency sorting, and source-authored
`EXT_mesh_gpu_instancing` composition are not promised by this crowd API.
- Each source primitive still has its own draw, and every actor's clip evaluation and palette
preparation currently occur on the CPU.
- Crowd buffers have a fixed capacity; recreate the crowd to increase it.

Use the regular [glTF animation reference](/docs/api-reference/gltf/gltf-animation) when an asset
requires independently updated materials, morph geometry, cameras, or lights without batching.

## Ownership and cleanup

```ts
crowd.getActor('runner');
crowd.removeActors(['walker']);
crowd.update(1 / 60);

crowd.destroy();
crowd.destroy();
```

Removing an actor releases only its private CPU-side animation state; remaining actors continue
using the shared GPU models. Destroying the crowd releases its packed palette resources and calls
the shared `scenegraphs.destroy()` lifecycle exactly once. Destruction is idempotent and does not
destroy the application-owned device or borrowed image-based-lighting textures.

## Public API

```ts
import {
createGLTFAnimatedCrowd,
GLTFAnimatedCrowd,
type GLTFAnimatedCrowdOptions,
GLTFCrowdActor,
type GLTFCrowdActorOptions,
type GLTFCrowdClipSelectionOptions,
type GLTFCrowdPrimitiveGroup
} from '@luma.gl/gltf';
```

| API | Purpose |
| --- | --- |
| `createGLTFAnimatedCrowd(device, gltf, options?)` | Parse one postprocessed asset and allocate shared crowd resources. |
| `crowd.addActors(options[])`, `crowd.addActor(options?)` | Add lightweight actors; the batched form uploads once. |
| `crowd.getActor(id)`, `crowd.removeActor(id)` | Inspect or remove one independent actor. |
| `crowd.removeActors(ids)` | Remove and compact many actors with one upload. |
| `crowd.actorCount`, `crowd.capacity`, `crowd.actors` | Inspect live actors and fixed storage capacity. |
| `crowd.scenegraphs`, `crowd.models`, `crowd.primitiveGroups` | Inspect the shared parsed asset and primitive draw groups. |
| `crowd.update(deltaSeconds)` | Evaluate actor clips and upload current transforms and palettes. |
| `crowd.draw(renderPass)` | Issue one instanced draw per compatible source primitive. |
| `crowd.destroy()` | Release owned actor, palette, and shared scenegraph resources. |
| `actor.selectClip()`, `actor.seek()`, `actor.setPhase()` | Select, crossfade, or reposition an independent clip. |
| `actor.play()`, `actor.pause()`, `actor.setSpeed()`, `actor.setLoop()` | Configure independent playback. |
| `actor.setTransform()`, `actor.root`, `actor.getNode()` | Set placement and inspect private source-node state. |
| `actor.update(deltaSeconds)`, `actor.destroy()` | Advance or remove one independent actor. |
3 changes: 3 additions & 0 deletions docs/api-reference/gltf/gltf-animation.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ See the [engine animation guide](/docs/api-guide/engine/animation) and
[AnimationMixer API reference](/docs/api-reference/engine/animation/animation-mixer) for pause,
seek, reverse playback, once/repeat/ping-pong loops, weighted blending, and crossfading.

To share GPU models across independently posed actors, see
[GPU-animated glTF crowds](/docs/api-reference/gltf/gltf-animated-crowd).

## Supported channels and interpolation

| Source channel | Runtime target |
Expand Down
1 change: 1 addition & 0 deletions docs/table-of-contents.json
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@
"api-reference/gltf/gltf-materials",
"api-reference/gltf/gltf-native-extensions",
"api-reference/gltf/gltf-animation",
"api-reference/gltf/gltf-animated-crowd",
"api-reference/gltf/gltf-interchange",
"api-reference/gltf/gltf-extensions"
]
Expand Down
25 changes: 22 additions & 3 deletions examples/showcase/gltf/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
makeHtmlCustomPanel
} from '../../example-panels';
import GLTFCatalogApp, {
GLTF_CROWD_INFO_ID,
GLTF_MODEL_INFO_ID,
saveOptions,
type GLTFCatalogModel,
Expand Down Expand Up @@ -89,12 +90,13 @@ void main(void) {
`;

const GLTF_DESCRIPTION_HTML = `\
<p>Browse production-quality glTF sample assets with interactive camera and animation controls.</p>
<p>Explore animated glTF characters, skeletal rigs, and expressive motion.</p>
<div id="loading-state" class="gltf-loading-indicator" hidden>
<span class="gltf-loading-spinner" aria-hidden="true"></span>
</div>
<p style="margin-top: 8px;">Drag to orbit. Use the mouse wheel or trackpad to zoom.</p>
<div id="${GLTF_MODEL_INFO_ID}" style="margin-top: 12px; display: none;"></div>
<div id="${GLTF_CROWD_INFO_ID}" style="margin-top: 8px;" hidden></div>
<div id="model-light-indicator" style="margin-top: 8px;"></div>
<div id="extension-support" style="margin-top: 12px;"></div>
<div id="error" style="color: #b00020; margin-top: 8px;"></div>
Expand Down Expand Up @@ -138,11 +140,11 @@ export default class AppAnimationLoopTemplate extends GLTFCatalogApp {
}

getDefaultModelName(): string {
return 'DamagedHelmet';
return 'RobotExpressive';
}

getModelStorageKey(): string {
return 'showcase-last-gltf-model-v2';
return 'showcase-last-gltf-model-v3';
}

getClearColor(): [number, number, number, number] {
Expand Down Expand Up @@ -266,6 +268,7 @@ export default class AppAnimationLoopTemplate extends GLTFCatalogApp {
return {
extensionName: this.extensionName,
modelValue: this.selectedModelValue || LOADING_MODEL_VALUE,
instanceCount: this.getAnimationInstanceCount(),
useModelLights: this.options['useModelLights'],
cameraAnimation: this.options['cameraAnimation'],
gltfAnimation: this.options['gltfAnimation']
Expand Down Expand Up @@ -294,6 +297,11 @@ export default class AppAnimationLoopTemplate extends GLTFCatalogApp {
this.selectModel(modelValue);
return;
}
const instanceCount = getChangedSetting(changedSettings, 'instanceCount')?.nextValue;
if (typeof instanceCount === 'number') {
this.setAnimationInstanceCount(instanceCount);
return;
}
for (const optionName of ['useModelLights', 'cameraAnimation', 'gltfAnimation'] as const) {
const nextValue = getChangedSetting(changedSettings, optionName)?.nextValue;
if (typeof nextValue === 'boolean') {
Expand Down Expand Up @@ -417,6 +425,7 @@ function encodeModelOption(modelOption: GLTFModelReference): string {
type GltfSettingsState = {
extensionName: string;
modelValue: string;
instanceCount: number;
useModelLights: boolean;
cameraAnimation: boolean;
gltfAnimation: boolean;
Expand Down Expand Up @@ -469,6 +478,16 @@ export function makeGltfSettingsSchema(
name: 'Animation',
initiallyCollapsed: false,
settings: [
{
name: 'instanceCount',
label: 'GPU Crowd Actors',
type: 'number',
persist: 'none',
min: 1,
max: 100,
step: 1,
sliderDebounceMs: 120
},
{
name: 'useModelLights',
label: 'Use Model Lights',
Expand Down
Loading
Loading