diff --git a/flow-state/src/checks/phase2.js b/flow-state/src/checks/phase2.js index e27849e..7f23d61 100644 --- a/flow-state/src/checks/phase2.js +++ b/flow-state/src/checks/phase2.js @@ -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'); +}); diff --git a/flow-state/src/params/serialize.js b/flow-state/src/params/serialize.js new file mode 100644 index 0000000..a560acd --- /dev/null +++ b/flow-state/src/params/serialize.js @@ -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, + })), + })), + }; +} diff --git a/flow-state/src/scenes/registry.js b/flow-state/src/scenes/registry.js index 1327749..a710d8f 100644 --- a/flow-state/src/scenes/registry.js +++ b/flow-state/src/scenes/registry.js @@ -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 = []; diff --git a/flow-state/src/scenes/shader/classic-wave.js b/flow-state/src/scenes/shader/classic-wave.js new file mode 100644 index 0000000..ba2273b --- /dev/null +++ b/flow-state/src/scenes/shader/classic-wave.js @@ -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; diff --git a/flow-state/src/scenes/shader/floating-geometry.js b/flow-state/src/scenes/shader/floating-geometry.js new file mode 100644 index 0000000..993e9fd --- /dev/null +++ b/flow-state/src/scenes/shader/floating-geometry.js @@ -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; diff --git a/flow-state/src/scenes/shader/psychedelic-drift.js b/flow-state/src/scenes/shader/psychedelic-drift.js new file mode 100644 index 0000000..d6adddd --- /dev/null +++ b/flow-state/src/scenes/shader/psychedelic-drift.js @@ -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; diff --git a/flow-state/src/scenes/shader/synthwave-run.js b/flow-state/src/scenes/shader/synthwave-run.js new file mode 100644 index 0000000..eeb9b8b --- /dev/null +++ b/flow-state/src/scenes/shader/synthwave-run.js @@ -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; diff --git a/flow-state/src/ui/ParamPanel.js b/flow-state/src/ui/ParamPanel.js new file mode 100644 index 0000000..d56286f --- /dev/null +++ b/flow-state/src/ui/ParamPanel.js @@ -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 = `${module.name} + ${module.family}`; + 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 = '