@zakkster/lite-scratch-fx 1.0.0 → 1.3.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.
@@ -3,7 +3,20 @@
3
3
  *
4
4
  * Mounts a reveal recipe, scans the remaining visible pixels of the scratch layer for
5
5
  * spawn points, delegates physics + rendering to the recipe, and auto-cleans up on
6
- * completion. The particle engine owns its own requestAnimationFrame loop.
6
+ * completion.
7
+ *
8
+ * Driving model (three ways, all keep dt correct -- the double-drive that corrupts dt is
9
+ * never possible):
10
+ * - DEFAULT: the controller owns a SoaParticleEngine and lets it self-drive via its own
11
+ * requestAnimationFrame loop (start() on reveal, stop() on completion).
12
+ * - HOST-DRIVEN (`{ driven: true }`): the controller never calls start(); the host runs
13
+ * one clock (e.g. @zakkster/lite-ticker / lite-raf) and calls `controller.tick(dt)`
14
+ * every frame with dt in SECONDS. One page loop drives N controllers.
15
+ * - SHARED ENGINE (`{ engine }`): N controllers share one caller-supplied engine, so one
16
+ * lane pool serves the whole page instead of one per controller. The engine has a
17
+ * single render slot and one particle pool, so ONE reveal per shared engine at a time
18
+ * -- a reveal attempted while another is active on the same engine is ignored. Pair
19
+ * with `{ driven: true }` and drive the page from one clock.
7
20
  *
8
21
  * Recipe interface:
