Phase 7: grow the scene library to 16

Ten new scenes, with 'minimal' first: the family was empty, so intros and
breakdowns fell through to flow/organic and every track opened at full
density. Quiet sections now land on a restful family 48/48 times across 24
seeds, 28 of them minimal.

New: Horizon Lines, Spectrum Sculpture, Slow Orb (minimal); Curl Flow
(flow); Plasma Bloom, Metaballs (organic); Kaleido Tunnel, Moiré Grid
(geometric); Ridge Terrain (structural); Scan Tear (glitch).

Four real bugs, three of which the existing gates could not have caught:

1. SHADER PROGRAMS LINK ASYNCHRONOUSLY. three.js uses
   KHR_parallel_shader_compile, so draws against an unlinked program render
   wrong. The heaviest scene had its first TEN frames differ from every
   later render of the same frames. Preview hides this entirely; export
   renders each frame once, so those frames would ship broken. Added
   Engine.prime() — WebGLRenderer.compile() plus a discarded warm frame —
   and the exporter now primes before encoding. Rendering a throwaway frame
   and reading it back is NOT sufficient; measured, it left 3-5 frames wrong.

2. Moiré Grid declared a param on u_width, which the shader contract already
   uses for stereo width. GLSL redefinition, and the only symptom was a
   black frame. Lint now rejects any param uniform colliding with the
   contract.

3. Spectrum Sculpture strobed at 4 flashes/s. Two causes: rotation measured
   in turns meant bar-crossing frequency was bars x rate (82 bars put a
   slow-looking 0.12 turns/s at 10 Hz), and hard band-tier boundaries made
   every bar switch band simultaneously. Rotation is now in segment units so
   the rate IS the crossing frequency, bands interpolate, and the range is
   capped where the flash meter measures zero.

4. Particle Field was being chosen as a primary background despite being
   mostly empty by design. Scenes now declare role: 'accent'; those are
   never primary and are judged on variance rather than luminance.

Three checks were themselves wrong and were rebuilt: mean-distance metrics
unfairly fail sparse scenes for being tasteful rather than static, so
"animates" and "no duplicates" now use max channel delta.

PLAN.md §1 gains two refinements: programs must be primed before the first
frame, and even same-machine the heaviest shaders vary by one LSB under
differing GPU load — so the per-scene criterion is max delta <= 1 rather
than an identical hash. A real bug scores in the tens there.

