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.

typescript
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: use timestamp-query and EXT_disjoint_timer_query_webgl2 as 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: read GPU_DISJOINT_EXT after the query resolves and drop the sample when it is set, exactly as the poll loop does.
  • timestamp-query feature not requested. Reading a QuerySet of type timestamp without listing the feature in requiredFeatures throws 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 warmup frames before recording, and keep the sprite, canvas size, and point buffer byte-identical across both backends.

Up: WebGPU vs WebGL 2.0 for Spatial Workloads