Tutorial · Intermediate · ~50 min
Three.js · BufferGeometry · Shaders · Performance

Particle System with LOD and Billboards

A naive particle system with 100 000 particles will grind most GPUs to a halt. This tutorial builds a high-performance system using BufferGeometry with typed arrays, billboard sprites rendered via a custom vertex shader, and distance-based level-of-detail to skip invisible work.

1Flat particle pool with typed arrays

Keep all data in flat Float32Arrays rather than JS objects. Each index maps to one particle across several parallel arrays:

const MAX = 100_000; // Parallel typed arrays — one slot per particle const pos = new Float32Array(MAX * 3); // xyz const vel = new Float32Array(MAX * 3); // vxvyvz const life = new Float32Array(MAX); // remaining lifetime (s) const maxLife = new Float32Array(MAX); // initial lifetime for t=life/maxLife const size = new Float32Array(MAX); // particle radius const dead = new Uint8Array(MAX); // 0=alive, 1=dead (for pool) let alive = 0; // count of live particles let head = 0; // ring-buffer emit cursor // Initialise all as dead dead.fill(1);
Typed arrays live in contiguous memory, so iterating them is cache-friendly — much faster than arrays of objects like {x,y,z,vx,vy,...}.

2Upload to GPU as BufferGeometry

import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js'; const geo = new THREE.BufferGeometry(); const posAttr = new THREE.BufferAttribute(pos, 3); const sizeAttr = new THREE.BufferAttribute(size, 1); const lifeAttr = new THREE.BufferAttribute(life, 1); posAttr.setUsage(THREE.DynamicDrawUsage); // hint: updates every frame sizeAttr.setUsage(THREE.DynamicDrawUsage); lifeAttr.setUsage(THREE.DynamicDrawUsage); geo.setAttribute('position', posAttr); geo.setAttribute('a_size', sizeAttr); geo.setAttribute('a_life', lifeAttr); geo.setDrawRange(0, MAX); // draw up to MAX points const particles = new THREE.Points(geo, /* custom material below */); scene.add(particles);

3Billboard vertex shader

const mat = new THREE.ShaderMaterial({ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, uniforms: { u_tex: { value: sparkTexture } }, vertexShader: /* glsl */ ` attribute float a_size; attribute float a_life; // 0=dead, 1=newborn varying float v_life; void main() { v_life = a_life; if (a_life <= 0.0) { // Push dead particles off-screen (no discard in vertex shader) gl_Position = vec4(99999.0, 0.0, 0.0, 1.0); gl_PointSize = 0.0; return; } vec4 mvp = modelViewMatrix * vec4(position, 1.0); gl_PointSize = a_size * (300.0 / -mvp.z); // perspective size gl_Position = projectionMatrix * mvp; } `, fragmentShader: /* glsl */ ` uniform sampler2D u_tex; varying float v_life; void main() { vec4 tex = texture2D(u_tex, gl_PointCoord); float alpha = tex.a * v_life; // fade out with life gl_FragColor = vec4(tex.rgb, alpha); } `, }); particles.material = mat;

4Emit and recycle particles

function emit(x, y, z, count = 100) { for (let i = 0; i < count; i++) { // Ring-buffer: overwrite oldest particle const idx = (head++) % MAX; const i3 = idx * 3; pos[i3] = x; pos[i3+1] = y; pos[i3+2] = z; const theta = Math.random() * Math.PI * 2; const phi = Math.acos(2 * Math.random() - 1); const speed = 0.5 + Math.random() * 2.5; vel[i3] = Math.sin(phi) * Math.cos(theta) * speed; vel[i3+1] = Math.abs(Math.cos(phi)) * speed + 1; // bias upward vel[i3+2] = Math.sin(phi) * Math.sin(theta) * speed; maxLife[idx] = life[idx] = 0.5 + Math.random() * 1.5; size[idx] = 2 + Math.random() * 6; dead[idx] = 0; } } function update(dt) { for (let i = 0; i < MAX; i++) { if (dead[i]) continue; life[i] -= dt; if (life[i] <= 0) { dead[i] = 1; life[i] = 0; continue; } const i3 = i * 3; vel[i3+1] -= 2.5 * dt; // gravity pos[i3] += vel[i3] * dt; pos[i3+1] += vel[i3+1] * dt; pos[i3+2] += vel[i3+2] * dt; } posAttr.needsUpdate = true; lifeAttr.needsUpdate = true; }

5Distance-based LOD

// Skip updates for particles beyond a threshold distance from camera const camPos = new THREE.Vector3(); function updateLOD(camera) { camera.getWorldPosition(camPos); for (let i = 0; i < MAX; i++) { if (dead[i]) continue; const i3 = i * 3; const dx = pos[i3] - camPos.x; const dy = pos[i3+1] - camPos.y; const dz = pos[i3+2] - camPos.z; const d2 = dx*dx + dy*dy + dz*dz; // Skip update but still render particles > 200 units away if (d2 > 200 * 200) continue; // Full update only for nearby particles // (velocity integration etc. already skipped by LOD check above) } }

6Additive blending for fire and smoke

// Fire: additive blending — bright where particles overlap // orange-yellow gradient by life const fireFrag = /* glsl */ ` varying float v_life; void main() { float r = length(gl_PointCoord - 0.5); if (r > 0.5) discard; // Colour: white hot → orange → red vec3 hot = vec3(1.0, 1.0, 0.7); vec3 mid = vec3(1.0, 0.4, 0.0); vec3 cool = vec3(0.4, 0.0, 0.0); float t = clamp(v_life, 0.0, 1.0); vec3 col = t > 0.5 ? mix(mid, hot, (t - 0.5) * 2.0) : mix(cool, mid, t * 2.0); float alpha = (1.0 - r * 2.0) * v_life; gl_FragColor = vec4(col * alpha, alpha); } `; // Smoke: normal (src-alpha) blending with greyscale darkening over time const smokeFrag = /* glsl */ ` varying float v_life; void main() { float r = length(gl_PointCoord - 0.5); if (r > 0.5) discard; float grey = 0.1 + v_life * 0.4; float alpha = (1.0 - r * 2.0) * v_life * 0.6; gl_FragColor = vec4(grey, grey, grey, alpha); } `;
Combine a fire emitter (additive) with a smoke emitter (alpha blend) — add the smoke mesh as a separate Points object drawn after fire to ensure correct sorting.