4、矢量字体渲染:从轮廓到像素的艺术
矢量字体渲染,说白了就是把数学公式变成屏幕上你能看到的字。我刚开始接触这块时,觉得不就是画几个点连成线嘛,真上手才发现——坑多着呢。
这一章我们聚焦四个核心环节:字形轮廓怎么描述、贝塞尔曲线怎么算、怎么把曲线填成像素、最后怎么让边缘不刺眼。嗯,一个一个来。
4.1 字形轮廓(Glyph Outline)——字体的骨架
每个字符在字体文件里存的不是像素,而是一组轮廓描述。比如字母 'A',它由外轮廓(倒三角)和内轮廓(中间的洞)组成。轮廓本身是一系列点和控制点。
我习惯把轮廓理解成「封闭的路径」。路径由线段和曲线拼接而成,起点和终点必须重合。TrueType 和 OpenType 字体都基于这个思路,只是曲线类型不同。
举个例子,数字 '0' 的外圈是顺时针,内圈是逆时针。这样渲染器才能知道中间是空的。我曾经在项目里把方向搞反了,结果数字 '0' 渲染出来像个实心烧饼……
4.2 贝塞尔曲线——曲线是怎么描述的
矢量字体里最常用的就是二次贝塞尔曲线(TrueType)和三次贝塞尔曲线(PostScript/OpenType CFF)。
公式其实不复杂,但咱们做嵌入式的,更关心怎么算得快。
4.2.1 二次贝塞尔曲线
三个点:起点 P0、控制点 P1、终点 P2。参数 t 从 0 到 1:
B(t) = (1-t)²·P0 + 2·(1-t)·t·P1 + t²·P2
说白了就是线性插值的线性插值。我在 STM32 上做过测试,用定点数算这个,比浮点快 3 倍以上。
4.2.2 三次贝塞尔曲线
四个点:P0、P1、P2、P3。公式长一点:
B(t) = (1-t)³·P0 + 3·(1-t)²·t·P1 + 3·(1-t)·t²·P2 + t³·P3
三次曲线能描述更复杂的形状,比如 S 形。代价是计算量翻倍。在低端 MCU 上,我建议能拆成二次就别用三次。
| 曲线类型 | 控制点数 | 字体格式 | 计算量(相对) |
|---|---|---|---|
| 二次贝塞尔 | 3 | TrueType | 1x |
| 三次贝塞尔 | 4 | OpenType CFF | 1.8x |
4.3 渲染成位图——从矢量到像素的跨越
这一步叫「扫描转换」。核心问题:给定一个封闭轮廓,哪些像素应该被填黑?
最经典的方法是逐行扫描。对每一行像素,计算轮廓线与该行的交点,然后配对填充。嗯,说起来简单,实现起来有几个坑。
4.3.1 交点计算
把贝塞尔曲线离散成线段,然后求线段与扫描线的交点。离散步长很关键——步长太大,字形变形;步长太小,性能崩了。
我一般这样设步长:
// 根据字体大小自适应步长
int steps = (int)(fontSize * 0.5f);
if (steps < 4) steps = 4;
if (steps > 32) steps = 32;
字体 16px 时步长 8,48px 时步长 24。效果不错,性能也扛得住。
4.3.2 奇偶规则 vs 非零环绕规则
这两个规则决定了「内部」怎么定义。奇偶规则简单:从像素出发画一条射线,与轮廓相交奇数次就在内部。非零环绕规则更复杂,但能处理自交轮廓。
嵌入式场景下,我建议用奇偶规则。计算量小,而且大多数字体轮廓不会自交。
4.4 抗锯齿(Anti-Aliasing)——让边缘不再狗啃
纯黑白位图的边缘有锯齿,俗称「狗牙」。抗锯齿的本质是:让边缘像素不是非黑即白,而是根据覆盖面积给一个灰度值。
我做过对比,同样的 16px 字体,开抗锯齿后视觉清晰度提升至少一个档次。
4.4.1 灰度抗锯齿(Grayscale AA)
最简单的方法:计算像素被字形覆盖的面积比例。覆盖 80% 就给 80% 灰度,覆盖 20% 就给 20% 灰度。
实现时可以用子像素采样:把一个像素拆成 4x4 或 8x8 个子像素,统计落在字形内的子像素个数。
// 4x4 子像素抗锯齿
uint8_t coverage = 0;
for (int sy = 0; sy < 4; sy++) {
for (int sx = 0; sx < 4; sx++) {
if (isInsideGlyph(x + sx/4.0f, y + sy/4.0f)) {
coverage++;
}
}
}
uint8_t gray = (coverage * 255) / 16;
4x4 采样能产生 17 级灰度,人眼基本看不出阶梯了。
4.4.2 亚像素抗锯齿(Subpixel AA)
LCD 屏幕每个像素由 R、G、B 三个子像素组成。亚像素渲染利用这个特性,在子像素级别做抗锯齿,水平分辨率能提升 3 倍。
但要注意:亚像素渲染对屏幕方向敏感,横屏竖屏效果不一样。我在项目里只用灰度 AA,因为亚像素在低分辨率屏上反而容易产生彩色条纹。
| 抗锯齿方式 | 灰度级数 | 性能开销 | 适用场景 |
|---|---|---|---|
| 无抗锯齿 | 2 | 低 | 极小字体、性能受限 |
| 灰度 AA (4x4) | 17 | 中 | 大多数嵌入式场景 |
| 灰度 AA (8x8) | 65 | 高 | 大字体、高精度需求 |
| 亚像素 AA | — | 高 | LCD 屏幕、水平排列 |
4.5 本章知识体系
下面这张图把四个环节串起来了,方便你理解整体流程:
4.6 小结
矢量字体渲染这条路,说白了就是「用数学描述形状,用算法填满像素」。轮廓是骨架,贝塞尔是肌肉,扫描转换是血肉,抗锯齿是皮肤——缺一个都不行。
我个人觉得,最难的不是理解每个环节,而是把它们串起来跑在资源受限的芯片上。你想想看,一个 72MHz 的 STM32,要渲染 24px 的汉字,还得保持 30fps——每一步都得精打细算。
嗯,这一章的内容就到这儿。代码示例和具体实现细节,你可以在配套的工程里找到。记住:纸上得来终觉浅,绝知此事要躬行。