Fish tank

3 340 fish swim as GPU boids. A device table holds them, a neighbour grid sorts them by cell, and live lists group them by species. A coroutine reads back eaten food. Caustics, bubbles, crumbs and bloom complete the tank.

Files, in reading order

  1. scene.tsCreates the scene: its resources, its settings and what each frame records.
  2. school.tsSteers fish of three species as boids in a neighbour grid, and draws them as instances.
  3. food.tsStores sinking pellets in a table and reads back bite counters to remove eaten pellets.
  4. water.tsWater shading for lighting.create.
  5. floor.tsDraws the sand as one plane instance.
  6. bubbles.tsBubbles that puff from vents in the sand, wobble and grow as they rise, and burst at the top.
  7. ui.ts

Kit modules bloom, cameras, clock, finish, instances, lighting, liveLists, materials, meshPool, owner, particles, passes, pipelines, readback, shadows, table, targets. Also @typegpu/color, @typegpu/noise, math/random, typegpu.

scene.ts

import {  bloom,  cameras,  clock,  finish,  lighting,  owner,  passes,  pipelines,  targets,  type Frame,  type Surface,} from '@typegpu/kit';import { random } from 'math/random';import { d, type TgpuRoot } from 'typegpu';import type { Settings } from '../settings.ts';import { createSchool } from './school.ts';import { createFood } from './food.ts';import { createWater, DEEP, TANK } from './water.ts';import { createFloor } from './floor.ts';import { createBubbles } from './bubbles.ts';const SAMPLES = 4;const HDR = 'rgba16float';const CLEAR = [DEEP.x, DEEP.y, DEEP.z, 1] as const;/** Tank centre, camera target and shadow centre. */const MIDDLE = [0, TANK.y / 2, 0] as const;/** Pellets per feed, and x and z spread from the tank centre in world units. */const FEED = { pellets: 12, x: 5, z: 3 };/** Pipeline warm-up options, including loading-cover progress. */export type SceneOptions = pipelines.CompileOptions;/** * Builds a fish tank with boids, food, sand, bubbles, bloom and a final pass. Frames compile * pipelines before drawing. `ready` settles when compilation finishes. */export function createScene(root: TgpuRoot, surface: Surface, options: SceneOptions = {}) {  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 view = cameras.orbit(root, {    size: surface,    target: MIDDLE,    distance: 17,    pitch: 0.12,    pitchRange: [-0.3, 1.2],    distanceRange: [4, 30],  });  const sim = clock.fixedStep({ step: 1 / 60, maxSteps: 3 });  const time = root.createUniform(d.f32);  const ahead = root.createUniform(d.f32);  const water = own(createWater(root, time));  const light = own(    lighting.create(root, {      shading: water.shading,      settings: {        sunDirection: [0.3, 1, 0.2],        sunIntensity: 1.2,        skyTop: [0.3, 0.45, 0.5],        skyBottom: [0.16, 0.15, 0.12],      },      shadow: { extent: 18, center: MIDDLE },    }),  );  const scene = passes    .create(      root,      screen,      { scene: passes.color, depth: passes.depth },      { resolve: { scene: 'hdr' }, clear: CLEAR },    )    .pipe(view.inject())    .beneath(light.inject());  /** The settings bloom and the final pass share, which the panel's look folder tunes. */  const look = {    exposure: 1.2,    threshold: bloom.defaults.threshold,    glowIntensity: finish.defaults.glowIntensity,    vignette: finish.defaults.vignette,  };  const glow = own(bloom.create(root, { source: () => screen.textures.hdr, settings: look }));  const final = own(    finish.create(root, {      format: surface.format,      source: () => screen.textures.hdr,      settings: look,      glow,    }),  );  const shared = { time, camera: view, water } as const;  const food = own(createFood(root, { ...shared, step: sim.step, capacity: 64 }));  /** The boids' weights, which the panel tunes. */  const steering = { separation: 0.9, alignment: 1.2, cohesion: 0.7, wander: 1.2, appetite: 6 };  const school = own(    createSchool(root, {      ...shared,      // Fish extrapolate by `ahead`; particles extrapolate themselves      step: sim.step,      ahead,      capacity: 4096,      food,      settings: steering,    }),  );  const floor = own(createFloor(root, shared));  const bubbles = own(createBubbles(root, { camera: view, step: sim.step }));  // Only the fish cast shadows, and crumbs blend over the opaque drawings before them  const drawn = [    scene.add(floor.renderer),    scene.add(school.renderer),    scene.add(food.pellets),    scene.add(food.crumbs),    scene.add(bubbles.drawing),  ];  const casters = [light.shadow.add(school.renderer)];  const values = { ...steering, paused: false, ...look };  school.populate();  let compiled = false;  const ready = pipelines    .compile([school, food, scene, light.shadow, glow, final], options)    .then(() => {      compiled = true;    });  let now = 0;  /** Drops `FEED.pellets` pellets below the surface. */  const feed = () => {    for (let i = 0; i < FEED.pellets; i++) {      const x = random.float(Math.random, -FEED.x, FEED.x);      const z = random.float(Math.random, -FEED.z, FEED.z);      food.drop(x, z, now);    }  };  const lens = { folder: 'look' } as const;  const settings: Settings<typeof values> = {    values,    knobs: {      separation: { min: 0, max: 3, step: 0.05 },      alignment: { min: 0, max: 3, step: 0.05 },      cohesion: { min: 0, max: 3, step: 0.05 },      wander: { min: 0, max: 4, step: 0.05 },      appetite: { min: 0, max: 12, step: 0.1 },      paused: {},      exposure: { ...lens, min: 0.2, max: 4, step: 0.05 },      threshold: { ...lens, min: 0, max: 3, step: 0.05 },      glowIntensity: { ...lens, min: 0, max: 1, step: 0.01 },      vignette: { ...lens, min: 0, max: 1, step: 0.01 },    },    write(key) {      if (key === undefined || key === 'paused') sim.paused = values.paused;      if (key === undefined || key in steering) school.settings.write(values);      if (key === undefined || key in look) {        final.settings.patch(values);        glow.settings.patch(values);      }    },  };  return {    view,    settings,    /** Settles when frames finish compiling pipelines and begin drawing. */    ready,    ui: { controls: { feed: { press: feed } } },    get pellets() {      return food.table.count;    },    frame(frame: Frame) {      now = frame.time;      if (!compiled) return;      food.eat();      time.write(frame.time);      view.update(frame.dt);      screen.fit();      food.table.flush();      if (!sim.paused) bubbles.vent(frame.dt);      school.flush();      floor.flush();      const compute = frame.encoder.beginComputePass();      bubbles.spawn(compute);      for (let steps = sim.advance(frame.dt); steps > 0; steps--) {        school.step(compute);        food.step(compute);        bubbles.step(compute);      }      ahead.write(sim.ahead);      food.compact(compute, sim.ahead);      bubbles.compact(compute, sim.ahead);      compute.end();      food.capture(frame);      light.shadow.record(frame, casters);      scene.record(frame, drawn);      glow.record(frame);      final.record(frame, surface.view(frame));    },    destroy: own.destroy,  };}

school.ts

import * as grid from '@typegpu/kit/grid';import { srgbToLinear } from '@typegpu/color';import { instances, materials, meshPool } from '@typegpu/kit';import { meshes } from '@typegpu/kit/geometry';import { randf } from '@typegpu/noise';import { random } from 'math/random';import { d, std, tgpu, type TgpuComputePass, type TgpuRoot, type TgpuUniform } from 'typegpu';import { bite, Pellet, pelletPosition, type Food } from './food.ts';import { TANK, type Water } from './water.ts';const SchoolSettings = d.struct({  separation: d.f32,  alignment: d.f32,  cohesion: d.f32,  wander: d.f32,  appetite: d.f32,});type Rgb = readonly [number, number, number];interface Body {  /** The greatest height and width as shares of length. */  readonly height: number;  readonly width: number;  /** The mesh's rings along the body and sides round each ring. */  readonly rings: number;  readonly sides: number;}interface Species {  readonly count: number;  /** The shortest and longest fish, nose to tail, in world units. */  readonly length: readonly [number, number];  /** Top speed in world units a second. */  readonly speed: number;  /** Back and belly colours in sRGB. */  readonly back: Rgb;  readonly belly: Rgb;  readonly body: Body;}/** Small fish in large numbers, a middling species, and a few tall, slow ones. */const SPECIES: readonly Species[] = [  {    count: 2400,    length: [0.22, 0.3],    speed: 2.2,    back: [0.1, 0.45, 0.95],    belly: [0.95, 0.25, 0.22],    body: { height: 0.3, width: 0.16, rings: 8, sides: 6 },  },  {    count: 900,    length: [0.3, 0.4],    speed: 2.8,    back: [0.85, 0.82, 0.6],    belly: [0.2, 0.28, 0.55],    body: { height: 0.26, width: 0.14, rings: 10, sides: 6 },  },  {    count: 40,    length: [0.9, 1.2],    speed: 1.1,    back: [0.95, 0.92, 0.82],    belly: [0.22, 0.2, 0.18],    body: { height: 0.95, width: 0.12, rings: 16, sides: 8 },  },];/** Tail-fin start from nose 0 to tail 1, and its thickness and height as shares of the body's. */const FIN = { from: 0.75, thickness: 0.1, height: 0.6 };/** Minimum body thickness as a fraction of length. Keeps normals finite at the tail-fin edge. */const THINNEST = 0.004;/** Tail beat in radians a second, its phase change nose to tail, and sway as a share of length. */const BEAT = { rate: 10, wave: 5, sway: 0.14 };const ROUGHNESS = 0.35;/** World units a fish sees its neighbours within. */const SIGHT = 1;/** The tank's lowest and highest corners, which the walls keep fish within. */const LOW = d.vec3f(-TANK.x / 2, 0, -TANK.z / 2);const HIGH = d.vec3f(TANK.x / 2, TANK.y, TANK.z / 2);/** World units from a wall at which it starts pushing a fish away. */const MARGIN = 0.6;/** Wall push one world unit inside `MARGIN`. */const PUSH = 8;/** World units a fish smells food from. */const SMELL = 6;/** World units a fish bites food from. */const REACH = 0.35;/** The slowest a fish swims, as a share of its top speed. */const SLOWEST = 1 / 3;/** Where fish start in world units: x and z either side of the centre, y a range of heights. */const START = { x: 6, y: [1, TANK.y - 1], z: 3 } as const;/** The speed a fish starts at, as a share of its top speed. */const CRUISE = 0.5;/** Maximum fractional variation from a species' top speed. */const VARIETY = 0.15;const WORKGROUP = 64;const TAU = Math.PI * 2;/** Sorted snapshot of fish positions and velocities. Steering reads it while fish move. */const Neighbour = d.struct({ position: d.vec3f, species: d.u32, velocity: d.vec3f });/** * Builds a one-unit fish facing +z, tapering from a spindle body into a flat tail fin. `u` runs * from nose to tail, and `v` runs around the body from its back for material shading. */const bodyOf = ({ height, width, rings, sides }: Body) => {  const across = (t: number) => {    'use gpu';    const body = std.sin(Math.PI * std.pow(t, 0.7));    const fin = std.smoothstep(FIN.from, 1, t);    return d.vec2f(      std.max(width * body * std.mix(1, FIN.thickness, fin), THINNEST),      height * std.mix(body, 1, fin * FIN.height),    );  };  // Clockwise winding seen from the nose makes the triangles face outward  return meshes.parametric(    {      at: (t, around) => {        'use gpu';        const size = across(t);        const angle = around * TAU;        return d.vec3f(-std.sin(angle) * size.x, std.cos(angle) * size.y, 0.5 - t);      },      normalAt: (t, around) => {        'use gpu';        const size = std.max(across(t), d.vec2f(THINNEST));        const angle = around * TAU;        return std.normalize(d.vec3f(-std.sin(angle) / size.x, std.cos(angle) / size.y, 0));      },    },    { cols: rings, rows: sides },  );};export interface SchoolOptions {  readonly capacity: number;  /** Seconds per `step`. */  readonly step: number;  /** Seconds since the last fixed step, from `sim.ahead`. Fish extrapolate by it. */  readonly ahead: TgpuUniform<d.F32>;  readonly time: TgpuUniform<d.F32>;  readonly food: Food;  readonly water: Water;  readonly settings: d.InferInput<typeof SchoolSettings>;}/** Steers fish of three species as boids in a neighbour grid, and draws them as instances. */export function createSchool(root: TgpuRoot, options: SchoolOptions) {  const { capacity, step, ahead, time } = options;  /**   * Each species uses one mesh. Kernels write fish positions. `deform` animates tails and   * extrapolates along local +z, which faces the velocity.   */  const Fish = instances.define('Fish', {    host: {      belly: d.vec3f,      /** Top speed in world units a second. */      speed: d.f32,      /** Tail phase offset prevents neighbours swimming in step. */      phase: d.f32,    },    device: { velocity: d.vec3f },    transform: 'device',    deform: (vertex, fish) => {      'use gpu';      const t = vertex.uv.x;      const beat = std.sin(time.$ * BEAT.rate + fish.phase - t * BEAT.wave) * BEAT.sway * t * t;      const swum = std.length(fish.velocity) * ahead.$;      return vertex.position + d.vec3f(beat, 0, swum / fish.scale.z);    },    material: (point, fish) => {      'use gpu';      const up = std.cos(point.uv.y * TAU);      const color = std.mix(fish.belly, fish.color, std.smoothstep(-0.4, 0.5, up));      return materials.glossy(color, ROUGHNESS);    },  });  const shapes = SPECIES.map(({ body }) => bodyOf(body));  const pool = meshPool.create(root, meshPool.roomFor(shapes));  const bodies = shapes.map((shape) => pool.add(shape));  const school = instances.create(root, pool, { kind: Fish, capacity });  const renderer = school.renderer().pipe(options.water.inject());  const settings = root.createUniform(SchoolSettings, options.settings);  const fish = school.inject();  const cells = grid.create(root, {    ...school.live(),    low: LOW,    size: TANK,    sight: SIGHT,    item: Neighbour,    position: Fish.position.pipe(fish),    itemOf: tgpu      .fn((i: number) => {        'use gpu';        return Neighbour({          position: Fish.position(i),          species: Fish.mesh(i),          velocity: Fish.velocity(i),        });      })      .pipe(fish),  });  const steerKernel = tgpu.computeFn({    workgroupSize: [WORKGROUP],    in: { gid: d.builtin.globalInvocationId },  })(({ gid }) => {    'use gpu';    const i = gid.x;    if (!Fish.alive(i)) return;    const position = Fish.position(i);    const velocity = Fish.velocity(i);    const species = Fish.mesh(i);    randf.seed4(d.vec4f(position, d.f32(i)));    // Separate from all nearby fish, but align and gather only with the same species    let away = d.vec3f();    let heading = d.vec3f();    let centre = d.vec3f();    let mates = d.f32(0);    const walk = cells.walk(position);    for (let r = d.u32(0); r < grid.RANGES; r++) {      const range = cells.range(walk, r);      for (let j = d.u32(0); j < range.count; j++) {        const other = cells.neighbour(range, j);        const offset = other.position - position;        const distance2 = std.dot(offset, offset);        if (distance2 > SIGHT * SIGHT || distance2 < 1e-6) continue;        away -= offset / distance2;        if (other.species === species) {          heading += other.velocity;          centre += offset;          mates += 1;        }      }    }    let force = away * settings.$.separation;    if (mates > 0) {      force += (heading / mates - velocity) * settings.$.alignment;      force += (centre / mates) * settings.$.cohesion;    }    force += (std.clamp(position, LOW + MARGIN, HIGH - MARGIN) - position) * PUSH;    // Head for the nearest pellet within smell, and bite it within reach    let nearest = d.f32(SMELL * SMELL);    let toward = d.vec3f();    let meal = d.i32(-1);    for (let k = d.u32(0); k < Pellet.span(); k++) {      if (!Pellet.alive(k)) continue;      const offset = pelletPosition(k, time.$) - position;      const distance2 = std.dot(offset, offset);      if (distance2 < nearest) {        nearest = distance2;        toward = d.vec3f(offset);        meal = d.i32(k);      }    }    if (meal >= 0) {      force += std.normalize(toward) * settings.$.appetite;      if (nearest < REACH * REACH) bite(d.u32(meal));    }    force += randf.inUnitSphere() * settings.$.wander;    const speed = Fish.speed(i);    const pushed = velocity + force * step;    const length = std.max(std.length(pushed), 1e-4);    const moved = Fish.device.$[i]!;    moved.velocity = (pushed / length) * std.clamp(length, speed * SLOWEST, speed);    moved.position = position + moved.velocity * step;    moved.rotation = instances.facing(moved.velocity);  });  const steerPipeline = root    .pipe(school.inject())    .pipe(options.food.inject())    .pipe(cells.inject())    .createComputePipeline({ compute: steerKernel });  const destroy = () => {    school.destroy();    pool.destroy();    cells.destroy();  };  return {    settings,    initAsync: () => Promise.all([cells.initAsync(), steerPipeline.initAsync()]),    /** Adds every species' fish at random through the tank, each heading its own way. */    populate() {      SPECIES.forEach(({ count, length, speed, back, belly }, species) => {        const color = srgbToLinear(d.vec3f(...back));        const underside = srgbToLinear(d.vec3f(...belly));        const cruise = speed * CRUISE;        for (let i = 0; i < count; i++) {          const angle = random.float(Math.random, 0, TAU);          const velocity = d.vec3f(Math.cos(angle) * cruise, 0, Math.sin(angle) * cruise);          school.add(bodies[species]!, {            position: [              random.float(Math.random, -START.x, START.x),              random.float(Math.random, ...START.y),              random.float(Math.random, -START.z, START.z),            ],            rotation: instances.facing(velocity),            scale: random.float(Math.random, ...length),            color,            belly: underside,            speed: speed * random.float(Math.random, 1 - VARIETY, 1 + VARIETY),            phase: random.float(Math.random, 0, TAU),            velocity,          });        }      });    },    /** Uploads and lists fish. Call once per frame before simulation steps. */    flush: school.flush,    step(pass: TgpuComputePass) {      const workgroups = Math.ceil(school.span / WORKGROUP);      if (workgroups === 0) return;      cells.sort(pass);      pass.setPipeline(steerPipeline);      pass.dispatchWorkgroups(workgroups);    },    renderer,    destroy,    [Symbol.dispose]: destroy,  };}

food.ts

import {  cameras,  liveLists,  Particle,  particles,  passes,  readback,  table,  type CameraBase,  type Frame,  type Inject,  type RowId,} from '@typegpu/kit';import { srgbToLinear } from '@typegpu/color';import { meshes } from '@typegpu/kit/geometry';import { randf } from '@typegpu/noise';import { d, std, tgpu, type TgpuComputePass, type TgpuRoot, type TgpuUniform } from 'typegpu';import { caustics, fog, TANK, type Water } from './water.ts';const cube = meshes.unindexed(meshes.box());export const Pellet = table.define('Pellet', {  host: {    /** Drop position below the water surface. */    drop: d.vec3f,    droppedAt: d.f32,  },});/** Bites needed to consume one pellet. */export const MEAL = 120;/** World units under the surface a pellet is dropped at. */const DROPPED = 0.3;/** World units a second a pellet sinks. */const SINK = 0.45;/** World units along a pellet's sides. */const SIZE = 0.08;/** Radians a second a pellet turns. */const SPIN = 1.7;const COLOR = srgbToLinear(d.vec3f(0.85, 0.45, 0.18));/** Lighting weights for overhead light, ambient light and caustics. */const LIT = { above: 0.6, around: 0.4, caustics: 0.4 };/** * Crumb colour, width in world units, maximum launch speed in world units per second, and * minimum and maximum lifetime in seconds. */const CRUMB = {  color: d.vec4f(srgbToLinear(d.vec3f(0.6, 0.32, 0.13)), 1),  size: 0.04,  speed: 0.8,  life: [3, 5],} as const;/** Chance of emitting a crumb per bite and maximum crumbs per pellet per step. */const SHED = { odds: 0.3, most: 4 };/** Reads of the bite counters per second. */const READS_HZ = 10;const WORKGROUP = 64;const bites = tgpu.mutableAccessor(d.arrayOf(d.atomic(d.u32)));/** Pellet position at `time`. Sinks from its drop position, then rests on the sand. */export const pelletPosition = (i: number, time: number) => {  'use gpu';  const drop = Pellet.drop(i);  const fallen = (time - Pellet.droppedAt(i)) * SINK;  return d.vec3f(drop.x, std.max(drop.y - fallen, SIZE / 2), drop.z);};/** Counts a bite on pellet `i`. The CPU removes pellets after enough bites. */export const bite = (i: number) => {  'use gpu';  std.atomicAdd(bites.$[i]!, 1);};/** Rests a crumb that has sunk to the sand on it. */const settle = particles.collide(  {    distance: (p) => {      'use gpu';      return p.y;    },  },  { bounce: 0, friction: 1 },);const turnY = (p: d.v3f, angle: number) => {  'use gpu';  const c = std.cos(angle);  const s = std.sin(angle);  return d.vec3f(p.x * c + p.z * s, p.y, p.z * c - p.x * s);};const Corner = d.struct({ position: d.vec4f, world: d.vec3f, normal: d.vec3f });const Seen = d.struct({ color: d.vec4f });export interface FoodOptions {  readonly capacity: number;  /** The length of a step in seconds; see `particles.Options.step`. */  readonly step: number;  readonly time: TgpuUniform<d.F32>;  readonly camera: CameraBase;  readonly water: Water;}/** Stores sinking pellets in a table and reads back bite counters to remove eaten pellets. */export function createFood(root: TgpuRoot, options: FoodOptions) {  const { capacity, time } = options;  const rows = table.create(root, Pellet, { capacity });  const live = liveLists.create(root, { ...rows.live(), draws: [cube] });  const crumbs = particles.create(root, options.camera, {    capacity: 2048,    step: options.step,    blend: 'over',    update: settle,    settings: { gravity: [0, -1.5, 0], drag: 3 },  });  const counters = root.createBuffer(d.arrayOf(d.atomic(d.u32), capacity)).$usage('storage');  const counted = readback.create(root, counters, { hz: READS_HZ });  const seenBuffer = root.createBuffer(d.arrayOf(d.u32, capacity)).$usage('storage');  const seen = seenBuffer.as('mutable');  const uneaten: RowId<'Pellet'>[] = [];  const inject: Inject = (cfg) => cfg.pipe(rows.inject()).with(bites, counters.as('mutable'));  // Emit crumbs here because the boids kernel already uses the 8 storage buffers WebGPU guarantees  const crumbleKernel = tgpu.computeFn({    workgroupSize: [WORKGROUP],    in: { gid: d.builtin.globalInvocationId },  })(({ gid }) => {    'use gpu';    const i = gid.x;    if (!Pellet.alive(i)) return;    const bitten = std.atomicLoad(bites.$[i]!);    const fresh = std.min(bitten - seen.$[i]!, SHED.most);    seen.$[i] = bitten;    // Use bite counts to vary the seed between steps sharing one frame time    randf.seed3(d.vec3f(i, bitten, time.$));    const at = pelletPosition(i, time.$);    for (let k = d.u32(0); k < fresh; k++) {      if (randf.sample() > SHED.odds) continue;      crumbs.emit(        Particle({          position: at,          age: 0,          velocity: randf.inUnitSphere() * CRUMB.speed,          life: std.mix(CRUMB.life[0], CRUMB.life[1], randf.sample()),          color: CRUMB.color,          size: CRUMB.size,          seed: randf.sample(),        }),      );    }  });  const crumblePipeline = root    .pipe(inject)    .pipe(crumbs.inject())    .createComputePipeline({ compute: crumbleKernel });  const at = (vertexIndex: number, instanceIndex: number) => {    'use gpu';    const slot = live.slot(0, instanceIndex);    const corner = cube.vertexAt(vertexIndex);    const spin = time.$ * SPIN + d.f32(slot);    const world = pelletPosition(slot, time.$) + turnY(corner.position, spin) * SIZE;    return Corner({      position: cameras.current.$.viewProjection * d.vec4f(world, 1),      world,      normal: turnY(corner.normal, spin),    });  };  const surfaceOf = (corner: d.Infer<typeof Corner>) => {    'use gpu';    const above = std.max(corner.normal.y, 0) * LIT.above;    const light = above + LIT.around + caustics(corner.world, time.$) * LIT.caustics;    const distance = std.length(cameras.current.$.position - corner.world);    return Seen({ color: d.vec4f(fog(COLOR * light, distance, corner.world.y), 1) });  };  const drawing: passes.Drawing<typeof Corner, d.Infer<typeof Seen>> = {    Vertex: Corner,    at,    surfaceOf,    color: passes.shaded,    primitive: { cullMode: 'back' },    inject: () => (cfg) => cfg.pipe(rows.inject()).pipe(live.inject()).pipe(options.water.inject()),    draw: (pass) => live.draw(pass, 0),  };  const destroy = () => {    rows.destroy();    live.destroy();    crumbs.destroy();    counters.destroy();    seenBuffer.destroy();    counted.destroy();  };  return {    table: rows,    /** Fills pellet accessors for kernels using `pelletPosition` and `bite`. */    inject: () => inject,    initAsync: () =>      Promise.all([live.initAsync(), crumbs.initAsync(), crumblePipeline.initAsync()]),    pellets: drawing,    /** Add crumbs after pellets because crumbs blend over earlier drawings. */    crumbs: crumbs.drawing,    /** Drops a pellet below the surface. Does nothing at capacity. */    drop(x: number, z: number, now: number) {      if (rows.count === capacity) return;      const id = rows.add({ drop: [x, TANK.y - DROPPED, z], droppedAt: now });      const zeroed = { [rows.slotOf(id)]: 0 };      counters.patch(zeroed);      seenBuffer.patch(zeroed);      // Earlier counts belong to the previous pellet in this slot      counted.reset();      uneaten.push(id);    },    /** Removes pellets eaten to a meal by the newest bite counts. Call once a frame. */    eat() {      const eaten = counted.take();      if (!eaten) return;      for (let i = uneaten.length - 1; i >= 0; i--) {        const id = uneaten[i]!;        if (eaten[rows.slotOf(id)]! < MEAL) continue;        rows.remove(id);        uneaten.splice(i, 1);      }    },    /** Emits crumbs for this step's bites, then steps them. Record after the fish. */    step(pass: TgpuComputePass) {      const workgroups = Math.ceil(rows.span / WORKGROUP);      if (workgroups > 0) {        pass.setPipeline(crumblePipeline);        pass.dispatchWorkgroups(workgroups);      }      crumbs.step(pass);    },    /** Lists pellets and crumbs for drawing. Record after simulation steps. */    compact(pass: TgpuComputePass, ahead: number) {      live.compact(pass);      crumbs.compact(pass, ahead);    },    /** Copies the bite counters ten times a second. Record after fish have eaten. */    capture(frame: Frame) {      counted.capture(frame);    },    destroy,    [Symbol.dispose]: destroy,  };}export type Food = ReturnType<typeof createFood>;

water.ts

import { cameras, shadows, type lighting } from '@typegpu/kit';import { srgbToLinear } from '@typegpu/color';import { perlin3d } from '@typegpu/noise';import { d, std, type TgpuRoot, type TgpuUniform } from 'typegpu';/** Deep-water and clear colour in linear light. */export const DEEP = srgbToLinear(d.vec3f(0.01, 0.045, 0.07));/** Surface-water colour in linear light. */const SHALLOW = srgbToLinear(d.vec3f(0.07, 0.24, 0.3));/** Extinction per world unit. One unit transmits `exp(-MURK)` of the light. */const MURK = 0.06;/** Tank dimensions in world units. x and z centre on the origin, and y rises from the sand. */export const TANK = d.vec3f(16, 8, 10);/** Caustic noise scale per world unit, drift per second, sharpness and maximum brightness. */const CAUSTICS = { scale: 0.45, drift: 0.3, sharpness: 10, brightness: 2.2 };/** The share of the sun's light that comes through between the caustics. */const BETWEEN = 0.3;/** Moving light patterns focused by the rippling water surface. */export const caustics = (world: d.v3f, time: number) => {  'use gpu';  const p = d.vec3f(world.xz * CAUSTICS.scale, time * CAUSTICS.drift);  const ridges = 1 - std.abs(perlin3d.sample(p) + perlin3d.sample(p * 2.1 + 7) * 0.5);  return std.pow(std.saturate(ridges), CAUSTICS.sharpness) * CAUSTICS.brightness;};/** Distance fog tending toward brighter water near the surface. */export const fog = (color: d.v3f, distance: number, height: number) => {  'use gpu';  const water = std.mix(DEEP, SHALLOW, std.saturate(height / TANK.y));  return std.mix(water, color, std.exp(-distance * MURK));};/** * Water shading for `lighting.create`. Caustics modulate sunlight, and fog uses the water * colour. Pipelines using this shading or `caustics` must pipe in its `inject()` to bind the * noise cache. */export function createWater(root: TgpuRoot, time: TgpuUniform<d.F32>) {  const noise = perlin3d.staticCache({ root, size: d.vec3u(16, 16, 32) });  const water: lighting.Shading = {    sunShadow: (point) => {      'use gpu';      return shadows.filtered(point) * (BETWEEN + caustics(point.position, time.$));    },    fog: (color, point) => {      'use gpu';      return fog(color, std.length(cameras.current.$.position - point.position), point.position.y);    },  };  return { shading: water, inject: () => noise.inject(), destroy: () => noise.destroy() };}export type Water = ReturnType<typeof createWater>;

floor.ts

import { srgbToLinear } from '@typegpu/color';import { instances, materials, meshPool } from '@typegpu/kit';import { meshes } from '@typegpu/kit/geometry';import { perlin3d } from '@typegpu/noise';import { d, std, type TgpuRoot } from 'typegpu';import { TANK, type Water } from './water.ts';const SAND = srgbToLinear(d.vec3f(0.75, 0.66, 0.48));const SHADE = srgbToLinear(d.vec3f(0.45, 0.38, 0.28));/** Ripple frequency per world unit along x and z. */const RIPPLES = d.vec2f(0.8, 2.4);const Sand = instances.define('Sand', {  material: (point) => {    'use gpu';    const at = point.position;    const ripples = perlin3d.sample(d.vec3f(at.xz * RIPPLES, 5)) * 0.5 + 0.5;    return materials.matte(std.mix(SHADE, SAND, ripples));  },});export interface FloorOptions {  readonly water: Water;}/** * Draws the sand as one plane instance. Nothing lies below it, so add it to scene passes * without a shadow pass. */export function createFloor(root: TgpuRoot, options: FloorOptions) {  const shape = meshes.plane({ width: TANK.x, depth: TANK.z });  const pool = meshPool.create(root, meshPool.roomFor([shape]));  const sand = instances.create(root, pool, { kind: Sand, capacity: 1 });  sand.add(pool.add(shape));  const renderer = sand.renderer().pipe(options.water.inject());  const destroy = () => {    sand.destroy();    pool.destroy();  };  return {    flush: sand.flush,    renderer,    destroy,    [Symbol.dispose]: destroy,  };}

bubbles.ts

import { clock, Particle, particles, type CameraBase } from '@typegpu/kit';import { random } from 'math/random';import { d, std, type TgpuRoot } from 'typegpu';import { TANK } from './water.ts';/** Vents in the sand that bubbles puff from, in world units. */const VENTS: [number, number, number][] = [  [-5.2, 0.1, -2.5],  [-4.6, 0.1, -2.1],  [2.8, 0.1, 1.6],  [6.1, 0.1, -3.2],];/** Minimum and maximum bubbles per vent burst. */const PUFF = [1, 3] as const;/** Each burst's spread round its vent, its bubbles' size, lifetime and colour. */const BURST = {  area: [0.08, 0, 0.08],  scatter: 0.2,  size: 0.1,  life: 20,  color: [0.55, 0.85, 1, 0.9],} as const;/** Wobble frequency in radians per second and acceleration in world units per second squared. */const WOBBLE = { rate: 7, push: 2 };/** The share of its size a bubble grows by each second. */const GROWTH = 0.05;/** Brightness of a bubble's rim. */const RIM = 0.5;/** Highlight centre and radius relative to a unit-radius bubble, and highlight brightness. */const HIGHLIGHT = { at: d.vec2f(-0.35, 0.4), radius: 0.25, brightness: 2 };/** Additive shading for a thin rim and a highlight facing the light. */const rim = (bubble: Particle, local: d.v2f) => {  'use gpu';  const r = std.length(local);  const ring = std.smoothstep(0.6, 0.95, r) * (1 - std.smoothstep(0.95, 1, r));  const highlight = std.saturate(1 - std.length(local - HIGHLIGHT.at) / HIGHLIGHT.radius);  return d.vec4f(    bubble.color.xyz * (ring * RIM + highlight * HIGHLIGHT.brightness) * bubble.color.w,    0,  );};/** Wobbles a bubble sideways and grows it as it rises, and bursts it at the surface. */const rise = (bubble: Particle, dt: number) => {  'use gpu';  let next = Particle(bubble);  const wobble = std.sin(bubble.age * WOBBLE.rate + bubble.seed * 40) * WOBBLE.push;  next.velocity.x = bubble.velocity.x + wobble * dt;  next.size = bubble.size * (1 + GROWTH * dt);  if (bubble.position.y > TANK.y - bubble.size) next.life = 0;  return next;};export interface BubblesOptions {  /** The length of a step in seconds; see `particles.Options.step`. */  readonly step: number;  readonly camera: CameraBase;}/** Bubbles that puff from vents in the sand, wobble and grow as they rise, and burst at the top. */export function createBubbles(root: TgpuRoot, options: BubblesOptions) {  const { step, camera } = options;  const bubbles = particles.create(root, camera, {    step,    capacity: 1024,    blend: 'add',    update: rise,    shade: rim,    // Buoyancy and drag give a terminal speed of 0.8 world units per second    settings: { gravity: [0, 1.2, 0], drag: 1.5 },  });  const puff = clock.cadence({ hz: 5 });  return {    ...bubbles,    /** Schedules occasional vent bursts. Call once per frame before `spawn`. */    vent(dt: number) {      if (!puff.due(dt)) return;      bubbles.burst({        ...BURST,        count: random.int(Math.random, ...PUFF),        position: random.choice(Math.random, VENTS),      });    },  };}

ui.ts

import { buttons } from '../ui.ts';export const ui = {  controls: buttons({ feed: { label: 'feed', keys: ['KeyF'] } }, { right: [['feed']] }),};