Profiling WebGL with Spector.js
This page captures a single frame from a CesiumJS viewer with Spector.js and reads it — counting draw calls and grouping them by what changed between them, finding the redundant state changes and texture binds, checking which shader programs are in use, and turning the capture into a specific change to the tileset rather than a vague “it is slow”.
Why you hit this
The browser’s own performance panel tells you the frame took 38 ms and that most of it was in a function called _executeCommands. That is true and unusable. What you need to know is how many draw calls that frame issued, how many of them were for tiles outside the view, how many texture binds happened, and whether the shader was recompiled — and none of that is visible from a JavaScript profile.
Spector.js captures the actual WebGL command stream for one frame. It is the only tool that answers “what did the GPU actually get asked to do?”, and the answer is usually surprising: a tileset that looks fine issues 2,800 draw calls per frame because every building is its own primitive.
Prerequisites
- Chrome or Firefox with the Spector.js extension, or the library loaded in the page.
- A CesiumJS viewer with the tileset loaded and the camera at a representative view.
npm install spectorjsif you want captures from a script rather than by hand.
Step-by-Step
1. Capture a frame reproducibly
import * as SPECTOR from 'spectorjs';
export function attachSpector(canvas, { commandsPerFrame = 20_000 } = {}) {
const spector = new SPECTOR.Spector();
spector.spyCanvases();
spector.setMarker('tileset-frame');
return {
spector,
capture(frames = 1) {
return new Promise((resolve) => {
spector.onCaptureStarted.add(() => console.log('capture started'));
spector.onCapture.add((capture) => resolve(capture));
spector.captureCanvas(canvas, commandsPerFrame, frames);
});
},
};
}
export async function captureAtView(viewer, tileset, view, { settleMs = 4000 } = {}) {
// A capture of a frame that is still loading tiles measures the loader, not the scene.
viewer.camera.setView(view);
await waitForTilesSettled(tileset, settleMs);
const { capture } = attachSpector(viewer.canvas);
viewer.scene.requestRender();
const frame = await capture(1);
return {
frame,
context: {
tilesLoaded: tileset.tilesLoaded,
selectedTiles: tileset._selectedTiles?.length ?? null,
maximumScreenSpaceError: tileset.maximumScreenSpaceError,
resolution: [viewer.canvas.width, viewer.canvas.height],
devicePixelRatio: window.devicePixelRatio,
},
};
}
function waitForTilesSettled(tileset, timeoutMs) {
return new Promise((resolve) => {
const started = performance.now();
const check = () => {
if (tileset.tilesLoaded || performance.now() - started > timeoutMs) {
setTimeout(resolve, 250); // one more frame for the last upload
return;
}
requestAnimationFrame(check);
};
check();
});
}
Waiting for tilesLoaded before capturing is the step that makes a capture meaningful. A frame captured while tiles are still arriving is dominated by texImage2D and bufferData calls from the loader, which tells you about the loading path and nothing about the steady-state rendering cost — and those are different problems with different fixes.
Recording the context alongside the capture matters because a draw-call count is meaningless without the screen-space error and the resolution that produced it. A capture at maximumScreenSpaceError: 32 on a 1080p canvas is not comparable to one at 8 on a 4K display, and comparing them is how a “regression” gets invented.
Setting the camera with setView rather than flying makes the capture repeatable, which is what lets two captures a week apart be compared.
2. Inventory the draw calls
const DRAW_COMMANDS = new Set([
'drawArrays', 'drawElements', 'drawArraysInstanced', 'drawElementsInstanced',
'drawRangeElements', 'drawArraysInstancedANGLE', 'drawElementsInstancedANGLE',
]);
export function drawCallInventory(capture) {
const commands = capture.commands ?? [];
const draws = [];
let currentProgram = null;
let currentTexture = null;
let currentFramebuffer = 'default';
for (const cmd of commands) {
const name = cmd.name;
if (name === 'useProgram') currentProgram = cmd.text ?? String(cmd.arguments?.[0]);
if (name === 'bindTexture') currentTexture = String(cmd.arguments?.[1]);
if (name === 'bindFramebuffer') {
currentFramebuffer = cmd.arguments?.[1] ? String(cmd.arguments[1]) : 'default';
}
if (!DRAW_COMMANDS.has(name)) continue;
const mode = cmd.arguments?.[0];
const count = Number(cmd.arguments?.[1] ?? cmd.arguments?.[2] ?? 0);
const instances = name.includes('Instanced')
? Number(cmd.arguments?.[cmd.arguments.length - 1] ?? 1)
: 1;
draws.push({
command: name,
mode,
vertexOrIndexCount: count,
instances,
triangles: Math.floor(count / 3) * instances,
program: currentProgram,
texture: currentTexture,
framebuffer: currentFramebuffer,
});
}
const byProgram = new Map();
for (const d of draws) {
const entry = byProgram.get(d.program) ?? { draws: 0, triangles: 0, instanced: 0 };
entry.draws += 1;
entry.triangles += d.triangles;
if (d.instances > 1) entry.instanced += 1;
byProgram.set(d.program, entry);
}
return {
totalCommands: commands.length,
drawCalls: draws.length,
triangles: draws.reduce((s, d) => s + d.triangles, 0),
instancedDrawCalls: draws.filter((d) => d.instances > 1).length,
instancesTotal: draws.reduce((s, d) => s + (d.instances > 1 ? d.instances : 0), 0),
framebuffers: [...new Set(draws.map((d) => d.framebuffer))],
programs: [...byProgram.entries()]
.map(([program, v]) => ({ program, ...v }))
.sort((a, b) => b.draws - a.draws),
smallDraws: draws.filter((d) => d.triangles < 300).length,
draws,
};
}
The smallDraws count is the finding that most often explains a slow tileset. A draw call with fewer than 300 triangles costs almost the same as one with 30,000 — the per-call overhead dominates — so a frame with 2,400 draw calls averaging 180 triangles is spending its budget on state changes rather than on geometry.
Grouping by shader program separates the passes, which matters in CesiumJS because the globe, the terrain, the tileset and any post-processing are different programs. A frame with 2,800 draw calls where 2,600 belong to one program is a tileset problem; one where they are spread across twelve programs has too many distinct materials.
Tracking the bound framebuffer catches the case where half the draw calls are for a shadow map or a picking pass that nobody needs — and both are switchable off in a viewer.
3. Count the redundant state changes
const STATE_COMMANDS = {
useProgram: (c) => String(c.arguments?.[0]),
bindTexture: (c) => `${c.arguments?.[0]}:${c.arguments?.[1]}`,
bindBuffer: (c) => `${c.arguments?.[0]}:${c.arguments?.[1]}`,
bindVertexArray: (c) => String(c.arguments?.[0]),
blendFunc: (c) => (c.arguments ?? []).join(','),
depthFunc: (c) => String(c.arguments?.[0]),
cullFace: (c) => String(c.arguments?.[0]),
enable: (c) => `enable:${c.arguments?.[0]}`,
disable: (c) => `disable:${c.arguments?.[0]}`,
viewport: (c) => (c.arguments ?? []).join(','),
};
export function redundantStateChanges(capture) {
const commands = capture.commands ?? [];
const lastValue = new Map();
const counts = new Map();
const redundant = new Map();
for (const cmd of commands) {
const keyFn = STATE_COMMANDS[cmd.name];
if (!keyFn) continue;
const value = keyFn(cmd);
counts.set(cmd.name, (counts.get(cmd.name) ?? 0) + 1);
if (lastValue.get(cmd.name) === value) {
redundant.set(cmd.name, (redundant.get(cmd.name) ?? 0) + 1);
}
lastValue.set(cmd.name, value);
}
const rows = [...counts.entries()].map(([name, total]) => ({
command: name,
total,
redundant: redundant.get(name) ?? 0,
redundantShare: Number(((redundant.get(name) ?? 0) / total).toFixed(3)),
})).sort((a, b) => b.redundant - a.redundant);
return {
stateCommands: [...counts.values()].reduce((a, b) => a + b, 0),
redundantTotal: [...redundant.values()].reduce((a, b) => a + b, 0),
rows: rows.slice(0, 8),
verdict: rows[0]?.redundant > 200
? `${rows[0].redundant} redundant ${rows[0].command} calls — the renderer is `
+ 'not sorting by state'
: 'state changes look reasonable',
};
}
A redundant state change is one that sets the driver to a value it already has. The driver usually filters these cheaply, and “usually” is doing a lot of work — on some mobile drivers a redundant useProgram still triggers a validation pass, and 600 of them per frame is measurable.
More useful is what the count implies: a high redundant bindTexture count means draw calls are not sorted by texture, so the renderer is alternating between materials. That is not something a viewer user can fix directly, and it is a strong argument for the atlasing in merging meshes to cut draw calls — fewer distinct textures means fewer binds however the renderer sorts.
viewport appearing in this list at all is worth investigating: it usually means a render pass is being set up per object, which is a viewer configuration problem.
4. Check the textures actually bound
export function textureInventory(capture) {
const commands = capture.commands ?? [];
const textures = new Map();
let unit = 0;
for (const cmd of commands) {
if (cmd.name === 'activeTexture') unit = Number(cmd.arguments?.[0]) - 0x84C0;
if (cmd.name === 'texImage2D' || cmd.name === 'texStorage2D'
|| cmd.name === 'compressedTexImage2D') {
const key = `${cmd.name}@unit${unit}`;
const width = Number(cmd.arguments?.[3] ?? 0);
const height = Number(cmd.arguments?.[4] ?? 0);
const entry = textures.get(key) ?? { uploads: 0, sizes: [] };
entry.uploads += 1;
if (width && height) entry.sizes.push([width, height]);
textures.set(key, entry);
}
}
const uploads = [...textures.entries()].map(([key, v]) => {
const areas = v.sizes.map(([w, h]) => w * h);
const compressed = key.startsWith('compressed');
const bytesPerTexel = compressed ? 0.5 : 4;
return {
key,
uploads: v.uploads,
largest: v.sizes.length
? v.sizes[areas.indexOf(Math.max(...areas))]
: null,
estimatedGpuMB: Number((areas.reduce((a, b) => a + b, 0)
* bytesPerTexel * 1.333 / 1e6).toFixed(2)),
compressed,
};
}).sort((a, b) => b.estimatedGpuMB - a.estimatedGpuMB);
return {
uploadCommands: uploads.reduce((s, u) => s + u.uploads, 0),
estimatedGpuMB: Number(uploads.reduce((s, u) => s + u.estimatedGpuMB, 0)
.toFixed(2)),
anyUncompressed: uploads.some((u) => !u.compressed && u.estimatedGpuMB > 1),
uploads: uploads.slice(0, 6),
verdict: uploads.some((u) => !u.compressed && u.estimatedGpuMB > 20)
? 'large uncompressed textures — KTX2/Basis would cut GPU memory 4–6×'
: 'texture memory looks controlled',
};
}
Texture uploads appearing in a steady-state frame are the finding here. Once tiles are loaded, a frame should upload nothing; texImage2D in a settled frame means textures are being evicted and re-uploaded because the cache is too small, which shows as periodic hitching rather than a consistently low frame rate.
The GPU memory estimate is deliberately crude and useful: width × height × 4 × 1.333 for an uncompressed texture, or × 0.5 for a Basis-compressed one. That factor of eight is the whole argument for KTX2, and seeing it against a real capture’s numbers is more persuasive than the general claim.
5. Inspect the shader programs
export function shaderReport(capture) {
const programs = capture.programs ?? capture.initState?.programs ?? [];
const rows = programs.map((p, i) => {
const vertex = p.shaders?.find((s) => /vertex/i.test(s.name ?? s.type ?? ''));
const fragment = p.shaders?.find((s) => /fragment/i.test(s.name ?? s.type ?? ''));
const fragSource = fragment?.source ?? '';
return {
index: i,
uniforms: (p.uniforms ?? []).length,
attributes: (p.attributes ?? []).length,
vertexLines: (vertex?.source ?? '').split('\n').length,
fragmentLines: fragSource.split('\n').length,
textureSamples: (fragSource.match(/texture2D|texture\s*\(/g) ?? []).length,
branches: (fragSource.match(/\bif\s*\(/g) ?? []).length,
loops: (fragSource.match(/\bfor\s*\(/g) ?? []).length,
usesDerivatives: /dFdx|dFdy|fwidth/.test(fragSource),
styleConditions: (fragSource.match(/czm_style|tiles3d_/g) ?? []).length,
};
});
const heavy = rows.filter((r) => r.branches > 16 || r.textureSamples > 8);
return {
programs: rows.length,
rows: rows.sort((a, b) => b.branches - a.branches).slice(0, 5),
heavyPrograms: heavy.length,
verdict: heavy.length
? `${heavy.length} program(s) with heavy branching or many texture samples — `
+ 'check the Cesium3DTileStyle conditions'
: 'shaders are simple',
};
}
Branch count in the fragment shader is the number that connects a capture back to a data decision. A Cesium3DTileStyle with 200 conditions compiles to 200 comparisons in the fragment shader, and the capture shows it — which is the measured version of the advice in styling tiles by metadata with Cesium3DTileStyle.
A program count above about fifteen in a tileset frame usually means the content has many distinct material configurations, each producing a shader variant. Every variant is a compile on first use, which is the source of a one-off stutter when a new kind of tile appears.
6. Turn the capture into a change
const THRESHOLDS = {
drawCallsPerFrame: 400,
smallDrawShare: 0.25,
trianglesPerFrame: 4_000_000,
programs: 15,
fragmentBranches: 16,
uncompressedTextureMB: 20,
uploadsInSettledFrame: 0,
};
export function diagnose(capture, context, thresholds = THRESHOLDS) {
const draws = drawCallInventory(capture);
const state = redundantStateChanges(capture);
const textures = textureInventory(capture);
const shaders = shaderReport(capture);
const findings = [];
const smallShare = draws.smallDraws / Math.max(draws.drawCalls, 1);
if (draws.drawCalls > thresholds.drawCallsPerFrame) {
findings.push({
severity: 'error',
observation: `${draws.drawCalls} draw calls in one frame`,
fix: smallShare > thresholds.smallDrawShare
? 'merge primitives per material in the tile content (gltf-transform join)'
: 'reduce the number of visible tiles: raise maximumScreenSpaceError or '
+ 'make tiles larger',
});
}
if (smallShare > thresholds.smallDrawShare) {
findings.push({
severity: 'error',
observation: `${(smallShare * 100).toFixed(0)}% of draw calls carry `
+ '< 300 triangles',
fix: 'the content has one primitive per feature; join by material and use '
+ 'feature ids for picking',
});
}
if (draws.triangles > thresholds.trianglesPerFrame) {
findings.push({
severity: 'warn',
observation: `${(draws.triangles / 1e6).toFixed(1)} M triangles per frame`,
fix: 'raise maximumScreenSpaceError, or add a coarser level to the tileset',
});
}
if (shaders.programs > thresholds.programs) {
findings.push({
severity: 'warn',
observation: `${shaders.programs} shader programs`,
fix: 'dedup materials in the content; each variant is a compile on first use',
});
}
const worstShader = shaders.rows[0];
if (worstShader && worstShader.branches > thresholds.fragmentBranches) {
findings.push({
severity: 'warn',
observation: `fragment shader has ${worstShader.branches} branches`,
fix: 'bucket the Cesium3DTileStyle conditions to 16 or fewer',
});
}
if (textures.uploadCommands > thresholds.uploadsInSettledFrame) {
findings.push({
severity: 'error',
observation: `${textures.uploadCommands} texture upload(s) in a settled frame`,
fix: 'the tile cache is evicting textures — raise tileset.cacheBytes',
});
}
if (textures.anyUncompressed
&& textures.estimatedGpuMB > thresholds.uncompressedTextureMB) {
findings.push({
severity: 'warn',
observation: `about ${textures.estimatedGpuMB} MB of uncompressed texture`,
fix: 'encode tile textures as KTX2/Basis',
});
}
return {
context,
summary: {
drawCalls: draws.drawCalls,
triangles: draws.triangles,
smallDrawShare: Number(smallShare.toFixed(3)),
instancedDrawCalls: draws.instancedDrawCalls,
programs: shaders.programs,
redundantStateChanges: state.redundantTotal,
estimatedTextureGpuMB: textures.estimatedGpuMB,
},
findings,
errors: findings.filter((f) => f.severity === 'error').length,
pass: !findings.some((f) => f.severity === 'error'),
};
}
Every finding carries a fix that names a specific change to the content or the viewer configuration, which is the difference between a profile and a diagnosis. “2,847 draw calls” is an observation; “the content has one primitive per feature, join by material” is something someone can do this afternoon.
The distinction between too many draw calls and too many small draw calls is the important branch. Too many large draw calls means too many visible tiles, and the fix is in the tileset’s structure or the screen-space error; too many small ones means the tile content is fragmented, and the fix is in the content pipeline.
Expected Output & Verification
{
"context": {
"tilesLoaded": true, "selectedTiles": 148,
"maximumScreenSpaceError": 16, "resolution": [2560, 1340],
"devicePixelRatio": 2
},
"summary": {
"drawCalls": 2847, "triangles": 2284118, "smallDrawShare": 0.739,
"instancedDrawCalls": 4, "programs": 9,
"redundantStateChanges": 5337, "estimatedTextureGpuMB": 412.88
},
"findings": [
{"severity": "error", "observation": "2847 draw calls in one frame",
"fix": "merge primitives per material in the tile content (gltf-transform join)"},
{"severity": "error", "observation": "74% of draw calls carry < 300 triangles",
"fix": "the content has one primitive per feature; join by material and use feature ids for picking"},
{"severity": "error", "observation": "18 texture upload(s) in a settled frame",
"fix": "the tile cache is evicting textures — raise tileset.cacheBytes"},
{"severity": "warn", "observation": "about 412.88 MB of uncompressed texture",
"fix": "encode tile textures as KTX2/Basis"}
],
"errors": 3, "pass": false
}
Three errors with concrete fixes, from one captured frame. The 74% small-draw share against 148 selected tiles says each tile is issuing about 19 draw calls, which is one per building — exactly the situation the merge step exists for.
The 18 texture uploads in a settled frame is the second independent finding and explains a symptom the frame rate does not: periodic hitching as the cache thrashes.
Verify the diagnosis by making the change and re-capturing the same view:
export async function beforeAfter(viewer, tilesetUrlBefore, tilesetUrlAfter, view) {
const results = {};
for (const [label, url] of [['before', tilesetUrlBefore],
['after', tilesetUrlAfter]]) {
const tileset = await Cesium.Cesium3DTileset.fromUrl(url, {
maximumScreenSpaceError: 16,
cacheBytes: 536_870_912,
});
viewer.scene.primitives.add(tileset);
const { frame, context } = await captureAtView(viewer, tileset, view);
const d = diagnose(frame, context);
const frames = [];
for (let i = 0; i < 120; i++) {
const t0 = performance.now();
viewer.scene.render();
frames.push(performance.now() - t0);
await new Promise((r) => requestAnimationFrame(r));
}
frames.sort((a, b) => a - b);
results[label] = {
...d.summary,
medianFrameMs: Number(frames[60].toFixed(2)),
p95FrameMs: Number(frames[114].toFixed(2)),
};
viewer.scene.primitives.remove(tileset);
}
const before = results.before;
const after = results.after;
return {
results,
drawCallReduction: Number((before.drawCalls / Math.max(after.drawCalls, 1))
.toFixed(1)),
frameTimeReduction: Number((before.medianFrameMs - after.medianFrameMs)
.toFixed(2)),
trianglesUnchanged: Math.abs(before.triangles - after.triangles)
/ Math.max(before.triangles, 1) < 0.02,
verdict: after.medianFrameMs < before.medianFrameMs * 0.75
? 'the change worked'
: 'draw calls fell and the frame time did not — the bottleneck is elsewhere',
};
}
The trianglesUnchanged assertion is what makes this a valid comparison: a merge should change the draw-call count and not the geometry, so a triangle count that also fell means something else changed too and the frame-time improvement cannot be attributed.
The verdict’s second branch is the honest outcome worth naming. A tileset whose draw calls drop 20× with no frame-time improvement was never draw-call bound, and the capture’s other findings — texture memory, shader branches, triangle count — are where to look next.
Then verify the capture is representative rather than a lucky frame:
export async function captureStability(viewer, tileset, view, { captures = 5 } = {}) {
const rows = [];
for (let i = 0; i < captures; i++) {
const { frame, context } = await captureAtView(viewer, tileset, view,
{ settleMs: 6000 });
const d = diagnose(frame, context);
rows.push({
capture: i,
drawCalls: d.summary.drawCalls,
triangles: d.summary.triangles,
textureUploads: d.summary.estimatedTextureGpuMB,
selectedTiles: context.selectedTiles,
});
}
const calls = rows.map((r) => r.drawCalls);
const spread = (Math.max(...calls) - Math.min(...calls)) / Math.max(...calls);
return {
rows,
drawCallSpread: Number(spread.toFixed(3)),
stable: spread < 0.05,
note: spread >= 0.05
? 'captures differ by more than 5% — the scene has not settled, or the '
+ 'camera moved between captures'
: 'captures are consistent; the numbers are representative',
};
}
A draw-call count that varies by more than a few percent across captures of the same view means the scene was not settled, and every number derived from it is provisional. Five captures is cheap and it converts a single measurement into a claim.
Performance Notes
- Spector.js slows the page substantially while spying. Capture, read the result, then reload without the extension; never measure frame times with it attached.
- A capture of 20,000 commands is a few megabytes of JSON. A frame with 2,800 draw calls generates roughly 15,000 commands, so the default limit is usually adequate; raise it if the capture truncates.
- Capture one frame, not ten. The interesting information is in a single frame’s command stream, and ten captures are ten times the JSON for the same finding.
- The browser’s own frame timing is the measurement; the capture is the explanation. Use both.
EXT_disjoint_timer_query_webgl2gives real GPU timings where the browser exposes it, which is the only way to attribute time to a specific draw call rather than counting calls.- Automate the capture in CI against a fixed view and assert on the draw-call count; it is a stable number and it regresses quietly.
Common Errors
The capture is empty. spyCanvases() was called after the context was created, or the canvas was obtained differently. Call it before the viewer initialises.
The capture is dominated by texImage2D and bufferData. The frame was captured while tiles were loading. Wait for tilesLoaded.
Draw-call count varies wildly between captures. The camera moved or the scene had not settled. Fix the view with setView and use the stability check.
capture.programs is empty. Some Spector versions put programs in initState. The shader report handles both.
Frame time is much worse than usual while profiling. Expected — the spy wraps every WebGL call. Detach before timing.
Everything looks fine and the viewer still stutters. The stutter is probably not in the render frame: check the loader, the decode workers, and garbage collection in the browser’s performance panel.
The instanced draw-call count is 0 on a tileset with instanced trees. The instancing extension was flattened by the content pipeline; see generating instanced tiles for trees.
Frequently Asked Questions
Spector.js or the browser’s WebGL inspector?
Spector.js gives the full command stream with state at each call, which is what the analysis above needs. Chrome’s own tooling is better for GPU timing and worse for command inspection, so they complement each other.
Can I capture from a headless browser in CI?
Yes, with the library rather than the extension: load spectorjs, capture on a fixed view, and assert on the draw-call count. It is a stable regression test and much cheaper than a visual one.
Does a high draw-call count always matter?
On desktop, up to a few hundred calls per frame is free. The threshold that matters is the device: a mid-range phone starts struggling at about 150, so a tileset intended for mobile needs an order of magnitude fewer calls than one for a workstation.
Related Guides
- Merging Meshes to Cut Draw Calls — the fix for the commonest finding
- Diagnosing Slow First Render of Tilesets — the loading path this capture deliberately excludes
- Debugging with the Cesium 3D Tiles Inspector — the tileset-level view of the same frame