Shading Basics

"《Real-Time Rendering 4th》笔记——第五章 着色基础知识"

Posted by Ciel on July 7, 2019

Shading Basics

本章讨论着色基础,同时适用于真实化与风格化渲染。(本书9-14章主要讨论真实感渲染,15章则是风格化渲染)

渲染三维物体的图像时,模型不仅要有合适的几何形状,还要有理想的视觉表现。根据应用程序的不同,可以从真实感到出于创造性原因而选择的各种风格化表现。

\img\in-post\rtr5\5-1

原书图5.1,上图是使用虚幻引擎渲染的真实景观场景。下图来自Campo Santo的游戏《Firewatch》,该游戏采用了富有特点的艺术风格。

1着色模型

确定呈现对象外观的第一步是选择一个着色模型,来描述对象的颜色应该如何根据表面方向、视图方向和光照等因素而变化。

  • 例如,使用Gooch着色模型的一个变体。这是非真实感渲染的一种形式,是为了增加技术插图中细节的可读性。Gooch着色背后的基本思想是将表面法线与光的位置进行比较。如果法线指向光,则用较暖的色调给表面上色;如果它指向别处,则使用较冷的色调。两者之间的角度在这些色调之间进行插值,这些色调基于用户提供的表面颜色。在本例中,我们为模型添加了一个风格化的“高亮”效果,以使表面具有闪亮的外观。着色模型通常具有用于控制外观变化的属性。设置这些属性的值是确定对象外观的下一步。示例模型只有一个表面颜色属性,如图5.2的底部图像所示:

\img\in-post\rtr5\5-2

原书图5.2,一个风格化的着色模型结合Gooch着色和高光效果。上图显示了一个表面颜色为中性的复杂物体。下图显示了不同表面颜色的球体。

  • 像大多数着色模型一样,这个例子是由相对于视图和光照方向的表面方向来反映的。为便于着色,这些方向通常表示为标准化(单位长度)向量,如下所示:

\img\in-post\rtr5\5-3

原书图5.3,示例着色模型的单位长度向量输入:表面法线n、视图向量v和光方向l。

  • 确定了着色模型的所有输入,可以查看模型本身的数学定义:

\img\in-post\rtr5\e1

这个方程使用了以下中间计算:

\img\in-post\rtr5\e2

此定义中的一些数学表达式也经常出现在其他着色模型中。clamping操作(通常限制到0或限制在0和1之间)在着色中很常见。这里使用了$x^{\mp}$表示clamping操作,限制s在0-1。点积出现三次,每次出现在两个单位长度向量之间,两个向量的点积是它们的长度和夹角余弦的乘积。因此,两个单位长度向量的点积只是余弦,它比较两个向量彼此对齐程度的有用量度。在着色中,由余弦组成的简单函数通常是描述两个方向(例如光的方向和表面法线)之间关系最简单准确的数学表达式。

  • 另一个常用的着色操作是基于0到1之间的标量值在两种颜色之间进行线性插值。该操作的形式为$tc_a+(1-t)c_b$,当t的值分别在1和0之间移动时,在ca和cb之间插值。该式在着色模型中出现两次,第一次是在$c_{warm}$和$c_{cool}$之间进行插值,第二次是在之前的插值结果和$c_{highlight}$之间进行插值。线性插值在着色器中经常出现,它是一个内置函数,称为lerp或mix,在我们所见过的每种着色语言中都是如此。

  • $r=2(n\cdot l)n-l$计算出了反射光向量,它将l映射到n上。虽然不像前两个操作那么常见,但对于大多数着色语言来说很常用,可以使用内置的反射(reflect)函数。

  • 通过以不同方式将这些操作与各种数学表达式和着色参数组合,可以为各种风格化和逼真的表现定义着色模型。

2光源

光照为着色提供了一个主导方向。可以有多个光源,每个光源都有自己的大小、形状、颜色和强度;间接照明增加了更多的变化。我们将在第9章中看到,基于物理的、逼真的光照模型需要考虑所有这些参数。

相反,根据应用程序的需要和视觉风格,风格化的着色模型可以以许多不同的方式使用光照。一些高度程式化的模型可能根本没有灯光的概念,或者(像Gooch着色示例)可能只使用它来提供一些简单的方向性。

  • 光照复杂性的下一步是让着色模型以二分的方式对光的存在或不存在做出反应。用这种着色模型表面在受光和不受光时有不同表现。这意味着区分这两种情况与这些因素有关:与光源的距离、阴影(这将在第7章讨论)、表面是否朝向光源(即表面法线n与光方向l之间的夹角大于90),或这些因素的某些组合。

  • 这可以表示为一个简单的插值,可能是0到1的有限范围。或者作为一个无界限的量,以另一种方式着色。后者的一个常见选择是将阴影模型分解为光照和非光照部分,光照强度$k_{light}$线性缩放光照部分:

\img\in-post\rtr5\e3

  • 非光照部分funlit(n,v)对应于将光视为二分的着色模型的“不受光影响时的外观”。根据所需的视觉风格和应用程序的需要,它可以有多种形式。例如,funlit() = (0,0,0)将使任何受到光源照射的表面变成纯黑色。另外,非光照部分可以表示某种形式的风格化外观,类似于Gooch模型为背向光的表面使用的冷色。通常,着色模型的这一部分表示某种形式的照明,其不直接来自明确放置的光源,例如来自天空的光或来自周围物体的光。这些其他形式的照明将在第10章和第11章中讨论。

  • 我们之前提到过,光源不影响表面光的方向l大于表面法线n90度的点(实际来自表面下方)。这可以被认为是光相对于表面的方向及其对着色一般影响的一个特例。虽然是基于物理的,但这种关系可以从简单的几何原理推导出来,并且对于许多类型的非基于物理的、风格化的着色模型也是有用的。

\img\in-post\rtr5\5-4

原书图5.4,上图显示了光在表面上的横截面图。在左边,光线直接照射到表面上;在中间,它们以一个角度照射到表面上;在右边,我们看到使用向量点积来计算角度余弦。下图显示了横切面(包括光和视图向量)与整个表面的关系。

  • 光在表面上的反射可以被可视化为一组光线,光线的密度与照射到表面的光强相对应,用于表面着色。光线沿横截面照射到表面的距离与l和n夹角的余弦成反比。所以,入射到表面的光线的总密度与l和n夹角的余弦成正比,正如我们之前看到的,它等于这两个单位长度向量的点积。在这里可以看到,l与光实际入射方向相反,但这样方便计算因此做点积前取光照方向的反向量。
  • 更精确地说,当点积为正时,光线密度(以及光线对阴影的贡献)与点积成正比。负值对应于从表面后面射来的光线,这些光线没有相互作用。所以在乘以光的点积之前需要先把点积限制到0以上。使用$x^+$符号表示将负值保持为零:

\img\in-post\rtr5\e4

  • 支持多个光源的着色模型通常使用公式5.5的结构,或者基于物理模型的公式5.6。这对于风格化的模型也是有好处的,因为它有助于确保整体的一致性,特别是对那些背对光线或处于阴影的表面。然而,一些模型并不适合这种结构,这种模型将使用公式5.5中的结构。

  • 函数flit()最简单的选择可能是将其设置为常量颜色,$f_{lit}()=c_{surface}$,得到如下着色模型:

\img\in-post\rtr5\e5

  • 这个发光部分对应Lambert光照模型。该模型适用于理想的反射面,即表面完全哑光。这里对Lambert模型做了一个稍微简化的解释,将在第9章进行更严格的讨论。Lambertian模型本身可用于简单的着色,是许多着色模型的关键组成部分。

  • 从方程5.3-5.6可以看出,光源通过两个参数与着色模型相互作用:指向光的向量l和光源颜色$c_{light}$。 存在各种不同类型的光源,这主要取决于这两个参数在场景中如何变化。

  • 接下来将讨论几种常见的光源,它们有一个共同点:在给定的表面位置,每个光源只从一个方向l照射表面。换句话说,光源从表面位置看是一个极小的点。这对于真实世界的光来说并不完全正确,但是大多数光源相对于它们与被照明表面的距离来说都很小,使得这是一个合理的近似。在第7.1.2节和第10.1节中,我们将讨论从一系列方向照亮表面位置的光源,即区域灯光(area light)。

2.1方向光

  • 定向光(Directional light)是最简单的光源模型。l和$c_{light}$在场景中都是常量,只是$c_{light}$可能会被阴影减弱。定向光没有位置属性。当然,实际光源在空间中确实有特定的位置。定向光是抽象,它适用于当光源距离相对于场景大小较大时。比如远距离灯光,几乎所有被太阳照亮的场景。
  • 方向光的概念可以稍微扩展,允许改变$c_{light}$的值,而光的方向l保持不变。这通常是为了表现特殊场景。例如,可以定义一个区域有两个嵌套的盒子,$c_{light}$在外框是纯黑色(0,0,0),在内框等于一个常数,并在两个盒子之间的区域进行插值。

2.2精确光

精确光(punctual light)不是指准时赴约的灯,而是一种有位置的灯,不像方向灯。这些光也没有尺寸,没有形状或大小,不像真实的光源。我们使用“punctual”这个术语,来自拉丁语,意思是“点”,指的是由一个单一的局部位置产生的所有光源组成的类。我们用“point light”这个术语来表示一种特殊的发射器,它在所有方向上都发出同样的光。所以,点光源和聚光灯是两种不同形式的精确光。光方向向量l变化取决于当前着色表面点$p_0$相对于精确光位置:$l=\frac{p_{light}-p_0}{| p_{light}-p_0 |}\qquad$.

这个方程是向量归一化的一个例子:用一个向量除以它的长度,得到一个指向相同方向的单位长度向量。这是另一种常见的着色操作,与我们在前一节中看到的着色操作一样,它是大多数着色语言中的内置函数。然而,有时需要此操作的中间结果,这需要在多个步骤中使用更基本的操作显式地执行规范化。将其应用于精确光方向计算,得到如下结果:

\img\in-post\rtr5\e6

我们需要的中间值是r,即精确光源和当前着色点之间的距离。除了对光向量进行归一化外,还需要用r值作为距离计算光色$c_{light}$衰减(变暗)的函数。这将在下一节中进一步讨论。

点/泛光(Point/Omni Lights)

  • 在所有方向上均匀发光的精确光被称为点光或泛光。对于点光源,$c_{light}$的变化是距离r的函数,变化的唯一来源是上面提到的距离衰减。图5.5显示了为什么会出现这种变暗,使用类似的几何推理来演示图5.4中的余弦因子。在给定的表面上,点光源的光线间距与表面到光线的距离成正比。与图5.4中的余弦因子不同,这种间距的增加沿着曲面的两个维度进行,因此光线密度($c_{light}$)与平方距离倒数$1/r^2$成正比。这使我们能够使用单个光属性$c_{light0}$指定$c_{light}$中的空间变化,$c_{light0}$定义为固定参考距离$r_0$处的$c_{light}$值:

\img\in-post\rtr5\e7

  • 式5.11常称为反平方衰减。虽然从技术上讲,点光源的距离衰减是正确的,但是有一些问题使得这个方程对于实际的着色使用不是很理想。第一个问题发生在相对较小的距离。当r趋于0时,$c_{light}$的值将以无界的方式增加。当r达到0时,我们会得到一个被0整除的奇点。为了解决这个问题,一个常见的修改方法是在分母上加一个小值$\epsilon$,如式5.12。$\epsilon$的确切值取决于应用程序;例如,虚幻游戏引擎使用$\epsilon =1$ cm。CryEngine和Frostbite游戏中使用替代方法是把r限制在最小值$r_{min}$之间,如式5.13。与前一种方法中使用的任意值$\epsilon$不同,$r_{min}$的值有一个物理解释:发出光的物理对象的半径。r小于$r_{min}$的值对应于穿透物理光源内部表面,这是不可能的。

\img\in-post\rtr5\5-5

原书图5.5,点光源的光线间距与距离r成比例增加。由于间距增加发生在二维空间,光线密度(光强)成比例减少到$1/r^2$。

  • 相反,反平方衰减的第二个问题发生在相对距离较大时。问题不在于视觉效果,而在于性能。尽管光强随着距离的增加而减小,但它从不等于0。为了有效渲染,希望光在某些有限距离处达到0强度(第20章)。有许多不同的方法修改反平方方程来实现这一点。 理想情况下,修改应尽可能少。为了避免光在边界处出现明显截止,最好同样距离下修正函数的导数和值达到0。一种解决方案是将反平方方程乘以具有所需属性的窗口函数。虚幻引擎和寒霜引擎都使用这样的方法:

\img\in-post\rtr5\e8

+2表示在平方之前将值(如果是负数)固定为0。图5.6给出了一个反平方曲线的例子,即式5.14中的窗口函数,以及两者相乘的结果。

\img\in-post\rtr5\5-6

原书图5.6,该图显示了一个反平方曲线(使用$\epsilon$方法避免奇点,$\epsilon$值为1),如式5.14所示的窗口函数($r_{max}$设置为3)和窗口曲线。

  • 应用需求将影响所使用方法的选择。例如,当距离衰减函数以相对较低的空间频率采样时(例如在光映射或每个顶点中),$r_{max}$处的导数为0就显得尤为重要。CryEngine不使用光映射或顶点光照,因此它采用了更简单的调整,切换到0.8$r_{max}$和$r_{max}$之间的线性衰减。

  • 对于某些应用程序,匹配反平方曲线不是优先的,因此可以使用其他函数。这有效地将方程5.11-5.14推广到下面:$c_{light}(r)=c_{light0}f_{dist}(r)$,其中$f_{dist}(r)$是距离的函数。这样的函数称为距离衰减函数。在某些情况下,使用平方衰减函数是有性能约束的。例如,游戏《Just Cause 2》需要非常便宜的灯光计算。这就需要一个易于计算的衰减函数,同时也足够平滑,以避免每个顶点的光照伪像。

