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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
@@ -186,6 +256,18 @@ export default class BabylonJSController {
186
256
  shadowBlurKernel: 16, // Shadow blur amount
187
257
  ssaoEnabled: true, // Screen Space Ambient Occlusion
188
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
+ },
189
271
  };
190
272
 
191
273
  /**
@@ -200,6 +282,7 @@ export default class BabylonJSController {
200
282
 
201
283
  const debugInfo = gl?.getExtension('WEBGL_debug_renderer_info');
202
284
  const rendererName = debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : '';
285
+ const isAngleRenderer = /^ANGLE\s*\(/i.test(rendererName);
203
286
 
204
287
  // If we have WebGL renderer info and it looks like a desktop GPU, skip mobile detection
205
288
  // Desktop GPUs typically have "NVIDIA", "AMD", "Intel" in the name
@@ -210,6 +293,10 @@ export default class BabylonJSController {
210
293
 
211
294
  // If we detect a desktop GPU, go directly to desktop detection
212
295
  if (isDesktopGPU && !isMobileGPU) {
296
+ if (isAngleRenderer) {
297
+ console.log(`[PrefViewer] ANGLE-backed renderer detected: ${rendererName}`);
298
+ return this.#detectDesktopTier(gl, rendererName, true);
299
+ }
213
300
  console.log(`[PrefViewer] Desktop GPU detected: ${rendererName}`);
214
301
  return this.#detectDesktopTier(gl, rendererName);
215
302
  }
@@ -246,7 +333,7 @@ export default class BabylonJSController {
246
333
  * @param {string} rendererName - GPU renderer name
247
334
  * @returns {string} Hardware tier
248
335
  */
249
- #detectDesktopTier(gl, rendererName) {
336
+ #detectDesktopTier(gl, rendererName, conservative = false) {
250
337
  // Check WebGL capabilities
251
338
  const maxTextureSize = gl?.getParameter(gl.MAX_TEXTURE_SIZE) || 8192;
252
339
 
@@ -256,12 +343,14 @@ export default class BabylonJSController {
256
343
  const isAmdHighEnd = /RX (6|7|8|9)[0-9]{2,}|RX Vega|Radeon Pro|WX [0-9]/i.test(rendererName);
257
344
  const isIntelIntegrated = /Intel.*UHD|Intel.*Iris|Mesa|llvmpipe/i.test(rendererName);
258
345
 
259
- let tier = 'high'; // Default to high for desktop
346
+ let tier = conservative ? 'medium' : 'high'; // Default to conservative desktop when ANGLE is in the path
260
347
 
261
348
  if (isIntelIntegrated || maxTextureSize < 8192) {
262
349
  tier = 'medium';
263
- } else if (isAppleSilicon || isNvidiaHighEnd || isAmdHighEnd || maxTextureSize >= 16384) {
350
+ } else if (!conservative && (isAppleSilicon || isNvidiaHighEnd || isAmdHighEnd || maxTextureSize >= 16384)) {
264
351
  tier = 'ultra';
352
+ } else if (conservative && maxTextureSize >= 16384) {
353
+ tier = 'high';
265
354
  }
266
355
 
267
356
  console.log(`[PrefViewer] Desktop tier: ${tier} (GPU: ${rendererName || 'unknown'}, MaxTexture: ${maxTextureSize})`);
@@ -364,6 +453,7 @@ export default class BabylonJSController {
364
453
  antialiasingEnabled: false,
365
454
  hardwareScaling: 2.5, // Aggressive downscaling
366
455
  maxModelResolution: 512, // Low-res model loading
456
+ environmentTextureSize: 512,
367
457
  },
368
458
  // Mobile high-end / Tablet / Desktop low
369
459
  medium: {
@@ -375,28 +465,31 @@ export default class BabylonJSController {
375
465
  antialiasingEnabled: true,
376
466
  hardwareScaling: 2.0,
377
467
  maxModelResolution: 1024,
468
+ environmentTextureSize: 1024,
378
469
  },
379
470
  // Desktop mid-range / High-end mobile
380
471
  high: {
381
472
  shadowMapSize: 1024,
382
473
  iblShadowResolution: 0, // 1024x1024 - good balance
383
474
  iblSampleDirections: 4, // Quality sampling
384
- shadowBlurKernel: 16, // Smooth shadows
385
- ssaoEnabled: true,
475
+ shadowBlurKernel: 8, // Sharper shadows for product clarity
476
+ ssaoEnabled: false,
386
477
  antialiasingEnabled: true,
387
- hardwareScaling: 1.5,
478
+ hardwareScaling: 1.0,
388
479
  maxModelResolution: 2048,
480
+ environmentTextureSize: 2048,
389
481
  },
390
482
  // Desktop high-end (RTX, Apple Silicon, etc.)
391
483
  ultra: {
392
484
  shadowMapSize: 2048,
393
485
  iblShadowResolution: 1, // 2048x2048
394
486
  iblSampleDirections: 8, // Maximum quality
395
- shadowBlurKernel: 32, // Ultra smooth
396
- ssaoEnabled: true,
487
+ shadowBlurKernel: 16, // Preserve clarity over softness
488
+ ssaoEnabled: false,
397
489
  antialiasingEnabled: true,
398
490
  hardwareScaling: 1.0, // Native resolution
399
491
  maxModelResolution: 4096,
492
+ environmentTextureSize: 4096,
400
493
  },
401
494
  };
402
495
 
@@ -509,6 +602,11 @@ export default class BabylonJSController {
509
602
  this.#settings[key] = settings[key];
510
603
  changed = true;
511
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
+ }
512
610
  });
