本文翻译自 Font Rendering from First Principles,原载于 Hacker News。
字体渲染是一项常常被我们视为理所当然的技术——很难想象没有它我们该如何与计算机交互。但这项技术究竟有多复杂?事实证明,比大多数人想象的要复杂得多:
- 文本可以以任意大小渲染。字体数据是如何编码的,才能让字形在任何目标分辨率下都保持高质量?
- 字体通常是曲线的,而像素不是。我们应该如何对字形进行抗锯齿处理,以保持文本的视觉美感?
- 我们应该如何设计一个尊重不同语言不同布局规则(如英语与阿拉伯语)的系统?
看看 FreeType(GPL 许可,被 Chromium、GNU/Linux 等使用),他们声称代码量超过 20 万行。
我决定自己实现一个。在这篇文章中,我将提供 TTF 文件规范和我的实现的高层次概述。首先,为什么不直接使用 FreeType 或其他等效库?自己实现有什么好处?
- 对让我们能够使用互联网等基础技术产生更深的理解和欣赏。没有它,你现在就读不到这篇文章。
- 建立对渲染网页/GUI所需工作量的直觉。为什么字体缓存很重要?我们如何降低渲染时间?
- 对基础知识的良好理解能够解锁进一步扩展的能力。例如通过 SDF 添加程序化边框。
- 这很有趣,为什么不呢?我们是娱乐编程的爱好者。
TTF 文件格式
在开始渲染字符串之前,我们首先需要读入字体数据。我的实现专注于 TTF(TrueType)文件格式。另一种常用的格式 OTF(OpenType)可以被视为 TTF 的超集(还包括 PostScript 字体),所以如果你想渲染 OTF 字体,你无论如何都需要一个 TTF 解析器。有很多字体使用 TTF 而非 OTF,足以覆盖我的初始用例(拉丁字母数字字形),所以我怀疑我不会很快将实现扩展到 OTF。
从高层次来看,TTF 文件提供了字符码点到字形信息之间的某种映射。
什么是码点(Codepoint)?
字符的”码点”指的是它的 Unicode 编码格式。你可能熟悉 ASCII 编码,它是拉丁字母表中每个字符的 1 字节宽表示(例如,字符 ‘A’ 映射到 65,’z’ 映射到 122 等)。ASCII 的一个主要限制是它没有提供足够的空间来描述非拉丁语言。Unicode 是解决这个问题的国际标准解决方案。Unicode 有多种编码格式,例如 UTF-8 和 UTF-32;它们的唯一区别在于如何编码这个码点(UTF-32 总是使用 4 字节表示码点,UTF-8 使用可变长度)。方便的是,UTF-8 与 ASCII 向后兼容(感谢 Ken Thompson 和 Rob Pike)。由于我现在只专注于拉丁字母,这意味着 ‘a’ 的码点可以通过 (uint8_t) ('a') 获取。
什么是字形(Glyph)?
字形只是字母和字符的一个抽象。TTF 文件不知道字母 ‘a’ 是什么,而是某个值(Unicode 码点)到该值的一些数据(字形)的不透明映射。这包括与该线条相关的实际点和曲线,以便我们可以绘制它,以及我们需要小心考虑的关于该字符的额外度量信息——拉丁字母表突出了为什么这些度量很重要,看这张图片作为例子:
这里,同一个字符串被渲染了两次(下面:可读的字符串,上面:每个字形的纹理完全不透明)。观察字形之间的差异——中心如何相对于基线定位,字符之间添加了多少空间,所有这些都高度依赖于你试图渲染的字体和字符。
回到 TTF 文件,你可以在这里找到参考手册。你会很快注意到整体文件格式本身并不是太复杂;有许多表包含组成整个字体的单个字形的不同信息。只有少数表是立即相关的:
glyf:存储字形形状数据。loca:将字形索引映射到glyf表中的偏移量。cmap:将 Unicode 码点映射到字形索引。
从这里,我们可以开始描绘如何获取字体形状信息。对于我们想要渲染的每个字符:
- 通过
cmap表确定其字形索引。 - 通过在
loca表中查找其对应的值来确定字形数据在文件中的位置。 - 在
glyf表中提取字体形状信息。
还有几个其他表有一些值得注意的有用信息:
head:包含关于字体的全局信息。maxp:描述字体中某些参数的最大值(例如”这个字体包含多少个字形?”),这对于边界检查很有用。hhea:包含关于水平字体的信息。这包含ascent和descent变量,对于确定字体的总垂直大小很有用。hmtx:描述每个字形的水平布局的表(例如字形的advance)。kern:可选提供的表,描述字符对的额外字距调整信息。
当然还有其他表供你根据用例进行检查(例如 vhea 和 vmtx 存在于垂直语言中)。TTF 文件规范的一个主要部分是指定将字体缩放到特定分辨率的指令——如果你计划只支持较小比例的位图渲染,这变得更加重要。我发现替代方法,即基于 SDF 的渲染,在不涉及规范的这一部分的情况下实现了高质量结果,所以我忽略了这些表。
这一步的主要挑战之一是验证你从 TTF 文件数据中读取的数据,因为我们从不透明的二进制 blob 读取(至少我不太习惯)。运行调试器是你的朋友。我还建议熟悉并将你的结果与另一个已知良好的 TTF 解析器进行比较,以便在困难时期进行比较,我为此目的使用了 stb_truetype。
字形解析
很好,现在我们知道字形数据在哪里,是时候解析它了。
TTF 字形由一系列轮廓(contour)组成,这些轮廓本身被描述为一系列二次贝塞尔曲线。这些由 3 个点组成:起点、终点和”控制”点。贝塞尔曲线被正式定义为这些点的”线性组合”(起点 -> 控制点 -> 终点),其中,给定范围 [0, 1] 内的 t,计算点对之间的线性插值,递归地,直到坍缩到单个点。例如,对于给定的 t = 0.4,我们在起点和控制点之间放置一个中间点 40% 的位置,在控制点和终点之间放置一个中间点 40% 的位置,然后重复,在我们的中间点之间 40% 的位置放置我们最终的在曲线上的点。这对 0 和 1 之间的所有 t 值重复以创建最终曲线。
描述二次贝塞尔曲线的另一种方式是通过公式:(((1-t)^2) * P_start) + (2(1 - t)t * P_control) + ((t^2) * P_end),其中,再次,t 在范围 [0, 1] 内(一旦你意识到这只是底层的常规二次方程,这就不那么糟糕了)。
回到链条上,这些曲线被组合形成轮廓,这些轮廓被组合形成整体字形形状。作为例子,这里是描述 Consola 字体中字形 ‘B’ 的轮廓,其中每个轮廓是单独的颜色(注意贝塞尔控制点用紫色着色):
我们看到,在这种情况下,有 3 个独立的轮廓:一个用于”外壳”,两个用于从中切出的”孔”。TTF 规范要求这些类别的轮廓被单独定义——”外壳”轮廓点的顺序必须是顺时针的,”孔”轮廓点必须是逆时针的。我们稍后在光栅化字形时将使用此信息来知道我们何时进入或退出形状,所以在这里注意很好。
TTF 将二次贝塞尔点存储在一组连续数组中,一旦解析,就会坍缩成一个相当简单的结构:
struct GlyphPoint {
B8 on_curve; // 如果为 false,此点是控制点。
V2 position; // 点的校正位置(最初作为与前一个点的增量提供)。
};
接下来,我们需要解开 TTF 应用于我们点数据的一些压缩。有时,它从流中省略点——我们必须把它们加回来。考虑预期的点流看起来像:点(在曲线上)-> 点(离曲线)-> 点(在曲线上)-> 重复。查看我们实际提供的数据流,我们经常遇到像 点(离曲线)-> 点(离曲线)这样的情况——我们在这里做什么?在这些场景中,一个在曲线上的点被_隐含_在这些离曲线点的中点,我们需要自己添加它。此外,规范很乐意给你像 点(在曲线上)-> 点(在曲线上) 这样的情况——这里没有任何技巧,这只是这两点之间的直线,这只是另一个需要注意的情况,与二次贝塞尔情况分开。
我选择建模的方式是通过如下描述曲线:
struct GlyphCurve {
V2 point; // 此曲线的起点。
V2 control;
};
其中贝塞尔曲线的终点被暗示为轮廓中下一个贝塞尔曲线的起点。我选择通过在不必要的中点插入控制点来强制线性情况进入此模型,以简化后续处理,你当然可以选择将这些情况分开并单独处理它们。
文件规范还描述了”复合字形”——这本质上是另一种压缩形式。某些字符包含在整个给定字体中常用的子字形(想想 ‘i’ 和 ‘j’ 上面的点,或像 é 这样的字符上的变音符号)——TTF 不是多次复制轮廓数据以覆盖所有字符,而是将它们定义为单独的字形,并指示你根据目标字符使用一组变换合并它们。精确的变换计算在规范中有更详细的描述,但实际检索轮廓形状本身的困难部分如上所述被重用。
字形光栅化
有了形状,是时候进行光栅化了。算法大致如下:
-
确定我们想要光栅化到的位图的目标部分。
你可以为每个字符生成单独的位图,但生成包含所有字符的图集并在稍后向 GPU 发出绘制调用时索引到其中可能更有效/最优。这就是我所做的。因此,我们需要确定图集的哪个子部分将字符位图渲染到。
-
对于目标位图中的每一行(y 值),确定字形空间中对应的 y 值。
这可以通过两个空间之间的简单线性映射来完成。例如,我们知道目标位图的高度和字形的高度,所以,给定目标位图空间中的某个 y 值,字形空间中对应的 y 可以通过使用比例找到(
y / target_height = ??? / glyph height)。这里需要注意的一件事是我们想从像素的中间测量,而不是顶部或底部。 -
确定我们的 y 值在字形空间中的轮廓的 x 交点。
这可以通过求解二次贝塞尔公式中的
t来完成——在0 <= t <= 1的情况下,我们有一个交点(因为曲线被链接在一起,我只检查交点0 <= t < 1,因为Bezier_n(1) == Bezier_n+1(0))。注意!求解二次方程涉及除以某个变量值,它可能是 0——这些情况描述了秘密是直线的贝塞尔曲线。对于这些情况,你应该改为检查线性交点。我早先强制线条进入二次贝塞尔表示的决定在这里很好用,因为我们无论如何都必须实现这个检查。 -
对于每个交点,确定它是”进入”还是”离开”形状。
例如,考虑一条射线水平穿过字母 ‘A’ 中的孔——这条线上有四个独立的交点。我们需要确定哪组交点构成形状的”入”和”出”。合理地认为,对于给定的线,我们应该总是有偶数个交点;对于每个入口,必须有一个出口。然而,实际上,由于浮点精度误差(和其他奇怪魔法),这可能并不总是成立。
这可以通过利用 TTF 规范中如何描述轮廓来处理。如前所述,”外壳”必须顺时针定义,”孔”必须逆时针定义。因此,通过查看我们相交的贝塞尔曲线的导数,我们可以确定我们是在进入(y 值增加)还是离开(y 值减少)形状。Apple 规范建议在我们遇到交点时累积运行缠绕计数——这是通过根据我们是进入还是离开形状为我们遇到的每个交点 +/- 1 来实现的。当缠绕顺序 > 0 时,笔应该落下,我们应该正在绘制。否则,笔应该抬起。
-
将交点转换到目标位图空间,并进行光栅化。
现在我们有了所需的所有信息,我们可以索引到我们的目标位图中,并根据每个 x 值是在形状内部还是外部来着色。
很好,我们完成了!结果看起来怎么样?
相当糟糕…为什么?据我所知,这发生有几个原因。
- 缺乏抗锯齿(尽管启用线性采样并不能拯救它…)。
- TTF 字体有关于如何以各种大小渲染位图的额外指令,我们现在故意忽略。
- 位图字体不能很好地缩放。
理想情况下,我们希望位图图集具有相对较小的点大小,以节省内存空间。然而,根据我的经验,只有当字体大小相对较高时,我们才能真正获得体面的结果(这里的例子渲染为 32 像素大小,看起来仍然很糟糕)。
SDF 字形渲染
我们有许多选项可以纠正我们次优的字体光栅(MSDF 是我考虑的另一个选项,你也可以看看子像素渲染)。我选择为字形生成 SDF,这很有吸引力,因为作为已实现内容的附加步骤,它相当简单(例如对于位图光栅化)。此外,SDF 字体可以很好地缩放到任意分辨率,这是一个合理的约束,例如在 3D 环境中渲染标志等(例如玩家可能任意接近正在渲染的字体的游戏)。我还发现 SDF 字体在 2D 场景中也看起来合理(例如对于 UI、菜单等)。
但首先,什么是 SDF?它将如何解决我们这里的问题?
SDF 代表”有符号距离场”(Signed Distance Field)。SDF 可以被描述为任意形状的函数表示,它确定到该形状边缘的距离(”有符号”来自这个值在形状内部为负,在形状外部为正)。对于字体渲染,我们不是为目标位图中的每个像素计算二进制的”这是在内部还是外部”决定,而是确定从该像素到相反状态的最近点(绘制或未绘制)的距离。这沿着形状的”内部性”边缘提供了平滑的梯度,我们可以利用 GPU 快速高效地大规模插值值的能力,以比原始位图表示更广泛的分辨率范围渲染字体字符。要了解更多关于 SDF 的信息(以及它们如何更普遍地应用于 2/3D 形状),Xor 的这篇文章是一个很好的起点。
这是字母 ‘B’ 的 SDF。注意当我们沿着字母的硬边缘进一步延伸时,字母周围有一个梯度——我们可以使用它来对抗原始位图进行抗锯齿。
根据 Valve 关于这个主题的论文,为给定的字形生成 SDF:
-
生成字形的位图。
这完全重用了我们之前实现的内容。
根据我的经验,在这里光栅化一个相对较大的位图很重要,以便为后续步骤提供高水平的粒度。本质上,分辨率越高,后面的距离测量就越准确,从而产生更高质量的结果。由于生成 SDF 后不维护位图光栅,我们不需要支付那么高的内存成本来在更长时间内保持高分辨率位图光栅。我这里默认位图字体高度为 64。
-
与位图光栅化类似,我们需要确定在目标位图中何处放置 SDF 光栅。
-
对于 SDF 目标位图中的每个像素,我们需要确定原始位图中哪个像素最近。
这与生成原始位图类似,但略有不同——在这种情况下,我们只需要映射目标位图 y 值(这里,我们对每个像素都这样做)。
-
对于目标 SDF 位图中的每个像素,确定到原始字形位图中相反状态像素的最近距离。
这可以通过选择某个”扩散因子”(内核大小)并在目标像素周围搜索该大小的正方形来完成。这个参数是可调的,我发现值 4 在这里是合理的。在没有找到开启像素的情况下,使用给定内核大小的最大可能距离。
-
将距离映射到字节范围 [0, 255] 并将其保存到 SDF 图集。
这里的一个警告是,这里生成的 SDF 位图本身不是为了渲染——这些更好地描述为每个候选字形的 SDF 函数值的缓存。所以,我们需要额外的指令来将这些距离值转换为我们可以在屏幕上绘制的实际位图。我为此目的生成了一个着色器(OpenGL)。更有趣的是片段着色器:
#version 330 core
uniform sampler2D atlas_image;
uniform vec3 text_color;
uniform float threshold;
uniform float smoothing;
in vec2 glyph_tex_coord;
out vec4 frag_color;
void main() {
float dist = texture(atlas_image, glyph_tex_coord).r;
float alpha = smoothstep(threshold - smoothing, threshold + smoothing, dist);
frag_color = vec4(text_color, alpha);
}
你会看到我们为我们正在渲染的每个给定像素提取缓存的距离值,对其应用一些 smoothstep 来确定 alpha,在最终像素颜色中使用结果。简而言之,这确定了沿着 SDF 周围的”内部性”场的哪个距离我们实际上认为在该字符的内部和外部。这由 2 个参数控制,threshold,这是入/出的截止,以及 smoothing,它在阈值值上应用一些逐渐过渡/模糊。
那么,这些结果看起来怎么样?
好多了。这与原始位图并排看起来怎么样?SDF 光栅中是否仍然存在缺陷?
这使用位图高度 100 和 SDF 高度 32(缩放到各种渲染高度)。你可以看到,总的来说,SDF 在各个方面都比原始位图看起来更好。但是,我们开始在较大的字体大小上看到一些不希望的伪影。这可以通过以更高的分辨率渲染 SDF 图集来纠正,但这伴随着更高的内存成本,所以选择你的毒药。
最后,结果看起来还不错。这是一个我使用这种技术渲染一些 UI 小部件的例子:
我的完整实现可以在这里找到。
总结
这篇文章从底层原理出发,完整地介绍了字体渲染的技术栈:
- TTF 文件格式:理解
cmap、loca、glyf等核心表的作用,以及如何从码点映射到字形数据。 - 字形解析:二次贝塞尔曲线的数学原理,轮廓的方向性规则(顺时针/逆时针),以及复合字形的优化策略。
- 位图光栅化:通过扫描线算法将矢量形状转换为像素,处理缠绕计数来判断内外。
- SDF 渲染:有符号距离场技术让字体在任意缩放下保持清晰,是现代游戏和 UI 中常用的技术。
推荐资源
如果你想深入学习如何自己实现字体渲染,我推荐以下资源:
- Sebastian Lague 的视频:Coding Adventure: Rendering Text
- Sphaerophoria 的视频系列
- Tsoding 的视频:Rasterizing Splines in C (from fundamentals)
- stb_truetype 实现
字体渲染是计算机图形学中一个精妙而复杂的领域,理解其原理不仅能让我们更好地使用现有工具,也为实现自定义效果(如描边、阴影、发光等)打开了大门。