From 62b5b75ec97df887fb738ff06ba9150d3b244df2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 6 Apr 2026 00:30:40 +0800 Subject: [PATCH] feat: add 3d splat assembly loading effect --- components/canvas/world3d/World3DViewer.tsx | 159 +++++++++++++++++++- 1 file changed, 155 insertions(+), 4 deletions(-) diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index 316a73836..e54f6209b 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -42,6 +42,7 @@ type MovementCommand = | "down"; type MovementState = Record; +type SplatLoadingPhase = "loading" | "fading" | "hidden"; type World3DViewerProps = { mode: World3DViewMode; @@ -182,12 +183,158 @@ const SPLAT_LOADING_PARTICLES = [ { 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; + +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({ + phase, +}: { + phase: Exclude; +}) { + const pointsRef = useRef(null); + const materialRef = useRef(null); + 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]); + + 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 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); + + seeds.forEach((seed, index) => { + 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); + + positions[stride] = + seed.target.x * phaseMix + Math.cos(orbitAngle) * orbitRadius; + positions[stride + 1] = + seed.target.y * phaseMix + + verticalWave + + Math.sin(orbitAngle * 0.7) * orbitRadius * 0.18; + positions[stride + 2] = + seed.target.z * phaseMix + Math.sin(orbitAngle) * orbitRadius; + }); + + geometry.attributes.position.needsUpdate = true; + }); + + return ( + + + + + + + + ); +} + function WorldSplatLoadingOverlay({ previewImageUrl, phase, }: { previewImageUrl?: string | null; - phase: "loading" | "fading"; + phase: Exclude; }) { const isFading = phase === "fading"; @@ -506,6 +653,7 @@ function HotspotBillboard({ function WorldSplatScene({ splatUrl, + assemblyPhase = "loading", firstPerson = false, hotspots = [], interactionMode = "navigate", @@ -515,6 +663,7 @@ function WorldSplatScene({ onLoadStateChange, }: { splatUrl: string; + assemblyPhase?: SplatLoadingPhase; firstPerson?: boolean; hotspots?: World3DHotspot[]; interactionMode?: "navigate" | "hotspot"; @@ -606,6 +755,9 @@ function WorldSplatScene({ position={[0, groundPlaneOffset, 0]} /> ) : null} + {assemblyPhase !== "hidden" ? ( + + ) : null} @@ -709,9 +861,7 @@ function WorldSplatViewer({ semanticsMetadata?: Record | null; }) { const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading"); - const [overlayPhase, setOverlayPhase] = useState<"loading" | "fading" | "hidden">( - "loading" - ); + const [overlayPhase, setOverlayPhase] = useState("loading"); useEffect(() => { setLoadState("loading"); @@ -767,6 +917,7 @@ function WorldSplatViewer({ gl={{ antialias: false }} >