513
611
 
514
612
  if (changed) {
@@ -543,6 +641,10 @@ export default class BabylonJSController {
543
641
  if (typeof parsed?.[key] === "string") {
544
642
  this.#settings[key] = parsed[key];
545
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
+ }
546
648
  });
547
649
  } catch (error) {
548
650
  console.warn("PrefViewer: unable to load render settings", error);
@@ -895,6 +997,7 @@ export default class BabylonJSController {
895
997
  this.#camera.lowerRadiusLimit = 5;
896
998
  this.#camera.upperRadiusLimit = 20;
897
999
  this.#camera.metadata = { locked: false };
1000
+ this.#applyShowroomCameraFraming(this.#camera);
898
1001
  this.#camera.attachControl(this.#canvas, true);
899
1002
  this.#scene.activeCamera = this.#camera;
900
1003
  }
@@ -916,25 +1019,11 @@ export default class BabylonJSController {
916
1019
  const cameraLightName = "PrefViewerCameraLight";
917
1020
  const dirLightName = "PrefViewerDirLight";
918
1021
 
919
- const hemiLight = this.#scene.getLightByName(hemiLightName);
920
- const cameraLight = this.#scene.getLightByName(cameraLightName);
921
- const dirLight = this.#scene.getLightByName(dirLightName);
922
-
923
1022
  let lightsChanged = false;
924
1023
 
925
1024
  const iblEnabled = this.#settings.iblEnabled && (this.#options.ibl?.valid === true || !!this.#options.ibl?.cachedUrl);
926
1025
 
927
1026
  if (iblEnabled) {
928
- if (hemiLight) {
929
- hemiLight.dispose();
930
- }
931
- if (cameraLight) {
932
- cameraLight.dispose();
933
- }
934
- if (dirLight) {
935
- dirLight.dispose();
936
- }
937
- this.#hemiLight = this.#dirLight = this.#cameraLight = null;
938
1027
  lightsChanged = await this.#initializeEnvironmentTexture();
939
1028
  } else {
940
1029
  // If IBL is disabled but an environment texture exists, dispose it to save resources and ensure it doesn't affect the lighting
@@ -943,27 +1032,22 @@ export default class BabylonJSController {
943
1032
  this.#scene.environmentTexture = null;
944
1033
  lightsChanged = true;
945
1034
  }
946
-
947
- // Add a hemispheric light for basic ambient illumination
948
- if (!this.#hemiLight) {
949
- this.#hemiLight = new HemisphericLight(hemiLightName, new Vector3(-10, 10, -10), this.#scene);
950
- this.#hemiLight.intensity = 0.6;
951
- }
952
-
953
- // Add a directional light to cast shadows and provide stronger directional illumination
954
- if (!this.#dirLight) {
955
- this.#dirLight = new DirectionalLight(dirLightName, new Vector3(-10, 10, -10), this.#scene);
956
- this.#dirLight.position = new Vector3(5, 4, 5); // light is IN FRONT + ABOVE + to the RIGHT
957
- this.#dirLight.intensity = 0.6;
958
- }
959
-
960
- // Add a point light that follows the camera to ensure the model is always well-lit from the viewer's perspective
961
- if (!this.#cameraLight) {
962
- this.#cameraLight = new PointLight(cameraLightName, this.#camera.position, this.#scene);
963
- this.#cameraLight.parent = this.#camera;
964
- this.#cameraLight.intensity = 0.3;
965
- }
966
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;
967
1051
  return lightsChanged;
968
1052
  }
969
1053
 
@@ -1140,34 +1224,13 @@ export default class BabylonJSController {
1140
1224
  const caps = this.#scene.getEngine()?.getCaps?.() || {};
1141
1225
  const maxSamples = typeof caps.maxMSAASamples === "number" ? caps.maxMSAASamples : 4;
1142
1226
  defaultPipeline.samples = Math.max(1, Math.min(8, maxSamples));
1143
- // FXAA - Fast Approximate Anti-Aliasing
1144
- defaultPipeline.fxaaEnabled = true;
1145
- defaultPipeline.fxaa.samples = 8;
1146
- defaultPipeline.fxaa.adaptScaleToCurrentViewport = true;
1147
- if (defaultPipeline.fxaa.edgeThreshold !== undefined) {
1148
- defaultPipeline.fxaa.edgeThreshold = 0.125;
1149
- }
1150
- if (defaultPipeline.fxaa.edgeThresholdMin !== undefined) {
1151
- defaultPipeline.fxaa.edgeThresholdMin = 0.0625;
1152
- }
1153
- if (defaultPipeline.fxaa.subPixelQuality !== undefined) {
1154
- defaultPipeline.fxaa.subPixelQuality = 0.75;
1155
- }
1227
+ // FXAA can soften product surfaces too much; keep MSAA only for crisper materials.
1228
+ defaultPipeline.fxaaEnabled = false;
1156
1229
 
1157
- // Grain
1158
- defaultPipeline.grainEnabled = true;
1159
- defaultPipeline.grain.adaptScaleToCurrentViewport = true;
1160
- defaultPipeline.grain.animated = false;
1161
- defaultPipeline.grain.intensity = 3;
1230
+ // Grain is disabled to keep product surfaces clean and avoid visible noise.
1231
+ defaultPipeline.grainEnabled = false;
1162
1232
 
1163
1233
  // Configure post-processes to calculate only once instead of every frame for better performance
1164
- if (defaultPipeline.fxaa?._postProcess) {
1165
- defaultPipeline.fxaa._postProcess.autoClear = false;
1166
- }
1167
- if (defaultPipeline.grain?._postProcess) {
1168
- defaultPipeline.grain._postProcess.autoClear = false;
1169
- }
1170
-
1171
1234
  this.#renderPipelines.default = defaultPipeline;
1172
1235
  pipelineManager.update();
1173
1236
  return true;
@@ -1201,7 +1264,8 @@ export default class BabylonJSController {
1201
1264
  let hdrTexture = null;
1202
1265
  if (this.#options.ibl?.cachedUrl) {
1203
1266
  const hdrTextureURI = this.#options.ibl.cachedUrl;
1204
- 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);
1205
1269
  } else if (this.#hdrTexture && this.#options.ibl?.valid === true) {
1206
1270
  hdrTexture = this.#hdrTexture.clone();
1207
1271
  } else {
@@ -1218,8 +1282,10 @@ export default class BabylonJSController {
1218
1282
  this.#options.ibl?.consumeCachedUrl?.(true);
1219
1283
  }
1220
1284
 
1221
- hdrTexture.level = this.#options.ibl.intensity;
1285
+ hdrTexture.level = this.#options.ibl.intensity ?? 1;
1222
1286
  this.#scene.environmentTexture = hdrTexture;
1287
+ this.#applyShowroomLook();
1288
+ this.#applyLightingConfig();
1223
1289
  this.#scene.markAllMaterialsAsDirty(Material.TextureDirtyFlag);
1224
1290
  return true;
1225
1291
  }
@@ -1305,7 +1371,7 @@ export default class BabylonJSController {
1305
1371
  });
1306
1372
  const materialsForReceivingShadows = this.#scene.materials.filter((material) => {
1307
1373
  if (material instanceof PBRMaterial) {
1308
- material.enableSpecularAntiAliasing = false;
1374
+ material.enableSpecularAntiAliasing = true;
1309
1375
  }
1310
1376
  return true;
1311
1377
  });
