第15章 - 三维地形与环境特效

15.1 先说清能力边界:main 没有地形网格

很多教程会把 MapLibre 的 raster-dem 和「3D 地形」混为一谈。查一遍 main 分支的源码就能确认:当前 main 没有地形网格渲染(terrain mesh)。

include/mln/style/layers/   里没有 terrain 相关图层
src/mln/style/sources/      只有 raster_dem_source.cpp,没有 terrain_source
src/mln/renderer/           只有 render_raster_dem_source.cpp,没有地形网格的渲染源

main 上跟「高程 / 三维」有关的实际能力是这四样:

能力 入口 需要的数据
山体阴影 hillshade 图层 + raster-dem 源 DEM 瓦片(PNG 编码高程)
高程配色 color-relief 图层 + raster-dem 源 同上,按色带映射高程
建筑挤出 fill-extrusion 图层 多边形 + height / base 属性
光照 Light(style 根节点) 无需额外数据

要做真三维地形(顶点位移、地形遮挡、沿坡度的线),得自己上 CustomLayer(第 13 章)或走 MapLibre GL JS。这一章讲前四样:参数、数据编码、性能与坑。

15.2 DEM 数据源:两种编码

include/mln/util/tileset.hpp 定义了 DEM 的两种编码:

enum class RasterEncoding : std::uint8_t {
    Mapbox,    // Mapbox DEM
    Terrarium, // Terrarium DEM
};

同一个高程值,两种编码的存法完全不同:

编码 解码公式 常见数据源
Mapbox -10000 + ((R * 256 * 256 + G * 256 + B) * 0.1) Mapbox 系 DEM、多数商业瓦片
Terrarium (R * 256 + G + B / 256) - 32768 Mapzen/AWS Terrain Tiles(elevation-tiles-prod)

编码写错的典型表现:地形整体沉到海平面以下(高程全变负),或者山体阴影方向完全不对。Mapbox 编码的基准是 -10000,Terrarium 是 -32768,两者差 22768 米——不是精度问题,是语义问题。

src/mln/geometry/dem_data.cpp 里 DEMData 负责解码,构造函数的第二个参数就是 Tileset::RasterEncoding。它把三种编码统一成 4 个 float 的 unpack 向量(getUnpackVector()),后续采样直接点乘。解码结果带一圈 1 像素的边,用于跨瓦片计算坡度时插值。

15.2.1 声明数据源

mln::Tileset tileset;
tileset.tiles = {"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"};
tileset.zoomRange = {0, 15};
tileset.scheme = mln::Tileset::Scheme::XYZ;
tileset.rasterEncoding = mln::Tileset::RasterEncoding::Terrarium;
tileset.attribution = "Terrain Tiles: Mapzen/AWS";

auto source = std::make_shared<mln::style::RasterDEMSource>(
    "dem", tileset, /* tileSize = */ 512, std::nullopt);
map.getStyle().addSource(source);

tileSize 传 512 还是 256 要跟数据源的实际瓦片尺寸对上。AWS Terrain Tiles 的 terrarium 是 256×256 的 PNG,声明成 512 会让坡度被算成一半。RasterDEMSource 的构造函数签名是 (id, urlOrTileset, tileSize, optional<SourceOptions>),第四个参数可以覆盖 minzoom/maxzoom。

15.2.2 用 TileJSON 时注意 zoom 覆盖

SourceOptions 里有一个容易忽略的注释:style 里内联的 zoom 范围优先于拉回来的 TileJSON。这跟 maplibre-gl-js 的行为一致。所以:

{"source": {"type": "raster-dem", "url": "https://example.com/dem.json", "maxzoom": 12}}

这段 JSON 里的 maxzoom: 12 会覆盖 TileJSON 里的 maxzoom: 15。想用数据源声明值就别写内联。

15.3 hillshade 图层:参数都在光照上

