Falling sand

The GPU steps sand, water, lava, wood and crystal cell by cell. Lava, fire, crystals and particle embers light the cave through @typegpu/radiance-cascades. Water tints the light, and walls and steam reflect it. Crystals dim as sand buries them. Bloom adds glow, and events count cooled lava and burning wood.

Files, in reading order

  1. scene.tsCreates the scene: its resources, its settings and what each frame records.
  2. cells.tsStores cells in rows from the top left and material traits indexed by id.
  3. rules.tsMoves cells within disjoint 2 × 2 blocks.
  4. cave.tsInitialises the cave and paints pointer strokes.
  5. embers.tsEmits sparks from lava and burning wood.
  6. densities.tsBlurs steam and smoke density and fills gaps in falling water, lava and sand.
  7. light.tsBuilds a holographic radiance field from cells, embers and reflected light from walls and steam in the last field update.
  8. drawing.tsDraws one cell per pixel with lit solid faces, a far wall, scattered light, tinted water and emissive materials.

Kit modules bloom, cameras, clock, events, finish, owner, particles, passes, pipelines, targets, tone. Also @typegpu/noise, @typegpu/radiance-cascades/holographic, @typegpu/sdf, typegpu.

scene.ts

import {  cameras,  clock,  events,  owner,  passes,  pipelines,  targets,  type Frame,  type Surface,  bloom,  finish,} from '@typegpu/kit';import { d, std, type TgpuRoot } from 'typegpu';import type { Pointing } from '../contract.ts';import type { Settings } from '../settings.ts';import { createCells, HEIGHT, WIDTH } from './cells.ts';import { createRules } from './rules.ts';import { createCave, PAINTS, type Paint } from './cave.ts';import { createEmbers } from './embers.ts';import { createDensities } from './densities.ts';import { createLight } from './light.ts';import { createDrawing, type View } from './drawing.ts';const STEP_SECONDS = 1 / 150;const FIELD_HZ = 30;/** Field updates after an edit, so light reflected off the walls settles. */export const SETTLE_UPDATES = 12;const COUNTERS_HZ = 4;/** Simulation warm-up in seconds. Opens the cave with springs already pouring. */const PREWARM_SECONDS = 3;const PREWARM_CHUNK = 30;/** Render pixels per cell. Higher resolution lets vapour move smoothly over discrete cells. */const PICTURE_SCALE = 4;const PICTURE = { width: WIDTH * PICTURE_SCALE, height: HEIGHT * PICTURE_SCALE };const FIELD = { width: WIDTH, height: HEIGHT };const ASPECT = WIDTH / HEIGHT;const LOOK = { exposure: 1, glowIntensity: 0.2, vignette: 0.3 };/** The diagnostic views' finish, without vignette or dither. */const PLAIN = { vignette: 0, dither: 0 };/** Where the tone curve reaches white, in linear radiance. */const WHITE = 5;const BLOOM = { levels: 5, settings: { threshold: 0.85 } };/** Targets that later passes sample. */const SAMPLED = { format: 'rgba16float', sampled: true } as const;/** Per-channel Reinhard with a white point. Keeps dark colours and turns bright lava yellow. */const reinhard = (color: d.v3f) => {  'use gpu';  return std.clamp((color * (1 + color / (WHITE * WHITE))) / (1 + color), d.vec3f(), d.vec3f(1));};const defaults = {  paint: 'sand' as Paint,  /** Brush radius in cells. */  brush: 4,  springs: true,  /** Feeds light reflected off walls and steam back into the next field update. */  bounce: true,  /** Displays the cave, linear radiance or field medium. */  view: 'cave' as View,  /** Pauses cells and embers. Edits still update the light field. */  paused: false,};/** Sand, water, lava, wood and crystal stepped cell by cell, lit through a radiance field. */export async function createScene(root: TgpuRoot, surface: Surface) {  const values = { ...defaults };  const own = owner();  const counts = own(events.create(root, ['cooled', 'burnt'], { hz: COUNTERS_HZ }));  const cells = own(createCells(root));  const cave = own(createCave(root, cells));  const view = cameras.flat(root, {    size: PICTURE,    height: HEIGHT,    center: [WIDTH / 2, HEIGHT / 2],  });  const sim = clock.fixedStep({ step: STEP_SECONDS });  const cadence = clock.cadence({ hz: FIELD_HZ });  const seconds = root.createUniform(d.f32);  const { sparks: embers, spark } = createEmbers(root, {    camera: view,    step: STEP_SECONDS,    cells,    counts,  });  own(embers);  const rules = createRules(root, { cells, counts, spark });  const glows = own(targets.create(root, FIELD, { emission: SAMPLED }));  const emission = passes    .create(root, glows, { emission: passes.color }, { clear: [0, 0, 0, 0] })    .pipe(view.inject());  const emitting = emission.add({ ...embers.drawing, label: 'ember light' });  const densities = own(createDensities(root, { cells }));  const light = own(    createLight(root, {      cells,      densities,      embers: glows.textures.emission,      seconds,      bounce: values.bounce,    }),  );  const screen = own(targets.create(root, PICTURE, { hdr: SAMPLED }));  const drawing = createDrawing(root, { cells, light, densities, seconds });  const scene = passes.create(root, screen, { hdr: passes.color }).pipe(view.inject());  const cavern = scene.add(drawing.drawing);  const sparks = scene.add(embers.drawing);  const hdr = () => screen.textures.hdr;  const glow = own(bloom.create(root, { source: hdr, ...BLOOM }));  const finalOptions = { format: surface.format, source: hdr, filter: 'sharp' } as const;  const final = own(finish.create(root, { ...finalOptions, tone: reinhard, glow, settings: LOOK }));  const diagnostic = own(finish.create(root, { ...finalOptions, tone: null, settings: PLAIN }));  let restarting = true;  let settling = SETTLE_UPDATES;  const settle = () => {    settling = values.bounce ? SETTLE_UPDATES : 1;  };  const viewport = { x: 0, y: 0, width: surface.width, height: surface.height };  const fit = () => {    viewport.width = Math.min(surface.width, surface.height * ASPECT);    viewport.height = viewport.width / ASPECT;    viewport.x = (surface.width - viewport.width) / 2;    viewport.y = (surface.height - viewport.height) / 2;  };  fit();  let previous: readonly [number, number] | undefined;  const pointing = {    get reach() {      return (values.brush * viewport.height) / (HEIGHT * surface.pixelRatio);    },    drag(x, y, start) {      if (start) previous = undefined;      fit();      const across = (x * surface.width - viewport.x) / viewport.width;      const down = (y * surface.height - viewport.y) / viewport.height;      if (across < 0 || across > 1 || down < 0 || down > 1) {        previous = undefined;        return;      }      const at: readonly [number, number] = [across * WIDTH, down * HEIGHT];      cave.stroke(previous ?? at, at, values.brush, values.paint);      previous = at;    },  } satisfies Pointing;  const settings: Settings<typeof values> = {    values,    knobs: {      paint: { options: PAINTS },      brush: { label: 'brush radius (cells)', min: 1, max: 16, step: 1 },      springs: {},      bounce: { label: 'reflected light' },      view: { options: ['cave', 'radiance', 'medium'] },      paused: {},    },    write(key) {      if (key === undefined || key === 'paused') sim.paused = values.paused;      if (key === undefined || key === 'springs') rules.springs(values.springs);      if (key === undefined || key === 'bounce') {        light.bounce(values.bounce);        settle();      }      if (key === undefined || key === 'view') drawing.view(values.view);    },    actions: {      reset() {        restarting = true;        previous = undefined;        cave.reset();      },    },    readouts: {      'lava cooled': () => counts.latest?.cooled ?? 0,      'wood set alight': () => counts.latest?.burnt ?? 0,    },  };  await pipelines.compile([    cave,    cells,    densities,    rules,    embers,    light,    scene,    emission,    glow,    final,    diagnostic,  ]);  const warm = root.createCommandEncoder();  cave.record(warm);  warm.submit();  for (let left = PREWARM_SECONDS / STEP_SECONDS; left > 0; left -= PREWARM_CHUNK) {    const chunk = root.createCommandEncoder();    const pass = chunk.beginComputePass();    for (let i = 0; i < PREWARM_CHUNK; i++) {      rules.step(pass);      embers.step(pass);    }    pass.end();    chunk.submit();  }  return {    settings,    pointing,    get settling() {      return settling > 0;    },    frame(frame: Frame) {      view.update(frame.dt);      fit();      if (restarting) {        counts.clear(frame.encoder);        embers.clear(frame.encoder);        restarting = false;        settle();      }      if (cave.record(frame.encoder)) settle();      const steps = sim.advance(frame.dt);      if (steps > 0) {        seconds.write(sim.time);      }      const compute = frame.encoder.beginComputePass();      for (let i = 0; i < steps; i++) {        rules.step(compute);        embers.step(compute);      }      embers.spawn(compute);      embers.compact(compute, sim.ahead);      cells.shape(compute);      densities.measure(compute);      compute.end();      counts.capture(frame);      const due = cadence.due(frame.dt);      if (settling > 0 || (!values.paused && due)) {        emission.record(frame, emitting);        const gathering = frame.encoder.beginComputePass();        light.solve(gathering);        gathering.end();        settling = Math.max(0, settling - 1);      }      const pass = scene.begin(frame);      cavern.draw(pass);      if (values.view === 'cave') sparks.draw(pass);      pass.end();      if (values.view === 'cave') glow.record(frame);      const output = values.view === 'cave' ? final : diagnostic;      output.record(frame, surface.view(frame), { viewport });    },    destroy: own.destroy,  };}

cells.ts

