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.

← Three.js From ZeroS0-09 · Quick Wins

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.

— fps
Move your mouse over the canvas

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.

Pitfall. You can shortcut this by just doing 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

"Particles displace, but only from the center, not the cursor." Mouse uniform isn't updating. Check console for the raycaster hit — if it's always (0, 0, 0), the ray isn't intersecting the plane.
"Ripples are too small / not visible." Wave amplitude is in world units; if your grid spans 8 units, a 0.3-unit wave is subtle. Bump it to 0.6.
"All particles same size — the size pulse doesn't work." gl_PointSize only works on POINTS draw calls. If you accidentally created a Mesh instead of Points, it's ignored.

Exercises

  1. Click bursts. On click, set a uClickTime uniform. 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.
  2. 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.
  3. 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.