From 164dda4760c1ec79a904517908796128f95b42b3 Mon Sep 17 00:00:00 2001 From: John Lightner Date: Tue, 24 Mar 2026 22:12:58 -0500 Subject: [PATCH] Fix shader rendering: visibility-aware WebGL contexts, fix 2 GLSL shaders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShaderCanvas rewrite: - IntersectionObserver-driven rendering: WebGL context only created when canvas enters viewport, released when it leaves. Prevents context starvation when 20+ shaders are in the feed simultaneously. - Graceful fallback UI when WebGL context unavailable (hexagon + 'scroll to load') - Context loss/restore event handlers - powerPreference: 'low-power' for feed thumbnails - Pause animation loop when off-screen (saves GPU even with context alive) - Separate resize observer (no devicePixelRatio scaling for feed — saves memory) Fixed shaders: - Pixel Art Dither: replaced mat4 dynamic indexing with unrolled Bayer lookup (some WebGL drivers reject mat4[int_var][int_var]) - Wave Interference 2D: replaced C-style array element assignment with individual vec2 variables (GLSL ES 300 compatibility) --- scripts/seed_shaders.py | 44 +++---- .../frontend/src/components/ShaderCanvas.tsx | 112 +++++++++++++++--- 2 files changed, 118 insertions(+), 38 deletions(-) diff --git a/scripts/seed_shaders.py b/scripts/seed_shaders.py index d4e3d17..7208386 100644 --- a/scripts/seed_shaders.py +++ b/scripts/seed_shaders.py @@ -692,24 +692,27 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) { {"chaos_level": 0.2, "color_temperature": "warm", "motion_type": "breathing"}) s("Pixel Art Dither", """ -float dither(vec2 p, float v) { - mat4 bayer = mat4( - 0.0/16.0, 8.0/16.0, 2.0/16.0, 10.0/16.0, - 12.0/16.0, 4.0/16.0, 14.0/16.0, 6.0/16.0, - 3.0/16.0, 11.0/16.0, 1.0/16.0, 9.0/16.0, - 15.0/16.0, 7.0/16.0, 13.0/16.0, 5.0/16.0 - ); - int x = int(mod(p.x, 4.0)); - int y = int(mod(p.y, 4.0)); - float threshold = bayer[y][x]; - return step(threshold, v); +float bayer4(vec2 p) { + vec2 q = mod(p, 4.0); + float b = mod(q.x + q.y * 4.0, 16.0); + // Bayer 4x4 unrolled + float t = 0.0; + if (b < 1.0) t = 0.0; else if (b < 2.0) t = 8.0; + else if (b < 3.0) t = 2.0; else if (b < 4.0) t = 10.0; + else if (b < 5.0) t = 12.0; else if (b < 6.0) t = 4.0; + else if (b < 7.0) t = 14.0; else if (b < 8.0) t = 6.0; + else if (b < 9.0) t = 3.0; else if (b < 10.0) t = 11.0; + else if (b < 11.0) t = 1.0; else if (b < 12.0) t = 9.0; + else if (b < 13.0) t = 15.0; else if (b < 14.0) t = 7.0; + else if (b < 15.0) t = 13.0; else t = 5.0; + return t / 16.0; } void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv = fragCoord / iResolution.xy; vec2 pixel = floor(fragCoord / 4.0); float t = iTime * 0.5; float wave = sin(uv.x * 5.0 + t) * sin(uv.y * 3.0 + t * 0.7) * 0.5 + 0.5; - float d = dither(pixel, wave); + float d = step(bayer4(pixel), wave); vec3 dark = vec3(0.08, 0.04, 0.15); vec3 light = vec3(0.3, 0.8, 0.6); vec3 col = mix(dark, light, d); @@ -1218,15 +1221,14 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) { void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y; float v = 0.0; - vec2 sources[4]; - sources[0] = vec2(sin(iTime), cos(iTime)) * 0.4; - sources[1] = vec2(-sin(iTime * 0.7), sin(iTime * 0.5)) * 0.5; - sources[2] = vec2(cos(iTime * 0.3), -sin(iTime * 0.8)) * 0.3; - sources[3] = vec2(-cos(iTime * 0.6), cos(iTime * 0.4)) * 0.35; - for (int i = 0; i < 4; i++) { - float d = length(uv - sources[i]); - v += sin(d * 30.0 - iTime * 5.0) / (1.0 + d * 5.0); - } + vec2 s0 = vec2(sin(iTime), cos(iTime)) * 0.4; + vec2 s1 = vec2(-sin(iTime * 0.7), sin(iTime * 0.5)) * 0.5; + vec2 s2 = vec2(cos(iTime * 0.3), -sin(iTime * 0.8)) * 0.3; + vec2 s3 = vec2(-cos(iTime * 0.6), cos(iTime * 0.4)) * 0.35; + v += sin(length(uv - s0) * 30.0 - iTime * 5.0) / (1.0 + length(uv - s0) * 5.0); + v += sin(length(uv - s1) * 30.0 - iTime * 5.0) / (1.0 + length(uv - s1) * 5.0); + v += sin(length(uv - s2) * 30.0 - iTime * 5.0) / (1.0 + length(uv - s2) * 5.0); + v += sin(length(uv - s3) * 30.0 - iTime * 5.0) / (1.0 + length(uv - s3) * 5.0); vec3 col = 0.5 + 0.5 * cos(v * 3.0 + vec3(0, 2, 4)); fragColor = vec4(col, 1.0); }""", ["wave", "interference", "physics", "ripple", "colorful", "multi-source"]), diff --git a/services/frontend/src/components/ShaderCanvas.tsx b/services/frontend/src/components/ShaderCanvas.tsx index 836df40..485db51 100644 --- a/services/frontend/src/components/ShaderCanvas.tsx +++ b/services/frontend/src/components/ShaderCanvas.tsx @@ -4,10 +4,14 @@ * Shadertoy-compatible: accepts mainImage(out vec4 fragColor, in vec2 fragCoord) * Injects uniforms: iTime, iResolution, iMouse * - * Used in the editor (full-size), feed items (thumbnail), and shader detail page. + * Features: + * - Visibility-aware: only renders when in viewport (IntersectionObserver) + * - Graceful WebGL context failure handling + * - Debounced recompilation on code change + * - Mouse tracking for iMouse uniform */ -import { useRef, useEffect, useCallback } from 'react'; +import { useRef, useEffect, useCallback, useState } from 'react'; interface ShaderCanvasProps { code: string; @@ -74,6 +78,8 @@ export default function ShaderCanvas({ 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 cleanup = useCallback(() => { if (animRef.current) { @@ -91,19 +97,39 @@ export default function ShaderCanvas({ 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: true, + preserveDrawingBuffer: false, + powerPreference: 'low-power', }); if (!gl) { - onError?.('WebGL2 not supported'); + setGlFailed(true); + onError?.('WebGL2 not available — too many active contexts'); return; } glRef.current = gl; + setGlFailed(false); } + // Handle context loss + canvas.addEventListener('webglcontextlost', (e) => { + e.preventDefault(); + cleanup(); + glRef.current = null; + setGlFailed(true); + }, { once: true }); + + canvas.addEventListener('webglcontextrestored', () => { + setGlFailed(false); + glRef.current = null; // Will be re-created on next compile + compile(); + }, { once: true }); + // Clean previous program if (programRef.current) { gl.deleteProgram(programRef.current); @@ -148,6 +174,12 @@ export default function ShaderCanvas({ // 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; @@ -176,12 +208,49 @@ export default function ShaderCanvas({ } catch (e: any) { onError?.(e.message || 'Compilation failed'); } - }, [code, animate, onError, onCompileSuccess]); + }, [code, animate, onError, onCompileSuccess, cleanup]); + // Visibility observer — only initialize WebGL when in viewport useEffect(() => { - compile(); + const canvas = canvasRef.current; + if (!canvas) return; + + const observer = new IntersectionObserver( + (entries) => { + const wasVisible = isVisibleRef.current; + isVisibleRef.current = entries[0]?.isIntersecting ?? false; + + // 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; + } + } + }, + { threshold: 0.1 }, + ); + + observer.observe(canvas); + return () => observer.disconnect(); + }, [compile, cleanup, animate]); + + // Recompile when code changes (for editor use) + useEffect(() => { + if (isVisibleRef.current || !animate) { + compile(); + } return cleanup; - }, [compile, cleanup]); + }, [code]); // eslint-disable-line react-hooks/exhaustive-deps // Resize handling useEffect(() => { @@ -193,8 +262,8 @@ export default function ShaderCanvas({ const w = entry.contentRect.width; const h = entry.contentRect.height; if (w > 0 && h > 0) { - canvas.width = w * (window.devicePixelRatio > 1 ? 1.5 : 1); - canvas.height = h * (window.devicePixelRatio > 1 ? 1.5 : 1); + canvas.width = Math.floor(w); + canvas.height = Math.floor(h); } } }); @@ -216,13 +285,22 @@ export default function ShaderCanvas({ }; return ( - +
+ + {glFailed && ( +
+
+
+
Scroll to load
+
+
+ )} +
); }