The Shader Gallery That Lied: Debugging 216 WebGL Presets

A WebGL shader gallery can look perfectly healthy while 14% of it is broken. My audio-reactive GLSL playground shipped with 216 presets, a live editor, and a compile-status indicator, and for months it carried 30 presets that rendered black, froze, blew out to a white screen, or never compiled at all. Nobody filed a bug. The first real report was one sentence: “many of the preset samples weren’t working.”

The interesting part is not that shaders broke. Shaders break constantly. The interesting part is the machinery that kept the breakage invisible, and what it took to make the screen tell the truth. The failure taxonomy that came out of one afternoon of forensic debugging generalizes to any system that renders generative content at scale.

TL;DR

  • A “keep the last good shader” fallback is a great editing feature and a terrible gallery feature. When a preset fails to compile, the canvas keeps showing the previous preset, so a broken shader looks like a working one with the wrong art. Eleven of my 216 presets had never compiled once.
  • Your eyes cannot audit 216 animations. A pixel-readback harness can: gl.readPixels on sampled regions, two frames apart, classifies every preset as black, frozen, or alive in minutes.
  • In a single-feedback-buffer engine, the displayed color is next frame’s simulation state. Cellular automata that painted living cells in decorative colors were killing themselves within two frames. Moving state to the alpha channel fixed the whole class.
  • Additive glow kernels shaped like k/(d+ε) integrate to a white screen when you sum thousands of samples. Feedback loops multiplied by an audio term that can exceed 1.0 diverge exponentially. Both are math bugs, not art choices.
  • Every claim about “the shader compiles” should be an assertion against the real compiler, not an inference from the absence of visible errors.

The fallback that hides failure

The playground’s compiler wraps every compile in a last-good guard. If the new source fails, the engine keeps the previous program running, shows a small error badge in the editor, and waits for the user’s next keystroke. For its designed purpose, live editing, the guard is exactly right. Nobody wants a black flash on every typo.

The same guard becomes a liar the moment presets load through it. Click a card whose shader has a syntax error and the compile fails silently, the last-good program keeps rendering, and the canvas shows the previous preset’s visuals under the new preset’s name. Unless you happen to open the editor panel and notice the badge, the gallery looks fully stocked.

My first automated pass made the same mistake the fallback invites. I sampled presets and checked the DOM for error messages: zero failures, everything compiles. It took a second pass, calling the compiler directly and asserting on its return value per preset, to surface the truth:

for (const preset of playground.presets.presets) {
  const meta = playground.presets.parseMeta(preset.source);
  const params = meta?.params?.map(p => p.name) ?? [];
  const result = playground.compiler.compile(
    playground.presets.getShaderBody(preset.source), params);
  if (!result.success) failures.push(preset.name);
}

Eleven names came back. Eleven presets that had shipped, displayed a neighbor’s pixels, and never run a single frame of their own.

Measuring what is actually on screen

Compile success is necessary, not sufficient. A shader can compile and still render nothing: a raymarcher whose camera misses the scene, a particle system whose deposits are subpixel, a simulation that decays to zero. The only ground truth is the framebuffer.

The harness that found the rest of the breakage is small enough to paste into a console. It registers its own requestAnimationFrame callback, which the browser runs after the app’s callback in the same frame, so the back buffer still holds the frame’s pixels even with preserveDrawingBuffer: false.1 It reads a handful of sampled regions, computes mean luminance, waits 700 ms, and reads again:

function readLuminance(gl) {
  return new Promise(resolve => requestAnimationFrame(() => {
    const N = 40, buf = new Uint8Array(N * N * 4);
    let total = 0;
    for (const [fx, fy] of SAMPLE_POINTS) {
      gl.readPixels(x(fx), y(fy), N, N, gl.RGBA, gl.UNSIGNED_BYTE, buf);
      total += meanLuma(buf);
    }
    resolve(total / SAMPLE_POINTS.length);
  }));
}
// classify: both reads < 0.015 → BLACK; |Δ| < 0.002 → FROZEN; else ALIVE

Two readings per preset, three classes: black, frozen, alive. The sweep across all 216 presets takes about four minutes and produces a work list instead of a vibe. It also produces false positives, which matters: slow simulations read as frozen at 700 ms, and thin features slip between sample regions. The harness finds candidates; the verdict on each still needs eyes, or a longer window. Gray-Scott looked dead in every sweep and turned out to be growing beautiful rings that simply had not reached my sample points yet.

