Three flow scenes with a still point in them

Rain Column, Magnet Lines and Kármán Street. The flow family was seven ways of
advecting a noise field; none of them had an upstream, an obstacle or a rule.
These do: rain falls in three parallax layers under one shear, the field lines
are exact contours of the poles' stream function so they close on themselves,
and the vortex street is shed by a body that never moves.

The slow axes took four rounds to land. A fine-grained axis — line count, drop
density — loses to the camera's own drift in the time-averaged comparison Phase
11 makes, so all three ended up on a param that moves a large low-frequency
area: streak length, pole spread, and a wake haze that dissolves the streamlines
it widens over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino 2026-08-15 22:42:21 +02:00
parent 47e6fa33f9
commit 10a4cff0f6
4 changed files with 312 additions and 0 deletions

View File

@ -42,6 +42,9 @@ import { eclipseField } from './shader/eclipse-field.js';
import { girderLattice } from './shader/girder-lattice.js'; import { girderLattice } from './shader/girder-lattice.js';
import { quasicrystal } from './shader/quasicrystal.js'; import { quasicrystal } from './shader/quasicrystal.js';
import { timeSmear } from './shader/time-smear.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 * The scene library. Families exist so the arc driver can choose by section
@ -109,6 +112,9 @@ const MODULES = [
girderLattice, girderLattice,
quasicrystal, quasicrystal,
timeSmear, timeSmear,
rainColumn,
magnetLines,
karmanStreet,
]; ];
const errors = []; const errors = [];

View File

@ -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;

View File

@ -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;

View File

@ -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;