diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index 92dc15c23..53643bda3 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -38,6 +38,12 @@ type MovementCommand = type MovementState = Record; type SplatLoadingPhase = "loading" | "revealing" | "hidden"; +type SplatParticleCloudData = { + count: number; + colors: Float32Array; + positions: Float32Array; + seeds: Float32Array; +}; type SparkRevealProfile = { blurAmount: number; preBlurAmount: number; @@ -151,6 +157,7 @@ function lerpSparkRevealProfile( } const SPLAT_REVEAL_DURATION_MS = 2600; +const SPLAT_PARTICLE_SAMPLE_LIMIT = 5200; const KEY_TO_MOVEMENT: Partial> = { KeyW: "forward", @@ -201,6 +208,67 @@ function getSemanticFacts(metadata?: Record | null) { return facts; } +function seededUnit(index: number, salt: number) { + const value = Math.sin(index * 12.9898 + salt * 78.233) * 43758.5453; + return value - Math.floor(value); +} + +function buildSplatParticleCloudData( + splatMesh: SparkSplatMesh, + sampleLimit = SPLAT_PARTICLE_SAMPLE_LIMIT +): SplatParticleCloudData | null { + const totalSplats = Math.max(0, splatMesh.packedSplats?.numSplats || 0); + if (!totalSplats) return null; + + const sampleStep = Math.max(1, Math.floor(totalSplats / sampleLimit)); + const points: Array<{ + center: THREE.Vector3; + color: THREE.Color; + opacity: number; + }> = []; + const warmWhite = new THREE.Color("#fff1dc"); + + splatMesh.forEachSplat((index, center, _scales, _quaternion, opacity, color) => { + if (points.length >= sampleLimit) return; + if (index % sampleStep !== 0) return; + if (opacity < 0.035) return; + + points.push({ + center: center.clone(), + color: color.clone(), + opacity, + }); + }); + + if (!points.length) return null; + + const colors = new Float32Array(points.length * 3); + const positions = new Float32Array(points.length * 3); + const seeds = new Float32Array(points.length); + + points.forEach((point, index) => { + const stride = index * 3; + const displayColor = point.color + .clone() + .lerp(warmWhite, 0.28) + .multiplyScalar(0.86 + Math.min(point.opacity, 1) * 0.32); + positions[stride] = point.center.x; + positions[stride + 1] = point.center.y; + positions[stride + 2] = point.center.z; + colors[stride] = displayColor.r; + colors[stride + 1] = displayColor.g; + colors[stride + 2] = displayColor.b; + seeds[index] = seededUnit(index, 31) * Math.PI * 2; + }); + + return { + count: points.length, + colors, + positions, + seeds, + }; +} + function positionCameraInsideWorld({ camera, controls, @@ -305,6 +373,124 @@ function WorldEmptyState({ ); } +function SplatParticleCloud({ + data, + phase, +}: { + data: SplatParticleCloudData; + phase: SplatLoadingPhase; +}) { + const geometryRef = useRef(null); + const materialRef = useRef(null); + const pointsRef = useRef(null); + const phaseRef = useRef(phase); + const phaseStartedAtRef = useRef(performance.now()); + const positionBuffer = useMemo(() => data.positions.slice(), [data.positions]); + + 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 phaseNow = phaseRef.current; + const elapsedSincePhase = performance.now() - phaseStartedAtRef.current; + const revealProgress = + phaseNow === "revealing" + ? THREE.MathUtils.smoothstep( + Math.min(elapsedSincePhase / SPLAT_REVEAL_DURATION_MS, 1), + 0, + 1 + ) + : phaseNow === "hidden" + ? 1 + : 0; + + const particleIntroEnd = 0.18; + const particleHoldEnd = 0.62; + let particleOpacity = 0; + let particleSize = 0.01; + + if (phaseNow === "revealing") { + if (revealProgress < particleIntroEnd) { + const intro = THREE.MathUtils.smoothstep(revealProgress / particleIntroEnd, 0, 1); + particleOpacity = THREE.MathUtils.lerp(0.08, 0.92, intro); + particleSize = THREE.MathUtils.lerp(0.01, 0.028, intro); + } else if (revealProgress < particleHoldEnd) { + particleOpacity = 0.92; + particleSize = 0.028; + } else { + const outro = THREE.MathUtils.smoothstep( + (revealProgress - particleHoldEnd) / (1 - particleHoldEnd), + 0, + 1 + ); + particleOpacity = THREE.MathUtils.lerp(0.92, 0, outro); + particleSize = THREE.MathUtils.lerp(0.028, 0.006, outro); + } + } + + const positionAttribute = geometry.getAttribute("position"); + const positions = positionAttribute.array as Float32Array; + const time = state.clock.getElapsedTime(); + const jitterStrength = phaseNow === "revealing" ? (1 - revealProgress) * 0.012 : 0; + + for (let index = 0; index < data.count; index += 1) { + const stride = index * 3; + const seed = data.seeds[index] || 0; + positions[stride] = data.positions[stride] + Math.sin(time * 2.8 + seed) * jitterStrength; + positions[stride + 1] = + data.positions[stride + 1] + + Math.cos(time * 2.3 + seed * 1.7) * jitterStrength * 0.7; + positions[stride + 2] = + data.positions[stride + 2] + Math.sin(time * 2.1 + seed * 0.6) * jitterStrength; + } + + positionAttribute.needsUpdate = true; + material.opacity = particleOpacity; + material.size = particleSize; + material.visible = particleOpacity > 0.001; + }); + + return ( + + + + + + + + + + ); +} + function WorldSplatLoadingOverlay({ previewImageUrl, phase, @@ -499,6 +685,7 @@ function WorldSplatScene({ onHotspotSelect, semanticsMetadata, onLoadStateChange, + onRevealComplete, }: { splatUrl: string; revealPhase?: SplatLoadingPhase; @@ -509,11 +696,14 @@ function WorldSplatScene({ onHotspotSelect?: (hotspotId: string) => void; semanticsMetadata?: Record | null; onLoadStateChange?: (state: "ready" | "error") => void; + onRevealComplete?: () => void; }) { const camera = useThree((state) => state.camera); const renderer = useThree((state) => state.gl); const orbitControlsRef = useRef(null); const hasAppliedInitialViewRef = useRef(false); + const revealCompletedRef = useRef(false); + const [particleCloudData, setParticleCloudData] = useState(null); const semanticFacts = useMemo( () => getSemanticFacts(semanticsMetadata), [semanticsMetadata] @@ -538,6 +728,7 @@ function WorldSplatScene({ useEffect(() => { sparkRenderer.frustumCulled = false; sparkRenderer.renderOrder = 10; + sparkRenderer.originDistance = 0.12; applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE); return () => { sparkRenderer.removeFromParent(); @@ -548,11 +739,16 @@ function WorldSplatScene({ if (revealPhaseRef.current !== revealPhase) { revealPhaseRef.current = revealPhase; revealStartedAtRef.current = performance.now(); + if (revealPhase === "loading") { + revealCompletedRef.current = false; + } } }, [revealPhase]); useEffect(() => { hasAppliedInitialViewRef.current = false; + revealCompletedRef.current = false; + setParticleCloudData(null); splatMesh.frustumCulled = false; splatMesh.rotation.x = Math.PI; applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_LOADING_PROFILE); @@ -563,6 +759,7 @@ function WorldSplatScene({ if (cancelled) return; window.requestAnimationFrame(() => { if (cancelled || hasAppliedInitialViewRef.current) return; + setParticleCloudData(buildSplatParticleCloudData(splatMesh)); positionCameraInsideWorld({ camera, controls: orbitControlsRef.current, @@ -583,6 +780,9 @@ function WorldSplatScene({ }, [camera, groundPlaneOffset, onLoadStateChange, sparkRenderer, splatMesh]); useFrame(() => { + sparkRenderer.position.copy(camera.position); + sparkRenderer.updateMatrixWorld(); + const phase = revealPhaseRef.current; const revealElapsed = phase === "revealing" @@ -619,6 +819,10 @@ function WorldSplatScene({ ); } applySparkRevealProfile(sparkRenderer, splatMesh, profile); + if (revealElapsed >= 1 && !revealCompletedRef.current) { + revealCompletedRef.current = true; + onRevealComplete?.(); + } } else { applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE); } @@ -651,6 +855,7 @@ function WorldSplatScene({ + {particleCloudData ? : null} {hotspots.map((hotspot) => ( { if (loadState === "ready") { setOverlayPhase("revealing"); - const timer = window.setTimeout(() => { - setOverlayPhase("hidden"); - }, SPLAT_REVEAL_DURATION_MS); - return () => window.clearTimeout(timer); } if (loadState === "loading") { @@ -804,6 +1005,7 @@ function WorldSplatViewer({ onHotspotSelect={onHotspotSelect} onLoadStateChange={setLoadState} onPick={onPick} + onRevealComplete={() => setOverlayPhase("hidden")} revealPhase={overlayPhase} semanticsMetadata={semanticsMetadata} splatUrl={splatUrl}