Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 17.2Advanced ~29 min · shot 2 of 6

Particles

In one line

A particle is a tiny object with a position, a velocity and a lifetime. This topic builds an emitter with gravity and drag, reuses particle objects from a pool with a hard cap, and measures in headless Chromium how many a machine can afford.

0/6 · 0%

Think of it like this

A confetti cannon with a cartridge of 150 pieces of paper. Each shot fires pieces from the cartridge, each piece flutters down, and when it lands it goes back into the cartridge. If you shoot faster than the paper comes back, the cannon simply fires fewer pieces. It never prints new paper.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

particle
A small object with a position, a velocity and a lifetime, updated by the same rules as all the others.
emitter
The code that creates particles at a point with random speeds and directions.
object pool
A fixed set of objects made once and reused. Particles are marked free or in use instead of being created and thrown away.
drag
A force that slows things down in proportion to their speed, like air resistance.
seed
The starting number of a seeded random generator. The same seed always gives the same numbers.
garbage collector
The part of the browser that frees objects nobody uses. When it runs, the page can pause for a moment.

Step by step

01Read the fixed-step run

"Emit, move, die" first runs five particles by hand with a seed and a fixed step of 1/60 s. Its three lines never change. Then it hands the same functions to a live canvas: click the canvas for a burst.

02Change gravity

Set GRAVITY = 0. After 0.5 s particle 0 is at (98.0, 47.6): it keeps going up and fades out. With gravity 600 it was at y 107.9, already falling.

03Change drag

Set DRAG = 0. The particle is at (75.2, 102.1) after 0.5 s and flies off the left side by 1.5 s (x = −74.3), because nothing slows it.

04Fill the pool

In "Reuse, don't allocate" change createPool(200) to 300. Peak alive becomes 240 and dropped becomes 0. Change LIFE to 0.3: peak alive is 120, nothing dropped.

05Push the count

In "How many?" press 10,000 and watch the frame strip under the viewfinder. Switch the shape to rects. The page prints how many canvas calls each frame makes.

Try it yourself

  1. 1

    Turn gravity off

    In "Emit, move, die" set GRAVITY = 0. The three lines become y = 47.6, 22.9 and 11.2: the particle keeps rising, slowing only through drag.

  2. 2

    Turn drag off

    Set DRAG = 0. After 0.5 s: x = 75.2, y = 102.1; after 1 s: x = 0.4, y = 254.2; after 1.5 s: x = −74.3, y = 556.3. The particle leaves the 300 pixel canvas.

  3. 3

    Make the pool bigger

    In "Reuse, don't allocate" try createPool(300): pool of 300: 300 objects made, peak alive 240 and dropped … 0.

Code & diagrams

Emit, move, die

An emitter, a gravity-and-drag step, and a seeded fixed-step test whose numbers never change. Click the canvas for a burst (seeded too). Particles are removed by filter(), which allocates: see the next example for the fix.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

start: x=150.0 y=100.0
after 0.5 s: x=98.0 y=107.9 alive=5
after 1 s: x=73.5 y=215.9 alive=3
after 1.5 s: x=61.9 y=371.1 alive=0
Reuse, don't allocate

The same 50 bursts twice. The pool makes its objects once, and a full pool makes fewer particles instead of failing.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

no pool: 2000 objects made for 2000 particles
pool of 200: 200 objects made, peak alive 200
dropped because the pool was full: 320
How many?

