Optimizing Large Vertex Sets in Three.js with Indexed BufferGeometry and Interleaved Buffers
Learn how to cut GPU memory usage and improve draw‑call efficiency by combining indexed BufferGeometry with interleaved attribute buffers for big meshes.
15 May 2026, 11:57 UTC

The Problem: Too Many Vertices, Too Much Overhead
When a scene contains a dense mesh—such as a terrain built from a high‑resolution height map—the raw vertex count can climb into the hundreds of thousands. Storing each vertex’s position, normal, and UV in separate BufferAttribute arrays works, but the GPU must fetch data from three distinct memory regions for every vertex. This scattered access hurts cache locality and increases the amount of data sent from CPU to GPU each frame, especially if you update the height map dynamically.
Why Indexed BufferGeometry Helps
Three.js’ BufferGeometry already replaces the old Geometry class by keeping vertex data in typed arrays that map directly to GPU buffers. Adding an index buffer (setIndex) lets multiple triangles reuse the same vertex, cutting the number of stored positions (and normals/UVs) roughly in half for a regular grid. Fewer vertices mean smaller buffers, less bandwidth, and fewer vertex shader invocations.
Interleaved Buffers for Better Cache Locality
Instead of three separate BufferAttribute objects, you can store position, normal, and UV data back‑to‑back in a single typed array via an InterleavedBuffer. The GPU then reads a vertex’s attributes with a predictable stride, which improves cache hits during the vertex fetch stage. The trade‑off is a bit more setup code and the need to recalculate the stride whenever you change the attribute layout.
Worked Example: Building a Height‑Map Terrain
The following snippet creates a terrain from a two‑dimensional height array (heightData) using an indexed, interleaved BufferGeometry. Replace the placeholders with your actual data.
import * as THREE from 'three'; function createTerrain(width, height, heightData) { const geometry = new THREE.BufferGeometry(); const vertices = width * height; const indices = []; const interleaved = new Float32Array(vertices * 8); // 3 pos + 3 normal + 2 uv // ---- fill vertex attributes ------------------------------------------------- for (let z = 0; z < height; z++) { for (let x = 0; x < width; x++) { const i = (z * width + x) * 8; const vx = x - width / 2; const vz = z - height / 2; const vy = heightData[z][x]; // position interleaved[i] = vx; interleaved[i + 1] = vy; interleaved[i + 2] = vz; // normal (simplified: flat shading, replace with proper calculation) interleaved[i + 3] = 0; interleaved[i + 4] = 1; interleaved[i + 5] = 0; // uv interleaved[i + 6] = x / (width - 1); interleaved[i + 7] = z / (height - 1); } } // ---- build index list (each quad => two triangles) ------------------------ for (let z = 0; z < height - 1; z++) { for (let x = 0; x < width - 1; x++) { const topLeft = z * width + x; const topRight = topLeft + 1; const bottomLeft = (z + 1) * width + x; const bottomRight = bottomLeft + 1; indices.push(topLeft, bottomLeft, topRight); indices.push(topRight, bottomLeft, bottomRight); } } // ---- assign interleaved buffer -------------------------------------------- const interleavedBuffer = new THREE.InterleavedBuffer(interleaved, 8); // stride = 8 floats geometry.setAttribute('position', new THREE.InterleavedBufferAttribute(interleavedBuffer, 3, 0)); // offset 0 geometry.setAttribute('normal', new THREE.InterleavedBufferAttribute(interleavedBuffer, 3, 3)); // offset 3 geometry.setAttribute('uv', new THREE.InterleavedBufferAttribute(interleavedBuffer, 2, 6)); // offset 6 // ---- set index buffer ----------------------------------------------------- geometry.setIndex(indices); geometry.computeBoundingSphere(); return geometry; } // Usage (example placeholders) const width = 256; const height = 256; const heightData = Array.from({ length: height }, () => Array(width).fill(0)); const terrain = createTerrain(width, height, heightData); const material = new THREE.MeshStandardMaterial({ color: 0x88cc88 }); const mesh = new THREE.Mesh(terrain, material); scene.add(mesh); // To update heights dynamically: function updateHeights(newData) { const posAttr = mesh.geometry.getAttribute('position'); const interleavedBuf = posAttr.data; // InterleavedBuffer const array = interleavedBuf.array; // underlying Float32Array let idx = 0; for (let z = 0; z < height; z++) { for (let x = 0; x < width; x++) { array[idx + 1] = newData[z][x]; // update Y (position) only idx += 8; } } posAttr.needsUpdate = true; // notify GPU }Trade‑offs and Limitations
- Setup complexity: Building an interleaved buffer requires manual stride calculation and attribute offsets, which is more error‑prone than using three separate
BufferAttributeobjects. - Update cost: If you modify only one attribute (e.g., heights), you still need to write into the interleaved array and mark the whole attribute as needing an update. For very frequent per‑vertex changes, separate buffers might let you update a smaller slice.
- Index buffer overhead: For non‑regular meshes where vertex reuse is low, the index array can add memory without saving much.
When to Apply This Pattern
Use indexed, interleaved BufferGeometry when:
- The mesh is large and mostly static or changes infrequently.
- Vertex reuse is high (grids, height‑maps, parametric surfaces).
- You notice GPU‑side stalls or high memory usage in the browser’s performance tools.
If your geometry changes every frame or you need to stream disparate attributes, start with simple BufferAttribute objects and profile before adding interleaving.
Verification Steps
- After creating the geometry, open the console and inspect
geometry.attributes. You should see threeInterleavedBufferAttributeentries with the correctitemSize(3, 3, 2). - Compare memory:
geometry.index.array.lengthshould be roughly2 * (width‑1) * (height‑1) * 3(two triangles per quad). The position array length will bewidth * height * 3instead ofwidth * height * 3 * (vertices per triangle)when not indexed. - To test dynamic updates, call
updateHeightswith modified data and render a frame. The terrain should visually reflect the new heights; if it does not, check thatneedsUpdatewas set to true on the attribute you changed.
These checks let you confirm that the indexed, interleaved layout is active and that your update path correctly notifies the GPU.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.