第14章 - 三维地形天空与大气效果
14.1 地形是一条独立的渲染支路
开 3D 地形不是给地图”加个高度”,它改变了渲染管线的形态:setTerrain() 之后,Painter 会启用 render-to-texture,把每个瓦片先画进纹理,再按 DEM 高度贴到地形网格上;深度缓冲单独维护一份;符号(文字、图标)的遮挡由地形深度决定。
Terrain 类(src/render/terrain.ts)的职责注释写得很清楚,一共五件事:
- 通过内部
TerrainTileManager加载raster-dem瓦片 - 建一个尺寸等于地图容器的深度 framebuffer,用来算坐标可见性
- 把 render-to-texture 瓦片缓存起来
- 按瓦片坐标计算高程
- 生成地形网格(terrain mesh)
它构造函数里的默认值直接决定了显存开销:
this.exaggeration = typeof options.exaggeration === 'number' ? options.exaggeration : 1.0;
this.qualityFactor = 2;
this.meshSize = 128;
exaggeration默认 1.0,即 DEM 高程即真实高度;设为 2 就是夸张一倍,视觉冲击更大但海拔读数全错。meshSize默认 128,即每块瓦片切成 128×128 的四边形网格(约 1.6 万个顶点)。qualityFactor默认 2,render-to-texture 的尺寸是2 × tileSize,512 瓦片即 1024×1024 纹理。
源码注释里还写明:正常每个瓦片 1–3 张纹理,每张 1024×1024,另有”最近 150 个渲染瓦片”的缓存。这组数字解释了一个常见现象——开地形后移动端显存与掉帧明显上升,1024×1024 RGBA 纹理在 150 块规模下就是 600 MB 量级(150 × 1024 × 1024 × 4 ≈ 629 MB,未计 mipmap),实际实现按需分配,但量级足以说明为什么低端机要谨慎。
14.2 DEM 源:编码比分辨率重要
地形数据来自 raster-dem 源,高程编码在 PNG 的 RGB 通道里。编码方式有三种,选错就是”地形高度全部离谱”:
encoding |
通道算法 | 常见来源 |
|---|---|---|
'mapbox'(默认) |
-10000 + (R×256×256 + G×256 + B) × 0.1 米 |
Mapbox Terrain-RGB 规范的数据 |
'terrarium' |
(R×256 + G + B/256) - 32768 米 |
AWS Terrain Tiles / Mapzen |
'custom' |
由 redFactor、greenFactor、blueFactor、baseShift 四个参数组合 |
自建编码 |
encoding 是源级参数,写在 raster-dem 源里,不写默认按 mapbox 解。用 terrarium 数据不写 encoding,海拔会出现几千米的系统偏差——地形看起来”到处是山”,且所有高程都不可信。
map.addSource('dem', {
type: 'raster-dem',
tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'],
tileSize: 256, // terrarium 数据通常是 256
maxzoom: 14,
encoding: 'terrarium' // 关键:不写会按 mapbox 解
});
tileSize 要跟服务端实际一致:terrarium 生态的瓦片多为 256,Mapbox Terrain-RGB 多为 256(也有 512 的)。tileSize 写错不会报错,但高程采样点会错位,表现为地形”整体平移”或边缘错切。
14.2.1 开启地形
map.on('load', () => {
map.addSource('dem', {
type: 'raster-dem',
tiles: ['https://demotiles.maplibre.org/terrain-tiles/tiles.json'],
tileSize: 256,
encoding: 'mapbox'
});
map.setTerrain({ source: 'dem', exaggeration: 1.5 });
// 倾斜到 60° 以上才能看出立体感,默认 0° 是俯视
map.easeTo({ pitch: 65, bearing: -20, duration: 800 });
});
setTerrain() 的签名是 setTerrain(options: TerrainSpecification | null),传 null 关闭地形并调用 terrain.destroy()、释放 RTT 资源。开启时如果 options.source 不存在,会直接抛错:
Error: cannot load terrain, because there exists no source with ID: dem
这个错误同样来自 throw,不是 error 事件,必须 try/catch。它的常见触发条件是 addSource 写在了 load 之前、或者源 id 拼错。
getTerrain() 返回构造时的 TerrainSpecification,可以用它做开关状态判断:
const terrainOn = map.getTerrain() !== null;
map.setTerrain(terrainOn ? null : { source: 'dem', exaggeration: 1.2 });
14.3 TerrainControl 与一个完整的可跑页面
TerrainControl 是”地形开关”按钮的现成实现,构造参数就是一个完整的 TerrainSpecification:
map.addControl(new maplibregl.TerrainControl({ source: 'dem', exaggeration: 1.5 }), 'top-right');
它内部只做一件事:getTerrain() 有值就 setTerrain(null),否则 setTerrain(this.options),并监听 terrain 事件切换按钮图标类名(maplibregl-ctrl-terrain ↔ maplibregl-ctrl-terrain-enabled)。
下面是一个带地形、天空、雾的完整页面:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>地形 + 天空 + 雾</title>
<link href="https://unpkg.com/maplibre-gl@6.17.0/dist/maplibre-gl.css" rel="stylesheet" />
<style>body { margin: 0; } #map { height: 100vh; }</style>
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/maplibre-gl@6.17.0/dist/maplibre-gl.js"></script>
<script>
const map = new maplibregl.Map({
container: 'map',
style: {
version: 8,
sources: {
dem: {
type: 'raster-dem',
tiles: ['https://demotiles.maplibre.org/terrain-tiles/tiles.json'],
tileSize: 256,
encoding: 'mapbox'
},
osm: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors'
}
},
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
sky: {
'sky-color': '#0d1b2a',
'horizon-color': '#f4a261',
'fog-color': '#e9c46a',
'horizon-fog-blend': 0.6,
'sky-horizon-blend': 0.7,
'fog-ground-blend': 0.4,
'atmosphere-blend': 0.9
},
light: { anchor: 'map', position: [1.5, 90, 45], intensity: 0.6 }
},
center: [116.10, 39.95], // 京西山区
zoom: 11,
pitch: 68,
bearing: -30
});
map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), 'top-right');
map.addControl(new maplibregl.TerrainControl({ source: 'dem', exaggeration: 1.5 }));
map.addControl(new maplibregl.ScaleControl({ maxWidth: 110 }));
map.on('load', () => {
map.setTerrain({ source: 'dem', exaggeration: 1.5 });
});
// 地形加载完再读高程:getElevation 对已加载瓦片返回米数
map.on('idle', () => {
const elev = map.getElevation([116.10, 39.95]);
if (elev !== null) console.log('高程', elev.toFixed(1), 'm');
});
map.on('error', (e) => console.error('[map error]', e.error && e.error.message));
</script>
</body>
</html>
这个页面能直接验证三件事:pitch: 68 时天空与雾可见;exaggeration 改 2 会明显”拉高”山体;getElevation() 在地形瓦片加载完后返回非 null。
14.4 天空与雾:一套参数,两个层次
sky 属于 style 的根级配置,运行时用 map.setSky() / map.getSky() 改:
map.setSky({ 'sky-color': '#87ceeb', 'atmosphere-blend': ['interpolate', ['linear'], ['zoom'], 10, 1, 14, 0] });
规范与源码的默认值不一样,这是个容易踩的坑:
| 参数 | Style Spec 默认值 | setSky 未传参时源码默认 |
|---|---|---|
sky-color |
#88C6FC |
transparent |
horizon-color |
#ffffff |
transparent |
fog-color |
#ffffff |
transparent |
fog-ground-blend |
0.5 |
1 |
horizon-fog-blend |
0.8 |
未设 |
sky-horizon-blend |
0.8 |
未设 |
atmosphere-blend |
0.8 |
0 |
源码 Sky.setSky() 里,未传参数时会填一组”全透明 + atmosphere-blend: 0“的值,含义是”不画天空”。所以 map.setSky(undefined) 不是恢复规范默认值,而是关掉大气效果。要恢复默认得显式传对象。
14.4.1 雾只在 pitch ≥ 60 时出现
这一点在源码里是硬编码的:
export function calculateFogBlendOpacity(pitch: number): number {
if (pitch < 60) return 0; // 完全不可见
if (pitch < 70) return (pitch - 60) / 10; // 线性淡入
return 1;
}
pitch < 60 时雾的透明度为 0。很多人配好了 fog-color 却”看不到雾”,原因就是相机没倾斜。60°→70° 之间线性淡入,70° 以上全量。
雾的绘制范围是”从远裁剪面到地图中心”,它并不知道地平线在哪,所以靠近地平线时效果最自然;pitch 太高(> 80)反而会看到雾覆盖到近处。这也是为什么源码注释里说它”只适合做地平线附近的气氛”。
14.4.2 三个 blend 参数的含义
三个 blend 都是 0–1 的插值权重,容易记混,按”谁和谁混”来记:
sky-horizon-blend:天空色与地平线色的过渡。0 只用天空色,1 在中天就开始过渡。horizon-fog-blend:地平线色与雾色的混合。0 只用地平线色,1 只用雾色。fog-ground-blend:雾在地形上的分布。0 靠近地图中心,1 靠近地平线。
调的时候一次只动一个,否则排查不出是哪一项导致颜色不对。
14.5 光照与立体感
light 是全局光源,影响 fill-extrusion、hillshade、color-relief 等三维/明暗图层:
| 参数 | 默认值 | 说明 |
|---|---|---|
anchor |
'viewport' |
光照相对视口还是地图旋转 |
position |
[1.15, 210, 30] |
[r 半径, a 方位角°, p 极角°],p=0 正上方、180 正下方 |
color |
#ffffff |
光色 |
intensity |
0.5 |
0–1,越大明暗对比越强 |
map.setLight({ anchor: 'map', position: [1.5, 90, 45], intensity: 0.7, color: '#ffd8a8' });
// 模拟傍晚侧光:方位角 90°(正东),极角 45°(较低)
anchor: 'map' 时方位角以正北为 0° 顺时针;'viewport' 时以视口顶部为 0°。做”太阳跟着时间走”的动画,要用 anchor: 'map',否则地图一旋转光就跟着转。
14.6 排错
| 现象 | 原因 | 修法 |
|---|---|---|
| 地形一片平坦 | 源不是 raster-dem,或 encoding 与数据不匹配 |
核对源类型与 encoding |
| 地形高度整体偏几千米 | terrarium 数据按 mapbox 解了 | 加 encoding: 'terrarium' |
| 地形看起来”错位” | tileSize 与服务端不一致 |
256 数据写 256 |
cannot load terrain, because there exists no source with ID: dem |
source id 不存在,或 setTerrain 早于 addSource |
在 load 里先加源再设地形;try/catch |
| 天空完全看不到 | 相机 pitch 太低,天空在地平线外 | easeTo({ pitch: 60 }) 以上 |
| 雾看不到 | pitch < 60,或 atmosphere-blend: 0 |
提高 pitch;显式设 atmosphere-blend |
| 高德/OSM 底图被地形”撕开” | 底图瓦片与 DEM 瓦片边界不齐 | 保证两者 tileSize、maxzoom 一致,底图加 maxzoom 与 DEM 相同 |
“撕开”的本质是 RTT 时栅格瓦片按 DEM 网格重投影,两张瓦片金字塔错位时接缝就露出来。统一 tileSize 与 maxzoom 能消除大部分接缝。
14.7 生产建议
默认关闭地形。 显存与带宽开销大,且地形在低端 Android 上常触发上下文丢失(webglcontextlost)。需要时用按钮开,开之前先确认设备能力。
exaggeration 只做视觉夸张,不进业务计算。 高程读数一律用 getElevation() 拿 DEM 值,自己乘系数;不要反过来从夸张后的地形量算距离或坡度。
DEM 用 maxzoom 截断。 高程数据多在 z12–z14 收敛,设到 15 只是把同一块瓦片放大,带宽翻倍精度不变。
sky 与 light 一起配才有立体感。 只调 sky 是”背景板”,光源方向与天空色不一致(比如天空是日落色、光却从正上方来)会显得假。用 light.anchor: 'map' + 与 horizon-color 相近的 light.color 更容易协调。
监听 terrain 事件同步 UI。 TerrainControl 之外的自定义 UI(比如”海拔显示”面板)应在 map.on('terrain') 时更新状态,而不是轮询 getTerrain()。
14.8 本章小结
| 要点 | 事实 |
|---|---|
| 地形默认值 | exaggeration: 1.0、meshSize: 128、qualityFactor: 2(RTT 纹理为 2×tileSize) |
| DEM 编码 | 'mapbox'(默认)/ 'terrarium' / 'custom',选错全图高度失真 |
| 抛错而非事件 | setTerrain 的源不存在、矢量源 tileSize ≠ 512 都是 throw |
| sky 默认差异 | spec 默认 atmosphere-blend: 0.8;源码 setSky() 未传参时填全透明 + 0 |
| 雾可见条件 | pitch < 60 不可见,60–70 线性淡入,≥70 全量 |
| 光照 | anchor 默认 viewport,position 默认 [1.15, 210, 30],intensity 默认 0.5 |