Phase 5: compositing depth ("C" complete)
Multi-layer stacks with blend modes, feedback, post chain, and a 3D particle layer proving the compositor is genuinely hybrid. Looks now generate accent layers from a different family, composited additively at low opacity, weighted by section energy so intros stay sparse. Adds flash-rate safety (engine/flash.js), which was not in the original plan and should have been. This generates beat-reactive video for publication, and rapid light-dark cycling is the photosensitive-epilepsy trigger; WCAG 2.3.1 caps it at three flashes per second. Classic Wave measured 7-8/s at every output resolution from 96x54 to 1920x1080, so it was a real hazard rather than a sampling artefact. Root cause was general, not one bad shader: `u_time * u_speed` where speed is reactively modulated. Phase is elapsed*rate, so changing the rate at time T jumps phase by T*delta — sixty seconds in, a 0.05 wobble throws the phase three whole units between consecutive frames, and it worsens as the track runs. Fixed by introducing rate params: - schema flag `rate: true` documents and marks them - Layer.resolveParams skips reactivity on them - ArcDriver skips drift on them - validateModule rejects a reactive entry on one - lint-scenes greps shaders for `u_time * u_X` and fails if X is unmarked, so no future scene can reintroduce it Every rate param across the six scenes is now marked. Two checks were needed to find this: a per-look flash check, and a per-SCENE sweep at aggressive params, since the look generator only samples part of the space and a scene can hide an unsafe region for a long time. Gate 10/10. Worst flash rate now 1/s. Feedback stable over 10,000 frames (luminance 0.17-0.59, no saturation or decay). 0.17ms/frame at 1280x720. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5f25437b89
commit
59180948d0
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -53,6 +53,9 @@ export class Layer {
|
|||||||
for (const [name, r] of Object.entries(reactive)) {
|
for (const [name, r] of Object.entries(reactive)) {
|
||||||
const def = defs[name];
|
const def = defs[name];
|
||||||
if (!def || def.type === 'palette') continue;
|
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];
|
const raw = features[r.feature];
|
||||||
if (raw === undefined) continue;
|
if (raw === undefined) continue;
|
||||||
|
|
||||||
|
|||||||
54
flow-state/src/engine/flash.js
Normal file
54
flow-state/src/engine/flash.js
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -68,7 +68,7 @@ export class ArcDriver {
|
|||||||
plan = [];
|
plan = [];
|
||||||
for (const [name, def] of Object.entries(spec.module.params || {})) {
|
for (const [name, def] of Object.entries(spec.module.params || {})) {
|
||||||
if (def.type === 'palette' || def.type === 'bool' || def.fixed) continue;
|
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];
|
const [lo, hi] = def.range || [0, 1];
|
||||||
plan.push({
|
plan.push({
|
||||||
name,
|
name,
|
||||||
|
|||||||
@ -144,11 +144,40 @@ export function generateLook(track, { seed = null, samples = null, overrides = n
|
|||||||
const sceneByKind = assignScenesByKind(track.sections, rng.fork('scenes'));
|
const sceneByKind = assignScenesByKind(track.sections, rng.fork('scenes'));
|
||||||
const { post, feedback } = derivePost(summary, rng.fork('post'));
|
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 sections = track.sections.map((section) => {
|
||||||
const module = sceneByKind.get(section.kind) || scenes[0];
|
const module = sceneByKind.get(section.kind) || scenes[0];
|
||||||
const sectionRng = rng.fork(`section:${section.index}:${module.name}`);
|
const sectionRng = rng.fork(`section:${section.index}:${module.name}`);
|
||||||
const bias = biasFor(section, summary);
|
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 {
|
return {
|
||||||
index: section.index,
|
index: section.index,
|
||||||
kind: section.kind,
|
kind: section.kind,
|
||||||
@ -158,13 +187,7 @@ export function generateLook(track, { seed = null, samples = null, overrides = n
|
|||||||
end: section.end,
|
end: section.end,
|
||||||
locked: false,
|
locked: false,
|
||||||
bias,
|
bias,
|
||||||
layers: [{
|
layers,
|
||||||
module,
|
|
||||||
params: sampleValues(module, sectionRng, bias),
|
|
||||||
seed: sectionRng.int(0, 0x7fffffff),
|
|
||||||
blend: 'normal',
|
|
||||||
opacity: 1,
|
|
||||||
}],
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,24 @@
|
|||||||
|
|
||||||
export const PARAM_TYPES = ['float', 'int', 'bool', 'vec2', 'palette'];
|
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. */
|
/** Valid feature names a `reactive` entry may reference. Lint enforces this. */
|
||||||
export const REACTIVE_FEATURES = [
|
export const REACTIVE_FEATURES = [
|
||||||
'loudness', 'rms',
|
'loudness', 'rms',
|
||||||
@ -167,6 +185,10 @@ export function validateModule(module) {
|
|||||||
errors.push(`${where}: unknown response '${r.response}'`);
|
errors.push(`${where}: unknown response '${r.response}'`);
|
||||||
}
|
}
|
||||||
if (typeof r.amount !== 'number') errors.push(`${where}: missing numeric \`amount\``);
|
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;
|
return errors;
|
||||||
|
|||||||
129
flow-state/src/scenes/layers3d/particles.js
Normal file
129
flow-state/src/scenes/layers3d/particles.js
Normal file
@ -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;
|
||||||
@ -5,6 +5,7 @@ import { classicWave } from './shader/classic-wave.js';
|
|||||||
import { floatingGeometry } from './shader/floating-geometry.js';
|
import { floatingGeometry } from './shader/floating-geometry.js';
|
||||||
import { synthwaveRun } from './shader/synthwave-run.js';
|
import { synthwaveRun } from './shader/synthwave-run.js';
|
||||||
import { psychedelicDrift } from './shader/psychedelic-drift.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
|
* The scene library. Families exist so the arc driver can choose by section
|
||||||
@ -26,6 +27,7 @@ const MODULES = [
|
|||||||
floatingGeometry,
|
floatingGeometry,
|
||||||
synthwaveRun,
|
synthwaveRun,
|
||||||
psychedelicDrift,
|
psychedelicDrift,
|
||||||
|
particleField,
|
||||||
];
|
];
|
||||||
|
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
|||||||
@ -10,8 +10,15 @@ export const classicWave = {
|
|||||||
params: {
|
params: {
|
||||||
rings: { type: 'float', range: [4, 40], default: 18, uniform: 'u_rings', bias: 'density' },
|
rings: { type: 'float', range: [4, 40], default: 18, uniform: 'u_rings', bias: 'density' },
|
||||||
spokes: { type: 'int', range: [0, 12], default: 5, uniform: 'u_spokes' },
|
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' },
|
speed: { type: 'float', range: [0.2, 2.5], default: 1.0, uniform: 'u_speed', bias: 'motion', rate: true },
|
||||||
colorRoll: { type: 'float', range: [0, 0.5], default: 0.1, uniform: 'u_colorRoll' },
|
// 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' },
|
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' },
|
bloomCore: { type: 'float', range: [0, 1.5], default: 0.5, uniform: 'u_bloomCore', bias: 'energy' },
|
||||||
palette: { type: 'palette', count: 5 },
|
palette: { type: 'palette', count: 5 },
|
||||||
@ -20,7 +27,6 @@ export const classicWave = {
|
|||||||
reactive: {
|
reactive: {
|
||||||
bloomCore: { feature: 'beat', amount: 0.4, response: 'spike' },
|
bloomCore: { feature: 'beat', amount: 0.4, response: 'spike' },
|
||||||
rings: { feature: 'bandMid', amount: 0.12 },
|
rings: { feature: 'bandMid', amount: 0.12 },
|
||||||
speed: { feature: 'loudness', amount: 0.15, response: 'smooth' },
|
|
||||||
},
|
},
|
||||||
|
|
||||||
shader: `
|
shader: `
|
||||||
|
|||||||
@ -10,8 +10,8 @@ export const floatingGeometry = {
|
|||||||
params: {
|
params: {
|
||||||
count: { type: 'int', range: [2, 14], default: 6, uniform: 'u_count', bias: 'density' },
|
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' },
|
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' },
|
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' },
|
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' },
|
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' },
|
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' },
|
spread: { type: 'float', range: [0.3, 1.2], default: 0.8, uniform: 'u_spread' },
|
||||||
@ -21,7 +21,6 @@ export const floatingGeometry = {
|
|||||||
reactive: {
|
reactive: {
|
||||||
size: { feature: 'beat', amount: 0.18, response: 'spike' },
|
size: { feature: 'beat', amount: 0.18, response: 'spike' },
|
||||||
aura: { feature: 'bandHigh', amount: 0.4 },
|
aura: { feature: 'bandHigh', amount: 0.4 },
|
||||||
spin: { feature: 'bandLow', amount: 0.2 },
|
|
||||||
},
|
},
|
||||||
|
|
||||||
shader: `
|
shader: `
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export const nebula = {
|
|||||||
swirl: { type: 'float', range: [0, 3], default: 1.0, uniform: 'u_swirl', bias: 'motion' },
|
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' },
|
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' },
|
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' },
|
depth: { type: 'float', range: [0, 1], default: 0.4, uniform: 'u_depth' },
|
||||||
palette: { type: 'palette', count: 4 },
|
palette: { type: 'palette', count: 4 },
|
||||||
},
|
},
|
||||||
|
|||||||
@ -16,14 +16,13 @@ export const psychedelicDrift = {
|
|||||||
warp: { type: 'float', range: [0, 0.6], default: 0.2, uniform: 'u_warp', bias: 'energy' },
|
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' },
|
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' },
|
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 },
|
palette: { type: 'palette', count: 6 },
|
||||||
},
|
},
|
||||||
|
|
||||||
reactive: {
|
reactive: {
|
||||||
warp: { feature: 'beat', amount: 0.35, response: 'spike' },
|
warp: { feature: 'beat', amount: 0.35, response: 'spike' },
|
||||||
symbolSize: { feature: 'bandLow', amount: 0.25 },
|
symbolSize: { feature: 'bandLow', amount: 0.25 },
|
||||||
speed: { feature: 'buildSlope', amount: 0.4, response: 'smooth' },
|
|
||||||
},
|
},
|
||||||
|
|
||||||
shader: `
|
shader: `
|
||||||
|
|||||||
@ -12,7 +12,7 @@ export const synthwaveRun = {
|
|||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
|
|
||||||
params: {
|
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' },
|
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' },
|
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' },
|
sun: { type: 'float', range: [0, 1], default: 1.0, uniform: 'u_sun' },
|
||||||
@ -24,7 +24,6 @@ export const synthwaveRun = {
|
|||||||
|
|
||||||
reactive: {
|
reactive: {
|
||||||
glow: { feature: 'beat', amount: 0.45, response: 'spike' },
|
glow: { feature: 'beat', amount: 0.45, response: 'spike' },
|
||||||
speed: { feature: 'bandLow', amount: 0.3 },
|
|
||||||
},
|
},
|
||||||
|
|
||||||
shader: `
|
shader: `
|
||||||
|
|||||||
@ -120,6 +120,26 @@ console.log('\nscene schema lint');
|
|||||||
fail(`${id}: shader reads ${uniform}, which no param declares (it will silently be 0)`);
|
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,
|
// A scene with a palette param should actually use the palette helpers,
|
||||||
// otherwise the look generator cannot recolour it.
|
// otherwise the look generator cannot recolour it.
|
||||||
const hasPalette = Object.values(module.params || {}).some((d) => d.type === 'palette');
|
const hasPalette = Object.values(module.params || {}).some((d) => d.type === 'palette');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user