A crowd from a baked clip
Open the live demo · Read the source · View on GitHub
A skinned draw hands the vertex stage one uniform array of joint matrices,
and a uniform is the same for every copy of an instanced draw. Give a
thousand villagers a skeleton apiece and they all stand in the pose the last
one set. BakedPoses sidesteps this by sampling every clip once, ahead of
time, into a table: from then on an instance only needs which clip and how
far into it, the two numbers instancing already carries per copy.
Step 1: Build the rig once #
The table is sampled from an ordinary rig, the same shoulder-and-arm chain a single skinned character would use. Nothing about it knows it is about to become a table.
// The rig every clip is baked against: a shoulder with an arm hanging off
// it. `BakedPoses` samples it once per clip and needs no scene of its own
// afterwards.
final Scene rig = Scene();
final SceneNode shoulder = rig.add(SceneNode(name: 'shoulder'));
final SceneNode arm = SceneNode(name: 'arm')..setPosition(0.0, -0.8, 0.0);
shoulder.add(arm);
final Skeleton skeleton = Skeleton(
name: 'wave',
joints: <SceneNode>[shoulder, arm],
inverseBindMatrices: <Matrix4>[
Matrix4.copy(shoulder.worldMatrix)..invert(),
Matrix4.copy(arm.worldMatrix)..invert(),
],
);
Step 2: Bake every clip into one table #
Two clips, a slow wave and a fast one, go into one call. The table comes back
as a flat list of matrices: clips rows deep, frames rows a clip, four
floats a column, one joint's worth of columns per matrix.
// Two clips, a slow wave and a fast one, baked into one table. A crowd
// wearing both is one sampler, not two.
final AnimationPlayer player = AnimationPlayer(
clips: <AnimationClip>[_wave(seconds: 1.6), _wave(seconds: 0.5)],
targets: <AnimationTarget?>[shoulder, null],
);
_table = BakedPoses.of(
player,
skeleton: skeleton,
meshWorld: Matrix4.identity(),
framesPerClip: _framesPerClip,
);
Note. The clip is baked, not the pose. A crowd built this way plays clips: no per-instance inverse kinematics, nothing that computes a pose from the world at run time, since that pose would need to reach a texture that frame and the table is built once and never again.
Step 3: Read a row per instance, every frame #
This is the part a vertex shader would do by sampling the texture at a row worked out from the instance's clip and time. Nothing here touches the skeleton: the table already has the answer, so each instance is only ever two lookups and a blend between them.
A joint matrix is a delta from rest, not a place in the world: the shoulder's row is the identity until it turns. Composing it with the arm's own rest offset is what turns "how far the shoulder has swung" back into "where the arm hangs now".
// What a shader would do per instance: pick a clip's rows, blend the two
// the current time falls between, and use the joint matrix that came out.
// Nothing here touches a skeleton; the table already has the answer.
//
// A joint matrix is a delta from rest, not a place in the world — the
// shoulder is joint 0, and its matrix is identity when the shoulder has
// not turned. Composing it with the arm's own rest offset turns that
// delta back into "where the arm hangs now, relative to its shoulder".
for (var i = 0; i < _people; i++) {
final int clip = _clip[i];
final double duration = _table.durations[clip];
final double t = ((_time + _phase[i] * duration) % duration) / duration;
final double row = t * _framesPerClip;
final int a = row.floor() % _framesPerClip;
final int b = (a + 1) % _framesPerClip;
final double f = row - row.floor();
final Matrix4 ma = _table.matrixAt(clip, a, 0);
final Matrix4 mb = _table.matrixAt(clip, b, 0);
final Matrix4 shoulderDelta = Matrix4.zero();
for (var e = 0; e < 16; e++) {
shoulderDelta.storage[e] = ma.storage[e] * (1 - f) + mb.storage[e] * f;
}
_arms[i].setLocalMatrix(
shoulderDelta.multiplied(Matrix4.translation(Vector3(0.0, -0.4, 0.0))),
);
}
Six figures share the one table, half on the slow clip and half on the fast one, each started at a different point in its clip so they do not wave in step.