/** * 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(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 (
); }