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
- scene.tsCreates the scene: its resources, its settings and what each frame records.
- dominoes.tsAllocates enough capacity for the larger layout.
- topple.tsComputes next angles from current neighbour states, then copies them into the table.
- floor.tsLays a walnut floor 40 m square under the run.
- picture.tsSamples an image over each domino cell, averages it and paints the back with the nearest palette colour.
- field.tsOrders the far-edge lead line before its columns of
ROWSdominoes. - layout.tsOrders the lead before two branches to the spirals.
- sunset.tsDraws a banded sky, a striped sun and three hill ranges.
- 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') };