A WebGL-to-WebGPU Concept Map

Porting mostly moves WebGL's global state into WebGPU's objects:

WebGL 2 concepts and their WebGPU equivalents
WebGL 2 WebGPU
Program linked from two shaders Shader module; the pipeline names entry points
vertexAttribPointer(), VAO buffers layout in the pipeline, setVertexBuffer()
enable(BLEND), depthFunc(), cullFace() Fields of the pipeline descriptor
uniform4fv() and friends Uniform buffer, bind group, setBindGroup()
Texture unit plus texParameteri() Texture view plus a separate sampler
bindFramebuffer(), clear() beginRenderPass() with loadOp and clearValue
antialias: true by default sampleCount: 4 and a resolve target, by hand
getError() Error scopes, uncapturederror (Errors and Device Loss)
Transform feedback Compute shaders writing storage buffers

The page below runs Cover Wall's cover wall and its port side by side. cover-wall-gl.js holds the WebGL code unchanged, wrapped in coverWallVertices() and drawCoverWallGL(); the WebGPU half reads the same interleaved data, seven floats per vertex, which WebGL described with three vertexAttribPointer() calls:

demos/ch04/port-cover-wall.html: Section 3.1.8 in WebGL 2 (left) and WebGPU (right)HTML
<canvas id="gl" width="680" height="192" style="width:340px"></canvas>
<canvas id="gpu" width="680" height="192" style="width:340px"></canvas>
<script src="webgl-helper.js"></script><script src="cover-wall-gl.js"></script>
<script type="module">
const vertices = coverWallVertices();                  // x, y, u, v, r, g, b per vertex
drawCoverWallGL(document.getElementById('gl'), vertices);        // Chapter 3, unchanged
const device = await (await navigator.gpu.requestAdapter()).requestDevice();
const format = navigator.gpu.getPreferredCanvasFormat();
const context = document.getElementById('gpu').getContext('webgpu');
context.configure({ device, format });
const module = device.createShaderModule({ code: `
  struct Varyings { @builtin(position) position: vec4f,              // gl_Position
                    @location(0) uv: vec2f, @location(1) color: vec3f }  // out vUV, vColor
  @vertex fn vs(@location(0) position: vec2f, @location(1) uv: vec2f,
                @location(2) color: vec3f) -> Varyings {
    return Varyings(vec4f(position, 0, 1), uv, color);
  }
  @fragment fn fs(in: Varyings) -> @location(0) vec4f {
    let spine = mix(0.55, 1.0, smoothstep(0.07, 0.10, in.uv.x));   // same built-ins
    return vec4f(in.color * spine * mix(1.2, 0.7, in.uv.y), 1);
  }` });
const buffer = device.createBuffer({ size: vertices.byteLength,
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(buffer, 0, vertices);
const pipeline = device.createRenderPipeline({ layout: 'auto',
  vertex: { module, buffers: [{ arrayStride: 28, attributes: [          // the VAO's job
    { shaderLocation: 0, offset: 0, format: 'float32x2' },
    { shaderLocation: 1, offset: 8, format: 'float32x2' },
    { shaderLocation: 2, offset: 16, format: 'float32x3' }] }] },
  fragment: { module, targets: [{ format }] } });
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture()
  .createView(), loadOp: 'clear', clearValue: [0.93, 0.91, 0.87, 1], storeOp: 'store' }] });
pass.setPipeline(pipeline), pass.setVertexBuffer(0, buffer), pass.draw(36), pass.end();
device.queue.submit([encoder.finish()]);
window.__done = true;
</script>
Browser output of Listing 4.104
Browser output of 104

