All articles
15 min read

Particle Systems: How to Create Stunning Visual Effects With Code

particle systemcreative codinggenerative artphysicsJavaScript

Every explosion in a video game, every swirl of sparks in a music video, every flock of birds in a nature documentary — these are all particle systems. A particle system manages hundreds or thousands of tiny objects, each following simple rules, and the collective behavior creates something far more complex and beautiful than any single particle could.

Particle systems are one of the most versatile tools in creative coding. They can simulate fire, rain, smoke, galaxies, flocking birds, fireworks, dust, snow, and abstract art that defies categorization. And they're surprisingly simple to build: each particle is just a position, a velocity, and a few rules for how it changes over time.

This guide covers eight working particle systems you can build in your browser. Every example uses vanilla JavaScript and HTML Canvas — no libraries, no frameworks, no WebGL. Just particles, physics, and your imagination.

The anatomy of a particle system

Every particle system has four components:

  1. Emitter — where particles are born (a point, a line, an area, or the mouse cursor)
  2. Particle state — position, velocity, age, color, size, and any custom properties
  3. Forces — gravity, wind, attraction, repulsion, drag, turbulence
  4. Lifecycle — particles are born, live for some duration, and die (freeing memory for new ones)

The update loop is always the same: for each particle, apply forces to velocity, add velocity to position, age the particle, render it, and remove it if it's dead. That's it. Everything else is creative variation on these basics.

1. Basic emitter — particles from a point

The simplest particle system: particles spawn from the center, fly outward with random velocities, fade out, and die. This is the "hello world" of particle effects.

const canvas = document.createElement('canvas');
canvas.width = 400; canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const particles = [];
const maxParticles = 500;

function spawn() {
  if (particles.length >= maxParticles) return;
  const angle = Math.random() * Math.PI * 2;
  const speed = 0.5 + Math.random() * 2;
  particles.push({
    x: canvas.width / 2,
    y: canvas.height / 2,
    vx: Math.cos(angle) * speed,
    vy: Math.sin(angle) * speed,
    life: 1.0,
    decay: 0.003 + Math.random() * 0.008,
    hue: Math.random() * 60 + 10,
    size: 1 + Math.random() * 3
  });
}

function update() {
  ctx.fillStyle = 'rgba(10, 10, 15, 0.15)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  for (let i = 0; i < 5; i++) spawn();

  for (let i = particles.length - 1; i >= 0; i--) {
    const p = particles[i];
    p.x += p.vx;
    p.y += p.vy;
    p.life -= p.decay;
    if (p.life <= 0) { particles.splice(i, 1); continue; }
    ctx.beginPath();
    ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
    ctx.fillStyle = `hsla(${p.hue}, 80%, 60%, ${p.life})`;
    ctx.fill();
  }
  requestAnimationFrame(update);
}
update();

The key insight here is the life property. Each particle starts at 1.0 and decays toward 0. We use this value to control both opacity and size, so particles naturally fade and shrink as they age. The semi-transparent background fill creates motion trails — a technique used in nearly every particle art piece.

2. Forces — gravity, wind, and drag

Particles become interesting when forces act on them. Gravity pulls them down. Wind pushes them sideways. Drag slows them over time. Combining forces creates realistic and mesmerizing behavior.

const canvas = document.createElement('canvas');
canvas.width = 400; canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const particles = [];
const gravity = 0.03;
const wind = 0.01;
const drag = 0.998;

function spawn(x, y) {
  const angle = -Math.PI / 2 + (Math.random() - 0.5) * 1.2;
  const speed = 2 + Math.random() * 3;
  particles.push({
    x, y,
    vx: Math.cos(angle) * speed,
    vy: Math.sin(angle) * speed,
    life: 1.0,
    decay: 0.005 + Math.random() * 0.005,
    hue: 200 + Math.random() * 60,
    size: 2 + Math.random() * 2
  });
}