\img\in-post\rtr5\e9

  • 在其他情况下,衰减函数的选择可能是出于创造性的考虑。例如,虚幻引擎,对于现实和风格化的游戏,有两种模式的光衰减:一个反平方模式,如公式5.12所述,和指数衰减模式,可以调整以创建各种衰减曲线。游戏《古墓丽影》(2013)的开发者使用样条编辑工具来绘制衰减曲线,从而能够更好地控制曲线形状。

聚光灯(Spotlights)

  • 与点光源不同的是,几乎所有真实光源的照明都随方向和距离而变化。这种变化可以表示为一个方向衰减函数$f_{dir}(r)$与距离衰减函数相结合,以定义光强度的整体空间变化:$c_{light}= c_{light0}f_{dist}(r)f_{dir}(l)$.

  • $f_{dir}(l)$的不同选择可以产生不同的照明效果。其中一个重要的类型是聚光灯,它把光投射在一个圆锥体上。聚光方向衰减函数围绕聚光方向矢量s具有旋转对称性,可以表示为s到表面的反向光向量-l的夹角$\theta_s$函数。光向量需要反转,定义为一个指向光的向量。

  • 大多数聚光灯函数使用$\theta_s$的cos组成的表达式。聚光灯通常有一个阴影角(umbra angle)$\theta_u$,限制光对于所有的$\theta_s \ge \theta_u,f_{dir}(l)=0$。这个角度可以用与前面看到的最大衰减距离$r_{max}$类似的方式进行剔除。聚光灯通常还有一个半影角$\theta_p$(penumbra angle),它定义了一个内锥,在那里光强度满值。

\img\in-post\rtr5\5-7

原书图5.7,聚光灯:$\theta_s$是从光的定义方向s到-l即到表面方向的角度;$\theta_p$表示半影角;$\theta_u$表示本影角。

  • 各种方向衰减函数用于聚光灯,但它们往往大致相似。例如,函数$f_{dirF}(l)$用于Frostbite游戏引擎,函数$f_{dirT}(l)$用于three.js浏览器图形库:

\img\in-post\rtr5\e10

$x^{\mp}$是clamp操作,限制x在0-1之间。smoothstep函数是一种三次多项式,常用于平滑插值。它是大多数着色语言的内置函数。

\img\in-post\rtr5\5-8

原书图5-8,一些类型的灯。从左到右:定向,没有衰减的点光,平滑的聚光灯。请注意,由于光线与表面之间的角度变化,点光源照射边缘会暗一些。

其他精确光

精确光的$c_{light}$值还有许多其他的变化方式。

$f_{dir}(l)$函数不限于上面讨论的简单聚光灯衰减函数;它可以表示任何类型的方向变化,包括从真实光源测量的复杂模式。IES(The Illuminating Engineering Society)为这样的测量定义了标准文件格式,并在许多游戏或引擎中应用。

游戏《古墓丽影》(2013)有一种点状光,它在x轴、y轴和z轴上应用了独立的衰减函数。在《古墓丽影》中,曲线也可以用来随着时间的推移改变光的强度,例如,产生一个刺眼的火炬。

在6.9节中,我们将讨论如何通过使用纹理来改变光的强度和颜色。

2.3其他光源类型

方向光和精确光的主要特征是光的方向l的计算方法。不同类型的光可以通过使用其他方法来计算光的方向来定义。例如,除了前面提到的光类型,《古墓丽影》也有使用线段而不是点作为光源的胶囊灯。对于每个像素,以线段上最近点的方向作为光的方向l。

只要着色器有l和$c_{light}$用于计算着色方程,任何方法都可以用来计算这些值。

到目前为止讨论的光的类型都是抽象的。实际上,光源有大小和形状,它们从多个方向照亮表面的点。在渲染中,这样的光被称为区域光(area lights),它们在实时应用中的使用正在稳步增加。区域光渲染技术分为两类:一类是模拟区域光部分遮挡导致阴影边缘软化的技术(第7.1.2节),另一类是模拟区域光对表面着色影响的技术(第10.1节)。第二种类型的照明最明显的是光滑的镜面,在那里,光的形状和大小可以通过反射清晰地辨别出来。方向光和精确光不太可能被废弃,尽管它们不再像过去那样无处不在。计算光面积的近似方法已经开发出来,而且实现起来相对便宜,因此得到了更广泛的应用。GPU性能的提高也允许比过去更精确的技术。

3实现着色模型

本节将讨论设计和编写着色实现的一些关键考虑事项,还将介绍一个简单的实现示例。

3.1评估频率

  • 在设计着色实现时,需要根据计算的频率来划分计算。首先,确定给定计算的结果在整个draw call中是否总是常量。在这种情况下,计算可以由应用程序执行,通常在CPU上执行,不过可以使用GPU计算着色器进行特别昂贵的计算。计算结果通过统一(uniform)着色器输入传递给图形API。

  • 即使在这一类别中,也有很多可能的评估频率。 最简单的情况是着色方程中的常量子表达式,但这也适用于任何基于很少变化的因素的计算,比如硬件配置和安装选项。在编译着色器时,这种着色计算甚至不需要设置统一的着色器输入。或者可以在安装时或加载应用程序时以离线预计算方式执行计算。

  • 另一种情况是,着色计算的结果在应用程序运行过程中发生变化,但是变化非常缓慢,没有必要每帧都更新它。例如,在虚拟游戏世界中依赖于一天中时间的光照因素。如果计算很昂贵,那么将其分摊到多个帧可能是值得的。

  • 其他情况包括每帧执行一次计算,如连接视图和透视图矩阵;或每个模型更新一次,如根据位置更新模型光照参数;或者每次绘制调用一次,例如,更新模型中每个材料的参数。按评估频率对统一着色器输入进行分组对于应用程序效率很有用,并且还可以通过最小化不断更新来帮助提高GPU性能。

  • 如果着色计算的结果在绘制调用中发生更改,则不能通过统一的着色器输入将其传递给着色器。相反,它必须由第3章中描述的可编程着色器阶段之一计算,如果需要,通过不同的着色器输入传递到其他阶段。理论上,着色计算可以在任何可编程阶段执行,每个阶段对应一个不同的评估频率:

    • 顶点着色器(Vertex shader) - 评估每个细分前顶点

    • 外壳着色器(Hull shader)——评估每个表面补丁

    • 域着色器(Domain shader)——评估每个细分后顶点

    • 几何着色器(Geometry shader)——评估每个片元

    • 像素着色器(Pixel shader)——评估每个像素

  • 实际上,大多数着色计算是按像素执行的。虽然这些通常在像素着色器中实现,但是计算着色器的实现越来越普遍;第20章将讨论几个例子。其他阶段主要用于几何运算,如变换和变形。为了理解为什么会这样,我们将比较每个顶点和每个像素着色计算的结果。在较早的文本中,它们有时分别被称为Gouraud shading和Phong shading,但这些术语在今天并不常用。这个对比使用的着色模型有点类似于公式5.1中的着色模型,但修改后可以使用多个光源。

  • 图5.9显示了不同顶点密度模型的逐像素和逐顶点着色结果。对于龙来说,一个非常密集的网格,两者之间的差异很小。但是在茶壶上,顶点着色的计算会导致可见的错误,比如角度形状的高光,而在两个三角形的平面上,顶点着色的版本显然是不正确的。这些错误的原因是着色方程的某些部分,特别是高亮部分,其值在网格表面上是非线性变化的。这使得它们不适合顶点着色器,顶点着色器的结果在输入到像素着色器之前会在三角形上进行线性插值。

\img\in-post\rtr5\5-9

原书图5.9,为示例着色模型的逐像素和逐顶点计算的比较

式5.19,三种不同的顶点密度模型上的表现。左列显示逐像素计算结果,中列显示逐顶点计算结果,右列显示每个模型的线框渲染图,以显示顶点密度。

  • 原则上,可以在像素着色器中只计算着色模型的高光部分,然后在顶点着色器中计算其余部分。这可能不会产生可视化的伪影,而且理论上可以节省一些计算。实际上,这种混合实现通常不是最优的。着色模型的线性变化部分往往是计算成本最低的,以这种方式分割阴影计算往往会增加开销,比如重复计算和额外的变化输入,从而超过节省部分。

  • 如前所述,在大多数实现中,顶点着色器负责非着色操作,如几何变换和变形。最终得到的几何表面属性,转换成适当的坐标系,由顶点着色器编写出来,在三角形上进行线性插值,并作为不同的着色器输入传递到像素着色器。这些属性通常包括曲面的位置、曲面法向量,如果需要进行法向映射,还可以选择曲面切向量。

  • 注意,即使顶点着色器总是生成单位长度的曲面法线,插值也可以改变它们的长度。参见图5.10的左侧。因此,法线需要在像素着色器中重新规范化(缩放到长度1)。然而,顶点着色器生成法线的长度仍然很重要。如果法线长度在顶点之间有明显的变化,例如作为顶点混合的一个边,这将使插值偏移,如图5.10右侧。由于这两个影响,常常在插值前插值后进行规范化,即在顶点着色器和像素着色器中。

\img\in-post\rtr5\5-10

原书图5.10,在左边,我们看到单位法线在曲面上的线性插值得到长度小于1的插值向量。在右边,我们看到具有显著不同长度的法线的线性插值导致内插方向偏向两个法线中较长的那条。

  • 与表面法线不同,指向特定位置的向量,如视图向量和用于精确光的向量,通常不进行插值。相反,插值表面位置用于在像素着色器中计算这些向量。除了我们已经看到的归一化(在任何情况下都需要在像素着色器中执行)之外,这些向量都是用向量减法计算的,这非常快。如果由于某种原因需要插值这些向量,请不要事先将它们标准化。 这将产生不正确的结果,如图5.11所示。

\img\in-post\rtr5\5-11

原书图5.11,两个光矢量之间的插值。在左边,插值前对它们进行归一化会导致插值后的方向不正确。在右边,插值非标准化向量会得到正确的结果。

  • 前面我们提到过顶点着色器将曲面几何转换为适当的坐标系。相机和光的位置通过一致的变量传递给像素着色器,通常由应用程序转换为相同的坐标系。这将最小化像素着色器所做的工作,从而将所有的着色模型向量带入相同的坐标空间。但是哪个坐标系才是合适的呢?可能性包括全局世界空间以及摄像机的本地坐标系,或者更少见的是当前呈现模型的坐标系。通常根据系统的考虑(如性能、可实现性和简单性)为呈现系统整体做出选择。例如,如果渲染的场景需要包含大量的灯光,那么可以选择世界空间来避免改变灯光的位置。另外,相机空间可能是首选,以更好地优化与视图向量相关的像素着色器操作,并可能提高精度(第16.6节)。

  • 尽管大多数着色器实现,包括我们将要讨论的示例实现,都遵循上面描述的一般大纲,但当然也有例外。例如,一些应用程序出于风格上的原因选择了每个图元着色的平面外观。这种样式通常称为flat shading。图5.12显示了两个示例。

\img\in-post\rtr5\5-12

原书图5.12,两款flat shading风格游戏《Kentucky Route Zero》和《That Dragon ,Cancer》。

  • 原则上,平面着色可以在几何着色器中执行,但最近的实现通常使用顶点着色器。这是通过将每个图元的属性与其第一个顶点关联起来并禁用顶点值插值来实现的。禁用插值(可以对每个顶点值单独执行)将使第一个顶点的值传递给图元中的所有像素。

3.2应用实例

现在我们将展示一个示例着色模型实现。如前所述,我们正在实现的着色模型类似于公式5.1中的扩展Gooch模型,但是经过了修改,可以处理多个光源:

\img\in-post\rtr5\e11

进行以下中间计算:

\img\in-post\rtr5\e12

这个公式适用于式5.6中的多光结构:

\img\in-post\rtr5\e13

本例中的亮项和非亮项如下,通过调整冷色的非光照贡献,使结果看起来更像原始方程:

\img\in-post\rtr5\e14

在大多数典型的渲染应用程序中,不同材质属性的值(如$c_{surface}$)将存储在顶点数据中,或者更常见是存储在纹理中(第6章)。然而,为了保持这个示例实现的简单性,我们将假设$c_{surface}$在整个模型中是常量。这个实现将使用着色器的动态分支功能在所有光源上进行循环。虽然这种直接的方法可以很好地用于相当简单的场景,但它不能很好地扩展到具有许多光源的大型和几何复杂的场景。为了简单起见,这里只支持一种光源:点光源。着色模型不是单独实现的,而是在更大的渲染框架中实现。这个例子是从“Phone-shaded Cube”WebGL2示例修改而来的,但同样的原则也适用于更复杂的框架。我们将讨论一些来自应用程序的GLSL着色器代码和JavaScript WebGL调用的示例。目的是为了展现一般的实现原则。我们将按照“由内到外”的顺序进行实现,首先是像素着色器,然后是顶点着色器,最后是应用程序端图形API调用。

着色器代码包含着色器输入和输出定义。正如前面3.3节所讨论的,使用GLSL着色器输入可以分为两类。一个是一组统一的输入,这些输入的值由应用程序设置,并且在draw call期间保持不变。第二种类型由不同的输入组成,这些输入的值可以在着色器调用(像素或顶点)之间更改。像素着色器的不同输入输出在GLSL中标记如下:

in vec3 vPos;
in vec3 vNormal;
out vec4 outColor;

这个像素着色器只有一个最终颜色输出。像素着色器输入与顶点着色器输出相匹配,顶点着色器输出在输入像素着色器之前在三角形上进行插值。这个像素着色器有两个不同的输入:表面位置和表面法线,都在应用程序的世界空间坐标系中。uniform输入数量很多,所以为了简洁起见,这里只给出两个与光源有关的定义:

struct Light {
    vec4 position;

