@preference-sl/pref-viewer 2.16.0-beta.0 → 2.16.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/babylonjs-controller.js +815 -36
- package/src/file-storage.js +126 -5
- package/src/gltf-resolver.js +58 -24
- package/src/pref-viewer-3d.js +104 -0
- package/src/pref-viewer.js +147 -1
- package/src/styles.js +1 -0
|
@@ -165,6 +165,9 @@ export default class BabylonJSController {
|
|
|
165
165
|
// References to parent custom elements
|
|
166
166
|
#prefViewer3D = undefined;
|
|
167
167
|
#prefViewer = undefined;
|
|
168
|
+
#modelFirstPaintedEmitted = false;
|
|
169
|
+
#modelFirstPaintedLoadToken = 0;
|
|
170
|
+
#pendingModelReplacementCleanup = null;
|
|
168
171
|
|
|
169
172
|
// Babylon.js core objects
|
|
170
173
|
#engine = null;
|
|
@@ -174,6 +177,7 @@ export default class BabylonJSController {
|
|
|
174
177
|
#dirLight = null;
|
|
175
178
|
#moonLight = null;
|
|
176
179
|
#cameraLight = null;
|
|
180
|
+
#debugCameraFrameInfo = null;
|
|
177
181
|
#shadowGen = [];
|
|
178
182
|
#XRExperience = null;
|
|
179
183
|
#canvasResizeObserver = null;
|
|
@@ -185,6 +189,11 @@ export default class BabylonJSController {
|
|
|
185
189
|
|
|
186
190
|
#gltfResolver = null; // GLTFResolver instance
|
|
187
191
|
#babylonJSAnimationController = null; // AnimationController instance
|
|
192
|
+
#loadingInProgress = false; // blocks #requestRender while assets are loading to prevent WebGL errors from rendering with disposed shaders
|
|
193
|
+
#isLoadingModelIndependent = false; // guards against concurrent independent model loads
|
|
194
|
+
#isLoadingEnvironmentIndependent = false; // guards against concurrent independent environment loads
|
|
195
|
+
#isLoadingMaterialsIndependent = false; // guards against concurrent independent materials loads
|
|
196
|
+
#startRenderInProgress = false; // true while #startRender() is running (after #loadingInProgress clears)
|
|
188
197
|
|
|
189
198
|
#renderPipelines = {
|
|
190
199
|
default: null,
|
|
@@ -748,8 +757,8 @@ export default class BabylonJSController {
|
|
|
748
757
|
* @param {number} [options.continuousMs=0] - Milliseconds to keep continuous rendering active.
|
|
749
758
|
* @returns {boolean} True when the request was accepted, false when scene/engine are unavailable.
|
|
750
759
|
*/
|
|
751
|
-
#requestRender({ frames = 1, continuousMs = 0 } = {}) {
|
|
752
|
-
if (!this.#scene || !this.#engine) {
|
|
760
|
+
#requestRender({ frames = 1, continuousMs = 0, force = false } = {}) {
|
|
761
|
+
if (!this.#scene || !this.#engine || (this.#loadingInProgress && !force)) {
|
|
753
762
|
return false;
|
|
754
763
|
}
|
|
755
764
|
|
|
@@ -1112,6 +1121,11 @@ export default class BabylonJSController {
|
|
|
1112
1121
|
|
|
1113
1122
|
const pipelineName = "PrefViewerSSAORenderingPipeline";
|
|
1114
1123
|
|
|
1124
|
+
// Skip recreation if already alive — same reason as DefaultRenderingPipeline guard
|
|
1125
|
+
if (this.#renderPipelines.ssao && supportedPipelines.find((p) => p.name === pipelineName)) {
|
|
1126
|
+
return true;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1115
1129
|
const ssaoRatio = {
|
|
1116
1130
|
ssaoRatio: 0.5,
|
|
1117
1131
|
combineRatio: 1.0,
|
|
@@ -1213,6 +1227,21 @@ export default class BabylonJSController {
|
|
|
1213
1227
|
|
|
1214
1228
|
const pipelineName = "PrefViewerDefaultRenderingPipeline";
|
|
1215
1229
|
|
|
1230
|
+
// Skip recreation if the pipeline is already alive — creating a new DefaultRenderingPipeline
|
|
1231
|
+
// while one exists disposes the underlying GeometryBufferRenderer, deleting GL programs that
|
|
1232
|
+
// subMeshes still reference, causing useProgram:deleted errors on the next render frame.
|
|
1233
|
+
if (this.#renderPipelines.default && supportedPipelines.find((p) => p.name === pipelineName)) {
|
|
1234
|
+
return true;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// Purge stale effect cache before creating a new pipeline. pipeline.dispose() calls
|
|
1238
|
+
// gl.deleteProgram() per post-process but leaves entries in Engine._compiledEffects.
|
|
1239
|
+
// A new DefaultRenderingPipeline with the same shader key gets a cache hit and receives
|
|
1240
|
+
// the deleted program → useProgram:deleted on the first render frame.
|
|
1241
|
+
// Safe here because #startRender always stops the loop before reaching this point.
|
|
1242
|
+
this.#scene.getEngine().releaseEffects?.();
|
|
1243
|
+
this.#scene.getEngine().releaseComputeEffects?.();
|
|
1244
|
+
|
|
1216
1245
|
let defaultPipeline = new DefaultRenderingPipeline(pipelineName, true, this.#scene, [this.#scene.activeCamera], true);
|
|
1217
1246
|
|
|
1218
1247
|
if (!defaultPipeline) {
|
|
@@ -1390,6 +1419,11 @@ export default class BabylonJSController {
|
|
|
1390
1419
|
|
|
1391
1420
|
const pipelineName = "PrefViewerIblShadowsRenderPipeline";
|
|
1392
1421
|
|
|
1422
|
+
// Skip recreation if already alive — same reason as DefaultRenderingPipeline guard
|
|
1423
|
+
if (this.#renderPipelines.iblShadows && supportedPipelines.find((p) => p.name === pipelineName)) {
|
|
1424
|
+
return true;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1393
1427
|
// Use hardware-adaptive quality settings
|
|
1394
1428
|
const pipelineOptions = {
|
|
1395
1429
|
resolutionExp: this.#config.quality.iblShadowResolution, // Adaptive: 0=1024, 1=2048, 2=4096
|
|
@@ -1641,7 +1675,7 @@ export default class BabylonJSController {
|
|
|
1641
1675
|
const showroom = this.#config.look?.showroom || {};
|
|
1642
1676
|
const imageProcessing = this.#scene.imageProcessingConfiguration;
|
|
1643
1677
|
|
|
1644
|
-
this.#scene.clearColor = new Color4(
|
|
1678
|
+
this.#scene.clearColor = new Color4(1, 1, 1, 1).toLinearSpace();
|
|
1645
1679
|
imageProcessing.exposure = showroom.exposure ?? 0.95;
|
|
1646
1680
|
imageProcessing.contrast = showroom.contrast ?? 1.08;
|
|
1647
1681
|
imageProcessing.toneMappingEnabled = showroom.toneMappingEnabled ?? true;
|
|
@@ -1681,12 +1715,7 @@ export default class BabylonJSController {
|
|
|
1681
1715
|
imageProcessing.colorCurvesEnabled = false;
|
|
1682
1716
|
}
|
|
1683
1717
|
|
|
1684
|
-
this.#scene.clearColor = new Color4(
|
|
1685
|
-
lerp(0.035, 0.98, daylight),
|
|
1686
|
-
lerp(0.045, 0.98, daylight),
|
|
1687
|
-
lerp(0.085, 0.98, daylight),
|
|
1688
|
-
1,
|
|
1689
|
-
).toLinearSpace();
|
|
1718
|
+
this.#scene.clearColor = new Color4(1, 1, 1, 1).toLinearSpace();
|
|
1690
1719
|
|
|
1691
1720
|
if ("environmentIntensity" in this.#scene) {
|
|
1692
1721
|
this.#scene.environmentIntensity = lerp(0.42, showroom.environmentIntensity ?? 1.08, peakLight);
|
|
@@ -2175,6 +2204,10 @@ export default class BabylonJSController {
|
|
|
2175
2204
|
}
|
|
2176
2205
|
this.#state.resize.lastAppliedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
2177
2206
|
console.log(`PrefViewer: Applying resize after ${Math.round(elapsed)}ms`);
|
|
2207
|
+
// Stop the loop before resize: engine.resize() rebuilds DefaultRenderingPipeline
|
|
2208
|
+
// via onResizeObservable, which disposes GL programs. If the loop is running,
|
|
2209
|
+
// the next frame would call useProgram on a deleted program → INVALID_OPERATION.
|
|
2210
|
+
this.#stopEngineRenderLoop();
|
|
2178
2211
|
this.#applyShowroomCameraFraming();
|
|
2179
2212
|
this.#engine.resize();
|
|
2180
2213
|
const needsEnhancedBurst = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled || this.#settings.shadowsEnabled;
|
|
@@ -2217,7 +2250,7 @@ export default class BabylonJSController {
|
|
|
2217
2250
|
* @param {MaterialData} optionMaterial - Material option containing value, nodePrefixes, nodeNames, and state.
|
|
2218
2251
|
* @returns {boolean} True if any mesh material was set, false otherwise.
|
|
2219
2252
|
*/
|
|
2220
|
-
#setOptionsMaterial(optionMaterial) {
|
|
2253
|
+
#setOptionsMaterial(optionMaterial, force = false) {
|
|
2221
2254
|
if (!optionMaterial || !(optionMaterial.value || (optionMaterial.isPending && optionMaterial.update.value)) || !(optionMaterial.nodePrefixes.length || optionMaterial.nodeNames.length)) {
|
|
2222
2255
|
return false;
|
|
2223
2256
|
}
|
|
@@ -2235,7 +2268,7 @@ export default class BabylonJSController {
|
|
|
2235
2268
|
if (container.state.name === "materials") {
|
|
2236
2269
|
return;
|
|
2237
2270
|
}
|
|
2238
|
-
if (container.assetContainer && (container.state.isPending || materialContainer.state.isPending || optionMaterial.isPending)) {
|
|
2271
|
+
if (container.assetContainer && (force || container.state.isPending || materialContainer.state.isPending || optionMaterial.isPending)) {
|
|
2239
2272
|
assetContainersToProcess.push(container.assetContainer);
|
|
2240
2273
|
}
|
|
2241
2274
|
});
|
|
@@ -2268,10 +2301,10 @@ export default class BabylonJSController {
|
|
|
2268
2301
|
* @private
|
|
2269
2302
|
* @returns {boolean} True if any material option was set, false otherwise.
|
|
2270
2303
|
*/
|
|
2271
|
-
#setOptions_Materials() {
|
|
2304
|
+
#setOptions_Materials(force = false) {
|
|
2272
2305
|
let someSetted = false;
|
|
2273
2306
|
Object.values(this.#options.materials).forEach((material) => {
|
|
2274
|
-
let settedMaterial = this.#setOptionsMaterial(material);
|
|
2307
|
+
let settedMaterial = this.#setOptionsMaterial(material, force);
|
|
2275
2308
|
someSetted = someSetted || settedMaterial;
|
|
2276
2309
|
});
|
|
2277
2310
|
return someSetted;
|
|
@@ -2380,7 +2413,9 @@ export default class BabylonJSController {
|
|
|
2380
2413
|
prefViewer3D = host?.nodeName === "PREF-VIEWER-3D" ? host : undefined;
|
|
2381
2414
|
}
|
|
2382
2415
|
|
|
2383
|
-
|
|
2416
|
+
if (prefViewer3D) {
|
|
2417
|
+
this.#prefViewer3D = prefViewer3D;
|
|
2418
|
+
}
|
|
2384
2419
|
}
|
|
2385
2420
|
|
|
2386
2421
|
/**
|
|
@@ -2395,7 +2430,6 @@ export default class BabylonJSController {
|
|
|
2395
2430
|
|
|
2396
2431
|
this.#getPrefViewer3DComponent();
|
|
2397
2432
|
if (this.#prefViewer3D === undefined) {
|
|
2398
|
-
this.#prefViewer = undefined;
|
|
2399
2433
|
return;
|
|
2400
2434
|
}
|
|
2401
2435
|
|
|
@@ -2405,7 +2439,9 @@ export default class BabylonJSController {
|
|
|
2405
2439
|
prefViewer = host?.nodeName === "PREF-VIEWER" ? host : undefined;
|
|
2406
2440
|
}
|
|
2407
2441
|
|
|
2408
|
-
|
|
2442
|
+
if (prefViewer) {
|
|
2443
|
+
this.#prefViewer = prefViewer;
|
|
2444
|
+
}
|
|
2409
2445
|
}
|
|
2410
2446
|
|
|
2411
2447
|
/**
|
|
@@ -2419,13 +2455,94 @@ export default class BabylonJSController {
|
|
|
2419
2455
|
// Cache references to parent custom elements
|
|
2420
2456
|
this.#getPrefViewer3DComponent();
|
|
2421
2457
|
this.#getPrefViewerComponent();
|
|
2458
|
+
const syncVisibility = (component, fallbackAttr) => {
|
|
2459
|
+
if (!component) {
|
|
2460
|
+
return;
|
|
2461
|
+
}
|
|
2462
|
+
if (typeof component.syncVisibility === "function") {
|
|
2463
|
+
component.syncVisibility(name, isVisible);
|
|
2464
|
+
return;
|
|
2465
|
+
}
|
|
2466
|
+
component.setAttribute(fallbackAttr, isVisible ? "true" : "false");
|
|
2467
|
+
};
|
|
2422
2468
|
if (name === "model") {
|
|
2423
|
-
this.#prefViewer3D
|
|
2424
|
-
this.#prefViewer
|
|
2469
|
+
syncVisibility(this.#prefViewer3D, "show-model");
|
|
2470
|
+
syncVisibility(this.#prefViewer, "show-model");
|
|
2425
2471
|
} else if (name === "environment") {
|
|
2426
|
-
this.#prefViewer3D
|
|
2427
|
-
this.#prefViewer
|
|
2472
|
+
syncVisibility(this.#prefViewer3D, "show-scene");
|
|
2473
|
+
syncVisibility(this.#prefViewer, "show-scene");
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
/**
|
|
2478
|
+
* Dispatches a "model-first-painted" event once the model-only preview has had a chance to render a frame.
|
|
2479
|
+
* @private
|
|
2480
|
+
* @param {object} [detail] - Optional metadata describing the paint gate.
|
|
2481
|
+
* @returns {void}
|
|
2482
|
+
*/
|
|
2483
|
+
#emitModelFirstPainted(detail = {}) {
|
|
2484
|
+
this.#getPrefViewer3DComponent();
|
|
2485
|
+
this.#getPrefViewerComponent();
|
|
2486
|
+
const target = this.#prefViewer || this.#prefViewer3D || this.#canvas;
|
|
2487
|
+
if (!target) {
|
|
2488
|
+
return;
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
const customEventOptions = {
|
|
2492
|
+
bubbles: true,
|
|
2493
|
+
cancelable: false,
|
|
2494
|
+
composed: true,
|
|
2495
|
+
detail,
|
|
2496
|
+
};
|
|
2497
|
+
target.dispatchEvent(new CustomEvent("model-first-painted", customEventOptions));
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
/**
|
|
2501
|
+
* Dispatches a granular 3D load phase event so the ecommerce trace UI can measure tail phases.
|
|
2502
|
+
* @private
|
|
2503
|
+
* @param {string} phase - Phase event name.
|
|
2504
|
+
* @param {object} [detail] - Optional phase metadata.
|
|
2505
|
+
* @returns {void}
|
|
2506
|
+
*/
|
|
2507
|
+
#emitSceneLoadPhase(phase, detail = {}) {
|
|
2508
|
+
this.#getPrefViewer3DComponent();
|
|
2509
|
+
this.#getPrefViewerComponent();
|
|
2510
|
+
const target = this.#prefViewer || this.#prefViewer3D || this.#canvas;
|
|
2511
|
+
if (!target) {
|
|
2512
|
+
return;
|
|
2428
2513
|
}
|
|
2514
|
+
|
|
2515
|
+
const customEventOptions = {
|
|
2516
|
+
bubbles: true,
|
|
2517
|
+
cancelable: false,
|
|
2518
|
+
composed: true,
|
|
2519
|
+
detail,
|
|
2520
|
+
};
|
|
2521
|
+
target.dispatchEvent(new CustomEvent(phase, customEventOptions));
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
/**
|
|
2525
|
+
* Waits for one or more animation frames so the browser can paint the current model-only state.
|
|
2526
|
+
* @private
|
|
2527
|
+
* @param {number} [frameCount=2] - Number of animation frames to wait.
|
|
2528
|
+
* @returns {Promise<void>}
|
|
2529
|
+
*/
|
|
2530
|
+
async #waitForPaint(frameCount = 2) {
|
|
2531
|
+
if (typeof requestAnimationFrame !== "function") {
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
const totalFrames = Math.max(1, frameCount);
|
|
2536
|
+
await new Promise((resolve) => {
|
|
2537
|
+
const step = (remaining) => {
|
|
2538
|
+
if (remaining <= 0) {
|
|
2539
|
+
resolve();
|
|
2540
|
+
return;
|
|
2541
|
+
}
|
|
2542
|
+
requestAnimationFrame(() => step(remaining - 1));
|
|
2543
|
+
};
|
|
2544
|
+
requestAnimationFrame(() => step(totalFrames - 1));
|
|
2545
|
+
});
|
|
2429
2546
|
}
|
|
2430
2547
|
|
|
2431
2548
|
/**
|
|
@@ -2473,8 +2590,21 @@ export default class BabylonJSController {
|
|
|
2473
2590
|
* @param {AssetContainer} newAssetContainer - The new asset container to add to the scene.
|
|
2474
2591
|
* @returns {boolean} True if the container was replaced and added, false otherwise.
|
|
2475
2592
|
*/
|
|
2476
|
-
#replaceContainer(container, newAssetContainer) {
|
|
2593
|
+
#replaceContainer(container, newAssetContainer, { releaseEffects = true } = {}) {
|
|
2594
|
+
const keepPreviousVisibleUntilPaint = container.state.name === "model" && container.assetContainer && container.state.isVisible;
|
|
2595
|
+
if (this.#pendingModelReplacementCleanup && container.state.name === "model") {
|
|
2596
|
+
this.#finalizePendingModelReplacement();
|
|
2597
|
+
}
|
|
2477
2598
|
if (container.assetContainer) {
|
|
2599
|
+
if (keepPreviousVisibleUntilPaint) {
|
|
2600
|
+
if (this.#pendingModelReplacementCleanup) {
|
|
2601
|
+
this.#finalizePendingModelReplacement();
|
|
2602
|
+
}
|
|
2603
|
+
const oldAssetContainer = container.assetContainer;
|
|
2604
|
+
container.assetContainer = newAssetContainer;
|
|
2605
|
+
this.#pendingModelReplacementCleanup = { container, oldAssetContainer, releaseEffects };
|
|
2606
|
+
return true;
|
|
2607
|
+
}
|
|
2478
2608
|
this.#removeContainer(container, false);
|
|
2479
2609
|
container.assetContainer.dispose();
|
|
2480
2610
|
container.assetContainer = null;
|
|
@@ -2485,13 +2615,75 @@ export default class BabylonJSController {
|
|
|
2485
2615
|
newAssetContainer?.dispose();
|
|
2486
2616
|
return false;
|
|
2487
2617
|
}
|
|
2488
|
-
|
|
2489
|
-
|
|
2618
|
+
// Skip releaseEffects when the render loop is active: deleting GL programs while frames
|
|
2619
|
+
// are being produced causes useProgram:deleted errors. Also skipped when the caller
|
|
2620
|
+
// explicitly passes releaseEffects:false (e.g. loadContainerIndependent).
|
|
2621
|
+
if (releaseEffects && !this.#state.render.isLoopRunning) {
|
|
2622
|
+
this.#scene.getEngine().releaseEffects();
|
|
2623
|
+
this.#scene.getEngine().releaseComputeEffects();
|
|
2624
|
+
}
|
|
2490
2625
|
|
|
2491
2626
|
container.assetContainer = newAssetContainer;
|
|
2492
2627
|
return this.#addContainer(container, false);
|
|
2493
2628
|
}
|
|
2494
2629
|
|
|
2630
|
+
/**
|
|
2631
|
+
* Finalizes a deferred model replacement by disposing the previously visible model container.
|
|
2632
|
+
* @private
|
|
2633
|
+
* @returns {void}
|
|
2634
|
+
*/
|
|
2635
|
+
#finalizePendingModelReplacement() {
|
|
2636
|
+
if (!this.#pendingModelReplacementCleanup) {
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
const { container, oldAssetContainer } = this.#pendingModelReplacementCleanup;
|
|
2640
|
+
this.#pendingModelReplacementCleanup = null;
|
|
2641
|
+
if (!oldAssetContainer) {
|
|
2642
|
+
return;
|
|
2643
|
+
}
|
|
2644
|
+
const stagedAssetContainer = container?.assetContainer ?? null;
|
|
2645
|
+
if (stagedAssetContainer) {
|
|
2646
|
+
try {
|
|
2647
|
+
stagedAssetContainer.meshes?.forEach((mesh) => {
|
|
2648
|
+
mesh.visibility = 0;
|
|
2649
|
+
});
|
|
2650
|
+
} catch {
|
|
2651
|
+
// ignore
|
|
2652
|
+
}
|
|
2653
|
+
try {
|
|
2654
|
+
container.state.visible = false;
|
|
2655
|
+
this.#addContainer(container, false);
|
|
2656
|
+
} catch {
|
|
2657
|
+
// ignore
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
try {
|
|
2661
|
+
oldAssetContainer.removeAllFromScene();
|
|
2662
|
+
} catch {
|
|
2663
|
+
// ignore
|
|
2664
|
+
}
|
|
2665
|
+
try {
|
|
2666
|
+
oldAssetContainer.dispose();
|
|
2667
|
+
} catch {
|
|
2668
|
+
// ignore
|
|
2669
|
+
}
|
|
2670
|
+
if (stagedAssetContainer) {
|
|
2671
|
+
try {
|
|
2672
|
+
stagedAssetContainer.meshes?.forEach((mesh) => {
|
|
2673
|
+
mesh.visibility = 1;
|
|
2674
|
+
});
|
|
2675
|
+
this.#setVisibilityOfWallAndFloorInModel();
|
|
2676
|
+
} catch {
|
|
2677
|
+
// ignore
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
this.#requestRender({
|
|
2681
|
+
frames: 3,
|
|
2682
|
+
continuousMs: this.#config.render.interactionMs,
|
|
2683
|
+
force: true,
|
|
2684
|
+
});
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2495
2687
|
/**
|
|
2496
2688
|
* Stops every animation group on the provided asset container to guarantee new loads start from a clean state.
|
|
2497
2689
|
* @private
|
|
@@ -2548,12 +2740,209 @@ export default class BabylonJSController {
|
|
|
2548
2740
|
if (!this.#containers.model.assetContainer || !this.#containers.model.state.isVisible) {
|
|
2549
2741
|
return false;
|
|
2550
2742
|
}
|
|
2551
|
-
|
|
2743
|
+
// In model-only previews, or while the environment is still loading, keep wall/floor meshes visible.
|
|
2744
|
+
// Only defer to the environment's visibility after it has fully loaded.
|
|
2745
|
+
show = show !== undefined
|
|
2746
|
+
? show
|
|
2747
|
+
: (this.#containers.environment.state.isSuccess
|
|
2748
|
+
? this.#containers.environment.state.isVisible
|
|
2749
|
+
: true);
|
|
2552
2750
|
const nodePrefixes = Object.values(this.#options.materials).flatMap((material) => material.nodePrefixes);
|
|
2553
2751
|
const nodeNames = Object.values(this.#options.materials).flatMap((material) => material.nodeNames);
|
|
2554
2752
|
this.#containers.model.assetContainer.meshes.filter((meshToFilter) => nodePrefixes.some((prefix) => meshToFilter.name.startsWith(prefix)) || nodeNames.includes(meshToFilter.name)).forEach((mesh) => mesh.setEnabled(show));
|
|
2555
2753
|
}
|
|
2556
2754
|
|
|
2755
|
+
/**
|
|
2756
|
+
* Frames the active ArcRotateCamera around the visible model when no environment scene is loaded.
|
|
2757
|
+
* @private
|
|
2758
|
+
* @returns {boolean} True if the camera was adjusted, otherwise false.
|
|
2759
|
+
*/
|
|
2760
|
+
#frameCameraToModel({ allowWhenEnvironmentVisible = false } = {}) {
|
|
2761
|
+
const camera = this.#scene?.activeCamera instanceof ArcRotateCamera
|
|
2762
|
+
? this.#scene.activeCamera
|
|
2763
|
+
: this.#camera;
|
|
2764
|
+
const modelContainer = this.#containers.model;
|
|
2765
|
+
const inspect = {
|
|
2766
|
+
cameraType: camera?.getClassName?.() ?? camera?.constructor?.name ?? null,
|
|
2767
|
+
hasModelContainer: !!modelContainer.assetContainer,
|
|
2768
|
+
modelVisible: modelContainer.state.isVisible,
|
|
2769
|
+
envHasContainer: !!this.#containers.environment.assetContainer,
|
|
2770
|
+
envVisible: this.#containers.environment.state.isVisible,
|
|
2771
|
+
meshCount: modelContainer.assetContainer?.meshes?.length ?? 0,
|
|
2772
|
+
};
|
|
2773
|
+
this.#debugCameraFrameInfo = { phase: "inspect", ...inspect };
|
|
2774
|
+
console.log("[viewer] frameCameraToModel inspect", inspect);
|
|
2775
|
+
if (!(camera instanceof ArcRotateCamera) || !modelContainer.assetContainer || !modelContainer.state.isVisible) {
|
|
2776
|
+
this.#debugCameraFrameInfo = {
|
|
2777
|
+
phase: "skipped",
|
|
2778
|
+
reason: !(camera instanceof ArcRotateCamera)
|
|
2779
|
+
? "no-arcrotate-camera"
|
|
2780
|
+
: !modelContainer.assetContainer
|
|
2781
|
+
? "no-model"
|
|
2782
|
+
: !modelContainer.state.isVisible
|
|
2783
|
+
? "model-hidden"
|
|
2784
|
+
: "unknown",
|
|
2785
|
+
...inspect,
|
|
2786
|
+
};
|
|
2787
|
+
return false;
|
|
2788
|
+
}
|
|
2789
|
+
if (!allowWhenEnvironmentVisible && this.#containers.environment.assetContainer && this.#containers.environment.state.isVisible) {
|
|
2790
|
+
this.#debugCameraFrameInfo = {
|
|
2791
|
+
phase: "skipped",
|
|
2792
|
+
reason: "environment-visible",
|
|
2793
|
+
...inspect,
|
|
2794
|
+
};
|
|
2795
|
+
return false;
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
let min = null;
|
|
2799
|
+
let max = null;
|
|
2800
|
+
modelContainer.assetContainer.meshes.forEach((mesh) => {
|
|
2801
|
+
if (!mesh?.getTotalVertices?.() || mesh.getTotalVertices() <= 0) {
|
|
2802
|
+
return;
|
|
2803
|
+
}
|
|
2804
|
+
mesh.computeWorldMatrix?.(true);
|
|
2805
|
+
const boundingBox = mesh.getBoundingInfo?.()?.boundingBox;
|
|
2806
|
+
if (!boundingBox) {
|
|
2807
|
+
return;
|
|
2808
|
+
}
|
|
2809
|
+
const minimum = boundingBox.minimumWorld;
|
|
2810
|
+
const maximum = boundingBox.maximumWorld;
|
|
2811
|
+
if (!minimum || !maximum) {
|
|
2812
|
+
return;
|
|
2813
|
+
}
|
|
2814
|
+
min = min
|
|
2815
|
+
? new Vector3(Math.min(min.x, minimum.x), Math.min(min.y, minimum.y), Math.min(min.z, minimum.z))
|
|
2816
|
+
: minimum.clone();
|
|
2817
|
+
max = max
|
|
2818
|
+
? new Vector3(Math.max(max.x, maximum.x), Math.max(max.y, maximum.y), Math.max(max.z, maximum.z))
|
|
2819
|
+
: maximum.clone();
|
|
2820
|
+
});
|
|
2821
|
+
|
|
2822
|
+
if (!min || !max) {
|
|
2823
|
+
const skipped = {
|
|
2824
|
+
hasCamera: camera instanceof ArcRotateCamera,
|
|
2825
|
+
hasModel: !!modelContainer.assetContainer,
|
|
2826
|
+
modelVisible: modelContainer.state.isVisible,
|
|
2827
|
+
environmentVisible: this.#containers.environment.state.isVisible,
|
|
2828
|
+
};
|
|
2829
|
+
this.#debugCameraFrameInfo = {
|
|
2830
|
+
phase: "skipped",
|
|
2831
|
+
reason: "no-bounds",
|
|
2832
|
+
...inspect,
|
|
2833
|
+
...skipped,
|
|
2834
|
+
};
|
|
2835
|
+
console.log("[viewer] frameCameraToModel skipped (no bounds)", skipped);
|
|
2836
|
+
return false;
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
const size = max.subtract(min);
|
|
2840
|
+
const largestDimension = Math.max(size.x, size.y, size.z);
|
|
2841
|
+
if (!Number.isFinite(largestDimension) || largestDimension <= 0) {
|
|
2842
|
+
return false;
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
const center = min.add(max).scale(0.5);
|
|
2846
|
+
camera.setTarget(center);
|
|
2847
|
+
const radiusMultiplier = allowWhenEnvironmentVisible ? 1.7 : 1.4;
|
|
2848
|
+
camera.radius = Math.min(
|
|
2849
|
+
Math.max(largestDimension * radiusMultiplier, camera.lowerRadiusLimit ?? largestDimension * radiusMultiplier),
|
|
2850
|
+
camera.upperRadiusLimit ?? largestDimension * radiusMultiplier,
|
|
2851
|
+
);
|
|
2852
|
+
// Keep the initial model-only view aligned to the showroom's frontal composition.
|
|
2853
|
+
// This preserves the user's camera intent once the scene becomes visible, but gives
|
|
2854
|
+
// the standalone model a consistent front-facing presentation.
|
|
2855
|
+
// Rotate the front-facing composition 180° while keeping the same framing/radius.
|
|
2856
|
+
camera.alpha = Math.PI;
|
|
2857
|
+
camera.beta = Math.PI * 0.47;
|
|
2858
|
+
console.log("[viewer] frameCameraToModel applied", {
|
|
2859
|
+
center,
|
|
2860
|
+
size,
|
|
2861
|
+
radius: camera.radius,
|
|
2862
|
+
});
|
|
2863
|
+
this.#debugCameraFrameInfo = {
|
|
2864
|
+
phase: "applied",
|
|
2865
|
+
...inspect,
|
|
2866
|
+
center: { x: center.x, y: center.y, z: center.z },
|
|
2867
|
+
size: { x: size.x, y: size.y, z: size.z },
|
|
2868
|
+
radius: camera.radius,
|
|
2869
|
+
cameraTarget: camera.target ? { x: camera.target.x, y: camera.target.y, z: camera.target.z } : null,
|
|
2870
|
+
cameraPosition: camera.position ? { x: camera.position.x, y: camera.position.y, z: camera.position.z } : null,
|
|
2871
|
+
cameraLocked: !!camera.metadata?.locked,
|
|
2872
|
+
};
|
|
2873
|
+
return true;
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
/**
|
|
2877
|
+
* Public wrapper to force the active camera to frame the visible model.
|
|
2878
|
+
* @public
|
|
2879
|
+
* @returns {boolean}
|
|
2880
|
+
*/
|
|
2881
|
+
focusModel() {
|
|
2882
|
+
return this.#frameCameraToModel();
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
/**
|
|
2886
|
+
* Returns a compact snapshot of the current debug-relevant 3D state.
|
|
2887
|
+
* @public
|
|
2888
|
+
* @returns {object}
|
|
2889
|
+
*/
|
|
2890
|
+
getDebugState() {
|
|
2891
|
+
const activeCamera = this.#scene?.activeCamera ?? this.#camera ?? null;
|
|
2892
|
+
const modelMeshes = this.#containers.model?.assetContainer?.meshes ?? [];
|
|
2893
|
+
let boundsMin = null;
|
|
2894
|
+
let boundsMax = null;
|
|
2895
|
+
for (const mesh of modelMeshes) {
|
|
2896
|
+
if (!mesh?.getTotalVertices?.() || mesh.getTotalVertices() <= 0) continue;
|
|
2897
|
+
mesh.computeWorldMatrix?.(true);
|
|
2898
|
+
const boundingBox = mesh.getBoundingInfo?.()?.boundingBox;
|
|
2899
|
+
if (!boundingBox?.minimumWorld || !boundingBox?.maximumWorld) continue;
|
|
2900
|
+
const minimum = boundingBox.minimumWorld;
|
|
2901
|
+
const maximum = boundingBox.maximumWorld;
|
|
2902
|
+
boundsMin = boundsMin
|
|
2903
|
+
? { x: Math.min(boundsMin.x, minimum.x), y: Math.min(boundsMin.y, minimum.y), z: Math.min(boundsMin.z, minimum.z) }
|
|
2904
|
+
: { x: minimum.x, y: minimum.y, z: minimum.z };
|
|
2905
|
+
boundsMax = boundsMax
|
|
2906
|
+
? { x: Math.max(boundsMax.x, maximum.x), y: Math.max(boundsMax.y, maximum.y), z: Math.max(boundsMax.z, maximum.z) }
|
|
2907
|
+
: { x: maximum.x, y: maximum.y, z: maximum.z };
|
|
2908
|
+
}
|
|
2909
|
+
const modelBounds = boundsMin && boundsMax ? {
|
|
2910
|
+
min: boundsMin,
|
|
2911
|
+
max: boundsMax,
|
|
2912
|
+
center: {
|
|
2913
|
+
x: (boundsMin.x + boundsMax.x) / 2,
|
|
2914
|
+
y: (boundsMin.y + boundsMax.y) / 2,
|
|
2915
|
+
z: (boundsMin.z + boundsMax.z) / 2,
|
|
2916
|
+
},
|
|
2917
|
+
size: {
|
|
2918
|
+
x: boundsMax.x - boundsMin.x,
|
|
2919
|
+
y: boundsMax.y - boundsMin.y,
|
|
2920
|
+
z: boundsMax.z - boundsMin.z,
|
|
2921
|
+
},
|
|
2922
|
+
} : null;
|
|
2923
|
+
return {
|
|
2924
|
+
camera: activeCamera ? {
|
|
2925
|
+
className: activeCamera.getClassName?.() ?? activeCamera.constructor?.name ?? null,
|
|
2926
|
+
position: activeCamera.position ? { x: activeCamera.position.x, y: activeCamera.position.y, z: activeCamera.position.z } : null,
|
|
2927
|
+
target: activeCamera.target ? { x: activeCamera.target.x, y: activeCamera.target.y, z: activeCamera.target.z } : null,
|
|
2928
|
+
radius: typeof activeCamera.radius === "number" ? activeCamera.radius : null,
|
|
2929
|
+
locked: !!activeCamera.metadata?.locked,
|
|
2930
|
+
} : null,
|
|
2931
|
+
model: {
|
|
2932
|
+
visible: !!this.#containers.model?.state?.isVisible,
|
|
2933
|
+
hasContainer: !!this.#containers.model?.assetContainer,
|
|
2934
|
+
meshCount: this.#containers.model?.assetContainer?.meshes?.length ?? 0,
|
|
2935
|
+
bounds: modelBounds,
|
|
2936
|
+
},
|
|
2937
|
+
environment: {
|
|
2938
|
+
visible: !!this.#containers.environment?.state?.isVisible,
|
|
2939
|
+
hasContainer: !!this.#containers.environment?.assetContainer,
|
|
2940
|
+
},
|
|
2941
|
+
lastCameraFrame: this.#debugCameraFrameInfo,
|
|
2942
|
+
renderLoopRunning: !!this.#state?.render?.isLoopRunning,
|
|
2943
|
+
};
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2557
2946
|
/**
|
|
2558
2947
|
* Stops the Babylon.js render loop for the current scene.
|
|
2559
2948
|
* @private
|
|
@@ -2571,12 +2960,25 @@ export default class BabylonJSController {
|
|
|
2571
2960
|
* @private
|
|
2572
2961
|
* @returns {Promise<void>}
|
|
2573
2962
|
*/
|
|
2574
|
-
async #startRender() {
|
|
2963
|
+
async #startRender({ skipWhenReady = false, forceRender = false, skipCameraDependentEffects = false } = {}) {
|
|
2575
2964
|
if (!this.#scene) return;
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2965
|
+
this.#startRenderInProgress = true;
|
|
2966
|
+
try {
|
|
2967
|
+
if (!skipCameraDependentEffects) {
|
|
2968
|
+
await this.#loadCameraDependentEffects();
|
|
2969
|
+
}
|
|
2970
|
+
// skipWhenReady: model materials already compiled via compileMaterials:true.
|
|
2971
|
+
// Skips scene-wide whenReadyAsync so the model appears immediately (~1s).
|
|
2972
|
+
if (!skipWhenReady) {
|
|
2973
|
+
await this.#scene.whenReadyAsync();
|
|
2974
|
+
}
|
|
2975
|
+
const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#config.render.burstFramesEnhanced : this.#config.render.burstFramesBase;
|
|
2976
|
+
// forceRender: bypasses #loadingInProgress gate so loadContainerIndependent
|
|
2977
|
+
// can show the model preview while #loadContainers is still loading scene/materials.
|
|
2978
|
+
this.#requestRender({ frames: frames, continuousMs: this.#config.render.interactionMs, force: forceRender });
|
|
2979
|
+
} finally {
|
|
2980
|
+
this.#startRenderInProgress = false;
|
|
2981
|
+
}
|
|
2580
2982
|
}
|
|
2581
2983
|
|
|
2582
2984
|
/**
|
|
@@ -2595,7 +2997,7 @@ export default class BabylonJSController {
|
|
|
2595
2997
|
* 3. Builds the Babylon plugin options so extras are surfaced as metadata, then imports the container with
|
|
2596
2998
|
* `LoadAssetContainerAsync`, returning the tuple so the caller can decide how to attach it to the scene.
|
|
2597
2999
|
*/
|
|
2598
|
-
async #loadAssetContainer(container, force = false) {
|
|
3000
|
+
async #loadAssetContainer(container, force = false, { isIndependent = false } = {}) {
|
|
2599
3001
|
if (container?.state?.update?.storage === undefined || container?.state?.size === undefined || container?.state?.timeStamp === undefined) {
|
|
2600
3002
|
return [container, false];
|
|
2601
3003
|
}
|
|
@@ -2611,12 +3013,30 @@ export default class BabylonJSController {
|
|
|
2611
3013
|
const currentSize = force ? 0 : container.state.size;
|
|
2612
3014
|
const currentTimeStamp = force ? null : container.state.timeStamp;
|
|
2613
3015
|
|
|
3016
|
+
const storageType = container.state.update.storage?.db ? "idb" : container.state.update.storage?.url ? "url" : "?";
|
|
3017
|
+
const storageId = container.state.update.storage?.id ?? container.state.update.storage?.url ?? "?";
|
|
3018
|
+
const tResolve = performance.now();
|
|
2614
3019
|
let sourceData = await this.#gltfResolver.getSource(container.state.update.storage, currentSize, currentTimeStamp);
|
|
3020
|
+
console.log(`[perf][${container.state.name}] getSource(${storageType}:${storageId}) ${Math.round(performance.now() - tResolve)}ms → ${sourceData ? `${sourceData.extension} ${sourceData.objectURLs?.length ?? 0} assets` : "skipped"}`);
|
|
2615
3021
|
|
|
2616
3022
|
if (!sourceData) {
|
|
2617
3023
|
return [container, false];
|
|
2618
3024
|
}
|
|
2619
3025
|
|
|
3026
|
+
// Abort if loadContainerIndependent claimed this container while getSource was in flight.
|
|
3027
|
+
// isIndependent=true means THIS call IS the independent load — don't abort yourself.
|
|
3028
|
+
// Returns null (not false) so #loadContainers skips setSuccess(false) for this container.
|
|
3029
|
+
const isClaimedIndependently =
|
|
3030
|
+
(container.state.name === "model" && this.#isLoadingModelIndependent) ||
|
|
3031
|
+
(container.state.name === "environment" && this.#isLoadingEnvironmentIndependent) ||
|
|
3032
|
+
(container.state.name === "materials" && this.#isLoadingMaterialsIndependent);
|
|
3033
|
+
if (isClaimedIndependently && !isIndependent) {
|
|
3034
|
+
if (sourceData.objectURLs?.length) {
|
|
3035
|
+
this.#gltfResolver.revokeObjectURLs(sourceData.objectURLs);
|
|
3036
|
+
}
|
|
3037
|
+
return [container, null];
|
|
3038
|
+
}
|
|
3039
|
+
|
|
2620
3040
|
container.state.setPendingCacheData(sourceData.size, sourceData.timeStamp, sourceData.metadata);
|
|
2621
3041
|
|
|
2622
3042
|
// https://doc.babylonjs.com/typedoc/interfaces/BABYLON.LoadAssetContainerOptions
|
|
@@ -2635,9 +3055,12 @@ export default class BabylonJSController {
|
|
|
2635
3055
|
};
|
|
2636
3056
|
|
|
2637
3057
|
let assetContainer = null;
|
|
3058
|
+
const t0 = performance.now();
|
|
2638
3059
|
|
|
2639
3060
|
try {
|
|
2640
3061
|
assetContainer = await LoadAssetContainerAsync(sourceData.source, this.#scene, options);
|
|
3062
|
+
const elapsed = Math.round(performance.now() - t0);
|
|
3063
|
+
console.log(`[perf][${container.state.name}] LoadAssetContainerAsync ${elapsed}ms`);
|
|
2641
3064
|
return [container, assetContainer];
|
|
2642
3065
|
} catch (error) {
|
|
2643
3066
|
return [container, assetContainer];
|
|
@@ -2657,7 +3080,29 @@ export default class BabylonJSController {
|
|
|
2657
3080
|
*/
|
|
2658
3081
|
async #loadContainers(force = false) {
|
|
2659
3082
|
this.#detachAnimationChangedListener();
|
|
2660
|
-
|
|
3083
|
+
this.#loadingInProgress = true;
|
|
3084
|
+
|
|
3085
|
+
// When only the model is pending (scene/environment already rendered), skip
|
|
3086
|
+
// unloading camera-dependent effects (DefaultRenderingPipeline, SSAO, shadows).
|
|
3087
|
+
// Disposing those pipelines while the render loop was recently active deletes GL
|
|
3088
|
+
// programs that are still referenced, causing WebGL INVALID_OPERATION errors.
|
|
3089
|
+
const isModelOnlyLoad =
|
|
3090
|
+
this.#containers.model?.state?.isPending === true &&
|
|
3091
|
+
this.#containers.environment?.state?.isPending !== true &&
|
|
3092
|
+
this.#containers.materials?.state?.isPending !== true;
|
|
3093
|
+
|
|
3094
|
+
if (isModelOnlyLoad) {
|
|
3095
|
+
this.#stopEngineRenderLoop();
|
|
3096
|
+
this.#resetRenderState();
|
|
3097
|
+
// Replacing an already-visible model can leave Babylon post-process chains attached to
|
|
3098
|
+
// a camera that is about to be discarded or re-bound. Tear them down here so the next
|
|
3099
|
+
// startRender() rebuilds a clean pipeline instead of tripping over stale null entries.
|
|
3100
|
+
if (this.#containers.model?.assetContainer) {
|
|
3101
|
+
await this.#unloadCameraDependentEffects();
|
|
3102
|
+
}
|
|
3103
|
+
} else {
|
|
3104
|
+
await this.#stopRender();
|
|
3105
|
+
}
|
|
2661
3106
|
|
|
2662
3107
|
let oldModelMetadata = { ...(this.#containers.model?.state?.metadata ?? {}) };
|
|
2663
3108
|
let newModelMetadata = {};
|
|
@@ -2672,8 +3117,12 @@ export default class BabylonJSController {
|
|
|
2672
3117
|
error: null,
|
|
2673
3118
|
};
|
|
2674
3119
|
|
|
3120
|
+
const tSceneStart = performance.now();
|
|
3121
|
+
let tAfterLoad, tAfterMerge;
|
|
3122
|
+
|
|
2675
3123
|
await Promise.allSettled(promiseArray)
|
|
2676
3124
|
.then(async (values) => {
|
|
3125
|
+
tAfterLoad = performance.now();
|
|
2677
3126
|
// Scene may have been disposed (disconnectedCallback) while async loading was in
|
|
2678
3127
|
// progress. Abort cleanly: #replaceContainer already guards the GPU calls, but
|
|
2679
3128
|
// we skip the post-load option/visibility calls too to avoid further null-derefs.
|
|
@@ -2682,6 +3131,12 @@ export default class BabylonJSController {
|
|
|
2682
3131
|
return;
|
|
2683
3132
|
}
|
|
2684
3133
|
this.#disposeAnimationController();
|
|
3134
|
+
this.#emitSceneLoadPhase("scene-load-assets-complete", {
|
|
3135
|
+
path: "full",
|
|
3136
|
+
containerCount: values.filter((result) => result.status === "fulfilled" && result.value?.[1]).length,
|
|
3137
|
+
tAfterLoad: tAfterLoad != null ? Math.round(tAfterLoad) : null,
|
|
3138
|
+
tSceneStart: Math.round(tSceneStart),
|
|
3139
|
+
});
|
|
2685
3140
|
values.forEach((result) => {
|
|
2686
3141
|
const container = result.value ? result.value[0] : null;
|
|
2687
3142
|
const assetContainer = result.value ? result.value[1] : null;
|
|
@@ -2697,17 +3152,29 @@ export default class BabylonJSController {
|
|
|
2697
3152
|
this.#replaceContainer(container, assetContainer);
|
|
2698
3153
|
container.state.setSuccess(true);
|
|
2699
3154
|
} else {
|
|
2700
|
-
|
|
2701
|
-
|
|
3155
|
+
// null sentinel = claimed by loadContainerIndependent — already merged independently.
|
|
3156
|
+
// false = normal skip (no data change) or error. Only mark failure for the latter.
|
|
3157
|
+
if (assetContainer !== null) {
|
|
3158
|
+
if (container.assetContainer && container.state.mustBeShown !== container.state.isVisible && container.state.name === "materials") {
|
|
3159
|
+
container.state.mustBeShown ? this.#addContainer(container) : this.#removeContainer(container);
|
|
3160
|
+
}
|
|
3161
|
+
container.state.setSuccess(false);
|
|
2702
3162
|
}
|
|
2703
|
-
container.state.setSuccess(false);
|
|
2704
3163
|
}
|
|
2705
3164
|
});
|
|
2706
3165
|
|
|
2707
|
-
this.#setOptions_Materials();
|
|
3166
|
+
this.#setOptions_Materials(true);
|
|
2708
3167
|
this.#setOptions_Camera();
|
|
2709
3168
|
await this.#setOptions_IBL();
|
|
2710
3169
|
this.#setVisibilityOfWallAndFloorInModel();
|
|
3170
|
+
this.#frameCameraToModel();
|
|
3171
|
+
tAfterMerge = performance.now();
|
|
3172
|
+
this.#emitSceneLoadPhase("scene-load-merge-complete", {
|
|
3173
|
+
path: "full",
|
|
3174
|
+
tAfterMerge: tAfterMerge != null ? Math.round(tAfterMerge) : null,
|
|
3175
|
+
tAfterLoad: tAfterLoad != null ? Math.round(tAfterLoad) : null,
|
|
3176
|
+
tSceneStart: Math.round(tSceneStart),
|
|
3177
|
+
});
|
|
2711
3178
|
detail.success = true;
|
|
2712
3179
|
})
|
|
2713
3180
|
.catch((error) => {
|
|
@@ -2720,7 +3187,9 @@ export default class BabylonJSController {
|
|
|
2720
3187
|
this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
|
|
2721
3188
|
this.#setMaxSimultaneousLights();
|
|
2722
3189
|
this.#enhanceTextureQuality();
|
|
2723
|
-
|
|
3190
|
+
if (this.#containers.model.assetContainer) {
|
|
3191
|
+
this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
|
|
3192
|
+
}
|
|
2724
3193
|
// Apply stored highlight settings so fresh page loads respect persisted state
|
|
2725
3194
|
this.#setHighlightConfig({
|
|
2726
3195
|
enabled: this.#settings.highlightEnabled,
|
|
@@ -2729,7 +3198,24 @@ export default class BabylonJSController {
|
|
|
2729
3198
|
if (this.#babylonJSAnimationController?.hasAnimations?.()) {
|
|
2730
3199
|
this.#attachAnimationChangedListener();
|
|
2731
3200
|
}
|
|
3201
|
+
this.#loadingInProgress = false;
|
|
3202
|
+
// If loadContainerIndependent already merged a container and started the render
|
|
3203
|
+
// loop while this scene load was in flight, #startRender was already called.
|
|
3204
|
+
// Calling it again is safe (pipeline guards skip recreation) but redundant only
|
|
3205
|
+
// when the loop is already running AND scene/shaders are ready; whenReadyAsync
|
|
3206
|
+
// will settle quickly for the newly added containers.
|
|
2732
3207
|
await this.#startRender();
|
|
3208
|
+
this.#frameCameraToModel({ allowWhenEnvironmentVisible: true });
|
|
3209
|
+
this.#emitSceneLoadPhase("scene-load-shaders-complete", {
|
|
3210
|
+
path: "full",
|
|
3211
|
+
tAfterShaders: Math.round(performance.now()),
|
|
3212
|
+
tAfterMerge: tAfterMerge != null ? Math.round(tAfterMerge) : null,
|
|
3213
|
+
});
|
|
3214
|
+
const tLoad = tAfterLoad ? Math.round(tAfterLoad - tSceneStart) : null;
|
|
3215
|
+
const tMerge = tAfterLoad && tAfterMerge ? Math.round(tAfterMerge - tAfterLoad) : null;
|
|
3216
|
+
const tShaders = tAfterMerge ? Math.round(performance.now() - tAfterMerge) : null;
|
|
3217
|
+
const tTotal = Math.round(performance.now() - tSceneStart);
|
|
3218
|
+
console.log(`[perf][scene] load=${tLoad}ms merge=${tMerge}ms shaders=${tShaders}ms total=${tTotal}ms`);
|
|
2733
3219
|
});
|
|
2734
3220
|
return detail;
|
|
2735
3221
|
}
|
|
@@ -3035,6 +3521,7 @@ export default class BabylonJSController {
|
|
|
3035
3521
|
this.#disablingPromises.general = (async () => {
|
|
3036
3522
|
this.#disableInteraction();
|
|
3037
3523
|
this.#disposeAnimationController();
|
|
3524
|
+
this.#finalizePendingModelReplacement();
|
|
3038
3525
|
this.#disposeGLTFResolver();
|
|
3039
3526
|
try {
|
|
3040
3527
|
await this.#disposeXRExperience();
|
|
@@ -3180,6 +3667,297 @@ export default class BabylonJSController {
|
|
|
3180
3667
|
return await this.#loadContainers(force);
|
|
3181
3668
|
}
|
|
3182
3669
|
|
|
3670
|
+
/**
|
|
3671
|
+
* Loads a single named container in the background while the render loop keeps running,
|
|
3672
|
+
* then merges it into the scene atomically (only the merge momentarily pauses the loop).
|
|
3673
|
+
* Camera-dependent effects (post-processing, SSAO, shadows) are preserved across the merge.
|
|
3674
|
+
* If a full #loadContainers is in progress, waits for it to finish before merging.
|
|
3675
|
+
* @public
|
|
3676
|
+
* @param {string} containerName - Name of the container to load ("model", "environment", "materials").
|
|
3677
|
+
* @param {Function} [onReady] - Optional callback invoked after any concurrent scene load finishes
|
|
3678
|
+
* and before the asset is fetched. Use this to mark the container pending (e.g. via
|
|
3679
|
+
* #checkNeedToUpdateContainers) so that the concurrent #loadContainers doesn't consume it first.
|
|
3680
|
+
* @returns {Promise<{success: boolean, error: any, loadedContainers: string[]}>}
|
|
3681
|
+
*/
|
|
3682
|
+
async loadContainerIndependent(containerName, onReady, options = {}) {
|
|
3683
|
+
const loadSessionId = containerName === "model" ? (options?.loadSessionId ?? null) : null;
|
|
3684
|
+
if (containerName === "model" && this.#isLoadingModelIndependent) {
|
|
3685
|
+
return { success: false, error: "Already loading model independently", loadedContainers: [], loadSessionId };
|
|
3686
|
+
}
|
|
3687
|
+
if (containerName === "environment" && this.#isLoadingEnvironmentIndependent) {
|
|
3688
|
+
return { success: false, error: "Already loading environment independently", loadedContainers: [], loadSessionId };
|
|
3689
|
+
}
|
|
3690
|
+
if (containerName === "materials" && this.#isLoadingMaterialsIndependent) {
|
|
3691
|
+
return { success: false, error: "Already loading materials independently", loadedContainers: [], loadSessionId };
|
|
3692
|
+
}
|
|
3693
|
+
|
|
3694
|
+
if (containerName === "model") this.#isLoadingModelIndependent = true;
|
|
3695
|
+
else if (containerName === "environment") this.#isLoadingEnvironmentIndependent = true;
|
|
3696
|
+
else if (containerName === "materials") this.#isLoadingMaterialsIndependent = true;
|
|
3697
|
+
|
|
3698
|
+
// Only reset model-first-painted tracking for model loads — resetting it for
|
|
3699
|
+
// environment/materials would stale any in-flight model-first-painted gate.
|
|
3700
|
+
if (containerName === "model") {
|
|
3701
|
+
this.#modelFirstPaintedEmitted = false;
|
|
3702
|
+
}
|
|
3703
|
+
const modelFirstPaintedLoadToken = containerName === "model"
|
|
3704
|
+
? ++this.#modelFirstPaintedLoadToken
|
|
3705
|
+
: this.#modelFirstPaintedLoadToken;
|
|
3706
|
+
let assetContainer = null;
|
|
3707
|
+
const tIndepStart = performance.now();
|
|
3708
|
+
// Whether the container was already pending inside a running #loadContainers batch.
|
|
3709
|
+
// If so, we fall back to the sequential path (wait → onReady → load) to avoid a
|
|
3710
|
+
// double-load conflict. Otherwise, load the GLTF in parallel with the scene load
|
|
3711
|
+
// and only wait for the atomic merge window.
|
|
3712
|
+
const isAlreadyHandled =
|
|
3713
|
+
this.#containers[containerName]?.state?.isPending === true && this.#loadingInProgress;
|
|
3714
|
+
const path = isAlreadyHandled ? "claimed" : "par";
|
|
3715
|
+
let container;
|
|
3716
|
+
let tAfterLoad, tAfterWait;
|
|
3717
|
+
try {
|
|
3718
|
+
if (!isAlreadyHandled) {
|
|
3719
|
+
// Optimistic path: prepare state and load GLTF NOW, in parallel with any running
|
|
3720
|
+
// #loadContainers. The merge waits for the scene, but the download does not.
|
|
3721
|
+
if (!this.#scene) {
|
|
3722
|
+
return { success: false, error: "No scene available", loadedContainers: [], loadSessionId };
|
|
3723
|
+
}
|
|
3724
|
+
onReady?.();
|
|
3725
|
+
container = this.#containers[containerName];
|
|
3726
|
+
if (!container) {
|
|
3727
|
+
return { success: false, error: `Container "${containerName}" not found`, loadedContainers: [], loadSessionId };
|
|
3728
|
+
}
|
|
3729
|
+
// Phase 1 (parallel): load GLTF while scene may still be loading.
|
|
3730
|
+
// No wait — merge happens immediately after load. #replaceContainer skips
|
|
3731
|
+
// releaseEffects when the loop is running, so no WebGL programs are deleted.
|
|
3732
|
+
const [, loaded] = await this.#loadAssetContainer(container, false, { isIndependent: true });
|
|
3733
|
+
assetContainer = loaded || null;
|
|
3734
|
+
tAfterLoad = performance.now();
|
|
3735
|
+
tAfterWait = tAfterLoad; // no wait phase in optimistic path
|
|
3736
|
+
} else {
|
|
3737
|
+
// Parallel claim path: container was already submitted to a running #loadContainers
|
|
3738
|
+
// batch. #isLoadingModelIndependent (set at the top of this method) signals
|
|
3739
|
+
// #loadAssetContainer to abort the in-flight batch load and return a null sentinel,
|
|
3740
|
+
// so #loadContainers skips merging this container. We load it here in parallel —
|
|
3741
|
+
// no waiting for scene/materials to finish.
|
|
3742
|
+
if (!this.#scene) {
|
|
3743
|
+
return { success: false, error: "No scene available", loadedContainers: [], loadSessionId };
|
|
3744
|
+
}
|
|
3745
|
+
// Call onReady() to ensure the container state carries this load's payload,
|
|
3746
|
+
// which may differ from what #loadContainers was given if the caller swapped the model.
|
|
3747
|
+
onReady?.();
|
|
3748
|
+
container = this.#containers[containerName];
|
|
3749
|
+
if (!container) {
|
|
3750
|
+
return { success: false, error: `Container "${containerName}" not found`, loadedContainers: [], loadSessionId };
|
|
3751
|
+
}
|
|
3752
|
+
const [, loaded] = await this.#loadAssetContainer(container, false, { isIndependent: true });
|
|
3753
|
+
assetContainer = loaded || null;
|
|
3754
|
+
tAfterLoad = performance.now();
|
|
3755
|
+
tAfterWait = tIndepStart; // no wait phase — measure load from start
|
|
3756
|
+
}
|
|
3757
|
+
|
|
3758
|
+
if (!assetContainer) {
|
|
3759
|
+
return { success: false, error: "Failed to load asset container", loadedContainers: [], loadSessionId };
|
|
3760
|
+
}
|
|
3761
|
+
if (!this.#scene) {
|
|
3762
|
+
assetContainer.dispose();
|
|
3763
|
+
return { success: false, error: "Scene was disposed", loadedContainers: [], loadSessionId };
|
|
3764
|
+
}
|
|
3765
|
+
|
|
3766
|
+
this.#emitSceneLoadPhase("scene-load-assets-complete", {
|
|
3767
|
+
containerName,
|
|
3768
|
+
path,
|
|
3769
|
+
tAfterLoad: tAfterLoad != null ? Math.round(tAfterLoad) : null,
|
|
3770
|
+
tIndepStart: Math.round(tIndepStart),
|
|
3771
|
+
});
|
|
3772
|
+
|
|
3773
|
+
// Phase 3: atomic merge — briefly stop the loop for the merge only
|
|
3774
|
+
this.#stopEngineRenderLoop();
|
|
3775
|
+
|
|
3776
|
+
const oldModelMetadata = containerName === "model"
|
|
3777
|
+
? { ...(this.#containers.model?.state?.metadata ?? {}) }
|
|
3778
|
+
: {};
|
|
3779
|
+
|
|
3780
|
+
this.#disposeAnimationController();
|
|
3781
|
+
|
|
3782
|
+
if (containerName === "model") {
|
|
3783
|
+
this.#assetContainer_deleteLights(assetContainer);
|
|
3784
|
+
this.#assetContainer_stopAnimations(assetContainer);
|
|
3785
|
+
}
|
|
3786
|
+
if (containerName === "model" || containerName === "environment") {
|
|
3787
|
+
this.#assetContainer_retagCameras(assetContainer);
|
|
3788
|
+
}
|
|
3789
|
+
|
|
3790
|
+
this.#replaceContainer(container, assetContainer, { releaseEffects: false });
|
|
3791
|
+
container.state.setSuccess(true);
|
|
3792
|
+
|
|
3793
|
+
// Progressive independent loads may bring model/materials/environment in any order.
|
|
3794
|
+
// Force material rebinding after each merge so already-visible model meshes pick up
|
|
3795
|
+
// the latest material definitions instead of waiting for pending flags that may
|
|
3796
|
+
// already have been cleared.
|
|
3797
|
+
this.#setOptions_Materials(true);
|
|
3798
|
+
this.#setOptions_Camera();
|
|
3799
|
+
// For model: IBL not yet loaded (scene still in flight) — create only ambient/directional
|
|
3800
|
+
// lights. #loadContainers will call #createLights() again with full IBL once scene merges.
|
|
3801
|
+
// For environment: IBL state was pre-marked pending by loadSceneIndependent before calling
|
|
3802
|
+
// here, so #setOptions_IBL() picks it up and applies it immediately after lights are ready.
|
|
3803
|
+
await this.#createLights();
|
|
3804
|
+
if (containerName === "environment") {
|
|
3805
|
+
await this.#setOptions_IBL();
|
|
3806
|
+
}
|
|
3807
|
+
|
|
3808
|
+
if (containerName === "model") {
|
|
3809
|
+
const newModelMetadata = { ...(container.state.update?.metadata ?? {}) };
|
|
3810
|
+
this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
|
|
3811
|
+
}
|
|
3812
|
+
|
|
3813
|
+
if (this.#containers.model.assetContainer) {
|
|
3814
|
+
this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
|
|
3815
|
+
}
|
|
3816
|
+
const tAfterMerge = performance.now();
|
|
3817
|
+
this.#emitSceneLoadPhase("scene-load-merge-complete", {
|
|
3818
|
+
containerName,
|
|
3819
|
+
path,
|
|
3820
|
+
tAfterMerge: Math.round(tAfterMerge),
|
|
3821
|
+
tAfterLoad: tAfterLoad != null ? Math.round(tAfterLoad) : null,
|
|
3822
|
+
tIndepStart: Math.round(tIndepStart),
|
|
3823
|
+
});
|
|
3824
|
+
|
|
3825
|
+
// Use #startRender() so the next frame is scheduled after the atomic merge.
|
|
3826
|
+
// In progressive loads, rebuilding camera-dependent effects (DefaultRenderingPipeline,
|
|
3827
|
+
// SSAO, shadows) while the model is already visible can delete GL programs that are
|
|
3828
|
+
// still referenced by the current frame, which leaves the canvas gray until a later
|
|
3829
|
+
// full reload. Keep the existing pipeline alive for independent model/environment/
|
|
3830
|
+
// materials appends; the full load path still rebuilds everything when needed.
|
|
3831
|
+
await this.#startRender({ skipWhenReady: true, forceRender: true, skipCameraDependentEffects: true });
|
|
3832
|
+
const tAfterShaders = performance.now();
|
|
3833
|
+
|
|
3834
|
+
this.#setHighlightConfig({
|
|
3835
|
+
enabled: this.#settings.highlightEnabled,
|
|
3836
|
+
color: this.#settings.highlightColor,
|
|
3837
|
+
});
|
|
3838
|
+
this.#setMaxSimultaneousLights();
|
|
3839
|
+
this.#enhanceTextureQuality();
|
|
3840
|
+
this.#setVisibilityOfWallAndFloorInModel();
|
|
3841
|
+
|
|
3842
|
+
if (this.#babylonJSAnimationController?.hasAnimations?.()) {
|
|
3843
|
+
this.#attachAnimationChangedListener();
|
|
3844
|
+
}
|
|
3845
|
+
|
|
3846
|
+
this.#requestRender({ frames: 3, continuousMs: this.#config.render.interactionMs });
|
|
3847
|
+
if (containerName === "model" && !this.#modelFirstPaintedEmitted) {
|
|
3848
|
+
try {
|
|
3849
|
+
performance.mark(`viewer:model-first-painted:scheduled:${modelFirstPaintedLoadToken}`);
|
|
3850
|
+
} catch {
|
|
3851
|
+
// ignore
|
|
3852
|
+
}
|
|
3853
|
+
console.log("[viewer] model-first-painted gate scheduled", {
|
|
3854
|
+
containerName,
|
|
3855
|
+
path: isAlreadyHandled ? "claimed" : "par",
|
|
3856
|
+
loadToken: modelFirstPaintedLoadToken,
|
|
3857
|
+
renderLoopRunning: this.#state.render.isLoopRunning,
|
|
3858
|
+
loopRequested: true,
|
|
3859
|
+
});
|
|
3860
|
+
void (async () => {
|
|
3861
|
+
await this.#waitForPaint(2);
|
|
3862
|
+
try {
|
|
3863
|
+
performance.mark(`viewer:model-first-painted:after-wait:${modelFirstPaintedLoadToken}`);
|
|
3864
|
+
} catch {
|
|
3865
|
+
// ignore
|
|
3866
|
+
}
|
|
3867
|
+
console.log("[viewer] model-first-painted gate after paint wait", {
|
|
3868
|
+
containerName,
|
|
3869
|
+
path: isAlreadyHandled ? "claimed" : "par",
|
|
3870
|
+
loadToken: modelFirstPaintedLoadToken,
|
|
3871
|
+
currentToken: this.#modelFirstPaintedLoadToken,
|
|
3872
|
+
alreadyEmitted: this.#modelFirstPaintedEmitted,
|
|
3873
|
+
renderLoopRunning: this.#state.render.isLoopRunning,
|
|
3874
|
+
});
|
|
3875
|
+
if (modelFirstPaintedLoadToken !== this.#modelFirstPaintedLoadToken) {
|
|
3876
|
+
try {
|
|
3877
|
+
performance.mark(`viewer:model-first-painted:stale:${modelFirstPaintedLoadToken}`);
|
|
3878
|
+
} catch {
|
|
3879
|
+
// ignore
|
|
3880
|
+
}
|
|
3881
|
+
console.log("[viewer] model-first-painted gate skipped (stale load token)", {
|
|
3882
|
+
containerName,
|
|
3883
|
+
path: isAlreadyHandled ? "claimed" : "par",
|
|
3884
|
+
loadToken: modelFirstPaintedLoadToken,
|
|
3885
|
+
currentToken: this.#modelFirstPaintedLoadToken,
|
|
3886
|
+
});
|
|
3887
|
+
return;
|
|
3888
|
+
}
|
|
3889
|
+
const paintTarget = this.#prefViewer || this.#prefViewer3D || this.#canvas;
|
|
3890
|
+
if (!this.#modelFirstPaintedEmitted && paintTarget) {
|
|
3891
|
+
this.#modelFirstPaintedEmitted = true;
|
|
3892
|
+
try {
|
|
3893
|
+
performance.mark(`viewer:model-first-painted:emitted:${modelFirstPaintedLoadToken}`);
|
|
3894
|
+
} catch {
|
|
3895
|
+
// ignore
|
|
3896
|
+
}
|
|
3897
|
+
console.log("[viewer] model-first-painted ✅ emitted", {
|
|
3898
|
+
containerName,
|
|
3899
|
+
path: isAlreadyHandled ? "claimed" : "par",
|
|
3900
|
+
loadToken: modelFirstPaintedLoadToken,
|
|
3901
|
+
tAfterPaint: Math.round(performance.now()),
|
|
3902
|
+
});
|
|
3903
|
+
this.#emitModelFirstPainted({
|
|
3904
|
+
containerName,
|
|
3905
|
+
path: isAlreadyHandled ? "claimed" : "par",
|
|
3906
|
+
tIndepStart: Math.round(tIndepStart),
|
|
3907
|
+
tAfterLoad: tAfterLoad != null ? Math.round(tAfterLoad) : null,
|
|
3908
|
+
tAfterWait: tAfterWait != null ? Math.round(tAfterWait) : null,
|
|
3909
|
+
tAfterMerge: Math.round(tAfterMerge),
|
|
3910
|
+
tAfterPaint: Math.round(performance.now()),
|
|
3911
|
+
});
|
|
3912
|
+
this.#finalizePendingModelReplacement();
|
|
3913
|
+
}
|
|
3914
|
+
})();
|
|
3915
|
+
}
|
|
3916
|
+
|
|
3917
|
+
// In the optimistic path: load runs first, then wait. In the sequential path: wait
|
|
3918
|
+
// runs first, then load. Compute each phase relative to its actual start point.
|
|
3919
|
+
const tLoad = isAlreadyHandled
|
|
3920
|
+
? Math.round(tAfterLoad - tAfterWait)
|
|
3921
|
+
: Math.round(tAfterLoad - tIndepStart);
|
|
3922
|
+
const tWait = isAlreadyHandled
|
|
3923
|
+
? Math.round(tAfterWait - tIndepStart)
|
|
3924
|
+
: Math.round(tAfterWait - tAfterLoad);
|
|
3925
|
+
// Merge starts after the wait in both paths (optimistic: load→wait→merge; sequential: wait→load→merge)
|
|
3926
|
+
const tMerge = Math.round(tAfterMerge - (isAlreadyHandled ? tAfterLoad : tAfterWait));
|
|
3927
|
+
const tShaders = Math.round(tAfterShaders - tAfterMerge);
|
|
3928
|
+
const tTotal = Math.round(tAfterShaders - tIndepStart);
|
|
3929
|
+
this.#emitSceneLoadPhase("scene-load-shaders-complete", {
|
|
3930
|
+
containerName,
|
|
3931
|
+
path,
|
|
3932
|
+
tAfterShaders: Math.round(tAfterShaders),
|
|
3933
|
+
tAfterMerge: Math.round(tAfterMerge),
|
|
3934
|
+
tTotal,
|
|
3935
|
+
});
|
|
3936
|
+
this.#emitSceneLoadPhase("scene-load-container-complete", {
|
|
3937
|
+
containerName,
|
|
3938
|
+
path,
|
|
3939
|
+
tLoad,
|
|
3940
|
+
tWait,
|
|
3941
|
+
tMerge,
|
|
3942
|
+
tShaders,
|
|
3943
|
+
tTotal,
|
|
3944
|
+
});
|
|
3945
|
+
console.log(`[perf][${containerName}:indep:${path}] load=${tLoad}ms wait=${tWait}ms merge=${tMerge}ms shaders=${tShaders}ms total=${tTotal}ms`);
|
|
3946
|
+
|
|
3947
|
+
return { success: true, error: null, loadedContainers: [containerName], loadSessionId };
|
|
3948
|
+
} catch (error) {
|
|
3949
|
+
assetContainer?.dispose();
|
|
3950
|
+
if (!this.#state.render.isLoopRunning && this.#scene && this.#engine) {
|
|
3951
|
+
this.#startEngineRenderLoop();
|
|
3952
|
+
}
|
|
3953
|
+
return { success: false, error, loadedContainers: [], loadSessionId };
|
|
3954
|
+
} finally {
|
|
3955
|
+
if (containerName === "model") this.#isLoadingModelIndependent = false;
|
|
3956
|
+
else if (containerName === "environment") this.#isLoadingEnvironmentIndependent = false;
|
|
3957
|
+
else if (containerName === "materials") this.#isLoadingMaterialsIndependent = false;
|
|
3958
|
+
}
|
|
3959
|
+
}
|
|
3960
|
+
|
|
3183
3961
|
/**
|
|
3184
3962
|
* Applies camera options from the configuration to the active camera.
|
|
3185
3963
|
* Stops and restarts the render loop to apply changes.
|
|
@@ -3384,3 +4162,4 @@ export default class BabylonJSController {
|
|
|
3384
4162
|
}
|
|
3385
4163
|
}
|
|
3386
4164
|
}
|
|
4165
|
+
|