@wave3d/core 0.1.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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/dist/_virtual/_rolldown/runtime.js +13 -0
  4. package/dist/config/model.d.ts +240 -0
  5. package/dist/config/model.js +496 -0
  6. package/dist/config/model.js.map +1 -0
  7. package/dist/core-loader.d.ts +10 -0
  8. package/dist/core-loader.js +12 -0
  9. package/dist/core-loader.js.map +1 -0
  10. package/dist/index.d.ts +4 -0
  11. package/dist/index.js +3 -0
  12. package/dist/presets.d.ts +8 -0
  13. package/dist/presets.js +544 -0
  14. package/dist/presets.js.map +1 -0
  15. package/dist/renderer/WaveGeometry.d.ts +31 -0
  16. package/dist/renderer/WaveGeometry.js +92 -0
  17. package/dist/renderer/WaveGeometry.js.map +1 -0
  18. package/dist/renderer/WaveRenderer.d.ts +232 -0
  19. package/dist/renderer/WaveRenderer.js +1118 -0
  20. package/dist/renderer/WaveRenderer.js.map +1 -0
  21. package/dist/renderer/heroPalette.d.ts +10 -0
  22. package/dist/renderer/heroPalette.js +34 -0
  23. package/dist/renderer/heroPalette.js.map +1 -0
  24. package/dist/renderer/index.d.ts +4 -0
  25. package/dist/renderer/index.js +4 -0
  26. package/dist/renderer/palette.d.ts +67 -0
  27. package/dist/renderer/palette.js +535 -0
  28. package/dist/renderer/palette.js.map +1 -0
  29. package/dist/renderer/shaders.js +545 -0
  30. package/dist/renderer/shaders.js.map +1 -0
  31. package/dist/shell/createWave.d.ts +58 -0
  32. package/dist/shell/createWave.js +161 -0
  33. package/dist/shell/createWave.js.map +1 -0
  34. package/dist/shell/poster.js +64 -0
  35. package/dist/shell/poster.js.map +1 -0
  36. package/dist/shell/probe.js +34 -0
  37. package/dist/shell/probe.js.map +1 -0
  38. package/dist/standalone/wave3d.standalone.js +13507 -0
  39. package/dist/standalone.d.ts +13 -0
  40. package/dist/standalone.js +17 -0
  41. package/dist/standalone.js.map +1 -0
  42. package/dist/studio/StudioWaveRenderer.d.ts +187 -0
  43. package/dist/studio/StudioWaveRenderer.js +905 -0
  44. package/dist/studio/StudioWaveRenderer.js.map +1 -0
  45. package/dist/studio/index.d.ts +3 -0
  46. package/dist/studio/index.js +3 -0
  47. package/dist/studio/randomize.d.ts +28 -0
  48. package/dist/studio/randomize.js +264 -0
  49. package/dist/studio/randomize.js.map +1 -0
  50. package/dist/util/base64.js +14 -0
  51. package/dist/util/base64.js.map +1 -0
  52. package/dist/util/math.js +18 -0
  53. package/dist/util/math.js.map +1 -0
  54. package/package.json +76 -0
  55. package/skills/wave3d/SKILL.md +158 -0
