@zakkster/lite-scratch-fx 1.0.0 → 1.2.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.
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Pixel scanner -- finds the still-covered pixels of a scratch layer as normalized
3
+ * 0..1 spawn points. Allocated ONCE per scanner and reused across every scan: the
4
+ * offscreen canvas, its context, and the spot arrays are built lazily and grown only
5
+ * when the source canvas is resized, so a scan allocates nothing per pixel (only
6
+ * getImageData's single buffer, which the 2D canvas API mandates).
7
+ *
8
+ * Shared by ScratchController and ScratchStage.
9
+ *
10
+ * Copyright (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
11
+ * MIT License.
12
+ */
13
+
14
+ /**
15
+ * @param {HTMLCanvasElement} sourceCanvas The scratch layer to scan.
16
+ * @returns {{ scan(precision:number): { spotX: Float32Array, spotY: Float32Array, count: number },
17
+ * destroy(): void }}
18
+ */
19
+ export function createPixelScanner(sourceCanvas) {
20
+ let scanCanvas = null;
21
+ let scanCtx = null;
22
+ let scanW = 0, scanH = 0;
23
+ let spotX = new Float32Array(0);
24
+ let spotY = new Float32Array(0);
25
+ const result = { spotX, spotY, count: 0 }; // reused -- no per-scan allocation
26
+
27
+ function ensure(precision) {
28
+ const srcW = sourceCanvas.width || sourceCanvas.offsetWidth || 1;
29
+ const srcH = sourceCanvas.height || sourceCanvas.offsetHeight || 1;
30
+ const h = Math.max(1, Math.floor(srcH * (precision / srcW)));
31
+ if (scanCanvas && scanW === precision && scanH === h) return;
32
+ scanW = precision;
33
+ scanH = h;
34
+ if (!scanCanvas) {
35
+ scanCanvas = document.createElement('canvas');
36
+ scanCtx = scanCanvas.getContext('2d', { willReadFrequently: true });
37
+ }
38
+ scanCanvas.width = scanW;
39
+ scanCanvas.height = scanH;
40
+ const cap = scanW * scanH;
41
+ if (spotX.length < cap) {
42
+ spotX = new Float32Array(cap);
43
+ spotY = new Float32Array(cap);
44
+ }
45
+ }
46
+
47
+ return {
48
+ scan(precision) {
49
+ ensure(precision);
50
+ scanCtx.clearRect(0, 0, scanW, scanH);
51
+ scanCtx.drawImage(sourceCanvas, 0, 0, scanW, scanH);
52
+ const data = scanCtx.getImageData(0, 0, scanW, scanH).data;
53
+ let n = 0;
54
+ for (let i = 3; i < data.length; i += 4) {
55
+ if (data[i] > 128) {
56
+ const p = (i - 3) / 4;
57
+ spotX[n] = (p % scanW) / scanW;
58
+ spotY[n] = ((p / scanW) | 0) / scanH;
59
+ n++;
60
+ }
61
+ }
62
+ result.spotX = spotX;
63
+ result.spotY = spotY;
64
+ result.count = n;
65
+ return result;
66
+ },
67
+ destroy() {
68
+ scanCanvas = null;
69
+ scanCtx = null;
70
+ },
71
+ };
72
+ }
73
+
74
+ export default createPixelScanner;
@@ -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,84 +42,45 @@
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
+
47
+ // Shared engines currently driving a reveal. A shared engine has one render slot and one
48
+ // particle pool, so only one controller may reveal on it at a time; a second reveal is
49
+ // ignored rather than clobbering the first. Owned engines never enter this set.
50
+ const busySharedEngines = new WeakSet();
32
51
 
33
52
  /**
34
53
  * @param {HTMLCanvasElement} sourceCanvas The scratch layer being revealed
35
54
  * @param {HTMLCanvasElement} effectCanvas Overlay canvas for VFX
36
55
  * @param {Object} [options]
56
+ * @param {number} [options.maxParticles=2000] Pool capacity (ignored when `engine` is given)
57
+ * @param {number} [options.seed=Date.now()] Deterministic RNG seed
58
+ * @param {number} [options.scanPrecision=32] Horizontal resolution of the pixel scan
59
+ * @param {boolean} [options.driven=false] Host-driven: skip start(), call tick(dt)
60
+ * @param {SoaParticleEngine} [options.engine] Share a caller-supplied engine + lane pool
37
61
  */
38
62
  export function createScratchController(sourceCanvas, effectCanvas, {
39
63
  maxParticles = 2000,
40
64
  seed = Date.now(),
41
65
  scanPrecision = 32,
66
+ driven = false,
67
+ engine: externalEngine = null,
42
68
  } = {}) {
43
69
  const ctx = effectCanvas.getContext('2d');
44
- const engine = new SoaParticleEngine(maxParticles);
70
+ const ownEngine = !externalEngine;
71
+ const engine = externalEngine || new SoaParticleEngine(maxParticles);
45
72
  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
73
 
52
74
  let activeRecipe = null;
53
75
  let destroyed = false;
54
76
  let elapsed = 0;
55
77
  let onComplete = null;
56
78
 
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;
79
+ // The pixel scanner owns the offscreen scan canvas + spot arrays, all allocated once
80
+ // and reused across reveals -- zero per-pixel allocation on the reveal-start frame.
81
+ const scanner = createPixelScanner(sourceCanvas);
68
82
  const spot = { x: 0.5, y: 0.5 }; // the single shared, reused spawn point
69
83
 
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
84
  // The particle-view object handed to recipe.tick every frame. Built ONCE and
111
85
  // reused: the engine's SoA arrays are stable references for the life of the engine,
112
86
  // so a fresh `{ x, y, ... }` literal per frame would be a pure zero-GC violation on
@@ -114,7 +88,7 @@ export function createScratchController(sourceCanvas, effectCanvas, {
114
88
  let pView = null;
115
89
 
116
90
  // ── Render callback (the engine calls this every frame with raw SoA arrays) ──
117
- engine.onTick((dt, x, y, vx, vy, life, invLife, data, max) => {
91
+ function renderTick(dt, x, y, vx, vy, life, invLife, data, max) {
118
92
  if (destroyed || !activeRecipe) return;
119
93
  elapsed += dt;
120
94
  const w = effectCanvas.width;
@@ -134,7 +108,8 @@ export function createScratchController(sourceCanvas, effectCanvas, {
134
108
  );
135
109
 
136
110
  if (isFinished) {
137
- engine.stop();
111
+ if (!driven) engine.stop();
112
+ releaseEngine();
138
113
  if (activeRecipe.destroy) activeRecipe.destroy();
139
114
  activeRecipe = null;
140
115
  ctx.clearRect(0, 0, w, h);
@@ -142,28 +117,48 @@ export function createScratchController(sourceCanvas, effectCanvas, {
142
117
  onComplete = null;
143
118
  if (done) done();
144
119
  }
145
- });
120
+ }
121
+
122
+ // Release our hold on a shared engine's single render slot so the next controller can
123
+ // reveal on it. A no-op for an owned engine, which stays bound for its whole life.
124
+ function releaseEngine() {
125
+ if (ownEngine) return;
126
+ engine.onTick(null);
127
+ busySharedEngines.delete(engine);
128
+ }
129
+
130
+ // An owned engine is bound once for its whole life; the renderTick early-returns when no
131
+ // reveal is active. A shared engine is bound per-reveal (in reveal) and released on
132
+ // completion so it can serve other controllers.
133
+ if (ownEngine) engine.onTick(renderTick);
146
134
 
147
135
  return {
148
136
  /**
149
- * Execute a reveal recipe.
137
+ * Execute a reveal recipe. Ignored if this controller is already revealing, is
138
+ * destroyed, or shares an engine that another controller is revealing on.
150
139
  * @param {Object} recipe
151
140
  * @param {Function} [onDone] Called when the effect completes
152
141
  */
153
142
  reveal(recipe, onDone) {
154
143
  if (destroyed || activeRecipe) return;
144
+ if (!ownEngine && busySharedEngines.has(engine)) return;
155
145
 
156
146
  activeRecipe = recipe;
157
147
  onComplete = onDone || null;
158
148
  elapsed = 0;
149
+
150
+ if (!ownEngine) {
151
+ engine.onTick(renderTick);
152
+ busySharedEngines.add(engine);
153
+ }
159
154
  engine.clear();
160
155
 
161
156
  // Size the effect canvas to match the scratch layer
162
157
  const w = effectCanvas.width = sourceCanvas.offsetWidth || sourceCanvas.width;
163
158
  const h = effectCanvas.height = sourceCanvas.offsetHeight || sourceCanvas.height;
164
159
 
165
- // Scan spawn points from visible pixels (fills spotX/spotY, sets spotCount).
166
- scanPixels();
160
+ // Scan spawn points from visible pixels.
161
+ const spots = scanner.scan(scanPrecision);
167
162
 
168
163
  // Init recipe
169
164
  recipe.init(ctx, maxParticles, w, h);
@@ -175,10 +170,10 @@ export function createScratchController(sourceCanvas, effectCanvas, {
175
170
  const count = Math.min(requested, maxParticles);
176
171
  for (let i = 0; i < count; i++) {
177
172
  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];
173
+ if (spots.count > 0) {
174
+ const k = rng.int(0, spots.count - 1);
175
+ spot.x = spots.spotX[k];
176
+ spot.y = spots.spotY[k];
182
177
  } else {
183
178
  spot.x = 0.5;
184
179
  spot.y = 0.5;
@@ -187,8 +182,21 @@ export function createScratchController(sourceCanvas, effectCanvas, {
187
182
  engine.emit(state.x, state.y, state.vx, state.vy, state.life, idx);
188
183
  }
189
184
 
190
- // The engine owns its RAF loop; start() schedules it and drives onTick.
191
- engine.start();
185
+ // Self-driven: the engine's RAF loop drives renderTick. Host-driven: the host
186
+ // calls controller.tick(dt) instead; we schedule nothing.
187
+ if (!driven) engine.start();
188
+ },
189
+
190
+ /**
191
+ * Advance one frame in host-driven mode. `dt` is in SECONDS. A no-op unless the
192
+ * controller was created with `{ driven: true }` AND a reveal is active -- so a host
193
+ * can safely call tick() on every controller each frame; only the revealing one (and,
194
+ * for a shared engine, only the single active controller) does work.
195
+ * @param {number} dt Seconds since the previous frame.
196
+ */
197
+ tick(dt) {
198
+ if (destroyed || !driven || activeRecipe === null) return;
199
+ engine.tick(dt);
192
200
  },
193
201
 
194
202
  /** Re-seed the RNG. */
@@ -197,13 +205,14 @@ export function createScratchController(sourceCanvas, effectCanvas, {
197
205
  destroy() {
198
206
  if (destroyed) return;
199
207
  destroyed = true;
200
- engine.stop();
208
+ if (!driven) engine.stop();
209
+ releaseEngine();
201
210
  if (activeRecipe && activeRecipe.destroy) activeRecipe.destroy();
202
211
  activeRecipe = null;
203
212
  onComplete = null;
204
- scanCanvas = null;
205
- scanCtx = null;
206
- engine.destroy();
213
+ scanner.destroy();
214
+ // Only tear down an engine we created. A caller-supplied engine is theirs.
215
+ if (ownEngine) engine.destroy();
207
216
  },
208
217
  };
209
218
  }