第06章 - 渲染引擎与图形后端

6.1 这一章解决什么问题

“换渲染后端”听上去像改个编译开关,实际要理解三层东西:平台创建上下文的方式、gfx 抽象层怎么屏蔽差异、drawable 管线如何组织绘制。搞不清这三层,遇到”Metal 下某个图层不显示”这类问题就只能靠猜。

main 分支同时存在四个后端实现:OpenGL(src/mln/gl)、Metal(src/mln/mtl)、Vulkan(src/mln/vulkan)、WebGPU(src/mln/webgpu)。同一个后端在 iOS 与 Android 上的窗口系统集成又完全不同。本章拆开讲这些差异,以及代码层面共用的抽象。

6.2 三层结构

+-----------------------------------------------------------+
| 平台层:创建 surface / 上下文,驱动渲染循环                |
|  iOS: CAMetalLayer + MLNMapView+Metal.mm                  |
|  Android: SurfaceView/TextureView + GLSurfaceView 体系     |
|  Linux: GLFW + X11/Wayland + EGL                          |
+---------------------------+-------------------------------+
                            | 传给核心
+---------------------------v-------------------------------+
| gfx 抽象层(include/mln/gfx/)                             |
|  RendererBackend  上下文与 renderable 的抽象               |
|  Context         纹理/缓冲/命令编码器/drawable 构建         |
|  Drawable         绘制单元:shader + 顶点 + uniform        |
|  ShaderRegistry   着色器注册与查找                          |
+---------------------------+-------------------------------+
                            | 具体实现
+---------------------------v-------------------------------+
| gl/   mtl/   vulkan/   webgpu/                             |
| Context、Drawable、Texture2D、RenderPass、CommandEncoder    |
+-----------------------------------------------------------+

gfx::RendererBackend 的纯虚接口定义了后端必须提供什么:

// include/mln/gfx/renderer_backend.hpp(节选)
class RendererBackend {
public:
    virtual ~RendererBackend();
    virtual Renderable& getDefaultRenderable() = 0;
    virtual void initShaders(gfx::ShaderRegistry&, const ProgramParameters&) = 0;
    virtual std::unique_ptr<Context> createContext() = 0;
    virtual void activate() = 0;
    virtual void deactivate() = 0;
};

五个函数就是后端契约:拿到一个可绘制的 Renderable、注册内置 shader、创建 Context、激活/去激活上下文。

gfx::Context 是资源工厂:

// include/mln/gfx/context.hpp(节选)
class Context {
public:
    virtual std::unique_ptr<OffscreenTexture> createOffscreenTexture(Size, TextureChannelDataType) = 0;
    virtual std::unique_ptr<CommandEncoder> createCommandEncoder() = 0;
    virtual gfx::VertexAttributeArrayPtr createVertexAttributeArray() const = 0;
    virtual UniqueDrawableBuilder createDrawableBuilder(std::string name) = 0;
    // ...
};

gfx::Drawable 是绘制单元,最小契约只有一个 draw:

class Drawable {
public:
    virtual void draw(PaintParameters&) const = 0;
    const gfx::ShaderProgramBasePtr& getShader() const { return shader; }
    virtual void setShader(gfx::ShaderProgramBasePtr value) { shader = std::move(value); }
};

后端实现各自派生:gl/DrawableGL、mtl/DrawableMTL、vulkan/Drawable、webgpu/Drawable,但都通过 gfx 接口被上层调度。这就是”一份渲染逻辑跑四个后端”的实现方式。

6.3 四个后端的能力差异

后端 实现目录 着色语言 编译开关 典型平台
OpenGL gl/ GLSL MLN_WITH_OPENGL Android、Linux、Windows
Metal mtl/ MSL MLN_WITH_METAL iOS、macOS
Vulkan vulkan/ SPIR-V MLN_WITH_VULKAN Linux、Windows、Android(实验)
WebGPU webgpu/ WGSL MLN_WITH_WEBGPU 实验,Dawn 或 wgpu-native

着色器源码在 src/mln/shaders/ 按后端分目录:gl/、mtl/、vulkan/、webgpu/,加上公共的 shaders/ 头(uniform block 定义)。新增一个图层需要为每个目标后端写一份着色器,这是跨后端的现实成本——不是写一次到处跑。

WebGPU 还有额外的实现选择:

# Dawn 实现
cmake --preset linux-webgpu-dawn

