3D場景實(shí)戰(zhàn):從偽3D到真三維交互)
簡介這份資源是面向前端開發(fā)者與3D可視化學(xué)習(xí)者的Vue Three.js智慧園區(qū)實(shí)戰(zhàn)項(xiàng)目適合具備Vue基礎(chǔ)、希望進(jìn)階WebGL三維場景開發(fā)的中級(jí)工程師。項(xiàng)目以Three.js完成場景、相機(jī)、渲染器搭建結(jié)合Vue組件化組織頁面構(gòu)建出可展示電力監(jiān)測、水力監(jiān)測等設(shè)備的立體園區(qū)模型并配有動(dòng)態(tài)交互效果可用于智慧城市、園區(qū)管理類可視化界面的學(xué)習(xí)與二次開發(fā)。壓縮包共67個(gè)文件約35.33MB包含21個(gè)js腳本、5個(gè)vue組件、3個(gè)json配置、3個(gè)css樣式以及24張png貼圖、1個(gè)glb模型、2個(gè)wasm與3段mp4演示視頻資源結(jié)構(gòu)覆蓋模型、貼圖、路由與狀態(tài)管理等模塊。目前已有2436人學(xué)習(xí)下載。通過完整源碼與目錄組織讀者可掌握Three.js模型加載、光照材質(zhì)設(shè)置、Vue與三維場景通信等關(guān)鍵實(shí)現(xiàn)并借鑒其工程化組織方式快速搭建自己的3D可視化項(xiàng)目。1. 智慧園區(qū)大屏為什么總像貼圖從一次翻車現(xiàn)場說起去年幫一個(gè)做園區(qū)運(yùn)營的朋友看他們新上的可視化大屏頁面打開挺唬人樓棟、道路、綠化帶一應(yīng)俱全鼠標(biāo)一拖還能轉(zhuǎn)。但轉(zhuǎn)兩下就露餡了樓是平的車是貼上去的點(diǎn)一棟樓彈出來的還是寫死的假數(shù)據(jù)。這種“偽 3D”在智慧園區(qū)項(xiàng)目里太常見了本質(zhì)就是拿幾張等距視角的 PNG 拼在一起看著像三維實(shí)際連個(gè)正經(jīng)相機(jī)都沒有。真正能用的智慧園區(qū)前端 3D 場景得讓瀏覽器里跑起一個(gè)真實(shí)的三維世界有透視相機(jī)、有光照、有可拾取的獨(dú)立模型樓棟能按樓層高亮設(shè)備能按狀態(tài)變色數(shù)據(jù)能實(shí)時(shí)掛到三維坐標(biāo)上。這套 Vue Three.js 的組合就是干這個(gè)的——Vue 管數(shù)據(jù)流和組件生命周期Three.js 管場景渲染和交互拾取。適合誰適合手里已經(jīng)有園區(qū) GIS 數(shù)據(jù)或 CAD 底圖、需要在 Web 端做輕量化三維呈現(xiàn)的前端也適合想從 ECharts 大屏往真三維遷移的團(tuán)隊(duì)。下面按我實(shí)際拆包復(fù)現(xiàn)的順序把這份資源從環(huán)境搭到進(jìn)階技巧走一遍。2. 環(huán)境搭建與場景初始化把 Three.js 塞進(jìn) Vue 組件2.1 為什么不用現(xiàn)成的三維大屏模板市面上不少“智慧園區(qū)模板”是拿一張俯視圖當(dāng)背景上面浮幾個(gè) CSS 動(dòng)畫圖標(biāo)改起來快但擴(kuò)展性極差——想加個(gè)樓棟剖切、想按設(shè)備類型篩選全得推倒重來。這份資源走的是原生 Three.js 渲染管線場景里的每個(gè)樓棟、每條道路都是獨(dú)立 Mesh意味著你能對(duì)任意對(duì)象做射線拾取、材質(zhì)替換、坐標(biāo)查詢。代價(jià)是初始化代碼比模板多但后面每加一個(gè)交互都不用求人。選 Vue 而不是純 HTML 的原因也簡單園區(qū)場景里大量狀態(tài)是響應(yīng)式的——選中樓棟變了要高亮、告警數(shù)量變了要更新標(biāo)簽、視角切換要聯(lián)動(dòng)側(cè)邊欄。用 Vue 的ref和watch管這些狀態(tài)比手動(dòng)操作 DOM 清爽得多。Three.js 場景本身放在onMounted里初始化onUnmounted里銷毀避免組件切換時(shí) WebGL 上下文泄漏——這個(gè)坑后面會(huì)細(xì)說。2.2 依賴安裝與最小可運(yùn)行場景先建 Vue 3 項(xiàng)目裝 Three.js。版本上我一般鎖three0.160.x附近太新的版本某些后處理插件還沒跟上太老的又缺WebGLRenderer的色調(diào)映射改進(jìn)。# 創(chuàng)建 Vue 3 項(xiàng)目選 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 里引不用額外裝包接著寫一個(gè)最小的場景組件。核心就四樣?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() { // 場景所有三維對(duì)象的容器 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)格給場景一個(gè)空間參照 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() // 主動(dòng)釋放 WebGL 上下文 }) /script template div refcontainer classscene-container/div /template style scoped .scene-container { width: 100%; height: 100vh; overflow: hidden; } /style這段代碼里幾個(gè)參數(shù)值得說清楚。PerspectiveCamera的fov設(shè) 45 而不是默認(rèn) 50是因?yàn)閳@區(qū)場景視野大fov 太廣邊緣畸變明顯near設(shè) 0.1 保證貼近樓棟時(shí)不穿模far設(shè) 2000 覆蓋整個(gè)園區(qū)尺度。setPixelRatio用Math.min卡在 2是因?yàn)?4K 屏上像素比到 3 甚至 4渲染面積翻幾倍幀率直接掉到 20 以下——這是大屏項(xiàng)目最常見的性能翻車點(diǎn)。onUnmounted里三連銷毀cancel、dispose、forceContextLoss缺一不可只 cancel 動(dòng)畫不釋放上下文來回切路由十幾次瀏覽器就報(bào)“Too many active WebGL contexts”。3. 園區(qū)模型加載與數(shù)據(jù)綁定讓樓棟和設(shè)備活起來3.1 模型格式選型glTF 為什么是默認(rèn)答案園區(qū)三維模型來源通常是建模軟件導(dǎo)出格式選擇上我踩過的坑夠?qū)懸豁摷垺BJ 不帶材質(zhì)和層級(jí)導(dǎo)進(jìn)來一片灰FBX 體積大、解析慢瀏覽器端加載經(jīng)??ㄋ纆lTF.glb/.gltf是 Khronos 定的 Web 三維傳輸格式二進(jìn)制打包、支持 PBR 材質(zhì)、保留節(jié)點(diǎn)層級(jí)Three.js 官方加載器也最完善。這份資源用的就是 glb單個(gè)文件包含幾何、材質(zhì)、貼圖丟進(jìn)public/models/直接引。模型本身有個(gè)硬要求樓棟、設(shè)備、道路這些需要單獨(dú)交互的對(duì)象在建模階段就得拆成獨(dú)立節(jié)點(diǎn)并命名。比如Building_A、Device_001別合并成一個(gè)整體。合并了后期想單獨(dú)高亮某棟樓只能靠頂點(diǎn)著色器硬算得不償失。3.2 加載 glb 并建立對(duì)象索引import { GLTFLoader } from three/examples/jsm/loaders/GLTFLoader.js // 存放所有可交互對(duì)象的索引表key 是模型里的節(jié)點(diǎn)名 const interactables new Map() function loadParkModel() { const loader new GLTFLoader() loader.load(/models/park.glb, (gltf) { const model gltf.scene // 遍歷所有子節(jié)點(diǎn)把命名對(duì)象登記進(jìn)索引表 model.traverse((child) { if (child.isMesh) { child.castShadow true child.receiveShadow true // 用節(jié)點(diǎn)名做 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é)點(diǎn)和燈光。索引表用Map而不是數(shù)組因?yàn)楹竺姘疵植闃菞澥歉哳l操作Map.get是 O(1)??s放那行是血淚經(jīng)驗(yàn)不同建模軟件導(dǎo)出單位不統(tǒng)一有的按厘米導(dǎo)進(jìn)來樓比相機(jī)還大整個(gè)場景比例全亂。我一般先看模型包圍盒尺寸再?zèng)Q定縮放系數(shù)而不是無腦0.01。3.3 把業(yè)務(wù)數(shù)據(jù)掛到三維對(duì)象上模型加載完只是空殼真正讓它“智慧”的是數(shù)據(jù)綁定。園區(qū)場景里典型的數(shù)據(jù)有三類樓棟的運(yùn)營指標(biāo)入駐率、能耗、設(shè)備的實(shí)時(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 上射線拾取時(shí)直接取 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 } }) }這里有個(gè)容易翻車的細(xì)節(jié)mesh.material.clone()。glb 里多個(gè)樓棟可能共用同一個(gè)材質(zhì)實(shí)例直接改mesh.material.color會(huì)導(dǎo)致所有用這個(gè)材質(zhì)的樓棟一起變色??寺∫环菰俑闹挥绊懏?dāng)前對(duì)象。emissive是自發(fā)光屬性配合emissiveIntensity做告警紅光比直接改color更有“亮起來”的視覺沖擊大屏上遠(yuǎn)看也醒目。4. 交互與拾取點(diǎn)擊樓棟彈窗、懸停高亮的實(shí)現(xiàn)4.1 射線拾取的基本原理三維場景里的“點(diǎn)擊”和 DOM 不是一回事。屏幕上點(diǎn)一個(gè)像素要反推它對(duì)應(yīng)場景里哪個(gè)物體靠的是射線拾取Raycaster從相機(jī)位置穿過鼠標(biāo)點(diǎn)發(fā)一條射線和場景里所有物體求交返回最近的交點(diǎn)。Three.js 的Raycaster封裝了這套計(jì)算但坐標(biāo)轉(zhuǎn)換必須做對(duì)——鼠標(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) // 只對(duì)可交互對(duì)象求交別拿整個(gè)場景算性能差十倍 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第二個(gè)參數(shù)recursive設(shè)false因?yàn)槲覀兯饕砝锎娴囊呀?jīng)是具體 Mesh不需要再往下遞歸子節(jié)點(diǎn)設(shè)true會(huì)多算很多無用層級(jí)。高亮前先遍歷清空emissive否則鼠標(biāo)劃過一片樓棟會(huì)留下一串“亮過的”痕跡這是新手最常被 QA 提的 bug。4.2 點(diǎn)擊彈窗與 Vue 狀態(tài)聯(lián)動(dòng)拾取到對(duì)象后把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 } } // 掛載時(shí)綁定卸載時(shí)解綁防止內(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)聽一定要成對(duì)解綁。我見過一個(gè)項(xiàng)目場景組件銷毀了但監(jiān)聽還掛在 canvas 上切了五次頁面后每次點(diǎn)擊觸發(fā)五遍彈窗查了半天才發(fā)現(xiàn)是監(jiān)聽沒清。pointermove做懸停高亮?xí)r如果場景對(duì)象多建議加節(jié)流比如每 50ms 才做一次射線檢測不然鼠標(biāo)一動(dòng)就滿幀計(jì)算低端機(jī)直接卡成幻燈片。5. 避坑與排查五個(gè)讓場景崩掉的真實(shí)問題5.1 現(xiàn)象切路由回來場景全黑控制臺(tái)報(bào)上下文丟失原因onUnmounted里只調(diào)了renderer.dispose()沒有forceContextLoss()。瀏覽器對(duì)同時(shí)活躍的 WebGL 上下文數(shù)量有限制通常 8 到 16 個(gè)舊上下文沒釋放新的創(chuàng)建失敗。解決卸載時(shí)按順序執(zhí)行cancelAnimationFrame→controls.dispose()→renderer.dispose()→renderer.forceContextLoss()并把renderer.domElement從 DOM 里移除。四步缺一不可。5.2 現(xiàn)象模型加載出來是純黑材質(zhì)貼圖全丟原因glb 里的貼圖路徑是相對(duì)路徑打包后路徑變了或者渲染器沒開色彩空間管理PBR 材質(zhì)顯示異常。解決確認(rèn) glb 是自包含的貼圖內(nèi)嵌別用外部引用貼圖的 gltf。渲染器上設(shè)renderer.outputColorSpace THREE.SRGBColorSpaceThree.js 新版本默認(rèn)已是 sRGB但老項(xiàng)目升級(jí)上來常忘。另外檢查場景里有沒有加光源只有環(huán)境光強(qiáng)度太低也會(huì)顯得黑。5.3 現(xiàn)象鼠標(biāo)懸停高亮錯(cuò)位點(diǎn) A 樓亮 B 樓原因canvas 尺寸和 CSS 顯示尺寸不一致getBoundingClientRect拿到的寬高和renderer.setSize設(shè)的不一樣歸一化坐標(biāo)算錯(cuò)。解決監(jiān)聽容器resize同步更新camera.aspect、camera.updateProjectionMatrix()和renderer.setSize()。別用固定像素值用容器的clientWidth/clientHeight。5.4 現(xiàn)象大屏上幀率只有 15風(fēng)扇狂轉(zhuǎn)原因setPixelRatio沒限制高分屏上渲染分辨率是邏輯分辨率的兩三倍或者場景里對(duì)象太多沒做視錐剔除。解決setPixelRatio(Math.min(devicePixelRatio, 2))卡上限。對(duì)象超過幾百個(gè)時(shí)考慮用InstancedMesh合并同類設(shè)備或者按相機(jī)距離做 LOD遠(yuǎn)處用低模。陰影貼圖分辨率也別開太高2048夠用4096是性能殺手。5.5 現(xiàn)象彈窗數(shù)據(jù)對(duì)不上點(diǎn)樓棟顯示的是別的樓原因userData被多個(gè)對(duì)象共享引用或者模型節(jié)點(diǎn)名重復(fù)Map索引時(shí)后面的覆蓋了前面的。解決綁定數(shù)據(jù)前先console.log檢查interactables.size和模型實(shí)際節(jié)點(diǎn)數(shù)是否一致。建模階段就要求節(jié)點(diǎn)名唯一別出現(xiàn)兩個(gè)Building_A。userData賦值用展開運(yùn)算符新建對(duì)象別直接賦同一個(gè)引用。6. 進(jìn)階技巧用 CSS2DRenderer 做不隨視角旋轉(zhuǎn)的標(biāo)簽三維場景里掛文字標(biāo)簽直接用Sprite或貼圖文字的話相機(jī)一轉(zhuǎn)標(biāo)簽就跟著歪大屏上看著很別扭。園區(qū)項(xiàng)目里樓棟名稱、設(shè)備編號(hào)這類標(biāo)簽業(yè)務(wù)上要求始終正對(duì)屏幕這時(shí)候用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)簽層會(huì)擋住 canvas射線拾取全失效點(diǎn)擊樓棟沒反應(yīng)。標(biāo)簽的 CSS 里加transform: translate(-50%, -100%)讓文字底部對(duì)準(zhǔn)坐標(biāo)點(diǎn)視覺上更貼樓頂。標(biāo)簽數(shù)量多的時(shí)候比如幾百個(gè)設(shè)備編號(hào)別每個(gè)都建 DOM按相機(jī)距離做顯隱遠(yuǎn)處的不渲染不然 DOM 節(jié)點(diǎn)太多瀏覽器一樣卡。還有個(gè)實(shí)用技巧標(biāo)簽內(nèi)容隨數(shù)據(jù)變。把div的引用存進(jìn)userData數(shù)據(jù)更新時(shí)直接改div.textContent不用重建CSS2DObject。我一般會(huì)在bindDataToModel里順手把標(biāo)簽 DOM 也掛上數(shù)據(jù)一變標(biāo)簽跟著變省得兩套邏輯各管各的。從那以后我每次初始化三維場景都強(qiáng)制走一遍「銷毀四連 像素比卡 2 標(biāo)簽層 pointerEvents 關(guān)掉」這三件事少一步后面就得花半天排查。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取