Plaza 新闻汇总

亚像素距离变换:Use.GPU 中的字体渲染

本文介绍了 Use.GPU 中的文本渲染技术,该技术使用了一种定制的 Signed Distance Fields (SDFs) 方法。

**SDFs**

SDFs 的核心思想很简单:通过记录每个点到图形边缘的距离,生成一个渐变场或图像,来绘制任意大小的清晰、抗锯齿图形。这可以比目标分辨率更低。

然后,增加对比度,直到渐变在目标大小处恰好宽 1 个像素。可以对其进行采样,以获得完美的抗锯齿不透明度蒙版。

这种方法适用于典型尺寸的文本,并且可以完美处理分数偏移和缩放,且不会出现闪烁。从信号处理数学的角度来看,它也相当正确:它非常接近像素大小的圆形窗口的平均值,即低通卷积。

关键的是,它以渲染的字形作为输入,这意味着可以完全忽略 TrueType 字体的细节和贝塞尔曲线光栅化,并将这些工作卸载到现有的库中。

**经典的 EDT**

常用的解决方案是欧氏距离变换 (EDT)。给定一个二进制蒙版,它将生成一个无符号距离场。它包含内部或外部区域的平方距离 d²,可以对其进行平方根运算。

与傅里叶变换类似,可以通过先水平应用于每一行 X,然后垂直应用于每一列 Y(反之亦然)的方式将其应用于 2D 图像。要制作一个带符号的距离场,需要分别对内部和外部执行此操作,然后将两者组合为 inside – outside 或反之亦然。

算法很有技巧,是 80 年代风格的 C 代码,时间复杂度为 O(N),变量名很多,并且非常适合 CPU 缓存。虽然经常被复制粘贴,但很少有人理解。在 TypeScript 中,它看起来像这样,其中数组被就地修改,f、v 和 z 是最多 1 行/列的临时缓冲区。参数 offset 和 stride 允许代码在扁平化的 2D 数组中的 X 或 Y 方向上使用。

**有问题的 EDT**

那么问题出在哪里呢?上述内容假设了一个二进制蒙版。

如果尝试从 P 中减去一个二进制 N,则会产生一个差 1 的错误:

它直接从 1 变到 -1 并返回。可以添加 +/- 0.5 来解决此问题。

但是,如果每个白色和黑色之间都有一个灰色的像素,我们将它分类为内部 (0) 和外部 (0),则似乎可以正常工作:

这是有人必须意识到的结果,他们推断:“对于 50% 不透明的像素,内部和外部之间的边缘恰好落在像素的中间,上述结论是正确的。”

“较浅的灰色更靠近内部,较深的灰色更靠近外部。因此,只需将 l = level - 0.5 视为带符号的距离,并使用 l² 作为灰色像素的初始内部或外部值即可。这将导致正或负距离场按亚像素量 l 偏移。然后,EDT 将在 X 和 Y 方向上传播它。”

最初的想法是正确的,因为这只是在反向运行 SDF 渲染。不透明度蒙版中的灰色像素是对比度调整 SDF 且未将其放大为纯黑色或白色时获得的结果。灰色像素内部的信息是“正确的”,直至四舍五入。

但是这里有两个错误。

第一个错误是,即使在抗锯齿图像中,白色像素也可能紧挨着黑色像素。尤其是在字体中,它们是像素提示的。因此,那里的 SDF 是错误的,因为它直接从 -1 变到 1。这会导致轮廓加倍,例如,在这个底边附近。

要解决此问题,可以通过有意地使这些边缘变成非常暗或非常浅的灰色来消除清晰的情况。

但第二个错误更细微。EDT 在 2D 中有效,因为可以将 X 的输出作为 Y 的输入。但这意味着提供给 X 的任何非零输入都表示另一个维度 Z,与 X 和 Y 分开。结果的平方距离将为 x² + y² + z²。这是一个 3D 距离,而不是 2D 距离。

鉴于所有这些,令人惊讶的是它竟然起作用了。最终字形中不那么明显的原因仅仅是因为正负场在其各自灰色像素周围包含相同但相反的误差。

**非亚像素 EDT**

如前所述,EDT 算法本质上是在每次都创建一个 1D 沃罗诺伊图。它为每个数组索引找到到最近最小值的距离。但这些最小值本身没有理由位于整数偏移量上,因为第二个 for 循环有效地对数据进行了重新采样。

