Fix shader rendering: visibility-aware WebGL contexts, fix 2 GLSL shaders

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)
This commit is contained in:
John Lightner 2026-03-24 22:12:58 -05:00
parent 1047a1f5fe
commit 164dda4760
2 changed files with 118 additions and 38 deletions

View file

@ -692,24 +692,27 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
{"chaos_level": 0.2, "color_temperature": "warm", "motion_type": "breathing"}) {"chaos_level": 0.2, "color_temperature": "warm", "motion_type": "breathing"})
s("Pixel Art Dither", """ s("Pixel Art Dither", """
float dither(vec2 p, float v) { float bayer4(vec2 p) {
mat4 bayer = mat4( vec2 q = mod(p, 4.0);
0.0/16.0, 8.0/16.0, 2.0/16.0, 10.0/16.0, float b = mod(q.x + q.y * 4.0, 16.0);
12.0/16.0, 4.0/16.0, 14.0/16.0, 6.0/16.0, // Bayer 4x4 unrolled
3.0/16.0, 11.0/16.0, 1.0/16.0, 9.0/16.0, float t = 0.0;
15.0/16.0, 7.0/16.0, 13.0/16.0, 5.0/16.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;
int x = int(mod(p.x, 4.0)); else if (b < 5.0) t = 12.0; else if (b < 6.0) t = 4.0;
int y = int(mod(p.y, 4.0)); else if (b < 7.0) t = 14.0; else if (b < 8.0) t = 6.0;
float threshold = bayer[y][x]; else if (b < 9.0) t = 3.0; else if (b < 10.0) t = 11.0;
return step(threshold, v); 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) { void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy; vec2 uv = fragCoord / iResolution.xy;
vec2 pixel = floor(fragCoord / 4.0); vec2 pixel = floor(fragCoord / 4.0);
float t = iTime * 0.5; 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 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 dark = vec3(0.08, 0.04, 0.15);
vec3 light = vec3(0.3, 0.8, 0.6); vec3 light = vec3(0.3, 0.8, 0.6);
vec3 col = mix(dark, light, d); 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) { void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y; vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
float v = 0.0; float v = 0.0;
vec2 sources[4]; vec2 s0 = vec2(sin(iTime), cos(iTime)) * 0.4;
sources[0] = vec2(sin(iTime), cos(iTime)) * 0.4; vec2 s1 = vec2(-sin(iTime * 0.7), sin(iTime * 0.5)) * 0.5;
sources[1] = vec2(-sin(iTime * 0.7), sin(iTime * 0.5)) * 0.5; vec2 s2 = vec2(cos(iTime * 0.3), -sin(iTime * 0.8)) * 0.3;
sources[2] = vec2(cos(iTime * 0.3), -sin(iTime * 0.8)) * 0.3; vec2 s3 = vec2(-cos(iTime * 0.6), cos(iTime * 0.4)) * 0.35;
sources[3] = 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);
for (int i = 0; i < 4; i++) { v += sin(length(uv - s1) * 30.0 - iTime * 5.0) / (1.0 + length(uv - s1) * 5.0);
float d = length(uv - sources[i]); v += sin(length(uv - s2) * 30.0 - iTime * 5.0) / (1.0 + length(uv - s2) * 5.0);
v += sin(d * 30.0 - iTime * 5.0) / (1.0 + d * 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)); vec3 col = 0.5 + 0.5 * cos(v * 3.0 + vec3(0, 2, 4));
fragColor = vec4(col, 1.0); fragColor = vec4(col, 1.0);
}""", ["wave", "interference", "physics", "ripple", "colorful", "multi-source"]), }""", ["wave", "interference", "physics", "ripple", "colorful", "multi-source"]),

View file

@ -4,10 +4,14 @@
* Shadertoy-compatible: accepts mainImage(out vec4 fragColor, in vec2 fragCoord) * Shadertoy-compatible: accepts mainImage(out vec4 fragColor, in vec2 fragCoord)
* Injects uniforms: iTime, iResolution, iMouse * 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 { interface ShaderCanvasProps {
code: string; code: string;
@ -74,6 +78,8 @@ export default function ShaderCanvas({
const animRef = useRef<number>(0); const animRef = useRef<number>(0);
const startTimeRef = useRef<number>(0); const startTimeRef = useRef<number>(0);
const mouseRef = useRef<[number, number, number, number]>([0, 0, 0, 0]); const mouseRef = useRef<[number, number, number, number]>([0, 0, 0, 0]);
const isVisibleRef = useRef(false);
const [glFailed, setGlFailed] = useState(false);
const cleanup = useCallback(() => { const cleanup = useCallback(() => {
if (animRef.current) { if (animRef.current) {
@ -91,19 +97,39 @@ export default function ShaderCanvas({
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas || !code.trim()) return; if (!canvas || !code.trim()) return;
// Don't compile if not visible (saves WebGL contexts)
if (!isVisibleRef.current && animate) return;
let gl = glRef.current; let gl = glRef.current;
if (!gl) { if (!gl) {
gl = canvas.getContext('webgl2', { gl = canvas.getContext('webgl2', {
antialias: false, antialias: false,
preserveDrawingBuffer: true, preserveDrawingBuffer: false,
powerPreference: 'low-power',
}); });
if (!gl) { if (!gl) {
onError?.('WebGL2 not supported'); setGlFailed(true);
onError?.('WebGL2 not available — too many active contexts');
return; return;
} }
glRef.current = gl; 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 // Clean previous program
if (programRef.current) { if (programRef.current) {
gl.deleteProgram(programRef.current); gl.deleteProgram(programRef.current);
@ -148,6 +174,12 @@ export default function ShaderCanvas({
// Start render loop // Start render loop
const render = () => { const render = () => {
if (!programRef.current || !glRef.current) return; 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 gl = glRef.current;
const w = canvas.width; const w = canvas.width;
const h = canvas.height; const h = canvas.height;
@ -176,12 +208,49 @@ export default function ShaderCanvas({
} catch (e: any) { } catch (e: any) {
onError?.(e.message || 'Compilation failed'); onError?.(e.message || 'Compilation failed');
} }
}, [code, animate, onError, onCompileSuccess]); }, [code, animate, onError, onCompileSuccess, cleanup]);
// Visibility observer — only initialize WebGL when in viewport
useEffect(() => { useEffect(() => {
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(); 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; return cleanup;
}, [compile, cleanup]); }, [code]); // eslint-disable-line react-hooks/exhaustive-deps
// Resize handling // Resize handling
useEffect(() => { useEffect(() => {
@ -193,8 +262,8 @@ export default function ShaderCanvas({
const w = entry.contentRect.width; const w = entry.contentRect.width;
const h = entry.contentRect.height; const h = entry.contentRect.height;
if (w > 0 && h > 0) { if (w > 0 && h > 0) {
canvas.width = w * (window.devicePixelRatio > 1 ? 1.5 : 1); canvas.width = Math.floor(w);
canvas.height = h * (window.devicePixelRatio > 1 ? 1.5 : 1); canvas.height = Math.floor(h);
} }
} }
}); });
@ -216,13 +285,22 @@ export default function ShaderCanvas({
}; };
return ( return (
<div className={`relative ${className}`} style={{ width: width ? `${width}px` : '100%', height: height ? `${height}px` : '100%' }}>
<canvas <canvas
ref={canvasRef} ref={canvasRef}
width={width || 640} width={width || 640}
height={height || 360} height={height || 360}
className={`block ${className}`} className="block w-full h-full"
style={{ width: width ? `${width}px` : '100%', height: height ? `${height}px` : '100%' }}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
/> />
{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>
); );
} }