ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于Vue与Three.js的中国3D地图可视化大屏实战解析

基于Vue与Three.js的中国3D地图可视化大屏实战解析 简介本资源是一个基于Three.js与Vue.js实现的交互式中国3D地理可视化项目面向前端开发者、GIS初学者及Web 3D技术实践者解决传统二维地图缺乏空间感知与动态交互的问题适用于数字孪生、政务可视化、教学演示等场景。压缩包共2000个文件主体为2002个JavaScript文件含three.module.js、web-ifc-api.js等核心3D库、165个JSON格式地理数据与配置文件、50个Markdown文档说明及少量CSS/HTML资源整体体积11.35MB结构模块清晰涵盖场景初始化、省份网格建模、相机控制、光照纹理配置与Vue响应式状态管理逻辑。已有3829人学习下载提供开箱即用的Three-chinaMap-Demo.html入口完整包含地形渲染、鼠标拖拽缩放、区域点击高亮、LOD性能优化等可运行功能是深入理解WebGL封装、Vue 3D状态联动与大规模地理数据可视化的优质实践样本。 去年接了一个展厅数据可视化需求要在现场大屏上放一张中国3D地图能看省份海拔差异、重点城市分布、还有点击省份弹出业务数据。当时第一反应就是用 Three.js因为要的是真三维不是那种用 CSS 搞出来的伪 3D。整个项目最终用 Vue Three.js 完成前后大概两周时间踩了不少坑也总结出一套可以直接复用的流程。这篇文章就把整个项目从技术选型到核心实现、再到坑位排查完整拆一遍希望对正在做 Web 3D 可视化大屏的朋友有帮助。先说结论Vue 负责页面骨架、交互状态和业务数据Three.js 负责地图渲染和三维场景两者通过组件生命周期绑定用 ref 挂载 DOM用 reactive 驱动场景状态。这套分工在实操里非常顺也是后文展开的主线。1. 为什么是 Three.js Vue而不是其他方案1.1 Three.js 到底解决了什么问题很多人会问地图可视化用 ECharts 或者 Mapbox 不就行了为什么还要上 Three.js我的回答是视业务目标而定。ECharts 的 map3D 确实能做出带挤出效果的 3D 地图但它的扩展边界很明显——你想在某个城市上方放一个动态波纹标记、想从北京画一条飞到乌鲁木齐的曲线、想让相机沿着某个路径自动巡航这些在 ECharts 里做起来要么依赖 hack要么干脆做不了。Three.js 是底层 WebGL 渲染引擎本质上是给你一块自由的 3D 画布地图只是你在这块画布上搭出来的一个场景而已。换句话说Three.js 不是“地图库”它比地图库更底层也更灵活。这次项目里实际用到的高频能力包括几何体挤出ExtrudeGeometry、多边形轮廓线、Raycaster 点击拾取、OrbitControls 相机控制、Sprite 标记点、CatmullRomCurve3 飞线。这些在 Three.js 里都是成熟能力组合起来就能做出一张交互感很强的中国3D地图。1.2 Vue 在这里不是主角但离了它很别扭地图主体虽然是 Three.js 干的但整个页面不可能只有一张地图左侧要放省份数据排行右侧要放选中省份的详情面板底部可能还有时间轴筛选。这些 UI 和地图之间需要联动比如鼠标悬停到某个省右侧面板实时更新点击某个省地图视角飞过去。如果纯用原生 JS 写这些状态管理会非常痛苦而 Vue 的响应式数据天生就适合干这事。我的做法是把“当前选中省份”“当前悬停省份”这类状态放在 Vue 的 reactive 对象里Three.js 侧监听这些状态变化对应更新地图材质颜色、相机位置、UI 面板内容。反过来Three.js 的 Raycaster 拾取到省份后修改的也是 Vue 的响应式对象这样 UI 和 3D 场景就形成了一个闭环数据流。这个思路对于所有用 Three.js 做数据可视化大屏的项目都适用。2. 环境搭建与地图数据预处理2.1 快速初始化 Vue3 Vite Three.js 开发环境这个项目我用的 Vite 而不是 Vue CLI原因很简单Vite 冷启动快、热更新快在开发阶段会舒服很多。Node.js 版本用的是 18直接按官方文档创建命令如下npm create vitelatest china-3d-map -- --template vue cd china-3d-map npm install npm install three npm install --save-dev unplugin-auto-import unplugin-vue-components装完以后先确认一下 three 的版本。我写这篇文章时 three 已经到 0.160 了API 基本稳定下面示例代码在 0.150 ~ 0.170 之间都能跑。不过要注意一个小变化新版本里THREE.Geometry已经移除很久了所有几何体都是BufferGeometry写代码时别再用旧 API。项目里还需要引入地图 GeoJSON 数据。中国省份边界的 GeoJSON 可以在阿里云 DataV 的公开数据集里拿到搜索“china.json”或者“中国地图 geojson”就能找到。我用的这份数据包含了省份名称、adcode、以及对应的多边形坐标数组格式是标准的 GeoJSON FeatureCollection。2.2 GeoJSON 坐标数据解析从经纬度到 3D 平面坐标拿到 GeoJSON 以后第一步不是急着渲染而是把经纬度坐标转换成 Three.js 场景里能用的平面坐标。这里面有个新手最容易踩的坑直接把经纬度当成 x/y 用。中国地图的大致范围是经度 73°~135°、纬度 18°~54°如果你原样用这组数字去创建几何体地图确实能显示出来但它会跑到坐标轴的右上方很远的地方而且比例失衡相机很难定位。比较稳妥的做法是先计算整个数据集的最小/最大经纬度然后按比例映射到一个以原点为中心的矩形区域内。下面是我项目里实际用的投影函数function normalizeGeoJSON(features, width 100, height 100) { // 1. 计算经纬度范围 let minLon Infinity, maxLon -Infinity; let minLat Infinity, maxLat -Infinity; features.forEach(feature { const coords getAllCoordinates(feature); coords.forEach(([lon, lat]) { minLon Math.min(minLon, lon); maxLon Math.max(maxLon, lon); minLat Math.min(minLat, lat); maxLat Math.max(maxLat, lat); }); }); // 2. 计算等比缩放比例保持地图不变形 const xScale width / (maxLon - minLon); const yScale height / (maxLat - minLat); const scale Math.min(xScale, yScale); // 3. 返回一个经纬度转 x/y 的函数 return function project(lon, lat) { const x (lon - minLon) * scale - width / 2; const y (lat - minLat) * scale - height / 2; return { x, y }; }; } function getAllCoordinates(feature) { // GeoJSON 可能是 Polygon 也可能是 MultiPolygon统一拍平处理 const geometry feature.geometry; if (geometry.type Polygon) return geometry.coordinates.flat(); if (geometry.type MultiPolygon) return geometry.coordinates.flat(2); return []; }这里有个细节值得多说一句为什么用Math.min(xScale, yScale)而不是分别用 x 和 y 方向的缩放因为如果不取等比例地图的横向和纵向会被拉伸中国地图看起来会比实际“胖”或者“瘦”。很多教程没提这一点做完以后地图轮廓总觉得不对劲其实就是这里出了问题。等比例缩放牺牲的是一部分展示面积但换来的地图形状保真是完全值得的。2.3 三种可选的“高度”数据来源这个项目标题叫“3D地图”3D 体现在哪里最直观的就是每个省份的墙体和挤出高度。在实操里高度数据常见有三种来源真实地形高程比如 NASA 的 SRTM 30米分辨率 DEM 数据但处理起来很麻烦需要切片、读像素值对前端来说成本太高一般不做业务指标映射比如各省 GDP、人口、销售额用数值大小决定挤出高度这是大屏项目最常用、也是效果最直观的方案模拟海拔如果只是想要一个酷炫的展示效果并不需要反映真实业务数据那可以直接给每个省份设定一个“虚拟海拔”比如西藏高度 5000四川 1000你只需要维护一个省名对应高度的字典就行。我这次项目一开始用了真实海拔数据模拟方案后来客户要求能反映业务数据变化于是改成按指标映射高度。建议你把高度计算抽成一个函数后续切换数据源会非常方便function getProvinceHeight(provinceName, data) { const value data[provinceName] || 0; return 3 value * 20; // 最低 3 个单位最高根据数据自适应 }实践下来如果所有省份高度都是平的比如都是 3地图看起来就是一张“饼”没有立体感。至少要保证西藏、青海、云南这些区域比东部省份高一点视觉上才有“中国地势三级阶梯”的感觉。这虽然不是真实地理数据但观众的直觉会觉得“对就是这种感觉”。3. 核心功能拆解与实现3.1 用 ExtrudeGeometry 把省份从“平面”变成“立体”处理完坐标以后就可以为每个省份创建几何体了。Three.js 里做立体地图最直接的方法是ExtrudeGeometry先画一个多边形平面再沿 z 轴方向挤出一个高度就得到了有“墙体”的立体形状。具体流程分三步第一步把省份的多边形轮廓转成THREE.Shape。注意一个省可能有多个不相连的轮廓比如广东省包含海南岛不对海南是单独的省份但有些省份确实有飞地或岛屿所以一个Shape可能不够需要循环处理。function createProvinceShape(coordinates, project) { const shape new THREE.Shape(); coordinates.forEach(([lon, lat], index) { const { x, y } project(lon, lat); if (index 0) shape.moveTo(x, y); else shape.lineTo(x, y); }); return shape; }第二步用ExtrudeGeometry挤出。这里有两个关键参数depth是挤出高度也就是省份的“海拔”bevelEnabled默认是true会产生一个倒角我建议在大多数地图场景里把它关掉否则省份边缘会有一个斜切面影响视觉精度而且会额外增加顶点数。const geometry new THREE.ExtrudeGeometry(shape, { depth: provinceHeight, bevelEnabled: false, });第三步把几何体平移到合适的位置。ExtrudeGeometry默认从 z0 向上挤所以省底和顶都在一个很小的范围内。为了让地图看起来是“悬浮”的或者“沉在底座上的”可以整体平移geometry.translate(0, 0, -provinceHeight / 2)让省体居中。每个省份对应一个独立 Mesh把这个 Mesh 放进一个THREE.Group里方便统一管理。给每个 Mesh 的userData上挂省份名称、adcode、原始高度等业务字段后面做点击高亮和飞线都会用到。3.2 省份高亮、选中与点击交互交互是大屏项目的重头戏。Three.js 里做点击拾取的标准方式是用Raycaster从相机发射一条射线检测与哪些 Mesh 相交。这里有个性能优化的点场景里可能有几十个省份 Mesh如果每一次鼠标移动都做全量射线检测会有一些多余的 CPU 开销。我一般会单独维护一个provinceMeshes数组只对这个数组做检测而不是对scene.children全部检测。const raycaster new THREE.Raycaster(); const mouse new THREE.Vector2(); function onMouseMove(event) { // 将鼠标位置转换成 NDC 坐标-1 ~ 1 mouse.x (event.clientX / window.innerWidth) * 2 - 1; mouse.y -(event.clientY / window.innerHeight) * 2 1; raycaster.setFromCamera(mouse, camera); const intersects raycaster.intersectObjects(provinceMeshes); if (intersects.length 0) { const mesh intersects[0].object; hoverProvince(mesh.userData.name); } else { clearHover(); } }高亮的效果我推荐两种方案一起用。一是改变材质颜色悬停时从默认蓝色调变成亮一点的青色或绿色二是叠加一个描边效果用EdgesGeometryLineSegments把省份轮廓线画出来选中时让轮廓线变成亮色。只有颜色变化的话在展厅大屏的高亮环境下容易被忽略加上轮廓线以后辨识度会高很多。关于点击后如何把 Vue 侧的 UI 面板带出来我的数据流是这样的点击某个省先更新 Vue 的 reactivestate.currentProvince然后地图侧通过watch监听这个值执行相机飞行动画用 Tween 或自定义轨迹飞行到目标省份上方同时把省份材质换成选中色。UI 面板里展示的业务数据直接根据state.currentProvince从接口或者本地 JSON 里读取。3.3 城市标记与飞线动画让地图有生命力光有立体的省份轮廓还不够大屏上还得有“点”和“线”——点代表重点城市线代表城市之间的关联。城市标记我用的是THREE.Sprite Canvas 生成的纹理这样能保证文字比如城市名始终面向相机而且成本很低。function createCitySprite(name, position) { const canvas document.createElement(canvas); canvas.width 128; canvas.height 64; const ctx canvas.getContext(2d); ctx.fillStyle #ffffff; ctx.font bold 24px sans-serif; ctx.textAlign center; ctx.fillText(name, 64, 36); const texture new THREE.CanvasTexture(canvas); const material new THREE.SpriteMaterial({ map: texture, transparent: true }); const sprite new THREE.Sprite(material); sprite.position.copy(position); sprite.scale.set(8, 4, 1); return sprite; }飞线是这种地图展示里最出效果的一环。我的做法是拿到起点和终点城市的三维坐标后用CatmullRomCurve3创建一条贝塞尔曲线。为了让飞线看起来有弧度我在起点和终点中间加一个抬升的中间点控制点高度向上偏移 20~30 个单位。然后用TubeGeometry生成管状线条或者用Line直接渲染曲线。带渐变效果的飞线更推荐用Line2FatLines但实现复杂度会高一些。第一次做的话先用CatmullRomCurve3.getPoints(50)生成一系列点再用THREE.Line画出来效果已经足够。飞线动画有两种常见做法一种是让线的透明度或宽度沿路径随时间变化需要 Shader另一种是移动一个“光点”从起点到终点后面这种比较好理解。用一个球体或 Sprite 作为飞行光点每一帧更新它在曲线上的位置// 预先计算曲线 const curve new THREE.CatmullRomCurve3([startPoint, controlPoint, endPoint]); // 在 requestAnimationFrame 里更新 const t (Date.now() / 1500) % 1; // 1.5 秒走完一次 const point curve.getPoint(t); flyPoint.position.copy(point);3.4 Vue 生命周期与 Three.js 场景的绑定把 Three.js 塞进 Vue 组件里有一个老生常态但很多人还是会踩的坑场景只能在组件挂载后创建组件销毁前必须清理。我的组件结构大概是这样的template div refmapContainer classmap-container/div /template script setup import { ref, onMounted, onBeforeUnmount } from vue; const mapContainer ref(null); let renderer, scene, camera, controls; let animationId null; function initScene() { const container mapContainer.value; const width container.clientWidth; const height container.clientHeight; scene new THREE.Scene(); camera new THREE.PerspectiveCamera(45, width / height, 0.1, 1000); camera.position.set(0, -80, 80); camera.lookAt(0, 0, 0); renderer new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setSize(width, height); container.appendChild(renderer.domElement); controls new OrbitControls(camera, renderer.domElement); controls.enableDamping true; } function animate() { animationId requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); } onMounted(() { initScene(); loadMapData(); animate(); window.addEventListener(resize, onResize); }); onBeforeUnmount(() { cancelAnimationFrame(animationId); window.removeEventListener(resize, onResize); renderer.dispose(); mapContainer.value.removeChild(renderer.domElement); }); /script这里额外提醒一下renderer.dispose()之后一定要把 canvas 从 DOM 里移除否则在 Vue 的 HMR 热更新或路由切换时你的页面里会保留很多幽灵 canvas占用显存。4. 性能优化与三个最值得记录的坑4.1 大屏渲染性能优化展厅大屏的分辨率一般是 1920x1080 或者更高数据量大的时候容易掉帧。我做过的优化主要有几个方向合并静态几何体如果某些省份的几何体不需要单独交互可以先把它们合并成一个几何体一次 draw call 渲染完能显著降低 GPU 压力。需要交互的省份再单独保留减少透明物体大量半透明叠加会触发透明度排序GPU 开销成倍增加。标记点、贴片能用不透明的就尽量不透明GeoJSON 数据简化中国地图的 GeoJSON 原始文件可能有几百 KB顶点数非常多。可以用 mapshaper 之类的工具把精度从 5 位小数降到 2~3 位肉眼几乎看不出差别但顶点数可以减少一半以上使用 DRACOLoader 压缩模型如果还有 GLTF/GLB 模型比如建筑模型、装饰物务必用 Draco 压缩压缩率通常在 70% 以上。我记得有一次地图旋转的时候掉帧特别严重检查了半天发现是 GeoJSON 里每个省份的轮廓精度都特别高渲染时的顶点数达到了十几万。用 mapshaper 简化到 2% 以后帧率从 30 以下直接拉回 60效果非常明显。4.2 坐标偏移导致省份轮廓“错位”的排查思路这个项目里最典型的一个问题地图渲染出来以后某个省份的形状整体偏离了它本来的位置或者省份之间有交叠。排查下来原因通常出在getAllCoordinates函数把MultiPolygon拍平的逻辑写错了。GeoJSON 里Polygon的坐标层级是三层数组最外层是环Ring每个环里是坐标点数组而MultiPolygon是四层数组。拍平时如果flat(1)或flat(2)层级不对取出来的点就会错乱。建议你写完之后做一个可视化调试先把所有省份的轮廓线用LineSegments画出来叠加在平面底图上对比这样可以快速判断题是否出在轮廓解析上。如果轮廓整体错位一定是投影函数的问题如果只有个别省份错位、扭曲那就是MultiPolygon解析的问题按上面提到的拍平层级逐层检查即可。4.3 透明材质渲染顺序问题Three.js 里透明物体的渲染顺序是一个老大难问题。地图场景里如果既要有透明的省份漂浮块半透明膜状效果又要有透明的飞线、透明的标记点经常会出现“透过一个透明物体看到另一个透明物体时后面的物体有一部分不显示”的现象。这个问题没有一劳永逸的解决办法。我的经验是depends 优先级 renderOrder 手动调整。在创建 Mesh 的时候显式设置provinceMesh.renderOrder 1; flyLineMesh.renderOrder 2; sprite.renderOrder 3;同时把透明材质的depthWrite: false设上防止透明物体写入深度缓冲导致后面的透明物体被遮挡。这个技巧不是万能的但对大多数情况能明显改善。5. 项目还能怎么扩展从“会动的地图”到“可用的产品”做完主体功能以后我并没有马上收工而是按客户需求加了几个扩展点这里也一并分享出来。第一是时间轴播放。在 Vue 侧做一个 slider拖动时间轴时各省的挤出高度会按照时间变化而重新计算形成一个数据演变的动画效果。实现起来并不复杂因为省份高度已经抽成了getProvinceHeight函数改动高度时只需要循环省份 Mesh更新几何体或者重新创建几何体就行。不过要注意频繁重建几何体很耗内存更好的做法是用geometry.attributes.position动态修改顶点位置但计算量会大一些需要自己权衡。第二是图表面板与地图联动。页面右侧放一个 ECharts 折线图展示选中省份的历史数据。点击省份时Vue 侧更新 ECharts 的option这个很简单重点在于地图和图表要共用一套数据源避免两侧数据不一致。第三是部署和加载体验。地图 GeoJSON 文件如果比较大建议放到 CDN 上并用fetch异步加载期间显示一个 loading 状态。还可以用topojson-client把 TopoJSON 转 GeoJSON文件更小加载更快。不过 TopoJSON 会增加一个转换步骤如果不是特别在意首屏速度直接用优化后的 GeoJSON 就够。我在实际部署时踩过一个相关的小坑如果项目部署在 Nginx 子路径下比如http://example.com/geo/静态资源路径默认是相对路径直接fetch(china.json)会 404。解决方法是给 Vite 配置base: /geo/或者用相对路径fetch(new URL(./china.json, import.meta.url).href)。6. 最后几个实用建议写到这里项目主体基本讲完了。按照我的经验再给大家几个通用建议别期待一次写对。三维场景坐标系远比二维 Web 页面复杂最好先把地图轮廓线渲染出来看形状再往上叠加立体和交互。一步一步来更容易定位问题。相机位置和灯光是照片质量的关键。同一种蓝色材质在偏暗的展厅大屏和明亮的办公显示器上呈现效果差别巨大。建议用HemisphereLight作为环境光再配一个DirectionalLight作为主光并花时间调光的方向阴影和高光会比默认光照自然很多。保持场景可配置化。把省份颜色、高度、飞线速度、相机初始位置这些参数全部抽到一个配置文件里。因为大屏项目的需求沟通通常不是一次性能敲定的客户总会在交付前提出“这个紫色换一下”“地图转得再慢一点”之类的改动。配置化之后改起来就是调参数的事。最后再分享一个我在项目交付阶段学到的经验不要只盯着技术指标还要多考虑“使用场景”。客户看大屏的时候不会关注你用的是 Three.js 的哪个 API只会关注视觉效果是否够震撼、交互反馈是否够流畅。所以宁可减少几个花哨功能也要保证相机旋转、飞线动画、点击响应这些核心交互的流畅度。我这次项目里最受好评的一个设置其实是 OrbitControls 的autoRotate——地图加载完成后自动缓慢旋转立刻让整个大屏的科技感上了一个台阶。这个设置对代码来说只有一行但现场效果提升非常大强烈建议也试一下。本文还有配套的精品资源点击获取
返回列表