Hot-Swapping Shaders

Hot-Swapping Shaders for Live Editing

Live-coding editors recompile as you type: build a new program, and only if it links, delete the old one and switch; otherwise keep drawing with the old program and show the log:

Swapping in an edited fragment shader only if it buildsJavaScript
const vs = `#version 300 es
  out vec2 vUV; void main() { vUV = vec2(gl_VertexID & 1, gl_VertexID >> 1) * 2.0;
                              gl_Position = vec4(vUV * 2.0 - 1.0, 0.0, 1.0); }`;
const head = '#version 300 es\nprecision mediump float;\nin vec2 vUV; out vec4 c;\n';
let current = GLH.load(gl, vs, head + 'void main() { c = vec4(vUV, 0.5, 1.0); }');
function rebuild(source) {
  try {
    const next = GLH.load(gl, vs, source);       // throws on a compile or link error
    gl.deleteProgram(current.program);
    current = next;
    return 'swapped';
  } catch (e) {
    return e.message.trim();                     // the old program stays in use
  }
}
console.log(rebuild(head + 'void main() { c = vec4(0.12, 0.37, vUV.y, 1.0); }'));
console.log(rebuild(head + 'void main() { c = vec4(0.12, 0.37, vUV.z, 1.0); }'));
Output
swapped
Fragment shader failed to compile:
ERROR: 0:4: 'z' : vector field selection out of range

Call rebuild() from a debounced input handler. GLH.load() calls useProgram() only after a successful link, so a failed edit leaves the last good program drawing. Re-send uniforms after a swap, since a new program starts with every uniform at zero.

Edit the fragment shader: it is swapped in only when it builds, otherwise the old one keeps drawingHTMLLive
<!doctype html>
<style>
  body { margin: 0; font: 12px system-ui, sans-serif; background: #f7f4ee; color: #333; }
  .row { display: flex; gap: 10px; max-width: 600px; }
  textarea { flex: 1; height: 190px; font: 11px/1.4 monospace; border: 1px solid #ccc; padding: 6px; }
  canvas { width: 220px; height: 190px; flex: none; }
  #status { max-width: 600px; margin-top: 6px; font-family: monospace; font-size: 11px; white-space: pre-wrap; }
  .bad { color: #b5452f; } .good { color: #3f7d3a; }
</style>
<div class="row">
  <textarea id="src" spellcheck="false"></textarea>
  <canvas id="view" width="220" height="190"></canvas>
</div>
<div id="status"></div>
<script>
const gl = document.getElementById('view').getContext('webgl2');
const vs = `#version 300 es
out vec2 vUV; uniform float uTime;
void main() {
  vUV = vec2(gl_VertexID & 1, gl_VertexID >> 1) * 2.0;     // a big triangle, no buffer
  gl_Position = vec4(vUV * 2.0 - 1.0, 0.0, 1.0);
}`;
const head = '#version 300 es\nprecision mediump float;\nin vec2 vUV; out vec4 c;\nuniform float uTime;\n';

function load(fsSource) {                 // throws on a compile or link error
  const program = gl.createProgram();
  for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fsSource]]) {
    const s = gl.createShader(type);
    gl.shaderSource(s, src); gl.compileShader(s);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
    gl.attachShader(program, s);
  }
  gl.linkProgram(program);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(program));
  return { program, uTime: gl.getUniformLocation(program, 'uTime') };
}
let current = load(head + 'void main() { c = vec4(vUV, 0.5, 1.0); }');

function rebuild(source) {
  try {
    const next = load(source);
    gl.deleteProgram(current.program);    // only now is the old program thrown away
    current = next;
    return { ok: true, text: 'swapped' };
  } catch (e) {
    return { ok: false, text: e.message.trim() };   // the old program stays in use
  }
}
const status = document.getElementById('status');
function report(r) { status.className = r.ok ? 'good' : 'bad'; status.textContent = r.text; }

// The book's two edits: the first builds, the second (vUV.z on a vec2) does not.
const log = [rebuild(head + 'void main() { c = vec4(0.12, 0.37, vUV.y, 1.0); }'),
             rebuild(head + 'void main() { c = vec4(0.12, 0.37, vUV.z, 1.0); }')];
const editor = document.getElementById('src');
editor.value = head + `void main() {
  float rings = sin(length(vUV - 0.5) * 40.0 - uTime * 3.0);
  vec3 blue = vec3(0.12, 0.37, 0.55), gold = vec3(0.88, 0.6, 0.06);
  c = vec4(mix(blue, gold, 0.5 + 0.5 * rings), 1.0);
}`;
report(rebuild(editor.value));
status.className = '';
status.textContent = 'Startup edits: ' + log.map(r => r.text.split('\n')[0]).join(' | ') +
  '\nThen loaded the editor text: swapped. Type to edit (debounced 300 ms).';

let timer;
editor.addEventListener('input', () => {
  clearTimeout(timer);
  timer = setTimeout(() => report(rebuild(editor.value)), 300);
});
gl.bindVertexArray(gl.createVertexArray());
function frame(ms) {
  gl.useProgram(current.program);
  gl.uniform1f(current.uTime, ms / 1000);   // re-sent every frame, so a new program gets it too
  gl.drawArrays(gl.TRIANGLES, 0, 3);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>