jacquardSnapshot

← snapshot

15072 bytes
"use client";

import { useEffect, useRef, useState } from "react";
import { readThread, SUBJECTS, TEMPO } from "./orb-figure";
import type { OrbState, OrbSubject } from "./jackie-orb-svg";

/**
 * Jackie's face in three dimensions: a raymarched kaleidoscopic IFS.
 *
 * The technique is the same idea as the SVG version, done properly. A
 * kaleidoscopic iterated function system folds space against itself a few
 * times per step — an n-fold polar fold for the mirrors, an `abs()` fold for
 * the reflection, then a scale-and-offset. The distance field that falls out
 * is genuinely self-similar at every depth, and because it is raymarched it
 * has real depth, real normals and real shading rather than a flat rosette.
 *
 * No library: one fullscreen triangle and a fragment shader. The whole thing
 * is a few hundred lines of GLSL, which is a smaller dependency footprint
 * than any 3D engine and keeps the graph the boundary has to reason about
 * exactly as small as it was.
 *
 * Subject still names the figure — fold count, primitive and fold offsets all
 * change — and colour still names whose turn it is.
 */

const VERT = `#version 300 es
void main() {
  // one oversized triangle covering the viewport, no buffers needed
  vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
  gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}`;

const FRAG = `#version 300 es
precision highp float;
out vec4 outColor;

uniform vec2  uRes;
uniform float uTime;
uniform float uLevel;     // 0..1 speech level
uniform vec3  uThread;    // provenance colour
uniform float uFolds;     // n-fold symmetry
uniform int   uShape;     // character of the deformation
uniform float uScale;     // fractal frequency
uniform vec3  uOffset;    // per-octave drift
uniform float uSpin;      // per-octave rotation
uniform vec2  uLean;      // -1..1, where the pointer is
uniform float uAttract;   // 0..1, how much interaction is happening

const int OCT   = 5;
const int STEPS = 64;

mat2 rot(float a){ float c=cos(a), s=sin(a); return mat2(c,-s,s,c); }

/**
 * Self-similar displacement: the same wave evaluated at doubling frequency
 * and halving amplitude. This is the fractal — continuous rather than
 * carved, which is what keeps the figure soft instead of jagged.
 */
float fbm(vec3 p){
  float amp = 0.5;
  float sum = 0.0;
  for (int i = 0; i < OCT; i++){
    sum += amp * sin(p.x + uOffset.x) * sin(p.y + uOffset.y) * sin(p.z + uOffset.z);
    p = p * 2.02;
    p.xy = rot(uSpin) * p.xy;
    p.yz = rot(uSpin * 0.7) * p.yz;
    amp *= 0.5;
  }
  return sum;
}

/** n-fold mirrored polar fold — the kaleidoscope's mirrors, applied gently. */
vec3 foldN(vec3 p, float n){
  float a = atan(p.z, p.x);
  float r = length(p.xz);
  float seg = 6.28318530718 / n;
  a = mod(a + seg * 0.5, seg) - seg * 0.5;
  a = abs(a);
  return vec3(cos(a) * r, p.y, sin(a) * r);
}

/** How the deformation reads, per subject. */
float character(vec3 q){
  if (uShape == 1) {            // gate: banded, held in place
    return 0.55 * fbm(q) + 0.45 * sin(q.y * 3.0 + uTime * 0.4);
  }
  if (uShape == 2) {            // decision: an ornate equatorial swell
    return fbm(q) * (0.7 + 0.5 * cos(q.y * 2.0));
  }
  if (uShape == 3) {            // snapshot: sharper crests
    float f = fbm(q);
    return sign(f) * pow(abs(f), 0.65);
  }
  if (uShape == 4) {            // interview: drawn out along the axis
    return fbm(vec3(q.x, q.y * 0.45, q.z));
  }
  return fbm(q);                // lobby / file / cloth: the plain figure
}

float core(vec3 p){
  vec3 q = foldN(p, uFolds) * uScale;
  q += vec3(0.0, uTime * 0.22, 0.0);
  float disp = character(q);
  float amp = 0.17 + uLevel * 0.22 + uAttract * 0.07;
  // Displaced spheres are not exact distance fields; the 0.55 keeps the
  // march conservative enough not to tunnel through the surface.
  return (length(p) - (0.92 + amp * disp)) * 0.55;
}

/**
 * A sigil ring: a thin torus whose section is notched n times around its
 * circumference, so it reads as inscribed rather than plain. Several of
 * these, tilted differently and counter-rotating, are the mandala.
 */
float sigilRing(vec3 p, float radius, float thick, float n, float phase){
  float a = atan(p.z, p.x) + phase;
  // Two harmonics: the coarse one cuts the ring into glyph blocks, the fine
  // one inscribes inside them.
  float coarse = 0.5 + 0.5 * cos(a * n);
  float fine   = 0.5 + 0.5 * cos(a * n * 3.0);
  float notch  = coarse * (0.72 + 0.28 * fine);
  vec2 q = vec2(length(p.xz) - (radius + notch * 0.055), p.y);
  return length(q) - thick * (0.18 + 1.45 * notch);
}

/** Returns (distance, material) — 0 is the core, 1 the rings. */
vec2 mapAll(vec3 p){
  float dc = core(p);
  float dr = 1e9;
  for (int i = 0; i < 3; i++){
    float fi = float(i);
    vec3 q = p;
    float dir = mod(fi, 2.0) < 0.5 ? 1.0 : -1.0;
    float sp  = uTime * (0.22 + fi * 0.16) * dir + fi * 1.7;
    q.yz = rot(0.42 + fi * 0.62) * q.yz;
    q.xz = rot(sp) * q.xz;
    dr = min(dr, sigilRing(q, 1.32 + fi * 0.29, 0.030, uFolds * (1.0 + fi), sp * 2.0));
  }
  return dc < dr ? vec2(dc, 0.0) : vec2(dr, 1.0);
}

float map(vec3 p){ return mapAll(p).x; }

vec3 normalAt(vec3 p){
  vec2 e = vec2(0.0025, 0.0);
  return normalize(vec3(
    map(p+e.xyy) - map(p-e.xyy),
    map(p+e.yxy) - map(p-e.yxy),
    map(p+e.yyx) - map(p-e.yyx)));
}

void main(){
  vec2 uv = (gl_FragCoord.xy * 2.0 - uRes) / min(uRes.x, uRes.y);

  float t = uTime * 0.10;
  float dist = 4.15 - uLevel * 0.20 - uAttract * 0.38;
  vec3 ro = vec3(sin(t) * dist, 0.30 + sin(t * 0.6) * 0.16, cos(t) * dist);

  // Lean: shift the eye against the pointer, keep looking at the centre, so
  // the figure turns to face wherever the cursor is.
  vec3 fr = normalize(cross(vec3(0.0,1.0,0.0), normalize(-ro)));
  vec3 fu = normalize(cross(normalize(-ro), fr));
  ro += fr * (-uLean.x * 0.75) + fu * (uLean.y * 0.55);

  vec3 fwd = normalize(-ro);
  vec3 rgt = normalize(cross(vec3(0.0,1.0,0.0), fwd));
  vec3 up  = cross(fwd, rgt);
  vec3 rd  = normalize(uv.x * rgt + uv.y * up + 1.7 * fwd);

  // The CSS token is sRGB; shade in linear or the second gamma encode below
  // washes every colour toward white.
  vec3 thread = pow(uThread, vec3(2.2));
  // A softer, lighter companion — the figure reads as lit from within
  // rather than painted one flat hue.
  vec3 tint = mix(thread, vec3(1.0), 0.10);

  float d = 0.9;
  float hit = -1.0;
  float mat = 0.0;
  float halo = 0.0;
  float ringGlow = 0.0;
  for (int i = 0; i < STEPS; i++){
    vec3 p = ro + rd * d;
    vec2 m = mapAll(p);
    float h = m.x;
    // Soft accumulation near the isosurface: this is what gives the bloom
    // its gradient instead of a hard silhouette.
    halo += exp(-abs(h) * 9.0) * 0.030;
    // The rings burn brighter than the core and trail light behind them.
    if (m.y > 0.5) ringGlow += exp(-abs(h) * 26.0) * 0.075;
    if (h < 0.0015){ hit = d; mat = m.y; break; }
    d += max(h * 0.85, 0.006);
    if (d > 7.5) break;
  }

  vec3 col = vec3(0.0);
  float alpha = 0.0;

  if (hit > 0.0){
    vec3 p = ro + rd * hit;
    vec3 n = normalAt(p);
    vec3 l = normalize(vec3(0.35, 0.8, 0.45));
    float diff = clamp(dot(n, l) * 0.5 + 0.5, 0.0, 1.0);   // wrapped, soft
    float fres = pow(1.0 - clamp(dot(n, -rd), 0.0, 1.0), 2.2);

    if (mat > 0.5){
      // Rings: inscribed light, close to white at the crest.
      col  = mix(thread, vec3(1.0), 0.10) * (0.85 + 1.15 * diff);
      col += mix(thread, vec3(1.0), 0.35) * fres * 0.45;
    } else {
      col  = thread * (0.30 + 1.25 * diff * diff);
      col  = mix(col, tint, fres * (0.22 + uAttract * 0.16));
      col += tint * fres * (0.16 + uLevel * 0.22 + uAttract * 0.20);
    }
    alpha = 1.0;
  }

  // The bloom lives outside the surface too, so the edge never cuts.
  col   += tint * halo * (0.34 + uLevel * 0.34 + uAttract * 0.26);
  col   += mix(thread, vec3(1.0), 0.12) * ringGlow * (1.15 + uAttract * 0.7);
  alpha  = max(alpha, clamp(halo * 1.4 + ringGlow * 1.6, 0.0, 1.0));

  // Fade to nothing at the rim of the canvas so it sits on the page.
  float r = length(uv);
  alpha *= 1.0 - smoothstep(0.98, 1.30, r);

  col = col / (1.0 + col);                       // Reinhard
  col = pow(clamp(col, 0.0, 1.0), vec3(0.4545)); // gamma
  outColor = vec4(col * alpha, alpha);
}`;

