perf: upgrade world viewer to spark 2
This commit is contained in:
parent
147f9394c8
commit
1c935bf7ae
@ -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}
|
||||
|
||||
@ -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]>,
|
||||
|
||||
@ -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
13
pnpm-lock.yaml
generated
@ -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': {}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user