WebGL: the state machine under Three.js
WebGL is not a library of "draw this" functions but a big configurable state machine — you bind buffers into slots, attach a compiled shader program, describe attributes and uniforms, and only then issue a draw call that silently operates on whatever happens to be currently bound, which is the single mental shift that makes raw WebGL legible and explains why Three.js exists.
WebGL: the state machine under Three.js
The pipeline lesson described a fixed sequence of stages — vertex buffers, vertex shader, rasterization, fragment shader, per-fragment tests — that runs to turn triangles into pixels. WebGL is the browser API that actually drives that pipeline. If you have never looked at raw WebGL code, you'd be forgiven for expecting something like drawTriangle(vertices, color). It is nothing like that, and the gap between what you'd expect and what it actually is explains almost everything about why Three.js exists and why it looks the way it does.
WebGL has no function that takes "a triangle and a color" as arguments and draws it. Instead, WebGL is a state machine: a single object (gl) holding a large amount of current state — which buffer is active, which shader program is active, what each vertex attribute means, what every uniform is set to, whether depth testing is on. You spend almost all of your code mutating that state, one piece at a time, and then call a draw function that takes no data at all — it just says "go," and the GPU executes using whatever is currently configured. Understanding that split, between setting state and triggering a draw that reads state, is the one idea this whole lesson is built on.
The bind model: the mental shift that unlocks everything else
Here is the pattern that trips up almost everyone the first time: functions that look like they take a buffer as an argument usually don't. Consider:
gl.bindBuffer(gl.ARRAY_BUFFER, myBuffer);You might read this as "operate on myBuffer." That is not what it does. It binds myBuffer into a slot — here, the slot named gl.ARRAY_BUFFER — inside the gl object's current state. From this point on, any other call that mentions gl.ARRAY_BUFFER (not the buffer itself) acts on whatever is currently sitting in that slot:
gl.bindBuffer(gl.ARRAY_BUFFER, myBuffer);
gl.bufferData(gl.ARRAY_BUFFER, someFloat32Array, gl.STATIC_DRAW);bufferData never mentions myBuffer by name. It says "upload this data into whatever is bound to ARRAY_BUFFER right now," and that happens to be myBuffer because of the line above. If you bound a different buffer in between — even for an unrelated reason, in unrelated code — bufferData would silently write into the wrong buffer, with no error, because as far as WebGL is concerned there is no "wrong" buffer, only "the currently bound one."
This is the core mental model of the entire API: almost nothing is passed as an argument to the function that uses it. Data goes into a slot first, via a bind call, and every subsequent call implicitly reads from whatever's in that slot. Buffers bind to targets (ARRAY_BUFFER, ELEMENT_ARRAY_BUFFER), textures bind to texture units, a shader program becomes "current" via gl.useProgram. WebGL is, structurally, a pile of named slots plus functions that read and write whatever is currently sitting in them.
The program: a shader pair is also just current state
A shader program is the vertex shader and fragment shader from the pipeline lesson, compiled and linked together. Getting one onto the GPU is a small ceremony:
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertexShaderSource);
gl.compileShader(vertexShader);
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragmentShaderSource);
gl.compileShader(fragmentShader);
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);Each shader is compiled from its source text independently, then both are attached to a program object and linked — linking is where the GPU checks that the vertex shader's outputs and the fragment shader's inputs actually line up, and produces one runnable unit out of the two separate programs. Compiling and linking happen once, ahead of time, not per frame.
Making that program active for drawing is, unsurprisingly, another bind-style call:
gl.useProgram(program);There is no slot named "program" that you pass around explicitly elsewhere — useProgram sets the current program, exactly like bindBuffer sets the current buffer for a target. Every draw call after this line runs program's shaders, until something calls useProgram again with a different program. The program is state, the same as everything else.
Attributes: wiring bound bytes to a shader input
A buffer bound to ARRAY_BUFFER is just raw bytes to WebGL — it has no idea those bytes are meant to be vec3 positions rather than, say, colors or normals. You have to describe the layout explicitly, once, per attribute:
const positionLoc = gl.getAttribLocation(program, "position");
gl.enableVertexAttribArray(positionLoc);
gl.vertexAttribPointer(
positionLoc, // which shader input this feeds
3, // components per vertex (x, y, z)
gl.FLOAT, // type of each component
false, // normalize? (no, already floats)
0, // stride: 0 means "tightly packed"
0, // offset into the buffer, in bytes
);getAttribLocation asks the linked program where its position input lives (an integer slot the linker assigned). vertexAttribPointer is the layout description: it says "starting at this byte offset, read 3 floats per vertex, tightly packed, and feed them to attribute slot positionLoc" — and critically, it reads from whatever buffer is currently bound to ARRAY_BUFFER at the moment you call it. That's the bind model again: vertexAttribPointer doesn't take a buffer argument; it captures a reference to whatever's currently in the ARRAY_BUFFER slot. Get the bind order wrong — describe the pointer before binding the right buffer — and you've wired the shader input to the wrong data, again with no error. (Lesson 3 covers stride and offset in depth, including interleaved attributes.)
Uniforms: per-draw constants set onto the current program
Where an attribute varies per vertex, a uniform is a value that's constant for an entire draw call — a transform matrix, a color, a light position. Uniforms are set directly onto whichever program is currently active:
const mvpLoc = gl.getUniformLocation(program, "modelViewProjection");
gl.uniformMatrix4fv(mvpLoc, false, mvpMatrixArray);Same shape as everything else: getUniformLocation asks the current program where a named uniform lives, and gl.uniform* (there's a whole family — uniform1f, uniform3fv, uniformMatrix4fv, and more, one per data shape) writes a value into that slot on the currently-bound program. Set it once per draw, before that draw's drawArrays/drawElements call, and it holds until you change it again or switch programs.
The full sequence to draw one triangle
Put all of that together and here is genuinely everything needed to get one flat-colored triangle on screen in raw WebGL, assuming the shaders from the pipeline lesson:
// One-time setup: compile + link the program (shown above), create a buffer
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
0, 0.5, 0, -0.5, -0.5, 0, 0.5, -0.5, 0,
]), gl.STATIC_DRAW);
// Per-frame (or per-object) drawing sequence:
gl.useProgram(program); // 1. make this program current
gl.bindBuffer(gl.ARRAY_BUFFER, buffer); // 2. bind the vertex data
const positionLoc = gl.getAttribLocation(program, "position");
gl.enableVertexAttribArray(positionLoc);
gl.vertexAttribPointer(positionLoc, 3, gl.FLOAT, false, 0, 0); // 3. describe layout
const mvpLoc = gl.getUniformLocation(program, "modelViewProjection");
gl.uniformMatrix4fv(mvpLoc, false, mvpMatrixArray); // 4. set per-draw constants
gl.drawArrays(gl.TRIANGLES, 0, 3); // 5. go — read all the above stateNotice the last line: drawArrays takes a primitive type, a start index, and a vertex count. It does not take a buffer, a program, or a matrix. Every one of those was set as state in the four steps before it, and the draw call just triggers the GPU to run the pipeline against whatever is currently configured. That's the whole API in miniature — set state, bind data into slots, then issue a stateless "go."
Why the API is shaped like this
This isn't an accident of bad design — it mirrors the hardware. A GPU genuinely has a fixed set of internal registers and slots for "what buffer am I reading vertices from," "what program am I running," "what texture is bound to unit 0." WebGL's bind-then-draw shape is a fairly thin wrapper over that reality; it doesn't hide the state, it exposes it directly as JavaScript calls. WebGL itself is derived from OpenGL ES (the embedded-systems flavor of OpenGL, adapted for the web) — and OpenGL's state-machine design dates back decades, to when graphics hardware really was just banks of configurable registers with a "go" trigger. The verbosity isn't WebGL being unfriendly; it's a fairly direct reflection of the hardware model, plus a JavaScript binding.
The payoff: what Three.js is actually managing
Everything above — creating buffers, binding them into slots, compiling and linking programs, looking up attribute and uniform locations, describing layouts, remembering to rebind the right things in the right order before every draw — is bookkeeping that has to happen correctly and in order for every single mesh, every single frame. Get one bind call out of order and you get a silent wrong result, not an exception.
Three.js's Renderer, Mesh, Material, and BufferGeometry classes are, mechanically, a manager for exactly this state. When you write:
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const mesh = new THREE.Mesh(geometry, material);
renderer.render(scene, camera);Three.js is tracking, per mesh, which buffer needs to be bound, which program needs to be current, which attribute locations correspond to which BufferAttributes, and which uniforms need updating — and it issues all the raw gl.bindBuffer / gl.useProgram / gl.vertexAttribPointer / gl.uniform* / gl.drawArrays calls from the section above, in the right order, on your behalf, for every object, every frame. That's why three lines of Three.js can replace roughly forty lines of raw WebGL: the forty lines never went away, they just moved into a library that got them right once so you don't have to get them right every time.
Where this goes next
This lesson treated a vertex buffer as something that just exists, already bound, full of the right numbers. The next lesson backs up one step further: how do those numbers — positions, normals, UVs — actually get from a JavaScript array into GPU memory in the first place, and how does the GPU know how to slice that memory back into per-vertex attributes? Geometry and buffers: getting vertices into GPU memory covers exactly that.
Go deeper
- WebGL Fundamentals — WebGL Fundamentals — The canonical explanation of WebGL as a rasterization engine driven by bound buffers, programs, and attributes — the same bind model this lesson walks through, with runnable examples.
- WebGL Fundamentals — How WebGL Works — Goes deeper on attributes, uniforms, and varyings as the three ways data crosses into a shader, with diagrams of the binding points this lesson describes in prose.
- MDN — Getting started with WebGL — MDN's own walkthrough of the exact bind → describe → draw sequence, useful as a second worked example against this lesson's.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- Explain in your own words why gl.bindBuffer(gl.ARRAY_BUFFER, buf) is not 'operate on buf' — what does it actually do, and what reads the result?
- Walk through the bind-to-a-slot bug in the Callout: why does object B render with object A's texture, and why is there no error?
- What does linking a program actually check, and why does compiling the vertex and fragment shaders separately not already guarantee they work together?
- What does gl.vertexAttribPointer describe, and which currently-bound object does it silently read from when you call it?
- How is a uniform different from an attribute in terms of how often its value changes, and where does gl.uniform* write its value to?
- List, in order, the five kinds of calls in the 'full sequence to draw one triangle' and say which ones are 'setting state' versus the one that's 'triggering a draw.'
- In one sentence, explain what Three.js's Mesh/Material/BufferGeometry classes are actually managing under the hood, and why that explains the '3 lines replace 40' claim.