    vec4 color;

};
uniform LightUBlock {
    Light uLights [MAXLIGHTS];

};
uniform uint uLightCount;

因为这些都是点光源,所以每个点的定义都包含一个位置和一个颜色。这些被定义为vec4而不是vec3,以符合GLSL std140数据标准。虽然因此可能会导致一点空间浪费,但它简化了确保CPU和GPU之间数据布局一致的任务,这也是本例中使用它的原因。Light结构数组定义在一个统一结构块中,这是一个GLSL特性,用于将一组统一变量绑定到一个缓冲区对象,以便更快地传输数据。数组长度定义为应用程序在单个draw call调用允许的最大灯光数。稍后我们将看到,应用程序在着色器编译之前用正确的值(本例中为10)替换着色器源中的MAXLIGHTS字符串。uniform整数uLightCount是绘制调用中激活灯光的实际数量。

接下来是像素着色器代码:

vec3 lit( vec3 l, vec3 n, vec3 v) {
    vec3 r_l = reflect (-l, n);
    float s = clamp (100.0 * dot(r_l , v) - 97.0 , 0.0 , 1.0) ;
    vec3 highlightColor = vec3 (2 ,2 ,2);
    return mix( uWarmColor , highlightColor , s);
}
void main () {
    vec3 n = normalize ( vNormal );
    vec3 v = normalize ( uEyePosition .xyz - vPos );
    outColor = vec4 ( uFUnlit , 1.0) ;
    for ( uint i = 0u; i < uLightCount ; i++) {
        vec3 l = normalize ( uLights [i]. position .xyz - vPos );
        float NdL = clamp (dot(n, l), 0.0 , 1.0) ;
        outColor .rgb += NdL * uLights [i]. color .rgb * lit(l,n,v);
    }
}

我们为lit项定义了一个函数,它由main()函数调用。总的来说,这是方程5.20和5.21的一个简单的GLSL实现。注意,$f_{unlit}()$和$c_{warm}$的值作为统一变量传递。由于这些值在整个绘制调用期间都是常量,因此应用程序可以计算这些值,从而节省一些GPU周期。

这个像素着色器使用了几个内置GLSL函数。reflect()的作用是在第二个向量定义的平面上反射第一个向量,在本例中是反向光入射向量和曲面法向量。由于我们希望光向量和反射向量都指向原离表面的地方,所以在将其传递到reflect()前需要先对光向量进行反向。clamp()函数有三个输入,其中后两个参数定义了第一个输入被限制的范围。在大多数GPU上,限制在0-1之间的特殊情况(对应HLSL中的saturate()函数)是快速的,通常都无额外消耗。这就是在这里使用它的原因,尽管我们知道它不会超过1,只需要将值固定到0。mix()函数也有三个输入,并根据第三个值(一个介于0和1之间的混合参数)在其中的暖色和高光色之间进行线性插值。在HLSL中,这个函数称为lerp()。最后,normalize()将一个向量除以其长度,将其长度缩放为1。

现在让我们看看顶点着色器。由于我们已经看到了像素着色器的一些例子,所以我们不会展示它的任何一个uniform设计,但是不同的输入和输出设计是值得研究的:

layout ( location =0) in vec4 position ;
layout ( location =1) in vec4 normal ;
out vec3 vPos ;
out vec3 vNormal ;

注意,如前所述,顶点着色器输出匹配像素着色器的不同输入。输入包括指定数据如何在顶点数组中布局的指令。接下来是顶点着色器代码:

void main () {
    vec4 worldPosition = uModel * position ;
    vPos = worldPosition . xyz ;
    vNormal = ( uModel * normal ). xyz ;
    gl_Position = viewProj * worldPosition ;
}

这些是顶点着色器的常见操作。着色器将表面位置和法线转换为世界空间,并将它们传递给像素着色器用于着色。最后将表面位置转换为裁剪空间并传递到gl_Position, gl_Position是光栅化器使用的一个特殊的系统定义变量。gl_Position变量是任何顶点着色器所需的输出。

注意,法向量在顶点着色器中没有标准化。它们不需要标准化,因为在原始网格数据中它们的长度为1,并且这个应用程序不执行任何操作,例如顶点混合或不均匀缩放,这些操作可能不均匀地改变它们的长度。模型矩阵可以有一个统一的尺度因子,但这会按比例改变所有法线的长度,从而不会导致图5.10右侧所示的问题。

应用程序使用WebGL API进行各种渲染和着色器设置。每个可编程着色器阶段都是单独设置的,然后它们都绑定到一个程序对象。这是像素着色器设置代码:

var fSource = document . getElementById (" fragment "). text . trim ();
var maxLights = 10;
fSource = fSource . replace (/ MAXLIGHTS /g, maxLights . toString ());
var fragmentShader = gl. createShader (gl. FRAGMENT_SHADER );
gl. shaderSource ( fragmentShader , fSource );
gl. compileShader ( fragmentShader );

注意“片段着色器(fragment Shader)”引用。这个术语由WebGL(以及它所基于的OpenGL)使用。正如本书前面提到的,虽然“像素着色器(pixel shader)”在某些方面不够精确,但它是更常见的用法,我们将在本书中遵循。在这段代码中,MAXLIGHTS字符串也被替换为适当的数值。大多数渲染框架执行类似的预编译着色器操作。

还有更多的应用程序端代码用于设置统一量、初始化顶点数组、清除和绘图。这里的目标是让你了解如何将着色器作为单独的处理器来处理,并使用它们自己的编程环境。

3.3材质系统

渲染框架很少只实现一个着色器,通常需要一个专门的系统来处理应用程序使用的各种材质、着色模型和着色器。

正如在前面的章节中所解释的,着色器是为GPU的可编程着色器阶段之一编写的程序。因此,它是一个低级的图形API资源,而不是艺术家可以直接与之交互的东西。相比之下,材质(material)是表面视觉外观的一种面向艺术家的封装。材质有时也描述非视觉方面,如碰撞属性,这超出了本书的范围因此后面不做讨论。

  • 虽然材质是通过着色器实现的,但这不是简单的一对一对应。在不同的渲染情况下,相同的材质可以使用不同的着色器。着色器也可以由多个材质共享。最常见的情况是参数化材质。在其最简单的形式中,材质参数化需要两种类型的材质实体:材质模板(material templates)和材质实例(material instances)。每个材质模板描述了一类材质,并具有一组参数,可以根据参数类型指定数值、颜色或纹理值。每个材质实例对应于一个材质模板及其所有参数的特定值集。一些渲染框架(如虚幻引擎)允许更复杂的层次结构,其中的材质模板是从其他模板派生而来的。

  • 参数可以在运行时通过向着色器程序传递统一的输入来解析,或者在编译时通过在编译着色器之前替换值来解析。编译时参数的一种常见类型是布尔开关,它控制给定材质特性的激活。这可以由艺术家通过材质用户界面中的复选框来设置,也可以由材质系统进行程序设置,例如为那些视觉效果可以忽略的远距离对象关掉一些特性降低着色器成本。

  • 虽然材质参数可能与着色模型的参数一一对应,但并不总是这样。材质可以将给定的着色模型参数(如表面颜色)的值固定为常量。另一种情况是,着色模型参数可以通过一系列复杂的操作来计算,这些操作将多个材质参数以及插值顶点或纹理值作为输入。在某些情况下,诸如表面位置、表面方向甚至时间等参数也可能成为计算的因素。基于表面位置和方向的着色在地形材料中尤其常见。例如,高度和表面法线可以用来控制雪的外观,混合在一个白色的表面颜色在高海拔水平和几乎水平的表面。基于时间的着色在动画材料中很常见,比如闪烁的霓虹灯。

  • 材质系统最重要的任务之一是将各种着色器功能划分为单独的元素,并控制这些元素如何组合。在许多情况下,这种类型的组合是有用的,包括以下内容:

    • 用几何处理组合表面着色,如刚性变换、顶点混合、变形、细分、实例化和裁剪。这些功能独立地变化:表面着色取决于材质,几何处理取决于网格。因此,可以方便地单独编写,并根据需要由材质系统组成。

    • 使用合成操作(如像素丢弃和混合)组合表面着色。这与移动gpu尤其相关,其中混合通常在像素着色器中执行。 通常希望独立于用于表面着色的材质来选择这些操作。

    • 通过着色模型本身计算组合用于计算着色模型参数的操作。 这允许一次创建着色模型实现并将其与各种不同方法结合使用以计算着色模型参数。

    • 相互组合各自可选择的材质特性、选择逻辑和着色器的其余部分。这允许分别编写每个特性的实现。

    • 用光源评估组合着色模型及其参数计算:计算每个光源在着色点处的$c_{light}$和l值。延迟渲染(在第20章中讨论)等技术改变了这个组合的结构。在渲染支持多种此类技术的框架时,这又增加了一层复杂性。

  • 如果图形API提供这种类型的着色器代码模块化作为核心特性,将会非常方便。遗憾的是,与CPU代码不同,GPU着色器不允许代码片段的编译后链接。每个着色器阶段的程序都被编译为一个单元。着色器阶段之间的分离确实提供了一些有限的模块性,这在某种程度上限制了我们列表中的第一项:用几何处理(通常在其他着色器阶段执行)组合表面着色(通常在像素着色器中执行)。但是这种匹配并不完美,因为每个着色器都执行其他操作,而且其他类型的合成仍然需要处理。考虑到这些限制,材质系统实现所有这些类型的组合的唯一方法是在源代码级别。这主要涉及字符串操作,如连接和替换,通常通过c风格的预处理指令执行,如#include、#if和#define。

  • 早期的渲染系统有相对较少的着色器变体,而且通常每个变体都是手工编写的。这有一些好处。例如每个变体都可以在完全了解最终着色器程序的情况下进行优化。然而,随着变体数量的增加,这种方法很快变得不切实际。当考虑到所有不同的部分和选项时,可能的不同着色器变体的数量是巨大的。这就是模块化和可组合性如此重要的原因。

  • 设计用于处理着色器变体的系统时要解决的第一个问题是,是否在运行时通过动态分支执行不同选项之间的选择,还是在编译时通过条件预处理进行选择。在较老的硬件上,动态分支通常是不可能的,或者非常慢,所以不能选择运行时。然后在编译时处理所有变量,包括所有不同光类型计数的可能组合。

  • 相比之下,当前gpu处理动态分支非常好,特别是当分支对draw call中的所有像素都表现相同时。如今,许多功能变化,比如灯光的数量,都是在运行时处理的。然而,向着色器添加大量的功能变化会导致不同的成本:寄存器数量的增加和相应的占用率的降低,从而降低性能。有关详细信息,请参见第18.4.5节。因此,编译时的变化仍然是有价值的。它避免包含永远不会执行的复杂逻辑。

  • 例如,让我们假设一个应用程序支持三种不同类型的灯。两种灯类型很简单:点光和方向光。 第三种类型是广义聚光灯,支持列表照明模式和其他复杂功能,需要大量的着色器代码才能实现。但是,假设一般聚光灯使用得相对较少,应用程序中只有不到5%的灯是这种类型。在过去,会为这三种光类型的每种可能的计数组合编译一个单独的着色器变体,以避免动态分支。虽然现在不需要这样做,但编译两个单独的变体仍然是有益的,一个用于此类灯数等于或大于1的情况,另一个用于此类灯数恰好为0的情况。由于其代码更简单,第二种变体(最常用)可能具有更低的寄存器占用率,从而具有更高的性能。

  • 现代材质系统同时使用运行时和编译时着色器。即使全部的负担不再只在编译时处理,总体的复杂性和变化的数量仍在增加,所以仍然需要编译大量着色器的变体。只有实际使用的变体才会被编译,但是着色器编译系统必须重新设计以处理大量可能的变体。

  • 材质系统设计师采用不同的策略来实现这些设计目标。虽然这些策略有时被表示为相互排斥的系统架构,但是这些策略可以并且通常被组合在同一个系统中。这些策略包括以下内容:

    • 代码重用——在共享文件中实现函数,使用#include预处理器指令从任何需要它们的着色器访问这些函数。

    • 减法——一种着色器,通常称为ubershader或supershader,它聚合了大量功能,使用编译时预处理器条件和动态分支的组合来删除未使用的部分并在互斥的备选之间切换。

    • 加法——各种功能被定义为带有输入和输出连接器的节点,这些节点被组合在一起。这类似于代码重用策略,但是更加结构化。节点的组合可以通过文本或可视化图形编辑器来完成。后者的目的是使非工程师,如技术美术,更容易编写新的材质模板。通常,只有部分着色器可用于可视化图形创作。例如,在虚幻引擎中,图形编辑器只能完成着色模型输入的计算。见图5.13。

    • 基于模板——定义了一个接口,只要它们符合该接口,就可以插入不同的实现。这比加法策略更正式一些,通常用于更大的功能块。这种接口的一个常见例子是分离着色模型参数的计算和着色模型本身的计算。虚幻引擎有不同的“材质域”,包括用于计算着色模型参数的表面域和用于计算为给定光源$c_{light}$标量值的光函数域。在Unity中也存在类似的”表面着色器”结构。注意,延迟着色技术(在第20章中讨论)强制执行类似的结构,使用G-buffer作为接口。

\img\in-post\rtr5\5-13

