@preference-sl/pref-viewer 2.13.0 → 2.14.0-beta.0

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",
3
+ "version": "2.14.0-beta.0",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "scripts": {
@@ -141,9 +141,11 @@ export default class BabylonJSController {
141
141
  // Render loop state balances performance with responsiveness.
142
142
  render: {
143
143
  isLoopRunning: false,
144
+ isIdle: false,
144
145
  dirtyFrames: 0,
145
146
  continuousUntil: 0,
146
147
  lastRenderAt: 0,
148
+ lastActivityAt: 0,
147
149
  },
148
150
  // Resize state batches frequent ResizeObserver notifications.
149
151
  resize: {
@@ -164,13 +166,259 @@ export default class BabylonJSController {
164
166
  burstFramesEnhanced: 32, // when AA/SSAO/IBL is enabled, more frames are needed to reach stable output
165
167
  interactionMs: 250,
166
168
  animationMs: 200,
167
- idleThrottleMs: 1000 / 15,
169
+ idleThrottleMs: 1000 / 8, // Reduced from 15 FPS to 8 FPS for idle - reduces GPU/CPU load
170
+ idleThresholdMs: 3000, // Time before going to true idle (no renders)
168
171
  },
169
172
  resize: {
170
173
  throttleMs: 50, // cap resize work to ~20 Hz while dragging/resizing containers
171
174
  },
175
+ // Hardware-adaptive quality settings (applied based on GPU capabilities)
176
+ quality: {
177
+ // Settings for different hardware tiers: 'low', 'medium', 'high'
178
+ // Applied automatically based on GPU detected capabilities
179
+ tier: 'medium', // default, will be updated after hardware detection
180
+ shadowMapSize: 1024, // Standard shadow generator resolution
181
+ iblShadowResolution: 1, // IBL shadow resolution exponent (0=1024, 1=2048, 2=4096)
182
+ iblSampleDirections: 4, // IBL shadow quality
183
+ shadowBlurKernel: 16, // Shadow blur amount
184
+ ssaoEnabled: true, // Screen Space Ambient Occlusion
185
+ },
172
186
  };
173
187
 
188
+ /**
189
+ * Detects hardware capabilities and determines optimal quality tier.
190
+ * Uses WebGL renderer info, device type, and battery status to classify hardware.
191
+ * @private
192
+ * @returns {string} Hardware tier: 'low', 'medium', 'high', or 'ultra'
193
+ */
194
+ #detectHardwareTier() {
195
+ // First, try to get WebGL info to help identify the device
196
+ const gl = this.#engine?.getRenderingCanvas()?.getContext?.('webgl2') || this.#engine?.getRenderingCanvas()?.getContext?.('webgl');
197
+
198
+ const debugInfo = gl?.getExtension('WEBGL_debug_renderer_info');
199
+ const rendererName = debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : '';
200
+
201
+ // If we have WebGL renderer info and it looks like a desktop GPU, skip mobile detection
202
+ // Desktop GPUs typically have "NVIDIA", "AMD", "Intel" in the name
203
+ const isDesktopGPU = /NVIDIA|AMD|Intel|Radeon|GeForce|Quadro/i.test(rendererName);
204
+
205
+ // Also check if it's explicitly a mobile GPU
206
+ const isMobileGPU = /Adreno|Mali|Apple GPU/i.test(rendererName);
207
+
208
+ // If we detect a desktop GPU, go directly to desktop detection
209
+ if (isDesktopGPU && !isMobileGPU) {
210
+ console.log(`[PrefViewer] Desktop GPU detected: ${rendererName}`);
211
+ return this.#detectDesktopTier(gl, rendererName);
212
+ }
213
+
214
+ // If we detect a mobile GPU, it's definitely mobile
215
+ if (isMobileGPU) {
216
+ console.log(`[PrefViewer] Mobile GPU detected: ${rendererName} - Quality tier: low`);
217
+ return 'low';
218
+ }
219
+
220
+ // If no WebGL info or unclear, fall back to user agent detection
221
+ const mobileInfo = this.#detectMobileInfo();
222
+
223
+ // Only treat as mobile if we have strong mobile indicators
224
+ if (mobileInfo.isMobile && !mobileInfo.isDesktopLike) {
225
+ if (mobileInfo.isHighEnd) {
226
+ console.log(`[PrefViewer] High-end mobile detected: ${mobileInfo.deviceName} - Quality tier: medium`);
227
+ return 'medium';
228
+ } else {
229
+ console.log(`[PrefViewer] Mobile detected: ${mobileInfo.deviceName} - Quality tier: low`);
230
+ return 'low';
231
+ }
232
+ }
233
+
234
+ // If still unclear, default to desktop detection
235
+ console.log(`[PrefViewer] Using desktop GPU detection (unknown renderer: ${rendererName || 'none'})`);
236
+ return this.#detectDesktopTier(gl, rendererName);
237
+ }
238
+
239
+ /**
240
+ * Detects hardware tier for desktop GPUs based on renderer name and WebGL capabilities.
241
+ * @private
242
+ * @param {WebGLRenderingContext} gl - WebGL context
243
+ * @param {string} rendererName - GPU renderer name
244
+ * @returns {string} Hardware tier
245
+ */
246
+ #detectDesktopTier(gl, rendererName) {
247
+ // Check WebGL capabilities
248
+ const maxTextureSize = gl?.getParameter(gl.MAX_TEXTURE_SIZE) || 8192;
249
+
250
+ // Detect high-end GPUs
251
+ const isAppleSilicon = /Apple|M1|M2|M3|M4/i.test(rendererName);
252
+ const isNvidiaHighEnd = /RTX|GeForce (3|4|5|6|7|8|9|40|50|60|70|80|90)|RTX A[5-9]|RTX Pro|Quadro/i.test(rendererName);
253
+ const isAmdHighEnd = /RX (6|7|8|9)[0-9]{2,}|RX Vega|Radeon Pro|WX [0-9]/i.test(rendererName);
254
+ const isIntelIntegrated = /Intel.*UHD|Intel.*Iris|Mesa|llvmpipe/i.test(rendererName);
255
+
256
+ let tier = 'high'; // Default to high for desktop
257
+
258
+ if (isIntelIntegrated || maxTextureSize < 8192) {
259
+ tier = 'medium';
260
+ } else if (isAppleSilicon || isNvidiaHighEnd || isAmdHighEnd || maxTextureSize >= 16384) {
261
+ tier = 'ultra';
262
+ }
263
+
264
+ console.log(`[PrefViewer] Desktop tier: ${tier} (GPU: ${rendererName || 'unknown'}, MaxTexture: ${maxTextureSize})`);
265
+ return tier;
266
+ }
267
+
268
+ /**
269
+ * Detects mobile device type and capabilities.
270
+ * Analyzes user agent, screen specs, and device performance indicators.
271
+ * @private
272
+ * @returns {Object} Mobile device info with tier classification
273
+ */
274
+ #detectMobileInfo() {
275
+ const userAgent = navigator.userAgent || '';
276
+ const screenWidth = window.screen?.width || 0;
277
+ const screenHeight = window.screen?.height || 0;
278
+ const pixelRatio = window.devicePixelRatio || 1;
279
+
280
+ // Detect device type
281
+ const isIPhone = /iPhone/i.test(userAgent);
282
+ const isIPad = /iPad/i.test(userAgent);
283
+ const isAndroid = /Android/i.test(userAgent);
284
+ const isMobile = /Mobile/i.test(userAgent);
285
+
286
+ // High-end mobile patterns (recent flagship devices)
287
+ const highEndPatterns = [
288
+ /iPhone (1[4-9]|SE)/i, // iPhone 14+
289
+ /iPad Pro/i, // iPad Pro
290
+ /SM-S91[0-9]/i, // Samsung S21+
291
+ /SM-S9[0-9]/i, // Samsung S20+
292
+ /Pixel [7-9]/i, // Pixel 7+
293
+ /OnePlus [8-9|10|11]/i, // OnePlus 8+
294
+ /Xiaomi (1[2-4]|Mi 1[1-4])/i, // Xiaomi 12+
295
+ /Huawei (P50|Mate 4|)/i, // Huawei P50+
296
+ ];
297
+
298
+ // Mid-range patterns
299
+ const midRangePatterns = [
300
+ /iPhone (1[0-3]|SE)/i, // iPhone 10-13
301
+ /iPad Air/i, // iPad Air
302
+ /SM-A[0-9]/i, // Samsung A series
303
+ /Redmi Note/i, // Redmi Note
304
+ /Pixel [4-6]/i, // Pixel 4-6
305
+ /Moto G/i, // Moto G
306
+ ];
307
+
308
+ const isHighEnd = highEndPatterns.some(p => p.test(userAgent));
309
+ const isMidRange = midRangePatterns.some(p => p.test(userAgent));
310
+
311
+ // Detect tablet ONLY if explicitly in user agent (not just screen size)
312
+ // Many desktops have large screens and would incorrectly be detected as tablets
313
+ const isTablet = /iPad|Android.*Tablet|Nexus 9|Nexus 10/i.test(userAgent);
314
+
315
+ // Detect if mobile (phone form factor) - must have explicit mobile indicators
316
+ const isMobilePhone = /Android.*Mobile|iPhone|iPod/i.test(userAgent);
317
+
318
+ // Check if it looks like a desktop/laptop (has "Windows", "Mac", "Linux" but no mobile indicators)
319
+ const isDesktopLike = /Windows NT|Macintosh|Linux|X11/i.test(userAgent) && !isMobilePhone && !isTablet;
320
+
321
+ // Device name for logging
322
+ let deviceName = 'Unknown';
323
+ if (isIPhone) {
324
+ const match = userAgent.match(/iPhone.*OS (\d+)/);
325
+ deviceName = match ? `iPhone OS ${match[1]}` : 'iPhone';
326
+ } else if (isIPad) {
327
+ deviceName = 'iPad';
328
+ } else if (isAndroid) {
329
+ const match = userAgent.match(/Android.*;.*(.+?)\)/);
330
+ deviceName = match ? match[1].trim() : 'Android';
331
+ }
332
+
333
+ return {
334
+ isMobile: isMobilePhone && !isDesktopLike,
335
+ isTablet: isTablet && !isDesktopLike,
336
+ isDesktopLike,
337
+ isHighEnd: isHighEnd || (isTablet && pixelRatio >= 2),
338
+ isMidRange: isMidRange,
339
+ isLowEnd: !isHighEnd && !isMidRange,
340
+ deviceName,
341
+ screenWidth,
342
+ screenHeight,
343
+ pixelRatio,
344
+ };
345
+ }
346
+
347
+ /**
348
+ * Applies quality settings based on detected hardware tier.
349
+ * @private
350
+ * @param {string} tier - Hardware tier: 'low', 'medium', or 'high'
351
+ */
352
+ #applyQualitySettings(tier) {
353
+ const qualityPresets = {
354
+ // Mobile low-end (old phones, budget devices)
355
+ low: {
356
+ shadowMapSize: 256, // Very small for speed
357
+ iblShadowResolution: -1, // Disable IBL shadows
358
+ iblSampleDirections: 1, // Minimal sampling
359
+ shadowBlurKernel: 0, // No blur
360
+ ssaoEnabled: false,
361
+ antialiasingEnabled: false,
362
+ hardwareScaling: 2.5, // Aggressive downscaling
363
+ maxModelResolution: 512, // Low-res model loading
364
+ },
365
+ // Mobile high-end / Tablet / Desktop low
366
+ medium: {
367
+ shadowMapSize: 512,
368
+ iblShadowResolution: 0, // 1024x1024
369
+ iblSampleDirections: 2, // Fast sampling
370
+ shadowBlurKernel: 4, // Light blur
371
+ ssaoEnabled: false, // Disable for mobile battery
372
+ antialiasingEnabled: true,
373
+ hardwareScaling: 2.0,
374
+ maxModelResolution: 1024,
375
+ },
376
+ // Desktop mid-range / High-end mobile
377
+ high: {
378
+ shadowMapSize: 1024,
379
+ iblShadowResolution: 0, // 1024x1024 - good balance
380
+ iblSampleDirections: 4, // Quality sampling
381
+ shadowBlurKernel: 16, // Smooth shadows
382
+ ssaoEnabled: true,
383
+ antialiasingEnabled: true,
384
+ hardwareScaling: 1.5,
385
+ maxModelResolution: 2048,
386
+ },
387
+ // Desktop high-end (RTX, Apple Silicon, etc.)
388
+ ultra: {
389
+ shadowMapSize: 2048,
390
+ iblShadowResolution: 1, // 2048x2048
391
+ iblSampleDirections: 8, // Maximum quality
392
+ shadowBlurKernel: 32, // Ultra smooth
393
+ ssaoEnabled: true,
394
+ antialiasingEnabled: true,
395
+ hardwareScaling: 1.0, // Native resolution
396
+ maxModelResolution: 4096,
397
+ },
398
+ };
399
+
400
+ // Map tier to preset with fallback
401
+ const presetMap = {
402
+ 'low': qualityPresets.low,
403
+ 'medium': qualityPresets.medium,
404
+ 'high': qualityPresets.high,
405
+ 'ultra': qualityPresets.ultra,
406
+ };
407
+
408
+ const preset = presetMap[tier] || qualityPresets.medium;
409
+
410
+ // Apply preset to config
411
+ this.#config.quality = {
412
+ tier,
413
+ ...preset,
414
+ // Legacy compatibility
415
+ shadowMapSize: preset.shadowMapSize,
416
+ iblShadowResolution: preset.iblShadowResolution,
417
+ iblSampleDirections: preset.iblSampleDirections,
418
+ shadowBlurKernel: preset.shadowBlurKernel,
419
+ };
420
+ }
421
+
174
422
  // 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
