
簡介3D可視化技術正成為智慧校園與數字孿生場景的重要呈現方式。通過WebGL渲染引擎three.js開發者可以在瀏覽器中構建交互式三維地圖。實現過程中經緯度坐標需經局部坐標系轉換以避免浮點精度引發的場景抖動地形可借助PlaneGeometry與噪聲函數生成建筑則通過ExtrudeGeometry拉伸并利用InstancedMesh合并繪制從而大幅降低draw call。該技術路徑不僅適用于校園也可推廣至園區、車間等小范圍場景甚至可與vue2百度的3D實景地圖模型加載相結合。掌握這些基礎原理與工程實踐即可高效完成從數據準備到交互展示的完整流程。1. 項目定位與方案選擇為什么用 three.js 做 3D 地圖前陣子接到一個校園三維可視化需求要求把整個校區的地形、建筑、道路、水系在瀏覽器里做成可交互的 3D 場景。一提到 3D 地圖很多人第一反應是 Cesium、Mapbox GL或者 UE5 像素流但綜合考慮項目預算、部署成本、前端技術棧之后我最終選擇了 three.js 作為核心渲染引擎。這篇博文就把整個實現過程拆開講一講從數據準備到坐標轉換從地形生成到建筑建模從交互拾取到性能優化一條線走完。先說說為什么是 three.js 而不是其他方案。Cesium 主打全球尺度加載傾斜攝影、地形瓦片確實強但如果需要的只是一個園區、一個校區、一個廠區這種小范圍場景用 Cesium 就像殺雞用牛刀學習成本高、包體體積大、API 抽象層級也重。Mapbox GL 的地圖底圖渲染很強但它本身是 2.5D 的想在它上面自由做建筑體塊拉伸、做水流動畫、做自定義交互限制比較多。three.js 的好處是足夠底層也足夠靈活場景、網格、材質、相機全部自己掌控做小范圍 3D 地圖再合適不過。它不叫“地圖引擎”但它能拼裝出你想要的一切地圖效果。這個方案適合誰來參考如果你正在做智慧校園、智慧園區、數字孿生車間、展廳大屏可視化或者單純想給自己的個人項目做一個炫酷的 3D 小地圖這篇文章里的思路和代碼都可以直接復用。接下來我會以一個校園 3D 地圖項目為案例背景把關鍵環節逐一拆解每一步該怎么做、為什么這么做、坑在哪里都會講清楚。2. 數據準備與坐標系轉換地圖數據怎么變成 three.js 場景2.1 先搞清楚數據從哪來做 3D 地圖第一步不是寫代碼而是搞數據。一個校園地圖需要的數據大致分四類地形數據地塊范圍、高程信息如果沒有精確 DEM 數據可以用噪聲函數模擬起伏。建筑數據建筑輪廓多邊形、建筑高度、樓層數通常可以從 OpenStreetMap 導出 GeoJSON或者從規劃圖紙里人工繪制。道路與路網道路中心線或邊線同樣是 GeoJSON 格式。水系與綠地河流、湖泊、池塘的邊界以及綠化區域范圍。我用的是 OSM 導出的校園 GeoJSON加上自己補充的建筑高度字段。如果你在給一個具體園區做項目拿到 CAD 圖紙之后轉成 GeoJSON 也是常見路徑。注意OSM 的建筑數據常常缺高度需要人工調研或者按樓層數估算。我當時是按“每層 3.5 米”乘樓層數算的誤差在可接受范圍內。2.2 經緯度坐標怎么變成平面坐標GeoJSON 里存的是經緯度而 three.js 場景是一個三維笛卡爾坐標系X 向右、Y 向上、Z 朝向屏幕外或者用 Z 表示南北方向。所以第一步必須做投影轉換。常用的做法是 Web Mercator 投影也就是把經緯度轉成米制平面坐標。不過實際項目里不需要引入很重的 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 }; }這樣做完所有經緯度點都變成了以米為單位的平面坐標。但 web 墨卡托的數值非常大幾百萬的量級直接塞進 three.js 會觸發浮點數精度問題導致場景抖動、閃爍。解決辦法是“局部坐標系化”把所有坐標都減去中心點的坐標讓場景原點附近的數值保持在幾千以內。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 保持不變。這樣地形上的東西南北方向和經緯網一致后續擺放模型、調整相機方向都符合直覺。2.3 為什么局部坐標系能避免精度坑three.js 的相機、場景節點在渲染時會做大量矩陣運算坐標值越大浮點數誤差越明顯。想象一下你在城市的另一端看一棟樓和站在樓底下看這棟樓同一個模型在空間里的表現截然不同。局部坐標把項目范圍縮小到原點附近幾百米的場景里Float32 精度完全夠用。踩過這個坑之后我建議所有 GIS 數據進 three.js 之前都先做“去中心化”。別嫌麻煩這步不做后面模型抖動、貼圖閃爍會折騰到你想放棄。3. 地形生成與場景環境先把“地”搭起來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);相機初始化位置我習慣放在地形的斜上方約 45 度俯視角看校園全景剛剛好。抗鋸齒必須打開否則地形邊界、建筑邊緣的鋸齒會非常明顯尤其是大屏投放場景鋸齒一放大就很廉價。OrbitControls 是必裝的輔助庫它解決了相機旋轉、縮放、平移的基礎交互。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 限制最大俯視角度防止用戶把視角轉到地面以下。enableDamping 打開慣性效果手感會順滑很多。3.2 用 PlaneGeometry 生成地形網格地形我直接用 PlaneGeometry 來生成分段數決定地形細膩度。一個 705 米 x 500 米的校園用 128 段 x 128 段頂點數約 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 平面旋轉 -90 度后變成 XZ 平面Y 軸朝上這樣才符合 three.js 地形的通用做法。接下來要處理頂點高度。有真實 DEM 數據的直接讀取高程數組寫入頂點 Y 值。沒有數據的情況下我用簡單柏林噪聲來模擬地形起伏保證場景不死板。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 一定要調否則地形上光照表現是平的明暗變化完全沒有看起來像塑料片。3.3 頂點著色讓地形有質感地形高度不只是幾何形態還可以映射成顏色。低洼處用藍綠色山丘用褐色頂部用灰白色這樣可以做出等高線般的視覺效果。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));用頂點色而不是貼圖的好處是不依賴外部圖片資源部署方便而且顏色隨地形動態變化時只需要改數組非常靈活。材質記得開啟 vertexColors。const material new THREE.MeshStandardMaterial({ vertexColors: true }); const terrain new THREE.Mesh(geometry, material); scene.add(terrain);3.4 光照、霧和天空3D 地圖對光照要求不高但不打光不行。我習慣用一束平行光模擬太陽再加一個環境光補亮陰影面。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);如果開了陰影地形要設置 receiveShadow建筑要設置 castShadow。但陰影會顯著增加渲染開銷移動端建議直接關掉用偽陰影替代。再給場景加一層淡霧一是弱化地平線的硬邊二是有一種空間縱深的感覺。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 必須設成 false否則建筑棱邊會有斜角樓體看起來圓滾滾而且會大幅增加頂點數。建筑材質可以做成“白天模式”和“夜晚模式”兩套白天用淺灰白加一點環境反射夜晚把窗戶區域做成自發光。我在項目里給建筑材質單獨加了 emissive 屬性夜晚讓色塊微微泛光大屏視覺效果很不錯。4.2 建筑數量上百后怎么優化校園里單體建筑四五十棟還好但如果做園區建筑上百棟逐個創建 Mesh 會導致 draw call 爆炸。優化手段是用 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); }這樣一搞上百棟建筑只產生幾次 draw call幀率壓力小得多。4.3 道路怎么做才不“飄”道路數據通常是線或者窄多邊形。最簡單的方式是把路網轉成 Line 對象配合 LineBasicMaterial 輸出。但如果想做出有質感的道路我更推薦把道路兩邊線和中心線組合成多邊形然后貼到地形表面。最穩妥的貼地做法是“偏移法”把道路多邊形每個頂點的高度采樣地形高度再手動加 0.3 米的偏移量。不要試圖讓道路物理貼合地形網格那樣徒增復雜度。我試過把道路做成獨立 Mesh 貼在模型上只要偏移值大于地形起伏俯視看不出破綻側面看也不會 z-fighting。水系同理用多邊形勾勒河岸、湖面邊界鋪一個透明材質const waterMaterial new THREE.MeshStandardMaterial({ color: 0x3399ff, transparent: true, opacity: 0.7, });河岸邊緣多邊形的每個點同樣需要從地形采樣高度。這個步驟漏了的話水面就會在某處懸空或者穿到地下。4.4 河流流動怎么做純靜態水面總感覺差點意思。給水面加一個 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. 交互與標注點擊、懸浮、彈窗和文字標簽5.1 鼠標拾取建筑地圖沒有交互就沒有靈魂。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); } }這里有個經驗buildingMeshes 不要傳三維數組嵌套的對象要拍平成一維數組否則 Raycaster 遞歸遍歷會非常慢。建筑數量多的時候建議用三個參數快速篩選raycaster.intersectObjects(buildingMeshes, false)false 表示不遞歸子節點。5.2 標注用 CSS2DRenderer 比 Sprite 強建筑上的名稱標注很多人會想到 Sprite。Sprite 確實會用但文字一旦多了就糊成一片而且縮放距離和相機交互很難控制。我更推薦 CSS2DRenderer它是把 HTML 標簽掛在 3D 坐標上清晰度完勝也能很方便地加 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 元素要關閉 pointerEvents否則它會在 three.js canvas 上面蓋一層擋住鼠標事件導致點擊拾取失效。這個坑我踩了一次排查半天才發現是標簽層擋住了畫布。創建標注對象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);標注渲染循環里必須同時調用 CSS2DRenderer 的 renderlabelRenderer.render(scene, camera);5.3 點擊建筑高亮與信息面板點擊高亮我用的是兩種方案結合一是給建筑材質設置 emissive 提亮二是給選中建筑加一層外發光邊框。最簡單的是改 emissivebuilding.material.emissive.setHex(0xff8800);注意如果建筑是共享材質直接改 material 會同時改變所有同材質的建筑。所以必須 clone 一份材質再改building.material building.material.clone(); building.material.emissive.setHex(0xff8800);信息面板就是普通的 HTML 絕對定位彈窗點擊建筑后顯示名稱、樓層數、面積、用途。HTML 做展示層的優勢是排版、樣式、響應式都容易控制不用在 three.js 里畫 UI。6. 性能優化與常見問題排查6.1 控制 draw callsthree.js 場景中每個 Mesh 每個材質基本對應一次 draw call。校園場景如果建筑、路燈、樹木、路牌全部獨立生成幾百上千的 draw call 是常態低端設備掉幀嚴重。經驗值如下60 FPS 目標下桌面端盡量控制在 300 次 draw call 以內。移動端大屏盡量控制在 100 次以內。優化手段按優先級排InstancedMesh 合并同類建筑、合并靜止的地形道路網格、刪除不可見物體、大場景開視錐剔除、合并紋理圖集。6.2 浮點精度和坐標抖動前面提過局部坐標能解決大部分精度問題但還有一個補充技巧把 camera 的 position 也往原點靠。如果你做的是超大地圖比如整個城市那就需要“隨動原點”方案即相機移動時把整個場景反向移動始終保持相機離原點很近。const offset camera.position.clone(); // 當相機距離原點超過閾值時 scene.position.sub(offset); camera.position.sub(offset);這不是我這個項目的剛需但如果你后續要擴展到更大范圍建議提前架構。6.3 常見問題速查現象原因解決辦法地形邊緣閃白z-fighting相鄰 Mesh 貼太近偏移 0.01~0.1建筑飄在半空輪廓點高度未采樣地形每個點做高度采樣再加 baseY水面穿地水面頂點高度低于地形水面統一抬高到地形最高水位點擊沒反應CSS2D 標簽遮擋 canvaslabelRenderer.domElement.style.pointerEvents none移動端卡頓陰影 抗鋸齒開銷太大關陰影或關 antialias建筑材質全變了多個建筑共享同一個 material修改前先 clone()相機穿地OrbitControls 未限制俯仰角設置 maxPolarAngle這七個問題是我項目里實際遇到過的每一個都花了不少時間定位。尤其 z-fighting 和共享材質定位過程極其迷惑經驗不分享出來真心難受。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 組件里而是抽成獨立模塊。好處是后續更換框架、復用場景、單元測試都方便。數據請求放在 Vue 層通過事件或方法注入到三維場景中這樣數據和渲染完全解耦。6.5 three.js 版本與資源獲取熱詞里有人問 three.js 怎么下載、中文文檔在哪統一說一嘴。three.js 目前最推薦的方式是 npm 安裝npm install three如果做 CDN 引入官方推薦用 ES module 的 importmap 方式。中文文檔方面three.js 官方文檔是英文的國內有一些中文鏡像站但更新可能滯后我更建議直接看官方示例和源碼遇到不懂的 API 去搜“three.js 類名 用法”比看靜態文檔更高效。注意 npm 安裝版本要用 latest避免舊版本 API 和當前示例代碼不一致。有些老教程用的是 Geometry新版已經改成 BufferGeometry照著抄會出現各種報錯。7. 還能怎么擴展這個校園 3D 地圖做完之后我最大的體會是three.js 做小范圍 3D 地圖的能力被很多人低估了。它沒有內置“地圖”的概念但正因如此它的表達完全由你掌控。你可以在上面疊加熱力圖、動態軌跡、時鐘動畫、夜景燈光秀、天氣系統甚至把 BIM 模型導進去做樓宇內部漫游。后續我打算在這個項目里加入行人導航動畫讓小紅點在校園道路網里自動尋路想一想就覺得可以做很多有趣的玩法。如果你正在規劃類似項目我的建議是先從最小可行版本開始一小塊地形、兩棟建筑、一條路跑通全流程再逐步增加密度和效果。別一上來就想做超大場景先解決“有沒有”再解決“好不好”。做 3D 地圖最磨人的不是 three.js 本身而是數據清理和坐標系轉換這幾步耐心做扎實后面就能順暢很多。本文還有配套的精品資源點擊獲取