ignifx
All examples

Animator

Gameplay3D

  • Mouse
  • Touch
  • Gamepad

Control a fox with an animation state machine. Changing speed blends its movement clips, while the Survey trigger plays a look-around animation. The panel shows the current state and counts animation events as they fire.

A low-poly orange fox mid-stride on a grey tiled floor at dusk, front legs reaching forward and back legs pushing off, its tail streaming behind it and its shadow falling to the left.
Open standalone

` devtools

WebGPU: checking…

Try this

  • Drag speed from 0 to 8 and watch the Gait readout name the two clips being mixed at each value.
  • Press survey: the State readout changes, both events fire, and the exit time returns the fox to its gait.
  • Set Rate to 0.25x and press survey again to watch the events in slow motion.
Show source code

Source

main.ts
import { ANIMATOR_ASSET_TYPE, Animator, Camera, MODEL_ASSET_TYPE, Model, physics, threeD } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { bind, button, readout, slider } from "../_kit/panel.ts";import { createGridGround, createLightRig } from "../_kit/stage.ts";// The document sits in this example's own directory rather than under `assets/`, so it is not in// the plugin's manifest. `?url` makes Vite emit and hash it like any other asset and hand back its// path; resolving that against this module's own URL is what turns it into the absolute address// `app.assets.load` needs, in dev and in the built site alike.import machineUrl from "./fox.animator.json?url";import type { AnimatorAsset, ModelAsset } from "ignifx";/** * An `Animator`: one `.animator.json` document, one rigged model, and the four things a state * machine is for. `fox.animator.json` next to this file is the whole behaviour and is worth * reading first. It declares a **1D blend tree** on a `speed` parameter — `Survey` at 0, `Walk` at * 2, `Run` at 6, so a value of 4 plays half of each and the gait follows the number rather than * snapping at a threshold; a **trigger** that cuts to a one-shot `survey` state from wherever the * machine is; two **animation events** on that state, which arrive on `Animator.onEvent` as the * clip crosses them; and an **exit time**, which returns the fox to its locomotion nine tenths of * the way through. The code writes one parameter and sets one trigger; the rest is data. * * `Survey` is named twice on purpose: the Fox ships three clips and that one is both the idle and * the look-around, so it is the blend tree's low end *and* a state of its own with `loop: false` * and events on it. The fox runs on the spot for the same kind of reason — the parameter is the * subject, and `third-person` is where a controller writes it from a character's real speed. *//** The metres one of the Fox's authoring units becomes: it is modelled in centimetres. */const FOX_SCALE = 0.009;/** How tall the scaled fox is, in metres: its 79.03-unit bind pose times {@link FOX_SCALE}. */const FOX_HEIGHT = 0.71;/** * The `speed` value the page opens on: between `Walk` and `Run`, so the blend is doing something. * * @remarks * The number the **document** declares, and the panel opens the slider there to match. A start * value belongs in the document, because an `Animator` builds its state machine on its first * advance and a `setFloat` before the first frame has nothing to write to. */const START_SPEED = 4;/** The address of the Khronos Fox. */const FOX_ADDRESS = "models/fox.glb";/** * Where Havok's WebAssembly is served from. * * @remarks * `threeD()` requires `physics()`, and the manifest carries no entry for an extension's public * asset — it is copied unhashed and served by name, so `"auto"` resolves to a page-relative path a * run page three segments deep cannot reach. `third-person/main.ts` says it at more length. */const HAVOK_WASM_URL = `${import.meta.env.BASE_URL}assets/HavokPhysics.wasm`;/** How many recent event names the readout shows. */const EVENT_HISTORY = 3;/** * How far into the locomotion cycle the fox starts, in seconds. * * @remarks * A clip's first frame is rarely its best; this one opens with the fox almost square. `Animator` * writes the pose from its layer's own cursor every frame, so advancing the machine once starts * the fox mid-stride on the first frame, live and in a `?static=1` capture alike. */const START_PHASE = 0.38;bootExample({  title: "Animator",  // `threeD()` requires `physics()` and `input()` before it; the kit registers `input()` first.  extensions: [physics(), threeD()],  settings: {    rendering: {      clearColor: { r: 0.36, g: 0.395, b: 0.44, a: 1 },      msaaSamples: 4,      features: { shadows: true },    },    time: { fixedDeltaTime: 1 / 60 },    physics: { havokWasm: HAVOK_WASM_URL },  },  async setup({ app, panel, afterStart }) {    const eye = app.world.createEntity("Main Camera");    eye.addComponent(Camera, { near: 0.05, far: 100, fov: 40 });    attachOrbit(app, eye, {      yaw: 132,      pitch: 16,      distance: 2.1,      target: { x: 0, y: FOX_HEIGHT * 0.6, z: 0 },      minDistance: 1,      maxDistance: 10,    });    // Both handles are awaited before `app.start()`, where a completed load settles at once.    const model = app.assets.load<ModelAsset>(FOX_ADDRESS, { type: MODEL_ASSET_TYPE });    const machineAddress = new URL(machineUrl, import.meta.url).href;    const machine = app.assets.load<AnimatorAsset>(machineAddress, { type: ANIMATOR_ASSET_TYPE });    await createGridGround(app, { size: 24 });    await Promise.all([model.promise, machine.promise]);    createLightRig(app, { focus: { x: 0, y: FOX_HEIGHT / 2, z: 0 }, keyPosition: { x: -2.4, y: 3.4, z: -2 } });    const fox = app.world.createEntity("Fox");    fox.transform.localScale.set(FOX_SCALE, FOX_SCALE, FOX_SCALE);    fox.addComponent(Model, { model: model.retain(), castShadows: true, receiveShadows: true });    // One `Animator` per **model asset**: Lite binds an animation group to a single manager, so a    // second animator over one `.glb` is refused the clips. A second fox needs a second address.    const animator = fox.addComponent(Animator, { animator: machine.retain() });    // `onEvent` fires once per crossing, in the frame the clip passes the event's time; `owner`    // scopes the connection so it disconnects when the animator does.    const seen: string[] = [];    let total = 0;    animator.onEvent.connect(      (name: string): void => {        total += 1;        seen.unshift(name);        seen.length = Math.min(seen.length, EVENT_HISTORY);        app.log.info("animation event:", name);      },      { owner: animator },    );    // The machine exists from the animator's first advance, which `app.start()` has just run.    afterStart((): void => {      animator.stateMachine?.advance(START_PHASE);    });    // One number drives the whole tree: the document decides which clips it mixes, and in what    // proportion, from this one parameter.    const setSpeed = (value: number): void => {      animator.setFloat("speed", value);    };    panel({      title: "Animator",      groups: [        {          label: "Blend tree",          controls: [            slider("speed", { min: 0, max: 8, step: 0.1 }, { value: START_SPEED, change: setSpeed }),            readout("Gait", (): string => gaitFor(animator.getFloat("speed"))),            slider("Rate", { min: 0.1, max: 2, step: 0.05, format: times }, bind(animator, "speed")),          ],        },        {          label: "Trigger",          controls: [            // A trigger is consumed by the first transition that reads it, so one press is one            // look-around however long the frame took.            button("survey", (): void => {              animator.setTrigger("survey");            }),            readout("State", (): string => animator.currentState()),            readout("Clip time", (): string => `${(animator.normalizedTime() * 100).toFixed(0)}%`),          ],        },        {          label: "Animation events",          controls: [            readout("Fired", (): string => String(total)),            readout("Recent", (): string => (seen.length === 0 ? "none yet" : seen.join(", "))),          ],        },        {          label: "Frame",          collapsed: true,          controls: [readout("Draw calls", (): string => String(app.renderer.drawCalls))],        },      ],    });  },});/** * Names the clips a `speed` value is mixing, so the readout says what the number means. * * @param speed - The `speed` parameter. * @returns The pair being blended, or the single clip at a threshold. */function gaitFor(speed: number): string {  if (speed < 2) {    return speed <= 0 ? "Survey" : "Survey + Walk";  }  return speed === 2 ? "Walk" : speed < 6 ? "Walk + Run" : "Run";}/** * Writes a playback multiplier. * * @param value - The multiplier. * @returns The text for the slider's value cell. */function times(value: number): string {  return `${value.toFixed(2)}x`;}
fox.animator.json
{  "format": "ignifx.animator",  "formatVersion": 1,  "parameters": [    { "name": "speed", "kind": "float", "value": 4 },    { "name": "survey", "kind": "trigger" }  ],  "layers": [{ "name": "Base", "weight": 1, "defaultState": "locomotion" }],  "states": [    { "name": "locomotion", "blendTree": "locomotion", "loop": true },    {      "name": "survey",      "clip": "Survey",      "loop": false,      "events": [        { "time": 0.3, "name": "look-up" },        { "time": 0.75, "name": "sniff" }      ]    }  ],  "transitions": [    {      "from": "any",      "to": "survey",      "conditions": [{ "param": "survey", "op": "trigger" }],      "duration": 0.12    },    { "from": "survey", "to": "locomotion", "duration": 0.25, "exitTime": 0.9 }  ],  "blendTrees1D": [    {      "name": "locomotion",      "param": "speed",      "children": [        { "clip": "Survey", "threshold": 0 },        { "clip": "Walk", "threshold": 2 },        { "clip": "Run", "threshold": 6 }      ]    }  ]}

Uses:AnimatorAnimatorAssetAnimator.onEventModelapp.assets.loadOrbitCamera

Assets:Fox — CC0 1.0 (model), CC-BY 4.0 (rig, animation and glTF conversion), PixelMannen, tomkranis, AsoboStudio and scurest