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.
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)