Navigate
Search topics across all sections
Buffer Geometry
Under the hood, every shape in Three.js is just a list of numbers — positions of dots in 3D space that get connected into triangles. BufferGeometry is how those numbers get to the GPU, and understanding it gives you the power to create any shape imaginable.
You build a wave animation by modifying vertex positions every frame. The positions change in your JavaScript code — you can log them and see the new values. But on screen, the plane is completely frozen. The wave never moves. No errors anywhere.
Vertex positions update in JavaScript but the rendered mesh doesn't change. No console errors.
Real-world
Think of BufferGeometry like a connect-the-dots puzzle.
You place numbered dots in 3D space — those are your vertices. Each dot has a position (x, y, z).
Then you tell the computer which dots to connect: "Connect dot 0, dot 1, and dot 2 to make a triangle." That's the index.
The more dots you place and connect, the more complex your shape gets. A cube is 8 dots connected into 12 triangles. A smooth sphere is hundreds of dots carefully arranged on a round surface.
The key insight: all those dots and connections are stored as a flat list of numbers that gets uploaded to the GPU for ultra-fast rendering.
Numbers
Float32Array of positions
Attribute
Tells GPU: '3 numbers = 1 vertex'
GPU Buffer
Uploaded to video memory
Triangles
Connected and rendered!
Hands-On: Building a Triangle from Numbers
Let's build the simplest possible shape — a single triangle — from raw numbers. This is what every built-in geometry does under the hood.
Step 1: Place three dots in space
const positions = new Float32Array([
-1, -1, 0, // Dot 0: bottom-left
1, -1, 0, // Dot 1: bottom-right
0, 1, 0, // Dot 2: top-center
]);Nine numbers, three dots. Every three numbers is one vertex: [x, y, z]. We placed three dots in a triangle shape — bottom-left, bottom-right, and top-center. These are just numbers in memory right now — nothing is on screen yet.
Step 2: Tell the GPU how to read them
<mesh>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
array={positions}
count={3}
itemSize={3}
/>
</bufferGeometry>
<meshBasicMaterial color="hotpink" side={DoubleSide} />
</mesh>The itemSize of 3 tells the GPU: "every 3 numbers is one vertex." The count of 3 says "there are 3 vertices." We use meshBasicMaterial here because it doesn't need lighting — we haven't set up normals yet.
Step 3: Make it react to light
// Inside the bufferGeometry, after position:
const geo = useRef<BufferGeometry>(null!);
useEffect(() => {
geo.current.computeVertexNormals();
}, []);
// Now you can use meshStandardMaterial!
<meshStandardMaterial color="hotpink" />computeVertexNormals() calculates which direction each face points — this tells the lighting system how light bounces off the surface. Without normals, physically-based materials render as pure black. Think of normals as tiny arrows pointing "outward" from each surface.
What you just learned
BufferGeometry stores vertex data as flat arrays of numbers (Float32Array) that get uploaded directly to the GPU.
The 'itemSize' tells the GPU how to read the array: 3 for positions (x,y,z), 2 for UVs (u,v), 3 for normals (nx,ny,nz).
After modifying buffer data at runtime, you must set needsUpdate = true to tell the GPU to re-upload the data.
An index buffer lets you define vertices once and reuse them across triangles, saving memory for complex shapes.
Question
A square is made of 2 triangles that share an edge. Without an index buffer, you need 6 vertices (3 per triangle). With an index buffer, you only need 4 unique vertices. How much memory does this save if you have a terrain grid with 10,000 squares?
Think about it...
You modify vertex positions in your useFrame callback and the values change correctly when you log them. But the mesh on screen doesn't move. What did you forget?
Hint: The GPU has its own copy of the vertex data...
Try These Challenges
Put what you learned into practice. Try each challenge in the demo above using the Leva controls, then check the solution.
Try This!
Beginner
Change waveHeight to 0 — what do you see?
Try This!
Intermediate
Max out waveFrequency — what happens?
Try This!
Beginner
Toggle wireframe to see the vertex grid
These are the patterns that trip up developers most often. Switch between Wrong and Fixed to compare the code side by side.
useFrame(({ clock }) => {
const pos = geo.attributes.position;
for (let i = 0; i < pos.count; i++) {
pos.setY(i, Math.sin(pos.getX(i) + clock.elapsedTime));
}
// FORGOT: pos.needsUpdate = true;
// GPU still has the old data!
});// WRONG: UVs need itemSize 2, not 3!
const uvs = new Float32Array([0,0, 1,0, 0.5,1]);
geo.setAttribute('uv',
new BufferAttribute(uvs, 3) // Should be 2!
);// WRONG: Can't push to a Float32Array!
const positions = new Float32Array(9); // 3 verts
// Later...
// positions.push(1, 2, 3); // TypeError!Best Practices
Use Indexed Geometry
Always prefer indexed geometry for meshes with shared vertices. It reduces memory and improves performance by letting the GPU cache and reuse vertex computations.
Set needsUpdate Sparingly
Only set needsUpdate = true on frames where data actually changed. Re-uploading buffers to the GPU every frame is expensive. The flag auto-resets after upload.
Pre-Allocate for Dynamic Data
For particle systems or trails, allocate the maximum buffer size upfront and use setDrawRange to control how much is drawn. You cannot resize buffers after creation.
Use getX/setY Helper Methods
BufferAttribute provides getX(i), setY(i, val) etc. These are much clearer than raw array indexing (array[i * 3 + 1]) and handle interleaved buffers correctly.