Projects bibleweb Docs atmosphere-particle-systems.md

Particle Systems for Web-Based Atmospheric Effects

Last modified March 28, 2026

Particle Systems for Web-Based Atmospheric Effects

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


1. Canvas 2D Particle Systems

How It Works

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.

Object Pool Implementation

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.

Quadratic Fade-In / Fade-Out

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.

Batch Drawing with Minimal State Changes

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

Performance Characteristics

  • Handles 500–2,000 particles at 60fps comfortably on modern hardware.
  • For our 512-particle max, Canvas 2D is entirely sufficient.
  • Performance degrades linearly with particle count beyond ~2,000.
  • shadowBlur is expensive — avoid it; simulate glow via globalCompositeOperation: 'lighter' instead.
  • Use integer pixel coordinates (x | 0) to avoid sub-pixel anti-aliasing overhead.

2. WebGL Particle Systems

When to Use WebGL

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.

Point Sprites (Fastest GPU Approach)

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

GPU-Side Simulation via Transform Feedback (WebGL 2)

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.

Point Sprites vs. Geometry Instancing

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.


3. Particle System Architecture

Emitter Design

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

Update Loop

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

4. Performance Benchmarks

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.


5. Particle Libraries Comparison

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:

  1. Our needs are narrow: two soft-ambient emitters with specific color palettes and depth layers. No interaction, no shape variety, no physics.
  2. Sparticles is the closest library match (Canvas 2D, atmospheric, lightweight), but still adds ~10 KB of configuration surface we don't need.
  3. A custom system for 512 particles is ~80–120 lines of TypeScript. No dependency, no version drift, trivially tree-shaken.
  4. We need precise control over render layering (behind terrain vs. after UI), which libraries make awkward.

6. Rendering Techniques: Glow and Additive Blending

globalCompositeOperation: 'lighter' for Additive Blending

For 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.

Soft Particle Effect Without Shaders

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.


7. Memory Management

Pre-allocation Pattern

Based on web.dev's static memory pools article, the correct pattern is:

  1. Allocate all particle objects at startup (init()), never during animation.
  2. Use an active: boolean flag (or a free-list index) — not splice/push/pop.
  3. Reset particle fields in 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).

Avoiding GC Pressure in the Update Loop

  • Never use .filter(), .map(), or .reduce() inside requestAnimationFrame — these allocate temporary arrays every frame.
  • Iterate the pool array directly with a for loop, skipping !p.active entries.
  • Avoid string concatenation for color values; cache color strings in the emitter config.
  • 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.

8. Layered Rendering (Depth Layers)

Multiple Stacked Canvas Elements

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.

Single Canvas with Save/Restore

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.


9. Recommendation for BibleWeb

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:

  • 512 particles is far below Canvas 2D's practical limit of ~2,000 at 60fps.
  • No library dependency — total implementation is ~150 lines of TypeScript.
  • Full control over render layering without fighting library abstractions.
  • Pre-allocated pool of 512 Particle objects; free-list for O(1) acquire.
  • Soft-particle sprites cached as off-screen canvases at init.
  • globalCompositeOperation: 'source-over' for ambient dust, 'lighter' for campfire sparks.
  • Two stacked <canvas> elements for the particle layers (one behind terrain, one in front).
  • Quadratic fade envelope matches MonoGame's behavior exactly.
  • 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.


Sources