jacquardSnapshot

← snapshot

9244 bytes
/**
 * One WebGL context for every shader tile in the lab.
 *
 * Browsers cap how many WebGL contexts may live at once — Chromium allows 16
 * and silently kills the oldest past that, which shows up as a mysteriously
 * blank tile rather than an error. A gallery of twenty figures cannot afford a
 * context each, so the shader tiles share one: the pool renders each variant
 * into a single offscreen canvas and blits the result into that tile's plain
 * 2D canvas. Thirteen raymarched figures then cost one context, not thirteen.
 */

import { GLSL_PRELUDE, type GlslVariant } from "./glsl-variants";

/** Everything a tile animates on. Mirrors the lab's shared voice simulation. */
export interface PoolState {
  level: number;
  attract: number;
  leanX: number;
  leanY: number;
  tempo: number;
  thread: [number, number, number];
}

export interface PoolTile {
  variant: GlslVariant;
  target: HTMLCanvasElement;
  /** Device pixels — the square edge of both the render and the blit. */
  px: number;
  read: () => PoolState | null;
}

const VERT = `#version 300 es
void main(){
  vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
  gl_Position = vec4(p*2.0-1.0, 0.0, 1.0);
}`;

/** One raymarcher, many distance fields. */
export function buildFrag(v: GlslVariant): string {
  const shade =
    v.shade ??
    `
      if (mat > 0.5){
        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);
      }`;

  return `#version 300 es
precision highp float;
out vec4 outColor;
uniform vec2 uRes; uniform float uTime; uniform float uLevel;
uniform vec3 uThread; uniform vec2 uLean; uniform float uAttract;

${GLSL_PRELUDE}

vec2 mapAll(vec3 p){${v.map}}
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);
  vec3 thread = pow(uThread, vec3(2.2));
  vec3 tint = mix(thread, vec3(1.0), 0.10);

  float t = uTime*0.10;
  float dist = ${(v.dist ?? 3.4).toFixed(2)} - uLevel*0.18 - uAttract*0.30;
  vec3 ro = vec3(sin(t)*dist, 0.30 + sin(t*0.6)*0.16, cos(t)*dist);
  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.7) + fu*(uLean.y*0.5);

  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);

  float d = 0.90, hit = -1.0, mat = 0.0, halo = 0.0, ring = 0.0, fog = 0.0;
  for (int i=0;i<${v.steps ?? 64};i++){
    vec3 p = ro + rd*d;
    vec2 m = mapAll(p);
    float h = m.x;
    halo += exp(-abs(h)*9.0)*0.030;
    if (m.y > 0.5 && m.y < 1.5) ring += exp(-abs(h)*26.0)*0.075;
    if (m.y > 1.5) fog += clamp(-h, 0.0, 0.1)*0.55;   // volumetric variants
    if (h < 0.0015){ hit = d; mat = m.y; break; }
    d += max(h*${(v.relax ?? 0.85).toFixed(2)}, ${(v.minStep ?? 0.006).toFixed(4)});
    if (d > 8.0) break;
  }

  halo = min(halo, 1.15); ring = min(ring, 1.6); fog = min(fog, 1.2);

  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);
    float fres = pow(1.0-clamp(dot(n,-rd),0.0,1.0), 2.2);
    ${shade}
    alpha = 1.0;
  }
  col   += tint*halo*(0.34 + uLevel*0.34 + uAttract*0.26);
  col   += mix(thread, vec3(1.0), 0.12)*ring*(1.15 + uAttract*0.7);
  col   += tint*fog*(1.2 + uLevel*0.6);
  alpha  = max(alpha, clamp(halo*1.4 + ring*1.6 + fog*1.8, 0.0, 1.0));

  float r = length(uv);
  alpha *= 1.0 - smoothstep(0.98, 1.32, r);
  col = col/(1.0+col);
  col = pow(clamp(col,0.0,1.0), vec3(0.4545));
  outColor = vec4(col*alpha, alpha);
}`;
}

interface Compiled {
  prog: WebGLProgram;
  u: Record<string, WebGLUniformLocation | null>;
}

class GlslPool {
  private gl: WebGL2RenderingContext | null = null;
  private surface: HTMLCanvasElement | null = null;
  private readonly progs = new Map<string, Compiled | null>();
  private readonly tiles = new Set<PoolTile>();
  private raf = 0;
  private clock = 0;
  private last = 0;
  private edge = 0;

  add(tile: PoolTile): () => void {
    this.tiles.add(tile);
    this.start();
    return () => {
      this.tiles.delete(tile);
      if (this.tiles.size === 0) this.stop();
    };
  }

