Slime mould
400 000 Physarum agents grow a network of glowing threads across dark agar toward food. The first tutorial builds the scene. A fixed-step kernel moves agents in a buffer. They sense the trail ahead, steer toward it and deposit more with atomics. Another kernel blurs and fades the trail grid. Food attracts hungry agents and feeds the network. A full-screen pass maps trail density through a tone curve and a crimson-to-gold colour ramp, with pulses and bloom. Random numbers come from @typegpu/noise. Tap to drop more food.
Files, in reading order
- scene.tsCreates the scene: its resources, its settings and what each frame records.
- agents.tsMoves Physarum agents along a trail.
- agar.tsDraws the agar dish over the whole picture, darkening towards its edges.
- oats.tsPaints food into a texture matching the trail grid when flakes change.
- trail.tsStores trail density in a square grid.
- veins.tsMaps trail density through a tone curve and colour ramp, then adds it over the agar with glowing food.
Kit modules baker, bloom, clock, finish, owner, pipelines, targets. Also @typegpu/color, @typegpu/noise, typegpu.
scene.ts
import { clock, owner, pipelines, targets, type Frame, type Surface, bloom, finish,} from '@typegpu/kit';import { d, type TgpuRoot } from 'typegpu';import type { Pointing } from '../contract.ts';import type { Settings } from '../settings.ts';import { createAgents } from './agents.ts';import { createAgar, sheetAt, World } from './agar.ts';import { createOats } from './oats.ts';import { createTrail } from './trail.ts';import { createVeins } from './veins.ts';const STEP = 1 / 60;const HDR = 'rgba16float';/** How much of the bloom the final pass adds at glow 1. */const GLOW = 0.2;/** Agent capacity, and the most the count setting reaches. */const MOST = 1_000_000;const DEGREES = Math.PI / 180;/** Settings `agents.set` reads. */const MOULD = ['count', 'sensorAngle', 'sensorDistance', 'turn', 'speed', 'deposit', 'endurance'];export interface SceneOptions { /** Agents at the start. Default 400,000. */ readonly count?: number; /** Trail grid cells along each side. Default 2048. */ readonly grid?: number;}/** A Physarum slime mould whose agents follow and lay a trail, fed by oats a tap drops. */export async function createScene(root: TgpuRoot, surface: Surface, options: SceneOptions = {}) { const { count = 400_000, grid = 2048 } = options; const values = { count, sensorAngle: 30, sensorDistance: 35, turn: 45, speed: 1.2, deposit: 0.5, doubling: 2, endurance: 6, decay: 0.07, blur: 1, food: 100, light: 1, pulse: 1, glow: 1, paused: false, }; const own = owner(); const sim = clock.fixedStep({ step: STEP }); const world = root.createUniform(World, { size: d.vec2f(surface.width, surface.height), time: 0, }); const screen = own(targets.create(root, surface, { hdr: { format: HDR, sampled: true } })); const agar = createAgar(root, world, HDR); const oats = own(createOats(root, { size: grid })); const trail = own(createTrail(root, { size: grid })); const agents = own(createAgents(root, trail, { capacity: MOST, food: oats.foodAt })); const veins = createVeins(root, world, { trail, oats }, HDR); const hdr = () => screen.textures.hdr; const glow = own(bloom.create(root, { source: hdr, settings: { threshold: 1, knee: 0.5 } })); const final = own( finish.create(root, { format: surface.format, source: hdr, glow, settings: { glowIntensity: GLOW, vignette: 0.3 }, }), ); await pipelines.compile([agar, trail, oats, agents, veins, glow, final]); const physarum = () => agents.set({ count: values.count, sensorAngle: values.sensorAngle * DEGREES, sensorDistance: values.sensorDistance, turn: values.turn * DEGREES, speed: values.speed, deposit: values.deposit, hunger: STEP / values.endurance, }); let scattering = true; /** Growth start time, in simulated seconds. */ let born = 0; const regrow = () => { trail.clear(); scattering = true; born = sim.time; }; const settings: Settings<typeof values> = { values, knobs: { count: { min: 50_000, max: MOST, step: 10_000, folder: 'mould' }, sensorAngle: { min: 5, max: 90, step: 1, folder: 'mould', label: 'sensor angle (degrees)' }, sensorDistance: { min: 2, max: 40, step: 0.5, folder: 'mould', label: 'sensor distance (cells)', }, turn: { label: 'turn (degrees/step)', min: 0, max: 90, step: 1, folder: 'mould' }, speed: { label: 'speed (cells/step)', min: 0.2, max: 4, step: 0.1, folder: 'mould' }, doubling: { label: 'doubling time (s)', min: 0.5, max: 10, step: 0.1, folder: 'mould' }, endurance: { label: 'endurance (s)', min: 1, max: 60, step: 1, folder: 'mould' }, deposit: { min: 0, max: 2, step: 0.05, folder: 'trail' }, decay: { min: 0, max: 0.5, step: 0.005, folder: 'trail' }, blur: { min: 0, max: 1, step: 0.05, folder: 'trail' }, food: { min: 0, max: 200, step: 1, folder: 'mould' }, light: { min: 0, max: 2, step: 0.05, folder: 'look' }, pulse: { min: 0, max: 2, step: 0.05, folder: 'look' }, glow: { min: 0, max: 2, step: 0.05, folder: 'look' }, paused: {}, }, write(key) { if (key === undefined || key === 'paused') sim.paused = values.paused; if (key === undefined || MOULD.includes(key)) physarum(); // Changing the count resets growth to prevent agents overlapping if (key === 'count') regrow(); if (key === undefined || key === 'decay' || key === 'blur') { trail.set({ keep: 1 - values.decay, blur: values.blur }); } if (key === undefined || key === 'food') oats.set(values.food); if (key === undefined || key === 'light' || key === 'pulse') { veins.look.write({ light: values.light, pulse: values.pulse }); } if (key === undefined || key === 'glow') { final.settings.patch({ glowIntensity: GLOW * values.glow }); } }, actions: { regrow }, }; const pointing: Pointing = { press(x, y) { const { width, height } = surface; oats.drop(sheetAt(d.vec2f(x * width, y * height), d.vec2f(width, height))); }, }; return { settings, pointing, frame(frame: Frame) { if (screen.fit()) world.patch({ size: d.vec2f(screen.width, screen.height) }); const steps = sim.advance(frame.dt); if (steps > 0) { world.patch({ time: sim.time }); agents.grow(sim.time - born, values.doubling); } if (scattering) agents.scatter(frame.encoder); scattering = false; oats.paint(frame.encoder); for (let i = 0; i < steps; i++) { const pass = frame.encoder.beginComputePass(); agents.move(pass); trail.spread(pass); pass.end(); } const pass = frame.encoder.beginRenderPass({ colorAttachments: { view: screen.views.hdr } }); agar.draw(pass); veins.draw(pass); pass.end(); glow.record(frame); final.record(frame, surface.view(frame)); }, destroy: own.destroy, };}agents.ts
import { randf } from '@typegpu/noise';import { d, std, tgpu, type TgpuCommandEncoder, type TgpuComputePass, type TgpuRoot,} from 'typegpu';import type { Trail } from './trail.ts';const WORKGROUP = 64;/** Initial agent count. */const SEED = 16384;/** Fraction of the initial blob's cells occupied. */const FILL = 0.2;/** Agents a 3 × 3 neighbourhood holds before growth into it stops. */const ROOM = 5;/** Agents in a 3 × 3 neighbourhood that make an agent there leave. */const CROWD = 30;/** The chance each step that an agent checks whether it stands in a crowd. */const THINNING = 0.05;/** The golden angle prevents agents in the initial blob from lining up. */const GOLDEN = Math.PI * (3 - Math.sqrt(5));/** How much an agent's heading wanders each step on its own, in radians either way. */const WANDER = 0.08;/** * Agent position in trail cells and heading in radians. `awake` is 1 after growth and 0 before * it. Food level falls from 1 when fed to 0 when starved. */const Agent = d.struct({ position: d.vec2f, heading: d.f32, awake: d.u32, fed: d.f32 });/** * Maximum agent count, growth limit, sensing, steering and hunger. Angles are in radians and * distances in trail cells. Hunger is the food level lost per step. */export const Physarum = d.struct({ count: d.u32, alive: d.u32, sensorAngle: d.f32, sensorDistance: d.f32, turn: d.f32, speed: d.f32, deposit: d.f32, hunger: d.f32,});/** No food attraction or hunger. */const enough = (_uv: d.v2f) => { 'use gpu'; return d.vec2f(0, 1);};const toward = (angle: number) => { 'use gpu'; return d.vec2f(std.cos(angle), std.sin(angle));};/** * Moves Physarum agents along a trail. Each agent senses ahead, turns toward the strongest * trail, moves and deposits more. Agents grow where there is room, forming a network of * threads. * * @param trail The grid for sensing and deposits. Its size sets the sheet size. * @param options.food Samples food at grid coordinates from 0 to 1. Returns attraction in `x` * and 1 in `y` where an agent can eat. Defaults to no attraction or hunger. */export function createAgents( root: TgpuRoot, trail: Trail, options: { capacity: number; food?: (uv: d.v2f) => d.v2f },) { const { capacity, food = enough } = options; const size = trail.size; const centre = d.vec2f(size / 2); const agents = root.createMutable(d.arrayOf(Agent, capacity)); const physarum = root.createUniform(Physarum); const taken = root.createMutable(d.arrayOf(d.atomic(d.u32), size * size)); const cellOf = (position: d.v2f) => { 'use gpu'; const cell = d.vec2u(position); return cell.y * size + cell.x; }; /** Excludes a one-cell border around the sheet. */ const onSheet = (position: d.v2f) => { 'use gpu'; return std.all(std.ge(position, d.vec2f(1))) && std.all(std.lt(position, d.vec2f(size - 1))); }; const enter = (position: d.v2f) => { 'use gpu'; if (!onSheet(position)) return false; std.atomicAdd(taken.$[cellOf(position)]!, 1); return true; }; const neighbours = (position: d.v2f) => { 'use gpu'; let count = d.u32(0); for (let y = -1; y <= 1; y++) { for (let x = -1; x <= 1; x++) { count += std.atomicLoad(taken.$[cellOf(position + d.vec2f(d.f32(x), d.f32(y)))]!); } } return count; }; /** Lays agents on a spiral, the first `SEED` awake and the rest asleep, reserved for growth. */ const scatter = tgpu.computeFn({ workgroupSize: [WORKGROUP], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { 'use gpu'; if (gid.x >= physarum.$.count) return; randf.seed(d.f32(gid.x)); const out = d.f32(gid.x) * GOLDEN; const position = centre + toward(out) * std.sqrt(d.f32(gid.x) / (Math.PI * FILL)); const heading = randf.sample() * Math.PI * 2; let awake = d.u32(0); if (gid.x < SEED && enter(position)) awake = 1; agents.$[gid.x] = Agent({ position, heading, awake, fed: 0.5 + 0.5 * randf.sample() }); }); /** * Samples attraction `distance` cells away at `angle` radians from the heading. Food begins * to attract a half-starved agent and attracts it more as hunger grows. */ const sniff = (agent: d.Infer<typeof Agent>, angle: number) => { 'use gpu'; const uv = (agent.position + toward(agent.heading + angle) * physarum.$.sensorDistance) / size; const hunger = std.saturate(1 - agent.fed * 2); return trail.at(uv) + food(uv).x * hunger; }; const move = tgpu.computeFn({ workgroupSize: [WORKGROUP], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { 'use gpu'; if (gid.x >= physarum.$.count) return; const agent = agents.$[gid.x]!; // A sleeping agent grows ahead of a randomly chosen awake parent if there is room. Its // heading changes the random seed between steps while it sleeps if (agent.awake === 0) { if (gid.x >= physarum.$.alive) return; randf.seed2(d.vec2f(d.f32(gid.x), agent.heading)); agent.heading = randf.sample() * Math.PI * 2; const parent = agents.$[d.u32(randf.sample() * d.f32(physarum.$.alive))]!; if (parent.awake === 0) return; const heading = parent.heading + (randf.sample() - 0.5) * 2; const position = parent.position + toward(heading) * 2; if (neighbours(position) < ROOM && enter(position)) { // Vary the inherited food level so agents do not all starve at once const fed = std.max(parent.fed, randf.sample()); agents.$[gid.x] = Agent({ position, heading, awake: 1, fed }); } return; } randf.seed3(d.vec3f(agent.position, d.f32(gid.x))); const eats = food(agent.position / size).y > 0.5; agent.fed = std.select(agent.fed - physarum.$.hunger, 1, eats); // Starved or crowded agents sleep until they can regrow where there is room const crowded = randf.sample() < THINNING && neighbours(agent.position) >= CROWD; if (agent.fed <= 0 || crowded) { std.atomicSub(taken.$[cellOf(agent.position)]!, 1); agent.awake = 0; return; } const angle = physarum.$.sensorAngle; const ahead = sniff(agent, d.f32(0)); const left = sniff(agent, -angle); const right = sniff(agent, angle); // When both sides beat the forward sample, choose a side at random const turn = physarum.$.turn * (0.5 + randf.sample()); let heading = agent.heading + (randf.sample() - 0.5) * 2 * WANDER; if (ahead < left && ahead < right) { heading += std.select(-turn, turn, randf.sample() < 0.5); } else if (left > ahead && left > right) { heading -= turn; } else if (right > ahead && right > left) { heading += turn; } // At the sheet edge, turn back and deposit trail without moving const next = agent.position + toward(heading) * physarum.$.speed; const from = cellOf(agent.position); if (!onSheet(next)) { heading += Math.PI * (0.75 + 0.5 * randf.sample()); } else { if (cellOf(next) !== from) { std.atomicSub(taken.$[from]!, 1); std.atomicAdd(taken.$[cellOf(next)]!, 1); } agent.position = d.vec2f(next); } agent.heading = heading; // Fed agents deposit more, strengthening trails near food and along paths from it trail.deposit( d.vec2i(agent.position), physarum.$.deposit * (0.3 + 1.7 * agent.fed * agent.fed), ); }); const scattering = root.createComputePipeline({ compute: scatter }); const moving = root.createComputePipeline({ compute: move }); let count = 0; const grown = { alive: SEED }; return { initAsync: () => Promise.all([scattering.initAsync(), moving.initAsync()]), /** Sets the growth limit, sensing, steering and hunger rate. Clamps the count to capacity. */ set(values: Omit<d.InferInput<typeof Physarum>, 'alive'>) { count = Math.min(values.count, capacity); grown.alive = Math.min(grown.alive, count); physarum.write({ ...values, count, alive: grown.alive }); }, /** * Limits awake agents by growth age. The initial blob doubles every `doubling` seconds * until it reaches the requested count. */ grow(seconds: number, doubling: number) { const alive = Math.min(count, Math.floor(SEED * 2 ** (seconds / doubling))); if (alive === grown.alive) return; grown.alive = alive; physarum.patch(grown); }, scatter(encoder: TgpuCommandEncoder) { grown.alive = Math.min(SEED, count); physarum.patch(grown); taken.buffer.clear(encoder); const pass = encoder.beginComputePass(); pass.setPipeline(scattering); pass.dispatchWorkgroups(Math.ceil(count / WORKGROUP)); pass.end(); }, move(pass: TgpuComputePass) { pass.setPipeline(moving); pass.dispatchWorkgroups(Math.ceil(count / WORKGROUP)); }, destroy() { agents.buffer.destroy(); taken.buffer.destroy(); }, };}agar.ts
import { hexToRgb, srgbToLinear } from '@typegpu/color';import { common, d, std, tgpu, type TgpuRenderCommands, type TgpuRoot, type TgpuUniform,} from 'typegpu';/** Picture size in pixels and elapsed growth time in seconds. */export const World = d.struct({ size: d.vec2f, time: d.f32 });/** Converts an sRGB hex colour, such as 0xc0301c, to linear light. */export const hex = (rgb: number) => srgbToLinear(hexToRgb(rgb));const AGAR = hex(0x080b10);const DEEP = hex(0x030406);/** * Sheet coordinates have (0, 0) at the picture centre, with y pointing down. One unit is half * the picture's longer side. The mould grid covers -1 to 1 on both axes, so on a wide picture its * top and bottom run past the edges. */export const sheetAt = (pixel: d.v2f, size: d.v2f) => { 'use gpu'; return ((pixel - size * 0.5) * 2) / std.max(size.x, size.y);};/** Draws the agar dish over the whole picture, darkening towards its edges. */export function createAgar( root: TgpuRoot, world: TgpuUniform<typeof World>, format: GPUTextureFormat,) { const ground = tgpu.fragmentFn({ in: { pixel: d.builtin.position }, out: d.vec4f })(({ pixel, }) => { 'use gpu'; const at = sheetAt(pixel.xy, world.$.size); return d.vec4f(std.mix(AGAR, DEEP, std.smoothstep(0.2, 1.1, std.length(at))), 1); }); const drawing = root.createRenderPipeline({ vertex: common.fullScreenTriangle, fragment: ground, targets: { format }, }); return { initAsync: () => drawing.initAsync(), draw(pass: TgpuRenderCommands) { pass.setPipeline(drawing); pass.draw(3); }, };}oats.ts
import { baker } from '@typegpu/kit';import { d, std, type TgpuCommandEncoder, type TgpuRoot } from 'typegpu';/** Maximum flake count. Adding a flake at capacity replaces the oldest. */const MOST = 32;/** How fast a flake's core glow falls off from its centre. */const CORE = 400;/** How fast a flake's halo glow falls off from its centre. */const HALO = 0.6;/** Radians each flake turns from the one laid before it. */const TURN = 2.4;/** Half-length and half-width of a dropped flake in sheet units. */const DROPPED = d.vec2f(0.055, 0.045);/** Flake centre in sheet coordinates from -1 to 1, half-length, half-width and angle in radians. */const Oat = d.struct({ at: d.vec2f, size: d.vec2f, angle: d.f32 });/** * Flake count, attraction strength and attraction range in sheet units. The sheet spans -1 to * 1. */export const Food = d.struct({ count: d.u32, strength: d.f32, reach: d.f32 });/** Initial flakes surround the starting blob and stay visible in a wide picture. */const LAID: [number, number][] = [ [0.4, -0.12], [0.2, 0.34], [-0.14, 0.42], [-0.46, 0.22], [-0.62, -0.2], [-0.28, -0.4], [0.08, -0.46], [0.7, 0.2], [-0.84, 0.18], [0.62, -0.38], [0.48, 0.48], [-0.66, 0.46], [0.88, -0.1],];const rotate = (v: d.v2f, angle: number) => { 'use gpu'; const c = std.cos(angle); const s = std.sin(angle); return d.vec2f(c * v.x - s * v.y, s * v.x + c * v.y);};/** * Paints food into a texture matching the trail grid when flakes change. Each cell stores * coverage, attraction, core glow and halo glow. */export function createOats(root: TgpuRoot, options: { size: number }) { const { size } = options; const oats = root.createReadonly(d.arrayOf(Oat, MOST)); const food = root.createUniform(Food); const texture = root .createTexture({ size: [size, size], format: 'rgba16float' }) .$usage('storage', 'sampled'); const reading = texture.createView(d.texture2d(d.f32)); const smooth = root.createSampler({ magFilter: 'linear', minFilter: 'linear' }); const painting = baker.create(root, texture, { texel: (uv) => { 'use gpu'; const at = uv * 2 - 1; const cell = 2 / size; let cover = d.f32(0); let smell = d.f32(0); let core = d.f32(0); let halo = d.f32(0); for (let i = d.u32(0); i < food.$.count; i++) { const oat = oats.$[i]!; // Transform the ellipse into a unit circle before measuring distance const local = rotate(at - oat.at, -oat.angle) / oat.size; const r = std.length(local); const edge = (r - 1) * std.min(oat.size.x, oat.size.y); cover = std.max(cover, std.smoothstep(cell, -cell, edge)); const reach = std.max(edge, 0) / food.$.reach; smell += std.exp(-reach * reach); core += std.exp(-r * r * CORE); halo += std.exp(-r * r * HALO); } return d.vec4f(cover, smell, core, halo); }, }); const written = { count: LAID.length, strength: 1, reach: 0.2 }; let laid = LAID.length; let changed = true; oats.write( LAID.map(([x, y], i) => ({ at: d.vec2f(x, y), size: d.vec2f(0.05 + 0.01 * (i % 3), 0.04 + 0.006 * ((i * 2) % 3)), angle: i * TURN, })), ); return { /** * Samples food at grid coordinates `uv` from 0 to 1. Returns attraction in `x` and flake * coverage in `y`. */ foodAt: (uv: d.v2f) => { 'use gpu'; const found = std.textureSampleLevel(reading.$, smooth.$, uv, 0).xy; return d.vec2f(found.y * food.$.strength, found.x); }, /** Returns core glow in `x` and halo glow in `y` at grid coordinates `uv`. */ glowAt: (uv: d.v2f) => { 'use gpu'; return std.textureSampleLevel(reading.$, smooth.$, uv, 0).zw; }, initAsync: () => painting.initAsync(), set(strength: number) { written.strength = strength; food.write(written); }, /** * Adds a flake at sheet coordinates `at`, from -1 to 1. Replaces the oldest flake at * capacity. */ drop(at: d.v2f) { oats.patch({ [laid % MOST]: { at, size: DROPPED, angle: laid * TURN }, }); laid++; written.count = Math.min(laid, MOST); food.write(written); changed = true; }, /** Repaints only when flakes have changed. */ paint(encoder: TgpuCommandEncoder) { if (!changed) return; changed = false; painting.record(encoder); }, destroy() { texture.destroy(); }, };}export type Oats = ReturnType<typeof createOats>;trail.ts
import { d, std, tgpu, type TgpuComputePass, type TgpuRoot } from 'typegpu';/** Integer units per deposit. Atomics require integer additions. */const PARTS = 1024;const WORKGROUP = 16;/** Trail retention per step and blur strength, both from 0 to 1. */export const Spread = d.struct({ keep: d.f32, blur: d.f32 });/** * Stores trail density in a square grid. Agents deposit with atomics. Each step blurs and fades * the trail, then adds deposits. Texture sampling interpolates between cells. */export function createTrail(root: TgpuRoot, options: { size: number }) { const { size } = options; const texture = () => root.createTexture({ size: [size, size], format: 'rgba16float' }).$usage('storage', 'sampled'); const trail = texture(); const spare = texture(); const reading = trail.createView(d.texture2d(d.f32)); const writing = trail.createView(d.textureStorage2d('rgba16float', 'write-only')); const spareReading = spare.createView(d.texture2d(d.f32)); const spareWriting = spare.createView(d.textureStorage2d('rgba16float', 'write-only')); const smooth = root.createSampler({ magFilter: 'linear', minFilter: 'linear' }); const dropped = root.createMutable(d.arrayOf(d.atomic(d.u32), size * size)); const spread = root.createUniform(Spread); /** Atomically adds `amount` to a cell, allowing concurrent deposits. */ const deposit = (cell: d.v2i, amount: number) => { 'use gpu'; std.atomicAdd(dropped.$[cell.y * size + cell.x]!, d.u32(amount * PARTS)); }; /** Samples the trail at grid coordinates `uv` from 0 to 1, with interpolation between cells. */ const at = (uv: d.v2f) => { 'use gpu'; return std.textureSampleLevel(reading.$, smooth.$, uv, 0).x; }; const inside = (cell: d.v2i) => { 'use gpu'; return std.clamp(cell, d.vec2i(0), d.vec2i(size - 1)); }; // Reading neighbours while writing the same texture would depend on thread order. Blur across // into a spare texture, then down into the trail texture const across = tgpu.computeFn({ workgroupSize: [WORKGROUP, WORKGROUP], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { 'use gpu'; if (gid.x >= size || gid.y >= size) return; const cell = d.vec2i(gid.xy); const here = std.textureLoad(reading.$, cell, 0).x; const left = std.textureLoad(reading.$, inside(cell - d.vec2i(1, 0)), 0).x; const right = std.textureLoad(reading.$, inside(cell + d.vec2i(1, 0)), 0).x; const blurred = std.mix(here, (left + here * 2 + right) / 4, spread.$.blur); std.textureStore(spareWriting.$, gid.xy, d.vec4f(blurred, 0, 0, 1)); }); const down = tgpu.computeFn({ workgroupSize: [WORKGROUP, WORKGROUP], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { 'use gpu'; if (gid.x >= size || gid.y >= size) return; const cell = d.vec2i(gid.xy); const here = std.textureLoad(spareReading.$, cell, 0).x; const up = std.textureLoad(spareReading.$, inside(cell - d.vec2i(0, 1)), 0).x; const below = std.textureLoad(spareReading.$, inside(cell + d.vec2i(0, 1)), 0).x; const blurred = std.mix(here, (up + here * 2 + below) / 4, spread.$.blur); const index = gid.y * size + gid.x; const fresh = d.f32(std.atomicLoad(dropped.$[index]!)) / PARTS; std.atomicStore(dropped.$[index]!, 0); std.textureStore(writing.$, gid.xy, d.vec4f(blurred * spread.$.keep + fresh, 0, 0, 1)); }); const blurringAcross = root.createComputePipeline({ compute: across }); const blurringDown = root.createComputePipeline({ compute: down }); const groups = Math.ceil(size / WORKGROUP); return { /** Grid width and height in cells. */ size, deposit, at, initAsync: () => Promise.all([blurringAcross.initAsync(), blurringDown.initAsync()]), set: (values: d.InferInput<typeof Spread>) => spread.write(values), spread(pass: TgpuComputePass) { pass.setPipeline(blurringAcross); pass.dispatchWorkgroups(groups, groups); pass.setPipeline(blurringDown); pass.dispatchWorkgroups(groups, groups); }, clear() { trail.clear(); dropped.buffer.clear(); }, destroy() { trail.destroy(); spare.destroy(); dropped.buffer.destroy(); }, };}export type Trail = ReturnType<typeof createTrail>;veins.ts
import { pipelines } from '@typegpu/kit';import { common, d, std, tgpu, type TgpuRenderCommands, type TgpuRoot, type TgpuUniform,} from 'typegpu';import { hex, sheetAt, World } from './agar.ts';import type { Oats } from './oats.ts';import type { Trail } from './trail.ts';/** Colour ramp for trail density, from faint threads to dense hubs. */const CRIMSON = hex(0x3a0610);const RED = hex(0xb0201a);const ORANGE = hex(0xff7a2a);const GOLD = hex(0xffe0a0);const FOOD = hex(0xffe8b8);const HALO = hex(0xc8803a);/** * Trail density at the top of the colour ramp. The tone curve compresses the density range while * preserving fine threads and bright hubs. */export const FULL = 18;/** Gain for faint trails in the tone curve. */export const GAIN = 0.6;/** Pulse period in seconds. */const PERIOD = 3.5;/** Waves between the centre and the edge at once. */const WAVES = 1.6;/** How bright the mould is and how strongly it pulses, each 0 to 2. */export const Look = d.struct({ light: d.f32, pulse: d.f32 });/** Maps tone from 0 to 1 to a colour. Values above 1 exceed white. */export const ramp = (tone: number) => { 'use gpu'; let color = CRIMSON * std.smoothstep(0, 0.25, tone); color = std.mix(color, RED, std.smoothstep(0.2, 0.5, tone)); color = std.mix(color, ORANGE, std.smoothstep(0.45, 0.8, tone)); color = std.mix(color, GOLD, std.smoothstep(0.75, 1, tone)); // Values above white let bloom pick up dense hubs and thick trails return color * (1 + 1.2 * std.smoothstep(0.85, 1.2, tone));};/** * Maps trail density through a tone curve and colour ramp, then adds it over the agar with * glowing food. */export function createVeins( root: TgpuRoot, world: TgpuUniform<typeof World>, inputs: { trail: Trail; oats: Oats }, format: GPUTextureFormat,) { const { trail, oats } = inputs; const look = root.createUniform(Look, { light: 1, pulse: 1 }); const veins = tgpu.fragmentFn({ in: { pixel: d.builtin.position }, out: d.vec4f })(({ pixel, }) => { 'use gpu'; const at = sheetAt(pixel.xy, world.$.size); const uv = at * 0.5 + 0.5; const glow = oats.glowAt(uv); const food = FOOD * (glow.x * 3) + HALO * (glow.y * 0.008); // Average nearby cells to hide grain from individual agents const reach = 0.75 / d.f32(trail.size); const density = (trail.at(uv) * 2 + trail.at(uv + d.vec2f(reach, 0)) + trail.at(uv - d.vec2f(reach, 0)) + trail.at(uv + d.vec2f(0, reach)) + trail.at(uv - d.vec2f(0, reach))) / 6; const tone = std.log(1 + density * GAIN) / std.log(1 + FULL * GAIN); // Outward waves of brightness imitate shuttle streaming const swirl = 0.15 * std.sin(std.atan2(at.y, at.x) * 3 + std.length(at) * 5); const phase = world.$.time / PERIOD - std.length(at) * WAVES + swirl; const wave = std.sin(phase * Math.PI * 2) * std.smoothstep(0.55, 0.9, tone); const pulse = 1 + 0.4 * look.$.pulse * wave; return d.vec4f(ramp(tone) * (look.$.light * pulse) + food, 0); }); const drawing = root.createRenderPipeline({ vertex: common.fullScreenTriangle, fragment: veins, targets: { format, blend: pipelines.blend.add }, }); return { look, initAsync: () => drawing.initAsync(), draw(pass: TgpuRenderCommands) { pass.setPipeline(drawing); pass.draw(3); }, };}