@preference-sl/pref-viewer 2.14.0-beta.5 → 2.14.0-beta.7

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, Material, MeshBuilder, PBRMaterial, PointerEventTypes, PointLight, RenderTargetTexture, Scene, ShadowGenerator, SpotLight, SSAORenderingPipeline, Tools, UniversalCamera, Vector3, WebXRDefaultExperience, WebXRFeatureName, WebXRSessionManager, WebXRState, WhenTextureReadyAsync } from "@babylonjs/core";
1
+ import { ArcRotateCamera, AssetContainer, Camera, Color3, Color4, DefaultRenderingPipeline, DirectionalLight, Engine, FreeCamera, HDRCubeTexture, HemisphericLight, IblShadowsRenderPipeline, ImageProcessingConfiguration, LoadAssetContainerAsync, Material, MeshBuilder, PBRMaterial, PointerEventTypes, PointLight, RenderTargetTexture, Scene, ShadowGenerator, SpotLight, SSAORenderingPipeline, Texture, Tools, UniversalCamera, Vector3, WebXRDefaultExperience, WebXRFeatureName, WebXRSessionManager, WebXRState, WhenTextureReadyAsync } from "@babylonjs/core";
2
2
  import { DracoCompression } from "@babylonjs/core/Meshes/Compression/dracoCompression.js";
3
3
  import "@babylonjs/loaders";
4
4
  import "@babylonjs/loaders/glTF/2.0/Extensions/KHR_draco_mesh_compression.js";
@@ -11,6 +11,74 @@ import BabylonJSAnimationController from "./babylonjs-animation-controller.js";
11
11
  import OpeningAnimation from "./babylonjs-animation-opening.js";
12
12
  import { translate } from "./localization/i18n.js";
13
13
 
14
+ export function getAdaptiveHardwareScalingLevel(baseScaling = 1, pixelRatio = 1) {
15
+ const safeBaseScaling = Number.isFinite(baseScaling) && baseScaling > 0 ? baseScaling : 1;
16
+ const safePixelRatio = Number.isFinite(pixelRatio) && pixelRatio > 0 ? pixelRatio : 1;
17
+ return safeBaseScaling / safePixelRatio;
18
+ }
19
+
20
+ function clamp01(value) {
21
+ if (!Number.isFinite(value)) {
22
+ return 0.5;
23
+ }
24
+ return Math.min(1, Math.max(0, value));
25
+ }
26
+
27
+ function lerp(from, to, amount) {
28
+ const t = clamp01(amount);
29
+ return from + (to - from) * t;
30
+ }
31
+
32
+ function smoothstep(edge0, edge1, x) {
33
+ if (edge0 === edge1) {
34
+ return x >= edge1 ? 1 : 0;
35
+ }
36
+ const t = clamp01((x - edge0) / (edge1 - edge0));
37
+ return t * t * (3 - 2 * t);
38
+ }
39
+
40
+ function bellCurve(x) {
41
+ const clamped = clamp01(x);
42
+ return Math.sin(clamped * Math.PI);
43
+ }
44
+
45
+ function normalizeDirection(x, y, z) {
46
+ const length = Math.hypot(x, y, z) || 1;
47
+ return {
48
+ x: x / length,
49
+ y: y / length,
50
+ z: z / length,
51
+ };
52
+ }
53
+
54
+ export function getLightingCycleProfile(timeOfDay = 0.5) {
55
+ const normalizedTime = clamp01(timeOfDay);
56
+ const timeHours = normalizedTime * 24;
57
+ const sunriseHour = 6.25;
58
+ const sunsetHour = 20.25;
59
+ const daylightProgress = clamp01((timeHours - sunriseHour) / (sunsetHour - sunriseHour));
60
+ const dayFactor = timeHours >= sunriseHour && timeHours <= sunsetHour ? Math.pow(bellCurve(daylightProgress), 0.88) : 0;
61
+ const nightFactor = 1 - dayFactor;
62
+ const solarArc = dayFactor > 0 ? Math.pow(dayFactor, 0.72) : 0;
63
+ const solarAzimuth = lerp(-Math.PI * 0.82, Math.PI * 0.82, daylightProgress);
64
+ const solarElevation = lerp(-0.18, 1.18, solarArc);
65
+ const sunPhase = normalizedTime * Math.PI * 2 - Math.PI / 2;
66
+ const sunDirection = normalizeDirection(-Math.cos(sunPhase), -Math.sin(sunPhase), -0.35);
67
+ const moonDirection = normalizeDirection(-sunDirection.x, -sunDirection.y, -sunDirection.z);
68
+
69
+ return {
70
+ timeOfDay: normalizedTime,
71
+ timeHours,
72
+ sunPhase,
73
+ solarAzimuth,
74
+ solarElevation,
75
+ dayFactor,
76
+ nightFactor,
77
+ sunDirection,
78
+ moonDirection,
79
+ };
80
+ }
81
+
14
82
  /**
15
83
  * BabylonJSController coordinates the PrefViewer 3D runtime: it bootstraps Babylon.js, manages asset containers,
16
84
  * rebuilds camera-dependent pipelines once textures are ready, brokers XR/download interactions, and persists
@@ -85,6 +153,7 @@ export default class BabylonJSController {
85
153
  ambientOcclusionEnabled: true,
86
154
  iblEnabled: true,
87
155
  shadowsEnabled: false,
156
+ lightingTimeOfDay: 0.5,
88
157
  // Highlight settings
89
158
  highlightEnabled: true,
90
159
  highlightColor: "#ff6700",
@@ -103,6 +172,7 @@ export default class BabylonJSController {
103
172
  #camera = null;
104
173
  #hemiLight = null;
105
174
  #dirLight = null;
175
+ #moonLight = null;
106
176
  #cameraLight = null;
107
177
  #shadowGen = [];
108
178
  #XRExperience = null;
@@ -114,7 +184,6 @@ export default class BabylonJSController {
114
184
  #options = {};
115
185
 
116
186
  #gltfResolver = null; // GLTFResolver instance
117
- #loadGeneration = 0; // incremented per #loadContainers call to discard stale deferred results
118
187
  #babylonJSAnimationController = null; // AnimationController instance
119
188
 
120
189
  #renderPipelines = {
@@ -187,6 +256,18 @@ export default class BabylonJSController {
187
256
  shadowBlurKernel: 16, // Shadow blur amount
188
257
  ssaoEnabled: true, // Screen Space Ambient Occlusion
189
258
  },
259
+ look: {
260
+ showroom: {
261
+ exposure: 0.92,
262
+ contrast: 1.02,
263
+ toneMappingEnabled: true,
264
+ toneMappingType: ImageProcessingConfiguration?.TONEMAPPING_ACES ?? 0,
265
+ environmentIntensity: 1.08,
266
+ hemiLightIntensity: 0.52,
267
+ dirLightIntensity: 0.56,
268
+ cameraLightIntensity: 0.16,
269
+ },
270
+ },
190
271
  };
191
272
 
192
273
  /**
@@ -201,6 +282,7 @@ export default class BabylonJSController {
201
282
 
202
283
  const debugInfo = gl?.getExtension('WEBGL_debug_renderer_info');
203
284
  const rendererName = debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : '';
285
+ const isAngleRenderer = /^ANGLE\s*\(/i.test(rendererName);
204
286
 
205
287
  // If we have WebGL renderer info and it looks like a desktop GPU, skip mobile detection
206
288
  // Desktop GPUs typically have "NVIDIA", "AMD", "Intel" in the name
@@ -211,6 +293,10 @@ export default class BabylonJSController {
211
293
 
212
294
  // If we detect a desktop GPU, go directly to desktop detection
213
295
  if (isDesktopGPU && !isMobileGPU) {
296
+ if (isAngleRenderer) {
297
+ console.log(`[PrefViewer] ANGLE-backed renderer detected: ${rendererName}`);
298
+ return this.#detectDesktopTier(gl, rendererName, true);
299
+ }
214
300
  console.log(`[PrefViewer] Desktop GPU detected: ${rendererName}`);
215
301
  return this.#detectDesktopTier(gl, rendererName);
216
302
  }
@@ -247,7 +333,7 @@ export default class BabylonJSController {
247
333
  * @param {string} rendererName - GPU renderer name
248
334
  * @returns {string} Hardware tier
249
335
  */
