A draw call pushes vertices through a fixed sequence of stages. Two are programs you write in GLSL; the rest are fixed-function hardware that you only configure.

The vertex shader writes gl_Position in clip space. Primitive assembly builds points, lines or triangles and clips them; the divide by w and the viewport transform map them onto pixels. The rasterizer makes a fragment per covered pixel, interpolating the vertex shader's outputs, and the fragment shader colors it (or discards it) before depth, stencil and blending decide what reaches the framebuffer.
<!doctype html>
<style>
body { margin: 0; font: 12px system-ui, sans-serif; background: #f7f4ee; color: #333; }
.stages { display: flex; gap: 4px; max-width: 600px; margin-bottom: 6px; }
.stages div { flex: 1; padding: 5px 2px; text-align: center; border-radius: 4px; background: #e6e1d6; }
.stages .prog { background: #1f5f8b; color: #fff; }
canvas { display: block; width: 100%; max-width: 600px; }
p { margin: 4px 0; max-width: 600px; color: #555; }
</style>
<div class="stages">
<div class="prog">vertex shader</div><div>primitive assembly + clip</div>
<div>rasterizer</div><div class="prog">fragment shader</div><div>depth / blend → framebuffer</div>
</div>
<canvas id="view" width="600" height="280"></canvas>
<p>Left: the rasterizer makes one fragment per covered pixel of a 30×28 target. Right: the same draw at full resolution. Colours are the vertex shader's outputs, interpolated.</p>
<script>
const canvas = document.getElementById('view');
const gl = canvas.getContext('webgl2');
function compile(vsSource, fsSource) {
const p = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, vsSource], [gl.FRAGMENT_SHADER, fsSource]]) {
const s = gl.createShader(type);
gl.shaderSource(s, src.trim()); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
gl.attachShader(p, s);
}
gl.bindAttribLocation(p, 0, 'aPos');
gl.bindAttribLocation(p, 1, 'aColor');
gl.linkProgram(p);
return p;
}
// The vertex shader writes gl_Position in clip space and passes a colour on.
const triangle = compile(`#version 300 es
in vec2 aPos; in vec3 aColor; out vec3 vColor;
uniform float uTime;
void main() {
float c = cos(uTime), s = sin(uTime);
vColor = aColor;
gl_Position = vec4(mat2(c, s, -s, c) * aPos, 0.0, 1.0);
gl_PointSize = 7.0;
}`, `#version 300 es
precision mediump float; in vec3 vColor; out vec4 outColor;
void main() { outColor = vec4(vColor, 1.0); }`); // the fragment shader colours each fragment
// Shows the small render target with NEAREST filtering so each fragment is a visible block.
const show = compile(`#version 300 es
in vec2 aPos; out vec2 vUV;
void main() { vUV = aPos * 0.5 + 0.5; gl_Position = vec4(aPos, 0.0, 1.0); }`, `#version 300 es
precision mediump float; in vec2 vUV; uniform sampler2D uTex; out vec4 outColor;
void main() {
vec2 cell = fract(vUV * vec2(30.0, 28.0));
float gap = step(0.08, cell.x) * step(0.08, cell.y);
outColor = mix(vec4(0.85, 0.83, 0.78, 1.0), texture(uTex, vUV), gap);
}`);
const triVAO = gl.createVertexArray();
gl.bindVertexArray(triVAO);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
0.0, 0.8, 0.12, 0.37, 0.55, -0.75, -0.6, 0.87, 0.47, 0.13, 0.75, -0.5, 0.71, 0.27, 0.18]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 20, 0);
gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 20, 8);
const quadVAO = gl.createVertexArray();
gl.bindVertexArray(quadVAO);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, 1,1, -1,-1, 1,1, -1,1]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
// A tiny 30x28 render target: its pixels are the fragments we want to see.
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 30, 28, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
function drawTriangle(t, mode = gl.TRIANGLES) {
gl.useProgram(triangle);
gl.uniform1f(gl.getUniformLocation(triangle, 'uTime'), t);
gl.bindVertexArray(triVAO);
gl.drawArrays(mode, 0, 3);
}
function frame(ms) {
const t = ms * 0.0003;
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); // low-resolution pass
gl.viewport(0, 0, 30, 28);
gl.clearColor(1, 1, 1, 1); gl.clear(gl.COLOR_BUFFER_BIT);
drawTriangle(t);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.clearColor(0.97, 0.96, 0.93, 1); gl.clear(gl.COLOR_BUFFER_BIT);
gl.viewport(0, 0, 295, 280); // left: fragments as blocks
gl.useProgram(show); gl.bindVertexArray(quadVAO); gl.bindTexture(gl.TEXTURE_2D, tex);
gl.drawArrays(gl.TRIANGLES, 0, 6);
drawTriangle(t, gl.POINTS); // the three vertices on top
gl.viewport(305, 0, 295, 280); // right: full resolution
drawTriangle(t);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>