perf: upgrade world viewer to spark 2
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-14 14:57:56 +08:00
parent 147f9394c8
commit 1c935bf7ae
4 changed files with 96 additions and 21 deletions

View File

@ -170,6 +170,13 @@ const SPLAT_REVEAL_DURATION_MS = 900;
const SPLAT_PARTICLE_SAMPLE_LIMIT = 720; const SPLAT_PARTICLE_SAMPLE_LIMIT = 720;
const SPLAT_WORLD_BOUNDS_SAMPLE_LIMIT = 1200; const SPLAT_WORLD_BOUNDS_SAMPLE_LIMIT = 1200;
const SPLAT_FAST_LOAD_SKIP_REVEAL_MS = 1200; const SPLAT_FAST_LOAD_SKIP_REVEAL_MS = 1200;
const SPARK_INLINE_LOD_BUILD_THRESHOLD = 180_000;
const SPARK_INLINE_LOD_SPLAT_SCALE = 0.78;
const SPARK_INLINE_LOD_RENDER_SCALE = 1.8;
const SPARK_MINIMAL_FRAME_LOD_SPLAT_SCALE = 0.6;
const SPARK_MINIMAL_FRAME_LOD_RENDER_SCALE = 2.4;
const SPARK_MIN_SORT_INTERVAL_MS = 24;
const SPARK_MINIMAL_FRAME_SORT_INTERVAL_MS = 40;
const KEY_TO_MOVEMENT: Partial<Record<string, MovementCommand>> = { const KEY_TO_MOVEMENT: Partial<Record<string, MovementCommand>> = {
KeyW: "forward", KeyW: "forward",
@ -230,6 +237,7 @@ function buildSplatParticleCloudData(
sampleLimit = SPLAT_PARTICLE_SAMPLE_LIMIT sampleLimit = SPLAT_PARTICLE_SAMPLE_LIMIT
): SplatParticleCloudData | null { ): SplatParticleCloudData | null {
const packedSplats = splatMesh.packedSplats; const packedSplats = splatMesh.packedSplats;
if (!packedSplats) return null;
const totalSplats = Math.max(0, packedSplats?.numSplats || 0); const totalSplats = Math.max(0, packedSplats?.numSplats || 0);
if (!totalSplats) return null; if (!totalSplats) return null;
@ -303,6 +311,9 @@ function getSplatWorldBounds(splatMesh: SparkSplatMesh) {
function getRobustSplatWorldBounds(splatMesh: SparkSplatMesh) { function getRobustSplatWorldBounds(splatMesh: SparkSplatMesh) {
const packedSplats = splatMesh.packedSplats; const packedSplats = splatMesh.packedSplats;
if (!packedSplats) {
return getSplatWorldBounds(splatMesh);
}
const totalSplats = Math.max(0, packedSplats?.numSplats || 0); const totalSplats = Math.max(0, packedSplats?.numSplats || 0);
if (totalSplats < 32) { if (totalSplats < 32) {
return getSplatWorldBounds(splatMesh); return getSplatWorldBounds(splatMesh);
@ -679,15 +690,44 @@ function WorldSplatLoadingOverlay({
phase: Exclude<SplatLoadingPhase, "hidden">; phase: Exclude<SplatLoadingPhase, "hidden">;
}) { }) {
const isRevealing = phase === "revealing"; const isRevealing = phase === "revealing";
const isLoading = phase === "loading";
return ( return (
<> <>
{phase === "loading" ? ( {previewImageUrl ? (
<div
className={cn(
"pointer-events-none absolute inset-0 overflow-hidden transition-opacity duration-500",
isLoading ? "opacity-100" : "opacity-30"
)}
>
<img
alt="世界预览"
className={cn(
"absolute inset-0 h-full w-full object-cover transition-transform duration-700",
isLoading ? "scale-[1.01]" : "scale-100"
)}
decoding="async"
fetchPriority="high"
src={previewImageUrl}
/>
<div
className={cn(
"absolute inset-0 transition-opacity duration-500",
isLoading
? "bg-gradient-to-b from-black/10 via-black/12 to-black/30"
: "bg-gradient-to-b from-black/0 via-black/5 to-black/18"
)}
/>
</div>
) : phase === "loading" ? (
<div className="pointer-events-none absolute inset-0 overflow-hidden transition-opacity duration-300"> <div className="pointer-events-none absolute inset-0 overflow-hidden transition-opacity duration-300">
{previewImageUrl ? ( {previewImageUrl ? (
<img <img
alt="世界预览" alt="世界预览"
className="absolute inset-0 h-full w-full object-cover opacity-[0.1] blur-[10px] saturate-[0.72]" className="absolute inset-0 h-full w-full object-cover opacity-[0.1] blur-[10px] saturate-[0.72]"
decoding="async"
fetchPriority="high"
src={previewImageUrl} src={previewImageUrl}
/> />
) : null} ) : null}
@ -703,11 +743,17 @@ function WorldSplatLoadingOverlay({
<div className="rounded-2xl border border-white/10 bg-black/45 px-3 py-2 shadow-[0_20px_60px_rgba(0,0,0,0.38)] backdrop-blur-xl"> <div className="rounded-2xl border border-white/10 bg-black/45 px-3 py-2 shadow-[0_20px_60px_rgba(0,0,0,0.38)] backdrop-blur-xl">
<div className="flex items-center gap-2 text-[11px] text-zinc-100"> <div className="flex items-center gap-2 text-[11px] text-zinc-100">
<Sparkles className="h-3.5 w-3.5 text-cyan-200" /> <Sparkles className="h-3.5 w-3.5 text-cyan-200" />
<span>{isRevealing ? "世界数据正在聚合" : "正在载入世界数据"}</span> <span>{isRevealing ? "高清世界正在接管预览" : "正在载入世界数据"}</span>
</div> </div>
<div className="mt-1 flex items-center gap-2 text-[10px] text-zinc-400"> <div className="mt-1 flex items-center gap-2 text-[10px] text-zinc-400">
<Loader2 className="h-3 w-3 animate-spin" /> <Loader2 className="h-3 w-3 animate-spin" />
<span>{isRevealing ? "当前世界正在从颗粒态恢复细节" : "空间数据准备中"}</span> <span>
{isRevealing
? "预览图已显示,世界细节正在补齐"
: previewImageUrl
? "先展示世界预览图,空间数据后台继续加载"
: "空间数据准备中"}
</span>
</div> </div>
</div> </div>
</div> </div>
@ -951,6 +997,7 @@ function WorldSplatScene({
splatUrl, splatUrl,
revealPhase = "loading", revealPhase = "loading",
firstPerson = false, firstPerson = false,
minimalFrame = false,
hotspots = [], hotspots = [],
interactionMode = "navigate", interactionMode = "navigate",
onPick, onPick,
@ -964,6 +1011,7 @@ function WorldSplatScene({
splatUrl: string; splatUrl: string;
revealPhase?: SplatLoadingPhase; revealPhase?: SplatLoadingPhase;
firstPerson?: boolean; firstPerson?: boolean;
minimalFrame?: boolean;
hotspots?: World3DHotspot[]; hotspots?: World3DHotspot[];
interactionMode?: "navigate" | "hotspot"; interactionMode?: "navigate" | "hotspot";
onPick?: (pick: World3DPickResult) => void; onPick?: (pick: World3DPickResult) => void;
@ -996,17 +1044,36 @@ function WorldSplatScene({
renderer, renderer,
blurAmount: 0.18, blurAmount: 0.18,
maxStdDev: Math.sqrt(7), maxStdDev: Math.sqrt(7),
originDistance: 0.85, sortRadial: true,
view: { minSortIntervalMs: minimalFrame
sortRadial: true, ? SPARK_MINIMAL_FRAME_SORT_INTERVAL_MS
sortDistance: 0.04, : SPARK_MIN_SORT_INTERVAL_MS,
}, enableLod: true,
enableLodFetching: false,
lodSplatScale: minimalFrame
? SPARK_MINIMAL_FRAME_LOD_SPLAT_SCALE
: SPARK_INLINE_LOD_SPLAT_SCALE,
lodRenderScale: minimalFrame
? SPARK_MINIMAL_FRAME_LOD_RENDER_SCALE
: SPARK_INLINE_LOD_RENDER_SCALE,
numLodFetchers: 2,
}), }),
[renderer] [minimalFrame, renderer]
); );
const splatMesh = useMemo( const splatMesh = useMemo(
() => new SparkSplatMesh({ fileType: SplatFileType.SPZ, url: splatUrl }), () =>
[splatUrl] new SparkSplatMesh({
fileType: SplatFileType.SPZ,
url: splatUrl,
lod: true,
nonLod: true,
lodAbove: SPARK_INLINE_LOD_BUILD_THRESHOLD,
enableLod: true,
lodScale: minimalFrame
? SPARK_MINIMAL_FRAME_LOD_SPLAT_SCALE
: SPARK_INLINE_LOD_SPLAT_SCALE,
}),
[minimalFrame, splatUrl]
); );
const groundPlaneOffset = const groundPlaneOffset =
typeof semanticsMetadata?.ground_plane_offset === "number" typeof semanticsMetadata?.ground_plane_offset === "number"
@ -1023,6 +1090,7 @@ function WorldSplatScene({
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE); applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE);
return () => { return () => {
sparkRenderer.removeFromParent(); sparkRenderer.removeFromParent();
sparkRenderer.dispose();
}; };
}, [sparkRenderer, splatMesh]); }, [sparkRenderer, splatMesh]);
@ -1182,7 +1250,7 @@ function WorldSplatScene({
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE); applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE);
} }
sparkRenderer.needsUpdate = true; sparkRenderer.dirty = true;
}); });
const handlePointerDown = (event: ThreeEvent<PointerEvent>) => { const handlePointerDown = (event: ThreeEvent<PointerEvent>) => {
@ -1428,12 +1496,12 @@ function WorldSplatViewer({
<Canvas <Canvas
camera={{ position: [0, 1.6, 4], fov: 55 }} camera={{ position: [0, 1.6, 4], fov: 55 }}
className="h-full w-full bg-[radial-gradient(circle_at_top,_rgba(15,23,42,0.95),_rgba(1,1,1,1)_68%)]" className="h-full w-full bg-[radial-gradient(circle_at_top,_rgba(15,23,42,0.95),_rgba(1,1,1,1)_68%)]"
dpr={[1, 1.25]} dpr={minimalFrame ? [0.85, 1] : [1, 1]}
frameloop="always" frameloop="always"
gl={{ gl={{
antialias: false, antialias: false,
powerPreference: "high-performance", powerPreference: "high-performance",
preserveDrawingBuffer: true, preserveDrawingBuffer: Boolean(onCaptureReady),
}} }}
> >
<WorldSplatCaptureBridge onCaptureReady={onCaptureReady} /> <WorldSplatCaptureBridge onCaptureReady={onCaptureReady} />
@ -1441,6 +1509,7 @@ function WorldSplatViewer({
firstPerson={firstPerson} firstPerson={firstPerson}
hotspots={hotspots} hotspots={hotspots}
interactionMode={interactionMode} interactionMode={interactionMode}
minimalFrame={minimalFrame}
onHotspotSelect={onHotspotSelect} onHotspotSelect={onHotspotSelect}
onLoadStateChange={setLoadState} onLoadStateChange={setLoadState}
onPick={onPick} onPick={onPick}

View File

@ -59,7 +59,10 @@ export function pickBestWorldLabsSplatUrl(
return pickBestWorldLabsSplatEntry(spzUrls)?.[1] || null; return pickBestWorldLabsSplatEntry(spzUrls)?.[1] || null;
} }
const INLINE_WORLDLABS_SPLAT_TARGET_SCORE = 1_500_000; // World Labs exports multiple SPZ tiers (for example 100k / 500k / full).
// For embedded previews we bias hard toward the lightweight tier so users see
// the world quickly, then can switch to imagery / mesh for detail if needed.
const INLINE_WORLDLABS_SPLAT_TARGET_SCORE = 100_000;
function pickClosestResolutionEntry( function pickClosestResolutionEntry(
entries: Array<[string, string]>, entries: Array<[string, string]>,

View File

@ -60,7 +60,7 @@
"@radix-ui/react-visually-hidden": "^1.1.0", "@radix-ui/react-visually-hidden": "^1.1.0",
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0", "@react-three/fiber": "^9.5.0",
"@sparkjsdev/spark": "^0.1.10", "@sparkjsdev/spark": "^2.0.0",
"@tanstack/react-virtual": "^3.13.24", "@tanstack/react-virtual": "^3.13.24",
"@types/form-data": "^2.5.2", "@types/form-data": "^2.5.2",
"@vercel/analytics": "^1.3.1", "@vercel/analytics": "^1.3.1",

13
pnpm-lock.yaml generated
View File

@ -129,8 +129,8 @@ importers:
specifier: ^9.5.0 specifier: ^9.5.0
version: 9.5.0(@types/react@18.3.18)(immer@11.1.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0) version: 9.5.0(@types/react@18.3.18)(immer@11.1.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)
'@sparkjsdev/spark': '@sparkjsdev/spark':
specifier: ^0.1.10 specifier: ^2.0.0
version: 0.1.10 version: 2.0.0(three@0.182.0)
'@tanstack/react-virtual': '@tanstack/react-virtual':
specifier: ^3.13.24 specifier: ^3.13.24
version: 3.13.24(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021) version: 3.13.24(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
@ -2977,8 +2977,10 @@ packages:
resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@sparkjsdev/spark@0.1.10': '@sparkjsdev/spark@2.0.0':
resolution: {integrity: sha512-CiijdZQuj7KPDUqIZPiEqyUkJCYo1JqR05vq/V+ElxMwqR7L70ZuZDyIKcasjZHSiPB8pGRMH8HZGqUKO9aRPQ==} resolution: {integrity: sha512-W1p7NM4xXcUAVVcVus2mIFwcAC3VckyE0l4wXJuSmhcOgb6hHpqhQ/Ks9MK9ts7j23K+UwbCBwe+aTc/zgGygw==}
peerDependencies:
three: ^0.180.0
'@standard-schema/spec@1.0.0': '@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
@ -9424,9 +9426,10 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@sparkjsdev/spark@0.1.10': '@sparkjsdev/spark@2.0.0(three@0.182.0)':
dependencies: dependencies:
fflate: 0.8.2 fflate: 0.8.2
three: 0.182.0
'@standard-schema/spec@1.0.0': {} '@standard-schema/spec@1.0.0': {}