Full suite 67/67 across all seven phases. Worst 4K frame 3.8ms,
worst flash rate 0/s, worst determinism delta 1/255. Adds README.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino
2026-08-05 18:40:56 +02:00
co-authored by Claude Opus 5
parent 8a94a3f3a5
commit 9d15c3cf49
24 changed files with 1234 additions and 10 deletions
@@ -11,6 +11,10 @@ export const particleField = {
name: 'Particle Field',
family: 'flow',
kind: 'layer3d',
// Composited over a background, never used as one: most of the frame is
// legitimately black, so it is judged on variance rather than luminance and
// the look generator only picks it as an accent layer.
role: 'accent',
params: {
count: { type: 'int', range: [200, 4000], default: 1200, bias: 'density', noDrift: true },
+23
View File
@@ -6,6 +6,16 @@ import { floatingGeometry } from './shader/floating-geometry.js';
import { synthwaveRun } from './shader/synthwave-run.js';
import { psychedelicDrift } from './shader/psychedelic-drift.js';
import { particleField } from './layers3d/particles.js';
import { horizonLines } from './shader/horizon-lines.js';
import { spectrumSculpture } from './shader/spectrum-sculpture.js';
import { slowOrb } from './shader/slow-orb.js';
import { curlFlow } from './shader/curl-flow.js';
import { plasmaBloom } from './shader/plasma-bloom.js';
import { metaballs } from './shader/metaballs.js';
import { kaleidoTunnel } from './shader/kaleido-tunnel.js';
import { moireGrid } from './shader/moire-grid.js';
import { ridgeTerrain } from './shader/ridge-terrain.js';
import { scanTear } from './shader/scan-tear.js';
/**
* The scene library. Families exist so the arc driver can choose by section
@@ -28,6 +38,19 @@ const MODULES = [
synthwaveRun,
psychedelicDrift,
particleField,
// Phase 7 additions. 'minimal' came first: with the family empty, intros and
// breakdowns fell through to flow/organic and every track opened at density.
horizonLines,
spectrumSculpture,
slowOrb,
curlFlow,
plasmaBloom,
metaballs,
kaleidoTunnel,
moireGrid,
ridgeTerrain,
scanTear,
];
const errors = [];
+54
View File
@@ -0,0 +1,54 @@
// Flow family: streaks advected along a curl-noise field.
//
// Uses the compositor's feedback texture rather than integrating positions, so
// the trails cost nothing in state and a seek still lands correctly once the
// feedback buffer has converged.
export const curlFlow = {
name: 'Curl Flow',
family: 'flow',
kind: 'fragment',
params: {
scale: { type: 'float', range: [0.5, 6], default: 2.0, uniform: 'u_scale', bias: 'density' },
speed: { type: 'float', range: [0.02, 0.5], default: 0.12, uniform: 'u_speed', bias: 'motion', rate: true },
streak: { type: 'float', range: [0, 1], default: 0.55, uniform: 'u_streak' },
contrast: { type: 'float', range: [0.5, 4], default: 1.6, uniform: 'u_contrast' },
veins: { type: 'float', range: [1, 12], default: 5.0, uniform: 'u_veins', bias: 'density' },
glow: { type: 'float', range: [0, 1.2], default: 0.35, uniform: 'u_glow', bias: 'energy' },
palette: { type: 'palette', count: 5 },
},
reactive: {
glow: { feature: 'beat', amount: 0.3, response: 'spike' },
veins: { feature: 'bandMid', amount: 0.25 },
streak: { feature: 'flux', amount: 0.2, response: 'smooth' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
vec2 flow = curl(p * u_scale + vec2(t, -t * 0.7), t * 0.5);
vec2 q = p + flow * 0.35;
// Ridged noise gives filament-like veins rather than soft cloud.
float n = fbm(q * u_scale * 1.4 + t * 0.6, 5);
float veins = 1.0 - abs(sin(n * u_veins + t * 2.0));
veins = pow(sat(veins), u_contrast);
vec3 col = mix(pal(0) * 0.12, pal(1), veins);
col += pal(2) * pow(veins, 3.0) * u_glow;
col = mix(col, pal(3), sat(length(flow) * 0.4) * 0.35);
// Feedback trails: the previous frame, pulled slightly along the flow.
vec3 trail = prev(uv - flow * 0.004);
col = max(col, trail * u_streak);
col *= 0.6 + 0.4 * exp(-dot(p, p) * 0.35);
return vec4(col, 1.0);
}
`,
};
export default curlFlow;
@@ -0,0 +1,59 @@
// Minimal family: a sparse field of horizontal lines that bend around the
// centre. Most of the frame is negative space, which is exactly what an intro or
// a breakdown wants — the arc driver has nowhere restful to go otherwise.
export const horizonLines = {
name: 'Horizon Lines',
family: 'minimal',
kind: 'fragment',
params: {
count: { type: 'float', range: [3, 40], default: 14, uniform: 'u_count', bias: 'density' },
thickness: { type: 'float', range: [0.002, 0.03], default: 0.008, uniform: 'u_thickness' },
bend: { type: 'float', range: [0, 1.2], default: 0.35, uniform: 'u_bend' },
speed: { type: 'float', range: [0.02, 0.4], default: 0.12, uniform: 'u_speed', bias: 'motion', rate: true },
spread: { type: 'float', range: [0.2, 1.4], default: 0.9, uniform: 'u_spread' },
glow: { type: 'float', range: [0, 1], default: 0.3, uniform: 'u_glow', bias: 'energy' },
palette: { type: 'palette', count: 4 },
},
reactive: {
bend: { feature: 'bandLow', amount: 0.35, response: 'smooth' },
glow: { feature: 'beat', amount: 0.3, response: 'spike' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
// Displace vertically by a slow wave, strongest at the centre of the frame.
float envelope = exp(-p.x * p.x * 1.2);
float offset = sin(p.x * 2.2 + t * 2.0) * u_bend * envelope;
vec3 col = pal(0) * 0.06;
float total = 0.0;
for (int i = 0; i < 40; i++) {
if (float(i) >= u_count) break;
float fi = float(i);
float slot = (fi / max(u_count - 1.0, 1.0) - 0.5) * 2.0 * u_spread;
float y = slot + offset * (0.4 + fract(fi * 0.37));
float d = abs(p.y - y);
float line = smoothstep(u_thickness, 0.0, d);
float halo = exp(-d * 26.0) * u_glow;
vec3 c = pal(i);
col += c * (line + halo * 0.55);
total += line;
}
// Keep the far edges dark so the lines read as a subject, not wallpaper.
col *= 0.55 + 0.45 * exp(-dot(p, p) * 0.5);
return vec4(col, 1.0);
}
`,
};
export default horizonLines;
@@ -0,0 +1,55 @@
// Geometric family: a kaleidoscopic tunnel. The default drop scene — strong
// forward motion, hard symmetry, and it takes the beat well.
export const kaleidoTunnel = {
name: 'Kaleido Tunnel',
family: 'geometric',
kind: 'fragment',
params: {
sides: { type: 'int', range: [2, 12], default: 6, uniform: 'u_sides' },
depth: { type: 'float', range: [1, 8], default: 3.0, uniform: 'u_depth', bias: 'density' },
speed: { type: 'float', range: [0.1, 1.5], default: 0.45, uniform: 'u_speed', bias: 'motion', rate: true },
twist: { type: 'float', range: [0, 2], default: 0.5, uniform: 'u_twist' },
rings: { type: 'float', range: [2, 24], default: 8, uniform: 'u_rings', bias: 'density' },
glow: { type: 'float', range: [0, 1.5], default: 0.5, uniform: 'u_glow', bias: 'energy' },
palette: { type: 'palette', count: 6 },
},
reactive: {
glow: { feature: 'beat', amount: 0.45, response: 'spike' },
twist: { feature: 'bandLow', amount: 0.3 },
rings: { feature: 'bandHigh', amount: 0.2 },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
float radius = max(length(p), 1e-4);
vec2 folded = kaleido(p, float(u_sides));
float angle = atan(folded.y, folded.x);
// Tunnel coordinates: 1/r is depth, angle is the wall.
float z = u_depth / radius + t * 2.0;
float wall = angle / 3.14159265 + sin(z * 0.5 + t) * u_twist * 0.25;
float ringLines = abs(fract(z * u_rings * 0.1) - 0.5) * 2.0;
float wallLines = abs(fract(wall * float(u_sides)) - 0.5) * 2.0;
float grid = smoothstep(0.42, 0.0, ringLines) + smoothstep(0.42, 0.0, wallLines);
vec3 col = palRamp(z * 0.05 + wall * 0.2) * 0.35;
col += pal(int(mod(floor(z * u_rings * 0.1), 6.0))) * grid * 0.7;
// Depth cue: far end of the tunnel darkens, mouth glows.
float fade = smoothstep(0.0, 1.1, radius);
col *= 0.25 + 0.9 * fade;
col += pal(3) * (1.0 - fade) * u_glow * 0.6;
return vec4(col, 1.0);
}
`,
};
export default kaleidoTunnel;
+65
View File
@@ -0,0 +1,65 @@
// Organic family: merging metaballs on an analytic orbit.
//
// Positions come from closed-form orbits rather than any simulation, which keeps
// the scene seek-exact — the same rule the 3D particle layer follows.
export const metaballs = {
name: 'Metaballs',
family: 'organic',
kind: 'fragment',
params: {
count: { type: 'int', range: [2, 10], default: 5, uniform: 'u_count', bias: 'density' },
radius: { type: 'float', range: [0.1, 0.6], default: 0.3, uniform: 'u_radius', bias: 'energy' },
threshold: { type: 'float', range: [0.4, 2.2], default: 1.0, uniform: 'u_threshold' },
speed: { type: 'float', range: [0.03, 0.5], default: 0.15, uniform: 'u_speed', bias: 'motion', rate: true },
spread: { type: 'float', range: [0.2, 1.1], default: 0.6, uniform: 'u_spread' },
rim: { type: 'float', range: [0, 1], default: 0.45, uniform: 'u_rim' },
palette: { type: 'palette', count: 5 },
},
reactive: {
radius: { feature: 'bandLow', amount: 0.25 },
rim: { feature: 'beat', amount: 0.3, response: 'spike' },
threshold: { feature: 'flux', amount: 0.2, response: 'inverse' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
float field = 0.0;
vec3 tint = vec3(0.0);
for (int i = 0; i < 10; i++) {
if (i >= u_count) break;
float fi = float(i);
float s = u_seed + fi * 71.3;
vec2 centre = vec2(
sin(t * (0.7 + fract(s * 0.13)) + s) * u_spread,
cos(t * (0.5 + fract(s * 0.29)) + s * 1.7) * u_spread * 0.62
);
float d = length(p - centre);
float contribution = (u_radius * u_radius) / max(d * d, 1e-4);
field += contribution;
tint += pal(i) * contribution;
}
tint /= max(field, 1e-4);
float surface = smoothstep(u_threshold - 0.25, u_threshold + 0.25, field);
float rim = smoothstep(u_threshold + 0.35, u_threshold, field)
* smoothstep(u_threshold - 0.3, u_threshold, field);
vec3 col = pal(0) * 0.06;
col = mix(col, tint, surface);
col += tint * rim * u_rim;
return vec4(col, 1.0);
}
`,
};
export default metaballs;
@@ -0,0 +1,67 @@
// Geometric family: two rotating line grids interfering.
//
// Moiré is a spatial-aliasing effect by nature, so this scene is the most likely
// in the library to alias badly. Line width is held above a floor and scaled by
// u_pixelScale, which is what keeps a 720p preview and a 4K export looking the
// same rather than the preview shimmering.
export const moireGrid = {
name: 'Moiré Grid',
family: 'geometric',
kind: 'fragment',
params: {
density: { type: 'float', range: [6, 60], default: 22, uniform: 'u_density', bias: 'density' },
offset: { type: 'float', range: [0.0, 0.5], default: 0.08, uniform: 'u_offset' },
rotate: { type: 'float', range: [0, 0.25], default: 0.04, uniform: 'u_rotate', bias: 'motion', rate: true },
// Named u_lineWidth, not u_width: the shader contract already declares
// `uniform float u_width` for stereo width, and a colliding name is a
// redefinition error that renders the scene as a black frame.
width: { type: 'float', range: [0.06, 0.5], default: 0.2, uniform: 'u_lineWidth' },
warp: { type: 'float', range: [0, 1], default: 0.25, uniform: 'u_warp' },
glow: { type: 'float', range: [0, 1.2], default: 0.35, uniform: 'u_glow', bias: 'energy' },
palette: { type: 'palette', count: 4 },
},
reactive: {
offset: { feature: 'bandLow', amount: 0.35 },
glow: { feature: 'beat', amount: 0.35, response: 'spike' },
warp: { feature: 'flux', amount: 0.2, response: 'smooth' },
},
shader: `
// Anti-aliased line grid: the smoothstep edge is widened by the screen-space
// derivative, so lines stay a consistent visual weight at any resolution.
float grid(vec2 q, float density, float width) {
vec2 g = q * density;
vec2 f = abs(fract(g) - 0.5);
float d = min(f.x, f.y);
float aa = max(fwidth(d), 0.001);
return smoothstep(width * 0.5 + aa, width * 0.5 - aa, d);
}
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_rotate + u_seed;
vec2 warp = vec2(fbm(p * 1.5 + t, 3), fbm(p * 1.5 - t, 3)) - 0.5;
vec2 q = p + warp * u_warp;
float a = grid(rot(t * 6.28318530718) * q, u_density, u_lineWidth);
float b = grid(rot(-t * 6.28318530718 + u_offset * 3.14159) * (q + u_offset), u_density, u_lineWidth);
// The interference term is the point: where both grids land, it peaks.
float interference = a * b;
float either = max(a, b);
vec3 col = pal(0) * 0.05;
col += pal(1) * either * 0.35;
col += pal(2) * interference * (0.8 + u_glow);
col += pal(3) * pow(interference, 3.0) * u_glow;
col *= 0.6 + 0.4 * exp(-dot(p, p) * 0.3);
return vec4(col, 1.0);
}
`,
};
export default moireGrid;
@@ -0,0 +1,50 @@
// Organic family: domain-warped plasma. The workhorse sustain scene — it holds
// up for minutes because the warp keeps folding new structure into itself rather
// than cycling.
export const plasmaBloom = {
name: 'Plasma Bloom',
family: 'organic',
kind: 'fragment',
params: {
scale: { type: 'float', range: [0.8, 6], default: 2.4, uniform: 'u_scale', bias: 'density' },
warp: { type: 'float', range: [0, 3], default: 1.2, uniform: 'u_warp' },
speed: { type: 'float', range: [0.02, 0.5], default: 0.1, uniform: 'u_speed', bias: 'motion', rate: true },
bands: { type: 'float', range: [1, 10], default: 3.5, uniform: 'u_bands' },
softness:{ type: 'float', range: [0, 1], default: 0.5, uniform: 'u_softness' },
glow: { type: 'float', range: [0, 1.5], default: 0.4, uniform: 'u_glow', bias: 'energy' },
palette: { type: 'palette', count: 6 },
},
reactive: {
warp: { feature: 'bandLow', amount: 0.35 },
glow: { feature: 'beat', amount: 0.35, response: 'spike' },
bands: { feature: 'centroid', amount: 0.2, response: 'smooth' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
// Two rounds of domain warping. One looks like noise; two looks organic.
vec2 q = vec2(fbm(p * u_scale + t, 4), fbm(p * u_scale + vec2(5.2, 1.3) - t, 4));
vec2 r = vec2(fbm(p * u_scale + q * u_warp * 2.0 + vec2(1.7, 9.2) + t * 0.6, 5),
fbm(p * u_scale + q * u_warp * 2.0 + vec2(8.3, 2.8) - t * 0.4, 5));
float v = fbm(p * u_scale + r * u_warp * 2.0, 5);
float shaped = sin(v * u_bands * 3.14159 + t * 1.5) * 0.5 + 0.5;
shaped = mix(shaped, smoothstep(0.25, 0.75, shaped), u_softness);
vec3 col = palRamp(shaped * 0.6 + length(r) * 0.25);
col *= 0.35 + 0.75 * shaped;
col += pal(4) * pow(shaped, 5.0) * u_glow;
// Dark corners so the bloom has somewhere to sit.
col *= 0.55 + 0.45 * exp(-dot(p, p) * 0.4);
return vec4(col, 1.0);
}
`,
};
export default plasmaBloom;
@@ -0,0 +1,72 @@
// Structural family: layered ridge silhouettes receding to a horizon.
//
// Cheap fake depth — parallax layers rather than a raymarch — which keeps it
// affordable at 4K while still reading as a place rather than a pattern.
export const ridgeTerrain = {
name: 'Ridge Terrain',
family: 'structural',
kind: 'fragment',
params: {
layers: { type: 'int', range: [2, 10], default: 6, uniform: 'u_layers', bias: 'density' },
height: { type: 'float', range: [0.1, 0.8], default: 0.35, uniform: 'u_height', bias: 'energy' },
rough: { type: 'float', range: [1, 6], default: 2.5, uniform: 'u_rough' },
speed: { type: 'float', range: [0.01, 0.3], default: 0.06, uniform: 'u_speed', bias: 'motion', rate: true },
horizon: { type: 'float', range: [-0.4, 0.4], default: 0.0, uniform: 'u_horizon' },
haze: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_haze' },
stars: { type: 'float', range: [0, 1], default: 0.3, uniform: 'u_stars' },
palette: { type: 'palette', count: 6 },
},
reactive: {
height: { feature: 'bandLow', amount: 0.25, response: 'smooth' },
haze: { feature: 'beat', amount: 0.2, response: 'spike' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
// Sky gradient above the horizon.
float sky = sat((p.y - u_horizon) * 0.8 + 0.5);
vec3 col = mix(pal(0) * 0.35, pal(1) * 0.18, sky);
// Sparse stars, only in the upper sky, fading as haze rises.
if (u_stars > 0.01 && p.y > u_horizon) {
vec2 cell = floor(uv * 220.0);
float rnd = hash12(cell);
float star = step(0.9975, rnd) * sat((p.y - u_horizon) * 2.0);
col += vec3(star) * u_stars * (0.6 + 0.4 * sin(t * 8.0 + rnd * 30.0));
}
// Ridges, far to near. Each is a 1D fbm silhouette.
for (int i = 0; i < 10; i++) {
if (i >= u_layers) break;
float fi = float(i);
float depth = fi / float(max(u_layers - 1, 1)); // 0 far .. 1 near
float parallax = mix(0.15, 1.0, depth);
float x = p.x * mix(0.6, 1.8, depth) + t * parallax + fi * 13.7;
float ridge = fbm(vec2(x, fi * 5.1) * u_rough, 4) - 0.5;
float base = u_horizon - depth * 0.28;
float top = base + ridge * u_height * mix(0.5, 1.3, depth);
float mask = smoothstep(0.004, 0.0, p.y - top);
vec3 tint = mix(pal(2), pal(4), depth);
// Distant layers wash out toward the sky colour.
tint = mix(mix(pal(1) * 0.4, tint, 0.35 + depth * 0.65), tint, 1.0 - u_haze * (1.0 - depth));
col = mix(col, tint * (0.25 + depth * 0.75), mask);
// Rim light along each crest.
col += pal(5) * smoothstep(0.02, 0.0, abs(p.y - top)) * (0.12 + depth * 0.25) * u_haze;
}
return vec4(col, 1.0);
}
`,
};
export default ridgeTerrain;
+73
View File
@@ -0,0 +1,73 @@
// Glitch family: horizontal block displacement, chroma tearing and scanlines,
// built on the feedback buffer so the corruption smears across frames.
//
// Everything here is quantised to a beat- or bar-locked step rather than driven
// continuously. Free-running glitch reads as a broken renderer; glitch that
// lands on the grid reads as an effect.
export const scanTear = {
name: 'Scan Tear',
family: 'glitch',
kind: 'fragment',
params: {
slices: { type: 'float', range: [4, 48], default: 16, uniform: 'u_slices', bias: 'density' },
shift: { type: 'float', range: [0, 0.4], default: 0.12, uniform: 'u_shift', bias: 'energy' },
tear: { type: 'float', range: [0, 1], default: 0.4, uniform: 'u_tear' },
chroma: { type: 'float', range: [0, 0.08], default: 0.02, uniform: 'u_chromaSplit' },
scan: { type: 'float', range: [0, 1], default: 0.35, uniform: 'u_scan' },
persist: { type: 'float', range: [0, 0.9], default: 0.45, uniform: 'u_persist' },
speed: { type: 'float', range: [0.05, 0.8], default: 0.25, uniform: 'u_speed', bias: 'motion', rate: true },
palette: { type: 'palette', count: 5 },
},
reactive: {
shift: { feature: 'beat', amount: 0.5, response: 'spike' },
tear: { feature: 'flux', amount: 0.35, response: 'spike' },
slices:{ feature: 'bandHigh', amount: 0.25 },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
// Quantise to the bar so displacement steps in time with the music rather
// than crawling. floor() of the bar phase gives a stable step per bar.
float step_ = floor(u_barPhase * 8.0) + floor(t * 4.0) * 8.0;
float row = floor(uv.y * u_slices);
float rowRandom = hash12(vec2(row, step_));
// Only some rows tear, and only above the tear threshold.
float torn = step(1.0 - u_tear, rowRandom);
float offset = (rowRandom - 0.5) * 2.0 * u_shift * torn;
vec2 q = vec2(fract(uv.x + offset), uv.y);
// Base image: a banded field, so the scene stands alone rather than needing
// something underneath it.
float band = fbm(vec2(q.x * 3.0, q.y * 6.0 + t), 4);
float ramp = fract(band * 2.0 + q.y * 2.0 - t * 0.5);
vec3 col = palRamp(ramp * 0.7 + row * 0.02);
col *= 0.4 + 0.6 * smoothstep(0.1, 0.9, band);
// Chroma split, strongest on torn rows.
float split = u_chromaSplit * (0.35 + torn);
col.r = mix(col.r, palRamp(ramp + split).r, 0.6);
col.b = mix(col.b, palRamp(ramp - split).b, 0.6);
// Scanlines, in normalised space so they survive a resolution change.
float lines = 0.5 + 0.5 * sin(uv.y * 900.0 * u_pixelScale);
col *= 1.0 - u_scan * 0.45 * lines;
// Smear the previous frame along the displacement.
vec3 ghost = prev(vec2(fract(uv.x + offset * 0.6), uv.y));
col = max(col, ghost * u_persist);
col += pal(4) * torn * u_shift * 0.6;
return vec4(col, 1.0);
}
`,
};
export default scanTear;
+55
View File
@@ -0,0 +1,55 @@
// Minimal family: one soft body drifting through a mostly empty frame.
//
// The quietest scene in the library, and the one an ambient intro or a long
// breakdown should usually land on. Nothing here reacts sharply — the beat
// mapping is deliberately weak, because a scene whose job is stillness should
// not twitch.
export const slowOrb = {
name: 'Slow Orb',
family: 'minimal',
kind: 'fragment',
params: {
size: { type: 'float', range: [0.15, 0.8], default: 0.38, uniform: 'u_size', bias: 'energy' },
softness: { type: 'float', range: [0.2, 1.0], default: 0.7, uniform: 'u_softness' },
drift: { type: 'float', range: [0.01, 0.2], default: 0.05, uniform: 'u_drift', bias: 'motion', rate: true },
wobble: { type: 'float', range: [0, 0.5], default: 0.15, uniform: 'u_wobble' },
halo: { type: 'float', range: [0, 1.2], default: 0.4, uniform: 'u_halo' },
grain: { type: 'float', range: [0, 0.5], default: 0.12, uniform: 'u_grain' },
palette: { type: 'palette', count: 4 },
},
reactive: {
size: { feature: 'loudness', amount: 0.12, response: 'smooth' },
halo: { feature: 'beat', amount: 0.15, response: 'spike' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_drift + u_seed;
vec2 centre = vec2(sin(t * 1.7) * 0.28, cos(t * 1.3) * 0.18);
vec2 q = p - centre;
// Break the silhouette so it never reads as a hard circle.
float wobble = fbm(q * 2.4 + t, 4) * u_wobble;
float d = length(q) * (1.0 + wobble) - u_size;
float body = smoothstep(u_softness * 0.5, -u_softness * 0.5, d);
float glow = exp(-max(d, 0.0) * (5.0 / max(u_halo, 0.05))) * u_halo;
vec3 col = pal(0) * 0.05;
col = mix(col, pal(1), body * 0.85);
col += pal(2) * body * body * 0.5;
col += pal(3) * glow * 0.35;
// Fine grain keeps large flat areas from banding.
col += (hash12(uv * 640.0 + floor(u_frame)) - 0.5) * u_grain * 0.08;
return vec4(col, 1.0);
}
`,
};
export default slowOrb;
@@ -0,0 +1,94 @@
// Minimal family: a radial bar sculpture driven by the band split.
//
// This is the one scene that shows the spectrum more or less literally. It reads
// as a music visualiser rather than as an abstraction, which is why it is
// deliberately restrained — thin bars, lots of black — and why it lives in
// `minimal` rather than `geometric`.
export const spectrumSculpture = {
name: 'Spectrum Sculpture',
family: 'minimal',
kind: 'fragment',
params: {
bars: { type: 'float', range: [8, 96], default: 40, uniform: 'u_bars', bias: 'density' },
radius: { type: 'float', range: [0.15, 0.7], default: 0.35, uniform: 'u_radius' },
length: { type: 'float', range: [0.05, 0.6], default: 0.25, uniform: 'u_length', bias: 'energy' },
thickness: { type: 'float', range: [0.1, 0.9], default: 0.45, uniform: 'u_thickness' },
// Bar segments per second, NOT turns per second. Rotating by a full turn
// meant the bar-crossing frequency was bars x rate — at 82 bars that put a
// slow-looking 0.12 turns/s at 10 Hz of luminance flicker. In segment units
// the crossing frequency IS the rate, so it stays under the flash ceiling
// whatever the bar count.
// Capped at 0.4 by measurement, not by taste: at 0.83 the mirror fold puts
// this at 4 flashes/s, and at 0.4 it measures 0. Bar count no longer affects
// it now that rotation is in segment units.
rotate: { type: 'float', range: [0, 0.4], default: 0.2, uniform: 'u_rotate', bias: 'motion', rate: true },
mirror: { type: 'bool', default: true, uniform: 'u_mirror' },
palette: { type: 'palette', count: 5 },
},
reactive: {
// Kept low deliberately: bar length scales the whole ring at once, so a
// large amount pumps global luminance and measured 4 flashes/s against a
// ceiling of 3. See engine/flash.js.
length: { feature: 'loudness', amount: 0.15, response: 'smooth' },
thickness: { feature: 'beat', amount: 0.12, response: 'spike' },
},
shader: `
float bandByIndex(float i) {
if (i < 0.5) return u_bandSub;
if (i < 1.5) return u_bandLow;
if (i < 2.5) return u_bandMid;
if (i < 3.5) return u_bandHigh;
return u_bandAir;
}
float bandAt(float x) {
float s = clamp(x, 0.0, 1.0) * 4.0;
float i = floor(s);
float f = fract(s);
f = f * f * (3.0 - 2.0 * f);
return mix(bandByIndex(i), bandByIndex(i + 1.0), f);
}
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_rotate + u_seed;
float bars = u_bars;
float seg = 6.28318530718 / bars;
float angle = atan(p.y, p.x) + t * seg;
float radius = length(p);
float index = floor((angle + 3.14159265) / seg);
float cellAngle = mod(angle + 3.14159265, seg) / seg;
// Fold the ring so the two halves mirror; reads as a designed object rather
// than a spinning readout.
float slot = u_mirror > 0.5 ? abs(index / bars - 0.5) * 2.0 : index / bars;
// Band split across the ring, sub at one end and air at the other, INTERPOLATED
// rather than switched. Hard tier boundaries made every bar jump between bands
// at the same moment as the ring rotated, which stepped whole-frame luminance
// and measured 4 flashes/s against a ceiling of 3. Blending removes the step
// and looks better besides.
float band = bandAt(slot);
float height = u_radius + u_length * (0.25 + band);
float inBar = step(u_radius, radius) * step(radius, height);
float shape = smoothstep(0.5 - u_thickness * 0.5, 0.5, cellAngle)
* smoothstep(0.5 + u_thickness * 0.5, 0.5, cellAngle);
vec3 col = pal(0) * 0.05;
col += palRamp(slot * 0.7 + 0.15) * inBar * shape * (0.6 + band);
// Inner ring outline holds the composition together.
col += pal(2) * smoothstep(0.006, 0.0, abs(radius - u_radius)) * 0.35;
return vec4(col, 1.0);
}
`,
};
export default spectrumSculpture;