Context: Porting a MonoGame C# desktop particle system to SvelteKit. The source system uses a 512-particle pool, warm-toned ambient particles with low alpha (25–40), spawn regions as percentages of screen, velocity ranges, 4–9 second lifespans, quadratic ease-in/out fading, campfire sparks, and layered rendering (behind terrain vs. after UI).
Canvas 2D runs entirely on the CPU. Each frame, you clear the canvas (or clear dirty regions), iterate your particle array, and call fillRect, arc, or drawImage for each particle. The browser's compositing pipeline then pushes the canvas bitmap to the GPU for display — but the particle math and draw calls all happen in JavaScript.
Pre-allocate all particle objects at startup and never create new ones at runtime. This keeps the garbage collector quiet during animation frames.
interface Particle {
active: boolean;
x: number; y: number;
vx: number; vy: number;
alpha: number;
age: number;
lifespan: number; // seconds
r: number; g: number; b: number;
baseAlpha: number; // max alpha (0-1)
}
class ParticlePool {
private pool: Particle[];
private capacity: number;
constructor(capacity = 512) {
this.capacity = capacity;
// Pre-allocate entire pool at startup — no new objects at runtime
this.pool = Array.from({ length: capacity }, () => ({
active: false,
x: 0, y: 0, vx: 0, vy: 0,
alpha: 0, age: 0, lifespan: 0,
r: 255, g: 200, b: 150, baseAlpha: 0
}));
}
acquire(): Particle | null {
for (let i = 0; i < this.capacity; i++) {
if (!this.pool[i].active) {
this.pool[i].active = true;
return this.pool[i];
}
}
return null; // pool exhausted
}
release(p: Particle): void {
p.active = false;
// Reset state so the next acquire gets a clean object
p.age = 0;
p.alpha = 0;
}
get activeParticles(): Particle[] {
return this.pool.filter(p => p.active);
}
}
The key insight from web.dev's memory pooling guide: because pooled objects are never dereferenced from a live array, the GC never collects them. All memory is allocated up front at init(), and the runtime stays in a static memory state.
Map particle age to alpha using a smooth quadratic curve that peaks in the middle of the lifespan:
function computeAlpha(age: number, lifespan: number, baseAlpha: number): number {
const t = age / lifespan; // 0..1
let envelope: number;
if (t < 0.2) {
// Fade in: quadratic ease-in over first 20% of life
const u = t / 0.2;
envelope = u * u;
} else if (t > 0.8) {
// Fade out: quadratic ease-out over last 20% of life
const u = (1.0 - t) / 0.2;
envelope = u * u;
} else {
envelope = 1.0;
}
return baseAlpha * envelope;
}
This matches the MonoGame source's soft fade behavior. For the Sea of Galilee scene, baseAlpha would be 40/255 ≈ 0.157; for Jerusalem dust, 25/255 ≈ 0.098.
Group particles by color before drawing to minimize fillStyle reassignments. Each state change flushes the canvas state machine.
function renderParticles(
ctx: CanvasRenderingContext2D,
particles: Particle[]
): void {
ctx.save();
// Sort by color family to batch fillStyle changes
// For our use case we have 2 color groups: beige and amber — sort is cheap
const sorted = particles.slice().sort((a, b) => a.r - b.r);
let lastR = -1, lastG = -1, lastB = -1;
for (const p of sorted) {
if (!p.active) continue;
const alpha = computeAlpha(p.age, p.lifespan, p.baseAlpha);
if (alpha <= 0) continue;
if (p.r !== lastR || p.g !== lastG || p.b !== lastB) {
ctx.fillStyle = `rgb(${p.r},${p.g},${p.b})`;
lastR = p.r; lastG = p.g; lastB = p.b;
}
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.arc(p.x, p.y, 2, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
shadowBlur is expensive — avoid it; simulate glow via globalCompositeOperation: 'lighter' instead.x | 0) to avoid sub-pixel anti-aliasing overhead.The GPU advantage becomes meaningful above ~3,000–5,000 particles. Below that threshold, the overhead of setting up WebGL buffers, shaders, and state can exceed Canvas 2D cost on simpler content. For our 512-particle ambient system, WebGL is overkill — but understanding it matters for campfire sparks that might burst to higher counts.
WebGL point sprites render each particle as a single vertex that the GPU expands into a screen-aligned quad. This avoids sending 4 vertices per particle.
// Vertex shader
attribute vec2 a_position;
attribute float a_alpha;
attribute float a_size;
uniform mat3 u_transform;
varying float v_alpha;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
gl_PointSize = a_size;
v_alpha = a_alpha;
}
// Fragment shader
precision mediump float;
varying float v_alpha;
uniform vec3 u_color;
void main() {
// Soft circular falloff from center of point sprite
vec2 coord = gl_PointCoord - 0.5;
float dist = length(coord);
if (dist > 0.5) discard;
float softness = 1.0 - smoothstep(0.3, 0.5, dist);
gl_FragColor = vec4(u_color, v_alpha * softness);
}
For systems requiring 10,000+ particles, keep particle state entirely on the GPU. The technique uses two buffers that ping-pong: one buffer is read by the update shader, the transform feedback writes to the other, then they swap roles. Each particle stores 6 floats (24 bytes): position XY, velocity XY, age, lifespan.
// Float layout per particle: [x, y, vx, vy, age, lifespan]
const FLOATS_PER_PARTICLE = 6;
const particleData = new Float32Array(maxParticles * FLOATS_PER_PARTICLE);
// Particle update happens entirely on GPU — no CPU-GPU transfer per frame
// The vertex shader increments age and respawns particles past lifespan
This approach renders 100,000–500,000 particles at 60fps. For our use case it is unnecessary, but is documented here as an upgrade path if the scene ever needs dense volumetric effects.
A benchmark from Geeks3D shows point sprites are fastest for particles; geometry instancing is ~10% slower (but gives more control over shape), while geometry shaders (not supported in WebGL 2 at all) are 30% slower. For our soft ambient particles, point sprites are the right choice if WebGL is used.
An emitter owns a particle pool and controls spawn parameters. For our scenes, emitters are configured per-scene with spawn regions as fractions of screen dimensions — matching the MonoGame source pattern exactly.
interface EmitterConfig {
poolSize: number; // max 512
spawnRate: number; // particles per second
spawnRegion: {
xMin: number; xMax: number; // 0..1 fraction of screen width
yMin: number; yMax: number; // 0..1 fraction of screen height
};
velocity: {
xMin: number; xMax: number; // px/s
yMin: number; yMax: number;
};
lifespan: { min: number; max: number }; // seconds
color: { r: number; g: number; b: number };
baseAlpha: number; // peak alpha (0..1)
}
// Sea of Galilee config
const galileeAmbient: EmitterConfig = {
poolSize: 512,
spawnRate: 30,
spawnRegion: { xMin: 0, xMax: 1, yMin: 0.1, yMax: 0.9 },
velocity: { xMin: -8, xMax: 8, yMin: -4, yMax: 4 },
lifespan: { min: 4, max: 9 },
color: { r: 255, g: 220, b: 170 }, // warm beige
baseAlpha: 40 / 255
};
// Jerusalem dust config
const jerusalemDust: EmitterConfig = {
poolSize: 512,
spawnRate: 25,
spawnRegion: { xMin: 0, xMax: 1, yMin: 0.3, yMax: 1.0 },
velocity: { xMin: -5, xMax: 15, yMin: -2, yMax: 2 }, // horizontal drift
lifespan: { min: 5, max: 9 },
color: { r: 255, g: 200, b: 120 }, // warm amber
baseAlpha: 25 / 255
};
class ParticleEmitter {
private pool: ParticlePool;
private config: EmitterConfig;
private spawnAccumulator = 0;
update(dt: number, canvasW: number, canvasH: number): void {
// Spawn new particles based on rate
this.spawnAccumulator += this.config.spawnRate * dt;
while (this.spawnAccumulator >= 1) {
this.spawnOne(canvasW, canvasH);
this.spawnAccumulator -= 1;
}
// Update all active particles
for (const p of this.pool.activeParticles) {
p.age += dt;
if (p.age >= p.lifespan) {
this.pool.release(p);
continue;
}
p.x += p.vx * dt;
p.y += p.vy * dt;
p.alpha = computeAlpha(p.age, p.lifespan, p.baseAlpha);
}
}
private spawnOne(canvasW: number, canvasH: number): void {
const p = this.pool.acquire();
if (!p) return;
const r = this.config.spawnRegion;
const v = this.config.velocity;
p.x = (r.xMin + Math.random() * (r.xMax - r.xMin)) * canvasW;
p.y = (r.yMin + Math.random() * (r.yMax - r.yMin)) * canvasH;
p.vx = v.xMin + Math.random() * (v.xMax - v.xMin);
p.vy = v.yMin + Math.random() * (v.yMax - v.yMin);
p.lifespan = this.config.lifespan.min +
Math.random() * (this.config.lifespan.max - this.config.lifespan.min);
p.age = 0;
p.r = this.config.color.r;
p.g = this.config.color.g;
p.b = this.config.color.b;
p.baseAlpha = this.config.baseAlpha;
}
}
| Renderer | Particle Count at 60fps | Notes |
|---|---|---|
| Canvas 2D | 500–2,000 | CPU-bound; degrades linearly above ~1,500 |
| PixiJS (WebGL) | 5,000–20,000 | Batched sprite renderer; good for textured |
| WebGL raw | 100,000–500,000 | Point sprites, minimal state changes |
| WebGL2 TF | 500,000–2,000,000 | GPU-side simulation via transform feedback |
| WebGPU | 2,000,000+ | Compute shaders; available in all major browsers as of 2025 |
For our use case (512 particles max): Canvas 2D is the correct choice. It requires zero external dependencies, is trivially debuggable, and 512 particles is well within its comfort zone even on mobile devices.
The crossover point where WebGL becomes worth its complexity is roughly 2,000–3,000 independently-updated particles. A performance comparison from semisignal.com found that on desktop Chrome, WebGL renders ~30% more sprites at 60fps than Canvas 2D at equivalent counts — but this advantage only matters at scale. Notably, Canvas 2D can actually outperform WebGL on Mac/Chrome at lower counts due to Apple's optimized 2D compositing path.
| Library | Weekly Downloads | Bundle (gzip) | Svelte Support | Best For |
|---|---|---|---|---|
| tsParticles | ~134,000 | ~50–200 KB* | Yes (official) | Configurable effects, presets |
| particles.js | ~24,000 | ~28 KB | No | Simple effects, legacy projects |
| Sparticles | ~1,000 | ~10 KB | Framework-free | Ambient/atmospheric, lightweight |
| Proton Engine | ~2,400 | ~35 KB | No | Complex physics, multi-renderer |
| Custom | — | ~3–5 KB | Native | Full control, atmospheric effects |
*tsParticles offers @tsparticles/slim and @tsparticles/engine for reduced bundle sizes via tree-shaking.
Recommendation for BibleWeb: A custom implementation is best. Here's why:
globalCompositeOperation: 'lighter' for Additive BlendingFor campfire sparks and warm glow, additive blending creates physically correct light accumulation — overlapping particles brighten rather than occlude:
// Save context state
ctx.save();
// Switch to additive blending for spark/glow pass
ctx.globalCompositeOperation = 'lighter';
for (const spark of sparks) {
ctx.globalAlpha = spark.alpha;
ctx.fillStyle = `rgb(${spark.r},${spark.g},${spark.b})`;
ctx.beginPath();
ctx.arc(spark.x, spark.y, spark.radius, 0, Math.PI * 2);
ctx.fill();
}
// Restore to 'source-over' for non-additive layers
ctx.restore();
For ambient dust particles, source-over (the default) is correct — these particles scatter light, they don't emit it.
Create a radial gradient per particle type, cached as an off-screen canvas, then drawImage it. This fakes a soft-edge glow without per-particle gradient creation:
function createSoftParticleSprite(
r: number, g: number, b: number, radius = 8
): HTMLCanvasElement {
const size = radius * 2;
const offscreen = document.createElement('canvas');
offscreen.width = size;
offscreen.height = size;
const ctx = offscreen.getContext('2d')!;
const grad = ctx.createRadialGradient(radius, radius, 0, radius, radius, radius);
grad.addColorStop(0, `rgba(${r},${g},${b},1)`);
grad.addColorStop(0.4, `rgba(${r},${g},${b},0.6)`);
grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, size, size);
return offscreen;
}
// Create once at init — one sprite per color type
const beigeSoftSprite = createSoftParticleSprite(255, 220, 170);
const amberSoftSprite = createSoftParticleSprite(255, 200, 120);
// Draw: cheaper than creating a gradient each frame
ctx.globalAlpha = p.alpha;
ctx.drawImage(beigeSoftSprite, p.x - 8, p.y - 8);
This technique is recommended specifically for atmospheric particles where a slight halo is more convincing than a sharp disc.
Based on web.dev's static memory pools article, the correct pattern is:
init()), never during animation.active: boolean flag (or a free-list index) — not splice/push/pop.release(), not in acquire(), so acquire paths stay cheap.class ParticlePool {
private pool: Particle[];
// Free-list: indices of inactive particles for O(1) acquire
private freeList: number[];
constructor(capacity: number) {
this.pool = Array.from({ length: capacity }, () => makeParticle());
this.freeList = Array.from({ length: capacity }, (_, i) => i);
}
acquire(): Particle | null {
if (this.freeList.length === 0) return null;
const idx = this.freeList.pop()!; // O(1)
this.pool[idx].active = true;
return this.pool[idx];
}
release(p: Particle): void {
p.active = false;
// Reset to default state
p.age = 0; p.alpha = 0; p.vx = 0; p.vy = 0;
this.freeList.push(this.pool.indexOf(p)); // O(n) — acceptable for 512
}
}
For 512 particles: the indexOf on release is irrelevant at this scale. For 10,000+ particles, store the pool index on the particle object to make release O(1).
.filter(), .map(), or .reduce() inside requestAnimationFrame — these allocate temporary arrays every frame.for loop, skipping !p.active entries.requestAnimationFrame callback receives a DOMHighResTimeStamp — compute dt = (now - lastTime) / 1000 and clamp to Math.min(dt, 0.05) to prevent particles jumping after tab re-focus.The recommended pattern from MDN and IBM's canvas layering guide is to use multiple absolutely-positioned <canvas> elements at the same viewport position. The GPU composites them automatically using each canvas's alpha channel.
<!-- SceneCanvas.svelte -->
<div class="scene-container">
<!-- Layer 0: background (sky, terrain far) — rarely redrawn -->
<canvas bind:this={bgCanvas} class="layer" />
<!-- Layer 1: background particles (ambient dust behind terrain) -->
<canvas bind:this={bgParticlesCanvas} class="layer" />
<!-- Layer 2: terrain / midground sprites -->
<canvas bind:this={terrainCanvas} class="layer" />
<!-- Layer 3: foreground particles (campfire sparks, foreground motes) -->
<canvas bind:this={fgParticlesCanvas} class="layer" />
<!-- Layer 4: UI overlay (HUD, verse text) -->
<canvas bind:this={uiCanvas} class="layer" />
</div>
<style>
.scene-container { position: relative; }
.layer {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
pointer-events: none; /* Let clicks fall through to terrain layer */
}
</style>
Each canvas is only redrawn when its content changes. The terrain canvas can be drawn once; the particle canvases update every frame; the UI canvas only updates on user interaction. This matches the MonoGame pattern where background particles render before terrain and foreground particles render after the UI pass.
For simpler scenes, a single canvas with ordered draw calls works equally well and has lower memory overhead:
function renderFrame(ctx: CanvasRenderingContext2D): void {
ctx.clearRect(0, 0, width, height);
drawBackground(ctx); // sky, far terrain
bgEmitter.render(ctx); // ambient particles (behind terrain)
drawTerrain(ctx); // midground terrain
fgEmitter.render(ctx); // foreground particles
sparkEmitter.render(ctx); // campfire sparks (additive blend)
drawUI(ctx); // HUD and overlays
}
The multiple-canvas approach is worth it only when background layers are static and expensive to redraw. For our animated scenes where everything moves, a single canvas with correct draw-order is simpler and sufficient.
Given the constraints (512 max particles, two color palettes, low-alpha atmospheric effects, layered rendering behind/in-front-of terrain):
Use Canvas 2D with a custom emitter + object pool. Rationale:
Particle objects; free-list for O(1) acquire.globalCompositeOperation: 'source-over' for ambient dust, 'lighter' for campfire sparks.<canvas> elements for the particle layers (one behind terrain, one in front).requestAnimationFrame loop with clamped dt for tab-visibility safety.WebGL becomes relevant only if you later add dense volumetric effects (thousands of dust motes in a storm, rain, etc.). At that point, PixiJS's ParticleContainer is the lowest-friction WebGL upgrade path.