第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 两个时间 |