@lovo/matter-react 0.4.1 → 0.6.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/dist/index.js CHANGED
@@ -37,29 +37,29 @@ var baseStyle = {
37
37
  whiteSpace: "pre"
38
38
  };
39
39
  function ShaderMonitor({ anchor = "top-right" }) {
40
- const ctx = useContext(ShaderContext);
40
+ const shaderContext = useContext(ShaderContext);
41
41
  const [stats, setStats] = useState2({ fps: 0, ticks: 0, frames: 0 });
42
42
  const ticksRef = useRef(0);
43
43
  const fpsAccumRef = useRef({ frames: 0, lastSampleAt: 0, fps: 0 });
44
44
  useEffect2(() => {
45
- if (!ctx) return;
46
- const client = (tick) => {
45
+ if (!shaderContext) return;
46
+ const schedulerTickHandler = (tick) => {
47
47
  ticksRef.current += 1;
48
- const acc = fpsAccumRef.current;
49
- acc.frames += 1;
50
- if (acc.lastSampleAt === 0) acc.lastSampleAt = tick.now;
51
- const dt = tick.now - acc.lastSampleAt;
52
- if (dt >= 500) {
53
- acc.fps = Math.round(acc.frames * 1e3 / dt);
54
- acc.frames = 0;
55
- acc.lastSampleAt = tick.now;
48
+ const fpsAccumulator = fpsAccumRef.current;
49
+ fpsAccumulator.frames += 1;
50
+ if (fpsAccumulator.lastSampleAt === 0) fpsAccumulator.lastSampleAt = tick.now;
51
+ const deltaTimeSinceLastSample = tick.now - fpsAccumulator.lastSampleAt;
52
+ if (deltaTimeSinceLastSample >= 500) {
53
+ fpsAccumulator.fps = Math.round(fpsAccumulator.frames * 1e3 / deltaTimeSinceLastSample);
54
+ fpsAccumulator.frames = 0;
55
+ fpsAccumulator.lastSampleAt = tick.now;
56
56
  }
57
- setStats({ fps: acc.fps, ticks: ticksRef.current, frames: acc.frames });
57
+ setStats({ fps: fpsAccumulator.fps, ticks: ticksRef.current, frames: fpsAccumulator.frames });
58
58
  };
59
- ctx.scheduler.add(client);
60
- return () => ctx.scheduler.remove(client);
61
- }, [ctx]);
62
- if (!ctx) {
59
+ shaderContext.scheduler.add(schedulerTickHandler);
60
+ return () => shaderContext.scheduler.remove(schedulerTickHandler);
61
+ }, [shaderContext]);
62
+ if (!shaderContext) {
63
63
  return /* @__PURE__ */ jsx2("div", { "data-testid": "matter-monitor", style: { ...baseStyle, ...anchorStyle[anchor] }, children: "no scene" });
64
64
  }
65
65
  return /* @__PURE__ */ jsxs("div", { "data-testid": "matter-monitor", style: { ...baseStyle, ...anchorStyle[anchor] }, children: [
@@ -76,17 +76,50 @@ function ShaderMonitor({ anchor = "top-right" }) {
76
76
  }
77
77
 
78
78
  // src/components/shader-scene/shader-scene.tsx
79
+ import { useEffect as useEffect4, useRef as useRef2, useState as useState4 } from "react";
79
80
  import {
80
81
  createIntersectionWatcher,
81
82
  createRenderer,
82
83
  createVisibilityWatcher,
83
84
  FrameScheduler
84
85
  } from "@lovo/matter";
85
- import { useEffect as useEffect3, useRef as useRef2, useState as useState3 } from "react";
86
86
  import { OrthographicCamera, Scene } from "three";
87
- import { pass } from "three/tsl";
87
+ import { pass, vec4 } from "three/tsl";
88
88
  import { PostProcessing } from "three/webgpu";
89
- import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
89
+
90
+ // src/hooks/use-display-gamut/use-display-gamut.ts
91
+ import { useEffect as useEffect3, useState as useState3 } from "react";
92
+ var P3_QUERY = "(color-gamut: p3)";
93
+ function detectGamut() {
94
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
95
+ return "srgb";
96
+ }
97
+ return window.matchMedia(P3_QUERY).matches ? "p3" : "srgb";
98
+ }
99
+ function useDisplayGamut(preference) {
100
+ const [resolved, setResolved] = useState3(
101
+ () => preference === "auto" ? detectGamut() : preference
102
+ );
103
+ useEffect3(() => {
104
+ if (preference !== "auto") {
105
+ setResolved(preference);
106
+ return;
107
+ }
108
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
109
+ setResolved("srgb");
110
+ return;
111
+ }
112
+ const mediaQuery = window.matchMedia(P3_QUERY);
113
+ const update = () => setResolved(mediaQuery.matches ? "p3" : "srgb");
114
+ update();
115
+ mediaQuery.addEventListener("change", update);
116
+ return () => mediaQuery.removeEventListener("change", update);
117
+ }, [preference]);
118
+ return resolved;
119
+ }
120
+
121
+ // src/components/shader-scene/shader-scene.tsx
122
+ import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
90
123
  var defaultStyle = {
91
124
  position: "absolute",
92
125
  inset: 0,
@@ -95,18 +128,21 @@ var defaultStyle = {
95
128
  height: "100%"
96
129
  };
97
130
  function ShaderScene(props) {
98
- const { children, fallback, className, style, maxDPR } = props;
131
+ const { children, fallback, className, style, maxDPR, gamut = "auto" } = props;
132
+ const resolvedGamut = useDisplayGamut(gamut);
99
133
  const canvasRef = useRef2(null);
100
- const [ctx, setCtx] = useState3(null);
101
- const [error, setError] = useState3(null);
102
- useEffect3(() => {
134
+ const [shaderContext, setShaderContext] = useState4(null);
135
+ const [error, setError] = useState4(null);
136
+ const [firstFramePainted, setFirstFramePainted] = useState4(false);
137
+ useEffect4(() => {
103
138
  const canvas = canvasRef.current;
104
139
  if (!canvas) return;
105
140
  let cancelled = false;
106
141
  let cleanup = null;
142
+ let firstPaintRaf = null;
107
143
  const setup = async () => {
108
144
  try {
109
- const renderer = await createRenderer(canvas, { maxDPR });
145
+ const renderer = await createRenderer(canvas, { maxDPR, gamut: resolvedGamut });
110
146
  if (cancelled) {
111
147
  renderer.dispose();
112
148
  return;
@@ -117,12 +153,11 @@ function ShaderScene(props) {
117
153
  const postProcessing = new PostProcessing(renderer.three);
118
154
  const scheduler = new FrameScheduler();
119
155
  const overlays = /* @__PURE__ */ new Map();
120
- const basePass = pass(scene, camera);
156
+ const basePassNode = vec4(pass(scene, camera));
121
157
  const rebuildOutputNode = () => {
122
- const seed = basePass;
123
158
  postProcessing.outputNode = Array.from(overlays.values()).reduce(
124
- (node, transform) => transform(node),
125
- seed
159
+ (currentPipeline, transform) => transform(currentPipeline),
160
+ basePassNode
126
161
  );
127
162
  postProcessing.needsUpdate = true;
128
163
  };
@@ -136,7 +171,18 @@ function ShaderScene(props) {
136
171
  rebuildOutputNode();
137
172
  };
138
173
  };
139
- scheduler.add(() => postProcessing.render());
174
+ let firstPaintSignaled = false;
175
+ const renderFrame = () => {
176
+ postProcessing.render();
177
+ if (!firstPaintSignaled && (scene.children.length > 0 || overlays.size > 0)) {
178
+ firstPaintSignaled = true;
179
+ firstPaintRaf = requestAnimationFrame(() => {
180
+ firstPaintRaf = null;
181
+ if (!cancelled) setFirstFramePainted(true);
182
+ });
183
+ }
184
+ };
185
+ scheduler.add(renderFrame);
140
186
  scheduler.start();
141
187
  const visibility = createVisibilityWatcher();
142
188
  const intersection = createIntersectionWatcher(canvas);
@@ -148,33 +194,38 @@ function ShaderScene(props) {
148
194
  updatePauseState();
149
195
  const unsubVisibility = visibility.subscribe(updatePauseState);
150
196
  const unsubIntersection = intersection.subscribe(updatePauseState);
151
- const onResize = () => renderer.resize();
152
- window.addEventListener("resize", onResize);
197
+ const resizeObserver = new ResizeObserver(() => renderer.resize());
198
+ resizeObserver.observe(canvas);
153
199
  cleanup = () => {
154
200
  unsubVisibility();
155
201
  unsubIntersection();
156
202
  visibility.dispose();
157
203
  intersection.dispose();
158
- window.removeEventListener("resize", onResize);
204
+ resizeObserver.disconnect();
159
205
  scheduler.dispose();
160
206
  renderer.dispose();
161
207
  };
162
- setCtx({ renderer, scene, camera, scheduler, registerOverlay });
163
- } catch (err) {
208
+ setShaderContext({ renderer, scene, camera, scheduler, registerOverlay });
209
+ } catch (caughtError) {
164
210
  if (cancelled) return;
165
- const e = err instanceof Error ? err : new Error(String(err));
166
- console.error("[ShaderScene] renderer init failed:", e);
167
- setError(e);
211
+ const normalizedError = caughtError instanceof Error ? caughtError : new Error(String(caughtError));
212
+ console.error("[ShaderScene] renderer init failed:", normalizedError);
213
+ setError(normalizedError);
168
214
  }
169
215
  };
170
216
  void setup();
171
217
  return () => {
172
218
  cancelled = true;
219
+ if (firstPaintRaf !== null) {
220
+ cancelAnimationFrame(firstPaintRaf);
221
+ firstPaintRaf = null;
222
+ }
173
223
  cleanup?.();
174
224
  cleanup = null;
175
- setCtx(null);
225
+ setShaderContext(null);
226
+ setFirstFramePainted(false);
176
227
  };
177
- }, [maxDPR]);
228
+ }, [maxDPR, resolvedGamut]);
178
229
  let content;
179
230
  if (error) {
180
231
  content = /* @__PURE__ */ jsxs2(
@@ -200,10 +251,11 @@ function ShaderScene(props) {
200
251
  ]
201
252
  }
202
253
  );
203
- } else if (ctx) {
204
- content = /* @__PURE__ */ jsx3(ShaderContext.Provider, { value: ctx, children });
205
254
  } else {
206
- content = fallback ?? null;
255
+ content = /* @__PURE__ */ jsxs2(Fragment2, { children: [
256
+ shaderContext && /* @__PURE__ */ jsx3(ShaderContext.Provider, { value: shaderContext, children }),
257
+ !firstFramePainted && (fallback ?? null)
258
+ ] });
207
259
  }
208
260
  return /* @__PURE__ */ jsxs2("div", { className, style: { ...defaultStyle, ...style }, children: [
209
261
  /* @__PURE__ */ jsx3("canvas", { ref: canvasRef, style: { width: "100%", height: "100%", display: "block" } }),
@@ -212,7 +264,7 @@ function ShaderScene(props) {
212
264
  }
213
265
 
214
266
  // src/hooks/use-animatable-uniform/use-animatable-uniform.ts
215
- import { useEffect as useEffect4, useMemo } from "react";
267
+ import { useEffect as useEffect5, useMemo } from "react";
216
268
  import { uniform } from "three/tsl";
217
269
  var isSignal = (value) => {
218
270
  if (typeof value !== "object" || value === null) return false;
@@ -223,7 +275,7 @@ function useAnimatableUniform(value) {
223
275
  const initial = isSignal(value) ? value.get() : value;
224
276
  return uniform(initial);
225
277
  }, []);
226
- useEffect4(() => {
278
+ useEffect5(() => {
227
279
  if (isSignal(value)) {
228
280
  const unsub = value.on("change", (next) => {
229
281
  uniformNode.value = next;
@@ -237,8 +289,8 @@ function useAnimatableUniform(value) {
237
289
  }
238
290
 
239
291
  // src/hooks/use-cursor/use-cursor.ts
292
+ import { useEffect as useEffect6, useState as useState5 } from "react";
240
293
  import { CursorInput } from "@lovo/matter";
241
- import { useEffect as useEffect5, useState as useState4 } from "react";
242
294
 
243
295
  // src/hooks/use-shader-context/use-shader-context.ts
244
296
  import { useContext as useContext2 } from "react";
@@ -252,81 +304,91 @@ var STUB_SIGNAL = {
252
304
  on: () => () => void 0
253
305
  };
254
306
  function useCursor(opts = {}) {
255
- const ctx = useShaderContext();
256
- const [input, setInput] = useState4(null);
257
- useEffect5(() => {
258
- const canvas = ctx?.renderer.three.domElement;
259
- const elementOpt = opts.element ?? (canvas instanceof HTMLElement ? canvas : void 0);
260
- const fresh = new CursorInput({ ...opts, element: elementOpt });
261
- setInput(fresh);
307
+ const shaderContext = useShaderContext();
308
+ const [input, setInput] = useState5(null);
309
+ useEffect6(() => {
310
+ const canvas = shaderContext?.renderer.three.domElement;
311
+ const resolvedElement = opts.element ?? (canvas instanceof HTMLElement ? canvas : void 0);
312
+ const newCursorInput = new CursorInput({ ...opts, element: resolvedElement });
313
+ setInput(newCursorInput);
262
314
  let detach = null;
263
- if (ctx?.scheduler) {
264
- const client = ({ delta }) => fresh.tick(delta);
265
- ctx.scheduler.add(client);
266
- detach = () => ctx.scheduler.remove(client);
315
+ if (shaderContext?.scheduler) {
316
+ const schedulerTickHandler = ({ delta }) => newCursorInput.tick(delta);
317
+ shaderContext.scheduler.add(schedulerTickHandler);
318
+ detach = () => shaderContext.scheduler.remove(schedulerTickHandler);
267
319
  } else {
268
- let raf = null;
320
+ let animationFrameId = null;
269
321
  let lastNow = performance.now();
270
322
  const loop = (now) => {
271
323
  const delta = (now - lastNow) / 1e3;
272
324
  lastNow = now;
273
- fresh.tick(delta);
274
- raf = requestAnimationFrame(loop);
325
+ newCursorInput.tick(delta);
326
+ animationFrameId = requestAnimationFrame(loop);
275
327
  };
276
- raf = requestAnimationFrame(loop);
328
+ animationFrameId = requestAnimationFrame(loop);
277
329
  detach = () => {
278
- if (raf !== null) cancelAnimationFrame(raf);
330
+ if (animationFrameId !== null) cancelAnimationFrame(animationFrameId);
279
331
  };
280
332
  }
281
333
  return () => {
282
334
  detach();
283
- fresh.dispose();
335
+ newCursorInput.dispose();
284
336
  setInput(null);
285
337
  };
286
- }, [ctx]);
338
+ }, [shaderContext]);
287
339
  return input ?? STUB_SIGNAL;
288
340
  }
289
341
 
290
342
  // src/hooks/use-overlay-pass/use-overlay-pass.ts
291
- import { useEffect as useEffect6 } from "react";
292
- function useOverlayPass(transform, deps) {
293
- const ctx = useShaderContext();
294
- useEffect6(() => {
295
- if (!ctx) return;
296
- const unregister = ctx.registerOverlay(transform);
343
+ import { useEffect as useEffect7 } from "react";
344
+ function usePostProcessPass(transform, deps) {
345
+ const shaderContext = useShaderContext();
346
+ useEffect7(() => {
347
+ if (!shaderContext) return;
348
+ const unregister = shaderContext.registerOverlay(transform);
297
349
  return unregister;
298
- }, [ctx, ...deps]);
350
+ }, [shaderContext, ...deps]);
351
+ }
352
+
353
+ // src/hooks/use-resize/use-resize.ts
354
+ import { useEffect as useEffect8, useState as useState6 } from "react";
355
+
356
+ // src/internal/create-signal.ts
357
+ function createSignal(getValue) {
358
+ const listeners = /* @__PURE__ */ new Set();
359
+ return {
360
+ listeners,
361
+ signal: {
362
+ get: getValue,
363
+ on: (_event, listener) => {
364
+ listeners.add(listener);
365
+ return () => {
366
+ listeners.delete(listener);
367
+ };
368
+ }
369
+ }
370
+ };
299
371
  }
300
372
 
301
373
  // src/hooks/use-resize/use-resize.ts
302
- import { useEffect as useEffect7, useState as useState5 } from "react";
303
374
  var STUB_SIGNAL2 = {
304
375
  get: () => [0, 0, 1],
305
376
  on: () => () => void 0
306
377
  };
307
378
  function useResize() {
308
- const ctx = useShaderContext();
309
- const [signal, setSignal] = useState5(null);
310
- useEffect7(() => {
311
- if (!ctx) return void 0;
312
- const canvas = ctx.renderer.three.domElement;
379
+ const shaderContext = useShaderContext();
380
+ const [signal, setSignal] = useState6(null);
381
+ useEffect8(() => {
382
+ if (!shaderContext) return void 0;
383
+ const canvas = shaderContext.renderer.three.domElement;
313
384
  if (!(canvas instanceof HTMLCanvasElement)) return void 0;
314
385
  let value = [
315
386
  canvas.clientWidth,
316
387
  canvas.clientHeight,
317
388
  typeof window !== "undefined" ? window.devicePixelRatio : 1
318
389
  ];
319
- const listeners = /* @__PURE__ */ new Set();
320
- const fresh = {
321
- get: () => value,
322
- on: (_event, cb) => {
323
- listeners.add(cb);
324
- return () => {
325
- listeners.delete(cb);
326
- };
327
- }
328
- };
329
- setSignal(fresh);
390
+ const { signal: newSignal, listeners } = createSignal(() => value);
391
+ setSignal(newSignal);
330
392
  const emit = () => {
331
393
  const next = [
332
394
  canvas.clientWidth,
@@ -335,66 +397,59 @@ function useResize() {
335
397
  ];
336
398
  if (next[0] === value[0] && next[1] === value[1] && next[2] === value[2]) return;
337
399
  value = next;
338
- for (const cb of listeners) cb(next);
400
+ for (const listener of listeners) listener(next);
339
401
  };
340
402
  const observer = new ResizeObserver(emit);
341
403
  observer.observe(canvas);
342
- let mql = null;
343
- let mqlHandler = null;
404
+ let mediaQueryList = null;
405
+ let mediaQueryListener = null;
344
406
  const setupDprWatch = () => {
345
407
  if (typeof window === "undefined") return;
346
408
  const dpr = window.devicePixelRatio;
347
- const next = window.matchMedia(`(resolution: ${dpr}dppx)`);
348
- const handler = () => {
409
+ const nextMediaQueryList = window.matchMedia(`(resolution: ${dpr}dppx)`);
410
+ const nextMediaQueryListener = () => {
349
411
  emit();
350
- if (mql && mqlHandler) mql.removeEventListener("change", mqlHandler);
412
+ if (mediaQueryList && mediaQueryListener)
413
+ mediaQueryList.removeEventListener("change", mediaQueryListener);
351
414
  setupDprWatch();
352
415
  };
353
- next.addEventListener("change", handler);
354
- mql = next;
355
- mqlHandler = handler;
416
+ nextMediaQueryList.addEventListener("change", nextMediaQueryListener);
417
+ mediaQueryList = nextMediaQueryList;
418
+ mediaQueryListener = nextMediaQueryListener;
356
419
  };
357
420
  setupDprWatch();
358
421
  return () => {
359
422
  observer.disconnect();
360
- if (mql && mqlHandler) mql.removeEventListener("change", mqlHandler);
361
- mql = null;
362
- mqlHandler = null;
423
+ if (mediaQueryList && mediaQueryListener)
424
+ mediaQueryList.removeEventListener("change", mediaQueryListener);
425
+ mediaQueryList = null;
426
+ mediaQueryListener = null;
363
427
  listeners.clear();
364
428
  setSignal(null);
365
429
  };
366
- }, [ctx]);
430
+ }, [shaderContext]);
367
431
  return signal ?? STUB_SIGNAL2;
368
432
  }
369
433
 
370
434
  // src/hooks/use-scroll/use-scroll.ts
371
- import { useEffect as useEffect8, useState as useState6 } from "react";
435
+ import { useEffect as useEffect9, useState as useState7 } from "react";
372
436
  var STUB_SIGNAL3 = {
373
437
  get: () => [0, 0],
374
438
  on: () => () => void 0
375
439
  };
376
440
  function useScroll() {
377
- const [signal, setSignal] = useState6(null);
378
- useEffect8(() => {
441
+ const [signal, setSignal] = useState7(null);
442
+ useEffect9(() => {
379
443
  if (typeof window === "undefined") return void 0;
380
444
  const compute = () => {
381
- const y = window.scrollY;
445
+ const scrollYPosition = window.scrollY;
382
446
  const max = Math.max(document.documentElement.scrollHeight - window.innerHeight, 1);
383
- const progress = Math.max(0, Math.min(1, y / max));
384
- return [y, progress];
447
+ const progress = Math.max(0, Math.min(1, scrollYPosition / max));
448
+ return [scrollYPosition, progress];
385
449
  };
386
450
  let value = compute();
387
- const listeners = /* @__PURE__ */ new Set();
388
- const fresh = {
389
- get: () => value,
390
- on: (_event, cb) => {
391
- listeners.add(cb);
392
- return () => {
393
- listeners.delete(cb);
394
- };
395
- }
396
- };
397
- setSignal(fresh);
451
+ const { signal: newSignal, listeners } = createSignal(() => value);
452
+ setSignal(newSignal);
398
453
  let rafPending = false;
399
454
  const onScroll = () => {
400
455
  if (rafPending) return;
@@ -404,7 +459,7 @@ function useScroll() {
404
459
  const next = compute();
405
460
  if (next[0] === value[0] && next[1] === value[1]) return;
406
461
  value = next;
407
- for (const cb of listeners) cb(next);
462
+ for (const listener of listeners) listener(next);
408
463
  });
409
464
  };
410
465
  window.addEventListener("scroll", onScroll, { passive: true });
@@ -418,29 +473,28 @@ function useScroll() {
418
473
  }
419
474
 
420
475
  // src/hooks/use-shader-material/use-shader-material.ts
421
- import { useEffect as useEffect9, useMemo as useMemo2 } from "react";
476
+ import { useEffect as useEffect10, useMemo as useMemo2 } from "react";
422
477
  import { MeshBasicNodeMaterial } from "three/webgpu";
423
478
  function useShaderMaterial(build) {
424
479
  const material = useMemo2(() => {
425
- const m = new MeshBasicNodeMaterial();
426
- m.colorNode = build();
427
- return m;
480
+ const nodeMaterial = new MeshBasicNodeMaterial();
481
+ nodeMaterial.colorNode = build();
482
+ return nodeMaterial;
428
483
  }, [build]);
429
- useEffect9(() => {
484
+ useEffect10(() => {
430
485
  return () => material.dispose();
431
486
  }, [material]);
432
487
  return material;
433
488
  }
434
489
 
435
490
  // src/hooks/use-static-hint/use-static-hint.ts
436
- import { useEffect as useEffect10 } from "react";
437
- function useStaticHint(hint) {
438
- const ctx = useShaderContext();
439
- useEffect10(() => {
440
- if (!ctx) return;
441
- ctx.scheduler.setIdle(hint);
442
- return () => ctx.scheduler.setIdle(false);
443
- }, [ctx, hint]);
491
+ import { useEffect as useEffect11 } from "react";
492
+ function useStaticSceneHint(isStatic) {
493
+ const shaderContext = useShaderContext();
494
+ useEffect11(() => {
495
+ if (!shaderContext) return;
496
+ return shaderContext.scheduler.setIdle(isStatic);
497
+ }, [shaderContext, isStatic]);
444
498
  }
445
499
  export {
446
500
  FallbackBoundary,
@@ -448,11 +502,12 @@ export {
448
502
  ShaderScene,
449
503
  useAnimatableUniform,
450
504
  useCursor,
451
- useOverlayPass,
505
+ useDisplayGamut,
506
+ usePostProcessPass,
452
507
  useResize,
453
508
  useScroll,
454
509
  useShaderContext,
455
510
  useShaderMaterial,
456
- useStaticHint
511
+ useStaticSceneHint
457
512
  };
458
513
  //# sourceMappingURL=index.js.map