Uploading BookNest's six decoded covers was timed both ways in the same page, 15 runs after two warm-ups, median in milliseconds. WebGL 2 ran on ANGLE 2,592 's Direct3D 11 backend and WebGPU on Dawn 1,149 's Direct3D 12 backend, on the same GTX 1650; each run created fresh textures and waited for the GPU (a 1-pixel readPixels() for WebGL, onSubmittedWorkDone() for WebGPU). The machine was shared with other work, so compare ratios, not absolutes:
| Six 256 x 384 covers | WebGL 2 | WebGPU, to completion | WebGPU, JavaScript only |
|---|---|---|---|
| From ImageBitmap | 1.7 | 6.7 | 0.5 |
| From raw bytes | 1.4 | 4.9 | 1.1 |
| From ImageBitmap, with mipmaps | 2.1 | 8.7 | 1.1 |
WebGL finished sooner here, and WebGPU's JavaScript cost was the smallest: copyExternalImageToTexture() only queues work for the GPU process, and the wait is spent on the GPU timeline. An empty submit's round trip took 0.2 ms, so most of WebGPU's time is the copy path itself, which draws each bitmap into the texture and converts it. Uploads from raw bytes (writeTexture()) were the faster WebGPU path, and the render-pass mip chain added about 2 ms for 48 levels. The practical conclusions: upload once and reuse textures, batch uploads before a frame rather than during it, and measure on your target devices, since backends and drivers differ.
<!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="370"></canvas>
<canvas id="labels" width="600" height="370"></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);
}
// Six 256 x 384 covers, median ms (Chrome 154, GTX 1650, shared machine): WebGL 2, WebGPU to completion, WebGPU JS only.
const book = [['From ImageBitmap', [1.7, 6.7, 0.5]], ['From raw bytes', [1.4, 4.9, 1.1]], ['ImageBitmap + mipmaps', [2.1, 8.7, 1.1]]];
const series = [['WebGL 2', [0.55, 0.35, 0.65]], ['WebGPU, to completion', [0.08, 0.40, 0.75]], ['WebGPU, JavaScript only', [0.35, 0.70, 0.95]]];
async function measure(device) { // median of 5 runs, this browser
const art = new OffscreenCanvas(256, 384), g = art.getContext('2d');
g.fillStyle = '#b5452e'; g.fillRect(0, 0, 256, 384); g.fillStyle = '#f3e6c8'; g.fillRect(0, 60, 256, 120);
const bitmap = await createImageBitmap(art);
const raw = new Uint8Array(256 * 384 * 4).fill(180);
const usage = GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT;
const run = async (upload) => {
const js = [], total = [];
for (let r = 0; r < 5; r++) {
const t0 = performance.now();
const textures = Array.from({ length: 6 }, () => device.createTexture({ size: [256, 384], format: 'rgba8unorm', usage }));
textures.forEach(upload);
const t1 = performance.now();
await device.queue.onSubmittedWorkDone(); // wait for the GPU timeline
js.push(t1 - t0); total.push(performance.now() - t0);
textures.forEach((t) => t.destroy());
}
const median = (a) => a.sort((x, y) => x - y)[2];
return [median(total), median(js)];
};
const fromBitmap = await run((texture) => device.queue.copyExternalImageToTexture({ source: bitmap }, { texture }, [256, 384]));
const fromBytes = await run((texture) => device.queue.writeTexture({ texture }, raw, { bytesPerRow: 1024 }, [256, 384]));
return [['From ImageBitmap', fromBitmap], ['From raw bytes', fromBytes]];
}
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 live = await measure(device);
// Bars: x, y, width, height, r, g, b, radius; drawn by one instanced pipeline below.
const bars = [];
const scale = (ms) => ms * 38; // 38 px per millisecond, capped
book.forEach(([, values], row) => values.forEach((ms, k) =>
bars.push([190, 46 + row * 72 + k * 18, Math.min(scale(ms), 380), 14, ...series[k][1], 3])));
live.forEach(([, [total, js]], row) => {
bars.push([190, 272 + row * 42, Math.min(scale(total), 380), 14, ...series[1][1], 3]);
bars.push([190, 272 + row * 42 + 18, Math.min(scale(js), 380), 14, ...series[2][1], 3]);
});
const module = device.createShaderModule({ code: `
struct Bar { rect: vec4f, style: vec4f }
@group(0) @binding(0) var<storage> bars: array<Bar>;
struct Out { @builtin(position) pos: vec4f, @location(0) color: vec3f }
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> Out {
let q = vec2f(f32(v & 1), f32(v >> 1));
let px = bars[i].rect.xy + q * bars[i].rect.zw;
return Out(vec4f(px.x / 300 - 1, 1 - px.y / 185, 0, 1), bars[i].style.rgb);
}
@fragment fn fs(in: Out) -> @location(0) vec4f { return vec4f(in.color, 1); }` });
const pipeline = device.createRenderPipeline({ layout: 'auto', primitive: { topology: 'triangle-strip' },
vertex: { module }, fragment: { module, targets: [{ format }] } });
const data = new Float32Array(bars.flat());
const buffer = device.createBuffer({ size: data.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(buffer, 0, data);
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, device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer } }] }));
pass.draw(4, bars.length);
pass.end();
device.queue.submit([encoder.finish()]);
ink.font = 'bold 13px system-ui, sans-serif'; ink.fillStyle = '#222';
ink.fillText("Six 256 x 384 covers, median ms (the book's measurements)", 12, 22);
ink.font = '12px system-ui, sans-serif';
book.forEach(([name, values], row) => {
ink.fillStyle = '#222'; ink.textAlign = 'right'; ink.fillText(name, 180, 72 + row * 72);
values.forEach((ms, k) => { ink.textAlign = 'left'; ink.fillStyle = '#444'; ink.fillText(`${ms}`, 196 + Math.min(scale(ms), 380), 58 + row * 72 + k * 18); });
});
ink.font = 'bold 13px system-ui, sans-serif'; ink.fillStyle = '#222'; ink.textAlign = 'left';
ink.fillText('This browser, WebGPU only', 12, 262);
ink.font = '12px system-ui, sans-serif';
live.forEach(([name, [total, js]], row) => {
ink.fillStyle = '#222'; ink.textAlign = 'right'; ink.fillText(name, 180, 290 + row * 42);
ink.textAlign = 'left'; ink.fillStyle = '#444';
ink.fillText(total.toFixed(1), 196 + Math.min(scale(total), 380), 284 + row * 42);
ink.fillText(js.toFixed(1), 196 + Math.min(scale(js), 380), 302 + row * 42);
});
series.forEach(([name, c], k) => {
ink.fillStyle = `rgb(${c.map((v) => v * 255).join(',')})`; ink.fillRect(12 + k * 196, 352, 12, 12);
ink.fillStyle = '#333'; ink.fillText(name, 30 + k * 196, 362);
});
}
main();
</script>