第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,掉帧是正常的。
实践上的三条:
- DEM 的
maxzoom控制在 12–13。再高的级别用户看不到差别,内存和绘制成本却翻倍。 hillshade只放一份。同一个 DEM 源同时挂hillshade和color-relief会解码两次——RenderRasterDEMSource缓存了解码结果,但两个图层各自建立 bucket。- 移动端
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 |