Eleven shaders that never compiled

The compile failures split into four families, and the forensics were more instructive than the fixes.

Six presets had identifiers cut in half by an old mass edit. Someone, at some point, ran a broad “make everything audio-reactive” pass that inserted * (1.0 + u_mid * 0.15) after value expressions. The insertion point was calculated naively, so warpAmt became warp * (1.0 + u_mid * 0.15)Amt and sin(t * 0.3) became sin + u_treble * 0.1(t * 0.3). Six shaders across the file carried the same scar tissue, identifiable by grepping for the impossible token sequence 0.15)[A-Za-z]. A mass edit that breaks six files identically is easy to find once you know one instance; the hard part was that nothing ever surfaced instance one.

Two presets used GLSL reserved words as variable names. active and patch read like ordinary identifiers, and most languages would take them. GLSL ES reserves both.2 The shader that declared float active was a lightning-storm effect that had presumably never flashed for anyone.

One preset declared a slider named refract. The playground turns preset metadata into uniforms, so the slider became uniform float refract;, which shadowed the built-in refract() function the same shader called four lines later. GLSL lets a variable shadow a builtin; it does not let you call the variable. Renaming the slider to refraction fixed a crystal that had never refracted.

One preset added an int to a float. vec3(0, 2, 4 + mid) fails in GLSL ES, which has no implicit int-to-float conversion.2 Desktop GLSL forgives the same line, which is exactly why it survived authoring: it was probably written or tested somewhere more permissive than the target.

The common thread: all 216 shaders lived inside one 3,000-line JavaScript file as single-line string literals. A one-line, 2,400-character GLSL string is unreviewable by humans and invisible to linters. Nothing in the toolchain ever parsed these shaders as shaders until the moment a visitor clicked the card.

The simulations that killed themselves

The subtler class of breakage was alive presets rendering black or freezing, and its root cause is a design constraint worth internalizing if you ever build a feedback-buffer engine.

The playground gives every preset a previous-frame texture, u_prevFrame, implemented as a ping-pong pair of RGBA16F framebuffers. There is one buffer. Whatever color the shader outputs for display is also, unavoidably, the state it reads back next frame. Display and state are the same memory.

Now consider a Game of Life preset. It reads a cell’s state with step(0.5, texture(u_prevFrame, uv).r) and then, for style, paints living cells mint green: vec3(0.1, 0.8, 0.2). The red channel of a living cell is 0.1. Next frame, step(0.5, 0.1) reads that cell as dead. The simulation executes correctly for exactly one generation and then the pretty colors erase the world. Eleven presets carried some version of the same self-corruption: Conway variants, Brian’s Brain, three Wolfram rules, SmoothLife, Lenia, and two reaction-diffusion systems whose chemical fields were overwritten by their own color mapping every frame.

The fix is a convention, not eleven patches: state lives in the alpha channel, display lives in RGB, and never the two shall meet. The canvas context runs with alpha: false, so the alpha channel never reaches the screen, but it round-trips through the float feedback texture perfectly.3 A cell writes fragColor = vec4(anyColorYouWant, alive) and reads texture(u_prevFrame, uv).a. Decorative freedom in three channels, exact state in the fourth.

Gray-Scott reaction-diffusion rings growing on the repaired playground, orange ringed cells with fringing instabilities on black

Gray-Scott needed two state channels, one per chemical, so it keeps state in red and green with one twist: the encoding stores 1 - a instead of a, because the activator sits near 1.0 across the whole background and would otherwise render the screen solid red.4 Invert it and the background renders black while the organism glows. The encoding is the display.

Brian's Brain cellular automaton full of blue and orange gliders after the state-channel fix

Brian’s Brain earned one extra repair. On a fine grid the automaton burns out in seconds, waves annihilate and the field goes quiet, which reads as “broken” even when the code is right.5 The repaired preset re-seeds a few hundred random cells on every detected beat of the music. The automaton now dances instead of dying, and the audio reactivity is structural rather than a brightness trick.

The white screens are math bugs

Four presets failed in the opposite direction: solid white. Two mechanisms, both quantitative.