@@ -1530,6 +1596,184 @@ export default class BabylonJSController {
1530
1596
  }
1531
1597
  }
1532
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
+
1533
1777
  /**
1534
1778
  * Resets pointer-picking sampling state.
1535
1779
  * @private
@@ -1696,7 +1940,7 @@ export default class BabylonJSController {
1696
1940
  }
1697
1941
  this.#engine.dispose();
1698
1942
  this.#engine = this.#scene = this.#camera = null;
1699
- this.#hemiLight = this.#dirLight = this.#cameraLight = null;
1943
+ this.#hemiLight = this.#dirLight = this.#moonLight = this.#cameraLight = null;
1700
1944
  }
1701
1945
 
1702
1946
  /**
@@ -1931,8 +2175,11 @@ export default class BabylonJSController {
1931
2175
  }
1932
2176
  this.#state.resize.lastAppliedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1933
2177
  console.log(`PrefViewer: Applying resize after ${Math.round(elapsed)}ms`);
2178
+ this.#applyShowroomCameraFraming();
1934
2179
  this.#engine.resize();
1935
- 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 });
1936
2183
  };
1937
2184
 
1938
2185
  if (elapsed >= this.#config.resize.throttleMs) {
@@ -2084,6 +2331,7 @@ export default class BabylonJSController {
2084
2331
  cameraState.setSuccess(true);
2085
2332
  }
2086
2333
  }
2334
+ this.#applyShowroomCameraFraming(camera);
2087
2335
  this.#scene.activeCamera?.detachControl();
2088
2336
  camera.detachControl();
2089
2337
  if (!cameraState.locked) {
@@ -2471,6 +2719,7 @@ export default class BabylonJSController {
2471
2719
  .finally(async () => {
2472
2720
  this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
2473
2721
  this.#setMaxSimultaneousLights();
2722
+ this.#enhanceTextureQuality();
2474
2723
  this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
2475
2724
  // Apply stored highlight settings so fresh page loads respect persisted state
2476
2725
  this.#setHighlightConfig({
@@ -2736,19 +2985,13 @@ export default class BabylonJSController {
2736
2985
  */
