Particle System
Drive many small elements with simple per-particle rules to get emergent, organic motion. Use 2D canvas for hundreds, GPU Points for thousands.
When to use
- Particle/constellation backgrounds and ambient motion.
- Celebratory bursts: confetti, sparks. Weather: snow, rain. Volumetric: smoke.
- Flow-field / curl-noise swirls and data-driven point clouds.
- Connected-dot networks (lines between nearby particles).
Core loop: integrate per particle
Each particle holds state and is advanced every frame: accumulate forces into acceleration, integrate velocity and position, age it, respawn when dead. Scale by dt for frame-rate independence.
Prefer semi-implicit Euler (update velocity first, then position with the new velocity, as above) — it is stable for the spring/drag forces particles use. Use a fixed or clamped dt (Math.min(dt, 1/30)) so a stalled tab does not explode the simulation.
Forces
A force is a function returning an acceleration [fx, fy]. Compose a list.
Repulsion is attract with negative strength. Springs toward a home position give "settle back" effects.
Flow fields / curl noise (organic swirl)
Sample a noise field to derive a velocity direction per particle. Use the noise value as an angle:
True curl noise is divergence-free (no sources/sinks → fluid-like). Compute the curl of a potential by finite differences:
Add time to the noise input (noise2D(x*scale, y*scale + t)) to make the field evolve.
Emission: burst vs continuous
- Burst (confetti, sparks): spawn N particles at once at a point with randomized angle/speed within a cone, then let gravity + drag take over. No respawn — remove when dead.
- Continuous (snow, smoke): spawn a steady rate; respawn dead particles at the top/source.
Randomize within a range for natural spread: angle = base + (Math.random()-0.5)*spread; speed = min + Math.random()*(max-min).
Confetti reads as confetti because of rotation + flat rectangles + gravity + air drag, not round dots. Snow reads as snow from slow fall + gentle horizontal sine sway + size-varied depth.
Connected-dot network without O(n²)
Naively checking every pair is O(n²) and dies past ~300 particles. Use a uniform spatial grid: bin particles by cell, only compare against the 8 neighboring cells.
This is O(n) for evenly distributed particles. Each pair is found twice; halve work by only checking forward neighbors if needed.
GPU particles: Three.js Points + shader
For thousands+, push all positions into a BufferGeometry and render as Points. Animate in the vertex shader for true GPU scale.
AdditiveBlending + depthWrite: false gives the glowing-particle look. discard on gl_PointCoord distance makes square points round. For per-particle data (life, seed), add custom attributes and read them in the shader.
Deliver & verify (standalone HTML)
Packaged helper (
scripts/):scripts/seek-shot.sh anim.html 0 1.5 3freezes the?t=Nharness and screenshots each moment;scripts/contact-sheet.sh sheet.png frame-*.pngtiles them for one-glance review. Seescripts/README.md.
For a self-contained particle effect (constellation background, confetti burst, flow field, GPU points) the deliverable is one HTML file that opens directly in a browser — canvas 2D inline, or Three.js from a CDN via an importmap for GPU Points, one render loop, no build step. A single file is the right tier; don't reach for a bundler when one file does the job.
Output contract:
- One
.html: for canvas, the simulation + 2D draw loop in one inline<script>; for GPU points, importmap pinsthreeto a CDN with thePointssetup inline. - Drive the sim from one accumulated
time(sum of clampeddt, orclock.getElapsedTime()/u_timefor GPU). NoDate.now()scattered per particle. - Seed the RNG — replace bare
Math.random()with a seeded PRNG (e.g. mulberry32) so spawn positions, angles, and bursts reproduce frame-for-frame.
Seek/freeze harness — advance to a fixed time, render ONE frame for screenshots. ?t=N re-seeds, steps the sim deterministically to N seconds with a fixed timestep, renders once, and stops the loop.
Verify loop — render → freeze → screenshot → check: open at three instants — start, mid, settle (?t=0, ?t=<mid>, ?t=<end>; for a burst, t≈0 spawn / t≈0.5 spread / t≈1.5 settle) — screenshot each, and check both fidelity (matches the brief) and artifacts: a blank canvas = parse/init error (check the console), particles escaping the frame (clamp/wrap missing), NaN positions (everything vanishes), all particles bunched at the origin (RNG not wired). For GPU points, WebGL needs a GPU context; Playwright/Chromium supplies one (swiftshader) headless.
Before you finish:
- Canvas renders particles — not blank, no console/WebGL errors, no CDN 404s.
?t=Nfreezes a reproducible frame (seeded RNG + fixed timestep → same N → same pixels).- Screenshotted at start / mid / settle — matches the brief, no escaped/NaN/origin-bunched particles.
- Disposed and leak-free if embedded in an SPA (cancel the rAF loop; for GPU, dispose geometry/material/renderer).
prefers-reduced-motionhonored — fewer particles or a static field where motion is decorative.
Quick reference
Reference files
references/particle-recipes.md— Complete canvas confetti, snow, and smoke systems; mouse attraction/repulsion; full simplex flow-field and curl-noise field with a rendered streaming look; the spatial-grid connected-dot background end to end; and a GPUPointssystem with per-particle life/seed attributes, additive glow, and respawn in the shader.

