@wave3d/core 0.4.1 → 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.
@@ -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 COVERING this FRAME_W × FRAME_H rectangle (centred on cameraTarget) into the canvas
17
- * scaled to fill both dimensions, cropping the aspect overflow so a given cameraZoom /
18
- * cameraTarget frames the wave the SAME at any canvas size or aspect (only the cropped margin
19
- * differs). FRAME_H = FRAME_W / (16/9) makes the reference a 16:9 rectangle; for canvases wider
20
- * than that the width binds, narrower ones zoom in to fill instead of
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).
@@ -201,6 +232,11 @@ var WaveRenderer = class {
201
232
  resizeObserver;
202
233
  intersectionObserver;
203
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;
204
240
  capturing = false;
205
241
  /** Fixed backing-buffer dimensions used by the studio's visible export frame. Embeds leave
206
242
  * this unset and continue to resize responsively with their container and device DPR. */
@@ -256,6 +292,7 @@ var WaveRenderer = class {
256
292
  this.intersectionObserver.observe(container);
257
293
  this.resizeObserver = new ResizeObserver(this.onResize);
258
294
  this.resizeObserver.observe(container);
295
+ this.watchDpr();
259
296
  this.applyBackground();
260
297
  this.buildWaves();
261
298
  this.resize();
@@ -1093,9 +1130,57 @@ var WaveRenderer = class {
1093
1130
  this.halftoneCmykPass = void 0;
1094
1131
  }
1095
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. */
1096
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();
1097
1166
  this.resize();
1098
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
+ }
1099
1184
  onContextLost = (e) => {
1100
1185
  e.preventDefault();
1101
1186
  cancelAnimationFrame(this.rafId);
@@ -1111,9 +1196,12 @@ var WaveRenderer = class {
1111
1196
  this.updateRunning();
1112
1197
  };
1113
1198
  resize() {
1114
- const w = this.outputSize?.width ?? Math.max(1, this.container.clientWidth);
1115
- const h = this.outputSize?.height ?? Math.max(1, this.container.clientHeight);
1116
- const dpr = this.outputSize ? 1 : Math.min(window.devicePixelRatio || 1, this.config.dprMax);
1199
+ const { w, h, dpr } = this.viewportMetrics();
1200
+ this.lastResize = {
1201
+ w,
1202
+ h,
1203
+ dpr
1204
+ };
1117
1205
  this.renderer.setPixelRatio(dpr);
1118
1206
  this.renderer.setSize(w, h, !this.outputSize);
1119
1207
  if (this.outputSize) {
@@ -1387,16 +1475,20 @@ var WaveRenderer = class {
1387
1475
  onAfterRenderFrame() {}
1388
1476
  /** Hook ④: called at the end of resize(), before the trailing renderOnce(). */
1389
1477
  onAfterResize() {}
1390
- /** Responsive ortho zoom: COVER the FRAME_W × FRAME_H reference frame onto the canvas so the
1391
- * wave frames the same at any size/aspect/dpr (only the cropped margin differs), times the
1392
- * user's cameraZoom. `max(...)` = cover (fill both axes, crop overflow); `min(...)` would be
1393
- * 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. */
1394
1481
  applyZoom() {
1395
1482
  const dw = this.camera.right - this.camera.left;
1396
1483
  const dh = this.camera.top - this.camera.bottom;
1397
- this.camera.zoom = Math.max(dw / FRAME_W, dh / 750) * (this.config.cameraZoom ?? 1) * this.interactionZoom;
1484
+ this.camera.zoom = this.baseFrameZoom(dw, dh) * (this.config.cameraZoom ?? 1) * this.interactionZoom;
1398
1485
  this.camera.updateProjectionMatrix();
1399
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
+ }
1400
1492
  /** Fit the orthographic near/far planes to the scene before every render, so no part of a wave
1401
1493
  * is ever clipped as the camera orbits / dollies / pans (or when waves are added or scaled).
1402
1494
  *
@@ -1509,12 +1601,14 @@ var WaveRenderer = class {
1509
1601
  }
1510
1602
  dispose() {
1511
1603
  cancelAnimationFrame(this.rafId);
1604
+ cancelAnimationFrame(this.resizeRaf);
1512
1605
  this.running = false;
1513
1606
  this.interaction?.dispose();
1514
1607
  this.interaction = void 0;
1515
1608
  this.resizeObserver.disconnect();
1516
1609
  this.intersectionObserver.disconnect();
1517
1610
  this.motionQuery.removeEventListener("change", this.onMotionChange);
1611
+ this.dprQuery?.removeEventListener("change", this.onDprChange);
1518
1612
  document.removeEventListener("visibilitychange", this.onVisibilityChange);
1519
1613
  this.renderer.domElement.removeEventListener("webglcontextlost", this.onContextLost);
1520
1614
  this.renderer.domElement.removeEventListener("webglcontextrestored", this.onContextRestored);
@@ -1538,6 +1632,6 @@ var WaveRenderer = class {
1538
1632
  }
1539
1633
  };
1540
1634
  //#endregion
1541
- export { FRAME_H, FRAME_W, WaveRenderer, hexToLinearVec3 };
1635
+ export { FRAME_H, FRAME_W, WaveRenderer, frameZoom, hexToLinearVec3 };
1542
1636
 
1543
1637
  //# sourceMappingURL=WaveRenderer.js.map