fix: fallback 3d world when splat stalls
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:47:11 +08:00
parent 4cc0977c9f
commit f1ab6b6255

View File

@ -185,7 +185,7 @@ 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_FETCH_TIMEOUT_MS = 18_000;
const SPARK_SPLAT_LOAD_TIMEOUT_MS = 18_000;
const SPARK_SPLAT_LOAD_TIMEOUT_MS = 8_000;
const KEY_TO_MOVEMENT: Partial<Record<string, MovementCommand>> = {
KeyW: "forward",
@ -1718,6 +1718,9 @@ function WorldSplatCaptureBridge({
function WorldSplatViewer({
splatUrl,
splatUrls,
fallbackMeshUrl,
fallbackMeshSettings,
fallbackMeshLayerVisibility,
worldUrl,
previewImageUrl,
firstPerson,
@ -1725,6 +1728,8 @@ function WorldSplatViewer({
interactionMode,
onPick,
onHotspotSelect,
onFallbackMeshLayersChange,
onFallbackMeshStatsChange,
semanticsMetadata,
resetViewToken,
minimalFrame = false,
@ -1734,6 +1739,9 @@ function WorldSplatViewer({
}: {
splatUrl?: string | null;
splatUrls?: string[] | null;
fallbackMeshUrl?: string | null;
fallbackMeshSettings?: Model3DViewerSettings;
fallbackMeshLayerVisibility?: Record<string, boolean>;
worldUrl?: string | null;
previewImageUrl?: string | null;
minimalFrame?: boolean;
@ -1742,6 +1750,8 @@ function WorldSplatViewer({
interactionMode?: "navigate" | "hotspot";
onPick?: (pick: World3DPickResult) => void;
onHotspotSelect?: (hotspotId: string) => void;
onFallbackMeshLayersChange?: (layers: Model3DLayer[]) => void;
onFallbackMeshStatsChange?: (stats: Model3DStats) => void;
semanticsMetadata?: Record<string, unknown> | null;
resetViewToken?: number;
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
@ -1758,6 +1768,7 @@ function WorldSplatViewer({
const [prefetchedSplat, setPrefetchedSplat] = useState<PrefetchedSplatAsset | null>(
null
);
const [useMeshFallback, setUseMeshFallback] = useState(false);
const resolvedSplatUrls = useMemo(() => {
const rawUrls = [
splatUrl,
@ -1775,13 +1786,16 @@ function WorldSplatViewer({
}, [splatUrl, splatUrls]);
const resolvedSplatSignature = resolvedSplatUrls.join("|");
const resolvedSplatUrl = resolvedSplatUrls[activeSplatIndex] || "";
const resolvedFallbackMeshUrl = String(fallbackMeshUrl || "").trim() || null;
const hasMeshFallback = Boolean(resolvedFallbackMeshUrl);
const loadStartedAtRef = useRef(0);
const movementStateRef = useRef<MovementState>(createMovementState());
useEffect(() => {
setActiveSplatIndex(0);
setFailedSplatLoads([]);
}, [resolvedSplatSignature]);
setUseMeshFallback(false);
}, [resolvedFallbackMeshUrl, resolvedSplatSignature]);
useEffect(() => {
if (!resolvedSplatUrl) return;
@ -1834,6 +1848,9 @@ function WorldSplatViewer({
const failedUrl = resolvedSplatUrl;
const nextIndex = activeSplatIndex + 1;
const nextUrl = resolvedSplatUrls[nextIndex] || "";
const shouldUseMeshFallback =
hasMeshFallback &&
(message.includes("SPZ 加载超时") || !nextUrl);
if (failedUrl) {
setFailedSplatLoads((items) => [
@ -1842,6 +1859,20 @@ function WorldSplatViewer({
]);
}
if (shouldUseMeshFallback) {
console.warn("[WorldSplatViewer] Switching to mesh fallback", {
error: message,
failedUrl,
attemptedUrls: resolvedSplatUrls.slice(0, nextIndex),
fallbackMeshUrl: resolvedFallbackMeshUrl,
});
setPrefetchedSplat(null);
setLoadProgress(null);
setOverlayPhase("hidden");
setUseMeshFallback(true);
return;
}
if (nextUrl) {
console.warn("[WorldSplatViewer] Falling back to next SPZ asset", {
error: message,
@ -1864,11 +1895,17 @@ function WorldSplatViewer({
setPrefetchedSplat(null);
setLoadState("error");
},
[activeSplatIndex, resolvedSplatUrl, resolvedSplatUrls]
[
activeSplatIndex,
hasMeshFallback,
resolvedFallbackMeshUrl,
resolvedSplatUrl,
resolvedSplatUrls,
]
);
useEffect(() => {
if (!resolvedSplatUrl) return;
if (!resolvedSplatUrl || useMeshFallback) return;
const abortController = new AbortController();
let cancelled = false;
@ -1905,7 +1942,25 @@ function WorldSplatViewer({
globalThis.clearTimeout(timeoutId);
abortController.abort();
};
}, [handleSplatLoadStateChange, resolvedSplatUrl]);
}, [handleSplatLoadStateChange, resolvedSplatUrl, useMeshFallback]);
if ((useMeshFallback || !resolvedSplatUrl) && resolvedFallbackMeshUrl) {
return (
<Model3DViewer
emptyStateTitle="准备加载 3D Mesh"
layerVisibility={fallbackMeshLayerVisibility}
modelRotation={[Math.PI, 0, 0]}
modelUrl={resolvedFallbackMeshUrl}
onCaptureReady={onCaptureReady}
onLayersChange={onFallbackMeshLayersChange}
onStatsChange={onFallbackMeshStatsChange}
previewImageUrl={previewImageUrl}
resetViewToken={resetViewToken}
settings={fallbackMeshSettings || DEFAULT_MESH_SETTINGS}
showMovementControls={showMovementControls}
/>
);
}
if (!resolvedSplatUrl) {
return (
@ -2108,8 +2163,13 @@ export function World3DViewer({
<div className={cn("h-full w-full", className)}>
<WorldSplatViewer
firstPerson={firstPerson}
fallbackMeshLayerVisibility={meshLayerVisibility}
fallbackMeshSettings={meshSettings}
fallbackMeshUrl={meshUrl}
hotspots={hotspots}
interactionMode={interactionMode}
onFallbackMeshLayersChange={onMeshLayersChange}
onFallbackMeshStatsChange={onMeshStatsChange}
onCaptureReady={onCaptureReady}
onHotspotSelect={onHotspotSelect}
onPick={onPick}