function update() {
  ctx.fillStyle = 'rgba(5, 5, 20, 0.1)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  for (let i = 0; i < 3; i++) {
    spawn(canvas.width / 2, canvas.height * 0.8);
  }

  for (let i = particles.length - 1; i >= 0; i--) {
    const p = particles[i];
    p.vy += gravity;
    p.vx += wind * Math.sin(Date.now() * 0.001);
    p.vx *= drag;
    p.vy *= drag;
    p.x += p.vx;
    p.y += p.vy;
    p.life -= p.decay;

    if (p.life <= 0 || p.y > canvas.height) {
      particles.splice(i, 1); continue;
    }

    const alpha = p.life * 0.8;
    ctx.beginPath();
    ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
    ctx.fillStyle = `hsla(${p.hue + (1 - p.life) * 40}, 70%, ${40 + p.life * 30}%, ${alpha})`;
    ctx.fill();
  }
  requestAnimationFrame(update);
}
update();

The fountain shoots particles upward, gravity curves them back down, and oscillating wind makes them sway. The drag coefficient (0.998) means particles lose 0.2% of their speed each frame — without it they'd accelerate forever under gravity. Notice how the hue shifts as particles age: young particles are blue, old ones shift toward cyan. This color-over-lifetime technique adds visual depth with almost no code.

3. Trails — drawing history with lines

Rendering particles as dots is fine, but trails transform them into ribbons of light. Instead of drawing a circle at the current position, we draw a line from the previous position to the current one. The effect is dramatic.

const canvas = document.createElement('canvas');
canvas.width = 400; canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const particles = [];

function spawn() {
  const angle = Math.random() * Math.PI * 2;
  const r = 50 + Math.random() * 30;
  const cx = canvas.width / 2, cy = canvas.height / 2;
  particles.push({
    x: cx + Math.cos(angle) * r,
    y: cy + Math.sin(angle) * r,
    px: cx + Math.cos(angle) * r,
    py: cy + Math.sin(angle) * r,
    vx: Math.cos(angle + Math.PI / 2) * 1.5,
    vy: Math.sin(angle + Math.PI / 2) * 1.5,
    life: 1.0,
    decay: 0.002 + Math.random() * 0.003,
    hue: angle * 180 / Math.PI + 180
  });
}

function update() {
  ctx.fillStyle = 'rgba(0, 0, 0, 0.03)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  if (particles.length < 300) spawn();

  const cx = canvas.width / 2, cy = canvas.height / 2;
  for (let i = particles.length - 1; i >= 0; i--) {
    const p = particles[i];
    const dx = cx - p.x, dy = cy - p.y;
    const dist = Math.sqrt(dx * dx + dy * dy);
    const force = 0.8 / (dist + 1);
    p.vx += dx * force * 0.01;
    p.vy += dy * force * 0.01;
    p.vx += (Math.random() - 0.5) * 0.1;
    p.vy += (Math.random() - 0.5) * 0.1;
    p.px = p.x; p.py = p.y;
    p.x += p.vx; p.y += p.vy;
    p.life -= p.decay;

    if (p.life <= 0) { particles.splice(i, 1); continue; }

    ctx.beginPath();
    ctx.moveTo(p.px, p.py);
    ctx.lineTo(p.x, p.y);
    ctx.strokeStyle = `hsla(${p.hue}, 80%, 60%, ${p.life * 0.6})`;
    ctx.lineWidth = p.life * 2;
    ctx.stroke();
  }
  requestAnimationFrame(update);
}
update();

Particles orbit the center (attracted by a central force) while random jitter keeps them from settling into perfect circles. The trail effect comes from storing px, py (previous position) and drawing a line segment each frame. With the slow fade (rgba(0,0,0,0.03)), trails persist for a long time, building up into luminous orbital paths. The hue is based on the initial spawn angle, creating a rainbow spiral.

4. Fireworks — staged particle emission

Fireworks demonstrate a core particle system concept: secondary emission. A rocket particle flies up, dies, and its death triggers the birth of dozens of explosion particles. This staged approach creates complex effects from simple rules.

const canvas = document.createElement('canvas');
canvas.width = 400; canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const rockets = [];
const sparks = [];

