用 Three.js 實現一個瀏覽器端 3D 看車的專案

瀏覽器端 3D 看車:從 GLB 到可互動展廳的技術實作

本文基於開源專案 3d-car-viewing,記錄如何用 Next.js + React Three Fiber + Three.js 在瀏覽器中搭建一套可切換車型、可互動部件、可分享深連結的 3D 看車體驗。重點放在實作過程中的重難點與對應解法,而非 API 羅列。


專案預覽: https://jiaxiantao.github.io/3d-car-viewing/

一、專案概覽

1.1 要解決什麼問題

傳統 2D 圖片看車無法展示空間關係;而直接使用第三方 GLB 車模又面臨命名混亂、部件合併、無骨骼動畫等現實問題。本專案的目標是:

  • 在瀏覽器中載入主流 GLB 車模(SUV / 轎車 / 越野),提供接近展廳的互動體驗;
  • 命名不規範、網格合併的模型盡量「能識別多少做多少」,識別不了的按鈕明確禁用並提示原因;
  • 載入失敗時自動回退到內建幾何體車模,保證展示不白屏;
  • 支援車漆、多機位、場景模式、截圖、全螢幕,以及 URL 深連結分享。

1.2 技術棧

層級選型應用框架Next.js 16 · React 193D 渲染three.js · @react-three/fiber · @react-three/drei語言 / 樣式TypeScript 5 · Tailwind CSS 4狀態分層原則:

  • page.tsx 持有所有使用者可見狀態(車門、燈光、車漆、車型等),透過 props 下發給 Canvas;
  • car-showroom-scene.tsx 負責 WebGL 生命週期:GLTF 載入、rig 綁定、每幀動畫、相機過渡;
  • 互動按鈕是否可用,由 onAssetRigCapabilities 回呼上報的 capabilities 決定,避免「點了沒反應」。

二、效果預覽

image.png

2.1 主介面 — 整車 WebGL 展示

image.png

2.2 車身互動 — 車門 / 燈光 / 車漆

image.png

image.png

2.3 駕駛動態 — 啟動 / 制動 / 環車巡檢

image.png

2.4 多機位視角

image.png

2.5 場景模式切換

image.png

2.6 開發除錯 — GLB 部件識別面板

image.png


三、重難點一:第三方 GLB 的「部件 Rig 自動發現」

3.1 難點描述

Sketchfab、Forza 匯出等管道的 GLB 車模沒有統一的命名規範,也通常不含 glTF 骨骼動畫。不同車型的差異極大:

車型mesh 規模拆分方式實際能力suv-mainstream.glb(Audi Q3)~253按部件拆分車門 / 後行李廂 / 天窗可動;路面四輪與車身合併sedan-mainstream.glb(BMW M2)~1200節點名 Object_*,靠材質識別燈 / 漆燈發光、改色、怠速振動;門與輪為合併網格offroad-mainstream.glb(Brabus G900)~109路面輪焊在車身車燈 / 振動;備胎不轉若簡單用正則匹配 door 關鍵字,很容易把橫跨半個車身的合併網格誤判為車門,旋轉時整塊車身跟著轉——體驗災難。

3.2 解法:啟發式發現 + 車型 Profile 覆蓋

核心邏輯在 src/lib/asset-car-rig.tsdiscoverAssetCarRig()

  1. 遍歷所有 Mesh,收集名稱、材質名、包圍盒中心與尺寸;
  2. 空間分區:根據整車 AABB 計算前 / 後、左 / 右閾值,車門必須落在對應象限;
  3. 局部面板校驗isLocalizedPanel() 拒絕 footprint 覆蓋大部分車身的 mesh;
  4. 排除規則DOOR_EXCLUDE 等正則過濾尾燈、內裝、擋風玻璃等誤匹配;
  5. Profile 覆蓋market-rig-profiles.ts 為特定 URL 提供精確正則,彌補自動發現的盲區。
ts 代碼解讀複製代碼// market-rig-profiles.ts — 按 URL 匹配車型規則
const suvQ3Profile: MarketRigProfile = {
  id: "suv-q3",
  urlPattern: /suv-mainstream/i,
  leftDoor: [/polySurface5638/i, /Door_Soft_Black_Plastic_Q3/i],
  rightDoor: [/polySurface5634/i, /polySurface5632/i],
  trunk: [/Boot_ext2_Mesh_049_Carpaint/i, /Boot_ext17/i],
  headLight: [/\bHL\d_Mesh/i, /Hl_Projection_lamp/i],
  bakedWheels: true, // 路面輪與車身合併,不嘗試旋轉
};

