@zakkster/lite-embers
v1.0.0
Published
Zero-GC, SoA volumetric ember engine with localized spawn bounds, O(C) color bucketing, chromatic shimmer, dynamic cooling buoyancy, and decoupled death hooks.
Maintainers
Readme
@zakkster/lite-embers
Zero-GC SoA volumetric ember engine with localized spawn bounds, chromatic shimmer, dynamic cooling buoyancy, and decoupled death hooks. One dependency. 197 lines.
Live Demo
https://cdpn.io/pen/debug/YPGYWoa
Why lite-embers?
| Feature | lite-embers | tsparticles | p5.js | pixi-particles | |---|---|---|---|---| | Zero-GC hot path | Yes | No | No | No | | SoA flat arrays | 13 arrays | No | No | No | | Localized spawn box | Yes | Partial | Manual | Manual | | Dynamic buoyancy | Convection sim | No | Manual | No | | Chromatic shimmer | Per-ember | No | No | No | | Color-bucketed render | O(C) batching | No | No | No | | Death hooks | onEmberDeath(x,y) | Callback | No | Callback | | OKLCH colors | Yes | No | No | No | | Bundle size | < 2KB | ~40KB | ~800KB | ~20KB |
Installation
npm install @zakkster/lite-embersQuick Start
import { EmberEngine } from '@zakkster/lite-embers';
const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
const embers = new EmberEngine(5000);
let w = canvas.width, h = canvas.height;
let last = performance.now();
function loop(time) {
const dt = Math.min((time - last) / 1000, 0.1);
last = time;
embers.spawn(dt, w, h, w / 2 - 30, h - 80, 60, 20);
ctx.fillStyle = '#0a0a14';
ctx.fillRect(0, 0, w, h);
embers.updateAndDraw(ctx, dt, w, h);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);Important:
updateAndDraw()does not clear the canvas. Embers are an overlay effect. Callctx.clearRect()or draw your background before callingupdateAndDraw().
The Ember Pipeline
Phase 1: Spawn (localized bounding box)
Embers spawn strictly inside a user-defined rectangle (boxX, boxY, boxW, boxH). Density auto-scales with the box area — a narrow torch produces fewer embers than a wide campfire.
Every ember gets precomputed values at spawn:
| Property | Formula | Effect |
|---|---|---|
| Drift amplitude | driftAmplitude × zScale | Parallax variation |
| Drift speed | driftFreq × zScale | Per-ember sway speed |
| Wind | wind × zScale | Parallax wind |
| Base radius | baseRadius × (0.5–1.0) | Size variation |
| invLife | 1.0 / life | Avoids division in render loop |
Phase 2: Rise with Dynamic Buoyancy
Embers don't just float upward at a constant speed. The buoyancy force scales with height:
heightRatio = 1.0 - clamp(y / h, 0, 1)
dynamicBuoyancy = buoyancy × (0.6 + 0.4 × heightRatio)Near the fire source (high heightRatio), embers rise at full buoyancy. As they gain altitude, buoyancy weakens to 60% — simulating real convection currents where hot air decelerates as it cools and spreads.
Phase 3: Chromatic Shimmer
The color index oscillates via a sine wave, creating a flickering heat effect:
rawIdx = (lifeRatio × colorCount) + sin(elapsed × 15 + popPhase) × 0.5This reuses the precomputed popPhase for zero additional allocation. Embers flicker between adjacent heat tiers — a hot-orange ember briefly glints white-hot, then cools back.
Phase 4: Organic Spark Pop
Each ember's radius pulsates:
pop = 1.0 + 0.2 × sin(elapsed × 6 + popPhase)
radius = baseRadius × lifeRatio × popCombined with shimmer, this produces the organic "breathing" look of real fireplace embers.
O(C) Color-Bucketed Rendering
Instead of setting fillStyle per-particle, embers are binned by their current color index. Each color tier renders in one batched ctx.beginPath() + ctx.fill() call — 4 draw calls for all 5,000 embers.
| Color Tier | OKLCH | Temperature |
|---|---|---|
| 0 | oklch(0.20, 0.10, 20) | Smoldering (dark red) |
| 1 | oklch(0.50, 0.25, 30) | Cooling (cherry) |
| 2 | oklch(0.75, 0.25, 45) | Hot (orange) |
| 3 | oklch(0.95, 0.15, 80) | Core (white-hot) |
Full Config Reference
All config values are live-mutable between frames.
| Option | Type | Default | Description |
|---|---|---|---|
| buoyancy | number | 120 | Upward force (px/s²). Scales with height. |
| wind | number | 20 | Horizontal drift (px/s). Positive = right. |
| density | number | 5.0 | Spawn rate. Scales with bounding box area. |
| baseRadius | number | 3.0 | Base ember size (px). Randomized ±50%. |
| driftAmplitude | number | 25 | Horizontal sway amplitude (px). |
| driftFreq | number | 2.0 | Sway frequency (Hz). Per-ember jitter. |
| lifeMin | number | 1.5 | Minimum ember lifetime (seconds). |
| lifeMax | number | 4.0 | Maximum ember lifetime (seconds). |
| onEmberDeath | Function | null | null | (x, y) => void — fires when an ember dies. |
| heatColors | Array | 4 OKLCH stops | Heat gradient: smoldering → cherry → orange → white-hot. Pre-parsed at construction. |
| rng | Function | Math.random | RNG function. Inject for determinism. |
Canvas Setup (No Built-in Resize)
import { EmberEngine } from '@zakkster/lite-embers';
const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
const embers = new EmberEngine();
let w = 0, h = 0;
const dpr = window.devicePixelRatio || 1;
function updateSize() {
w = canvas.clientWidth || window.innerWidth;
h = canvas.clientHeight || window.innerHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
}
let scheduled = false;
new ResizeObserver(() => {
if (!scheduled) {
scheduled = true;
requestAnimationFrame(() => { scheduled = false; updateSize(); });
}
}).observe(canvas.parentElement || document.body);
updateSize();Seeded Random (Deterministic)
import { EmberEngine } from '@zakkster/lite-embers';
import { Random } from '@zakkster/lite-random';
const rng = new Random(42);
const embers = new EmberEngine(5000, { rng: () => rng.next() });Recipes
The onEmberDeath hook passes coordinates to a smoke engine without coupling the two. The ember engine doesn't know smoke exists.
import { EmberEngine } from '@zakkster/lite-embers';
import { SmokeEngine } from '@zakkster/lite-smoke';
const smoke = new SmokeEngine(1000, { dpr: devicePixelRatio });
const embers = new EmberEngine(3000, {
onEmberDeath(x, y) {
smoke.emit(x, y, 1, -Math.PI/2 - 0.3, -Math.PI/2 + 0.3, 5, 20, 8, 20, 15);
}
});
function loop(dt) {
embers.spawn(dt, w, h, fireX, fireY - 30, 80, 30);
ctx.fillStyle = '#0a0a14';
ctx.fillRect(0, 0, w, h);
embers.updateAndDraw(ctx, dt, w, h);
smoke.updateAndDraw(ctx, dt, w, h);
}One engine, multiple spawn points. Each spawn() call uses a different bounding box.
const embers = new EmberEngine(8000);
const braziers = [
{ x: 100, y: 500, w: 30, h: 20 },
{ x: 400, y: 500, w: 30, h: 20 },
{ x: 700, y: 500, w: 30, h: 20 },
];
function loop(dt) {
for (const b of braziers) {
embers.spawn(dt, w, h, b.x - b.w/2, b.y - b.h, b.w, b.h);
}
embers.updateAndDraw(ctx, dt, w, h);
}Replace the heat gradient with a fantasy palette. Purple → violet → magenta → white-pink.
const embers = new EmberEngine(4000, {
heatColors: [
{ l: 0.3, c: 0.2, h: 270 }, // dark purple
{ l: 0.5, c: 0.3, h: 290 }, // violet
{ l: 0.7, c: 0.25, h: 310 }, // magenta
{ l: 0.95, c: 0.1, h: 330 }, // white-pink
],
buoyancy: 80,
driftAmplitude: 40,
});The spawn box follows a moving entity. Embers trail behind naturally.
function loop(dt) {
embers.spawn(dt, w, h, player.x - 15, player.y - 10, 30, 15);
embers.updateAndDraw(ctx, dt, w, h);
}buoyancySlider.oninput = () => embers.config.buoyancy = +buoyancySlider.value;
windSlider.oninput = () => embers.config.wind = +windSlider.value;
densitySlider.oninput = () => embers.config.density = +densitySlider.value;
driftSlider.oninput = () => embers.config.driftAmplitude = +driftSlider.value;All VFX engines share the same (ctx, dt, w, h) overlay API.
import { EmberEngine } from '@zakkster/lite-embers';
import { RainEngine } from '@zakkster/lite-rain';
import { FireworksEngine } from '@zakkster/lite-fireworks';
const embers = new EmberEngine(3000);
const rain = new RainEngine(8000);
const fireworks = new FireworksEngine(5000);
function loop(dt) {
embers.spawn(dt, w, h, 380, 550, 60, 20);
rain.spawn(dt, w, h);
ctx.fillStyle = '#0a0a14';
ctx.fillRect(0, 0, w, h);
embers.updateAndDraw(ctx, dt, w, h);
rain.updateAndDraw(ctx, dt, w, h);
fireworks.updateAndDraw(ctx, dt, w, h);
}API
new EmberEngine(maxParticles?, config?)
| Parameter | Type | Default | Description |
|---|---|---|---|
| maxParticles | number | 5000 | Pool capacity. |
| config | EmberConfig | see above | All options. Live-mutable. |
Methods
| Method | Description |
|---|---|
| .spawn(dt, w, h, boxX?, boxY?, boxW?, boxH?) | Spawn embers within bounding box. Defaults to full canvas bottom. |
| .updateAndDraw(ctx, dt, w, h) | Physics + render. Does not clear canvas. |
| .clear() | Kill all particles immediately. |
| .destroy() | Null all 13 typed arrays. Idempotent. |
spawn() Bounding Box
boxX, boxY ─────────────┐
│ │ boxH
│ embers spawn here │
└────────────────────────┘
boxWDefault: boxX=0, boxY=h, boxW=w, boxH=50 — full canvas bottom strip.
License
MIT
Part of the @zakkster ecosystem
Zero-GC, deterministic, tree-shakeable micro-libraries for high-performance web presentation.
