@lovo/matter 0.4.1 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @lovo/matter
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c67eb98: Rename engine exports to spelled-out, domain-accurate names (breaking).
8
+
9
+ - `fbm` → `fractalNoise` (and `FBMOptions` → `FractalNoiseOptions`)
10
+ - `noise` → `simplexNoise`
11
+ - `sdfCircle` → `signedDistanceFieldCircle`
12
+ - `time` → `elapsedTime`
13
+ - `Vec2` → `Vector2`
14
+
15
+ `TSLNode`, `voronoi`, `colorRamp`, `quantize`, `displace`, `cursorRipple`, and `filmGrain` are unchanged.
16
+
17
+ **Migration:** one-pass find-and-replace in your imports and call sites. No behavioral changes.
18
+
3
19
  ## 0.4.1
4
20
 
5
21
  ### Patch Changes
package/README.md CHANGED
@@ -15,18 +15,18 @@ npm install @lovo/matter three
15
15
 
16
16
  ## What's inside
17
17
 
18
- - **TSL primitives**: `fbm`, `voronoi`, `colorRamp`, `quantize`, and a handful of others — composable shader fragments for procedural visuals.
18
+ - **TSL primitives**: `fractalNoise`, `voronoi`, `colorRamp`, `quantize`, and a handful of others — composable shader fragments for procedural visuals.
19
19
  - **Renderer**: thin wrapper around `WebGPURenderer` that handles canvas resize, DPR, and `setClearColor`.
20
20
  - **Scheduler**: visibility/intersection-aware render loop that pauses when the canvas is off-screen or the tab is hidden.
21
21
 
22
22
  ## Minimal usage
23
23
 
