Geometry and buffers: getting vertices into GPU memory
A Three.js mesh is, underneath the friendly BufferGeometry object, a set of plain typed arrays copied once into GPU buffers plus a description of how to slice that memory back into per-vertex attributes, and this lesson works through how that upload happens, why it should happen exactly once, and how a separate index buffer lets a shared vertex be stored once but drawn many times.
Geometry and buffers: getting vertices into GPU memory
The pipeline lesson said vertex attributes "sit in GPU memory" and left it there, as a given. The state machine lesson showed you bind a buffer and describe its layout with vertexAttribPointer, but treated the buffer's contents as already correct, already uploaded, already there. This lesson fills that gap: how does an array of numbers sitting in a JavaScript variable actually end up as GPU memory the vertex shader can read, and how does the GPU know which bytes mean "position" versus "normal" versus "which vertices form a triangle"?
The short version, which the rest of this lesson unpacks: a mesh is one or more flat typed arrays (Float32Array for attributes, an integer array for indices), copied once across the CPU→GPU boundary into buffer objects, plus a small layout description telling the GPU how to read those bytes back out per vertex.
Attributes start as flat typed arrays
Forget objects and classes for a moment. The rawest possible description of a triangle's positions is:
// Three vertices, x/y/z each — one flat array, not an array of {x,y,z} objects
const positions = new Float32Array([
0, 0.5, 0,
-0.5, -0.5, 0,
0.5, -0.5, 0,
]);A Float32Array is a typed array: a fixed-length, fixed-type block of raw bytes, not a general JavaScript array of boxed numbers. That matters because GPU memory is exactly this — raw bytes with a known layout, no object headers, no per-element type tags. A Float32Array is the JavaScript-side data structure that already looks like GPU memory, which is precisely why it's the thing you hand to WebGL rather than a plain array of numbers.
A real mesh has several such arrays side by side: positions (3 floats per vertex), often normals (3 floats — which way the surface faces, needed for lighting), often uvs (2 floats — where to sample a texture). Each is its own flat array, one entry (or triple, or pair) per vertex, in the same vertex order.
Uploading once: bufferData is a copy across a real boundary
Getting that array onto the GPU is a single call, already previewed in the state-machine lesson:
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);gl.createBuffer() allocates an opaque handle to a block of GPU memory — at this point it's empty. bufferData is the actual transfer: it copies the bytes of positions from your JavaScript array, across the bus, into that GPU-side allocation. This is a genuine boundary crossing, not a reference or a view — the GPU gets its own copy, and after this call, changes to the JavaScript positions array do nothing to what's on the GPU until you call bufferData (or a partial-update variant) again.
That third argument, gl.STATIC_DRAW, is a hint, not an enforced rule — it tells the driver "you're going to write this once and draw with it many times," which lets the driver choose a memory layout optimized for repeated reads rather than repeated writes. The companion hints are DYNAMIC_DRAW (uploaded repeatedly, e.g. per-frame vertex data you regenerate on the CPU) and STREAM_DRAW (uploaded once, used only a few times). For a static mesh — a piece of terrain, a character's base geometry — you want STATIC_DRAW, and you want the bufferData call to run exactly once, at load time, never inside your render loop.
Describing the layout: interleaved versus separate buffers
A buffer full of bytes has no self-describing structure — WebGL doesn't know it's "positions" until you tell it, via vertexAttribPointer (covered mechanically in the state-machine lesson), how many components per vertex, what type, and — the two arguments that matter here — stride and offset.
You have a choice about how attributes share memory:
Separate buffers. Positions live in one buffer, normals in another, UVs in a third. Simple to reason about, simple to update one attribute without touching the others, but the GPU has to read from three separate memory locations per vertex, which can be less cache-friendly.
Interleaved buffers. All of a vertex's attributes are packed together, vertex by vertex, in one buffer: [x,y,z, nx,ny,nz, u,v, x,y,z, nx,ny,nz, u,v, ...]. This is usually the more cache-friendly layout, because everything the vertex shader needs for one vertex sits contiguously in memory, read together in one pass, rather than scattered across three buffers.
Interleaving is where stride and offset stop being boilerplate zeros and start doing real work. Stride is "how many bytes to skip to get from this vertex's attribute to the next vertex's same attribute." Offset is "how many bytes into each vertex's chunk this particular attribute starts."
| Attribute | Components | Offset (bytes) | Stride (bytes) |
|---|---|---|---|
| position | 3 (x, y, z) | 0 | 32 |
| normal | 3 (nx, ny, nz) | 12 | 32 |
| uv | 2 (u, v) | 24 | 32 |
Each vertex here is 3 + 3 + 2 = 8 floats, 32 bytes. Position starts at byte 0 of each 32-byte chunk; normal starts 12 bytes in (past the three position floats); uv starts 24 bytes in (past position and normal). The stride, 32, is the same for all three because it describes the same repeating chunk — it's "how far to jump to reach the next vertex," which is constant regardless of which attribute you're reading. With separate (non-interleaved) buffers, stride is simply 0, meaning "tightly packed, nothing else in between," because each buffer holds only one attribute.
Indexed drawing: storing a shared vertex once
A cube has 8 geometric corners, but each corner is touched by 3 faces, and if each face needs its own normal at that corner (for flat shading) you might store that corner 3 times — once per face — with the same position but a different normal. Plenty of geometry, though, shares vertices identically: a flat-shaded quad made of two triangles has two corners that are exactly the same vertex, position and all, referenced by both triangles.
Storing that shared vertex twice wastes memory, and it wastes something more valuable: the vertex shader would run again on data that's identical to a vertex it already processed. Indexed drawing fixes this with a second buffer, the index buffer (also called the element array buffer), which doesn't hold attributes at all — it holds small integers that say "triangle 1 is vertices 0, 1, 2; triangle 2 is vertices 0, 2, 3," reusing vertex 0 and vertex 2 by reference instead of storing them again.
// 4 unique vertices for a quad, referenced by 2 triangles
const positions = new Float32Array([
-0.5, 0.5, 0, // 0: top-left
0.5, 0.5, 0, // 1: top-right
0.5, -0.5, 0, // 2: bottom-right
-0.5, -0.5, 0, // 3: bottom-left
]);
const indices = new Uint16Array([
0, 1, 2, // first triangle
0, 2, 3, // second triangle, reusing vertices 0 and 2
]);
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
// drawElements reads the currently bound ELEMENT_ARRAY_BUFFER
gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT, 0);Four positions instead of six, for a saving that only grows on denser meshes — a real character mesh might have each interior vertex shared by six or more triangles. The saving isn't just memory. Tie this back to the pipeline lesson: the vertex shader runs once per vertex the GPU decides to process, and with a well-ordered index buffer the GPU can recognize a just-processed vertex being referenced again nearby and reuse its already-computed output from a small cache instead of re-running the vertex shader on identical input. Fewer stored vertices and fewer redundant vertex-shader runs, from the same index buffer.
How Three.js's BufferGeometry maps onto exactly this
Once the raw mechanics are in view, THREE.BufferGeometry stops looking like a special 3D object and starts looking like a thin, typed wrapper over precisely the buffers-plus-layout picture above:
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array([
0, 0.5, 0,
-0.5, -0.5, 0,
0.5, -0.5, 0,
]);
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const indices = new Uint16Array([0, 1, 2]);
geometry.setIndex(new THREE.BufferAttribute(indices, 1));new THREE.BufferAttribute(positions, 3) is "here's a flat typed array, and here's how many components make up one vertex's worth" — exactly the two things vertexAttribPointer needs. setAttribute("position", ...) is what eventually becomes a gl.bindBuffer + gl.bufferData + vertexAttribPointer sequence, run once when the geometry is first drawn (or whenever you mark it dirty — more on that below). setIndex(...) is the index buffer: it becomes the ELEMENT_ARRAY_BUFFER upload, and it's what flips Three.js from calling gl.drawArrays to calling gl.drawElements under the hood. Nothing here is a new concept — it's the same VBO/IBO/layout mechanics from above, given a JavaScript-friendly constructor.
The performance rule: upload once, mutate deliberately
The rule this whole lesson has been building toward: build the typed arrays and upload them once, at load time, and never again for geometry that doesn't change shape frame to frame. If you do need to change vertex data after upload — animating a vertex's position on the CPU, say — you write into the existing typed array directly and then explicitly tell Three.js the GPU-side copy is stale:
geometry.attributes.position.array[0] += 0.01; // mutate in place
geometry.attributes.position.needsUpdate = true; // tell Three.js to re-uploadSetting needsUpdate = true is what triggers Three.js to issue a fresh bufferData call for just that attribute before the next draw, instead of silently trusting a stale GPU copy or — the wasteful alternative — re-uploading every attribute of every mesh every frame whether it changed or not. This is the JavaScript-facing edge of the exact CPU→GPU boundary from earlier: skip the flag and your CPU-side edit never reaches the GPU; set it every frame on geometry that never changes and you've reintroduced the per-frame upload cost this section exists to avoid.
Where this goes next
Buffers and indices describe what to draw and how it's laid out in memory, but nothing so far has covered the actual command that fires the GPU at this data, how many vertices or triangles that command processes, or why the number of times you call it, per frame, is itself a performance concern independent of buffer size. Draw calls picks up exactly there, building directly on the VBO/IBO picture from this lesson.
Go deeper
- WebGL Fundamentals — Attributes — Goes deeper on stride and offset with interactive diagrams, for the interleaved-versus-separate-buffer tradeoff this lesson introduces.
- MDN — WebGLRenderingContext.bufferData() — The authoritative reference on the STATIC_DRAW/DYNAMIC_DRAW/STREAM_DRAW usage hints and what each actually signals to the driver.
- Three.js manual — Geometry — Shows BufferGeometry and BufferAttribute from the library-user side, worth reading right after this lesson's buffers-first explanation.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- Why is a Float32Array, rather than a plain JavaScript array, the natural thing to hand to gl.bufferData?
- What actually happens when gl.bufferData runs — where do the bytes end up, and what happens on the GPU side if you later mutate the original JavaScript array without calling bufferData again?
- In the interleaved layout table, explain why the stride is the same 32 bytes for position, normal, and uv, while the offset differs for each.
- Walk through why a shared cube-corner vertex benefits from indexed drawing on both memory and vertex-shader-invocation grounds.
- Map THREE.BufferAttribute and geometry.setIndex onto the specific raw-WebGL calls each one becomes.
- Why is 'upload once, draw many times' the default performance rule for static geometry, and what is the STATIC_DRAW hint actually telling the driver?
- If you mutate geometry.attributes.position.array directly but forget needsUpdate = true, what does the GPU end up drawing, and why?