import { byStage, owner, unfilled, type Inject } from '@typegpu/kit';import { randf } from '@typegpu/noise';import { d, std, tgpu, type TgpuComputePass, type TgpuRoot } from 'typegpu';export const WIDTH = 320;export const HEIGHT = 180;const CELLS = WIDTH * HEIGHT;/** The grid's size in cells, which divides cell coordinates into texture coordinates. */export const GRID = d.vec2f(WIDTH, HEIGHT);export const WORKGROUP = 8;/** Workgroups that cover the grid, one thread a cell. */export const GROUPS = { x: Math.ceil(WIDTH / WORKGROUP), y: Math.ceil(HEIGHT / WORKGROUP) };/** * A compute entry point over a two-dimensional grid of threads, `WORKGROUP` square. Give it a * named function, as in `gridKernel(function step({ gid }) {…})`, to name the entry point in WGSL. */export const gridKernel = tgpu.computeFn({  workgroupSize: [WORKGROUP, WORKGROUP],  in: { gid: d.builtin.globalInvocationId },});/** What a cell holds, in its low byte. */export const Matter = {  empty: 0,  rock: 1,  sand: 2,  water: 3,  lava: 4,  obsidian: 5,  wood: 6,  burning: 7,  fire: 8,  smoke: 9,  steam: 10,  crystal: 11,  ash: 12,  spring: 13,  drain: 14,} as const;/** Movement categories for fixed solids, powders, liquids and gases. */export const Kind = { fixed: 0, powder: 1, liquid: 2, gas: 3 } as const;/** Per-cell extinction threshold for treating a cell as solid. */export const OPAQUE = 4;/** Search depth for nearby open cells, in cells. */export const RELIEF_CELLS = 5;export const Material = d.struct({  /** Linear colour where light falls on it. */  albedo: d.vec3f,  /** Heavier cells sink through lighter ones. Fixed cells never move. */  density: d.f32,  /** Linear radiance it gives off. */  glow: d.vec3f,  /** Per-step probability of falling, or rising for gases, when space permits. */  fall: d.f32,  /** Extinction per cell and colour channel for the radiance field. */  extinction: d.vec3f,  /** Per-step probability of sideways movement. */  flow: d.f32,  /** Per-step probability of lifetime decay. */  decay: d.f32,  kind: d.u32,});type Traits = d.InferInput<typeof Material>;const solid = [OPAQUE, OPAQUE, OPAQUE] as const;const none = [0, 0, 0] as const;const FIXED = 100;const BASALT = [0.17, 0.16, 0.22] as const;const material = (traits: Partial<Traits> & Pick<Traits, 'kind'>): Traits => ({  albedo: none,  density: 1,  glow: none,  fall: 0,  extinction: solid,  flow: 0,  decay: 0,  ...traits,});const TRAITS: Record<keyof typeof Matter, Traits> = {  empty: material({ kind: Kind.gas, extinction: [0.004, 0.0045, 0.005] }),  rock: material({ kind: Kind.fixed, density: FIXED, albedo: BASALT }),  sand: material({ kind: Kind.powder, density: 5, fall: 0.8, albedo: [0.86, 0.68, 0.38] }),  water: material({    kind: Kind.liquid,    density: 3,    fall: 1,    flow: 1,    albedo: [0.05, 0.34, 0.36],    extinction: [0.24, 0.06, 0.05],  }),  lava: material({    kind: Kind.liquid,    density: 4,    fall: 1,    flow: 1,    albedo: [0.3, 0.06, 0.02],    glow: [3.6, 1.05, 0.14],  }),  obsidian: material({ kind: Kind.powder, density: 3.5, fall: 1, albedo: [0.13, 0.1, 0.2] }),  wood: material({ kind: Kind.fixed, density: FIXED, albedo: [0.36, 0.2, 0.09] }),  burning: material({    kind: Kind.fixed,    density: FIXED,    decay: 0.1,    albedo: [0.06, 0.025, 0.012],    glow: [2.6, 0.62, 0.08],  }),  fire: material({    kind: Kind.gas,    density: 0.6,    fall: 0.5,    flow: 0.5,    decay: 1,    glow: [4, 1.5, 0.22],    extinction: [0.5, 0.5, 0.5],  }),  smoke: material({    kind: Kind.gas,    density: 0.5,    fall: 0.4,    flow: 0.5,    decay: 0.25,    albedo: [0.2, 0.18, 0.18],    extinction: [0.25, 0.25, 0.25],  }),  steam: material({    kind: Kind.gas,    density: 0.3,    fall: 0.6,    flow: 0.7,    decay: 0.18,    albedo: [0.75, 0.78, 0.82],    extinction: [0.04, 0.04, 0.04],  }),  crystal: material({    kind: Kind.fixed,    density: FIXED,    albedo: [0.2, 0.5, 0.6],    glow: [0.25, 1.9, 2.6],    extinction: [2, 2, 2],  }),  ash: material({    kind: Kind.powder,    density: 1.5,    fall: 0.35,    flow: 0.2,    albedo: [0.22, 0.21, 0.2],  }),  spring: material({ kind: Kind.fixed, density: FIXED, albedo: BASALT }),  drain: material({ kind: Kind.fixed, density: FIXED, albedo: BASALT }),};/** Material traits indexed by `Matter` id, as `TRAITS` lists them in its order. */export const MATERIALS = Object.values(TRAITS);const Grid = d.arrayOf(d.u32, CELLS);const Table = d.arrayOf(Material, MATERIALS.length);/** Lifetime a painted fire cell starts with, in decay steps. Fire fades as it ages. */export const FIRE_LIFE = 40;/** Lifetime of wood that paint or an ember sets alight, in decay steps. */export const BURN_LIFE = 255;const SHADE_SHIFT = 8;const LIFE_SHIFT = 16;const FLOW_SHIFT = 24;export const LIFE_ONE = 1 << LIFE_SHIFT;const FLOW_BIT = 1 << FLOW_SHIFT;/** Packs a cell from its four bytes, low to high: matter, shade, life and flow. */const pack = (matter: number, shade: number, life: number, flow: number) => {  'use gpu';  return (    d.u32(matter) |    ((d.u32(shade) & 0xff) << SHADE_SHIFT) |    ((d.u32(life) & 0xff) << LIFE_SHIFT) |    (d.u32(flow) << FLOW_SHIFT)  );};/** Creates a cell with material lifetime, random shade and random flow direction. */export const cellOf = (matter: number, life: number) => {  'use gpu';  const shade = d.u32(randf.sample() * 256);  const right = d.u32(randf.sample() * 2);  return pack(matter, shade, life, right);};/** Creates a rock cell with `shade` from 0 to 255. */export const rockOf = (shade: number) => {  'use gpu';  return pack(Matter.rock, shade, 0, 0);};export const matterOf = (cell: number) => {  'use gpu';  return cell & 0xff;};/** Random shade from 0 to 1, fixed at birth. */export const shadeOf = (cell: number) => {  'use gpu';  return d.f32((cell >>> SHADE_SHIFT) & 0xff) / 255;};export const lifeOf = (cell: number) => {  'use gpu';  return (cell >>> LIFE_SHIFT) & 0xff;};/** * Creates a spring emitting `pours` with a per-step probability in units of 1/255. Keeps the * shade of `rock`, the rock cell it replaces. */export const springOf = (pours: number, chance: number, rock: number) => {  'use gpu';  return pack(Matter.spring, rock >>> SHADE_SHIFT, pours, chance);};/** * Creates a drain for touching grains and liquids. `water` enables draining water. Probability * is per step in units of 1/255. Keeps the shade of `rock`, the rock cell it replaces. */export const drainOf = (chance: number, water: boolean, rock: number) => {  'use gpu';  return pack(Matter.drain, rock >>> SHADE_SHIFT, chance, d.u32(water));};export const drainsWater = (cell: number) => {  'use gpu';  return cell >>> FLOW_SHIFT !== 0;};/** Creates a crystal cell with facet shade and base-to-tip progress from 0 to 1. */export const crystalOf = (facet: number, along: number) => {  'use gpu';  return pack(Matter.crystal, std.saturate(facet) * 255, std.saturate(along) * 255, 0);};/** Spring emission probability per step, from 0 to 1. */export const pourOf = (cell: number) => {  'use gpu';  return d.f32(cell >>> FLOW_SHIFT) / 255;};export const rightward = (cell: number) => {  'use gpu';  return (cell & FLOW_BIT) !== 0;};export const turned = (cell: number) => {  'use gpu';  return cell ^ FLOW_BIT;};/** Whether `p` lies in the grid. */const inside = (p: d.v2i) => {  'use gpu';  return p.x >= 0 && p.y >= 0 && p.x < WIDTH && p.y < HEIGHT;};/** A grid-sized texture that a kernel writes and later passes read. */export const gridTexture = (root: TgpuRoot) => {  const texture = root    .createTexture({ size: [WIDTH, HEIGHT], format: 'rgba16float' })    .$usage('storage', 'sampled');  return {    writing: texture.createView(d.textureStorage2d('rgba16float', 'write-only')),    reading: texture.createView(d.texture2d(d.f32)),    destroy: () => texture.destroy(),  };};/** Stores cells in rows from the top left and material traits indexed by id. */export function createCells(root: TgpuRoot) {  const own = owner();  const buffer = own(root.createBuffer(Grid).$usage('storage'));  const mutable = buffer.as('mutable');  const table = root.createReadonly(Table, MATERIALS);  const grid = tgpu.accessor(Grid, unfilled('the cells', 'cells', 'createCells'));  const writable = tgpu.mutableAccessor(Grid, unfilled('the cells', 'cells', 'createCells'));  const materials = tgpu.accessor(Table, unfilled('the materials', 'cells', 'createCells'));  const inject: Inject = (cfg) =>    cfg.with(grid, byStage(buffer)).with(writable, mutable).with(materials, table);  /** Reads a cell by column and row. Returns rock outside the grid. */  const cellAt = (p: d.v2i) => {    'use gpu';    if (!inside(p)) return d.u32(Matter.rock);    return grid.$[d.u32(p.y) * WIDTH + d.u32(p.x)]!;  };  /** Writes a cell from a compute shader. Ignores coordinates outside the grid. */  const put = (p: d.v2i, cell: number) => {    'use gpu';    if (!inside(p)) return;    writable.$[d.u32(p.y) * WIDTH + d.u32(p.x)] = cell;  };  const traitsOf = (cell: number) => {    'use gpu';    return Material(materials.$[matterOf(cell)]!);  };  const matterAt = (p: d.v2i) => {    'use gpu';    return matterOf(cellAt(p));  };  const kindAt = (p: d.v2i) => {    'use gpu';    return traitsOf(cellAt(p)).kind;  };  /** Tests solid-cell extinction, including rock and sand but excluding water and air. */  const opaqueAt = (p: d.v2i) => {    'use gpu';    return materials.$[matterAt(p)]!.extinction.x >= OPAQUE;  };  /** Direction and distance from each solid cell to open cells nearby, for lighting solid rims. */  const relief = own(gridTexture(root));  const shape = gridKernel(function shape({ gid }) {    'use gpu';    if (gid.x >= WIDTH || gid.y >= HEIGHT) return;    const at = d.vec2i(gid.xy);    if (!opaqueAt(at)) {      std.textureStore(relief.writing.$, gid.xy, d.vec4f());      return;    }    let depth = d.f32(RELIEF_CELLS + 1);    let out = d.vec2f();    for (let y = -RELIEF_CELLS; y <= RELIEF_CELLS; y++) {      for (let x = -RELIEF_CELLS; x <= RELIEF_CELLS; x++) {        if (opaqueAt(at + d.vec2i(x, y))) continue;        const offset = d.vec2f(d.f32(x), d.f32(y));        depth = std.min(depth, d.f32(std.max(std.abs(x), std.abs(y))));        out += offset / std.dot(offset, offset);      }    }    let normal = d.vec2f();    if (std.dot(out, out) > 0) normal = std.normalize(out);    std.textureStore(relief.writing.$, gid.xy, d.vec4f(normal, depth, 0));  });  const shaping = root.pipe(inject).createComputePipeline({ compute: shape });  /**   * Returns direction to nearby open cells in `xy` and distance in `z`. Distance is 1 at the   * surface, up to `RELIEF_CELLS + 1` inside, and 0 in open cells.   */  const reliefAt = (p: d.v2i) => {    'use gpu';    return std.textureLoad(relief.reading.$, p, 0);  };  return {    inject: () => inject,    cellAt,    put,    traitsOf,    matterAt,    kindAt,    opaqueAt,    reliefAt,    initAsync: () => shaping.initAsync(),    /** Recomputes relief after cells move. */    shape(pass: TgpuComputePass) {      pass.setPipeline(shaping);      pass.dispatchWorkgroups(GROUPS.x, GROUPS.y);    },    destroy: own.destroy,  };}export type Cells = ReturnType<typeof createCells>;

rules.ts

