PRJ-007 · software-systems

ParticleBench

Open source · Author & maintainer · 2026

ParticleBench

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);

Back to Index