24
24
  ```typescript
25
- import { fbm, colorRamp } from '@lovo/matter'
25
+ import { fractalNoise, colorRamp } from '@lovo/matter'
26
26
  import { uv, vec3, time } from 'three/tsl'
27
27
 
28
28
  // Inside your TSL fragment graph:
29
- const noise = fbm(uv().mul(4).add(time.mul(0.1)))
29
+ const noise = fractalNoise(uv().mul(4).add(time.mul(0.1)))
30
30
  const color = colorRamp(noise, [
31
31
  { stop: 0.0, color: vec3(0.05, 0.05, 0.1) },
32
32
  { stop: 1.0, color: vec3(0.3, 0.5, 0.95) },
package/dist/index.cjs CHANGED
@@ -29,15 +29,15 @@ __export(index_exports, {
29
29
  createVisibilityWatcher: () => createVisibilityWatcher,
30
30
  cursorRipple: () => cursorRipple,
31
31
  displace: () => displace,
32
- fbm: () => fbm,
32
+ elapsedTime: () => elapsedTime,
33
33
  filmGrain: () => filmGrain,
34
+ fractalNoise: () => fractalNoise,
34
35
  getReducedMotionPolicy: () => getReducedMotionPolicy,
35
36
  getReducedMotionTimeScale: () => getReducedMotionTimeScale,
36
- noise: () => noise,
37
37
  quantize: () => quantize,
38
- sdfCircle: () => sdfCircle,
39
38
  setReducedMotionPolicy: () => setReducedMotionPolicy,
40
- time: () => time,
39
+ signedDistanceFieldCircle: () => signedDistanceFieldCircle,
40
+ simplexNoise: () => simplexNoise,
41
41
  voronoi: () => voronoi
42
42
  });
43
43
  module.exports = __toCommonJS(index_exports);
@@ -63,10 +63,10 @@ async function createRenderer(canvas, opts = {}) {
63
63
  const resolvedClearColor = clearColor instanceof import_three.Color ? clearColor : new import_three.Color(clearColor);
64
64
  three.setClearColor(resolvedClearColor, clearAlpha);
65
65
  const resize = () => {
66
- const w = canvas.clientWidth;
67
- const h = canvas.clientHeight;
68
- if (canvas.width !== w * three.getPixelRatio() || canvas.height !== h * three.getPixelRatio()) {
69
- three.setSize(w, h, false);
66
+ const canvasWidth = canvas.clientWidth;
67
+ const canvasHeight = canvas.clientHeight;
68
+ if (canvas.width !== canvasWidth * three.getPixelRatio() || canvas.height !== canvasHeight * three.getPixelRatio()) {
69
+ three.setSize(canvasWidth, canvasHeight, false);
70
70
  }
71
71
  };
72
72
  resize();
@@ -100,16 +100,19 @@ var CursorInput = class {
100
100
  this.element = element;
101
101
  this.handleMouseMove = (e) => {
102
102
  if (!(e instanceof MouseEvent)) return;
103
- const me = e;
103
+ const mouseEvent = e;
104
104
  if (this.element) {
105
- const r = this.element.getBoundingClientRect();
106
- const w = r.width || 1;
107
- const h = r.height || 1;
108
- this.target = [(me.clientX - r.left) / w, (me.clientY - r.top) / h];
105
+ const elementRect = this.element.getBoundingClientRect();
106
+ const elementWidth = elementRect.width || 1;
107
+ const elementHeight = elementRect.height || 1;
108
+ this.target = [
109
+ (mouseEvent.clientX - elementRect.left) / elementWidth,
110
+ (mouseEvent.clientY - elementRect.top) / elementHeight
111
+ ];
109
112
  } else {
110
- const w = typeof window !== "undefined" && window.innerWidth || 1;
111
- const h = typeof window !== "undefined" && window.innerHeight || 1;
112
- this.target = [me.clientX / w, me.clientY / h];
113
+ const viewportWidth = typeof window !== "undefined" && window.innerWidth || 1;
114
+ const viewportHeight = typeof window !== "undefined" && window.innerHeight || 1;
115
+ this.target = [mouseEvent.clientX / viewportWidth, mouseEvent.clientY / viewportHeight];
113
116
  }
114
117
  this.targetDirty = true;
115
118
  };
@@ -120,9 +123,9 @@ var CursorInput = class {
120
123
  return this.value;
121
124
  }
122
125
  /** Subscribe to change events. Returns an unsubscribe function. */
123
- on(_event, cb) {
124
- this.listeners.add(cb);
125
- return () => this.listeners.delete(cb);
126
+ on(_eventType, changeListener) {
127
+ this.listeners.add(changeListener);
128
+ return () => this.listeners.delete(changeListener);
126
129
  }
127
130
  /**
128
131
  * Advance the smoothing one tick. Called by the host scheduler; not
@@ -151,8 +154,8 @@ var CursorInput = class {
151
154
  this.listeners.clear();
152
155
  }
153
156
  };
154
- var clamp01 = (n) => Math.max(0, Math.min(1, n));
155
- var lerp = (a, b, t) => a + (b - a) * t;
157
+ var clamp01 = (value) => Math.max(0, Math.min(1, value));
158
+ var lerp = (startValue, endValue, blendFactor) => startValue + (endValue - startValue) * blendFactor;
156
159
 
157
160
  // src/primitives/color-ramp/color-ramp.ts
158
161
  var import_tsl = require("three/tsl");
@@ -163,12 +166,12 @@ function colorRamp(t, stops) {
163
166
  if (stops.length === 1) return (0, import_tsl.mix)(first.color, first.color, 0);
164
167
  let result = (0, import_tsl.mix)(first.color, first.color, 0);
165
168
  for (let i = 1; i < stops.length; i += 1) {
166
- const prev = stops[i - 1];
169
+ const previousStop = stops[i - 1];
167
170
  const next = stops[i];
168
- if (prev === void 0 || next === void 0) continue;
169
- const span = next.position - prev.position;
170
- if (span <= 0) continue;
171
- const localT = (0, import_tsl2.clamp)((0, import_tsl2.div)((0, import_tsl2.sub)(t, prev.position), span), 0, 1);
171
+ if (previousStop === void 0 || next === void 0) continue;
172
+ const positionSpan = next.position - previousStop.position;
173
+ if (positionSpan <= 0) continue;
174
+ const localT = (0, import_tsl2.clamp)((0, import_tsl2.div)((0, import_tsl2.sub)(t, previousStop.position), positionSpan), 0, 1);
172
175
  result = (0, import_tsl.mix)(result, next.color, localT);
173
176
  }
174
177
  return result;
@@ -176,26 +179,26 @@ function colorRamp(t, stops) {
176
179
 
177
180
  // src/primitives/noise/noise.ts
178
181
  var import_tsl3 = require("three/tsl");
179
- function noise(p) {
182
+ function simplexNoise(p) {
180
183
  return (0, import_tsl3.mx_noise_float)(p);
181
184
  }
182
185
 
183
186
  // src/primitives/fbm/fbm.ts
184
187
  var import_tsl4 = require("three/tsl");
185
- function fbm(p, opts = {}) {
188
+ function fractalNoise(p, opts = {}) {
186
189
  const octaves = opts.octaves ?? 4;
187
190
  const lacunarity = opts.lacunarity ?? 2;
188
191
  const gain = opts.gain ?? 0.5;
189
- let sum = noise(p);
190
- let amp = 1;
191
- let freq = 1;
192
- let total = amp;
192
+ let sum = simplexNoise(p);
193
+ let amplitude = 1;
194
+ let frequency = 1;
195
+ let total = amplitude;
193
196
  for (let i = 1; i < octaves; i += 1) {
194
- freq *= lacunarity;
195
- amp *= gain;
196
- total += amp;
197
- const pAtFreq = (0, import_tsl4.add)((0, import_tsl4.mul)(p, freq), i * 100);
198
- const layer = noise(pAtFreq).mul(amp);
197
+ frequency *= lacunarity;
198
+ amplitude *= gain;
199
+ total += amplitude;
200
+ const pAtFreq = (0, import_tsl4.add)((0, import_tsl4.mul)(p, frequency), i * 100);
201
+ const layer = simplexNoise(pAtFreq).mul(amplitude);
199
202
  sum = sum.add(layer);
200
203
  }
201
204
  return sum.div(total);
@@ -212,13 +215,13 @@ function quantize(t, steps) {
212
215
  if (steps <= 1) {
213
216
  return t.mul(0);
214
217
  }
215
- const denom = steps - 1;
216
- return t.mul(denom).add(0.5).floor().div(denom);
218
+ const denominator = steps - 1;
219
+ return t.mul(denominator).add(0.5).floor().div(denominator);
217
220
  }
218
221
 
219
222
  // src/primitives/sdf-circle/sdf-circle.ts
220
223
  var import_tsl6 = require("three/tsl");
221
- function sdfCircle(p, radius) {
224
+ function signedDistanceFieldCircle(p, radius) {
222
225
  return (0, import_tsl6.length)(p).sub(radius);
223
226
  }
224
227
 
@@ -243,7 +246,7 @@ var state = {
243
246
  function setReducedMotionPolicy(policy) {
244
247
  if (state.policy === policy) return;
245
248
  state.policy = policy;
246
- for (const w of state.watchers) w.recompute();
249
+ for (const watcher of state.watchers) watcher.recompute();
247
250
  }
248
251
  function getReducedMotionPolicy() {
249
252
  return state.policy;
@@ -264,8 +267,8 @@ function createReducedMotionWatcher() {
264
267
  if (typeof matchMedia !== "function") {
265
268
  return {
266
269
  scale: () => computeScale(false),
267
- subscribe: (cb) => {
268
- void cb;
270
+ subscribe: (listener) => {
271
+ void listener;
269
272
  return () => {
270
273
  };
271
274
  },
@@ -274,33 +277,33 @@ function createReducedMotionWatcher() {
274
277
  }
275
278
  };
276
279
  }
277
- const mql = matchMedia("(prefers-reduced-motion: reduce)");
278
- const subs = /* @__PURE__ */ new Set();
279
- let last = computeScale(mql.matches);
280
+ const mediaQueryList = matchMedia("(prefers-reduced-motion: reduce)");
281
+ const subscriptions = /* @__PURE__ */ new Set();
282
+ let lastComputedScale = computeScale(mediaQueryList.matches);
280
283
  const onChange = () => {
281
- const next = computeScale(mql.matches);
282
- if (next !== last) {
283
- last = next;
284
- for (const cb of subs) cb(next);
284
+ const next = computeScale(mediaQueryList.matches);
285
+ if (next !== lastComputedScale) {
286
+ lastComputedScale = next;
287
+ for (const listener of subscriptions) listener(next);
285
288
  }
286
289
  };
287
- mql.addEventListener("change", onChange);
290
+ mediaQueryList.addEventListener("change", onChange);
288
291
  const watcher = {
289
- scale: () => last,
290
- subscribe(cb) {
291
- subs.add(cb);
292
- return () => subs.delete(cb);
292
+ scale: () => lastComputedScale,
293
+ subscribe(listener) {
294
+ subscriptions.add(listener);
295
+ return () => subscriptions.delete(listener);
293
296
  },
294
297
  recompute() {
295
- const next = computeScale(mql.matches);
296
- if (next !== last) {
297
- last = next;
298
- for (const cb of subs) cb(next);
298
+ const next = computeScale(mediaQueryList.matches);
299
+ if (next !== lastComputedScale) {
300
+ lastComputedScale = next;
301
+ for (const listener of subscriptions) listener(next);
299
302
  }
300
303
  },
301
304
  dispose() {
302
- mql.removeEventListener("change", onChange);
303
- subs.clear();
305
+ mediaQueryList.removeEventListener("change", onChange);
306
+ subscriptions.clear();
304
307
  state.watchers.delete(watcher);
305
308
  }
306
309
  };
@@ -322,7 +325,7 @@ function getReducedMotionTimeScale() {
322
325
  }
323
326
 
324
327
  // src/primitives/time/time.ts
325
- var time = import_tsl9.time.mul(getReducedMotionTimeScale());
328
+ var elapsedTime = import_tsl9.time.mul(getReducedMotionTimeScale());
326
329
 
327
330
  // src/primitives/cursor-ripple/cursor-ripple.ts
328
331
  function cursorRipple(p, center, opts = {}) {
@@ -331,19 +334,17 @@ function cursorRipple(p, center, opts = {}) {
331
334
  const speed = opts.speed ?? 6;
332
335
  const amplitude = opts.amplitude ?? 0.5;
333
336
  const d = (0, import_tsl10.length)((0, import_tsl10.sub)(p, center));
334
- const wave = (0, import_tsl10.sin)(d.mul(frequency).sub(time.mul(speed)));
337
+ const wave = (0, import_tsl10.sin)(d.mul(frequency).sub(elapsedTime.mul(speed)));
335
338
  const decay = (0, import_tsl10.smoothstep)(reach, 0, d);
336
339
  return wave.mul(amplitude).mul(decay);
337
340
  }
338
341
 
339
342
  // src/primitives/film-grain/film-grain.ts
340
343
  var import_tsl11 = require("three/tsl");
341
- function filmGrain(uvNode, intensity, timeOffset = 0) {
342
- const HASH_C1 = (0, import_tsl11.vec2)(2127.1, 81.17);
343
- const HASH_C2 = (0, import_tsl11.vec2)(1269.5, 283.37);
344
- const base = (0, import_tsl11.vec2)(uvNode.dot(HASH_C1).add(timeOffset), uvNode.dot(HASH_C2).add(timeOffset));
345
- const hash = (0, import_tsl11.fract)((0, import_tsl11.sin)(base).mul(43758.5453));
346
- return (0, import_tsl11.length)(hash).sub(0.765).mul(intensity);
344
+ function filmGrain(intensity, timeOffset = 0) {
345
+ const pixel = import_tsl11.screenCoordinate.xy.floor();
346
+ const seed = pixel.x.toUint().mul(1973).add(pixel.y.toUint().mul(9277)).add((0, import_tsl11.mul)(timeOffset, 26699).toUint());
347
+ return (0, import_tsl11.hash)(seed).sub(0.5).mul(intensity);
347
348
  }
348
349
 
349
350
  // src/runtime/visibility/visibility.ts
@@ -357,21 +358,21 @@ function createVisibilityWatcher() {
357
358
  }
358
359
  };
359
360
  }
360
- const subs = /* @__PURE__ */ new Set();
361
+ const subscriptions = /* @__PURE__ */ new Set();
361
362
  const onChange = () => {
362
- const v = document.visibilityState === "visible";
363
- for (const cb of subs) cb(v);
363
+ const isVisible = document.visibilityState === "visible";
364
+ for (const listener of subscriptions) listener(isVisible);
364
365
  };
365
366
  document.addEventListener("visibilitychange", onChange);
366
367
  return {
367
368
  isVisible: () => document.visibilityState === "visible",
368
- subscribe(cb) {
369
- subs.add(cb);
370
- return () => subs.delete(cb);
369
+ subscribe(listener) {
370
+ subscriptions.add(listener);
371
+ return () => subscriptions.delete(listener);
371
372
  },
372
373
  dispose() {
373
374
  document.removeEventListener("visibilitychange", onChange);
374
- subs.clear();
375
+ subscriptions.clear();
375
376
  }
376
377
  };
377
378
  }
@@ -387,27 +388,27 @@ function createIntersectionWatcher(canvas) {
387
388
  }
388
389
  };
389
390
  }
390
- const subs = /* @__PURE__ */ new Set();
391
+ const subscriptions = /* @__PURE__ */ new Set();
391
392
  let inView = true;
392
- const obs = new IntersectionObserver(
393
+ const observer = new IntersectionObserver(
393
394
  (entries) => {
394
- const next = entries.some((e) => e.isIntersecting);
395
+ const next = entries.some((entry) => entry.isIntersecting);
395
396
  if (next === inView) return;
396
397
  inView = next;
397
- for (const cb of subs) cb(inView);
398
+ for (const listener of subscriptions) listener(inView);
398
399
  },
399
400
  { threshold: 0 }
400
401
  );
401
- obs.observe(canvas);
402
+ observer.observe(canvas);
402
403
  return {
403
404
  isInView: () => inView,
404
- subscribe(cb) {
405
- subs.add(cb);
406
- return () => subs.delete(cb);
405
+ subscribe(listener) {
406
+ subscriptions.add(listener);
407
+ return () => subscriptions.delete(listener);
407
408
  },
408
409
  dispose() {
409
- obs.disconnect();
410
- subs.clear();
410
+ observer.disconnect();
411
+ subscriptions.clear();
411
412
  }
412
413
  };
413
414
  }
@@ -418,10 +419,19 @@ var FrameScheduler = class {
418
419
  rafId = null;
419
420
  running = false;
420
421
  paused = false;
421
- idle = false;
422
422
  flushPending = false;
423
423
  startedAt = 0;
424
424
  lastTickAt = 0;
425
+ // Reference-counted idle voting. The scheduler is idle only when at least
426
+ // one component has voted idle AND no component has voted animated. This
427
+ // prevents a static component (e.g. LinearGradient speed=0) from halting
428
+ // the loop while an animated overlay (e.g. FilmGrain) is still running.
429
+ idleVotes = 0;
430
+ animatedVotes = 0;
431
+ /** True when all participating components prefer idle and none need animation. */
432
+ get idle() {
433
+ return this.idleVotes > 0 && this.animatedVotes === 0;
434
+ }
425
435
  /** Activate the scheduler. The rAF loop starts on the first client added. */
426
436
  start() {
427
437
  this.running = true;
@@ -457,19 +467,39 @@ var FrameScheduler = class {
457
467
  this.clients.clear();
458
468
  }
459
469
  /**
460
- * Mark the scheduler idle. The next tick still fires (a final flush so
461
- * uniform changes that triggered the idle state are rendered), then the
462
- * rAF loop halts. Use `requestRender()` or `setIdle(false)` to wake.
470
+ * Cast a vote on whether the scheduler should be idle.
471
+ *
472
+ * `setIdle(true)` increments the idle-vote count; the returned cleanup
473
+ * decrements it. `setIdle(false)` increments the animated-vote count;
474
+ * its cleanup decrements that. The scheduler halts (after one flush tick)
475
+ * only when `idleVotes > 0 && animatedVotes === 0`.
476
+ *
477
+ * Callers are responsible for calling the returned cleanup on unmount.
478
+ * Use `requestRender()` or cast a `setIdle(false)` vote to wake the loop
479
+ * without permanently registering an animated preference.
463
480
  */
464
481
  setIdle(idle) {
465
- if (this.idle === idle) return;
466
- this.idle = idle;
467
482
  if (idle) {
468
- this.flushPending = true;
469
- this.maybeQueue();
483
+ const wasIdle = this.idle;
484
+ this.idleVotes += 1;
485
+ const nowIdle = this.idle;
486
+ if (!wasIdle && nowIdle) this.onBecameIdle();
487
+ return () => {
488
+ const prevIdle = this.idle;
489
+ this.idleVotes = Math.max(0, this.idleVotes - 1);
490
+ const afterIdle = this.idle;
491
+ if (prevIdle && !afterIdle) this.onBecameAnimated();
492
+ };
470
493
  } else {
471
- this.flushPending = false;
472
- this.maybeQueue();
494
+ const wasIdle = this.idle;
495
+ this.animatedVotes += 1;
496
+ if (wasIdle) this.onBecameAnimated();
497
+ return () => {
498
+ const prevIdle = this.idle;
499
+ this.animatedVotes = Math.max(0, this.animatedVotes - 1);
500
+ const nowIdle = this.idle;
501
+ if (!prevIdle && nowIdle) this.onBecameIdle();
502
+ };
473
503
  }
474
504
  }
475
505
  /** Force a single tick while idle. Useful for prop-change invalidation. */
@@ -478,6 +508,14 @@ var FrameScheduler = class {
478
508
  this.flushPending = true;
479
509
  this.maybeQueue();
480
510
  }
511
+ onBecameIdle() {
512
+ this.flushPending = true;
513
+ this.maybeQueue();
514
+ }
515
+ onBecameAnimated() {
516
+ this.flushPending = false;
517
+ this.maybeQueue();
518
+ }
481
519
  maybeQueue() {
482
520
  if (this.rafId !== null) return;
483
521
  if (!this.running) return;
@@ -520,15 +558,15 @@ var FrameScheduler = class {
520
558
  createVisibilityWatcher,
521
559
  cursorRipple,
522
560
  displace,
523
- fbm,
561
+ elapsedTime,
524
562
  filmGrain,
563
+ fractalNoise,
525
564
  getReducedMotionPolicy,
526
565
  getReducedMotionTimeScale,
527
- noise,
528
566
  quantize,
529
- sdfCircle,
530
567
  setReducedMotionPolicy,
531
- time,
568
+ signedDistanceFieldCircle,
569
+ simplexNoise,
532
570
  voronoi
533
571
  });
534
572
  //# sourceMappingURL=index.cjs.map