All examples
Skinned animation
- 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.

WebGPU: checking…See browser support
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
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)), ], }, ], }); },});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); }}