function launchRocket() {
  rockets.push({
    x: 100 + Math.random() * 200,
    y: canvas.height,
    vy: -(4 + Math.random() * 3),
    hue: Math.random() * 360,
    targetY: 80 + Math.random() * 150
  });
}

function explode(x, y, hue) {
  const count = 60 + Math.floor(Math.random() * 40);
  for (let i = 0; i < count; i++) {
    const angle = Math.random() * Math.PI * 2;
    const speed = 1 + Math.random() * 3;
    sparks.push({
      x, y,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed,
      life: 1.0,
      decay: 0.008 + Math.random() * 0.012,
      hue: hue + (Math.random() - 0.5) * 30,
      size: 1 + Math.random() * 2
    });
  }
}

function update() {
  ctx.fillStyle = 'rgba(0, 0, 10, 0.15)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  if (Math.random() < 0.02) launchRocket();

  for (let i = rockets.length - 1; i >= 0; i--) {
    const r = rockets[i];
    r.y += r.vy;
    ctx.beginPath();
    ctx.arc(r.x, r.y, 2, 0, Math.PI * 2);
    ctx.fillStyle = `hsl(${r.hue}, 100%, 80%)`;
    ctx.fill();
    if (r.y <= r.targetY) {
      explode(r.x, r.y, r.hue);
      rockets.splice(i, 1);
    }
  }

  for (let i = sparks.length - 1; i >= 0; i--) {
    const s = sparks[i];
    s.vy += 0.03;
    s.vx *= 0.99;
    s.vy *= 0.99;
    s.x += s.vx;
    s.y += s.vy;
    s.life -= s.decay;
    if (s.life <= 0) { sparks.splice(i, 1); continue; }
    ctx.beginPath();
    ctx.arc(s.x, s.y, s.size * s.life, 0, Math.PI * 2);
    ctx.fillStyle = `hsla(${s.hue}, 90%, ${50 + s.life * 30}%, ${s.life})`;
    ctx.fill();
  }
  requestAnimationFrame(update);
}
update();

Two separate particle pools (rockets and sparks) with different physics. Rockets have no gravity and a fixed target altitude. When they reach it, they're removed and replaced by an explosion of sparks that do have gravity and drag. The slight hue variation in sparks (hue + random * 30) gives each explosion a natural color spread rather than a flat uniform color.

5. Flocking — emergent group behavior

Craig Reynolds' "boids" algorithm (1987) shows that three simple rules create strikingly realistic flocking behavior. Each particle (boid) follows these rules relative to nearby neighbors:

  1. Separation — steer away from neighbors that are too close
  2. Alignment — steer toward the average heading of nearby neighbors
  3. Cohesion — steer toward the average position of nearby neighbors
const canvas = document.createElement('canvas');
canvas.width = 400; canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const boids = [];
const N = 150;
const maxSpeed = 2;
const perception = 50;

for (let i = 0; i < N; i++) {
  const angle = Math.random() * Math.PI * 2;
  boids.push({
    x: Math.random() * canvas.width,
    y: Math.random() * canvas.height,
    vx: Math.cos(angle) * maxSpeed,
    vy: Math.sin(angle) * maxSpeed
  });
}