發現完成後輸出 AssetCarRig 結構體,包含:

  • 車門 / 後行李廂的 pivot 群組(執行期掛接 mesh);
  • 大燈 / 尾燈 / 雙黃燈的 材質引用列表
  • 車漆材質、天窗節點、車輪節點;
  • capabilities 標誌位,驅動 UI 按鈕啟用 / 禁用。

3.3 互動熱區

GLB 模型本身沒有可點擊的 DOM,因此在 AssetInteractionZones 中為每個 pivot 生成透明 Box 碰撞體,綁定 onClickpointer 樣式,實現 3D 場景內直接點門開門。


四、重難點二:無 glTF 動畫的「偽骨骼」驅動

4.1 難點描述

沒有廠商級開門動畫時,只能對識別出的 mesh 群組做程式化變換。難點在於:

  • 車門需要繞鉸鏈旋轉,但 GLB 裡沒有鉸鏈節點;
  • 車輪需要原地自轉 + 前輪轉向,但不能往場景圖裡塞額外 pivot(會破壞原有層級);
  • 引擎啟動、制動、雙黃燈需要多通道動畫疊加且互不打架。

4.2 解法:Pivot 群組 + useFrame 阻尼插值

車門 / 後行李廂:發現階段將相關 mesh 掛到 THREE.Group pivot 上,設定鉸鏈位置;每幀用 THREE.MathUtils.damp 平滑插值目標角度:

ts 代碼解讀複製代碼// car-showroom-scene.tsx — AssetModel useFrame 片段
if (rig.leftDoorPivot) {
  const target = state.leftDoorOpen ? -ASSET_DOOR_MAX_OPEN_RADIANS : 0;
  rig.leftDoorPivot.rotation.y = THREE.MathUtils.damp(
    rig.leftDoorPivot.rotation.y, target, 8, delta,
  );
}

車輪applyWheelMotion() 每幀從 userData.showroomWheel.base 矩陣重建變換,繞自軸滾動、繞轉向軸偏轉,不引入額外 helper 節點

ts 代碼解讀複製代碼// asset-car-rig.ts
export function applyWheelMotion(node, spinAngle, steerAngle) {
  // pivot → steer → spin → 還原 base 矩陣
  WHEEL_MATRIX.makeTranslation(pivot.x, pivot.y, pivot.z);
  if (steerAxis && steerAngle !== 0) {
    WHEEL_MATRIX.multiply(WHEEL_ROTATION.makeRotationAxis(steerAxis, steerAngle));
  }
  WHEEL_MATRIX.multiply(WHEEL_ROTATION.makeRotationAxis(spinAxis, spinAngle));
  // ...
  WHEEL_MATRIX.decompose(node.position, node.quaternion, node.scale);
}

駕駛體感疊加(同一 useFrame 內):

狀態表現engineOn車身 Y 軸微振(sin(t*8)*0.02)點火瞬間ignitionPulse 抬升 + 俯仰speedKph + braking速度阻尼、輪速角速度、制動俯仰hazardOn``sin(t*8) 方波驅動尾燈閃爍braking尾燈額外紅色 emissive 疊加#### 4.3 車燈發光:材質 emissive 而非額外 Mesh

大燈 / 尾燈透過 boostShowroomMaterialEmissive() 動態調節材質的 emissiveemissiveIntensity,並針對頭燈鏡片設定 toneMapped: false 以獲得 HDR 輝光感。尾燈刻意限制 tailMax,避免高亮度過曝成白色。


五、重難點三:異構 GLB 的統一歸一化

5.1 難點描述

不同來源的 GLB 尺度差異巨大(FBX 匯出常見 scale=0.01),朝向也不一致(多數 Z 軸朝前,而展廳相機預設假設 -X 為車頭)。若不做歸一化,切換車型時機位、地面接觸、orbit 距離全部失效。

5.2 解法:normalizeMarketModel()

ts 代碼解讀複製代碼// normalize-market-model.ts 核心步驟
export function normalizeMarketModel(root, targetLength = 4, groundY = -0.22) {
  hideHelperMeshes(root);           // 隱藏 camera / gizmo 等輔助 mesh
  root.position.sub(center);        // 幾何中心對齊原點
  root.scale.multiplyScalar(scale); // 最長軸縮放到 ~4m
  root.rotation.y = -Math.PI / 2;   // Z-forward → -X-forward
  root.position.y += groundY - groundedBounds.min.y; // 輪胎落地
  return getVisibleMeshBounds(root);
}

關鍵點:

  • 使用世界空間包圍盒updateWorldMatrix 後計算),正確包含父級 scale;
  • 先旋轉再落地,避免旋轉前 AABB 導致的懸浮或穿地;
  • 回傳的 bounds 供相機、orbit 限制、頭燈聚光燈錨點重複使用。

六、重難點四:自適應相機與環車巡檢

6.1 難點描述

