feat(v2.0.5): Intentional Amnesia — active forgetting via top-down inhibitory control

First AI memory system to model forgetting as a neuroscience-grounded
PROCESS rather than passive decay. Adds the `suppress` MCP tool (#24),
Rac1 cascade worker, migration V10, and dashboard forgetting indicators.

Based on:
- Anderson, Hanslmayr & Quaegebeur (2025), Nat Rev Neurosci — right
  lateral PFC as the domain-general inhibitory controller; SIF
  compounds with each stopping attempt.
- Cervantes-Sandoval et al. (2020), Front Cell Neurosci PMC7477079 —
  Rac1 GTPase as the active synaptic destabilization mechanism.

What's new:
* `suppress` MCP tool — each call compounds `suppression_count` and
  subtracts a `0.15 × count` penalty (saturating at 80%) from
  retrieval scores during hybrid search. Distinct from delete
  (removes) and demote (one-shot).
* Rac1 cascade worker — background sweep piggybacks the 6h
  consolidation loop, walks `memory_connections` edges from
  recently-suppressed seeds, applies attenuated FSRS decay to
  co-activated neighbors. You don't just forget Jake — you fade
  the café, the roommate, the birthday.
* 24h labile window — reversible via `suppress({id, reverse: true})`
  within 24 hours. Matches Nader reconsolidation semantics.
* Migration V10 — additive-only (`suppression_count`, `suppressed_at`
  + partial indices). All v2.0.x DBs upgrade seamlessly on first launch.
* Dashboard: `ForgettingIndicator.svelte` pulses when suppressions
  are active. 3D graph nodes dim to 20% opacity when suppressed.
  New WebSocket events: `MemorySuppressed`, `MemoryUnsuppressed`,
  `Rac1CascadeSwept`. Heartbeat carries `suppressed_count`.
* Search pipeline: SIF penalty inserted into the accessibility stage
  so it stacks on top of passive FSRS decay.
* Tool count bumped 23 → 24. Cognitive modules 29 → 30.

Memories persist — they are INHIBITED, not erased. `memory.get(id)`
returns full content through any number of suppressions. The 24h
labile window is a grace period for regret.

Also fixes issue #31 (dashboard graph view buggy) as a companion UI
bug discovered during the v2.0.5 audit cycle:

* Root cause: node glow `SpriteMaterial` had no `map`, so
  `THREE.Sprite` rendered as a solid-coloured 1×1 plane. Additive
  blending + `UnrealBloomPass(0.8, 0.4, 0.85)` amplified the square
  edges into hard-edged glowing cubes.
* Fix: shared 128×128 radial-gradient `CanvasTexture` singleton used
  as the sprite map. Retuned bloom to `(0.55, 0.6, 0.2)`. Halved fog
  density (0.008 → 0.0035). Edges bumped from dark navy `0x4a4a7a`
  to brand violet `0x8b5cf6` with higher opacity. Added explicit
  `scene.background` and a 2000-point starfield for depth.
* 21 regression tests added in `ui-fixes.test.ts` locking every
  invariant in (shared texture singleton, depthWrite:false, scale
  ×6, bloom magic numbers via source regex, starfield presence).

Tests: 1,284 Rust (+47) + 171 Vitest (+21) = 1,455 total, 0 failed
Clippy: clean across all targets, zero warnings
Release binary: 22.6MB, `cargo build --release -p vestige-mcp` green
Versions: workspace aligned at 2.0.5 across all 6 crates/packages

Closes #31
This commit is contained in:
Sam Valladares 2026-04-14 17:30:30 -05:00
parent 95bde93b49
commit 8178beb961
359 changed files with 8277 additions and 3416 deletions

View file

@ -48,12 +48,14 @@ describe('EdgeManager', () => {
expect(line.userData.target).toBe('b');
});
it('caps opacity at 0.6', () => {
it('caps opacity at 0.8 (raised from 0.6 in v2.0.6 issue #31 fix)', () => {
const edges = [makeEdge('a', 'b', { weight: 10.0 })];
manager.createEdges(edges, positions);
const line = manager.group.children[0] as any;
expect(line.material.opacity).toBeLessThanOrEqual(0.6);
expect(line.material.opacity).toBeLessThanOrEqual(0.8);
// Baseline floor too — with weight 10 we should be at the cap, not below old 0.6
expect(line.material.opacity).toBeGreaterThanOrEqual(0.6);
});
});
@ -122,7 +124,8 @@ describe('EdgeManager', () => {
}
const line = manager.group.children[0] as any;
expect(line.material.opacity).toBe(0.5);
// v2.0.6 issue #31 fix raised final edge opacity 0.5 → 0.65
expect(line.material.opacity).toBe(0.65);
});
it('uses easeOutCubic for smooth deceleration', () => {
@ -266,7 +269,8 @@ describe('EdgeManager', () => {
// Both should be fully grown
expect(manager.group.children.length).toBe(2);
manager.group.children.forEach((child) => {
expect((child as any).material.opacity).toBe(0.5);
// v2.0.6 issue #31 fix raised final edge opacity 0.5 → 0.65
expect((child as any).material.opacity).toBe(0.65);
});
});

View file

@ -3,15 +3,29 @@
*/
import { vi } from 'vitest';
// Minimal canvas gradient mock — collects colour stops so tests can inspect
// them if they want to, but is mostly a no-op for runtime.
function createMockGradient() {
return {
colorStops: [] as Array<{ offset: number; color: string }>,
addColorStop(offset: number, color: string) {
this.colorStops.push({ offset, color });
},
};
}
// Minimal canvas 2D context mock
const mockContext2D = {
clearRect: vi.fn(),
fillRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn(() => ({ width: 100 })),
createRadialGradient: vi.fn(() => createMockGradient()),
createLinearGradient: vi.fn(() => createMockGradient()),
font: '',
textAlign: '',
textBaseline: '',
fillStyle: '',
fillStyle: '' as string | object,
shadowColor: '',
shadowBlur: 0,
shadowOffsetX: 0,

View file

@ -282,10 +282,16 @@ export class MeshBasicMaterial extends BaseMaterial {
}
export class LineBasicMaterial extends BaseMaterial {
depthWrite = true;
constructor(params?: Record<string, unknown>) {
super();
if (params) {
if (typeof params.opacity === 'number') this.opacity = params.opacity;
if (typeof params.transparent === 'boolean') this.transparent = params.transparent;
if (params.color instanceof Color) this.color = params.color;
else if (typeof params.color === 'number') this.color = new Color(params.color);
if (typeof params.blending === 'number') this.blending = params.blending;
if (typeof params.depthWrite === 'boolean') this.depthWrite = params.depthWrite;
}
}
}
@ -303,10 +309,19 @@ export class PointsMaterial extends BaseMaterial {
}
export class SpriteMaterial extends BaseMaterial {
depthWrite = true;
constructor(params?: Record<string, unknown>) {
super();
if (params) {
if (typeof params.opacity === 'number') this.opacity = params.opacity;
if (typeof params.transparent === 'boolean') this.transparent = params.transparent;
if (params.color instanceof Color) this.color = params.color;
else if (typeof params.color === 'number') this.color = new Color(params.color);
if (typeof params.blending === 'number') this.blending = params.blending;
if (typeof params.depthWrite === 'boolean') this.depthWrite = params.depthWrite;
if (params.map && typeof params.map === 'object') {
this.map = params.map as { dispose: () => void };
}
}
}
}

View file

@ -0,0 +1,236 @@
/**
* Regression tests for vestige issue #31 (v2.0.6 Phase 1 dashboard UI fix).
*
* Before v2.0.6 the graph view rendered "glowing cubes" instead of round halos,
* with navy edges swallowed by heavy fog. Root cause: the node glow SpriteMaterial
* had no `map` set, so THREE.Sprite rendered as a solid-coloured plane whose
* square edges were then amplified by UnrealBloomPass into hard bright squares.
*
* These tests lock in every property that was broken so any regression surfaces
* as a red test instead of shipping another ugly screenshot into the issue tracker.
*
* The scene.ts assertions are intentionally source-level (fs regex) because the
* real scene.ts pulls in three/addons (OrbitControls, EffectComposer, UnrealBloomPass,
* WebGLRenderer) which are painful to mock in isolation. Reading the .ts file and
* regex-checking the magic numbers catches accidental revert/tweaks without needing
* a full WebGL harness.
*/
import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
vi.mock('three', async () => {
const mock = await import('./three-mock');
return { ...mock };
});
import { NodeManager } from '../nodes';
import { EdgeManager } from '../edges';
import { Vector3, AdditiveBlending } from './three-mock';
import { makeNode, makeEdge, resetNodeCounter } from './helpers';
// ---------------------------------------------------------------------------
// 1. Node glow sprite — THE fix for the "glowing cubes" artifact
// ---------------------------------------------------------------------------
describe('issue #31 — node glow sprites render as round halos, not squares', () => {
let manager: NodeManager;
beforeEach(() => {
resetNodeCounter();
manager = new NodeManager();
});
it('glow SpriteMaterial has a map set (the root cause of the square artifact)', () => {
manager.createNodes([makeNode({ id: 'a', retention: 0.8 })]);
const glow = manager.glowMap.get('a')!;
const mat = glow.material as any;
// Without a map, THREE.Sprite renders as a solid coloured plane —
// additive blend + bloom then turns it into a glowing square.
// The fix generates a shared radial-gradient CanvasTexture and assigns
// it here, so bloom has a soft circular shape to diffuse.
expect(mat.map).not.toBeNull();
expect(mat.map).toBeDefined();
});
it('glow sprites on multiple nodes SHARE the same texture instance (singleton cache)', () => {
// The shared texture is a module-level cache — if a future refactor
// accidentally creates one per-node we'll leak memory on large graphs.
manager.createNodes([
makeNode({ id: 'a' }),
makeNode({ id: 'b' }),
makeNode({ id: 'c' }),
]);
const a = (manager.glowMap.get('a')!.material as any).map;
const b = (manager.glowMap.get('b')!.material as any).map;
const c = (manager.glowMap.get('c')!.material as any).map;
expect(a).toBe(b);
expect(b).toBe(c);
});
it('glow sprite has depthWrite:false to prevent z-fighting with the sphere behind it', () => {
manager.createNodes([makeNode({ id: 'a' })]);
const mat = manager.glowMap.get('a')!.material as any;
expect(mat.depthWrite).toBe(false);
});
it('glow sprite uses additive blending (required for bloom to read as light)', () => {
manager.createNodes([makeNode({ id: 'a' })]);
const mat = manager.glowMap.get('a')!.material as any;
expect(mat.blending).toBe(AdditiveBlending);
});
it('glow sprite scale uses the new 6× multiplier (was 4× — gradient needed more footprint)', () => {
// size = 0.5 + retention*2 → 0.5 + 1.0*2 = 2.5
// glow scale with new formula: 2.5 * 6 * 1.0 = 15
manager.createNodes([makeNode({ id: 'full', retention: 1.0 })]);
const glow = manager.glowMap.get('full')!;
expect(glow.scale.x).toBeCloseTo(15, 5);
expect(glow.scale.y).toBeCloseTo(15, 5);
});
it('glow sprite base opacity is 0.3 + retention*0.35 (was 0.15 + retention*0.2)', () => {
manager.createNodes([makeNode({ id: 'full', retention: 1.0 })]);
const mat = manager.glowMap.get('full')!.material as any;
// 0.3 + 1.0 * 0.35 = 0.65
expect(mat.opacity).toBeCloseTo(0.65, 5);
});
it('suppressed node glow opacity drops to 0.1 (v2.0.5 active forgetting)', () => {
manager.createNodes([makeNode({ id: 's', retention: 0.8, suppression_count: 2 })]);
const mat = manager.glowMap.get('s')!.material as any;
expect(mat.opacity).toBeCloseTo(0.1, 5);
});
});
// ---------------------------------------------------------------------------
// 2. Edge materials — dark navy → brand violet, higher opacity
// ---------------------------------------------------------------------------
describe('issue #31 — edges are brand violet and actually visible', () => {
let manager: EdgeManager;
let positions: Map<string, InstanceType<typeof Vector3>>;
beforeEach(() => {
manager = new EdgeManager();
positions = new Map([
['a', new Vector3(0, 0, 0)],
['b', new Vector3(10, 0, 0)],
]);
});
it('edge color is brand violet 0x8b5cf6, not the old dark navy 0x4a4a7a', () => {
manager.createEdges([makeEdge('a', 'b', { weight: 0.5 })], positions);
const line = manager.group.children[0] as any;
const c = line.material.color;
// 0x8b5cf6 → r=139/255, g=92/255, b=246/255
expect(c.r).toBeCloseTo(0x8b / 255, 3);
expect(c.g).toBeCloseTo(0x5c / 255, 3);
expect(c.b).toBeCloseTo(0xf6 / 255, 3);
// And definitely NOT the old navy 0x4a4a7a (74/255, 74/255, 122/255)
expect(c.r).not.toBeCloseTo(0x4a / 255, 3);
});
it('edges have depthWrite:false so they additively blend through fog cleanly', () => {
manager.createEdges([makeEdge('a', 'b')], positions);
const line = manager.group.children[0] as any;
expect(line.material.depthWrite).toBe(false);
});
it('edge opacity base is 0.25 + weight*0.5 (was 0.1 + weight*0.5)', () => {
manager.createEdges([makeEdge('a', 'b', { weight: 0.5 })], positions);
const line = manager.group.children[0] as any;
// 0.25 + 0.5 * 0.5 = 0.5
expect(line.material.opacity).toBeCloseTo(0.5, 5);
});
it('edge opacity with low weight still reads (new floor catches regressions)', () => {
manager.createEdges([makeEdge('a', 'b', { weight: 0.0 })], positions);
const line = manager.group.children[0] as any;
// Floor is 0.25 — used to be 0.1 which was invisible through fog
expect(line.material.opacity).toBeGreaterThanOrEqual(0.25);
});
it('edge opacity cap is 0.8 (was 0.6)', () => {
manager.createEdges([makeEdge('a', 'b', { weight: 100.0 })], positions);
const line = manager.group.children[0] as any;
expect(line.material.opacity).toBeCloseTo(0.8, 5);
});
});
// ---------------------------------------------------------------------------
// 3. Scene config — source-level regex assertions (scene.ts needs three/addons)
// ---------------------------------------------------------------------------
describe('issue #31 — scene.ts bloom/fog/starfield config is locked in', () => {
const __dirname = dirname(fileURLToPath(import.meta.url));
let src: string;
beforeAll(() => {
src = readFileSync(resolve(__dirname, '../scene.ts'), 'utf-8');
});
it('fog density is reduced from 0.008 → 0.0035', () => {
// Positive match: the new density appears inside a FogExp2 call
expect(src).toMatch(/FogExp2\(\s*0x[0-9a-f]+,\s*0\.0035/i);
// Negative match: the old aggressive density is gone
expect(src).not.toMatch(/FogExp2\(\s*0x[0-9a-f]+,\s*0\.008\b/i);
});
it('bloom strength is 0.55 (was 0.8 — was blown out)', () => {
// Match on the constructor signature: (size, strength, radius, threshold)
expect(src).toMatch(
/new UnrealBloomPass\([\s\S]*?,\s*0\.55,\s*0\.6,\s*0\.2\s*\)/
);
// Old values must be gone
expect(src).not.toMatch(/new UnrealBloomPass\([\s\S]*?,\s*0\.8,\s*0\.4,\s*0\.85\s*\)/);
});
it('scene.background is explicitly set (not left as default black void)', () => {
expect(src).toMatch(/scene\.background\s*=/);
});
it('a starfield is created and added to the scene', () => {
// createStarfield helper exists and is called at least once
expect(src).toMatch(/function\s+createStarfield\s*\(/);
expect(src).toMatch(/createStarfield\s*\(\s*\)/);
expect(src).toMatch(/scene\.add\(\s*starfield\s*\)/);
});
it('starfield is exposed on SceneContext (so dispose/update can touch it later)', () => {
expect(src).toMatch(/starfield:\s*THREE\.Points/);
});
it('ACESFilmicToneMapping still active (did not accidentally revert tone map)', () => {
expect(src).toMatch(/ACESFilmicToneMapping/);
});
});
// ---------------------------------------------------------------------------
// 4. Source-level checks on nodes.ts — the shared glow texture helper
// ---------------------------------------------------------------------------
describe('issue #31 — nodes.ts glow texture helper exists and is a singleton', () => {
const __dirname = dirname(fileURLToPath(import.meta.url));
let src: string;
beforeAll(() => {
src = readFileSync(resolve(__dirname, '../nodes.ts'), 'utf-8');
});
it('shared glow texture cache exists at module level', () => {
expect(src).toMatch(/let\s+sharedGlowTexture/);
expect(src).toMatch(/function\s+getGlowTexture\s*\(/);
});
it('radial gradient has a transparent outer stop (not hard edge)', () => {
// The key insight — colour stops must go to rgba(255,255,255,0) at the edge
expect(src).toMatch(/createRadialGradient/);
expect(src).toMatch(/rgba\(255,\s*255,\s*255,\s*0(?:\.0)?\)/);
});
it('SpriteMaterial is constructed with a map parameter', () => {
expect(src).toMatch(/new THREE\.SpriteMaterial\(\{[\s\S]*?map:\s*getGlowTexture\(\)/);
});
});

View file

@ -36,11 +36,15 @@ export class EdgeManager {
const points = [sourcePos, targetPos];
const geometry = new THREE.BufferGeometry().setFromPoints(points);
// Brand violet (#8b5cf6) instead of the old dark navy 0x4a4a7a
// which was invisible against the fog. Higher opacity base so
// edges actually read as a graph.
const material = new THREE.LineBasicMaterial({
color: 0x4a4a7a,
color: 0x8b5cf6,
transparent: true,
opacity: Math.min(0.1 + edge.weight * 0.5, 0.6),
opacity: Math.min(0.25 + edge.weight * 0.5, 0.8),
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const line = new THREE.Line(geometry, material);
@ -58,10 +62,11 @@ export class EdgeManager {
const points = [sourcePos.clone(), sourcePos.clone()];
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({
color: 0x4a4a7a,
color: 0x8b5cf6,
transparent: true,
opacity: 0,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const line = new THREE.Line(geometry, material);
@ -111,11 +116,11 @@ export class EdgeManager {
attrs.needsUpdate = true;
const mat = g.line.material as THREE.LineBasicMaterial;
mat.opacity = progress * 0.5;
mat.opacity = progress * 0.65;
if (g.frame >= g.totalFrames) {
// Final opacity from weight
mat.opacity = 0.5;
// Final opacity matches new createEdges baseline
mat.opacity = 0.65;
this.growingEdges.splice(i, 1);
}
}
@ -126,7 +131,7 @@ export class EdgeManager {
d.frame++;
const progress = d.frame / d.totalFrames;
const mat = d.line.material as THREE.LineBasicMaterial;
mat.opacity = Math.max(0, 0.5 * (1 - progress));
mat.opacity = Math.max(0, 0.65 * (1 - progress));
if (d.frame >= d.totalFrames) {
this.group.remove(d.line);

View file

@ -2,6 +2,37 @@ import * as THREE from 'three';
import type { GraphNode } from '$types';
import { NODE_TYPE_COLORS } from '$types';
// Shared radial-gradient texture used for every node's glow Sprite.
// Without a map, THREE.Sprite renders as a flat coloured plane — additive-
// blending + UnrealBloomPass then amplifies its square edges into the
// hard-edged "glowing cubes" artefact reported in issue #31. Using a
// soft radial gradient gives a real round halo and lets bloom do its job.
let sharedGlowTexture: THREE.Texture | null = null;
function getGlowTexture(): THREE.Texture {
if (sharedGlowTexture) return sharedGlowTexture;
const size = 128;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) {
// Fallback: empty 1x1 texture; halos will be invisible but nothing crashes.
sharedGlowTexture = new THREE.Texture();
return sharedGlowTexture;
}
const gradient = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
gradient.addColorStop(0.0, 'rgba(255, 255, 255, 1.0)');
gradient.addColorStop(0.25, 'rgba(255, 255, 255, 0.7)');
gradient.addColorStop(0.55, 'rgba(255, 255, 255, 0.2)');
gradient.addColorStop(1.0, 'rgba(255, 255, 255, 0.0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, size);
const tex = new THREE.CanvasTexture(canvas);
tex.needsUpdate = true;
sharedGlowTexture = tex;
return tex;
}
function easeOutElastic(t: number): number {
if (t === 0 || t === 1) return t;
const p = 0.3;
@ -90,16 +121,20 @@ export class NodeManager {
const size = 0.5 + node.retention * 2;
const color = NODE_TYPE_COLORS[node.type] || '#8B95A5';
// v2.0.5 Active Forgetting: suppressed memories dim to 20% opacity
// and lose their emissive glow, mimicking inhibitory-control silencing.
const isSuppressed = (node.suppression_count ?? 0) > 0;
// Node mesh
const geometry = new THREE.SphereGeometry(size, 16, 16);
const material = new THREE.MeshStandardMaterial({
color: new THREE.Color(color),
emissive: new THREE.Color(color),
emissiveIntensity: 0.3 + node.retention * 0.5,
emissiveIntensity: isSuppressed ? 0.0 : 0.3 + node.retention * 0.5,
roughness: 0.3,
metalness: 0.1,
transparent: true,
opacity: 0.3 + node.retention * 0.7,
opacity: isSuppressed ? 0.2 : 0.3 + node.retention * 0.7,
});
const mesh = new THREE.Mesh(geometry, material);
@ -109,15 +144,20 @@ export class NodeManager {
this.meshMap.set(node.id, mesh);
this.group.add(mesh);
// Glow sprite
// Glow sprite — radial-gradient texture kills the square-halo artefact
// from issue #31. depthWrite:false prevents z-fighting with the sphere.
const spriteMat = new THREE.SpriteMaterial({
map: getGlowTexture(),
color: new THREE.Color(color),
transparent: true,
opacity: initialScale > 0 ? 0.15 + node.retention * 0.2 : 0,
opacity: initialScale > 0 ? (isSuppressed ? 0.1 : 0.3 + node.retention * 0.35) : 0,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const sprite = new THREE.Sprite(spriteMat);
sprite.scale.set(size * 4 * initialScale, size * 4 * initialScale, 1);
// Slightly larger halo — the gradient falls off quickly so we need
// more screen real estate for a visible soft bloom footprint.
sprite.scale.set(size * 6 * initialScale, size * 6 * initialScale, 1);
sprite.position.copy(pos);
sprite.userData = { isGlow: true, nodeId: node.id };
this.glowMap.set(node.id, sprite);
@ -275,8 +315,8 @@ export class NodeManager {
if (mn.frame >= 5) {
const glowT = Math.min((mn.frame - 5) / 5, 1);
const glowMat = mn.glow.material as THREE.SpriteMaterial;
glowMat.opacity = glowT * 0.25;
const glowSize = mn.targetScale * 4 * scale;
glowMat.opacity = glowT * 0.4;
const glowSize = mn.targetScale * 6 * scale;
mn.glow.scale.set(glowSize, glowSize, 1);
}
@ -300,7 +340,7 @@ export class NodeManager {
const scale = Math.max(0.001, dn.originalScale * shrink);
dn.mesh.scale.setScalar(scale);
const glowScale = scale * 4;
const glowScale = scale * 6;
dn.glow.scale.set(glowScale, glowScale, 1);
// Fade opacity
@ -342,7 +382,7 @@ export class NodeManager {
const glow = this.glowMap.get(gn.id);
if (glow) {
const glowSize = scale * 4;
const glowSize = scale * 6;
glow.scale.set(glowSize, glowSize, 1);
}

View file

@ -18,11 +18,53 @@ export interface SceneContext {
point1: THREE.PointLight;
point2: THREE.PointLight;
};
starfield: THREE.Points;
}
function createStarfield(): THREE.Points {
// 2000 dim points distributed on a spherical shell at radius 600-1000.
// Purely decorative depth cue — never intersects the graph area and
// sits below the bloom threshold so it doesn't bloom.
const starCount = 2000;
const positions = new Float32Array(starCount * 3);
const colors = new Float32Array(starCount * 3);
for (let i = 0; i < starCount; i++) {
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const r = 600 + Math.random() * 400;
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
positions[i * 3 + 2] = r * Math.cos(phi);
// Subtle colour variation — mostly cool white, some violet tint.
const tint = Math.random();
colors[i * 3] = 0.55 + tint * 0.25;
colors[i * 3 + 1] = 0.55 + tint * 0.15;
colors[i * 3 + 2] = 0.75 + tint * 0.25;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const mat = new THREE.PointsMaterial({
size: 1.6,
sizeAttenuation: true,
vertexColors: true,
transparent: true,
opacity: 0.6,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
return new THREE.Points(geo, mat);
}
export function createScene(container: HTMLDivElement): SceneContext {
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x050510, 0.008);
// Darker-than-black background with a subtle colour cast. Combined with
// the starfield and reduced fog, the void has depth instead of reading
// as a broken shader canvas.
scene.background = new THREE.Color(0x05050f);
// Fog density reduced 0.008 → 0.0035 — the old value was killing every
// edge and node past 50 units. Lighter colour blends into the background.
scene.fog = new THREE.FogExp2(0x0a0a1a, 0.0035);
const camera = new THREE.PerspectiveCamera(
60,
@ -40,7 +82,7 @@ export function createScene(container: HTMLDivElement): SceneContext {
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
renderer.toneMappingExposure = 1.25;
container.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
@ -55,25 +97,32 @@ export function createScene(container: HTMLDivElement): SceneContext {
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
// Bloom retuned for radial-gradient glow sprites (issue #31 fix):
// strength 0.8 → 0.55 — gentler, avoids the old blown-out look
// radius 0.4 → 0.6 — softer falloff, diffuses cleanly through glow
// threshold 0.85 → 0.2 — let mid-tones bloom instead of highlights only
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(container.clientWidth, container.clientHeight),
0.8,
0.4,
0.85
0.55,
0.6,
0.2
);
composer.addPass(bloomPass);
const ambient = new THREE.AmbientLight(0x1a1a3a, 0.5);
const ambient = new THREE.AmbientLight(0x2a2a5a, 0.7);
scene.add(ambient);
const point1 = new THREE.PointLight(0x6366f1, 1.5, 200);
const point1 = new THREE.PointLight(0x6366f1, 1.8, 240);
point1.position.set(50, 50, 50);
scene.add(point1);
const point2 = new THREE.PointLight(0xa855f7, 1, 200);
const point2 = new THREE.PointLight(0xa855f7, 1.2, 240);
point2.position.set(-50, -30, -50);
scene.add(point2);
const starfield = createStarfield();
scene.add(starfield);
const raycaster = new THREE.Raycaster();
raycaster.params.Points = { threshold: 2 };
const mouse = new THREE.Vector2();
@ -88,6 +137,7 @@ export function createScene(container: HTMLDivElement): SceneContext {
raycaster,
mouse,
lights: { ambient, point1, point2 },
starfield,
};
}