Domino run

Tap a domino to start a wave along 1 474 rounded boxes on a walnut floor. GPU picking finds the domino, and a fixed-step kernel topples the run through S-bends, three forks and twin spirals. Events count fallen dominoes, and each strike emits dust. A low sun lights and shadows the run, with bloom and a filmic tone curve. Ambient occlusion from a half-resolution depth pass darkens the sky light between dominoes and in the bends; a setting turns it off, and another shows it alone in grey. The Domino run tutorial builds it. A second layout uses a 100 × 60 field to show a portrait. A kernel samples an image over each domino's cell and paints its back with the nearest of sixteen colours, so the picture appears as the wave passes. The default picture is a kernel-drawn sunset. Drop an image to use your own.

Files, in reading order

  1. scene.tsCreates the scene: its resources, its settings and what each frame records.
  2. dominoes.tsAllocates enough capacity for the larger layout.
  3. topple.tsComputes next angles from current neighbour states, then copies them into the table.
  4. floor.tsLays a walnut floor 40 m square under the run.
  5. picture.tsSamples an image over each domino cell, averages it and paints the back with the nearest palette colour.
  6. field.tsOrders the far-edge lead line before its columns of ROWS dominoes.
  7. layout.tsOrders the lead before two branches to the spirals.
  8. sunset.tsDraws a banded sky, a striped sun and three hill ranges.
  9. ui.ts

Kit modules ao, baker, bindings, bloom, cameras, clock, coroutines, events, finish, images, instances, lighting, materials, meshPool, owner, particles, passes, picker, pipelines, shading, targets, tone. Also @typegpu/color, @typegpu/noise, math, typegpu.

scene.ts