六種預設機位(全景 / 前臉 / 側視 / 車尾 / 駕駛艙)若寫死座標,換車型後必然構圖失調;自動環車巡檢又需要在手動 orbit 與程式化路徑之間平滑切換。

6.2 解法:包圍盒驅動 + 過渡插值

showroom-camera.tsgetBoundsCameraPose() 根據 boundssizecenter 按比例計算機位:

ts 代碼解讀複製代碼const span = Math.max(size.x, size.y, size.z, 1e-3);
const dist = span * 1.28;
// front: 相機位於 bounds.min.x 前方 dist*0.9 ...

CameraRig 在 preset 或 bounds 變化時觸發 beginTransitionToPreset(),用 transitionProgressRef 做 ease 插值;開啟 autoTour 時改由 sampleAutoTourPose() 沿橢圓軌道採樣,帶輕微上下起伏。

getOrbitDistanceLimits() 同樣基於 span 動態計算 minDistance / maxDistance,保證大小車型都能合理縮放。


七、重難點五:離線 IBL 與多場景氛圍

7.1 難點描述

@react-three/drei<Environment preset="city" /> 會從 CDN 載 HDR,內網 / 離線部署易失敗導致畫面全黑。同時需要影棚 / 白天 / 夜晚三套差異明顯的燈光與地面表現。

7.2 解法:RoomEnvironment + 場景模式配置表

ShowroomImageBasedLighting 使用 Three.js 內建 RoomEnvironment + PMREMGenerator 在本地生成環境貼圖,零外部 CDN 依賴

showroom-scene-modes.ts 將三套模式的背景色、霧效、環境光強度、地面金屬度 / 粗糙度、頭燈聚光強度等收斂為 SHOWROOM_SCENE_MODES 配置物件,CarShowroomSceneShowroomReflectiveFloor 統一消費,避免燈光與地板不一致。

夜晚模式配合 MeshReflectorMaterial@react-three/drei)實現濕地反射;根據 performanceTier 動態降低反射貼圖解析度,兼顧行動端。


八、重難點六:GLB 載入管線與健壯性

8.1 載入流程

8.2 關鍵工程細節

機制作用gltfSceneCache(LRU, limit=6)二次切換車型秒開;clone(true) 後重建 rig(Box3 方法在 clone 後會遺失)切換車型時保留舊模型新資源就緒前舊車仍可見,上層 Html overlay 顯示進度MIN_LOADING_OVERLAY_MS = 480本機快取命中時 overlay 仍短暫展示,避免閃爍候選 URL 鏈式重試主模型 → alternates → fallback,全失敗才切幾何體dynamic(..., { ssr: false })Canvas 僅客戶端掛載,避免 SSR 報錯#### 8.3 幾何體回退車模

CarModel 用純 Three.js 幾何體拼裝整車(車身、車門 pivot、輪圈、內裝等),與 GLB 路徑共用同一套 useFrame 動畫邏輯。回退時 capabilities 全開,保證展示功能完整。


九、重難點七:URL 深連結與 Hydration 安全

9.1 難點描述

希望 ?model=suv&paint=midnight&camera=front&mode=night 可分享、可重新整理還原;但 Next.js SSR 首屏不能讀 window.location,否則 hydration 不一致。

9.2 解法

ts 代碼解讀複製代碼// page.tsx — 客戶端 mount 後一次性 hydrate
useEffect(() => {
  const initial = readShowroomUrlState();
  if (initial.category) setSelectedCategory(initial.category);
  // ...
}, []);

// use-showroom-url-state.ts — 狀態變化寫回 URL
useEffect(() => {
  const params = new URLSearchParams();
  params.set("model", state.category);
  params.set("paint", state.paintId);
  // ...
  window.history.replaceState(null, "", nextUrl); // 不污染歷史堆疊
}, [state.category, state.paintId, ...]);

replaceState 而非 pushState:使用者頻繁調色 / 切視角時,瀏覽器「上一頁」不會一步步回退每個中間狀態。


十、效能優化清單

手段位置說明AdaptiveDpr / AdaptiveEventsCanvas 內幀率下降時自動降 DPR、減少事件頻率dpr={[1, 1.75]}Canvas props限制最大像素比performanceTier 降低 Reflector 解析度ShowroomReflectiveFloor行動端濕地反射降採樣GLTF 記憶體 LRUloadGltfScene限制快取車型數量,淘汰時 dispose 幾何體與材質preserveDrawingBuffer: trueCanvas gl 設定截圖需保留幀緩衝,略有 GPU 開銷陰影貼圖 1024²頭燈 SpotLight平衡品質與效能---

十一、擴充指南

11.1 接入新車型

  1. 將 GLB 放入 public/models/market/
  2. src/lib/car-categories.ts 註冊 URL;
  3. gltf.report 或 Blender 查看 mesh / 材質命名;
  4. 若自動發現不準,在 market-rig-profiles.ts 增加 MarketRigProfile
  5. 重新整理頁面,開發環境下檢查 Debug 面板的部件識別結果。