2737
2986
  async enable() {
2738
2987
  this.#configureDracoCompression();
2739
- 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);
2740
2989
  this.#engine.disableUniformBuffers = true;
2741
2990
 
2742
2991
  // OPTIMIZATION: Detect hardware tier and apply quality settings
2743
2992
  const detectedTier = this.#detectHardwareTier();
2744
2993
  this.#applyQualitySettings(detectedTier);
2745
2994
 
2746
- // OPTIMIZATION: Apply hardware scaling from quality preset
2747
- const pixelRatio = typeof window !== 'undefined' ? window.devicePixelRatio : 1;
2748
- const baseScaling = this.#config.quality.hardwareScaling || 1;
2749
- const scalingLevel = pixelRatio > 1 ? baseScaling * pixelRatio : baseScaling;
2750
- this.#engine.setHardwareScalingLevel(scalingLevel);
2751
-
2752
2995
  // OPTIMIZATION: Initialize idle tracking
2753
2996
  this.#state.render.lastActivityAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2754
2997
 
@@ -2764,14 +3007,7 @@ export default class BabylonJSController {
2764
3007
  geometryBufferRenderer.generateNormalsInWorldSpace = true;
2765
3008
  }
2766
3009
 
2767
- this.#scene.clearColor = new Color4(1, 1, 1, 1).toLinearSpace();
2768
-
2769
- // Lowered exposure to prevent scenes from looking blown out when the DefaultRenderingPipeline (Antialiasing) is enabled.
2770
- this.#scene.imageProcessingConfiguration.exposure = 0.75;
2771
- this.#scene.imageProcessingConfiguration.contrast = 1.0;
2772
- this.#scene.imageProcessingConfiguration.toneMappingEnabled = false;
2773
- this.#scene.imageProcessingConfiguration.vignetteEnabled = false;
2774
- this.#scene.imageProcessingConfiguration.colorCurvesEnabled = false;
3010
+ this.#applyShowroomLook();
2775
3011
 