250
- #detectDesktopTier(gl, rendererName) {
336
+ #detectDesktopTier(gl, rendererName, conservative = false) {
251
337
  // Check WebGL capabilities
252
338
  const maxTextureSize = gl?.getParameter(gl.MAX_TEXTURE_SIZE) || 8192;
253
339
 
@@ -257,12 +343,14 @@ export default class BabylonJSController {
257
343
  const isAmdHighEnd = /RX (6|7|8|9)[0-9]{2,}|RX Vega|Radeon Pro|WX [0-9]/i.test(rendererName);
258
344
  const isIntelIntegrated = /Intel.*UHD|Intel.*Iris|Mesa|llvmpipe/i.test(rendererName);
259
345
 
260
- let tier = 'high'; // Default to high for desktop
346
+ let tier = conservative ? 'medium' : 'high'; // Default to conservative desktop when ANGLE is in the path
261
347
 
262
348
  if (isIntelIntegrated || maxTextureSize < 8192) {
263
349
  tier = 'medium';
264
- } else if (isAppleSilicon || isNvidiaHighEnd || isAmdHighEnd || maxTextureSize >= 16384) {
350
+ } else if (!conservative && (isAppleSilicon || isNvidiaHighEnd || isAmdHighEnd || maxTextureSize >= 16384)) {
265
351
  tier = 'ultra';
352
+ } else if (conservative && maxTextureSize >= 16384) {
353
+ tier = 'high';
266
354
  }
267
355
 
268
356
  console.log(`[PrefViewer] Desktop tier: ${tier} (GPU: ${rendererName || 'unknown'}, MaxTexture: ${maxTextureSize})`);
@@ -365,6 +453,7 @@ export default class BabylonJSController {
365
453
  antialiasingEnabled: false,
366
454
  hardwareScaling: 2.5, // Aggressive downscaling
367
455
  maxModelResolution: 512, // Low-res model loading
456
+ environmentTextureSize: 512,
368
457
  },
369
458
  // Mobile high-end / Tablet / Desktop low
370
459
  medium: {
@@ -376,28 +465,31 @@ export default class BabylonJSController {
376
465
  antialiasingEnabled: true,
377
466
  hardwareScaling: 2.0,
378
467
  maxModelResolution: 1024,
468
+ environmentTextureSize: 1024,
379
469
  },
380
470
  // Desktop mid-range / High-end mobile
381
471
  high: {
382
472
  shadowMapSize: 1024,
383
473
  iblShadowResolution: 0, // 1024x1024 - good balance
384
474
  iblSampleDirections: 4, // Quality sampling
385
- shadowBlurKernel: 16, // Smooth shadows
386
- ssaoEnabled: true,
475
+ shadowBlurKernel: 8, // Sharper shadows for product clarity
476
+ ssaoEnabled: false,
387
477
  antialiasingEnabled: true,
388
- hardwareScaling: 1.5,
478
+ hardwareScaling: 1.0,
389
479
  maxModelResolution: 2048,
480
+ environmentTextureSize: 2048,
390
481
  },
391
482
  // Desktop high-end (RTX, Apple Silicon, etc.)
392
483
  ultra: {
393
484
  shadowMapSize: 2048,
394
485
  iblShadowResolution: 1, // 2048x2048
395
486
  iblSampleDirections: 8, // Maximum quality
396
- shadowBlurKernel: 32, // Ultra smooth
397
- ssaoEnabled: true,
487
+ shadowBlurKernel: 16, // Preserve clarity over softness
488
+ ssaoEnabled: false,
398
489
  antialiasingEnabled: true,
399
490
  hardwareScaling: 1.0, // Native resolution
400
491
  maxModelResolution: 4096,
492
+ environmentTextureSize: 4096,
401
493
  },
402
494
  };
403
495
 
@@ -510,6 +602,11 @@ export default class BabylonJSController {
510
602
  this.#settings[key] = settings[key];
511
603
  changed = true;
512
604
  }
605
+ // Handle numeric settings such as lightingTimeOfDay
606
+ if (typeof settings[key] === "number" && Number.isFinite(settings[key]) && this.#settings[key] !== settings[key]) {
607
+ this.#settings[key] = settings[key];
608
+ changed = true;
609
+ }
513
610
  });