原书图5.13,虚幻引擎材质编辑器。注意节点图右侧的高节点。这个节点的输入连接器对应于渲染引擎使用的各种着色输入,包括所有的着色模型参数。

  • 对于更具体的例子,WebGL Insights(现在是免费的)一书中有几章讨论了各种引擎如何控制它们的着色器管线。除了组合,现代材质系统还有其他几个重要的设计考虑,比如需要支持多个平台,同时最小化着色器代码的重复。这包括考虑平台、着色语言和api之间的性能和功能差异的功能变化。Destiny着色器系统是这类问题的典型解决方案。它使用一个专有的预处理器层,该层使用自定义着色语言编写的着色器。这允许编写独立于平台的材质,并自动转换为不同的着色语言和实现。虚幻引擎和unity有类似系统。

  • 材质系统也需要保证良好的性能。除了专门编译着色变体外,材质系统还可以执行其他一些常见的优化。Destiny着色器系统和虚幻引擎自动检测绘制调用(如前面实现示例中的暖色和冷色计算)中的常量计算,并将其移出着色器。另一个例子是Destiny中使用的作用域系统,用于区分以不同频率更新的常量(例如,每帧一次、每盏灯一次、每一个对象一次),并在适当的时间更新每一组常量,以减少API开销。

  • 正如我们所看到的,实现着色方程的问题在于决定哪些部分可以简化,计算各种表达式的频率,以及用户如何修改和控制外观。渲染管线的最终输出是颜色和混合值。 抗锯齿,透明度和图像显示的其余部分详细说明了这些值如何组合和修改以便显示。

4锯齿和抗锯齿

想象一个大的黑色三角形在白色背景上缓慢移动。由于屏幕网格单元被三角形覆盖,表示该单元的像素值应该平滑地降低强度。在各种基本渲染器中通常发生的情况是,当网格单元格的中心被覆盖时,像素颜色立即从白色变为黑色。标准GPU渲染也不例外。参见图5.14中最左边的一列。

三角形以像素表示,要么在这里,要么不在那里。画的线也有类似的问题。因为边缘有锯齿状的外观,所以这个视觉现象被称为“锯齿状(jaggies)”,当动画化时就变成了“小爬虫(crawlies)”。更正式地说,这个问题称为走样(aliasing),而避免它的努力称为反走样(antialiasing)技术。

采样理论和数字过滤的主题范围很大。由于这是渲染的一个关键领域,我们将介绍采样和滤波的基本理论。然后我们将关注当前可以实时做什么来减轻锯齿。

\img\in-post\rtr5\5-14

原书图5.14,上面一行显示了三个图像,它们对三角形、直线和一些点具有不同程度的抗锯齿。下一行的图像是上一行的放大图。最左边的列每个像素只使用一个样本,这意味着没有使用抗锯齿。中间的列图像用每个像素4个样本(在网格模式中)渲染,而右边的列使用每个像素8个样本(在一个4X4棋盘格中,采样的正方形的一半)。

4.1采样滤波理论

渲染图像的过程本质上是一个采样任务。这是因为图像的生成是对三维场景进行采样的过程,以便获得图像中每个像素(离散像素阵列)的颜色值。要使用纹理映射(第6章),必须对纹理进行重采样,以便在不同的条件下获得良好的结果。为了在动画中生成一个图像序列,动画通常以均匀的时间间隔采样。本节介绍采样、重构和过滤的主题。为了简单起见,大多数材质将以一维的形式呈现。这些概念自然的扩展到二维,因此可以用于处理二维图像。

图5.15展示了连续信号如何以均匀间隔采样,即离散化。这个采样过程的目标是用数字表示信息。这样做会减少信息量。然而,需要对采样信号进行重构才能恢复原始信号。这是通过过滤采样信号来实现的。

\img\in-post\rtr5\5-15

原书图5.15,对连续信号(左)进行采样(中),然后通过重构(右)恢复原始信号。

走样的一个经典例子是老西部片中拍摄的旋转车轮。由于运动速度比相机记录的图像快得多,轮子可能看起来旋转得很慢(向后或向前),甚至可能看起来根本就不旋转。这可以在图5.16中看到。产生这种效果的原因是车轮的图像是在一系列的时间步骤中拍摄的,称为时间走样。

\img\in-post\rtr5\5-16

原书图5.16,最上面一行显示了车轮的原始信息。第二行由于采样不充分使它看起来向相反方向移动。这是一个由于采样率过低而产生走样的例子。第三行采样率恰好是半圈,不能确定轮子向哪个方向旋转。这是Nyquist限制。第四行,每转一圈的采样率比其他两个采样率高,可以发现轮子转的方向是对的。

计算机图形中走样的常见例子是光栅化线条或三角形边缘的“锯齿(jaggies)”,闪烁的高光被称为“fireflies”,以及带有检查图案的纹理被缩小(第6.2.2节)。

当以过低的频率采样信号时,会发生走样。所采样的信号显示为比原始信号频率低的信号,如图5.17所示。要正确地采样信号(即有可能从样本中重建原始信号),采样频率必须大于被采样信号最大频率的两倍。这通常被称为采样定理,采样频率被称为Nyquist rate或Nyquist limit,这是由瑞典科学家哈利·奈奎斯特在1928年发现的。Nyquist limit如图5.16所示。该定理使用“最大频率”这一术语的事实意味着信号必须是带限的(band-limited),这意味着没有任何频率高于某个极限。换句话说,相对于相邻样本之间的距离,信号必须足够平滑。

\img\in-post\rtr5\5-17

原书图5.17,蓝色实线为原始信号,红色圆圈表示均匀间隔的采样点,绿色虚线为重构信号。上面的图显示采样率太低,因此重构信号的频率较低。底部显示的采样率恰好是原始信号频率的两倍,重建的信号在这里是一条水平线。可以证明,如果采样率稍微提高一点,完全重构是可能的。

在使用点样本渲染三维场景时,通常不会有带限。三角形、阴影边界和其他现象的边缘会产生不连续变化的信号,从而产生无限的频率。此外,无论样本的排列有多紧密,物体仍然可以小到根本不会被采样。因此,在使用点采样渲染场景时,不可能完全避免走样问题,而且我们几乎总是使用点采样。然而,有时我们可以知道什么时候信号是带限的。一个例子是将纹理应用到一个表面。可以将纹理样本的频率与像素的采样率进行比较计算。如果这个频率低于奈奎斯特极限,那么就不需要特殊的操作来正确采样纹理。如果频率过高,则使用多种算法对纹理进行带限(第6.2.2节)。

重构(Reconstruction)

给定一个带限采样信号,我们现在将讨论如何从采样信号重建原始信号。为此,必须使用过滤器。图5.18显示了三个常用的过滤器。注意,滤波器的面积应该始终为1,否则重构的信号可能会出现增长或收缩。

\img\in-post\rtr5\5-18

原书图5.18,左上角显示box过滤器,右上角显示tent过滤器。底部显示了sinc过滤器(它已经被限制在这里的x轴上)。

在图5.19中,使用box filter(最邻近点法)重构采样信号。这是最坏的使用,因为产生的信号是一个不连续的阶梯。尽管如此,由于其简单性,它仍然经常用于计算机图形学中。如图所示,将盒型过滤器放在每个采样点上,然后进行缩放,使过滤器的顶点与采样点重合。所有这些经过缩放和平移的方框函数的和就是右边显示的重构信号。

\img\in-post\rtr5\5-19

原书图5.19,采样后的信号(左)用盒形滤波器进行重构。这是通过在每个采样点上放置盒型滤波器,并在y方向缩放它,使过滤器的高度与采样点相同。最后的和就是重构信号(右)。

box过滤器可以用其他过滤器替换。在图5.20中,帐篷滤波器,也称为三角形滤波器,用于重构采样信号。注意,该滤波器实现了相邻采样点之间的线性插值,由于重建信号现在是连续的,所以它比盒型过滤器效果好。

\img\in-post\rtr5\5-20

原书图5.20,采样后的信号(左)用帐篷过滤器进行重构。重建信号显示在右边。

然而,帐篷滤波器重建信号的平稳性较差,在采样点处存在突变。这与帐篷滤波器不是完美的重建有关。为了得到完美的重构,必须使用理想的低通滤波器。信号的频率分量是正弦波:$sin(2\pi f)$,其中f是该分量的频率。在此情况下,低通滤波器去除所有频率高于滤波器定义的特定频率的频率分量。直观地说,低通滤波器消除了信号的尖锐特征。理想的低通滤波器是sinc滤波器(图5.18底部):$sinc(x)= \frac{sin(\pi x)}{\pi x}$。

傅里叶分析理论解释了为什么正弦滤波器是理想的低通滤波器。简单来说,理由如下。理想的低通滤波器是在频域内的一个盒型滤波器,当它与信号相乘时,会去除滤波器宽度以上的所有频率。将盒型滤波器从频域变换到空间域得到一个sinc函数。同时,乘法运算被转换成卷积函数(convolution function)。

使用sinc滤波器对信号进行重构,得到更加平滑的结果,如图5.21所示。采样过程中引入了信号中的高频分量(突变),而低通滤波器的任务就是去除这些高频分量。事实上,sinc滤波器消除了频率大于1/2采样率的所有正弦波。当采样频率为1.0时(即,采样信号的频率必须小于1/2)sinc滤波器是最理想的重构滤波器。更一般地,假设采样频率为fs,即相邻样本之间的间隔为1/fs。对于这种情况,最理想的重构滤波器是$sinc(f_sx)$,它消除了所有高于$f_s/2$的频率。这在重新采样信号时非常有用(下一节)。 然而,sinc的滤波器宽度是有限的,并且在某些区域是负的,因此在实践中很少有用。

\img\in-post\rtr5\5-21

原书图5.21,在这里sinc滤波器被用来重建信号。该滤波器是一种理想的低通滤波器。

在低质量的盒型/帐篷滤波器和不实用的sinc滤波器之间,有一个有用的中间地带。最广泛使用的滤波器位于两个极端之间。所有这些滤波函数都与sinc函数有一定的近似关系,但它们所包含的像素个数是有限制的。最接近sinc函数的滤波器在其部分域上有负值。对于不需要或不实用负滤波器值的应用程序,通常使用没有负叶瓣的滤波器(通常称为高斯滤波器,因为它们要么派生自高斯曲线,要么类似于高斯曲线)。第12.1节更详细地讨论了滤波器函数及其使用。

使用任意滤波器后,得到连续信号。然而,在计算机图形学中,我们不能直接显示连续信号,但我们可以使用它们来重新采样连续信号到另一个大小,即要么放大信号,要么减弱信号。

重采样(Resampling)

重采样是用来放大或缩小采样信号。假设原始样本点位于整数坐标(即样本之间是单位间隔)。此外,假设重新采样后,我们希望新采样点的位置一致,采样点之间的间隔为a。对于a>1,缩小(下采样);对于a<1,放大(上采样)。

放大是这两种情况中比较简单的,所以让我们从这里开始。假设采样信号如前一节所示被重构。直观地说,由于现在的信号是完全重构的,并且是连续的,所以所需要做的就是按期望的间隔重新采样重构信号。这个过程可以在图5.22中看到。

\img\in-post\rtr5\5-22

原书图5.22,左边是采样信号和重建信号。右边是重建后的信号以采样率的两倍进行了二次采样即放大。

然而,缩小不能用这种方法。原始信号的频率太高,采样率难以避免走样。相反,应该使用一个使用sinc(x/a)的滤波器从采样信号中创建一个连续信号。在此之后,可以按所需的间隔重新采样。这可以在图5.23中看到。换句话说,在这里使用sinc(x/a)作为滤波器,可以增加低通滤波器的宽度,从而去除更多的信号的高频率内容。如图所示,将单个sinc的滤波器宽度增加一倍,将重采样率降低到原始采样率的一半。将其与数字图像相关联,这类似于先将其模糊(去除高频),然后以较低分辨率重新采样图像。

\img\in-post\rtr5\5-23

原书图5.23,左边是采样信号和重建信号。在右边,为了使样本间的间隔加倍,滤波器的宽度增加了一倍,也就是缩小了。

以采样滤波理论为框架,实时渲染中运用了各种算法减少走样。

4.2基于屏幕的抗锯齿

如果没有很好地采样和过滤,三角形的边缘会产生明显的伪影。阴影边界、高光和其他颜色快速变化的现象也会导致类似的问题。本节讨论的算法有助于提高这些情况下的渲染质量。它们有基于屏幕的共同特点,即它们只对管线的输出样本进行操作。没有一种最佳的抗锯齿技术,因为每种技术在质量、捕捉清晰细节或其他现象的能力、运动过程中的表现、内存成本、GPU要求和速度方面都有不同的优势。

在图5.14中的黑三角形示例中,一个问题是采样率低。在每个像素的网格单元的中心取一个样本,因此对该单元所知最多的是该中心是否被三角形覆盖。通过对每个屏幕网格单元使用更多的样本并以某种方式混合这些样本,可以计算出更好的像素颜色。如图5.24所示。

\img\in-post\rtr5\5-24

原书图5.24,在左边,一个红色的三角形被渲染,像素的中心有一个样本。由于三角形没有覆盖样本,所以像素将是白色的,即使像素的大部分被红色三角形覆盖。在右侧,每个像素使用4个样本,可以看到,其中两个样本被红色三角形覆盖,混合形成粉色像素。

基于屏幕的抗锯齿方案的一般策略是对屏幕使用采样模式,然后对采样进行加权求和,生成像素颜色p:

\img\in-post\rtr5\e15

其中n为一个像素的采样数。函数$c(i,x,y)$是一个样本颜色,$w_i$是一个权值,在0-1范围内,样本将对整体像素颜色做出贡献。样本位置取决于它是1-n系列中的哪个样本,并且该函数还可选地使用像素位置(x,y)的整数部分。换句话说,在屏幕网格上采样的位置对于每个示例都是不同的,并且可以选择的采样模式随像素的不同而变化。样例通常是实时渲染系统中的点样例(以及大多数其他渲染系统)。函数c可以看成两个函数。首先,函数f(i,n)检索屏幕上需要样本的浮点$(x_f,y_f)$位置。然后对屏幕上的这个位置进行采样,即检索该精确点处的颜色。选择采样方案并且渲染管道用于在特定的子像素位置计算样本,通常基于每一帧(或每个应用)设置。

