Frame Timing

Reading Frame Timing with the Performance API

performance.now() around draw calls measures only the CPU side: WebGL calls return once queued, and the GPU runs them later. In a page that is not cross-origin isolated, Chrome 1 rounded every reading to 0.1 ms, and requestAnimationFrame timestamps came 16.6 to 16.8 ms apart (60 Hz). For the GPU side, EXT_disjoint_timer_query_webgl2 wraps draws in a TIME_ELAPSED_EXT query that answers in nanoseconds a few frames later; Chrome 154 exposed it on both renderers. The helper measures both and keeps the medians:

webgl-helper.js, part 35: median CPU and GPU time of a drawing functionJavaScript
// Section 3.20: median CPU and GPU milliseconds of draw() over some animation frames
GLH.time = async function (gl, draw, frames = 20) {
  const timer = gl.getExtension('EXT_disjoint_timer_query_webgl2');   // null: no GPU times
  const cpu = [], gpu = [], pending = [];
  for (let i = 0; i < frames; i++) {
    await new Promise(requestAnimationFrame);
    const query = timer && gl.createQuery();
    if (query) gl.beginQuery(timer.TIME_ELAPSED_EXT, query);
    const start = performance.now();
    draw();
    cpu.push(performance.now() - start);             // JavaScript and WebGL's validation
    if (query) { gl.endQuery(timer.TIME_ELAPSED_EXT); pending.push(query); }
  }
  while (pending.length) {                           // answers arrive frames later
    await new Promise(requestAnimationFrame);
    const disjoint = gl.getParameter(timer.GPU_DISJOINT_EXT);   // clocks reset: discard
    while (pending.length && gl.getQueryParameter(pending[0], gl.QUERY_RESULT_AVAILABLE)) {
      const ns = gl.getQueryParameter(pending.shift(), gl.QUERY_RESULT);
      if (!disjoint) gpu.push(ns / 1e6);
    }
  }
  const median = (list) => list.sort((a, b) => a - b)[list.length >> 1] ?? NaN;
  return { cpu: median(cpu), gpu: median(gpu) };
};

"Disjoint" means the GPU clock was interrupted, invalidating the queries in flight. Only one TIME_ELAPSED_EXT query may be active at a time, so time whole passes.

Live frame timing: requestAnimationFrame intervals, CPU time from performance.now() and GPU time from a TIME_ELAPSED_EXT queryHTMLLive
<!doctype html>
<style>
  body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
  canvas { display: block; width: 100%; max-width: 600px; }
  pre { max-width: 600px; margin: 4px 0; font: 11px monospace; }
</style>
<canvas id="c" width="600" height="320"></canvas>
<pre id="out"></pre>
<script>
const gl = document.getElementById('c').getContext('webgl2');
function program(vs, fs) {
  const p = gl.createProgram();
  for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]]) {
    const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
    gl.attachShader(p, s);
  }
  gl.linkProgram(p);
  return p;
}
// The work being timed: 3,000 instanced covers with a deliberately busy fragment shader.
const scene = program(`#version 300 es
uniform float uTime; out vec2 vUV; flat out int vBook;
float hash(float n) { return fract(sin(n * 12.9898) * 43758.5453); }
void main() {
  vUV = vec2(gl_VertexID & 1, gl_VertexID >> 1);
  float id = float(gl_InstanceID); vBook = gl_InstanceID % 6;
  vec2 p = vec2(hash(id) * 2.0 - 1.0, hash(id + 5.0) * 1.2 - 0.35) + 0.05 * vec2(sin(uTime + id), cos(uTime * 1.3 + id));
  gl_Position = vec4(p + (vUV - 0.5) * vec2(0.04, 0.09), 0.0, 1.0);
}`, `#version 300 es
precision highp float; in vec2 vUV; flat in int vBook; out vec4 c;
const vec3 covers[6] = vec3[](vec3(0.12,0.37,0.55), vec3(0.36,0.25,0.6), vec3(0.88,0.6,0.06), vec3(0.25,0.49,0.23), vec3(0.71,0.27,0.18), vec3(0.16,0.62,0.56));
void main() {
  float s = 0.0;
  for (int i = 0; i < 40; i++) s += sin(vUV.y * float(i) * 3.0) * 0.01;   // pointless work to time
  c = vec4(covers[vBook] * (0.8 + s + 0.2 * vUV.y), 1.0);
}`);
// The graph: bars for the last 150 frames, drawn with scissored clears in the bottom strip.
const timer = gl.getExtension('EXT_disjoint_timer_query_webgl2');   // null: no GPU times
const history = [], pending = [];
let last = 0, gpuMs = NaN;
gl.bindVertexArray(gl.createVertexArray());
function frame(now) {
  const interval = last ? now - last : 16.7; last = now;
  // Collect finished GPU queries (they answer a few frames later).
  const disjoint = timer && gl.getParameter(timer.GPU_DISJOINT_EXT);
  while (pending.length && gl.getQueryParameter(pending[0], gl.QUERY_RESULT_AVAILABLE)) {
    const q = pending.shift(), ns = gl.getQueryParameter(q, gl.QUERY_RESULT);
    if (!disjoint) gpuMs = ns / 1e6;                            // clocks reset: discard
    gl.deleteQuery(q);
  }
  const query = timer && pending.length < 4 && gl.createQuery();
  if (query) gl.beginQuery(timer.TIME_ELAPSED_EXT, query);
  const start = performance.now();
  gl.viewport(0, 110, 600, 210);
  gl.disable(gl.SCISSOR_TEST);
  gl.clearColor(0.93, 0.91, 0.87, 1); gl.clear(gl.COLOR_BUFFER_BIT);
  gl.useProgram(scene);
  gl.uniform1f(gl.getUniformLocation(scene, 'uTime'), now / 1000);
  gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, 3000);
  const cpuMs = performance.now() - start;                       // JavaScript + WebGL validation only
  if (query) { gl.endQuery(timer.TIME_ELAPSED_EXT); pending.push(query); }
  history.push([interval, cpuMs, gpuMs]); if (history.length > 150) history.shift();
  gl.enable(gl.SCISSOR_TEST);
  gl.scissor(0, 0, 600, 108); gl.clearColor(1, 1, 1, 1); gl.clear(gl.COLOR_BUFFER_BIT);
  gl.scissor(0, Math.round(16.7 * 3), 600, 1); gl.clearColor(0.6, 0.6, 0.6, 1); gl.clear(gl.COLOR_BUFFER_BIT);   // the 16.7 ms line
  history.forEach(([iv, cpu, gpu], i) => {
    const x = i * 4;
    [[iv, [0.8, 0.8, 0.8]], [gpu, [0.88, 0.6, 0.06]], [cpu, [0.12, 0.37, 0.55]]].forEach(([ms, color]) => {
      if (!(ms > 0)) return;
      gl.scissor(x, 0, 3, Math.min(108, Math.max(1, Math.round(ms * 3))));
      gl.clearColor(...color, 1); gl.clear(gl.COLOR_BUFFER_BIT);
    });
  });
  document.getElementById('out').textContent =
    `rAF interval ${interval.toFixed(1)} ms (grey) | CPU ${cpuMs.toFixed(2)} ms (blue) | ` +
    `GPU ${timer ? (isNaN(gpuMs) ? 'waiting...' : gpuMs.toFixed(3) + ' ms (gold)') : 'EXT_disjoint_timer_query_webgl2 not exposed'}`;
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>