Phase 2: param schema, auto-binding, generated UI

All five party-stage shaders now ported. Each declares its params
declaratively; uniform binding, UI controls, seeded sampling and arc
automation all derive from that one block, so a new scene costs a shader
and a schema and nothing else.

Port changes: LED-grid masks stripped, hardcoded colours replaced with
palette lookups, magic numbers lifted into params. Psychedelic Drift's
internal 15-second scene timer removed — keeping the visuals moving is the
arc driver's job, and it knows where the song's real transitions are.

ParamPanel generates controls from the schema alone and knows about no
specific scene; a hand-written control would be a bug.

Gate 8/8: schemas valid, uniforms accounted for in both directions, all
scenes compile and render, 128 range-sweep frames with no black, blown or
flat results, every param exposed in the UI, edits clamped, presets
round-trip and survive schema drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino 2026-08-05 11:14:04 +02:00
parent cb320557e6
commit c9b80308d2
8 changed files with 740 additions and 1 deletions

View File

@ -1 +1,197 @@
// Phase 2 gate — filled in when the phase lands.
// Phase 2 gate — parameter schema, automatic binding, generated UI.
//
// The range sweep is the check that earns its keep: it renders every scene at
// several points across every declared range and rejects frames that are black,
// blown out or flat. Those are exactly the states a look generator will wander
// into on some seed, and finding them here is far cheaper than finding them at
// minute four of an export.
import { check, expect } from './framework.js';
import { Engine } from '../engine/Engine.js';
import { scenes, FAMILIES } from '../scenes/registry.js';
import { defaultValues, sweepValues, validateModule } from '../params/schema.js';
import { serializeParams, deserializeParams } from '../params/serialize.js';
import { ParamPanel } from '../ui/ParamPanel.js';
import { frameLuminance, frameVariance } from '../engine/hash.js';
import { featureProviderFor } from '../audio/FeatureTrack.js';
import { testTrack } from './phase1.js';
const PALETTE = [
[0.06, 0.03, 0.16], [0.85, 0.15, 0.55], [0.15, 0.75, 0.95],
[0.98, 0.85, 0.35], [0.55, 0.25, 0.85], [0.2, 0.95, 0.6],
];
function makeEngine(module, params, track) {
const engine = new Engine({ width: 192, height: 108 });
engine.timeline.setDuration(track.duration);
engine.setFeatureProvider(featureProviderFor(track));
engine.compositor.postEnabled = false; // sweep the scene, not the grade
engine.setLayerSpecs([{
module, params, seed: 777, opacity: 1, blend: 'normal', palette: PALETTE,
}]);
return engine;
}
check(2, 'every scene schema validates', () => {
const problems = [];
for (const module of scenes) {
problems.push(...validateModule(module));
if (!FAMILIES[module.family]) problems.push(`${module.name}: unknown family`);
}
return expect(problems.length === 0,
problems.length ? problems.join(' · ') : `${scenes.length} scenes valid`);
});
check(2, 'declared uniforms and shader sources agree both ways', () => {
const CONTRACT = new Set([
'u_resolution', 'u_aspect', 'u_pixelScale', 'u_time', 'u_frame', 'u_progress',
'u_seed', 'u_opacity', 'u_colors', 'u_colorCount', 'u_prev', 'u_hasPrev',
'u_loudness', 'u_rms', 'u_bandSub', 'u_bandLow', 'u_bandMid', 'u_bandHigh',
'u_bandAir', 'u_flux', 'u_centroid', 'u_flatness', 'u_width', 'u_beat',
'u_beatPhase', 'u_barPhase', 'u_phrasePhase', 'u_sectionProgress',
'u_sectionEnergy', 'u_buildSlope',
]);
const problems = [];
for (const module of scenes) {
if (module.kind !== 'fragment') continue;
const declared = new Map();
for (const [name, def] of Object.entries(module.params || {})) {
if (def.uniform) declared.set(def.uniform, name);
}
for (const [uniform, param] of declared) {
if (!new RegExp(`\\b${uniform}\\b`).test(module.shader)) {
problems.push(`${module.name}: ${param} declares ${uniform}, never read`);
}
}
for (const uniform of new Set(module.shader.match(/\bu_[A-Za-z0-9_]+\b/g) || [])) {
if (!CONTRACT.has(uniform) && !declared.has(uniform)) {
problems.push(`${module.name}: reads undeclared ${uniform}`);
}
}
}
return expect(problems.length === 0,
problems.length ? problems.join(' · ') : 'all uniforms accounted for');
});
check(2, 'every scene compiles and renders', () => {
const track = testTrack();
const problems = [];
for (const module of scenes) {
const engine = makeEngine(module, defaultValues(module), track);
try {
const pixels = engine.readPixels(engine.renderFrame(1200));
const lum = frameLuminance(pixels);
const variance = frameVariance(pixels);
if (!(lum > 0.001)) problems.push(`${module.name}: black frame`);
if (variance < 0.002) problems.push(`${module.name}: flat (var ${variance.toFixed(4)})`);
} catch (err) {
problems.push(`${module.name}: ${err.message}`);
} finally {
engine.dispose();
}
}
return expect(problems.length === 0,
problems.length ? problems.join(' · ') : `${scenes.length} scenes render`);
});
check(2, 'param range sweep produces no dead or blown frames', () => {
const track = testTrack();
const problems = [];
let rendered = 0;
for (const module of scenes) {
const base = defaultValues(module);
for (const [name, def] of Object.entries(module.params || {})) {
if (def.type === 'palette') continue;
for (const value of sweepValues(def, 4)) {
const params = { ...base, [name]: value };
const engine = makeEngine(module, params, track);
try {
const pixels = engine.readPixels(engine.renderFrame(1200));
rendered++;
const lum = frameLuminance(pixels);
const variance = frameVariance(pixels);
const label = `${module.name}.${name}=${JSON.stringify(value)}`;
if (lum > 0.985) problems.push(`${label} blown (lum ${lum.toFixed(3)})`);
if (lum < 0.0008) problems.push(`${label} black (lum ${lum.toFixed(5)})`);
if (variance < 0.0015) problems.push(`${label} flat (var ${variance.toFixed(4)})`);
} catch (err) {
problems.push(`${module.name}.${name}: ${err.message}`);
} finally {
engine.dispose();
}
}
}
}
const shown = problems.slice(0, 6).join(' · ');
return expect(problems.length === 0,
problems.length
? shown + (problems.length > 6 ? ` · +${problems.length - 6} more` : '')
: `${rendered} sweep frames across ${scenes.length} scenes, all live`);
}, { slow: true });
check(2, 'every declared param gets a generated control', () => {
const host = document.createElement('div');
const problems = [];
for (const module of scenes) {
const panel = new ParamPanel(host, () => {});
panel.build(module, defaultValues(module));
const expected = Object.entries(module.params || {})
.filter(([, def]) => def.type !== 'palette')
.map(([name]) => name);
const actual = panel.controlNames();
for (const name of expected) {
if (!actual.includes(name)) problems.push(`${module.name}.${name} has no control`);
}
}
return expect(problems.length === 0,
problems.length ? problems.join(' · ') : 'all params exposed in the UI');
});
check(2, 'control edits emit clamped values', () => {
const host = document.createElement('div');
const module = scenes.find((s) => Object.values(s.params).some((d) => d.type === 'float'));
const received = [];
const panel = new ParamPanel(host, (name, value) => received.push([name, value]));
panel.build(module, defaultValues(module));
const [name, control] = [...panel.controls.entries()][0];
control.input.value = control.input.max;
control.input.dispatchEvent(new Event('input'));
const def = module.params[name];
const hi = def.range[1];
const last = received[received.length - 1];
const value = last && (Array.isArray(last[1]) ? last[1][0] : last[1]);
return expect(last && last[0] === name && Math.abs(value - hi) < 1e-6,
`emitted ${JSON.stringify(last)}, expected ${name}=${hi}`);
});
check(2, 'params round-trip through serialisation unchanged', () => {
const problems = [];
for (const module of scenes) {
const values = defaultValues(module);
const stored = JSON.parse(JSON.stringify(serializeParams(module, values)));
const restored = deserializeParams(module, stored);
for (const [name, def] of Object.entries(module.params || {})) {
if (def.type === 'palette') continue;
const a = values[name], b = restored[name];
const same = def.type === 'vec2' ? a[0] === b[0] && a[1] === b[1] : a === b;
if (!same) problems.push(`${module.name}.${name}: ${JSON.stringify(a)}${JSON.stringify(b)}`);
}
}
return expect(problems.length === 0,
problems.length ? problems.join(' · ') : `${scenes.length} scenes round-trip cleanly`);
});
check(2, 'a preset missing or gaining keys still loads', () => {
// Presets outlive schema edits; failing to load one is worse than losing a value.
const module = scenes[0];
const partial = deserializeParams(module, { __gone__: 5 });
const defaults = defaultValues(module);
const same = Object.keys(defaults).every((k) => {
const a = defaults[k], b = partial[k];
return Array.isArray(a) ? a[0] === b[0] : a === b;
});
return expect(same, 'unknown keys ignored, missing keys defaulted');
});