抗锯齿的另一个变量是$w_i$,即每个样本的权重。这些权重之和为1。实时渲染系统中使用的大多数方法都给它们的样本一个统一的权重,即$w_i=1/n$。图形硬件的默认模式(像素中心的单个样本)是上面抗锯齿方程的最简单例子。只有一项且这一项的权值是1,采样函数f总是返回被采样像素的中心。

计算每个像素多个完整样本的抗锯齿算法称为超采样supersampling(或过采样oversampling)方法。概念上最简单的全场景抗锯齿(full-scene antialiasing,FSAA),也称为“超采样抗锯齿”(supersampling antialiasing,SSAA),以更高的分辨率呈现场景,然后过滤邻近的样本来创建图像。例如,定义一个1280×1024像素的图像。如果你在屏幕外渲染2560×2048的图像,平均2×2的区域生成一个屏幕内像素,每个像素有四个样本,并使用盒型滤波器。注意,这对应于图5.25中的2x2网格采样。这种方法代价高昂,因为所有子样本必须完全着色和填充每个样本的z-bffuer深度。FSAA的主要优点是简单。这个方法的其他质量较低的版本在一个屏幕轴上采样的速率是它的两倍,因此称为1×2或2×1次超采样。通常,为了简单起见,使用2次方分辨率和一个盒型滤波器。NVIDIA的动态超分辨率特性是一种更精细的超采样形式,其中场景以更高的分辨率呈现,并使用13个样本高斯滤波器生成显示的图像。

\img\in-post\rtr5\5-25

原书图5.25,一些像素采样方案的比较,每个像素的采样范围从最小到最多。五点形共享角落样本,并将其中心样本的权重设置为像素最终颜色的一半。2×2旋转网格比2×2垂直网格捕捉到更多的水平边缘灰度。类似地,尽管使用的样本较少,但是8 rooks模式比4×4网格捕捉到更多的灰色级别。

一种与超级采样相关的采样方法基于累积缓冲器的思想。这种方法不是使用一个大的屏幕外缓冲区,而是使用一个与所需图像具有相同分辨率的缓冲区,但是每个通道有更多的颜色位。要获得一个场景的2×2个采样,需要生成4张图像,视图在屏幕x或y方向上根据需要移动半个像素。生成的每个图像都基于网格单元内不同的样本位置。对于实时渲染系统来说,每帧重新渲染场景几次并将结果复制到屏幕上的额外成本使得这种算法的成本很高。当性能不是很重要时,它对于生成高质量的图像很有用,因为每个像素可以使用任意数量的样本(放置在任何位置)。累加缓冲区过去是一个单独的硬件。OpenGL API直接支持它,但在3.0版本中不支持它。在现代gpu上,累加缓冲区的概念可以在像素着色器中实现,方法是为输出缓冲区使用更高精度的颜色格式。

当物体边缘、高光和尖锐阴影等现象导致颜色突变时,需要额外的样本。阴影通常可以做得更柔和,高光更平滑,以避免锯齿。可以增加特定对象类型的大小,比如电线,这样就可以保证它们在长度上的每个位置至少覆盖一个像素。对象边缘的锯齿仍然是一个主要的采样问题。可以使用分析方法,在绘制过程中检测对象边缘,并将它们的影响考虑在内,但是这些方法通常比简单地获取更多的样本更昂贵,也不那么健壮。然而,GPU的功能,如保守光栅化和光栅化顺序视图开辟了新的可能性。

通过生成完全由单独计算的着色和深度组成的样本,进行超采样和累积缓冲总体增益相对较低,成本较高,因为每个样本都必须通过像素着色器。

多采样抗锯齿(Multisampling antialiasing,MSAA)通过每像素计算一次表面阴影,并在样本之间共享结果,降低了较高的计算成本。像素可能有四个(x,y)样本位置,每个片段都有自己的颜色和z-depth,但是对于应用于像素的每个对象片段,像素着色器只评估一次。如果片段覆盖了所有MSAA位置样本,则在像素的中心评估着色样本。如果片段覆盖更少的位置样本,则着色样本的位置可以移动,以更好地表示覆盖的位置。例如,这样做可以避免对纹理边缘进行采样。这种位置调整称为质心采样或质心插值,并由GPU自动完成,如果启用。质心采样避免了三角形外的问题,但会导致导数计算返回不正确的值,参见图5.26。

\img\in-post\rtr5\5-26

原书图5.26,在中间,有两个物体重叠的像素。红色的物体覆盖了三个样本,蓝色的只有一个。像素着色器评估位置显示为绿色。因为红色三角形覆盖了像素的中心,所以这个位置用于着色器评估。蓝色对象的像素着色器在样本的位置进行评估。对于MSAA,在所有四个位置都存储一个单独的颜色和深度。右边是EQAA的2f4x模式。这四个示例现在有四个ID值,它们索引一个包含存储的两种颜色和深度的表。

MSAA比纯超采样方案更快,因为片段只被着色一次。它着重于以更高的速率采样片段的像素覆盖率,并共享计算得到的颜色。通过进一步解耦采样和覆盖可以节省更多的内存,这反过来又可以使抗锯齿更快——接触的内存越少,渲染的速度就越快。英伟达在2006年推出了覆盖采样抗锯齿(coverage sampling antialiasing,CSAA), AMD也紧随其后推出了增强质量抗锯齿(enhanced quality antialiasing,EQAA)。这些技术的工作原理是只以较高的采样率存储片段的覆盖率。例如,EQAA的”2f4x”模式存储两个颜色和深度值,在四个样例位置之间共享。颜色和深度不再为特定位置存储,而是保存在一个表中。然后,四个示例中的每个只需要一个位就可以指定这两个存储值中哪个与它的位置相关联。见图5.26。覆盖样本指定每个片段对最终像素颜色的贡献。如果超过存储的颜色数量,则删除存储的颜色,并将其样本标记为unknown。这些样品与最终的颜色无关。对于大多数场景,包含三个或更多可见的不透明片段的像素相对较少,而这些片段在颜色上是完全不同的,因此这种方案在实践中表现得很好。然而为了最好的质量表现,游戏Forza Horizon 2采用了4×MSAA,虽然EQAA有性能优势。

一旦将所有几何图形呈现到一个多样本缓冲区中,然后执行解析操作。此过程将样本颜色平均在一起,以确定像素的颜色。值得注意的是,当使用具有高动态范围颜色值的多采样时,可能会出现问题。在这种情况下,为了避免伪像,通常需要在解析之前对值进行色调映射(tone-map)。这可能是昂贵的,所以可以使用更简单的近似色调映射函数或其他方法。

默认情况MASS使用一个盒型滤波器。2007年ATI推出了自定义滤波器抗混叠(custom fi lter antialiasing,CFAA),具有使用窄和宽帐篷过滤器的能力,可以稍微扩展到其他像素单元。这种模式已经被EQAA支持所取代。在现代gpu像素或计算着色器上,可以访问MSAA样本并使用所需的任何重构滤波器,包括从周围像素样本中提取样本的滤波器。一个更宽的滤波器可以减少锯齿,尽管失去了清晰的细节。Pettineo发现三次平滑步长和宽度为2或3像素的b样条滤波器,总体效果最好。还有一个性能成本,因为即使使用自定义着色器来模拟默认的盒型滤波器解析也需要更长的时间,而更宽的滤波器内核意味着增加了示例访问成本。

NVIDIA内置的TXAA支持类似地在比单个像素更宽的区域上使用更好的重建滤波器,从而获得更好的结果。它和最新的MFAA(multiframe antialiasing,多帧抗锯齿)方案都使用了时间抗锯齿(temporal antialiasing,TAA),这是一种使用前几帧的结果来改进图像的一般技术。在某种程度上,这种技术之所以成为可能,是因为它允许程序员设置每个帧的MSAA采样模式。这种技术可以解决诸如旋转车轮之类的混叠问题,还可以提高边缘绘制质量。

想象一下,通过“生成一系列图像”来手动执行采样模式,其中每个渲染都使用像素内的不同位置作为采样的位置。这个偏移是通过在投影矩阵上附加一个微小的平移来完成的。生成并平均的图像越多,结果就越好。这种使用多个偏移图像的概念被用于时间抗锯齿算法。生成一个单独的图像,可能使用MSAA或其他方法,并混合以前的图像。通常只使用两到四帧。旧的图像可能会被赋予指数级的更少的权重,尽管如果观众和场景不移动,这可能会产生帧闪烁的效果,所以通常只有最后一帧和当前帧的权重是相等的。对于不同亚像素位置上的每一帧样本,这些样本的加权和比单一帧具有更好的边缘覆盖率估计。因此,使用最新的两帧平均在一起的系统可以得到更好的结果。每个帧不需要额外的样本,这正是这种方法如此吸引人的原因。甚至可以使用时间采样来生成分辨率较低的图像,并将其放大到显示器的分辨率。此外,光照方法或其他需要很多样本才能得到好的结果的技术可以用更少的样本来替代每一帧,因为结果将混合在多个帧上。

在不增加采样成本的情况下为静态场景提供抗锯齿的同时,这种算法在用于时间抗锯齿时存在一些问题。如果帧的权重不相等,静态场景中的物体就会闪烁。快速移动的物体或相机的快速移动会导致重影,也就是由于前几帧的贡献而留下的痕迹。一个解决重影的方法是只对缓慢移动的对象执行这种抗锯齿。另一个重要的方法是使用重投影(第12.2节)来更好地关联以前帧和当前帧的对象。在这种方案中,对象生成的运动矢量存储在单独的“速度缓冲区”中(第12.5节)。这些向量用于将前一帧与当前帧关联,即从当前像素位置减去矢量,求出该物体表面位置的前一帧颜色像素。在当前框架中不太可能是表面的一部分的样本被丢弃。由于时间抗锯齿不需要额外的样本,也不需要额外的工作,因此近年来这种算法得到了广泛的应用。这种关注的部分原因是延迟着色技术(第20.1节)与MSAA和其他多采样不兼容。方法各不相同,并且根据应用程序的内容和目标,开发了一系列避免伪像和提高质量的技术。例如,Wihlidal的演示展示了如何将EQAA、时间反走样和应用于棋盘格采样模式的各种滤波技术结合起来以保持质量,同时降低像素着色器调用的数量。iglesia - guitian等人总结了之前的工作,提出了使用像素历史和预测来最小化滤波伪影的方案。Patney等人对Karis和Lottes在虚幻引擎4实现上的TAA工作进行了扩展,将其用于虚拟现实应用程序中,添加了可变大小的采样以及眼动补偿(第21.3.2节)。

采样方式(Sampling Patterns)

有效采样模式是减少锯齿、时间和其他方面的关键因素。Naiman指出,人类最容易受到近水平和近垂直边缘锯齿的干扰。坡度接近45度的边缘是第二令人不安的。旋转网格超采样(Rotated grid supersampling,RGSS)使用旋转的正方形模式来在像素内提供更多的垂直和水平分辨率。图5.25显示了这种模式的一个示例。

RGSS模式是拉丁超立方体(Latin hypercube)或n -rooks采样的一种形式,其中n个样本被放置在一个n×n网格中,每一行和每一列有一个样本。使用RGSS,四个样本分别位于4×4亚像素网格的单独行和列中。与常规的2×2采样模式相比,这种模式特别适合捕获接近水平和垂直的边缘,在常规的2×2采样模式中,这种边缘可能覆盖偶数个样本,因此提供的有效水平更低。

N-rooks是创建良好采样模式的起点,但它还不够。例如,样本可能都位于子像素网格的对角线上,因此对于几乎平行于该对角线的边,结果很差。见图5.27。为了更好地采样,我们希望避免将两个样本放在一起。我们还想要一个均匀的分布,将样本均匀地分布在该区域。为了形成这种模式,分层采样技术(如拉丁超立方体采样)与其他方法如抖动、哈尔顿序列和泊松盘采样(jittering, Halton sequences, and Poisson disk sampling)相结合。

\img\in-post\rtr5\5-27

N-rooks抽样。左边是一个合法的N-rooks模式,但是它在捕捉沿其直线对角线的三角形边缘方面表现得很差,因为当这个三角形移动时,所有示例位置都将位于三角形内或三角形外。右边是一个模式,可以更有效地捕捉这条边和其他边。

在实践中,GPU制造商通常将这种采样模式硬连接到硬件中,以便对抗锯齿进行多采样。图5.28显示了在实践中使用的一些MSAA模式。对于时间抗锯齿,覆盖模式是程序员想要的,因为示例位置可以随着帧的不同而变化。例如,Karis发现一个基本的Halton序列比GPU提供的任何MSAA模式工作得更好。Halton序列在空间中产生的样本看似随机,但差异很小,即它们在空间中分布良好,没有聚类。

\img\in-post\rtr5\5-28

原书图5.28,AMD和NVIDIA图形加速器中MSAA采样模式。绿色方块是着色样本的位置,红色方块是计算并保存的位置样本。从左至右:2×、4×、6×(AMD)和8 ×(NVIDIA)采样。

虽然子像素网格模式可以更好地近似每个三角形如何覆盖网格单元,但它并不理想。一个场景可以由屏幕上任意小的物体组成,这意味着没有采样率可以完美地捕捉到它们。如果这些微小的物体或特征形成一个图案,在固定的时间间隔内采样会产生摩尔条纹和其他干涉图案。在超采样中使用的网格模式很可能是混叠的。

一种解决方案是使用随机抽样,它提供了一个更随机的模式。随机化倾向于用噪声代替重复的锯齿效应,而人类的视觉系统对噪声的容忍度要高得多。使用较少结构的模式会有所帮助,但是当像素之间重复时,它仍然会显示锯齿。一种解决方案是在每个像素处使用不同的采样模式,或者随着时间的推移改变每个采样位置。交叉采样(Interleaved sampling)是指一组图像中的每个像素都有不同的采样模式,在过去几十年里,它偶尔会得到硬件的支持。Molnar、Keller和Heidrich发现,当对每个像素使用相同的模式时,使用交错随机抽样可以最小化产生的混叠伪影。

