diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index f22d4b423..b9f90295d 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -178,6 +178,7 @@ const SPARK_MINIMAL_FRAME_LOD_SPLAT_SCALE = 0.82; const SPARK_MINIMAL_FRAME_LOD_RENDER_SCALE = 1.6; const SPARK_MIN_SORT_INTERVAL_MS = 24; const SPARK_MINIMAL_FRAME_SORT_INTERVAL_MS = 40; +const SPARK_SPLAT_LOAD_TIMEOUT_MS = 45_000; const KEY_TO_MOVEMENT: Partial> = { KeyW: "forward", @@ -507,12 +508,14 @@ function WorldEmptyState({ description, fillFrame = false, previewImageUrl, + loading = true, }: { icon: React.ComponentType<{ className?: string }>; title: string; description: string; fillFrame?: boolean; previewImageUrl?: string | null; + loading?: boolean; }) { if (previewImageUrl && fillFrame) { return ( @@ -523,7 +526,13 @@ function WorldEmptyState({ src={previewImageUrl} />
- + {loading ? ( + + ) : ( +
+ +
+ )}
); @@ -549,7 +558,13 @@ function WorldEmptyState({ src={previewImageUrl} />
- + {loading ? ( + + ) : ( +
+ +
+ )}
) : ( @@ -1173,40 +1188,63 @@ function WorldSplatScene({ let cancelled = false; let idleCallbackId: number | null = null; let timeoutId: ReturnType | null = null; + const loadTimeoutId = globalThis.setTimeout(() => { + if (cancelled) return; + onLoadStateChange?.("error"); + }, SPARK_SPLAT_LOAD_TIMEOUT_MS); + + const markReady = () => { + if (cancelled || hasAppliedInitialViewRef.current) return; + try { + lastCameraPoseRef.current = positionCameraInsideWorld({ + camera, + controls: orbitControlsRef.current, + firstPerson, + groundPlaneOffset, + splatMesh, + }); + } catch (error) { + console.warn("[WorldSplatScene] Failed to position camera for SPZ", error); + lastCameraPoseRef.current = null; + } + + hasAppliedInitialViewRef.current = true; + onLoadStateChange?.("ready"); + + const buildParticleCloud = () => { + if (cancelled || revealPhaseRef.current === "hidden") return; + try { + setParticleCloudData(buildSplatParticleCloudData(splatMesh)); + } catch (error) { + console.warn("[WorldSplatScene] Failed to build SPZ particle cloud", error); + } + }; + + if ("requestIdleCallback" in window) { + idleCallbackId = window.requestIdleCallback(buildParticleCloud, { + timeout: 250, + }); + } else { + timeoutId = globalThis.setTimeout(buildParticleCloud, 160); + } + }; + splatMesh.initialized .then(() => { if (cancelled) return; - window.requestAnimationFrame(() => { - if (cancelled || hasAppliedInitialViewRef.current) return; - lastCameraPoseRef.current = positionCameraInsideWorld({ - camera, - controls: orbitControlsRef.current, - firstPerson, - groundPlaneOffset, - splatMesh, - }); - hasAppliedInitialViewRef.current = true; - onLoadStateChange?.("ready"); - - const buildParticleCloud = () => { - if (cancelled || revealPhaseRef.current === "hidden") return; - setParticleCloudData(buildSplatParticleCloudData(splatMesh)); - }; - - if ("requestIdleCallback" in window) { - idleCallbackId = window.requestIdleCallback(buildParticleCloud, { - timeout: 250, - }); - } else { - timeoutId = globalThis.setTimeout(buildParticleCloud, 160); - } - }); + globalThis.clearTimeout(loadTimeoutId); + window.requestAnimationFrame(markReady); }) - .catch(() => { - if (!cancelled) onLoadStateChange?.("error"); + .catch((error) => { + globalThis.clearTimeout(loadTimeoutId); + if (!cancelled) { + console.warn("[WorldSplatScene] Failed to load SPZ", error); + onLoadStateChange?.("error"); + } }); return () => { cancelled = true; + globalThis.clearTimeout(loadTimeoutId); if (idleCallbackId !== null && "cancelIdleCallback" in window) { window.cancelIdleCallback(idleCallbackId); } @@ -1551,6 +1589,7 @@ function WorldSplatViewer({ } fillFrame={minimalFrame} icon={AlertCircle} + loading={false} previewImageUrl={previewImageUrl} title="World 视图加载失败" />