@wave3d/core 0.7.0 → 0.9.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.
Files changed (75) hide show
  1. package/README.md +3 -1
  2. package/dist/config/model.d.ts +78 -4
  3. package/dist/config/model.js +12 -2
  4. package/dist/config/model.js.map +1 -1
  5. package/dist/index.d.ts +3 -2
  6. package/dist/presets.js +35 -0
  7. package/dist/presets.js.map +1 -1
  8. package/dist/renderer/WaveRenderer.d.ts +146 -12
  9. package/dist/renderer/WaveRenderer.js +218 -56
  10. package/dist/renderer/WaveRenderer.js.map +1 -1
  11. package/dist/renderer/WaveRendererGPU.js +291 -0
  12. package/dist/renderer/WaveRendererGPU.js.map +1 -0
  13. package/dist/renderer/gpu-loader.js +2 -0
  14. package/dist/renderer/index.d.ts +3 -2
  15. package/dist/renderer/interaction.d.ts +29 -0
  16. package/dist/renderer/interaction.js +77 -31
  17. package/dist/renderer/interaction.js.map +1 -1
  18. package/dist/renderer/interactionGates.js +59 -0
  19. package/dist/renderer/interactionGates.js.map +1 -0
  20. package/dist/renderer/particleField.d.ts +1 -28
  21. package/dist/renderer/particleField.js +173 -16
  22. package/dist/renderer/particleField.js.map +1 -1
  23. package/dist/renderer/particleFieldGPU.js +141 -0
  24. package/dist/renderer/particleFieldGPU.js.map +1 -0
  25. package/dist/renderer/shaders.js +178 -86
  26. package/dist/renderer/shaders.js.map +1 -1
  27. package/dist/renderer/tilt.d.ts +17 -0
  28. package/dist/renderer/tilt.js +124 -0
  29. package/dist/renderer/tilt.js.map +1 -0
  30. package/dist/renderer/tsl/color.js +129 -0
  31. package/dist/renderer/tsl/color.js.map +1 -0
  32. package/dist/renderer/tsl/noise.js +83 -0
  33. package/dist/renderer/tsl/noise.js.map +1 -0
  34. package/dist/renderer/tsl/packedArray.js +76 -0
  35. package/dist/renderer/tsl/packedArray.js.map +1 -0
  36. package/dist/renderer/tsl/particleMaterial.js +133 -0
  37. package/dist/renderer/tsl/particleMaterial.js.map +1 -0
  38. package/dist/renderer/tsl/particleUniforms.js +44 -0
  39. package/dist/renderer/tsl/particleUniforms.js.map +1 -0
  40. package/dist/renderer/tsl/pointerField.js +73 -0
  41. package/dist/renderer/tsl/pointerField.js.map +1 -0
  42. package/dist/renderer/tsl/post.js +63 -0
  43. package/dist/renderer/tsl/post.js.map +1 -0
  44. package/dist/renderer/tsl/postChain.js +56 -0
  45. package/dist/renderer/tsl/postChain.js.map +1 -0
  46. package/dist/renderer/tsl/postEffects.js +221 -0
  47. package/dist/renderer/tsl/postEffects.js.map +1 -0
  48. package/dist/renderer/tsl/types.js +28 -0
  49. package/dist/renderer/tsl/types.js.map +1 -0
  50. package/dist/renderer/tsl/uniforms.js +152 -0
  51. package/dist/renderer/tsl/uniforms.js.map +1 -0
  52. package/dist/renderer/tsl/waveMaterial.js +185 -0
  53. package/dist/renderer/tsl/waveMaterial.js.map +1 -0
  54. package/dist/renderer/tsl/waveShape.js +108 -0
  55. package/dist/renderer/tsl/waveShape.js.map +1 -0
  56. package/dist/shell/createWave.d.ts +29 -0
  57. package/dist/shell/createWave.js +39 -11
  58. package/dist/shell/createWave.js.map +1 -1
  59. package/dist/shell/probe.js +18 -1
  60. package/dist/shell/probe.js.map +1 -1
  61. package/dist/standalone/wave3d.standalone.js +2219 -1929
  62. package/dist/standalone/wave3d.standalone.webgpu.js +36647 -0
  63. package/dist/standalone.d.ts +10 -3
  64. package/dist/standalone.js +10 -3
  65. package/dist/standalone.js.map +1 -1
  66. package/dist/studio/StudioWaveRenderer.d.ts +4 -0
  67. package/dist/studio/StudioWaveRenderer.js +9 -0
  68. package/dist/studio/StudioWaveRenderer.js.map +1 -1
  69. package/dist/studio/StudioWaveRendererGPU.js +18 -0
  70. package/dist/studio/StudioWaveRendererGPU.js.map +1 -0
  71. package/dist/studio/index.d.ts +12 -2
  72. package/dist/studio/index.js +14 -1
  73. package/dist/studio/index.js.map +1 -0
  74. package/package.json +12 -3
  75. package/skills/wave3d/SKILL.md +29 -2
