第14章 - 三维地形天空与大气效果

14.1 地形是一条独立的渲染支路

开 3D 地形不是给地图”加个高度”,它改变了渲染管线的形态:setTerrain() 之后,Painter 会启用 render-to-texture,把每个瓦片先画进纹理,再按 DEM 高度贴到地形网格上;深度缓冲单独维护一份;符号(文字、图标)的遮挡由地形深度决定。

Terrain 类(src/render/terrain.ts)的职责注释写得很清楚,一共五件事:

  1. 通过内部 TerrainTileManager 加载 raster-dem 瓦片
  2. 建一个尺寸等于地图容器的深度 framebuffer,用来算坐标可见性
  3. 把 render-to-texture 瓦片缓存起来
  4. 按瓦片坐标计算高程
  5. 生成地形网格(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