DEV Community

SEN LLC
SEN LLC

Posted on

A 350-Line GLSL Shader Playground in the Browser — WebGL Init, Line-Number-Aware Errors, and URL-Hash Sharing

Sometimes you want to try a shader without spinning up Shadertoy, without a build step, without anything. Just a textarea, a canvas, and a refresh-while-you-type loop. This is that page: ~350 lines of vanilla JS, a fixed full-screen-quad vertex shader, and a fragment shader you edit live. Compile errors land back in the UI with line numbers; the URL hash round-trips your source so links shared on Twitter just work.

shader-playground UI: dark-themed layout with a GLSL fragment shader source on the left (precision mediump float, uniform declarations, a main() that derives uv from gl_FragCoord and outputs a radial wave) and a 380×380 WebGL canvas on the right showing an orange/pink/purple concentric-ring pattern centered near the cursor. Above the panes are uniform-chip tags (vec2 u_resolution / vec2 u_mouse / float u_time) and the controls

🌐 Demo: https://sen.ltd/portfolio/shader-playground/
📦 GitHub: https://github.com/sen-ltd/shader-playground

What it does

  1. The vertex shader is fixed — a full-screen quad as two triangles.
  2. The fragment shader is what the user edits. The page runs gl.compileShader(...) 220 ms after each keystroke (debounced) and swaps the program if compilation succeeds.
  3. Three uniforms are auto-bound: u_resolution (drawing buffer size), u_mouse (cursor in WebGL pixel coords), u_time (seconds since page load).
  4. Compile errors land in the UI with line numbers, parsed from the driver's info log.
  5. The shader source round-trips through location.hash as base64url, so sharing a permalink works the same in any browser.

That's it. The rest of this post is the small list of footguns the implementation hits.

WebGL context — two flags worth setting

const gl =
  els.canvas.getContext("webgl", {
    antialias: true,
    premultipliedAlpha: false,
  }) ||
  els.canvas.getContext("experimental-webgl");
Enter fullscreen mode Exit fullscreen mode

Two flags that almost always come up:

  • premultipliedAlpha: false is the difference between "canvas is an opaque drawing surface" and "canvas blends into the DOM behind it". If you forget this and your fragment shader writes gl_FragColor = vec4(rgb, 1.0), you'll still see the page background bleeding through on some browsers — and you'll spend ten minutes wondering why. Setting it explicitly removes the surprise.
  • antialias: true is the default, but make it explicit so you remember it's there. MSAA on the swapchain makes shape edges look clean without any per-fragment MSAA logic.

The experimental-webgl fallback is mostly historical at this point (Android Chrome 2014-ish, IE11). Costs one extra || to keep it, so I do.

Parsing the compile-error log

Drivers (mostly) emit lines like:

ERROR: 0:5: 'foo' : undeclared identifier
ERROR: 0:12: 'bar' : assignment to const variable
WARNING: 0:3: implicit conversion
Enter fullscreen mode Exit fullscreen mode

0 is the file index, almost always zero for a single source string. 5 is the source line, and the rest is the human-readable message.

const ERROR_RE =
  /^\s*(ERROR|WARNING)\s*:\s*(\d+)\s*:\s*(\d+)\s*:\s*(.+?)\s*$/i;

export function parseShaderError(infoLog) {
  if (!infoLog) return [];
  const out = [];
  for (const rawLine of infoLog.split(/\r?\n/)) {
    const line = rawLine.trim();
    if (!line) continue;
    const m = line.match(ERROR_RE);
    if (m) {
      out.push({
        severity: m[1].toUpperCase() === "ERROR" ? "error" : "warning",
        line: Number(m[3]),
        message: m[4],
      });
    } else {
      // Mali / Adreno often prepend a free-form summary line. Don't drop it.
      out.push({ severity: "error", line: 0, message: line });
    }
  }
  return out;
}
Enter fullscreen mode Exit fullscreen mode

Two small but load-bearing details:

  1. Case-insensitive (ERROR|WARNING). Some drivers ship "Error" with a capital E only. The i flag handles both.
  2. Catch the free-form summary lines. Some implementations prepend Fragment shader failed to compile with the following errors: ahead of the structured rows. If the regex doesn't match, we still surface the line — tagged with line: 0 so the UI shows it but doesn't pretend it points at the user's source.

The unit test pins both behaviours:

test("parseShaderError surfaces free-form lines as line-0 errors", () => {
  const log =
    "Fragment shader failed to compile with the following errors:\n" +
    "ERROR: 0:5: 'foo' : undeclared identifier\n";
  const out = parseShaderError(log);
  assert.equal(out.length, 2);
  assert.equal(out[0].line, 0);
  assert.equal(out[1].line, 5);
});
Enter fullscreen mode Exit fullscreen mode

Uniform binding — guard the null

After a successful link, look up the uniform locations once:

state.uniformLocs = {
  u_resolution: gl.getUniformLocation(program, "u_resolution"),
  u_mouse:      gl.getUniformLocation(program, "u_mouse"),
  u_time:       gl.getUniformLocation(program, "u_time"),
};
Enter fullscreen mode Exit fullscreen mode

In the rAF loop:

const { u_resolution, u_mouse, u_time } = state.uniformLocs;
if (u_resolution) gl.uniform2f(u_resolution, gl.drawingBufferWidth, gl.drawingBufferHeight);
if (u_mouse)      gl.uniform2f(u_mouse,  state.mouse[0] * gl.drawingBufferWidth, state.mouse[1] * gl.drawingBufferHeight);
if (u_time)       gl.uniform1f(u_time,   (performance.now() - state.startTime) / 1000);
Enter fullscreen mode Exit fullscreen mode

The if (u_X) checks matter. getUniformLocation returns null when the named uniform doesn't exist in the linked program — which happens whenever the user deletes the corresponding uniform vec2 u_mouse; line, or whenever the compiler optimises it out for being unused. Calling gl.uniform2f(null, ...) is an INVALID_OPERATION and pollutes the console, but doesn't break anything else.

Note also gl.drawingBufferWidth / gl.drawingBufferHeight rather than the CSS pixel dimensions. The drawing buffer is what the fragment shader sees in gl_FragCoord.xy, and on retina displays the two differ by devicePixelRatio.

Mouse coords are WebGL-flipped

MouseEvent.clientY increases downward (CSS / DOM convention). gl_FragCoord.y increases upward (OpenGL / WebGL convention). Forgetting to flip leaves your radial-wave-from-the-cursor effect inverted on the vertical axis.

function onMouseMove(e) {
  const rect = els.canvas.getBoundingClientRect();
  state.mouse[0] = (e.clientX - rect.left) / rect.width;
  state.mouse[1] = 1 - (e.clientY - rect.top) / rect.height;  // flip
}
Enter fullscreen mode Exit fullscreen mode

I normalise to 0..1 here and multiply by drawingBufferWidth/Height at uniform-update time, so the math doesn't have to know about the DPR.

URL-hash sharing

A shader source becomes a permalink:

export function encodeShaderToHash(source) {
  const bytes = new TextEncoder().encode(source);
  let bin = "";
  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
  return btoa(bin)
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}

export function decodeShaderFromHash(hash) {
  if (!hash) return "";
  let b64 = String(hash).replace(/-/g, "+").replace(/_/g, "/");
  while (b64.length % 4) b64 += "=";
  let bin;
  try { bin = atob(b64); } catch { return null; }
  const bytes = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
  try {
    return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
  } catch { return null; }
}
Enter fullscreen mode Exit fullscreen mode

UTF-8 round-trip, so comments in any language survive intact:

test("encode/decode round-trips UTF-8 (comments in Japanese)", () => {
  const src = "// シェーダのコメント\nvoid main() { gl_FragColor = vec4(1.0); }";
  const hash = encodeShaderToHash(src);
  assert.equal(decodeShaderFromHash(hash), src);
});
Enter fullscreen mode Exit fullscreen mode

URL safety: + / = are stripped or replaced with - _ so the hash can drop into any URL parser. The decoder restores the padding before calling atob so the original ASCII bytes come back.

I did not gzip-compress the source before encoding. The page is aimed at hand-written shaders in the 30-50-line range — 1-2 KB raw, ~1.4-2.7 KB base64. Well under the 2 MB URL limit Chrome / Firefox tolerate in practice. If you need to share bigger work, layering CompressionStream (browser-native gzip) on top of the base64 wrapper is a four-line addition; the API just becomes async.

"Did the source actually change?"

textarea's input event fires on every arrow-key navigation, every cursor blink that touches the field, every paste-and-undo. Recompiling on each event is wasteful. Fold whitespace and CRLF first:

export function shouldRecompile(prev, next) {
  if (prev === next) return false;
  const np = String(prev || "").replace(/\r\n/g, "\n").trimEnd();
  const nn = String(next || "").replace(/\r\n/g, "\n").trimEnd();
  return np !== nn;
}
Enter fullscreen mode Exit fullscreen mode
  • CRLF → LF: pasting from a Windows editor flips line endings without the user noticing. The shader source is byte-for-byte different, but the compiler-visible source is the same. Skip.
  • Trim trailing whitespace: Enter at end-of-buffer adds a \n; that's a no-op for the compiler.

The 220 ms debounce on top of that is comfortable. Type, pause, watch the canvas update. The number was picked by feel — 50 ms recompiles too aggressively during long edits, 500 ms feels laggy.

TL;DR

  • Set premultipliedAlpha: false on the WebGL context unless you mean to blend with the DOM behind.
  • Parse getShaderInfoLog() with a tolerant regex (case-insensitive, free-form line fallback). Tag unrecognised lines with line: 0.
  • Guard null from getUniformLocation before calling gl.uniform* — it returns null for unused or removed uniforms.
  • Use gl.drawingBufferWidth/Height for the u_resolution uniform, not CSS dimensions.
  • Flip clientY to WebGL-space when feeding the mouse to u_mouse.
  • For URL-hash sharing, UTF-8 → base64url is enough for hand-written shaders; defer compression until size matters.
  • Fold whitespace before deciding to recompile; debounce around 200 ms feels best.

Source: https://github.com/sen-ltd/shader-playground — MIT, ~350 lines of JS, 17 unit tests, no build step, zero runtime dependencies.


🛠 Built by SEN LLC as part of an ongoing series of small, focused developer tools. Browse the full portfolio for more.

Top comments (0)