Hex Grid
40 columns × 10 rows of point-top hexagons (R=52px). Seeded LCG RNG for reproducible layout. Wrapped at 3120px so the scroll seam is seamless.
Chaos ↔ Parallel Transition
normX = smoothstep over ±240px window. Left side: noise offsets applied. Right side: offsets zeroed, particles snap to hex targets. Smooth cubic interpolation.
Particle Shape
Fragment shader blends a sphere SDF (chaos) to a hexagon SDF (parallel) via normX. Phong lighting fades from smooth sphere normals to flat faceted shading.
Spark Bursts
Fired when normX crosses 1.0 (crystallization). 3 types: 2 forward lance streaks, 4 perpendicular fan sparks, 4 random debris. Tapered streak shader with velocity-aligned rotation.
Color System
Chaos: Z-depth gradient orange (#F07828) → steel-blue (#3A5A90). Parallel: gold (#EBC05D) → brass (#C9A030). Colors pre-baked at init, blended per-tick via normX.
Edge Layers
Three separate LineSegments: gold lattice (parallel phase), chaos spider-web on hex vertices (depth-colored), random chaos edges (probabilistic proximity, seed RNG). All fade with normX.
1 — Module imports (importmap)
<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>
2 — initFlow() — full function source
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;
}