@@ -1,8 +1,8 @@
1
1
  import { ensureStudioConfig } from "../config/model.js";
2
2
  import { WaveGeometry } from "./WaveGeometry.js";
3
3
  import { ditherFragmentShader, fragmentShader, halftoneCmykFragmentShader, halftoneFragmentShader, heatmapFragmentShader, innerLightFragmentShader, lineFragmentShader, paperTextureFragmentShader, postFragmentShader, postVertexShader, vertexShader } from "./shaders.js";
4
+ import { anyPointerFxActive, interactionActive, wavePointerFxActive, waveRipplesActive } from "./interactionGates.js";
4
5
  import { ParticleField } from "./particleField.js";
5
- import { InteractionController, SCENE_APPLIERS, WAVE_APPLIERS, anyPointerFxActive, interactionActive, wavePointerFxActive, waveRipplesActive } from "./interaction.js";
6
6
  import { PALETTE_MAPS, buildBackgroundGradientCanvas, buildBackgroundImageCanvas, buildBackgroundMeshCanvas, buildPaletteTexture, canvasToTexture, configurePaletteTexture, drawBackgroundMediaFrame, loadPaletteImage, paletteMapCanvas, paletteSignature } from "./palette.js";
7
7
  import { buildHeroPaletteCanvas, buildHeroPaletteTexture } from "./heroPalette.js";
8
8
  import * as THREE from "three";
