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