上次接了一个城市网格化管理的展示项目,客户提的需求就一句话:“要一个能转起来的3D地图。”我们一开始心想这不简单?直接把原来的矢量地图加个倾斜角度,拉几个建筑盒子,结果评审时客户一句话怼回来:“这是模型,不是地图。”这句话让我回去认真研究了两周,才慢慢摸清Web地图服务开发里3D地图开发的水有多深。这篇东西不是官方文档复读,是我自己从踩坑里爬出来的总结,适合刚接触三维GIS、想从0开始做Web 3D地图的开发者,也适合那些已经在做2D地图、正准备往3D迁移的团队参考。
1. 3D地图开发的本质:从“画符号”变成“搭场景”
1.1 二维地图和三维地图到底差在哪
先想清楚一个前提:二维Web地图的核心是“符号化表达”,它把真实世界抽象成点、线、面,再通过图层和样式引擎画到屏幕上。缩放、平移、切换图层,本质上都是在操作一个平面坐标系里的几何对象。
三维地图完全不同。它要模拟的是一个带相机、带光照、带遮挡关系的立体场景。你在页面上看到一座建筑,不再是一个带边框的矩形面,而是一组由顶点、法线、纹理贴图组成的几何体,在GPU里经过顶点着色器、片元着色器、深度测试之后才呈现在屏幕上。这意味着,三维地图开发者的日常从“调样式”变成了“管场景”——管相机朝向、管LOD调度、管深度精度、管纹理显存,还要管坐标原点漂移后导致的精度问题。
这里有一个最常见的误区:很多从2D转过来的同学以为,给地图加个pitch(俯仰角)把房子“立”起来,就是3D了。严格说那只能叫2.5D或者叫伪3D。真正的3D地图,至少要满足两件事:一是数据本身具备三维几何或高度属性,二是渲染时存在真实的深度关系与空间变换。否则你只是给二维图形做了个透视变形,遇到相机旋转、建筑遮挡、地形起伏这些场景,立刻就会穿帮。
1.2 一个3D地图项目的完整数据链路
二维地图的数据链路通常是:数据源(OSM、测绘数据、业务数据)→ 矢量化/切片 → 瓦片服务 → 前端渲染。每个环节都相对成熟,出问题也好排查。
三维地图的数据链路要长得多。拿一个常见的城市级展示项目举例:
数据源可能包括遥感影像、DEM高程、建筑轮廓矢量、倾斜摄影模型、BIM模型、业务属性表。这些数据先要统一到同一个坐标系和高度基准,再经过不同工具的预处理:影像切片、高程地形切片、三维模型轻量化或重新拓扑、纹理压缩。接着需要把这些数据组织成适合Web流式加载的结构,最常见的是3D Tiles或矢量瓦片加建筑挤出。最后前端三维引擎读取这些数据,做视锥剔除、LOD切换、遮挡剔除,然后渲染。
任何一个环节的数据格式、坐标基准、坐标系方向不对,都会在最终画面上表现为“模型漂浮在空中”“建筑陷入地里”“纹理全黑”“白屏”等等。所以,做3D地图开发,先别急着选引擎,先把数据链路理清楚,后面才不会被各种诡异问题逼疯。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三维引擎选型:别再“谁火用谁”,先按数据反推技术栈
2.1 三个流派,三种哲学
当前Web端主流的3D地图方案,可以大致分三个流派:
第一个流派是以CesiumJS为代表的地理空间引擎。它定位就是“真三维地球”,内置全球影像、地形、3D Tiles、点云、BIM等格式支持,坐标系统天然面向全球尺度。它解决的是“如何把真实世界的高精度三维数据塞进浏览器”这个问题,适合数字孪生、智慧城市、自然资源、军事仿真等严肃GIS场景。
第二个流派是MapLibre GL JS、Mapbox GL JS这类矢量地图引擎。它们以矢量瓦片为核心,渲染性能高,样式表达能力强,在pitch角度配合fill-extrusion图层可以做建筑高度挤出,也能加载地形RGB做简单的起伏。它们的问题是:对真三维模型(倾斜摄影、BIM、点云)的支持很弱,不适合承载高精度的三维模型数据。但它胜在轻量、视觉表现力好,非常适合做可视化大屏、业务面板、POI检索、轨迹展示。
第三个流派是Three.js这类通用WebGL引擎。它跟GIS没有直接关系,但它的底层渲染能力很强、生态非常庞大。如果你的项目有大量自定义交互、粒子效果、模型动画,又想叠加地图瓦片作为底图,可以考虑用Three.js作为渲染核心,再通过代码把地图瓦片同步成纹理贴在球面或平面上。代价是:投影转换、瓦片调度、LOD、相机控制这些原本引擎帮你做好的事,全都要自己实现或接第三方插件。
另外还有Deck.gl、ECharts GL这类偏可视化的库。它们适合做数据图层叠加,不太适合当作完整的3D地图底子,通常是和其他引擎搭配使用。
2.2 决策框架:从业务数据反推技术栈
我在做选型时,不太喜欢列一堆功能特性对比,而是先问三个问题:
第一,你的核心数据是什么类型?如果核心是倾斜摄影模型、BIM、点云,那基本只能选Cesium这一类原生支持3D Tiles的引擎。如果核心是GeoJSON、矢量瓦片、业务坐标点,选MapLibre更轻快。如果核心是自定义的三维动画、工业模型、游戏化场景,Three.js更合适。
第二,交互到什么程度?你需要“双击定位到一个楼栋、点选建筑弹出属性、沿着道路飞行漫游”,这些在Cesium和MapLibre里都有成熟API。但如果你要做“第一人称走进建筑内部、拖拽模型部件、爆炸图动画”,那还是Three.js这类通用渲染引擎更顺手。
第三,团队技术栈和长期维护成本。一个前端团队如果全是熟悉OpenLayers/Leaflet的2D地图背景,硬上Cesium虽然有学习成本,但相对可控。如果直接自研一个基于Three.js的“类GIS引擎”,那从坐标投影到瓦片调度都得自己维护,工程量不是一个展示项目能覆盖的。
我把常用选型放到表里,方便对照:
| 场景 | 推荐引擎 | 理由 |
|---|---|---|
| 倾斜摄影/城市级实景/BIM | CesiumJS | 3D Tiles原生支持,全球地形影像成熟 |
| 大屏可视化/楼宇盒子/业务图层 | MapLibre GL JS | 轻量高性能,样式美观,学习成本低 |
| 自定义3D交互/游戏化场景 | Three.js | 渲染自由度最高,生态丰富 |
| 海量点数据/轨迹可视化 | Deck.gl | GPU批量渲染,叠加底图方便 |
| 图表+3D地图混合大屏 | ECharts GL | 图表能力强,但只能算辅助方案 |
2.3 关于“自研3D地图”的劝退发言
我见过不少团队,觉得Cesium太重、MapLibre不够自由,想基于Three.js自己写一套瓦片调度和坐标转换,做“更可控的3D地图”。我真心劝一句:除非你的团队有很强的图形学和GIS功底,否则别碰。
原因很简单:地图引擎里最难的部分不是画模型,而是数据调度。你看上去只是“加载了几百个瓦片”,实际上背后涉及瓦片层级选择、屏幕空间误差计算、视锥剔除、显存管理、线程调度。这些逻辑在Cesium里迭代了十多年才稳定,靠两三个前端用业余时间赶工,很难追平。如果项目周期紧,老老实实选一个成熟的引擎,把精力放在业务和数据处理上,才是正经路子。
3. 数据的深水区:坐标系、高程基准与三维格式
3.1 经纬度怎么变成屏幕坐标
三维地图渲染的底层,需要把经纬度坐标先转成三维空间坐标。这里有个关键点:日常Web GIS里常用的EPSG:3857(Web Mercator)平面投影,并不适合直接用于三维渲染。原因是Web Mercator在高纬度地区会产生极大的面积和形状变形,放到三维场景里会出现纬度越高、地物被拉得越离谱的现象。
三维引擎通常的做法是使用地心坐标系(ECEF,如EPSG:4978),把地球当成一个椭球体,每个经纬度加上高度,转换成椭球面上的三维直角坐标。Cesium内部就使用这个体系,但它对外提供的API仍然以WGS84经纬度和米制高度为主,开发者传经度、纬度、高度,底层自动完成转换。
用代码表达这个关系比较直观:
javascript复制// 经纬度 + 高度(单位米)→ ECEF三维坐标
const position = Cesium.Cartesian3.fromDegrees(
104.06, // 经度
30.67, // 纬度
500 // 高度,单位:米
);
所以,如果你有一份数据是平面坐标系的CAD图纸,想直接叠加到3D地图上,必须先做坐标转换。很多“模型位置不对”“模型跑到海里”的案例,本质都是坐标系没转对,而不是引擎的问题。
3.2 高程是三维地图最容易翻车的区域
二维地图只需要管经纬度,三维地图还要管高度。而高度恰恰是最容易出问题的地方。
全球比较常见的高程数据来源包括SRTM、ASTER GDEM、ALOS等,它们以DEM(数字高程模型)形式提供,每个像素存储一个地形高度值。引擎拿到DEM后,会生成地形网格,并在渲染时叠加影像纹理。
这里的关键坑是:不同来源的高程数据,基准可能不一样。有的是基于椭球高(以WGS84椭球为参考),有的是基于海拔高(以大地水准面为参考)。两者之间可能相差几十米。而倾斜摄影模型、BIM模型建模时用的基准,又不一定跟你的地形数据一致。结果就是:地形明明是对的,模型却悬浮在空中或者陷入地底。
排查思路很简单:先确认所有数据是不是统一到了同一套高程基准。如果统一了还悬浮,再检查模型自身是否有偏移值。我在项目中习惯在开发环境里加一个“高度偏移”参数,调试时手动调整模型高度,一旦确认误差稳定,再固化到数据预处理流程里,而不是在前端硬写。
3.3 常见三维数据格式与适用场景
做3D地图开发,绕不开下面几种格式:
- GeoJSON挤出:把建筑轮廓多边形转成带height属性的Feature,引擎通过fill-extrusion渲染成一个个立体“盒子”。适合快速做楼宇高度可视化,缺点是细节粗糙,不能表达建筑外观。
- glTF/GLB:通用三维模型格式,适合单体化精细模型,比如一个风机、一台设备。它本身不携带GIS坐标,需要额外定义一个位置和旋转角,引擎把它摆到地图上。
- 3D Tiles:Cesium提出后开放的三维数据流式切片规范,是把海量倾斜摄影、BIM、点云组织成树形结构按需加载的标准方案。现在很多商业软件和开源工具都支持导出。
- 点云(LAS/3D Tiles点云):激光扫描数据,体积很大,但有时候精度高到可以直接量测,常用于工程测绘和文物数字化。
选格式的原则是:能用挤出解决的就别上模型,能用glTF解决的问题就别上3D Tiles,只有达到城市级、区域级的大规模数据,才值得付出切片成本去上3D Tiles。数据格式越重,后面的性能优化越费劲。
4. 三维地图服务怎么发布:从3D Tiles切片到Web托管
4.1 3D Tiles的工作原理
3D Tiles并不是一个单一文件,而是一个数据组织和分级调度的规范。它的核心是一个名为tileset.json的入口文件,里面描述了一棵空间树结构,树的每个节点对应一块空间区域,节点里可能是一个或多个瓦片数据文件,例如b3dm(批量三维模型)、pnts(点云)、glb等。
渲染时,引擎根据相机位置、视锥范围、屏幕空间误差(Screen Space Error,SSE)来决定当前要加载哪些节点、卸载哪些节点。离得近、误差大的区域加载更精细的子节点,离得远、误差小的区域保留粗糙的父节点。这套机制跟2D地图的瓦片金字塔非常像,只不过2D瓦片是固定的图片切片,3D Tiles是动态的三维数据树。
理解了这一点,你对“3D地图为什么会卡”就能有一个基本判断:不是模型太大,而是每一帧要调度的数据量太大。加载策略合理,一个几十GB的倾斜摄影项目也能在浏览器里流畅转;加载策略不合理,一个几百MB的模型也能把帧率拖到个位数。
4.2 如何把模型发布成Web 3D地图服务
很多人在本地用专业软件打开模型没问题,一放到Web就白屏,原因是没有走“切片-托管-加载”这条路。
完整的发布流程大概是:
- 获取原始模型或测绘成果,例如OSGB格式倾斜摄影、RVT格式BIM、LAS点云。
- 选择一个切片工具,把原始数据转成3D Tiles。常见选择有:Cesium ion(在线处理,省事但可能有数据合规顾虑)、3d-tiles-tools、py3dtiles,部分商业软件如ContextCapture、SuperMap、大疆智图也支持直接输出3D Tiles。
- 处理坐标系和高程基准,确保输出数据与你的地图底图一致。这一步建议在切片前完成,而不是切片后在代码里做偏移。
- 把生成的整个目录(含tileset.json和数据文件)托管到Web服务器或对象存储。3D Tiles依赖静态文件访问,也不需要专门的GIS服务软件,Nginx、OSS、S3都能扛。
- 前端通过Cesium的Cesium3DTileset加载入口文件。
Nginx托管3D Tiles时,跨域是一个高频问题。如果你的页面在a.example.com,瓦片服务在b.example.com,浏览器会拦截跨域请求。解决办法是服务端配置CORS头,例如:
nginx复制server {
listen 80;
server_name tiles.example.com;
location /3dtiles/ {
root /data/gis;
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, OPTIONS";
}
}
这里顺便提醒一个低级但常见的坑:很多团队第一次加载3D Tiles时,直接在本机用file://协议打开页面,结果要么是跨域报错,要么是资源路径不对。开发阶段请务必起一个本地HTTP服务,别用双击HTML的方式调试。
4.3 用代码加载一个3D Tiles服务
在Cesium中加载一个已发布的tileset,核心代码很短:
javascript复制const viewer = new Cesium.Viewer('cesiumContainer', {
animation: false,
timeline: false,
baseLayerPicker: false
});
async function loadTileset() {
try {
const tileset = await Cesium.Cesium3DTileset.fromUrl(
'https://tiles.example.com/3dtiles/tileset.json'
);
viewer.scene.primitives.add(tileset);
viewer.flyTo(tileset);
} catch (error) {
console.error('3D Tiles加载失败', error);
}
}
loadTileset();
需要注意,Cesium3DTileset.fromUrl返回的是Promise,所以要用await或.then处理。加载成功后再调用viewer.flyTo,让相机自动飞到模型范围。不要一上来就手动设相机位置,因为你还不知道模型的包围盒在哪,飞到包围盒是最稳的。
5. 从零写一个3D地图页面:Cesium和MapLibre各一版
5.1 Cesium版本:加载全球底图和三维建筑
Cesium的页面骨架很有意思,它自带全套地球控件,你要做的更多是在Viewer上做减法。以下是一个最小可用示例:
html复制<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Cesium 3D地图最小示例</title>
<script src="https://cesium.com/downloads/cesiumjs/releases/1.120/Build/Cesium/Cesium.js"></script>
<link href="https://cesium.com/downloads/cesiumjs/releases/1.120/Build/Cesium/Widgets/widgets.css" rel="stylesheet">
<style>
html, body, #cesiumContainer { width: 100%; height: 100%; margin: 0; padding: 0; }
</style>
</head>
<body>
<div id="cesiumContainer"></div>
<script>
const viewer = new Cesium.Viewer('cesiumContainer', {
animation: false,
timeline: false,
baseLayerPicker: false
});
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(104.06, 30.67, 5000),
orientation: {
heading: Cesium.Math.toRadians(0),
pitch: Cesium.Math.toRadians(-45),
roll: 0
}
});
</script>
</body>
</html>
打开这个页面,你会看到默认的全球影像可以直接缩放浏览。这里的关键点在于理解camera.flyTo的三个参数:destination是相机目标位置,heading是朝向角,pitch是俯仰角(负数表示从空中往下看),roll是翻滚角。你可以在flyTo完成后,通过鼠标拖拽旋转、滚轮缩放观察画面变化,这是3D地图最基本的交互。
5.2 MapLibre版本:轻量建筑挤出
如果你不想引入Cesium这种“重炮”,只是想在业务大屏上快速做出一个有立体感的城市界面,MapLibre GL是更合适的方案。下面的示例加载一个基础地图,再通过GeoJSON数据挤出两栋建筑:
html复制<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>MapLibre 3D建筑挤出示例</title>
<link href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" rel="stylesheet" />
<style>
html, body, #map { width: 100%; height: 100%; margin: 0; }
</style>
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<script>
const map = new maplibregl.Map({
container: 'map',
style: 'https://demotiles.maplibre.org/style.json',
center: [104.06, 30.67],
zoom: 14,
pitch: 60,
bearing: -30
});
map.on('load', () => {
map.addLayer({
id: 'buildings-demo',
type: 'fill-extrusion',
source: {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { height: 80, name: 'A座' },
geometry: {
type: 'Polygon',
coordinates: [[
[104.060, 30.670],
[104.064, 30.670],
[104.064, 30.673],
[104.060, 30.673],
[104.060, 30.670]
]]
}
},
{
type: 'Feature',
properties: { height: 120, name: 'B座' },
geometry: {
type: 'Polygon',
coordinates: [[
[104.066, 30.670],
[104.070, 30.670],
[104.070, 30.672],
[104.066, 30.672],
[104.066, 30.670]
]]
}
}
]
}
},
paint: {
'fill-extrusion-color': [
'interpolate', ['linear'], ['get', 'height'],
0, '#3fa9f5',
100, '#f5a93f'
],
'fill-extrusion-height': ['get', 'height']
}
});
});
</script>
</body>
</html>
这里fill-extrusion是关键图层类型。它的两个核心paint属性是fill-extrusion-height(挤出高度,单位米)和fill-extrusion-color(颜色)。我故意用了一个interpolate表达式,让建筑高度超过100米后变成橙色,这样在大屏上能直观区分高层和低层。
注意,MapLibre的挤出只能表达垂直柱体,建筑顶部永远是平的,墙面上也不会有窗户纹理。如果客户要的是“看到真实建筑外观”,那它做不到,还是得回到倾斜摄影加3D Tiles。
5.3 加交互:视角飞行、点击拾取与属性弹窗
有了基础场景,后续最有用的交互是三个:飞行定位、点击拾取、属性展示。
Cesium的飞行定位很简单:
javascript复制function flyToPosition(lng, lat, height) {
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(lng, lat, height)
});
}
点击拾取在Cesium里用scene.pick,可以拿到你点击到的对象及其属性。如果是3D Tiles里的模型,属性往往存放在tileset的属性表里,需要额外做数据关联。
MapLibre的点击拾取更贴近二维开发习惯:
javascript复制map.on('click', 'buildings-demo', (e) => {
const feature = e.features[0];
new maplibregl.Popup()
.setLngLat(e.lngLat)
.setHTML('<strong>' + feature.properties.name + '</strong><br/>高度:' + feature.properties.height + '米')
.addTo(map);
});
map.on('mouseenter', 'buildings-demo', () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'buildings-demo', () => {
map.getCanvas().style.cursor = '';
});
这个交互模式在业务系统里几乎必用:鼠标移入变小手,点击弹出详情。很多刚上手的人会忘记在mouseenter和mouseleave里切换光标,导致用户不知道哪些对象可以点。
6. 性能优化与排障:帧率上不去,先查这四件事
6.1 把GPU资源吃光的三大元凶:几何体、纹理、draw call
3D地图卡顿,首先查渲染侧的三个指标:几何体数量、纹理内存、draw call数量。
几何体太多,顶点缓冲和索引缓冲占内存,顶点着色器压力大。纹理太大太多,显存直接被占满,加载时还会触发纹理压缩和上传的卡顿。draw call太多,CPU与GPU之间的通信开销成倍增长,即使每个对象都很简单,也照样卡。
优化手段从低到高排队:对静态模型做合并或实例化绘制,减少draw call;对纹理做压缩和尺寸归一化,统一使用图集;对看不清的小物体直接不加载或降低细节。Cesium的3D Tiles天然做了类似的分层调度,但如果你用Three.js做自定义场景,这些优化就要自己设计。
6.2 引擎侧的三板斧:LOD、剔除、瓦片调度
在数据侧,最有效的优化是让引擎“少画没必要的东西”。这里有三板斧:
第一是LOD(细节层次)。离相机近的地方加载高精度模型,远的地方加载简化模型,再远就直接不显示。3D Tiles的SSE机制就是干这个的。如果你自己组织数据,一定要按LOD思路拆分模型,别把整个小区当成一个单体模型加载。
第二是视锥剔除。只渲染相机视锥范围内的对象。Cesium默认开启视锥剔除,但如果你在Three.js场景里用了大量非优化的Object3D,忘记设置包围盒,剔除就会失效。
第三是瓦片调度策略。包括预加载策略、卸载策略、并发上限。Cesium有内置调度器,一般无需手动调。但如果你发现相机移动时场景长期白茫茫一片,可能是tileset的maximumScreenSpaceError设得太小,引擎疯狂加载高细节瓦片,反而拖慢了首屏。把这几个参数适度调大,比如从默认的16调到32,往往能明显改善。
6.3 高频问题排查链路
最后分享几个我实际排查过的高频问题,以及我的处理思路。
模型悬浮或陷入地形。先查数据高程基准,再查模型自带的偏移参数。Cesium里可以临时通过model.heightReference和高度偏移字段做测试,但最终应该在数据预处理阶段修正。
白屏。按顺序检查:控制台是否有CORS报错,是则检查服务器跨域头;入口tileset.json加载了没有,没有则检查URL路径;如果tileset.json加载了但场景还是空的,检查tileset内部数据文件的相对路径;最后检查相机位置是否在模型范围内。
模型闪烁、剧烈抖动。这种情况往往是深度精度不足。原因是相机近裁剪面和远裁剪面距离比过大,深度缓冲的精度被稀释了。可以调整相机near/far参数,或者启用对数深度缓冲。Cesium里可以设置viewer.scene.logarithmicDepthBuffer = true,能解决很多近景闪烁问题。
内存持续上涨。原因可能是大量纹理没有释放,或者3D Tiles持续加载后没有卸载。用浏览器开发工具的Memory面板抓一次堆快照,对比操作前后的对象数量,定位到谁在增长。如果是Cesium,可以手动调用tileset.trimLoadedTiles()释放离屏瓦片。
帧率低找不准方向。用渲染面板的FPS/GPU指标辅助定位,Cesium提供了scene.debugShowFramesPerSecond=true,可以直接在画面左上角显示帧率;再做一次二分切换:只加载底图时帧率多少,加载模型后帧率多少,加载交互后帧率多少,能快速锁定是哪一环拖了后腿。
最后再分享一个小技巧:刚接触3D地图开发时,别一上来就追求“城市级数字孪生”这种大目标。把一个小区、三栋楼、一个交互弹窗跑通,远比对着几百GB的倾斜摄影数据干瞪眼有用。选一个在你业务里最常见的数据格式,把它从原始数据一直推到浏览器画面里,整个过程走一遍,你才能真正理解Web地图服务开发里3D地图开发的边界到底在哪。之后再看那些花哨的效果,基本上都是在这个链路上加料而已。
