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

  1. scene.tsCreates the scene: its resources, its settings and what each frame records.
  2. agents.tsMoves Physarum agents along a trail.
  3. agar.tsDraws the agar dish over the whole picture, darkening towards its edges.
  4. oats.tsPaints food into a texture matching the trail grid when flakes change.
  5. trail.tsStores trail density in a square grid.
  6. 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);    },  };}