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.
🌐 Demo: https://sen.ltd/portfolio/shader-playground/
📦 GitHub: https://github.com/sen-ltd/shader-playground
What it does
- The vertex shader is fixed — a full-screen quad as two triangles.
- 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. -
Three uniforms are auto-bound:
u_resolution(drawing buffer size),u_mouse(cursor in WebGL pixel coords),u_time(seconds since page load). - Compile errors land in the UI with line numbers, parsed from the driver's info log.
-
The shader source round-trips through
location.hashas 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");
Two flags that almost always come up:
-
premultipliedAlpha: falseis 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 writesgl_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: trueis 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
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;
}
Two small but load-bearing details:
-
Case-insensitive
(ERROR|WARNING). Some drivers ship "Error" with a capital E only. Theiflag handles both. -
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 withline: 0so 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);
});
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"),
};
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);
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
}
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; }
}
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);
});
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;
}
- 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:
Enterat 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: falseon 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 withline: 0. - Guard
nullfromgetUniformLocationbefore callinggl.uniform*— it returns null for unused or removed uniforms. - Use
gl.drawingBufferWidth/Heightfor theu_resolutionuniform, not CSS dimensions. - Flip
clientYto WebGL-space when feeding the mouse tou_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)