實踐:從坐標(biāo)轉(zhuǎn)換到性能優(yōu)化)
簡介3D可視化技術(shù)正成為智慧校園與數(shù)字孿生場景的重要呈現(xiàn)方式。通過WebGL渲染引擎three.js開發(fā)者可以在瀏覽器中構(gòu)建交互式三維地圖。實現(xiàn)過程中經(jīng)緯度坐標(biāo)需經(jīng)局部坐標(biāo)系轉(zhuǎn)換以避免浮點精度引發(fā)的場景抖動地形可借助PlaneGeometry與噪聲函數(shù)生成建筑則通過ExtrudeGeometry拉伸并利用InstancedMesh合并繪制從而大幅降低draw call。該技術(shù)路徑不僅適用于校園也可推廣至園區(qū)、車間等小范圍場景甚至可與vue2百度的3D實景地圖模型加載相結(jié)合。掌握這些基礎(chǔ)原理與工程實踐即可高效完成從數(shù)據(jù)準(zhǔn)備到交互展示的完整流程。1. 項目定位與方案選擇為什么用 three.js 做 3D 地圖前陣子接到一個校園三維可視化需求要求把整個校區(qū)的地形、建筑、道路、水系在瀏覽器里做成可交互的 3D 場景。一提到 3D 地圖很多人第一反應(yīng)是 Cesium、Mapbox GL或者 UE5 像素流但綜合考慮項目預(yù)算、部署成本、前端技術(shù)棧之后我最終選擇了 three.js 作為核心渲染引擎。這篇博文就把整個實現(xiàn)過程拆開講一講從數(shù)據(jù)準(zhǔn)備到坐標(biāo)轉(zhuǎn)換從地形生成到建筑建模從交互拾取到性能優(yōu)化一條線走完。先說說為什么是 three.js 而不是其他方案。Cesium 主打全球尺度加載傾斜攝影、地形瓦片確實強但如果需要的只是一個園區(qū)、一個校區(qū)、一個廠區(qū)這種小范圍場景用 Cesium 就像殺雞用牛刀學(xué)習(xí)成本高、包體體積大、API 抽象層級也重。Mapbox GL 的地圖底圖渲染很強但它本身是 2.5D 的想在它上面自由做建筑體塊拉伸、做水流動畫、做自定義交互限制比較多。three.js 的好處是足夠底層也足夠靈活場景、網(wǎng)格、材質(zhì)、相機全部自己掌控做小范圍 3D 地圖再合適不過。它不叫“地圖引擎”但它能拼裝出你想要的一切地圖效果。這個方案適合誰來參考如果你正在做智慧校園、智慧園區(qū)、數(shù)字孿生車間、展廳大屏可視化或者單純想給自己的個人項目做一個炫酷的 3D 小地圖這篇文章里的思路和代碼都可以直接復(fù)用。接下來我會以一個校園 3D 地圖項目為案例背景把關(guān)鍵環(huán)節(jié)逐一拆解每一步該怎么做、為什么這么做、坑在哪里都會講清楚。2. 數(shù)據(jù)準(zhǔn)備與坐標(biāo)系轉(zhuǎn)換地圖數(shù)據(jù)怎么變成 three.js 場景2.1 先搞清楚數(shù)據(jù)從哪來做 3D 地圖第一步不是寫代碼而是搞數(shù)據(jù)。一個校園地圖需要的數(shù)據(jù)大致分四類地形數(shù)據(jù)地塊范圍、高程信息如果沒有精確 DEM 數(shù)據(jù)可以用噪聲函數(shù)模擬起伏。建筑數(shù)據(jù)建筑輪廓多邊形、建筑高度、樓層數(shù)通??梢詮?OpenStreetMap 導(dǎo)出 GeoJSON或者從規(guī)劃圖紙里人工繪制。道路與路網(wǎng)道路中心線或邊線同樣是 GeoJSON 格式。水系與綠地河流、湖泊、池塘的邊界以及綠化區(qū)域范圍。我用的是 OSM 導(dǎo)出的校園 GeoJSON加上自己補充的建筑高度字段。如果你在給一個具體園區(qū)做項目拿到 CAD 圖紙之后轉(zhuǎn)成 GeoJSON 也是常見路徑。注意OSM 的建筑數(shù)據(jù)常常缺高度需要人工調(diào)研或者按樓層數(shù)估算。我當(dāng)時是按“每層 3.5 米”乘樓層數(shù)算的誤差在可接受范圍內(nèi)。2.2 經(jīng)緯度坐標(biāo)怎么變成平面坐標(biāo)GeoJSON 里存的是經(jīng)緯度而 three.js 場景是一個三維笛卡爾坐標(biāo)系X 向右、Y 向上、Z 朝向屏幕外或者用 Z 表示南北方向。所以第一步必須做投影轉(zhuǎn)換。常用的做法是 Web Mercator 投影也就是把經(jīng)緯度轉(zhuǎn)成米制平面坐標(biāo)。不過實際項目里不需要引入很重的 GIS 庫簡單算一個局部切平面就夠了function lonLatToMercator(lon, lat) { const x lon * 20037508.34 / 180; let y Math.log(Math.tan((90 lat) * Math.PI / 360)) / (Math.PI / 180); y y * 20037508.34 / 180; return { x, y }; }這樣做完所有經(jīng)緯度點都變成了以米為單位的平面坐標(biāo)。但 web 墨卡托的數(shù)值非常大幾百萬的量級直接塞進 three.js 會觸發(fā)浮點數(shù)精度問題導(dǎo)致場景抖動、閃爍。解決辦法是“局部坐標(biāo)系化”把所有坐標(biāo)都減去中心點的坐標(biāo)讓場景原點附近的數(shù)值保持在幾千以內(nèi)。const center lonLatToMercator(centerLon, centerLat); const positions feature.geometry.coordinates[0].map(([lon, lat]) { const p lonLatToMercator(lon, lat); return { x: p.x - center.x, z: p.y - center.y }; });這里有個約定我把墨卡托的 y 放到了 three.js 的 z 軸上x 保持不變。這樣地形上的東西南北方向和經(jīng)緯網(wǎng)一致后續(xù)擺放模型、調(diào)整相機方向都符合直覺。2.3 為什么局部坐標(biāo)系能避免精度坑three.js 的相機、場景節(jié)點在渲染時會做大量矩陣運算坐標(biāo)值越大浮點數(shù)誤差越明顯。想象一下你在城市的另一端看一棟樓和站在樓底下看這棟樓同一個模型在空間里的表現(xiàn)截然不同。局部坐標(biāo)把項目范圍縮小到原點附近幾百米的場景里Float32 精度完全夠用。踩過這個坑之后我建議所有 GIS 數(shù)據(jù)進 three.js 之前都先做“去中心化”。別嫌麻煩這步不做后面模型抖動、貼圖閃爍會折騰到你想放棄。3. 地形生成與場景環(huán)境先把“地”搭起來3.1 場景初始化核心流程其實只有幾行代碼但每一步都要有意識地去配置。const scene new THREE.Scene(); const camera new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 10000); camera.position.set(300, 350, 500); const renderer new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.shadowMap.enabled true; document.body.appendChild(renderer.domElement);相機初始化位置我習(xí)慣放在地形的斜上方約 45 度俯視角看校園全景剛剛好??逛忼X必須打開否則地形邊界、建筑邊緣的鋸齒會非常明顯尤其是大屏投放場景鋸齒一放大就很廉價。OrbitControls 是必裝的輔助庫它解決了相機旋轉(zhuǎn)、縮放、平移的基礎(chǔ)交互。import { OrbitControls } from three/examples/jsm/controls/OrbitControls.js; const controls new OrbitControls(camera, renderer.domElement); controls.enableDamping true; controls.maxPolarAngle Math.PI / 2.4;maxPolarAngle 限制最大俯視角度防止用戶把視角轉(zhuǎn)到地面以下。enableDamping 打開慣性效果手感會順滑很多。3.2 用 PlaneGeometry 生成地形網(wǎng)格地形我直接用 PlaneGeometry 來生成分段數(shù)決定地形細膩度。一個 705 米 x 500 米的校園用 128 段 x 128 段頂點數(shù)約 1.6 萬個完全夠用。const width 800; const depth 600; const segments 128; const geometry new THREE.PlaneGeometry(width, depth, segments, segments); geometry.rotateX(-Math.PI / 2);注意 rotateX。PlaneGeometry 默認在 XY 平面旋轉(zhuǎn) -90 度后變成 XZ 平面Y 軸朝上這樣才符合 three.js 地形的通用做法。接下來要處理頂點高度。有真實 DEM 數(shù)據(jù)的直接讀取高程數(shù)組寫入頂點 Y 值。沒有數(shù)據(jù)的情況下我用簡單柏林噪聲來模擬地形起伏保證場景不死板。const positionAttribute geometry.attributes.position; for (let i 0; i positionAttribute.count; i) { const x positionAttribute.getX(i); const z positionAttribute.getZ(i); const height getNoiseHeight(x, z); positionAttribute.setY(i, height); } geometry.computeVertexNormals();computeVertexNormals 一定要調(diào)否則地形上光照表現(xiàn)是平的明暗變化完全沒有看起來像塑料片。3.3 頂點著色讓地形有質(zhì)感地形高度不只是幾何形態(tài)還可以映射成顏色。低洼處用藍綠色山丘用褐色頂部用灰白色這樣可以做出等高線般的視覺效果。const colors []; for (let i 0; i positionAttribute.count; i) { const y positionAttribute.getY(i); const t (y - minHeight) / (maxHeight - minHeight); const color new THREE.Color(); if (t 0.3) { color.setRGB(0.3, 0.6, 0.4); } else if (t 0.7) { color.setRGB(0.1, 0.4, 0.2); } else { color.setRGB(0.5, 0.5, 0.4); } colors.push(color.r, color.g, color.b); } geometry.setAttribute(color, new THREE.Float32BufferAttribute(colors, 3));用頂點色而不是貼圖的好處是不依賴外部圖片資源部署方便而且顏色隨地形動態(tài)變化時只需要改數(shù)組非常靈活。材質(zhì)記得開啟 vertexColors。const material new THREE.MeshStandardMaterial({ vertexColors: true }); const terrain new THREE.Mesh(geometry, material); scene.add(terrain);3.4 光照、霧和天空3D 地圖對光照要求不高但不打光不行。我習(xí)慣用一束平行光模擬太陽再加一個環(huán)境光補亮陰影面。const sunLight new THREE.DirectionalLight(0xffffff, 1.5); sunLight.position.set(200, 400, 300); scene.add(sunLight); const ambient new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambient);如果開了陰影地形要設(shè)置 receiveShadow建筑要設(shè)置 castShadow。但陰影會顯著增加渲染開銷移動端建議直接關(guān)掉用偽陰影替代。再給場景加一層淡霧一是弱化地平線的硬邊二是有一種空間縱深的感覺。scene.fog new THREE.Fog(0xf0f5e8, 800, 2500);4. 建筑、道路和水系的模型化從“地圖”到“場景”4.1 建筑用 ExtrudeGeometry 拉伸拿到建筑輪廓后最直接的做法是利用 ExtrudeGeometry 把多邊形拉伸成體塊。const shape new THREE.Shape(); polygon.forEach((point, i) { if (i 0) shape.moveTo(point.x, point.z); else shape.lineTo(point.x, point.z); }); shape.closePath(); const extrudeSettings { depth: buildingHeight, bevelEnabled: false }; const geometry new THREE.ExtrudeGeometry(shape, extrudeSettings); const mesh new THREE.Mesh(geometry, buildingMaterial);這里的 depth 就是樓高Y 軸方向向上拉伸。bevelEnabled 必須設(shè)成 false否則建筑棱邊會有斜角樓體看起來圓滾滾而且會大幅增加頂點數(shù)。建筑材質(zhì)可以做成“白天模式”和“夜晚模式”兩套白天用淺灰白加一點環(huán)境反射夜晚把窗戶區(qū)域做成自發(fā)光。我在項目里給建筑材質(zhì)單獨加了 emissive 屬性夜晚讓色塊微微泛光大屏視覺效果很不錯。4.2 建筑數(shù)量上百后怎么優(yōu)化校園里單體建筑四五十棟還好但如果做園區(qū)建筑上百棟逐個創(chuàng)建 Mesh 會導(dǎo)致 draw call 爆炸。優(yōu)化手段是用 InstancedMesh 批量渲染同種模型。不過 InstancedMesh 要求所有實例共享同一個幾何體建筑高度各不相同怎么辦我的做法是把建筑按高度分成幾檔每檔用一個幾何體然后用 InstancedMesh 渲染同一檔里的所有建筑。const groupedBuildings buildBuildingsByHeight(buildings, [8, 16, 24, 36]); for (const [height, list] of groupedBuildings) { const geometry createBuildingGeometry(height); const mesh new THREE.InstancedMesh(geometry, material, list.length); list.forEach((building, i) { const matrix new THREE.Matrix4(); matrix.setPosition(building.x, height / 2, building.z); matrix.scale(new THREE.Vector3(building.scaleX, 1, building.scaleZ)); mesh.setMatrixAt(i, matrix); }); scene.add(mesh); }這樣一搞上百棟建筑只產(chǎn)生幾次 draw call幀率壓力小得多。4.3 道路怎么做才不“飄”道路數(shù)據(jù)通常是線或者窄多邊形。最簡單的方式是把路網(wǎng)轉(zhuǎn)成 Line 對象配合 LineBasicMaterial 輸出。但如果想做出有質(zhì)感的道路我更推薦把道路兩邊線和中心線組合成多邊形然后貼到地形表面。最穩(wěn)妥的貼地做法是“偏移法”把道路多邊形每個頂點的高度采樣地形高度再手動加 0.3 米的偏移量。不要試圖讓道路物理貼合地形網(wǎng)格那樣徒增復(fù)雜度。我試過把道路做成獨立 Mesh 貼在模型上只要偏移值大于地形起伏俯視看不出破綻側(cè)面看也不會 z-fighting。水系同理用多邊形勾勒河岸、湖面邊界鋪一個透明材質(zhì)const waterMaterial new THREE.MeshStandardMaterial({ color: 0x3399ff, transparent: true, opacity: 0.7, });河岸邊緣多邊形的每個點同樣需要從地形采樣高度。這個步驟漏了的話水面就會在某處懸空或者穿到地下。4.4 河流流動怎么做純靜態(tài)水面總感覺差點意思。給水面加一個 UV 偏移做出流動方向感waterMaterial.onBeforeCompile (shader) { shader.uniforms.uTime { value: 0 }; shader.vertexShader uniform float uTime; varying vec2 vUv; ${shader.vertexShader} .replace( #include begin_vertex, #include begin_vertex vUv uv; float flow sin(uv.x * 10.0 uTime) * 0.02; transformed.y flow; ); };要點是讓水面頂點做小幅正弦抖動同時讓紋理采樣點隨時間偏移。做過之后河面有微微的波光流動整個場景立刻“活”了。5. 交互與標(biāo)注點擊、懸浮、彈窗和文字標(biāo)簽5.1 鼠標(biāo)拾取建筑地圖沒有交互就沒有靈魂。three.js 里點擊判斷用 Raycasterconst raycaster new THREE.Raycaster(); const mouse new THREE.Vector2(); function onMouseClick(event) { mouse.x (event.clientX / window.innerWidth) * 2 - 1; mouse.y -(event.clientY / window.innerHeight) * 2 1; raycaster.setFromCamera(mouse, camera); const intersects raycaster.intersectObjects(buildingMeshes); if (intersects.length 0) { const building intersects[0].object; showBuildingInfo(building); } }這里有個經(jīng)驗buildingMeshes 不要傳三維數(shù)組嵌套的對象要拍平成一維數(shù)組否則 Raycaster 遞歸遍歷會非常慢。建筑數(shù)量多的時候建議用三個參數(shù)快速篩選raycaster.intersectObjects(buildingMeshes, false)false 表示不遞歸子節(jié)點。5.2 標(biāo)注用 CSS2DRenderer 比 Sprite 強建筑上的名稱標(biāo)注很多人會想到 Sprite。Sprite 確實會用但文字一旦多了就糊成一片而且縮放距離和相機交互很難控制。我更推薦 CSS2DRenderer它是把 HTML 標(biāo)簽掛在 3D 坐標(biāo)上清晰度完勝也能很方便地加 CSS 樣式、hover 效果。import { CSS2DRenderer, CSS2DObject } from three/examples/jsm/renderers/CSS2DRenderer.js; const labelRenderer new CSS2DRenderer(); labelRenderer.setSize(window.innerWidth, window.innerHeight); labelRenderer.domElement.style.position absolute; labelRenderer.domElement.style.top 0; labelRenderer.domElement.style.pointerEvents none; document.body.appendChild(labelRenderer.domElement);注意 CSS2DRenderer 的 DOM 元素要關(guān)閉 pointerEvents否則它會在 three.js canvas 上面蓋一層擋住鼠標(biāo)事件導(dǎo)致點擊拾取失效。這個坑我踩了一次排查半天才發(fā)現(xiàn)是標(biāo)簽層擋住了畫布。創(chuàng)建標(biāo)注對象const div document.createElement(div); div.className label; div.textContent building.name; const label new CSS2DObject(div); label.position.set(building.x, building.height 5, building.z); scene.add(label);標(biāo)注渲染循環(huán)里必須同時調(diào)用 CSS2DRenderer 的 renderlabelRenderer.render(scene, camera);5.3 點擊建筑高亮與信息面板點擊高亮我用的是兩種方案結(jié)合一是給建筑材質(zhì)設(shè)置 emissive 提亮二是給選中建筑加一層外發(fā)光邊框。最簡單的是改 emissivebuilding.material.emissive.setHex(0xff8800);注意如果建筑是共享材質(zhì)直接改 material 會同時改變所有同材質(zhì)的建筑。所以必須 clone 一份材質(zhì)再改building.material building.material.clone(); building.material.emissive.setHex(0xff8800);信息面板就是普通的 HTML 絕對定位彈窗點擊建筑后顯示名稱、樓層數(shù)、面積、用途。HTML 做展示層的優(yōu)勢是排版、樣式、響應(yīng)式都容易控制不用在 three.js 里畫 UI。6. 性能優(yōu)化與常見問題排查6.1 控制 draw callsthree.js 場景中每個 Mesh 每個材質(zhì)基本對應(yīng)一次 draw call。校園場景如果建筑、路燈、樹木、路牌全部獨立生成幾百上千的 draw call 是常態(tài)低端設(shè)備掉幀嚴重。經(jīng)驗值如下60 FPS 目標(biāo)下桌面端盡量控制在 300 次 draw call 以內(nèi)。移動端大屏盡量控制在 100 次以內(nèi)。優(yōu)化手段按優(yōu)先級排InstancedMesh 合并同類建筑、合并靜止的地形道路網(wǎng)格、刪除不可見物體、大場景開視錐剔除、合并紋理圖集。6.2 浮點精度和坐標(biāo)抖動前面提過局部坐標(biāo)能解決大部分精度問題但還有一個補充技巧把 camera 的 position 也往原點靠。如果你做的是超大地圖比如整個城市那就需要“隨動原點”方案即相機移動時把整個場景反向移動始終保持相機離原點很近。const offset camera.position.clone(); // 當(dāng)相機距離原點超過閾值時 scene.position.sub(offset); camera.position.sub(offset);這不是我這個項目的剛需但如果你后續(xù)要擴展到更大范圍建議提前架構(gòu)。6.3 常見問題速查現(xiàn)象原因解決辦法地形邊緣閃白z-fighting相鄰 Mesh 貼太近偏移 0.01~0.1建筑飄在半空輪廓點高度未采樣地形每個點做高度采樣再加 baseY水面穿地水面頂點高度低于地形水面統(tǒng)一抬高到地形最高水位點擊沒反應(yīng)CSS2D 標(biāo)簽遮擋 canvaslabelRenderer.domElement.style.pointerEvents none移動端卡頓陰影 抗鋸齒開銷太大關(guān)陰影或關(guān) antialias建筑材質(zhì)全變了多個建筑共享同一個 material修改前先 clone()相機穿地OrbitControls 未限制俯仰角設(shè)置 maxPolarAngle這七個問題是我項目里實際遇到過的每一個都花了不少時間定位。尤其 z-fighting 和共享材質(zhì)定位過程極其迷惑經(jīng)驗不分享出來真心難受。6.4 項目工程化在 Vue 里集成 three.js熱詞里提到“vue2 百度 3d 實景地圖加載 3d 模型”這個方向很多人問。我在實際項目里是把 three.js 場景封裝成一個獨立的類然后掛在 Vue 的 mounted 鉤子初始化卸載時銷毀渲染器和 scene。這里給出一個精簡的集成模式export default { mounted() { this.map3d new Campus3DMap(); this.map3d.init(document.getElementById(map-container)); }, beforeDestroy() { this.map3d.dispose(); } };所有的 three.js 代碼盡量不直接寫在 Vue 組件里而是抽成獨立模塊。好處是后續(xù)更換框架、復(fù)用場景、單元測試都方便。數(shù)據(jù)請求放在 Vue 層通過事件或方法注入到三維場景中這樣數(shù)據(jù)和渲染完全解耦。6.5 three.js 版本與資源獲取熱詞里有人問 three.js 怎么下載、中文文檔在哪統(tǒng)一說一嘴。three.js 目前最推薦的方式是 npm 安裝npm install three如果做 CDN 引入官方推薦用 ES module 的 importmap 方式。中文文檔方面three.js 官方文檔是英文的國內(nèi)有一些中文鏡像站但更新可能滯后我更建議直接看官方示例和源碼遇到不懂的 API 去搜“three.js 類名 用法”比看靜態(tài)文檔更高效。注意 npm 安裝版本要用 latest避免舊版本 API 和當(dāng)前示例代碼不一致。有些老教程用的是 Geometry新版已經(jīng)改成 BufferGeometry照著抄會出現(xiàn)各種報錯。7. 還能怎么擴展這個校園 3D 地圖做完之后我最大的體會是three.js 做小范圍 3D 地圖的能力被很多人低估了。它沒有內(nèi)置“地圖”的概念但正因如此它的表達完全由你掌控。你可以在上面疊加熱力圖、動態(tài)軌跡、時鐘動畫、夜景燈光秀、天氣系統(tǒng)甚至把 BIM 模型導(dǎo)進去做樓宇內(nèi)部漫游。后續(xù)我打算在這個項目里加入行人導(dǎo)航動畫讓小紅點在校園道路網(wǎng)里自動尋路想一想就覺得可以做很多有趣的玩法。如果你正在規(guī)劃類似項目我的建議是先從最小可行版本開始一小塊地形、兩棟建筑、一條路跑通全流程再逐步增加密度和效果。別一上來就想做超大場景先解決“有沒有”再解決“好不好”。做 3D 地圖最磨人的不是 three.js 本身而是數(shù)據(jù)清理和坐標(biāo)系轉(zhuǎn)換這幾步耐心做扎實后面就能順暢很多。本文還有配套的精品資源點擊獲取