Three.js From Zero · Article s0-09
Particles Cursor
Particles Cursor is Article s0-09 of Three.js From Zero, a MasterAllArts free interactive lesson for artists learning creative 3D on the web.
Season 0 · Article 09 · Quick Wins
A field of 20,000 particles that ripples and shifts color around the cursor. Pure shader work — the mouse position is one uniform, the displacement happens entirely on the GPU.
The trick: GPU does the math
The bad approach: per particle, in JS, compute distance from cursor and update the position attribute every frame. That's a 20,000-iteration loop at 60fps — perfectly fine on a desktop, dies on mobile.
The right approach: pass the cursor as a uniform. The vertex shader runs once per particle, in parallel on the GPU. The distance test happens 20,000 times per particle not per frame on the CPU. Same logic, different parallelism, 10× faster.
Step 1 — The grid
const SIZE = 140; // 140 × 140 = 19,600 particles
const positions = new Float32Array(SIZE * SIZE * 3);
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
const i = (y * SIZE + x) * 3;
positions[i + 0] = (x / SIZE - 0.5) * 8;
positions[i + 1] = (y / SIZE - 0.5) * 4.5;
positions[i + 2] = 0;
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
The positions are static. We upload them once at startup. The displacement happens in the shader using position as the rest pose.
Step 2 — The shader knows about the cursor
const material = new THREE.ShaderMaterial({
uniforms: {
uMouse: { value: new THREE.Vector3(0, 0, 0) },
uTime: { value: 0 },
},
vertexShader: /*glsl*/`
uniform vec3 uMouse;
uniform float uTime;
varying float vDist;
void main() {
vec3 pos = position;
// Distance from this particle to the cursor (XY plane only)
float d = distance(pos.xy, uMouse.xy);
// Wave: ripples emanate from the cursor
float wave = sin(d * 4.0 - uTime * 2.0) * exp(-d * 0.6);
pos.z += wave * 0.3;
// Slight breathing
pos.z += sin(pos.x * 0.4 + uTime) * 0.05;
vDist = d;
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
gl_PointSize = 3.0 * (1.0 + (1.0 - smoothstep(0.0, 1.5, d)) * 4.0);
gl_Position = projectionMatrix * mv;
}
`,
fragmentShader: /*glsl*/`
varying float vDist;
void main() {
vec2 uv = gl_PointCoord - 0.5;
if (length(uv) > 0.5) discard;
// Color: warm near cursor, cool far
vec3 nearColor = vec3(1.0, 0.7, 0.9);
vec3 farColor = vec3(0.3, 0.4, 0.8);
vec3 col = mix(nearColor, farColor, smoothstep(0.0, 1.5, vDist));
float alpha = (1.0 - length(uv) * 2.0) * (1.2 - smoothstep(0.0, 3.0, vDist));
gl_FragColor = vec4(col, alpha);
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
Three behaviors fall out of one shader:
- Wave ripple.
sin(d * 4.0 - uTime * 2.0)is a wave that propagates outward as time advances.exp(-d * 0.6)attenuates it with distance. - Size pulse. Particles near the cursor are bigger (multiplier of up to 5×).
- Color near→far. Pink near cursor, blue at edges. Smooth interpolation by distance.
Step 3 — Pipe the cursor in
const raycaster = new THREE.Raycaster();
const ndc = new THREE.Vector2();
const plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0); // z=0 plane
const hit = new THREE.Vector3();
mount.addEventListener('pointermove', (e) => {
const r = mount.getBoundingClientRect();
ndc.x = ((e.clientX - r.left) / r.width) * 2 - 1;
ndc.y = -((e.clientY - r.top) / r.height) * 2 + 1;
raycaster.setFromCamera(ndc, camera);
raycaster.ray.intersectPlane(plane, hit);
material.uniforms.uMouse.value.copy(hit);
});
The cursor lives on a 2D screen. The particles live in 3D world space. Raycaster projects the cursor into the scene and finds where its ray hits the z=0 plane. That's our 3D cursor position.
uMouse = (ndcX * viewWidth, ndcY * viewHeight, 0) — but the math breaks the moment your camera moves. The raycaster approach is robust to camera changes.Step 4 — Animate uTime
renderer.setAnimationLoop((t) => {
material.uniforms.uTime.value = t * 0.001;
renderer.render(scene, camera);
});
That's it. The grid never moves on the CPU. The cursor uniform updates on pointermove. The shader handles everything else.
Common first-time pitfalls
gl_PointSize only works on POINTS draw calls. If you accidentally created a Mesh instead of Points, it's ignored.Exercises
- Click bursts. On click, set a
uClickTimeuniform. In the shader, add an extra wave that pulses outward only during the first 1 second after click. Layer it on top of the cursor wave. - Persistent trails. Track cursor positions over time in a small array, pass as an array uniform. Add waves from each historical position with decaying weight. Visible cursor trail.
- Logo cursor. Instead of a grid, place particles in your logo's silhouette (sample alpha of a texture). The shader stays the same — only the rest-pose positions change.
UP NEXT
S0-10 — Raging Sea → Gerstner waves, foam at depth threshold, sun reflection. The shader-tutorial classic.