diff --git a/flow-state/src/scenes/registry.js b/flow-state/src/scenes/registry.js index 540f516..51b41bf 100644 --- a/flow-state/src/scenes/registry.js +++ b/flow-state/src/scenes/registry.js @@ -19,6 +19,11 @@ import { scanTear } from './shader/scan-tear.js'; import { blockMosh } from './shader/block-mosh.js'; import { neonCity } from './shader/neon-city.js'; import { flora } from './shader/flora.js'; +import { fireflyDrift } from './shader/firefly-drift.js'; +import { silkRibbon } from './shader/silk-ribbon.js'; +import { pylonGrid } from './shader/pylon-grid.js'; +import { prismBloom } from './shader/prism-bloom.js'; +import { pitchShatter } from './shader/pitch-shatter.js'; /** * The scene library. Families exist so the arc driver can choose by section @@ -57,6 +62,11 @@ const MODULES = [ blockMosh, neonCity, flora, + fireflyDrift, + silkRibbon, + pylonGrid, + prismBloom, + pitchShatter, ]; const errors = []; diff --git a/flow-state/src/scenes/shader/firefly-drift.js b/flow-state/src/scenes/shader/firefly-drift.js new file mode 100644 index 0000000..97b8eee --- /dev/null +++ b/flow-state/src/scenes/shader/firefly-drift.js @@ -0,0 +1,62 @@ +// Flow family: a drifting swarm of glowing motes advected along a curl-noise +// field. Each mote rides a divergence-free curl flow and wanders on top of it, +// so the swarm pools and diverges like embers on a breeze rather than orbiting. +// +// A base field is always present so the scene stands alone before any feedback +// has converged; the motes just make the movement legible. + +export const fireflyDrift = { + name: 'Firefly Drift', + family: 'flow', + kind: 'fragment', + // Personality: see look/Personality.js. + traits: ['camera', 'style'], + + params: { + count: { type: 'float', range: [6, 48], default: 22, uniform: 'u_count', bias: 'density' }, + speed: { type: 'float', range: [0.05, 0.8], default: 0.3, uniform: 'u_speed', bias: 'motion', rate: true }, + glow: { type: 'float', range: [0, 1.2], default: 0.5, uniform: 'u_glow', bias: 'energy' }, + jitter: { type: 'float', range: [0, 0.4], default: 0.12, uniform: 'u_jitter' }, + spread: { type: 'float', range: [0.4, 1.3], default: 0.95, uniform: 'u_spread' }, + palette: { type: 'palette', count: 4 }, + }, + + reactive: { + glow: { feature: 'beat', amount: 0.3, response: 'spike' }, + jitter: { feature: 'bandHigh', amount: 0.2, response: 'smooth' }, + }, + + shader: ` +vec4 scene(vec2 uv, vec2 p) { + float t = u_time * u_speed + u_seed; + p = sigCamera(p); + + // Soft ambient wash so the frame is never black, even standing alone. + vec3 col = pal(0) * (0.06 + 0.05 * fbm(p * 1.5 + vec2(0.0, t * 0.3), 3)); + + for (int i = 0; i < 48; i++) { + if (float(i) >= u_count) break; + float fi = float(i); + + // Seed the mote to a stable place in the frame (deterministic per i). + vec2 grid = vec2(hash12(vec2(fi, 1.7)), hash12(vec2(fi, 9.1))); + vec2 base = (grid - 0.5) * 2.0 * u_spread * vec2(1.0, 0.7); + + // Advect along the curl field plus a bounded wander term on top. + vec2 flow = curl(grid * 3.0 + vec2(0.0, t * 0.15), t * 0.5); + vec2 pos = base + flow * 1.3 + + vec2(sin(t * (0.4 + fract(fi * 0.13))), + cos(t * (0.5 + fract(fi * 0.29)))) * u_jitter; + + float d = length(p - pos); + + vec3 hc = pal(int(mod(fi, 4.0))); + col += hc * (exp(-d * d / (0.015 + u_glow * 0.04)) * (1.0 + u_glow * 0.6)); + col += hc * exp(-d * d * 60.0); + } + + col += sigGrain(uv); + return vec4(col, 1.0); +} +`, +}; \ No newline at end of file diff --git a/flow-state/src/scenes/shader/pitch-shatter.js b/flow-state/src/scenes/shader/pitch-shatter.js new file mode 100644 index 0000000..7ad4603 --- /dev/null +++ b/flow-state/src/scenes/shader/pitch-shatter.js @@ -0,0 +1,71 @@ +// Glitch family: vertical transposition. The frame is cut into vertical bands, +// each of which shifts up or down by a small amount. That alone is a tear; what +// makes it churn is that the offset is re-rolled on the quantised grid and the +// previous frame's transposed image is smeared underneath — so slices translate +// to a new fixed pose each step instead of crawling, and the pitch reads as an +// edit rather than a broken renderer. + +export const pitchShatter = { + name: 'Pitch Shatter', + family: 'glitch', + kind: 'fragment', + // Personality: see look/Personality.js. + traits: ['camera', 'style'], + + params: { + slices: { type: 'float', range: [4, 40], default: 18, uniform: 'u_slices', bias: 'density' }, + amp: { type: 'float', range: [0, 0.35], default: 0.1, uniform: 'u_amp', bias: 'energy' }, + quantize: { type: 'float', range: [1, 8], default: 4, uniform: 'u_quantize' }, + bleed: { type: 'float', range: [0, 0.9], default: 0.4, uniform: 'u_bleed' }, + glow: { type: 'float', range: [0, 1], default: 0.3, uniform: 'u_glow', bias: 'energy' }, + speed: { type: 'float', range: [0.05, 0.8], default: 0.25, uniform: 'u_speed', bias: 'motion', rate: true }, + palette: { type: 'palette', count: 5 }, + }, + + reactive: { + amp: { feature: 'beat', amount: 0.4, response: 'spike' }, + bleed: { feature: 'flux', amount: 0.25, response: 'smooth' }, + glow: { feature: 'bandHigh', amount: 0.2 }, + }, + + shader: ` +vec4 scene(vec2 uv, vec2 p) { + float t = u_time * u_speed + u_seed; + p = sigCamera(p); + + // One shared grid for every discontinuity below: slices re-roll on the + // quantised step rather than moving continuously frame-to-frame. + float q = max(u_quantize, 1.0); + float step_ = floor(u_barPhase * q) + floor(t * 6.0) * q; + + // A vertical slice is a column of pixels; each gets one fixed offset per + // step, decided by a hash of (slice, step) so it jumps cleanly on the grid. + float slice = floor(uv.x * u_slices); + float sliceRand = hash12(vec2(slice, step_ * 31.0)); + float pitch = (sliceRand - 0.5) * 2.0 * u_amp; + + // The base image is always sampled from the un-pitched field so the scene + // has real content behind the displacement, even on its first frame. + vec2 baseUv = vec2(uv.x, fract(uv.y - pitch)); + float field = fbm(vec2(baseUv.x * 2.5, baseUv.y * 4.0) + t * 0.4, 4); + float ramp = fract(field * 2.0 + baseUv.y * 2.0 - t * 0.4); + vec3 col = palRamp(ramp * 0.7 + slice * 0.02); + col *= 0.4 + 0.6 * smoothstep(0.1, 0.9, field); + + // The smear: pull the previous frame across the pitch so slices leave a + // transient ghost as they land, and the corruption feels sludgy. + vec3 ghost = prev(vec2(uv.x, fract(uv.y + pitch * 0.55))); + col = mix(col, ghost, u_bleed * (0.4 + sliceRand)); + + // A thin scan beam rooting the hard edge of each step, kept local and beat + // -pinned so the frame never swings in whole-frame luminance. + float beamY = fract(u_beatPhase) * 2.0 - 1.0; + float beam = exp(-abs(p.y - beamY * 0.6) * 6.0); + col += pal(3) * beam * u_slices * 0.001 * (0.5 + u_beat); + + col += pal(int(mod(slice, 5.0))) * (1.0 - sliceRand) * u_glow * 0.3 * sliceRand; + col += sigGrain(uv); + return vec4(col, 1.0); +} +`, +}; \ No newline at end of file diff --git a/flow-state/src/scenes/shader/prism-bloom.js b/flow-state/src/scenes/shader/prism-bloom.js new file mode 100644 index 0000000..afe3fe3 --- /dev/null +++ b/flow-state/src/scenes/shader/prism-bloom.js @@ -0,0 +1,75 @@ +// Geometric family: a burst of rotating folded prisms. A core sits at the +// centre in the track's signature form, and around it a constellation of folded +// rings counter-rotate and breathe — prism facets read as hard geometry meeting +// a soft bloom. Everything is closed-form, so the whole lattice is seek-exact. + +export const prismBloom = { + name: 'Prism Bloom', + family: 'geometric', + kind: 'fragment', + // Personality: see look/Personality.js. + traits: ['shape', 'camera', 'style'], + + params: { + sites: { type: 'float', range: [2, 12], default: 6, uniform: 'u_sites', bias: 'density' }, + layers: { type: 'float', range: [1, 8], default: 5, uniform: 'u_layers', bias: 'density' }, + thickness: { type: 'float', range: [0.008, 0.05], default: 0.02, uniform: 'u_thickness' }, + radius: { type: 'float', range: [0.15, 0.7], default: 0.32, uniform: 'u_radius', bias: 'energy' }, + spin: { type: 'float', range: [0, 1.5], default: 0.4, uniform: 'u_spin', bias: 'motion' }, + glow: { type: 'float', range: [0, 1.4], default: 0.5, uniform: 'u_glow', bias: 'energy' }, + speed: { type: 'float', range: [0.05, 0.8], default: 0.3, uniform: 'u_speed', bias: 'motion', rate: true }, + palette: { type: 'palette', count: 6 }, + }, + + reactive: { + glow: { feature: 'beat', amount: 0.35, response: 'smooth' }, + radius: { feature: 'bandLow', amount: 0.2 }, + spin: { feature: 'flux', amount: 0.15, response: 'smooth' }, + }, + + shader: ` +// A ring whose radius is folded into N star points, keeping the geometry angular +// rather than circular. +float ring(vec2 q, float sites, float radius, float width) { + float a = atan(q.y, q.x); + float seg = 6.28318530718 / sites; + float wa = abs(mod(a + seg * 0.5, seg) - seg * 0.5); + float starRadius = radius * max(cos(wa * sites), 1e-3); + float d = abs(length(q) - starRadius); + return smoothstep(width + 0.004, width - 0.004, d); +} + +vec4 scene(vec2 uv, vec2 p) { + float t = u_time * u_speed + u_seed; + p = sigFolded(sigCamera(p)); + + // Facet count yields to the track's signature form when it names one. + float sites = u_sigSides > 2.5 ? u_sigSides : float(u_sites); + + vec3 col = pal(0) * 0.04; + for (int i = 0; i < 8; i++) { + if (float(i) >= u_layers) break; + float fi = float(i); + + // Layers counter-rotate and breathe independently; all closed-form. + float layerRadius = u_radius * (0.5 + fi * 0.24); + float breathe = 0.78 + 0.22 * sin(u_time * 0.9 + fi * 1.7); + vec2 lp = rot(fi * 0.7 + t * (fi * 0.5 + 0.3) * u_spin) * p; + + float r = ring(lp, sites, layerRadius * breathe, u_thickness); + col += pal(int(mod(fi, 6.0))) * r * (0.4 + u_glow * 0.8); + + float halo = exp(-(length(lp) / max(layerRadius * breathe, 1e-3)) * 3.0) * u_glow * 0.25; + col += pal(0) * halo; + } + + // The heart of the bloom is the track's own form. + float core = sigForm(p, vec2(0.0), 0.09 + u_beat * 0.06); + col = mix(col, pal(3), core); + col += pal(3) * exp(-dot(p, p) * 8.0) * (0.5 + u_glow); + + col += sigGrain(uv); + return vec4(col, 1.0); +} +`, +}; \ No newline at end of file diff --git a/flow-state/src/scenes/shader/pylon-grid.js b/flow-state/src/scenes/shader/pylon-grid.js new file mode 100644 index 0000000..a37041b --- /dev/null +++ b/flow-state/src/scenes/shader/pylon-grid.js @@ -0,0 +1,84 @@ +// Structural family: a field of standing pylons receding to the track's +// horizon. The columns converge on a vanishing point, giving real perspective +// depth, and each column carries a crown in the signature form so a hexagonal +// track grows hexagonal pylons. The beat walks a pulse of light down the rows +// rather than flashing the whole frame. + +export const pylonGrid = { + name: 'Pylon Grid', + family: 'structural', + kind: 'fragment', + // Personality: see look/Personality.js. + traits: ['shape', 'space'], + + params: { + columns: { type: 'float', range: [3, 16], default: 8, uniform: 'u_columns', bias: 'density' }, + rows: { type: 'float', range: [2, 10], default: 6, uniform: 'u_rows', bias: 'density' }, + spread: { type: 'float', range: [0.6, 1.6], default: 1.1, uniform: 'u_spread' }, + height: { type: 'float', range: [0.3, 1.4], default: 0.85, uniform: 'u_height', bias: 'energy' }, + pulse: { type: 'float', range: [0, 1.2], default: 0.5, uniform: 'u_pulse', bias: 'energy' }, + speed: { type: 'float', range: [0.05, 0.8], default: 0.3, uniform: 'u_speed', bias: 'motion', rate: true }, + palette: { type: 'palette', count: 4 }, + }, + + reactive: { + pulse: { feature: 'beat', amount: 0.4, response: 'smooth' }, + rows: { feature: 'bandLow', amount: 0.25 }, + }, + + shader: ` +// Distance to a thin vertical strut from (cx, y0) up to (cx, y1). +float strut(vec2 p, float cx, float y0, float y1, float w) { + float xd = abs(p.x - cx); + float yd = max(max(y0 - p.y, p.y - y1), 0.0); + float d = sqrt(xd * xd + yd * yd); + return smoothstep(w + 0.004, w - 0.004, d); +} + +vec4 scene(vec2 uv, vec2 p) { + float t = u_time * u_speed + u_seed; + float hy = sigHorizonY(); + vec3 col = pal(0) * 0.05; + + for (int r = 0; r < 10; r++) { + if (float(r) >= u_rows) break; + float fr = float(r); + + // Perspective: the last row sits on the horizon, the first at the bottom + // of the frame, everything between scaling by its depth fraction. + float f = fr / max(u_rows - 1.0, 1.0); // 0 near .. 1 far + float y0 = mix(-1.08, hy + 0.06, f); // row baseline + float scale = mix(1.0, 0.12, f); // perspective shrink + float depthShade = mix(1.0, 0.35, f); // far rows dim + + for (int c = 0; c < 16; c++) { + if (float(c) >= u_columns) break; + float fc = float(c); + float cx = (fc / max(u_columns - 1.0, 1.0) - 0.5) * 2.0 * u_spread * scale; + + float h = u_height * scale; + float yTop = y0 + h; + + // The beat pulse walks a glow down the depth axis, row by row, so the + // light travels rather than strobing the whole frame. + float walk = sat(1.0 - abs(fr - mod(u_beatPhase * 6.0 + t * 0.4, u_rows))); + float inten = (0.35 + 0.65 * walk) * depthShade * (0.7 + 0.3 * u_beat); + + float w = 0.02 * scale + 0.006; + col += pal(int(mod(fc, 4.0))) * strut(p, cx, y0, yTop, w) * inten; + + // Crown stamped in the track's signature form. + vec2 crown = vec2(cx, yTop); + float size = (0.06 + 0.2 * scale) * (1.0 + u_beat * 0.4); + col += pal(3) * sigForm(p, crown, size) * inten * (0.6 + u_pulse); + col += pal(int(mod(fr, 4.0))) * exp(-dot(p - crown, p - crown) * 60.0) * u_pulse * depthShade; + } + } + + // Ground haze and the shared air between here and the horizon. + col = sigAir(col, p, smoothstep(0.0, 1.6, abs(p.y - hy))); + col += sigGrain(uv); + return vec4(col, 1.0); +} +`, +}; \ No newline at end of file diff --git a/flow-state/src/scenes/shader/silk-ribbon.js b/flow-state/src/scenes/shader/silk-ribbon.js new file mode 100644 index 0000000..b9780bd --- /dev/null +++ b/flow-state/src/scenes/shader/silk-ribbon.js @@ -0,0 +1,68 @@ +// Minimal family: a single ribbon of light draping across an almost empty +// frame. The curve is a closed-form travelling wave, so it is seek-exact; it is +// drawn by sampling the polyline and taking the minimum distance, which reads as +// one fluid strand rather than a repeated motif. Most of the frame is negative +// space — what an intro or a breakdown needs. + +export const silkRibbon = { + name: 'Silk Ribbon', + family: 'minimal', + kind: 'fragment', + // Personality: see look/Personality.js. + traits: ['camera', 'style'], + + params: { + thickness: { type: 'float', range: [0.008, 0.08], default: 0.03, uniform: 'u_thickness' }, + wave: { type: 'float', range: [0.1, 2.5], default: 1.1, uniform: 'u_wave' }, + sway: { type: 'float', range: [0.05, 0.6], default: 0.25, uniform: 'u_sway', bias: 'motion' }, + glow: { type: 'float', range: [0, 1.5], default: 0.5, uniform: 'u_glow', bias: 'energy' }, + speed: { type: 'float', range: [0.02, 0.4], default: 0.12, uniform: 'u_speed', bias: 'motion', rate: true }, + spread: { type: 'float', range: [0.5, 1.2], default: 0.9, uniform: 'u_spread' }, + palette: { type: 'palette', count: 4 }, + }, + + reactive: { + glow: { feature: 'beat', amount: 0.3, response: 'smooth' }, + wave: { feature: 'bandLow', amount: 0.25, response: 'smooth' }, + }, + + shader: ` +// Nearest distance from p to a fixed travelling strand. +vec4 scene(vec2 uv, vec2 p) { + float t = u_time * u_speed + u_seed; + p = sigCamera(p); + + // Bare wash so the frame is never black. + vec3 col = pal(0) * 0.04; + + // Two nested strands, offset in phase so the ribbon reads as a drape with a + // folded edge rather than a single hairline. + for (int k = 0; k < 2; k++) { + float fk = float(k); + float ph = fk * 2.2 + t * 0.3; + float best = 1e9; + + for (int n = 0; n < 48; n++) { + float uu = (float(n) + 0.5) / 48.0; + float x = (uu - 0.5) * 2.0 * u_spread; + float y = sin(uu * 6.28318530718 * u_wave + ph) * 0.28 + + sin(uu * 13.1 - t * 0.8 + fk) * 0.06 + + u_sway * 0.4 * sin(t * 0.6 + fk); + float d = length(p - vec2(x, y)); + best = min(best, d); + } + + vec3 hc = pal(int(fk) % 3); + col += hc * exp(-best * best / (u_thickness * u_thickness)); + col += pal(3) * exp(-best * best * 60.0) * u_glow * 0.6; + } + + // Knot the ribbon just below frame centre so it has somewhere to pull from. + float knot = length(p - vec2(0.0, 0.1)); + col = mix(col, pal(2), exp(-knot * knot * 30.0) * u_glow * 0.5); + + col += sigGrain(uv); + return vec4(col, 1.0); +} +`, +}; \ No newline at end of file