music-video-gen/flow-state/src/scenes/layers3d/particles.js
Dejvino 59180948d0 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>
2026-08-05 11:38:26 +02:00

130 lines
5.3 KiB
JavaScript

// 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;