矢量合成管线
2D 图形渲染引擎的核心任务是矢量路径的光栅化和多图层合成。理解这两个核心机制,是掌握 2D 渲染原理的基础。
矢量绘制
2D 图形主要分为两种:位图和矢量,2D 渲染引擎需要同时支持两者。但是,其实主要工作量集中在矢量的绘制上。
位图(如 PNG、JPG)已经是像素数据,渲染时直接作为纹理上传到 GPU 或拷贝到目标表面,几乎不需要额外计算。
矢量(如 SVG、字体字形、基本几何)则由数学描述组成——直线、二次/三次贝塞尔曲线、圆弧等。引擎需要实时将矢量路径转换为像素,这个过程称为光栅化(Rasterization)。
矢量光栅化与 3D 引擎中的三角形光栅化类似,但面临更大挑战。2D 路径可能非凸、多环、自交,还需处理复杂的填充规则(nonzero 规则、even-odd 规则)和抗锯齿(计算每个像素的 alpha 覆盖率)。常用算法包括:
- 扫描线算法(Scanline):经典 CPU 方式,使用活跃边表(AET)+ 边缘列表逐行扫描,Skia 的 CPU 后端采用此方法
- GPU 方式:将路径细分(tessellation)成三角形,或使用 stencil buffer + cover,以及符号距离场(SDF)
文字本质上是矢量的一种——字形来自字体文件的贝塞尔曲线轮廓。但由于文字光栅化极其耗时,实际渲染中通常特殊处理:预生成字形纹理图集(Glyph Atlas)或 SDF 纹理,避免每次都从路径光栅化。
图层合成
2D 引擎大量使用分层 + 合成策略。不同数据源(位图、矢量路径、文字)可能在各自的绘制管线中并行处理,最终按顺序合成到目标表面。这种设计带来三个优势:
- 支持叠加顺序(z-index)和混合模式(blend modes:SrcOver、Multiply、Screen 等)
- 提供缓存机制,复杂子树只渲染一次,后续复用
- 支持并行/异步渲染,多线程光栅化 + GPU 合成
画家算法
2D 渲染采用画家算法(Painter's Algorithm)处理遮挡关系:从后向前(back-to-front)逐层绘制,后绘制的内容覆盖先绘制的内容。这与 3D 引擎的深度缓冲(Z-buffer)有本质区别。
3D 引擎通过深度测试自动处理遮挡,无需严格排序。而 2D 引擎通常不使用深度测试,完全依赖绘制顺序保证正确遮挡。只有在特殊场景(如 stencil 裁剪)时才会显式启用深度相关功能。
典型合成流程
以 Skia 和 Flutter 为例,典型的合成流程如下:
- 构建显示列表:记录所有绘制命令(drawRect、drawPath、drawImage、drawText 等),每条命令携带 Paint 参数(颜色、笔刷、混合模式、变换矩阵)
- 分层决策:复杂元素(滤镜、裁剪、透明度 < 1、特殊混合模式)通常推入独立图层(SkLayer 或类似结构)
- 按画家算法顺序绘制:从后向前逐层渲染,透明元素单独排序后处理
- GPU 合成:各层作为纹理,执行合成 pass(混合 + 滤镜)
缓存优化
图层缓存是 2D 引擎的关键优化。复杂子树(如复杂的 SVG 路径、带有滤镜的容器)光栅化后缓存为位图,后续帧直接复用,避免重复计算。缓存策略需要权衡内存占用和渲染性能,动态内容需要及时失效缓存,静态内容则尽可能长地保留。
文字排版
文字的处理是 2D 渲染引擎中最复杂的部分之一,通常分为字符编码解析、字体整形(Shaping)、光栅化和合成四个阶段。
与简单图形渲染不同,文字渲染面临三个核心难题:
- 语义与图形的转换——一个 Unicode 编码不对应唯一的字形;
- 上下文相关性——尤其在阿拉伯语或印地语中,同一字母在单词不同位置的形状完全不同;
- 精度要求极高——人眼对文字边缘模糊和间距异常极其敏感;
成熟的 2D 引擎通常集成以下开源库来构建文字子系统:
- FreeType:读取 TTF/OTF 字体文件,提取贝塞尔曲线轮廓,转换为位图或 SDF 数据
- HarfBuzz:决定每个字形在屏幕上的精确坐标,处理字距微调(Kerning)、连字(Ligatures)、从右往左(RTL)排版
- FriBidi:处理 Unicode 双向算法(Bidi),当文字同时包含左到右和右到左语言时决定显示顺序
字形缓存
光栅化将曲线转为像素的过程极其耗时,渲染引擎使用字形纹理图集(Glyph Atlas)来优化。首次遇到字符时调用 FreeType 生成位图存入 GPU 纹理,记录 UV 坐标,后续渲染直接从纹理采样。针对动态文本,引擎会维护 LRU 缓存自动清理长时间不用的字形。
API 分层
以 Skia 为例,排版引擎提供两个层级的接口:
- 低级接口:
drawGlyphs()直接在指定坐标绘制已计算好位置的字形 ID,需要开发者自己处理排版逻辑 - 高级接口:提供类似 HTML/CSS 的能力,设置字体大小、行高、对齐方式、自动换行、图文混排。SkParagraph 会自动调用 HarfBuzz 进行布局计算,开发者只需给出字符串
与 3D 渲染引擎的对比
2D 和 3D 渲染引擎在设计上有根本差异,这些差异源于各自的核心任务。
| 方面 | 2D 渲染引擎(Skia / Cairo / Flutter) | 3D 渲染引擎(OpenGL / Vulkan / UE) |
|---|---|---|
| 几何源 | 2D 路径(贝塞尔、直线、矩形、圆等) | 3D 网格(三角形) |
| 变换 | 2D 仿射变换(translate/rotate/scale/skew) | 3D 投影 + 视图 + 模型变换(透视/正交) |
| 光栅化对象 | 矢量路径(scanline / tessellation / SDF) | 三角形(硬件光栅化) |
| 遮挡/排序 | 画家算法(back-to-front 顺序) | 深度缓冲(Z-test)或排序(透明物体) |
| 光照 | 基本无(或简单着色器) | 复杂 PBR / GI / 阴影 |
| 图层/合成 | 核心特性(多层、缓存、blend modes) | 次要(后处理 pass、多 pass 渲染) |
| 性能瓶颈 | 路径复杂度、图层数量、CPU 光栅化 | 顶点/片元数量、带宽、光追 |
一句话概括:2D 引擎的核心是"矢量 → 光栅 + 分层合成",强调精确的 2D 路径填充、混合顺序和缓存复用;3D 引擎的核心是"3D 几何 → 投影 + 光栅 + 深度/光照",强调空间变换和真实感模拟。
为什么 2D 更依赖图层
3D 引擎中图层是"应该较少使用"的特性,因为会破坏早期 Z 剔除和批处理优化,带来 overdraw、draw call 爆炸、带宽压力等性能问题。但在 2D 引擎中,图层是核心特性,原因在于:
2D 渲染没有深度缓冲,必须靠图层顺序 + 合成保证正确叠加;2D 合成成本低(简单混合 + GPU 纹理 blit);2D 引擎的绘制顺序本身就要求严格的画家算法,图层结构与此天然契合。因此,2D 引擎的分层设计不是性能负担,而是实现正确渲染和高效缓存的基础架构。