其他一些gpu支持的算法值得注意。一种实时抗锯齿方案是英伟达较老的Quincunx方法,该方案可以让样本包含多个像素。“Quincunx”是指五个物体的排列,四个在一个正方形中,第五个在中间,比如六面骰子上的5点图案。Quincunx多采样抗锯齿使用这种模式,将四个外部样本放在像素的角落。参见图5.25。每个角样本值被分配到它的四个相邻像素。不同于其他实时方案对每个样本的权重相同(大多数其他实时方案都是这样),中心样本的权重为1/2,而每个角样本的权重为1/8。由于这种共享,平均每个像素只需要两个样本,结果比两个样本的FSAA方法要好得多。这种模式近似于一个二维帐篷滤波器,正如前一节所讨论的,它优于盒型滤波器。

Quincunx采样还可以通过使用每个像素的单个样本来应用于时间抗锯齿。每一帧在每个轴上距前一帧偏移半像素,偏移方向在帧与帧之间交替。前一帧提供像素角样,采用双线性插值快速计算每个像素的贡献。计算结果与当前帧的平均值。每个帧的权重相等意味着静态视图没有闪烁的伪像。对齐移动对象的问题仍然存在,但该方案本身易于编写代码,而且在每帧仅使用一个像素样本的情况下,效果会好得多。

在单个帧中使用Quincunx时,通过在像素边界共享样本,Quincunx只有两个样本,成本很低。RGSS模式更擅长捕捉几乎水平和垂直边缘的更多渐变。FLIPQUAD模式最初是为移动图形开发的,它结合了这两个理想的特性。它的优点是每像素只需要2个样本,质量与RGSS类似(每像素4个样本)。这个抽样模式如图5.29所示。Hasselgren等人探索了其他利用样本共享的廉价采样模式。

\img\in-post\rtr5\5-29

原书图5.29,左边显示了RGSS采样模式。每像素需要4个样本。通过将这些位置移动到像素边缘,可以跨边缘共享样本。然而,要做到这一点,每一个其他像素必须有一个反射的样本模式,如图所示。得到的样本模式称为FLIPQUAD,每个像素需要两个样本。

和Quincunx一样,双样本FLIPQUAD模式也可以用于时间抗锯齿和扩展到两帧。Drobot在他的混合重建抗锯齿(hybrid reconstruction antialiasing,HRAA)工作中解决了哪种双样本模式最好的问题。他探索了不同的采样模式的时间抗混叠,发现FLIPQUAD模式是五个测试中最好。棋盘格模式也用于时间抗锯齿。El Mansouri讨论了使用两个样本的MSAA来创建一个棋盘渲染,以减少着色器的成本,同时解决锯齿问题。Jimenez使用SMAA、时间抗锯齿和多种其他技术提供了一种解决方案,其中抗锯齿质量可以随着渲染引擎负载而改变。Carpentier和Ishiyama在边缘采样,将采样网格旋转45°。他们将这种时间抗锯齿方案与FXAA(稍后讨论)相结合,以高效地渲染在高分辨率显示器上。

操作步骤(Morphological Methods)

锯齿通常由边缘产生,例如由几何形状、尖锐的阴影或明亮的高光形成的边缘。对锯齿具有相关结构的知识可以被用来提供更好的抗锯齿结果。2009年Reshetov提出了一种类似的算法,称之为形态学抗锯齿(MLAA,morphological antialiasing)。“形态学”指与结构或形状有关的。早在1983年,Bloomenthal就在这一领域进行了研究。Reshetov的论文重振了对多采样方法替代方法的研究,强调寻找和重构边缘。

这种形式的抗锯齿是作为一个后处理来执行的。也就是说,渲染照常完成,然后将结果提供给生成抗锯齿结果的进程。自2009年以来,已经开发了广泛的技术。那些依赖于额外缓冲区(如深度和法线)的方法可以提供更好的结果,如子像素重构抗锯齿(subpixel reconstruction antialiasing,SRAA),但只能用于对几何边缘抗锯齿。分析方法,如几何缓冲区反走样(geometry buffer antialiasing,GBAA)和距离到边缘反走样(distance-to-edge antialiasing,DEAA),使渲染器计算三角形边缘位于何处的附加信息,例如,边缘距像素中心有多远。

大多数通用的方案只需要颜色缓冲,这意味着它们还可以从阴影、高光或各种以前应用的后处理技术(如剪影边缘渲染)中改进边缘(15.2.3节)。例如,定向局域抗锯齿(directionally localized antialiasing,DLAA)是基于这样一种观察:近乎垂直的边缘应该在水平方向上进行模糊处理,同样,近乎水平的边缘也应该与相邻的边缘在垂直方向上进行模糊处理。

更精细的边缘检测形式试图找到可能包含任何角度边缘的像素,并确定其覆盖范围。研究潜在边缘周围的邻域,目的是尽可能重建原始边缘所在的位置。边缘对像素的影响可以用来混合相邻像素的颜色。有关流程的概念视图,请参见图5.30。

\img\in-post\rtr5\5-30

原书图5.30,形态抗锯齿技术。左边是经过反走样的图像。目标是确定形成它的边缘的可能方向。在中间,该算法通过检查邻居来记录边缘的可能性。给定样本,显示了两个可能的边缘位置。在右边,使用最佳猜测边缘将邻近的颜色按估计覆盖率的比例混合到中心像素中。对图像中的每个像素重复这个过程。

Iourcha等人通过检查以像素为单位的MSAA样本来改进边缘查找,从而计算出更好的结果。注意,边缘预测和混合比基于样本的算法能给出更高的精度结果。例如,每个像素使用4个样本的技术只能为对象的边缘提供5个层次的混合:没有覆盖样本、覆盖一个样本、两个、三个和四个。估计的边缘位置可以有更多的位置,从而提供更好的结果。

有几种情况基于图像的算法可能会产生错误结果。首先,如果两个对象之间的颜色差异小于算法的阈值,则不能检测到边缘。有三个或多个不同表面重叠的像素很难解释。具有高对比度或高频元素的表面,颜色在像素之间快速变化,可能会导致算法遗漏边缘。特别是当应用形态学抗锯齿时,文本质量通常会受到影响。对象角可能是一个挑战,一些算法给它们一个圆形的外观。假定边是直的,曲线也会受到不利的影响。单个像素的改变会导致重构边缘的方式发生很大的变化,从而在帧与帧之间创建明显的伪影。改进这一问题的一种方法是使用MSAA覆盖掩码来改进边缘确定。

形态学抗锯齿方案仅使用所提供的信息。例如,宽度小于一个像素的物体,如电线或绳子,只要没有恰好覆盖像素的中心位置,屏幕上就会有间隙。在这种情况下,多采样可以提高质量;仅仅基于图像的抗锯齿是不行的。此外,执行时间可以根据查看的内容而变化。例如,一幅草地视图的抗锯齿时间可能是天空视图的三倍。

尽管如此,基于图像的方法可以为有限的内存和处理成本提供抗锯齿支持,因此它们在许多应用程序中得到了应用。只有颜色的版本也与渲染管道解耦,使它们更容易修改或禁用,甚至可以作为GPU驱动程序选项公开。两种最流行的算法是快速近似抗锯齿(fast approximate antialiasing,FXAA)和子像素形态反走样(subpixel morphological antialiasing,SMAA),部分原因是它们都为各种机器提供了可靠的(和免费的)源代码实现。两种算法都使用纯颜色输入,SMAA具有访问MSAA示例的优势。每一个都有自己的设置,在速度和质量之间进行交易。成本通常在每帧1到2毫秒之间,主要是因为这是游戏愿意花费的时间。最后,两种算法都可以利用时间抗锯齿。Jimenez提出了一种改进的SMAA实现,比FXAA更快,并描述了一种时间抗混叠方案。最后,我们推荐读者阅读Reshetov和Jimenez对形态学技术及其在电子游戏中的应用所作的广泛综述。

5透明度、Alpha值和合成

半透明物体允许光通过它们的方式有很多种。对于渲染算法,可以大致分为基于光和基于视图的效果。基于光的反射是指物体使光线衰减或转移,使场景中的其他物体被照亮并呈现出不同的效果。基于视图的效果是那些渲染半透明对象本身的效果。

在本节中,我们将处理基于视图的透明的最简单形式,其中半透明对象充当其背后对象颜色的衰减器。更精细的基于视图和光的效果,如磨砂玻璃,光的弯曲(折射),光由于透明物厚度的衰减,由于观察角度反射率和透射率的变化将在后面的章节中讨论。

给出透明度错觉的一种方法称为屏幕门透明度(screen-door transparency)。其想法是用像素对齐的棋盘填充模式渲染透明三角形。也就是说,三角形的其他每个像素都被渲染,从而使后面的对象部分可见。通常,屏幕上的像素距离足够近,以至于棋盘图案本身是不可见的。这种方法的一个主要缺点是,通常只能在屏幕的一个区域上令人信服地呈现一个透明对象。例如,如果透明的红色对象和透明的绿色对象呈现在蓝色对象之上,那么这三种颜色中只有两种可以出现在棋盘格模式中。另外,50%的棋盘是有限的。其他较大的像素掩码可以用来给出其他百分比,但这些掩码往往会创建可检测的模式。

也就是说,这种技术的一个优点是简单。透明对象可以在任何时间、以任何顺序呈现,不需要特殊的硬件。透明度问题通过使所有对象在其覆盖的像素处不透明来解决。同样的想法也适用于裁剪纹理的抗锯齿边缘,但是在亚像素级,使用alpha覆盖(第6.6节)。

Enderton等提出的随机透明方法是将亚像素屏幕门掩码与随机采样相结合。通过使用随机点画模式来表示片段的alpha覆盖,创建了合理但有噪声的图像。 见图5.31。为了使结果看起来合理,每个像素需要大量的样本,并且所有的亚像素样本都需要相当大的内存。优点是不需要混合,而且抗锯齿、透明度和任何其他创建部分覆盖像素的现象都由一个机制覆盖。

\img\in-post\rtr5\5-31

原书图5.31,随机透明度。 产生的噪音显示在放大区域。

大多数透明算法都是将透明对象的颜色与其背后对象的颜色混合在一起。为此,需要透明度混合(aplha blending)的概念。当对象在屏幕上渲染时,RGB颜色和z-buffer深度与每个像素相关联。另一个称为alpha($\alpha$)的组件也可以为对象覆盖的每个像素定义。Alpha是一个值,用于描述给定像素的对象片段的不透明度和覆盖率。alpha值为1.0表示该对象是不透明的,并且完全覆盖了像素区域;0.0表示像素没有被覆盖,即片段是完全透明的。

一个像素的alpha值可以表示不透明度、覆盖率,也可以同时表示两者,这取决于环境。例如,一个肥皂泡的边缘可能覆盖四分之三的像素,0.75,并且可能几乎是透明的,让十分之九的光线进入眼睛,所以它是十分之一不透明的,0.1。它的alpha值是0.75×0.1 = 0.075。然而,如果我们使用MSAA或类似的抗锯齿方案,覆盖范围将由样本本身考虑。四分之三的样品会受到肥皂泡的影响。在每个样本中,我们将使用0.1的不透明度值作为alpha值。

5.1混合

要使对象看起来透明,可以在现有场景的顶部渲染它,alpha值小于1.0。对象覆盖的每个像素都将从像素着色器接收到一个结果RGB(也称为RGBA)。通常使用over操作符将这个片段的值与原始像素颜色混合,如下所示:

\img\in-post\rtr5\e16

其中$c_s$为透明对象的颜色(称为源),$\alpha_s$为对象的alpha值, $c_d$为混合前的像素颜色(称为目标),$c_o$为将透明对象放置在现有场景上的结果颜色。在渲染管道发送$c_s$和$\alpha_s$的情况下,像素的原始彩色$c_d$被结果$c_o$替换。如果结果RGB$\alpha$中$\alpha$值为1.0,等式简单的执行用对象的颜色完全替换像素颜色。例如:将一个红色的半透明对象渲染到蓝色背景上。假设在某个像素处,对象的RGB着色为(0.9,0.2,0.1),背景为(0.1,0.1,0.9);,对象的不透明度设置为0.6。然后是这两种颜色的混合为:$0.6(0.9,0.2,0.1)+(1-0.6)(0.1,0.1,0.9)$,得到的颜色值为:(0.58,0.16,0.42)

over操作符给渲染的对象一个半透明的外观。透明是这样工作的,在某种意义上,当我们看到后面的物体时,我们认为它是透明的。使用over模拟了薄纱织物的真实效果。织物后面的物体的视图部分是模糊的——织物的线是不透明的。在实践中,松散的织物具有随角度变化的alpha覆盖。我们这里的重点是alpha模拟了材质覆盖像素的程度。

over运算符在模拟其他透明物体时就不那么令人信服了,尤其是通过彩色玻璃或塑料观察物体。在现实世界中,蓝色物体前的红色滤光片通常会使蓝色物体看起来很暗,因为这个物体反射的光很少能通过红色滤光片。参见图5.32。当使用over进行混合时,结果是红色和蓝色的一部分加在一起。最好将这两种颜色相乘,并添加透明物体本身的反射。这种物理透过率在14.5.1和14.5.2节中讨论。

\img\in-post\rtr5\5-32

原书图5.32,红色薄纱方形织物和红色塑料过滤器,赋予不同的透明度效果。注意阴影的不同之处。

在基本的混合阶段操作符中,over通常用于透明效果。另一种有一定用途的操作是加法混合,它简单地对像素值求和。也就是说$c_o=\alpha c_s+c_d$。这种混合模式可以很好地用于发光的物体,如闪电或火花,这些物体不会减弱后面的像素,而只会使它们变亮。但是,由于不透明的表面没有经过过滤,因此这种模式在透明度方面看起来并不正确。对于几个层状的半透明表面,如烟雾或火,叠加混合(additive blending)具有使现象的颜色饱和的效果。