View File

@ -0,0 +1,56 @@
// Preset serialisation. A preset is just a param set plus the scene it belongs
// to, so "save the look I tuned" and "the look generator's output" are the same
// kind of object and can be diffed, stored and round-tripped identically.
import { clampValue, defaultValues } from './schema.js';
export const PRESET_VERSION = 1;
export function serializeParams(module, values) {
const out = {};
for (const [name, def] of Object.entries(module.params || {})) {
if (def.type === 'palette') continue; // palettes live on the look, not the scene
const v = values[name];
if (v === undefined) continue;
out[name] = def.type === 'vec2' ? [v[0], v[1]] : v;
}
return out;
}
/**
* Parse a stored param set back onto a module. Unknown keys are dropped and
* missing ones fall back to defaults, so a preset saved before a scene gained or
* lost a param still loads instead of throwing.
*/
export function deserializeParams(module, stored) {
const values = defaultValues(module);
if (!stored) return values;
for (const [name, def] of Object.entries(module.params || {})) {
if (def.type === 'palette') continue;
if (stored[name] === undefined) continue;
values[name] = clampValue(def, stored[name]);
}
return values;
}
export function serializeLook(look) {
return {
version: PRESET_VERSION,
seed: look.seed,
palette: look.palette,
post: look.post,
feedback: look.feedback,
sections: look.sections.map((s) => ({
index: s.index,
kind: s.kind,
locked: !!s.locked,
layers: s.layers.map((l) => ({
scene: l.module.name,
blend: l.blend,
opacity: l.opacity,
seed: l.seed,
params: l.params,
})),
})),
};
}

