Porting mostly moves WebGL's global state into WebGPU's objects:
| 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:
<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>
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.
<!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>