因此,可以获取一个输入蒙版,并为每个索引标记一个水平偏移量 Δ:

只要偏移量很小,就不会有两个索引交换顺序,并且代码仍然有效。然后,根据偏移的抛物线构建沃罗诺伊图,但在未偏移的索引处对结果进行采样。

**问题 1 - 相反的偏移**

这让我陷入第一个兔子洞,试图使 EDT 能够进行亚像素处理,而不会失去其吸引人的简单性。我首先研究了 1D 中亚像素 EDT 的细微差别。这有点像海市蜃楼,因为大多数实际问题只出现在 2D 中。尽管这里有一个重要的见解。

给定一个零和无穷大的蒙版,只能移动每个段的第一个和最后一个点。无穷大不会做任何事情,而中间的零应该保持为零。

使用偏移量 A 某种程度上按预期工作:这将增加或减少由分数像素填充的值,计算一个平方距离 (d + A)²,其中 A 可以为正或负。但偏移索引处的值本身始终为 (0 + A)²(正)。这意味着它始终在外部,无论它是在向左还是向右移动。

如果 A 向左移动 (–) ,则该点在内部,并且(无符号)距离应为 0。在 B 处情况相反:如果 B 向右移动 (+) ,则距离应为 0。这似乎很烦人,但可以解决,因为零可以通过相反的带符号场填充。但这只在查看二进制 1D 案例时才有效,其中只有零和无穷大。

在 2D 中,第二次传递具有非零距离,因此每个索引都可以偏移:

现在,明确地解析每个亚像素比您想象的要困难:

重要的是要注意,EDT 采样的函数实际上并不平滑:它是离散抛物线集的最小值,这些抛物线以一定角度交叉。输出的平方根仅产生平滑的线性渐变,因为它以整数偏移量对每个抛物线进行采样。每个中心在每次传递中仅按整数的平方向上移动,因此交叉点是可预测的。永远不会采样 (d + …)² 的“错误”一侧。亚像素 EDT 没有这种优势。

亚像素 EDT 并没有不可挽回地损坏。相反,它们仅在导致无符号距离场增加时有效,即如果它们扩展了空隙。这是一个问题:任何扩展正场的偏移都会收缩负场,反之亦然。

要解决此问题,需要摆脱随意的阶段,并真正理解 P 和 N 作为连续的 2D 场。

**问题 2 - 对角线**

考虑一个具有锯齿状的斜边。要了解经典 EDT 如何解决它,可以将其转换为所有白色像素中心的沃罗诺伊图:

在底部附近,该场受角上的白色像素支配:它们向下形成对角线部分。在边缘本身附近,该场在大致呈三角形的部分内完全垂直运行。在这两种情况下,指向单元格中心的箭头与真实的对角线边缘仅大致垂直。

在完美的对角线附近,边缘距离是错误的。边缘像素的距离向上或向右 (1),而不是更合乎逻辑的对角线 0.707….边缘上的真正最近点不是网格的一部分。

这些场实际上直到 6-7 个像素外才会正确解析。可以使用例如 8 倍降尺度来隐藏这些缺陷,但这需要 64 倍的像素。无论哪种方式,都不应期望从 EDT 获得完美的数值精度。仅仅因为它在数学上是可分离的并不意味着它特别好。

事实上,它之所以可分离仅仅因为它不太好。

**问题 3 - 渐变**

在 2D 中,灰色情况也只有一个正确的答案。考虑一个斜边,抗锯齿:

将其阈值化为黑色、灰色或白色,得到:

如果现在将灰色分类为内部和外部,则突出显示的像素将成为两个蒙版的一部分。正负场都将精确为零,因此 SDF (P - N) 也是如此:

这会创建一个幻像垂直边缘,将 P 和 N 推开,并导致平均斜率小于 45º。该场形状根本不对,因为灰色像素可能被其他灰色像素包围。

这也解释了为什么 TinySDF 尽管像素化却神奇地似乎可以工作。l² 灰色校正填充了 (P - N) 字段中为零的间隙,并且它在每一侧向对称错误的 P 和 N 字段进行插值。

如果改为将灰色分类为既不是内部也不是外部,则 P 和 N 在边界处重叠,并且如果操作正确,则可以将其解析为具有清晰 45 度斜率的连贯 SDF:

