How to Program a Simple Particle System

By Shane Brumback · · Free Three.js tutorial

To program a simple particle system in Three.js, put thousands of random positions in one BufferGeometry, draw them as THREE.Points with a soft round glow texture and AdditiveBlending, then rotate the whole cloud a little every frame. A small shader adds per-particle size, drift, twinkle and distance fog.

Introduction: What Are We Building?

Short answer: a simple particle system in Three.js is one BufferGeometry full of random particle positions, drawn as THREE.Points with one material. Rotate the whole object a little every frame and the cloud floats. That is the whole idea, and it scales to thousands of particles in a single draw call.

In this free tutorial we build a slowly rotating 3D cloud of 5,000 soft, glowing particles that fills the screen, the same floating-particle effect as the original version of this page, rebuilt to look professional: round glow sprites instead of squares, a cyan and blue palette, gentle drift and twinkle, distance fog and mouse parallax. It is the foundation behind star fields, dust, snow and magical website backgrounds. When you want particles that move on their own paths, the fire fountain particle system tutorial adds velocity and gravity.

A few words you will see, explained once:

  • Particle system: many tiny points that together form an effect like smoke, sparks or rain.
  • BufferGeometry: Three.js's container for raw vertex data in typed arrays.
  • Shader: a small program that runs on the graphics card (GPU) for every particle and pixel.
  • Uniform: a value JavaScript passes to the shader, the same for every particle.
  • Additive blending: overlapping colors add together, so busy areas glow brighter.
  • Draw call: one request from the CPU to the GPU to draw something. Fewer is faster.

Example Code & Demo

The glowing particles behind this page are the demo itself. Press Launch on the full code below to open it full screen in a new tab, move your mouse for parallax and use Stop Animation to freeze it.

▼ Jump to the Full Code

Steps to Programming Your First Particle System with Three.js

The whole demo is one HTML file. It loads Three.js from a CDN with an import map:

<script type="importmap">
{
  "imports": {
    "three": "https://unpkg.com/three@0.160.0/build/three.module.js"
  }
}
</script>

Step 1: Set Up the Scene, Camera and Renderer

In plain words: we make an empty 3D world, a camera to look at it, and a canvas that draws it.

The scene holds everything, the camera decides what we see, and the renderer draws the picture onto a canvas. Exponential fog fades far particles into the dark background, which gives the cloud real depth. The pixel ratio is capped at 2 so high-density phones don't render four times more pixels than they need.

// ---------- Step 1: Scene, camera and renderer ----------
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x02040a, 0.09);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.z = 5;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // cap for phones
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0x02040a);
document.body.appendChild(renderer.domElement);

Step 2: Make a Soft, Round Glow Sprite

In plain words: we paint a tiny glowing dot once, so particles look like soft lights instead of hard squares.

Points are squares by default. Here a 64x64 canvas is filled with a radial gradient (bright center, transparent edge) and turned into a CanvasTexture. No image file to download, and every particle samples this one texture.

// ---------- Step 2: A soft, round glow sprite ----------
function makeGlowTexture() {
  const c = document.createElement('canvas');
  c.width = c.height = 64;
  const g = c.getContext('2d');
  const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32);
  grad.addColorStop(0.0, 'rgba(255,255,255,1)');
  grad.addColorStop(0.2, 'rgba(255,255,255,0.8)');
  grad.addColorStop(0.5, 'rgba(255,255,255,0.2)');
  grad.addColorStop(1.0, 'rgba(255,255,255,0)');
  g.fillStyle = grad;
  g.fillRect(0, 0, 64, 64);
  return new THREE.CanvasTexture(c);
}
const glowTexture = makeGlowTexture();

Step 3: Create a BufferGeometry Full of Random Particles

In plain words: we scatter thousands of particles at random inside a box and give each one its own color, size and timing.