# wgpu-native 实现
cmake --preset linux-webgpu-wgpu

对应 CMake 变量 MLN_WEBGPU_IMPL_DAWN=ON 或 MLN_WEBGPU_IMPL_WGPU=ON。两个实现的 shader 和管线差异被 webgpu/ 目录里的 webgpu_cpp_impl.cpp 之类文件吸收。

6.4 上下文生命周期与丢失恢复

RendererBackend 的 activate()/deactivate() 是上下文切换,用于多地图实例共享 GPU 时切当前上下文。包在 gfx::BackendScope 里自动管理:

{
    gfx::BackendScope scope(backend);
    // 这段时间内 backend 处于激活状态
    context.createCommandEncoder();
}   // 析构时自动 deactivate

上下文丢失(GPU reset、后台切换)由 Renderer::markContextLost() 通知:

// include/mln/renderer/renderer.hpp
void markContextLost();

调用后渲染器进入”需要重建资源”的状态,下一次 render() 会重新创建纹理与缓冲。

在 iOS 上,进入后台再回前台、Metal 设备被系统回收,都会触发这条路径。地图变黑但没崩溃通常就是上下文丢失后没有正确重建资源——检查平台层是否在 applicationDidBecomeActive 时调用了 markContextLost,而不是当作什么都没发生。

6.5 Renderer 的构造与像素比

核心渲染器构造签名(include/mln/renderer/renderer.hpp):

Renderer(gfx::RendererBackend& backend,
         float pixelRatio,
         const std::optional<std::string>& localFontFamily = std::nullopt);

三个参数都有实际影响:

参数 作用 常见错误
backend 后端实例,生命周期由调用方保证 提前析构导致悬垂引用
pixelRatio 逻辑像素到物理像素的倍率 传 1.0 导致高分屏模糊
localFontFamily 本地字体族,用于 CJK 回退 不传时中文可能显示为方框

pixelRatio 必须传设备真实密度。Android 是 DisplayMetrics.density,iOS 是 UIScreen.main.scale。传错的表现是地图内容清晰但标注模糊,因为标注是用逻辑像素排版的。

localFontFamily 与 CJK 排版相关。上游 changelog 记录过:默认情况下 CJK 字符用 text-font 指定的字体渲染,如果设备上没有该字体,会回退到 localFontFamily 传入的字体族列表。这解释了”英文正常、中文全是方框”的经典问题——不是样式错,是字体没找到。修复方式是按平台传系统字体,例如 iOS 传 "PingFang SC"。

6.6 渲染树与每帧工作

一帧的分工(RenderOrchestrator 负责):

createRenderTree(UpdateParameters)
  |
  +-- 对每个 RenderSource:
  |     TilePyramid 更新可见瓦片 -> 触发缺瓦片请求
  |     已就绪瓦片 -> 取出 bucket / drawable
  |
  +-- 对每个 RenderLayer:
  |     paint/layout 属性求值(表达式)
  |     生成 drawable + tweaker(像素比、相机矩阵)
  |
  +-- 生成 RenderTree
  v
Renderer::Impl::render(RenderTree)
  -> backend.activate()
  -> 遍历 drawable.draw(PaintParameters&)
  -> backend.deactivate()

关键点是 tweaker 机制。DrawableTweaker(drawable_tweaker.hpp)在 drawable 创建后、绘制前修改其 uniform,比如把当前相机矩阵塞进去。这避免了每个图层自己重复算矩阵。

6.7 性能要点

绘制调用数

每个 drawable 对应一次 draw call。符号图层按 tile 分组,层数多、瓦片多时 draw call 会爆炸。用 Renderer::getPlacedSymbolsData()(需要先 collectPlacedSymbolData(true))能看实际摆放的符号数量,判断是不是标注过多导致。

帧时间

RendererObserver::onDidFinishRenderingFrame(RenderMode mode, bool repaint, bool placementChanged, double frameEncodingTime, double frameRenderingTime) 的后两个参数分别对应帧构建与 GPU 提交时间。编码时间高说明 CPU 侧(表达式求值、drawable 更新)重;渲染时间高说明 GPU 侧(填充率、着色器)重。这两者优化方向完全不同,先分清再动手。

内存

Renderer::reduceMemoryUse() 与 Renderer::clearData() 是两条回收路径:前者降缓存,后者清空数据。Android 的 onLowMemory 和 iOS 的内存警告都应映射到这两个调用。