The halves look identical. Compared pixel by pixel, 7,882 of 522,240 channel values differed, all on cover edges: WebGL antialiases by default and this pipeline has one sample. With { antialias: false } passed to WebGL, the largest difference was 1 in 255 (GLSL's mediump). One trap surfaced on the way: a WebGL canvas is cleared once its frame is presented, and a module script's first await ends the task, so read WebGL pixels before awaiting.

The same interleaved cover-wall vertices drawn by WebGL 2 (left) and by its WebGPU port (right), with the concept map belowHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f4ee; font: 12px system-ui, sans-serif; color: #222; }
  .row { display: flex; gap: 8px; max-width: 600px; }
  .row div { flex: 1; min-width: 0; }
  canvas { display: block; width: 100%; border-radius: 6px; }
  table { max-width: 600px; width: 100%; border-collapse: collapse; margin-top: 8px; font-size: 11.5px; }
  td { padding: 2px 6px; border-bottom: 1px solid #e2ddd3; }
  td:first-child { color: #6a2a0e; } td:last-child { color: #123a7a; }
</style>
<div class="row">
  <div><b>WebGL 2</b><canvas id="gl" width="296" height="120"></canvas></div>
  <div><b>WebGPU</b><canvas id="gpu" width="296" height="120"></canvas></div>
</div>
<table id="map"></table>
<script>
const rows = [['Program linked from two shaders', 'Shader module; the pipeline names entry points'],
  ['vertexAttribPointer(), VAO', 'buffers layout in the pipeline, setVertexBuffer()'],
  ['enable(BLEND), depthFunc(), cullFace()', 'Fields of the pipeline descriptor'],
  ['uniform4fv() and friends', 'Uniform buffer, bind group, setBindGroup()'],
  ['Texture unit plus texParameteri()', 'Texture view plus a separate sampler'],
  ['bindFramebuffer(), clear()', 'beginRenderPass() with loadOp and clearValue'],
  ['antialias: true by default', 'sampleCount: 4 and a resolve target, by hand'],
  ['getError()', 'Error scopes, uncapturederror'],
  ['Transform feedback', 'Compute shaders writing storage buffers']];
document.getElementById('map').innerHTML = rows.map(([a, b]) => `<tr><td>${a}</td><td>${b}</td></tr>`).join('');

// Six covers as two triangles each: x, y, u, v, r, g, b per vertex (7 floats, 28 bytes).
function coverWallVertices() {
  const tints = [[0.12, 0.37, 0.55], [0.36, 0.25, 0.60], [0.71, 0.27, 0.18], [0.88, 0.60, 0.06], [0.25, 0.49, 0.23], [0.16, 0.62, 0.56]];
  const out = [];
  tints.forEach((c, i) => {
    const x0 = -0.92 + i * 0.31, x1 = x0 + 0.26, y0 = -0.8, y1 = 0.8;
    for (const [x, y, u, v] of [[x0, y0, 0, 1], [x1, y0, 1, 1], [x1, y1, 1, 0], [x0, y0, 0, 1], [x1, y1, 1, 0], [x0, y1, 0, 0]]) out.push(x, y, u, v, ...c);
  });
  return new Float32Array(out);
}
const vertices = coverWallVertices();

function drawCoverWallGL(canvas, data) {           // the WebGL half, as it was
  const gl = canvas.getContext('webgl2');
  if (!gl) return false;
  const compile = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return s; };
  const program = gl.createProgram();
  gl.attachShader(program, compile(gl.VERTEX_SHADER, `#version 300 es
    in vec2 position; in vec2 uv; in vec3 color; out vec2 vUV; out vec3 vColor;
    void main() { vUV = uv; vColor = color; gl_Position = vec4(position, 0, 1); }`));
  gl.attachShader(program, compile(gl.FRAGMENT_SHADER, `#version 300 es
    precision mediump float; in vec2 vUV; in vec3 vColor; out vec4 outColor;
    void main() { float spine = mix(0.55, 1.0, smoothstep(0.07, 0.10, vUV.x));
                  outColor = vec4(vColor * spine * mix(1.2, 0.7, vUV.y), 1); }`));
  gl.linkProgram(program);
  gl.useProgram(program);
  gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
  gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
  [['position', 2, 0], ['uv', 2, 8], ['color', 3, 16]].forEach(([name, size, offset]) => {
    const loc = gl.getAttribLocation(program, name);
    gl.enableVertexAttribArray(loc);
    gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 28, offset);
  });
  gl.clearColor(0.93, 0.91, 0.87, 1); gl.clear(gl.COLOR_BUFFER_BIT);
  gl.drawArrays(gl.TRIANGLES, 0, 36);
  return true;
}

function showMessage(canvas, text) {              // 2D fallback message
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = '#fbeaea'; ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#8a2b2b'; ctx.font = '13px system-ui, sans-serif'; ctx.textAlign = 'center';
  ctx.fillText(text, canvas.width / 2, canvas.height / 2);
}

async function main() {
  const glCanvas = document.getElementById('gl'), gpuCanvas = document.getElementById('gpu');
  if (!drawCoverWallGL(glCanvas, vertices)) showMessage(glCanvas, 'WebGL 2 is not available');
  const adapter = await navigator.gpu?.requestAdapter();
  if (!adapter) return showMessage(gpuCanvas, 'WebGPU is not available in this browser');
  const device = await adapter.requestDevice();
  const format = navigator.gpu.getPreferredCanvasFormat();
  const context = gpuCanvas.getContext('webgpu');
  context.configure({ device, format });
  const module = device.createShaderModule({ code: `
    struct Varyings { @builtin(position) position: vec4f,              // gl_Position
                      @location(0) uv: vec2f, @location(1) color: vec3f }  // out vUV, vColor
    @vertex fn vs(@location(0) position: vec2f, @location(1) uv: vec2f,
                  @location(2) color: vec3f) -> Varyings {
      return Varyings(vec4f(position, 0, 1), uv, color);
    }
    @fragment fn fs(in: Varyings) -> @location(0) vec4f {
      let spine = mix(0.55, 1.0, smoothstep(0.07, 0.10, in.uv.x));   // same built-ins
      return vec4f(in.color * spine * mix(1.2, 0.7, in.uv.y), 1);
    }` });
  const buffer = device.createBuffer({ size: vertices.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(buffer, 0, vertices);
  const pipeline = device.createRenderPipeline({ layout: 'auto',
    vertex: { module, buffers: [{ arrayStride: 28, attributes: [          // the VAO's job
      { shaderLocation: 0, offset: 0, format: 'float32x2' },
      { shaderLocation: 1, offset: 8, format: 'float32x2' },
      { shaderLocation: 2, offset: 16, format: 'float32x3' }] }] },
    fragment: { module, targets: [{ format }] } });
  const encoder = device.createCommandEncoder();
  const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
    loadOp: 'clear', clearValue: [0.93, 0.91, 0.87, 1], storeOp: 'store' }] });
  pass.setPipeline(pipeline); pass.setVertexBuffer(0, buffer); pass.draw(36); pass.end();
  device.queue.submit([encoder.finish()]);
}
main();
</script>