使用coverage遮罩渲染字体,第1部分

图片

当我们开始开发性能分析器时 ,我们知道我们将自己完成几乎所有的UI渲染。 很快,我们不得不决定选择哪种方法来呈现字体。 我们有以下要求:

  1. 我们必须能够实时渲染任何大小的任何字体,以适应Windows用户选择的系统字体及其大小。
  2. 字体渲染应该非常快,允许渲染字体时不要刹车。
  3. 我们的用户界面具有许多平滑的动画,因此文本应该能够在屏幕上平滑移动。
  4. 小字体时应可读。

那时我还不是一个出色的专家,所以我在Internet上搜索信息,发现了许多用于渲染字体的技术。 我还与游击队游戏技术总监Michail van der Leu进行了交谈。 该公司尝试了多种渲染字体的方法,其渲染引擎是世界上最好的之一。 Mihil简要概述了他对新字体渲染技术的想法。 尽管我们已经拥有足够的现有技术,但是这个想法吸引了我,我开始实施它,而没有注意打开字体的奇妙世界。

在本系列文章中,我将详细描述我们使用的技术,将描述分为三个部分:

  • 第一部分中,我们将学习如何使用从统一网格中采样的16xAA实时渲染字形。
  • 第二部分中,我们将转到旋转的网格,以对水平和垂直边缘进行漂亮的抗锯齿。 我们还将看到如何将完成的着色器几乎完全缩小为一个纹理和一个查找表。
  • 第三部分中,我们将学习如何使用Compute和CPU实时光栅化字形。

您还可以在分析器中查看完成的结果,但这是使用我们的字体渲染器渲染Segoe UI字体的屏幕示例:


字母S增加了,栅格化大小仅为6x9纹素。 原始矢量数据被渲染为路径,而旋转的采样模式则从绿色和红色矩形渲染。 由于以高于6×9的分辨率渲染,因此灰色阴影不会在像素的最终阴影中显示,因此会显示子像素的色相。 这是非常有用的调试可视化效果,可确保亚像素级别上的所有计算均正常运行。


想法:存储涂料而不是遮荫


字体渲染器需要处理的主要问题是在固定像素网格中显示可缩放矢量字体数据。 从向量空间到完成像素的过渡方法不同。 在大多数这些技术中,在渲染到临时存储区(例如纹理)之前,对曲线数据进行栅格化处理以获得特定的像素大小。 临时存储用作字形缓存:多次渲染同一字形时,将从缓存中提取字形并重新使用以避免重新栅格化。

在以中间数据格式存储数据的方式上,技术上的差异显而易见。 例如,Windows字体系统将字形光栅化为特定大小的像素。 数据存储为每个像素的色相 。 阴影表示此像素字形的覆盖范围的最佳近似值。 渲染时,只需将像素从字形缓存复制到目标像素网格即可。 将数据转换为像素格式时,它们的缩放比例不好,因此,缩小时会出现模糊字形,而放大时会出现清晰可见的字形。 因此,对于每个最终大小,字形都被渲染到字形缓存中。

有符号距离场使用不同的方法。 可以维持到字形最近边缘的距离 ,而不是像素的色调。 这种方法的优点是对于弯曲的边缘,数据缩放比阴影要好得多。 随着字形放大,曲线保持平滑。 这种方法的缺点是笔直和锐利的边缘会被平滑。 通过存储颜色数据的高级解决方案(如FreeType) ,比SDF更好。

如果保留了像素的色相,则必须首先计算其覆盖范围。 例如,stb_truetype很好地说明了如何计算覆盖率和色相。 另一种流行的近似覆盖方法是以比最终分辨率更高的频率对字形进行采样。 这将计算适合目标像素区域中字形的样本数。 命中数除以最大可能样本数确定了色相。 由于已经针对特定的像素网格分辨率和对齐方式将覆盖率转换为色调,因此无法在目标像素之间放置字形:色调无法正确反映目标像素窗口样本的真实覆盖率。 因此,以及我们稍后将考虑的其他一些原因,此类系统不支持亚像素移动。

