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_WORLD_BOUNDS_SAMPLE_LIMIT = 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>> = {
KeyW: "forward",
@ -230,6 +237,7 @@ function buildSplatParticleCloudData(
sampleLimit = SPLAT_PARTICLE_SAMPLE_LIMIT
): SplatParticleCloudData | null {
const packedSplats = splatMesh.packedSplats;
if (!packedSplats) return null;
const totalSplats = Math.max(0, packedSplats?.numSplats || 0);
if (!totalSplats) return null;
@ -303,6 +311,9 @@ function getSplatWorldBounds(splatMesh: SparkSplatMesh) {
function getRobustSplatWorldBounds(splatMesh: SparkSplatMesh) {
const packedSplats = splatMesh.packedSplats;
if (!packedSplats) {
return getSplatWorldBounds(splatMesh);
}
const totalSplats = Math.max(0, packedSplats?.numSplats || 0);
if (totalSplats < 32) {
return getSplatWorldBounds(splatMesh);
@ -679,15 +690,44 @@ function WorldSplatLoadingOverlay({
phase: Exclude<SplatLoadingPhase, "hidden">;
}) {
const isRevealing = phase === "revealing";
const isLoading = phase === "loading";
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">
{previewImageUrl ? (
<img
alt="世界预览"
className="absolute inset-0 h-full w-full object-cover opacity-[0.1] blur-[10px] saturate-[0.72]"
decoding="async"
fetchPriority="high"
src={previewImageUrl}
/>
) : 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="flex items-center gap-2 text-[11px] text-zinc-100">
<Sparkles className="h-3.5 w-3.5 text-cyan-200" />
<span>{isRevealing ? "世界数据正在聚合" : "正在载入世界数据"}</span>
<span>{isRevealing ? "高清世界正在接管预览" : "正在载入世界数据"}</span>
</div>
<div className="mt-1 flex items-center gap-2 text-[10px] text-zinc-400">
<Loader2 className="h-3 w-3 animate-spin" />
<span>{isRevealing ? "当前世界正在从颗粒态恢复细节" : "空间数据准备中"}</span>
<span>
{isRevealing
? "预览图已显示,世界细节正在补齐"
: previewImageUrl
? "先展示世界预览图,空间数据后台继续加载"
: "空间数据准备中"}
</span>
</div>
</div>
</div>
@ -951,6 +997,7 @@ function WorldSplatScene({
splatUrl,
revealPhase = "loading",
firstPerson = false,
minimalFrame = false,
hotspots = [],
interactionMode = "navigate",
onPick,
@ -964,6 +1011,7 @@ function WorldSplatScene({
splatUrl: string;
revealPhase?: SplatLoadingPhase;
firstPerson?: boolean;
minimalFrame?: boolean;
hotspots?: World3DHotspot[];
interactionMode?: "navigate" | "hotspot";
onPick?: (pick: World3DPickResult) => void;
@ -996,17 +1044,36 @@ function WorldSplatScene({
renderer,
blurAmount: 0.18,
maxStdDev: Math.sqrt(7),
originDistance: 0.85,
view: {
sortRadial: true,
sortDistance: 0.04,
},
sortRadial: true,
minSortIntervalMs: minimalFrame
? SPARK_MINIMAL_FRAME_SORT_INTERVAL_MS
: 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(
() => 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 =
typeof semanticsMetadata?.ground_plane_offset === "number"
@ -1023,6 +1090,7 @@ function WorldSplatScene({
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE);
return () => {
sparkRenderer.removeFromParent();
sparkRenderer.dispose();
};
}, [sparkRenderer, splatMesh]);
@ -1182,7 +1250,7 @@ function WorldSplatScene({
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE);
}
sparkRenderer.needsUpdate = true;
sparkRenderer.dirty = true;
});
const handlePointerDown = (event: ThreeEvent<PointerEvent>) => {
@ -1428,12 +1496,12 @@ function WorldSplatViewer({
<Canvas
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%)]"
dpr={[1, 1.25]}
dpr={minimalFrame ? [0.85, 1] : [1, 1]}
frameloop="always"
gl={{
antialias: false,
powerPreference: "high-performance",
preserveDrawingBuffer: true,
preserveDrawingBuffer: Boolean(onCaptureReady),
}}
>
<WorldSplatCaptureBridge onCaptureReady={onCaptureReady} />
@ -1441,6 +1509,7 @@ function WorldSplatViewer({
firstPerson={firstPerson}
hotspots={hotspots}
interactionMode={interactionMode}
minimalFrame={minimalFrame}
onHotspotSelect={onHotspotSelect}
onLoadStateChange={setLoadState}
onPick={onPick}

View File

@ -59,7 +59,10 @@ export function pickBestWorldLabsSplatUrl(
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(
entries: Array<[string, string]>,

View File

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

13
pnpm-lock.yaml generated
View File

@ -129,8 +129,8 @@ importers:
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)
'@sparkjsdev/spark':
specifier: ^0.1.10
version: 0.1.10
specifier: ^2.0.0
version: 2.0.0(three@0.182.0)
'@tanstack/react-virtual':
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)
@ -2977,8 +2977,10 @@ packages:
resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==}
engines: {node: '>=18.0.0'}
'@sparkjsdev/spark@0.1.10':
resolution: {integrity: sha512-CiijdZQuj7KPDUqIZPiEqyUkJCYo1JqR05vq/V+ElxMwqR7L70ZuZDyIKcasjZHSiPB8pGRMH8HZGqUKO9aRPQ==}
'@sparkjsdev/spark@2.0.0':
resolution: {integrity: sha512-W1p7NM4xXcUAVVcVus2mIFwcAC3VckyE0l4wXJuSmhcOgb6hHpqhQ/Ks9MK9ts7j23K+UwbCBwe+aTc/zgGygw==}
peerDependencies:
three: ^0.180.0
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
@ -9424,9 +9426,10 @@ snapshots:
dependencies:
tslib: 2.8.1
'@sparkjsdev/spark@0.1.10':
'@sparkjsdev/spark@2.0.0(three@0.182.0)':
dependencies:
fflate: 0.8.2
three: 0.182.0
'@standard-schema/spec@1.0.0': {}