diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index e54f6209b..2f10046b3 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -42,7 +42,13 @@ type MovementCommand = | "down"; type MovementState = Record; -type SplatLoadingPhase = "loading" | "fading" | "hidden"; +type SplatLoadingPhase = "loading" | "revealing" | "hidden"; +type SplatRevealCloudData = { + count: number; + colors: Float32Array; + startPositions: Float32Array; + targetPositions: Float32Array; +}; type World3DViewerProps = { mode: World3DViewMode; @@ -168,81 +174,143 @@ function WorldEmptyState({ ); } -const SPLAT_LOADING_PARTICLES = [ - { x: 8, y: 18, size: 5, dx: 18, dy: -22, delay: 0.1, duration: 4.6 }, - { x: 18, y: 64, size: 7, dx: 26, dy: -18, delay: 0.4, duration: 5.2 }, - { x: 26, y: 34, size: 4, dx: -12, dy: -28, delay: 0.7, duration: 4.8 }, - { x: 34, y: 78, size: 6, dx: 14, dy: -24, delay: 1.1, duration: 5.6 }, - { x: 44, y: 16, size: 8, dx: -18, dy: 18, delay: 0.3, duration: 5.4 }, - { x: 52, y: 56, size: 5, dx: 20, dy: -26, delay: 0.9, duration: 4.9 }, - { x: 61, y: 28, size: 6, dx: -22, dy: -16, delay: 1.4, duration: 5.8 }, - { x: 68, y: 72, size: 4, dx: 16, dy: -20, delay: 0.2, duration: 4.7 }, - { x: 76, y: 42, size: 8, dx: -14, dy: 22, delay: 1.2, duration: 5.1 }, - { x: 84, y: 62, size: 5, dx: 22, dy: -14, delay: 0.6, duration: 5.7 }, - { x: 91, y: 24, size: 6, dx: -16, dy: 18, delay: 1.5, duration: 4.5 }, - { x: 12, y: 44, size: 4, dx: 12, dy: 14, delay: 1.8, duration: 5.3 }, -] as const; - -const SPLAT_ASSEMBLY_POINT_COUNT = 220; -const SPLAT_ASSEMBLY_COLORS = ["#67e8f9", "#93c5fd", "#f8fafc", "#22d3ee"] as const; +const REAL_SPLAT_REVEAL_SAMPLE_LIMIT = 3600; function seededUnit(index: number, salt: number) { const value = Math.sin(index * 12.9898 + salt * 78.233) * 43758.5453; return value - Math.floor(value); } -function SplatAssemblyCloud({ +function buildSplatRevealCloudData( + splatMesh: SparkSplatMesh, + sampleLimit = REAL_SPLAT_REVEAL_SAMPLE_LIMIT +): SplatRevealCloudData | null { + const totalSplats = Math.max(0, splatMesh.packedSplats?.numSplats || 0); + if (!totalSplats) return null; + + const sampleStep = Math.max(1, Math.floor(totalSplats / sampleLimit)); + const sampled: Array<{ + center: THREE.Vector3; + color: THREE.Color; + opacity: number; + }> = []; + const bounds = new THREE.Box3(); + + splatMesh.forEachSplat((index, center, _scales, _quaternion, opacity, color) => { + if (sampled.length >= sampleLimit) return; + if (opacity < 0.04) return; + if (index % sampleStep !== 0) return; + + const target = center.clone(); + bounds.expandByPoint(target); + sampled.push({ + center: target, + color: color.clone(), + opacity, + }); + }); + + if (!sampled.length) return null; + + const sceneCenter = bounds.getCenter(new THREE.Vector3()); + const sceneBoundsSize = bounds.getSize(new THREE.Vector3()); + const sceneSize = sceneBoundsSize.length(); + const sceneRadius = Math.max(sceneSize * 0.42, 1.6); + const white = new THREE.Color("#fff4de"); + const colors = new Float32Array(sampled.length * 3); + const startPositions = new Float32Array(sampled.length * 3); + const targetPositions = new Float32Array(sampled.length * 3); + + sampled.forEach((sample, index) => { + const stride = index * 3; + const direction = sample.center.clone().sub(sceneCenter); + if (direction.lengthSq() < 1e-4) { + direction.set( + seededUnit(index, 10) - 0.5, + seededUnit(index, 11) - 0.35, + seededUnit(index, 12) - 0.5 + ); + } + + direction.normalize(); + const swirlAxis = new THREE.Vector3( + seededUnit(index, 13) - 0.5, + seededUnit(index, 14) - 0.5, + seededUnit(index, 15) - 0.5 + ); + if (swirlAxis.lengthSq() < 1e-4) { + swirlAxis.set(0.3, 1, 0.2); + } + swirlAxis.normalize(); + + const tangent = new THREE.Vector3().crossVectors(direction, swirlAxis); + if (tangent.lengthSq() < 1e-4) { + tangent.set(1, 0, 0); + } else { + tangent.normalize(); + } + + const start = sample.center + .clone() + .sub(sceneCenter) + .multiplyScalar(1.08 + seededUnit(index, 16) * 0.38) + .add(sceneCenter) + .addScaledVector( + tangent, + sceneRadius * (0.018 + seededUnit(index, 17) * 0.085) + ) + .addScaledVector( + direction, + sceneRadius * (0.035 + seededUnit(index, 18) * 0.12) + ) + .add( + new THREE.Vector3( + 0, + sceneBoundsSize.y * (seededUnit(index, 19) - 0.5) * 0.16, + 0 + ) + ); + + const displayColor = sample.color + .clone() + .lerp(white, 0.42) + .multiplyScalar(0.88 + Math.min(sample.opacity, 1) * 0.24); + + targetPositions[stride] = sample.center.x; + targetPositions[stride + 1] = sample.center.y; + targetPositions[stride + 2] = sample.center.z; + startPositions[stride] = start.x; + startPositions[stride + 1] = start.y; + startPositions[stride + 2] = start.z; + colors[stride] = displayColor.r; + colors[stride + 1] = displayColor.g; + colors[stride + 2] = displayColor.b; + }); + + return { + count: sampled.length, + colors, + startPositions, + targetPositions, + }; +} + +function SplatRevealCloud({ + data, phase, }: { + data: SplatRevealCloudData; phase: Exclude; }) { const pointsRef = useRef(null); const materialRef = useRef(null); + const positionBuffer = useMemo( + () => data.startPositions.slice(), + [data.startPositions] + ); const geometryRef = useRef(null); const phaseRef = useRef(phase); - const phaseStartedAtRef = useRef(0); - - const seeds = useMemo( - () => - Array.from({ length: SPLAT_ASSEMBLY_POINT_COUNT }, (_, index) => { - const spread = seededUnit(index, 1); - const tilt = seededUnit(index, 2); - const orbit = seededUnit(index, 3); - const colorIndex = Math.floor( - seededUnit(index, 4) * SPLAT_ASSEMBLY_COLORS.length - ); - - return { - target: new THREE.Vector3( - (spread - 0.5) * 4.8, - 0.45 + tilt * 2.6, - (orbit - 0.5) * 4.4 - ), - orbitRadius: 0.4 + seededUnit(index, 5) * 2.2, - orbitSpeed: 0.2 + seededUnit(index, 6) * 0.8, - angle: seededUnit(index, 7) * Math.PI * 2, - verticalAmplitude: 0.08 + seededUnit(index, 8) * 0.32, - drift: 0.65 + seededUnit(index, 9) * 0.75, - color: new THREE.Color( - SPLAT_ASSEMBLY_COLORS[colorIndex] || SPLAT_ASSEMBLY_COLORS[0] - ), - }; - }), - [] - ); - const positions = useMemo( - () => new Float32Array(SPLAT_ASSEMBLY_POINT_COUNT * 3), - [] - ); - const colors = useMemo(() => { - const result = new Float32Array(SPLAT_ASSEMBLY_POINT_COUNT * 3); - seeds.forEach((seed, index) => { - result[index * 3] = seed.color.r; - result[index * 3 + 1] = seed.color.g; - result[index * 3 + 2] = seed.color.b; - }); - return result; - }, [seeds]); + const phaseStartedAtRef = useRef(performance.now()); useEffect(() => { if (phaseRef.current !== phase) { @@ -263,69 +331,70 @@ function SplatAssemblyCloud({ if (!geometry || !material) return; const elapsed = state.clock.getElapsedTime(); - const fadeElapsed = - phaseRef.current === "fading" - ? Math.min((performance.now() - phaseStartedAtRef.current) / 850, 1) - : 0; - const baseOpacity = - phaseRef.current === "loading" - ? 0.78 - : THREE.MathUtils.lerp(0.78, 0, fadeElapsed); - material.opacity = baseOpacity; - material.size = THREE.MathUtils.lerp(0.09, 0.02, fadeElapsed); + const rawProgress = Math.min( + (performance.now() - phaseStartedAtRef.current) / 1450, + 1 + ); + const easedProgress = 1 - Math.pow(1 - rawProgress, 3); + const positionAttribute = geometry.getAttribute("position"); + const positions = positionAttribute.array as Float32Array; - seeds.forEach((seed, index) => { + for (let index = 0; index < data.count; index += 1) { const stride = index * 3; - const orbitAngle = elapsed * seed.orbitSpeed + seed.angle; - const phaseMix = - phaseRef.current === "loading" - ? 0.22 + 0.2 * Math.sin(elapsed * 0.65 + seed.angle) - : THREE.MathUtils.lerp(0.72, 1.02, fadeElapsed); - const orbitRadius = - seed.orbitRadius * - (phaseRef.current === "loading" - ? 0.95 + 0.08 * Math.sin(elapsed * 1.3 + seed.angle) - : Math.max(0, 0.45 * (1 - fadeElapsed))); - const verticalWave = - Math.sin(elapsed * seed.drift + seed.angle * 1.4) * - seed.verticalAmplitude * - (phaseRef.current === "loading" ? 1 : 1 - fadeElapsed); - + const swirl = + Math.sin(elapsed * 2.4 + index * 0.17) * (1 - easedProgress) * 0.018; positions[stride] = - seed.target.x * phaseMix + Math.cos(orbitAngle) * orbitRadius; + THREE.MathUtils.lerp( + data.startPositions[stride], + data.targetPositions[stride], + easedProgress + ) + + swirl; positions[stride + 1] = - seed.target.y * phaseMix + - verticalWave + - Math.sin(orbitAngle * 0.7) * orbitRadius * 0.18; + THREE.MathUtils.lerp( + data.startPositions[stride + 1], + data.targetPositions[stride + 1], + easedProgress + ) + + swirl * 0.45; positions[stride + 2] = - seed.target.z * phaseMix + Math.sin(orbitAngle) * orbitRadius; - }); + THREE.MathUtils.lerp( + data.startPositions[stride + 2], + data.targetPositions[stride + 2], + easedProgress + ) - + swirl * 0.65; + } - geometry.attributes.position.needsUpdate = true; + positionAttribute.needsUpdate = true; + material.opacity = THREE.MathUtils.lerp(0.9, 0, easedProgress); + material.size = THREE.MathUtils.lerp(0.04, 0.009, easedProgress); }); return ( - - - + + + + + + - - - - + + ); } @@ -336,99 +405,48 @@ function WorldSplatLoadingOverlay({ previewImageUrl?: string | null; phase: Exclude; }) { - const isFading = phase === "fading"; + const isRevealing = phase === "revealing"; return (
{previewImageUrl ? ( 世界预览 ) : null} -
-
+
+
+
+
+
-
- {SPLAT_LOADING_PARTICLES.map((particle, index) => ( - - ))} -
- -
- -
-
-
- +
+
+
+ + {isRevealing ? "真实点云正在成形" : "正在载入真实 Gaussian Splats"}
-
-

- {isFading ? "正在显露 3D 世界细节" : "正在构建 3D 世界体积"} -

-

- {isFading - ? "Gaussian Splat 已加载完成,正在把真实细节从过渡层中释放出来。" - : "SPZ 会先以粗糙体积快速占位,再逐步补齐细节。先用粒子过渡能让加载观感更顺。"} -

-
-
- - {isFading ? "即将完成" : "初始化 Splat 渲染器"} +
+ + {isRevealing ? "点云聚合中" : "SPZ 数据准备中"}
@@ -653,7 +671,7 @@ function HotspotBillboard({ function WorldSplatScene({ splatUrl, - assemblyPhase = "loading", + revealPhase = "loading", firstPerson = false, hotspots = [], interactionMode = "navigate", @@ -663,7 +681,7 @@ function WorldSplatScene({ onLoadStateChange, }: { splatUrl: string; - assemblyPhase?: SplatLoadingPhase; + revealPhase?: SplatLoadingPhase; firstPerson?: boolean; hotspots?: World3DHotspot[]; interactionMode?: "navigate" | "hotspot"; @@ -676,15 +694,19 @@ function WorldSplatScene({ const orbitControlsRef = useRef(null); const [virtualMovement, setVirtualMovement] = useState(createMovementState); + const [revealCloudData, setRevealCloudData] = + useState(null); const semanticFacts = useMemo( () => getSemanticFacts(semanticsMetadata), [semanticsMetadata] ); + const revealPhaseRef = useRef(revealPhase); + const revealStartedAtRef = useRef(performance.now()); const sparkRenderer = useMemo( () => new SparkRenderer({ renderer, - blurAmount: 0.2, + blurAmount: 0.18, maxStdDev: Math.sqrt(7), }), [renderer] @@ -694,18 +716,36 @@ function WorldSplatScene({ useEffect(() => { sparkRenderer.frustumCulled = false; sparkRenderer.renderOrder = 10; + sparkRenderer.minAlpha = 0.02; + sparkRenderer.preBlurAmount = 0.04; return () => { sparkRenderer.removeFromParent(); }; }, [sparkRenderer]); + useEffect(() => { + if (revealPhaseRef.current !== revealPhase) { + revealPhaseRef.current = revealPhase; + revealStartedAtRef.current = performance.now(); + } + }, [revealPhase]); + useEffect(() => { splatMesh.frustumCulled = false; splatMesh.rotation.x = Math.PI; + splatMesh.opacity = 0; + sparkRenderer.blurAmount = 0.95; + sparkRenderer.preBlurAmount = 0.28; + sparkRenderer.minAlpha = 0.08; + sparkRenderer.maxStdDev = Math.sqrt(10); + setRevealCloudData(null); + let cancelled = false; splatMesh.initialized .then(() => { - if (!cancelled) onLoadStateChange?.("ready"); + if (cancelled) return; + setRevealCloudData(buildSplatRevealCloudData(splatMesh)); + onLoadStateChange?.("ready"); }) .catch(() => { if (!cancelled) onLoadStateChange?.("error"); @@ -714,9 +754,40 @@ function WorldSplatScene({ cancelled = true; splatMesh.dispose(); }; - }, [onLoadStateChange, splatMesh]); + }, [onLoadStateChange, sparkRenderer, splatMesh]); useFrame(() => { + const phase = revealPhaseRef.current; + const revealElapsed = + phase === "revealing" + ? Math.min((performance.now() - revealStartedAtRef.current) / 1250, 1) + : 0; + const easedReveal = 1 - Math.pow(1 - revealElapsed, 3); + + if (phase === "loading") { + splatMesh.opacity = 0; + sparkRenderer.blurAmount = 0.95; + sparkRenderer.preBlurAmount = 0.28; + sparkRenderer.minAlpha = 0.08; + sparkRenderer.maxStdDev = Math.sqrt(10); + } else if (phase === "revealing") { + splatMesh.opacity = THREE.MathUtils.lerp(0.02, 1, easedReveal); + sparkRenderer.blurAmount = THREE.MathUtils.lerp(0.95, 0.16, easedReveal); + sparkRenderer.preBlurAmount = THREE.MathUtils.lerp(0.28, 0.04, easedReveal); + sparkRenderer.minAlpha = THREE.MathUtils.lerp(0.08, 0.02, easedReveal); + sparkRenderer.maxStdDev = THREE.MathUtils.lerp( + Math.sqrt(10), + Math.sqrt(7), + easedReveal + ); + } else { + splatMesh.opacity = 1; + sparkRenderer.blurAmount = 0.16; + sparkRenderer.preBlurAmount = 0.04; + sparkRenderer.minAlpha = 0.02; + sparkRenderer.maxStdDev = Math.sqrt(7); + } + sparkRenderer.needsUpdate = true; }); @@ -755,8 +826,8 @@ function WorldSplatScene({ position={[0, groundPlaneOffset, 0]} /> ) : null} - {assemblyPhase !== "hidden" ? ( - + {revealPhase !== "hidden" && revealCloudData ? ( + ) : null} @@ -870,10 +941,10 @@ function WorldSplatViewer({ useEffect(() => { if (loadState === "ready") { - setOverlayPhase("fading"); + setOverlayPhase("revealing"); const timer = window.setTimeout(() => { setOverlayPhase("hidden"); - }, 720); + }, 1250); return () => window.clearTimeout(timer); } @@ -917,13 +988,13 @@ function WorldSplatViewer({ gl={{ antialias: false }} >