@wave3d/core 0.9.0 → 0.11.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 (53) hide show
  1. package/dist/config/model.d.ts +209 -6
  2. package/dist/config/model.js +85 -3
  3. package/dist/config/model.js.map +1 -1
  4. package/dist/index.d.ts +3 -2
  5. package/dist/index.js +3 -2
  6. package/dist/presets.js +295 -0
  7. package/dist/presets.js.map +1 -1
  8. package/dist/renderer/WaveGeometry.js +21 -0
  9. package/dist/renderer/WaveGeometry.js.map +1 -1
  10. package/dist/renderer/WaveRenderer.d.ts +62 -0
  11. package/dist/renderer/WaveRenderer.js +349 -10
  12. package/dist/renderer/WaveRenderer.js.map +1 -1
  13. package/dist/renderer/WaveRendererGPU.js +32 -2
  14. package/dist/renderer/WaveRendererGPU.js.map +1 -1
  15. package/dist/renderer/interaction.js +12 -0
  16. package/dist/renderer/interaction.js.map +1 -1
  17. package/dist/renderer/particleField.js +26 -2
  18. package/dist/renderer/particleField.js.map +1 -1
  19. package/dist/renderer/particleFieldGPU.js +6 -0
  20. package/dist/renderer/particleFieldGPU.js.map +1 -1
  21. package/dist/renderer/shaders.js +748 -13
  22. package/dist/renderer/shaders.js.map +1 -1
  23. package/dist/renderer/tsl/dissolve.js +56 -0
  24. package/dist/renderer/tsl/dissolve.js.map +1 -0
  25. package/dist/renderer/tsl/particleMaterial.js +46 -8
  26. package/dist/renderer/tsl/particleMaterial.js.map +1 -1
  27. package/dist/renderer/tsl/uniforms.js +40 -1
  28. package/dist/renderer/tsl/uniforms.js.map +1 -1
  29. package/dist/renderer/tsl/waveMaterial.js +275 -26
  30. package/dist/renderer/tsl/waveMaterial.js.map +1 -1
  31. package/dist/renderer/tsl/waveShape.js +31 -10
  32. package/dist/renderer/tsl/waveShape.js.map +1 -1
  33. package/dist/renderer/wavePath.js +189 -0
  34. package/dist/renderer/wavePath.js.map +1 -0
  35. package/dist/shell/createWave.d.ts +23 -3
  36. package/dist/shell/createWave.js +5 -4
  37. package/dist/shell/createWave.js.map +1 -1
  38. package/dist/shell/probe.d.ts +28 -0
  39. package/dist/shell/probe.js +58 -11
  40. package/dist/shell/probe.js.map +1 -1
  41. package/dist/standalone/wave3d.standalone.js +3401 -2108
  42. package/dist/standalone/wave3d.standalone.webgpu.js +7372 -5848
  43. package/dist/standalone.d.ts +2 -2
  44. package/dist/standalone.js +2 -2
  45. package/dist/studio/StudioWaveRenderer.d.ts +115 -8
  46. package/dist/studio/StudioWaveRenderer.js +588 -14
  47. package/dist/studio/StudioWaveRenderer.js.map +1 -1
  48. package/dist/studio/index.d.ts +2 -2
  49. package/dist/studio/index.js.map +1 -1
  50. package/dist/studio/randomize.js +0 -1
  51. package/dist/studio/randomize.js.map +1 -1
  52. package/package.json +1 -1
  53. package/skills/wave3d/SKILL.md +142 -5
@@ -108,6 +108,14 @@ type Wave = {
108
108
  /** This wave's own particle / dust field — created when its `particles.count` first goes >0,
109
109
  * disposed at 0 / absent (the WavePalette lifecycle pattern). Undefined = no dust for this wave. */
110
110
  particleField?: WaveParticleField;
111
+ /** The baked centreline LUT for this wave's `path` (absent when it has none). */
112
+ pathTexture?: THREE.DataTexture;
113
+ /** Signature of the points the LUT was baked from, so a drag only rewrites it when it changed. */
114
+ pathSig?: string;
115
+ /** Glass only: the layer-count companion (this wave's vertex stage + a coverage-only fragment),
116
+ * built lazily by renderGlassPasses() and rebuilt when `layerKey` no longer matches. */
117
+ layerMaterial?: THREE.Material;
118
+ layerKey?: string;
111
119
  };
112
120
  /** Convert an sRGB hex string to a linear-space RGB vector (three's ColorManagement does the
113
121
  * sRGB→linear conversion on parse). Exported for the studio subclass's live light-uniform push. */
