Understanding Three.js Buffers and Points by Creating a Tunnel
Stop using Meshes for particles! Learn how to use BufferGeometry and Points to create high-performance effects in React Three Fiber.
When you first start with React Three Fiber (R3F), it feels magical. You create a <boxGeometry>, put it inside a <mesh>, and suddenly you have a 3D cube.
But then you want to build something bigger. A starfield. A galaxy. A long, winding tunnel made of thousands of glowing dots.
Your first idea might be: "I need 2,000 particles, so I will just create a list of 2,000 <mesh> objects with tiny spheres."
Do not do this. Your framerate will tank.
Why is using many Meshes bad?
A Mesh is heavy. It is not just a simple dot on the screen.
- It calculates physics.
- It calculates how light hits its surface (reflections and shadows).
- It calculates its own rotation and scale matrices.
If you make 2,000 meshes, the computer has to do all these heavy calculations 2,000 times every single frame (60 times a second). This is called being "Draw Call heavy."
It is like asking 2,000 people to carry one brick each. It is slow and chaotic. We want one big truck to carry 2,000 bricks at once.
That "truck" is what we call Points and Buffers.
The Code: A Simple Particle Tunnel
We want to create a tunnel where particles form the walls. Here is the code. Don't worry if it looks difficult; we will explain it simply below.
import { useMemo } from "react";
import * as THREE from "three";
export default function Tunnel() {
// 1. Configuration
const count = 2000; // Number of particles
const radius = 2; // Width of the tunnel
const length = 40; // Length of the tunnel
// 2. The Math (Generating Positions)
// We use useMemo so this calculation only happens ONCE.
const particlesPosition = useMemo(() => {
// Create a Float32Array. We need 3 numbers (x,y,z) for every 1 particle.
const positions = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
// Index for the array (0, 3, 6, 9...)
const i3 = i * 3;
// MATH: Create the circle shape
// 1. Get a random angle around the circle
const angle = Math.random() * Math.PI * 2;
// 2. Add a little randomness to radius so walls look thick
const r = radius + Math.random() * 0.5;
// 3. Calculate X and Y (Position on the circle ring)
const x = Math.cos(angle) * r;
const y = Math.sin(angle) * r;
// 4. Calculate Z (Depth)
// Spread them randomly along the length.
const z = Math.random() * -length;
// Fill the array
positions[i3 + 0] = x;
positions[i3 + 1] = y;
positions[i3 + 2] = z;
}
return positions;
}, [count, radius, length]);
// 3. The Render
return (
<points>
{/* The Container: Holds the shape data */}
<bufferGeometry>
{/* The Data Bridge: Connects JS math to the GPU */}
<bufferAttribute
attach="attributes-position"
count={count}
array={particlesPosition}
itemSize={3}
/>
</bufferGeometry>
{/* The Look: Defines how dots are drawn */}
<pointsMaterial
size={0.05}
color="#ffffff"
sizeAttenuation={true} // Makes dots smaller if far away
depthWrite={false} // Prevents glitchy overlapping
/>
</points>
);
}
Part 1: The "Big Three" Concepts
To make this fast, we need three specific things working together.
1. The Object: <points>
This is the secret to high performance. Unlike a Mesh, <points> does not care about connecting dots to make a surface. It does not care about fancy lighting or physics.
It tells the computer: "Here is a list of 2,000 coordinates. Just draw one white square at each coordinate."
The computer treats all 2,000 dots as one single object. This makes it incredibly fast.
2. The Data Bridge: Float32Array & <bufferAttribute>
Why do we use Float32Array? Why not a normal Array?
A normal JavaScript Array [] is very flexible. It can hold text, numbers, and objects all mixed together. Because it is so flexible, it is slow for the computer to read.
Your Graphics Card (GPU) is like a calculator. It only understands pure numbers.
Float32Arrayis a "Typed Array." It forces every item to be a decimal number (a "float").- The "32" means 32-bits of memory. This is exactly the format the GPU speaks natively (C++ language).
By using Float32Array, we pack the data in a way the GPU can read instantly without translating it.
The BufferAttribute:
This is the label for the data. We have a long list of 6,000 numbers: [x, y, z, x, y, z, x, y, z...].
The prop itemSize={3} is the most important part. It tells the GPU: "Read this list in groups of 3. Every 3 numbers make 1 dot."
3. The Container: <bufferGeometry>
In older Three.js, we had "Geometry." Now we have "BufferGeometry." "Buffer" just means a block of memory. This component is the box that holds all your data streams.
- It holds the Position data (Where is the dot?).
- It can also hold Color data (What color is the dot?).
When we write attach="attributes-position", we are plugging our Float32Array into the "Position" slot of this box.
Part 2: The Math (Making the Circle)
How do we arrange the dots to look like a tunnel? If we just picked random X and Y numbers, we would get a square shape. To get a circle, we use Polar Coordinates.
Imagine a clock face:
- Angle: We pick a random angle (like pointing the hand of a clock).
- Radius: We decide how long the clock hand is.
- Math: We use
Math.cos()andMath.sin()to convert that "clock hand" position into X (horizontal) and Y (vertical) positions on the screen.
For the depth (Z-axis), we just pick a random number to push the dot far away into the screen.
Part 3: The Shipping Analogy
Why do we need all these complicated parts? Think of it like a shipping company. You need to move data from your CPU (JavaScript) to your GPU (Graphics Card).
- The Cargo (
Float32Array): This is your raw material. A pile of 6,000 screws. - The Manifest (
<bufferAttribute>): This is the instruction paper. It says "Group these screws into bags of 3." - The Container (
<bufferGeometry>): This is the big shipping container that holds the cargo and the instructions together so nothing gets lost.
If you miss one part, the shipment fails.
- Container without Attribute = Empty box.
- Attribute without Data = Instructions for nothing.
- Data without Attribute = A messy pile of numbers the GPU cannot read.
Part 4: What’s Next? Unlocking the Full Potential
Now that you understand the "Shipping Container" logic (Buffers), you have unlocked a superpower. You are no longer limited to the shapes Three.js gives you. You can build anything.
Here is what is possible now that you know this technique. Which one should we explore in the next blog?
1. The "Glow Up" with Shaders
Right now, our dots are just simple white squares using <pointsMaterial>.
But what if you want them to glow? Or fade out beautifully at the edges? Or change color based on how fast they are moving?
To do this, we swap the basic material for a Shader. Shaders allow you to program every single pixel individually. You can make the tunnel pulse with music or look like a hyperspace jump from Star Wars.
2. Changing the "Form" (Lines & Meshes)
In our code, we wrapped our bufferGeometry (our data) inside <points>.
But the data is just raw coordinates. We can wrap it in different things!
- Change
<points>to<line>: Instead of floating dust, the computer will draw lines between your points. This is how you create constellations, lightning bolts, or audio visualizers that vibrate with sound. - Change
<points>to<mesh>: If you connect the dots with triangles, you create solid surfaces. This is how video game terrain is made. You create a flat grid of points and use math to pull some up (mountains) and push some down (valleys).
3. Making it Move (Animation & Morphing)
Since the positions are just a list of numbers [x,y,z...], we can change those numbers every frame!
- Explosions: You can mathematically push all points outward from the center.
- Morphing: You can have two lists of data (one shaped like a Sphere, one shaped like a Cube) and tell the GPU to slowly slide the points from one shape to the other.