export function JackieOrbGl({
  state,
  subject,
  level,
  size,
  onFail,
}: {
  state: OrbState;
  subject: OrbSubject;
  level: number;
  size: number;
  onFail: () => void;
}) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const stateRef = useRef({ state, subject, level });
  stateRef.current = { state, subject, level };

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const gl = canvas.getContext("webgl2", {
      alpha: true,
      antialias: false,
      premultipliedAlpha: true,
    });
    if (!gl) {
      onFail();
      return;
    }

    const compile = (type: number, src: string) => {
      const sh = gl.createShader(type);
      if (!sh) return null;
      gl.shaderSource(sh, src);
      gl.compileShader(sh);
      if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
        console.warn("orb shader:", gl.getShaderInfoLog(sh));
        return null;
      }
      return sh;
    };

    const vs = compile(gl.VERTEX_SHADER, VERT);
    const fs = compile(gl.FRAGMENT_SHADER, FRAG);
    const prog = vs && fs ? gl.createProgram() : null;
    if (!vs || !fs || !prog) {
      onFail();
      return;
    }
    gl.attachShader(prog, vs);
    gl.attachShader(prog, fs);
    gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
      console.warn("orb link:", gl.getProgramInfoLog(prog));
      onFail();
      return;
    }
    gl.useProgram(prog);

    const u = (n: string) => gl.getUniformLocation(prog, n);
    const uRes = u("uRes");
    const uTime = u("uTime");
    const uLevel = u("uLevel");
    const uThread = u("uThread");
    const uFolds = u("uFolds");
    const uShape = u("uShape");
    const uScale = u("uScale");
    const uOffset = u("uOffset");
    const uSpin = u("uSpin");
    const uLean = u("uLean");
    const uAttract = u("uAttract");

    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const px = Math.round(size * dpr);
    canvas.width = px;
    canvas.height = px;
    gl.viewport(0, 0, px, px);
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);

    const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;

    let raf = 0;
    let clock = 0;
    let last = performance.now();
    let smoothLevel = 0;

    /* ---- attention ------------------------------------------------------
     * The figure watches the pointer and leans toward whatever is being
     * hovered. Targets are spring-damped rather than followed directly, so
     * it moves like something with mass instead of snapping.
     */
    let wantLeanX = 0;
    let wantLeanY = 0;
    let wantAttract = 0;
    let leanX = 0;
    let leanY = 0;
    let leanVX = 0;
    let leanVY = 0;
    let attract = 0;

    const INTERACTIVE = "a, button, .jac-offer, .jac-seen-chip, textarea, select, input";

    const aim = (x: number, y: number, pull: number) => {
      const r = canvas.getBoundingClientRect();
      const cx = r.left + r.width / 2;
      const cy = r.top + r.height / 2;
      // Normalised by a generous radius so distant targets still register a
      // direction, just a gentler one.
      const reach = Math.max(window.innerWidth, window.innerHeight) * 0.42;
      wantLeanX = Math.max(-1, Math.min(1, (x - cx) / reach));
      wantLeanY = Math.max(-1, Math.min(1, (y - cy) / reach));
      wantAttract = pull;
    };

    const onPointerMove = (e: PointerEvent) => {
      const el = (e.target as Element | null)?.closest?.(INTERACTIVE);
      if (el) {
        const r = el.getBoundingClientRect();
        aim(r.left + r.width / 2, r.top + r.height / 2, 1);
      } else {
        aim(e.clientX, e.clientY, 0.34);
      }
    };
    const onPointerLeave = () => {
      wantLeanX = 0;
      wantLeanY = 0;
      wantAttract = 0;
    };

    window.addEventListener("pointermove", onPointerMove, { passive: true });
    document.addEventListener("pointerleave", onPointerLeave);
    window.addEventListener("blur", onPointerLeave);

    const frame = (now: number) => {
      const dt = Math.min(0.05, (now - last) / 1000);
      last = now;
      const cur = stateRef.current;
      const spec = SUBJECTS[cur.subject];

      // A still frame still reads as a figure; it just stops turning.
      if (!reduced) clock += dt * TEMPO[cur.state];
      smoothLevel += (cur.level - smoothLevel) * Math.min(1, dt * 8);

      // Critically-damped-ish spring toward the current attention target.
      const K = 62;
      const D = 13;
      if (reduced) {
        leanX = wantLeanX;
        leanY = wantLeanY;
        attract = wantAttract;
      } else {
        leanVX += ((wantLeanX - leanX) * K - leanVX * D) * dt;
        leanVY += ((wantLeanY - leanY) * K - leanVY * D) * dt;
        leanX += leanVX * dt;
        leanY += leanVY * dt;
        attract += (wantAttract - attract) * Math.min(1, dt * 5);
      }

      // The whole element drifts a few pixels the same way — the figure
      // gravitates bodily toward what you are reaching for, not just
      // optically.
      const host = canvas.parentElement;
      if (host) {
        host.style.setProperty("--orb-drift-x", `${(leanX * 9).toFixed(2)}px`);
        host.style.setProperty("--orb-drift-y", `${(leanY * 7).toFixed(2)}px`);
      }

      gl.uniform2f(uRes, px, px);
      gl.uniform1f(uTime, clock);
      gl.uniform1f(uLevel, smoothLevel);
      const [r, g, b] = readThread(canvas, cur.state === "listening");
      gl.uniform3f(uThread, r, g, b);
      gl.uniform1f(uFolds, spec.folds);
      gl.uniform1i(uShape, spec.shape);
      gl.uniform1f(uScale, spec.scale);
      gl.uniform3f(uOffset, spec.offset[0], spec.offset[1], spec.offset[2]);
      gl.uniform1f(uSpin, spec.spin);
      gl.uniform2f(uLean, leanX, leanY);
      gl.uniform1f(uAttract, attract);

      gl.clearColor(0, 0, 0, 0);
      gl.clear(gl.COLOR_BUFFER_BIT);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
      raf = requestAnimationFrame(frame);
    };
    raf = requestAnimationFrame(frame);

    const onLost = (e: Event) => {
      e.preventDefault();
      cancelAnimationFrame(raf);
      onFail();
    };
    canvas.addEventListener("webglcontextlost", onLost);

    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener("pointermove", onPointerMove);
      document.removeEventListener("pointerleave", onPointerLeave);
      window.removeEventListener("blur", onPointerLeave);
      canvas.removeEventListener("webglcontextlost", onLost);
      gl.deleteProgram(prog);
      gl.deleteShader(vs);
      gl.deleteShader(fs);
    };
  }, [size, onFail]);

  return (
    <canvas
      ref={canvasRef}
      className="jac-orb-canvas"
      style={{ width: size, height: size }}
      aria-hidden="true"
    />
  );
}