@@ -174,6 +174,12 @@ function hexToLinearVec3(hex, target) {
174
174
  */
175
175
  var WaveRenderer = class {
176
176
  renderer;
177
+ /**
178
+ * Whether this renderer can draw yet. The WebGL backend is ready the moment it is constructed;
179
+ * the WebGPU subclass has to `await renderer.init()` first, and `renderOnce()` throws before that.
180
+ * The base class is always ready, so the WebGL path is unaffected.
181
+ */
182
+ ready = true;
177
183
  scene = new THREE.Scene();
178
184
  camera;
179
185
  group = new THREE.Group();
@@ -211,8 +217,17 @@ var WaveRenderer = class {
211
217
  clipSphere = new THREE.Sphere();
212
218
  clipTmpA = new THREE.Vector3();
213
219
  clipTmpB = new THREE.Vector3();
214
- /** Created by syncInteraction() when interaction turns on, disposed when it turns off. */
220
+ /** Created by loadInteraction() once the runtime chunk lands, disposed when interaction turns off.
221
+ * Stays undefined for the frames between the config turning it on and the chunk arriving. */
215
222
  interaction;
223
+ /** The chunk itself, kept for the applier tables. Set and cleared with `interaction`. */
224
+ interactionModule;
225
+ /** A fetch already in flight, so a per-frame refresh can't start a second one. */
226
+ interactionLoading = false;
227
+ /** setInteractionInput() calls made while the chunk was in flight, replayed once it lands. */
228
+ stagedInputs;
229
+ /** Set by dispose(), so an in-flight chunk can't attach listeners to a dead renderer. */
230
+ disposed = false;
216
231
  /** Extra ortho-zoom MULTIPLIER from a cameraZoom binding (1 = none); applied in applyZoom().
217
232
  * Protected so the studio's writeCameraToConfig() can divide it back out (keep it out of config). */
218
233
  interactionZoom = 1;
@@ -250,12 +265,7 @@ var WaveRenderer = class {
250
265
  this.config = ensureStudioConfig(config);
251
266
  this.respectReducedMotion = options.respectReducedMotion ?? true;
252
267
  this.skipIntroRamp = options.skipIntroRamp ?? false;
253
- this.renderer = new THREE.WebGLRenderer({
254
- antialias: true,
255
- alpha: true,
256
- preserveDrawingBuffer: true,
257
- powerPreference: "high-performance"
258
- });
268
+ this.renderer = this.createRenderer();
259
269
  this.renderer.outputColorSpace = THREE.SRGBColorSpace;
260
270
  this.renderer.setClearColor(0, 0);
261
271
  container.appendChild(this.renderer.domElement);
@@ -301,6 +311,24 @@ var WaveRenderer = class {
301
311
  this.buildWaves();
302
312
  this.resize();
303
313
  }
314
+ /**
315
+ * Build the backing three renderer. Overridden by the WebGPU subclass; called from the
316
+ * constructor, so it must not depend on subclass FIELDS (prototype methods are available during
317
+ * `super()`, field initialisers are not).
318
+ */
319
+ createRenderer() {
320
+ return new THREE.WebGLRenderer({
321
+ antialias: true,
322
+ alpha: true,
323
+ preserveDrawingBuffer: true,
324
+ powerPreference: "high-performance"
325
+ });
326
+ }
327
+ /**
328
+ * Await any asynchronous backend startup. A no-op on WebGL; the WebGPU subclass overrides it to
329
+ * `await renderer.init()` and then draw the first frame. Safe to call more than once.
330
+ */
331
+ async init() {}
304
332
  get segments() {
305
333
  const q = this.config.quality / Math.sqrt(Math.max(1, this.config.waves.length));
306
334
  return THREE.MathUtils.clamp(Math.round(BASE_SEGMENTS * q), 24, 360);
@@ -464,16 +492,7 @@ var WaveRenderer = class {
464
492
  addWave() {
465
493
  const geo = new WaveGeometry(this.segments);
466
494
  const sc = this.config.waves[this.waves.length] ?? this.config.waves[0];
467
- const material = new THREE.ShaderMaterial({
468
- uniforms: this.makeUniforms(),
469
- defines: this.waveDefines(sc),
470
- vertexShader,
471
- fragmentShader: sc?.theme === "wireframe" ? lineFragmentShader : fragmentShader,
472
- transparent: true,
473
- depthTest: true,
474
- depthWrite: true,
475
- side: THREE.DoubleSide
476
- });
495
+ const material = this.createWaveMaterial(sc);
477
496
  this.applyBlendMode(material, sc?.blendMode ?? "squared");
478
497
  const mesh = new THREE.Mesh(geo.geometry, material);
479
498
  mesh.frustumCulled = false;
@@ -491,6 +510,45 @@ var WaveRenderer = class {
491
510
  });
492
511
  }
493
512
  /**
513
+ * Build the material for one wave. The GLSL backend compiles a ShaderMaterial with `#define`
514
+ * variants; the WebGPU subclass builds a TSL node graph instead. Both expose the same `uniforms`
515
+ * surface, so everything downstream in `refresh()` is shared.
516
+ */
517
+ createWaveMaterial(sc) {
518
+ return new THREE.ShaderMaterial({
519
+ uniforms: this.makeUniforms(),
520
+ defines: this.waveDefines(sc),
521
+ vertexShader,
522
+ fragmentShader: sc?.theme === "wireframe" ? lineFragmentShader : fragmentShader,
523
+ transparent: true,
524
+ depthTest: true,
525
+ depthWrite: true,
526
+ side: THREE.DoubleSide
527
+ });
528
+ }
529
+ /**
530
+ * Re-select this wave's shader variant when its config changes shape (theme, twist motion, helix,
531
+ * …). Returns true if the material needs recompiling. The GLSL backend swaps `defines` and the
532
+ * fragment source; the WebGPU subclass rebuilds the node graph, since a TSL variant is a
533
+ * different graph rather than a different define set.
534
+ */
535
+ applyWaveVariant(wave, sc) {
536
+ const material = wave.material;
537
+ let changed = false;
538
+ const wantDefines = this.waveDefines(sc);
539
+ const curDefines = material.defines ?? {};
540
+ if (Object.keys(wantDefines).sort().join(",") !== Object.keys(curDefines).sort().join(",")) {
541
+ material.defines = wantDefines;
542
+ changed = true;
543
+ }
544
+ const wantFrag = sc.theme === "wireframe" ? lineFragmentShader : fragmentShader;
545
+ if (material.fragmentShader !== wantFrag) {
546
+ material.fragmentShader = wantFrag;
547
+ changed = true;
548
+ }
549
+ return changed;
550
+ }
551
+ /**
494
552
  * Apply config.blendMode to a material. "squared" (the default) is the hero blend:
495
553
  * CustomBlending with AddEquation, src = SrcColorFactor, dst = ZeroFactor, so the
496
554
  * framebuffer result is fragColor² — the squaring deepens the colours into the vivid
@@ -516,7 +574,7 @@ var WaveRenderer = class {
516
574
  s.geometry.dispose();
517
575
  s.palette.dispose();
518
576
  if (s.particleField) {
519
- this.scene.remove(s.particleField.points);
577
+ this.scene.remove(s.particleField.object);
520
578
  s.particleField.dispose();
521
579
  }
522
580
  }
@@ -538,7 +596,7 @@ var WaveRenderer = class {
538
596
  s.geometry.dispose();
539
597
  s.palette.dispose();
540
598
  if (s.particleField) {
541
- this.scene.remove(s.particleField.points);
599
+ this.scene.remove(s.particleField.object);
542
600
  s.particleField.dispose();
543
601
  }
544
602
  }
@@ -566,17 +624,7 @@ var WaveRenderer = class {
566
624
  const sc = this.config.waves[i] ?? this.config.waves[this.config.waves.length - 1];
567
625
  const u = wave.material.uniforms;
568
626
  if (this.applyBlendMode(wave.material, sc.blendMode)) wave.material.needsUpdate = true;
569
- const wantDefines = this.waveDefines(sc);
570
- const curDefines = wave.material.defines ?? {};
571
- if (Object.keys(wantDefines).sort().join(",") !== Object.keys(curDefines).sort().join(",")) {
572
- wave.material.defines = wantDefines;
573
- wave.material.needsUpdate = true;
574
- }
575
- const wantFrag = sc.theme === "wireframe" ? lineFragmentShader : fragmentShader;
576
- if (wave.material.fragmentShader !== wantFrag) {
577
- wave.material.fragmentShader = wantFrag;
578
- wave.material.needsUpdate = true;
579
- }
627
+ if (this.applyWaveVariant(wave, sc)) wave.material.needsUpdate = true;
580
628
  const stops = [...sc.palette].sort((a, b) => a.pos - b.pos);
581
629
  const colorCount = Math.max(1, Math.min(stops.length, 8));
582
630
  const colors = u.uColors.value;
@@ -731,18 +779,41 @@ var WaveRenderer = class {
731
779
  if (this.config.transparentBackground) {
732
780
  this.scene.background = null;
733
781
  this.renderer.setClearColor(0, 0);
782
+ this.onBackgroundChanged();
734
783
  return;
735
784
  }
736
785
  const matte = new THREE.Color(this.config.background);
737
786
  this.renderer.setClearColor(matte, 1);
738
787
  if (this.config.backgroundMode === "color") {
739
788
  this.applyColorBackground(matte);
789
+ this.onBackgroundChanged();
740
790
  return;
741
791
  }
742
792
  const { width, height } = this.backgroundCanvasSize(this.config.backgroundVideoUrl ? 2048 : 4096);
743
793
  if (this.config.backgroundMode === "gradient") this.applyGradientBackground(width, height);
744
794
  else this.applyImageBackground(matte, width, height);
795
+ this.onBackgroundChanged();
796
+ }
797
+ /**
798
+ * Build a wave's particle field. The GLSL backend returns a `THREE.Points` field; the TSL backend
799
+ * returns an instanced-sprite one wired to this wave's own uniform registry.
800
+ */
801
+ createParticleField(_wave, _sc, onReady) {
802
+ return new ParticleField(onReady);
803
+ }
804
+ /**
805
+ * Largest texture edge the backend will accept, used to cap the background canvas. WebGL reports
806
+ * it on `capabilities`; WebGPU has no such object, so the subclass reads the device limit.
807
+ */
808
+ maxTextureSize() {
809
+ return this.renderer.capabilities.maxTextureSize;
745
810
  }
811
+ /**
812
+ * Called whenever the scene background changes. A no-op on WebGL, where `EffectComposer`'s
813
+ * RenderPass reads the scene afresh each frame; the WebGPU subclass rebuilds its post chain,
814
+ * because a `pass()` node captures the background state at the point its render context is built.
815
+ */
816
+ onBackgroundChanged() {}
746
817
  applyColorBackground(matte) {
747
818
  this.clearBackgroundVideo();
748
819
  this.backgroundTexture?.dispose();
@@ -872,7 +943,7 @@ var WaveRenderer = class {
872
943
  backgroundCanvasSize(maxRequestedEdge = 4096) {
873
944
  const rawWidth = this.outputSize?.width ?? Math.max(1, this.container.clientWidth);
874
945
  const rawHeight = this.outputSize?.height ?? Math.max(1, this.container.clientHeight);
875
- const maxEdge = Math.min(maxRequestedEdge, this.renderer.capabilities.maxTextureSize);
946
+ const maxEdge = Math.min(maxRequestedEdge, this.maxTextureSize());
876
947
  const scale = Math.min(1, maxEdge / Math.max(rawWidth, rawHeight));
877
948
  return {
878
949
  width: Math.max(1, Math.round(rawWidth * scale)),
@@ -1247,8 +1318,7 @@ var WaveRenderer = class {
1247
1318
  this.renderer.domElement.style.width = "100%";
1248
1319
  this.renderer.domElement.style.height = "100%";
1249
1320
  }
1250
- this.composer.setPixelRatio(dpr);
1251
- this.composer.setSize(w, h);
1321
+ this.resizePost(w, h, dpr);
1252
1322
  const dw = w * dpr;
1253
1323
  const dh = h * dpr;
1254
1324
  this.postPass.uniforms.uResolution.value.set(dw, dh);
@@ -1334,14 +1404,28 @@ var WaveRenderer = class {
1334
1404
  this.postPass.uniforms.uTime.value = t;
1335
1405
  for (const w of this.waves) w.particleField?.setTime(t);
1336
1406
  }
1407
+ /** Draw the composed frame. WebGL runs the EffectComposer; WebGPU runs a node post chain. */
1408
+ renderComposed() {
1409
+ this.composer.render();
1410
+ }
1411
+ /** Resize the post chain's render targets. */
1412
+ resizePost(w, h, dpr) {
1413
+ this.composer.setPixelRatio(dpr);
1414
+ this.composer.setSize(w, h);
1415
+ }
1416
+ /** Release the post chain's GPU resources. */
1417
+ disposePost() {
1418
+ this.composer.dispose();
1419
+ }
1337
1420
  /** Render exactly one frame at the current time. */
1338
1421
  renderOnce() {
1422
+ if (!this.ready) return;
1339
1423
  this.updateBackgroundVideoFrame();
1340
1424
  this.updateTime();
1341
1425
  this.applyInteraction();
1342
1426
  this.updateSceneFx();
1343
1427
  this.updateClipPlanes();
1344
- this.composer.render();
1428
+ this.renderComposed();
1345
1429
  this.onAfterRenderFrame();
1346
1430
  }
1347
1431
  /** Insert / sync / remove EACH wave's particle field — mirrors applyBloom's lazy lifecycle, per wave.
@@ -1351,15 +1435,18 @@ var WaveRenderer = class {
1351
1435
  applyParticles() {
1352
1436
  const loop = this.config.loopSeconds ?? 0;
1353
1437
  this.waves.forEach((wave, i) => {
1354
- const cfg = (this.config.waves[i] ?? this.config.waves[this.config.waves.length - 1])?.particles;
1438
+ const sc = this.config.waves[i] ?? this.config.waves[this.config.waves.length - 1];
1439
+ const cfg = sc?.particles;
1355
1440
  if (cfg && cfg.count > 0) {
1356
1441
  if (!wave.particleField) {
1357
- wave.particleField = new ParticleField();
1358
- this.scene.add(wave.particleField.points);
1442
+ wave.particleField = this.createParticleField(wave, sc, () => {
1443
+ if (!this.running) this.renderOnce();
1444
+ });
1445
+ this.scene.add(wave.particleField.object);
1359
1446
  }
1360
1447
  wave.particleField.sync(cfg, loop);
1361
1448
  } else if (wave.particleField) {
1362
- this.scene.remove(wave.particleField.points);
1449
+ this.scene.remove(wave.particleField.object);
1363
1450
  wave.particleField.dispose();
1364
1451
  wave.particleField = void 0;
1365
1452
  }
@@ -1401,8 +1488,10 @@ var WaveRenderer = class {
1401
1488
  });
1402
1489
  });
1403
1490
  }
1404
- /** The shape-affecting subset of waveDefines() (what the shared waveShape reads) used to compile
1405
- * each wave's particle shader to match its own deform. */
1491
+ /** The subset of waveDefines() the particle program shares: the shape gates (what waveShape reads,
1492
+ * so the dust matches its wave's deform) plus the pointer gates (what pointerField reads, so the
1493
+ * dust reacts to the same cursor). Every one of these is derived from CONFIG alone — live input
1494
+ * must never reach here, or each frame would flip the define set and recompile the point program. */
1406
1495
  shapeDefines(sc) {
1407
1496
  const all = this.waveDefines(sc);
1408
1497
  const out = {};
@@ -1411,7 +1500,9 @@ var WaveRenderer = class {
1411
1500
  "DETAIL_OCTAVE",
1412
1501
  "HELIX",
1413
1502
  "TWIST_MOTION",
1414
- "RADIAL"
1503
+ "RADIAL",
1504
+ "POINTER_FX",
1505
+ "POINTER_RIPPLES"
1415
1506
  ]) if (k in all) out[k] = "";
1416
1507
  return out;
1417
1508
  }
@@ -1419,10 +1510,11 @@ var WaveRenderer = class {
1419
1510
  * refresh(); the compiled define set (POINTER_FX etc.) is handled separately by waveDefines(). */
1420
1511
  syncInteraction() {
1421
1512
  const active = interactionActive(this.config);
1422
- if (active && !this.interaction) this.interaction = new InteractionController(this.container, () => this.config);
1513
+ if (active && !this.interaction) this.loadInteraction();
1423
1514
  else if (!active && this.interaction) {
1424
1515
  this.interaction.dispose();
1425
1516
  this.interaction = void 0;
1517
+ this.interactionModule = void 0;
1426
1518
  this.interactionTimeOffset = 0;
1427
1519
  if (this.interactionZoom !== 1) {
1428
1520
  this.interactionZoom = 1;
@@ -1430,18 +1522,50 @@ var WaveRenderer = class {
1430
1522
  }
1431
1523
  }
1432
1524
  }
1525
+ /**
1526
+ * Fetch the interactivity runtime and attach it. Deliberately a DYNAMIC import: the controller,
1527
+ * its listeners, the applier tables and the tilt sensor are ~3.8 KB gzipped that a scene with no
1528
+ * `interaction` block never runs, and a static import would put all of it in every bundle. The
1529
+ * cost is that interaction goes live a chunk-fetch after the first frame instead of on it —
1530
+ * invisible in practice, since there is nothing to react to until a reader moves.
1531
+ *
1532
+ * A failed fetch is not fatal: the wave renders exactly as an inert one does, which is the same
1533
+ * thing that happens on a config with no interaction at all.
1534
+ */
1535
+ async loadInteraction() {
1536
+ if (this.interactionLoading) return;
1537
+ this.interactionLoading = true;
1538
+ try {
1539
+ const mod = await import("./interaction.js");
1540
+ if (this.disposed || this.interaction || !interactionActive(this.config)) return;
1541
+ this.interactionModule = mod;
1542
+ this.interaction = new mod.InteractionController(this.container, () => this.config);
1543
+ if (this.stagedInputs) {
1544
+ for (const [name, value] of this.stagedInputs) this.interaction.setInput(name, value);
1545
+ this.stagedInputs = void 0;
1546
+ }
1547
+ this.onInteractionReady();
1548
+ } catch {} finally {
1549
+ this.interactionLoading = false;
1550
+ }
1551
+ }
1552
+ /** Hook for subclasses that hold state the controller must be told about once it exists (the
1553
+ * studio's scroll preview). No-op in the base renderer. */
1554
+ onInteractionReady() {}
1433
1555
  /** Per-frame interaction write: dynamic pointer-field uniforms + bindings. No-op without a
1434
1556
  * controller. While capturing it writes the REST state instead (pointer field zeroed, every bound
1435
1557
  * param at its authored base) — merely skipping the write would freeze whatever live hover/scroll
1436
1558
  * state the previous frame left in the uniforms, so exports wouldn't be deterministic. */
1437
1559
  applyInteraction() {
1438
- if (!this.interaction) return;
1560
+ const ic = this.interaction;
1561
+ const mod = this.interactionModule;
1562
+ if (!ic || !mod) return;
1439
1563
  if (this.capturing) {
1440
- this.applyInteractionRest();
1564
+ this.applyInteractionRest(mod);
1441
1565
  return;
1442
1566
  }
1443
- if (anyPointerFxActive(this.config)) this.applyPointerField(this.interaction);
1444
- this.applyBindings(this.interaction);
1567
+ if (anyPointerFxActive(this.config)) this.applyPointerField(ic);
1568
+ this.applyBindings(ic, mod);
1445
1569
  }
1446
1570
  /** Write the capture-frame interaction state: exactly what this config renders with no input —
1447
1571
  * pointer presence + ripple envelopes zeroed (vPointerFall gates every hover effect to 0) and each
@@ -1449,7 +1573,7 @@ var WaveRenderer = class {
1449
1573
  * the capture resumes mid-gesture; the trailing renderOnce() in captureImage restores the preview.
1450
1574
  * interactionZoom is deliberately NOT reset — captureImage strips it from camera.zoom itself, and
1451
1575
  * the post-capture restore depends on it being unchanged. */
1452
- applyInteractionRest() {
1576
+ applyInteractionRest(mod) {
1453
1577
  for (let i = 0; i < this.waves.length; i++) {
1454
1578
  const sc = this.config.waves[i] ?? this.config.waves[this.config.waves.length - 1];
1455
1579
  if (!wavePointerFxActive(this.config, sc)) continue;
@@ -1467,7 +1591,7 @@ var WaveRenderer = class {
1467
1591
  out: this.interactionSceneOut
1468
1592
  };
1469
1593
  for (const b of sceneBindings) {
1470
- const applier = SCENE_APPLIERS[b.target];
1594
+ const applier = mod.SCENE_APPLIERS[b.target];
1471
1595
  applier.apply(applier.base(this.config), sceneArgs);
1472
1596
  }
1473
1597
  }
@@ -1481,7 +1605,7 @@ var WaveRenderer = class {
1481
1605
  if (!sc || !bindings || bindings.length === 0) continue;
1482
1606
  const wave = this.waves[i];
1483
1607
  for (const b of bindings) {
1484
- const applier = WAVE_APPLIERS[b.target];
1608
+ const applier = mod.WAVE_APPLIERS[b.target];
1485
1609
  applier.apply(applier.base(sc), {
1486
1610
  u: wave.material.uniforms,
1487
1611
  mesh: wave.mesh
@@ -1523,7 +1647,7 @@ var WaveRenderer = class {
1523
1647
  }
1524
1648
  /** Evaluate bindings via the applier tables: value = mix(from ?? base, to, smoothedSource). Scene
1525
1649
  * bindings drive scene params; each wave's bindings drive that wave's uniforms. */
1526
- applyBindings(ic) {
1650
+ applyBindings(ic, mod) {
1527
1651
  this.interactionSceneOut.timeOffset = this.config.timeOffset ?? 0;
1528
1652
  this.interactionSceneOut.zoom = this.config.cameraZoom ?? 1;
1529
1653
  const sceneArgs = {
@@ -1531,7 +1655,7 @@ var WaveRenderer = class {
1531
1655
  out: this.interactionSceneOut
1532
1656
  };
1533
1657
  for (const b of this.config.interaction?.bindings ?? []) {
1534
- const applier = SCENE_APPLIERS[b.target];
1658
+ const applier = mod.SCENE_APPLIERS[b.target];
1535
1659
  const value = THREE.MathUtils.lerp(b.from ?? applier.base(this.config), b.to, ic.bindingValue(b));
1536
1660
  applier.apply(value, sceneArgs);
1537
1661
  }
@@ -1541,7 +1665,7 @@ var WaveRenderer = class {
1541
1665
  if (!sc || !bindings || bindings.length === 0) continue;
1542
1666
  const wave = this.waves[i];
1543
1667
  for (const b of bindings) {
1544
- const applier = WAVE_APPLIERS[b.target];
1668
+ const applier = mod.WAVE_APPLIERS[b.target];
1545
1669
  const value = THREE.MathUtils.lerp(b.from ?? applier.base(sc), b.to, ic.bindingValue(b));
1546
1670
  applier.apply(value, {
1547
1671
  u: wave.material.uniforms,
@@ -1556,9 +1680,45 @@ var WaveRenderer = class {
1556
1680
  this.applyZoom();
1557
1681
  }
1558
1682
  }
1559
- /** Feed a `custom:<name>` interaction input (developer API). No-op when interaction is off. */
1683
+ /** Feed a `custom:<name>` interaction input (developer API). No-op when interaction is off.
1684
+ * Values fed before the interaction chunk lands are STAGED (last one per name wins) and replayed
1685
+ * when it does — otherwise a one-shot input sent right after `onReady` would vanish into the
1686
+ * fetch window. */
1560
1687
  setInteractionInput(name, value) {
1561
- this.interaction?.setInput(name, value);
1688
+ if (this.interaction) {
1689
+ this.interaction.setInput(name, value);
1690
+ return;
1691
+ }
1692
+ if (typeof name !== "string" || !Number.isFinite(value)) return;
1693
+ if (!interactionActive(this.config)) return;
1694
+ (this.stagedInputs ??= /* @__PURE__ */ new Map()).set(name, value);
1695
+ }
1696
+ /**
1697
+ * Explicitly ask for the device-orientation sensor. OPTIONAL, and on iOS it opens a modal
1698
+ * permission dialog — so this belongs to a page where tilt is the point, not to a decorative
1699
+ * background, which should simply go without tilt there. Nothing calls this for you.
1700
+ *
1701
+ * CALL IT FROM A USER GESTURE: iOS 13+ only grants the sensor from inside a tap handler, and
1702
+ * awaiting anything before it (a fetch, a timeout) spends the gesture. Resolves true once
1703
+ * readings can flow — false when
1704
+ * the platform has no sensor, the scene declares no `interaction.tilt`, or the reader refused.
1705
+ * Where no permission is required the sensor is already live and this resolves true.
1706
+ */
1707
+ enableTilt() {
1708
+ if (!this.interaction) {
1709
+ if (interactionActive(this.config)) this.loadInteraction();
1710
+ return Promise.resolve(false);
1711
+ }
1712
+ return this.interaction.enableTilt();
1713
+ }
1714
+ /** Where the tilt sensor stands. `"prompt"` is exactly when a tap-to-enable affordance helps. */
1715
+ tiltStatus() {
1716
+ if (this.interaction) return this.interaction.tiltStatus();
1717
+ return typeof window !== "undefined" && typeof window.DeviceOrientationEvent !== "undefined" ? "prompt" : "unsupported";
1718
+ }
1719
+ /** Take the next orientation reading as the neutral pose — for when the reader has changed grip. */
1720
+ recenterTilt() {
1721
+ this.interaction?.recenterTilt();
1562
1722
  }
1563
1723
  /** Re-evaluate play/pause after `config.paused` changes. */
1564
1724
  refreshPlayback() {
@@ -1714,11 +1874,13 @@ var WaveRenderer = class {
1714
1874
  this.applyCameraFromConfig();
1715
1875
  }
1716
1876
  dispose() {
1877
+ this.disposed = true;
1717
1878
  cancelAnimationFrame(this.rafId);
1718
1879
  cancelAnimationFrame(this.resizeRaf);
1719
1880
  this.running = false;
1720
1881
  this.interaction?.dispose();
1721
1882
  this.interaction = void 0;
1883
+ this.interactionModule = void 0;
1722
1884
  this.resizeObserver.disconnect();
1723
1885
  this.intersectionObserver.disconnect();
1724
1886
  this.motionQuery.removeEventListener("change", this.onMotionChange);
@@ -1741,7 +1903,7 @@ var WaveRenderer = class {
1741
1903
  this.heatmapPass?.dispose();
1742
1904
  this.paperTexturePass?.dispose();
1743
1905
  this.halftoneCmykPass?.dispose();
1744
- this.composer.dispose();
1906
+ this.disposePost();
1745
1907
  this.renderer.dispose();
1746
1908
  this.renderer.domElement.remove();
1747
1909
  }