ignifx
GitHubnpm · soon
All examples

Skinned animation

Models3D

  • Mouse
  • Touch
  • Gamepad

The Khronos Fox ships three animations — Survey, Walk and Run — and core does not play them: `ModelAsset` strips the clips off the loaded glTF and `Animator`, from `@ignifx/3d`, runs a state machine document over them. The document here is the smallest one that means anything: one float, one layer, two states and a one-dimensional blend tree. Gait moves along the tree and weights Walk against Run on one shared cursor; the Mix readout is those weights, live.

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

` devtools

WebGPU: checking…

Try this

  • Drag Gait from walk to run and read the Mix line: two clips at once, phase-locked, with no call to blend them.
  • Set Speed to zero and drag the Playhead: the pose is recomputed from the state machine's cursor every frame, so seeking is moving the cursor.
  • Switch the State to Survey: a crossfade does not care that the two clips are 1.16 and 3.42 seconds long.
Show source

Source

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 { attachOrbit } from "../_kit/orbit.ts";import { bind, readout, select, slider } from "../_kit/panel.ts";import { createGridGround, createLightRig, loadEnvironment } from "../_kit/stage.ts";import { OpeningPose, seek, stateLengthSeconds } from "./playhead.ts";import type { AssetHandle, ModelAsset } from "ignifx";/** * A rigged glTF with three clips: choose one, blend two of them into each other, change the rate, * and drag the playhead. * * The Fox ships three animations — `Survey`, `Walk` and `Run` — and nothing in core plays them. * `ModelAsset` strips the clips off the loaded glTF and hands them on as read-only metadata * (`Model.animations`), and `Animator`, from `@ignifx/3d`, is what drives them: it runs a * **state machine document**, not a clip list, because that is what a game needs by the second * character. This example is the smallest such document — one layer, two states, a blend tree — * and the panel is its parameters. * * Three things a reader should take away: * * 1. **A state names a clip of the `.glb`, by name.** `Survey` below is the clip's own name, and *    the document would fail to pose anything if it were spelled differently. * 2. **Blending is a parameter, not a call.** `gait` moves along the one-dimensional blend tree, *    which weights `Walk` and `Run` against each other and keeps both phase-locked to the same *    cursor. Switching *states* is the other kind of blend, and `crossFade` is that one. * 3. **The pose is recomputed from the machine's cursor every frame**, in `PostUpdate`. That is *    what makes the playhead slider possible with no engine API for it; `playhead.ts`, beside this *    file, is the whole of that. * * `threeD()` requires `physics()` and `input()`, so all three are registered even though nothing * here collides with anything: the toolkit's controllers move a `CharacterController` and its * navigation needs the layer table, and the extension host refuses a partial graph rather than * failing later. *//** * Where Havok's WebAssembly is served from. * * @remarks * `threeD()` requires `physics()`, and `physics({ havokWasm: "auto" })` — the default — asks the * asset manifest for `HavokPhysics.wasm`. The manifest has no entry for it: an extension's public * asset is copied **unhashed, by base name** into the public asset path, next to the manifest * rather than in it, so `resolveUrl` falls back to the relative `assets/HavokPhysics.wasm` and the * browser resolves that against `/examples/<slug>/run/` — a 404, and then `IGX-0903`. Vite's own * `BASE_URL` is the base the plugin wrote its URLs from, so this one line 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. * * @remarks * The file is authored 79.03 units tall and 154.72 long in its bind pose (measured from the * committed `.glb` with `@gltf-transform/core`'s `getBounds`, 2026-09-08), which makes it a * 0.79 m animal at this scale — a large fox, and the size the grid reads best against. */const MODEL_SCALE = 0.01;/** The clock the fixed step runs at, so a blend is the same length on any machine. */const FIXED_STEP = 1 / 60;/** How the panel writes a fraction. */const PERCENT = 100;/** The clear colour: the site's dark `--bg`, a shade deeper. */const CLEAR_COLOR = { r: 0.043, g: 0.059, b: 0.094, a: 1 };/** * The opening shot: the state, the gait and the playhead every capture is taken from. * * @remarks * `playhead` is not zero because frame zero of a locomotion clip is the pose the rigger happened to * key first, which for this rig is a mid-stride with both near legs occluding the far ones. A * quarter of the way through `Run` puts the fore legs forward and the hind legs back, which is what * a still of a running animal should look like. `?static=1` freezes the clock before `app.start()`, * so this is exactly the frozen pose (`playhead.ts`, `OpeningPose`). */const SHOT = { state: "Locomotion", gait: 1, playhead: 0.25, yaw: 52, pitch: 24 } as const;/** * The states the panel's select offers, and the clip or tree each one plays. * * @remarks * `Locomotion` is the blend tree; `Survey` is the fox looking around, which is a single clip and * three and a half times longer than either gait, so switching between the two is also a * demonstration that a crossfade does not care how long its two sides are. */const STATES: readonly string[] = ["Locomotion", "Survey"];bootExample({  title: "Skinned animation",  extensions: [physics(), threeD()],  settings: {    rendering: {      clearColor: CLEAR_COLOR,      msaaSamples: 4,      // Read once, when `app.start()` registers the scene; asking afterwards is `IGX-0704`.      // `skeletons` is Lite's opt-in for skinning a **Standard**-material mesh      // (`index.d.ts`: "Enable four/eight-influence skeletal skinning for Standard meshes"), and it      // is declared because a scene that mixes a glTF's PBR materials with a material built from      // `standardMaterialDefinition` needs it and there is no way to ask for it later.      features: { shadows: true, skeletons: true },    },    time: { fixedDeltaTime: FIXED_STEP },    physics: { havokWasm: HAVOK_WASM },  },  async setup({ app, panel }) {    app.registerComponents([OpeningPose]);    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,      // With a 36-degree vertical field of view the top of the frame is 18 degrees above centre,      // so a pitch under that leaves the ground plane's horizon — and a band of empty clear colour      // above it — in shot.      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 });    // Wide enough that its far edge is past the vanishing line at this camera pitch. One grid cell    // is one metre at any size, so the frame says how big the fox is without a ruler in it.    await createGridGround(app, { size: 60 });    // 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]);    const sky = app.world      .createEntity("Environment")      // `skybox` is decided when the `.env` loads, not here: `studio.environment.json` declares      // `skyboxEnabled: false`, so the component is told the same thing rather than left at its      // default and reported as `IGX-0711`. The background is the clear colour.      .addComponent(Environment, { environment, clearColor: CLEAR_COLOR, skybox: { enabled: false, size: 20 } });    sky.imageProcessing.toneMapping = "aces";    sky.imageProcessing.exposure = 1.1;    // The document. `defineAnimator` validates it and fills in every default, so what is written    // here is only what this example decides: one float, one layer, two states, one blend tree.    // `clip` and the blend children name **animation groups of the `.glb`**, by name.    const animatorDocument = defineAnimator(      {        // `value` is the parameter's opening value, and declaring it here rather than calling        // `setFloat` in `setup` is not a style choice: the state machine is built on the        // animator's first `PostUpdate`, so a `setFloat` before the first frame reaches nothing.        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 with one holder, so the animator's    // `asset()` field takes it exactly as it would take a `.animator.json` off the manifest. A    // document loaded from a file is the usual way; this one is in the source so it can be read.    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 runs from -88.10 to 66.63 along Z, so its centre is 10.74 units off    // the file's origin; this is that measurement, scaled, which puts the animal in the frame's    // middle rather than a tenth of a metre to one side of it.    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 });    entity.addComponent(OpeningPose, { playhead: SHOT.playhead });    /**     * Which clips are contributing to this frame's pose, and how much of each.     *     * @returns One `name weight` pair per weighted clip, or a dash before the machine exists.     */    const mix = (): string => {      const clips = animator.stateMachine?.clips ?? [];      if (clips.length === 0) {        return "";      }      return clips.map((clip) => `${clip.clip} ${clip.weight.toFixed(2)}`).join(" · ");    };    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);              },            }),            // The blend tree's parameter. At 0 the pose is Walk, at 1 it is Run, and in between it            // is both at once — one clip's weight against the other's, on one shared cursor.            slider(              "Gait",              { min: 0, max: 1, step: 0.01, format: (value: number): string => (value < 0.5 ? "walk" : "run") },              {                value: SHOT.gait,                change: (value: number): void => {                  animator.setFloat("gait", value);                },              },            ),            readout("Mix", mix),          ],        },        {          label: "Playback",          controls: [            // A multiplier on every state's own rate. Zero holds the pose, which is what makes the            // playhead below worth dragging.            slider("Speed", { min: 0, max: 2, step: 0.05 }, bind(animator, "speed")),            slider(              "Playhead",              { min: 0, max: 1, step: 0.01, format: (value: number): string => `${(value * PERCENT).toFixed(0)}%` },              {                value: SHOT.playhead,                change: (value: number): void => {                  seek(animator, fox, value);                },              },            ),            readout("State", (): string => animator.currentState()),            readout("Length", (): string => `${stateLengthSeconds(animator, fox).toFixed(2)} 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 { Animator, f32, Model, Script } from "ignifx";import type { AnimatorStateMachine, ScriptCallbacks } from "ignifx";/** * Seeking a clip: the one thing the panel asks for that `Animator` has no single call for, and the * script that puts the opening pose on screen before the first capture. * * @remarks * `Animator` is a thin shell over two halves (`packages/3d/src/animator/animator.ts` says so at * length): a pure {@link AnimatorStateMachine}, which turns parameters and a delta into per-clip * weights and a cursor, and a mixer, which is the only part that knows Babylon Lite exists. 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. A * `seek(animator, model, t)` in the engine would spare a reader all of this; until there is one, * this file is what it costs. *//** What the machine assumes a clip is worth when nothing has declared its length, in seconds. */const FALLBACK_LENGTH_SECONDS = 1;/** * How long the state a layer is currently in runs for, in seconds. * * @remarks * The clip lengths come from `Model.animations` — Babylon Lite's own animation groups, which * `ModelAsset` strips off the loaded glTF and the `Animator` hands to its mixer. 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 (`state-machine.ts`, `#lengthOf`) 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;  }  const lengths = new Map<string, number>();  for (const clip of model.animations) {    lengths.set(clip.name, clip.duration);  }  if (state.clip !== "") {    return lengths.get(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, lengths.get(child.clip) ?? 0);  }  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. * * @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);}/** * Puts the opening pose on screen: one seek, on the first frame that has a state machine to seek. * * @remarks * The machine does not exist until the animator's first `PostUpdate`, which is after `app.start()` * has returned, so the opening pose cannot be set while the world is being built. `lateUpdate` is * the phase that runs **after** `PostUpdate` — the same reason `ThirdPersonCamera` frames a * character there — so the first `lateUpdate` is the earliest moment a cursor can be written, and * the pose it produces is drawn from the next frame on. Sixteen settling frames later the capture * is taken, and `?static=1` has held the clock at zero throughout, so the frozen pose is exactly * this one. */export class OpeningPose extends Script.define({ playhead: f32(0, { min: 0, max: 1 }) }) implements ScriptCallbacks {  /** The namespaced registration id. */  static typeId = "skinned-animation/OpeningPose";  /** Whether the seek has happened. The script does nothing at all afterwards. */  #posed = false;  /** Seeks once, as soon as there is a state machine. */  lateUpdate(): void {    if (this.#posed) {      return;    }    const animator = this.entity.getComponent(Animator);    const model = this.entity.getComponent(Model);    if (animator === null || model === null || animator.stateMachine === null) {      return;    }    this.#posed = true;    seek(animator, model, this.playhead);  }}

Uses:ModelAnimatordefineAnimatorAnimatorStateMachineModel.animationsfeatures.skeletons

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