fractafrag/services/frontend/src/components/ShaderCanvas.tsx
John Lightner c9967a17a0 Fix ShaderCanvas scroll-back rendering via canvas element replacement
Root cause: Chromium limits ~16 simultaneous WebGL contexts. When scrolling
through a feed of 20+ shader cards, older contexts get silently evicted.
Once a context is lost on a canvas element, getContext('webgl2') returns null
on that same element forever — even after loseContext()/restore cycles.

Solution: The ShaderCanvas component now renders a container div and creates
canvas elements imperatively. When re-entering viewport:
1. Check if existing GL context is still alive (isContextLost)
2. If alive: just restart the animation loop
3. If dead: remove the old canvas, create a fresh DOM element, get a new
   context, recompile, and start rendering

This means scrolling down creates new contexts and scrolling back up
replaces dead canvases with fresh ones. At any given time only ~9 visible
canvases hold active contexts — well within Chrome's limit.

Also: 200px rootMargin on IntersectionObserver pre-compiles shaders
before cards enter viewport for smoother scroll experience.
2026-03-24 22:28:36 -05:00

260 lines
8.3 KiB
TypeScript

/**
* ShaderCanvas — WebGL GLSL renderer with viewport-aware lifecycle.
*
* Chromium limits ~16 simultaneous WebGL contexts. When a context is lost
* (browser evicts it silently), you can't re-create it on the same canvas.
*
* Solution: when re-entering viewport, if the context is dead, replace the
* canvas DOM element with a fresh one. A new canvas gets a new context.
*/
import { useRef, useEffect, useCallback, useState } from 'react';
interface ShaderCanvasProps {
code: string;
width?: number;
height?: number;
className?: string;
animate?: boolean;
onError?: (error: string) => void;
onCompileSuccess?: () => void;
}
const VERT = `#version 300 es
in vec4 a_position;
void main() { gl_Position = a_position; }`;
function buildFrag(userCode: string): string {
const pfx = `#version 300 es
precision highp float;
uniform float iTime;
uniform vec3 iResolution;
uniform vec4 iMouse;
out vec4 outColor;
`;
if (userCode.includes('mainImage')) {
return pfx + userCode + `
void main() { vec4 c; mainImage(c, gl_FragCoord.xy); outColor = c; }`;
}
return pfx + userCode.replace(/gl_FragColor/g, 'outColor');
}
export default function ShaderCanvas({
code,
width,
height,
className = '',
animate = true,
onError,
onCompileSuccess,
}: ShaderCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null);
const stateRef = useRef({
canvas: null as HTMLCanvasElement | null,
gl: null as WebGL2RenderingContext | null,
prog: null as WebGLProgram | null,
anim: 0,
t0: 0,
visible: false,
running: false,
mouse: [0, 0, 0, 0] as [number, number, number, number],
});
const codeRef = useRef(code);
codeRef.current = code;
// ── Create a fresh canvas element ──────────────────────
const createCanvas = useCallback(() => {
const container = containerRef.current;
if (!container) return null;
const s = stateRef.current;
// Remove old canvas
if (s.canvas && s.canvas.parentNode) {
if (s.anim) { cancelAnimationFrame(s.anim); s.anim = 0; }
s.running = false;
s.prog = null;
s.gl = null;
s.canvas.remove();
}
const canvas = document.createElement('canvas');
canvas.className = 'block w-full h-full';
canvas.width = container.clientWidth || width || 640;
canvas.height = container.clientHeight || height || 360;
canvas.addEventListener('mousemove', (e) => {
const r = canvas.getBoundingClientRect();
s.mouse = [e.clientX - r.left, r.height - (e.clientY - r.top), 0, 0];
});
container.appendChild(canvas);
s.canvas = canvas;
return canvas;
}, [width, height]);
// ── Compile shader ─────────────────────────────────────
const compile = useCallback((canvas: HTMLCanvasElement) => {
const s = stateRef.current;
let gl = s.gl;
if (!gl || gl.isContextLost()) {
gl = canvas.getContext('webgl2', { antialias: false, powerPreference: 'low-power' });
if (!gl) return false;
s.gl = gl;
}
if (s.prog) { gl.deleteProgram(s.prog); s.prog = null; }
try {
const vs = gl.createShader(gl.VERTEX_SHADER)!;
gl.shaderSource(vs, VERT);
gl.compileShader(vs);
if (!gl.getShaderParameter(vs, gl.COMPILE_STATUS))
throw new Error(gl.getShaderInfoLog(vs) || 'VS error');
const fs = gl.createShader(gl.FRAGMENT_SHADER)!;
gl.shaderSource(fs, buildFrag(codeRef.current));
gl.compileShader(fs);
if (!gl.getShaderParameter(fs, gl.COMPILE_STATUS)) {
const e = gl.getShaderInfoLog(fs) || 'FS error';
gl.deleteShader(vs); gl.deleteShader(fs); throw new Error(e);
}
const p = gl.createProgram()!;
gl.attachShader(p, vs); gl.attachShader(p, fs);
gl.linkProgram(p);
gl.deleteShader(vs); gl.deleteShader(fs);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
const e = gl.getProgramInfoLog(p) || 'Link error';
gl.deleteProgram(p); throw new Error(e);
}
s.prog = p;
gl.useProgram(p);
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1,1,-1,-1,1,1,1]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(p, 'a_position');
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
s.t0 = performance.now();
onCompileSuccess?.();
onError?.('');
return true;
} catch (e: any) {
onError?.(e.message);
return false;
}
}, [onError, onCompileSuccess]);
// ── Animation loop ─────────────────────────────────────
const startLoop = useCallback(() => {
const s = stateRef.current;
if (s.running || !s.gl || !s.prog || !s.canvas) return;
s.running = true;
const gl = s.gl;
const prog = s.prog;
const canvas = s.canvas;
const tick = () => {
if (!s.visible && animate) { s.running = false; s.anim = 0; return; }
if (!s.gl || !s.prog || gl.isContextLost()) { s.running = false; s.anim = 0; return; }
const w = canvas.width, h = canvas.height;
gl.viewport(0, 0, w, h);
const t = (performance.now() - s.t0) / 1000;
const uT = gl.getUniformLocation(prog, 'iTime');
const uR = gl.getUniformLocation(prog, 'iResolution');
const uM = gl.getUniformLocation(prog, 'iMouse');
if (uT) gl.uniform1f(uT, t);
if (uR) gl.uniform3f(uR, w, h, 1);
if (uM) gl.uniform4f(uM, ...s.mouse);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
s.anim = animate ? requestAnimationFrame(tick) : 0;
};
tick();
}, [animate]);
// ── Full setup: new canvas → compile → loop ────────────
const fullSetup = useCallback(() => {
const canvas = createCanvas();
if (!canvas) return;
if (compile(canvas)) {
startLoop();
}
}, [createCanvas, compile, startLoop]);
// ── Visibility observer ────────────────────────────────
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new IntersectionObserver(
([entry]) => {
const s = stateRef.current;
const was = s.visible;
s.visible = entry.isIntersecting;
if (s.visible && !was) {
// Entering viewport
if (s.gl && !s.gl.isContextLost() && s.prog) {
// Context still alive — just restart loop
startLoop();
} else {
// Context lost or never created — fresh canvas
fullSetup();
}
} else if (!s.visible && was) {
// Leaving viewport — stop loop (context stays on canvas)
if (s.anim) { cancelAnimationFrame(s.anim); s.anim = 0; }
s.running = false;
}
},
{ threshold: 0.01, rootMargin: '200px' },
);
observer.observe(container);
return () => {
observer.disconnect();
const s = stateRef.current;
if (s.anim) cancelAnimationFrame(s.anim);
s.running = false;
};
}, [fullSetup, startLoop]);
// ── Code change (editor) ───────────────────────────────
useEffect(() => {
const s = stateRef.current;
if (s.visible && s.canvas) {
if (s.anim) { cancelAnimationFrame(s.anim); s.anim = 0; }
s.running = false;
if (compile(s.canvas)) startLoop();
}
}, [code]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Container resize → canvas resize ───────────────────
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const ro = new ResizeObserver(([e]) => {
const s = stateRef.current;
if (!s.canvas) return;
const w = Math.floor(e.contentRect.width);
const h = Math.floor(e.contentRect.height);
if (w > 0 && h > 0) { s.canvas.width = w; s.canvas.height = h; }
});
ro.observe(container);
return () => ro.disconnect();
}, []);
return (
<div
ref={containerRef}
className={className}
style={{ width: width ? `${width}px` : '100%', height: height ? `${height}px` : '100%' }}
/>
);
}