A neural cellular automaton grows a 3D donut from one cell, trained live on WebGPU with the Cells-to-Pixels recipe.
---
format: typebulb/v1
name: Regrow
---
**code.tsx**
```tsx
import { App, Component, a, button, canvas, div, h1, p, path, polygon, polyline, rect, span, svg } from "domeleon"
import {
Module, compile, isWebGPUAvailable, Conv2d, Linear, init, lr, capture, loadSafetensors,
conv2d, maxPool2d, relu, sin, greater, where, mul, add, sub, div as tdiv, mean, sum, abs, square, sqrt, clamp,
matmul, narrow, concat, reshape, permute, randn, ones, zeros,
type Tensor, type CompiledTraining, type CompiledForward, type LR,
} from "tensorgrad"
// ============================================================================
// "From Cells to Pixels" (cells2pixels.github.io) live in the browser on
// WebGPU: a Growing Neural Cellular Automaton grows a donut from one seed cell
// and regrows it when you bite it. Each cell holds a C-dim latent; a shared NCA
// update rule runs the G² lattice, and a SIREN decoder paints it up to a GH²
// RGBA image — the visible donut. The raw cell channels are latent, not color.
//
// Loss is the paper's stack: masked L2 + L1 on the render, a shape loss on the
// alpha/alive mask, LPIPS(VGG16) on a padding crop (random 3-of-4 channels),
// and a ×100 overflow penalty. Two non-obvious pieces:
// • per-param gradient normalization on the NCA only (not the SIREN) is what
// makes L1 safe on a CA (tensorgrad's `normalizeGrads`).
// • LPIPS-VGG16 weights ride in as frozen INPUTS so DCE prunes their gradient
// kernels; the forward is validated against torch to 8 decimals.
//
// Proof of concept, so it diverges from the paper for browser speed: mini scale
// (G=32/K=24/S=4, not G=96/8×) and a single 1500-step run (not 5×4000 rounds
// with pool flushes) — enough for a stable donut, not their fine detail. The
// donut's center is a HOLE, so the paper's seed-at-center breaks; placeSeed
// relocates the seed onto the ring.
// ============================================================================
const C = 32, FC = 256 // cell channels, NCA hidden dim
const G = 32 // cell lattice (paper: 96)
const B = 8 // batch
const K = 24 // CA steps unrolled per forward (≈ 2× the donut radius)
const S = 4, GH = G * S // render upscale, render resolution
const H = 64, OMEGA = 10 // SIREN width, frequency
const PAD = 5 // arena border in cells (room to grow)
const PADH = PAD * S // border in render px
const CROP = GH - 2 * PADH // LPIPS crop = the image region inside the border
const POOL_SIZE = 1024
const TRAIN_STEPS = 1500
const INJECT_EVERY = 32 // re-seed batch slot 0 every N steps
const makeLR = () => lr.multiStep({ peak: 1e-3, milestones: [1000], gamma: 0.3 })
const GG = G * G, STATE_LEN = C * GG, GGH = GH * GH
const SIN = 4 + C // SIREN input: 4 sub-cell coords + C latent
const DAMAGE_R = 3 // scratch radius in cells (display only, never trained)
const CIRCULAR = true // toroidal perception padding
const R2 = "https://assets.typebulb.com" // hosted assets (published fallback)
const LPIPS_PATH = "weights/lpips-vgg16.safetensors" // local repo copy (~59MB)
const LPIPS_URL = `${R2}/weights/lpips-vgg16.safetensors`
// The trained checkpoint rides the bulb's assets/ chain: served from regrow/assets/
// when present (a fresh training run saves there), else from the bulb's hosted
// assets. LPIPS stays an absolute URL on purpose — it's shared across the
// tensorgrad bulbs, and a per-bulb copy would break CacheStorage sharing.
const TRAINED_ASSET = "assets/donut.safetensors"
const sleep = (ms: number) => new Promise(res => setTimeout(res, ms))
const nextFrame = () => new Promise(res => requestAnimationFrame(res))
const clamp01 = (v: number) => v < 0 ? 0 : v > 1 ? 1 : v
const slog = (s: string) => { if (tb.mode === "local") tb.log(s) }
// seeded uniform(-bound, bound) for the SIREN init (paper: uniform draws)
const uniformInit = (n: number, bound: number, seed: number) => {
const a = new Float32Array(n)
let h = (seed * 0x9e3779b9) >>> 0
for (let i = 0; i < n; i++) {
h = (h * 1664525 + 1013904223) >>> 0
a[i] = (h / 4294967296 * 2 - 1) * bound
}
return a
}
// ============================================================================
// Model — the NCA update net (w1→relu→w2) + the SIREN decoder (s1..sOut).
// w1 small-random, w2 zeros, SIREN uniform init — the paper's scheme.
// ============================================================================
class NCAModel extends Module {
w1 = new Conv2d(4 * C, FC, 1, { init: init.randn({ scale: 0.02 }) })
w2 = new Conv2d(FC, C, 1, { bias: false, init: init.zeros() })
s1 = new Linear(SIN, H, { init: init.literal(uniformInit(SIN * H, 1 / SIN, 1)) })
s2 = new Linear(H, H, { init: init.literal(uniformInit(H * H, Math.sqrt(6 / H) / OMEGA, 2)) })
s3 = new Linear(H, H, { init: init.literal(uniformInit(H * H, Math.sqrt(6 / H) / OMEGA, 3)) })
sOut = new Linear(H, 4, { init: init.literal(uniformInit(H * 4, Math.sqrt(6 / H) / OMEGA, 4)) })
}
// per-param grad normalization applies to the NCA only — the paper's
// normalize_model_grads(model) touches the CA update rule, not the LPPN/SIREN
const NCA_PARAMS = new Set(["w1.W", "w1.b", "w2.W"])
// ============================================================================
// CA graphs: circular perception, Bernoulli-0.5 stochastic update, pre×post
// alive gating (torch-parity semantics).
// ============================================================================
const alive = (s: Tensor) =>
greater(maxPool2d(narrow(s, 1, 3, 1), 3, { stride: 1, padding: 1 }), 0.1)
const boolToF32 = (m: Tensor, b: number, g: number) =>
where(m, ones([b, 1, g, g]), zeros([b, 1, g, g]))
function circularPad1(s: Tensor): Tensor {
const px = concat([narrow(s, 3, G - 1, 1), s, narrow(s, 3, 0, 1)], 3)
return concat([narrow(px, 2, G - 1, 1), px, narrow(px, 2, 0, 1)], 2)
}
function ncaStep(m: NCAModel, s: Tensor, filters: Tensor, b: number): Tensor {
const pre = alive(s)
const z = CIRCULAR
? conv2d(circularPad1(s), filters, { padding: 0, groups: C })
: conv2d(s, filters, { padding: 1, groups: C })
const ds = m.w2.fwd(relu(m.w1.fwd(z)))
const mask = greater(randn([b, 1, G, G]), 0)
const s2 = where(mask, add(s, ds), s)
const post = alive(s2)
return mul(s2, mul(boolToF32(pre, b, G), boolToF32(post, b, G)))
}
// exact bilinear ×S upsample of `ch` channels (tent conv + depth-to-space)
function bilinearUp(x: Tensor, w: Tensor, ch: number, b: number): Tensor {
const shifted = conv2d(x, w, { padding: 1, groups: ch })
const t = reshape(shifted, [b, ch, S, S, G, G])
return reshape(permute(t, [0, 1, 4, 2, 5, 3]), [b, ch, GH, GH])
}
// the paper's renderer: bilinear state + trig sub-cell coords → SIREN → RGBA,
// gated by the RENDER-RES living mask (bilinear alpha, 3×3 maxpool over
// render pixels, >0.1) — their get_living_mask(x_up). Also returns the raw
// bilinear alpha (their x_up[:,3:4]) for the shape loss.
function decode(m: NCAModel, s: Tensor, bilin: Tensor, bilin1: Tensor, coords: Tensor, b: number): { rendered: Tensor; aUp: Tensor } {
const up = bilinearUp(s, bilin, C, b)
const feats = concat([coords, up], 1)
const rows = reshape(permute(feats, [0, 2, 3, 1]), [b * GGH, SIN])
let h = sin(mul(m.s1.fwd(rows), OMEGA))
h = sin(mul(m.s2.fwd(h), OMEGA))
h = sin(mul(m.s3.fwd(h), OMEGA))
const img = permute(reshape(m.sOut.fwd(h), [b, GH, GH, 4]), [0, 3, 1, 2])
const aUp = bilinearUp(narrow(s, 1, 3, 1), bilin1, 1, b)
const mask = greater(maxPool2d(aUp, 3, { stride: 1, padding: 1 }), 0.1)
return { rendered: mul(img, where(mask, ones([b, 1, GH, GH]), zeros([b, 1, GH, GH]))), aUp }
}
// ============================================================================
// LPIPS(VGG16) in-graph — validated against torch to 8 decimals (see header).
// Weights ride in as frozen INPUTS (DCE prunes their gradient kernels); the
// input transform (2x−1 then ScalingLayer) is applied in-graph via lp_in_*
// (folding it into conv1_1 is inexact at zero-padded borders — measured 2.4%).
// ============================================================================
const VGG_PLAN: [string, number, boolean][] = [ // (name, outC, maxpool-before)
["conv1_1", 64, false], ["conv1_2", 64, false],
["conv2_1", 128, true], ["conv2_2", 128, false],
["conv3_1", 256, true], ["conv3_2", 256, false], ["conv3_3", 256, false],
["conv4_1", 512, true], ["conv4_2", 512, false], ["conv4_3", 512, false],
["conv5_1", 512, true], ["conv5_2", 512, false], ["conv5_3", 512, false],
]
const VGG_TAPS = new Set(["conv1_2", "conv2_2", "conv3_3", "conv4_3", "conv5_3"])
const TAP_C = [64, 128, 256, 512, 512]
const LP_SPEC: Record<string, number[]> = { lp_in_gain: [1, 3, 1, 1], lp_in_offset: [1, 3, 1, 1] }
{
let prevC = 3
for (const [name, c] of VGG_PLAN) {
LP_SPEC[`lp_${name}_W`] = [c, prevC, 3, 3]
LP_SPEC[`lp_${name}_b`] = [1, c, 1, 1]
prevC = c
}
for (let k = 0; k < 5; k++) LP_SPEC[`lp_lin${k}`] = [1, TAP_C[k]!, 1, 1]
}
function vggTaps(rgb01: Tensor, inp: Record<string, Tensor>): Tensor[] {
let h = sub(mul(rgb01, inp["lp_in_gain"]!), inp["lp_in_offset"]!)
const taps: Tensor[] = []
for (const [name, , pool] of VGG_PLAN) {
if (pool) h = maxPool2d(h, 2)
h = relu(add(conv2d(h, inp[`lp_${name}_W`]!, { padding: 1 }), inp[`lp_${name}_b`]!))
if (VGG_TAPS.has(name)) taps.push(h)
}
return taps
}
// LPIPS v0.1: unit-normalize each tap across channels (eps outside the sqrt),
// squared diff, weight by lin, spatial mean, sum taps
function lpips(x: Tensor, y: Tensor, inp: Record<string, Tensor>): Tensor {
const fx = vggTaps(x, inp), fy = vggTaps(y, inp)
const unit = (f: Tensor) => tdiv(f, add(sqrt(sum(mul(f, f), 1, { keepDims: true })), 1e-10))
let total: Tensor | null = null
for (let k = 0; k < 5; k++) {
const d = sub(unit(fx[k]!), unit(fy[k]!))
const w = mul(mul(d, d), inp[`lp_lin${k}`]!)
const s = mul(mean(w), TAP_C[k]!) // mean over BCHW × C = channel-sum, batch+spatial mean
total = total === null ? s : add(total, s)
}
return total!
}
// pick 3 of the 4 RGBA channels (random triplet per batch element, host-fed
// one-hot `sel` [B,3,4]) from a padding-cropped image — the paper's LPIPS diet
function cropSelect(img: Tensor, sel: Tensor): Tensor {
const c = narrow(narrow(img, 2, PADH, CROP), 3, PADH, CROP) // [B,4,CROP,CROP]
const flat = reshape(c, [B, 4, CROP * CROP])
return reshape(matmul(sel, flat), [B, 3, CROP, CROP]) // batched [B,3,4]·[B,4,·]
}
// ============================================================================
// The paper's loss, term for term (image_loss.py + loss.py):
// L2 + L1 (full canvas, masked render) + shape L2 + shape L1 (target alpha
// vs 2·aUp, unweighted) + LPIPS (crop, random channel triplet) + 100·overflow
// ============================================================================
function lossFn(m: NCAModel, inp: {
state: Tensor; filters: Tensor; bilin: Tensor; bilin1: Tensor; coords: Tensor
targetHi: Tensor; chnSel: Tensor
} & Record<string, Tensor>) {
const { state, filters, bilin, bilin1, coords, targetHi, chnSel } = inp
let s = state
for (let k = 0; k < K; k++) s = ncaStep(m, s, filters, B)
s = capture("final", s)
const { rendered, aUp } = decode(m, s, bilin, bilin1, coords, B)
const diff = sub(rendered, targetHi)
const l2 = mean(square(diff))
const l1 = mean(abs(diff))
const shapeDiff = sub(narrow(targetHi, 1, 3, 1), mul(aUp, 2)) // paper: alpha × 2.0
const shapeL2 = mean(square(shapeDiff))
const shapeL1 = mean(abs(shapeDiff))
const lp = lpips(cropSelect(rendered, chnSel), cropSelect(targetHi, chnSel), inp)
const over = mul(mean(abs(sub(s, clamp(s, -1, 1)))), 100)
return add(add(add(add(add(l2, l1), shapeL2), shapeL1), lp), over)
}
// Pretrained mode never trains, but demo/render must attach to a CompiledTraining
// to share its param buffers — and the only way to get one is compile(), which
// requires a loss. Compiling the real lossFn (VGG16-LPIPS twice + a K=24 rollout,
// plus the whole backward pass) purely to throw it away is what pinned the GPU
// process on load and froze even the host page. This stand-in touches every param
// (one CA step + one decode) so none is DCE'd out of the shared buffer set, but
// never goes near VGG16. It is only ever compiled, never stepped.
function lossFnLight(m: NCAModel, { state, filters, bilin, bilin1, coords }: {
state: Tensor; filters: Tensor; bilin: Tensor; bilin1: Tensor; coords: Tensor
}) {
const s = ncaStep(m, state, filters, B)
const { rendered } = decode(m, s, bilin, bilin1, coords, B)
return mean(square(rendered))
}
function demoFn(m: NCAModel, { state, filters }: { state: Tensor; filters: Tensor }) {
return ncaStep(m, state, filters, 1)
}
function renderFn(m: NCAModel, { state, bilin, bilin1, coords }: { state: Tensor; bilin: Tensor; bilin1: Tensor; coords: Tensor }) {
return decode(m, state, bilin, bilin1, coords, 1).rendered
}
// ============================================================================
// Host-side constant tables: perception filters, ×S bilinear upsample kernels,
// sub-cell trig coords, seeds.
// ============================================================================
const F3 = [
[0, 0, 0, 0, 1, 0, 0, 0, 0],
[-1, 0, 1, -2, 0, 2, -1, 0, 1],
[-1, -2, -1, 0, 0, 0, 1, 2, 1],
[1, 2, 1, 2, -12, 2, 1, 2, 1],
]
const FILTERS = (() => {
const a = new Float32Array(4 * C * 9)
for (let k = 0; k < 4; k++)
for (let c = 0; c < C; c++)
a.set(F3[k]!, (c * 4 + k) * 9)
return a
})()
const W1D = [
[0.375, 0.625, 0],
[0.125, 0.875, 0],
[0, 0.875, 0.125],
[0, 0.625, 0.375],
]
const makeBilin = (ch: number) => {
const a = new Float32Array(ch * S * S * 9)
for (let c = 0; c < ch; c++)
for (let dy = 0; dy < S; dy++)
for (let dx = 0; dx < S; dx++) {
const off = (c * S * S + dy * S + dx) * 9
for (let ky = 0; ky < 3; ky++)
for (let kx = 0; kx < 3; kx++)
a[off + ky * 3 + kx] = W1D[dy]![ky]! * W1D[dx]![kx]!
}
return a
}
const BILIN = makeBilin(C)
const BILIN1 = makeBilin(1)
const COORDS1 = (() => {
const a = new Float32Array(4 * GGH)
const f = [-0.75, -0.25, 0.25, 0.75]
for (let y = 0; y < GH; y++)
for (let x = 0; x < GH; x++) {
const i = y * GH + x
const cy = f[y % S]! * Math.PI, cx = f[x % S]! * Math.PI
a[i] = Math.sin(cy)
a[GGH + i] = Math.sin(cx)
a[2 * GGH + i] = Math.cos(cy)
a[3 * GGH + i] = Math.cos(cx)
}
return a
})()
const COORDS_B = (() => {
const a = new Float32Array(B * 4 * GGH)
for (let b = 0; b < B; b++) a.set(COORDS1, b * 4 * GGH)
return a
})()
const makeSeedAt = (x: number, y: number) => {
const a = new Float32Array(STATE_LEN)
for (let c = 3; c < C; c++) a[c * GG + y * G + x] = 1
return a
}
let SEED = makeSeedAt(G >> 1, G >> 1)
let SEED_POS: [number, number] = [G >> 1, G >> 1]
let LO_TARGET: Float32Array | null = null // S×S box-averaged target (drift probe only — no loss term)
// the paper seeds dead center, but the donut is a hole there. If the seed pixel
// is transparent, death is absorbing (a lethal rule), so relocate the seed to
// the nearest solid pixel and log it.
function placeSeed(lo: Float32Array) {
const cx = G >> 1, cy = G >> 1
if (lo[3 * GG + cy * G + cx]! >= 0.95) { SEED = makeSeedAt(cx, cy); SEED_POS = [cx, cy]; return }
let sx = cx, sy = cy, bestD = Infinity
for (let y = 0; y < G; y++)
for (let x = 0; x < G; x++)
if (lo[3 * GG + y * G + x]! >= 0.95) {
const d = (x - cx) ** 2 + (y - cy) ** 2
if (d < bestD) { bestD = d; sx = x; sy = y }
}
slog(`WARNING: target center is transparent — seed moved to (${sx},${sy}) (paper seeds dead center)`)
SEED = makeSeedAt(sx, sy)
SEED_POS = [sx, sy]
}
// random 3-of-4 channel one-hots, [B,3,4], fresh per training step
function fillChnSel(sel: Float32Array) {
sel.fill(0)
for (let b = 0; b < B; b++) {
const perm = [0, 1, 2, 3]
for (let i = 3; i > 0; i--) {
const j = (Math.random() * (i + 1)) | 0
const t = perm[i]!; perm[i] = perm[j]!; perm[j] = t
}
for (let i = 0; i < 3; i++) sel[b * 12 + i * 4 + perm[i]!] = 1
}
}
function sampleDistinct(out: Int32Array, n: number) {
for (let i = 0; i < out.length; i++) {
let v: number
do { v = (Math.random() * n) | 0 } while (out.subarray(0, i).includes(v))
out[i] = v
}
}
function carveDisc(s: Float32Array, off: number, cx: number, cy: number, r: number) {
for (let y = Math.max(0, Math.floor(cy - r)); y <= Math.min(G - 1, Math.ceil(cy + r)); y++)
for (let x = Math.max(0, Math.floor(cx - r)); x <= Math.min(G - 1, Math.ceil(cx + r)); x++) {
if ((x - cx) ** 2 + (y - cy) ** 2 > r * r) continue
for (let c = 0; c < C; c++) s[off + c * GG + y * G + x] = 0
}
}
function sliceL2(state: Float32Array, slice: number, tgt: Float32Array) {
const off = slice * STATE_LEN
let s = 0
for (let i = 0; i < 4 * GG; i++) { const d = state[off + i]! - tgt[i]!; s += d * d }
return s / (4 * GG)
}
const aliveCount = (s: Float32Array, off = 0) => {
let n = 0
for (let i = 0; i < GG; i++) if (s[off + 3 * GG + i]! > 0.1) n++
return n
}
const deadOrExploded = (alive: number, maxAbs: number) =>
alive === 0 || alive > GG * 0.6 || maxAbs > 3
// Procedurally generated target: a genuinely 3D donut, RAY-MARCHED — a real
// torus SDF tilted back in Z, orthographic camera down -Z, so the hole reads
// as an ellipse and the near rim actually OCCLUDES the far one. Shaded with a
// warm key light (soft self-shadow + ambient occlusion so the hole reads deep),
// a broad glaze sheen plus a tight specular glint, and a cool rim. Fully
// procedural: no image, no three.js. Emits the same premultiplied CHW RGBA hi +
// S×S box-averaged lo the PNG loader did. The donut's center is a HOLE
// (transparent), so the paper's seed-at-center assumption breaks; placeSeed's
// tripwire relocates the seed onto the ring.
async function loadTarget(): Promise<{ hi: Float32Array; lo: Float32Array }> {
const INNER = GH - 2 * PADH
const cx = GH / 2, cy = GH / 2
const R = INNER * 0.30 // major (ring) radius, px
const r = INNER * 0.15 // minor (tube) radius, px
const THETA = 0.98 // tilt angle (rad) ≈ 56°; near rim
const cT = Math.cos(THETA), sT = -Math.sin(THETA) // sits low+front, far rim high+back
const EXTENT = R + r
const nrm = (x: number, y: number, z: number) => {
const l = Math.hypot(x, y, z) || 1
return [x / l, y / l, z / l] as const
}
const L = nrm(-0.45, 0.72, 0.6) // key light: upper-left, toward viewer
const V = [0, 0, 1] as const // view direction (camera sits at +Z)
const Hlf = nrm(L[0] + V[0], L[1] + V[1], L[2] + V[2]) // Blinn half-vector
const ALBEDO = [0.96, 0.34, 0.52] as const // berry-pink glaze
const KEY = [1.0, 0.94, 0.88] as const // warm key light
const RIM = [0.6, 0.72, 1.0] as const // cool rim light
// signed distance to the Z-axis torus, tilted back by THETA around X
const map = (x: number, y: number, z: number) => {
const oy = y * cT + z * sT
const oz = -y * sT + z * cT
const qx = Math.hypot(x, oy) - R
return Math.hypot(qx, oz) - r
}
const normalAt = (x: number, y: number, z: number) => {
const e = 0.3
return nrm(
map(x + e, y, z) - map(x - e, y, z),
map(x, y + e, z) - map(x, y - e, z),
map(x, y, z + e) - map(x, y, z - e),
)
}
// Quilez soft shadow toward the light (the near tube shadows the far one)
const softShadow = (x: number, y: number, z: number) => {
let res = 1, t = 1.2
for (let i = 0; i < 32 && t < EXTENT * 2.5; i++) {
const h = map(x + L[0] * t, y + L[1] * t, z + L[2] * t)
if (h < 0.001) return 0
if (12 * h / t < res) res = 12 * h / t
t += Math.max(0.4, h)
}
return clamp01(res)
}
// Quilez 5-tap ambient occlusion along the surface normal
const ambientOcc = (x: number, y: number, z: number, nx: number, ny: number, nz: number) => {
let occ = 0, sca = 1
for (let i = 1; i <= 5; i++) {
const d = 0.5 * i
const h = map(x + nx * d, y + ny * d, z + nz * d)
occ += (d - h) * sca
sca *= 0.7
}
return clamp01(1 - 1.5 * occ)
}
const hi = new Float32Array(4 * GGH)
const SS = 3 // subsamples/axis (silhouette AA)
const roZ = EXTENT + 4 // ortho ray start, just outside
const tMax = 2 * EXTENT + 8
for (let y = 0; y < GH; y++) {
for (let x = 0; x < GH; x++) {
let cr = 0, cg = 0, cb = 0, cov = 0
for (let sy = 0; sy < SS; sy++)
for (let sx = 0; sx < SS; sx++) {
const wx = x + (sx + 0.5) / SS - cx
const wy = -(y + (sy + 0.5) / SS - cy)
let t = 0, pz = 0, hit = false
for (let it = 0; it < 96; it++) {
const zz = roZ - t
const d = map(wx, wy, zz)
if (d < 0.02) { pz = zz; hit = true; break }
t += d
if (t > tMax) break
}
if (!hit) continue
cov++
const [nx, ny, nz] = normalAt(wx, wy, pz)
const diff = Math.max(0, nx * L[0] + ny * L[1] + nz * L[2])
const sh = softShadow(wx, wy, pz)
const occ = ambientOcc(wx, wy, pz, nx, ny, nz)
const ndh = Math.max(0, nx * Hlf[0] + ny * Hlf[1] + nz * Hlf[2])
const spec = (Math.pow(ndh, 64) * 0.9 + Math.pow(ndh, 12) * 0.22) * sh
const ndv = Math.max(0, nz) // N·V, V = +Z
const rim = Math.pow(1 - ndv, 3) * 0.35
const key = diff * sh
const amb = 0.3 * occ
cr += clamp01(ALBEDO[0] * (amb + key * KEY[0]) + spec * KEY[0] + rim * RIM[0])
cg += clamp01(ALBEDO[1] * (amb + key * KEY[1]) + spec * KEY[1] + rim * RIM[1])
cb += clamp01(ALBEDO[2] * (amb + key * KEY[2]) + spec * KEY[2] + rim * RIM[2])
}
const n = SS * SS
const a = cov / n
if (a <= 0) continue
const i = y * GH + x
hi[i] = cr / n // premultiplied (Σ over hits / n = color × coverage)
hi[GGH + i] = cg / n
hi[2 * GGH + i] = cb / n
hi[3 * GGH + i] = a
}
// the ray-march is pure CPU; yield every few rows so the event loop
// (status paint, worker messages) isn't starved and the page can't look hung
if ((y & 7) === 0) await sleep(0)
}
const lo = new Float32Array(4 * GG)
for (let c = 0; c < 4; c++)
for (let y = 0; y < G; y++)
for (let x = 0; x < G; x++) {
let s = 0
for (let dy = 0; dy < S; dy++)
for (let dx = 0; dx < S; dx++)
s += hi[c * GGH + (y * S + dy) * GH + x * S + dx]!
lo[c * GG + y * G + x] = s / (S * S)
}
return { hi, lo }
}
const tile = (t: Float32Array, n: number) => {
const out = new Float32Array(n * t.length)
for (let b = 0; b < n; b++) out.set(t, b * t.length)
return out
}
function saveSafetensors(record: Record<string, Float32Array>): Uint8Array {
const names = Object.keys(record).sort()
const header: Record<string, { dtype: string; shape: number[]; data_offsets: [number, number] }> = {}
let off = 0
for (const n of names) {
const len = record[n]!.length
header[n] = { dtype: "F32", shape: [len], data_offsets: [off, off + len * 4] }
off += len * 4
}
const hj = new TextEncoder().encode(JSON.stringify(header))
const buf = new Uint8Array(8 + hj.length + off)
new DataView(buf.buffer).setBigUint64(0, BigInt(hj.length), true)
buf.set(hj, 8)
let p = 8 + hj.length
for (const n of names) {
const f = record[n]!
buf.set(new Uint8Array(f.buffer, f.byteOffset, f.byteLength), p)
p += f.byteLength
}
return buf
}
// ============================================================================
// LPIPS-VGG16 weights (frozen). Local repo file for dev; the hosted copy
// (streamed with a byte counter) for a published train, where there is no fs.
// ============================================================================
type Progress = (received: number, total: number) => void
// stream a fetch body, reporting bytes-so-far against Content-Length (0 if the
// server omits it), so a big download shows progress instead of looking hung.
async function readWithProgress(res: Response, onProgress?: Progress): Promise<ArrayBuffer> {
if (!onProgress || !res.body) return res.arrayBuffer()
const total = Number(res.headers.get("content-length")) || 0
const reader = res.body.getReader()
const chunks: Uint8Array[] = []
let received = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
received += value.length
onProgress(received, total)
}
const out = new Uint8Array(received)
let off = 0
for (const c of chunks) { out.set(c, off); off += c.length }
return out.buffer as ArrayBuffer
}
// Fetch an asset once from the network, thereafter from CacheStorage — which
// survives refreshes and (unlike the plain HTTP cache) isn't evicted for being
// large. Without this, a refresh re-downloaded the ~59MB LPIPS every time.
// Falls back to a plain download if the Cache API is unavailable.
async function cachedFetch(url: string, onProgress?: Progress): Promise<ArrayBuffer> {
const download = async (): Promise<ArrayBuffer> => {
const res = await fetch(url)
if (!res.ok) throw new Error(`fetch ${url} -> ${res.status}`)
return readWithProgress(res, onProgress)
}
try {
const cache = await caches.open("tensorgrad-assets")
const hit = await cache.match(url)
if (hit) return hit.arrayBuffer()
const buf = await download()
try { await cache.put(url, new Response(buf)) } catch { /* caching is an optimization */ }
return buf
} catch {
return download()
}
}
let LP: Record<string, Float32Array> = {}
let lpipsPromise: Promise<void> | null = null
function ensureLpips(onProgress?: Progress): Promise<void> {
if (!lpipsPromise) lpipsPromise = (async () => {
let buf: ArrayBuffer
try {
const bytes = new Uint8Array(await tb.fs.readBytes(LPIPS_PATH)) // local repo copy (dev)
buf = bytes.buffer as ArrayBuffer
} catch {
// hosted copy (published), cached in CacheStorage so a refresh never
// re-downloads the ~59MB — the VGG16 LPIPS weights never change.
buf = await cachedFetch(LPIPS_URL, onProgress)
}
const { tensors } = loadSafetensors(buf)
const out: Record<string, Float32Array> = {
lp_in_gain: tensors["in.gain"]!, lp_in_offset: tensors["in.offset"]!,
}
for (const [name] of VGG_PLAN) {
out[`lp_${name}_W`] = tensors[`${name}.weight`]!
out[`lp_${name}_b`] = tensors[`${name}.bias`]!
}
for (let k = 0; k < 5; k++) out[`lp_lin${k}`] = tensors[`lin${k}.weight`]!
LP = out
slog(`lpips: ${Object.keys(out).length} tensors ready`)
})()
return lpipsPromise
}
// ============================================================================
// Trainer — owns the compile and the training loop. `pretrained` loads the
// saved checkpoint; `train` runs a fresh 1500-step run.
// ============================================================================
class Trainer extends Component {
status = "starting…"
mode: "pretrained" | "train" = "pretrained"
lossHist: number[] = []
#runId = 0
#train: CompiledTraining<NCAModel> | null = null
#infer: CompiledForward | null = null
#render: CompiledForward | null = null
get #stage() { return (this.ctx.root as unknown as IRoot).stage }
retrain() {
const stage = this.#stage
stage.stopDemo()
if (stage.paused) stage.togglePause()
this.run()
}
setMode(m: "pretrained" | "train") {
if (m === this.mode) return
this.mode = m
this.retrain()
}
// one full (re)compile: fresh model, optimizer moments, and LR schedule.
// Pretrained mode compiles a LIGHT training graph (no VGG16-LPIPS, no K=24
// rollout) whose only job is to own the shared param buffers the demo/render
// forwards attach to. The heavy lossFn graph is built only when the user
// actually trains — otherwise every load paid to compile a VGG16 graph it
// never ran, saturating the GPU and freezing the page (and the host UI).
async #compileFresh(): Promise<void> {
this.#train?.destroy()
this.#train = null; this.#infer = null; this.#render = null
const full = this.mode === "train"
const t = full
? await compile({
model: new NCAModel(),
loss: lossFn,
optimizer: {
kind: "adam", lr: makeLR(),
// the paper's normalize_model_grads: per-param grad normalization on
// the NCA only (the SIREN's grads are left raw). No global clipping.
normalizeGrads: (n) => NCA_PARAMS.has(n),
},
inputs: {
state: [B, C, G, G], filters: [4 * C, 1, 3, 3],
bilin: [C * S * S, 1, 3, 3], bilin1: [S * S, 1, 3, 3],
coords: [B, 4, GH, GH], targetHi: [B, 4, GH, GH], chnSel: [B, 3, 4],
...LP_SPEC,
},
})
: await compile({
model: new NCAModel(),
loss: lossFnLight,
optimizer: { kind: "adam", lr: makeLR() },
inputs: {
state: [B, C, G, G], filters: [4 * C, 1, 3, 3],
bilin: [C * S * S, 1, 3, 3], bilin1: [S * S, 1, 3, 3],
coords: [B, 4, GH, GH],
},
})
this.#infer = await t.attach({
forward: demoFn,
inputs: { state: [1, C, G, G], filters: [4 * C, 1, 3, 3] },
})
this.#render = await t.attach({
forward: renderFn,
inputs: { state: [1, C, G, G], bilin: [C * S * S, 1, 3, 3], bilin1: [S * S, 1, 3, 3], coords: [1, 4, GH, GH] },
})
this.#train = t
}
// load the trained checkpoint through the assets chain: a locally-saved file
// shadows the hosted copy, one fetch covers dev and published alike.
async #loadWeights(): Promise<boolean> {
const upload = async (buf: ArrayBuffer) => this.#train!.uploadParams(loadSafetensors(buf).tensors)
try {
const res = await fetch(TRAINED_ASSET)
if (!res.ok) throw new Error(`${res.status}`)
await upload(await res.arrayBuffer())
slog(`loaded ${TRAINED_ASSET}`)
return true
} catch { return false }
}
async run() {
const stage = this.#stage
const run = ++this.#runId
if (!isWebGPUAvailable()) {
this.#setStatus("This needs WebGPU (recent Chrome, Edge, or Safari).")
return
}
this.#setStatus(`generating the donut…`)
let target: { hi: Float32Array; lo: Float32Array }
try { target = await loadTarget() }
catch (e) {
this.#setStatus(`target generation failed (${(e as Error).message})`)
slog(`target load failed: ${(e as Error).message}`)
return
}
if (run !== this.#runId) return
const { hi, lo } = target
placeSeed(lo)
LO_TARGET = lo
stage.showTarget(hi)
const targetHi = tile(hi, B)
this.#setStatus(`compiling WGSL kernels (K=${K} rollout + SIREN + VGG16-LPIPS)…`)
await this.#compileFresh()
if (run !== this.#runId) return
// pretrained mode never auto-trains: load the checkpoint, or say there
// isn't one. Training only ever runs on an explicit `train` click.
if (this.mode === "pretrained") {
if (await this.#loadWeights()) {
if (run !== this.#runId) return
this.lossHist = []
this.#setStatus(`ready: bite the donut and watch it regrow`)
stage.startDemo(this.#infer!, this.#render!)
} else {
this.#setStatus(`no pretrained weights found. click “train” to grow one`)
}
return
}
this.#setStatus("loading LPIPS-VGG16 (~59MB, first time only)…")
await ensureLpips((received, total) => {
const mb = (received / 1e6).toFixed(0)
const totMb = total ? (total / 1e6).toFixed(0) : "?"
this.#setStatus(`downloading LPIPS-VGG16 (${mb} of ${totMb} MB)…`)
})
if (run !== this.#runId) return
// ------------------------------------------------------------------
// The training loop: one flat TRAIN_STEPS run of the paper's recipe. A
// seed is injected into batch slot 0 every INJECT_EVERY steps; uniform
// pool sampling, full-batch writeback. No damage, no grading, no rounds —
// one 1500-step pass gives a stable donut (more rounds only buy fine
// detail this proof of concept doesn't chase).
// ------------------------------------------------------------------
const state = new Float32Array(B * STATE_LEN)
const chnSel = new Float32Array(B * 3 * 4)
const idx = new Int32Array(B)
// the on-screen view during training: the LAST batch slice after its
// K-step rollout, straight from the step's 'final' capture, rendered
// through the SIREN — you watch exactly what the optimizer just saw.
// (Slot 0 is the seed-injection slot; the last slice never is.)
const viewState = new Float32Array(STATE_LEN)
// pause = playground: scratches carve the view state, and while paused it
// advances at frame rate under the current weights so wounds heal live.
// Carves that race an in-flight infer step are re-applied to its output.
const viewCarves: [number, number][] = []
stage.onTrainCarve = (x, y) => {
if (!stage.paused) stage.togglePause() // scratching mid-train drops into the paused playground
stage.carveInto(viewState, x, y)
viewCarves.push([x, y])
stage.paintCells(viewState)
}
let loss = NaN, ema = NaN
let stepMsAcc = 0, stepMsN = 0
this.lossHist = []
slog(`training start: donut, G=${G} K=${K} B=${B} S=${S} pool=${POOL_SIZE} steps=${TRAIN_STEPS}`)
const pool: Float32Array[] = Array.from({ length: POOL_SIZE }, () => SEED.slice())
let step = 0
while (step < TRAIN_STEPS) {
if (run !== this.#runId) return
if (stage.paused) {
this.#setStatus("paused: bite the donut, it regrows with the weights so far")
while (stage.paused) {
if (run !== this.#runId) return
viewCarves.length = 0
const dr = await this.#infer!.run({ state: viewState, filters: FILTERS })
if (dr.kind === "completed") {
const nx = dr.output as Float32Array
for (const [cx, cy] of viewCarves) stage.carveInto(nx, cx, cy)
viewState.set(nx)
}
if (aliveCount(viewState) === 0) viewState.set(SEED) // fully scrubbed: regrow
stage.paintCells(viewState)
const rr = await this.#render!.run({ state: viewState, bilin: BILIN, bilin1: BILIN1, coords: COORDS1 })
if (rr.kind === "completed") stage.paintRender(rr.output as Float32Array)
await nextFrame()
}
}
sampleDistinct(idx, POOL_SIZE)
for (let b = 0; b < B; b++) state.set(pool[idx[b]!]!, b * STATE_LEN)
if (step % INJECT_EVERY === 0) state.set(SEED, 0) // paper: x[:1] = seed
fillChnSel(chnSel)
const t0 = performance.now()
const r = await this.#train!.step({
state, filters: FILTERS, bilin: BILIN, bilin1: BILIN1,
coords: COORDS_B, targetHi, chnSel, ...LP,
})
stepMsAcc += performance.now() - t0; stepMsN++
if (r.kind === "failed") {
if (run !== this.#runId) return
slog(`step ${step} FAILED: ${r.error}`)
this.#setStatus(`training failed (${r.error}) — hit “train” again for a fresh init`)
return
}
if (r.kind === "completed") {
step++
loss = r.loss
const fin = r.captures.get("final") as Float32Array
for (let b = 0; b < B; b++) pool[idx[b]!]!.set(fin.subarray(b * STATE_LEN, (b + 1) * STATE_LEN))
viewState.set(fin.subarray((B - 1) * STATE_LEN, B * STATE_LEN))
// a scratch that raced this in-flight step (auto-pausing) would be
// clobbered by the writeback above — re-apply it before it shows.
for (const [cx, cy] of viewCarves) stage.carveInto(viewState, cx, cy)
viewCarves.length = 0
stage.paintCells(viewState)
const rr = await this.#render!.run({ state: viewState, bilin: BILIN, bilin1: BILIN1, coords: COORDS1 })
if (rr.kind === "completed") stage.paintRender(rr.output as Float32Array)
if (step % 10 === 0 || step === 1) {
let mx = 0, alive0 = 0
for (let i = 0; i < STATE_LEN; i++) { const v = Math.abs(fin[i]!); if (v > mx) mx = v }
for (let i = 0; i < GG; i++) if (fin[3 * GG + i]! > 0.1) alive0++
slog(`step ${step} loss ${loss.toExponential(4)} aliveSeed ${alive0} maxAbs ${mx.toFixed(3)} ms/step ${(stepMsAcc / Math.max(1, stepMsN)).toFixed(0)}`)
stepMsAcc = 0; stepMsN = 0
if (!Number.isNaN(ema)) this.lossHist.push(ema)
}
ema = Number.isNaN(ema) ? loss : 0.9 * ema + 0.1 * loss
this.#setStatus(`step ${step}/${TRAIN_STEPS} · loss ${ema.toFixed(4)}`)
}
await sleep(0)
}
if (run !== this.#runId) return
slog(`TRAINING COMPLETE loss ${loss.toFixed(5)}`)
await this.#saveWeights()
this.#setStatus(`trained live on your GPU. bite the donut and watch it regrow`)
stage.startDemo(this.#infer!, this.#render!)
}
// download the current params and write them into assets/ — the next load
// serves this file, shadowing the hosted checkpoint (trusted local runs only).
async #saveWeights(): Promise<boolean> {
if (!this.#train) return false
try {
const params = await this.#train.downloadParams()
await tb.fs.write(TRAINED_ASSET, saveSafetensors(params))
slog(`saved weights → ${TRAINED_ASSET}`)
return true
} catch (e) { slog(`weight save failed: ${(e as Error).message}`); return false }
}
#setStatus(s: string) { this.status = s; this.update() }
}
// ============================================================================
// Stage — the three canvases (cells, painted hero, target) + the demo loop.
// ============================================================================
class Stage extends Component {
paused = false
demoActive = false
#live = false // has either left box painted yet? gates the "loading…" overlay
#canvas: HTMLCanvasElement | null = null
#ctx: CanvasRenderingContext2D | null = null
#img: ImageData | null = null
#cellsCtx: CanvasRenderingContext2D | null = null
#cellsImg: ImageData | null = null
#demoToken = 0
#state: Float32Array | null = null
#painting = false
#tctx: CanvasRenderingContext2D | null = null
#timg: ImageData | null = null
#target: Float32Array | null = null
#renderDamaged: (() => void) | null = null
onTrainCarve: ((x: number, y: number) => void) | null = null
#pendingCarves: [number, number][] = []
boot(cv: HTMLCanvasElement) {
if (this.#canvas) return
cv.width = cv.height = GH
this.#canvas = cv
this.#ctx = cv.getContext("2d")!
this.#img = new ImageData(GH, GH)
cv.addEventListener("pointerdown", e => {
this.#painting = true
cv.setPointerCapture(e.pointerId)
this.#damageAt(e)
})
cv.addEventListener("pointermove", e => { if (this.#painting) this.#damageAt(e) })
cv.addEventListener("pointerup", () => { this.#painting = false })
}
bootCells(cv: HTMLCanvasElement) {
if (this.#cellsCtx) return
cv.width = cv.height = G
this.#cellsCtx = cv.getContext("2d")!
this.#cellsImg = new ImageData(G, G)
}
togglePause() {
this.paused = !this.paused
this.update()
return this.paused
}
// the two left boxes are blank until the first frame paints; `live` gates a
// centered "loading…" overlay on them, removed the moment either one paints.
get live() { return this.#live }
#markLive() { if (!this.#live) { this.#live = true; this.update() } }
paintRender(arr: Float32Array) {
if (!this.#ctx) return
this.#fill(arr, this.#img!, GGH)
this.#ctx.putImageData(this.#img!, 0, 0)
this.#markLive()
}
paintCells(arr: Float32Array) {
if (this.#cellsCtx) this.#blit(arr, this.#cellsImg!, this.#cellsCtx, GG)
this.#markLive()
}
bootTarget(cv: HTMLCanvasElement) {
if (this.#tctx) return
cv.width = cv.height = GH
this.#tctx = cv.getContext("2d")!
this.#timg = new ImageData(GH, GH)
if (this.#target) this.showTarget(this.#target)
}
showTarget(arr: Float32Array) {
this.#target = arr
if (this.#tctx) this.#blit(arr, this.#timg!, this.#tctx, GGH)
}
#blit(arr: Float32Array, img: ImageData, ctx: CanvasRenderingContext2D, n: number) {
this.#fill(arr, img, n)
ctx.putImageData(img, 0, 0)
}
#fill(arr: Float32Array, img: ImageData, n: number) {
const d = img.data
for (let i = 0; i < n; i++) {
const a = clamp01(arr[3 * n + i]!)
const ia = a > 0.01 ? 1 / Math.max(a, 0.2) : 0
d[i * 4] = clamp01(arr[i]! * ia) * 255
d[i * 4 + 1] = clamp01(arr[n + i]! * ia) * 255
d[i * 4 + 2] = clamp01(arr[2 * n + i]! * ia) * 255
d[i * 4 + 3] = a * 255
}
}
async startDemo(infer: CompiledForward, render: CompiledForward) {
const token = ++this.#demoToken
this.demoActive = true
this.#state = SEED.slice()
let renderBusy = false
this.#renderDamaged = () => {
if (renderBusy || token !== this.#demoToken) return
renderBusy = true
render.run({ state: this.#state!, bilin: BILIN, bilin1: BILIN1, coords: COORDS1 }).then(r => {
renderBusy = false
if (token === this.#demoToken && r.kind === "completed") this.paintRender(r.output as Float32Array)
})
}
this.update()
let frames = 0
while (token === this.#demoToken) {
if (!this.paused) {
this.#pendingCarves.length = 0
const r = await infer.run({ state: this.#state!, filters: FILTERS })
if (token !== this.#demoToken) return
if (r.kind === "completed") {
const next = r.output as Float32Array
for (const [cx, cy] of this.#pendingCarves) this.carveInto(next, cx, cy)
this.#pendingCarves.length = 0
this.#state = this.#stabilize(next) ? SEED.slice() : next
}
const rr = await render.run({ state: this.#state!, bilin: BILIN, bilin1: BILIN1, coords: COORDS1 })
if (token !== this.#demoToken) return
if (rr.kind === "completed") {
this.paintRender(rr.output as Float32Array)
this.paintCells(this.#state!)
}
frames++
if (frames % 60 === 0 && LO_TARGET) slog(`drift t=${frames} mse=${sliceL2(this.#state!, 0, LO_TARGET).toFixed(5)}`)
}
await nextFrame()
}
}
stopDemo() {
this.#demoToken++
this.demoActive = false
this.#renderDamaged = null
this.#live = false // a fresh (re)compile is coming — show the loading overlay again
this.update()
}
reseed() {
if (!this.demoActive || !this.#state) return
this.#state.set(SEED)
if (this.paused) this.togglePause()
}
#aliveCount() { return this.#state ? aliveCount(this.#state) : 0 }
#stabilize(s: Float32Array): boolean {
let maxAbs = 0
for (let i = 0; i < STATE_LEN; i++) {
const v = s[i]!
const av = v < 0 ? -v : v
if (av > maxAbs) maxAbs = av
if (av > 1) s[i] = v < 0 ? -1 : 1
}
return deadOrExploded(aliveCount(s), maxAbs)
}
testPoke() {
if (!this.demoActive || !this.#state) {
if (this.onTrainCarve) { this.onTrainCarve(...SEED_POS); slog("poked the training display lineage"); return }
slog("poke ignored: demo not active")
return
}
const before = this.#aliveCount()
const [cx, cy] = SEED_POS
this.#carve(cx, cy)
slog(`poked at (${cx},${cy}): alive ${before} -> ${this.#aliveCount()}`)
}
testStats() { slog(`demo alive ${this.#aliveCount()} (demoActive ${this.demoActive})`) }
#carve(x: number, y: number) {
this.carveInto(this.#state!, x, y)
this.#pendingCarves.push([x, y])
}
carveInto(s: Float32Array, x: number, y: number) { carveDisc(s, 0, x, y, DAMAGE_R) }
#damageAt(e: PointerEvent) {
const rect = this.#canvas!.getBoundingClientRect()
const x = Math.floor((e.clientX - rect.left) / rect.width * G)
const y = Math.floor((e.clientY - rect.top) / rect.height * G)
if (!this.demoActive || !this.#state) { this.onTrainCarve?.(x, y); return }
this.#carve(x, y)
this.paintCells(this.#state!)
this.#renderDamaged?.()
}
}
// ============================================================================
// Root — header, canvases, dock.
// ============================================================================
const icon = (...kids: any[]) => svg({ class: "ico", viewBox: "0 0 24 24", fill: "currentColor" }, ...kids)
const ICON = {
play: () => icon(polygon({ points: "8,5 8,19 19,12" })),
pause: () => icon(rect({ x: 7, y: 5, width: 3.5, height: 14, rx: 1 }), rect({ x: 13.5, y: 5, width: 3.5, height: 14, rx: 1 })),
refresh: () => icon(path({ d: "M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" })),
}
interface IRoot { trainer: Trainer; stage: Stage }
class Root extends Component implements IRoot {
trainer = new Trainer()
stage = new Stage()
#boot(el: Element) {
this.stage.boot(el as HTMLCanvasElement)
this.trainer.run()
}
view() {
return div({ class: "wrap" },
div({ class: "header" },
h1({ class: "title" }, "Regrow the Donut"),
p({ class: "cap" },
"Every cell runs the same tiny neural network, and from a single seed they cooperate to grow the ",
"donut, then regrow it when you bite it. The twist: the cells never hold color, they hold a ",
"latent code that a shared decoder paints into a far higher-resolution image. It learns all of ",
"this live on your GPU, using the method from ",
a({ class: "link", href: "https://cells2pixels.github.io/", target: "_blank", rel: "noopener" }, "“From Cells to Pixels”"),
".",
),
),
div({ class: "row" },
div({ class: "panel" },
div({ class: "pane-wrap" },
canvas({ class: "pane cells", key: "cells", onMounted: (el: Element) => this.stage.bootCells(el as HTMLCanvasElement) }),
this.stage.live ? null : span({ class: "pane-loading" }, "loading…"),
),
span({ class: "pane-label" }, `cells ${G}² · raw lattice`),
),
div({ class: "panel" },
div({ class: "pane-wrap" },
canvas({ class: "pane grid", key: "grid", onMounted: (el: Element) => this.#boot(el) }),
this.stage.live ? null : span({ class: "pane-loading" }, "loading…"),
),
span({ class: "pane-label" }, `painted ${GH}² · bite me`),
),
div({ class: "panel" },
canvas({ class: "pane", key: "target", onMounted: (el: Element) => this.stage.bootTarget(el as HTMLCanvasElement) }),
span({ class: "pane-label" }, "target · the donut"),
),
),
div({ class: "dock" },
div({ class: "controls" },
div({ class: "modes" },
button({ class: ["mode-btn", this.trainer.mode === "pretrained" ? "active" : ""], title: "load the pretrained donut and watch it regrow", onClick: () => this.trainer.setMode("pretrained") }, "pretrained"),
button({ class: ["mode-btn", this.trainer.mode === "train" ? "active" : ""], title: "train a fresh donut from one seed cell", onClick: () => this.trainer.setMode("train") }, "train"),
),
div({ class: "bar" },
button(
{ class: "icon-btn", title: this.stage.paused ? "play" : "pause", onClick: () => this.stage.togglePause() },
this.stage.paused ? ICON.play() : ICON.pause(),
),
this.stage.demoActive
? button({ class: "icon-btn", title: "restart from a single seed cell", onClick: () => this.stage.reseed() }, ICON.refresh())
: null,
),
),
this.trainer.lossHist.length > 1 ? this.lossChart() : null,
div({ class: "readout" }, this.trainer.status),
),
)
}
lossChart() {
const h = this.trainer.lossHist
if (h.length < 2) return null
const W = 240, Hh = 34
const max = Math.max(...h), min = Math.min(...h), rng = max - min || 1
const x = (i: number) => i / (h.length - 1) * W
const y = (l: number) => 2 + (max - l) / rng * (Hh - 4)
const line = h.map((l, i) => `${x(i).toFixed(1)},${y(l).toFixed(1)}`).join(" ")
return svg({ class: "loss-chart", viewBox: `0 0 ${W} ${Hh}`, preserveAspectRatio: "none" },
polyline({ points: line, fill: "none", stroke: "var(--accent)", strokeWidth: 1.5, vectorEffect: "non-scaling-stroke" }),
)
}
}
const root = new Root()
new App({ root, id: "app" })
tb.onMessage((m: unknown) => {
if (m === "poke") root.stage.testPoke()
if (m === "stats") root.stage.testStats()
})
```
**styles.css**
```css
:root {
color-scheme: light;
--font: ui-sans-serif, system-ui, sans-serif;
--font-mono: ui-monospace, Menlo, Consolas, monospace;
--text: 0.9rem;
--accent: #9a794b;
--bg: #f1f1f1;
--fg: #1c1c1c;
--muted: #626262;
--readout: #414141;
--panel: #ffffffcc;
--panel-border: #d4d4d4;
--btn-bg: #ffffff;
--btn-fg-hover: #121212;
--btn-border-hover: #b6b6b6;
}
html[data-theme="dark"] {
color-scheme: dark;
--bg: #0e0e0e;
--fg: #eaeaea;
--muted: #b0b0b0;
--readout: #d2d2d2;
--panel: #161616cc;
--panel-border: #2e2e2e;
--btn-bg: #1e1e1e;
--btn-fg-hover: #ffffff;
--btn-border-hover: #454545;
}
html { font-size: 106.25%; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg); font-family: var(--font); }
body { font-size: var(--text); }
#app { height: 100dvh; min-height: 520px; }
.wrap { height: 100%; display: flex; flex-direction: column; --gap: 14px; }
.header, .dock { display: flex; flex-direction: column; align-items: center; gap: var(--gap); padding: var(--gap) 18px; text-align: center; }
.header > *, .dock > * { margin: 0; }
.title { font-size: 1.5rem; font-weight: 680; letter-spacing: .04em; text-transform: uppercase; color: var(--accent); }
.cap { max-width: 720px; line-height: 1.5; color: var(--muted); }
.cap .link { color: var(--accent); text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--accent) 45%, transparent); }
.cap .link:hover { border-bottom-color: var(--accent); }
/* the triptych: three equal panels, full width, pipeline order */
.row { flex: 1 1 auto; min-height: 200px; max-width: 900px; margin: 0 auto; display: flex; align-items: center; justify-content: center; gap: 14px; padding: 4px 18px; }
.panel { flex: 0 1 auto; display: flex; flex-direction: column; align-items: center; gap: 6px; min-width: 0; }
.pane {
width: min(28vw, 46vmin, 260px);
aspect-ratio: 1;
border: 1px solid var(--panel-border);
border-radius: 8px;
}
.pane.cells { image-rendering: pixelated; }
/* the hero canvas: quiet prominence via a soft warm elevation shadow + a muted
accent-tinted edge, not a hard gold outline (which read as a stark alert). a
gentle hover deepens the tint — the "you can touch this one" cue on approach. */
.pane.grid {
touch-action: none;
cursor: crosshair;
border-color: color-mix(in srgb, var(--accent) 38%, var(--panel-border));
box-shadow: 0 10px 28px -14px color-mix(in srgb, var(--accent) 50%, transparent);
}
.pane.grid:hover { border-color: color-mix(in srgb, var(--accent) 62%, var(--panel-border)); }
.pane-label { font-family: var(--font-mono); font-size: 0.78rem; color: var(--muted); }
.pane-wrap { position: relative; line-height: 0; }
.pane-loading {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
font-family: var(--font-mono); font-size: 0.78rem; letter-spacing: .04em; color: var(--muted);
pointer-events: none; animation: paneLoad 1.4s ease-in-out infinite;
}
@keyframes paneLoad { 0%, 100% { opacity: .4 } 50% { opacity: .85 } }
@media (max-width: 620px) {
/* stack the triptych so each pane gets real width and its label sits centered
under it, instead of three squished panes with colliding mono captions */
#app { height: auto; min-height: 100dvh; }
.wrap { height: auto; }
.row { flex-direction: column; gap: 16px; padding: 12px 18px; }
.pane { width: min(72vw, 300px); }
}
.dock { padding-bottom: calc(var(--gap) + 4px); }
.controls { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 10px; }
.bar { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 8px; }
.icon-btn { cursor: pointer; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; color: var(--readout); background: var(--btn-bg); border: 1px solid var(--panel-border); border-radius: 50%; }
.icon-btn:hover { color: var(--btn-fg-hover); border-color: var(--btn-border-hover); }
.icon-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.ico { width: 1.45em; height: 1.45em; flex: none; }
.readout { font-family: var(--font-mono); color: var(--readout); user-select: text; cursor: text; }
.modes { display: inline-flex; gap: 2px; padding: 2px; background: var(--btn-bg); border: 1px solid var(--panel-border); border-radius: 10px; }
.mode-btn { cursor: pointer; font: inherit; font-size: 0.8rem; color: var(--readout); background: transparent; border: none; border-radius: 0; padding: 4px 12px; }
.mode-btn:first-child { border-radius: 8px 0 0 8px; }
.mode-btn:last-child { border-radius: 0 8px 8px 0; }
.mode-btn.active { background: var(--accent); color: #fff; }
.loss-chart { width: 240px; max-width: 100%; height: 34px; display: block; opacity: 0.85; }
@media (max-height: 620px) {
.wrap { --gap: 9px; }
.cap { display: none; }
.title { font-size: 1.3rem; }
}
```
**index.html**
```html
<div id="app"></div>
```
**config.json**
```json
{
"description": "A neural cellular automaton grows a 3D donut from one cell, trained live on WebGPU with the Cells-to-Pixels recipe.",
"dependencies": {
"tensorgrad": "^0.4.2",
"domeleon": "^0.6.0"
}
}
```