import {  bindings,  bloom,  cameras,  clock,  coroutines,  lighting,  meshPool,  ao,  owner,  particles,  passes,  pipelines,  finish,  shading,  targets,  tone,  type Frame,  type Surface,} from '@typegpu/kit';import { d, tgpu, type TgpuRoot } from 'typegpu';import type { Picture, Pointing } from '../contract.ts';import type { Settings } from '../settings.ts';import { createDominoes, type Layout } from './dominoes.ts';import { createTopple, puff, STEP } from './topple.ts';import { createFloor } from './floor.ts';import { createPicture } from './picture.ts';import { MIDDLE } from './field.ts';/** Linear colour of the dim room. The floor fades into it. */export const ROOM = [0.07, 0.05, 0.035] as const;/** Direction toward a sun `degrees` above the floor, to the left and behind the run. */export const sunAt = (degrees: number): [number, number, number] => {  const up = (degrees * Math.PI) / 180;  const around = 2.3;  return [Math.cos(up) * Math.cos(around), Math.sin(up), -Math.cos(up) * Math.sin(around)];};/** * Each layout's camera, the point the automatic orbit turns round, and the seconds the fallen * dominoes rest before automatic replay stands them up. */export const VIEWS = {  run: {    camera: { target: [0, 0, 45], distance: 185, yaw: -0.3, pitch: 0.38 },    pivot: [0, -135],    rest: 2,  },  portrait: {    camera: { target: [MIDDLE[0], 0, MIDDLE[1] + 8], distance: 330, yaw: 0, pitch: 0.95 },    pivot: MIDDLE,    rest: 4,  },} as const;/** Linear light: a warm low sun, blue sky, and light off the floor warming shaded sides. */export const LIGHT = {  sunDirection: sunAt(24),  sunColor: [1, 0.7, 0.4],  sunIntensity: 5.6,  skyTop: [0.19, 0.33, 0.78],  skyBottom: [0.2, 0.13, 0.07],  skyIntensity: 0.75,  skyReflection: 0.8,  fogColor: ROOM,  fogDensity: 0.0012,} as const;/** Shadow resolution of 4096 texels across 5.6 m, centred on the run. */export const SHADOW = { size: 4096, extent: 560, filter: 9, center: [0, 0, -80] } as const;/** How far occluders darken sky light, in centimetres: a little under the gap between dominoes. */const CREASE = 2.5;/** Darkens the sky light in creases, such as under each domino and between close ones. */const occludedSky = (material: shading.Material, point: shading.Point) => {  'use gpu';  return shading.hemisphere(material, point) * ao.at(point.position);};/** Shows visibility as stored, white in the open and black in a closed crease. */const grey = (color: d.v3f) => {  'use gpu';  return d.vec3f(color.x);};/** Seconds the ghost finger waits before its tap. */const BEFORE_TAP = 1;/** Seconds without a newly fallen domino before the ghost counts the run as settled. */const SETTLED = 0.5;/** Peak turn of the automatic orbit, in radians per second. */const DRIFT_SPEED = 0.008;/** How fast the automatic orbit swings back and forth, in radians of its cycle per second. */const DRIFT_SWING = 0.1;/** * Tap a domino to start a wave along the run or reveal a portrait on the backs of a fallen * field. */export async function createScene(root: TgpuRoot, surface: Surface) {  const values = {    layout: 'run' as Layout,    /** Fraction of the layout to display, in wave order. */    share: 1,    /** Sun elevation in degrees. */    sun: 24,    /** Automatically starts the wave and stands dominoes up again. Stops when you tap. */    auto: true,    /** Darkens sky light under and between dominoes. */    occlusion: true,    /** Shows the finished picture, or the occlusion alone in grey. */    view: 'picture' as 'picture' | 'occlusion',  };  const own = owner();  const screen = own(    targets.create(root, surface, {      scene: { format: 'rgba16float', samples: 4 },      depth: { format: 'depth24plus', samples: 4 },      hdr: { format: 'rgba16float', sampled: true },    }),  );  const view = cameras.orbit(root, {    size: surface,    ...VIEWS.run.camera,    target: [...VIEWS.run.camera.target],    fov: 0.6,    near: 2,    far: 4000,    pitchRange: [0.08, 1.4],    distanceRange: [40, 900],  });  /** Half-resolution depth the opaque drawings draw first, for the occlusion to read. */  const early = own(    targets.create(root, surface, {      depth: { format: 'depth32float', scale: 0.5, sampled: true },    }),  );  const prepass = passes    .create(root, early, { depth: passes.depth }, { label: 'prepass' })    .pipe(view.inject());  const occlusion = own(    ao.create(root, view, {      depth: () => early.textures.depth,      settings: { radius: CREASE },      enabled: () => values.occlusion,    }),  );  const light = own(    lighting.create(root, {      settings: { ...LIGHT, sunDirection: sunAt(values.sun) },      shading: { ambient: tgpu.fn(occludedSky).pipe(occlusion.inject()) },      shadow: SHADOW,    }),  );  const shared = bindings.share(root, { ao: occlusion.part });  const scene = passes    .create(      root,      screen,      { scene: passes.color, depth: passes.depth },      { resolve: { scene: 'hdr' }, clear: [...ROOM, 1], shared },    )    .pipe(view.inject())    .beneath(light.inject());  const pool = own(meshPool.create(root, { capacity: 2 }));  const floor = own(createFloor(root, pool));  const dominoes = own(createDominoes(root, { pool, layout: values.layout, share: values.share }));  const picture = own(createPicture(root, { dominoes }));  const sim = clock.fixedStep({ step: STEP, maxSteps: 8 });  const dust = own(    particles.create(root, view, {      capacity: 2048,      step: STEP,      blend: 'over',      shade: tgpu.fn(puff).pipe(light.inject()),      settings: { gravity: [0, -4, 0], drag: 3 },    }),  );  const topple = own(createTopple(root, { view, surface, dominoes, dust }));  const drawn = [scene.add(floor.drawing), scene.add(dominoes.renderer), scene.add(dust.drawing)];  const casters = [light.shadow.add(dominoes.renderer)];  const shapes = [prepass.add(floor.drawing), prepass.add(dominoes.renderer)];  const glow = own(    bloom.create(root, {      source: () => screen.textures.hdr,      settings: { threshold: 2, knee: 0.8 },    }),  );  const final = own(    finish.create(root, {      format: surface.format,      source: () => screen.textures.hdr,      glow,      tone: tone.aces,      settings: { exposure: 0.85, glowIntensity: 0.1, vignette: 0.45 },    }),  );  const occlusionView = own(    finish.create(root, {      format: surface.format,      source: occlusion,      tone: null,      encode: false,      grade: grey,      settings: { vignette: 0, dither: 0 },    }),  );  /** The final pass for each `view`. */  const outputs = { picture: final, occlusion: occlusionView };  await pipelines.compile([    scene,    light.shadow,    prepass,    occlusion,    topple,    picture,    dust,    glow,    final,    occlusionView,  ]);  const scheduler = own(coroutines.create());  /** The ghost finger while automatic replay runs. */  let haunting: coroutines.Coroutine<void> | undefined;  const stopGhost = () => {    haunting?.cancel();    haunting = undefined;  };  let clearing = false;  /** Stands the dominoes up again and clears the fallen count. */  const standUp = () => {    topple.rise();    clearing = true;  };  /** Resets the layout and camera. */  const lay = () => {    dominoes.lay(values.layout, values.share);    picture.repaint();    const { camera } = VIEWS[values.layout];    Object.assign(view.goal, { ...camera, target: [...camera.target] });    stopGhost();    clearing = true;  };  const settings: Settings<typeof values> = {    values,    knobs: {      layout: { label: 'layout', options: ['run', 'portrait'] },      share: { label: 'layout fraction', min: 0.2, max: 1, step: 0.01 },      sun: { label: 'sun elevation (degrees)', min: 10, max: 60, step: 1 },      auto: { label: 'automatic replay', live: true },      occlusion: { label: 'ambient occlusion' },      view: { options: ['picture', 'occlusion'] },    },    write(key) {      if (key === undefined || key === 'sun') {        light.settings.patch({ sunDirection: sunAt(values.sun) });      }      if (key === 'layout' || key === 'share') lay();    },    actions: { reset: standUp },    readouts: { fallen: () => topple.counts.latest?.fallen ?? 0 },  };  const pointing = {    /** Pushes the picked domino and stops automatic replay. */    press(x, y) {      topple.tap(x, y);      values.auto = false;    },  } satisfies Pointing;  const onScreen = [0, 0, 0];  const beforeTap = coroutines.seconds(BEFORE_TAP);  /** Taps the first domino, then stands the run up once no domino has fallen for a while. */  function* ghost() {    for (;;) {      while (topple.rising) yield* coroutines.nextFrame;      yield* beforeTap;      if (view.projectAt(dominoes.first, onScreen)) topple.tap(onScreen[0]!, onScreen[1]!);      for (let quiet = 0, seen = 0; ;) {        const { dt } = yield* coroutines.nextFrame;        const fallen = topple.counts.latest?.fallen ?? 0;        quiet = fallen === seen ? quiet + dt : 0;        seen = fallen;        if (fallen > 0 && quiet >= SETTLED + VIEWS[values.layout].rest) break;      }      standUp();    }  }  /**   * Orbits by `angle` radians around the layout pivot during automatic replay. Distant dominoes   * move less on screen than nearby ones.   */  const drift = (angle: number) => {    const { goal } = view;    const [px, pz] = VIEWS[values.layout].pivot;    const x = goal.target[0] - px;    const z = goal.target[2] - pz;    const cos = Math.cos(angle);    const sin = Math.sin(angle);    goal.target[0] = px + x * cos + z * sin;    goal.target[2] = pz - x * sin + z * cos;    goal.yaw += angle;  };  /** Crops `image` to fill the portrait field and switches to that layout. */  const show = (image: Picture) => {    picture.show(image);    if (values.layout === 'portrait') return;    values.layout = 'portrait';    lay();  };  return {    view,    settings,    pointing,    ui: { portrait: show },    frame(frame: Frame) {      if (values.auto) drift(frame.dt * DRIFT_SPEED * Math.cos(frame.time * DRIFT_SWING));      view.update(frame.dt);      screen.fit();      early.fit();      if (values.auto) haunting ??= scheduler.start(ghost());      else stopGhost();      scheduler.run(frame);      if (clearing) topple.counts.clear(frame.encoder);      clearing = false;      const steps = sim.advance(frame.dt);      const compute = frame.encoder.beginComputePass();      picture.record(compute);      topple.record(compute, steps, frame.dt);      for (let step = 0; step < steps; step++) dust.step(compute);      dust.compact(compute, sim.ahead);      compute.end();      topple.counts.capture(frame);      light.shadow.record(frame, casters);      if (values.occlusion) prepass.record(frame, shapes);      occlusion.record(frame);      scene.record(frame, drawn);      glow.record(frame);      outputs[values.view].record(frame, surface.view(frame));    },    destroy: own.destroy,  };}

dominoes.ts

