第13章 - 自定义图层与渲染扩展
13.1 扩展点有四层,别选错
「往地图上加自己的东西」有几种做法,成本和能力完全不同:
| 方式 | 入口 | 能画什么 | 需要写图形代码 |
|---|---|---|---|
| 样式层(首选) | AddLayer / setPaintProperty |
已有类型的任意组合 | 否 |
| CustomGeometrySource | 喂 GeoJSON 瓦片 | 任意几何,走内置渲染管线 | 否 |
| CustomVectorSource | 喂 MVT 瓦片字节 | 完整矢量瓦片能力,含要素属性 | 否 |
| CustomLayer | 实现 CustomLayerHost |
任意 draw call(点云、粒子、视频面) | 是 |
| 插件 API(C ABI) | include/mln/plugin/plugin_api.h |
带自定义 shader 的图层,跨后端 | 是,但不用碰 C++ 类型 |
前三个只产出数据,渲染、光照、拾取、标注避让都由 core 处理;后两个要自己管 GPU 资源和着色器。能用样式层表达的,就不要写 CustomLayer——它有明确代价,下面逐条说明。
13.2 CustomLayerHost:五个回调的生命周期
include/mln/style/layers/custom_layer_host.hpp 定义了一个纯虚类,只有五个方法:
class CustomLayerHost {
public:
virtual void initialize(const CustomLayerInitParameters&) = 0; // 上下文就绪,建资源
virtual void preRender(const gfx::Context&, const CustomLayerRenderParameters&) {}
virtual void render(const CustomLayerRenderParameters&) = 0; // 每帧一次
virtual void contextLost() = 0;
virtual void deinitialize() = 0;
};
生命周期是 initialize → 每帧 render → deinitialize,中间可能插入 contextLost。三条硬约束:
initialize里申请的资源,必须在deinitialize里释放。contextLost之后不会再调deinitialize,下次绘制前会重新initialize。所以contextLost里要丢弃已失效的句柄并置空,别让deinitialize去删已经没了的对象。deinitialize可能在initialize从未调用过的情况下被调用(图层加了又立刻删)。所有句柄初始化为 0,释放前判空。
initialize 的参数按后端区分:Vulkan 拿到的是 mln::style::vulkan::CustomLayerInitParameters,里面有 vk::Device、vk::PhysicalDevice 和 dispatcher;Metal/GL 用基类 CustomLayerInitParameters,目前没有额外句柄。
13.2.1 渲染参数:相机与两个矩阵
CustomLayerRenderParameters 每帧传进来:
| 字段 | 典型值 | 含义 |
|---|---|---|
width / height |
像素 | 绘制区尺寸 |
latitude / longitude |
度 | 相机中心 |
zoom / bearing / pitch |
相机姿态 | |
fieldOfView |
0.6435011087932844 弧度(约 36.87°) |
垂直视场角,来自 DEFAULT_FOV |
projectionMatrix |
16 个 double |
标准投影矩阵,nearZ = 1 个 tile 单位 |
nearClippedProjectionMatrix |
16 个 double |
近裁剪版本 |
projectionMatrix 的存储顺序直接决定你有没有画对:OpenGL 下按 glUniformMatrix4fv(loc, 1, GL_FALSE, ptr) 上传时,内存布局是列主序。搞反的表现是几何整体镜像或飞出屏幕。nearZ = 1 这个约定意味着顶点 z 落在 [0,1] 才不会被近平面裁掉。
fieldOfView 的默认值来自 include/mln/util/constants.hpp 的 DEFAULT_FOV,源码注释说明它是从「相机高度 = 1.5 个屏幕高度」反推出的 fov:fov = 2 * arctan((h/2) / (h*1.5))。
13.3 一个能跑的 CustomLayer(Android + GLES)
CustomLayer 的 Java 侧只是一个「占位层」,真正的实现必须在 JNI 里创建 CustomLayerHost 并把指针传进去。platform/android/.../CustomLayer.java 的构造签名是 CustomLayer(String id, long host),注释写得很直白:host 是你在自己的 JNI 代码里建立的 mln::style::CustomLayerHost 指针,图层接管它的所有权。
// Host 的生命周期由图层接管,Java 侧只传指针
public class TriangleLayerHost implements CustomLayerHost {
private long nativePtr;
@Override public long initialize() {
nativePtr = nativeCreate(); // 建 VAO/VBO/ShaderProgram
return nativePtr;
}
@Override public void render(long ptr, float[] matrix, double lat, double lon,
double zoom, double bearing, double pitch, double fov) {
nativeRender(ptr, matrix);
}
@Override public void contextLost() { nativeContextLost(nativePtr); }
@Override public void deinitialize() { nativeDestroy(nativePtr); nativePtr = 0; }
private native long nativeCreate();
private native void nativeRender(long p, float[] mvp);
private native void nativeContextLost(long p);
private native void nativeDestroy(long p);
}
C++ 侧最小实现(GLES 3.0,画一个铺满屏幕的三角形):
class TriangleHost final : public mln::style::CustomLayerHost {
public:
void initialize(const mln::style::CustomLayerInitParameters&) override {
glGenVertexArrays(1, &vao);
glGenBuffers(1, &vbo);
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(kVerts), kVerts, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, nullptr);
program = buildProgram(kVertexSrc, kFragmentSrc);
}
void render(const mln::style::CustomLayerRenderParameters& p) override {
glUseProgram(program);
glUniformMatrix4fv(uMvp, 1, GL_FALSE, p.projectionMatrix.data());
glBindVertexArray(vao);
glDrawArrays(GL_TRIANGLES, 0, 3);
}
void contextLost() override {
vao = vbo = program = 0; // 句柄已失效,置空等下次 initialize
}
void deinitialize() override {
if (vbo) glDeleteBuffers(1, &vbo);
if (vao) glDeleteVertexArrays(1, &vao);
if (program) glDeleteProgram(program);
vao = vbo = program = 0;
}
private:
GLuint vao = 0, vbo = 0, program = 0, uMvp = 0;
};
注册方式是 new 一个 host 交给 CustomLayer:
auto layer = std::make_unique<mln::style::CustomLayer>(
"triangle", std::make_unique<TriangleHost>());
map.getStyle().addLayer(std::move(layer));
13.4 插件 API:给 CustomLayer 的 C ABI 版本
include/mln/plugin/plugin_api.h 是一份纯 C 头文件(21302 字节),把「注册一个自定义图层」抽象成稳定的 ABI,插件用 C 或任何能导出 C 符号的语言写,不必跟着 C++ 类布局走。构建时开 -DMLN_WITH_PLUGINS=ON,Android 对应 *WithPluginApi flavor。
关键常量与枚举(都在这一个头文件里):
| 名称 | 值 | 说明 |
|---|---|---|
MLN_PLUGIN_ABI_VERSION_1 |
1u |
ABI 版本 |
MLN_PLUGIN_MAX_VERTEX_ATTRIBUTES |
16 |
每个 shader 的最大顶点属性数 |
mln_plugin_backend |
OpenGL / Vulkan / Metal 位掩码 | 插件声明自己能跑在哪些后端 |
mln_plugin_status |
OK / ALREADY_REGISTERED / INVALID_ARGUMENT / UNSUPPORTED_ABI / CONFLICT / NOT_FOUND / CALLBACK_ERROR | 返回值 |
mln_plugin_value_type |
FLOAT / FLOAT2 / COLOR / STRING | 属性类型 |
头文件开头的「Ownership and call contract」把规则写死了,抄几条最容易违规的:
- 每个结构体首字段是
struct_size,接收方必须先校验它再访问其它字段。 - 指针是借用语义,两侧各自分配释放,不允许 STL 对象、C++ 异常或分配器所有权跨接口。
- 所有回调(包括 destroy)必须正常返回,不能抛异常;C++ 插件要在回调内部 catch 住并转成状态码。
- 宿主传入的数组/字符串只在回调期间有效,要留存必须自己拷贝。
- 回调必须对进程生命周期可调用:不支持卸载/注销插件。
插件 API 不支持 WebGPU,这是 android-build-flavors.md 里明说的限制。
13.5 数据源扩展:两条不需要写 shader 的路
13.5.1 CustomGeometrySource
自己算 GeoJSON 瓦片,交给内置管线渲染。适合「几何来自数据库/算法,不适合预先写成文件」的场景(热力图网格、动态等值面、实时轨迹)。
mln::style::CustomGeometrySource::Options opts;
opts.zoomRange = {0, 14};
opts.tileOptions.tolerance = 0.375; // 简化容差(像素)
opts.tileOptions.tileSize = 512; // 瓦片逻辑尺寸
opts.tileOptions.buffer = 128; // 边缘缓冲,单位是 extent 的 1/N
opts.tileOptions.clip = false;
opts.tileOptions.wrap = false;
opts.fetchTileFunction = [&](const mln::CanonicalTileID& id) {
// 可以同步算,也可以丢到线程池后回来 setTileData
source->setTileData(id, buildGeoJSON(id));
};
opts.cancelTileFunction = [](const mln::CanonicalTileID&) { /* 取消在途计算 */ };
auto source = std::make_shared<mln::style::CustomGeometrySource>("grid", opts);
map.getStyle().addSource(source);
TileOptions 的默认值就在头文件里:tolerance = 0.375、tileSize = util::tileSize_I(512)、buffer = 128、clip = false、wrap = false。buffer 的单位不是像素,是瓦片 extent 的分割数——EXTENT 是 8192,所以 128 对应 8192/128 = 64 个内部单位,跨瓦片边界的线宽不会被切断。
invalidateRegion(const LatLngBounds&) 是增量更新的入口:区域内所有瓦片会被重算。数据每分钟更新一次时用它,别整源重建。
13.5.2 CustomVectorSource
自己产出 MVT 字节,能力比 GeoJSON 强(带要素属性、可做 data-driven 样式)。目前只支持 TileDataFormat::MVT 一种格式。
mln::style::CustomVectorSource::Options opts;
opts.zoomRange = {0, 18};
opts.fetchTileFunction = [&](const mln::CanonicalTileID& id) {
auto bytes = std::make_shared<const std::string>(buildMvt(id));
source->setTileData(id, bytes, mln::style::TileDataFormat::MVT);
};
opts.cancelTileFunction = [](const mln::CanonicalTileID&) {};
auto source = std::make_shared<mln::style::CustomVectorSource>("mine", opts);
setTileError(id, error) 用于回报失败(比如后端接口 500),invalidateTile(id) 强制单块重取。supportsLayerType() 决定这个源能配哪些图层类型——默认实现只允许 compatible 的类型,配 fill-extrusion 之类要确认是否被支持。
13.6 iOS:MLNCustomStyleLayer
Objective-C 侧不用实现 CustomLayerHost,而是继承 MLNCustomStyleLayer 并覆写四个方法:
| 方法 | 时机 |
|---|---|
initWithIdentifier: |
构造,只存 id |
didMoveToMapView: |
加入地图,可在 mapView 上做准备 |
willMoveFromMapView: |
移出地图,清理 |
preDrawInMapView:withContext: |
绘制前,参数是 MLNStyleLayerDrawingContext |
drawInMapView:withContext: |
实际绘制 |
setNeedsDisplay |
主动触发重绘 |
MLNStyleLayerDrawingContext 里有投影矩阵与相机状态,等价于 C++ 的 CustomLayerRenderParameters。绘制时当前 Metal/GL 上下文已经激活,可以直接下发编码指令。
13.7 排错:CustomLayer 的典型故障
画面全黑,其它图层正常:render 里没有恢复深度/模板状态。头文件注释明确说「实现不必把 depth mask、stencil mask 及对应测试标志恢复原值」,但如果你开启了 GL_DEPTH_TEST 又没关,后续图层会被裁掉——表现是「我在最上层画,下面的路网没了」。改法是在 render 结束时把改过的状态改回去。
不透明图层被裁掉:注释里那句「确保用 z=1 绘制片元,以便利用不透明片元剔除」是关键。z < 1 的片元会被判定为在更远处,跟后面的图层争深度。2D 显示的场景把 z 写到 1.0。
转屏后画面拉伸:projectionMatrix 是随 width/height 变的,缓存了第一次的矩阵就会拉伸。每帧从参数里取,别存。
切换前后台后崩溃:contextLost 后资源已失效,但有些实现只在 deinitialize 里清资源。崩溃栈通常指向 glDelete* 或 Metal 的 release。按 13.2 的三条约束改。
render 里做耗时计算导致掉帧:render 在主/渲染线程,每帧都调。数据准备放到自己的线程,render 只做 draw call。需要通知主线程重绘时调 map.triggerRepaint(),别在自己的线程碰上下文。
Android 上 UnsatisfiedLinkError:CustomLayer(String id, long host) 的 host 必须来自你自己的 .so,且该 .so 被加载过。Java 侧只负责把指针传过去,没加载 native 库时 initialize 直接失败。
插件注册返回 UNSUPPORTED_ABI:struct_size 与宿主期望不符,通常是头文件版本不一致。插件必须用与运行时同一个 plugin_api.h 编译。
13.8 参数速查
| 参数 | 位置 | 默认值 | 说明 |
|---|---|---|---|
DEFAULT_FOV |
util/constants.hpp |
0.6435011087932844 弧度 |
约 36.87°,由相机高度反推 |
EXTENT |
util/constants.hpp |
8192 |
瓦片内部坐标范围,CustomGeometrySource 的 buffer 单位基准 |
tileSize_I |
util/constants.hpp |
512 |
TileOptions.tileSize 默认值 |
CustomGeometrySource::TileOptions::tolerance |
头文件 | 0.375 |
简化容差(像素) |
TileOptions.buffer |
头文件 | 128 |
瓦片边缘缓冲 |
TileOptions.clip / wrap |
头文件 | false / false |
是否裁剪到瓦片 / 跨日期变更线 |
Options.zoomRange |
两个自定义源 | {0, 18} |
可提供数据的级别范围 |
MLN_PLUGIN_MAX_VERTEX_ATTRIBUTES |
plugin_api.h |
16 |
插件 shader 属性上限(对齐 GL/Vulkan 最小保证) |
13.9 生产建议
先问「内置图层能不能凑出来」。很多看起来需要自定义渲染的需求,其实是 fill-extrusion 加表达式、heatmap 调权重、raster 叠色带。内置图层有标注避让、批量合并、深度排序,这些自己写要几周。
CustomLayer 只画「地图坐标下的东西」。它跟着相机矩阵走,适合点云、管线、粒子。纯屏幕空间的浮层(图例、比例尺、罗盘)用宿主 UI 框架画,别塞进地图渲染管线。
给 CustomLayer 一个明确的 z 策略。写 1.0 表示「和普通图层一起参与深度」,写更小值表示「在更远处」。同一图层混用两种 z 会导致部分三角形被后面的图层盖住,调试成本很高。
CustomGeometrySource 优先于 CustomVectorSource。前者传 GeoJSON,序列化成本低、调试直观;只有当要素数量大到 MVT 才有优势(单块几万个点),或者需要完整属性表达式时才上 MVT。
插件 API 只在真的需要跨语言分发时用。它的价值是 ABI 稳定、能编译成独立 .so;纯 C++ 项目直接用 CustomLayerHost 更省事,少一层 struct_size 校验和状态码转换。
13.10 本章要点
| 结论 | 依据 |
|---|---|
| CustomLayerHost 只有 5 个纯虚方法 | custom_layer_host.hpp |
contextLost 之后不会调 deinitialize,会重新 initialize |
头文件注释 |
projectionMatrix 的 nearZ = 1 个 tile 单位,内存为列主序 |
custom_layer_render_parameters.hpp 注释 |
| 默认 fov 是 0.6435 弧度,由「相机高度 1.5 屏高」反推 | constants.hpp 的 DEFAULT_FOV |
| CustomGeometrySource 的 buffer 单位基于 EXTENT=8192 | constants.hpp + TileOptions |
| 插件 API 是 C ABI、16 个顶点属性上限、不支持 WebGPU | plugin_api.h、android-build-flavors.md |
AsyncRequest 之外没有别的取消机制,自定义源的 cancel 回调要自己实现 |
custom_vector_source.hpp 的 cancelTileFunction |