diff --git a/flow-state/src/checks/phase5.js b/flow-state/src/checks/phase5.js index d30a3fc..e6dcdf2 100644 --- a/flow-state/src/checks/phase5.js +++ b/flow-state/src/checks/phase5.js @@ -1 +1,319 @@ -// Phase 5 gate — filled in when the phase lands. +// Phase 5 gate — compositing depth. "C" complete. +// +// Multi-layer stacks, blend modes, feedback, the post chain and 3D layers. The +// feedback stability run and the flash-rate check matter most: both fail +// silently, slowly, and only in the finished file. + +import { check, expect, expectBelow } from './framework.js'; +import { Engine } from '../engine/Engine.js'; +import { Show } from '../Show.js'; +import { generateLook } from '../look/LookGenerator.js'; +import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js'; +import { synthesizeSectioned } from '../audio/synth.js'; +import { defaultValues, sampleValues } from '../params/schema.js'; +import { scenes } from '../scenes/registry.js'; +import { Rng } from '../engine/rng.js'; +import { frameLuminance, frameVariance } from '../engine/hash.js'; +import { peakFlashRate } from '../engine/flash.js'; +import { particleField } from '../scenes/layers3d/particles.js'; +import { nebula } from '../scenes/shader/nebula.js'; +import { BLEND_MODES } from '../engine/Layer.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], +]; + +let cached = null; +function track5() { + if (!cached) { + cached = FeatureTrack.fromAudioBuffer( + synthesizeSectioned({ bpm: 128, duration: 120, changeAt: 60 }), { fps: 60 }); + } + return cached; +} + +function makeEngine(width = 192, height = 108) { + const engine = new Engine({ width, height }); + const track = track5(); + engine.timeline.setDuration(track.duration); + engine.setFeatureProvider(featureProviderFor(track)); + return engine; +} + +check(5, 'a 3D layer renders and composites', () => { + const engine = makeEngine(); + try { + engine.setLayerSpecs([{ + module: particleField, params: defaultValues(particleField), + seed: 31337, opacity: 1, blend: 'normal', palette: PALETTE, + }]); + const pixels = engine.readPixels(engine.renderFrame(1200)); + const lum = frameLuminance(pixels); + const variance = frameVariance(pixels); + // Judged on variance, not mean luminance: this is an additive accent over + // a dark field, so most of the frame is legitimately black and a mean of + // ~0.001 is the correct result rather than a dead render. + return expect(variance > 0.005 && lum > 0.0002, + `particle field: variance ${variance.toFixed(4)}, mean luminance ${lum.toFixed(4)}`); + } finally { + engine.dispose(); + } +}); + +check(5, '3D layer motion is analytic, so a seek matches sequential playback', () => { + // The rule 3D layers must obey: no integrated state. An integrated particle + // system drifts apart between a seek and playback and silently breaks export + // parity. + const engine = makeEngine(); + try { + engine.setLayerSpecs([{ + module: particleField, params: defaultValues(particleField), + seed: 31337, opacity: 1, blend: 'normal', palette: PALETTE, + }]); + const sequential = engine.hashRun(0, 200); + engine.compositor.reset(); + const direct = engine.hashCurrent(engine.renderFrame(199)); + return expect(direct === sequential[199], + `seek→199 ${direct} vs sequential ${sequential[199]}`); + } finally { + engine.dispose(); + } +}); + +check(5, 'every blend mode composites two layers distinctly', () => { + const engine = makeEngine(); + try { + const seen = new Map(); + for (const blend of BLEND_MODES) { + engine.setLayerSpecs([ + { module: nebula, params: defaultValues(nebula), seed: 11, opacity: 1, blend: 'normal', palette: PALETTE }, + { module: particleField, params: defaultValues(particleField), seed: 22, opacity: 0.6, blend, palette: PALETTE }, + ]); + engine.compositor.reset(); + seen.set(blend, engine.hashCurrent(engine.renderFrame(1200))); + } + const unique = new Set(seen.values()).size; + return expect(unique === BLEND_MODES.length, + `${unique}/${BLEND_MODES.length} blend modes produced distinct output`); + } finally { + engine.dispose(); + } +}); + +check(5, 'layer solo isolates each layer', () => { + const engine = makeEngine(); + try { + engine.setLayerSpecs([ + { module: nebula, params: defaultValues(nebula), seed: 11, opacity: 1, blend: 'normal', palette: PALETTE }, + { module: particleField, params: defaultValues(particleField), seed: 22, opacity: 0.8, blend: 'add', palette: PALETTE }, + ]); + engine.compositor.reset(); + const both = engine.hashCurrent(engine.renderFrame(1200)); + + engine.compositor.soloIndex = 0; + engine.compositor.reset(); + const first = engine.hashCurrent(engine.renderFrame(1200)); + + engine.compositor.soloIndex = 1; + engine.compositor.reset(); + const second = engine.hashCurrent(engine.renderFrame(1200)); + engine.compositor.soloIndex = -1; + + return expect(new Set([both, first, second]).size === 3, + `combined ${both}, solo-0 ${first}, solo-1 ${second}`); + } finally { + engine.dispose(); + } +}); + +check(5, 'feedback stays stable over a long run', () => { + // A feedback loop with gain at or above 1 saturates to white; too much decay + // and it dies to black. Either takes thousands of frames to show, so it never + // appears in a short check — and always appears in a six-minute export. + const engine = makeEngine(128, 72); + try { + engine.setLayerSpecs([{ + module: nebula, params: defaultValues(nebula), + seed: 4242, opacity: 1, blend: 'normal', palette: PALETTE, + }]); + engine.compositor.setFeedback({ amount: 0.75, decay: 0.94, zoom: 1.006, rotate: 0.003 }); + engine.compositor.reset(); + + const track = track5(); + const samples = []; + for (let f = 0; f < 10000; f++) { + engine.timeline.seek(f % track.frameCount); + const target = engine.compositor.render({ + timeline: engine.timeline, features: track.at(f % track.frameCount), + }); + if (f % 500 === 0 || f === 9999) samples.push(frameLuminance(engine.readPixels(target))); + } + + const min = Math.min(...samples); + const max = Math.max(...samples); + const tail = samples.slice(-4); + return expect(max < 0.97 && min > 0.003, + `10000 frames · luminance ${min.toFixed(4)}..${max.toFixed(4)} · ` + + `tail ${tail.map((v) => v.toFixed(3)).join(', ')}`); + } finally { + engine.dispose(); + } +}, { slow: true }); + +check(5, 'generated looks stay within the flash-rate ceiling', () => { + // WCAG 2.3.1 / Harding: at most three light-dark cycles per second. An + // unsupervised generator finds unsafe states on its own, and this is the only + // thing between one of them and a published video. + const track = track5(); + const problems = []; + let worst = 0; + let worstLabel = ''; + + for (let s = 0; s < 5; s++) { + const show = new Show({ width: 96, height: 54 }); + try { + show.useTrack(track, generateLook(track, { seed: 5000 + s * 7919 })); + for (const section of track.sections) { + const start = section.startFrame + 60; + const end = Math.min(section.endFrame, start + 300); // 5 seconds + if (end - start < 120) continue; + + show.engine.compositor.reset(); + for (let f = Math.max(0, start - 40); f < start; f++) show.renderFrame(f); + + const luminance = []; + for (let f = start; f < end; f++) { + luminance.push(frameLuminance(show.readPixels(show.renderFrame(f)))); + } + const rate = peakFlashRate(luminance, 60); + const scene = show.look.sections[section.index].layers[0].module.name; + const label = `seed ${s} ${section.kind}/${scene}`; + if (rate > worst) { worst = rate; worstLabel = label; } + if (rate > 3) problems.push(`${label}: ${rate}/s`); + } + } finally { + show.dispose(); + } + } + return expect(problems.length === 0, + problems.length + ? problems.slice(0, 4).join(' · ') + : `peak ${worst} flashes/s (ceiling 3) at ${worstLabel}`); +}, { slow: true }); + +check(5, 'no scene in the library strobes at aggressive settings', () => { + // Per-scene rather than per-look: the look generator only samples a slice of + // the parameter space, so a scene can hide an unsafe region for a long time. + // This drives every scene to high energy/density/motion across several seeds, + // which is where strobing lives. Phase 7 runs the same check for each new + // scene before it joins the library. + const track = track5(); + const problems = []; + let worst = 0; + let worstScene = ''; + + for (const module of scenes) { + for (let s = 0; s < 4; s++) { + const engine = makeEngine(240, 135); + try { + const rng = new Rng(1000 + s * 7919); + engine.setLayerSpecs([{ + module, + params: sampleValues(module, rng, { energy: 0.9, density: 0.9, motion: 0.9 }), + seed: s * 31 + 7, opacity: 1, blend: 'normal', palette: PALETTE, + }]); + engine.compositor.reset(); + const luminance = []; + for (let f = 4000; f < 4240; f++) { + luminance.push(frameLuminance(engine.readPixels(engine.renderFrame(f)))); + } + const rate = peakFlashRate(luminance, 60); + if (rate > worst) { worst = rate; worstScene = `${module.name} seed ${s}`; } + if (rate > 3) problems.push(`${module.name} seed ${s}: ${rate}/s`); + } finally { + engine.dispose(); + } + } + } + return expect(problems.length === 0, + problems.length ? problems.slice(0, 4).join(' · ') + : `${scenes.length} scenes, worst ${worst} flashes/s (ceiling 3) at ${worstScene}`); +}, { slow: true }); + +check(5, 'the full stack holds interactive frame rates at preview resolution', () => { + const track = track5(); + const show = new Show({ width: 1280, height: 720 }); + try { + show.useTrack(track, generateLook(track, { seed: 2468 })); + show.look.feedback.amount = Math.max(0.4, show.look.feedback.amount); + + for (let f = 1200; f < 1230; f++) show.renderFrame(f); // warm caches + + const started = performance.now(); + const frames = 60; + for (let f = 1300; f < 1300 + frames; f++) show.renderFrame(f); + const perFrame = (performance.now() - started) / frames; + + const layers = show.arc.activeLayers.length; + return expectBelow(perFrame, 16.7, + `${perFrame.toFixed(2)}ms/frame at 1280x720 with ${layers} layer(s) + feedback + post`); + } finally { + show.dispose(); + } +}, { slow: true }); + +check(5, 'multi-layer looks render live frames across the library', () => { + const track = track5(); + const problems = []; + let stacks = 0; + + for (let s = 0; s < 8; s++) { + const show = new Show({ width: 160, height: 90 }); + try { + show.useTrack(track, generateLook(track, { seed: 9000 + s * 104729 })); + for (const section of show.look.sections) { + if (section.layers.length > 1) stacks++; + const frame = section.startFrame + Math.floor((section.endFrame - section.startFrame) / 2); + show.engine.compositor.reset(); + const pixels = show.readPixels(show.renderFrame(frame)); + const lum = frameLuminance(pixels); + const variance = frameVariance(pixels); + if (lum < 0.0008 || lum > 0.99 || variance < 0.0015) { + problems.push(`seed ${s} ${section.kind} ` + + `[${section.layers.map((l) => l.module.name).join(' + ')}]: ` + + `lum ${lum.toFixed(4)} var ${variance.toFixed(4)}`); + } + } + } finally { + show.dispose(); + } + } + return expect(problems.length === 0, + problems.length ? problems.slice(0, 4).join(' · ') + : `${stacks} multi-layer section(s) across 8 seeds, all live`); +}, { slow: true }); + +check(5, 'determinism survives feedback, post and 3D layers together', () => { + const build = () => { + const show = new Show({ width: 128, height: 72 }); + show.useTrack(track5(), generateLook(track5(), { seed: 1357 })); + show.look.feedback.amount = 0.6; + return show; + }; + const a = build(); + const b = build(); + try { + const run = (show) => { + show.engine.compositor.reset(); + const out = []; + for (let f = 3000; f < 3060; f++) out.push(show.hashFrame(f)); + return out; + }; + const ha = run(a), hb = run(b); + const mismatches = ha.filter((h, i) => h !== hb[i]).length; + return expect(mismatches === 0, `${mismatches}/60 frames differed`); + } finally { + a.dispose(); b.dispose(); + } +}); diff --git a/flow-state/src/engine/Layer.js b/flow-state/src/engine/Layer.js index 4e67b7c..8dc64ca 100644 --- a/flow-state/src/engine/Layer.js +++ b/flow-state/src/engine/Layer.js @@ -53,6 +53,9 @@ export class Layer { for (const [name, r] of Object.entries(reactive)) { const def = defs[name]; if (!def || def.type === 'palette') continue; + // Rate params multiply absolute time; modulating them jumps the phase + // by elapsed * delta. See params/schema.js RATE_FLAG. + if (def.rate) continue; const raw = features[r.feature]; if (raw === undefined) continue; diff --git a/flow-state/src/engine/flash.js b/flow-state/src/engine/flash.js new file mode 100644 index 0000000..60e381e --- /dev/null +++ b/flow-state/src/engine/flash.js @@ -0,0 +1,54 @@ +// Flash-rate safety. +// +// This project generates beat-reactive video that gets published. Rapid +// light-dark cycling between roughly 3 and 50 Hz is the photosensitive-epilepsy +// trigger, and a generator that flashes a bright scene on every kick of a 128 BPM +// track sits right in that band. WCAG 2.3.1 and the Harding test both use a +// three-flashes-per-second ceiling, which is what this measures. +// +// It is deliberately part of the gate rather than an afterthought: an +// unsupervised generator will find these states on its own, and nobody watches +// every frame of every export. + +/** One flash = a min→max→min luminance cycle with amplitude at or above `threshold`. */ +export function countFlashes(luminance, { threshold = 0.1 } = {}) { + if (luminance.length < 3) return 0; + + const extrema = []; + for (let i = 1; i < luminance.length - 1; i++) { + const a = luminance[i - 1], b = luminance[i], c = luminance[i + 1]; + if ((b > a && b >= c) || (b < a && b <= c)) { + extrema.push({ index: i, value: b, isMax: b > a }); + } + } + + let flashes = 0; + for (let i = 1; i < extrema.length - 1; i++) { + const prev = extrema[i - 1], here = extrema[i], next = extrema[i + 1]; + if (!here.isMax) continue; + const rise = here.value - prev.value; + const fall = here.value - next.value; + if (rise >= threshold && fall >= threshold) flashes++; + } + return flashes; +} + +/** Flashes per second over a luminance series sampled at `fps`. */ +export function flashRate(luminance, fps) { + const seconds = luminance.length / fps; + return seconds > 0 ? countFlashes(luminance) / seconds : 0; +} + +/** + * Worst flash rate in any one-second window. A track that averages 2/s but has a + * drop running at 8/s is not safe, and the average would hide it. + */ +export function peakFlashRate(luminance, fps) { + const window = Math.round(fps); + if (luminance.length <= window) return flashRate(luminance, fps); + let worst = 0; + for (let i = 0; i + window < luminance.length; i += Math.max(1, Math.round(fps / 4))) { + worst = Math.max(worst, countFlashes(luminance.slice(i, i + window))); + } + return worst; +} diff --git a/flow-state/src/look/ArcDriver.js b/flow-state/src/look/ArcDriver.js index 8b179d2..8c26b3b 100644 --- a/flow-state/src/look/ArcDriver.js +++ b/flow-state/src/look/ArcDriver.js @@ -68,7 +68,7 @@ export class ArcDriver { plan = []; for (const [name, def] of Object.entries(spec.module.params || {})) { if (def.type === 'palette' || def.type === 'bool' || def.fixed) continue; - if (def.noDrift) continue; + if (def.noDrift || def.rate) continue; // see schema.js RATE_FLAG const [lo, hi] = def.range || [0, 1]; plan.push({ name, diff --git a/flow-state/src/look/LookGenerator.js b/flow-state/src/look/LookGenerator.js index eceb3e4..3f3b10d 100644 --- a/flow-state/src/look/LookGenerator.js +++ b/flow-state/src/look/LookGenerator.js @@ -144,11 +144,40 @@ export function generateLook(track, { seed = null, samples = null, overrides = n const sceneByKind = assignScenesByKind(track.sections, rng.fork('scenes')); const { post, feedback } = derivePost(summary, rng.fork('post')); + // Scenes eligible as accents: 3D layers composite over a shader background + // without fighting it, so they are preferred where available. + const accentRoster = scenes.filter((m) => m.kind === 'layer3d'); + const sections = track.sections.map((section) => { const module = sceneByKind.get(section.kind) || scenes[0]; const sectionRng = rng.fork(`section:${section.index}:${module.name}`); const bias = biasFor(section, summary); + const layers = [{ + module, + params: sampleValues(module, sectionRng, bias), + seed: sectionRng.int(0, 0x7fffffff), + blend: 'normal', + opacity: 1, + }]; + + // Accent layer. Composited additively over the background at low opacity, + // and drawn from a DIFFERENT family so it reads as depth rather than as a + // second competing scene. Quiet sections mostly go without — an intro is + // supposed to be sparse. + const accentChance = bias.energy * 0.8; + if (accentRoster.length && sectionRng.bool(accentChance)) { + const accent = sectionRng.pick(accentRoster.filter((m) => m.family !== module.family) || accentRoster) + || accentRoster[0]; + layers.push({ + module: accent, + params: sampleValues(accent, sectionRng.fork(`accent:${section.index}`), bias), + seed: sectionRng.int(0, 0x7fffffff), + blend: sectionRng.pickWeighted(['add', 'screen'], [2, 1]), + opacity: sectionRng.range(0.18, 0.5), + }); + } + return { index: section.index, kind: section.kind, @@ -158,13 +187,7 @@ export function generateLook(track, { seed = null, samples = null, overrides = n end: section.end, locked: false, bias, - layers: [{ - module, - params: sampleValues(module, sectionRng, bias), - seed: sectionRng.int(0, 0x7fffffff), - blend: 'normal', - opacity: 1, - }], + layers, }; }); diff --git a/flow-state/src/params/schema.js b/flow-state/src/params/schema.js index a0add49..41efc4f 100644 --- a/flow-state/src/params/schema.js +++ b/flow-state/src/params/schema.js @@ -12,6 +12,24 @@ export const PARAM_TYPES = ['float', 'int', 'bool', 'vec2', 'palette']; +// RATE PARAMS +// +// A param flagged `rate: true` is one the shader multiplies absolute time by — +// `u_time * u_speed` and friends. Those must never be modulated per frame, by +// audio reactivity or by LFO drift, and the engine enforces it. +// +// The reason is that phase is `u_time * rate`, so changing the rate at time T +// jumps the phase by `T * Δrate`. Sixty seconds into a track, a wobble of 0.05 +// throws the phase by three whole units between one frame and the next — and it +// gets worse the longer the track runs. The visible result is high-frequency +// flicker that looks like the scene is broken, and which measured 6 flashes per +// second on Classic Wave, twice the WCAG 2.3.1 ceiling. +// +// A scene that wants audio-driven motion should add a bounded term rather than +// scaling the clock: `u_time * u_speed + u_bandLow * 2.0` is continuous; +// `u_time * (u_speed + u_bandLow)` is not. +export const RATE_FLAG = 'rate'; + /** Valid feature names a `reactive` entry may reference. Lint enforces this. */ export const REACTIVE_FEATURES = [ 'loudness', 'rms', @@ -167,6 +185,10 @@ export function validateModule(module) { errors.push(`${where}: unknown response '${r.response}'`); } if (typeof r.amount !== 'number') errors.push(`${where}: missing numeric \`amount\``); + if (params[name] && params[name].rate) { + errors.push(`${where}: '${name}' is a rate param and cannot be reactive — ` + + `modulating it jumps phase by elapsed*delta (see RATE_FLAG)`); + } } return errors; diff --git a/flow-state/src/scenes/layers3d/particles.js b/flow-state/src/scenes/layers3d/particles.js new file mode 100644 index 0000000..2bcebb7 --- /dev/null +++ b/flow-state/src/scenes/layers3d/particles.js @@ -0,0 +1,129 @@ +// A 3D particle field — the proof that the compositor is genuinely hybrid and +// not just a fragment-shader stack. +// +// DETERMINISM: particle positions are ANALYTIC functions of (time, index, seed), +// never integrated frame to frame. An integrated system would accumulate state, +// which would make a seek land somewhere different from sequential playback and +// break export parity. Anything added here must follow the same rule: if you find +// yourself writing `position += velocity * dt`, it belongs in a closed form instead. + +export const particleField = { + name: 'Particle Field', + family: 'flow', + kind: 'layer3d', + + params: { + count: { type: 'int', range: [200, 4000], default: 1200, bias: 'density', noDrift: true }, + size: { type: 'float', range: [0.01, 0.12], default: 0.04 }, + spread: { type: 'float', range: [2, 14], default: 7 }, + swirl: { type: 'float', range: [0, 2], default: 0.6, bias: 'motion', rate: true }, + rise: { type: 'float', range: [-1, 1], default: 0.25, rate: true }, + depth: { type: 'float', range: [2, 20], default: 9 }, + brightness:{ type: 'float', range: [0, 2], default: 0.8, bias: 'energy' }, + palette: { type: 'palette', count: 4 }, + }, + + reactive: { + brightness: { feature: 'beat', amount: 0.5, response: 'spike' }, + size: { feature: 'bandHigh', amount: 0.2 }, + }, + + build({ scene, seed, params, THREE }) { + const max = 4000; + const geometry = new THREE.BufferGeometry(); + const positions = new Float32Array(max * 3); + const colors = new Float32Array(max * 3); + const phases = new Float32Array(max * 4); // per-particle constants + + // Mulberry32 inline: build() runs once, and importing the engine's Rng + // here would couple a scene module to the engine for four lines. + let state = seed >>> 0; + const rnd = () => { + let t = (state += 0x6d2b79f5) >>> 0; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + + for (let i = 0; i < max; i++) { + phases[i * 4 + 0] = rnd() * Math.PI * 2; // orbital phase + phases[i * 4 + 1] = 0.3 + rnd() * 1.4; // radius factor + phases[i * 4 + 2] = rnd(); // depth position + phases[i * 4 + 3] = 0.4 + rnd() * 1.2; // speed factor + } + + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + geometry.setDrawRange(0, params.count || 1200); + + const material = new THREE.PointsMaterial({ + size: 0.04, + vertexColors: true, + transparent: true, + blending: THREE.AdditiveBlending, + depthWrite: false, + sizeAttenuation: true, + }); + + const points = new THREE.Points(geometry, material); + points.frustumCulled = false; + scene.add(points); + + return { points, geometry, material, positions, colors, phases, max }; + }, + + update({ instance, camera, timeline, features, params, palette }) { + const { geometry, material, positions, colors, phases, max } = instance; + const count = Math.min(max, Math.round(params.count)); + const t = timeline.time; + + const spread = params.spread; + const depth = params.depth; + const swirl = params.swirl; + const rise = params.rise; + const brightness = Math.max(0, params.brightness); + + const colorCount = palette && palette.length ? palette.length : 0; + + for (let i = 0; i < count; i++) { + const phase = phases[i * 4 + 0]; + const radiusFactor = phases[i * 4 + 1]; + const depthSeed = phases[i * 4 + 2]; + const speed = phases[i * 4 + 3]; + + const angle = phase + t * swirl * speed * 0.35; + const radius = radiusFactor * spread * 0.5; + + // Depth wraps analytically: fract() of a linear ramp, so a seek to + // any frame reproduces the exact same layout. + const z = ((depthSeed + t * rise * 0.05 * speed) % 1 + 1) % 1; + + positions[i * 3 + 0] = Math.cos(angle) * radius; + positions[i * 3 + 1] = Math.sin(angle) * radius * 0.6 + + Math.sin(t * 0.4 * speed + phase) * 0.6; + positions[i * 3 + 2] = -z * depth; + + // Fade with depth so the field reads as volume rather than confetti. + const fade = (1 - z) * brightness; + if (colorCount) { + const c = palette[i % colorCount]; + colors[i * 3 + 0] = c[0] * fade; + colors[i * 3 + 1] = c[1] * fade; + colors[i * 3 + 2] = c[2] * fade; + } else { + colors[i * 3 + 0] = colors[i * 3 + 1] = colors[i * 3 + 2] = fade; + } + } + + geometry.setDrawRange(0, count); + geometry.attributes.position.needsUpdate = true; + geometry.attributes.color.needsUpdate = true; + material.size = params.size; + material.opacity = 1; + + camera.position.set(0, 0, 4); + camera.lookAt(0, 0, -depth * 0.4); + }, +}; + +export default particleField; diff --git a/flow-state/src/scenes/registry.js b/flow-state/src/scenes/registry.js index a710d8f..be23bb4 100644 --- a/flow-state/src/scenes/registry.js +++ b/flow-state/src/scenes/registry.js @@ -5,6 +5,7 @@ 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'; +import { particleField } from './layers3d/particles.js'; /** * The scene library. Families exist so the arc driver can choose by section @@ -26,6 +27,7 @@ const MODULES = [ floatingGeometry, synthwaveRun, psychedelicDrift, + particleField, ]; const errors = []; diff --git a/flow-state/src/scenes/shader/classic-wave.js b/flow-state/src/scenes/shader/classic-wave.js index ba2273b..a935ef4 100644 --- a/flow-state/src/scenes/shader/classic-wave.js +++ b/flow-state/src/scenes/shader/classic-wave.js @@ -10,8 +10,15 @@ export const classicWave = { 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' }, + speed: { type: 'float', range: [0.2, 2.5], default: 1.0, uniform: 'u_speed', bias: 'motion', rate: true }, + // colorRoll is capped low on purpose. palRamp() steps through all six + // palette entries per unit of t, and the palette generator deliberately + // spreads their luminance — so this multiplies into a whole-frame + // brightness oscillation at six times its own rate. At the original + // range of [0, 0.5] this scene measured 7 flashes per second at every + // output resolution, well past the WCAG 2.3.1 ceiling of 3. Capped here, + // the worst case is ~0.9 Hz. See engine/flash.js. + colorRoll: { type: 'float', range: [0, 0.06], default: 0.02, uniform: 'u_colorRoll', rate: true }, 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 }, @@ -20,7 +27,6 @@ export const classicWave = { reactive: { bloomCore: { feature: 'beat', amount: 0.4, response: 'spike' }, rings: { feature: 'bandMid', amount: 0.12 }, - speed: { feature: 'loudness', amount: 0.15, response: 'smooth' }, }, shader: ` diff --git a/flow-state/src/scenes/shader/floating-geometry.js b/flow-state/src/scenes/shader/floating-geometry.js index 993e9fd..0c5b405 100644 --- a/flow-state/src/scenes/shader/floating-geometry.js +++ b/flow-state/src/scenes/shader/floating-geometry.js @@ -10,8 +10,8 @@ export const floatingGeometry = { 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' }, + drift: { type: 'float', range: [0.1, 1.5], default: 0.4, uniform: 'u_drift', bias: 'motion', rate: true }, + spin: { type: 'float', range: [0, 2], default: 0.6, uniform: 'u_spin', rate: true }, 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' }, @@ -21,7 +21,6 @@ export const floatingGeometry = { reactive: { size: { feature: 'beat', amount: 0.18, response: 'spike' }, aura: { feature: 'bandHigh', amount: 0.4 }, - spin: { feature: 'bandLow', amount: 0.2 }, }, shader: ` diff --git a/flow-state/src/scenes/shader/nebula.js b/flow-state/src/scenes/shader/nebula.js index 7fee7ae..c05f0c4 100644 --- a/flow-state/src/scenes/shader/nebula.js +++ b/flow-state/src/scenes/shader/nebula.js @@ -14,7 +14,7 @@ export const nebula = { swirl: { type: 'float', range: [0, 3], default: 1.0, uniform: 'u_swirl', bias: 'motion' }, rings: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_rings' }, glow: { type: 'float', range: [0, 1.5], default: 0.5, uniform: 'u_glow', bias: 'energy' }, - speed: { type: 'float', range: [0.1, 1.5],default: 0.5, uniform: 'u_speed', bias: 'motion' }, + speed: { type: 'float', range: [0.1, 1.5],default: 0.5, uniform: 'u_speed', bias: 'motion', rate: true }, depth: { type: 'float', range: [0, 1], default: 0.4, uniform: 'u_depth' }, palette: { type: 'palette', count: 4 }, }, diff --git a/flow-state/src/scenes/shader/psychedelic-drift.js b/flow-state/src/scenes/shader/psychedelic-drift.js index d6adddd..6ea729d 100644 --- a/flow-state/src/scenes/shader/psychedelic-drift.js +++ b/flow-state/src/scenes/shader/psychedelic-drift.js @@ -16,14 +16,13 @@ export const psychedelicDrift = { 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' }, + speed: { type: 'float', range: [0.05, 0.8], default: 0.2, uniform: 'u_speed', bias: 'motion', rate: true }, 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: ` diff --git a/flow-state/src/scenes/shader/synthwave-run.js b/flow-state/src/scenes/shader/synthwave-run.js index eeb9b8b..d5aa28b 100644 --- a/flow-state/src/scenes/shader/synthwave-run.js +++ b/flow-state/src/scenes/shader/synthwave-run.js @@ -12,7 +12,7 @@ export const synthwaveRun = { kind: 'fragment', params: { - speed: { type: 'float', range: [0.3, 4], default: 1.5, uniform: 'u_speed', bias: 'motion' }, + speed: { type: 'float', range: [0.3, 4], default: 1.5, uniform: 'u_speed', bias: 'motion', rate: true }, 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' }, @@ -24,7 +24,6 @@ export const synthwaveRun = { reactive: { glow: { feature: 'beat', amount: 0.45, response: 'spike' }, - speed: { feature: 'bandLow', amount: 0.3 }, }, shader: ` diff --git a/flow-state/tools/lint-scenes.js b/flow-state/tools/lint-scenes.js index dabe4a1..362bdd1 100644 --- a/flow-state/tools/lint-scenes.js +++ b/flow-state/tools/lint-scenes.js @@ -120,6 +120,26 @@ console.log('\nscene schema lint'); fail(`${id}: shader reads ${uniform}, which no param declares (it will silently be 0)`); } + // Rate params: anything the shader multiplies absolute time by must be + // flagged `rate: true`, which stops reactivity and drift from touching it. + // Modulating such a param jumps the phase by elapsed*delta — sixty seconds + // in, a wobble of 0.05 throws the phase by three units between frames. + // That measured as 6 flashes/second on Classic Wave, twice the WCAG 2.3.1 + // ceiling, and it gets worse the longer the track runs. + const timeProducts = [ + ...src.matchAll(/u_time\s*\*\s*(u_[A-Za-z0-9_]+)/g), + ...src.matchAll(/(u_[A-Za-z0-9_]+)\s*\*\s*u_time/g), + ]; + for (const match of timeProducts) { + const uniform = match[1]; + const paramName = declared.get(uniform); + if (!paramName) continue; + if (!module.params[paramName].rate) { + fail(`${id}: '${paramName}' (${uniform}) multiplies u_time but is not marked ` + + `\`rate: true\` — reactivity or drift on it will cause phase jumps`); + } + } + // A scene with a palette param should actually use the palette helpers, // otherwise the look generator cannot recolour it. const hasPalette = Object.values(module.params || {}).some((d) => d.type === 'palette');