Skip to content

Commit be19fc8

Browse files
committed
feat(brain): add Beams — pooled merged-geometry pulse shader, bezier arcs
P4 of Cortex Brain v2. - brain-v2/util/bezierArc.js — quadratic bezier with control point lifted radially outward from origin (+18% mid-length). Returns segments+1 THREE.Vector3 points for any from→to pair. - brain-v2/PulseShader.js — ShaderMaterial with traveling pulse (uTime, head/trail smoothstep), per-vertex color attribute (aColor) fading to white at the head, RedFormat FloatType DataTexture for activation. Additive blending, depth-write off. - brain-v2/Beams.js — pre-allocated 64-slot pool, single merged LineSegments BufferGeometry (1088 verts: 64 × 17), attributes aProgress/aBeamId/aColor. fire({from, to, color, life}) writes 17 bezier-arc verts into a slot + colors + activation. tick(now) advances uTime and decays activation per slot via riseDecay (rise 80 ms, τ 280 ms). Pool reuses oldest active slot when full. activeCount + dispose helpers. - brain-v2/index.jsx — mounts Beams alongside Satellites; tick fan-out drives both. Dev-only window.__brainFire(fromId, toId, color) for smoke testing. Tests: 134 passed (vitest --run). Build: vite green.
1 parent 5685835 commit be19fc8

5 files changed

