PRJ-007 · software-systems
ParticleBench
Open source · Author & maintainer · 2026
An open-source benchmarking harness that answers one question precisely: how many particles can this device animate at 60fps, under which architecture — CPU, transform feedback, or GPGPU?
Core Philosophy
Every WebGL tutorial claims its particle technique is fast, and almost none of them measure. ParticleBench exists because 'fast' is an empirical claim. The harness holds visual output constant — same simulation, same blending, same camera — and swaps only the data architecture, so the numbers isolate what actually matters.
The discipline extends to statistics: frame times are reported as P50/P95/P99 with warm-up discarded, because averages flatter stutter. If a technique hitches every 60 frames, the harness makes that visible instead of hiding it in a mean.
Outcomes
- 310+ GitHub stars; results cited in two university courses
- Benchmarked 5 architectures across 40+ community-submitted devices
- Found a 22× spread between naive CPU and GPGPU ping-pong at 1M particles
Deliverables
- Benchmark suite (5 particle architectures)
- Statistical frame-time analyzer
- Public results explorer
Technical Anatomy
Architecture matrix
Five implementations of the same curl-noise simulation: CPU + BufferGeometry, CPU + instancing, transform feedback, texture ping-pong GPGPU, and fragment-shader-only. Each exposes identical knobs (count, size, blending) to keep comparisons honest.
Frame-time statistics
Timing uses a calibrated rAF-delta collector (WebGL timer queries where available). The analyzer discards the first 300 frames, then reports percentiles and a stutter index — frames exceeding 2× the median.
const sorted = deltas.slice(300).sort((a, b) => a - b);
const p = (q: number) => sorted[Math.floor(q * sorted.length)];
return { p50: p(0.5), p95: p(0.95), p99: p(0.99),
stutter: sorted.filter((d) => d > 2 * p(0.5)).length };
GPGPU ping-pong core
Positions and velocities live in two float textures swapped each frame. The vertex stage samples position by particle index, so a million particles cost one draw call and zero CPU-side iteration.
vec4 pos = texture(uPositions, uvFromIndex(gl_VertexID));
gl_Position = uViewProjection * vec4(pos.xyz, 1.0);