Navigate

Search topics across all sections

GitHub
Particles & Lines

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.

terminal
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

Particles.tsxTSX
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

Particles.tsxTSX
<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

Particles.tsxTSX
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.

1
Creating new geometry every frame
Allocating thousands of objects 60 times per second
Don't do this
Particles.tsxTSX
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
});
Allocating new Float32Arrays every frame causes massive garbage collection pauses and GPU re-uploads. Pre-allocate the buffer once and mutate the existing array in place, then flag needsUpdate.
2
Particles disappear behind other objects
depthWrite is still true on the particle material
Don't do this
Particles.tsxTSX
<pointsMaterial
  size={0.1}
  color="white"
  transparent
  opacity={0.8}
  // depthWrite defaults to true!
/>
// Particles occlude each other in wrong order
When depthWrite is true, particles write to the depth buffer, preventing particles behind them from rendering. Set depthWrite={false} so all particles are visible. Additive blending makes overlapping particles glow brighter instead of occluding.
3
Forgetting sizeAttenuation
Particles are the same size regardless of distance
Don't do this
Particles.tsxTSX
<pointsMaterial
  size={5}
  sizeAttenuation={false}
  // Size is in pixels — no depth!
/>
With sizeAttenuation={false}, size is in screen pixels and particles look flat. With sizeAttenuation={true} (the default), size is in world units and particles shrink with distance like real objects. Much more convincing.

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.