Animation
A figure walking a circle on a blend tree, reaching for a lantern, its pace set in DriftScript.
Starts examples/animation in this page, on WebGPU where your browser has it.
Animation is the chapter that walks through it.
From a checkout of the engine, npm run examples serves it at /animation/.
Source
main.ts
examples/animation/main.ts
/**
* A figure walking a circle round a lantern: it idles, walks or runs as the switch says, its feet
* keeping pace with the ground, and its right hand reaches for the lantern as it passes.
*
* Everything is built here: the skeleton, a mesh skinned to it a piece a bone, and three clips as
* keyframes. A blend tree crossfades the clips over one speed parameter; the walk and the run keep
* distance as their clock and the idle keeps time. The animation logic is a DriftScript module
* through `drift/animation`: it eases the pace, samples the tree, and solves the arm's two-bone
* reach.
*/
import { BlendTree, Skeleton, createPose } from '@driftengine/animation';
import type { AnimationClip, JointTrack, Pose } from '@driftengine/animation';
import { MeshBuilder, computeLightMatrix, createEnvironment } from '@driftengine/core';
import type { Vec3 } from '@driftengine/core';
import { patchModule } from 'driftscript';
import { createReadout } from '../common/readout';
import { exported, hostScript } from '../common/script';
import { controls, flag, openStage } from '../common/stage';
import * as rigScript from './rig.drs';
const stage = await openStage({
directionalShadows: true,
outputTransform: 'aces',
sceneSamples: 4,
});
const { renderer, camera } = stage;
// #region skeleton
/** Seventeen joints in a relaxed standing pose, each with its parent and a name. */
const JOINTS: [string, number, number, number, number][] = [
['hips', -1, 0, 1.0, 0],
['spine', 0, 0, 1.25, 0],
['chest', 1, 0, 1.5, 0],
['neck', 2, 0, 1.68, 0],
['head', 3, 0, 1.82, 0],
['leftShoulder', 2, 0.22, 1.55, 0],
['leftElbow', 5, 0.25, 1.27, 0],
['leftHand', 6, 0.27, 1.0, 0],
['rightShoulder', 2, -0.22, 1.55, 0],
['rightElbow', 8, -0.25, 1.27, 0],
['rightHand', 9, -0.27, 1.0, 0],
['leftHip', 0, 0.1, 0.95, 0],
['leftKnee', 11, 0.1, 0.52, 0],
['leftFoot', 12, 0.1, 0.08, 0],
['rightHip', 0, -0.1, 0.95, 0],
['rightKnee', 14, -0.1, 0.52, 0],
['rightFoot', 15, -0.1, 0.08, 0],
];
/** The inverse of each joint's place in the bind pose: here only a translation, undone. */
const inverseBind = new Float32Array(JOINTS.length * 16);
JOINTS.forEach(([, , x, y, z], j) => {
inverseBind.set([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -x, -y, -z, 1], j * 16);
});
const skeleton = new Skeleton(
JOINTS.map(([name, parent]) => ({ name, parent })),
inverseBind,
);
/** The bind pose as each joint relative to its parent, which a clip leaves where it does not reach. */
const bind = createPose(JOINTS.length);
JOINTS.forEach(([, parent, x, y, z], j) => {
const [, , px, py, pz] = JOINTS[parent] ?? ['', -1, 0, 0, 0];
bind.translation.set(parent < 0 ? [x, y, z] : [x - px, y - py, z - pz], j * 3);
});
// #endregion
// #region mesh
/** A piece of the body a bone, each bound rigidly to the joint the bone hangs from. */
const SKIN: Vec3 = [0.85, 0.66, 0.52];
const CLOTH: Vec3 = [0.3, 0.42, 0.62];
const body = new MeshBuilder();
body.setJoint(0).addBox([0, 1.0, 0], [0.17, 0.1, 0.11], CLOTH);
body.setJoint(1).addBox([0, 1.2, 0], [0.16, 0.13, 0.1], CLOTH);
body.setJoint(2).addBox([0, 1.45, 0], [0.21, 0.14, 0.12], CLOTH);
body.setJoint(3).addCapsule([0, 1.68, 0], 0.045, 0.05, SKIN);
body.setJoint(4).addSphere([0, 1.86, 0], 0.13, SKIN, 0, 16, 10);
for (const [shoulder, side] of [
[5, 1],
[8, -1],
] as const) {
body.setJoint(shoulder).addCapsule([side * 0.235, 1.41, 0], 0.055, 0.11, CLOTH);
body.setJoint(shoulder + 1).addCapsule([side * 0.26, 1.14, 0], 0.05, 0.1, SKIN);
body.setJoint(shoulder + 2).addSphere([side * 0.27, 0.97, 0], 0.055, SKIN);
}
for (const [hip, side] of [
[11, 1],
[14, -1],
] as const) {
body.setJoint(hip).addCapsule([side * 0.1, 0.74, 0], 0.075, 0.15, CLOTH);
body.setJoint(hip + 1).addCapsule([side * 0.1, 0.3, 0], 0.06, 0.16, [0.25, 0.25, 0.28]);
body.setJoint(hip + 2).addBox([side * 0.1, 0.04, 0.05], [0.06, 0.04, 0.11], [0.2, 0.18, 0.16]);
}
const figure = renderer.createMesh(body.build());
// #endregion
// #region clips
/** A rotation about x as a quaternion: positive swings a hanging limb forward. */
const pitch = (a: number): number[] => [-Math.sin(a / 2), 0, 0, Math.cos(a / 2)];
/** A looping clip from keys spread evenly over its duration, a joint's angles at a time. */
function clip(
name: string,
durationSec: number,
angles: Record<number, number[]>,
bob: number[],
): AnimationClip {
const tracks: JointTrack[] = [];
for (const [joint, keys] of Object.entries(angles)) {
const times = Float32Array.from(keys, (_, i) => (i / (keys.length - 1)) * durationSec);
tracks.push({
joint: Number(joint),
path: 'rotation',
times,
values: Float32Array.from(keys.flatMap(pitch)),
});
}
const times = Float32Array.from(bob, (_, i) => (i / (bob.length - 1)) * durationSec);
tracks.push({
joint: 0,
path: 'translation',
times,
values: Float32Array.from(bob.flatMap((y) => [0, y, 0])),
});
return { name, durationSec, tracks };
}
/** One way, back through the middle, and the other way: a leg or an arm across a stride. */
const swing = (a: number): number[] => [a, 0, -a, 0, a];
/** A stride a second: legs and arms swing opposite, a knee folds as its foot passes, the hips dip. */
const WALK = clip(
'walk',
1,
{
11: swing(0.45),
14: swing(-0.45),
12: [0, -0.15, 0, -0.6, 0],
15: [0, -0.6, 0, -0.15, 0],
5: swing(-0.35),
8: swing(0.35),
},
[1.0, 0.97, 1.0, 0.97, 1.0],
);
// #endregion
const RUN = clip(
'run',
0.7,
{
11: swing(0.8),
14: swing(-0.8),
12: [-0.2, -0.3, -0.2, -1.2, -0.2],
15: [-0.2, -1.2, -0.2, -0.3, -0.2],
5: swing(-0.7),
8: swing(0.7),
6: [0.9, 0.9, 0.9, 0.9, 0.9],
9: [0.9, 0.9, 0.9, 0.9, 0.9],
/* A lean into the run: the spine points up, so forward is the other sign. */
1: [-0.15, -0.15, -0.15, -0.15, -0.15],
},
[0.95, 1.0, 0.95, 1.0, 0.95],
);
const IDLE = clip(
'idle',
3,
{ 2: [0, 0.03, 0, 0.03, 0], 5: [0, 0.04, 0, 0.04, 0], 8: [0, 0.04, 0, 0.04, 0] },
[1, 1.005, 1, 1.005, 1],
);
// #region tree
/** Idle, walk and run over one speed parameter. The walk and run take distance as their clock. */
const tree = new BlendTree(
{
kind: 'oneDimensional',
parameter: 'speed',
children: [
{ at: 0, node: { kind: 'clip', clip: IDLE } },
{ at: 1.4, node: { kind: 'clip', clip: WALK, clock: 'walkClock' } },
{ at: 4, node: { kind: 'clip', clip: RUN, clock: 'runClock' } },
],
},
JOINTS.length,
bind,
);
const pose = createPose(JOINTS.length);
// #endregion
// #region script
/** The rig's logic, hosted, and its record of the pace. */
const rig = hostScript(rigScript);
interface Gait {
wanted: number;
speed: number;
travelled: number;
reaching: boolean;
reached: boolean;
}
const gait = exported<() => Gait>(rig, 'createGait')();
type Animate = (
gait: Gait,
tree: BlendTree,
skeleton: Skeleton,
pose: Pose,
at: number,
dt: number,
x: number,
y: number,
z: number,
) => void;
if (import.meta.hot) {
import.meta.hot.accept('./rig.drs', (next) => {
if (next !== undefined) patchModule(rig, next as Record<string, unknown>, { Gait: [gait] });
});
}
// #endregion
const PACES: Record<string, number> = { idle: 0, walk: 1.4, run: 4 };
gait.wanted = PACES[flag('pace', 'walk')] ?? 1.4;
gait.reaching = flag('reach', 'on') === 'on';
controls([
{
key: 'pace',
label: 'pace',
value: flag('pace', 'walk'),
options: ['idle', 'walk', 'run'].map((p) => ({ text: p, value: p })),
change: (value) => {
gait.wanted = PACES[value] ?? 1.4;
},
},
{
key: 'reach',
label: 'reach',
value: gait.reaching ? 'on' : 'off',
options: ['on', 'off'].map((r) => ({ text: r, value: r })),
change: (value) => {
gait.reaching = value === 'on';
},
},
]);
/** The figure walks a circle round the origin; the lantern hangs just inside it, to its right. */
const RADIUS = 2.5;
const LANTERN: Vec3 = [-1.9, 1.75, 0];
let around = Math.PI / 2;
const model = new Float32Array(16);
const lanternLocal = new Float32Array(3);
const ground = renderer.createMesh(
new MeshBuilder().addBox([0, -0.1, 0], [60, 0.1, 60], [0.36, 0.42, 0.33]).build(),
);
const post = renderer.createMesh(
new MeshBuilder()
.addCylinder([LANTERN[0] + 0.6, 1.05, 0], 0.04, 1.05, 'y', [0.25, 0.25, 0.27])
.addBox([LANTERN[0] + 0.3, 2.08, 0], [0.32, 0.025, 0.025], [0.25, 0.25, 0.27])
.addBox([LANTERN[0], 1.97, 0], [0.008, 0.1, 0.008], [0.2, 0.2, 0.2])
.addSphere(LANTERN, 0.12, [1, 0.85, 0.5], 2, 14, 8)
.build(),
);
const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
const env = createEnvironment({
directionalDir: [0.45, 0.8, -0.4],
directionalColor: [1.7, 1.6, 1.45],
ambient: [0.32, 0.35, 0.42],
ambientGround: [0.13, 0.12, 0.11],
nightFactor: 0.6,
emissiveGain: 1.2,
});
const lightMatrix = new Float32Array(16);
env.lightViewProj = lightMatrix;
env.shadowStrength = 0.8;
env.shadowDepthSpan = computeLightMatrix(
env.directionalDir,
0,
1,
0,
4.5,
renderer.shadowMapSize,
lightMatrix,
);
const readout = createReadout(renderer, 1);
let time = 0;
stage.run({
simulate(dt) {
time += dt;
/* Counter-clockwise round the circle at the figure's own pace, facing the way it goes. */
around += (gait.speed * dt) / RADIUS;
const x = Math.cos(around) * RADIUS;
const z = Math.sin(around) * RADIUS;
const heading = Math.atan2(-Math.sin(around), Math.cos(around));
const c = Math.cos(heading);
const s = Math.sin(heading);
model.set([c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, x, 0, z, 1]);
/* The lantern in the figure's own frame: the inverse of a turn and a move. */
const dx = LANTERN[0] - x;
const dz = LANTERN[2] - z;
lanternLocal[0] = c * dx - s * dz;
lanternLocal[1] = LANTERN[1];
lanternLocal[2] = s * dx + c * dz;
exported<Animate>(rig, 'animate')(
gait,
tree,
skeleton,
pose,
time,
dt,
lanternLocal[0],
lanternLocal[1],
lanternLocal[2],
);
},
render() {
camera.fovYDeg = 40;
camera.position[0] = 3;
camera.position[1] = 3.2;
camera.position[2] = -7.5;
camera.lookAt(-0.6, 0.8, 0);
skeleton.applyPose(pose);
renderer.beginShadowPass(lightMatrix, 'static');
renderer.drawShadowCasters((sink) => {
sink.mesh(ground, IDENTITY);
sink.mesh(post, IDENTITY);
sink.skinnedMesh(figure, model, skeleton.palette);
});
renderer.endShadowPass();
renderer.beginFrame([0.56, 0.62, 0.7]);
renderer.bindMeshPass(camera, env);
renderer.drawMesh(ground, IDENTITY);
renderer.drawMesh(post, IDENTITY);
// #region draw
/* The palette is each joint's world matrix times its inverse bind; the mesh deforms by it. */
renderer.setSkinPalette(skeleton.palette);
renderer.drawMesh(figure, model);
renderer.setSkinPalette(null);
// #endregion
readout.set(0, `${gait.speed.toFixed(1)} M/S${gait.reached ? ' REACHING' : ''}`);
readout.draw(time);
renderer.endFrame();
},
});rig.drs
examples/animation/rig.drs
// The figure's animation: how fast it is going, how that blends its idle, walk and run, and its
// right hand reaching for the lantern when the lantern is close.
//
// Under `npm run examples`, change a rule and save: the figure moves by it from the next frame.
// Try a slower change of pace, or reaching from further away.
import { blendAt, blendSet, reach } from "drift/animation"
import { clamp, sqrt } from "std/math"
data Gait {
// The speed the switch asks for and the speed the figure is at, in metres a second.
wanted: f32 = 1.4
speed: f32 = 0
// Metres walked, which is the clock the walk and the run keep, so a foot does not slide.
travelled: f32 = 0
// Whether the hand may reach at all, and whether it reached on the last frame.
reaching: bool = true
reached: bool = false
}
// #region animate
// One frame: ease the pace, sample the blend, and reach where the lantern is near.
// The lantern's position is given in the figure's own frame, which is where its joints are.
fn animate(gait: mut Gait, tree: Blend, skeleton: Skeleton, pose: Pose, at: f32, dt: f32,
lanternX: f32, lanternY: f32, lanternZ: f32) {
// A metre a second of change in pace a second, either way.
gait.speed = gait.speed + math.clamp(gait.wanted - gait.speed, 0 - dt, dt)
gait.travelled = gait.travelled + gait.speed * dt
animation.blendSet(tree, "speed", gait.speed)
// Each clip's time is distance: the walk plays at its own pace at 1.4 metres a second, the
// run at 4, so a foot stays where it was put whatever the pace.
animation.blendSet(tree, "walkClock", gait.travelled / 1.4)
animation.blendSet(tree, "runClock", gait.travelled / 4)
animation.blendAt(tree, at, pose)
// The right shoulder, elbow and hand are joints 8, 9 and 10. Reach within a metre and a half,
// elbow bending down and back.
let dx = lanternX + 0.2
let dy = lanternY - 1.6
let dz = lanternZ
let near = math.sqrt(dx * dx + dy * dy + dz * dz) < 1.5
gait.reached = false
if gait.reaching && near {
gait.reached = animation.reach(skeleton, pose, 8, 9, 10, lanternX, lanternY, lanternZ, -0.4, 0.8, -1)
}
}
// #endregion