Skip to content

Particles

A Three.js guide for coding agents. Also covers particles, Points, PointsMaterial, THREE.Points, particle system, stars, and 21 more.

Show all 27 aliases

particles, Points, PointsMaterial, THREE.Points, particle system, stars, starfield, galaxy generator, particle cloud, BufferGeometry particles, position attribute, vertex colors, vertexColors, sizeAttenuation, alphaMap particles, alphaTest, depthWrite false, depthTest particles, additive blending, AdditiveBlending, particle edges showing, particles hiding each other, how do I make particles in three.js, how do I animate particles, how do I color particles individually, needsUpdate attribute, dispose geometry on regenerate

Core model

  • A particle system is a Mesh-shaped trio: a BufferGeometry, a PointsMaterial, and a THREE.Points object instead of THREE.Mesh. Every vertex of the geometry renders as one particle
  • Each particle is a camera-facing plane of two triangles. That is why counts can reach hundreds of thousands at good frame rates, and also why sorting and transparency get tricky
  • Any built-in geometry works (SphereGeometry vertices become particles), but real particle work uses a custom BufferGeometry so you control every position
const geometry = new THREE.BufferGeometry()
const material = new THREE.PointsMaterial({ size: 0.02, sizeAttenuation: true })
const points = new THREE.Points(geometry, material)
scene.add(points)

PointsMaterial essentials

  • size: particle size for all particles
  • sizeAttenuation: true: distant particles render smaller, giving perspective. Off means constant screen-space size
  • color: tints every particle, and multiplies with map, alphaMap, and vertex colors. Assign with new THREE.Color('#ff88cc') when setting after construction
  • map: puts a texture on each particle. On its own it shows the texture's black background and square edges
  • transparent: true plus alphaMap (instead of map): makes the texture's black regions invisible. This is the standard setup for textured particles

The transparency sorting problem

Particles draw in creation order, not depth order, so front particles randomly occlude back ones and square edges flicker through. Three fixes, none perfect, pick per project:

  • alphaTest = 0.001: skip rendering fully transparent pixels. Cheap, still shows minor glitches on semi-transparent edges
  • depthTest = false: stop testing against the depth buffer entirely. Fixes particles against particles but breaks the scene: particles draw on top of other objects (add a cube to see it). Only safe when particles are the whole scene and share one color
  • depthWrite = false: particles still test against the depth buffer but do not write to it. Usually the best default, almost no drawback. Ordering versus other transparent objects can still occasionally be wrong depending on add order
particlesMaterial.transparent = true
particlesMaterial.alphaMap = particleTexture
particlesMaterial.depthWrite = false

Blending

  • blending = THREE.AdditiveBlending adds each drawn pixel's color to what is already there instead of replacing it. Overlapping particles brighten and saturate, which reads as glow. Keep depthWrite: false with it
  • Additive blending costs performance per overdrawn pixel. Expect lower max particle counts at 60fps
  • With additive blending and enough small particles you can skip textures entirely: default square points read as round dots at small sizes (the galaxy generator does exactly this and avoids the whole alpha and depth mess)

Custom BufferGeometry particle clouds

  • Positions live in one flat Float32Array of length count * 3: [x0, y0, z0, x1, y1, z1, ...]
  • Register with setAttribute('position', new THREE.BufferAttribute(array, 3)). The 3 is the itemSize, values per vertex
const count = 5000
const positions = new Float32Array(count * 3)
for (let i = 0; i < count; i++) {
  const i3 = i * 3
  positions[i3    ] = (Math.random() - 0.5) * 10
  positions[i3 + 1] = (Math.random() - 0.5) * 10
  positions[i3 + 2] = (Math.random() - 0.5) * 10
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
  • The i3 = i * 3 loop shape (iterate particles, index the flat array) is the canonical pattern. Prefer it over iterating count * 3 raw indices, because per-particle logic (radius, branch, color mix) needs the particle index

Per-vertex colors

  • Add a color attribute exactly like position: Float32Array(count * 3) of r, g, b values in 0 to 1
  • Enable with vertexColors: true on the material
  • The material's color property still multiplies vertex colors. Leave it white (or unset) for pure vertex colors
const colors = new Float32Array(count * 3)
// fill r, g, b per particle...
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))
material.vertexColors = true

Animating particles

Three approaches, in ascending power and cost sensitivity:

  1. Transform the Points object. Points inherits from Object3D, so points.rotation.y = elapsedTime * 0.2 rotates the whole cloud. Cheapest, no per-particle control
  2. Mutate the position attribute each frame. Read and write the flat array, then flag the upload:
