第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