Press a count. The page does not time itself (the timeline's clock would hide real time): watch the frame strip under the viewfinder instead. The console shows what one oversized step does to a falling particle.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

one 16.7 ms frame: falls 0.2 px
one 5000 ms step, not clamped: falls 15000.0 px
same step clamped to 50 ms: falls 1.5 px

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Allocate a new particle for every burst

In "Reuse, don't allocate", count what the list approach really makes for 500 bursts of 40: change BURSTS = 50 to BURSTS = 500.

terminal
$ Run and read the first line
── what you'll see ──
no pool: 20000 objects made for 20000 particles
pool of 200: 200 objects made, peak alive 200
dropped because the pool was full: 3320
(each burst makes 40 new objects and the old ones become garbage; the pool still holds 200)

Myth vs fact

Myth

Drawing a pre-made sprite is always faster than drawing a shape.

Fact

In our headless Chromium run, drawImage of an 8 pixel sprite took 31 to 55 ms for 10,000 particles, against 14 to 20 ms for arcs and 5 to 6 ms for squares (two runs). It depends on the browser, canvas type and GPU. Measure the one you target.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    find((q) => !q.alive) scans the pool from the start every time. For 150 objects it is nothing, for 50,000 it is not. Keep a free-list (a stack of free indexes) or a write cursor that wraps around, so finding a free slot costs the same however large the pool is.

  • ▸

    Order matters when a burst uses a seed: the pool hands out free objects in array order, so the same seed gives the same burst only if the pool's state is the same. Reset the pool in tests.

  • ▸

    A burst that is dropped silently is a decision. For confetti it is invisible. For an effect that carries meaning (a hit marker, a validation spark) keep the particles that matter and drop the old ones first.

Remember this

  1. 1

    The problem this solves: sparks, confetti, snow and dust are many small things that live for a second. Making a fresh object for each one on every burst leaves thousands of dead objects for the garbage collector, which cleans up in pauses you see as stutter. And with no upper limit, one button pressed in a loop can freeze a phone. A particle system gives every particle the same simple rules, reuses its objects, and has a cap.

  2. 2

    What a particle is. An object with x, y, a velocity vx, vy, an age and a life. An emitter creates particles with random starting values: here a fan of directions, mostly upward, and speeds between 200 and 400 pixels per second. Every frame each particle gets the same update: gravity adds to vy, drag slows it, it moves by velocity × dt, and it dies when age reaches life. All the variety comes from the random start, never from special cases.

  3. 3

    Gravity and drag, in time. Gravity is an acceleration: vy += GRAVITY × dt. Drag multiplies the velocity by Math.exp(-DRAG × dt), so the same share is lost per second at 30, 60 and 120 Hz. The simple vx *= 0.98 per frame loses far more per second on a 120 Hz screen than on a 30 Hz one (Topic 17.4 measures this for a follower). Run "Emit, move, die": with seed 7, particle 0 starts at (150, 100) and after 0.5 s is at (98.0, 107.9), after 1 s at (73.5, 215.9). Three of five are still alive at 1 s and none at 1.5 s.

  4. 4

    Seeded random numbers. Math.random() gives a different burst every time, which makes a bug impossible to repeat. mulberry32(seed) is a small generator: the same seed gives the same list of numbers on every machine (Topic 17.3). Use a seed in tests and the demo above, and Math.random in production, passed in as an argument as burst(x, y, random) does.

  5. 5

    Object pooling and the cap. A pool makes all particle objects once. "Dying" only sets alive = false, and "emitting" finds a free object and sets its fields. In "Reuse, don't allocate", 50 bursts of 40 particles made 2,000 objects the new-object way and 200 with a pool. That pool of 200 was full at its peak, so 320 particles were not made. That is the cap working: a burst that finds no free object makes fewer pieces, instead of making the frame longer. With 300 objects, the peak was 240 alive and nothing was dropped. With particles that live 0.3 s instead of 0.6 s, the peak is 120 and a pool of 200 never fills.

  6. 6

    How many can a machine afford? One frame has 16.7 ms at 60 Hz, and 8.3 ms at 120 Hz. We measured the JavaScript plus the drawing of one frame, with the result read back so the work is really finished, in headless Chromium 153 on this machine (software canvas, an 800 by 500 canvas, median of 40 frames). The numbers moved between two runs, so here is the range. Arcs (three canvas calls per particle) took 1.5 to 1.9 ms for 1,000, about 9 ms for 5,000 and 14 to 20 ms for 10,000. Squares from fillRect (one call) took 0.5 to 0.7, 2.3 to 3.0 and 4.7 to 6.0 ms. With the CPU slowed 4× (Chrome DevTools' throttling, through the same protocol) the arcs took 9.5 to 12, 42 to 56 and 95 to 159 ms: 10,000 arcs would run at 6 to 10 frames a second. Drawing a prebuilt sprite with drawImage was slower than both here (2.6 to 3.5, 14 to 21 and 31 to 55 ms), so do not assume the usual advice: measure on your own device. These are headless numbers, not a promise for a real phone, and only the order (squares, then arcs, then sprites) stayed the same.

  7. 7

    A tab switch in the middle of a burst. If the tab hides for 5 s and one frame then arrives, an unclamped step moves a falling particle 15,000 px in one jump, while a clamped 50 ms step moves it 1.5 px. The ticker's maxDelta (Phase 8) does this for the course. A celebratory burst is also decoration: with reduced motion switched on it should not play at all, and its flashing must stay well under three flashes a second.

Explain it without notes

01

Why does a particle system use a pool and a cap?

02

Why is drag written as Math.exp(-DRAG × dt) and not vx *= 0.98?

03

What would you measure before choosing a particle count?

Practice

01

Change createPool(size) so that spawn runs in constant time, using a stack of free indexes.

02

Make emit in "Emit, move, die" shoot a ring of n particles evenly around a circle, with the same speed.

Trade-offs

  • ↔

    A pool uses its memory all the time, even when nothing is happening. A small cap (150 here) keeps that cost trivial; a huge one is memory spent for nothing.

Done when you can

  • You can write an emitter and an update step with gravity and drag in seconds, not frames

  • You can reuse objects from a pool and say what the cap does

  • You can measure how many particles a device affords, and say where the numbers came from

  • You can explain what a clamped step does after a tab switch