Navigate
Search topics across all sections
Points & Particle Systems
A mesh renders triangles. Points render dots. Thousands of tiny dots dancing in 3D space become snow, rain, fire, stars, or confetti. Each particle is just a position, a size, and a color, but together they create effects that feel alive.
You create a particle system with 5,000 particles. It works great for 10 seconds, then the framerate drops to single digits. Your devtools show memory climbing every frame. The problem? You are creating new Float32Arrays inside useFrame instead of reusing a single buffer.
Performance degrades over time. Memory usage grows without limit. Browser eventually freezes.
Real-world
Imagine a jar of fireflies on a warm summer night. You unscrew the lid and hundreds of tiny glowing dots float out into the darkness.
Each firefly is just a point in space with a position (where it is), a size (how big it glows), and a color (warm yellow). They do not have complicated shapes or surfaces. They are just dots.
But together, hundreds of these simple dots create something magical. That is exactly what a particle system does. Instead of rendering complex meshes, you render thousands of lightweight points and animate their positions every frame.
How a Particle System Works
From a flat array of numbers to a cloud of glowing dots on screen.
Positions
Float32Array with x,y,z per particle
BufferGeometry
Attach positions as attribute
PointsMaterial
Size, color, blending, attenuation
useFrame
Animate positions + needsUpdate
Building a Particle System Step by Step
Let us create a floating particle cloud from scratch.
Step 1 -- Generate random positions
const count = 2000;
const positions = useMemo(() => {
const arr = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
arr[i * 3] = (Math.random() - 0.5) * 10; // x
arr[i * 3 + 1] = (Math.random() - 0.5) * 10; // y
arr[i * 3 + 2] = (Math.random() - 0.5) * 10; // z
}
return arr;
}, []);Each particle needs 3 numbers: x, y, z. For 2,000 particles that is 6,000 numbers in a single flat array. We wrap it in useMemo so it only runs once.
Step 2 -- Attach positions and render as Points
<points>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
array={positions}
count={count}
itemSize={3}
/>
</bufferGeometry>
<pointsMaterial
size={0.08}
color="#88ccff"
sizeAttenuation
transparent
opacity={0.8}
depthWrite={false}
blending={THREE.AdditiveBlending}
/>
</points>Instead of a mesh, we use a points element. The GPU renders each vertex as a tiny square billboard. AdditiveBlending makes overlapping particles glow brighter.
Step 3 -- Animate with useFrame
const pointsRef = useRef<THREE.Points>(null);
useFrame(() => {
const attr = pointsRef.current!.geometry
.attributes.position as THREE.BufferAttribute;
const arr = attr.array as Float32Array;
for (let i = 0; i < count; i++) {
arr[i * 3 + 1] += 0.005; // drift upward
if (arr[i * 3 + 1] > 5) arr[i * 3 + 1] = -5;
}
attr.needsUpdate = true;
});We modify the existing array in place — no allocations. Then we flag needsUpdate so the GPU re-reads the buffer. This is the critical performance pattern for particles.
What you just learned
Points render vertices as tiny camera-facing squares — one draw call for thousands of particles.
Particle positions live in a Float32Array with 3 values (x, y, z) per particle.
useFrame + needsUpdate lets you animate particles by mutating the buffer in place every frame.
depthWrite={false} and AdditiveBlending make particles layer and glow instead of occluding each other.
Question
A particle system with 10,000 particles renders in a single draw call. The same 10,000 objects as individual meshes would be 10,000 draw calls. What does this tell you about when to use Points versus meshes for visual effects?
Think about it...
You have a snow effect with 3,000 particles. After a few seconds, some particles disappear — they seem to be hidden behind other particles. What is the most likely fix?
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
Set count to 5000 — galaxy!
Try This!
Beginner
Set size to 0.5 — big snowflakes
Try This!
Beginner
Set speed to 0 — frozen in time
These are the patterns that trip up developers most often. Switch between Wrong and Fixed to compare the code side by side.
useFrame(() => {
// WRONG: Creates new arrays every frame!
const newPositions = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
newPositions[i * 3] = Math.random() * 10;
// ...
}
// This leaks memory and kills the GPU
});<pointsMaterial
size={0.1}
color="white"
transparent
opacity={0.8}
// depthWrite defaults to true!
/>
// Particles occlude each other in wrong order<pointsMaterial
size={5}
sizeAttenuation={false}
// Size is in pixels — no depth!
/>Best Practices
Pre-allocate buffers
Create your Float32Array once with the maximum particle count. Use setDrawRange to control how many are visible. Never allocate inside useFrame.
Use additive blending for glow
AdditiveBlending makes overlapping particles brighter instead of opaque, creating natural glow effects for fire, sparks, and magic.
Move heavy work to the GPU
For very large systems (100k+ particles), use a custom ShaderMaterial to animate positions on the GPU instead of looping through arrays in JavaScript.
Pool and recycle particles
Instead of creating and destroying particles, reset dead particles to new positions. Keep the buffer the same size and wrap positions around.