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