@preference-sl/pref-viewer 2.15.1 → 2.16.0-beta.1
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 +742 -36
- package/src/file-storage.js +126 -5
- package/src/gltf-resolver.js +58 -24
- package/src/pref-viewer-3d.js +112 -1
- package/src/pref-viewer.js +147 -1
- package/src/styles.js +1 -0
|
@@ -165,6 +165,8 @@ 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;
|
|
168
170
|
|
|
169
171
|
// Babylon.js core objects
|
|
170
172
|
#engine = null;
|
|
@@ -174,6 +176,7 @@ export default class BabylonJSController {
|
|
|
174
176
|
#dirLight = null;
|
|
175
177
|
#moonLight = null;
|
|
176
178
|
#cameraLight = null;
|
|
179
|
+
#debugCameraFrameInfo = null;
|
|
177
180
|
#shadowGen = [];
|
|
178
181
|
#XRExperience = null;
|
|
179
182
|
#canvasResizeObserver = null;
|
|
@@ -185,6 +188,11 @@ export default class BabylonJSController {
|
|
|
185
188
|
|
|
186
189
|
#gltfResolver = null; // GLTFResolver instance
|
|
187
190
|
#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)
|
|
188
196
|
|
|
189
197
|
#renderPipelines = {
|
|
190
198
|
default: null,
|
|
@@ -748,8 +756,8 @@ export default class BabylonJSController {
|
|
|
748
756
|
* @param {number} [options.continuousMs=0] - Milliseconds to keep continuous rendering active.
|
|
749
757
|
* @returns {boolean} True when the request was accepted, false when scene/engine are unavailable.
|
|
750
758
|
*/
|
|
751
|
-
#requestRender({ frames = 1, continuousMs = 0 } = {}) {
|
|
752
|
-
if (!this.#scene || !this.#engine) {
|
|
759
|
+
#requestRender({ frames = 1, continuousMs = 0, force = false } = {}) {
|
|
760
|
+
if (!this.#scene || !this.#engine || (this.#loadingInProgress && !force)) {
|
|
753
761
|
return false;
|
|
754
762
|
}
|
|
755
763
|
|
|
@@ -1112,6 +1120,11 @@ export default class BabylonJSController {
|
|
|
1112
1120
|
|
|
1113
1121
|
const pipelineName = "PrefViewerSSAORenderingPipeline";
|
|
1114
1122
|
|
|
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
|
+
|
|
1115
1128
|
const ssaoRatio = {
|
|
1116
1129
|
ssaoRatio: 0.5,
|
|
1117
1130
|
combineRatio: 1.0,
|
|
@@ -1213,6 +1226,21 @@ export default class BabylonJSController {
|
|
|
1213
1226
|
|
|
1214
1227
|
const pipelineName = "PrefViewerDefaultRenderingPipeline";
|
|
1215
1228
|
|
|
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
|
+
|
|
1216
1244
|
let defaultPipeline = new DefaultRenderingPipeline(pipelineName, true, this.#scene, [this.#scene.activeCamera], true);
|
|
1217
1245
|
|
|
1218
1246
|
if (!defaultPipeline) {
|
|
@@ -1390,6 +1418,11 @@ export default class BabylonJSController {
|
|
|
1390
1418
|
|
|
1391
1419
|
const pipelineName = "PrefViewerIblShadowsRenderPipeline";
|
|
1392
1420
|
|
|
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
|
+
|
|
1393
1426
|
// Use hardware-adaptive quality settings
|
|
1394
1427
|
const pipelineOptions = {
|
|
1395
1428
|
resolutionExp: this.#config.quality.iblShadowResolution, // Adaptive: 0=1024, 1=2048, 2=4096
|
|
@@ -1641,7 +1674,7 @@ export default class BabylonJSController {
|
|
|
1641
1674
|
const showroom = this.#config.look?.showroom || {};
|
|
1642
1675
|
const imageProcessing = this.#scene.imageProcessingConfiguration;
|
|
1643
1676
|
|
|
1644
|
-
this.#scene.clearColor = new Color4(
|
|
1677
|
+
this.#scene.clearColor = new Color4(1, 1, 1, 1).toLinearSpace();
|
|
1645
1678
|
imageProcessing.exposure = showroom.exposure ?? 0.95;
|
|
1646
1679
|
imageProcessing.contrast = showroom.contrast ?? 1.08;
|
|
1647
1680
|
imageProcessing.toneMappingEnabled = showroom.toneMappingEnabled ?? true;
|
|
@@ -1681,12 +1714,7 @@ export default class BabylonJSController {
|
|
|
1681
1714
|
imageProcessing.colorCurvesEnabled = false;
|
|
1682
1715
|
}
|
|
1683
1716
|
|
|
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();
|
|
1717
|
+
this.#scene.clearColor = new Color4(1, 1, 1, 1).toLinearSpace();
|
|
1690
1718
|
|
|
1691
1719
|
if ("environmentIntensity" in this.#scene) {
|
|
1692
1720
|
this.#scene.environmentIntensity = lerp(0.42, showroom.environmentIntensity ?? 1.08, peakLight);
|
|
@@ -2175,6 +2203,10 @@ export default class BabylonJSController {
|
|
|
2175
2203
|
}
|
|
2176
2204
|
this.#state.resize.lastAppliedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
2177
2205
|
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();
|
|
2178
2210
|
this.#applyShowroomCameraFraming();
|
|
2179
2211
|
this.#engine.resize();
|
|
2180
2212
|
const needsEnhancedBurst = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled || this.#settings.shadowsEnabled;
|
|
@@ -2217,7 +2249,7 @@ export default class BabylonJSController {
|
|
|
2217
2249
|
* @param {MaterialData} optionMaterial - Material option containing value, nodePrefixes, nodeNames, and state.
|
|
2218
2250
|
* @returns {boolean} True if any mesh material was set, false otherwise.
|
|
2219
2251
|
*/
|
|
2220
|
-
#setOptionsMaterial(optionMaterial) {
|
|
2252
|
+
#setOptionsMaterial(optionMaterial, force = false) {
|
|
2221
2253
|
if (!optionMaterial || !(optionMaterial.value || (optionMaterial.isPending && optionMaterial.update.value)) || !(optionMaterial.nodePrefixes.length || optionMaterial.nodeNames.length)) {
|
|
2222
2254
|
return false;
|
|
2223
2255
|
}
|
|
@@ -2235,7 +2267,7 @@ export default class BabylonJSController {
|
|
|
2235
2267
|
if (container.state.name === "materials") {
|
|
2236
2268
|
return;
|
|
2237
2269
|
}
|
|
2238
|
-
if (container.assetContainer && (container.state.isPending || materialContainer.state.isPending || optionMaterial.isPending)) {
|
|
2270
|
+
if (container.assetContainer && (force || container.state.isPending || materialContainer.state.isPending || optionMaterial.isPending)) {
|
|
2239
2271
|
assetContainersToProcess.push(container.assetContainer);
|
|
2240
2272
|
}
|
|
2241
2273
|
});
|
|
@@ -2268,10 +2300,10 @@ export default class BabylonJSController {
|
|
|
2268
2300
|
* @private
|
|
2269
2301
|
* @returns {boolean} True if any material option was set, false otherwise.
|
|
2270
2302
|
*/
|
|
2271
|
-
#setOptions_Materials() {
|
|
2303
|
+
#setOptions_Materials(force = false) {
|
|
2272
2304
|
let someSetted = false;
|
|
2273
2305
|
Object.values(this.#options.materials).forEach((material) => {
|
|
2274
|
-
let settedMaterial = this.#setOptionsMaterial(material);
|
|
2306
|
+
let settedMaterial = this.#setOptionsMaterial(material, force);
|
|
2275
2307
|
someSetted = someSetted || settedMaterial;
|
|
2276
2308
|
});
|
|
2277
2309
|
return someSetted;
|
|
@@ -2380,7 +2412,9 @@ export default class BabylonJSController {
|
|
|
2380
2412
|
prefViewer3D = host?.nodeName === "PREF-VIEWER-3D" ? host : undefined;
|
|
2381
2413
|
}
|
|
2382
2414
|
|
|
2383
|
-
|
|
2415
|
+
if (prefViewer3D) {
|
|
2416
|
+
this.#prefViewer3D = prefViewer3D;
|
|
2417
|
+
}
|
|
2384
2418
|
}
|
|
2385
2419
|
|
|
2386
2420
|
/**
|
|
@@ -2395,7 +2429,6 @@ export default class BabylonJSController {
|
|
|
2395
2429
|
|
|
2396
2430
|
this.#getPrefViewer3DComponent();
|
|
2397
2431
|
if (this.#prefViewer3D === undefined) {
|
|
2398
|
-
this.#prefViewer = undefined;
|
|
2399
2432
|
return;
|
|
2400
2433
|
}
|
|
2401
2434
|
|
|
@@ -2405,7 +2438,9 @@ export default class BabylonJSController {
|
|
|
2405
2438
|
prefViewer = host?.nodeName === "PREF-VIEWER" ? host : undefined;
|
|
2406
2439
|
}
|
|
2407
2440
|
|
|
2408
|
-
|
|
2441
|
+
if (prefViewer) {
|
|
2442
|
+
this.#prefViewer = prefViewer;
|
|
2443
|
+
}
|
|
2409
2444
|
}
|
|
2410
2445
|
|
|
2411
2446
|
/**
|
|
@@ -2419,13 +2454,94 @@ export default class BabylonJSController {
|
|
|
2419
2454
|
// Cache references to parent custom elements
|
|
2420
2455
|
this.#getPrefViewer3DComponent();
|
|
2421
2456
|
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
|
+
};
|
|
2422
2467
|
if (name === "model") {
|
|
2423
|
-
this.#prefViewer3D
|
|
2424
|
-
this.#prefViewer
|
|
2468
|
+
syncVisibility(this.#prefViewer3D, "show-model");
|
|
2469
|
+
syncVisibility(this.#prefViewer, "show-model");
|
|
2425
2470
|
} else if (name === "environment") {
|
|
2426
|
-
this.#prefViewer3D
|
|
2427
|
-
this.#prefViewer
|
|
2471
|
+
syncVisibility(this.#prefViewer3D, "show-scene");
|
|
2472
|
+
syncVisibility(this.#prefViewer, "show-scene");
|
|
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;
|
|
2428
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;
|
|
2512
|
+
}
|
|
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
|
+
});
|
|
2429
2545
|
}
|
|
2430
2546
|
|
|
2431
2547
|
/**
|
|
@@ -2473,7 +2589,7 @@ export default class BabylonJSController {
|
|
|
2473
2589
|
* @param {AssetContainer} newAssetContainer - The new asset container to add to the scene.
|
|
2474
2590
|
* @returns {boolean} True if the container was replaced and added, false otherwise.
|
|
2475
2591
|
*/
|
|
2476
|
-
#replaceContainer(container, newAssetContainer) {
|
|
2592
|
+
#replaceContainer(container, newAssetContainer, { releaseEffects = true } = {}) {
|
|
2477
2593
|
if (container.assetContainer) {
|
|
2478
2594
|
this.#removeContainer(container, false);
|
|
2479
2595
|
container.assetContainer.dispose();
|
|
@@ -2485,8 +2601,13 @@ export default class BabylonJSController {
|
|
|
2485
2601
|
newAssetContainer?.dispose();
|
|
2486
2602
|
return false;
|
|
2487
2603
|
}
|
|
2488
|
-
|
|
2489
|
-
|
|
2604
|
+
// Skip releaseEffects when the render loop is active: deleting GL programs while frames
|
|
2605
|
+
// are being produced causes useProgram:deleted errors. Also skipped when the caller
|
|
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
|
+
}
|
|
2490
2611
|
|
|
2491
2612
|
container.assetContainer = newAssetContainer;
|
|
2492
2613
|
return this.#addContainer(container, false);
|
|
@@ -2548,12 +2669,209 @@ export default class BabylonJSController {
|
|
|
2548
2669
|
if (!this.#containers.model.assetContainer || !this.#containers.model.state.isVisible) {
|
|
2549
2670
|
return false;
|
|
2550
2671
|
}
|
|
2551
|
-
|
|
2672
|
+
// In model-only previews, or while the environment is still loading, keep wall/floor meshes visible.
|
|
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);
|
|
2552
2679
|
const nodePrefixes = Object.values(this.#options.materials).flatMap((material) => material.nodePrefixes);
|
|
2553
2680
|
const nodeNames = Object.values(this.#options.materials).flatMap((material) => material.nodeNames);
|
|
2554
2681
|
this.#containers.model.assetContainer.meshes.filter((meshToFilter) => nodePrefixes.some((prefix) => meshToFilter.name.startsWith(prefix)) || nodeNames.includes(meshToFilter.name)).forEach((mesh) => mesh.setEnabled(show));
|
|
2555
2682
|
}
|
|
2556
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
|
+
};
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2557
2875
|
/**
|
|
2558
2876
|
* Stops the Babylon.js render loop for the current scene.
|
|
2559
2877
|
* @private
|
|
@@ -2571,12 +2889,25 @@ export default class BabylonJSController {
|
|
|
2571
2889
|
* @private
|
|
2572
2890
|
* @returns {Promise<void>}
|
|
2573
2891
|
*/
|
|
2574
|
-
async #startRender() {
|
|
2892
|
+
async #startRender({ skipWhenReady = false, forceRender = false, skipCameraDependentEffects = false } = {}) {
|
|
2575
2893
|
if (!this.#scene) return;
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2894
|
+
this.#startRenderInProgress = true;
|
|
2895
|
+
try {
|
|
2896
|
+
if (!skipCameraDependentEffects) {
|
|
2897
|
+
await this.#loadCameraDependentEffects();
|
|
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
|
+
}
|
|
2580
2911
|
}
|
|
2581
2912
|
|
|
2582
2913
|
/**
|
|
@@ -2595,7 +2926,7 @@ export default class BabylonJSController {
|
|
|
2595
2926
|
* 3. Builds the Babylon plugin options so extras are surfaced as metadata, then imports the container with
|
|
2596
2927
|
* `LoadAssetContainerAsync`, returning the tuple so the caller can decide how to attach it to the scene.
|
|
2597
2928
|
*/
|
|
2598
|
-
async #loadAssetContainer(container, force = false) {
|
|
2929
|
+
async #loadAssetContainer(container, force = false, { isIndependent = false } = {}) {
|
|
2599
2930
|
if (container?.state?.update?.storage === undefined || container?.state?.size === undefined || container?.state?.timeStamp === undefined) {
|
|
2600
2931
|
return [container, false];
|
|
2601
2932
|
}
|
|
@@ -2611,12 +2942,30 @@ export default class BabylonJSController {
|
|
|
2611
2942
|
const currentSize = force ? 0 : container.state.size;
|
|
2612
2943
|
const currentTimeStamp = force ? null : container.state.timeStamp;
|
|
2613
2944
|
|
|
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();
|
|
2614
2948
|
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"}`);
|
|
2615
2950
|
|
|
2616
2951
|
if (!sourceData) {
|
|
2617
2952
|
return [container, false];
|
|
2618
2953
|
}
|
|
2619
2954
|
|
|
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
|
+
|
|
2620
2969
|
container.state.setPendingCacheData(sourceData.size, sourceData.timeStamp, sourceData.metadata);
|
|
2621
2970
|
|
|
2622
2971
|
// https://doc.babylonjs.com/typedoc/interfaces/BABYLON.LoadAssetContainerOptions
|
|
@@ -2635,9 +2984,12 @@ export default class BabylonJSController {
|
|
|
2635
2984
|
};
|
|
2636
2985
|
|
|
2637
2986
|
let assetContainer = null;
|
|
2987
|
+
const t0 = performance.now();
|
|
2638
2988
|
|
|
2639
2989
|
try {
|
|
2640
2990
|
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`);
|
|
2641
2993
|
return [container, assetContainer];
|
|
2642
2994
|
} catch (error) {
|
|
2643
2995
|
return [container, assetContainer];
|
|
@@ -2657,7 +3009,29 @@ export default class BabylonJSController {
|
|
|
2657
3009
|
*/
|
|
2658
3010
|
async #loadContainers(force = false) {
|
|
2659
3011
|
this.#detachAnimationChangedListener();
|
|
2660
|
-
|
|
3012
|
+
this.#loadingInProgress = true;
|
|
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
|
+
}
|
|
2661
3035
|
|
|
2662
3036
|
let oldModelMetadata = { ...(this.#containers.model?.state?.metadata ?? {}) };
|
|
2663
3037
|
let newModelMetadata = {};
|
|
@@ -2672,8 +3046,12 @@ export default class BabylonJSController {
|
|
|
2672
3046
|
error: null,
|
|
2673
3047
|
};
|
|
2674
3048
|
|
|
3049
|
+
const tSceneStart = performance.now();
|
|
3050
|
+
let tAfterLoad, tAfterMerge;
|
|
3051
|
+
|
|
2675
3052
|
await Promise.allSettled(promiseArray)
|
|
2676
3053
|
.then(async (values) => {
|
|
3054
|
+
tAfterLoad = performance.now();
|
|
2677
3055
|
// Scene may have been disposed (disconnectedCallback) while async loading was in
|
|
2678
3056
|
// progress. Abort cleanly: #replaceContainer already guards the GPU calls, but
|
|
2679
3057
|
// we skip the post-load option/visibility calls too to avoid further null-derefs.
|
|
@@ -2682,6 +3060,12 @@ export default class BabylonJSController {
|
|
|
2682
3060
|
return;
|
|
2683
3061
|
}
|
|
2684
3062
|
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
|
+
});
|
|
2685
3069
|
values.forEach((result) => {
|
|
2686
3070
|
const container = result.value ? result.value[0] : null;
|
|
2687
3071
|
const assetContainer = result.value ? result.value[1] : null;
|
|
@@ -2697,17 +3081,29 @@ export default class BabylonJSController {
|
|
|
2697
3081
|
this.#replaceContainer(container, assetContainer);
|
|
2698
3082
|
container.state.setSuccess(true);
|
|
2699
3083
|
} else {
|
|
2700
|
-
|
|
2701
|
-
|
|
3084
|
+
// null sentinel = claimed by loadContainerIndependent — already merged independently.
|
|
3085
|
+
// false = normal skip (no data change) or error. Only mark failure for the latter.
|
|
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);
|
|
2702
3091
|
}
|
|
2703
|
-
container.state.setSuccess(false);
|
|
2704
3092
|
}
|
|
2705
3093
|
});
|
|
2706
3094
|
|
|
2707
|
-
this.#setOptions_Materials();
|
|
3095
|
+
this.#setOptions_Materials(true);
|
|
2708
3096
|
this.#setOptions_Camera();
|
|
2709
3097
|
await this.#setOptions_IBL();
|
|
2710
3098
|
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
|
+
});
|
|
2711
3107
|
detail.success = true;
|
|
2712
3108
|
})
|
|
2713
3109
|
.catch((error) => {
|
|
@@ -2720,7 +3116,9 @@ export default class BabylonJSController {
|
|
|
2720
3116
|
this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
|
|
2721
3117
|
this.#setMaxSimultaneousLights();
|
|
2722
3118
|
this.#enhanceTextureQuality();
|
|
2723
|
-
|
|
3119
|
+
if (this.#containers.model.assetContainer) {
|
|
3120
|
+
this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
|
|
3121
|
+
}
|
|
2724
3122
|
// Apply stored highlight settings so fresh page loads respect persisted state
|
|
2725
3123
|
this.#setHighlightConfig({
|
|
2726
3124
|
enabled: this.#settings.highlightEnabled,
|
|
@@ -2729,7 +3127,24 @@ export default class BabylonJSController {
|
|
|
2729
3127
|
if (this.#babylonJSAnimationController?.hasAnimations?.()) {
|
|
2730
3128
|
this.#attachAnimationChangedListener();
|
|
2731
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.
|
|
2732
3136
|
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`);
|
|
2733
3148
|
});
|
|
2734
3149
|
return detail;
|
|
2735
3150
|
}
|
|
@@ -3180,6 +3595,296 @@ export default class BabylonJSController {
|
|
|
3180
3595
|
return await this.#loadContainers(force);
|
|
3181
3596
|
}
|
|
3182
3597
|
|
|
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
|
+
|
|
3183
3888
|
/**
|
|
3184
3889
|
* Applies camera options from the configuration to the active camera.
|
|
3185
3890
|
* Stops and restarts the render loop to apply changes.
|
|
@@ -3384,3 +4089,4 @@ export default class BabylonJSController {
|
|
|
3384
4089
|
}
|
|
3385
4090
|
}
|
|
3386
4091
|
}
|
|
4092
|
+
|