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.
This commit is contained in:
parent
164dda4760
commit
c9967a17a0
1 changed files with 179 additions and 225 deletions
|
|
@ -1,14 +1,11 @@
|
|||
/**
|
||||
* ShaderCanvas — Core WebGL component for rendering GLSL shaders.
|
||||
* ShaderCanvas — WebGL GLSL renderer with viewport-aware lifecycle.
|
||||
*
|
||||
* Shadertoy-compatible: accepts mainImage(out vec4 fragColor, in vec2 fragCoord)
|
||||
* Injects uniforms: iTime, iResolution, iMouse
|
||||
* 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.
|
||||
*
|
||||
* Features:
|
||||
* - Visibility-aware: only renders when in viewport (IntersectionObserver)
|
||||
* - Graceful WebGL context failure handling
|
||||
* - Debounced recompilation on code change
|
||||
* - Mouse tracking for iMouse uniform
|
||||
* 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';
|
||||
|
|
@ -23,44 +20,23 @@ interface ShaderCanvasProps {
|
|||
onCompileSuccess?: () => void;
|
||||
}
|
||||
|
||||
const VERTEX_SHADER = `#version 300 es
|
||||
const VERT = `#version 300 es
|
||||
in vec4 a_position;
|
||||
void main() { gl_Position = a_position; }
|
||||
`;
|
||||
void main() { gl_Position = a_position; }`;
|
||||
|
||||
function buildFragmentShader(userCode: string): string {
|
||||
const prefix = `#version 300 es
|
||||
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 prefix + userCode + `
|
||||
void main() {
|
||||
vec4 col;
|
||||
mainImage(col, gl_FragCoord.xy);
|
||||
outColor = col;
|
||||
}`;
|
||||
return pfx + userCode + `
|
||||
void main() { vec4 c; mainImage(c, gl_FragCoord.xy); outColor = c; }`;
|
||||
}
|
||||
|
||||
// If user has void main(), replace gl_FragColor with outColor
|
||||
return prefix + userCode.replace(/gl_FragColor/g, 'outColor');
|
||||
}
|
||||
|
||||
function createShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader | null {
|
||||
const shader = gl.createShader(type);
|
||||
if (!shader) return null;
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
const info = gl.getShaderInfoLog(shader) || 'Unknown error';
|
||||
gl.deleteShader(shader);
|
||||
throw new Error(info);
|
||||
}
|
||||
return shader;
|
||||
return pfx + userCode.replace(/gl_FragColor/g, 'outColor');
|
||||
}
|
||||
|
||||
export default function ShaderCanvas({
|
||||
|
|
@ -72,235 +48,213 @@ export default function ShaderCanvas({
|
|||
onError,
|
||||
onCompileSuccess,
|
||||
}: ShaderCanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const glRef = useRef<WebGL2RenderingContext | null>(null);
|
||||
const programRef = useRef<WebGLProgram | null>(null);
|
||||
const animRef = useRef<number>(0);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
const mouseRef = useRef<[number, number, number, number]>([0, 0, 0, 0]);
|
||||
const isVisibleRef = useRef(false);
|
||||
const [glFailed, setGlFailed] = useState(false);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (animRef.current) {
|
||||
cancelAnimationFrame(animRef.current);
|
||||
animRef.current = 0;
|
||||
}
|
||||
const gl = glRef.current;
|
||||
if (gl && programRef.current) {
|
||||
gl.deleteProgram(programRef.current);
|
||||
programRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const compile = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !code.trim()) return;
|
||||
|
||||
// Don't compile if not visible (saves WebGL contexts)
|
||||
if (!isVisibleRef.current && animate) return;
|
||||
|
||||
let gl = glRef.current;
|
||||
if (!gl) {
|
||||
gl = canvas.getContext('webgl2', {
|
||||
antialias: false,
|
||||
preserveDrawingBuffer: false,
|
||||
powerPreference: 'low-power',
|
||||
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],
|
||||
});
|
||||
if (!gl) {
|
||||
setGlFailed(true);
|
||||
onError?.('WebGL2 not available — too many active contexts');
|
||||
return;
|
||||
}
|
||||
glRef.current = gl;
|
||||
setGlFailed(false);
|
||||
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();
|
||||
}
|
||||
|
||||
// Handle context loss
|
||||
canvas.addEventListener('webglcontextlost', (e) => {
|
||||
e.preventDefault();
|
||||
cleanup();
|
||||
glRef.current = null;
|
||||
setGlFailed(true);
|
||||
}, { once: true });
|
||||
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]);
|
||||
|
||||
canvas.addEventListener('webglcontextrestored', () => {
|
||||
setGlFailed(false);
|
||||
glRef.current = null; // Will be re-created on next compile
|
||||
compile();
|
||||
}, { once: true });
|
||||
// ── Compile shader ─────────────────────────────────────
|
||||
const compile = useCallback((canvas: HTMLCanvasElement) => {
|
||||
const s = stateRef.current;
|
||||
|
||||
// Clean previous program
|
||||
if (programRef.current) {
|
||||
gl.deleteProgram(programRef.current);
|
||||
programRef.current = null;
|
||||
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 = createShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
|
||||
const fs = createShader(gl, gl.FRAGMENT_SHADER, buildFragmentShader(code));
|
||||
if (!vs || !fs) throw new Error('Failed to create shaders');
|
||||
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 program = gl.createProgram()!;
|
||||
gl.attachShader(program, vs);
|
||||
gl.attachShader(program, fs);
|
||||
gl.linkProgram(program);
|
||||
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
const err = gl.getProgramInfoLog(program) || 'Link failed';
|
||||
gl.deleteProgram(program);
|
||||
throw new Error(err);
|
||||
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);
|
||||
}
|
||||
|
||||
// Clean up individual shaders
|
||||
gl.deleteShader(vs);
|
||||
gl.deleteShader(fs);
|
||||
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);
|
||||
}
|
||||
|
||||
programRef.current = program;
|
||||
gl.useProgram(program);
|
||||
s.prog = p;
|
||||
gl.useProgram(p);
|
||||
|
||||
// Set up fullscreen quad
|
||||
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 posLoc = gl.getAttribLocation(program, 'a_position');
|
||||
gl.enableVertexAttribArray(posLoc);
|
||||
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
|
||||
const loc = gl.getAttribLocation(p, 'a_position');
|
||||
gl.enableVertexAttribArray(loc);
|
||||
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
startTimeRef.current = performance.now();
|
||||
s.t0 = performance.now();
|
||||
onCompileSuccess?.();
|
||||
onError?.('');
|
||||
|
||||
// Start render loop
|
||||
const render = () => {
|
||||
if (!programRef.current || !glRef.current) return;
|
||||
// Pause rendering when not visible
|
||||
if (!isVisibleRef.current && animate) {
|
||||
animRef.current = requestAnimationFrame(render);
|
||||
return;
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
onError?.(e.message);
|
||||
return false;
|
||||
}
|
||||
}, [onError, onCompileSuccess]);
|
||||
|
||||
const gl = glRef.current;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const t = (performance.now() - startTimeRef.current) / 1000;
|
||||
// ── 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 uTime = gl.getUniformLocation(programRef.current, 'iTime');
|
||||
const uRes = gl.getUniformLocation(programRef.current, 'iResolution');
|
||||
const uMouse = gl.getUniformLocation(programRef.current, 'iMouse');
|
||||
|
||||
if (uTime) gl.uniform1f(uTime, t);
|
||||
if (uRes) gl.uniform3f(uRes, w, h, 1.0);
|
||||
if (uMouse) gl.uniform4f(uMouse, ...mouseRef.current);
|
||||
|
||||
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);
|
||||
|
||||
if (animate) {
|
||||
animRef.current = requestAnimationFrame(render);
|
||||
}
|
||||
s.anim = animate ? requestAnimationFrame(tick) : 0;
|
||||
};
|
||||
tick();
|
||||
}, [animate]);
|
||||
|
||||
if (animRef.current) cancelAnimationFrame(animRef.current);
|
||||
render();
|
||||
|
||||
} catch (e: any) {
|
||||
onError?.(e.message || 'Compilation failed');
|
||||
}
|
||||
}, [code, animate, onError, onCompileSuccess, cleanup]);
|
||||
|
||||
// Visibility observer — only initialize WebGL when in viewport
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
// ── 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(
|
||||
(entries) => {
|
||||
const wasVisible = isVisibleRef.current;
|
||||
isVisibleRef.current = entries[0]?.isIntersecting ?? false;
|
||||
([entry]) => {
|
||||
const s = stateRef.current;
|
||||
const was = s.visible;
|
||||
s.visible = entry.isIntersecting;
|
||||
|
||||
// When becoming visible for the first time, compile
|
||||
if (isVisibleRef.current && !wasVisible) {
|
||||
if (!programRef.current) {
|
||||
compile();
|
||||
}
|
||||
}
|
||||
|
||||
// When going out of viewport, release the WebGL context to free resources
|
||||
if (!isVisibleRef.current && wasVisible && animate) {
|
||||
cleanup();
|
||||
if (glRef.current) {
|
||||
const ext = glRef.current.getExtension('WEBGL_lose_context');
|
||||
ext?.loseContext();
|
||||
glRef.current = null;
|
||||
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.1 },
|
||||
{ threshold: 0.01, rootMargin: '200px' },
|
||||
);
|
||||
|
||||
observer.observe(canvas);
|
||||
return () => observer.disconnect();
|
||||
}, [compile, cleanup, animate]);
|
||||
observer.observe(container);
|
||||
|
||||
// Recompile when code changes (for editor use)
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
const s = stateRef.current;
|
||||
if (s.anim) cancelAnimationFrame(s.anim);
|
||||
s.running = false;
|
||||
};
|
||||
}, [fullSetup, startLoop]);
|
||||
|
||||
// ── Code change (editor) ───────────────────────────────
|
||||
useEffect(() => {
|
||||
if (isVisibleRef.current || !animate) {
|
||||
compile();
|
||||
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();
|
||||
}
|
||||
return cleanup;
|
||||
}, [code]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Resize handling
|
||||
// ── Container resize → canvas resize ───────────────────
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const w = entry.contentRect.width;
|
||||
const h = entry.contentRect.height;
|
||||
if (w > 0 && h > 0) {
|
||||
canvas.width = Math.floor(w);
|
||||
canvas.height = Math.floor(h);
|
||||
}
|
||||
}
|
||||
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; }
|
||||
});
|
||||
|
||||
observer.observe(canvas);
|
||||
return () => observer.disconnect();
|
||||
ro.observe(container);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Mouse tracking
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
mouseRef.current = [
|
||||
e.clientX - rect.left,
|
||||
rect.height - (e.clientY - rect.top),
|
||||
mouseRef.current[2],
|
||||
mouseRef.current[3],
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} style={{ width: width ? `${width}px` : '100%', height: height ? `${height}px` : '100%' }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={width || 640}
|
||||
height={height || 360}
|
||||
className="block w-full h-full"
|
||||
onMouseMove={handleMouseMove}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
style={{ width: width ? `${width}px` : '100%', height: height ? `${height}px` : '100%' }}
|
||||
/>
|
||||
{glFailed && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-surface-2">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-1">⬡</div>
|
||||
<div className="text-xs text-gray-500">Scroll to load</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue