@preference-sl/pref-viewer 2.13.0-beta.2 → 2.13.0-beta.21

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.
@@ -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";
@@ -8,59 +8,73 @@ 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
  /**
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.
15
+ * BabylonJSController coordinates the PrefViewer 3D runtime: it bootstraps Babylon.js, manages asset containers,
16
+ * rebuilds camera-dependent pipelines once textures are ready, brokers XR/download interactions, and persists
17
+ * render toggles so the UI can stay declarative. PrefViewer hands over container + option state, while this class
18
+ * turns it into a deterministic scene lifecycle with exports.
18
19
  *
19
20
  * 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.
21
+ * - Creates the Babylon.js engine/scene/camera stack, configures Draco decoders, wires render loops plus a throttled
22
+ * ResizeObserver, and exposes download/XR helpers.
23
+ * - Resolves GLTF/GLB sources via GLTFResolver, loads them into `AssetContainer`s, and toggles visibility by
24
+ * mutating container state plus `show-model/show-scene` attributes.
25
+ * - Applies/persists AA, SSAO, IBL, and shadow flags; when they change it stops the render loop, tears down pipelines,
26
+ * reloads containers, and reinstalls effects after environment textures finish loading.
27
+ * - Manages keyboard/pointer/wheel handlers, animation menus, and WebXR so PrefViewer menus and DOM attributes stay
28
+ * synchronized with Babylon state, using sampled pointer picking and last-picked tracking to reduce raycast/highlight
29
+ * cost on dense scenes.
25
30
  * - Generates GLB, glTF+ZIP, or USDZ exports with timestamped names and localized dialog copy.
26
31
  * - Translates metadata (inner floor offsets, cast/receive shadows, camera locks) into scene adjustments after reloads.
27
32
  *
28
33
  * Runtime Flow
29
34
  * 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.
35
+ * 2. Call `enable()` to configure Draco, spin up the engine/scene, attach interaction + XR hooks, and start the render loop.
36
+ * 3. When PrefViewer marks containers/options pending, invoke `load()` (or `reloadWithCurrentSettings()`) so the controller
37
+ * fetches sources, rebuilds containers, and reattaches pipelines.
38
+ * 4. Use `scheduleRenderSettingsReload()` to merge/persist toggles; when it reports `changed: true`, call
39
+ * `reloadWithCurrentSettings()` to apply the staged settings.
40
+ * 5. Use `setContainerVisibility`, `setMaterialOptions`, `setCameraOptions`, or `setIBLOptions` for targeted updates; these
41
+ * helpers stop/restart the render loop while they rebuild camera-dependent resources.
42
+ * 6. Invoke `disable()` when the element disconnects to tear down scenes, XR sessions, observers, and handlers.
43
+ * `disable()` is asynchronous; it waits for XR/session shutdown before engine disposal and also disposes the
44
+ * shared GLTF resolver, which closes its internal IndexedDB handle.
36
45
  *
37
46
  * Public API Highlights
38
47
  * - constructor(canvas, containers, options)
39
48
  * - enable() / disable()
40
- * - load()
49
+ * - load() / reloadWithCurrentSettings()
41
50
  * - downloadGLB(content) / downloadGLTF(content) / downloadUSDZ(content)
42
- * - getRenderSettings() / applyRenderSettings(partial)
43
- * - setRenderSettings(settings) [via PrefViewer menu integration]
51
+ * - getRenderSettings() / scheduleRenderSettingsReload(settings)
52
+ * - setContainerVisibility(name, show)
53
+ * - setMaterialOptions() / setCameraOptions() / setIBLOptions()
44
54
  *
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.
55
+ * Key Invariants
56
+ * - Asset containers must expose `setPendingWithCurrentStorage`/`setPending` before calling load/reload; the controller
57
+ * reads those flags to resolve fresh sources and avoids touching the DOM until data is ready.
58
+ * - Camera-dependent pipelines (DefaultRenderingPipeline, SSAO, IBL shadows, directional shadow generators) are rebuilt
59
+ * only after the active camera and environment textures are ready; render-loop restarts gate those transitions.
60
+ * - `show-model`/`show-scene` DOM attributes reflect container visibility; there are no direct `showModel()/hideModel()` APIs.
61
+ * - IBL shadows require `iblEnabled` plus `options.ibl.shadows` and a loaded HDR texture; otherwise fallback directional
62
+ * lights and environment-contributed lights supply classic shadow generators.
63
+ * - IBL lifecycle: when `options.ibl.cachedUrl` is present, a new `HDRCubeTexture` is created and cloned into `#hdrTexture`;
64
+ * then `options.ibl.consumeCachedUrl(true)` clears/revokes the temporary URL. Subsequent reloads reuse `#hdrTexture.clone()`
65
+ * while `options.ibl.valid` remains true.
66
+ * - Browser-only features guard `window`, localStorage, and XR APIs before use so the controller is safe to construct
67
+ * in SSR/Node contexts (though functionality activates only in browsers).
68
+ * - Pointer-pick lifecycle: hover/highlight raycasts are sampled on POINTERMOVE (time + distance thresholds), wheel
69
+ * input avoids picks entirely, and right-click POINTERUP performs an on-demand pick for context-menu targeting.
70
+ * - Runtime bookkeeping is split into mutable `#state` and tuning constants under `#config` to keep behavior changes
71
+ * explicit and reduce cross-field drift.
72
+ * - Resize lifecycle: canvas resize notifications are throttled (with trailing execution) before calling `engine.resize()`
73
+ * and any queued resize callback is canceled during teardown.
74
+ * - Teardown lifecycle: concurrent `disable()` calls are coalesced into a single in-flight promise to avoid races
75
+ * during XR exit and engine disposal.
61
76
  */
62
77
  export default class BabylonJSController {
63
-
64
78
  #RENDER_SETTINGS_STORAGE_KEY = "pref-viewer/render-settings";
65
79
 
66
80
  // Default render settings
@@ -72,7 +86,7 @@ export default class BabylonJSController {
72
86
  iblEnabled: true,
73
87
  shadowsEnabled: false,
74
88
  };
75
-
89
+
76
90
  // Canvas HTML element
77
91
  #canvas = null;
78
92
 
@@ -91,9 +105,11 @@ export default class BabylonJSController {
91
105
  #XRExperience = null;
92
106
  #canvasResizeObserver = null;
93
107
 
108
+ #hdrTexture = null; // reusable in-memory HDR source cloned into scene.environmentTexture across reloads
109
+
94
110
  #containers = {};
95
111
  #options = {};
96
-
112
+
97
113
  #gltfResolver = null; // GLTFResolver instance
98
114
  #babylonJSAnimationController = null; // AnimationController instance
99
115
 
@@ -104,13 +120,63 @@ export default class BabylonJSController {
104
120
  };
105
121
 
106
122
  #handlers = {
123
+ onAnimationGroupChanged: null,
107
124
  onKeyUp: null,
108
125
  onPointerObservable: null,
126
+ onResize: null,
109
127
  renderLoop: null,
110
128
  };
111
-
129
+
112
130
  #settings = { ...BabylonJSController.DEFAULT_RENDER_SETTINGS };
113
131
 
132
+ // Runtime mutable state (changes while the app is running).
133
+ #state = {
134
+ // Pointer-picking sampling state avoids expensive scene.pick on every move.
135
+ pointerPicking: {
136
+ lastMovePickAt: 0,
137
+ lastMovePickX: NaN,
138
+ lastMovePickY: NaN,
139
+ lastPickedMeshId: null,
140
+ },
141
+ // Render loop state balances performance with responsiveness.
142
+ render: {
143
+ isLoopRunning: false,
144
+ dirtyFrames: 0,
145
+ continuousUntil: 0,
146
+ lastRenderAt: 0,
147
+ },
148
+ // Resize state batches frequent ResizeObserver notifications.
149
+ resize: {
150
+ isScheduled: false,
151
+ timeoutId: null,
152
+ lastAppliedAt: 0,
153
+ },
154
+ };
155
+
156
+ // Runtime configuration constants (tuning knobs, not per-frame state).
157
+ #config = {
158
+ pointerPicking: {
159
+ movePickIntervalMs: 50, // cap expensive scene.pick calls to ~20 Hz while moving the pointer
160
+ movePickMinDistancePx: 2, // skip picks for sub-pixel jitter
161
+ },
162
+ render: {
163
+ burstFramesBase: 2,
164
+ burstFramesEnhanced: 32, // when AA/SSAO/IBL is enabled, more frames are needed to reach stable output
165
+ interactionMs: 250,
166
+ animationMs: 200,
167
+ idleThrottleMs: 1000 / 15,
168
+ },
169
+ resize: {
170
+ throttleMs: 50, // cap resize work to ~20 Hz while dragging/resizing containers
171
+ },
172
+ };
173
+
174
+ // Promises to track async disable() lifecycle when XR and general teardown may run concurrently; ensures idempotent disable calls are safe and callers can await full teardown completion.
175
+ #disablingPromises = {
176
+ xr: null,
177
+ general: null,
178
+ };
179
+
114
180
  /**
115
181
  * Constructs a new BabylonJSController instance.
116
182
  * Initializes the canvas, asset containers, and options for the Babylon.js scene.
@@ -143,8 +209,10 @@ export default class BabylonJSController {
143
209
  * @returns {void}
144
210
  */
145
211
  #bindHandlers() {
212
+ this.#handlers.onAnimationGroupChanged = this.#onAnimationGroupChanged.bind(this);
146
213
  this.#handlers.onKeyUp = this.#onKeyUp.bind(this);
147
214
  this.#handlers.onPointerObservable = this.#onPointerObservable.bind(this);
215
+ this.#handlers.onResize = this.#onResize.bind(this);
148
216
  this.#handlers.renderLoop = this.#renderLoop.bind(this);
149
217
  }
150
218
 
@@ -173,7 +241,7 @@ export default class BabylonJSController {
173
241
  * @param {object} [settings={}] - Partial map of render settings (AA, SSAO, IBL, shadows, etc.).
174
242
  * @returns {boolean} True when any setting changed and was saved.
175
243
  */
176
- #applyRenderSettings(settings = {}) {
244
+ #saveRenderSettings(settings = {}) {
177
245
  if (!settings) {
178
246
  return false;
179
247
  }
@@ -186,12 +254,8 @@ export default class BabylonJSController {
186
254
  }
187
255
  });
188
256
 
189
- if (changed && settings.iblEnabled === false && this.#scene) {
190
- this.#scene.environmentTexture = null;
191
- }
192
-
193
257
  if (changed) {
194
- this.#saveRenderSettings();
258
+ this.#storeRenderSettings();
195
259
  }
196
260
 
197
261
  return changed;
@@ -229,14 +293,14 @@ export default class BabylonJSController {
229
293
  * @private
230
294
  * @returns {void}
231
295
  */
232
- #saveRenderSettings() {
296
+ #storeRenderSettings() {
233
297
  if (typeof window === "undefined" || !window?.localStorage) {
234
298
  return;
235
299
  }
236
300
  try {
237
301
  window.localStorage.setItem(this.#RENDER_SETTINGS_STORAGE_KEY, JSON.stringify(this.#settings));
238
302
  } catch (error) {
239
- console.warn("PrefViewer: unable to save render settings", error);
303
+ console.warn("PrefViewer: unable to store render settings", error);
240
304
  }
241
305
  }
242
306
 
@@ -269,21 +333,191 @@ export default class BabylonJSController {
269
333
  if (this.#options?.materials) {
270
334
  Object.values(this.#options.materials).forEach((material) => material?.setPendingWithCurrent?.());
271
335
  }
272
- if (this.#options?.ibl?.setPending) {
273
- this.#options.ibl.setPending();
336
+ }
337
+
338
+ /**
339
+ * Starts Babylon's engine render loop if it is not already running.
340
+ * @private
341
+ * @returns {boolean} True when the loop was started, false when no engine is available or it was already running.
342
+ */
343
+ #startEngineRenderLoop() {
344
+ if (!this.#engine || this.#state.render.isLoopRunning) {
345
+ return false;
346
+ }
347
+ this.#engine.runRenderLoop(this.#handlers.renderLoop);
348
+ this.#state.render.isLoopRunning = true;
349
+ return true;
350
+ }
351
+
352
+ /**
353
+ * Stops Babylon's engine render loop when it is currently active.
354
+ * @private
355
+ * @returns {boolean} True when the loop was stopped, false when no engine is available or it was already stopped.
356
+ */
357
+ #stopEngineRenderLoop() {
358
+ if (!this.#engine || !this.#state.render.isLoopRunning) {
359
+ return false;
360
+ }
361
+ this.#engine.stopRenderLoop(this.#handlers.renderLoop);
362
+ this.#state.render.isLoopRunning = false;
363
+ return true;
364
+ }
365
+
366
+ /**
367
+ * Resets transient render-loop bookkeeping so the next render request starts from a clean baseline.
368
+ * Clears queued burst frames, ends any active continuous-render window, and drops the idle-throttle timestamp.
369
+ * @private
370
+ * @returns {void}
371
+ */
372
+ #resetRenderState() {
373
+ this.#state.render.dirtyFrames = 0;
374
+ this.#state.render.continuousUntil = 0;
375
+ this.#state.render.lastRenderAt = 0;
376
+ }
377
+
378
+ /**
379
+ * Marks the scene as dirty and optionally extends a short continuous-render window.
380
+ * Ensures the engine loop is running so the requested frames can be produced.
381
+ * @private
382
+ * @param {{frames?:number, continuousMs?:number}} [options={}] - Render request options.
383
+ * @param {number} [options.frames=1] - Minimum number of frames to render.
384
+ * @param {number} [options.continuousMs=0] - Milliseconds to keep continuous rendering active.
385
+ * @returns {boolean} True when the request was accepted, false when scene/engine are unavailable.
386
+ */
387
+ #requestRender({ frames = 1, continuousMs = 0 } = {}) {
388
+ if (!this.#scene || !this.#engine) {
389
+ return false;
390
+ }
391
+
392
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
393
+ this.#state.render.dirtyFrames = Math.max(this.#state.render.dirtyFrames, Math.max(1, frames));
394
+ if (continuousMs > 0) {
395
+ this.#state.render.continuousUntil = Math.max(this.#state.render.continuousUntil, now + continuousMs);
396
+ }
397
+ this.#startEngineRenderLoop();
398
+ return true;
399
+ }
400
+
401
+ /**
402
+ * Checks whether an ArcRotateCamera still has non-zero inertial movement.
403
+ * @private
404
+ * @param {ArcRotateCamera} camera - Camera to evaluate.
405
+ * @returns {boolean} True when any inertial offset is still active.
406
+ */
407
+ #isArcRotateCameraInMotion(camera) {
408
+ const EPSILON = 0.00001;
409
+ 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;
410
+ }
411
+
412
+ /**
413
+ * Checks whether a FreeCamera/UniversalCamera is currently moving or rotating.
414
+ * @private
415
+ * @param {FreeCamera|UniversalCamera} camera - Camera to evaluate.
416
+ * @returns {boolean} True when translation or rotation deltas are active.
417
+ */
418
+ #isUniversalOrFreeCameraInMotion(camera) {
419
+ const EPSILON = 0.00001;
420
+ const direction = camera?.cameraDirection;
421
+ const rotation = camera?.cameraRotation;
422
+ const directionMoving = !!direction && (Math.abs(direction.x) > EPSILON || Math.abs(direction.y) > EPSILON || Math.abs(direction.z) > EPSILON);
423
+ const rotationMoving = !!rotation && (Math.abs(rotation.x) > EPSILON || Math.abs(rotation.y) > EPSILON || Math.abs(rotation.z) > EPSILON);
424
+ return directionMoving || rotationMoving;
425
+ }
426
+
427
+ /**
428
+ * Detects motion for the current active camera based on its concrete camera type.
429
+ * @private
430
+ * @returns {boolean} True when the active camera is moving, otherwise false.
431
+ */
432
+ #isCameraInMotion() {
433
+ const camera = this.#scene?.activeCamera;
434
+ if (!camera) {
435
+ return false;
436
+ }
437
+ if (camera instanceof ArcRotateCamera) {
438
+ return this.#isArcRotateCameraInMotion(camera);
439
+ }
440
+ if (camera instanceof UniversalCamera || camera instanceof FreeCamera) {
441
+ return this.#isUniversalOrFreeCameraInMotion(camera);
442
+ }
443
+ return false;
444
+ }
445
+
446
+ /**
447
+ * Determines whether scene animations are currently running.
448
+ * @private
449
+ * @returns {boolean} True when at least one animation group is playing.
450
+ */
451
+ #isAnimationRunning() {
452
+ if (!this.#scene) {
453
+ return false;
454
+ }
455
+ const hasAnimatables = (this.#scene.animatables?.length || 0) > 0;
456
+ if (!hasAnimatables) {
457
+ return false;
458
+ }
459
+ return this.#scene.animationGroups?.some((group) => group?.isPlaying) || false;
460
+ }
461
+
462
+ /**
463
+ * Evaluates whether the renderer should stay in continuous mode.
464
+ * XR always forces continuous rendering; animation/camera motion also extends the
465
+ * continuous deadline window to avoid abrupt stop-start behavior.
466
+ * @private
467
+ * @param {number} now - Current high-resolution timestamp.
468
+ * @returns {boolean} True when continuous rendering should remain active.
469
+ */
470
+ #shouldRenderContinuously(now) {
471
+ const inXR = this.#XRExperience?.baseExperience?.state === WebXRState.IN_XR;
472
+ if (inXR) {
473
+ return true;
474
+ }
475
+
476
+ const animationRunning = this.#isAnimationRunning();
477
+ const cameraInMotion = this.#isCameraInMotion();
478
+
479
+ if (animationRunning) {
480
+ this.#state.render.continuousUntil = Math.max(this.#state.render.continuousUntil, now + this.#config.render.animationMs);
274
481
  }
482
+ if (cameraInMotion) {
483
+ this.#state.render.continuousUntil = Math.max(this.#state.render.continuousUntil, now + this.#config.render.interactionMs);
484
+ }
485
+
486
+ return animationRunning || cameraInMotion || this.#state.render.continuousUntil > now;
275
487
  }
276
488
 
277
489
  /**
278
490
  * Render loop callback for Babylon.js.
491
+ * Runs only while scene state is dirty, interactive motion is active, animations are running, or XR is active.
492
+ * It self-stops when the scene becomes idle.
279
493
  * @private
280
494
  * @returns {void}
281
- * @description
282
- * Continuously renders the current scene if it exists.
283
- * Used by the engine's runRenderLoop method to update the view.
284
495
  */
285
496
  #renderLoop() {
286
- this.#scene && this.#scene.render();
497
+ if (!this.#scene) {
498
+ this.#stopEngineRenderLoop();
499
+ return;
500
+ }
501
+
502
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
503
+ const continuous = this.#shouldRenderContinuously(now);
504
+ const needsRender = continuous || this.#state.render.dirtyFrames > 0;
505
+
506
+ if (!needsRender) {
507
+ this.#stopEngineRenderLoop();
508
+ return;
509
+ }
510
+
511
+ if (!continuous && this.#state.render.lastRenderAt > 0 && now - this.#state.render.lastRenderAt < this.#config.render.idleThrottleMs) {
512
+ return;
513
+ }
514
+
515
+ this.#scene.render();
516
+ this.#state.render.lastRenderAt = now;
517
+
518
+ if (this.#state.render.dirtyFrames > 0) {
519
+ this.#state.render.dirtyFrames -= 1;
520
+ }
287
521
  }
288
522
 
289
523
  /**
@@ -382,49 +616,75 @@ export default class BabylonJSController {
382
616
  * Adds a hemispheric ambient light, a directional light for shadows, a shadow generator, and a camera-attached point light.
383
617
  * Sets light intensities and shadow properties for realistic rendering.
384
618
  * @private
385
- * @returns {void}
619
+ * @returns {Promise<boolean>} Returns true if lights were changed, false otherwise.
620
+ * @description
621
+ * IBL path is considered available when either:
622
+ * - `options.ibl.cachedUrl` is present (new pending URL), or
623
+ * - `options.ibl.valid === true` (reusable in-memory `#hdrTexture` exists).
386
624
  */
387
- #createLights() {
388
- if (this.#settings.iblEnabled && this.#options.ibl && this.#options.ibl.url) {
389
- this.#hemiLight = this.#dirLight = this.#cameraLight = null;
390
- this.#initializeEnvironmentTexture();
391
- }
625
+ async #createLights() {
626
+ const hemiLightName = "PrefViewerHemiLight";
627
+ const cameraLightName = "PrefViewerCameraLight";
628
+ const dirLightName = "PrefViewerDirLight";
392
629
 
393
- if (this.#scene.environmentTexture) {
394
- return true;
395
- }
630
+ const hemiLight = this.#scene.getLightByName(hemiLightName);
631
+ const cameraLight = this.#scene.getLightByName(cameraLightName);
632
+ const dirLight = this.#scene.getLightByName(dirLightName);
396
633
 
397
- // 1) Stronger ambient fill
398
- this.#hemiLight = this.#scene.getLightByName("PrefViewerHemiLight");
399
- if (!this.#hemiLight) {
400
- this.#hemiLight = new HemisphericLight("PrefViewerHemiLight", new Vector3(-10, 10, -10), this.#scene);
401
- this.#hemiLight.intensity = 0.6;
402
- }
634
+ let lightsChanged = false;
403
635
 
404
- // 2) Directional light from the front-right, angled slightly down
405
- this.#dirLight = this.#scene.getLightByName("PrefViewerDirLight");
406
- if (!this.#dirLight) {
407
- this.#dirLight = new DirectionalLight("PrefViewerDirLight", new Vector3(-10, 10, -10), this.#scene);
408
- this.#dirLight.position = new Vector3(5, 4, 5); // light is IN FRONT + ABOVE + to the RIGHT
409
- this.#dirLight.intensity = 0.6;
410
- }
636
+ const iblEnabled = this.#settings.iblEnabled && (this.#options.ibl?.valid === true || !!this.#options.ibl?.cachedUrl);
411
637
 
412
- // 3) Camera‐attached headlight
413
- this.#cameraLight = this.#scene.getLightByName("PrefViewerCameraLight");
414
- if (!this.#cameraLight) {
415
- this.#cameraLight = new PointLight("PrefViewerCameraLight", this.#camera.position, this.#scene);
416
- this.#cameraLight.parent = this.#camera;
417
- this.#cameraLight.intensity = 0.3;
638
+ if (iblEnabled) {
639
+ if (hemiLight) {
640
+ hemiLight.dispose();
641
+ }
642
+ if (cameraLight) {
643
+ cameraLight.dispose();
644
+ }
645
+ if (dirLight) {
646
+ dirLight.dispose();
647
+ }
648
+ this.#hemiLight = this.#dirLight = this.#cameraLight = null;
649
+ lightsChanged = await this.#initializeEnvironmentTexture();
650
+ } else {
651
+ // If IBL is disabled but an environment texture exists, dispose it to save resources and ensure it doesn't affect the lighting
652
+ if (this.#scene.environmentTexture) {
653
+ this.#scene.environmentTexture.dispose();
654
+ this.#scene.environmentTexture = null;
655
+ lightsChanged = true;
656
+ }
657
+
658
+ // Add a hemispheric light for basic ambient illumination
659
+ if (!this.#hemiLight) {
660
+ this.#hemiLight = new HemisphericLight(hemiLightName, new Vector3(-10, 10, -10), this.#scene);
661
+ this.#hemiLight.intensity = 0.6;
662
+ }
663
+
664
+ // Add a directional light to cast shadows and provide stronger directional illumination
665
+ if (!this.#dirLight) {
666
+ this.#dirLight = new DirectionalLight(dirLightName, new Vector3(-10, 10, -10), this.#scene);
667
+ this.#dirLight.position = new Vector3(5, 4, 5); // light is IN FRONT + ABOVE + to the RIGHT
668
+ this.#dirLight.intensity = 0.6;
669
+ }
670
+
671
+ // Add a point light that follows the camera to ensure the model is always well-lit from the viewer's perspective
672
+ if (!this.#cameraLight) {
673
+ this.#cameraLight = new PointLight(cameraLightName, this.#camera.position, this.#scene);
674
+ this.#cameraLight.parent = this.#camera;
675
+ this.#cameraLight.intensity = 0.3;
676
+ }
418
677
  }
678
+ return lightsChanged;
419
679
  }