9
22
  * {
@@ -29,97 +42,71 @@
29
42
 
30
43
  import { SoaParticleEngine } from '@zakkster/lite-soa-particle-engine';
31
44
  import { Random } from '@zakkster/lite-random';
45
+ import { createPixelScanner } from './PixelScan.js';
46
+ import { captureHostStyle, restoreHostStyle } from './HostStyle.js';
47
+
48
+ // Shared engines currently driving a reveal. A shared engine has one render slot and one
49
+ // particle pool, so only one controller may reveal on it at a time; a second reveal is
50
+ // ignored rather than clobbering the first. Owned engines never enter this set.
51
+ const busySharedEngines = new WeakSet();
32
52
 
33
53
  /**
34
54
  * @param {HTMLCanvasElement} sourceCanvas The scratch layer being revealed
35
55
  * @param {HTMLCanvasElement} effectCanvas Overlay canvas for VFX
36
56
  * @param {Object} [options]
57
+ * @param {number} [options.maxParticles=2000] Pool capacity (ignored when `engine` is given)
58
+ * @param {number} [options.seed=Date.now()] Deterministic RNG seed
59
+ * @param {number} [options.scanPrecision=32] Horizontal resolution of the pixel scan
60
+ * @param {boolean} [options.driven=false] Host-driven: skip start(), call tick(dt)
61
+ * @param {SoaParticleEngine} [options.engine] Share a caller-supplied engine + lane pool
62
+ * @param {number} [options.dpr=1] Device-pixel-ratio for sharp high-DPI output
37
63
  */
38
64
  export function createScratchController(sourceCanvas, effectCanvas, {
39
65
  maxParticles = 2000,
40
66
  seed = Date.now(),
41
67
  scanPrecision = 32,
68
+ driven = false,
69
+ engine: externalEngine = null,
70
+ dpr = 1,
42
71
  } = {}) {
72
+ if (!(typeof dpr === 'number' && Number.isFinite(dpr) && dpr > 0)) {
73
+ throw new TypeError('createScratchController: dpr must be a positive finite number');
74
+ }
43
75
  const ctx = effectCanvas.getContext('2d');
44
- const engine = new SoaParticleEngine(maxParticles);
76
+ const ownEngine = !externalEngine;
77
+ const engine = externalEngine || new SoaParticleEngine(maxParticles);
45
78
  const rng = new Random(seed);
46
- // The SoA engine owns its own requestAnimationFrame loop: start() schedules it, stop()
47
- // cancels it, and it computes dt internally. The controller must NOT also drive
48
- // engine._loop from an external ticker -- that double-drives the loop and corrupts its
49
- // dt, which breaks both particle life decay and completion detection. (An earlier
50
- // version accepted a `sharedTicker` and did exactly that; it is removed.)
51
79
 
52
80
  let activeRecipe = null;
53
81
  let destroyed = false;
54
82
  let elapsed = 0;
55
83
  let onComplete = null;
84
+ // The host layer's inline style as it was at reveal START -- one small object per
85
+ // reveal (cold path), restored on every terminal transition so recipes leave no
86
+ // surviving inline opacity/transform/filter/clipPath/transformOrigin.
87
+ let hostSnapshot = null;
88
+ // Logical (CSS-pixel) reveal dimensions, cached at reveal start. Recipes author and
89
+ // draw in these; the device-pixel canvas is dpr x larger and the 2D transform maps
90
+ // logical -> device. Every per-frame clearRect and recipe call uses these, never the
91
+ // device-px effectCanvas.width/height.
92
+ let logicalW = 0, logicalH = 0;
56
93
 
57
- // ── Reveal-scan scratch, all allocated ONCE (zero-GC on the reveal-start frame) ──
58
- // The offscreen scan canvas, its context, and the spawn-point arrays are built here
59
- // and reused across every reveal. Only getImageData() -- a single small typed array
60
- // per reveal, mandated by the 2D canvas API -- is not reusable; the per-pixel object
61
- // churn of the previous design (one `{x, y}` literal per surviving pixel) is gone.
62
- let scanCanvas = null;
63
- let scanCtx = null;
64
- let scanW = 0, scanH = 0;
65
- let spotX = new Float32Array(0); // grown lazily if the source canvas is ever resized
66
- let spotY = new Float32Array(0);
67
- let spotCount = 0;
94
+ // The pixel scanner owns the offscreen scan canvas + spot arrays, all allocated once
95
+ // and reused across reveals -- zero per-pixel allocation on the reveal-start frame.
96
+ const scanner = createPixelScanner(sourceCanvas);
68
97
  const spot = { x: 0.5, y: 0.5 }; // the single shared, reused spawn point
69
98
 
70
- function ensureScanBuffers(precision) {
71
- const srcW = sourceCanvas.width || sourceCanvas.offsetWidth || 1;
72
- const srcH = sourceCanvas.height || sourceCanvas.offsetHeight || 1;
73
- const h = Math.max(1, Math.floor(srcH * (precision / srcW)));
74
- if (scanCanvas && scanW === precision && scanH === h) return;
75
- // Dims changed (or first run): (re)build. Off the hot path -- once per resize.
76
- scanW = precision;
77
- scanH = h;
78
- if (!scanCanvas) {
79
- scanCanvas = document.createElement('canvas');
80
- scanCtx = scanCanvas.getContext('2d', { willReadFrequently: true });
81
- }
82
- scanCanvas.width = scanW;
83
- scanCanvas.height = scanH;
84
- const cap = scanW * scanH;
85
- if (spotX.length < cap) {
86
- spotX = new Float32Array(cap);
87
- spotY = new Float32Array(cap);
88
- }
89
- }
90
-
91
- // ── Scan remaining visible pixels from the scratch canvas into spotX/spotY ──
92
- // Fills the preallocated arrays and sets spotCount. Allocates nothing per pixel.
93
- function scanPixels(precision = scanPrecision) {
94
- ensureScanBuffers(precision);
95
- scanCtx.clearRect(0, 0, scanW, scanH);
96
- scanCtx.drawImage(sourceCanvas, 0, 0, scanW, scanH);
97
- const data = scanCtx.getImageData(0, 0, scanW, scanH).data;
98
- let n = 0;
99
- for (let i = 3; i < data.length; i += 4) {
100
- if (data[i] > 128) {
101
- const p = (i - 3) / 4;
102
- spotX[n] = (p % scanW) / scanW;
103
- spotY[n] = ((p / scanW) | 0) / scanH;
104
- n++;
105
- }
106
- }
107
- spotCount = n;
108
- }
109
-
110
99
  // The particle-view object handed to recipe.tick every frame. Built ONCE and
111
100
  // reused: the engine's SoA arrays are stable references for the life of the engine,
112
101
  // so a fresh `{ x, y, ... }` literal per frame would be a pure zero-GC violation on
113
102
  // the hottest path in the package. Reassign the fields each frame (free) instead.
114
103
  let pView = null;
115
104
 
116
- // ── Render callback (the engine calls this every frame with raw SoA arrays) ──
117
- engine.onTick((dt, x, y, vx, vy, life, invLife, data, max) => {
105
+ // -- Render callback (the engine calls this every frame with raw SoA arrays) --
106
+ function renderTick(dt, x, y, vx, vy, life, invLife, data, max) {
118
107
  if (destroyed || !activeRecipe) return;
119
108
  elapsed += dt;
120
- const w = effectCanvas.width;
121
- const h = effectCanvas.height;
122
- ctx.clearRect(0, 0, w, h);
109
+ ctx.clearRect(0, 0, logicalW, logicalH);
123
110
 
124
111
  if (pView === null) {
125
112
  pView = { x, y, vx, vy, life, invLife, data, max };
@@ -130,43 +117,84 @@ export function createScratchController(sourceCanvas, effectCanvas, {
130
117
 
131
118
  // Delegate everything to the recipe. Returns true when done.
132
119
  const isFinished = activeRecipe.tick(
133
- dt, elapsed * 1000, pView, ctx, sourceCanvas, w, h,
120
+ dt, elapsed * 1000, pView, ctx, sourceCanvas, logicalW, logicalH,
134
121
  );
135
122
 
136
- if (isFinished) {
137
- engine.stop();
138
- if (activeRecipe.destroy) activeRecipe.destroy();
139
- activeRecipe = null;
140
- ctx.clearRect(0, 0, w, h);
141
- const done = onComplete;
142
- onComplete = null;
143
- if (done) done();
144
- }
145
- });
123
+ if (isFinished) endReveal(true);
124
+ }
125
+
126
+ // The single terminator for a reveal. Every terminal transition -- natural completion
127
+ // (fireDone=true), cancel() (false), and destroy() (false) -- routes through here, so
128
+ // the engine release, recipe teardown, host-style restore, and canvas clear happen in
129
+ // exactly one place and can never diverge. Cold path: called once per reveal, never
130
+ // per frame. onDone fires ONLY on natural completion (fireDone).
131
+ function endReveal(fireDone) {
132
+ if (!activeRecipe) return;
133
+ if (ownEngine && !driven) engine.stop();
134
+ releaseEngine();
135
+ if (activeRecipe.destroy) activeRecipe.destroy();
136
+ if (hostSnapshot) { restoreHostStyle(sourceCanvas, hostSnapshot); hostSnapshot = null; }
137
+ ctx.clearRect(0, 0, logicalW, logicalH);
138
+ const done = onComplete;
139
+ activeRecipe = null;
140
+ onComplete = null;
141
+ if (fireDone && done) done();
142
+ }
143
+
144
+ // Release our hold on a shared engine's single render slot so the next controller can
145
+ // reveal on it. A no-op for an owned engine, which stays bound for its whole life.
146
+ function releaseEngine() {
147
+ if (ownEngine) return;
148
+ engine.onTick(null);
149
+ busySharedEngines.delete(engine);
150
+ }
151
+
152
+ // An owned engine is bound once for its whole life; the renderTick early-returns when no
153
+ // reveal is active. A shared engine is bound per-reveal (in reveal) and released on
154
+ // completion so it can serve other controllers.
155
+ if (ownEngine) engine.onTick(renderTick);
146
156
 
147
157
  return {
148
158
  /**
149
- * Execute a reveal recipe.
159
+ * Execute a reveal recipe. Ignored if this controller is already revealing, is
160
+ * destroyed, or shares an engine that another controller is revealing on.
150
161
  * @param {Object} recipe
151
162
  * @param {Function} [onDone] Called when the effect completes
152
163
  */
153
164
  reveal(recipe, onDone) {
154
165
  if (destroyed || activeRecipe) return;
166
+ if (!ownEngine && busySharedEngines.has(engine)) return;
155
167
 
156
168
  activeRecipe = recipe;
157
169
  onComplete = onDone || null;
158
170
  elapsed = 0;
171
+
172
+ if (!ownEngine) {
173
+ engine.onTick(renderTick);
174
+ busySharedEngines.add(engine);
175
+ }
159
176
  engine.clear();
160
177
 
161
- // Size the effect canvas to match the scratch layer
162
- const w = effectCanvas.width = sourceCanvas.offsetWidth || sourceCanvas.width;
163
- const h = effectCanvas.height = sourceCanvas.offsetHeight || sourceCanvas.height;
178
+ // Logical (CSS-pixel) size of the scratch layer -- recipes author in these.
179
+ logicalW = sourceCanvas.offsetWidth || sourceCanvas.width;
180
+ logicalH = sourceCanvas.offsetHeight || sourceCanvas.height;
181
+ // Device-pixel backing store: dpr x larger so output is sharp on high-DPI
182
+ // screens. Assigning width/height also resets the 2D transform, so map logical
183
+ // -> device with setTransform. At dpr === 1 skip it: behaviour is then
184
+ // byte-identical to the pre-DPR path (no setTransform call, no scaled dims).
185
+ effectCanvas.width = Math.round(logicalW * dpr);
186
+ effectCanvas.height = Math.round(logicalH * dpr);
187
+ if (dpr !== 1) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
188
+
189
+ // Borrow the host layer's inline style: snapshot it now (cold path, one small
190
+ // object) so endReveal can return it pristine on completion/cancel/destroy.
191
+ hostSnapshot = captureHostStyle(sourceCanvas);
164
192
 
165
- // Scan spawn points from visible pixels (fills spotX/spotY, sets spotCount).
166
- scanPixels();
193
+ // Scan spawn points from visible pixels.
194
+ const spots = scanner.scan(scanPrecision);
167
195
 
168
- // Init recipe
169
- recipe.init(ctx, maxParticles, w, h);
196
+ // Init recipe with LOGICAL size (recipes stay in CSS px).
197
+ recipe.init(ctx, maxParticles, logicalW, logicalH);
170
198
 
171
199
  // Populate ring buffer. A recipe may declare `count: 0` (a pure-canvas reveal
172
200
  // like Shine or Implosion that draws no particles); use ?? so an explicit 0 is
@@ -175,20 +203,43 @@ export function createScratchController(sourceCanvas, effectCanvas, {
175
203
  const count = Math.min(requested, maxParticles);
176
204
  for (let i = 0; i < count; i++) {
177
205
  const idx = engine._head;
178
- if (spotCount > 0) {
179
- const k = rng.int(0, spotCount - 1);
180
- spot.x = spotX[k];
181
- spot.y = spotY[k];
206
+ if (spots.count > 0) {
207
+ const k = rng.int(0, spots.count - 1);
208
+ spot.x = spots.spotX[k];
209
+ spot.y = spots.spotY[k];
182
210
  } else {
183
211
  spot.x = 0.5;
184
212
  spot.y = 0.5;
185
213
  }
186
- const state = recipe.spawn(idx, rng, w, h, spot);
214
+ const state = recipe.spawn(idx, rng, logicalW, logicalH, spot);
187
215
  engine.emit(state.x, state.y, state.vx, state.vy, state.life, idx);
188
216
  }
189
217
 
190
- // The engine owns its RAF loop; start() schedules it and drives onTick.
191
- engine.start();
218
+ // Self-driven: the engine's RAF loop drives renderTick. Host-driven: the host
219
+ // calls controller.tick(dt) instead; we schedule nothing.
220
+ if (!driven) engine.start();
221
+ },
222
+
223
+ /**
224
+ * Advance one frame in host-driven mode. `dt` is in SECONDS. A no-op unless the
225
+ * controller was created with `{ driven: true }` AND a reveal is active -- so a host
226
+ * can safely call tick() on every controller each frame; only the revealing one (and,
227
+ * for a shared engine, only the single active controller) does work.
228
+ * @param {number} dt Seconds since the previous frame.
229
+ */
230
+ tick(dt) {
231
+ if (destroyed || !driven || activeRecipe === null) return;
232
+ engine.tick(dt);
233
+ },
234
+
235
+ /**
236
+ * Abort an in-flight reveal. Stops the effect, restores the host canvas's inline
237
+ * style to exactly what it was at reveal start, and does NOT fire onDone. The
238
+ * controller is revealable again afterwards. A no-op when idle or destroyed.
239
+ */
240
+ cancel() {
241
+ if (destroyed || !activeRecipe) return;
242
+ endReveal(false);
192
243
  },
193
244
 
194
245
  /** Re-seed the RNG. */
@@ -197,13 +248,12 @@ export function createScratchController(sourceCanvas, effectCanvas, {
197
248
  destroy() {
198
249
  if (destroyed) return;
199
250
  destroyed = true;
200
- engine.stop();
201
- if (activeRecipe && activeRecipe.destroy) activeRecipe.destroy();
202
- activeRecipe = null;
203
- onComplete = null;
204
- scanCanvas = null;
205
- scanCtx = null;
206
- engine.destroy();
251
+ // Route any in-flight reveal through the single terminator (stops/releases the
252
+ // engine, restores host style, no onDone) before the controller's own teardown.
253
+ endReveal(false);
254
+ scanner.destroy();
255
+ // Only tear down an engine we created. A caller-supplied engine is theirs.
256
+ if (ownEngine) engine.destroy();
207
257
  },
208
258
  };
209
259
  }