调试标志

Map::setDebug(MapDebugOptions) 可以叠加:

标志 效果
TileBorders 画瓦片边界
Timestamps 每帧时间戳
CollisionBoxes 标注碰撞框
Overdraw 过绘制可视化

Overdraw 在排查”为什么这个页面 GPU 占用高”时最有用。

6.8 后端选择对包体积与启动的影响

四个后端不是”随便挑一个”的关系,编译进去的代码量差别明显:

后端 主要依赖 对包体积的影响
OpenGL 系统 GL/GLES 库 最小,系统自带
Metal 系统 Metal 框架 小,系统自带
Vulkan Vulkan SDK / 驱动加载器,Android 侧还要打包验证层 中等,需带 loader
WebGPU Dawn 或 wgpu-native 最大,两者都是完整实现

Android 侧尤其明显。MapLibreAndroid/build.gradle.kts 里挂了 maplibre.download-vulkan-validation 插件,说明构建体系要为 Vulkan 变体准备验证层资源。而 OpenGL 变体不需要这些,包体积与启动时间都更友好——这是官方把 OpenGL 作为 Android 默认路径的实际原因之一。

启动时间也受影响。Metal 与 OpenGL 的上下文创建在毫秒量级,WebGPU 首次初始化要建立设备并编译管线缓存,冷启动更慢。如果首屏是地图,这个差异用户能感知。

6.9 着色器注册流程

后端初始化时通过 RendererBackend::initShaders(gfx::ShaderRegistry&, const ProgramParameters&) 把内置着色器注册进 ShaderRegistry。上层创建 drawable 时按名字查 shader:

// 伪代码,展示调用关系
gfx::ShaderRegistry& registry = backend.getShaderRegistry();
auto shader = registry.get("background");
if (shader) {
    drawable->setShader(shader);
}

自定义图层(CustomLayer)的着色器也走同样机制,只是注册时机在图层初始化时。注册失败或名字拼错的表现是 drawable 有几何但不出图——排查时先看 shader 是否为 null。

6.10 排错

真机上地图全黑,模拟器正常

Metal 上下文创建失败。检查是否用 ios-metal preset 构建、设备是否支持对应 Metal 版本。

切换前后台后地图不再刷新

上下文丢失未处理。平台层需要在回到前台时调用 markContextLost() 并重新激活后端。

Android 上 Vulkan 后端崩溃

Vulkan 在 Android 上仍是实验路径,官方 CI 的稳定组合是 OpenGL。生产不要用。

高分屏上标注模糊、地图清晰

pixelRatio 传了 1.0,但绘制时用的是物理分辨率。传真实 density。

中文显示为方框

缺字体回退。按平台传 localFontFamily,或把字体打包进样式对应平台的字体栈。

某个图层只在 Metal 下消失

大概率是该图层的 shader 在 Metal 分支缺失或 uniform 布局不一致。对比 src/mln/shaders/gl/ 与 mtl/ 下同名 shader 的字段。

6.11 生产建议

  • 一个应用只用一个后端:编译期单选意味着无法运行时切换。iOS 用 Metal、Android 用 OpenGL 是最稳的组合。
  • 不要追 WebGPU:当前是实验路径,两个实现(Dawn/wgpu-native)行为还有差异,不能进关键链路。
  • pixelRatio 与 localFontFamily 必须显式设置:这两个参数直接决定清晰度与中文字形,默认值在移动端不够用。
  • 监控帧时间而非 FPS:FPS 掩盖抖动。用 onDidFinishRenderingFrame 拿编码/渲染耗时,分别设阈值。
  • 上下文丢失要有恢复路径:iOS 后台切换、Android 低内存回收都会触发,平台层必须处理。

6.12 本章小结

要点 事实
抽象层 gfx::RendererBackend、Context、Drawable、ShaderRegistry
后端实现 gl/、mtl/、vulkan/、webgpu/ 四套独立实现
选择方式 编译期单选,validate-backend-options.cmake 强制
着色器 每个后端一份,目录 src/mln/shaders/{gl,mtl,vulkan,webgpu}
构造关键参数 pixelRatio(清晰度)、localFontFamily(CJK 回退)
上下文丢失 Renderer::markContextLost() 后重建资源
帧耗时观测 onDidFinishRenderingFrame 的 encoding/rendering 两个时间