View File

@ -1,6 +1,10 @@
import { validateModule } from '../params/schema.js';
import { nebula } from './shader/nebula.js';
import { classicWave } from './shader/classic-wave.js';
import { floatingGeometry } from './shader/floating-geometry.js';
import { synthwaveRun } from './shader/synthwave-run.js';
import { psychedelicDrift } from './shader/psychedelic-drift.js';
/**
* The scene library. Families exist so the arc driver can choose by section
@ -18,6 +22,10 @@ export const FAMILIES = {
const MODULES = [
nebula,
classicWave,
floatingGeometry,
synthwaveRun,
psychedelicDrift,
];
const errors = [];

View File

@ -0,0 +1,51 @@
// Ported from party-stage's "Classic Wave". The original picked a hue from a
// rolling HSV ramp; here the ramp indexes the palette instead, so the look
// generator can actually recolour it.
export const classicWave = {
name: 'Classic Wave',
family: 'flow',
kind: 'fragment',
params: {
rings: { type: 'float', range: [4, 40], default: 18, uniform: 'u_rings', bias: 'density' },
spokes: { type: 'int', range: [0, 12], default: 5, uniform: 'u_spokes' },
speed: { type: 'float', range: [0.2, 2.5], default: 1.0, uniform: 'u_speed', bias: 'motion' },
colorRoll: { type: 'float', range: [0, 0.5], default: 0.1, uniform: 'u_colorRoll' },
softness: { type: 'float', range: [0, 1], default: 0.4, uniform: 'u_softness' },
bloomCore: { type: 'float', range: [0, 1.5], default: 0.5, uniform: 'u_bloomCore', bias: 'energy' },
palette: { type: 'palette', count: 5 },
},
reactive: {
bloomCore: { feature: 'beat', amount: 0.4, response: 'spike' },
rings: { feature: 'bandMid', amount: 0.12 },
speed: { feature: 'loudness', amount: 0.15, response: 'smooth' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float d = length(p);
float angle = atan(p.y, p.x);
float t = u_time * u_speed + u_seed;
float wave = sin(d * u_rings - t * 2.0);
float spoke = u_spokes > 0 ? sin(angle * float(u_spokes) + t) * u_beat : 0.0;
float v = 0.5 + 0.5 * sin(wave + spoke);
v = mix(v, smoothstep(0.2, 0.8, v), u_softness);
vec3 col = palRamp(t * u_colorRoll + d * 0.25) * v;
// Core glow, the part that reads as the "hit".
col += pal(0) * (1.0 - smoothstep(0.0, 0.7, d)) * u_bloomCore * 0.6;
// Keep the corners from clipping to flat colour.
col *= 0.6 + 0.4 * (1.0 - smoothstep(0.8, 1.8, d));
return vec4(col, 1.0);
}
`,
};
export default classicWave;

View File

@ -0,0 +1,66 @@
// Ported from party-stage's "Floating Geometry". Shape count, size and motion
// were fixed constants in the original; they are the whole point of the scene,
// so they are now params the look generator can move.
export const floatingGeometry = {
name: 'Floating Geometry',
family: 'geometric',
kind: 'fragment',
params: {
count: { type: 'int', range: [2, 14], default: 6, uniform: 'u_count', bias: 'density' },
size: { type: 'float', range: [0.04, 0.3],default: 0.15,uniform: 'u_size' },
drift: { type: 'float', range: [0.1, 1.5], default: 0.4, uniform: 'u_drift', bias: 'motion' },
spin: { type: 'float', range: [0, 2], default: 0.6, uniform: 'u_spin' },
boxRatio: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_boxRatio' },
aura: { type: 'float', range: [0, 1], default: 0.25,uniform: 'u_aura', bias: 'energy' },
spread: { type: 'float', range: [0.3, 1.2], default: 0.8, uniform: 'u_spread' },
palette: { type: 'palette', count: 5 },
},
reactive: {
size: { feature: 'beat', amount: 0.18, response: 'spike' },
aura: { feature: 'bandHigh', amount: 0.4 },
spin: { feature: 'bandLow', amount: 0.2 },
},
shader: `
float sdCircle(vec2 p, float r) { return length(p) - r; }
float sdBox(vec2 p, vec2 b) {
vec2 d = abs(p) - b;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_drift + u_seed;
// Background wash from the two darkest palette entries.
vec3 col = mix(pal(0) * 0.18, pal(1) * 0.24, sin(t) * 0.5 + 0.5);
for (int i = 0; i < 14; i++) {
if (i >= u_count) break;
float fi = float(i);
float s = u_seed + fi * 123.456;
vec2 pos = vec2(
sin(t * 0.5 + s) * u_spread,
cos(t * 0.3 + s * 1.1) * u_spread * 0.55
);
vec2 sp = rot(t * (0.2 + fract(s) * u_spin)) * (p - pos);
float size = u_size * (0.6 + fract(s * 0.7) * 0.8);
float d = fract(s * 0.37) < u_boxRatio ? sdBox(sp, vec2(size * 0.8)) : sdCircle(sp, size);
vec3 shapeColor = pal(i + int(floor(t * 0.3)));
float intensity = smoothstep(0.012, 0.0, d);
col = mix(col, shapeColor, intensity * 0.85);
col += shapeColor * (1.0 - smoothstep(0.0, size * 2.2, abs(d))) * u_aura;
}
return vec4(col, 1.0);
}
`,
};
export default floatingGeometry;

View File

@ -0,0 +1,133 @@
// Ported from party-stage's "Drugged Out Flow".
//
// The original ran its own 15-second internal scene timer to keep itself
// interesting. That job now belongs to the arc driver, which knows where the
// song's actual transitions are — so the internal timer is gone and the scene
// variety is driven by `variant`, a param the arc can step.
export const psychedelicDrift = {
name: 'Psychedelic Drift',
family: 'glitch',
kind: 'fragment',
params: {
count: { type: 'int', range: [4, 24], default: 12, uniform: 'u_count', bias: 'density' },
variant: { type: 'float', range: [0, 8], default: 0, uniform: 'u_variant' },
warp: { type: 'float', range: [0, 0.6], default: 0.2, uniform: 'u_warp', bias: 'energy' },
beams: { type: 'int', range: [0, 5], default: 3, uniform: 'u_beams' },
symbolSize: { type: 'float', range: [0.03, 0.14], default: 0.07, uniform: 'u_symbolSize' },
speed: { type: 'float', range: [0.05, 0.8], default: 0.2, uniform: 'u_speed', bias: 'motion' },
palette: { type: 'palette', count: 6 },
},
reactive: {
warp: { feature: 'beat', amount: 0.35, response: 'spike' },
symbolSize: { feature: 'bandLow', amount: 0.25 },
speed: { feature: 'buildSlope', amount: 0.4, response: 'smooth' },
},
shader: `
float sdCircle(vec2 p, float r) { return length(p) - r; }
float sdStar(vec2 p, float r, float points) {
float a = atan(p.y, p.x);
float seg = 6.28318530718 / points;
a = mod(a + seg * 0.5, seg) - seg * 0.5;
return length(p) * cos(a) - r * 0.5;
}
float sdSmiley(vec2 p, float r, float t, float id) {
float d = sdCircle(p, r);
float blink = smoothstep(0.9, 0.95, sin(t * 0.5 + id * 1.2));
float eyeR = 0.18 * r * (1.0 - blink * 0.9);
d = max(d, -sdCircle(vec2(abs(p.x) - 0.3 * r, p.y - 0.35 * r), eyeR));
float mouthType = sin(t * 1.2 + id);
if (mouthType > 0.6) {
d = max(d, -sdCircle(p - vec2(0.0, -0.2) * r, 0.15 * r));
} else if (mouthType > -0.6) {
float mouth = sdCircle(p - vec2(0.0, -0.1) * r, 0.45 * r);
mouth = max(mouth, -(p.y - (0.05 + 0.1 * u_beat) * r));
d = max(d, -mouth);
} else {
d = max(d, -sdCircle(p - vec2(0.0, -0.25) * r, 0.08 * r));
}
return d;
}
float distToSegment(vec2 p, vec2 a, vec2 b) {
vec2 pa = p - a, ba = b - a;
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
return length(pa - ba * h);
}
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
float beat = u_beat;
float vs = u_seed + floor(u_variant) * 100.0;
// Swirling background.
float bg = sin(p.x * 3.0 + t) * cos(p.y * 3.0 - t * 0.7) + sin(length(p) * 4.0 - t);
vec3 col = mix(pal(0) * 0.12, pal(1) * 0.18, bg * 0.5 + 0.5);
col += pal(0) * 0.05 * beat;
// Lens distortion that drags everything toward a wandering centre.
vec2 centre = vec2(sin(t * 0.3 + vs) * 0.5, cos(t * 0.4 + vs * 1.1) * 0.5);
float radius = 0.35 + beat * 0.15;
float dc = length(p - centre);
vec2 dp = p;
if (dc < radius && dc > 1e-4) {
dp += normalize(p - centre) * smoothstep(radius, 0.0, dc) * (u_warp + beat * 0.2);
}
// Beam endpoints, precomputed so symbols can react to them.
vec2 b1[5], b2[5];
for (int j = 0; j < 5; j++) {
float ls = vs + float(j) * 789.0;
b1[j] = vec2(sin(t * 0.4 + ls) * 0.8, cos(t * 0.3 + ls * 1.1) * 0.5);
b2[j] = vec2(sin(t * 0.5 + ls * 1.5) * 0.8, cos(t * 0.4 + ls * 1.7) * 0.5);
}
for (int i = 0; i < 24; i++) {
if (i >= u_count) break;
float fi = float(i);
float s = vs + fi * 123.456;
float speed = 0.3 + hash11(s) * 0.4;
vec2 basePos = vec2(sin(t * speed + s) * 0.8, cos(t * speed * 0.8 + s * 1.4) * 0.5);
vec2 pos = mix(basePos, centre, 0.15 + beat * 0.1);
vec2 sp = rot(t * (0.1 + fract(s * 0.7))) * (dp - pos);
float size = u_symbolSize * (0.7 + fract(s * 0.3) * 0.6);
float d;
float kind = mod(fi + floor(u_variant), 3.0);
if (kind == 0.0) d = sdStar(sp, size, 5.0 + floor(hash11(s * 1.2) * 3.0));
else if (kind == 1.0) d = sdSmiley(sp, size, u_time, fi);
else d = sdCircle(sp, size);
float hit = 0.0;
for (int j = 0; j < 5; j++) {
if (j >= u_beams) break;
hit = max(hit, smoothstep(0.15, 0.0, distToSegment(pos, b1[j], b2[j])));
}
vec3 sc = mix(pal(i + int(floor(t * 0.3))), vec3(1.0), hit * 0.8);
float intensity = smoothstep(0.012, 0.0, d);
col = mix(col, sc, intensity * sat(0.7 + beat * 0.3 + hit));
col += sc * (1.0 - smoothstep(0.0, size * 2.5, abs(d))) * 0.15 * (0.5 + beat * 0.5 + hit * 2.0);
}
for (int i = 0; i < 5; i++) {
if (i >= u_beams) break;
float w = 0.008 + beat * 0.005;
float intensity = smoothstep(w, 0.0, distToSegment(dp, b1[i], b2[i]));
col += pal(i + 2) * intensity * (0.6 + beat);
}
return vec4(col, 1.0);
}
`,
};
export default psychedelicDrift;

View File

@ -0,0 +1,107 @@
// Ported from party-stage's "Synthwave Run".
//
// The original hardcoded magenta and cyan and drew a car silhouette with
// red taillights. The car is kept — it is the scene's whole identity — but the
// colours now come from the palette, and the elements (grid, mountains, sun,
// car) are individually switchable so the arc driver can strip it back to just
// the grid during a breakdown and bring the rest in for the drop.
export const synthwaveRun = {
name: 'Synthwave Run',
family: 'structural',
kind: 'fragment',
params: {
speed: { type: 'float', range: [0.3, 4], default: 1.5, uniform: 'u_speed', bias: 'motion' },
gridDensity:{ type: 'float', range: [0.5, 3], default: 1.0, uniform: 'u_gridDensity', bias: 'density' },
horizon: { type: 'float', range: [-0.3, 0.3],default: 0.0, uniform: 'u_horizon' },
sun: { type: 'float', range: [0, 1], default: 1.0, uniform: 'u_sun' },
mountains: { type: 'float', range: [0, 1], default: 1.0, uniform: 'u_mountains' },
car: { type: 'float', range: [0, 1], default: 1.0, uniform: 'u_car' },
glow: { type: 'float', range: [0, 1.5], default: 0.5, uniform: 'u_glow', bias: 'energy' },
palette: { type: 'palette', count: 4 },
},
reactive: {
glow: { feature: 'beat', amount: 0.45, response: 'spike' },
speed: { feature: 'bandLow', amount: 0.3 },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
float beat = u_beat;
float horizon = u_horizon;
vec3 colorMain = pal(0);
vec3 colorSec = pal(1);
vec3 col = pal(3) * 0.08;
// --- perspective ground grid ---
if (p.y < horizon) {
float perspective = 1.0 / (horizon - p.y + 0.05);
vec2 g = vec2(p.x * perspective, perspective + t * 2.0) * u_gridDensity;
float lines = smoothstep(0.05, 0.0, abs(fract(g.x + 0.5) - 0.5))
+ smoothstep(0.05, 0.0, abs(fract(g.y + 0.5) - 0.5));
col = mix(col, colorSec, lines * 0.5 * (0.5 + beat));
if (abs(p.x * perspective) < 0.8) col += colorMain * 0.15 * perspective;
}
// --- wireframe mountains ---
if (u_mountains > 0.01 && p.y > horizon - 0.1) {
float mx = abs(p.x) - 0.5;
if (mx > 0.0) {
float h = vnoise(vec2(mx * 2.0 + t * 0.1, u_seed)) * 0.6 * smoothstep(0.0, 0.5, mx);
if (p.y - horizon < h) {
float edge = abs(p.y - horizon - h);
float wire = smoothstep(0.02, 0.0, edge)
+ smoothstep(0.01, 0.0, abs(fract((p.y - horizon) * 10.0) - 0.5))
+ smoothstep(0.01, 0.0, abs(fract(mx * 10.0) - 0.5));
col = mix(col, colorMain, wire * 0.4 * u_mountains);
}
}
}
// --- retro sun with scanline cutouts ---
if (u_sun > 0.01) {
vec2 sunPos = vec2(0.0, 0.1);
float sunRad = 0.4;
float dSun = length(p - sunPos);
if (dSun < sunRad && p.y > horizon) {
float scan = sin((p.y - t * 0.1) * 50.0);
if (scan < mix(0.8, -1.0, (p.y - sunPos.y + sunRad) / (sunRad * 2.0))) {
vec3 sunCol = mix(colorMain, pal(2), sat(p.y * 2.0));
col = mix(col, sunCol, (0.8 + beat * 0.2) * u_sun);
}
}
}
// --- car silhouette ---
if (u_car > 0.01) {
vec2 carPos = vec2(sin(t * 0.1) * 0.2, -0.78 + sin(t * 30.0) * 0.001);
vec2 cp = p - carPos;
float cpX = abs(cp.x);
float body = step(cpX, 0.35) * step(abs(cp.y), 0.12);
float glass = step(cpX, 0.22) * step(abs(cp.y - 0.18), 0.07);
float wingTop = step(cpX, 0.32) * step(abs(cp.y - 0.20), 0.025);
float wingSide = step(abs(cpX - 0.30), 0.03) * step(abs(cp.y - 0.12), 0.1);
if (body + glass + wingTop + wingSide > 0.0) {
col = mix(col, vec3(0.015), 0.95 * u_car);
float r = 0.025;
float l1 = smoothstep(r, r * 0.6, length(vec2(cpX - 0.21, cp.y - 0.03)));
float l2 = smoothstep(r, r * 0.6, length(vec2(cpX - 0.29, cp.y - 0.03)));
col = mix(col, pal(2) * (0.6 + beat * 1.4), sat(l1 + l2) * u_car);
}
}
// Horizon haze, scaled by the glow param.
col += colorMain * exp(-abs(p.y - horizon) * 8.0) * u_glow * 0.35;
return vec4(col, 1.0);
}
`,
};
export default synthwaveRun;

View File

@ -0,0 +1,122 @@
// Controls generated from a scene's param schema.
//
// Nothing here knows about any specific scene. That is the point: a new scene
// gets a full editing UI for free, which is what makes "auto with override"
// affordable across a library of thirty. Adding a hand-written control for a
// scene would be a bug, not a feature.
const FORMATTERS = {
float: (v) => (typeof v === 'number' ? v.toFixed(2) : '—'),
int: (v) => String(v),
bool: (v) => (v ? 'on' : 'off'),
vec2: (v) => (Array.isArray(v) ? `${v[0].toFixed(2)}, ${v[1].toFixed(2)}` : '—'),
};
export class ParamPanel {
/**
* @param {HTMLElement} container
* @param {(name: string, value: any) => void} onChange
*/
constructor(container, onChange) {
this.container = container;
this.onChange = onChange;
this.controls = new Map();
this.module = null;
}
/** Rebuild for a scene module and its current values. */
build(module, values) {
this.container.innerHTML = '';
this.controls.clear();
this.module = module;
if (!module) return;
const heading = document.createElement('div');
heading.className = 'pp-heading';
heading.innerHTML = `<span class="pp-name">${module.name}</span>
<span class="pp-family">${module.family}</span>`;
this.container.appendChild(heading);
for (const [name, def] of Object.entries(module.params || {})) {
if (def.type === 'palette') continue; // edited in the look tab
const control = this._buildControl(name, def, values[name]);
if (control) {
this.container.appendChild(control.element);
this.controls.set(name, control);
}
}
const reactive = module.reactive || {};
if (Object.keys(reactive).length) {
const note = document.createElement('div');
note.className = 'pp-reactive';
note.innerHTML = '<div class="pp-sub">audio-driven</div>' +
Object.entries(reactive)
.map(([p, r]) => `<div class="pp-react-row"><span>${p}</span>` +
`<span class="pp-feature">${r.feature}</span>` +
`<span class="pp-amount">${r.amount > 0 ? '+' : ''}${r.amount}</span></div>`)
.join('');
this.container.appendChild(note);
}
}
_buildControl(name, def, value) {
const element = document.createElement('div');
element.className = 'pp-row';
const label = document.createElement('label');
label.className = 'pp-label';
label.textContent = name;
const readout = document.createElement('span');
readout.className = 'pp-value';
let input;
if (def.type === 'bool') {
input = document.createElement('input');
input.type = 'checkbox';
input.checked = !!value;
input.addEventListener('change', () => {
readout.textContent = FORMATTERS.bool(input.checked);
this.onChange(name, input.checked);
});
} else {
const [lo, hi] = def.range || [0, 1];
input = document.createElement('input');
input.type = 'range';
input.min = String(lo);
input.max = String(hi);
input.step = def.type === 'int' ? '1' : String((hi - lo) / 200);
input.value = String(def.type === 'vec2' ? (value ? value[0] : lo) : value ?? lo);
input.addEventListener('input', () => {
const v = def.type === 'int' ? Math.round(+input.value) : +input.value;
const out = def.type === 'vec2' ? [v, v] : v;
readout.textContent = (FORMATTERS[def.type] || FORMATTERS.float)(out);
this.onChange(name, out);
});
}
input.className = 'pp-input';
readout.textContent = (FORMATTERS[def.type] || FORMATTERS.float)(value);
element.append(label, input, readout);
return { element, input, readout, def };
}
/** Push new values into the controls without emitting change events. */
update(values) {
for (const [name, control] of this.controls) {
const v = values[name];
if (v === undefined) continue;
if (control.def.type === 'bool') control.input.checked = !!v;
else control.input.value = String(control.def.type === 'vec2' ? v[0] : v);
control.readout.textContent =
(FORMATTERS[control.def.type] || FORMATTERS.float)(v);
}
}
/** Names of every control currently rendered. Used by the Phase 2 gate. */
controlNames() {
return [...this.controls.keys()];
}
}