詳細命名約定見 market-glb-rig.md

11.2 新增互動能力

  1. asset-car-rig.ts 擴展 AssetCarRigdiscoverAssetCarRig 掃描邏輯;
  2. AssetModeluseFrame 中驅動動畫;
  3. page.tsx 增加狀態欄位與按鈕,透過 supportsInteraction() 門控。

十二、已知限制與取捨

  1. 無 glTF 動畫的模型只能做近似開合,無法達到 CAD 級精度;
  2. 合併網格的車身(如 BMW M2 車門)無法拆分,按鈕會禁用——這是模型結構限制,不是程式 bug;
  3. 車身噴漆只改 paintMaterials,不會影響燈罩玻璃材質;
  4. 倉庫內 GLB 體積約 120MB,首次 clone 較慢;生產環境建議 CDN + 壓縮紋理;
  5. 截圖功能依賴 preserveDrawingBuffer,極高解析度下行動端可能 OOM。

十三、總結

本專案的核心工程價值在於:用一套可擴充的 Rig 發現層,把不可控的第三方 GLB 翻譯成可控的互動能力,並在發現失敗時優雅降級。技術路徑可歸納為:

markdown 代碼解讀複製代碼GLB 載入 → 歸一化 → Rig 發現(啟發式 + Profile)→ capabilities 上報
    → useFrame 多通道動畫 → 包圍盒驅動相機 → 離線 IBL 場景
    → URL 深連結分享 → 效能自適應

如果你正在做類似的 3D 商品展示、房地產漫遊或工業視覺化,這套「發現層 + 降級層 + 配置層」的分層思路可以直接復用;差異主要在 Profile 規則與動畫表現,而不在 R3F 基礎搭建。


附錄:關鍵檔案索引

檔案職責src/app/page.tsx頁面狀態、互動 UI、URL hydratesrc/components/car-showroom-scene.tsxCanvas、GLTF 載入、AssetModel / CarModelsrc/lib/asset-car-rig.ts部件自動發現、車輪運動、燈光 emissivesrc/lib/market-rig-profiles.ts車型級正則覆蓋src/lib/normalize-market-model.tsGLB 縮放 / 朝向 / 落地src/lib/showroom-camera.ts機位 / 環車 / orbit 限制src/lib/showroom-scene-modes.ts影棚 / 白天 / 夜晚配置src/components/showroom-environment.tsxIBL、地面反射、頭燈聚光src/lib/use-showroom-url-state.ts深連結讀寫docs/ARCHITECTURE.md貢獻者向架構說明docs/market-glb-rig.mdGLB 建模與命名要求### 專案地址

項連結預覽地址jiaxiantao.github.io/3d-car-view…GitHub 倉庫github.com/jiaxiantao/…Clone 地址https://github.com/jiaxiantao/3d-car-viewing.gitIssue / 討論github.com/jiaxiantao/…---

參考文章

主題標題連結Three.js 官方手冊Three.js Manualthreejs.org/manual/React Three FiberR3F 文件(Getting Started / API)docs.pmnd.rs/react-three…drei 輔助函式庫@react-three/drei 文件與範例github.com/pmndrs/dreiglTF 規範Khronos glTF 2.0 Specificationregistry.khronos.org/glTF/specs/…GLB 除錯gltf.report — 線上查看 mesh / 材質結構gltf.report/環境光照Three.js RoomEnvironment 範例(PMREM / IBL)threejs.org/docs/#examp…Next.jsNext.js App Router 文件nextjs.org/docs/app效能R3F Performance pitfallsdocs.pmnd.rs/react-three…---

參考書籍

書名作者說明Discover Three.jsJos Dirksen面向 Web 的 Three.js 入門與進階,涵蓋場景圖、光照、載入與動畫;線上免費閱讀Real-Time 3D Graphics with WebGL 2(第 2 版)Faruna, Lipchak, et al.WebGL 2 與即時渲染管線,適合理解 PBR、陰影、後製等底層概念Fundamentals of Computer Graphics(第 5 版)Marschner, Shirley電腦圖學經典教材,相機、變換、光照模型等理論基礎Interactive Computer Graphics: A Top-Down Approach with WebGL(第 8 版)Angel, Shreiner以 WebGL 為載體的互動式圖學課程用書,與 Three.js 抽象層互補WebGL InsightsPatrick Cozzi (ed.)WebGL 工程實作合集,含效能優化、資源管理與渲染技巧


原文出處:https://juejin.cn/post/7653437401840189474


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝14   💬2   ❤️1
743
🥈
我愛JS
📝1   ❤️1
67
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登