看起来像差 1 的错误实际上是 2D 或更高维度中的正确方法。然后,亚像素 SDF 将是该字段的修改版本,其中 P 和 N 侧同步更改以保持相互一致。

虽然我们将以一种迂回的方式到达那里。

**问题 4 - 交换**

值得指出的是:亚像素 EDT 在 2D 中根本无法交换。

首先,考虑普通 EDT 的数据流:

来自角像素的信息可以在进行 X-then-Y 和 Y-then-X 时都通过空隙流动。但来自水平边缘像素的信息只能垂直然后水平流动。这没关系,因为相邻像素之间的分隔线也完全垂直:红色箭头永远不会“获胜”。

但是,如果引入亚像素偏移,则分隔线可能会发生变化:

数据流仍然限于原始 EDT 模式,因此顶部的边缘像素只能通过向下开始来传播。它们只能在顺序为 Y-then-X 时影响相邻列。对于垂直边缘,情况相反。

也就是说,这仅在浅凹曲线上的问题,在附近没有任何角像素的地方。错误在于它“捕捉”到错误的边缘点,但仅当它已经远离边缘几个像素时才会发生。在这种情况下,较小的 x² 项被更大的 y² 项所掩盖,因此平方根运算后的绝对误差很小。

**ESDT**

了解了所有这些,以下是如何组装“真正的”欧氏亚像素距离变换。

**亚像素偏移**

首先,需要确定亚像素偏移。仍然可以将 level - 0.5 视为任何灰色像素的带符号距离,并暂时忽略所有白色和黑色。

棘手的部分是确定该距离的确切方向。作为近似值,可以检查每个灰色像素周围的 3x3 邻域,并对平面进行最小二乘拟合。只要在此邻域中至少有一个白色像素和一个黑色像素,就会得到一个指向实际边缘所在位置的向量。在实践中,使用简单的 [1 2 1] 内核在此处应用一些水平/垂直平滑。

该逻辑对于细褶皱和尖峰被禁用,在这些位置它不起作用。此类点被视为完全屏蔽,以便相邻距离在那里传播。例如,需要让 W 的尖锐负空间正确显示。

我还实现了一个松弛步骤,如果相邻向量指向相似的方向,则会对其进行平滑处理。但是,效果很小,并且会使非常锐利的角变圆,因此最终默认情况下将其禁用。

然后,目标是执行一个使用这些偏移位置作为最小值的 ESDT,以获得亚像素精度的距离场。

**P 和 N 连接**