The first is the glow kernel. An attractor preset integrates a trajectory and deposits light at every step with col += c * 0.0003 / (d + 0.002). That kernel looks innocent for one sample. Sum it over 4,000 trajectory points and the far-field contribution alone is 4000 × 0.0003 / 0.5 ≈ 2.4 for every pixel on screen. The whole canvas saturates, and the attractor drowns in its own glow. The repair is a kernel with compact support, exp(-d * 70.0) * k, which deposits meaningful light only near the trajectory and rounds to zero everywhere else.

The second is the runaway feedback multiplier. Accumulator presets decayed their trails with lines like col *= 0.88 + u_energy * 0.12, intending a subtle audio-driven brightness. But the playground multiplies audio uniforms by a user-facing React slider that defaults to 2.0, so u_energy regularly exceeds 1.0, the multiplier crosses 1.0, and a feedback loop multiplied by more than one diverges exponentially. Paint Drip saturated to a white canvas in seconds of loud music. Every write-back multiplier in a feedback loop now clamps strictly below 1: col *= min(0.88 + u_energy * 0.12, 0.995). An accumulator’s equilibrium is deposit / (1 - decay); the moment decay touches 1.0, there is no equilibrium.

Black until you press play

The last cohort was not broken code at all. Eighteen presets multiplied their output by audio energy, and with no track playing, audio energy is zero. A first-time visitor who opens the page and browses presets before starting the music sees a wall of black cards and reasonably concludes the whole thing is broken. Every earlier failure made that conclusion more likely; the design made it the default first impression.

The engine now synthesizes a gentle groove when nothing is playing: a fabricated FFT with an 84 BPM pulse, a slow spectral sway, and a deterministic beat, fed into the same analysis pipeline the real audio uses. Every audio-reactive preset moves from the first frame. Pressing play replaces the synthetic signal with the real one mid-frame, no seams. The idle state is now a preview of the reactive behavior instead of a void.

What survives contact with other codebases

The specific bugs are shader trivia. The patterns are not.

Separate state from display, structurally. Any feedback system where output feeds back as input needs a convention that keeps simulation data out of the presentation channels. Alpha worked here; a second render target works elsewhere. What does not work is trusting every author to keep thresholds and palettes aligned forever.

Fallbacks need alarms. Graceful degradation without loud telemetry converts failures into silent lies. If the last-good guard had logged one console error per failed preset load, the first of those eleven shaders would have lasted a day instead of shipping for months.

Test the output, not the absence of errors. The compile gate asserts against the real compiler. The pixel harness asserts against the real framebuffer. Both fit in a hundred lines and run in the browser the shaders actually target. Neither cares what the code claims about itself.

Big string blobs are dark matter. The moment those 216 shaders became one-line JavaScript strings, every tool that could have caught a reserved word or a mangled identifier went blind. Content that a compiler consumes deserves to live where a compiler, or at least a linter and a diff, can see it.

The playground is live, all 216 presets verified compiling and rendering, at /fun/glsl-playground. If you want the gentler on-ramp to writing shaders rather than debugging them, the interactive GLSL lab covers the fundamentals with live controls. Bring headphones. The cellular automata dance now.


  1. MDN, WebGL best practices, covers drawing-buffer invalidation after compositing and why same-frame reads work without preserveDrawingBuffer

  2. Khronos, The OpenGL ES Shading Language, Version 3.00, keywords and reserved words plus operator type rules. Both behaviors verified directly against ANGLE’s GLSL ES front end: float active = 1.0 fails with “Illegal use of reserved word” and float x = 4 + 1.0 fails with a wrong-operand-types error. 

  3. Khronos, WebGL EXT_color_buffer_float extension, which makes RGBA16F renderable and lets the alpha channel round-trip through the feedback texture at half-float precision. 

  4. Karl Sims, Reaction-Diffusion Tutorial, the canonical visual explanation of Gray-Scott feed/kill parameter space. 

  5. Wikipedia, Brian’s Brain, including its tendency toward expanding waves of activity that annihilate on collision. 

Artículos relacionados

GLSL for Builders: A Shader Lab You Can Actually Use

A practical GLSL playground with live controls for learning shader intuition fast. Presets, uniform manipulation, and ze…

17 min de lectura

Recreating MacPaint in the Browser: The 1984 Source Code Is the Spec

I rebuilt MacPaint 1.3 in the browser and checked every tool against Bill Atkinson's released Pascal source. What faithf…

13 min de lectura

Engineering Philosophy: Margaret Hamilton

Margaret Hamilton coined 'software engineering' and wrote the Apollo flight code that survived the 1202 alarm. Defensive…

27 min de lectura