Skeletons

300 CC0 KayKit skeletons use four characters on one rig, in a walled courtyard at night. The glTF reader loads their skin and animation clips, the flinch from KayKit Character Animations. A state machine on the GPU makes them rise, wander, taunt, fight, fall and rise again, and its hooks start each clip. An attacker finds the nearest skeleton through a neighbour grid, turns on it and steps in, and the one its blow lands on flinches on an additive layer over whatever it is doing. One kernel computes every pose's joints, and vertex shaders skin the meshes as they draw. A low moon casts their long shadows across flagstones that a material hook paints, and twelve braziers along the wall light them, point lights from a function that flickers each flame. Particle streams send up tapered flames, embers, and smoke lit by the braziers alone.

Files, in reading order

  1. scene.tsCreates the scene: its resources, its settings and what each frame records.
  2. horde.tsAnimates four armed characters on one rig.
  3. fire.tsFlames and embers rising from each brazier rim in rims, and smoke above them that their light lights.
  4. sky.ts
  5. yard.tsA walled courtyard: flagstones, a wall round them with a gate on each side, and a brazier on a stone plinth either side of each gate, against the wall, whose flames light the yard.
  6. assets.ts

Kit modules bloom, cameras, clock, environment, finish, instances, lighting, machines, materials, meshPool, owner, particles, passes, pipelines, poses, shading, targets. Also @typegpu/color, @typegpu/noise, math/random, typegpu, wgpu-matrix.

scene.ts

import {  bloom,  cameras,  clock,  finish,  lighting,  meshPool,  owner,  passes,  pipelines,  targets,  type Frame,  type Surface,} from '@typegpu/kit';import type { TgpuRoot } from 'typegpu';import type { Model } from '../models.ts';import type { Settings } from '../settings.ts';import { createHorde, type ModelName } from './horde.ts';import { createFire } from './fire.ts';import { HORIZON, MOON, night } from './sky.ts';import { createYard, GATE_ANGLE } from './yard.ts';const SAMPLES = 4;const HDR = 'rgba16float';/** Yard radius per square root of skeleton count. Gives each skeleton about 3π m². */const SPREAD = 1.75;const GATES = 4;/** Seconds in a step of the skeletons' machine and of the fire. */const STEP = 1 / 60;export interface SceneOptions {  /** Each file as `gltf.read` returns it. */  readonly models: Readonly<Record<ModelName, Model>>;  /** Skeletons in the yard. Default 300. */  readonly count?: number;}/** * A walled yard of armed skeletons on one rig, whose actions a machine on the GPU starts, under a * low moon and between braziers. The view starts over the yard, looking * from one gate to the opposite one. */export async function createScene(root: TgpuRoot, surface: Surface, options: SceneOptions) {  const { models, count = 300 } = options;  const radius = Math.sqrt(count) * SPREAD;  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 },    }),  );  const near = radius * 0.3;  const view = cameras.orbit(root, {    size: surface,    target: [near * Math.cos(GATE_ANGLE), 1, near * Math.sin(GATE_ANGLE)],    distance: 25,    yaw: GATE_ANGLE,    pitch: 0.4,    pitchRange: [0.04, 1.4],    distanceRange: [4, radius * 3],  });  const pool = own(meshPool.create(root, { capacity: 64 }));  const sim = clock.fixedStep({ step: STEP });  const flames = clock.fixedStep({ step: STEP });  const yard = own(createYard(root, pool, { radius, gates: GATES }));  const light = own(    lighting.create(root, {      settings: {        sunDirection: MOON,        sunColor: [0.68, 0.78, 1],        sunIntensity: 1.7,        skyTop: [0.025, 0.04, 0.09],        skyBottom: [0.02, 0.016, 0.014],        fogColor: HORIZON,        fogDensity: 0.012,        fogFalloff: 0.1,      },      points: yard.points,      shadow: { extent: yard.reach * 2, size: 4096, filter: 9 },    }),  );  const scene = passes    .create(      root,      screen,      { scene: passes.color, depth: passes.depth },      { resolve: { scene: 'hdr' }, clear: [...HORIZON, 1] },    )    .pipe(view.inject())    .beneath(light.inject());  const skeletons = own(createHorde(root, pool, { models, count, radius, step: STEP }));  const fire = own(createFire(root, view, light, { rims: yard.rims, step: STEP }));  const renderer = skeletons.horde.renderer({ cull: true }).pipe(skeletons.poses.inject());  const stone = yard.stone.renderer();  const iron = yard.iron.renderer();  const drawn = [    scene.add(yard.ground.renderer()),    scene.add(stone),    scene.add(iron),    scene.add(renderer),    scene.add(night),    ...fire.drawings.map((drawing) => scene.add(drawing)),  ];  const casters = [light.shadow.add(renderer), light.shadow.add(stone), light.shadow.add(iron)];  const glow = own(    bloom.create(root, { source: () => screen.textures.hdr, settings: { threshold: 1.2 } }),  );  const final = own(    finish.create(root, {      format: surface.format,      source: () => screen.textures.hdr,      glow,      settings: { exposure: 1.3, glowIntensity: 0.12, vignette: 0.3 },    }),  );  await pipelines.compile([skeletons, ...fire.systems, scene, light.shadow, glow, final]);  const values = {    /** Horde time multiplier relative to page time. */    speed: 1,  };  const settings: Settings<typeof values> = {    values,    knobs: { speed: { min: 0, max: 2, step: 0.05 } },  };  return {    view,    settings,    frame(frame: Frame) {      view.update(frame.dt);      const steps = sim.advance(frame.dt * values.speed);      const burning = flames.advance(frame.dt);      yard.burn(frame.time);      const compute = frame.encoder.beginComputePass();      skeletons.animate(compute, sim.time + sim.ahead, steps);      renderer.cull(compute);      fire.record(compute, burning, flames.ahead);      compute.end();      screen.fit();      light.shadow.record(frame, casters);      scene.record(frame, drawn);      glow.record(frame);      final.record(frame, surface.view(frame));    },    destroy: own.destroy,  };}