import type { Events } from '@typegpu/kit';import { randf } from '@typegpu/noise';import { d, std, tgpu, type TgpuComputePass, type TgpuRoot } from 'typegpu';import {  BURN_LIFE,  cellOf,  type Cells,  drainsWater,  FIRE_LIFE,  gridKernel,  HEIGHT,  Kind,  LIFE_ONE,  lifeOf,  Matter,  matterOf,  pourOf,  rightward,  turned,  WIDTH,  WORKGROUP,} from './cells.ts';/** 2 × 2 block grid with one extra block per axis for shifted steps. */const BLOCKS_X = WIDTH / 2 + 1;const BLOCKS_Y = HEIGHT / 2 + 1;/** Rows per parity. */const ROWS = HEIGHT / 2;/** Workgroups over the blocks, and over one parity's rows. */const BLOCK_GROUPS = { x: Math.ceil(BLOCKS_X / WORKGROUP), y: Math.ceil(BLOCKS_Y / WORKGROUP) };const ROW_GROUPS = Math.ceil(ROWS / WORKGROUP);/** Liquid-flow passes per simulation step. Each covers both row parities. */const FLOW_PASSES = 3;/** Probability of reversing behind another liquid. A low value preserves flow direction. */const TURNS = 0.1;/** Per-step probability of sinking through liquid or non-air gas. */const SINK = 0.35;/** Per-step probability of a falling liquid drifting sideways by one cell. */const SPRAY = 0.05;/** Sideways-drift multiplier for gas with nothing above it. */const LOOSE_DRIFT = 0.3;const LAVA_IGNITES = 0.05;const FIRE_SPREADS = 0.006;const EMBER_SPREADS = 0.002;/** Per-step probabilities of lava melting sand and obsidian on contact. */const MELTS_SAND = 0.03;const MELTS_OBSIDIAN = 0.01;const DOUSED = 0.08;const FLAMES = 0.04;const SMOKES = 0.3;const CONDENSES = 0.35;const CHARS = 0.4;/** Chance a step that a lava surface or a burning cell spits an ember. */const SPITS = 0.0025;/** Per-step probability of young steam spreading sideways into air and halving its lifetime. */const BILLOWS = 0.4;/** Steam lifetime threshold for spreading, in decay steps. */const YOUNG_STEAM = 90;/** Minimum and maximum lifetimes in decay steps. */const STEAM_LIFE = { least: 60, most: 170 } as const;const SMOKE_LIFE = { least: 80, most: 200 } as const;const FLAME_LIFE = { least: 12, most: FIRE_LIFE } as const;const KINDLED_LIFE = { least: 180, most: BURN_LIFE } as const;const Controls = d.struct({ springs: d.u32 });const between = tgpu.fn(  [d.f32, d.f32],  d.f32,)((least, most) => {  'use gpu';  return least + randf.sample() * (most - least);});/** * Moves cells within disjoint 2 × 2 blocks. Shifts the blocks between steps so threads never * write overlapping cells and all neighbour pairs share a block within four steps. */export function createRules(  root: TgpuRoot,  options: {    readonly cells: Cells;    readonly counts: Pick<Events<'cooled' | 'burnt'>, 'add' | 'inject'>;    /** Emits from a cell in y-down coordinates, with `heat` from 0 to 1. */    readonly spark: (at: d.v2f, heat: number) => void;  },) {  const { cells, counts, spark } = options;  const tick = root.createMutable(d.u32);  const controls = root.createUniform(Controls, { springs: 1 });  const ignite = () => {    'use gpu';    counts.add.burnt(1);    return cellOf(Matter.burning, between(KINDLED_LIFE.least, KINDLED_LIFE.most));  };  /** Contact reaction for `a` and `b`. */  const act = (a: number, b: number) => {    'use gpu';    const actor = matterOf(a);    const other = matterOf(b);    let first = d.u32(a);    let second = d.u32(b);    switch (actor) {      case Matter.lava:        if (other === Matter.water) {          first = cellOf(Matter.obsidian, 0);          second = cellOf(Matter.steam, between(STEAM_LIFE.least, STEAM_LIFE.most));          counts.add.cooled(1);        } else if (other === Matter.wood && randf.sample() < LAVA_IGNITES) {          second = ignite();        } else if (other === Matter.sand && randf.sample() < MELTS_SAND) {          second = d.u32(Matter.empty);        } else if (other === Matter.obsidian && randf.sample() < MELTS_OBSIDIAN) {          second = cellOf(Matter.lava, 0);        }        break;      case Matter.burning:      case Matter.fire: {        const burning = actor === Matter.burning;        if (other === Matter.wood) {          const spreading = burning ? EMBER_SPREADS : FIRE_SPREADS;          if (randf.sample() < spreading) second = ignite();        } else if (other === Matter.water) {          if (randf.sample() < DOUSED) {            if (burning) first = cellOf(Matter.wood, 0);            else first = cellOf(Matter.steam, between(STEAM_LIFE.least, STEAM_LIFE.most));            second = cellOf(Matter.steam, between(STEAM_LIFE.least, STEAM_LIFE.most));          }        } else if (burning && other === Matter.empty && randf.sample() < FLAMES) {          second = cellOf(Matter.fire, between(FLAME_LIFE.least, FLAME_LIFE.most));        }        break;      }      case Matter.spring: {        const pours = lifeOf(a);        const open = cells.traitsOf(b).kind === Kind.gas;        if (open && controls.$.springs !== 0 && randf.sample() < pourOf(a)) {          second = cellOf(pours, 0);        } else if (pours === Matter.lava && other === Matter.obsidian) {          // Melt cooled crust around the lava spout to keep it open          second = cellOf(Matter.lava, 0);        }        break;      }      case Matter.steam: {        const life = lifeOf(a);        if (other === Matter.empty && life > YOUNG_STEAM && randf.sample() < BILLOWS) {          first = cellOf(Matter.steam, life / 2);          second = cellOf(Matter.steam, life / 2);        }        break;      }      case Matter.drain: {        const kind = cells.traitsOf(b).kind;        const takes = kind === Kind.powder || (kind === Kind.liquid && other !== Matter.water);        const seeps = d.f32(lifeOf(a)) / 255;        if ((takes || (other === Matter.water && drainsWater(a))) && randf.sample() < seeps) {          second = d.u32(Matter.empty);        }        break;      }    }    return d.vec2u(first, second);  };  /** Tests contact reactions in both directions. */  const react = (a: number, b: number) => {    'use gpu';    const once = act(a, b);    const twice = act(once.y, once.x);    return d.vec2u(twice.y, twice.x);  };  /** Reacts the cells in a block's `x` and `y`, then those in its `z` and `w`. */  const reactPairs = (block: d.v4u) => {    'use gpu';    return d.vec4u(react(block.x, block.y), react(block.z, block.w));  };  /** Decays a cell's lifetime and transforms expired cells. */  const age = (cell: number) => {    'use gpu';    const decay = cells.traitsOf(cell).decay;    if (decay === 0 || randf.sample() >= decay) return d.u32(cell);    if (lifeOf(cell) > 1) return cell - LIFE_ONE;    switch (matterOf(cell)) {      case Matter.fire:        if (randf.sample() < SMOKES) {          return cellOf(Matter.smoke, between(SMOKE_LIFE.least, SMOKE_LIFE.most));        }        break;      case Matter.steam:        if (randf.sample() < CONDENSES) return cellOf(Matter.water, 0);        break;      case Matter.burning:        if (randf.sample() < CHARS) return cellOf(Matter.ash, 0);        break;    }    return d.u32(Matter.empty);  };  const isLiquid = (cell: number) => {    'use gpu';    return cells.traitsOf(cell).kind === Kind.liquid;  };  const isVapour = (cell: number) => {    'use gpu';    return cells.traitsOf(cell).kind === Kind.gas && matterOf(cell) !== Matter.empty;  };  const rests = (cell: number, below: number) => {    'use gpu';    const under = cells.traitsOf(below);    return under.kind === Kind.fixed || under.density >= cells.traitsOf(cell).density;  };  const capped = (cell: number, above: number) => {    'use gpu';    const over = cells.traitsOf(above);    return over.kind === Kind.fixed || over.density <= cells.traitsOf(cell).density;  };  /** Tests sideways liquid movement into lighter movable material. */  const yields = (mover: number, target: number) => {    'use gpu';    const into = cells.traitsOf(target);    return into.kind !== Kind.fixed && into.density < cells.traitsOf(mover).density;  };  /** Reverses a blocked liquid at walls, or with low probability behind other liquid. */  const turns = (ahead: number) => {    'use gpu';    return cells.traitsOf(ahead).kind === Kind.fixed || randf.sample() < TURNS;  };  /** Tests vertical exchange. Heavier matter sinks, and gas rises through air. */  const sinks = (top: number, bottom: number) => {    'use gpu';    const upper = cells.traitsOf(top);    const lower = cells.traitsOf(bottom);    if (upper.kind === Kind.fixed || lower.kind === Kind.fixed) return false;    if (upper.density <= lower.density) return false;    let chance = d.f32(SINK);    if (matterOf(bottom) === Matter.empty) chance = upper.fall;    else if (matterOf(top) === Matter.empty) chance = lower.fall;    return randf.sample() < chance;  };  /** Tests a falling liquid drifting one cell sideways as it falls. */  const sprays = (mover: number, below: number, target: number) => {    'use gpu';    if (!isLiquid(mover)) return false;    if (matterOf(below) !== Matter.empty || matterOf(target) !== Matter.empty) return false;    return randf.sample() < SPRAY;  };  /** Tests a grain or liquid sliding down past `beside` into `target`. */  const slides = (mover: number, below: number, target: number, beside: number) => {    'use gpu';    const moving = cells.traitsOf(mover);    if (moving.kind !== Kind.powder && moving.kind !== Kind.liquid) return false;    const into = cells.traitsOf(target);    if (into.kind === Kind.fixed || into.density >= moving.density) return false;    const under = cells.traitsOf(below);    if (under.kind !== Kind.fixed && under.density < moving.density) return false;    if (under.kind === Kind.fixed && cells.traitsOf(beside).kind === Kind.fixed) return false;    return randf.sample() < moving.fall;  };  /** Tests a gas rising past `beside` into `target`. */  const rises = (mover: number, above: number, target: number, beside: number) => {    'use gpu';    const moving = cells.traitsOf(mover);    if (moving.kind !== Kind.gas || matterOf(mover) === Matter.empty) return false;    const into = cells.traitsOf(target);    if (into.kind === Kind.fixed || into.density <= moving.density) return false;    const over = cells.traitsOf(above);    if (over.kind !== Kind.fixed && over.density > moving.density) return false;    if (over.kind === Kind.fixed && cells.traitsOf(beside).kind === Kind.fixed) return false;    return randf.sample() < moving.fall;  };  /**   * Moves supported liquids in the top row along their flow directions, reversing where   * blocked.   */  const pour = (left: number, right: number, belowLeft: number, belowRight: number) => {    'use gpu';    let l = d.u32(left);    let r = d.u32(right);    if (isLiquid(l) && rightward(l) && rests(l, belowLeft)) {      if (!yields(l, r)) {        if (turns(r)) l = turned(l);      } else if (randf.sample() < cells.traitsOf(l).flow) return d.vec2u(r, l);    }    if (isLiquid(r) && !rightward(r) && rests(r, belowRight)) {      if (!yields(r, l)) {        if (turns(l)) r = turned(r);      } else if (randf.sample() < cells.traitsOf(r).flow) return d.vec2u(r, l);    }    return d.vec2u(l, r);  };  /** Drifts bottom-row gas into air, mainly when the row above traps it. */  const drift = (left: number, right: number, aboveLeft: number, aboveRight: number) => {    'use gpu';    const towardsRight = randf.sample() < 0.5;    const mover = std.select(right, left, towardsRight);    const target = std.select(left, right, towardsRight);    const above = std.select(aboveRight, aboveLeft, towardsRight);    if (!isVapour(mover) || matterOf(target) !== Matter.empty) return d.vec2u(left, right);    let chance = cells.traitsOf(mover).flow;    if (!capped(mover, above)) chance *= LOOSE_DRIFT;    if (randf.sample() < chance) return d.vec2u(right, left);    return d.vec2u(left, right);  };  /** Spits an ember from lava or a burning cell with air above it. */  const spits = (over: number, hot: number, at: d.v2i) => {    'use gpu';    if (matterOf(over) !== Matter.empty) return;    const matter = matterOf(hot);    if (matter !== Matter.lava && matter !== Matter.burning) return;    if (randf.sample() < SPITS) spark(d.vec2f(at) + 0.5, randf.sample());  };  const step = gridKernel(function step({ gid }) {    'use gpu';    if (gid.x >= BLOCKS_X || gid.y >= BLOCKS_Y) return;    // Shift rows every step to let falling cells cross blocks. Shift columns every other step    // to alternate left and right positions within each top row    const phase = tick.$ & 3;    const shift = d.vec2i(d.i32(((phase + 1) >>> 1) & 1), d.i32(phase & 1));    const origin = d.vec2i(gid.xy) * 2 - shift;    if (origin.x >= WIDTH || origin.y >= HEIGHT) return;    randf.seed4(d.vec4f(d.vec2f(gid.xy), d.f32(tick.$ & 0xffff), d.f32(tick.$ >>> 16)));    // The block's top left in x, top right in y, bottom left in z and bottom right in w    const atTopRight = origin + d.vec2i(1, 0);    const atBottomLeft = origin + d.vec2i(0, 1);    const atBottomRight = origin + d.vec2i(1, 1);    let block = d.vec4u(      cells.cellAt(origin),      cells.cellAt(atTopRight),      cells.cellAt(atBottomLeft),      cells.cellAt(atBottomRight),    );    // React the rows, the columns, then the diagonals. Each swizzle gathers two pairs into the    // lanes `reactPairs` pairs, and the second one puts them back    block = reactPairs(block);    block = reactPairs(block.xzyw).xzyw;    block = reactPairs(block.xwyz).xzwy;    block = d.vec4u(age(block.x), age(block.y), age(block.z), age(block.w));    spits(block.x, block.z, atBottomLeft);    spits(block.y, block.w, atBottomRight);    // Each move swaps two cells, as its swizzle shows. Mirror half the blocks to avoid    // directional bias    const mirrored = randf.sample() < 0.5;    if (mirrored) block = block.yxwz;    if (sprays(block.x, block.z, block.w)) block = block.wyzx;    if (sinks(block.x, block.z)) block = block.zyxw;    if (sinks(block.y, block.w)) block = block.xwzy;    if (slides(block.x, block.z, block.w, block.y)) block = block.wyzx;    if (slides(block.y, block.w, block.z, block.x)) block = block.xzyw;    if (rises(block.z, block.x, block.y, block.w)) block = block.xzyw;    if (rises(block.w, block.y, block.x, block.z)) block = block.wyzx;    if (mirrored) block = block.yxwz;    const top = pour(block.x, block.y, block.z, block.w);    const bottom = drift(block.z, block.w, top.x, top.y);    cells.put(origin, top.x);    cells.put(atTopRight, top.y);    cells.put(atBottomLeft, bottom.x);    cells.put(atBottomRight, bottom.y);  });  /**   * Moves liquids in one row parity, two cells per thread. Supporting rows have the opposite   * parity, so this dispatch never writes them.   */  const flowing = (parity: number, offset: number) =>    gridKernel(function flow({ gid }) {      'use gpu';      if (gid.x >= BLOCKS_X || gid.y >= ROWS) return;      const left = d.vec2i(d.i32(gid.x) * 2 - offset, d.i32(gid.y) * 2 + parity);      const right = left + d.vec2i(1, 0);      const l = cells.cellAt(left);      const r = cells.cellAt(right);      if (!isLiquid(l) && !isLiquid(r)) return;      randf.seed4(d.vec4f(d.vec2f(left), d.f32(tick.$ & 0xffff), d.f32(tick.$ >>> 16) + offset));      const below = d.vec2i(0, 1);      const pair = pour(l, r, cells.cellAt(left + below), cells.cellAt(right + below));      cells.put(left, pair.x);      cells.put(right, pair.y);    });  const ticking = root.createComputePipeline({    compute: tgpu.computeFn({ workgroupSize: [1] })(() => {      'use gpu';      tick.$ += 1;    }),  });  const stepping = root    .pipe(cells.inject())    .pipe(counts.inject())    .createComputePipeline({ compute: step });  const flowingAt = (parity: number, offset: number) =>    root.pipe(cells.inject()).createComputePipeline({ compute: flowing(parity, offset) });  /** Both row parities at column offsets 0 and 1. */  const pairings = [0, 1].map((offset) => [flowingAt(0, offset), flowingAt(1, offset)]);  // Passes alternate column offsets so liquid can move one cell per pass  const flows = Array.from({ length: FLOW_PASSES }, (_, pass) => pairings[pass % 2]!).flat();  return {    initAsync: () =>      Promise.all([ticking, stepping, ...pairings.flat()].map((pipeline) => pipeline.initAsync())),    springs: (on: boolean) => controls.write({ springs: on ? 1 : 0 }),    /** Steps all cells, then moves liquids along their rows. */    step(pass: TgpuComputePass) {      pass.setPipeline(ticking);      pass.dispatchWorkgroups(1);      pass.setPipeline(stepping);      pass.dispatchWorkgroups(BLOCK_GROUPS.x, BLOCK_GROUPS.y);      for (const flow of flows) {        pass.setPipeline(flow);        pass.dispatchWorkgroups(BLOCK_GROUPS.x, ROW_GROUPS);      }    },  };}

cave.ts

import { perlin2d, randf } from '@typegpu/noise';import { sdLine, sdRoundedBox2d } from '@typegpu/sdf';import { d, std, tgpu, type TgpuCommandEncoder, type TgpuRoot } from 'typegpu';import {  BURN_LIFE,  cellOf,  type Cells,  crystalOf,  drainOf,  FIRE_LIFE,  gridKernel,  GROUPS,  HEIGHT,  Kind,  Matter,  matterOf,  rockOf,  springOf,  WIDTH,  WORKGROUP,} from './cells.ts';/** Strokes painted per frame. Later strokes stretch the last one. */const STROKES = 64;const Stroke = d.struct({ start: d.vec2f, finish: d.vec2f, radius: d.f32, matter: d.u32 });/** Queued strokes, and a seed that changes each time they are painted. */const Batch = d.struct({ count: d.u32, seed: d.u32 });/** What the pointer paints, and the matter it puts down. `erase` empties cells, rock included. */const PAINTED = {  sand: Matter.sand,  water: Matter.water,  lava: Matter.lava,  wood: Matter.wood,  fire: Matter.fire,  crystal: Matter.crystal,  rock: Matter.rock,  erase: Matter.empty,} as const;export type Paint = keyof typeof PAINTED;export const PAINTS = Object.keys(PAINTED) as Paint[];/** Fraction of painted area filled by loose material. Leaves gaps between grains. */const POURED = 0.45;/** Base-to-tip progress of painted crystal, from 0 to 1. */const PAINTED_CRYSTAL = 0.6;/** Rounded-box bounds before noise roughening. */const HALL = { center: d.vec2f(160, 88), half: d.vec2f(152, 84), round: 36 } as const;/** Outline-noise frequencies per cell and displacement amplitudes in cells. */const ROUGH = {  broad: 0.021,  broadCells: 30,  mid: 0.07,  midCells: 12,  fine: 0.22,  fineCells: 5,} as const;/** Roughness multipliers for the floor and rock overhangs. */const ROUGHER = { floor: 0.25, massif: 0.45 } as const;/** Coordinate-warp frequency per cell and displacement in cells. Breaks straight outlines. */const WARP = { scale: 0.03, cells: 20 } as const;/** Floor rows under the water overhang, in the basin and under the lava overhang. */const FLOOR = { grotto: 131, basin: 168, right: 124 } as const;/** Basin-wall columns and their feet at the basin floor. */const BASIN = { left: 94, leftFoot: 114, rightFoot: 228, right: 246, dip: 3 } as const;/** Dam position and lava-bed row. Lava spills over the dam into water. */const DAM = { x: 178, top: 128, width: 4, bed: 150 } as const;/** Initial water and lava surface rows. Lava fills the area to the right of the dam. */const FILLED = { water: 142, lava: 129 } as const;/** A rock overhang tapering from `wallRadius` at `wall` to `tipRadius` at `tip`, in cells. */const Massif = d.struct({ wall: d.vec2f, tip: d.vec2f, wallRadius: d.f32, tipRadius: d.f32 });/** The overhangs above the water and the lava. */const massifs = tgpu.const(d.arrayOf(Massif, 2), [  { wall: d.vec2f(-12, 70), tip: d.vec2f(118, 64), wallRadius: 17, tipRadius: 9 },  { wall: d.vec2f(332, 64), tip: d.vec2f(200, 58), wallRadius: 17, tipRadius: 10 },]);/** * A vertical opening `half` cells either side of column `x`, with a spring at row `top` pouring * `pours` with a per-step probability of `chance` in units of 1/255. */const Cleft = d.struct({ x: d.f32, half: d.f32, top: d.f32, pours: d.u32, chance: d.u32 });/** The openings through the water and lava overhangs, and a crack above the grotto. */const clefts = tgpu.const(d.arrayOf(Cleft, 3), [  { x: 112, half: 3, top: 63, pours: Matter.water, chance: 102 },  { x: 208, half: 2.6, top: 57, pours: Matter.lava, chance: 64 },  { x: 52, half: 2.1, top: 72, pours: Matter.sand, chance: 20 },]);/** Open-cleft depth below each spring, in cells. */const CLEFT_DEPTH = 30;/** Per-step probability in units of 1/255 that a stalactite tip drips. */const DRIP = 1;/** * Drain regions in rock, between corners `low` and `high`. Left-wall outlets remove excess water * and sand. Floor seeps remove lava and grit less often. Probabilities use units of 1/255. */const SEEPS = {  water: { low: d.vec2f(80, 140), high: d.vec2f(136, 151) },  sand: { low: d.vec2f(88, 131), high: d.vec2f(108, 138) },  floor: { top: 148, chance: 24 },  bed: 1,  outlet: 255,} as const;/** Stalactite column, base half-width and length in cells. */const Stalactite = d.struct({ x: d.f32, half: d.f32, length: d.f32 });const STALACTITES = [  { x: 40, half: 4, length: 14 },  { x: 60, half: 4.5, length: 15 },  { x: 78, half: 6.5, length: 25 },  { x: 101, half: 2.5, length: 10 },  { x: 125, half: 5, length: 31 },  { x: 176, half: 3, length: 13 },  { x: 214, half: 7.5, length: 27 },  { x: 237, half: 3, length: 16 },  { x: 266, half: 5.5, length: 21 },  { x: 291, half: 3.5, length: 11 },] as const;const stalactites = tgpu.const(d.arrayOf(Stalactite, STALACTITES.length), [...STALACTITES]);const CEILING_CELLS = 10;/** Crystal prisms ordered front to back, with base, tip and half-width in cells. */const Prism = d.struct({ base: d.vec2f, tip: d.vec2f, width: d.f32 });const PRISMS: d.InferInput<typeof Prism>[] = [  // Sand can bury this cluster  { base: [52, 134], tip: [52, 109], width: 2.2 },  { base: [49, 134], tip: [42, 116], width: 1.8 },  { base: [55, 134], tip: [63, 115], width: 1.8 },  { base: [46, 134], tip: [37, 125], width: 1.4 },  { base: [58, 134], tip: [68, 124], width: 1.4 },  { base: [150, -2], tip: [152, 30], width: 2.4 },  { base: [144, -2], tip: [136, 22], width: 1.7 },  { base: [157, -2], tip: [167, 24], width: 1.8 },  { base: [139, 0], tip: [129, 13], width: 1.3 },  { base: [324, 32], tip: [301, 24], width: 2.1 },  { base: [324, 38], tip: [306, 43], width: 1.6 },  { base: [324, 26], tip: [310, 15], width: 1.3 },  { base: [100, 170], tip: [117, 151], width: 2 },  { base: [98, 166], tip: [104, 149], width: 1.4 },  { base: [104, 172], tip: [125, 162], width: 1.5 },];/** Wooden beams from `base` to `tip`, `radius` cells thick. */const Timber = d.struct({ base: d.vec2f, tip: d.vec2f, radius: d.f32 });const TIMBERS: d.InferInput<typeof Timber>[] = [  { base: [258, 66], tip: [258, 128], radius: 1.7 },  { base: [296, 66], tip: [296, 128], radius: 1.7 },  { base: [252, 90], tip: [302, 90], radius: 1.3 },  { base: [252, 108], tip: [302, 108], radius: 1.3 },  { base: [259, 91], tip: [295, 107], radius: 1 },  { base: [295, 91], tip: [259, 107], radius: 1 },];/** Signed distance to a roughened outline, in cells. */const roughAt = (p: d.v2f) => {  'use gpu';  return (    perlin2d.sample(p * ROUGH.broad) * ROUGH.broadCells +    perlin2d.sample(p * ROUGH.mid + 11) * ROUGH.midCells +    perlin2d.sample(p * ROUGH.fine + 23) * ROUGH.fineCells  );};/** Floor surface row at a column. */const floorAt = (x: number) => {  'use gpu';  const down = std.smoothstep(BASIN.left, BASIN.leftFoot, x) * (FLOOR.basin - FLOOR.grotto);  const up = std.smoothstep(BASIN.rightFoot, BASIN.right, x) * (FLOOR.basin - FLOOR.right);  const middle = (BASIN.leftFoot + BASIN.rightFoot) / 2;  const half = (BASIN.rightFoot - BASIN.leftFoot) / 2;  // `**` compiles to `pow`, which is NaN for a negative base on most GPUs  const centred = (x - middle) / half;  const dip = std.saturate(1 - centred * centred) * BASIN.dip;  const bed = std.smoothstep(DAM.x - 2, DAM.x + 5, x) * (FLOOR.basin + dip - DAM.bed);  const across = (x - DAM.x) / DAM.width;  const dam = std.max(FLOOR.basin + dip - bed - DAM.top, 0) * std.exp(-across * across);  return FLOOR.grotto + down - up + dip - bed - dam;};/** Signed distance from `p` to an overhang, in cells. */const massifAt = (p: d.v2f, massif: d.Infer<typeof Massif>) => {  'use gpu';  const span = massif.tip - massif.wall;  const h = std.saturate(std.dot(p - massif.wall, span) / std.dot(span, span));  return std.distance(p, massif.wall + span * h) - std.mix(massif.wallRadius, massif.tipRadius, h);};/** Horizontal distance from `p` to a stalactite's wavering axis. */const spikeOffset = (p: d.v2f, spike: d.Infer<typeof Stalactite>) => {  'use gpu';  const wobble = perlin2d.sample(d.vec2f(p.y * 0.08, spike.x)) * 3;  return std.abs(p.x - spike.x + wobble);};/** Stalactite half-width at row `y`, including roughness. */const spikeWidth = (spike: d.Infer<typeof Stalactite>, y: number) => {  'use gpu';  const down = (y - CEILING_CELLS) / spike.length;  const lumpy = 0.8 + perlin2d.sample(d.vec2f(y * 0.3, spike.x)) * 0.5;  return spike.half * std.max(1 - down, 0) ** 1.3 * lumpy;};/** Whether `p` lies in a cleft's opening below its spring. */const cleftAt = (p: d.v2f, cleft: d.Infer<typeof Cleft>) => {  'use gpu';  const wobble = std.abs(perlin2d.sample(d.vec2f(p.y * 0.35, cleft.x))) * 1.2 + 0.4;  const beside = std.abs(p.x - cleft.x) < cleft.half + wobble;  return beside && p.y > cleft.top + 1 && p.y < cleft.top + CLEFT_DEPTH;};/** Whether `p` lies in the row of a cleft's spring. */const springAt = (p: d.v2f, cleft: d.Infer<typeof Cleft>) => {  'use gpu';  return std.abs(p.x - cleft.x) < cleft.half && p.y > cleft.top && p.y < cleft.top + 1;};const solidAt = (p: d.v2f) => {  'use gpu';  const rough = roughAt(p);  const bend = d.vec2f(perlin2d.sample(p * WARP.scale + 31), perlin2d.sample(p * WARP.scale + 57));  const q = p + bend * WARP.cells;  let rock = sdRoundedBox2d(q - HALL.center, HALL.half, HALL.round) + rough > 0;  if (q.y > floorAt(q.x) + rough * ROUGHER.floor) rock = true;  for (const massif of tgpu.unroll(massifs.$)) {    if (massifAt(q, massif) + rough * ROUGHER.massif < 0) rock = true;  }  for (let i = d.u32(0); i < STALACTITES.length; i++) {    const spike = stalactites.$[i]!;    if (spikeOffset(p, spike) < spikeWidth(spike, p.y)) rock = true;  }  for (const cleft of tgpu.unroll(clefts.$)) {    if (cleftAt(p, cleft)) rock = false;  }  return rock;};/** Tests the bottom centre cell of a stalactite, where drips originate. */const dripsAt = (p: d.v2f) => {  'use gpu';  let drips = false;  for (let i = d.u32(0); i < STALACTITES.length; i++) {    const spike = stalactites.$[i]!;    const off = spikeOffset(p, spike);    const width = spikeWidth(spike, p.y);    const below = spikeWidth(spike, p.y + 1);    if (off < 0.5 && off < width && off >= below) drips = true;  }  return drips;};/** Whether `p` lies strictly between corners `low` and `high`. */const within = (p: d.v2f, low: d.v2f, high: d.v2f) => {  'use gpu';  return std.all(std.gt(p, low)) && std.all(std.lt(p, high));};/** Rock shade from horizontal strata and grain. */const strataAt = (p: d.v2f) => {  'use gpu';  const beds = perlin2d.sample(p * d.vec2f(0.025, 0.11)) * 0.5 + 0.5;  const grain = perlin2d.sample(p * 0.4) * 0.5 + 0.5;  return std.saturate(beds * 0.7 + grain * 0.3);};const rockAt = (p: d.v2f) => {  'use gpu';  return rockOf((strataAt(p) * 0.65 + randf.sample() * 0.35) * 255);};/** Maps prism coordinates from -1 to 1 to a dark side, lit side or front facet. */const facetOf = (across: number) => {  'use gpu';  if (across < -0.34) return d.f32(0.5);  if (across > 0.34) return d.f32(0.75);  return d.f32(1);};/** Initialises the cave and paints pointer strokes. */export function createCave(root: TgpuRoot, cells: Cells) {  const strokes = root.createReadonly(d.arrayOf(Stroke, STROKES));  const batch = root.createUniform(Batch);  const prisms = root.createReadonly(d.arrayOf(Prism, PRISMS.length), PRISMS);  const timbers = root.createReadonly(d.arrayOf(Timber, TIMBERS.length), TIMBERS);  // `solidAt` over the grid and a cell around it, so `start` reads neighbours instead of  // repeating the noise: Windows shader compilers inline each call  const solid = root.createMutable(d.arrayOf(d.u32, (WIDTH + 2) * (HEIGHT + 2)));  const crystalAt = (p: d.v2f) => {    'use gpu';    for (let i = d.u32(0); i < PRISMS.length; i++) {      const prism = prisms.$[i]!;      const axis = prism.tip - prism.base;      const length = std.length(axis);      const along = std.dot(p - prism.base, axis) / length;      const across = ((p.x - prism.base.x) * axis.y - (p.y - prism.base.y) * axis.x) / length;      const reach = prism.width * std.saturate((length - along) / (prism.width * 1.8));      if (along > -1 && along < length && std.abs(across) < reach) {        return crystalOf(facetOf(across / prism.width), along / length);      }    }    return d.u32(Matter.empty);  };  /** Reads `solidAt` at the centre `p` of a cell in the grid or a cell outside it. */  const solidNear = (p: d.v2f) => {    'use gpu';    const at = d.vec2u(p + 0.5);    return solid.$[at.y * (WIDTH + 2) + at.x] === 1;  };  /** Rock, a drain where the seeps run, or air where the noise leaves an isolated speck. */  const wallAt = (p: d.v2f, stone: number) => {    'use gpu';    const open =      d.u32(!solidNear(p + d.vec2f(1, 0))) +      d.u32(!solidNear(p - d.vec2f(1, 0))) +      d.u32(!solidNear(p + d.vec2f(0, 1))) +      d.u32(!solidNear(p - d.vec2f(0, 1)));    if (open >= 3) return d.u32(Matter.empty);    if (open === 0) return stone;    const water = within(p, SEEPS.water.low, SEEPS.water.high);    const sand = within(p, SEEPS.sand.low, SEEPS.sand.high);    if (water || sand) return drainOf(SEEPS.outlet, water, stone);    const floor = p.x > BASIN.left && p.x < DAM.x - DAM.width && p.y > SEEPS.floor.top;    if (floor) return drainOf(SEEPS.floor.chance, false, stone);    const bed = p.x > DAM.x + DAM.width && p.x < BASIN.right && p.y > DAM.top + 4;    if (bed) return drainOf(SEEPS.bed, false, stone);    return stone;  };  /** Water and lava either side of the dam, the timber beams, or air. */  const hollowAt = (p: d.v2f) => {    'use gpu';    const inBasin = p.x > BASIN.left && p.x < BASIN.right;    if (inBasin && p.x > DAM.x && p.y > FILLED.lava) return cellOf(Matter.lava, 0);    if (inBasin && p.x < DAM.x && p.y > FILLED.water) return cellOf(Matter.water, 0);    for (let i = d.u32(0); i < TIMBERS.length; i++) {      const timber = timbers.$[i]!;      if (sdLine(p, timber.base, timber.tip) < timber.radius) return cellOf(Matter.wood, 0);    }    return d.u32(Matter.empty);  };  /** The cell the cave starts with at `p`. */  const startAt = (p: d.v2f) => {    'use gpu';    const stone = rockAt(p);    for (const cleft of tgpu.unroll(clefts.$)) {      if (springAt(p, cleft)) return springOf(cleft.pours, cleft.chance, stone);    }    if (dripsAt(p) && solidNear(p - d.vec2f(0, 1))) return springOf(Matter.water, DRIP, stone);    const crystal = crystalAt(p);    if (crystal !== Matter.empty) return crystal;    if (solidNear(p)) return wallAt(p, stone);    return hollowAt(p);  };  /** What a stroke of `matter` leaves where `cell` was. */  const paintedOver = (cell: number, matter: number, p: d.v2f) => {    'use gpu';    const now = cells.traitsOf(cell).kind;    if (matter === Matter.empty) return d.u32(Matter.empty);    if (matter === Matter.rock) return rockAt(p);    if (matter === Matter.crystal) {      return crystalOf(facetOf(randf.sample() * 2 - 1), PAINTED_CRYSTAL);    }    if (cells.traitsOf(matter).kind === Kind.fixed) return cellOf(matter, 0);    if (matter === Matter.fire && now === Kind.fixed) {      if (matterOf(cell) === Matter.wood) return cellOf(Matter.burning, BURN_LIFE);      return d.u32(cell);    }    if (now === Kind.gas && randf.sample() < POURED) return cellOf(matter, FIRE_LIFE);    return d.u32(cell);  };  const outline = gridKernel(function outline({ gid }) {    'use gpu';    if (gid.x >= WIDTH + 2 || gid.y >= HEIGHT + 2) return;    solid.$[gid.y * (WIDTH + 2) + gid.x] = d.u32(solidAt(d.vec2f(gid.xy) - 0.5));  });  const start = gridKernel(function start({ gid }) {    'use gpu';    if (gid.x >= WIDTH || gid.y >= HEIGHT) return;    randf.seed2(d.vec2f(gid.xy));    cells.put(d.vec2i(gid.xy), startAt(d.vec2f(gid.xy) + 0.5));  });  const paint = gridKernel(function paint({ gid }) {    'use gpu';    if (gid.x >= WIDTH || gid.y >= HEIGHT) return;    randf.seed3(d.vec3f(d.vec2f(gid.xy), d.f32(batch.$.seed)));    const at = d.vec2i(gid.xy);    const p = d.vec2f(gid.xy) + 0.5;    let cell = cells.cellAt(at);    for (let i = d.u32(0); i < batch.$.count; i++) {      const stroke = strokes.$[i]!;      let distance = std.distance(p, stroke.start);      // A zero-length segment makes `sdLine` divide by zero, so measure point distance instead      if (std.any(std.ne(stroke.start, stroke.finish))) {        distance = sdLine(p, stroke.start, stroke.finish);      }      if (distance > stroke.radius) continue;      cell = paintedOver(cell, stroke.matter, p);    }    cells.put(at, cell);  });  const outlining = root.createComputePipeline({ compute: outline });  const starting = root.pipe(cells.inject()).createComputePipeline({ compute: start });  const painting = root.pipe(cells.inject()).createComputePipeline({ compute: paint });  const outlineGroups = [    Math.ceil((WIDTH + 2) / WORKGROUP),    Math.ceil((HEIGHT + 2) / WORKGROUP),  ] as const;  const pending: d.InferInput<typeof Stroke>[] = [];  let batches = 0;  let resetting = true;  return {    initAsync: () =>      Promise.all([outlining.initAsync(), starting.initAsync(), painting.initAsync()]),    /** Queues a stroke in cell coordinates, with y pointing down. */    stroke(      from: readonly [number, number],      to: readonly [number, number],      radius: number,      what: Paint,    ) {      const stroke = { start: from, finish: to, radius, matter: PAINTED[what] };      if (pending.length < STROKES) pending.push(stroke);      else pending[STROKES - 1] = { ...stroke, start: pending[STROKES - 1]!.start };    },    /** Resets the cave or paints queued strokes. Returns false if no work was queued. */    record(encoder: TgpuCommandEncoder) {      if (!resetting && pending.length === 0) return false;      const pass = encoder.beginComputePass();      if (resetting) {        pass.setPipeline(outlining);        pass.dispatchWorkgroups(...outlineGroups);        pass.setPipeline(starting);        pass.dispatchWorkgroups(GROUPS.x, GROUPS.y);      }      resetting = false;      if (pending.length > 0) {        strokes.buffer.write(pending);        batch.write({ count: pending.length, seed: batches++ % 65536 });        pending.length = 0;        pass.setPipeline(painting);        pass.dispatchWorkgroups(GROUPS.x, GROUPS.y);      }      pass.end();      return true;    },    reset() {      resetting = true;      pending.length = 0;    },    destroy() {      solid.buffer.destroy();    },  };}

embers.ts

import { type CameraBase, type Events, Particle, particles } from '@typegpu/kit';import { randf } from '@typegpu/noise';import { d, std, tgpu, type TgpuRoot } from 'typegpu';import { BURN_LIFE, type Cells, cellOf, HEIGHT, Kind, Matter, matterOf } from './cells.ts';const CAPACITY = 2048;const GRAVITY_CELLS = 45;const DRAG = 0.5;const SPEED = { least: 22, most: 52, across: 14 } as const;const LIFE_SECONDS = { least: 0.7, most: 2.2 } as const;const SIZE_CELLS = 1.8;const HOT = d.vec3f(7, 3.4, 0.8);const COOL = d.vec3f(3.2, 0.55, 0.05);const CORE = 0.35;const HALO = 0.2;/** Seconds an ember ignores its source material after birth. */const GRACE_SECONDS = 0.08;const STEAM_LIFE = 120;/** Chance an ember that lands on wood sets it alight. */const IGNITES = 0.2;const glowing = (ember: Particle, local: d.v2f) => {  'use gpu';  const radius = std.length(local);  const core = 1 - std.smoothstep(CORE, CORE * 2, radius);  const halo = std.saturate(1 - radius) * HALO;  const left = std.saturate(1 - ember.age / ember.life);  const brightness = (core + halo) * left;  return d.vec4f(ember.color.rgb * brightness, 0);};/** Emits sparks from lava and burning wood. Sparks ignite wood and turn to steam on water. */export function createEmbers(  root: TgpuRoot,  options: {    readonly camera: CameraBase;    readonly step: number;    readonly cells: Cells;    readonly counts: Pick<Events<'burnt'>, 'add' | 'inject'>;  },) {  const { camera, step, cells, counts } = options;  const land = (ember: Particle) => {    'use gpu';    const next = Particle(ember);    if (next.age >= next.life || next.age < GRACE_SECONDS) return next;    const at = d.vec2i(      d.i32(std.floor(next.position.x)),      d.i32(std.floor(HEIGHT - next.position.y)),    );    const cell = cells.cellAt(at);    if (cells.traitsOf(cell).kind === Kind.gas) return next;    randf.seed2(next.position.xy + next.seed);    const matter = matterOf(cell);    if (matter === Matter.wood && randf.sample() < IGNITES) {      cells.put(at, cellOf(Matter.burning, BURN_LIFE));      counts.add.burnt(1);    } else if (matter === Matter.water) {      cells.put(at, cellOf(Matter.steam, STEAM_LIFE));    }    next.life = next.age;    return next;  };  const sparks = particles.create(root, camera, {    capacity: CAPACITY,    step,    dimensions: 2,    update: tgpu.fn(land).pipe(cells.inject()).pipe(counts.inject()),    shade: glowing,    settings: { gravity: [0, -GRAVITY_CELLS, 0], drag: DRAG },  });  /** Emits from the top of a cell using y-down cell coordinates and `heat` from 0 to 1. */  const spark = tgpu    .fn((at: d.v2f, heat: number) => {      'use gpu';      const speed = SPEED.least + (SPEED.most - SPEED.least) * (randf.sample() * 0.6 + heat * 0.4);      const across = (randf.sample() * 2 - 1) * SPEED.across;      sparks.emit(        Particle({          position: d.vec3f(at.x, HEIGHT - at.y + 0.5, 0),          age: 0,          velocity: d.vec3f(across, speed, 0),          life: LIFE_SECONDS.least + (LIFE_SECONDS.most - LIFE_SECONDS.least) * randf.sample(),          color: d.vec4f(std.mix(COOL, HOT, heat), 1),          size: SIZE_CELLS,          seed: randf.sample(),        }),      );    })    .pipe(sparks.inject());  return { sparks, spark };}

densities.ts

import { owner } from '@typegpu/kit';import { d, std, type TgpuComputePass, type TgpuRoot } from 'typegpu';import {  type Cells,  GRID,  gridKernel,  gridTexture,  GROUPS,  HEIGHT,  Kind,  lifeOf,  Matter,  matterOf,  WIDTH,} from './cells.ts';/** Vapour sampling radius and blur width in cells. */const VAPOUR = { reach: 4, spread: 2.8 } as const;/** Density multiplier from the last lifetime step to the first. */const YOUTH = { old: 0.35, young: 1 } as const;/** Vertical and horizontal sampling reach for filling gaps in falling streams, in cells. */const STREAM = { rows: 6, columns: 1 } as const;/** * Blurs steam and smoke density and fills gaps in falling water, lava and sand. Makes discrete * falling cells appear as continuous streams. */export function createDensities(root: TgpuRoot, options: { readonly cells: Cells }) {  const { cells } = options;  const own = owner();  const vapour = own(gridTexture(root));  const streams = own(gridTexture(root));  const smooth = root.createSampler({ minFilter: 'linear', magFilter: 'linear' });  const measure = gridKernel(function measure({ gid }) {    'use gpu';    if (gid.x >= WIDTH || gid.y >= HEIGHT) return;    const at = d.vec2i(gid.xy);    let mist = d.vec2f();    let weights = d.f32(0);    for (let y = -VAPOUR.reach; y <= VAPOUR.reach; y++) {      for (let x = -VAPOUR.reach; x <= VAPOUR.reach; x++) {        const offset = d.vec2f(d.f32(x), d.f32(y));        const weight = std.exp(-std.dot(offset, offset) / (VAPOUR.spread * VAPOUR.spread));        const cell = cells.cellAt(at + d.vec2i(x, y));        const matter = matterOf(cell);        const youth = std.mix(YOUTH.old, YOUTH.young, d.f32(lifeOf(cell)) / 255);        if (matter === Matter.steam) mist.x += weight * youth;        if (matter === Matter.smoke) mist.y += weight * youth;        weights += weight;      }    }    let above = d.vec3f();    let below = d.vec3f();    for (let y = -STREAM.rows; y <= STREAM.rows; y++) {      for (let x = -STREAM.columns; x <= STREAM.columns; x++) {        const p = at + d.vec2i(x, y);        const cell = cells.cellAt(p);        const matter = matterOf(cell);        if (matter !== Matter.water && matter !== Matter.lava && matter !== Matter.sand) continue;        // Only cells with gas below count as falling        if (cells.kindAt(p + d.vec2i(0, 1)) !== Kind.gas) continue;        const falling = d.vec3f(          d.f32(matter === Matter.water),          d.f32(matter === Matter.lava),          d.f32(matter === Matter.sand),        );        if (y <= 0) above += falling;        if (y >= 0) below += falling;      }    }    // Fill between falling cells without extending beyond a stream's ends    const falls = std.min(above, below);    const counted = (STREAM.rows + 1) * (STREAM.columns * 2 + 1);    std.textureStore(vapour.writing.$, gid.xy, d.vec4f(mist / weights, 0, 1));    std.textureStore(streams.writing.$, gid.xy, d.vec4f(falls / counted, 1));  });  const measuring = root.pipe(cells.inject()).createComputePipeline({ compute: measure });  return {    /** Interpolated steam density in `x` and smoke density in `y` at cell coordinates. */    vapourAt: (p: d.v2f) => {      'use gpu';      return std.textureSampleLevel(vapour.reading.$, smooth.$, p / GRID, 0).xy;    },    /** Water, lava and sand stream densities from 0 to 1, nonzero only between falling cells. */    streamsAt: (cell: d.v2i) => {      'use gpu';      return std.textureLoad(streams.reading.$, cell, 0).xyz;    },    initAsync: () => measuring.initAsync(),    measure(pass: TgpuComputePass) {      pass.setPipeline(measuring);      pass.dispatchWorkgroups(GROUPS.x, GROUPS.y);    },    destroy: own.destroy,  };}export type Densities = ReturnType<typeof createDensities>;

light.ts

import { owner } from '@typegpu/kit';import { perlin2d } from '@typegpu/noise';import * as hrc from '@typegpu/radiance-cascades/holographic';import {  d,  std,  tgpu,  type SampledFlag,  type TgpuComputePass,  type TgpuRoot,  type TgpuTexture,  type TgpuUniform,} from 'typegpu';import type { Densities } from './densities.ts';import {  type Cells,  FIRE_LIFE,  GRID,  gridKernel,  gridTexture,  GROUPS,  HEIGHT,  Kind,  lifeOf,  Matter,  matterOf,  shadeOf,  WIDTH,} from './cells.ts';const TRACED_LEVELS = 3;const SECTOR_SHARE = 1 / 4;/** How much of an ember's drawn glow lights the cave. */const EMBER_LIGHT = 1;/** Extinction multiplier for grains falling through air. */const FALLING_GRAINS = 0.15;const FLICKER = { scale: 0.3, perSecond: 7 } as const;/** Hot, crust-free emission from falling lava. */const FALLING_LAVA = d.vec3f(2.6, 1, 0.22);/** Lava emission multiplier for the light field. Keeps lava the main light source. */const LAVA_LIGHT = 5;/** Falling-lava density threshold and extinction. */const RIBBON = { least: 0.04, full: 0.3, extinction: 1.2 } as const;/** Crystal brightness multiplier when buried. */const BURIED = 0.12;/** Sample offsets three and six cells away to detect surrounding grains and air. */const AROUND = [3, 6].flatMap((reach) =>  [-1, 0, 1].flatMap((y) =>    [-1, 0, 1].filter((x) => x !== 0 || y !== 0).map((x) => ({ x: x * reach, y: y * reach })),  ),);/** Crystal pulse rates and colours. High crystals are violet, and lower crystals are cyan. */const CRYSTAL = {  violet: d.vec3f(1.2, 0.36, 2.3),  violetAbove: 45,  pulse: 0.6,  depth: 0.15,} as const;/** Crystal emission multiplier for the field. Keeps lava the main light source. */const CRYSTAL_LIGHT = 0.7;/** * Cell emission. Fire fades with age, burning wood flickers, and crystal colour depends on * height. */export const glowOf = (base: d.v3f, cell: number, p: d.v2f, seconds: number) => {  'use gpu';  const matter = matterOf(cell);  if (matter === Matter.fire) {    const young = d.f32(lifeOf(cell)) / FIRE_LIFE;    return base * (0.4 + young) + d.vec3f(1, 1, 0.5) * young * young;  }  if (matter === Matter.burning) {    const flicker = perlin2d.sample(p * FLICKER.scale + d.vec2f(seconds * FLICKER.perSecond, 0));    return base * (0.5 + flicker * 0.6 + shadeOf(cell) * 0.3);  }  if (matter === Matter.crystal) {    let hue = d.vec3f(base);    if (p.y < CRYSTAL.violetAbove) hue = d.vec3f(CRYSTAL.violet);    const pulse = perlin2d.sample(d.vec2f(p.x * 0.02, seconds * CRYSTAL.pulse)) * CRYSTAL.depth;    return hue * (0.85 + shadeOf(cell) * 0.3 + pulse);  }  return d.vec3f(base);};/** * Builds a holographic radiance field from cells, embers and reflected light from walls and * steam in the last field update. */export function createLight(  root: TgpuRoot,  options: {    readonly cells: Cells;    readonly densities: Densities;    /** Ember drawing at the light-field resolution. */    readonly embers: TgpuTexture & SampledFlag;    readonly seconds: TgpuUniform<d.F32>;    readonly bounce: boolean;  },) {  const { cells, densities, seconds } = options;  const own = owner();  const glow = own(gridTexture(root));  const thickness = own(gridTexture(root));  const emberRead = options.embers.createView(d.texture2d(d.f32));  const smooth = root.createSampler({ minFilter: 'linear', magFilter: 'linear' });  const bounce = root.createUniform(d.u32, options.bounce ? 1 : 0);  const field = own(    hrc.create({      root,      size: { width: WIDTH, height: HEIGHT },      tracedLevels: TRACED_LEVELS,      transmission: 'rgb',      medium: (pixel) => {        'use gpu';        return {          emission: std.textureLoad(glow.reading.$, pixel, 0).rgb,          extinction: std.textureLoad(thickness.reading.$, pixel, 0).rgb,        };      },    }),  );  const radiance = field.output.createView(d.texture2d(d.f32));  const sectors = field.sectors.createView(d.texture2dArray(d.f32));  /** Mean light at a cell. */  const lightAt = (cell: d.v2i) => {    'use gpu';    return std.textureLoad(radiance.$, cell, 0).rgb;  };  /** Interpolated mean light at cell coordinates. */  const radianceAt = (p: d.v2f) => {    'use gpu';    return std.textureSampleLevel(radiance.$, smooth.$, p / GRID, 0).rgb;  };  /** Samples incoming light `reach` cells outward along a solid face's `normal`. */  const faceAt = (cell: d.v2i, normal: d.v2f, reach: number) => {    'use gpu';    const outside = (d.vec2f(cell) + 0.5 + normal * reach) / GRID;    const weights = d.vec4f(      std.max(normal.x, 0),      std.max(normal.y, 0),      std.max(-normal.x, 0),      std.max(-normal.y, 0),    );    let light = d.vec3f();    for (const sector of tgpu.unroll([0, 1, 2, 3])) {      light +=        std.textureSampleLevel(sectors.$, smooth.$, outside, sector, 0).rgb * weights[sector];    }    // Each sector contributes a quarter of the mean. Divide by that fraction to recover side    // lighting    return light / (SECTOR_SHARE * std.max(std.abs(normal.x) + std.abs(normal.y), 1));  };  /** Crystal brightness multiplier, from 1 in open air to `BURIED` among grains. */  const shineAt = (cell: d.v2i) => {    'use gpu';    let open = d.f32(0);    let buried = d.f32(0);    for (const offset of tgpu.unroll(AROUND)) {      const kind = cells.kindAt(cell + d.vec2i(offset.x, offset.y));      if (kind === Kind.powder) buried += 1;      if (kind === Kind.gas || kind === Kind.liquid) open += 1;    }    const covered = std.smoothstep(0.15, 0.75, buried / std.max(open + buried, 1));    return std.mix(1, BURIED, covered);  };  /** Medium properties at a cell for the diagnostic view. */  const mediumAt = (cell: d.v2i) => {    'use gpu';    return d.vec4f(      std.textureLoad(glow.reading.$, cell, 0).rgb,      std.textureLoad(thickness.reading.$, cell, 0).x,    );  };  /** Whether air lies left or right of a cell. */  const airBeside = (at: d.v2i) => {    'use gpu';    return (      cells.matterAt(at + d.vec2i(-1, 0)) === Matter.empty ||      cells.matterAt(at + d.vec2i(1, 0)) === Matter.empty    );  };  /** What a cell gives off into the field. Falling lava burns hotter, and buried crystal dims. */  const givenAt = (cell: number, at: d.v2i, p: d.v2f) => {    'use gpu';    const base = glowOf(cells.traitsOf(cell).glow, cell, p, seconds.$);    const matter = matterOf(cell);    if (matter === Matter.crystal) return base * CRYSTAL_LIGHT * shineAt(at);    if (matter !== Matter.lava) return base;    if (airBeside(at)) return FALLING_LAVA * LAVA_LIGHT;    return base * LAVA_LIGHT;  };  const fill = gridKernel(function fill({ gid }) {    'use gpu';    if (gid.x >= WIDTH || gid.y >= HEIGHT) return;    const at = d.vec2i(gid.xy);    const p = d.vec2f(gid.xy) + 0.5;    const cell = cells.cellAt(at);    const traits = cells.traitsOf(cell);    let thick = d.vec3f(traits.extinction);    let given = givenAt(cell, at, p);    // Grains beside or above air pass more light, so falling streams stay lit    const below = cells.matterAt(at + d.vec2i(0, 1));    if (traits.kind === Kind.powder && (below === Matter.empty || airBeside(at))) {      thick *= FALLING_GRAINS;    }    // Emit along the full falling-lava ribbon to avoid disconnected points of light    const falls = densities.streamsAt(at).y;    if (traits.kind === Kind.gas && falls > RIBBON.least) {      const full = std.smoothstep(RIBBON.least, RIBBON.full, falls);      thick = std.max(thick, d.vec3f(RIBBON.extinction * full));      given = FALLING_LAVA * LAVA_LIGHT * full;    }    // Scale emission and extinction together to preserve their ratio    let emission = given * thick;    if (bounce.$ !== 0) {      const matter = matterOf(cell);      const relief = cells.reliefAt(at);      if (relief.z === 1 && std.dot(traits.glow, traits.glow) === 0) {        emission += traits.albedo * faceAt(at, relief.xy, d.f32(1)) * thick;      } else if (matter === Matter.steam || matter === Matter.smoke) {        emission += traits.albedo * radianceAt(p) * thick;      }    }    emission += std.textureLoad(emberRead.$, gid.xy, 0).rgb * EMBER_LIGHT;    std.textureStore(glow.writing.$, gid.xy, d.vec4f(emission, 1));    std.textureStore(thickness.writing.$, gid.xy, d.vec4f(thick, 1));  });  const filling = root.pipe(cells.inject()).createComputePipeline({ compute: fill });  return {    lightAt,    radianceAt,    shineAt,    faceAt,    mediumAt,    bounce: (enabled: boolean) => bounce.write(enabled ? 1 : 0),    initAsync: () => Promise.all([field.initAsync(), filling.initAsync()]),    solve(pass: TgpuComputePass) {      pass.setPipeline(filling);      pass.dispatchWorkgroups(GROUPS.x, GROUPS.y);      field.run({ pass });    },    destroy: own.destroy,  };}export type Light = ReturnType<typeof createLight>;

drawing.ts

import { passes, tone } from '@typegpu/kit';import { perlin2d } from '@typegpu/noise';import { d, std, tgpu, type TgpuRoot, type TgpuUniform } from 'typegpu';import {  type Cells,  GRID,  Kind,  lifeOf,  Matter,  MATERIALS,  matterOf,  OPAQUE,  RELIEF_CELLS,  shadeOf,} from './cells.ts';import type { Densities } from './densities.ts';import { glowOf, type Light } from './light.ts';/** Blue-violet ambient light outside the radiance field. */const AMBIENT = d.vec3f(0.03, 0.028, 0.06);/** Ambient rock colour preserves visible texture in darkness. */const ROCK_AMBIENT = d.vec3f(0.035, 0.032, 0.08);/** Far-wall colour, darker and bluer than foreground rock. */const BACK = d.vec3f(0.028, 0.026, 0.042);/** Air-light scattering strength for dust and vapour. */const HAZE = 0.012;/** How quickly light fades into a solid, per cell past its surface. */const RIM_FALLOFF = 0.45;/** Light multiplier for exposed solid edges relative to the cut face. */const EDGE = 1.35;const STONE_STEPS = 4;const OBSIDIAN_GLINT = { shade: 0.86, gain: 1.6 } as const;/** Crystal brightness at base and tip, and outline highlight strength. */const CRYSTAL_SHADE = { base: 0.35, tip: 1.2, outline: 1.3 } as const;/** Ambient grain colour and lighting desaturation strength. */const GRAINS = { ambient: d.vec3f(0.07, 0.055, 0.04), wash: 0.4 } as const;/** How much more light grains catch while they fall through the air, spread out. */const FALLING_GRAINS = 1.3;const WATER = {  /** RGB tint for the far wall seen through water. Depth adds exponential attenuation. */  through: d.vec3f(0.25, 0.7, 0.78),  /** Water scattering strength and depth scaling. */  scatter: 0.55,  deep: 0.06,  /** Water-surface glint strength. */  glint: 0.7,  /** Foam colour and lighting multipliers for falling or splashing water. */  foam: d.vec3f(0.6, 0.8, 0.85),  foamLight: 3,  foamAmbient: 8,  /** Maximum water depth in rows. */  depth: 8,} as const;/** Opacity of an isolated falling drop against the background. */const DROP = 0.06;/** Vapour density, opacity and light. `peak` keeps steam over lava amber rather than white. */const VAPOUR = {  full: 0.32,  steam: 0.85,  smoke: 0.55,  light: 3.2,  peak: 3,  ambient: 0.6,  solids: 0.45,} as const;const albedoOf = (matter: number) =>  d.vec3f(...(MATERIALS[matter]!.albedo as [number, number, number]));const STEAM = albedoOf(Matter.steam);const SMOKE = albedoOf(Matter.smoke);const SAND = albedoOf(Matter.sand);const WATER_TINT = albedoOf(Matter.water);/** Density for a full falling ribbon, and water and lava streak speeds in cells per second. */const RIBBON = { full: 0.3, waterSpeed: 30, lavaSpeed: 18 } as const;/** Minimum water, lava and sand stream densities for drawing ribbons. */const RIBBON_LEAST = d.vec3f(0.1, 0.04, 0.04);/** Falling-water colour between foam and transparent water. */const FALL = d.vec3f(0.22, 0.5, 0.56);const LAVA_FALL = { edge: d.vec3f(1.6, 0.16, 0.01), core: d.vec3f(5, 2.2, 0.4) } as const;/** * Sand-stream speed in cells per second, full-density threshold, grain coverage at edges and * centre, and glint, light and dust strengths. */const SAND_FALL = {  speed: 26,  full: 0.05,  edge: 0.35,  middle: -0.05,  glint: 0.4,  light: 2.2,  dust: 0.45,} as const;/** Lava drift in cells per second, noise frequencies per cell, and body, crust and vein colours. */const LAVA = {  drift: d.vec2f(1.2, -0.6),  swirl: 0.05,  patches: 0.09,  veins: 0.11,  spread: 0.035,  body: d.vec3f(2.2, 0.28, 0.02),  crust: d.vec3f(0.5, 0.04, 0.01),  vein: d.vec3f(4.5, 1.9, 0.35),} as const;const EXTINCTION_TINT = d.vec3f(0.11, 0.13, 0.17);const DIAGNOSTIC_EMISSION_GAIN = 0.25;export type View = 'cave' | 'radiance' | 'medium';const VIEWS: Record<View, number> = { cave: 0, radiance: 1, medium: 2 };const Seen = d.struct({ color: d.vec4f });/** Quantised stone shade around 1, with strata, grain and cracks. */const stoneAt = (p: d.v2f, beds: number) => {  'use gpu';  const grain = perlin2d.sample(p * 0.55) * 0.5 + 0.5;  const crack = std.smoothstep(0.06, 0.01, std.abs(perlin2d.sample(p * 0.13 + 19)));  const step = std.floor((beds * 0.75 + grain * 0.25) * STONE_STEPS) / (STONE_STEPS - 1);  return (0.3 + step * 1.4) * (1 - crack * 0.55);};/** Far-wall shade around 1. Uses softer strata and grain than foreground rock. */const wallAt = (p: d.v2f) => {  'use gpu';  const beds = perlin2d.sample(p * d.vec2f(0.02, 0.06) + 3) * 0.5 + 0.5;  const grain = perlin2d.sample(p * 0.35) * 0.5 + 0.5;  return 0.6 + beds * 0.6 + grain * 0.25;};/** Material-specific grain shade. */const grainOf = (matter: number, shade: number, at: d.v2i) => {  'use gpu';  if (matter === Matter.wood) {    const seam = d.f32((at.x * 3 + at.y * 5) % 7 === 0) * 0.25;    return 0.75 + shade * 0.35 - seam;  }  if (matter === Matter.sand) return 0.55 + shade * 0.8;  return 0.8 + shade * 0.4;};/** Foam of colour `foam` lit by `air`. */const foamIn = (foam: d.v3f, air: d.v3f) => {  'use gpu';  return foam * (AMBIENT * WATER.foamAmbient + air * WATER.foamLight);};/** Lava colour sampled at cell coordinates. */const lavaAt = (p: d.v2f, seconds: number) => {  'use gpu';  const q = p + LAVA.drift * seconds;  const swirl = perlin2d.sample(q * LAVA.swirl) * 0.5 + 0.5;  const crust = std.smoothstep(-0.05, 0.35, perlin2d.sample(q * LAVA.patches + 13));  // Use a slower noise to vary vein width and brightness along the zero contour  const seam = std.abs(perlin2d.sample(q * LAVA.veins + 29));  const width = 0.03 + (perlin2d.sample(q * LAVA.spread + 41) * 0.5 + 0.5) * 0.08;  const strength = std.smoothstep(-0.1, 0.4, perlin2d.sample(q * LAVA.spread + 53));  const vein = std.smoothstep(width, width * 0.3, seam) * strength;  const molten = std.mix(LAVA.body * (0.55 + swirl * 0.9), LAVA.crust, crust * 0.7);  return std.mix(molten, LAVA.vein, vein * 0.5);};/** Falling streak intensity from 0 to 1 at `speed` cells per second. */const streaksAt = tgpu.fn(  [d.vec2f, d.f32, d.f32],  d.f32,)((p, seconds, speed) => {  'use gpu';  const flow = perlin2d.sample(d.vec2f(p.x * 0.45, (p.y - seconds * speed) * 0.06));  return std.smoothstep(-0.2, 0.45, flow);});/** Sand-grain pattern from -1 to 1, with features about one cell wide. */const grainsAt = (p: d.v2f, seconds: number) => {  'use gpu';  return perlin2d.sample(d.vec2f(p.x * 1.3, (p.y - seconds * SAND_FALL.speed) * 0.75));};/** * Draws one cell per pixel with lit solid faces, a far wall, scattered light, tinted water and * emissive materials. */export function createDrawing(  root: TgpuRoot,  options: {    readonly cells: Cells;    readonly light: Light;    readonly densities: Densities;    readonly seconds: TgpuUniform<d.F32>;  },) {  const { cells, light, densities, seconds } = options;  const view = root.createUniform(d.u32);  const isAir = (at: d.v2i) => {    'use gpu';    return cells.matterAt(at) === Matter.empty;  };  /** Surface light fading into a solid cell. */  const faceLight = (at: d.v2i) => {    'use gpu';    const relief = cells.reliefAt(at);    if (relief.z > RELIEF_CELLS || std.dot(relief.xy, relief.xy) === 0) return d.vec3f();    let face = light.faceAt(at, relief.xy, relief.z) * std.exp((1 - relief.z) * RIM_FALLOFF);    if (relief.z === 1) face = face * EDGE;    return face;  };  /** The stream drawn as a ribbon over a cell: lava, water, sand, or empty for none. */  const ribbonOf = (at: d.v2i, kind: number, streams: d.v3f) => {    'use gpu';    const falling = kind !== Kind.fixed && cells.kindAt(at + d.vec2i(0, 1)) === Kind.gas;    if (kind !== Kind.gas && !falling) return d.u32(Matter.empty);    if (streams.y > RIBBON_LEAST.y) return d.u32(Matter.lava);    if (streams.x > RIBBON_LEAST.x) return d.u32(Matter.water);    if (streams.z > RIBBON_LEAST.z) return d.u32(Matter.sand);    return d.u32(Matter.empty);  };  /** A falling stream of lava, water or sand, drawn over the cell at `at`. */  const fallColor = (ribbon: number, cell: number, at: d.v2i, back: d.v3f, streams: d.v3f) => {    'use gpu';    const center = d.vec2f(at) + 0.5;    const air = light.lightAt(at);    if (ribbon === Matter.lava) {      const full = std.smoothstep(RIBBON_LEAST.y, RIBBON.full, streams.y);      const streak = streaksAt(center, seconds.$, RIBBON.lavaSpeed);      return std.mix(LAVA_FALL.edge, LAVA_FALL.core, full * (0.4 + streak * 0.6));    }    if (ribbon === Matter.water) {      const full = std.smoothstep(RIBBON_LEAST.x, RIBBON.full, streams.x);      const streak = streaksAt(center, seconds.$, RIBBON.waterSpeed);      return foamIn(std.mix(FALL, WATER.foam, streak * (0.4 + full * 0.6)), air);    }    const full = std.smoothstep(RIBBON_LEAST.z, SAND_FALL.full, streams.z);    const grains = grainsAt(center, seconds.$);    const washed = std.mix(air, d.vec3f(tone.luminance(air)), GRAINS.wash);    const sand = SAND * (GRAINS.ambient + washed * SAND_FALL.light);    const covered = std.mix(SAND_FALL.edge, SAND_FALL.middle, full);    if (matterOf(cell) !== Matter.sand && grains <= covered) {      return std.mix(back, sand * 0.5, full * SAND_FALL.dust);    }    const grain = grainOf(d.u32(Matter.sand), shadeOf(cell), at);    return sand * (1 + std.abs(grains) * SAND_FALL.glint) * grain;  };  /** A crystal, brighter towards its tip and along its outline. */  const crystalColor = (cell: number, at: d.v2i, glow: d.v3f) => {    'use gpu';    const along = d.f32(lifeOf(cell)) / 255;    let bright = shadeOf(cell) * std.mix(CRYSTAL_SHADE.base, CRYSTAL_SHADE.tip, along);    if (      cells.matterAt(at + d.vec2i(1, 0)) !== Matter.crystal ||      cells.matterAt(at + d.vec2i(-1, 0)) !== Matter.crystal ||      cells.matterAt(at + d.vec2i(0, 1)) !== Matter.crystal ||      cells.matterAt(at + d.vec2i(0, -1)) !== Matter.crystal    ) {      bright = std.max(bright, CRYSTAL_SHADE.outline * (0.5 + along * 0.6));    }    return glow * bright * light.shineAt(at);  };  /** Water seen through to the far wall, darker in depth and foaming where it is open. */  const waterColor = (at: d.v2i, back: d.v3f, streams: d.v3f) => {    'use gpu';    const air = light.lightAt(at);    if (cells.kindAt(at + d.vec2i(0, 1)) === Kind.gas) {      return std.mix(back, foamIn(WATER.foam, air), DROP);    }    let depth = d.f32(0);    for (let k = 1; k <= WATER.depth; k++) {      if (cells.matterAt(at - d.vec2i(0, k)) !== Matter.water) break;      depth += 1;    }    const through = back * WATER.through * std.exp(-depth * 0.2);    const scattered = WATER_TINT * (AMBIENT * 2 + air * 1.4);    let color = std.mix(through, scattered, WATER.scatter + depth * WATER.deep);    const center = d.vec2f(at) + 0.5;    const shimmer = perlin2d.sample(center * d.vec2f(0.2, 0.35) + d.vec2f(0, seconds.$ * 1.3));    color *= 0.92 + shimmer * 0.16;    if (depth === 0 && isAir(at - d.vec2i(0, 1))) color += air * WATER.glint + WATER_TINT * 0.02;    const open = d.f32(isAir(at + d.vec2i(-1, 0))) + d.f32(isAir(at + d.vec2i(1, 0)));    const froth = std.saturate(open * 0.3 + std.smoothstep(0, RIBBON.full, streams.x));    return std.mix(color, foamIn(WATER.foam, air), froth * 0.7);  };  /** Rock, wood, grains and other solids lit at their faces. Loose grains also catch light in air. */  const solidColor = (cell: number, at: d.v2i, glow: d.v3f) => {    'use gpu';    const matter = matterOf(cell);    const traits = cells.traitsOf(cell);    const shade = shadeOf(cell);    let face = faceLight(at);    let ambient = d.vec3f(ROCK_AMBIENT);    if (traits.kind === Kind.powder) {      face = std.max(face, light.lightAt(at) * FALLING_GRAINS);      face = std.mix(face, d.vec3f(tone.luminance(face)), GRAINS.wash);      ambient = d.vec3f(GRAINS.ambient);    }    let texture = grainOf(matter, shade, at);    if (traits.kind === Kind.fixed && matter !== Matter.wood && matter !== Matter.burning) {      texture = stoneAt(d.vec2f(at) + 0.5, shade);    }    let color = traits.albedo * texture * (ambient + face);    if (matter === Matter.burning) color += glow;    if (matter === Matter.obsidian && shade > OBSIDIAN_GLINT.shade) {      color += face * OBSIDIAN_GLINT.gain * traits.albedo * 4;    }    return color;  };  /** The cave at a cell under its vapour: a falling stream, or the cell's own matter. */  const caveColor = (cell: number, at: d.v2i) => {    'use gpu';    const center = d.vec2f(at) + 0.5;    const air = light.lightAt(at);    const traits = cells.traitsOf(cell);    const back = BACK * wallAt(center) * (AMBIENT + air) + air * HAZE;    const glow = glowOf(traits.glow, cell, center, seconds.$);    const streams = densities.streamsAt(at);    const ribbon = ribbonOf(at, traits.kind, streams);    if (ribbon !== Matter.empty) return fallColor(ribbon, cell, at, back, streams);    switch (matterOf(cell)) {      case Matter.lava:        return lavaAt(center, seconds.$) * (1 + streams.y);      case Matter.crystal:        return crystalColor(cell, at, glow);      case Matter.fire:        return back + glow;      case Matter.water:        return waterColor(at, back, streams);    }    if (traits.kind === Kind.gas) return back;    return solidColor(cell, at, glow);  };  /** Steam and smoke over `color`, thinner over solids and grains. */  const veiled = (color: d.v3f, pixel: d.v2f, kind: number) => {    'use gpu';    const mist = densities.vapourAt(pixel);    const lit = light.radianceAt(pixel);    const shine = lit * VAPOUR.light;    const glowing = shine / (1 + std.max(shine.x, std.max(shine.y, shine.z)) / VAPOUR.peak);    let veil = d.f32(1);    if (kind === Kind.fixed || kind === Kind.powder) veil = VAPOUR.solids;    const steam = std.smoothstep(0, VAPOUR.full, mist.x) * VAPOUR.steam * veil;    const smoke = std.smoothstep(0, VAPOUR.full, mist.y) * VAPOUR.smoke * veil;    const steamy = std.mix(color, STEAM * (AMBIENT * VAPOUR.ambient + glowing), steam);    return std.mix(steamy, SMOKE * (AMBIENT + lit), smoke);  };  const surfaceOf = (corner: d.Infer<typeof passes.fullScreen.Vertex>) => {    'use gpu';    const pixel = corner.uv * GRID;    const at = d.vec2i(std.floor(pixel));    if (view.$ === VIEWS.radiance) return Seen({ color: d.vec4f(light.radianceAt(pixel), 1) });    if (view.$ === VIEWS.medium) {      const medium = light.mediumAt(at);      const color =        medium.rgb * DIAGNOSTIC_EMISSION_GAIN + EXTINCTION_TINT * std.min(medium.a, OPAQUE);      return Seen({ color: d.vec4f(color, 1) });    }    const cell = cells.cellAt(at);    const color = veiled(caveColor(cell, at), pixel, cells.traitsOf(cell).kind);    return Seen({ color: d.vec4f(color, 1) });  };  const drawing: passes.Drawing<typeof passes.fullScreen.Vertex, d.Infer<typeof Seen>> = {    ...passes.fullScreen,    label: 'cave',    surfaceOf,    color: passes.shaded,    inject: () => (cfg) => cfg.pipe(cells.inject()),    draw: (pass) => pass.draw(3),  };  return {    drawing,    view: (name: View) => view.write(VIEWS[name]),  };}