为了正确渲染透明对象,我们需要在不透明对象之后绘制它们。这是通过首先渲染所有不透明对象并关闭混合,然后用over操作渲染透明对象来完成的。理论上,我们总是可以继续,因为1.0的不透明alpha值会给出源颜色并隐藏目标颜色,但是这样做的代价更大,没有真正的好处。

z缓冲区的一个限制是每个像素只能存储一个对象。如果多个透明对象重叠在同一个像素上,则z缓冲区无法单独保存并在之后解析所有可见对象的效果。当使用在任何给定像素的透明表面上时,通常需要按前后顺序渲染。不这样做会给出错误的感知。实现这种排序的一种方法是对单个对象进行排序,例如,根据其中心点沿视图方向的距离。这种粗略的排序可以很好地工作,但是在不同的情况下会有很多问题。首先,这个顺序只是一个近似值,所以被划分为较远的物体可能在被认为较近的物体前面。相互渗透的对象不可能在每个网格的基础上解决所有视角,除非将每个网格分割成单独的部分。有关示例,请参见图5.33中的左边图像。即使是一个带有凹面的网格,在屏幕上与自身重叠的视图方向上也会出现排序问题。

\img\in-post\rtr5\5-33

原书图5.33,在左边,使用z-buffer透明渲染模型。以任意顺序渲染网格会产生严重的错误。在右侧,深度剥离提供了正确的外观,但需要额外的通道。

尽管如此,由于它的简单性和速度,以及不需要额外的内存或特殊的GPU支持,执行粗略的透明度排序仍然是常用的。如果实现了,通常最好在执行透明时关闭z-depth替换。也就是说,z缓冲区仍然正常测试,但留下的表面不会改变存储的z深度;最接近不透明表面的深度保持不变。这样,所有透明的物体至少都会以某种形式出现,而不是在相机旋转改变排序顺序时突然出现或消失。其他技术也可以帮助改善外观,如绘制每个透明网格两次,第一次渲染背面,然后是正面。

上面的方程也可以修改,使混合前后得到相同的结果。这种混合模式称为under操作符:

\img\in-post\rtr5\e17

注意,under要求目标保持alpha值,而over不需要。换句话说,目标(正在混合的较近的透明表面)不是不透明的,因此需要有一个alpha值。under的公式就像over,但是源和目标交换了。另外,请注意计算alpha的公式是与顺序无关的,因此可以交换源和目标的alpha,最终alpha是相同的结果。

alpha的等式来自于将片段的alphas视为覆盖物。 Porter和Du注意到,由于我们不知道任何一个片段的覆盖区域的形状,我们假设每个片段与其alpha成比例地覆盖另一个片段。例如,如果$\alpha_s=0.7$,像素以某种方式划分为两个区域,其中0.7被源片段覆盖,0.3没有。排除任何其他知识,覆盖$\alpha_d=0.6$的目标片段将与源片段按比例重叠。该公式具有几何解释,如图5.34所示。

\img\in-post\rtr5\5-34

原书图5.34,一个像素和两个片段,s和d。通过沿着不同的轴对齐两个片段,每个片段覆盖了另一个片段的一定比例,即它们是不相关的。这两个片段所覆盖的面积等于输出的alpha值$\alpha_s-\alpha_s\alpha_d+\alpha_d$。这意味着将这两个区域相加,然后减去它们重叠的区域。

5.2顺序无关的透明度

使用under方程将所有透明对象绘制到一个单独的颜色缓冲区中,然后使用over将这个颜色缓冲区合并到场景的不透明视图上。under操作符的另一个用途是执行一种称为深度剥离(depth peeling)的与顺序无关的透明(Order-Independent Transparency,OIT)算法。与顺序无关意味着应用程序不需要执行排序。深度剥离背后的思想是使用两个z缓冲区和多个通道。首先,生成一个渲染遍历,以便所有表面的z深度(包括透明表面)都位于第一个z缓冲区中。在第二遍中,所有透明对象都被渲染。如果对象的z深度与第一个z缓冲区中的值匹配,我们知道这是最近的透明对象,并将其RGB$\alpha$保存到单独的颜色缓冲区。我们还通过保存任何透明对象的z深度(如果有的话)来“剥离”这一层,该透明对象超出第一个z深度且距离最近。这个z深度是第二个最近的透明物体的距离。连续通道继续剥离并使用under添加透明层。我们在经过一些遍历之后停止,然后将透明图像混合到不透明图像之上。参见图5.35。

\img\in-post\rtr5\5-35

原书图5.35,每个深度剥离通道绘制一个透明层。在左边是第一个通道,显示的图层直接可见的眼睛。第二层显示在中间,在每个像素处显示第二个最近的透明表面,在本例中为对象的背面。第三层,在右边,是第三个最近的透明表面(最终结果见图14.33)。

已经开发了该方案的几个变体。例如,Thibieroz给出了一种向后工作的算法,它的优点是能够立即混合透明值,这意味着不需要单独的alpha通道。深度剥离的一个问题是知道需要多少遍才能捕获所有透明层。一种硬件解决方案是提供一个像素绘制计数器,它告诉绘制过程中写入了多少像素;当一个遍历没有渲染像素时,就进行渲染。使用under的好处是,最重要的透明层——眼睛最先看到的那些——在早期就渲染出来了。每个透明的表面总是增加它所覆盖像素的alpha值。如果一个像素的alpha值接近1.0,则混合的贡献使该像素几乎不透明,因此距离较远的物体的影响可以忽略不计。当一个遍历呈现的像素数低于某个最小值,或者指定一个固定的遍历数时,可以缩短从前到后的剥离。这对于前后剥离效果不太好,因为最近的(通常也是最重要的)层是最后绘制的,因此可能会在早期终止时丢失。

虽然深度剥离是有效的,但是比较缓慢,因为每层剥离是一个单独的渲染通道对于所有透明物体。Bavoil和Myers提出了双重深度剥离,即每次剥离两个深度剥离层(最近的和剩余的最远的),从而将渲染通道减半。Liu等人探索了一种桶式排序方法,一次可以捕获多达32层。这种方法的一个缺点是,它需要相当大的内存来为所有层保持有序。使用MSAA或类似的抗锯齿技术将大大增加成本。

以交互速率将透明对象正确地混合在一起的问题并不是我们缺少算法,而是将这些算法有效地映射到GPU的问题。1984年,Carpenter提出了A-buffer,另一种多采样的形式。在A -buffer中,渲染的每个三角形为它完全或部分覆盖的每个屏幕网格单元创建一个覆盖掩码。每个像素存储所有相关片段的列表。不透明的片段可以剔除它们后面的片段,类似于z-buffer。所有的片段都存储在透明的表面。一旦所有列表都形成,通过遍历片段并解析每个示例就会产生最终的结果。

在GPU上创建片段链表的想法是通过directx11中公开的新功能实现的。所使用的特性包括无序访问视图(UAVs)和原子操作,见第3.8节。通过MSAA进行抗锯齿是通过访问覆盖掩码和评估每个样本的像素着色来实现的。该算法对每个透明表面进行栅格化,并将生成的片段插入到一个长数组中。除了颜色和深度之外,还生成一个单独的指针结构,将每个片段链接到为像素存储的前一个片段。然后执行一个单独的遍历,其中呈现一个填充屏幕的四边形,以便在每个像素处计算像素着色器。该着色器通过跟踪链接检索每个像素处的所有透明片段。检索到的每个片段依次与前面的片段进行排序。然后将这个排序后的列表混合到前面,以给出最终的像素颜色。由于混合是由像素着色器执行的,如果需要,可以为每个像素指定不同的混合模式。GPU和APIs的不断发展通过降低使用原子操作符的成本提高了性能。

与GPU上的链表实现一样,A-buffer的优点是只分配每个像素所需的片段。在某种意义上,这也可能是一个缺点,因为在开始渲染帧之前,所需的存储量是未知的。一个有毛发、烟雾或其他物体的场景,可能有许多重叠的透明表面,可以产生大量的碎片。Andersson指出,对于复杂的游戏场景,最多50个透明网格对象(如树叶)和最多200个半透明粒子可能会重叠。

GPU通常预先分配内存资源,如缓冲区和数组,链表方法也不例外。用户需要决定多少内存是足够的,而内存耗尽会导致明显的伪像。Salvi和Vaidyanathan提出了一种解决这个问题的方法,使用Intel引入的名为像素同步的GPU特性的多层alpha混合。参见图5.36。这种功能提供了可编程的混合,与原子相比开销更小。他们的方法重新定义了存储和混合,以便在内存耗尽时可以优雅地降级。粗略的排序顺序可以使他们的方案受益。DirectX 11.3引入了光栅化顺序视图(第3.8节),这是一种缓冲区,允许在支持该特性的任何GPU上实现这种透明方法。移动设备也有类似的技术,称为平铺本地存储(tile local storage),允许它们实现多层alpha混合。然而,这样的机制有性能成本,所以这种类型的算法可能很昂贵。

\img\in-post\rtr5\5-36

原书图5.36,左上角,执行传统的前后alpha混合,由于排序不正确导致渲染错误。右上角,A-buffer给出一个完美的,非交互结果。左下角是多层alpha混合渲染。右下角显示的是A-buffer和多层图像之间的差异性,可见性乘以4。

这种方法建立在k-buffer的思想上,由Bavoil等人引入,在k-buffer中,前几个可见层被尽可能地保存和排序,更深层的层被尽可能地丢弃和合并。Maule等人使用k-buffer并通过加权平均来解释这些较远的深层。加权和和加权平均透明技术是顺序无关的,是单遍的,并且几乎在每个GPU上运行。问题是它们没有考虑对象的顺序。例如,使用alpha表示覆盖范围,薄纱蓝色围巾上的薄纱红色围巾呈现紫罗兰色,而不是正确地看到红色围巾上有一点蓝色。虽然几乎不透明的物体给出的结果很差,但这类算法对可视化很有用,对高度透明的表面和粒子也很有效。参见图5.37。

\img\in-post\rtr5\5-37

原书图5.37,随着不透明度的增加,对象顺序变得更加重要。

加权和透明度公式为:

\img\in-post\rtr5\e18

其中n为透明面数,$c_i$和$\alpha_i$为透明值集合,$c_d$为场景中不透明部分的颜色。这两个和分别作为透明表面进行累积和存储,在透明遍历结束时,在每个像素处对方程求值。这种方法的问题是第一个和饱和,即生成大于(1.0,1.0,1.0)的颜色值,并且该背景颜色可能会产生负面影响,因为阿尔法的和可以超过1.0。

通常首选加权平均方程,因为它避免了以下问题:

\img\in-post\rtr5\e19

第一行表示透明渲染期间生成的两个独立缓冲区中的结果。每一个对$c_{sum}$有贡献的表面都有一个影响,这个影响的权重是它的alpha。接近不透明的表面贡献了更多的颜色,而接近透明的表面几乎没有影响。通过$c_{sum}/\alpha_{sum}$,我们得到加权平均透明度颜色。$\alpha_{avg}$值是所有alpha值的平均值。值u是对n个透明表面应用此平均alpha,n次后目标(不透明场景)的估计可见性。最后一行实际上是over运算符,(1-u)表示源的alpha。

加权平均的一个限制是,对于相同的alpha,它将所有颜色均匀地混合,而不考虑顺序。McGuire和Bavoil引入了加权混合的顺序独立透明度,以给出更令人信服的结果。在他们的公式中,到表面的距离也会影响重量,越近的表面影响越大。此外,u不是求alpha的平均值,而是通过将($1-\alpha_i$)项乘在一起并从1中减去,得到曲面集的真实alpha覆盖范围来计算的。这种方法产生的结果在视觉上更具说服力,如图5.38所示。

\img\in-post\rtr5\5-38

原书图5.38,两个不同的摄像机位置查看相同的引擎模型,都以加权混合顺序无关的透明度渲染。距离加权有助于明确哪些表面更接近观察者。

缺点是,在一个大的环境中,相互靠近的物体在距离上的权重几乎相等,这使得结果与加权平均值相差无几。此外,随着相机与透明物体之间距离的变化,深度权重也可能随之变化,但这种变化是渐进的。

McGuire和Mara扩展了这种方法,使其包含了一种可信的透射颜色效果等。如前所述,本节讨论的所有透明度算法都混合了各种颜色,而不是过滤它们,模拟像素覆盖。为了给出滤色器效果,像素着色器读取不透明场景,并且每个透明表面将其在该场景中覆盖的像素乘以其颜色,将结果保存到第三个缓冲区。在这个缓冲区中,不透明对象现在由透明对象着色,然后在解析透明缓冲区时使用这个缓冲区代替不透明场景。这种方法之所以有效,是因为与由于覆盖而产生的透明度不同,彩色传输是与顺序无关的。

还有其他一些算法使用了这里介绍的几种技术中的元素。例如,Wyman根据内存需求、插入和合并方法、是否使用alpha或几何覆盖以及如何处理丢弃的片段对以前的工作进行分类。他提出了两种新方法,这两种方法是通过寻找以前研究中的空白发现的。他的随机分层alpha混合方法使用k-buffers、加权平均和随机透明度。他的另一个算法是对Salvi和Vaidyanathan方法的变体,使用覆盖掩码而不是alpha。

给出了各种类型的透明内容、渲染方法和GPU的功能,没有完美的渲染透明对象的解决方案。我们建议感兴趣的读者参考Wyman的论文和Maule等人对交互透明性算法的更详细的研究。McGuire的介绍提供了一个更广泛的视野,通过其他相关的现象,如体积照明,色彩传输,折射,这将在本书后面更深入地讨论。

5.3预乘Alpha和合成

