做了几年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是缩放因子,控制整个地图在屏幕上的大小。推荐先用scale=1看整体效果,再根据项目聚焦区域幅度微调。还有个小细节:经纬度和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?style=6&x={x}&y={y}&z={z}。接入Three.js时,我最初想的是把瓦片贴到一个PlaneGeometry上,然后动态更新纹理。实际发现性能开销不大,但拼接很麻烦,因为需要根据瓦片级别动态计算哪些瓦片在当前视野里。
更聪明的做法是用多个PlaneGeometry拼接成底图网格,每个Plane贴一个瓦片纹理。但是瓦片数量一多就会出现纹理闪烁和加载错位。我最终用了另一个方案:把当前级别的9张瓦片(3x3)合成一张大纹理,贴到一个大平面上。这样切换视野时只需要重新合成一次纹理,拼缝问题也消失了。
具体思路是用Canvas把9张Image画在一起,再用CanvasTexture传给Three.js:
function 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到ExtrudeGeometry
3.1 楼宇数据的获取与清洗
有了底图之后,场景还是扁平的,需要把建筑拉伸成立体形状。建筑轮廓数据一般用GeoJSON格式,每个建筑是一个Polygon,可能带height或levels属性。数据可以从OSM(OpenStreetMap)开放数据里提取,但国内建筑层级数据不全,很多只有轮廓没有高度。这时有两个选择:一是从高德等数据接口拿建筑高度,二是按业务需求给重点区域手动指定楼高。
数据清洗这一步容易被忽略,但恰恰是问题最多的环节。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); // 让建筑底部对齐y=0 return geometry; }顶点顺序要注意,Three.js的Shape需要顶点按逆时针或顺时针一致排列,如果反了拉伸出来的面会翻转或消失。清洗函数里我统一做了顶点顺序归一化:用鞋带公式算面积,如果面积小于0就翻转顶点顺序。
还有一个渲染上的大坑:ExtrudeGeometry生成的几何体顶点数量很大,如果几百栋建筑每栋都独立生成Mesh,draw 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开始向上长,底图和建筑的地面放在y=0平面,光柱底部和地面对齐。
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;压缩纹理 |
| 地图整体偏移 | 中心点设置不对或比例尺不匹配 | 通过天地图坐标拾取重新取中心点,校准scale |
6.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熟不熟练,其实真正考验的是工程思维:数据准不准、性能稳不稳、异常能不能兜底。我建议每个做这类项目的同学,拿到需求后先别急着写代码,花一天时间把数据源、坐标系、性能预算这三个方向梳理清楚,后面能少走无数弯路。