@scenography/engine 0.3.1 → 0.4.1

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/index.mjs CHANGED
@@ -64,6 +64,7 @@ const HIGHLIGHT_COLOR = "#ef4444";
64
64
  const HIGHLIGHT_THICKNESS = 1.5;
65
65
  const HIGHLIGHT_MARGIN = 1.5;
66
66
  const CLICK_SLOP = 4;
67
+ const PICK_INTERVAL = 60;
67
68
  /**
68
69
  * Pointer picking for artworks: hovering a piece's click target (its label, or
69
70
  * the piece itself when it has none) draws a pulsing red frame and shows a
@@ -85,6 +86,8 @@ var InteractionController = class {
85
86
  radii = /* @__PURE__ */ new Map();
86
87
  hovered = null;
87
88
  time = 0;
89
+ pickElapsed = 0;
90
+ pointerMoved = false;
88
91
  highlight = new Group();
89
92
  highlightMat = null;
90
93
  handlers = [];
@@ -127,6 +130,7 @@ var InteractionController = class {
127
130
  const rect = this.canvas.getBoundingClientRect();
128
131
  this.pointer.set((e.clientX - rect.left) / rect.width * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
129
132
  this.over = true;
133
+ this.pointerMoved = true;
130
134
  }
131
135
  onMove(e) {
132
136
  this.setPointer(e);
@@ -168,18 +172,38 @@ var InteractionController = class {
168
172
  }
169
173
  return null;
170
174
  }
171
- /** Recompute hover state and animate the highlight. Call once per frame. */
175
+ /**
176
+ * Recompute hover state and animate the highlight. Call once per frame.
177
+ * Hover picking is throttled: a full-scene raycast runs at most once every
178
+ * `PICK_INTERVAL` ms, or immediately after the pointer moves. The camera can
179
+ * move without the pointer, so the interval keeps hovering correct while
180
+ * walking.
181
+ */
172
182
  update(deltaMs) {
173
183
  this.time += deltaMs;
174
- const pick = this.over && this.canInteract() ? this.raycast() : null;
175
- if ((pick?.id ?? null) !== this.hovered) {
176
- this.hovered = pick?.id ?? null;
177
- this.canvas.style.cursor = pick ? "pointer" : "";
178
- if (pick) this.buildHighlight(pick);
179
- else this.highlight.visible = false;
184
+ if (!this.over || !this.canInteract()) {
185
+ this.pickElapsed = PICK_INTERVAL;
186
+ this.pointerMoved = false;
187
+ this.applyPick(null);
188
+ } else {
189
+ this.pickElapsed += deltaMs;
190
+ if (this.pointerMoved) this.pickElapsed = PICK_INTERVAL;
191
+ this.pointerMoved = false;
192
+ if (this.pickElapsed >= PICK_INTERVAL) {
193
+ this.pickElapsed = 0;
194
+ this.applyPick(this.raycast());
195
+ }
180
196
  }
181
197
  if (this.highlight.visible && this.highlightMat) this.highlightMat.opacity = .45 + .45 * (.5 + .5 * Math.sin(this.time / 320));
182
198
  }
199
+ /** Swap the hovered piece, touching the canvas and the highlight only on change. */
200
+ applyPick(pick) {
201
+ if ((pick?.id ?? null) === this.hovered) return;
202
+ this.hovered = pick?.id ?? null;
203
+ this.canvas.style.cursor = pick ? "pointer" : "";
204
+ if (pick) this.buildHighlight(pick);
205
+ else this.highlight.visible = false;
206
+ }
183
207
  clearHighlight() {
184
208
  for (const child of [...this.highlight.children]) {
185
209
  this.highlight.remove(child);
@@ -478,8 +502,126 @@ const EDGES = "edges";
478
502
  */
479
503
  const EYE_HEIGHT = "eyeHeight";
480
504
  //#endregion
505
+ //#region src/builders/textureCache.ts
506
+ /** Two surfaces can share a texture only at the same tiling and color space. */
507
+ const textureKey = (src, repeat, srgb) => `${src}|${repeat[0]}x${repeat[1]}|${srgb ? "srgb" : "linear"}`;
508
+ /** Tiling and color space of a texture; the image arrives later from the loader. */
509
+ const configureTexture = (texture, repeat, srgb) => {
510
+ texture.wrapS = RepeatWrapping;
511
+ texture.wrapT = RepeatWrapping;
512
+ texture.repeat.set(repeat[0], repeat[1]);
513
+ texture.colorSpace = srgb ? SRGBColorSpace : NoColorSpace;
514
+ };
515
+ /**
516
+ * Shares textures between materials: the same source at the same tiling and
517
+ * color space becomes one texture, loaded once and uploaded to the GPU once.
518
+ * Reference counted, so releasing a material keeps the texture alive while
519
+ * another one still samples it.
520
+ */
521
+ var TextureCache = class {
522
+ source;
523
+ entries = /* @__PURE__ */ new Map();
524
+ owners = /* @__PURE__ */ new Map();
525
+ constructor(source) {
526
+ this.source = source;
527
+ }
528
+ /** The shared texture for a source, created on first use. */
529
+ acquire(src, repeat, srgb) {
530
+ const key = textureKey(src, repeat, srgb);
531
+ const existing = this.entries.get(key);
532
+ if (existing) {
533
+ existing.refs += 1;
534
+ return existing.texture;
535
+ }
536
+ const texture = this.source(src);
537
+ configureTexture(texture, repeat, srgb);
538
+ const entry = {
539
+ key,
540
+ texture,
541
+ refs: 1
542
+ };
543
+ this.entries.set(key, entry);
544
+ this.owners.set(texture, entry);
545
+ return texture;
546
+ }
547
+ /** Drop one reference; the texture is disposed when the last one goes. */
548
+ release(texture) {
549
+ const entry = this.owners.get(texture);
550
+ if (!entry) {
551
+ texture.dispose();
552
+ return;
553
+ }
554
+ entry.refs -= 1;
555
+ if (entry.refs > 0) return;
556
+ this.entries.delete(entry.key);
557
+ this.owners.delete(texture);
558
+ texture.dispose();
559
+ }
560
+ /** Textures currently shared. */
561
+ get size() {
562
+ return this.entries.size;
563
+ }
564
+ };
565
+ //#endregion
566
+ //#region src/builders/texture.ts
567
+ const textureLoader = new TextureLoader();
568
+ const cache = new TextureCache((src) => textureLoader.load(src));
569
+ /**
570
+ * Load one map into `material[slot]`. Textures are shared: every material that
571
+ * samples the same source at the same tiling reuses one texture object. `.exr`
572
+ * maps go through EXRLoader, imported on demand, and are not shared because
573
+ * they resolve asynchronously.
574
+ */
575
+ const loadMap = (material, slot, src, repeat, srgb) => {
576
+ if (src.toLowerCase().endsWith(".exr")) {
577
+ import("three/examples/jsm/loaders/EXRLoader.js").then(({ EXRLoader }) => {
578
+ new EXRLoader().load(src, (texture) => {
579
+ configureTexture(texture, repeat, srgb);
580
+ texture.needsUpdate = true;
581
+ material[slot] = texture;
582
+ material.needsUpdate = true;
583
+ });
584
+ });
585
+ return;
586
+ }
587
+ material[slot] = cache.acquire(src, repeat, srgb);
588
+ };
589
+ /**
590
+ * Apply a `SurfaceTexture` to a standard material. Returns whether a second UV
591
+ * set (`uv1`) is needed — three samples `aoMap` from it, so the geometry must
592
+ * provide one.
593
+ */
594
+ const applyTexture = (material, texture) => {
595
+ const repeat = texture.repeat ?? [1, 1];
596
+ loadMap(material, "map", texture.src, repeat, true);
597
+ if (texture.normal) {
598
+ loadMap(material, "normalMap", texture.normal, repeat, false);
599
+ const scale = texture.normalScale ?? 1;
600
+ material.normalScale = new Vector2(scale, scale);
601
+ }
602
+ if (texture.roughness) loadMap(material, "roughnessMap", texture.roughness, repeat, false);
603
+ if (texture.ao) loadMap(material, "aoMap", texture.ao, repeat, false);
604
+ material.needsUpdate = true;
605
+ return texture.ao !== void 0;
606
+ };
607
+ /**
608
+ * Release one reference to a texture assigned by `applyTexture`. Shared
609
+ * textures stay alive while another material still samples them.
610
+ */
611
+ const releaseTexture = (texture) => {
612
+ cache.release(texture);
613
+ };
614
+ //#endregion
481
615
  //#region src/SceneManager.ts
616
+ const TEXTURE_SLOTS = [
617
+ "map",
618
+ "normalMap",
619
+ "roughnessMap",
620
+ "aoMap",
621
+ "metalnessMap"
622
+ ];
482
623
  const disposeObject = (object) => {
624
+ const materials = /* @__PURE__ */ new Set();
483
625
  object.traverse((child) => {
484
626
  const video = child.userData?.video;
485
627
  if (video) {
@@ -489,21 +631,18 @@ const disposeObject = (object) => {
489
631
  }
490
632
  const mesh = child;
491
633
  mesh.geometry?.dispose?.();
492
- const dispose = (m) => {
493
- const material = m;
494
- for (const slot of [
495
- "map",
496
- "normalMap",
497
- "roughnessMap",
498
- "aoMap",
499
- "metalnessMap"
500
- ]) (material?.[slot])?.dispose?.();
501
- material?.dispose?.();
502
- };
503
634
  const material = mesh.material;
504
- if (Array.isArray(material)) material.forEach(dispose);
505
- else dispose(material);
635
+ if (Array.isArray(material)) for (const entry of material) materials.add(entry);
636
+ else if (material) materials.add(material);
506
637
  });
638
+ for (const entry of materials) {
639
+ const material = entry;
640
+ for (const slot of TEXTURE_SLOTS) {
641
+ const texture = material[slot];
642
+ if (texture) releaseTexture(texture);
643
+ }
644
+ material.dispose?.();
645
+ }
507
646
  };
508
647
  /**
509
648
  * Owns the live three.js scene graph and keeps it in sync with a `Scene`
@@ -953,50 +1092,6 @@ function createGallery(scene, options) {
953
1092
  };
954
1093
  }
955
1094
  //#endregion
956
- //#region src/builders/texture.ts
957
- const textureLoader = new TextureLoader();
958
- /** Configure a loaded texture: tiling + color space (sRGB only for color maps). */
959
- const configure = (tex, repeat, srgb) => {
960
- tex.wrapS = RepeatWrapping;
961
- tex.wrapT = RepeatWrapping;
962
- tex.repeat.set(repeat[0], repeat[1]);
963
- tex.colorSpace = srgb ? SRGBColorSpace : NoColorSpace;
964
- tex.needsUpdate = true;
965
- };
966
- /**
967
- * Load one map and assign it to `material[slot]` once ready. `.exr` maps go
968
- * through EXRLoader, imported on demand so it only ships in the bundle when a
969
- * scene actually uses an EXR map; everything else uses the image loader.
970
- */
971
- const loadMap = (material, slot, src, repeat, srgb) => {
972
- const assign = (tex) => {
973
- configure(tex, repeat, srgb);
974
- material[slot] = tex;
975
- material.needsUpdate = true;
976
- };
977
- if (src.toLowerCase().endsWith(".exr")) import("three/examples/jsm/loaders/EXRLoader.js").then(({ EXRLoader }) => {
978
- new EXRLoader().load(src, assign);
979
- });
980
- else textureLoader.load(src, assign);
981
- };
982
- /**
983
- * Apply a `SurfaceTexture` to a standard material. Returns whether a second UV
984
- * set (`uv1`) is needed — three samples `aoMap` from it, so the geometry must
985
- * provide one.
986
- */
987
- const applyTexture = (material, texture) => {
988
- const repeat = texture.repeat ?? [1, 1];
989
- loadMap(material, "map", texture.src, repeat, true);
990
- if (texture.normal) {
991
- loadMap(material, "normalMap", texture.normal, repeat, false);
992
- const s = texture.normalScale ?? 1;
993
- material.normalScale = new Vector2(s, s);
994
- }
995
- if (texture.roughness) loadMap(material, "roughnessMap", texture.roughness, repeat, false);
996
- if (texture.ao) loadMap(material, "aoMap", texture.ao, repeat, false);
997
- return texture.ao !== void 0;
998
- };
999
- //#endregion
1000
1095
  export { COLLIDERS, CapabilityRegistry, ControlSettings, DEFAULT_CONTROL_FLAGS, EDGES, EYE_HEIGHT, InputBus, InteractionController, MODE_PRESETS, ROAM_CONTROL, SceneManager, TERRAIN, applyMode, applyTexture, createGallery, isSystemPlugin, modeOf };
1001
1096
 
1002
1097
  //# sourceMappingURL=index.mjs.map