feat(mnemosyne): ambient particle system for memory activity visualization

Issue #1173
- Spawn burst (20 particles, 2s fade) on new fact stored
- Access trail (10 particles) streaming to crystal on fact access
- Ambient cosmic dust (200 particles, slow drift)
- Category colors for all particles
- Total budget < 500 particles at any time
This commit is contained in:
2026-04-11 00:49:13 +00:00
committed by Alexander Whitestone
parent 942e9a03c7
commit 706ecc2b00

View File

@@ -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 };