514
611
 
515
612
  if (changed) {
@@ -544,6 +641,10 @@ export default class BabylonJSController {
544
641
  if (typeof parsed?.[key] === "string") {
545
642
  this.#settings[key] = parsed[key];
546
643
  }
644
+ // Handle numeric settings such as lightingTimeOfDay
645
+ if (typeof parsed?.[key] === "number" && Number.isFinite(parsed[key])) {
646
+ this.#settings[key] = parsed[key];
647
+ }
547
648
  });
548
649
  } catch (error) {
549
650
  console.warn("PrefViewer: unable to load render settings", error);
@@ -896,6 +997,7 @@ export default class BabylonJSController {
896
997
  this.#camera.lowerRadiusLimit = 5;
897
998
  this.#camera.upperRadiusLimit = 20;
898
999
  this.#camera.metadata = { locked: false };
1000
+ this.#applyShowroomCameraFraming(this.#camera);
899
1001
  this.#camera.attachControl(this.#canvas, true);
900
1002
  this.#scene.activeCamera = this.#camera;
901
1003
  }
@@ -917,25 +1019,11 @@ export default class BabylonJSController {
917
1019
  const cameraLightName = "PrefViewerCameraLight";
918
1020
  const dirLightName = "PrefViewerDirLight";
919
1021
 
920
- const hemiLight = this.#scene.getLightByName(hemiLightName);
921
- const cameraLight = this.#scene.getLightByName(cameraLightName);
922
- const dirLight = this.#scene.getLightByName(dirLightName);
923
-
924
1022
  let lightsChanged = false;
925
1023
 
926
1024
  const iblEnabled = this.#settings.iblEnabled && (this.#options.ibl?.valid === true || !!this.#options.ibl?.cachedUrl);
927
1025
 
928
1026
  if (iblEnabled) {
929
- if (hemiLight) {
930
- hemiLight.dispose();
931
- }
932
- if (cameraLight) {
933
- cameraLight.dispose();
934
- }
935
- if (dirLight) {
936
- dirLight.dispose();
937
- }
938
- this.#hemiLight = this.#dirLight = this.#cameraLight = null;
939
1027
  lightsChanged = await this.#initializeEnvironmentTexture();
940
1028
  } else {
941
1029
  // If IBL is disabled but an environment texture exists, dispose it to save resources and ensure it doesn't affect the lighting
@@ -944,27 +1032,22 @@ export default class BabylonJSController {
944
1032
  this.#scene.environmentTexture = null;
945
1033
  lightsChanged = true;
946
1034
  }
947
-
948
- // Add a hemispheric light for basic ambient illumination
949
- if (!this.#hemiLight) {
950
- this.#hemiLight = new HemisphericLight(hemiLightName, new Vector3(-10, 10, -10), this.#scene);
951
- this.#hemiLight.intensity = 0.6;
952
- }
953
-
954
- // Add a directional light to cast shadows and provide stronger directional illumination
955
- if (!this.#dirLight) {
956
- this.#dirLight = new DirectionalLight(dirLightName, new Vector3(-10, 10, -10), this.#scene);
957
- this.#dirLight.position = new Vector3(5, 4, 5); // light is IN FRONT + ABOVE + to the RIGHT
958
- this.#dirLight.intensity = 0.6;
959
- }
960
-
961
- // Add a point light that follows the camera to ensure the model is always well-lit from the viewer's perspective
962
- if (!this.#cameraLight) {
963
- this.#cameraLight = new PointLight(cameraLightName, this.#camera.position, this.#scene);
964
- this.#cameraLight.parent = this.#camera;
965
- this.#cameraLight.intensity = 0.3;
966
- }
967
1035
  }
1036
+ if (!this.#hemiLight) {
1037
+ this.#hemiLight = new HemisphericLight(hemiLightName, new Vector3(-10, 10, -10), this.#scene);
1038
+ }
1039
+ if (!this.#dirLight) {
1040
+ this.#dirLight = new DirectionalLight(dirLightName, new Vector3(-10, 10, -10), this.#scene);
1041
+ this.#dirLight.position = new Vector3(5, 4, 5);
1042
+ }
1043
+ if (!this.#moonLight) {
1044
+ this.#moonLight = new DirectionalLight("PrefViewerMoonLight", new Vector3(10, -10, 8), this.#scene);
1045
+ }
1046
+ if (!this.#cameraLight) {
1047
+ this.#cameraLight = new PointLight(cameraLightName, this.#camera.position, this.#scene);
1048
+ this.#cameraLight.parent = this.#camera;
1049
+ }
1050
+ lightsChanged = this.#applyLightingConfig() || lightsChanged;
968
1051
  return lightsChanged;
969
1052
  }
970
1053
 
@@ -1141,34 +1224,13 @@ export default class BabylonJSController {
1141
1224
  const caps = this.#scene.getEngine()?.getCaps?.() || {};
1142
1225
  const maxSamples = typeof caps.maxMSAASamples === "number" ? caps.maxMSAASamples : 4;
1143
1226
  defaultPipeline.samples = Math.max(1, Math.min(8, maxSamples));
1144
- // FXAA - Fast Approximate Anti-Aliasing
1145
- defaultPipeline.fxaaEnabled = true;
1146
- defaultPipeline.fxaa.samples = 8;
1147
- defaultPipeline.fxaa.adaptScaleToCurrentViewport = true;
1148
- if (defaultPipeline.fxaa.edgeThreshold !== undefined) {
1149
- defaultPipeline.fxaa.edgeThreshold = 0.125;
1150
- }
1151
- if (defaultPipeline.fxaa.edgeThresholdMin !== undefined) {
1152
- defaultPipeline.fxaa.edgeThresholdMin = 0.0625;
1153
- }
1154
- if (defaultPipeline.fxaa.subPixelQuality !== undefined) {
1155
- defaultPipeline.fxaa.subPixelQuality = 0.75;
1156
- }
1227
+ // FXAA can soften product surfaces too much; keep MSAA only for crisper materials.
1228
+ defaultPipeline.fxaaEnabled = false;
1157
1229
 
1158
- // Grain
1159
- defaultPipeline.grainEnabled = true;
1160
- defaultPipeline.grain.adaptScaleToCurrentViewport = true;
1161
- defaultPipeline.grain.animated = false;
1162
- defaultPipeline.grain.intensity = 3;
1230
+ // Grain is disabled to keep product surfaces clean and avoid visible noise.
1231
+ defaultPipeline.grainEnabled = false;
1163
1232
 
1164
1233
  // Configure post-processes to calculate only once instead of every frame for better performance
1165
- if (defaultPipeline.fxaa?._postProcess) {
1166
- defaultPipeline.fxaa._postProcess.autoClear = false;
1167
- }
1168
- if (defaultPipeline.grain?._postProcess) {
1169
- defaultPipeline.grain._postProcess.autoClear = false;
1170
- }
1171
-
1172
1234
  this.#renderPipelines.default = defaultPipeline;
1173
1235
  pipelineManager.update();
1174
1236
  return true;
@@ -1202,7 +1264,8 @@ export default class BabylonJSController {
1202
1264
  let hdrTexture = null;
1203
1265
  if (this.#options.ibl?.cachedUrl) {
1204
1266
  const hdrTextureURI = this.#options.ibl.cachedUrl;
1205
- hdrTexture = new HDRCubeTexture(hdrTextureURI, this.#scene, 1024, false, false, false, true, undefined, undefined, false, true, true);
1267
+ const envTextureSize = this.#config.quality.environmentTextureSize || 1024;
1268
+ hdrTexture = new HDRCubeTexture(hdrTextureURI, this.#scene, envTextureSize, false, false, false, true, undefined, undefined, false, true, true);
1206
1269
  } else if (this.#hdrTexture && this.#options.ibl?.valid === true) {
1207
1270
  hdrTexture = this.#hdrTexture.clone();
1208
1271
  } else {
@@ -1219,8 +1282,10 @@ export default class BabylonJSController {
1219
1282
  this.#options.ibl?.consumeCachedUrl?.(true);
1220
1283
  }
1221
1284
 
1222
- hdrTexture.level = this.#options.ibl.intensity;
1285
+ hdrTexture.level = this.#options.ibl.intensity ?? 1;
1223
1286
  this.#scene.environmentTexture = hdrTexture;
1287
+ this.#applyShowroomLook();
1288
+ this.#applyLightingConfig();
1224
1289
  this.#scene.markAllMaterialsAsDirty(Material.TextureDirtyFlag);
1225
1290
  return true;
1226
1291
  }
@@ -1306,7 +1371,7 @@ export default class BabylonJSController {
1306
1371
  });
1307
1372
  const materialsForReceivingShadows = this.#scene.materials.filter((material) => {
1308
1373
  if (material instanceof PBRMaterial) {
1309
- material.enableSpecularAntiAliasing = false;
1374
+ material.enableSpecularAntiAliasing = true;
1310
1375
  }
1311
1376
  return true;
1312
1377
  });
@@ -1531,6 +1596,184 @@ export default class BabylonJSController {
1531
1596
  }
1532
1597
  }
1533
1598
 
1599
+ /**
1600
+ * Improves texture sharpness by enabling trilinear filtering and anisotropy.
1601
+ * @private
1602
+ * @returns {void}
1603
+ */
1604
+ #enhanceTextureQuality() {
1605
+ if (!this.#scene) {
1606
+ return;
1607
+ }
1608
+
1609
+ const caps = this.#scene.getEngine()?.getCaps?.() || {};
1610
+ const maxAnisotropy = Math.max(1, Math.min(16, caps.maxAnisotropy || 1));
1611
+
1612
+ this.#scene.textures?.forEach((texture) => {
1613
+ if (!texture || texture.isDisposed?.()) {
1614
+ return;
1615
+ }
1616
+
1617
+ if ("anisotropicFilteringLevel" in texture) {
1618
+ texture.anisotropicFilteringLevel = maxAnisotropy;
1619
+ }
1620
+
1621
+ if (typeof texture.updateSamplingMode === "function") {
1622
+ try {
1623
+ texture.updateSamplingMode(Texture.TRILINEAR_SAMPLINGMODE);
1624
+ } catch {
1625
+ // Ignore texture subclasses that do not support sampling changes.
1626
+ }
1627
+ }
1628
+ });
1629
+ }
1630
+
1631
+ /**
1632
+ * Applies the showroom tonemapping curve and lighting bias used by the 3D viewer.
1633
+ * @private
1634
+ * @returns {void}
1635
+ */
1636
+ #applyShowroomLook() {
1637
+ if (!this.#scene?.imageProcessingConfiguration) {
1638
+ return;
1639
+ }
1640
+
1641
+ const showroom = this.#config.look?.showroom || {};
1642
+ const imageProcessing = this.#scene.imageProcessingConfiguration;
1643
+
1644
+ this.#scene.clearColor = new Color4(0.98, 0.98, 0.98, 1).toLinearSpace();
1645
+ imageProcessing.exposure = showroom.exposure ?? 0.95;
1646
+ imageProcessing.contrast = showroom.contrast ?? 1.08;
1647
+ imageProcessing.toneMappingEnabled = showroom.toneMappingEnabled ?? true;
1648
+ imageProcessing.toneMappingType = showroom.toneMappingType ?? (ImageProcessingConfiguration?.TONEMAPPING_ACES ?? 0);
1649
+ imageProcessing.vignetteEnabled = false;
1650
+ imageProcessing.colorCurvesEnabled = false;
1651
+
1652
+ if ("environmentIntensity" in this.#scene) {
1653
+ this.#scene.environmentIntensity = showroom.environmentIntensity ?? 1.15;
1654
+ }
1655
+ }
1656
+
1657
+ /**
1658
+ * Applies the current time-of-day cycle to the scene lighting, environment intensity, and tonemapping bias.
1659
+ * @private
1660
+ * @returns {boolean} True when a scene existed and was updated.
1661
+ */
1662
+ #applyLightingConfig() {
1663
+ if (!this.#scene) {
1664
+ return false;
1665
+ }
1666
+
1667
+ const showroom = this.#config.look?.showroom || {};
1668
+ const timeOfDay = clamp01(this.#settings.lightingTimeOfDay ?? 0.5);
1669
+ const profile = getLightingCycleProfile(timeOfDay);
1670
+ const daylight = profile.dayFactor;
1671
+ const peakLight = daylight > 0 ? Math.pow(daylight, 0.72) : 0;
1672
+ const twilight = 1 - peakLight;
1673
+
1674
+ if (this.#scene.imageProcessingConfiguration) {
1675
+ const imageProcessing = this.#scene.imageProcessingConfiguration;
1676
+ imageProcessing.exposure = lerp(0.78, showroom.exposure ?? 0.92, peakLight);
1677
+ imageProcessing.contrast = lerp(0.98, showroom.contrast ?? 1.02, peakLight);
1678
+ imageProcessing.toneMappingEnabled = showroom.toneMappingEnabled ?? true;
1679
+ imageProcessing.toneMappingType = showroom.toneMappingType ?? (ImageProcessingConfiguration?.TONEMAPPING_ACES ?? 0);
1680
+ imageProcessing.vignetteEnabled = false;
1681
+ imageProcessing.colorCurvesEnabled = false;
1682
+ }
1683
+
1684
+ this.#scene.clearColor = new Color4(
1685
+ lerp(0.035, 0.98, daylight),
1686
+ lerp(0.045, 0.98, daylight),
1687
+ lerp(0.085, 0.98, daylight),
1688
+ 1,
1689
+ ).toLinearSpace();
1690
+
1691
+ if ("environmentIntensity" in this.#scene) {
1692
+ this.#scene.environmentIntensity = lerp(0.42, showroom.environmentIntensity ?? 1.08, peakLight);
1693
+ }
1694
+
1695
+ if (this.#hemiLight) {
1696
+ this.#hemiLight.direction = new Vector3(
1697
+ lerp(-0.45, -0.8, peakLight),
1698
+ lerp(0.45, 0.95, peakLight),
1699
+ lerp(-0.75, -0.35, peakLight),
1700
+ );
1701
+ this.#hemiLight.intensity = lerp(0.18, showroom.hemiLightIntensity ?? 0.52, peakLight);
1702
+ this.#hemiLight.diffuse = new Color3(
1703
+ lerp(0.42, 0.92, peakLight),
1704
+ lerp(0.48, 0.96, peakLight),
1705
+ lerp(0.62, 0.98, peakLight),
1706
+ );
1707
+ this.#hemiLight.groundColor = new Color3(
1708
+ lerp(0.08, 0.3, twilight),
1709
+ lerp(0.08, 0.28, twilight),
1710
+ lerp(0.1, 0.22, twilight),
1711
+ );
1712
+ }
1713
+
1714
+ if (this.#dirLight) {
1715
+ this.#dirLight.direction = new Vector3(profile.sunDirection.x, profile.sunDirection.y, profile.sunDirection.z);
1716
+ this.#dirLight.intensity = lerp(0.02, showroom.dirLightIntensity ?? 0.56, peakLight);
1717
+ this.#dirLight.diffuse = new Color3(
1718
+ lerp(0.28, 1.0, peakLight),
1719
+ lerp(0.3, 0.97, peakLight),
1720
+ lerp(0.34, 0.9, peakLight),
1721
+ );
1722
+ }
1723
+
1724
+ if (this.#moonLight) {
1725
+ this.#moonLight.direction = new Vector3(profile.moonDirection.x, profile.moonDirection.y, profile.moonDirection.z);
1726
+ this.#moonLight.intensity = lerp(0.44, 0.03, peakLight);
1727
+ this.#moonLight.diffuse = new Color3(0.58, 0.7, 1.0);
1728
+ }
1729
+
1730
+ if (this.#cameraLight) {
1731
+ this.#cameraLight.intensity = lerp(0.06, showroom.cameraLightIntensity ?? 0.16, peakLight);
1732
+ this.#cameraLight.diffuse = new Color3(
1733
+ lerp(0.35, 1.0, peakLight),
1734
+ lerp(0.35, 0.97, peakLight),
1735
+ lerp(0.36, 0.92, peakLight),
1736
+ );
1737
+ }
1738
+
1739
+ this.#scene.markAllMaterialsAsDirty?.(Material.LightDirtyFlag ?? Material.TextureDirtyFlag);
1740
+ this.#requestRender({ frames: 2, continuousMs: this.#config.render.interactionMs });
1741
+ return true;
1742
+ }
1743
+
1744
+ /**
1745
+ * Recomputes the effective hardware scaling from the current device pixel ratio.
1746
+ * This keeps the 3D output crisp when the viewport changes or the browser zoom/DPR shifts.
1747
+ * @private
1748
+ * @returns {void}
1749
+ */
1750
+ #applyViewportHardwareScaling() {
1751
+ if (!this.#engine) {
1752
+ return;
1753
+ }
1754
+
1755
+ const pixelRatio = typeof window !== "undefined" ? window.devicePixelRatio : 1;
1756
+ const baseScaling = this.#config.quality.hardwareScaling || 1;
1757
+ const scalingLevel = getAdaptiveHardwareScalingLevel(baseScaling, pixelRatio);
1758
+ this.#engine.setHardwareScalingLevel(scalingLevel);
1759
+ }
1760
+
1761
+ /**
1762
+ * Keeps the showroom camera framing stable when the viewport aspect ratio changes.
1763
+ * The 3D showroom is meant to feel compositionally stable, so we prefer a horizontal-fixed
1764
+ * field of view on ArcRotate cameras to avoid the background appearing undersized on wide layouts.
1765
+ * @private
1766
+ * @param {Camera|null|undefined} camera - Active camera to adjust.
1767
+ * @returns {void}
1768
+ */
1769
+ #applyShowroomCameraFraming(camera = this.#scene?.activeCamera) {
1770
+ if (!(camera instanceof ArcRotateCamera)) {
1771
+ return;
1772
+ }
1773
+
1774
+ camera.fovMode = Camera?.FOVMODE_HORIZONTAL_FIXED ?? 1;
1775
+ }
1776
+
1534
1777
  /**
1535
1778
  * Resets pointer-picking sampling state.
1536
1779
  * @private
@@ -1697,7 +1940,7 @@ export default class BabylonJSController {
1697
1940
  }
1698
1941
  this.#engine.dispose();
1699
1942
  this.#engine = this.#scene = this.#camera = null;
1700
- this.#hemiLight = this.#dirLight = this.#cameraLight = null;
1943
+ this.#hemiLight = this.#dirLight = this.#moonLight = this.#cameraLight = null;
1701
1944
  }
1702
1945
 
1703
1946
  /**
@@ -1932,8 +2175,11 @@ export default class BabylonJSController {
1932
2175
  }
1933
2176
  this.#state.resize.lastAppliedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1934
2177
  console.log(`PrefViewer: Applying resize after ${Math.round(elapsed)}ms`);
2178
+ this.#applyShowroomCameraFraming();
1935
2179
  this.#engine.resize();
1936
- this.#requestRender({ frames: this.#config.render.burstFramesBase, continuousMs: this.#config.render.interactionMs });
2180
+ const needsEnhancedBurst = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled || this.#settings.shadowsEnabled;
2181
+ const frames = needsEnhancedBurst ? this.#config.render.burstFramesEnhanced : this.#config.render.burstFramesBase;
2182
+ this.#requestRender({ frames, continuousMs: this.#config.render.interactionMs });
1937
2183
  };
1938
2184
 
1939
2185
  if (elapsed >= this.#config.resize.throttleMs) {
@@ -2085,6 +2331,7 @@ export default class BabylonJSController {
2085
2331
  cameraState.setSuccess(true);
2086
2332
  }
2087
2333
  }
2334
+ this.#applyShowroomCameraFraming(camera);
2088
2335
  this.#scene.activeCamera?.detachControl();
2089
2336
  camera.detachControl();
2090
2337
  if (!cameraState.locked) {
@@ -2401,51 +2648,23 @@ export default class BabylonJSController {
2401
2648
 
2402
2649
  /**
2403
2650
  * Loads all asset containers (model, environment, materials, etc.) and adds them to the scene.
2404
- *
2405
- * Uses **two-phase progressive loading** when the environment container is pending:
2406
- * Phase 1 – Load model + materials, apply them, and start the render loop so the user sees
2407
- * the configured product immediately.
2408
- * Phase 2 The environment (scene with trees, walls, floor…) was already downloading in
2409
- * parallel. Once it resolves, briefly stop rendering, splice it in, reapply
2410
- * camera/IBL/visibility options, and restart.
2411
- *
2412
- * When the environment is *not* pending (e.g. only the model changed), the original
2413
- * single-phase behaviour is preserved — all containers settle immediately and are applied
2414
- * together.
2415
- *
2416
- * A generation counter (`#loadGeneration`) guards Phase 2: if a newer `load()` call starts
2417
- * while the environment is still downloading, the stale result is disposed instead of applied.
2418
- *
2419
- * @private
2420
- * @param {boolean} [force=false] - Bypass cached size/timestamp checks.
2421
- * @returns {Promise<{success: boolean, error: any}>}
2651
+ * @private
2652
+ * @returns {Promise<{success: boolean, error: any}>} Resolves to an object indicating if loading succeeded and any error encountered.
2653
+ * @description
2654
+ * Waits for all containers to load in parallel, then replaces or adds them to the scene as needed.
2655
+ * Applies material and camera options, sets wall/floor visibility, and initializes lights and shadows.
2656
+ * Returns an object with success status and error details.
2422
2657
  */
