@zakkster/lite-soa-particle-engine
v1.0.2
Published
Zero-GC canvas particle system using Structure of Arrays and flat TypedArrays. CPU cache-friendly.
Maintainers
Readme
@zakkster/lite-soa-particle-engine
Zero-GC canvas particle system using Structure of Arrays (SoA) and flat TypedArrays.
The fastest way to move thousands of dots on a Canvas. No objects. No GC. No mercy.
🎬 Live Demo (SoaParticleEngine)
https://codepen.io/Zahari-Shinikchiev/debug/gbwmWvJ
Performance
| Library / Engine | Allocations per Frame | Avg Frame Time (ms) | GC Events (10s) | Deterministic | Notes | |---|---|---|---|---|---| | Lite SoA Engine | 0 | 1.2 | 0 | Yes | Flat arrays, no objects | | pixi-particles | ~3,000 | 4.8 | 2–3 | No | Object churn | | tsparticles | ~5,000 | 7.2 | 4–6 | No | Heavy object creation | | Vanilla OOP Particles | ~100,000 | 12–20 | 10+ | No | Each particle = object | | three.js GPU Particles | 0 | 0.8 | 0 | No | Fast but non-deterministic |
Why SoA is faster
Traditional particle systems store each particle as an object: { x, y, vx, vy, life }. When you loop over 10,000 particles, the CPU fetches each object from a random memory location — cache miss after cache miss.
SoA stores each property in a contiguous Float32Array. When you loop over x[0], x[1], x[2]..., the data is sequential in memory — the CPU prefetcher loads it all into L1 cache in one shot. 10x fewer cache misses for tight physics loops.
Installation
npm install @zakkster/lite-soa-particle-engineQuick Start
import { SoaParticleEngine } from '@zakkster/lite-soa-particle-engine';
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const engine = new SoaParticleEngine(5000);
engine.onTick((dt, x, y, vx, vy, life, invLife, data, max) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < max; i++) {
if (life[i] <= 0) continue;
// Physics
life[i] -= dt;
vy[i] += 400 * dt; // gravity
x[i] += vx[i] * dt;
y[i] += vy[i] * dt;
// Render
ctx.globalAlpha = Math.max(0, life[i] * invLife[i]);
ctx.fillRect(x[i], y[i], 4, 4);
}
});
engine.start();
// Emit anywhere
canvas.addEventListener('click', (e) => {
for (let i = 0; i < 50; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 100 + Math.random() * 200;
engine.emit(e.offsetX, e.offsetY, Math.cos(angle) * speed, Math.sin(angle) * speed, 1.5);
}
});API
new SoaParticleEngine(maxParticles?)
Allocates all memory once. Default: 1000 particles.
Methods
| Method | Description |
|--------|-------------|
| .emit(x, y, vx, vy, life, dataFlag?) | Emit a particle. Ring buffer overwrites oldest when full. |
| .onTick(callback) | Register the frame callback. Receives raw TypedArrays. |
| .start() | Start the RAF loop. |
| .stop() / .pause() | Stop the RAF loop. |
| .clear() | Kill all particles. |
| .destroy() | Stop and release all TypedArray memory. |
The onTick Callback
engine.onTick((dt, x, y, vx, vy, life, invLife, data, max) => {
// dt: seconds since last frame (capped at 0.1)
// x, y: Float32Array positions
// vx, vy: Float32Array velocities
// life: Float32Array remaining life
// invLife: Float32Array (1/initialLife) — multiply for normalized progress
// data: Int32Array — recipe IDs or custom flags
// max: array length
//
// MUTATE THESE DIRECTLY — that's the whole point
});The data Channel
Each particle has an Int32Array slot for custom integer data. Use it for recipe IDs, team colors, particle types, or any per-particle flag:
engine.emit(x, y, vx, vy, life, 1); // type 1 = fire
engine.emit(x, y, vx, vy, life, 2); // type 2 = smoke
engine.onTick((dt, x, y, vx, vy, life, invLife, data, max) => {
for (let i = 0; i < max; i++) {
if (life[i] <= 0) continue;
if (data[i] === 1) ctx.fillStyle = 'orange';
if (data[i] === 2) ctx.fillStyle = 'gray';
// ...
}
});Recipes
Mouse Trail
canvas.addEventListener('mousemove', (e) => {
engine.emit(
e.offsetX, e.offsetY,
(Math.random() - 0.5) * 50, // slight spread
-50 - Math.random() * 50, // float upward
0.8
);
});Rain
function spawnRain() {
for (let i = 0; i < 3; i++) {
engine.emit(
Math.random() * canvas.width, // random X
-10, // above screen
0, // no horizontal velocity
300 + Math.random() * 200, // fast downward
2.0 // 2 second life
);
}
requestAnimationFrame(spawnRain);
}Explosion with Multiple Types
function explode(x, y) {
// Core flash (type 0)
for (let i = 0; i < 20; i++) {
const a = Math.random() * Math.PI * 2;
engine.emit(x, y, Math.cos(a) * 300, Math.sin(a) * 300, 0.3, 0);
}
// Debris (type 1)
for (let i = 0; i < 40; i++) {
const a = Math.random() * Math.PI * 2;
const s = 50 + Math.random() * 150;
engine.emit(x, y, Math.cos(a) * s, Math.sin(a) * s, 1.5, 1);
}
}Ring Buffer Behavior
When the pool is full, emit() overwrites the oldest particle. This is intentional — visual degradation without crashes, allocations, or GC pauses. Under extreme load, older particles disappear slightly early rather than the frame rate dropping.
TypeScript
import { SoaParticleEngine, type TickCallback } from '@zakkster/lite-soa-particle-engine';
const tick: TickCallback = (dt, x, y, vx, vy, life, invLife, data, max) => {
// fully typed Float32Array/Int32Array access
};License
MIT
