fix: harden 3d world splat loading
This commit is contained in:
parent
981a01a191
commit
4cc0977c9f
@ -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) {
|
||||
|
||||
@ -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<Record<string, MovementCommand>> = {
|
||||
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<PrefetchedSplatAsset> {
|
||||
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<MovementState>;
|
||||
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<number | undefined>(resetViewToken);
|
||||
const lastFirstPersonRef = useRef(firstPerson);
|
||||
const revealCompletedRef = useRef(false);
|
||||
const splatOnLoadRef = useRef<(() => void) | null>(null);
|
||||
const [controlsMountVersion, setControlsMountVersion] = useState(0);
|
||||
const [particleCloudData, setParticleCloudData] = useState<SplatParticleCloudData | null>(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<typeof globalThis.setInterval> | null = null;
|
||||
let readyFrameId: number | null = null;
|
||||
let timeoutId: ReturnType<typeof globalThis.setTimeout> | 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<typeof globalThis.setTimeout> | 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({
|
||||
/>
|
||||
))}
|
||||
<CameraMovementControls
|
||||
enabled={movementEnabled && interactionMode === "navigate"}
|
||||
enabled={interactionReady && movementEnabled && interactionMode === "navigate"}
|
||||
firstPerson={firstPerson}
|
||||
movementStateRef={movementStateRef}
|
||||
orbitControlsRef={orbitControlsRef}
|
||||
/>
|
||||
<FirstPersonLookControls
|
||||
enabled={interactionMode === "navigate" && firstPerson}
|
||||
enabled={interactionReady && interactionMode === "navigate" && firstPerson}
|
||||
resetToken={resetViewToken}
|
||||
/>
|
||||
{!firstPerson ? (
|
||||
@ -1485,7 +1655,7 @@ function WorldSplatScene({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{(firstPerson || interactionMode === "hotspot") && (
|
||||
{interactionReady && (firstPerson || interactionMode === "hotspot") && (
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
{firstPerson && interactionMode === "navigate" ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/55 px-3 py-2 text-[10px] text-zinc-300 shadow-xl backdrop-blur-sm">
|
||||
@ -1585,6 +1755,9 @@ function WorldSplatViewer({
|
||||
Array<{ url: string; error: string }>
|
||||
>([]);
|
||||
const [loadProgress, setLoadProgress] = useState<number | null>(null);
|
||||
const [prefetchedSplat, setPrefetchedSplat] = useState<PrefetchedSplatAsset | null>(
|
||||
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 (
|
||||
<WorldEmptyState
|
||||
@ -1739,25 +1955,30 @@ function WorldSplatViewer({
|
||||
}}
|
||||
>
|
||||
<WorldSplatCaptureBridge onCaptureReady={onCaptureReady} />
|
||||
<WorldSplatScene
|
||||
key={resolvedSplatUrl}
|
||||
firstPerson={firstPerson}
|
||||
hotspots={hotspots}
|
||||
interactionMode={interactionMode}
|
||||
minimalFrame={minimalFrame}
|
||||
onHotspotSelect={onHotspotSelect}
|
||||
onLoadProgress={handleSplatProgress}
|
||||
onLoadStateChange={handleSplatLoadStateChange}
|
||||
onPick={onPick}
|
||||
onRevealComplete={() => setOverlayPhase("hidden")}
|
||||
revealPhase={overlayPhase}
|
||||
resetViewToken={resetViewToken}
|
||||
semanticsMetadata={semanticsMetadata}
|
||||
showInfoOverlay={showInfoOverlay}
|
||||
splatUrl={resolvedSplatUrl}
|
||||
movementStateRef={movementStateRef}
|
||||
movementEnabled={showMovementControls}
|
||||
/>
|
||||
{prefetchedSplat?.url === resolvedSplatUrl ? (
|
||||
<WorldSplatScene
|
||||
key={resolvedSplatUrl}
|
||||
firstPerson={firstPerson}
|
||||
hotspots={hotspots}
|
||||
interactionMode={interactionMode}
|
||||
interactionReady={loadState === "ready"}
|
||||
minimalFrame={minimalFrame}
|
||||
movementEnabled={showMovementControls && loadState === "ready"}
|
||||
movementStateRef={movementStateRef}
|
||||
onHotspotSelect={onHotspotSelect}
|
||||
onLoadProgress={handleSplatProgress}
|
||||
onLoadStateChange={handleSplatLoadStateChange}
|
||||
onPick={onPick}
|
||||
onRevealComplete={() => setOverlayPhase("hidden")}
|
||||
revealPhase={overlayPhase}
|
||||
resetViewToken={resetViewToken}
|
||||
semanticsMetadata={semanticsMetadata}
|
||||
showInfoOverlay={showInfoOverlay}
|
||||
splatBytes={prefetchedSplat.bytes}
|
||||
splatFileName={prefetchedSplat.fileName}
|
||||
splatUrl={resolvedSplatUrl}
|
||||
/>
|
||||
) : null}
|
||||
</Canvas>
|
||||
{showMovementControls && loadState === "ready" ? (
|
||||
<WorldMovementPad movementStateRef={movementStateRef} />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user