3D場景實戰(zhàn):從偽3D到真三維交互)
簡介這份資源是面向前端開發(fā)者與3D可視化學(xué)習(xí)者的Vue Three.js智慧園區(qū)實戰(zhàn)項目適合具備Vue基礎(chǔ)、希望進(jìn)階WebGL三維場景開發(fā)的中級工程師。項目以Three.js完成場景、相機(jī)、渲染器搭建結(jié)合Vue組件化組織頁面構(gòu)建出可展示電力監(jiān)測、水力監(jiān)測等設(shè)備的立體園區(qū)模型并配有動態(tài)交互效果可用于智慧城市、園區(qū)管理類可視化界面的學(xué)習(xí)與二次開發(fā)。壓縮包共67個文件約35.33MB包含21個js腳本、5個vue組件、3個json配置、3個css樣式以及24張png貼圖、1個glb模型、2個wasm與3段mp4演示視頻資源結(jié)構(gòu)覆蓋模型、貼圖、路由與狀態(tài)管理等模塊。目前已有2436人學(xué)習(xí)下載。通過完整源碼與目錄組織讀者可掌握Three.js模型加載、光照材質(zhì)設(shè)置、Vue與三維場景通信等關(guān)鍵實現(xiàn)并借鑒其工程化組織方式快速搭建自己的3D可視化項目。1. 智慧園區(qū)大屏為什么總像貼圖從一次翻車現(xiàn)場說起去年幫一個做園區(qū)運營的朋友看他們新上的可視化大屏頁面打開挺唬人樓棟、道路、綠化帶一應(yīng)俱全鼠標(biāo)一拖還能轉(zhuǎn)。但轉(zhuǎn)兩下就露餡了樓是平的車是貼上去的點一棟樓彈出來的還是寫死的假數(shù)據(jù)。這種“偽 3D”在智慧園區(qū)項目里太常見了本質(zhì)就是拿幾張等距視角的 PNG 拼在一起看著像三維實際連個正經(jīng)相機(jī)都沒有。真正能用的智慧園區(qū)前端 3D 場景得讓瀏覽器里跑起一個真實的三維世界有透視相機(jī)、有光照、有可拾取的獨立模型樓棟能按樓層高亮設(shè)備能按狀態(tài)變色數(shù)據(jù)能實時掛到三維坐標(biāo)上。這套 Vue Three.js 的組合就是干這個的——Vue 管數(shù)據(jù)流和組件生命周期Three.js 管場景渲染和交互拾取。適合誰適合手里已經(jīng)有園區(qū) GIS 數(shù)據(jù)或 CAD 底圖、需要在 Web 端做輕量化三維呈現(xiàn)的前端也適合想從 ECharts 大屏往真三維遷移的團(tuán)隊。下面按我實際拆包復(fù)現(xiàn)的順序把這份資源從環(huán)境搭到進(jìn)階技巧走一遍。2. 環(huán)境搭建與場景初始化把 Three.js 塞進(jìn) Vue 組件2.1 為什么不用現(xiàn)成的三維大屏模板市面上不少“智慧園區(qū)模板”是拿一張俯視圖當(dāng)背景上面浮幾個 CSS 動畫圖標(biāo)改起來快但擴(kuò)展性極差——想加個樓棟剖切、想按設(shè)備類型篩選全得推倒重來。這份資源走的是原生 Three.js 渲染管線場景里的每個樓棟、每條道路都是獨立 Mesh意味著你能對任意對象做射線拾取、材質(zhì)替換、坐標(biāo)查詢。代價是初始化代碼比模板多但后面每加一個交互都不用求人。選 Vue 而不是純 HTML 的原因也簡單園區(qū)場景里大量狀態(tài)是響應(yīng)式的——選中樓棟變了要高亮、告警數(shù)量變了要更新標(biāo)簽、視角切換要聯(lián)動側(cè)邊欄。用 Vue 的ref和watch管這些狀態(tài)比手動操作 DOM 清爽得多。Three.js 場景本身放在onMounted里初始化onUnmounted里銷毀避免組件切換時 WebGL 上下文泄漏——這個坑后面會細(xì)說。2.2 依賴安裝與最小可運行場景先建 Vue 3 項目裝 Three.js。版本上我一般鎖three0.160.x附近太新的版本某些后處理插件還沒跟上太老的又缺WebGLRenderer的色調(diào)映射改進(jìn)。# 創(chuàng)建 Vue 3 項目選 Vite 構(gòu)建 npm create vitelatest smart-park-3d -- --template vue cd smart-park-3d # 安裝 Three.js 核心庫與類型聲明 npm install three npm install -D types/three # 如果要用軌道控制器從 examples 里引不用額外裝包接著寫一個最小的場景組件。核心就四樣?xùn)|西場景Scene、相機(jī)Camera、渲染器Renderer、渲染循環(huán)。// src/components/ParkScene.vue script setup import { ref, onMounted, onUnmounted } from vue import * as THREE from three import { OrbitControls } from three/examples/jsm/controls/OrbitControls.js const container ref(null) let scene, camera, renderer, controls, animationId function initScene() { // 場景所有三維對象的容器 scene new THREE.Scene() scene.background new THREE.Color(0x0a1a2f) // 深藍(lán)底園區(qū)夜景常用 // 透視相機(jī)fov 45 接近人眼near/far 決定可見范圍 const { clientWidth: w, clientHeight: h } container.value camera new THREE.PerspectiveCamera(45, w / h, 0.1, 2000) camera.position.set(120, 90, 120) // 等距視角園區(qū)全景常用機(jī)位 camera.lookAt(0, 0, 0) // 渲染器開抗鋸齒限制像素比防止高分屏卡頓 renderer new THREE.WebGLRenderer({ antialias: true }) renderer.setSize(w, h) renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) container.value.appendChild(renderer.domElement) // 軌道控制器允許旋轉(zhuǎn)、縮放、平移 controls new OrbitControls(camera, renderer.domElement) controls.enableDamping true // 慣性阻尼手感更順 controls.maxPolarAngle Math.PI / 2.2 // 禁止轉(zhuǎn)到地平面以下 // 環(huán)境光 平行光沒有光模型就是純黑 scene.add(new THREE.AmbientLight(0xffffff, 0.6)) const dirLight new THREE.DirectionalLight(0xffffff, 1.2) dirLight.position.set(80, 150, 60) scene.add(dirLight) // 地面網(wǎng)格給場景一個空間參照 const grid new THREE.GridHelper(400, 40, 0x1e5a8a, 0x123a5a) scene.add(grid) animate() } function animate() { animationId requestAnimationFrame(animate) controls.update() // 阻尼開啟后必須每幀調(diào)用 renderer.render(scene, camera) } onMounted(initScene) onUnmounted(() { cancelAnimationFrame(animationId) controls.dispose() renderer.dispose() renderer.forceContextLoss() // 主動釋放 WebGL 上下文 }) /script template div refcontainer classscene-container/div /template style scoped .scene-container { width: 100%; height: 100vh; overflow: hidden; } /style這段代碼里幾個參數(shù)值得說清楚。PerspectiveCamera的fov設(shè) 45 而不是默認(rèn) 50是因為園區(qū)場景視野大fov 太廣邊緣畸變明顯near設(shè) 0.1 保證貼近樓棟時不穿模far設(shè) 2000 覆蓋整個園區(qū)尺度。setPixelRatio用Math.min卡在 2是因為 4K 屏上像素比到 3 甚至 4渲染面積翻幾倍幀率直接掉到 20 以下——這是大屏項目最常見的性能翻車點。onUnmounted里三連銷毀cancel、dispose、forceContextLoss缺一不可只 cancel 動畫不釋放上下文來回切路由十幾次瀏覽器就報“Too many active WebGL contexts”。3. 園區(qū)模型加載與數(shù)據(jù)綁定讓樓棟和設(shè)備活起來3.1 模型格式選型glTF 為什么是默認(rèn)答案園區(qū)三維模型來源通常是建模軟件導(dǎo)出格式選擇上我踩過的坑夠?qū)懸豁摷?。OBJ 不帶材質(zhì)和層級導(dǎo)進(jìn)來一片灰FBX 體積大、解析慢瀏覽器端加載經(jīng)??ㄋ纆lTF.glb/.gltf是 Khronos 定的 Web 三維傳輸格式二進(jìn)制打包、支持 PBR 材質(zhì)、保留節(jié)點層級Three.js 官方加載器也最完善。這份資源用的就是 glb單個文件包含幾何、材質(zhì)、貼圖丟進(jìn)public/models/直接引。模型本身有個硬要求樓棟、設(shè)備、道路這些需要單獨交互的對象在建模階段就得拆成獨立節(jié)點并命名。比如Building_A、Device_001別合并成一個整體。合并了后期想單獨高亮某棟樓只能靠頂點著色器硬算得不償失。3.2 加載 glb 并建立對象索引import { GLTFLoader } from three/examples/jsm/loaders/GLTFLoader.js // 存放所有可交互對象的索引表key 是模型里的節(jié)點名 const interactables new Map() function loadParkModel() { const loader new GLTFLoader() loader.load(/models/park.glb, (gltf) { const model gltf.scene // 遍歷所有子節(jié)點把命名對象登記進(jìn)索引表 model.traverse((child) { if (child.isMesh) { child.castShadow true child.receiveShadow true // 用節(jié)點名做 key方便按業(yè)務(wù) ID 查找 if (child.name) interactables.set(child.name, child) } }) // 統(tǒng)一縮放建模單位可能是厘米Three.js 默認(rèn)按米 model.scale.set(0.01, 0.01, 0.01) scene.add(model) }, undefined, (err) { console.error(模型加載失敗, err) }) }traverse是遍歷場景圖的標(biāo)準(zhǔn)方法isMesh過濾掉空節(jié)點和燈光。索引表用Map而不是數(shù)組因為后面按名字查樓棟是高頻操作Map.get是 O(1)??s放那行是血淚經(jīng)驗不同建模軟件導(dǎo)出單位不統(tǒng)一有的按厘米導(dǎo)進(jìn)來樓比相機(jī)還大整個場景比例全亂。我一般先看模型包圍盒尺寸再決定縮放系數(shù)而不是無腦0.01。3.3 把業(yè)務(wù)數(shù)據(jù)掛到三維對象上模型加載完只是空殼真正讓它“智慧”的是數(shù)據(jù)綁定。園區(qū)場景里典型的數(shù)據(jù)有三類樓棟的運營指標(biāo)入駐率、能耗、設(shè)備的實時狀態(tài)在線、告警、離線、人員的定位坐標(biāo)。綁定方式我推薦用userData掛載而不是全局變量。// 模擬從接口拿到的樓棟數(shù)據(jù) const buildingData [ { id: Building_A, name: A棟研發(fā)樓, occupancy: 0.92, status: normal }, { id: Building_B, name: B棟廠房, occupancy: 0.68, status: warning }, ] function bindDataToModel(dataList) { dataList.forEach((item) { const mesh interactables.get(item.id) if (!mesh) return // 把業(yè)務(wù)數(shù)據(jù)掛到 mesh 上射線拾取時直接取 mesh.userData { ...item } // 按狀態(tài)改材質(zhì)顏色告警樓棟染紅 if (item.status warning) { mesh.material mesh.material.clone() // 克隆避免影響共享材質(zhì) mesh.material.emissive new THREE.Color(0xff4444) mesh.material.emissiveIntensity 0.4 } }) }這里有個容易翻車的細(xì)節(jié)mesh.material.clone()。glb 里多個樓棟可能共用同一個材質(zhì)實例直接改mesh.material.color會導(dǎo)致所有用這個材質(zhì)的樓棟一起變色??寺∫环菰俑闹挥绊懏?dāng)前對象。emissive是自發(fā)光屬性配合emissiveIntensity做告警紅光比直接改color更有“亮起來”的視覺沖擊大屏上遠(yuǎn)看也醒目。4. 交互與拾取點擊樓棟彈窗、懸停高亮的實現(xiàn)4.1 射線拾取的基本原理三維場景里的“點擊”和 DOM 不是一回事。屏幕上點一個像素要反推它對應(yīng)場景里哪個物體靠的是射線拾取Raycaster從相機(jī)位置穿過鼠標(biāo)點發(fā)一條射線和場景里所有物體求交返回最近的交點。Three.js 的Raycaster封裝了這套計算但坐標(biāo)轉(zhuǎn)換必須做對——鼠標(biāo)坐標(biāo)是屏幕像素得歸一化到-1到1之間。const raycaster new THREE.Raycaster() const pointer new THREE.Vector2() function onPointerMove(event) { const rect renderer.domElement.getBoundingClientRect() // 歸一化坐標(biāo)屏幕像素轉(zhuǎn) NDC pointer.x ((event.clientX - rect.left) / rect.width) * 2 - 1 pointer.y -((event.clientY - rect.top) / rect.height) * 2 1 raycaster.setFromCamera(pointer, camera) // 只對可交互對象求交別拿整個場景算性能差十倍 const targets [...interactables.values()] const hits raycaster.intersectObjects(targets, false) // 先清掉上一幀的高亮 targets.forEach((m) m.material?.emissive?.set(0x000000)) if (hits.length 0) { const hit hits[0].object hit.material.emissive new THREE.Color(0x2266ff) hit.material.emissiveIntensity 0.5 renderer.domElement.style.cursor pointer } else { renderer.domElement.style.cursor default } }intersectObjects第二個參數(shù)recursive設(shè)false因為我們索引表里存的已經(jīng)是具體 Mesh不需要再往下遞歸子節(jié)點設(shè)true會多算很多無用層級。高亮前先遍歷清空emissive否則鼠標(biāo)劃過一片樓棟會留下一串“亮過的”痕跡這是新手最常被 QA 提的 bug。4.2 點擊彈窗與 Vue 狀態(tài)聯(lián)動拾取到對象后把userData里的業(yè)務(wù)數(shù)據(jù)拋給 Vue 層由 Vue 控制彈窗顯示。這樣三維邏輯和 UI 邏輯解耦彈窗樣式隨便改不影響場景。const emit defineEmits([select-building]) const selectedInfo ref(null) function onClick(event) { const rect renderer.domElement.getBoundingClientRect() pointer.x ((event.clientX - rect.left) / rect.width) * 2 - 1 pointer.y -((event.clientY - rect.top) / rect.height) * 2 1 raycaster.setFromCamera(pointer, camera) const hits raycaster.intersectObjects([...interactables.values()], false) if (hits.length 0) { const data hits[0].object.userData selectedInfo.value data emit(select-building, data) // 通知父組件 } else { selectedInfo.value null } } // 掛載時綁定卸載時解綁防止內(nèi)存泄漏 onMounted(() { renderer.domElement.addEventListener(click, onClick) renderer.domElement.addEventListener(pointermove, onPointerMove) }) onUnmounted(() { renderer.domElement.removeEventListener(click, onClick) renderer.domElement.removeEventListener(pointermove, onPointerMove) })事件監(jiān)聽一定要成對解綁。我見過一個項目場景組件銷毀了但監(jiān)聽還掛在 canvas 上切了五次頁面后每次點擊觸發(fā)五遍彈窗查了半天才發(fā)現(xiàn)是監(jiān)聽沒清。pointermove做懸停高亮?xí)r如果場景對象多建議加節(jié)流比如每 50ms 才做一次射線檢測不然鼠標(biāo)一動就滿幀計算低端機(jī)直接卡成幻燈片。5. 避坑與排查五個讓場景崩掉的真實問題5.1 現(xiàn)象切路由回來場景全黑控制臺報上下文丟失原因onUnmounted里只調(diào)了renderer.dispose()沒有forceContextLoss()。瀏覽器對同時活躍的 WebGL 上下文數(shù)量有限制通常 8 到 16 個舊上下文沒釋放新的創(chuàng)建失敗。解決卸載時按順序執(zhí)行cancelAnimationFrame→controls.dispose()→renderer.dispose()→renderer.forceContextLoss()并把renderer.domElement從 DOM 里移除。四步缺一不可。5.2 現(xiàn)象模型加載出來是純黑材質(zhì)貼圖全丟原因glb 里的貼圖路徑是相對路徑打包后路徑變了或者渲染器沒開色彩空間管理PBR 材質(zhì)顯示異常。解決確認(rèn) glb 是自包含的貼圖內(nèi)嵌別用外部引用貼圖的 gltf。渲染器上設(shè)renderer.outputColorSpace THREE.SRGBColorSpaceThree.js 新版本默認(rèn)已是 sRGB但老項目升級上來常忘。另外檢查場景里有沒有加光源只有環(huán)境光強(qiáng)度太低也會顯得黑。5.3 現(xiàn)象鼠標(biāo)懸停高亮錯位點 A 樓亮 B 樓原因canvas 尺寸和 CSS 顯示尺寸不一致getBoundingClientRect拿到的寬高和renderer.setSize設(shè)的不一樣歸一化坐標(biāo)算錯。解決監(jiān)聽容器resize同步更新camera.aspect、camera.updateProjectionMatrix()和renderer.setSize()。別用固定像素值用容器的clientWidth/clientHeight。5.4 現(xiàn)象大屏上幀率只有 15風(fēng)扇狂轉(zhuǎn)原因setPixelRatio沒限制高分屏上渲染分辨率是邏輯分辨率的兩三倍或者場景里對象太多沒做視錐剔除。解決setPixelRatio(Math.min(devicePixelRatio, 2))卡上限。對象超過幾百個時考慮用InstancedMesh合并同類設(shè)備或者按相機(jī)距離做 LOD遠(yuǎn)處用低模。陰影貼圖分辨率也別開太高2048夠用4096是性能殺手。5.5 現(xiàn)象彈窗數(shù)據(jù)對不上點樓棟顯示的是別的樓原因userData被多個對象共享引用或者模型節(jié)點名重復(fù)Map索引時后面的覆蓋了前面的。解決綁定數(shù)據(jù)前先console.log檢查interactables.size和模型實際節(jié)點數(shù)是否一致。建模階段就要求節(jié)點名唯一別出現(xiàn)兩個Building_A。userData賦值用展開運算符新建對象別直接賦同一個引用。6. 進(jìn)階技巧用 CSS2DRenderer 做不隨視角旋轉(zhuǎn)的標(biāo)簽三維場景里掛文字標(biāo)簽直接用Sprite或貼圖文字的話相機(jī)一轉(zhuǎn)標(biāo)簽就跟著歪大屏上看著很別扭。園區(qū)項目里樓棟名稱、設(shè)備編號這類標(biāo)簽業(yè)務(wù)上要求始終正對屏幕這時候用CSS2DRenderer最省事——它把 DOM 元素按三維坐標(biāo)投影到屏幕上標(biāo)簽本身是普通 HTML字體清晰、樣式隨便調(diào)還不受相機(jī)旋轉(zhuǎn)影響。import { CSS2DRenderer, CSS2DObject } from three/examples/jsm/renderers/CSS2DRenderer.js let labelRenderer function initLabelRenderer() { labelRenderer new CSS2DRenderer() labelRenderer.setSize(container.value.clientWidth, container.value.clientHeight) // 關(guān)鍵標(biāo)簽層要蓋在 canvas 上且不能攔截鼠標(biāo)事件 labelRenderer.domElement.style.position absolute labelRenderer.domElement.style.top 0 labelRenderer.domElement.style.pointerEvents none container.value.appendChild(labelRenderer.domElement) } function addBuildingLabel(mesh, text) { const div document.createElement(div) div.className building-label div.textContent text const label new CSS2DObject(div) // 標(biāo)簽掛在樓棟頂部y 值取包圍盒高度 const box new THREE.Box3().setFromObject(mesh) label.position.set(0, box.max.y - box.min.y 5, 0) mesh.add(label) } // 渲染循環(huán)里同步渲染標(biāo)簽層 function animate() { animationId requestAnimationFrame(animate) controls.update() renderer.render(scene, camera) labelRenderer.render(scene, camera) // 別漏這行 }pointerEvents none是必須的否則標(biāo)簽層會擋住 canvas射線拾取全失效點擊樓棟沒反應(yīng)。標(biāo)簽的 CSS 里加transform: translate(-50%, -100%)讓文字底部對準(zhǔn)坐標(biāo)點視覺上更貼樓頂。標(biāo)簽數(shù)量多的時候比如幾百個設(shè)備編號別每個都建 DOM按相機(jī)距離做顯隱遠(yuǎn)處的不渲染不然 DOM 節(jié)點太多瀏覽器一樣卡。還有個實用技巧標(biāo)簽內(nèi)容隨數(shù)據(jù)變。把div的引用存進(jìn)userData數(shù)據(jù)更新時直接改div.textContent不用重建CSS2DObject。我一般會在bindDataToModel里順手把標(biāo)簽 DOM 也掛上數(shù)據(jù)一變標(biāo)簽跟著變省得兩套邏輯各管各的。從那以后我每次初始化三維場景都強(qiáng)制走一遍「銷毀四連 像素比卡 2 標(biāo)簽層 pointerEvents 關(guān)掉」這三件事少一步后面就得花半天排查。希望幫到你。本文還有配套的精品資源點擊獲取