Spector.js 1,619 (github.com/BabylonJS/Spector.js (https://github.com/BabylonJS/Spector.js 1,619 ), MIT, 0.9.33 of 14 September 2026) records every WebGL call of a frame with its arguments, the GL state, textures and shaders at each draw, and a thumbnail after each draw. Use its Chrome 1 or Firefox 555 extension on any page, or load it from npm 2,036 (npm install spectorjs) and drive it from code, even headless. This page draws Depth, Culling, Blending's shelf and captures a frame:
<canvas id="c" width="620" height="200"></canvas>
<script src="https://cdn.jsdelivr.net/npm/spectorjs@0.9.33/dist/spector.bundle.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gl-matrix@3.4.4/gl-matrix-min.js"></script>
<script src="webgl-helper.js"></script>
<script>
const spector = new SPECTOR.Spector(); // create it before the frames you capture
const gl = GLH.context('c'), { attributes, uniforms } = GLH.tint(gl);
const shelf = GLH.shelf(gl, attributes); // six books and a plank
uniforms.uViewProjection(shelf.camera(620 / 200));
gl.enable(gl.DEPTH_TEST);
(function frame() {
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); shelf.draw(uniforms);
requestAnimationFrame(frame);
})();
spector.onCapture.add((capture) => {
console.log(capture.commands.length, 'commands; the last:', capture.commands.at(-1).text);
console.log(Object.entries(capture.analyses[0]).slice(1).map((e) => e.join(' ')).join(', '));
console.log(JSON.stringify(capture.analyses[1]));
window.__done = true;
});
setTimeout(() => spector.captureCanvas(gl.canvas), 200);
</script>Output
30 commands; the last: drawElements: TRIANGLES, 36 indices, UNSIGNED_SHORT, 0
drawElements 7, uniform1fv 7, uniform3fv 7, uniformMatrix4fv 7, bindVertexArray 1, clear 1
{"analyserName":"CommandsSummary","total":30,"draw":7,"clear":1}Seven draws and 21 uniform calls for seven boxes, found without reading the code; spector.displayUI() opens the capture in an inspector that links each draw to its state, shaders and uniforms.
<!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: 10px monospace; white-space: pre-wrap; max-height: 170px; overflow: auto; background: #fff; border: 1px solid #ddd; padding: 4px; }
</style>
<script src="https://cdn.jsdelivr.net/npm/spectorjs@0.9.33/dist/spector.bundle.js"></script>
<canvas id="c" width="600" height="200"></canvas>
<pre id="out">capturing...</pre>
<script>
const spector = new SPECTOR.Spector(); // create it before the frames you capture
const canvas = document.getElementById('c');
const gl = canvas.getContext('webgl2');
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
layout(location = 0) in vec3 aPosition; layout(location = 1) in vec3 aNormal;
uniform mat4 uViewProjection, uModel; out float vLight;
void main() { vLight = 0.45 + 0.55 * max(dot(normalize(mat3(uModel) * aNormal), normalize(vec3(0.4, 0.8, 0.6))), 0.0);
gl_Position = uViewProjection * uModel * vec4(aPosition, 1.0); }`], [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; uniform vec3 uColor; in float vLight; out vec4 c; void main() { c = vec4(uColor * vLight, 1.0); }`]]) {
const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); gl.attachShader(program, s);
}
gl.linkProgram(program); gl.useProgram(program);
// A unit cube and six books plus a plank.
const cube = [];
for (const [n, a1, b1] of [[[0,0,1],[1,0,0],[0,1,0]], [[0,0,-1],[-1,0,0],[0,1,0]], [[1,0,0],[0,0,-1],[0,1,0]],
[[-1,0,0],[0,0,1],[0,1,0]], [[0,1,0],[1,0,0],[0,0,-1]], [[0,-1,0],[1,0,0],[0,0,1]]])
for (const [a, b] of [[-1,-1],[1,-1],[1,1],[-1,-1],[1,1],[-1,1]]) cube.push(...[0, 1, 2].map(i => (n[i] + a * a1[i] + b * b1[i]) / 2), ...n);
gl.bindVertexArray(gl.createVertexArray());
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(cube), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0); gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12); gl.enableVertexAttribArray(1);
const box = (x, y, z, sx, sy, sz) => [sx, 0, 0, 0, 0, sy, 0, 0, 0, 0, sz, 0, x, y, z, 1];
const colors = [[0.12, 0.37, 0.55], [0.36, 0.25, 0.6], [0.88, 0.6, 0.06], [0.25, 0.49, 0.23], [0.71, 0.27, 0.18], [0.16, 0.62, 0.56]];
const items = colors.map((color, i) => ({ color, model: box(-1.25 + i * 0.5, 0.5, 0, 0.4, 1, 0.6) }));
items.push({ color: [0.54, 0.38, 0.25], model: box(0, -0.05, 0, 3.4, 0.1, 0.9) });
// The camera: perspective x lookAt, column-major.
function camera(eye, target, fovy, aspect, near, far) {
const sub = (p, q) => p.map((c, i) => c - q[i]), unit = (v) => v.map((c) => c / Math.hypot(...v));
const cross = (p, q) => [p[1] * q[2] - p[2] * q[1], p[2] * q[0] - p[0] * q[2], p[0] * q[1] - p[1] * q[0]];
const dot = (p, q) => p[0] * q[0] + p[1] * q[1] + p[2] * q[2];
const z = unit(sub(eye, target)), x = unit(cross([0, 1, 0], z)), y = cross(z, x);
const view = [x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, eye), -dot(y, eye), -dot(z, eye), 1];
const f = 1 / Math.tan(fovy / 2), d = near - far;
const proj = [f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) / d, -1, 0, 0, 2 * far * near / d, 0];
const out = new Array(16).fill(0);
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) for (let k = 0; k < 4; k++) out[c * 4 + r] += proj[k * 4 + r] * view[c * 4 + k];
return out;
}
const vp = camera([0.8, 1.4, 4.2], [0, 0.4, 0], 0.7, 3, 0.1, 20);
gl.uniformMatrix4fv(gl.getUniformLocation(program, 'uViewProjection'), false, vp);
gl.enable(gl.DEPTH_TEST);
(function frame() {
gl.clearColor(0.93, 0.91, 0.87, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
for (const { color, model } of items) { // per item: 2 uniform calls, 1 draw
gl.uniformMatrix4fv(gl.getUniformLocation(program, 'uModel'), false, model);
gl.uniform3fv(gl.getUniformLocation(program, 'uColor'), color);
gl.drawArrays(gl.TRIANGLES, 0, 36);
}
requestAnimationFrame(frame);
})();
spector.onCapture.add((capture) => {
const commands = capture.commands.map((c) => c.name);
const counts = {};
for (const name of commands) counts[name] = (counts[name] || 0) + 1;
document.getElementById('out').textContent =
`${capture.commands.length} commands in the captured frame; the last: ${capture.commands.at(-1).text}\n` +
`per command: ${Object.entries(counts).map(([n, k]) => `${n} x${k}`).join(', ')}\n\n` +
capture.commands.slice(0, 24).map((c) => c.text).join('\n');
});
setTimeout(() => spector.captureCanvas(canvas), 300);
</script>