/** * @module tui/canvas/gpu/ca-kernels * * WGSL kernels for the Living Canvas: a GPU cellular-automata compositor that * modulates ANY source texture (media frame, generated image, procedural field) * through a continuum of rule families — from perfect stillness to chaos: * * 0 identity — energy 0 ⇒ the raw image, pixel-exact * 1 shimmer — sub-pixel luminance breathing, barely alive * 2 reaction — Gray-Scott reaction-diffusion feeding on image luminance * 3 flow — curl-noise displacement field warps the image * 4 life — smooth-life CA whose food supply is the image * 5 chaos — high-energy unstable displacement + rule instability * * The LLM never touches pixels: it emits a tiny VisualIntent (family, energy, * palette, mood) and these shaders turn intent into deterministic motion. * `energy` scales every family continuously, so family×energy is a smooth 2-D * control surface the model can steer per response — and mic level breathes * through `audio` live while the user speaks. * * State layout: two ping-pong f32 field buffers (A/B chemistry or flow phase), * a source RGBA texture (sampled bilinearly with cover fit), and a u32 pixel * output buffer (RGBA8) read back for the terminal. */ /** Rule family ids, mirrored in WGSL below. */ export declare const CA_FAMILIES: { readonly identity: 0; readonly shimmer: 1; readonly reaction: 2; readonly flow: 3; readonly life: 4; readonly chaos: 5; }; export type CaFamilyName = keyof typeof CA_FAMILIES; export declare const CA_FAMILY_NAMES: CaFamilyName[]; /** Palettes (name → three anchor colors, lerped by CA intensity). */ export declare const CA_PALETTES: Record; /** * Uniform block — keep in sync with `Params` in the WGSL. * 12 × 4 bytes = 48 bytes (16-byte aligned). * Order: size.xy, srcSize.xy, t, dt, family, energy, audio, paletteMix, seed, reserveBottom. */ export declare const PARAMS_FLOATS = 12; export declare const CA_WGSL = "\nstruct Params {\n size: vec2, // output w,h in px\n srcSize: vec2, // source texture w,h in px (0 \u21D2 no source)\n t: f32, // sim time seconds\n dt: f32, // fixed step\n family: f32, // rule family 0..5\n energy: f32, // 0..1 \u2014 the master aliveness dial\n audio: f32, // live mic level 0..1 (breath)\n paletteMix: f32, // 0 keep source colors \u2026 1 full palette\n seed: f32, // deterministic variation\n reserveBottom: f32, // rows(px) at bottom to keep pristine (chat strip)\n};\n\n@group(0) @binding(0) var P: Params;\n@group(0) @binding(1) var PAL: array, 3>;\n@group(0) @binding(2) var fieldIn: array>;\n@group(0) @binding(3) var fieldOut: array>;\n@group(0) @binding(4) var src: array; // source RGBA8\n@group(0) @binding(5) var outPix: array; // output RGBA8\n\nfn hash(p: vec2) -> f32 {\n let h = dot(p, vec2(127.1, 311.7)) + P.seed * 17.0;\n return fract(sin(h) * 43758.5453123);\n}\n\nfn noise(p: vec2) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n return mix(\n mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),\n mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),\n u.y,\n );\n}\n\n/** Curl of a scalar noise field \u2014 divergence-free flow that looks organic. */\nfn curl(p: vec2, t: f32) -> vec2 {\n let e = 0.7;\n let q = p * 0.012 + vec2(t * 0.05, -t * 0.037);\n let n1 = noise(q + vec2(0.0, e * 0.012));\n let n2 = noise(q - vec2(0.0, e * 0.012));\n let n3 = noise(q + vec2(e * 0.012, 0.0));\n let n4 = noise(q - vec2(e * 0.012, 0.0));\n return vec2(n1 - n2, n4 - n3) * 80.0;\n}\n\nfn srcIndex(x: i32, y: i32) -> u32 {\n let w = i32(P.srcSize.x);\n let h = i32(P.srcSize.y);\n let xc = clamp(x, 0, w - 1);\n let yc = clamp(y, 0, h - 1);\n return u32(yc * w + xc);\n}\n\nfn unpack(c: u32) -> vec4 {\n return vec4(\n f32(c & 0xFFu),\n f32((c >> 8u) & 0xFFu),\n f32((c >> 16u) & 0xFFu),\n f32((c >> 24u) & 0xFFu),\n ) / 255.0;\n}\n\n/**\n * Bilinear sample of the source at output-normalized uv, with COVER fit (crop\n * the source to the output aspect, centered) and half-texel-correct mapping \u2014\n * a same-size identity pass reproduces the source EXACTLY (f = 0 everywhere).\n */\nfn sampleSrc(uv: vec2) -> vec4 {\n if (P.srcSize.x < 1.0) {\n // No source: a soft procedural field so the CA has something to eat.\n let v = noise(uv * 6.0 + vec2(P.t * 0.05, 0.0));\n return vec4(v * 0.25, v * 0.3, v * 0.4, 1.0);\n }\n // cover-fit window: crop source to the output aspect, centered\n let outAspect = P.size.x / P.size.y;\n let srcAspect = P.srcSize.x / P.srcSize.y;\n var win = vec2(1.0, 1.0);\n if (srcAspect > outAspect) { win.x = outAspect / srcAspect; } else { win.y = srcAspect / outAspect; }\n let uv2 = clamp((uv - vec2(0.5, 0.5)) * win + vec2(0.5, 0.5), vec2(0.0), vec2(1.0));\n let p = max(uv2 * P.srcSize - vec2(0.5, 0.5), vec2(0.0, 0.0));\n let i = floor(p);\n let f = fract(p);\n let x = i32(i.x);\n let y = i32(i.y);\n let c00 = unpack(src[srcIndex(x, y)]);\n let c10 = unpack(src[srcIndex(x + 1, y)]);\n let c01 = unpack(src[srcIndex(x, y + 1)]);\n let c11 = unpack(src[srcIndex(x + 1, y + 1)]);\n return mix(mix(c00, c10, f.x), mix(c01, c11, f.x), f.y);\n}\n\nfn luma(c: vec4) -> f32 {\n return dot(c.rgb, vec3(0.299, 0.587, 0.114));\n}\n\nfn fieldAt(x: i32, y: i32) -> vec2 {\n let w = i32(P.size.x);\n let h = i32(P.size.y);\n let xi = (x + w) % w;\n let yi = (y + h) % h;\n return fieldIn[u32(yi * w + xi)];\n}\n\nfn palette(v: f32) -> vec3 {\n let t = clamp(v, 0.0, 1.0) * 2.0;\n if (t < 1.0) { return mix(PAL[0].rgb, PAL[1].rgb, t); }\n return mix(PAL[1].rgb, PAL[2].rgb, t - 1.0);\n}\n\n@compute @workgroup_size(16, 16)\nfn step(@builtin(global_invocation_id) gid: vec3) {\n let w = u32(P.size.x);\n let h = u32(P.size.y);\n if (gid.x >= w || gid.y >= h) { return; }\n let x = i32(gid.x);\n let y = i32(gid.y);\n let idx = gid.y * w + gid.x;\n // pixel-center uv: same-size identity sampling is exact (no half-texel blur)\n let uv = vec2((f32(gid.x) + 0.5) / P.size.x, (f32(gid.y) + 0.5) / P.size.y);\n // breath: energy modulated by live audio + slow sine so nothing is static\n let breath = clamp(P.energy * (0.85 + 0.15 * sin(P.t * 0.9)) + P.audio * 0.25, 0.0, 1.2);\n let fam = i32(P.family + 0.5);\n\n var field = fieldAt(x, y);\n var color = vec4(0.0);\n\n if (fam == 0 || breath < 0.005) {\n // identity \u2014 the raw image, pixel-exact\n color = sampleSrc(uv);\n field = vec2(luma(color), 0.0);\n } else if (fam == 1) {\n // shimmer \u2014 sub-pixel uv wobble + luminance breathing\n let n = noise(uv * 40.0 + vec2(P.t * 0.6, -P.t * 0.4));\n let wob = (n - 0.5) * 0.004 * breath;\n color = sampleSrc(uv + vec2(wob, -wob));\n let lift = 1.0 + (n - 0.5) * 0.16 * breath;\n color = vec4(color.rgb * lift, color.a);\n field = vec2(luma(color), n);\n } else if (fam == 2) {\n // Gray-Scott reaction-diffusion; the image's luminance feeds chemical A\n let c = fieldAt(x, y);\n var lap = fieldAt(x - 1, y) + fieldAt(x + 1, y) + fieldAt(x, y - 1) + fieldAt(x, y + 1)\n + (fieldAt(x - 1, y - 1) + fieldAt(x + 1, y - 1) + fieldAt(x - 1, y + 1) + fieldAt(x + 1, y + 1)) * 0.5;\n lap = lap / 6.0 - c;\n let srcC = sampleSrc(uv);\n let feedBase = 0.030 + 0.025 * luma(srcC);\n let kill = 0.060;\n let a = c.x; let b = c.y;\n let abb = a * b * b;\n var a2 = a + (0.9 * lap.x - abb + feedBase * (1.0 - a)) * (0.6 + breath);\n var b2 = b + (0.45 * lap.y + abb - (kill + feedBase) * b) * (0.6 + breath);\n // seed chemistry from image edges when empty\n if (b2 < 0.001 && hash(uv * P.size + vec2(P.t, 0.0)) > 0.9995) { b2 = 0.5; }\n field = vec2(clamp(a2, 0.0, 1.0), clamp(b2, 0.0, 1.0));\n let reaction = field.y * 1.6;\n color = mix(srcC, vec4(palette(reaction), 1.0), clamp(reaction * P.paletteMix + reaction * 0.3, 0.0, 0.85) * breath);\n } else if (fam == 3) {\n // flow \u2014 curl-noise displacement warps the image itself\n let v = curl(vec2(f32(x), f32(y)), P.t) * breath;\n let disp = v / P.size * 1.8;\n color = sampleSrc(clamp(uv + disp, vec2(0.0), vec2(1.0)));\n field = vec2(length(v) * 0.01, 0.0);\n // faint streamline glow\n let glow = clamp(field.x - 0.35, 0.0, 1.0) * 0.5 * breath;\n color = vec4(mix(color.rgb, palette(field.x), glow * P.paletteMix), color.a);\n } else if (fam == 4) {\n // smooth-life \u2014 CA lives where the image is bright; paints with palette\n var n = 0.0;\n for (var dy = -1; dy <= 1; dy++) {\n for (var dx = -1; dx <= 1; dx++) {\n if (dx != 0 || dy != 0) { n += fieldAt(x + dx, y + dy).x; }\n }\n }\n let c = fieldAt(x, y).x;\n let food = luma(sampleSrc(uv));\n var next = c;\n if (n > 1.8 + (1.0 - food) && n < 3.6) { next = min(1.0, c + 0.30 * breath); }\n else { next = max(0.0, c - 0.12 * breath); }\n if (next < 0.01 && hash(uv * P.size + vec2(0.0, P.t)) > 0.9993) { next = 1.0; }\n field = vec2(next, n * 0.125);\n let srcC = sampleSrc(uv);\n color = mix(srcC, vec4(palette(next), 1.0), clamp(next * (0.35 + 0.5 * P.paletteMix), 0.0, 0.9) * breath);\n } else {\n // chaos \u2014 heavy unstable displacement + hue churn\n let v1 = curl(vec2(f32(x), f32(y)), P.t * 2.3);\n let v2 = curl(vec2(f32(y), f32(x)) * 1.7, -P.t * 1.1);\n let disp = (v1 + v2) / P.size * (3.5 * breath);\n var c1 = sampleSrc(fract(uv + disp));\n let churn = noise(uv * 9.0 + vec2(P.t * 1.7, P.t * 1.3));\n c1 = vec4(mix(c1.rgb, palette(churn), 0.45 * breath * max(P.paletteMix, 0.4)), 1.0);\n color = c1;\n field = vec2(churn, length(disp));\n }\n\n // chat strip stays pristine: hard-protect the reserved bottom rows\n if (f32(gid.y) >= P.size.y - P.reserveBottom) {\n color = sampleSrc(uv);\n }\n\n fieldOut[idx] = field;\n let r = u32(clamp(color.r, 0.0, 1.0) * 255.0);\n let g = u32(clamp(color.g, 0.0, 1.0) * 255.0);\n let b = u32(clamp(color.b, 0.0, 1.0) * 255.0);\n outPix[idx] = 0xFF000000u | (b << 16u) | (g << 8u) | r;\n}\n";