From 5363a62e0ed56ddf1fd25d6687646d5034abdde7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 6 Apr 2026 01:31:58 +0800 Subject: [PATCH] feat: add granular splat reveal transition --- components/canvas/world3d/World3DViewer.tsx | 405 ++++++-------------- 1 file changed, 109 insertions(+), 296 deletions(-) diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index 2f10046b3..d158a8840 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -43,11 +43,14 @@ type MovementCommand = type MovementState = Record; type SplatLoadingPhase = "loading" | "revealing" | "hidden"; -type SplatRevealCloudData = { - count: number; - colors: Float32Array; - startPositions: Float32Array; - targetPositions: Float32Array; +type SparkRevealProfile = { + blurAmount: number; + preBlurAmount: number; + minAlpha: number; + maxStdDev: number; + focalAdjustment: number; + falloff: number; + opacity: number; }; type World3DViewerProps = { @@ -88,6 +91,70 @@ const EMPTY_MOVEMENT_STATE: MovementState = { down: false, }; +const SPLAT_LOADING_PROFILE: SparkRevealProfile = { + blurAmount: 1.15, + preBlurAmount: 0.34, + minAlpha: 0.1, + maxStdDev: Math.sqrt(10.5), + focalAdjustment: 0.82, + falloff: 0.72, + opacity: 0, +}; + +const SPLAT_PARTICLE_PROFILE: SparkRevealProfile = { + blurAmount: 0.01, + preBlurAmount: 0, + minAlpha: 0.19, + maxStdDev: Math.sqrt(2.2), + focalAdjustment: 2.35, + falloff: 0.2, + opacity: 1, +}; + +const SPLAT_NORMAL_PROFILE: SparkRevealProfile = { + blurAmount: 0.12, + preBlurAmount: 0.02, + minAlpha: 0.015, + maxStdDev: Math.sqrt(6.5), + focalAdjustment: 1.18, + falloff: 1, + opacity: 1, +}; + +function applySparkRevealProfile( + sparkRenderer: SparkRenderer, + splatMesh: SparkSplatMesh, + profile: SparkRevealProfile +) { + splatMesh.opacity = profile.opacity; + sparkRenderer.blurAmount = profile.blurAmount; + sparkRenderer.preBlurAmount = profile.preBlurAmount; + sparkRenderer.minAlpha = profile.minAlpha; + sparkRenderer.maxStdDev = profile.maxStdDev; + sparkRenderer.focalAdjustment = profile.focalAdjustment; + sparkRenderer.falloff = profile.falloff; +} + +function lerpSparkRevealProfile( + from: SparkRevealProfile, + to: SparkRevealProfile, + progress: number +): SparkRevealProfile { + return { + blurAmount: THREE.MathUtils.lerp(from.blurAmount, to.blurAmount, progress), + preBlurAmount: THREE.MathUtils.lerp(from.preBlurAmount, to.preBlurAmount, progress), + minAlpha: THREE.MathUtils.lerp(from.minAlpha, to.minAlpha, progress), + maxStdDev: THREE.MathUtils.lerp(from.maxStdDev, to.maxStdDev, progress), + focalAdjustment: THREE.MathUtils.lerp( + from.focalAdjustment, + to.focalAdjustment, + progress + ), + falloff: THREE.MathUtils.lerp(from.falloff, to.falloff, progress), + opacity: THREE.MathUtils.lerp(from.opacity, to.opacity, progress), + }; +} + const KEY_TO_MOVEMENT: Partial> = { KeyW: "forward", ArrowUp: "forward", @@ -174,230 +241,6 @@ function WorldEmptyState({ ); } -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 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(performance.now()); - - useEffect(() => { - if (phaseRef.current !== phase) { - phaseRef.current = phase; - phaseStartedAtRef.current = performance.now(); - } - }, [phase]); - - useEffect(() => { - if (pointsRef.current) { - pointsRef.current.raycast = () => null; - } - }, []); - - useFrame((state) => { - const geometry = geometryRef.current; - const material = materialRef.current; - if (!geometry || !material) return; - - const elapsed = state.clock.getElapsedTime(); - 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; - - for (let index = 0; index < data.count; index += 1) { - const stride = index * 3; - const swirl = - Math.sin(elapsed * 2.4 + index * 0.17) * (1 - easedProgress) * 0.018; - positions[stride] = - THREE.MathUtils.lerp( - data.startPositions[stride], - data.targetPositions[stride], - easedProgress - ) + - swirl; - positions[stride + 1] = - THREE.MathUtils.lerp( - data.startPositions[stride + 1], - data.targetPositions[stride + 1], - easedProgress - ) + - swirl * 0.45; - positions[stride + 2] = - THREE.MathUtils.lerp( - data.startPositions[stride + 2], - data.targetPositions[stride + 2], - easedProgress - ) - - swirl * 0.65; - } - - positionAttribute.needsUpdate = true; - material.opacity = THREE.MathUtils.lerp(0.9, 0, easedProgress); - material.size = THREE.MathUtils.lerp(0.04, 0.009, easedProgress); - }); - - return ( - - - - - - - - - - ); -} - function WorldSplatLoadingOverlay({ previewImageUrl, phase, @@ -408,49 +251,37 @@ function WorldSplatLoadingOverlay({ const isRevealing = phase === "revealing"; return ( -
- {previewImageUrl ? ( - 世界预览 + <> + {phase === "loading" ? ( +
+ {previewImageUrl ? ( + 世界预览 + ) : null} +
+
) : null} -
-
-
-
-
-
+
- {isRevealing ? "真实点云正在成形" : "正在载入真实 Gaussian Splats"} + {isRevealing ? "真实世界颗粒正在聚合" : "正在载入真实 Gaussian Splats"}
- {isRevealing ? "点云聚合中" : "SPZ 数据准备中"} + {isRevealing ? "真实 splat 正在从颗粒态恢复成世界" : "SPZ 数据准备中"}
- - -
+ ); } @@ -694,8 +525,6 @@ function WorldSplatScene({ const orbitControlsRef = useRef(null); const [virtualMovement, setVirtualMovement] = useState(createMovementState); - const [revealCloudData, setRevealCloudData] = - useState(null); const semanticFacts = useMemo( () => getSemanticFacts(semanticsMetadata), [semanticsMetadata] @@ -716,12 +545,11 @@ function WorldSplatScene({ useEffect(() => { sparkRenderer.frustumCulled = false; sparkRenderer.renderOrder = 10; - sparkRenderer.minAlpha = 0.02; - sparkRenderer.preBlurAmount = 0.04; + applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE); return () => { sparkRenderer.removeFromParent(); }; - }, [sparkRenderer]); + }, [sparkRenderer, splatMesh]); useEffect(() => { if (revealPhaseRef.current !== revealPhase) { @@ -733,18 +561,12 @@ function WorldSplatScene({ 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); + applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_LOADING_PROFILE); let cancelled = false; splatMesh.initialized .then(() => { if (cancelled) return; - setRevealCloudData(buildSplatRevealCloudData(splatMesh)); onLoadStateChange?.("ready"); }) .catch(() => { @@ -760,32 +582,26 @@ function WorldSplatScene({ const phase = revealPhaseRef.current; const revealElapsed = phase === "revealing" - ? Math.min((performance.now() - revealStartedAtRef.current) / 1250, 1) + ? Math.min((performance.now() - revealStartedAtRef.current) / 1650, 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); + applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_LOADING_PROFILE); } 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 + const particleHold = 0.32; + const granularProgress = + easedReveal <= particleHold + ? 0 + : (easedReveal - particleHold) / (1 - particleHold); + const profile = lerpSparkRevealProfile( + SPLAT_PARTICLE_PROFILE, + SPLAT_NORMAL_PROFILE, + THREE.MathUtils.smoothstep(granularProgress, 0, 1) ); + applySparkRevealProfile(sparkRenderer, splatMesh, profile); } else { - splatMesh.opacity = 1; - sparkRenderer.blurAmount = 0.16; - sparkRenderer.preBlurAmount = 0.04; - sparkRenderer.minAlpha = 0.02; - sparkRenderer.maxStdDev = Math.sqrt(7); + applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE); } sparkRenderer.needsUpdate = true; @@ -826,9 +642,6 @@ function WorldSplatScene({ position={[0, groundPlaneOffset, 0]} /> ) : null} - {revealPhase !== "hidden" && revealCloudData ? ( - - ) : null} @@ -944,7 +757,7 @@ function WorldSplatViewer({ setOverlayPhase("revealing"); const timer = window.setTimeout(() => { setOverlayPhase("hidden"); - }, 1250); + }, 1650); return () => window.clearTimeout(timer); }