function update() {
  ctx.fillStyle = 'rgba(10, 10, 20, 0.2)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  for (const b of boids) {
    let sepX = 0, sepY = 0, sepCount = 0;
    let aliX = 0, aliY = 0, aliCount = 0;
    let cohX = 0, cohY = 0, cohCount = 0;

    for (const other of boids) {
      if (other === b) continue;
      const dx = other.x - b.x, dy = other.y - b.y;
      const dist = Math.sqrt(dx * dx + dy * dy);
      if (dist < perception) {
        aliX += other.vx; aliY += other.vy; aliCount++;
        cohX += other.x; cohY += other.y; cohCount++;
        if (dist < perception * 0.4) {
          sepX -= dx / dist; sepY -= dy / dist; sepCount++;
        }
      }
    }

    if (sepCount > 0) { b.vx += sepX * 0.15; b.vy += sepY * 0.15; }
    if (aliCount > 0) {
      b.vx += (aliX / aliCount - b.vx) * 0.05;
      b.vy += (aliY / aliCount - b.vy) * 0.05;
    }
    if (cohCount > 0) {
      b.vx += (cohX / cohCount - b.x) * 0.003;
      b.vy += (cohY / cohCount - b.y) * 0.003;
    }

    const speed = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
    if (speed > maxSpeed) { b.vx = (b.vx / speed) * maxSpeed; b.vy = (b.vy / speed) * maxSpeed; }

    b.x += b.vx; b.y += b.vy;
    if (b.x < 0) b.x += canvas.width;
    if (b.x > canvas.width) b.x -= canvas.width;
    if (b.y < 0) b.y += canvas.height;
    if (b.y > canvas.height) b.y -= canvas.height;

    const angle = Math.atan2(b.vy, b.vx);
    const hue = (angle * 180 / Math.PI + 360) % 360;
    ctx.save();
    ctx.translate(b.x, b.y);
    ctx.rotate(angle);
    ctx.beginPath();
    ctx.moveTo(6, 0);
    ctx.lineTo(-3, 3);
    ctx.lineTo(-3, -3);
    ctx.closePath();
    ctx.fillStyle = `hsl(${hue}, 70%, 60%)`;
    ctx.fill();
    ctx.restore();
  }
  requestAnimationFrame(update);
}
update();

Watch how the boids self-organize into flocks, split around obstacles, and rejoin. The three forces balance each other: separation prevents collision, alignment creates coordinated movement, and cohesion keeps the flock together. The hue is mapped to heading direction, making it easy to see when a group moves in unison. This O(n²) approach works fine for ~200 boids; for thousands, you'd use a spatial hash grid to find neighbors efficiently.

6. Galaxy spiral — orbital mechanics as art

Particles orbiting a central mass naturally form spiral patterns, especially when you add a bit of radial drift. This produces mesmerizing galaxy-like formations.

const canvas = document.createElement('canvas');
canvas.width = 400; canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const stars = [];
const cx = canvas.width / 2, cy = canvas.height / 2;

for (let i = 0; i < 2000; i++) {
  const arm = Math.floor(Math.random() * 3);
  const armAngle = (arm / 3) * Math.PI * 2;
  const dist = 20 + Math.random() * 160;
  const spread = (Math.random() - 0.5) * 0.8 * (dist / 160);
  const angle = armAngle + dist * 0.02 + spread;
  stars.push({
    dist,
    angle,
    speed: 0.3 / (dist * 0.1 + 1),
    size: Math.random() < 0.05 ? 1.5 + Math.random() : 0.5 + Math.random() * 0.5,
    brightness: 0.3 + Math.random() * 0.7,
    hue: dist < 40 ? 40 + Math.random() * 20 : 200 + Math.random() * 40 + dist * 0.2
  });
}

function update() {
  ctx.fillStyle = 'rgba(0, 0, 5, 0.08)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  for (const s of stars) {
    s.angle += s.speed * 0.01;
    const x = cx + Math.cos(s.angle) * s.dist;
    const y = cy + Math.sin(s.angle) * s.dist;

    ctx.beginPath();
    ctx.arc(x, y, s.size, 0, Math.PI * 2);
    ctx.fillStyle = `hsla(${s.hue}, 60%, ${50 + s.brightness * 30}%, ${s.brightness * 0.8})`;
    ctx.fill();
  }

  // Bright core glow
  const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, 30);
  grad.addColorStop(0, 'rgba(255, 240, 200, 0.15)');
  grad.addColorStop(1, 'rgba(255, 240, 200, 0)');
  ctx.fillStyle = grad;
  ctx.fillRect(cx - 30, cy - 30, 60, 60);

  requestAnimationFrame(update);
}
update();

