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:
- Displays thousands of particles in 3D space
- Responds to mouse movement
- Uses custom shaders for visual effects
- Maintains 60fps performance
- Includes smooth animations
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:
- Responds smoothly to mouse movement
- Maintains high performance
- Creates beautiful visual effects
- Works across different devices
Next Steps
You can extend this system by:
- Adding different particle shapes
- Implementing physics simulations
- Creating particle trails
- Adding sound effects
- Making particles emit light
Performance Tips
- Use InstancedBufferGeometry for large particle counts
- Implement frustum culling to only render visible particles
- Use efficient shaders with minimal branching
- Monitor frame rate and adjust quality accordingly
- 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!