← 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"
/>
);
}