fix: harden 3d world splat loading
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-25 20:35:39 +08:00
parent 981a01a191
commit 4cc0977c9f
2 changed files with 258 additions and 36 deletions

View File

@ -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) {

View File

@ -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} />