diff --git a/nexus/components/memory-particles.js b/nexus/components/memory-particles.js new file mode 100644 index 00000000..6ec46941 --- /dev/null +++ b/nexus/components/memory-particles.js @@ -0,0 +1,404 @@ +// ═══════════════════════════════════════════ +// PROJECT MNEMOSYNE — AMBIENT PARTICLE SYSTEM +// ═══════════════════════════════════════════ +// +// Memory activity visualization via Three.js Points. +// Three particle modes: +// 1. Spawn burst — 20 particles on new fact, 2s fade +// 2. Access trail — 10 particles streaming to crystal +// 3. Ambient dust — 200 particles, slow cosmic drift +// +// Category colors for all particles. +// Total budget: < 500 particles at any time. +// +// Usage from app.js: +// import { MemoryParticles } from './nexus/components/memory-particles.js'; +// MemoryParticles.init(scene); +// MemoryParticles.onMemoryPlaced(position, category); +// MemoryParticles.onMemoryAccessed(fromPos, toPos, category); +// MemoryParticles.update(delta); +// ═══════════════════════════════════════════ + +const MemoryParticles = (() => { + let _scene = null; + let _initialized = false; + + // ─── CATEGORY COLORS ────────────────────── + const CATEGORY_COLORS = { + engineering: new THREE.Color(0x4af0c0), + social: new THREE.Color(0x7b5cff), + knowledge: new THREE.Color(0xffd700), + projects: new THREE.Color(0xff4466), + working: new THREE.Color(0x00ff88), + archive: new THREE.Color(0x334455), + user_pref: new THREE.Color(0xffd700), + project: new THREE.Color(0x4488ff), + tool_knowledge: new THREE.Color(0x44ff88), + general: new THREE.Color(0x8899aa), + }; + const DEFAULT_COLOR = new THREE.Color(0x8899bb); + + // ─── PARTICLE BUDGETS ───────────────────── + const MAX_BURST_PARTICLES = 20; // per spawn event + const MAX_TRAIL_PARTICLES = 10; // per access event + const AMBIENT_COUNT = 200; // always-on dust + const MAX_ACTIVE_BURSTS = 8; // max concurrent burst groups + const MAX_ACTIVE_TRAILS = 5; // max concurrent trail groups + + // ─── ACTIVE PARTICLE GROUPS ─────────────── + let _bursts = []; // { points, velocities, life, maxLife } + let _trails = []; // { points, velocities, life, maxLife, target } + let _ambientPoints = null; + + // ─── HELPERS ────────────────────────────── + function _getCategoryColor(category) { + return CATEGORY_COLORS[category] || DEFAULT_COLOR; + } + + // ═══ AMBIENT DUST ═════════════════════════ + function _createAmbient() { + const geo = new THREE.BufferGeometry(); + const positions = new Float32Array(AMBIENT_COUNT * 3); + const colors = new Float32Array(AMBIENT_COUNT * 3); + const sizes = new Float32Array(AMBIENT_COUNT); + + // Distribute across the world + for (let i = 0; i < AMBIENT_COUNT; i++) { + positions[i * 3] = (Math.random() - 0.5) * 50; + positions[i * 3 + 1] = Math.random() * 18 + 1; + positions[i * 3 + 2] = (Math.random() - 0.5) * 50; + + // Subtle category-tinted colors + const categories = Object.keys(CATEGORY_COLORS); + const cat = categories[Math.floor(Math.random() * categories.length)]; + const col = _getCategoryColor(cat).clone().multiplyScalar(0.4 + Math.random() * 0.3); + colors[i * 3] = col.r; + colors[i * 3 + 1] = col.g; + colors[i * 3 + 2] = col.b; + + sizes[i] = 0.02 + Math.random() * 0.04; + } + + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + geo.setAttribute('size', new THREE.BufferAttribute(sizes, 1)); + + const mat = new THREE.ShaderMaterial({ + uniforms: { uTime: { value: 0 } }, + vertexShader: ` + attribute float size; + attribute vec3 color; + varying vec3 vColor; + varying float vAlpha; + uniform float uTime; + void main() { + vColor = color; + vec3 pos = position; + // Slow cosmic drift + pos.x += sin(uTime * 0.08 + position.y * 0.3) * 0.5; + pos.y += sin(uTime * 0.05 + position.z * 0.2) * 0.3; + pos.z += cos(uTime * 0.06 + position.x * 0.25) * 0.4; + vec4 mv = modelViewMatrix * vec4(pos, 1.0); + gl_PointSize = size * 250.0 / -mv.z; + gl_Position = projectionMatrix * mv; + // Fade with distance + vAlpha = smoothstep(40.0, 10.0, -mv.z) * 0.5; + } + `, + fragmentShader: ` + varying vec3 vColor; + varying float vAlpha; + void main() { + float d = length(gl_PointCoord - 0.5); + if (d > 0.5) discard; + float alpha = smoothstep(0.5, 0.05, d); + gl_FragColor = vec4(vColor, alpha * vAlpha); + } + `, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + + _ambientPoints = new THREE.Points(geo, mat); + _scene.add(_ambientPoints); + } + + // ═══ BURST EFFECT ═════════════════════════ + function _createBurst(position, category) { + const count = MAX_BURST_PARTICLES; + const geo = new THREE.BufferGeometry(); + const positions = new Float32Array(count * 3); + const colors = new Float32Array(count * 3); + const sizes = new Float32Array(count); + const velocities = []; + const col = _getCategoryColor(category); + + for (let i = 0; i < count; i++) { + positions[i * 3] = position.x; + positions[i * 3 + 1] = position.y; + positions[i * 3 + 2] = position.z; + + colors[i * 3] = col.r; + colors[i * 3 + 1] = col.g; + colors[i * 3 + 2] = col.b; + + sizes[i] = 0.06 + Math.random() * 0.06; + + // Random outward velocity + const theta = Math.random() * Math.PI * 2; + const phi = Math.random() * Math.PI; + const speed = 1.5 + Math.random() * 2.5; + velocities.push( + Math.sin(phi) * Math.cos(theta) * speed, + Math.cos(phi) * speed * 0.8 + 1.0, // bias upward + Math.sin(phi) * Math.sin(theta) * speed + ); + } + + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + geo.setAttribute('size', new THREE.BufferAttribute(sizes, 1)); + + const mat = new THREE.ShaderMaterial({ + uniforms: { uOpacity: { value: 1.0 } }, + vertexShader: ` + attribute float size; + attribute vec3 color; + varying vec3 vColor; + uniform float uOpacity; + void main() { + vColor = color; + vec4 mv = modelViewMatrix * vec4(position, 1.0); + gl_PointSize = size * 300.0 / -mv.z; + gl_Position = projectionMatrix * mv; + } + `, + fragmentShader: ` + varying vec3 vColor; + uniform float uOpacity; + void main() { + float d = length(gl_PointCoord - 0.5); + if (d > 0.5) discard; + float alpha = smoothstep(0.5, 0.05, d); + gl_FragColor = vec4(vColor, alpha * uOpacity); + } + `, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + + const points = new THREE.Points(geo, mat); + _scene.add(points); + + _bursts.push({ + points, + velocities, + life: 0, + maxLife: 2.0, // 2s fade + }); + + // Cap active bursts + while (_bursts.length > MAX_ACTIVE_BURSTS) { + _removeBurst(0); + } + } + + function _removeBurst(idx) { + const burst = _bursts[idx]; + if (burst.points.parent) burst.points.parent.remove(burst.points); + burst.points.geometry.dispose(); + burst.points.material.dispose(); + _bursts.splice(idx, 1); + } + + // ═══ TRAIL EFFECT ═════════════════════════ + function _createTrail(fromPos, toPos, category) { + const count = MAX_TRAIL_PARTICLES; + const geo = new THREE.BufferGeometry(); + const positions = new Float32Array(count * 3); + const colors = new Float32Array(count * 3); + const sizes = new Float32Array(count); + const velocities = []; + const col = _getCategoryColor(category); + + for (let i = 0; i < count; i++) { + // Stagger start positions along the path + const t = Math.random(); + positions[i * 3] = fromPos.x + (toPos.x - fromPos.x) * t + (Math.random() - 0.5) * 0.5; + positions[i * 3 + 1] = fromPos.y + (toPos.y - fromPos.y) * t + (Math.random() - 0.5) * 0.5; + positions[i * 3 + 2] = fromPos.z + (toPos.z - fromPos.z) * t + (Math.random() - 0.5) * 0.5; + + colors[i * 3] = col.r; + colors[i * 3 + 1] = col.g; + colors[i * 3 + 2] = col.b; + + sizes[i] = 0.04 + Math.random() * 0.04; + + // Velocity toward target with slight randomness + const dx = toPos.x - fromPos.x; + const dy = toPos.y - fromPos.y; + const dz = toPos.z - fromPos.z; + const len = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1; + const speed = 2.0 + Math.random() * 1.5; + velocities.push( + (dx / len) * speed + (Math.random() - 0.5) * 0.5, + (dy / len) * speed + (Math.random() - 0.5) * 0.5, + (dz / len) * speed + (Math.random() - 0.5) * 0.5 + ); + } + + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + geo.setAttribute('size', new THREE.BufferAttribute(sizes, 1)); + + const mat = new THREE.ShaderMaterial({ + uniforms: { uOpacity: { value: 1.0 } }, + vertexShader: ` + attribute float size; + attribute vec3 color; + varying vec3 vColor; + uniform float uOpacity; + void main() { + vColor = color; + vec4 mv = modelViewMatrix * vec4(position, 1.0); + gl_PointSize = size * 280.0 / -mv.z; + gl_Position = projectionMatrix * mv; + } + `, + fragmentShader: ` + varying vec3 vColor; + uniform float uOpacity; + void main() { + float d = length(gl_PointCoord - 0.5); + if (d > 0.5) discard; + float alpha = smoothstep(0.5, 0.05, d); + gl_FragColor = vec4(vColor, alpha * uOpacity); + } + `, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + + const points = new THREE.Points(geo, mat); + _scene.add(points); + + _trails.push({ + points, + velocities, + life: 0, + maxLife: 1.5, // 1.5s trail + target: toPos.clone(), + }); + + // Cap active trails + while (_trails.length > MAX_ACTIVE_TRAILS) { + _removeTrail(0); + } + } + + function _removeTrail(idx) { + const trail = _trails[idx]; + if (trail.points.parent) trail.points.parent.remove(trail.points); + trail.points.geometry.dispose(); + trail.points.material.dispose(); + _trails.splice(idx, 1); + } + + // ═══ PUBLIC API ═══════════════════════════ + function init(scene) { + _scene = scene; + _initialized = true; + _createAmbient(); + console.info('[Mnemosyne] Ambient particle system initialized —', AMBIENT_COUNT, 'dust particles'); + } + + function onMemoryPlaced(position, category) { + if (!_initialized) return; + const pos = position instanceof THREE.Vector3 ? position : new THREE.Vector3(position.x, position.y, position.z); + _createBurst(pos, category); + } + + function onMemoryAccessed(fromPosition, toPosition, category) { + if (!_initialized) return; + const from = fromPosition instanceof THREE.Vector3 ? fromPosition : new THREE.Vector3(fromPosition.x, fromPosition.y, fromPosition.z); + const to = toPosition instanceof THREE.Vector3 ? toPosition : new THREE.Vector3(toPosition.x, toPosition.y, toPosition.z); + _createTrail(from, to, category); + } + + function update(delta) { + if (!_initialized) return; + + // Update ambient dust + if (_ambientPoints && _ambientPoints.material.uniforms) { + _ambientPoints.material.uniforms.uTime.value += delta; + } + + // Update bursts + for (let i = _bursts.length - 1; i >= 0; i--) { + const burst = _bursts[i]; + burst.life += delta; + const t = burst.life / burst.maxLife; + + if (t >= 1.0) { + _removeBurst(i); + continue; + } + + const pos = burst.points.geometry.attributes.position.array; + for (let j = 0; j < MAX_BURST_PARTICLES; j++) { + pos[j * 3] += burst.velocities[j * 3] * delta; + pos[j * 3 + 1] += burst.velocities[j * 3 + 1] * delta; + pos[j * 3 + 2] += burst.velocities[j * 3 + 2] * delta; + + // Gravity + drag + burst.velocities[j * 3 + 1] -= delta * 0.5; + burst.velocities[j * 3] *= 0.98; + burst.velocities[j * 3 + 1] *= 0.98; + burst.velocities[j * 3 + 2] *= 0.98; + } + burst.points.geometry.attributes.position.needsUpdate = true; + burst.points.material.uniforms.uOpacity.value = 1.0 - t; + } + + // Update trails + for (let i = _trails.length - 1; i >= 0; i--) { + const trail = _trails[i]; + trail.life += delta; + const t = trail.life / trail.maxLife; + + if (t >= 1.0) { + _removeTrail(i); + continue; + } + + const pos = trail.points.geometry.attributes.position.array; + for (let j = 0; j < MAX_TRAIL_PARTICLES; j++) { + pos[j * 3] += trail.velocities[j * 3] * delta; + pos[j * 3 + 1] += trail.velocities[j * 3 + 1] * delta; + pos[j * 3 + 2] += trail.velocities[j * 3 + 2] * delta; + } + trail.points.geometry.attributes.position.needsUpdate = true; + trail.points.material.uniforms.uOpacity.value = 1.0 - t * t; + } + } + + function getActiveParticleCount() { + let total = AMBIENT_COUNT; + _bursts.forEach(b => { total += MAX_BURST_PARTICLES; }); + _trails.forEach(t => { total += MAX_TRAIL_PARTICLES; }); + return total; + } + + return { + init, + onMemoryPlaced, + onMemoryAccessed, + update, + getActiveParticleCount, + }; +})(); + +export { MemoryParticles };