diff --git a/flow-state/src/scenes/registry.js b/flow-state/src/scenes/registry.js index ebb5ee9..30fcb8e 100644 --- a/flow-state/src/scenes/registry.js +++ b/flow-state/src/scenes/registry.js @@ -42,6 +42,9 @@ import { eclipseField } from './shader/eclipse-field.js'; import { girderLattice } from './shader/girder-lattice.js'; import { quasicrystal } from './shader/quasicrystal.js'; import { timeSmear } from './shader/time-smear.js'; +import { rainColumn } from './shader/rain-column.js'; +import { magnetLines } from './shader/magnet-lines.js'; +import { karmanStreet } from './shader/karman-street.js'; /** * The scene library. Families exist so the arc driver can choose by section @@ -109,6 +112,9 @@ const MODULES = [ girderLattice, quasicrystal, timeSmear, + rainColumn, + magnetLines, + karmanStreet, ]; const errors = []; diff --git a/flow-state/src/scenes/shader/karman-street.js b/flow-state/src/scenes/shader/karman-street.js new file mode 100644 index 0000000..81cd7ab --- /dev/null +++ b/flow-state/src/scenes/shader/karman-street.js @@ -0,0 +1,107 @@ +// Flow family: a vortex street. One bluff body sits still in a moving stream and +// sheds vortices alternately off each shoulder; they drift downstream, growing +// and weakening, curling the streamlines as they pass. +// +// Every other flow scene moves the whole frame — Vortex Drift spins it, Curl +// Flow advects it, Aurora Veil sweeps it. This one has a fixed obstacle and a +// wake, so the image has an upstream and a downstream and a still point in it, +// and the motion is a consequence of the body rather than the state of the +// frame. The body is the track's signature form. + +export const karmanStreet = { + name: 'Kármán Street', + family: 'flow', + kind: 'fragment', + texture: 0.7, + traits: ['shape', 'camera', 'space', 'style'], + + params: { + shed: { type: 'float', range: [0.03, 0.5], default: 0.12, uniform: 'u_shed', bias: 'motion', rate: true }, + drift: { type: 'float', range: [0.15, 0.7], default: 0.42, uniform: 'u_drift' }, + offset: { type: 'float', range: [0.05, 0.5], default: 0.22, uniform: 'u_offset' }, + radius: { type: 'float', range: [0.06, 0.3], default: 0.15, uniform: 'u_radius' }, + body: { type: 'float', range: [0.05, 0.28], default: 0.13, uniform: 'u_body' }, + streams: { type: 'float', range: [4, 44], default: 16, uniform: 'u_streams', bias: 'density' }, + warp: { type: 'float', range: [0, 0.5], default: 0.22, uniform: 'u_warp' }, + wake: { type: 'float', range: [0, 1], default: 0.35, uniform: 'u_wake', slowAxis: true }, + glow: { type: 'float', range: [0, 1.4], default: 0.55, uniform: 'u_glow', bias: 'energy' }, + palette: { type: 'palette', count: 5 }, + }, + + reactive: { + glow: { feature: 'loudness', amount: 0.3, response: 'smooth' }, + radius: { feature: 'bandLow', amount: 0.25, response: 'smooth' }, + }, + + shader: ` +vec4 scene(vec2 uv, vec2 p) { + float t = u_time * u_shed + u_seed; + p = sigCamera(p); + + // The stream runs along the track's horizon, so the wake sits in the same + // place the ground does in every other scene that has one. + float lane = sigHorizonY() * 0.35; + vec2 origin = vec2(-0.85, lane); + + // Eight vortices in flight at once: one shed per unit of t, alternating + // sides, each one older and larger than the one behind it. + vec2 vel = vec2(0.0); + float vort = 0.0; + // lint: fixed-cost — eight vortices in flight, not a search + for (int i = 0; i < 8; i++) { + float age = fract(t) + float(i); + float idx = floor(t) - float(i); + float sgn = mod(idx, 2.0) < 0.5 ? 1.0 : -1.0; + + vec2 c = origin + vec2(age * u_drift, + sgn * u_offset * (1.0 - exp(-age * 1.8)) + sin(age * 0.6 + idx) * 0.02); + float rad = u_radius * (0.55 + age * 0.16); + + vec2 d = p - c; + float r2 = dot(d, d) + 1e-4; + float fall = exp(-r2 / (rad * rad)); + + // Induced velocity, bounded at the core so the centre does not blow up. + vel += sgn * vec2(-d.y, d.x) * (fall / (r2 + rad * rad)) * rad; + vort += sgn * fall * exp(-age * 0.25); + } + + // Streamlines: a plain ruled field, dragged by the induced velocity. The + // curl in the image is entirely the vortices bending straight lines. + vec2 q = p - vel * u_warp; + float ruled = sin(q.y * u_streams) * 0.5 + 0.5; + float w = 0.25 + u_sigLine * 0.4; + float line = smoothstep(w, w * 0.2, abs(ruled - 0.5) * 2.0); + + // Turbulent haze: everything downstream of the body is stirred, and the + // band of stirred water widens across the track. That is the scene's long + // journey — a clean stream at the top of the video and a churned one at the + // end — so the haze both lights the wake and dissolves the ruled lines + // inside it, rather than being a wash laid over an unchanged image. + float lanePos = (p.y - lane) / (0.1 + u_wake * 0.8); + float haze = exp(-lanePos * lanePos) * smoothstep(-0.15, 0.6, p.x - origin.x); + line *= 1.0 - 0.85 * haze * u_wake; + + vec3 col = mix(pal(0) * 0.08, pal(1) * 0.14, uv.y); + col += palRamp(0.45 + q.y * 0.05) * line * (0.35 + u_glow * 0.45); + col += pal(2) * haze * u_wake * 0.9; + col += pal(4) * haze * sat(abs(vort)) * u_wake * 0.5; + + // The cores themselves, warm one way and cool the other. + col += pal(3) * sat(vort) * u_glow * 0.5; + col += pal(2) * sat(-vort) * u_glow * 0.5; + + // The obstacle: still, solid, with the stream piling up on its nose. + float d = sigShape((p - origin) / u_body) * u_body; + col = mix(col, pal(0) * 0.1, smoothstep(0.005, -0.005, d)); + col += pal(4) * sigEdge(d) * (0.5 + u_glow * 0.5); + col += pal(4) * exp(-abs(d) * 14.0) * smoothstep(0.1, -0.3, p.x - origin.x) * u_glow * 0.35; + + col = sigAir(col, p, smoothstep(0.0, 1.6, length(p - origin) * 0.7)); + col += sigGrain(uv); + return vec4(col, 1.0); +} +`, +}; + +export default karmanStreet; diff --git a/flow-state/src/scenes/shader/magnet-lines.js b/flow-state/src/scenes/shader/magnet-lines.js new file mode 100644 index 0000000..8f26076 --- /dev/null +++ b/flow-state/src/scenes/shader/magnet-lines.js @@ -0,0 +1,94 @@ +// Flow family: the field lines of three drifting magnetic poles, drawn the way +// iron filings draw them — as closed loops leaving one pole and arriving at the +// next, thickening where the field is strong. +// +// Curl Flow and Smoke Column sample a noise field and let particles wander in +// it; nothing about the image is solved. Here the lines are the exact contours +// of the stream function of the poles, so they never cross, never fray, and +// reconfigure globally the instant a pole moves — a field with rules rather than +// a field with texture. The poles themselves are the track's signature form. + +export const magnetLines = { + name: 'Magnet Lines', + family: 'flow', + kind: 'fragment', + // Line work. Grain furs it up, so take only a dusting. + texture: 0.5, + traits: ['shape', 'camera', 'style'], + + params: { + lines: { type: 'int', range: [3, 26], default: 11, uniform: 'u_lines', bias: 'density' }, + spread: { type: 'float', range: [0.15, 1.1], default: 0.55, uniform: 'u_spread', slowAxis: true }, + orbit: { type: 'float', range: [0.01, 0.3], default: 0.05, uniform: 'u_orbit', bias: 'motion', rate: true }, + core: { type: 'float', range: [0.02, 0.22], default: 0.09, uniform: 'u_core' }, + filings: { type: 'float', range: [0, 1], default: 0.45, uniform: 'u_filings' }, + glow: { type: 'float', range: [0, 1.5], default: 0.6, uniform: 'u_glow', bias: 'energy' }, + palette: { type: 'palette', count: 5 }, + }, + + reactive: { + glow: { feature: 'bandLow', amount: 0.35, response: 'smooth' }, + core: { feature: 'beat', amount: 0.2, response: 'spike' }, + }, + + shader: ` +// Pole i, on a slow lissajous so the three never settle into a rotation. +vec2 poleAt(int i, float t) { + float fi = float(i); + float a = t + fi * 2.0944; + return vec2(cos(a), sin(a * 1.31 + fi)) * u_spread; +} + +vec4 scene(vec2 uv, vec2 p) { + float t = u_time * u_orbit + u_seed; + p = sigCamera(p); + + // Stream function of the poles. Its contours ARE the field lines, which is + // why they close on themselves for free. Charges sum to +1 so the whole + // thing wraps once per turn at infinity, and an integer contour count keeps + // that wrap continuous instead of leaving a seam. + float psi = 0.0; + float energy = 0.0; + // lint: fixed-cost — three poles, not a search + for (int i = 0; i < 3; i++) { + vec2 d = p - poleAt(i, t); + float q = mod(float(i), 2.0) < 0.5 ? 1.0 : -1.0; + psi += q * atan(d.y, d.x); + energy += 1.0 / (dot(d, d) * 14.0 + 0.35); + } + + float f = psi * float(u_lines) / 6.28318530718; + float band = abs(fract(f) - 0.5) * 2.0; // 0 on the line, 1 between lines + + // Lines are thin where the field is weak and thick where it crowds, which + // is the whole visual signature of filings. + float w = (0.18 + u_sigLine * 0.5) * (0.35 + sat(energy) * 0.9); + float line = smoothstep(w, w * 0.15, band); + + // Filings: the line is not solid, it is a queue of grains along it. + float grainAlong = vnoise(vec2(f * 40.0, psi * 3.0 + t)); + line *= mix(1.0, smoothstep(0.25, 0.75, grainAlong), u_filings); + + vec3 col = pal(0) * 0.06; + col += palRamp(0.15 + fract(f) * 0.15 + energy * 0.1) * line * (0.6 + u_glow * 0.7); + col += pal(2) * sat(energy) * 0.12 * u_glow; + + // The poles: the track's form, lit from inside, one hot and one cold. + // lint: fixed-cost — the same three poles + for (int i = 0; i < 3; i++) { + vec2 c = poleAt(i, t); + float d = sigShape((p - c) / u_core) * u_core; + vec3 tint = mod(float(i), 2.0) < 0.5 ? pal(3) : pal(4); + col = mix(col, tint * 0.25, smoothstep(0.004, -0.004, d)); + col += tint * sigEdge(d) * (0.5 + u_glow * 0.6); + col += tint * exp(-abs(d) * 12.0) * u_glow * 0.25; + } + + col *= 0.7 + 0.3 * exp(-dot(p, p) * 0.3); + col += sigGrain(uv); + return vec4(col, 1.0); +} +`, +}; + +export default magnetLines; diff --git a/flow-state/src/scenes/shader/rain-column.js b/flow-state/src/scenes/shader/rain-column.js new file mode 100644 index 0000000..d65931e --- /dev/null +++ b/flow-state/src/scenes/shader/rain-column.js @@ -0,0 +1,105 @@ +// Flow family: sheets of rain falling through three depth layers, sheared by a +// wind that gusts on the phrase, over a wet ground that catches the light. +// +// The flow family is otherwise made of continuous fields — smoke, curl, aurora, +// all of which advect and diffuse. Rain does neither: it is thousands of +// discrete hard streaks travelling in one direction at three different speeds, +// and the only thing that makes it read as a single weather system is that the +// wind shears every layer by the same angle. Smoke Column rises and spreads; +// this falls and stays separate. + +export const rainColumn = { + name: 'Rain Column', + family: 'flow', + kind: 'fragment', + traits: ['camera', 'space', 'style'], + + params: { + sheets: { type: 'float', range: [6, 90], default: 30, uniform: 'u_sheets', bias: 'density' }, + fall: { type: 'float', range: [0.15, 1.6], default: 0.55, uniform: 'u_fall', bias: 'motion', rate: true }, + streak: { type: 'float', range: [0.05, 0.7], default: 0.28, uniform: 'u_streak', slowAxis: true }, + wind: { type: 'float', range: [-0.9, 0.9], default: 0.25, uniform: 'u_wind' }, + gust: { type: 'float', range: [0, 0.5], default: 0.15, uniform: 'u_gust' }, + wet: { type: 'float', range: [0, 1], default: 0.55, uniform: 'u_wet' }, + glow: { type: 'float', range: [0, 1.4], default: 0.5, uniform: 'u_glow', bias: 'energy' }, + palette: { type: 'palette', count: 5 }, + }, + + reactive: { + glow: { feature: 'loudness', amount: 0.3, response: 'smooth' }, + wet: { feature: 'bandLow', amount: 0.2, response: 'smooth' }, + }, + + shader: ` +// One sheet of rain: a column grid, one drop per column, each with its own +// phase and a slightly different fall speed so the sheet never marches in step. +float sheet(vec2 q, float cols, float t, float len, float w, float salt) { + float x = q.x * cols; + float ci = floor(x); + float fx = (fract(x) - 0.5) / cols; // back into scene units + float ph = hash11(ci * 12.71 + salt + u_seed); + + float y = fract(q.y * 0.55 + t * (0.8 + ph * 0.5) + ph); + float head = smoothstep(len, 0.0, y); // bright at the head, fading behind + float body = smoothstep(w, w * 0.15, abs(fx)); + return head * head * body; +} + +vec4 scene(vec2 uv, vec2 p) { + float t = u_time * u_fall + u_seed; + p = sigCamera(p); + + float horizon = sigHorizonY() * 0.5 - 0.1; + float above = p.y - horizon; + + // The wind is one angle for the whole storm, gusting on the phrase so the + // rain leans as a body rather than per layer. + float lean = u_wind + sin(u_phrasePhase * 6.28318530718) * u_gust; + + // Sky: darkest overhead, brightest where the light gets under the cloud. + vec3 col = mix(pal(1) * 0.22, pal(0) * 0.06, sat(above * 0.7 + 0.1)); + col += pal(3) * exp(-abs(above) * 6.0) * u_glow * 0.35; + + // Three depth layers: far is dense, slow and dim; near is sparse, fast and + // wide. Same shear, so they read as one storm seen through itself. + float rain = 0.0; + // lint: fixed-cost — three depth layers, not a search + for (int i = 0; i < 3; i++) { + float d = float(i); + float near = d * 0.5; // 0 far .. 1 near + vec2 q = p; + q.x += q.y * lean; + q *= mix(2.2, 0.9, near); // parallax scale + q.x += near * 3.7; // decorrelate layers + float s = sheet(q, u_sheets * mix(1.4, 0.6, near), t * mix(0.7, 1.6, near), + u_streak, mix(0.35, 0.9, near) / u_sheets, d * 31.0); + rain += s * mix(0.35, 1.0, near); + } + rain *= smoothstep(-0.05, 0.35, above); // no rain below the ground + + col += palRamp(0.55 + rain * 0.2) * rain * (0.8 + u_glow * 0.9); + + { + float depth = sat(-above * 2.4); + vec3 ground = mix(pal(2) * 0.3, pal(0) * 0.12, depth); + + // Wet ground: a compressed mirror of the sky glow, broken up by the + // ripples the rain is putting into it. The mirror is what makes the + // ground read as water rather than as a dark half of the frame. + ground += pal(3) * exp(above * 4.0) * u_glow * 1.1 * u_wet; + + float ripple = sin((-above * 26.0) - t * 5.0 + vnoise(vec2(p.x * 5.0, t * 0.7)) * 7.0); + ground += pal(4) * sat(ripple) * u_wet * (0.06 + depth * 0.3); + ground += pal(3) * sat(ripple) * exp(above * 8.0) * u_wet * 0.5; + + col = mix(col, ground, smoothstep(0.03, -0.03, above)); + } + + col = sigAir(col, p, smoothstep(0.2, 1.5, abs(p.x) * 0.6 + sat(above))); + col += sigGrain(uv); + return vec4(col, 1.0); +} +`, +}; + +export default rainColumn;