但是,如果我们需要在像素之间自由移动字形怎么办? 如果色调是预先计算的,则在目标像素区域中的像素之间移动时,我们将无法找到色调。 但是,我们可以在渲染时延迟从coverage到色调的转换。 为此,我们不会存储阴影,而是存储涂料 。 我们以16个目标分辨率的频率采样一个字形,并且对于每个采样,我们都保存一个位。 在4×4网格上采样时,每个像素仅存储16位就足够了。 这将是我们的保护罩 。 在渲染过程中,我们需要计算进入目标像素窗口的位数,该目标像素的分辨率与texel存储库的分辨率相同,但未物理连接到目标像素窗口。 下面的动画显示了以四个纹理像素栅格化的一部分字形(蓝色)。 每个纹理像素分为4×4单元的网格。 灰色矩形表示在字形中动态移动的像素窗口。 在运行时,对落入像素窗口的样本数进行计数以确定色相。


简要介绍基本字体渲染技术


在继续讨论字体渲染系统的实现之前,我想简要介绍一下此过程中使用的主要技术:字体提示和子像素渲染(在Windows上,此技术称为ClearType)。 如果您仅对抗锯齿技术感兴趣,可以跳过本节。

在实施渲染器的过程中,我越来越了解字体渲染开发的悠久历史。 研究完全集中在字体渲染的唯一方面-小尺寸的可读性。 为大字体创建出色的渲染器非常简单,但是要编写一个以小尺寸保持可读性的系统却非常困难。 字体渲染的研究历史悠久,影响深远。 例如,阅读有关光栅悲剧的信息 。 逻辑上这是计算机专家的主要问题,因为在计算机的早期阶段,屏幕分辨率很低。 这一定是OS开发人员必须处理的首要任务之一:如何在屏幕分辨率较低的设备上使文本可读? 令我惊讶的是,高质量的字体渲染系统非常面向像素。 例如,以以下方式构造字形:字形始于像素的边界,字形的宽度是像素数的倍数,并且调整了内容以适合像素。 这种技术称为网格划分。 我曾经使用过计算机游戏和3D图形,其中的世界是由单位构建的,并投影到像素中,所以我有些惊讶。 我发现在字体渲染领域这是一个非常重要的选择。

为了显示网格化的重要性,让我们看一下字形栅格化的可能方案。 想象一下,在像素网格上对字形进行了栅格化,但是字形的形状与网格结构并不完全匹配:


抗锯齿将使字形右边和左边的像素均等灰。 如果字形稍微移动以使其更好地匹配像素的边界,则只有一个像素将被着色,并且将变为完全黑色:


现在,字形与像素的匹配度很高,颜色变得越来越模糊。 清晰度差异很大。 西方字体有许多带有水平和垂直线的字形,如果它们与像素网格的匹配程度不高,则灰色阴影会使字体变得模糊。 即使是最好的抗锯齿技术也无法解决此问题。

提出了字体提示作为解决方案。 字体作者应在其字体中添加有关字形不能完美匹配的信息的信息。 字体渲染系统会扭曲这些曲线,以将其捕捉到像素网格。 这大大提高了字体的清晰度,但代价是:

  • 字体略有变形 。 字体看起来与预期的不完全一样。
  • 所有字形都必须附加到像素网格:字形的开头和字形的宽度。 因此,不可能在像素之间为它们设置动画。

有趣的是,在解决这个问题上,苹果和微软采取了不同的方式。 Microsoft坚持绝对的清晰度,Apple寻求更准确地显示字体。 在Internet上,您会发现有人抱怨Apple机器上的字体模糊,但许多人喜欢他们在Apple上看到的字体。 这部分是出于品味的问题。 这是 Joel关于软件帖子, 这是 Peter Bilak关于该主题帖子,但是,如果您搜索Internet,则可以找到更多信息。

由于现代屏幕中的DPI分辨率正在迅速提高,因此出现了一个问题,即将来是否需要像今天这样需要字体提示。 在当前状态下,我发现字体提示是一种非常有用的技术,可以清晰地呈现字体。 但是,我的文章中描述的技术将来可能会成为有趣的替代方法,因为字形可以自由放置在画布上而不会变形。 并且由于这本质上是一种抗锯齿技术,因此它可以用于任何目的,而不仅仅是渲染字体。

