All examples
Skinned animation
A rigged fox demonstrates animation playback and blending. Move between walking and running, change the playback speed or drag the playhead to inspect a pose. Switch to Survey to see a transition into a different animation.
Off airLivePaused
Skinned animation
Models · 3D

WebGPU: checking…See browser support
Try this
- Drag Gait from walk to run. The Mix line shows how much each animation contributes.
- Set Speed to zero, then drag the Playhead to inspect individual poses.
- Switch State to Survey and watch the animations crossfade.
Show source code
Source
import { ANIMATOR_ASSET_TYPE, Animator, AnimatorAsset, Camera, defineAnimator, Environment, MODEL_ASSET_TYPE, Model, physics, threeD,} from "ignifx";import { bootExample } from "../_kit/boot.ts";import { percent, twoPlaces } from "../_kit/format.ts";import { attachOrbit } from "../_kit/orbit.ts";import { bind, readout, select, slider } from "../_kit/panel.ts";import { createGridGround, createLightRig, loadEnvironment } from "../_kit/stage.ts";import { clipMix, gaitLabel, seek, stateLengthSeconds } from "./playhead.ts";import type { AssetHandle, ModelAsset } from "ignifx";/** * A skinned glTF model played through an `Animator` state machine written in code. * * The machine has two states. `Locomotion` is a 1D blend tree whose `gait` parameter mixes the * Fox's Walk and Run clips; `Survey` is a single clip. `crossFade` moves between them over a quarter * of a second, and the skeleton is posed from the machine's cursor every frame in `PostUpdate`. * `Animator` comes from the `threeD()` extension, which needs `physics()` registered before it. *//** * Where Havok's WebAssembly is served from. `threeD()` requires `physics()`, and the asset manifest * has no entry for an extension's own file, so the default `"auto"` would look for it next to the * run page and 404 with `IGX-0903`. Vite's `BASE_URL` is right in `dev` and in `build`. */const HAVOK_WASM = `${import.meta.env.BASE_URL}assets/HavokPhysics.wasm`;/** The animator document's address. It is built here rather than loaded, so it names no file. */const ANIMATOR_ADDRESS = "memory:skinned-animation/fox.animator.json";/** The Fox, from `assets/models/fox.glb`. */const MODEL_ADDRESS = "models/fox.glb";/** The uniform scale the Fox is drawn at: its 79-unit bind pose becomes a 0.79 m animal. */const MODEL_SCALE = 0.01;/** * The opening shot: the state, the gait and the playhead every capture is taken from. A quarter of * the way through `Run` puts the fore legs forward and the hind legs back, which reads as running in * a still; frame zero has the near legs hiding the far ones. */const SHOT = { state: "Locomotion", gait: 1, playhead: 0.25, yaw: 52, pitch: 24 } as const;/** The states the panel's select offers. `Survey` is three and a half times longer than either gait. */const STATES: readonly string[] = ["Locomotion", "Survey"];bootExample({ title: "Skinned animation", extensions: [physics(), threeD()], settings: { rendering: { clearColor: { r: 0.043, g: 0.059, b: 0.094, a: 1 }, // Read once, when `app.start()` registers the scene; asking afterwards is `IGX-0704`. features: { shadows: true }, }, physics: { havokWasm: HAVOK_WASM }, }, async setup({ app, panel, afterStart }) { const focus = { x: 0, y: 0.32, z: 0 }; const eye = app.world.createEntity("Main Camera"); eye.addComponent(Camera, { near: 0.05, far: 200, fov: 36 }); const orbit = attachOrbit(app, eye, { yaw: SHOT.yaw, pitch: SHOT.pitch, minDistance: 0.8, maxDistance: 10 }); orbit.frame({ center: focus, radius: 0.95 }, 1.2); createLightRig(app, { focus, keyIntensity: 2.4, rimIntensity: 1.1, shadowDarkness: 0.3 }); // Awaited before the loop runs, so both settle at once rather than waiting for a `PreUpdate`. const model: AssetHandle<ModelAsset> = app.assets.load(MODEL_ADDRESS, { type: MODEL_ASSET_TYPE }); const environment = loadEnvironment(app, "studio"); await Promise.all([model.promise, environment.promise]); // `studio.environment.json` loads with its skybox off, so the component says the same rather // than being reported as `IGX-0711`. The background is the clear colour. const sky = app.world .createEntity("Environment") .addComponent(Environment, { environment, skybox: { enabled: false, size: 20 } }); sky.imageProcessing.toneMapping = "aces"; sky.imageProcessing.exposure = 1.1; // After the `Environment`, so the fade reuses it. One grid cell is a metre: the frame says how big // the fox is without a ruler in it. await createGridGround(app, { fade: true }); // `defineAnimator` validates the document and fills in every default, so what is written here // is only what this example decides. `clip` and the blend children name the `.glb`'s animation // groups. `gait` opens at its `value`: the machine is built on the animator's first // `PostUpdate`, so a `setFloat` during setup would reach nothing. const animatorDocument = defineAnimator( { parameters: [{ name: "gait", kind: "float", value: SHOT.gait }], layers: [{ name: "Base", defaultState: "Locomotion" }], states: [ { name: "Locomotion", blendTree: "locomotion" }, { name: "Survey", clip: "Survey" }, ], blendTrees1D: [ { name: "locomotion", param: "gait", children: [ { clip: "Walk", threshold: 0 }, { clip: "Run", threshold: 1 }, ], }, ], }, ANIMATOR_ADDRESS, ); // `register` publishes an in-code value as a loaded handle, so the animator takes it exactly as // it would take a `.animator.json` off the manifest. const animatorAsset = app.assets.register(new AnimatorAsset(ANIMATOR_ADDRESS, animatorDocument), { type: ANIMATOR_ASSET_TYPE, address: ANIMATOR_ADDRESS, }); const entity = app.world.createEntity("Fox"); entity.transform.localScale.set(MODEL_SCALE, MODEL_SCALE, MODEL_SCALE); // The rig's bind-pose box is centred 10.74 units off the file's origin along Z. entity.transform.localPosition.set(0, 0, 0.1074); const fox = entity.addComponent(Model, { model, castShadows: true, receiveShadows: true }); const animator = entity.addComponent(Animator, { animator: animatorAsset, speed: 1 }); // The machine exists once the first frame has run, which `afterStart` waits for; `?static=1` // holds the clock, so this is exactly the frozen pose. afterStart((): void => { seek(animator, fox, SHOT.playhead); }); panel({ title: "Skinned animation", groups: [ { label: "Clips", controls: [ // `crossFade` is `play` with a transition length: the machine keeps both states' // cursors running and ramps one weight into the other over these seconds. select("State", STATES, { value: SHOT.state, change: (state: string): void => { animator.crossFade(state, 0.25); }, }), // At 0 the pose is Walk, at 1 it is Run, and in between it is both at once. slider( "Gait", { min: 0, max: 1, step: 0.01, format: gaitLabel }, { value: SHOT.gait, change: (value: number): void => { animator.setFloat("gait", value); }, read: (): number => animator.getFloat("gait"), }, ), readout("Mix", (): string => clipMix(animator)), ], }, { label: "Playback", controls: [ // A multiplier on every state's own rate. Zero holds the pose, so the playhead can be dragged. slider("Speed", { min: 0, max: 2, step: 0.05, format: twoPlaces }, bind(animator, "speed")), slider( "Playhead", { min: 0, max: 1, step: 0.01, format: percent }, { value: SHOT.playhead, change: (value: number): void => { seek(animator, fox, value); }, read: (): number => animator.normalizedTime(), }, ), readout("State", (): string => animator.currentState()), readout("Length", (): string => `${twoPlaces(stateLengthSeconds(animator, fox))} s`), ], }, { label: "Frame", collapsed: true, controls: [ readout("Draw calls", (): string => String(app.renderer.drawCalls)), readout("Clips in file", (): string => String(fox.animations.length)), ], }, ], }); },});import { twoPlaces } from "../_kit/format.ts";import type { Animator, AnimatorStateMachine, Model } from "ignifx";/** * Seeking a clip — the one thing the panel asks for that `Animator` has no single call for — and * the two lines of text the panel reads the blend with. * * @remarks * `Animator` is a thin shell over two halves: a pure {@link AnimatorStateMachine}, which turns * parameters and a delta into per-clip weights and a cursor, and a mixer, which poses the skeleton. * Every frame, in `PostUpdate`, the component copies its `speed` onto the machine, advances the * machine by the frame's delta, and writes the resulting cursor into every weighted clip — so **the * pose is a pure function of the machine's cursor**, recomputed from scratch each frame. * * That is what makes a seek possible with no new engine API: move the cursor, and the next * `PostUpdate` poses the skeleton from wherever it now is. `animator.stateMachine` is the public * handle on it and `advance(seconds)` is a public method, so a seek is one signed `advance` of the * difference between where the cursor is and where the slider wants it. * * Two details are load bearing, and both are in {@link seek}: the machine's `speed` has to be one * for the call (the component overwrites it on the very next frame, so nothing has to be restored), * and the delta is in **seconds**, which means the length of whatever the current state plays. *//** What the machine assumes a clip is worth when nothing has declared its length, in seconds. */const FALLBACK_LENGTH_SECONDS = 1;/** * How long one of the model's clips runs, in seconds. * * @param model - The model whose clips are measured. * @param name - The clip's name in the `.glb`. * @returns The duration, or `0` when the model has no such clip. */function clipLength(model: Model, name: string): number { for (const clip of model.animations) { if (clip.name === name) { return clip.duration; } } return 0;}/** * How long the state a layer is currently in runs for, in seconds. * * @remarks * The clip lengths come from `Model.animations`, the loaded glTF's animation groups. A state that * names one clip is that clip long. A state that names a **blend tree** is as long as its longest * child, which is the machine's own rule and is what stops the cursor jumping when the blend * parameter crosses between clips of different lengths. * * @param animator - The animator whose current state is measured. * @param model - The model whose clips it plays. * @returns The length in seconds, or {@link FALLBACK_LENGTH_SECONDS} before the model has loaded. */export function stateLengthSeconds(animator: Animator, model: Model): number { const asset = animator.animator?.value ?? null; const state = asset === null ? null : asset.state(animator.currentState()); if (asset === null || state === null) { return FALLBACK_LENGTH_SECONDS; } if (state.clip !== "") { return clipLength(model, state.clip) || FALLBACK_LENGTH_SECONDS; } const tree = asset.definition.blendTrees1D.find((candidate) => candidate.name === state.blendTree); let longest = 0; for (const child of tree?.children ?? []) { longest = Math.max(longest, clipLength(model, child.clip)); } return longest > 0 ? longest : FALLBACK_LENGTH_SECONDS;}/** * Moves the playhead to a fraction of the current state. * * @remarks * Seeking while the clip is running does what scrubbing a playing video does: the pose jumps to * where you dropped it and carries on from there. Set the speed to zero first to hold a pose. Does * nothing before the animator's first `PostUpdate`, which is when it builds its machine. * * @param animator - The animator to seek. * @param model - The model whose clip lengths say how long a fraction is. * @param target - Where the playhead should sit, in `[0, 1]`. */export function seek(animator: Animator, model: Model, target: number): void { const machine: AnimatorStateMachine | null = animator.stateMachine; if (machine === null) { return; } const seconds = (target - machine.normalizedTime()) * stateLengthSeconds(animator, model); // The component assigns `machine.speed = this.speed` at the top of every `PostUpdate`, so this // write only affects the `advance` below and nothing has to put it back. Without it a seek does // nothing at all whenever the animator is stopped, which is exactly when a seek is wanted. machine.speed = 1; machine.advance(seconds);}/** * Writes the gait parameter: the clip's name at either end, the blend weight in between. * * @param value - The `gait` parameter, `0` (walk) to `1` (run). * @returns The text for the slider's value cell. */export function gaitLabel(value: number): string { return value <= 0 ? "walk" : value >= 1 ? "run" : twoPlaces(value);}/** * Which clips are contributing to this frame's pose, and how much of each. * * @param animator - The animator to read. * @returns One `name weight` pair per weighted clip, or a dash before the machine exists. */export function clipMix(animator: Animator): string { const clips = animator.stateMachine?.clips ?? []; return clips.length === 0 ? "—" : clips.map((clip) => `${clip.clip} ${twoPlaces(clip.weight)}`).join(" · ");}