把地球上的每一栋楼都在浏览器里立起来,这件事听起来像个炫技 Demo,真做进项目里却是一整套关于数据、投影、渲染和内存的博弈。我在三个不同类型的项目里反复折腾过 Mapbox 加载全球3D建筑 这套方案:最早是给一个产业园区做可视化看板,只需要一平方公里的楼;后来做成城市级三维底图,需要覆盖主城区几百平方公里;最后一次直接把尺度拉到全球,用户从卫星视角一路滚轮推进到街角,看着建筑从地表"长"出来。三次踩的坑完全不一样,但底层逻辑是同一套:把矢量瓦片里的建筑轮廓,用 fill-extrusion 图层沿着高度字段挤出成立体块。
这篇文章想解决的问题很具体——你手里有一个 Mapbox 的地图实例,想在上面加载 3D 建筑,从一座城做到全球,既要画面好看,又要帧率稳、内存不炸。适合三类人看:刚接触 Mapbox GL JS、还在纠结图层的同学;已经能加出楼但发现掉帧、闪烁、楼压成平面的同学;以及正在评估"全球尺度到底要不要自己切瓦片"的技术负责人。我会把选型逻辑、参数计算、代码配置、真机实测和排查表都摊开讲,能直接抄的部分我会写清楚。
1. 全球3D建筑的技术路线与选型拆解
1.1 先问清楚:你要的是"全球"还是"一座城"
很多人一上来就问"怎么加载全球3D建筑",但其实需求可以拆成三个完全不同的量级,量级决定了技术方案,选错了后面全是白工。
第一种是单点级,一栋楼或者一个园区,几十到几百个建筑体。这种场景用fill-extrusion属于杀鸡用牛刀,直接上 glTF 模型、用custom layer或者干脆用three.js叠加更合适,因为你需要的是精细的立面、玻璃反射、楼顶设备这些细节,而fill-extrusion只能挤出没有门窗的素模。
第二种是城市级,覆盖几十到几百平方公里。这是fill-extrusion的主场,也是绝大多数"3D城市"项目的真实形态。数据量在百万级建筑这个量级,用矢量瓦片按需加载完全扛得住。
第三种才是全球级。全球尺度的难点根本不在渲染,而在于:全球建筑轮廓数据总量极大,你不可能一次性塞进客户端;用户在低缩放级别下看到的是整块大陆,这时候加载建筑毫无意义,必须有严格的分级策略。
我吃过一次亏:项目需求写的是"全球3D建筑",实际业务只在国内几个城市用。结果我按全球方案做了瓦片切片和分级加载,多花了三周,最后发现用户从没把镜头拖出过那两个城市。所以第一步永远是拿着地图问业务方:用户最远会缩到多大范围?答案往往能让工作量砍掉一半。
1.2 四种实现路径的取舍
确定了尺度,接下来是路线选择。我在不同项目里把这四条路都走过一遍,各自的代价差别很大。
| 路线 | 数据来源 | 上手成本 | 全球可行性 | 适用场景 |
|---|---|---|---|---|
| 直接读官方 composite 源的 building 图层 | 平台自带 | 极低,20 行代码 | 中,低缩放无数据 | 快速验证、城市级原型 |
| 官方 Standard 样式内置 3D | 平台自带 | 最低,改样式即可 | 高 | 不想碰代码的产品型项目 |
| 自建矢量瓦片 + fill-extrusion | 开放地图数据自切 | 中高,需要数据工程 | 高 | 全球尺度正式项目 |
| 第三方建筑模型批量加载 | 商业模型库 | 高 | 低 | 单体建筑精模展示 |
第一条路是我建议所有人都先跑的:用composite源里的building图层,配一个fill-extrusion,二十行代码就能看到楼立起来。它的价值在于让你在半小时内确认"这个视觉效果是不是产品想要的",而不是花三天搭数据管线之后发现方向错了。
第二条路很多人忽略。平台的标准样式里已经内置了三维建筑和地标,你只要在初始化时选对样式配置,连图层都不用加。缺点是可控性差,配色、高度逻辑、显示层级都被封在样式里,一旦产品要求"按建筑高度做颜色分层",你就得回到第三条路。
第三条路是正式项目的归宿。自己切瓦片意味着你完全掌控数据:哪些建筑要、高度字段缺失怎么兜底、最低显示到 zoom 几、属性怎么裁剪。代价是你要处理数据工程——全球开放建筑数据的原始体积极大,直接丢给切片工具会在内存上炸掉。
第四条路只适合少量精模。全球尺度下加载模型文件是不可能的,一个 glTF 几百 KB,一万栋楼就是几个 GB,用户浏览器会先崩给你看。
提示:不要用"能不能加载出来"判断方案好坏,要用"加载出来之后帧率和内存还剩多少"判断。前者十分钟能验证,后者决定项目能不能上线。
1.3 全球尺度的性能账,得先算清楚
挤出一栋楼要多少三角形?一个 n 边形的建筑轮廓,墙体需要 2n 个三角形(每面墙两个),屋顶需要 n-2 个三角形。也就是说一个规整的矩形楼(4 个顶点)= 8 + 2 = 10 个三角形。听起来微不足道,但乘上数量级就不一样了。
按 zoom 15 的瓦片算一下:赤道处瓦片宽度约 40075016 / 2^15 ≈ 1223 米,纬度 30 度附近乘以 cos(30°) ≈ 0.866,大约 1060 米。一个瓦片就是 1 平方公里出头。密集城区一平方公里有 2000 到 4000 栋建筑是常态,按平均 8 个顶点算,单个瓦片大约 15 万到 30 万个三角形。
这个量级对 GPU 完全不是问题,现代显卡轻松处理百万级三角形。真正的问题在别处:
- 单个瓦片的解析时间。GeoJSON 转瓦片后是二进制,但浏览器仍要解析几何、构建顶点缓冲。一个瓦片 3000 个 feature,解析时间能到几十毫秒,转到新区域时会有明显卡顿。
- 瓦片缓存的内存占用。GL JS 默认会缓存一定数量的瓦片,缩放级别越低、视野越大,同时驻留的瓦片越多。全球视角下如果没做分级,缓存会迅速膨胀。
- 绘制调用的填充率压力。半透明的
fill-extrusion需要排序和混合,像素填充成本远高于不透明。一个全屏的半透明建筑层能让中端笔记本掉到 20 帧。
我习惯在动手前先做一张这样的估算表,把"理论三角形数"和"实际帧率"分开看。前者告诉你数据要不要简化,后者告诉你渲染参数要不要收。
2. 数据准备:建筑轮廓、高度字段与瓦片化
2.1 高度字段从哪来,以及它有多不靠谱
3D 建筑最关键的一个属性就是高度。全球公开的建筑数据里,高度字段的覆盖率低得让人绝望——大部分建筑轮廓只有形状,没有height,甚至连building:levels(层数)都没有。
常见的高度字段有这么几个:
| 字段 | 含义 | 单位 | 覆盖率 |
|---|---|---|---|
height | 建筑总高度 | 米 | 低 |
min_height | 建筑底部离地高度 | 米 | 极低 |
building:levels | 地上层数 | 层 | 中低 |
building:min_level | 底部起始层 | 层 | 极低 |
building | 建筑类型(住宅、商业等) | 枚举 | 高 |
所以高度表达式必须写成"多级兜底":优先取height,没有就取building:levels × 层高,再没有就给一个默认值。层高取多少?我一般用 3.2 米,比常见的 3 米略高一点,因为统计数据里的层数往往不含设备层和挑高的大堂,实测下来 3.2 更接近真实轮廓高度。
这里有个细节值得说:兜底值不要用 0。高度为 0 的建筑在fill-extrusion里等于不存在,视觉上就是地图上缺了一块。给个 8 到 12 米的默认值,至少能让城市肌理看起来是连续的。我甚至在某个项目里做过更"讨巧"的处理——根据建筑的类型字段给不同的默认高度,住宅 12 米、商业 20 米、工业 8 米,虽然是假的,但视觉上比一片统一高度自然得多,产品方也没意见。
对于min_height,如果你的数据里没有,就统一填 0。但要注意:连接型建筑、天桥、架空层这些如果min_height缺失,会被从地面直接挤出来,视觉上会穿过道路。这类建筑建议在数据清洗阶段就单独标记,或者直接过滤掉。
2.2 自己切瓦片:工具选型与一条能跑通的命令
全球建筑数据的切片,工具层面主要有两个选择:tippecanoe和planetiler。
tippecanoe是老牌工具,C++ 写的,生态成熟,参数极其丰富,社区里大部分教程都基于它。它的短板是处理全球级数据时对内存的需求很高,得先按大洲甚至按国家切块,再逐块跑,最后合并。
planetiler是新一代,基于 JVM,流式处理,内存占用友好得多,跑全球数据是它的设计目标之一。代价是你需要配 Java 环境,而且自定义过滤逻辑不如 tippecanoe 灵活。
我现在的习惯是:城市级、数据量在千万 feature 以下,用 tippecanoe,快且省事;全球级,用 planetiler,或者至少按大洲切块后用 tippecanoe 分批跑。
一条能跑通的 tippecanoe 命令大概长这样:
tippecanoe -o buildings.mbtiles \ -zg \ --drop-densest-as-needed \ --extend-zooms-if-still-dropping \ --layer=building \ --include=building --include=height --include=min_height \ --include=building:levels --include=type \ --no-tile-compression几个参数我得解释一下为什么这么写:
-zg让工具自动猜最大缩放级别。它会根据建筑间距判断,密集城区通常猜到 14 到 15 就够,再高瓦片体积会爆炸。--drop-densest-as-needed是救命参数。瓦片超过大小上限时会丢弃最密集的要素,保证低缩放级别仍然能出图。不加这个,全球数据在 zoom 6 会直接切失败。--extend-zooms-if-still-dropping配合上一条,允许工具在必要时往更高的 zoom 延伸,避免低级别丢得太狠。--include系列用来裁剪属性。每个属性都会占字节,全球级数据下裁剪属性能让体积降 20% 到 40%,这不是优化,这是必做项。
切完的瓦片怎么交付?我推荐.pmtiles格式,单文件、支持按范围请求,不需要架设瓦片服务器。把文件丢到对象存储上,前端直接配一个 source 指向它,比维护一套瓦片服务省心一个数量级。
注意:全球数据切片是个体力活,我第一次跑的时候没预估好内存,机器跑到 30 多 GB 被系统杀掉,切了六个小时白干。建议先在单个国家或省份的数据上把参数调通,再上全球,别拿全量数据试错。
2.3 样式层配置还是代码里动态构造
这里有个容易纠结的点:3D 建筑的图层,到底写在地图样式里,还是在前端代码里 addLayer?
我的判断标准是谁来决定显示逻辑。如果配色、高度阈值、显示层级是设计定死的,基本不变,那写进样式里更合适——减少前端代码量,样式更新不用发版。如果显示逻辑跟业务状态联动,比如楼层越高颜色越深、被选中的楼要变色,那必须在代码里动态构造,因为setPaintProperty是实时改样式中唯一靠谱的途径。
实际项目里我基本走"代码里加,但把参数抽成配置对象"这条路。好处是同一个组件复用到不同项目时,只换配置不改逻辑:
const BUILDING_STYLE = { minzoom: 14, colorStops: [0, '#e8e3da', 20, '#d5cec3', 60, '#bfb6a8', 300, '#8d8171'], defaultHeight: 10, levelHeight: 3.2, opacity: 0.95 };这套结构让我在后面做移动端降级、做主题切换、做客户定制配色时都只需要动一个对象,不用满地图找散落的魔法数字。
3. fill-extrusion 核心配置:从二十行到可上线的图层
3.1 最小可用版本,先让它立起来
如果你只是想快速看到效果,这段代码就够了。它用的是平台自带的composite源,不需要任何额外数据准备:
map.on('load', () => { map.addLayer({ id: 'global-3d-buildings', source: 'composite', 'source-layer': 'building', filter: ['==', ['geometry-type'], 'Polygon'], type: 'fill-extrusion', minzoom: 14, paint: { 'fill-extrusion-color': '#d8d3cb', 'fill-extrusion-height': [ 'interpolate', ['linear'], ['zoom'], 14, 0, 15.05, ['coalesce', ['get', 'height'], 8] ], 'fill-extrusion-base': ['coalesce', ['get', 'min_height'], 0], 'fill-extrusion-opacity': 0.95 } }); });逐行拆一下关键点:
filter里那句['==', ['geometry-type'], 'Polygon']不是可选的。建筑图层里可能混有面要素之外的数据,不做过滤会在渲染时抛错或者出现莫名其妙的图形。
minzoom: 14是性能开关,低于这个级别整层直接不渲染。这个值怎么定?看你的数据密度。城市核心区 zoom 13 就能看到楼群轮廓了,但全球尺度下 zoom 14 以上才加载,能省掉 95% 的无意义流量。
fill-extrusion-height里那个interpolate是关键设计。它让高度在 zoom 14 到 15.05 之间从 0 平滑过渡到真实高度,视觉上就是"镜头推近时楼从地里长出来"。不加这个插值,你会看到楼在跨过某个缩放级别的瞬间"啪"地弹出来,非常出戏。
fill-extrusion-base用来处理架空建筑。虽然大部分数据里没有这个字段,但加上兜底 0 不会有副作用,反而在后续接入自己的数据时省一次改代码。
3.2 高度表达式:多级兜底怎么写才不出错
上面那段用的是最简单的兜底。真实项目里我会写成三级:
const heightExpr = [ 'coalesce', ['get', 'height'], ['*', ['get', 'building:levels'], 3.2], 10 ];这段表达式有一个很容易踩的坑:['*', ['get', 'building:levels'], 3.2]在属性缺失时返回的可能是null而不是 0。coalesce会跳过null,所以逻辑是通的。但如果你的数据里building:levels存的是字符串"5",乘法会得到NaN,整个图层可能直接不渲染。
我遇到过一模一样的坑:某批自采数据的层数字段被导出成了字符串,本地测试用的是官方瓦片所以没问题,一换成自己的数据整层楼全没了,排查了两个小时。解决办法是在数据清洗阶段强制转成数字,或者在表达式里加一层to-number:
const heightExpr = [ 'coalesce', ['to-number', ['get', 'height'], 0], ['*', ['to-number', ['get', 'building:levels'], 0], 3.2], 10 ];to-number的第二个参数是转换失败时的默认值,这个写法比['get']裸取安全得多。代价是表达式求值稍慢,但实测在几千个 feature 的瓦片上差别可以忽略。
还有一个隐藏问题:负数或异常大的高度。有的数据里height是-1表示"未知",有的是9999表示"未测量"。这两种值如果直接进渲染,前者会让楼凹到地下,后者会在城市里戳出一根通天柱。上线前一定要做一次数据体检,统计高度字段的分布,把异常值在切片阶段就裁掉或者改成合理值。
3.3 用颜色和高度做视觉分层
单色的 3D 城市看起来像一堆水泥块,没有层次。让颜色随高度变化是最低成本的提升:
const colorExpr = [ 'interpolate', ['linear'], heightExpr, 0, '#e8e3da', 20, '#d5cec3', 60, '#bfb6a8', 120, '#a89c8c', 300, '#8d8171' ]; map.setPaintProperty('global-3d-buildings', 'fill-extrusion-color', colorExpr);这个配色是从浅到深的暖灰,模拟自然光下高低建筑受光不同的效果。为什么用这么克制的色阶而不是彩色?因为 3D 建筑本质是底图,它上面还要叠业务数据(热力图、路径、标记点)。底图颜色饱和度一高,上面的业务层就全被吃掉了。
如果你的项目就是纯展示,那可以放开一点。我做过一版按建筑类型上色的:住宅暖白、商业浅橙、工业灰蓝。效果确实好看,但有个前提——数据类型字段的准确率得够。数据不准的话,会出现一片小区里蹦出几栋橙色商业楼,一眼假。
还有一种做法是按高度做明暗而不是色相变化,只在 HSV 的明度维度上调整。这样整个城市看起来是统一的色调,但高低关系清晰。我现在的默认方案就是这个。
3.4 光照、透明度与垂直渐变
fill-extrusion本身不带光源计算,楼体的亮面暗面是由图层的光照配置决定的。默认光照下,西南朝向的墙面亮、东北朝向的暗,这个默认值其实已经够用。想调的话:
map.setLight({ anchor: 'viewport', color: '#ffffff', intensity: 0.4, position: [1.15, 210, 30] });position三个分量分别是半径、方位角、极角,改动它们相当于在球面上挪动光源。anchor设为viewport时光照跟着视角走,楼体明暗关系在旋转地图时保持一致;设为map则固定在大地坐标系上,旋转地图时明暗会变。
我一般用viewport,因为用户旋转地图时如果明暗不停变化,视觉上会很晃。但只要项目里有"日出日落"这类氛围需求,就必须用map,那时候明暗变化本身就是需求的一部分。
再说两个容易忽略的参数:
fill-extrusion-opacity不要随便设成 0.6、0.7。半透明建筑需要正确的深度排序,而大批量几何的排序在浏览器里做得并不完美,会出现"近处的楼被远处的楼盖住"的穿插错误。我实测过,0.9 以上基本看不出问题,0.8 开始偶尔穿帮,0.6 以下必定穿帮。如果产品非要半透明效果,宁可调颜色明度也别调透明度。
fill-extrusion-vertical-gradient是个布尔值,开启后楼体会有一个从上到下的渐变,模拟环境光遮蔽,让楼看起来更"实"。默认是true,我建议保持。关掉它楼体会变成纯平面色,明显"发飘"。但要注意,这个参数在某些低端 GPU 上有额外开销,移动端降级时可以关掉换帧率。
4. 全球尺度:投影切换、分级加载与交互
4.1 globe 投影下的建筑表现
从城市做到全球,第一个绕不开的是投影。墨卡托投影在两极附近会极度拉伸,全球视角下格陵兰岛看起来比非洲还大,这在展示类项目里是不能接受的。GL JS 支持globe投影,把地球还原成一个球:
map.setProjection({ name: 'globe' }); // 需要在样式初始化阶段就指定时 const map = new mapboxgl.Map({ container: 'map', style: styleUrl, projection: { name: 'globe' }, zoom: 2 });球体投影下,3D 建筑的表现有个明显特点:低缩放级别完全看不到楼,只有推到街道级才会出现。这不是 bug,是必然的——一栋 30 米的楼在地球尺度上连一个像素都占不到。所以全球项目里的建筑层,minzoom一定要设得比城市项目高,我一般设 13 或 14。
另一个要注意的是投影切换的时机。GL JS 支持在运行时切换投影,但切换瞬间所有已加载的瓦片都要重新投影,如果这时候建筑层正在渲染,会有一到两秒的剧烈掉帧。我的做法是在setProjection前后各加一个小的过渡:先把建筑层透明度降到 0,切换投影,idle之后再恢复。用户感知到的是"楼淡出又淡入",比卡顿两秒好得多。
还有一点,v3 之后球体投影和地形(terrain)可以叠加使用。但建筑挤出和地形叠加时,楼体的底部是按地形高度贴合的,如果地形数据和建筑数据的高度基准不一致,会出现楼"陷进山里"或者"飘在半空"。这种情况我建议不要开地形,或者把建筑层的显示范围限制在平坦区域。
4.2 分级加载:让流量和帧率都可控
全球尺度的核心策略就四个字:分级加载。具体做法是把"显示什么"和"缩放到哪"绑定起来。
| 缩放级别 | 显示内容 | 数据来源 | 大概流量 |
|---|---|---|---|
| 0-5 | 只有地球/国家轮廓 | 底图瓦片 | < 100 KB |
| 6-10 | 主要城市轮廓、地形 | 底图 + 地形瓦片 | 数百 KB |
| 11-13 | 城市建筑群的整体体块 | 低精度建筑瓦片(简化版) | 1-3 MB |
| 14-16 | 完整 3D 建筑 | 完整建筑瓦片 | 按视野范围流式加载 |
| 17+ | 建筑 + 细节属性 | 完整瓦片 | 同上 |
实现上,最简单的做法是加两层:一层低精度建筑(简化过的轮廓,不含细节属性,从 zoom 12 开始显示),一层高精度建筑(zoom 14 开始显示)。两层用不同的瓦片源,靠minzoom和maxzoom自然切换。
map.addLayer({ id: 'buildings-lod-low', source: 'buildings-low', 'source-layer': 'building', type: 'fill-extrusion', minzoom: 12, maxzoom: 14.5, paint: { 'fill-extrusion-color': '#d5cec3', 'fill-extrusion-height': ['get', 'h'], 'fill-extrusion-opacity': 1 } });低精度层只保留h(高度)一个属性,颜色固定,不参与交互,这样它的瓦片体积可以压到完整版的三分之一。缩放级别跨过 14.5 时两层自动交替,用户察觉不到。
提示:两层交接的时候最容易出问题。如果低精度层的
maxzoom和高精度层的minzoom没对齐,会出现"楼消失一帧再出现"的闪烁。把两个值设成同一个数(比如都是 14),让它们在同一帧交接。
还有一种更省的办法是按视野裁剪数据。全球建筑数据里,99% 的楼用户永远看不到。如果你的业务只涉及若干热点城市,那就在切片阶段只保留这些城市周边的建筑,其他区域留空。这么做能把瓦片体积砍掉两个数量级,代价是用户缩放到其他区域时地图是平的。这个取舍必须和产品方明确沟通,别自己偷偷做。
4.3 拾取、高亮与 feature-state
只要项目涉及"点击某栋楼看详情",就必须处理拾取。有两个坑:
第一,fill-extrusion的点击命中判定是按俯视投影算的,斜视角下点楼体的上半部分可能点不中,用户会觉得"点不准"。缓解办法是把queryRenderedFeatures的radius参数调大一点,比如 5 个像素,容忍一定的偏差。
第二,用feature-state做高亮,必须先给 source 设promoteId或generateId。用官方composite源是做不了 feature-state 的,因为那个源没有暴露 id。所以一旦有交互需求,就必须走自建瓦片这条路,切瓦片时给每栋楼生成一个稳定的 id 字段,然后:
map.addSource('buildings', { type: 'vector', url: 'pmtiles://buildings.pmtiles', promoteId: 'bid' }); map.on('click', 'buildings-3d', (e) => { if (!e.features.length) return; const id = e.features[0].id; if (lastId !== null) { map.setFeatureState({ source: 'buildings', id: lastId }, { active: false }); } lastId = id; map.setFeatureState({ source: 'buildings', id }, { active: true }); });配合图层里的条件表达式:
'fill-extrusion-color': [ 'case', ['boolean', ['feature-state', 'active'], false], '#ff7a45', ['interpolate', ['linear'], heightExpr, 0, '#e8e3da', 60, '#bfb6a8', 300, '#8d8171'] ]这里有个务必注意的点:feature-state只对当前已渲染的瓦片有效。用户选中的楼如果因为缩放变化被卸载出视野,再回来时高亮就丢了。我的处理是在sourcedata事件里重新setFeatureState一次,用自己维护的状态表去对。这个逻辑不加,用户投诉率会很高。
5. 性能调优:从能跑到跑得稳
5.1 掉帧的三个真凶
3D 建筑层上线后帧率不达标,90% 的情况是下面三个原因之一。我按排查顺序列出来,基本能覆盖绝大多数场景。
第一个真凶:半透明。前面提过,fill-extrusion-opacity小于 0.9 会触发深度排序,全屏建筑层的填充成本会翻好几倍。实测在一台核显笔记本上,不透明版本 55 帧,0.7 透明度版本直接掉到 18 帧。所以除非产品硬性要求,透明度就用 1 或 0.95。
第二个真凶:过高的最大缩放级别。瓦片的体积随 zoom 呈指数增长,maxzoom从 15 提到 16,数据总量能翻 4 倍以上。而 zoom 16 以上的建筑细节,用户在大部分屏幕上是看不清的。我一般把maxzoom定在 15,然后用map.setMaxZoom(18)允许用户继续放大——超出瓦片层级后地图会把 15 级的瓦片放大显示,视觉上略微模糊但不影响使用,省下的流量非常可观。
第三个真凶:同时驻留的瓦片太多。GL JS 的瓦片缓存策略是按视野加缓冲区来的,pitch 打得越斜、视野越大,需要加载的瓦片越多。85 度的俯仰角下单屏可能要加载 60 个以上的瓦片。如果每个瓦片都带完整建筑数据,内存会迅速见顶。
相应的对策是把maxPitch收到 60 到 70 之间。我知道很多人喜欢把俯仰开到 85 做那种"贴地飞行"的效果,但那个视角下建筑层的性能代价是成倍增加的,而且远处的楼会因为精度问题糊成一片,视觉收益并不高。
5.2 内存与瓦片缓存的取舍
内存问题比帧率问题更隐蔽,因为它是渐进式的:刚打开页面一切正常,用户拖着地图逛了五分钟后突然卡死。我遇到过一次,在一个低配平板上,用户连续缩放十几次之后页面直接白屏。
原因在于瓦片缓存没有上限地增长。GL JS 内部有缓存机制,但 3D 建筑层的瓦片携带的顶点数据比普通图层大得多,几百个瓦片驻留就是几百 MB。
我的处理方案有三步:
第一,把建筑的属性裁到极限。切瓦片时只保留height、min_height、id三个字段,building类型、地址、用途这些全部去掉。属性在瓦片里占的空间比想象中大,全球级数据下裁属性带来的收益能到 40%。
第二,用高低两层代替一层。低层数据极度简化(多边形顶点数上限压到 8 个),只在低缩放级别显示,切片后体积很小,可以大量缓存而不用担心内存。
第三,监听error和idle事件做兜底。如果 30 秒内瓦片请求失败超过一定比例,自动把建筑层的minzoom往上调一级,牺牲一点体验换取可用性。这种"降级自愈"逻辑在弱网环境下非常有用。
5.3 移动端的降级清单
同样的方案,桌面端跑得好好的,到了手机上一定要做降级。我整理了一份默认降级表,每次新项目直接照抄,再按实际情况微调:
| 参数 | 桌面端 | 移动端 | 原因 |
|---|---|---|---|
minzoom | 13 | 15 | 手机屏幕小,低级别建筑看不清还占流量 |
maxzoom(瓦片) | 15 | 14 | 减少瓦片体积,牺牲少量细节 |
fill-extrusion-opacity | 0.95 | 1 | 干掉排序开销 |
fill-extrusion-vertical-gradient | 开启 | 关闭 | 省掉逐像素计算 |
maxPitch | 70 | 50 | 减少同屏瓦片数 |
| 建筑层数量 | 两层 | 一层 | 减少绘制批次 |
判断移动端不要只看 UA,我用的是window.matchMedia('(pointer: coarse)')加屏幕宽度双重判断。UA 判断在平板和二合一设备上经常出错,触屏指针判断更准。
还有一点是首屏策略。手机端我一般让建筑层延迟加载:等底图、地形、业务图层都idle之后再addLayer,用一个 500 毫秒的定时器兜底。这样用户看到的第一屏是完整的地图,建筑在后面的几百毫秒里"长"出来,感知上比一上来就卡着加载所有东西好。
6. 常见问题排查速查表
6.1 楼压成平面、完全看不见
这是最高频的问题,我按可能性从高到低列一下排查顺序。
先看高度表达式有没有返回 0 或null。在控制台里跑一下map.queryRenderedFeatures({ layers: ['global-3d-buildings'] }),看返回的 feature 属性里高度字段是什么。如果全是undefined,说明源选错了,source-layer名字不对,或者数据里的字段名和你以为的不一样。
再看minzoom和当前缩放级别。地图 zoom 是 12 但你设了minzoom: 14,当然是空的。这个错误在调试时特别容易犯,因为你觉得"我明明加了图层"。
然后检查 filter 是不是把所有要素过滤掉了。经典的错误是写了['==', 'extrude', 'true'],但你的数据里根本没有extrude字段。这个 filter 是从官方示例抄来的,抄的时候一定要确认数据源匹配。
最后看图层顺序。fill-extrusion必须叠加在底图之上,如果被某个不透明的填充层盖住,你会以为楼没渲染,其实是在下面。
6.2 楼体闪烁、Z-fighting、互相穿插
闪烁分两种,成因完全不同。
一种是相邻建筑的共面闪烁。两栋楼贴在一起,墙面完全重合,渲染时会打架。这在连排建筑密集的老城区特别常见。解决办法是切瓦片时用--detect-shared-borders做拓扑清理,让相邻建筑共享边界顶点,或者干脆用--coalesce把小块合并。
另一种是远近建筑的深度排序错误。这种情况我在半透明建筑层上见过太多次,根本原因是半透明绘制的顺序依赖。把fill-extrusion-opacity提到 1,问题立刻消失。这也是我一直反对用半透明建筑层的原因。
还有一种"穿插"其实不是渲染问题,而是数据问题:min_height字段的值大于height,挤压出来的几何体是反的,视觉上就是楼体穿透地面或者向上翻折。这个在数据体检阶段用一句 SQL 就能筛出来,别等到线上才发现。
6.3 首屏白屏与瓦片 404
自己切瓦片之后,最常见的问题是"地图能出来,但楼不出来,控制台一片 404"。
我踩过的具体原因有这么几个:
pmtiles协议没注册。用.pmtiles格式必须引入对应的协议注册库并调用一次注册函数,忘了这一步所有的瓦片请求都会失败,但地图底图因为是普通 URL 所以正常,很容易误判成"数据切错了"。- 对象存储的 CORS 没配。瓦片文件放在对象存储上,如果没允许跨域,请求会静默失败。这个错误在控制台里的表现可能只是一句含糊的网络错误,需要看 Network 面板里的具体响应头。
- 瓦片目录结构对不上。
map.addSource里的tilesURL 模板,{z}/{x}/{y}的顺序不能错,{z}写成了{Z}也会失败。这种低级错误我真的犯过,找了半小时。 source-layer名称不一致。切瓦片时用--layer=building指定了图层名,前端写成了buildings,结果就是"瓦片加载成功但没有任何要素"。这个错误最隐蔽,因为网络请求全是 200。
排查这类问题我的固定动作是:先在浏览器里直接打开一个具体的瓦片 URL,看能不能下载;能下载就用工具解开看里面的图层名和属性名;确认无误再回头看前端配置。逐层验证比在代码里瞎猜快得多。
7. 一些写在代码之外的经验
7.1 关于数据授权与合规边界
用开放地图数据做 3D 建筑,绕不开授权问题。全球公开的建筑轮廓数据大多有明确的署名要求,商业项目里必须在合适的位置标注数据来源,具体条款要去对应的数据提供方页面确认,别默认"网上能下就能商用"。
另外一个容易被忽略的点是建筑数据的敏感性。某些区域的部分建筑在公开数据里是被模糊化或者缺失的,这不是数据质量问题,而是数据本身的边界。做全球展示类项目时,最好不要承诺"覆盖每一栋建筑",把表述改成"展示主要城市建筑形态",避免不必要的麻烦。
7.2 关于视觉调校的几个手感
纯技术之外,3D 建筑好不好看,有几个参数的手感值得分享。
高度插值的区间长度。zoom 从 13 到 15 之间做高度过渡,跨度 2 级;我试过跨 1 级(13.5 到 14.5),楼"长出来"的过程太急,像弹出来的;也试过跨 3 级,过渡太慢,用户推到街道级还有一半楼是矮的。1.5 到 2 级之间最舒服。
建筑的颜色不要用纯白或纯灰。纯白在强光下会溢出,纯灰看起来像塑料。带一点点暖色偏移的灰(比如#d8d3cb)在地图底色的衬托下最自然。
楼顶和墙面的明暗差。默认光照下楼顶是最亮的,墙面次之。如果发现楼顶太亮刺眼,可以把intensity从 0.5 降到 0.35,整体的对比度会柔和很多。
建筑的"边界线"要不要画。给fill-extrusion加个outline其实不支持,很多人的做法是叠一层line图层。我的建议是别加——全球尺度下线条数量太多,性能代价大,而且建筑密集区域的线条会把城市糊成一片黑。真要强调轮廓,用颜色明度差就够了。
7.3 后续还能怎么扩展
这套基础方案做完之后,往下有几个自然的延展方向。
一个是建筑与业务数据联动。给每栋楼挂上能耗、入驻率、人流这类指标,用feature-state或者表达式动态改颜色和高度,做成一栋楼就是一张数据卡的形态。这个方向在园区和商业体项目里特别受欢迎。
另一个是时间维度。不同的时间点显示不同的建筑状态(在建、已建成、规划中),用图层透明度或者不同的挤出高度表现,配合一个时间轴滑块。技术难度不高,但演示效果很好。
还有一个是和地形的结合。在山地城市,建筑如果不贴合地形会显得很假。前面提过地形和挤出叠加有基准问题,但如果你的数据本身带了准确的基底高度,这个组合是能做出很好的效果的。
最后说个我在实际项目里慢慢形成的判断:3D 建筑这个功能,技术实现的难度远比想象中低,真正的功夫在数据清洗和性能调优上。我见过太多团队花一周把楼立起来,然后花一个月解决"为什么用户一拖地图就卡"。所以如果让我重做一遍,我会把 70% 的精力放在切片参数和数据体检上,剩下的 30% 才写前端代码。这个比例,才是一个能上线的全球 3D 建筑方案的真实成本结构。