Rebuilding particlr's spatial hash for 2,500 live particles

PHASE05
DATE2026-07-10

What shipped

Phase 5 replaces the naive all-pairs neighbor query with a uniform spatial hash. Collision-reactive presets (the ember-* family and everything using repelNeighbors) now hold frame budget at 2,500 particles, up from ~800.

The hash lives in src/sim/spatialHash.ts and is rebuilt at the top of each fixed tick before behaviors run. Behaviors query it through a single neighborsOf(p, radius) call; none touch particle arrays directly anymore.

Decisions

Cell size was the main argument. Adaptive sizing (cells scaled to the preset’s max interaction radius) looked right on paper, but measuring it showed the rebuild cost eating the win: presets keep particle interaction radii within a narrow band, so a fixed 64px cell was never more than one lookup from optimal. A quadtree was rejected without prototyping — rebuild-per-tick favors flat structures, and the fixed-timestep loop makes per-tick rebuild the simplest correctness story.

Rebuild-vs-incremental went the same way. Incremental updates only pay off when a minority of particles move per tick; in particlr everything moves every tick.

What broke

First implementation hashed on render position, not sim position, which worked until interpolation was on — then neighbors flickered at cell boundaries at high time scales. Cost an evening. The fix was hashing on the fixed-step sim position only, and it exposed that two behaviors were reading render position for logic, which is now lint-blocked.

Numbers

Measured on the usual bench rig (M-series laptop throttled profile, 2,500 particles, ember-drift preset, 10s capture, p95). Neighbor pass went from 11.8ms all-pairs at 800 particles to 3.1ms hashed at 2,500. Rebuild is 0.4ms, flat with respect to particle motion.

Live demo — the ember-field preset running on the current runtime build.

demo ▸ ember-field

Next

Phase 6 targets the renderer: sprite batching audit and moving per-particle tint math into the shader. Goal is 5,000 particles inside the same budget.

Decisions
DecisionWhyAlternatives rejected
Fixed 64px cell size instead of adaptive cellsPresets cluster particle sizes tightly; adaptive sizing added a rebuild cost with no measured winAdaptive cell sizing per preset, Quadtree
Hash rebuilt every tick rather than incrementally updatedFull rebuild is 0.4ms at 2,500 particles; incremental bookkeeping was slower to write and easier to get wrongIncremental insert/remove on movement
Benchmarks
MetricValueTarget
neighbor pass @ 2500 particles3.1ms<4.16ms (240Hz budget)
hash rebuild @ 2500 particles0.4ms<1ms