over操作符还用于混合照片或对象的合成渲染。这个过程叫做compositing。在这种情况下,每个像素的alpha值与对象的RGB颜色值一起存储。由alpha通道形成的图像有时被称为matte。它显示了物体的轮廓形状。有关示例参见图6.27。然后可以使用这个RGB$\alpha$图像将其与其他此类元素或背景进行混合。

使用合成RGB$\alpha$数据的一种方法是使用premultiplied alphas(也称为associated alphas)。也就是说,RGB值在使用之前要乘以alpha值。这使得合成方程更加有效$c_o=c’_s+(\alpha)c_d$。预乘alpha还可以在不改变混合状态的情况下使用over和加法混合,因为在混合过程中添加了源颜色。注意,使用预乘的RGB$\alpha$值,RGB组件通常不会大于alpha值,尽管可以这样创建一个特别明亮的半透明值。

渲染合成图像自然地与预相乘的alpha相吻合。在黑色背景上渲染的抗锯齿不透明对象默认提供预乘值。假设一个白色(1,1,1)三角形沿其边缘覆盖了某个像素的40%。使用(非常精确的)抗锯齿,像素值将被设置为灰度0.4,即我们将保存颜色(0.4,0.4,0.4)对于这个像素。如果存储alpha值,也将是0.4,因为这是三角形所覆盖的面积。RGB$\alpha$值将是(0.4,0.4,0.4,0.4),这是一个预乘值。

图像存储的另一种方式是使用unmultiply alphas,也称为unassociated alphas,甚至是令人费解的术语nonpremultiply alphas。一个未乘alpha的就是它说的RGB值不乘以alpha值。对于白色三角形的例子,未乘的颜色是(1,1,1,0.4)。这种表示方法的优点是可以存储三角形的原始颜色,但是在显示之前,这种颜色总是需要乘以存储的alpha值。在执行过滤和混合时,最好使用预乘数据,因为使用未乘的阿尔法不能正确地执行线性插值等操作。伪像,如物体边缘周围的黑色条纹,可能发生。进一步讨论请参见第6.6节的末尾。预乘阿尔法也允许更干净的理论处理。

对于图像处理应用程序,无关联的alpha可以在不建立底层图像的原始数据的情况下对照片进行掩码。此外,无关联的alpha表示可以使用颜色通道的全部精确范围。也就是说,必须谨慎地将未相乘的RGB$\alpha$值正确地转换为用于计算机图形计算的线性空间。例如,没有任何浏览器能够正确地做到这一点,它们也不太可能做到这一点,因为现在需要不正确的行为。支持alpha的图像文件格式包括PNG (unassociated alpha only)、OpenEXR (associated only)和TIFF(两种alpha类型)。

与alpha通道相关的一个概念是色度键入(chroma-keying )。这是一个来自视频制作的术语,演员在绿色或蓝色屏幕上拍摄,并与背景混合。在电影行业,这一过程被称为绿幕或蓝幕。这里的想法是,一个特定的颜色色调(用于胶片)或精确的值(用于计算机图形)被指定为透明的;无论何时检测到背景,都会显示出来。这允许使用RGB颜色给图像一个轮廓形状;不需要存储alpha。该方案的一个缺点是,对象要么完全不透明,要么在任何像素处都是透明的。实际上只有1.0或0.0。例如,GIF格式允许指定一种颜色为透明。

6显示编码

当我们计算光照、纹理或其他操作的效果时,所使用的值被假定为线性的。非正式地说,这意味着加法和乘法按预期工作。然而,为了避免各种视觉伪影,显示缓冲区和纹理使用非线性编码,我们必须考虑这一点。简短而草率的回答如下:取着色器输出颜色范围[0,1]把它们提高1/2.2的幂,进行所谓的伽马校正(gamma correction)。对传入的纹理和颜色执行相反的操作。在大多数情况下,你可以告诉GPU为你做这些事情。本节介绍这么做的方式和原因。

我们从阴极射线管(CRT)开始。在数字成像的早期,CRT显示器是标准。这些器件表现出输入电压与显示亮度之间的幂率关系。当应用于一个像素的能量水平增加时,所发出的辐射不会线性增长,而是(令人惊讶的)正比于该水平上升到大于1的幂。例如,假设幂是2。将一个像素设置为50%,它所发出的光量将是设置为1.0的像素的四分之一,即$0.5^2=0.25$。尽管液晶显示器和其他显示技术具有不同于CRT的固有响应曲线,但它们是用转换电路制造的,这使得它们能够模拟CRT响应。

这个幂函数几乎与人类视觉的明度敏感性相反。这种幸运的巧合的结果是,编码在感知上大致是一致的。也就是说,在可显示的范围内,一对编码值N和N +1之间的感知差异大致是恒定的。作为阈值对比度测量,我们可以在大范围的条件下检测亮度的差异约为1%。当颜色存储在有限精度的显示缓冲区时,这种近乎最优的值分布可以最小化带状伪影(第23.6节)。同样的好处也适用于纹理,它们通常使用相同的编码。

显示传递函数描述了显缓冲区中的数字值与显示发出的辐射水平之间的关系。由于这个原因,它也被称为电光传递函数(electrical optical transfer function,EOTF)。显示传输功能是硬件的一部分,计算机显示器、电视和电影放映机有不同的标准。在这个过程的另一端,图像和视频捕捉设备也有一个标准的传输函数,称为光电传输函数(optical electric transfer function,OETF)。

当编码线性颜色值用于显示时,我们的目标是抵消显示传递函数的影响,这样无论我们计算什么值,都会发出相应的辐射水平。例如,如果计算值加倍,则希望输出辐射加倍。为了保持这种联系,我们用显示传递函数的倒数来抵消它的非线性影响。这种使显示器的响应曲线无效的过程也称为伽马校正。当解码纹理值时,我们需要应用显示传递函数来生成一个线性值用于着色。图5.39显示了解码和编码在显示过程中的使用情况。

\img\in-post\rtr5\5-39

原书图5.39,在左侧,一个PNG颜色纹理被GPU着色器访问,它的非线性编码值(蓝色)被转换为一个线性值。在着色和色调映射之后(参见第8.2.2节),最终计算值(绿色)编码和存储在帧缓冲区中。这个值和显示传递函数决定发出的辐射量(红色)。绿色和红色的函数相互抵消,因此发出的辐射与线性计算值成正比。

个人电脑显示器的标准传递函数被定义为一个指定的彩色空间称为sRGB。大多数控制gpu的api可以设置为在从纹理读取值或将值写入颜色缓冲区时自动应用正确的sRGB转换。正如第6.2.2节所讨论的,mipmap的生成也将考虑sRGB编码。纹理值之间的双线性插值将正确工作,首先转换为线性值,然后执行插值。Alpha混合是通过将存储值解码回线性值、混合新值,然后对结果进行编码来正确完成的。

重要的是在渲染的最后阶段应用转换,即将值写入用于显示的帧缓冲区。如果在显示编码之后应用后处理,这些影响将对非线性值进行计算,这通常是不正确的,并且经常会导致伪影。显示编码(display encoding)可以被认为是一种压缩形式,它最好地保留了值的感知效果。考虑这个领域的一个好方法是我们使用线性值来执行物理计算,每当我们想要显示结果或访问可显示的图像(如颜色纹理)时,我们需要将数据移入或移出其显示编码形式,使用适当的编码或解码变换。

如果您确实需要手动应用sRGB,可以使用一个标准转换方程或几个简化版本。实际上,显示器是由每个颜色通道的若干位控制的,例如,消费者级显示器是8位,给出一组范围在[0,255]。这里,我们将显示编码的级别表示为一个范围[0.0,1.0]忽略比特数。线性值也在范围[0.0,1.0]内,表示为浮点数。我们用x表示这些线性值,用y表示存储在帧缓冲区中的非线性编码值。为了将线性值转换为sRGB非线性编码值,我们应用sRGB显示传递函数的倒数:

\img\in-post\rtr5\e21

用x表示线性RGB三元组的通道。该方程应用于每个通道,这三个生成的值驱动显示。如果手动应用转换函数,请小心。一种错误是使用编码的颜色而不是线性形式,另一种错误是对颜色进行两次解码或编码。

这两个转换表达式的底部是一个简单的乘法,这是由于数字硬件需要使转换完全可逆而产生的。上面的表达式涉及将值提升到幂,几乎适用于输入值x的整个[0.0,1.0]范围。考虑到偏移量和比例,这个函数近似于一个更简单的公式:

\img\in-post\rtr5\e22

$\gamma=2.2$。希腊字母$\gamma$是名称“伽玛校正(gamma correction)”的基础。

正如计算值必须编码以显示一样,静态或视频摄像机捕获的图像在用于计算之前必须转换为线性值。显示器或电视上看到的任何颜色都有一些显示编码的RGB三元组,可以通过屏幕捕获或颜色选择器获得。这些值以PNG、JPEG和GIF等文件格式存储,这些格式可以直接发送到帧缓冲区,以便在屏幕上显示,而无需进行转换。换句话说,无论您在屏幕上看到什么,根据定义都是显示编码的数据。在着色计算中使用这些颜色之前,我们必须将这种编码形式转换回线性值。从显示编码到线性值的sRGB转换是:

\img\in-post\rtr5\e23

用y表示规格化的显示通道值,即存储在图像或帧缓冲区中,在范围[0.0,1.0]内。这个解码函数与我们之前的sRGB公式相反。这意味着如果一个纹理被着色器访问并且输出没有改变,它将会像预期的那样显示。解码函数与显示传输函数相同,因为存储在纹理中的值已经过编码以正确显示。是为了转换为线性值,而不是转换为线性响应显示。

较简单的伽玛显示传递函数为式5.31的逆函数:

\img\in-post\rtr5\e24

有时会看到更简单的转换,特别是在移动和浏览器应用程序上:

\img\in-post\rtr5\e25

也就是说,取线性值的平方根来进行转换,然后用它本身乘以自己作为逆运算。虽然这是一个粗略的近似,但这种转换总比完全忽略问题要好。

如果不注意gamma,较低的线性值在屏幕上会显得太暗。一个相关的错误是,如果不执行伽玛校正,某些颜色的色调会发生变化。比如$\gamma=2.2$。我们想要从显示的像素发出与线性计算值成比例的辐射,这意味着我们必须将线性值提高到(1/2.2)次方。线性值0.1给出0.351,0.2给出0.481,0.5给出0.730。如果不进行编码,按原样使用这些值将导致显示器发出比需要的更少的辐射。注意0.0和1.0不会被这些变换改变。在伽马校正之前,黑色表面通常会被场景制作人员人为提高,在逆显示变换中抵消。

忽略伽马校正的另一个问题是,对物理线性辐射值正确的着色计算是在非线性值上执行的。 图5.40中可以看到一个例子。

\img\in-post\rtr5\5-40

原书图5.40,两个重叠的聚光灯照亮了一个平面。在左图中,添加0.6和0.4的光值后,不进行gamma校正。加法是在非线性值上进行的,会引起误差。注意,左边的光看起来比右边的亮得多,重叠部分看起来非常明亮。在右边的图像中,值是经过伽马校正的。灯光本身也按比例变亮,它们在重叠的地方结合得很好。

忽略伽马校正也会影响抗锯齿边缘的质量。例如,假设一个三角形边缘覆盖四个屏幕网格单元格(图5.41)。三角形归一化辐射为1(白色);背景是0(黑色)。从左到右备覆盖率为1/8,3/8,5/8,7/8。因此,如果我们使用盒型滤波器,我们希望将像素的归一化线性亮度表示为0.125、0.375、0.625和0.875。正确的方法是对线性值执行反走样,对四个结果值应用编码函数。如果不这样做,像素所代表的亮度将会太暗,从而导致如右图边缘的变形。这种人工伪像被称为绳索(roping),因为它的边缘看起来有点像一根扭曲的绳子。图5.42显示了这个效果。

\img\in-post\rtr5\5-41

原书图5.41,在左边,四个像素被一个白色三角形的边缘覆盖在一个黑色(显示为灰色)背景上,显示真实的区域覆盖。如果不进行伽玛校正,中间色调的变暗将导致边缘的扭曲,如右图所示。

\img\in-post\rtr5\5-42

原书图5.42,在左边,抗锯齿线的集合是伽马校正的;中间,对集合进行了部分校正;右边,没有伽马校正。

sRGB标准创建于1996年,已成为大多数计算机显示器的标准。然而,从那时起,显示技术就不断发展。更亮、能显示更广泛颜色的显示器已经开发出来。第8.1.3节讨论了彩色显示和亮度,第8.2.1节给出了高动态范围显示的显示编码。Hart的文章提供了关于高级显示器的详细信息。

进一步阅读和参考资料

Pharr等更深入地讨论了采样模式和抗锯齿。Teschner的课程笔记展示了各种抽样模式生成方法。Drobot回顾了以前关于实时抗锯齿的研究,解释了各种技术的属性和性能。有关多种形态抗锯齿方法的信息可以在相关SIGGRAPH课程的讲义中找到。Reshetov和Jimenez提供了游戏中形态学和相关时间抗锯齿工作的最新回顾。

对于透明度研究,我们再次向感兴趣的读者推荐McGuire的报告和Wyman的工作。Blinn的文章“What Is a Pixel?”在讨论不同定义的同时,为计算机图形学的几个领域提供了一个极好的介绍。Blinn的Dirty Pixels and Notation, Notation, Notation书籍包括一些介绍性文章关于滤波、抗锯齿、透明、合成和伽玛校正的文章。Jimenez的演讲详细介绍了用于抗锯齿的最新技术。

Gritz和d’Eon对伽马校正问题有很好的总结。Poynton的书深入介绍了各种媒体中的伽马校正,以及其他与颜色相关的主题。Selan的白皮书是一个较新的来源,解释了显示编码及其在电影工业中的应用,以及许多其他相关信息。