2776
3012
  // Skip the built-in pointer picking logic since the controller implements its own optimized raycasting for interaction.
2777
3013
  this.#scene.skipPointerMovePicking = true;
@@ -2985,6 +3221,60 @@ export default class BabylonJSController {
2985
3221
  }
2986
3222
  }
2987
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
+
2988
3278
  /**
2989
3279
  * Sets the visibility of a container (model, environment, etc.) by name.
2990
3280
  * Adds or removes the container from the scene and updates wall/floor visibility.
@@ -3039,45 +3329,6 @@ export default class BabylonJSController {
3039
3329
  return await this.#loadContainers();
3040
3330
  }
3041
3331
 
3042
- /**
3043
- * Sets highlight configuration for animation hover effects.
3044
- * @public
3045
- * @param {{showAnimationMenu?:boolean, highlightColor?:string, highlightEnabled?:boolean}} config - Highlight configuration.
3046
- * @returns {void}
3047
- */
3048
- setIlluminationConfig(config = {}) {
3049
-
3050
- if (config.showAnimationMenu !== undefined) {
3051
- // Handle show-animation-menu via the 3D component's parent
3052
- // This is handled in pref-viewer-3d.js attributeChangedCallback
3053
- }
3054
-
3055
- // Handle highlight color for animation hover
3056
- if (config.highlightColor !== undefined || config.highlightEnabled !== undefined) {
3057
- // Normalize highlightEnabled: accept boolean, string "true"/"false", or null
3058
- let enabled = config.highlightEnabled;
3059
- if (typeof enabled === "string") {
3060
- enabled = enabled.toLowerCase() !== "false";
3061
- } else if (enabled === null) {
3062
- enabled = undefined;
3063
- }
3064
- // Keep #settings in sync so getRenderSettings() always reflects the real state
3065
- if (enabled !== undefined) {
3066
- this.#settings.highlightEnabled = enabled;
3067
- }
3068
- if (config.highlightColor !== undefined && config.highlightColor !== null) {
3069
- this.#settings.highlightColor = config.highlightColor;
3070
- }
3071
- this.#setHighlightConfig({
3072
- color: config.highlightColor ?? undefined,
3073
- enabled,
3074
- });
3075
- }
3076
-
3077
- // Persist settings
3078
- this.#storeRenderSettings();
3079
- }
3080
-
3081
3332
  /**
3082
3333
  * Returns a snapshot of all available animations and their current state.
3083
3334
  * @public