import { srgbToLinear } from '@typegpu/color';import { meshes } from '@typegpu/kit/geometry';import { instances, materials, type MeshPool } from '@typegpu/kit';import { d, std, type TgpuRoot } from 'typegpu';import { firstOf, HEIGHT, layOut, NONE, THICKNESS, WIDTH } from './layout.ts';import { layField } from './field.ts';/** Linear colours for the domino palette. */const PALETTE = (  [    [0.95, 0.92, 0.86],    [0.96, 0.7, 0.23],    [0.96, 0.52, 0.3],    [0.8, 0.16, 0.16],    [0.12, 0.6, 0.58],    [0.14, 0.28, 0.6],  ] as const).map(([r, g, b]) => srgbToLinear(d.vec3f(r, g, b)));/** Satin lacquer with a broad highlight. */const ROUGHNESS = 0.38;/** Layouts ordered by wave arrival. */export const LAYOUTS = { run: layOut(), portrait: layField() };export type Layout = keyof typeof LAYOUTS;/** * Each domino pivots about its bottom front edge at `foot`, with orientation `yaw`, tilt * `angle` and angular speed `spin` in radians per second. `hit` records impact speed for one * step. `prev` and `next` hold neighbour ids, with 0 for no neighbour. `order` measures * progress along the run from 0 to 1. The kernels in `topple.ts` write the transform on the * GPU. * * The front uses the material colour. A portrait kernel paints `back` from image coordinates * `cell`, with -1 for no pixel. Edges blend from front to back colour as the domino falls, * revealing the picture. */export const Domino = instances.define('Domino', {  transform: 'device',  host: { foot: d.vec3f, yaw: d.f32, order: d.f32, prev: d.u32, next: d.vec2u, cell: d.vec2f },  device: { angle: d.f32, spin: d.f32, hit: d.f32, back: d.vec3f },  material: (point, domino, base) => {    'use gpu';    // The back faces -z in domino space    const turnedBack = domino.rotation * d.vec4f(-1, -1, -1, 1);    const normal = instances.rotate(turnedBack, point.normal);    // Standing dominoes show the image only on their flat backs. Blend the edge colour as they    // fall so the rounded tops hide the picture until then    const fallen = std.smoothstep(0.6, 1.2, domino.angle);    const back = std.mix(      std.smoothstep(0.9, 0.97, -normal.z),      std.smoothstep(0.6, 0.8, -normal.z),      fallen,    );    const front = std.smoothstep(0.6, 0.8, normal.z);    const edges = std.mix(base.color, domino.back, fallen);    const color = std.mix(std.mix(edges, base.color, front), domino.back, back);    return materials.glossy(color, base.roughness);  },});const shape = meshes.roundedBox({  width: WIDTH,  height: HEIGHT,  depth: THICKNESS,  radius: 0.12,  segments: 2,});/** Unit quaternion rotating +z toward `yaw`. */const turned = (yaw: number) => [0, Math.sin(yaw / 2), 0, Math.cos(yaw / 2)] as const;/** * Allocates enough capacity for the larger layout. `lay` stands up the first `share` of a * layout in wave order without creating resources. */export function createDominoes(  root: TgpuRoot,  options: { pool: MeshPool; layout: Layout; share: number },) {  const { pool } = options;  const capacity = Math.max(...Object.values(LAYOUTS).map((each) => each.length));  const set = instances.create(root, pool, { kind: Domino, capacity });  const mesh = pool.add(shape);  let ids: instances.Id<'Domino'>[] = [];  let first: readonly [number, number, number] = [0, 0, 0];  /** Replaces the layout with its first `share` of dominoes, standing upright. */  const lay = (layout: Layout, share: number) => {    for (const id of ids) set.remove(id);    const all = LAYOUTS[layout];    const run = firstOf(all, Math.round(all.length * share));    first = [all[0]!.x, HEIGHT / 2, all[0]!.z];    const last = Math.max(...run.map((each) => each.depth), 1);    ids = run.map((each) =>      set.add(mesh, {        position: [each.x, HEIGHT / 2, each.z],        rotation: turned(each.yaw),        ...materials.glossy(PALETTE[each.color]!, ROUGHNESS),        foot: [each.x, 0, each.z],        yaw: each.yaw,        order: each.depth / last,        cell: each.cell ?? [-1, -1],        angle: 0,        spin: 0,        hit: 0,        back: PALETTE[each.color]!,      }),    );    const idOf = (index: number) => (index === NONE ? 0 : ids[index]!);    run.forEach((each, i) => {      set.write(ids[i]!, { prev: idOf(each.prev), next: [idOf(each.next[0]), idOf(each.next[1])] });    });    set.flush();  };  lay(options.layout, options.share);  return {    set,    /** Position of the first domino, used by the automatic tap. */    get first() {      return first;    },    renderer: set.renderer(),    lay,    destroy: () => set.destroy(),  };}export type Dominoes = ReturnType<typeof createDominoes>;

topple.ts

import {  events,  Particle,  picker,  shading,  type CameraBase,  type Particles,  type Surface,} from '@typegpu/kit';import { randf } from '@typegpu/noise';import { d, std, tgpu, type TgpuComputePass, type TgpuRoot } from 'typegpu';import { Domino, type Dominoes } from './dominoes.ts';import { HEIGHT, SPACING, THICKNESS, WIDTH } from './layout.ts';/** Seconds per step of the fall. */export const STEP = 1 / 120;/** * Angular acceleration from gravity, in radians per second squared. Slower than a physical * domino. */export const GRAVITY = 700;/** Tilt at which the centre of mass passes the pivot and gravity tips the domino forward. */export const TIP = Math.atan(THICKNESS / HEIGHT);/** The share of a domino's spin it hands the one it strikes. */export const CARRY = 0.85;/** The share of a leaning domino's weight that pushes the one it leans on. */export const PUSH = 0.3;/** Angular speed imparted by a tap, enough to tip the domino. */export const KICK = 6;/** Seconds the reset wave takes from the last domino to the first. It slows toward the start. */export const RISE = 5;/** Seconds each domino takes to rise. */export const LIFT = 0.5;/** Seconds a reset runs: the wave, the last domino's lift and a few steps for it to settle. */const RESET = RISE + LIFT + 0.2;/** * Reset start time as a fraction of `RISE`, at progress `back` from the last domino. The wave * travels nearly five times faster at the end than at the start. */export const rewound = (back: number) => {  'use gpu';  return back * (0.35 + 0.65 * back);};const WORKGROUP = 64;/** Next-step values, copied to the table after all dominoes have read the current step. */const Motion = d.struct({ angle: d.f32, spin: d.f32, hit: d.f32 });/** * Resting angle against a neighbour tilted by `next` radians. The top corner meets the * neighbour back face at distance `SPACING`. */export const leanOn = (next: number) => {  'use gpu';  const reach = (SPACING * std.cos(next) - THICKNESS) / HEIGHT;  return next + std.asin(std.clamp(reach, -1, 1));};/** * One step of a domino's turn from `angle` and `spin`: gravity tips it past `TIP`, `pushed` is * the weight of a neighbour leaning on it, and the ground stops it. Returns the next angle and * spin. */export const swing = (angle: number, spin: number, pushed: number) => {  'use gpu';  let turning = spin;  if (angle > 0 || spin > 0) {    turning += GRAVITY * (std.sin(angle - TIP) + pushed * PUSH) * STEP;  }  const next = angle + turning * STEP;  if (next <= 0) return d.vec2f();  return d.vec2f(next, turning);};/** The way domino `i` falls. */const forwardOf = (i: number) => {  'use gpu';  const yaw = Domino.yaw(i);  return d.vec3f(std.sin(yaw), 0, std.cos(yaw));};/** Rotates toward the fall direction, then tilts forward by `angle`. */const rotationOf = (yaw: number, angle: number) => {  'use gpu';  const turn = d.vec2f(std.sin(yaw / 2), std.cos(yaw / 2));  const tip = d.vec2f(std.sin(angle / 2), std.cos(angle / 2));  return d.vec4f(turn.y * tip.x, turn.x * tip.y, -turn.x * tip.x, turn.y * tip.y);};/** Centre position after pivoting by `angle` around the bottom front edge. */const middleOf = (i: number, angle: number) => {  'use gpu';  const forward = forwardOf(i);  const edge = Domino.foot(i) + forward * (THICKNESS / 2);  const up = (HEIGHT / 2) * std.cos(angle) + (THICKNESS / 2) * std.sin(angle);  const ahead = (HEIGHT / 2) * std.sin(angle) - (THICKNESS / 2) * std.cos(angle);  return edge + d.vec3f(0, up, 0) + forward * ahead;};/** * Chooses the next domino that limits the lean most. Returns id 0 at the end of a line, where * the domino falls flat. */const restsOn = (i: number) => {  'use gpu';  const next = Domino.next(i);  if (    next.y !== 0 &&    leanOn(Domino.angle(Domino.slotOf(next.y))) < leanOn(Domino.angle(Domino.slotOf(next.x)))  ) {    return next.y;  }  return next.x;};/** Maximum lean against domino id `next`. Id 0 allows falling flat. */const leanLimit = (next: number) => {  'use gpu';  if (next === 0) return d.f32(Math.PI / 2);  return leanOn(Domino.angle(Domino.slotOf(next)));};/** * Shades fading dust with sun and sky light. Fill its lighting accessors with * `tgpu.fn(puff).pipe(light.inject())`. */export const puff = (particle: Particle, local: d.v2f) => {  'use gpu';  const env = shading.settings();  const sun = shading.sunShare(shading.pointAt(particle.position, d.vec3f(0, 1, 0)));  const light =    env.sunColor * (env.sunIntensity * sun * 0.3) + env.skyTop * (env.skyIntensity * 0.6);  const dot = std.saturate(1 - std.length(local));  const left = 1 - particle.age / particle.life;  const alpha = dot * dot * left * particle.color.w;  return d.vec4f(particle.color.xyz * light * alpha, alpha);};/** Mostly transparent, pale warm grey. */const DUST = d.vec4f(0.85, 0.78, 0.68, 0.14);/** * Computes next angles from current neighbour states, then copies them into the table. GPU * picking turns a tap into an initial spin. Events count fallen dominoes, and impacts emit * dust. */export function createTopple(  root: TgpuRoot,  options: { view: CameraBase; surface: Surface; dominoes: Dominoes; dust: Particles },) {  const { view, surface, dominoes, dust } = options;  const { set } = dominoes;  const capacity = set.capacity;  const motion = root.createMutable(d.arrayOf(Motion, capacity));  /** Seconds since reset began, or -1 when inactive. */  const rising = root.createUniform(d.f32, -1);  const counts = events.create(root, ['fallen'], { hz: 10 });  /** Emits dust at the back foot of the struck domino `i`. */  const strike = (i: number) => {    'use gpu';    const forward = forwardOf(i);    const side = d.vec3f(forward.z, 0, -forward.x);    const at = Domino.foot(i) - forward * (THICKNESS / 2);    randf.seed2(d.vec2f(at.xz));    for (let k = 0; k < 3; k++) {      const across = randf.sample() * 2 - 1;      dust.emit(        Particle({          position: at + side * (across * WIDTH * 0.5) + d.vec3f(0, 0.3, 0),          age: 0,          velocity: side * (across * 6) + d.vec3f(0, 2 + randf.sample() * 3, 0) - forward * 3,          life: 0.7 + randf.sample() * 0.5,          color: DUST,          size: 2 + randf.sample() * 1.5,          seed: randf.sample(),        }),      );    }  };  /** Steps each domino: impacts start its spin, gravity tips it, and what it leans on stops it. */  const fall = tgpu.computeFn({    workgroupSize: [WORKGROUP],    in: { gid: d.builtin.globalInvocationId },  })(({ gid }) => {    'use gpu';    const i = gid.x;    if (i >= Domino.span() || !Domino.alive(i)) return;    let angle = Domino.angle(i);    let spin = Domino.spin(i);    if (rising.$ >= 0) {      // During reset, `hit` stores the starting angle. Limit the lean against the rising      // neighbour so dominoes cannot pass through each other      const begin = rewound(1 - Domino.order(i)) * RISE;      const t = std.saturate((rising.$ - begin) / LIFT);      let from = Domino.hit(i);      if (t === 0) from = angle;      angle = std.min(from * (1 - std.smoothstep(0, 1, t)), leanLimit(restsOn(i)));      if (t === 1) from = 0;      motion.$[i] = Motion({ angle, spin: 0, hit: from });      return;    }    const was = angle;    let pushed = d.f32(0);    const prev = Domino.prev(i);    if (prev !== 0) {      const p = Domino.slotOf(prev);      const before = Domino.angle(p);      if (angle === 0 && spin === 0 && Domino.hit(p) > 0) {        spin = Domino.hit(p) * CARRY;        strike(i);      }      if (before >= leanOn(angle) - 0.001) pushed = std.max(std.sin(before - TIP), 0);    }    const swung = swing(angle, spin, pushed);    angle = swung.x;    spin = swung.y;    const on = restsOn(i);    const limit = leanLimit(on);    let hit = d.f32(0);    if (angle >= limit) {      hit = spin;      angle = limit;      if (on === 0) spin = 0;      else spin = std.min(spin, Domino.spin(Domino.slotOf(on)));    }    if (was < Math.PI / 4 && angle >= Math.PI / 4) counts.add.fallen(1);    motion.$[i] = Motion({ angle, spin, hit });  });  const place = tgpu.computeFn({    workgroupSize: [WORKGROUP],    in: { gid: d.builtin.globalInvocationId },  })(({ gid }) => {    'use gpu';    const i = gid.x;    if (i >= Domino.span() || !Domino.alive(i)) return;    const moved = Motion(motion.$[i]!);    const row = Domino.device.$[i]!;    row.angle = moved.angle;    row.spin = moved.spin;    row.hit = moved.hit;    row.rotation = rotationOf(Domino.yaw(i), moved.angle);    row.position = middleOf(i, moved.angle);  });  const hits = picker.create(root, view, {    capacity,    size: surface,    position: Domino.position.pipe(set.inject()),    radius: () => {      'use gpu';      return HEIGHT / 2;    },    // Only standing dominoes accept a push    alive: tgpu      .fn((i: number) => {        'use gpu';        return Domino.alive(i) && Domino.angle(i) === 0;      })      .pipe(set.inject()),  });  const push = tgpu.computeFn({ workgroupSize: [1] })(() => {    'use gpu';    const i = hits.found();    if (i === picker.none || Domino.spin(i) > 0) return;    Domino.device.$[i]!.spin = KICK;  });  const falling = root    .pipe(set.inject())    .pipe(counts.inject())    .pipe(dust.inject())    .createComputePipeline({ compute: fall });  const placing = root.pipe(set.inject()).createComputePipeline({ compute: place });  const pushing = root    .pipe(hits.inject())    .pipe(set.inject())    .createComputePipeline({ compute: push });  let tapped: [x: number, y: number] | undefined;  /** The CPU's copy of `rising`. */  let risen = -1;  return {    counts,    initAsync: () =>      Promise.all([        hits.initAsync(),        falling.initAsync(),        placing.initAsync(),        pushing.initAsync(),      ]),    /** Picks and pushes a domino at `x, y`, from 0 to 1 across the canvas from its top left. */    tap(x: number, y: number) {      tapped = [x, y];    },    /** Stands dominoes up from the last to the first over `RISE` seconds. */    rise() {      risen = 0;    },    get rising() {      return risen >= 0;    },    /**     * Applies the picked push and runs `steps` simulation steps. `dt` is seconds since the last     * frame.     */    record(pass: TgpuComputePass, steps: number, dt: number) {      if (risen >= 0) {        rising.write(risen);        risen += dt;        if (risen > RESET) {          risen = -1;          rising.write(-1);        }      }      if (tapped) {        hits.pick(pass, ...tapped);        pass.setPipeline(pushing);        pass.dispatchWorkgroups(1);        tapped = undefined;      }      const groups = Math.ceil(set.span / WORKGROUP);      for (let step = 0; step < steps; step++) {        pass.setPipeline(falling);        pass.dispatchWorkgroups(groups);        pass.setPipeline(placing);        pass.dispatchWorkgroups(groups);      }    },    destroy() {      hits.destroy();      counts.destroy();      motion.buffer.destroy();    },  };}

floor.ts

import { srgbToLinear } from '@typegpu/color';import { meshes } from '@typegpu/kit/geometry';import { perlin2d, randf } from '@typegpu/noise';import { instances, materials, type MeshPool } from '@typegpu/kit';import { d, std, type TgpuRoot } from 'typegpu';/** A board's width and length, in centimetres. The boards run along z. */const BOARD = d.vec2f(16, 180);const DARK = srgbToLinear(d.vec3f(0.2, 0.13, 0.085));const LIGHT = srgbToLinear(d.vec3f(0.36, 0.24, 0.155));const SEAM = srgbToLinear(d.vec3f(0.08, 0.05, 0.035));/** The point the floor fades out from, and how far out it fades into the dim room. */const CENTER = d.vec2f(0, -80);const FADE = d.vec2f(350, 1200);/** * Returns pixel coverage of lines around integer coordinates, with half-width `width`. Fades * lines at a spacing of a few pixels to prevent distant shimmer. */const seam = (at: number, width: number) => {  'use gpu';  const off = std.abs(std.fract(at + 0.5) - 0.5);  const pixel = std.max(std.fwidth(at), 1e-5);  const covered = 1 - std.smoothstep(width - pixel, width + pixel, off);  return covered * std.saturate(1.5 - pixel * 6);};/** * Draws walnut boards with per-board shades, broad patterns, fine grain and dark seams. * Averages grain smaller than a pixel. */const woodAt = (at: d.v2f) => {  'use gpu';  const across = at.x / BOARD.x;  const row = std.floor(across);  randf.seed(row);  // Offset joints between rows  const along = at.y / BOARD.y + randf.sample();  randf.seed2(d.vec2f(row, std.floor(along)));  const shade = randf.sample();  const phase = randf.sample() * 50;  const figure = perlin2d.sample(d.vec2f(at.x * 0.3, at.y * 0.008) + phase);  const lines = perlin2d.sample(d.vec2f(at.x * 1.1 + figure * 2, at.y * 0.02) + phase * 2);  const fine = std.pow(1 - std.abs(lines), 5);  const sharp = std.saturate(2 - std.fwidth(at.x) * 8);  const grain = std.mix(0.18, fine, sharp);  const tone = std.saturate(0.25 + shade * 0.45 + figure * 0.3);  const wood = std.mix(DARK, LIGHT, tone) * (1 - grain * 0.45);  const seams = std.max(seam(across, 0.012), seam(along, 0.003));  const far = std.smoothstep(FADE.x, FADE.y, std.distance(at, CENTER));  return std.mix(wood, SEAM, seams * 0.8) * (1 - far * 0.85);};const Floor = instances.define('Floor', {  material: (point) => {    'use gpu';    return materials.glossy(woodAt(point.position.xz), 0.78);  },});/** Lays a walnut floor 40 m square under the run. */export function createFloor(root: TgpuRoot, pool: MeshPool) {  const boards = instances.create(root, pool, { kind: Floor, capacity: 1 });  boards.add(pool.add(meshes.plane({ width: 4000, depth: 4000 })), { position: [0, 0, -400] });  boards.flush();  return { drawing: boards.renderer(), destroy: () => boards.destroy() };}

picture.ts

import { linearRgbToOklab, srgbToLinear } from '@typegpu/color';import { clamp } from 'math';import { d, std, tgpu, type TgpuComputePass, type TgpuRoot, type TgpuTexture } from 'typegpu';import { images } from '@typegpu/kit';import type { Picture } from '../contract.ts';import { Domino, type Dominoes } from './dominoes.ts';import { COLUMNS, ROWS } from './field.ts';import { createSunset, SIZE } from './sunset.ts';/** * Sixteen sRGB colours available for domino art. Each image pixel uses the nearest palette * colour. */const COLORS = [  [0.07, 0.07, 0.08],  [0.95, 0.94, 0.9],  [0.55, 0.55, 0.56],  [0.82, 0.12, 0.14],  [0.48, 0.08, 0.1],  [0.97, 0.5, 0.1],  [0.98, 0.84, 0.18],  [0.5, 0.78, 0.28],  [0.1, 0.42, 0.22],  [0.45, 0.72, 0.95],  [0.2, 0.45, 0.82],  [0.12, 0.22, 0.6],  [0.46, 0.22, 0.62],  [0.96, 0.55, 0.7],  [0.45, 0.27, 0.14],  [0.9, 0.72, 0.55],] as const;const PALETTE = COLORS.map(([r, g, b]) => srgbToLinear(d.vec3f(r, g, b)));/** Image crop coordinates from 0 to 1 and the mip level matching the cell sample spacing. */const Framing = d.struct({ offset: d.vec2f, size: d.vec2f, level: d.f32 });/** Swappable image binding for the portrait. */const source = tgpu.bindGroupLayout({ image: { texture: d.texture2d(d.f32) } });/** Samples per cell side, averaged to one colour. */const SAMPLES = 4;const WORKGROUP = 64;/** Crops a `width × height` image with `levels` mips to fill the `COLUMNS × ROWS` field. */const framingOf = (width: number, height: number, levels: number) => {  const image = width / height;  const field = COLUMNS / ROWS;  const size = image > field ? d.vec2f(field / image, 1) : d.vec2f(1, image / field);  const offset = d.vec2f((1 - size.x) / 2, (1 - size.y) / 2);  const texels = (size.x * width) / COLUMNS / SAMPLES;  const level = clamp(Math.log2(texels), 0, levels - 1);  return { offset, size, level };};/** * Samples an image over each domino cell, averages it and paints the back with the nearest * palette colour. Starts with a sunset. `show` changes the image. */export function createPicture(root: TgpuRoot, options: { dominoes: Dominoes }) {  const { set } = options.dominoes;  const palette = root.createReadonly(d.arrayOf(d.vec3f, PALETTE.length), PALETTE);  const framing = root.createUniform(Framing, framingOf(SIZE[0], SIZE[1], 1));  const smooth = root.createSampler({    magFilter: 'linear',    minFilter: 'linear',    mipmapFilter: 'linear',  });  const sunset = createSunset(root);  /** Average linear colour over the image cell. */  const averageOver = (cell: d.v2f) => {    'use gpu';    let sum = d.vec3f();    for (let y = 0; y < SAMPLES; y++) {      for (let x = 0; x < SAMPLES; x++) {        const inField = (cell + (d.vec2f(x, y) + 0.5) / SAMPLES) / d.vec2f(COLUMNS, ROWS);        const uv = framing.$.offset + inField * framing.$.size;        sum += std.textureSampleLevel(source.$.image, smooth.$, uv, framing.$.level).rgb;      }    }    return sum / (SAMPLES * SAMPLES);  };  /** Finds the nearest palette colour in Oklab. */  const nearest = (color: d.v3f) => {    'use gpu';    const wanted = linearRgbToOklab(color);    let best = d.vec3f(palette.$[0]!);    let least = d.f32(1e9);    for (let k = d.u32(0); k < PALETTE.length; k++) {      const each = d.vec3f(palette.$[k]!);      const gap = std.distance(linearRgbToOklab(each), wanted);      if (gap < least) {        least = gap;        best = d.vec3f(each);      }    }    return best;  };  const paint = tgpu.computeFn({    workgroupSize: [WORKGROUP],    in: { gid: d.builtin.globalInvocationId },  })(({ gid }) => {    'use gpu';    const i = gid.x;    if (i >= Domino.span() || !Domino.alive(i)) return;    const cell = Domino.cell(i);    if (cell.x < 0) return;    Domino.device.$[i]!.back = nearest(averageOver(cell));  });  const painting = root.pipe(set.inject()).createComputePipeline({ compute: paint });  let image = root.createBindGroup(source, { image: sunset.texture });  /** The texture of the last image shown, which replaces the sunset. */  let shown: TgpuTexture | undefined;  let sunsetDrawn = false;  let changed = true;  return {    initAsync: () => Promise.all([sunset.initAsync(), painting.initAsync()]),    /**     * Crops `picture` to fill the field. Creates its texture and mips when the page receives a     * dropped file.     */    show(picture: Picture) {      const texture = images.create(root, picture);      const [width, height] = texture.props.size;      shown?.destroy();      shown = texture;      image = root.createBindGroup(source, { image: texture });      framing.write(framingOf(width, height, root.unwrap(texture).mipLevelCount));      changed = true;    },    /** Requests repainting after a layout change. */    repaint() {      changed = true;    },    /** Repaints only when the image or layout has changed. */    record(pass: TgpuComputePass) {      if (!changed) return;      changed = false;      if (!sunsetDrawn) {        sunset.record(pass);        sunsetDrawn = true;      }      pass.setPipeline(painting);      pass.setBindGroup(image);      pass.dispatchWorkgroups(Math.ceil(set.span / WORKGROUP));    },    destroy() {      sunset.destroy();      shown?.destroy();    },  };}

field.ts

// Lay out the portrait once on the CPU. A lead line along the far edge triggers columns toward// the viewer, producing a diagonal waveimport { NONE, SPACING, type Placed } from './layout.ts';/** Image width and height in dominoes, one pixel per domino. */export const COLUMNS = 100;export const ROWS = 60;/** The field's centre in world x and z. */export const MIDDLE = [0, -60] as const;/** Standing dominoes use eight-wide diagonal colour bands. The falling wave crosses these bands. */const bandOf = (column: number, row: number) => Math.floor((column - row + ROWS) / 8) % 6;/** * Orders the far-edge lead line before its columns of `ROWS` dominoes. The lead falls right and * columns fall toward +z. Each `cell` holds image coordinates from the top left. */export function layField(): Placed[] {  const placed: Placed[] = [];  const left = MIDDLE[0] - ((COLUMNS - 1) * SPACING) / 2;  const far = MIDDLE[1] - ((ROWS - 1) * SPACING) / 2;  for (let column = 0; column < COLUMNS; column++) {    placed.push({      x: left + column * SPACING,      z: far - SPACING,      yaw: Math.PI / 2,      depth: column,      prev: column === 0 ? NONE : column - 1,      next: [column + 1 < COLUMNS ? column + 1 : NONE, NONE],      color: 0,    });  }  for (let column = 0; column < COLUMNS; column++) {    const lead = placed[column]!;    for (let row = 0; row < ROWS; row++) {      const index = placed.length;      const prev = row === 0 ? column : index - 1;      placed.push({        x: lead.x,        z: far + row * SPACING,        yaw: 0,        depth: column + 1 + row,        prev,        next: [NONE, NONE],        color: bandOf(column, row),        cell: [column, row],      });      const pushes = placed[prev]!.next;      pushes[pushes[0] === NONE ? 0 : 1] = index;    }  }  return placed;}

layout.ts

// Lay out the run once on the CPU, with a domino every `SPACING` centimetres along curves. Each// domino pushes one or two neighbours. One world unit is one centimetreimport { clamp } from 'math';/** Width, height and thickness in centimetres. */export const WIDTH = 2.4;export const HEIGHT = 4.8;export const THICKNESS = 0.75;/** Spacing between domino centres along the run, about two thirds of their height. */export const SPACING = 3.1;/** Sentinel id for a missing neighbour. */export const NONE = -1;export interface Placed {  /** Foot centre in world coordinates. */  readonly x: number;  readonly z: number;  /** Fall direction in radians around +y, measured from +z. */  readonly yaw: number;  /** Distance from the start in dominoes. */  readonly depth: number;  /** Id of the preceding domino, or `NONE`. */  prev: number;  /** Ids of the next one or two dominoes, or `NONE`. */  readonly next: [number, number];  readonly color: number;  /** Portrait pixel column and row, measured from the top left. */  readonly cell?: readonly [column: number, row: number];}type Point = readonly [x: number, z: number];/** Maps progress `t` from 0 to 1 to a point on a curve. */type Curve = (t: number) => Point;/** * Cubic Hermite curve from `from` to `to`, with endpoint tangents `out` and `in`. Tangent * lengths control the pull toward each direction. */const hermite =  (from: Point, out: Point, to: Point, into: Point): Curve =>  (t) => {    const t2 = t * t;    const t3 = t2 * t;    const a = 2 * t3 - 3 * t2 + 1;    const b = t3 - 2 * t2 + t;    const c = -2 * t3 + 3 * t2;    const d = t3 - t2;    return [      a * from[0] + b * out[0] + c * to[0] + d * into[0],      a * from[1] + b * out[1] + c * to[1] + d * into[1],    ];  };/** * Spirals inward around `center` from `radius` to `inner`, decreasing by `pitch` per turn. * Starts at `start` radians with direction `turn`, +1 or -1. Angles run from +x toward +z in * the xz plane. */const spiral =  (    center: Point,    radius: number,    inner: number,    pitch: number,    start: number,    turn: number,  ): Curve =>  (t) => {    const angle = (t * (radius - inner) * Math.PI * 2) / pitch;    const r = radius - (pitch * angle) / (Math.PI * 2);    const at = start + turn * angle;    return [center[0] + r * Math.cos(at), center[1] + r * Math.sin(at)];  };/** Smoothstep from 0 to 1, clamping `t` to that range. */const smooth = (t: number) => {  const c = clamp(t, 0, 1);  return c * c * (3 - 2 * c);};/** Offsets `curve` to its left by `offset(length)` at distance `length`. */const beside =  (curve: Curve, length: number, offset: (along: number) => number): Curve =>  (t) => {    const e = 1e-4;    const [x, z] = curve(t);    const [x1, z1] = curve(Math.min(t + e, 1));    const [x0, z0] = curve(Math.max(t - e, 0));    const tx = x1 - x0;    const tz = z1 - z0;    const norm = Math.hypot(tx, tz) || 1;    const side = offset(t * length);    // Left is relative to the curve direction, viewed from +y    return [x + (tz / norm) * side, z - (tx / norm) * side];  };/** Samples a curve as a polyline of `steps` points and measures its length. */const sample = (curve: Curve, steps: number) => {  const points = Array.from({ length: steps + 1 }, (_, i) => curve(i / steps));  let length = 0;  for (let i = 1; i < points.length; i++) {    length += Math.hypot(points[i]![0] - points[i - 1]![0], points[i]![1] - points[i - 1]![1]);  }  return { points, length };};/** * Spaces dominoes evenly from distance `first` to the curve end, about `SPACING` apart. The * last domino stands at the endpoint. */const along = (curve: Curve, first = 0) => {  const { points, length } = sample(curve, 6000);  const gaps = Math.max(Math.round((length - first) / SPACING), 1);  const spacing = (length - first) / gaps;  const stands: { x: number; z: number; yaw: number }[] = [];  let walked = 0;  let next = first;  for (let i = 1; i < points.length; i++) {    const [x0, z0] = points[i - 1]!;    const [x1, z1] = points[i]!;    const step = Math.hypot(x1 - x0, z1 - z0);    while (walked + step >= next - 1e-3) {      const t = Math.min((next - walked) / step, 1);      stands.push({        x: x0 + (x1 - x0) * t,        z: z0 + (z1 - z0) * t,        yaw: Math.atan2(x1 - x0, z1 - z0),      });      next += spacing;    }    walked += step;  }  return stands.slice(0, gaps + 1);};/** Palette indices. `PALETTE` in `dominoes.ts` holds the colours. */const [IVORY, SAFFRON, CORAL, CRIMSON, TEAL, BLUE] = [0, 1, 2, 3, 4, 5];/** Colour sections repeating along the run's depth: bands, stripes and stepped gradients. */const SECTIONS: readonly { readonly length: number; readonly at: (i: number) => number }[] = [  { length: 96, at: (i) => [SAFFRON, CORAL, CRIMSON, CORAL, SAFFRON, IVORY][Math.floor(i / 16)]! },  { length: 80, at: (i) => (Math.floor(i / 10) % 2 === 0 ? BLUE : IVORY) },  {    length: 72,    at: (i) =>      [TEAL, TEAL, TEAL, BLUE, TEAL, BLUE, BLUE, TEAL, BLUE, BLUE, BLUE, BLUE][Math.floor(i / 6)]!,  },  { length: 60, at: (i) => [IVORY, SAFFRON, IVORY][Math.floor(i / 20)]! },  { length: 120, at: (i) => [CRIMSON, TEAL, CORAL, BLUE, SAFFRON][Math.floor(i / 24)]! },  { length: 80, at: (i) => (Math.floor(i / 10) % 2 === 0 ? CRIMSON : SAFFRON) },];const CYCLE = SECTIONS.reduce((sum, section) => sum + section.length, 0);const colorAt = (depth: number) => {  let i = depth % CYCLE;  for (const section of SECTIONS) {    if (i < section.length) return section.at(i);    i -= section.length;  }  return IVORY;};/** Twin spirals, each with two arms branching from one line. */const SPIRAL = { radius: 84, inner: 22, gap: 9 } as const;/** How far each of two lines starts from the line they branch from, in centimetres. */const SPLIT = 1.5;/** Centimetres over which the spiral arms part from `SPLIT` to half a ring gap each. */const PARTING = 30;const CENTERS: readonly Point[] = [  [-100, -135],  [100, -135],];/** * Catmull-Rom spline through `points`. Each interior tangent follows the line between the * neighbouring points. Endpoint tangents follow the first and last segments. */const through = (points: readonly Point[]): Curve => {  const at = (i: number) => points[clamp(i, 0, points.length - 1)]!;  const tangent = (i: number): Point => [    (at(i + 1)[0] - at(i - 1)[0]) / 2,    (at(i + 1)[1] - at(i - 1)[1]) / 2,  ];  const legs = points.length - 1;  return (t) => {    const leg = Math.min(Math.floor(t * legs), legs - 1);    return hermite(at(leg), tangent(leg), at(leg + 1), tangent(leg + 1))(t * legs - leg);  };};/** The lead follows an S-bend across the front before turning toward the first fork. */const LEAD = through([  [30, 116],  [-10, 112],  [-50, 100],  [-76, 76],  [-68, 48],  [-36, 32],  [-8, 14],  [0, -6],  [0, -10],]);/** * Orders the lead before two branches to the spirals. Each branch splits into two spiral arms * winding inward side by side. */export function layOut(): Placed[] {  const placed: Placed[] = [];  /** Adds dominoes after `from`, linking it to the first. Returns the last index. */  const line = (stands: { x: number; z: number; yaw: number }[], from: number) => {    let prev = from;    const depth0 = from === NONE ? 0 : placed[from]!.depth + 1;    stands.forEach((stand, k) => {      const index = placed.length;      placed.push({        ...stand,        depth: depth0 + k,        prev,        next: [NONE, NONE],        color: colorAt(depth0 + k),      });      if (prev !== NONE) {        const pushes = placed[prev]!.next;        pushes[pushes[0] === NONE ? 0 : 1] = index;      }      prev = index;    });    return prev;  };  const fork = line(along(LEAD), NONE);  const { x, z, yaw } = placed[fork]!;  const forward: Point = [Math.sin(yaw), Math.cos(yaw)];  CENTERS.forEach((center, side) => {    const turn = side === 0 ? 1 : -1;    const middle = SPIRAL.radius - SPIRAL.gap / 2;    const entry: Point = [center[0], center[1] + middle];    const lateral = -turn * SPLIT;    const start: Point = [      x + forward[0] * SPACING - forward[1] * lateral,      z + forward[1] * SPACING + forward[0] * lateral,    ];    // Leave the fork straight ahead and arrive heading around the spiral    const reach = Math.hypot(entry[0] - start[0], entry[1] - start[1]);    const out: Point = [forward[0] * reach * 0.9, forward[1] * reach * 0.9];    const into: Point = [-turn * reach * 1.4, 0];    const split = line(along(hermite(start, out, entry, into)), fork);    const curve = spiral(center, middle, SPIRAL.inner, SPIRAL.gap * 2, Math.PI / 2, turn);    const length = sample(curve, 4000).length;    for (const arm of [1, -1]) {      const offset = (s: number) => arm * (SPLIT + (SPIRAL.gap / 2 - SPLIT) * smooth(s / PARTING));      line(along(beside(curve, length, offset), SPACING), split);    }  });  return placed;}/** * Keeps the first `count` dominoes in wave order, shortening the arms. Removes links to omitted * dominoes so each line ends by falling flat. */export function firstOf(run: readonly Placed[], count: number): Placed[] {  // Sort in place because Hermes has no `toSorted`  const order = run.map((_, i) => i);  order.sort((a, b) => run[a]!.depth - run[b]!.depth || a - b);  const kept = order.slice(0, count);  kept.sort((a, b) => a - b);  const index = new Map(kept.map((old, i) => [old, i]));  const at = (old: number) => (old === NONE ? NONE : (index.get(old) ?? NONE));  return kept.map((old) => {    const each = run[old]!;    const [a, b] = [at(each.next[0]), at(each.next[1])];    return { ...each, prev: at(each.prev), next: a === NONE ? [b, NONE] : [a, b] };  });}

sunset.ts

import { srgbToLinear } from '@typegpu/color';import { perlin2d } from '@typegpu/noise';import { baker } from '@typegpu/kit';import { d, std, type TgpuRoot } from 'typegpu';/** Texture dimensions. Each domino in the 100 × 60 field gets 4 × 4 texels. */export const SIZE = [400, 240] as const;/** Width over height, so distances measured in picture heights stay round. */const ASPECT = SIZE[0] / SIZE[1];/** sRGB palette ordered from sky to foreground hills. */const NIGHT = d.vec3f(0.12, 0.1, 0.36);const DUSK = d.vec3f(0.55, 0.2, 0.55);const ROSE = d.vec3f(0.95, 0.4, 0.5);const EMBER = d.vec3f(0.98, 0.55, 0.15);const GLOW = d.vec3f(0.99, 0.64, 0.2);const SUN = d.vec3f(1, 0.92, 0.45);const SUN_LOW = d.vec3f(1, 0.8, 0.22);const FAR = d.vec3f(0.5, 0.22, 0.55);const NEAR = d.vec3f(0.16, 0.14, 0.42);const NEAREST = d.vec3f(0.06, 0.05, 0.12);/** Sun centre measured from the picture top left, and radius, in picture heights. */const SUN_AT = d.vec2f(0.5, 0.56);const SUN_RADIUS = 0.27;/** Maps height from the top at 0 to the horizon at 1 through colour bands. */const skyAt = (y: number) => {  'use gpu';  let color = std.mix(NIGHT, DUSK, std.smoothstep(0, 0.22, y));  color = std.mix(color, ROSE, std.smoothstep(0.18, 0.38, y));  color = std.mix(color, EMBER, std.smoothstep(0.36, 0.52, y));  return std.mix(color, GLOW, std.smoothstep(0.5, 0.62, y));};/** Hill height at `x`, measured from the picture top. Ridged noise adds peaks to `base`. */const ridgeAt = (x: number, base: number, height: number, scale: number, seed: number) => {  'use gpu';  const ridge = 1 - std.abs(perlin2d.sample(d.vec2f(x * scale, seed)));  const roll = perlin2d.sample(d.vec2f(x * scale * 0.35, seed + 7));  return base - (ridge * ridge * 0.7 + roll * 0.5) * height;};/** * Draws a banded sky, a striped sun and three hill ranges. Large shapes remain visible at 100 × * 60. */export function createSunset(root: TgpuRoot) {  const texture = root    .createTexture({ size: SIZE, format: 'rgba16float' })    .$usage('storage', 'sampled');  const drawing = baker.create(root, texture, {    texel: (uv) => {      'use gpu';      // Use picture heights on both axes to keep the sun round      const x = uv.x * ASPECT;      let color = skyAt(uv.y);      const fromSun = d.vec2f(x - SUN_AT.x * ASPECT, uv.y - SUN_AT.y);      const below = std.max(uv.y - SUN_AT.y + 0.04, 0);      const barred = std.fract(below * 28) < below * 4.5;      if (std.length(fromSun) < SUN_RADIUS && !barred) {        color = std.mix(SUN, SUN_LOW, std.smoothstep(-SUN_RADIUS, SUN_RADIUS, fromSun.y));      }      if (uv.y > ridgeAt(x, 0.72, 0.22, 3.2, 1)) color = FAR;      if (uv.y > ridgeAt(x, 0.83, 0.2, 4.5, 2)) color = NEAR;      if (uv.y > ridgeAt(x, 0.94, 0.16, 2.4, 3)) color = NEAREST;      return d.vec4f(srgbToLinear(color), 1);    },  });  return { ...drawing, texture, destroy: () => texture.destroy() };}

ui.ts

import { image } from '../ui.ts';export const ui = { portrait: image('choose image') };