Three.js particle system for deliberateworks.dev. Particles flow left-to-right in a horizontal bus; left of center they scatter in chaotic noise, right of center they crystallize into a hexagonal lattice — noise collapsing into signal.
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.161.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.161.0/examples/jsm/"}}</script>
<script type="module">
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
const clock = new THREE.Clock();
const flowTick = initFlow();
function animate() { requestAnimationFrame(animate); flowTick(); }
animate();
</script>
function initFlow() {
const canvas = document.getElementById('flow-canvas');
const logoImg = document.getElementById('logo-img');
const frame = document.getElementById('logo-frame');
const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 3));
const composer = new EffectComposer(renderer);
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, 600),
0.75, // strength
0.45, // radius
0.13 // threshold — raised from 0.08 to prevent gold lattice washout with denser hex geometry
);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x181B2A);
const cam = new THREE.OrthographicCamera(-600, 600, 300, -300, 0.1, 1000);
cam.position.z = 100;
composer.addPass(new RenderPass(scene, cam));
composer.addPass(bloomPass);
let _mouseX = -99999, _mouseY = -99999;
const MOUSE_R = 90;
const MOUSE_R2 = MOUSE_R * MOUSE_R;
const MOUSE_F = 28;
document.addEventListener('mousemove', (e) => {
const r = renderer.domElement.getBoundingClientRect();
_mouseX = e.clientX - r.left - r.width * 0.5;
_mouseY = -(e.clientY - r.top - r.height * 0.5);
});
document.addEventListener('mouseleave', () => { _mouseX = _mouseY = -99999; });
// Seeded LCG — reproducible layout every load
let _s = 1734891023;
function sr() { _s = (_s * 1664525 + 1013904223) & 0xFFFFFFFF; return (_s >>> 0) / 0xFFFFFFFF; }
// Hex grid constants
const HEX_R = 52;
const HEX_H = HEX_R * Math.sqrt(3);
const HEX_HH = HEX_H * 0.5;
const HEX_BAND = HEX_R * 0.5;
const HEX_COL_STEP = HEX_R * 1.5;
// Align to 2×HEX_COL_STEP so column parity matches at wrap seam (no double-hex overlap).
// 3000 covers any realistic desktop canvas width + MARGIN buffer.
const WRAP_W_STATIC = Math.ceil(3000 / (HEX_COL_STEP * 2)) * HEX_COL_STEP * 2; // = 3120
const N_HEX_COLS = Math.round(WRAP_W_STATIC / HEX_COL_STEP); // = 40
const N_HEX_ROWS = 10;
// Build hex vertex list + edge list
const _vertMap = new Map();
const _targetXList = [], _targetYList = [];
const hexEdgesArr = [];
const _edgeSet = new Set();
function _addVert(x, y) {
const key = `${Math.round(x * 10)},${Math.round(y * 10)}`;
if (!_vertMap.has(key)) { _vertMap.set(key, _targetXList.length); _targetXList.push(x); _targetYList.push(y); }
return _vertMap.get(key);
}
function _tryEdge(a, b) {
const k = a < b ? `${a},${b}` : `${b},${a}`;
if (!_edgeSet.has(k)) { _edgeSet.add(k); hexEdgesArr.push(a, b); }
}
const GRID_CX = -WRAP_W_STATIC / 2;
for (let c = 0; c < N_HEX_COLS; c++) {
const cx = c * HEX_COL_STEP + GRID_CX;
const yBase = (c & 1) ? -HEX_HH : 0;
for (let r = 0; r < N_HEX_ROWS; r++) {
const topY = yBase - r * HEX_H + HEX_HH;
const midY = yBase - r * HEX_H;
const botY = topY - HEX_H;
const TL = _addVert(cx - HEX_BAND, topY);
const TR = _addVert(cx + HEX_BAND, topY);
const R = _addVert(cx + HEX_R, midY);
const BR = _addVert(cx + HEX_BAND, botY);
const BL = _addVert(cx - HEX_BAND, botY);
const L = _addVert(cx - HEX_R, midY);
_tryEdge(TL, TR); _tryEdge(TR, R); _tryEdge(R, BR);
_tryEdge(BR, BL); _tryEdge(BL, L); _tryEdge(L, TL);
}
}
// Center the grid vertically at Y=0 so it fills the canvas symmetrically
let _gMinY = Infinity, _gMaxY = -Infinity;
for (let i = 0; i < _targetYList.length; i++) {
if (_targetYList[i] < _gMinY) _gMinY = _targetYList[i];
if (_targetYList[i] > _gMaxY) _gMaxY = _targetYList[i];
}
const _gMidY = (_gMinY + _gMaxY) * 0.5;
for (let i = 0; i < _targetYList.length; i++) _targetYList[i] -= _gMidY;
const count = _targetXList.length;
const _targetX = new Float32Array(_targetXList);
const _targetY = new Float32Array(_targetYList);
const N_HEX_EDGES = hexEdgesArr.length / 2;
// Chaos edges — probabilistic proximity, seeded RNG
const chaosEdgesArr = [];
for (let i = 0; i < count; i++) {
let ct = 0;
for (let j = i + 1; j < count && ct < 2; j++) {
const dx = _targetX[i] - _targetX[j], dy = _targetY[i] - _targetY[j];
if (dx*dx + dy*dy < 36000 && sr() < 0.04) { chaosEdgesArr.push(i, j); ct++; }
}
}
const N_CHAOS_EDGES = chaosEdgesArr.length / 2;
// Per-particle arrays — fixed chaos offsets (v5 physics)
const _cdx = new Float32Array(count);
const _cdy = new Float32Array(count);
const _cdz = new Float32Array(count);
const _sx = new Float32Array(count);
const _flash = new Float32Array(count);
const _prevNorm = new Float32Array(count);
const _normXAttr = new Float32Array(count);
const _orderT = new Float32Array(count);
const _px = new Float32Array(count);
const _py = new Float32Array(count);
const _pinit = new Uint8Array(count);
const SMOOTH = 12;
const SNAP_THRESH = WRAP_W_STATIC * 0.4;
for (let i = 0; i < count; i++) {
const angle = sr() * Math.PI * 2;
const dist = 60 + sr() * 120;
_cdx[i] = Math.cos(angle) * dist;
_cdy[i] = Math.sin(angle) * dist;
_cdz[i] = (sr() - 0.5) * 160;
}
// Particle positions + depth-based colors (pre-allocated, baked at init)
const _pos = new Float32Array(count * 3);
const _col = new Float32Array(count * 3);
const _baseCol = new Float32Array(count * 3); // chaos zone colors — Z-depth gradient
const _parCol = new Float32Array(count * 3); // parallel zone depth colors
// Chaos: near (high Z) = saturated orange, far (low Z) = muted steel-blue accent
const chaosNear = new THREE.Color(0xF07828); // saturated amber-orange
const chaosFar = new THREE.Color(0x3A5A90); // muted steel-blue (accent, not dominant)
// Parallel: orange/gold dominant, blue recedes further
const parNear = new THREE.Color(0xEBC05D); // gold (brand)
const parFar = new THREE.Color(0xC9A030); // brass — warm tones throughout parallel zone
const _dc = new THREE.Color(); // scratch Color for lerp
for (let i = 0; i < count; i++) {
const z_norm = (_cdz[i] + 80) / 160; // 0 = far back, 1 = near front
_dc.lerpColors(chaosFar, chaosNear, z_norm);
_col[i*3] = _dc.r; _baseCol[i*3] = _dc.r;
_col[i*3 + 1] = _dc.g; _baseCol[i*3 + 1] = _dc.g;
_col[i*3 + 2] = _dc.b; _baseCol[i*3 + 2] = _dc.b;
_dc.lerpColors(parFar, parNear, z_norm);
_parCol[i*3] = _dc.r;
_parCol[i*3 + 1] = _dc.g;
_parCol[i*3 + 2] = _dc.b;
}
const dotGeo = new THREE.BufferGeometry();
dotGeo.setAttribute('position', new THREE.BufferAttribute(_pos, 3));
dotGeo.setAttribute('color', new THREE.BufferAttribute(_col, 3));
dotGeo.setAttribute('normX', new THREE.BufferAttribute(_normXAttr, 1));
const dotMat = new THREE.ShaderMaterial({
uniforms: { uSize: { value: 3.5 * renderer.getPixelRatio() } },
vertexShader: `
attribute vec3 color;
attribute float normX;
varying vec3 vColor;
varying float vNormX;
uniform float uSize;
void main() {
vColor = color;
vNormX = normX;
gl_PointSize = uSize * (0.88 + normX * 0.47);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
precision mediump float;
varying vec3 vColor;
varying float vNormX;
void main() {
vec2 uv = gl_PointCoord - 0.5;
float t = smoothstep(0.55, 0.95, vNormX);
// Sphere SDF
float sphereDist = length(uv);
// Point-top hexagon SDF (circumradius 0.5)
vec2 q = abs(uv);
float hexDist = max(q.y, q.x * 0.866025 + q.y * 0.5);
// Blend shape: sphere in chaos zone, hexagon in parallel zone
float d = mix(sphereDist, hexDist, t);
if (d > 0.5) discard;
// Normal: smooth sphere shading -> flat faceted in parallel zone
float z = sqrt(max(0.0, 0.25 - dot(uv, uv)));
vec3 Nsphere = normalize(vec3(uv * 2.0, z * 2.0));
vec3 N = mix(Nsphere, vec3(0.0, 0.0, 1.0), t);
vec3 L = normalize(vec3(0.4, 0.7, 1.0));
float diff = max(0.0, dot(N, L));
vec3 R = reflect(-L, N);
float shininess = mix(24.0, 48.0, t);
float spec = pow(max(0.0, dot(R, vec3(0.0, 0.0, 1.0))), shininess);
vec3 col = vColor * (0.15 + 0.85 * diff) + spec * mix(0.9, 1.4, t);
float edge = mix(0.35, 0.46, t);
float alpha = smoothstep(0.5, edge, d);
gl_FragColor = vec4(col, alpha * mix(0.55, 0.78, t));
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
scene.add(new THREE.Points(dotGeo, dotMat));
// Particle trails — 4-deep ring buffer ghost positions
const TRAIL_DEPTH = 4;
const _trailPos = new Float32Array(count * TRAIL_DEPTH * 3);
const _trailCol = new Float32Array(count * TRAIL_DEPTH * 3);
const _trailFade = new Float32Array(count * TRAIL_DEPTH);
for (let i = 0; i < count; i++) {
for (let d = 0; d < TRAIL_DEPTH; d++) {
_trailFade[i * TRAIL_DEPTH + d] = (TRAIL_DEPTH - 1 - d) / (TRAIL_DEPTH - 1) * 0.65;
}
}
const trailGeo = new THREE.BufferGeometry();
trailGeo.setAttribute('position', new THREE.BufferAttribute(_trailPos, 3));
trailGeo.setAttribute('color', new THREE.BufferAttribute(_trailCol, 3));
trailGeo.setAttribute('fade', new THREE.BufferAttribute(_trailFade, 1));
const trailMat = new THREE.ShaderMaterial({
uniforms: { uSize: { value: 2.2 * renderer.getPixelRatio() } },
vertexShader: `
attribute vec3 color;
attribute float fade;
varying vec3 vColor;
varying float vFade;
uniform float uSize;
void main() {
vColor = color; vFade = fade;
gl_PointSize = uSize;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
precision mediump float;
varying vec3 vColor;
varying float vFade;
void main() {
if (length(gl_PointCoord - 0.5) > 0.5) discard;
gl_FragColor = vec4(vColor, vFade * 0.38);
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
scene.add(new THREE.Points(trailGeo, trailMat));
// Spark pool — multi-type oriented streaks emitted on chaos->parallel snap
const SPARK_COUNT = 256;
const _spkPos = new Float32Array(SPARK_COUNT * 3);
const _spkVel = new Float32Array(SPARK_COUNT * 3);
const _spkCol = new Float32Array(SPARK_COUNT * 3);
const _spkTgt = new Float32Array(SPARK_COUNT * 3);
const _spkLife = new Float32Array(SPARK_COUNT);
const _spkMaxL = new Float32Array(SPARK_COUNT);
const _spkVelN = new Float32Array(SPARK_COUNT * 2);
const _spkSpd = new Float32Array(SPARK_COUNT);
let _spkNext = 0;
const spkGeo = new THREE.BufferGeometry();
spkGeo.setAttribute('position', new THREE.BufferAttribute(_spkPos, 3));
spkGeo.setAttribute('color', new THREE.BufferAttribute(_spkCol, 3));
spkGeo.setAttribute('velDir', new THREE.BufferAttribute(_spkVelN, 2));
spkGeo.setAttribute('speed', new THREE.BufferAttribute(_spkSpd, 1));
scene.add(new THREE.Points(spkGeo, new THREE.ShaderMaterial({
uniforms: { uSize: { value: 14.0 * renderer.getPixelRatio() } },
vertexShader: `
attribute vec3 color;
attribute vec2 velDir;
attribute float speed;
varying vec3 vColor;
varying vec2 vVelDir;
uniform float uSize;
void main() {
vColor = color;
vVelDir = velDir;
float depthScale = 1.0 + position.z * 0.003;
gl_PointSize = uSize * (0.55 + speed * 0.0032) * max(0.2, depthScale);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
precision mediump float;
varying vec3 vColor;
varying vec2 vVelDir;
void main() {
vec2 uv = gl_PointCoord - 0.5;
// Rotate so velocity direction is along +X
float c = vVelDir.x, s = vVelDir.y;
vec2 r = vec2(uv.x * c + uv.y * s, -uv.x * s + uv.y * c);
// Tapered streak: tip at +0.44 (front), tail at -0.32
const float TIP = 0.44, TAIL = 0.32;
if (r.x > TIP || r.x < -TAIL) discard;
float frac = (TIP - r.x) / (TIP + TAIL);
float halfW = max(0.018, 0.15 * frac);
if (abs(r.y) > halfW) discard;
float edge = 1.0 - abs(r.y) / halfW;
float tip = smoothstep(TIP, TIP * 0.55, r.x);
gl_FragColor = vec4(vColor, edge * tip * 0.97);
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
})));
// Hex edges — gold lattice (crystallized phase)
const _hexEdgePos = new Float32Array(N_HEX_EDGES * 6);
const _hexEdgeOrd = new Float32Array(N_HEX_EDGES * 2);
const hexEdgeGeo = new THREE.BufferGeometry();
hexEdgeGeo.setAttribute('position', new THREE.BufferAttribute(_hexEdgePos, 3));
hexEdgeGeo.setAttribute('aOrder', new THREE.BufferAttribute(_hexEdgeOrd, 1));
scene.add(new THREE.LineSegments(hexEdgeGeo, new THREE.ShaderMaterial({
vertexShader: `attribute float aOrder; varying float vOrder; void main() { vOrder = aOrder; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `precision mediump float; varying float vOrder; void main() { float a = vOrder * 0.55; if (a < 0.01) discard; gl_FragColor = vec4(0.922, 0.753, 0.365, a); }`,
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
})));
// Hex edges — chaos spider-web phase (depth-colored amber/blue while chaotic)
const _hexChaosPosE = new Float32Array(N_HEX_EDGES * 6);
const _hexChaosOrd = new Float32Array(N_HEX_EDGES * 2);
const _hexChaosCol = new Float32Array(N_HEX_EDGES * 6);
const hexChaosEdgeGeo = new THREE.BufferGeometry();
hexChaosEdgeGeo.setAttribute('position', new THREE.BufferAttribute(_hexChaosPosE, 3));
hexChaosEdgeGeo.setAttribute('aOrder', new THREE.BufferAttribute(_hexChaosOrd, 1));
hexChaosEdgeGeo.setAttribute('aColor', new THREE.BufferAttribute(_hexChaosCol, 3));
scene.add(new THREE.LineSegments(hexChaosEdgeGeo, new THREE.ShaderMaterial({
vertexShader: `attribute float aOrder; attribute vec3 aColor; varying float vOrder; varying vec3 vColor; void main() { vOrder = aOrder; vColor = aColor; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `precision mediump float; varying float vOrder; varying vec3 vColor; void main() { if (vOrder < 0.005) discard; gl_FragColor = vec4(vColor, vOrder); }`,
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
})));
// Chaos edges — random spider-web, depth-colored, fade out as particles crystallize
const _chaosEdgePos = new Float32Array(N_CHAOS_EDGES * 6);
const _chaosEdgeOrd = new Float32Array(N_CHAOS_EDGES * 2);
const _chaosEdgeCol = new Float32Array(N_CHAOS_EDGES * 6);
const chaosEdgeGeo = new THREE.BufferGeometry();
chaosEdgeGeo.setAttribute('position', new THREE.BufferAttribute(_chaosEdgePos, 3));
chaosEdgeGeo.setAttribute('aOrder', new THREE.BufferAttribute(_chaosEdgeOrd, 1));
chaosEdgeGeo.setAttribute('aColor', new THREE.BufferAttribute(_chaosEdgeCol, 3));
scene.add(new THREE.LineSegments(chaosEdgeGeo, new THREE.ShaderMaterial({
vertexShader: `attribute float aOrder; attribute vec3 aColor; varying float vOrder; varying vec3 vColor; void main() { vOrder = aOrder; vColor = aColor; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `precision mediump float; varying float vOrder; varying vec3 vColor; void main() { if (vOrder < 0.005) discard; gl_FragColor = vec4(vColor, vOrder * 0.22); }`,
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
})));
const FLOW_SPEED = 28;
let TRANS_START = -240;
let TRANS_END = 240;
const MARGIN = 380;
let _noiseMult = 1.0;
let _lastT = 0;
function resize() {
const isMobile = window.innerWidth < 768;
const w = isMobile ? window.innerWidth : frame.offsetWidth + 1000;
const frameBot = frame.offsetTop + frame.offsetHeight;
const h = isMobile ? frame.offsetHeight : Math.max(frameBot + 200, 600);
renderer.setSize(w, h);
cam.left = -w/2; cam.right = w/2; cam.top = h/2; cam.bottom = -h/2;
cam.updateProjectionMatrix();
cam.position.y = (frame.offsetTop + frame.offsetHeight / 2) - h / 2;
composer.setSize(w, h);
bloomPass.resolution.set(w, h);
const halfW = w / 2;
TRANS_START = isMobile ? -halfW * 0.45 : -240;
TRANS_END = isMobile ? halfW * 0.45 : 240;
_noiseMult = isMobile ? 0.30 : 1.0;
}
function tick() {
const t = clock.getElapsedTime();
const dt = Math.min(t - _lastT, 0.1);
_lastT = t;
const scroll = t * FLOW_SPEED;
const SHIFT = WRAP_W_STATIC / 2;
const lerpK = Math.min(SMOOTH * dt, 1);
for (let i = 0; i < count; i++) {
const rawSx = (_targetX[i] + SHIFT + scroll) % WRAP_W_STATIC;
const sx = ((rawSx % WRAP_W_STATIC) + WRAP_W_STATIC) % WRAP_W_STATIC - SHIFT;
_sx[i] = sx;
let normX = 0;
if (sx > TRANS_END) {
normX = 1;
} else if (sx > TRANS_START) {
const tRaw = (sx - TRANS_START) / (TRANS_END - TRANS_START);
normX = tRaw * tRaw * (3.0 - 2.0 * tRaw); // smoothstep cubic
}
const nf = 1.0 - normX;
const tx = sx + _cdx[i] * nf * _noiseMult;
const ty = _targetY[i] + _cdy[i] * nf * _noiseMult;
if (!_pinit[i]) {
_px[i] = tx; _py[i] = ty; _pinit[i] = 1;
} else if (Math.abs(tx - _px[i]) > SNAP_THRESH) {
_px[i] = tx; _py[i] = ty;
const ts = i * TRAIL_DEPTH * 3;
for (let d = 0; d < TRAIL_DEPTH * 3; d++) _trailPos[ts + d] = -99999;
} else {
_px[i] += (tx - _px[i]) * lerpK;
_py[i] += (ty - _py[i]) * lerpK;
}
// Mouse repulsion — springs back when cursor leaves
let rx = _px[i], ry = _py[i];
const mdx = rx - _mouseX, mdy = ry - _mouseY;
const md2 = mdx * mdx + mdy * mdy;
if (md2 < MOUSE_R2 && md2 > 0.01) {
const md = Math.sqrt(md2), push = (MOUSE_R - md) / MOUSE_R * MOUSE_F;
rx += (mdx / md) * push;
ry += (mdy / md) * push;
}
_pos[i*3] = rx;
_pos[i*3 + 1] = ry;
_pos[i*3 + 2] = _cdz[i] * nf * _noiseMult;
// Trail ring buffer — shift and insert newest position at slot 0
for (let d = TRAIL_DEPTH - 1; d > 0; d--) {
const dst = (i * TRAIL_DEPTH + d) * 3, src = (i * TRAIL_DEPTH + d - 1) * 3;
_trailPos[dst] = _trailPos[src]; _trailCol[dst] = _trailCol[src];
_trailPos[dst+1] = _trailPos[src+1]; _trailCol[dst+1] = _trailCol[src+1];
_trailPos[dst+2] = _trailPos[src+2]; _trailCol[dst+2] = _trailCol[src+2];
}
const t0 = i * TRAIL_DEPTH * 3;
_trailPos[t0] = _pos[i*3]; _trailCol[t0] = _col[i*3];
_trailPos[t0+1] = _pos[i*3+1]; _trailCol[t0+1] = _col[i*3+1];
_trailPos[t0+2] = _pos[i*3+2]; _trailCol[t0+2] = _col[i*3+2];
// Spark burst on chaos -> parallel crystallization
if (_prevNorm[i] < 1.0 && normX >= 1.0) {
_flash[i] = 1.0;
if (t > 0.5) {
const px = _pos[i*3], py = _pos[i*3+1];
const pr = _col[i*3], pg = _col[i*3+1], pb = _col[i*3+2];
// 2 lance sparks — forward along flow (+X)
for (let s = 0; s < 2; s++) {
_spkNext = (_spkNext + 1) % SPARK_COUNT;
const sk = _spkNext;
const a = (s===0?0.10:-0.10) + (Math.random()-0.5)*0.18;
const spd = 480 + Math.random()*220;
const vx = Math.cos(a)*spd, vy = Math.sin(a)*spd;
_spkPos[sk*3]=px; _spkPos[sk*3+1]=py; _spkPos[sk*3+2]=0;
_spkVel[sk*3]=vx; _spkVel[sk*3+1]=vy; _spkVel[sk*3+2]=0;
_spkLife[sk]=0.50+Math.random()*0.30; _spkMaxL[sk]=_spkLife[sk];
_spkSpd[sk]=spd; _spkVelN[sk*2]=vx/spd; _spkVelN[sk*2+1]=vy/spd;
_spkTgt[sk*3]=0.95; _spkTgt[sk*3+1]=0.78; _spkTgt[sk*3+2]=0.22;
}
// 4 fan sparks — perpendicular ±Y
for (let s = 0; s < 4; s++) {
_spkNext = (_spkNext + 1) % SPARK_COUNT;
const sk = _spkNext;
const base = s < 2 ? Math.PI*0.5 : -Math.PI*0.5;
const a = base + (Math.random()-0.5)*Math.PI*0.55;
const spd = 200+Math.random()*170;
const vx = Math.cos(a)*spd, vy = Math.sin(a)*spd;
_spkPos[sk*3]=px; _spkPos[sk*3+1]=py; _spkPos[sk*3+2]=0;
_spkVel[sk*3]=vx; _spkVel[sk*3+1]=vy; _spkVel[sk*3+2]=0;
_spkLife[sk]=0.24+Math.random()*0.20; _spkMaxL[sk]=_spkLife[sk];
_spkSpd[sk]=spd; _spkVelN[sk*2]=vx/spd; _spkVelN[sk*2+1]=vy/spd;
_spkTgt[sk*3]=0.96; _spkTgt[sk*3+1]=0.42; _spkTgt[sk*3+2]=0.08;
}
// 4 debris — random angle, Z scatter, inherits node depth color
for (let s = 0; s < 4; s++) {
_spkNext = (_spkNext + 1) % SPARK_COUNT;
const sk = _spkNext;
const a = Math.random()*Math.PI*2;
const spd = 90+Math.random()*130;
const vx = Math.cos(a)*spd, vy = Math.sin(a)*spd;
const vz = (Math.random()-0.5)*220;
_spkPos[sk*3]=px; _spkPos[sk*3+1]=py; _spkPos[sk*3+2]=0;
_spkVel[sk*3]=vx; _spkVel[sk*3+1]=vy; _spkVel[sk*3+2]=vz;
_spkLife[sk]=0.18+Math.random()*0.14; _spkMaxL[sk]=_spkLife[sk];
_spkSpd[sk]=spd; _spkVelN[sk*2]=vx/spd; _spkVelN[sk*2+1]=vy/spd;
_spkTgt[sk*3]=pr; _spkTgt[sk*3+1]=pg; _spkTgt[sk*3+2]=pb;
}
}
}
_prevNorm[i] = normX;
_normXAttr[i] = normX;
_orderT[i] = normX;
_flash[i] = Math.max(0, _flash[i] - dt * 2.0);
const f = _flash[i];
const br = _baseCol[i*3] + (_parCol[i*3] - _baseCol[i*3]) * normX;
const bg = _baseCol[i*3 + 1] + (_parCol[i*3 + 1] - _baseCol[i*3 + 1]) * normX;
const bb = _baseCol[i*3 + 2] + (_parCol[i*3 + 2] - _baseCol[i*3 + 2]) * normX;
_col[i*3] = br + (1.0 - br) * f;
_col[i*3 + 1] = bg + (1.0 - bg) * f;
_col[i*3 + 2] = bb + (1.0 - bb) * f;
}
// Spark pool physics — drag + color fade
for (let k = 0; k < SPARK_COUNT; k++) {
if (_spkLife[k] <= 0) { _spkPos[k*3] = -99999; continue; }
_spkLife[k] -= dt;
_spkPos[k*3] += _spkVel[k*3] * dt;
_spkPos[k*3 + 1] += _spkVel[k*3 + 1] * dt;
_spkPos[k*3 + 2] += _spkVel[k*3 + 2] * dt;
_spkVel[k*3] *= 0.90;
_spkVel[k*3 + 1] *= 0.90;
const lf = Math.max(0, _spkLife[k]) / _spkMaxL[k];
_spkCol[k*3] = _spkTgt[k*3] * lf * 1.6;
_spkCol[k*3 + 1] = _spkTgt[k*3 + 1] * lf * 1.6;
_spkCol[k*3 + 2] = _spkTgt[k*3 + 2] * lf * 1.6;
const vx = _spkVel[k*3], vy = _spkVel[k*3+1];
const spd = Math.sqrt(vx*vx + vy*vy);
_spkSpd[k] = spd;
if (spd > 0.01) { _spkVelN[k*2]=vx/spd; _spkVelN[k*2+1]=vy/spd; }
}
spkGeo.attributes.position.needsUpdate = true;
spkGeo.attributes.color.needsUpdate = true;
spkGeo.attributes.velDir.needsUpdate = true;
spkGeo.attributes.speed.needsUpdate = true;
trailGeo.attributes.position.needsUpdate = true;
trailGeo.attributes.color.needsUpdate = true;
dotGeo.attributes.position.needsUpdate = true;
dotGeo.attributes.color.needsUpdate = true;
dotGeo.attributes.normX.needsUpdate = true;
// Hex edge geometry updates
for (let e = 0; e < N_HEX_EDGES; e++) {
const a = hexEdgesArr[e * 2], b = hexEdgesArr[e * 2 + 1];
if (Math.abs(_sx[a] - _sx[b]) > SHIFT) {
_hexEdgePos[e*6] = _hexEdgePos[e*6+3] = -99999;
_hexChaosPosE[e*6] = _hexChaosPosE[e*6+3] = -99999;
_hexEdgeOrd[e*2] = _hexEdgeOrd[e*2+1] = 0;
_hexChaosOrd[e*2] = _hexChaosOrd[e*2+1] = 0;
continue;
}
_hexEdgePos[e*6] = _pos[a*3]; _hexEdgePos[e*6+1] = _pos[a*3+1]; _hexEdgePos[e*6+2] = _pos[a*3+2];
_hexEdgePos[e*6+3] = _pos[b*3]; _hexEdgePos[e*6+4] = _pos[b*3+1]; _hexEdgePos[e*6+5] = _pos[b*3+2];
const minT = Math.min(_orderT[a], _orderT[b]);
_hexEdgeOrd[e*2] = _hexEdgeOrd[e*2+1] = minT * minT;
_hexChaosPosE[e*6] = _hexEdgePos[e*6]; _hexChaosPosE[e*6+1] = _hexEdgePos[e*6+1]; _hexChaosPosE[e*6+2] = _hexEdgePos[e*6+2];
_hexChaosPosE[e*6+3] = _hexEdgePos[e*6+3]; _hexChaosPosE[e*6+4] = _hexEdgePos[e*6+4]; _hexChaosPosE[e*6+5] = _hexEdgePos[e*6+5];
const maxCf = Math.max(1 - _orderT[a], 1 - _orderT[b]);
const dx2 = _pos[a*3] - _pos[b*3], dy2 = _pos[a*3+1] - _pos[b*3+1];
_hexChaosOrd[e*2] = _hexChaosOrd[e*2+1] = (dx2*dx2 + dy2*dy2 < 127000) ? maxCf * 0.28 : 0;
_hexChaosCol[e*6] = _baseCol[a*3]; _hexChaosCol[e*6+1] = _baseCol[a*3+1]; _hexChaosCol[e*6+2] = _baseCol[a*3+2];
_hexChaosCol[e*6+3] = _baseCol[b*3]; _hexChaosCol[e*6+4] = _baseCol[b*3+1]; _hexChaosCol[e*6+5] = _baseCol[b*3+2];
}
hexEdgeGeo.attributes.position.needsUpdate = true;
hexEdgeGeo.attributes.aOrder.needsUpdate = true;
hexChaosEdgeGeo.attributes.position.needsUpdate = true;
hexChaosEdgeGeo.attributes.aOrder.needsUpdate = true;
hexChaosEdgeGeo.attributes.aColor.needsUpdate = true;
for (let e = 0; e < N_CHAOS_EDGES; e++) {
const a = chaosEdgesArr[e * 2], b = chaosEdgesArr[e * 2 + 1];
if (Math.abs(_sx[a] - _sx[b]) > SHIFT) {
_chaosEdgePos[e*6] = _chaosEdgePos[e*6+3] = -99999;
_chaosEdgeOrd[e*2] = _chaosEdgeOrd[e*2+1] = 0;
continue;
}
const dx = _pos[a*3] - _pos[b*3], dy = _pos[a*3+1] - _pos[b*3+1];
if (dx*dx + dy*dy > 46000) {
_chaosEdgePos[e*6] = _chaosEdgePos[e*6+3] = -99999;
_chaosEdgeOrd[e*2] = _chaosEdgeOrd[e*2+1] = 0;
continue;
}
_chaosEdgePos[e*6] = _pos[a*3]; _chaosEdgePos[e*6+1] = _pos[a*3+1]; _chaosEdgePos[e*6+2] = _pos[a*3+2];
_chaosEdgePos[e*6+3] = _pos[b*3]; _chaosEdgePos[e*6+4] = _pos[b*3+1]; _chaosEdgePos[e*6+5] = _pos[b*3+2];
const maxUn = Math.max(1 - _orderT[a], 1 - _orderT[b]);
_chaosEdgeOrd[e*2] = _chaosEdgeOrd[e*2+1] = maxUn;
_chaosEdgeCol[e*6] = _baseCol[a*3]; _chaosEdgeCol[e*6+1] = _baseCol[a*3+1]; _chaosEdgeCol[e*6+2] = _baseCol[a*3+2];
_chaosEdgeCol[e*6+3] = _baseCol[b*3]; _chaosEdgeCol[e*6+4] = _baseCol[b*3+1]; _chaosEdgeCol[e*6+5] = _baseCol[b*3+2];
}
chaosEdgeGeo.attributes.position.needsUpdate = true;
chaosEdgeGeo.attributes.aOrder.needsUpdate = true;
chaosEdgeGeo.attributes.aColor.needsUpdate = true;
composer.render();
}
window.addEventListener('resize', resize);
resize();
return tick;
}