Compute Shaders

Compute Shaders and What WebGL Cannot Do

A compute shader runs on the GPU outside the drawing pipeline: any number of invocations in workgroups that share fast local memory, synchronize at barriers, and read and write arbitrary places in storage buffers. WebGL 2 has none of it (gl.dispatchCompute is undefined, and GLSL ES 3.00 has no buffer blocks, atomics or shared variables), so general GPU work in WebGL must be disguised as drawing:

These cover image filters and particles, but algorithms that need cooperation between threads, such as the reductions, prefix sums and sorts of Parallel Reduction to GPU Price Sort, take many passes and read-backs in WebGL and a few dispatches in WebGPU. The machine-learning runtimes of ONNX Runtime Web and Transformers.js likewise run their GPU operators as WebGPU compute shaders.

A brightness histogram of a cover computed on the GPU without compute shaders: one point per pixel, scattered into bins with additive blendingHTMLLive
<!doctype html>
<style>
  body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
  canvas { display: block; width: 100%; max-width: 600px; }
  .names { display: flex; max-width: 600px; text-align: center; font: 11px monospace; }
  .names div { padding: 4px 2px; }
  p { max-width: 600px; margin: 2px 6px; font: 11px monospace; }
</style>
<canvas id="c" width="600" height="260"></canvas>
<div class="names"><div style="width:40%">input: 128 x 128 cover texture</div><div style="width:60%">64-bin histogram (scatter pass, then drawn)</div></div>
<p id="out"></p>
<script>
const gl = document.getElementById('c').getContext('webgl2');
gl.getExtension('EXT_color_buffer_float');
const blendable32 = !!gl.getExtension('EXT_float_blend');   // blending into R32F needs this; RGBA16F otherwise
const S = 128, BINS = 64;
// The cover to analyse, painted with the canvas 2D API.
const paint = document.createElement('canvas'); paint.width = paint.height = S;
const g = paint.getContext('2d');
const sky = g.createLinearGradient(0, 0, 0, S); sky.addColorStop(0, '#16324f'); sky.addColorStop(1, '#e8a33c');
g.fillStyle = sky; g.fillRect(0, 0, S, S);
g.fillStyle = '#f6e7c8'; g.beginPath(); g.arc(88, 50, 16, 0, 7); g.fill();
g.fillStyle = '#0d1b2a'; g.fillRect(0, 96, S, 32);
g.fillStyle = '#f7f4ee'; g.font = 'bold 14px Georgia'; g.textAlign = 'center'; g.fillText('The Quiet', 64, 112); g.fillText('Harbor', 64, 125);
function texture(internal, w, h, format, type, source) {
  const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, !!source);
  gl.texImage2D(gl.TEXTURE_2D, 0, internal, w, h, 0, format, type, source ?? null);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
  return t;
}
const cover = texture(gl.RGBA8, S, S, gl.RGBA, gl.UNSIGNED_BYTE, paint);
const bins = blendable32 ? texture(gl.R32F, BINS, 1, gl.RED, gl.FLOAT) : texture(gl.RGBA16F, BINS, 1, gl.RGBA, gl.HALF_FLOAT);
const fbo = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, bins, 0);
function program(vs, fs) {
  const p = gl.createProgram();
  for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]]) {
    const s = gl.createShader(type); gl.shaderSource(s, '#version 300 es\nprecision highp float;\n' + src); gl.compileShader(s); gl.attachShader(p, s);
  }
  gl.linkProgram(p); return p;
}
// Scatter: vertex i reads pixel i and moves itself to the bin its brightness falls in.
const scatter = program(`uniform sampler2D uCover;
void main() { vec3 rgb = texelFetch(uCover, ivec2(gl_VertexID % ${S}, gl_VertexID / ${S}), 0).rgb;
  float bin = floor(dot(rgb, vec3(0.2126, 0.7152, 0.0722)) * ${BINS - 1}.0 + 0.5);
  gl_PointSize = 1.0; gl_Position = vec4((bin + 0.5) / ${BINS}.0 * 2.0 - 1.0, 0.0, 0.0, 1.0); }`,
  `out vec4 c; void main() { c = vec4(1.0); }`);
gl.viewport(0, 0, BINS, 1); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(scatter); gl.bindVertexArray(gl.createVertexArray());
gl.bindTexture(gl.TEXTURE_2D, cover);                        // read the cover, never the bins being written
gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);            // collisions add up: a slow stand-in for atomicAdd
gl.drawArrays(gl.POINTS, 0, S * S);
gl.disable(gl.BLEND);
const counts = new Float32Array(BINS * 4);                     // one read-back to find the tallest bin
gl.readPixels(0, 0, BINS, 1, gl.RGBA, gl.FLOAT, counts);
const tallest = Math.max(...counts.filter((_, i) => i % 4 === 0));
// Show the cover on the left and the bins as bars on the right.
const quad = `uniform vec4 uRect; out vec2 vUV;
void main() { vUV = vec2(gl_VertexID & 1, gl_VertexID >> 1); gl_Position = vec4(mix(uRect.xy, uRect.zw, vUV), 0.0, 1.0); }`;
const image = program(quad, `uniform sampler2D uTex; in vec2 vUV; out vec4 c; void main() { c = texture(uTex, vUV); }`);
const chart = program(quad, `uniform sampler2D uTex; uniform float uMax; in vec2 vUV; out vec4 c;
void main() { float h = texture(uTex, vec2(vUV.x, 0.5)).r / uMax, x = fract(vUV.x * ${BINS}.0);
  vec3 bar = mix(vec3(0.1, 0.2, 0.32), vec3(0.95, 0.72, 0.3), vUV.x);
  c = vec4(vUV.y < h && x > 0.12 ? bar : vec3(0.93, 0.91, 0.87), 1.0); }`);
gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, 600, 260);
gl.clearColor(0.97, 0.96, 0.93, 1); gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(image); gl.bindTexture(gl.TEXTURE_2D, cover);
gl.uniform4f(gl.getUniformLocation(image, 'uRect'), -0.9, -0.84, -0.2, 0.78);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
gl.useProgram(chart); gl.bindTexture(gl.TEXTURE_2D, bins);
gl.uniform4f(gl.getUniformLocation(chart, 'uRect'), -0.1, -0.84, 0.96, 0.84);
gl.uniform1f(gl.getUniformLocation(chart, 'uMax'), tallest);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
const total = counts.reduce((sum, v, i) => sum + (i % 4 ? 0 : v), 0);
document.getElementById('out').textContent = `${total} pixels counted into ${blendable32 ? 'R32F' : 'RGBA16F'} bins | tallest bin ${tallest} | ` +
  `gl.dispatchCompute: ${typeof gl.dispatchCompute}`;
</script>