首页 / 资讯中心 / 文章详情

Three.js大屏3D地图可视化实战:坐标转换、建筑拉伸与性能优化

Three.js大屏3D地图可视化实战:坐标转换、建筑拉伸与性能优化 ★ FEATURED ARTICLE
做了几年Three.js可视化项目这次接到一个大屏3D地图可视化需求时还是被几个问题给卡住了。说它难吧市面上现成的案例一大堆说它简单吧真正要把地图数据、业务数据和3D场景串起来做到大屏上看着好看、跑着流畅中间的坑真不少。这篇文章就把我这次从头搭建一个大屏3D地图的过程完整复盘一下坐标转换、瓦片底图、楼房拉伸、飞线特效、大屏适配、性能优化每个环节都给出可直接复用的代码和参数希望能给正在做同类项目的朋友省点时间。先说清楚这套东西能满足什么场景省市县级别的3D地图大屏、智慧园区或智慧城市指挥中心、物流调度和轨迹回放等。适合有一定Three.js基础、但没做过GIS数据落地的前端同学参考全部代码跑通后你能得到一个带有真实地图底图、立体的城市建筑、动态飞线和数据标注的3D大屏基础框架。1. 项目需求拆解与技术选型为什么最终落在Three.js上1.1 大屏3D地图可视化到底在做什么大屏3D地图和普通网页里的3D场景有个本质区别普通3D是“造一个虚拟世界”建筑、地形都是模型师做出来的而大屏3D地图是“把真实世界用3D方式还原”建筑的地理位置必须准确道路走向必须贴合实际。这就意味着第一优先级不是美术效果而是地理数据的精度和坐标转换的正确性。从我这次项目来看整个技术链路可以拆成四层地图数据层底图瓦片、建筑轮廓、坐标转换层经纬度到屏幕3D坐标、场景渲染层Three.js的相机、灯光、特效、业务交互层飞线、标签、点击弹窗。1.2 为什么选Three.js而不是三维GIS引擎项目启动时组里讨论过两个方向一是直接用Cesium或Mapbox GL这类专业三维GIS引擎二是用Three.js从零搭。最终选了Three.js原因是这几个项目痛点很精准地戳中了引擎方案的软肋业务定制需求多大屏需要很多特效比如飞线流动、涟漪扩散、柱状光柱、故障扫描等Three.js的ShaderMaterial可以完全控制而Cesium虽说也能做但定制成本和渲染管线的理解成本要高不少。全屏HUD融合大屏除了3D场景旁边还有大量ECharts图表、Tab列表用Canvas渲染的Three.js和DOM层天然隔离切换UI不会有任何卡顿也不存在引擎的弹窗遮挡问题。轻量化部署Three.js本身只是一个库按需引入不像Cesium那样是个重量级引擎。对于政务和指挥中心的内网环境静态文件体积和加载速度都很敏感。当然Three.js也付出了代价没有内置的地图切片加载方案所有GIS逻辑都要自己写。这一块我后面会详细展开这也是本项目中最核心、最容易出问题的部分。1.3 地图数据源的选型思路大屏项目的地图数据通常有两条路在线服务和离线GeoJSON。在线服务用瓦片底图优点是效果好、信息全缺点是需要联网且存在瓦片源防盗链问题。离线数据用GeoJSON建筑轮廓、行政区边界优点是渲染稳定、可控性强缺点是数据获取和整理麻烦。我的建议是在线底图 离线轮廓数据的组合方案底图用在线瓦片保证视觉真实感建筑轮廓和区域边界用离线GeoJSON保证关键业务数据的准确性。两个数据源互不干扰即使在线瓦片加载失败场景也不会白屏至少还有建筑框架在。这个容错思路在大屏这种对稳定性要求极高的场景里特别重要。2. 核心第一步地理坐标到Three.js坐标的正确换算2.1 Web墨卡托投影到底怎么算如果直接从项目里跳过去不做坐标换算后面所有建筑位置都会飘。地图的经纬度是球面坐标Three.js的世界坐标系是平面直角坐标系中间必须经过投影。大屏3D地图几乎统一用Web墨卡托投影EPSG:3857公式很简单x lon * 20037508.34 / 180 y ln(tan((90 lat) * PI / 360)) * 6378137注意这里的x和y单位是米而且是以本初子午线和赤道交点为零点的绝对坐标。但Three.js里我们不可能在几千万米的尺度上工作所以还要把坐标平移到场景中心附近。我记得第一次做的时候直接把投影后的坐标塞进Three.js结果整个地图缩成了一个点因为坐标值太大了浮点精度全丢。正确做法是先定一个中心点比如项目聚焦的城区中心把所有坐标都减掉中心点坐标得到相对坐标再乘一个缩放系数让1个单位约等于1米或几米然后场景才撑得开。具体代码实现如下function lonLatToWorld(lon, lat, center, scale) { const x (lon * 20037508.34 / 180 - center.x) * scale; const y (Math.log(Math.tan((90 lat) * Math.PI / 360)) * 6378137 - center.y) * scale; return new THREE.Vector3(x, 0, -y); }这里的scale是缩放因子控制整个地图在屏幕上的大小。推荐先用scale1看整体效果再根据项目聚焦区域幅度微调。还有个小细节经纬度和Three.js的Z轴方向是反的墨卡托y轴正方向是北向上Three.js里默认Z轴负方向是屏幕向里。我习惯把y取反后放到Z轴上这样相机摆在南边往北看地图朝向和真实世界一致。2.2 中心点选择与天地图坐标拾取中心点选的不好整个地图就会偏到场景角落。选取中心点的正确姿势是先看业务数据集中在哪个区域再让中心点尽量落在数据分布的中心。一个实用的工具是天地图的坐标拾取功能直接在网页上点击目标位置就能拿到经纬度非常直观。用在高德或者百度地图上也能拿到坐标但注意高德和百度的坐标系与标准WGS84有偏移如果底图源和坐标源用的是不同坐标系就会出现建筑贴不到底图上的问题后面排查你会疯掉的。我这次项目用了一个折中思路让后端把业务数据的坐标统一转成WGS84经纬度再给我前端只认这一种坐标系。这样不管是和天地图的瓦片底图对接还是和在线OSM底图对接都不会发生偏移。const center { x: 117.2 * 20037508.34 / 180, y: Math.log(Math.tan((90 31.8) * Math.PI / 360)) * 6378137 };合肥滨湖新区的中心大概在117.2, 31.8这个就是我项目里的实际中心点底图加载和建筑数据偏移都以它为参照。2.3 瓦片底图的加载与拼接原理在线瓦片底图的原理是把地图切成若干256x256的小方块按级别z、列号x、行号y组织。瓦片地址通常长这样https://webrd0{s}.is.autonavi.com/appmaptile?style6x{x}y{y}z{z}。接入Three.js时我最初想的是把瓦片贴到一个PlaneGeometry上然后动态更新纹理。实际发现性能开销不大但拼接很麻烦因为需要根据瓦片级别动态计算哪些瓦片在当前视野里。更聪明的做法是用多个PlaneGeometry拼接成底图网格每个Plane贴一个瓦片纹理。但是瓦片数量一多就会出现纹理闪烁和加载错位。我最终用了另一个方案把当前级别的9张瓦片3x3合成一张大纹理贴到一个大平面上。这样切换视野时只需要重新合成一次纹理拼缝问题也消失了。具体思路是用Canvas把9张Image画在一起再用CanvasTexture传给Three.jsfunction buildTileTexture(tiles, z, x, y) { const canvas document.createElement(canvas); canvas.width 768; canvas.height 768; const ctx canvas.getContext(2d); const promises tiles.map((tile, i) { const img new Image(); img.crossOrigin anonymous; img.src tile.url; return new Promise(resolve { img.onload () { const tx (i % 3) * 256; const ty Math.floor(i / 3) * 256; ctx.drawImage(img, tx, ty, 256, 256); resolve(); }; }); }); return Promise.all(promises).then(() new THREE.CanvasTexture(canvas)); }底图平面只需要放在y-0.1这样的微低位置和建筑层隔开避免深度冲突。还有一点要留意瓦片服务对crossOrigin很敏感部分在线源直接在response header里不带CORS配置浏览器会报错。遇到这种情况我会在本地用Node写个简单的代理接口把瓦片请求转发一遍并加上允许跨域的header。直接绕过防盗链不可取正经做法是找官方可商用的瓦片服务或自建瓦片服务。3. 城市建筑立体化从GeoJSON到ExtrudeGeometry3.1 楼宇数据的获取与清洗有了底图之后场景还是扁平的需要把建筑拉伸成立体形状。建筑轮廓数据一般用GeoJSON格式每个建筑是一个Polygon可能带height或levels属性。数据可以从OSMOpenStreetMap开放数据里提取但国内建筑层级数据不全很多只有轮廓没有高度。这时有两个选择一是从高德等数据接口拿建筑高度二是按业务需求给重点区域手动指定楼高。数据清洗这一步容易被忽略但恰恰是问题最多的环节。GeoJSON里经常出现多边形的顶点顺序不一致、自相交、甚至空几何对象。我用一个清洗函数统一处理只保留Polygon和MultiPolygon类型把所有多边形坐标数组转为二维点数组剔除面积为0的退化图形。3.2 用ExtrudeGeometry完成楼宇拉伸Three.js拉伸建筑用的是ExtrudeGeometry参数里最关键的是depth和bevelEnabled。depth就是楼高单位是米但要注意和之前场景缩放系数匹配。bevelEnabled要设成false否则建筑会出现倒角侧面看会有一条斜切缝。实际代码如下function createBuilding(shapePoints, height) { const shape new THREE.Shape(shapePoints.map(p new THREE.Vector2(p[0], p[1]))); const extrudeSettings { depth: height, bevelEnabled: false }; const geometry new THREE.ExtrudeGeometry(shape, extrudeSettings); geometry.translate(0, 0, -height / 2); // 让建筑底部对齐y0 return geometry; }顶点顺序要注意Three.js的Shape需要顶点按逆时针或顺时针一致排列如果反了拉伸出来的面会翻转或消失。清洗函数里我统一做了顶点顺序归一化用鞋带公式算面积如果面积小于0就翻转顶点顺序。还有一个渲染上的大坑ExtrudeGeometry生成的几何体顶点数量很大如果几百栋建筑每栋都独立生成Meshdraw call会直接爆炸。标准做法是把所有建筑的几何体合并成一个Geometry或BufferGeometry然后通过设置不同的颜色分区来区分不同建筑。合并之后场景draw call骤降帧率从20帧直接拉到满帧。3.3 业务数据点位的坐标挂载建筑立起来了接下来要把业务数据以标记点或者光柱的形式挂到地图上。业务点位的坐标是真实的经纬度要用前面写的lonLatToWorld换算成场景坐标。然后用InstancedMesh批量创建圆柱或光柱每个instance的矩阵就是业务数据的坐标。const dummy new THREE.Object3D(); businessData.forEach((item, i) { const pos lonLatToWorld(item.lon, item.lat, center, scale); dummy.position.set(pos.x, 0, pos.z); dummy.scale.set(1, item.value / maxValue * 20, 1); dummy.updateMatrix(); instancedMesh.setMatrixAt(i, dummy.matrix); }); instancedMesh.instanceMatrix.needsUpdate true;InstancedMesh的好处是一批点位只产生一次draw call在大屏点位数千的情况下也能扛得住。这里有个细节我必须强调标高问题。业务点位的y坐标如果是0光柱会有一部分埋在地底最好让y从0开始向上长底图和建筑的地面放在y0平面光柱底部和地面对齐。4. 飞线、光柱与大屏适配把“大屏感”做出来4.1 飞线动画原理与实现飞线是大屏3D地图的点睛之笔用来表示数据流动方向比如物流路线、信息流走向。飞线本质上是一条贝塞尔曲线从起点A到终点B在空中划一道弧线。我最早用Line绘制飞线发现视觉效果很单薄后来改用管线TubeGeometry加渐变纹理效果一下子立体起来。飞线的核心是让一个发光块沿着曲线运动实现方式有两种一种是用ShaderMaterial通过uniform传时间让uv.x大于某个值的部分透明形成流动感另一种是生成一系列小球体按时间参数更新位置。我倾向于用ShaderMaterial方案因为性能更好而且流动方向容易控制。核心Shader代码大致长这样varying vec2 vUv; void main() { vUv uv; gl_Position projectionMatrix * modelViewMatrix * vec4(position, 1.0); }uniform float time; uniform vec3 color; varying vec2 vUv; void main() { float flow fract(vUv.x - time * 0.5); float alpha smoothstep(0.0, 0.2, flow) * (1.0 - smoothstep(0.8, 1.0, flow)); gl_FragColor vec4(color, alpha); }这里飞线的uv.x方向是从起点到终点所以流动时vUv.x - time会让亮带沿着曲线跑。要注意控制uniform的更新频率不需要每帧都更新每帧更新time值即可。4.2 光柱、涟漪和扫描特效光柱在大屏上表示事件点或统计点通常是一个从地面向上放射的半透明圆柱。我用CylinderGeometry加上自定义Shader做了自发光和边缘发光效果。涟漪则是从事件点向外扩散的圆环可以用RingGeometry配合缩放动画实现。这两个特效的实现都不复杂但要注意关闭深度写入depthWrite: false否则多个半透明特效叠加时会出现渲染顺序问题看起来很不自然。还有一个常常被忽略的点抗锯齿。大屏分辨率一般是1920x1080或者更高开启MSAA后小字号标签和细线会清晰很多。WebGLRenderer默认antialias是false记得创建时显式打开const renderer new THREE.WebGLRenderer({ antialias: true, alpha: true });4.3 大屏适配1920x1080设计稿的scale方案大屏适配是大屏项目里最考验细节的部分。不同屏幕分辨率差异巨大有的拼接屏是5760x1080有的指挥中心是3840x2160。我日常用的一套成熟方案按1920x1080设计稿开发用CSS的transform scale做整体缩放。#screen { width: 1920px; height: 1080px; transform-origin: left top; transform: scale(calc(100vw / 1920)); }但这种方案有个隐患transform缩放会让整个页面包括Canvas都被缩放如果Canvas的像素比和物理像素不匹配画面会模糊。解决办法是让Canvas的渲染分辨率独立于CSS缩放Three.js里用renderer.setPixelRatio(window.devicePixelRatio)处理但如果页面被scale了devicePixelRatio其实已经变了需要自己计算实际需要的pixelRatio。我实践下来的做法是Canvas固定1920x1080作为内部逻辑尺寸外部用CSS缩放然后手动设置renderer.setPixelRatio(window.devicePixelRatio * (1920 / document.body.clientWidth))。这样不管屏多大3D画面都是清晰的。5. 大屏3D地图的性能优化从20帧到满帧的实战调优5.1 DrawCall是一切性能瓶颈的核心做Three.js项目的人都知道DrawCall这个词但大屏场景下它的威力会加倍放大。一条飞线20个DrawCall、一个光柱20个DrawCall、1000个点位1000个DrawCall满屏特效叠加下来几万个DrawCall帧率当然上不去。优化手段优先级我列一下第一优先合并静态几何体。所有建筑、底图、装饰面能合并的合并只保留一个Mesh。第二优先InstancedMesh。重复出现的点位、圆柱、光柱用InstancedMesh替代。第三优先消灭影子。大屏场景真的不需要实时阴影shadowMap一开帧率直接掉一半。第四优先关掉后处理。Bloom、SSAO这些后处理效果在大屏上是锦上添花但性能代价极高只在高端机器上才开。5.2 离屏渲染和纹理优化大屏场景有大量纹理地形、建筑贴图、飞线贴图。每张纹理都是显存里的宝贝一个512x512的纹理就需要大概1MB显存贴图做多了显存直接爆掉。优化措施是压缩纹理尺寸瓦片底图用256或512就够了光泽贴图用128标签贴图用64。同时把所有材质里的纹理anisotropy控制在一定范围内太高了会显著增加采样开销。还有一招离屏渲染值得提把不需要交互变化的静态画面先渲染到离屏纹理上然后大屏每帧只贴一张纹理。比如底图建筑这些很少变的部分可以在场景初始化后渲一次FBO之后每帧只是drawTexture。但这个方案要小心相机切换或摄像机动画时失效我的做法是只在完全静态的视角下启用这个优化。5.3 帧率监控与热更新策略优化无从下手的时候最好先量化问题。我在项目里加了一个简单的帧率统计脚本每秒刷新一次显示FPS。如果FPS低于50就按优先级砍特效如果高于55再考虑加细节。这种数据驱动的调优方式比盲目猜问题要高效太多。另外大屏上如果业务数据是实时更新的比如新点位不断加入场景不要直接向场景里addMesh。正确做法是用对象池预先创建一批Mesh放在池子里新数据来了取一个数据过期了回收。这样避免了频繁创建销毁对象导致的GC卡顿。我实测下来对象池方案比反复add/remove的方案帧率波动小得多。6. 常见问题与排查技巧实录6.1 高频问题速查表现象根因解决方案建筑位置和底图对不上坐标源坐标系不一致高德GCJ-02与WGS84混用统一用WGS84经纬度或用坐标转换库统一处理瓦片底图加载失败/请求报403瓦片服务防盗链改用自有瓦片服务服务端加转发代理和合法Referer建筑拉伸后看不到立面ExtrudeGeometry顶点顺序错乱清洗数据时用鞋带公式归一化顶点顺序飞线动画闪烁 / 透明叠加异常半透明物体的深度排序问题关闭depthWrite调renderOrder大屏模糊Canvas逻辑分辨率与物理像素不匹配手动调整setPixelRatio值帧率低DrawCall过高或显存占用过大合并几何体用InstancedMesh压缩纹理地图整体偏移中心点设置不对或比例尺不匹配通过天地图坐标拾取重新取中心点校准scale6.2 我一个一个踩过的调试细节瓦片加载失败这个问题在我项目里出现过两次第一次是高德瓦片在部分浏览器里报跨域错误第二次是某个网络环境里瓦片URL被墙。排查思路是先在浏览器Network面板检查瓦片请求的响应状态如果200但图片加载不出来多半是CORS问题如果直接403或404是防盗链或source地址失效。最后我在服务端加了路由代理通过同一个域名转发瓦片请求问题立刻消失。坐标偏移的问题我印象最深。项目初期所有建筑和点位明明都是从数据库拿的坐标但到了场景里就是和底图错位几公里。后来用高德地图的坐标拾取器对比了一下发现数据库里的经纬度是高德坐标系而底图用的天地图坐标系是WGS84两者相差大约几百到一千米。这个问题如果不追根溯源光调整场景里的偏移量永远治标不治本。还有一个小经验标签文字。Three.js里做文字标签最容易踩的坑是中文乱码和模糊。我的方案是用CSS2DRenderer渲染DOM标签而不是把文字画到Canvas纹理上。DOM标签清晰度完全取决于屏幕像素缩放也不会模糊而且天然支持点击事件省去了射线检测的麻烦。当然DOM标签会挡Three.js的鼠标交互所以在点击底座Mesh时要把DOM层pointer-events设为none需要点击文字时再开回来。6.3 项目的下一步扩展方向现在这套基础框架跑通之后后面可以做的扩展方向也顺理成章接入实时业务消息队列后飞线可以真实反映物流、车辆或事件的动态流向结合WebSocket推送点位上可以动态冒泡更新统计数字如果再引入3D Tiles标准还能把倾斜摄影模型直接叠加到场景里那样整个大屏的沉浸感和真实性会比单纯挤压建筑高出一个量级。这些方向我之前都调研过后续有实践成果了再写文章细聊。最后说点掏心窝的话。大屏3D地图这类项目表面上考验的是Three.js熟不熟练其实真正考验的是工程思维数据准不准、性能稳不稳、异常能不能兜底。我建议每个做这类项目的同学拿到需求后先别急着写代码花一天时间把数据源、坐标系、性能预算这三个方向梳理清楚后面能少走无数弯路。
阅读完成 · 觉得有帮助?
咨询建站