The galaxy structure comes from three spiral arms (arm / 3 * 2π), each with particles distributed along a curve that winds tighter near the center (dist * 0.02). Inner stars orbit faster (Keplerian rotation) and are warmer in color (yellow), while outer stars are blue. The spread parameter adds randomness proportional to distance, so the arms fuzz out at the edges — just like real galaxies. The slow background fade creates long persistence, making the spiral structure visible even with small individual stars.

7. Rain and splash — particle collision with surfaces

When particles hit a boundary, something should happen. Bouncing is the simplest response, but spawning secondary particles (like splash droplets) makes it much more convincing. This rain simulation uses both.

const canvas = document.createElement('canvas');
canvas.width = 400; canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const drops = [];
const splashes = [];
const groundY = canvas.height * 0.85;

function spawnDrop() {
  drops.push({
    x: Math.random() * canvas.width,
    y: -10,
    vy: 4 + Math.random() * 4,
    vx: -0.5,
    length: 8 + Math.random() * 12,
    opacity: 0.2 + Math.random() * 0.3
  });
}

function spawnSplash(x) {
  const count = 2 + Math.floor(Math.random() * 3);
  for (let i = 0; i < count; i++) {
    const angle = -Math.PI * (0.2 + Math.random() * 0.6);
    const speed = 1 + Math.random() * 2;
    splashes.push({
      x, y: groundY,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed,
      life: 1.0,
      decay: 0.03 + Math.random() * 0.03,
      size: 1 + Math.random()
    });
  }
}

function update() {
  ctx.fillStyle = '#0a0e14';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // Ground reflection
  const grd = ctx.createLinearGradient(0, groundY, 0, canvas.height);
  grd.addColorStop(0, 'rgba(40, 60, 80, 0.3)');
  grd.addColorStop(1, 'rgba(10, 14, 20, 0.1)');
  ctx.fillStyle = grd;
  ctx.fillRect(0, groundY, canvas.width, canvas.height - groundY);

  for (let i = 0; i < 3; i++) spawnDrop();

  for (let i = drops.length - 1; i >= 0; i--) {
    const d = drops[i];
    d.x += d.vx;
    d.y += d.vy;
    ctx.beginPath();
    ctx.moveTo(d.x, d.y);
    ctx.lineTo(d.x + d.vx * 2, d.y - d.length);
    ctx.strokeStyle = `rgba(150, 180, 220, ${d.opacity})`;
    ctx.lineWidth = 1;
    ctx.stroke();
    if (d.y >= groundY) {
      spawnSplash(d.x);
      drops.splice(i, 1);
    }
  }

  for (let i = splashes.length - 1; i >= 0; i--) {
    const s = splashes[i];
    s.vy += 0.1;
    s.x += s.vx;
    s.y += s.vy;
    s.life -= s.decay;
    if (s.life <= 0 || s.y > groundY + 5) {
      splashes.splice(i, 1); continue;
    }
    ctx.beginPath();
    ctx.arc(s.x, s.y, s.size * s.life, 0, Math.PI * 2);
    ctx.fillStyle = `rgba(150, 190, 240, ${s.life * 0.6})`;
    ctx.fill();
  }
  requestAnimationFrame(update);
}
update();

Rain drops are rendered as streaks (lines from current to previous position) rather than circles — this conveys motion and speed. When a drop hits the ground line, it's destroyed and spawns 2-4 tiny splash particles that arc upward briefly before gravity pulls them back. The wet ground effect uses a simple gradient overlay. This pattern of "particle hits surface → spawn secondary particles" is used everywhere: sparks from grinding metal, water from fountains, debris from impacts.

8. Interactive attraction field — mouse-driven particle art

The most engaging particle systems respond to user input. Here, the mouse creates an attraction/repulsion field that particles swirl around. Move the mouse to sculpt flows of light in real time.

const canvas = document.createElement('canvas');
canvas.width = 400; canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const particles = [];
const N = 800;
let mx = canvas.width / 2, my = canvas.height / 2;
let attract = true;