for (let i = 0; i < count; i++) {
  const i3 = i * 3
  const x = geometry.attributes.position.array[i3]
  geometry.attributes.position.array[i3 + 1] = Math.sin(elapsedTime + x)
}
geometry.attributes.position.needsUpdate = true

Gotchas: nothing moves without needsUpdate = true (Three.js must be told to re-upload the buffer). Offsetting the sine by x per particle is what turns uniform bobbing into a traveling wave. This approach re-uploads the whole attribute every frame, fine for thousands, wrong for hundreds of thousands 3. Custom shader. For large counts, move the animation math into a vertex shader so the GPU animates and the CPU never touches the buffer. This is the only approach that scales to millions

Galaxy generator pattern

The reference pattern for parameterized, regenerable particle systems.

Structure

  • One parameters object holds every tweakable (count, size, radius, branches, spin, randomness, randomnessPower, insideColor, outsideColor)
  • One generateGalaxy() function builds geometry, material, and Points from the current parameters. Call it once at startup and again on every tweak
  • Wire GUI tweaks with onFinishChange(generateGalaxy), not onChange, so you do not regenerate on every pixel of a slider drag. The tweaks must be registered after the function is defined

Disposal on regenerate (critical)

Regenerating without cleanup stacks galaxies on top of each other and leaks GPU memory until the machine heats up. Hold geometry, material, points in outer-scope variables and destroy the old set first:

let geometry = null
let material = null
let points = null

const generateGalaxy = () => {
  if (points !== null) {
    geometry.dispose()
    material.dispose()
    scene.remove(points)
  }
  // build new geometry, material, points...
}
  • scene.remove() alone does not free GPU buffers. dispose() on both geometry and material is what releases them. Points objects themselves need no dispose

Shape math

Per particle, compute radius, branch angle, and spin, then add randomness:

const radius = Math.random() * parameters.radius
const branchAngle = (i % parameters.branches) / parameters.branches * Math.PI * 2
const spinAngle = radius * parameters.spin

positions[i3    ] = Math.cos(branchAngle + spinAngle) * radius + randomX
positions[i3 + 1] = randomY
positions[i3 + 2] = Math.sin(branchAngle + spinAngle) * radius + randomZ
  • Branches: i % branches cycles particles across branches; dividing by the branch count and multiplying by Math.PI * 2 spreads them evenly around the circle
  • Spin: multiply the radius by a spin factor and add it to the angle. Farther particles twist more, which is what draws the spiral arms
  • Randomness scaled by radius: multiply the random offset by radius so the core stays tight and the edges spread

Randomness that concentrates on the branch

Uniform (Math.random() - 0.5) offsets look like a fuzzy box and the branch pattern still shows. Crush the distribution toward zero with a power, and restore both signs manually because Math.pow cannot take negative bases:

const randomX =
  Math.pow(Math.random(), parameters.randomnessPower) *
  (Math.random() < 0.5 ? 1 : -1) *
  parameters.randomness * radius

Higher randomnessPower pulls more particles close to the branch line, leaving a thin organic haze at the edges.

Color mixing inside to outside

  • Create THREE.Color instances from the hex parameters inside generateGalaxy (so regenerations pick up GUI color changes)
  • Per particle, clone the inside color and lerp toward the outside color by normalized distance. lerp(other, t) mutates in place, which is why the clone matters: without it every particle drags the shared base color further out
const colorInside = new THREE.Color(parameters.insideColor)
const colorOutside = new THREE.Color(parameters.outsideColor)

const mixedColor = colorInside.clone()
mixedColor.lerp(colorOutside, radius / parameters.radius)

colors[i3    ] = mixedColor.r
colors[i3 + 1] = mixedColor.g
colors[i3 + 2] = mixedColor.b
  • Material for the galaxy look: sizeAttenuation: true, depthWrite: false, blending: THREE.AdditiveBlending, vertexColors: true, small size around 0.01, count around 100000

Common mistakes

  • Using map instead of transparent: true + alphaMap, so particles show black squares
  • Turning off depthTest in a scene that has other objects, so particles float above everything
  • Forgetting position.needsUpdate = true after mutating the attribute array, so nothing animates
  • Regenerating a parameterized system without dispose() on the old geometry and material, leaking GPU memory on every tweak
  • Registering onFinishChange GUI callbacks before the generate function exists
  • Using Math.pow on a value that can be negative for randomness shaping, which returns NaN; compute the power on Math.random() and apply the sign separately
  • Calling lerp on a shared Color instead of a clone, so the color mix accumulates across particles
  • Animating tens of thousands of particles by rewriting the position attribute in JavaScript every frame instead of moving the math to a shader

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-three-js-guide({ topic: "three-js-particles" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems