@preference-sl/pref-viewer 2.13.0-beta.1 → 2.13.0-beta.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "2.13.0-beta.1",
3
+ "version": "2.13.0-beta.3",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "scripts": {
@@ -1,4 +1,4 @@
1
- import { ArcRotateCamera, AssetContainer, Camera, Color4, DefaultRenderingPipeline, DirectionalLight, Engine, FreeCamera, HDRCubeTexture, HemisphericLight, IblShadowsRenderPipeline, LoadAssetContainerAsync, MeshBuilder, PBRMaterial, PointerEventTypes, PointLight, RenderTargetTexture, Scene, ShadowGenerator, SpotLight, SSAORenderingPipeline, Tools, UniversalCamera, Vector3, WebXRDefaultExperience, WebXRFeatureName, WebXRSessionManager, WebXRState } from "@babylonjs/core";
1
+ import { ArcRotateCamera, AssetContainer, Camera, Color4, DefaultRenderingPipeline, DirectionalLight, Engine, FreeCamera, HDRCubeTexture, HemisphericLight, IblShadowsRenderPipeline, LoadAssetContainerAsync, Material, MeshBuilder, PBRMaterial, PointerEventTypes, PointLight, RenderTargetTexture, Scene, ShadowGenerator, SpotLight, SSAORenderingPipeline, Tools, UniversalCamera, Vector3, WebXRDefaultExperience, WebXRFeatureName, WebXRSessionManager, WebXRState, WhenTextureReadyAsync } from "@babylonjs/core";
2
2
  import { DracoCompression } from "@babylonjs/core/Meshes/Compression/dracoCompression.js";
3
3
  import "@babylonjs/loaders";
4
4
  import "@babylonjs/loaders/glTF/2.0/Extensions/KHR_draco_mesh_compression.js";
@@ -11,53 +11,53 @@ import BabylonJSAnimationController from "./babylonjs-animation-controller.js";
11
11
  import { translate } from "./localization/i18n.js";
12
12
 
13
13
  /**
14
- * BabylonJSController is the PrefViewer 3D engine coordinator: it bootstraps Babylon.js, manages asset containers,
15
- * rebuilds post-process pipelines on demand, brokers XR/download interactions, and persists user render toggles so UI
16
- * components can stay declarative. Higher layers hand over container + option state, while this class turns it into a
17
- * fully interactive scene with deterministic reloads and exports.
14
+ * BabylonJSController coordinates the PrefViewer 3D runtime: it bootstraps Babylon.js, manages asset containers,
15
+ * rebuilds camera-dependent pipelines once textures are ready, brokers XR/download interactions, and persists
16
+ * render toggles so the UI can stay declarative. PrefViewer hands over container + option state, while this class
17
+ * turns it into a deterministic scene lifecycle with exports.
18
18
  *
19
19
  * Overview
20
- * - Spins up the Babylon.js engine/scene/camera stack, configures Draco loaders, and wires resize + render loops.
21
- * - Resolves GLTF/GLB sources through GLTFResolver, loads them into `AssetContainer`s, and toggles their visibility.
22
- * - Applies render-setting switches (AA, SSAO, IBL, shadows), persists them in localStorage, and rehydrates on startup.
23
- * - Rebuilds DefaultRenderingPipeline, SSAO, IBL, and shadow pipelines every time the active camera or assets change.
24
- * - Connects keyboard, pointer, wheel, hover, and XR events so menus, animation controllers, and PrefViewer attributes stay in sync.
20
+ * - Creates the Babylon.js engine/scene/camera stack, configures Draco decoders, wires resize + render loops, and
21
+ * exposes download/xR helpers.
22
+ * - Resolves GLTF/GLB sources via GLTFResolver, loads them into `AssetContainer`s, and toggles visibility by
23
+ * mutating container state plus `show-model/show-scene` attributes.
24
+ * - Applies/persists AA, SSAO, IBL, and shadow flags; when they change it stops the render loop, tears down pipelines,
25
+ * reloads containers, and reinstalls effects after environment textures finish loading.
26
+ * - Manages keyboard/pointer/wheel handlers, animation menus, and WebXR so PrefViewer menus and DOM attributes stay
27
+ * synchronized with Babylon state.
25
28
  * - Generates GLB, glTF+ZIP, or USDZ exports with timestamped names and localized dialog copy.
26
29
  * - Translates metadata (inner floor offsets, cast/receive shadows, camera locks) into scene adjustments after reloads.
27
30
  *
28
31
  * Runtime Flow
29
32
  * 1. Instantiate with `new BabylonJSController(canvas, containers, options)`.
30
- * 2. Call `enable()` to configure Draco, create the engine/scene, observers, and XR hooks.
31
- * 3. Whenever PrefViewer marks containers/options pending, invoke `load()` to fetch sources and rebuild pipelines.
32
- * 4. Use `applyRenderSettings` helpers (via menu events) to merge toggles, persist them, and trigger reloads when needed.
33
- * 5. Respond to PrefViewer tasks by calling `showModel()/hideModel()` equivalents through container state changes.
34
- * 6. Surface downloads through `downloadGLB/GLTF/USDZ` or the private `#openDownloadDialog()` triggered by keyboard shortcuts.
35
- * 7. Invoke `disable()` when the element disconnects to teardown scenes, XR sessions, and listeners.
33
+ * 2. Call `enable()` to configure Draco, spin up the engine/scene, attach interaction + XR hooks, and start the render loop.
34
+ * 3. When PrefViewer marks containers/options pending, invoke `load()` (or `reloadWithCurrentSettings()`) so the controller
35
+ * fetches sources, rebuilds containers, and reattaches pipelines.
36
+ * 4. Use `scheduleRenderSettingsReload()` to merge/persist toggles; when it reports `changed: true`, call
37
+ * `reloadWithCurrentSettings()` to apply the staged settings.
38
+ * 5. Use `setContainerVisibility`, `setMaterialOptions`, `setCameraOptions`, or `setIBLOptions` for targeted updates; these
39
+ * helpers stop/restart the render loop while they rebuild camera-dependent resources.
40
+ * 6. Invoke `disable()` when the element disconnects to tear down scenes, XR sessions, observers, and handlers.
36
41
  *
37
42
  * Public API Highlights
38
43
  * - constructor(canvas, containers, options)
39
44
  * - enable() / disable()
40
- * - load()
45
+ * - load() / reloadWithCurrentSettings()
41
46
  * - downloadGLB(content) / downloadGLTF(content) / downloadUSDZ(content)
42
- * - getRenderSettings() / applyRenderSettings(partial)
43
- * - setRenderSettings(settings) [via PrefViewer menu integration]
47
+ * - getRenderSettings() / scheduleRenderSettingsReload(settings)
48
+ * - setContainerVisibility(name, show)
49
+ * - setMaterialOptions() / setCameraOptions() / setIBLOptions()
44
50
  *
45
- * Key Subsystems
46
- * - Persistence: #applyRenderSettings, #loadStoredRenderSettings, #saveRenderSettings keep AA/SSAO/IBL/shadow flags synced.
47
- * - Loading pipeline: #markContainersForReload, #markOptionsForReload, #loadAssetContainer, #loadContainers orchestrate deterministic reloads.
48
- * - Visual setup: #configureDracoCompression, #createCamera, #createLights, #initializeVisualImprovements, #initializeAmbientOcclussion,
49
- * #initializeIBLShadows, #initializeDefaultLightShadows, #initializeEnvironmentShadows, #setMaxSimultaneousLights.
50
- * - Interaction + XR: #bindHandlers, #enableInteraction, #onPointerObservable, #onMouseWheel, #onKeyUp, #createXRExperience,
51
- * #addStylesToARButton, #disposeXRExperience.
52
- * - Container helpers: #addContainer, #removeContainer, #replaceContainer, #setOptions_Materials, #setOptions_Camera,
53
- * #setOptions_IBL, #setVisibilityOfWallAndFloorInModel, #getPrefViewerComponent.
54
- * - Metadata + download utilities: #checkModelMetadata, #checkInnerFloorTranslation, #translateNodeY, #addDateToName,
55
- * #downloadZip, #openDownloadDialog.
56
- *
57
- * Notes
58
- * - Designed to be long-lived per PrefViewer instance; it caches parent components to reflect `show-model/show-scene` attributes.
59
- * - All browser-only features guard against SSR/Node usage by checking `window` before touching localStorage or XR APIs.
60
- * - Relies on PrefViewerMenu3D events to trigger render-setting updates, ensuring UI and persisted state never drift apart.
51
+ * Key Invariants
52
+ * - Asset containers must expose `setPendingWithCurrentStorage`/`setPending` before calling load/reload; the controller
53
+ * reads those flags to resolve fresh sources and avoids touching the DOM until data is ready.
54
+ * - Camera-dependent pipelines (DefaultRenderingPipeline, SSAO, IBL shadows, directional shadow generators) are rebuilt
55
+ * only after the active camera and environment textures are ready; render-loop restarts gate those transitions.
56
+ * - `show-model`/`show-scene` DOM attributes reflect container visibility; there are no direct `showModel()/hideModel()` APIs.
57
+ * - IBL shadows require `iblEnabled` plus `options.ibl.shadows` and a loaded HDR texture; otherwise fallback directional
58
+ * lights and environment-contributed lights supply classic shadow generators.
59
+ * - Browser-only features guard `window`, localStorage, and XR APIs before use so the controller is safe to construct
60
+ * in SSR/Node contexts (though functionality activates only in browsers).
61
61
  */
62
62
  export default class BabylonJSController {
63
63
 
@@ -96,7 +96,13 @@ export default class BabylonJSController {
96
96
 
97
97
  #gltfResolver = null; // GLTFResolver instance
98
98
  #babylonJSAnimationController = null; // AnimationController instance
99
-
99
+
100
+ #renderPipelines = {
101
+ default: null,
102
+ ssao: null,
103
+ iblShadows: null,
104
+ };
105
+
100
106
  #handlers = {
101
107
  onKeyUp: null,
102
108
  onPointerObservable: null,
@@ -167,7 +173,7 @@ export default class BabylonJSController {
167
173
  * @param {object} [settings={}] - Partial map of render settings (AA, SSAO, IBL, shadows, etc.).
168
174
  * @returns {boolean} True when any setting changed and was saved.
169
175
  */
170
- #applyRenderSettings(settings = {}) {
176
+ #saveRenderSettings(settings = {}) {
171
177
  if (!settings) {
172
178
  return false;
173
179
  }
@@ -180,12 +186,8 @@ export default class BabylonJSController {
180
186
  }
181
187
  });
182
188
 
183
- if (changed && settings.iblEnabled === false && this.#scene) {
184
- this.#scene.environmentTexture = null;
185
- }
186
-
187
189
  if (changed) {
188
- this.#saveRenderSettings();
190
+ this.#storeRenderSettings();
189
191
  }
190
192
 
191
193
  return changed;
@@ -223,14 +225,14 @@ export default class BabylonJSController {
223
225
  * @private
224
226
  * @returns {void}
225
227
  */
226
- #saveRenderSettings() {
228
+ #storeRenderSettings() {
227
229
  if (typeof window === "undefined" || !window?.localStorage) {
228
230
  return;
229
231
  }
230
232
  try {
231
233
  window.localStorage.setItem(this.#RENDER_SETTINGS_STORAGE_KEY, JSON.stringify(this.#settings));
232
234
  } catch (error) {
233
- console.warn("PrefViewer: unable to save render settings", error);
235
+ console.warn("PrefViewer: unable to store render settings", error);
234
236
  }
235
237
  }
236
238
 
@@ -263,9 +265,6 @@ export default class BabylonJSController {
263
265
  if (this.#options?.materials) {
264
266
  Object.values(this.#options.materials).forEach((material) => material?.setPendingWithCurrent?.());
265
267
  }
266
- if (this.#options?.ibl?.setPending) {
267
- this.#options.ibl.setPending();
268
- }
269
268
  }
270
269
 
271
270
  /**
@@ -376,40 +375,99 @@ export default class BabylonJSController {
376
375
  * Adds a hemispheric ambient light, a directional light for shadows, a shadow generator, and a camera-attached point light.
377
376
  * Sets light intensities and shadow properties for realistic rendering.
378
377
  * @private
379
- * @returns {void}
378
+ * @returns {Promise<boolean>} Returns true if lights were changed, false otherwise.
380
379
  */
381
- #createLights() {
382
- if (this.#settings.iblEnabled && this.#options.ibl && this.#options.ibl.url) {
380
+ async #createLights() {
381
+ const hemiLightName = "PrefViewerHemiLight";
382
+ const cameraLightName = "PrefViewerCameraLight";
383
+ const dirLightName = "PrefViewerDirLight";
384
+
385
+ const hemiLight = this.#scene.getLightByName(hemiLightName);
386
+ const cameraLight = this.#scene.getLightByName(cameraLightName);
387
+ const dirLight = this.#scene.getLightByName(dirLightName);
388
+
389
+ let lightsChanged = false;
390
+
391
+ const iblEnabled = this.#settings.iblEnabled && this.#options.ibl && this.#options.ibl.cachedUrl !== null;
392
+
393
+ if (iblEnabled) {
394
+ if (hemiLight) {
395
+ hemiLight.dispose();
396
+ }
397
+ if (cameraLight) {
398
+ cameraLight.dispose();
399
+ }
400
+ if (dirLight) {
401
+ dirLight.dispose();
402
+ }
383
403
  this.#hemiLight = this.#dirLight = this.#cameraLight = null;
384
- this.#initializeEnvironmentTexture();
404
+ lightsChanged = await this.#initializeEnvironmentTexture();
405
+ } else {
406
+ // If IBL is disabled but an environment texture exists, dispose it to save resources and ensure it doesn't affect the lighting
407
+ if (this.#scene.environmentTexture) {
408
+ this.#scene.environmentTexture.dispose();
409
+ this.#scene.environmentTexture = null;
410
+ lightsChanged = true;
411
+ }
412
+
413
+ // Add a hemispheric light for basic ambient illumination
414
+ if (!this.#hemiLight) {
415
+ this.#hemiLight = new HemisphericLight(hemiLightName, new Vector3(-10, 10, -10), this.#scene);
416
+ this.#hemiLight.intensity = 0.6;
417
+ }
418
+
419
+ // Add a directional light to cast shadows and provide stronger directional illumination
420
+ if (!this.#dirLight) {
421
+ this.#dirLight = new DirectionalLight(dirLightName, new Vector3(-10, 10, -10), this.#scene);
422
+ this.#dirLight.position = new Vector3(5, 4, 5); // light is IN FRONT + ABOVE + to the RIGHT
423
+ this.#dirLight.intensity = 0.6;
424
+ }
425
+
426
+ // Add a point light that follows the camera to ensure the model is always well-lit from the viewer's perspective
427
+ if (!this.#cameraLight) {
428
+ this.#cameraLight = new PointLight(cameraLightName, this.#camera.position, this.#scene);
429
+ this.#cameraLight.parent = this.#camera;
430
+ this.#cameraLight.intensity = 0.3;
431
+ }
385
432
  }
433
+ return lightsChanged;
434
+ }
386
435
 
387
- if (this.#scene.environmentTexture) {
388
- return true;
436
+ /**
437
+ * Detaches and disposes the SSAO render pipeline from the active camera when it exists.
438
+ * Guards against missing scene resources or absent pipelines, returning false when no cleanup is needed.
439
+ * @private
440
+ * @returns {Promise<boolean>} Returns true when the SSAO pipeline was disabled, false otherwise.
441
+ */
442
+ async #disableAmbientOcclusion() {
443
+ const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
444
+
445
+ if (!this.#scene || !pipelineManager || this.#scene?.activeCamera === null) {
446
+ return false;
389
447
  }
390
448
 
391
- // 1) Stronger ambient fill
392
- this.#hemiLight = this.#scene.getLightByName("PrefViewerHemiLight");
393
- if (!this.#hemiLight) {
394
- this.#hemiLight = new HemisphericLight("PrefViewerHemiLight", new Vector3(-10, 10, -10), this.#scene);
395
- this.#hemiLight.intensity = 0.6;
449
+ const supportedPipelines = pipelineManager.supportedPipelines;
450
+
451
+ if (supportedPipelines === undefined) {
452
+ return false;
396
453
  }
397
454
 
398
- // 2) Directional light from the front-right, angled slightly down
399
- this.#dirLight = this.#scene.getLightByName("PrefViewerDirLight");
400
- if (!this.#dirLight) {
401
- this.#dirLight = new DirectionalLight("PrefViewerDirLight", new Vector3(-10, 10, -10), this.#scene);
402
- this.#dirLight.position = new Vector3(5, 4, 5); // light is IN FRONT + ABOVE + to the RIGHT
403
- this.#dirLight.intensity = 0.6;
455
+ if (!this.#renderPipelines.ssao || !this.#renderPipelines.ssao?.name) {
456
+ return false;
404
457
  }
405
458
 
406
- // 3) Camera‐attached headlight
407
- this.#cameraLight = this.#scene.getLightByName("PrefViewerCameraLight");
408
- if (!this.#cameraLight) {
409
- this.#cameraLight = new PointLight("PrefViewerCameraLight", this.#camera.position, this.#scene);
410
- this.#cameraLight.parent = this.#camera;
411
- this.#cameraLight.intensity = 0.3;
459
+ const pipelineName = this.#renderPipelines.ssao.name;
460
+ let ssaoPipeline = supportedPipelines ? supportedPipelines.find((pipeline) => pipeline.name === pipelineName) : false;
461
+
462
+ if (ssaoPipeline) {
463
+ pipelineManager.detachCamerasFromRenderPipeline(pipelineName, [this.#scene.activeCamera]);
464
+ pipelineManager.removePipeline(pipelineName);
465
+ pipelineManager.update();
466
+ ssaoPipeline.dispose();
467
+ this.#renderPipelines.ssao = ssaoPipeline = null;
412
468
  }
469
+
470
+ return true;
413
471
  }
414
472
 
415
473
  /**
@@ -417,38 +475,31 @@ export default class BabylonJSController {
417
475
  * Disposes previous SSAO pipelines, instantiates a tuned `SSAORenderingPipeline`, and attaches it to the
418
476
  * current camera so contact shadows enhance depth perception once assets reload or the camera changes.
419
477
  * @private
420
- * @returns {boolean} True if the SSAO pipeline is supported and enabled, otherwise false.
478
+ * @returns {Promise<boolean>} True if the SSAO pipeline is supported and enabled, otherwise false.
421
479
  */
422
- #initializeAmbientOcclussion() {
423
- if (!this.#scene || !this.#scene.postProcessRenderPipelineManager || this.#scene.activeCamera === null) {
480
+ async #initializeAmbientOcclussion() {
481
+ const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
482
+ if (!this.#scene || !pipelineManager || this.#scene?.activeCamera === null) {
424
483
  return false;
425
484
  }
426
485
 
427
486
  if (!this.#settings.ambientOcclusionEnabled) {
428
487
  return false;
429
488
  }
430
-
431
- const pipelineName = "PrefViewerSSAORenderingPipeline";
432
-
433
- const supportedPipelines = this.#scene.postProcessRenderPipelineManager.supportedPipelines;
434
-
435
- if (!supportedPipelines) {
489
+ const supportedPipelines = pipelineManager.supportedPipelines;
490
+
491
+ if (supportedPipelines === undefined) {
436
492
  return false;
437
493
  }
438
-
439
- const oldSsaoPipeline = supportedPipelines ? supportedPipelines.find((pipeline) => pipeline.name === pipelineName) : false;
440
-
441
- if (oldSsaoPipeline) {
442
- oldSsaoPipeline.dispose();
443
- this.#scene.postProcessRenderPipelineManager.update();
444
- }
494
+
495
+ const pipelineName = "PrefViewerSSAORenderingPipeline";
445
496
 
446
497
  const ssaoRatio = {
447
498
  ssaoRatio: 0.5,
448
499
  combineRatio: 1.0
449
500
  };
450
501
 
451
- const ssaoPipeline = new SSAORenderingPipeline(pipelineName, this.#scene, ssaoRatio, [this.#scene.activeCamera]);
502
+ let ssaoPipeline = new SSAORenderingPipeline(pipelineName, this.#scene, ssaoRatio, [this.#scene.activeCamera]);
452
503
 
453
504
  if (!ssaoPipeline){
454
505
  return false;
@@ -466,48 +517,85 @@ export default class BabylonJSController {
466
517
  ssaoPipeline._ssaoPostProcess.autoClear = false;
467
518
  ssaoPipeline._ssaoPostProcess.samples = 1;
468
519
  }
520
+ if (ssaoPipeline._combinePostProcess) {
521
+ ssaoPipeline._combinePostProcess.autoClear = false;
522
+ ssaoPipeline._combinePostProcess.samples = 1;
523
+ }
469
524
 
470
- this.#scene.postProcessRenderPipelineManager.update();
525
+ this.#renderPipelines.ssao = ssaoPipeline;
526
+ pipelineManager.update();
471
527
  return true;
472
528
  } else {
473
529
  ssaoPipeline.dispose();
474
- this.#scene.postProcessRenderPipelineManager.update();
530
+ this.#renderPipelines.ssao = ssaoPipeline = null;
531
+ pipelineManager.update();
475
532
  return false;
476
533
  }
477
534
  }
478
535
 
479
536
  /**
480
- * Rebuilds the custom default rendering pipeline (MSAA, FXAA, film grain) for the active camera.
481
- * Disposes any previous pipeline instance to avoid duplicates, then attaches a fresh
482
- * `DefaultRenderingPipeline` with tuned settings for sharper anti-aliasing and subtle grain.
537
+ * Tears down the default rendering pipeline (MSAA/FXAA/grain) for the active camera when present.
538
+ * Ensures stale pipelines detach cleanly so a fresh one can be installed on the next load.
483
539
  * @private
484
- * @returns {boolean} True when the pipeline is supported and active, otherwise false.
485
- * @see {@link https://doc.babylonjs.com/features/featuresDeepDive/postProcesses/defaultRenderingPipeline|Using the Default Rendering Pipeline | Babylon.js Documentation}
540
+ * @returns {Promise<boolean>} Returns true when the pipeline was removed, false otherwise.
486
541
  */
487
- #initializeVisualImprovements() {
488
- if (!this.#scene || !this.#scene.postProcessRenderPipelineManager || this.#scene.activeCamera === null) {
542
+ async #disableVisualImprovements() {
543
+ const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
544
+
545
+ if (!this.#scene || !pipelineManager || this.#scene?.activeCamera === null) {
489
546
  return false;
490
547
  }
491
548
 
492
- const pipelineName = "PrefViewerDefaultRenderingPipeline";
493
- const supportedPipelines = this.#scene.postProcessRenderPipelineManager.supportedPipelines;
549
+ const supportedPipelines = pipelineManager.supportedPipelines;
550
+
551
+ if (supportedPipelines === undefined) {
552
+ return false;
553
+ }
494
554
 
495
- if (!supportedPipelines) {
555
+ if (!this.#renderPipelines.default || !this.#renderPipelines.default?.name) {
496
556
  return false;
497
557
  }
498
558
 
499
- const oldDefaultPipeline = supportedPipelines ? supportedPipelines.find((pipeline) => pipeline.name === pipelineName) : false;
559
+ const pipelineName = this.#renderPipelines.default.name;
560
+ let defaultPipeline = supportedPipelines ? supportedPipelines.find((pipeline) => pipeline.name === pipelineName) : false;
500
561
 
501
- if (oldDefaultPipeline) {
502
- oldDefaultPipeline.dispose();
503
- this.#scene.postProcessRenderPipelineManager.update();
562
+ if (defaultPipeline) {
563
+ pipelineManager.detachCamerasFromRenderPipeline(pipelineName, [this.#scene.activeCamera]);
564
+ pipelineManager.removePipeline(pipelineName);
565
+ pipelineManager.update();
566
+ defaultPipeline.dispose();
567
+ this.#renderPipelines.default = defaultPipeline = null;
568
+ }
569
+
570
+ return true;
571
+ }
572
+
573
+ /**
574
+ * Rebuilds the custom default rendering pipeline (MSAA, FXAA, film grain) for the active camera.
575
+ * Disposes any previous pipeline instance to avoid duplicates, then attaches a fresh
576
+ * `DefaultRenderingPipeline` with tuned settings for sharper anti-aliasing and subtle grain.
577
+ * @private
578
+ * @returns {Promise<boolean>} True when the pipeline is supported and active, otherwise false.
579
+ * @see {@link https://doc.babylonjs.com/features/featuresDeepDive/postProcesses/defaultRenderingPipeline|Using the Default Rendering Pipeline | Babylon.js Documentation}
580
+ */
581
+ async #initializeVisualImprovements() {
582
+ const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
583
+ if (!this.#scene || !pipelineManager || this.#scene?.activeCamera === null) {
584
+ return false;
504
585
  }
505
586
 
506
587
  if (!this.#settings.antiAliasingEnabled) {
507
588
  return false;
508
589
  }
590
+ const supportedPipelines = pipelineManager.supportedPipelines;
591
+
592
+ if (supportedPipelines === undefined) {
593
+ return false;
594
+ }
595
+
596
+ const pipelineName = "PrefViewerDefaultRenderingPipeline";
509
597
 
510
- const defaultPipeline = new DefaultRenderingPipeline(pipelineName, true, this.#scene, [this.#scene.activeCamera], true);
598
+ let defaultPipeline = new DefaultRenderingPipeline(pipelineName, true, this.#scene, [this.#scene.activeCamera], true);
511
599
 
512
600
  if (!defaultPipeline){
513
601
  return false;
@@ -518,7 +606,6 @@ export default class BabylonJSController {
518
606
  const caps = this.#scene.getEngine()?.getCaps?.() || {};
519
607
  const maxSamples = typeof caps.maxMSAASamples === "number" ? caps.maxMSAASamples : 4;
520
608
  defaultPipeline.samples = Math.max(1, Math.min(8, maxSamples));
521
-
522
609
  // FXAA - Fast Approximate Anti-Aliasing
523
610
  defaultPipeline.fxaaEnabled = true;
524
611
  defaultPipeline.fxaa.samples = 8;
@@ -547,11 +634,13 @@ export default class BabylonJSController {
547
634
  defaultPipeline.grain._postProcess.autoClear = false;
548
635
  }
549
636
 
550
- this.#scene.postProcessRenderPipelineManager.update();
637
+ this.#renderPipelines.default = defaultPipeline;
638
+ pipelineManager.update();
551
639
  return true;
552
640
  } else {
553
641
  defaultPipeline.dispose();
554
- this.#scene.postProcessRenderPipelineManager.update();
642
+ this.#renderPipelines.default = defaultPipeline = null;
643
+ pipelineManager.update();
555
644
  return false;
556
645
  }
557
646
  }
@@ -561,13 +650,60 @@ export default class BabylonJSController {
561
650
  * Loads an HDR texture from a predefined URI and assigns it to the scene's environmentTexture property.
562
651
  * Configures gamma space, mipmaps, and intensity level for realistic lighting.
563
652
  * @private
564
- * @returns {boolean}
653
+ * @returns {Promise<boolean>} Returns true if the environment texture was changed, false if it was already up to date or failed to load.
565
654
  */
566
- #initializeEnvironmentTexture() {
567
- const hdrTextureURI = this.#options.ibl.url;
568
- const hdrTexture = new HDRCubeTexture(hdrTextureURI, this.#scene, 128, false, false, false, true);
655
+ async #initializeEnvironmentTexture() {
656
+ if (this.#scene.environmentTexture) {
657
+ this.#scene.environmentTexture.dispose();
658
+ this.#scene.environmentTexture = null;
659
+ }
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);
662
+ await WhenTextureReadyAsync(hdrTexture);
569
663
  hdrTexture.level = this.#options.ibl.intensity;
570
664
  this.#scene.environmentTexture = hdrTexture;
665
+ this.#scene.markAllMaterialsAsDirty(Material.TextureDirtyFlag);
666
+ return true;
667
+ }
668
+
669
+ /**
670
+ * Removes the IBL shadow render pipeline from the active camera when present.
671
+ * Ensures voxelized shadow data is disposed so reloading environments installs a clean pipeline.
672
+ * @private
673
+ * @returns {Promise<boolean>} Returns true when the pipeline was removed, false otherwise.
674
+ */
675
+ async #disableIBLShadows() {
676
+ const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
677
+
678
+ if (!this.#scene || !pipelineManager || this.#scene?.activeCamera === null) {
679
+ return false;
680
+ }
681
+
682
+ const supportedPipelines = pipelineManager.supportedPipelines;
683
+
684
+ if (supportedPipelines === undefined) {
685
+ return false;
686
+ }
687
+
688
+ if (!this.#renderPipelines.iblShadows || !this.#renderPipelines.iblShadows?.name) {
689
+ return false;
690
+ }
691
+
692
+ const pipelineName = this.#renderPipelines.iblShadows.name;
693
+ let iblShadowsPipeline = supportedPipelines ? supportedPipelines.find((pipeline) => pipeline.name === pipelineName) : false;
694
+
695
+ if (iblShadowsPipeline) {
696
+ iblShadowsPipeline.toggleShadow(false);
697
+ iblShadowsPipeline.clearShadowCastingMeshes();
698
+ iblShadowsPipeline.clearShadowReceivingMaterials();
699
+ iblShadowsPipeline.resetAccumulation();
700
+ pipelineManager.detachCamerasFromRenderPipeline(pipelineName, [this.#scene.activeCamera]);
701
+ pipelineManager.removePipeline(pipelineName);
702
+ pipelineManager.update();
703
+ iblShadowsPipeline.dispose();
704
+ this.#renderPipelines.iblShadows = iblShadowsPipeline = null;
705
+ }
706
+
571
707
  return true;
572
708
  }
573
709
 
@@ -577,30 +713,37 @@ export default class BabylonJSController {
577
713
  * Configures pipeline options for resolution, sampling, opacity, and debugging.
578
714
  * Only applies if the scene has an environment texture set.
579
715
  * @private
580
- * @returns {void|false} Returns false if no environment texture is set; otherwise void.
716
+ * @returns {Promise<void|boolean>} Returns false if no environment texture is set; otherwise void.
581
717
  */
582
- #initializeIBLShadows() {
583
- if (!this.#scene || !this.#scene.postProcessRenderPipelineManager || this.#scene.activeCamera === null) {
718
+ async #initializeIBLShadows() {
719
+
720
+ await this.#scene.whenReadyAsync();
721
+
722
+ const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
723
+
724
+ if (!this.#scene || !pipelineManager || this.#scene?.activeCamera === null) {
584
725
  return false;
585
726
  }
586
-
587
- // if (!this.#scene.environmentTexture || !this.#scene.environmentTexture.isReady()) {
727
+
588
728
  if (!this.#scene.environmentTexture) {
589
729
  return false;
590
730
  }
591
- const pipelineName = "PrefViewerIblShadowsRenderPipeline";
592
- const supportedPipelines = this.#scene.postProcessRenderPipelineManager.supportedPipelines;
593
731
 
732
+ if (!this.#scene.environmentTexture.isReady()) {
733
+ const self = this;
734
+ this.#scene.environmentTexture.onLoadObservable.addOnce(() => {
735
+ self.#initializeIBLShadows();
736
+ });
737
+ return false;
738
+ }
739
+
740
+ const supportedPipelines = pipelineManager.supportedPipelines;
741
+
594
742
  if (!supportedPipelines) {
595
743
  return false;
596
744
  }
597
745
 
598
- const oldIblShadowsRenderPipeline = supportedPipelines ? supportedPipelines.find((pipeline) => pipeline.name === pipelineName) : false;
599
-
600
- if (oldIblShadowsRenderPipeline) {
601
- oldIblShadowsRenderPipeline.dispose();
602
- this.#scene.postProcessRenderPipelineManager.update();
603
- }
746
+ const pipelineName = "PrefViewerIblShadowsRenderPipeline";
604
747
 
605
748
  const pipelineOptions = {
606
749
  resolutionExp: 1, // Higher resolution for better shadow quality (recomended 8)
@@ -611,13 +754,13 @@ export default class BabylonJSController {
611
754
  shadowOpacity: 0.85,
612
755
  };
613
756
 
614
- const iblShadowsRenderPipeline = new IblShadowsRenderPipeline(pipelineName, this.#scene, pipelineOptions, [this.#scene.activeCamera]);
757
+ let iblShadowsPipeline = new IblShadowsRenderPipeline(pipelineName, this.#scene, pipelineOptions, [this.#scene.activeCamera]);
615
758
 
616
- if (!iblShadowsRenderPipeline) {
759
+ if (!iblShadowsPipeline) {
617
760
  return false;
618
761
  }
619
762
 
620
- if (iblShadowsRenderPipeline.isSupported) {
763
+ if (iblShadowsPipeline.isSupported) {
621
764
  // Disable all debug passes for performance
622
765
  const pipelineProps = {
623
766
  allowDebugPasses: false,
@@ -631,13 +774,8 @@ export default class BabylonJSController {
631
774
  accumulationPassDebugEnabled: false,
632
775
  };
633
776
 
634
- Object.assign(iblShadowsRenderPipeline, pipelineProps);
635
-
636
- if (iblShadowsRenderPipeline._ssaoPostProcess) {
637
- iblShadowsRenderPipeline._ssaoPostProcess.autoClear = false;
638
- iblShadowsRenderPipeline._ssaoPostProcess.samples = 1;
639
- }
640
-
777
+ Object.assign(iblShadowsPipeline, pipelineProps);
778
+
641
779
  this.#scene.meshes.forEach((mesh) => {
642
780
  const isRootMesh = mesh.id.startsWith("__root__");
643
781
  if (isRootMesh) {
@@ -647,26 +785,29 @@ export default class BabylonJSController {
647
785
  const isHDRIMesh = mesh.name?.toLowerCase() === "hdri";
648
786
  const extrasCastShadows = mesh.metadata?.gltf?.extras?.castShadows;
649
787
  const meshGenerateShadows = typeof extrasCastShadows === "boolean" ? extrasCastShadows : isHDRIMesh ? false : true;
650
-
788
+
651
789
  if (meshGenerateShadows) {
652
- iblShadowsRenderPipeline.addShadowCastingMesh(mesh);
653
- iblShadowsRenderPipeline.updateSceneBounds();
790
+ iblShadowsPipeline.addShadowCastingMesh(mesh);
791
+ iblShadowsPipeline.updateSceneBounds();
654
792
  }
655
793
  });
656
-
794
+
657
795
  this.#scene.materials.forEach((material) => {
658
796
  if (material instanceof PBRMaterial) {
659
797
  material.enableSpecularAntiAliasing = false;
660
798
  }
661
- iblShadowsRenderPipeline.addShadowReceivingMaterial(material);
799
+ iblShadowsPipeline.addShadowReceivingMaterial(material);
662
800
  });
663
-
664
- iblShadowsRenderPipeline.updateVoxelization();
665
- this.#scene.postProcessRenderPipelineManager.update();
801
+
802
+ iblShadowsPipeline.toggleShadow(true);
803
+ iblShadowsPipeline.updateVoxelization();
804
+ this.#renderPipelines.iblShadows = iblShadowsPipeline;
805
+ pipelineManager.update();
666
806
  return true;
667
807
  } else {
668
- iblShadowsRenderPipeline.dispose();
669
- this.#scene.postProcessRenderPipelineManager.update();
808
+ iblShadowsPipeline.dispose();
809
+ this.#renderPipelines.iblShadows = iblShadowsPipeline = null;
810
+ pipelineManager.update();
670
811
  return false;
671
812
  }
672
813
  }
@@ -699,8 +840,7 @@ export default class BabylonJSController {
699
840
  * @private
700
841
  * @returns {void}
701
842
  */
702
- #initializeDefaultLightShadows() {
703
- this.#shadowGen = [];
843
+ async #initializeDefaultLightShadows() {
704
844
  if (!this.#dirLight) {
705
845
  return;
706
846
  }
@@ -723,7 +863,7 @@ export default class BabylonJSController {
723
863
  * @private
724
864
  * @returns {void}
725
865
  */
726
- #initializeEnvironmentShadows() {
866
+ async #initializeEnvironmentShadows() {
727
867
  this.#shadowGen = this.#shadowGen.filter((generator) => {
728
868
  if (!generator || typeof generator.getLight !== "function") {
729
869
  return false;
@@ -766,6 +906,20 @@ export default class BabylonJSController {
766
906
  });
767
907
  }
768
908
 
909
+ /**
910
+ * Disposes every active shadow generator plus the IBL shadow pipeline to avoid stale casters across reloads.
911
+ * Clears the cached `#shadowGen` array so subsequent loads can rebuild fresh generators.
912
+ * @private
913
+ * @returns {void}
914
+ */
915
+ async #disableShadows() {
916
+ this.#shadowGen.forEach((shadowGenerator) => {
917
+ shadowGenerator.dispose();
918
+ });
919
+ this.#shadowGen = [];
920
+ await this.#disableIBLShadows();
921
+ }
922
+
769
923
  /**
770
924
  * Initializes shadows for the Babylon.js scene.
771
925
  * @private
@@ -774,28 +928,22 @@ export default class BabylonJSController {
774
928
  * If no environment texture is set, initializes IBL shadows.
775
929
  * Otherwise, sets up shadow casting and receiving for all relevant meshes using the shadow generator.
776
930
  */
777
- #initializeShadows() {
931
+ async #initializeShadows() {
778
932
  if (!this.#settings.shadowsEnabled) {
779
933
  return false;
780
934
  }
781
935
 
782
936
  this.#ensureMeshesReceiveShadows();
783
937
 
784
- if (this.#scene.environmentTexture) {
785
- if (this.#options.ibl.shadows) {
786
- if (this.#scene.environmentTexture.isReady()) {
787
- this.#initializeIBLShadows();
788
- } else {
789
- const self = this;
790
- this.#scene.environmentTexture.onLoadObservable.addOnce(() => {
791
- self.#initializeIBLShadows();
792
- });
793
- }
794
- }
938
+ const iblEnabled = this.#settings.iblEnabled && this.#options.ibl && this.#options.ibl.cachedUrl !== null;
939
+ const iblShadowsEnabled = iblEnabled && this.#options.ibl.shadows;
940
+
941
+ if (iblShadowsEnabled) {
942
+ await this.#initializeIBLShadows();
795
943
  } else {
796
- this.#initializeDefaultLightShadows();
944
+ await this.#initializeDefaultLightShadows();
797
945
  }
798
- this.#initializeEnvironmentShadows();
946
+ await this.#initializeEnvironmentShadows();
799
947
  }
800
948
 
801
949
  /**
@@ -913,7 +1061,6 @@ export default class BabylonJSController {
913
1061
  this.#engine.dispose();
914
1062
  this.#engine = this.#scene = this.#camera = null;
915
1063
  this.#hemiLight = this.#dirLight = this.#cameraLight = null;
916
- this.#shadowGen = [];
917
1064
  }
918
1065
 
919
1066
  /**
@@ -1129,14 +1276,8 @@ export default class BabylonJSController {
1129
1276
  * @private
1130
1277
  * @returns {boolean} True when lights were refreshed due to pending IBL changes, otherwise false.
1131
1278
  */
1132
- #setOptions_IBL() {
1133
- if (this.#options.ibl.isPending && this.#settings.iblEnabled) {
1134
- this.#options.ibl.setSuccess(true);
1135
- this.#createLights();
1136
- return true;
1137
- }
1138
- this.#createLights();
1139
- return false;
1279
+ async #setOptions_IBL() {
1280
+ return await this.#createLights();
1140
1281
  }
1141
1282
 
1142
1283
  /**
@@ -1258,6 +1399,52 @@ export default class BabylonJSController {
1258
1399
  return this.#addContainer(container, false);
1259
1400
  }
1260
1401
 
1402
+ /**
1403
+ * Stops every animation group on the provided asset container to guarantee new loads start from a clean state.
1404
+ * @private
1405
+ * @param {AssetContainer} assetContainer - Container whose animation groups should be halted.
1406
+ */
1407
+ #assetContainer_stopAnimations(assetContainer) {
1408
+ if (!assetContainer.animationGroups || assetContainer.animationGroups.length === 0) {
1409
+ return;
1410
+ }
1411
+ assetContainer.animationGroups.forEach((animationGroup) => {
1412
+ animationGroup.stop();
1413
+ });
1414
+ }
1415
+
1416
+ /**
1417
+ * Disposes every imported light so subsequent reloads avoid duplicating scene illumination.
1418
+ * @private
1419
+ * @param {AssetContainer} assetContainer - Container whose lights should be cleaned up.
1420
+ * @returns {void}
1421
+ */
1422
+ #assetContainer_deleteLights(assetContainer) {
1423
+ if (!assetContainer.lights || assetContainer.lights.length === 0) {
1424
+ return;
1425
+ }
1426
+ assetContainer.lights.forEach((light) => {
1427
+ light.dispose();
1428
+ });
1429
+ assetContainer.lights = [];
1430
+ }
1431
+
1432
+ /**
1433
+ * Assigns unique ids to every imported camera so Babylon.js does not reuse stale SSAO effects between reloads.
1434
+ * @private
1435
+ * @param {AssetContainer} assetContainer - Container whose cameras need deterministic id regeneration.
1436
+ * @returns {void}
1437
+ */
1438
+ #assetContainer_retagCameras(assetContainer) {
1439
+ if (!assetContainer.cameras || assetContainer.cameras.length === 0) {
1440
+ return;
1441
+ }
1442
+ assetContainer.cameras.forEach((camera) => {
1443
+ const sufix = "_" + Date.now();
1444
+ camera.id = `${camera.id || camera.name || "camera"}${sufix}`;
1445
+ });
1446
+ }
1447
+
1261
1448
  /**
1262
1449
  * Sets the visibility of wall and floor meshes in the model container based on the provided value or environment visibility.
1263
1450
  * @private
@@ -1279,8 +1466,9 @@ export default class BabylonJSController {
1279
1466
  * @private
1280
1467
  * @returns {void}
1281
1468
  */
1282
- #stopRender() {
1469
+ async #stopRender() {
1283
1470
  this.#engine.stopRenderLoop(this.#handlers.renderLoop);
1471
+ await this.#unloadCameraDependentEffects();
1284
1472
  }
1285
1473
  /**
1286
1474
  * Starts the Babylon.js render loop for the current scene.
@@ -1289,20 +1477,30 @@ export default class BabylonJSController {
1289
1477
  * @returns {Promise<void>}
1290
1478
  */
1291
1479
  async #startRender() {
1292
- await this.#scene.whenReadyAsync();
1293
- this.#engine.runRenderLoop(this.#handlers.renderLoop);
1480
+ await this.#loadCameraDependentEffects();
1481
+ this.#scene.executeWhenReady(() => {
1482
+ this.#engine.runRenderLoop(this.#handlers.renderLoop);
1483
+ });
1294
1484
  }
1295
1485
 
1296
1486
  /**
1297
- * Loads an asset container (model, environment, materials, etc.) using the provided container state.
1487
+ * Loads a single asset container (model, environment, materials, etc.) based on the container state flags.
1488
+ * Skips work when nothing is pending, otherwise resolves the GLTF source, refreshes cache metadata and streams it
1489
+ * into the Babylon.js scene via `LoadAssetContainerAsync`.
1298
1490
  * @private
1299
- * @param {object} container - The container object containing asset state and metadata.
1300
- * @returns {Promise<[object, AssetContainer|boolean]>} Resolves to an array with the container and the loaded asset container, or false if loading fails.
1491
+ * @param {object} container - Container descriptor that carries the GLTF storage pointer and current cache info.
1492
+ * @param {boolean} [force=false] - When true, bypasses cached size/timestamp so the resolver re-downloads the asset.
1493
+ * @returns {Promise<[object, AssetContainer|boolean]>} Resolves to `[container, assetContainer]` on success, or
1494
+ * `[container, false]` when loading was skipped or failed.
1301
1495
  * @description
1302
- * Resolves the asset source using GLTFResolver, prepares plugin options, and loads the asset into the Babylon.js scene.
1303
- * Updates the container's cache data and returns the container along with the loaded asset container or false if loading fails.
1496
+ * 1. Validates that the container has pending data and initializes the shared `GLTFResolver` if needed.
1497
+ * 2. Requests the source blob (respecting the cached size/timestamp unless `force` is set) and stores the new cache
1498
+ * metadata via `setPendingCacheData`.
1499
+ * 3. Builds the Babylon plugin options so extras are surfaced as metadata, then imports the container with
1500
+ * `LoadAssetContainerAsync`, returning the tuple so the caller can decide how to attach it to the scene.
1304
1501
  */
1305
- async #loadAssetContainer(container) {
1502
+ async #loadAssetContainer(container, force = false) {
1503
+
1306
1504
  if (container?.state?.update?.storage === undefined || container?.state?.size === undefined || container?.state?.timeStamp === undefined) {
1307
1505
  return [container, false];
1308
1506
  }
@@ -1314,9 +1512,12 @@ export default class BabylonJSController {
1314
1512
  if (!this.#gltfResolver) {
1315
1513
  this.#gltfResolver = new GLTFResolver();
1316
1514
  }
1317
- //let sourceData = await this.#gltfResolver.getSource(container.state.update.storage, container.state.size, container.state.timeStamp);
1318
- // TEMPORARY: We never pass 'size' or 'timeStamp' to always force a reload and avoid issues with the active camera in reloading assetContainers
1319
- let sourceData = await this.#gltfResolver.getSource(container.state.update.storage, 0, null);
1515
+
1516
+ const currentSize = force ? 0 : container.state.size;
1517
+ const currentTimeStamp = force ? null : container.state.timeStamp;
1518
+
1519
+ let sourceData = await this.#gltfResolver.getSource(container.state.update.storage, currentSize, currentTimeStamp);
1520
+
1320
1521
  if (!sourceData) {
1321
1522
  return [container, false];
1322
1523
  }
@@ -1358,8 +1559,7 @@ export default class BabylonJSController {
1358
1559
  * Returns an object with success status and error details.
1359
1560
  */
1360
1561
  async #loadContainers() {
1361
- this.#stopRender();
1362
- this.#scene.postProcessRenderPipelineManager?.dispose();
1562
+ await this.#stopRender();
1363
1563
 
1364
1564
  let oldModelMetadata = { ...(this.#containers.model?.state?.metadata ?? {}) };
1365
1565
  let newModelMetadata = {};
@@ -1375,16 +1575,20 @@ export default class BabylonJSController {
1375
1575
  };
1376
1576
 
1377
1577
  await Promise.allSettled(promiseArray)
1378
- .then((values) => {
1578
+ .then(async (values) => {
1379
1579
  this.#disposeAnimationController();
1380
1580
  values.forEach((result) => {
1381
1581
  const container = result.value ? result.value[0] : null;
1382
1582
  const assetContainer = result.value ? result.value[1] : null;
1383
1583
  if (result.status === "fulfilled" && assetContainer) {
1384
1584
  if (container.state.name === "model") {
1385
- assetContainer.lights = [];
1585
+ this.#assetContainer_deleteLights(assetContainer);
1586
+ this.#assetContainer_stopAnimations(assetContainer);
1386
1587
  newModelMetadata = { ...(container.state.update.metadata ?? {}) };
1387
1588
  }
1589
+ if (container.state.name === "model" || container.state.name === "environment") {
1590
+ this.#assetContainer_retagCameras(assetContainer);
1591
+ }
1388
1592
  this.#replaceContainer(container, assetContainer);
1389
1593
  container.state.setSuccess(true);
1390
1594
  } else {
@@ -1397,7 +1601,7 @@ export default class BabylonJSController {
1397
1601
 
1398
1602
  this.#setOptions_Materials();
1399
1603
  this.#setOptions_Camera();
1400
- this.#setOptions_IBL();
1604
+ await this.#setOptions_IBL();
1401
1605
  this.#setVisibilityOfWallAndFloorInModel();
1402
1606
  detail.success = true;
1403
1607
  })
@@ -1410,9 +1614,8 @@ export default class BabylonJSController {
1410
1614
  .finally(async () => {
1411
1615
  this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
1412
1616
  this.#setMaxSimultaneousLights();
1413
- this.#loadCameraDepentEffects();
1414
1617
  this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
1415
- this.#startRender();
1618
+ await this.#startRender();
1416
1619
  });
1417
1620
  return detail;
1418
1621
  }
@@ -1423,10 +1626,22 @@ export default class BabylonJSController {
1423
1626
  * @private
1424
1627
  * @returns {void}
1425
1628
  */
1426
- #loadCameraDepentEffects() {
1427
- this.#initializeVisualImprovements();
1428
- this.#initializeAmbientOcclussion();
1429
- this.#initializeShadows();
1629
+ async #loadCameraDependentEffects() {
1630
+ await this.#initializeVisualImprovements();
1631
+ await this.#initializeAmbientOcclussion();
1632
+ await this.#initializeShadows();
1633
+ }
1634
+
1635
+ /**
1636
+ * Shuts down every post-process tied to the active camera before stopping the render loop.
1637
+ * Ensures AA, SSAO, and shadow resources detach cleanly so reloads rebuild from scratch.
1638
+ * @private
1639
+ * @returns {void}
1640
+ */
1641
+ async #unloadCameraDependentEffects() {
1642
+ await this.#disableVisualImprovements();
1643
+ await this.#disableAmbientOcclusion();
1644
+ await this.#disableShadows();
1430
1645
  }
1431
1646
 
1432
1647
  /**
@@ -1670,7 +1885,15 @@ export default class BabylonJSController {
1670
1885
  geometryBufferRenderer.generateNormalsInWorldSpace = true;
1671
1886
  }
1672
1887
 
1673
- this.#scene.clearColor = new Color4(1, 1, 1, 1);
1888
+ this.#scene.clearColor = new Color4(1, 1, 1, 1).toLinearSpace();
1889
+
1890
+ // Lowered exposure to prevent scenes from looking blown out when the DefaultRenderingPipeline (Antialiasing) is enabled.
1891
+ this.#scene.imageProcessingConfiguration.exposure = 0.75;
1892
+ this.#scene.imageProcessingConfiguration.contrast = 1.0;
1893
+ this.#scene.imageProcessingConfiguration.toneMappingEnabled = false;
1894
+ this.#scene.imageProcessingConfiguration.vignetteEnabled = false;
1895
+ this.#scene.imageProcessingConfiguration.colorCurvesEnabled = false;
1896
+
1674
1897
  this.#createCamera();
1675
1898
  this.#enableInteraction();
1676
1899
  await this.#createXRExperience();
@@ -1690,6 +1913,7 @@ export default class BabylonJSController {
1690
1913
  this.#disableInteraction();
1691
1914
  this.#disposeAnimationController();
1692
1915
  this.#disposeXRExperience();
1916
+ this.#unloadCameraDependentEffects();
1693
1917
  this.#disposeEngine();
1694
1918
  }
1695
1919
 
@@ -1815,26 +2039,6 @@ export default class BabylonJSController {
1815
2039
  return await this.#loadContainers();
1816
2040
  }
1817
2041
 
1818
- /**
1819
- * Merges incoming render flags with the current configuration, persists them, and marks
1820
- * all dependent loaders/options as pending when something actually changed.
1821
- * @public
1822
- * @param {{antiAliasingEnabled?:boolean, ambientOcclusionEnabled?:boolean, iblEnabled?:boolean, shadowsEnabled?:boolean}} settings Partial set of render toggles to apply.
1823
- * @returns {{changed:boolean, settings:{antiAliasingEnabled:boolean, ambientOcclusionEnabled:boolean, iblEnabled:boolean, shadowsEnabled:boolean}}}
1824
- * @description
1825
- * Callers can inspect the `changed` flag to decide whether to trigger a reload with
1826
- * `reloadWithCurrentSettings()` or simply reuse the returned snapshot.
1827
- */
1828
- scheduleRenderSettingsReload(settings = {}) {
1829
- const changed = this.#applyRenderSettings(settings);
1830
- if (!changed) {
1831
- return { changed: false, settings: this.getRenderSettings() };
1832
- }
1833
- this.#markContainersForReload();
1834
- this.#markOptionsForReload();
1835
- return { changed: true, settings: this.getRenderSettings() };
1836
- }
1837
-
1838
2042
  /**
1839
2043
  * Applies camera options from the configuration to the active camera.
1840
2044
  * Stops and restarts the render loop to apply changes.
@@ -1844,7 +2048,6 @@ export default class BabylonJSController {
1844
2048
  setCameraOptions() {
1845
2049
  this.#stopRender();
1846
2050
  const cameraOptionsSetted = this.#setOptions_Camera();
1847
- this.#loadCameraDepentEffects();
1848
2051
  this.#startRender();
1849
2052
  return cameraOptionsSetted;
1850
2053
  }
@@ -1866,12 +2069,11 @@ export default class BabylonJSController {
1866
2069
  * Reapplies image-based lighting configuration (HDR URL, intensity, shadow mode).
1867
2070
  * Stops rendering, pushes pending IBL state into the scene, rebuilds camera-dependent effects, then resumes rendering.
1868
2071
  * @public
1869
- * @returns {void}
2072
+ * @returns {boolean} True if IBL options were set successfully, false otherwise.
1870
2073
  */
1871
2074
  setIBLOptions() {
1872
2075
  this.#stopRender();
1873
2076
  const IBLOptionsSetted = this.#setOptions_IBL();
1874
- this.#loadCameraDepentEffects();
1875
2077
  this.#startRender();
1876
2078
  return IBLOptionsSetted;
1877
2079
  }
@@ -1900,6 +2102,26 @@ export default class BabylonJSController {
1900
2102
  this.#startRender();
1901
2103
  }
1902
2104
 
2105
+ /**
2106
+ * Merges incoming render flags with the current configuration, persists them, and marks
2107
+ * all dependent loaders/options as pending when something actually changed.
2108
+ * @public
2109
+ * @param {{antiAliasingEnabled?:boolean, ambientOcclusionEnabled?:boolean, iblEnabled?:boolean, shadowsEnabled?:boolean}} settings Partial set of render toggles to apply.
2110
+ * @returns {{changed:boolean, settings:{antiAliasingEnabled:boolean, ambientOcclusionEnabled:boolean, iblEnabled:boolean, shadowsEnabled:boolean}}}
2111
+ * @description
2112
+ * Callers can inspect the `changed` flag to decide whether to trigger a reload with
2113
+ * `reloadWithCurrentSettings()` or simply reuse the returned snapshot.
2114
+ */
2115
+ scheduleRenderSettingsReload(settings = {}) {
2116
+ const changed = this.#saveRenderSettings(settings);
2117
+ if (!changed) {
2118
+ return { changed: false, settings: this.getRenderSettings() };
2119
+ }
2120
+ this.#markContainersForReload();
2121
+ this.#markOptionsForReload();
2122
+ return { changed: true, settings: this.getRenderSettings() };
2123
+ }
2124
+
1903
2125
  /**
1904
2126
  * Reloads every asset container using the latest staged render settings.
1905
2127
  * Intended to be called after `scheduleRenderSettingsReload()` marks data as pending.
@@ -70,9 +70,7 @@ export class ContainerData {
70
70
  }
71
71
  const targetShow = this.update.show !== null ? this.update.show : this.show;
72
72
  this.setPending(storedSource, targetShow);
73
- this.update.size = this.size;
74
- this.update.timeStamp = this.timeStamp;
75
- this.update.metadata = { ...(this.metadata ?? {}) };
73
+ this.setPendingCacheData(this.size, this.timeStamp, this.metadata);
76
74
  return true;
77
75
  }
78
76
  get isPending() {
@@ -225,38 +223,20 @@ export class CameraData {
225
223
  * - Inspect `isPending`/`isSuccess` to drive UI or re-render logic.
226
224
  */
227
225
  export class IBLData {
228
- constructor(url = null, intensity = 1.0, shadows = false, timeStamp = null) {
226
+ defaultIntensity = 1.0;
227
+ defaultShadows = false;
228
+ constructor(url = null, intensity = this.defaultIntensity, shadows = this.defaultShadows, timeStamp = null) {
229
229
  this.url = url;
230
+ this.cachedUrl = null;
230
231
  this.intensity = intensity;
231
232
  this.shadows = shadows;
232
233
  this.timeStamp = timeStamp;
233
- this.reset();
234
- }
235
- reset() {
236
- this.pending = false;
237
- this.success = false;
238
- }
239
- setValues(url, intensity, shadows, timeStamp) {
240
- this.url = url !== undefined ? url : this.url;
241
- this.intensity = intensity !== undefined ? intensity : this.intensity;
242
- this.shadows = shadows !== undefined ? shadows : this.shadows;
243
- this.timeStamp = timeStamp !== undefined ? timeStamp : this.timeStamp;
244
- }
245
- setSuccess(success = false) {
246
- if (success) {
247
- this.success = true;
248
- } else {
249
- this.success = false;
250
- }
251
234
  }
252
- setPending() {
253
- this.pending = true;
254
- this.success = false;
255
- }
256
- get isPending() {
257
- return this.pending === true;
258
- }
259
- get isSuccess() {
260
- return this.success === true;
235
+ setValues(url, cachedUrl = null, intensity = this.defaultIntensity, shadows = this.defaultShadows, timeStamp = null) {
236
+ this.url = url;
237
+ this.cachedUrl = cachedUrl;
238
+ this.intensity = intensity;
239
+ this.shadows = shadows;
240
+ this.timeStamp = timeStamp;
261
241
  }
262
242
  }
@@ -239,7 +239,6 @@ export default class PrefViewer3D extends HTMLElement {
239
239
  Object.values(this.#data.containers).forEach((container) => container.reset());
240
240
  Object.values(this.#data.options.materials).forEach((material) => material.reset());
241
241
  this.#data.options.camera.reset();
242
- this.#data.options.ibl.reset();
243
242
  }
244
243
 
245
244
  /**
@@ -335,6 +334,7 @@ export default class PrefViewer3D extends HTMLElement {
335
334
  const iblState = this.#data.options.ibl;
336
335
 
337
336
  let url = undefined;
337
+ let cachedUrl = undefined;
338
338
  let timeStamp = undefined;
339
339
  let shadows = undefined;
340
340
  let intensity = undefined;
@@ -344,7 +344,7 @@ export default class PrefViewer3D extends HTMLElement {
344
344
  const fileStorage = new FileStorage("PrefViewer", "Files");
345
345
  const newURL = await fileStorage.getURL(options.ibl.url);
346
346
  if (newURL) {
347
- url = newURL;
347
+ cachedUrl = newURL;
348
348
  timeStamp = await fileStorage.getTimeStamp(options.ibl.url);
349
349
  }
350
350
  }
@@ -358,10 +358,11 @@ export default class PrefViewer3D extends HTMLElement {
358
358
  const needUpdate = url !== undefined && url !== iblState.url ||
359
359
  timeStamp !== undefined && timeStamp !== iblState.timeStamp ||
360
360
  shadows !== undefined && shadows !== iblState.shadows ||
361
- intensity !== undefined && intensity !== iblState.intensity;
361
+ shadows === undefined && iblState.shadows !== iblState.defaultShadows ||
362
+ intensity !== undefined && intensity !== iblState.intensity ||
363
+ intensity === undefined && iblState.intensity !== iblState.defaultIntensity;
362
364
  if (needUpdate) {
363
- iblState.setValues(url, intensity, shadows, timeStamp);
364
- iblState.setPending(true);
365
+ iblState.setValues(url, cachedUrl, intensity, shadows, timeStamp);
365
366
  }
366
367
 
367
368
  return needUpdate;
@@ -9,7 +9,7 @@ import { DEFAULT_LOCALE, resolveLocale, translate } from "./localization/i18n.js
9
9
  * - Builds an accessible hover/focus-activated panel with switches for AA, SSAO, IBL, and dynamic shadows.
10
10
  * - Caches translated copy in `#texts` and listens for culture changes via the `culture` attribute or `setCulture()`.
11
11
  * - Tracks applied vs. draft render settings so pending diffs, button enablement, and status text stay in sync.
12
- * - Emits `pref-viewer-menu-apply` whenever the user confirms toggles, allowing PrefViewer/BabylonJSController to persist.
12
+ * - Emits `pref-viewer-menu-3d-apply` whenever the user confirms toggles, allowing PrefViewer/BabylonJSController to persist.
13
13
  * - Shows transient status/error messages and per-switch pending states while operations complete.
14
14
  *
15
15
  * Public API:
@@ -369,8 +369,15 @@ export default class PrefViewerMenu3D extends HTMLElement {
369
369
  if (this.#isApplying || !this.#hasPendingChanges()) {
370
370
  return;
371
371
  }
372
+
372
373
  const detail = { settings: { ...this.#draftSettings } };
373
- this.dispatchEvent(new CustomEvent("pref-viewer-menu-apply", { detail, bubbles: true, composed: true }));
374
+ const customEventOptions = {
375
+ bubbles: true,
376
+ cancelable: true,
377
+ composed: true,
378
+ detail: detail,
379
+ };
380
+ this.dispatchEvent(new CustomEvent("pref-viewer-menu-3d-apply", customEventOptions));
374
381
  }
375
382
 
376
383
  /**
@@ -216,7 +216,7 @@ export default class PrefViewer extends HTMLElement {
216
216
  this.#component3D.remove();
217
217
  }
218
218
  if (this.#menu3D) {
219
- this.#menu3D.removeEventListener("pref-viewer-menu-apply", this.#handlers.onMenuApply);
219
+ this.#menu3D.removeEventListener("pref-viewer-menu-3d-apply", this.#handlers.onMenuApply);
220
220
  this.#menu3D.remove();
221
221
  this.#menu3D = null;
222
222
  }
@@ -264,7 +264,7 @@ export default class PrefViewer extends HTMLElement {
264
264
  return;
265
265
  }
266
266
  if (this.#menu3D) {
267
- this.#menu3D.removeEventListener("pref-viewer-menu-apply", this.#handlers.onMenuApply);
267
+ this.#menu3D.removeEventListener("pref-viewer-menu-3d-apply", this.#handlers.onMenuApply);
268
268
  this.#menu3D.remove();
269
269
  }
270
270
  this.#menu3D = document.createElement("pref-viewer-menu-3d");
@@ -280,7 +280,7 @@ export default class PrefViewer extends HTMLElement {
280
280
  this.#handlers.onViewerHoverStart = () => this.#menu3D.setViewerHover(true);
281
281
  this.#handlers.onViewerHoverEnd = () => this.#menu3D.setViewerHover(false);
282
282
 
283
- this.#menu3D.addEventListener("pref-viewer-menu-apply", this.#handlers.onMenuApply);
283
+ this.#menu3D.addEventListener("pref-viewer-menu-3d-apply", this.#handlers.onMenuApply);
284
284
  this.#wrapper.addEventListener("mouseenter", this.#handlers.onViewerHoverStart);
285
285
  this.#wrapper.addEventListener("mouseleave", this.#handlers.onViewerHoverEnd);
286
286
 
@@ -601,7 +601,7 @@ export default class PrefViewer extends HTMLElement {
601
601
  }
602
602
 
603
603
  /**
604
- * Handles the custom "pref-viewer-menu-apply" event emitted by the menu component.
604
+ * Handles the custom "pref-viewer-menu-3d-apply" event emitted by the menu component.
605
605
  * Forwards the requested settings to the render-application pipeline.
606
606
  * @private
607
607
  * @param {CustomEvent} event - Menu event containing the `settings` payload.