15,000 Particles Forming a Letter — Building a Three.js Particle System

15,000 Particles Forming a Letter

The hero of my portfolio is a giant "4" made of 15,000 floating particles. They drift, respond to mouse movement, and morph between shapes. Here's how I built it.

The Problem: Sampling Points from a Font Glyph

You can't just place particles at pixel positions. A font glyph is a vector path — you need to convert it into thousands of discrete 3D coordinates.

The approach: render the glyph to a canvas, read the pixel data, and collect positions where pixels are non-transparent.

// In a web worker
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext("2d");
ctx.font = `${fontSize}px Outfit`;
ctx.fillText("4", 0, fontSize);

const imageData = ctx.getImageData(0, 0, width, height);
const points: [number, number, number][] = [];

for (let y = 0; y < height; y += step) {
  for (let x = 0; x < width; x += step) {
    const alpha = imageData.data[(y * width + x) * 4 + 3];
    if (alpha > 128) {
      points.push([x - width/2, -(y - height/2), 0]);
    }
  }
}

This gives you a dense grid of points. But there's a problem.

The Clipping Bug

My first version sampled points sequentially — take the first 15,000 points from the grid. This works when your grid has way more points than particles.

But on mobile, I reduced the particle count to 8,000 for performance. Sequential sampling from an ordered grid means you get the top rows of the glyph but the bottom gets cut off. The "4" was missing its foot.

The fix: random sampling.

// Shuffle the points array, then take the first N
function seededShuffle(arr: any[], seed: number) {
  let m = arr.length;
  while (m) {
    const i = Math.floor(seededRandom(seed++) * m--);
    [arr[m], arr[i]] = [arr[i], arr[m]];
  }
  return arr;
}

const sampled = seededShuffle([...points], 42).slice(0, particleCount);

Seeded random gives deterministic results — the same shuffle every time, so the particle shape doesn't change between renders. But the points are evenly distributed across the entire glyph regardless of count.

Web Worker for Performance

Point sampling takes ~50ms on desktop, ~200ms on mobile. That's too long to block the main thread during page load.

Moving it to a web worker means the particle positions compute in the background while the page renders. When the worker finishes, particles appear smoothly.

// Main thread
const worker = new Worker(new URL("./particle-worker.ts", import.meta.url));
worker.postMessage({ glyph: "4", count: 15000, width: 800 });
worker.onmessage = (e) => setPositions(e.data.positions);

Mouse Interaction

When the cursor moves near a particle, it pushes away. The physics are simple — calculate the distance from cursor to particle, apply a repulsion force that falls off with distance.

// In the animation loop
const dx = mouseX - particle.x;
const dy = mouseY - particle.y;
const dist = Math.sqrt(dx * dx + dy * dy);

if (dist < repulsionRadius) {
  const force = (1 - dist / repulsionRadius) * strength;
  particle.vx -= (dx / dist) * force;
  particle.vy -= (dy / dist) * force;
}

// Spring back to original position
particle.vx += (particle.originX - particle.x) * springStrength;
particle.vy += (particle.originY - particle.y) * springStrength;

// Damping
particle.vx *= 0.95;
particle.vy *= 0.95;

The spring force pulls particles back to their original position. Damping prevents oscillation. The result feels organic — push particles away, they flow back.

Mobile: Touch Instead of Mouse

On touch devices, I added double-tap to trigger the morph effect and touch-move for interaction. The physics values scale with viewport size so the interaction doesn't feel too strong or weak on different screens.

Performance

  • Desktop (1920px): 15,000 particles, 60fps
  • Laptop (1440px): 12,000 particles, 60fps
  • Tablet: 10,000 particles, 55-60fps
  • Mobile: 8,000 particles, 50-60fps

The particle size also scales — sub-pixel particles (< 1px) disappear at screen edges on some GPUs. I enforce a minimum pointSize based on the device pixel ratio.


Want to see it live? Visit 4ugusta.dev. Building something similar? 4UGUSTA Systems can help.

A

Augusta Bhardwaj

Full-stack & AI engineer. Building production AI systems at YC-backed startups. Founder of 4UGUSTA Systems — a web development and AI agency.

← Back to all posts