Lines changed: 315 additions & 0 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import * as THREE from "three";
2+
import { bezierArcPoints } from "./util/bezierArc.js";
3+
import { createActivationTexture, createPulseMaterial } from "./PulseShader.js";
4+
import { riseDecay } from "./util/easing.js";
5+
6+
const POOL_SIZE = 64;
7+
const SEGMENTS = 16;
8+
const VERTS_PER_BEAM = SEGMENTS + 1;
9+
const RISE_MS = 80;
10+
const TAU_MS = 280;
11+
const DEFAULT_LIFE_MS = 600;
12+
13+
const _from = new THREE.Vector3();
14+
const _to = new THREE.Vector3();
15+
16+
export function createBeams({ scene }) {
17+
const totalVerts = POOL_SIZE * VERTS_PER_BEAM;
18+
const positions = new Float32Array(totalVerts * 3);
19+
const progresses = new Float32Array(totalVerts);
20+
const beamIds = new Float32Array(totalVerts);
21+
const colors = new Float32Array(totalVerts * 3);
22+
const indices = new Uint16Array(POOL_SIZE * SEGMENTS * 2);
23+
24+
for (let beam = 0; beam < POOL_SIZE; beam += 1) {
25+
for (let s = 0; s <= SEGMENTS; s += 1) {
26+
const v = beam * VERTS_PER_BEAM + s;
27+
progresses[v] = s / SEGMENTS;
28+
beamIds[v] = beam;
29+
}
30+
for (let s = 0; s < SEGMENTS; s += 1) {
31+
const i = (beam * SEGMENTS + s) * 2;
32+
indices[i] = beam * VERTS_PER_BEAM + s;
33+
indices[i + 1] = beam * VERTS_PER_BEAM + s + 1;
34+
}
35+
}
36+
37+
const geometry = new THREE.BufferGeometry();
38+
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
39+
geometry.setAttribute("aProgress", new THREE.BufferAttribute(progresses, 1));
40+
geometry.setAttribute("aBeamId", new THREE.BufferAttribute(beamIds, 1));
41+
geometry.setAttribute("aColor", new THREE.BufferAttribute(colors, 3));
42+
geometry.setIndex(new THREE.BufferAttribute(indices, 1));
43+
44+
const { texture, data } = createActivationTexture(POOL_SIZE);
45+
const material = createPulseMaterial({
46+
activationTexture: texture,
47+
beamCount: POOL_SIZE,
48+
});
49+
50+
const mesh = new THREE.LineSegments(geometry, material);
51+
mesh.frustumCulled = false;
52+
mesh.renderOrder = 1;
53+
mesh.name = "brain-v2-beams";
54+
scene.add(mesh);
55+
56+
const slots = Array.from({ length: POOL_SIZE }, () => ({
57+
active: false,
58+
startTime: 0,
59+
lifeMs: DEFAULT_LIFE_MS,
60+
}));
61+
62+
function findSlot(now) {
63+
for (let i = 0; i < POOL_SIZE; i += 1) {
64+
if (!slots[i].active) return i;
65+
}
66+
let oldestIdx = 0;
67+
let oldestTime = Infinity;
68+
for (let i = 0; i < POOL_SIZE; i += 1) {
69+
if (slots[i].startTime < oldestTime) {
70+
oldestTime = slots[i].startTime;
71+
oldestIdx = i;
72+
}
73+
}
74+
return oldestIdx;
75+
}
76+
77+
function fire({ from, to, color = "#22d3ee", life = DEFAULT_LIFE_MS, now = performance.now() }) {
78+
if (!from || !to) return -1;
79+
_from.set(from.x, from.y, from.z);
80+
_to.set(to.x, to.y, to.z);
81+
const arc = bezierArcPoints(_from, _to, SEGMENTS, 0.18);
82+
const slot = findSlot(now);
83+
const baseVert = slot * VERTS_PER_BEAM;
84+
const c = new THREE.Color(color);
85+
for (let i = 0; i < arc.length; i += 1) {
86+
const v = baseVert + i;
87+
positions[v * 3 + 0] = arc[i].x;
88+
positions[v * 3 + 1] = arc[i].y;
89+
positions[v * 3 + 2] = arc[i].z;
90+
colors[v * 3 + 0] = c.r;
91+
colors[v * 3 + 1] = c.g;
92+
colors[v * 3 + 2] = c.b;
93+
}
94+
geometry.attributes.position.needsUpdate = true;
95+
geometry.attributes.aColor.needsUpdate = true;
96+
slots[slot].active = true;
97+
slots[slot].startTime = now;
98+
slots[slot].lifeMs = life;
99+
data[slot] = 0;
100+
texture.needsUpdate = true;
101+
return slot;
102+
}
103+
104+
function tick(now = performance.now()) {
105+
let dirty = false;
106+
for (let i = 0; i < POOL_SIZE; i += 1) {
107+
const slot = slots[i];
108+
if (!slot.active) {
109+
if (data[i] !== 0) {
110+
data[i] = 0;
111+
dirty = true;
112+
}
113+
continue;
114+
}
115+
const t = now - slot.startTime;
116+
if (t >= slot.lifeMs) {
117+
slot.active = false;
118+
data[i] = 0;
119+
dirty = true;
120+
continue;
121+
}
122+
const value = riseDecay(t, RISE_MS, TAU_MS);
123+
data[i] = Math.min(1, value);
124+
dirty = true;
125+
}
126+
if (dirty) texture.needsUpdate = true;
127+
material.uniforms.uTime.value = (now * 0.001) % 1000;
128+
}
129+
130+
function activeCount() {
131+
return slots.reduce((n, s) => n + (s.active ? 1 : 0), 0);
132+
}
133+
134+
function dispose() {
135+
scene.remove(mesh);
136+
geometry.dispose();
137+
material.dispose();
138+
texture.dispose();
139+
}
140+
141+
return { mesh, fire, tick, activeCount, dispose };
142+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import * as THREE from "three";
2+
3+
const VERTEX = /* glsl */ `
4+
attribute float aProgress;
5+
attribute float aBeamId;
6+
attribute vec3 aColor;
7+
uniform sampler2D uActivation;
8+
uniform float uBeamCount;
9+
varying float vProgress;
10+
varying float vActivation;
11+
varying vec3 vColor;
12+
13+
void main() {
14+
vProgress = aProgress;
15+
vColor = aColor;
16+
float u = (aBeamId + 0.5) / max(uBeamCount, 1.0);
17+
vActivation = texture2D(uActivation, vec2(u, 0.5)).r;
18+
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
19+
}
20+
`;
21+
22+
const FRAGMENT = /* glsl */ `
23+
precision mediump float;
24+
uniform float uTime;
25+
uniform float uHeadSpeed;
26+
uniform float uHeadWidth;
27+
uniform float uTrailWidth;
28+
uniform float uBaseOpacity;
29+
varying float vProgress;
30+
varying float vActivation;
31+
varying vec3 vColor;
32+
33+
void main() {
34+
float head = mod(uTime * uHeadSpeed, 1.0);
35+
float lead = smoothstep(head - uHeadWidth, head, vProgress);
36+
float trail = 1.0 - smoothstep(head, head + uTrailWidth, vProgress);
37+
float pulse = lead * trail;
38+
39+
vec3 white = vec3(1.0);
40+
vec3 color = mix(vColor, white, clamp(pulse, 0.0, 1.0));
41+
float intensity = (uBaseOpacity + vActivation * 0.85 + pulse * vActivation * 1.4);
42+
gl_FragColor = vec4(color * intensity, clamp(intensity, 0.0, 1.0));
43+
}
44+
`;
45+
46+
export function createActivationTexture(slotCount) {
47+
const size = Math.max(slotCount, 1);
48+
const data = new Float32Array(size);
49+
const texture = new THREE.DataTexture(data, size, 1, THREE.RedFormat, THREE.FloatType);
50+
texture.needsUpdate = true;
51+
texture.minFilter = THREE.NearestFilter;
52+
texture.magFilter = THREE.NearestFilter;
53+
texture.generateMipmaps = false;
54+
return { texture, data };
55+
}
56+
57+
export function createPulseMaterial({
58+
activationTexture,
59+
beamCount,
60+
baseOpacity = 0.0,
61+
headSpeed = 0.6,
62+
headWidth = 0.1,
63+
trailWidth = 0.22,
64+
} = {}) {
65+
return new THREE.ShaderMaterial({
66+
uniforms: {
67+
uTime: { value: 0 },
68+
uActivation: { value: activationTexture },
69+
uBeamCount: { value: beamCount },
70+
uHeadSpeed: { value: headSpeed },
71+
uHeadWidth: { value: headWidth },
72+
uTrailWidth: { value: trailWidth },
73+
uBaseOpacity: { value: baseOpacity },
74+
},
75+
vertexShader: VERTEX,
76+
fragmentShader: FRAGMENT,
77+
transparent: true,
78+
blending: THREE.AdditiveBlending,
79+
depthWrite: false,
80+
});
81+
}

desktop/cortex-control-center/src/brain-v2/index.jsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import { useEffect, useRef, useState } from "react";
22
import { createScene } from "./Scene.js";
33
import { createCore, tickCore, disposeCore } from "./Core.js";
44
import { createSatellites } from "./Satellites.js";
5+
import { createBeams } from "./Beams.js";
56
import { buildTiers } from "./Tiers.js";
67

78
export function BrainV2({ api = null, active = true }) {
89
const containerRef = useRef(null);
910
const sceneRef = useRef(null);
1011
const coreRef = useRef(null);
1112
const satellitesRef = useRef(null);
13+
const beamsRef = useRef(null);
1214
const [dimensions, setDimensions] = useState({
1315
width: Math.max(window.innerWidth - 260, 400),
1416
height: Math.max(window.innerHeight - 20, 300),
@@ -45,13 +47,36 @@ export function BrainV2({ api = null, active = true }) {
4547
const satellites = createSatellites({ scene: sceneHandle.scene });
4648
satellitesRef.current = satellites;
4749

50+
const beams = createBeams({ scene: sceneHandle.scene });
51+
beamsRef.current = beams;
52+
53+
if (typeof window !== "undefined") {
54+
window.__brainFire = (fromId, toId, color) => {
55+
const sats = satellitesRef.current;
56+
if (!sats) return;
57+
const a = sats.getSlotById(fromId);
58+
const b = sats.getSlotById(toId) || { x: 0, y: 0, z: 0 };
59+
if (!a) return;
60+
beamsRef.current?.fire({ from: a, to: b, color: color || "#22d3ee" });
61+
sats.pulseSlot(fromId);
62+
};
63+
}
64+
4865
const unregister = sceneHandle.registerTick((t, now) => {
4966
tickCore(core, t, now);
5067
satellites.tick(t, now);
68+
beams.tick(now);
5169
});
5270

5371
return () => {
5472
unregister();
73+
if (typeof window !== "undefined" && window.__brainFire) {
74+
delete window.__brainFire;
75+
}
76+
if (beamsRef.current) {
77+
beamsRef.current.dispose();
78+
beamsRef.current = null;
79+
}
5580
if (satellitesRef.current) {
5681
satellitesRef.current.dispose();
5782
satellitesRef.current = null;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import * as THREE from "three";
2+
3+
const _mid = new THREE.Vector3();
4+
const _control = new THREE.Vector3();
5+
6+
export function bezierArcPoints(from, to, segments = 16, lift = 0.18) {
7+
const points = [];
8+
_mid.set(
9+
(from.x + to.x) * 0.5,
10+
(from.y + to.y) * 0.5,
11+
(from.z + to.z) * 0.5,
12+
);
13+
const midLength = _mid.length();
14+
if (midLength < 1e-3) {
15+
_control.set(0, 1, 0).multiplyScalar(midLength * lift + 1);
16+
} else {
17+
_control.copy(_mid).normalize().multiplyScalar(midLength * (1 + lift));
18+
}
19+
for (let i = 0; i <= segments; i += 1) {
20+
const t = i / segments;
21+
const omt = 1 - t;
22+
const x = omt * omt * from.x + 2 * omt * t * _control.x + t * t * to.x;
23+
const y = omt * omt * from.y + 2 * omt * t * _control.y + t * t * to.y;
24+
const z = omt * omt * from.z + 2 * omt * t * _control.z + t * t * to.z;
25+
points.push(new THREE.Vector3(x, y, z));
26+
}
27+
return points;
28+
}

desktop/cortex-control-center/src/brain-visualizer.test.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,42 @@ describe("Brain v2 scene scaffolding", () => {
106106
expect(fnv1a).toContain("16777619");
107107
});
108108
});
109+
110+
describe("Brain v2 Beams + PulseShader", () => {
111+
const beams = readFileSync(new URL("./brain-v2/Beams.js", import.meta.url), "utf8");
112+
const pulseShader = readFileSync(new URL("./brain-v2/PulseShader.js", import.meta.url), "utf8");
113+
const bezier = readFileSync(new URL("./brain-v2/util/bezierArc.js", import.meta.url), "utf8");
114+
115+
it("Beams pool 64 slots, single merged LineSegments, GLSL pulse material", () => {
116+
expect(beams).toContain("POOL_SIZE = 64");
117+
expect(beams).toContain("SEGMENTS = 16");
118+
expect(beams).toContain("LineSegments");
119+
expect(beams).toContain("createPulseMaterial");
120+
expect(beams).toContain("createActivationTexture");
121+
expect(beams).toContain("export function createBeams");
122+
expect(beams).toContain("riseDecay");
123+
});
124+
125+
it("PulseShader uses RedFormat FloatType DataTexture with NearestFilter", () => {
126+
expect(pulseShader).toContain("export function createActivationTexture");
127+
expect(pulseShader).toContain("export function createPulseMaterial");
128+
expect(pulseShader).toContain("RedFormat");
129+
expect(pulseShader).toContain("FloatType");
130+
expect(pulseShader).toContain("NearestFilter");
131+
expect(pulseShader).toContain("AdditiveBlending");
132+
expect(pulseShader).toContain("uTime");
133+
expect(pulseShader).toContain("vProgress");
134+
expect(pulseShader).toContain("aColor");
135+
});
136+
137+
it("bezierArcPoints returns segments+1 control-lifted points", () => {
138+
expect(bezier).toContain("export function bezierArcPoints");
139+
expect(bezier).toContain("normalize().multiplyScalar");
140+
});
141+
142+
it("BrainV2 mounts Beams alongside Satellites and exposes window.__brainFire", () => {
143+
expect(v2Index).toContain("createBeams");
144+
expect(v2Index).toContain("beamsRef.current?.fire");
145+
expect(v2Index).toContain("window.__brainFire");
146+
});
147+
});

0 commit comments

Comments
 (0)