Stampede

4 096 horses gallop around a valley at golden hour. The GPU steers them among neighbours found through the grid. Distance culling chooses skinned meshes near the camera and posed impostor cards farther away. Three sun cascades keep shadows sharp beside a horse and reach trees 320 m out. A horse changing gait blends along a line from walk to gallop and keeps its stride. Events count each group. The pointer frightens horses, and panic spreads through the herd. Hover to pick and name a horse, click it to ride along, or click the ground to send a thunderclap through the herd. The horse is by Quaternius (CC0). Trees and rocks are from Kay Lousberg's KayKit Forest Nature Pack (CC0).

Files, in reading order

  1. scene.tsCreates the scene: its resources, its settings and what each frame records.
  2. herd.tsMoves horses on the GPU using neighbour steering, gait selection and panic propagation.
  3. horses.tsShared figures ordered to match herd pose ids.
  4. selection.tsBody-sized picking target centred on a moving body joint.
  5. land.tsBakes the valley into a mesh of its own once and draws it with the scene's lighting.
  6. scenery.tsDraws scenery instances from the shared pool, lit by the scene's lighting.
  7. dust.tsDraws drifting, spreading hoof dust lit by the scene's sun and sky.
  8. assets.ts

Kit modules bloom, cameras, clock, coroutines, environment, events, finish, font, impostors, instances, lighting, machines, materials, meshPool, owner, particles, passes, picker, pipelines, poses, readback, shading, targets, text, tone. Also @typegpu/color, @typegpu/noise, typegpu, wgpu-matrix.

scene.ts