HillshadeLayer 是自动生成的类(头文件第一行就写着 // This file is generated. Do not edit.),全部属性围绕「光从哪来、阴影多深」:

属性 类型 默认值 说明
hillshade-exaggeration number 0.5 明暗强度,0 是平面,1 是全对比
hillshade-illumination-direction numberArray 335 光源方位角,0–359
hillshade-illumination-altitude numberArray 45 光源高度角,0 是日落、90 是正午
hillshade-illumination-anchor enum viewport map(正北)或 viewport(视口顶部)
hillshade-method enum standard standard 或 multidirectional
hillshade-accent-color color #000000 阴影侧强调色
hillshade-shadow-color colorArray #000000 阴影颜色
hillshade-highlight-color colorArray #FFFFFF 高光颜色

hillshade-illumination-anchor 的默认值是 viewport,这是最容易被当成 bug 的行为:拖地图旋转时,光跟着视口转而不是固定在地理坐标系里,所以山体明暗在转地图时会「流动」。要物理上正确就改成 map。

hillshade-method: multidirectional 允许 illumination-direction / illumination-altitude 传数组、给多个光源,代价是每个像素算多次光照。单光源够用的场景别开。

auto layer = std::make_unique<mln::style::HillshadeLayer>("hillshade", "dem");
layer->setHillshadeExaggeration(0.8f);
layer->setHillshadeIlluminationAnchor(mln::style::HillshadeIlluminationAnchorType::Map);
layer->setHillshadeIlluminationDirection(std::vector<float>{315.0f});
layer->setHillshadeShadowColor(std::vector<mln::Color>{mln::Color::black()});
layer->setHillshadeHighlightColor(std::vector<mln::Color>{mln::Color::white()});
map.getStyle().addLayer(std::move(layer));

15.3.1 Technique: 山体阴影 + 底图叠加

hillshade 图层默认是不透明的灰白图。实际产品里几乎都要调透明度再叠在矢量底图上,做法是把 hillshade 放在底图之上、标注之下,然后用 raster-opacity 一类手段控制强度——hillshade 没有 opacity 属性,控制强度的正确入口是 exaggeration 加 accent-color 的亮度。想要「半透明山影」,更常见的做法是把 hillshade 渲成 PNG 再作为 raster 源叠加,或在样式里用 background-color 配合。

15.4 color-relief:把高程映射成色带

ColorReliefLayer 只有两个属性,重点全在 color-relief-color 上:

属性 类型 默认值
color-relief-color colorRamp null(必须显式设置)
color-relief-opacity number 1

color-relief-color 的类型是 ColorRampPropertyValue,值是「高程 → 颜色」的阶梯,用表达式给出。这个属性没有默认色带,不设就是透明,什么都不显示。

// 用表达式构造色带:低处绿、中段棕、高处白
mln::style::ColorReliefLayer layer("relief", "dem");
auto ramp = mln::style::ColorRampPropertyValue::fromExpression(
    mln::style::expression::parse(
        "interpolate",
        mln::style::expression::parse("linear"),
        mln::style::expression::parse("elevation"),
        0.0, mln::Color::fromString("#006837"),
        1500.0, mln::Color::fromString("#a67c52"),
        4000.0, mln::Color::fromString("#ffffff")));
layer.setColorReliefColor(ramp);

elevation 是 color-relief 专用的数据驱动输入,直接取 DEM 解码后的米数。同一个表达式在 hillshade 上不可用——这是两种图层能力不同的地方。

15.5 fill-extrusion:三维建筑与光照

fill-extrusion 是 main 上唯一「真的会画立体几何」的图层。属性分三组:几何高度、材质、光照响应。

属性 类型 默认值 说明
fill-extrusion-height number 0 顶面高度(米)
fill-extrusion-base number 0 底面高度(米)
fill-extrusion-color color #000000 顶面颜色
fill-extrusion-opacity number 0.9 透明度
fill-extrusion-vertical-gradient boolean true 侧面是否有垂直明暗渐变
fill-extrusion-translate / -translate-anchor     平移
auto ext = std::make_unique<mln::style::FillExtrusionLayer>("buildings", "buildings");
ext->setFillExtrusionHeight(mln::style::PropertyExpression<float>(
    mln::style::expression::parse("get", "height")));
ext->setFillExtrusionBase(mln::style::PropertyExpression<float>(
    mln::style::expression::parse("get", "min_height")));
ext->setFillExtrusionColor(mln::style::PropertyExpression<mln::Color>(
    mln::style::expression::parse("get", "color")));
ext->setFillExtrusionOpacity(0.85f);
map.getStyle().addLayer(std::move(ext));

height / min_height 是矢量瓦片里的属性名,单位米。建筑数据里这两个字段缺失是最常见的「挤出看不见」原因——高度是 0,几何退化成一个平面。

15.5.1 Light:全局光照只影响 fill-extrusion

Light 挂在样式根节点,setLight() 改的是全局光照。属性组很短:anchor、color、intensity、position。它作用于 fill-extrusion 的明暗计算,对 hillshade 没有影响——后者走自己的光照参数。

auto light = std::make_unique<mln::style::Light>();
light->setAnchor(mln::style::LightAnchorType::Viewport);
light->setColor(mln::Color::fromString("#ffffff"));
light->setIntensity(0.4f);
light->setPosition(mln::style::Position{1.15, 210.0, 30.0});  // radial, azimuthal, polar
map.getStyle().setLight(std::move(light));

Position 的三个分量是 radial(径向距离)、azimuthal(方位角,度)、polar(极角,度)。方位角 210 表示光从西南来,这是经典默认值。改光位后 fill-extrusion 的侧面明暗立刻变,但 hillshade 不动——两张图不一致就是这么来的。

15.6 性能:DEM 比矢量瓦片更贵

三个量级差异值得记住:

  • 解码在 worker 线程。RasterDEMTileWorker::parse() 接收瓦片字节 + RasterEncoding,解码成 DEMData 后回主线程。每个 Style 有 4 个 worker,DEM 解码和矢量瓦片解析抢同一批线程。
  • 边填充要读邻瓦片。DEMData::backfillBorder() 需要相邻瓦片来算边界坡度,意味着每个 DEM 瓦片至少要等 4 个邻居的元数据。放大到 DEM 的 maxzoom 之上时,这个代价会乘以父瓦片数量。
  • hillshade 是逐像素计算。exaggeration、多光源、高 tileSize 都会线性增加片元成本。手机上把 hillshade 铺满屏、同时开着 fill-extrusion,掉帧是正常的。

实践上的三条:

  1. DEM 的 maxzoom 控制在 12–13。再高的级别用户看不到差别,内存和绘制成本却翻倍。
  2. hillshade 只放一份。同一个 DEM 源同时挂 hillshade 和 color-relief 会解码两次——RenderRasterDEMSource 缓存了解码结果,但两个图层各自建立 bucket。
  3. 移动端 fill-extrusion 与 hillshade 不要同时铺满全屏。它们的深度测试与混合都在同一帧内,中端机很容易掉到 30 fps 以下。

    15.7 排错

山体阴影整片灰,看不出起伏:raster-dem 的 rasterEncoding 与数据源不匹配。AWS Terrain(terrarium)配成 Mapbox 编码,高程会整体偏移约 2.2 万米,坡度几乎为常数,结果就是一片均匀灰。用 DEMData::get() 在测试里采样几个已知点验证。

阴影方向与光照方向相反:hillshade-illumination-anchor 是 viewport(默认)时,光源相对视口固定,转地图时方向会变。需要地理固定就设 map。另一可能是把 illumination-direction 与 Light.position.azimuthal 当成同一个东西——它们分别属于两个图层体系。

fill-extrusion 什么都不显示:按顺序查三件事。数据里有没有 height 属性(没有就是 0 米);图层的 source-layer 是否对上;相机 zoom 是否低于该图层的 minzoom。三者都对还不显示,看 fill-extrusion-opacity 是不是被设成了 0。

建筑底面陷进地面:fill-extrusion-base 用了 min_height,但数据里没有这个字段,表达式返回 null 导致整个要素被过滤。给表达式加 fallback:["coalesce", ["get", "min_height"], 0]。

color-relief 全透明:color-relief-color 没有默认值,不设就是 null。这是设计如此,不是 bug。

DEM 瓦片数暴涨:检查样式里内联的 maxzoom 有没有覆盖掉 TileJSON 的值(见 15.2.2),以及有没有给 raster-dem 源配 bounds。没有 bounds 时源的覆盖范围按全球算,低级别瓦片会多取一圈。

15.8 参数速查

参数 所属 默认值 备注
RasterEncoding::Mapbox tileset — 解码基准 -10000
RasterEncoding::Terrarium tileset — 解码基准 -32768
raster-dem.tileSize 源声明 调用方传 必须与数据实际尺寸一致
hillshade-exaggeration hillshade 0.5 明暗强度
hillshade-illumination-direction hillshade 335 方位角 0–359
hillshade-illumination-altitude hillshade 45 高度角,0=日落 90=正午
hillshade-illumination-anchor hillshade viewport 改 map 才地理固定
hillshade-method hillshade standard multidirectional 支持多光源
color-relief-opacity color-relief 1 —
fill-extrusion-opacity fill-extrusion 0.9 —
fill-extrusion-vertical-gradient fill-extrusion true 侧面明暗渐变
Light.position style {1.15, 210, 30} radial / azimuthal / polar

15.9 生产建议

先确认需求是「看起来立体」还是「真三维」。前者用 hillshade + fill-extrusion 就够,成本是几行样式;后者需要地形网格,main 不支持,要换渲染方案或自己写 CustomLayer(第 13 章的矩阵参数直接可用)。

DEM 源单独配置网络与缓存。高程瓦片体积小但数量多,走离线缓存时很容易把 50 MB 的 ambient cache 挤爆。用独立的 FileSource 配置或把 DEM 打进预置包(第 11 章)。

exaggeration 不要超过 1.0。超过之后阴影对比过强,山脊会变成纯黑块,并且在低 zoom 下出现明显的马赛克。真实产品一般在 0.4–0.8。

把光照定成产品级常量。Light 与 hillshade 的光照参数散落在两处,团队里各自调会出现「山影和建筑光影不一致」。定一组值写进样式模板,别让每个图层单独调。

15.10 本章要点

结论 依据
main 分支没有地形网格,terrain 相关源与图层都不存在 include/mln/style/layers 无 terrain,src/mln/style/sources 只有 raster_dem_source
DEM 两种编码:Mapbox(-10000 基准)与 Terrarium(-32768 基准) tileset.hpp 的 RasterEncoding、dem_data.cpp 解码
hillshade-illumination-anchor 默认 viewport,转地图时光照会「流动」 style spec v8 默认值
hillshade-exaggeration 默认 0.5,illumination-direction 默认 335 同上
color-relief-color 无默认值,不设即透明 v8 spec 的 default 为 null
Light 只影响 fill-extrusion,不影响 hillshade light.hpp 属性组与 hillshade 独立参数
DEM 解码在 worker 线程,边界坡度要读邻瓦片 raster_dem_tile_worker.hpp、DEMData::backfillBorder