This is the same idea as the original version of this tutorial: random x, y and z positions between -5 and 5. A BufferGeometry stores them in typed arrays the graphics card (the GPU) reads directly, so the whole cloud is one object and one draw call. Each particle also gets a color picked from a cyan and blue palette, a random size and a random phase (a starting offset so particles don't all twinkle in sync). Phones get half the particles.

// ---------- Step 3: Fill a cube with random particles ----------
const isMobile = window.innerWidth < 768;
const PARTICLE_COUNT = isMobile ? 2500 : 5000;
const positions = new Float32Array(PARTICLE_COUNT * 3);
const colors = new Float32Array(PARTICLE_COUNT * 3);
const sizes = new Float32Array(PARTICLE_COUNT);
const phases = new Float32Array(PARTICLE_COUNT);
const palette = [new THREE.Color(0x2ad1d6), new THREE.Color(0x3b82f6), new THREE.Color(0x8be9fd), new THREE.Color(0xffffff)];
const tmp = new THREE.Color();
for (let i = 0; i < PARTICLE_COUNT; i++) {
  positions[i * 3]     = Math.random() * 10 - 5;
  positions[i * 3 + 1] = Math.random() * 10 - 5;
  positions[i * 3 + 2] = Math.random() * 10 - 5;
  tmp.copy(palette[Math.floor(Math.random() * palette.length)]).offsetHSL(0, 0, (Math.random() - 0.5) * 0.1);
  colors[i * 3] = tmp.r; colors[i * 3 + 1] = tmp.g; colors[i * 3 + 2] = tmp.b;
  sizes[i] = 0.4 + Math.random() * 1.2;
  phases[i] = Math.random() * Math.PI * 2;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geometry.setAttribute('aSize', new THREE.BufferAttribute(sizes, 1));
geometry.setAttribute('aPhase', new THREE.BufferAttribute(phases, 1));

Step 4: Add a Glowing Material with Drift, Twinkle and Depth Fade

In plain words: a small program on the GPU draws each particle as a soft glowing dot that drifts, twinkles and fades with distance.

A shader is a tiny program that runs on the GPU for every particle and pixel. The vertex shader nudges each particle in a slow circle (drift), makes closer particles bigger (size attenuation) and computes a twinkle value. The fragment shader samples the glow texture and fades far particles into the fog color. THREE.AdditiveBlending adds overlapping light together so dense areas glow, and depthWrite: false stops particles from cutting holes in each other.

// ---------- Step 4: A glowing material with drift, twinkle and depth fade ----------
const material = new THREE.ShaderMaterial({
  uniforms: {
    uTime: { value: 0 },
    uMap: { value: glowTexture },
    uScale: { value: renderer.getPixelRatio() * 60.0 },
    uFogColor: { value: scene.fog.color },
    uFogDensity: { value: scene.fog.density }
  },
  vertexShader: `
    attribute float aSize;
    attribute float aPhase;
    uniform float uTime;
    uniform float uScale;
    varying vec3 vColor;
    varying float vTwinkle;
    varying float vDepth;
    void main() {
      vec3 p = position;
      p.x += sin(uTime * 0.3 + aPhase) * 0.15;
      p.y += cos(uTime * 0.25 + aPhase * 1.3) * 0.15;
      vec4 mv = modelViewMatrix * vec4(p, 1.0);
      vTwinkle = 0.6 + 0.4 * sin(uTime * 2.0 + aPhase * 3.0);
      vColor = color;
      vDepth = -mv.z;
      gl_PointSize = aSize * uScale / -mv.z;
      gl_Position = projectionMatrix * mv;
    }
  `,
  fragmentShader: `
    uniform sampler2D uMap;
    uniform vec3 uFogColor;
    uniform float uFogDensity;
    varying vec3 vColor;
    varying float vTwinkle;
    varying float vDepth;
    void main() {
      float a = texture2D(uMap, gl_PointCoord).a;
      float fog = exp(-uFogDensity * uFogDensity * vDepth * vDepth * 6.0);
      gl_FragColor = vec4(vColor * vTwinkle * fog, a * fog);
    }
  `,
  vertexColors: true,
  transparent: true,
  depthWrite: false,
  blending: THREE.AdditiveBlending
});
const particleSystem = new THREE.Points(geometry, material);
scene.add(particleSystem);

Step 5: Rotate the Cloud Smoothly and Add Mouse Parallax

In plain words: each frame we turn the whole cloud a tiny bit and let the camera lean toward the mouse.

Rotating the Points object turns every particle at once, exactly like the original demo, but here the speed is multiplied by the frame time (dt) so it's equally smooth at 30, 60 or 120 frames per second. The camera eases toward the mouse position for a gentle 3D parallax, and setAnimationLoop runs the loop once per screen refresh.

// ---------- Step 5: Smooth rotation and mouse parallax ----------
const mouse = new THREE.Vector2();
window.addEventListener('pointermove', (e) => {
  mouse.x = e.clientX / window.innerWidth - 0.5;
  mouse.y = e.clientY / window.innerHeight - 0.5;
});
const clock = new THREE.Clock();
let isAnimating = true;
let elapsed = 0;
function animate() {
  const dt = Math.min(clock.getDelta(), 0.05);
  if (isAnimating) {
    elapsed += dt;
    particleSystem.rotation.x += dt * 0.05;
    particleSystem.rotation.y += dt * 0.08;
    material.uniforms.uTime.value = elapsed;
  }
  camera.position.x += (mouse.x * 1.5 - camera.position.x) * 0.05;
  camera.position.y += (-mouse.y * 1.5 - camera.position.y) * 0.05;
  camera.lookAt(scene.position);
  renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);

Step 6: Add a Stop/Start Button and Handle Window Resize

In plain words: one button pauses or resumes the motion, and the view adapts when the window changes size.

Stop Animation freezes rotation, drift and twinkle while the scene keeps rendering, so parallax still works. The resize handler updates the camera's aspect ratio, the renderer size and the particle scale so particles stay the same size on screen.

// ---------- Step 6: Stop/Start button and window resize ----------
const toggle = document.getElementById('toggle');
toggle.addEventListener('click', () => {
  isAnimating = !isAnimating;
  toggle.textContent = isAnimating ? 'Stop Animation' : 'Start Animation';
});
window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
  material.uniforms.uScale.value = renderer.getPixelRatio() * 60.0;
});

