@zakkster/lite-ambient-fx 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AmbientFX.d.ts +179 -0
- package/AmbientFX.js +868 -0
- package/CHANGELOG.md +74 -0
- package/LICENSE.txt +21 -0
- package/README.md +436 -0
- package/llms.txt +210 -0
- package/package.json +60 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@zakkster/lite-ambient-fx` are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [1.0.0] — 2026-07-11
|
|
9
|
+
|
|
10
|
+
Initial public release. Extracted, cleaned up, and hardened from a private
|
|
11
|
+
scratch-card game where the atmosphere layer had to render behind live UI
|
|
12
|
+
at 60fps without touching the reactive graph.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- `createAmbientFX(canvas, options)` -- mount an atmosphere on any canvas.
|
|
16
|
+
- Six shipped presets: `Fire`, `Night`, `Ice`, `Frost`, `Toxic`, `Void`.
|
|
17
|
+
- Four particle behaviors: `EMBER`, `MIST`, `FLOAT`, `CHAOS`.
|
|
18
|
+
- `BEHAVIORS` registry + `registerBehavior(name, def)` for adding custom
|
|
19
|
+
behaviors without touching the core.
|
|
20
|
+
- Pooled per-frame `FrameContext` (`{ cfg, W, H, dt, ds, timestamp,
|
|
21
|
+
isInit, getSprite, respawn }`) passed to every spawn/tick call; owned
|
|
22
|
+
by the instance and mutated in place, zero allocation per frame.
|
|
23
|
+
- Live-tweakable knobs via `updateConfig` -- count, alpha, size, speed,
|
|
24
|
+
decay, turbulence, wind (shallow-merged), colors, spark, behavior.
|
|
25
|
+
- `setTheme(name)` -- instant preset swap with sprite-cache eviction.
|
|
26
|
+
- `pause()` / `resume()` -- manual RAF control (visibility auto-pause remains).
|
|
27
|
+
- `destroy()` -- releases RAF handle, visibility listener, ResizeObserver,
|
|
28
|
+
and evicts sprites owned by the instance.
|
|
29
|
+
- Full TypeScript declarations in `AmbientFX.d.ts`, including
|
|
30
|
+
`BehaviorDefinition`, `FrameContext`, and `Particle` interfaces.
|
|
31
|
+
- 70-test suite across `test/01-config_test.mjs`,
|
|
32
|
+
`test/02-runtime_test.mjs`, and `test/03-registry_test.mjs`.
|
|
33
|
+
- Playground demo under `demo/index.html` with theme picker, live slider
|
|
34
|
+
panel, and CRT-phosphor aesthetic.
|
|
35
|
+
|
|
36
|
+
### Performance
|
|
37
|
+
- **Zero string allocation in the sprite cache** -- switched to a nested
|
|
38
|
+
`Map<color, Map<physicalSize, canvas>>` instead of concatenating a
|
|
39
|
+
`${color}:${size}` key on every lookup.
|
|
40
|
+
- **`spriteCanvas` cached on the particle at spawn time** -- the render
|
|
41
|
+
loop reads `p.spriteCanvas` directly, so `getSprite` is only called at
|
|
42
|
+
spawn (rare) rather than per particle per frame (18k times/sec for a
|
|
43
|
+
Fire preset before this fix).
|
|
44
|
+
- **Monomorphic particle shape** -- every particle carries the union of
|
|
45
|
+
all behavior-specific fields from the moment it's pushed to the pool.
|
|
46
|
+
No property is added or removed at runtime; V8's hidden class stays
|
|
47
|
+
stable across theme/behavior swaps.
|
|
48
|
+
- **Behavior dispatch hoisted out of the render loop** -- one lookup per
|
|
49
|
+
frame instead of one branch per particle per frame. Each behavior owns
|
|
50
|
+
a dedicated tick loop; the branch predictor sees a single path.
|
|
51
|
+
- **`Math.trunc` for the CHAOS flicker phase** -- avoids the 32-bit
|
|
52
|
+
signed cast in `timestamp >> 7` that would wrap negative after
|
|
53
|
+
~24 days of `performance.now()` accumulation.
|
|
54
|
+
- **Div-to-mul strength reduction on alpha envelopes** -- `p.life / 0.2`,
|
|
55
|
+
`/ 0.8`, `/ 0.1` in EMBER/FLOAT hot loops replaced with multiplications
|
|
56
|
+
by pre-computed reciprocals (`EMBER_FADE_IN_INV`, `EMBER_FADE_OUT_INV`,
|
|
57
|
+
`FLOAT_FADE_INV`). Same math, cheaper op per particle per frame.
|
|
58
|
+
- **Named module constants for repeat literals** -- `DEG_TO_RAD` for the
|
|
59
|
+
LUT init, `MIST_LIFE_WRAP_MS` for the MIST accumulator wrap,
|
|
60
|
+
`RESPAWN_MARGIN_Y` for the EMBER/FLOAT top-edge margin. No hot-path
|
|
61
|
+
wins on their own (JITs fold these), but they make intent obvious and
|
|
62
|
+
prevent drift.
|
|
63
|
+
- **Dead `const cfg = frame.cfg` removed from CHAOS tick** -- CHAOS
|
|
64
|
+
doesn't read cfg during the loop; the destructure was pure noise.
|
|
65
|
+
- **MIST margin collapse** -- `marginX` and `marginY` were always equal
|
|
66
|
+
(`cfg.size + 100`); collapsed to a single `margin` local.
|
|
67
|
+
|
|
68
|
+
### Notes
|
|
69
|
+
- Zero runtime dependencies. Single-file ESM.
|
|
70
|
+
- `sideEffects: false` in `package.json` for tree-shaking.
|
|
71
|
+
- Copyright: Zahary Shinikchiev.
|
|
72
|
+
- First-frame primed via a `lastTime = -1` sentinel; `timestamp === 0`
|
|
73
|
+
is now a valid raf callback (matters only for tests / non-browser
|
|
74
|
+
drivers -- real `performance.now()` is always > 0 at raf time).
|
package/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zahary Shinikchiev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
# @zakkster/lite-ambient-fx
|
|
2
|
+
|
|
3
|
+
> Fullscreen ambient particle atmospheres. Six themed presets across four behaviors. Sprite-cached, DPR-aware, resize-preserving, visibility-paused. One file. Zero dependencies.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@zakkster/lite-ambient-fx)
|
|
6
|
+

|
|
7
|
+
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
8
|
+
[](https://bundlephobia.com/result?p=@zakkster/lite-ambient-fx)
|
|
9
|
+
[](https://www.npmjs.com/package/@zakkster/lite-ambient-fx)
|
|
10
|
+
[](https://www.npmjs.com/package/@zakkster/lite-ambient-fx)
|
|
11
|
+

|
|
12
|
+

|
|
13
|
+

|
|
14
|
+
[](https://opensource.org/licenses/MIT)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
Extracted from a scratch-card game where the atmosphere layer had to render behind live UI at 60fps without touching the reactive graph. Six presets ship out of the box; every knob is live-tweakable; the whole thing fits in one `<script type="module">` and one `<canvas>` tag.
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
npm install @zakkster/lite-ambient-fx
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
import { createAmbientFX } from "@zakkster/lite-ambient-fx";
|
|
25
|
+
|
|
26
|
+
const canvas = document.getElementById("bg");
|
|
27
|
+
const fx = createAmbientFX(canvas, { theme: "Void" });
|
|
28
|
+
|
|
29
|
+
// live-tweak later
|
|
30
|
+
fx.updateConfig({ count: 250, alpha: 0.85 });
|
|
31
|
+
|
|
32
|
+
// or swap the whole preset
|
|
33
|
+
fx.setTheme("Fire");
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
That's the whole surface for the common case. Full API below.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Table of contents
|
|
41
|
+
|
|
42
|
+
- [What it looks like](#what-it-looks-like)
|
|
43
|
+
- [Why this exists](#why-this-exists)
|
|
44
|
+
- [What you get](#what-you-get)
|
|
45
|
+
- [The six shipped presets](#the-six-shipped-presets)
|
|
46
|
+
- [The four behaviors](#the-four-behaviors)
|
|
47
|
+
- [API reference](#api-reference)
|
|
48
|
+
- [Configuration knobs](#configuration-knobs)
|
|
49
|
+
- [Sizing, DPR, and resize](#sizing-dpr-and-resize)
|
|
50
|
+
- [Performance notes](#performance-notes)
|
|
51
|
+
- [Browser and runtime support](#browser-and-runtime-support)
|
|
52
|
+
- [Integration recipes](#integration-recipes)
|
|
53
|
+
- [Testing](#testing)
|
|
54
|
+
- [Ecosystem](#ecosystem)
|
|
55
|
+
- [FAQ](#faq)
|
|
56
|
+
- [License](#license)
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## What it looks like
|
|
61
|
+
|
|
62
|
+
Run the playground locally:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
git clone https://github.com/PeshoVurtoleta/lite-ambient-fx
|
|
66
|
+
cd lite-ambient-fx
|
|
67
|
+
npx serve .
|
|
68
|
+
# open http://localhost:3000/demo/
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The playground includes a theme picker, a live slider panel, and an FPS/count HUD, over a CRT phosphor-grid backdrop.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Why this exists
|
|
76
|
+
|
|
77
|
+
Landing pages, game menus, splash screens, and dashboards want an atmosphere. The two available paths were both bad:
|
|
78
|
+
|
|
79
|
+
1. **Ship a general-purpose particle engine** — [tsparticles](https://github.com/tsparticles/tsparticles) covers everything, but it's ~200KB gzipped and its config surface is a small language. You spend an hour picking values before you see anything.
|
|
80
|
+
2. **Hand-roll it** — twenty times, badly, every time. The DPR gets forgotten, the resize handler resets the whole simulation, the sprite cache is a memory leak, the `visibilitychange` handler is missing, and the frame delta is unclamped so a tab wake-up nukes the sim.
|
|
81
|
+
|
|
82
|
+
`lite-ambient-fx` is the middle path: **six curated presets** you drop in with one line, **four particle behaviors** underneath them, and **one file** of code. Every hard-earned lesson from re-writing the same 400 lines is baked in.
|
|
83
|
+
|
|
84
|
+
Constraints it was built under:
|
|
85
|
+
|
|
86
|
+
1. **One file, one dep-free import.** Nothing to configure, nothing to bundle, nothing to keep in sync.
|
|
87
|
+
2. **Presets are the API.** Users pick a name, not a config tree.
|
|
88
|
+
3. **Live-tweakable.** `updateConfig({ count: 200 })` works mid-run without a re-init.
|
|
89
|
+
4. **Well-behaved.** DPR-aware, resize-preserving, visibility-paused, delta-time clamped.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## What you get
|
|
94
|
+
|
|
95
|
+
- **`createAmbientFX(canvas, options)`** — mount an atmosphere on any canvas element.
|
|
96
|
+
- **`setTheme(name)`** — swap to a preset.
|
|
97
|
+
- **`updateConfig(overrides)`** — change any knob live (count, alpha, size, speed, decay, turbulence, wind, colors).
|
|
98
|
+
- **`pause()` / `resume()`** — manual control over the RAF loop (visibility already handles auto-pause).
|
|
99
|
+
- **`destroy()`** — release everything: RAF handle, visibility listener, ResizeObserver, sprite cache.
|
|
100
|
+
- **`config`** — a defensive-copy read of the current settings.
|
|
101
|
+
- **`THEMES` / `THEME_META`** — the preset table plus UI-builder metadata.
|
|
102
|
+
- **`clearAmbientSpriteCache()`** — targeted or full sprite eviction.
|
|
103
|
+
- **Pure helpers** — `mergeThemeConfig`, `validateConfig`, `envelopeAlpha`, `sinLut`, `deltaScale` — exported so tests and downstream consumers can share the math.
|
|
104
|
+
|
|
105
|
+
Full types in [`AmbientFX.d.ts`](./AmbientFX.d.ts).
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## The six shipped presets
|
|
110
|
+
|
|
111
|
+
| Preset | Behavior | Vibe |
|
|
112
|
+
| ------- | -------- | ------------------------------------------------------------ |
|
|
113
|
+
| `Fire` | EMBER | Orange-yellow embers rising with mild leftward wind |
|
|
114
|
+
| `Night` | EMBER | Cooler gold sparks with rightward drift; fewer particles |
|
|
115
|
+
| `Ice` | MIST | Large breathing blue-white fog blobs drifting horizontally |
|
|
116
|
+
| `Frost` | MIST | Pale off-white lavender fog, slower drift |
|
|
117
|
+
| `Toxic` | FLOAT | Neon green particles rising with sine-wave horizontal sway |
|
|
118
|
+
| `Void` | CHAOS | Fast omnidirectional purple particles with 8Hz flicker |
|
|
119
|
+
|
|
120
|
+
Every preset is a full `AmbientConfig`; you can inspect them at `THEMES[name]` and copy any field into `overrides`.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## The four behaviors
|
|
125
|
+
|
|
126
|
+
| Behavior | Motion | Life model | Best for |
|
|
127
|
+
| --------- | --------------------------------------------------- | ------------------------------------------------------- | ------------------------------------ |
|
|
128
|
+
| **EMBER** | Rise with wind + turbulence; ~15% "super" sparks | 0 → 1 progress; fade-in 0–0.2, fade-out 0.2–1 | Fire, sparks, dust motes |
|
|
129
|
+
| **MIST** | Slow horizontal drift; wrap at edges; sine breathe | ms accumulator; wraps at 72s to avoid Float32 drift | Fog, clouds, dense atmosphere |
|
|
130
|
+
| **FLOAT** | Rise with sine-wave horizontal sway (no turbulence) | 0 → 1 progress; fade-in 0–0.1, sustain, fade-out 0.9–1 | Bubbles, spores, gentle rain of dust |
|
|
131
|
+
| **CHAOS** | Random omnidirectional velocity, straight lines | 0 → 1 progress; ~7.8Hz bit-flicker on alpha | Void energy, static, glitch fields |
|
|
132
|
+
|
|
133
|
+
All four share:
|
|
134
|
+
|
|
135
|
+
- Delta-time scaling to `dt / 16` (60fps reference).
|
|
136
|
+
- Per-particle depth `z ∈ [0.2, 1.0]` that modulates size, velocity, and max alpha for a parallax feel.
|
|
137
|
+
- A shared 360-entry sine LUT (`Float32Array`) for turbulence and MIST breathing.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## API reference
|
|
142
|
+
|
|
143
|
+
### `createAmbientFX(canvas, options?)`
|
|
144
|
+
|
|
145
|
+
Mount an atmosphere on a canvas. The canvas is expected to be a fullscreen overlay (see [Sizing, DPR, and resize](#sizing-dpr-and-resize)).
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
const fx = createAmbientFX(canvas, {
|
|
149
|
+
theme: "Fire", // preset name (default: "Fire")
|
|
150
|
+
overrides: { count: 400, alpha: 0.9 }, // partial config, merged over theme
|
|
151
|
+
autoStart: true, // start the RAF loop (default: true)
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Returns an `AmbientInstance`.
|
|
156
|
+
|
|
157
|
+
### `AmbientInstance`
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
interface AmbientInstance {
|
|
161
|
+
setTheme(name): void; // swap to another preset
|
|
162
|
+
updateConfig(overrides): void; // change any knob live
|
|
163
|
+
readonly config: AmbientConfig; // defensive-copy snapshot
|
|
164
|
+
readonly theme: ThemeName; // current theme name
|
|
165
|
+
readonly count: number; // live particle count
|
|
166
|
+
readonly running: boolean; // RAF loop state
|
|
167
|
+
pause(): void; // stop RAF (idempotent)
|
|
168
|
+
resume(): void; // restart RAF (idempotent)
|
|
169
|
+
destroy(): void; // release everything (idempotent)
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Named exports
|
|
174
|
+
|
|
175
|
+
- `THEMES` — `Record<ThemeName, AmbientConfig>`; the six shipped presets.
|
|
176
|
+
- `THEME_META` — `Array<{ id, name, icon, behavior }>` for UI builders.
|
|
177
|
+
- `BEHAVIORS` — the behavior registry, keyed by name. Four built-in entries at module load.
|
|
178
|
+
- `registerBehavior(name, def)` — install a custom behavior or replace a built-in.
|
|
179
|
+
- `VERSION` — the package version string.
|
|
180
|
+
- `mergeThemeConfig(base, overrides)` — pure config merge; shallow-merges the `wind` vector.
|
|
181
|
+
- `validateConfig(cfg)` — throws on the first structural violation, returns the input on success.
|
|
182
|
+
- `envelopeAlpha(mode, life, maxAlpha)` — the fade curve per built-in behavior.
|
|
183
|
+
- `sinLut(index)` — sign-safe LUT access.
|
|
184
|
+
- `deltaScale(dtMs)` — `dtMs / 16`.
|
|
185
|
+
- `clearAmbientSpriteCache(colors?)` — evict sprites by color, or the whole cache.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Adding a custom behavior
|
|
190
|
+
|
|
191
|
+
The four shipped behaviors are just entries in the `BEHAVIORS` registry. Adding a fifth is a two-file change: register it, then reference it from a config.
|
|
192
|
+
|
|
193
|
+
```js
|
|
194
|
+
import { registerBehavior, createAmbientFX } from "@zakkster/lite-ambient-fx";
|
|
195
|
+
|
|
196
|
+
registerBehavior("SNOW", {
|
|
197
|
+
spriteLogical: 64, // CSS-pixel sprite size for this behavior
|
|
198
|
+
spawn(p, frame) {
|
|
199
|
+
// Populate every field of `p`. NEVER add new fields.
|
|
200
|
+
const cfg = frame.cfg;
|
|
201
|
+
p.z = Math.random() * 0.8 + 0.2;
|
|
202
|
+
p.color = cfg.colors[(Math.random() * cfg.colors.length) | 0];
|
|
203
|
+
p.spriteCanvas = frame.getSprite(p.color, 64);
|
|
204
|
+
p.life = 0;
|
|
205
|
+
p.x = Math.random() * frame.W;
|
|
206
|
+
p.y = frame.isInit ? Math.random() * frame.H : -20;
|
|
207
|
+
p.size = (Math.random() * cfg.size + 4) * p.z;
|
|
208
|
+
p.vx = (Math.random() - 0.5) * 0.5;
|
|
209
|
+
p.vy = cfg.speed * (0.5 + Math.random() * 0.5);
|
|
210
|
+
p.decay = cfg.decay;
|
|
211
|
+
p.maxAlpha = cfg.alpha * p.z;
|
|
212
|
+
// Zero the MIST-only slots to keep the monomorphic shape stable.
|
|
213
|
+
p.anchorX = 0; p.anchorY = 0; p.pulseOffset = 0;
|
|
214
|
+
},
|
|
215
|
+
tick(particles, ctx, frame) {
|
|
216
|
+
const ds = frame.ds;
|
|
217
|
+
const respawn = frame.respawn;
|
|
218
|
+
for (let i = 0; i < particles.length; i++) {
|
|
219
|
+
const p = particles[i];
|
|
220
|
+
p.y += (p.vy + frame.cfg.wind.y) * p.z * ds;
|
|
221
|
+
p.x += (p.vx + frame.cfg.wind.x) * p.z * ds + Math.sin(p.y * 0.02) * 0.3;
|
|
222
|
+
p.life += p.decay * ds;
|
|
223
|
+
if (p.y > frame.H + 20 || p.life >= 1) { respawn(p, false); continue; }
|
|
224
|
+
ctx.globalAlpha = p.maxAlpha;
|
|
225
|
+
const half = p.size * 0.5;
|
|
226
|
+
ctx.drawImage(p.spriteCanvas,
|
|
227
|
+
(p.x - half) | 0, (p.y - half) | 0,
|
|
228
|
+
p.size | 0, p.size | 0);
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Then wire it up with any theme:
|
|
234
|
+
createAmbientFX(canvas, {
|
|
235
|
+
theme: "Ice",
|
|
236
|
+
overrides: { behavior: "SNOW" }, // switches behavior, keeps Ice's palette
|
|
237
|
+
});
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
**Rules the registry enforces on you**:
|
|
241
|
+
|
|
242
|
+
1. `spawn` must populate **every** field of `p`. Do not add new fields — `p` is a monomorphic object initialized with the union of all built-in behavior fields; V8 uses a stable hidden class for it, and adding a property mid-run causes a deopt.
|
|
243
|
+
2. `spawn` must set `p.spriteCanvas` via `frame.getSprite(color, spriteLogical)` — this is the DPR-aware path, and it caches the resolved canvas on the particle so the hot loop pays no lookup cost.
|
|
244
|
+
3. `tick` may call `frame.respawn(p, false)` on dead particles to recycle them in place.
|
|
245
|
+
4. `frame` is pooled — do not retain references to it or its slots past the current call.
|
|
246
|
+
|
|
247
|
+
The `Particle` and `FrameContext` interfaces are exported from `AmbientFX.d.ts` for TS consumers.
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## Configuration knobs
|
|
252
|
+
|
|
253
|
+
Every preset is an `AmbientConfig`:
|
|
254
|
+
|
|
255
|
+
| Key | Type | Meaning |
|
|
256
|
+
| ------------ | ------------- | --------------------------------------------------------------------------------------- |
|
|
257
|
+
| `behavior` | `Behavior` | `"EMBER" \| "MIST" \| "FLOAT" \| "CHAOS"` |
|
|
258
|
+
| `colors` | `string[]` | Base palette; particles pick uniformly at random |
|
|
259
|
+
| `spark` | `string` | Rarer highlight color (~10% chance per spawn) |
|
|
260
|
+
| `count` | `number` | Live particle count |
|
|
261
|
+
| `wind` | `{ x, y }` | Constant advection vector per frame |
|
|
262
|
+
| `decay` | `number` | Per-frame life increment (smaller = longer-lived) |
|
|
263
|
+
| `speed` | `number` | Base velocity magnitude |
|
|
264
|
+
| `size` | `number` | Sprite draw size; small (2–30) for EMBER/FLOAT/CHAOS, large (50–500) for MIST |
|
|
265
|
+
| `alpha` | `number` | Alpha cap, clamped to `[0, 1]`; multiplied by per-particle `z` for depth |
|
|
266
|
+
| `turbulence` | `number` | Amplitude of the sin-LUT lateral turbulence |
|
|
267
|
+
|
|
268
|
+
**Live-update semantics:** `updateConfig` changes take effect for **new spawns**; particles alive at the moment of the change keep the values they were spawned with until they die and respawn. This is intentional — it lets a knob slide look smooth instead of snapping the whole atmosphere. If you want an immediate hard reset, call `updateConfig({ count: same })` — it re-initialises the pool.
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Sizing, DPR, and resize
|
|
273
|
+
|
|
274
|
+
The canvas is expected to be positioned as a fullscreen overlay via CSS. Recommended baseline:
|
|
275
|
+
|
|
276
|
+
```css
|
|
277
|
+
canvas#fx {
|
|
278
|
+
position: fixed;
|
|
279
|
+
inset: 0;
|
|
280
|
+
width: 100dvw;
|
|
281
|
+
height: 100dvh;
|
|
282
|
+
z-index: 0;
|
|
283
|
+
mix-blend-mode: screen;
|
|
284
|
+
pointer-events: none;
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Inside the library:
|
|
289
|
+
|
|
290
|
+
- **DPR handling** — sprites are cached at physical pixel size (`logicalSize × devicePixelRatio`), keyed on that physical size so a retina render doesn't reuse a blurry 1× sprite.
|
|
291
|
+
- **Resize handling** — a `ResizeObserver` on the canvas's parent element coalesces bursts to one `requestAnimationFrame`. On resize, particle positions are **rescaled proportionally** rather than re-spawned, so a window drag or orientation change doesn't restart the atmosphere.
|
|
292
|
+
- **Visibility handling** — when the tab becomes visible again, `lastTime` is reset so the next frame doesn't compute a huge delta and teleport every particle.
|
|
293
|
+
- **Delta-time clamping** — every frame's `dt` is clamped to 50ms. A tab wake-up produces one flat step, not a spike.
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## Performance notes
|
|
298
|
+
|
|
299
|
+
- **Zero string allocation in the sprite cache** — sprites are indexed as `Map<color, Map<physicalSize, canvas>>`. No template-literal key on the hot path.
|
|
300
|
+
- **`spriteCanvas` cached on the particle at spawn time** — the render loop reads `p.spriteCanvas` directly and calls `getSprite` zero times per frame. Sprite resolution happens only when a particle spawns (rare) or the theme changes (very rare).
|
|
301
|
+
- **Monomorphic particle shape** — every particle carries the union of behavior-specific fields (`anchorX`, `anchorY`, `pulseOffset` for MIST; unused-but-zeroed for other behaviors) from the moment it's pushed to the pool. V8's hidden class stays stable across theme/behavior swaps.
|
|
302
|
+
- **Behavior dispatch hoisted out of the render loop** — one registry lookup per frame, then a dedicated per-behavior tick loop. The branch predictor sees one path per behavior instead of a mode-check per particle.
|
|
303
|
+
- **`Math.trunc` for the CHAOS flicker phase** — avoids the 32-bit signed cast that `timestamp >> 7` does, which would wrap negative after ~24 days of `performance.now()`.
|
|
304
|
+
- **Zero-alloc pooled `FrameContext`** — the object passed to `spawn`/`tick` is allocated once at instance creation and mutated in place per frame.
|
|
305
|
+
- **Delta-time clamping** — every frame's `dt` is capped at 50ms so a tab wake-up produces one flat step, not a spike.
|
|
306
|
+
|
|
307
|
+
If you need to render **10,000+ particles**, this is not the package — reach for an SoA GPU pipeline instead. `lite-ambient-fx` is tuned for 40–500 particles as an atmosphere layer behind normal UI.
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Browser and runtime support
|
|
312
|
+
|
|
313
|
+
Pure ES2020 + Canvas 2D. Runs anywhere with a modern browser and a `<canvas>`.
|
|
314
|
+
|
|
315
|
+
| Target | Supported |
|
|
316
|
+
| ----------------------------- | --------- |
|
|
317
|
+
| Chrome / Edge (last 2 majors) | yes |
|
|
318
|
+
| Firefox (last 2 majors) | yes |
|
|
319
|
+
| Safari 14+ | yes |
|
|
320
|
+
| Twitch Extensions | yes |
|
|
321
|
+
| Node.js 18+ (for tests) | yes |
|
|
322
|
+
| SSR | N/A |
|
|
323
|
+
|
|
324
|
+
The module doesn't touch `document` or `window` at the top level — DOM access is deferred to `createAmbientFX` — so a bundler picking it up in an SSR context won't crash at import time.
|
|
325
|
+
|
|
326
|
+
ESM-only. Modern bundlers handle this; legacy consumers can use a wrapper.
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
## Integration recipes
|
|
331
|
+
|
|
332
|
+
### As a hero-section background
|
|
333
|
+
|
|
334
|
+
```html
|
|
335
|
+
<canvas id="bg" style="position:fixed;inset:0;z-index:0;mix-blend-mode:screen"></canvas>
|
|
336
|
+
<main style="position:relative;z-index:1"> ... </main>
|
|
337
|
+
|
|
338
|
+
<script type="module">
|
|
339
|
+
import { createAmbientFX } from "@zakkster/lite-ambient-fx";
|
|
340
|
+
createAmbientFX(document.getElementById("bg"), { theme: "Toxic" });
|
|
341
|
+
</script>
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
### Theme-follows-app-mode
|
|
345
|
+
|
|
346
|
+
```js
|
|
347
|
+
import { createAmbientFX } from "@zakkster/lite-ambient-fx";
|
|
348
|
+
|
|
349
|
+
const fx = createAmbientFX(canvas, { theme: "Frost" });
|
|
350
|
+
|
|
351
|
+
const media = matchMedia("(prefers-color-scheme: dark)");
|
|
352
|
+
function applyMode() {
|
|
353
|
+
fx.setTheme(media.matches ? "Void" : "Frost");
|
|
354
|
+
}
|
|
355
|
+
media.addEventListener("change", applyMode);
|
|
356
|
+
applyMode();
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### Driven by lite-signal
|
|
360
|
+
|
|
361
|
+
```js
|
|
362
|
+
import { signal, effect } from "@zakkster/lite-signal";
|
|
363
|
+
import { createAmbientFX } from "@zakkster/lite-ambient-fx";
|
|
364
|
+
|
|
365
|
+
const intensity = signal(1.0);
|
|
366
|
+
const fx = createAmbientFX(canvas, { theme: "Fire" });
|
|
367
|
+
|
|
368
|
+
effect(() => {
|
|
369
|
+
fx.updateConfig({ alpha: intensity(), count: (200 + intensity() * 200) | 0 });
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// somewhere else in the app:
|
|
373
|
+
intensity.set(0.4);
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
### Pause on modal open
|
|
377
|
+
|
|
378
|
+
```js
|
|
379
|
+
const fx = createAmbientFX(canvas, { theme: "Void" });
|
|
380
|
+
dialog.addEventListener("open", () => fx.pause());
|
|
381
|
+
dialog.addEventListener("close", () => fx.resume());
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
---
|
|
385
|
+
|
|
386
|
+
## Testing
|
|
387
|
+
|
|
388
|
+
Three test files under `test/`:
|
|
389
|
+
|
|
390
|
+
- **`01-config_test.mjs`** — DOM-free pure-helper tests. THEMES surface, `mergeThemeConfig` semantics, `validateConfig` throws, sine LUT wrap, `envelopeAlpha` curves per behavior, VERSION parity with `package.json`.
|
|
391
|
+
- **`02-runtime_test.mjs`** — full lifecycle under a minimal DOM shim (mocked canvas 2D context, `requestAnimationFrame`, `document`). Boot, theme swap, config update, pause/resume, destroy, dt clamping across a simulated 10-second tab freeze.
|
|
392
|
+
- **`03-registry_test.mjs`** — `BEHAVIORS` surface, `registerBehavior` argument validation, end-to-end custom behavior use, particle shape monomorphism, and the `FrameContext` spawn/respawn contract.
|
|
393
|
+
|
|
394
|
+
70 tests across all three files.
|
|
395
|
+
|
|
396
|
+
```
|
|
397
|
+
npm test
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
The DOM shim is intentionally minimal — this is a canvas-only package, and unit-level canvas fidelity would be a maintenance sink. Visual regressions are caught by the demo.
|
|
401
|
+
|
|
402
|
+
---
|
|
403
|
+
|
|
404
|
+
## Ecosystem
|
|
405
|
+
|
|
406
|
+
Part of the [`@zakkster`](https://www.npmjs.com/~zakkster) zero-GC stack: **[`lite-signal`](https://www.npmjs.com/package/@zakkster/lite-signal)** · **[`lite-gl`](https://www.npmjs.com/package/@zakkster/lite-gl)** · **[`lite-scene`](https://www.npmjs.com/package/@zakkster/lite-scene)** · **[`lite-color`](https://www.npmjs.com/package/@zakkster/lite-color)** · **[`lite-raf`](https://www.npmjs.com/package/@zakkster/lite-raf)** · **[`lite-time`](https://www.npmjs.com/package/@zakkster/lite-time)**
|
|
407
|
+
|
|
408
|
+
`lite-ambient-fx` deliberately does **not** import from these — it's the "drop it on any page" tier of the stack. If you're already using `lite-signal` and `lite-raf` in your app, feed values into `updateConfig` from a `rafEffect` for smooth parameter automation. If you need seeded reproducibility, wire in `@zakkster/lite-random` upstream and pass the values through `overrides`.
|
|
409
|
+
|
|
410
|
+
---
|
|
411
|
+
|
|
412
|
+
## FAQ
|
|
413
|
+
|
|
414
|
+
**Why single-file with no dependencies?** Because that's what makes the package trivial to drop into anything — a landing page, an Astro island, a Vue app, a Twitch extension, a Codepen. Bundlers can tree-shake nothing away since it's already one file, but they also can't accidentally pull in a duplicate of another package.
|
|
415
|
+
|
|
416
|
+
**Can I use my own palette instead of one of the six presets?** Yes — pass `overrides: { colors: [...], spark: "..." }` at construction, or call `fx.updateConfig({ colors: [...] })` later. Any valid CSS color string works (hex, rgb, hsl, oklch on modern browsers).
|
|
417
|
+
|
|
418
|
+
**Do I need `lite-viewport` or a shared ticker?** No — this package handles DPR, resize, and its own RAF loop internally. If you want one shared RAF across multiple ambient layers, use `pause()` and drive `_tickManually` yourself (roadmap for v1.1).
|
|
419
|
+
|
|
420
|
+
**Why is my MIST theme rendering as solid squares?** You forgot `mix-blend-mode: screen` on the canvas CSS. MIST relies on additive blending to look like fog.
|
|
421
|
+
|
|
422
|
+
**What happens if `devicePixelRatio` changes at runtime (drag between monitors)?** The `ResizeObserver` fires on the size change; the next frame re-primes sprites at the new physical size. There's a one-frame flash of the old sprites; imperceptible in practice.
|
|
423
|
+
|
|
424
|
+
**Is `lite-ambient-fx` a `tsparticles` replacement?** No. `tsparticles` is a general-purpose particle engine with dozens of shape/interaction modules. `lite-ambient-fx` is six curated atmospheres, one file, no config language. Different tier.
|
|
425
|
+
|
|
426
|
+
**Can I run it in a Web Worker with OffscreenCanvas?** Not out of the box — the module reads `window.devicePixelRatio` and `document.hidden` directly. Porting is straightforward if you want to try it; PRs welcome.
|
|
427
|
+
|
|
428
|
+
---
|
|
429
|
+
|
|
430
|
+
## License
|
|
431
|
+
|
|
432
|
+
MIT © Zahary Shinikchiev
|
|
433
|
+
|
|
434
|
+
---
|
|
435
|
+
|
|
436
|
+
> Part of the **@zakkster** zero-GC stack.
|