我们之前看到,只有非蒙版像素才能具有影响输出的偏移量 (#1)。只有灰色像素才有偏移量,但我们得出结论,灰色像素应该被屏蔽,以形成具有正确形状的连接 SDF (#3)。这行不通。

SDFs 既是问题又是解决方案。扩展和收缩 SDF 很容易:添加或减去一个常数。因此,可以提前在几何上扩展 P 和 N 字段,然后在数值上撤消它。这是通过在最初计算的偏移量之上,将它们各自的灰色像素中心向相反方向推半像素来完成的:

这样,它们就可以在两个字段中都保持屏蔽状态,但始终被推入 0 到 1 像素之间。P 和 N 灰色像素偏移之间的距离始终恰好为 1,因此 P 和 N 之间的非零重叠在任何地方都保证恰好宽 1 个像素。它是一个完美的连接,无论在何处对其进行采样,因为两端之间的线穿过像素中心。

然后,在计算最终 SDF 时,执行相反的操作,将每个像素偏移半像素,并使用 max 进行修剪:

一次只有一个 P 或 N 将 > 0.5,因此这是精确的。

为了处理纯黑/白边缘,将白色像素(仅水平或垂直)的任何黑色邻居视为具有 0.5 像素偏移量的灰色(在 P/N 膨胀之前)。不需要发生实际的模糊,结果在数值上是精确的,减去一个很小的误差,这很好。

**ESDT 状态**

然后,ESDT 的状态包括记住每个像素的带符号 X 和 Y 偏移量,而不是平方距离。这些被分解成距离和阈值计算中,分成其适当的平行和正交分量,即 X/Y 或 Y/X。与 EDT 不同,每次 X 或 Y 传递都必须同时意识到两个轴。但算法在其他方面基本保持不变,这里 X-then-Y。

**X 传递**

在开始时,只有灰色像素有偏移量,并且它们都在 -1…1(不包括)范围内。随着 ESDT 的每次传递,获胜的最小值的偏移量会传播到其影响范围,跟踪总距离 (Δx, Δy) (> 1)。最后,每个像素的偏移量都指向最近的边缘,因此可以将平方距离计算为 Δx² + Δy²。

**Y 传递**

您可以看到,左上角的垂直距离实际上是垂直的,而不是平均垂直于轮廓定向:它们还没有机会水平传播。但它们确实考虑了垂直亚像素偏移,这是主要组成部分。因此,即使没有校正,它仍然可以创建具有惊人小误差的平滑 SDF。

**修复**

可交换性错误都偏向正值,这意味着可以获得真实距离场的上限。

可以获取 X 然后 Y 和 Y 然后 X 的最小值。这将重用所有相同的准备工作,并且会以 2 倍的 ESDT 为代价恢复旋转独立性。可以尝试使用一些技巧以 1.5 倍的成本进行 X 然后 Y 然后 X。但两者都不会改善对角线区域,这些区域在原始 EDT 中仍然存在问题。

相反,实现了一个额外的松弛传递。它访问每个像素的目标,并仔细检查 4 个直接邻居(具有亚像素偏移)中是否有一个不是更好的解决方案:

这是一个很好的启发式方法,因为如果目标偏离 >1 像素,则要么存在可行的可交换传播路径,要么距离非常远,误差可以忽略不计。它修复了对角线,在分辨率允许的情况下创建整齐的线:

可以进一步发挥作用,因为知道偏移量应该垂直于字形轮廓。可以在此处添加一些点积的重新投影,但使它在边缘情况下不会发生错误会很棘手。

虽然可以看出可视化时未松弛的偏移是错误的,并且已修复的偏移更好,但输出字形的视觉差异很小。需要将字形放大到巨大的尺寸才能并排看到差异。因此,它默认情况下也被禁用。原始 EDT 中的对角线也是错误的,而且几乎看不出来。

**Emoji**

表情符号通常存储为全彩色透明 PNG 或 SVG。ESDT 可以直接应用于其不透明度蒙版以获取 SDF,因此没有问题。

有一些极少数的表情符号具有半透明区域,但可以将其设为实心。为此,只需使用一个过滤器来检测具有(几乎)相同透明度级别的像素的“+”形排列。然后将其膨胀 3x3 以获得每个区域的平均透明度级别。然后除以它以仅保留抗锯齿边缘的透明度。

真正的问题是在边缘处混合颜色,当表情符号正在渲染和缩放时。透明像素的 RGB 颜色未定义,因此存在的任何值都会混合到周围的像素中,例如,创建一个微妙的黑色光晕:

一个常见的解决方案是预乘 Alpha。不透明度被烘焙到 RGB 通道中作为 (R * A, G * A, B * A, A),并且透明区域必须是完全透明的黑色。这允许使用预乘混合模式,其中 RGB 通道直接相加而无需进一步缩放,以消除错误。

但 SDF 字形的不透明度通道是动态的,并且独立于颜色,因此无法预乘。即使对于完全透明的区域,也需要有效的颜色值,以便向上或向下缩放仍然干净。

幸运的是,ESDT 计算了从每个像素直接指向最近边缘的 X 和 Y 偏移量。可以使用它们在一个传递中向外传播颜色,填充整个背景。它不需要非常精确,因此不需要过滤。

结果看起来非常棒。在正常尺寸下,清晰的边缘隐藏了内部有点模糊的事实。表情符号字体通过底层的 ab_glyph 库支持,但对于 Web 来说太大了(10MB 以上)。因此,可以根据需要按需加载 .PNG,无论需要什么分辨率。将其连接到 2D 画布以渲染本机系统表情符号留给读者作为练习。

**着色**

最后,注意如何使用 SDF 实际进行渲染,这比您想象的要细致入微。

我将所有 SDF 字形按需打包到一个图集中,这与我在 Use.GPU 中其他地方使用的相同。它具有一个不回溯的自定义布局算法,针对运行时使用类似大小的片段填充布局进行了优化。字形在其正常字体大小的 1.5 倍处进行光栅化,然后四舍五入到最接近的 2 的幂。额外的 50% 确保低 DPI 显示器上的小字体仍然使用更高质量的 SDF,而高 DPI 显示器只需放大该 SDF 即可,而不会出现明显的质量损失。舍入确保类似的字体大小重用相同的 SDF。也可以独立于字体大小覆盖细节。

要确定绘制 SDF 的对比度因子,通常使用屏幕空间导数。这样做有好的和坏的方面。目标是获得 SDF 像素与屏幕像素的比率,因此最好的方法是为 GPU 提供 SDF 纹理像素的坐标,并要求它计算相邻屏幕像素之间的差异。这对于 3D 中以一定角度的表面也有效。这样做的错误方法将改为使用相对纹理坐标,并引入基于视图或图集大小的其他缩放因子,而它们都应该抵消。

然后,在调整 SDF 的对比度以进行渲染时,务必围绕零级进行调整。字形的理想矢量形状在缩放时不应扩展或收缩。与 TinySDF 类似,使用 75% 灰色作为零级,以便为外部分配更多 SDF 范围而不是内部,因为扩展字形比收缩字形更常见。

同时,其中心恰好位于零级边缘的像素实际上有一半在内部,一半在外部,即 50% 不透明。因此,在缩放 SDF 后,需要向该值添加 0.5 以获得混合的正确不透明度。这为您提供了一个数学上精确的字体渲染,该渲染近似于使用像素大小的圆形或方框进行卷积。

但我更进一步。字体并非为屏幕而设计,而是为纸张而设计,使用会渗透的墨水。某些渲染器(例如 MacOS)会复制此效果。物理渗透距离相对恒定,因此字体越大,渗透效果的比例越小。在 32 像素或更大尺寸时,使用 0.25 像素的渗透效果最佳。对于较小的尺寸,它线性减弱。当缩小文本块时,它们会变得稍微更粗,而不是变细,这实际上是在查看文档缩略图时的一个很好的效果,在该缩略图中,文本行在 SDF 分辨率失败时会变成一个固体块。

在 Use.GPU 中,我更喜欢使用伽马校正线性 RGB 颜色,即使是 2D 也是如此。最让我惊讶的是,它的外观无可争议地好得多。即使在低 DPI 的情况下,文本在小尺寸下看起来也坚固耐用且易读。因为 SDF 可以缩放,所以没有真正的字体提示,但它真的不需要,这只是一个不错的额外功能。

据推测,可以在 SDF 字形内部跟踪提示点或边缘,然后以某种方式进行动态扭曲,但这比现在所做的要复杂得多,现在所做的是在屏幕上涂抹对比度纹理。它确实有可以打开的捕捉功能,可以避免各个字母的抖动。但如果将其关闭,则可以获得流畅的亚像素所有内容:

我始终非常喜欢彩色 LCD 上使用的 3x1 亚像素渲染(即 ClearType 等),并且当由于高 DPI 显示器的流行而逐渐淘汰时,我感到有些难过。但事实证明,3 倍分辨率仅提供了边际优势……真正的改进始终是它具有自定义伽马校正混合模式,这是很多人仍然做错的事情。即使没有 RGB 亚像素,伽马校正 AA 看起来也很好。在我们的有生之年,将整个桌面转换为线性 RGB 也不会发生,但我现在真的更想要它。

有些人将某些人与抗锯齿相关的“模糊文本”通常只是使用错误的伽马曲线混合的文本,并且没有为相关的字体提供合适的渗透效果。

如果要从现有的输入数据创建 SDF,则亚像素精度至关重要。如果没有它,完全清晰的笔划实际上会变得不均匀,对角线看起来可能凹凸不平,并且无法制作清晰的扩张轮廓或阴影。如果使用 EDT,则必须从高分辨率源开始,然后缩小边缘附近的所有错误。但如果使用 ESDT,则可以放大甚至表情符号 PNG 以获得不错的效果。

事后看来,这似乎很明显,但在使其某种程度上起作用与真正使所有细节都正确之间存在巨大差异。有许多错误的开始和死胡同,因为亚像素精度也意味着一个坏像素会毁掉一切。

在某些圈子里,SDF 文本现在已经成为一个老把戏……但稳健可靠的实现仍然需要相当多的工作,对于更难的部分来说,几乎没有可以借鉴的东西。

顺便说一句,我确实考虑过是否可以直接使用沃罗诺伊技术,但就计算而言,它要复杂得多。尽管很漂亮:

ESDT 足够快,可以在运行时使用,并且该实现可用作独立导入以供即插即用。

这篇文章最初是一个单独的实时 WebGPU 图表,您可以随意使用它。所有图表源代码也可用。

原文地址
2024-12-27 06:31:48