Benchmarking WebGPU and WebGL 2.0 Point Rendering
The specific trap this page defuses is measuring the wrong thing. Wrap a draw call in performance.now(), subtract, and you have timed how long the CPU spent recording commands plus whatever queue latency the driver happened to add — not how long the GPU spent rendering. Because WebGPU and WebGL 2.0 buffer work differently, that CPU-side number flatters whichever API defers more, and the benchmark tells you nothing about the rasterizer. A fair point-rendering comparison has to read the clock on the GPU: timestamp-query on WebGPU and the EXT_disjoint_timer_query_webgl2 extension on WebGL 2.0. Both bracket the actual GPU span, and both must be paired with a warm-up and a multi-frame median to survive shader compilation and driver scheduling noise. This harness renders the same N-million-point buffer on both paths and reports GPU milliseconds you can defend.
The API choice this benchmark informs is laid out in WebGPU vs WebGL 2.0 for spatial workloads; this page produces the numbers that decision leans on, using the same packed Float32Array of projected coordinates for both backends so the only variable is the rendering API.
Runnable reference implementation
The harness below acquires both backends over one canvas, uploads an identical instanced-point buffer, and times a fixed number of frames on each with GPU-side queries. It discards warm-up frames, takes the median to reject scheduler outliers, and reports GPU milliseconds per frame. Copy it, feed it a point count, and read the two medians.
interface BenchResult { api: "webgpu" | "webgl2"; points: number; gpuMedianMs: number; frames: number[]; }
const median = (xs: number[]): number => {
const s = [...xs].sort((a, b) => a - b);
const m = s.length >> 1;
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
};
// ---------- WebGPU path: timestamp-query brackets the render pass ----------
async function benchWebGPU(canvas: HTMLCanvasElement, xy: Float32Array,
frames: number, warmup: number): Promise<BenchResult> {
const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
if (!adapter?.features.has("timestamp-query")) throw new Error("timestamp-query unsupported");
const device = await adapter.requestDevice({ requiredFeatures: ["timestamp-query"] });
const ctx = canvas.getContext("webgpu") as GPUCanvasContext;
const format = navigator.gpu.getPreferredCanvasFormat();
ctx.configure({ device, format, alphaMode: "opaque" });
const n = xy.length / 2;
const inst = device.createBuffer({ size: xy.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(inst, 0, xy);
const quad = device.createBuffer({ size: 32, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(quad, 0, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]));
const shader = device.createShaderModule({ code: /* wgsl */ `
struct VOut { @builtin(position) pos: vec4<f32> };
@vertex fn vs(@location(0) corner: vec2<f32>, @location(1) center: vec2<f32>) -> VOut {
var o: VOut;
o.pos = vec4<f32>(center + corner * 0.001, 0.0, 1.0); // 2px sprite in clip space
return o;
}
@fragment fn fs() -> @location(0) vec4<f32> { return vec4<f32>(0.15, 0.42, 0.45, 1.0); }
` });
const pipeline = device.createRenderPipeline({
layout: "auto",
vertex: { module: shader, entryPoint: "vs", buffers: [
{ arrayStride: 8, stepMode: "vertex", attributes: [{ shaderLocation: 0, offset: 0, format: "float32x2" }] },
{ arrayStride: 8, stepMode: "instance", attributes: [{ shaderLocation: 1, offset: 0, format: "float32x2" }] },
] },
fragment: { module: shader, entryPoint: "fs", targets: [{ format }] },
primitive: { topology: "triangle-strip" },
});
const querySet = device.createQuerySet({ type: "timestamp", count: 2 });
const resolve = device.createBuffer({ size: 16, usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC });
const readback = device.createBuffer({ size: 16, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
const samples: number[] = [];
for (let f = 0; f < frames + warmup; f++) {
const enc = device.createCommandEncoder();
const pass = enc.beginRenderPass({
colorAttachments: [{ view: ctx.getCurrentTexture().createView(), loadOp: "clear", storeOp: "store", clearValue: { r: 0, g: 0, b: 0, a: 1 } }],
timestampWrites: { querySet, beginningOfPassWriteIndex: 0, endOfPassWriteIndex: 1 },
});
pass.setPipeline(pipeline);
pass.setVertexBuffer(0, quad);
pass.setVertexBuffer(1, inst);
pass.draw(4, n);
pass.end();
enc.resolveQuerySet(querySet, 0, 2, resolve, 0);
enc.copyBufferToBuffer(resolve, 0, readback, 0, 16);
device.queue.submit([enc.finish()]);
await readback.mapAsync(GPUMapMode.READ);
const t = new BigUint64Array(readback.getMappedRange());
const ms = Number(t[1] - t[0]) / 1e6; // timestamps are nanoseconds
readback.unmap();
if (f >= warmup) samples.push(ms); // discard warm-up frames
}
return { api: "webgpu", points: n, gpuMedianMs: median(samples), frames: samples };
}
// ---------- WebGL 2.0 path: EXT_disjoint_timer_query_webgl2 brackets the draw ----------
async function benchWebGL2(canvas: HTMLCanvasElement, xy: Float32Array,
frames: number, warmup: number): Promise<BenchResult> {
const gl = canvas.getContext("webgl2", { antialias: false })!;
const ext = gl.getExtension("EXT_disjoint_timer_query_webgl2");
if (!ext) throw new Error("EXT_disjoint_timer_query_webgl2 unsupported");
const prog = linkProgram(gl,
`#version 300 es
layout(location=0) in vec2 corner; layout(location=1) in vec2 center;
void main() { gl_Position = vec4(center + corner * 0.001, 0.0, 1.0); }`,
`#version 300 es
precision highp float; out vec4 frag;
void main() { frag = vec4(0.88, 0.39, 0.30, 1.0); }`);
const n = xy.length / 2;
const quad = gl.createBuffer()!; gl.bindBuffer(gl.ARRAY_BUFFER, quad);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0);
const inst = gl.createBuffer()!; gl.bindBuffer(gl.ARRAY_BUFFER, inst);
gl.bufferData(gl.ARRAY_BUFFER, xy, gl.STATIC_DRAW);
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(1);
gl.vertexAttribDivisor(1, 1);
gl.useProgram(prog);
const await1 = (q: WebGLQuery): Promise<number> => new Promise((res) => {
const poll = () => {
const avail = gl.getQueryParameter(q, gl.QUERY_RESULT_AVAILABLE);
const disjoint = gl.getParameter(ext.GPU_DISJOINT_EXT);
if (avail && !disjoint) res(gl.getQueryParameter(q, gl.QUERY_RESULT) / 1e6); // ns -> ms
else if (disjoint) res(NaN); // discard: clock was reset mid-query
else requestAnimationFrame(poll);
};
poll();
});
const samples: number[] = [];
for (let f = 0; f < frames + warmup; f++) {
const q = gl.createQuery()!;
gl.beginQuery(ext.TIME_ELAPSED_EXT, q);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, n);
gl.endQuery(ext.TIME_ELAPSED_EXT);
const ms = await await1(q);
gl.deleteQuery(q);
if (f >= warmup && !Number.isNaN(ms)) samples.push(ms);
}
return { api: "webgl2", points: n, gpuMedianMs: median(samples), frames: samples };
}
function linkProgram(gl: WebGL2RenderingContext, vs: string, fs: string): WebGLProgram {
const c = (t: number, src: string) => { const s = gl.createShader(t)!; gl.shaderSource(s, src); gl.compileShader(s); return s; };
const p = gl.createProgram()!;
gl.attachShader(p, c(gl.VERTEX_SHADER, vs));
gl.attachShader(p, c(gl.FRAGMENT_SHADER, fs));
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p) ?? "link failed");
return p;
}
Run each backend on its own canvas (a WebGPU context and a WebGL 2.0 context cannot share one canvas), feed both the identical xy array, and compare gpuMedianMs. Sweep points across 1e6, 2e6, 5e6, and 10e6 to trace the crossover rather than reading a single figure.
Parameter reference
| Parameter | Typical value | Guidance |
|---|---|---|
points (N) |
1M – 10M | Sweep it; a single N hides the crossover. Instance count equals N — one sprite per point. |
frames |
60 – 120 | Median over enough frames to reject scheduler spikes. Fewer than ~30 is noisy. |
warmup |
10 – 20 | Discards shader-compile and first-use allocation cost so the median reflects steady state. |
| Sprite half-size | 0.001 clip units |
Keep fill rate constant across both paths, or you benchmark overdraw, not geometry. |
powerPreference |
high-performance |
Forces the discrete GPU on dual-GPU laptops; without it the integrated GPU skews results. |
| WebGPU timestamp period | ns (fixed) | timestamp-query values are nanoseconds; divide by 1e6 for ms. No per-device scaling needed. |
WebGL2 TIME_ELAPSED_EXT |
ns | Same unit; always check GPU_DISJOINT_EXT and discard the sample if the clock was reset. |
antialias |
false |
MSAA changes fragment counts; disable it on both paths for an apples-to-apples geometry test. |
| Canvas size | fixed, e.g. 1920×1080 | Resolution drives fill rate; pin it identically for both backends. |
The two most consequential knobs are the sprite size and the warm-up count. A larger sprite turns the test into a fill-rate benchmark where both APIs converge on the same rasterizer; too small a warm-up leaves WebGPU’s one-time pipeline compilation in the median and makes it look slower than it runs.
Failure modes
- Timing the CPU instead of the GPU. Bracketing the draw with
performance.now()measures command recording and queue latency, not GPU work. Detection: numbers swing with unrelated CPU load and disagree with the timestamp median. Fix: usetimestamp-queryandEXT_disjoint_timer_query_webgl2as shown; treat wall-clock only as a submit-latency sanity check. - Ignoring
GPU_DISJOINT_EXT. When the WebGL 2.0 timer clock is reset mid-query — a context switch or power-state change — the elapsed value is garbage. Detection: occasional absurd spikes or near-zero times. Fix: readGPU_DISJOINT_EXTafter the query resolves and drop the sample when it is set, exactly as the poll loop does. timestamp-queryfeature not requested. Reading aQuerySetof typetimestampwithout listing the feature inrequiredFeaturesthrows at device creation or produces zeros. Detection:adapter.features.has("timestamp-query")is false, or all deltas are zero. Fix: gate the WebGPU path on the feature and request it; fall back to submit-boundary timing only when it is absent.- First-frame compilation counted as render time. Without warm-up, shader compilation and lazy buffer allocation land in the first samples and inflate the median for whichever path compiles more eagerly. Detection: the first few frames are multiples slower than the rest. Fix: discard
warmupframes before recording, and keep the sprite, canvas size, and point buffer byte-identical across both backends.
Related
- WebGPU vs WebGL 2.0 for Spatial Workloads — the decision this benchmark feeds, with the full capability matrix and code-path contrast.
- Performance Tuning & Profiling for WebGPU Spatial Pipelines — the broader profiling context these GPU timings slot into.
- Compute Shader vs CPU Spatial Indexing — the analogous measure-the-crossover exercise for index queries rather than rendering.
- Implementing WebGL 2.0 Fallbacks When WebGPU Fails — the fallback path whose rendering cost this harness quantifies.