Tutorial · All Levels · ~40 min
WebGL · Three.js · Spector.js · DevTools · GPU Profiling

Debugging WebGL — DevTools, Spector.js & Profiling

WebGL errors are notoriously silent — a black screen gives you nothing to go on. This guide covers systematic debugging: polling gl.getError(), querying renderer info, capturing frames with Spector.js, profiling GPU timelines, and reducing draw calls for production performance.

1gl.getError() and shader compilation errors

WebGL queues errors — call gl.getError() after each suspect call in development:

function glCheck(gl, label = '') { const err = gl.getError(); if (err !== gl.NO_ERROR) { const names = { [gl.INVALID_ENUM]: 'INVALID_ENUM', [gl.INVALID_VALUE]: 'INVALID_VALUE', [gl.INVALID_OPERATION]: 'INVALID_OPERATION', [gl.OUT_OF_MEMORY]: 'OUT_OF_MEMORY', }; throw new Error(`GL error at "${label}": ${names[err] ?? err}`); } } // Usage: gl.bindBuffer(gl.ARRAY_BUFFER, buf); glCheck(gl, 'bindBuffer'); gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); glCheck(gl, 'bufferData'); // Shader compilation — always check the info log function compileShader(gl, type, src) { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(s)); // <-- line numbers + GLSL errors throw new Error('Shader compile failed'); } return s; } // Program link gl.linkProgram(prog); if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { console.error(gl.getProgramInfoLog(prog)); // attribute / varying mismatch info throw new Error('Program link failed'); }
Wrap gl.getError() calls in a debug mode flag — calling it every frame forces a CPU/GPU sync and tanks performance. Remove them or gate behind if (DEBUG) before shipping.

2WEBGL_debug_renderer_info

This extension exposes the actual GPU model string, useful for identifying driver-specific bugs:

const canvas = document.querySelector('canvas'); const gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl'); const dbgInfo = gl.getExtension('WEBGL_debug_renderer_info'); if (dbgInfo) { const vendor = gl.getParameter(dbgInfo.UNMASKED_VENDOR_WEBGL); const renderer = gl.getParameter(dbgInfo.UNMASKED_RENDERER_WEBGL); console.log('GPU vendor: ', vendor); // e.g. "NVIDIA Corporation" console.log('GPU renderer:', renderer); // e.g. "NVIDIA GeForce RTX 4090/PCIe/SSE2" } else { console.warn('WEBGL_debug_renderer_info not available (some Firefox configs)'); } // In Three.js: const info = renderer.extensions.get('WEBGL_debug_renderer_info'); // renderer here is THREE.WebGLRenderer if (info) { const r = renderer.getContext().getParameter(info.UNMASKED_RENDERER_WEBGL); console.log('Three.js GPU:', r); }
Firefox Privacy Resistance mode may return generic strings ("Mozilla" / "Mozilla") to prevent GPU fingerprinting. This is a privacy feature, not a bug.

3Three.js renderer.info

renderer.info provides live render statistics — check it in your debug overlay:

// After renderer.render(scene, camera): const i = renderer.info; console.table({ 'Draw calls': i.render.calls, // major performance metric 'Triangles': i.render.triangles, 'Points': i.render.points, 'Lines': i.render.lines, 'Geometries': i.memory.geometries, // GPU-memory objects 'Textures': i.memory.textures, 'Programs': i.programs?.length ?? '–', // compiled shaders }); // HUD overlay (useful during development) const hud = document.createElement('div'); Object.assign(hud.style, { position:'fixed', bottom:'10px', left:'10px', color:'#4ade80', fontFamily:'monospace', fontSize:'12px', background:'rgba(0,0,0,.5)', padding:'6px 10px', borderRadius:'4px', pointerEvents:'none', zIndex:'9999' }); document.body.appendChild(hud); function updateHUD() { hud.textContent = `DC: ${renderer.info.render.calls} | Tri: ${renderer.info.render.triangles}`; renderer.info.reset(); // reset per-frame counters }

4Spector.js — frame capture and inspection

Spector.js is a browser extension that captures a full WebGL frame and lets you inspect every draw call, uniform, and texture state.

For programmatic integration, load Spector.js as a module:

// Development only — remove from production bundles import * as SPECTOR from 'https://cdn.jsdelivr.net/npm/spectorjs/dist/spector.bundle.js'; const spector = new SPECTOR.Spector(); // Capture the next N frames and open result viewer spector.displayUI(); // floating UI button overlaid on canvas // or spector.startCapture(canvas, 1); // capture 1 frame programmatically spector.onCapture.add(result => { console.log('Spector result JSON:', JSON.stringify(result, null, 2)); });
Check for redundant state changes in Spector — repeated bindBuffer or useProgram calls with the same object are a common source of unnecessary CPU overhead.

5Chrome GPU timeline profiling

// CPU-side frame timing with Performance API let t0 = performance.now(); renderer.render(scene, camera); // Note: this does NOT include GPU time — WebGL calls return immediately. // Use EXT_disjoint_timer_query for true GPU timing: const ext = gl.getExtension('EXT_disjoint_timer_query_webgl2'); if (ext) { const query = gl.createQuery(); gl.beginQuery(ext.TIME_ELAPSED_EXT, query); renderer.render(scene, camera); gl.endQuery(ext.TIME_ELAPSED_EXT); // Poll result on a future frame (non-blocking) setTimeout(() => { const available = gl.getQueryParameter(query, gl.QUERY_RESULT_AVAILABLE); if (available) { const ns = gl.getQueryParameter(query, gl.QUERY_RESULT); console.log('GPU frame time:', (ns / 1e6).toFixed(2), 'ms'); } }, 100); }

6Draw call reduction techniques

import { BufferGeometryUtils } from 'three/examples/jsm/utils/BufferGeometryUtils.js'; // Merge 1000 trees into a single draw call const merged = BufferGeometryUtils.mergeGeometries( trees.map(t => { const g = t.geometry.clone(); g.applyMatrix4(t.matrixWorld); // bake world transform return g; }) ); scene.add(new THREE.Mesh(merged, treeMat)); trees.forEach(t => scene.remove(t)); // remove individual meshes