🟣 Three.js · WebGL · Tutorial
📅 Березень 2026 ⏱ ≈ 12 хв читання 🟡 Середній 📦 Three.js r165+

Three.js Particle System Tutorial

How to render 100,000 animated particles at 60 fps in the browser — using BufferGeometry and Float32Array to keep all particle data on the GPU without touching the JavaScript garbage collector once per frame.

Why BufferGeometry (not Geometry)

Three.js has two ways to define geometry. The legacy Geometry class stores data as JavaScript objects and arrays — you could write geometry.vertices.push(new THREE.Vector3(...)). It was convenient but slow: every frame spent on reading positions from the GPU, running JS GC on thousands of Vector3 objects, and re-uploading to the GPU.

BufferGeometry stores data as typed arrays — Float32Array, Uint16Array, etc. — which are contiguous memory blocks that can be handed directly to the GPU as buffer objects. The advantages:

Legacy note: The Geometry class was removed in Three.js r125 (2021). All modern Three.js code uses BufferGeometry.

Project Setup

Import Three.js from a CDN or install via npm:

/* Option A: ES module from CDN (no bundler required) */ import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.165.0/build/three.module.js'; /* Option B: npm install three → import * as THREE from 'three'; */

Minimal HTML scaffold:

<!DOCTYPE html> <html> <head><meta name="viewport" content="width=device-width,initial-scale=1"></head> <body style="margin:0;overflow:hidden;background:#000"> <canvas id="c"></canvas> <script type="module" src="particles.js"></script> </body> </html>
// particles.js — boilerplate scene setup import * as THREE from 'three'; const canvas = document.getElementById('c'); const renderer = new THREE.WebGLRenderer({ canvas, antialias: false }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(window.innerWidth, window.innerHeight); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(0, 0, 80); window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); });

Allocating Positions with Float32Array

Allocate the typed array once before the loop. Three floats per particle (x, y, z). Never create a new array inside the animation loop.

const COUNT = 100_000; // 100,000 particles // ONE allocation — reused every frame const positions = new Float32Array(COUNT * 3); // Initialise random positions in a sphere of radius 50 for (let i = 0; i < COUNT; i++) { const r = 50 * Math.cbrt(Math.random()); // uniform sphere distribution const theta = Math.random() * Math.PI * 2; const phi = Math.acos(2 * Math.random() - 1); positions[i * 3] = r * Math.sin(phi) * Math.cos(theta); // x positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta); // y positions[i * 3 + 2] = r * Math.cos(phi); // z } const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
Uniform sphere distribution tip: A common mistake is generating a random point in [−r, +r]³ and rejecting points outside the sphere. This is fine, but using spherical coordinates with r * Math.cbrt(Math.random()) ensures uniform volume density rather than surface concentration at the poles.

PointsMaterial and the Points Object

THREE.Points renders each vertex as a screen-space sprite — the fastest primitive for particles; no triangles, no indices. PointsMaterial controls size and colour.

const material = new THREE.PointsMaterial({ size: 0.3, // world-space size sizeAttenuation: true, // particles shrink with distance (perspective) color: 0xffffff, transparent: true, opacity: 0.8, depthWrite: false, // prevents particles from occluding each other blending: THREE.AdditiveBlending // bright overlap — glowing effect }); const particles = new THREE.Points(geometry, material); scene.add(particles);

AdditiveBlending means overlapping particles add their colours together, creating a "glow" where densely packed particles become bright white — the classic star/nebula look. Use THREE.NormalBlending for opaque particles (e.g. sand, smoke).

Per-Particle Vertex Colours

Add a color attribute with three floats per particle (r, g, b in the range 0–1) and enable vertexColors: true:

const colors = new Float32Array(COUNT * 3); // one allocation const _color = new THREE.Color(); for (let i = 0; i < COUNT; i++) { // Hue based on height, full saturation, half lightness const y = positions[i * 3 + 1]; _color.setHSL((y + 50) / 100, 1.0, 0.5); colors[i * 3] = _color.r; colors[i * 3 + 1] = _color.g; colors[i * 3 + 2] = _color.b; } geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); material.vertexColors = true;
Reuse the Color object: Notice the _color object is allocated once outside the loop. setHSL() mutates it in place. Creating new THREE.Color() inside the loop would allocate 100,000 objects — triggering GC during the next frame.

The Animation Loop — Zero Allocations

The golden rule: never allocate inside the animation loop. Declare all temporary variables before requestAnimationFrame begins, then mutate them in place each frame.

const posAttr = geometry.getAttribute('position'); let t = 0; function animate() { requestAnimationFrame(animate); t += 0.001; // Update particle positions — read and write the Float32Array directly for (let i = 0; i < COUNT; i++) { const ix = i * 3; // simple orbit: rotate each particle around the Y axis const x = positions[ix]; const z = positions[ix + 2]; const s = Math.sin(t), c = Math.cos(t); positions[ix] = x * c - z * s; // rotate x positions[ix + 2] = x * s + z * c; // rotate z } // Tell Three.js the positions buffer changed this frame posAttr.needsUpdate = true; renderer.render(scene, camera); } animate();

The loop above modifies ~1.2 MB of Float32Array data per frame entirely in JavaScript. For CPU-intensive simulations (SPH fluid, gravity N-body) you would move this logic into a GLSL vertex shader, letting the GPU update all positions in parallel and eliminating the typed array copy entirely.

Custom Shader: Circular Sprites

By default, PointsMaterial renders square billboards. A custom shader that discards fragments outside a circle gives round particles:

const material = new THREE.ShaderMaterial({ uniforms: { uTime: { value: 0 } }, vertexShader: ` attribute vec3 color; varying vec3 vColor; void main() { vColor = color; vec4 mvPos = modelViewMatrix * vec4(position, 1.0); gl_PointSize = 3.0 * (300.0 / -mvPos.z); // perspective scaling gl_Position = projectionMatrix * mvPos; } `, fragmentShader: ` varying vec3 vColor; void main() { // gl_PointCoord: (0,0) = top-left, (1,1) = bottom-right vec2 uv = gl_PointCoord - 0.5; float r = dot(uv, uv); // r² — avoid sqrt if (r > 0.25) discard; // outside circle radius 0.5 float alpha = 1.0 - smoothstep(0.15, 0.25, r); gl_FragColor = vec4(vColor, alpha); } `, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, vertexColors: true });
Optimisation: Using dot(uv, uv) and comparing against 0.25 (which is 0.5²) avoids a sqrt() call per fragment. Fragment shaders run for every rasterised pixel, so small per-fragment optimisations multiply across millions of invocations.

Performance Reference

Approach 100k particles, dGPU desktop 100k particles, mobile GC pressure
Float32Array + BufferGeometry (this tutorial) ~0.5 ms/frame CPU ~2–5 ms/frame None (after init)
Legacy Geometry (Vector3 objects) ~5–15 ms/frame CPU Not viable High — GC pauses every few seconds
GPU compute (compute shaders / transform feedback) <0.1 ms/frame CPU <1 ms/frame None

For particle counts above ~500k, or simulations that require per-particle physics (gravity, collisions, SPH), move all simulation logic into a GLSL compute shader (WebGPU) or a vertex shader with transform feedback (WebGL 2). The CPU then only issues a single draw call and reads nothing back from the GPU.

Next Steps

🌊 SPH Fluid Simulation (GPU particles) →