Home Kyle Bario
threejs-tutorial!
Creating a Particle System with Three.js: A Step-by-Step Tutorial

Creating a Particle System with Three.js: A Step-by-Step Tutorial

Particle systems are one of the most visually impressive effects you can create with Three.js. In this tutorial, I’ll show you how to build an interactive particle system that responds to mouse movement and creates stunning visual effects.

What We’ll Build

We’ll create a particle system that:

Setting Up the Project

First, let’s set up our basic Three.js scene:

import * as THREE from 'three';

// Scene setup
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  75,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);
const renderer = new THREE.WebGLRenderer({ antialias: true });

renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// Camera position
camera.position.z = 5;

Creating the Particle Geometry

We’ll use InstancedBufferGeometry for optimal performance:

// Particle count
const PARTICLE_COUNT = 10000;

// Create geometry
const geometry = new THREE.BufferGeometry();

// Create positions for each particle
const positions = new Float32Array(PARTICLE_COUNT * 3);
const colors = new Float32Array(PARTICLE_COUNT * 3);
const sizes = new Float32Array(PARTICLE_COUNT);

for (let i = 0; i < PARTICLE_COUNT; i++) {
  // Random positions in a sphere
  const radius = Math.random() * 10;
  const theta = Math.random() * Math.PI * 2;
  const phi = Math.acos(Math.random() * 2 - 1);

  positions[i * 3] = radius * Math.sin(phi) * Math.cos(theta);
  positions[i * 3 + 1] = radius * Math.sin(phi) * Math.sin(theta);
  positions[i * 3 + 2] = radius * Math.cos(phi);

  // Random colors
  colors[i * 3] = Math.random();
  colors[i * 3 + 1] = Math.random();
  colors[i * 3 + 2] = Math.random();

  // Random sizes
  sizes[i] = Math.random() * 2 + 0.5;
}

geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1));

Creating Custom Shaders

For the best visual effects, we’ll use custom shaders:

// Vertex shader
const vertexShader = `
  attribute float size;
  attribute vec3 color;
  varying vec3 vColor;
  uniform float time;
  uniform vec3 mouse;
  
  void main() {
    vColor = color;
    
    // Add some movement based on time
    vec3 pos = position;
    pos.x += sin(time * 0.001 + position.y * 0.1) * 0.1;
    pos.y += cos(time * 0.001 + position.x * 0.1) * 0.1;
    
    // Attract particles to mouse position
    vec3 toMouse = mouse - pos;
    float distance = length(toMouse);
    if (distance < 2.0) {
      pos += normalize(toMouse) * (2.0 - distance) * 0.1;
    }
    
    vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
    gl_PointSize = size * (300.0 / -mvPosition.z);
    gl_Position = projectionMatrix * mvPosition;
  }
`;

// Fragment shader
const fragmentShader = `
  varying vec3 vColor;
  
  void main() {
    // Create a circular particle
    vec2 center = gl_PointCoord - vec2(0.5);
    float dist = length(center);
    
    if (dist > 0.5) {
      discard;
    }
    
    // Add a glow effect
    float alpha = 1.0 - smoothstep(0.0, 0.5, dist);
    gl_FragColor = vec4(vColor, alpha);
  }
`;

Creating the Material and Mesh

// Create material
const material = new THREE.ShaderMaterial({
  vertexShader,
  fragmentShader,
  transparent: true,
  blending: THREE.AdditiveBlending,
  depthWrite: false,
  uniforms: {
    time: { value: 0 },
    mouse: { value: new THREE.Vector3() },
  },
});

// Create mesh
const particles = new THREE.Points(geometry, material);
scene.add(particles);

Adding Mouse Interaction

// Mouse tracking
const mouse = new THREE.Vector3();

function onMouseMove(event) {
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
  mouse.z = 0;

  // Convert to world coordinates
  mouse.unproject(camera);
  mouse.multiplyScalar(5);
}

window.addEventListener('mousemove', onMouseMove);

Animation Loop

const clock = new THREE.Clock();

function animate() {
  requestAnimationFrame(animate);

  const time = clock.getElapsedTime();

  // Update uniforms
  material.uniforms.time.value = time;
  material.uniforms.mouse.value.copy(mouse);

  // Rotate the entire particle system
  particles.rotation.x = time * 0.1;
  particles.rotation.y = time * 0.15;

  renderer.render(scene, camera);
}

animate();

Performance Optimizations

To ensure smooth performance:

// Frustum culling
const frustum = new THREE.Frustum();
const cameraMatrix = new THREE.Matrix4();

function updateFrustum() {
  camera.updateMatrix();
  camera.updateMatrixWorld();
  cameraMatrix.multiplyMatrices(
    camera.projectionMatrix,
    camera.matrixWorldInverse
  );
  frustum.setFromProjectionMatrix(cameraMatrix);
}

// Only render particles in view
function optimizeParticles() {
  updateFrustum();

  // You could implement more sophisticated culling here
  // For now, we'll just limit the particle count based on performance
  const fps = 1000 / (clock.getDelta() * 1000);
  if (fps < 30) {
    // Reduce particle count or effects
  }
}

Adding Post-Processing Effects

For even more visual impact, add some post-processing:

import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';

// Setup post-processing
const composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
const bloomPass = new UnrealBloomPass(
  new THREE.Vector2(window.innerWidth, window.innerHeight),
  0.5, // strength
  0.4, // radius
  0.85 // threshold
);

composer.addPass(renderPass);
composer.addPass(bloomPass);

Responsive Design

Don’t forget to handle window resizing:

function onWindowResize() {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
  composer.setSize(window.innerWidth, window.innerHeight);
}

window.addEventListener('resize', onWindowResize);

Final Result

The final particle system creates a mesmerizing, interactive experience that:

Next Steps

You can extend this system by:

Performance Tips

  1. Use InstancedBufferGeometry for large particle counts
  2. Implement frustum culling to only render visible particles
  3. Use efficient shaders with minimal branching
  4. Monitor frame rate and adjust quality accordingly
  5. Use object pooling for dynamic particles

This tutorial demonstrates the power of Three.js for creating stunning visual effects. The key is balancing visual impact with performance—always test on different devices and optimize accordingly.

Happy coding!