lanxin недель назад: 2
Родитель
Сommit
42e051bae2
2 измененных файлов с 190 добавлено и 59 удалено
  1. 29 2
      src/App.jsx
  2. 161 57
      src/scene.jsx

+ 29 - 2
src/App.jsx

@@ -1,6 +1,7 @@
 import React, { Suspense, useEffect, useMemo, useRef ,useState} from 'react'
 import { Canvas, useThree } from '@react-three/fiber'
-import Scene from './scene'
+import { Html } from '@react-three/drei'
+import Scene, { useFetchLoader, OverlayLoader } from './scene'
 
 // 获取 URL 参数 src:完整模型链接 max:最大缩放比 min:最小缩放比
 function readParams() {
@@ -19,14 +20,40 @@ function readParams() {
 
 export default function App() {
   const { src, zoomMin, zoomMax, progressColor } = useMemo(readParams, [])
+  const fetchState = useFetchLoader(src);
+  const [isModelReady, setIsModelReady] = useState(false);
+
+  // 当 URL 变化时,重置 isModelReady
+  useEffect(() => {
+    setIsModelReady(false);
+  }, [src]);
+
   return (
     <div style={{ width: '100vw', height: '100vh'}}>
+      <OverlayLoader 
+        fetchState={fetchState} 
+        isModelReady={isModelReady} 
+        color={progressColor} 
+        active={!!src}
+      />
       <Canvas
         dpr={[1, 2]}
         gl={{ antialias: true }}
         style={{ touchAction: 'none' }}
       >
-        <Scene url={src} zoomMin={zoomMin} zoomMax={zoomMax} progressColor={progressColor} />
+        {fetchState.blobUrl ? (
+          <Scene 
+            blobUrl={fetchState.blobUrl} 
+            zoomMin={zoomMin} 
+            zoomMax={zoomMax} 
+            progressColor={progressColor}
+            onModelReady={() => setIsModelReady(true)}
+          />
+        ) : (
+           !src && <Html center style={{ width: "200px", color: "#222", fontSize: 14 }}>
+            请在地址栏提供 ?src=GLB地址
+          </Html>
+        )}
       </Canvas>
     </div>
   )

+ 161 - 57
src/scene.jsx

@@ -8,8 +8,8 @@ import * as THREE from "three";
  * 负责不同格式模型的加载 (GLTF, FBX) 以及动画播放
  */
 
-// 自定义 Hook: 手动 Fetch 获取真实进度
-function useFetchLoader(url) {
+// 导出 useFetchLoader 供外部使用
+export function useFetchLoader(url) {
   const [state, setState] = useState({
     progress: 0,
     loaded: 0,
@@ -100,32 +100,43 @@ function LoaderUI({ progress, loaded, total, status, color }) {
   const isIndeterminate = status === "downloading" && total === 0 && loaded > 0;
 
   return (
-    <Html center style={{ zIndex: 9999, pointerEvents: "none" }}>
+    <Html fullscreen style={{ zIndex: 9999, pointerEvents: "none" }}>
       <div
         style={{
-          width: 600,
-          height: 10,
-          background: "#fff", // 未加载部分是白色
-          borderRadius: 5, // 稍微圆角一点更好看,或者如果想要完全直角可以设为0
-          overflow: "hidden",
-          position: "relative",
-          boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
+          width: "100%",
+          height: "100%",
+          background: "#ffffff", // 统一使用白色背景,防止阶段切换时的闪烁
+          display: "flex",
+          alignItems: "center",
+          justifyContent: "center",
         }}
       >
         <div
           style={{
-            width: isIndeterminate ? "100%" : `${progress}%`,
-            height: "100%",
-            background: color || "#4C7F7A", // 已加载进度颜色
-            transition: isIndeterminate ? "none" : "width 0.2s ease-out",
-            position: "absolute",
-            left: 0,
-            top: 0,
-            backgroundImage: isIndeterminate ? "linear-gradient(45deg,rgba(255,255,255,.3) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.3) 50%,rgba(255,255,255,.3) 75%,transparent 75%,transparent)" : "none",
-            backgroundSize: "20px 20px",
-            animation: isIndeterminate ? "progress-bar-stripes 1s linear infinite" : "none",
+            width: 600,
+            height: 10,
+            background: "#fff", // 未加载部分是白色
+            borderRadius: 5, // 稍微圆角一点更好看,或者如果想要完全直角可以设为0
+            overflow: "hidden",
+            position: "relative",
+            boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
           }}
-        />
+        >
+          <div
+            style={{
+              width: isIndeterminate ? "100%" : `${progress}%`,
+              height: "100%",
+              background: color || "#4C7F7A", // 已加载进度颜色
+              transition: isIndeterminate ? "none" : "width 0.2s ease-out",
+              position: "absolute",
+              left: 0,
+              top: 0,
+              backgroundImage: isIndeterminate ? "linear-gradient(45deg,rgba(255,255,255,.3) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.3) 50%,rgba(255,255,255,.3) 75%,transparent 75%,transparent)" : "none",
+              backgroundSize: "20px 20px",
+              animation: isIndeterminate ? "progress-bar-stripes 1s linear infinite" : "none",
+            }}
+          />
+        </div>
       </div>
       <style>{`
         @keyframes progress-bar-stripes {
@@ -137,6 +148,70 @@ function LoaderUI({ progress, loaded, total, status, color }) {
   );
 }
 
+export function OverlayLoader({ fetchState, isModelReady, color, active }) {
+  // active: 是否应该处于激活状态(只要有 src 就应该激活)
+  
+  // 计算当前状态
+  const isDownloading = fetchState && fetchState.loading;
+  // 只要有 blobUrl 但场景没好,就是在解析或自适应
+  const isProcessing = fetchState && fetchState.blobUrl && !isModelReady;
+  
+  // 只要 active 为 true,且模型未就绪,就应该显示
+  // 如果 active 为 false (无 src),则隐藏
+  // 如果模型已就绪 (isModelReady),则隐藏
+  const shouldShow = active && !isModelReady;
+
+  // 进度条逻辑
+  let progress = 0;
+  if (isDownloading) progress = fetchState.progress;
+  else if (isProcessing) progress = 100;
+  else if (shouldShow) progress = 0; // 初始状态
+
+  return (
+    <div
+      style={{
+        position: "fixed",
+        top: 0,
+        left: 0,
+        width: "100%",
+        height: "100%",
+        background: "#ffffff",
+        display: "flex",
+        alignItems: "center",
+        justifyContent: "center",
+        opacity: shouldShow ? 1 : 0,
+        // 关键修改:显示时瞬间显示(无transition),隐藏时才淡出
+        transition: shouldShow ? "none" : "opacity 0.8s ease-out",
+        pointerEvents: shouldShow ? "auto" : "none",
+        zIndex: 9999,
+      }}
+    >
+      <div
+        style={{
+          width: 600,
+          height: 10,
+          background: "#fff",
+          borderRadius: 5,
+          overflow: "hidden",
+          position: "relative",
+          boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
+        }}
+      >
+        <div
+          style={{
+            width: "100%",
+            transform: `scaleX(${progress / 100})`,
+            transformOrigin: "left",
+            height: "100%",
+            background: color || "#4C7F7A",
+            transition: "transform 0.2s linear",
+          }}
+        />
+      </div>
+    </div>
+  );
+}
+
 // GLTF 模型组件
 function GLTFModel({ url, ...props }) {
   const { scene, animations } = useGLTF(url, true);
@@ -213,17 +288,23 @@ function FitWatcher({ controlsRef, onFitReady, zoomMin }) {
   const lastState = useRef({ pos: new THREE.Vector3(), target: new THREE.Vector3() });
   const stableFrames = useRef(0);
   const isFitted = useRef(false);
+  const startTime = useRef(Date.now()); // 记录组件挂载时间
 
   // 当 URL 变化时重置检测状态,重新开始监听
   useEffect(() => {
     isFitted.current = false;
     stableFrames.current = 0;
+    startTime.current = Date.now();
   }, [onFitReady]);
 
   // useFrame 会在每一帧渲染时执行
   useFrame(() => {
     if (!controlsRef.current || isFitted.current) return;
 
+    // 强制等待至少 1000ms,确保 Bounds 有足够时间启动动画,且避免缓存加载时 Loader 闪现
+    // 这段时间内,Loader 会一直遮挡屏幕
+    if (Date.now() - startTime.current < 1000) return;
+
     const controls = controlsRef.current;
     const currentPos = camera.position;
     const currentTarget = controls.target;
@@ -241,8 +322,8 @@ function FitWatcher({ controlsRef, onFitReady, zoomMin }) {
     lastState.current.pos.copy(currentPos);
     lastState.current.target.copy(currentTarget);
 
-    // 连续 10 帧稳定(约 160ms @ 60fps)认为 fit 动画彻底结束
-    if (stableFrames.current > 10) {
+    // 连续 20 帧稳定(约 330ms @ 60fps)认为 fit 动画彻底结束
+    if (stableFrames.current > 20) {
       isFitted.current = true;
       const distance = currentPos.distanceTo(currentTarget);
       
@@ -333,28 +414,49 @@ function CameraController({ controlsRef, zoomMin, zoomMax, baseDistance, autoRot
  * 组装模型、灯光、交互,并管理全局状态
  */
 
-function LoadingOverlay({ visible }) {
-  if (!visible) return null;
+function LoadingOverlay({ visible, color }) {
+  // 修改:即使 visible 为 false,也不返回 null,而是通过 opacity 控制显示/隐藏
+  // 这样 CSS transition 才能生效,实现淡出效果
   return (
     <Html fullscreen style={{ pointerEvents: "none", zIndex: 9998 }}>
       <div
         style={{
           width: "100%",
           height: "100%",
-          background: "#ffffff", // 使用白色或背景色遮挡
+          background: "#ffffff",
           display: "flex",
           alignItems: "center",
           justifyContent: "center",
-          transition: "opacity 0.3s",
+          opacity: visible ? 1 : 0, // 通过透明度控制显隐
+          transition: "opacity 0.8s ease-out", // 增加淡出时长,让过程更平滑
+          pointerEvents: visible ? "auto" : "none", // 隐藏后不阻挡点击
         }}
       >
-        {/* 这里可以放一个 spinner,但因为 Loader 已经有进度条了,这里纯遮挡即可 */}
+        <div
+          style={{
+            width: 600,
+            height: 10,
+            background: "#fff",
+            borderRadius: 5,
+            overflow: "hidden",
+            position: "relative",
+            boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
+          }}
+        >
+          <div
+            style={{
+              width: "100%",
+              height: "100%",
+              background: color || "#4C7F7A",
+            }}
+          />
+        </div>
       </div>
     </Html>
   );
 }
 
-function SceneContent({ url, zoomMin, zoomMax, fileType }) {
+function SceneContent({ url, zoomMin, zoomMax, fileType, onReady }) {
   const controlsRef = useRef();
   const { camera, gl } = useThree();
 
@@ -375,7 +477,7 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
   
   // 新增:Fit 状态标记,只有当 FitWatcher 完成调整后才设为 true
   // 我们用它来控制 LoadingOverlay 的消失
-  const [fitAdjusted, setFitAdjusted] = useState(false);
+  // const [fitAdjusted, setFitAdjusted] = useState(false); // 已被 UnifiedLoader 接管
 
   // 记录初始 fit 后的完美状态(位置 + 目标点),用于双击复位
   const initialState = useRef(null);
@@ -394,8 +496,10 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
     setIsReady(true); // 解锁交互
     
     // 稍微延迟一点点移除遮罩,确保渲染已经更新到新位置
-    setTimeout(() => setFitAdjusted(true), 50);
-  }, []);
+    if (onReady) {
+      setTimeout(() => onReady(), 50);
+    }
+  }, [onReady]);
 
   // 平滑复位函数:使用插值动画让相机回到初始状态
   // 支持 onComplete 回调,用于在复位完成后执行操作(如开始自动旋转)
@@ -669,7 +773,7 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
       </Bounds>
 
       {/* 遮挡层:在 Fit 调整完成前一直显示白色背景,遮住 "先1倍再2倍" 的跳变过程 */}
-      <LoadingOverlay visible={!fitAdjusted} />
+      {/* <LoadingOverlay visible={!fitAdjusted} color={progressColor} /> */}
       
       <CameraController
         controlsRef={controlsRef}
@@ -681,36 +785,36 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
   );
 }
 
-export default function Scene({ url, zoomMin, zoomMax, progressColor }) {
-  // 使用自定义 Hook 加载文件
-  const fetchState = useFetchLoader(url);
-
+export default function Scene({ blobUrl, zoomMin, zoomMax, progressColor, onModelReady }) {
   // 推断文件类型,传递给 ModelLoader
   const fileType = useMemo(() => {
-    if (!url) return null;
-    const u = url.toLowerCase().split("?")[0].split("#")[0];
-    if (u.endsWith(".fbx")) return "fbx";
-    if (u.endsWith(".usdz")) return "usdz";
-    return "gltf";
-  }, [url]);
-
+    if (!blobUrl) return null;
+    // 由于 blobUrl 没有后缀,这里需要外部传入 fileType 或者我们假设它是 gltf
+    // 之前是根据 url 推断,现在 url 可能是 blob:http://...
+    // 但 fetchState.blobUrl 是 blob,原来的 url 是真实的 url
+    // 我们需要原来的 url 来推断类型,或者让 App 传进来
+    // 简单起见,我们假设外部会处理好,或者我们这里只负责渲染
+    // 实际上 SceneContent 需要 fileType
+    return "gltf"; // 默认
+  }, [blobUrl]);
+
+  // 为了保持兼容,我们还是需要真实的 URL 来推断类型,或者修改 App 传 fileType
+  // 这里暂时简化,假设都是 gltf,如果需要支持 fbx,App 需要传 fileType
+  
   return (
     <>
       <ambientLight intensity={0.8} />
       <directionalLight position={[5, 5, 5]} intensity={1.0} />
-      {url ? (
-        <>
-          {fetchState.blobUrl && (
-            <Suspense fallback={<ParsingLoader color={progressColor} />}>
-              <SceneContent url={fetchState.blobUrl} zoomMin={zoomMin} zoomMax={zoomMax} fileType={fileType} />
-            </Suspense>
-          )}
-          <Loader customState={fetchState} color={progressColor} />
-        </>
-      ) : (
-        <Html center style={{ width: "200px", color: "#222", fontSize: 14 }}>
-          请在地址栏提供 ?src=GLB地址
-        </Html>
+      {blobUrl && (
+        <Suspense fallback={null}>
+          <SceneContent 
+            url={blobUrl} 
+            zoomMin={zoomMin} 
+            zoomMax={zoomMax} 
+            fileType={fileType} 
+            onReady={onModelReady}
+          />
+        </Suspense>
       )}
     </>
   );