最后,我将简要讨论子像素渲染 。 过去,人们意识到,通过使用计算机显示器的红色,绿色和蓝色光线,您可以将屏幕的水平分辨率提高三倍。 每个像素都是由这些光线建立的,这些光线在物理上是分开的。 我们的眼睛将它们的值混合在一起,创建了单个像素的颜色。 当字形仅覆盖像素的一部分时,仅打开叠加在字形上的光束,这会使水平分辨率提高三倍。 如果使用ClearType之类的技术放大屏幕图像,则可以看到字形边缘周围的颜色:


有趣的是,我将在本文中讨论的方法可以扩展到亚像素渲染。 我已经实现了它的原型。 唯一的缺点是,由于在ClearType之类的技术中添加了过滤功能,因此我们需要获取更多的纹理样本。 也许将来我会考虑。

使用统一网格进行字形渲染


假设我们以16倍于目标的分辨率采样了一个字形,并将其保存在纹理中。 我将在本文的第三部分中介绍如何完成此操作。 采样模式是均匀的网格,即16个采样点均匀分布在纹理像素上。 每个字形都以与目标分辨率相同的分辨率渲染,我们每个纹理像素存储16位,每个位对应一个样本。 正如我们将在计算覆盖率掩码的过程中看到的那样,样本的存储顺序很重要。 通常,一个纹理像素的采样点及其位置如下所示:


获取纹理


我们将通过存储在纹理像素中的coverage位来移动像素窗口。 我们需要回答以下问题:多少像素将进入像素窗口? 如下图所示:


在这里,我们看到四个纹理像素,在其上部分地叠加了一个字形。 一个像素(蓝色表示)覆盖了部分纹理像素。 我们需要确定像素窗口穿过多少个样本。 首先,我们需要以下内容:

  • 计算像素窗口相对于4像素的相对位置。
  • 获取与像素窗口相交的纹理像素。

我们的实现基于OpenGL,因此纹理空间的起点从左下角开始。 让我们从计算像素窗口的相对位置开始。 传递到像素着色器的UV坐标是像素中心的UV坐标。 假设UV已归一化,我们首先可以通过将UV乘以纹理大小将其转换为texel空间。 从像素中心减去0.5,我们得到像素窗口的左下角。 通过将此值四舍五入,我们可以计算出左下部纹理像素的左下部位置。 该图显示了texel空间中这三个点的示例:


像素的左下角和texel网格的左下角之间的差异是像素窗口在归一化坐标系中的相对位置。 在此图像中,像素窗口的位置将为[0.69,0.37]。 在代码中:

vec2 bottomLeftPixelPos = uv * size -0.5;
vec2 bottomLeftTexelPos = floor(bottomLeftPixelPos);
vec2 weigth = bottomLeftPixelPos - bottomLeftTexelPos;


使用textureGather指令,我们可以一次获得四个纹理像素。 它仅在OpenGL 4.0及更高版本中可用,因此您可以改为运行四个texelFetch。 如果我们仅传递TextureGather UV坐标,那么像素窗口与texel的完美匹配将出现问题:


在这里,我们看到三个水平纹理像素,其像素窗口(以蓝色显示)与中央纹理像素完全匹配。 计算出的权重接近1.0,但是textureGather选择了中心和右纹理像素。 原因是textureGather执行的计算可能与浮点权重的计算略有不同。 四舍五入的GPU计算和浮点权重计算的差异导致像素中心周围出现毛刺。

要解决此问题,您需要确保权重计算保证与TextureGather采样匹配。 为此,我们将永远不会对像素中心进行采样,而只会在2×2纹素网格的中心进行采样。 从计算出的并且已经四舍五入的左纹理元素底部位置,我们添加完整的纹理元素以到达纹理元素网格的中心。


该图显示,使用纹理像素网格的中心,textureGather采集的四个采样点将始终位于纹理像素的中心。 在代码中:

vec2 centerTexelPos = (bottomLeftTexelPos + vec2(1.0, 1.0)) / size;
uvec4 result = textureGather(fontSampler, centerTexelPos, 0);


像素视窗水平遮罩


我们得到了四个纹理像素,它们一起形成8×8覆盖位的网格。 要计算像素窗口中的位,我们首先需要重置像素窗口外部的位。 为此,我们将创建一个像素窗口蒙版,并在像素蒙版和texel coverage蒙版之间执行按位与运算。 水平和垂直掩膜分别执行。

水平像素蒙版应与水平权重一起移动,如以下动画所示:


