Each draw call costs CPU time in the browser, ANGLE 2,592 and the driver. drawArraysInstanced(mode, first, count, instances) draws the same vertices instances times in one call; the vertex shader tells copies apart with gl_InstanceID, and drawElementsInstanced() does the same for indexed meshes. WebGL 1 offered both through ANGLE_instanced_arrays, without gl_InstanceID. GPU Particles draws a 4-vertex strip 400 times in one call, with corners taken from gl_VertexID.
<!doctype html>
<style>
body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
canvas { display: block; width: 100%; max-width: 600px; }
p { max-width: 600px; margin: 4px 0; font-family: monospace; }
</style>
<canvas id="c" width="600" height="320"></canvas>
<p id="out"></p>
<script>
const gl = document.getElementById('c').getContext('webgl2');
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
uniform float uTime, uAspect;
out vec3 vColor;
float hash(float n) { return fract(sin(n * 12.9898) * 43758.5453); }
void main() {
// One box: 36 vertices from gl_VertexID (6 faces x 2 triangles).
int face = gl_VertexID / 6, corner = gl_VertexID % 6;
vec2 q = vec2[6](vec2(-1), vec2(1, -1), vec2(1), vec2(-1), vec2(1), vec2(-1, 1))[corner];
vec3 n = vec3[6](vec3(0,0,1), vec3(0,0,-1), vec3(1,0,0), vec3(-1,0,0), vec3(0,1,0), vec3(0,-1,0))[face];
vec3 u = vec3[6](vec3(1,0,0), vec3(-1,0,0), vec3(0,0,-1), vec3(0,0,1), vec3(1,0,0), vec3(1,0,0))[face];
vec3 p = (n + q.x * u + q.y * cross(n, u)) * 0.5;
// Which copy is this? gl_InstanceID picks a shelf, a slot and a random size and colour.
float id = float(gl_InstanceID);
float shelf = floor(id / 60.0), slot = mod(id, 60.0);
float height = 0.7 + 0.3 * hash(id), width = 0.6 + 0.4 * hash(id + 7.0);
vec3 size = vec3(0.014 * width, 0.08 * height, 0.05); // half extents
vec3 place = vec3(-0.93 + slot * 0.031, -0.9 + shelf * 0.19 + size.y, 0.0);
float tilt = hash(id + 3.0) > 0.93 ? 0.25 : 0.0; // a few books lean
p *= size * 2.0;
p.xy = mat2(cos(tilt), sin(tilt), -sin(tilt), cos(tilt)) * p.xy;
vec3 palette[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));
float light = 0.55 + 0.45 * max(dot(n, normalize(vec3(-0.3, 0.4, 0.9))), 0.0);
float glow = 0.5 + 0.5 * sin(uTime * 2.0 - slot * 0.2 + shelf); // a wave running along the shelves
vColor = palette[int(hash(id + 1.0) * 6.0)] * light * (0.8 + 0.3 * glow);
vec3 world = place + p;
float c = cos(0.25 * sin(uTime * 0.5)), s = sin(0.25 * sin(uTime * 0.5));
world.xz = mat2(c, s, -s, c) * world.xz; // the bookcase sways gently
gl_Position = vec4(world.x, world.y * uAspect, -world.z * 0.5, 1.0);
}`], [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; in vec3 vColor; out vec4 c; void main() { c = vec4(vColor, 1.0); }`]]) {
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(program, s);
}
gl.linkProgram(program); gl.useProgram(program);
gl.bindVertexArray(gl.createVertexArray()); // no buffers at all: IDs build everything
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
gl.uniform1f(gl.getUniformLocation(program, 'uAspect'), 1.0);
const BOOKS = 600;
document.getElementById('out').textContent = `drawArraysInstanced(TRIANGLES, 0, 36, ${BOOKS}): 1 draw call, ${36 * BOOKS} vertices`;
function frame(ms) {
gl.clearColor(0.35, 0.24, 0.15, 1); // the bookcase's back panel
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.uniform1f(gl.getUniformLocation(program, 'uTime'), ms / 1000);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, BOOKS); // 36 vertices, 600 times
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>