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
- scene.tsCreates the scene: its resources, its settings and what each frame records.
- school.tsSteers fish of three species as boids in a neighbour grid, and draws them as instances.
- food.tsStores sinking pellets in a table and reads back bite counters to remove eaten pellets.
- water.tsWater shading for
lighting.create. - floor.tsDraws the sand as one plane instance.
- bubbles.tsBubbles that puff from vents in the sand, wobble and grow as they rise, and burst at the top.
- 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']] }),};