leafthrough 0.1.6 → 0.1.7

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/README.md CHANGED
@@ -190,6 +190,10 @@ renderer.setAnimationLoop((now) => {
190
190
  `receiveShadow` on the sheets, which the magazine sets itself.
191
191
  - **Depth.** See [Depth](#depth): the sheets stand a little apart, which needs enough depth
192
192
  precision from your camera.
193
+ - **Several magazines.** Magazines drawn by the same renderer share the built-in grain's
194
+ texture and the sheet geometry of each layout and page aspect: made for the first, freed
195
+ with the last. The grain itself is drawn once per page, and the wrinkles are sampled once
196
+ per aspect.
193
197
  - **Rendering on demand.** If you only draw when something changes, listen for `change`:
194
198
  it fires when input, loading or a method call needs a frame. Then keep drawing while
195
199
  `tick()` returns true.
@@ -311,14 +315,14 @@ The magazine fills the element you give it and keeps its proportions, centred.
311
315
  | `formatPageLabel` | `(detail) => string` | `"Pages 14–15 of 28"` | What screen readers hear when pages change. |
312
316
  | `onPageChange` | `(detail) => void` | | Same as the `pagechange` event. |
313
317
  | `onReady` | `() => void` | | The first pages are loaded and on screen. |
314
- | `onPageLoad` | `(page) => void` | | A page's image loaded. Same as the `pageload` event. |
318
+ | `onPageLoad` | `(page) => void` | | A page's image is loaded and on the GPU. Same as the `pageload` event. |
315
319
  | `onError` | `(error, page?) => void` | | A page failed to load. It stays blank paper until `retry()`. |
316
320
 
317
321
  **`paper`**
318
322
 
319
323
  | | Default | |
320
324
  | ------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------ |
321
- | `grain` | `true` | `false` turns it off. You can also pass a tiling greyscale image or its URL; only its brightest specks show. |
325
+ | `grain` | `true` | `false` turns it off. You can also pass a tiling greyscale image or its URL; only its brightest specks show. The built-in one is drawn once per page, however many magazines there are. |
322
326
  | `grainScale` | `1.46` | Tiles across a page. |
323
327
  | `grainStrength` | `0.17` | How much the grain darkens. |
324
328
  | `showThrough` | spread `0.04`, single `0.3` | How much of the other side shows through the paper. |
@@ -411,11 +415,17 @@ pages: [
411
415
  front page's outline (draw the back page as its mirror image).
412
416
  - **Cross-origin URLs.** URLs on another origin need CORS headers, because the images are
413
417
  uploaded to WebGL.
414
- - **Textures.** A three.js `Texture` you pass in is never disposed, but the magazine sets its
415
- `colorSpace`, `premultiplyAlpha` and `anisotropy`. Give it a texture of its own if you
416
- use the same one elsewhere.
418
+ - **Textures.** A three.js `Texture` you pass in is taken as it is: never disposed, and its
419
+ `colorSpace`, `premultiplyAlpha` and `anisotropy` are left alone, so one that's on the
420
+ GPU already, or that your own material uses too, isn't uploaded again. Pages the magazine
421
+ loads itself are sRGB and premultiplied; for yours to look the same, set `colorSpace` to
422
+ `THREE.SRGBColorSpace`, and `premultiplyAlpha` to `true` if it has transparent parts (the
423
+ shader lays it over the paper as premultiplied colour).
417
424
  - **Load order.** Pages load nearest the reader first, four at a time, and the order follows
418
- the reader as they turn or jump. Pages that haven't arrived show blank paper.
425
+ the reader as they turn or jump. Pages that haven't arrived show blank paper. Once decoded,
426
+ the pages in view and next to them go to the GPU at once; the rest go one per frame, or
427
+ more in idle time, so a dozen arriving together don't drop a frame. `pageload` fires for
428
+ each as it's on the GPU.
419
429
  - **Memory.** Pages within `preload` of the visible ones load; pages more than twice that
420
430
  far away unload, freeing their GPU memory, and load again when the reader comes back.
421
431
  Visible means anything on screen: pages still turning in a quick riffle, and in `single`
@@ -250,11 +250,11 @@ function displayImage(source, alt = "") {
250
250
  return img;
251
251
  }
252
252
  if (typeof HTMLCanvasElement !== "undefined" && (drawable instanceof HTMLCanvasElement || drawable instanceof ImageBitmap)) {
253
- const canvas = document.createElement("canvas");
254
- canvas.width = drawable.width;
255
- canvas.height = drawable.height;
256
- canvas.getContext("2d")?.drawImage(drawable, 0, 0);
257
- return describe(canvas, alt, style);
253
+ const canvas2 = document.createElement("canvas");
254
+ canvas2.width = drawable.width;
255
+ canvas2.height = drawable.height;
256
+ canvas2.getContext("2d")?.drawImage(drawable, 0, 0);
257
+ return describe(canvas2, alt, style);
258
258
  }
259
259
  return describe(document.createElement("div"), alt, style);
260
260
  }
@@ -426,6 +426,10 @@ import * as THREE7 from "three";
426
426
  // src/textures.ts
427
427
  import * as THREE4 from "three";
428
428
  var CONCURRENCY = 4;
429
+ var NEAR = 1;
430
+ var UPLOADS_PER_FRAME = 1;
431
+ var IDLE_SPARE = 8;
432
+ var UPLOAD_TIMEOUT = 100;
429
433
  var PageLoader = class {
430
434
  constructor(sources, renderer, radius, events) {
431
435
  this.sources = sources;
@@ -443,6 +447,9 @@ var PageLoader = class {
443
447
  owned = /* @__PURE__ */ new Set();
444
448
  inFlight = /* @__PURE__ */ new Map();
445
449
  failed = /* @__PURE__ */ new Set();
450
+ // decoded pages waiting for their turn on the GPU
451
+ pending = /* @__PURE__ */ new Map();
452
+ cancelUploads = null;
446
453
  // `first` pages load before the rest; the rest go nearest first
447
454
  first = [];
448
455
  // the pages in view, and the one being read, which loads first among them
@@ -491,6 +498,15 @@ var PageLoader = class {
491
498
  this.owned.delete(texture);
492
499
  texture.dispose();
493
500
  });
501
+ for (const [i, texture] of this.pending) {
502
+ if (this.distance(i) > keep) {
503
+ this.pending.delete(i);
504
+ if (this.owned.delete(texture)) texture.dispose();
505
+ } else if (this.distance(i) <= NEAR) {
506
+ this.pending.delete(i);
507
+ this.show(i, texture);
508
+ }
509
+ }
494
510
  this.pump();
495
511
  }
496
512
  setWrap(wrap) {
@@ -509,6 +525,9 @@ var PageLoader = class {
509
525
  }
510
526
  dispose() {
511
527
  this.disposed = true;
528
+ this.cancelUploads?.();
529
+ this.cancelUploads = null;
530
+ this.pending.clear();
512
531
  for (const request of this.inFlight.values()) request.abort();
513
532
  for (const texture of this.owned) texture.dispose();
514
533
  this.owned.clear();
@@ -523,7 +542,7 @@ var PageLoader = class {
523
542
  // starts the most wanted pages while there's room
524
543
  pump() {
525
544
  if (this.disposed || this.inFlight.size >= CONCURRENCY) return;
526
- const wanted = (i) => !this.textures[i] && !this.inFlight.has(i) && !this.failed.has(i) && this.distance(i) <= this.radius;
545
+ const wanted = (i) => !this.textures[i] && !this.inFlight.has(i) && !this.pending.has(i) && !this.failed.has(i) && this.distance(i) <= this.radius;
527
546
  const queue = [
528
547
  ...this.first.filter((i) => this.waiting.has(i)),
529
548
  ...this.sources.map((_, i) => i).sort((a, b) => this.distance(a) - this.distance(b) || Math.abs(a - this.around) - Math.abs(b - this.around))
@@ -541,13 +560,17 @@ var PageLoader = class {
541
560
  if (this.owned.delete(texture)) texture.dispose();
542
561
  return;
543
562
  }
544
- texture.colorSpace = THREE4.SRGBColorSpace;
545
- texture.premultiplyAlpha = true;
546
- texture.anisotropy = this.renderer.capabilities.getMaxAnisotropy();
547
- texture.needsUpdate = true;
548
- this.renderer.initTexture(texture);
549
- this.textures[page] = texture;
550
- this.events.onLoad(page, texture);
563
+ if (this.owned.has(texture)) {
564
+ texture.colorSpace = THREE4.SRGBColorSpace;
565
+ texture.premultiplyAlpha = true;
566
+ texture.anisotropy = this.renderer.capabilities.getMaxAnisotropy();
567
+ texture.needsUpdate = true;
568
+ }
569
+ if (this.waiting.has(page) || this.distance(page) <= NEAR) this.show(page, texture);
570
+ else {
571
+ this.pending.set(page, texture);
572
+ this.scheduleUploads();
573
+ }
551
574
  }).catch((error) => {
552
575
  if (this.disposed || request.signal.aborted) return;
553
576
  this.failed.add(page);
@@ -558,6 +581,41 @@ var PageLoader = class {
558
581
  this.pump();
559
582
  });
560
583
  }
584
+ // Puts the page's texture on the GPU and hands it over. A texture passed in is taken as it
585
+ // is, so one that's on the GPU already, or shared with the caller's own material, isn't
586
+ // uploaded again: initTexture only uploads what the renderer hasn't got.
587
+ show(page, texture) {
588
+ this.renderer.initTexture(texture);
589
+ this.textures[page] = texture;
590
+ this.events.onLoad(page, texture);
591
+ }
592
+ // the next pending pages, in idle time where the browser offers it, else before the next frame
593
+ scheduleUploads() {
594
+ if (this.cancelUploads || !this.pending.size) return;
595
+ if (typeof requestIdleCallback === "function") {
596
+ const id = requestIdleCallback((deadline) => this.uploadSome(deadline), { timeout: UPLOAD_TIMEOUT });
597
+ this.cancelUploads = () => cancelIdleCallback(id);
598
+ } else {
599
+ const id = requestAnimationFrame(() => this.uploadSome());
600
+ this.cancelUploads = () => cancelAnimationFrame(id);
601
+ }
602
+ }
603
+ // nearest the reader first
604
+ uploadSome(deadline) {
605
+ this.cancelUploads = null;
606
+ const queue = [...this.pending.keys()].sort(
607
+ (a, b) => this.distance(a) - this.distance(b) || Math.abs(a - this.around) - Math.abs(b - this.around)
608
+ );
609
+ let count = 0;
610
+ for (const page of queue) {
611
+ if (count >= UPLOADS_PER_FRAME && !(deadline && deadline.timeRemaining() > IDLE_SPARE)) break;
612
+ const texture = this.pending.get(page);
613
+ this.pending.delete(page);
614
+ this.show(page, texture);
615
+ count++;
616
+ }
617
+ this.scheduleUploads();
618
+ }
561
619
  async createTexture(source, signal) {
562
620
  if (source instanceof THREE4.Texture) return source;
563
621
  const own = (texture) => (this.owned.add(texture), texture);
@@ -567,11 +625,11 @@ var PageLoader = class {
567
625
  if (!source.complete || !source.naturalWidth) await source.decode();
568
626
  return own(new THREE4.Texture(source));
569
627
  }
570
- const canvas = document.createElement("canvas");
571
- canvas.width = source.width;
572
- canvas.height = source.height;
573
- canvas.getContext("2d").drawImage(source, 0, 0);
574
- return own(new THREE4.CanvasTexture(canvas));
628
+ const canvas2 = document.createElement("canvas");
629
+ canvas2.width = source.width;
630
+ canvas2.height = source.height;
631
+ canvas2.getContext("2d").drawImage(source, 0, 0);
632
+ return own(new THREE4.CanvasTexture(canvas2));
575
633
  }
576
634
  };
577
635
  function loadImage(url, signal) {
@@ -815,6 +873,37 @@ function solidTexture(color) {
815
873
  return texture;
816
874
  }
817
875
 
876
+ // src/shared.ts
877
+ var SharedResources = class {
878
+ entries = /* @__PURE__ */ new Map();
879
+ /** The resource under `key`, made by `make` for the first user. Pair every call with `release`. */
880
+ acquire(key, make) {
881
+ let entry = this.entries.get(key);
882
+ if (!entry) {
883
+ entry = { value: make(), users: 0 };
884
+ this.entries.set(key, entry);
885
+ }
886
+ entry.users++;
887
+ return entry.value;
888
+ }
889
+ /** Lets go of `key`; the last user's release frees it. */
890
+ release(key) {
891
+ const entry = this.entries.get(key);
892
+ if (!entry || --entry.users > 0) return;
893
+ this.entries.delete(key);
894
+ entry.value.dispose();
895
+ }
896
+ };
897
+ var byRenderer = /* @__PURE__ */ new WeakMap();
898
+ function sharedResources(renderer) {
899
+ let shared = byRenderer.get(renderer);
900
+ if (!shared) {
901
+ shared = new SharedResources();
902
+ byRenderer.set(renderer, shared);
903
+ }
904
+ return shared;
905
+ }
906
+
818
907
  // src/paper/paperPattern.ts
819
908
  import * as THREE6 from "three";
820
909
  var SIZE = 384;
@@ -865,7 +954,9 @@ function spread(values) {
865
954
  for (const v of values) variance += (v - mean) ** 2;
866
955
  return Math.sqrt(variance / values.length) || 1;
867
956
  }
868
- function createPaperPattern(renderer) {
957
+ var canvas = null;
958
+ function paperPatternCanvas() {
959
+ if (canvas) return canvas;
869
960
  let s = 7;
870
961
  const random = () => (s = s * 1664525 + 1013904223 >>> 0) / 4294967296;
871
962
  const noise = new Float32Array(SIZE * SIZE);
@@ -893,7 +984,7 @@ function createPaperPattern(renderer) {
893
984
  const cloudScale = CLOUD * spread(grain) / spread(cloud);
894
985
  for (let i = 0; i < grain.length; i++) grain[i] += cloud[i] * cloudScale;
895
986
  const order = Array.from(grain.keys()).sort((a, b) => grain[a] - grain[b]);
896
- const canvas = document.createElement("canvas");
987
+ canvas = document.createElement("canvas");
897
988
  canvas.width = canvas.height = SIZE;
898
989
  const ctx = canvas.getContext("2d");
899
990
  const image = ctx.createImageData(SIZE, SIZE);
@@ -909,13 +1000,17 @@ function createPaperPattern(renderer) {
909
1000
  image.data[o + 3] = 255;
910
1001
  });
911
1002
  ctx.putImageData(image, 0, 0);
912
- const texture = new THREE6.CanvasTexture(canvas);
1003
+ return canvas;
1004
+ }
1005
+ function createPaperPattern(renderer) {
1006
+ const texture = new THREE6.CanvasTexture(paperPatternCanvas());
913
1007
  texture.wrapS = texture.wrapT = THREE6.RepeatWrapping;
914
1008
  texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
915
1009
  return texture;
916
1010
  }
917
1011
 
918
1012
  // src/paper/paperTextures.ts
1013
+ var BUILTIN_PATTERN = "paper-pattern";
919
1014
  var PaperTextures = class {
920
1015
  constructor(backColor, events) {
921
1016
  this.events = events;
@@ -940,30 +1035,34 @@ var PaperTextures = class {
940
1035
  const grain = settings.paper.grain;
941
1036
  const key = grain === true || grain === void 0 ? "builtin" : grain === false ? "none" : grain;
942
1037
  if (this.pattern?.key === key) return this.pattern.texture;
943
- this.pattern?.texture.dispose();
944
- let texture;
945
- if (key === "builtin") texture = createPaperPattern(renderer);
946
- else if (key === "none") texture = solidTexture("#000000");
947
- else {
948
- texture = solidTexture("#000000");
1038
+ this.pattern?.release();
1039
+ if (key === "builtin") {
1040
+ const shared = sharedResources(renderer);
1041
+ const texture = shared.acquire(BUILTIN_PATTERN, () => createPaperPattern(renderer));
1042
+ this.pattern = { texture, key, release: () => shared.release(BUILTIN_PATTERN) };
1043
+ return texture;
1044
+ }
1045
+ const pattern = { texture: solidTexture("#000000"), key, release: () => pattern.texture.dispose() };
1046
+ this.pattern = pattern;
1047
+ if (key !== "none") {
949
1048
  loadGrain(key).then(
950
1049
  (loaded) => {
951
- if (this.pattern?.key !== key || this.disposed) return loaded.dispose();
952
- this.pattern.texture.dispose();
953
- this.pattern.texture = loaded;
1050
+ if (this.pattern !== pattern || this.disposed) return loaded.dispose();
1051
+ pattern.texture.dispose();
1052
+ pattern.texture = loaded;
954
1053
  this.events.onGrain();
955
1054
  },
956
1055
  (error) => {
957
- if (this.pattern?.key === key && !this.disposed) this.events.onError(error);
1056
+ if (this.pattern === pattern && !this.disposed) this.events.onError(error);
958
1057
  }
959
1058
  );
960
1059
  }
961
- this.pattern = { texture, key };
962
- return texture;
1060
+ return pattern.texture;
963
1061
  }
964
1062
  dispose() {
965
1063
  this.disposed = true;
966
- this.pattern?.texture.dispose();
1064
+ this.pattern?.release();
1065
+ this.pattern = null;
967
1066
  this.blank.dispose();
968
1067
  this.backBlank.texture.dispose();
969
1068
  }
@@ -1210,6 +1309,10 @@ var fragmentChunk = (
1210
1309
  function createSingleGeometry(aspect) {
1211
1310
  return new THREE9.PlaneGeometry(1, aspect, 64, Math.round(64 * aspect));
1212
1311
  }
1312
+ function sharedSingleGeometry(shared, aspect) {
1313
+ const key = `single-geometry:${aspect}`;
1314
+ return { geometry: shared.acquire(key, () => createSingleGeometry(aspect)), release: () => shared.release(key) };
1315
+ }
1213
1316
  function createSingleSheet(geometry, front, back, pattern, settings, shading) {
1214
1317
  const uniforms = {
1215
1318
  uPileLift: { value: new THREE9.Vector3(1, 0, 0) },
@@ -1310,6 +1413,7 @@ var SingleEngine = class {
1310
1413
  metrics;
1311
1414
  requestFrame;
1312
1415
  geometry;
1416
+ releaseGeometry;
1313
1417
  sheets;
1314
1418
  count;
1315
1419
  radiusSpacing;
@@ -1342,7 +1446,9 @@ var SingleEngine = class {
1342
1446
  this.count = ctx.textures.length;
1343
1447
  this.maxPosition = this.count;
1344
1448
  this.current = clamp(Math.round(ctx.position), 0, Math.max(this.count - 1, 0));
1345
- this.geometry = createSingleGeometry(ctx.settings.pageAspect);
1449
+ const { geometry, release } = sharedSingleGeometry(ctx.shared, ctx.settings.pageAspect);
1450
+ this.geometry = geometry;
1451
+ this.releaseGeometry = release;
1346
1452
  this.sheets = ctx.textures.map((front, index) => {
1347
1453
  const { mesh, uniforms } = createSingleSheet(this.geometry, front, ctx.blank, ctx.pattern, ctx.settings, ctx.shading);
1348
1454
  this.group.add(mesh);
@@ -1426,7 +1532,7 @@ var SingleEngine = class {
1426
1532
  this.cancelHold();
1427
1533
  this.abort.abort();
1428
1534
  for (const sheet of this.sheets) disposeSheetMesh(sheet.mesh);
1429
- this.geometry.dispose();
1535
+ this.releaseGeometry();
1430
1536
  }
1431
1537
  // --- helpers ----------------------------------------------------------------
1432
1538
  randomWobble() {
@@ -1476,6 +1582,10 @@ var SingleEngine = class {
1476
1582
  shutting(turned) {
1477
1583
  return smooth((this.closing(turned) - 0.6) / 0.4);
1478
1584
  }
1585
+ // how far a read page has settled flat under the pile: 0 still on the roll, 1 flat
1586
+ fallOf(sheet, paced, shut) {
1587
+ return Math.max(smooth((paced - (sheet.index + 1) - this.fallDelay) / this.fallDuration), shut);
1588
+ }
1479
1589
  inFlight(sheet) {
1480
1590
  return this.flights.some((f) => f.sheet === sheet);
1481
1591
  }
@@ -1778,11 +1888,13 @@ var SingleEngine = class {
1778
1888
  sheet.uniforms.uTrim.value.set(sheet === top || this.busy(sheet) ? 0 : 1, edge.bottom, edge.top);
1779
1889
  }
1780
1890
  const closing = this.closing(turned) > 0;
1891
+ const paced = this.paced(turned);
1892
+ const shut = this.shutting(turned);
1781
1893
  const pileFrom = Math.max(this.current, top?.index ?? this.current);
1782
1894
  for (const sheet of this.sheets) {
1783
1895
  const i = sheet.index;
1784
1896
  sheet.mesh.visible = closing || i <= pileFrom + PILE.renderDepth || this.busy(sheet);
1785
- const depth = i < this.current ? this.current - 1 - i : i - this.current;
1897
+ const depth = i >= this.current ? i - this.current : this.fallOf(sheet, paced, shut) >= 1 ? this.count - this.current + i : this.current - 1 - i;
1786
1898
  layerSheet(sheet.mesh, depth, this.count, this.busy(sheet), this.settings.paper.depthBias);
1787
1899
  }
1788
1900
  const read = smooth(this.count > 1 ? this.paced(turned) / (this.count - 1) : 0) * (1 - this.shutting(turned));
@@ -1815,10 +1927,7 @@ var SingleEngine = class {
1815
1927
  const spineOpen = (ROLL.spineOpenStartDeg + (ROLL.spineOpenEndDeg - ROLL.spineOpenStartDeg) * Math.pow(openFraction, ROLL.spineOpenPower)) * Math.PI / 180;
1816
1928
  const shut = this.shutting(turned);
1817
1929
  const closed = this.closing(turned) >= 1 ? 1 : 0;
1818
- const fall = Math.max(
1819
- smooth((paced - (sheet.index + 1) - this.fallDelay) / this.fallDuration),
1820
- shut
1821
- );
1930
+ const fall = this.fallOf(sheet, paced, shut);
1822
1931
  const spacing = this.radiusSpacing[sheet.index] ?? 0;
1823
1932
  const topR = ROLL.coverTopRadius + (ROLL.backTopRadius - ROLL.coverTopRadius) * spacing;
1824
1933
  const bottomR = ROLL.coverBottomRadius + (ROLL.backBottomRadius - ROLL.coverBottomRadius) * spacing;
@@ -1861,7 +1970,7 @@ var SingleEngine = class {
1861
1970
  const under = P - 1 - sheet.index;
1862
1971
  const before = Math.max(0, turned - 1 - sheet.index);
1863
1972
  const around = smooth(T);
1864
- const offset = fallen ? before * Math.cos(spineOpen) : under + (before - under) * around;
1973
+ const offset = fallen ? -(sheet.index + 1) : under + (before - under) * around;
1865
1974
  u.uStackGap.value = this.settings.paper.sheetGap * (offset + (under - offset) * shut);
1866
1975
  }
1867
1976
  // a turning page: swing round the spine up to the open angle, then roll round the cone
@@ -2003,20 +2112,29 @@ function wrinkleNoise(u, v, aspect) {
2003
2112
  }
2004
2113
  return value;
2005
2114
  }
2115
+ var noiseByAspect = /* @__PURE__ */ new Map();
2006
2116
  function createSpreadGeometry(aspect) {
2007
2117
  const geometry = new THREE11.PlaneGeometry(1, aspect, 64, Math.round(64 * aspect));
2008
- const uv = geometry.attributes.uv;
2009
- const noise = new Float32Array(uv.count * 3);
2010
- for (let i = 0; i < uv.count; i++) {
2011
- const u = uv.getX(i);
2012
- const v = uv.getY(i);
2013
- noise[i * 3] = wrinkleNoise(u, v, aspect);
2014
- noise[i * 3 + 1] = wrinkleNoise(u + NORMAL_EPSILON2, v, aspect);
2015
- noise[i * 3 + 2] = wrinkleNoise(u, v + NORMAL_EPSILON2 / aspect, aspect);
2118
+ let noise = noiseByAspect.get(aspect);
2119
+ if (!noise) {
2120
+ const uv = geometry.attributes.uv;
2121
+ noise = new Float32Array(uv.count * 3);
2122
+ for (let i = 0; i < uv.count; i++) {
2123
+ const u = uv.getX(i);
2124
+ const v = uv.getY(i);
2125
+ noise[i * 3] = wrinkleNoise(u, v, aspect);
2126
+ noise[i * 3 + 1] = wrinkleNoise(u + NORMAL_EPSILON2, v, aspect);
2127
+ noise[i * 3 + 2] = wrinkleNoise(u, v + NORMAL_EPSILON2 / aspect, aspect);
2128
+ }
2129
+ noiseByAspect.set(aspect, noise);
2016
2130
  }
2017
2131
  geometry.setAttribute("aNoise", new THREE11.BufferAttribute(noise, 3));
2018
2132
  return geometry;
2019
2133
  }
2134
+ function sharedSpreadGeometry(shared, aspect) {
2135
+ const key = `spread-geometry:${aspect}`;
2136
+ return { geometry: shared.acquire(key, () => createSpreadGeometry(aspect)), release: () => shared.release(key) };
2137
+ }
2020
2138
  function createSpreadSheet(geometry, front, back, pattern, settings, shading) {
2021
2139
  const uniforms = {
2022
2140
  uFlipProgress: { value: 0 },
@@ -2087,6 +2205,7 @@ var SpreadEngine = class {
2087
2205
  metrics;
2088
2206
  requestFrame;
2089
2207
  geometry;
2208
+ releaseGeometry;
2090
2209
  sheets;
2091
2210
  abort = new AbortController();
2092
2211
  shading;
@@ -2114,7 +2233,9 @@ var SpreadEngine = class {
2114
2233
  const count = Math.ceil(ctx.textures.length / 2);
2115
2234
  this.maxPosition = count;
2116
2235
  this.current = clamp2(Math.round(ctx.position), 0, count);
2117
- this.geometry = createSpreadGeometry(ctx.settings.pageAspect);
2236
+ const { geometry, release } = sharedSpreadGeometry(ctx.shared, ctx.settings.pageAspect);
2237
+ this.geometry = geometry;
2238
+ this.releaseGeometry = release;
2118
2239
  this.sheets = [];
2119
2240
  for (let i = 0; i < count; i++) {
2120
2241
  const front = ctx.textures[2 * i];
@@ -2183,7 +2304,7 @@ var SpreadEngine = class {
2183
2304
  this.cancelHold();
2184
2305
  this.abort.abort();
2185
2306
  for (const sheet of this.sheets) disposeSheetMesh(sheet.mesh);
2186
- this.geometry.dispose();
2307
+ this.releaseGeometry();
2187
2308
  }
2188
2309
  // --- helpers ----------------------------------------------------------------
2189
2310
  randomWobble() {
@@ -2876,6 +2997,7 @@ var LeafThrough = class extends EventTarget {
2876
2997
  textures,
2877
2998
  blank: layout === "single" ? this.paper.back(settings.paper.backColor) : blank,
2878
2999
  pattern: this.paper.grain(settings, renderer),
3000
+ shared: sharedResources(renderer),
2879
3001
  position: this.pageToPosition(page, layout),
2880
3002
  metrics: this.metrics,
2881
3003
  shading,
@@ -2928,18 +3050,18 @@ var LeafThrough = class extends EventTarget {
2928
3050
  const right = Math.min(placed.view.x + placed.view.width, (documentWidth - pageLeft) / scaleX);
2929
3051
  const top = Math.max(placed.view.y, -pageTop / scaleY);
2930
3052
  const bottom = placed.view.y + placed.view.height;
2931
- const canvas = { x: left, y: top, width: Math.max(1, right - left), height: Math.max(1, bottom - top) };
3053
+ const canvas2 = { x: left, y: top, width: Math.max(1, right - left), height: Math.max(1, bottom - top) };
2932
3054
  const px = (n) => `${Math.round(n * 100) / 100}px`;
2933
3055
  Object.assign(this.canvas.style, {
2934
- left: px(canvas.x),
2935
- top: px(canvas.y),
2936
- width: px(canvas.width),
2937
- height: px(canvas.height)
3056
+ left: px(canvas2.x),
3057
+ top: px(canvas2.y),
3058
+ width: px(canvas2.width),
3059
+ height: px(canvas2.height)
2938
3060
  });
2939
3061
  const maxRatio = Math.min(devicePixelRatio, settings.maxPixelRatio);
2940
- const budget = Math.sqrt(15e6 / Math.max(1, canvas.width * canvas.height));
3062
+ const budget = Math.sqrt(15e6 / Math.max(1, canvas2.width * canvas2.height));
2941
3063
  this.renderer.setPixelRatio(Math.min(maxRatio, budget));
2942
- this.renderer.setSize(Math.max(1, Math.round(canvas.width)), Math.max(1, Math.round(canvas.height)), false);
3064
+ this.renderer.setSize(Math.max(1, Math.round(canvas2.width)), Math.max(1, Math.round(canvas2.height)), false);
2943
3065
  const { eye, view, distance } = framing;
2944
3066
  const halfWidth = Math.max(eye.x - view.x, view.x + view.width - eye.x);
2945
3067
  const halfHeight = Math.max(eye.y - view.y, view.y + view.height - eye.y);
@@ -2951,10 +3073,10 @@ var LeafThrough = class extends EventTarget {
2951
3073
  camera.setViewOffset(
2952
3074
  2 * halfWidth * scale,
2953
3075
  2 * halfHeight * scale,
2954
- canvas.x - frustumLeft,
2955
- canvas.y - frustumTop,
2956
- canvas.width,
2957
- canvas.height
3076
+ canvas2.x - frustumLeft,
3077
+ canvas2.y - frustumTop,
3078
+ canvas2.width,
3079
+ canvas2.height
2958
3080
  );
2959
3081
  camera.updateProjectionMatrix();
2960
3082
  }
@@ -3122,10 +3244,10 @@ var LeafThrough = class extends EventTarget {
3122
3244
  this.syncContent(view);
3123
3245
  }
3124
3246
  };
3125
- function createRenderer(canvas) {
3247
+ function createRenderer(canvas2) {
3126
3248
  let renderer;
3127
3249
  try {
3128
- renderer = new THREE13.WebGLRenderer({ canvas, antialias: true, alpha: true });
3250
+ renderer = new THREE13.WebGLRenderer({ canvas: canvas2, antialias: true, alpha: true });
3129
3251
  } catch {
3130
3252
  return null;
3131
3253
  }
@@ -3158,9 +3280,10 @@ export {
3158
3280
  Riffle,
3159
3281
  bindKeyboard,
3160
3282
  PageLoader,
3283
+ sharedResources,
3161
3284
  PaperTextures,
3162
3285
  SingleEngine,
3163
3286
  SpreadEngine,
3164
3287
  LeafThrough
3165
3288
  };
3166
- //# sourceMappingURL=chunk-QFOGCGJJ.js.map
3289
+ //# sourceMappingURL=chunk-KCIOFDSW.js.map