  private context(): WebGL2RenderingContext | null {
    if (this.gl) return this.gl;
    const surface = document.createElement("canvas");
    const gl = surface.getContext("webgl2", {
      alpha: true,
      antialias: false,
      premultipliedAlpha: true,
      // The pool's whole job is to be copied out of, which needs the buffer
      // to survive past the draw call.
      preserveDrawingBuffer: true,
    });
    if (!gl) return null;
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
    this.gl = gl;
    this.surface = surface;
    return gl;
  }

  private program(gl: WebGL2RenderingContext, v: GlslVariant): Compiled | null {
    const cached = this.progs.get(v.id);
    if (cached !== undefined) return cached;

    const sh = (type: number, src: string) => {
      const o = gl.createShader(type);
      if (!o) return null;
      gl.shaderSource(o, src);
      gl.compileShader(o);
      if (!gl.getShaderParameter(o, gl.COMPILE_STATUS)) {
        console.warn(`[lab:${v.id}]`, gl.getShaderInfoLog(o) ?? "compile failed");
        gl.deleteShader(o);
        return null;
      }
      return o;
    };

    const vs = sh(gl.VERTEX_SHADER, VERT);
    const fs = sh(gl.FRAGMENT_SHADER, buildFrag(v));
    let out: Compiled | null = null;
    if (vs && fs) {
      const prog = gl.createProgram();
      if (prog) {
        gl.attachShader(prog, vs);
        gl.attachShader(prog, fs);
        gl.linkProgram(prog);
        if (gl.getProgramParameter(prog, gl.LINK_STATUS)) {
          const names = ["uRes", "uTime", "uLevel", "uThread", "uLean", "uAttract"];
          const u: Record<string, WebGLUniformLocation | null> = {};
          for (const n of names) u[n] = gl.getUniformLocation(prog, n);
          out = { prog, u };
        } else {
          console.warn(`[lab:${v.id}] link`, gl.getProgramInfoLog(prog) ?? "link failed");
        }
      }
    }
    if (vs) gl.deleteShader(vs);
    if (fs) gl.deleteShader(fs);
    this.progs.set(v.id, out);
    return out;
  }

  private start() {
    if (this.raf) return;
    this.last = performance.now();
    const loop = (now: number) => {
      const dt = Math.min(0.05, (now - this.last) / 1000);
      this.last = now;
      this.frame(dt);
      this.raf = requestAnimationFrame(loop);
    };
    this.raf = requestAnimationFrame(loop);
  }

  private stop() {
    cancelAnimationFrame(this.raf);
    this.raf = 0;
  }

  private frame(dt: number) {
    const gl = this.context();
    const surface = this.surface;
    if (!gl || !surface || gl.isContextLost()) return;

    // Tempo is shared, so advance the pool clock once rather than per tile —
    // that is also what keeps the figures comparable to each other.
    let tempo = 1;
    for (const t of this.tiles) {
      const s = t.read();
      if (s) {
        tempo = s.tempo;
        break;
      }
    }
    this.clock += dt * tempo;

    // Render largest-first so the shared surface only grows, never thrashes.
    const ordered = [...this.tiles].sort((a, b) => b.px - a.px);
    for (const tile of ordered) {
      // An off-screen tile keeps its last frame rather than burning a march.
      const s = tile.read();
      if (!s) continue;
      const compiled = this.program(gl, tile.variant);
      if (!compiled) continue;
      const px = Math.max(1, Math.round(tile.px));
      if (this.edge !== px) {
        surface.width = px;
        surface.height = px;
        this.edge = px;
      }
      gl.viewport(0, 0, px, px);
      gl.useProgram(compiled.prog);
      const u = compiled.u;
      gl.uniform2f(u.uRes ?? null, px, px);
      gl.uniform1f(u.uTime ?? null, this.clock);
      gl.uniform1f(u.uLevel ?? null, s.level);
      gl.uniform3f(u.uThread ?? null, s.thread[0], s.thread[1], s.thread[2]);
      gl.uniform2f(u.uLean ?? null, s.leanX, s.leanY);
      gl.uniform1f(u.uAttract ?? null, s.attract);
      gl.clearColor(0, 0, 0, 0);
      gl.clear(gl.COLOR_BUFFER_BIT);
      gl.drawArrays(gl.TRIANGLES, 0, 3);

      const ctx = tile.target.getContext("2d");
      if (!ctx) continue;
      if (tile.target.width !== px) {
        tile.target.width = px;
        tile.target.height = px;
      }
      ctx.clearRect(0, 0, px, px);
      ctx.drawImage(surface, 0, 0, px, px, 0, 0, px, px);
    }
  }
}

let pool: GlslPool | null = null;

/** Registers a tile with the shared pool; the returned function unregisters. */
export function registerGlslTile(tile: PoolTile): () => void {
  pool ??= new GlslPool();
  return pool.add(tile);
}