该图显示了一个8位掩码,其值0x0F0向右移动(零插入到左侧)。 在动画中,遮罩是按权重线性动画的,但是实际上,位移是一步一步的操作。 当像素窗口越过样本的边界时,遮罩会更改值。 在下一个动画中,将以红色和绿色列显示该动画,并逐步进行动画处理。 仅当样本的中心相交时,该值才会更改:


为了使蒙版仅在单元的中心而不是在其边缘移动,简单的舍入就足够了:

unsigned int pixelMask = 0x0F0 >> int(round(weight.x * 4.0));

现在,我们有了一个跨越两个纹理像素的完整8位字符串的像素蒙版。 如果我们在16位覆盖掩码中选择正确的存储类型,则可以通过多种方式组合左右纹理元素,并一次对一条完整的8位行执行水平像素掩码。 但是,当我们移动到旋转的网格时,这对于垂直遮罩来说将成为问题。 因此,相反,我们将彼此组合在一起的两个左纹理元素和两个右纹理元素,以创建两个32位覆盖率掩码。 我们分别屏蔽左右结果。

左纹素的蒙版使用像素蒙版的高4位,右纹素的蒙版使用较低的4位。 在统一的网格中,每行都有相同的水平蒙版,因此我们可以为每行复制一个蒙版,然后准备好水平蒙版:

unsigned int leftRowMask = pixelMask >> 4;
unsigned int rightRowMask = pixelMask & 0xF;
unsigned int leftMask = (leftRowMask << 12) | (leftRowMask << 8) | (leftRowMask << 4) | leftRowMask;
unsigned int rightMask = (rightRowMask << 12) | (rightRowMask << 8) | (rightRowMask << 4) | rightRowMask;


要进行遮罩,我们组合了两个左纹理元素和两个右纹理元素,然后遮罩了水平线:

unsigned int left = ((topLeft & leftMask) << 16) | (bottomLeft & leftMask);
unsigned int right = ((topRight & rightMask) << 16) | (bottomRight & rightMask);


现在结果可能像这样:


我们已经可以使用bitCount指令对结果的位数进行计数。 我们不应该将其除以16,而应除以32,因为在垂直遮罩之后,我们仍然可以拥有32个潜在位,而不是16。这是此阶段字形的完整渲染:


在这里,我们看到了基于原始矢量数据(白色轮廓)和采样点可视化呈现的放大字母S。 如果该点是绿色,则该点位于字形内;如果是红色,则不在字形内。 灰度显示在此阶段计算的色相。 在渲染字体的过程中,存在许多错误的可能性,包括光栅化,将数据存储在纹理图集中的方法以及计算最终色相。 这样的可视化对于验证计算非常有用。 它们对于在亚像素级别调试伪像特别重要。

垂直遮罩


现在我们准备屏蔽垂直位。 要垂直遮罩,我们使用略有不同的方法。 要处理垂直移位,请务必记住我们如何保存位:按行顺序。 最底行是四个最低有效位,而顶行是四个最高有效位。 我们可以简单地一一清理,然后根据像素窗口的垂直位置对其进行移动。

我们将创建一个覆盖两个纹理像素整个高度的蒙版。 结果,我们要保存四条完整的 texel线并屏蔽所有其余的texel线,也就是说,屏蔽将为4×4位,等于0xFFFF。 根据像素窗口的位置,我们移动底线并清除顶线。

int shiftDown = int(round(weightY * 4.0)) * 4;
left = (left >> shiftDown) & 0xFFFF;
right = (right >> shiftDown) & 0xFFFF;


结果,我们还屏蔽了像素窗口外部的垂直位:


现在,我们足以计算纹理像素中剩余的位数,可以通过bitCount操作完成,然后将结果除以16,得到所需的阴影!

float shade = (bitCount(left) + bitCount(right)) / 16.0;

现在,这封信的完整呈现如下:


待续...


在第二部分中,我们将进行下一步,看看如何将这种技术应用于旋转的网格。 我们将计算此方案:


我们将看到几乎所有这些都可以简化为几个表。

感谢Sebastian Aaltonen( @SebAaltonen )为解决textureGather问题所提供的帮助,当然还要感谢 Michael van der Leu( @MvdleeuwGG )的想法和晚上有趣的对话。

Source: https://habr.com/ru/post/zh-CN433670/


All Articles