diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index 5b5bd5084..41339f96e 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -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> = { 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; }) { const isRevealing = phase === "revealing"; + const isLoading = phase === "loading"; return ( <> - {phase === "loading" ? ( + {previewImageUrl ? ( +
+ 世界预览 +
+
+ ) : phase === "loading" ? (
{previewImageUrl ? ( 世界预览 ) : null} @@ -703,11 +743,17 @@ function WorldSplatLoadingOverlay({
- {isRevealing ? "世界数据正在聚合" : "正在载入世界数据"} + {isRevealing ? "高清世界正在接管预览" : "正在载入世界数据"}
- {isRevealing ? "当前世界正在从颗粒态恢复细节" : "空间数据准备中"} + + {isRevealing + ? "预览图已显示,世界细节正在补齐" + : previewImageUrl + ? "先展示世界预览图,空间数据后台继续加载" + : "空间数据准备中"} +
@@ -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) => { @@ -1428,12 +1496,12 @@ function WorldSplatViewer({ @@ -1441,6 +1509,7 @@ function WorldSplatViewer({ firstPerson={firstPerson} hotspots={hotspots} interactionMode={interactionMode} + minimalFrame={minimalFrame} onHotspotSelect={onHotspotSelect} onLoadStateChange={setLoadState} onPick={onPick} diff --git a/lib/worldlabs/splat-utils.ts b/lib/worldlabs/splat-utils.ts index 4a1e14338..de522b6ae 100644 --- a/lib/worldlabs/splat-utils.ts +++ b/lib/worldlabs/splat-utils.ts @@ -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]>, diff --git a/package.json b/package.json index 395d04358..1c6f9610e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae35ac2c2..907377271 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': {}