瀏覽器端 3D 看車:從 GLB 到可互動展廳的技術實作
本文基於開源專案 3d-car-viewing,記錄如何用 Next.js + React Three Fiber + Three.js 在瀏覽器中搭建一套可切換車型、可互動部件、可分享深連結的 3D 看車體驗。重點放在實作過程中的重難點與對應解法,而非 API 羅列。
專案預覽: https://jiaxiantao.github.io/3d-car-viewing/
傳統 2D 圖片看車無法展示空間關係;而直接使用第三方 GLB 車模又面臨命名混亂、部件合併、無骨骼動畫等現實問題。本專案的目標是:
層級選型應用框架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 決定,避免「點了沒反應」。







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 關鍵字,很容易把橫跨半個車身的合併網格誤判為車門,旋轉時整塊車身跟著轉——體驗災難。
核心邏輯在 src/lib/asset-car-rig.ts 的 discoverAssetCarRig():
isLocalizedPanel() 拒絕 footprint 覆蓋大部分車身的 mesh;DOOR_EXCLUDE 等正則過濾尾燈、內裝、擋風玻璃等誤匹配;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 結構體,包含:
capabilities 標誌位,驅動 UI 按鈕啟用 / 禁用。GLB 模型本身沒有可點擊的 DOM,因此在 AssetInteractionZones 中為每個 pivot 生成透明 Box 碰撞體,綁定 onClick 與 pointer 樣式,實現 3D 場景內直接點門開門。
沒有廠商級開門動畫時,只能對識別出的 mesh 群組做程式化變換。難點在於:
車門 / 後行李廂:發現階段將相關 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() 動態調節材質的 emissive 與 emissiveIntensity,並針對頭燈鏡片設定 toneMapped: false 以獲得 HDR 輝光感。尾燈刻意限制 tailMax,避免高亮度過曝成白色。
不同來源的 GLB 尺度差異巨大(FBX 匯出常見 scale=0.01),朝向也不一致(多數 Z 軸朝前,而展廳相機預設假設 -X 為車頭)。若不做歸一化,切換車型時機位、地面接觸、orbit 距離全部失效。
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;六種預設機位(全景 / 前臉 / 側視 / 車尾 / 駕駛艙)若寫死座標,換車型後必然構圖失調;自動環車巡檢又需要在手動 orbit 與程式化路徑之間平滑切換。
showroom-camera.ts 中 getBoundsCameraPose() 根據 bounds 的 size 與 center 按比例計算機位:
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,保證大小車型都能合理縮放。
@react-three/drei 的 <Environment preset="city" /> 會從 CDN 載 HDR,內網 / 離線部署易失敗導致畫面全黑。同時需要影棚 / 白天 / 夜晚三套差異明顯的燈光與地面表現。
ShowroomImageBasedLighting 使用 Three.js 內建 RoomEnvironment + PMREMGenerator 在本地生成環境貼圖,零外部 CDN 依賴。
showroom-scene-modes.ts 將三套模式的背景色、霧效、環境光強度、地面金屬度 / 粗糙度、頭燈聚光強度等收斂為 SHOWROOM_SCENE_MODES 配置物件,CarShowroomScene 與 ShowroomReflectiveFloor 統一消費,避免燈光與地板不一致。
夜晚模式配合 MeshReflectorMaterial(@react-three/drei)實現濕地反射;根據 performanceTier 動態降低反射貼圖解析度,兼顧行動端。
機制作用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 全開,保證展示功能完整。
希望 ?model=suv&paint=midnight&camera=front&mode=night 可分享、可重新整理還原;但 Next.js SSR 首屏不能讀 window.location,否則 hydration 不一致。
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平衡品質與效能---
public/models/market/;src/lib/car-categories.ts 註冊 URL;market-rig-profiles.ts 增加 MarketRigProfile;詳細命名約定見 market-glb-rig.md。
asset-car-rig.ts 擴展 AssetCarRig 與 discoverAssetCarRig 掃描邏輯;AssetModel 的 useFrame 中驅動動畫;page.tsx 增加狀態欄位與按鈕,透過 supportsInteraction() 門控。paintMaterials,不會影響燈罩玻璃材質;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 工程實作合集,含效能優化、資源管理與渲染技巧