@preference-sl/pref-viewer 2.13.0-beta.6 → 2.13.0-beta.8

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.
@@ -8,6 +8,7 @@ import JSZip from "jszip";
8
8
  import GLTFResolver from "./gltf-resolver.js";
9
9
  import { MaterialData } from "./pref-viewer-3d-data.js";
10
10
  import BabylonJSAnimationController from "./babylonjs-animation-controller.js";
11
+ import OpeningAnimation from "./babylonjs-animation-opening.js";
11
12
  import { translate } from "./localization/i18n.js";
12
13
 
13
14
  /**
@@ -56,11 +57,13 @@ import { translate } from "./localization/i18n.js";
56
57
  * - `show-model`/`show-scene` DOM attributes reflect container visibility; there are no direct `showModel()/hideModel()` APIs.
57
58
  * - IBL shadows require `iblEnabled` plus `options.ibl.shadows` and a loaded HDR texture; otherwise fallback directional
58
59
  * lights and environment-contributed lights supply classic shadow generators.
60
+ * - IBL lifecycle: when `options.ibl.cachedUrl` is present, a new `HDRCubeTexture` is created and cloned into `#hdrTexture`;
61
+ * then `options.ibl.consumeCachedUrl(true)` clears/revokes the temporary URL. Subsequent reloads reuse `#hdrTexture.clone()`
62
+ * while `options.ibl.valid` remains true.
59
63
  * - Browser-only features guard `window`, localStorage, and XR APIs before use so the controller is safe to construct
60
64
  * in SSR/Node contexts (though functionality activates only in browsers).
61
65
  */
62
66
  export default class BabylonJSController {
63
-
64
67
  #RENDER_SETTINGS_STORAGE_KEY = "pref-viewer/render-settings";
65
68
 
66
69
  // Default render settings
@@ -72,7 +75,7 @@ export default class BabylonJSController {
72
75
  iblEnabled: true,
73
76
  shadowsEnabled: false,
74
77
  };
75
-
78
+
76
79
  // Canvas HTML element
77
80
  #canvas = null;
78
81
 
@@ -90,10 +93,13 @@ export default class BabylonJSController {
90
93
  #shadowGen = [];
91
94
  #XRExperience = null;
92
95
  #canvasResizeObserver = null;
93
-
96
+
97
+ #hdrTexture = null; // reusable in-memory HDR source cloned into scene.environmentTexture across reloads
98
+ #lastPickedMeshId = null;
99
+
94
100
  #containers = {};
95
101
  #options = {};
96
-
102
+
97
103
  #gltfResolver = null; // GLTFResolver instance
98
104
  #babylonJSAnimationController = null; // AnimationController instance
99
105
 
@@ -106,11 +112,28 @@ export default class BabylonJSController {
106
112
  #handlers = {
107
113
  onKeyUp: null,
108
114
  onPointerObservable: null,
115
+ onAnimationGroupChanged: null,
116
+ onResize: null,
109
117
  renderLoop: null,
110
118
  };
111
-
119
+
112
120
  #settings = { ...BabylonJSController.DEFAULT_RENDER_SETTINGS };
113
121
 
122
+ #renderState = {
123
+ isLoopRunning: false,
124
+ dirtyFrames: 0,
125
+ continuousUntil: 0,
126
+ lastRenderAt: 0,
127
+ };
128
+
129
+ #renderConfig = {
130
+ burstFramesBase: 2,
131
+ burstFramesEnhanced: 32, // when AA/SSAO/IBL is enabled, more frames are needed to reach stable output
132
+ interactionMs: 250,
133
+ animationMs: 200,
134
+ idleThrottleMs: 1000 / 15,
135
+ };
136
+
114
137
  /**
115
138
  * Constructs a new BabylonJSController instance.
116
139
  * Initializes the canvas, asset containers, and options for the Babylon.js scene.
@@ -143,8 +166,10 @@ export default class BabylonJSController {
143
166
  * @returns {void}
144
167
  */
145
168
  #bindHandlers() {
169
+ this.#handlers.onAnimationGroupChanged = this.#onAnimationGroupChanged.bind(this);
146
170
  this.#handlers.onKeyUp = this.#onKeyUp.bind(this);
147
171
  this.#handlers.onPointerObservable = this.#onPointerObservable.bind(this);
172
+ this.#handlers.onResize = this.#onResize.bind(this);
148
173
  this.#handlers.renderLoop = this.#renderLoop.bind(this);
149
174
  }
150
175
 
@@ -267,16 +292,177 @@ export default class BabylonJSController {
267
292
  }
268
293
  }
269
294
 
295
+ /**
296
+ * Starts Babylon's engine render loop if it is not already running.
297
+ * @private
298
+ * @returns {boolean} True when the loop was started, false when no engine is available or it was already running.
299
+ */
300
+ #startEngineRenderLoop() {
301
+ if (!this.#engine || this.#renderState.isLoopRunning) {
302
+ return false;
303
+ }
304
+ this.#engine.runRenderLoop(this.#handlers.renderLoop);
305
+ this.#renderState.isLoopRunning = true;
306
+ return true;
307
+ }
308
+
309
+ /**
310
+ * Stops Babylon's engine render loop when it is currently active.
311
+ * @private
312
+ * @returns {boolean} True when the loop was stopped, false when no engine is available or it was already stopped.
313
+ */
314
+ #stopEngineRenderLoop() {
315
+ if (!this.#engine || !this.#renderState.isLoopRunning) {
316
+ return false;
317
+ }
318
+ this.#engine.stopRenderLoop(this.#handlers.renderLoop);
319
+ this.#renderState.isLoopRunning = false;
320
+ return true;
321
+ }
322
+
323
+ /**
324
+ * Marks the scene as dirty and optionally extends a short continuous-render window.
325
+ * Ensures the engine loop is running so the requested frames can be produced.
326
+ * @private
327
+ * @param {{frames?:number, continuousMs?:number}} [options={}] - Render request options.
328
+ * @param {number} [options.frames=1] - Minimum number of frames to render.
329
+ * @param {number} [options.continuousMs=0] - Milliseconds to keep continuous rendering active.
330
+ * @returns {boolean} True when the request was accepted, false when scene/engine are unavailable.
331
+ */
332
+ #requestRender({ frames = 1, continuousMs = 0 } = {}) {
333
+ if (!this.#scene || !this.#engine) {
334
+ return false;
335
+ }
336
+
337
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
338
+ this.#renderState.dirtyFrames = Math.max(this.#renderState.dirtyFrames, Math.max(1, frames));
339
+ if (continuousMs > 0) {
340
+ this.#renderState.continuousUntil = Math.max(this.#renderState.continuousUntil, now + continuousMs);
341
+ }
342
+ this.#startEngineRenderLoop();
343
+ return true;
344
+ }
345
+
346
+ /**
347
+ * Checks whether an ArcRotateCamera still has non-zero inertial movement.
348
+ * @private
349
+ * @param {ArcRotateCamera} camera - Camera to evaluate.
350
+ * @returns {boolean} True when any inertial offset is still active.
351
+ */
352
+ #isArcRotateCameraInMotion(camera) {
353
+ const EPSILON = 0.00001;
354
+ return Math.abs(camera?.inertialAlphaOffset || 0) > EPSILON || Math.abs(camera?.inertialBetaOffset || 0) > EPSILON || Math.abs(camera?.inertialRadiusOffset || 0) > EPSILON || Math.abs(camera?.inertialPanningX || 0) > EPSILON || Math.abs(camera?.inertialPanningY || 0) > EPSILON;
355
+ }
356
+
357
+ /**
358
+ * Checks whether a FreeCamera/UniversalCamera is currently moving or rotating.
359
+ * @private
360
+ * @param {FreeCamera|UniversalCamera} camera - Camera to evaluate.
361
+ * @returns {boolean} True when translation or rotation deltas are active.
362
+ */
363
+ #isUniversalOrFreeCameraInMotion(camera) {
364
+ const EPSILON = 0.00001;
365
+ const direction = camera?.cameraDirection;
366
+ const rotation = camera?.cameraRotation;
367
+ const directionMoving = !!direction && (Math.abs(direction.x) > EPSILON || Math.abs(direction.y) > EPSILON || Math.abs(direction.z) > EPSILON);
368
+ const rotationMoving = !!rotation && (Math.abs(rotation.x) > EPSILON || Math.abs(rotation.y) > EPSILON || Math.abs(rotation.z) > EPSILON);
369
+ return directionMoving || rotationMoving;
370
+ }
371
+
372
+ /**
373
+ * Detects motion for the current active camera based on its concrete camera type.
374
+ * @private
375
+ * @returns {boolean} True when the active camera is moving, otherwise false.
376
+ */
377
+ #isCameraInMotion() {
378
+ const camera = this.#scene?.activeCamera;
379
+ if (!camera) {
380
+ return false;
381
+ }
382
+ if (camera instanceof ArcRotateCamera) {
383
+ return this.#isArcRotateCameraInMotion(camera);
384
+ }
385
+ if (camera instanceof UniversalCamera || camera instanceof FreeCamera) {
386
+ return this.#isUniversalOrFreeCameraInMotion(camera);
387
+ }
388
+ return false;
389
+ }
390
+
391
+ /**
392
+ * Determines whether scene animations are currently running.
393
+ * @private
394
+ * @returns {boolean} True when at least one animation group is playing.
395
+ */
396
+ #isAnimationRunning() {
397
+ if (!this.#scene) {
398
+ return false;
399
+ }
400
+ const hasAnimatables = (this.#scene.animatables?.length || 0) > 0;
401
+ if (!hasAnimatables) {
402
+ return false;
403
+ }
404
+ return this.#scene.animationGroups?.some((group) => group?.isPlaying) || false;
405
+ }
406
+
407
+ /**
408
+ * Evaluates whether the renderer should stay in continuous mode.
409
+ * XR always forces continuous rendering; animation/camera motion also extends the
410
+ * continuous deadline window to avoid abrupt stop-start behavior.
411
+ * @private
412
+ * @param {number} now - Current high-resolution timestamp.
413
+ * @returns {boolean} True when continuous rendering should remain active.
414
+ */
415
+ #shouldRenderContinuously(now) {
416
+ const inXR = this.#XRExperience?.baseExperience?.state === WebXRState.IN_XR;
417
+ if (inXR) {
418
+ return true;
419
+ }
420
+
421
+ const animationRunning = this.#isAnimationRunning();
422
+ const cameraInMotion = this.#isCameraInMotion();
423
+
424
+ if (animationRunning) {
425
+ this.#renderState.continuousUntil = Math.max(this.#renderState.continuousUntil, now + this.#renderConfig.animationMs);
426
+ }
427
+ if (cameraInMotion) {
428
+ this.#renderState.continuousUntil = Math.max(this.#renderState.continuousUntil, now + this.#renderConfig.interactionMs);
429
+ }
430
+
431
+ return animationRunning || cameraInMotion || this.#renderState.continuousUntil > now;
432
+ }
433
+
270
434
  /**
271
435
  * Render loop callback for Babylon.js.
436
+ * Runs only while scene state is dirty, interactive motion is active, animations are running, or XR is active.
437
+ * It self-stops when the scene becomes idle.
272
438
  * @private
273
439
  * @returns {void}
274
- * @description
275
- * Continuously renders the current scene if it exists.
276
- * Used by the engine's runRenderLoop method to update the view.
277
440
  */
278
441
  #renderLoop() {
279
- this.#scene && this.#scene.render();
442
+ if (!this.#scene) {
443
+ this.#stopEngineRenderLoop();
444
+ return;
445
+ }
446
+
447
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
448
+ const continuous = this.#shouldRenderContinuously(now);
449
+ const needsRender = continuous || this.#renderState.dirtyFrames > 0;
450
+
451
+ if (!needsRender) {
452
+ this.#stopEngineRenderLoop();
453
+ return;
454
+ }
455
+
456
+ if (!continuous && this.#renderState.lastRenderAt > 0 && now - this.#renderState.lastRenderAt < this.#renderConfig.idleThrottleMs) {
457
+ return;
458
+ }
459
+
460
+ this.#scene.render();
461
+ this.#renderState.lastRenderAt = now;
462
+
463
+ if (this.#renderState.dirtyFrames > 0) {
464
+ this.#renderState.dirtyFrames -= 1;
465
+ }
280
466
  }
281
467
 
282
468
  /**
@@ -376,6 +562,10 @@ export default class BabylonJSController {
376
562
  * Sets light intensities and shadow properties for realistic rendering.
377
563
  * @private
378
564
  * @returns {Promise<boolean>} Returns true if lights were changed, false otherwise.
565
+ * @description
566
+ * IBL path is considered available when either:
567
+ * - `options.ibl.cachedUrl` is present (new pending URL), or
568
+ * - `options.ibl.valid === true` (reusable in-memory `#hdrTexture` exists).
379
569
  */
380
570
  async #createLights() {
381
571
  const hemiLightName = "PrefViewerHemiLight";
@@ -388,7 +578,7 @@ export default class BabylonJSController {
388
578
 
389
579
  let lightsChanged = false;
390
580
 
391
- const iblEnabled = this.#settings.iblEnabled && this.#options.ibl?.cachedUrl !== null;
581
+ const iblEnabled = this.#settings.iblEnabled && (this.#options.ibl?.valid === true || !!this.#options.ibl?.cachedUrl);
392
582
 
393
583
  if (iblEnabled) {
394
584
  if (hemiLight) {
@@ -415,14 +605,14 @@ export default class BabylonJSController {
415
605
  this.#hemiLight = new HemisphericLight(hemiLightName, new Vector3(-10, 10, -10), this.#scene);
416
606
  this.#hemiLight.intensity = 0.6;
417
607
  }
418
-
608
+
419
609
  // Add a directional light to cast shadows and provide stronger directional illumination
420
610
  if (!this.#dirLight) {
421
611
  this.#dirLight = new DirectionalLight(dirLightName, new Vector3(-10, 10, -10), this.#scene);
422
612
  this.#dirLight.position = new Vector3(5, 4, 5); // light is IN FRONT + ABOVE + to the RIGHT
423
613
  this.#dirLight.intensity = 0.6;
424
614
  }
425
-
615
+
426
616
  // Add a point light that follows the camera to ensure the model is always well-lit from the viewer's perspective
427
617
  if (!this.#cameraLight) {
428
618
  this.#cameraLight = new PointLight(cameraLightName, this.#camera.position, this.#scene);
@@ -447,7 +637,7 @@ export default class BabylonJSController {
447
637
  }
448
638
 
449
639
  const supportedPipelines = pipelineManager.supportedPipelines;
450
-
640
+
451
641
  if (supportedPipelines === undefined) {
452
642
  return false;
453
643
  }
@@ -487,23 +677,23 @@ export default class BabylonJSController {
487
677
  return false;
488
678
  }
489
679
  const supportedPipelines = pipelineManager.supportedPipelines;
490
-
680
+
491
681
  if (supportedPipelines === undefined) {
492
682
  return false;
493
683
  }
494
-
684
+
495
685
  const pipelineName = "PrefViewerSSAORenderingPipeline";
496
686
 
497
687
  const ssaoRatio = {
498
688
  ssaoRatio: 0.5,
499
- combineRatio: 1.0
689
+ combineRatio: 1.0,
500
690
  };
501
691
 
502
692
  let ssaoPipeline = new SSAORenderingPipeline(pipelineName, this.#scene, ssaoRatio, [this.#scene.activeCamera]);
503
693
 
504
- if (!ssaoPipeline){
694
+ if (!ssaoPipeline) {
505
695
  return false;
506
- }
696
+ }
507
697
 
508
698
  if (ssaoPipeline.isSupported) {
509
699
  ssaoPipeline.fallOff = 0.000001;
@@ -511,7 +701,7 @@ export default class BabylonJSController {
511
701
  ssaoPipeline.radius = 0.0001;
512
702
  ssaoPipeline.totalStrength = 1;
513
703
  ssaoPipeline.base = 0.6;
514
-
704
+
515
705
  // Configure SSAO to calculate only once instead of every frame for better performance
516
706
  if (ssaoPipeline._ssaoPostProcess) {
517
707
  ssaoPipeline._ssaoPostProcess.autoClear = false;
@@ -521,7 +711,7 @@ export default class BabylonJSController {
521
711
  ssaoPipeline._combinePostProcess.autoClear = false;
522
712
  ssaoPipeline._combinePostProcess.samples = 1;
523
713
  }
524
-
714
+
525
715
  this.#renderPipelines.ssao = ssaoPipeline;
526
716
  pipelineManager.update();
527
717
  return true;
@@ -547,7 +737,7 @@ export default class BabylonJSController {
547
737
  }
548
738
 
549
739
  const supportedPipelines = pipelineManager.supportedPipelines;
550
-
740
+
551
741
  if (supportedPipelines === undefined) {
552
742
  return false;
553
743
  }
@@ -588,18 +778,18 @@ export default class BabylonJSController {
588
778
  return false;
589
779
  }
590
780
  const supportedPipelines = pipelineManager.supportedPipelines;
591
-
781
+
592
782
  if (supportedPipelines === undefined) {
593
783
  return false;
594
784
  }
595
-
785
+
596
786
  const pipelineName = "PrefViewerDefaultRenderingPipeline";
597
787
 
598
788
  let defaultPipeline = new DefaultRenderingPipeline(pipelineName, true, this.#scene, [this.#scene.activeCamera], true);
599
789
 
600
- if (!defaultPipeline){
790
+ if (!defaultPipeline) {
601
791
  return false;
602
- }
792
+ }
603
793
 
604
794
  if (defaultPipeline.isSupported) {
605
795
  // MSAA - Multisample Anti-Aliasing
@@ -647,19 +837,43 @@ export default class BabylonJSController {
647
837
 
648
838
  /**
649
839
  * Initializes the environment texture for the Babylon.js scene.
650
- * Loads an HDR texture from a predefined URI and assigns it to the scene's environmentTexture property.
651
- * Configures gamma space, mipmaps, and intensity level for realistic lighting.
840
+ * Resolves the active HDR environment texture using either a fresh `cachedUrl`
841
+ * or the reusable in-memory clone (`#hdrTexture`), then assigns it to `scene.environmentTexture`.
652
842
  * @private
653
843
  * @returns {Promise<boolean>} Returns true if the environment texture was changed, false if it was already up to date or failed to load.
844
+ * @description
845
+ * Lifecycle implemented here:
846
+ * 1. If `options.ibl.cachedUrl` exists, create `HDRCubeTexture` from it.
847
+ * 2. Wait for readiness, clone it into `#hdrTexture` for reuse.
848
+ * 3. Call `options.ibl.consumeCachedUrl(true)` to revoke temporary object URLs and clear `cachedUrl`.
849
+ * 4. On following reloads, if `options.ibl.valid === true` and no `cachedUrl` is present, use `#hdrTexture.clone()`.
654
850
  */
655
851
  async #initializeEnvironmentTexture() {
656
852
  if (this.#scene.environmentTexture) {
657
853
  this.#scene.environmentTexture.dispose();
658
854
  this.#scene.environmentTexture = null;
659
855
  }
660
- const hdrTextureURI = this.#options.ibl.cachedUrl;
661
- const hdrTexture = new HDRCubeTexture(hdrTextureURI, this.#scene, 1024, false, false, false, true, undefined, undefined, false, true, true);
856
+
857
+ let hdrTexture = null;
858
+ if (this.#options.ibl?.cachedUrl) {
859
+ const hdrTextureURI = this.#options.ibl.cachedUrl;
860
+ hdrTexture = new HDRCubeTexture(hdrTextureURI, this.#scene, 1024, false, false, false, true, undefined, undefined, false, true, true);
861
+ } else if (this.#hdrTexture && this.#options.ibl?.valid === true) {
862
+ hdrTexture = this.#hdrTexture.clone();
863
+ } else {
864
+ return false;
865
+ }
866
+
662
867
  await WhenTextureReadyAsync(hdrTexture);
868
+
869
+ if (this.#options.ibl?.cachedUrl) {
870
+ if (this.#hdrTexture) {
871
+ this.#hdrTexture.dispose();
872
+ }
873
+ this.#hdrTexture = hdrTexture.clone();
874
+ this.#options.ibl?.consumeCachedUrl?.(true);
875
+ }
876
+
663
877
  hdrTexture.level = this.#options.ibl.intensity;
664
878
  this.#scene.environmentTexture = hdrTexture;
665
879
  this.#scene.markAllMaterialsAsDirty(Material.TextureDirtyFlag);
@@ -680,7 +894,7 @@ export default class BabylonJSController {
680
894
  }
681
895
 
682
896
  const supportedPipelines = pipelineManager.supportedPipelines;
683
-
897
+
684
898
  if (supportedPipelines === undefined) {
685
899
  return false;
686
900
  }
@@ -716,16 +930,9 @@ export default class BabylonJSController {
716
930
  * @returns {Promise<void|boolean>} Returns false if no environment texture is set; otherwise void.
717
931
  */
718
932
  async #initializeIBLShadows() {
719
-
720
- await this.#scene.whenReadyAsync();
721
-
722
933
  const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
723
934
 
724
- if (!this.#scene || !pipelineManager || this.#scene?.activeCamera === null) {
725
- return false;
726
- }
727
-
728
- if (!this.#scene.environmentTexture) {
935
+ if (!this.#scene || !this.#scene?.activeCamera || !this.#scene?.environmentTexture || !pipelineManager) {
729
936
  return false;
730
937
  }
731
938
 
@@ -736,13 +943,41 @@ export default class BabylonJSController {
736
943
  });
737
944
  return false;
738
945
  }
739
-
946
+
947
+ const meshesForCastingShadows = this.#scene.meshes.filter((mesh) => {
948
+ const isRootMesh = mesh.id.startsWith("__root__");
949
+ if (isRootMesh) {
950
+ return false;
951
+ }
952
+
953
+ const isHDRIMesh = mesh.name?.toLowerCase() === "hdri";
954
+ const extrasCastShadows = mesh.metadata?.gltf?.extras?.castShadows;
955
+ const meshGenerateShadows = typeof extrasCastShadows === "boolean" ? extrasCastShadows : isHDRIMesh ? false : true;
956
+
957
+ if (meshGenerateShadows) {
958
+ return true;
959
+ }
960
+ return false;
961
+ });
962
+ const materialsForReceivingShadows = this.#scene.materials.filter((material) => {
963
+ if (material instanceof PBRMaterial) {
964
+ material.enableSpecularAntiAliasing = false;
965
+ }
966
+ return true;
967
+ });
968
+
969
+ if (meshesForCastingShadows.length === 0 || materialsForReceivingShadows.length === 0) {
970
+ return false;
971
+ }
972
+
740
973
  const supportedPipelines = pipelineManager.supportedPipelines;
741
-
974
+
742
975
  if (!supportedPipelines) {
743
976
  return false;
744
977
  }
745
978
 
979
+ await this.#scene.whenReadyAsync();
980
+
746
981
  const pipelineName = "PrefViewerIblShadowsRenderPipeline";
747
982
 
748
983
  const pipelineOptions = {
@@ -759,7 +994,7 @@ export default class BabylonJSController {
759
994
  if (!iblShadowsPipeline) {
760
995
  return false;
761
996
  }
762
-
997
+
763
998
  if (iblShadowsPipeline.isSupported) {
764
999
  // Disable all debug passes for performance
765
1000
  const pipelineProps = {
@@ -775,30 +1010,11 @@ export default class BabylonJSController {
775
1010
  };
776
1011
 
777
1012
  Object.assign(iblShadowsPipeline, pipelineProps);
778
-
779
- this.#scene.meshes.forEach((mesh) => {
780
- const isRootMesh = mesh.id.startsWith("__root__");
781
- if (isRootMesh) {
782
- return false;
783
- }
784
-
785
- const isHDRIMesh = mesh.name?.toLowerCase() === "hdri";
786
- const extrasCastShadows = mesh.metadata?.gltf?.extras?.castShadows;
787
- const meshGenerateShadows = typeof extrasCastShadows === "boolean" ? extrasCastShadows : isHDRIMesh ? false : true;
788
-
789
- if (meshGenerateShadows) {
790
- iblShadowsPipeline.addShadowCastingMesh(mesh);
791
- iblShadowsPipeline.updateSceneBounds();
792
- }
793
- });
794
-
795
- this.#scene.materials.forEach((material) => {
796
- if (material instanceof PBRMaterial) {
797
- material.enableSpecularAntiAliasing = false;
798
- }
799
- iblShadowsPipeline.addShadowReceivingMaterial(material);
800
- });
801
-
1013
+
1014
+ meshesForCastingShadows.forEach((mesh) => iblShadowsPipeline.addShadowCastingMesh(mesh));
1015
+ materialsForReceivingShadows.forEach((material) => iblShadowsPipeline.addShadowReceivingMaterial(material));
1016
+
1017
+ iblShadowsPipeline.updateSceneBounds();
802
1018
  iblShadowsPipeline.toggleShadow(true);
803
1019
  iblShadowsPipeline.updateVoxelization();
804
1020
  this.#renderPipelines.iblShadows = iblShadowsPipeline;
@@ -935,7 +1151,7 @@ export default class BabylonJSController {
935
1151
 
936
1152
  this.#ensureMeshesReceiveShadows();
937
1153
 
938
- const iblEnabled = this.#settings.iblEnabled && this.#options.ibl?.cachedUrl !== null;
1154
+ const iblEnabled = this.#settings.iblEnabled && (this.#options.ibl?.valid === true || !!this.#options.ibl?.cachedUrl);
939
1155
  const iblShadowsEnabled = iblEnabled && this.#options.ibl.shadows;
940
1156
 
941
1157
  if (iblShadowsEnabled) {
@@ -964,23 +1180,6 @@ export default class BabylonJSController {
964
1180
  }
965
1181
  }
966
1182
 
967
- /**
968
- * Handles pointer events observed on the Babylon.js scene.
969
- * @private
970
- * @param {PointerInfo} info - The pointer event information from Babylon.js.
971
- * @returns {void}
972
- */
973
- #onPointerObservable(info) {
974
- const pickInfo = this.#scene.pick(this.#scene.pointerX, this.#scene.pointerY);
975
- if (info.type === PointerEventTypes.POINTERUP) {
976
- this.#onPointerUp(info.event, pickInfo);
977
- } else if (info.type === PointerEventTypes.POINTERMOVE) {
978
- this.#onPointerMove(info.event, pickInfo);
979
- } else if (info.type === PointerEventTypes.POINTERWHEEL) {
980
- this.#onMouseWheel(info.event, pickInfo);
981
- }
982
- }
983
-
984
1183
  /**
985
1184
  * Sets up interaction handlers for the Babylon.js canvas and scene.
986
1185
  * @private
@@ -993,6 +1192,13 @@ export default class BabylonJSController {
993
1192
  if (this.#scene) {
994
1193
  this.#scene.onPointerObservable.add(this.#handlers.onPointerObservable);
995
1194
  }
1195
+ if (this.#engine) {
1196
+ this.#canvasResizeObserver = new ResizeObserver(() => {
1197
+ this.#engine.resize();
1198
+ this.#requestRender({ frames: this.#renderConfig.burstFramesBase, continuousMs: this.#renderConfig.interactionMs });
1199
+ });
1200
+ this.#canvasResizeObserver.observe(this.#canvas);
1201
+ }
996
1202
  }
997
1203
 
998
1204
  /**
@@ -1007,6 +1213,9 @@ export default class BabylonJSController {
1007
1213
  if (this.#scene !== null) {
1008
1214
  this.#scene.onPointerObservable.removeCallback(this.#handlers.onPointerObservable);
1009
1215
  }
1216
+ this.#canvasResizeObserver?.disconnect();
1217
+ this.#canvasResizeObserver = null;
1218
+ this.#detachAnimationChangedListener();
1010
1219
  }
1011
1220
 
1012
1221
  /**
@@ -1058,11 +1267,84 @@ export default class BabylonJSController {
1058
1267
  if (!this.#engine) {
1059
1268
  return;
1060
1269
  }
1270
+ if (this.#hdrTexture) {
1271
+ this.#hdrTexture.dispose();
1272
+ this.#hdrTexture = null;
1273
+ }
1061
1274
  this.#engine.dispose();
1062
1275
  this.#engine = this.#scene = this.#camera = null;
1063
1276
  this.#hemiLight = this.#dirLight = this.#cameraLight = null;
1064
1277
  }
1065
1278
 
1279
+ /**
1280
+ * Handles animation state events emitted by `OpeningAnimation` instances.
1281
+ * Routes opening/closing states to continuous rendering and all other states
1282
+ * (paused/opened/closed) to a short final render burst.
1283
+ * @private
1284
+ * @param {CustomEvent} event - Event carrying animation state in `event.detail.state`.
1285
+ * @returns {void}
1286
+ */
1287
+ #onAnimationGroupChanged(event) {
1288
+ const state = event?.detail?.state;
1289
+ if (state === undefined) {
1290
+ return;
1291
+ }
1292
+
1293
+ if (state === OpeningAnimation.states.opening || state === OpeningAnimation.states.closing) {
1294
+ this.#onAnimationGroupPlay();
1295
+ } else {
1296
+ this.#onAnimationGroupStop();
1297
+ }
1298
+ }
1299
+
1300
+ /**
1301
+ * Marks animation playback as active and requests short continuous rendering so animated transforms remain smooth while state is changing.
1302
+ * @private
1303
+ * @returns {void}
1304
+ */
1305
+ #onAnimationGroupPlay() {
1306
+ this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.animationMs });
1307
+ }
1308
+
1309
+ /**
1310
+ * Handles animation stop/pause/end transitions by requesting a final render burst.
1311
+ * @private
1312
+ * @returns {void}
1313
+ */
1314
+ #onAnimationGroupStop() {
1315
+ const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#renderConfig.burstFramesEnhanced : this.#renderConfig.burstFramesBase;
1316
+ this.#requestRender({ frames: frames });
1317
+ }
1318
+
1319
+ /**
1320
+ * Attaches the `prefviewer-animation-changed` listener to the nearest `pref-viewer-3d` host.
1321
+ * Removes any previous registration first to avoid duplicate callbacks across reload cycles.
1322
+ * @private
1323
+ * @returns {boolean} True when the listener is attached, false when no host is available.
1324
+ */
1325
+ #attachAnimationChangedListener() {
1326
+ this.#getPrefViewer3DComponent();
1327
+ if (!this.#prefViewer3D) {
1328
+ return false;
1329
+ }
1330
+ this.#detachAnimationChangedListener();
1331
+ this.#prefViewer3D.addEventListener("prefviewer-animation-changed", this.#handlers.onAnimationGroupChanged);
1332
+ return true;
1333
+ }
1334
+
1335
+ /**
1336
+ * Detaches the `prefviewer-animation-changed` listener from the cached `pref-viewer-3d` host.
1337
+ * @private
1338
+ * @returns {boolean} True when a host exists and the listener removal was attempted, false otherwise.
1339
+ */
1340
+ #detachAnimationChangedListener() {
1341
+ if (!this.#prefViewer3D) {
1342
+ return false;
1343
+ }
1344
+ this.#prefViewer3D.removeEventListener("prefviewer-animation-changed", this.#handlers.onAnimationGroupChanged);
1345
+ return true;
1346
+ }
1347
+
1066
1348
  /**
1067
1349
  * Handles keyup events on the Babylon.js canvas for triggering model and scene downloads.
1068
1350
  * @private
@@ -1090,11 +1372,12 @@ export default class BabylonJSController {
1090
1372
  * @returns {void|false} Returns false if there is no active camera; otherwise, void.
1091
1373
  */
1092
1374
  #onMouseWheel(event, pickInfo) {
1375
+ event.preventDefault();
1093
1376
  const camera = this.#scene?.activeCamera;
1094
1377
  if (!camera) {
1095
1378
  return false;
1096
1379
  }
1097
- if (!camera.metadata?.locked) {
1380
+ if (!camera.metadata?.locked) {
1098
1381
  if (camera instanceof ArcRotateCamera) {
1099
1382
  camera.wheelPrecision = camera.wheelPrecision || 3.0;
1100
1383
  camera.inertialRadiusOffset -= event.deltaY * camera.wheelPrecision * 0.001;
@@ -1107,8 +1390,8 @@ export default class BabylonJSController {
1107
1390
  const movementVector = direction.scale(zoomSpeed);
1108
1391
  camera.position = camera.position.add(movementVector);
1109
1392
  }
1393
+ this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.interactionMs });
1110
1394
  }
1111
- event.preventDefault();
1112
1395
  }
1113
1396
 
1114
1397
  /**
@@ -1136,9 +1419,54 @@ export default class BabylonJSController {
1136
1419
  * @returns {void}
1137
1420
  */
1138
1421
  #onPointerMove(event, pickInfo) {
1422
+ const camera = this.#scene?.activeCamera;
1423
+ if (camera && !camera.metadata?.locked) {
1424
+ this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.interactionMs });
1425
+ }
1139
1426
  if (this.#babylonJSAnimationController) {
1140
- this.#babylonJSAnimationController.highlightMeshes(pickInfo);
1427
+ const pickedMeshId = pickInfo?.pickedMesh?.id || null;
1428
+ if (this.#lastPickedMeshId !== pickedMeshId) {
1429
+ const highlightResult = this.#babylonJSAnimationController.highlightMeshes(pickInfo);
1430
+ if (highlightResult.changed) {
1431
+ this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.interactionMs });
1432
+ }
1433
+ }
1434
+ }
1435
+ }
1436
+
1437
+ /**
1438
+ * Handles pointer events observed on the Babylon.js scene.
1439
+ * @private
1440
+ * @param {PointerInfo} info - The pointer event information from Babylon.js.
1441
+ * @returns {void}
1442
+ */
1443
+ #onPointerObservable(info) {
1444
+ const pickInfo = this.#scene.pick(this.#scene.pointerX, this.#scene.pointerY);
1445
+ const pickedMeshId = pickInfo?.pickedMesh?.id || null;
1446
+
1447
+ if (info.type === PointerEventTypes.POINTERMOVE) {
1448
+ this.#onPointerMove(info.event, pickInfo);
1449
+ } else if (info.type === PointerEventTypes.POINTERUP) {
1450
+ this.#onPointerUp(info.event, pickInfo);
1451
+ } else if (info.type === PointerEventTypes.POINTERWHEEL) {
1452
+ this.#onMouseWheel(info.event, pickInfo);
1141
1453
  }
1454
+ this.#lastPickedMeshId = pickedMeshId;
1455
+ }
1456
+
1457
+ /**
1458
+ * Handles canvas resize notifications.
1459
+ * Resizes the Babylon engine and requests a short on-demand render burst so camera-dependent
1460
+ * buffers and post-process pipelines are redrawn at the new viewport size.
1461
+ * @private
1462
+ * @returns {void}
1463
+ */
1464
+ #onResize() {
1465
+ if (!this.#engine) {
1466
+ return;
1467
+ }
1468
+ this.#engine.resize();
1469
+ this.#requestRender({ frames: this.#renderConfig.burstFramesBase, continuousMs: this.#renderConfig.interactionMs });
1142
1470
  }
1143
1471
 
1144
1472
  /**
@@ -1180,7 +1508,7 @@ export default class BabylonJSController {
1180
1508
  .forEach((mesh) => {
1181
1509
  mesh.material = material;
1182
1510
  someSetted = true;
1183
- })
1511
+ }),
1184
1512
  );
1185
1513
 
1186
1514
  if (someSetted) {
@@ -1275,6 +1603,9 @@ export default class BabylonJSController {
1275
1603
  * Marks the IBL state as successful, recreates lights so the new environment takes effect, and reports whether anything changed.
1276
1604
  * @private
1277
1605
  * @returns {boolean} True when lights were refreshed due to pending IBL changes, otherwise false.
1606
+ * @description
1607
+ * Delegates to `#createLights()`, which executes the full IBL URL-to-texture lifecycle:
1608
+ * `cachedUrl -> HDRCubeTexture -> #hdrTexture clone -> consumeCachedUrl(true)`.
1278
1609
  */
1279
1610
  async #setOptions_IBL() {
1280
1611
  return await this.#createLights();
@@ -1291,34 +1622,47 @@ export default class BabylonJSController {
1291
1622
  }
1292
1623
 
1293
1624
  /**
1294
- * Caches and retrieves the parent custom element "PREF-VIEWER-3D" for efficient access.
1625
+ * Resolves and caches the closest `pref-viewer-3d` host associated with the rendering canvas.
1295
1626
  * @private
1296
1627
  * @returns {void}
1297
1628
  */
1298
1629
  #getPrefViewer3DComponent() {
1299
- if (this.#prefViewer3D === undefined) {
1300
- const grandParentElement = this.#canvas.parentElement.parentElement;
1301
- this.#prefViewer3D = grandParentElement && grandParentElement.nodeName === "PREF-VIEWER-3D" ? grandParentElement : null;
1630
+ if (this.#prefViewer3D !== undefined) {
1631
+ return;
1632
+ }
1633
+
1634
+ let prefViewer3D = this.#canvas?.closest?.("pref-viewer-3d") || undefined;
1635
+ if (!prefViewer3D) {
1636
+ const host = this.#canvas?.getRootNode?.()?.host;
1637
+ prefViewer3D = host?.nodeName === "PREF-VIEWER-3D" ? host : undefined;
1302
1638
  }
1639
+
1640
+ this.#prefViewer3D = prefViewer3D;
1303
1641
  }
1304
1642
 
1305
1643
  /**
1306
- * Caches and retrieves the parent custom element "PREF-VIEWER" for efficient access.
1644
+ * Resolves and caches the closest `pref-viewer` host associated with `#prefViewer3D`.
1307
1645
  * @private
1308
1646
  * @returns {void}
1309
1647
  */
1310
1648
  #getPrefViewerComponent() {
1311
- if (this.#prefViewer === undefined) {
1312
- if (this.#prefViewer3D === undefined) {
1313
- this.#getPrefViewer3DComponent();
1314
- }
1315
- if (!this.#prefViewer3D) {
1316
- this.#prefViewer = null;
1317
- return;
1318
- }
1319
- const rootNode = this.#prefViewer3D ? this.#prefViewer3D.getRootNode().host : null;
1320
- this.#prefViewer = rootNode && rootNode.nodeName === "PREF-VIEWER" ? rootNode : null;
1649
+ if (this.#prefViewer !== undefined) {
1650
+ return;
1321
1651
  }
1652
+
1653
+ this.#getPrefViewer3DComponent();
1654
+ if (this.#prefViewer3D === undefined) {
1655
+ this.#prefViewer = undefined;
1656
+ return;
1657
+ }
1658
+
1659
+ let prefViewer = this.#prefViewer3D.closest?.("pref-viewer") || undefined;
1660
+ if (!prefViewer) {
1661
+ const host = this.#prefViewer3D.getRootNode?.()?.host;
1662
+ prefViewer = host?.nodeName === "PREF-VIEWER" ? host : undefined;
1663
+ }
1664
+
1665
+ this.#prefViewer = prefViewer;
1322
1666
  }