The Power of Three.js Particle Systems: Elevating Website Interactivity

Particle systems turn a flat page into something that feels alive. Three.js draws thousands of particles in real time inside any modern browser, with no plugin, so the same technique works for a hero background, a product reveal or the muzzle flash in a game.

1. Real-Time Particle Generation and Manipulation

Every particle in this demo is drawn, drifted and twinkled 60 times a second, and the whole cloud is still one draw call. Because the data lives in typed arrays, changes are a one-number edit: raise PARTICLE_COUNT for a denser cloud, widen the * 10 - 5 range for a bigger one, or slow the rotation for a calmer background.

2. Powerful Animation Capabilities

The shader animates drift, twinkle and size for every particle with no extra JavaScript work, and additive blending makes overlaps glow. The result looks like a professional visual effect while staying simple enough to read in one sitting.

Simple Particle System vs. Fire Fountain: Which Should You Start With?

This simple particle systemFire fountain tutorial
Goallearn the core loopa full lava VFX
Particles5,000 in one THREE.Pointsblobs, embers, smoke, crust
Motionwhole cloud rotates, particles driftvelocity, gravity, recycling
Draw calls1about 8
Best forfirst particle system, backgroundsgame-ready effects

The Full Code

Copy this into a file named index.html and open it in any modern browser, or hit Launch. Three.js loads from a CDN, so there is nothing to install.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Simple Particle System - Three.js</title>
<style>
  html, body { margin: 0; height: 100%; overflow: hidden; background: #02040a; }
  canvas { display: block; }
  #ui { position: fixed; left: 12px; bottom: 12px; display: flex; gap: 8px; font-family: Arial, sans-serif; }
  #ui button { padding: 10px 16px; border-radius: 8px; border: 1px solid #2ad1d6; background: rgba(0,0,0,0.6); color: #fff; cursor: pointer; }
</style>
<script type="importmap">
{
  "imports": {
    "three": "https://unpkg.com/three@0.160.0/build/three.module.js"
  }
}
</script>
</head>
<body>
<div id="ui"><button id="toggle">Stop Animation</button></div>
<script type="module">
import * as THREE from 'three';

// ---------- Step 1: Scene, camera and renderer ----------
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x02040a, 0.09);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.z = 5;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // cap for phones
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0x02040a);
document.body.appendChild(renderer.domElement);

// ---------- Step 2: A soft, round glow sprite ----------
function makeGlowTexture() {
  const c = document.createElement('canvas');
  c.width = c.height = 64;
  const g = c.getContext('2d');
  const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32);
  grad.addColorStop(0.0, 'rgba(255,255,255,1)');
  grad.addColorStop(0.2, 'rgba(255,255,255,0.8)');
  grad.addColorStop(0.5, 'rgba(255,255,255,0.2)');
  grad.addColorStop(1.0, 'rgba(255,255,255,0)');
  g.fillStyle = grad;
  g.fillRect(0, 0, 64, 64);
  return new THREE.CanvasTexture(c);
}
const glowTexture = makeGlowTexture();

// ---------- Step 3: Fill a cube with random particles ----------
const isMobile = window.innerWidth < 768;
const PARTICLE_COUNT = isMobile ? 2500 : 5000;
const positions = new Float32Array(PARTICLE_COUNT * 3);
const colors = new Float32Array(PARTICLE_COUNT * 3);
const sizes = new Float32Array(PARTICLE_COUNT);
const phases = new Float32Array(PARTICLE_COUNT);
const palette = [new THREE.Color(0x2ad1d6), new THREE.Color(0x3b82f6), new THREE.Color(0x8be9fd), new THREE.Color(0xffffff)];
const tmp = new THREE.Color();
for (let i = 0; i < PARTICLE_COUNT; i++) {
  positions[i * 3]     = Math.random() * 10 - 5;
  positions[i * 3 + 1] = Math.random() * 10 - 5;
  positions[i * 3 + 2] = Math.random() * 10 - 5;
  tmp.copy(palette[Math.floor(Math.random() * palette.length)]).offsetHSL(0, 0, (Math.random() - 0.5) * 0.1);
  colors[i * 3] = tmp.r; colors[i * 3 + 1] = tmp.g; colors[i * 3 + 2] = tmp.b;
  sizes[i] = 0.4 + Math.random() * 1.2;
  phases[i] = Math.random() * Math.PI * 2;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geometry.setAttribute('aSize', new THREE.BufferAttribute(sizes, 1));
geometry.setAttribute('aPhase', new THREE.BufferAttribute(phases, 1));

// ---------- Step 4: A glowing material with drift, twinkle and depth fade ----------
const material = new THREE.ShaderMaterial({
  uniforms: {
    uTime: { value: 0 },
    uMap: { value: glowTexture },
    uScale: { value: renderer.getPixelRatio() * 60.0 },
    uFogColor: { value: scene.fog.color },
    uFogDensity: { value: scene.fog.density }
  },
  vertexShader: `
    attribute float aSize;
    attribute float aPhase;
    uniform float uTime;
    uniform float uScale;
    varying vec3 vColor;
    varying float vTwinkle;
    varying float vDepth;
    void main() {
      vec3 p = position;
      p.x += sin(uTime * 0.3 + aPhase) * 0.15;
      p.y += cos(uTime * 0.25 + aPhase * 1.3) * 0.15;
      vec4 mv = modelViewMatrix * vec4(p, 1.0);
      vTwinkle = 0.6 + 0.4 * sin(uTime * 2.0 + aPhase * 3.0);
      vColor = color;
      vDepth = -mv.z;
      gl_PointSize = aSize * uScale / -mv.z;
      gl_Position = projectionMatrix * mv;
    }
  `,
  fragmentShader: `
    uniform sampler2D uMap;
    uniform vec3 uFogColor;
    uniform float uFogDensity;
    varying vec3 vColor;
    varying float vTwinkle;
    varying float vDepth;
    void main() {
      float a = texture2D(uMap, gl_PointCoord).a;
      float fog = exp(-uFogDensity * uFogDensity * vDepth * vDepth * 6.0);
      gl_FragColor = vec4(vColor * vTwinkle * fog, a * fog);
    }
  `,
  vertexColors: true,
  transparent: true,
  depthWrite: false,
  blending: THREE.AdditiveBlending
});
const particleSystem = new THREE.Points(geometry, material);
scene.add(particleSystem);

// ---------- Step 5: Smooth rotation and mouse parallax ----------
const mouse = new THREE.Vector2();
window.addEventListener('pointermove', (e) => {
  mouse.x = e.clientX / window.innerWidth - 0.5;
  mouse.y = e.clientY / window.innerHeight - 0.5;
});
const clock = new THREE.Clock();
let isAnimating = true;
let elapsed = 0;
function animate() {
  const dt = Math.min(clock.getDelta(), 0.05);
  if (isAnimating) {
    elapsed += dt;
    particleSystem.rotation.x += dt * 0.05;
    particleSystem.rotation.y += dt * 0.08;
    material.uniforms.uTime.value = elapsed;
  }
  camera.position.x += (mouse.x * 1.5 - camera.position.x) * 0.05;
  camera.position.y += (-mouse.y * 1.5 - camera.position.y) * 0.05;
  camera.lookAt(scene.position);
  renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);

// ---------- Step 6: Stop/Start button and window resize ----------
const toggle = document.getElementById('toggle');
toggle.addEventListener('click', () => {
  isAnimating = !isAnimating;
  toggle.textContent = isAnimating ? 'Stop Animation' : 'Start Animation';
});
window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
  material.uniforms.uScale.value = renderer.getPixelRatio() * 60.0;
});
</script>
</body>
</html>