@@ -0,0 +1,1118 @@
1
+ import { ensureStudioConfig } from "../config/model.js";
2
+ import { fragmentShader, lineFragmentShader, postFragmentShader, postVertexShader, vertexShader } from "./shaders.js";
3
+ import { WaveGeometry } from "./WaveGeometry.js";
4
+ import { PALETTE_MAPS, buildBackgroundGradientCanvas, buildBackgroundImageCanvas, buildBackgroundMeshCanvas, buildPaletteTexture, canvasToTexture, configurePaletteTexture, drawBackgroundMediaFrame, loadPaletteImage, paletteMapCanvas, paletteSignature } from "./palette.js";
5
+ import { buildHeroPaletteCanvas, buildHeroPaletteTexture } from "./heroPalette.js";
6
+ import * as THREE from "three";
7
+ import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
8
+ import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
9
+ import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
10
+ import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
11
+ import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
12
+ //#region src/renderer/WaveRenderer.ts
13
+ const BASE_SEGMENTS = 220;
14
+ /** Reference frame (world units) the orthographic camera fills at cameraZoom 1. The wave is
15
+ * framed by COVERING this FRAME_W × FRAME_H rectangle (centred on cameraTarget) into the canvas
16
+ * — scaled to fill both dimensions, cropping the aspect overflow — so a given cameraZoom /
17
+ * cameraTarget frames the wave the SAME at any canvas size or aspect (only the cropped margin
18
+ * differs). FRAME_H = FRAME_W / (16/9) makes the reference a 16:9 rectangle; for canvases wider
19
+ * than that the width binds, narrower ones zoom in to fill instead of
20
+ * showing empty bands. This is what makes a saved preset reproduce on anyone's screen. */
21
+ const FRAME_W = 1333;
22
+ const FRAME_H = 750;
23
+ /**
24
+ * Per-wave 2D palette texture (+ optional looping video). One instance per wave, so each
25
+ * wave carries its own palette. Guarded by a signature so it only rebuilds when that wave's
26
+ * palette actually changes (not every refresh).
27
+ */
28
+ var WavePalette = class {
29
+ makeVideo;
30
+ onVideoReady;
31
+ texture;
32
+ sig = "";
33
+ video;
34
+ videoUrl = "";
35
+ failedUrl = "";
36
+ constructor(makeVideo, onVideoReady) {
37
+ this.makeVideo = makeVideo;
38
+ this.onVideoReady = onVideoReady;
39
+ }
40
+ /** Rebuild this wave's palette texture from its config (video / custom image / stops /
41
+ * built-in map / hero LUT) and point the wave's palette uniforms at it. */
42
+ apply(cfg, uniforms) {
43
+ const videoUrl = cfg.paletteVideoUrl;
44
+ const url = cfg.paletteImageUrl;
45
+ const source = cfg.paletteSource ?? "hero";
46
+ let sig;
47
+ let build;
48
+ if (videoUrl) {
49
+ this.ensureVideo(videoUrl);
50
+ if (this.video?.readyState && this.video.readyState >= 2) {
51
+ sig = "video|" + videoUrl;
52
+ build = () => configurePaletteTexture(new THREE.VideoTexture(this.video));
53
+ } else {
54
+ sig = "video-loading|" + videoUrl;
55
+ build = () => buildHeroPaletteTexture();
56
+ }
57
+ } else if (url) {
58
+ this.clearVideo();
59
+ sig = "url|" + url;
60
+ build = () => loadPaletteImage(url);
61
+ } else if (source === "stops") {
62
+ this.clearVideo();
63
+ const opts = {
64
+ stops: cfg.palette,
65
+ edgeColor: cfg.paletteEdgeColor ?? "#8e9dff",
66
+ edgeAmount: cfg.paletteEdgeAmount ?? .3
67
+ };
68
+ sig = "stops|" + paletteSignature(opts);
69
+ build = () => buildPaletteTexture(opts);
70
+ } else if (PALETTE_MAPS[source]) {
71
+ this.clearVideo();
72
+ sig = "map|" + source;
73
+ build = () => canvasToTexture(paletteMapCanvas(PALETTE_MAPS[source]));
74
+ } else {
75
+ this.clearVideo();
76
+ sig = "hero";
77
+ build = () => buildHeroPaletteTexture();
78
+ }
79
+ if (sig !== this.sig || !this.texture) {
80
+ this.texture?.dispose();
81
+ this.texture = build();
82
+ this.sig = sig;
83
+ }
84
+ uniforms.uPalette.value = this.texture;
85
+ uniforms.uUsePalette.value = cfg.usePaletteTexture === false ? 0 : 1;
86
+ uniforms.uPaletteRaw.value = 1;
87
+ }
88
+ /** Play/pause this wave's palette video with the render loop. */
89
+ syncPlayback(cfg, running) {
90
+ const v = this.video;
91
+ if (!v) return;
92
+ if (running && cfg.gradientType !== "mesh" && cfg.usePaletteTexture !== false && !!cfg.paletteVideoUrl) v.play().catch(() => {});
93
+ else v.pause();
94
+ }
95
+ ensureVideo(url) {
96
+ if (this.videoUrl === url || this.failedUrl === url) return;
97
+ this.clearVideo();
98
+ this.videoUrl = url;
99
+ const video = this.makeVideo(url);
100
+ video.addEventListener("loadeddata", () => {
101
+ this.video = video;
102
+ this.failedUrl = "";
103
+ this.sig = "";
104
+ this.onVideoReady();
105
+ }, { once: true });
106
+ video.addEventListener("error", () => {
107
+ this.failedUrl = url;
108
+ this.sig = "";
109
+ this.onVideoReady();
110
+ }, { once: true });
111
+ this.video = video;
112
+ video.load();
113
+ }
114
+ clearVideo() {
115
+ if (this.video) {
116
+ this.video.pause();
117
+ this.video.removeAttribute("src");
118
+ this.video.load();
119
+ }
120
+ this.video = void 0;
121
+ this.videoUrl = "";
122
+ }
123
+ dispose() {
124
+ this.texture?.dispose();
125
+ this.texture = void 0;
126
+ this.clearVideo();
127
+ this.failedUrl = "";
128
+ }
129
+ };
130
+ const HEX_SCRATCH = new THREE.Color();
131
+ /** Convert an sRGB hex string to a linear-space RGB vector (three's ColorManagement does the
132
+ * sRGB→linear conversion on parse). Exported for the studio subclass's live light-uniform push. */
133
+ function hexToLinearVec3(hex, target) {
134
+ const c = HEX_SCRATCH.set(hex);
135
+ return target.set(c.r, c.g, c.b);
136
+ }
137
+ /**
138
+ * Renders a gradient "wave of light" from a {@link StudioConfig}. Framework-agnostic:
139
+ * it needs only a DOM container and a config. The studio mutates the config in
140
+ * place and calls `refresh()` / `rebuild()`.
141
+ */
142
+ var WaveRenderer = class {
143
+ renderer;
144
+ scene = new THREE.Scene();
145
+ camera;
146
+ group = new THREE.Group();
147
+ composer;
148
+ postPass;
149
+ /** Optional bloom pass — created lazily when bloomStrength first goes >0, removed at 0. */
150
+ bloomPass;
151
+ container;
152
+ respectReducedMotion;
153
+ skipIntroRamp;
154
+ config;
155
+ waves = [];
156
+ backgroundTexture;
157
+ backgroundSig = "";
158
+ backgroundImage;
159
+ backgroundImageUrl = "";
160
+ failedBackgroundImageUrl = "";
161
+ backgroundVideo;
162
+ backgroundVideoUrl = "";
163
+ failedBackgroundVideoUrl = "";
164
+ backgroundVideoCanvas;
165
+ /** Authored default camera pose, for "Reset camera". */
166
+ homeCamPos = new THREE.Vector3();
167
+ homeCamTarget = new THREE.Vector3();
168
+ clipBox = new THREE.Box3();
169
+ clipSphere = new THREE.Sphere();
170
+ clipTmpA = new THREE.Vector3();
171
+ clipTmpB = new THREE.Vector3();
172
+ clock = new THREE.Clock();
173
+ time = 0;
174
+ rafId = 0;
175
+ running = false;
176
+ started = false;
177
+ visible = true;
178
+ pageVisible = true;
179
+ reducedMotion = false;
180
+ /** Intro ramp: eases animation time 0→1 over ~1s on load (when config.introRamp). */
181
+ introTimeRamp = 0;
182
+ resizeObserver;
183
+ intersectionObserver;
184
+ motionQuery;
185
+ capturing = false;
186
+ /** Fixed backing-buffer dimensions used by the studio's visible export frame. Embeds leave
187
+ * this unset and continue to resize responsively with their container and device DPR. */
188
+ outputSize;
189
+ constructor(container, config, options = {}) {
190
+ this.container = container;
191
+ this.config = ensureStudioConfig(config);
192
+ this.respectReducedMotion = options.respectReducedMotion ?? true;
193
+ this.skipIntroRamp = options.skipIntroRamp ?? false;
194
+ this.renderer = new THREE.WebGLRenderer({
195
+ antialias: true,
196
+ alpha: true,
197
+ preserveDrawingBuffer: true,
198
+ powerPreference: "high-performance"
199
+ });
200
+ this.renderer.outputColorSpace = THREE.SRGBColorSpace;
201
+ this.renderer.setClearColor(0, 0);
202
+ container.appendChild(this.renderer.domElement);
203
+ this.renderer.domElement.addEventListener("webglcontextlost", this.onContextLost, false);
204
+ this.renderer.domElement.addEventListener("webglcontextrestored", this.onContextRestored, false);
205
+ this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 1, 1e4);
206
+ this.camera.position.set(config.cameraPosition.x, config.cameraPosition.y, config.cameraPosition.z);
207
+ this.camera.zoom = config.cameraZoom ?? 1;
208
+ this.camera.lookAt(config.cameraTarget.x, config.cameraTarget.y, config.cameraTarget.z);
209
+ this.camera.updateProjectionMatrix();
210
+ this.homeCamPos.copy(this.camera.position);
211
+ this.homeCamTarget.set(config.cameraTarget.x, config.cameraTarget.y, config.cameraTarget.z);
212
+ this.scene.add(this.group);
213
+ this.composer = new EffectComposer(this.renderer);
214
+ this.composer.addPass(new RenderPass(this.scene, this.camera));
215
+ this.postPass = new ShaderPass({
216
+ uniforms: {
217
+ tDiffuse: { value: null },
218
+ uResolution: { value: new THREE.Vector2(1, 1) },
219
+ uBlurAmount: { value: config.blur },
220
+ uBlurSamples: { value: 6 },
221
+ uGrainAmount: { value: config.grain },
222
+ uTime: { value: 0 }
223
+ },
224
+ vertexShader: postVertexShader,
225
+ fragmentShader: postFragmentShader
226
+ });
227
+ this.composer.addPass(this.postPass);
228
+ this.composer.addPass(new OutputPass());
229
+ this.motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
230
+ this.reducedMotion = this.respectReducedMotion && this.motionQuery.matches;
231
+ this.motionQuery.addEventListener("change", this.onMotionChange);
232
+ document.addEventListener("visibilitychange", this.onVisibilityChange);
233
+ this.intersectionObserver = new IntersectionObserver((entries) => {
234
+ this.visible = entries[0]?.isIntersecting ?? true;
235
+ this.updateRunning();
236
+ }, { rootMargin: "100px" });
237
+ this.intersectionObserver.observe(container);
238
+ this.resizeObserver = new ResizeObserver(this.onResize);
239
+ this.resizeObserver.observe(container);
240
+ this.applyBackground();
241
+ this.buildWaves();
242
+ this.resize();
243
+ }
244
+ get segments() {
245
+ const q = this.config.quality / Math.sqrt(Math.max(1, this.config.waves.length));
246
+ return THREE.MathUtils.clamp(Math.round(BASE_SEGMENTS * q), 24, 360);
247
+ }
248
+ makeUniforms() {
249
+ const colors = [];
250
+ const colorPos = [];
251
+ for (let i = 0; i < 8; i++) {
252
+ colors.push(new THREE.Vector3(1, 1, 1));
253
+ colorPos.push(i / 7);
254
+ }
255
+ const meshPointPos = [];
256
+ const meshPointColor = [];
257
+ const meshPointInfluence = [];
258
+ for (let i = 0; i < 8; i++) {
259
+ meshPointPos.push(new THREE.Vector2(.5, .5));
260
+ meshPointColor.push(new THREE.Vector3(1, 1, 1));
261
+ meshPointInfluence.push(.65);
262
+ }
263
+ const lightPos = [];
264
+ const lightColor = [];
265
+ const lightIntensity = [];
266
+ for (let i = 0; i < 8; i++) {
267
+ lightPos.push(new THREE.Vector3());
268
+ lightColor.push(new THREE.Vector3(1, 1, 1));
269
+ lightIntensity.push(0);
270
+ }
271
+ const bandBounds = [];
272
+ const bandParams = [];
273
+ const bandParaPow = [];
274
+ for (let i = 0; i < 4; i++) {
275
+ bandBounds.push(new THREE.Vector4());
276
+ bandParams.push(new THREE.Vector4());
277
+ bandParaPow.push(0);
278
+ }
279
+ return {
280
+ uTime: { value: 0 },
281
+ uSpeed: { value: .05 },
282
+ uSeed: { value: 0 },
283
+ uDispFreqX: { value: .003234 },
284
+ uDispFreqZ: { value: .00799 },
285
+ uDispAmount: { value: 6.051 },
286
+ uDetailFreq: { value: .04 },
287
+ uDetailAmount: { value: 0 },
288
+ uTwFreqX: { value: -.055 },
289
+ uTwFreqY: { value: .077 },
290
+ uTwFreqZ: { value: -.518 },
291
+ uTwPowX: { value: 3.95 },
292
+ uTwPowY: { value: 5.85 },
293
+ uTwPowZ: { value: 6.33 },
294
+ uLoopSeconds: { value: 0 },
295
+ uColors: { value: colors },
296
+ uColorPos: { value: colorPos },
297
+ uColorCount: { value: 2 },
298
+ uGradType: { value: 0 },
299
+ uGradAngle: { value: 0 },
300
+ uGradShift: { value: .15 },
301
+ uMeshPointPos: { value: meshPointPos },
302
+ uMeshPointColor: { value: meshPointColor },
303
+ uMeshPointInfluence: { value: meshPointInfluence },
304
+ uMeshPointCount: { value: 0 },
305
+ uMeshSoftness: { value: .62 },
306
+ uPalette: { value: null },
307
+ uUsePalette: { value: 1 },
308
+ uPaletteRaw: { value: 1 },
309
+ uPaletteScale: { value: new THREE.Vector2(1, 1) },
310
+ uPaletteOffset: { value: new THREE.Vector2(0, 0) },
311
+ uPaletteRotation: { value: 0 },
312
+ uDebug: { value: 0 },
313
+ uSheen: { value: 1 },
314
+ uRoundness: { value: .35 },
315
+ uIridescence: { value: 0 },
316
+ uDepthTint: { value: 0 },
317
+ uDepthTintColor: { value: new THREE.Vector3() },
318
+ uHueShift: { value: 0 },
319
+ uContrast: { value: 1 },
320
+ uSaturation: { value: 1 },
321
+ uFiberCount: { value: 90 },
322
+ uFiberStrength: { value: .25 },
323
+ uTexture: { value: 0 },
324
+ uCreaseLight: { value: .15 },
325
+ uCreaseSharpness: { value: 2 },
326
+ uCreaseSoftness: { value: 1 },
327
+ uEdgeFade: { value: .06 },
328
+ uEdgeFeather: { value: .1 },
329
+ uOpacity: { value: 1 },
330
+ uSquared: { value: 1 },
331
+ uResolution: { value: new THREE.Vector2(1, 1) },
332
+ uAmbient: { value: .45 },
333
+ uNumLights: { value: 1 },
334
+ uLightPos: { value: lightPos },
335
+ uLightColor: { value: lightColor },
336
+ uLightIntensity: { value: lightIntensity },
337
+ uNumNoiseBands: { value: 0 },
338
+ uNoiseBandBounds: { value: bandBounds },
339
+ uNoiseBandParams: { value: bandParams },
340
+ uNoiseBandParaPow: { value: bandParaPow },
341
+ uLineAmount: { value: 425 },
342
+ uLineThickness: { value: 1 },
343
+ uLineDerivativePower: { value: .95 },
344
+ uMaxWidth: { value: 1232 },
345
+ uClearColor: { value: new THREE.Vector3(1, 1, 1) }
346
+ };
347
+ }
348
+ /** Vertex-shader #defines for a wave: TWIST_MOTION (per-wave animated twist wobble) and
349
+ * LOOP_MOTION (scene-level seamless loop). Both select #ifdef-gated code paths; an empty
350
+ * object compiles the default (linear-time) program. */
351
+ waveDefines(sc) {
352
+ const defines = {};
353
+ if (sc?.twistMotion) defines.TWIST_MOTION = "";
354
+ if ((this.config.loopSeconds ?? 0) > 0) defines.LOOP_MOTION = "";
355
+ if ((sc?.detailAmount ?? 0) !== 0) defines.DETAIL_OCTAVE = "";
356
+ if ((sc?.depthTint ?? 0) > 0) defines.DEPTH_TINT = "";
357
+ if ((sc?.edgeFeather ?? .1) !== .1) defines.EDGE_FEATHER = "";
358
+ return defines;
359
+ }
360
+ addWave() {
361
+ const geo = new WaveGeometry(this.segments);
362
+ const sc = this.config.waves[this.waves.length] ?? this.config.waves[0];
363
+ const material = new THREE.ShaderMaterial({
364
+ uniforms: this.makeUniforms(),
365
+ defines: this.waveDefines(sc),
366
+ vertexShader,
367
+ fragmentShader: sc?.theme === "wireframe" ? lineFragmentShader : fragmentShader,
368
+ transparent: true,
369
+ depthTest: true,
370
+ depthWrite: true,
371
+ side: THREE.DoubleSide
372
+ });
373
+ this.applyBlendMode(material, sc?.blendMode ?? "squared");
374
+ const mesh = new THREE.Mesh(geo.geometry, material);
375
+ mesh.frustumCulled = false;
376
+ this.group.add(mesh);
377
+ const palette = new WavePalette((url) => this.createLoopingVideo(url), () => {
378
+ this.updatePaletteTextures();
379
+ this.syncVideoPlayback();
380
+ if (!this.running) this.renderOnce();
381
+ });
382
+ this.waves.push({
383
+ mesh,
384
+ material,
385
+ geometry: geo,
386
+ palette
387
+ });
388
+ }
389
+ /**
390
+ * Apply config.blendMode to a material. "squared" (the default) is the hero blend:
391
+ * CustomBlending with AddEquation, src = SrcColorFactor, dst = ZeroFactor, so the
392
+ * framebuffer result is fragColor² — the squaring deepens the colours into the vivid
393
+ * hero look (without it the wave reads pastel). "additive"/"normal"/"multiply" are
394
+ * authoring overrides. Multiply uses Three's premultiplied-alpha path; the custom
395
+ * fragment shaders premultiply their output when Three injects PREMULTIPLIED_ALPHA.
396
+ * Returns true if material state changed (caller flags needsUpdate).
397
+ */
398
+ applyBlendMode(material, mode) {
399
+ const squared = mode === "squared";
400
+ const blending = mode === "additive" ? THREE.AdditiveBlending : mode === "multiply" ? THREE.MultiplyBlending : THREE.NormalBlending;
401
+ const premultipliedAlpha = mode === "multiply" || squared;
402
+ material.uniforms.uSquared.value = squared ? 1 : 0;
403
+ if (material.blending === blending && material.premultipliedAlpha === premultipliedAlpha) return false;
404
+ material.blending = blending;
405
+ material.premultipliedAlpha = premultipliedAlpha;
406
+ return true;
407
+ }
408
+ disposeWaves() {
409
+ for (const s of this.waves) {
410
+ this.group.remove(s.mesh);
411
+ s.material.dispose();
412
+ s.geometry.dispose();
413
+ s.palette.dispose();
414
+ }
415
+ this.waves = [];
416
+ }
417
+ /**
418
+ * Reconcile the wave pool to `waveCount` WITHOUT tearing everything down:
419
+ * keep existing waves (so the compiled shader program is never deleted and
420
+ * re-compiled — that churn can crash some GPU drivers), add/remove only the
421
+ * delta, and resize each geometry to the current quality.
422
+ */
423
+ buildWaves() {
424
+ const target = Math.max(1, this.config.waves.length);
425
+ while (this.waves.length > target) {
426
+ const s = this.waves.pop();
427
+ if (!s) break;
428
+ this.group.remove(s.mesh);
429
+ s.material.dispose();
430
+ s.geometry.dispose();
431
+ s.palette.dispose();
432
+ }
433
+ while (this.waves.length < target) this.addWave();
434
+ const segments = this.segments;
435
+ this.waves.forEach((s, i) => {
436
+ s.geometry.resize(segments);
437
+ s.mesh.renderOrder = i;
438
+ });
439
+ this.refresh();
440
+ }
441
+ /** Re-read per-frame-independent values from the (mutated) config. */
442
+ refresh() {
443
+ this.applyBackground();
444
+ this.applyPost();
445
+ if (!this.isCameraExternallyDriven()) {
446
+ const p = this.config.cameraPosition;
447
+ const tg = this.config.cameraTarget;
448
+ this.camera.position.set(p.x, p.y, p.z);
449
+ this.camera.lookAt(tg.x, tg.y, tg.z);
450
+ }
451
+ this.waves.forEach((wave, i) => {
452
+ const sc = this.config.waves[i] ?? this.config.waves[this.config.waves.length - 1];
453
+ const u = wave.material.uniforms;
454
+ if (this.applyBlendMode(wave.material, sc.blendMode)) wave.material.needsUpdate = true;
455
+ const wantDefines = this.waveDefines(sc);
456
+ const curDefines = wave.material.defines ?? {};
457
+ if (Object.keys(wantDefines).sort().join(",") !== Object.keys(curDefines).sort().join(",")) {
458
+ wave.material.defines = wantDefines;
459
+ wave.material.needsUpdate = true;
460
+ }
461
+ const wantFrag = sc.theme === "wireframe" ? lineFragmentShader : fragmentShader;
462
+ if (wave.material.fragmentShader !== wantFrag) {
463
+ wave.material.fragmentShader = wantFrag;
464
+ wave.material.needsUpdate = true;
465
+ }
466
+ const stops = [...sc.palette].sort((a, b) => a.pos - b.pos);
467
+ const colorCount = Math.max(1, Math.min(stops.length, 8));
468
+ const colors = u.uColors.value;
469
+ const colorPos = u.uColorPos.value;
470
+ for (let c = 0; c < 8; c++) {
471
+ const stop = stops[Math.min(c, colorCount - 1)] ?? {
472
+ color: "#ffffff",
473
+ pos: 0
474
+ };
475
+ hexToLinearVec3(stop.color, colors[c]);
476
+ colorPos[c] = stop.pos;
477
+ }
478
+ u.uColorCount.value = colorCount;
479
+ u.uGradType.value = sc.gradientType === "radial" ? 1 : sc.gradientType === "conic" ? 2 : sc.gradientType === "mesh" ? 3 : 0;
480
+ u.uGradAngle.value = (sc.gradientAngle ?? 0) * Math.PI / 180;
481
+ u.uGradShift.value = sc.gradientShift ?? 0;
482
+ const meshPoints = sc.meshGradientPoints.slice(0, 8);
483
+ const meshPointPos = u.uMeshPointPos.value;
484
+ const meshPointColor = u.uMeshPointColor.value;
485
+ const meshPointInfluence = u.uMeshPointInfluence.value;
486
+ for (let pointIndex = 0; pointIndex < 8; pointIndex++) {
487
+ const point = meshPoints[pointIndex] ?? meshPoints[meshPoints.length - 1];
488
+ if (!point) continue;
489
+ meshPointPos[pointIndex].set(point.x, point.y);
490
+ hexToLinearVec3(point.color, meshPointColor[pointIndex]);
491
+ meshPointInfluence[pointIndex] = point.influence;
492
+ }
493
+ u.uMeshPointCount.value = meshPoints.length;
494
+ u.uMeshSoftness.value = sc.meshGradientSoftness;
495
+ u.uPaletteScale.value.set(sc.paletteTextureScale?.x ?? 1, sc.paletteTextureScale?.y ?? 1);
496
+ u.uPaletteOffset.value.set(sc.paletteTextureOffset?.x ?? 0, sc.paletteTextureOffset?.y ?? 0);
497
+ u.uPaletteRotation.value = (sc.paletteTextureRotation ?? 0) * Math.PI / 180;
498
+ u.uHueShift.value = sc.hueShift;
499
+ u.uContrast.value = sc.colorContrast;
500
+ u.uSaturation.value = sc.colorSaturation;
501
+ u.uLineAmount.value = sc.lineAmount ?? 425;
502
+ u.uLineThickness.value = sc.lineThickness ?? 1;
503
+ u.uLineDerivativePower.value = sc.lineDerivativePower ?? .95;
504
+ u.uMaxWidth.value = sc.maxWidth ?? 1232;
505
+ hexToLinearVec3(this.config.background, u.uClearColor.value);
506
+ u.uFiberCount.value = sc.fiberCount;
507
+ u.uFiberStrength.value = sc.fiberStrength;
508
+ u.uTexture.value = sc.texture;
509
+ u.uCreaseLight.value = sc.creaseLight;
510
+ u.uCreaseSharpness.value = sc.creaseSharpness;
511
+ u.uCreaseSoftness.value = sc.creaseSoftness;
512
+ u.uSheen.value = sc.sheen ?? 1;
513
+ u.uRoundness.value = sc.roundness ?? .35;
514
+ u.uIridescence.value = sc.iridescence ?? 0;
515
+ u.uDepthTint.value = sc.depthTint ?? 0;
516
+ hexToLinearVec3(sc.depthTintColor ?? "#0a2540", u.uDepthTintColor.value);
517
+ u.uEdgeFade.value = sc.edgeFade;
518
+ u.uEdgeFeather.value = sc.edgeFeather ?? .1;
519
+ const lights = this.config.lights ?? [];
520
+ u.uAmbient.value = this.config.ambient ?? .45;
521
+ u.uNumLights.value = Math.min(lights.length, 8);
522
+ const lPos = u.uLightPos.value;
523
+ const lCol = u.uLightColor.value;
524
+ const lInt = u.uLightIntensity.value;
525
+ for (let li = 0; li < 8; li++) {
526
+ const light = lights[li];
527
+ if (light) {
528
+ lPos[li].set(light.position.x, light.position.y, light.position.z);
529
+ hexToLinearVec3(light.color, lCol[li]);
530
+ lInt[li] = light.intensity;
531
+ } else lInt[li] = 0;
532
+ }
533
+ const bands = sc.noiseBands ?? [];
534
+ u.uNumNoiseBands.value = Math.min(bands.length, 4);
535
+ const bBounds = u.uNoiseBandBounds.value;
536
+ const bParams = u.uNoiseBandParams.value;
537
+ const bPara = u.uNoiseBandParaPow.value;
538
+ for (let bi = 0; bi < 4; bi++) {
539
+ const band = bands[bi];
540
+ if (band) {
541
+ bBounds[bi].set(band.startX, band.endX, band.startY, band.endY);
542
+ bParams[bi].set(band.feather, band.strength, band.frequency, band.colorAttenuation);
543
+ bPara[bi] = band.parabolaPower;
544
+ }
545
+ }
546
+ u.uSpeed.value = sc.speed;
547
+ u.uSeed.value = sc.seed;
548
+ u.uLoopSeconds.value = this.config.loopSeconds ?? 0;
549
+ u.uDispFreqX.value = sc.displaceFrequency.x;
550
+ u.uDispFreqZ.value = sc.displaceFrequency.y;
551
+ u.uDispAmount.value = sc.displaceAmount;
552
+ u.uDetailFreq.value = sc.detailFrequency ?? .04;
553
+ u.uDetailAmount.value = sc.detailAmount ?? 0;
554
+ u.uTwFreqX.value = sc.twistFrequency.x;
555
+ u.uTwFreqY.value = sc.twistFrequency.y;
556
+ u.uTwFreqZ.value = sc.twistFrequency.z;
557
+ u.uTwPowX.value = sc.twistPower.x;
558
+ u.uTwPowY.value = sc.twistPower.y;
559
+ u.uTwPowZ.value = sc.twistPower.z;
560
+ wave.mesh.scale.set(sc.scale.x, sc.scale.y, sc.scale.z);
561
+ wave.mesh.rotation.set(THREE.MathUtils.degToRad(sc.rotation.x), THREE.MathUtils.degToRad(sc.rotation.y), THREE.MathUtils.degToRad(sc.rotation.z));
562
+ wave.mesh.position.set(sc.position.x, sc.position.y, sc.position.z);
563
+ u.uOpacity.value = sc.opacity;
564
+ });
565
+ this.updatePaletteTextures();
566
+ this.syncVideoPlayback();
567
+ this.group.scale.set(this.config.mirrorH ? -1 : 1, this.config.mirrorV ? -1 : 1, 1);
568
+ this.onAfterRefresh();
569
+ if (!this.running) this.renderOnce();
570
+ }
571
+ /** Point every wave's palette sampler at its own WavePalette texture (each rebuilt only
572
+ * when that wave's palette actually changes — see WavePalette.apply). */
573
+ updatePaletteTextures() {
574
+ this.waves.forEach((wave, i) => {
575
+ const sc = this.config.waves[i] ?? this.config.waves[this.config.waves.length - 1];
576
+ wave.palette.apply(sc, wave.material.uniforms);
577
+ });
578
+ }
579
+ /** Dev: 0 = normal, 1 = visualise the crease value, 2 = visualise derivative normal. */
580
+ setDebug(v) {
581
+ for (const s of this.waves) s.material.uniforms.uDebug.value = v;
582
+ this.renderOnce();
583
+ }
584
+ /** Rebuild geometry + waves (call when waveCount or quality changes). */
585
+ rebuild() {
586
+ this.buildWaves();
587
+ }
588
+ applyBackground() {
589
+ if (this.config.transparentBackground) {
590
+ this.scene.background = null;
591
+ this.renderer.setClearColor(0, 0);
592
+ return;
593
+ }
594
+ const matte = new THREE.Color(this.config.background);
595
+ this.renderer.setClearColor(matte, 1);
596
+ if (this.config.backgroundMode === "color") {
597
+ this.applyColorBackground(matte);
598
+ return;
599
+ }
600
+ const { width, height } = this.backgroundCanvasSize(this.config.backgroundVideoUrl ? 2048 : 4096);
601
+ if (this.config.backgroundMode === "gradient") this.applyGradientBackground(width, height);
602
+ else this.applyImageBackground(matte, width, height);
603
+ }
604
+ applyColorBackground(matte) {
605
+ this.clearBackgroundVideo();
606
+ this.backgroundTexture?.dispose();
607
+ this.backgroundTexture = void 0;
608
+ this.backgroundSig = "";
609
+ this.scene.background = matte;
610
+ }
611
+ applyGradientBackground(width, height) {
612
+ this.clearBackgroundVideo();
613
+ const gradType = this.config.backgroundGradientType;
614
+ if (gradType === "mesh") {
615
+ const pts = this.config.backgroundMeshPoints ?? [];
616
+ const softness = this.config.backgroundMeshSoftness ?? .62;
617
+ const ptSig = pts.map((p) => `${p.color}@${p.x.toFixed(3)},${p.y.toFixed(3)},${p.influence.toFixed(3)}`).join(",");
618
+ const sig = [
619
+ "mesh",
620
+ softness.toFixed(3),
621
+ ptSig,
622
+ width,
623
+ height
624
+ ].join("|");
625
+ if (sig !== this.backgroundSig || !this.backgroundTexture) {
626
+ const canvas = buildBackgroundMeshCanvas(pts, softness, width, height);
627
+ this.backgroundTexture?.dispose();
628
+ this.backgroundTexture = canvasToTexture(canvas);
629
+ this.backgroundSig = sig;
630
+ }
631
+ this.scene.background = this.backgroundTexture;
632
+ return;
633
+ }
634
+ const source = this.config.backgroundGradientSource ?? "stops";
635
+ const def = PALETTE_MAPS[source];
636
+ const stops = source !== "stops" && def?.kind === "gradient" && def.stops ? def.stops : this.config.backgroundPalette;
637
+ const stopSig = stops.map((stop) => `${stop.color}@${stop.pos.toFixed(3)}`).join(",");
638
+ const sig = [
639
+ "gradient",
640
+ source,
641
+ gradType,
642
+ this.config.backgroundGradientAngle,
643
+ stopSig,
644
+ width,
645
+ height
646
+ ].join("|");
647
+ if (sig !== this.backgroundSig || !this.backgroundTexture) {
648
+ const canvas = buildBackgroundGradientCanvas({
649
+ stops,
650
+ type: gradType,
651
+ angle: this.config.backgroundGradientAngle,
652
+ width,
653
+ height
654
+ });
655
+ this.backgroundTexture?.dispose();
656
+ this.backgroundTexture = canvasToTexture(canvas);
657
+ this.backgroundSig = sig;
658
+ }
659
+ this.scene.background = this.backgroundTexture;
660
+ }
661
+ /** Image mode: a live video, a user-loaded image, or a built-in map. `matte` shows while an
662
+ * async source is still loading. */
663
+ applyImageBackground(matte, width, height) {
664
+ const fit = this.config.backgroundImageFit ?? "cover";
665
+ const zoom = this.config.backgroundImageZoom ?? 1;
666
+ const position = this.config.backgroundImagePosition ?? {
667
+ x: 0,
668
+ y: 0
669
+ };
670
+ const videoUrl = this.config.backgroundVideoUrl;
671
+ const customUrl = this.config.backgroundImageUrl;
672
+ let source;
673
+ let sourceWidth;
674
+ let sourceHeight;
675
+ let sourceSig;
676
+ if (videoUrl) {
677
+ this.ensureBackgroundVideo(videoUrl);
678
+ if (!this.backgroundVideo || this.backgroundVideo.readyState < 2) {
679
+ this.scene.background = matte;
680
+ return;
681
+ }
682
+ source = this.backgroundVideo;
683
+ sourceWidth = this.backgroundVideo.videoWidth;
684
+ sourceHeight = this.backgroundVideo.videoHeight;
685
+ sourceSig = `video|${videoUrl}`;
686
+ } else if (customUrl) {
687
+ this.clearBackgroundVideo();
688
+ if (!this.backgroundImage || this.backgroundImageUrl !== customUrl || !this.backgroundImage.complete) {
689
+ this.loadBackgroundImage(customUrl);
690
+ this.scene.background = matte;
691
+ return;
692
+ }
693
+ source = this.backgroundImage;
694
+ sourceWidth = this.backgroundImage.naturalWidth;
695
+ sourceHeight = this.backgroundImage.naturalHeight;
696
+ sourceSig = `custom|${customUrl}`;
697
+ } else {
698
+ this.clearBackgroundVideo();
699
+ const imageSource = this.config.backgroundImageSource ?? "vaporwave";
700
+ const canvas = imageSource === "hero" ? buildHeroPaletteCanvas() : PALETTE_MAPS[imageSource]?.kind === "image" ? paletteMapCanvas(PALETTE_MAPS[imageSource], Math.max(width, height)) : null;
701
+ if (!canvas) {
702
+ this.scene.background = matte;
703
+ return;
704
+ }
705
+ source = canvas;
706
+ sourceWidth = canvas.width;
707
+ sourceHeight = canvas.height;
708
+ sourceSig = `map|${imageSource}`;
709
+ }
710
+ const sig = [
711
+ "image",
712
+ sourceSig,
713
+ fit,
714
+ zoom,
715
+ position.x,
716
+ position.y,
717
+ this.config.background,
718
+ width,
719
+ height
720
+ ].join("|");
721
+ if (sig !== this.backgroundSig || !this.backgroundTexture) {
722
+ const canvas = buildBackgroundImageCanvas(source, sourceWidth, sourceHeight, width, height, fit, this.config.background, zoom, position.x, position.y);
723
+ this.backgroundTexture?.dispose();
724
+ this.backgroundTexture = canvasToTexture(canvas);
725
+ this.backgroundSig = sig;
726
+ this.backgroundVideoCanvas = videoUrl ? canvas : void 0;
727
+ }
728
+ this.scene.background = this.backgroundTexture;
729
+ }
730
+ backgroundCanvasSize(maxRequestedEdge = 4096) {
731
+ const rawWidth = this.outputSize?.width ?? Math.max(1, this.container.clientWidth);
732
+ const rawHeight = this.outputSize?.height ?? Math.max(1, this.container.clientHeight);
733
+ const maxEdge = Math.min(maxRequestedEdge, this.renderer.capabilities.maxTextureSize);
734
+ const scale = Math.min(1, maxEdge / Math.max(rawWidth, rawHeight));
735
+ return {
736
+ width: Math.max(1, Math.round(rawWidth * scale)),
737
+ height: Math.max(1, Math.round(rawHeight * scale))
738
+ };
739
+ }
740
+ loadBackgroundImage(url) {
741
+ if (this.backgroundImageUrl === url || this.failedBackgroundImageUrl === url) return;
742
+ this.backgroundImageUrl = url;
743
+ this.backgroundImage = void 0;
744
+ const image = new Image();
745
+ image.decoding = "async";
746
+ if (!url.startsWith("data:") && !url.startsWith("blob:")) image.crossOrigin = "anonymous";
747
+ image.addEventListener("load", () => {
748
+ if (this.config.backgroundImageUrl !== url) return;
749
+ this.backgroundImage = image;
750
+ this.failedBackgroundImageUrl = "";
751
+ this.backgroundSig = "";
752
+ this.applyBackground();
753
+ if (!this.running) this.renderOnce();
754
+ }, { once: true });
755
+ image.addEventListener("error", () => {
756
+ if (this.config.backgroundImageUrl !== url) return;
757
+ this.failedBackgroundImageUrl = url;
758
+ this.backgroundSig = "";
759
+ this.applyBackground();
760
+ if (!this.running) this.renderOnce();
761
+ }, { once: true });
762
+ image.src = url;
763
+ }
764
+ ensureBackgroundVideo(url) {
765
+ if (this.backgroundVideoUrl === url || this.failedBackgroundVideoUrl === url) return;
766
+ this.clearBackgroundVideo();
767
+ this.backgroundVideoUrl = url;
768
+ const video = this.createLoopingVideo(url);
769
+ video.addEventListener("loadeddata", () => {
770
+ if (this.config.backgroundVideoUrl !== url) return;
771
+ this.backgroundVideo = video;
772
+ this.failedBackgroundVideoUrl = "";
773
+ this.backgroundSig = "";
774
+ this.applyBackground();
775
+ this.syncVideoPlayback();
776
+ if (!this.running) this.renderOnce();
777
+ }, { once: true });
778
+ video.addEventListener("error", () => {
779
+ if (this.config.backgroundVideoUrl !== url) return;
780
+ this.failedBackgroundVideoUrl = url;
781
+ this.backgroundSig = "";
782
+ this.applyBackground();
783
+ if (!this.running) this.renderOnce();
784
+ }, { once: true });
785
+ this.backgroundVideo = video;
786
+ video.load();
787
+ }
788
+ clearBackgroundVideo() {
789
+ if (this.backgroundVideo) {
790
+ this.backgroundVideo.pause();
791
+ this.backgroundVideo.removeAttribute("src");
792
+ this.backgroundVideo.load();
793
+ }
794
+ this.backgroundVideo = void 0;
795
+ this.backgroundVideoUrl = "";
796
+ this.failedBackgroundVideoUrl = "";
797
+ this.backgroundVideoCanvas = void 0;
798
+ }
799
+ createLoopingVideo(url) {
800
+ const video = document.createElement("video");
801
+ video.muted = true;
802
+ video.defaultMuted = true;
803
+ video.loop = true;
804
+ video.playsInline = true;
805
+ video.preload = "auto";
806
+ if (!url.startsWith("data:") && !url.startsWith("blob:")) video.crossOrigin = "anonymous";
807
+ video.src = url;
808
+ return video;
809
+ }
810
+ syncVideoPlayback() {
811
+ const backgroundActive = this.running && !this.config.transparentBackground && this.config.backgroundMode === "image" && !!this.config.backgroundVideoUrl;
812
+ if (this.backgroundVideo) if (backgroundActive) this.backgroundVideo.play().catch(() => {});
813
+ else this.backgroundVideo.pause();
814
+ this.waves.forEach((wave, i) => {
815
+ const sc = this.config.waves[i] ?? this.config.waves[this.config.waves.length - 1];
816
+ wave.palette.syncPlayback(sc, this.running);
817
+ });
818
+ }
819
+ updateBackgroundVideoFrame() {
820
+ const video = this.backgroundVideo;
821
+ const canvas = this.backgroundVideoCanvas;
822
+ if (!video || !canvas || video.readyState < 2 || !this.backgroundTexture) return;
823
+ const position = this.config.backgroundImagePosition ?? {
824
+ x: 0,
825
+ y: 0
826
+ };
827
+ drawBackgroundMediaFrame(canvas, video, video.videoWidth, video.videoHeight, this.config.backgroundImageFit ?? "cover", this.config.background, this.config.backgroundImageZoom ?? 1, position.x, position.y);
828
+ this.backgroundTexture.needsUpdate = true;
829
+ }
830
+ applyPost() {
831
+ const u = this.postPass.uniforms;
832
+ u.uBlurAmount.value = this.config.blur;
833
+ u.uGrainAmount.value = this.config.grain;
834
+ u.uBlurSamples.value = Math.round(this.config.blurSamples ?? 6);
835
+ this.applyBloom();
836
+ }
837
+ /** Insert / tune / remove the bloom pass. strength 0 removes it from the composer entirely, so
838
+ * cost and pixels are identical to bloom-off; the pass (and its mip-chain render targets) is
839
+ * created lazily the first time bloom is enabled and disposed when turned back off. It sits
840
+ * right after the scene RenderPass so it blooms the wave before the grain/blur pass. */
841
+ applyBloom() {
842
+ const strength = this.config.bloomStrength ?? 0;
843
+ if (strength > 0) {
844
+ if (!this.bloomPass) {
845
+ const size = this.renderer.getDrawingBufferSize(new THREE.Vector2());
846
+ this.bloomPass = new UnrealBloomPass(size, strength, this.config.bloomRadius ?? .4, this.config.bloomThreshold ?? .85);
847
+ this.composer.insertPass(this.bloomPass, 1);
848
+ }
849
+ this.bloomPass.strength = strength;
850
+ this.bloomPass.radius = this.config.bloomRadius ?? .4;
851
+ this.bloomPass.threshold = this.config.bloomThreshold ?? .85;
852
+ } else if (this.bloomPass) {
853
+ this.composer.removePass(this.bloomPass);
854
+ this.bloomPass.dispose();
855
+ this.bloomPass = void 0;
856
+ }
857
+ }
858
+ onResize = () => {
859
+ this.resize();
860
+ };
861
+ onContextLost = (e) => {
862
+ e.preventDefault();
863
+ cancelAnimationFrame(this.rafId);
864
+ this.running = false;
865
+ };
866
+ onContextRestored = () => {
867
+ this.disposeWaves();
868
+ this.backgroundTexture?.dispose();
869
+ this.backgroundTexture = void 0;
870
+ this.backgroundSig = "";
871
+ this.buildWaves();
872
+ this.resize();
873
+ this.updateRunning();
874
+ };
875
+ resize() {
876
+ const w = this.outputSize?.width ?? Math.max(1, this.container.clientWidth);
877
+ const h = this.outputSize?.height ?? Math.max(1, this.container.clientHeight);
878
+ const dpr = this.outputSize ? 1 : Math.min(window.devicePixelRatio || 1, this.config.dprMax);
879
+ this.renderer.setPixelRatio(dpr);
880
+ this.renderer.setSize(w, h, !this.outputSize);
881
+ if (this.outputSize) {
882
+ this.renderer.domElement.style.width = "100%";
883
+ this.renderer.domElement.style.height = "100%";
884
+ }
885
+ this.composer.setPixelRatio(dpr);
886
+ this.composer.setSize(w, h);
887
+ const dw = w * dpr;
888
+ const dh = h * dpr;
889
+ this.postPass.uniforms.uResolution.value.set(dw, dh);
890
+ for (const s of this.waves) s.material.uniforms.uResolution.value.set(dw, dh);
891
+ this.camera.left = -dw / 2;
892
+ this.camera.right = dw / 2;
893
+ this.camera.top = dh / 2;
894
+ this.camera.bottom = -dh / 2;
895
+ this.applyZoom();
896
+ this.applyBackground();
897
+ this.onAfterResize();
898
+ if (!this.running) this.renderOnce();
899
+ }
900
+ /** Set an exact output buffer while CSS scales the canvas into the on-screen export frame. */
901
+ setOutputSize(width, height) {
902
+ this.outputSize = {
903
+ width: THREE.MathUtils.clamp(Math.round(width), 64, 8192),
904
+ height: THREE.MathUtils.clamp(Math.round(height), 64, 8192)
905
+ };
906
+ this.resize();
907
+ }
908
+ start() {
909
+ this.started = true;
910
+ this.updateRunning();
911
+ }
912
+ stop() {
913
+ this.started = false;
914
+ this.updateRunning();
915
+ }
916
+ onMotionChange = (e) => {
917
+ this.reducedMotion = this.respectReducedMotion && e.matches;
918
+ this.updateRunning();
919
+ };
920
+ onVisibilityChange = () => {
921
+ this.pageVisible = document.visibilityState === "visible";
922
+ this.updateRunning();
923
+ };
924
+ updateRunning() {
925
+ const shouldAnimate = this.started && this.visible && this.pageVisible && !this.config.paused && !this.reducedMotion;
926
+ if (shouldAnimate && !this.running) {
927
+ this.running = true;
928
+ this.clock.start();
929
+ this.clock.getDelta();
930
+ this.rafId = requestAnimationFrame(this.loop);
931
+ } else if (!shouldAnimate && this.running) {
932
+ this.running = false;
933
+ cancelAnimationFrame(this.rafId);
934
+ }
935
+ if (!this.running) {
936
+ this.introTimeRamp = 1;
937
+ this.renderOnce();
938
+ }
939
+ this.syncVideoPlayback();
940
+ }
941
+ loop = () => {
942
+ if (!this.running) return;
943
+ this.time += this.clock.getDelta();
944
+ if (this.introTimeRamp < 1) this.introTimeRamp = Math.min(1, this.introTimeRamp + .016);
945
+ this.renderOnce();
946
+ this.rafId = requestAnimationFrame(this.loop);
947
+ };
948
+ /** Advance the per-frame clock uniforms (geometry itself is static). Time model:
949
+ * time = elapsed·introTimeRamp + timeOffset — the ramp eases the animation in on load. */
950
+ updateTime() {
951
+ const ramp = this.config.introRamp === false || this.skipIntroRamp ? 1 : this.introTimeRamp;
952
+ const t = this.time * ramp + (this.config.timeOffset ?? 0);
953
+ for (let i = 0; i < this.waves.length; i++) {
954
+ const u = this.waves[i].material.uniforms;
955
+ u.uTime.value = t;
956
+ const sc = this.config.waves[i] ?? this.config.waves[this.config.waves.length - 1];
957
+ const dx = sc.paletteDriftX ?? 0;
958
+ const dy = sc.paletteDriftY ?? 0;
959
+ if (dx !== 0 || dy !== 0) {
960
+ const base = sc.paletteTextureOffset;
961
+ u.uPaletteOffset.value.set(base.x + dx * t, base.y + dy * t);
962
+ }
963
+ }
964
+ this.postPass.uniforms.uTime.value = t;
965
+ }
966
+ /** Render exactly one frame at the current time. */
967
+ renderOnce() {
968
+ this.updateBackgroundVideoFrame();
969
+ this.updateTime();
970
+ this.updateClipPlanes();
971
+ this.composer.render();
972
+ this.onAfterRenderFrame();
973
+ }
974
+ /** Re-evaluate play/pause after `config.paused` changes. */
975
+ refreshPlayback() {
976
+ this.updateRunning();
977
+ }
978
+ /** Jump the camera to the config's authored framing (cameraPosition / cameraTarget /
979
+ * cameraZoom). Called on whole-config swaps (preset / reset / randomize / import). The base
980
+ * applies the pose directly; the studio subclass overrides it to also drive the orbit target
981
+ * and sync the panel. Hook ⑤. */
982
+ applyCameraFromConfig() {
983
+ const p = this.config.cameraPosition;
984
+ const tg = this.config.cameraTarget;
985
+ this.camera.position.set(p.x, p.y, p.z);
986
+ this.camera.up.set(0, 1, 0);
987
+ this.camera.lookAt(tg.x, tg.y, tg.z);
988
+ this.applyZoom();
989
+ if (!this.running) this.renderOnce();
990
+ }
991
+ /** Hook ①: true while orbit or an edit gizmo owns the camera, so refresh() won't reset it. */
992
+ isCameraExternallyDriven() {
993
+ return false;
994
+ }
995
+ /** Hook ②: called at the end of refresh(), before the trailing renderOnce(). */
996
+ onAfterRefresh() {}
997
+ /** Hook ③: called at the end of renderOnce(), after the composed frame is drawn. */
998
+ onAfterRenderFrame() {}
999
+ /** Hook ④: called at the end of resize(), before the trailing renderOnce(). */
1000
+ onAfterResize() {}
1001
+ /** Responsive ortho zoom: COVER the FRAME_W × FRAME_H reference frame onto the canvas so the
1002
+ * wave frames the same at any size/aspect/dpr (only the cropped margin differs), times the
1003
+ * user's cameraZoom. `max(...)` = cover (fill both axes, crop overflow); `min(...)` would be
1004
+ * contain (fit with letterbox bands). Cover keeps the wave filling the frame on every screen. */
1005
+ applyZoom() {
1006
+ const dw = this.camera.right - this.camera.left;
1007
+ const dh = this.camera.top - this.camera.bottom;
1008
+ this.camera.zoom = Math.max(dw / FRAME_W, dh / 750) * (this.config.cameraZoom ?? 1);
1009
+ this.camera.updateProjectionMatrix();
1010
+ }
1011
+ /** Fit the orthographic near/far planes to the scene before every render, so no part of a wave
1012
+ * is ever clipped as the camera orbits / dollies / pans (or when waves are added or scaled).
1013
+ *
1014
+ * The camera is *constructed* with fixed 1..10000 planes that only suit the authored hero
1015
+ * framing; once the view moves, the wave's depth extent along the view axis easily crosses
1016
+ * them and the GPU hard-slices the geometry along a flat plane — chunks of the wave vanish.
1017
+ *
1018
+ * The in-shader twist rotates each vertex about its LOCAL origin, so it can never move a vertex
1019
+ * further from that origin than the base geometry already sits. A sphere at each mesh's origin
1020
+ * (radius = base extent × the mesh's largest world scale, plus the Y displacement, ×1.2 slack)
1021
+ * therefore safely contains the fully-deformed wave. We union those, then bracket the union
1022
+ * along the view axis with a margin. Bracketing to the scene (rather than a fixed huge slab)
1023
+ * keeps clip-space depth scene-normalised — so the wireframe theme's depth fade, which reads
1024
+ * gl_Position.z, looks the same at any camera distance instead of washing out. Runs each frame;
1025
+ * it's a handful of vector ops over 1–8 meshes with no allocation. */
1026
+ updateClipPlanes() {
1027
+ this.clipBox.makeEmpty();
1028
+ for (const wave of this.waves) {
1029
+ const mesh = wave.mesh;
1030
+ mesh.updateWorldMatrix(true, false);
1031
+ const bs = mesh.geometry.boundingSphere;
1032
+ if (!bs) continue;
1033
+ const center = this.clipTmpA.setFromMatrixPosition(mesh.matrixWorld);
1034
+ const disp = Math.abs(Number(wave.material.uniforms.uDispAmount.value) || 0);
1035
+ const radius = (bs.center.length() + bs.radius + disp) * mesh.matrixWorld.getMaxScaleOnAxis() * 1.2;
1036
+ this.clipBox.expandByPoint(this.clipTmpB.copy(center).addScalar(radius));
1037
+ this.clipBox.expandByPoint(this.clipTmpB.copy(center).addScalar(-radius));
1038
+ }
1039
+ if (this.clipBox.isEmpty()) return;
1040
+ this.clipBox.getBoundingSphere(this.clipSphere);
1041
+ const viewDir = this.camera.getWorldDirection(this.clipTmpA);
1042
+ const centerDepth = this.clipTmpB.copy(this.clipSphere.center).sub(this.camera.position).dot(viewDir);
1043
+ const radius = this.clipSphere.radius;
1044
+ const margin = radius * .25 + 10;
1045
+ this.camera.near = centerDepth - radius - margin;
1046
+ this.camera.far = centerDepth + radius + margin;
1047
+ this.camera.updateProjectionMatrix();
1048
+ }
1049
+ /** A world-space position delta that drops a duplicated wave into open frame space beside the
1050
+ * one it was copied from — screen-left and a touch down, sized to the visible frame — instead
1051
+ * of hidden exactly on top of it or (for the hero, which fills the right of the frame) pushed
1052
+ * off-frame. Camera-relative: it's "left on screen" no matter how the view is rotated/zoomed,
1053
+ * because it's built from the camera's right/up axes and the frame's visible world size. */
1054
+ async captureImage(mime, transparent = true, quality) {
1055
+ const prev = this.config.transparentBackground;
1056
+ if (transparent !== prev) {
1057
+ this.config.transparentBackground = transparent;
1058
+ this.applyBackground();
1059
+ }
1060
+ let blob = null;
1061
+ try {
1062
+ this.capturing = true;
1063
+ this.renderOnce();
1064
+ blob = await new Promise((resolve) => this.canvas.toBlob(resolve, mime, quality));
1065
+ } finally {
1066
+ this.capturing = false;
1067
+ if (transparent !== prev) {
1068
+ this.config.transparentBackground = prev;
1069
+ this.applyBackground();
1070
+ }
1071
+ this.renderOnce();
1072
+ }
1073
+ if (!blob || blob.type !== mime) throw new Error(`Failed to capture ${mime}`);
1074
+ return blob;
1075
+ }
1076
+ captureStream(fps = 60) {
1077
+ return this.canvas.captureStream(fps);
1078
+ }
1079
+ get canvas() {
1080
+ return this.renderer.domElement;
1081
+ }
1082
+ getConfig() {
1083
+ return this.config;
1084
+ }
1085
+ setConfig(config) {
1086
+ const next = ensureStudioConfig(config);
1087
+ const structural = next.waves.length !== this.waves.length || next.quality !== this.config.quality;
1088
+ this.config = next;
1089
+ if (structural) this.rebuild();
1090
+ else this.refresh();
1091
+ this.applyCameraFromConfig();
1092
+ }
1093
+ dispose() {
1094
+ cancelAnimationFrame(this.rafId);
1095
+ this.running = false;
1096
+ this.resizeObserver.disconnect();
1097
+ this.intersectionObserver.disconnect();
1098
+ this.motionQuery.removeEventListener("change", this.onMotionChange);
1099
+ document.removeEventListener("visibilitychange", this.onVisibilityChange);
1100
+ this.renderer.domElement.removeEventListener("webglcontextlost", this.onContextLost);
1101
+ this.renderer.domElement.removeEventListener("webglcontextrestored", this.onContextRestored);
1102
+ this.clearBackgroundVideo();
1103
+ this.backgroundTexture?.dispose();
1104
+ for (const s of this.waves) {
1105
+ s.material.dispose();
1106
+ s.geometry.dispose();
1107
+ s.palette.dispose();
1108
+ }
1109
+ this.bloomPass?.dispose();
1110
+ this.composer.dispose();
1111
+ this.renderer.dispose();
1112
+ this.renderer.domElement.remove();
1113
+ }
1114
+ };
1115
+ //#endregion
1116
+ export { FRAME_H, FRAME_W, WaveRenderer, hexToLinearVec3 };
1117
+
1118
+ //# sourceMappingURL=WaveRenderer.js.map