@@ -147,6 +155,12 @@ declare class WaveRenderer {
147
155
  protected config: StudioConfig;
148
156
  protected waves: Wave[];
149
157
  private backgroundTexture?;
158
+ /** The frame behind the glass waves — allocated only once a wave asks for the glass theme. */
159
+ private backdropTarget?;
160
+ /** How many glass layers cover each pixel. A sheet that folds over itself is thicker there, and
161
+ * thickness is the cue that reads as VOLUME rather than as a tinted film — but glass draws
162
+ * opaque, so the nearest layer wins and the fold behind it is invisible without counting. */
163
+ private layerTarget?;
150
164
  private backgroundSig;
151
165
  private backgroundImage?;
152
166
  private backgroundImageUrl;
@@ -186,6 +200,11 @@ declare class WaveRenderer {
186
200
  protected running: boolean;
187
201
  private started;
188
202
  private visible;
203
+ /** Read from the document, NOT assumed: the shell builds the renderer whenever the engine chunk
204
+ * lands, which can be after the reader has left the tab. Assumed visible, that renderer marked
205
+ * itself running with no frame to run on, and the `visibilitychange` that brought the reader back
206
+ * found nothing to restart — so the delta baseline was never reset and the first frame was handed
207
+ * the whole absence at once (see MAX_FRAME_SECONDS for the same stall without the event). */
189
208
  private pageVisible;
190
209
  private reducedMotion;
191
210
  /** Intro ramp: eases animation time 0→1 over ~1s on load (when config.introRamp). */
@@ -216,6 +235,10 @@ declare class WaveRenderer {
216
235
  init(): Promise<void>;
217
236
  private get segments();
218
237
  private makeUniforms;
238
+ /** Reconcile a wave's baked path LUT with its config. The texture is rewritten in place when the
239
+ * points change — which is what makes dragging a control point cheap, since the alternative is
240
+ * rebuilding 80k vertices per frame — and disposed when a wave loses its path. */
241
+ private syncPathTexture;
219
242
  /** Vertex-shader #defines for a wave: TWIST_MOTION (per-wave animated twist wobble) and
220
243
  * LOOP_MOTION (scene-level seamless loop). Both select #ifdef-gated code paths; an empty
221
244
  * object compiles the default (linear-time) program. */
@@ -227,6 +250,20 @@ declare class WaveRenderer {
227
250
  * surface, so everything downstream in `refresh()` is shared.
228
251
  */
229
252
  protected createWaveMaterial(sc: WaveConfig | undefined): WaveMaterial;
253
+ /**
254
+ * The glass LAYER-COUNT companion for one wave: the same vertex program on the same uniforms
255
+ * object (shared by reference, so every per-frame write reaches it), with a fragment that only
256
+ * marks coverage. Drawn additively with depth off, so every fold over a pixel adds up.
257
+ *
258
+ * A stock override material could not stand in for this: its vertex stage knows nothing of the
259
+ * deformation and it culls back faces, so the count came out for the rest-pose plane — measured
260
+ * at a tenth of the real silhouette on the Liquid Glass preset. The WebGPU subclass builds the
261
+ * node twin from the same flags.
262
+ */
263
+ protected createLayerMaterial(sc: WaveConfig, material: WaveMaterial): THREE.Material;
264
+ /** What the layer companion was built against; when this changes, it is rebuilt. */
265
+ protected layerVariantKey(material: WaveMaterial): string;
266
+ private dropLayerMaterial;
230
267
  /**
231
268
  * Re-select this wave's shader variant when its config changes shape (theme, twist motion, helix,
232
269
  * …). Returns true if the material needs recompiling. The GLSL backend swaps `defines` and the
@@ -360,6 +397,31 @@ declare class WaveRenderer {
360
397
  private updateTime;
361
398
  /** Draw the composed frame. WebGL runs the EffectComposer; WebGPU runs a node post chain. */
362
399
  protected renderComposed(): void;
400
+ /** Capture what sits BEHIND the glass waves, so they have something to bend.
401
+ *
402
+ * Glass is drawn opaque and fakes its see-through by sampling this texture, which is why
403
+ * overlapping sheets need no sorting. The pass is skipped entirely when no wave asks for it, so
404
+ * a scene without glass renders exactly as before.
405
+ *
406
+ * The texture MUST be unbound while we render into it: binding a target as a texture while it is
407
+ * the render target is a framebuffer feedback loop, and the frame is undefined. */
408
+ /** The world axis from a surface toward the camera. Constant under an orthographic projection,
409
+ * which is exactly why it is a uniform rather than something the shader derives per fragment. */
410
+ private pushViewAxis;
411
+ private viewAxisTmp;
412
+ private empty?;
413
+ private backdropClear;
414
+ private prevClear;
415
+ private depthTmp;
416
+ private sizeTmp;
417
+ /** Pin a capture's sampling so both backends read it the same way. Left to their defaults the
418
+ * two disagree about filtering and edge behaviour, and a glass sheet samples FAR from the
419
+ * fragment — one offset per channel — so the disagreement shows up as a red/blue bias with green
420
+ * untouched, which looks like a dispersion bug and is not one. */
421
+ private pinSampling;
422
+ /** A 1x1 transparent texture, used wherever a sampler must be BOUND but must contribute nothing. */
423
+ private emptyTexture;
424
+ protected renderGlassPasses(): void;
363
425
  /** Resize the post chain's render targets. */
364
426
  protected resizePost(w: number, h: number, dpr: number): void;
365
427
  /** Release the post chain's GPU resources. */
@@ -1,6 +1,7 @@
1
1
  import { ensureStudioConfig } from "../config/model.js";
2
2
  import { WaveGeometry } from "./WaveGeometry.js";
3
- import { ditherFragmentShader, fragmentShader, halftoneCmykFragmentShader, halftoneFragmentShader, heatmapFragmentShader, innerLightFragmentShader, lineFragmentShader, paperTextureFragmentShader, postFragmentShader, postVertexShader, vertexShader } from "./shaders.js";
3
+ import { bakePathTexture, writePathTexture } from "./wavePath.js";
4
+ import { ditherFragmentShader, fragmentShader, glassFragmentShader, glassLayerFragmentShader, halftoneCmykFragmentShader, halftoneFragmentShader, heatmapFragmentShader, innerLightFragmentShader, lineFragmentShader, paperTextureFragmentShader, postFragmentShader, postVertexShader, vertexShader } from "./shaders.js";
4
5
  import { anyPointerFxActive, interactionActive, wavePointerFxActive, waveRipplesActive } from "./interactionGates.js";
5
6
  import { ParticleField } from "./particleField.js";
6
7
  import { PALETTE_MAPS, buildBackgroundGradientCanvas, buildBackgroundImageCanvas, buildBackgroundMeshCanvas, buildPaletteTexture, canvasToTexture, configurePaletteTexture, drawBackgroundMediaFrame, loadPaletteImage, paletteMapCanvas, paletteSignature } from "./palette.js";
@@ -12,7 +13,40 @@ import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
12
13
  import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
13
14
  import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
14
15
  //#region src/renderer/WaveRenderer.ts
16
+ /** Which fragment program a wave's theme wants. */
17
+ function fragmentFor(sc) {
18
+ if (sc?.theme === "wireframe") return lineFragmentShader;
19
+ if (sc?.theme === "glass") return glassFragmentShader;
20
+ return fragmentShader;
21
+ }
22
+ /** How opaque a wave's between-strand colour is. Absent is the page background at full strength (the
23
+ * theme as it always drew); `"transparent"` or an 8-digit hex carries its own alpha. */
24
+ function gapAlpha(color) {
25
+ if (!color) return 1;
26
+ if (color === "transparent") return 0;
27
+ if (/^#[0-9a-f]{8}$/i.test(color)) return parseInt(color.slice(7, 9), 16) / 255;
28
+ return 1;
29
+ }
30
+ /** The colour half of the same field — the alpha is stripped, and `"transparent"` has no colour of
31
+ * its own, so it falls back to the page background it is standing in for. */
32
+ function gapColorHex(color) {
33
+ if (!color || color === "transparent") return void 0;
34
+ return /^#[0-9a-f]{8}$/i.test(color) ? color.slice(0, 7) : color;
35
+ }
36
+ /** Dissolve axis name → the float the shaders branch on (see dissolveChunk's dissolveCoord). */
37
+ const DISSOLVE_AXIS_INDEX = {
38
+ length: 0,
39
+ width: 1,
40
+ screenX: 2,
41
+ screenY: 3
42
+ };
15
43
  const BASE_SEGMENTS = 220;
44
+ /** The longest frame the clock will believe, in seconds. Anything past it is a STALL — frames
45
+ * suspended with no `visibilitychange` to say so (an embedding webview that parks rAF, a debugger
46
+ * pause, the machine asleep) — and replaying a stall is a fast-forward, not motion. A ceiling, not
47
+ * a jitter clamp: recordings run on this same wall-clock loop, so a merely slow frame (a 4K export
48
+ * at 10fps) must still advance by its real length or a `loopSeconds` export stops closing. */
49
+ const MAX_FRAME_SECONDS = 1;
16
50
  /** Reference frame (world units) the orthographic camera fills at cameraZoom 1. The wave is
17
51
  * framed by mapping this FRAME_W × FRAME_H rectangle (centred on cameraTarget) onto the canvas,
18
52
  * so a given cameraZoom / cameraTarget frames the wave the SAME at any canvas size or aspect
@@ -202,6 +236,12 @@ var WaveRenderer = class {
202
236
  config;
203
237
  waves = [];
204
238
  backgroundTexture;
239
+ /** The frame behind the glass waves — allocated only once a wave asks for the glass theme. */
240
+ backdropTarget;
241
+ /** How many glass layers cover each pixel. A sheet that folds over itself is thicker there, and
242
+ * thickness is the cue that reads as VOLUME rather than as a tinted film — but glass draws
243
+ * opaque, so the nearest layer wins and the fold behind it is invisible without counting. */
244
+ layerTarget;
205
245
  backgroundSig = "";
206
246
  backgroundImage;
207
247
  backgroundImageUrl = "";
@@ -244,7 +284,12 @@ var WaveRenderer = class {
244
284
  running = false;
245
285
  started = false;
246
286
  visible = true;
247
- pageVisible = true;
287
+ /** Read from the document, NOT assumed: the shell builds the renderer whenever the engine chunk
288
+ * lands, which can be after the reader has left the tab. Assumed visible, that renderer marked
289
+ * itself running with no frame to run on, and the `visibilitychange` that brought the reader back
290
+ * found nothing to restart — so the delta baseline was never reset and the first frame was handed
291
+ * the whole absence at once (see MAX_FRAME_SECONDS for the same stall without the event). */
292
+ pageVisible = document.visibilityState === "visible";
248
293
  reducedMotion = false;
249
294
  /** Intro ramp: eases animation time 0→1 over ~1s on load (when config.introRamp). */
250
295
  introTimeRamp = 0;
@@ -422,6 +467,28 @@ var WaveRenderer = class {
422
467
  uCreaseSoftness: { value: 1 },
423
468
  uEdgeFade: { value: .06 },
424
469
  uEdgeFeather: { value: .1 },
470
+ uBackdrop: { value: null },
471
+ uGlassStrength: { value: 90 },
472
+ uGlassChroma: { value: .7 },
473
+ uGlassFrost: { value: .08 },
474
+ uGlassSpec: { value: 1.2 },
475
+ uGlassVibrancy: { value: .05 },
476
+ uGlassTint: { value: .12 },
477
+ uGlassRimPower: { value: 1.2 },
478
+ uGlassRipple: { value: 0 },
479
+ uGlassRippleScale: { value: .012 },
480
+ uGlassFlow: { value: .9 },
481
+ uGlassPath: { value: .45 },
482
+ uGlassDensity: { value: 1.2 },
483
+ uGlassRim: { value: .5 },
484
+ uGlassIrid: { value: 0 },
485
+ uGlassFilmNm: { value: 380 },
486
+ uGlassIor: { value: 1.45 },
487
+ uLayers: { value: null },
488
+ uGlassLayerGain: { value: .6 },
489
+ uGlassFusion: { value: 0 },
490
+ uGlassCaustic: { value: .4 },
491
+ uViewAxis: { value: new THREE.Vector3(0, 0, 1) },
425
492
  uOpacity: { value: 1 },
426
493
  uSquared: { value: 1 },
427
494
  uResolution: { value: this.renderer.getDrawingBufferSize(new THREE.Vector2()) },
@@ -437,19 +504,32 @@ var WaveRenderer = class {
437
504
  uLineAmount: { value: 425 },
438
505
  uLineThickness: { value: 1 },
439
506
  uLineDerivativePower: { value: .95 },
440
- uMaxWidth: { value: 1232 },
507
+ uLineDepthFade: { value: 1 },
508
+ uLineSharpness: { value: 0 },
509
+ uLineGapOpacity: { value: 1 },
510
+ uLineLight: { value: 0 },
441
511
  uClearColor: { value: new THREE.Vector3(1, 1, 1) },
442
512
  uHelixTurns: { value: 0 },
443
513
  uHelixRadius: { value: 0 },
444
514
  uHelixRoll: { value: 0 },
445
515
  uHelixPhase: { value: 0 },
516
+ uPathTex: { value: null },
446
517
  uRadialAmount: { value: 0 },
447
518
  uRadialArc: { value: 160 },
448
519
  uRadialSpread: { value: 1 },
449
520
  uRadialRadius: { value: 40 },
450
521
  uRadialCenter: { value: 0 },
522
+ uRadialCone: { value: 0 },
523
+ uRadialSwirl: { value: 0 },
451
524
  uRungAmount: { value: 0 },
452
525
  uRungThickness: { value: 1 },
526
+ uDissolveAmount: { value: 0 },
527
+ uDissolveBand: { value: .35 },
528
+ uDissolveScale: { value: 90 },
529
+ uDissolveBlocky: { value: .6 },
530
+ uDissolveAxis: { value: 0 },
531
+ uDissolveReverse: { value: 0 },
532
+ uDissolveDust: { value: 1 },
453
533
  uPointer: { value: new THREE.Vector2(0, 0) },
454
534
  uPointerActive: { value: 0 },
455
535
  uPointerRadius: { value: .6 },
@@ -468,6 +548,30 @@ var WaveRenderer = class {
468
548
  uRippleAmp: { value: rippleAmp }
469
549
  };
470
550
  }
551
+ /** Reconcile a wave's baked path LUT with its config. The texture is rewritten in place when the
552
+ * points change — which is what makes dragging a control point cheap, since the alternative is
553
+ * rebuilding 80k vertices per frame — and disposed when a wave loses its path. */
554
+ syncPathTexture(wave, sc) {
555
+ const pts = sc.path;
556
+ if (!pts || pts.length < 2) {
557
+ if (wave.pathTexture) {
558
+ wave.pathTexture.dispose();
559
+ wave.pathTexture = void 0;
560
+ wave.pathSig = void 0;
561
+ }
562
+ return;
563
+ }
564
+ const sig = JSON.stringify(pts);
565
+ if (!wave.pathTexture) {
566
+ wave.pathTexture = bakePathTexture(pts);
567
+ wave.pathSig = sig;
568
+ } else if (sig !== wave.pathSig) {
569
+ writePathTexture(wave.pathTexture.image.data, pts);
570
+ wave.pathTexture.needsUpdate = true;
571
+ wave.pathSig = sig;
572
+ }
573
+ wave.material.uniforms.uPathTex.value = wave.pathTexture;
574
+ }
471
575
  /** Vertex-shader #defines for a wave: TWIST_MOTION (per-wave animated twist wobble) and
472
576
  * LOOP_MOTION (scene-level seamless loop). Both select #ifdef-gated code paths; an empty
473
577
  * object compiles the default (linear-time) program. */
@@ -481,8 +585,15 @@ var WaveRenderer = class {
481
585
  if ((sc?.edgeFeather ?? .1) !== .1) defines.EDGE_FEATHER = "";
482
586
  const bindsHelix = sc?.interaction?.bindings?.some((b) => b.target.startsWith("helix")) ?? false;
483
587
  if ((sc?.helixRadius ?? 0) !== 0 || (sc?.helixRoll ?? 0) !== 0 || bindsHelix) defines.HELIX = "";
588
+ if (sc?.path && sc.path.length >= 2) defines.PATH = "";
589
+ if (sc?.theme === "glass") defines.VERTEX_NORMAL = "";
484
590
  if ((sc?.radialAmount ?? 0) !== 0) defines.RADIAL = "";
485
591
  if (sc?.theme === "wireframe" && (sc.rungAmount ?? 0) > 0) defines.RUNGS = "";
592
+ if (sc?.theme === "wireframe" && (sc.lineSharpness ?? 0) > 0) defines.LINE_SHARP = "";
593
+ if (sc?.theme === "wireframe" && gapAlpha(sc.lineGapColor) < 1) defines.LINE_CLEAR_GAPS = "";
594
+ if (sc?.theme === "wireframe" && (sc.lineLight ?? 0) > 0) defines.LINE_LIGHT = "";
595
+ const bindsDissolve = sc?.interaction?.bindings?.some((b) => b.target === "dissolveAmount") ?? false;
596
+ if (sc?.dissolve && ((sc.dissolve.amount ?? 0) > 0 || bindsDissolve)) defines.DISSOLVE = "";
486
597
  if (sc && wavePointerFxActive(this.config, sc)) {
487
598
  defines.POINTER_FX = "";
488
599
  if (waveRipplesActive(this.config, sc)) defines.POINTER_RIPPLES = "";
@@ -519,14 +630,51 @@ var WaveRenderer = class {
519
630
  uniforms: this.makeUniforms(),
520
631
  defines: this.waveDefines(sc),
521
632
  vertexShader,
522
- fragmentShader: sc?.theme === "wireframe" ? lineFragmentShader : fragmentShader,
523
- transparent: true,
633
+ fragmentShader: fragmentFor(sc),
634
+ transparent: sc?.theme !== "glass",
524
635
  depthTest: true,
525
636
  depthWrite: true,
526
637
  side: THREE.DoubleSide
527
638
  });
528
639
  }
529
640
  /**
641
+ * The glass LAYER-COUNT companion for one wave: the same vertex program on the same uniforms
642
+ * object (shared by reference, so every per-frame write reaches it), with a fragment that only
643
+ * marks coverage. Drawn additively with depth off, so every fold over a pixel adds up.
644
+ *
645
+ * A stock override material could not stand in for this: its vertex stage knows nothing of the
646
+ * deformation and it culls back faces, so the count came out for the rest-pose plane — measured
647
+ * at a tenth of the real silhouette on the Liquid Glass preset. The WebGPU subclass builds the
648
+ * node twin from the same flags.
649
+ */
650
+ createLayerMaterial(sc, material) {
651
+ const main = material;
652
+ const defines = { ...main.defines ?? this.waveDefines(sc) };
653
+ delete defines.VERTEX_NORMAL;
654
+ return new THREE.ShaderMaterial({
655
+ uniforms: main.uniforms,
656
+ defines,
657
+ vertexShader,
658
+ fragmentShader: glassLayerFragmentShader,
659
+ transparent: true,
660
+ blending: THREE.AdditiveBlending,
661
+ depthTest: false,
662
+ depthWrite: false,
663
+ side: THREE.DoubleSide
664
+ });
665
+ }
666
+ /** What the layer companion was built against; when this changes, it is rebuilt. */
667
+ layerVariantKey(material) {
668
+ const defines = material.defines ?? {};
669
+ return Object.keys(defines).sort().join(",");
670
+ }
671
+ dropLayerMaterial(wave) {
672
+ if (!wave.layerMaterial) return;
673
+ wave.layerMaterial.dispose();
674
+ wave.layerMaterial = void 0;
675
+ wave.layerKey = void 0;
676
+ }
677
+ /**
530
678
  * Re-select this wave's shader variant when its config changes shape (theme, twist motion, helix,
531
679
  * …). Returns true if the material needs recompiling. The GLSL backend swaps `defines` and the
532
680
  * fragment source; the WebGPU subclass rebuilds the node graph, since a TSL variant is a
@@ -541,11 +689,12 @@ var WaveRenderer = class {
541
689
  material.defines = wantDefines;
542
690
  changed = true;
543
691
  }
544
- const wantFrag = sc.theme === "wireframe" ? lineFragmentShader : fragmentShader;
692
+ const wantFrag = fragmentFor(sc);
545
693
  if (material.fragmentShader !== wantFrag) {
546
694
  material.fragmentShader = wantFrag;
547
695
  changed = true;
548
696
  }
697
+ material.transparent = sc.theme !== "glass";
549
698
  return changed;
550
699
  }
551
700
  /**
@@ -571,6 +720,7 @@ var WaveRenderer = class {
571
720
  for (const s of this.waves) {
572
721
  this.group.remove(s.mesh);
573
722
  s.material.dispose();
723
+ s.layerMaterial?.dispose();
574
724
  s.geometry.dispose();
575
725
  s.palette.dispose();
576
726
  if (s.particleField) {
@@ -593,6 +743,7 @@ var WaveRenderer = class {
593
743
  if (!s) break;
594
744
  this.group.remove(s.mesh);
595
745
  s.material.dispose();
746
+ s.layerMaterial?.dispose();
596
747
  s.geometry.dispose();
597
748
  s.palette.dispose();
598
749
  if (s.particleField) {
@@ -663,10 +814,15 @@ var WaveRenderer = class {
663
814
  u.uLineAmount.value = sc.lineAmount ?? 425;
664
815
  u.uLineThickness.value = sc.lineThickness ?? 1;
665
816
  u.uLineDerivativePower.value = sc.lineDerivativePower ?? .95;
817
+ u.uLineDepthFade.value = sc.lineDepthFade ?? 1;
818
+ u.uLineSharpness.value = sc.lineSharpness ?? 0;
819
+ u.uLineGapOpacity.value = gapAlpha(sc.lineGapColor);
820
+ u.uLineLight.value = sc.lineLight ?? 0;
821
+ const wantDepthWrite = !(sc.theme === "wireframe" && gapAlpha(sc.lineGapColor) < 1);
822
+ if (wave.material.depthWrite !== wantDepthWrite) wave.material.depthWrite = wantDepthWrite;
666
823
  u.uRungAmount.value = sc.rungAmount ?? 0;
667
824
  u.uRungThickness.value = sc.rungThickness ?? 1;
668
- u.uMaxWidth.value = sc.maxWidth ?? 1232;
669
- hexToLinearVec3(this.config.background, u.uClearColor.value);
825
+ hexToLinearVec3(gapColorHex(sc.lineGapColor) ?? this.config.background, u.uClearColor.value);
670
826
  u.uFiberCount.value = sc.fiberCount;
671
827
  u.uFiberStrength.value = sc.fiberStrength;
672
828
  u.uTexture.value = sc.texture;
@@ -680,6 +836,32 @@ var WaveRenderer = class {
680
836
  hexToLinearVec3(sc.depthTintColor ?? "#0a2540", u.uDepthTintColor.value);
681
837
  u.uEdgeFade.value = sc.edgeFade;
682
838
  u.uEdgeFeather.value = sc.edgeFeather ?? .1;
839
+ if (sc.theme === "glass") {
840
+ hexToLinearVec3(this.config.background, u.uClearColor.value);
841
+ u.uGlassStrength.value = sc.glassStrength ?? 90;
842
+ u.uGlassChroma.value = sc.glassChroma ?? .7;
843
+ u.uGlassFrost.value = sc.glassFrost ?? .08;
844
+ u.uGlassSpec.value = sc.glassSpec ?? 1.2;
845
+ u.uGlassVibrancy.value = sc.glassVibrancy ?? .05;
846
+ u.uGlassTint.value = sc.glassTint ?? .12;
847
+ u.uGlassRimPower.value = sc.glassRimPower ?? 1.2;
848
+ u.uGlassRipple.value = sc.glassRipple ?? 0;
849
+ u.uGlassRippleScale.value = sc.glassRippleScale ?? .012;
850
+ {
851
+ const flow = sc.glassFlow ?? .9;
852
+ const loop = this.config.loopSeconds ?? 0;
853
+ u.uGlassFlow.value = loop > 0 && flow !== 0 ? Math.max(1, Math.round(Math.abs(flow) * loop / (Math.PI * 2))) * (Math.PI * 2) / loop / (flow < 0 ? -1 : 1) : flow;
854
+ }
855
+ u.uGlassPath.value = sc.glassPath ?? .45;
856
+ u.uGlassDensity.value = sc.glassDensity ?? 1.2;
857
+ u.uGlassRim.value = sc.glassRim ?? .5;
858
+ u.uGlassIrid.value = sc.glassIrid ?? 0;
859
+ u.uGlassFilmNm.value = sc.glassFilmNm ?? 380;
860
+ u.uGlassIor.value = sc.glassIor ?? 1.45;
861
+ u.uGlassLayerGain.value = sc.glassLayerGain ?? .6;
862
+ u.uGlassFusion.value = sc.glassFusion ?? 0;
863
+ u.uGlassCaustic.value = sc.glassCaustic ?? .4;
864
+ }
683
865
  const lights = this.config.lights ?? [];
684
866
  u.uAmbient.value = this.config.ambient ?? .45;
685
867
  u.uNumLights.value = Math.min(lights.length, 8);
@@ -724,12 +906,23 @@ var WaveRenderer = class {
724
906
  u.uHelixTurns.value = sc.helixTurns ?? 0;
725
907
  u.uHelixRadius.value = sc.helixRadius ?? 0;
726
908
  u.uHelixRoll.value = sc.helixRoll ?? 0;
909
+ this.syncPathTexture(wave, sc);
727
910
  u.uHelixPhase.value = sc.helixPhase ?? 0;
728
911
  u.uRadialAmount.value = sc.radialAmount ?? 0;
729
912
  u.uRadialArc.value = sc.radialArc ?? 160;
730
913
  u.uRadialSpread.value = sc.radialSpread ?? 1;
731
914
  u.uRadialRadius.value = sc.radialRadius ?? 40;
732
915
  u.uRadialCenter.value = sc.radialCenter ?? 0;
916
+ u.uRadialCone.value = sc.radialCone ?? 0;
917
+ u.uRadialSwirl.value = sc.radialSwirl ?? 0;
918
+ const dis = sc.dissolve;
919
+ u.uDissolveAmount.value = dis?.amount ?? 0;
920
+ u.uDissolveBand.value = dis?.band ?? .35;
921
+ u.uDissolveScale.value = dis?.scale ?? 90;
922
+ u.uDissolveBlocky.value = dis?.blocky ?? .6;
923
+ u.uDissolveAxis.value = DISSOLVE_AXIS_INDEX[dis?.axis ?? "length"] ?? 0;
924
+ u.uDissolveReverse.value = dis?.reverse ? 1 : 0;
925
+ u.uDissolveDust.value = dis?.dust ?? 1;
733
926
  wave.mesh.scale.set(sc.scale.x, sc.scale.y, sc.scale.z);
734
927
  wave.mesh.rotation.set(THREE.MathUtils.degToRad(sc.rotation.x), THREE.MathUtils.degToRad(sc.rotation.y), THREE.MathUtils.degToRad(sc.rotation.z));
735
928
  wave.mesh.position.set(sc.position.x, sc.position.y, sc.position.z);
@@ -1378,7 +1571,7 @@ var WaveRenderer = class {
1378
1571
  loop = () => {
1379
1572
  if (!this.running) return;
1380
1573
  this.timer.update();
1381
- const dt = this.timer.getDelta();
1574
+ const dt = Math.min(this.timer.getDelta(), MAX_FRAME_SECONDS);
1382
1575
  this.time += dt;
1383
1576
  this.interaction?.update(dt);
1384
1577
  if (this.introTimeRamp < 1) this.introTimeRamp = Math.min(1, this.introTimeRamp + .016);
@@ -1406,8 +1599,144 @@ var WaveRenderer = class {
1406
1599
  }
1407
1600
  /** Draw the composed frame. WebGL runs the EffectComposer; WebGPU runs a node post chain. */
1408
1601
  renderComposed() {
1602
+ this.renderGlassPasses();
1409
1603
  this.composer.render();
1410
1604
  }
1605
+ /** Capture what sits BEHIND the glass waves, so they have something to bend.
1606
+ *
1607
+ * Glass is drawn opaque and fakes its see-through by sampling this texture, which is why
1608
+ * overlapping sheets need no sorting. The pass is skipped entirely when no wave asks for it, so
1609
+ * a scene without glass renders exactly as before.
1610
+ *
1611
+ * The texture MUST be unbound while we render into it: binding a target as a texture while it is
1612
+ * the render target is a framebuffer feedback loop, and the frame is undefined. */
1613
+ /** The world axis from a surface toward the camera. Constant under an orthographic projection,
1614
+ * which is exactly why it is a uniform rather than something the shader derives per fragment. */
1615
+ pushViewAxis() {
1616
+ const fwd = this.camera.getWorldDirection(this.viewAxisTmp).multiplyScalar(-1);
1617
+ for (const w of this.waves) w.material.uniforms.uViewAxis.value.copy(fwd);
1618
+ }
1619
+ viewAxisTmp = new THREE.Vector3();
1620
+ empty;
1621
+ backdropClear = new THREE.Color();
1622
+ prevClear = new THREE.Color();
1623
+ depthTmp = new THREE.Vector3();
1624
+ sizeTmp = new THREE.Vector2();
1625
+ /** Pin a capture's sampling so both backends read it the same way. Left to their defaults the
1626
+ * two disagree about filtering and edge behaviour, and a glass sheet samples FAR from the
1627
+ * fragment — one offset per channel — so the disagreement shows up as a red/blue bias with green
1628
+ * untouched, which looks like a dispersion bug and is not one. */
1629
+ pinSampling(t) {
1630
+ t.texture.minFilter = THREE.LinearFilter;
1631
+ t.texture.magFilter = THREE.LinearFilter;
1632
+ t.texture.wrapS = THREE.ClampToEdgeWrapping;
1633
+ t.texture.wrapT = THREE.ClampToEdgeWrapping;
1634
+ t.texture.generateMipmaps = false;
1635
+ return t;
1636
+ }
1637
+ /** A 1x1 transparent texture, used wherever a sampler must be BOUND but must contribute nothing. */
1638
+ emptyTexture() {
1639
+ if (!this.empty) {
1640
+ this.empty = new THREE.DataTexture(new Uint8Array([
1641
+ 0,
1642
+ 0,
1643
+ 0,
1644
+ 0
1645
+ ]), 1, 1);
1646
+ this.empty.needsUpdate = true;
1647
+ }
1648
+ return this.empty;
1649
+ }
1650
+ renderGlassPasses() {
1651
+ this.pushViewAxis();
1652
+ const glassIdx = [];
1653
+ for (let i = 0; i < this.waves.length; i++) if ((this.config.waves[i] ?? this.config.waves[this.config.waves.length - 1])?.theme === "glass") glassIdx.push(i);
1654
+ if (glassIdx.length === 0) {
1655
+ if (this.backdropTarget) for (const w of this.waves) w.material.uniforms.uBackdrop.value = null;
1656
+ for (const w of this.waves) this.dropLayerMaterial(w);
1657
+ return;
1658
+ }
1659
+ this.scene.updateMatrixWorld();
1660
+ const axis = this.viewAxisTmp;
1661
+ const depthOf = (i) => this.waves[i].mesh.getWorldPosition(this.depthTmp).dot(axis);
1662
+ const glassDepth = glassIdx.map(depthOf);
1663
+ const excluded = [];
1664
+ for (let i = 0; i < this.waves.length; i++) {
1665
+ const d = depthOf(i);
1666
+ const inFront = glassIdx.some((g, k) => {
1667
+ const dd = d - glassDepth[k];
1668
+ return dd > .001 || Math.abs(dd) <= .001 && i > g;
1669
+ });
1670
+ if (glassIdx.includes(i) || inFront) excluded.push(this.waves[i].mesh);
1671
+ }
1672
+ const size = this.renderer.getDrawingBufferSize(this.sizeTmp);
1673
+ if (!this.backdropTarget) this.backdropTarget = this.pinSampling(new THREE.WebGLRenderTarget(size.x, size.y, {
1674
+ depthBuffer: true,
1675
+ stencilBuffer: false
1676
+ }));
1677
+ else if (this.backdropTarget.width !== size.x || this.backdropTarget.height !== size.y) this.backdropTarget.setSize(size.x, size.y);
1678
+ const prevTarget = this.renderer.getRenderTarget();
1679
+ const prevClearAlpha = this.renderer.getClearAlpha();
1680
+ this.renderer.getClearColor(this.prevClear);
1681
+ const prevBg = this.scene.background;
1682
+ const restore = () => {
1683
+ this.renderer.setRenderTarget(prevTarget);
1684
+ this.renderer.setClearColor(this.prevClear, prevClearAlpha);
1685
+ this.scene.background = prevBg;
1686
+ };
1687
+ for (const w of this.waves) w.material.uniforms.uBackdrop.value = this.emptyTexture();
1688
+ for (const m of excluded) m.visible = false;
1689
+ this.renderer.setRenderTarget(this.backdropTarget);
1690
+ this.backdropClear.set(this.config.background || "#ffffff");
1691
+ this.renderer.setClearColor(this.backdropClear, 1);
1692
+ this.renderer.clear(true, true, false);
1693
+ this.renderer.render(this.scene, this.camera);
1694
+ for (const m of excluded) m.visible = true;
1695
+ for (const w of this.waves) w.material.uniforms.uBackdrop.value = this.backdropTarget.texture;
1696
+ const hidden = [];
1697
+ const hide = (o) => {
1698
+ if (o && o.visible) {
1699
+ o.visible = false;
1700
+ hidden.push(o);
1701
+ }
1702
+ };
1703
+ for (let i = 0; i < this.waves.length; i++) {
1704
+ if (!glassIdx.includes(i)) hide(this.waves[i].mesh);
1705
+ hide(this.waves[i].particleField?.object);
1706
+ }
1707
+ this.scene.background = null;
1708
+ if (!this.layerTarget) this.layerTarget = this.pinSampling(new THREE.WebGLRenderTarget(size.x, size.y, {
1709
+ depthBuffer: false,
1710
+ stencilBuffer: false
1711
+ }));
1712
+ else if (this.layerTarget.width !== size.x || this.layerTarget.height !== size.y) this.layerTarget.setSize(size.x, size.y);
1713
+ for (const w of this.waves) w.material.uniforms.uLayers.value = this.emptyTexture();
1714
+ const swapped = [];
1715
+ for (let i = 0; i < this.waves.length; i++) {
1716
+ const w = this.waves[i];
1717
+ if (!glassIdx.includes(i)) {
1718
+ this.dropLayerMaterial(w);
1719
+ continue;
1720
+ }
1721
+ const sc = this.config.waves[i] ?? this.config.waves[this.config.waves.length - 1];
1722
+ const key = this.layerVariantKey(w.material);
1723
+ if (w.layerMaterial && w.layerKey !== key) this.dropLayerMaterial(w);
1724
+ if (!w.layerMaterial) {
1725
+ w.layerMaterial = this.createLayerMaterial(sc, w.material);
1726
+ w.layerKey = key;
1727
+ }
1728
+ w.mesh.material = w.layerMaterial;
1729
+ swapped.push(w);
1730
+ }
1731
+ this.renderer.setRenderTarget(this.layerTarget);
1732
+ this.renderer.setClearColor(0, 0);
1733
+ this.renderer.clear(true, false, false);
1734
+ this.renderer.render(this.scene, this.camera);
1735
+ for (const w of swapped) w.mesh.material = w.material;
1736
+ for (const w of this.waves) w.material.uniforms.uLayers.value = this.layerTarget.texture;
1737
+ for (const o of hidden) o.visible = true;
1738
+ restore();
1739
+ }
1411
1740
  /** Resize the post chain's render targets. */
1412
1741
  resizePost(w, h, dpr) {
1413
1742
  this.composer.setPixelRatio(dpr);
@@ -1416,6 +1745,12 @@ var WaveRenderer = class {
1416
1745
  /** Release the post chain's GPU resources. */
1417
1746
  disposePost() {
1418
1747
  this.composer.dispose();
1748
+ this.backdropTarget?.dispose();
1749
+ this.backdropTarget = void 0;
1750
+ this.layerTarget?.dispose();
1751
+ this.layerTarget = void 0;
1752
+ this.empty?.dispose();
1753
+ this.empty = void 0;
1419
1754
  }
1420
1755
  /** Render exactly one frame at the current time. */
1421
1756
  renderOnce() {
@@ -1501,8 +1836,10 @@ var WaveRenderer = class {
1501
1836
  "HELIX",
1502
1837
  "TWIST_MOTION",
1503
1838
  "RADIAL",
1839
+ "PATH",
1504
1840
  "POINTER_FX",
1505
- "POINTER_RIPPLES"
1841
+ "POINTER_RIPPLES",
1842
+ "DISSOLVE"
1506
1843
  ]) if (k in all) out[k] = "";
1507
1844
  return out;
1508
1845
  }
@@ -1892,9 +2229,11 @@ var WaveRenderer = class {
1892
2229
  this.backgroundTexture?.dispose();
1893
2230
  for (const s of this.waves) {
1894
2231
  s.material.dispose();
2232
+ s.layerMaterial?.dispose();
1895
2233
  s.geometry.dispose();
1896
2234
  s.palette.dispose();
1897
2235
  s.particleField?.dispose();
2236
+ s.pathTexture?.dispose();
1898
2237
  }
1899
2238
  this.bloomPass?.dispose();
1900
2239
  this.ditherPass?.dispose();