Batching Geometry to Cut WebGL State Changes
Your WebGL chart draws each mark with its own drawArrays call, the profiler shows the GPU mostly idle, and the frame time scales with the number of marks rather than their pixel area — the bottleneck is the thousands of state changes between draws, not the drawing itself.
Every useProgram, bindBuffer, and uniform call is a driver-level state change that stalls the command pipeline, and issuing one per mark buries the GPU in bookkeeping. This page is a companion within WebGL shader optimization: the cure is batching — pack many marks into one buffer and one draw call, and sort what remains so you switch program and texture as rarely as possible. It sits alongside reducing draw calls with instanced rendering: instancing is one batching technique for repeated geometry, while this page covers the broader discipline of minimising state churn across a whole scene.
Diagnostic checklist
Broken vs fixed
// ❌ BROKEN: bind and draw per mark; state churn dominates the frame.
function drawMarks(gl: WebGL2RenderingContext, marks: readonly Mark[]): void {
for (const m of marks) {
gl.useProgram(m.program); // driver stall every iteration
gl.bindBuffer(gl.ARRAY_BUFFER, m.buffer);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.uniform4fv(m.colorLoc, m.color); // uniform per mark
gl.drawArrays(gl.TRIANGLES, 0, 6); // one draw per mark → thousands of calls
}
}
// ✅ FIXED: sort by state, pack shared attributes, draw each batch once.
interface Mark { program: WebGLProgram; texture: WebGLTexture; x: number; y: number; r: number; g: number; b: number }
function drawBatched(gl: WebGL2RenderingContext, marks: readonly Mark[], buf: WebGLBuffer, loc: number): void {
// PERF: sort by the costliest state first (program), then texture, to minimise switches.
const sorted: Mark[] = [...marks].sort((a, b) =>
a.program === b.program ? textureId(a.texture) - textureId(b.texture) : programId(a.program) - programId(b.program),
);
let i = 0;
while (i < sorted.length) {
const program = sorted[i].program;
const texture = sorted[i].texture;
gl.useProgram(program); // set the expensive state ONCE per batch
gl.bindTexture(gl.TEXTURE_2D, texture);
// Collect every consecutive mark that shares this program+texture.
let j = i;
const packed: number[] = [];
while (j < sorted.length && sorted[j].program === program && sorted[j].texture === texture) {
const m = sorted[j];
packed.push(m.x, m.y, m.r, m.g, m.b); // interleave into one shared buffer
j++;
}
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(packed), gl.DYNAMIC_DRAW);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 20, 0);
gl.drawArrays(gl.POINTS, 0, j - i); // ONE draw for the whole batch
i = j;
}
// A11Y: batching changes GPU calls only; the accessible data table is unaffected.
}
declare function programId(p: WebGLProgram): number;
declare function textureId(t: WebGLTexture): number;
The lever is the cost hierarchy of state changes. Switching the active program with useProgram is the most expensive operation the driver handles between draws, followed by binding framebuffers and textures, then buffers, then setting uniforms. Issuing one draw per mark pays the top of that hierarchy thousands of times; sorting marks so that everything sharing a program is drawn consecutively means you pay useProgram once per distinct program instead of once per mark. Packing the per-mark attributes into a single interleaved buffer then lets one drawArrays emit the entire group, so the CPU-to-GPU command count collapses from thousands to a handful.
Step-by-step fix
function batchBoundaries(marks: readonly Mark[]): number[] {
const bounds: number[] = [0];
for (let k = 1; k < marks.length; k++) {
if (marks[k].program !== marks[k - 1].program || marks[k].texture !== marks[k - 1].texture) bounds.push(k);
}
bounds.push(marks.length);
return bounds; // one draw call per adjacent pair of boundaries
}
Verification
// The number of draw calls must equal the number of distinct program+texture groups.
const marks: Mark[] = buildScene();
const groups = new Set(marks.map((m) => `${programId(m.program)}:${textureId(m.texture)}`));
let drawCalls = 0;
const spy = (gl.drawArrays = ((orig) => function (this: unknown, ...a: unknown[]) { drawCalls++; return (orig as Function).apply(this, a); })(gl.drawArrays) as typeof gl.drawArrays);
drawBatched(gl, marks, buf, loc);
console.assert(drawCalls === groups.size, `expected ${groups.size} draws, issued ${drawCalls}`);
void spy;
Use the Spector.js extension or a WebGL trace to count calls in a frame: draw calls should equal the number of distinct program-and-texture combinations, and useProgram should fire once per program rather than once per mark. A frame time that stops tracking mark count and starts tracking covered pixels confirms the GPU, not the command stream, is now the limit.
Why state changes cost more than drawing, and when to instance
A GPU is a throughput machine: it wants long, uninterrupted streams of the same work. Each state change flushes or reconfigures the pipeline — the driver must validate the new program, rebind resources, and sometimes wait for in-flight commands to drain before the new state is safe to apply. That synchronisation is why a scene of a thousand tiny marks drawn individually can be slower than a single mark covering the whole screen: the pixels are cheap, the thousand context switches are not. Batching removes the switches, so the GPU gets the long homogeneous streams it is built for.
Instanced rendering is the sharpest batching tool when every mark shares the same geometry — a quad, a circle, a glyph — and differs only in per-instance attributes like position and colour. There you upload the geometry once and a per-instance attribute buffer, then draw all instances in a single call, which is even leaner than packing vertices because the shared vertices are never duplicated. The detailed mechanics are in reducing draw calls with instanced rendering; reach for it when geometry repeats, and for the sort-and-pack approach here when marks vary in shape but still share programs and textures you can group.
Edge cases & gotchas
- Transparency and sort order. Sorting by program breaks back-to-front ordering that alpha blending needs; draw opaque batches sorted by state first, then translucent marks sorted by depth, accepting a few extra switches for correct blending.
- Uniform vs attribute. A value that varies per mark cannot be a uniform inside a batch; move it into the interleaved attribute buffer or it takes the whole batch’s single value.
- Rebuilding buffers every frame.
bufferDataper batch per frame re-uploads everything; for static marks upload once and only the changed range withbufferSubData. - Texture atlases. Many small textures force a bind per texture; pack them into an atlas so marks that differ only by image still share one texture and batch together.
- Over-sorting cost. Sorting thousands of marks every frame has its own O(n log n) cost; if the draw order is stable, sort once and re-sort only when the set changes.
- Attribute stride mistakes. An interleaved buffer needs the correct byte stride and offset in
vertexAttribPointer; a wrong stride reads colour as position and scrambles the batch silently.
Frequently Asked Questions
Why is my WebGL chart slow when the GPU looks idle?
Because the bottleneck is CPU-side state changes and draw-call submission, not GPU fill rate. Issuing useProgram, bindBuffer, and a draw per mark floods the driver with synchronisation work that stalls the command pipeline while the GPU waits for instructions. Batching marks into few draws lets the GPU run the long homogeneous streams it is designed for, so utilisation climbs and frame time drops.
What is the right order to sort marks in for batching?
Sort by the most expensive state change first. useProgram is costliest, so group by program, then by texture or framebuffer, then rely on shared buffers and uniforms within each group. This ordering minimises the number of times the priciest state switches, which is what dominates the frame when you are draw-call bound.
When should I use instancing instead of packing vertices?
Use instancing when every mark shares identical geometry and differs only in per-instance data like position, colour, or size — you upload the geometry once and draw all instances in a single call, avoiding duplicated vertices entirely. Use the sort-and-pack approach when marks vary in shape but still share programs and textures you can group into batches. They combine: instance within a group, batch across groups.
Related
- WebGL Shader Optimization — the parent guide to GPU-side performance.
- Reduce Draw Calls with Instanced Rendering — the instancing technique for repeated geometry.
- Custom GLSL Shaders for Scatter Plots — the shader that draws each batched point.