@wave3d/core 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/model.d.ts +62 -1
- package/dist/config/model.js +45 -1
- package/dist/config/model.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/renderer/WaveRenderer.d.ts +85 -12
- package/dist/renderer/WaveRenderer.js +299 -16
- package/dist/renderer/WaveRenderer.js.map +1 -1
- package/dist/renderer/index.d.ts +2 -2
- package/dist/renderer/index.js +2 -2
- package/dist/renderer/shaders.js +213 -6
- package/dist/renderer/shaders.js.map +1 -1
- package/dist/standalone/wave3d.standalone.js +446 -248
- package/dist/standalone.d.ts +2 -2
- package/dist/standalone.js +2 -2
- package/dist/studio/StudioWaveRenderer.d.ts +4 -0
- package/dist/studio/StudioWaveRenderer.js +10 -3
- package/dist/studio/StudioWaveRenderer.js.map +1 -1
- package/dist/studio/index.d.ts +2 -2
- package/dist/studio/index.js +2 -2
- package/dist/studio/randomize.d.ts +4 -1
- package/dist/studio/randomize.js +48 -1
- package/dist/studio/randomize.js.map +1 -1
- package/package.json +1 -1
- package/skills/wave3d/SKILL.md +40 -4
|
@@ -1,18 +1,31 @@
|
|
|
1
|
-
import { StudioConfig, WaveConfig } from "../config/model.js";
|
|
1
|
+
import { CameraFit, StudioConfig, WaveConfig } from "../config/model.js";
|
|
2
2
|
import { WaveGeometry } from "./WaveGeometry.js";
|
|
3
3
|
import { InteractionController } from "./interaction.js";
|
|
4
4
|
import * as THREE from "three";
|
|
5
5
|
|
|
6
6
|
//#region src/renderer/WaveRenderer.d.ts
|
|
7
7
|
/** Reference frame (world units) the orthographic camera fills at cameraZoom 1. The wave is
|
|
8
|
-
* framed by
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* showing empty bands. This is what makes a saved preset reproduce on anyone's screen. */
|
|
8
|
+
* framed by mapping this FRAME_W × FRAME_H rectangle (centred on cameraTarget) onto the canvas,
|
|
9
|
+
* so a given cameraZoom / cameraTarget frames the wave the SAME at any canvas size or aspect
|
|
10
|
+
* (only the margin differs). FRAME_H = FRAME_W / (16/9) makes the reference a 16:9 rectangle.
|
|
11
|
+
* This is what makes a saved preset reproduce on anyone's screen. See {@link frameZoom} for how
|
|
12
|
+
* a canvas of a different aspect is reconciled against it. */
|
|
14
13
|
declare const FRAME_W = 1333;
|
|
15
14
|
declare const FRAME_H = 750;
|
|
15
|
+
/**
|
|
16
|
+
* The responsive base zoom: how many device pixels one world unit occupies so the FRAME_W × FRAME_H
|
|
17
|
+
* reference lands on a `dw × dh` (device px) canvas under `fit`, then clamped so at least
|
|
18
|
+
* `minVisibleWidth` of the frame's width survives.
|
|
19
|
+
*
|
|
20
|
+
* Shared by the renderer (which applies it) and the studio (which inverts it to persist a
|
|
21
|
+
* scroll-zoom back into config.cameraZoom) — one implementation, so the two cannot drift.
|
|
22
|
+
*
|
|
23
|
+
* The clamp is a pure zoom CEILING layered on top of the fit, which is what lets both knobs
|
|
24
|
+
* coexist: it can only widen the view, never tighten it, so it is inert for `contain`/`width`
|
|
25
|
+
* (already at or below that zoom) and bites exactly where the crop hurts — `cover`/`height` on a
|
|
26
|
+
* canvas narrower than 16:9. `minVisibleWidth` 0 disables it and restores pure-fit behaviour.
|
|
27
|
+
*/
|
|
28
|
+
declare function frameZoom(dw: number, dh: number, fit: CameraFit, minVisibleWidth?: number): number;
|
|
16
29
|
interface WaveRendererOptions {
|
|
17
30
|
/** Honor prefers-reduced-motion by freezing animation. Default true. */
|
|
18
31
|
respectReducedMotion?: boolean;
|
|
@@ -71,6 +84,12 @@ declare class WaveRenderer {
|
|
|
71
84
|
private readonly postPass;
|
|
72
85
|
/** Optional bloom pass — created lazily when bloomStrength first goes >0, removed at 0. */
|
|
73
86
|
private bloomPass?;
|
|
87
|
+
private ditherPass?;
|
|
88
|
+
private innerLightPass?;
|
|
89
|
+
private halftonePass?;
|
|
90
|
+
private heatmapPass?;
|
|
91
|
+
private paperTexturePass?;
|
|
92
|
+
private halftoneCmykPass?;
|
|
74
93
|
protected readonly container: HTMLElement;
|
|
75
94
|
private readonly respectReducedMotion;
|
|
76
95
|
private readonly skipIntroRamp;
|
|
@@ -114,6 +133,11 @@ declare class WaveRenderer {
|
|
|
114
133
|
private readonly resizeObserver;
|
|
115
134
|
private readonly intersectionObserver;
|
|
116
135
|
private readonly motionQuery;
|
|
136
|
+
/** Re-armed at the live devicePixelRatio on every change — see watchDpr(). */
|
|
137
|
+
private dprQuery?;
|
|
138
|
+
/** Pending coalesced resize (0 = none), and the metrics the last resize() actually applied. */
|
|
139
|
+
private resizeRaf;
|
|
140
|
+
private lastResize?;
|
|
117
141
|
protected capturing: boolean;
|
|
118
142
|
/** Fixed backing-buffer dimensions used by the studio's visible export frame. Embeds leave
|
|
119
143
|
* this unset and continue to resize responsively with their container and device DPR. */
|
|
@@ -172,7 +196,54 @@ declare class WaveRenderer {
|
|
|
172
196
|
* created lazily the first time bloom is enabled and disposed when turned back off. It sits
|
|
173
197
|
* right after the scene RenderPass so it blooms the wave before the grain/blur pass. */
|
|
174
198
|
private applyBloom;
|
|
199
|
+
/** Insert / tune / remove the dithering pass — a self-contained "layered" post shader (an ordered
|
|
200
|
+
* Bayer dither, in the spirit of paper-design/shaders). Like bloom, dither 0 removes the pass
|
|
201
|
+
* entirely so cost and pixels match dither-off, and it's created lazily on first enable. It is
|
|
202
|
+
* appended AFTER OutputPass so it runs last and quantizes display-space colour (tone-mapped +
|
|
203
|
+
* sRGB) — dithering the linear composer buffer would crush the steps in the shadows. */
|
|
204
|
+
private applyDither;
|
|
205
|
+
/** Insert / tune / remove the innerLight pass — volumetric light streaks scattered from the bright
|
|
206
|
+
* wave toward a light point (innerLightX/Y in UV). Scene zone (index 1) so it scatters the raw wave
|
|
207
|
+
* like bloom. innerLight 0 removes the pass entirely; created lazily on first enable. */
|
|
208
|
+
private applyInnerLight;
|
|
209
|
+
/** Insert / tune / remove the halftone pass — a rotated dot screen (dot size scales with local
|
|
210
|
+
* brightness) over the finished image. halftone 0 removes the pass; created lazily on enable. */
|
|
211
|
+
private applyHalftone;
|
|
212
|
+
/** Heatmap: recolour the final image by luminance → thermal palette. Finish zone. */
|
|
213
|
+
private applyHeatmap;
|
|
214
|
+
/** Paper texture: fibrous substrate shading multiplied over the image. Finish zone. */
|
|
215
|
+
private applyPaperTexture;
|
|
216
|
+
/** CMYK halftone: four rotated dot screens (cyan/magenta/yellow/black). Finish zone. */
|
|
217
|
+
private applyHalftoneCmyk;
|
|
218
|
+
/** Coalesce observer-driven resizes to one per frame, and drop any that don't move a device pixel.
|
|
219
|
+
*
|
|
220
|
+
* resize() is expensive — composer.setSize reallocates every pass's render target, and
|
|
221
|
+
* applyBackground() rebuilds a container-sized canvas + texture for gradient/image backgrounds.
|
|
222
|
+
* The old 1:1 `observe → resize()` paid that for observations that changed nothing: the observer
|
|
223
|
+
* reports fractional content-box sizes, so sub-pixel layout shifts (and anything that rounds to
|
|
224
|
+
* the same backing buffer) triggered a full reallocation, as did every observation while an
|
|
225
|
+
* export frame is pinned and the container is not what drives the buffer at all.
|
|
226
|
+
*
|
|
227
|
+
* Genuine per-frame changes — a mobile URL bar collapsing animates the container height — still
|
|
228
|
+
* resize every frame. That work is necessary; the canvas would otherwise stretch. What is
|
|
229
|
+
* removed is the redundant work, not the real work.
|
|
230
|
+
*
|
|
231
|
+
* Only this path is throttled. `resize()` itself stays synchronous and unconditional — context
|
|
232
|
+
* restore and setOutputSize must re-apply immediately, and on a fresh GPU context the metrics
|
|
233
|
+
* are unchanged but the resources are not. */
|
|
175
234
|
private onResize;
|
|
235
|
+
/** Re-arm the DPR watch and re-render at the new device-pixel ratio.
|
|
236
|
+
*
|
|
237
|
+
* ResizeObserver watches the CSS box only, so browser zoom or dragging the window to a monitor
|
|
238
|
+
* with a different DPR changes devicePixelRatio without changing the box — the backing buffer
|
|
239
|
+
* stayed at the old resolution and the wave went soft until something else forced a resize. */
|
|
240
|
+
private onDprChange;
|
|
241
|
+
/** A `(resolution: Xdppx)` query only fires when we LEAVE the current ratio, so it is re-armed at
|
|
242
|
+
* the new one on every change. */
|
|
243
|
+
private watchDpr;
|
|
244
|
+
/** The backing-buffer metrics resize() will apply: the export frame when one is pinned, else the
|
|
245
|
+
* container box at the (clamped) device-pixel ratio. */
|
|
246
|
+
private viewportMetrics;
|
|
176
247
|
private onContextLost;
|
|
177
248
|
private onContextRestored;
|
|
178
249
|
resize(): void;
|
|
@@ -228,11 +299,13 @@ declare class WaveRenderer {
|
|
|
228
299
|
protected onAfterRenderFrame(): void;
|
|
229
300
|
/** Hook ④: called at the end of resize(), before the trailing renderOnce(). */
|
|
230
301
|
protected onAfterResize(): void;
|
|
231
|
-
/** Responsive ortho zoom:
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
* contain (fit with letterbox bands). Cover keeps the wave filling the frame on every screen. */
|
|
302
|
+
/** Responsive ortho zoom: map the FRAME_W × FRAME_H reference frame onto the canvas (per
|
|
303
|
+
* config.cameraFit / cameraMinVisibleWidth — see {@link frameZoom}) so the wave frames the same
|
|
304
|
+
* at any size/aspect/dpr, times the user's cameraZoom. */
|
|
235
305
|
protected applyZoom(): void;
|
|
306
|
+
/** The responsive base zoom for the current config's framing policy, before the cameraZoom
|
|
307
|
+
* multiplier. Subclasses invert this to recover cameraZoom from a live camera. */
|
|
308
|
+
protected baseFrameZoom(dw: number, dh: number): number;
|
|
236
309
|
/** Fit the orthographic near/far planes to the scene before every render, so no part of a wave
|
|
237
310
|
* is ever clipped as the camera orbits / dollies / pans (or when waves are added or scaled).
|
|
238
311
|
*
|
|
@@ -262,5 +335,5 @@ declare class WaveRenderer {
|
|
|
262
335
|
dispose(): void;
|
|
263
336
|
}
|
|
264
337
|
//#endregion
|
|
265
|
-
export { FRAME_H, FRAME_W, WaveRenderer, WaveRendererOptions, hexToLinearVec3 };
|
|
338
|
+
export { FRAME_H, FRAME_W, WaveRenderer, WaveRendererOptions, frameZoom, hexToLinearVec3 };
|
|
266
339
|
//# sourceMappingURL=WaveRenderer.d.ts.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ensureStudioConfig } from "../config/model.js";
|
|
2
|
-
import { fragmentShader, lineFragmentShader, postFragmentShader, postVertexShader, vertexShader } from "./shaders.js";
|
|
2
|
+
import { ditherFragmentShader, fragmentShader, halftoneCmykFragmentShader, halftoneFragmentShader, heatmapFragmentShader, innerLightFragmentShader, lineFragmentShader, paperTextureFragmentShader, postFragmentShader, postVertexShader, vertexShader } from "./shaders.js";
|
|
3
3
|
import { WaveGeometry } from "./WaveGeometry.js";
|
|
4
4
|
import { InteractionController, SCENE_APPLIERS, WAVE_APPLIERS, anyPointerFxActive, interactionActive, wavePointerFxActive, waveRipplesActive } from "./interaction.js";
|
|
5
5
|
import { PALETTE_MAPS, buildBackgroundGradientCanvas, buildBackgroundImageCanvas, buildBackgroundMeshCanvas, buildPaletteTexture, canvasToTexture, configurePaletteTexture, drawBackgroundMediaFrame, loadPaletteImage, paletteMapCanvas, paletteSignature } from "./palette.js";
|
|
@@ -13,15 +13,46 @@ import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js"
|
|
|
13
13
|
//#region src/renderer/WaveRenderer.ts
|
|
14
14
|
const BASE_SEGMENTS = 220;
|
|
15
15
|
/** Reference frame (world units) the orthographic camera fills at cameraZoom 1. The wave is
|
|
16
|
-
* framed by
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* showing empty bands. This is what makes a saved preset reproduce on anyone's screen. */
|
|
16
|
+
* framed by mapping this FRAME_W × FRAME_H rectangle (centred on cameraTarget) onto the canvas,
|
|
17
|
+
* so a given cameraZoom / cameraTarget frames the wave the SAME at any canvas size or aspect
|
|
18
|
+
* (only the margin differs). FRAME_H = FRAME_W / (16/9) makes the reference a 16:9 rectangle.
|
|
19
|
+
* This is what makes a saved preset reproduce on anyone's screen. See {@link frameZoom} for how
|
|
20
|
+
* a canvas of a different aspect is reconciled against it. */
|
|
22
21
|
const FRAME_W = 1333;
|
|
23
22
|
const FRAME_H = 750;
|
|
24
23
|
/**
|
|
24
|
+
* The responsive base zoom: how many device pixels one world unit occupies so the FRAME_W × FRAME_H
|
|
25
|
+
* reference lands on a `dw × dh` (device px) canvas under `fit`, then clamped so at least
|
|
26
|
+
* `minVisibleWidth` of the frame's width survives.
|
|
27
|
+
*
|
|
28
|
+
* Shared by the renderer (which applies it) and the studio (which inverts it to persist a
|
|
29
|
+
* scroll-zoom back into config.cameraZoom) — one implementation, so the two cannot drift.
|
|
30
|
+
*
|
|
31
|
+
* The clamp is a pure zoom CEILING layered on top of the fit, which is what lets both knobs
|
|
32
|
+
* coexist: it can only widen the view, never tighten it, so it is inert for `contain`/`width`
|
|
33
|
+
* (already at or below that zoom) and bites exactly where the crop hurts — `cover`/`height` on a
|
|
34
|
+
* canvas narrower than 16:9. `minVisibleWidth` 0 disables it and restores pure-fit behaviour.
|
|
35
|
+
*/
|
|
36
|
+
function frameZoom(dw, dh, fit, minVisibleWidth = 0) {
|
|
37
|
+
const byWidth = dw / FRAME_W;
|
|
38
|
+
const byHeight = dh / 750;
|
|
39
|
+
let zoom;
|
|
40
|
+
switch (fit) {
|
|
41
|
+
case "contain":
|
|
42
|
+
zoom = Math.min(byWidth, byHeight);
|
|
43
|
+
break;
|
|
44
|
+
case "width":
|
|
45
|
+
zoom = byWidth;
|
|
46
|
+
break;
|
|
47
|
+
case "height":
|
|
48
|
+
zoom = byHeight;
|
|
49
|
+
break;
|
|
50
|
+
default: zoom = Math.max(byWidth, byHeight);
|
|
51
|
+
}
|
|
52
|
+
if (minVisibleWidth > 0) zoom = Math.min(zoom, dw / (FRAME_W * minVisibleWidth));
|
|
53
|
+
return zoom;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
25
56
|
* Per-wave 2D palette texture (+ optional looping video). One instance per wave, so each
|
|
26
57
|
* wave carries its own palette. Guarded by a signature so it only rebuilds when that wave's
|
|
27
58
|
* palette actually changes (not every refresh).
|
|
@@ -149,6 +180,12 @@ var WaveRenderer = class {
|
|
|
149
180
|
postPass;
|
|
150
181
|
/** Optional bloom pass — created lazily when bloomStrength first goes >0, removed at 0. */
|
|
151
182
|
bloomPass;
|
|
183
|
+
ditherPass;
|
|
184
|
+
innerLightPass;
|
|
185
|
+
halftonePass;
|
|
186
|
+
heatmapPass;
|
|
187
|
+
paperTexturePass;
|
|
188
|
+
halftoneCmykPass;
|
|
152
189
|
container;
|
|
153
190
|
respectReducedMotion;
|
|
154
191
|
skipIntroRamp;
|
|
@@ -195,6 +232,11 @@ var WaveRenderer = class {
|
|
|
195
232
|
resizeObserver;
|
|
196
233
|
intersectionObserver;
|
|
197
234
|
motionQuery;
|
|
235
|
+
/** Re-armed at the live devicePixelRatio on every change — see watchDpr(). */
|
|
236
|
+
dprQuery;
|
|
237
|
+
/** Pending coalesced resize (0 = none), and the metrics the last resize() actually applied. */
|
|
238
|
+
resizeRaf = 0;
|
|
239
|
+
lastResize;
|
|
198
240
|
capturing = false;
|
|
199
241
|
/** Fixed backing-buffer dimensions used by the studio's visible export frame. Embeds leave
|
|
200
242
|
* this unset and continue to resize responsively with their container and device DPR. */
|
|
@@ -250,6 +292,7 @@ var WaveRenderer = class {
|
|
|
250
292
|
this.intersectionObserver.observe(container);
|
|
251
293
|
this.resizeObserver = new ResizeObserver(this.onResize);
|
|
252
294
|
this.resizeObserver.observe(container);
|
|
295
|
+
this.watchDpr();
|
|
253
296
|
this.applyBackground();
|
|
254
297
|
this.buildWaves();
|
|
255
298
|
this.resize();
|
|
@@ -372,6 +415,7 @@ var WaveRenderer = class {
|
|
|
372
415
|
uPointerPush: { value: 0 },
|
|
373
416
|
uPointerWake: { value: 0 },
|
|
374
417
|
uPointerVel: { value: new THREE.Vector2(0, 0) },
|
|
418
|
+
uShapeFlow: { value: 0 },
|
|
375
419
|
uPointerThin: { value: 0 },
|
|
376
420
|
uPointerHue: { value: 0 },
|
|
377
421
|
uPointerLighten: { value: 0 },
|
|
@@ -605,6 +649,7 @@ var WaveRenderer = class {
|
|
|
605
649
|
u.uOpacity.value = sc.opacity;
|
|
606
650
|
});
|
|
607
651
|
const sharedRadius = (this.config.interaction?.radius ?? .3) * 2;
|
|
652
|
+
const sharedFlow = this.config.interaction?.ribbonFlow ?? .8;
|
|
608
653
|
this.waves.forEach((wave, i) => {
|
|
609
654
|
const sc = this.config.waves[i] ?? this.config.waves[this.config.waves.length - 1];
|
|
610
655
|
if (!wavePointerFxActive(this.config, sc)) return;
|
|
@@ -618,6 +663,7 @@ var WaveRenderer = class {
|
|
|
618
663
|
u.uPointerHue.value = h?.hueShift ?? 0;
|
|
619
664
|
u.uPointerLighten.value = h?.lighten ?? 0;
|
|
620
665
|
u.uPointerRipple.value = sc.interaction?.press?.ripple ?? 0;
|
|
666
|
+
u.uShapeFlow.value = sharedFlow;
|
|
621
667
|
});
|
|
622
668
|
this.updatePaletteTextures();
|
|
623
669
|
this.syncVideoPlayback();
|
|
@@ -890,6 +936,12 @@ var WaveRenderer = class {
|
|
|
890
936
|
u.uGrainAmount.value = this.config.grain;
|
|
891
937
|
u.uBlurSamples.value = Math.round(this.config.blurSamples ?? 6);
|
|
892
938
|
this.applyBloom();
|
|
939
|
+
this.applyInnerLight();
|
|
940
|
+
this.applyHalftone();
|
|
941
|
+
this.applyHeatmap();
|
|
942
|
+
this.applyHalftoneCmyk();
|
|
943
|
+
this.applyPaperTexture();
|
|
944
|
+
this.applyDither();
|
|
893
945
|
}
|
|
894
946
|
/** Insert / tune / remove the bloom pass. strength 0 removes it from the composer entirely, so
|
|
895
947
|
* cost and pixels are identical to bloom-off; the pass (and its mip-chain render targets) is
|
|
@@ -912,9 +964,223 @@ var WaveRenderer = class {
|
|
|
912
964
|
this.bloomPass = void 0;
|
|
913
965
|
}
|
|
914
966
|
}
|
|
967
|
+
/** Insert / tune / remove the dithering pass — a self-contained "layered" post shader (an ordered
|
|
968
|
+
* Bayer dither, in the spirit of paper-design/shaders). Like bloom, dither 0 removes the pass
|
|
969
|
+
* entirely so cost and pixels match dither-off, and it's created lazily on first enable. It is
|
|
970
|
+
* appended AFTER OutputPass so it runs last and quantizes display-space colour (tone-mapped +
|
|
971
|
+
* sRGB) — dithering the linear composer buffer would crush the steps in the shadows. */
|
|
972
|
+
applyDither() {
|
|
973
|
+
const strength = this.config.dither ?? 0;
|
|
974
|
+
if (strength > 0) {
|
|
975
|
+
if (!this.ditherPass) {
|
|
976
|
+
this.ditherPass = new ShaderPass({
|
|
977
|
+
uniforms: {
|
|
978
|
+
tDiffuse: { value: null },
|
|
979
|
+
uResolution: { value: this.renderer.getDrawingBufferSize(new THREE.Vector2()) },
|
|
980
|
+
uDitherStrength: { value: strength },
|
|
981
|
+
uDitherScale: { value: this.config.ditherScale ?? 2 },
|
|
982
|
+
uDitherSteps: { value: this.config.ditherSteps ?? 4 }
|
|
983
|
+
},
|
|
984
|
+
vertexShader: postVertexShader,
|
|
985
|
+
fragmentShader: ditherFragmentShader
|
|
986
|
+
});
|
|
987
|
+
this.composer.addPass(this.ditherPass);
|
|
988
|
+
}
|
|
989
|
+
const u = this.ditherPass.uniforms;
|
|
990
|
+
u.uDitherStrength.value = strength;
|
|
991
|
+
u.uDitherScale.value = Math.max(1, this.config.ditherScale ?? 2);
|
|
992
|
+
u.uDitherSteps.value = Math.max(2, Math.round(this.config.ditherSteps ?? 4));
|
|
993
|
+
} else if (this.ditherPass) {
|
|
994
|
+
this.composer.removePass(this.ditherPass);
|
|
995
|
+
this.ditherPass.dispose();
|
|
996
|
+
this.ditherPass = void 0;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
/** Insert / tune / remove the innerLight pass — volumetric light streaks scattered from the bright
|
|
1000
|
+
* wave toward a light point (innerLightX/Y in UV). Scene zone (index 1) so it scatters the raw wave
|
|
1001
|
+
* like bloom. innerLight 0 removes the pass entirely; created lazily on first enable. */
|
|
1002
|
+
applyInnerLight() {
|
|
1003
|
+
const strength = this.config.innerLight ?? 0;
|
|
1004
|
+
if (strength > 0) {
|
|
1005
|
+
const cx = this.config.innerLightX ?? .5;
|
|
1006
|
+
const cy = this.config.innerLightY ?? .15;
|
|
1007
|
+
if (!this.innerLightPass) {
|
|
1008
|
+
this.innerLightPass = new ShaderPass({
|
|
1009
|
+
uniforms: {
|
|
1010
|
+
tDiffuse: { value: null },
|
|
1011
|
+
uInnerLight: { value: strength },
|
|
1012
|
+
uInnerLightDensity: { value: this.config.innerLightDensity ?? .5 },
|
|
1013
|
+
uInnerLightDecay: { value: this.config.innerLightDecay ?? .95 },
|
|
1014
|
+
uInnerLightCenter: { value: new THREE.Vector2(cx, cy) }
|
|
1015
|
+
},
|
|
1016
|
+
vertexShader: postVertexShader,
|
|
1017
|
+
fragmentShader: innerLightFragmentShader
|
|
1018
|
+
});
|
|
1019
|
+
this.composer.insertPass(this.innerLightPass, 1);
|
|
1020
|
+
}
|
|
1021
|
+
const u = this.innerLightPass.uniforms;
|
|
1022
|
+
u.uInnerLight.value = strength;
|
|
1023
|
+
u.uInnerLightDensity.value = this.config.innerLightDensity ?? .5;
|
|
1024
|
+
u.uInnerLightDecay.value = this.config.innerLightDecay ?? .95;
|
|
1025
|
+
u.uInnerLightCenter.value.set(cx, cy);
|
|
1026
|
+
} else if (this.innerLightPass) {
|
|
1027
|
+
this.composer.removePass(this.innerLightPass);
|
|
1028
|
+
this.innerLightPass.dispose();
|
|
1029
|
+
this.innerLightPass = void 0;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
/** Insert / tune / remove the halftone pass — a rotated dot screen (dot size scales with local
|
|
1033
|
+
* brightness) over the finished image. halftone 0 removes the pass; created lazily on enable. */
|
|
1034
|
+
applyHalftone() {
|
|
1035
|
+
const strength = this.config.halftone ?? 0;
|
|
1036
|
+
if (strength > 0) {
|
|
1037
|
+
if (!this.halftonePass) {
|
|
1038
|
+
this.halftonePass = new ShaderPass({
|
|
1039
|
+
uniforms: {
|
|
1040
|
+
tDiffuse: { value: null },
|
|
1041
|
+
uResolution: { value: this.renderer.getDrawingBufferSize(new THREE.Vector2()) },
|
|
1042
|
+
uHalftone: { value: strength },
|
|
1043
|
+
uHalftoneCell: { value: this.config.halftoneCell ?? 6 },
|
|
1044
|
+
uHalftoneAngle: { value: this.config.halftoneAngle ?? .4 }
|
|
1045
|
+
},
|
|
1046
|
+
vertexShader: postVertexShader,
|
|
1047
|
+
fragmentShader: halftoneFragmentShader
|
|
1048
|
+
});
|
|
1049
|
+
this.composer.addPass(this.halftonePass);
|
|
1050
|
+
}
|
|
1051
|
+
const u = this.halftonePass.uniforms;
|
|
1052
|
+
u.uHalftone.value = strength;
|
|
1053
|
+
u.uHalftoneCell.value = Math.max(2, this.config.halftoneCell ?? 6);
|
|
1054
|
+
u.uHalftoneAngle.value = this.config.halftoneAngle ?? .4;
|
|
1055
|
+
} else if (this.halftonePass) {
|
|
1056
|
+
this.composer.removePass(this.halftonePass);
|
|
1057
|
+
this.halftonePass.dispose();
|
|
1058
|
+
this.halftonePass = void 0;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
/** Heatmap: recolour the final image by luminance → thermal palette. Finish zone. */
|
|
1062
|
+
applyHeatmap() {
|
|
1063
|
+
const strength = this.config.heatmap ?? 0;
|
|
1064
|
+
if (strength > 0) {
|
|
1065
|
+
if (!this.heatmapPass) {
|
|
1066
|
+
this.heatmapPass = new ShaderPass({
|
|
1067
|
+
uniforms: {
|
|
1068
|
+
tDiffuse: { value: null },
|
|
1069
|
+
uHeatmap: { value: strength }
|
|
1070
|
+
},
|
|
1071
|
+
vertexShader: postVertexShader,
|
|
1072
|
+
fragmentShader: heatmapFragmentShader
|
|
1073
|
+
});
|
|
1074
|
+
this.composer.addPass(this.heatmapPass);
|
|
1075
|
+
}
|
|
1076
|
+
this.heatmapPass.uniforms.uHeatmap.value = strength;
|
|
1077
|
+
} else if (this.heatmapPass) {
|
|
1078
|
+
this.composer.removePass(this.heatmapPass);
|
|
1079
|
+
this.heatmapPass.dispose();
|
|
1080
|
+
this.heatmapPass = void 0;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
/** Paper texture: fibrous substrate shading multiplied over the image. Finish zone. */
|
|
1084
|
+
applyPaperTexture() {
|
|
1085
|
+
const strength = this.config.paperTexture ?? 0;
|
|
1086
|
+
if (strength > 0) {
|
|
1087
|
+
if (!this.paperTexturePass) {
|
|
1088
|
+
this.paperTexturePass = new ShaderPass({
|
|
1089
|
+
uniforms: {
|
|
1090
|
+
tDiffuse: { value: null },
|
|
1091
|
+
uPaper: { value: strength },
|
|
1092
|
+
uPaperScale: { value: this.config.paperTextureScale ?? 2 }
|
|
1093
|
+
},
|
|
1094
|
+
vertexShader: postVertexShader,
|
|
1095
|
+
fragmentShader: paperTextureFragmentShader
|
|
1096
|
+
});
|
|
1097
|
+
this.composer.addPass(this.paperTexturePass);
|
|
1098
|
+
}
|
|
1099
|
+
const u = this.paperTexturePass.uniforms;
|
|
1100
|
+
u.uPaper.value = strength;
|
|
1101
|
+
u.uPaperScale.value = Math.max(.5, this.config.paperTextureScale ?? 2);
|
|
1102
|
+
} else if (this.paperTexturePass) {
|
|
1103
|
+
this.composer.removePass(this.paperTexturePass);
|
|
1104
|
+
this.paperTexturePass.dispose();
|
|
1105
|
+
this.paperTexturePass = void 0;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
/** CMYK halftone: four rotated dot screens (cyan/magenta/yellow/black). Finish zone. */
|
|
1109
|
+
applyHalftoneCmyk() {
|
|
1110
|
+
const strength = this.config.halftoneCmyk ?? 0;
|
|
1111
|
+
if (strength > 0) {
|
|
1112
|
+
if (!this.halftoneCmykPass) {
|
|
1113
|
+
this.halftoneCmykPass = new ShaderPass({
|
|
1114
|
+
uniforms: {
|
|
1115
|
+
tDiffuse: { value: null },
|
|
1116
|
+
uHalftoneCmyk: { value: strength },
|
|
1117
|
+
uHalftoneCmykCell: { value: this.config.halftoneCmykCell ?? 6 }
|
|
1118
|
+
},
|
|
1119
|
+
vertexShader: postVertexShader,
|
|
1120
|
+
fragmentShader: halftoneCmykFragmentShader
|
|
1121
|
+
});
|
|
1122
|
+
this.composer.addPass(this.halftoneCmykPass);
|
|
1123
|
+
}
|
|
1124
|
+
const u = this.halftoneCmykPass.uniforms;
|
|
1125
|
+
u.uHalftoneCmyk.value = strength;
|
|
1126
|
+
u.uHalftoneCmykCell.value = Math.max(2, this.config.halftoneCmykCell ?? 6);
|
|
1127
|
+
} else if (this.halftoneCmykPass) {
|
|
1128
|
+
this.composer.removePass(this.halftoneCmykPass);
|
|
1129
|
+
this.halftoneCmykPass.dispose();
|
|
1130
|
+
this.halftoneCmykPass = void 0;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
/** Coalesce observer-driven resizes to one per frame, and drop any that don't move a device pixel.
|
|
1134
|
+
*
|
|
1135
|
+
* resize() is expensive — composer.setSize reallocates every pass's render target, and
|
|
1136
|
+
* applyBackground() rebuilds a container-sized canvas + texture for gradient/image backgrounds.
|
|
1137
|
+
* The old 1:1 `observe → resize()` paid that for observations that changed nothing: the observer
|
|
1138
|
+
* reports fractional content-box sizes, so sub-pixel layout shifts (and anything that rounds to
|
|
1139
|
+
* the same backing buffer) triggered a full reallocation, as did every observation while an
|
|
1140
|
+
* export frame is pinned and the container is not what drives the buffer at all.
|
|
1141
|
+
*
|
|
1142
|
+
* Genuine per-frame changes — a mobile URL bar collapsing animates the container height — still
|
|
1143
|
+
* resize every frame. That work is necessary; the canvas would otherwise stretch. What is
|
|
1144
|
+
* removed is the redundant work, not the real work.
|
|
1145
|
+
*
|
|
1146
|
+
* Only this path is throttled. `resize()` itself stays synchronous and unconditional — context
|
|
1147
|
+
* restore and setOutputSize must re-apply immediately, and on a fresh GPU context the metrics
|
|
1148
|
+
* are unchanged but the resources are not. */
|
|
915
1149
|
onResize = () => {
|
|
1150
|
+
if (this.resizeRaf) return;
|
|
1151
|
+
this.resizeRaf = requestAnimationFrame(() => {
|
|
1152
|
+
this.resizeRaf = 0;
|
|
1153
|
+
const next = this.viewportMetrics();
|
|
1154
|
+
const last = this.lastResize;
|
|
1155
|
+
if (last && next.w === last.w && next.h === last.h && next.dpr === last.dpr) return;
|
|
1156
|
+
this.resize();
|
|
1157
|
+
});
|
|
1158
|
+
};
|
|
1159
|
+
/** Re-arm the DPR watch and re-render at the new device-pixel ratio.
|
|
1160
|
+
*
|
|
1161
|
+
* ResizeObserver watches the CSS box only, so browser zoom or dragging the window to a monitor
|
|
1162
|
+
* with a different DPR changes devicePixelRatio without changing the box — the backing buffer
|
|
1163
|
+
* stayed at the old resolution and the wave went soft until something else forced a resize. */
|
|
1164
|
+
onDprChange = () => {
|
|
1165
|
+
this.watchDpr();
|
|
916
1166
|
this.resize();
|
|
917
1167
|
};
|
|
1168
|
+
/** A `(resolution: Xdppx)` query only fires when we LEAVE the current ratio, so it is re-armed at
|
|
1169
|
+
* the new one on every change. */
|
|
1170
|
+
watchDpr() {
|
|
1171
|
+
this.dprQuery?.removeEventListener("change", this.onDprChange);
|
|
1172
|
+
this.dprQuery = window.matchMedia(`(resolution: ${window.devicePixelRatio || 1}dppx)`);
|
|
1173
|
+
this.dprQuery.addEventListener("change", this.onDprChange);
|
|
1174
|
+
}
|
|
1175
|
+
/** The backing-buffer metrics resize() will apply: the export frame when one is pinned, else the
|
|
1176
|
+
* container box at the (clamped) device-pixel ratio. */
|
|
1177
|
+
viewportMetrics() {
|
|
1178
|
+
return {
|
|
1179
|
+
w: this.outputSize?.width ?? Math.max(1, this.container.clientWidth),
|
|
1180
|
+
h: this.outputSize?.height ?? Math.max(1, this.container.clientHeight),
|
|
1181
|
+
dpr: this.outputSize ? 1 : Math.min(window.devicePixelRatio || 1, this.config.dprMax)
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
918
1184
|
onContextLost = (e) => {
|
|
919
1185
|
e.preventDefault();
|
|
920
1186
|
cancelAnimationFrame(this.rafId);
|
|
@@ -930,9 +1196,12 @@ var WaveRenderer = class {
|
|
|
930
1196
|
this.updateRunning();
|
|
931
1197
|
};
|
|
932
1198
|
resize() {
|
|
933
|
-
const w
|
|
934
|
-
|
|
935
|
-
|
|
1199
|
+
const { w, h, dpr } = this.viewportMetrics();
|
|
1200
|
+
this.lastResize = {
|
|
1201
|
+
w,
|
|
1202
|
+
h,
|
|
1203
|
+
dpr
|
|
1204
|
+
};
|
|
936
1205
|
this.renderer.setPixelRatio(dpr);
|
|
937
1206
|
this.renderer.setSize(w, h, !this.outputSize);
|
|
938
1207
|
if (this.outputSize) {
|
|
@@ -944,6 +1213,8 @@ var WaveRenderer = class {
|
|
|
944
1213
|
const dw = w * dpr;
|
|
945
1214
|
const dh = h * dpr;
|
|
946
1215
|
this.postPass.uniforms.uResolution.value.set(dw, dh);
|
|
1216
|
+
if (this.ditherPass) this.ditherPass.uniforms.uResolution.value.set(dw, dh);
|
|
1217
|
+
if (this.halftonePass) this.halftonePass.uniforms.uResolution.value.set(dw, dh);
|
|
947
1218
|
for (const s of this.waves) s.material.uniforms.uResolution.value.set(dw, dh);
|
|
948
1219
|
this.camera.left = -dw / 2;
|
|
949
1220
|
this.camera.right = dw / 2;
|
|
@@ -1204,16 +1475,20 @@ var WaveRenderer = class {
|
|
|
1204
1475
|
onAfterRenderFrame() {}
|
|
1205
1476
|
/** Hook ④: called at the end of resize(), before the trailing renderOnce(). */
|
|
1206
1477
|
onAfterResize() {}
|
|
1207
|
-
/** Responsive ortho zoom:
|
|
1208
|
-
*
|
|
1209
|
-
*
|
|
1210
|
-
* contain (fit with letterbox bands). Cover keeps the wave filling the frame on every screen. */
|
|
1478
|
+
/** Responsive ortho zoom: map the FRAME_W × FRAME_H reference frame onto the canvas (per
|
|
1479
|
+
* config.cameraFit / cameraMinVisibleWidth — see {@link frameZoom}) so the wave frames the same
|
|
1480
|
+
* at any size/aspect/dpr, times the user's cameraZoom. */
|
|
1211
1481
|
applyZoom() {
|
|
1212
1482
|
const dw = this.camera.right - this.camera.left;
|
|
1213
1483
|
const dh = this.camera.top - this.camera.bottom;
|
|
1214
|
-
this.camera.zoom =
|
|
1484
|
+
this.camera.zoom = this.baseFrameZoom(dw, dh) * (this.config.cameraZoom ?? 1) * this.interactionZoom;
|
|
1215
1485
|
this.camera.updateProjectionMatrix();
|
|
1216
1486
|
}
|
|
1487
|
+
/** The responsive base zoom for the current config's framing policy, before the cameraZoom
|
|
1488
|
+
* multiplier. Subclasses invert this to recover cameraZoom from a live camera. */
|
|
1489
|
+
baseFrameZoom(dw, dh) {
|
|
1490
|
+
return frameZoom(dw, dh, this.config.cameraFit ?? "cover", this.config.cameraMinVisibleWidth ?? 0);
|
|
1491
|
+
}
|
|
1217
1492
|
/** Fit the orthographic near/far planes to the scene before every render, so no part of a wave
|
|
1218
1493
|
* is ever clipped as the camera orbits / dollies / pans (or when waves are added or scaled).
|
|
1219
1494
|
*
|
|
@@ -1326,12 +1601,14 @@ var WaveRenderer = class {
|
|
|
1326
1601
|
}
|
|
1327
1602
|
dispose() {
|
|
1328
1603
|
cancelAnimationFrame(this.rafId);
|
|
1604
|
+
cancelAnimationFrame(this.resizeRaf);
|
|
1329
1605
|
this.running = false;
|
|
1330
1606
|
this.interaction?.dispose();
|
|
1331
1607
|
this.interaction = void 0;
|
|
1332
1608
|
this.resizeObserver.disconnect();
|
|
1333
1609
|
this.intersectionObserver.disconnect();
|
|
1334
1610
|
this.motionQuery.removeEventListener("change", this.onMotionChange);
|
|
1611
|
+
this.dprQuery?.removeEventListener("change", this.onDprChange);
|
|
1335
1612
|
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
|
1336
1613
|
this.renderer.domElement.removeEventListener("webglcontextlost", this.onContextLost);
|
|
1337
1614
|
this.renderer.domElement.removeEventListener("webglcontextrestored", this.onContextRestored);
|
|
@@ -1343,12 +1620,18 @@ var WaveRenderer = class {
|
|
|
1343
1620
|
s.palette.dispose();
|
|
1344
1621
|
}
|
|
1345
1622
|
this.bloomPass?.dispose();
|
|
1623
|
+
this.ditherPass?.dispose();
|
|
1624
|
+
this.innerLightPass?.dispose();
|
|
1625
|
+
this.halftonePass?.dispose();
|
|
1626
|
+
this.heatmapPass?.dispose();
|
|
1627
|
+
this.paperTexturePass?.dispose();
|
|
1628
|
+
this.halftoneCmykPass?.dispose();
|
|
1346
1629
|
this.composer.dispose();
|
|
1347
1630
|
this.renderer.dispose();
|
|
1348
1631
|
this.renderer.domElement.remove();
|
|
1349
1632
|
}
|
|
1350
1633
|
};
|
|
1351
1634
|
//#endregion
|
|
1352
|
-
export { FRAME_H, FRAME_W, WaveRenderer, hexToLinearVec3 };
|
|
1635
|
+
export { FRAME_H, FRAME_W, WaveRenderer, frameZoom, hexToLinearVec3 };
|
|
1353
1636
|
|
|
1354
1637
|
//# sourceMappingURL=WaveRenderer.js.map
|