423
  #disablingPromises = {
176
424
  xr: null,
@@ -463,6 +711,7 @@ export default class BabylonJSController {
463
711
  * Evaluates whether the renderer should stay in continuous mode.
464
712
  * XR always forces continuous rendering; animation/camera motion also extends the
465
713
  * continuous deadline window to avoid abrupt stop-start behavior.
714
+ * OPTIMIZATION: Added true idle mode - stops rendering completely after idleThresholdMs of inactivity.
466
715
  * @private
467
716
  * @param {number} now - Current high-resolution timestamp.
468
717
  * @returns {boolean} True when continuous rendering should remain active.
@@ -470,6 +719,8 @@ export default class BabylonJSController {
470
719
  #shouldRenderContinuously(now) {
471
720
  const inXR = this.#XRExperience?.baseExperience?.state === WebXRState.IN_XR;
472
721
  if (inXR) {
722
+ this.#state.render.isIdle = false;
723
+ this.#state.render.lastActivityAt = now;
473
724
  return true;
474
725
  }
475
726
 
@@ -478,18 +729,36 @@ export default class BabylonJSController {
478
729
 
479
730
  if (animationRunning) {
480
731
  this.#state.render.continuousUntil = Math.max(this.#state.render.continuousUntil, now + this.#config.render.animationMs);
732
+ this.#state.render.isIdle = false;
733
+ this.#state.render.lastActivityAt = now;
481
734
  }
482
735
  if (cameraInMotion) {
483
736
  this.#state.render.continuousUntil = Math.max(this.#state.render.continuousUntil, now + this.#config.render.interactionMs);
737
+ this.#state.render.isIdle = false;
738
+ this.#state.render.lastActivityAt = now;
739
+ }
740
+
741
+ // OPTIMIZATION: True idle mode - if no activity for idleThresholdMs, stop rendering completely
742
+ const timeSinceActivity = now - this.#state.render.lastActivityAt;
743
+ if (timeSinceActivity > this.#config.render.idleThresholdMs) {
744
+ this.#state.render.isIdle = true;
484
745
  }
485
746
 
486
- return animationRunning || cameraInMotion || this.#state.render.continuousUntil > now;
747
+ const shouldRender = animationRunning || cameraInMotion || this.#state.render.continuousUntil > now;
748
+
749
+ // Reset idle flag if we need to render
750
+ if (shouldRender) {
751
+ this.#state.render.isIdle = false;
752
+ }
753
+
754
+ return shouldRender;
487
755
  }
488
756
 
489
757
  /**
490
758
  * Render loop callback for Babylon.js.
491
759
  * 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.
760
+ * It self-stops when the scene becomes idle (true idle mode - no renders at all).
761
+ * OPTIMIZATION: Added true idle mode that completely stops rendering after idleThresholdMs.
493
762
  * @private
494
763
  * @returns {void}
495
764
  */
@@ -503,6 +772,12 @@ export default class BabylonJSController {
503
772
  const continuous = this.#shouldRenderContinuously(now);
504
773
  const needsRender = continuous || this.#state.render.dirtyFrames > 0;
505
774
 
775
+ // OPTIMIZATION: True idle mode - stop rendering completely when idle
776
+ if (this.#state.render.isIdle && !needsRender) {
777
+ this.#stopEngineRenderLoop();
778
+ return;
779
+ }
780
+
506
781
  if (!needsRender) {
507
782
  this.#stopEngineRenderLoop();
508
783
  return;
@@ -1035,9 +1310,10 @@ export default class BabylonJSController {
1035
1310
 
1036
1311
  const pipelineName = "PrefViewerIblShadowsRenderPipeline";
1037
1312
 
1313
+ // Use hardware-adaptive quality settings
1038
1314
  const pipelineOptions = {
1039
- resolutionExp: 1, // Higher resolution for better shadow quality (recomended 8)
1040
- sampleDirections: 4, // More sample directions for smoother shadows (recomended 4)
1315
+ resolutionExp: this.#config.quality.iblShadowResolution, // Adaptive: 0=1024, 1=2048, 2=4096
1316
+ sampleDirections: this.#config.quality.iblSampleDirections, // Adaptive: 2=fast, 4=quality
1041
1317
  ssShadowsEnabled: true,
1042
1318
  shadowRemanence: 0.85,
1043
1319
  triPlanarVoxelization: true,
@@ -1108,6 +1384,7 @@ export default class BabylonJSController {
1108
1384
 
1109
1385
  /**
1110
1386
  * Configures soft shadows for the built-in directional light used when no HDR environment is present.
1387
+ * Uses hardware-adaptive quality settings.
1111
1388
  * @private
1112
1389
  * @returns {void}
1113
1390
  */
@@ -1116,9 +1393,12 @@ export default class BabylonJSController {
1116
1393
  return;
1117
1394
  }
1118
1395
  this.#dirLight.autoUpdateExtends = false;
1119
- const shadowGenerator = new ShadowGenerator(1024, this.#dirLight);
1396
+ // Use quality-adaptive shadow map size
1397
+ const shadowMapSize = this.#config.quality.shadowMapSize;
1398
+ const shadowGenerator = new ShadowGenerator(shadowMapSize, this.#dirLight);
1120
1399
  shadowGenerator.useBlurExponentialShadowMap = true;
1121
- shadowGenerator.blurKernel = 16;
1400
+ // Use quality-adaptive blur kernel
1401
+ shadowGenerator.blurKernel = this.#config.quality.shadowBlurKernel;
1122
1402
  shadowGenerator.darkness = 0.5;
1123
1403
  shadowGenerator.bias = 0.0005;
1124
1404
  shadowGenerator.normalBias = 0.02;
@@ -1224,6 +1504,7 @@ export default class BabylonJSController {
1224
1504
  * @returns {void}
1225
1505
  */
1226
1506
  #setMaxSimultaneousLights() {
1507
+ if (!this.#scene) return;
1227
1508
  let lightsNumber = 1; // At least one light coming from the environment texture contribution
1228
1509
  this.#scene.lights.forEach((light) => {
1229
1510
  if (light.isEnabled()) {
@@ -1498,6 +1779,7 @@ export default class BabylonJSController {
1498
1779
 
1499
1780
  /**
1500
1781
  * Handles mouse wheel events on the Babylon.js canvas for zooming the camera.
1782
+ * OPTIMIZATION: Tracks activity for idle mode.
1501
1783
  * @private
1502
1784
  * @param {WheelEvent} event - The mouse wheel event.
1503
1785
  * @param {Object} pickInfo - The result of the scene pick operation (not used in this method).
@@ -1505,6 +1787,12 @@ export default class BabylonJSController {
1505
1787
  */
1506
1788
  #onMouseWheel(event, pickInfo) {
1507
1789
  event.preventDefault();
1790
+
1791
+ // OPTIMIZATION: Mark activity for idle mode
1792
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1793
+ this.#state.render.lastActivityAt = now;
1794
+ this.#state.render.isIdle = false;
1795
+
1508
1796
  const camera = this.#scene?.activeCamera;
1509
1797
  if (!camera) {
1510
1798
  return false;
@@ -1526,14 +1814,20 @@ export default class BabylonJSController {
1526
1814
  }
1527
1815
  }
1528
1816
 
1529
- /**
1817
+ /**
1530
1818
  * Handles pointer up events on the Babylon.js scene.
1819
+ * OPTIMIZATION: Tracks activity for idle mode.
1531
1820
  * @private
1532
1821
  * @param {PointerEvent} event - The pointer up event.
1533
1822
  * @param {PickInfo} pickInfo - The result of the scene pick operation.
1534
1823
  * @returns {void}
1535
1824
  */
1536
1825
  #onPointerUp(event, pickInfo) {
1826
+ // OPTIMIZATION: Mark activity for idle mode
1827
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1828
+ this.#state.render.lastActivityAt = now;
1829
+ this.#state.render.isIdle = false;
1830
+
1537
1831
  if (this.#babylonJSAnimationController) {
1538
1832
  if (event.button !== 2 || !pickInfo || !pickInfo.pickedMesh) {
1539
1833
  this.#babylonJSAnimationController.hideMenu();
@@ -1547,12 +1841,18 @@ export default class BabylonJSController {
1547
1841
 
1548
1842
  /**
1549
1843
  * Handles pointer move events on the Babylon.js scene.
1844
+ * OPTIMIZATION: Tracks activity for idle mode.
1550
1845
  * @private
1551
1846
  * @param {PointerEvent} event - The pointer move event.
1552
1847
  * @param {PickInfo|null} pickInfo - The sampled result of the scene pick operation (may be null when sampling skips a raycast).
1553
1848
  * @returns {void}
1554
1849
  */
1555
1850
  #onPointerMove(event, pickInfo) {
1851
+ // OPTIMIZATION: Mark activity for idle mode
1852
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1853
+ this.#state.render.lastActivityAt = now;
1854
+ this.#state.render.isIdle = false;
1855
+
1556
1856
  const camera = this.#scene?.activeCamera;
1557
1857
  if (camera && !camera.metadata?.locked) {
1558
1858
  this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
@@ -1917,6 +2217,12 @@ export default class BabylonJSController {
1917
2217
  container.assetContainer.dispose();
1918
2218
  container.assetContainer = null;
1919
2219
  }
2220
+ if (!this.#scene) {
2221
+ // Scene was disposed (disconnect) while async loading was in progress; discard the
2222
+ // newly loaded container so we don't leak GPU resources and avoid the null-deref below.
2223
+ newAssetContainer?.dispose();
2224
+ return false;
2225
+ }
1920
2226
  this.#scene.getEngine().releaseEffects();
1921
2227
  this.#scene.getEngine().releaseComputeEffects();
1922
2228
 
@@ -2004,6 +2310,7 @@ export default class BabylonJSController {
2004
2310
  * @returns {Promise<void>}
2005
2311
  */
2006
2312
  async #startRender() {
2313
+ if (!this.#scene) return;
2007
2314
  await this.#loadCameraDependentEffects();
2008
2315
  await this.#scene.whenReadyAsync();
2009
2316
  const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#config.render.burstFramesEnhanced : this.#config.render.burstFramesBase;
@@ -2105,6 +2412,13 @@ export default class BabylonJSController {
2105
2412
 
2106
2413
  await Promise.allSettled(promiseArray)
2107
2414
  .then(async (values) => {
2415
+ // Scene may have been disposed (disconnectedCallback) while async loading was in
2416
+ // progress. Abort cleanly: #replaceContainer already guards the GPU calls, but
2417
+ // we skip the post-load option/visibility calls too to avoid further null-derefs.
2418
+ if (!this.#scene) {
2419
+ values.forEach((result) => { result.value?.[1]?.dispose(); });
2420
+ return;
2421
+ }
2108
2422
  this.#disposeAnimationController();
2109
2423
  values.forEach((result) => {
2110
2424
  const container = result.value ? result.value[0] : null;
@@ -2405,6 +2719,20 @@ export default class BabylonJSController {
2405
2719
  this.#configureDracoCompression();
2406
2720
  this.#engine = new Engine(this.#canvas, true, { alpha: true, stencil: true, preserveDrawingBuffer: false });
2407
2721
  this.#engine.disableUniformBuffers = true;
2722
+
2723
+ // OPTIMIZATION: Detect hardware tier and apply quality settings
2724
+ const detectedTier = this.#detectHardwareTier();
2725
+ this.#applyQualitySettings(detectedTier);
2726
+
2727
+ // OPTIMIZATION: Apply hardware scaling from quality preset
2728
+ const pixelRatio = typeof window !== 'undefined' ? window.devicePixelRatio : 1;
2729
+ const baseScaling = this.#config.quality.hardwareScaling || 1;
2730
+ const scalingLevel = pixelRatio > 1 ? baseScaling * pixelRatio : baseScaling;
2731
+ this.#engine.setHardwareScalingLevel(scalingLevel);
2732
+
2733
+ // OPTIMIZATION: Initialize idle tracking
2734
+ this.#state.render.lastActivityAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2735
+
2408
2736
  this.#scene = new Scene(this.#engine);
2409
2737
 
2410
2738
  // Activate the rendering of geometry data into a G-buffer, essential for advanced effects like deferred shading,