2423
2658
  async #loadContainers(force = false) {
2424
- const generation = ++this.#loadGeneration;
2425
2659
  this.#detachAnimationChangedListener();
2426
2660
  await this.#stopRender();
2427
2661
 
2428
2662
  let oldModelMetadata = { ...(this.#containers.model?.state?.metadata ?? {}) };
2429
2663
  let newModelMetadata = {};
2430
2664
 
2431
- // Kick off ALL container loads in parallel — the heavy environment download starts now.
2432
- const allLoadPromises = new Map();
2665
+ const promiseArray = [];
2433
2666
  Object.values(this.#containers).forEach((container) => {
2434
- allLoadPromises.set(container.state.name, this.#loadAssetContainer(container, force));
2435
- });
2436
-
2437
- // When the environment is pending (heavy scene geometry) we defer it to Phase 2 so
2438
- // the model can render first. Non-pending environments resolve immediately with
2439
- // [container, false] and are handled in Phase 1 like any other container.
2440
- const environmentPending = this.#containers.environment?.state?.isPending === true;
2441
- const priorityPromises = [];
2442
- let deferredPromise = null;
2443
- allLoadPromises.forEach((promise, name) => {
2444
- if (name === "environment" && environmentPending) {
2445
- deferredPromise = promise;
2446
- } else {
2447
- priorityPromises.push(promise);
2448
- }
2667
+ promiseArray.push(this.#loadAssetContainer(container, force));
2449
2668
  });
2450
2669
 
2451
2670
  let detail = {
@@ -2453,18 +2672,13 @@ export default class BabylonJSController {
2453
2672
  error: null,
2454
2673
  };
2455
2674
 
2456
- // ── Phase 1: Priority containers (model + materials) ────────────────────
2457
- await Promise.allSettled(priorityPromises)
2675
+ await Promise.allSettled(promiseArray)
2458
2676
  .then(async (values) => {
2459
2677
  // Scene may have been disposed (disconnectedCallback) while async loading was in
2460
2678
  // progress. Abort cleanly: #replaceContainer already guards the GPU calls, but
2461
2679
  // we skip the post-load option/visibility calls too to avoid further null-derefs.
2462
2680
  if (!this.#scene) {
2463
2681
  values.forEach((result) => { result.value?.[1]?.dispose(); });
2464
- if (deferredPromise) {
2465
- deferredPromise.then((r) => { r?.[1]?.dispose?.(); }).catch(() => {});
2466
- deferredPromise = null;
2467
- }
2468
2682
  return;
2469
2683
  }
2470
2684
  this.#disposeAnimationController();
@@ -2503,11 +2717,9 @@ export default class BabylonJSController {
2503
2717
  detail.error = error;
2504
2718
  })
2505
2719
  .finally(async () => {
2506
- if (!deferredPromise) {
2507
- // No deferred work — single-phase path (original behaviour).
2508
- this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
2509
- }
2720
+ this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
2510
2721
  this.#setMaxSimultaneousLights();
2722
+ this.#enhanceTextureQuality();
2511
2723
  this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
2512
2724
  // Apply stored highlight settings so fresh page loads respect persisted state
2513
2725
  this.#setHighlightConfig({
@@ -2519,48 +2731,6 @@ export default class BabylonJSController {
2519
2731
  }
2520
2732
  await this.#startRender();
2521
2733
  });
2522
-
2523
- // ── Phase 2: Deferred environment (already downloading in parallel) ─────
2524
- if (deferredPromise) {
2525
- let deferredResult;
2526
- try {
2527
- deferredResult = await deferredPromise;
2528
- } catch (error) {
2529
- console.error("PrefViewer: failed to load environment progressively", error);
2530
- deferredResult = [this.#containers.environment, null];
2531
- }
2532
-
2533
- // A newer load() was triggered while we waited — discard stale results.
2534
- if (this.#loadGeneration !== generation) {
2535
- deferredResult?.[1]?.dispose?.();
2536
- return detail;
2537
- }
2538
-
2539
- if (this.#scene) {
2540
- const [container, assetContainer] = deferredResult;
2541
- if (assetContainer) {
2542
- this.#detachAnimationChangedListener();
2543
- await this.#stopRender();
2544
- this.#assetContainer_retagCameras(assetContainer);
2545
- this.#replaceContainer(container, assetContainer);
2546
- container.state.setSuccess(true);
2547
- this.#setOptions_Camera();
2548
- await this.#setOptions_IBL();
2549
- this.#setVisibilityOfWallAndFloorInModel();
2550
- this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
2551
- this.#setMaxSimultaneousLights();
2552
- this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
2553
- if (this.#babylonJSAnimationController?.hasAnimations?.()) {
2554
- this.#attachAnimationChangedListener();
2555
- }
2556
- await this.#startRender();
2557
- } else {
2558
- container.state.setSuccess(false);
2559
- this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
2560
- }
2561
- }
2562
- }
2563
-
2564
2734
  return detail;
2565
2735
  }
2566
2736
 
@@ -2815,19 +2985,13 @@ export default class BabylonJSController {
2815
2985
  */
2816
2986
  async enable() {
2817
2987
  this.#configureDracoCompression();
2818
- this.#engine = new Engine(this.#canvas, true, { alpha: true, stencil: true, preserveDrawingBuffer: false });
2988
+ this.#engine = new Engine(this.#canvas, true, { alpha: true, stencil: true, preserveDrawingBuffer: false }, true);
2819
2989
  this.#engine.disableUniformBuffers = true;
2820
2990
 
2821
2991
  // OPTIMIZATION: Detect hardware tier and apply quality settings
2822
2992
  const detectedTier = this.#detectHardwareTier();
2823
2993
  this.#applyQualitySettings(detectedTier);
2824
2994
 
2825
- // OPTIMIZATION: Apply hardware scaling from quality preset
2826
- const pixelRatio = typeof window !== 'undefined' ? window.devicePixelRatio : 1;
2827
- const baseScaling = this.#config.quality.hardwareScaling || 1;
2828
- const scalingLevel = pixelRatio > 1 ? baseScaling * pixelRatio : baseScaling;
2829
- this.#engine.setHardwareScalingLevel(scalingLevel);
2830
-
2831
2995
  // OPTIMIZATION: Initialize idle tracking
2832
2996
  this.#state.render.lastActivityAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2833
2997
 
@@ -2843,14 +3007,7 @@ export default class BabylonJSController {
2843
3007
  geometryBufferRenderer.generateNormalsInWorldSpace = true;
2844
3008
  }
2845
3009
 
2846
- this.#scene.clearColor = new Color4(1, 1, 1, 1).toLinearSpace();
2847
-
2848
- // Lowered exposure to prevent scenes from looking blown out when the DefaultRenderingPipeline (Antialiasing) is enabled.
2849
- this.#scene.imageProcessingConfiguration.exposure = 0.75;
2850
- this.#scene.imageProcessingConfiguration.contrast = 1.0;
2851
- this.#scene.imageProcessingConfiguration.toneMappingEnabled = false;
2852
- this.#scene.imageProcessingConfiguration.vignetteEnabled = false;
2853
- this.#scene.imageProcessingConfiguration.colorCurvesEnabled = false;
3010
+ this.#applyShowroomLook();
2854
3011
 
2855
3012
  // Skip the built-in pointer picking logic since the controller implements its own optimized raycasting for interaction.
2856
3013
  this.#scene.skipPointerMovePicking = true;
@@ -3064,6 +3221,60 @@ export default class BabylonJSController {
3064
3221
  }
3065
3222
  }
3066
3223
 
3224
+ /**
3225
+ * Applies lighting and highlight configuration without forcing a full scene reload.
3226
+ * @public
3227
+ * @param {{lightingTimeOfDay?:number, highlightColor?:string, highlightEnabled?:boolean, showAnimationMenu?:boolean}} config - Lighting/highlight configuration.
3228
+ * @returns {void}
3229
+ */
3230
+ setIlluminationConfig(config = {}) {
3231
+ let shouldApplyLighting = false;
3232
+
3233
+ if (typeof config.lightingTimeOfDay === "number" && Number.isFinite(config.lightingTimeOfDay)) {
3234
+ const normalizedTime = clamp01(config.lightingTimeOfDay);
3235
+ if (this.#settings.lightingTimeOfDay !== normalizedTime) {
3236
+ this.#settings.lightingTimeOfDay = normalizedTime;
3237
+ shouldApplyLighting = true;
3238
+ }
3239
+ }
3240
+
3241
+ if (config.showAnimationMenu !== undefined) {
3242
+ // Handle show-animation-menu via the 3D component's parent
3243
+ // This is handled in pref-viewer-3d.js attributeChangedCallback
3244
+ }
3245
+
3246
+ // Handle highlight color for animation hover
3247
+ if (config.highlightColor !== undefined || config.highlightEnabled !== undefined) {
3248
+ // Normalize highlightEnabled: accept boolean, string "true"/"false", or null
3249
+ let enabled = config.highlightEnabled;
3250
+ if (typeof enabled === "string") {
3251
+ enabled = enabled.toLowerCase() !== "false";
3252
+ } else if (enabled === null) {
3253
+ enabled = undefined;
3254
+ }
3255
+ // Keep #settings in sync so getRenderSettings() always reflects the real state
3256
+ if (enabled !== undefined) {
3257
+ this.#settings.highlightEnabled = enabled;
3258
+ }
3259
+ if (config.highlightColor !== undefined && config.highlightColor !== null) {
3260
+ this.#settings.highlightColor = config.highlightColor;
3261
+ }
3262
+ this.#setHighlightConfig({
3263
+ color: config.highlightColor ?? undefined,
3264
+ enabled,
3265
+ });
3266
+ }
3267
+
3268
+ if (shouldApplyLighting && this.#scene) {
3269
+ this.#applyLightingConfig();
3270
+ }
3271
+
3272
+ // Persist settings unless caller explicitly requests a non-persistent update (used by playback animation).
3273
+ if (config.persist !== false) {
3274
+ this.#storeRenderSettings();
3275
+ }
3276
+ }
3277
+
3067
3278
  /**
3068
3279
  * Sets the visibility of a container (model, environment, etc.) by name.
3069
3280
  * Adds or removes the container from the scene and updates wall/floor visibility.
@@ -3118,45 +3329,6 @@ export default class BabylonJSController {
3118
3329
  return await this.#loadContainers();
3119
3330
  }
3120
3331
 
3121
- /**
3122
- * Sets highlight configuration for animation hover effects.
3123
- * @public
3124
- * @param {{showAnimationMenu?:boolean, highlightColor?:string, highlightEnabled?:boolean}} config - Highlight configuration.
3125
- * @returns {void}
3126
- */
3127
- setIlluminationConfig(config = {}) {
3128
-
3129
- if (config.showAnimationMenu !== undefined) {
3130
- // Handle show-animation-menu via the 3D component's parent
3131
- // This is handled in pref-viewer-3d.js attributeChangedCallback
3132
- }
3133
-
3134
- // Handle highlight color for animation hover
3135
- if (config.highlightColor !== undefined || config.highlightEnabled !== undefined) {
3136
- // Normalize highlightEnabled: accept boolean, string "true"/"false", or null
3137
- let enabled = config.highlightEnabled;
3138
- if (typeof enabled === "string") {
3139
- enabled = enabled.toLowerCase() !== "false";
3140
- } else if (enabled === null) {
3141
- enabled = undefined;
3142
- }
3143
- // Keep #settings in sync so getRenderSettings() always reflects the real state
3144
- if (enabled !== undefined) {
3145
- this.#settings.highlightEnabled = enabled;
3146
- }
3147
- if (config.highlightColor !== undefined && config.highlightColor !== null) {
3148
- this.#settings.highlightColor = config.highlightColor;
3149
- }
3150
- this.#setHighlightConfig({
3151
- color: config.highlightColor ?? undefined,
3152
- enabled,
3153
- });
3154
- }
3155
-
3156
- // Persist settings
3157
- this.#storeRenderSettings();
3158
- }
3159
-
3160
3332
  /**
3161
3333
  * Returns a snapshot of all available animations and their current state.
3162
3334
  * @public