Skip to article
WebGL 18 Feb 2026 15 min read 2.1K views

Building Particle Systems with Three.js & WebGL Shaders

A practical walkthrough of creating high-performance 3D particle effects entirely on the GPU using custom GLSL shaders.

Suboor Khan

Full-Stack Developer & Technical Writer

A single GPU thread can process thousands of particles in parallel. A single JavaScript thread cannot. That fundamental truth is why GPU-based particle systems run at 60fps with a million points while CPU-based approaches choke at ten thousand.

In this article we'll build a high-performance 3D particle system entirely on the GPU using Three.js custom ShaderMaterial and raw GLSL—no postprocessing libraries, no helpers, just the metal.

Setting Up BufferGeometry

Every particle is a vertex. We pre-allocate a Float32Array and upload it once. The GPU owns it after that—we never touch it from JavaScript each frame.

const COUNT = 200_000;
const geometry = new THREE.BufferGeometry();

const positions  = new Float32Array(COUNT * 3);
const randoms    = new Float32Array(COUNT * 3);
const scales     = new Float32Array(COUNT);

for (let i = 0; i < COUNT; i++) {
  // Random sphere distribution
  const theta = Math.random() * Math.PI * 2;
  const phi   = Math.acos(2 * Math.random() - 1);
  const r     = Math.cbrt(Math.random()) * 4;

  positions[i*3]   = r * Math.sin(phi) * Math.cos(theta);
  positions[i*3+1] = r * Math.sin(phi) * Math.sin(theta);
  positions[i*3+2] = r * Math.cos(phi);
  randoms[i*3]     = Math.random();
  randoms[i*3+1]   = Math.random();
  randoms[i*3+2]   = Math.random();
  scales[i]        = Math.random();
}

geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('aRandom',  new THREE.BufferAttribute(randoms,   3));
geometry.setAttribute('aScale',   new THREE.BufferAttribute(scales,    1));

The Vertex Shader

The vertex shader runs once per particle, per frame, on the GPU. We pass uTime as a uniform and animate position entirely on the GPU—zero JS per-frame cost.

// vertexShader.glsl
uniform float uTime;
uniform float uSize;

attribute vec3 aRandom;
attribute float aScale;

varying float vLife;

void main() {
  vec3 pos = position;

  // Orbit each particle at a unique speed + phase
  float angle = uTime * (0.3 + aRandom.x * 0.7);
  float radius = length(pos.xz);
  pos.x = cos(angle) * radius;
  pos.z = sin(angle) * radius;

  // Breathing Y motion
  pos.y += sin(uTime * aRandom.y * 2.0) * 0.2;

  vec4 mv = modelViewMatrix * vec4(pos, 1.0);
  gl_Position   = projectionMatrix * mv;
  gl_PointSize  = uSize * aScale * (1.0 / -mv.z);

  vLife = aRandom.z;
}

The Fragment Shader

Each particle renders as a gl_POINTS quad. We shape it into a soft circle and apply colour from a gradient based on vLife.

// fragmentShader.glsl
varying float vLife;

void main() {
  vec2 uv = gl_PointCoord - 0.5;
  float d = length(uv);
  if (d > 0.5) discard;                     // circular crop

  float alpha = 1.0 - smoothstep(0.3, 0.5, d);

  vec3 colA = vec3(0.486, 0.239, 0.929);   // purple
  vec3 colB = vec3(0.925, 0.447, 0.600);   // pink
  vec3 col  = mix(colA, colB, vLife);

  gl_FragColor = vec4(col, alpha * 0.85);
}

Animation Loop

The only thing we do per frame from JavaScript is increment the time uniform. The GPU handles everything else.

const material = new THREE.ShaderMaterial({
  vertexShader, fragmentShader,
  uniforms: {
    uTime: { value: 0 },
    uSize: { value: 120 * renderer.getPixelRatio() },
  },
  transparent:  true,
  depthWrite:   false,
  blending:     THREE.AdditiveBlending,
});

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

function animate(t) {
  requestAnimationFrame(animate);
  material.uniforms.uTime.value = t * 0.001;  // only this per frame
  renderer.render(scene, camera);
}
requestAnimationFrame(animate);

Performance Tips

  • Set depthWrite: false and use additive blending for transparent particles—avoids expensive depth sorting
  • Cap pixelRatio at 1.5 on mobile with renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5))
  • Use IntersectionObserver to pause the animation loop when the canvas is off-screen
  • Prefer gl_PointSize over instanced meshes for pure-point particles—cheaper overdraw
  • Use powerPreference: 'low-power' on mobile to avoid draining the battery

200K

Particles @ 60fps

0.3ms

JS per frame

~4MB

GPU memory

Summary

  • Allocate all geometry data once as typed arrays—let the GPU own it
  • Drive all animation through uniforms—zero per-particle JS each frame
  • Use additive blending + discard for soft circle particles
  • Pause the loop with IntersectionObserver when not visible
  • Custom ShaderMaterial beats all high-level abstractions for raw particle counts

Stay Updated

Enjoyed this article?

Deep-dive articles on React, AI, WebGL, and software craft — twice a month. No spam, ever.