Taking It Further

Frequently Asked Questions

How do I create a basic particle system in Three.js?

Create a BufferGeometry, set a Float32Array of positions as the position attribute, create a material, then create a THREE.Points object and add it to the scene. Animate by updating the position attribute values each frame and setting needsUpdate.

What is the difference between Points and Sprites in Three.js?

Points renders thousands of particles efficiently as a single draw call using BufferGeometry. Sprites are individual objects that always face the camera, but each is a separate draw call. Use Points for large particle counts (100+) and Sprites for a few labels or icons.

How do I add a texture to Three.js particles?

Load a texture and pass it to PointsMaterial: new THREE.PointsMaterial({ map: texture, transparent: true, alphaTest: 0.5 }). Or, as in this tutorial, draw a radial gradient on a small canvas, wrap it in a THREE.CanvasTexture and sample it with gl_PointCoord, so no image file is needed.

Why are my Three.js particles rendering as squares?

Particles are squares by default. Add a circular texture with transparent: true, or use a ShaderMaterial whose fragment shader fades or discards pixels outside a circle using gl_PointCoord.

How do I make Three.js particles glow?

Use THREE.AdditiveBlending with transparent: true and depthWrite: false so overlapping particles add their light. A soft radial-gradient sprite with a bright center makes each point read as a small light.

How do I make Three.js particles twinkle or drift?

Give each particle a random phase attribute and pass the elapsed time to the shader as a uniform. In the vertex shader, offset the position with sin(uTime + phase) for drift and compute a brightness of sin(uTime * 2.0 + phase) for twinkle.

How many particles can Three.js handle?

Tens of thousands on desktop and several thousand on phones when they share one THREE.Points object. The CPU update loop is usually the limit, so cap the count on mobile if the frame rate drops.

SEE 3D AND 2D BROWSER GAMES IN ACTION

My free browser games are full Three.js 3D experiences — no download, they run right in your browser.