WebGL's Ceiling

WebGL's Ceiling and the Case for a New Web GPU API

WebGL 2 built shadows, PBR and deferred rendering in WebGL; its limits are structural, not missing features. Every call mutates hidden global state that ANGLE 2,592 (ANGLE) must re-check at each draw, errors come back through a synchronous gl.getError(), and there is no compute stage (Compute Shaders). Each limit follows from the OpenGL ES 3.0 state-machine design, so the browser vendors designed a new API instead of patching the old one:

WebGL 2's structural limits and how WebGPU addresses each
WebGL 2 limitation WebGPU's answer
One global state machine, re-validated per draw Immutable pipeline objects validated once
Loose uniforms set one at a time Bind groups of buffers, textures and samplers
No compute shaders or storage buffers Compute pipelines and storage buffers
Synchronous getError() round trips Asynchronous error scopes and events
GLSL compiled by each driver WGSL validated by the browser first

WebGL is not going away (WebGL or WebGPU? says when to prefer it).

WebGL 2's structural limits and WebGPU's answer to each, drawn as WebGPU cardsHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f4ee; font: 14px system-ui, sans-serif; }
  .stage { position: relative; width: 100%; max-width: 600px; }
  .stage canvas { display: block; width: 100%; }
  .stage canvas + canvas { position: absolute; inset: 0; pointer-events: none; }
</style>
<div class="stage">
  <canvas id="view" width="600" height="360"></canvas>
  <canvas id="labels" width="600" height="360"></canvas>
</div>
<script>
const canvas = document.getElementById('view');
const ink = document.getElementById('labels').getContext('2d');

function showMessage(text) {                     // 2D fallback when WebGPU is missing
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = '#fbeaea'; ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#8a2b2b'; ctx.font = '18px system-ui, sans-serif'; ctx.textAlign = 'center';
  ctx.fillText(text, canvas.width / 2, canvas.height / 2);
}
function label(text, x, y, size = 12, color = '#2b2b2b', align = 'center', weight = '') {
  ink.font = `${weight} ${size}px system-ui, sans-serif`; ink.fillStyle = color; ink.textAlign = align;
  ink.fillText(text, x, y);
}

const rows = [
  ['One global state machine,', 're-validated at every draw', 'Immutable pipeline objects,', 'validated once'],
  ['Loose uniforms set', 'one at a time', 'Bind groups of buffers,', 'textures and samplers'],
  ['No compute shaders', 'or storage buffers', 'Compute pipelines and', 'storage buffers'],
  ['Synchronous getError()', 'round trips', 'Asynchronous error scopes', 'and uncapturederror events'],
  ['GLSL compiled by', 'each driver', 'WGSL validated by', 'the browser first']];

async function main() {
  const adapter = await navigator.gpu?.requestAdapter();
  if (!adapter) return showMessage('WebGPU is not available in this browser');
  const device = await adapter.requestDevice();
  const context = canvas.getContext('webgpu');
  const format = navigator.gpu.getPreferredCanvasFormat();
  context.configure({ device, format });

  const boxes = [];                                  // x, y, w, h, r, g, b, corner radius
  label('WebGL 2 limitation', 145, 26, 14, '#8a3324', 'center', 'bold');
  label("WebGPU's answer", 455, 26, 14, '#1e6b3a', 'center', 'bold');
  rows.forEach(([a1, a2, b1, b2], i) => {
    const y = 42 + i * 63;
    boxes.push([10, y, 270, 52, 0.98, 0.86, 0.82, 10], [320, y, 270, 52, 0.84, 0.94, 0.85, 10]);
    boxes.push([286, y + 25, 22, 3, 0.35, 0.35, 0.35, 1]);          // arrow shaft
    for (let k = 0; k < 6; k++) boxes.push([302 + k, y + 21 + k, 2, 11 - 2 * k, 0.35, 0.35, 0.35, 0]);
    label(a1, 145, y + 22, 13, '#5a2217'); label(a2, 145, y + 40, 13, '#5a2217');
    label(b1, 455, y + 22, 13, '#174d2b'); label(b2, 455, y + 40, 13, '#174d2b');
  });

  const module = device.createShaderModule({ code: `
    struct Box { rect: vec4f, style: vec4f }          // style: r, g, b, corner radius
    @group(0) @binding(0) var<storage> boxes: array<Box>;
    struct Out { @builtin(position) pos: vec4f, @location(0) local: vec2f,
                 @location(1) @interpolate(flat) i: u32 }
    @vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> Out {
      let corner = vec2f(f32(v & 1), f32(v >> 1));   // triangle-strip corners
      let r = boxes[i].rect;
      let px = r.xy + corner * r.zw;
      return Out(vec4f(px.x / 300 - 1, 1 - px.y / 180, 0, 1), corner * r.zw, i);
    }
    @fragment fn fs(in: Out) -> @location(0) vec4f {
      let b = boxes[in.i];
      let half = b.rect.zw / 2;
      let q = abs(in.local - half) - half + b.style.w; // signed distance to a rounded box
      let d = length(max(q, vec2f(0))) + min(max(q.x, q.y), 0) - b.style.w;
      let a = clamp(0.5 - d, 0, 1);
      return vec4f(b.style.rgb * a, a);               // premultiplied alpha
    }` });
  const blend = { srcFactor: 'one', dstFactor: 'one-minus-src-alpha' };
  const pipeline = device.createRenderPipeline({ layout: 'auto',
    vertex: { module }, primitive: { topology: 'triangle-strip' },
    fragment: { module, targets: [{ format, blend: { color: blend, alpha: blend } }] } });
  const data = new Float32Array(boxes.flat());
  const buffer = device.createBuffer({ size: data.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(buffer, 0, data);
  const group = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer } }] });

  const encoder = device.createCommandEncoder();
  const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
    clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }] });
  pass.setPipeline(pipeline);
  pass.setBindGroup(0, group);
  pass.draw(4, boxes.length);
  pass.end();
  device.queue.submit([encoder.finish()]);
}
main();
</script>