@zakkster/lite-snow
v1.4.0
Published
Zero-GC, SoA environmental snow engine with drift physics, Z-depth parallax, persistent-pack accumulation, dimension caching, and off-screen culling. Designed for cinematic snowfall with high-performance rendering and natural flake behavior.
Downloads
689
Maintainers
Readme
@zakkster/lite-snow
Zero-GC SoA environmental snow engine with drift physics, Z-depth parallax, opt-in persistent-pack accumulation, and bucketed rendering. Zero dependencies. 3 presets.
Live Demo
https://cdpn.io/pen/debug/yyapJqB
Why lite-snow?
| Feature | lite-snow | tsparticles | weatherJS | p5.js | |---|---|---|---|---| | Zero-GC hot path | Yes | No | No | No | | SoA flat arrays | 14 arrays | No | No | No | | Z-depth parallax | Yes (0.2-1.0) | No | No | Manual | | Sinusoidal drift | Per-flake | Partial | No | Manual | | Snow accumulation | Persistent pack + ellipse morph | No | No | No | | Bucketed rendering | 3 tiers | No | No | No | | Built-in presets | 3 | Config-heavy | No | No | | OKLCH color | Yes | No | No | No | | Bundle size | 2.0KB gzipped | ~40KB | ~10KB | ~800KB |
Installation
npm install @zakkster/lite-snowQuick Start
import { SnowEngine } from '@zakkster/lite-snow';
const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
const snow = new SnowEngine(10000);
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;
snow.spawn(dt, w, h);
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, w, h); // dark sky background
snow.updateAndDraw(ctx, dt, w, h); // snow overlays on top
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);One-Liner with Presets
import { SnowEngine, SNOW_PRESETS } from '@zakkster/lite-snow';
const blizzard = new SnowEngine(15000, SNOW_PRESETS.blizzard);Important:
updateAndDraw()does not clear the canvas. Snow is an overlay effect. Callctx.clearRect()or draw your background before callingupdateAndDraw().
Presets
| Preset | Density | Wind | Gravity | Drift | Radius | Feel |
|---|---|---|---|---|---|---|
| SNOW_PRESETS.flurry | 10 | 30 | 40 | 15 | 2.5 | Gentle, peaceful |
| SNOW_PRESETS.heavy | 24 | 150 | 80 | 25 | 3.5 | Dense, windy |
| SNOW_PRESETS.blizzard | 40 | 400 | 250 | 50 | 2.0 | Extreme whiteout |
The Snow Pipeline
Phase 1: Falling Flake (state = 1)
Snowflakes spawn above the viewport with random Z-depth (0.2-1.0). Every parameter scales by Z:
| Property | Formula | Effect |
|---|---|---|
| Fall speed | gravity × z | Far flakes fall slower |
| Wind drift | wind × z | Far flakes drift less |
| Flake radius | (baseRadius +/- jitter) × z | Far flakes are smaller |
| Drift amplitude | driftAmplitude × z | Far flakes sway less |
| Render alpha | z × 0.8 | Far flakes are faint |
Each flake has a unique sinusoidal drift -- a sine wave with per-flake random phase, frequency, and amplitude. This produces the natural floating-leaf motion that makes snow look real instead of just falling vertically.
All Z-dependent values are precomputed at spawn: gz[], wz[], radius[], driftAmp[], bucket[].
Phase 2: Melt (state = 2)
When a flake reaches the floor (y >= h), it transitions to a settled state:
- Shape morphs from a circle to a flat ellipse (2.5× width, 0.5× height) -- simulating a flake flattening on the ground
- Alpha fades from
zto 0 overmeltTimeMintomeltTimeMaxseconds - Computed via
invMeltMax(one division per frame, not per flake)
This creates a subtle accumulation layer at the bottom of the canvas -- flakes don't just disappear, they settle and melt.
With accumulate: true (opt-in, construction-time only), settled flakes also raise a persistent pack: a Uint16Array heightmap, one column per packResolution px, that flakes land ON and that decays over time. It is an exact integer ledger (packSum === landed - decayed - capped - truncated), caps each column at maxPackHeight, skips out-of-range columns rather than clamping them into a false wall, and truncates on resize. Off by default, the engine is byte-identical to v1.2.0 and pack === null.
Bucketed Rendering
Flakes are binned into 3 depth tiers at spawn:
| Bucket | Z Range | Alpha | Radius Scale | |---|---|---|---| | 0 (far) | 0.2-0.4 | 0.24 | ~0.3× | | 1 (mid) | 0.4-0.7 | 0.44 | ~0.55× | | 2 (near) | 0.7-1.0 | 0.72 | ~0.9× |
Each depth bucket renders in one batched ctx.fill() call. Melting flakes are
quantized into at most 8 alpha bands, each drawn in a single fill() -- so a full
frame is 3 depth-bucket fills plus up to 8 melt-band fills (3 + 8 worst case),
plus one closed-path pack fill when accumulate: true and the pack is non-empty
(3 + 1 + 8 worst case), independent of how many flakes are on screen. The physics pass bins every live
flake into preallocated index lists in one sweep, so the render touches only live
slots, never the whole pool.
Full Config Reference
All config values are live-mutable between frames.
| Option | Type | Default | Description |
|---|---|---|---|
| gravity | number | 40 | Downward acceleration (px/s^2). Snow is very light. |
| wind | number | 30 | Horizontal wind (px/s). Positive = right. |
| density | number | 10.0 | Spawn multiplier. Auto-scales with canvas area. |
| baseRadius | number | 2.5 | Base flake radius (px). Depth-scaled per flake. |
| driftAmplitude | number | 15 | Horizontal drift sine amplitude (px). Depth-scaled. |
| driftFreq | number | 1.0 | Drift sine frequency (Hz). Per-flake jitter +/-0.25. |
| meltTimeMin | number | 2.0 | Minimum time before settled flake fades (seconds). |
| meltTimeMax | number | 5.0 | Maximum melt time (seconds). |
| color | OklchColor | string | 'oklch(0.98 0.02 250)' | Flake color. Pre-parsed at construction. |
| rng | Function | Math.random | RNG function. Inject for determinism. |
Canvas Setup (No Built-in Resize)
import { SnowEngine } from '@zakkster/lite-snow';
const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
const snow = new SnowEngine();
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 { SnowEngine } from '@zakkster/lite-snow';
import { Random } from '@zakkster/lite-random';
const rng = new Random(42);
const snow = new SnowEngine(10000, { rng: () => rng.next() });Recipes
import { SnowEngine, SNOW_PRESETS } from '@zakkster/lite-snow';
const snow = new SnowEngine(8000, {
...SNOW_PRESETS.flurry,
color: { l: 0.95, c: 0.02, h: 240 },
});const snow = new SnowEngine(12000, SNOW_PRESETS.heavy);const snow = new SnowEngine(15000, SNOW_PRESETS.blizzard);
// Ramp wind over time
let windTarget = 400;
setInterval(() => {
windTarget = 200 + Math.random() * 500 * (Math.random() > 0.5 ? 1 : -1);
}, 3000);
// In loop:
snow.config.wind += (windTarget - snow.config.wind) * dt * 1.5;function gameLoop(dt) {
snow.spawn(dt, w, h);
ctx.clearRect(0, 0, w, h);
drawBackground();
drawCharacters();
drawUI();
snow.updateAndDraw(ctx, dt, w, h);
}// Volcanic ash
const ash = new SnowEngine(6000, {
color: { l: 0.3, c: 0.05, h: 30 },
gravity: 60,
driftAmplitude: 20,
});
// Cherry blossom petals
const petals = new SnowEngine(4000, {
color: { l: 0.8, c: 0.15, h: 340 },
gravity: 25,
driftAmplitude: 30,
baseRadius: 3.5,
});
// Floating embers
const embers = new SnowEngine(3000, {
color: { l: 0.6, c: 0.25, h: 30 },
gravity: -15, // float upward!
wind: 50,
driftAmplitude: 10,
baseRadius: 1.5,
});import { SnowEngine } from '@zakkster/lite-snow';
import { RainEngine } from '@zakkster/lite-rain';
import { FireworksEngine } from '@zakkster/lite-fireworks';
const snow = new SnowEngine(8000);
const rain = new RainEngine(6000, { density: 3 });
const fireworks = new FireworksEngine(5000);
function loop(time) {
const dt = /* ... */;
snow.spawn(dt, w, h);
rain.spawn(dt, w, h);
fireworks.updateAndDraw(ctx, dt, w, h); // bloom background
snow.updateAndDraw(ctx, dt, w, h); // snow overlay
rain.updateAndDraw(ctx, dt, w, h); // rain on top
}windSlider.oninput = () => snow.config.wind = +windSlider.value;
densitySlider.oninput = () => snow.config.density = +densitySlider.value;
gravitySlider.oninput = () => snow.config.gravity = +gravitySlider.value;
driftSlider.oninput = () => snow.config.driftAmplitude = +driftSlider.value;API
new SnowEngine(maxParticles?, config?)
| Parameter | Type | Default | Description |
|---|---|---|---|
| maxParticles | number | 10000 | Pool capacity. Shared between flakes and melting. |
| config | SnowConfig | see above | All options. Live-mutable. |
Both are validated at construction and throw a RangeError naming the value:
| Input | Rule |
|---|---|
| maxParticles | integer, 1 <= n <= 10000000 |
| baseRadius | finite, > 0 |
Sizing. The pool costs 66 bytes per particle -- twelve Float32Array
columns at 4 bytes plus two Uint8Array columns at 1 byte (50 bytes of SoA
state), plus four Uint32Array render-bin index lists at 4 bytes each (16 bytes).
The two velocity columns vx/vy (S5 living air) add +8 bytes over v1.1.1's 58.
The default 10000 slots are 660 KB; the 10000000 ceiling is 660 MB. The ceiling
exists so a typo fails loudly instead of attempting a multi-gigabyte allocation.
The bin lists are allocated once at construction and rebuilt in place each frame,
never reallocated.
The accumulation pack (S6) is a separate fixed cost, not per-particle: one
Uint16Array of maxPackWidth / packResolution columns (default 4096 / 4 = 1024
cells = 2 KB), allocated once and only when accumulate: true, never reallocated
(a resize truncates it in place). Unarmed, pack === null and costs nothing.
Methods
| Method | Description |
|---|---|
| .spawn(dt, w, h) | Spawn new flakes. Auto-scales with area × density. |
| .updateAndDraw(ctx, dt, w, h) | Physics + render. Does not clear canvas. |
| .clear() | Full simulation reset: kills all particles, zeroes the drift clock, and invalidates the dimension cache. |
| .destroy() | Nulls the 14 typed arrays, the config and the render bins. Idempotent. |
Telemetry
| Getter | Description |
|---|---|
| .fallingCount | Flakes in the falling phase (state = 1). |
| .meltingCount | Flakes in the melt phase (state = 2). |
| .activeCount | Sum of the two -- pool occupancy. |
All three are O(1) integer reads maintained by the existing state transitions,
not scans. Use activeCount / maxParticles to see how close the pool is to
saturation; a pool pinned at capacity means density is outrunning the melt
rate and new flakes are being silently dropped.
Because they are maintained rather than counted, writing state[i] directly
desynchronises them. The fourteen SoA columns are public and you may read them
freely, but a slot's state is the engine's to change: go through spawn(),
clear() or the melt lifecycle. clear() resynchronises from scratch.
SNOW_PRESETS
| Preset | Description |
|---|---|
| .flurry | Gentle snowfall |
| .heavy | Dense, windy |
| .blizzard | Extreme whiteout |
The table and every preset object are frozen. Spread a preset to customise it
({ ...SNOW_PRESETS.heavy, wind: 40 }) -- assigning into one would otherwise
reconfigure every engine built from it afterwards.
Fail-closed frames
spawn() and updateAndDraw() validate dt, w and h once per call, before
touching any state. A frame the engine cannot trust is a frame it does not run:
| Input | Behaviour |
|---|---|
| dt non-finite (NaN, Infinity) or negative | no-op frame |
| dt greater than 0.1 | clamped to 0.1 |
| dt of 0 | runs; no motion, still renders |
| w or h non-finite or <= 0 | no-op frame |
| ctx null, or missing arc / ellipse | no-op frame |
A no-op frame advances no clock, touches no particle, and draws nothing -- the
canvas keeps whatever was last drawn, so a dropped frame reads as a held frame
rather than a blank one. Nothing is substituted for a rejected dt: a default
value would fabricate motion you did not ask for.
This matters most with requestAnimationFrame on the first tick, after a
backgrounded tab, or on a canvas that has not been laid out yet -- all of which
can hand you a NaN or 0. Before 1.0.2 those inputs corrupted the pool
permanently.
If a draw call throws -- a lost context, a canvas that goes away mid-frame --
the engine restores ctx.globalAlpha to 1.0 and rethrows. It does not swallow
the error, and it does not leave your subsequent drawing translucent at whatever
depth alpha it happened to be using. Physics has already settled by then, so the
engine is intact and the next frame renders normally.
License
MIT
Part of the @zakkster ecosystem
Zero-GC, deterministic, tree-shakeable micro-libraries for high-performance web presentation.
