All examples

Walk on terrain

Terrain3D
  • Keyboard
  • Mouse
  • Touch
  • Gamepad

`@ignifx/terrain` does not import `@ignifx/physics`, so a terrain in a game with no physics costs no physics code. The join is data: `terrain.colliderInit()` answers the exact fields a `HeightfieldCollider` declares — a row-major heights array, its sample counts and the size in metres — and the collider centres its shape on the terrain's own entity, so handing it straight to `addComponent` is the entire integration, and re-reading it inside `onHeightsChanged` is how a sculpted terrain stays solid. The readouts under it come from the height field rather than from the collider, which is why `heightAt` and `slopeAt` answer whether or not physics is running. The rig's `idle`, `walk` and `run` clips are one blend tree in `walker.animator.json`, driven by the controller's speed. The dust is a `ParticleSystem` that never plays: a script counts stride length and calls `emit()`, and emission runs late in `Update` so a burst asked for this frame is drawn this frame.

Off airLivePaused

Walk on terrain

Terrain · 3D

A small blocky white character seen side-on in mid-stride on a gentle grassy slope, leaning into its step with its shadow on the grass beside it, while a hillside streaked with brown dirt rises behind it to a ridge under a pale blue sky.
Open standalone

WebGPU: checking…

Try this

  1. Hold S down the long slope behind the start, then W back up it: Slope reaches 30° on the steepest stretch, and Grounded stays yes the whole way.
  2. Walk, then hold Shift: the rig blends from its walk clip into its run on the controller's speed alone.
  3. Pull Stride down to 0.6 m and take a few steps from the start: a puff per 0.6 m of ground, not per second.
Show source code
main.ts
import {  ANIMATOR_ASSET_TYPE,  Animator,  Camera,  CharacterController,  HeightfieldCollider,  MODEL_ASSET_TYPE,  Model,  particleAssetFromDefinition,  particleDefinition,  particles,  ParticleSystem,  physics,  Terrain,  terrain,  terrainAssetFromDefinition,  ThirdPersonCamera,  ThirdPersonController,  threeD,} from "ignifx";import { bootExample } from "../_kit/boot.ts";import { metres } from "../_kit/format.ts";import { bind, readout, slider, toggle } from "../_kit/panel.ts";import { easedEdgeHeights } from "../_kit/terrain.ts";import { dressHills, HILLS, SKY, StayOnTerrain } from "./hills.ts";import { attachTouchStick, DragToLook, PLAYER_ACTIONS } from "./controls.ts";import { Footsteps } from "./footsteps.ts";import { Gait } from "./gait.ts";import { posePosterStride, shotFor, SPAWN } from "./shot.ts";import machineUrl from "./walker.animator.json?url";import type { AnimatorAsset, ModelAsset, Vec3 } from "ignifx";/** * A character walking on a terrain, and the one line that makes the ground solid. * * ## Physics by data, not by dependency * * `@ignifx/terrain` does not import `@ignifx/physics`: a terrain in a game with no physics costs no * physics code. The join is `terrain.colliderInit()`, which answers the exact fields a * `HeightfieldCollider` declares — a row-major `heights` array, its sample counts and the size in * metres. The collider centres its shape on its own entity, which is the terrain's, so passing the * init straight into `addComponent` is the whole integration, and re-reading it inside * `onHeightsChanged` is how a sculpted terrain stays solid. The readouts' `heightAt` and `slopeAt` * read the height field itself, so they work whether or not physics is running. * * ## The gait * * The rig carries `idle`, `walk` and `run` clips, and `walker.animator.json` blends them by one * `speed` parameter that `gait.ts` copies from the controller each frame, so the stride on screen * is the speed the readout shows. * * ## The dust * * `footsteps.ts` counts stride length and calls `ParticleSystem.emit()`. Emission runs late in * `Update`, after every script, so a burst asked for this frame is uploaded and drawn this frame. *//** The repository's own rigged "box-man". */const RIG_ADDRESS = "models/rig.glb";/** Where Havok's WebAssembly is served from; an extension's public asset is copied unhashed. */const HAVOK_WASM_URL = `${import.meta.env.BASE_URL}assets/HavokPhysics.wasm`;/** The character capsule's height, in metres. The rig's boxes reach 1.45 m above its origin. */const CAPSULE_HEIGHT = 1.6;/** How far above its origin the rig's lowest box starts, in metres. */const RIG_SOLE = 0.35;bootExample({  title: "Walk on terrain",  // `threeD()` requires `physics()` and `input()` before it; the kit registers `input()` first.  extensions: [physics(), threeD(), terrain(), particles()],  settings: {    rendering: { clearColor: SKY, msaaSamples: 4, features: { shadows: true } },    time: { fixedDeltaTime: 1 / 60 },    physics: { havokWasm: HAVOK_WASM_URL },  },  async setup({ app, panel, afterStart, flags }) {    app.registerComponents([DragToLook, Footsteps, Gait, StayOnTerrain]);    app.input.loadActions(PLAYER_ACTIONS);    // The last 24 m ease down to the level of the apron that continues the hills to the horizon.    const hills = await terrainAssetFromDefinition(app, HILLS, { heights: easedEdgeHeights(HILLS, 24) });    const rig = await app.assets.loadAsync<ModelAsset>(RIG_ADDRESS, { type: MODEL_ASSET_TYPE });    const machine = await app.assets.loadAsync<AnimatorAsset>(new URL(machineUrl, import.meta.url).href, {      type: ANIMATOR_ASSET_TYPE,    });    dressHills(app, hills.value.field.width);    const groundEntity = app.world.createEntity("Hills");    const ground = groundEntity.addComponent(Terrain, { definition: hills });    // The whole physics integration: the terrain answers in the shape the collider declares, and    // an entity with a collider and no `Rigidbody` gets an implicit static body.    const collider = groundEntity.addComponent(HeightfieldCollider, ground.colliderInit());    const shot = shotFor(flags.isStatic);    const standY = (x: number, z: number): number => hills.value.field.heightAt(x, z) + CAPSULE_HEIGHT / 2 + 0.02;    const character = app.world.createEntity("Walker", {      position: { x: shot.x, y: standY(shot.x, shot.z), z: shot.z },    });    character.transform.localEulerAngles = { x: 0, y: shot.yaw, z: 0 };    character.addComponent(CharacterController, { height: CAPSULE_HEIGHT, radius: 0.32, slopeLimit: 48 });    const controller = character.addComponent(ThirdPersonController, {      walkSpeed: 3.4,      sprintSpeed: 6.6,      jumpHeight: 1.1,      rotateToMovement: true,    });    character.addComponent(DragToLook);    const bounds = character.addComponent(StayOnTerrain);    bounds.ground = ground;    bounds.spawn = { x: SPAWN.x, y: standY(SPAWN.x, SPAWN.z), z: SPAWN.z };    bounds.halfHeight = CAPSULE_HEIGHT / 2;    // The model hangs off a child: a `CharacterController`'s capsule is centred on its own entity,    // and the rig's boxes start {@link RIG_SOLE} above its origin, so the origin goes that far below    // the capsule's foot for the boxes to stand on the ground rather than hover over it.    const body = app.world.createEntity("Walker Body", { parent: character });    body.transform.localPosition.set(0, -CAPSULE_HEIGHT / 2 - RIG_SOLE, 0);    body.addComponent(Model, { model: rig, castShadows: true, receiveShadows: true });    const gait = character.addComponent(Gait);    const animator = body.addComponent(Animator, { animator: machine });    gait.controller = controller;    gait.animator = animator;    const dustEntity = app.world.createEntity("Footstep dust", { parent: character });    dustEntity.transform.localPosition.set(0, -CAPSULE_HEIGHT / 2 + 0.05, 0);    const dust = dustEntity.addComponent(ParticleSystem, {      definition: particleAssetFromDefinition(app, particleDefinition("dust"), "fx/footstep-dust"),      playOnAwake: false,      seed: 5,    });    const steps = character.addComponent(Footsteps);    steps.controller = controller;    steps.dust = dust;    const eye = app.world.createEntity("Main Camera");    eye.addComponent(Camera, { near: 0.1, far: 400, fov: 56 });    eye.transform.localEulerAngles = { x: shot.pitch, y: shot.cameraYaw, z: 0 };    const boom = eye.addComponent(ThirdPersonCamera, {      target: character,      distance: shot.distance,      shoulderOffset: { x: 0.4, y: 0.6, z: 0 },      damping: 0.07,      minPitch: -12,      maxPitch: 55,      sensitivity: 0.2,      // The boom sweeps physics bodies, and the heightfield is one: a hill behind the walker      // shortens the boom instead of swallowing the camera.      collisionEnabled: true,    });    afterStart((): void => {      boom.snap();      if (flags.isStatic) {        posePosterStride(gait, animator, controller.walkSpeed);      }    });    attachTouchStick(app);    // The readouts sample the ground under the walker, and show speeds to one decimal.    const feet = (): Vec3 => character.transform.position;    const perSecond = (v: number): string => `${v.toFixed(1)} m/s`;    panel({      title: "Walk on terrain",      groups: [        {          label: "Character",          controls: [            slider("Walk", { min: 1, max: 7, step: 0.1, format: perSecond }, bind(controller, "walkSpeed")),            slider("Jump", { min: 0.2, max: 2.5, step: 0.05, format: metres }, bind(controller, "jumpHeight")),            readout("Speed", (): string => perSecond(controller.speed)),            readout("Grounded", (): string => (controller.isGrounded ? "yes" : "no")),          ],        },        {          label: "The ground under it",          controls: [            readout("Terrain height", (): string => metres(ground.heightAt(feet().x, feet().z))),            readout("Slope", (): string => `${ground.slopeAt(feet().x, feet().z).toFixed(0)}°`),            readout("Collider samples", (): string => `${String(collider.samplesX)} x ${String(collider.samplesZ)}`),          ],        },        {          label: "Dust",          controls: [            toggle("Footstep dust", bind(steps, "dustEnabled")),            slider("Stride", { min: 0.6, max: 4, step: 0.1, format: metres }, bind(steps, "strideLength")),            readout("Particles alive", (): string => String(dust.aliveCount)),          ],        },      ],    });  },});
shot.ts
/** * The composition: where the walker stands and how the camera first sees it, live and in the * `?static=1` capture the gallery's poster is taken from. * * @remarks * The two differ on purpose. The live page opens on the flat hilltop the walk starts from, seen from * behind; the poster has to say "walking on a slope" in one still frame, with a clock that is stopped * before the first frame, so it stands the walker side-on on a rise and poses its rig mid-stride. * Every number was chosen against rendered candidates. */import type { Animator } from "ignifx";import type { Gait } from "./gait.ts";/** Where the walker stands, which way it faces, and where the camera's boom starts. */export interface Shot {  /** Where the walker stands along X, in metres; Y comes from the height field. */  readonly x: number;  /** Where the walker stands along Z, in metres. */  readonly z: number;  /** Which way the walker faces, in degrees about Y. */  readonly yaw: number;  /** The camera's opening yaw, in degrees. */  readonly cameraYaw: number;  /** The camera's opening pitch, in degrees. */  readonly pitch: number;  /** The boom's length, in metres. */  readonly distance: number;}/** Where the walk starts, and where a walker that falls off the world is put back. */export const SPAWN = { x: -6, z: -14 } as const;/** The live page's opening: standing at the start, seen from behind over the shoulder. */const OPENING: Shot = { ...SPAWN, yaw: 0, cameraYaw: 28, pitch: 16, distance: 5.2 };/** The poster: side-on on an 8° rise 30 m from the start, with the hillside rising behind it. */const POSTER: Shot = { x: 24, z: -17, yaw: -63, cameraYaw: 7, pitch: 13, distance: 4 };/** How far into the walk clip the poster's rig is posed, in seconds: mid-stride. */const POSTER_PHASE = 0.6;/** * Picks the shot for this page load. * * @param isStatic - Whether the page was opened with `?static=1`. * @returns The poster's shot under `?static=1`, otherwise the live opening. */export function shotFor(isStatic: boolean): Shot {  return isStatic ? POSTER : OPENING;}/** * Poses the poster's walker mid-stride. Call it once `app.start()` has run, under `?static=1` only. * * @remarks * The stopped clock leaves the controller at 0 m/s, so the gait would hold the rig idle. This * unhooks the gait from the controller, sets the blend tree to the walk and advances the machine, * which exists from the animator's first advance, into the clip. * * @param gait - The script that otherwise copies the controller's speed into the animator. * @param animator - The rig's state machine. * @param walkSpeed - The controller's walk speed: the blend tree's walk threshold. */export function posePosterStride(gait: Gait, animator: Animator, walkSpeed: number): void {  gait.controller = null;  animator.setFloat("speed", walkSpeed);  animator.stateMachine?.advance(POSTER_PHASE);}
hills.ts
/** * The walkable world: the hills' terrain document, the sky and ground around them, and a script * that keeps the character on them. * * The heightfield collider is exactly as big as the terrain, so a character that walks off its edge * has nothing under it and falls for ever. A wall of colliders would stop it, and would also stop * the camera's collision sweep from seeing past the edge; instead {@link StayOnTerrain} puts the * character back inside whenever it crosses a line a few metres in from the edge, and back at the * start if it ever falls below the ground. */import { CharacterController, clamp, Script } from "ignifx";import { fadeToHorizon } from "../_kit/horizon.ts";import { createSky } from "../_kit/sky.ts";import { createApron } from "../_kit/terrain.ts";import type { App, ScriptCallbacks, Terrain, TerrainDefinitionInput, Vec3Like } from "ignifx";/** The sky the hills stand against, in sRGB. */export const SKY = { r: 0.61, g: 0.81, b: 1, a: 1 } as const;/** The grass layer's colour, which the apron beyond the hills continues. */const GRASS_COLOR = [0.33, 0.45, 0.23] as const;/** The hills: 128 m of noise, grass on the gentle slopes, then dirt, then rock. */export const HILLS: TerrainDefinitionInput = {  name: "hills",  size: { width: 128, depth: 128, height: 13 },  resolution: 257,  chunks: { size: 32, lodLevels: 3, lodDistance: 48, skirtDepth: 1 },  noise: { seed: 21, octaves: 5, frequency: 0.02, persistence: 0.45 },  layers: [    { name: "grass", color: GRASS_COLOR },    { name: "dirt", color: [0.44, 0.36, 0.26] },    { name: "rock", color: [0.45, 0.44, 0.42] },  ],  splatRules: [    { layer: "grass", slope: [0, 22] },    { layer: "dirt", slope: [16, 34] },    { layer: "rock", slope: [30, 90] },  ],  material: { roughness: 0.95, metallic: 0 },};/** * Lights the hills and surrounds them: the sun and its shadows, fog that fades to the sky, and a * grass apron that carries the ground on to the horizon. * * @param app - The app. * @param fieldWidth - The terrain's width in metres, which the apron leaves open in its middle. */export function dressHills(app: App, fieldWidth: number): void {  createSky(app, {    sky: SKY,    ground: { r: 0.28, g: 0.3, b: 0.22, a: 1 },    sunPosition: { x: -26, y: 40, z: -22 },    sunIntensity: 3,    shadows: { mapSize: 2048, maxDistance: 60, darkness: 0.32 },  });  // The walker stays on the 128 m field, so the apron has to reach past the fog's end from its edge.  const fade = fadeToHorizon(app, { start: 90, end: 220, groundSize: 2 * (220 + 64) + 40 });  createApron(app, { size: fade.groundSize, hole: fieldWidth, color: GRASS_COLOR });}/** How far below the lowest ground a fall counts as lost, in metres. */const LOST_BELOW = 10;/** * Holds a character controller inside a terrain's extent. * * @example * ```ts * const bounds = character.addComponent(StayOnTerrain); * bounds.ground = ground; * bounds.spawn = { x: -6, y: 12, z: -14 }; * ``` */export class StayOnTerrain extends Script implements ScriptCallbacks {  /** The namespaced registration id. */  static typeId = "terrain-walk/StayOnTerrain";  /** The ground to stay on. Assigned in code. */  ground: Terrain | null = null;  /** Where a lost character is put back, in world metres. */  spawn: Vec3Like = { x: 0, y: 0, z: 0 };  /** How far in from the terrain's edge the character is held, in metres. */  margin = 4;  /** Half the capsule's height, so a character put back stands on the ground rather than in it. */  halfHeight = 0.8;  /** The controller to move. Found in `awake`. */  #controller: CharacterController | null = null;  /** Where the character is put, reused so no frame allocates (coding standards §7). */  readonly #to = { x: 0, y: 0, z: 0 };  /** Finds the controller on the same entity. */  awake(): void {    this.#controller = this.entity.getComponent(CharacterController);  }  /** Puts the character back inside the edge, or at the start if it has fallen through. */  update(): void {    const ground = this.ground;    const controller = this.#controller;    if (ground === null || controller === null || !ground.isLoaded) {      return;    }    const at = this.transform.position;    if (at.y < -LOST_BELOW) {      controller.teleport(this.spawn);      return;    }    const limitX = ground.size.width / 2 - this.margin;    const limitZ = ground.size.depth / 2 - this.margin;    if (Math.abs(at.x) <= limitX && Math.abs(at.z) <= limitZ) {      return;    }    this.#to.x = clamp(at.x, -limitX, limitX);    this.#to.z = clamp(at.z, -limitZ, limitZ);    this.#to.y = Math.max(at.y, ground.heightAt(this.#to.x, this.#to.z) + this.halfHeight);    controller.teleport(this.#to);  }}
controls.ts
/** * The walker's input: one action map, the gate that arms the mouse look only while the pointer is * down, and the on-screen stick a touch screen walks with. * * `ThirdPersonController` reads `Move`, `Jump` and `Sprint` and `ThirdPersonCamera` reads `Look`, * all four by **name**, so an example only has to declare a map that carries them. A page cannot * lock the pointer the way a full-screen game does — the visitor has to be able to reach the * parameter panel — so `Look` is bound to `<Pointer>/delta` and switched off unless the pointer is * down or a stick is pushed. */import { defineInputActions, Script, VirtualJoystick } from "ignifx";import { hasTouchScreen } from "../_kit/touch.ts";import type { App, InputAction, InputActionsDefinition, ScriptCallbacks } from "ignifx";/** Below this magnitude a stick counts as centred, and the mouse look stays switched off. */const STICK_EPSILON = 0.001;/** The actions the character and its camera read. */export const PLAYER_ACTIONS: InputActionsDefinition = defineInputActions({  maps: [    {      name: "Player",      actions: [        {          name: "Move",          type: "vector2",          bindings: [            {              composite: "2DVector",              up: "<Keyboard>/w",              down: "<Keyboard>/s",              left: "<Keyboard>/a",              right: "<Keyboard>/d",            },            {              composite: "2DVector",              up: "<Keyboard>/arrowUp",              down: "<Keyboard>/arrowDown",              left: "<Keyboard>/arrowLeft",              right: "<Keyboard>/arrowRight",            },            { path: "<Gamepad>/leftStick", processors: ["deadzone(0.2)"] },            { path: "<Gamepad>/dpad" },            // What the on-screen stick from `attachTouchStick` writes on a touch screen.            { path: "<Virtual>/joystick", processors: ["deadzone(0.15)"] },          ],        },        {          name: "Look",          type: "vector2",          bindings: [{ path: "<Pointer>/delta" }, { path: "<Gamepad>/rightStick", processors: ["deadzone(0.2)"] }],        },        {          name: "LookStick",          type: "vector2",          bindings: [{ path: "<Gamepad>/rightStick", processors: ["deadzone(0.2)"] }],        },        { name: "Drag", bindings: [{ path: "<Pointer>/press" }] },        { name: "Jump", bindings: [{ path: "<Keyboard>/space" }, { path: "<Gamepad>/buttonSouth" }] },        { name: "Sprint", bindings: [{ path: "<Keyboard>/shiftLeft" }, { path: "<Gamepad>/leftStickPress" }] },      ],    },  ],});/** * Arms the `Look` action only while the pointer is down or a stick is pushed. * * @remarks * A pointer action is masked for the frame while the UI overlay holds the pointer, so `Drag` reads * as released while a slider is being dragged — which is why dragging one never also turns the * camera. */export class DragToLook extends Script implements ScriptCallbacks {  /** The namespaced registration id. */  static typeId = "terrain-walk/DragToLook";  /** Before the camera that reads `Look`, so a press turns the view in the frame it lands. */  static executionOrder = -10;  /** The three actions, found once in `awake` rather than by name every frame (standards §7). */  #look: InputAction | null = null;  #drag: InputAction | null = null;  #stick: InputAction | null = null;  /** Finds the actions; `main.ts` loads the map before it adds this component. */  awake(): void {    const actions = this.app.input.actions;    this.#look = actions.find("Look");    this.#drag = actions.find("Drag");    this.#stick = actions.find("LookStick");  }  /** Switches `Look` on and off for the frame. */  update(): void {    const look = this.#look;    if (look === null) {      return;    }    const held = this.#drag?.isPressed ?? false;    const stick = this.#stick?.vector;    const pushed = stick !== undefined && Math.hypot(stick.x, stick.y) > STICK_EPSILON;    look.enabled = held || pushed;  }}/** * Mounts a walking stick in the bottom-left corner on a touch screen; dragging the frame still looks. * * @remarks * The stick writes `<Virtual>/joystick`, which {@link PLAYER_ACTIONS} binds to `Move`, so the * controller walks on it exactly as it does on the keyboard. * * @param app - The app; needs `ui()`, which the kit always registers. * @returns The stick, or `null` on a device without touch. */export function attachTouchStick(app: App): VirtualJoystick | null {  if (!hasTouchScreen()) {    return null;  }  const bottom = "calc(1.5rem + var(--ignifx-safe-bottom, 0px))";  return new VirtualJoystick(app, { control: "joystick", ariaLabel: "Walk", style: { left: "1.5rem", bottom } });}
gait.ts
/** * The walker's gait: the one script between the character controller and the rig's state machine. * * `walker.animator.json` blends the rig's `idle`, `walk` and `run` clips by one `speed` parameter, * and this writes the controller's ground speed into it every frame. The parameter's name is the * document's and not the engine's, which is why the mapping lives in the example rather than in * `ThirdPersonController`. It runs in `update`: the controller has already moved on the fixed step, * and the animator samples in `PostUpdate`, so the pose drawn is the speed of this very frame. */import { Script } from "ignifx";import type { Animator, ScriptCallbacks, ThirdPersonController } from "ignifx";/** The blend tree's parameter, as `walker.animator.json` names it. */const SPEED_PARAMETER = "speed";/** * Feeds the controller's speed to the walker's animator. * * @example * ```ts * const gait = character.addComponent(Gait); * gait.controller = controller; * gait.animator = body.addComponent(Animator, { animator: machine }); * ``` */export class Gait extends Script implements ScriptCallbacks {  /** The namespaced registration id. */  static typeId = "terrain-walk/Gait";  /** The controller whose speed picks the clip. Assigned in code. */  controller: ThirdPersonController | null = null;  /** The state machine on the rig. Assigned in code. */  animator: Animator | null = null;  /** Writes this frame's ground speed into the blend tree. */  update(): void {    const controller = this.controller;    const animator = this.animator;    if (controller === null || animator === null) {      return;    }    animator.setFloat(SPEED_PARAMETER, controller.speed);  }}
walker.animator.json
{  "format": "ignifx.animator",  "formatVersion": 1,  "parameters": [{ "name": "speed", "kind": "float", "value": 0 }],  "layers": [{ "name": "Base", "weight": 1, "defaultState": "locomotion" }],  "states": [{ "name": "locomotion", "blendTree": "locomotion", "loop": true }],  "blendTrees1D": [    {      "name": "locomotion",      "param": "speed",      "children": [        { "clip": "idle", "threshold": 0 },        { "clip": "walk", "threshold": 3.4 },        { "clip": "run", "threshold": 6.6 }      ]    }  ]}
footsteps.ts
/** * Dust under the walker's feet: one `ParticleSystem` that never plays, and a script that calls * `emit()` once per stride. * * `emit(count)` spawns at the system's current clock whether the system is playing or not, and the * emission phase runs late in `Update`, after every script, so a burst asked for this frame is * uploaded and drawn this frame. Stride length rather than a timer is what makes the puffs line up * with the ground: a sprinting character leaves them the same distance apart as a walking one. */import { Script } from "ignifx";import type { ParticleSystem, ScriptCallbacks, ThirdPersonController } from "ignifx";/** Below this speed, in metres per second, the character is not walking and leaves no dust. */const WALK_THRESHOLD = 0.6;/** How many particles one footfall throws up. */const PARTICLES_PER_STEP = 5;/** * Emits a puff of dust every stride while the character is on the ground. * * @example * ```ts * const steps = character.addComponent(Footsteps); * steps.controller = controller; * steps.dust = dust; * ``` */export class Footsteps extends Script implements ScriptCallbacks {  /** The namespaced registration id. */  static typeId = "terrain-walk/Footsteps";  /** The controller whose speed and grounding decide when a step lands. Assigned in code. */  controller: ThirdPersonController | null = null;  /** The effect the steps emit into. Assigned in code. */  dust: ParticleSystem | null = null;  /** Metres between puffs. */  strideLength = 1.6;  /** Whether the dust is switched on at all; the panel writes it. */  dustEnabled = true;  /** How far the character has walked since the last puff, in metres. */  #travelled = 0;  /**   * Counts distance walked and emits when a stride is complete.   *   * @param dt - Seconds since the previous frame.   */  update(dt: number): void {    const controller = this.controller;    const dust = this.dust;    if (controller === null || dust === null) {      return;    }    if (!controller.isGrounded || controller.speed < WALK_THRESHOLD) {      this.#travelled = 0;      return;    }    this.#travelled += controller.speed * dt;    if (this.#travelled < this.strideLength) {      return;    }    // Keep the overshoot, so the puffs land a stride apart whatever the frame rate.    this.#travelled -= this.strideLength;    if (this.dustEnabled) {      dust.emit(PARTICLES_PER_STEP);    }  }}
Uses
Terrain.colliderInitHeightfieldColliderThirdPersonControllerAnimatorParticleSystemCharacterController
Assets
ignifx rig — Apache-2.0, Astrum Forge Studios