import {  bloom,  cameras,  clock,  coroutines,  environment,  finish,  impostors,  lighting,  meshPool,  owner,  passes,  picker,  pipelines,  readback,  targets,  tone,  type Baked,  type Frame,  type Surface,} from '@typegpu/kit';import type { TgpuRoot } from 'typegpu';import type { Pointing } from '../contract.ts';import type { Model } from '../models.ts';import type { Settings } from '../settings.ts';import {  CAPACITY,  createHerd,  LANE_SPREAD,  STEP_SECONDS,  trackAt,  trackHeading,  wrap,} from './herd.ts';import { createLevels, createPoses } from './horses.ts';import { createLabel, createPicking, PICK_REACH_POINTS } from './selection.ts';import { createLand, HAZE, heightAt, sky } from './land.ts';import { createScenery, layout, SCENERY, type SceneryName } from './scenery.ts';import { createDust } from './dust.ts';const SAMPLES = 4;const HDR = 'rgba16float';/** Initial herd-centre angle in radians, on the near straight with the sun ahead. */const START_AROUND = 1.85;/** Camera-target height above the ground, or above a followed horse at its size. */const LOOK_ABOVE_METRES = 1.6;/** Camera-target offset outside the herd, in lane spreads. */const FLANK = 0.7;/** Camera yaw lead along the herd's outer flank. */const AHEAD_RADIANS = 0.5;const VIEW = {  distance: 28,  pitch: 0.07,  fov: 0.9,  near: 0.3,  far: 9000,  pitchRange: [-0.1, 1.45],  distanceRange: [6, 600],} as const;/** Minimum camera height above the ground, including during orbit. */const EYE_ABOVE_GROUND_METRES = 1.2;const FOLLOW_HZ = 60;/** How close the camera rides to a horse it follows. */const FOLLOW_METRES = 10;/** The sun, about 12 degrees above the horizon and ahead of the initial view. */const SUN = {  sunDirection: [-0.38, 0.21, -0.9],  sunColor: [1, 0.72, 0.44],  sunIntensity: 2.6,} as const;const SKY_FILL = {  skyTop: [0.42, 0.52, 0.72],  skyBottom: [0.34, 0.26, 0.16],  skyIntensity: 0.55,} as const;const FOG = { fogDensity: 0.00055, fogFalloff: 0.0025 } as const;/** Three cascades fitted to the view, the nearest about 35 m deep and the last ending 320 m out. */const SHADOW = { cascades: 3, far: 320 } as const;const LOOK = { exposure: 1.05, glowIntensity: 0.12, vignette: 0.22 };const BLOOM = { threshold: 1.6 };const DUST_CAPACITY = 8192;/** Simulation warm-up in seconds so dust appears in the first frame. */const PREWARM_SECONDS = 4;const PREWARM_CHUNK = 15;/** How far the pointer's ray searches the hills, from a first step of a metre. */const GROUND = { reach: 2500, step: 1 };const LONG_AGO_SECONDS = 1e3;/** Camera height threshold for herd avoidance, and avoidance radius. */const EYE_LOW_METRES = 5;const EYE_ROOM_METRES = 4;/** Pointer fear fade duration after movement stops, in seconds. */const FEAR_FADE_SECONDS = 0.8;/** Pointer fear below which the herd ignores the pointer. */const LEAST_FEAR = 0.01;type Vec3 = [number, number, number];const defaults = {  count: 4096,  urgency: 0.85,  handOff: 50,  dust: 1,  tint: false,  follow: 'herd' as 'herd' | 'picked' | 'none',};export interface SceneOptions {  /** The horse with its skin and clips, as `gltf.read` returns it. */  readonly horse: Model;  /** The trees, rocks and bush, each as its own model. */  readonly scenery: Readonly<Record<SceneryName, meshPool.ModelData>>;  readonly font: Baked;  /** Initial horse count within the fixed capacity. Default 4096. */  readonly count?: number;  /** Receives compilation progress as a fraction after each part finishes. */  readonly onProgress?: (fraction: number) => void;  /** Random-number source. Defaults to `Math.random`. */  readonly random?: () => number;}const along: [number, number] = [0, 0];const headingAngle = (around: number) => {  trackHeading(around, along);  return Math.atan2(along[0], along[1]);};/** Writes the camera target beside the herd centred at `around` into `out`. */const herdTarget = (around: number, out: Vec3): Vec3 => {  trackAt(around, along);  const x = along[0] * (1 + LANE_SPREAD * FLANK);  const z = along[1] * (1 + LANE_SPREAD * FLANK);  out[0] = x;  out[1] = heightAt(x, z) + LOOK_ABOVE_METRES;  out[2] = z;  return out;};/** * Runs a galloping herd around a valley. Uses skins near the camera, cards farther away and GPU * panic propagation between neighbours. */export async function createScene(root: TgpuRoot, surface: Surface, options: SceneOptions) {  const { horse } = options;  const values = { ...defaults, count: options.count ?? defaults.count };  const own = owner();  const screen = own(    targets.create(root, surface, {      scene: { format: HDR, samples: SAMPLES },      depth: { format: 'depth24plus', samples: SAMPLES },      hdr: { format: HDR, sampled: true },    }),  );  let heading = headingAngle(START_AROUND);  const view = cameras.orbit(root, {    size: surface,    ...VIEW,    target: herdTarget(START_AROUND, [0, 0, 0]),    // The outside of a clockwise track lies to the right    yaw: heading - Math.PI / 2 + AHEAD_RADIANS,    floor: (x, z) => heightAt(x, z) + EYE_ABOVE_GROUND_METRES,  });  const light = own(    lighting.create(root, {      settings: {        ...SUN,        ...SKY_FILL,        fogColor: [HAZE.x, HAZE.y, HAZE.z],        ...FOG,      },      shadow: { eye: view, ...SHADOW },    }),  );  // The land, the scenery, the horses and the sky read the lighting from beneath  const scene = passes    .create(      root,      screen,      { scene: passes.color, depth: passes.depth },      { resolve: { scene: 'hdr' } },    )    .pipe(view.inject())    .beneath(light.inject());  const land = own(createLand(root));  const pool = own(    meshPool.create(      root,      // The horse, the scenery and the horse's cards      meshPool.roomFor([horse, ...SCENERY.map((name) => options.scenery[name]), impostors.CARD]),    ),  );  const body = pool.add(horse);  const cards = pool.add(impostors.CARD);  const card = cards.meshes[0]!;  const { placed, obstacles } = layout();  const scenery = own(createScenery(root, pool, options.scenery, placed));  const posed = createPoses(root, horse);  own(posed.poses);  const sim = clock.fixedStep({ step: STEP_SECONDS });  const herd = own(    createHerd(root, {      count: values.count,      middle: START_AROUND,      handOff: values.handOff,      obstacles,      poses: posed.ids,      ...(options.random ? { random: options.random } : {}),    }),  );  const picked = own(createPicking(root, view, { model: horse, herd, posed, surface }));  const levels = own(    createLevels(root, pool, { body, card, posed, herd, picked, view, model: horse }),  );  const label = own(createLabel(root, view, { font: options.font, picked, herd }));  const dust = own(    createDust(root, {      camera: view,      light,      step: sim.step,      herd,      capacity: DUST_CAPACITY,      amount: values.dust,    }),  );  const following = own(readback.create(root, herd.watched, { hz: FOLLOW_HZ }));  // The sky fills what the land and the herd leave  const drawn = [    scene.add(land.renderer),    scene.add(scenery.renderer),    scene.add(levels.solid),    scene.add(levels.distant),    scene.add(environment.background(sky)),    scene.add(dust.drawing),    scene.add(label.drawing),  ];  const casters = [    light.shadow.add(scenery.renderer),    light.shadow.add(levels.solid),    light.shadow.add(levels.distant),  ];  const glow = own(bloom.create(root, { source: () => screen.textures.hdr, settings: BLOOM }));  const final = own(    finish.create(root, {      format: surface.format,      source: () => screen.textures.hdr,      tone: tone.aces,      glow,      settings: LOOK,    }),  );  let now = 0;  let clapAge = LONG_AGO_SECONDS;  let followed: number = picker.none;  const pointer = { x: 0.5, y: 0.5, over: false, movedAt: -LONG_AGO_SECONDS };  /** Where this frame picks for a click. */  let pressed: [number, number] | undefined;  const clicks = own(coroutines.create());  /** Resumes once this frame's pick is free, so a second click in a frame picks in the next. */  const pickFree = coroutines.wait({ ready: () => !pressed });  /** What the herd fears, written each frame. */  const threats = {    pointer: [0, 0, 0] as Vec3,    fear: 0,    clapAge,    eye: [0, 0] as [number, number],    eyeRadius: 0,  };  const ray = { origin: [0, 0, 0], direction: [0, 0, 0] };  const eye = { origin: [0, 0, 0], direction: [0, 0, 0] };  /** Writes the ground under `x`, `y` into `out`, or returns undefined when the ray misses. */  const groundUnder = (x: number, y: number, out: Vec3) => {    view.rayAt(x, y, ray);    return cameras.hitHeight(ray, heightAt, out, GROUND) === undefined ? undefined : out;  };  const follow = (member: number) => {    followed = member;    herd.controls.patch({ followed });    following.reset();    view.goal.distance = member === picker.none ? VIEW.distance : FOLLOW_METRES;  };  const repopulate = () => {    herd.populate(values.count);    levels.populate();    follow(picker.none);    sim.reset();  };  const settings: Settings<typeof values> = {    values,    knobs: {      count: { label: 'herd size', min: 256, max: CAPACITY, step: 256 },      urgency: { min: 0, max: 1, step: 0.01 },      handOff: { label: 'impostor distance (m)', min: 10, max: 120, step: 1 },      dust: { min: 0, max: 2, step: 0.05 },      tint: { label: 'colour by detail level' },      follow: { options: ['herd', 'picked', 'none'], live: true },    },    write(key) {      if (key === 'count') repopulate();      if (key === 'follow' && values.follow === 'herd') follow(picker.none);      if (key === undefined || key === 'handOff') herd.controls.patch({ handOff: values.handOff });      if (key === undefined || key === 'dust') dust.settings.write({ amount: values.dust });      if (key === undefined || key === 'tint') levels.tint(values.tint);    },    readouts: {      skinned: () => levels.counts?.skinned ?? 'pending',      impostors: () => levels.counts?.impostors ?? 'pending',    },  };  const pointing: Pointing = {    reach: PICK_REACH_POINTS,    move(x, y) {      pointer.x = x;      pointer.y = y;      pointer.over = true;      pointer.movedAt = now;    },    leave() {      pointer.over = false;      picked.clear();    },    press(x, y) {      clicks.start(click(x, y, groundUnder(x, y, [0, 0, 0])));    },  };  /** Points the camera at the herd, or at the followed horse once its position reads back. */  const steerView = (dt: number) => {    const watched = following.latest;    if (values.follow === 'herd' || (values.follow === 'picked' && followed === picker.none)) {      herdTarget(herd.middle, view.goal.target);    } else if (values.follow === 'picked' && watched) {      const size = herd.traits[followed]?.size ?? 1;      const { position } = watched;      const { target } = view.goal;      target[0] = position.x;      target[1] = position.y + LOOK_ABOVE_METRES * size;      target[2] = position.z;    }    const nextHeading = headingAngle(herd.middle);    if (values.follow === 'herd') view.goal.yaw += wrap(nextHeading - heading);    heading = nextHeading;    view.update(dt);  };  /** Follows the horse a click picks, or claps on the `ground` it hit when it finds no horse. */  function* click(x: number, y: number, ground: Vec3 | undefined) {    yield* pickFree;    pressed = [x, y];    const taken = yield* coroutines.until(picked.result.next());    if (taken !== null) {      follow(taken);      values.follow = 'picked';    } else if (ground) {      clapAge = 0;      herd.threat.patch({ clap: ground });      dust.clap(ground);    }  }  /** Writes the pointer's fear, the clap's age and a low camera for the herd to avoid. */  const threaten = () => {    const fear = pointer.over ? Math.exp(-(now - pointer.movedAt) / FEAR_FADE_SECONDS) : 0;    const seen =      fear > LEAST_FEAR && groundUnder(pointer.x, pointer.y, threats.pointer) !== undefined;    if (!seen) threats.pointer.fill(0);    threats.fear = seen ? fear : 0;    threats.clapAge = clapAge;    view.rayAt(0.5, 0.5, eye);    const { origin } = eye;    threats.eye[0] = origin[0]!;    threats.eye[1] = origin[2]!;    const low = origin[1]! - heightAt(origin[0]!, origin[2]!) < EYE_LOW_METRES;    threats.eyeRadius = low ? EYE_ROOM_METRES : 0;    herd.threat.patch(threats);  };  const parts = [scene, light.shadow, glow, final, herd, levels, picked, label, dust];  await Promise.all([body.ready, cards.ready, scenery.ready, pipelines.compile(parts, options)]);  scenery.flush();  // Advance the herd centre per warm-up chunk so steering follows it  for (; now < PREWARM_SECONDS; now += PREWARM_CHUNK * STEP_SECONDS) {    herd.advance(PREWARM_CHUNK * STEP_SECONDS, values.urgency);    const warm = root.createCommandEncoder();    const warming = warm.beginComputePass();    for (let i = 0; i < PREWARM_CHUNK; i++) {      herd.step(warming);      dust.step(warming);    }    dust.compact(warming, 0);    warming.end();    warm.submit();  }  return {    view,    settings,    pointing,    frame(frame: Frame) {      const steps = sim.advance(frame.dt);      const seconds = steps * STEP_SECONDS;      herd.advance(seconds, values.urgency);      now += seconds;      clapAge += seconds;      steerView(frame.dt);      clicks.run(frame);      threaten();      levels.flush();      const compute = frame.encoder.beginComputePass();      dust.spawn(compute);      for (let i = 0; i < steps; i++) {        herd.step(compute);        dust.step(compute);      }      dust.compact(compute, sim.ahead);      posed.poses.animate(compute, now);      if (pressed) picked.pick(compute, ...pressed);      else if (pointer.over) picked.pick(compute, pointer.x, pointer.y);      label.write(compute);      levels.arrange(compute);      scenery.renderer.cull(compute);      compute.end();      if (pressed) {        picked.result.capture(frame);        pressed = undefined;      }      levels.capture(frame);      if (followed !== picker.none) following.capture(frame);      screen.fit();      light.shadow.record(frame, casters);      scene.record(frame, drawn);      glow.record(frame);      final.record(frame, surface.view(frame));    },    destroy: own.destroy,  };}

herd.ts

import { machines, owner, picker, unfilled, type Inject } from '@typegpu/kit';import * as grid from '@typegpu/kit/grid';import { d, std, tgpu, type TgpuComputePass, type TgpuRoot } from 'typegpu';import { heightAt, LOOP_METRES, loopAt } from './land.ts';export const CAPACITY = 8192;/** Model-to-metre scale. A horse is about 2 m tall at the head. */export const HORSE_SCALE = 0.42;export const STEP_SECONDS = 1 / 60;const WORKGROUP = 64;/** Metres a gallop's and a walk's cycle covers at scale 1. */const GALLOP_STRIDE = 6.6;const WALK_STRIDE = 2.1;/** Held moving poses: phases of a cycle, each at evenly spaced mixes from walk to gallop. */export const PHASES = 24;export const MIXES = 9;/** Idle and grazing poses with independent clocks, assigned by horse index. */export const RESTING_POSES = 6;export const POSES = PHASES * MIXES + RESTING_POSES * 2;/** Walk-to-gallop mix change per second, so a gait change passes through every held mix. */const GAIT_PER_SECOND = 1.25;/** Walking, full-gallop and panic speeds in metres per second. */const WALK_SPEED = 1.7;const GALLOP_SPEED = 11.5;const PANIC_SPEED = 15.5;/** Gait-switch speed thresholds. Walks below the lower one and gallops above the upper one. */const GALLOP_FROM = 5.2;const WALK_FROM = 4.2;const RESTING_SPEED = 0.35;/** Maximum urgency for occasional grazing. */const GRAZING_BELOW = 0.3;/** Average grazing starts per second for calm walking horses. */const GRAZES_PER_SECOND = 0.03;/** Minimum grazing duration and mean additional duration in seconds. */const GRAZE_SECONDS = 4;const GRAZE_MORE_SECONDS = 3.5;const SIGHT_METRES = 7;const PERSONAL_METRES = 3.2;const RESPONSE_PER_SECOND = 1.6;const SEPARATION = 16;const ALIGNMENT = 0.9;const COHESION = 0.2;/** Lateral track spread in loop-radius units. */export const LANE_SPREAD = 0.15;const LANE_PULL = 9;/** Position-restoring rate per second along the herd. */const CATCH_UP_PER_SECOND = 0.12;const CATCH_UP_SHARE = 0.3;const OBSTACLE_REACH_METRES = 9;const OBSTACLE_PUSH = 26;const LOOK_AHEAD_SECONDS = 0.8;const EYE_REACH_METRES = 6;const EYE_PUSH = 90;const FEAR_METRES = 26;const FLEE = 30;const CLAP_METRES_PER_SECOND = 70;const CLAP_BAND_METRES = 9;const CLAP_REACH_METRES = 260;const CLAP_PUSH = 40;const PANIC_SECONDS = 2.6;/** Panic retention per neighbour hop and propagation rate. */const PANIC_KEPT = 0.86;const PANIC_SPREAD_PER_SECOND = 4;/** Track area each horse takes at running density, in square metres. */const SQUARE_METRES_EACH = 10;/** Grid bounds include the track and a margin. */const LOW = d.vec3f(-LOOP_METRES[0] * 1.3, -20, -LOOP_METRES[1] * 1.45);const SIZE = d.vec3f(LOOP_METRES[0] * 2.6, 60, LOOP_METRES[1] * 2.9);export const Horse = d.struct({  position: d.vec3f,  panic: d.f32,  velocity: d.vec2f,  /** Ground-plane facing, retained while stationary. */  facing: d.vec2f,  /** Gait-cycle progress from 0 to 1. */  phase: d.f32,  /** Pose id. */  pose: d.u32,  /** Mix from walk at 0 to gallop at 1, easing towards the gait's. */  gait: d.f32,});/** Per-horse traits, fixed until the herd is populated again. */const Trait = d.struct({  /** Lateral track position from -1 to 1. */  lane: d.f32,  /** Longitudinal offset from the herd centre in metres. */  place: d.f32,  /** Speed multiplier relative to herd speed. */  pace: d.f32,  /** Size multiplier. */  size: d.f32,});const Controls = d.struct({  count: d.u32,  /** Skin-to-card transition distance in metres. */  handOff: d.f32,  /** Herd-centre angle around the oval in radians. */  middle: d.f32,  urgency: d.f32,  time: d.f32,  /** Followed horse id, or `picker.none`. `watched` holds its state. */  followed: d.u32,});/** Followed horse position and heading for camera readback. */const Watched = d.struct({ position: d.vec3f, velocity: d.vec2f });const Threat = d.struct({  pointer: d.vec3f,  /** Pointer fear from 0 to 1. Full strength while moving over ground, fading when still. */  fear: d.f32,  clap: d.vec3f,  /** Seconds since the last thunderclap. */  clapAge: d.f32,  /** Camera position and avoidance radius while the camera is low among the horses. */  eye: d.vec2f,  eyeRadius: d.f32,});/** Ground-plane position and avoidance radius of a rock or tree. */const Obstacle = d.struct({ place: d.vec2f, radius: d.f32 });const Neighbour = d.struct({ position: d.vec3f, panic: d.f32, velocity: d.vec2f });const missing = unfilled('the herd', 'herd', 'createHerd');export const horses = tgpu.mutableAccessor(d.arrayOf(Horse), missing);/** The horses as `step` left them, read-only for render pipelines. */export const seen = tgpu.accessor(d.arrayOf(Horse), missing);export const traits = tgpu.accessor(d.arrayOf(Trait), missing);/** Herd urgency from 0 to 1. */const hurry = tgpu.accessor(d.f32, missing);/** Size-adjusted speed of horse `i` in metres per second. */const paceOf = (i: number) => {  'use gpu';  return std.length(horses.$[i]!.velocity) / traits.$[i]!.size;};const fast = (i: number) => {  'use gpu';  return paceOf(i) > GALLOP_FROM;};const slow = (i: number) => {  'use gpu';  return paceOf(i) < WALK_FROM;};const calm = (i: number) => {  'use gpu';  return hurry.$ < GRAZING_BELOW && horses.$[i]!.panic < 0.1;};const restless = (i: number) => {  'use gpu';  return !calm(i);};/** * Selects walking or galloping by speed. Separate thresholds prevent gait flicker. Calm horses * occasionally graze. */export const Gait = machines.define('Gait', {  states: {    galloping: { next: [{ to: 'walking', when: slow }] },    walking: {      next: [        { to: 'galloping', when: fast },        { to: 'grazing', when: calm, rate: GRAZES_PER_SECOND },      ],    },    grazing: {      next: [        { to: 'walking', when: restless },        { to: 'walking', after: GRAZE_SECONDS, rate: 1 / GRAZE_MORE_SECONDS },      ],    },  },});/** Track angle at a position, measured on the herd clock. */export const aroundAt = (x: number, z: number) => {  'use gpu';  return std.atan2(z / LOOP_METRES[1], x / LOOP_METRES[0]);};/** Push away along `offset`, from 0 at `reach` metres outside `radius` to `push` at `radius`. */const avoid = tgpu.fn(  [d.vec2f, d.f32, d.f32, d.f32],  d.vec2f,)((offset, radius, reach, push) => {  'use gpu';  const distance = std.length(offset);  const gap = distance - radius;  if (gap < reach) {    const close = 1 - std.max(gap, 0) / reach;    return (offset / std.max(distance, 0.1)) * push * close * close;  }  return d.vec2f();});/** Track length per radian at `around`, in metres. */const metresPerRadian = (around: number) => {  'use gpu';  const along = LOOP_METRES[0] * std.sin(around);  const across = LOOP_METRES[1] * std.cos(around);  return std.sqrt(along * along + across * across);};/** The way clockwise along the track at `place`, turned back towards `lane`, from -1 to 1. */const routeAt = (place: d.v2f, lane: number) => {  'use gpu';  const around = aroundAt(place.x, place.y);  const along = std.normalize(    d.vec2f(LOOP_METRES[0] * std.sin(around), -LOOP_METRES[1] * std.cos(around)),  );  const outward = std.normalize(    place / d.vec2f(LOOP_METRES[0] * LOOP_METRES[0], LOOP_METRES[1] * LOOP_METRES[1]),  );  const off = 1 + lane * LANE_SPREAD - loopAt(place.x, place.y);  return std.normalize(along + outward * off * LANE_PULL);};/** * Speed in metres per second added to `cruise` that brings a horse at `place` back to its place, * `metres` along the track from the herd's middle at `middle` radians: positive behind it and * negative ahead of it. The herd moves towards decreasing angles. */const catchUpAt = (place: d.v2f, metres: number, middle: number, cruise: number) => {  'use gpu';  const around = aroundAt(place.x, place.y);  let behind = around - middle + metres / metresPerRadian(around);  behind -= std.round(behind / (Math.PI * 2)) * Math.PI * 2;  return std.clamp(    behind * metresPerRadian(around) * CATCH_UP_PER_SECOND,    -cruise * CATCH_UP_SHARE,    cruise * CATCH_UP_SHARE,  );};/** CPU equivalent of `metresPerRadian`. */export function metresAround(around: number) {  return Math.hypot(LOOP_METRES[0] * Math.sin(around), LOOP_METRES[1] * Math.cos(around));}/** `angle` brought within half a turn of zero. */export const wrap = (angle: number) => angle - Math.round(angle / (Math.PI * 2)) * Math.PI * 2;/** Track position at `around`. The herd travels clockwise viewed from above. */export function trackAt(around: number, out: [number, number] = [0, 0]): [number, number] {  out[0] = LOOP_METRES[0] * Math.cos(around);  out[1] = LOOP_METRES[1] * Math.sin(around);  return out;}/** Track tangent at `around`. */export function trackHeading(around: number, out: [number, number] = [0, 0]): [number, number] {  const length = metresAround(around);  out[0] = (LOOP_METRES[0] * Math.sin(around)) / length;  out[1] = (-LOOP_METRES[1] * Math.cos(around)) / length;  return out;}/** Track length in metres occupied by `count` horses at running density. */function herdLength(count: number) {  const width = LOOP_METRES[1] * LANE_SPREAD * 2;  return (count * SQUARE_METRES_EACH) / width;}/** Cruising speed for the given urgency before adding panic. */export function cruiseAt(urgency: number) {  return WALK_SPEED + (GALLOP_SPEED - WALK_SPEED) * urgency;}interface HerdOptions {  readonly count: number;  /** Herd-centre angle around the oval to start the herd at, in radians. */  readonly middle: number;  /** Metres from the eye beyond which horses are drawn as cards. */  readonly handOff: number;  readonly obstacles: readonly {    readonly place: readonly [number, number];    readonly radius: number;  }[];  /**   * Pose ids ordered by moving poses, a cycle of phases per mix from walk to gallop, then idle   * poses and grazing poses.   */  readonly poses: readonly number[];  /** Random-number source for the horses' traits. Default `Math.random`. */  readonly random?: () => number;}/** Moves horses on the GPU using neighbour steering, gait selection and panic propagation. */export function createHerd(root: TgpuRoot, options: HerdOptions) {  const random = options.random ?? Math.random;  const own = owner();  const state = own(root.createBuffer(d.arrayOf(Horse, CAPACITY)).$usage('storage'));  const traitData = own(root.createBuffer(d.arrayOf(Trait, CAPACITY)).$usage('storage'));  let middle = options.middle;  const clock = { middle, urgency: 1, time: 0 };  const controls = root.createUniform(Controls, {    ...clock,    count: options.count,    handOff: options.handOff,    followed: picker.none,  });  const watched = root.createBuffer(Watched).$usage('storage');  const watching = watched.as('mutable');  const threat = root.createUniform(Threat);  const obstacles = root.createReadonly(    d.arrayOf(Obstacle, options.obstacles.length),    options.obstacles.map(({ place, radius }) => ({ place: d.vec2f(...place), radius })),  );  const poseIds = root.createReadonly(d.arrayOf(d.u32, POSES), [...options.poses]);  const alive = (i: number) => {    'use gpu';    return i < controls.$.count;  };  const herdUrgency = () => {    'use gpu';    return controls.$.urgency;  };  const moving: Inject = (cfg) =>    cfg      .with(horses, state.as('mutable'))      .with(traits, traitData.as('readonly'))      .with(hurry, herdUrgency);  const gaits = own(    machines      .create(root, Gait, {        capacity: CAPACITY,        step: STEP_SECONDS,        alive,      })      .pipe(moving),  );  const reading: Inject = (cfg) => gaits.inject()(moving(cfg));  const seeing: Inject = (cfg) => cfg.with(seen, state.as('readonly'));  const cells = own(    grid.create(root, {      capacity: CAPACITY,      low: LOW,      size: SIZE,      sight: SIGHT_METRES,      item: Neighbour,      alive,      position: tgpu        .fn((i: number) => {          'use gpu';          return d.vec3f(horses.$[i]!.position);        })        .pipe(moving),      itemOf: tgpu        .fn((i: number) => {          'use gpu';          const horse = horses.$[i]!;          return Neighbour({            position: horse.position,            panic: horse.panic,            velocity: horse.velocity,          });        })        .pipe(moving),    }),  );  const poseOf = (i: number, speed: number, gait: number, phase: number, mixed: number) => {    'use gpu';    if (speed < RESTING_SPEED) {      const resting = i % RESTING_POSES;      if (gait === Gait.grazing) return poseIds.$[PHASES * MIXES + RESTING_POSES + resting]!;      return poseIds.$[PHASES * MIXES + resting]!;    }    const level = d.u32(std.round(mixed * (MIXES - 1)));    return poseIds.$[level * PHASES + (d.u32(phase * PHASES) % PHASES)]!;  };  const step = tgpu.computeFn({    workgroupSize: [WORKGROUP],    in: { gid: d.builtin.globalInvocationId },  })(({ gid }) => {    'use gpu';    const i = gid.x;    if (i >= controls.$.count) return;    const horse = Horse(horses.$[i]!);    const trait = traits.$[i]!;    const gait = Gait.state(i);    const place = horse.position.xz;    let away = d.vec2f();    let heading = d.vec2f();    let centre = d.vec2f();    let mates = d.f32(0);    let panicked = d.f32(0);    const near = cells.around(horse.position);    for (let r = d.u32(0); r < grid.RANGES; r++) {      const range = cells.range(near, r);      for (let j = d.u32(0); j < range.count; j++) {        const other = cells.neighbour(range, j);        const offset = other.position.xz - place;        const distance2 = std.dot(offset, offset);        if (distance2 > SIGHT_METRES * SIGHT_METRES || distance2 < 1e-6) continue;        if (distance2 < PERSONAL_METRES * PERSONAL_METRES) away -= offset / distance2;        heading += other.velocity;        centre += offset;        mates += 1;        panicked = std.max(panicked, other.panic);      }    }    const route = routeAt(place, trait.lane);    const cruise = std.mix(WALK_SPEED, GALLOP_SPEED, controls.$.urgency);    const catchUp = catchUpAt(place, trait.place, controls.$.middle, cruise);    let panic = horse.panic * std.exp(-STEP_SECONDS / PANIC_SECONDS);    const spread = panicked * PANIC_KEPT;    if (spread > panic)      panic += (spread - panic) * std.saturate(PANIC_SPREAD_PER_SECOND * STEP_SECONDS);    let flee = d.vec2f();    const threats = threat.$;    if (threats.fear > 0) {      const offset = place - threats.pointer.xz;      const distance = std.length(offset);      if (distance < FEAR_METRES) {        const fear = (1 - distance / FEAR_METRES) * threats.fear;        panic = std.max(panic, fear);        flee += (offset / std.max(distance, 0.1)) * FLEE * fear;      }    }    const reach = threats.clapAge * CLAP_METRES_PER_SECOND;    if (reach < CLAP_REACH_METRES) {      const offset = place - threats.clap.xz;      const distance = std.length(offset);      if (std.abs(distance - reach) < CLAP_BAND_METRES) {        panic = std.max(panic, 1 - reach / CLAP_REACH_METRES);        flee += (offset / std.max(distance, 0.1)) * CLAP_PUSH;      }    }    const grazing = gait === Gait.grazing;    let wanted = (std.mix(cruise, PANIC_SPEED, panic) + catchUp) * trait.pace;    if (grazing) wanted = 0;    // Avoid obstacles using a point ahead of the horse    const ahead = place + horse.velocity * LOOK_AHEAD_SECONDS;    let swerve = d.vec2f();    for (let k = d.u32(0); k < obstacles.$.length; k++) {      const obstacle = obstacles.$[k]!;      const offset = ahead - obstacle.place;      swerve += avoid(offset, obstacle.radius, OBSTACLE_REACH_METRES, OBSTACLE_PUSH);    }    if (threats.eyeRadius > 0) {      swerve += avoid(place - threats.eye, threats.eyeRadius, EYE_REACH_METRES, EYE_PUSH);    }    let force = (route * wanted - horse.velocity) * RESPONSE_PER_SECOND;    force += away * SEPARATION + swerve + flee;    if (mates > 0) {      force += (heading / mates - horse.velocity) * ALIGNMENT * (1 - panic * 0.5);      force += (centre / mates) * COHESION;    }    let velocity = horse.velocity + force * STEP_SECONDS;    const speed = std.length(velocity);    const fastest = std.max(wanted * 1.15, PANIC_SPEED * panic);    if (speed > fastest) velocity *= fastest / std.max(speed, 1e-4);    if (grazing) velocity *= 0.9;    const moved = std.length(velocity);    const next = place + velocity * STEP_SECONDS;    const galloping = std.select(d.f32(0), d.f32(1), gait === Gait.galloping);    const turn = GAIT_PER_SECOND * STEP_SECONDS;    const mixed = horse.gait + std.clamp(galloping - horse.gait, -turn, turn);    const stride = std.mix(WALK_STRIDE, GALLOP_STRIDE, mixed) * trait.size;    const phase = std.fract(horse.phase + (moved * STEP_SECONDS) / stride);    let facing = d.vec2f(horse.facing);    if (moved > RESTING_SPEED) facing = std.normalize(std.mix(facing, velocity / moved, 0.25));    const position = d.vec3f(next.x, heightAt(next.x, next.y), next.y);    if (i === controls.$.followed) watching.$ = Watched({ position, velocity });    horses.$[i] = Horse({      position,      panic,      velocity,      facing,      phase,      pose: poseOf(i, moved, gait, phase, mixed),      gait: mixed,    });  });  const stepping = root.pipe(reading).pipe(cells.inject()).createComputePipeline({ compute: step });  const traitValues: d.Infer<typeof Trait>[] = [];  let count = options.count;  const populate = (wanted: number) => {    if (!Number.isInteger(wanted) || wanted < 1 || wanted > CAPACITY) {      throw new RangeError(`The herd holds 1 to ${CAPACITY} horses, got ${wanted}.`);    }    count = wanted;    const length = herdLength(count);    const states: d.InferInput<typeof Horse>[] = [];    traitValues.length = 0;    for (let i = 0; i < count; i++) {      // Lower edge density gives the herd uneven sides      const lane = random() + random() - 1;      // Concentrate horses near the herd centre      const place = ((random() + random() + random()) / 3 - 0.5) * length;      const size = 0.9 + random() * 0.2;      const pace = 0.95 + random() * 0.1;      const around = middle - place / metresAround(middle);      const radius = 1 + lane * LANE_SPREAD;      const [x, z] = trackAt(around).map((value) => value * radius) as [number, number];      const [hx, hz] = trackHeading(around);      const phase = random();      traitValues.push({ lane, place, pace, size });      states.push({        position: d.vec3f(x, heightAt(x, z), z),        panic: 0,        velocity: d.vec2f(hx * GALLOP_SPEED, hz * GALLOP_SPEED),        facing: d.vec2f(hx, hz),        phase,        pose: options.poses[(MIXES - 1) * PHASES + Math.floor(phase * PHASES)]!,        gait: 1,      });      gaits.start(i, Gait.galloping);    }    state.write(states);    traitData.write(traitValues);    controls.patch({ count });  };  populate(options.count);  return {    controls,    threat,    /** Followed horse state written by `step` for readback. */    watched,    traits: traitValues,    get count() {      return count;    },    /** Herd-centre angle around the oval, which leads the horses clockwise. */    get middle() {      return middle;    },    populate,    gaits,    /** Moves the herd's centre on by `seconds` at the cruising speed of `urgency`, for the next steps. */    advance(seconds: number, urgency: number) {      middle = wrap(middle - (cruiseAt(urgency) * seconds) / metresAround(middle));      clock.middle = middle;      clock.urgency = urgency;      clock.time += seconds;      controls.patch(clock);    },    step(pass: TgpuComputePass) {      cells.sort(pass);      pass.setPipeline(stepping);      pass.dispatchWorkgroups(Math.ceil(count / WORKGROUP));      gaits.step(pass);    },    /**     * Fills `horses`, `traits` and `Gait` accessors for pipelines created from this     * configuration.     */    inject: () => reading,    /** Fills `seen` for pipelines that only read the horses, such as renderers. */    seeing: () => seeing,    initAsync: () => Promise.all([cells.initAsync(), stepping.initAsync(), gaits.initAsync()]),    destroy: own.destroy,  };}export type Herd = ReturnType<typeof createHerd>;

horses.ts

import {  events,  impostors,  instances,  owner,  poses,  shading,  unfilled,  type CameraBase,  type Inject,  type MeshPool,  type meshPool,  type Picker,} from '@typegpu/kit';import { srgbToLinear } from '@typegpu/color';import { d, std, tgpu, type TgpuComputePass, type TgpuRoot } from 'typegpu';import type { Model } from '../models.ts';import {  CAPACITY,  HORSE_SCALE,  Gait,  horses,  MIXES,  PHASES,  POSES,  RESTING_POSES,  seen,  type Herd,} from './herd.ts';const FADE_METRES = 8;const NEVER_HANDS_OVER_METRES = 1e6;/** Body-bound centre height above the hooves and radius in model units. */const BODY_HEIGHT = 1.2 / HORSE_SCALE;const BODY_RADIUS = 2 / HORSE_SCALE;const ATLAS_VIEWS = 5;const ATLAS_TEXELS = 64;/** Captured card phases, gallop first and walk second. */const CARD_GALLOP = 6;const CARD_WALK = 4;const COUNTS_HZ = 15;const WORKGROUP = 64;const TINT = [0.25, 0.7, 1.4] as const;const WARM = d.vec3f(1.15, 1, 0.8);const GLOW = d.vec3f(0.35, 0.2, 0.06);const GLOW_BASE = d.vec3f(0.02, 0.01, 0.002);const RIM_POWER = 3;const ROUGHNESS = 0.72;type Triple = readonly [number, number, number];/** Body and mane colours in sRGB, with coat frequency weights. */const COATS = [  { name: 'bay', body: [0.45, 0.25, 0.13], mane: [0.07, 0.05, 0.04], weight: 5 },  { name: 'chestnut', body: [0.56, 0.3, 0.14], mane: [0.5, 0.27, 0.12], weight: 4 },  { name: 'brown', body: [0.28, 0.17, 0.1], mane: [0.06, 0.05, 0.04], weight: 3 },  { name: 'black', body: [0.11, 0.1, 0.095], mane: [0.05, 0.05, 0.05], weight: 2 },  { name: 'grey', body: [0.62, 0.62, 0.6], mane: [0.36, 0.36, 0.36], weight: 2 },  { name: 'white', body: [0.85, 0.83, 0.79], mane: [0.9, 0.88, 0.84], weight: 1 },  { name: 'palomino', body: [0.8, 0.6, 0.31], mane: [0.94, 0.89, 0.76], weight: 2 },  { name: 'dun', body: [0.7, 0.57, 0.4], mane: [0.14, 0.11, 0.09], weight: 2 },] as const;/** Model coat-material brightness multipliers. */const SHADES: Readonly<Record<string, number>> = {  Main: 1,  Main_Dark: 0.6,  Main_Light: 1.45,  Muzzle: 0.35,};const MANE = 'Hair';/** Neutral capture grey. Card tint multiplies it into the selected coat. */const CAPTURED = 0.5;const CAPTURED_MANE = 0.45;const GOLDEN = (Math.sqrt(5) - 1) / 2;const linear = (color: Triple): Triple => {  const value = srgbToLinear(d.vec3f(...color));  return [value.x, value.y, value.z];};const scaled = (color: Triple, by: number): Triple => [color[0] * by, color[1] * by, color[2] * by];/** `color` times `by`, each channel at most 1. */const brightened = (color: Triple, by: number): Triple => [  Math.min(color[0] * by, 1),  Math.min(color[1] * by, 1),  Math.min(color[2] * by, 1),];const coats = COATS.map(({ body, mane }) => ({ body: linear(body), mane: linear(mane) }));const totalWeight = COATS.reduce((sum, { weight }) => sum + weight, 0);/** Assigns coats by weighted frequency across horse indices, with brightness variation. */function coatOf(member: number) {  let pick = ((member * GOLDEN) % 1) * totalWeight;  let coat = 0;  while (pick >= COATS[coat]!.weight) pick -= COATS[coat++]!.weight;  const shade = 0.88 + 0.24 * ((member * GOLDEN * GOLDEN * 7) % 1);  const { body, mane } = coats[coat]!;  return { body: scaled(body, shade), mane };}/** * How a horse mesh takes its coat: the mane colour, the body colour at a brightness, or its own * material colour. */type Coating = 'mane' | number | undefined;const coatingOf = (material: string): Coating => (material === MANE ? 'mane' : SHADES[material]);const coatColor = (coat: ReturnType<typeof coatOf>, coating: Coating, own: Triple) => {  if (coating === 'mane') return coat.mane;  return coating === undefined ? own : brightened(coat.body, coating);};/** Grey a mesh is captured in for cards, which the card tint multiplies into its coat. */const capturedGrey = (coating: Coating) => {  if (coating === 'mane') return CAPTURED_MANE;  return coating === undefined ? undefined : CAPTURED * coating;};/** Animation clip names in the model file. */const CLIPS = { gallop: 'Gallop', walk: 'Walk', idle: 'Idle', graze: 'Eating' } as const;const missing = unfilled('the horse levels', 'levels', 'createLevels');/** The hovered and the followed horse, which stay skinned and glow. */const hovered = tgpu.accessor(d.u32, missing);const followed = tgpu.accessor(d.u32, missing);const selectedOf = (member: number) => {  'use gpu';  return member === hovered.$ || member === followed.$;};/** Where horse `member` stands and faces, at its row's `scale`, which `populate` sizes. */const placementOf = (member: number, scale: d.v3f) => {  'use gpu';  const horse = seen.$[member]!;  return instances.Placement({    rotation: instances.facing(d.vec3f(horse.facing.x, 0, horse.facing.y)),    position: d.vec3f(horse.position),    scale,  });};const HorseBody = instances.define('Horse', {  host: { member: d.u32 },  place: (horse) => {    'use gpu';    return placementOf(horse.member, horse.scale);  },  pose: (horse) => {    'use gpu';    return seen.$[horse.member]!.pose;  },  bounds: (horse) => {    'use gpu';    return instances.Sphere({      center: seen.$[horse.member]!.position + d.vec3f(0, BODY_HEIGHT, 0) * horse.scale,      radius: BODY_RADIUS * horse.scale.x,    });  },  material: (point, horse, base) => {    'use gpu';    const selected = selectedOf(horse.member);    const rim = std.pow(1 - std.saturate(std.dot(point.normal, point.view)), RIM_POWER);    const lit = shading.Material(base);    lit.color = base.color * std.select(d.vec3f(1), WARM, selected);    lit.emission = base.emission + std.select(d.vec3f(), GLOW * rim + GLOW_BASE, selected);    return lit;  },});const HorseCard = impostors.define('HorseCard', {  host: { member: d.u32 },  place: (horse) => {    'use gpu';    return placementOf(horse.member, horse.scale);  },});/** Shared figures ordered to match herd pose ids. */export function createPoses(root: TgpuRoot, model: Model) {  if (!model.skin) throw new Error('The horse needs its skin.');  const clip = (name: string) => poses.clip(model, name);  const clips = {    gallop: clip(CLIPS.gallop),    walk: clip(CLIPS.walk),    idle: clip(CLIPS.idle),    graze: clip(CLIPS.graze),  };  const figures = poses.create(    root,    model.skin,    { moving: [clips.walk, clips.gallop], idle: clips.idle, graze: clips.graze },    { capacity: POSES },  );  // Hold every phase of the walk-to-gallop cycle at each mix, so a horse changing gait keeps its  // phase and steps through the mixes  const moving = Array.from({ length: MIXES * PHASES }, (_, i) => {    const mix = Math.floor(i / PHASES) / (MIXES - 1);    const cycle = clips.walk.duration + (clips.gallop.duration - clips.walk.duration) * mix;    return figures.add('moving', 0, { from: ((i % PHASES) / PHASES) * cycle, mix, speed: 0 });  });  const playing = (name: 'idle' | 'graze') =>    Array.from({ length: RESTING_POSES }, (_, i) =>      figures.add(name, 0, { from: (clips[name].duration * i) / RESTING_POSES }),    );  const gallop = moving.slice((MIXES - 1) * PHASES);  const walk = moving.slice(0, PHASES);  const ids = [...moving, ...playing('idle'), ...playing('graze')];  return {    poses: figures,    ids: ids as readonly number[],    /** Captures every fourth gallop phase and every sixth walk phase. */    captured: [      ...Array.from({ length: CARD_GALLOP }, (_, i) => gallop[(i * PHASES) / CARD_GALLOP]!),      ...Array.from({ length: CARD_WALK }, (_, i) => walk[(i * PHASES) / CARD_WALK]!),    ],  };}export type Posed = ReturnType<typeof createPoses>;interface LevelsOptions {  readonly body: meshPool.Model;  /** `impostors.CARD` in the pool. */  readonly card: meshPool.Mesh;  readonly posed: Posed;  readonly herd: Herd;  readonly picked: Picker;  readonly view: CameraBase;  /** Original model for material-name lookup. */  readonly model: Model;}/** Draws nearby horses as skins and distant horses as cards, culled for scene and shadow views. */export function createLevels(root: TgpuRoot, pool: MeshPool, options: LevelsOptions) {  const { body, card, posed, herd, picked, view, model } = options;  const coatings = model.meshes.map((mesh) => coatingOf(mesh.material.name ?? ''));  const own = owner();  const captured = body.meshes.map((mesh, i) => {    const grey = capturedGrey(coatings[i]);    return {      mesh,      pose: posed.captured,      ...(grey === undefined ? {} : { color: [grey, grey, grey] as const }),    };  });  const atlas = own(    impostors.create(root, pool, captured, {      card,      views: ATLAS_VIEWS,      size: ATLAS_TEXELS,      poses: posed.poses,    }),  );  // Evaluate capture figures earlier in the same encoder  const capture = root.createCommandEncoder();  const posing = capture.beginComputePass();  posed.poses.animate(posing, 0);  posing.end();  atlas.capture(capture);  capture.submit();  const bodies = own(    instances.create(root, pool, { kind: HorseBody, capacity: CAPACITY * body.meshes.length }),  );  const cards = own(instances.create(root, pool, { kind: HorseCard, capacity: CAPACITY }));  const bodyIds: instances.Id<'Horse'>[] = [];  const cardIds: instances.Id<'HorseCard'>[] = [];  let tinted = false;  const cardColorOf = (member: number) => {    const { body: coat } = coatOf(member);    const tint = tinted ? TINT : ([1, 1, 1] as const);    return d.vec3f(      (coat[0] / CAPTURED) * tint[0],      (coat[1] / CAPTURED) * tint[1],      (coat[2] / CAPTURED) * tint[2],    );  };  const populate = () => {    for (const id of bodyIds) bodies.remove(id);    for (const id of cardIds) cards.remove(id);    bodyIds.length = 0;    cardIds.length = 0;    for (let member = 0; member < herd.count; member++) {      const coat = coatOf(member);      const scale = herd.traits[member]!.size * HORSE_SCALE;      const horse = bodies.add(body, (mesh, i) => {        const color = coatColor(coat, coatings[i], mesh.material.color);        return { member, scale, color, roughness: ROUGHNESS, metallic: 0 };      });      bodyIds.push(horse);      cardIds.push(        cards.add(card, {          member,          scale,          color: cardColorOf(member),          roughness: ROUGHNESS,          metallic: 0,        }),      );    }    picked.result.reset();  };  populate();  const keptDistance = (selected: boolean) => {    'use gpu';    if (selected) return d.f32(NEVER_HANDS_OVER_METRES);    return herd.controls.$.handOff;  };  const following = () => {    'use gpu';    return herd.controls.$.followed;  };  const choosing: Inject = (cfg) =>    picked.inject()(cfg).with(hovered, picked.found).with(followed, following);  const keptTo = tgpu    .fn((row: number) => {      'use gpu';      return keptDistance(selectedOf(HorseBody.member(row)));    })    .pipe(bodies.inject());  const keptFrom = tgpu    .fn((row: number) => {      'use gpu';      return keptDistance(selectedOf(HorseCard.member(row)));    })    .pipe(cards.inject());  const solid = bodies    .renderer({ cull: true, to: keptTo, fade: FADE_METRES, eye: view })    .pipe(posed.poses.inject())    .pipe(herd.seeing())    .pipe(choosing);  const distant = cards    .renderer({      cull: true,      from: keptFrom,      fade: FADE_METRES,      eye: view,    })    .pipe(atlas.inject('nearest'))    .pipe(herd.seeing())    .pipe(choosing);  const counters = own(    events.create(root, ['skinned', 'impostors'], { hz: COUNTS_HZ, per: 'frame' }),  );  // Picks each card's captured phase and counts the horses drawn each way  const phaseCards = tgpu.computeFn({    workgroupSize: [WORKGROUP],    in: { gid: d.builtin.globalInvocationId },  })(({ gid }) => {    'use gpu';    const row = gid.x;    if (row >= HorseCard.span() || !HorseCard.alive(row)) return;    const member = HorseCard.member(row);    const horse = horses.$[member]!;    HorseCard.device.$[row]!.phase = std.select(      CARD_GALLOP + (d.u32(horse.phase * CARD_WALK) % CARD_WALK),      d.u32(horse.phase * CARD_GALLOP) % CARD_GALLOP,      Gait.state(member) === Gait.galloping,    );    const kept = keptDistance(selectedOf(member));    if (std.distance(view.uniform.$.position, horse.position) < kept) counters.add.skinned(1);    else counters.add.impostors(1);  });  const phasing = root    .pipe(cards.inject())    .pipe(herd.inject())    .pipe(choosing)    .pipe(counters.inject())    .createComputePipeline({ compute: phaseCards });  return {    solid,    distant,    populate,    tint(show: boolean) {      tinted = show;      for (let i = 0; i < cardIds.length; i++) cards.write(cardIds[i]!, { color: cardColorOf(i) });    },    initAsync: () => phasing.initAsync(),    flush() {      bodies.flush();      cards.flush();    },    arrange(pass: TgpuComputePass) {      pass.setPipeline(phasing);      pass.dispatchWorkgroups(Math.ceil(cards.span / WORKGROUP));      solid.cull(pass);      distant.cull(pass);    },    capture: counters.capture,    get counts() {      return counters.latest;    },    destroy: own.destroy,  };}

selection.ts

import {  font,  instances,  picker,  poses,  text,  type Baked,  type CameraBase,  type Picker,  type Surface,} from '@typegpu/kit';import { d, std, tgpu, type TgpuComputePass, type TgpuRoot } from 'typegpu';import { mat4 } from 'wgpu-matrix';import type { Model } from '../models.ts';import { CAPACITY, HORSE_SCALE, horses, traits, type Herd } from './herd.ts';import type { Posed } from './horses.ts';/** Picker target at the body centre in model-bound coordinates. */const BODY_MIDDLE = [0, -0.006, 0.03] as const;const PICK_RADIUS_METRES = 1.6;export const PICK_REACH_POINTS = 3;/** Horse names, each shared by a group of indices. */const NAMES = `Comet Juniper Saffron Biscuit Thistle Maverick Clover Dune Ember Willow Pepper Sable  Tumbleweed Marigold Rook Sorrel Nutmeg Gale Bramble Cinder Honey Flint Mistral Pippin Ochre Quill  Rosemary Sundance Tansy Umber Vesper Wren`.split(/\s+/);const LONGEST = Math.max(...NAMES.map((name) => name.length));const EM_POINTS = 26;const LABEL_METRES = 2.5;const INK = d.vec3f(1, 0.9, 0.7);const OUTLINE_PIXELS = 0.8;const OUTLINE = d.vec3f(0.03, 0.02, 0.015);interface PickingOptions {  readonly model: Model;  readonly herd: Herd;  readonly posed: Posed;  readonly surface: Surface;}/** The joint of the horse's skin bound nearest `BODY_MIDDLE`. */function middleJointOf(model: Model) {  let middle = { joint: 0, distance: Infinity };  for (const [joint, { inverseBind }] of model.skin!.joints.entries()) {    const [x, y, z] = mat4.getTranslation(mat4.inverse(inverseBind));    const distance =      (x! - BODY_MIDDLE[0]) ** 2 + (y! - BODY_MIDDLE[1]) ** 2 + (z! - BODY_MIDDLE[2]) ** 2;    if (distance < middle.distance) middle = { joint, distance };  }  return middle.joint;}/** Body-sized picking target centred on a moving body joint. */export function createPicking(root: TgpuRoot, view: CameraBase, options: PickingOptions) {  const { model, herd, posed, surface } = options;  const middleJoint = middleJointOf(model);  return picker.create(root, view, {    capacity: CAPACITY,    size: surface,    rank: picker.byEye,    settings: { reach: PICK_REACH_POINTS },    alive: (member) => {      'use gpu';      return member < herd.controls.$.count;    },    position: tgpu      .fn((member: number) => {        'use gpu';        const horse = horses.$[member]!;        const at = poses.jointPosition(horse.pose, middleJoint);        const facing = instances.facing(d.vec3f(horse.facing.x, 0, horse.facing.y));        const turned = instances.rotate(facing, at);        return horse.position + turned * (traits.$[member]!.size * HORSE_SCALE);      })      .pipe(herd.inject())      .pipe(posed.poses.inject()),    radius: tgpu      .fn((member: number) => {        'use gpu';        return PICK_RADIUS_METRES * traits.$[member]!.size;      })      .pipe(herd.inject()),  });}/** Fills a glyph with its colour inside a dark outline. */const outlined = (fragment: text.Fragment) => {  'use gpu';  const fill = std.saturate(fragment.distance + 0.5);  const edge = std.saturate(fragment.round + OUTLINE_PIXELS + 0.5);  return d.vec4f(    std.mix(OUTLINE, fragment.color.xyz, fill) * edge * fragment.color.w,    edge * fragment.color.w,  );};interface LabelOptions {  readonly font: Baked;  readonly picked: Picker;  readonly herd: Herd;}/** * Writes the hovered horse's label on the GPU in the same frame as picking, copying its name from * a set laid out once and never drawn. */export function createLabel(root: TgpuRoot, view: CameraBase, options: LabelOptions) {  const { font: baked, picked, herd } = options;  const sans = font.create(root, baked);  const names = text.create(root, sans, { capacity: NAMES.length, length: LONGEST });  for (const [i, name] of NAMES.entries()) {    names.write(i, name, { em: EM_POINTS, baseline: 'alphabetic' });  }  const label = text.create(root, sans, {    camera: view,    capacity: 1,    length: LONGEST,    place: text.pinned,    shade: outlined,  });  const writeName = tgpu.computeFn({ workgroupSize: [1] })(() => {    'use gpu';    const member = picked.found();    if (member === picker.none) {      label.erase(0);      return;    }    for (let k = d.u32(0); k < LONGEST; k++)      label.setQuad(0, k, names.quadAt(member % NAMES.length, k));    label.setLabel(      0,      text.Label({        position: horses.$[member]!.position + d.vec3f(0, LABEL_METRES * traits.$[member]!.size, 0),        color: d.vec4f(INK, 1),      }),    );  });  const writing = root    .pipe(picked.inject())    .pipe(herd.inject())    .pipe(label.inject())    .pipe(names.inject())    .createComputePipeline({ compute: writeName });  return {    drawing: label.drawing,    initAsync: () => writing.initAsync(),    write(pass: TgpuComputePass) {      pass.setPipeline(writing);      pass.dispatchWorkgroups(1);    },    destroy() {      label.destroy();      names.destroy();      sans.destroy();    },  };}

land.ts

import { cameras, environment, instances, materials, meshPool, shading } from '@typegpu/kit';import { meshes } from '@typegpu/kit/geometry';import { srgbToLinear } from '@typegpu/color';import { perlin2d } from '@typegpu/noise';import { d, std, type TgpuRoot } from 'typegpu';/** Half the length and half the width of the oval the herd runs around, in metres. */export const LOOP_METRES = [520, 200] as const;/** The infield's knoll, at the oval's middle. */const KNOLL_METRES = 16;const HILL_METRES = 40;const HILL_WAVE_METRES = 22;/** Ridges across the hills, metres high. */const RIDGE_METRES = [22, 16, 9, 5] as const;const RANGE_METRES = 2100;const RANGE_RISE_METRES = 900;const PEAK_METRES = 340;const PEAK_WAVES_METRES = [120, 60] as const;/** World units from the middle that the land reaches. */const LAND_METRES = 4600;const RINGS = 200;const SECTORS = 320;/** Radial-grid exponent. Concentrates rings near the centre. */const RING_POWER = 2.2;const NORMAL_STEP_METRES = 0.8;const SAGE = srgbToLinear(d.vec3f(0.34, 0.45, 0.2));const OCHRE = srgbToLinear(d.vec3f(0.62, 0.52, 0.24));const SCRUB = srgbToLinear(d.vec3f(0.2, 0.27, 0.12));const TRODDEN = srgbToLinear(d.vec3f(0.52, 0.42, 0.3));const ROCK = srgbToLinear(d.vec3f(0.5, 0.46, 0.42));const ROUGHNESS = 0.95;const CLIFF_SLOPE = 0.6;const PATCH_METRES = 80;const TUFT_METRES = 7;const BLADE_METRES = 1.3;/** Loop radius where hills and darker scrub begin. */const SCRUB_FROM = 1.35;/** Worn-track half-width in loop-width units. */const TRACK_WIDTH = 0.16;const ZENITH = srgbToLinear(d.vec3f(0.4, 0.56, 0.8));/** The haze at the horizon, which the fog matches so the far hills fade into it. */export const HAZE = srgbToLinear(d.vec3f(0.98, 0.8, 0.58));const HORIZON_POWER = 0.38;const SUN_GLOW = srgbToLinear(d.vec3f(1, 0.72, 0.4));/** The sun's disc, in the lighting's sun colour times its intensity. */const SUN_DISC = 9;/** * Normalised oval radius at a position. One lies on the track, lower values inside and higher * values outside. */export const loopAt = (x: number, z: number) => {  'use gpu';  const along = x / LOOP_METRES[0];  const across = z / LOOP_METRES[1];  return std.sqrt(along * along + across * across);};/** Warped ridge pattern from 0 to 1. */const ridge = (x: number, z: number, alongX: number, alongZ: number) => {  'use gpu';  return std.abs(std.sin(x * alongX + z * alongZ + std.sin(z * alongX) * 1.5));};/** Height above the valley floor in metres, shared by CPU and GPU calculations. */export const heightAt = (x: number, z: number) => {  'use gpu';  const r = std.sqrt(x * x + z * z);  const angle = std.atan2(z, x);  const loop = loopAt(x, z);  const roll =    std.sin(x * 0.021 + 1.3) * 0.8 +    std.sin(z * 0.033 + x * 0.011) * 0.6 +    std.sin((x + z) * 0.009 + 0.4) * 1.1;  const knoll = KNOLL_METRES * (1 - std.smoothstep(0.12, 0.66, loop));  const hills =    (HILL_METRES +      std.sin(angle * 5 + 2) * HILL_WAVE_METRES +      ridge(x, z, 0.0071, 0.0043) * RIDGE_METRES[0] +      ridge(x, z, -0.0052, 0.0113) * RIDGE_METRES[1] +      ridge(x, z, 0.0163, -0.0121) * RIDGE_METRES[2] +      ridge(x, z, 0.031, 0.027) * RIDGE_METRES[3]) *    std.smoothstep(1.4, 2.6, loop);  const range =    (PEAK_METRES +      std.sin(angle * 4 + 0.7) * PEAK_WAVES_METRES[0] +      std.sin(angle * 9 + 1.9) * PEAK_WAVES_METRES[1]) *    std.smoothstep(RANGE_METRES, RANGE_METRES + RANGE_RISE_METRES, r);  return roll + knoll + hills + range;};/** Ground normal at a position. */export const normalAt = (x: number, z: number) => {  'use gpu';  const dx = heightAt(x + NORMAL_STEP_METRES, z) - heightAt(x - NORMAL_STEP_METRES, z);  const dz = heightAt(x, z + NORMAL_STEP_METRES) - heightAt(x, z - NORMAL_STEP_METRES);  return std.normalize(d.vec3f(-dx, NORMAL_STEP_METRES * 2, -dz));};/** * Position on the polar grid at sector fraction `u` and ring fraction `v`, with rings concentrated * near the centre. */const placeAt = (u: number, v: number) => {  'use gpu';  // The last sector meets the first exactly  const angle = std.select(u, d.f32(0), u >= 1) * Math.PI * 2;  return d.vec2f(std.cos(angle), std.sin(angle)) * (LAND_METRES * std.pow(v, RING_POWER));};const grassAt = (place: d.v2f, height: number, far: number) => {  'use gpu';  const patch = perlin2d.sample(place / PATCH_METRES);  // Fade fine detail with distance before it shimmers  const tuft = perlin2d.sample(place / TUFT_METRES) * (1 - std.smoothstep(150, 700, far));  const blade = perlin2d.sample(place / BLADE_METRES) * (1 - std.smoothstep(25, 110, far));  const loop = loopAt(place.x, place.y);  let color = std.mix(SAGE, OCHRE, std.smoothstep(-0.3, 0.3, patch + tuft * 0.4));  const scrub =    std.smoothstep(SCRUB_FROM, SCRUB_FROM + 0.3, loop) *    std.smoothstep(-0.1, 0.25, patch * 0.8 - tuft * 0.5);  color = std.mix(color, SCRUB, scrub * 0.8);  const worn = 1 - std.smoothstep(TRACK_WIDTH * 0.5, TRACK_WIDTH, std.abs(loop - 1));  color = std.mix(color, TRODDEN, worn * (0.4 + tuft * 0.25));  return color * (0.88 + blade * 0.24 + std.smoothstep(4, 16, height) * 0.06);};/** Grass worn along the track, with scrub and rock on the hills and far ranges. */const Land = instances.define('Land', {  material: (point) => {    'use gpu';    const cliff = 1 - std.smoothstep(CLIFF_SLOPE - 0.15, CLIFF_SLOPE, point.normal.y);    const far = std.distance(point.position, cameras.current.$.position);    const color = std.mix(grassAt(point.position.xz, point.position.y, far), ROCK, cliff);    return materials.glossy(color, ROUGHNESS);  },});/** Bakes the valley into a mesh of its own once and draws it with the scene's lighting. */export function createLand(root: TgpuRoot) {  const shape = meshes.parametric(    {      at: (u, v) => {        'use gpu';        const place = placeAt(u, v);        return d.vec3f(place.x, heightAt(place.x, place.y), place.y);      },      normalAt: (u, v) => {        'use gpu';        const place = placeAt(u, v);        return normalAt(place.x, place.y);      },    },    { cols: SECTORS, rows: RINGS },  );  const pool = meshPool.create(root, meshPool.roomFor([shape]));  const field = instances.create(root, pool, { kind: Land, capacity: 1 });  field.add(pool.add(shape));  field.flush();  return {    renderer: field.renderer(),    destroy() {      field.destroy();      pool.destroy();    },  };}/** * The sky in a direction: haze at the horizon, blue overhead, and the glow and disc of the * lighting's sun. */export const sky = (direction: d.v3f) => {  'use gpu';  const toSun = std.saturate(std.dot(direction, shading.towardsSun()));  const up = std.saturate(direction.y);  const color = std.mix(HAZE, ZENITH, std.pow(up, HORIZON_POWER));  const glow = std.pow(toSun, 24) * 0.7 + std.pow(toSun, 400) * 3;  return color + SUN_GLOW * glow + environment.sunDisc(direction, SUN_DISC);};

scenery.ts

import { instances, meshPool, type MeshPool } from '@typegpu/kit';import { quat } from 'wgpu-matrix';import type { TgpuRoot } from 'typegpu';import { heightAt, LOOP_METRES, loopAt } from './land.ts';/** The nodes of `models/scenery.glb`, from Kay Lousberg's KayKit Forest Nature Pack (CC0). */export const SCENERY = [  'Tree_1_A',  'Tree_2_A',  'Tree_4_A',  'Tree_Bare_1_A',  'Rock_1_J',  'Rock_3_A',  'Rock_3_F',  'Bush_2_A',] as const;export type SceneryName = (typeof SCENERY)[number];const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));const UP = [0, 1, 0] as const;/** Track obstacles that split the herd. */const ON_TRACK = 9;const OFF_TRACK_TREES = 90;/** Grove count, trees per grove and grove width in metres. */const GROVES = 70;const GROVE_TREES = 9;const GROVE_METRES = 60;const KNOLL_ROCKS = 26;const BUSHES = 320;const TREES = ['Tree_1_A', 'Tree_2_A', 'Tree_4_A', 'Tree_Bare_1_A'] as const;const TREE_SCALE = [2.2, 3.2] as const;const GROVE_SCALE = [2.6, 4.2] as const;const CRAG_SCALE = [3, 4.2] as const;/** Crag avoidance radius at scale 1. */const CRAG_RADIUS = 1.9;const TREE_RADIUS = 0.5;const BOULDER_SCALE = [3.5, 7] as const;const BUSH_SCALE = [4, 7] as const;/** Tint multiplier shifting bright model greens toward dry-grass olive. */const DRY_LEAVES = [0.9, 0.72, 0.5] as const;export interface Placed {  readonly model: SceneryName;  readonly position: [number, number, number];  readonly yaw: number;  readonly scale: number;}/** Evenly distributed value between `low` and `high` for index `i`. */const between = (i: number, [low, high]: readonly [number, number], salt = 0) =>  low + (high - low) * (((i + salt) * GOLDEN_ANGLE) % 1);/** The point `around` radians round an oval `loop` times the track's. */const ovalAt = (around: number, loop: number) => [  Math.cos(around) * LOOP_METRES[0] * loop,  Math.sin(around) * LOOP_METRES[1] * loop,];const onGround = (  model: SceneryName,  x: number,  z: number,  yaw: number,  scale: number,): Placed => ({  model,  position: [x, heightAt(x, z) - 0.2, z],  yaw,  scale,});/** Scenery placements and herd-avoidance obstacles. */export function layout() {  const placed: Placed[] = [];  const obstacles: { place: [number, number]; radius: number }[] = [];  for (let i = 0; i < ON_TRACK; i++) {    const around = (i / ON_TRACK) * Math.PI * 2 + 0.9;    const [x, z] = ovalAt(around, 1 + Math.sin(i * 2.3) * 0.06);    const crag = i % 3 !== 1;    const scale = between(i, crag ? CRAG_SCALE : TREE_SCALE);    placed.push(onGround(crag ? 'Rock_1_J' : 'Tree_1_A', x, z, i * 2.1, scale));    obstacles.push({ place: [x, z], radius: (crag ? CRAG_RADIUS : TREE_RADIUS) * scale + 1.5 });  }  for (let i = 0; i < OFF_TRACK_TREES; i++) {    const around = i * GOLDEN_ANGLE * 7;    const loop = i % 4 === 0 ? between(i, [0.35, 0.72]) : between(i, [1.32, 2.2], 3);    const [x, z] = ovalAt(around, loop);    placed.push(onGround(TREES[i % TREES.length]!, x, z, around * 3, between(i, TREE_SCALE, 5)));  }  for (let i = 0; i < GROVES; i++) {    const around = i * GOLDEN_ANGLE * 5 + 0.3;    const [middleX, middleZ] = ovalAt(around, between(i, [1.5, 3.2], 17));    for (let j = 0; j < GROVE_TREES; j++) {      const angle = j * GOLDEN_ANGLE;      const r = (GROVE_METRES / 2) * Math.sqrt((j + 0.5) / GROVE_TREES);      const x = middleX + Math.cos(angle) * r;      const z = middleZ + Math.sin(angle) * r;      const tree = TREES[(i + (j % 3 === 0 ? 1 : 0)) % 3]!;      placed.push(onGround(tree, x, z, angle * 3, between(i * GROVE_TREES + j, GROVE_SCALE)));    }  }  for (let i = 0; i < KNOLL_ROCKS; i++) {    const angle = i * GOLDEN_ANGLE;    const [x, z] = ovalAt(angle, 0.06 + 0.3 * Math.sqrt((i + 0.5) / KNOLL_ROCKS));    const model = i % 5 === 0 ? 'Rock_1_J' : i % 2 === 0 ? 'Rock_3_A' : 'Rock_3_F';    const scale = model === 'Rock_1_J' ? between(i, CRAG_SCALE) : between(i, BOULDER_SCALE);    placed.push(onGround(model, x, z, angle * 5, scale));  }  for (let i = 0; i < BUSHES; i++) {    const around = i * GOLDEN_ANGLE * 3;    const [x, z] = ovalAt(around, between(i, [0.2, 2.4], 11));    // Keep bushes off the herd track    if (Math.abs(loopAt(x, z) - 1) < 0.2) continue;    placed.push(onGround('Bush_2_A', x, z, around, between(i, BUSH_SCALE, 7)));  }  return { placed, obstacles };}/** Draws scenery instances from the shared pool, lit by the scene's lighting. */export function createScenery(  root: TgpuRoot,  pool: MeshPool,  models: Readonly<Record<SceneryName, meshPool.ModelData>>,  placed: readonly Placed[],) {  const loaded = new Map(SCENERY.map((name) => [name, pool.add(models[name])] as const));  const things = instances.create(root, pool, {    capacity: placed.reduce((sum, { model }) => sum + models[model].meshes.length, 0),  });  for (const { model, position, yaw, scale } of placed) {    const rotation = quat.fromAxisAngle(UP, yaw);    things.add(loaded.get(model)!, { position, rotation, scale, color: DRY_LEAVES });  }  return {    ready: Promise.all([...loaded.values()].map((model) => model.ready)),    renderer: things.renderer({ cull: true }),    flush: () => things.flush(),    destroy: () => things.destroy(),  };}

dust.ts

import {  cameras,  materials,  Particle,  particles,  shading,  type CameraBase,  type Lighting,} from '@typegpu/kit';import { srgbToLinear } from '@typegpu/color';import { randf } from '@typegpu/noise';import { d, std, tgpu, type TgpuComputePass, type TgpuRoot } from 'typegpu';import { horses, STEP_SECONDS, type Herd } from './herd.ts';const WORKGROUP = 64;/** Horse speeds where hoof dust begins and reaches maximum emission. */const KICK_FROM = 5;const KICK_FULL = 14;/** Puffs a galloping horse kicks up a second at full speed. */const PUFFS_PER_SECOND = 0.45;const LIFE_SECONDS = [2.5, 4.5] as const;const SIZE_METRES = 2.6;const GROWTH_METRES_PER_SECOND = 0.9;const OPACITY = 0.18;const LIFT_METRES_PER_SECOND = 0.7;const SCATTER_METRES_PER_SECOND = 1.2;const DUST = srgbToLinear(d.vec3f(0.78, 0.64, 0.46));/** Share of the sunlight a puff scatters, and how forward scattering brightens it toward the sun. */const SUN_SHARE = 0.28;const FORWARD_POWER = 5;const FORWARD_GLOW = 3.2;const CLAP_PUFFS = 160;const CLAP_SPEED_METRES_PER_SECOND = 7;const Settings = d.struct({ amount: d.f32 });/** Sunlight a puff scatters toward the eye, most when the eye looks toward the sun. */const scatter: shading.ReflectHook = (material, point, direction, radiance) => {  'use gpu';  const forward = std.pow(std.saturate(-std.dot(point.view, direction)), FORWARD_POWER);  return material.color * radiance * (SUN_SHARE * (0.35 + forward * FORWARD_GLOW));};/** A puff takes the light at its centre, so the herd's shadows would flicker across it whole. */const unshadowed: shading.SunShadowHook = () => {  'use gpu';  return d.f32(1);};interface DustOptions {  readonly camera: CameraBase;  readonly light: Lighting;  readonly step: number;  readonly herd: Herd;  readonly capacity: number;  /** Scales how much dust the herd kicks up and how thick it looks. */  readonly amount: number;}/** Draws drifting, spreading hoof dust lit by the scene's sun and sky. */export function createDust(root: TgpuRoot, options: DustOptions) {  const { camera: view, light, step, herd, capacity, amount } = options;  const settings = root.createUniform(Settings, { amount });  const shadePuff = (puff: Particle, local: d.v2f) => {    'use gpu';    const r2 = std.dot(local, local);    const body = std.saturate(1 - r2) * std.saturate(1 - r2);    const t = puff.age / puff.life;    const life = std.saturate(t * 6) * (1 - t);    const alpha = std.saturate(body * life * puff.color.w * settings.$.amount);    const facing = cameras.towards(cameras.current.$, puff.position);    const lit = shading.shade(materials.matte(DUST), shading.pointAt(puff.position, facing));    return d.vec4f(lit * alpha, alpha);  };  const dust = particles.create(root, view, {    label: 'dust',    capacity,    step,    blend: 'over',    settings: { gravity: [0, 0, 0], drag: 0.8 },    update: (puff: Particle, dt: number) => {      'use gpu';      const next = Particle(puff);      next.size = puff.size + GROWTH_METRES_PER_SECOND * dt;      return next;    },    shade: tgpu      .fn(shadePuff)      .pipe(light.inject())      .pipe((cfg) => cfg.with(shading.reflect, scatter).with(shading.sunShadow, unshadowed)),  });  const kick = tgpu.computeFn({    workgroupSize: [WORKGROUP],    in: { gid: d.builtin.globalInvocationId },  })(({ gid }) => {    'use gpu';    const i = gid.x;    if (i >= herd.controls.$.count) return;    const horse = horses.$[i]!;    const speed = std.length(horse.velocity);    const kicking = std.saturate((speed - KICK_FROM) / (KICK_FULL - KICK_FROM));    randf.seed3(d.vec3f(d.f32(i), herd.controls.$.time + 0.5, horse.phase));    if (randf.sample() >= kicking * PUFFS_PER_SECOND * STEP_SECONDS * settings.$.amount) return;    dust.emit(      Particle({        position: horse.position + d.vec3f(randf.sample() - 0.5, 0.3, randf.sample() - 0.5),        age: 0,        velocity:          d.vec3f(horse.velocity.x * 0.12, LIFT_METRES_PER_SECOND, horse.velocity.y * 0.12) +          randf.inUnitSphere() * SCATTER_METRES_PER_SECOND,        life: std.mix(LIFE_SECONDS[0], LIFE_SECONDS[1], randf.sample()),        color: d.vec4f(0, 0, 0, OPACITY),        size: SIZE_METRES * (0.7 + randf.sample() * 0.6),        seed: randf.sample(),      }),    );  });  const kicking = root    .pipe(herd.inject())    .pipe(dust.inject())    .createComputePipeline({ compute: kick });  return {    settings,    drawing: dust.drawing,    /** Emits a dust ring at a thunderclap. */    clap(at: readonly [number, number, number]) {      dust.burst({        count: CLAP_PUFFS,        position: [at[0], at[1] + 0.5, at[2]],        area: [3, 0.3, 3],        velocity: [0, 1.2, 0],        scatter: CLAP_SPEED_METRES_PER_SECOND,        color: [0, 0, 0, OPACITY * 2.2],        size: SIZE_METRES * 1.6,        life: LIFE_SECONDS[1],      });    },    /** Spawns queued thunderclap dust before simulation steps. */    spawn: dust.spawn,    /** Steps dust and emits hoof puffs. Record after the herd step. */    step(pass: TgpuComputePass) {      dust.step(pass);      pass.setPipeline(kicking);      pass.dispatchWorkgroups(Math.ceil(herd.count / WORKGROUP));    },    /** Lists live dust after frame steps. */    compact: dust.compact,    initAsync: () => Promise.all([dust.initAsync(), kicking.initAsync()]),    destroy: dust.destroy,  };}

assets.ts

import { font, glb } from '../assets.ts';import { SCENERY } from './scenery.ts';/** Quaternius horse and clips, KayKit scenery and the shipped Inter font. */export const assets = {  horse: glb('models/horse.glb'),  scenery: glb('models/scenery.glb', { parts: SCENERY }),  font: font('inter'),};