@preference-sl/pref-viewer 2.16.0-beta.1 → 2.16.0-beta.11
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/LICENSE.md +13 -0
- package/README.md +486 -0
- package/package.json +7 -7
- package/src/babylonjs-animation-opening.js +11 -5
- package/src/babylonjs-controller.js +455 -903
- package/src/file-storage.js +5 -126
- package/src/gltf-resolver.js +24 -58
- package/src/localization/translations.js +5 -17
- package/src/material-utils.js +72 -0
- package/src/pref-viewer-2d.js +14 -21
- package/src/pref-viewer-3d.js +160 -193
- package/src/pref-viewer-menu-3d.js +185 -268
- package/src/pref-viewer-task.js +7 -2
- package/src/pref-viewer.js +549 -333
- package/src/render-math.js +127 -0
- package/src/styles.js +163 -179
- package/Readme.md +0 -8
|
@@ -1,4 +1,4 @@
|
|
|
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";
|
|
1
|
+
import { ArcRotateCamera, AssetContainer, BRDFTextureTools, 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";
|
|
@@ -10,74 +10,13 @@ import { MaterialData } from "./pref-viewer-3d-data.js";
|
|
|
10
10
|
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
|
+
import { PrefViewerStyleVariables } from "./styles.js";
|
|
14
|
+
import { clamp01, lerp, getAdaptiveHardwareScalingLevel, getLightingCycleProfile } from "./render-math.js";
|
|
15
|
+
import { markPrefViewerSimplifiedTransmissionMaterial, isPrefViewerPhysicalGlassMaterial, isPrefViewerTransparentRenderOrderMaterial } from "./material-utils.js";
|
|
13
16
|
|
|
14
|
-
export
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
}
|
|
17
|
+
// Re-export so existing consumers (tests, other modules) can keep importing from this file.
|
|
18
|
+
export { getAdaptiveHardwareScalingLevel, getLightingCycleProfile } from "./render-math.js";
|
|
19
|
+
export { markPrefViewerSimplifiedTransmissionMaterial, isPrefViewerPhysicalGlassMaterial, isPrefViewerTransparentRenderOrderMaterial } from "./material-utils.js";
|
|
81
20
|
|
|
82
21
|
/**
|
|
83
22
|
* BabylonJSController coordinates the PrefViewer 3D runtime: it bootstraps Babylon.js, manages asset containers,
|
|
@@ -100,9 +39,10 @@ export function getLightingCycleProfile(timeOfDay = 0.5) {
|
|
|
100
39
|
*
|
|
101
40
|
* Runtime Flow
|
|
102
41
|
* 1. Instantiate with `new BabylonJSController(canvas, containers, options)`.
|
|
103
|
-
* 2. Call `enable()` to configure Draco, spin up the engine/scene, attach interaction + XR hooks
|
|
42
|
+
* 2. Call `enable()` to configure Draco, spin up the engine/scene, and attach interaction + XR hooks. The render loop
|
|
43
|
+
* does not start here; it starts automatically after the first `load()` completes.
|
|
104
44
|
* 3. When PrefViewer marks containers/options pending, invoke `load()` (or `reloadWithCurrentSettings()`) so the controller
|
|
105
|
-
* fetches sources, rebuilds containers, and
|
|
45
|
+
* fetches sources, rebuilds containers, reattaches pipelines, and starts the render loop.
|
|
106
46
|
* 4. Use `scheduleRenderSettingsReload()` to merge/persist toggles; when it reports `changed: true`, call
|
|
107
47
|
* `reloadWithCurrentSettings()` to apply the staged settings.
|
|
108
48
|
* 5. Use `setContainerVisibility`, `setMaterialOptions`, `setCameraOptions`, or `setIBLOptions` for targeted updates; these
|
|
@@ -114,15 +54,19 @@ export function getLightingCycleProfile(timeOfDay = 0.5) {
|
|
|
114
54
|
* Public API Highlights
|
|
115
55
|
* - constructor(canvas, containers, options)
|
|
116
56
|
* - enable() / disable()
|
|
117
|
-
* - load() / reloadWithCurrentSettings()
|
|
57
|
+
* - load(force?) / reloadWithCurrentSettings()
|
|
118
58
|
* - downloadGLB(content) / downloadGLTF(content) / downloadUSDZ(content)
|
|
119
59
|
* - getRenderSettings() / scheduleRenderSettingsReload(settings)
|
|
60
|
+
* - getDebugInfo()
|
|
120
61
|
* - setContainerVisibility(name, show)
|
|
121
62
|
* - setMaterialOptions() / setCameraOptions() / setIBLOptions()
|
|
63
|
+
* - setIlluminationConfig(config)
|
|
64
|
+
* - getAnimations() / playAnimation(name, action) / setAnimationProgress(name, progress) / setAnimationLoop(name, loop)
|
|
122
65
|
*
|
|
123
66
|
* Key Invariants
|
|
124
|
-
* - Asset containers must expose `setPendingWithCurrentStorage
|
|
125
|
-
*
|
|
67
|
+
* - Asset containers must expose `setPendingWithCurrentStorage` before calling load/reload; the controller reads that
|
|
68
|
+
* flag to resolve fresh sources. Option objects (camera, materials, IBL) expose `setPendingWithCurrent` for the same
|
|
69
|
+
* purpose. Neither set of flags touches the DOM — they only gate resolver calls.
|
|
126
70
|
* - Camera-dependent pipelines (DefaultRenderingPipeline, SSAO, IBL shadows, directional shadow generators) are rebuilt
|
|
127
71
|
* only after the active camera and environment textures are ready; render-loop restarts gate those transitions.
|
|
128
72
|
* - `show-model`/`show-scene` DOM attributes reflect container visibility; there are no direct `showModel()/hideModel()` APIs.
|
|
@@ -153,10 +97,13 @@ export default class BabylonJSController {
|
|
|
153
97
|
ambientOcclusionEnabled: true,
|
|
154
98
|
iblEnabled: true,
|
|
155
99
|
shadowsEnabled: false,
|
|
156
|
-
|
|
157
|
-
|
|
100
|
+
// Animation menu and highlight settings
|
|
101
|
+
showAnimationMenu: true,
|
|
158
102
|
highlightEnabled: true,
|
|
159
|
-
highlightColor:
|
|
103
|
+
highlightColor: PrefViewerStyleVariables.colorPrimary,
|
|
104
|
+
// Lighting time of day (0=midnight, 0.5=noon, 1=midnight)
|
|
105
|
+
showLightingTimeOfDay: true,
|
|
106
|
+
lightingTimeOfDay: 0.5,
|
|
160
107
|
};
|
|
161
108
|
|
|
162
109
|
// Canvas HTML element
|
|
@@ -165,8 +112,6 @@ export default class BabylonJSController {
|
|
|
165
112
|
// References to parent custom elements
|
|
166
113
|
#prefViewer3D = undefined;
|
|
167
114
|
#prefViewer = undefined;
|
|
168
|
-
#modelFirstPaintedEmitted = false;
|
|
169
|
-
#modelFirstPaintedLoadToken = 0;
|
|
170
115
|
|
|
171
116
|
// Babylon.js core objects
|
|
172
117
|
#engine = null;
|
|
@@ -176,23 +121,17 @@ export default class BabylonJSController {
|
|
|
176
121
|
#dirLight = null;
|
|
177
122
|
#moonLight = null;
|
|
178
123
|
#cameraLight = null;
|
|
179
|
-
#debugCameraFrameInfo = null;
|
|
180
124
|
#shadowGen = [];
|
|
181
125
|
#XRExperience = null;
|
|
182
126
|
#canvasResizeObserver = null;
|
|
183
|
-
|
|
184
|
-
#hdrTexture = null; //
|
|
127
|
+
|
|
128
|
+
#hdrTexture = null; // Reusable in-memory HDR source cloned into scene.environmentTexture across reloads
|
|
185
129
|
|
|
186
130
|
#containers = {};
|
|
187
131
|
#options = {};
|
|
188
132
|
|
|
189
133
|
#gltfResolver = null; // GLTFResolver instance
|
|
190
134
|
#babylonJSAnimationController = null; // AnimationController instance
|
|
191
|
-
#loadingInProgress = false; // blocks #requestRender while assets are loading to prevent WebGL errors from rendering with disposed shaders
|
|
192
|
-
#isLoadingModelIndependent = false; // guards against concurrent independent model loads
|
|
193
|
-
#isLoadingEnvironmentIndependent = false; // guards against concurrent independent environment loads
|
|
194
|
-
#isLoadingMaterialsIndependent = false; // guards against concurrent independent materials loads
|
|
195
|
-
#startRenderInProgress = false; // true while #startRender() is running (after #loadingInProgress clears)
|
|
196
135
|
|
|
197
136
|
#renderPipelines = {
|
|
198
137
|
default: null,
|
|
@@ -239,30 +178,30 @@ export default class BabylonJSController {
|
|
|
239
178
|
// Runtime configuration constants (tuning knobs, not per-frame state).
|
|
240
179
|
#config = {
|
|
241
180
|
pointerPicking: {
|
|
242
|
-
movePickIntervalMs: 50, //
|
|
243
|
-
movePickMinDistancePx: 2, //
|
|
181
|
+
movePickIntervalMs: 50, // Cap expensive scene.pick calls to ~20 Hz while moving the pointer
|
|
182
|
+
movePickMinDistancePx: 2, // Skip picks for sub-pixel jitter
|
|
244
183
|
},
|
|
245
184
|
render: {
|
|
246
185
|
burstFramesBase: 2,
|
|
247
|
-
burstFramesEnhanced: 32, //
|
|
186
|
+
burstFramesEnhanced: 32, // When AA/SSAO/IBL is enabled, more frames are needed to reach stable output
|
|
248
187
|
interactionMs: 250,
|
|
249
188
|
animationMs: 200,
|
|
250
189
|
idleThrottleMs: 1000 / 8, // Reduced from 15 FPS to 8 FPS for idle - reduces GPU/CPU load
|
|
251
190
|
idleThresholdMs: 3000, // Time before going to true idle (no renders)
|
|
252
191
|
},
|
|
253
192
|
resize: {
|
|
254
|
-
throttleMs: 50, //
|
|
193
|
+
throttleMs: 50, // Cap resize work to ~20 Hz while dragging/resizing containers
|
|
255
194
|
},
|
|
256
195
|
// Hardware-adaptive quality settings (applied based on GPU capabilities)
|
|
257
196
|
quality: {
|
|
258
197
|
// Settings for different hardware tiers: 'low', 'medium', 'high'
|
|
259
198
|
// Applied automatically based on GPU detected capabilities
|
|
260
|
-
tier: 'medium',
|
|
261
|
-
shadowMapSize: 1024,
|
|
262
|
-
iblShadowResolution: 1,
|
|
263
|
-
iblSampleDirections: 4,
|
|
264
|
-
shadowBlurKernel: 16,
|
|
265
|
-
ssaoEnabled: true,
|
|
199
|
+
tier: 'medium', // Default, will be updated after hardware detection
|
|
200
|
+
shadowMapSize: 1024, // Standard shadow generator resolution
|
|
201
|
+
iblShadowResolution: 1, // IBL shadow resolution exponent (0=1024, 1=2048, 2=4096)
|
|
202
|
+
iblSampleDirections: 4, // IBL shadow quality
|
|
203
|
+
shadowBlurKernel: 16, // Shadow blur amount
|
|
204
|
+
ssaoEnabled: true, // Screen Space Ambient Occlusion
|
|
266
205
|
},
|
|
267
206
|
look: {
|
|
268
207
|
showroom: {
|
|
@@ -287,18 +226,18 @@ export default class BabylonJSController {
|
|
|
287
226
|
#detectHardwareTier() {
|
|
288
227
|
// First, try to get WebGL info to help identify the device
|
|
289
228
|
const gl = this.#engine?.getRenderingCanvas()?.getContext?.('webgl2') || this.#engine?.getRenderingCanvas()?.getContext?.('webgl');
|
|
290
|
-
|
|
229
|
+
|
|
291
230
|
const debugInfo = gl?.getExtension('WEBGL_debug_renderer_info');
|
|
292
231
|
const rendererName = debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : '';
|
|
293
232
|
const isAngleRenderer = /^ANGLE\s*\(/i.test(rendererName);
|
|
294
|
-
|
|
233
|
+
|
|
295
234
|
// If we have WebGL renderer info and it looks like a desktop GPU, skip mobile detection
|
|
296
235
|
// Desktop GPUs typically have "NVIDIA", "AMD", "Intel" in the name
|
|
297
236
|
const isDesktopGPU = /NVIDIA|AMD|Intel|Radeon|GeForce|Quadro/i.test(rendererName);
|
|
298
|
-
|
|
237
|
+
|
|
299
238
|
// Also check if it's explicitly a mobile GPU
|
|
300
239
|
const isMobileGPU = /Adreno|Mali|Apple GPU/i.test(rendererName);
|
|
301
|
-
|
|
240
|
+
|
|
302
241
|
// If we detect a desktop GPU, go directly to desktop detection
|
|
303
242
|
if (isDesktopGPU && !isMobileGPU) {
|
|
304
243
|
if (isAngleRenderer) {
|
|
@@ -317,7 +256,7 @@ export default class BabylonJSController {
|
|
|
317
256
|
|
|
318
257
|
// If no WebGL info or unclear, fall back to user agent detection
|
|
319
258
|
const mobileInfo = this.#detectMobileInfo();
|
|
320
|
-
|
|
259
|
+
|
|
321
260
|
// Only treat as mobile if we have strong mobile indicators
|
|
322
261
|
if (mobileInfo.isMobile && !mobileInfo.isDesktopLike) {
|
|
323
262
|
if (mobileInfo.isHighEnd) {
|
|
@@ -344,15 +283,15 @@ export default class BabylonJSController {
|
|
|
344
283
|
#detectDesktopTier(gl, rendererName, conservative = false) {
|
|
345
284
|
// Check WebGL capabilities
|
|
346
285
|
const maxTextureSize = gl?.getParameter(gl.MAX_TEXTURE_SIZE) || 8192;
|
|
347
|
-
|
|
286
|
+
|
|
348
287
|
// Detect high-end GPUs
|
|
349
288
|
const isAppleSilicon = /Apple|M1|M2|M3|M4/i.test(rendererName);
|
|
350
289
|
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);
|
|
351
290
|
const isAmdHighEnd = /RX (6|7|8|9)[0-9]{2,}|RX Vega|Radeon Pro|WX [0-9]/i.test(rendererName);
|
|
352
291
|
const isIntelIntegrated = /Intel.*UHD|Intel.*Iris|Mesa|llvmpipe/i.test(rendererName);
|
|
353
|
-
|
|
292
|
+
|
|
354
293
|
let tier = conservative ? 'medium' : 'high'; // Default to conservative desktop when ANGLE is in the path
|
|
355
|
-
|
|
294
|
+
|
|
356
295
|
if (isIntelIntegrated || maxTextureSize < 8192) {
|
|
357
296
|
tier = 'medium';
|
|
358
297
|
} else if (!conservative && (isAppleSilicon || isNvidiaHighEnd || isAmdHighEnd || maxTextureSize >= 16384)) {
|
|
@@ -360,7 +299,7 @@ export default class BabylonJSController {
|
|
|
360
299
|
} else if (conservative && maxTextureSize >= 16384) {
|
|
361
300
|
tier = 'high';
|
|
362
301
|
}
|
|
363
|
-
|
|
302
|
+
|
|
364
303
|
console.log(`[PrefViewer] Desktop tier: ${tier} (GPU: ${rendererName || 'unknown'}, MaxTexture: ${maxTextureSize})`);
|
|
365
304
|
return tier;
|
|
366
305
|
}
|
|
@@ -376,48 +315,48 @@ export default class BabylonJSController {
|
|
|
376
315
|
const screenWidth = window.screen?.width || 0;
|
|
377
316
|
const screenHeight = window.screen?.height || 0;
|
|
378
317
|
const pixelRatio = window.devicePixelRatio || 1;
|
|
379
|
-
|
|
318
|
+
|
|
380
319
|
// Detect device type
|
|
381
320
|
const isIPhone = /iPhone/i.test(userAgent);
|
|
382
321
|
const isIPad = /iPad/i.test(userAgent);
|
|
383
322
|
const isAndroid = /Android/i.test(userAgent);
|
|
384
323
|
const isMobile = /Mobile/i.test(userAgent);
|
|
385
|
-
|
|
324
|
+
|
|
386
325
|
// High-end mobile patterns (recent flagship devices)
|
|
387
326
|
const highEndPatterns = [
|
|
388
327
|
/iPhone (1[4-9]|SE)/i, // iPhone 14+
|
|
389
|
-
/iPad Pro/i,
|
|
390
|
-
/SM-S91[0-9]/i,
|
|
391
|
-
/SM-S9[0-9]/i,
|
|
392
|
-
/Pixel [7-9]/i,
|
|
393
|
-
/OnePlus [8-9|10|11]/i,
|
|
328
|
+
/iPad Pro/i, // iPad Pro
|
|
329
|
+
/SM-S91[0-9]/i, // Samsung S21+
|
|
330
|
+
/SM-S9[0-9]/i, // Samsung S20+
|
|
331
|
+
/Pixel [7-9]/i, // Pixel 7+
|
|
332
|
+
/OnePlus [8-9|10|11]/i, // OnePlus 8+
|
|
394
333
|
/Xiaomi (1[2-4]|Mi 1[1-4])/i, // Xiaomi 12+
|
|
395
|
-
/Huawei (P50|Mate 4|)/i,
|
|
334
|
+
/Huawei (P50|Mate 4|)/i, // Huawei P50+
|
|
396
335
|
];
|
|
397
|
-
|
|
336
|
+
|
|
398
337
|
// Mid-range patterns
|
|
399
338
|
const midRangePatterns = [
|
|
400
|
-
/iPhone (1[0-3]|SE)/i,
|
|
339
|
+
/iPhone (1[0-3]|SE)/i, // iPhone 10-13
|
|
401
340
|
/iPad Air/i, // iPad Air
|
|
402
|
-
/SM-A[0-9]/i,
|
|
403
|
-
/Redmi Note/i,
|
|
404
|
-
/Pixel [4-6]/i,
|
|
405
|
-
/Moto G/i,
|
|
341
|
+
/SM-A[0-9]/i, // Samsung A series
|
|
342
|
+
/Redmi Note/i, // Redmi Note
|
|
343
|
+
/Pixel [4-6]/i, // Pixel 4-6
|
|
344
|
+
/Moto G/i, // Moto G
|
|
406
345
|
];
|
|
407
|
-
|
|
346
|
+
|
|
408
347
|
const isHighEnd = highEndPatterns.some(p => p.test(userAgent));
|
|
409
348
|
const isMidRange = midRangePatterns.some(p => p.test(userAgent));
|
|
410
|
-
|
|
349
|
+
|
|
411
350
|
// Detect tablet ONLY if explicitly in user agent (not just screen size)
|
|
412
351
|
// Many desktops have large screens and would incorrectly be detected as tablets
|
|
413
352
|
const isTablet = /iPad|Android.*Tablet|Nexus 9|Nexus 10/i.test(userAgent);
|
|
414
|
-
|
|
353
|
+
|
|
415
354
|
// Detect if mobile (phone form factor) - must have explicit mobile indicators
|
|
416
355
|
const isMobilePhone = /Android.*Mobile|iPhone|iPod/i.test(userAgent);
|
|
417
|
-
|
|
356
|
+
|
|
418
357
|
// Check if it looks like a desktop/laptop (has "Windows", "Mac", "Linux" but no mobile indicators)
|
|
419
358
|
const isDesktopLike = /Windows NT|Macintosh|Linux|X11/i.test(userAgent) && !isMobilePhone && !isTablet;
|
|
420
|
-
|
|
359
|
+
|
|
421
360
|
// Device name for logging
|
|
422
361
|
let deviceName = 'Unknown';
|
|
423
362
|
if (isIPhone) {
|
|
@@ -429,7 +368,7 @@ export default class BabylonJSController {
|
|
|
429
368
|
const match = userAgent.match(/Android.*;.*(.+?)\)/);
|
|
430
369
|
deviceName = match ? match[1].trim() : 'Android';
|
|
431
370
|
}
|
|
432
|
-
|
|
371
|
+
|
|
433
372
|
return {
|
|
434
373
|
isMobile: isMobilePhone && !isDesktopLike,
|
|
435
374
|
isTablet: isTablet && !isDesktopLike,
|
|
@@ -454,13 +393,13 @@ export default class BabylonJSController {
|
|
|
454
393
|
// Mobile low-end (old phones, budget devices)
|
|
455
394
|
low: {
|
|
456
395
|
shadowMapSize: 256, // Very small for speed
|
|
457
|
-
iblShadowResolution: -1,
|
|
396
|
+
iblShadowResolution: -1, // Disable IBL shadows
|
|
458
397
|
iblSampleDirections: 1, // Minimal sampling
|
|
459
398
|
shadowBlurKernel: 0, // No blur
|
|
460
399
|
ssaoEnabled: false,
|
|
461
400
|
antialiasingEnabled: false,
|
|
462
|
-
hardwareScaling: 2.5,
|
|
463
|
-
maxModelResolution: 512,
|
|
401
|
+
hardwareScaling: 2.5, // Aggressive downscaling
|
|
402
|
+
maxModelResolution: 512, // Low-res model loading
|
|
464
403
|
environmentTextureSize: 512,
|
|
465
404
|
},
|
|
466
405
|
// Mobile high-end / Tablet / Desktop low
|
|
@@ -478,7 +417,7 @@ export default class BabylonJSController {
|
|
|
478
417
|
// Desktop mid-range / High-end mobile
|
|
479
418
|
high: {
|
|
480
419
|
shadowMapSize: 1024,
|
|
481
|
-
iblShadowResolution: 0,
|
|
420
|
+
iblShadowResolution: 0, // 1024x1024 - good balance
|
|
482
421
|
iblSampleDirections: 4, // Quality sampling
|
|
483
422
|
shadowBlurKernel: 8, // Sharper shadows for product clarity
|
|
484
423
|
ssaoEnabled: false,
|
|
@@ -490,9 +429,9 @@ export default class BabylonJSController {
|
|
|
490
429
|
// Desktop high-end (RTX, Apple Silicon, etc.)
|
|
491
430
|
ultra: {
|
|
492
431
|
shadowMapSize: 2048,
|
|
493
|
-
iblShadowResolution: 1,
|
|
432
|
+
iblShadowResolution: 1, // 2048x2048
|
|
494
433
|
iblSampleDirections: 8, // Maximum quality
|
|
495
|
-
shadowBlurKernel: 16,
|
|
434
|
+
shadowBlurKernel: 16, // Preserve clarity over softness
|
|
496
435
|
ssaoEnabled: false,
|
|
497
436
|
antialiasingEnabled: true,
|
|
498
437
|
hardwareScaling: 1.0, // Native resolution
|
|
@@ -510,10 +449,10 @@ export default class BabylonJSController {
|
|
|
510
449
|
};
|
|
511
450
|
|
|
512
451
|
const preset = presetMap[tier] || qualityPresets.medium;
|
|
513
|
-
|
|
452
|
+
|
|
514
453
|
// Apply preset to config
|
|
515
|
-
this.#config.quality = {
|
|
516
|
-
tier,
|
|
454
|
+
this.#config.quality = {
|
|
455
|
+
tier,
|
|
517
456
|
...preset,
|
|
518
457
|
// Legacy compatibility
|
|
519
458
|
shadowMapSize: preset.shadowMapSize,
|
|
@@ -707,6 +646,55 @@ export default class BabylonJSController {
|
|
|
707
646
|
}
|
|
708
647
|
}
|
|
709
648
|
|
|
649
|
+
/**
|
|
650
|
+
* Preloads Babylon's internal BRDF lookup texture required by PBR materials.
|
|
651
|
+
* In render-on-demand hosts the RGBD expansion can stall unless a few renders are kept alive.
|
|
652
|
+
* @private
|
|
653
|
+
* @returns {Promise<boolean>} True when the texture is ready, false when it timed out or is unavailable.
|
|
654
|
+
*/
|
|
655
|
+
async #ensureEnvironmentBRDFTextureReady() {
|
|
656
|
+
if (!this.#scene || !this.#engine) {
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const brdfTexture = BRDFTextureTools.GetEnvironmentBRDFTexture(this.#scene);
|
|
661
|
+
if (!brdfTexture || brdfTexture.isReady?.()) {
|
|
662
|
+
return true;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const timeoutMs = 2000;
|
|
666
|
+
const startedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
667
|
+
this.#requestRender({ frames: this.#config.render.burstFramesEnhanced, continuousMs: timeoutMs });
|
|
668
|
+
|
|
669
|
+
try {
|
|
670
|
+
await new Promise((resolve) => {
|
|
671
|
+
const tick = () => {
|
|
672
|
+
if (!this.#scene || !this.#engine || brdfTexture.isDisposed?.()) {
|
|
673
|
+
resolve(false);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
if (brdfTexture.isReady?.()) {
|
|
677
|
+
resolve(true);
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
681
|
+
if (now - startedAt >= timeoutMs) {
|
|
682
|
+
resolve(false);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
this.#requestRender({ frames: 1, continuousMs: 100 });
|
|
686
|
+
setTimeout(tick, 16);
|
|
687
|
+
};
|
|
688
|
+
tick();
|
|
689
|
+
});
|
|
690
|
+
} catch (error) {
|
|
691
|
+
console.warn("PrefViewer: Error preparing Babylon BRDF texture:", error);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
this.#requestRender({ frames: 2 });
|
|
695
|
+
return brdfTexture.isReady?.() ?? false;
|
|
696
|
+
}
|
|
697
|
+
|
|
710
698
|
/**
|
|
711
699
|
* Starts Babylon's engine render loop if it is not already running.
|
|
712
700
|
* @private
|
|
@@ -734,7 +722,7 @@ export default class BabylonJSController {
|
|
|
734
722
|
this.#state.render.isLoopRunning = false;
|
|
735
723
|
return true;
|
|
736
724
|
}
|
|
737
|
-
|
|
725
|
+
|
|
738
726
|
/**
|
|
739
727
|
* Resets transient render-loop bookkeeping so the next render request starts from a clean baseline.
|
|
740
728
|
* Clears queued burst frames, ends any active continuous-render window, and drops the idle-throttle timestamp.
|
|
@@ -756,8 +744,8 @@ export default class BabylonJSController {
|
|
|
756
744
|
* @param {number} [options.continuousMs=0] - Milliseconds to keep continuous rendering active.
|
|
757
745
|
* @returns {boolean} True when the request was accepted, false when scene/engine are unavailable.
|
|
758
746
|
*/
|
|
759
|
-
#requestRender({ frames = 1, continuousMs = 0
|
|
760
|
-
if (!this.#scene || !this.#engine
|
|
747
|
+
#requestRender({ frames = 1, continuousMs = 0 } = {}) {
|
|
748
|
+
if (!this.#scene || !this.#engine) {
|
|
761
749
|
return false;
|
|
762
750
|
}
|
|
763
751
|
|
|
@@ -869,12 +857,12 @@ export default class BabylonJSController {
|
|
|
869
857
|
}
|
|
870
858
|
|
|
871
859
|
const shouldRender = animationRunning || cameraInMotion || this.#state.render.continuousUntil > now;
|
|
872
|
-
|
|
860
|
+
|
|
873
861
|
// Reset idle flag if we need to render
|
|
874
862
|
if (shouldRender) {
|
|
875
863
|
this.#state.render.isIdle = false;
|
|
876
864
|
}
|
|
877
|
-
|
|
865
|
+
|
|
878
866
|
return shouldRender;
|
|
879
867
|
}
|
|
880
868
|
|
|
@@ -910,7 +898,7 @@ export default class BabylonJSController {
|
|
|
910
898
|
if (!continuous && this.#state.render.lastRenderAt > 0 && now - this.#state.render.lastRenderAt < this.#config.render.idleThrottleMs) {
|
|
911
899
|
return;
|
|
912
900
|
}
|
|
913
|
-
|
|
901
|
+
|
|
914
902
|
this.#scene.render();
|
|
915
903
|
this.#state.render.lastRenderAt = now;
|
|
916
904
|
|
|
@@ -1120,11 +1108,6 @@ export default class BabylonJSController {
|
|
|
1120
1108
|
|
|
1121
1109
|
const pipelineName = "PrefViewerSSAORenderingPipeline";
|
|
1122
1110
|
|
|
1123
|
-
// Skip recreation if already alive — same reason as DefaultRenderingPipeline guard
|
|
1124
|
-
if (this.#renderPipelines.ssao && supportedPipelines.find((p) => p.name === pipelineName)) {
|
|
1125
|
-
return true;
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
1111
|
const ssaoRatio = {
|
|
1129
1112
|
ssaoRatio: 0.5,
|
|
1130
1113
|
combineRatio: 1.0,
|
|
@@ -1226,21 +1209,6 @@ export default class BabylonJSController {
|
|
|
1226
1209
|
|
|
1227
1210
|
const pipelineName = "PrefViewerDefaultRenderingPipeline";
|
|
1228
1211
|
|
|
1229
|
-
// Skip recreation if the pipeline is already alive — creating a new DefaultRenderingPipeline
|
|
1230
|
-
// while one exists disposes the underlying GeometryBufferRenderer, deleting GL programs that
|
|
1231
|
-
// subMeshes still reference, causing useProgram:deleted errors on the next render frame.
|
|
1232
|
-
if (this.#renderPipelines.default && supportedPipelines.find((p) => p.name === pipelineName)) {
|
|
1233
|
-
return true;
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
// Purge stale effect cache before creating a new pipeline. pipeline.dispose() calls
|
|
1237
|
-
// gl.deleteProgram() per post-process but leaves entries in Engine._compiledEffects.
|
|
1238
|
-
// A new DefaultRenderingPipeline with the same shader key gets a cache hit and receives
|
|
1239
|
-
// the deleted program → useProgram:deleted on the first render frame.
|
|
1240
|
-
// Safe here because #startRender always stops the loop before reaching this point.
|
|
1241
|
-
this.#scene.getEngine().releaseEffects?.();
|
|
1242
|
-
this.#scene.getEngine().releaseComputeEffects?.();
|
|
1243
|
-
|
|
1244
1212
|
let defaultPipeline = new DefaultRenderingPipeline(pipelineName, true, this.#scene, [this.#scene.activeCamera], true);
|
|
1245
1213
|
|
|
1246
1214
|
if (!defaultPipeline) {
|
|
@@ -1418,11 +1386,6 @@ export default class BabylonJSController {
|
|
|
1418
1386
|
|
|
1419
1387
|
const pipelineName = "PrefViewerIblShadowsRenderPipeline";
|
|
1420
1388
|
|
|
1421
|
-
// Skip recreation if already alive — same reason as DefaultRenderingPipeline guard
|
|
1422
|
-
if (this.#renderPipelines.iblShadows && supportedPipelines.find((p) => p.name === pipelineName)) {
|
|
1423
|
-
return true;
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
1389
|
// Use hardware-adaptive quality settings
|
|
1427
1390
|
const pipelineOptions = {
|
|
1428
1391
|
resolutionExp: this.#config.quality.iblShadowResolution, // Adaptive: 0=1024, 1=2048, 2=4096
|
|
@@ -1674,7 +1637,7 @@ export default class BabylonJSController {
|
|
|
1674
1637
|
const showroom = this.#config.look?.showroom || {};
|
|
1675
1638
|
const imageProcessing = this.#scene.imageProcessingConfiguration;
|
|
1676
1639
|
|
|
1677
|
-
this.#scene.clearColor = new Color4(
|
|
1640
|
+
this.#scene.clearColor = new Color4(0.98, 0.98, 0.98, 1).toLinearSpace();
|
|
1678
1641
|
imageProcessing.exposure = showroom.exposure ?? 0.95;
|
|
1679
1642
|
imageProcessing.contrast = showroom.contrast ?? 1.08;
|
|
1680
1643
|
imageProcessing.toneMappingEnabled = showroom.toneMappingEnabled ?? true;
|
|
@@ -1698,7 +1661,7 @@ export default class BabylonJSController {
|
|
|
1698
1661
|
}
|
|
1699
1662
|
|
|
1700
1663
|
const showroom = this.#config.look?.showroom || {};
|
|
1701
|
-
const timeOfDay = clamp01(this.#settings.lightingTimeOfDay ?? 0.5);
|
|
1664
|
+
const timeOfDay = clamp01(this.#settings.lightingTimeOfDay ? this.#settings.lightingTimeOfDay ?? 0.5 : 0.5);
|
|
1702
1665
|
const profile = getLightingCycleProfile(timeOfDay);
|
|
1703
1666
|
const daylight = profile.dayFactor;
|
|
1704
1667
|
const peakLight = daylight > 0 ? Math.pow(daylight, 0.72) : 0;
|
|
@@ -1714,7 +1677,12 @@ export default class BabylonJSController {
|
|
|
1714
1677
|
imageProcessing.colorCurvesEnabled = false;
|
|
1715
1678
|
}
|
|
1716
1679
|
|
|
1717
|
-
this.#scene.clearColor = new Color4(
|
|
1680
|
+
this.#scene.clearColor = new Color4(
|
|
1681
|
+
lerp(0.035, 0.98, daylight),
|
|
1682
|
+
lerp(0.045, 0.98, daylight),
|
|
1683
|
+
lerp(0.085, 0.98, daylight),
|
|
1684
|
+
1,
|
|
1685
|
+
).toLinearSpace();
|
|
1718
1686
|
|
|
1719
1687
|
if ("environmentIntensity" in this.#scene) {
|
|
1720
1688
|
this.#scene.environmentIntensity = lerp(0.42, showroom.environmentIntensity ?? 1.08, peakLight);
|
|
@@ -2016,7 +1984,7 @@ export default class BabylonJSController {
|
|
|
2016
1984
|
}
|
|
2017
1985
|
|
|
2018
1986
|
/**
|
|
2019
|
-
* Attaches the `
|
|
1987
|
+
* Attaches the `pref-viewer-3d-animation-changed` listener to the nearest `pref-viewer-3d` host.
|
|
2020
1988
|
* Removes any previous registration first to avoid duplicate callbacks across reload cycles.
|
|
2021
1989
|
* @private
|
|
2022
1990
|
* @returns {boolean} True when the listener is attached, false when no host is available.
|
|
@@ -2027,12 +1995,12 @@ export default class BabylonJSController {
|
|
|
2027
1995
|
return false;
|
|
2028
1996
|
}
|
|
2029
1997
|
this.#detachAnimationChangedListener();
|
|
2030
|
-
this.#prefViewer3D.addEventListener("
|
|
1998
|
+
this.#prefViewer3D.addEventListener("pref-viewer-3d-animation-changed", this.#handlers.onAnimationGroupChanged);
|
|
2031
1999
|
return true;
|
|
2032
2000
|
}
|
|
2033
2001
|
|
|
2034
2002
|
/**
|
|
2035
|
-
* Detaches the `
|
|
2003
|
+
* Detaches the `pref-viewer-3d-animation-changed` listener from the cached `pref-viewer-3d` host.
|
|
2036
2004
|
* @private
|
|
2037
2005
|
* @returns {boolean} True when a host exists and the listener removal was attempted, false otherwise.
|
|
2038
2006
|
*/
|
|
@@ -2040,7 +2008,7 @@ export default class BabylonJSController {
|
|
|
2040
2008
|
if (!this.#prefViewer3D) {
|
|
2041
2009
|
return false;
|
|
2042
2010
|
}
|
|
2043
|
-
this.#prefViewer3D.removeEventListener("
|
|
2011
|
+
this.#prefViewer3D.removeEventListener("pref-viewer-3d-animation-changed", this.#handlers.onAnimationGroupChanged);
|
|
2044
2012
|
return true;
|
|
2045
2013
|
}
|
|
2046
2014
|
|
|
@@ -2073,12 +2041,12 @@ export default class BabylonJSController {
|
|
|
2073
2041
|
*/
|
|
2074
2042
|
#onMouseWheel(event, pickInfo) {
|
|
2075
2043
|
event.preventDefault();
|
|
2076
|
-
|
|
2044
|
+
|
|
2077
2045
|
// OPTIMIZATION: Mark activity for idle mode
|
|
2078
2046
|
const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
2079
2047
|
this.#state.render.lastActivityAt = now;
|
|
2080
2048
|
this.#state.render.isIdle = false;
|
|
2081
|
-
|
|
2049
|
+
|
|
2082
2050
|
const camera = this.#scene?.activeCamera;
|
|
2083
2051
|
if (!camera) {
|
|
2084
2052
|
return false;
|
|
@@ -2113,7 +2081,7 @@ export default class BabylonJSController {
|
|
|
2113
2081
|
const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
2114
2082
|
this.#state.render.lastActivityAt = now;
|
|
2115
2083
|
this.#state.render.isIdle = false;
|
|
2116
|
-
|
|
2084
|
+
|
|
2117
2085
|
if (this.#babylonJSAnimationController) {
|
|
2118
2086
|
if (event.button !== 2 || !pickInfo || !pickInfo.pickedMesh) {
|
|
2119
2087
|
this.#babylonJSAnimationController.hideMenu();
|
|
@@ -2138,7 +2106,7 @@ export default class BabylonJSController {
|
|
|
2138
2106
|
const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
2139
2107
|
this.#state.render.lastActivityAt = now;
|
|
2140
2108
|
this.#state.render.isIdle = false;
|
|
2141
|
-
|
|
2109
|
+
|
|
2142
2110
|
const camera = this.#scene?.activeCamera;
|
|
2143
2111
|
if (camera && !camera.metadata?.locked) {
|
|
2144
2112
|
this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
|
|
@@ -2203,10 +2171,6 @@ export default class BabylonJSController {
|
|
|
2203
2171
|
}
|
|
2204
2172
|
this.#state.resize.lastAppliedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
2205
2173
|
console.log(`PrefViewer: Applying resize after ${Math.round(elapsed)}ms`);
|
|
2206
|
-
// Stop the loop before resize: engine.resize() rebuilds DefaultRenderingPipeline
|
|
2207
|
-
// via onResizeObservable, which disposes GL programs. If the loop is running,
|
|
2208
|
-
// the next frame would call useProgram on a deleted program → INVALID_OPERATION.
|
|
2209
|
-
this.#stopEngineRenderLoop();
|
|
2210
2174
|
this.#applyShowroomCameraFraming();
|
|
2211
2175
|
this.#engine.resize();
|
|
2212
2176
|
const needsEnhancedBurst = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled || this.#settings.shadowsEnabled;
|
|
@@ -2249,7 +2213,7 @@ export default class BabylonJSController {
|
|
|
2249
2213
|
* @param {MaterialData} optionMaterial - Material option containing value, nodePrefixes, nodeNames, and state.
|
|
2250
2214
|
* @returns {boolean} True if any mesh material was set, false otherwise.
|
|
2251
2215
|
*/
|
|
2252
|
-
#setOptionsMaterial(optionMaterial
|
|
2216
|
+
#setOptionsMaterial(optionMaterial) {
|
|
2253
2217
|
if (!optionMaterial || !(optionMaterial.value || (optionMaterial.isPending && optionMaterial.update.value)) || !(optionMaterial.nodePrefixes.length || optionMaterial.nodeNames.length)) {
|
|
2254
2218
|
return false;
|
|
2255
2219
|
}
|
|
@@ -2267,7 +2231,7 @@ export default class BabylonJSController {
|
|
|
2267
2231
|
if (container.state.name === "materials") {
|
|
2268
2232
|
return;
|
|
2269
2233
|
}
|
|
2270
|
-
if (container.assetContainer && (
|
|
2234
|
+
if (container.assetContainer && (container.state.isPending || materialContainer.state.isPending || optionMaterial.isPending)) {
|
|
2271
2235
|
assetContainersToProcess.push(container.assetContainer);
|
|
2272
2236
|
}
|
|
2273
2237
|
});
|
|
@@ -2278,7 +2242,7 @@ export default class BabylonJSController {
|
|
|
2278
2242
|
let someSetted = false;
|
|
2279
2243
|
assetContainersToProcess.forEach((assetContainer) =>
|
|
2280
2244
|
assetContainer.meshes
|
|
2281
|
-
.filter((meshToFilter) => optionMaterial.nodePrefixes.some((prefix) => meshToFilter.name.startsWith(prefix)) || optionMaterial.nodeNames.
|
|
2245
|
+
.filter((meshToFilter) => optionMaterial.nodePrefixes.some((prefix) => meshToFilter.name.toLowerCase().startsWith(prefix.toLowerCase())) || optionMaterial.nodeNames.some((n) => n.toLowerCase() === meshToFilter.name.toLowerCase()))
|
|
2282
2246
|
.forEach((mesh) => {
|
|
2283
2247
|
mesh.material = material;
|
|
2284
2248
|
someSetted = true;
|
|
@@ -2300,10 +2264,10 @@ export default class BabylonJSController {
|
|
|
2300
2264
|
* @private
|
|
2301
2265
|
* @returns {boolean} True if any material option was set, false otherwise.
|
|
2302
2266
|
*/
|
|
2303
|
-
#setOptions_Materials(
|
|
2267
|
+
#setOptions_Materials() {
|
|
2304
2268
|
let someSetted = false;
|
|
2305
2269
|
Object.values(this.#options.materials).forEach((material) => {
|
|
2306
|
-
let settedMaterial = this.#setOptionsMaterial(material
|
|
2270
|
+
let settedMaterial = this.#setOptionsMaterial(material);
|
|
2307
2271
|
someSetted = someSetted || settedMaterial;
|
|
2308
2272
|
});
|
|
2309
2273
|
return someSetted;
|
|
@@ -2412,9 +2376,7 @@ export default class BabylonJSController {
|
|
|
2412
2376
|
prefViewer3D = host?.nodeName === "PREF-VIEWER-3D" ? host : undefined;
|
|
2413
2377
|
}
|
|
2414
2378
|
|
|
2415
|
-
|
|
2416
|
-
this.#prefViewer3D = prefViewer3D;
|
|
2417
|
-
}
|
|
2379
|
+
this.#prefViewer3D = prefViewer3D;
|
|
2418
2380
|
}
|
|
2419
2381
|
|
|
2420
2382
|
/**
|
|
@@ -2429,6 +2391,7 @@ export default class BabylonJSController {
|
|
|
2429
2391
|
|
|
2430
2392
|
this.#getPrefViewer3DComponent();
|
|
2431
2393
|
if (this.#prefViewer3D === undefined) {
|
|
2394
|
+
this.#prefViewer = undefined;
|
|
2432
2395
|
return;
|
|
2433
2396
|
}
|
|
2434
2397
|
|
|
@@ -2438,9 +2401,7 @@ export default class BabylonJSController {
|
|
|
2438
2401
|
prefViewer = host?.nodeName === "PREF-VIEWER" ? host : undefined;
|
|
2439
2402
|
}
|
|
2440
2403
|
|
|
2441
|
-
|
|
2442
|
-
this.#prefViewer = prefViewer;
|
|
2443
|
-
}
|
|
2404
|
+
this.#prefViewer = prefViewer;
|
|
2444
2405
|
}
|
|
2445
2406
|
|
|
2446
2407
|
/**
|
|
@@ -2454,94 +2415,13 @@ export default class BabylonJSController {
|
|
|
2454
2415
|
// Cache references to parent custom elements
|
|
2455
2416
|
this.#getPrefViewer3DComponent();
|
|
2456
2417
|
this.#getPrefViewerComponent();
|
|
2457
|
-
const syncVisibility = (component, fallbackAttr) => {
|
|
2458
|
-
if (!component) {
|
|
2459
|
-
return;
|
|
2460
|
-
}
|
|
2461
|
-
if (typeof component.syncVisibility === "function") {
|
|
2462
|
-
component.syncVisibility(name, isVisible);
|
|
2463
|
-
return;
|
|
2464
|
-
}
|
|
2465
|
-
component.setAttribute(fallbackAttr, isVisible ? "true" : "false");
|
|
2466
|
-
};
|
|
2467
2418
|
if (name === "model") {
|
|
2468
|
-
|
|
2469
|
-
|
|
2419
|
+
this.#prefViewer3D?.setAttribute("show-model", isVisible ? "true" : "false");
|
|
2420
|
+
this.#prefViewer?.setAttribute("show-model", isVisible ? "true" : "false");
|
|
2470
2421
|
} else if (name === "environment") {
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
}
|
|
2474
|
-
}
|
|
2475
|
-
|
|
2476
|
-
/**
|
|
2477
|
-
* Dispatches a "model-first-painted" event once the model-only preview has had a chance to render a frame.
|
|
2478
|
-
* @private
|
|
2479
|
-
* @param {object} [detail] - Optional metadata describing the paint gate.
|
|
2480
|
-
* @returns {void}
|
|
2481
|
-
*/
|
|
2482
|
-
#emitModelFirstPainted(detail = {}) {
|
|
2483
|
-
this.#getPrefViewer3DComponent();
|
|
2484
|
-
this.#getPrefViewerComponent();
|
|
2485
|
-
const target = this.#prefViewer || this.#prefViewer3D || this.#canvas;
|
|
2486
|
-
if (!target) {
|
|
2487
|
-
return;
|
|
2488
|
-
}
|
|
2489
|
-
|
|
2490
|
-
const customEventOptions = {
|
|
2491
|
-
bubbles: true,
|
|
2492
|
-
cancelable: false,
|
|
2493
|
-
composed: true,
|
|
2494
|
-
detail,
|
|
2495
|
-
};
|
|
2496
|
-
target.dispatchEvent(new CustomEvent("model-first-painted", customEventOptions));
|
|
2497
|
-
}
|
|
2498
|
-
|
|
2499
|
-
/**
|
|
2500
|
-
* Dispatches a granular 3D load phase event so the ecommerce trace UI can measure tail phases.
|
|
2501
|
-
* @private
|
|
2502
|
-
* @param {string} phase - Phase event name.
|
|
2503
|
-
* @param {object} [detail] - Optional phase metadata.
|
|
2504
|
-
* @returns {void}
|
|
2505
|
-
*/
|
|
2506
|
-
#emitSceneLoadPhase(phase, detail = {}) {
|
|
2507
|
-
this.#getPrefViewer3DComponent();
|
|
2508
|
-
this.#getPrefViewerComponent();
|
|
2509
|
-
const target = this.#prefViewer || this.#prefViewer3D || this.#canvas;
|
|
2510
|
-
if (!target) {
|
|
2511
|
-
return;
|
|
2422
|
+
this.#prefViewer3D?.setAttribute("show-scene", isVisible ? "true" : "false");
|
|
2423
|
+
this.#prefViewer?.setAttribute("show-scene", isVisible ? "true" : "false");
|
|
2512
2424
|
}
|
|
2513
|
-
|
|
2514
|
-
const customEventOptions = {
|
|
2515
|
-
bubbles: true,
|
|
2516
|
-
cancelable: false,
|
|
2517
|
-
composed: true,
|
|
2518
|
-
detail,
|
|
2519
|
-
};
|
|
2520
|
-
target.dispatchEvent(new CustomEvent(phase, customEventOptions));
|
|
2521
|
-
}
|
|
2522
|
-
|
|
2523
|
-
/**
|
|
2524
|
-
* Waits for one or more animation frames so the browser can paint the current model-only state.
|
|
2525
|
-
* @private
|
|
2526
|
-
* @param {number} [frameCount=2] - Number of animation frames to wait.
|
|
2527
|
-
* @returns {Promise<void>}
|
|
2528
|
-
*/
|
|
2529
|
-
async #waitForPaint(frameCount = 2) {
|
|
2530
|
-
if (typeof requestAnimationFrame !== "function") {
|
|
2531
|
-
return;
|
|
2532
|
-
}
|
|
2533
|
-
|
|
2534
|
-
const totalFrames = Math.max(1, frameCount);
|
|
2535
|
-
await new Promise((resolve) => {
|
|
2536
|
-
const step = (remaining) => {
|
|
2537
|
-
if (remaining <= 0) {
|
|
2538
|
-
resolve();
|
|
2539
|
-
return;
|
|
2540
|
-
}
|
|
2541
|
-
requestAnimationFrame(() => step(remaining - 1));
|
|
2542
|
-
};
|
|
2543
|
-
requestAnimationFrame(() => step(totalFrames - 1));
|
|
2544
|
-
});
|
|
2545
2425
|
}
|
|
2546
2426
|
|
|
2547
2427
|
/**
|
|
@@ -2589,7 +2469,7 @@ export default class BabylonJSController {
|
|
|
2589
2469
|
* @param {AssetContainer} newAssetContainer - The new asset container to add to the scene.
|
|
2590
2470
|
* @returns {boolean} True if the container was replaced and added, false otherwise.
|
|
2591
2471
|
*/
|
|
2592
|
-
#replaceContainer(container, newAssetContainer
|
|
2472
|
+
#replaceContainer(container, newAssetContainer) {
|
|
2593
2473
|
if (container.assetContainer) {
|
|
2594
2474
|
this.#removeContainer(container, false);
|
|
2595
2475
|
container.assetContainer.dispose();
|
|
@@ -2601,13 +2481,8 @@ export default class BabylonJSController {
|
|
|
2601
2481
|
newAssetContainer?.dispose();
|
|
2602
2482
|
return false;
|
|
2603
2483
|
}
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
// explicitly passes releaseEffects:false (e.g. loadContainerIndependent).
|
|
2607
|
-
if (releaseEffects && !this.#state.render.isLoopRunning) {
|
|
2608
|
-
this.#scene.getEngine().releaseEffects();
|
|
2609
|
-
this.#scene.getEngine().releaseComputeEffects();
|
|
2610
|
-
}
|
|
2484
|
+
this.#scene.getEngine().releaseEffects();
|
|
2485
|
+
this.#scene.getEngine().releaseComputeEffects();
|
|
2611
2486
|
|
|
2612
2487
|
container.assetContainer = newAssetContainer;
|
|
2613
2488
|
return this.#addContainer(container, false);
|
|
@@ -2643,6 +2518,40 @@ export default class BabylonJSController {
|
|
|
2643
2518
|
assetContainer.lights = [];
|
|
2644
2519
|
}
|
|
2645
2520
|
|
|
2521
|
+
/**
|
|
2522
|
+
* Strips KHR_materials_transmission, KHR_materials_ior and KHR_materials_volume from
|
|
2523
|
+
* environment-container (scene) materials, converting them to simple alpha blending.
|
|
2524
|
+
* Babylon.js cannot render two physical-transmission materials (from separate GLTF
|
|
2525
|
+
* containers) through each other: the scene decorative glass (lamp cups, etc.) is
|
|
2526
|
+
* simplified so the model's physical glass keeps full correct transmission.
|
|
2527
|
+
* @private
|
|
2528
|
+
* @param {AssetContainer} assetContainer
|
|
2529
|
+
* @returns {void}
|
|
2530
|
+
*/
|
|
2531
|
+
#assetContainer_simplifyTransmissiveMaterials(assetContainer) {
|
|
2532
|
+
assetContainer.materials?.forEach((mat) => {
|
|
2533
|
+
if (!(mat instanceof PBRMaterial)) return;
|
|
2534
|
+
if (!mat.subSurface?.isRefractionEnabled && !mat.subSurface?.isTranslucencyEnabled) return;
|
|
2535
|
+
this.#simplifyTransmissiveMaterial(mat);
|
|
2536
|
+
});
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
/**
|
|
2540
|
+
* Converts a single PBRMaterial from physical KHR_materials_transmission to simple alpha
|
|
2541
|
+
* blending. Shared helper used by both the at-load simplification and the post-swap pass.
|
|
2542
|
+
* @private
|
|
2543
|
+
* @param {PBRMaterial} mat
|
|
2544
|
+
* @returns {void}
|
|
2545
|
+
*/
|
|
2546
|
+
#simplifyTransmissiveMaterial(mat) {
|
|
2547
|
+
const intensity = mat.subSurface.refractionIntensity ?? 1.0;
|
|
2548
|
+
mat.subSurface.isRefractionEnabled = false;
|
|
2549
|
+
mat.subSurface.isTranslucencyEnabled = false;
|
|
2550
|
+
mat.alpha = Math.max(0.28, 1.0 - intensity * 0.65);
|
|
2551
|
+
mat.transparencyMode = PBRMaterial.PBRMATERIAL_ALPHABLEND;
|
|
2552
|
+
markPrefViewerSimplifiedTransmissionMaterial(mat);
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2646
2555
|
/**
|
|
2647
2556
|
* Assigns unique ids to every imported camera so Babylon.js does not reuse stale SSAO effects between reloads.
|
|
2648
2557
|
* @private
|
|
@@ -2669,207 +2578,10 @@ export default class BabylonJSController {
|
|
|
2669
2578
|
if (!this.#containers.model.assetContainer || !this.#containers.model.state.isVisible) {
|
|
2670
2579
|
return false;
|
|
2671
2580
|
}
|
|
2672
|
-
|
|
2673
|
-
// Only defer to the environment's visibility after it has fully loaded.
|
|
2674
|
-
show = show !== undefined
|
|
2675
|
-
? show
|
|
2676
|
-
: (this.#containers.environment.state.isSuccess
|
|
2677
|
-
? this.#containers.environment.state.isVisible
|
|
2678
|
-
: true);
|
|
2581
|
+
show = show !== undefined ? show : this.#containers.environment.state.isVisible;
|
|
2679
2582
|
const nodePrefixes = Object.values(this.#options.materials).flatMap((material) => material.nodePrefixes);
|
|
2680
2583
|
const nodeNames = Object.values(this.#options.materials).flatMap((material) => material.nodeNames);
|
|
2681
|
-
this.#containers.model.assetContainer.meshes.filter((meshToFilter) => nodePrefixes.some((prefix) => meshToFilter.name.startsWith(prefix)) || nodeNames.
|
|
2682
|
-
}
|
|
2683
|
-
|
|
2684
|
-
/**
|
|
2685
|
-
* Frames the active ArcRotateCamera around the visible model when no environment scene is loaded.
|
|
2686
|
-
* @private
|
|
2687
|
-
* @returns {boolean} True if the camera was adjusted, otherwise false.
|
|
2688
|
-
*/
|
|
2689
|
-
#frameCameraToModel({ allowWhenEnvironmentVisible = false } = {}) {
|
|
2690
|
-
const camera = this.#scene?.activeCamera instanceof ArcRotateCamera
|
|
2691
|
-
? this.#scene.activeCamera
|
|
2692
|
-
: this.#camera;
|
|
2693
|
-
const modelContainer = this.#containers.model;
|
|
2694
|
-
const inspect = {
|
|
2695
|
-
cameraType: camera?.getClassName?.() ?? camera?.constructor?.name ?? null,
|
|
2696
|
-
hasModelContainer: !!modelContainer.assetContainer,
|
|
2697
|
-
modelVisible: modelContainer.state.isVisible,
|
|
2698
|
-
envHasContainer: !!this.#containers.environment.assetContainer,
|
|
2699
|
-
envVisible: this.#containers.environment.state.isVisible,
|
|
2700
|
-
meshCount: modelContainer.assetContainer?.meshes?.length ?? 0,
|
|
2701
|
-
};
|
|
2702
|
-
this.#debugCameraFrameInfo = { phase: "inspect", ...inspect };
|
|
2703
|
-
console.log("[viewer] frameCameraToModel inspect", inspect);
|
|
2704
|
-
if (!(camera instanceof ArcRotateCamera) || !modelContainer.assetContainer || !modelContainer.state.isVisible) {
|
|
2705
|
-
this.#debugCameraFrameInfo = {
|
|
2706
|
-
phase: "skipped",
|
|
2707
|
-
reason: !(camera instanceof ArcRotateCamera)
|
|
2708
|
-
? "no-arcrotate-camera"
|
|
2709
|
-
: !modelContainer.assetContainer
|
|
2710
|
-
? "no-model"
|
|
2711
|
-
: !modelContainer.state.isVisible
|
|
2712
|
-
? "model-hidden"
|
|
2713
|
-
: "unknown",
|
|
2714
|
-
...inspect,
|
|
2715
|
-
};
|
|
2716
|
-
return false;
|
|
2717
|
-
}
|
|
2718
|
-
if (!allowWhenEnvironmentVisible && this.#containers.environment.assetContainer && this.#containers.environment.state.isVisible) {
|
|
2719
|
-
this.#debugCameraFrameInfo = {
|
|
2720
|
-
phase: "skipped",
|
|
2721
|
-
reason: "environment-visible",
|
|
2722
|
-
...inspect,
|
|
2723
|
-
};
|
|
2724
|
-
return false;
|
|
2725
|
-
}
|
|
2726
|
-
|
|
2727
|
-
let min = null;
|
|
2728
|
-
let max = null;
|
|
2729
|
-
modelContainer.assetContainer.meshes.forEach((mesh) => {
|
|
2730
|
-
if (!mesh?.getTotalVertices?.() || mesh.getTotalVertices() <= 0) {
|
|
2731
|
-
return;
|
|
2732
|
-
}
|
|
2733
|
-
mesh.computeWorldMatrix?.(true);
|
|
2734
|
-
const boundingBox = mesh.getBoundingInfo?.()?.boundingBox;
|
|
2735
|
-
if (!boundingBox) {
|
|
2736
|
-
return;
|
|
2737
|
-
}
|
|
2738
|
-
const minimum = boundingBox.minimumWorld;
|
|
2739
|
-
const maximum = boundingBox.maximumWorld;
|
|
2740
|
-
if (!minimum || !maximum) {
|
|
2741
|
-
return;
|
|
2742
|
-
}
|
|
2743
|
-
min = min
|
|
2744
|
-
? new Vector3(Math.min(min.x, minimum.x), Math.min(min.y, minimum.y), Math.min(min.z, minimum.z))
|
|
2745
|
-
: minimum.clone();
|
|
2746
|
-
max = max
|
|
2747
|
-
? new Vector3(Math.max(max.x, maximum.x), Math.max(max.y, maximum.y), Math.max(max.z, maximum.z))
|
|
2748
|
-
: maximum.clone();
|
|
2749
|
-
});
|
|
2750
|
-
|
|
2751
|
-
if (!min || !max) {
|
|
2752
|
-
const skipped = {
|
|
2753
|
-
hasCamera: camera instanceof ArcRotateCamera,
|
|
2754
|
-
hasModel: !!modelContainer.assetContainer,
|
|
2755
|
-
modelVisible: modelContainer.state.isVisible,
|
|
2756
|
-
environmentVisible: this.#containers.environment.state.isVisible,
|
|
2757
|
-
};
|
|
2758
|
-
this.#debugCameraFrameInfo = {
|
|
2759
|
-
phase: "skipped",
|
|
2760
|
-
reason: "no-bounds",
|
|
2761
|
-
...inspect,
|
|
2762
|
-
...skipped,
|
|
2763
|
-
};
|
|
2764
|
-
console.log("[viewer] frameCameraToModel skipped (no bounds)", skipped);
|
|
2765
|
-
return false;
|
|
2766
|
-
}
|
|
2767
|
-
|
|
2768
|
-
const size = max.subtract(min);
|
|
2769
|
-
const largestDimension = Math.max(size.x, size.y, size.z);
|
|
2770
|
-
if (!Number.isFinite(largestDimension) || largestDimension <= 0) {
|
|
2771
|
-
return false;
|
|
2772
|
-
}
|
|
2773
|
-
|
|
2774
|
-
const center = min.add(max).scale(0.5);
|
|
2775
|
-
camera.setTarget(center);
|
|
2776
|
-
const radiusMultiplier = allowWhenEnvironmentVisible ? 1.7 : 1.4;
|
|
2777
|
-
camera.radius = Math.min(
|
|
2778
|
-
Math.max(largestDimension * radiusMultiplier, camera.lowerRadiusLimit ?? largestDimension * radiusMultiplier),
|
|
2779
|
-
camera.upperRadiusLimit ?? largestDimension * radiusMultiplier,
|
|
2780
|
-
);
|
|
2781
|
-
// Keep the initial model-only view aligned to the showroom's frontal composition.
|
|
2782
|
-
// This preserves the user's camera intent once the scene becomes visible, but gives
|
|
2783
|
-
// the standalone model a consistent front-facing presentation.
|
|
2784
|
-
// Rotate the front-facing composition 180° while keeping the same framing/radius.
|
|
2785
|
-
camera.alpha = Math.PI;
|
|
2786
|
-
camera.beta = Math.PI * 0.47;
|
|
2787
|
-
console.log("[viewer] frameCameraToModel applied", {
|
|
2788
|
-
center,
|
|
2789
|
-
size,
|
|
2790
|
-
radius: camera.radius,
|
|
2791
|
-
});
|
|
2792
|
-
this.#debugCameraFrameInfo = {
|
|
2793
|
-
phase: "applied",
|
|
2794
|
-
...inspect,
|
|
2795
|
-
center: { x: center.x, y: center.y, z: center.z },
|
|
2796
|
-
size: { x: size.x, y: size.y, z: size.z },
|
|
2797
|
-
radius: camera.radius,
|
|
2798
|
-
cameraTarget: camera.target ? { x: camera.target.x, y: camera.target.y, z: camera.target.z } : null,
|
|
2799
|
-
cameraPosition: camera.position ? { x: camera.position.x, y: camera.position.y, z: camera.position.z } : null,
|
|
2800
|
-
cameraLocked: !!camera.metadata?.locked,
|
|
2801
|
-
};
|
|
2802
|
-
return true;
|
|
2803
|
-
}
|
|
2804
|
-
|
|
2805
|
-
/**
|
|
2806
|
-
* Public wrapper to force the active camera to frame the visible model.
|
|
2807
|
-
* @public
|
|
2808
|
-
* @returns {boolean}
|
|
2809
|
-
*/
|
|
2810
|
-
focusModel() {
|
|
2811
|
-
return this.#frameCameraToModel();
|
|
2812
|
-
}
|
|
2813
|
-
|
|
2814
|
-
/**
|
|
2815
|
-
* Returns a compact snapshot of the current debug-relevant 3D state.
|
|
2816
|
-
* @public
|
|
2817
|
-
* @returns {object}
|
|
2818
|
-
*/
|
|
2819
|
-
getDebugState() {
|
|
2820
|
-
const activeCamera = this.#scene?.activeCamera ?? this.#camera ?? null;
|
|
2821
|
-
const modelMeshes = this.#containers.model?.assetContainer?.meshes ?? [];
|
|
2822
|
-
let boundsMin = null;
|
|
2823
|
-
let boundsMax = null;
|
|
2824
|
-
for (const mesh of modelMeshes) {
|
|
2825
|
-
if (!mesh?.getTotalVertices?.() || mesh.getTotalVertices() <= 0) continue;
|
|
2826
|
-
mesh.computeWorldMatrix?.(true);
|
|
2827
|
-
const boundingBox = mesh.getBoundingInfo?.()?.boundingBox;
|
|
2828
|
-
if (!boundingBox?.minimumWorld || !boundingBox?.maximumWorld) continue;
|
|
2829
|
-
const minimum = boundingBox.minimumWorld;
|
|
2830
|
-
const maximum = boundingBox.maximumWorld;
|
|
2831
|
-
boundsMin = boundsMin
|
|
2832
|
-
? { x: Math.min(boundsMin.x, minimum.x), y: Math.min(boundsMin.y, minimum.y), z: Math.min(boundsMin.z, minimum.z) }
|
|
2833
|
-
: { x: minimum.x, y: minimum.y, z: minimum.z };
|
|
2834
|
-
boundsMax = boundsMax
|
|
2835
|
-
? { x: Math.max(boundsMax.x, maximum.x), y: Math.max(boundsMax.y, maximum.y), z: Math.max(boundsMax.z, maximum.z) }
|
|
2836
|
-
: { x: maximum.x, y: maximum.y, z: maximum.z };
|
|
2837
|
-
}
|
|
2838
|
-
const modelBounds = boundsMin && boundsMax ? {
|
|
2839
|
-
min: boundsMin,
|
|
2840
|
-
max: boundsMax,
|
|
2841
|
-
center: {
|
|
2842
|
-
x: (boundsMin.x + boundsMax.x) / 2,
|
|
2843
|
-
y: (boundsMin.y + boundsMax.y) / 2,
|
|
2844
|
-
z: (boundsMin.z + boundsMax.z) / 2,
|
|
2845
|
-
},
|
|
2846
|
-
size: {
|
|
2847
|
-
x: boundsMax.x - boundsMin.x,
|
|
2848
|
-
y: boundsMax.y - boundsMin.y,
|
|
2849
|
-
z: boundsMax.z - boundsMin.z,
|
|
2850
|
-
},
|
|
2851
|
-
} : null;
|
|
2852
|
-
return {
|
|
2853
|
-
camera: activeCamera ? {
|
|
2854
|
-
className: activeCamera.getClassName?.() ?? activeCamera.constructor?.name ?? null,
|
|
2855
|
-
position: activeCamera.position ? { x: activeCamera.position.x, y: activeCamera.position.y, z: activeCamera.position.z } : null,
|
|
2856
|
-
target: activeCamera.target ? { x: activeCamera.target.x, y: activeCamera.target.y, z: activeCamera.target.z } : null,
|
|
2857
|
-
radius: typeof activeCamera.radius === "number" ? activeCamera.radius : null,
|
|
2858
|
-
locked: !!activeCamera.metadata?.locked,
|
|
2859
|
-
} : null,
|
|
2860
|
-
model: {
|
|
2861
|
-
visible: !!this.#containers.model?.state?.isVisible,
|
|
2862
|
-
hasContainer: !!this.#containers.model?.assetContainer,
|
|
2863
|
-
meshCount: this.#containers.model?.assetContainer?.meshes?.length ?? 0,
|
|
2864
|
-
bounds: modelBounds,
|
|
2865
|
-
},
|
|
2866
|
-
environment: {
|
|
2867
|
-
visible: !!this.#containers.environment?.state?.isVisible,
|
|
2868
|
-
hasContainer: !!this.#containers.environment?.assetContainer,
|
|
2869
|
-
},
|
|
2870
|
-
lastCameraFrame: this.#debugCameraFrameInfo,
|
|
2871
|
-
renderLoopRunning: !!this.#state?.render?.isLoopRunning,
|
|
2872
|
-
};
|
|
2584
|
+
this.#containers.model.assetContainer.meshes.filter((meshToFilter) => nodePrefixes.some((prefix) => meshToFilter.name.toLowerCase().startsWith(prefix.toLowerCase())) || nodeNames.some((n) => n.toLowerCase() === meshToFilter.name.toLowerCase())).forEach((mesh) => mesh.setEnabled(show));
|
|
2873
2585
|
}
|
|
2874
2586
|
|
|
2875
2587
|
/**
|
|
@@ -2889,25 +2601,12 @@ export default class BabylonJSController {
|
|
|
2889
2601
|
* @private
|
|
2890
2602
|
* @returns {Promise<void>}
|
|
2891
2603
|
*/
|
|
2892
|
-
async #startRender(
|
|
2604
|
+
async #startRender() {
|
|
2893
2605
|
if (!this.#scene) return;
|
|
2894
|
-
this.#
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
}
|
|
2899
|
-
// skipWhenReady: model materials already compiled via compileMaterials:true.
|
|
2900
|
-
// Skips scene-wide whenReadyAsync so the model appears immediately (~1s).
|
|
2901
|
-
if (!skipWhenReady) {
|
|
2902
|
-
await this.#scene.whenReadyAsync();
|
|
2903
|
-
}
|
|
2904
|
-
const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#config.render.burstFramesEnhanced : this.#config.render.burstFramesBase;
|
|
2905
|
-
// forceRender: bypasses #loadingInProgress gate so loadContainerIndependent
|
|
2906
|
-
// can show the model preview while #loadContainers is still loading scene/materials.
|
|
2907
|
-
this.#requestRender({ frames: frames, continuousMs: this.#config.render.interactionMs, force: forceRender });
|
|
2908
|
-
} finally {
|
|
2909
|
-
this.#startRenderInProgress = false;
|
|
2910
|
-
}
|
|
2606
|
+
await this.#loadCameraDependentEffects();
|
|
2607
|
+
await this.#scene.whenReadyAsync();
|
|
2608
|
+
const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#config.render.burstFramesEnhanced : this.#config.render.burstFramesBase;
|
|
2609
|
+
this.#requestRender({ frames: frames, continuousMs: this.#config.render.interactionMs });
|
|
2911
2610
|
}
|
|
2912
2611
|
|
|
2913
2612
|
/**
|
|
@@ -2926,7 +2625,7 @@ export default class BabylonJSController {
|
|
|
2926
2625
|
* 3. Builds the Babylon plugin options so extras are surfaced as metadata, then imports the container with
|
|
2927
2626
|
* `LoadAssetContainerAsync`, returning the tuple so the caller can decide how to attach it to the scene.
|
|
2928
2627
|
*/
|
|
2929
|
-
async #loadAssetContainer(container, force = false
|
|
2628
|
+
async #loadAssetContainer(container, force = false) {
|
|
2930
2629
|
if (container?.state?.update?.storage === undefined || container?.state?.size === undefined || container?.state?.timeStamp === undefined) {
|
|
2931
2630
|
return [container, false];
|
|
2932
2631
|
}
|
|
@@ -2942,30 +2641,12 @@ export default class BabylonJSController {
|
|
|
2942
2641
|
const currentSize = force ? 0 : container.state.size;
|
|
2943
2642
|
const currentTimeStamp = force ? null : container.state.timeStamp;
|
|
2944
2643
|
|
|
2945
|
-
const storageType = container.state.update.storage?.db ? "idb" : container.state.update.storage?.url ? "url" : "?";
|
|
2946
|
-
const storageId = container.state.update.storage?.id ?? container.state.update.storage?.url ?? "?";
|
|
2947
|
-
const tResolve = performance.now();
|
|
2948
2644
|
let sourceData = await this.#gltfResolver.getSource(container.state.update.storage, currentSize, currentTimeStamp);
|
|
2949
|
-
console.log(`[perf][${container.state.name}] getSource(${storageType}:${storageId}) ${Math.round(performance.now() - tResolve)}ms → ${sourceData ? `${sourceData.extension} ${sourceData.objectURLs?.length ?? 0} assets` : "skipped"}`);
|
|
2950
2645
|
|
|
2951
2646
|
if (!sourceData) {
|
|
2952
2647
|
return [container, false];
|
|
2953
2648
|
}
|
|
2954
2649
|
|
|
2955
|
-
// Abort if loadContainerIndependent claimed this container while getSource was in flight.
|
|
2956
|
-
// isIndependent=true means THIS call IS the independent load — don't abort yourself.
|
|
2957
|
-
// Returns null (not false) so #loadContainers skips setSuccess(false) for this container.
|
|
2958
|
-
const isClaimedIndependently =
|
|
2959
|
-
(container.state.name === "model" && this.#isLoadingModelIndependent) ||
|
|
2960
|
-
(container.state.name === "environment" && this.#isLoadingEnvironmentIndependent) ||
|
|
2961
|
-
(container.state.name === "materials" && this.#isLoadingMaterialsIndependent);
|
|
2962
|
-
if (isClaimedIndependently && !isIndependent) {
|
|
2963
|
-
if (sourceData.objectURLs?.length) {
|
|
2964
|
-
this.#gltfResolver.revokeObjectURLs(sourceData.objectURLs);
|
|
2965
|
-
}
|
|
2966
|
-
return [container, null];
|
|
2967
|
-
}
|
|
2968
|
-
|
|
2969
2650
|
container.state.setPendingCacheData(sourceData.size, sourceData.timeStamp, sourceData.metadata);
|
|
2970
2651
|
|
|
2971
2652
|
// https://doc.babylonjs.com/typedoc/interfaces/BABYLON.LoadAssetContainerOptions
|
|
@@ -2984,14 +2665,12 @@ export default class BabylonJSController {
|
|
|
2984
2665
|
};
|
|
2985
2666
|
|
|
2986
2667
|
let assetContainer = null;
|
|
2987
|
-
const t0 = performance.now();
|
|
2988
2668
|
|
|
2989
2669
|
try {
|
|
2990
2670
|
assetContainer = await LoadAssetContainerAsync(sourceData.source, this.#scene, options);
|
|
2991
|
-
const elapsed = Math.round(performance.now() - t0);
|
|
2992
|
-
console.log(`[perf][${container.state.name}] LoadAssetContainerAsync ${elapsed}ms`);
|
|
2993
2671
|
return [container, assetContainer];
|
|
2994
2672
|
} catch (error) {
|
|
2673
|
+
console.error(`[PrefViewer] LoadAssetContainerAsync failed for [${container.state.name}]`, error);
|
|
2995
2674
|
return [container, assetContainer];
|
|
2996
2675
|
} finally {
|
|
2997
2676
|
this.#gltfResolver.revokeObjectURLs(sourceData.objectURLs);
|
|
@@ -3009,29 +2688,7 @@ export default class BabylonJSController {
|
|
|
3009
2688
|
*/
|
|
3010
2689
|
async #loadContainers(force = false) {
|
|
3011
2690
|
this.#detachAnimationChangedListener();
|
|
3012
|
-
this.#
|
|
3013
|
-
|
|
3014
|
-
// When only the model is pending (scene/environment already rendered), skip
|
|
3015
|
-
// unloading camera-dependent effects (DefaultRenderingPipeline, SSAO, shadows).
|
|
3016
|
-
// Disposing those pipelines while the render loop was recently active deletes GL
|
|
3017
|
-
// programs that are still referenced, causing WebGL INVALID_OPERATION errors.
|
|
3018
|
-
const isModelOnlyLoad =
|
|
3019
|
-
this.#containers.model?.state?.isPending === true &&
|
|
3020
|
-
this.#containers.environment?.state?.isPending !== true &&
|
|
3021
|
-
this.#containers.materials?.state?.isPending !== true;
|
|
3022
|
-
|
|
3023
|
-
if (isModelOnlyLoad) {
|
|
3024
|
-
this.#stopEngineRenderLoop();
|
|
3025
|
-
this.#resetRenderState();
|
|
3026
|
-
// Replacing an already-visible model can leave Babylon post-process chains attached to
|
|
3027
|
-
// a camera that is about to be discarded or re-bound. Tear them down here so the next
|
|
3028
|
-
// startRender() rebuilds a clean pipeline instead of tripping over stale null entries.
|
|
3029
|
-
if (this.#containers.model?.assetContainer) {
|
|
3030
|
-
await this.#unloadCameraDependentEffects();
|
|
3031
|
-
}
|
|
3032
|
-
} else {
|
|
3033
|
-
await this.#stopRender();
|
|
3034
|
-
}
|
|
2691
|
+
await this.#stopRender();
|
|
3035
2692
|
|
|
3036
2693
|
let oldModelMetadata = { ...(this.#containers.model?.state?.metadata ?? {}) };
|
|
3037
2694
|
let newModelMetadata = {};
|
|
@@ -3046,12 +2703,8 @@ export default class BabylonJSController {
|
|
|
3046
2703
|
error: null,
|
|
3047
2704
|
};
|
|
3048
2705
|
|
|
3049
|
-
const tSceneStart = performance.now();
|
|
3050
|
-
let tAfterLoad, tAfterMerge;
|
|
3051
|
-
|
|
3052
2706
|
await Promise.allSettled(promiseArray)
|
|
3053
2707
|
.then(async (values) => {
|
|
3054
|
-
tAfterLoad = performance.now();
|
|
3055
2708
|
// Scene may have been disposed (disconnectedCallback) while async loading was in
|
|
3056
2709
|
// progress. Abort cleanly: #replaceContainer already guards the GPU calls, but
|
|
3057
2710
|
// we skip the post-load option/visibility calls too to avoid further null-derefs.
|
|
@@ -3060,12 +2713,6 @@ export default class BabylonJSController {
|
|
|
3060
2713
|
return;
|
|
3061
2714
|
}
|
|
3062
2715
|
this.#disposeAnimationController();
|
|
3063
|
-
this.#emitSceneLoadPhase("scene-load-assets-complete", {
|
|
3064
|
-
path: "full",
|
|
3065
|
-
containerCount: values.filter((result) => result.status === "fulfilled" && result.value?.[1]).length,
|
|
3066
|
-
tAfterLoad: tAfterLoad != null ? Math.round(tAfterLoad) : null,
|
|
3067
|
-
tSceneStart: Math.round(tSceneStart),
|
|
3068
|
-
});
|
|
3069
2716
|
values.forEach((result) => {
|
|
3070
2717
|
const container = result.value ? result.value[0] : null;
|
|
3071
2718
|
const assetContainer = result.value ? result.value[1] : null;
|
|
@@ -3078,32 +2725,23 @@ export default class BabylonJSController {
|
|
|
3078
2725
|
if (container.state.name === "model" || container.state.name === "environment") {
|
|
3079
2726
|
this.#assetContainer_retagCameras(assetContainer);
|
|
3080
2727
|
}
|
|
2728
|
+
if (container.state.name === "environment") {
|
|
2729
|
+
this.#assetContainer_simplifyTransmissiveMaterials(assetContainer);
|
|
2730
|
+
}
|
|
3081
2731
|
this.#replaceContainer(container, assetContainer);
|
|
3082
2732
|
container.state.setSuccess(true);
|
|
3083
2733
|
} else {
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
if (assetContainer !== null) {
|
|
3087
|
-
if (container.assetContainer && container.state.mustBeShown !== container.state.isVisible && container.state.name === "materials") {
|
|
3088
|
-
container.state.mustBeShown ? this.#addContainer(container) : this.#removeContainer(container);
|
|
3089
|
-
}
|
|
3090
|
-
container.state.setSuccess(false);
|
|
2734
|
+
if (container.assetContainer && container.state.mustBeShown !== container.state.isVisible && container.state.name === "materials") {
|
|
2735
|
+
container.state.mustBeShown ? this.#addContainer(container) : this.#removeContainer(container);
|
|
3091
2736
|
}
|
|
2737
|
+
container.state.setSuccess(false);
|
|
3092
2738
|
}
|
|
3093
2739
|
});
|
|
3094
2740
|
|
|
3095
|
-
this.#setOptions_Materials(
|
|
2741
|
+
this.#setOptions_Materials();
|
|
3096
2742
|
this.#setOptions_Camera();
|
|
3097
2743
|
await this.#setOptions_IBL();
|
|
3098
2744
|
this.#setVisibilityOfWallAndFloorInModel();
|
|
3099
|
-
this.#frameCameraToModel();
|
|
3100
|
-
tAfterMerge = performance.now();
|
|
3101
|
-
this.#emitSceneLoadPhase("scene-load-merge-complete", {
|
|
3102
|
-
path: "full",
|
|
3103
|
-
tAfterMerge: tAfterMerge != null ? Math.round(tAfterMerge) : null,
|
|
3104
|
-
tAfterLoad: tAfterLoad != null ? Math.round(tAfterLoad) : null,
|
|
3105
|
-
tSceneStart: Math.round(tSceneStart),
|
|
3106
|
-
});
|
|
3107
2745
|
detail.success = true;
|
|
3108
2746
|
})
|
|
3109
2747
|
.catch((error) => {
|
|
@@ -3116,39 +2754,34 @@ export default class BabylonJSController {
|
|
|
3116
2754
|
this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
|
|
3117
2755
|
this.#setMaxSimultaneousLights();
|
|
3118
2756
|
this.#enhanceTextureQuality();
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
}
|
|
3122
|
-
// Apply stored highlight settings so fresh page loads respect persisted state
|
|
3123
|
-
this.#setHighlightConfig({
|
|
3124
|
-
enabled: this.#settings.highlightEnabled,
|
|
3125
|
-
color: this.#settings.highlightColor,
|
|
3126
|
-
});
|
|
3127
|
-
if (this.#babylonJSAnimationController?.hasAnimations?.()) {
|
|
3128
|
-
this.#attachAnimationChangedListener();
|
|
3129
|
-
}
|
|
3130
|
-
this.#loadingInProgress = false;
|
|
3131
|
-
// If loadContainerIndependent already merged a container and started the render
|
|
3132
|
-
// loop while this scene load was in flight, #startRender was already called.
|
|
3133
|
-
// Calling it again is safe (pipeline guards skip recreation) but redundant only
|
|
3134
|
-
// when the loop is already running AND scene/shaders are ready; whenReadyAsync
|
|
3135
|
-
// will settle quickly for the newly added containers.
|
|
2757
|
+
this.#syncAnimationControllerAfterModelLoad();
|
|
2758
|
+
|
|
3136
2759
|
await this.#startRender();
|
|
3137
|
-
this.#frameCameraToModel({ allowWhenEnvironmentVisible: true });
|
|
3138
|
-
this.#emitSceneLoadPhase("scene-load-shaders-complete", {
|
|
3139
|
-
path: "full",
|
|
3140
|
-
tAfterShaders: Math.round(performance.now()),
|
|
3141
|
-
tAfterMerge: tAfterMerge != null ? Math.round(tAfterMerge) : null,
|
|
3142
|
-
});
|
|
3143
|
-
const tLoad = tAfterLoad ? Math.round(tAfterLoad - tSceneStart) : null;
|
|
3144
|
-
const tMerge = tAfterLoad && tAfterMerge ? Math.round(tAfterMerge - tAfterLoad) : null;
|
|
3145
|
-
const tShaders = tAfterMerge ? Math.round(performance.now() - tAfterMerge) : null;
|
|
3146
|
-
const tTotal = Math.round(performance.now() - tSceneStart);
|
|
3147
|
-
console.log(`[perf][scene] load=${tLoad}ms merge=${tMerge}ms shaders=${tShaders}ms total=${tTotal}ms`);
|
|
3148
2760
|
});
|
|
3149
2761
|
return detail;
|
|
3150
2762
|
}
|
|
3151
2763
|
|
|
2764
|
+
/**
|
|
2765
|
+
* Creates or disposes the animation controller after a model load according to current settings.
|
|
2766
|
+
* @private
|
|
2767
|
+
* @returns {void}
|
|
2768
|
+
*/
|
|
2769
|
+
#syncAnimationControllerAfterModelLoad() {
|
|
2770
|
+
if (this.#settings.showAnimationMenu && this.#containers.model.assetContainer) {
|
|
2771
|
+
this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
|
|
2772
|
+
// Apply stored highlight settings so fresh page loads respect persisted state
|
|
2773
|
+
this.#setHighlightConfig({
|
|
2774
|
+
enabled: this.#settings.highlightEnabled,
|
|
2775
|
+
color: this.#settings.highlightColor,
|
|
2776
|
+
});
|
|
2777
|
+
if (this.#babylonJSAnimationController?.hasAnimations?.()) {
|
|
2778
|
+
this.#attachAnimationChangedListener();
|
|
2779
|
+
}
|
|
2780
|
+
} else {
|
|
2781
|
+
this.#disposeAnimationController();
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
|
|
3152
2785
|
/**
|
|
3153
2786
|
* Reinstalls every camera-sensitive post-process (default pipeline, SSAO, shadows) after loads or option changes.
|
|
3154
2787
|
* Ensures the active camera always owns fresh pipelines so render quality remains consistent across reload cycles.
|
|
@@ -3260,7 +2893,7 @@ export default class BabylonJSController {
|
|
|
3260
2893
|
if (!this.#scene) {
|
|
3261
2894
|
return false;
|
|
3262
2895
|
}
|
|
3263
|
-
const node = this.#scene.getNodeByName(name);
|
|
2896
|
+
const node = this.#scene.getNodeByName(name) ?? this.#scene.getNodes().find((n) => n.name.toLowerCase() === name.toLowerCase());
|
|
3264
2897
|
if (!node || !node.position) {
|
|
3265
2898
|
return false;
|
|
3266
2899
|
}
|
|
@@ -3402,14 +3035,14 @@ export default class BabylonJSController {
|
|
|
3402
3035
|
this.#configureDracoCompression();
|
|
3403
3036
|
this.#engine = new Engine(this.#canvas, true, { alpha: true, stencil: true, preserveDrawingBuffer: false }, true);
|
|
3404
3037
|
this.#engine.disableUniformBuffers = true;
|
|
3405
|
-
|
|
3038
|
+
|
|
3406
3039
|
// OPTIMIZATION: Detect hardware tier and apply quality settings
|
|
3407
3040
|
const detectedTier = this.#detectHardwareTier();
|
|
3408
3041
|
this.#applyQualitySettings(detectedTier);
|
|
3409
|
-
|
|
3042
|
+
|
|
3410
3043
|
// OPTIMIZATION: Initialize idle tracking
|
|
3411
3044
|
this.#state.render.lastActivityAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
3412
|
-
|
|
3045
|
+
|
|
3413
3046
|
this.#scene = new Scene(this.#engine);
|
|
3414
3047
|
|
|
3415
3048
|
// Activate the rendering of geometry data into a G-buffer, essential for advanced effects like deferred shading,
|
|
@@ -3430,6 +3063,7 @@ export default class BabylonJSController {
|
|
|
3430
3063
|
this.#scene.skipPointerUpPicking = true;
|
|
3431
3064
|
|
|
3432
3065
|
this.#createCamera();
|
|
3066
|
+
await this.#ensureEnvironmentBRDFTextureReady();
|
|
3433
3067
|
this.#enableInteraction();
|
|
3434
3068
|
await this.#createXRExperience();
|
|
3435
3069
|
}
|
|
@@ -3585,6 +3219,182 @@ export default class BabylonJSController {
|
|
|
3585
3219
|
return { ...this.#settings };
|
|
3586
3220
|
}
|
|
3587
3221
|
|
|
3222
|
+
/**
|
|
3223
|
+
* Returns a diagnostic snapshot of the current Babylon.js runtime state.
|
|
3224
|
+
* Intended for integration troubleshooting and should not mutate scene state.
|
|
3225
|
+
* @public
|
|
3226
|
+
* @returns {object} Debug information for canvas, engine, scene, containers, render loop and settings.
|
|
3227
|
+
*/
|
|
3228
|
+
getDebugInfo() {
|
|
3229
|
+
const vectorToJSON = (value) => value ? { x: value.x, y: value.y, z: value.z } : null;
|
|
3230
|
+
const rect = this.#canvas?.getBoundingClientRect?.();
|
|
3231
|
+
const camera = this.#scene?.activeCamera ?? null;
|
|
3232
|
+
const sceneMeshes = this.#scene?.meshes ?? [];
|
|
3233
|
+
const sceneMaterials = this.#scene?.materials ?? [];
|
|
3234
|
+
const sceneTextures = this.#scene?.textures ?? [];
|
|
3235
|
+
const activeMeshes = this.#scene?.getActiveMeshes?.();
|
|
3236
|
+
const modelAssetContainer = this.#containers?.model?.assetContainer ?? null;
|
|
3237
|
+
const modelMeshes = modelAssetContainer?.meshes ?? [];
|
|
3238
|
+
const enabledModelMeshes = modelMeshes.filter((mesh) => mesh.isEnabled?.() !== false && mesh.isVisible !== false);
|
|
3239
|
+
const textureToJSON = (texture) => ({
|
|
3240
|
+
name: texture?.name ?? null,
|
|
3241
|
+
url: texture?.url ?? null,
|
|
3242
|
+
isReady: texture?.isReady?.() ?? null,
|
|
3243
|
+
hasTexture: Boolean(texture),
|
|
3244
|
+
className: texture?.getClassName?.() ?? texture?.constructor?.name ?? null,
|
|
3245
|
+
});
|
|
3246
|
+
const summarizeMaterial = (material) => {
|
|
3247
|
+
const relatedMeshes = sceneMeshes.filter((mesh) => mesh.material === material);
|
|
3248
|
+
const sampleMesh = relatedMeshes[0] ?? null;
|
|
3249
|
+
const activeTextures = material?.getActiveTextures?.() ?? [];
|
|
3250
|
+
let isReadyWithoutMesh = null;
|
|
3251
|
+
let isReadyWithMesh = null;
|
|
3252
|
+
try {
|
|
3253
|
+
isReadyWithoutMesh = typeof material?.isReady === "function" ? material.isReady() : null;
|
|
3254
|
+
} catch (error) {
|
|
3255
|
+
isReadyWithoutMesh = `error: ${error?.message ?? error}`;
|
|
3256
|
+
}
|
|
3257
|
+
try {
|
|
3258
|
+
isReadyWithMesh = typeof material?.isReady === "function" && sampleMesh ? material.isReady(sampleMesh) : null;
|
|
3259
|
+
} catch (error) {
|
|
3260
|
+
isReadyWithMesh = `error: ${error?.message ?? error}`;
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
return {
|
|
3264
|
+
name: material?.name ?? null,
|
|
3265
|
+
id: material?.id ?? null,
|
|
3266
|
+
className: material?.getClassName?.() ?? material?.constructor?.name ?? null,
|
|
3267
|
+
isReadyWithoutMesh,
|
|
3268
|
+
isReadyWithMesh,
|
|
3269
|
+
relatedMeshes: relatedMeshes.length,
|
|
3270
|
+
sampleMesh: sampleMesh ? {
|
|
3271
|
+
name: sampleMesh.name,
|
|
3272
|
+
id: sampleMesh.id,
|
|
3273
|
+
enabled: sampleMesh.isEnabled?.() ?? null,
|
|
3274
|
+
isVisible: sampleMesh.isVisible ?? null,
|
|
3275
|
+
hasVertices: sampleMesh.getTotalVertices?.() ?? null,
|
|
3276
|
+
hasIndices: sampleMesh.getTotalIndices?.() ?? null,
|
|
3277
|
+
} : null,
|
|
3278
|
+
textures: activeTextures.map(textureToJSON),
|
|
3279
|
+
notReadyTextures: activeTextures.filter((texture) => texture?.isReady?.() === false).map(textureToJSON),
|
|
3280
|
+
forceDepthWrite: material?.forceDepthWrite ?? null,
|
|
3281
|
+
transparencyMode: material?.transparencyMode ?? null,
|
|
3282
|
+
alpha: material?.alpha ?? null,
|
|
3283
|
+
backFaceCulling: material?.backFaceCulling ?? null,
|
|
3284
|
+
disableLighting: material?.disableLighting ?? null,
|
|
3285
|
+
};
|
|
3286
|
+
};
|
|
3287
|
+
const materialDiagnostics = sceneMaterials.slice(0, 25).map(summarizeMaterial);
|
|
3288
|
+
const notReadyMaterials = materialDiagnostics
|
|
3289
|
+
.filter((material) => material.isReadyWithoutMesh === false || material.isReadyWithMesh === false || material.notReadyTextures.length > 0)
|
|
3290
|
+
.slice(0, 25);
|
|
3291
|
+
const notReadyTextures = sceneTextures
|
|
3292
|
+
.filter((texture) => texture?.isReady?.() === false)
|
|
3293
|
+
.slice(0, 25)
|
|
3294
|
+
.map(textureToJSON);
|
|
3295
|
+
const notReadyMeshes = sceneMeshes
|
|
3296
|
+
.filter((mesh) => typeof mesh.isReady === "function" && !mesh.isReady())
|
|
3297
|
+
.slice(0, 25)
|
|
3298
|
+
.map((mesh) => ({
|
|
3299
|
+
name: mesh.name,
|
|
3300
|
+
id: mesh.id,
|
|
3301
|
+
enabled: mesh.isEnabled?.() ?? null,
|
|
3302
|
+
isVisible: mesh.isVisible ?? null,
|
|
3303
|
+
material: mesh.material?.name ?? null,
|
|
3304
|
+
}));
|
|
3305
|
+
const modelBounds = enabledModelMeshes.length && this.#scene?.getWorldExtends
|
|
3306
|
+
? this.#scene.getWorldExtends((mesh) => enabledModelMeshes.includes(mesh))
|
|
3307
|
+
: null;
|
|
3308
|
+
const summarizeContainer = (container) => ({
|
|
3309
|
+
show: container?.state?.show ?? null,
|
|
3310
|
+
visible: container?.state?.visible ?? null,
|
|
3311
|
+
mustBeShown: container?.state?.mustBeShown ?? null,
|
|
3312
|
+
isPending: container?.state?.isPending ?? null,
|
|
3313
|
+
isSuccess: container?.state?.isSuccess ?? null,
|
|
3314
|
+
hasAssetContainer: Boolean(container?.assetContainer),
|
|
3315
|
+
meshes: container?.assetContainer?.meshes?.length ?? 0,
|
|
3316
|
+
enabledMeshes: (container?.assetContainer?.meshes ?? []).filter((mesh) => mesh.isEnabled?.() !== false && mesh.isVisible !== false).length,
|
|
3317
|
+
materials: container?.assetContainer?.materials?.length ?? 0,
|
|
3318
|
+
cameras: container?.assetContainer?.cameras?.map((item) => ({
|
|
3319
|
+
name: item.name,
|
|
3320
|
+
id: item.id,
|
|
3321
|
+
className: item.getClassName?.() ?? item.constructor?.name ?? null,
|
|
3322
|
+
position: vectorToJSON(item.position),
|
|
3323
|
+
target: vectorToJSON(item.target),
|
|
3324
|
+
})) ?? [],
|
|
3325
|
+
animationGroups: container?.assetContainer?.animationGroups?.length ?? 0,
|
|
3326
|
+
});
|
|
3327
|
+
|
|
3328
|
+
return {
|
|
3329
|
+
canvas: {
|
|
3330
|
+
clientWidth: this.#canvas?.clientWidth ?? null,
|
|
3331
|
+
clientHeight: this.#canvas?.clientHeight ?? null,
|
|
3332
|
+
width: this.#canvas?.width ?? null,
|
|
3333
|
+
height: this.#canvas?.height ?? null,
|
|
3334
|
+
boundingWidth: rect?.width ?? null,
|
|
3335
|
+
boundingHeight: rect?.height ?? null,
|
|
3336
|
+
},
|
|
3337
|
+
engine: {
|
|
3338
|
+
renderWidth: this.#engine?.getRenderWidth?.() ?? null,
|
|
3339
|
+
renderHeight: this.#engine?.getRenderHeight?.() ?? null,
|
|
3340
|
+
hardwareScalingLevel: this.#engine?.getHardwareScalingLevel?.() ?? null,
|
|
3341
|
+
isPointerLock: this.#engine?.isPointerLock ?? null,
|
|
3342
|
+
isDisposed: this.#engine?.isDisposed ?? null,
|
|
3343
|
+
contextLost: this.#engine?.isContextLost?.() ?? null,
|
|
3344
|
+
},
|
|
3345
|
+
scene: {
|
|
3346
|
+
uid: this.#scene?.uid ?? null,
|
|
3347
|
+
isReady: this.#scene?.isReady?.() ?? null,
|
|
3348
|
+
meshes: sceneMeshes.length,
|
|
3349
|
+
visibleMeshes: activeMeshes?.length ?? activeMeshes?.data?.length ?? null,
|
|
3350
|
+
materials: sceneMaterials.length,
|
|
3351
|
+
textures: sceneTextures.length,
|
|
3352
|
+
lights: this.#scene?.lights?.length ?? 0,
|
|
3353
|
+
animationGroups: this.#scene?.animationGroups?.length ?? 0,
|
|
3354
|
+
activeCamera: camera ? {
|
|
3355
|
+
name: camera.name,
|
|
3356
|
+
id: camera.id,
|
|
3357
|
+
className: camera.getClassName?.() ?? camera.constructor?.name ?? null,
|
|
3358
|
+
position: vectorToJSON(camera.position),
|
|
3359
|
+
target: vectorToJSON(camera.target),
|
|
3360
|
+
radius: camera.radius ?? null,
|
|
3361
|
+
alpha: camera.alpha ?? null,
|
|
3362
|
+
beta: camera.beta ?? null,
|
|
3363
|
+
} : null,
|
|
3364
|
+
clearColor: this.#scene?.clearColor ? {
|
|
3365
|
+
r: this.#scene.clearColor.r,
|
|
3366
|
+
g: this.#scene.clearColor.g,
|
|
3367
|
+
b: this.#scene.clearColor.b,
|
|
3368
|
+
a: this.#scene.clearColor.a,
|
|
3369
|
+
} : null,
|
|
3370
|
+
environmentTexture: this.#scene?.environmentTexture ? {
|
|
3371
|
+
name: this.#scene.environmentTexture.name,
|
|
3372
|
+
isReady: this.#scene.environmentTexture.isReady?.() ?? null,
|
|
3373
|
+
} : null,
|
|
3374
|
+
notReadyMaterials,
|
|
3375
|
+
notReadyTextures,
|
|
3376
|
+
materialDiagnostics,
|
|
3377
|
+
notReadyMeshes,
|
|
3378
|
+
},
|
|
3379
|
+
containers: {
|
|
3380
|
+
model: summarizeContainer(this.#containers.model),
|
|
3381
|
+
environment: summarizeContainer(this.#containers.environment),
|
|
3382
|
+
materials: summarizeContainer(this.#containers.materials),
|
|
3383
|
+
},
|
|
3384
|
+
modelBounds: modelBounds ? {
|
|
3385
|
+
min: vectorToJSON(modelBounds.min),
|
|
3386
|
+
max: vectorToJSON(modelBounds.max),
|
|
3387
|
+
} : null,
|
|
3388
|
+
render: { ...this.#state.render },
|
|
3389
|
+
settings: { ...this.#settings },
|
|
3390
|
+
pipelines: {
|
|
3391
|
+
default: Boolean(this.#renderPipelines.default),
|
|
3392
|
+
ssao: Boolean(this.#renderPipelines.ssao),
|
|
3393
|
+
iblShadows: Boolean(this.#renderPipelines.iblShadows),
|
|
3394
|
+
},
|
|
3395
|
+
};
|
|
3396
|
+
}
|
|
3397
|
+
|
|
3588
3398
|
/**
|
|
3589
3399
|
* Loads all asset containers (model, environment, materials, etc.) and adds them to the scene.
|
|
3590
3400
|
* Applies material and camera options, sets visibility, and initializes lights and shadows.
|
|
@@ -3595,296 +3405,6 @@ export default class BabylonJSController {
|
|
|
3595
3405
|
return await this.#loadContainers(force);
|
|
3596
3406
|
}
|
|
3597
3407
|
|
|
3598
|
-
/**
|
|
3599
|
-
* Loads a single named container in the background while the render loop keeps running,
|
|
3600
|
-
* then merges it into the scene atomically (only the merge momentarily pauses the loop).
|
|
3601
|
-
* Camera-dependent effects (post-processing, SSAO, shadows) are preserved across the merge.
|
|
3602
|
-
* If a full #loadContainers is in progress, waits for it to finish before merging.
|
|
3603
|
-
* @public
|
|
3604
|
-
* @param {string} containerName - Name of the container to load ("model", "environment", "materials").
|
|
3605
|
-
* @param {Function} [onReady] - Optional callback invoked after any concurrent scene load finishes
|
|
3606
|
-
* and before the asset is fetched. Use this to mark the container pending (e.g. via
|
|
3607
|
-
* #checkNeedToUpdateContainers) so that the concurrent #loadContainers doesn't consume it first.
|
|
3608
|
-
* @returns {Promise<{success: boolean, error: any, loadedContainers: string[]}>}
|
|
3609
|
-
*/
|
|
3610
|
-
async loadContainerIndependent(containerName, onReady, options = {}) {
|
|
3611
|
-
const loadSessionId = containerName === "model" ? (options?.loadSessionId ?? null) : null;
|
|
3612
|
-
if (containerName === "model" && this.#isLoadingModelIndependent) {
|
|
3613
|
-
return { success: false, error: "Already loading model independently", loadedContainers: [], loadSessionId };
|
|
3614
|
-
}
|
|
3615
|
-
if (containerName === "environment" && this.#isLoadingEnvironmentIndependent) {
|
|
3616
|
-
return { success: false, error: "Already loading environment independently", loadedContainers: [], loadSessionId };
|
|
3617
|
-
}
|
|
3618
|
-
if (containerName === "materials" && this.#isLoadingMaterialsIndependent) {
|
|
3619
|
-
return { success: false, error: "Already loading materials independently", loadedContainers: [], loadSessionId };
|
|
3620
|
-
}
|
|
3621
|
-
|
|
3622
|
-
if (containerName === "model") this.#isLoadingModelIndependent = true;
|
|
3623
|
-
else if (containerName === "environment") this.#isLoadingEnvironmentIndependent = true;
|
|
3624
|
-
else if (containerName === "materials") this.#isLoadingMaterialsIndependent = true;
|
|
3625
|
-
|
|
3626
|
-
// Only reset model-first-painted tracking for model loads — resetting it for
|
|
3627
|
-
// environment/materials would stale any in-flight model-first-painted gate.
|
|
3628
|
-
if (containerName === "model") {
|
|
3629
|
-
this.#modelFirstPaintedEmitted = false;
|
|
3630
|
-
}
|
|
3631
|
-
const modelFirstPaintedLoadToken = containerName === "model"
|
|
3632
|
-
? ++this.#modelFirstPaintedLoadToken
|
|
3633
|
-
: this.#modelFirstPaintedLoadToken;
|
|
3634
|
-
let assetContainer = null;
|
|
3635
|
-
const tIndepStart = performance.now();
|
|
3636
|
-
// Whether the container was already pending inside a running #loadContainers batch.
|
|
3637
|
-
// If so, we fall back to the sequential path (wait → onReady → load) to avoid a
|
|
3638
|
-
// double-load conflict. Otherwise, load the GLTF in parallel with the scene load
|
|
3639
|
-
// and only wait for the atomic merge window.
|
|
3640
|
-
const isAlreadyHandled =
|
|
3641
|
-
this.#containers[containerName]?.state?.isPending === true && this.#loadingInProgress;
|
|
3642
|
-
const path = isAlreadyHandled ? "claimed" : "par";
|
|
3643
|
-
let container;
|
|
3644
|
-
let tAfterLoad, tAfterWait;
|
|
3645
|
-
try {
|
|
3646
|
-
if (!isAlreadyHandled) {
|
|
3647
|
-
// Optimistic path: prepare state and load GLTF NOW, in parallel with any running
|
|
3648
|
-
// #loadContainers. The merge waits for the scene, but the download does not.
|
|
3649
|
-
if (!this.#scene) {
|
|
3650
|
-
return { success: false, error: "No scene available", loadedContainers: [], loadSessionId };
|
|
3651
|
-
}
|
|
3652
|
-
onReady?.();
|
|
3653
|
-
container = this.#containers[containerName];
|
|
3654
|
-
if (!container) {
|
|
3655
|
-
return { success: false, error: `Container "${containerName}" not found`, loadedContainers: [], loadSessionId };
|
|
3656
|
-
}
|
|
3657
|
-
// Phase 1 (parallel): load GLTF while scene may still be loading.
|
|
3658
|
-
// No wait — merge happens immediately after load. #replaceContainer skips
|
|
3659
|
-
// releaseEffects when the loop is running, so no WebGL programs are deleted.
|
|
3660
|
-
const [, loaded] = await this.#loadAssetContainer(container, false, { isIndependent: true });
|
|
3661
|
-
assetContainer = loaded || null;
|
|
3662
|
-
tAfterLoad = performance.now();
|
|
3663
|
-
tAfterWait = tAfterLoad; // no wait phase in optimistic path
|
|
3664
|
-
} else {
|
|
3665
|
-
// Parallel claim path: container was already submitted to a running #loadContainers
|
|
3666
|
-
// batch. #isLoadingModelIndependent (set at the top of this method) signals
|
|
3667
|
-
// #loadAssetContainer to abort the in-flight batch load and return a null sentinel,
|
|
3668
|
-
// so #loadContainers skips merging this container. We load it here in parallel —
|
|
3669
|
-
// no waiting for scene/materials to finish.
|
|
3670
|
-
if (!this.#scene) {
|
|
3671
|
-
return { success: false, error: "No scene available", loadedContainers: [], loadSessionId };
|
|
3672
|
-
}
|
|
3673
|
-
// Call onReady() to ensure the container state carries this load's payload,
|
|
3674
|
-
// which may differ from what #loadContainers was given if the caller swapped the model.
|
|
3675
|
-
onReady?.();
|
|
3676
|
-
container = this.#containers[containerName];
|
|
3677
|
-
if (!container) {
|
|
3678
|
-
return { success: false, error: `Container "${containerName}" not found`, loadedContainers: [], loadSessionId };
|
|
3679
|
-
}
|
|
3680
|
-
const [, loaded] = await this.#loadAssetContainer(container, false, { isIndependent: true });
|
|
3681
|
-
assetContainer = loaded || null;
|
|
3682
|
-
tAfterLoad = performance.now();
|
|
3683
|
-
tAfterWait = tIndepStart; // no wait phase — measure load from start
|
|
3684
|
-
}
|
|
3685
|
-
|
|
3686
|
-
if (!assetContainer) {
|
|
3687
|
-
return { success: false, error: "Failed to load asset container", loadedContainers: [], loadSessionId };
|
|
3688
|
-
}
|
|
3689
|
-
if (!this.#scene) {
|
|
3690
|
-
assetContainer.dispose();
|
|
3691
|
-
return { success: false, error: "Scene was disposed", loadedContainers: [], loadSessionId };
|
|
3692
|
-
}
|
|
3693
|
-
|
|
3694
|
-
this.#emitSceneLoadPhase("scene-load-assets-complete", {
|
|
3695
|
-
containerName,
|
|
3696
|
-
path,
|
|
3697
|
-
tAfterLoad: tAfterLoad != null ? Math.round(tAfterLoad) : null,
|
|
3698
|
-
tIndepStart: Math.round(tIndepStart),
|
|
3699
|
-
});
|
|
3700
|
-
|
|
3701
|
-
// Phase 3: atomic merge — briefly stop the loop for the merge only
|
|
3702
|
-
this.#stopEngineRenderLoop();
|
|
3703
|
-
|
|
3704
|
-
const oldModelMetadata = containerName === "model"
|
|
3705
|
-
? { ...(this.#containers.model?.state?.metadata ?? {}) }
|
|
3706
|
-
: {};
|
|
3707
|
-
|
|
3708
|
-
this.#disposeAnimationController();
|
|
3709
|
-
|
|
3710
|
-
if (containerName === "model") {
|
|
3711
|
-
this.#assetContainer_deleteLights(assetContainer);
|
|
3712
|
-
this.#assetContainer_stopAnimations(assetContainer);
|
|
3713
|
-
}
|
|
3714
|
-
if (containerName === "model" || containerName === "environment") {
|
|
3715
|
-
this.#assetContainer_retagCameras(assetContainer);
|
|
3716
|
-
}
|
|
3717
|
-
|
|
3718
|
-
this.#replaceContainer(container, assetContainer, { releaseEffects: false });
|
|
3719
|
-
container.state.setSuccess(true);
|
|
3720
|
-
|
|
3721
|
-
// Progressive independent loads may bring model/materials/environment in any order.
|
|
3722
|
-
// Force material rebinding after each merge so already-visible model meshes pick up
|
|
3723
|
-
// the latest material definitions instead of waiting for pending flags that may
|
|
3724
|
-
// already have been cleared.
|
|
3725
|
-
this.#setOptions_Materials(true);
|
|
3726
|
-
this.#setOptions_Camera();
|
|
3727
|
-
// For model: IBL not yet loaded (scene still in flight) — create only ambient/directional
|
|
3728
|
-
// lights. #loadContainers will call #createLights() again with full IBL once scene merges.
|
|
3729
|
-
// For environment: IBL state was pre-marked pending by loadSceneIndependent before calling
|
|
3730
|
-
// here, so #setOptions_IBL() picks it up and applies it immediately after lights are ready.
|
|
3731
|
-
await this.#createLights();
|
|
3732
|
-
if (containerName === "environment") {
|
|
3733
|
-
await this.#setOptions_IBL();
|
|
3734
|
-
}
|
|
3735
|
-
|
|
3736
|
-
if (containerName === "model") {
|
|
3737
|
-
const newModelMetadata = { ...(container.state.update?.metadata ?? {}) };
|
|
3738
|
-
this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
|
|
3739
|
-
}
|
|
3740
|
-
|
|
3741
|
-
if (this.#containers.model.assetContainer) {
|
|
3742
|
-
this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
|
|
3743
|
-
}
|
|
3744
|
-
const tAfterMerge = performance.now();
|
|
3745
|
-
this.#emitSceneLoadPhase("scene-load-merge-complete", {
|
|
3746
|
-
containerName,
|
|
3747
|
-
path,
|
|
3748
|
-
tAfterMerge: Math.round(tAfterMerge),
|
|
3749
|
-
tAfterLoad: tAfterLoad != null ? Math.round(tAfterLoad) : null,
|
|
3750
|
-
tIndepStart: Math.round(tIndepStart),
|
|
3751
|
-
});
|
|
3752
|
-
|
|
3753
|
-
// Use #startRender() so the next frame is scheduled after the atomic merge.
|
|
3754
|
-
// In progressive loads, rebuilding camera-dependent effects (DefaultRenderingPipeline,
|
|
3755
|
-
// SSAO, shadows) while the model is already visible can delete GL programs that are
|
|
3756
|
-
// still referenced by the current frame, which leaves the canvas gray until a later
|
|
3757
|
-
// full reload. Keep the existing pipeline alive for independent model/environment/
|
|
3758
|
-
// materials appends; the full load path still rebuilds everything when needed.
|
|
3759
|
-
await this.#startRender({ skipWhenReady: true, forceRender: true, skipCameraDependentEffects: true });
|
|
3760
|
-
const tAfterShaders = performance.now();
|
|
3761
|
-
|
|
3762
|
-
this.#setHighlightConfig({
|
|
3763
|
-
enabled: this.#settings.highlightEnabled,
|
|
3764
|
-
color: this.#settings.highlightColor,
|
|
3765
|
-
});
|
|
3766
|
-
this.#setMaxSimultaneousLights();
|
|
3767
|
-
this.#enhanceTextureQuality();
|
|
3768
|
-
this.#setVisibilityOfWallAndFloorInModel();
|
|
3769
|
-
|
|
3770
|
-
if (this.#babylonJSAnimationController?.hasAnimations?.()) {
|
|
3771
|
-
this.#attachAnimationChangedListener();
|
|
3772
|
-
}
|
|
3773
|
-
|
|
3774
|
-
this.#requestRender({ frames: 3, continuousMs: this.#config.render.interactionMs });
|
|
3775
|
-
if (containerName === "model" && !this.#modelFirstPaintedEmitted) {
|
|
3776
|
-
try {
|
|
3777
|
-
performance.mark(`viewer:model-first-painted:scheduled:${modelFirstPaintedLoadToken}`);
|
|
3778
|
-
} catch {
|
|
3779
|
-
// ignore
|
|
3780
|
-
}
|
|
3781
|
-
console.log("[viewer] model-first-painted gate scheduled", {
|
|
3782
|
-
containerName,
|
|
3783
|
-
path: isAlreadyHandled ? "claimed" : "par",
|
|
3784
|
-
loadToken: modelFirstPaintedLoadToken,
|
|
3785
|
-
renderLoopRunning: this.#state.render.isLoopRunning,
|
|
3786
|
-
loopRequested: true,
|
|
3787
|
-
});
|
|
3788
|
-
void (async () => {
|
|
3789
|
-
await this.#waitForPaint(2);
|
|
3790
|
-
try {
|
|
3791
|
-
performance.mark(`viewer:model-first-painted:after-wait:${modelFirstPaintedLoadToken}`);
|
|
3792
|
-
} catch {
|
|
3793
|
-
// ignore
|
|
3794
|
-
}
|
|
3795
|
-
console.log("[viewer] model-first-painted gate after paint wait", {
|
|
3796
|
-
containerName,
|
|
3797
|
-
path: isAlreadyHandled ? "claimed" : "par",
|
|
3798
|
-
loadToken: modelFirstPaintedLoadToken,
|
|
3799
|
-
currentToken: this.#modelFirstPaintedLoadToken,
|
|
3800
|
-
alreadyEmitted: this.#modelFirstPaintedEmitted,
|
|
3801
|
-
renderLoopRunning: this.#state.render.isLoopRunning,
|
|
3802
|
-
});
|
|
3803
|
-
if (modelFirstPaintedLoadToken !== this.#modelFirstPaintedLoadToken) {
|
|
3804
|
-
try {
|
|
3805
|
-
performance.mark(`viewer:model-first-painted:stale:${modelFirstPaintedLoadToken}`);
|
|
3806
|
-
} catch {
|
|
3807
|
-
// ignore
|
|
3808
|
-
}
|
|
3809
|
-
console.log("[viewer] model-first-painted gate skipped (stale load token)", {
|
|
3810
|
-
containerName,
|
|
3811
|
-
path: isAlreadyHandled ? "claimed" : "par",
|
|
3812
|
-
loadToken: modelFirstPaintedLoadToken,
|
|
3813
|
-
currentToken: this.#modelFirstPaintedLoadToken,
|
|
3814
|
-
});
|
|
3815
|
-
return;
|
|
3816
|
-
}
|
|
3817
|
-
const paintTarget = this.#prefViewer || this.#prefViewer3D || this.#canvas;
|
|
3818
|
-
if (!this.#modelFirstPaintedEmitted && paintTarget) {
|
|
3819
|
-
this.#modelFirstPaintedEmitted = true;
|
|
3820
|
-
try {
|
|
3821
|
-
performance.mark(`viewer:model-first-painted:emitted:${modelFirstPaintedLoadToken}`);
|
|
3822
|
-
} catch {
|
|
3823
|
-
// ignore
|
|
3824
|
-
}
|
|
3825
|
-
console.log("[viewer] model-first-painted ✅ emitted", {
|
|
3826
|
-
containerName,
|
|
3827
|
-
path: isAlreadyHandled ? "claimed" : "par",
|
|
3828
|
-
loadToken: modelFirstPaintedLoadToken,
|
|
3829
|
-
tAfterPaint: Math.round(performance.now()),
|
|
3830
|
-
});
|
|
3831
|
-
this.#emitModelFirstPainted({
|
|
3832
|
-
containerName,
|
|
3833
|
-
path: isAlreadyHandled ? "claimed" : "par",
|
|
3834
|
-
tIndepStart: Math.round(tIndepStart),
|
|
3835
|
-
tAfterLoad: tAfterLoad != null ? Math.round(tAfterLoad) : null,
|
|
3836
|
-
tAfterWait: tAfterWait != null ? Math.round(tAfterWait) : null,
|
|
3837
|
-
tAfterMerge: Math.round(tAfterMerge),
|
|
3838
|
-
tAfterPaint: Math.round(performance.now()),
|
|
3839
|
-
});
|
|
3840
|
-
}
|
|
3841
|
-
})();
|
|
3842
|
-
}
|
|
3843
|
-
|
|
3844
|
-
// In the optimistic path: load runs first, then wait. In the sequential path: wait
|
|
3845
|
-
// runs first, then load. Compute each phase relative to its actual start point.
|
|
3846
|
-
const tLoad = isAlreadyHandled
|
|
3847
|
-
? Math.round(tAfterLoad - tAfterWait)
|
|
3848
|
-
: Math.round(tAfterLoad - tIndepStart);
|
|
3849
|
-
const tWait = isAlreadyHandled
|
|
3850
|
-
? Math.round(tAfterWait - tIndepStart)
|
|
3851
|
-
: Math.round(tAfterWait - tAfterLoad);
|
|
3852
|
-
// Merge starts after the wait in both paths (optimistic: load→wait→merge; sequential: wait→load→merge)
|
|
3853
|
-
const tMerge = Math.round(tAfterMerge - (isAlreadyHandled ? tAfterLoad : tAfterWait));
|
|
3854
|
-
const tShaders = Math.round(tAfterShaders - tAfterMerge);
|
|
3855
|
-
const tTotal = Math.round(tAfterShaders - tIndepStart);
|
|
3856
|
-
this.#emitSceneLoadPhase("scene-load-shaders-complete", {
|
|
3857
|
-
containerName,
|
|
3858
|
-
path,
|
|
3859
|
-
tAfterShaders: Math.round(tAfterShaders),
|
|
3860
|
-
tAfterMerge: Math.round(tAfterMerge),
|
|
3861
|
-
tTotal,
|
|
3862
|
-
});
|
|
3863
|
-
this.#emitSceneLoadPhase("scene-load-container-complete", {
|
|
3864
|
-
containerName,
|
|
3865
|
-
path,
|
|
3866
|
-
tLoad,
|
|
3867
|
-
tWait,
|
|
3868
|
-
tMerge,
|
|
3869
|
-
tShaders,
|
|
3870
|
-
tTotal,
|
|
3871
|
-
});
|
|
3872
|
-
console.log(`[perf][${containerName}:indep:${path}] load=${tLoad}ms wait=${tWait}ms merge=${tMerge}ms shaders=${tShaders}ms total=${tTotal}ms`);
|
|
3873
|
-
|
|
3874
|
-
return { success: true, error: null, loadedContainers: [containerName], loadSessionId };
|
|
3875
|
-
} catch (error) {
|
|
3876
|
-
assetContainer?.dispose();
|
|
3877
|
-
if (!this.#state.render.isLoopRunning && this.#scene && this.#engine) {
|
|
3878
|
-
this.#startEngineRenderLoop();
|
|
3879
|
-
}
|
|
3880
|
-
return { success: false, error, loadedContainers: [], loadSessionId };
|
|
3881
|
-
} finally {
|
|
3882
|
-
if (containerName === "model") this.#isLoadingModelIndependent = false;
|
|
3883
|
-
else if (containerName === "environment") this.#isLoadingEnvironmentIndependent = false;
|
|
3884
|
-
else if (containerName === "materials") this.#isLoadingMaterialsIndependent = false;
|
|
3885
|
-
}
|
|
3886
|
-
}
|
|
3887
|
-
|
|
3888
3408
|
/**
|
|
3889
3409
|
* Applies camera options from the configuration to the active camera.
|
|
3890
3410
|
* Stops and restarts the render loop to apply changes.
|
|
@@ -3927,14 +3447,42 @@ export default class BabylonJSController {
|
|
|
3927
3447
|
}
|
|
3928
3448
|
|
|
3929
3449
|
/**
|
|
3930
|
-
* Applies
|
|
3450
|
+
* Applies animation-menu, highlight, and time-of-day lighting configuration without forcing a full scene reload.
|
|
3451
|
+
* Values are merged into the persisted render settings snapshot unless `config.persist === false`.
|
|
3452
|
+
* When `showLightingTimeOfDay` is set to false and no explicit `lightingTimeOfDay` is supplied,
|
|
3453
|
+
* the active lighting time is reset to `DEFAULT_RENDER_SETTINGS.lightingTimeOfDay` (0.5).
|
|
3931
3454
|
* @public
|
|
3932
|
-
* @param {
|
|
3455
|
+
* @param {object} config - Immediate render/illumination configuration.
|
|
3456
|
+
* @param {boolean|string} [config.showAnimationMenu] - Enables/disables the animation controls when animations are available.
|
|
3457
|
+
* @param {boolean|string|null} [config.highlightEnabled] - Enables/disables hover highlight; string "false" is normalized to false, null is ignored.
|
|
3458
|
+
* @param {string|null} [config.highlightColor] - Hex color used by hover highlight; null is ignored.
|
|
3459
|
+
* @param {boolean|string} [config.showLightingTimeOfDay] - Shows/hides the time-of-day control and can reset time to the default when hidden.
|
|
3460
|
+
* @param {number} [config.lightingTimeOfDay] - Normalized time of day in the 0..1 range.
|
|
3461
|
+
* @param {boolean} [config.persist=true] - False for transient updates such as lighting playback frames.
|
|
3933
3462
|
* @returns {void}
|
|
3934
3463
|
*/
|
|
3935
3464
|
setIlluminationConfig(config = {}) {
|
|
3936
3465
|
let shouldApplyLighting = false;
|
|
3937
3466
|
|
|
3467
|
+
if (config.showLightingTimeOfDay !== undefined) {
|
|
3468
|
+
const showLighting = typeof config.showLightingTimeOfDay === "string"
|
|
3469
|
+
? config.showLightingTimeOfDay.toLowerCase() !== "false"
|
|
3470
|
+
: Boolean(config.showLightingTimeOfDay);
|
|
3471
|
+
if (this.#settings.showLightingTimeOfDay !== showLighting) {
|
|
3472
|
+
shouldApplyLighting = true;
|
|
3473
|
+
}
|
|
3474
|
+
this.#settings.showLightingTimeOfDay = showLighting;
|
|
3475
|
+
|
|
3476
|
+
const hasExplicitLightingTime = typeof config.lightingTimeOfDay === "number" && Number.isFinite(config.lightingTimeOfDay);
|
|
3477
|
+
if (!showLighting && !hasExplicitLightingTime) {
|
|
3478
|
+
const defaultTime = BabylonJSController.DEFAULT_RENDER_SETTINGS.lightingTimeOfDay;
|
|
3479
|
+
if (this.#settings.lightingTimeOfDay !== defaultTime) {
|
|
3480
|
+
this.#settings.lightingTimeOfDay = defaultTime;
|
|
3481
|
+
shouldApplyLighting = true;
|
|
3482
|
+
}
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
|
|
3938
3486
|
if (typeof config.lightingTimeOfDay === "number" && Number.isFinite(config.lightingTimeOfDay)) {
|
|
3939
3487
|
const normalizedTime = clamp01(config.lightingTimeOfDay);
|
|
3940
3488
|
if (this.#settings.lightingTimeOfDay !== normalizedTime) {
|
|
@@ -3944,8 +3492,11 @@ export default class BabylonJSController {
|
|
|
3944
3492
|
}
|
|
3945
3493
|
|
|
3946
3494
|
if (config.showAnimationMenu !== undefined) {
|
|
3947
|
-
|
|
3948
|
-
|
|
3495
|
+
const showMenu = typeof config.showAnimationMenu === "string"
|
|
3496
|
+
? config.showAnimationMenu.toLowerCase() !== "false"
|
|
3497
|
+
: Boolean(config.showAnimationMenu);
|
|
3498
|
+
this.#settings.showAnimationMenu = showMenu;
|
|
3499
|
+
this.#syncAnimationControllerAfterModelLoad();
|
|
3949
3500
|
}
|
|
3950
3501
|
|
|
3951
3502
|
// Handle highlight color for animation hover
|
|
@@ -3966,7 +3517,7 @@ export default class BabylonJSController {
|
|
|
3966
3517
|
}
|
|
3967
3518
|
this.#setHighlightConfig({
|
|
3968
3519
|
color: config.highlightColor ?? undefined,
|
|
3969
|
-
enabled,
|
|
3520
|
+
enabled: enabled,
|
|
3970
3521
|
});
|
|
3971
3522
|
}
|
|
3972
3523
|
|
|
@@ -4005,11 +3556,13 @@ export default class BabylonJSController {
|
|
|
4005
3556
|
}
|
|
4006
3557
|
|
|
4007
3558
|
/**
|
|
4008
|
-
* Merges
|
|
3559
|
+
* Merges reload-oriented render flags with the current configuration, persists them, and marks
|
|
4009
3560
|
* all dependent loaders/options as pending when something actually changed.
|
|
3561
|
+
* Immediate settings such as highlight, animation menu, and time-of-day lighting are handled by
|
|
3562
|
+
* `setIlluminationConfig()` before this method receives the remaining reload settings.
|
|
4010
3563
|
* @public
|
|
4011
|
-
* @param {{antiAliasingEnabled?:boolean, ambientOcclusionEnabled?:boolean, iblEnabled?:boolean, shadowsEnabled?:boolean}} settings Partial set of render toggles to apply.
|
|
4012
|
-
* @returns {{changed:boolean, settings:
|
|
3564
|
+
* @param {{antiAliasingEnabled?:boolean, ambientOcclusionEnabled?:boolean, iblEnabled?:boolean, shadowsEnabled?:boolean}} settings Partial set of reload render toggles to apply.
|
|
3565
|
+
* @returns {{changed:boolean, settings:object}} Current full render settings snapshot and whether a reload is required.
|
|
4013
3566
|
* @description
|
|
4014
3567
|
* Callers can inspect the `changed` flag to decide whether to trigger a reload with
|
|
4015
3568
|
* `reloadWithCurrentSettings()` or simply reuse the returned snapshot.
|
|
@@ -4089,4 +3642,3 @@ export default class BabylonJSController {
|
|
|
4089
3642
|
}
|
|
4090
3643
|
}
|
|
4091
3644
|
}
|
|
4092
|
-
|