From c9967a17a0bf978dface4bb50546db95fb4635d5 Mon Sep 17 00:00:00 2001 From: John Lightner Date: Tue, 24 Mar 2026 22:28:36 -0500 Subject: [PATCH] Fix ShaderCanvas scroll-back rendering via canvas element replacement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../frontend/src/components/ShaderCanvas.tsx | 404 ++++++++---------- 1 file changed, 179 insertions(+), 225 deletions(-) diff --git a/services/frontend/src/components/ShaderCanvas.tsx b/services/frontend/src/components/ShaderCanvas.tsx index 485db51..02a1b98 100644 --- a/services/frontend/src/components/ShaderCanvas.tsx +++ b/services/frontend/src/components/ShaderCanvas.tsx @@ -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(null); - const glRef = useRef(null); - const programRef = useRef(null); - const animRef = useRef(0); - const startTimeRef = useRef(0); - const mouseRef = useRef<[number, number, number, number]>([0, 0, 0, 0]); - const isVisibleRef = useRef(false); - const [glFailed, setGlFailed] = useState(false); + const containerRef = useRef(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; - 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; - } - }, []); + // ── Create a fresh canvas element ────────────────────── + const createCanvas = useCallback(() => { + const container = containerRef.current; + if (!container) return null; + const s = stateRef.current; - 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', - }); - if (!gl) { - setGlFailed(true); - onError?.('WebGL2 not available — too many active contexts'); - return; - } - glRef.current = gl; - setGlFailed(false); + // 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); + 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); - 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; - } - - const gl = glRef.current; - const w = canvas.width; - const h = canvas.height; - const t = (performance.now() - startTimeRef.current) / 1000; - - 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); - - gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); - - if (animate) { - animRef.current = requestAnimationFrame(render); - } - }; - - if (animRef.current) cancelAnimationFrame(animRef.current); - render(); - + return true; } catch (e: any) { - onError?.(e.message || 'Compilation failed'); + onError?.(e.message); + return false; } - }, [code, animate, onError, onCompileSuccess, cleanup]); + }, [onError, onCompileSuccess]); - // Visibility observer — only initialize WebGL when in viewport - useEffect(() => { - const canvas = canvasRef.current; + // ── 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( - (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 ( -
- - {glFailed && ( -
-
-
-
Scroll to load
-
-
- )} -
+
); }