music-video-gen/flow-state/src/scenes/layers3d/particles.js
Dejvino e6f5a2f0d5 Epic 2.5.2: make the framing actually reach the image
Revisiting the framing layer turned up that it was not reaching four of the
forty-two scenes, and that none of its gates could have told us.

Those gates check the PLAN — cue sizes, the distribution of shot sizes across
a population, headroom, seek determinism — and a plan that never reaches the
image passes every one of them. Rendered at wide, normal and close, Scan
Tear, Pylon Grid, Pitch Shatter and the 3D Particle Field came back
byte-identical at every size.

The cause was a category error in the first implementation. Framing was
applied inside sigCamera, which is gated on the `camera` personality trait —
so a scene that declined the track's drift and sway silently declined the
shot size as well. That gating is right for a TRAIT and wrong for framing,
which is not one: framing is where the camera is standing for this shot, and
no scene should be exempt from it because of an unrelated art-direction
decision.

- Framing now lives in the shader epilogue, applied to the coordinate every
  fragment scene is handed, so honouring it is not optional. uv is left
  unframed on purpose: it is screen space, and prev() and sigGrain belong to
  the output image rather than to the scene being filmed.
- Scan Tear and Pitch Shatter build their image from uv deliberately — a
  signal artefact happens to the signal, not to the world behind it. They now
  slice on raw uv and build the field they displace from a new framedUv(p),
  so the tear stays locked to the frame while the imagery behind it is filmed
  wide or close.
- Particle Field receives framing in update() and honours it as a camera
  dolly, which is what framing literally is when a layer has a real camera.
  Distance divided by scale, matching the fragment path where the coordinate
  is divided by it.

New gate renders instead of inspecting: 42 of 42 scenes now respond to
framing, weakest Ridge Terrain at 0.22 of its own brightness, against a 0.05
floor. Also drops a stale comment on Layer.setFraming that still claimed
sigCamera applied it.

105/105 checks pass including the slow set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:18:08 +02:00

164 lines
7.2 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',
// Composited over a background, never used as one: most of the frame is
// legitimately black, so it is judged on variance rather than luminance and
// the look generator only picks it as an accent layer.
role: 'accent',
// Personality: see look/Personality.js. A point cloud cannot draw the
// signature form and has no horizon, so it claims only the camera — which
// it can honour exactly, being the one scene with a real one.
traits: ['camera'],
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, personality, framing }) {
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;
// The track's camera, applied to the only literal camera in the library:
// the same slow returning pan, sway and roll every shader scene fakes in
// its coordinate space. Bounded and periodic, so a seek still lands on
// the same frame as sequential playback.
// The shot's framing, applied to the only literal camera in the
// library. Every fragment scene gets this as a coordinate scale in the
// shader epilogue; here it is what it actually is — the camera standing
// closer or further back. Dividing the distance by the scale matches
// the fragment behaviour, where the coordinate is divided by it.
const frame = framing || { scale: 1, shift: [0, 0] };
const dolly = 4 / Math.max(frame.scale, 0.05);
const cam = personality ? personality.camera : null;
if (cam) {
const pan = 20 * Math.sin(t * 0.05);
camera.position.set(
Math.cos(cam.driftAngle) * cam.driftRate * pan
+ Math.sin(t * cam.swayRate) * cam.sway + frame.shift[0],
Math.sin(cam.driftAngle) * cam.driftRate * pan
+ Math.cos(t * cam.swayRate * 0.83) * cam.sway + frame.shift[1],
dolly,
);
camera.rotation.z = cam.spin * t;
} else {
camera.position.set(frame.shift[0], frame.shift[1], dolly);
camera.rotation.z = 0;
}
camera.lookAt(camera.position.x, camera.position.y, -depth * 0.4);
},
};
export default particleField;