All examples

Skinned animation

Models3D
  • Mouse
  • Touch
  • Gamepad

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

A low-polygon orange fox at full stretch mid-run, fore legs forward and hind legs back, on a dark grid floor with its shadow beneath it.
Open standalone

WebGPU: checking…

Try this

  1. Drag Gait from walk to run. The Mix line shows how much each animation contributes.
  2. Set Speed to zero, then drag the Playhead to inspect individual poses.
  3. Switch State to Survey and watch the animations crossfade.
Show source code
main.ts
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)),          ],        },      ],    });  },});
playhead.ts
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(" · ");}
Uses
AnimatordefineAnimatorAnimatorAssetapp.assets.registerAnimator.crossFadeAnimatorStateMachineModel.animations
Assets
Fox — CC0 1.0 (model), CC-BY 4.0 (rig and glTF conversion), PixelMannen; rig and animation by tomkranis; conversion by @AsoboStudio and @scurestStudio environment — CC-BY 4.0, Babylon.js contributors