@wave3d/core 0.8.0 → 0.10.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 (71) hide show
  1. package/README.md +3 -1
  2. package/dist/config/model.d.ts +48 -2
  3. package/dist/config/model.js +8 -1
  4. package/dist/config/model.js.map +1 -1
  5. package/dist/index.d.ts +3 -2
  6. package/dist/renderer/WaveRenderer.d.ts +142 -10
  7. package/dist/renderer/WaveRenderer.js +209 -53
  8. package/dist/renderer/WaveRenderer.js.map +1 -1
  9. package/dist/renderer/WaveRendererGPU.js +291 -0
  10. package/dist/renderer/WaveRendererGPU.js.map +1 -0
  11. package/dist/renderer/gpu-loader.js +2 -0
  12. package/dist/renderer/index.d.ts +3 -2
  13. package/dist/renderer/interaction.d.ts +29 -0
  14. package/dist/renderer/interaction.js +77 -31
  15. package/dist/renderer/interaction.js.map +1 -1
  16. package/dist/renderer/interactionGates.js +59 -0
  17. package/dist/renderer/interactionGates.js.map +1 -0
  18. package/dist/renderer/particleField.d.ts +1 -59
  19. package/dist/renderer/particleField.js +49 -29
  20. package/dist/renderer/particleField.js.map +1 -1
  21. package/dist/renderer/particleFieldGPU.js +141 -0
  22. package/dist/renderer/particleFieldGPU.js.map +1 -0
  23. package/dist/renderer/tilt.d.ts +17 -0
  24. package/dist/renderer/tilt.js +124 -0
  25. package/dist/renderer/tilt.js.map +1 -0
  26. package/dist/renderer/tsl/color.js +129 -0
  27. package/dist/renderer/tsl/color.js.map +1 -0
  28. package/dist/renderer/tsl/noise.js +83 -0
  29. package/dist/renderer/tsl/noise.js.map +1 -0
  30. package/dist/renderer/tsl/packedArray.js +76 -0
  31. package/dist/renderer/tsl/packedArray.js.map +1 -0
  32. package/dist/renderer/tsl/particleMaterial.js +133 -0
  33. package/dist/renderer/tsl/particleMaterial.js.map +1 -0
  34. package/dist/renderer/tsl/particleUniforms.js +44 -0
  35. package/dist/renderer/tsl/particleUniforms.js.map +1 -0
  36. package/dist/renderer/tsl/pointerField.js +73 -0
  37. package/dist/renderer/tsl/pointerField.js.map +1 -0
  38. package/dist/renderer/tsl/post.js +63 -0
  39. package/dist/renderer/tsl/post.js.map +1 -0
  40. package/dist/renderer/tsl/postChain.js +56 -0
  41. package/dist/renderer/tsl/postChain.js.map +1 -0
  42. package/dist/renderer/tsl/postEffects.js +221 -0
  43. package/dist/renderer/tsl/postEffects.js.map +1 -0
  44. package/dist/renderer/tsl/types.js +28 -0
  45. package/dist/renderer/tsl/types.js.map +1 -0
  46. package/dist/renderer/tsl/uniforms.js +152 -0
  47. package/dist/renderer/tsl/uniforms.js.map +1 -0
  48. package/dist/renderer/tsl/waveMaterial.js +185 -0
  49. package/dist/renderer/tsl/waveMaterial.js.map +1 -0
  50. package/dist/renderer/tsl/waveShape.js +108 -0
  51. package/dist/renderer/tsl/waveShape.js.map +1 -0
  52. package/dist/shell/createWave.d.ts +29 -0
  53. package/dist/shell/createWave.js +39 -11
  54. package/dist/shell/createWave.js.map +1 -1
  55. package/dist/shell/probe.js +18 -1
  56. package/dist/shell/probe.js.map +1 -1
  57. package/dist/standalone/wave3d.standalone.js +1811 -1594
  58. package/dist/standalone/wave3d.standalone.webgpu.js +36647 -0
  59. package/dist/standalone.d.ts +10 -3
  60. package/dist/standalone.js +10 -3
  61. package/dist/standalone.js.map +1 -1
  62. package/dist/studio/StudioWaveRenderer.d.ts +4 -0
  63. package/dist/studio/StudioWaveRenderer.js +9 -0
  64. package/dist/studio/StudioWaveRenderer.js.map +1 -1
  65. package/dist/studio/StudioWaveRendererGPU.js +18 -0
  66. package/dist/studio/StudioWaveRendererGPU.js.map +1 -0
  67. package/dist/studio/index.d.ts +12 -2
  68. package/dist/studio/index.js +14 -1
  69. package/dist/studio/index.js.map +1 -0
  70. package/package.json +12 -3
  71. package/skills/wave3d/SKILL.md +29 -2