420
680
 
421
681
  /**
422
682
  * Detaches and disposes the SSAO render pipeline from the active camera when it exists.
423
683
  * Guards against missing scene resources or absent pipelines, returning false when no cleanup is needed.
424
684
  * @private
425
- * @returns {boolean} Returns true when the SSAO pipeline was disabled, false otherwise.
685
+ * @returns {Promise<boolean>} Returns true when the SSAO pipeline was disabled, false otherwise.
426
686
  */
427
- #disableAmbientOcclusion() {
687
+ async #disableAmbientOcclusion() {
428
688
  const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
429
689
 
430
690
  if (!this.#scene || !pipelineManager || this.#scene?.activeCamera === null) {
@@ -432,24 +692,24 @@ export default class BabylonJSController {
432
692
  }
433
693
 
434
694
  const supportedPipelines = pipelineManager.supportedPipelines;
435
-
695
+
436
696
  if (supportedPipelines === undefined) {
437
697
  return false;
438
698
  }
439
699
 
440
- if (!this.#renderPipelines.ssao) {
700
+ if (!this.#renderPipelines.ssao || !this.#renderPipelines.ssao?.name) {
441
701
  return false;
442
702
  }
443
703
 
444
704
  const pipelineName = this.#renderPipelines.ssao.name;
445
- const ssaoPipeline = supportedPipelines ? supportedPipelines.find((pipeline) => pipeline.name === pipelineName) : false;
705
+ let ssaoPipeline = supportedPipelines ? supportedPipelines.find((pipeline) => pipeline.name === pipelineName) : false;
446
706
 
447
707
  if (ssaoPipeline) {
448
708
  pipelineManager.detachCamerasFromRenderPipeline(pipelineName, [this.#scene.activeCamera]);
449
- ssaoPipeline.dispose();
450
709
  pipelineManager.removePipeline(pipelineName);
451
710
  pipelineManager.update();
452
- this.#renderPipelines.ssao = null;
711
+ ssaoPipeline.dispose();
712
+ this.#renderPipelines.ssao = ssaoPipeline = null;
453
713
  }
454
714
 
455
715
  return true;
@@ -460,9 +720,9 @@ export default class BabylonJSController {
460
720
  * Disposes previous SSAO pipelines, instantiates a tuned `SSAORenderingPipeline`, and attaches it to the
461
721
  * current camera so contact shadows enhance depth perception once assets reload or the camera changes.
462
722
  * @private
463
- * @returns {boolean} True if the SSAO pipeline is supported and enabled, otherwise false.
723
+ * @returns {Promise<boolean>} True if the SSAO pipeline is supported and enabled, otherwise false.
464
724
  */
465
- #initializeAmbientOcclussion() {
725
+ async #initializeAmbientOcclussion() {
466
726
  const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
467
727
  if (!this.#scene || !pipelineManager || this.#scene?.activeCamera === null) {
468
728
  return false;
@@ -472,46 +732,47 @@ export default class BabylonJSController {
472
732
  return false;
473
733
  }
474
734
  const supportedPipelines = pipelineManager.supportedPipelines;
475
-
735
+
476
736
  if (supportedPipelines === undefined) {
477
737
  return false;
478
738
  }
479
-
739
+
480
740
  const pipelineName = "PrefViewerSSAORenderingPipeline";
481
741
 
482
742
  const ssaoRatio = {
483
743
  ssaoRatio: 0.5,
484
- combineRatio: 1.0
744
+ combineRatio: 1.0,
485
745
  };
486
746
 
487
- this.#renderPipelines.ssao = new SSAORenderingPipeline(pipelineName, this.#scene, ssaoRatio, [this.#scene.activeCamera]);
747
+ let ssaoPipeline = new SSAORenderingPipeline(pipelineName, this.#scene, ssaoRatio, [this.#scene.activeCamera]);
488
748
 
489
- if (!this.#renderPipelines.ssao){
749
+ if (!ssaoPipeline) {
490
750
  return false;
491
- }
492
-
493
- if (this.#renderPipelines.ssao.isSupported) {
494
- this.#renderPipelines.ssao.fallOff = 0.000001;
495
- this.#renderPipelines.ssao.area = 1;
496
- this.#renderPipelines.ssao.radius = 0.0001;
497
- this.#renderPipelines.ssao.totalStrength = 1;
498
- this.#renderPipelines.ssao.base = 0.6;
499
-
751
+ }
752
+
753
+ if (ssaoPipeline.isSupported) {
754
+ ssaoPipeline.fallOff = 0.000001;
755
+ ssaoPipeline.area = 1;
756
+ ssaoPipeline.radius = 0.0001;
757
+ ssaoPipeline.totalStrength = 1;
758
+ ssaoPipeline.base = 0.6;
759
+
500
760
  // Configure SSAO to calculate only once instead of every frame for better performance
501
- if (this.#renderPipelines.ssao._ssaoPostProcess) {
502
- this.#renderPipelines.ssao._ssaoPostProcess.autoClear = false;
503
- this.#renderPipelines.ssao._ssaoPostProcess.samples = 1;
761
+ if (ssaoPipeline._ssaoPostProcess) {
762
+ ssaoPipeline._ssaoPostProcess.autoClear = false;
763
+ ssaoPipeline._ssaoPostProcess.samples = 1;
504
764
  }
505
- if (this.#renderPipelines.ssao._combinePostProcess) {
506
- this.#renderPipelines.ssao._combinePostProcess.autoClear = false;
507
- this.#renderPipelines.ssao._combinePostProcess.samples = 1;
765
+ if (ssaoPipeline._combinePostProcess) {
766
+ ssaoPipeline._combinePostProcess.autoClear = false;
767
+ ssaoPipeline._combinePostProcess.samples = 1;
508
768
  }
509
-
769
+
770
+ this.#renderPipelines.ssao = ssaoPipeline;
510
771
  pipelineManager.update();
511
772
  return true;
512
773
  } else {
513
- this.#renderPipelines.ssao.dispose();
514
- this.#renderPipelines.ssao = null;
774
+ ssaoPipeline.dispose();
775
+ this.#renderPipelines.ssao = ssaoPipeline = null;
515
776
  pipelineManager.update();
516
777
  return false;
517
778
  }
@@ -521,9 +782,9 @@ export default class BabylonJSController {
521
782
  * Tears down the default rendering pipeline (MSAA/FXAA/grain) for the active camera when present.
522
783
  * Ensures stale pipelines detach cleanly so a fresh one can be installed on the next load.
523
784
  * @private
524
- * @returns {boolean} Returns true when the pipeline was removed, false otherwise.
785
+ * @returns {Promise<boolean>} Returns true when the pipeline was removed, false otherwise.
525
786
  */
526
- #disableVisualImprovements() {
787
+ async #disableVisualImprovements() {
527
788
  const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
528
789
 
529
790
  if (!this.#scene || !pipelineManager || this.#scene?.activeCamera === null) {
@@ -531,24 +792,24 @@ export default class BabylonJSController {
531
792
  }
532
793
 
533
794
  const supportedPipelines = pipelineManager.supportedPipelines;
534
-
795
+
535
796
  if (supportedPipelines === undefined) {
536
797
  return false;
537
798
  }
538
799
 
539
- if (!this.#renderPipelines.default) {
800
+ if (!this.#renderPipelines.default || !this.#renderPipelines.default?.name) {
540
801
  return false;
541
802
  }
542
803
 
543
804
  const pipelineName = this.#renderPipelines.default.name;
544
- const defaultPipeline = supportedPipelines ? supportedPipelines.find((pipeline) => pipeline.name === pipelineName) : false;
805
+ let defaultPipeline = supportedPipelines ? supportedPipelines.find((pipeline) => pipeline.name === pipelineName) : false;
545
806
 
546
807
  if (defaultPipeline) {
547
808
  pipelineManager.detachCamerasFromRenderPipeline(pipelineName, [this.#scene.activeCamera]);
548
- defaultPipeline.dispose();
549
809
  pipelineManager.removePipeline(pipelineName);
550
810
  pipelineManager.update();
551
- this.#renderPipelines.default = null;
811
+ defaultPipeline.dispose();
812
+ this.#renderPipelines.default = defaultPipeline = null;
552
813
  }
553
814
 
554
815
  return true;
@@ -559,10 +820,10 @@ export default class BabylonJSController {
559
820
  * Disposes any previous pipeline instance to avoid duplicates, then attaches a fresh
560
821
  * `DefaultRenderingPipeline` with tuned settings for sharper anti-aliasing and subtle grain.
561
822
  * @private
562
- * @returns {boolean} True when the pipeline is supported and active, otherwise false.
823
+ * @returns {Promise<boolean>} True when the pipeline is supported and active, otherwise false.
563
824
  * @see {@link https://doc.babylonjs.com/features/featuresDeepDive/postProcesses/defaultRenderingPipeline|Using the Default Rendering Pipeline | Babylon.js Documentation}
564
825
  */
565
- #initializeVisualImprovements() {
826
+ async #initializeVisualImprovements() {
566
827
  const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
567
828
  if (!this.#scene || !pipelineManager || this.#scene?.activeCamera === null) {
568
829
  return false;
@@ -572,56 +833,58 @@ export default class BabylonJSController {
572
833
  return false;
573
834
  }
574
835
  const supportedPipelines = pipelineManager.supportedPipelines;
575
-
836
+
576
837
  if (supportedPipelines === undefined) {
577
838
  return false;
578
839
  }
579
-
840
+
580
841
  const pipelineName = "PrefViewerDefaultRenderingPipeline";
581
842
 
582
- this.#renderPipelines.default = new DefaultRenderingPipeline(pipelineName, true, this.#scene, [this.#scene.activeCamera], true);
843
+ let defaultPipeline = new DefaultRenderingPipeline(pipelineName, true, this.#scene, [this.#scene.activeCamera], true);
583
844
 
584
- if (!this.#renderPipelines.default){
845
+ if (!defaultPipeline) {
585
846
  return false;
586
- }
847
+ }
587
848
 
588
- if (this.#renderPipelines.default.isSupported) {
849
+ if (defaultPipeline.isSupported) {
589
850
  // MSAA - Multisample Anti-Aliasing
590
851
  const caps = this.#scene.getEngine()?.getCaps?.() || {};
591
852
  const maxSamples = typeof caps.maxMSAASamples === "number" ? caps.maxMSAASamples : 4;
592
- this.#renderPipelines.default.samples = Math.max(1, Math.min(8, maxSamples));
853
+ defaultPipeline.samples = Math.max(1, Math.min(8, maxSamples));
593
854
  // FXAA - Fast Approximate Anti-Aliasing
594
- this.#renderPipelines.default.fxaaEnabled = true;
595
- this.#renderPipelines.default.fxaa.samples = 8;
596
- this.#renderPipelines.default.fxaa.adaptScaleToCurrentViewport = true;
597
- if (this.#renderPipelines.default.fxaa.edgeThreshold !== undefined) {
598
- this.#renderPipelines.default.fxaa.edgeThreshold = 0.125;
855
+ defaultPipeline.fxaaEnabled = true;
856
+ defaultPipeline.fxaa.samples = 8;
857
+ defaultPipeline.fxaa.adaptScaleToCurrentViewport = true;
858
+ if (defaultPipeline.fxaa.edgeThreshold !== undefined) {
859
+ defaultPipeline.fxaa.edgeThreshold = 0.125;
599
860
  }
600
- if (this.#renderPipelines.default.fxaa.edgeThresholdMin !== undefined) {
601
- this.#renderPipelines.default.fxaa.edgeThresholdMin = 0.0625;
861
+ if (defaultPipeline.fxaa.edgeThresholdMin !== undefined) {
862
+ defaultPipeline.fxaa.edgeThresholdMin = 0.0625;
602
863
  }
603
- if (this.#renderPipelines.default.fxaa.subPixelQuality !== undefined) {
604
- this.#renderPipelines.default.fxaa.subPixelQuality = 0.75;
864
+ if (defaultPipeline.fxaa.subPixelQuality !== undefined) {
865
+ defaultPipeline.fxaa.subPixelQuality = 0.75;
605
866
  }
606
867
 
607
868
  // Grain
608
- this.#renderPipelines.default.grainEnabled = true;
609
- this.#renderPipelines.default.grain.adaptScaleToCurrentViewport = true;
610
- this.#renderPipelines.default.grain.animated = false;
611
- this.#renderPipelines.default.grain.intensity = 3;
869
+ defaultPipeline.grainEnabled = true;
870
+ defaultPipeline.grain.adaptScaleToCurrentViewport = true;
871
+ defaultPipeline.grain.animated = false;
872
+ defaultPipeline.grain.intensity = 3;
612
873
 
613
874
  // Configure post-processes to calculate only once instead of every frame for better performance
614
- if (this.#renderPipelines.default.fxaa?._postProcess) {
615
- this.#renderPipelines.default.fxaa._postProcess.autoClear = false;
875
+ if (defaultPipeline.fxaa?._postProcess) {
876
+ defaultPipeline.fxaa._postProcess.autoClear = false;
616
877
  }
617
- if (this.#renderPipelines.default.grain?._postProcess) {
618
- this.#renderPipelines.default.grain._postProcess.autoClear = false;
878
+ if (defaultPipeline.grain?._postProcess) {
879
+ defaultPipeline.grain._postProcess.autoClear = false;
619
880
  }
620
881
 
882
+ this.#renderPipelines.default = defaultPipeline;
621
883
  pipelineManager.update();
622
884
  return true;
623
885
  } else {
624
- this.#renderPipelines.default.dispose();
886
+ defaultPipeline.dispose();
887
+ this.#renderPipelines.default = defaultPipeline = null;
625
888
  pipelineManager.update();
626
889
  return false;
627
890
  }
@@ -629,16 +892,46 @@ export default class BabylonJSController {
629
892
 
630
893
  /**
631
894
  * Initializes the environment texture for the Babylon.js scene.
632
- * Loads an HDR texture from a predefined URI and assigns it to the scene's environmentTexture property.
633
- * Configures gamma space, mipmaps, and intensity level for realistic lighting.
895
+ * Resolves the active HDR environment texture using either a fresh `cachedUrl`
896
+ * or the reusable in-memory clone (`#hdrTexture`), then assigns it to `scene.environmentTexture`.
634
897
  * @private
635
- * @returns {boolean}
898
+ * @returns {Promise<boolean>} Returns true if the environment texture was changed, false if it was already up to date or failed to load.
899
+ * @description
900
+ * Lifecycle implemented here:
901
+ * 1. If `options.ibl.cachedUrl` exists, create `HDRCubeTexture` from it.
902
+ * 2. Wait for readiness, clone it into `#hdrTexture` for reuse.
903
+ * 3. Call `options.ibl.consumeCachedUrl(true)` to revoke temporary object URLs and clear `cachedUrl`.
904
+ * 4. On following reloads, if `options.ibl.valid === true` and no `cachedUrl` is present, use `#hdrTexture.clone()`.
636
905
  */
637
- #initializeEnvironmentTexture() {
638
- const hdrTextureURI = this.#options.ibl.url;
639
- const hdrTexture = new HDRCubeTexture(hdrTextureURI, this.#scene, 128, false, false, false, true);
906
+ async #initializeEnvironmentTexture() {
907
+ if (this.#scene.environmentTexture) {
908
+ this.#scene.environmentTexture.dispose();
909
+ this.#scene.environmentTexture = null;
910
+ }
911
+
912
+ let hdrTexture = null;
913
+ if (this.#options.ibl?.cachedUrl) {
914
+ const hdrTextureURI = this.#options.ibl.cachedUrl;
915
+ hdrTexture = new HDRCubeTexture(hdrTextureURI, this.#scene, 1024, false, false, false, true, undefined, undefined, false, true, true);
916
+ } else if (this.#hdrTexture && this.#options.ibl?.valid === true) {
917
+ hdrTexture = this.#hdrTexture.clone();
918
+ } else {
919
+ return false;
920
+ }
921
+
922
+ await WhenTextureReadyAsync(hdrTexture);
923
+
924
+ if (this.#options.ibl?.cachedUrl) {
925
+ if (this.#hdrTexture) {
926
+ this.#hdrTexture.dispose();
927
+ }
928
+ this.#hdrTexture = hdrTexture.clone();
929
+ this.#options.ibl?.consumeCachedUrl?.(true);
930
+ }
931
+
640
932
  hdrTexture.level = this.#options.ibl.intensity;
641
933
  this.#scene.environmentTexture = hdrTexture;
934
+ this.#scene.markAllMaterialsAsDirty(Material.TextureDirtyFlag);
642
935
  return true;
643
936
  }
644
937
 
@@ -646,9 +939,9 @@ export default class BabylonJSController {
646
939
  * Removes the IBL shadow render pipeline from the active camera when present.
647
940
  * Ensures voxelized shadow data is disposed so reloading environments installs a clean pipeline.
648
941
  * @private
649
- * @returns {boolean} Returns true when the pipeline was removed, false otherwise.
942
+ * @returns {Promise<boolean>} Returns true when the pipeline was removed, false otherwise.
650
943
  */
651
- #disableIBLShadows() {
944
+ async #disableIBLShadows() {
652
945
  const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
653
946
 
654
947
  if (!this.#scene || !pipelineManager || this.#scene?.activeCamera === null) {
@@ -656,24 +949,28 @@ export default class BabylonJSController {
656
949
  }
657
950
 
658
951
  const supportedPipelines = pipelineManager.supportedPipelines;
659
-
952
+
660
953
  if (supportedPipelines === undefined) {
661
954
  return false;
662
955
  }
663
956
 
664
- if (!this.#renderPipelines.iblShadows) {
957
+ if (!this.#renderPipelines.iblShadows || !this.#renderPipelines.iblShadows?.name) {
665
958
  return false;
666
959
  }
667
960
 
668
961
  const pipelineName = this.#renderPipelines.iblShadows.name;
669
- const defaultPipeline = supportedPipelines ? supportedPipelines.find((pipeline) => pipeline.name === pipelineName) : false;
962
+ let iblShadowsPipeline = supportedPipelines ? supportedPipelines.find((pipeline) => pipeline.name === pipelineName) : false;
670
963
 
671
- if (defaultPipeline) {
964
+ if (iblShadowsPipeline) {
965
+ iblShadowsPipeline.toggleShadow(false);
966
+ iblShadowsPipeline.clearShadowCastingMeshes();
967
+ iblShadowsPipeline.clearShadowReceivingMaterials();
968
+ iblShadowsPipeline.resetAccumulation();
672
969
  pipelineManager.detachCamerasFromRenderPipeline(pipelineName, [this.#scene.activeCamera]);
673
- defaultPipeline.dispose();
674
970
  pipelineManager.removePipeline(pipelineName);
675
971
  pipelineManager.update();
676
- this.#renderPipelines.iblShadows = null;
972
+ iblShadowsPipeline.dispose();
973
+ this.#renderPipelines.iblShadows = iblShadowsPipeline = null;
677
974
  }
678
975
 
679
976
  return true;
@@ -685,24 +982,57 @@ export default class BabylonJSController {
685
982
  * Configures pipeline options for resolution, sampling, opacity, and debugging.
686
983
  * Only applies if the scene has an environment texture set.
687
984
  * @private
688
- * @returns {void|false} Returns false if no environment texture is set; otherwise void.
985
+ * @returns {Promise<void|boolean>} Returns false if no environment texture is set; otherwise void.
689
986
  */
690
- #initializeIBLShadows() {
987
+ async #initializeIBLShadows() {
691
988
  const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
692
989
 
693
- if (!this.#scene || !pipelineManager || this.#scene?.activeCamera === null) {
990
+ if (!this.#scene || !this.#scene?.activeCamera || !this.#scene?.environmentTexture || !pipelineManager) {
991
+ return false;
992
+ }
993
+
994
+ if (!this.#scene.environmentTexture.isReady()) {
995
+ const self = this;
996
+ this.#scene.environmentTexture.onLoadObservable.addOnce(() => {
997
+ self.#initializeIBLShadows();
998
+ });
694
999
  return false;
695
1000
  }
696
1001
 
697
- if (!this.#scene.environmentTexture) {
1002
+ const meshesForCastingShadows = this.#scene.meshes.filter((mesh) => {
1003
+ const isRootMesh = mesh.id.startsWith("__root__");
1004
+ if (isRootMesh) {
1005
+ return false;
1006
+ }
1007
+
1008
+ const isHDRIMesh = mesh.name?.toLowerCase() === "hdri";
1009
+ const extrasCastShadows = mesh.metadata?.gltf?.extras?.castShadows;
1010
+ const meshGenerateShadows = typeof extrasCastShadows === "boolean" ? extrasCastShadows : isHDRIMesh ? false : true;
1011
+
1012
+ if (meshGenerateShadows) {
1013
+ return true;
1014
+ }
1015
+ return false;
1016
+ });
1017
+ const materialsForReceivingShadows = this.#scene.materials.filter((material) => {
1018
+ if (material instanceof PBRMaterial) {
1019
+ material.enableSpecularAntiAliasing = false;
1020
+ }
1021
+ return true;
1022
+ });
1023
+
1024
+ if (meshesForCastingShadows.length === 0 || materialsForReceivingShadows.length === 0) {
698
1025
  return false;
699
1026
  }
1027
+
700
1028
  const supportedPipelines = pipelineManager.supportedPipelines;
701
-
1029
+
702
1030
  if (!supportedPipelines) {
703
1031
  return false;
704
1032
  }
705
-
1033
+
1034
+ await this.#scene.whenReadyAsync();
1035
+
706
1036
  const pipelineName = "PrefViewerIblShadowsRenderPipeline";
707
1037
 
708
1038
  const pipelineOptions = {
@@ -714,13 +1044,13 @@ export default class BabylonJSController {
714
1044
  shadowOpacity: 0.85,
715
1045
  };
716
1046
 
717
- this.#renderPipelines.iblShadows = new IblShadowsRenderPipeline(pipelineName, this.#scene, pipelineOptions, [this.#scene.activeCamera]);
1047
+ let iblShadowsPipeline = new IblShadowsRenderPipeline(pipelineName, this.#scene, pipelineOptions, [this.#scene.activeCamera]);
718
1048
 
719
- if (!this.#renderPipelines.iblShadows) {
1049
+ if (!iblShadowsPipeline) {
720
1050
  return false;
721
1051
  }
722
-
723
- if (this.#renderPipelines.iblShadows.isSupported) {
1052
+
1053
+ if (iblShadowsPipeline.isSupported) {
724
1054
  // Disable all debug passes for performance
725
1055
  const pipelineProps = {
726
1056
  allowDebugPasses: false,
@@ -734,41 +1064,20 @@ export default class BabylonJSController {
734
1064
  accumulationPassDebugEnabled: false,
735
1065
  };
736
1066
 
737
- Object.assign(this.#renderPipelines.iblShadows, pipelineProps);
738
-
739
- if (this.#renderPipelines.iblShadows._ssaoPostProcess) {
740
- this.#renderPipelines.iblShadows._ssaoPostProcess.autoClear = false;
741
- this.#renderPipelines.iblShadows._ssaoPostProcess.samples = 1;
742
- }
743
-
744
- this.#scene.meshes.forEach((mesh) => {
745
- const isRootMesh = mesh.id.startsWith("__root__");
746
- if (isRootMesh) {
747
- return false;
748
- }
749
-
750
- const isHDRIMesh = mesh.name?.toLowerCase() === "hdri";
751
- const extrasCastShadows = mesh.metadata?.gltf?.extras?.castShadows;
752
- const meshGenerateShadows = typeof extrasCastShadows === "boolean" ? extrasCastShadows : isHDRIMesh ? false : true;
753
-
754
- if (meshGenerateShadows) {
755
- this.#renderPipelines.iblShadows.addShadowCastingMesh(mesh);
756
- this.#renderPipelines.iblShadows.updateSceneBounds();
757
- }
758
- });
1067
+ Object.assign(iblShadowsPipeline, pipelineProps);
759
1068
 
760
- this.#scene.materials.forEach((material) => {
761
- if (material instanceof PBRMaterial) {
762
- material.enableSpecularAntiAliasing = false;
763
- }
764
- this.#renderPipelines.iblShadows.addShadowReceivingMaterial(material);
765
- });
1069
+ meshesForCastingShadows.forEach((mesh) => iblShadowsPipeline.addShadowCastingMesh(mesh));
1070
+ materialsForReceivingShadows.forEach((material) => iblShadowsPipeline.addShadowReceivingMaterial(material));
766
1071
 
767
- this.#renderPipelines.iblShadows.updateVoxelization();
1072
+ iblShadowsPipeline.updateSceneBounds();
1073
+ iblShadowsPipeline.toggleShadow(true);
1074
+ iblShadowsPipeline.updateVoxelization();
1075
+ this.#renderPipelines.iblShadows = iblShadowsPipeline;
768
1076
  pipelineManager.update();
769
1077
  return true;
770
1078
  } else {
771
- this.#renderPipelines.iblShadows.dispose();
1079
+ iblShadowsPipeline.dispose();
1080
+ this.#renderPipelines.iblShadows = iblShadowsPipeline = null;
772
1081
  pipelineManager.update();
773
1082
  return false;
774
1083
  }
@@ -802,7 +1111,7 @@ export default class BabylonJSController {
802
1111
  * @private
803
1112
  * @returns {void}
804
1113
  */
805
- #initializeDefaultLightShadows() {
1114
+ async #initializeDefaultLightShadows() {
806
1115
  if (!this.#dirLight) {
807
1116
  return;
808
1117
  }
@@ -825,7 +1134,7 @@ export default class BabylonJSController {
825
1134
  * @private
826
1135
  * @returns {void}
827
1136
  */
828
- #initializeEnvironmentShadows() {
1137
+ async #initializeEnvironmentShadows() {
829
1138
  this.#shadowGen = this.#shadowGen.filter((generator) => {
830
1139
  if (!generator || typeof generator.getLight !== "function") {
831
1140
  return false;
@@ -874,12 +1183,12 @@ export default class BabylonJSController {
874
1183
  * @private
875
1184
  * @returns {void}
876
1185
  */
877
- #disableShadows() {
1186
+ async #disableShadows() {
878
1187
  this.#shadowGen.forEach((shadowGenerator) => {
879
1188
  shadowGenerator.dispose();
880
1189
  });
881
1190
  this.#shadowGen = [];
882
- this.#disableIBLShadows();
1191
+ await this.#disableIBLShadows();
883
1192
  }
884
1193
 
885
1194
  /**
@@ -890,28 +1199,22 @@ export default class BabylonJSController {
890
1199
  * If no environment texture is set, initializes IBL shadows.
891
1200
  * Otherwise, sets up shadow casting and receiving for all relevant meshes using the shadow generator.
892
1201
  */
893
- #initializeShadows() {
1202
+ async #initializeShadows() {
894
1203
  if (!this.#settings.shadowsEnabled) {
895
1204
  return false;
896
1205
  }
897
1206
 
898
1207
  this.#ensureMeshesReceiveShadows();
899
1208
 
900
- if (this.#scene.environmentTexture) {
901
- if (this.#options.ibl.shadows) {
902
- if (this.#scene.environmentTexture.isReady()) {
903
- this.#initializeIBLShadows();
904
- } else {
905
- const self = this;
906
- this.#scene.environmentTexture.onLoadObservable.addOnce(() => {
907
- self.#initializeIBLShadows();
908
- });
909
- }
910
- }
1209
+ const iblEnabled = this.#settings.iblEnabled && (this.#options.ibl?.valid === true || !!this.#options.ibl?.cachedUrl);
1210
+ const iblShadowsEnabled = iblEnabled && this.#options.ibl.shadows;
1211
+
1212
+ if (iblShadowsEnabled) {
1213
+ await this.#initializeIBLShadows();
911
1214
  } else {
912
- this.#initializeDefaultLightShadows();
1215
+ await this.#initializeDefaultLightShadows();
913
1216
  }
914
- this.#initializeEnvironmentShadows();
1217
+ await this.#initializeEnvironmentShadows();
915
1218
  }
916
1219
 
917
1220
  /**
@@ -933,20 +1236,52 @@ export default class BabylonJSController {
933
1236
  }
934
1237
 
935
1238
  /**
936
- * Handles pointer events observed on the Babylon.js scene.
1239
+ * Resets pointer-picking sampling state.
937
1240
  * @private
938
- * @param {PointerInfo} info - The pointer event information from Babylon.js.
939
1241
  * @returns {void}
940
1242
  */
941
- #onPointerObservable(info) {
942
- const pickInfo = this.#scene.pick(this.#scene.pointerX, this.#scene.pointerY);
943
- if (info.type === PointerEventTypes.POINTERUP) {
944
- this.#onPointerUp(info.event, pickInfo);
945
- } else if (info.type === PointerEventTypes.POINTERMOVE) {
946
- this.#onPointerMove(info.event, pickInfo);
947
- } else if (info.type === PointerEventTypes.POINTERWHEEL) {
948
- this.#onMouseWheel(info.event, pickInfo);
1243
+ #resetPointerPickingState() {
1244
+ this.#state.pointerPicking.lastMovePickAt = 0;
1245
+ this.#state.pointerPicking.lastMovePickX = NaN;
1246
+ this.#state.pointerPicking.lastMovePickY = NaN;
1247
+ this.#state.pointerPicking.lastPickedMeshId = null;
1248
+ }
1249
+
1250
+ /**
1251
+ * Decides whether a POINTERMOVE event should trigger a scene raycast.
1252
+ * Uses time + distance sampling to avoid expensive pick calls on every mouse move.
1253
+ * @private
1254
+ * @returns {boolean}
1255
+ */
1256
+ #shouldPickOnPointerMove() {
1257
+ if (!this.#scene || !this.#babylonJSAnimationController) {
1258
+ return false;
1259
+ }
1260
+
1261
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1262
+ const x = this.#scene.pointerX;
1263
+ const y = this.#scene.pointerY;
1264
+ const state = this.#state.pointerPicking;
1265
+ const config = this.#config.pointerPicking;
1266
+
1267
+ if (!Number.isFinite(state.lastMovePickX) || !Number.isFinite(state.lastMovePickY)) {
1268
+ state.lastMovePickX = x;
1269
+ state.lastMovePickY = y;
1270
+ state.lastMovePickAt = now;
1271
+ return true;
1272
+ }
1273
+
1274
+ const elapsed = now - state.lastMovePickAt >= config.movePickIntervalMs;
1275
+ const moved = Math.abs(x - state.lastMovePickX) >= config.movePickMinDistancePx || Math.abs(y - state.lastMovePickY) >= config.movePickMinDistancePx;
1276
+
1277
+ if (!elapsed || !moved) {
1278
+ return false;
949
1279
  }
1280
+
1281
+ state.lastMovePickX = x;
1282
+ state.lastMovePickY = y;
1283
+ state.lastMovePickAt = now;
1284
+ return true;
950
1285
  }
951
1286
 
952
1287
  /**
@@ -961,6 +1296,10 @@ export default class BabylonJSController {
961
1296
  if (this.#scene) {
962
1297
  this.#scene.onPointerObservable.add(this.#handlers.onPointerObservable);
963
1298
  }
1299
+ if (this.#engine) {
1300
+ this.#canvasResizeObserver = new ResizeObserver(this.#handlers.onResize);
1301
+ this.#canvasResizeObserver.observe(this.#canvas);
1302
+ }
964
1303
  }
965
1304
 
966
1305
  /**
@@ -975,6 +1314,11 @@ export default class BabylonJSController {
975
1314
  if (this.#scene !== null) {
976
1315
  this.#scene.onPointerObservable.removeCallback(this.#handlers.onPointerObservable);
977
1316
  }
1317
+ this.#cancelScheduledResize();
1318
+ this.#canvasResizeObserver?.disconnect();
1319
+ this.#canvasResizeObserver = null;
1320
+ this.#detachAnimationChangedListener();
1321
+ this.#resetPointerPickingState();
978
1322
  }
979
1323
 
980
1324
  /**
@@ -990,31 +1334,56 @@ export default class BabylonJSController {
990
1334
  }
991
1335
 
992
1336
  /**
993
- * Disposes the Babylon.js WebXR experience if it exists.
1337
+ * Disposes the shared GLTFResolver instance and closes its underlying storage handle.
994
1338
  * @private
995
1339
  * @returns {void}
996
1340
  */
997
- #disposeXRExperience() {
998
- if (!this.#XRExperience) {
1341
+ #disposeGLTFResolver() {
1342
+ if (this.#gltfResolver) {
1343
+ this.#gltfResolver.dispose();
1344
+ this.#gltfResolver = null;
1345
+ }
1346
+ }
1347
+
1348
+ /**
1349
+ * Disposes the Babylon.js WebXR experience if it exists.
1350
+ * If XR is currently active, waits for `exitXRAsync()` before disposing to avoid
1351
+ * tearing down the engine while the XR session is still shutting down.
1352
+ * Concurrent calls share the same in-flight promise so disposal runs only once.
1353
+ * @private
1354
+ * @returns {Promise<void>}
1355
+ */
1356
+ async #disposeXRExperience() {
1357
+ if (this.#disablingPromises.xr) {
1358
+ return await this.#disablingPromises.xr;
1359
+ }
1360
+
1361
+ const xrExperience = this.#XRExperience;
1362
+ if (!xrExperience) {
999
1363
  return;
1000
1364
  }
1001
1365
 
1002
- if (this.#XRExperience.baseExperience.state === WebXRState.IN_XR) {
1003
- this.#XRExperience.baseExperience
1004
- .exitXRAsync()
1005
- .then(() => {
1006
- this.#XRExperience.dispose();
1007
- this.#XRExperience = null;
1008
- })
1009
- .catch((error) => {
1010
- console.warn("Error exiting XR experience:", error);
1011
- this.#XRExperience.dispose();
1366
+ this.#disablingPromises.xr = (async () => {
1367
+ try {
1368
+ if (xrExperience.baseExperience?.state === WebXRState.IN_XR) {
1369
+ await xrExperience.baseExperience.exitXRAsync();
1370
+ }
1371
+ } catch (error) {
1372
+ console.warn("PrefViewer: Error exiting XR experience:", error);
1373
+ } finally {
1374
+ try {
1375
+ xrExperience.dispose();
1376
+ } catch (error) {
1377
+ console.warn("PrefViewer: Error disposing XR experience:", error);
1378
+ }
1379
+ if (this.#XRExperience === xrExperience) {
1012
1380
  this.#XRExperience = null;
1013
- });
1014
- } else {
1015
- this.#XRExperience.dispose();
1016
- this.#XRExperience = null;
1017
- }
1381
+ }
1382
+ this.#disablingPromises.xr = null;
1383
+ }
1384
+ })();
1385
+
1386
+ await this.#disablingPromises.xr;
1018
1387
  }
1019
1388
 
1020
1389
  /**
@@ -1026,11 +1395,88 @@ export default class BabylonJSController {
1026
1395
  if (!this.#engine) {
1027
1396
  return;
1028
1397
  }
1398
+ if (this.#hdrTexture) {
1399
+ this.#hdrTexture.dispose();
1400
+ this.#hdrTexture = null;
1401
+ }
1029
1402
  this.#engine.dispose();
1030
1403
  this.#engine = this.#scene = this.#camera = null;
1031
1404
  this.#hemiLight = this.#dirLight = this.#cameraLight = null;
1032
1405
  }
1033
1406
 
1407
+ /**
1408
+ * Handles animation state events emitted by `OpeningAnimation` instances.
1409
+ * Routes opening/closing states to continuous rendering and all other states
1410
+ * (paused/opened/closed) to a short final render burst.
1411
+ * @private
1412
+ * @param {CustomEvent} event - Event carrying animation state in `event.detail.state`.
1413
+ * @returns {void}
1414
+ */
1415
+ #onAnimationGroupChanged(event) {
1416
+ const state = event?.detail?.state;
1417
+ if (state === undefined) {
1418
+ return;
1419
+ }
1420
+
1421
+ if (state === OpeningAnimation.states.opening || state === OpeningAnimation.states.closing) {
1422
+ this.#onAnimationGroupPlay();
1423
+ } else {
1424
+ this.#onAnimationGroupStop();
1425
+ }
1426
+ }
1427
+
1428
+ /**
1429
+ * Marks animation playback as active and requests short continuous rendering so animated transforms remain smooth while state is changing.
1430
+ * @private
1431
+ * @returns {void}
1432
+ */
1433
+ #onAnimationGroupPlay() {
1434
+ this.#requestRender({ frames: 1, continuousMs: this.#config.render.animationMs });
1435
+ }
1436
+
1437
+ /**
1438
+ * Handles animation stop/pause/end transitions by requesting a final render burst.
1439
+ * @private
1440
+ * @returns {void}
1441
+ */
1442
+ #onAnimationGroupStop() {
1443
+ if (this.#settings.iblEnabled && this.#renderPipelines.iblShadows) {
1444
+ this.#renderPipelines.iblShadows.updateVoxelization();
1445
+ this.#scene?.postProcessRenderPipelineManager?.update();
1446
+ }
1447
+ const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#config.render.burstFramesEnhanced : this.#config.render.burstFramesBase;
1448
+ this.#requestRender({ frames: frames });
1449
+ }
1450
+
1451
+ /**
1452
+ * Attaches the `prefviewer-animation-changed` listener to the nearest `pref-viewer-3d` host.
1453
+ * Removes any previous registration first to avoid duplicate callbacks across reload cycles.
1454
+ * @private
1455
+ * @returns {boolean} True when the listener is attached, false when no host is available.
1456
+ */
1457
+ #attachAnimationChangedListener() {
1458
+ this.#getPrefViewer3DComponent();
1459
+ if (!this.#prefViewer3D) {
1460
+ return false;
1461
+ }
1462
+ this.#detachAnimationChangedListener();
1463
+ this.#prefViewer3D.addEventListener("prefviewer-animation-changed", this.#handlers.onAnimationGroupChanged);
1464
+ return true;
1465
+ }
1466
+
1467
+ /**
1468
+ * Detaches the `prefviewer-animation-changed` listener from the cached `pref-viewer-3d` host.
1469
+ * @private
1470
+ * @returns {boolean} True when a host exists and the listener removal was attempted, false otherwise.
1471
+ */
1472
+ #detachAnimationChangedListener() {
1473
+ if (!this.#prefViewer3D) {
1474
+ return false;
1475
+ }
1476
+ this.#prefViewer3D.removeEventListener("prefviewer-animation-changed", this.#handlers.onAnimationGroupChanged);
1477
+ return true;
1478
+ }
1479
+
1034
1480
  /**
1035
1481
  * Handles keyup events on the Babylon.js canvas for triggering model and scene downloads.
1036
1482
  * @private
@@ -1038,10 +1484,10 @@ export default class BabylonJSController {
1038
1484
  * @returns {void}
1039
1485
  */
1040
1486
  #onKeyUp(event) {
1041
- // CTRL + ALT + letter
1042
- if (event.ctrlKey && event.altKey && event.key !== undefined) {
1043
- switch (event.key.toLowerCase()) {
1044
- case "d":
1487
+ // CTRL + ALT + letter (uses event.code for physical key, layout-independent — fixes Mac Option+D producing "∂" instead of "d")
1488
+ if (event.ctrlKey && event.altKey && event.code !== undefined) {
1489
+ switch (event.code) {
1490
+ case "KeyD":
1045
1491
  this.#openDownloadDialog();
1046
1492
  break;
1047
1493
  default:
@@ -1058,11 +1504,12 @@ export default class BabylonJSController {
1058
1504
  * @returns {void|false} Returns false if there is no active camera; otherwise, void.
1059
1505
  */
1060
1506
  #onMouseWheel(event, pickInfo) {
1507
+ event.preventDefault();
1061
1508
  const camera = this.#scene?.activeCamera;
1062
1509
  if (!camera) {
1063
1510
  return false;
1064
1511
  }
1065
- if (!camera.metadata?.locked) {
1512
+ if (!camera.metadata?.locked) {
1066
1513
  if (camera instanceof ArcRotateCamera) {
1067
1514
  camera.wheelPrecision = camera.wheelPrecision || 3.0;
1068
1515
  camera.inertialRadiusOffset -= event.deltaY * camera.wheelPrecision * 0.001;
@@ -1075,8 +1522,8 @@ export default class BabylonJSController {
1075
1522
  const movementVector = direction.scale(zoomSpeed);
1076
1523
  camera.position = camera.position.add(movementVector);
1077
1524
  }
1525
+ this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
1078
1526
  }
1079
- event.preventDefault();
1080
1527
  }
1081
1528
 
1082
1529
  /**
@@ -1088,7 +1535,9 @@ export default class BabylonJSController {
1088
1535
  */
1089
1536
  #onPointerUp(event, pickInfo) {
1090
1537
  if (this.#babylonJSAnimationController) {
1091
- this.#babylonJSAnimationController.hideMenu();
1538
+ if (event.button !== 2 || !pickInfo || !pickInfo.pickedMesh) {
1539
+ this.#babylonJSAnimationController.hideMenu();
1540
+ }
1092
1541
  // Right click for showing animation menu
1093
1542
  if (event.button === 2) {
1094
1543
  this.#babylonJSAnimationController.showMenu(pickInfo);
@@ -1100,13 +1549,105 @@ export default class BabylonJSController {
1100
1549
  * Handles pointer move events on the Babylon.js scene.
1101
1550
  * @private
1102
1551
  * @param {PointerEvent} event - The pointer move event.
1103
- * @param {PickInfo} pickInfo - The result of the scene pick operation.
1552
+ * @param {PickInfo|null} pickInfo - The sampled result of the scene pick operation (may be null when sampling skips a raycast).
1104
1553
  * @returns {void}
1105
1554
  */
1106
1555
  #onPointerMove(event, pickInfo) {
1107
- if (this.#babylonJSAnimationController) {
1108
- this.#babylonJSAnimationController.highlightMeshes(pickInfo);
1556
+ const camera = this.#scene?.activeCamera;
1557
+ if (camera && !camera.metadata?.locked) {
1558
+ this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
1559
+ }
1560
+ if (this.#babylonJSAnimationController && pickInfo) {
1561
+ const pickedMeshId = pickInfo?.pickedMesh?.id || null;
1562
+ if (this.#state.pointerPicking.lastPickedMeshId !== pickedMeshId) {
1563
+ const highlightResult = this.#babylonJSAnimationController.highlightMeshes(pickInfo);
1564
+ if (highlightResult.changed) {
1565
+ this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
1566
+ }
1567
+ }
1568
+ }
1569
+ }
1570
+
1571
+ /**
1572
+ * Handles pointer events observed on the Babylon.js scene.
1573
+ * Uses pointer-move sampling to avoid running expensive `scene.pick()` on every event.
1574
+ * @private
1575
+ * @param {PointerInfo} info - The pointer event information from Babylon.js.
1576
+ * @returns {void}
1577
+ */
1578
+ #onPointerObservable(info) {
1579
+ if (info.type === PointerEventTypes.POINTERMOVE) {
1580
+ let pickInfo = null;
1581
+ let lastPickedMeshId = null;
1582
+ if (this.#shouldPickOnPointerMove()) {
1583
+ pickInfo = this.#scene.pick(this.#scene.pointerX, this.#scene.pointerY);
1584
+ lastPickedMeshId = pickInfo?.pickedMesh?.id || null;
1585
+ }
1586
+ this.#onPointerMove(info.event, pickInfo);
1587
+ this.#state.pointerPicking.lastPickedMeshId = lastPickedMeshId;
1588
+ } else if (info.type === PointerEventTypes.POINTERUP) {
1589
+ const pickInfo = info.event?.button === 2 ? this.#scene.pick(this.#scene.pointerX, this.#scene.pointerY) : null;
1590
+ if (pickInfo) {
1591
+ this.#state.pointerPicking.lastPickedMeshId = pickInfo?.pickedMesh?.id || null;
1592
+ }
1593
+ this.#onPointerUp(info.event, pickInfo);
1594
+ } else if (info.type === PointerEventTypes.POINTERWHEEL) {
1595
+ this.#onMouseWheel(info.event, null);
1596
+ }
1597
+ }
1598
+
1599
+ /**
1600
+ * Handles canvas resize notifications.
1601
+ * Resizes the Babylon engine and requests a short on-demand render burst so camera-dependent
1602
+ * buffers and post-process pipelines are redrawn at the new viewport size.
1603
+ * @private
1604
+ * @returns {void}
1605
+ */
1606
+ #onResize() {
1607
+ if (!this.#engine) {
1608
+ return;
1609
+ }
1610
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1611
+ const elapsed = now - this.#state.resize.lastAppliedAt;
1612
+ const applyResize = () => {
1613
+ this.#state.resize.timeoutId = null;
1614
+ this.#state.resize.isScheduled = false;
1615
+ if (!this.#engine) {
1616
+ return;
1617
+ }
1618
+ this.#state.resize.lastAppliedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1619
+ console.log(`PrefViewer: Applying resize after ${Math.round(elapsed)}ms`);
1620
+ this.#engine.resize();
1621
+ this.#requestRender({ frames: this.#config.render.burstFramesBase, continuousMs: this.#config.render.interactionMs });
1622
+ };
1623
+
1624
+ if (elapsed >= this.#config.resize.throttleMs) {
1625
+ applyResize();
1626
+ return;
1627
+ }
1628
+
1629
+ if (this.#state.resize.isScheduled) {
1630
+ return;
1631
+ }
1632
+
1633
+ this.#state.resize.isScheduled = true;
1634
+ const waitMs = Math.max(0, this.#config.resize.throttleMs - elapsed);
1635
+ this.#state.resize.timeoutId = setTimeout(() => {
1636
+ applyResize();
1637
+ }, waitMs);
1638
+ }
1639
+
1640
+ /**
1641
+ * Clears any queued throttled resize callback.
1642
+ * @private
1643
+ * @returns {void}
1644
+ */
1645
+ #cancelScheduledResize() {
1646
+ if (this.#state.resize.timeoutId !== null) {
1647
+ clearTimeout(this.#state.resize.timeoutId);
1109
1648
  }
1649
+ this.#state.resize.timeoutId = null;
1650
+ this.#state.resize.isScheduled = false;
1110
1651
  }
1111
1652
 
1112
1653
  /**
@@ -1148,7 +1689,7 @@ export default class BabylonJSController {
1148
1689
  .forEach((mesh) => {
1149
1690
  mesh.material = material;
1150
1691
  someSetted = true;
1151
- })
1692
+ }),
1152
1693
  );
1153
1694
 
1154
1695
  if (someSetted) {
@@ -1243,15 +1784,12 @@ export default class BabylonJSController {
1243
1784
  * Marks the IBL state as successful, recreates lights so the new environment takes effect, and reports whether anything changed.
1244
1785
  * @private
1245
1786
  * @returns {boolean} True when lights were refreshed due to pending IBL changes, otherwise false.
1787
+ * @description
1788
+ * Delegates to `#createLights()`, which executes the full IBL URL-to-texture lifecycle:
1789
+ * `cachedUrl -> HDRCubeTexture -> #hdrTexture clone -> consumeCachedUrl(true)`.
1246
1790
  */
1247
- #setOptions_IBL() {
1248
- if (this.#options.ibl.isPending && this.#settings.iblEnabled) {
1249
- this.#options.ibl.setSuccess(true);
1250
- this.#createLights();
1251
- return true;
1252
- }
1253
- this.#createLights();
1254
- return false;
1791
+ async #setOptions_IBL() {
1792
+ return await this.#createLights();
1255
1793
  }
1256
1794
 
1257
1795
  /**
@@ -1265,34 +1803,47 @@ export default class BabylonJSController {
1265
1803
  }
1266
1804
 
1267
1805
  /**
1268
- * Caches and retrieves the parent custom element "PREF-VIEWER-3D" for efficient access.
1806
+ * Resolves and caches the closest `pref-viewer-3d` host associated with the rendering canvas.
1269
1807
  * @private
1270
1808
  * @returns {void}
1271
1809
  */
1272
1810
  #getPrefViewer3DComponent() {
1273
- if (this.#prefViewer3D === undefined) {
1274
- const grandParentElement = this.#canvas.parentElement.parentElement;
1275
- this.#prefViewer3D = grandParentElement && grandParentElement.nodeName === "PREF-VIEWER-3D" ? grandParentElement : null;
1811
+ if (this.#prefViewer3D !== undefined) {
1812
+ return;
1276
1813
  }
1814
+
1815
+ let prefViewer3D = this.#canvas?.closest?.("pref-viewer-3d") || undefined;
1816
+ if (!prefViewer3D) {
1817
+ const host = this.#canvas?.getRootNode?.()?.host;
1818
+ prefViewer3D = host?.nodeName === "PREF-VIEWER-3D" ? host : undefined;
1819
+ }
1820
+
1821
+ this.#prefViewer3D = prefViewer3D;
1277
1822
  }
1278
1823
 
1279
1824
  /**
1280
- * Caches and retrieves the parent custom element "PREF-VIEWER" for efficient access.
1825
+ * Resolves and caches the closest `pref-viewer` host associated with `#prefViewer3D`.
1281
1826
  * @private
1282
1827
  * @returns {void}
1283
1828
  */
1284
1829
  #getPrefViewerComponent() {
1285
- if (this.#prefViewer === undefined) {
1286
- if (this.#prefViewer3D === undefined) {
1287
- this.#getPrefViewer3DComponent();
1288
- }
1289
- if (!this.#prefViewer3D) {
1290
- this.#prefViewer = null;
1291
- return;
1292
- }
1293
- const rootNode = this.#prefViewer3D ? this.#prefViewer3D.getRootNode().host : null;
1294
- this.#prefViewer = rootNode && rootNode.nodeName === "PREF-VIEWER" ? rootNode : null;
1830
+ if (this.#prefViewer !== undefined) {
1831
+ return;
1295
1832
  }
1833
+
1834
+ this.#getPrefViewer3DComponent();
1835
+ if (this.#prefViewer3D === undefined) {
1836
+ this.#prefViewer = undefined;
1837
+ return;
1838
+ }
1839
+
1840
+ let prefViewer = this.#prefViewer3D.closest?.("pref-viewer") || undefined;
1841
+ if (!prefViewer) {
1842
+ const host = this.#prefViewer3D.getRootNode?.()?.host;
1843
+ prefViewer = host?.nodeName === "PREF-VIEWER" ? host : undefined;
1844
+ }
1845
+
1846
+ this.#prefViewer = prefViewer;
1296
1847
  }
1297
1848
 
1298
1849
  /**
@@ -1373,6 +1924,52 @@ export default class BabylonJSController {
1373
1924
  return this.#addContainer(container, false);
1374
1925
  }
1375
1926
 
1927
+ /**
1928
+ * Stops every animation group on the provided asset container to guarantee new loads start from a clean state.
1929
+ * @private
1930
+ * @param {AssetContainer} assetContainer - Container whose animation groups should be halted.
1931
+ */
1932
+ #assetContainer_stopAnimations(assetContainer) {
1933
+ if (!assetContainer.animationGroups || assetContainer.animationGroups.length === 0) {
1934
+ return;
1935
+ }
1936
+ assetContainer.animationGroups.forEach((animationGroup) => {
1937
+ animationGroup.stop();
1938
+ });
1939
+ }
1940
+
1941
+ /**
1942
+ * Disposes every imported light so subsequent reloads avoid duplicating scene illumination.
1943
+ * @private
1944
+ * @param {AssetContainer} assetContainer - Container whose lights should be cleaned up.
1945
+ * @returns {void}
1946
+ */
1947
+ #assetContainer_deleteLights(assetContainer) {
1948
+ if (!assetContainer.lights || assetContainer.lights.length === 0) {
1949
+ return;
1950
+ }
1951
+ assetContainer.lights.forEach((light) => {
1952
+ light.dispose();
1953
+ });
1954
+ assetContainer.lights = [];
1955
+ }
1956
+
1957
+ /**
1958
+ * Assigns unique ids to every imported camera so Babylon.js does not reuse stale SSAO effects between reloads.
1959
+ * @private
1960
+ * @param {AssetContainer} assetContainer - Container whose cameras need deterministic id regeneration.
1961
+ * @returns {void}
1962
+ */
1963
+ #assetContainer_retagCameras(assetContainer) {
1964
+ if (!assetContainer.cameras || assetContainer.cameras.length === 0) {
1965
+ return;
1966
+ }
1967
+ assetContainer.cameras.forEach((camera) => {
1968
+ const sufix = "_" + Date.now();
1969
+ camera.id = `${camera.id || camera.name || "camera"}${sufix}`;
1970
+ });
1971
+ }
1972
+
1376
1973
  /**
1377
1974
  * Sets the visibility of wall and floor meshes in the model container based on the provided value or environment visibility.
1378
1975
  * @private
@@ -1394,10 +1991,12 @@ export default class BabylonJSController {
1394
1991
  * @private
1395
1992
  * @returns {void}
1396
1993
  */
1397
- #stopRender() {
1398
- this.#engine.stopRenderLoop(this.#handlers.renderLoop);
1399
- this.#unloadCameraDependentEffects();
1994
+ async #stopRender() {
1995
+ this.#stopEngineRenderLoop();
1996
+ this.#resetRenderState();
1997
+ await this.#unloadCameraDependentEffects();
1400
1998
  }
1999
+
1401
2000
  /**
1402
2001
  * Starts the Babylon.js render loop for the current scene.
1403
2002
  * Waits until the scene is ready before beginning continuous rendering.
@@ -1405,21 +2004,29 @@ export default class BabylonJSController {
1405
2004
  * @returns {Promise<void>}
1406
2005
  */
1407
2006
  async #startRender() {
1408
- this.#loadCameraDependentEffects();
2007
+ await this.#loadCameraDependentEffects();
1409
2008
  await this.#scene.whenReadyAsync();
1410
- this.#engine.runRenderLoop(this.#handlers.renderLoop);
2009
+ const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#config.render.burstFramesEnhanced : this.#config.render.burstFramesBase;
2010
+ this.#requestRender({ frames: frames, continuousMs: this.#config.render.interactionMs });
1411
2011
  }
1412
2012
 
1413
2013
  /**
1414
- * Loads an asset container (model, environment, materials, etc.) using the provided container state.
2014
+ * Loads a single asset container (model, environment, materials, etc.) based on the container state flags.
2015
+ * Skips work when nothing is pending, otherwise resolves the GLTF source, refreshes cache metadata and streams it
2016
+ * into the Babylon.js scene via `LoadAssetContainerAsync`.
1415
2017
  * @private
1416
- * @param {object} container - The container object containing asset state and metadata.
1417
- * @returns {Promise<[object, AssetContainer|boolean]>} Resolves to an array with the container and the loaded asset container, or false if loading fails.
2018
+ * @param {object} container - Container descriptor that carries the GLTF storage pointer and current cache info.
2019
+ * @param {boolean} [force=false] - When true, bypasses cached size/timestamp so the resolver re-downloads the asset.
2020
+ * @returns {Promise<[object, AssetContainer|boolean]>} Resolves to `[container, assetContainer]` on success, or
2021
+ * `[container, false]` when loading was skipped or failed.
1418
2022
  * @description
1419
- * Resolves the asset source using GLTFResolver, prepares plugin options, and loads the asset into the Babylon.js scene.
1420
- * Updates the container's cache data and returns the container along with the loaded asset container or false if loading fails.
2023
+ * 1. Validates that the container has pending data and initializes the shared `GLTFResolver` if needed.
2024
+ * 2. Requests the source blob (respecting the cached size/timestamp unless `force` is set) and stores the new cache
2025
+ * metadata via `setPendingCacheData`.
2026
+ * 3. Builds the Babylon plugin options so extras are surfaced as metadata, then imports the container with
2027
+ * `LoadAssetContainerAsync`, returning the tuple so the caller can decide how to attach it to the scene.
1421
2028
  */
1422
- async #loadAssetContainer(container) {
2029
+ async #loadAssetContainer(container, force = false) {
1423
2030
  if (container?.state?.update?.storage === undefined || container?.state?.size === undefined || container?.state?.timeStamp === undefined) {
1424
2031
  return [container, false];
1425
2032
  }
@@ -1431,9 +2038,12 @@ export default class BabylonJSController {
1431
2038
  if (!this.#gltfResolver) {
1432
2039
  this.#gltfResolver = new GLTFResolver();
1433
2040
  }
1434
- //let sourceData = await this.#gltfResolver.getSource(container.state.update.storage, container.state.size, container.state.timeStamp);
1435
- // TEMPORARY: We never pass 'size' or 'timeStamp' to always force a reload and avoid issues with the active camera in reloading assetContainers
1436
- let sourceData = await this.#gltfResolver.getSource(container.state.update.storage, 0, null);
2041
+
2042
+ const currentSize = force ? 0 : container.state.size;
2043
+ const currentTimeStamp = force ? null : container.state.timeStamp;
2044
+
2045
+ let sourceData = await this.#gltfResolver.getSource(container.state.update.storage, currentSize, currentTimeStamp);
2046
+
1437
2047
  if (!sourceData) {
1438
2048
  return [container, false];
1439
2049
  }
@@ -1462,6 +2072,8 @@ export default class BabylonJSController {
1462
2072
  return [container, assetContainer];
1463
2073
  } catch (error) {
1464
2074
  return [container, assetContainer];
2075
+ } finally {
2076
+ this.#gltfResolver.revokeObjectURLs(sourceData.objectURLs);
1465
2077
  }
1466
2078
  }
1467
2079
 
@@ -1474,15 +2086,16 @@ export default class BabylonJSController {
1474
2086
  * Applies material and camera options, sets wall/floor visibility, and initializes lights and shadows.
1475
2087
  * Returns an object with success status and error details.
1476
2088
  */
1477
- async #loadContainers() {
1478
- this.#stopRender();
2089
+ async #loadContainers(force = false) {
2090
+ this.#detachAnimationChangedListener();
2091
+ await this.#stopRender();
1479
2092
 
1480
2093
  let oldModelMetadata = { ...(this.#containers.model?.state?.metadata ?? {}) };
1481
2094
  let newModelMetadata = {};
1482
2095
 
1483
2096
  const promiseArray = [];
1484
2097
  Object.values(this.#containers).forEach((container) => {
1485
- promiseArray.push(this.#loadAssetContainer(container));
2098
+ promiseArray.push(this.#loadAssetContainer(container, force));
1486
2099
  });
1487
2100
 
1488
2101
  let detail = {
@@ -1491,22 +2104,19 @@ export default class BabylonJSController {
1491
2104
  };
1492
2105
 
1493
2106
  await Promise.allSettled(promiseArray)
1494
- .then((values) => {
2107
+ .then(async (values) => {
1495
2108
  this.#disposeAnimationController();
1496
2109
  values.forEach((result) => {
1497
2110
  const container = result.value ? result.value[0] : null;
1498
2111
  const assetContainer = result.value ? result.value[1] : null;
1499
2112
  if (result.status === "fulfilled" && assetContainer) {
1500
2113
  if (container.state.name === "model") {
1501
- assetContainer.lights = [];
2114
+ this.#assetContainer_deleteLights(assetContainer);
2115
+ this.#assetContainer_stopAnimations(assetContainer);
1502
2116
  newModelMetadata = { ...(container.state.update.metadata ?? {}) };
1503
2117
  }
1504
2118
  if (container.state.name === "model" || container.state.name === "environment") {
1505
- assetContainer.cameras.forEach((camera) => {
1506
- // To avoid conflicts when reloading the model we rename the id because Babylon.js caches the camera's SSAO effect by id.
1507
- const sufix = "_" + Date.now();
1508
- camera.id = `${camera.id || camera.name || "camera"}${sufix}`;
1509
- });
2119
+ this.#assetContainer_retagCameras(assetContainer);
1510
2120
  }
1511
2121
  this.#replaceContainer(container, assetContainer);
1512
2122
  container.state.setSuccess(true);
@@ -1520,7 +2130,7 @@ export default class BabylonJSController {
1520
2130
 
1521
2131
  this.#setOptions_Materials();
1522
2132
  this.#setOptions_Camera();
1523
- this.#setOptions_IBL();
2133
+ await this.#setOptions_IBL();
1524
2134
  this.#setVisibilityOfWallAndFloorInModel();
1525
2135
  detail.success = true;
1526
2136
  })
@@ -1534,7 +2144,10 @@ export default class BabylonJSController {
1534
2144
  this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
1535
2145
  this.#setMaxSimultaneousLights();
1536
2146
  this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
1537
- this.#startRender();
2147
+ if (this.#babylonJSAnimationController?.hasAnimations?.()) {
2148
+ this.#attachAnimationChangedListener();
2149
+ }
2150
+ await this.#startRender();
1538
2151
  });
1539
2152
  return detail;
1540
2153
  }
@@ -1545,10 +2158,10 @@ export default class BabylonJSController {
1545
2158
  * @private
1546
2159
  * @returns {void}
1547
2160
  */
1548
- #loadCameraDependentEffects() {
1549
- this.#initializeVisualImprovements();
1550
- this.#initializeAmbientOcclussion();
1551
- this.#initializeShadows();
2161
+ async #loadCameraDependentEffects() {
2162
+ await this.#initializeVisualImprovements();
2163
+ await this.#initializeAmbientOcclussion();
2164
+ await this.#initializeShadows();
1552
2165
  }
1553
2166
 
1554
2167
  /**
@@ -1557,10 +2170,10 @@ export default class BabylonJSController {
1557
2170
  * @private
1558
2171
  * @returns {void}
1559
2172
  */
1560
- #unloadCameraDependentEffects() {
1561
- this.#disableVisualImprovements();
1562
- this.#disableAmbientOcclusion();
1563
- this.#disableShadows();
2173
+ async #unloadCameraDependentEffects() {
2174
+ await this.#disableVisualImprovements();
2175
+ await this.#disableAmbientOcclusion();
2176
+ await this.#disableShadows();
1564
2177
  }
1565
2178
 
1566
2179
  /**
@@ -1793,10 +2406,10 @@ export default class BabylonJSController {
1793
2406
  this.#engine = new Engine(this.#canvas, true, { alpha: true, stencil: true, preserveDrawingBuffer: false });
1794
2407
  this.#engine.disableUniformBuffers = true;
1795
2408
  this.#scene = new Scene(this.#engine);
1796
-
2409
+
1797
2410
  // Activate the rendering of geometry data into a G-buffer, essential for advanced effects like deferred shading,
1798
- // SSAO, and Velocity-Texture-Animation (VAT), allowing for complex post-processing by separating rendering into
1799
- // different buffers (depth, normals, velocity) for later use in shaders.
2411
+ // SSAO, and Velocity-Texture-Animation (VAT), allowing for complex post-processing by separating rendering into
2412
+ // different buffers (depth, normals, velocity) for later use in shaders.
1800
2413
  const geometryBufferRenderer = this.#scene.enableGeometryBufferRenderer();
1801
2414
  if (geometryBufferRenderer) {
1802
2415
  geometryBufferRenderer.enableScreenspaceDepth = true;
@@ -1804,28 +2417,62 @@ export default class BabylonJSController {
1804
2417
  geometryBufferRenderer.generateNormalsInWorldSpace = true;
1805
2418
  }
1806
2419
 
1807
- this.#scene.clearColor = new Color4(1, 1, 1, 1);
2420
+ this.#scene.clearColor = new Color4(1, 1, 1, 1).toLinearSpace();
2421
+
2422
+ // Lowered exposure to prevent scenes from looking blown out when the DefaultRenderingPipeline (Antialiasing) is enabled.
2423
+ this.#scene.imageProcessingConfiguration.exposure = 0.75;
2424
+ this.#scene.imageProcessingConfiguration.contrast = 1.0;
2425
+ this.#scene.imageProcessingConfiguration.toneMappingEnabled = false;
2426
+ this.#scene.imageProcessingConfiguration.vignetteEnabled = false;
2427
+ this.#scene.imageProcessingConfiguration.colorCurvesEnabled = false;
2428
+
2429
+ // Skip the built-in pointer picking logic since the controller implements its own optimized raycasting for interaction.
2430
+ this.#scene.skipPointerMovePicking = true;
2431
+ this.#scene.skipPointerDownPicking = true;
2432
+ this.#scene.skipPointerUpPicking = true;
2433
+
1808
2434
  this.#createCamera();
1809
2435
  this.#enableInteraction();
1810
2436
  await this.#createXRExperience();
1811
- this.#startRender();
1812
- this.#canvasResizeObserver = new ResizeObserver(() => this.#engine && this.#engine.resize());
1813
- this.#canvasResizeObserver.observe(this.#canvas);
1814
2437
  }
1815
2438
 
1816
2439
  /**
1817
2440
  * Disposes the Babylon.js engine and disconnects the canvas resize observer.
1818
- * Cleans up all scene, camera, light, and XR resources.
2441
+ * Cleans up all scene, camera, light, XR, and GLTF resolver resources.
2442
+ * The teardown is asynchronous: it waits for XR/session-dependent shutdown work
2443
+ * before disposing the engine, and coalesces concurrent calls into one in-flight promise.
1819
2444
  * @public
1820
- * @returns {void}
2445
+ * @returns {Promise<void>}
1821
2446
  */
1822
- disable() {
1823
- this.#canvasResizeObserver.disconnect();
1824
- this.#disableInteraction();
1825
- this.#disposeAnimationController();
1826
- this.#disposeXRExperience();
1827
- this.#unloadCameraDependentEffects();
1828
- this.#disposeEngine();
2447
+ async disable() {
2448
+ if (this.#disablingPromises.general) {
2449
+ return await this.#disablingPromises.general;
2450
+ }
2451
+
2452
+ this.#disablingPromises.general = (async () => {
2453
+ this.#disableInteraction();
2454
+ this.#disposeAnimationController();
2455
+ this.#disposeGLTFResolver();
2456
+ try {
2457
+ await this.#disposeXRExperience();
2458
+ } catch (error) {
2459
+ console.warn("PrefViewer: Error while disposing XR experience:", error);
2460
+ }
2461
+ try {
2462
+ await this.#unloadCameraDependentEffects();
2463
+ } catch (error) {
2464
+ console.warn("PrefViewer: Error while unloading camera-dependent effects:", error);
2465
+ } finally {
2466
+ this.#stopEngineRenderLoop();
2467
+ this.#disposeEngine();
2468
+ }
2469
+ })();
2470
+
2471
+ try {
2472
+ await this.#disablingPromises.general;
2473
+ } finally {
2474
+ this.#disablingPromises.general = null;
2475
+ }
1829
2476
  }
1830
2477
 
1831
2478
  /**
@@ -1946,28 +2593,8 @@ export default class BabylonJSController {
1946
2593
  * @public
1947
2594
  * @returns {Promise<boolean>} Resolves to true if loading succeeds, false otherwise.
1948
2595
  */
1949
- async load() {
1950
- return await this.#loadContainers();
1951
- }
1952
-
1953
- /**
1954
- * Merges incoming render flags with the current configuration, persists them, and marks
1955
- * all dependent loaders/options as pending when something actually changed.
1956
- * @public
1957
- * @param {{antiAliasingEnabled?:boolean, ambientOcclusionEnabled?:boolean, iblEnabled?:boolean, shadowsEnabled?:boolean}} settings Partial set of render toggles to apply.
1958
- * @returns {{changed:boolean, settings:{antiAliasingEnabled:boolean, ambientOcclusionEnabled:boolean, iblEnabled:boolean, shadowsEnabled:boolean}}}
1959
- * @description
1960
- * Callers can inspect the `changed` flag to decide whether to trigger a reload with
1961
- * `reloadWithCurrentSettings()` or simply reuse the returned snapshot.
1962
- */
1963
- scheduleRenderSettingsReload(settings = {}) {
1964
- const changed = this.#applyRenderSettings(settings);
1965
- if (!changed) {
1966
- return { changed: false, settings: this.getRenderSettings() };
1967
- }
1968
- this.#markContainersForReload();
1969
- this.#markOptionsForReload();
1970
- return { changed: true, settings: this.getRenderSettings() };
2596
+ async load(force = false) {
2597
+ return await this.#loadContainers(force);
1971
2598
  }
1972
2599
 
1973
2600
  /**
@@ -2000,13 +2627,15 @@ export default class BabylonJSController {
2000
2627
  * Reapplies image-based lighting configuration (HDR URL, intensity, shadow mode).
2001
2628
  * Stops rendering, pushes pending IBL state into the scene, rebuilds camera-dependent effects, then resumes rendering.
2002
2629
  * @public
2003
- * @returns {void}
2630
+ * @returns {boolean} True if IBL options were set successfully, false otherwise.
2004
2631
  */
2005
- setIBLOptions() {
2006
- this.#stopRender();
2007
- const IBLOptionsSetted = this.#setOptions_IBL();
2008
- this.#startRender();
2009
- return IBLOptionsSetted;
2632
+ async setIBLOptions() {
2633
+ await this.#stopRender();
2634
+ try {
2635
+ return await this.#setOptions_IBL();
2636
+ } finally {
2637
+ await this.#startRender();
2638
+ }
2010
2639
  }
2011
2640
 
2012
2641
  /**
@@ -2033,6 +2662,26 @@ export default class BabylonJSController {
2033
2662
  this.#startRender();
2034
2663
  }
2035
2664
 
2665
+ /**
2666
+ * Merges incoming render flags with the current configuration, persists them, and marks
2667
+ * all dependent loaders/options as pending when something actually changed.
2668
+ * @public
2669
+ * @param {{antiAliasingEnabled?:boolean, ambientOcclusionEnabled?:boolean, iblEnabled?:boolean, shadowsEnabled?:boolean}} settings Partial set of render toggles to apply.
2670
+ * @returns {{changed:boolean, settings:{antiAliasingEnabled:boolean, ambientOcclusionEnabled:boolean, iblEnabled:boolean, shadowsEnabled:boolean}}}
2671
+ * @description
2672
+ * Callers can inspect the `changed` flag to decide whether to trigger a reload with
2673
+ * `reloadWithCurrentSettings()` or simply reuse the returned snapshot.
2674
+ */
2675
+ scheduleRenderSettingsReload(settings = {}) {
2676
+ const changed = this.#saveRenderSettings(settings);
2677
+ if (!changed) {
2678
+ return { changed: false, settings: this.getRenderSettings() };
2679
+ }
2680
+ this.#markContainersForReload();
2681
+ this.#markOptionsForReload();
2682
+ return { changed: true, settings: this.getRenderSettings() };
2683
+ }
2684
+
2036
2685
  /**
2037
2686
  * Reloads every asset container using the latest staged render settings.
2038
2687
  * Intended to be called after `scheduleRenderSettingsReload()` marks data as pending.