1323
1667
 
1324
1668
  /**
@@ -1467,7 +1811,10 @@ export default class BabylonJSController {
1467
1811
  * @returns {void}
1468
1812
  */
1469
1813
  async #stopRender() {
1470
- this.#engine.stopRenderLoop(this.#handlers.renderLoop);
1814
+ this.#stopEngineRenderLoop();
1815
+ this.#renderState.dirtyFrames = 0;
1816
+ this.#renderState.continuousUntil = 0;
1817
+ this.#renderState.lastRenderAt = 0;
1471
1818
  await this.#unloadCameraDependentEffects();
1472
1819
  }
1473
1820
  /**
@@ -1478,9 +1825,9 @@ export default class BabylonJSController {
1478
1825
  */
1479
1826
  async #startRender() {
1480
1827
  await this.#loadCameraDependentEffects();
1481
- this.#scene.executeWhenReady(() => {
1482
- this.#engine.runRenderLoop(this.#handlers.renderLoop);
1483
- });
1828
+ await this.#scene.whenReadyAsync();
1829
+ const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#renderConfig.burstFramesEnhanced : this.#renderConfig.burstFramesBase;
1830
+ this.#requestRender({ frames: frames, continuousMs: this.#renderConfig.interactionMs });
1484
1831
  }
1485
1832
 
1486
1833
  /**
@@ -1500,7 +1847,6 @@ export default class BabylonJSController {
1500
1847
  * `LoadAssetContainerAsync`, returning the tuple so the caller can decide how to attach it to the scene.
1501
1848
  */
1502
1849
  async #loadAssetContainer(container, force = false) {
1503
-
1504
1850
  if (container?.state?.update?.storage === undefined || container?.state?.size === undefined || container?.state?.timeStamp === undefined) {
1505
1851
  return [container, false];
1506
1852
  }
@@ -1546,6 +1892,8 @@ export default class BabylonJSController {
1546
1892
  return [container, assetContainer];
1547
1893
  } catch (error) {
1548
1894
  return [container, assetContainer];
1895
+ } finally {
1896
+ this.#gltfResolver.revokeObjectURLs(sourceData.objectURLs);
1549
1897
  }
1550
1898
  }
1551
1899
 
@@ -1559,6 +1907,7 @@ export default class BabylonJSController {
1559
1907
  * Returns an object with success status and error details.
1560
1908
  */
1561
1909
  async #loadContainers() {
1910
+ this.#detachAnimationChangedListener();
1562
1911
  await this.#stopRender();
1563
1912
 
1564
1913
  let oldModelMetadata = { ...(this.#containers.model?.state?.metadata ?? {}) };
@@ -1615,6 +1964,9 @@ export default class BabylonJSController {
1615
1964
  this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
1616
1965
  this.#setMaxSimultaneousLights();
1617
1966
  this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
1967
+ if (this.#babylonJSAnimationController?.hasAnimations?.()) {
1968
+ this.#attachAnimationChangedListener();
1969
+ }
1618
1970
  await this.#startRender();
1619
1971
  });
1620
1972
  return detail;
@@ -1874,10 +2226,10 @@ export default class BabylonJSController {
1874
2226
  this.#engine = new Engine(this.#canvas, true, { alpha: true, stencil: true, preserveDrawingBuffer: false });
1875
2227
  this.#engine.disableUniformBuffers = true;
1876
2228
  this.#scene = new Scene(this.#engine);
1877
-
2229
+
1878
2230
  // Activate the rendering of geometry data into a G-buffer, essential for advanced effects like deferred shading,
1879
- // SSAO, and Velocity-Texture-Animation (VAT), allowing for complex post-processing by separating rendering into
1880
- // different buffers (depth, normals, velocity) for later use in shaders.
2231
+ // SSAO, and Velocity-Texture-Animation (VAT), allowing for complex post-processing by separating rendering into
2232
+ // different buffers (depth, normals, velocity) for later use in shaders.
1881
2233
  const geometryBufferRenderer = this.#scene.enableGeometryBufferRenderer();
1882
2234
  if (geometryBufferRenderer) {
1883
2235
  geometryBufferRenderer.enableScreenspaceDepth = true;
@@ -1894,12 +2246,14 @@ export default class BabylonJSController {
1894
2246
  this.#scene.imageProcessingConfiguration.vignetteEnabled = false;
1895
2247
  this.#scene.imageProcessingConfiguration.colorCurvesEnabled = false;
1896
2248
 
2249
+ // Skip the built-in pointer picking logic since the controller implements its own optimized raycasting for interaction.
2250
+ this.#scene.skipPointerMovePicking = true;
2251
+ this.#scene.skipPointerDownPicking = true;
2252
+ this.#scene.skipPointerUpPicking = true;
2253
+
1897
2254
  this.#createCamera();
1898
2255
  this.#enableInteraction();
1899
2256
  await this.#createXRExperience();
1900
- this.#startRender();
1901
- this.#canvasResizeObserver = new ResizeObserver(() => this.#engine && this.#engine.resize());
1902
- this.#canvasResizeObserver.observe(this.#canvas);
1903
2257
  }
1904
2258
 
1905
2259
  /**
@@ -1909,11 +2263,11 @@ export default class BabylonJSController {
1909
2263
  * @returns {void}
1910
2264
  */
1911
2265
  disable() {
1912
- this.#canvasResizeObserver.disconnect();
1913
2266
  this.#disableInteraction();
1914
2267
  this.#disposeAnimationController();
1915
2268
  this.#disposeXRExperience();
1916
2269
  this.#unloadCameraDependentEffects();
2270
+ this.#stopEngineRenderLoop();
1917
2271
  this.#disposeEngine();
1918
2272
  }
1919
2273