diff --git a/app/api/canvas/3d/model-proxy/route.ts b/app/api/canvas/3d/model-proxy/route.ts index 832c82949..f7be306e6 100644 --- a/app/api/canvas/3d/model-proxy/route.ts +++ b/app/api/canvas/3d/model-proxy/route.ts @@ -206,6 +206,7 @@ async function handleRequest(request: Request, method: "GET" | "HEAD") { method, headers: forwardedHeaders, cache: "no-store", + signal: request.signal, }); if (!upstream.ok && upstream.status !== 206) { diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index dedb0e450..c6e18307f 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -45,6 +45,11 @@ type SplatParticleCloudData = { positions: Float32Array; seeds: Float32Array; }; +type PrefetchedSplatAsset = { + bytes: Uint8Array; + fileName: string; + url: string; +}; type SparkRevealProfile = { blurAmount: number; preBlurAmount: number; @@ -179,7 +184,8 @@ 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 SPARK_SPLAT_FETCH_TIMEOUT_MS = 18_000; +const SPARK_SPLAT_LOAD_TIMEOUT_MS = 18_000; const KEY_TO_MOVEMENT: Partial> = { KeyW: "forward", @@ -312,6 +318,21 @@ function getSplatWorldBounds(splatMesh: SparkSplatMesh) { .applyMatrix4(splatMesh.matrixWorld); } +function getLoadedSplatCount(splatMesh: SparkSplatMesh) { + try { + return Math.max( + 0, + splatMesh.splats?.getNumSplats?.() ?? + splatMesh.packedSplats?.getNumSplats?.() ?? + splatMesh.extSplats?.getNumSplats?.() ?? + splatMesh.numSplats ?? + 0 + ); + } catch { + return 0; + } +} + function getRobustSplatWorldBounds(splatMesh: SparkSplatMesh) { const packedSplats = splatMesh.packedSplats; if (!packedSplats) { @@ -761,6 +782,96 @@ function WorldSplatLoadingOverlay({ ); } +async function readSplatFetchError(response: Response) { + const fallback = `SPZ 请求失败(HTTP ${response.status})`; + const contentType = response.headers.get("content-type") || ""; + if (!contentType.includes("json") && !contentType.includes("text")) { + return fallback; + } + + const bodyText = await response.text().catch(() => ""); + if (!bodyText) return fallback; + + try { + const parsed = JSON.parse(bodyText) as { error?: unknown; message?: unknown }; + const message = parsed.error || parsed.message; + return typeof message === "string" && message.trim() + ? `${fallback}: ${message.trim()}` + : fallback; + } catch { + return `${fallback}: ${bodyText.slice(0, 180)}`; + } +} + +async function fetchSplatAsset( + url: string, + signal: AbortSignal, + onProgress?: (progress: number) => void +): Promise { + const response = await fetch(url, { + cache: "force-cache", + credentials: "same-origin", + signal, + }); + + if (!response.ok) { + throw new Error(await readSplatFetchError(response)); + } + + const contentLength = Number(response.headers.get("content-length") || 0); + const totalBytes = Number.isFinite(contentLength) && contentLength > 0 + ? contentLength + : null; + + if (!response.body) { + const bytes = new Uint8Array(await response.arrayBuffer()); + if (!bytes.byteLength) { + throw new Error("SPZ 请求成功但内容为空"); + } + onProgress?.(96); + return { + bytes, + fileName: url, + url, + }; + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let loadedBytes = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + chunks.push(value); + loadedBytes += value.byteLength; + + if (totalBytes) { + const progress = Math.round((loadedBytes / totalBytes) * 94); + onProgress?.(THREE.MathUtils.clamp(progress, 1, 96)); + } + } + + if (!loadedBytes) { + throw new Error("SPZ 请求成功但内容为空"); + } + + const bytes = new Uint8Array(loadedBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + + onProgress?.(96); + return { + bytes, + fileName: url, + url, + }; +} + function createMovementState(): MovementState { return { ...EMPTY_MOVEMENT_STATE }; } @@ -1070,6 +1181,8 @@ function HotspotBillboard({ function WorldSplatScene({ splatUrl, + splatBytes, + splatFileName, revealPhase = "loading", firstPerson = false, minimalFrame = false, @@ -1085,8 +1198,11 @@ function WorldSplatScene({ showInfoOverlay = true, movementStateRef, movementEnabled = false, + interactionReady = true, }: { splatUrl: string; + splatBytes?: Uint8Array | null; + splatFileName?: string | null; revealPhase?: SplatLoadingPhase; firstPerson?: boolean; minimalFrame?: boolean; @@ -1102,6 +1218,7 @@ function WorldSplatScene({ showInfoOverlay?: boolean; movementStateRef?: React.MutableRefObject; movementEnabled?: boolean; + interactionReady?: boolean; }) { const camera = useThree((state) => state.camera); const renderer = useThree((state) => state.gl); @@ -1112,6 +1229,7 @@ function WorldSplatScene({ const lastResetViewTokenRef = useRef(resetViewToken); const lastFirstPersonRef = useRef(firstPerson); const revealCompletedRef = useRef(false); + const splatOnLoadRef = useRef<(() => void) | null>(null); const [controlsMountVersion, setControlsMountVersion] = useState(0); const [particleCloudData, setParticleCloudData] = useState(null); const semanticFacts = useMemo( @@ -1146,7 +1264,14 @@ function WorldSplatScene({ () => new SparkSplatMesh({ fileType: SplatFileType.SPZ, - url: splatUrl, + ...(splatBytes + ? { + fileBytes: splatBytes, + fileName: splatFileName || splatUrl, + } + : { + url: splatUrl, + }), lod: true, nonLod: true, lodAbove: SPARK_INLINE_LOD_BUILD_THRESHOLD, @@ -1154,9 +1279,12 @@ function WorldSplatScene({ lodScale: minimalFrame ? SPARK_MINIMAL_FRAME_LOD_SPLAT_SCALE : SPARK_INLINE_LOD_SPLAT_SCALE, + onLoad: () => { + splatOnLoadRef.current?.(); + }, onProgress: onLoadProgress, }), - [minimalFrame, onLoadProgress, splatUrl] + [minimalFrame, onLoadProgress, splatBytes, splatFileName, splatUrl] ); const groundPlaneOffset = typeof semanticsMetadata?.ground_plane_offset === "number" @@ -1199,17 +1327,31 @@ function WorldSplatScene({ let cancelled = false; let idleCallbackId: number | null = null; + let readyPollId: ReturnType | null = null; + let readyFrameId: number | null = null; let timeoutId: ReturnType | null = null; - const loadTimeoutId = globalThis.setTimeout(() => { - if (cancelled) return; - onLoadStateChange?.( - "error", - `SPZ 加载超时(${SPARK_SPLAT_LOAD_TIMEOUT_MS}ms)` - ); - }, SPARK_SPLAT_LOAD_TIMEOUT_MS); + let loadTimeoutId: ReturnType | null = null; + const clearReadyTimers = () => { + if (loadTimeoutId !== null) { + globalThis.clearTimeout(loadTimeoutId); + loadTimeoutId = null; + } + if (readyPollId !== null) { + globalThis.clearInterval(readyPollId); + readyPollId = null; + } + if (readyFrameId !== null) { + window.cancelAnimationFrame(readyFrameId); + readyFrameId = null; + } + }; + const isSplatRenderable = () => + splatMesh.isInitialized || getLoadedSplatCount(splatMesh) > 0; const markReady = () => { if (cancelled || hasAppliedInitialViewRef.current) return; + if (!isSplatRenderable()) return; + clearReadyTimers(); try { lastCameraPoseRef.current = positionCameraInsideWorld({ camera, @@ -1243,15 +1385,40 @@ function WorldSplatScene({ timeoutId = globalThis.setTimeout(buildParticleCloud, 160); } }; + const scheduleMarkReady = () => { + if (cancelled || hasAppliedInitialViewRef.current) return; + if (readyFrameId !== null) return; + readyFrameId = window.requestAnimationFrame(() => { + readyFrameId = null; + markReady(); + }); + }; + loadTimeoutId = globalThis.setTimeout(() => { + if (cancelled) return; + if (isSplatRenderable()) { + scheduleMarkReady(); + return; + } + onLoadStateChange?.( + "error", + `SPZ 加载超时(${SPARK_SPLAT_LOAD_TIMEOUT_MS}ms)` + ); + }, SPARK_SPLAT_LOAD_TIMEOUT_MS); + + splatOnLoadRef.current = scheduleMarkReady; + readyPollId = globalThis.setInterval(() => { + if (isSplatRenderable()) { + scheduleMarkReady(); + } + }, 250); splatMesh.initialized .then(() => { if (cancelled) return; - globalThis.clearTimeout(loadTimeoutId); - window.requestAnimationFrame(markReady); + scheduleMarkReady(); }) .catch((error) => { - globalThis.clearTimeout(loadTimeoutId); + clearReadyTimers(); if (!cancelled) { const message = error instanceof Error ? error.message : String(error); console.warn("[WorldSplatScene] Failed to load SPZ", { @@ -1263,7 +1430,10 @@ function WorldSplatScene({ }); return () => { cancelled = true; - globalThis.clearTimeout(loadTimeoutId); + clearReadyTimers(); + if (splatOnLoadRef.current === scheduleMarkReady) { + splatOnLoadRef.current = null; + } if (idleCallbackId !== null && "cancelIdleCallback" in window) { window.cancelIdleCallback(idleCallbackId); } @@ -1429,13 +1599,13 @@ function WorldSplatScene({ /> ))} {!firstPerson ? ( @@ -1485,7 +1655,7 @@ function WorldSplatScene({ ) : null} - {(firstPerson || interactionMode === "hotspot") && ( + {interactionReady && (firstPerson || interactionMode === "hotspot") && (
{firstPerson && interactionMode === "navigate" ? (
@@ -1585,6 +1755,9 @@ function WorldSplatViewer({ Array<{ url: string; error: string }> >([]); const [loadProgress, setLoadProgress] = useState(null); + const [prefetchedSplat, setPrefetchedSplat] = useState( + null + ); const resolvedSplatUrls = useMemo(() => { const rawUrls = [ splatUrl, @@ -1613,6 +1786,7 @@ function WorldSplatViewer({ useEffect(() => { if (!resolvedSplatUrl) return; loadStartedAtRef.current = performance.now(); + setPrefetchedSplat(null); setLoadProgress(null); setLoadState("loading"); setOverlayPhase("loading"); @@ -1674,6 +1848,7 @@ function WorldSplatViewer({ failedUrl, nextUrl, }); + setPrefetchedSplat(null); setActiveSplatIndex(nextIndex); setLoadProgress(null); setLoadState("loading"); @@ -1686,11 +1861,52 @@ function WorldSplatViewer({ failedUrl, attemptedUrls: resolvedSplatUrls, }); + setPrefetchedSplat(null); setLoadState("error"); }, [activeSplatIndex, resolvedSplatUrl, resolvedSplatUrls] ); + useEffect(() => { + if (!resolvedSplatUrl) return; + + const abortController = new AbortController(); + let cancelled = false; + const timeoutId = globalThis.setTimeout(() => { + abortController.abort(); + }, SPARK_SPLAT_FETCH_TIMEOUT_MS); + + fetchSplatAsset(resolvedSplatUrl, abortController.signal, (progress) => { + if (cancelled) return; + setLoadProgress(progress); + }) + .then((asset) => { + if (cancelled) return; + globalThis.clearTimeout(timeoutId); + setPrefetchedSplat(asset); + setLoadProgress((progress) => + typeof progress === "number" ? Math.max(progress, 96) : 96 + ); + }) + .catch((error) => { + if (cancelled) return; + globalThis.clearTimeout(timeoutId); + const message = + error instanceof DOMException && error.name === "AbortError" + ? `SPZ 请求超时(${SPARK_SPLAT_FETCH_TIMEOUT_MS}ms)` + : error instanceof Error + ? error.message + : String(error); + handleSplatLoadStateChange("error", message); + }); + + return () => { + cancelled = true; + globalThis.clearTimeout(timeoutId); + abortController.abort(); + }; + }, [handleSplatLoadStateChange, resolvedSplatUrl]); + if (!resolvedSplatUrl) { return ( - setOverlayPhase("hidden")} - revealPhase={overlayPhase} - resetViewToken={resetViewToken} - semanticsMetadata={semanticsMetadata} - showInfoOverlay={showInfoOverlay} - splatUrl={resolvedSplatUrl} - movementStateRef={movementStateRef} - movementEnabled={showMovementControls} - /> + {prefetchedSplat?.url === resolvedSplatUrl ? ( + setOverlayPhase("hidden")} + revealPhase={overlayPhase} + resetViewToken={resetViewToken} + semanticsMetadata={semanticsMetadata} + showInfoOverlay={showInfoOverlay} + splatBytes={prefetchedSplat.bytes} + splatFileName={prefetchedSplat.fileName} + splatUrl={resolvedSplatUrl} + /> + ) : null} {showMovementControls && loadState === "ready" ? (