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:
@@ -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 { 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 = [];
|
||||
|
||||
@@ -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: `
|
||||
|
||||
@@ -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: `
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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: `
|
||||
|
||||
@@ -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: `
|
||||
|
||||
Reference in New Issue
Block a user