@zakkster/lite-smoke
v1.0.1
Published
Zero-GC, SoA volumetric smoke engine with DPI-aware radial buffers, dynamic drift frequencies, OKLCH palette transitions, and configurable blend modes. Ideal for fire, magic, industrial VFX, and high-performance atmospheric effects.
Downloads
347
Maintainers
Readme
@zakkster/lite-smoke
Zero-GC SoA volumetric smoke engine with DPI-aware radial puff buffers, dynamic drift, OKLCH palette transitions, and configurable blend modes. One dependency. 179 lines.
Live Demo
https://cdpn.io/pen/debug/QwKayed
Why lite-smoke?
| Feature | lite-smoke | tsparticles | p5.js | pixi-particles | |---|---|---|---|---| | Zero-GC hot path | Yes | No | No | No | | Pre-rendered puff buffers | DPI-aware | No | No | No | | GPU-accelerated render | drawImage | fillRect | arc | Sprite | | Alpha curve | Fade-in + fade-out | Linear | Manual | Linear | | Size growth | Over lifetime | No | Manual | Yes | | Configurable blend | Engine-level | Per-particle | Manual | Per-sprite | | OKLCH palette | Yes | No | No | No | | Velocity sleep | Yes (< 0.01) | No | No | No | | Bundle size | < 2KB | ~40KB | ~800KB | ~20KB |
Installation
npm install @zakkster/lite-smokeQuick Start
import { SmokeEngine } from '@zakkster/lite-smoke';
const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
const smoke = new SmokeEngine(2000, { dpr: window.devicePixelRatio || 1 });
let last = performance.now();
function loop(time) {
const dt = Math.min((time - last) / 1000, 0.1);
last = time;
ctx.fillStyle = '#0a0a14';
ctx.fillRect(0, 0, canvas.width, canvas.height);
smoke.updateAndDraw(ctx, dt, canvas.width, canvas.height);
requestAnimationFrame(loop);
}
// Emit a burst of smoke puffs (upward cone)
smoke.emit(400, 500, 10, -Math.PI/2 - 0.5, -Math.PI/2 + 0.5, 10, 40, 10, 30, 20);
requestAnimationFrame(loop);Important:
updateAndDraw()does not clear the canvas. Smoke is an overlay effect. Callctx.clearRect()or draw your background before callingupdateAndDraw().
The Smoke Pipeline
Construction: DPI-Aware Radial Puff Buffers
At construction, each palette color gets a pre-rendered radial gradient baked into an offscreen canvas. The buffer size scales by dpr for crisp rendering on Retina displays.
Physical size = bufferSize × dpr
Gradient: center(100% alpha) → mid(50% alpha) → edge(0% alpha)The render loop uses ctx.drawImage() (GPU-accelerated texture blit) instead of creating gradients per-frame. This is the single biggest performance win — gradient creation is one of the most expensive Canvas2D operations.
| Palette Index | Default Color | Phase |
|---|---|---|
| 0 | oklch(0.90, 0.20, 40) | Ignition (bright, warm) |
| 1 | oklch(0.20, 0.00, 0) | Thick core smoke |
| 2 | oklch(0.40, 0.00, 0) | Expanding smoke |
| 3 | oklch(0.60, 0.00, 0) | Dissipating |
Phase 1: Emission
emit() spawns puffs at a point with velocity, size, and growth rate. Each puff gets:
| Property | Formula | Effect |
|---|---|---|
| Velocity | cos/sin(angle) × speed | Directional emission |
| invLife | 1.0 / life | Avoids division in render loop |
| sizeGrow | growRate + random × growRate × 0.5 | Per-puff expansion jitter |
| driftPhase | random × TAU | Per-puff sine wobble offset |
Phase 2: Physics
Each frame, alive puffs run through:
1. BUOYANCY vy += buoyancy × dt (negative = upward in screen coords)
2. FRICTION vx *= friction, vy *= friction
3. SLEEP if |v| < 0.01 → v = 0 (skip physics for settled puffs)
4. DRIFT x += sin(elapsed × driftFreq + phase) × driftStrength × dt
5. POSITION x += vx × dt, y += vy × dt
6. CULLING kill if off-screen (±200px margin)Phase 3: Alpha Curve
The alpha is not a simple linear fade. It uses a compound curve:
inverseProgress = 1.0 - lifeRatio
alphaCurve = lifeRatio × min(1.0, inverseProgress × 5.0)
finalAlpha = alphaCurve × maxOpacityThis creates a fast fade-in at birth (puffs don't pop into existence — they grow from transparent over the first 20% of life) and a slow fade-out as smoke dissipates. The result looks volumetric without any actual 3D math.
Phase 4: Size Growth
Puffs expand over their lifetime, simulating real smoke diffusion:
size = sizeBase + inverseProgress × sizeGrowAt birth, a puff is small and bright. At death, it's large, faint, and spread out.
emit() Parameters
smoke.emit(x, y, count, angleMin, angleMax, speedMin, speedMax, sizeMin, sizeMax, growRate, lifeMin?, lifeMax?)| Param | Type | Default | Description |
|---|---|---|---|
| x, y | number | — | Emission origin |
| count | number | — | Number of puffs to spawn |
| angleMin/Max | number | — | Emission cone (radians). -Math.PI/2 = upward |
| speedMin/Max | number | — | Initial speed range (px/s) |
| sizeMin/Max | number | — | Starting puff size range (px) |
| growRate | number | — | Pixels of expansion over lifetime |
| lifeMin | number | 1.0 | Minimum puff lifetime (seconds) |
| lifeMax | number | 3.0 | Maximum puff lifetime (seconds) |
Angle Guide
-π/2 (straight up)
│
-π ──────┼────── 0 (right)
(left) │
π/2 (straight down)Full Config Reference
All config values are live-mutable between frames.
| Option | Type | Default | Description |
|---|---|---|---|
| buoyancy | number | -150 | Upward force (negative = up in screen coords). |
| friction | number | 0.95 | Velocity decay per frame (0–1). |
| driftStrength | number | 20 | Horizontal wobble amplitude (px). |
| driftFreq | number | 2.0 | Wobble frequency (Hz). |
| maxOpacity | number | 0.6 | Peak alpha for smoke puffs. |
| bufferSize | number | 64 | Puff texture resolution (px). Higher = smoother. |
| dpr | number | 1 | Device pixel ratio for puff buffers. |
| blendMode | string | 'source-over' | Canvas composite operation. |
| palette | Array | 4 OKLCH stops | Ignition → thick → expanding → dissipating. Pre-parsed at construction. |
| rng | Function | Math.random | RNG function. Inject for determinism. |
Canvas Setup (No Built-in Resize)
import { SmokeEngine } from '@zakkster/lite-smoke';
const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const smoke = new SmokeEngine(2000, { dpr });
let w = 0, h = 0;
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 { SmokeEngine } from '@zakkster/lite-smoke';
import { Random } from '@zakkster/lite-random';
const rng = new Random(42);
const smoke = new SmokeEngine(2000, { rng: () => rng.next() });Recipes
The onEmberDeath hook spawns smoke puffs at the exact position where each ember died.
import { EmberEngine } from '@zakkster/lite-embers';
import { SmokeEngine } from '@zakkster/lite-smoke';
const smoke = new SmokeEngine(1500, { dpr: devicePixelRatio });
const embers = new EmberEngine(3000, {
onEmberDeath(x, y) {
smoke.emit(x, y, 1, -1.8, -1.3, 5, 20, 8, 20, 15);
}
});Dark smoke on light backgrounds using multiply compositing.
const exhaust = new SmokeEngine(2000, {
blendMode: 'multiply',
buoyancy: -80,
palette: [
{ l: 0.15, c: 0.02, h: 30 },
{ l: 0.25, c: 0.01, h: 0 },
{ l: 0.35, c: 0.00, h: 0 },
{ l: 0.45, c: 0.00, h: 0 },
],
});
setInterval(() => {
exhaust.emit(200, 400, 3, -1.7, -1.4, 20, 60, 15, 40, 25, 2, 5);
}, 100);Ethereal glowing steam using additive blending. Low opacity for subtlety.
const steam = new SmokeEngine(1000, {
blendMode: 'lighter',
buoyancy: -200,
maxOpacity: 0.3,
palette: [
{ l: 0.9, c: 0.1, h: 200 },
{ l: 0.8, c: 0.05, h: 210 },
{ l: 0.7, c: 0.02, h: 220 },
{ l: 0.6, c: 0.01, h: 230 },
],
});Steady smoke emission using setInterval. Drift and buoyancy handle the rest.
const chimney = new SmokeEngine(2000, {
buoyancy: -120,
friction: 0.97,
driftStrength: 30,
});
setInterval(() => {
chimney.emit(chimneyX, chimneyY, 2, -1.7, -1.4, 15, 40, 10, 25, 20, 2, 4);
}, 80);buoyancySlider.oninput = () => smoke.config.buoyancy = +buoyancySlider.value;
frictionSlider.oninput = () => smoke.config.friction = +frictionSlider.value;
driftSlider.oninput = () => smoke.config.driftStrength = +driftSlider.value;
opacitySlider.oninput = () => smoke.config.maxOpacity = +opacitySlider.value;All VFX engines share the same (ctx, dt, w, h) overlay API.
import { SmokeEngine } from '@zakkster/lite-smoke';
import { EmberEngine } from '@zakkster/lite-embers';
import { RainEngine } from '@zakkster/lite-rain';
const smoke = new SmokeEngine(1000);
const embers = new EmberEngine(3000, {
onEmberDeath(x, y) { smoke.emit(x, y, 1, -1.7, -1.4, 5, 15, 8, 18, 12); }
});
const rain = new RainEngine(8000);
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);
smoke.updateAndDraw(ctx, dt, w, h);
rain.updateAndDraw(ctx, dt, w, h);
}API
new SmokeEngine(maxParticles?, config?)
| Parameter | Type | Default | Description |
|---|---|---|---|
| maxParticles | number | 3000 | Pool capacity. |
| config | SmokeConfig | see above | All options. Live-mutable after construction. |
Methods
| Method | Description |
|---|---|
| .emit(x, y, count, ...) | Spawn puffs. See emit() parameters above. |
| .updateAndDraw(ctx, dt, w, h) | Physics + render. Does not clear canvas. |
| .clear() | Kill all particles immediately. |
| .destroy() | Null all 11 arrays + puff buffers. Idempotent. |
License
MIT
Part of the @zakkster ecosystem
Zero-GC, deterministic, tree-shakeable micro-libraries for high-performance web presentation.
