Add Block Mosh, Neon City and Flora visualizers

Three new scene shaders to widen the library, now that the main families are
settled. Each is a shader plus a params block, registered in the single
source of truth (scenes/registry.js), so casting, UI, arc and checks pick
them up automatically.

- Block Mosh (glitch): a datamosh. Blocks pull the feedback buffer along
  per-block strokes on a bar-quantized grid so the corruption steps like an
  edit rather than crawling, and spills on onsets. Fills the most iconic
  gap in the glitch family.
- Neon City (structural): a receding skyline with instanced lit windows,
  distinct from the rolling ridgelines and the road grid it sits alongside.
- Flora (organic): an abstract plant of swaying stalks, teardrop petals and
  a bloom corona, all stamped in the track's signature shape.

Also adds HOWTO-visualizers.md, a quick reference for building the next one.
This commit is contained in:
Dejvino
2026-08-05 20:31:05 +02:00
parent ca5a68eb84
commit 7d59ef8e5f
6 changed files with 470 additions and 0 deletions
+6
View File
@@ -16,6 +16,9 @@ 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';
import { blockMosh } from './shader/block-mosh.js';
import { neonCity } from './shader/neon-city.js';
import { flora } from './shader/flora.js';
/**
* The scene library. Families exist so the arc driver can choose by section
@@ -51,6 +54,9 @@ const MODULES = [
moireGrid,
ridgeTerrain,
scanTear,
blockMosh,
neonCity,
flora,
];
const errors = [];
@@ -0,0 +1,74 @@
// Glitch family: a datamosh. The frame is cut into a coarse grid of blocks and
// each block is pulled along a per-block stroke drawn from the PREVIOUS frame —
// so the corruption accrues and slithers across frames instead of tearing once.
//
// Everything steps on one quantised grid: the block field re-rolls on bar lines
// rather than crawling, which is what makes this read as an edit rather than as
// a smooth warp. On an onset the spill widens, which is the "hit".
export const blockMosh = {
name: 'Block Mosh',
family: 'glitch',
kind: 'fragment',
// Personality: see look/Personality.js.
traits: ['camera', 'style'],
params: {
block: { type: 'float', range: [4, 48], default: 22, uniform: 'u_blocks', bias: 'density' },
smear: { type: 'float', range: [0, 0.5], default: 0.18, uniform: 'u_smear', bias: 'energy' },
quantize: { type: 'float', range: [1, 8], default: 4, uniform: 'u_quantize' },
bleed: { type: 'float', range: [0, 1], default: 0.45, uniform: 'u_bleed' },
speed: { type: 'float', range: [0.1, 1.4], default: 0.5, uniform: 'u_speed', bias: 'motion', rate: true },
burst: { type: 'float', range: [0, 0.4], default: 0.12, uniform: 'u_burst' },
palette: { type: 'palette', count: 4 },
},
reactive: {
smear: { feature: 'bandHigh', amount: 0.3, response: 'smooth' },
bleed: { feature: 'flux', amount: 0.2, response: 'spike' },
burst: { feature: 'beat', amount: 0.25, response: 'smooth' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
float beat = u_beat;
p = sigCamera(p);
// The thing being moshed: a sheared banded field, recoloured per track, so
// the corruption has real content to drag around even standing alone.
float shear = uv.y * (2.5 + u_sigLine * 3.0) + t * 0.45;
float field = fbm(vec2(uv.x * 1.2 + sin(shear) * 0.3, uv.y * 2.0 + t * 0.35), 3);
vec3 base = palRamp(field * 1.15 + uv.x * 0.2 + t * 0.03);
base *= 0.35 + 0.55 * sat(field * 1.5);
// One quantised grid for every jump below, so the mosh steps like an edit
// rather than crawling like a smooth warp.
float q = max(u_quantize, 1.0);
float st = floor(u_barPhase * q * 6.0) + floor(t * 4.0) * (q * 6.0);
vec2 cell = floor(uv * u_blocks);
// Each block gets one fixed stroke per step: a direction and a reach.
vec2 stroke = (hash22(cell + st * 31.0) - 0.5) * 2.0;
float reach = hash12(cell + st * 17.0);
vec2 dst = clamp(uv + stroke * u_smear * (0.3 + reach), 0.0, 1.0);
// The mosh pulls the PREVIOUS frame along the stroke and layers it over the
// fresh field. The previous frame is itself moshed, so smear accrues.
vec3 mosh = prev(dst);
float m = clamp(u_bleed * (0.5 + reach), 0.0, 1.0);
vec3 col = mix(base, mosh, m);
// Weak block tint, pulse-pinned to the beat but kept small — the frame as a
// whole never swings in luminance, so it stays clear of the WCAG 2.3.1
// flash ceiling even on a hard four-on-the-floor.
float blk = hash12(cell + st * 7.0);
col += pal(2) * u_burst * (0.5 + 0.5 * blk) * (0.5 + 0.5 * beat);
col += sigGrain(uv);
return vec4(col, 1.0);
}
`,
};
export default blockMosh;
+119
View File
@@ -0,0 +1,119 @@
// Organic family: an abstract plant — a plume of swaying stalks with teardrop
// leaves and a glowing bloom at each crown.
//
// Abstract on purpose: no soil, no foreground, just the silhouette of growth.
// Every petal and the crown are stamped in the track's signature form, so a
// hexagonal track grows hexagonal flora. The stalk bends on bass and the bloom
// pulses on the beat — the plant dances to the track rather than the camera
// shaking.
export const flora = {
name: 'Flora',
family: 'organic',
kind: 'fragment',
// Personality: see look/Personality.js.
traits: ['shape', 'camera', 'style'],
params: {
count: { type: 'float', range: [1, 5], default: 3, uniform: 'u_count', bias: 'density' },
height: { type: 'float', range: [0.6, 2.0], default: 1.1, uniform: 'u_height', bias: 'energy' },
stem: { type: 'float', range: [0.008, 0.05], default: 0.02, uniform: 'u_stem' },
sway: { type: 'float', range: [0.05, 0.5], default: 0.2, uniform: 'u_sway', bias: 'motion' },
swayRate:{ type: 'float', range: [0.1, 1.2], default: 0.6, uniform: 'u_swayRate', rate: true },
leaf: { type: 'float', range: [0.12, 0.5], default: 0.26, uniform: 'u_leaf' },
bud: { type: 'float', range: [0, 1], default: 0.55, uniform: 'u_bud', bias: 'energy' },
palette: { type: 'palette', count: 4 },
},
reactive: {
sway: { feature: 'bandLow', amount: 0.25, response: 'smooth' },
bud: { feature: 'beat', amount: 0.3, response: 'smooth' },
},
shader: `
// Horizontal lean of a spine at parametric height h (0 base .. 1 tip), seeded
// per plant and swaying more toward the crown like a real stem. Returns a value
// scaled by u_sway later, so the reactive bend applies to every part at once.
float swayAt(float h, float phase, float t) {
return sin(t * (0.35 + fract(phase * 1.7)) + phase)
+ h * sin(t * (0.35 + fract(phase * 0.6)) + phase * 2.1);
}
// A petal: an elongated blossom drawn in the track's signature form, growing
// forward along dir from a node and clipped to a blade shape.
float petal(vec2 p, vec2 node, vec2 dir, float len, float width) {
vec2 off = p - node;
float along = dot(off, dir);
vec2 perp = vec2(-dir.y, dir.x);
float across = dot(off, perp);
float taper = 0.6 + 0.4 * sat(along / max(len, 1e-3));
vec2 q = vec2(along / max(len, 1e-3), across / max(width * taper, 1e-3));
float form = sigForm(q * 0.5, vec2(0.0), 0.5);
float clip = smoothstep(0.0, -0.1, along) * smoothstep(len + 0.12, len * 0.72, along);
return form * clip;
}
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_swayRate + u_seed;
float beat = u_beat;
p = sigCamera(p);
// Soft vertical wash behind the plants.
vec3 col = mix(pal(0) * 0.03, pal(1) * 0.06, sat((p.y + 1.0) * 0.5));
for (int i = 0; i < 5; i++) {
if (i >= int(u_count)) break;
float fi = float(i);
float s = u_seed + fi * 43.7;
float baseX = (hash11(s * 1.7) - 0.5) * 1.25;
float height = u_height * (0.55 + 0.45 * hash11(s * 3.1));
float baseY = -0.92;
// Stem, sampled so the bend follows the sway curve rather than a chord.
float stemMask = 0.0;
for (int j = 0; j < 12; j++) {
float hj = (float(j) + 0.5) / 12.0;
float cx = baseX + swayAt(hj, s, t) * u_sway;
float cy = baseY + hj * height;
float d = length(p - vec2(cx, cy));
float taper = max(u_stem * (1.0 - hj * 0.8) + 0.002, 0.003);
stemMask = max(stemMask, smoothstep(taper + 0.004, taper - 0.004, d));
}
col = mix(col, mix(pal(1), pal(3), 0.3), stemMask);
// Stalk tip, reused for the petal rows and the bloom.
float tipX = baseX + swayAt(1.0, s, t) * u_sway;
float tipY = baseY + height;
// Petals up the upper half, alternating sides.
for (int l = 0; l < 6; l++) {
float fll = float(l);
float node = 0.45 + floor(fll * 0.5) * 0.12;
float side = fract(fll * 0.5) < 0.5 ? -1.0 : 1.0;
float nx = baseX + swayAt(node, s, t) * u_sway;
float ny = baseY + node * height;
vec2 dir = normalize(vec2(side, 0.55));
float len = u_leaf * 2.0;
float m = petal(p, vec2(nx, ny), dir, len, u_leaf);
vec3 leafCol = mix(pal(2), pal(3), fract(s * 0.4));
col = mix(col, leafCol, m);
col += pal(3) * m * (0.2 + 0.3 * beat);
}
// Bloom: a small corona of signature forms around the crown.
float bloom = 0.0;
for (int k = 0; k < 6; k++) {
float a = 6.2831853 * (float(k) + 0.5) / 6.0 + s;
vec2 off = vec2(cos(a), sin(a)) * u_leaf * (0.30 + 0.2 * beat);
bloom += sigForm(p - vec2(tipX, tipY), off, u_leaf * 0.5);
}
col = mix(col, pal(2), sat(bloom) * u_bud * (0.6 + 0.4 * beat));
}
col += sigGrain(uv);
return vec4(col, 1.0);
}
`,
};
export default flora;
+123
View File
@@ -0,0 +1,123 @@
// Structural family: a receding skyline of towers with instanced lit windows.
//
// Distinct from Ridge Terrain (rolling fbm ridgelines) and Synthwave Run (a
// ground grid): this is architecture, read as a front-on skyline. Heights and
// footprints are per-tower noise, drawn far-to-near so a nearer tower correctly
// occludes the one behind while a taller far tower still rises above a short
// one in front. Windows are lit per facade cell, so the city has interior life
// rather than reading as a lit cardboard cut-out.
export const neonCity = {
name: 'Neon City',
family: 'structural',
kind: 'fragment',
// Personality: see look/Personality.js.
traits: ['space', 'camera', 'style'],
params: {
cells: { type: 'float', range: [0.5, 4], default: 2.0, uniform: 'u_cells', bias: 'density' },
layers: { type: 'int', range: [2, 8], default: 6, uniform: 'u_layers', bias: 'density' },
height: { type: 'float', range: [0.2, 2.5], default: 1.0, uniform: 'u_height', bias: 'energy' },
speed: { type: 'float', range: [0.02, 0.4], default: 0.08, uniform: 'u_speed', bias: 'motion', rate: true },
horizon: { type: 'float', range: [-0.3, 0.3], default: 0.0, uniform: 'u_horizon' },
haze: { type: 'float', range: [0, 1], default: 0.45, uniform: 'u_haze' },
reflect: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_reflect' },
pulse: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_pulse' },
glow: { type: 'float', range: [0, 1.5], default: 0.4, uniform: 'u_glow', bias: 'energy' },
palette: { type: 'palette', count: 4 },
},
reactive: {
height: { feature: 'bandLow', amount: 0.18, response: 'smooth' },
pulse: { feature: 'beat', amount: 0.2, response: 'smooth' },
},
shader: `
float towerEdge(vec2 grid, float mullions) {
vec2 g = fract(grid);
float steel = min(1.0 - smoothstep(0.0, 0.28 * mullions, g.x),
1.0 - smoothstep(0.0, 0.28 * mullions, g.y));
return steel;
}
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
float beat = u_beat;
p = sigCamera(p);
// Shares the track's ground line with every other scene that has one.
float horizon = clamp(u_horizon + sigHorizonY() * 0.3, -0.9, 0.9);
// Sky, darkening away from the horizon glow.
vec3 sky = mix(pal(3) * 0.06, pal(0) * 0.35,
smoothstep(-0.1, 0.7, p.y - horizon));
sky *= 0.4 + 0.6 * exp(-abs(p.y - horizon) * 3.0);
vec3 col = sky;
for (int i = 0; i < 8; i++) {
if (i >= u_layers) break;
float fi = float(i);
float depth = float(i) / max(float(u_layers - 1), 1.0); // 0 far .. 1 near
float parallax = mix(0.25, 1.0, depth);
// Horizontal column coordinate for this depth ring. Receding layers
// get more cells per screen and slide slower, giving parallax.
float xsc = mix(1.2, 3.4, depth) * u_cells;
float xc = p.x * xsc + t * 0.18 * parallax + fi * 31.7;
float cx = floor(xc);
float xf = fract(xc);
float h1 = hash11(cx * 13.1 + fi * 7.3 + u_seed);
float h2 = hash11(cx * 61.7 + fi * 5.9 + u_seed * 0.7);
// Footprint half width, and the tower's top above its base.
float footprint = (0.55 + h1 * 0.35) * 0.5; // in cell fracs
float inTower = smoothstep(footprint + 0.04, footprint - 0.04,
abs(xf - 0.5));
float baseY = horizon - (0.05 + depth * 0.9);
float topY = baseY + u_height * (0.35 + h1 * 1.6) * mix(0.45, 1.0, depth);
float body = step(p.y, topY) * step(baseY, p.y);
float tower = inTower * body;
if (tower < 0.01) continue;
// Facade, tinted by how far into the screen the ring sits.
float gy = clamp((p.y - baseY) / max(topY - baseY, 1e-4), 0.0, 1.0);
vec3 facade = mix(pal(1), pal(3), depth);
// Instanced window grid — each facade cell decides itself lit or dark,
// with the pulse raising the lit population and the beat underlining it.
vec2 g = vec2(xf * 5.0, gy * 9.0);
vec2 cellg = floor(g);
float lit = step(0.42, hash12(cellg + fi * 3.1));
float steel = 1.0 - min(smoothstep(0.0, 0.35, fract(g.x)),
smoothstep(0.0, 0.35, fract(g.y)));
vec3 windowCol = mix(pal(2), pal(3), 0.3);
vec3 fut = mix(col, windowCol,
lit * steel * (0.45 + 0.55 * u_pulse + beat * 0.25));
// Ground-couple: darker, breath where the tower meets the street.
fut *= 0.7 + 0.3 * smoothstep(0.0, 0.5, gy);
// Haze pushes the far rings into the sky, the way distance actually does.
col = mix(col, fut, tower * (1.0 - u_haze * (1.0 - depth)));
}
// Street: a slate floor with a molten reflection of the nearest towers.
if (p.y < horizon) {
float street = smoothstep(0.0, -0.6, p.y - horizon);
col = mix(col, pal(3) * 0.08, street);
float refl = exp(-abs(p.y - horizon) * 4.0) * u_reflect;
col += pal(2) * refl * (0.08 + 0.2 * beat) * street;
}
// Horizon haze bloom, the city's glow pooling where towers meet the sky.
col += pal(2) * exp(-abs(p.y - horizon) * 9.0) * u_glow;
col = sigAir(col, p, smoothstep(0.0, 1.4, length(p)));
col += sigGrain(uv);
return vec4(col, 1.0);
}
`,
};
export default neonCity;