shadertown 0.7.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,15 +8,18 @@ export declare const shaderSources: {
8
8
  readonly bokeh: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// bokeh\n// ---------------------------------------------------------------------------\n\n// Bokeh — out-of-focus lights drifting through depth. Additive, soft-edged.\n\nfn bokehLayer(p : vec2f, depth : f32, t : f32) -> vec3f {\n let density = mix(2.5, 7.0, depth);\n let drift = vec2f(t * 0.06 * (1.0 - depth * 0.7), -t * 0.09 * (1.0 - depth * 0.5));\n let grid = p * density + drift * density;\n let base = floor(grid);\n let frac = fract(grid) - 0.5;\n\n var acc = vec3f(0.0);\n for (var y = -1; y <= 1; y = y + 1) {\n for (var x = -1; x <= 1; x = x + 1) {\n let neighbour = vec2f(f32(x), f32(y));\n let rnd = st_hash22(base + neighbour + depth * 91.7);\n if (rnd.x < 0.35) { continue; }\n\n let wobble = vec2f(sin(t * 0.5 + rnd.y * TAU), cos(t * 0.4 + rnd.x * TAU)) * 0.12;\n let centre = neighbour + rnd - 0.5 + wobble;\n let d = length(frac - centre);\n\n // A disc with a bright rim — how a real lens renders defocused points.\n let radius = (0.10 + rnd.y * 0.22) * params.a * mix(1.6, 0.5, depth);\n let disc = smoothstep(radius, radius * (1.0 - params.b), d);\n let rim = smoothstep(radius * (1.0 - params.b * 0.8), radius, d) * disc;\n\n let tint = st_palette(rnd.y);\n acc = acc + tint * (disc * 0.55 + rim * params.c) * mix(0.35, 1.0, 1.0 - depth);\n }\n }\n return acc;\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let p = st_centered(uv) * params.scale;\n let t = st_time();\n\n var col = mix(params.colorA.rgb * 0.10, vec3f(0.008, 0.008, 0.02), uv.y);\n for (var i = 0; i < 4; i = i + 1) {\n col = col + bokehLayer(p, f32(i) / 3.0, t) * params.d;\n }\n\n return st_post(st_tonemap(col * params.intensity), uv);\n}\n";
9
9
  readonly caldera: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// caldera\n// ---------------------------------------------------------------------------\n\n// Caldera — a volcanic island at night, lit by its own veins.\n//\n// The island is the usual fbm-minus-falloff, but the light source is inside\n// it: the valleys of a ridged field read as lava channels radiating from the\n// crater, brightest at the pool and cooling as they run out. Everything else\n// — basalt, the sea, the surf line that draws the coast — is lit by that\n// glow and a little moonlight.\n\nfn terrain(p : vec2f) -> f32 {\n return st_fbm2(p * 2.1 + vec2f(3.1, 7.7), 4) - smoothstep(0.1, 1.1, length(p)) * 0.9;\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let t = st_time();\n let p = st_centered(uv) * params.scale * 2.2;\n\n let h = terrain(p) - 0.14;\n let r = length(p);\n let craterR = 0.16 * params.a;\n\n // Moonlit sea: dark, but alive, and paler near the coast so the island\n // silhouette always reads.\n let swell = st_fbm2(p * 5.0 + vec2f(t * 0.1, -t * 0.07), 3);\n var col = vec3f(0.05, 0.07, 0.13) * (0.7 + swell * 0.6);\n col = col + vec3f(0.10, 0.13, 0.20) * exp(min(h, 0.0) * 9.0);\n\n let land = smoothstep(0.0, 0.015, h);\n\n // A thin surf line draws the coast in the dark.\n let coast = smoothstep(0.02, 0.0, abs(h)) * smoothstep(0.4, 0.8, st_fbm2(p * 12.0 + t * 0.25, 3));\n col = mix(col, vec3f(0.35, 0.42, 0.50), coast * 0.6);\n\n // Basalt: moon-grey shoulders, brighter with height so the cone shows.\n let rock = mix(vec3f(0.10, 0.10, 0.14), vec3f(0.24, 0.22, 0.27), smoothstep(0.0, 0.45, h))\n * (0.7 + 0.5 * st_fbm2(p * 7.0, 3));\n col = mix(col, rock, land);\n\n // Lava rivers: thin runs at fixed angles off the pool, meandering more\n // the further they fall, each pulsing outward — downhill — as it goes.\n // The angular multiplier is an integer, so the runs close over the seam.\n let ang = atan2(p.y, p.x);\n let meander = (st_fbm2(p * 2.6 + vec2f(7.7, 1.3), 3) - 0.5) * (2.2 + r * 4.5) * params.warp;\n let runs = abs(sin(ang * 3.0 + meander));\n let width = 0.22 * (0.4 + clamp(params.b, 0.0, 1.5)) / (1.0 + r * 2.5);\n let vein = smoothstep(width, width * 0.2, runs);\n let reach = smoothstep(craterR + mix(0.35, 1.0, clamp(params.b, 0.0, 1.5) / 1.5), craterR * 0.5, r);\n let pulse = 0.85 + 0.15 * sin(t * 1.1 + r * 5.0);\n let downhill = 0.7 + 0.3 * sin(r * 15.0 - t * 2.2 + meander);\n var lava = vein * reach * land * downhill;\n\n // The crater pool: a dark cooling crust floating on the bright melt.\n let crust = smoothstep(0.45, 0.75, st_fbm2(p * 9.0 + vec2f(0.0, t * 0.12), 3));\n let pool = smoothstep(craterR, craterR * 0.5, r) * (1.0 - crust * 0.55);\n lava = max(lava, pool);\n\n // Heat colours from the shared ramp: colorB the running orange, colorC\n // the white heart.\n let heat = st_palette(clamp(lava * 1.3, 0.0, 1.0));\n col = mix(col, heat, smoothstep(0.10, 0.45, lava));\n\n // The glow the mountain throws: veins light their own banks, the crater\n // lights the summit, and both tint the smoke of the night air.\n col = col + params.colorB.rgb * lava * lava * 1.4;\n col = col + params.colorB.rgb * exp(-abs(r - craterR * 0.7) * 9.0) * 0.25 * params.c * pulse;\n col = col + params.colorB.rgb * vein * reach * 0.22 * params.c;\n\n // Embers: a handful of soft sparks climbing off the crater, not pixels.\n for (var i = 0; i < 5; i = i + 1) {\n let fi = f32(i);\n let cycle = fract(t * (0.11 + st_hash11(fi) * 0.07) + st_hash11(fi * 3.7));\n let emberPos = vec2f(\n (st_hash11(fi * 7.3) - 0.5) * 0.5 + sin(t * 0.8 + fi * 2.2) * 0.06 + cycle * 0.3,\n -cycle * 0.9 + 0.1,\n );\n let fadeIn = smoothstep(0.0, 0.1, cycle) * smoothstep(1.0, 0.55, cycle);\n col = col + params.colorC.rgb * exp(-length(p - emberPos) * 90.0) * fadeIn * params.d * 0.8;\n }\n\n return st_post(st_tonemap(col * params.intensity), uv);\n}\n";
10
10
  readonly caustics: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// caustics\n// ---------------------------------------------------------------------------\n\n// Caustics — light focused through a moving water surface onto a pool floor.\n\nfn causticField(p : vec2f, t : f32, sharpness : f32) -> f32 {\n var acc = 0.0;\n var q = p;\n // Stacked, counter-rotating cell fields approximate the focusing pattern.\n for (var i = 0; i < 3; i = i + 1) {\n let fi = f32(i);\n let rotated = st_rot(fi * 1.1 + t * 0.05 * (fi + 1.0)) * q;\n let wave = sin(rotated.x * 6.0 + t * (1.1 + fi * 0.3))\n + sin(rotated.y * 6.0 - t * (0.9 + fi * 0.2))\n + sin((rotated.x + rotated.y) * 4.0 + t * 0.7);\n acc = acc + abs(wave) / 3.0;\n q = q * 1.35;\n }\n let v = 1.0 - clamp(acc / 3.0, 0.0, 1.0);\n return pow(v, 1.0 + sharpness * 14.0);\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let t = st_time();\n var p = st_centered(uv) * params.scale * 2.0;\n\n // Surface ripple displaces the floor lookup.\n let ripple = vec2f(st_fbm2(p * 1.5 + t * 0.1, 3), st_fbm2(p * 1.5 + 4.2 - t * 0.08, 3));\n p = p + (ripple - 0.5) * params.warp;\n\n let light = causticField(p, t, params.a);\n\n // Chromatic split — the three wavelengths focus at slightly different depths.\n let split = params.b * 0.03;\n let r = causticField(p + vec2f(split, 0.0), t, params.a);\n let b = causticField(p - vec2f(split, 0.0), t, params.a);\n\n let floorTone = mix(params.colorA.rgb, params.colorB.rgb, clamp(st_fbm2(p * 0.8, 3), 0.0, 1.0)) * 0.35;\n var col = floorTone;\n col = col + params.colorC.rgb * vec3f(r, light, b) * params.intensity * 2.2;\n\n // Depth falloff towards the pool edges.\n col = col * (0.55 + 0.45 * smoothstep(1.1, 0.1, length(st_centered(uv)) * params.c));\n\n return st_post(st_tonemap(col), uv);\n}\n";
11
+ readonly chladni: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// shadertown graph prelude — prepended (after the shared prelude) to every\n// graph shader at build time.\n//\n// A graph shader is not one pass but a whole render graph in a single module:\n// a displacement field, a normal pass, an instanced particle draw, an\n// Unreal-style bloom chain, and a present pass, each its own entry point. The\n// runtime (src/graph.ts) drives them with `draw()` and explicit entry names;\n// `effect()` picks the first vertex and fragment entries, which is why\n// `vs_fullscreen` and `fs_main` (the present pass) are declared first.\n//\n// The variant file below this prelude supplies exactly one function:\n//\n// fn st_field(p : vec2f, t : f32) -> vec4f\n//\n// `p` is the world-space position of a grid cell (±worldSize/2), `t` is\n// `st_time()`. The return value is displacement xyz in field units (scaled to\n// world units by `gu.world.y`) plus an aux channel in w — the shader's own\n// meaning of \"foam\": whatever the accent colour should land on.\n//\n// The pass structure, bloom chain and camera are ported from the vgpu\n// `fft-ocean` (\"Particles ocean\") example and keep its behaviour: additive\n// particles into an HDR scene, five blurred octaves recombined, linear to\n// sRGB on the way out.\n\nstruct GraphUniforms {\n view : mat4x4f,\n projection : mat4x4f,\n // canvas width, canvas height, point scale, grid resolution\n viewport : vec4f,\n // world size, height scale, point size, texel spacing (world / grid)\n world : vec4f,\n // fade near, fade far, fade power, unused\n fade : vec4f,\n // eye y, eye z, view pitch (radians), projection focal (1 / tan(fov / 2))\n camera : vec4f,\n}\n\nstruct BlurUniforms {\n direction : vec2f,\n invSize : vec2f,\n coeffs0 : vec4f,\n coeffs1 : vec4f,\n coeffs2 : vec4f,\n coeffs3 : vec4f,\n coeffs4 : vec4f,\n coeffs5 : vec4f,\n}\n\nstruct BloomUniforms {\n threshold : f32,\n smoothWidth : f32,\n strength : f32,\n radius : f32,\n factors0 : vec4f,\n factors1 : vec4f,\n}\n\n// Bindings are declared once for the whole module; vgpu builds layouts per\n// entry point, so each pass only owes the bindings it actually reads.\n@group(0) @binding(1) var<uniform> gu : GraphUniforms;\n@group(0) @binding(2) var<uniform> blurU : BlurUniforms;\n@group(0) @binding(3) var<uniform> bloomU : BloomUniforms;\n@group(0) @binding(4) var t_field : texture_2d<f32>;\n@group(0) @binding(5) var t_normal : texture_2d<f32>;\n@group(0) @binding(6) var t_scene : texture_2d<f32>;\n@group(0) @binding(7) var t_input : texture_2d<f32>;\n@group(0) @binding(8) var t_blur1 : texture_2d<f32>;\n@group(0) @binding(9) var t_blur2 : texture_2d<f32>;\n@group(0) @binding(10) var t_blur3 : texture_2d<f32>;\n@group(0) @binding(11) var t_blur4 : texture_2d<f32>;\n@group(0) @binding(12) var t_blur5 : texture_2d<f32>;\n@group(0) @binding(13) var t_bloom : texture_2d<f32>;\n@group(0) @binding(14) var linearSampler : sampler;\n\n// --- the pointer, in the world --------------------------------------------\n// The camera is fixed, so the pointer can be unprojected analytically: undo\n// the projection's focal scaling, undo the view's pitch, and the uv the\n// runtime delivers becomes a ray from the eye into the scene. Variants\n// intersect it with whatever surface they draw on.\n\nstruct PointerRay {\n origin : vec3f,\n dir : vec3f,\n}\n\n// The ray through any point of the frame, uv in 0..1 from the top left. Taking\n// the position rather than reading `params.pointer` lets a variant aim at a\n// place in the frame it picks itself.\nfn st_screenRay(uv : vec2f) -> PointerRay {\n let aspect = gu.viewport.x / max(gu.viewport.y, 1.0);\n let f = gu.camera.w;\n let ndc = vec2f(uv.x * 2.0 - 1.0, (1.0 - uv.y) * 2.0 - 1.0);\n let viewDir = normalize(vec3f(ndc.x * aspect / f, ndc.y / f, -1.0));\n // The view rotates the world by `camera.z` about x; the ray rotates back.\n let c = cos(gu.camera.z);\n let s = sin(gu.camera.z);\n var ray : PointerRay;\n ray.origin = vec3f(0.0, gu.camera.x, gu.camera.y);\n ray.dir = vec3f(viewDir.x, c * viewDir.y + s * viewDir.z, -s * viewDir.y + c * viewDir.z);\n return ray;\n}\n\nfn st_pointerRay() -> PointerRay {\n return st_screenRay(params.pointer);\n}\n\n// Where a point of the frame lands on the y = 0 water plane, in world xz.\nfn st_screenOnPlane(uv : vec2f) -> vec2f {\n let ray = st_screenRay(uv);\n let t = -ray.origin.y / min(ray.dir.y, -0.02);\n let hit = ray.origin + ray.dir * t;\n let half = gu.world.x * 0.5;\n return clamp(hit.xz, vec2f(-half), vec2f(half));\n}\n\n// Where the pointer lands on the y = 0 water plane, in world xz.\nfn st_pointerOnPlane() -> vec2f {\n return st_screenOnPlane(params.pointer);\n}\n\n// Where the pointer lands on the vertical plane z = depth, in world xy.\nfn st_pointerOnDepth(depth : f32) -> vec2f {\n let ray = st_pointerRay();\n let t = (depth - ray.origin.z) / min(ray.dir.z, -0.02);\n let hit = ray.origin + ray.dir * t;\n let half = gu.world.x * 0.5;\n return vec2f(clamp(hit.x, -half, half), clamp(hit.y, -20.0, 120.0));\n}\n\nstruct FullscreenOut {\n @builtin(position) position : vec4f,\n @location(0) uv : vec2f,\n}\n\n// Same triangle and uv orientation vgpu generates for a plain effect, so the\n// fullscreen fragment entries below read uv exactly like every other shader.\n@vertex fn vs_fullscreen(@builtin(vertex_index) vi : u32) -> FullscreenOut {\n var pos = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));\n var uv = array<vec2f, 3>(vec2f(0.0, 1.0), vec2f(2.0, 1.0), vec2f(0.0, -1.0));\n var out : FullscreenOut;\n out.position = vec4f(pos[vi], 0.0, 1.0);\n out.uv = uv[vi];\n return out;\n}\n\n// --- present ---------------------------------------------------------------\n// First fragment entry on purpose: the runtime's `effect()` for this shader\n// resolves to it, and the Stage drives it like any other shader's fragment.\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let scene = textureSample(t_scene, linearSampler, uv);\n let bloom = textureSample(t_bloom, linearSampler, uv);\n let linear = max(scene.rgb + bloom.rgb, vec3f(0.0));\n let lt = linear * 12.92;\n let gt = 1.055 * pow(linear, vec3f(0.41666)) - vec3f(0.055);\n let srgb = select(gt, lt, linear <= vec3f(0.0031308));\n return st_post(srgb, uv);\n}\n\n// --- displacement field ------------------------------------------------------\n\n@fragment fn fs_field(@location(0) uv : vec2f) -> @location(0) vec4f {\n let p = (uv - vec2f(0.5)) * gu.world.x;\n return st_field(p, st_time());\n}\n\n// --- normals ----------------------------------------------------------------\n\nfn st_loadField(coord : vec2i) -> vec4f {\n let n = i32(gu.viewport.w);\n let c = clamp(coord, vec2i(0), vec2i(n - 1, n - 1));\n return textureLoad(t_field, c, 0);\n}\n\n@fragment fn fs_normals(@builtin(position) position : vec4f) -> @location(0) vec4f {\n let coord = vec2i(position.xy - vec2f(0.5));\n let dx = gu.world.w;\n let s = gu.world.y;\n let r = st_loadField(coord + vec2i(1, 0));\n let l = st_loadField(coord - vec2i(1, 0));\n let t = st_loadField(coord + vec2i(0, 1));\n let b = st_loadField(coord - vec2i(0, 1));\n let dhdx = (r.y - l.y) * s / (2.0 * dx);\n let dhdz = (t.y - b.y) * s / (2.0 * dx);\n let normal = normalize(vec3f(-dhdx, 1.0, -dhdz));\n return vec4f(normal, st_loadField(coord).w);\n}\n\n// --- particles ----------------------------------------------------------------\n\nstruct ParticleOut {\n @builtin(position) position : vec4f,\n @location(0) pointCoord : vec2f,\n @location(1) aux : f32,\n @location(2) normal : vec3f,\n @location(3) viewDir : vec3f,\n @location(4) height : f32,\n @location(5) fade : f32,\n}\n\nfn st_quadCorner(vertexIndex : u32) -> vec2f {\n var corners = array<u32, 6>(0u, 1u, 2u, 2u, 1u, 3u);\n let cornerIndex = corners[vertexIndex % 6u];\n switch (cornerIndex) {\n case 0u: { return vec2f(-1.0, -1.0); }\n case 1u: { return vec2f( 1.0, -1.0); }\n case 2u: { return vec2f(-1.0, 1.0); }\n default: { return vec2f( 1.0, 1.0); }\n }\n}\n\n@vertex fn vs_particles(\n @builtin(vertex_index) vertexIndex : u32,\n @builtin(instance_index) instanceIndex : u32,\n) -> ParticleOut {\n let resolution = max(1u, u32(gu.viewport.w));\n let i = instanceIndex % resolution;\n let j = instanceIndex / resolution;\n let particleRef = vec2f(f32(i), f32(j)) / f32(resolution);\n let texCoord = vec2u(i, j);\n\n let raw = textureLoad(t_field, texCoord, 0);\n let disp = raw.xyz * gu.world.y;\n let nf = textureLoad(t_normal, texCoord, 0);\n\n let halfWorld = gu.world.x * 0.5;\n let base = vec3f(\n particleRef.x * gu.world.x - halfWorld,\n 0.0,\n particleRef.y * gu.world.x - halfWorld,\n );\n let pos = base + disp;\n\n let mv = gu.view * vec4f(pos, 1.0);\n let viewDir = -mv.xyz;\n let dist = -mv.z;\n let f = 1.0 - smoothstep(gu.fade.x, gu.fade.y, dist);\n let fade = pow(clamp(f, 0.0, 1.0), gu.fade.z);\n\n let projected = gu.projection * mv;\n let ndc = projected.xy / projected.w;\n\n let corner = st_quadCorner(vertexIndex);\n let pointSizePx = 2.0 * gu.world.z * gu.viewport.z;\n let clipOffset = corner * (pointSizePx / gu.viewport.xy) * projected.w;\n let clip = vec4f(ndc * projected.w + clipOffset, projected.z, projected.w);\n\n var out : ParticleOut;\n out.position = clip;\n out.pointCoord = corner * 0.5 + vec2f(0.5);\n out.aux = nf.w;\n out.normal = nf.xyz;\n out.viewDir = viewDir;\n out.height = raw.y;\n out.fade = fade;\n return out;\n}\n\n@fragment fn fs_particles(in : ParticleOut) -> @location(0) vec4f {\n let cc = in.pointCoord - vec2f(0.5);\n if (dot(cc, cc) > 0.25) { discard; }\n\n let n = normalize(in.normal);\n let v = normalize(in.viewDir);\n let fresnel = pow(1.0 - clamp(dot(n, v), 0.0, 1.0), 5.0);\n\n let aux = clamp(in.aux, 0.0, 1.0);\n let crest = smoothstep(-0.5, 1.5, in.height);\n\n var color = params.colorA.rgb * 0.5;\n color = color + params.colorB.rgb * crest * 0.5;\n color = color + params.colorB.rgb * fresnel * 0.15;\n color = mix(color, params.colorC.rgb, aux);\n\n // The source example exposed 512² splats; this grid is 256², so each of the\n // quarter as many splats carries four times the weight to match.\n var alpha = (0.02 + crest * 0.06 + fresnel * 0.04) * 4.0;\n alpha = mix(alpha, 1.0, aux);\n color = color * in.fade * params.intensity;\n alpha = clamp(alpha * in.fade, 0.0, 1.0);\n return vec4f(color, alpha);\n}\n\n// --- bloom -------------------------------------------------------------------\n\nfn st_luminance(rgb : vec3f) -> f32 {\n return dot(rgb, vec3f(0.299, 0.587, 0.114));\n}\n\n@fragment fn fs_bright(@location(0) uv : vec2f) -> @location(0) vec4f {\n // LuminosityHighPassShader, as ported by the fft-ocean example.\n let texel = textureSample(t_scene, linearSampler, uv);\n let gate = smoothstep(bloomU.threshold, bloomU.threshold + bloomU.smoothWidth, st_luminance(texel.xyz));\n return mix(vec4f(vec3f(0.0), 0.0), texel, gate);\n}\n\nconst ST_BLUR_RADIUS : u32 = 22u;\n\nfn st_blurCoefficient(i : u32) -> f32 {\n let packed = array<vec4f, 6>(\n blurU.coeffs0, blurU.coeffs1, blurU.coeffs2,\n blurU.coeffs3, blurU.coeffs4, blurU.coeffs5,\n );\n return packed[i / 4u][i % 4u];\n}\n\n@fragment fn fs_blur(@location(0) uv : vec2f) -> @location(0) vec4f {\n // UnrealBloomPass separable blur: coefficients past each level's radius are\n // zero, so one fixed loop serves all five levels.\n var weightSum = st_blurCoefficient(0u);\n var diffuseSum = textureSample(t_input, linearSampler, uv).rgb * weightSum;\n for (var i = 1u; i < ST_BLUR_RADIUS; i = i + 1u) {\n let w = st_blurCoefficient(i);\n let offset = blurU.direction * blurU.invSize * f32(i);\n let s1 = textureSample(t_input, linearSampler, uv + offset).rgb;\n let s2 = textureSample(t_input, linearSampler, uv - offset).rgb;\n diffuseSum = diffuseSum + (s1 + s2) * w;\n }\n return vec4f(diffuseSum, 1.0);\n}\n\nfn st_bloomFactor(i : u32) -> f32 {\n let v = array<f32, 8>(\n bloomU.factors0.x, bloomU.factors0.y, bloomU.factors0.z, bloomU.factors0.w,\n bloomU.factors1.x, bloomU.factors1.y, bloomU.factors1.z, bloomU.factors1.w,\n );\n let mirror = 1.2 - v[i];\n return mix(v[i], mirror, bloomU.radius);\n}\n\n@fragment fn fs_composite(@location(0) uv : vec2f) -> @location(0) vec4f {\n // UnrealBloomPass composite, tint colours all white.\n let bloom = 3.0 * bloomU.strength * (\n st_bloomFactor(0u) * textureSample(t_blur1, linearSampler, uv).rgb +\n st_bloomFactor(1u) * textureSample(t_blur2, linearSampler, uv).rgb +\n st_bloomFactor(2u) * textureSample(t_blur3, linearSampler, uv).rgb +\n st_bloomFactor(3u) * textureSample(t_blur4, linearSampler, uv).rgb +\n st_bloomFactor(4u) * textureSample(t_blur5, linearSampler, uv).rgb\n );\n return vec4f(bloom, max(bloom.r, max(bloom.g, bloom.b)));\n}\n\n// ---------------------------------------------------------------------------\n// chladni\n// ---------------------------------------------------------------------------\n\n// Chladni — a singing plate, and the sand that maps its silence.\n//\n// The height is a pair of square-plate standing waves, the classic\n// cos(mπx)cos(nπy) figures, beating in time. The aux channel is the inverse:\n// where the figure crosses zero the plate never moves, and that is where the\n// accent colour gathers — the same reason sand walks to the nodal lines of a\n// real vibrating plate. Sweep drifts the mode numbers continuously, so the\n// figure re-solves itself into the next one instead of cutting.\n\nfn chladniFigure(q : vec2f, m : f32, n : f32) -> f32 {\n // The difference of the two orderings is what draws the diagonals; the sum\n // alone would be a plain checkerboard of humps.\n return cos(m * PI * q.x) * cos(n * PI * q.y) - cos(n * PI * q.x) * cos(m * PI * q.y);\n}\n\nfn st_field(p : vec2f, t : f32) -> vec4f {\n // q spans roughly ±1 across the plate; scale zooms the figure.\n let q = p * (params.scale / (gu.world.x * 0.5));\n\n // Sweep walks both mode numbers on incommensurate clocks, so the figure\n // never repeats a loop the eye can catch.\n let m = params.a + params.d * (0.5 + 0.5 * sin(t * 0.21));\n let n = params.b + params.d * (0.5 + 0.5 * sin(t * 0.13 + 2.1));\n let figure = chladniFigure(q, m, n);\n\n // The plate rings: the whole figure oscillates through zero, which is what\n // separates a vibration from a relief map of one.\n var h = figure * 0.4 * cos(t * 2.6);\n\n // A travelling ripple from the driver at the centre, fading toward the rim.\n let r = length(q);\n h = h + params.warp * 0.18 * sin(r * 22.0 - t * 3.0) * exp(-r * 1.6);\n\n // Sand: bright exactly where the figure is silent. The vibration term does\n // not gate it — nodes hold still at every phase, so the lines hold steady\n // while the plate rings around them.\n let node = 1.0 - smoothstep(0.0, 0.22, abs(figure));\n let aux = clamp(node * params.c * 0.85, 0.0, 1.0);\n\n return vec4f(0.0, h, 0.0, aux);\n}\n";
11
12
  readonly cloth: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// cloth\n// ---------------------------------------------------------------------------\n\n// Cloth — a sheet of silk pinned by its top edge, and the pointer is a hand.\n//\n// The simulation is Jakobsen's (2001): points remember where they were last\n// step instead of carrying a velocity, so integration is one subtraction and\n// constraints are enforced by simply moving points — no forces, no springs,\n// no stiffness explosion. Every point holds twelve distance constraints\n// (structural, shear, bend) and the solver is Jacobi rather than Gauss-Seidel\n// because thousands of points relax in parallel; ten sweeps a frame is\n// enough for fabric that stretches like cloth and not like rubber. The\n// pointer carries an invisible sphere the constraints drape around.\n//\n// Drawing it is the unusual part. There is no vertex pipeline here, so the\n// cloth rasterises itself: each cell of the grid splats its own little patch\n// of surface into a G-buffer with atomicMin — depth rides the high bits and\n// the cell and its local uv ride the low ones, so one atomic is both the\n// z-test and the write. The fragment stage rebuilds the exact point on the\n// sheet from the simulation buffers and shades it there: smooth normals, a\n// thread-direction sheen, and a shadow thrown on the wall behind.\n\nstruct SimUniforms {\n // Seconds this step advances the simulation. 0 redraws without moving.\n dt : f32,\n aspect : f32,\n // How far the pointer moved this frame, in uv per second.\n pointerVel : vec2f,\n // The raster the cloth draws itself into.\n rasterW : f32,\n rasterH : f32,\n // How awake the pointer's sphere is. It wakes the moment the hand moves\n // and falls asleep after a few still seconds, so an untouched canvas shows\n // a sheet, not a sheet with a mysterious dent where a cursor happens to be.\n grab : f32,\n}\n\n@group(0) @binding(1) var<uniform> sim : SimUniforms;\n\n// Cloth grid. Positions ping-pong through the Jacobi sweeps; `prev` is the\n// verlet memory of where each point was one step ago.\nconst NX : i32 = 64;\nconst NY : i32 = 44;\n\n@group(0) @binding(2) var<storage, read> ptsIn : array<vec4f>;\n@group(0) @binding(3) var<storage, read_write> ptsOut : array<vec4f>;\n@group(0) @binding(4) var<storage, read_write> prevPts : array<vec4f>;\n@group(0) @binding(5) var<storage, read_write> norms : array<vec4f>;\n@group(0) @binding(6) var<storage, read_write> gbuf : array<atomic<u32>>;\n\n// The fragment stage reads the same state through read-only bindings.\n@group(0) @binding(8) var<storage, read> ptsView : array<vec4f>;\n@group(0) @binding(9) var<storage, read> normsView : array<vec4f>;\n@group(0) @binding(10) var<storage, read> gbufView : array<u32>;\n\n// Rest dimensions of the sheet, in world units. y grows downward like uv.\nconst CLOTH_W : f32 = 1.16;\nconst CLOTH_H : f32 = 1.04;\nconst PIN_Y : f32 = -0.46;\nconst REST_X : f32 = CLOTH_W / f32(NX - 1);\nconst REST_Y : f32 = CLOTH_H / f32(NY - 1);\nconst DIAG : f32 = 1.4142135;\nconst EMPTY : u32 = 0xffffffffu;\n\nfn pinAt(col : i32) -> vec3f {\n return vec3f(-CLOTH_W * 0.5 + f32(col) * REST_X, PIN_Y, 0.0);\n}\n\n// A mild perspective: z away from the viewer shrinks, z toward grows.\nfn project(p : vec3f) -> vec3f {\n let zoom = 0.82 * clamp(params.scale, 0.3, 2.5);\n let persp = 1.0 / (1.0 + p.z * 0.68);\n let s = p.xy * zoom * persp;\n return vec3f((0.5 + s.x / sim.aspect) * sim.rasterW, (0.5 + s.y) * sim.rasterH, p.z);\n}\n\n// Where the pointer sits on the cloth's plane, inverted from the projection.\nfn pointerWorld() -> vec3f {\n let zoom = 0.82 * clamp(params.scale, 0.3, 2.5);\n return vec3f(\n (params.pointer.x - 0.5) * sim.aspect / zoom,\n (params.pointer.y - 0.5) / zoom,\n 0.0,\n );\n}\n\nfn windAt(p : vec3f, t : f32) -> vec3f {\n // Two rates: a slow breathe that swings the whole sheet, and finer gusts\n // that ripple across it. Most of the wind blows in z — the billow is what\n // reads as depth.\n let breeze = 0.14 + params.warp * 0.2;\n let gust = params.warp;\n let n1 = st_fbm3(vec3f(p.x * 1.4, p.y * 1.4, t * 0.35), 2) - 0.5;\n let n2 = st_fbm3(vec3f(p.x * 2.3 + 11.0, p.y * 2.1, t * 0.8), 2) - 0.5;\n let slow = sin(t * 0.5 + p.y * 1.8) * 0.5 + 0.5;\n return vec3f(\n breeze * (0.4 + 0.6 * slow) + n2 * gust * 0.5,\n n1 * gust * 0.24,\n n1 * (gust * 1.2 + breeze * 0.6) + sin(t * 0.43 + p.x * 2.2) * breeze * 0.7,\n );\n}\n\n// --- passes ------------------------------------------------------------------\n\n@compute @workgroup_size(64)\nfn cs_integrate(@builtin(global_invocation_id) gid : vec3u) {\n let i = i32(gid.x);\n if (i >= NX * NY) { return; }\n let col = i % NX;\n let row = i / NX;\n\n // Read the verlet memory before overwriting it — this thread is the only\n // writer of its own slot, so the order within the function is the hazard.\n let p = ptsIn[i].xyz;\n let prev = prevPts[i].xyz;\n prevPts[i] = vec4f(p, 0.0);\n if (row == 0) {\n ptsOut[i] = vec4f(pinAt(col), 0.0);\n return;\n }\n\n let t = params.time * params.speed;\n let h = sim.dt;\n // params.d is Drape: heavy velvet at 2, weightless chiffon near 0.\n let gravity = vec3f(0.0, 0.75 + params.d * 0.8, 0.0);\n let acc = gravity + windAt(p, t) * (2.6 / max(0.4 + params.d, 0.4));\n\n let inertia = (p - prev) * 0.972;\n ptsOut[i] = vec4f(p + inertia + acc * h * h, 0.0);\n}\n\n@compute @workgroup_size(64)\nfn cs_constrain(@builtin(global_invocation_id) gid : vec3u) {\n let i = i32(gid.x);\n if (i >= NX * NY) { return; }\n let col = i % NX;\n let row = i / NX;\n\n if (row == 0) {\n ptsOut[i] = vec4f(pinAt(col), 0.0);\n return;\n }\n\n let p = ptsIn[i].xyz;\n var sum = vec3f(0.0);\n var weight = 0.0;\n\n // Twelve constraints in three rings. Structural holds the weave together,\n // shear stops it collapsing into a parallelogram, bend resists creasing.\n for (var k = 0; k < 12; k = k + 1) {\n var off : vec2i;\n var rest : f32;\n var stiff : f32;\n switch (k) {\n case 0: { off = vec2i(-1, 0); rest = REST_X; stiff = 1.0; }\n case 1: { off = vec2i(1, 0); rest = REST_X; stiff = 1.0; }\n case 2: { off = vec2i(0, -1); rest = REST_Y; stiff = 1.0; }\n case 3: { off = vec2i(0, 1); rest = REST_Y; stiff = 1.0; }\n case 4: { off = vec2i(-1, -1); rest = length(vec2f(REST_X, REST_Y)); stiff = 0.82; }\n case 5: { off = vec2i(1, -1); rest = length(vec2f(REST_X, REST_Y)); stiff = 0.82; }\n case 6: { off = vec2i(-1, 1); rest = length(vec2f(REST_X, REST_Y)); stiff = 0.82; }\n case 7: { off = vec2i(1, 1); rest = length(vec2f(REST_X, REST_Y)); stiff = 0.82; }\n case 8: { off = vec2i(-2, 0); rest = REST_X * 2.0; stiff = 0.45; }\n case 9: { off = vec2i(2, 0); rest = REST_X * 2.0; stiff = 0.45; }\n case 10: { off = vec2i(0, -2); rest = REST_Y * 2.0; stiff = 0.45; }\n default: { off = vec2i(0, 2); rest = REST_Y * 2.0; stiff = 0.45; }\n }\n let nc = col + off.x;\n let nr = row + off.y;\n if (nc < 0 || nc >= NX || nr < 0 || nr >= NY) { continue; }\n let q = ptsIn[nr * NX + nc].xyz;\n let d = p - q;\n let len = max(length(d), 0.00001);\n // Each endpoint owns half the error; Jacobi applies the average below.\n sum = sum + d * ((rest - len) / len) * 0.5 * stiff;\n weight = weight + stiff;\n }\n\n // Jacobi needs over-relaxation to converge in a dozen sweeps — 2.2 over\n // the summed stiffness is the strongest value that stays stable here.\n var next = p + sum * (2.2 / max(weight, 1.0));\n\n // The pointer's sphere. Pure projection — the constraints above turn the\n // shove into folds that travel across the whole sheet.\n let sphereR = (0.05 + params.c * 0.13) * sim.grab;\n let toP = next - pointerWorld();\n let dist = length(toP);\n if (dist < sphereR) {\n next = pointerWorld() + toP * (sphereR / max(dist, 0.0001));\n // A moving hand drags as well as blocks — a whisper of it per sweep.\n next = next + vec3f(sim.pointerVel * sim.dt * 0.1, 0.0);\n }\n\n ptsOut[i] = vec4f(next, 0.0);\n}\n\n@compute @workgroup_size(64)\nfn cs_normals(@builtin(global_invocation_id) gid : vec3u) {\n let i = i32(gid.x);\n if (i >= NX * NY) { return; }\n let col = i % NX;\n let row = i / NX;\n let l = ptsIn[row * NX + max(col - 1, 0)].xyz;\n let r = ptsIn[row * NX + min(col + 1, NX - 1)].xyz;\n let b = ptsIn[max(row - 1, 0) * NX + col].xyz;\n let t = ptsIn[min(row + 1, NY - 1) * NX + col].xyz;\n norms[i] = vec4f(normalize(cross(r - l, t - b)), 0.0);\n}\n\n@compute @workgroup_size(256)\nfn cs_clear(@builtin(global_invocation_id) gid : vec3u) {\n let cells = u32(sim.rasterW) * u32(sim.rasterH);\n if (gid.x >= cells) { return; }\n atomicStore(&gbuf[gid.x], EMPTY);\n}\n\n// One thread per cell of the cloth: estimate the patch's footprint on the\n// raster and splat enough samples to cover it without holes. Each sample is\n// one atomicMin: twelve depth bits up top so nearer wins, then the cell and\n// its local uv, so the winning write already carries everything the fragment\n// stage needs — no second pass, and no bit-exact recomputation to trust.\n@compute @workgroup_size(64)\nfn cs_raster(@builtin(global_invocation_id) gid : vec3u) {\n let cell = i32(gid.x);\n if (cell >= (NX - 1) * (NY - 1)) { return; }\n let cx = cell % (NX - 1);\n let cy = cell / (NX - 1);\n let p00 = ptsIn[cy * NX + cx].xyz;\n let p10 = ptsIn[cy * NX + cx + 1].xyz;\n let p01 = ptsIn[(cy + 1) * NX + cx].xyz;\n let p11 = ptsIn[(cy + 1) * NX + cx + 1].xyz;\n\n let s00 = project(p00);\n let s10 = project(p10);\n let s01 = project(p01);\n let s11 = project(p11);\n\n let lo = min(min(s00.xy, s10.xy), min(s01.xy, s11.xy));\n let hi = max(max(s00.xy, s10.xy), max(s01.xy, s11.xy));\n let span = hi - lo;\n // Nearly two samples per pixel of the widest axis: oversampled enough that\n // the bilinear patch leaves no gaps even mid-stretch, capped so a torn\n // cell cannot stall.\n let k = clamp(i32(max(span.x, span.y) * 1.9) + 1, 2, 36);\n\n let w = i32(sim.rasterW);\n let h = i32(sim.rasterH);\n for (var j = 0; j < k; j = j + 1) {\n for (var s = 0; s < k; s = s + 1) {\n let u = (f32(s) + 0.5) / f32(k);\n let v = (f32(j) + 0.5) / f32(k);\n let pos = mix(mix(p00, p10, u), mix(p01, p11, u), v);\n let scr = project(pos);\n let px = vec2i(scr.xy);\n if (px.x < 0 || px.x >= w || px.y < 0 || px.y >= h) { continue; }\n // 4094 rather than 4095, so a full G-buffer word can never collide\n // with the EMPTY sentinel.\n let depth = u32(clamp((scr.z + 2.0) * 1024.0, 0.0, 4094.0));\n let word = (depth << 20u) | (u32(cell) << 8u) | (u32(u * 15.99) << 4u) | u32(v * 15.99);\n atomicMin(&gbuf[px.y * w + px.x], word);\n }\n }\n}\n\n// --- the picture -------------------------------------------------------------\n\nfn coveredAt(px : vec2i) -> f32 {\n let w = i32(sim.rasterW);\n let h = i32(sim.rasterH);\n let c = vec2i(clamp(px.x, 0, w - 1), clamp(px.y, 0, h - 1));\n return select(1.0, 0.0, gbufView[c.y * w + c.x] == EMPTY);\n}\n\nfn backdrop(uv : vec2f) -> vec3f {\n // A wall behind the sheet, lit from the same corner as the cloth.\n let glow = 1.0 - length(vec2f((uv.x - 0.5) * sim.aspect, uv.y - 0.36)) * 0.9;\n return params.colorA.rgb * (0.16 + 0.3 * clamp(glow, 0.0, 1.0));\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let rs = vec2f(sim.rasterW, sim.rasterH);\n let pxf = uv * rs - 0.5;\n let base = vec2i(floor(pxf));\n let f = pxf - floor(pxf);\n\n // Coverage sampled bilinearly is the antialiasing: the silhouette fades\n // over one raster texel instead of stepping.\n let cov = mix(\n mix(coveredAt(base), coveredAt(base + vec2i(1, 0)), f.x),\n mix(coveredAt(base + vec2i(0, 1)), coveredAt(base + vec2i(1, 1)), f.x),\n f.y,\n );\n\n // The wall, with the sheet's shadow thrown down-right of it.\n var wall = backdrop(uv);\n let shadowPx = vec2i(pxf - vec2f(rs.x * -0.018, rs.y * -0.03));\n let shade = (coveredAt(shadowPx) + coveredAt(shadowPx + vec2i(2, 3)) + coveredAt(shadowPx - vec2i(2, 1))) / 3.0;\n wall = wall * (1.0 - shade * 0.45);\n\n if (cov < 0.001) {\n return st_post(st_tonemap(wall * params.intensity), uv);\n }\n\n // Take the G-buffer word of the nearest covered texel.\n var px = base;\n if (coveredAt(px) < 0.5) { px = base + vec2i(1, 0); }\n if (coveredAt(px) < 0.5) { px = base + vec2i(0, 1); }\n if (coveredAt(px) < 0.5) { px = base + vec2i(1, 1); }\n let w = i32(sim.rasterW);\n let word = gbufView[clamp(px.y, 0, i32(sim.rasterH) - 1) * w + clamp(px.x, 0, w - 1)];\n\n let cell = i32((word >> 8u) & 0xfffu);\n let cu = f32((word >> 4u) & 0xfu) / 15.0;\n let cv = f32(word & 0xfu) / 15.0;\n let cx = cell % (NX - 1);\n let cy = cell / (NX - 1);\n\n // Rebuild the exact point on the sheet from the simulation buffers — the\n // shading is computed on the true surface, not on raster texels.\n let i00 = cy * NX + cx;\n let n = normalize(mix(\n mix(normsView[i00].xyz, normsView[i00 + 1].xyz, cu),\n mix(normsView[i00 + NX].xyz, normsView[i00 + NX + 1].xyz, cu),\n cv,\n ));\n\n // The thread direction: the x axis laid onto the surface. Deriving it from\n // the smooth normal keeps the sheen continuous across cell boundaries,\n // where a per-cell edge vector would band.\n let tangent = normalize(vec3f(1.0, 0.0, 0.0) - n * n.x);\n\n let lightDir = normalize(vec3f(-0.45, -0.55, -0.7));\n let viewDir = vec3f(0.0, 0.0, -1.0);\n\n // Cloth is lit from both sides — a sheet has no inside.\n let diffuse = abs(dot(n, lightDir));\n\n // Kajiya-Kay: silk highlights run along the threads, not around a point.\n let th = dot(tangent, normalize(lightDir + viewDir));\n let sheen = pow(max(1.0 - th * th, 0.0), 48.0);\n\n // Grazing angles catch the light — the fresnel rim that reads as sheen.\n let rim = pow(1.0 - abs(dot(n, viewDir)), 2.6);\n\n // A steep diffuse curve: the folds go genuinely dark, which is where the\n // sense of weight comes from.\n var col = mix(params.colorA.rgb * 0.45, params.colorB.rgb, 0.10 + 0.90 * diffuse * diffuse);\n col = col + params.colorC.rgb * (sheen * 0.7 + rim * 0.45) * params.b;\n\n col = mix(wall, col, cov);\n return st_post(st_tonemap(col * params.intensity), uv);\n}\n";
12
13
  readonly cumulus: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// cumulus\n// ---------------------------------------------------------------------------\n\n// Cumulus — a sky of fair-weather cloud, shaded by one cheat.\n//\n// The clouds are fbm on a plane overhead, warped once so they cauliflower.\n// Their lighting is a single extra sample toward the sun: where density\n// falls off in that direction the cloud face is lit, where it rises the\n// face is in its own shadow. God rays are the same field again, sampled a\n// few times along the screen-space line to the sun.\n\nfn cloudDensity(q : vec2f, t : f32) -> f32 {\n let w = st_fbm2(q * 2.0 + vec2f(t * 0.03, 0.0), 3);\n return st_fbm2(q + (w - 0.5) * 0.8 + vec2f(t * 0.05, t * 0.012), 5);\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let t = st_time();\n let p = st_centered(uv);\n let yUp = -p.y;\n\n let look = (params.pointer - vec2f(0.5)) * 0.15;\n\n let rd = normalize(vec3f(p.x + look.x * 0.2, yUp + 0.32 + look.y * 0.2, 1.0));\n let sunDir = normalize(vec3f(0.45, 0.16 + 0.4 * params.c, 0.65));\n let sunAmt = clamp(dot(rd, sunDir), 0.0, 1.0);\n\n // Sky: colorA overhead falling to a bright horizon, sun in colorC.\n var col = mix(mix(params.colorA.rgb, vec3f(1.0), 0.6), params.colorA.rgb, smoothstep(0.0, 0.4, rd.y));\n col = col + params.colorC.rgb * pow(sunAmt, 700.0) * 2.5;\n col = col + params.colorC.rgb * pow(sunAmt, 12.0) * 0.3;\n\n if (rd.y > 0.015) {\n // Project onto the cloud deck.\n let q = rd.xz / rd.y * 0.6 * params.scale;\n let fade = smoothstep(0.015, 0.14, rd.y);\n\n let den = cloudDensity(q, t);\n let cover = mix(0.72, 0.42, clamp(params.a, 0.0, 1.5) / 1.5);\n let body = smoothstep(cover, cover + 0.17 * params.b, den);\n\n // The one-sample cheat: density falling toward the sun means lit.\n let toward = cloudDensity(q + sunDir.xz * 0.14, t);\n let lit = clamp((den - toward) * 4.0 + 0.45, 0.0, 1.0);\n let shade = mix(params.colorB.rgb * 0.72, vec3f(1.03, 1.01, 0.98), lit);\n\n col = mix(col, shade, body * fade * 0.96);\n\n // Silver lining where a cloud edge crosses the sun's halo.\n let edge = smoothstep(cover, cover + 0.08, den) * (1.0 - smoothstep(cover + 0.1, cover + 0.3, den));\n col = col + params.colorC.rgb * edge * pow(sunAmt, 6.0) * 0.8 * fade;\n }\n\n // God rays: the cloud field re-read a few times along the line to the sun\n // in screen space — pure math, the \"mask\" is the same analytic function.\n let sunScreen = vec2f(0.32 - look.x * 0.3, 0.05 + 0.28 * params.c);\n var ray = 0.0;\n var weight = 0.55;\n let toSun = sunScreen - vec2f(p.x, yUp);\n for (var i = 1; i <= 8; i = i + 1) {\n let sp = vec2f(p.x, yUp) + toSun * (f32(i) / 8.0);\n let sr = normalize(vec3f(sp.x, sp.y + 0.32, 1.0));\n if (sr.y < 0.02) { continue; }\n let sq = sr.xz / sr.y * 0.6 * params.scale;\n let open = 1.0 - smoothstep(0.5, 0.72, cloudDensity(sq, t));\n ray = ray + open * weight;\n weight = weight * 0.82;\n }\n let towardSun = clamp(1.0 - length(toSun) * 0.9, 0.0, 1.0);\n col = col + params.colorC.rgb * ray * 0.05 * params.d * towardSun;\n\n return st_post(st_tonemap(col * params.intensity), uv);\n}\n";
13
14
  readonly dither: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// dither\n// ---------------------------------------------------------------------------\n\n// Dither — a smooth field quantised through an 8x8 Bayer matrix.\n// Deliberately low-fi: flat colour, a visible pattern, no antialiasing.\n\n// Ordered-dither threshold, built by interleaving the bits of y and x^y.\nfn bayer8(coord : vec2f) -> f32 {\n let x = i32(coord.x) & 7;\n let y = i32(coord.y) & 7;\n var index = 0;\n for (var i = 0u; i < 3u; i = i + 1u) {\n let shift = 2u - i;\n index = (index << 1u) | ((y >> shift) & 1);\n index = (index << 1u) | (((x ^ y) >> shift) & 1);\n }\n return f32(index) / 64.0;\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let t = st_time();\n let p = st_centered(uv) * params.scale;\n\n // Centred on 0.5 so every quantisation level gets used. Biasing the source\n // upwards instead just crushes the image to the top level.\n let field = st_fbm2(p * 2.0 + vec2f(t * 0.10, t * 0.06), 5);\n let sweep = clamp((field - 0.5) * 1.7 + (0.5 - uv.y) * params.c + 0.5, 0.0, 1.0);\n\n // Pixelate first, so the pattern reads the same at any resolution.\n let pixel = max(params.a, 1.0);\n let screenPos = floor(uv * params.resolution / pixel);\n let threshold = (bayer8(screenPos) - 0.5) / max(floor(params.b), 2.0);\n\n let levels = max(floor(params.b), 2.0);\n let stepped = min(floor(clamp(sweep + threshold * params.d * 2.0, 0.0, 1.0) * levels), levels - 1.0);\n let quantised = stepped / (levels - 1.0);\n\n let col = st_palette(quantised) * params.intensity;\n return st_post(col, uv);\n}\n";
15
+ readonly dunes: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// shadertown graph prelude — prepended (after the shared prelude) to every\n// graph shader at build time.\n//\n// A graph shader is not one pass but a whole render graph in a single module:\n// a displacement field, a normal pass, an instanced particle draw, an\n// Unreal-style bloom chain, and a present pass, each its own entry point. The\n// runtime (src/graph.ts) drives them with `draw()` and explicit entry names;\n// `effect()` picks the first vertex and fragment entries, which is why\n// `vs_fullscreen` and `fs_main` (the present pass) are declared first.\n//\n// The variant file below this prelude supplies exactly one function:\n//\n// fn st_field(p : vec2f, t : f32) -> vec4f\n//\n// `p` is the world-space position of a grid cell (±worldSize/2), `t` is\n// `st_time()`. The return value is displacement xyz in field units (scaled to\n// world units by `gu.world.y`) plus an aux channel in w — the shader's own\n// meaning of \"foam\": whatever the accent colour should land on.\n//\n// The pass structure, bloom chain and camera are ported from the vgpu\n// `fft-ocean` (\"Particles ocean\") example and keep its behaviour: additive\n// particles into an HDR scene, five blurred octaves recombined, linear to\n// sRGB on the way out.\n\nstruct GraphUniforms {\n view : mat4x4f,\n projection : mat4x4f,\n // canvas width, canvas height, point scale, grid resolution\n viewport : vec4f,\n // world size, height scale, point size, texel spacing (world / grid)\n world : vec4f,\n // fade near, fade far, fade power, unused\n fade : vec4f,\n // eye y, eye z, view pitch (radians), projection focal (1 / tan(fov / 2))\n camera : vec4f,\n}\n\nstruct BlurUniforms {\n direction : vec2f,\n invSize : vec2f,\n coeffs0 : vec4f,\n coeffs1 : vec4f,\n coeffs2 : vec4f,\n coeffs3 : vec4f,\n coeffs4 : vec4f,\n coeffs5 : vec4f,\n}\n\nstruct BloomUniforms {\n threshold : f32,\n smoothWidth : f32,\n strength : f32,\n radius : f32,\n factors0 : vec4f,\n factors1 : vec4f,\n}\n\n// Bindings are declared once for the whole module; vgpu builds layouts per\n// entry point, so each pass only owes the bindings it actually reads.\n@group(0) @binding(1) var<uniform> gu : GraphUniforms;\n@group(0) @binding(2) var<uniform> blurU : BlurUniforms;\n@group(0) @binding(3) var<uniform> bloomU : BloomUniforms;\n@group(0) @binding(4) var t_field : texture_2d<f32>;\n@group(0) @binding(5) var t_normal : texture_2d<f32>;\n@group(0) @binding(6) var t_scene : texture_2d<f32>;\n@group(0) @binding(7) var t_input : texture_2d<f32>;\n@group(0) @binding(8) var t_blur1 : texture_2d<f32>;\n@group(0) @binding(9) var t_blur2 : texture_2d<f32>;\n@group(0) @binding(10) var t_blur3 : texture_2d<f32>;\n@group(0) @binding(11) var t_blur4 : texture_2d<f32>;\n@group(0) @binding(12) var t_blur5 : texture_2d<f32>;\n@group(0) @binding(13) var t_bloom : texture_2d<f32>;\n@group(0) @binding(14) var linearSampler : sampler;\n\n// --- the pointer, in the world --------------------------------------------\n// The camera is fixed, so the pointer can be unprojected analytically: undo\n// the projection's focal scaling, undo the view's pitch, and the uv the\n// runtime delivers becomes a ray from the eye into the scene. Variants\n// intersect it with whatever surface they draw on.\n\nstruct PointerRay {\n origin : vec3f,\n dir : vec3f,\n}\n\n// The ray through any point of the frame, uv in 0..1 from the top left. Taking\n// the position rather than reading `params.pointer` lets a variant aim at a\n// place in the frame it picks itself.\nfn st_screenRay(uv : vec2f) -> PointerRay {\n let aspect = gu.viewport.x / max(gu.viewport.y, 1.0);\n let f = gu.camera.w;\n let ndc = vec2f(uv.x * 2.0 - 1.0, (1.0 - uv.y) * 2.0 - 1.0);\n let viewDir = normalize(vec3f(ndc.x * aspect / f, ndc.y / f, -1.0));\n // The view rotates the world by `camera.z` about x; the ray rotates back.\n let c = cos(gu.camera.z);\n let s = sin(gu.camera.z);\n var ray : PointerRay;\n ray.origin = vec3f(0.0, gu.camera.x, gu.camera.y);\n ray.dir = vec3f(viewDir.x, c * viewDir.y + s * viewDir.z, -s * viewDir.y + c * viewDir.z);\n return ray;\n}\n\nfn st_pointerRay() -> PointerRay {\n return st_screenRay(params.pointer);\n}\n\n// Where a point of the frame lands on the y = 0 water plane, in world xz.\nfn st_screenOnPlane(uv : vec2f) -> vec2f {\n let ray = st_screenRay(uv);\n let t = -ray.origin.y / min(ray.dir.y, -0.02);\n let hit = ray.origin + ray.dir * t;\n let half = gu.world.x * 0.5;\n return clamp(hit.xz, vec2f(-half), vec2f(half));\n}\n\n// Where the pointer lands on the y = 0 water plane, in world xz.\nfn st_pointerOnPlane() -> vec2f {\n return st_screenOnPlane(params.pointer);\n}\n\n// Where the pointer lands on the vertical plane z = depth, in world xy.\nfn st_pointerOnDepth(depth : f32) -> vec2f {\n let ray = st_pointerRay();\n let t = (depth - ray.origin.z) / min(ray.dir.z, -0.02);\n let hit = ray.origin + ray.dir * t;\n let half = gu.world.x * 0.5;\n return vec2f(clamp(hit.x, -half, half), clamp(hit.y, -20.0, 120.0));\n}\n\nstruct FullscreenOut {\n @builtin(position) position : vec4f,\n @location(0) uv : vec2f,\n}\n\n// Same triangle and uv orientation vgpu generates for a plain effect, so the\n// fullscreen fragment entries below read uv exactly like every other shader.\n@vertex fn vs_fullscreen(@builtin(vertex_index) vi : u32) -> FullscreenOut {\n var pos = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));\n var uv = array<vec2f, 3>(vec2f(0.0, 1.0), vec2f(2.0, 1.0), vec2f(0.0, -1.0));\n var out : FullscreenOut;\n out.position = vec4f(pos[vi], 0.0, 1.0);\n out.uv = uv[vi];\n return out;\n}\n\n// --- present ---------------------------------------------------------------\n// First fragment entry on purpose: the runtime's `effect()` for this shader\n// resolves to it, and the Stage drives it like any other shader's fragment.\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let scene = textureSample(t_scene, linearSampler, uv);\n let bloom = textureSample(t_bloom, linearSampler, uv);\n let linear = max(scene.rgb + bloom.rgb, vec3f(0.0));\n let lt = linear * 12.92;\n let gt = 1.055 * pow(linear, vec3f(0.41666)) - vec3f(0.055);\n let srgb = select(gt, lt, linear <= vec3f(0.0031308));\n return st_post(srgb, uv);\n}\n\n// --- displacement field ------------------------------------------------------\n\n@fragment fn fs_field(@location(0) uv : vec2f) -> @location(0) vec4f {\n let p = (uv - vec2f(0.5)) * gu.world.x;\n return st_field(p, st_time());\n}\n\n// --- normals ----------------------------------------------------------------\n\nfn st_loadField(coord : vec2i) -> vec4f {\n let n = i32(gu.viewport.w);\n let c = clamp(coord, vec2i(0), vec2i(n - 1, n - 1));\n return textureLoad(t_field, c, 0);\n}\n\n@fragment fn fs_normals(@builtin(position) position : vec4f) -> @location(0) vec4f {\n let coord = vec2i(position.xy - vec2f(0.5));\n let dx = gu.world.w;\n let s = gu.world.y;\n let r = st_loadField(coord + vec2i(1, 0));\n let l = st_loadField(coord - vec2i(1, 0));\n let t = st_loadField(coord + vec2i(0, 1));\n let b = st_loadField(coord - vec2i(0, 1));\n let dhdx = (r.y - l.y) * s / (2.0 * dx);\n let dhdz = (t.y - b.y) * s / (2.0 * dx);\n let normal = normalize(vec3f(-dhdx, 1.0, -dhdz));\n return vec4f(normal, st_loadField(coord).w);\n}\n\n// --- particles ----------------------------------------------------------------\n\nstruct ParticleOut {\n @builtin(position) position : vec4f,\n @location(0) pointCoord : vec2f,\n @location(1) aux : f32,\n @location(2) normal : vec3f,\n @location(3) viewDir : vec3f,\n @location(4) height : f32,\n @location(5) fade : f32,\n}\n\nfn st_quadCorner(vertexIndex : u32) -> vec2f {\n var corners = array<u32, 6>(0u, 1u, 2u, 2u, 1u, 3u);\n let cornerIndex = corners[vertexIndex % 6u];\n switch (cornerIndex) {\n case 0u: { return vec2f(-1.0, -1.0); }\n case 1u: { return vec2f( 1.0, -1.0); }\n case 2u: { return vec2f(-1.0, 1.0); }\n default: { return vec2f( 1.0, 1.0); }\n }\n}\n\n@vertex fn vs_particles(\n @builtin(vertex_index) vertexIndex : u32,\n @builtin(instance_index) instanceIndex : u32,\n) -> ParticleOut {\n let resolution = max(1u, u32(gu.viewport.w));\n let i = instanceIndex % resolution;\n let j = instanceIndex / resolution;\n let particleRef = vec2f(f32(i), f32(j)) / f32(resolution);\n let texCoord = vec2u(i, j);\n\n let raw = textureLoad(t_field, texCoord, 0);\n let disp = raw.xyz * gu.world.y;\n let nf = textureLoad(t_normal, texCoord, 0);\n\n let halfWorld = gu.world.x * 0.5;\n let base = vec3f(\n particleRef.x * gu.world.x - halfWorld,\n 0.0,\n particleRef.y * gu.world.x - halfWorld,\n );\n let pos = base + disp;\n\n let mv = gu.view * vec4f(pos, 1.0);\n let viewDir = -mv.xyz;\n let dist = -mv.z;\n let f = 1.0 - smoothstep(gu.fade.x, gu.fade.y, dist);\n let fade = pow(clamp(f, 0.0, 1.0), gu.fade.z);\n\n let projected = gu.projection * mv;\n let ndc = projected.xy / projected.w;\n\n let corner = st_quadCorner(vertexIndex);\n let pointSizePx = 2.0 * gu.world.z * gu.viewport.z;\n let clipOffset = corner * (pointSizePx / gu.viewport.xy) * projected.w;\n let clip = vec4f(ndc * projected.w + clipOffset, projected.z, projected.w);\n\n var out : ParticleOut;\n out.position = clip;\n out.pointCoord = corner * 0.5 + vec2f(0.5);\n out.aux = nf.w;\n out.normal = nf.xyz;\n out.viewDir = viewDir;\n out.height = raw.y;\n out.fade = fade;\n return out;\n}\n\n@fragment fn fs_particles(in : ParticleOut) -> @location(0) vec4f {\n let cc = in.pointCoord - vec2f(0.5);\n if (dot(cc, cc) > 0.25) { discard; }\n\n let n = normalize(in.normal);\n let v = normalize(in.viewDir);\n let fresnel = pow(1.0 - clamp(dot(n, v), 0.0, 1.0), 5.0);\n\n let aux = clamp(in.aux, 0.0, 1.0);\n let crest = smoothstep(-0.5, 1.5, in.height);\n\n var color = params.colorA.rgb * 0.5;\n color = color + params.colorB.rgb * crest * 0.5;\n color = color + params.colorB.rgb * fresnel * 0.15;\n color = mix(color, params.colorC.rgb, aux);\n\n // The source example exposed 512² splats; this grid is 256², so each of the\n // quarter as many splats carries four times the weight to match.\n var alpha = (0.02 + crest * 0.06 + fresnel * 0.04) * 4.0;\n alpha = mix(alpha, 1.0, aux);\n color = color * in.fade * params.intensity;\n alpha = clamp(alpha * in.fade, 0.0, 1.0);\n return vec4f(color, alpha);\n}\n\n// --- bloom -------------------------------------------------------------------\n\nfn st_luminance(rgb : vec3f) -> f32 {\n return dot(rgb, vec3f(0.299, 0.587, 0.114));\n}\n\n@fragment fn fs_bright(@location(0) uv : vec2f) -> @location(0) vec4f {\n // LuminosityHighPassShader, as ported by the fft-ocean example.\n let texel = textureSample(t_scene, linearSampler, uv);\n let gate = smoothstep(bloomU.threshold, bloomU.threshold + bloomU.smoothWidth, st_luminance(texel.xyz));\n return mix(vec4f(vec3f(0.0), 0.0), texel, gate);\n}\n\nconst ST_BLUR_RADIUS : u32 = 22u;\n\nfn st_blurCoefficient(i : u32) -> f32 {\n let packed = array<vec4f, 6>(\n blurU.coeffs0, blurU.coeffs1, blurU.coeffs2,\n blurU.coeffs3, blurU.coeffs4, blurU.coeffs5,\n );\n return packed[i / 4u][i % 4u];\n}\n\n@fragment fn fs_blur(@location(0) uv : vec2f) -> @location(0) vec4f {\n // UnrealBloomPass separable blur: coefficients past each level's radius are\n // zero, so one fixed loop serves all five levels.\n var weightSum = st_blurCoefficient(0u);\n var diffuseSum = textureSample(t_input, linearSampler, uv).rgb * weightSum;\n for (var i = 1u; i < ST_BLUR_RADIUS; i = i + 1u) {\n let w = st_blurCoefficient(i);\n let offset = blurU.direction * blurU.invSize * f32(i);\n let s1 = textureSample(t_input, linearSampler, uv + offset).rgb;\n let s2 = textureSample(t_input, linearSampler, uv - offset).rgb;\n diffuseSum = diffuseSum + (s1 + s2) * w;\n }\n return vec4f(diffuseSum, 1.0);\n}\n\nfn st_bloomFactor(i : u32) -> f32 {\n let v = array<f32, 8>(\n bloomU.factors0.x, bloomU.factors0.y, bloomU.factors0.z, bloomU.factors0.w,\n bloomU.factors1.x, bloomU.factors1.y, bloomU.factors1.z, bloomU.factors1.w,\n );\n let mirror = 1.2 - v[i];\n return mix(v[i], mirror, bloomU.radius);\n}\n\n@fragment fn fs_composite(@location(0) uv : vec2f) -> @location(0) vec4f {\n // UnrealBloomPass composite, tint colours all white.\n let bloom = 3.0 * bloomU.strength * (\n st_bloomFactor(0u) * textureSample(t_blur1, linearSampler, uv).rgb +\n st_bloomFactor(1u) * textureSample(t_blur2, linearSampler, uv).rgb +\n st_bloomFactor(2u) * textureSample(t_blur3, linearSampler, uv).rgb +\n st_bloomFactor(3u) * textureSample(t_blur4, linearSampler, uv).rgb +\n st_bloomFactor(4u) * textureSample(t_blur5, linearSampler, uv).rgb\n );\n return vec4f(bloom, max(bloom.r, max(bloom.g, bloom.b)));\n}\n\n// ---------------------------------------------------------------------------\n// dunes\n// ---------------------------------------------------------------------------\n\n// Dunes — a sand sea crawling downwind, crests picked out in light.\n//\n// The relief is ridged noise blended over soft fbm: the ridge operator folds\n// every octave around its midline, which is what turns rolling hills into\n// slip faces with a sharp crest. The whole field advects along the wind and\n// meanders under a domain warp, and a fine ripple rides the surface the way\n// wind ripples ride real dunes — perpendicular to the flow, an order of\n// magnitude smaller than the forms they sit on. Aux lands the accent colour\n// on the crests, where the sand is driest and catches the sun first.\n\nfn st_field(p : vec2f, t : f32) -> vec4f {\n // q spans a couple of noise cells across the frame; scale zooms the field.\n let q = p * (params.scale * 2.4 / gu.world.x);\n\n // The wind: the whole sand sea drifts, fast enough to read, slow enough\n // to stay a landscape.\n let wind = t * params.b * 0.05;\n var s = q + vec2f(wind, wind * 0.35);\n\n // Meander: the dune belts snake instead of running in ruler lines.\n let w = params.warp * 0.55;\n s = s + w * vec2f(\n st_fbm2(s * 1.3 + vec2f(0.0, wind * 0.5), 3) - 0.5,\n st_fbm2(s * 1.3 + vec2f(4.2, -wind * 0.5), 3) - 0.5,\n );\n\n let ridged = st_ridge2(s * 1.5, 4);\n let soft = st_fbm2(s * 1.5, 4);\n var h = mix(soft * 2.0 - 1.0, ridged * 1.9 - 0.85, clamp(params.a, 0.0, 1.0));\n\n // Wind ripples: a striped wave bent by noise so it breaks into fingers.\n let stripe = sin(s.x * 26.0 + s.y * 52.0 + st_noise2(s * 5.0) * 5.0);\n h = h + stripe * 0.05 * params.d;\n\n // Crests: the top of the ridge signal, sharpened so only the spine lights.\n let spine = clamp(ridged, 0.0, 1.0);\n let aux = clamp(spine * spine * spine * params.c * 0.9, 0.0, 1.0);\n\n return vec4f(0.0, h, 0.0, aux);\n}\n";
14
16
  readonly duotone: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// shadertown source prelude — appended to the shared prelude for shaders that\n// read a layer instead of inventing one.\n//\n// Every binding here is additive. A generative shader declares none of them and\n// keeps the exact layout it always had, and `Source` is its own uniform block\n// rather than four more fields on `Params`, so the ABI that every already\n// shipped shader was built against never moves.\n\n@group(0) @binding(1) var srcTexture : texture_2d<f32>;\n@group(0) @binding(2) var srcSampler : sampler;\n\nstruct Source {\n // Pixel size of the bound texture. Zero means \"assume it matches the canvas\".\n size : vec2f,\n // 0 stretches the source over the canvas, 1 covers the canvas and crops.\n fit : f32,\n _pad : f32,\n}\n\n@group(0) @binding(3) var<uniform> source : Source;\n\n// How much of the source each axis shows once it is fitted. Both components are\n// 1 or less: covering means cropping, never adding.\nfn st_srcScale() -> vec2f {\n if (source.fit < 0.5 || source.size.x < 1.0 || source.size.y < 1.0) {\n return vec2f(1.0);\n }\n let canvas = st_aspect();\n let src = source.size.x / source.size.y;\n if (src > canvas) {\n return vec2f(canvas / src, 1.0);\n }\n return vec2f(1.0, src / canvas);\n}\n\n/** Canvas uv to source uv. */\nfn st_srcUv(uv : vec2f) -> vec2f {\n return (uv - 0.5) * st_srcScale() + 0.5;\n}\n\n/**\n * Canvas uv to source uv, with room left around the edge.\n *\n * A shader that displaces its reads runs off the source at the border, and the\n * sampler answers by smearing its last row of texels across the gap. Reading a\n * slightly smaller crop gives the displacement somewhere to go, at the cost of\n * a few per cent of the picture.\n */\nfn st_srcUvInset(uv : vec2f, margin : f32) -> vec2f {\n return (uv - 0.5) * st_srcScale() * (1.0 - clamp(margin, 0.0, 0.4)) + 0.5;\n}\n\n/**\n * A canvas-space displacement, in source uv.\n *\n * Divides out the aspect so a push of 0.01 moves the same distance sideways as\n * it does up, whatever shape the canvas or the source is.\n */\nfn st_srcWarp(offset : vec2f) -> vec2f {\n return offset * st_srcScale() / vec2f(st_aspect(), 1.0);\n}\n\n/** Samples in source uv. Kernels that walk neighbours want this one. */\nfn st_srcAt(srcUv : vec2f) -> vec4f {\n return textureSample(srcTexture, srcSampler, srcUv);\n}\n\n/** Samples in canvas uv — the ordinary read. */\nfn st_src(uv : vec2f) -> vec4f {\n return st_srcAt(st_srcUv(uv));\n}\n\nfn st_srcRgb(uv : vec2f) -> vec3f {\n return st_src(uv).rgb;\n}\n\n/** One source texel, in source uv. */\nfn st_srcTexel() -> vec2f {\n return 1.0 / max(source.size, vec2f(1.0));\n}\n\n/**\n * 1 inside the source, falling to 0 just outside it.\n *\n * The sampler clamps, so a shader that pushes past the border smears the last\n * row of texels across the gap. Anything that displaces should fade instead.\n */\nfn st_srcInside(srcUv : vec2f) -> f32 {\n let edge = min(srcUv, vec2f(1.0) - srcUv);\n return smoothstep(0.0, 0.004, min(edge.x, edge.y));\n}\n\n/** Four-tap softening. Cheap enough to run per colour channel. */\nfn st_srcSoften(uv : vec2f, radius : f32) -> vec3f {\n let base = st_srcUv(uv);\n var sum = vec3f(0.0);\n for (var i = 0; i < 4; i = i + 1) {\n let angle = f32(i) * 1.570796 + 0.785398;\n sum = sum + st_srcAt(base + st_srcWarp(vec2f(cos(angle), sin(angle)) * radius)).rgb;\n }\n return sum * 0.25;\n}\n\n/**\n * Thirteen-tap spiral blur. `radius` is in canvas space.\n *\n * The golden angle keeps the taps from lining up into spokes, which is what a\n * uniform ring blur gives you at low tap counts.\n */\nfn st_srcBlur(uv : vec2f, radius : f32) -> vec3f {\n let base = st_srcUv(uv);\n var sum = st_srcAt(base).rgb;\n for (var i = 0; i < 12; i = i + 1) {\n let fi = f32(i);\n let angle = fi * 2.399963;\n let r = radius * sqrt((fi + 0.5) / 12.0);\n sum = sum + st_srcAt(base + st_srcWarp(vec2f(cos(angle), sin(angle)) * r)).rgb;\n }\n return sum / 13.0;\n}\n\n// ---------------------------------------------------------------------------\n// duotone\n// ---------------------------------------------------------------------------\n\n// Duotone — the layer's brightness read through your palette.\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let src = st_srcRgb(uv);\n var l = st_luma(src);\n\n // Black and white point first, contrast about the midpoint after. In the\n // other order a hard contrast would clip before the range is set.\n l = clamp((l - params.a) / max(params.b - params.a, 0.01), 0.0, 1.0);\n l = clamp((l - 0.5) * max(params.c, 0.01) + 0.5, 0.0, 1.0);\n\n let mapped = st_palette(l);\n // Keeping some of the original chroma stops a busy photograph from turning\n // into a flat two-colour poster when that is not what you wanted.\n let col = mix(mapped, mix(mapped, src, 0.85), clamp(params.d, 0.0, 1.0));\n\n return st_post(col * params.intensity, uv);\n}\n";
15
17
  readonly edges: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// shadertown source prelude — appended to the shared prelude for shaders that\n// read a layer instead of inventing one.\n//\n// Every binding here is additive. A generative shader declares none of them and\n// keeps the exact layout it always had, and `Source` is its own uniform block\n// rather than four more fields on `Params`, so the ABI that every already\n// shipped shader was built against never moves.\n\n@group(0) @binding(1) var srcTexture : texture_2d<f32>;\n@group(0) @binding(2) var srcSampler : sampler;\n\nstruct Source {\n // Pixel size of the bound texture. Zero means \"assume it matches the canvas\".\n size : vec2f,\n // 0 stretches the source over the canvas, 1 covers the canvas and crops.\n fit : f32,\n _pad : f32,\n}\n\n@group(0) @binding(3) var<uniform> source : Source;\n\n// How much of the source each axis shows once it is fitted. Both components are\n// 1 or less: covering means cropping, never adding.\nfn st_srcScale() -> vec2f {\n if (source.fit < 0.5 || source.size.x < 1.0 || source.size.y < 1.0) {\n return vec2f(1.0);\n }\n let canvas = st_aspect();\n let src = source.size.x / source.size.y;\n if (src > canvas) {\n return vec2f(canvas / src, 1.0);\n }\n return vec2f(1.0, src / canvas);\n}\n\n/** Canvas uv to source uv. */\nfn st_srcUv(uv : vec2f) -> vec2f {\n return (uv - 0.5) * st_srcScale() + 0.5;\n}\n\n/**\n * Canvas uv to source uv, with room left around the edge.\n *\n * A shader that displaces its reads runs off the source at the border, and the\n * sampler answers by smearing its last row of texels across the gap. Reading a\n * slightly smaller crop gives the displacement somewhere to go, at the cost of\n * a few per cent of the picture.\n */\nfn st_srcUvInset(uv : vec2f, margin : f32) -> vec2f {\n return (uv - 0.5) * st_srcScale() * (1.0 - clamp(margin, 0.0, 0.4)) + 0.5;\n}\n\n/**\n * A canvas-space displacement, in source uv.\n *\n * Divides out the aspect so a push of 0.01 moves the same distance sideways as\n * it does up, whatever shape the canvas or the source is.\n */\nfn st_srcWarp(offset : vec2f) -> vec2f {\n return offset * st_srcScale() / vec2f(st_aspect(), 1.0);\n}\n\n/** Samples in source uv. Kernels that walk neighbours want this one. */\nfn st_srcAt(srcUv : vec2f) -> vec4f {\n return textureSample(srcTexture, srcSampler, srcUv);\n}\n\n/** Samples in canvas uv — the ordinary read. */\nfn st_src(uv : vec2f) -> vec4f {\n return st_srcAt(st_srcUv(uv));\n}\n\nfn st_srcRgb(uv : vec2f) -> vec3f {\n return st_src(uv).rgb;\n}\n\n/** One source texel, in source uv. */\nfn st_srcTexel() -> vec2f {\n return 1.0 / max(source.size, vec2f(1.0));\n}\n\n/**\n * 1 inside the source, falling to 0 just outside it.\n *\n * The sampler clamps, so a shader that pushes past the border smears the last\n * row of texels across the gap. Anything that displaces should fade instead.\n */\nfn st_srcInside(srcUv : vec2f) -> f32 {\n let edge = min(srcUv, vec2f(1.0) - srcUv);\n return smoothstep(0.0, 0.004, min(edge.x, edge.y));\n}\n\n/** Four-tap softening. Cheap enough to run per colour channel. */\nfn st_srcSoften(uv : vec2f, radius : f32) -> vec3f {\n let base = st_srcUv(uv);\n var sum = vec3f(0.0);\n for (var i = 0; i < 4; i = i + 1) {\n let angle = f32(i) * 1.570796 + 0.785398;\n sum = sum + st_srcAt(base + st_srcWarp(vec2f(cos(angle), sin(angle)) * radius)).rgb;\n }\n return sum * 0.25;\n}\n\n/**\n * Thirteen-tap spiral blur. `radius` is in canvas space.\n *\n * The golden angle keeps the taps from lining up into spokes, which is what a\n * uniform ring blur gives you at low tap counts.\n */\nfn st_srcBlur(uv : vec2f, radius : f32) -> vec3f {\n let base = st_srcUv(uv);\n var sum = st_srcAt(base).rgb;\n for (var i = 0; i < 12; i = i + 1) {\n let fi = f32(i);\n let angle = fi * 2.399963;\n let r = radius * sqrt((fi + 0.5) / 12.0);\n sum = sum + st_srcAt(base + st_srcWarp(vec2f(cos(angle), sin(angle)) * r)).rgb;\n }\n return sum / 13.0;\n}\n\n// ---------------------------------------------------------------------------\n// edges\n// ---------------------------------------------------------------------------\n\n// Edges — the layer reduced to its outlines.\n\nfn edges_luma(srcUv : vec2f) -> f32 {\n return st_luma(st_srcAt(srcUv).rgb);\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let s = st_srcUv(uv);\n // Widening the step is what turns a hairline into a drawn line. Sub-texel\n // steps would just sample the same texel three times.\n let e = st_srcTexel() * max(params.a, 0.5);\n\n let tl = edges_luma(s + vec2f(-e.x, e.y));\n let tc = edges_luma(s + vec2f( 0.0, e.y));\n let tr = edges_luma(s + vec2f( e.x, e.y));\n let ml = edges_luma(s + vec2f(-e.x, 0.0));\n let mr = edges_luma(s + vec2f( e.x, 0.0));\n let bl = edges_luma(s + vec2f(-e.x, -e.y));\n let bc = edges_luma(s + vec2f( 0.0, -e.y));\n let br = edges_luma(s + vec2f( e.x, -e.y));\n\n // Sobel: the same kernel turned ninety degrees for the second axis.\n let gx = (tr + 2.0 * mr + br) - (tl + 2.0 * ml + bl);\n let gy = (tl + 2.0 * tc + tr) - (bl + 2.0 * bc + br);\n let gradient = length(vec2f(gx, gy));\n\n let line = smoothstep(params.b, params.b + 0.2, gradient);\n\n // The ground under the lines: the palette's dark end, with as much of the\n // original showing through as `fill` asks for.\n let ground = mix(st_palette(0.0), st_srcAt(s).rgb, clamp(params.c, 0.0, 1.0));\n var col = mix(ground, st_palette(1.0), line);\n\n // A little bleed off the line reads as ink, not as aliasing.\n col = col + st_palette(1.0) * pow(line, 3.0) * params.d;\n\n return st_post(col * params.intensity, uv);\n}\n";
16
18
  readonly eyes: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// eyes\n// ---------------------------------------------------------------------------\n\n// Eyes — a wall of eyes that look where the pointer goes.\n//\n// Each cell is one eye: an almond of sclera, an iris that travels toward the\n// cursor, a lid that blinks on its own clock. The gaze is the interactive\n// part, but the idle frame carries itself — with no pointer the eyes wander,\n// each on its own phase, so a card is never a grid of dead centres.\n\n// Where this eye is looking, as an offset inside its socket.\nfn eyeGaze(id : vec2f, cursorDir : vec2f, cursorDist : f32, t : f32, follow : f32) -> vec2f {\n let seed = st_hash22(id);\n // Idle wander: a slow lissajous per eye, desynchronised by the hash.\n let wt = t * (0.5 + seed.x * 0.4) + seed.y * TAU;\n let wander = vec2f(cos(wt), sin(wt * 1.31)) * 0.12;\n\n // Tracking: full deflection quickly, so even a far cursor is clearly seen.\n let track = cursorDir * min(cursorDist * 0.8, 1.0) * 0.16;\n return mix(wander, track, follow);\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let t = st_time();\n let density = params.scale * 4.0;\n let p = st_centered(uv) * density;\n\n let id = floor(p);\n let f = fract(p) - 0.5;\n\n // The cursor in the same lattice space as the eyes. uv runs down the\n // screen and so does rendering, so no flip is needed for the gaze to\n // point the right way.\n let cursor = vec2f((params.pointer.x - 0.5) * st_aspect(), params.pointer.y - 0.5) * density;\n let toCursor = cursor - (id + vec2f(0.5));\n let cursorDist = length(toCursor);\n let cursorDir = toCursor / max(cursorDist, 0.0001);\n\n let seed = st_hash21(id);\n\n // Blink: a brief pulse on a per-eye clock. The squeeze narrows the almond\n // rather than sliding a lid, which is one smoothstep instead of a shape.\n let blinkPhase = fract(t * 0.13 * params.b + seed * 7.0);\n let closed = smoothstep(0.055, 0.0, abs(blinkPhase - 0.5));\n let open = (1.0 - closed) * (0.72 + 0.28 * seed);\n\n // Almond: the sclera's half-height as a function of x, pinched to zero at\n // the corners. Everything else is clipped against this.\n let halfWidth = 0.40;\n let xNorm = clamp(f.x / halfWidth, -1.0, 1.0);\n let lidHeight = open * 0.26 * sqrt(max(1.0 - xNorm * xNorm, 0.0));\n let aa = max(fwidth(f.y), 0.002);\n\n let inX = smoothstep(halfWidth + aa, halfWidth - aa, abs(f.x));\n let sclera = smoothstep(lidHeight + aa, lidHeight - aa, abs(f.y)) * inX;\n\n // Iris and pupil ride the gaze; both are clamped by the almond, so a look\n // to the side slides the iris under the lids the way a real eye does.\n let gaze = eyeGaze(id, cursorDir, cursorDist, t, params.a);\n let irisR = params.c * 0.16;\n let dIris = length(f - gaze);\n let iris = smoothstep(irisR + aa, irisR - aa, dIris) * sclera;\n let pupilR = irisR * clamp(params.d, 0.15, 0.9);\n let pupil = smoothstep(pupilR + aa, pupilR - aa, dIris) * sclera;\n\n // One fixed catchlight sells the sphere; it does not follow the gaze.\n let gleam = smoothstep(0.05, 0.0, length(f - gaze - vec2f(-0.05, -0.06))) * pupil;\n\n // Iris shading: darker at the rim, striated toward the pupil.\n let ring = clamp((dIris / max(irisR, 0.001)), 0.0, 1.0);\n let stria = 0.85 + 0.15 * sin(atan2(f.y - gaze.y, f.x - gaze.x) * 18.0 + seed * TAU);\n let irisTone = params.colorB.rgb * mix(1.15, 0.45, ring * ring) * stria;\n\n var col = params.colorA.rgb * (0.5 + 0.12 * st_hash21(id + 31.7));\n col = mix(col, params.colorC.rgb * (0.9 - 0.35 * abs(f.y) * 2.0), sclera);\n col = mix(col, irisTone, iris);\n col = mix(col, vec3f(0.02), pupil);\n col = mix(col, vec3f(1.0), gleam);\n\n // A soft shadow above each eye seats it in the wall.\n col = col * (1.0 - 0.25 * smoothstep(0.0, -0.3, f.y) * (1.0 - sclera));\n\n return st_post(col * params.intensity, uv);\n}\n";
19
+ readonly ferro: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// shadertown graph prelude — prepended (after the shared prelude) to every\n// graph shader at build time.\n//\n// A graph shader is not one pass but a whole render graph in a single module:\n// a displacement field, a normal pass, an instanced particle draw, an\n// Unreal-style bloom chain, and a present pass, each its own entry point. The\n// runtime (src/graph.ts) drives them with `draw()` and explicit entry names;\n// `effect()` picks the first vertex and fragment entries, which is why\n// `vs_fullscreen` and `fs_main` (the present pass) are declared first.\n//\n// The variant file below this prelude supplies exactly one function:\n//\n// fn st_field(p : vec2f, t : f32) -> vec4f\n//\n// `p` is the world-space position of a grid cell (±worldSize/2), `t` is\n// `st_time()`. The return value is displacement xyz in field units (scaled to\n// world units by `gu.world.y`) plus an aux channel in w — the shader's own\n// meaning of \"foam\": whatever the accent colour should land on.\n//\n// The pass structure, bloom chain and camera are ported from the vgpu\n// `fft-ocean` (\"Particles ocean\") example and keep its behaviour: additive\n// particles into an HDR scene, five blurred octaves recombined, linear to\n// sRGB on the way out.\n\nstruct GraphUniforms {\n view : mat4x4f,\n projection : mat4x4f,\n // canvas width, canvas height, point scale, grid resolution\n viewport : vec4f,\n // world size, height scale, point size, texel spacing (world / grid)\n world : vec4f,\n // fade near, fade far, fade power, unused\n fade : vec4f,\n // eye y, eye z, view pitch (radians), projection focal (1 / tan(fov / 2))\n camera : vec4f,\n}\n\nstruct BlurUniforms {\n direction : vec2f,\n invSize : vec2f,\n coeffs0 : vec4f,\n coeffs1 : vec4f,\n coeffs2 : vec4f,\n coeffs3 : vec4f,\n coeffs4 : vec4f,\n coeffs5 : vec4f,\n}\n\nstruct BloomUniforms {\n threshold : f32,\n smoothWidth : f32,\n strength : f32,\n radius : f32,\n factors0 : vec4f,\n factors1 : vec4f,\n}\n\n// Bindings are declared once for the whole module; vgpu builds layouts per\n// entry point, so each pass only owes the bindings it actually reads.\n@group(0) @binding(1) var<uniform> gu : GraphUniforms;\n@group(0) @binding(2) var<uniform> blurU : BlurUniforms;\n@group(0) @binding(3) var<uniform> bloomU : BloomUniforms;\n@group(0) @binding(4) var t_field : texture_2d<f32>;\n@group(0) @binding(5) var t_normal : texture_2d<f32>;\n@group(0) @binding(6) var t_scene : texture_2d<f32>;\n@group(0) @binding(7) var t_input : texture_2d<f32>;\n@group(0) @binding(8) var t_blur1 : texture_2d<f32>;\n@group(0) @binding(9) var t_blur2 : texture_2d<f32>;\n@group(0) @binding(10) var t_blur3 : texture_2d<f32>;\n@group(0) @binding(11) var t_blur4 : texture_2d<f32>;\n@group(0) @binding(12) var t_blur5 : texture_2d<f32>;\n@group(0) @binding(13) var t_bloom : texture_2d<f32>;\n@group(0) @binding(14) var linearSampler : sampler;\n\n// --- the pointer, in the world --------------------------------------------\n// The camera is fixed, so the pointer can be unprojected analytically: undo\n// the projection's focal scaling, undo the view's pitch, and the uv the\n// runtime delivers becomes a ray from the eye into the scene. Variants\n// intersect it with whatever surface they draw on.\n\nstruct PointerRay {\n origin : vec3f,\n dir : vec3f,\n}\n\n// The ray through any point of the frame, uv in 0..1 from the top left. Taking\n// the position rather than reading `params.pointer` lets a variant aim at a\n// place in the frame it picks itself.\nfn st_screenRay(uv : vec2f) -> PointerRay {\n let aspect = gu.viewport.x / max(gu.viewport.y, 1.0);\n let f = gu.camera.w;\n let ndc = vec2f(uv.x * 2.0 - 1.0, (1.0 - uv.y) * 2.0 - 1.0);\n let viewDir = normalize(vec3f(ndc.x * aspect / f, ndc.y / f, -1.0));\n // The view rotates the world by `camera.z` about x; the ray rotates back.\n let c = cos(gu.camera.z);\n let s = sin(gu.camera.z);\n var ray : PointerRay;\n ray.origin = vec3f(0.0, gu.camera.x, gu.camera.y);\n ray.dir = vec3f(viewDir.x, c * viewDir.y + s * viewDir.z, -s * viewDir.y + c * viewDir.z);\n return ray;\n}\n\nfn st_pointerRay() -> PointerRay {\n return st_screenRay(params.pointer);\n}\n\n// Where a point of the frame lands on the y = 0 water plane, in world xz.\nfn st_screenOnPlane(uv : vec2f) -> vec2f {\n let ray = st_screenRay(uv);\n let t = -ray.origin.y / min(ray.dir.y, -0.02);\n let hit = ray.origin + ray.dir * t;\n let half = gu.world.x * 0.5;\n return clamp(hit.xz, vec2f(-half), vec2f(half));\n}\n\n// Where the pointer lands on the y = 0 water plane, in world xz.\nfn st_pointerOnPlane() -> vec2f {\n return st_screenOnPlane(params.pointer);\n}\n\n// Where the pointer lands on the vertical plane z = depth, in world xy.\nfn st_pointerOnDepth(depth : f32) -> vec2f {\n let ray = st_pointerRay();\n let t = (depth - ray.origin.z) / min(ray.dir.z, -0.02);\n let hit = ray.origin + ray.dir * t;\n let half = gu.world.x * 0.5;\n return vec2f(clamp(hit.x, -half, half), clamp(hit.y, -20.0, 120.0));\n}\n\nstruct FullscreenOut {\n @builtin(position) position : vec4f,\n @location(0) uv : vec2f,\n}\n\n// Same triangle and uv orientation vgpu generates for a plain effect, so the\n// fullscreen fragment entries below read uv exactly like every other shader.\n@vertex fn vs_fullscreen(@builtin(vertex_index) vi : u32) -> FullscreenOut {\n var pos = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));\n var uv = array<vec2f, 3>(vec2f(0.0, 1.0), vec2f(2.0, 1.0), vec2f(0.0, -1.0));\n var out : FullscreenOut;\n out.position = vec4f(pos[vi], 0.0, 1.0);\n out.uv = uv[vi];\n return out;\n}\n\n// --- present ---------------------------------------------------------------\n// First fragment entry on purpose: the runtime's `effect()` for this shader\n// resolves to it, and the Stage drives it like any other shader's fragment.\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let scene = textureSample(t_scene, linearSampler, uv);\n let bloom = textureSample(t_bloom, linearSampler, uv);\n let linear = max(scene.rgb + bloom.rgb, vec3f(0.0));\n let lt = linear * 12.92;\n let gt = 1.055 * pow(linear, vec3f(0.41666)) - vec3f(0.055);\n let srgb = select(gt, lt, linear <= vec3f(0.0031308));\n return st_post(srgb, uv);\n}\n\n// --- displacement field ------------------------------------------------------\n\n@fragment fn fs_field(@location(0) uv : vec2f) -> @location(0) vec4f {\n let p = (uv - vec2f(0.5)) * gu.world.x;\n return st_field(p, st_time());\n}\n\n// --- normals ----------------------------------------------------------------\n\nfn st_loadField(coord : vec2i) -> vec4f {\n let n = i32(gu.viewport.w);\n let c = clamp(coord, vec2i(0), vec2i(n - 1, n - 1));\n return textureLoad(t_field, c, 0);\n}\n\n@fragment fn fs_normals(@builtin(position) position : vec4f) -> @location(0) vec4f {\n let coord = vec2i(position.xy - vec2f(0.5));\n let dx = gu.world.w;\n let s = gu.world.y;\n let r = st_loadField(coord + vec2i(1, 0));\n let l = st_loadField(coord - vec2i(1, 0));\n let t = st_loadField(coord + vec2i(0, 1));\n let b = st_loadField(coord - vec2i(0, 1));\n let dhdx = (r.y - l.y) * s / (2.0 * dx);\n let dhdz = (t.y - b.y) * s / (2.0 * dx);\n let normal = normalize(vec3f(-dhdx, 1.0, -dhdz));\n return vec4f(normal, st_loadField(coord).w);\n}\n\n// --- particles ----------------------------------------------------------------\n\nstruct ParticleOut {\n @builtin(position) position : vec4f,\n @location(0) pointCoord : vec2f,\n @location(1) aux : f32,\n @location(2) normal : vec3f,\n @location(3) viewDir : vec3f,\n @location(4) height : f32,\n @location(5) fade : f32,\n}\n\nfn st_quadCorner(vertexIndex : u32) -> vec2f {\n var corners = array<u32, 6>(0u, 1u, 2u, 2u, 1u, 3u);\n let cornerIndex = corners[vertexIndex % 6u];\n switch (cornerIndex) {\n case 0u: { return vec2f(-1.0, -1.0); }\n case 1u: { return vec2f( 1.0, -1.0); }\n case 2u: { return vec2f(-1.0, 1.0); }\n default: { return vec2f( 1.0, 1.0); }\n }\n}\n\n@vertex fn vs_particles(\n @builtin(vertex_index) vertexIndex : u32,\n @builtin(instance_index) instanceIndex : u32,\n) -> ParticleOut {\n let resolution = max(1u, u32(gu.viewport.w));\n let i = instanceIndex % resolution;\n let j = instanceIndex / resolution;\n let particleRef = vec2f(f32(i), f32(j)) / f32(resolution);\n let texCoord = vec2u(i, j);\n\n let raw = textureLoad(t_field, texCoord, 0);\n let disp = raw.xyz * gu.world.y;\n let nf = textureLoad(t_normal, texCoord, 0);\n\n let halfWorld = gu.world.x * 0.5;\n let base = vec3f(\n particleRef.x * gu.world.x - halfWorld,\n 0.0,\n particleRef.y * gu.world.x - halfWorld,\n );\n let pos = base + disp;\n\n let mv = gu.view * vec4f(pos, 1.0);\n let viewDir = -mv.xyz;\n let dist = -mv.z;\n let f = 1.0 - smoothstep(gu.fade.x, gu.fade.y, dist);\n let fade = pow(clamp(f, 0.0, 1.0), gu.fade.z);\n\n let projected = gu.projection * mv;\n let ndc = projected.xy / projected.w;\n\n let corner = st_quadCorner(vertexIndex);\n let pointSizePx = 2.0 * gu.world.z * gu.viewport.z;\n let clipOffset = corner * (pointSizePx / gu.viewport.xy) * projected.w;\n let clip = vec4f(ndc * projected.w + clipOffset, projected.z, projected.w);\n\n var out : ParticleOut;\n out.position = clip;\n out.pointCoord = corner * 0.5 + vec2f(0.5);\n out.aux = nf.w;\n out.normal = nf.xyz;\n out.viewDir = viewDir;\n out.height = raw.y;\n out.fade = fade;\n return out;\n}\n\n@fragment fn fs_particles(in : ParticleOut) -> @location(0) vec4f {\n let cc = in.pointCoord - vec2f(0.5);\n if (dot(cc, cc) > 0.25) { discard; }\n\n let n = normalize(in.normal);\n let v = normalize(in.viewDir);\n let fresnel = pow(1.0 - clamp(dot(n, v), 0.0, 1.0), 5.0);\n\n let aux = clamp(in.aux, 0.0, 1.0);\n let crest = smoothstep(-0.5, 1.5, in.height);\n\n var color = params.colorA.rgb * 0.5;\n color = color + params.colorB.rgb * crest * 0.5;\n color = color + params.colorB.rgb * fresnel * 0.15;\n color = mix(color, params.colorC.rgb, aux);\n\n // The source example exposed 512² splats; this grid is 256², so each of the\n // quarter as many splats carries four times the weight to match.\n var alpha = (0.02 + crest * 0.06 + fresnel * 0.04) * 4.0;\n alpha = mix(alpha, 1.0, aux);\n color = color * in.fade * params.intensity;\n alpha = clamp(alpha * in.fade, 0.0, 1.0);\n return vec4f(color, alpha);\n}\n\n// --- bloom -------------------------------------------------------------------\n\nfn st_luminance(rgb : vec3f) -> f32 {\n return dot(rgb, vec3f(0.299, 0.587, 0.114));\n}\n\n@fragment fn fs_bright(@location(0) uv : vec2f) -> @location(0) vec4f {\n // LuminosityHighPassShader, as ported by the fft-ocean example.\n let texel = textureSample(t_scene, linearSampler, uv);\n let gate = smoothstep(bloomU.threshold, bloomU.threshold + bloomU.smoothWidth, st_luminance(texel.xyz));\n return mix(vec4f(vec3f(0.0), 0.0), texel, gate);\n}\n\nconst ST_BLUR_RADIUS : u32 = 22u;\n\nfn st_blurCoefficient(i : u32) -> f32 {\n let packed = array<vec4f, 6>(\n blurU.coeffs0, blurU.coeffs1, blurU.coeffs2,\n blurU.coeffs3, blurU.coeffs4, blurU.coeffs5,\n );\n return packed[i / 4u][i % 4u];\n}\n\n@fragment fn fs_blur(@location(0) uv : vec2f) -> @location(0) vec4f {\n // UnrealBloomPass separable blur: coefficients past each level's radius are\n // zero, so one fixed loop serves all five levels.\n var weightSum = st_blurCoefficient(0u);\n var diffuseSum = textureSample(t_input, linearSampler, uv).rgb * weightSum;\n for (var i = 1u; i < ST_BLUR_RADIUS; i = i + 1u) {\n let w = st_blurCoefficient(i);\n let offset = blurU.direction * blurU.invSize * f32(i);\n let s1 = textureSample(t_input, linearSampler, uv + offset).rgb;\n let s2 = textureSample(t_input, linearSampler, uv - offset).rgb;\n diffuseSum = diffuseSum + (s1 + s2) * w;\n }\n return vec4f(diffuseSum, 1.0);\n}\n\nfn st_bloomFactor(i : u32) -> f32 {\n let v = array<f32, 8>(\n bloomU.factors0.x, bloomU.factors0.y, bloomU.factors0.z, bloomU.factors0.w,\n bloomU.factors1.x, bloomU.factors1.y, bloomU.factors1.z, bloomU.factors1.w,\n );\n let mirror = 1.2 - v[i];\n return mix(v[i], mirror, bloomU.radius);\n}\n\n@fragment fn fs_composite(@location(0) uv : vec2f) -> @location(0) vec4f {\n // UnrealBloomPass composite, tint colours all white.\n let bloom = 3.0 * bloomU.strength * (\n st_bloomFactor(0u) * textureSample(t_blur1, linearSampler, uv).rgb +\n st_bloomFactor(1u) * textureSample(t_blur2, linearSampler, uv).rgb +\n st_bloomFactor(2u) * textureSample(t_blur3, linearSampler, uv).rgb +\n st_bloomFactor(3u) * textureSample(t_blur4, linearSampler, uv).rgb +\n st_bloomFactor(4u) * textureSample(t_blur5, linearSampler, uv).rgb\n );\n return vec4f(bloom, max(bloom.r, max(bloom.g, bloom.b)));\n}\n\n// ---------------------------------------------------------------------------\n// ferro\n// ---------------------------------------------------------------------------\n\n// Ferro — a pool of ferrofluid, and the pointer is the magnet.\n//\n// The fluid rests as a low swell until the pointer's field reaches it: under\n// the cursor a crown of spikes rises, one cone per cell of a jittered\n// lattice, the hedgehog a real ferrofluid grows because the surface pays for\n// height with surface tension and settles on discrete spikes. The crown\n// follows the pointer live — this is the shader you move the mouse over.\n// Rings ripple outward from the same point, and the accent colour sits on\n// the spike tips where the field is strongest.\n\nfn ferroSpike(q : vec2f) -> f32 {\n // The nearest point of a jittered lattice, as a cone: exp of the squared\n // distance keeps the tip round and the flank steep, which is what reads\n // as fluid rather than as terrain.\n let cell = floor(q);\n var best = 99.0;\n for (var dy = -1; dy <= 1; dy = dy + 1) {\n for (var dx = -1; dx <= 1; dx = dx + 1) {\n let n = cell + vec2f(f32(dx), f32(dy));\n let site = n + 0.2 + 0.6 * st_hash22(n);\n best = min(best, dot(q - site, q - site));\n }\n }\n return exp(-best * 9.0);\n}\n\n/**\n * Where the magnet sits.\n *\n * A pointer that has never moved reports the exact centre of the frame, and\n * this camera unprojects the centre to the horizon: the crown rises two\n * hundred world units out, where it reads as a smudge of light rather than as\n * the hedgehog the shader is named for. A card is drawn once with no pointer,\n * so that smudge was the whole preview.\n *\n * Rest it low in the frame instead, close enough to the camera to see. Only\n * the untouched centre is replaced — a pointer parked on the centre by hand\n * has moved through something else first, and the offset is a pixel wide, so\n * every position a reader can actually ask for is honoured exactly.\n */\nfn ferroMagnet() -> vec2f {\n let untouched = all(abs(params.pointer - vec2f(0.5)) < vec2f(1e-5));\n return st_screenOnPlane(select(params.pointer, vec2f(0.5, 0.76), untouched));\n}\n\nfn st_field(p : vec2f, t : f32) -> vec4f {\n let magnet = ferroMagnet();\n let toMagnet = p - magnet;\n let r = length(toMagnet);\n\n // The magnet's reach, in world units. Everything the pointer does happens\n // inside this envelope, so the rest of the pool stays a quiet surface.\n let reach = 30.0 + params.a * 40.0;\n let field = exp(-(r * r) / (reach * reach));\n\n // The resting pool: a slow swell, so the fluid reads as liquid even when\n // the pointer is parked.\n let q = p * (params.scale * 3.0 / gu.world.x);\n var h = (st_fbm2(q * 1.6 + vec2f(t * 0.08, -t * 0.05), 3) - 0.5) * 0.5 * params.warp;\n\n // The crown: cones on a lattice, pulled up by the field. The lattice is\n // pinned to the world, not the pointer, so moving the magnet hands the\n // crown from one set of spikes to the next the way real fluid does.\n let spikeQ = p * (0.10 + params.b * 0.10);\n let cone = ferroSpike(spikeQ + vec2f(0.0, t * 0.02));\n let crown = cone * field * (0.6 + params.d * 1.6);\n h = h + crown;\n\n // Rings the disturbance sheds, dying with distance and with Ripples at 0.\n h = h + sin(r * 0.5 - t * 3.2) * exp(-r * 0.045) * 0.16 * params.c;\n\n // Spikes lean toward the magnet a little — the field lines are not vertical.\n let lean = select(-toMagnet / max(r, 1.0), vec2f(0.0), r < 1.0) * crown * 0.6;\n\n // Tips: the accent lands on the top of each cone, only inside the field.\n let aux = clamp(cone * cone * field * (0.7 + params.d * 0.5), 0.0, 1.0);\n\n return vec4f(lean.x, h, lean.y, aux);\n}\n";
17
20
  readonly "film-grain": "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// film-grain\n// ---------------------------------------------------------------------------\n\n// Film grain — a texture pass. Dark wash, heavy stock grain, halation, scanlines.\n\nfn wash(p : vec2f, t : f32) -> f32 {\n return st_fbm2(p * 1.2 + vec2f(t * 0.05, t * 0.03), 4);\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let t = st_time();\n let p = st_centered(uv) * params.scale;\n\n // Chromatic aberration: read the same wash at three lateral offsets.\n let ca = params.c * 0.02;\n let wr = wash(p + vec2f(ca, 0.0), t);\n let wg = wash(p, t);\n let wb = wash(p - vec2f(ca, 0.0), t);\n\n var col = vec3f(\n st_palette(clamp(wr * 1.3 - 0.1, 0.0, 1.0)).r,\n st_palette(clamp(wg * 1.3 - 0.1, 0.0, 1.0)).g,\n st_palette(clamp(wb * 1.3 - 0.1, 0.0, 1.0)).b\n ) * params.intensity;\n\n // Stock grain, sampled per cell so it stays the same size at any DPR.\n let cell = floor(uv * params.resolution / max(params.d, 0.5));\n let n1 = st_hash21(cell + floor(t * 24.0) * 17.13);\n let n2 = st_hash21(cell.yx + floor(t * 24.0) * 3.71);\n col = col + (n1 * 0.65 + n2 * 0.35 - 0.5) * params.a;\n\n // Scanlines.\n let lines = sin(uv.y * params.resolution.y * 1.5) * 0.5 + 0.5;\n col = col * (1.0 - lines * params.b);\n\n // Halation blooming out of the brightest areas.\n col = col + st_palette(1.0) * pow(clamp(st_luma(col), 0.0, 1.0), 4.0) * 0.4;\n\n return st_post(col, uv);\n}\n";
18
21
  readonly flow: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// shadertown source prelude — appended to the shared prelude for shaders that\n// read a layer instead of inventing one.\n//\n// Every binding here is additive. A generative shader declares none of them and\n// keeps the exact layout it always had, and `Source` is its own uniform block\n// rather than four more fields on `Params`, so the ABI that every already\n// shipped shader was built against never moves.\n\n@group(0) @binding(1) var srcTexture : texture_2d<f32>;\n@group(0) @binding(2) var srcSampler : sampler;\n\nstruct Source {\n // Pixel size of the bound texture. Zero means \"assume it matches the canvas\".\n size : vec2f,\n // 0 stretches the source over the canvas, 1 covers the canvas and crops.\n fit : f32,\n _pad : f32,\n}\n\n@group(0) @binding(3) var<uniform> source : Source;\n\n// How much of the source each axis shows once it is fitted. Both components are\n// 1 or less: covering means cropping, never adding.\nfn st_srcScale() -> vec2f {\n if (source.fit < 0.5 || source.size.x < 1.0 || source.size.y < 1.0) {\n return vec2f(1.0);\n }\n let canvas = st_aspect();\n let src = source.size.x / source.size.y;\n if (src > canvas) {\n return vec2f(canvas / src, 1.0);\n }\n return vec2f(1.0, src / canvas);\n}\n\n/** Canvas uv to source uv. */\nfn st_srcUv(uv : vec2f) -> vec2f {\n return (uv - 0.5) * st_srcScale() + 0.5;\n}\n\n/**\n * Canvas uv to source uv, with room left around the edge.\n *\n * A shader that displaces its reads runs off the source at the border, and the\n * sampler answers by smearing its last row of texels across the gap. Reading a\n * slightly smaller crop gives the displacement somewhere to go, at the cost of\n * a few per cent of the picture.\n */\nfn st_srcUvInset(uv : vec2f, margin : f32) -> vec2f {\n return (uv - 0.5) * st_srcScale() * (1.0 - clamp(margin, 0.0, 0.4)) + 0.5;\n}\n\n/**\n * A canvas-space displacement, in source uv.\n *\n * Divides out the aspect so a push of 0.01 moves the same distance sideways as\n * it does up, whatever shape the canvas or the source is.\n */\nfn st_srcWarp(offset : vec2f) -> vec2f {\n return offset * st_srcScale() / vec2f(st_aspect(), 1.0);\n}\n\n/** Samples in source uv. Kernels that walk neighbours want this one. */\nfn st_srcAt(srcUv : vec2f) -> vec4f {\n return textureSample(srcTexture, srcSampler, srcUv);\n}\n\n/** Samples in canvas uv — the ordinary read. */\nfn st_src(uv : vec2f) -> vec4f {\n return st_srcAt(st_srcUv(uv));\n}\n\nfn st_srcRgb(uv : vec2f) -> vec3f {\n return st_src(uv).rgb;\n}\n\n/** One source texel, in source uv. */\nfn st_srcTexel() -> vec2f {\n return 1.0 / max(source.size, vec2f(1.0));\n}\n\n/**\n * 1 inside the source, falling to 0 just outside it.\n *\n * The sampler clamps, so a shader that pushes past the border smears the last\n * row of texels across the gap. Anything that displaces should fade instead.\n */\nfn st_srcInside(srcUv : vec2f) -> f32 {\n let edge = min(srcUv, vec2f(1.0) - srcUv);\n return smoothstep(0.0, 0.004, min(edge.x, edge.y));\n}\n\n/** Four-tap softening. Cheap enough to run per colour channel. */\nfn st_srcSoften(uv : vec2f, radius : f32) -> vec3f {\n let base = st_srcUv(uv);\n var sum = vec3f(0.0);\n for (var i = 0; i < 4; i = i + 1) {\n let angle = f32(i) * 1.570796 + 0.785398;\n sum = sum + st_srcAt(base + st_srcWarp(vec2f(cos(angle), sin(angle)) * radius)).rgb;\n }\n return sum * 0.25;\n}\n\n/**\n * Thirteen-tap spiral blur. `radius` is in canvas space.\n *\n * The golden angle keeps the taps from lining up into spokes, which is what a\n * uniform ring blur gives you at low tap counts.\n */\nfn st_srcBlur(uv : vec2f, radius : f32) -> vec3f {\n let base = st_srcUv(uv);\n var sum = st_srcAt(base).rgb;\n for (var i = 0; i < 12; i = i + 1) {\n let fi = f32(i);\n let angle = fi * 2.399963;\n let r = radius * sqrt((fi + 0.5) / 12.0);\n sum = sum + st_srcAt(base + st_srcWarp(vec2f(cos(angle), sin(angle)) * r)).rgb;\n }\n return sum / 13.0;\n}\n\n// ---------------------------------------------------------------------------\n// flow\n// ---------------------------------------------------------------------------\n\n// Flow — the layer pushed around by a slow, turbulent field.\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let t = st_time();\n let p = st_centered(uv) * params.scale * 2.5;\n\n // Two fbm fields sampled a long way apart stand in for a divergence-free\n // field. It is not one, but the eye reads the swirl and not the maths.\n let drift = vec2f(t * 0.05, t * 0.03);\n // Turbulence trades soft hills for sharp crests. Ridged noise gives the flow\n // filaments and stringy detail; smooth noise gives it broad, lazy currents.\n let turbulence = clamp(params.a, 0.0, 1.0);\n let fx = mix(st_fbm2(p + drift, 4), st_ridge2(p + drift, 3), turbulence);\n let fy = mix(\n st_fbm2(p + drift + vec2f(17.3, 9.1), 4),\n st_ridge2(p + drift + vec2f(17.3, 9.1), 3),\n turbulence,\n );\n var field = vec2f(fx, fy) - 0.5;\n\n // Curl the field so it circulates rather than pulling everything one way.\n field = mix(field, vec2f(-field.y, field.x), clamp(params.b, 0.0, 1.0));\n\n let shove = field * params.warp * 0.12;\n\n // Smearing along the flow, rather than in a circle, is what makes it look\n // stirred instead of merely blurred.\n let smear = params.c * 0.06;\n let step = normalize(field + vec2f(0.0001)) * smear;\n var col = vec3f(0.0);\n for (var i = 0; i < 5; i = i + 1) {\n let k = (f32(i) / 4.0 - 0.5);\n // Inset, so the field has material to push around at the border rather\n // than running off the picture and smearing the last row of texels.\n col = col + st_srcAt(st_srcUvInset(uv + shove + step * k, 0.1)).rgb;\n }\n col = col / 5.0;\n\n // Tint by how hard the field is pushing, so the fast water shows colour.\n let speed = clamp(length(field) * 2.0, 0.0, 1.0);\n col = mix(col, col * st_palette(speed) * 1.8, clamp(params.d, 0.0, 1.0));\n\n return st_post(col * params.intensity, uv);\n}\n";
19
- readonly fluid: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// fluid\n// ---------------------------------------------------------------------------\n\n// Fluid — ink in water that answers the pointer.\n//\n// A real Navier–Stokes solve, the incompressible half of it: Stam's stable\n// fluids (1999) as GPU Gems ch. 38 lays it out. Velocity is advected through\n// itself semi-Lagrangian — walk back along the flow and take what you find\n// there, unconditionally stable at any speed. Twenty Jacobi sweeps then solve\n// for the pressure whose gradient cancels the divergence, which is the step\n// that makes it water instead of smear: push somewhere and the field routes\n// around itself. Vorticity confinement feeds back the small whirls the coarse\n// grid keeps averaging away. The dye rides on top at a finer grid than the\n// velocity that carries it — the solve stays cheap while the ink stays sharp.\n// Dragging the pointer splats force and ink together; leave it still and the\n// simulation breathes on its own.\n\nstruct SimUniforms {\n // Seconds this step advances the simulation. 0 redraws without moving.\n dt : f32,\n aspect : f32,\n // Velocity, pressure, divergence and curl live on this grid…\n simW : f32,\n simH : f32,\n // …and the dye on this finer one.\n dyeW : f32,\n dyeH : f32,\n // One splat per step: where, how hard, and what colour, or w <= 0 for none.\n splatPos : vec2f,\n splatVel : vec2f,\n // x is a position on the palette ramp, y a brightness, z scales the splat\n // radius (idle plumes land wider than a pointer stroke), w > 0 arms it.\n splatColor : vec4f,\n}\n\n@group(0) @binding(1) var<uniform> sim : SimUniforms;\n\n// Every neighbour-reading pass ping-pongs: read the whole of one buffer,\n// write the whole of another. Only the single-cell splats work in place,\n// through the `RW` bindings.\n@group(0) @binding(2) var<storage, read> velIn : array<vec2f>;\n@group(0) @binding(3) var<storage, read_write> velOut : array<vec2f>;\n@group(0) @binding(4) var<storage, read_write> velRW : array<vec2f>;\n@group(0) @binding(5) var<storage, read> pIn : array<f32>;\n@group(0) @binding(6) var<storage, read_write> pOut : array<f32>;\n@group(0) @binding(7) var<storage, read_write> divBuf : array<f32>;\n@group(0) @binding(8) var<storage, read_write> curlBuf : array<f32>;\n@group(0) @binding(9) var<storage, read> dyeIn : array<vec4f>;\n@group(0) @binding(10) var<storage, read_write> dyeOut : array<vec4f>;\n@group(0) @binding(11) var<storage, read_write> dyeRW : array<vec4f>;\n\n// Velocity survives on its own for a while; the 0.2 is tuned, not derived.\nconst VEL_DISSIPATION : f32 = 0.2;\n// How much of last frame's pressure seeds the next solve. Starting warm is\n// what lets twenty iterations do the work of a hundred cold ones.\nconst PRESSURE_CARRY : f32 = 0.8;\n\nfn simSize() -> vec2i { return vec2i(i32(sim.simW), i32(sim.simH)); }\nfn dyeSize() -> vec2i { return vec2i(i32(sim.dyeW), i32(sim.dyeH)); }\n\nfn velAt(c : vec2i) -> vec2f {\n let s = simSize();\n let q = clamp(c, vec2i(0), s - 1);\n return velIn[q.y * s.x + q.x];\n}\n\nfn pressureAt(c : vec2i) -> f32 {\n let s = simSize();\n let q = clamp(c, vec2i(0), s - 1);\n return pIn[q.y * s.x + q.x];\n}\n\n// Bilinear sample of the velocity field at a uv position.\nfn sampleVel(uv : vec2f) -> vec2f {\n let s = vec2f(sim.simW, sim.simH);\n let px = uv * s - 0.5;\n let base = floor(px);\n let f = px - base;\n let b = vec2i(base);\n let v00 = velAt(b);\n let v10 = velAt(b + vec2i(1, 0));\n let v01 = velAt(b + vec2i(0, 1));\n let v11 = velAt(b + vec2i(1, 1));\n return mix(mix(v00, v10, f.x), mix(v01, v11, f.x), f.y);\n}\n\nfn dyeAt(c : vec2i) -> vec4f {\n let s = dyeSize();\n let q = clamp(c, vec2i(0), s - 1);\n return dyeIn[q.y * s.x + q.x];\n}\n\nfn sampleDye(uv : vec2f) -> vec4f {\n let s = vec2f(sim.dyeW, sim.dyeH);\n let px = uv * s - 0.5;\n let base = floor(px);\n let f = px - base;\n let b = vec2i(base);\n let d00 = dyeAt(b);\n let d10 = dyeAt(b + vec2i(1, 0));\n let d01 = dyeAt(b + vec2i(0, 1));\n let d11 = dyeAt(b + vec2i(1, 1));\n return mix(mix(d00, d10, f.x), mix(d01, d11, f.x), f.y);\n}\n\n// The gaussian a splat deposits, in aspect-corrected uv space.\nfn splatWeight(uv : vec2f, radius : f32) -> f32 {\n var p = uv - sim.splatPos;\n p.x = p.x * sim.aspect;\n return exp(-dot(p, p) / max(radius, 0.00003));\n}\n\n// --- passes, in the order they run each step ---------------------------------\n\n@compute @workgroup_size(8, 8)\nfn cs_advect_vel(@builtin(global_invocation_id) gid : vec3u) {\n let s = simSize();\n let c = vec2i(gid.xy);\n if (c.x >= s.x || c.y >= s.y) { return; }\n let uv = (vec2f(c) + 0.5) / vec2f(s);\n let back = uv - velIn[c.y * s.x + c.x] * sim.dt;\n var v = sampleVel(back) / (1.0 + VEL_DISSIPATION * sim.dt);\n // Free-slip walls: the flow may run along an edge but never through it.\n if (c.x == 0 || c.x == s.x - 1) { v.x = 0.0; }\n if (c.y == 0 || c.y == s.y - 1) { v.y = 0.0; }\n velOut[c.y * s.x + c.x] = v;\n}\n\n@compute @workgroup_size(8, 8)\nfn cs_splat_force(@builtin(global_invocation_id) gid : vec3u) {\n let s = simSize();\n let c = vec2i(gid.xy);\n if (c.x >= s.x || c.y >= s.y) { return; }\n let uv = (vec2f(c) + 0.5) / vec2f(s);\n let radius = (0.0004 + params.a * params.a * 0.004) * 4.0 * sim.splatColor.z;\n let i = c.y * s.x + c.x;\n velRW[i] = velRW[i] + sim.splatVel * params.c * splatWeight(uv, radius);\n}\n\n@compute @workgroup_size(8, 8)\nfn cs_curl(@builtin(global_invocation_id) gid : vec3u) {\n let s = simSize();\n let c = vec2i(gid.xy);\n if (c.x >= s.x || c.y >= s.y) { return; }\n let l = velAt(c + vec2i(-1, 0)).y;\n let r = velAt(c + vec2i(1, 0)).y;\n let b = velAt(c + vec2i(0, -1)).x;\n let t = velAt(c + vec2i(0, 1)).x;\n curlBuf[c.y * s.x + c.x] = 0.5 * ((r - l) - (t - b));\n}\n\n@compute @workgroup_size(8, 8)\nfn cs_vorticity(@builtin(global_invocation_id) gid : vec3u) {\n let s = simSize();\n let c = vec2i(gid.xy);\n if (c.x >= s.x || c.y >= s.y) { return; }\n let idx = c.y * s.x + c.x;\n // Push each cell toward the nearest strong whirl, scaled by its spin —\n // this puts back the curl the coarse grid dissipates.\n let l = abs(curlBuf[clamp(c.x - 1, 0, s.x - 1) + c.y * s.x]);\n let r = abs(curlBuf[clamp(c.x + 1, 0, s.x - 1) + c.y * s.x]);\n let b = abs(curlBuf[c.x + clamp(c.y - 1, 0, s.y - 1) * s.x]);\n let t = abs(curlBuf[c.x + clamp(c.y + 1, 0, s.y - 1) * s.x]);\n var force = 0.5 * vec2f(t - b, l - r);\n force = force / (length(force) + 0.0001);\n force = force * curlBuf[idx] * (params.warp * 22.0);\n velOut[idx] = velIn[idx] + force * sim.dt;\n}\n\n@compute @workgroup_size(8, 8)\nfn cs_divergence(@builtin(global_invocation_id) gid : vec3u) {\n let s = simSize();\n let c = vec2i(gid.xy);\n if (c.x >= s.x || c.y >= s.y) { return; }\n let idx = c.y * s.x + c.x;\n let l = velAt(c + vec2i(-1, 0)).x;\n let r = velAt(c + vec2i(1, 0)).x;\n let b = velAt(c + vec2i(0, -1)).y;\n let t = velAt(c + vec2i(0, 1)).y;\n divBuf[idx] = 0.5 * ((r - l) + (t - b));\n // Seed the solve with a fraction of last frame's answer.\n pOut[idx] = pIn[idx] * PRESSURE_CARRY;\n}\n\n@compute @workgroup_size(8, 8)\nfn cs_jacobi(@builtin(global_invocation_id) gid : vec3u) {\n let s = simSize();\n let c = vec2i(gid.xy);\n if (c.x >= s.x || c.y >= s.y) { return; }\n let idx = c.y * s.x + c.x;\n let l = pressureAt(c + vec2i(-1, 0));\n let r = pressureAt(c + vec2i(1, 0));\n let b = pressureAt(c + vec2i(0, -1));\n let t = pressureAt(c + vec2i(0, 1));\n pOut[idx] = (l + r + b + t - divBuf[idx]) * 0.25;\n}\n\n@compute @workgroup_size(8, 8)\nfn cs_gradient(@builtin(global_invocation_id) gid : vec3u) {\n let s = simSize();\n let c = vec2i(gid.xy);\n if (c.x >= s.x || c.y >= s.y) { return; }\n let idx = c.y * s.x + c.x;\n let l = pressureAt(c + vec2i(-1, 0));\n let r = pressureAt(c + vec2i(1, 0));\n let b = pressureAt(c + vec2i(0, -1));\n let t = pressureAt(c + vec2i(0, 1));\n var v = velIn[idx] - 0.5 * vec2f(r - l, t - b);\n if (c.x == 0 || c.x == s.x - 1) { v.x = 0.0; }\n if (c.y == 0 || c.y == s.y - 1) { v.y = 0.0; }\n velOut[idx] = v;\n}\n\n@compute @workgroup_size(8, 8)\nfn cs_advect_dye(@builtin(global_invocation_id) gid : vec3u) {\n let s = dyeSize();\n let c = vec2i(gid.xy);\n if (c.x >= s.x || c.y >= s.y) { return; }\n let uv = (vec2f(c) + 0.5) / vec2f(s);\n let back = uv - sampleVel(uv) * sim.dt;\n // params.b is Fade: how quickly the ink thins back to water.\n dyeOut[c.y * s.x + c.x] = sampleDye(back) / (1.0 + params.b * sim.dt);\n}\n\n@compute @workgroup_size(8, 8)\nfn cs_splat_dye(@builtin(global_invocation_id) gid : vec3u) {\n let s = dyeSize();\n let c = vec2i(gid.xy);\n if (c.x >= s.x || c.y >= s.y) { return; }\n let uv = (vec2f(c) + 0.5) / vec2f(s);\n let radius = (0.0004 + params.a * params.a * 0.004) * sim.splatColor.z;\n let ink = st_palette(sim.splatColor.x) * sim.splatColor.y;\n let i = c.y * s.x + c.x;\n dyeRW[i] = dyeRW[i] + vec4f(ink, 0.0) * splatWeight(uv, radius);\n}\n\n// --- the picture -------------------------------------------------------------\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let d = sampleDye(uv);\n\n // The ink shades itself: treat its brightness as a height field and light\n // it from the upper left, which is what gives the billows a surface.\n let texel = 1.0 / vec2f(sim.dyeW, sim.dyeH);\n let lumL = st_luma(sampleDye(uv - vec2f(texel.x, 0.0)).rgb);\n let lumR = st_luma(sampleDye(uv + vec2f(texel.x, 0.0)).rgb);\n let lumB = st_luma(sampleDye(uv - vec2f(0.0, texel.y)).rgb);\n let lumT = st_luma(sampleDye(uv + vec2f(0.0, texel.y)).rgb);\n let n = normalize(vec3f(lumL - lumR, lumB - lumT, length(texel) * 1.6));\n let light = clamp(dot(n, normalize(vec3f(-0.4, 0.6, 0.8))) + 0.72, 0.72, 1.15);\n\n var col = d.rgb * light * params.intensity;\n\n // A four-tap halo around anything already bright — most of what reads as\n // premium in a fluid is the light leaking off the densest ink.\n let reach = texel * 11.0;\n var halo = vec3f(0.0);\n halo = halo + max(sampleDye(uv + vec2f(reach.x, 0.0)).rgb - 0.35, vec3f(0.0));\n halo = halo + max(sampleDye(uv - vec2f(reach.x, 0.0)).rgb - 0.35, vec3f(0.0));\n halo = halo + max(sampleDye(uv + vec2f(0.0, reach.y)).rgb - 0.35, vec3f(0.0));\n halo = halo + max(sampleDye(uv - vec2f(0.0, reach.y)).rgb - 0.35, vec3f(0.0));\n col = col + halo * 0.22 * params.intensity;\n\n // Deep water behind the ink, so the empty state has a colour of its own.\n let depth = 1.0 - length(vec2f((uv.x - 0.5) * 0.8, uv.y - 0.45));\n col = col + params.colorA.rgb * (0.05 + 0.1 * clamp(depth, 0.0, 1.0)) * (1.0 - st_luma(d.rgb) * 0.8);\n\n return st_post(st_tonemap(col), uv);\n}\n";
22
+ readonly fluid: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// fluid\n// ---------------------------------------------------------------------------\n\n// Fluid — ink in water that answers the pointer.\n//\n// A real Navier–Stokes solve, the incompressible half of it: Stam's stable\n// fluids (1999) as GPU Gems ch. 38 lays it out. Velocity is advected through\n// itself semi-Lagrangian — walk back along the flow and take what you find\n// there, unconditionally stable at any speed. Eight warm-started Jacobi\n// sweeps then solve for the pressure whose gradient cancels the divergence,\n// which is the step that makes it water instead of smear: push somewhere and\n// the field routes around itself. Vorticity confinement feeds back the small\n// whirls the coarse grid keeps averaging away. The dye rides on top at a\n// finer grid than the velocity that carries it — the solve stays cheap while\n// the ink stays sharp. Dragging the pointer lays force and ink down along\n// the stroke; leave it still and the simulation breathes on its own.\n\nstruct SimUniforms {\n // Seconds this step advances the simulation. 0 redraws without moving.\n dt : f32,\n aspect : f32,\n // Velocity, pressure, divergence and curl live on this grid…\n simW : f32,\n simH : f32,\n // …and the dye on this finer one.\n dyeW : f32,\n dyeH : f32,\n // One splat per step, laid along the segment the pointer covered this\n // frame so a fast stroke draws a line, never a trail of beads. From and to\n // coincide for the idle plumes, which collapses the segment to a point.\n splatPos : vec2f,\n splatFrom : vec2f,\n splatVel : vec2f,\n // x is a position on the palette ramp, y a brightness, z scales the splat\n // radius (idle plumes land wider than a pointer stroke), w > 0 arms it.\n splatColor : vec4f,\n}\n\n@group(0) @binding(1) var<uniform> sim : SimUniforms;\n\n// Every neighbour-reading pass ping-pongs: read the whole of one buffer,\n// write the whole of another. The splats ride inside the advection passes,\n// so nothing needs to work in place.\n@group(0) @binding(2) var<storage, read> velIn : array<vec2f>;\n@group(0) @binding(3) var<storage, read_write> velOut : array<vec2f>;\n@group(0) @binding(4) var<storage, read> pIn : array<f32>;\n@group(0) @binding(5) var<storage, read_write> pOut : array<f32>;\n@group(0) @binding(6) var<storage, read_write> divBuf : array<f32>;\n@group(0) @binding(7) var<storage, read_write> curlBuf : array<f32>;\n@group(0) @binding(8) var<storage, read> dyeIn : array<vec4f>;\n@group(0) @binding(9) var<storage, read_write> dyeOut : array<vec4f>;\n\n// Velocity survives on its own for a while; the 0.2 is tuned, not derived.\nconst VEL_DISSIPATION : f32 = 0.2;\n// How much of last frame's pressure seeds the next solve. Starting warm is\n// what lets eight iterations do the work of a hundred cold ones.\nconst PRESSURE_CARRY : f32 = 0.8;\n// A hard ceiling on field speed, in uv per second. Normal operation sits far\n// below it; it exists so a flung pointer at maximum Force cannot blow the\n// solve up.\nconst MAX_SPEED : f32 = 2.5;\n\nfn simSize() -> vec2i { return vec2i(i32(sim.simW), i32(sim.simH)); }\nfn dyeSize() -> vec2i { return vec2i(i32(sim.dyeW), i32(sim.dyeH)); }\n\nfn velAt(c : vec2i) -> vec2f {\n let s = simSize();\n let q = clamp(c, vec2i(0), s - 1);\n return velIn[q.y * s.x + q.x];\n}\n\nfn pressureAt(c : vec2i) -> f32 {\n let s = simSize();\n let q = clamp(c, vec2i(0), s - 1);\n return pIn[q.y * s.x + q.x];\n}\n\n// Bilinear sample of the velocity field at a uv position.\nfn sampleVel(uv : vec2f) -> vec2f {\n let s = vec2f(sim.simW, sim.simH);\n let px = uv * s - 0.5;\n let base = floor(px);\n let f = px - base;\n let b = vec2i(base);\n let v00 = velAt(b);\n let v10 = velAt(b + vec2i(1, 0));\n let v01 = velAt(b + vec2i(0, 1));\n let v11 = velAt(b + vec2i(1, 1));\n return mix(mix(v00, v10, f.x), mix(v01, v11, f.x), f.y);\n}\n\nfn dyeAt(c : vec2i) -> vec4f {\n let s = dyeSize();\n let q = clamp(c, vec2i(0), s - 1);\n return dyeIn[q.y * s.x + q.x];\n}\n\nfn sampleDye(uv : vec2f) -> vec4f {\n let s = vec2f(sim.dyeW, sim.dyeH);\n let px = uv * s - 0.5;\n let base = floor(px);\n let f = px - base;\n let b = vec2i(base);\n let d00 = dyeAt(b);\n let d10 = dyeAt(b + vec2i(1, 0));\n let d01 = dyeAt(b + vec2i(0, 1));\n let d11 = dyeAt(b + vec2i(1, 1));\n return mix(mix(d00, d10, f.x), mix(d01, d11, f.x), f.y);\n}\n\n// The gaussian a splat deposits, measured from the stroke segment rather\n// than a point, in aspect-corrected uv space.\nfn splatWeight(uv : vec2f, radius : f32) -> f32 {\n let scale = vec2f(sim.aspect, 1.0);\n let a = sim.splatFrom * scale;\n let d = sim.splatPos * scale - a;\n let p = uv * scale - a;\n let t = clamp(dot(p, d) / max(dot(d, d), 1e-7), 0.0, 1.0);\n let q = p - t * d;\n return exp(-dot(q, q) / max(radius, 0.00003));\n}\n\nfn clampSpeed(v : vec2f) -> vec2f {\n let speed = length(v);\n if (speed > MAX_SPEED) { return v * (MAX_SPEED / speed); }\n return v;\n}\n\n// --- passes, in the order they run each step ---------------------------------\n\n@compute @workgroup_size(8, 8)\nfn cs_advect_vel(@builtin(global_invocation_id) gid : vec3u) {\n let s = simSize();\n let c = vec2i(gid.xy);\n if (c.x >= s.x || c.y >= s.y) { return; }\n let uv = (vec2f(c) + 0.5) / vec2f(s);\n let back = uv - velIn[c.y * s.x + c.x] * sim.dt;\n var v = sampleVel(back) / (1.0 + VEL_DISSIPATION * sim.dt);\n // The force splat rides here rather than in a pass of its own — the field\n // is already in hand, so the splat costs nothing.\n if (sim.splatColor.w > 0.0) {\n let radius = (0.0004 + params.a * params.a * 0.004) * 4.0 * sim.splatColor.z;\n v = v + sim.splatVel * params.c * splatWeight(uv, radius);\n }\n v = clampSpeed(v);\n // Free-slip walls: the flow may run along an edge but never through it.\n if (c.x == 0 || c.x == s.x - 1) { v.x = 0.0; }\n if (c.y == 0 || c.y == s.y - 1) { v.y = 0.0; }\n velOut[c.y * s.x + c.x] = v;\n}\n\n@compute @workgroup_size(8, 8)\nfn cs_curl(@builtin(global_invocation_id) gid : vec3u) {\n let s = simSize();\n let c = vec2i(gid.xy);\n if (c.x >= s.x || c.y >= s.y) { return; }\n let l = velAt(c + vec2i(-1, 0)).y;\n let r = velAt(c + vec2i(1, 0)).y;\n let b = velAt(c + vec2i(0, -1)).x;\n let t = velAt(c + vec2i(0, 1)).x;\n curlBuf[c.y * s.x + c.x] = 0.5 * ((r - l) - (t - b));\n}\n\n@compute @workgroup_size(8, 8)\nfn cs_vorticity(@builtin(global_invocation_id) gid : vec3u) {\n let s = simSize();\n let c = vec2i(gid.xy);\n if (c.x >= s.x || c.y >= s.y) { return; }\n let idx = c.y * s.x + c.x;\n // Push each cell toward the nearest strong whirl, scaled by its spin —\n // this puts back the curl the coarse grid dissipates.\n let l = abs(curlBuf[clamp(c.x - 1, 0, s.x - 1) + c.y * s.x]);\n let r = abs(curlBuf[clamp(c.x + 1, 0, s.x - 1) + c.y * s.x]);\n let b = abs(curlBuf[c.x + clamp(c.y - 1, 0, s.y - 1) * s.x]);\n let t = abs(curlBuf[c.x + clamp(c.y + 1, 0, s.y - 1) * s.x]);\n var force = 0.5 * vec2f(t - b, l - r);\n force = force / (length(force) + 0.0001);\n force = force * curlBuf[idx] * (params.warp * 22.0);\n velOut[idx] = clampSpeed(velIn[idx] + force * sim.dt);\n}\n\n@compute @workgroup_size(8, 8)\nfn cs_divergence(@builtin(global_invocation_id) gid : vec3u) {\n let s = simSize();\n let c = vec2i(gid.xy);\n if (c.x >= s.x || c.y >= s.y) { return; }\n let idx = c.y * s.x + c.x;\n let l = velAt(c + vec2i(-1, 0)).x;\n let r = velAt(c + vec2i(1, 0)).x;\n let b = velAt(c + vec2i(0, -1)).y;\n let t = velAt(c + vec2i(0, 1)).y;\n divBuf[idx] = 0.5 * ((r - l) + (t - b));\n // Seed the solve with a fraction of last frame's answer.\n pOut[idx] = pIn[idx] * PRESSURE_CARRY;\n}\n\n@compute @workgroup_size(8, 8)\nfn cs_jacobi(@builtin(global_invocation_id) gid : vec3u) {\n let s = simSize();\n let c = vec2i(gid.xy);\n if (c.x >= s.x || c.y >= s.y) { return; }\n let idx = c.y * s.x + c.x;\n let l = pressureAt(c + vec2i(-1, 0));\n let r = pressureAt(c + vec2i(1, 0));\n let b = pressureAt(c + vec2i(0, -1));\n let t = pressureAt(c + vec2i(0, 1));\n pOut[idx] = (l + r + b + t - divBuf[idx]) * 0.25;\n}\n\n@compute @workgroup_size(8, 8)\nfn cs_gradient(@builtin(global_invocation_id) gid : vec3u) {\n let s = simSize();\n let c = vec2i(gid.xy);\n if (c.x >= s.x || c.y >= s.y) { return; }\n let idx = c.y * s.x + c.x;\n let l = pressureAt(c + vec2i(-1, 0));\n let r = pressureAt(c + vec2i(1, 0));\n let b = pressureAt(c + vec2i(0, -1));\n let t = pressureAt(c + vec2i(0, 1));\n var v = velIn[idx] - 0.5 * vec2f(r - l, t - b);\n if (c.x == 0 || c.x == s.x - 1) { v.x = 0.0; }\n if (c.y == 0 || c.y == s.y - 1) { v.y = 0.0; }\n velOut[idx] = v;\n}\n\n@compute @workgroup_size(8, 8)\nfn cs_advect_dye(@builtin(global_invocation_id) gid : vec3u) {\n let s = dyeSize();\n let c = vec2i(gid.xy);\n if (c.x >= s.x || c.y >= s.y) { return; }\n let uv = (vec2f(c) + 0.5) / vec2f(s);\n let back = uv - sampleVel(uv) * sim.dt;\n // params.b is Fade: how quickly the ink thins back to water.\n var d = sampleDye(back) / (1.0 + params.b * sim.dt);\n // The ink splat rides here for the same reason the force one rides in\n // cs_advect_vel: the buffer is already open.\n if (sim.splatColor.w > 0.0) {\n let radius = (0.0004 + params.a * params.a * 0.004) * sim.splatColor.z;\n let ink = st_palette(sim.splatColor.x) * sim.splatColor.y;\n d = d + vec4f(ink, 0.0) * splatWeight(uv, radius);\n }\n // A ceiling on density: a pointer resting on one spot must not pile ink\n // past what the tonemap can spend.\n dyeOut[c.y * s.x + c.x] = min(d, vec4f(6.0));\n}\n\n// --- the picture -------------------------------------------------------------\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let d = sampleDye(uv);\n\n // The ink shades itself: treat its brightness as a height field and light\n // it from the upper left, which is what gives the billows a surface.\n let texel = 1.0 / vec2f(sim.dyeW, sim.dyeH);\n let lumL = st_luma(sampleDye(uv - vec2f(texel.x, 0.0)).rgb);\n let lumR = st_luma(sampleDye(uv + vec2f(texel.x, 0.0)).rgb);\n let lumB = st_luma(sampleDye(uv - vec2f(0.0, texel.y)).rgb);\n let lumT = st_luma(sampleDye(uv + vec2f(0.0, texel.y)).rgb);\n let n = normalize(vec3f(lumL - lumR, lumB - lumT, length(texel) * 1.6));\n let light = clamp(dot(n, normalize(vec3f(-0.4, 0.6, 0.8))) + 0.72, 0.72, 1.15);\n\n var col = d.rgb * light * params.intensity;\n\n // A four-tap halo around anything already bright — most of what reads as\n // premium in a fluid is the light leaking off the densest ink.\n let reach = texel * 11.0;\n var halo = vec3f(0.0);\n halo = halo + max(sampleDye(uv + vec2f(reach.x, 0.0)).rgb - 0.35, vec3f(0.0));\n halo = halo + max(sampleDye(uv - vec2f(reach.x, 0.0)).rgb - 0.35, vec3f(0.0));\n halo = halo + max(sampleDye(uv + vec2f(0.0, reach.y)).rgb - 0.35, vec3f(0.0));\n halo = halo + max(sampleDye(uv - vec2f(0.0, reach.y)).rgb - 0.35, vec3f(0.0));\n col = col + halo * 0.22 * params.intensity;\n\n // Deep water behind the ink, so the empty state has a colour of its own.\n let depth = 1.0 - length(vec2f((uv.x - 0.5) * 0.8, uv.y - 0.45));\n col = col + params.colorA.rgb * (0.05 + 0.1 * clamp(depth, 0.0, 1.0)) * (1.0 - st_luma(d.rgb) * 0.8);\n\n return st_post(st_tonemap(col), uv);\n}\n";
20
23
  readonly glass: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// shadertown source prelude — appended to the shared prelude for shaders that\n// read a layer instead of inventing one.\n//\n// Every binding here is additive. A generative shader declares none of them and\n// keeps the exact layout it always had, and `Source` is its own uniform block\n// rather than four more fields on `Params`, so the ABI that every already\n// shipped shader was built against never moves.\n\n@group(0) @binding(1) var srcTexture : texture_2d<f32>;\n@group(0) @binding(2) var srcSampler : sampler;\n\nstruct Source {\n // Pixel size of the bound texture. Zero means \"assume it matches the canvas\".\n size : vec2f,\n // 0 stretches the source over the canvas, 1 covers the canvas and crops.\n fit : f32,\n _pad : f32,\n}\n\n@group(0) @binding(3) var<uniform> source : Source;\n\n// How much of the source each axis shows once it is fitted. Both components are\n// 1 or less: covering means cropping, never adding.\nfn st_srcScale() -> vec2f {\n if (source.fit < 0.5 || source.size.x < 1.0 || source.size.y < 1.0) {\n return vec2f(1.0);\n }\n let canvas = st_aspect();\n let src = source.size.x / source.size.y;\n if (src > canvas) {\n return vec2f(canvas / src, 1.0);\n }\n return vec2f(1.0, src / canvas);\n}\n\n/** Canvas uv to source uv. */\nfn st_srcUv(uv : vec2f) -> vec2f {\n return (uv - 0.5) * st_srcScale() + 0.5;\n}\n\n/**\n * Canvas uv to source uv, with room left around the edge.\n *\n * A shader that displaces its reads runs off the source at the border, and the\n * sampler answers by smearing its last row of texels across the gap. Reading a\n * slightly smaller crop gives the displacement somewhere to go, at the cost of\n * a few per cent of the picture.\n */\nfn st_srcUvInset(uv : vec2f, margin : f32) -> vec2f {\n return (uv - 0.5) * st_srcScale() * (1.0 - clamp(margin, 0.0, 0.4)) + 0.5;\n}\n\n/**\n * A canvas-space displacement, in source uv.\n *\n * Divides out the aspect so a push of 0.01 moves the same distance sideways as\n * it does up, whatever shape the canvas or the source is.\n */\nfn st_srcWarp(offset : vec2f) -> vec2f {\n return offset * st_srcScale() / vec2f(st_aspect(), 1.0);\n}\n\n/** Samples in source uv. Kernels that walk neighbours want this one. */\nfn st_srcAt(srcUv : vec2f) -> vec4f {\n return textureSample(srcTexture, srcSampler, srcUv);\n}\n\n/** Samples in canvas uv — the ordinary read. */\nfn st_src(uv : vec2f) -> vec4f {\n return st_srcAt(st_srcUv(uv));\n}\n\nfn st_srcRgb(uv : vec2f) -> vec3f {\n return st_src(uv).rgb;\n}\n\n/** One source texel, in source uv. */\nfn st_srcTexel() -> vec2f {\n return 1.0 / max(source.size, vec2f(1.0));\n}\n\n/**\n * 1 inside the source, falling to 0 just outside it.\n *\n * The sampler clamps, so a shader that pushes past the border smears the last\n * row of texels across the gap. Anything that displaces should fade instead.\n */\nfn st_srcInside(srcUv : vec2f) -> f32 {\n let edge = min(srcUv, vec2f(1.0) - srcUv);\n return smoothstep(0.0, 0.004, min(edge.x, edge.y));\n}\n\n/** Four-tap softening. Cheap enough to run per colour channel. */\nfn st_srcSoften(uv : vec2f, radius : f32) -> vec3f {\n let base = st_srcUv(uv);\n var sum = vec3f(0.0);\n for (var i = 0; i < 4; i = i + 1) {\n let angle = f32(i) * 1.570796 + 0.785398;\n sum = sum + st_srcAt(base + st_srcWarp(vec2f(cos(angle), sin(angle)) * radius)).rgb;\n }\n return sum * 0.25;\n}\n\n/**\n * Thirteen-tap spiral blur. `radius` is in canvas space.\n *\n * The golden angle keeps the taps from lining up into spokes, which is what a\n * uniform ring blur gives you at low tap counts.\n */\nfn st_srcBlur(uv : vec2f, radius : f32) -> vec3f {\n let base = st_srcUv(uv);\n var sum = st_srcAt(base).rgb;\n for (var i = 0; i < 12; i = i + 1) {\n let fi = f32(i);\n let angle = fi * 2.399963;\n let r = radius * sqrt((fi + 0.5) / 12.0);\n sum = sum + st_srcAt(base + st_srcWarp(vec2f(cos(angle), sin(angle)) * r)).rgb;\n }\n return sum / 13.0;\n}\n\n// ---------------------------------------------------------------------------\n// glass\n// ---------------------------------------------------------------------------\n\n// Glass — a refracting, frosted sheet over the layer behind it.\n\n// Two slow counter-drifting fields. Mixing smooth hills with sharp ridges is\n// what reads as a thick pane; either one alone reads as water.\nfn glass_height(p : vec2f, t : f32) -> f32 {\n let hills = st_fbm2(p + vec2f(t * 0.06, -t * 0.04), 3);\n let ridges = st_ridge2(p * 0.7 - vec2f(t * 0.03, t * 0.05), 2);\n return mix(hills, ridges, 0.55);\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let t = st_time();\n let p = st_centered(uv) * params.scale * 3.0;\n\n // Slope of the height field stands in for the surface normal. Forward\n // differences, not central: three evaluations instead of five, and the half\n // texel of bias is invisible next to the refraction it feeds.\n let e = 0.006;\n let h = glass_height(p, t);\n let hx = glass_height(p + vec2f(e, 0.0), t);\n let hy = glass_height(p + vec2f(0.0, e), t);\n let slope = vec2f(hx - h, hy - h) / e;\n\n let bend = st_srcWarp(-slope * params.warp * 0.012);\n let base = st_srcUv(uv) + bend;\n\n // Frost is a small spiral of taps around the bent coordinate. The height\n // rotates the spiral, so neighbouring pixels blur along different axes and\n // the result grains up instead of banding.\n let taps = 6;\n let frost = params.a * 0.03;\n var blurred = st_srcAt(base).rgb;\n for (var i = 0; i < taps; i = i + 1) {\n let fi = f32(i);\n let angle = fi * 2.399963 + h * 5.0;\n let radius = frost * sqrt((fi + 0.5) / f32(taps));\n blurred = blurred + st_srcAt(base + st_srcWarp(vec2f(cos(angle), sin(angle)) * radius)).rgb;\n }\n blurred = blurred / f32(taps + 1);\n\n // Dispersion: the red and blue ends bend by different amounts, so steep\n // slopes fringe the way real glass does.\n let spread = st_srcWarp(-slope * params.b * 0.004);\n var col = vec3f(\n st_srcAt(base + spread).r,\n blurred.g,\n st_srcAt(base - spread).b,\n );\n col = mix(blurred, col, clamp(params.b, 0.0, 1.0));\n\n // Steep ground catches the light. Squaring keeps the rims thin.\n let steep = clamp(length(slope) * 0.35, 0.0, 1.0);\n let rim = steep * steep * params.c;\n col = col + mix(vec3f(1.0), st_palette(h), 0.5) * rim * 0.6;\n\n // A pane is rarely colourless. Tint pulls the whole sheet toward the palette.\n col = mix(col, col * st_palette(h * 0.7 + 0.15) * 1.6, clamp(params.d, 0.0, 1.0));\n\n return st_post(col * params.intensity, uv);\n}\n";
21
24
  readonly gravity: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// gravity\n// ---------------------------------------------------------------------------\n\n// Gravity — a starfield lensed by a mass that rides the pointer.\n//\n// The lens is the physics, simplified once: a point mass deflects light by\n// the square of its Einstein radius over the impact distance, so the sample\n// position is the pixel pushed away from the mass by exactly that much. That\n// one line produces everything the picture is known for — stars smeared into\n// arcs, the ring at the Einstein radius, and the doubled images inside it —\n// with no machinery beyond remapping where the background is read.\n\n// A layer of stars, in whatever space the lens hands us.\nfn starLayer(q : vec2f, cells : f32, t : f32, density : f32) -> f32 {\n let g = q * cells;\n let id = floor(g);\n let rnd = st_hash22(id);\n if (rnd.x > density) { return 0.0; }\n let centre = id + 0.3 + rnd * 0.4;\n let d = length(g - centre);\n let twinkle = 0.7 + 0.3 * sin(t * (1.0 + rnd.y * 3.0) + rnd.x * TAU);\n return smoothstep(0.08, 0.0, d) * twinkle * (0.4 + rnd.y);\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let t = st_time();\n let p = st_centered(uv) / max(params.scale, 0.2);\n\n let mass = vec2f((params.pointer.x - 0.5) * st_aspect(), params.pointer.y - 0.5)\n / max(params.scale, 0.2);\n\n // The bend. Clamping the impact distance keeps the singularity from\n // spraying one pixel's noise across the middle of the frame.\n let thetaE = max(params.a, 0.02) * 0.18;\n let r = p - mass;\n let d = max(length(r), 0.012);\n let bend = r / d * (thetaE * thetaE / d);\n let q = p - bend + vec2f(t * 0.008, 0.0);\n\n // The event horizon: inside it, nothing arrives. Smooth, and scaled well\n // under the Einstein radius so the ring stays lit.\n let horizon = thetaE * 0.35;\n let shadow = smoothstep(horizon, horizon * 1.3, d);\n\n // Three star layers at different scales, all read through the same lens,\n // plus a faint nebula so the arcs have something soft to smear.\n var sky = vec3f(0.0);\n sky = sky + params.colorC.rgb * starLayer(q, 7.0, t, params.d * 0.4);\n sky = sky + params.colorC.rgb * starLayer(q + 31.7, 13.0, t * 1.3, params.d * 0.5) * 0.7;\n sky = sky + params.colorB.rgb * starLayer(q + 77.3, 23.0, t * 0.7, params.d * 0.6) * 0.45;\n sky = sky + st_palette(st_fbm2(q * 1.4, 3)) * 0.08;\n\n // The spacetime grid, read through the same remap so it visibly funnels\n // into the mass. Soft bands rather than fwidth lines: the derivative\n // explodes at the horizon and would ring alias there.\n let grid = pow(0.5 + 0.5 * sin(q.x * 28.0), 20.0) + pow(0.5 + 0.5 * sin(q.y * 28.0), 20.0);\n sky = sky + params.colorB.rgb * grid * params.c * 0.4;\n\n var col = params.colorA.rgb * 0.05 + sky * shadow;\n\n // The photon ring: light that grazed the horizon, brightest exactly at the\n // Einstein radius.\n let ring = exp(-abs(d - thetaE) * 60.0 / max(thetaE, 0.05));\n col = col + st_palette(0.75) * ring * params.b;\n\n // A thin hot rim on the shadow's edge itself.\n col = col + params.colorC.rgb * exp(-abs(d - horizon) * 160.0) * params.b * 0.8;\n\n return st_post(st_tonemap(col * params.intensity), uv);\n}\n";
22
25
  readonly halftone: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// halftone\n// ---------------------------------------------------------------------------\n\n// Halftone — a duotone image reproduced as a rotated dot screen.\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let t = st_time();\n let p = st_centered(uv) * params.scale;\n\n // The continuous-tone image being screened. Centred on 0.5 so the screen\n // actually has a range of dot sizes to work with instead of saturating.\n let field = st_fbm2(p * 1.8 + vec2f(t * 0.12, t * 0.07), 4);\n let sweep = clamp((field - 0.5) * 1.6 + (p.x + p.y) * 0.35 + 0.5, 0.0, 1.0);\n\n // Dot screen, rotated the way a real halftone plate would be.\n let angle = params.c * PI;\n let grid = st_rot(angle) * uv * params.resolution / max(params.a, 2.0);\n let cellPos = fract(grid) - 0.5;\n\n let ink = pow(sweep, 1.0 / max(params.d, 0.05));\n // 0.62 lets neighbouring dots touch at full ink while leaving the corners\n // open — past ~0.71 the screen fills solid and stops reading as halftone.\n let radius = sqrt(ink) * 0.62;\n let coverage = 1.0 - smoothstep(radius - params.b, radius + params.b, length(cellPos));\n\n let paper = params.colorA.rgb;\n let inkColor = mix(params.colorB.rgb, params.colorC.rgb, sweep);\n\n let col = mix(paper, inkColor, coverage * params.intensity);\n return st_post(col, uv);\n}\n";
@@ -28,10 +31,11 @@ export declare const shaderSources: {
28
31
  readonly lens: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// shadertown source prelude — appended to the shared prelude for shaders that\n// read a layer instead of inventing one.\n//\n// Every binding here is additive. A generative shader declares none of them and\n// keeps the exact layout it always had, and `Source` is its own uniform block\n// rather than four more fields on `Params`, so the ABI that every already\n// shipped shader was built against never moves.\n\n@group(0) @binding(1) var srcTexture : texture_2d<f32>;\n@group(0) @binding(2) var srcSampler : sampler;\n\nstruct Source {\n // Pixel size of the bound texture. Zero means \"assume it matches the canvas\".\n size : vec2f,\n // 0 stretches the source over the canvas, 1 covers the canvas and crops.\n fit : f32,\n _pad : f32,\n}\n\n@group(0) @binding(3) var<uniform> source : Source;\n\n// How much of the source each axis shows once it is fitted. Both components are\n// 1 or less: covering means cropping, never adding.\nfn st_srcScale() -> vec2f {\n if (source.fit < 0.5 || source.size.x < 1.0 || source.size.y < 1.0) {\n return vec2f(1.0);\n }\n let canvas = st_aspect();\n let src = source.size.x / source.size.y;\n if (src > canvas) {\n return vec2f(canvas / src, 1.0);\n }\n return vec2f(1.0, src / canvas);\n}\n\n/** Canvas uv to source uv. */\nfn st_srcUv(uv : vec2f) -> vec2f {\n return (uv - 0.5) * st_srcScale() + 0.5;\n}\n\n/**\n * Canvas uv to source uv, with room left around the edge.\n *\n * A shader that displaces its reads runs off the source at the border, and the\n * sampler answers by smearing its last row of texels across the gap. Reading a\n * slightly smaller crop gives the displacement somewhere to go, at the cost of\n * a few per cent of the picture.\n */\nfn st_srcUvInset(uv : vec2f, margin : f32) -> vec2f {\n return (uv - 0.5) * st_srcScale() * (1.0 - clamp(margin, 0.0, 0.4)) + 0.5;\n}\n\n/**\n * A canvas-space displacement, in source uv.\n *\n * Divides out the aspect so a push of 0.01 moves the same distance sideways as\n * it does up, whatever shape the canvas or the source is.\n */\nfn st_srcWarp(offset : vec2f) -> vec2f {\n return offset * st_srcScale() / vec2f(st_aspect(), 1.0);\n}\n\n/** Samples in source uv. Kernels that walk neighbours want this one. */\nfn st_srcAt(srcUv : vec2f) -> vec4f {\n return textureSample(srcTexture, srcSampler, srcUv);\n}\n\n/** Samples in canvas uv — the ordinary read. */\nfn st_src(uv : vec2f) -> vec4f {\n return st_srcAt(st_srcUv(uv));\n}\n\nfn st_srcRgb(uv : vec2f) -> vec3f {\n return st_src(uv).rgb;\n}\n\n/** One source texel, in source uv. */\nfn st_srcTexel() -> vec2f {\n return 1.0 / max(source.size, vec2f(1.0));\n}\n\n/**\n * 1 inside the source, falling to 0 just outside it.\n *\n * The sampler clamps, so a shader that pushes past the border smears the last\n * row of texels across the gap. Anything that displaces should fade instead.\n */\nfn st_srcInside(srcUv : vec2f) -> f32 {\n let edge = min(srcUv, vec2f(1.0) - srcUv);\n return smoothstep(0.0, 0.004, min(edge.x, edge.y));\n}\n\n/** Four-tap softening. Cheap enough to run per colour channel. */\nfn st_srcSoften(uv : vec2f, radius : f32) -> vec3f {\n let base = st_srcUv(uv);\n var sum = vec3f(0.0);\n for (var i = 0; i < 4; i = i + 1) {\n let angle = f32(i) * 1.570796 + 0.785398;\n sum = sum + st_srcAt(base + st_srcWarp(vec2f(cos(angle), sin(angle)) * radius)).rgb;\n }\n return sum * 0.25;\n}\n\n/**\n * Thirteen-tap spiral blur. `radius` is in canvas space.\n *\n * The golden angle keeps the taps from lining up into spokes, which is what a\n * uniform ring blur gives you at low tap counts.\n */\nfn st_srcBlur(uv : vec2f, radius : f32) -> vec3f {\n let base = st_srcUv(uv);\n var sum = st_srcAt(base).rgb;\n for (var i = 0; i < 12; i = i + 1) {\n let fi = f32(i);\n let angle = fi * 2.399963;\n let r = radius * sqrt((fi + 0.5) / 12.0);\n sum = sum + st_srcAt(base + st_srcWarp(vec2f(cos(angle), sin(angle)) * r)).rgb;\n }\n return sum / 13.0;\n}\n\n// ---------------------------------------------------------------------------\n// lens\n// ---------------------------------------------------------------------------\n\n// Lens — barrel distortion, colour fringing and soft corners.\n\n// Brown-Conrady, truncated after the second radial term. That is enough to\n// bend a straight line convincingly and cheap enough to run three times.\nfn lens_distort(p : vec2f, k : f32) -> vec2f {\n let r2 = dot(p, p);\n return p * (1.0 + k * r2 + k * k * r2 * r2 * 0.35);\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let aspect = st_aspect();\n let k = params.warp * 0.6;\n\n // Barrel distortion throws the corners outside the frame, which would leave\n // a black border around every preview. Measure how far the corner moves and\n // pull the whole field back in by that much, so bending the picture does not\n // also shrink it. Pincushion pulls inwards on its own and needs no help,\n // hence the clamp at 1.\n let corner = vec2f(aspect, 1.0) * 0.5;\n let stretch = length(lens_distort(corner, k)) / max(length(corner), 0.0001);\n let fill = 1.0 / max(stretch, 1.0);\n\n // Centred and aspect-corrected, so the distortion is radial about the frame\n // rather than about the corner of a stretched uv square.\n let p = (uv - 0.5) * vec2f(aspect, 1.0) * fill / max(params.c, 0.05);\n\n // Each channel gets its own constant. That is what glass does, and it is why\n // the fringing is invisible in the middle and worst in the corners.\n let spread = params.b * 0.06;\n let toUv = vec2f(1.0 / aspect, 1.0);\n let centre = lens_distort(p, k) * toUv + 0.5;\n let red = lens_distort(p, k * (1.0 - spread)) * toUv + 0.5;\n let blue = lens_distort(p, k * (1.0 + spread)) * toUv + 0.5;\n\n // Corners sit off the focal plane, so they lose sharpness first.\n let falloff = clamp(length(p) * 1.5, 0.0, 1.0);\n let soft = params.a * falloff * falloff * 0.02;\n\n var col = vec3f(\n st_srcSoften(red, soft).r,\n st_srcSoften(centre, soft).g,\n st_srcSoften(blue, soft).b,\n );\n\n // Past the frame the sampler repeats its last row. Fade to black instead.\n col = col * st_srcInside(st_srcUv(centre));\n\n // Barrel distortion pulls light towards the middle, so the corners dim.\n col = col * (1.0 - params.d * falloff * falloff);\n\n return st_post(col * params.intensity, uv);\n}\n";
29
32
  readonly "liquid-metal": "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// liquid-metal\n// ---------------------------------------------------------------------------\n\n// Liquid metal — a warped height field lit like polished chrome.\n// The look lives in the banded environment reflection, not in the noise: keep\n// the field low-frequency, and let the bands do the work.\n\nfn height(p : vec2f, t : f32) -> f32 {\n var q = p;\n q = q + vec2f(st_fbm2(q * 0.9 + vec2f(t * 0.18, 0.0), 3),\n st_fbm2(q * 0.9 + vec2f(0.0, t * 0.15), 3)) * params.warp * 1.6;\n return st_fbm2(q * 1.1 + vec2f(0.0, t * 0.08), 3);\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let p = st_centered(uv) * params.scale * 2.2;\n let t = st_time();\n\n // Fixed sampling offset in field units: a resolution-derived epsilon makes\n // the surface look completely different in a card and in a hero.\n let eps = 0.012;\n let h = height(p, t);\n let hx = height(p + vec2f(eps, 0.0), t);\n let hy = height(p + vec2f(0.0, eps), t);\n\n let slope = params.a * 0.55;\n let n = normalize(vec3f((h - hx) / eps * slope, (h - hy) / eps * slope, 1.0));\n\n let lightDir = normalize(vec3f(cos(t * 0.35) * 0.7, sin(t * 0.35) * 0.7, 0.9));\n let viewDir = vec3f(0.0, 0.0, 1.0);\n let halfway = normalize(lightDir + viewDir);\n\n let diffuse = clamp(dot(n, lightDir), 0.0, 1.0);\n let spec = pow(clamp(dot(n, halfway), 0.0, 1.0), 16.0 + params.b * 180.0);\n let fresnel = pow(1.0 - clamp(dot(n, viewDir), 0.0, 1.0), 4.0);\n\n // Wide, smooth reflection bands. Reflecting the view around the normal and\n // reading a ramp off it is what separates chrome from rough plastic.\n let reflected = reflect(-viewDir, n);\n let band = reflected.y * 1.6 + reflected.x * 0.5 + t * 0.05;\n let env = st_palette(clamp(band * 0.5 + 0.5, 0.0, 1.0));\n\n // A second, tighter band set gives the hard highlight streaks of polish.\n let streak = pow(abs(sin(band * PI * 1.5)), 8.0 + params.b * 40.0);\n\n var col = env * (0.35 + 0.75 * diffuse) * params.c;\n col = col + st_palette(1.0) * streak * 0.6 * params.c;\n col = col + vec3f(1.0) * spec * params.intensity * 1.4;\n col = col + st_palette(0.9) * fresnel * params.d;\n\n return st_post(st_tonemap(col), uv);\n}\n";
30
33
  readonly lumen: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// lumen\n// ---------------------------------------------------------------------------\n\n// Lumen — a lattice of dots, each one a lamp and a wall at the same time.\n//\n// The lattice is what makes this affordable. Every dot is the same size, so the\n// nearest one to any point is simply the nearest lattice node, and the distance\n// to the whole field collapses to `length(p - round(p)) - radius`. That is an\n// exact distance, not an estimate, so a ray crosses a dozen cells in a handful\n// of steps and the shadows it casts run the width of the frame.\n\nfn lumEmission(id : vec2f, t : f32, lit : f32) -> vec3f {\n let seed = st_hash21(id);\n // A wave crossing the lattice decides which dots are on. Each dot carries its\n // own phase, so the front arrives as a scatter rather than as a straight line.\n let wave = 0.5 + 0.5 * sin(dot(id, vec2f(0.31, 0.19)) - t * 1.6 + seed * TAU);\n let on = smoothstep(1.0 - lit, 1.0 - lit + 0.28, wave);\n return st_palette(fract(seed * 0.7 + t * 0.04)) * on * on * 2.6;\n}\n\n// Jimenez's interleaved gradient noise. Turning every pixel's fan by the same\n// angle bands the penumbrae into spokes, and the obvious fixes trade that for\n// something worse: white noise clumps, and a low-discrepancy sequence lays the\n// offset down in diagonal stripes. This one was built for exactly this job.\nfn lumIgn(pixel : vec2f) -> f32 {\n return fract(52.9829189 * fract(dot(pixel, vec2f(0.06711056, 0.00583715))));\n}\n\n// One ray through the field. It stops at the first dot it touches and brings\n// back that dot's light — which is nothing at all if the dot is dark.\nfn lumTrace(origin : vec2f, dir : vec2f, start : f32, t : f32, radius : f32, lit : f32, reach : f32) -> vec3f {\n var travel = start;\n for (var i = 0; i < 30; i = i + 1) {\n let p = origin + dir * travel;\n let id = round(p);\n let d = length(p - id) - radius;\n if (d < 0.006) {\n let fall = travel / max(reach, 0.05);\n return lumEmission(id, t, lit) / (1.0 + fall * fall);\n }\n travel = travel + max(d, 0.02);\n if (travel > 16.0) { break; }\n }\n return vec3f(0.0);\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let t = st_time();\n // Lattice units: one unit is one cell, which is what lets `round` find the\n // nearest dot.\n let p = st_centered(uv) * params.scale * 11.0;\n\n let radius = clamp(params.b, 0.02, 0.45);\n let lit = clamp(params.c, 0.0, 1.0);\n let rays = i32(clamp(params.a, 3.0, 40.0));\n\n let id = round(p);\n let d = length(p - id) - radius;\n\n let cellId = floor(uv * params.resolution);\n let dither = lumIgn(cellId);\n let turn = dither * TAU;\n\n // Leave from just outside the dot the pixel is standing on, so a dot is lit\n // by its neighbours instead of by itself, and stagger the start so the fans\n // do not all clear the same dot at the same distance.\n let start = max(-d, 0.0) + 0.015 + dither * 0.02;\n\n var sum = vec3f(0.0);\n for (var i = 0; i < rays; i = i + 1) {\n // A third of a wedge of slop on top of the ordered offset. Full white\n // noise per ray throws the stratification away and the frame goes\n // sandy; this much only breaks the pattern's grip on the lattice.\n let slop = (st_hash21(cellId + f32(i) * 17.31) - 0.5) * 0.35;\n let angle = turn + TAU * (f32(i) + 0.5 + slop) / f32(rays);\n sum = sum + lumTrace(p, vec2f(cos(angle), sin(angle)), start, t, radius, lit, params.d);\n }\n let gathered = sum / f32(rays) * params.intensity;\n\n // The air carries the radiance straight; a dot shows its own light plus a\n // little of what reaches it, over a grey body so the dark ones stay visible.\n let body = params.colorA.rgb * 0.55 + lumEmission(id, t, lit) + gathered * 0.4;\n // One pixel wide whatever the lattice is scaled to. The dots are the only\n // hard edge here, so a fixed width would show as the density changed.\n let dotEdge = max(fwidth(d), 0.00001);\n let mask = 1.0 - smoothstep(-dotEdge, dotEdge, d);\n\n var col = mix(gathered, body, mask);\n\n return st_post(st_tonemap(col), uv);\n}\n";
34
+ readonly maelstrom: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// shadertown graph prelude — prepended (after the shared prelude) to every\n// graph shader at build time.\n//\n// A graph shader is not one pass but a whole render graph in a single module:\n// a displacement field, a normal pass, an instanced particle draw, an\n// Unreal-style bloom chain, and a present pass, each its own entry point. The\n// runtime (src/graph.ts) drives them with `draw()` and explicit entry names;\n// `effect()` picks the first vertex and fragment entries, which is why\n// `vs_fullscreen` and `fs_main` (the present pass) are declared first.\n//\n// The variant file below this prelude supplies exactly one function:\n//\n// fn st_field(p : vec2f, t : f32) -> vec4f\n//\n// `p` is the world-space position of a grid cell (±worldSize/2), `t` is\n// `st_time()`. The return value is displacement xyz in field units (scaled to\n// world units by `gu.world.y`) plus an aux channel in w — the shader's own\n// meaning of \"foam\": whatever the accent colour should land on.\n//\n// The pass structure, bloom chain and camera are ported from the vgpu\n// `fft-ocean` (\"Particles ocean\") example and keep its behaviour: additive\n// particles into an HDR scene, five blurred octaves recombined, linear to\n// sRGB on the way out.\n\nstruct GraphUniforms {\n view : mat4x4f,\n projection : mat4x4f,\n // canvas width, canvas height, point scale, grid resolution\n viewport : vec4f,\n // world size, height scale, point size, texel spacing (world / grid)\n world : vec4f,\n // fade near, fade far, fade power, unused\n fade : vec4f,\n // eye y, eye z, view pitch (radians), projection focal (1 / tan(fov / 2))\n camera : vec4f,\n}\n\nstruct BlurUniforms {\n direction : vec2f,\n invSize : vec2f,\n coeffs0 : vec4f,\n coeffs1 : vec4f,\n coeffs2 : vec4f,\n coeffs3 : vec4f,\n coeffs4 : vec4f,\n coeffs5 : vec4f,\n}\n\nstruct BloomUniforms {\n threshold : f32,\n smoothWidth : f32,\n strength : f32,\n radius : f32,\n factors0 : vec4f,\n factors1 : vec4f,\n}\n\n// Bindings are declared once for the whole module; vgpu builds layouts per\n// entry point, so each pass only owes the bindings it actually reads.\n@group(0) @binding(1) var<uniform> gu : GraphUniforms;\n@group(0) @binding(2) var<uniform> blurU : BlurUniforms;\n@group(0) @binding(3) var<uniform> bloomU : BloomUniforms;\n@group(0) @binding(4) var t_field : texture_2d<f32>;\n@group(0) @binding(5) var t_normal : texture_2d<f32>;\n@group(0) @binding(6) var t_scene : texture_2d<f32>;\n@group(0) @binding(7) var t_input : texture_2d<f32>;\n@group(0) @binding(8) var t_blur1 : texture_2d<f32>;\n@group(0) @binding(9) var t_blur2 : texture_2d<f32>;\n@group(0) @binding(10) var t_blur3 : texture_2d<f32>;\n@group(0) @binding(11) var t_blur4 : texture_2d<f32>;\n@group(0) @binding(12) var t_blur5 : texture_2d<f32>;\n@group(0) @binding(13) var t_bloom : texture_2d<f32>;\n@group(0) @binding(14) var linearSampler : sampler;\n\n// --- the pointer, in the world --------------------------------------------\n// The camera is fixed, so the pointer can be unprojected analytically: undo\n// the projection's focal scaling, undo the view's pitch, and the uv the\n// runtime delivers becomes a ray from the eye into the scene. Variants\n// intersect it with whatever surface they draw on.\n\nstruct PointerRay {\n origin : vec3f,\n dir : vec3f,\n}\n\n// The ray through any point of the frame, uv in 0..1 from the top left. Taking\n// the position rather than reading `params.pointer` lets a variant aim at a\n// place in the frame it picks itself.\nfn st_screenRay(uv : vec2f) -> PointerRay {\n let aspect = gu.viewport.x / max(gu.viewport.y, 1.0);\n let f = gu.camera.w;\n let ndc = vec2f(uv.x * 2.0 - 1.0, (1.0 - uv.y) * 2.0 - 1.0);\n let viewDir = normalize(vec3f(ndc.x * aspect / f, ndc.y / f, -1.0));\n // The view rotates the world by `camera.z` about x; the ray rotates back.\n let c = cos(gu.camera.z);\n let s = sin(gu.camera.z);\n var ray : PointerRay;\n ray.origin = vec3f(0.0, gu.camera.x, gu.camera.y);\n ray.dir = vec3f(viewDir.x, c * viewDir.y + s * viewDir.z, -s * viewDir.y + c * viewDir.z);\n return ray;\n}\n\nfn st_pointerRay() -> PointerRay {\n return st_screenRay(params.pointer);\n}\n\n// Where a point of the frame lands on the y = 0 water plane, in world xz.\nfn st_screenOnPlane(uv : vec2f) -> vec2f {\n let ray = st_screenRay(uv);\n let t = -ray.origin.y / min(ray.dir.y, -0.02);\n let hit = ray.origin + ray.dir * t;\n let half = gu.world.x * 0.5;\n return clamp(hit.xz, vec2f(-half), vec2f(half));\n}\n\n// Where the pointer lands on the y = 0 water plane, in world xz.\nfn st_pointerOnPlane() -> vec2f {\n return st_screenOnPlane(params.pointer);\n}\n\n// Where the pointer lands on the vertical plane z = depth, in world xy.\nfn st_pointerOnDepth(depth : f32) -> vec2f {\n let ray = st_pointerRay();\n let t = (depth - ray.origin.z) / min(ray.dir.z, -0.02);\n let hit = ray.origin + ray.dir * t;\n let half = gu.world.x * 0.5;\n return vec2f(clamp(hit.x, -half, half), clamp(hit.y, -20.0, 120.0));\n}\n\nstruct FullscreenOut {\n @builtin(position) position : vec4f,\n @location(0) uv : vec2f,\n}\n\n// Same triangle and uv orientation vgpu generates for a plain effect, so the\n// fullscreen fragment entries below read uv exactly like every other shader.\n@vertex fn vs_fullscreen(@builtin(vertex_index) vi : u32) -> FullscreenOut {\n var pos = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));\n var uv = array<vec2f, 3>(vec2f(0.0, 1.0), vec2f(2.0, 1.0), vec2f(0.0, -1.0));\n var out : FullscreenOut;\n out.position = vec4f(pos[vi], 0.0, 1.0);\n out.uv = uv[vi];\n return out;\n}\n\n// --- present ---------------------------------------------------------------\n// First fragment entry on purpose: the runtime's `effect()` for this shader\n// resolves to it, and the Stage drives it like any other shader's fragment.\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let scene = textureSample(t_scene, linearSampler, uv);\n let bloom = textureSample(t_bloom, linearSampler, uv);\n let linear = max(scene.rgb + bloom.rgb, vec3f(0.0));\n let lt = linear * 12.92;\n let gt = 1.055 * pow(linear, vec3f(0.41666)) - vec3f(0.055);\n let srgb = select(gt, lt, linear <= vec3f(0.0031308));\n return st_post(srgb, uv);\n}\n\n// --- displacement field ------------------------------------------------------\n\n@fragment fn fs_field(@location(0) uv : vec2f) -> @location(0) vec4f {\n let p = (uv - vec2f(0.5)) * gu.world.x;\n return st_field(p, st_time());\n}\n\n// --- normals ----------------------------------------------------------------\n\nfn st_loadField(coord : vec2i) -> vec4f {\n let n = i32(gu.viewport.w);\n let c = clamp(coord, vec2i(0), vec2i(n - 1, n - 1));\n return textureLoad(t_field, c, 0);\n}\n\n@fragment fn fs_normals(@builtin(position) position : vec4f) -> @location(0) vec4f {\n let coord = vec2i(position.xy - vec2f(0.5));\n let dx = gu.world.w;\n let s = gu.world.y;\n let r = st_loadField(coord + vec2i(1, 0));\n let l = st_loadField(coord - vec2i(1, 0));\n let t = st_loadField(coord + vec2i(0, 1));\n let b = st_loadField(coord - vec2i(0, 1));\n let dhdx = (r.y - l.y) * s / (2.0 * dx);\n let dhdz = (t.y - b.y) * s / (2.0 * dx);\n let normal = normalize(vec3f(-dhdx, 1.0, -dhdz));\n return vec4f(normal, st_loadField(coord).w);\n}\n\n// --- particles ----------------------------------------------------------------\n\nstruct ParticleOut {\n @builtin(position) position : vec4f,\n @location(0) pointCoord : vec2f,\n @location(1) aux : f32,\n @location(2) normal : vec3f,\n @location(3) viewDir : vec3f,\n @location(4) height : f32,\n @location(5) fade : f32,\n}\n\nfn st_quadCorner(vertexIndex : u32) -> vec2f {\n var corners = array<u32, 6>(0u, 1u, 2u, 2u, 1u, 3u);\n let cornerIndex = corners[vertexIndex % 6u];\n switch (cornerIndex) {\n case 0u: { return vec2f(-1.0, -1.0); }\n case 1u: { return vec2f( 1.0, -1.0); }\n case 2u: { return vec2f(-1.0, 1.0); }\n default: { return vec2f( 1.0, 1.0); }\n }\n}\n\n@vertex fn vs_particles(\n @builtin(vertex_index) vertexIndex : u32,\n @builtin(instance_index) instanceIndex : u32,\n) -> ParticleOut {\n let resolution = max(1u, u32(gu.viewport.w));\n let i = instanceIndex % resolution;\n let j = instanceIndex / resolution;\n let particleRef = vec2f(f32(i), f32(j)) / f32(resolution);\n let texCoord = vec2u(i, j);\n\n let raw = textureLoad(t_field, texCoord, 0);\n let disp = raw.xyz * gu.world.y;\n let nf = textureLoad(t_normal, texCoord, 0);\n\n let halfWorld = gu.world.x * 0.5;\n let base = vec3f(\n particleRef.x * gu.world.x - halfWorld,\n 0.0,\n particleRef.y * gu.world.x - halfWorld,\n );\n let pos = base + disp;\n\n let mv = gu.view * vec4f(pos, 1.0);\n let viewDir = -mv.xyz;\n let dist = -mv.z;\n let f = 1.0 - smoothstep(gu.fade.x, gu.fade.y, dist);\n let fade = pow(clamp(f, 0.0, 1.0), gu.fade.z);\n\n let projected = gu.projection * mv;\n let ndc = projected.xy / projected.w;\n\n let corner = st_quadCorner(vertexIndex);\n let pointSizePx = 2.0 * gu.world.z * gu.viewport.z;\n let clipOffset = corner * (pointSizePx / gu.viewport.xy) * projected.w;\n let clip = vec4f(ndc * projected.w + clipOffset, projected.z, projected.w);\n\n var out : ParticleOut;\n out.position = clip;\n out.pointCoord = corner * 0.5 + vec2f(0.5);\n out.aux = nf.w;\n out.normal = nf.xyz;\n out.viewDir = viewDir;\n out.height = raw.y;\n out.fade = fade;\n return out;\n}\n\n@fragment fn fs_particles(in : ParticleOut) -> @location(0) vec4f {\n let cc = in.pointCoord - vec2f(0.5);\n if (dot(cc, cc) > 0.25) { discard; }\n\n let n = normalize(in.normal);\n let v = normalize(in.viewDir);\n let fresnel = pow(1.0 - clamp(dot(n, v), 0.0, 1.0), 5.0);\n\n let aux = clamp(in.aux, 0.0, 1.0);\n let crest = smoothstep(-0.5, 1.5, in.height);\n\n var color = params.colorA.rgb * 0.5;\n color = color + params.colorB.rgb * crest * 0.5;\n color = color + params.colorB.rgb * fresnel * 0.15;\n color = mix(color, params.colorC.rgb, aux);\n\n // The source example exposed 512² splats; this grid is 256², so each of the\n // quarter as many splats carries four times the weight to match.\n var alpha = (0.02 + crest * 0.06 + fresnel * 0.04) * 4.0;\n alpha = mix(alpha, 1.0, aux);\n color = color * in.fade * params.intensity;\n alpha = clamp(alpha * in.fade, 0.0, 1.0);\n return vec4f(color, alpha);\n}\n\n// --- bloom -------------------------------------------------------------------\n\nfn st_luminance(rgb : vec3f) -> f32 {\n return dot(rgb, vec3f(0.299, 0.587, 0.114));\n}\n\n@fragment fn fs_bright(@location(0) uv : vec2f) -> @location(0) vec4f {\n // LuminosityHighPassShader, as ported by the fft-ocean example.\n let texel = textureSample(t_scene, linearSampler, uv);\n let gate = smoothstep(bloomU.threshold, bloomU.threshold + bloomU.smoothWidth, st_luminance(texel.xyz));\n return mix(vec4f(vec3f(0.0), 0.0), texel, gate);\n}\n\nconst ST_BLUR_RADIUS : u32 = 22u;\n\nfn st_blurCoefficient(i : u32) -> f32 {\n let packed = array<vec4f, 6>(\n blurU.coeffs0, blurU.coeffs1, blurU.coeffs2,\n blurU.coeffs3, blurU.coeffs4, blurU.coeffs5,\n );\n return packed[i / 4u][i % 4u];\n}\n\n@fragment fn fs_blur(@location(0) uv : vec2f) -> @location(0) vec4f {\n // UnrealBloomPass separable blur: coefficients past each level's radius are\n // zero, so one fixed loop serves all five levels.\n var weightSum = st_blurCoefficient(0u);\n var diffuseSum = textureSample(t_input, linearSampler, uv).rgb * weightSum;\n for (var i = 1u; i < ST_BLUR_RADIUS; i = i + 1u) {\n let w = st_blurCoefficient(i);\n let offset = blurU.direction * blurU.invSize * f32(i);\n let s1 = textureSample(t_input, linearSampler, uv + offset).rgb;\n let s2 = textureSample(t_input, linearSampler, uv - offset).rgb;\n diffuseSum = diffuseSum + (s1 + s2) * w;\n }\n return vec4f(diffuseSum, 1.0);\n}\n\nfn st_bloomFactor(i : u32) -> f32 {\n let v = array<f32, 8>(\n bloomU.factors0.x, bloomU.factors0.y, bloomU.factors0.z, bloomU.factors0.w,\n bloomU.factors1.x, bloomU.factors1.y, bloomU.factors1.z, bloomU.factors1.w,\n );\n let mirror = 1.2 - v[i];\n return mix(v[i], mirror, bloomU.radius);\n}\n\n@fragment fn fs_composite(@location(0) uv : vec2f) -> @location(0) vec4f {\n // UnrealBloomPass composite, tint colours all white.\n let bloom = 3.0 * bloomU.strength * (\n st_bloomFactor(0u) * textureSample(t_blur1, linearSampler, uv).rgb +\n st_bloomFactor(1u) * textureSample(t_blur2, linearSampler, uv).rgb +\n st_bloomFactor(2u) * textureSample(t_blur3, linearSampler, uv).rgb +\n st_bloomFactor(3u) * textureSample(t_blur4, linearSampler, uv).rgb +\n st_bloomFactor(4u) * textureSample(t_blur5, linearSampler, uv).rgb\n );\n return vec4f(bloom, max(bloom.r, max(bloom.g, bloom.b)));\n}\n\n// ---------------------------------------------------------------------------\n// maelstrom\n// ---------------------------------------------------------------------------\n\n// Maelstrom — a whirlpool seen from the rim, arms wound into the drain.\n//\n// The surface is polar: spiral arms are a sine of angle-plus-log-radius — a\n// logarithmic spiral, the curve every real vortex draws because the twist\n// tightens as the water speeds up toward the middle. A funnel term pulls the\n// centre down, churn noise roughens the water, and the particles are also\n// displaced sideways along the tangent, so the grid itself visibly circles\n// the drain rather than bobbing in place. Aux is froth: the accent colour\n// rides the arm crests and thickens toward the eye.\n\nfn st_field(p : vec2f, t : f32) -> vec4f {\n // q spans roughly ±1 across the frame; scale zooms the vortex.\n var q = p * (params.scale / (gu.world.x * 0.5));\n\n // The eye wanders a little; a vortex pinned to the exact centre reads as a\n // screensaver, one that drifts reads as weather.\n q = q - params.warp * 0.15 * vec2f(sin(t * 0.5), cos(t * 0.37));\n\n let r = max(length(q), 0.02);\n let theta = atan2(q.y, q.x);\n let tangent = vec2f(-q.y, q.x) / r;\n\n // Inner water turns faster — the clamp keeps the eye from spinning to noise.\n let spin = t * (0.55 + 0.9 / max(r, 0.25));\n\n // Log-spiral arms, fading toward the rim where the pull lets go.\n let phase = theta * params.a + log(r) * params.b * 3.0 - spin;\n let amp = exp(-r * 1.3);\n let wave = sin(phase);\n var h = wave * amp * 0.9;\n\n // The funnel: the reason the picture has a middle.\n h = h - params.d * 1.3 * exp(-r * r * 3.5);\n\n // Churn: broken water riding the arms, advected the same way they turn.\n let churnP = vec2f(r * 3.0, theta * 2.0 + log(r) * params.b * 3.0 - spin);\n let churn = st_fbm2(churnP * 2.0 + vec2f(0.0, t * 0.3), 3) - 0.5;\n h = h + churn * 0.5 * params.c;\n\n // The water moves: sideways along the tangent with the arm it rides, so\n // the dots orbit instead of standing on a waving floor.\n let drift = tangent * (0.35 * amp * cos(phase));\n\n // Froth: the forward face of each arm, thickening toward the eye. Kept\n // partial on purpose — full froth buries the water colour under white.\n let crest = smoothstep(0.45, 1.0, wave) * amp;\n let eye = smoothstep(0.4, 0.08, r) * 0.4;\n let aux = clamp((crest * 0.8 + eye) * (0.5 + params.c * 0.5), 0.0, 1.0);\n\n return vec4f(drift.x, h, drift.y, aux);\n}\n";
31
35
  readonly magnet: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// magnet\n// ---------------------------------------------------------------------------\n\n// Magnet — the field of three charges, one of them riding the pointer.\n//\n// Nothing here is traced. The stream function of a point charge is its angle\n// and the potential is the log of its distance, so field lines are level sets\n// of one sum and equipotentials level sets of the other — the entire plot is\n// two sin() calls over closed forms. The continuity of the picture depends on\n// the charges being integers: sin(n * sum of atan2) survives the branch cuts\n// only when n times each charge is whole, which is why the pointer charge\n// slider steps by one.\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let t = st_time();\n let p = st_centered(uv) / max(params.scale, 0.2);\n\n let cursor = vec2f((params.pointer.x - 0.5) * st_aspect(), params.pointer.y - 0.5)\n / max(params.scale, 0.2);\n\n // Two fixed poles and the pointer. The pointer's charge is a control so it\n // can attract, repel, or sit out entirely at zero.\n let sep = 0.28;\n let poleA = vec2f(-sep, 0.0);\n let poleB = vec2f(sep, 0.0);\n let qC = round(clamp(params.c, -2.0, 2.0));\n\n let rA = p - poleA;\n let rB = p - poleB;\n let rC = p - cursor;\n let dA = max(length(rA), 0.015);\n let dB = max(length(rB), 0.015);\n let dC = max(length(rC), 0.015);\n\n // Stream function and potential, superposed.\n let psi = atan2(rA.y, rA.x) - atan2(rB.y, rB.x) + qC * atan2(rC.y, rC.x);\n let phi = log(dA) - log(dB) + qC * log(dC);\n\n // Field strength, for brightness: strong near a pole, dead in the middle\n // of nowhere. The E-field of a 2D charge falls off as 1/r.\n let eField = rA / (dA * dA) - rB / (dB * dB) + qC * rC / (dC * dC);\n let strength = length(eField);\n let energy = strength / (2.5 + strength);\n\n // Field lines flow along themselves: the phase moves with time, so the\n // lines read as current rather than as a printed diagram.\n let lineCount = max(round(params.a), 2.0);\n let lines = pow(0.5 + 0.5 * sin(psi * lineCount - t * 1.5 * params.b), 8.0);\n\n // Equipotentials stand still and stay faint — they are the grid the flow\n // is read against.\n let rings = pow(0.5 + 0.5 * sin(phi * 7.0), 12.0) * 0.35;\n\n var col = params.colorA.rgb * 0.10;\n col = col + st_palette(energy) * lines * energy * 2.4;\n col = col + params.colorB.rgb * rings * energy;\n\n // The poles themselves: hot cores, colour-coded by sign.\n col = col + params.colorC.rgb * exp(-dA * 24.0) * 1.4;\n col = col + params.colorB.rgb * exp(-dB * 24.0) * 1.4;\n let cursorTone = mix(params.colorB.rgb, params.colorC.rgb, step(0.0, qC));\n col = col + cursorTone * exp(-dC * 24.0) * 1.4 * abs(qC) * 0.5;\n\n return st_post(st_tonemap(col * params.intensity), uv);\n}\n";
32
36
  readonly meadow: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// meadow\n// ---------------------------------------------------------------------------\n\n// Meadow — a flower field at golden hour, five rows deep.\n//\n// Silhouettes, not portraits: every row is drawn as one tone against the\n// glowing sky, far rows barely darker than the haze and the nearest nearly\n// ink — the same trick a photographer gets free at this hour. Depth is faked\n// twice more: rows slide by their own parallax under the pointer, and rows\n// off the focal plane widen their antialiasing band, which at header size\n// reads as lens blur.\n\n// How much a row's silhouette covers this pixel: grass blades on a fine\n// grid, flowers on a coarse one, both leaning with the wind.\nfn rowMask(p : vec2f, seed : f32, sway : f32, aa : f32, density : f32) -> f32 {\n var mask = 0.0;\n\n // The ground itself rolls: without this every row is a straight comb.\n let ground = (st_fbm2(vec2f(p.x * 1.7, seed), 3) - 0.5) * 0.17;\n\n // Grass: three blades per cell, curved, tapering to nothing.\n let bladeW = 0.021 / density;\n let bladeCell = floor(p.x / bladeW);\n for (var k = -1; k <= 1; k = k + 1) {\n let id = bladeCell + f32(k);\n let rnd = st_hash22(vec2f(id * 0.37, seed));\n let bx = (id + rnd.x) * bladeW;\n let bh = (0.035 + rnd.y * 0.13) * (0.7 + 0.6 * st_hash11(id * 0.61 + seed));\n let yUp = -p.y - ground;\n let bend = (sway + (rnd.x - 0.5) * 0.4) * (yUp / bh) * (yUp / bh);\n let d = abs(p.x - bx - bend * bh) - 0.0035 * (1.0 - yUp / bh);\n let blade = smoothstep(aa, -aa, d) * step(0.0, yUp) * step(yUp, bh);\n mask = max(mask, blade);\n }\n\n // Flowers: sparser, taller, a stem with a petalled head.\n let cellW = 0.13 / density;\n let cellId = floor(p.x / cellW);\n for (var k = -1; k <= 1; k = k + 1) {\n let id = cellId + f32(k);\n let rnd = st_hash22(vec2f(id, seed + 4.7));\n if (rnd.x > 0.5) { continue; }\n\n let cx = (id + 0.2 + rnd.x * 0.6) * cellW;\n let sh = 0.07 + rnd.y * 0.14;\n let yUp = -p.y - ground;\n let lean = (sway * 1.4 + (rnd.y - 0.5) * 0.3) * pow(clamp(yUp / sh, 0.0, 1.0), 2.0);\n let sx = p.x - cx - lean * sh;\n\n let stem = smoothstep(aa, -aa, abs(sx) - 0.0028) * step(0.0, yUp) * step(yUp, sh);\n mask = max(mask, stem);\n\n // The head: a rose curve, so even the silhouette says flower.\n let head = vec2f(sx, yUp - sh);\n let hang = atan2(head.y, head.x);\n let petals = 5.0 + floor(rnd.x * 4.0);\n let headR = (0.020 + rnd.y * 0.016) * (0.55 + 0.45 * abs(cos(petals * hang * 0.5)));\n mask = max(mask, smoothstep(headR + aa, headR - aa, length(head)));\n }\n\n return mask;\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let t = st_time();\n let p = st_centered(uv);\n let yUp = -p.y;\n\n let look = (params.pointer - vec2f(0.5)) * 0.1;\n\n // The sky is the light source: colorA above, dissolving into the sun's\n // pool of colorC just over the field.\n let sunPos = vec2f(0.22 - look.x * 0.25, 0.05);\n let sunDist = length(vec2f(p.x, yUp) - sunPos);\n var col = mix(params.colorC.rgb, params.colorA.rgb, smoothstep(-0.15, 0.55, yUp - sunPos.y * 0.5));\n col = col + params.colorC.rgb * exp(-sunDist * 2.6) * 0.55 * params.c;\n col = col + vec3f(1.0, 0.97, 0.88) * exp(-sunDist * 16.0) * 1.2 * params.c;\n col = col + vec3f(1.0) * smoothstep(0.05, 0.035, sunDist) * params.c;\n\n // Four rows, back to front. Each is one flat tone: haze up close to the\n // sky's own colour at the back, ink at the front.\n let ink = params.colorB.rgb * 0.16;\n for (var i = 0; i < 4; i = i + 1) {\n let fi = f32(i) / 3.0; // 0 far, 1 near\n let rise = mix(-0.02, -0.46, fi);\n\n let zoom = mix(2.2, 1.0, fi);\n let q = vec2f(\n (p.x + look.x * mix(0.1, 0.6, fi) + f32(i) * 2.13) * zoom * params.scale,\n (p.y - (-rise)) * zoom,\n );\n\n // Focus sits on the middle rows; blur grows toward both ends of depth.\n let aa = (0.0016 + abs(fi - 0.6) * 0.012 * params.d) * zoom;\n let sway = params.b * 0.1 * sin(t * 1.2 + fi * 2.1) + params.b * 0.05 * sin(t * 2.7 + fi * 5.0);\n\n let mask = rowMask(q, f32(i) * 17.3, sway, aa, params.a);\n\n // Rows near the sun catch its rim on their silhouettes.\n let tone = mix(mix(col, ink, 0.35 + 0.16 * fi), ink, fi * fi);\n let rimmed = tone + params.colorC.rgb * exp(-sunDist * 3.0) * 0.25 * (1.0 - fi);\n col = mix(col, rimmed, mask);\n }\n\n return st_post(st_tonemap(col * params.intensity), uv);\n}\n";
33
37
  readonly melt: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// shadertown source prelude — appended to the shared prelude for shaders that\n// read a layer instead of inventing one.\n//\n// Every binding here is additive. A generative shader declares none of them and\n// keeps the exact layout it always had, and `Source` is its own uniform block\n// rather than four more fields on `Params`, so the ABI that every already\n// shipped shader was built against never moves.\n\n@group(0) @binding(1) var srcTexture : texture_2d<f32>;\n@group(0) @binding(2) var srcSampler : sampler;\n\nstruct Source {\n // Pixel size of the bound texture. Zero means \"assume it matches the canvas\".\n size : vec2f,\n // 0 stretches the source over the canvas, 1 covers the canvas and crops.\n fit : f32,\n _pad : f32,\n}\n\n@group(0) @binding(3) var<uniform> source : Source;\n\n// How much of the source each axis shows once it is fitted. Both components are\n// 1 or less: covering means cropping, never adding.\nfn st_srcScale() -> vec2f {\n if (source.fit < 0.5 || source.size.x < 1.0 || source.size.y < 1.0) {\n return vec2f(1.0);\n }\n let canvas = st_aspect();\n let src = source.size.x / source.size.y;\n if (src > canvas) {\n return vec2f(canvas / src, 1.0);\n }\n return vec2f(1.0, src / canvas);\n}\n\n/** Canvas uv to source uv. */\nfn st_srcUv(uv : vec2f) -> vec2f {\n return (uv - 0.5) * st_srcScale() + 0.5;\n}\n\n/**\n * Canvas uv to source uv, with room left around the edge.\n *\n * A shader that displaces its reads runs off the source at the border, and the\n * sampler answers by smearing its last row of texels across the gap. Reading a\n * slightly smaller crop gives the displacement somewhere to go, at the cost of\n * a few per cent of the picture.\n */\nfn st_srcUvInset(uv : vec2f, margin : f32) -> vec2f {\n return (uv - 0.5) * st_srcScale() * (1.0 - clamp(margin, 0.0, 0.4)) + 0.5;\n}\n\n/**\n * A canvas-space displacement, in source uv.\n *\n * Divides out the aspect so a push of 0.01 moves the same distance sideways as\n * it does up, whatever shape the canvas or the source is.\n */\nfn st_srcWarp(offset : vec2f) -> vec2f {\n return offset * st_srcScale() / vec2f(st_aspect(), 1.0);\n}\n\n/** Samples in source uv. Kernels that walk neighbours want this one. */\nfn st_srcAt(srcUv : vec2f) -> vec4f {\n return textureSample(srcTexture, srcSampler, srcUv);\n}\n\n/** Samples in canvas uv — the ordinary read. */\nfn st_src(uv : vec2f) -> vec4f {\n return st_srcAt(st_srcUv(uv));\n}\n\nfn st_srcRgb(uv : vec2f) -> vec3f {\n return st_src(uv).rgb;\n}\n\n/** One source texel, in source uv. */\nfn st_srcTexel() -> vec2f {\n return 1.0 / max(source.size, vec2f(1.0));\n}\n\n/**\n * 1 inside the source, falling to 0 just outside it.\n *\n * The sampler clamps, so a shader that pushes past the border smears the last\n * row of texels across the gap. Anything that displaces should fade instead.\n */\nfn st_srcInside(srcUv : vec2f) -> f32 {\n let edge = min(srcUv, vec2f(1.0) - srcUv);\n return smoothstep(0.0, 0.004, min(edge.x, edge.y));\n}\n\n/** Four-tap softening. Cheap enough to run per colour channel. */\nfn st_srcSoften(uv : vec2f, radius : f32) -> vec3f {\n let base = st_srcUv(uv);\n var sum = vec3f(0.0);\n for (var i = 0; i < 4; i = i + 1) {\n let angle = f32(i) * 1.570796 + 0.785398;\n sum = sum + st_srcAt(base + st_srcWarp(vec2f(cos(angle), sin(angle)) * radius)).rgb;\n }\n return sum * 0.25;\n}\n\n/**\n * Thirteen-tap spiral blur. `radius` is in canvas space.\n *\n * The golden angle keeps the taps from lining up into spokes, which is what a\n * uniform ring blur gives you at low tap counts.\n */\nfn st_srcBlur(uv : vec2f, radius : f32) -> vec3f {\n let base = st_srcUv(uv);\n var sum = st_srcAt(base).rgb;\n for (var i = 0; i < 12; i = i + 1) {\n let fi = f32(i);\n let angle = fi * 2.399963;\n let r = radius * sqrt((fi + 0.5) / 12.0);\n sum = sum + st_srcAt(base + st_srcWarp(vec2f(cos(angle), sin(angle)) * r)).rgb;\n }\n return sum / 13.0;\n}\n\n// ---------------------------------------------------------------------------\n// melt\n// ---------------------------------------------------------------------------\n\n// Melt — the layer runs downhill, and the pointer is the heat.\n//\n// The displacement reads upward: a melting pixel shows what used to be above\n// it, so the whole effect is sampling the source at a smaller y. How much\n// smaller is a column-coherent noise — coherent down each column, varied\n// across them — which is what makes drips instead of fog. The streak pass\n// resamples the climb and keeps the brightest tap, the way a run of paint\n// carries its highlight down with it.\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let t = st_time();\n let aspect = st_aspect();\n\n // The heat field: a global simmer plus the pointer's blowtorch. Melting\n // grows toward the bottom of the frame because runs accumulate.\n let toCursor = (uv - params.pointer) * vec2f(aspect, 1.0);\n let heat = params.a * 0.5 + exp(-dot(toCursor, toCursor) / max(params.d * params.d, 0.001)) * params.a;\n let gravityGain = smoothstep(0.0, 0.7, uv.y);\n\n // Drips: one noise per column family, drifting slowly so the runs crawl.\n let dripNoise = st_fbm2(vec2f(uv.x * params.b * 8.0, t * 0.3), 3);\n let drip = pow(dripNoise, 2.0) * heat * (0.3 + gravityGain);\n\n // A slight sideways wobble keeps the runs from being perfectly plumb.\n let wobble = (st_noise2(vec2f(uv.x * 20.0, uv.y * 4.0 - t * 0.5)) - 0.5) * params.warp * 0.02 * heat;\n\n let base = uv + vec2f(wobble, -drip * 0.25);\n var col = st_srcAt(st_srcUvInset(base, 0.06)).rgb;\n\n // Streaks: walk back up the drip path and keep the brightest thing seen.\n // Weighted toward the origin so the smear fades as it falls.\n var streak = col;\n for (var i = 1; i <= 6; i = i + 1) {\n let fi = f32(i) / 6.0;\n let tap = st_srcAt(st_srcUvInset(base + vec2f(0.0, -drip * 0.25 * fi), 0.06)).rgb;\n streak = max(streak, tap * (1.0 - fi * 0.5));\n }\n col = mix(col, streak, clamp(params.c, 0.0, 1.0) * smoothstep(0.05, 0.3, drip));\n\n // The melt line glows faintly where the drip gradient is steep — molten\n // edges, strongest under the torch.\n let glow = smoothstep(0.15, 0.6, drip) * heat * 0.3;\n col = col + params.colorC.rgb * glow;\n\n return st_post(col * params.intensity, uv);\n}\n";
34
- readonly "mesh-gradient": "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// mesh-gradient\n// ---------------------------------------------------------------------------\n\n// Mesh gradient — soft colour centres orbiting behind frosted glass.\n\n// A gaussian well, raised to a power so nearby colours dominate rather than\n// averaging. Without the exponent every centre contributes everywhere and the\n// middle of the frame turns grey.\nfn blob(p : vec2f, centre : vec2f, radius : f32) -> f32 {\n let d = length(p - centre);\n let g = exp(-d * d / max(radius * radius, 0.0001));\n return g * g * g;\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n var p = st_centered(uv) * params.scale;\n let t = st_time();\n\n // Gentle domain warp keeps the blend from looking like plain radial gradients.\n let warpNoise = vec2f(\n st_fbm2(p * 1.6 + vec2f(0.0, t * 0.12), 3),\n st_fbm2(p * 1.6 + vec2f(5.2, t * 0.12 + 1.3), 3)\n );\n p = p + (warpNoise - 0.5) * params.warp;\n\n let r = params.a;\n let spread = params.b;\n\n let c0 = vec2f(cos(t * 0.31) * spread, sin(t * 0.24) * spread * 0.6);\n let c1 = vec2f(cos(t * 0.19 + 2.1) * spread * 0.9, sin(t * 0.37 + 1.1) * spread * 0.7);\n let c2 = vec2f(cos(t * 0.27 + 4.3) * spread * 0.8, sin(t * 0.21 + 3.7) * spread * 0.75);\n let c3 = vec2f(cos(t * 0.15 + 5.6) * spread, sin(t * 0.33 + 0.4) * spread * 0.5);\n\n // Sharpening the weights before normalising is what keeps the colours\n // distinct. Plain gaussian weights average towards grey in the middle.\n let sharpen = 2.6;\n let w0 = pow(blob(p, c0, r), sharpen);\n let w1 = pow(blob(p, c1, r * 0.9), sharpen);\n let w2 = pow(blob(p, c2, r * 1.15), sharpen);\n let w3 = pow(blob(p, c3, r * 0.8), sharpen);\n let total = w0 + w1 + w2 + w3 + 1e-6;\n\n let base = mix(params.colorA.rgb, params.colorB.rgb, 0.5) * 0.25;\n var col = (params.colorA.rgb * w0\n + params.colorB.rgb * w1\n + params.colorC.rgb * w2\n + mix(params.colorA.rgb, params.colorC.rgb, 0.5) * w3) / total;\n\n // Coverage decides how much of the dark backdrop shows through.\n let coverage = clamp(pow(total, 1.0 / sharpen) * params.c, 0.0, 1.0);\n col = mix(base, col, coverage) * params.intensity;\n\n return st_post(col, uv);\n}\n";
38
+ readonly "mesh-gradient": "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// mesh-gradient\n// ---------------------------------------------------------------------------\n\n// Mesh gradient — flowing colour spots under swirl and noise.\n// Four colour spots drift along their own slow orbits; every pixel blends\n// them with inverse-power falloff, mixed as a weighted sum in OKLab so the\n// midpoints stay alive instead of greying out. Before sampling, the domain\n// is swirled around the centre (stronger inside, dying toward the edges)\n// and bent by simplex distortion, which turns radial blobs into a silky,\n// organic gradient. The blend happens in linear light and encodes to sRGB\n// once, before the shared post chain.\n\n// The uniform colours arrive sRGB-encoded and the canvas is non-sRGB, so the\n// shader owns both ends of the transfer function.\nfn mg_srgbToLinear(c : vec3f) -> vec3f {\n let lo = c / 12.92;\n let hi = pow(max((c + vec3f(0.055)) / 1.055, vec3f(0.0)), vec3f(2.4));\n return select(hi, lo, c <= vec3f(0.04045));\n}\n\nfn mg_linearToSrgb(c : vec3f) -> vec3f {\n let lo = c * 12.92;\n let hi = 1.055 * pow(max(c, vec3f(0.0)), vec3f(1.0 / 2.4)) - vec3f(0.055);\n return select(hi, lo, c <= vec3f(0.0031308));\n}\n\nfn mg_linearSrgbToOklab(c : vec3f) -> vec3f {\n let l = 0.4122214708 * c.r + 0.5363325363 * c.g + 0.0514459929 * c.b;\n let m = 0.2119034982 * c.r + 0.6806995451 * c.g + 0.1073969566 * c.b;\n let s = 0.0883024619 * c.r + 0.2817188376 * c.g + 0.6299787005 * c.b;\n // Cube roots of LMS. The inputs are non-negative for in-gamut colours; the\n // max() keeps pow defined when float error dips a hair below zero.\n let l3 = pow(max(l, 0.0), 1.0 / 3.0);\n let m3 = pow(max(m, 0.0), 1.0 / 3.0);\n let s3 = pow(max(s, 0.0), 1.0 / 3.0);\n return vec3f(\n 0.2104542553 * l3 + 0.7936177850 * m3 - 0.0040720468 * s3,\n 1.9779984951 * l3 - 2.4285922050 * m3 + 0.4505937099 * s3,\n 0.0259040371 * l3 + 0.7827717662 * m3 - 0.8086757660 * s3,\n );\n}\n\nfn mg_oklabToLinearSrgb(lab : vec3f) -> vec3f {\n let l3 = lab.x + 0.3963377774 * lab.y + 0.2158037573 * lab.z;\n let m3 = lab.x - 0.1055613458 * lab.y - 0.0638541728 * lab.z;\n let s3 = lab.x - 0.0894841775 * lab.y - 1.2914855480 * lab.z;\n let l = l3 * l3 * l3;\n let m = m3 * m3 * m3;\n let s = s3 * s3 * s3;\n return vec3f(\n 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,\n -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,\n -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,\n );\n}\n\n// 2D simplex noise (Ashima), signed in roughly [-1, 1]. The prelude's value\n// noise reads blocky and axis-aligned; the distortion needs an isotropic\n// field or the bends telegraph the lattice.\nfn mg_mod289v3(x : vec3f) -> vec3f {\n return x - floor(x * (1.0 / 289.0)) * 289.0;\n}\n\nfn mg_mod289v2(x : vec2f) -> vec2f {\n return x - floor(x * (1.0 / 289.0)) * 289.0;\n}\n\nfn mg_permute3(x : vec3f) -> vec3f {\n return mg_mod289v3(((x * 34.0) + 1.0) * x);\n}\n\nfn mg_simplex2d(v : vec2f) -> f32 {\n let C = vec4f(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);\n var i = floor(v + dot(v, C.yy));\n let x0 = v - i + dot(i, C.xx);\n let i1 = select(vec2f(0.0, 1.0), vec2f(1.0, 0.0), x0.x > x0.y);\n var x12 = x0.xyxy + C.xxzz;\n x12 = vec4f(x12.xy - i1, x12.zw);\n i = mg_mod289v2(i);\n let p = mg_permute3(mg_permute3(i.y + vec3f(0.0, i1.y, 1.0)) + i.x + vec3f(0.0, i1.x, 1.0));\n var m = max(vec3f(0.5) - vec3f(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), vec3f(0.0));\n m = m * m;\n m = m * m;\n let x = 2.0 * fract(p * C.www) - 1.0;\n let h = abs(x) - 0.5;\n let ox = floor(x + 0.5);\n let a0 = x - ox;\n m = m * (1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h));\n let g = vec3f(\n a0.x * x0.x + h.x * x0.y,\n a0.yz * x12.xz + h.yz * x12.yw,\n );\n return 130.0 * dot(m, g);\n}\n\n// Sharpened inverse-power weight of a spot at `site` for the point `p`.\n// The exponent keeps each colour region holding its own ink, so the gradient\n// reads as flowing zones instead of averaging into a single cream.\nfn mg_spotWeight(p : vec2f, site : vec2f) -> f32 {\n let d = p - site;\n return 1.0 / pow(dot(d, d) + 0.03, 1.5);\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let p = st_centered(uv) * params.scale;\n let t = st_time() * 0.1;\n\n // Swirl: rotate around the centre, strongest inside, easing off toward\n // the edges — wide and strong enough to twist the colours into arms.\n let radius = length(p);\n let swirlAngle = params.a * 3.4 * exp(-radius * 1.1) + t * 0.2;\n var q = st_rot(swirlAngle) * p;\n\n // Organic distortion at two octaves: the broad field bends the zones, the\n // finer one keeps their boundaries alive.\n let bend = vec2f(\n mg_simplex2d(q * 1.1 + vec2f(t * 0.8, 3.1)) + 0.5 * mg_simplex2d(q * 2.4 - vec2f(t * 1.2, 1.7)),\n mg_simplex2d(q * 1.1 - vec2f(2.7, t * 0.8)) + 0.5 * mg_simplex2d(q * 2.4 + vec2f(1.3, t * 1.2)),\n );\n q = q + bend * params.warp * 0.4;\n\n // The spots: four sites on independent slow orbits.\n let siteA = vec2f(0.62 * cos(t * 0.9), 0.45 * sin(t * 0.7));\n let siteB = vec2f(0.66 * cos(-t * 0.6 + 2.1), 0.5 * sin(t * 0.8 + 1.1));\n let siteC = vec2f(0.55 * cos(t * 0.5 + 4.2), 0.55 * sin(-t * 0.9 + 2.6));\n let siteD = vec2f(0.7 * cos(-t * 0.75 + 1.3), 0.4 * sin(t * 0.65 + 4.4));\n\n let wA = mg_spotWeight(q, siteA);\n let wB = mg_spotWeight(q, siteB);\n let wC = mg_spotWeight(q, siteC);\n let wD = mg_spotWeight(q, siteD);\n let wSum = max(wA + wB + wC + wD, 1e-5);\n\n // Weighted OKLab sum: an N-way perceptual mix, vivid through the middle.\n // The three palette colours cycle over four spots, so the fourth wears\n // colourA again and the field never shows a hole.\n let labA = mg_linearSrgbToOklab(mg_srgbToLinear(params.colorA.rgb));\n let labB = mg_linearSrgbToOklab(mg_srgbToLinear(params.colorB.rgb));\n let labC = mg_linearSrgbToOklab(mg_srgbToLinear(params.colorC.rgb));\n let lab = (labA * (wA + wD) + labB * wB + labC * wC) / wSum;\n\n let linear = max(mg_oklabToLinearSrgb(lab) * params.intensity, vec3f(0.0));\n return st_post(mg_linearToSrgb(linear), uv);\n}\n";
35
39
  readonly mosaic: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// shadertown source prelude — appended to the shared prelude for shaders that\n// read a layer instead of inventing one.\n//\n// Every binding here is additive. A generative shader declares none of them and\n// keeps the exact layout it always had, and `Source` is its own uniform block\n// rather than four more fields on `Params`, so the ABI that every already\n// shipped shader was built against never moves.\n\n@group(0) @binding(1) var srcTexture : texture_2d<f32>;\n@group(0) @binding(2) var srcSampler : sampler;\n\nstruct Source {\n // Pixel size of the bound texture. Zero means \"assume it matches the canvas\".\n size : vec2f,\n // 0 stretches the source over the canvas, 1 covers the canvas and crops.\n fit : f32,\n _pad : f32,\n}\n\n@group(0) @binding(3) var<uniform> source : Source;\n\n// How much of the source each axis shows once it is fitted. Both components are\n// 1 or less: covering means cropping, never adding.\nfn st_srcScale() -> vec2f {\n if (source.fit < 0.5 || source.size.x < 1.0 || source.size.y < 1.0) {\n return vec2f(1.0);\n }\n let canvas = st_aspect();\n let src = source.size.x / source.size.y;\n if (src > canvas) {\n return vec2f(canvas / src, 1.0);\n }\n return vec2f(1.0, src / canvas);\n}\n\n/** Canvas uv to source uv. */\nfn st_srcUv(uv : vec2f) -> vec2f {\n return (uv - 0.5) * st_srcScale() + 0.5;\n}\n\n/**\n * Canvas uv to source uv, with room left around the edge.\n *\n * A shader that displaces its reads runs off the source at the border, and the\n * sampler answers by smearing its last row of texels across the gap. Reading a\n * slightly smaller crop gives the displacement somewhere to go, at the cost of\n * a few per cent of the picture.\n */\nfn st_srcUvInset(uv : vec2f, margin : f32) -> vec2f {\n return (uv - 0.5) * st_srcScale() * (1.0 - clamp(margin, 0.0, 0.4)) + 0.5;\n}\n\n/**\n * A canvas-space displacement, in source uv.\n *\n * Divides out the aspect so a push of 0.01 moves the same distance sideways as\n * it does up, whatever shape the canvas or the source is.\n */\nfn st_srcWarp(offset : vec2f) -> vec2f {\n return offset * st_srcScale() / vec2f(st_aspect(), 1.0);\n}\n\n/** Samples in source uv. Kernels that walk neighbours want this one. */\nfn st_srcAt(srcUv : vec2f) -> vec4f {\n return textureSample(srcTexture, srcSampler, srcUv);\n}\n\n/** Samples in canvas uv — the ordinary read. */\nfn st_src(uv : vec2f) -> vec4f {\n return st_srcAt(st_srcUv(uv));\n}\n\nfn st_srcRgb(uv : vec2f) -> vec3f {\n return st_src(uv).rgb;\n}\n\n/** One source texel, in source uv. */\nfn st_srcTexel() -> vec2f {\n return 1.0 / max(source.size, vec2f(1.0));\n}\n\n/**\n * 1 inside the source, falling to 0 just outside it.\n *\n * The sampler clamps, so a shader that pushes past the border smears the last\n * row of texels across the gap. Anything that displaces should fade instead.\n */\nfn st_srcInside(srcUv : vec2f) -> f32 {\n let edge = min(srcUv, vec2f(1.0) - srcUv);\n return smoothstep(0.0, 0.004, min(edge.x, edge.y));\n}\n\n/** Four-tap softening. Cheap enough to run per colour channel. */\nfn st_srcSoften(uv : vec2f, radius : f32) -> vec3f {\n let base = st_srcUv(uv);\n var sum = vec3f(0.0);\n for (var i = 0; i < 4; i = i + 1) {\n let angle = f32(i) * 1.570796 + 0.785398;\n sum = sum + st_srcAt(base + st_srcWarp(vec2f(cos(angle), sin(angle)) * radius)).rgb;\n }\n return sum * 0.25;\n}\n\n/**\n * Thirteen-tap spiral blur. `radius` is in canvas space.\n *\n * The golden angle keeps the taps from lining up into spokes, which is what a\n * uniform ring blur gives you at low tap counts.\n */\nfn st_srcBlur(uv : vec2f, radius : f32) -> vec3f {\n let base = st_srcUv(uv);\n var sum = st_srcAt(base).rgb;\n for (var i = 0; i < 12; i = i + 1) {\n let fi = f32(i);\n let angle = fi * 2.399963;\n let r = radius * sqrt((fi + 0.5) / 12.0);\n sum = sum + st_srcAt(base + st_srcWarp(vec2f(cos(angle), sin(angle)) * r)).rgb;\n }\n return sum / 13.0;\n}\n\n// ---------------------------------------------------------------------------\n// mosaic\n// ---------------------------------------------------------------------------\n\n// Mosaic — the layer rebuilt out of tiles.\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n // Cells are counted across the short axis and scaled by aspect, so a tile\n // stays square whatever shape the canvas is.\n let cells = vec2f(max(params.a, 2.0) * st_aspect(), max(params.a, 2.0));\n let cell = floor(uv * cells);\n let middle = (cell + 0.5) / cells;\n\n // One tap at the tile's centre, not an average of it. Cheaper, and the\n // hard colour is the point — averaging would only reinvent a blur.\n var tile = st_srcRgb(middle);\n\n // Posterise after sampling: quantising the tile colour is what makes it look\n // assembled out of a limited set rather than merely coarse.\n let steps = max(params.d, 2.0);\n tile = floor(tile * steps + 0.5) / steps;\n\n let local = fract(uv * cells) - 0.5;\n\n // Square tile or round dot, and everything between. The dot grows with the\n // tile's brightness, so dark areas thin out the way a printed screen does.\n let square = max(abs(local.x), abs(local.y));\n let round = length(local) / max(st_luma(tile), 0.05) * 0.55;\n let shape = mix(square, round, clamp(params.b, 0.0, 1.0));\n\n let edge = 0.5 - clamp(params.c, 0.0, 0.45);\n // Anti-alias in tile units. A fixed smoothstep width is far narrower than a\n // device pixel once the tiles are small, which is what turns clean grout into\n // a moire of hard, uneven lines.\n let aa = max(cells.y / max(params.resolution.y, 1.0), 0.002);\n let mask = 1.0 - smoothstep(edge - aa, edge + aa, shape);\n\n let ground = st_palette(0.0);\n let col = mix(ground, tile, mask);\n\n return st_post(col * params.intensity, uv);\n}\n";
36
40
  readonly "neon-grid": "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// neon-grid\n// ---------------------------------------------------------------------------\n\n// Neon grid — perspective floor, banded sun, horizon haze. Synthwave by the book.\n\nfn gridLines(coord : vec2f, weight : f32) -> f32 {\n let width = max(fwidth(coord), vec2f(0.0001));\n let g = abs(fract(coord - 0.5) - 0.5) / width;\n return 1.0 - smoothstep(0.0, weight, min(g.x, g.y));\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let t = st_time();\n let p = st_centered(uv);\n let horizon = 0.02;\n\n // Floor is evaluated unconditionally: fwidth needs uniform control flow.\n let depth = 1.0 / max(p.y - horizon, 0.004);\n let floorCoord = vec2f(p.x * depth * 0.35, depth * 0.35 - t * params.b);\n let line = gridLines(floorCoord * params.a, 1.8);\n let below = step(horizon, p.y);\n let fade = smoothstep(0.0, 0.09, p.y - horizon) * smoothstep(1.4, 0.12, depth * 0.05);\n\n // Sky above the horizon.\n let skyT = clamp((horizon - p.y) * 2.4, 0.0, 1.0);\n var col = mix(params.colorA.rgb * 0.32, vec3f(0.015, 0.008, 0.05), skyT);\n\n // Sun: a disc sliced by horizontal bands, resting on the horizon.\n let sunCentre = vec2f(0.0, horizon - 0.17);\n let sunDist = length((p - sunCentre) * vec2f(1.0, 1.25));\n let sunMask = smoothstep(0.20, 0.19, sunDist);\n let bands = step(0.38, fract((p.y - sunCentre.y) * 26.0 + t * 0.4))\n * step(sunCentre.y + 0.015, p.y);\n let sunColor = mix(params.colorC.rgb, params.colorB.rgb,\n clamp((p.y - sunCentre.y + 0.2) * 2.5, 0.0, 1.0));\n col = mix(col, sunColor, sunMask * (1.0 - bands));\n col = col + sunColor * smoothstep(0.55, 0.0, sunDist) * 0.3 * params.d;\n\n // Floor: darken the plate, then lay the neon lines over it.\n col = mix(col, col * 0.3, below);\n col = col + params.colorB.rgb * line * fade * below * params.intensity * 1.8;\n\n // Horizon haze.\n col = col + params.colorB.rgb * exp(-abs(p.y - horizon) * 80.0) * params.c;\n\n return st_post(st_tonemap(col), uv);\n}\n";
37
41
  readonly "neon-sign": "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// shadertown source prelude — appended to the shared prelude for shaders that\n// read a layer instead of inventing one.\n//\n// Every binding here is additive. A generative shader declares none of them and\n// keeps the exact layout it always had, and `Source` is its own uniform block\n// rather than four more fields on `Params`, so the ABI that every already\n// shipped shader was built against never moves.\n\n@group(0) @binding(1) var srcTexture : texture_2d<f32>;\n@group(0) @binding(2) var srcSampler : sampler;\n\nstruct Source {\n // Pixel size of the bound texture. Zero means \"assume it matches the canvas\".\n size : vec2f,\n // 0 stretches the source over the canvas, 1 covers the canvas and crops.\n fit : f32,\n _pad : f32,\n}\n\n@group(0) @binding(3) var<uniform> source : Source;\n\n// How much of the source each axis shows once it is fitted. Both components are\n// 1 or less: covering means cropping, never adding.\nfn st_srcScale() -> vec2f {\n if (source.fit < 0.5 || source.size.x < 1.0 || source.size.y < 1.0) {\n return vec2f(1.0);\n }\n let canvas = st_aspect();\n let src = source.size.x / source.size.y;\n if (src > canvas) {\n return vec2f(canvas / src, 1.0);\n }\n return vec2f(1.0, src / canvas);\n}\n\n/** Canvas uv to source uv. */\nfn st_srcUv(uv : vec2f) -> vec2f {\n return (uv - 0.5) * st_srcScale() + 0.5;\n}\n\n/**\n * Canvas uv to source uv, with room left around the edge.\n *\n * A shader that displaces its reads runs off the source at the border, and the\n * sampler answers by smearing its last row of texels across the gap. Reading a\n * slightly smaller crop gives the displacement somewhere to go, at the cost of\n * a few per cent of the picture.\n */\nfn st_srcUvInset(uv : vec2f, margin : f32) -> vec2f {\n return (uv - 0.5) * st_srcScale() * (1.0 - clamp(margin, 0.0, 0.4)) + 0.5;\n}\n\n/**\n * A canvas-space displacement, in source uv.\n *\n * Divides out the aspect so a push of 0.01 moves the same distance sideways as\n * it does up, whatever shape the canvas or the source is.\n */\nfn st_srcWarp(offset : vec2f) -> vec2f {\n return offset * st_srcScale() / vec2f(st_aspect(), 1.0);\n}\n\n/** Samples in source uv. Kernels that walk neighbours want this one. */\nfn st_srcAt(srcUv : vec2f) -> vec4f {\n return textureSample(srcTexture, srcSampler, srcUv);\n}\n\n/** Samples in canvas uv — the ordinary read. */\nfn st_src(uv : vec2f) -> vec4f {\n return st_srcAt(st_srcUv(uv));\n}\n\nfn st_srcRgb(uv : vec2f) -> vec3f {\n return st_src(uv).rgb;\n}\n\n/** One source texel, in source uv. */\nfn st_srcTexel() -> vec2f {\n return 1.0 / max(source.size, vec2f(1.0));\n}\n\n/**\n * 1 inside the source, falling to 0 just outside it.\n *\n * The sampler clamps, so a shader that pushes past the border smears the last\n * row of texels across the gap. Anything that displaces should fade instead.\n */\nfn st_srcInside(srcUv : vec2f) -> f32 {\n let edge = min(srcUv, vec2f(1.0) - srcUv);\n return smoothstep(0.0, 0.004, min(edge.x, edge.y));\n}\n\n/** Four-tap softening. Cheap enough to run per colour channel. */\nfn st_srcSoften(uv : vec2f, radius : f32) -> vec3f {\n let base = st_srcUv(uv);\n var sum = vec3f(0.0);\n for (var i = 0; i < 4; i = i + 1) {\n let angle = f32(i) * 1.570796 + 0.785398;\n sum = sum + st_srcAt(base + st_srcWarp(vec2f(cos(angle), sin(angle)) * radius)).rgb;\n }\n return sum * 0.25;\n}\n\n/**\n * Thirteen-tap spiral blur. `radius` is in canvas space.\n *\n * The golden angle keeps the taps from lining up into spokes, which is what a\n * uniform ring blur gives you at low tap counts.\n */\nfn st_srcBlur(uv : vec2f, radius : f32) -> vec3f {\n let base = st_srcUv(uv);\n var sum = st_srcAt(base).rgb;\n for (var i = 0; i < 12; i = i + 1) {\n let fi = f32(i);\n let angle = fi * 2.399963;\n let r = radius * sqrt((fi + 0.5) / 12.0);\n sum = sum + st_srcAt(base + st_srcWarp(vec2f(cos(angle), sin(angle)) * r)).rgb;\n }\n return sum / 13.0;\n}\n\n// ---------------------------------------------------------------------------\n// neon-sign\n// ---------------------------------------------------------------------------\n\n// Neon sign — the layer's edges bent into glass tube and lit.\n//\n// The tube is the level set where the layer's luma crosses a threshold, which\n// is why this works on anything but sings on text: a glyph's boundary is one\n// clean crossing, and the band around it becomes the glass. The halo is not a\n// blur of the tube — it is the blurred luma itself, which is cheaper and puts\n// the light where the ink is rather than haloing both sides of every edge\n// equally.\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let t = st_time();\n\n let sharp = st_luma(st_srcRgb(uv));\n let soft = st_luma(st_srcBlur(uv, params.warp * 0.03));\n let wide = (st_luma(st_srcBlur(uv, params.warp * 0.055))\n + st_luma(st_srcBlur(uv, params.warp * 0.11))) * 0.5;\n\n // The tube: a band where luma crosses the threshold. fwidth keeps it one\n // apparent width whatever resolution the source arrived at.\n let threshold = clamp(params.a, 0.05, 0.95);\n let aa = max(fwidth(soft), 0.008);\n let tube = 1.0 - smoothstep(0.0, aa * 3.0 + 0.02, abs(soft - threshold));\n\n // Flicker: mains hum under an occasional sputter. The sputter is a hash on\n // a coarse clock so the dropouts arrive as events, not as noise.\n let hum = 0.96 + 0.04 * sin(t * 60.0);\n let sputter = 1.0 - params.c * step(0.93, st_hash11(floor(t * 18.0))) * st_hash11(floor(t * 47.0));\n let power = hum * sputter;\n\n // The wall behind the sign, faintly lit by the sign's own spill.\n var col = params.colorA.rgb * (0.10 + wide * 0.5 * power);\n\n // The fill: the inside of the letterforms, glowing dimly like a lit tube's\n // backing rather than reading as a flat sticker.\n let inside = smoothstep(threshold, threshold + 0.2, sharp);\n col = col + params.colorB.rgb * inside * params.b * 0.45 * power;\n\n // Halo then core: the colour sits in the halo and the core burns to white,\n // which is how a real tube photographs.\n col = col + params.colorB.rgb * pow(wide, 1.5) * 1.6 * power;\n col = col + mix(params.colorB.rgb, params.colorC.rgb, 0.8) * tube * 2.2 * power;\n\n return st_post(st_tonemap(col * params.intensity * (0.7 + params.d * 0.6)), uv);\n}\n";
@@ -44,6 +48,7 @@ export declare const shaderSources: {
44
48
  readonly "pixel-rain": "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// pixel-rain\n// ---------------------------------------------------------------------------\n\n// Pixel Rain — rain over a sleeping skyline, drawn on a virtual pixel grid.\n//\n// Three flat bands of night sky under a moon and a few fixed stars, two\n// ranks of hashed buildings — the far rank hazed, the near rank black with\n// a sparse lattice of windows whose lights toggle rarely — and per-column\n// raindrops that fall by whole cells per tick onto a shimmering street.\n// Nothing is blended; every pixel is one of a dozen stops.\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let px = st_pixelSize(params.a);\n let cell = floor(uv * params.resolution / px);\n let puv = (cell + 0.5) * px / params.resolution;\n let cells = params.resolution / px;\n\n let tt = floor(st_time() * 10.0) / 10.0;\n\n // Night sky: three flat bands, faintest at the top.\n var col = params.colorA.rgb * 0.5;\n if (puv.y > 0.55) {\n col = params.colorA.rgb;\n } else if (puv.y > 0.3) {\n col = params.colorA.rgb * 0.72;\n }\n\n // A few fixed stars, blinking very slowly. Hash on the cell, so they sit\n // still while the rain falls through them.\n let star = st_hash21(cell * 0.73);\n if (star > 0.9975 && puv.y < 0.45) {\n col = mix(params.colorA.rgb, vec3f(0.9), 0.35 + 0.3 * step(0.5, fract(star * 100.0 + tt * 0.2)));\n }\n\n // The moon: a hard disc, one shade, one shadow cell ring.\n let moonPos = vec2f(0.30 * st_aspect(), -0.30);\n let moonDist = length((puv - vec2f(0.5)) * vec2f(st_aspect(), 1.0) - moonPos);\n if (moonDist < 0.055) {\n col = vec3f(0.87, 0.89, 0.90);\n // A crescent: the shadow side takes the sky back.\n if (length((puv - vec2f(0.5)) * vec2f(st_aspect(), 1.0) - moonPos - vec2f(0.022, -0.012)) < 0.048) {\n col = params.colorA.rgb * 0.72;\n }\n }\n\n // Two ranks of buildings. Blocks are hashed by column group; the far rank\n // is drawn first and hazed towards the sky.\n var nearTop = 2.0; // the near roofline this pixel falls under, for antennas\n for (var layer = 0; layer < 2; layer = layer + 1) {\n let fl = f32(layer); // 0 far, 1 near\n let blockW = floor(mix(5.0, 8.0, fl));\n let block = floor((cell.x + fl * 37.0) / blockW);\n let rand = st_hash11(block + fl * 101.0);\n let topRow = cells.y * (mix(0.34, 0.52, fl) + rand * 0.28);\n\n if (cell.y > topRow) {\n col = mix(params.colorA.rgb * 0.38, params.colorA.rgb * 0.10, fl);\n\n // Windows: a sparse lattice, dim on the far rank, lit on the near.\n let wx = i32(cell.x) % 3;\n let wy = i32(cell.y) % 4;\n if (wx == 1 && wy == 2 && cell.y > topRow + 2.0) {\n let room = vec2f(floor(cell.x / 3.0), floor(cell.y / 4.0) + fl * 53.0);\n // A room's light toggles rarely — someone getting up, not a strobe.\n let awake = st_hash21(room + floor(tt * 0.25) * 5.0);\n if (awake > 1.0 - 0.3 * params.c) {\n col = mix(params.colorC.rgb * 0.4, params.colorC.rgb, fl);\n }\n }\n }\n if (layer == 1) {\n nearTop = topRow;\n\n // An antenna on the taller blocks: a one-cell mast with a slow red\n // blink at the tip.\n let mastX = block * blockW + floor(blockW * 0.5);\n if (rand > 0.62 && cell.x == mastX && cell.y <= topRow && cell.y > topRow - 4.0) {\n col = params.colorA.rgb * 0.16;\n if (cell.y > topRow - 2.0 && fract(tt * 0.5 + rand) < 0.25) {\n col = vec3f(0.85, 0.25, 0.25);\n }\n }\n }\n }\n\n // Rain: each sheared column owns a few drops, falling whole cells per\n // tick — a bright head cell trailing a dimmer one. The shear is the wind.\n let column = floor(cell.x + cell.y * params.d);\n for (var drop = 0; drop < 2; drop = drop + 1) {\n let rand = st_hash11(column * 2.7 + f32(drop) * 17.9);\n if (rand < clamp(params.b, 0.0, 1.0)) {\n let speed = 1.6 + rand * 1.4;\n let head = fract(rand * 7.13 + f32(drop) * 0.41 - tt * speed);\n let rows = fract(puv.y - head);\n if (rows < 1.2 / cells.y) {\n col = mix(params.colorB.rgb, vec3f(1.0), 0.25);\n } else if (rows < 3.0 / cells.y) {\n col = mix(col, params.colorB.rgb, 0.75);\n }\n }\n }\n\n // The street: a wet band at the foot of the frame, shimmering where drops\n // land. The shimmer re-rolls per tick, which reads as splashes.\n if (cell.y > cells.y - 3.0) {\n col = params.colorA.rgb * 0.55;\n let splash = st_hash21(vec2f(cell.x, floor(tt * 10.0)));\n if (splash > 1.0 - 0.14 * params.b) {\n col = mix(params.colorB.rgb, vec3f(1.0), 0.35);\n }\n // The city's light pools faintly on the wet asphalt.\n let room = st_hash11(floor(cell.x / 3.0));\n if (room > 0.55 && i32(cell.x) % 3 == 1) {\n col = mix(col, params.colorC.rgb, 0.22 * params.c);\n }\n }\n\n return st_post(col * params.intensity, uv);\n}\n";
45
49
  readonly "pixel-sea": "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// pixel-sea\n// ---------------------------------------------------------------------------\n\n// Pixel Sea — a 16-bit sea: dithered sky, low sun, and its path on the water.\n//\n// A proper seascape rather than abstract bands: horizon in the upper third,\n// perspective rows that pack tighter as they recede, and the sun's\n// reflection breaking into dashes on the swell. Everything is sampled at\n// cell centres and quantised to stops derived from the colour wells; the\n// clock ticks at a sprite-sheet rate so nothing moves by a fraction of a cell.\n\nfn bayer4(cell : vec2f) -> f32 {\n let x = i32(cell.x) & 3;\n let y = i32(cell.y) & 3;\n var index = 0;\n for (var i = 0u; i < 2u; i = i + 1u) {\n let shift = 1u - i;\n index = (index << 1u) | ((y >> shift) & 1);\n index = (index << 1u) | (((x ^ y) >> shift) & 1);\n }\n return (f32(index) + 0.5) / 16.0;\n}\n\n// Stops: 0 sky top, 1 sky horizon, 2 sun, 3 deep, 4 mid, 5 light, 6 glint —\n// derived from the three wells: colorA the sky, colorB the water, colorC the\n// sun, with the horizon warmed toward the sun the way haze warms it.\nfn seaRamp() -> array<vec3f, 7> {\n let sky = params.colorA.rgb;\n let water = params.colorB.rgb;\n let sun = params.colorC.rgb;\n var c : array<vec3f, 7>;\n c[0] = sky;\n c[1] = mix(sky, sun, 0.5) * 1.08;\n c[2] = sun;\n c[3] = water * vec3f(0.40, 0.44, 0.60);\n c[4] = water * vec3f(0.76, 0.79, 0.94);\n c[5] = mix(water, c[1], 0.42);\n c[6] = mix(sun, vec3f(1.0), 0.55);\n return c;\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let px = st_pixelSize(params.a);\n let cell = floor(uv * params.resolution / px);\n let puv = (cell + 0.5) * px / params.resolution;\n\n let tt = floor(st_time() * 8.0) / 8.0;\n let ramp = seaRamp();\n\n let horizon = 0.34;\n let aspect = st_aspect();\n let sunX = 0.5;\n let threshold = bayer4(cell) - 0.5;\n\n var col : vec3f;\n if (puv.y < horizon) {\n // Sky: dithered bands, brightest at the horizon.\n let skyT = puv.y / horizon;\n let band = clamp(floor(skyT * 4.0 + 0.5 + threshold * params.d), 0.0, 4.0);\n col = mix(ramp[0], ramp[1], band / 4.0);\n\n // The sun sits just above the water, a hard disc with a dithered halo.\n let sd = length((puv - vec2f(sunX, horizon - 0.09)) * vec2f(aspect, 1.0));\n if (sd < 0.055 * params.c) {\n col = ramp[2];\n } else if (sd < 0.055 * params.c * (1.0 + bayer4(cell + vec2f(1.0, 3.0)))) {\n col = mix(col, ramp[2], 0.8);\n }\n } else {\n // The sea. Distance from the horizon is the perspective proxy: rows\n // pack tighter and waves get finer as dist shrinks.\n let dist = puv.y - horizon;\n let z = 0.05 / max(dist, 0.004);\n let xw = (puv.x - 0.5) * aspect * z;\n\n // Each row of cells shears on its own phase — the displaced-scanline\n // trick — and a wave field decides which cells catch light.\n let shear = sin(cell.y * 0.9 + tt * 1.2) * (1.5 * px / params.resolution.x);\n let wave = st_noise2(vec2f((xw + shear * z * 40.0) * 2.4 * params.b, z * 1.7 * params.b - tt * 0.55));\n\n // Base water: three stops, lighter toward the horizon.\n let depthBand = clamp(floor(smoothstep(0.66, 0.0, dist) * 2.0 + 0.5 + threshold * params.d), 0.0, 2.0);\n col = ramp[u32(depthBand) + 3u];\n\n // Wave rows: cells the swell lifts take the next stop up.\n if (wave > 0.62 + threshold * 0.14) {\n col = ramp[u32(min(depthBand + 1.0, 2.0)) + 3u];\n }\n\n // The sun's path: a column that widens toward the viewer, its light\n // broken into dashes by the same swell.\n let halfWidth = 0.015 + 0.06 * dist;\n let inPath = abs(puv.x - sunX) / aspect < halfWidth * (0.7 + 0.6 * bayer4(cell + vec2f(2.0, 1.0)));\n if (inPath && wave > 0.52 + dist * 0.45 + threshold * 0.1) {\n col = ramp[2];\n }\n\n // Rare single-cell glints outside the path, re-rolled slowly.\n let glint = st_hash21(cell + floor(tt * 5.0) * 7.0);\n if (glint > 0.9965 && wave > 0.55) {\n col = ramp[6];\n }\n }\n\n return st_post(col * params.intensity, uv);\n}\n";
46
50
  readonly plasma: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// plasma\n// ---------------------------------------------------------------------------\n\n// Plasma — stacked sine fields, the oldest trick in the demoscene.\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let p = st_centered(uv) * params.scale * 6.0;\n let t = st_time();\n\n var v = 0.0;\n v = v + sin(p.x * 1.1 + t);\n v = v + sin(p.y * 1.3 + t * 1.17);\n v = v + sin((p.x + p.y) * 0.8 + t * 0.83);\n v = v + sin(length(p * params.a) * 1.4 - t * 1.6);\n\n // Rotating secondary field adds the interference bloom.\n let q = st_rot(t * 0.15 * params.b) * p;\n v = v + sin(q.x * 0.9 - q.y * 1.4 + t * 0.6) * params.c;\n\n let band = v * 0.125 + 0.5 + t * 0.05;\n var col = st_paletteCyclic(band * params.d);\n\n // Contour lines pick out the field's iso-levels.\n let contour = abs(fract(v * 0.9) - 0.5) * 2.0;\n col = col * (1.0 - smoothstep(0.85, 1.0, contour) * 0.55);\n\n return st_post(col * params.intensity, uv);\n}\n";
51
+ readonly pulsar: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// shadertown graph prelude — prepended (after the shared prelude) to every\n// graph shader at build time.\n//\n// A graph shader is not one pass but a whole render graph in a single module:\n// a displacement field, a normal pass, an instanced particle draw, an\n// Unreal-style bloom chain, and a present pass, each its own entry point. The\n// runtime (src/graph.ts) drives them with `draw()` and explicit entry names;\n// `effect()` picks the first vertex and fragment entries, which is why\n// `vs_fullscreen` and `fs_main` (the present pass) are declared first.\n//\n// The variant file below this prelude supplies exactly one function:\n//\n// fn st_field(p : vec2f, t : f32) -> vec4f\n//\n// `p` is the world-space position of a grid cell (±worldSize/2), `t` is\n// `st_time()`. The return value is displacement xyz in field units (scaled to\n// world units by `gu.world.y`) plus an aux channel in w — the shader's own\n// meaning of \"foam\": whatever the accent colour should land on.\n//\n// The pass structure, bloom chain and camera are ported from the vgpu\n// `fft-ocean` (\"Particles ocean\") example and keep its behaviour: additive\n// particles into an HDR scene, five blurred octaves recombined, linear to\n// sRGB on the way out.\n\nstruct GraphUniforms {\n view : mat4x4f,\n projection : mat4x4f,\n // canvas width, canvas height, point scale, grid resolution\n viewport : vec4f,\n // world size, height scale, point size, texel spacing (world / grid)\n world : vec4f,\n // fade near, fade far, fade power, unused\n fade : vec4f,\n // eye y, eye z, view pitch (radians), projection focal (1 / tan(fov / 2))\n camera : vec4f,\n}\n\nstruct BlurUniforms {\n direction : vec2f,\n invSize : vec2f,\n coeffs0 : vec4f,\n coeffs1 : vec4f,\n coeffs2 : vec4f,\n coeffs3 : vec4f,\n coeffs4 : vec4f,\n coeffs5 : vec4f,\n}\n\nstruct BloomUniforms {\n threshold : f32,\n smoothWidth : f32,\n strength : f32,\n radius : f32,\n factors0 : vec4f,\n factors1 : vec4f,\n}\n\n// Bindings are declared once for the whole module; vgpu builds layouts per\n// entry point, so each pass only owes the bindings it actually reads.\n@group(0) @binding(1) var<uniform> gu : GraphUniforms;\n@group(0) @binding(2) var<uniform> blurU : BlurUniforms;\n@group(0) @binding(3) var<uniform> bloomU : BloomUniforms;\n@group(0) @binding(4) var t_field : texture_2d<f32>;\n@group(0) @binding(5) var t_normal : texture_2d<f32>;\n@group(0) @binding(6) var t_scene : texture_2d<f32>;\n@group(0) @binding(7) var t_input : texture_2d<f32>;\n@group(0) @binding(8) var t_blur1 : texture_2d<f32>;\n@group(0) @binding(9) var t_blur2 : texture_2d<f32>;\n@group(0) @binding(10) var t_blur3 : texture_2d<f32>;\n@group(0) @binding(11) var t_blur4 : texture_2d<f32>;\n@group(0) @binding(12) var t_blur5 : texture_2d<f32>;\n@group(0) @binding(13) var t_bloom : texture_2d<f32>;\n@group(0) @binding(14) var linearSampler : sampler;\n\n// --- the pointer, in the world --------------------------------------------\n// The camera is fixed, so the pointer can be unprojected analytically: undo\n// the projection's focal scaling, undo the view's pitch, and the uv the\n// runtime delivers becomes a ray from the eye into the scene. Variants\n// intersect it with whatever surface they draw on.\n\nstruct PointerRay {\n origin : vec3f,\n dir : vec3f,\n}\n\n// The ray through any point of the frame, uv in 0..1 from the top left. Taking\n// the position rather than reading `params.pointer` lets a variant aim at a\n// place in the frame it picks itself.\nfn st_screenRay(uv : vec2f) -> PointerRay {\n let aspect = gu.viewport.x / max(gu.viewport.y, 1.0);\n let f = gu.camera.w;\n let ndc = vec2f(uv.x * 2.0 - 1.0, (1.0 - uv.y) * 2.0 - 1.0);\n let viewDir = normalize(vec3f(ndc.x * aspect / f, ndc.y / f, -1.0));\n // The view rotates the world by `camera.z` about x; the ray rotates back.\n let c = cos(gu.camera.z);\n let s = sin(gu.camera.z);\n var ray : PointerRay;\n ray.origin = vec3f(0.0, gu.camera.x, gu.camera.y);\n ray.dir = vec3f(viewDir.x, c * viewDir.y + s * viewDir.z, -s * viewDir.y + c * viewDir.z);\n return ray;\n}\n\nfn st_pointerRay() -> PointerRay {\n return st_screenRay(params.pointer);\n}\n\n// Where a point of the frame lands on the y = 0 water plane, in world xz.\nfn st_screenOnPlane(uv : vec2f) -> vec2f {\n let ray = st_screenRay(uv);\n let t = -ray.origin.y / min(ray.dir.y, -0.02);\n let hit = ray.origin + ray.dir * t;\n let half = gu.world.x * 0.5;\n return clamp(hit.xz, vec2f(-half), vec2f(half));\n}\n\n// Where the pointer lands on the y = 0 water plane, in world xz.\nfn st_pointerOnPlane() -> vec2f {\n return st_screenOnPlane(params.pointer);\n}\n\n// Where the pointer lands on the vertical plane z = depth, in world xy.\nfn st_pointerOnDepth(depth : f32) -> vec2f {\n let ray = st_pointerRay();\n let t = (depth - ray.origin.z) / min(ray.dir.z, -0.02);\n let hit = ray.origin + ray.dir * t;\n let half = gu.world.x * 0.5;\n return vec2f(clamp(hit.x, -half, half), clamp(hit.y, -20.0, 120.0));\n}\n\nstruct FullscreenOut {\n @builtin(position) position : vec4f,\n @location(0) uv : vec2f,\n}\n\n// Same triangle and uv orientation vgpu generates for a plain effect, so the\n// fullscreen fragment entries below read uv exactly like every other shader.\n@vertex fn vs_fullscreen(@builtin(vertex_index) vi : u32) -> FullscreenOut {\n var pos = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));\n var uv = array<vec2f, 3>(vec2f(0.0, 1.0), vec2f(2.0, 1.0), vec2f(0.0, -1.0));\n var out : FullscreenOut;\n out.position = vec4f(pos[vi], 0.0, 1.0);\n out.uv = uv[vi];\n return out;\n}\n\n// --- present ---------------------------------------------------------------\n// First fragment entry on purpose: the runtime's `effect()` for this shader\n// resolves to it, and the Stage drives it like any other shader's fragment.\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let scene = textureSample(t_scene, linearSampler, uv);\n let bloom = textureSample(t_bloom, linearSampler, uv);\n let linear = max(scene.rgb + bloom.rgb, vec3f(0.0));\n let lt = linear * 12.92;\n let gt = 1.055 * pow(linear, vec3f(0.41666)) - vec3f(0.055);\n let srgb = select(gt, lt, linear <= vec3f(0.0031308));\n return st_post(srgb, uv);\n}\n\n// --- displacement field ------------------------------------------------------\n\n@fragment fn fs_field(@location(0) uv : vec2f) -> @location(0) vec4f {\n let p = (uv - vec2f(0.5)) * gu.world.x;\n return st_field(p, st_time());\n}\n\n// --- normals ----------------------------------------------------------------\n\nfn st_loadField(coord : vec2i) -> vec4f {\n let n = i32(gu.viewport.w);\n let c = clamp(coord, vec2i(0), vec2i(n - 1, n - 1));\n return textureLoad(t_field, c, 0);\n}\n\n@fragment fn fs_normals(@builtin(position) position : vec4f) -> @location(0) vec4f {\n let coord = vec2i(position.xy - vec2f(0.5));\n let dx = gu.world.w;\n let s = gu.world.y;\n let r = st_loadField(coord + vec2i(1, 0));\n let l = st_loadField(coord - vec2i(1, 0));\n let t = st_loadField(coord + vec2i(0, 1));\n let b = st_loadField(coord - vec2i(0, 1));\n let dhdx = (r.y - l.y) * s / (2.0 * dx);\n let dhdz = (t.y - b.y) * s / (2.0 * dx);\n let normal = normalize(vec3f(-dhdx, 1.0, -dhdz));\n return vec4f(normal, st_loadField(coord).w);\n}\n\n// --- particles ----------------------------------------------------------------\n\nstruct ParticleOut {\n @builtin(position) position : vec4f,\n @location(0) pointCoord : vec2f,\n @location(1) aux : f32,\n @location(2) normal : vec3f,\n @location(3) viewDir : vec3f,\n @location(4) height : f32,\n @location(5) fade : f32,\n}\n\nfn st_quadCorner(vertexIndex : u32) -> vec2f {\n var corners = array<u32, 6>(0u, 1u, 2u, 2u, 1u, 3u);\n let cornerIndex = corners[vertexIndex % 6u];\n switch (cornerIndex) {\n case 0u: { return vec2f(-1.0, -1.0); }\n case 1u: { return vec2f( 1.0, -1.0); }\n case 2u: { return vec2f(-1.0, 1.0); }\n default: { return vec2f( 1.0, 1.0); }\n }\n}\n\n@vertex fn vs_particles(\n @builtin(vertex_index) vertexIndex : u32,\n @builtin(instance_index) instanceIndex : u32,\n) -> ParticleOut {\n let resolution = max(1u, u32(gu.viewport.w));\n let i = instanceIndex % resolution;\n let j = instanceIndex / resolution;\n let particleRef = vec2f(f32(i), f32(j)) / f32(resolution);\n let texCoord = vec2u(i, j);\n\n let raw = textureLoad(t_field, texCoord, 0);\n let disp = raw.xyz * gu.world.y;\n let nf = textureLoad(t_normal, texCoord, 0);\n\n let halfWorld = gu.world.x * 0.5;\n let base = vec3f(\n particleRef.x * gu.world.x - halfWorld,\n 0.0,\n particleRef.y * gu.world.x - halfWorld,\n );\n let pos = base + disp;\n\n let mv = gu.view * vec4f(pos, 1.0);\n let viewDir = -mv.xyz;\n let dist = -mv.z;\n let f = 1.0 - smoothstep(gu.fade.x, gu.fade.y, dist);\n let fade = pow(clamp(f, 0.0, 1.0), gu.fade.z);\n\n let projected = gu.projection * mv;\n let ndc = projected.xy / projected.w;\n\n let corner = st_quadCorner(vertexIndex);\n let pointSizePx = 2.0 * gu.world.z * gu.viewport.z;\n let clipOffset = corner * (pointSizePx / gu.viewport.xy) * projected.w;\n let clip = vec4f(ndc * projected.w + clipOffset, projected.z, projected.w);\n\n var out : ParticleOut;\n out.position = clip;\n out.pointCoord = corner * 0.5 + vec2f(0.5);\n out.aux = nf.w;\n out.normal = nf.xyz;\n out.viewDir = viewDir;\n out.height = raw.y;\n out.fade = fade;\n return out;\n}\n\n@fragment fn fs_particles(in : ParticleOut) -> @location(0) vec4f {\n let cc = in.pointCoord - vec2f(0.5);\n if (dot(cc, cc) > 0.25) { discard; }\n\n let n = normalize(in.normal);\n let v = normalize(in.viewDir);\n let fresnel = pow(1.0 - clamp(dot(n, v), 0.0, 1.0), 5.0);\n\n let aux = clamp(in.aux, 0.0, 1.0);\n let crest = smoothstep(-0.5, 1.5, in.height);\n\n var color = params.colorA.rgb * 0.5;\n color = color + params.colorB.rgb * crest * 0.5;\n color = color + params.colorB.rgb * fresnel * 0.15;\n color = mix(color, params.colorC.rgb, aux);\n\n // The source example exposed 512² splats; this grid is 256², so each of the\n // quarter as many splats carries four times the weight to match.\n var alpha = (0.02 + crest * 0.06 + fresnel * 0.04) * 4.0;\n alpha = mix(alpha, 1.0, aux);\n color = color * in.fade * params.intensity;\n alpha = clamp(alpha * in.fade, 0.0, 1.0);\n return vec4f(color, alpha);\n}\n\n// --- bloom -------------------------------------------------------------------\n\nfn st_luminance(rgb : vec3f) -> f32 {\n return dot(rgb, vec3f(0.299, 0.587, 0.114));\n}\n\n@fragment fn fs_bright(@location(0) uv : vec2f) -> @location(0) vec4f {\n // LuminosityHighPassShader, as ported by the fft-ocean example.\n let texel = textureSample(t_scene, linearSampler, uv);\n let gate = smoothstep(bloomU.threshold, bloomU.threshold + bloomU.smoothWidth, st_luminance(texel.xyz));\n return mix(vec4f(vec3f(0.0), 0.0), texel, gate);\n}\n\nconst ST_BLUR_RADIUS : u32 = 22u;\n\nfn st_blurCoefficient(i : u32) -> f32 {\n let packed = array<vec4f, 6>(\n blurU.coeffs0, blurU.coeffs1, blurU.coeffs2,\n blurU.coeffs3, blurU.coeffs4, blurU.coeffs5,\n );\n return packed[i / 4u][i % 4u];\n}\n\n@fragment fn fs_blur(@location(0) uv : vec2f) -> @location(0) vec4f {\n // UnrealBloomPass separable blur: coefficients past each level's radius are\n // zero, so one fixed loop serves all five levels.\n var weightSum = st_blurCoefficient(0u);\n var diffuseSum = textureSample(t_input, linearSampler, uv).rgb * weightSum;\n for (var i = 1u; i < ST_BLUR_RADIUS; i = i + 1u) {\n let w = st_blurCoefficient(i);\n let offset = blurU.direction * blurU.invSize * f32(i);\n let s1 = textureSample(t_input, linearSampler, uv + offset).rgb;\n let s2 = textureSample(t_input, linearSampler, uv - offset).rgb;\n diffuseSum = diffuseSum + (s1 + s2) * w;\n }\n return vec4f(diffuseSum, 1.0);\n}\n\nfn st_bloomFactor(i : u32) -> f32 {\n let v = array<f32, 8>(\n bloomU.factors0.x, bloomU.factors0.y, bloomU.factors0.z, bloomU.factors0.w,\n bloomU.factors1.x, bloomU.factors1.y, bloomU.factors1.z, bloomU.factors1.w,\n );\n let mirror = 1.2 - v[i];\n return mix(v[i], mirror, bloomU.radius);\n}\n\n@fragment fn fs_composite(@location(0) uv : vec2f) -> @location(0) vec4f {\n // UnrealBloomPass composite, tint colours all white.\n let bloom = 3.0 * bloomU.strength * (\n st_bloomFactor(0u) * textureSample(t_blur1, linearSampler, uv).rgb +\n st_bloomFactor(1u) * textureSample(t_blur2, linearSampler, uv).rgb +\n st_bloomFactor(2u) * textureSample(t_blur3, linearSampler, uv).rgb +\n st_bloomFactor(3u) * textureSample(t_blur4, linearSampler, uv).rgb +\n st_bloomFactor(4u) * textureSample(t_blur5, linearSampler, uv).rgb\n );\n return vec4f(bloom, max(bloom.r, max(bloom.g, bloom.b)));\n}\n\n// ---------------------------------------------------------------------------\n// pulsar\n// ---------------------------------------------------------------------------\n\n// Pulsar — a breathing globe of particles that leans toward your cursor.\n//\n// The grid gives up being a floor entirely: columns become longitude, rows\n// become latitude, and every particle is lifted off the sea plane onto a\n// sphere hanging in the dark. The surface carries a slow relief of 3D noise\n// sampled on the sphere itself — no seams, because the noise never sees the\n// uv wrap — and bands of light sweep the latitudes like a lighthouse turned\n// inside out. The pointer is a gentle gravity: wherever the cursor sits, the\n// whole globe drifts that way and stretches into a soft teardrop, brightest\n// on the side being pulled.\n\n// The centre sits below the frame, so what shows is the upper hemisphere —\n// a planet rising into the picture rather than a ball floating in it.\nconst PULSAR_CENTER : vec3f = vec3f(0.0, -12.0, 38.0);\nconst PULSAR_RADIUS : f32 = 42.0;\n\nfn st_field(p : vec2f, t : f32) -> vec4f {\n // Column → longitude (wrapping), row → latitude.\n let lon = (p.x / gu.world.x + 0.5) * TAU + t * (0.1 + params.d * 0.25);\n let lat = (p.y / gu.world.x + 0.5) * PI;\n let n = vec3f(sin(lat) * cos(lon), cos(lat), sin(lat) * sin(lon));\n\n let radius = PULSAR_RADIUS * params.scale;\n\n // Breath: the whole globe swells and settles.\n var r = radius * (1.0 + 0.05 * params.c * sin(t * 1.3));\n\n // Relief: noise on the sphere, drifting through itself, so the surface\n // boils slowly instead of scrolling.\n let relief = st_fbm3(n * 2.6 + vec3f(0.0, t * 0.1, 0.0), 4);\n r = r + (relief - 0.5) * radius * 0.28 * params.b;\n\n // The attraction: the point on the pointer's ray nearest the globe is\n // where the cursor \"is\" in the globe's space. The whole body drifts that\n // way, capped so it leans rather than leaves, and the surface facing the\n // pull stretches into a teardrop.\n let ray = st_pointerRay();\n let along = max(dot(PULSAR_CENTER - ray.origin, ray.dir), 0.0);\n let cursor = ray.origin + ray.dir * along;\n let delta = cursor - PULSAR_CENTER;\n let dist = length(delta);\n let pullDir = delta / max(dist, 0.001);\n // Full lean once the cursor is a couple of radii away; dead centre is rest.\n let lean = min(dist / (radius * 2.0), 1.0) * params.warp;\n let facing = max(dot(n, pullDir), 0.0);\n r = r + facing * facing * radius * 0.1 * lean;\n let drift = pullDir * radius * 0.12 * lean;\n\n // Bands: light sweeping the latitudes, sharpened to streaks, broken up by\n // the relief so they read as weather rather than as rings.\n let sweep = sin(lat * (3.0 + params.a * 5.0) + t * 1.6 + relief * 3.0);\n let bands = pow(clamp(sweep * 0.5 + 0.5, 0.0, 1.0), 6.0);\n\n // The pulled side glows, so the attraction is readable even mid-lean.\n // Capped low: sixty-five thousand additive splats share a small circle of\n // screen here, and full-strength aux would burn the core to white.\n let aux = clamp(bands * 0.45 + facing * facing * lean * 0.35 + (relief - 0.5) * 0.25, 0.0, 0.6);\n\n let shell = PULSAR_CENTER + drift + n * r;\n let s = gu.world.y;\n return vec4f(shell.x - p.x, shell.y, shell.z - p.y, aux) / vec4f(s, s, s, 1.0);\n}\n";
47
52
  readonly radiance: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// radiance\n// ---------------------------------------------------------------------------\n\n// Radiance — light traced from where it lands back to whatever emitted it.\n//\n// Global illumination taken the short way round. There is no light pass and no\n// probe grid to merge: the room is four signed distances, so every pixel can\n// fan rays into it and march each one to its source inside the same fragment\n// that needs the answer. What that buys is a true shadow — hard where a lamp is\n// small, soft where it is near, and cast by geometry that moves every frame.\n\nstruct Surface {\n dist : f32,\n emit : vec3f,\n}\n\nfn radDisc(p : vec2f, centre : vec2f, radius : f32) -> f32 {\n return length(p - centre) - radius;\n}\n\nfn radBar(p : vec2f, centre : vec2f, half : vec2f, angle : f32) -> f32 {\n let q = abs(st_rot(angle) * (p - centre)) - half;\n return length(max(q, vec2f(0.0))) + min(max(q.x, q.y), 0.0);\n}\n\nfn radNearer(a : Surface, b : Surface) -> Surface {\n if (b.dist < a.dist) { return b; }\n return a;\n}\n\n// Three drifting lamps, the pointer, and three turning bars that emit nothing.\n// A ray that lands on a bar comes back with black, which is the whole of the\n// shadowing — no shadow term, just an emitter that is not there.\nfn radScene(p : vec2f, t : f32, walls : f32) -> Surface {\n var hit = Surface(1e9, vec3f(0.0));\n\n for (var i = 0; i < 3; i = i + 1) {\n let fi = f32(i);\n let angle = t * (0.23 + fi * 0.11) + fi * 2.094;\n let centre = vec2f(cos(angle) * (0.34 - fi * 0.07), sin(angle * 1.4) * (0.24 - fi * 0.05));\n let lamp = Surface(\n radDisc(p, centre, 0.016 + fi * 0.007),\n st_palette(fi * 0.5) * (3.4 - fi * 0.7),\n );\n hit = radNearer(hit, lamp);\n }\n\n // The pointer is the brightest lamp in the room, and it sits in the middle\n // until something moves it — the still frame has to stand on its own.\n let cursor = vec2f((params.pointer.x - 0.5) * st_aspect(), params.pointer.y - 0.5);\n hit = radNearer(hit, Surface(radDisc(p, cursor, 0.028), st_palette(1.0) * 4.2));\n\n if (walls > 0.001) {\n for (var i = 0; i < 3; i = i + 1) {\n let fi = f32(i);\n let orbit = fi * 2.094 - t * 0.17;\n let centre = vec2f(cos(orbit), sin(orbit)) * 0.21;\n let bar = radBar(p, centre, vec2f(0.17, 0.012) * walls, orbit * 1.7 + fi);\n hit = radNearer(hit, Surface(bar, vec3f(0.0)));\n }\n }\n\n return hit;\n}\n\n// Jimenez's interleaved gradient noise. Turning every pixel's fan by the same\n// angle bands the penumbrae into spokes, and the obvious fixes trade that for\n// something worse: white noise clumps, and a low-discrepancy sequence lays the\n// offset down in diagonal stripes. This one was built for exactly this job.\nfn radIgn(pixel : vec2f) -> f32 {\n return fract(52.9829189 * fract(dot(pixel, vec2f(0.06711056, 0.00583715))));\n}\n\n// One ray, marched until it meets something or runs out of room. `start` skips\n// the surface the pixel is already standing on, so a lamp does not shadow\n// itself into a black disc.\nfn radTrace(origin : vec2f, dir : vec2f, start : f32, t : f32, reach : f32, walls : f32) -> vec3f {\n var travel = start;\n for (var i = 0; i < 26; i = i + 1) {\n let hit = radScene(origin + dir * travel, t, walls);\n if (hit.dist < 0.0025) {\n // Inverse square, offset by one so a pixel touching a lamp does not\n // divide by nothing and blow the frame out.\n let fall = travel / max(reach, 0.02);\n return hit.emit / (1.0 + fall * fall);\n }\n travel = travel + max(hit.dist, 0.004);\n if (travel > 3.0) { break; }\n }\n return vec3f(0.0);\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let t = st_time();\n let p = st_centered(uv) / max(params.scale, 0.05);\n\n let rays = i32(clamp(params.a, 3.0, 48.0));\n let walls = params.c;\n\n let cellId = floor(uv * params.resolution);\n let dither = radIgn(cellId);\n let turn = dither * TAU;\n\n let standing = radScene(p, t, walls);\n // Staggering where the rays begin as well as where they point breaks up the\n // ring the shared start distance would otherwise leave around every lamp.\n let start = max(-standing.dist, 0.0) + 0.004 + dither * 0.006;\n\n var sum = vec3f(0.0);\n for (var i = 0; i < rays; i = i + 1) {\n // A third of a wedge of slop on top of the ordered offset. Full white\n // noise per ray throws the stratification away and the frame goes\n // sandy; this much only breaks the pattern's grip on the lattice.\n let slop = (st_hash21(cellId + f32(i) * 17.31) - 0.5) * 0.35;\n let angle = turn + TAU * (f32(i) + 0.5 + slop) / f32(rays);\n sum = sum + radTrace(p, vec2f(cos(angle), sin(angle)), start, t, params.b, walls);\n }\n\n var col = sum / f32(rays) * params.intensity;\n\n // A lamp lights its own pixels. Without this the rays all leave from its\n // surface facing outwards, and the brightest thing in the room is a hole.\n col = col + standing.emit * (1.0 - smoothstep(-0.002, 0.002, standing.dist));\n\n // Skylight: the floor of the image, so the bars read as objects in a room\n // rather than as holes cut out of it.\n col = col + params.colorA.rgb * params.d;\n\n return st_post(st_tonemap(col), uv);\n}\n";
48
53
  readonly reveal: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// shadertown source prelude — appended to the shared prelude for shaders that\n// read a layer instead of inventing one.\n//\n// Every binding here is additive. A generative shader declares none of them and\n// keeps the exact layout it always had, and `Source` is its own uniform block\n// rather than four more fields on `Params`, so the ABI that every already\n// shipped shader was built against never moves.\n\n@group(0) @binding(1) var srcTexture : texture_2d<f32>;\n@group(0) @binding(2) var srcSampler : sampler;\n\nstruct Source {\n // Pixel size of the bound texture. Zero means \"assume it matches the canvas\".\n size : vec2f,\n // 0 stretches the source over the canvas, 1 covers the canvas and crops.\n fit : f32,\n _pad : f32,\n}\n\n@group(0) @binding(3) var<uniform> source : Source;\n\n// How much of the source each axis shows once it is fitted. Both components are\n// 1 or less: covering means cropping, never adding.\nfn st_srcScale() -> vec2f {\n if (source.fit < 0.5 || source.size.x < 1.0 || source.size.y < 1.0) {\n return vec2f(1.0);\n }\n let canvas = st_aspect();\n let src = source.size.x / source.size.y;\n if (src > canvas) {\n return vec2f(canvas / src, 1.0);\n }\n return vec2f(1.0, src / canvas);\n}\n\n/** Canvas uv to source uv. */\nfn st_srcUv(uv : vec2f) -> vec2f {\n return (uv - 0.5) * st_srcScale() + 0.5;\n}\n\n/**\n * Canvas uv to source uv, with room left around the edge.\n *\n * A shader that displaces its reads runs off the source at the border, and the\n * sampler answers by smearing its last row of texels across the gap. Reading a\n * slightly smaller crop gives the displacement somewhere to go, at the cost of\n * a few per cent of the picture.\n */\nfn st_srcUvInset(uv : vec2f, margin : f32) -> vec2f {\n return (uv - 0.5) * st_srcScale() * (1.0 - clamp(margin, 0.0, 0.4)) + 0.5;\n}\n\n/**\n * A canvas-space displacement, in source uv.\n *\n * Divides out the aspect so a push of 0.01 moves the same distance sideways as\n * it does up, whatever shape the canvas or the source is.\n */\nfn st_srcWarp(offset : vec2f) -> vec2f {\n return offset * st_srcScale() / vec2f(st_aspect(), 1.0);\n}\n\n/** Samples in source uv. Kernels that walk neighbours want this one. */\nfn st_srcAt(srcUv : vec2f) -> vec4f {\n return textureSample(srcTexture, srcSampler, srcUv);\n}\n\n/** Samples in canvas uv — the ordinary read. */\nfn st_src(uv : vec2f) -> vec4f {\n return st_srcAt(st_srcUv(uv));\n}\n\nfn st_srcRgb(uv : vec2f) -> vec3f {\n return st_src(uv).rgb;\n}\n\n/** One source texel, in source uv. */\nfn st_srcTexel() -> vec2f {\n return 1.0 / max(source.size, vec2f(1.0));\n}\n\n/**\n * 1 inside the source, falling to 0 just outside it.\n *\n * The sampler clamps, so a shader that pushes past the border smears the last\n * row of texels across the gap. Anything that displaces should fade instead.\n */\nfn st_srcInside(srcUv : vec2f) -> f32 {\n let edge = min(srcUv, vec2f(1.0) - srcUv);\n return smoothstep(0.0, 0.004, min(edge.x, edge.y));\n}\n\n/** Four-tap softening. Cheap enough to run per colour channel. */\nfn st_srcSoften(uv : vec2f, radius : f32) -> vec3f {\n let base = st_srcUv(uv);\n var sum = vec3f(0.0);\n for (var i = 0; i < 4; i = i + 1) {\n let angle = f32(i) * 1.570796 + 0.785398;\n sum = sum + st_srcAt(base + st_srcWarp(vec2f(cos(angle), sin(angle)) * radius)).rgb;\n }\n return sum * 0.25;\n}\n\n/**\n * Thirteen-tap spiral blur. `radius` is in canvas space.\n *\n * The golden angle keeps the taps from lining up into spokes, which is what a\n * uniform ring blur gives you at low tap counts.\n */\nfn st_srcBlur(uv : vec2f, radius : f32) -> vec3f {\n let base = st_srcUv(uv);\n var sum = st_srcAt(base).rgb;\n for (var i = 0; i < 12; i = i + 1) {\n let fi = f32(i);\n let angle = fi * 2.399963;\n let r = radius * sqrt((fi + 0.5) / 12.0);\n sum = sum + st_srcAt(base + st_srcWarp(vec2f(cos(angle), sin(angle)) * r)).rgb;\n }\n return sum / 13.0;\n}\n\n// ---------------------------------------------------------------------------\n// reveal\n// ---------------------------------------------------------------------------\n\n// Reveal — the layer wiped in over a colour ground.\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n // At speed 0, progress is exactly the control: a scrubber to drive from your\n // own code, bound to a scroll position or a load event.\n //\n // Running, it wipes in, holds the layer for most of the cycle, then clears.\n // A linear loop would spend half its time on an empty ground, and the offset\n // starts it revealed so the first frame is the picture and not the backdrop.\n let phase = fract(params.a + 0.4 + st_time() * 0.25);\n let cycled = smoothstep(0.0, 0.3, phase) * (1.0 - smoothstep(0.9, 1.0, phase));\n let progress = select(cycled, params.a, params.speed < 0.001);\n\n // The field the threshold cuts through. Noise dissolves, a rotated ramp\n // wipes, and `shape` slides between the two.\n let noise = st_fbm2(st_centered(uv) * params.scale * 4.0, 3);\n let axis = st_rot(params.c * TAU) * (uv - 0.5);\n let ramp = clamp(axis.x + 0.5, 0.0, 1.0);\n let field = mix(noise, ramp, clamp(params.b, 0.0, 1.0));\n\n // Widen the threshold band to soften the boundary. It has to cover the\n // whole 0..1 range at both ends, or the wipe never finishes.\n let soft = max(params.d, 0.001);\n let cut = progress * (1.0 + 2.0 * soft) - soft;\n let mask = 1.0 - smoothstep(cut - soft, cut + soft, field);\n\n // The ground follows the clean ramp, never the noise. Colouring it by the\n // dissolve field would leave static behind the wipe instead of a backdrop.\n let ground = st_palette(ramp);\n var col = mix(ground, st_srcRgb(uv), mask);\n\n // A bright rim rides the boundary, the way a burn does.\n let rim = 1.0 - abs(mask - 0.5) * 2.0;\n col = col + st_palette(1.0) * pow(rim, 3.0) * params.warp;\n\n return st_post(col * params.intensity, uv);\n}\n";
49
54
  readonly ripple: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// ripple\n// ---------------------------------------------------------------------------\n\n// Ripple — interference between travelling circular waves.\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let p = st_centered(uv) * params.scale;\n let t = st_time();\n\n let sourceCount = 5;\n var wave = 0.0;\n var gradient = vec2f(0.0);\n\n for (var i = 0; i < sourceCount; i = i + 1) {\n let fi = f32(i);\n let phase = fi * 2.399963;\n let orbit = 0.28 * params.b;\n let src = vec2f(cos(t * 0.23 + phase) * orbit, sin(t * 0.31 + phase * 1.7) * orbit);\n let delta = p - src;\n let d = length(delta) + 0.0001;\n let k = 40.0 * params.a;\n let decay = 1.0 / (1.0 + d * 6.0);\n let s = sin(d * k - t * 4.0 + phase) * decay;\n wave = wave + s;\n gradient = gradient + (delta / d) * cos(d * k - t * 4.0 + phase) * decay * k;\n }\n\n wave = wave / f32(sourceCount);\n gradient = gradient / f32(sourceCount);\n\n // Treat the wave slope as a refracting surface and read the palette through it.\n let refracted = clamp(0.5 + wave * 1.4 + gradient.x * 0.02 * params.c, 0.0, 1.0);\n var col = st_palette(refracted);\n\n // Caustic highlights where the wavefronts focus.\n let caustic = pow(clamp(1.0 - abs(wave) * 3.2, 0.0, 1.0), 6.0);\n col = col + vec3f(1.0) * caustic * params.d;\n\n return st_post(col * params.intensity, uv);\n}\n";
@@ -54,7 +59,7 @@ export declare const shaderSources: {
54
59
  readonly starfield: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// starfield\n// ---------------------------------------------------------------------------\n\n// Starfield — a warp corridor. Layered depth slices flying at the camera.\n\n// Two independent hashes per cell; sharing one seed across layers is what\n// produces the diagonal moiré you get from adding a scalar to both components.\nfn cellRandom(cell : vec2f, layerSeed : f32) -> vec3f {\n let a = st_hash22(cell + vec2f(layerSeed * 37.19, layerSeed * 11.73));\n let b = st_hash21(cell.yx + layerSeed * 91.31);\n return vec3f(a, b);\n}\n\nfn layer(p : vec2f, layerIndex : f32, t : f32) -> vec3f {\n let z = fract(layerIndex * 0.1667 - t * 0.12);\n let scaleByDepth = mix(16.0, 2.0, z);\n let fade = smoothstep(0.0, 0.22, z) * smoothstep(1.0, 0.7, z);\n if (fade <= 0.0) { return vec3f(0.0); }\n\n // Rotating each slice breaks the alignment between neighbouring layers.\n let grid = st_rot(layerIndex * 1.37) * p * scaleByDepth;\n let base = floor(grid);\n let frac = fract(grid) - 0.5;\n\n var acc = vec3f(0.0);\n for (var y = -1; y <= 1; y = y + 1) {\n for (var x = -1; x <= 1; x = x + 1) {\n let neighbour = vec2f(f32(x), f32(y));\n let rnd = cellRandom(base + neighbour, layerIndex);\n if (rnd.z < 0.72) { continue; }\n\n let point = neighbour + rnd.xy - 0.5;\n var offset = frac - point;\n // Motion blur: stretch each star along its radial direction.\n let radial = normalize(p + vec2f(1e-5));\n let along = dot(offset, radial);\n let across = offset - radial * along;\n offset = radial * along / (1.0 + params.b * 8.0 * (1.0 - z)) + across;\n\n // A tight inverse-square core keeps stars as points. Widen the epsilon\n // and six layers of them blur into fog.\n let d = length(offset);\n let brightness = 0.00022 * params.a * (0.35 + rnd.z);\n acc = acc + st_palette(rnd.y) * (brightness / (d * d + 0.000018)) * fade;\n }\n }\n return acc;\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n var p = st_centered(uv) * params.scale;\n let t = st_time();\n p = st_rot(t * 0.05 * params.d) * p;\n\n var col = vec3f(0.004, 0.005, 0.012);\n for (var i = 0; i < 6; i = i + 1) {\n col = col + layer(p, f32(i), t);\n }\n\n // Core glow at the vanishing point.\n let core = params.c * 0.05 / (length(p) + 0.06);\n col = col + st_palette(1.0) * core;\n\n return st_post(st_tonemap(col * params.intensity), uv);\n}\n";
55
60
  readonly swell: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// swell\n// ---------------------------------------------------------------------------\n\n// Swell — open ocean marched as a heightfield, so waves hide waves.\n//\n// The surface is real: eight steps of interpolation search land the ray on a\n// displaced heightfield, which is what buys actual silhouettes — a crest in\n// front occluding the trough behind it — instead of a flat plane wearing\n// normal-map make-up. Each octave is a product of the two axes of a\n// noise-warped sine, sharpened by a choppiness power: sharp crests, round\n// troughs, and no criss-cross lattice. The march reads three octaves, the\n// normal five; nobody can tell.\n\nfn waveOctave(uv : vec2f, choppy : f32) -> f32 {\n let n = st_noise2(uv) * 2.0 - 1.0;\n let q = uv + vec2f(n, n);\n let wv = vec2f(1.0) - abs(sin(q));\n let sw = abs(cos(q));\n let m = mix(wv, sw, wv);\n return pow(1.0 - pow(m.x * m.y, 0.65), choppy);\n}\n\nfn seaHeight(p2 : vec2f, t : f32, octaves : i32) -> f32 {\n var freq = 0.16;\n var amp = 0.62 * params.b;\n var choppy = mix(1.2, 4.5, clamp(params.d, 0.0, 2.0) * 0.5);\n var uv = vec2f(p2.x * 0.75, p2.y);\n var h = 0.0;\n for (var i = 0; i < 5; i = i + 1) {\n if (i >= octaves) { break; }\n var d = waveOctave((uv + vec2f(t, t)) * freq, choppy);\n d = d + waveOctave((uv - vec2f(t, t)) * freq, choppy);\n h = h + d * amp;\n uv = mat2x2f(vec2f(1.6, -1.2), vec2f(1.2, 1.6)) * uv;\n freq = freq * 1.9;\n amp = amp * 0.22;\n choppy = mix(choppy, 1.0, 0.2);\n }\n return h;\n}\n\nfn skyColor(dir : f32, sunAmt : f32) -> vec3f {\n var sky = mix(mix(params.colorA.rgb, vec3f(0.9, 0.93, 0.95), 0.75), params.colorA.rgb * 1.15, smoothstep(-0.02, 0.5, dir));\n sky = mix(sky, params.colorC.rgb, pow(sunAmt, 4.0) * 0.5);\n return sky;\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let t = st_time() * 0.8;\n let p = st_centered(uv);\n\n let look = (params.pointer - vec2f(0.5)) * 0.15;\n\n let maxAmp = 1.35 * params.b;\n let ro = vec3f(0.0, maxAmp + 2.2, t * 1.2);\n let rd = normalize(vec3f(p.x + look.x * 0.2, -p.y * 0.8 - 0.14 + look.y * 0.15, 1.15));\n\n let sunDir = normalize(vec3f(0.15 - look.x * 0.2, 0.03 + 0.3 * params.a, 0.9));\n let sunAmt = clamp(dot(rd, sunDir), 0.0, 1.0);\n\n var sky = skyColor(rd.y, sunAmt);\n sky = sky + params.colorC.rgb * pow(sunAmt, 1400.0) * 4.0;\n sky = sky + params.colorC.rgb * pow(sunAmt, 32.0) * 0.5;\n\n var col = sky;\n if (rd.y < -0.01) {\n // Bracket the crossing: the ray is above every wave at tm and below sea\n // floor level at tx, then eight secant steps pin the surface.\n var tm = (maxAmp - ro.y) / rd.y;\n var tx = -ro.y / rd.y * 1.6;\n var hm = ro.y + rd.y * tm - seaHeight((ro + rd * tm).xz, t, 3);\n var hx = ro.y + rd.y * tx - seaHeight((ro + rd * tx).xz, t, 3);\n var travelled = tx;\n for (var i = 0; i < 8; i = i + 1) {\n travelled = mix(tm, tx, hm / max(hm - hx, 1e-4));\n let pos = ro + rd * travelled;\n let h = pos.y - seaHeight(pos.xz, t, 3);\n if (h < 0.0) {\n tx = travelled;\n hx = h;\n } else {\n tm = travelled;\n hm = h;\n }\n }\n let pos = ro + rd * travelled;\n\n // Normal from central differences of the five-octave field, the step\n // widening with distance so far water stays calm instead of fizzing.\n let e = max(0.008 * travelled, 0.01);\n let h0 = seaHeight(pos.xz, t, 5);\n let n = normalize(vec3f(\n seaHeight(pos.xz - vec2f(e, 0.0), t, 5) - seaHeight(pos.xz + vec2f(e, 0.0), t, 5),\n 2.0 * e,\n seaHeight(pos.xz - vec2f(0.0, e), t, 5) - seaHeight(pos.xz + vec2f(0.0, e), t, 5),\n ));\n\n // Fresnel decides sky against body.\n let fres = 0.03 + 0.5 * pow(1.0 - clamp(dot(n, -rd), 0.0, 1.0), 3.0);\n let refl = skyColor(reflect(rd, n).y, sunAmt);\n\n // The body: deep colour lifted toward the crest colour with height —\n // the wrapped light that reads as the sun inside the wave.\n let lift = clamp(h0 / maxAmp, 0.0, 1.0);\n var body = params.colorA.rgb * 0.38;\n body = body + params.colorB.rgb * pow(clamp(dot(n, sunDir), 0.0, 1.0) * 0.35 + 0.65, 3.0) * lift * 0.75;\n\n col = mix(body, refl, fres);\n\n // Whitecaps where a crest peaks, eaten by noise into streaks.\n let cap = smoothstep(0.72, 0.95, lift) * smoothstep(0.35, 0.75, st_noise2(pos.xz * 1.6 + t * 0.8));\n col = mix(col, vec3f(0.93, 0.96, 0.96), cap * 0.85);\n\n // Glitter: tight and sharp nearby, a broad streak at distance.\n let spec = pow(clamp(dot(reflect(rd, n), sunDir), 0.0, 1.0), 260.0 / (1.0 + travelled * travelled * 0.02));\n col = col + params.colorC.rgb * spec * params.c * 2.0;\n\n // Haze folds sea into sky at the horizon.\n col = mix(col, sky, 1.0 - exp(-max(travelled - 6.0, 0.0) * 0.022));\n }\n\n return st_post(st_tonemap(col * params.intensity), uv);\n}\n";
56
61
  readonly swirl: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// shadertown source prelude — appended to the shared prelude for shaders that\n// read a layer instead of inventing one.\n//\n// Every binding here is additive. A generative shader declares none of them and\n// keeps the exact layout it always had, and `Source` is its own uniform block\n// rather than four more fields on `Params`, so the ABI that every already\n// shipped shader was built against never moves.\n\n@group(0) @binding(1) var srcTexture : texture_2d<f32>;\n@group(0) @binding(2) var srcSampler : sampler;\n\nstruct Source {\n // Pixel size of the bound texture. Zero means \"assume it matches the canvas\".\n size : vec2f,\n // 0 stretches the source over the canvas, 1 covers the canvas and crops.\n fit : f32,\n _pad : f32,\n}\n\n@group(0) @binding(3) var<uniform> source : Source;\n\n// How much of the source each axis shows once it is fitted. Both components are\n// 1 or less: covering means cropping, never adding.\nfn st_srcScale() -> vec2f {\n if (source.fit < 0.5 || source.size.x < 1.0 || source.size.y < 1.0) {\n return vec2f(1.0);\n }\n let canvas = st_aspect();\n let src = source.size.x / source.size.y;\n if (src > canvas) {\n return vec2f(canvas / src, 1.0);\n }\n return vec2f(1.0, src / canvas);\n}\n\n/** Canvas uv to source uv. */\nfn st_srcUv(uv : vec2f) -> vec2f {\n return (uv - 0.5) * st_srcScale() + 0.5;\n}\n\n/**\n * Canvas uv to source uv, with room left around the edge.\n *\n * A shader that displaces its reads runs off the source at the border, and the\n * sampler answers by smearing its last row of texels across the gap. Reading a\n * slightly smaller crop gives the displacement somewhere to go, at the cost of\n * a few per cent of the picture.\n */\nfn st_srcUvInset(uv : vec2f, margin : f32) -> vec2f {\n return (uv - 0.5) * st_srcScale() * (1.0 - clamp(margin, 0.0, 0.4)) + 0.5;\n}\n\n/**\n * A canvas-space displacement, in source uv.\n *\n * Divides out the aspect so a push of 0.01 moves the same distance sideways as\n * it does up, whatever shape the canvas or the source is.\n */\nfn st_srcWarp(offset : vec2f) -> vec2f {\n return offset * st_srcScale() / vec2f(st_aspect(), 1.0);\n}\n\n/** Samples in source uv. Kernels that walk neighbours want this one. */\nfn st_srcAt(srcUv : vec2f) -> vec4f {\n return textureSample(srcTexture, srcSampler, srcUv);\n}\n\n/** Samples in canvas uv — the ordinary read. */\nfn st_src(uv : vec2f) -> vec4f {\n return st_srcAt(st_srcUv(uv));\n}\n\nfn st_srcRgb(uv : vec2f) -> vec3f {\n return st_src(uv).rgb;\n}\n\n/** One source texel, in source uv. */\nfn st_srcTexel() -> vec2f {\n return 1.0 / max(source.size, vec2f(1.0));\n}\n\n/**\n * 1 inside the source, falling to 0 just outside it.\n *\n * The sampler clamps, so a shader that pushes past the border smears the last\n * row of texels across the gap. Anything that displaces should fade instead.\n */\nfn st_srcInside(srcUv : vec2f) -> f32 {\n let edge = min(srcUv, vec2f(1.0) - srcUv);\n return smoothstep(0.0, 0.004, min(edge.x, edge.y));\n}\n\n/** Four-tap softening. Cheap enough to run per colour channel. */\nfn st_srcSoften(uv : vec2f, radius : f32) -> vec3f {\n let base = st_srcUv(uv);\n var sum = vec3f(0.0);\n for (var i = 0; i < 4; i = i + 1) {\n let angle = f32(i) * 1.570796 + 0.785398;\n sum = sum + st_srcAt(base + st_srcWarp(vec2f(cos(angle), sin(angle)) * radius)).rgb;\n }\n return sum * 0.25;\n}\n\n/**\n * Thirteen-tap spiral blur. `radius` is in canvas space.\n *\n * The golden angle keeps the taps from lining up into spokes, which is what a\n * uniform ring blur gives you at low tap counts.\n */\nfn st_srcBlur(uv : vec2f, radius : f32) -> vec3f {\n let base = st_srcUv(uv);\n var sum = st_srcAt(base).rgb;\n for (var i = 0; i < 12; i = i + 1) {\n let fi = f32(i);\n let angle = fi * 2.399963;\n let r = radius * sqrt((fi + 0.5) / 12.0);\n sum = sum + st_srcAt(base + st_srcWarp(vec2f(cos(angle), sin(angle)) * r)).rgb;\n }\n return sum / 13.0;\n}\n\n// ---------------------------------------------------------------------------\n// swirl\n// ---------------------------------------------------------------------------\n\n// Swirl — a vortex that winds the layer around a point.\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let aspect = st_aspect();\n let centre = params.pointer;\n let offset = (uv - centre) * vec2f(aspect, 1.0);\n let dist = length(offset);\n\n // Falloff decides the whole character: 1 is a gentle whirlpool, 4 is a tight\n // knot with still water around it.\n let radius = max(params.a, 0.02);\n let falloff = pow(clamp(1.0 - dist / radius, 0.0, 1.0), max(params.b, 0.1));\n let angle = falloff * params.warp * 4.0 + st_time() * params.c;\n\n // Rotation about the centre pushes the corners outside a rectangle, so the\n // read is inset enough to keep them on the picture.\n let toUv = vec2f(1.0 / aspect, 1.0);\n let twisted = st_rot(angle) * offset;\n let source = st_srcUvInset(twisted * toUv + centre, 0.06);\n\n // Winding the channels by slightly different angles smears colour along the\n // direction of travel, which is what sells the spin.\n let apart = params.d * 0.15 * falloff;\n let ahead = st_srcUvInset((st_rot(angle + apart) * offset) * toUv + centre, 0.06);\n let behind = st_srcUvInset((st_rot(angle - apart) * offset) * toUv + centre, 0.06);\n\n let col = vec3f(\n st_srcAt(ahead).r,\n st_srcAt(source).g,\n st_srcAt(behind).b,\n );\n\n return st_post(col * params.intensity, uv);\n}\n";
57
- readonly tesla: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// tesla\n// ---------------------------------------------------------------------------\n\n// Tesla — a plasma globe whose filament reaches for your finger.\n//\n// The bolts are analytic: each one is a base angle plus a noise wiggle that\n// grows with radius, and a pixel's distance to it is its angular error times\n// its radius. No marching, no segments — the whole filament costs two fbm\n// calls. The pointer is the interactive part: one bolt always aims at it,\n// clamped to the glass, which is exactly what the real toy does when a hand\n// touches the dome.\n\nfn wrapAngle(x : f32) -> f32 {\n return x - TAU * floor(x / TAU + 0.5);\n}\n\n// The glow one filament deposits at polar point (radius, theta).\nfn boltGlow(radius : f32, theta : f32, baseAngle : f32, t : f32, seed : f32, width : f32) -> vec2f {\n // The wiggle grows from nothing at the electrode, so every bolt is rooted\n // at the centre no matter how hard it thrashes at the rim.\n let sway = (st_fbm2(vec2f(radius * 3.5 - t * 2.2, seed * 43.7), 3) - 0.5)\n * params.warp * 2.4 * radius;\n let d = abs(wrapAngle(theta - baseAngle - sway)) * radius;\n\n // Core and halo, both against a width that thins as the bolt travels.\n let w = width * (0.5 + radius);\n let core = exp(-d / max(w * 0.012, 0.0005));\n let halo = exp(-d / max(w * 0.05, 0.002));\n return vec2f(core, halo);\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let t = st_time();\n let p = st_centered(uv) / max(params.scale, 0.2);\n\n let R = 0.42;\n let radius = length(p);\n let theta = atan2(p.y, p.x);\n\n let cursor = vec2f((params.pointer.x - 0.5) * st_aspect(), params.pointer.y - 0.5)\n / max(params.scale, 0.2);\n\n let bolts = i32(clamp(params.a, 2.0, 8.0));\n var core = 0.0;\n var halo = 0.0;\n var tint = vec3f(0.0);\n\n for (var i = 0; i < bolts; i = i + 1) {\n let fi = f32(i);\n let seed = st_hash11(fi + 1.0);\n // Each bolt drifts around the globe at its own slow rate, and re-rolls\n // its heading on a coarse clock — the jump is what reads as electricity.\n let jump = floor(t * (1.5 + seed * 2.0) * params.c + seed * 9.0);\n let baseAngle = (fi + st_hash11(jump + fi * 17.0)) / f32(bolts) * TAU;\n let flicker = 0.55 + 0.45 * st_hash11(jump * 3.1 + fi);\n\n let g = boltGlow(radius, theta, baseAngle, t + seed * 8.0, seed, params.b);\n core = core + g.x * flicker;\n halo = halo + g.y * flicker;\n tint = tint + st_palette(seed) * g.y * flicker;\n }\n\n // The seeking bolt. It aims at the cursor, clamped to the inside of the\n // glass, and fades out when the cursor sits on the electrode where an\n // angle stops meaning anything.\n let cursorR = length(cursor);\n let aimR = min(cursorR, R - 0.015);\n let aim = atan2(cursor.y, cursor.x);\n let alive = smoothstep(0.06, 0.16, cursorR);\n let g = boltGlow(radius, theta, aim, t, 0.77, params.b * 0.8);\n let reach = smoothstep(aimR + 0.06, aimR - 0.02, radius);\n core = core + g.x * 1.8 * alive * reach;\n halo = halo + g.y * 1.4 * alive * reach;\n tint = tint + st_palette(0.9) * g.y * alive * reach;\n\n // Contact bloom where the filament meets the glass or the finger.\n let touch = cursor / max(cursorR, 0.0001) * aimR;\n halo = halo + exp(-length(p - touch) * 22.0) * 1.6 * alive;\n\n // Assemble inside the globe: electrode, haze, filaments.\n let inside = smoothstep(R + 0.004, R - 0.004, radius);\n let electrode = exp(-radius * 55.0);\n var col = params.colorA.rgb * (0.05 + params.d * 0.12 * (1.0 - radius / R));\n col = col + tint * 0.3 + params.colorB.rgb * halo * 0.45;\n col = col + params.colorC.rgb * (core * 1.6 + electrode * 0.9);\n col = col * inside;\n\n // The glass: a thin rim, brightest where filaments are near it.\n let rim = exp(-abs(radius - R) * 120.0);\n col = col + params.colorB.rgb * rim * (0.15 + halo * 0.6);\n\n // A faint stand shadow below, so the globe sits on something.\n col = col + params.colorA.rgb * 0.06 * smoothstep(0.1, 0.5, p.y) * (1.0 - inside);\n\n return st_post(st_tonemap(col * params.intensity), uv);\n}\n";
62
+ readonly tesla: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// tesla\n// ---------------------------------------------------------------------------\n\n// Tesla — a plasma globe whose filament reaches for your finger.\n//\n// The bolts are analytic: each one is a base angle plus a noise wiggle that\n// grows with radius, and a pixel's distance to it is its angular error times\n// its radius. No marching, no segments — the whole filament costs two fbm\n// calls. The pointer is the interactive part: one bolt always aims at it,\n// clamped to the glass, which is exactly what the real toy does when a hand\n// touches the dome.\n\nfn wrapAngle(x : f32) -> f32 {\n return x - TAU * floor(x / TAU + 0.5);\n}\n\n// The glow one filament deposits at polar point (radius, theta).\nfn boltGlow(radius : f32, theta : f32, baseAngle : f32, t : f32, seed : f32, width : f32) -> vec2f {\n // The wiggle grows from nothing at the electrode, so every bolt is rooted\n // at the centre no matter how hard it thrashes at the rim.\n let sway = (st_fbm2(vec2f(radius * 3.5 - t * 2.2, seed * 43.7), 3) - 0.5)\n * params.warp * 2.4 * radius;\n let d = abs(wrapAngle(theta - baseAngle - sway)) * radius;\n\n // Core and halo, both against a width that thins as the bolt travels.\n let w = width * (0.5 + radius);\n let core = exp(-d / max(w * 0.012, 0.0005));\n let halo = exp(-d / max(w * 0.05, 0.002));\n return vec2f(core, halo);\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let t = st_time();\n let p = st_centered(uv) / max(params.scale, 0.2);\n\n let R = 0.42;\n let radius = length(p);\n let theta = atan2(p.y, p.x);\n\n let cursor = vec2f((params.pointer.x - 0.5) * st_aspect(), params.pointer.y - 0.5)\n / max(params.scale, 0.2);\n\n let bolts = i32(clamp(params.a, 2.0, 8.0));\n var core = 0.0;\n var halo = 0.0;\n var tint = vec3f(0.0);\n\n for (var i = 0; i < bolts; i = i + 1) {\n let fi = f32(i);\n let seed = st_hash11(fi + 1.0);\n // Each bolt drifts around the globe at its own slow rate, and re-rolls\n // its heading on a coarse clock — the jump is what reads as electricity.\n let jump = floor(t * (1.5 + seed * 2.0) * params.c + seed * 9.0);\n let baseAngle = (fi + st_hash11(jump + fi * 17.0)) / f32(bolts) * TAU;\n let flicker = 0.55 + 0.45 * st_hash11(jump * 3.1 + fi);\n\n let g = boltGlow(radius, theta, baseAngle, t + seed * 8.0, seed, params.b);\n core = core + g.x * flicker;\n halo = halo + g.y * flicker;\n tint = tint + st_palette(seed) * g.y * flicker;\n }\n\n // The seeking bolt. It aims at the cursor, clamped to the inside of the\n // glass, and fades out when the cursor sits on the electrode where an\n // angle stops meaning anything.\n let cursorR = length(cursor);\n let aimR = min(cursorR, R - 0.015);\n let alive = smoothstep(0.06, 0.16, cursorR);\n // A pointer that has never moved reports the centre of the frame, which is\n // the electrode itself: atan2 has no answer there and Metal returns NaN.\n // The fade below cannot remove it, because NaN times zero is still NaN, and\n // one NaN blacks out the whole globe. Ask for the angle only where there is\n // one — the fade does the rest.\n let aim = select(0.0, atan2(cursor.y, cursor.x), cursorR > 1e-5);\n let g = boltGlow(radius, theta, aim, t, 0.77, params.b * 0.8);\n let reach = smoothstep(aimR + 0.06, aimR - 0.02, radius);\n core = core + g.x * 1.8 * alive * reach;\n halo = halo + g.y * 1.4 * alive * reach;\n tint = tint + st_palette(0.9) * g.y * alive * reach;\n\n // Contact bloom where the filament meets the glass or the finger.\n let touch = cursor / max(cursorR, 0.0001) * aimR;\n halo = halo + exp(-length(p - touch) * 22.0) * 1.6 * alive;\n\n // Assemble inside the globe: electrode, haze, filaments.\n let inside = smoothstep(R + 0.004, R - 0.004, radius);\n let electrode = exp(-radius * 55.0);\n var col = params.colorA.rgb * (0.05 + params.d * 0.12 * (1.0 - radius / R));\n col = col + tint * 0.3 + params.colorB.rgb * halo * 0.45;\n col = col + params.colorC.rgb * (core * 1.6 + electrode * 0.9);\n col = col * inside;\n\n // The glass: a thin rim, brightest where filaments are near it.\n let rim = exp(-abs(radius - R) * 120.0);\n col = col + params.colorB.rgb * rim * (0.15 + halo * 0.6);\n\n // A faint stand shadow below, so the globe sits on something.\n col = col + params.colorA.rgb * 0.06 * smoothstep(0.1, 0.5, p.y) * (1.0 - inside);\n\n return st_post(st_tonemap(col * params.intensity), uv);\n}\n";
58
63
  readonly torch: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// torch\n// ---------------------------------------------------------------------------\n\n// Torch — a light in your hand, moving over a rock wall in the dark.\n//\n// The wall is a heightfield and the lamp is the pointer, so the shading is\n// honest: the fbm that draws the rock is differentiated for a normal, and the\n// relief answers the light's position — crevices light up and go dark as the\n// torch passes over them, which is what makes the surface read as carved\n// rather than printed.\n\nfn rockHeight(p : vec2f) -> f32 {\n let crack = 1.0 - abs(st_noise2(p * 2.2) * 2.0 - 1.0);\n return st_fbm2(p * 5.0, 4) * 0.6 + st_ridge2(p * 11.0, 3) * 0.2 - crack * crack * 0.35;\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let t = st_time();\n let p = st_centered(uv) * params.scale;\n\n let cursor = vec2f((params.pointer.x - 0.5) * st_aspect(), params.pointer.y - 0.5) * params.scale;\n\n // The wall. Two extra height taps buy the normal; relief scales the slope\n // rather than the height, so the texture stays put while the depth changes.\n let eps = 0.008;\n let h = rockHeight(p);\n let hx = rockHeight(p + vec2f(eps, 0.0));\n let hy = rockHeight(p + vec2f(0.0, eps));\n let slope = vec2f(hx - h, hy - h) / eps * params.warp;\n let normal = normalize(vec3f(-slope, 1.0));\n\n // The torch: just above the wall, guttering. Two noise taps at different\n // rates — a slow breathe and a fast sputter — read as flame, where a sine\n // would read as a siren.\n let gutter = 0.85\n + 0.10 * (st_noise2(vec2f(t * 6.0, 3.7)) - 0.5) * 2.0 * params.b\n + 0.05 * (st_noise2(vec2f(t * 23.0, 9.1)) - 0.5) * 2.0 * params.b;\n let lampHeight = 0.35;\n let toLamp = vec3f(cursor - p, lampHeight);\n let lampDist = length(toLamp);\n let lightDir = toLamp / lampDist;\n\n // The beam: an inverse-square lamp inside a soft-edged pool of light.\n let radius = max(params.a, 0.1);\n let pool = smoothstep(radius, radius * 0.25, length(cursor - p));\n let diffuse = max(dot(normal, lightDir), 0.0) / (1.0 + lampDist * lampDist * 3.0);\n let spec = pow(max(dot(normal, normalize(lightDir + vec3f(0.0, 0.0, 1.0))), 0.0), 24.0);\n\n // The rock's own colour varies with a second, coarser field, so the wall\n // has geology as well as relief.\n let vein = st_fbm2(p * 1.3 + 17.0, 3);\n let albedo = mix(params.colorA.rgb, params.colorA.rgb * 2.6 + vec3f(0.04), smoothstep(0.3, 0.7, vein));\n\n var col = albedo * (params.d * 0.15);\n col = col + albedo * params.colorB.rgb * diffuse * pool * gutter * 4.2;\n col = col + params.colorC.rgb * spec * pool * gutter * 0.35;\n\n // Dust motes drifting through the beam. Only lit air shows them, which is\n // the whole reason a torch beam is visible at all.\n var motes = 0.0;\n for (var i = 0; i < 2; i = i + 1) {\n let fi = f32(i);\n let mp = p * (5.0 + fi * 3.0) + vec2f(t * (0.08 + fi * 0.05), t * 0.15);\n let cell = floor(mp);\n let rnd = st_hash22(cell);\n let d = length(fract(mp) - 0.5 - (rnd - 0.5) * 0.6);\n motes = motes + smoothstep(0.045, 0.0, d) * step(0.82, rnd.x);\n }\n col = col + params.colorC.rgb * motes * pool * gutter * params.c * 0.9;\n\n return st_post(st_tonemap(col * params.intensity), uv);\n}\n";
59
64
  readonly truchet: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// truchet\n// ---------------------------------------------------------------------------\n\n// Truchet — quarter-arc tiles that flip in place. Hard, graphic, no noise wash.\n\nfn arcTile(f : vec2f, flipped : bool, weight : f32) -> f32 {\n var q = f;\n if (flipped) { q.x = 1.0 - q.x; }\n let d1 = abs(length(q) - 0.5);\n let d2 = abs(length(q - vec2f(1.0, 1.0)) - 0.5);\n let d = min(d1, d2);\n return 1.0 - smoothstep(weight, weight + 0.02, d);\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let t = st_time();\n var p = st_centered(uv) * params.scale * 6.0;\n p = st_rot(t * 0.04 * params.d) * p;\n p = p + vec2f(t * 0.25 * params.c, 0.0);\n\n let base = floor(p);\n let f = fract(p);\n\n // Each tile flips on its own clock.\n let rnd = st_hash21(base);\n let phase = sin(t * 0.9 + rnd * TAU) * 0.5 + 0.5;\n let flipped = (rnd + phase * params.b) > 1.0;\n\n let line = arcTile(f, flipped, params.a * 0.12);\n\n let tint = st_palette(fract(rnd + t * 0.05));\n let bg = params.colorA.rgb * 0.14;\n var col = mix(bg, tint * params.intensity, line);\n\n // A soft bloom off the strokes keeps it from looking flat.\n col = col + tint * line * 0.25;\n\n return st_post(col, uv);\n}\n";
60
65
  readonly tunnel: "// shadertown shared prelude — prepended to every shader at build time.\n// One uniform block is the whole ABI: every shader reads the same struct,\n// so the runtime can swap shaders without changing a binding.\n\nstruct Params {\n resolution : vec2f,\n pointer : vec2f,\n time : f32,\n speed : f32,\n scale : f32,\n intensity : f32,\n vibrancy : f32,\n grain : f32,\n vignette : f32,\n warp : f32,\n a : f32,\n b : f32,\n c : f32,\n d : f32,\n colorA : vec4f,\n colorB : vec4f,\n colorC : vec4f,\n}\n\n@group(0) @binding(0) var<uniform> params : Params;\n\nconst PI : f32 = 3.141592653589793;\nconst TAU : f32 = 6.283185307179586;\n\nfn st_time() -> f32 {\n return params.time * params.speed;\n}\n\n// Centred, aspect-corrected coordinates. y stays in [-0.5, 0.5].\nfn st_centered(uv : vec2f) -> vec2f {\n let aspect = params.resolution.x / max(params.resolution.y, 1.0);\n return vec2f((uv.x - 0.5) * aspect, uv.y - 0.5);\n}\n\nfn st_aspect() -> f32 {\n return params.resolution.x / max(params.resolution.y, 1.0);\n}\n\nfn st_rot(angle : f32) -> mat2x2f {\n let s = sin(angle);\n let c = cos(angle);\n return mat2x2f(c, -s, s, c);\n}\n\n// The device-pixel size of one virtual cell, for pixel-art shaders. Cells\n// must be a whole number of device pixels: dividing the canvas by the\n// requested count directly leaves some cells one pixel fatter than others,\n// which the eye reads instantly as a broken grid. Use as:\n// let px = st_pixelSize(params.a);\n// let cell = floor(uv * params.resolution / px);\n// let puv = (cell + 0.5) * px / params.resolution;\nfn st_pixelSize(pixels : f32) -> f32 {\n return max(round(params.resolution.y / max(pixels, 8.0)), 1.0);\n}\n\n// --- hashing ---------------------------------------------------------------\n\nfn st_hash11(x : f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\n\nfn st_hash21(p : vec2f) -> f32 {\n var p3 = fract(vec3f(p.xyx) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn st_hash22(p : vec2f) -> vec2f {\n var p3 = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nfn st_hash33(p : vec3f) -> vec3f {\n var p3 = fract(p * vec3f(0.1031, 0.1030, 0.0973));\n p3 = p3 + dot(p3, p3.yxz + 33.33);\n return fract((p3.xxy + p3.yxx) * p3.zyx);\n}\n\n// --- noise -----------------------------------------------------------------\n\nfn st_noise2(p : vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let a = st_hash21(i);\n let b = st_hash21(i + vec2f(1.0, 0.0));\n let c = st_hash21(i + vec2f(0.0, 1.0));\n let d = st_hash21(i + vec2f(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfn st_noise3(p : vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = st_hash33(i + vec3f(0.0, 0.0, 0.0)).x;\n let n100 = st_hash33(i + vec3f(1.0, 0.0, 0.0)).x;\n let n010 = st_hash33(i + vec3f(0.0, 1.0, 0.0)).x;\n let n110 = st_hash33(i + vec3f(1.0, 1.0, 0.0)).x;\n let n001 = st_hash33(i + vec3f(0.0, 0.0, 1.0)).x;\n let n101 = st_hash33(i + vec3f(1.0, 0.0, 1.0)).x;\n let n011 = st_hash33(i + vec3f(0.0, 1.0, 1.0)).x;\n let n111 = st_hash33(i + vec3f(1.0, 1.0, 1.0)).x;\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n\nfn st_fbm2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.5);\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise2(shifted);\n shifted = rotation * shifted * 2.02;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\nfn st_fbm3(p : vec3f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n for (var i = 0; i < octaves; i = i + 1) {\n value = value + amplitude * st_noise3(shifted);\n shifted = shifted * 2.03;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// Ridged noise — sharp crests instead of soft hills.\nfn st_ridge2(p : vec2f, octaves : i32) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var shifted = p;\n let rotation = st_rot(0.7);\n for (var i = 0; i < octaves; i = i + 1) {\n let n = 1.0 - abs(st_noise2(shifted) * 2.0 - 1.0);\n value = value + amplitude * n * n;\n shifted = rotation * shifted * 2.11;\n amplitude = amplitude * 0.5;\n }\n return value;\n}\n\n// --- colour ----------------------------------------------------------------\n\n// Three-stop ramp built from the user's colours. t is clamped to [0, 1].\nfn st_palette(t : f32) -> vec3f {\n let x = clamp(t, 0.0, 1.0);\n let low = mix(params.colorA.rgb, params.colorB.rgb, smoothstep(0.0, 0.5, x));\n return mix(low, params.colorC.rgb, smoothstep(0.5, 1.0, x));\n}\n\n// Same ramp, but wrapping — good for cycling and polar shaders.\nfn st_paletteCyclic(t : f32) -> vec3f {\n let x = fract(t);\n let tri = 1.0 - abs(x * 2.0 - 1.0);\n return st_palette(tri);\n}\n\nfn st_luma(c : vec3f) -> f32 {\n return dot(c, vec3f(0.2126, 0.7152, 0.0722));\n}\n\nfn st_saturation(c : vec3f, amount : f32) -> vec3f {\n return mix(vec3f(st_luma(c)), c, amount);\n}\n\nfn st_tonemap(c : vec3f) -> vec3f {\n let x = max(c, vec3f(0.0));\n return (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);\n}\n\n// --- shared post chain -----------------------------------------------------\n// Every shader ends here, so vibrancy / grain / vignette behave identically\n// across the whole catalogue.\nfn st_post(color : vec3f, uv : vec2f) -> vec4f {\n var c = st_saturation(max(color, vec3f(0.0)), params.vibrancy);\n\n if (params.grain > 0.0) {\n let seed = uv * params.resolution + vec2f(params.time * 61.7, params.time * 37.3);\n let n = st_hash21(floor(seed));\n c = c + (n - 0.5) * params.grain;\n }\n\n if (params.vignette > 0.0) {\n let d = length(uv - vec2f(0.5)) * 1.41421356;\n c = c * (1.0 - params.vignette * smoothstep(0.22, 1.0, d));\n }\n\n return vec4f(clamp(c, vec3f(0.0), vec3f(1.0)), 1.0);\n}\n\n// ---------------------------------------------------------------------------\n// tunnel\n// ---------------------------------------------------------------------------\n\n// Tunnel — a raymarched corridor with emissive rings and distance fog.\n\n// The corridor centre wanders as you fly down it. Keep the amplitude well\n// under the radius, or the ray leaves the tube before it has travelled.\nfn tunnelPath(z : f32) -> vec2f {\n return vec2f(sin(z * 0.28) * 0.42, cos(z * 0.21) * 0.34);\n}\n\n// Distance from a point to the wall, measured from inside the tube.\nfn sceneSdf(pos : vec3f, radius : f32, ribs : f32) -> f32 {\n let q = pos.xy - tunnelPath(pos.z);\n let r = length(q);\n let theta = atan2(q.y, q.x);\n let corrugation = sin(pos.z * 2.4) * 0.05 * ribs + sin(theta * 6.0 + pos.z * 0.5) * 0.05 * ribs;\n return radius + corrugation - r;\n}\n\n@fragment fn fs_main(@location(0) uv : vec2f) -> @location(0) vec4f {\n let p = st_centered(uv) * 2.0;\n let t = st_time() * 2.0;\n\n let ro = vec3f(tunnelPath(t), t);\n // Aim slightly along the corridor so the walls sweep past rather than\n // sitting still around a fixed vanishing point.\n let ahead = tunnelPath(t + 2.0) - tunnelPath(t);\n let rd = normalize(vec3f(p / max(params.scale, 0.2) + ahead * 0.45, 1.6));\n\n var travelled = 0.05;\n var hit = false;\n for (var i = 0; i < 96; i = i + 1) {\n let d = sceneSdf(ro + rd * travelled, params.a, params.b);\n if (d < 0.004 * travelled) {\n hit = true;\n break;\n }\n travelled = travelled + max(d * 0.7, 0.01);\n if (travelled > 30.0) { break; }\n }\n\n if (!hit) {\n return st_post(vec3f(0.0), uv);\n }\n\n let pos = ro + rd * travelled;\n let q = pos.xy - tunnelPath(pos.z);\n let theta = atan2(q.y, q.x);\n\n // Wall texture in tunnel space: seams around, plates along. Both are thin\n // lines on a dark wall — wide bands read as a kaleidoscope, not a corridor.\n let seams = 1.0 - smoothstep(0.0, 0.16, abs(fract(theta / TAU * 8.0) - 0.5) * 2.0);\n let plates = 1.0 - smoothstep(0.0, 0.2, abs(fract(pos.z * 0.9) - 0.5) * 2.0);\n let grime = st_fbm2(vec2f(theta * 2.4, pos.z * 0.7), 3);\n let surface = clamp(0.12 + grime * 0.35 + max(seams, plates) * 0.55, 0.0, 1.0);\n\n // Cheap normal from the SDF gradient, lit from a lamp at the camera.\n let e = vec2f(0.01, 0.0);\n let n = normalize(vec3f(\n sceneSdf(pos + e.xyy, params.a, params.b) - sceneSdf(pos - e.xyy, params.a, params.b),\n sceneSdf(pos + e.yxy, params.a, params.b) - sceneSdf(pos - e.yxy, params.a, params.b),\n sceneSdf(pos + e.yyx, params.a, params.b) - sceneSdf(pos - e.yyx, params.a, params.b),\n ));\n let lamp = clamp(dot(n, -rd), 0.0, 1.0);\n\n // Falling off with distance as well as with the lamp angle is what makes\n // the near wall read as near.\n let nearness = 1.0 / (1.0 + travelled * 0.35);\n var col = st_palette(clamp(surface, 0.0, 1.0))\n * (0.10 + 1.15 * lamp * nearness) * params.intensity;\n\n // Emissive rings at fixed intervals down the corridor.\n let ring = pow(1.0 - abs(fract(pos.z * 0.5) * 2.0 - 1.0), 20.0);\n col = col + st_palette(1.0) * ring * params.c * 5.0;\n\n // Fog swallows the far end.\n col = col * exp(-travelled * 0.10 * (0.4 + params.d * 2.4));\n\n return st_post(st_tonemap(col), uv);\n}\n";
@@ -71,12 +76,15 @@ export declare const shaderKinds: {
71
76
  readonly bokeh: "generative";
72
77
  readonly caldera: "generative";
73
78
  readonly caustics: "generative";
79
+ readonly chladni: "graph";
74
80
  readonly cloth: "compute";
75
81
  readonly cumulus: "generative";
76
82
  readonly dither: "generative";
83
+ readonly dunes: "graph";
77
84
  readonly duotone: "source";
78
85
  readonly edges: "source";
79
86
  readonly eyes: "generative";
87
+ readonly ferro: "graph";
80
88
  readonly "film-grain": "generative";
81
89
  readonly flow: "source";
82
90
  readonly fluid: "compute";
@@ -91,6 +99,7 @@ export declare const shaderKinds: {
91
99
  readonly lens: "source";
92
100
  readonly "liquid-metal": "generative";
93
101
  readonly lumen: "generative";
102
+ readonly maelstrom: "graph";
94
103
  readonly magnet: "generative";
95
104
  readonly meadow: "generative";
96
105
  readonly melt: "source";
@@ -107,6 +116,7 @@ export declare const shaderKinds: {
107
116
  readonly "pixel-rain": "generative";
108
117
  readonly "pixel-sea": "generative";
109
118
  readonly plasma: "generative";
119
+ readonly pulsar: "graph";
110
120
  readonly radiance: "generative";
111
121
  readonly reveal: "source";
112
122
  readonly ripple: "generative";