@@ -1,7 +1,7 @@
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 { InteractionController, SCENE_APPLIERS, WAVE_APPLIERS, anyPointerFxActive, interactionActive, wavePointerFxActive, waveRipplesActive } from "./interaction.js";
4
+ import { anyPointerFxActive, interactionActive, wavePointerFxActive, waveRipplesActive } from "./interactionGates.js";
5
5
  import { ParticleField } from "./particleField.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";
@@ -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,17 +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(() => {
1442
+ wave.particleField = this.createParticleField(wave, sc, () => {
1358
1443
  if (!this.running) this.renderOnce();
1359
1444
  });
1360
- this.scene.add(wave.particleField.points);
1445
+ this.scene.add(wave.particleField.object);
1361
1446
  }
1362
1447
  wave.particleField.sync(cfg, loop);
1363
1448
  } else if (wave.particleField) {
1364
- this.scene.remove(wave.particleField.points);
1449
+ this.scene.remove(wave.particleField.object);
1365
1450
  wave.particleField.dispose();
1366
1451
  wave.particleField = void 0;
1367
1452
  }
@@ -1425,10 +1510,11 @@ var WaveRenderer = class {
1425
1510
  * refresh(); the compiled define set (POINTER_FX etc.) is handled separately by waveDefines(). */
1426
1511
  syncInteraction() {
1427
1512
  const active = interactionActive(this.config);
1428
- if (active && !this.interaction) this.interaction = new InteractionController(this.container, () => this.config);
1513
+ if (active && !this.interaction) this.loadInteraction();
1429
1514
  else if (!active && this.interaction) {
1430
1515
  this.interaction.dispose();
1431
1516
  this.interaction = void 0;
1517
+ this.interactionModule = void 0;
1432
1518
  this.interactionTimeOffset = 0;
1433
1519
  if (this.interactionZoom !== 1) {
1434
1520
  this.interactionZoom = 1;
@@ -1436,18 +1522,50 @@ var WaveRenderer = class {
1436
1522
  }
1437
1523
  }
1438
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() {}
1439
1555
  /** Per-frame interaction write: dynamic pointer-field uniforms + bindings. No-op without a
1440
1556
  * controller. While capturing it writes the REST state instead (pointer field zeroed, every bound
1441
1557
  * param at its authored base) — merely skipping the write would freeze whatever live hover/scroll
1442
1558
  * state the previous frame left in the uniforms, so exports wouldn't be deterministic. */
1443
1559
  applyInteraction() {
1444
- if (!this.interaction) return;
1560
+ const ic = this.interaction;
1561
+ const mod = this.interactionModule;
1562
+ if (!ic || !mod) return;
1445
1563
  if (this.capturing) {
1446
- this.applyInteractionRest();
1564
+ this.applyInteractionRest(mod);
1447
1565
  return;
1448
1566
  }
1449
- if (anyPointerFxActive(this.config)) this.applyPointerField(this.interaction);
1450
- this.applyBindings(this.interaction);
1567
+ if (anyPointerFxActive(this.config)) this.applyPointerField(ic);
1568
+ this.applyBindings(ic, mod);
1451
1569
  }
1452
1570
  /** Write the capture-frame interaction state: exactly what this config renders with no input —
1453
1571
  * pointer presence + ripple envelopes zeroed (vPointerFall gates every hover effect to 0) and each
@@ -1455,7 +1573,7 @@ var WaveRenderer = class {
1455
1573
  * the capture resumes mid-gesture; the trailing renderOnce() in captureImage restores the preview.
1456
1574
  * interactionZoom is deliberately NOT reset — captureImage strips it from camera.zoom itself, and
1457
1575
  * the post-capture restore depends on it being unchanged. */
1458
- applyInteractionRest() {
1576
+ applyInteractionRest(mod) {
1459
1577
  for (let i = 0; i < this.waves.length; i++) {
1460
1578
  const sc = this.config.waves[i] ?? this.config.waves[this.config.waves.length - 1];
1461
1579
  if (!wavePointerFxActive(this.config, sc)) continue;
@@ -1473,7 +1591,7 @@ var WaveRenderer = class {
1473
1591
  out: this.interactionSceneOut
1474
1592
  };
1475
1593
  for (const b of sceneBindings) {
1476
- const applier = SCENE_APPLIERS[b.target];
1594
+ const applier = mod.SCENE_APPLIERS[b.target];
1477
1595
  applier.apply(applier.base(this.config), sceneArgs);
1478
1596
  }
1479
1597
  }
@@ -1487,7 +1605,7 @@ var WaveRenderer = class {
1487
1605
  if (!sc || !bindings || bindings.length === 0) continue;
1488
1606
  const wave = this.waves[i];
1489
1607
  for (const b of bindings) {
1490
- const applier = WAVE_APPLIERS[b.target];
1608
+ const applier = mod.WAVE_APPLIERS[b.target];
1491
1609
  applier.apply(applier.base(sc), {
1492
1610
  u: wave.material.uniforms,
1493
1611
  mesh: wave.mesh
@@ -1529,7 +1647,7 @@ var WaveRenderer = class {
1529
1647
  }
1530
1648
  /** Evaluate bindings via the applier tables: value = mix(from ?? base, to, smoothedSource). Scene
1531
1649
  * bindings drive scene params; each wave's bindings drive that wave's uniforms. */
1532
- applyBindings(ic) {
1650
+ applyBindings(ic, mod) {
1533
1651
  this.interactionSceneOut.timeOffset = this.config.timeOffset ?? 0;
1534
1652
  this.interactionSceneOut.zoom = this.config.cameraZoom ?? 1;
1535
1653
  const sceneArgs = {
@@ -1537,7 +1655,7 @@ var WaveRenderer = class {
1537
1655
  out: this.interactionSceneOut
1538
1656
  };
1539
1657
  for (const b of this.config.interaction?.bindings ?? []) {
1540
- const applier = SCENE_APPLIERS[b.target];
1658
+ const applier = mod.SCENE_APPLIERS[b.target];
1541
1659
  const value = THREE.MathUtils.lerp(b.from ?? applier.base(this.config), b.to, ic.bindingValue(b));
1542
1660
  applier.apply(value, sceneArgs);
1543
1661
  }
@@ -1547,7 +1665,7 @@ var WaveRenderer = class {
1547
1665
  if (!sc || !bindings || bindings.length === 0) continue;
1548
1666
  const wave = this.waves[i];
1549
1667
  for (const b of bindings) {
1550
- const applier = WAVE_APPLIERS[b.target];
1668
+ const applier = mod.WAVE_APPLIERS[b.target];
1551
1669
  const value = THREE.MathUtils.lerp(b.from ?? applier.base(sc), b.to, ic.bindingValue(b));
1552
1670
  applier.apply(value, {
1553
1671
  u: wave.material.uniforms,
@@ -1562,9 +1680,45 @@ var WaveRenderer = class {
1562
1680
  this.applyZoom();
1563
1681
  }
1564
1682
  }
1565
- /** 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. */
1566
1687
  setInteractionInput(name, value) {
1567
- 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();
1568
1722
  }
1569
1723
  /** Re-evaluate play/pause after `config.paused` changes. */
1570
1724
  refreshPlayback() {
@@ -1720,11 +1874,13 @@ var WaveRenderer = class {
1720
1874
  this.applyCameraFromConfig();
1721
1875
  }
1722
1876
  dispose() {
1877
+ this.disposed = true;
1723
1878
  cancelAnimationFrame(this.rafId);
1724
1879
  cancelAnimationFrame(this.resizeRaf);
1725
1880
  this.running = false;
1726
1881
  this.interaction?.dispose();
1727
1882
  this.interaction = void 0;
1883
+ this.interactionModule = void 0;
1728
1884
  this.resizeObserver.disconnect();
1729
1885
  this.intersectionObserver.disconnect();
1730
1886
  this.motionQuery.removeEventListener("change", this.onMotionChange);
@@ -1747,7 +1903,7 @@ var WaveRenderer = class {
1747
1903
  this.heatmapPass?.dispose();
1748
1904
  this.paperTexturePass?.dispose();
1749
1905
  this.halftoneCmykPass?.dispose();
1750
- this.composer.dispose();
1906
+ this.disposePost();
1751
1907
  this.renderer.dispose();
1752
1908
  this.renderer.domElement.remove();
1753
1909
  }