horde.ts

import { instances, machines, poses, type MeshPool, type meshPool } from '@typegpu/kit';import * as grid from '@typegpu/kit/grid';import { random } from 'math/random';import { mat4 } from 'wgpu-matrix';import { d, std, tgpu, type TgpuComputePass, type TgpuRoot } from 'typegpu';import type { Model } from '../models.ts';export const MODELS = [  'warrior',  'minion',  'rogue',  'mage',  'blade',  'shield',  'axe',  'dagger',  'staff',  'skeleton-clips',  'melee-clips',  'hit-clips',] as const;export type ModelName = (typeof MODELS)[number];/** Animation clip names in the model files. */const CLIPS = {  awaken: 'Skeletons_Awaken_Floor',  walk: 'Skeletons_Walking',  idle: 'Skeletons_Idle',  taunt: 'Skeletons_Taunt',  death: 'Skeletons_Death',  resurrect: 'Skeletons_Death_Resurrect',  chop: 'Melee_1H_Attack_Chop',  stab: 'Melee_1H_Attack_Stab',  smash: 'Melee_2H_Attack_Chop',  hit: 'Hit_A',} as const;type Clip = keyof typeof CLIPS;type Attack = 'chop' | 'stab' | 'smash';/** Seconds into each attack where the blow lands. */const LANDS: Record<Attack, number> = { chop: 0.63, stab: 0.42, smash: 0.85 };const CHARACTERS = {  warrior: { right: 'blade', left: 'shield', attack: 'chop' },  minion: { right: 'axe', attack: 'chop' },  rogue: { right: 'dagger', attack: 'stab' },  mage: { right: 'staff', attack: 'smash' },} as const satisfies Record<string, { right: ModelName; left?: ModelName; attack: Attack }>;type Character = keyof typeof CHARACTERS;const CHARACTER_NAMES = Object.keys(CHARACTERS) as Character[];/** Walking speed in metres per second, matching planted-foot motion in the clip. */const PACE = 0.6;/** Minimum and maximum walking distance in metres. The yard edge can shorten a walk. */const WALK = [2, 8] as const;/** Minimum walking duration in seconds, including walks shortened by the yard edge. */const SHORTEST_WALK = 0.5;/** Seconds a skeleton takes to turn to where it walks. */const TURNING = 0.4;/** Seconds a skeleton takes to blend from one clip into the next. */const FADE = 0.3;/** Seconds a flinch takes to blend in over the one before it. */const FLINCH_FADE = 0.1;/** Minimum and maximum delay before rising from the ground, in seconds. */const WAKING = [0.5, 8] as const;/** Minimum and maximum idle duration in seconds. */const IDLING = [2, 6] as const;/** Minimum and maximum delay before rising after a fall, in seconds. */const LYING = [2, 5] as const;/** Probability of walking after a stationary clip. Otherwise idles. */const WANDERS = 0.7;/** Action probabilities after walking. Unassigned probability means idle. */const ARRIVED = { death: 0.05, taunt: 0.15, attack: 0.3 };/** Metres within which an attacker turns on the nearest skeleton and steps in. */const SIGHT = 2.4;/** Metres between an attacker and the skeleton it steps in to. */const CLOSE = 1.5;/** Metres a blow reaches, in front of the attacker. */const BLOW = 2;/** Pose-bound centre height above the feet in metres, for standing and fallen figures. */const MIDDLE = 1.2;/** Pose-bound radius in metres. */const REACH = 2.8;const WORKGROUP = 64;const NONE = 0xffffffff;/** * A skeleton between the actions its machine starts. It moves from `start` to `goal` between * `departs` and `arrives`, turning about +y from `turn.x` to `turn.y` radians, and its action * lasts `lasts` seconds. `attack` is its attack clip, which lasts `swing` seconds and whose blow * lands `lands` seconds in. */const Member = d.struct({  start: d.vec2f,  goal: d.vec2f,  turn: d.vec2f,  departs: d.f32,  arrives: d.f32,  lasts: d.f32,  pose: d.u32,  attack: d.u32,  lands: d.f32,  swing: d.f32,});/** One row per mesh, the body and each thing it holds, placed by the march from its member. */export const Skeleton = instances.define('Skeleton', {  transform: 'device',  host: { pose: d.u32, member: d.u32 },  pose: (skeleton) => {    'use gpu';    return skeleton.pose;  },  bounds: (skeleton) => {    'use gpu';    return instances.Sphere({ center: skeleton.position + d.vec3f(0, MIDDLE, 0), radius: REACH });  },});/** What a walk through the grid reads of each skeleton. */const Neighbour = d.struct({ at: d.vec2f, slot: d.u32 });/** Wraps `angle` in radians to -π through π. */const wrapped = (angle: number) => {  'use gpu';  return angle - Math.PI * 2 * std.floor(angle / (Math.PI * 2) + 0.5);};type Models = Readonly<Record<ModelName, Model>>;/** Each clip `CLIPS` names, from the clip files. */function clipsOf(models: Models) {  const files = ['skeleton-clips', 'melee-clips', 'hit-clips'] as const;  const clips = files.flatMap((file) => models[file].clips);  const pairs = Object.entries(CLIPS).map(([key, name]) => [key, poses.clip({ clips }, name)]);  return Object.fromEntries(pairs) as Record<Clip, poses.ClipData>;}/** Adds each character's meshes to `pool`, rebound to `skin`, with what it holds. */function arm(pool: MeshPool, models: Models, skin: poses.SkinData) {  // KayKit weapons face backwards in the hand slot, so they turn half a turn about their grip  const grip = mat4.rotationY(Math.PI);  const held = (name: ModelName, joint: string, placed?: { offset: Float32Array }) =>    pool.add(poses.attach(models[name], skin, joint, placed)).meshes;  return Object.fromEntries(    CHARACTER_NAMES.map((name) => {      const { right, left }: { right: ModelName; left?: ModelName } = CHARACTERS[name];      const body = pool.add(poses.rebind(models[name], models[name].skin!, skin));      const meshes = [...body.meshes, ...held(right, 'handslot.r', { offset: grip })];      if (left) meshes.push(...held(left, 'handslot.l'));      return [name, { meshes }];    }),  ) as Record<Character, { meshes: meshPool.Mesh[] }>;}export interface HordeOptions {  readonly models: Models;  readonly count: number;  /** Metres from the middle of the yard to its edge. */  readonly radius: number;  /** Seconds in a step of the skeletons' machine. */  readonly step: number;  /** Draws where the skeletons lie and when they wake. Default `Math.random`. */  readonly random?: () => number;}/** * Animates four armed characters on one rig. They rise, wander, idle, taunt, fight and fall. A * machine on the GPU starts each action and its clip, turns attackers on their nearest neighbour * and makes the one a blow lands on flinch on an additive layer. A kernel moves each skeleton. */export function createHorde(root: TgpuRoot, pool: MeshPool, options: HordeOptions) {  const { models, count, radius, step, random: dice = Math.random } = options;  const skin = models.warrior.skin!;  const clips = clipsOf(models);  const figures = poses.create(root, skin, clips, {    capacity: count,    layers: { flinch: { joints: ['spine'], add: true } },  });  const { base, flinch } = figures.layers;  const { idle, walk, taunt, death, resurrect, hit } = figures.clips;  const armed = arm(pool, models, skin);  const rows = Math.max(...CHARACTER_NAMES.map((name) => armed[name].meshes.length));  const horde = instances.create(root, pool, { kind: Skeleton, capacity: count * rows });  const now = root.createUniform(d.f32);  const initial: d.InferInput<typeof Member>[] = [];  for (let i = 0; i < count; i++) {    const character = CHARACTER_NAMES[i % CHARACTER_NAMES.length]!;    const distance = Math.sqrt(dice()) * radius;    const angle = random.float(dice, 0, Math.PI * 2);    const at = d.vec2f(Math.cos(angle) * distance, Math.sin(angle) * distance);    const yaw = random.float(dice, -Math.PI, Math.PI);    const wakes = random.float(dice, ...WAKING);    const pose = figures.add('awaken', wakes, { loop: false });    const { attack } = CHARACTERS[character];    horde.add(armed[character], { pose, member: i });    initial.push({      start: at,      goal: at,      turn: d.vec2f(yaw, yaw),      departs: 0,      arrives: 1,      lasts: wakes + clips.awaken.duration,      pose,      attack: figures.clips[attack],      lands: LANDS[attack],      swing: clips[attack].duration,    });  }  const members = root.createBuffer(d.arrayOf(Member, count), initial).$usage('storage');  const member = members.as('mutable');  /** Where skeleton `k` stands now. */  const placeOf = (k: number) => {    'use gpu';    const one = member.$[k]!;    const along = std.saturate((now.$ - one.departs) / std.max(one.arrives - one.departs, 1e-3));    return std.mix(one.start, one.goal, along);  };  // The grid sorts the skeletons once a frame, before the steps whose hooks walk it  const cells = grid.create(root, {    capacity: count,    low: d.vec3f(-radius - SIGHT, -SIGHT / 2, -radius - SIGHT),    size: d.vec3f((radius + SIGHT) * 2, SIGHT, (radius + SIGHT) * 2),    sight: SIGHT,    item: Neighbour,    position: (k) => {      'use gpu';      const at = placeOf(k);      return d.vec3f(at.x, 0, at.y);    },    itemOf: (k) => {      'use gpu';      return Neighbour({ at: placeOf(k), slot: k });    },  });  /** A number from `low` to `high` drawn for skeleton `i`. */  const roughly = tgpu.fn(    [d.u32, d.f32, d.f32],    d.f32,  )((i, low, high) => {    'use gpu';    return low + Doing.roll(i) * (high - low);  });  /**   * The nearest skeleton within `reach` of skeleton `i` as the grid sorted it, with slot `NONE`   * for none. A blow finds one on its feet in the half ahead; otherwise it is one standing still,   * which an attacker can step in to.   */  const nearest = (i: number, reach: number, blow: boolean) => {    'use gpu';    const here = member.$[i]!.goal;    const yaw = member.$[i]!.turn.y;    const facing = d.vec2f(std.sin(yaw), std.cos(yaw));    let found = Neighbour({ at: d.vec2f(), slot: NONE });    let best = d.f32(reach);    const near = cells.around(d.vec3f(here.x, 0, here.y));    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 away = other.at - here;        const gap = std.length(away);        // The states on their feet run from walking to striking        const state = Doing.state(other.slot);        const afoot = state >= Doing.walking && state <= Doing.striking;        const fits = blow ? std.dot(away, facing) > 0 : state !== Doing.walking;        if (other.slot !== i && gap < best && afoot && fits) {          best = gap;          found = Neighbour(other);        }      }    }    return found;  };  /**   * Moves skeleton `i` from where it stands to `to` over `seconds`, turning to `yaw` by the   * shorter arc, and makes its action last as long.   */  const go = (i: number, seconds: number, to: d.v2f, yaw: number) => {    'use gpu';    const one = member.$[i]!;    const facing = wrapped(one.turn.y);    one.start = d.vec2f(one.goal);    one.goal = d.vec2f(to);    one.turn = d.vec2f(facing, facing + wrapped(yaw - facing));    one.departs = now.$;    one.arrives = now.$ + seconds;    one.lasts = seconds;  };  /** Keeps skeleton `i` where it stands, facing its way, for `seconds`. */  const stand = (i: number, seconds: number) => {    'use gpu';    const one = member.$[i]!;    go(i, seconds, one.goal, one.turn.y);  };  /** Plays `clip` once in place, for as long as it lasts. */  const act = tgpu.fn([d.u32, d.u32, d.f32])((i, clip, seconds) => {    'use gpu';    poses.once(member.$[i]!.pose, clip, base, FADE);    stand(i, seconds);  });  /** Chooses a nearby walking target inside the yard. */  const wander = (i: number) => {    'use gpu';    const here = d.vec2f(member.$[i]!.goal);    const angle = roughly(i, 0, Math.PI * 2);    let to = here + d.vec2f(std.cos(angle), std.sin(angle)) * roughly(i, WALK[0], WALK[1]);    const out = std.length(to) / radius;    if (out > 1) to = to / out;    const seconds = std.max(std.distance(to, here) / PACE, SHORTEST_WALK);    poses.play(member.$[i]!.pose, walk, base, FADE, 0);    // glTF models face +z    go(i, seconds, to, std.atan2(to.x - here.x, to.y - here.y));  };  /** Swings at the nearest skeleton in sight, stepping in to it, or at the air. */  const windUp = (i: number) => {    'use gpu';    const one = member.$[i]!;    poses.once(one.pose, one.attack, base, FADE);    const foe = nearest(i, SIGHT, false);    if (foe.slot === NONE) {      stand(i, one.lands);      return;    }    const here = d.vec2f(one.goal);    const away = foe.at - here;    const gap = std.length(away);    go(i, one.lands, here + away * (std.max(gap - CLOSE, 0) / gap), std.atan2(away.x, away.y));  };  /** Lands the blow: the nearest skeleton within reach ahead flinches over what it is doing. */  const strike = (i: number) => {    'use gpu';    const one = member.$[i]!;    one.lasts = one.swing - one.lands;    const struck = nearest(i, BLOW, true).slot;    if (struck !== NONE) poses.once(member.$[struck]!.pose, hit, flinch, FLINCH_FADE);  };  const lasted = (i: number): number => {    'use gpu';    return member.$[i]!.lasts;  };  const stood = [    { to: 'walking', after: lasted, chance: WANDERS },    { to: 'idling', after: lasted },  ] as const;  const Doing = machines.define('Doing', {    states: {      waking: { next: stood },      walking: {        enter: wander,        next: [          { to: 'falling', after: lasted, chance: ARRIVED.death },          { to: 'taunting', after: lasted, chance: ARRIVED.taunt },          { to: 'attacking', after: lasted, chance: ARRIVED.attack },          { to: 'idling', after: lasted },        ],      },      idling: {        enter: (i) => {          'use gpu';          poses.play(member.$[i]!.pose, idle, base, FADE, 0);          stand(i, roughly(i, IDLING[0], IDLING[1]));        },        next: stood,      },      taunting: {        enter: (i) => {          'use gpu';          act(i, taunt, clips.taunt.duration);        },        next: stood,      },      attacking: { enter: windUp, next: [{ to: 'striking', after: lasted }] },      striking: { enter: strike, next: stood },      falling: {        enter: (i) => {          'use gpu';          act(i, death, clips.death.duration);        },        next: [{ to: 'lying', after: lasted }],      },      // Keep the fallen pose until the rise clip starts      lying: {        enter: (i) => {          'use gpu';          member.$[i]!.lasts = roughly(i, LYING[0], LYING[1]);        },        next: [{ to: 'rising', after: lasted }],      },      rising: {        enter: (i) => {          'use gpu';          act(i, resurrect, clips.resurrect.duration);        },        next: stood,      },    },  });  const doing = machines    .create(root, Doing, { capacity: count, step })    .pipe(cells.inject())    .pipe(figures.inject());  const march = tgpu.computeFn({    workgroupSize: [WORKGROUP],    in: { gid: d.builtin.globalInvocationId },  })(({ gid }) => {    'use gpu';    const i = gid.x;    if (!Skeleton.alive(i)) return;    const k = Skeleton.member(i);    const one = member.$[k]!;    const yaw = std.mix(one.turn.x, one.turn.y, std.smoothstep(0, TURNING, now.$ - one.departs));    const at = placeOf(k);    const skeleton = Skeleton.device.$[i]!;    skeleton.position = d.vec3f(at.x, 0, at.y);    skeleton.rotation = d.vec4f(0, std.sin(yaw / 2), 0, std.cos(yaw / 2));  });  const marching = root.pipe(horde.inject()).createComputePipeline({ compute: march });  return {    horde,    poses: figures,    /** What each skeleton is doing, a machine row each. */    doing,    initAsync: () =>      Promise.all([        figures.initAsync(),        cells.initAsync(),        doing.initAsync(),        marching.initAsync(),      ]),    /**     * Takes `steps` fixed steps of the machine, then figures and moves the skeletons at `time`. The     * steps start clips at `time` too.     */    animate(pass: TgpuComputePass, time: number, steps: number) {      horde.flush();      now.write(time);      if (steps > 0) cells.sort(pass);      for (let s = 0; s < steps; s++) doing.step(pass);      figures.animate(pass, time);      pass.setPipeline(marching);      pass.dispatchWorkgroups(Math.ceil(horde.span / WORKGROUP));    },    destroy() {      horde.destroy();      figures.destroy();      cells.destroy();      doing.destroy();      members.destroy();    },  };}

fire.ts

import { cameras, Particle, particles, shading, type Lighting } from '@typegpu/kit';import { d, std, tgpu, type TgpuComputePass, type TgpuRoot } from 'typegpu';type Vec3 = readonly [number, number, number];/** Flames, embers and smoke puffs a second from each brazier. */const RATE = { flames: 60, embers: 5, smoke: 5 };/** Where the flames are hottest, white-yellow, then orange, then a dull red as they fade. */const CORE = d.vec3f(7, 4.4, 1.6);const BODY = d.vec3f(3.2, 0.9, 0.15);const DYING = d.vec3f(0.5, 0.06, 0.01);const EMBER = [7, 2.6, 0.6, 1] as const;/** Metres across a flame and an ember. Anything smaller than `EMBER_BELOW` is an ember. */const FLAME_SIZE = 0.3;const EMBER_SIZE = 0.03;const EMBER_BELOW = 0.06;/** A flame's height over its width. */const TALL = 2;/** The share of the light reaching it that a puff of smoke scatters, and its opacity. */const SOOT = 0.0015;const PUFF = 0.22;/** Metres a second a puff widens by, and the breeze that leans the smoke along x. */const SPREAD = 0.3;const BREEZE = 0.12;/** Flames draw in towards the middle as they climb; embers flutter. */const rise = (flame: Particle) => {  'use gpu';  let next = Particle(flame);  const flutter = flame.age * 9 + flame.seed * 40;  if (flame.size < EMBER_BELOW) {    next.velocity += d.vec3f(std.sin(flutter) * 0.06, 0.02, std.cos(flutter) * 0.06);  } else {    next.velocity = d.vec3f(next.velocity.x * 0.9, next.velocity.y + 0.035, next.velocity.z * 0.9);  }  return next;};const burn = (flame: Particle, local: d.v2f) => {  'use gpu';  if (flame.size < EMBER_BELOW) return particles.glowingDisc(flame, local);  const age = flame.age / flame.life;  // A tongue: round at its foot, narrowing to a tip  const up = local.y * 0.5 + 0.5;  const across = local.x / std.max(1 - up * up * 0.85, 0.05);  const edge = std.saturate(1 - std.length(d.vec2f(across, local.y)));  const heat = std.mix(CORE, BODY, std.smoothstep(0, 0.45, age));  const color = std.mix(heat, DYING, std.smoothstep(0.45, 1, age));  const fade = edge * edge * std.smoothstep(0, 0.1, age) * (1 - age);  return d.vec4f(color * (fade * 0.5), 0);};/** Flames stand upright, twice as tall as wide, and shrink as they rise; embers stay round. */const shrink = (flame: Particle, corner: d.v2f) => {  'use gpu';  const tall = std.select(d.f32(1), TALL, flame.size >= EMBER_BELOW);  const across = cameras.current.$.right * corner.x + d.vec3f(0, corner.y * tall, 0);  const size = flame.size * (1 - (flame.age / flame.life) * 0.6);  return flame.position + across * (size * 0.5);};/** The moon lights the smoke as little as it lights the dark under the braziers. */const sunless: shading.SunShadowHook = () => {  'use gpu';  return d.f32(0);};/** * Flames and embers rising from each brazier rim in `rims`, and smoke above them that their light * lights. */export function createFire(  root: TgpuRoot,  view: cameras.CameraBase,  light: Lighting,  options: { readonly rims: readonly Vec3[]; readonly step: number },) {  const { rims, step } = options;  const fire = particles.create(root, view, {    capacity: 768,    step,    update: rise,    place: shrink,    shade: burn,    label: 'fire',    settings: { gravity: [0, 0, 0], drag: 0.6 },  });  const smoke = particles.create(root, view, {    capacity: 384,    step,    blend: 'over',    label: 'smoke',    update: tgpu      .fn((puff: Particle, dt: number) => {        'use gpu';        let next = Particle(puff);        next.size = puff.size + SPREAD * dt;        next.velocity = d.vec3f(BREEZE, puff.velocity.y, puff.velocity.z);        next.color = d.vec4f(shading.incoming(puff.position) * SOOT, puff.color.w);        return next;      })      .pipe(light.inject())      .with(shading.sunShadow, sunless),    settings: { gravity: [0, 0, 0], drag: 0.15 },  });  for (const [x, y, z] of rims) {    const flames = {      position: [x, y + 0.02, z],      area: [0.25, 0.04, 0.25],      velocity: [0, 0.8, 0],      scatter: 0.14,      size: FLAME_SIZE,      life: 0.65,      variance: 0.4,    } as const;    fire.stream(RATE.flames, flames);    fire.stream(RATE.embers, {      position: [x, y + 0.2, z],      area: [0.2, 0.05, 0.2],      velocity: [0, 1.7, 0],      scatter: 0.5,      color: EMBER,      size: EMBER_SIZE,      life: 3,      variance: 0.5,    });    const puff = {      position: [x, y + 1.5, z],      area: [0.15, 0.1, 0.15],      velocity: [0, 0.6, 0],      scatter: 0.08,      color: [0, 0, 0, PUFF],      size: 0.5,      life: 6,      variance: 0.3,    } as const;    // The first frame finds the braziers alight, a puff of smoke over each    fire.burst({ ...flames, count: RATE.flames * flames.life, aged: true });    smoke.burst({ ...puff, count: 1 });    smoke.stream(RATE.smoke, puff);  }  return {    systems: [fire, smoke],    /** Smoke first, so the flames add their light over it. */    drawings: [smoke.drawing, fire.drawing],    /** Spawns what the streams owe, takes `steps` steps and lists the particles to draw. */    record(pass: TgpuComputePass, steps: number, ahead: number) {      fire.spawn(pass);      smoke.spawn(pass);      for (let s = 0; s < steps; s++) {        fire.step(pass);        smoke.step(pass);      }      fire.compact(pass, ahead);      smoke.compact(pass, ahead);    },    destroy() {      fire.destroy();      smoke.destroy();    },  };}

sky.ts

import { environment } from '@typegpu/kit';import { d, std } from 'typegpu';/** The sky at the horizon, which the fog matches so the far yard fades into it. */export const HORIZON = [0.03, 0.045, 0.085] as const;/** Towards a low moon, so the skeletons cast long shadows across the flagstones. */export const MOON = [-0.76, 0.4, 0.51] as const;const LOW = d.vec3f(...HORIZON);const ZENITH = d.vec3f(0.004, 0.007, 0.022);const TOWARD_MOON = std.normalize(d.vec3f(...MOON));const MOONLIGHT = d.vec3f(0.75, 0.85, 1);const starAt = environment.stars({ cells: 110, chance: 0.16, size: 0.7 });const STAR_WARM = d.vec3f(1, 0.85, 0.7);const STAR_COOL = d.vec3f(0.7, 0.8, 1);/** A deep blue night, stars thinning towards the horizon, and the moon in its halo. */const nightAt = (direction: d.v3f) => {  'use gpu';  const up = direction.y;  const above = std.mix(LOW, ZENITH, std.pow(std.saturate(up), 0.5));  const star = starAt(direction);  const tint = std.mix(STAR_WARM, STAR_COOL, star.seed.y);  const stars = tint * (std.pow(star.seed.x, 8) * 0.8 * star.disc * std.smoothstep(0, 0.2, up));  const toward = std.dot(direction, TOWARD_MOON);  const disc = std.smoothstep(0.99975, 0.99985, toward) * 6;  const halo = std.exp((toward - 1) * 60) * 0.12 + std.exp((toward - 1) * 8) * 0.03;  return std.select(LOW, above + stars + MOONLIGHT * (disc + halo), up > 0);};export const night = environment.background(nightAt, { label: 'night sky' });

yard.ts

import { srgbToLinear } from '@typegpu/color';import { instances, lighting, materials, shading, type MeshPool } from '@typegpu/kit';import { meshes } from '@typegpu/kit/geometry';import { perlin2d } from '@typegpu/noise';import { mulberry32 } from 'math/random';import { d, std, tgpu, type TgpuRoot } from 'typegpu';type Vec3 = [number, number, number];const paint = (r: number, g: number, b: number) => srgbToLinear(d.vec3f(r, g, b));const WARM = paint(0.4, 0.37, 0.33);const COOL = paint(0.33, 0.34, 0.35);const JOINT = paint(0.14, 0.13, 0.12);const GROUND = paint(0.12, 0.115, 0.11);const IRON = materials.metal(paint(0.2, 0.19, 0.18), 0.55);const COALS = materials.glow(d.vec3f(1, 0.22, 0.03), 3);const DRESSED = materials.matte(paint(0.4, 0.38, 0.35));/** Flagstone width and depth in metres; each row runs along x, half a stone on from the last. */const FLAG = d.vec2f(1.6, 1.1);/** Half the width of a joint between flagstones, in metres. */const JOINT_HALF = 0.02;/** Height of a course of the wall's stones and length of a stone, in metres. */const COURSE = 0.6;const BLOCK = 1.3;/** The first gate's angle about +y from +x, and each gate's width in metres. */export const GATE_ANGLE = Math.PI / 4;const GATE_WIDTH = 6;/** Metres beyond the skeletons' edge to the wall, and in from the wall to the braziers. */const WALL_OUT = 3.5;const BRAZIERS_IN = 2.2;/** Height of the wall in metres, how much a stretch of it rises or sinks, and its thickness. */const WALL = 3.4;const WALL_VARY = 0.2;const WALL_THICK = 1;/** Mean length of a stretch of wall in metres. */const STRETCH = 4;/** Height of a brazier's rim over the ground, and of its flame's light over the rim, in metres. */export const RIM = 1.25;const FLAME_ABOVE_RIM = 0.35;const FLAME = d.vec3f(1, 0.5, 0.2);/** A brazier's light: a lantern's intensity, reaching a pool of the yard round it. */const BRAZIER = { intensity: 100, range: 22 };/** Fades a pattern of `scale` cells per metre where a cell spans less than about two pixels. */const resolved = (at: d.v2f, scale: number) => {  'use gpu';  return std.saturate(1.5 - std.length(std.fwidth(at)) * scale * 4);};/** * How much of a fine joint `distance` metres from the point covers its pixel: full where the * joint is wider than the pixel, thinner and fainter where it is not. */const jointAt = (distance: number, footprint: number) => {  'use gpu';  const width = std.max(JOINT_HALF, footprint * 0.5);  return std.smoothstep(width * 1.5, width * 0.5, distance) * std.saturate(JOINT_HALF / width);};/** * Flagstones in running bond, each a little warmer or cooler, lighter or darker than the next, * with fine joints that hold less of the sky's light, out to `wall` metres from the middle, beyond * which lies bare ground. */const yardOf = (wall: number) =>  instances.define('Yard', {    material: (point) => {      'use gpu';      const at = point.position.xz;      const row = std.floor(at.y / FLAG.y);      const bond = d.vec2f(at.x / FLAG.x + row * 0.5, at.y / FLAG.y);      const cell = std.floor(bond);      const inside = bond - cell;      const edge = std.min(        std.min(inside.x, 1 - inside.x) * FLAG.x,        std.min(inside.y, 1 - inside.y) * FLAG.y,      );      // Per stone a tint and a shade, and over the yard a slow drift between warmer and cooler stone      const tint = perlin2d.sample(cell * 0.61 + 0.37) * 0.5 + 0.5;      const shade = 1 + perlin2d.sample(cell * 0.83 + 5.3) * 0.2;      const drift = std.saturate(perlin2d.sample(at * 0.035 + 19) + 0.5);      let stone = std.mix(COOL, WARM, std.mix(tint, drift, 0.5)) * shade;      const joint = jointAt(edge, std.length(std.fwidth(at))) * resolved(at, 1 / FLAG.y);      // Worn arrises: each stone darkens a little towards its joints      const worn = std.smoothstep(0.1, 0, edge) * 0.15 * resolved(at, 4 / FLAG.y);      stone = std.mix(stone * (1 - worn), JOINT, joint * 0.8);      stone = std.mix(stone, GROUND, std.smoothstep(wall, wall + 1.5, std.length(at)));      return shading.Material({        color: stone,        roughness: std.mix(0.68 + tint * 0.14, 0.95, joint),        emission: d.vec3f(),        metallic: 0,        occlusion: 1 - joint * 0.6 - worn,      });    },  });/** Dressed stone in courses round the yard, each course's joints half a stone on from the last. */const Masonry = instances.define('Masonry', {  material: (point, _instance, base) => {    'use gpu';    const p = point.position;    const along = std.atan2(p.z, p.x) * std.length(p.xz);    const course = std.floor(p.y / COURSE);    const laid = along / BLOCK + course * 0.5;    const block = std.floor(laid);    const height = p.y / COURSE - course;    const across = laid - block;    const footprint = std.length(std.fwidth(d.vec2f(along, p.y)));    // Tops show no courses    const side = std.select(d.f32(1), 0, std.abs(point.normal.y) > 0.5);    const edge = std.min(std.min(height, 1 - height) * COURSE, std.min(across, 1 - across) * BLOCK);    const joint = jointAt(edge, footprint) * side * std.saturate(1.5 - footprint * (4 / COURSE));    const shade = 1 + perlin2d.sample(d.vec2f(block, course) * 0.83 + 0.21) * 0.16;    const color = std.mix(base.color.xyz * shade, JOINT, joint * 0.75);    return shading.Material({      color,      roughness: 0.85,      emission: d.vec3f(),      metallic: 0,      occlusion: 1 - joint * 0.5,    });  },});/** How brightly brazier `k` burns at `seconds`, around 0.85 of full. */const flicker = (seconds: number, k: number) => {  'use gpu';  return (    0.85 +    0.08 * std.sin(seconds * 13 + k * 1.7) +    0.05 * std.sin(seconds * 29 + k * 2.3) +    0.04 * std.sin(seconds * 7.3 + k)  );};/** Turns about +y so a box's x axis points out from the middle at `angle`. */const facingOut = (angle: number) => [0, Math.sin(-angle / 2), 0, Math.cos(-angle / 2)] as const;export interface YardOptions {  /** Metres from the middle to the edge the skeletons keep within. */  readonly radius: number;  /** Gates in the wall, each with a brazier either side and one halfway to the next. */  readonly gates: number;}/** * A walled courtyard: flagstones, a wall round them with a gate on each side, and a brazier on a * stone plinth either side of each gate, against the wall, whose flames light the yard. */export function createYard(root: TgpuRoot, pool: MeshPool, options: YardOptions) {  const { radius, gates } = options;  const gateAt = (gate: number) => (gate / gates) * Math.PI * 2 + GATE_ANGLE;  const wallAt = radius + WALL_OUT;  const ground = instances.create(root, pool, { kind: yardOf(wallAt + 0.5), capacity: 1 });  ground.add(pool.add(meshes.plane({ width: radius * 16, depth: radius * 16 })), {});  const block = pool.add(meshes.box({ width: 1, height: 1, depth: 1 }));  const stem = pool.add(meshes.cylinder({ radius: 0.07, height: 1, radialSegments: 12 }));  const bowl = pool.add(meshes.cylinder({ radius: 0.5, height: 0.32 }));  const lip = pool.add(meshes.torus({ radius: 0.5, tube: 0.045 }));  const embers = pool.add(meshes.sphere({ radius: 1 }));  // A wall of stretches round the yard, each a little higher or lower, open at the gates  const stretches = Math.round((Math.PI * 2 * wallAt) / STRETCH);  const stretch = (Math.PI * 2 * wallAt) / stretches;  const gateHalf = GATE_WIDTH / 2 / wallAt;  const between = (Math.PI * 2) / gates;  const state = mulberry32.create(7);  const roll = () => mulberry32.sample(state);  const stone = instances.create(root, pool, { kind: Masonry, capacity: stretches + gates * 3 });  for (let i = 0; i < stretches; i++) {    const angle = ((i + 0.5) / stretches) * Math.PI * 2;    const past = (((angle - GATE_ANGLE) % between) + between) % between;    if (Math.min(past, between - past) < gateHalf) continue;    const height = WALL + (roll() - 0.5) * 2 * WALL_VARY;    stone.add(block, {      position: [Math.cos(angle) * wallAt, height / 2, Math.sin(angle) * wallAt],      rotation: facingOut(angle),      scale: [WALL_THICK, height, stretch + 0.08],      ...DRESSED,    });  }  const brazierAt = wallAt - BRAZIERS_IN;  const flank = (GATE_WIDTH / 2 + 1) / brazierAt;  /** Each brazier's angle: either side of each gate, then one halfway between gates. */  const angles = Array.from({ length: gates * 3 }, (_, k) =>    k < gates * 2      ? gateAt(Math.floor(k / 2)) + (k % 2 ? flank : -flank)      : gateAt(k - gates * 2) + between / 2,  );  /** Where each brazier's rim is. */  const rims: Vec3[] = angles.map((angle) => [    Math.cos(angle) * brazierAt,    RIM,    Math.sin(angle) * brazierAt,  ]);  const flames = tgpu.const(    d.arrayOf(d.vec3f, rims.length),    rims.map(([x, y, z]) => d.vec3f(x, y + FLAME_ABOVE_RIM, z)),  );  const iron = instances.create(root, pool, { capacity: rims.length * 4 });  for (const [k, [x, , z]] of rims.entries()) {    const angle = angles[k]!;    stone.add(block, {      position: [x, 0.3, z],      rotation: facingOut(angle),      scale: [0.75, 0.6, 0.75],      ...DRESSED,    });    iron.add(stem, { position: [x, 0.85, z], scale: [1, 0.5, 1], ...IRON });    iron.add(bowl, { position: [x, RIM - 0.16, z], ...IRON });    iron.add(lip, { position: [x, RIM, z], ...IRON });    iron.add(embers, { position: [x, RIM - 0.06, z], scale: [0.42, 0.08, 0.42], ...COALS });  }  const time = root.createUniform(d.f32);  /** Brazier `slot`'s flame as a point light, flickering on its own. */  const flameLight = (slot: number) => {    'use gpu';    return lighting.Light({      position: d.vec3f(flames.$[slot]!),      color: FLAME,      intensity: BRAZIER.intensity * flicker(time.$, d.f32(slot)),      range: BRAZIER.range,    });  };  ground.flush();  stone.flush();  iron.flush();  return {    ground,    stone,    iron,    rims: rims as readonly Readonly<Vec3>[],    /** Metres from the middle to just beyond the wall, which a shadow covering the yard reaches. */    reach: wallAt + 2,    /** The braziers' flames, for the lighting's `points`. */    points: { light: flameLight, count: rims.length },    /** Sets the flames' flicker to `seconds` of page time. */    burn(seconds: number) {      time.write(seconds);    },    destroy() {      ground.destroy();      stone.destroy();      iron.destroy();    },  };}

assets.ts

import { each, glb } from '../assets.ts';import { MODELS } from './horde.ts';/** The skeletons, what they hold and their clips. */export const assets = { models: each(MODELS, (name) => glb(`models/${name}.glb`)) };