for (let i = 0; i < N; i++) {
  particles.push({
    x: Math.random() * canvas.width,
    y: Math.random() * canvas.height,
    vx: 0, vy: 0,
    hue: Math.random() * 360
  });
}

canvas.addEventListener('mousemove', (e) => {
  const rect = canvas.getBoundingClientRect();
  mx = e.clientX - rect.left;
  my = e.clientY - rect.top;
});

canvas.addEventListener('mousedown', () => attract = !attract);

function update() {
  ctx.fillStyle = 'rgba(5, 5, 10, 0.05)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  for (const p of particles) {
    const dx = mx - p.x, dy = my - p.y;
    const dist = Math.sqrt(dx * dx + dy * dy) + 0.1;
    const force = attract ? 50 / (dist + 10) : -30 / (dist + 5);
    const ax = (dx / dist) * force * 0.01;
    const ay = (dy / dist) * force * 0.01;
    p.vx += ax; p.vy += ay;
    p.vx *= 0.99; p.vy *= 0.99;
    p.x += p.vx; p.y += p.vy;

    // Wrap around edges
    if (p.x < 0) p.x += canvas.width;
    if (p.x > canvas.width) p.x -= canvas.width;
    if (p.y < 0) p.y += canvas.height;
    if (p.y > canvas.height) p.y -= canvas.height;

    const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
    const brightness = Math.min(70, 30 + speed * 15);
    p.hue = (p.hue + speed * 0.5) % 360;

    ctx.beginPath();
    ctx.arc(p.x, p.y, 1, 0, Math.PI * 2);
    ctx.fillStyle = `hsl(${p.hue}, 80%, ${brightness}%)`;
    ctx.fill();
  }
  requestAnimationFrame(update);
}
update();

Move your mouse over the canvas — particles rush toward the cursor (or flee from it if you click to toggle repulsion mode). The attraction force uses inverse-distance falloff, so nearby particles feel it strongly while distant ones drift gently. The hue shifts with speed, creating color waves as particles accelerate and decelerate. The slow trail fade turns the canvas into a glowing record of particle motion over time. This is the pattern behind interactive installations in museums and public spaces — simple physics, dramatic response to human gesture.

Performance tips for particle systems

  • Object pooling beats garbage collection. Instead of creating and destroying objects, keep a fixed array and mark particles as alive/dead. Reuse dead slots for new particles. This avoids GC pauses that cause visible stuttering.
  • Use typed arrays for hot data. For thousands of particles, store x, y, vx, vy in separate Float32Arrays rather than objects. Array-of-structs is easier to read; struct-of-arrays is faster to process.
  • Spatial hashing for neighbor queries. Flocking and interaction forces require checking nearby particles. A grid-based spatial hash turns O(n²) into O(n·k) where k is the average number of neighbors — essential above ~500 interacting particles.
  • Canvas compositing for glow effects. Set ctx.globalCompositeOperation = 'lighter' (additive blending) to make overlapping particles glow. This is free in terms of performance and dramatically improves visual quality for fire, sparks, and energy effects.
  • Batch rendering when possible. Drawing 1000 individual arc() calls is slow. For uniform particles, build an ImageData buffer and write pixels directly (as in the fluid simulation examples). For varied particles, consider WebGL instanced rendering.

Extending particle systems into art

The eight examples here cover the fundamentals: emission, forces, trails, collision, flocking, and interaction. But particle systems become truly artistic when you combine them with other techniques:

On Lumitree, particle systems are the beating heart of many micro-worlds. Firefly clouds that pulse in synchrony, dandelion seeds that drift on invisible wind, aurora borealis ribbons that shimmer with charged particles — each is a unique particle system born when a visitor plants a seed. Every branch on the tree is a self-contained universe under 50KB, and particle systems are one of the most efficient ways to create rich, animated scenes within that constraint.

Start with example 1 to understand the basics. Move to example 5 for emergent behavior that will surprise you. Play with example 8 to see how direct interaction transforms particles from a screensaver into an instrument. Then combine forces, trails, and flocking into something that's never existed before — that's where particle art lives.

Related articles