Realtime Rendering 4th notes - chapter5

(转载请注明出处,谢谢咯)
"A good picture is equivalent to a good deed." — Vincent Van Gogh

渲染3D物体时,我们不仅需要模型的几何形体,还需要我们渴望的视觉效果。根据应用需要,可以有从写实风格到多种多样的为创造而生的风格(两种风格如上图)。本章主要介绍两个都涉及到的知识点。

5.1 Shading Models(着色模型)

确定渲染对象外观的第一步,是选择着色模型(shading model)来描述对象的颜色,应如何根据表面方向,视图方向和光照等因素而变化。

例如我们使用Gooch shading model,它是一种非真实感渲染的形式。旨意提高技术绘图中的清晰程度。主要思想是将表面法线与光线的位置进行比较。如果法线指向光线,则用较暖的色调为表面着色;反之,则使用冷色调。两者之间的角度在这些色调之间进行插值,色调由用户提供。如下图所示。shading model通常具有控制模型外观变化的属性。设置它们是第二步。下图模型只有一种属性,表面颜色。

像很多着色模型一样,此示例也受观察方向,光照方向,曲面方向影响。在着色中,这三个方向都表示为单位向量。如下图。

现在我们已经定义了shading model的所有输入,现在我们看一下数学定义:

$$
\boldsymbol{c}_{\text{shaded}}=s\boldsymbol{c}_{\text{highlight}}+(1-s)(t)\boldsymbol{c}_{\text{warm}}+(1-t)\boldsymbol{c}_{\text{cool}}
\tag{5.1}
$$

在这个等式中,有如下中间计算过程:

$$
\begin{align}
\boldsymbol{c}_{\text{cool}}&=(0,0,0.55)+0.25\boldsymbol{c}_{\text{surface}},\\
\boldsymbol{c}_{\text{warm}}&=(0.3,0.3,0)+0.25\boldsymbol{c}_{\text{surface}},\\
\boldsymbol{c}_{\text{highlight}}&=(1,1,1),\\
t&=\frac{\boldsymbol{n}\cdot\boldsymbol{l}+1}{2},\\
\boldsymbol{r}&=2(\boldsymbol{n}\cdot\boldsymbol{l})\boldsymbol{n}-\boldsymbol{l},\\
s&=(100(\boldsymbol{r}\cdot\boldsymbol{v})-97)^\mp
\end{align}\tag{5.2}
$$

此定义中的一些数学表达式也经常出现在其他着色模型中。Clamping操作可以使值归0或夹在0和1之间,在着色中很常见。在高光的混合因子s计算式中,我们了用$x^\mp$符号代表Clamping0到1的操作。点积运算出现了3次,这是一种经常出现的模式,表示两个向量对齐的程度,也是两个向量夹角的余弦值。另一种常见的着色操作是基于0和1之间的标量值在两种颜色之间线性插值,它的形式是$t\boldsymbol{c}_a+(1-t)\boldsymbol{c}_b$,这里的t从1到0,颜色值从$\boldsymbol{c}_a$到$\boldsymbol{c}_b$。此模式在上面的模型中出现了两次。首先在冷暖色中间进行插值,然后用它们的结果和高光颜色进行插值。线性插值很常见,在每种着色语言中都是内置函数,一般叫lerp或者mix

$\boldsymbol{r}=2(\boldsymbol{n}\cdot\boldsymbol{l})\boldsymbol{n}-\boldsymbol{l}$计算$\boldsymbol{l}$根据法线$\boldsymbol{n}$的反射,大多着色语言也内置反射功能。


5.2 Light Sources(光源)

场景中可能有多个光照也可能没有光照,这取决于需求。每个光照都有自己的颜色,光照强度,大小,形状等。

使着色模型以二进制方式对光的存在与否作出反应,对应光源的开关,被着色的表面也会有两种不同外观。光源距离,阴影,表面是否背离光源(通过$\boldsymbol{n,l}$的夹角判断)或它们的组合,可以用以区分二者。然后是连续光强的情况。可以直接表示为刚才两种情况之间的差值,这意味着强度是一个范围,可能是0到1,或者是一个没有范围界限的值用其他方式影响着色。后者的一种常见方式是把着色模型分成亮和不亮两个部分,然后用光强$k_{\text{light}}$线性缩放亮的部分。

$$
\boldsymbol{c}_{\text{shaded}}=f_{\text{unlit}}(\boldsymbol{n,v})+k_{\text{light}}f_{\text{lit}}(\boldsymbol{l,n,v})
\tag{5.3}
$$

可以简单的扩展为一个RGB光$\boldsymbol{c}_{\text{light}}$:

$$
\boldsymbol{c}_{\text{shaded}}=f_{\text{unlit}}(\boldsymbol{n,v})+\boldsymbol{c}_{\text{light}}f_{\text{lit}}(\boldsymbol{l,n,v})
\tag{5.4}
$$

对于多光源则是:

$$
\boldsymbol{c}_{\text{shaded}}=f_{\text{unlit}}(\boldsymbol{n,v})
+
\sum\limits^n_{i=i}\boldsymbol{c}_{\text{light}_i}f_{\text{lit}}(\boldsymbol{l_i,n,v})
\tag{5.5}
$$

通常这种shading model描述的是不直接来自明确放置光源的情况,比如来自天空的光或者周围物体的反射光。

如上图,光对表面的影响可以看做是一组光线,其中光线的密度与光强度相对应,以用于表面着色。上图展示了一个亮面的横截面,沿该截面撞击表面的光线之间的密度,与$\boldsymbol{l,n}$之间的夹角余弦成反比。在这里我们就可以看到,定义与光线行进方向相反的矢量$\boldsymbol{l}$是方便的。否则我们要在计算余弦点积的时候取负。

更准确的说,光的密度(也就是光对表面着色的贡献量)是与$(\boldsymbol{n\cdot{l}})$成正比的,其中$(\boldsymbol{n\cdot{l}})\gt{0}$。负的值表示光在背面没有影响,所以我们要在使用点积的时候将它执行Clamping到0的操作,得到:

$$
\boldsymbol{c}_{\text{shaded}}=f_{\text{unlit}}(\boldsymbol{n,v})
+
\sum\limits^n_{i=i}(\boldsymbol{l_i}\cdot\boldsymbol{n})^+
\boldsymbol{c}_{\text{light}_i}f_{\text{lit}}(\boldsymbol{l_i,n,v})
\tag{5.6}
$$

支持多光源的着色模型通常使用公式5.5的结构,或者基于物理的模型所需的公式5.6。5.6对于风格化的模型也是有利的,因为它有助于确保光照的整体性,尤其是对于背离光或被遮蔽的表面。但有些模型不适合这种结构,他们将使用公式5.5的结构。

对于函数$f_{\text{lit}}()$最简单的选择是给定一个颜色

$$
f_{\text{lit}}()=\boldsymbol{c}_{\text{surface}}\tag{5.7}
$$

得到:

$$
\boldsymbol{c}_{\text{shaded}}=f_{\text{unlit}}(\boldsymbol{n,v})
+
\sum\limits^n_{i=i}(\boldsymbol{l_i}\cdot\boldsymbol{n})^+
\boldsymbol{c}_{\text{light}_i}\boldsymbol{c}_{\text{surface}}
\tag{5.8}
$$

这个模型的亮部对应Lambertian着色模型。该模型以理想的漫反射表面为前提进行工作,也就是完全无光泽的表面。Lambertian模型可以单独用于简单着色,他是许多着色模型中的关键组成部分。从公式5.3-5.6,我们看到光源影响着色模型看两个参数:指向光源的向量$\boldsymbol{l}$和光源颜色$\boldsymbol{c}_{\text{light}}$。各种不同类型的光源,也区别于这两个参数在场景中如何变化。

5.2.1 Direction Lights(平行光)

平行光(方向光)是最简单的光源。$\boldsymbol{l}$和$\boldsymbol{c}_{\text{light}}$都是恒定的,除了可以通过阴影进行衰减。平行光没有确切位置。

对于相对于场景大小较远的灯光如太阳光或者二十英尺远照射小桌面上面的模型时,应使用平行光。

稍作扩展,我们可以保持$\boldsymbol{l}$恒定,改变$\boldsymbol{c}_{\text{light}}$的值。出于性能或者创作需要,这样应用通常需要把光限制在场景的特定部分。例如一个区域可以定义为两个嵌套的盒形体积,其中外框的$\boldsymbol{c}_{\text{light}}=(0,0,0)$,内部$\boldsymbol{c}_{\text{light}}={\text{any constant}}$。两个盒子之间的区域使用这两个值的差值。

5.2.2 Punctual Lights(点源光)

Punctual Lights不是按时约会的灯(punctual直译英文为准时的),而是有确切位置的灯。我们使用的punctual来自拉丁语,意为点(point),用于表述所有源自单一位置的一类光源。我们使用术语点光(point light)来表示一种特殊类型的发射器,一种在所有方向均匀照射光的发射器。因此,点光和聚光灯是两种形式的点源光。光源方向$\boldsymbol{l}$随当前着色表面点$\boldsymbol{p}_0$相对于点源光的位置$\boldsymbol{p}_{\text{light}}$的关系而变化:

$$
\boldsymbol{l}=\frac{\boldsymbol{p}_{\text{light}}-\boldsymbol{p}_0}
{\Vert\boldsymbol{p}_{\text{light}}-\boldsymbol{p}_0\Vert}\tag{5.9}
$$

这也是一种常见的操作,通常内置于着色语言中。但有时需要使用这个值的中间结果,这需要多步操作:

$$
\begin{align}
\boldsymbol{d}&=\boldsymbol{p}_{\text{light}}-\boldsymbol{p}_0,\\
r&=\sqrt{\boldsymbol{d}\cdot\boldsymbol{d}},\\
\boldsymbol{l}&=\frac{\boldsymbol{d}}{r}
\end{align}
\tag{5.10}
$$

想要找到向量的长度我们只需用它自身的点积开方。我们需要的中间值是$r$,表示光源和当前着色表面点的距离。除了用于标准化$\boldsymbol{l}$外,还需要$r$的值计算作为距离因素计算光线的衰减。

Point/Omni Lights(点光)

在所有方向上均匀发光的点源光被称为点光源(point light)或者全向灯(omni light)。对于点光源,唯一的变化源是上面提到的距离衰减。

上图解释了为何出现衰减(变暗)的情况。在给定表面处,光线间的间隔$\boldsymbol{d}$和光距离表面的距离$\boldsymbol{r}$成正比,与余弦因子不同,这种间距的增加在物体表面沿着两个维度发生,因此光线密度(以及光的颜色$\boldsymbol{c}_{\text{light}}$)和距离的平方倒数$(1/r^2)$成正比。我们设$\boldsymbol{c}_{\text{light}_0}$为$\boldsymbol{c}_{\text{light}}$在光与表面间固定距离为$r_0$时的值:

$$
\boldsymbol{c}_{\text{light}}(r)=\boldsymbol{c}_{\text{light}_0}(\frac{r_0}{r})^2\tag{5.11}
$$

上述等式称为平方反比光衰减(inverse-square light attenuation),它虽然从技术上讲是正确的距离衰减,但有些问题使得这个等式不适用于实际着色。

问题一:当距离较小时,$r$趋近于0,$\boldsymbol{c}_{\text{light}}$趋近于无穷。所以解决办法一般是增加一个很小的值$\epsilon$在分母处:

$$
\boldsymbol{c}_{\text{light}}(r)=\boldsymbol{c}_{\text{light}_0}(\frac{r_0^2}{r^2+\epsilon})\tag{5.12}
$$

$\epsilon$的值取决于应用,如虚幻引擎取$\epsilon=1cm$。

使用在CryEngine和寒霜中的另一种修改,是对r取一个极小值,做Clamp操作:

$$
\boldsymbol{c}_{\text{light}}(r)=\boldsymbol{c}_{\text{light}_0}(\frac{r_0}{max(r,r_{\text{min}})})^2\tag{5.13}
$$

相比第一种方法,第二种方法不那么随意且$r_{\text{min}}$的值具有物理解释:发光物体的对象半径。也就是小于该值的物体表面将穿过光源本身,这是不可能的。

问题二:发生在距离相对较大的位置,问题不在于效果而是性能。因为虽然距离增加光强减少,但永远不会是0。为了避免光在边界处产生锋利的截断,最优的选择还是修正函数的导数与值在同一个距离值时到达0。一种解决方法是将方程乘以一个具备所需性质的windowing function。虚幻和寒霜都使用了这样一个函数:

$$
f_{\text{win}}(r)=(1-(\frac{r}{r_{\text{max}}})^4)^{+2}\tag{5.14}
$$

$+2$的意思是先判断值若为负,就先归零,然后再做平方操作。下图显示了windowing function和两者相乘的结果:

应用程序的要求会影响我们选择什么样的函数。例如,当距离衰减函数在一个相对低的空间频率(spatial frequency )中采样时(如光照贴图和逐顶点光照),在$r_{\text{max}}$处导数为0是很重要的。CryEngine不使用光照贴图或顶点光照,所以它做了更简单的调整,把$0.8r_{\text{max}}$到$r_{\text{max}}$间的衰减转换成线性衰减。

对于有些应用,匹配平方反比曲线不是那么重要,所以简化前面的函数如下:

$$
\boldsymbol{c}_{\text{light}}(r)=\boldsymbol{c}_{\text{light}_0}f_\text{dist}(r)\tag{5.15}
$$

$f_\text{dist}(r)$可以叫做距离衰减函数,受性能影响。如Just Cause 2需要计算成本极低的光照。需要一个简单的距离衰减函数,但也要避免顶点光照的失真:

$$
f_{\text{dist}}(r)=(1-(\frac{r}{r_{\text{max}}})^2)^{+2}\tag{5.16}
$$

其他情况下,距离衰减函数也可能由创造性因素驱动。比如虚幻引擎有两种光衰减:一个平方反比模式(5.12),以及指数衰减模式,可以调整以创建各种衰减曲线。游戏古墓丽影使用样条编辑器创作衰减曲线,从而更好的控制曲线的形状。

Spotlights(聚光灯)

不同于点光源,几乎所有真实世界的光源的照明都随着方向和距离变化。这种变化可以描述为方向衰减函数$f_{\text{dir}}(\boldsymbol{l})$,它与距离衰减函数相结合,以定义光强度的变化:

$$
\boldsymbol{c}_{\text{light}}=\boldsymbol{c}_{\text{light}_0}f_{\text{dist}}(r)f_{\text{dir}}(\boldsymbol{l})\tag{5.17}
$$

不同的$f_{\text{dir}}(\boldsymbol{l})$可以产生不同的效果。聚光灯的方向衰减函数在聚光灯的方向矢量周围具有旋转对称性,因此可以可以写为$\boldsymbol{s}$与反转光矢量$-\boldsymbol{l}$之间的角度$\theta_s$表示的函数。

大多数聚光灯函数使用由$\theta_s$描述,这是在着色中最常见的形式。聚光灯通常具有本影角$\theta_u$(umbra angle),它限制光,使得所有$\theta_s\ge\theta_u$都有$f_{\text{dir}}(\boldsymbol{l})=0$。该角度可以用类似先前所见$r_{\text{max}}$类似的方法进行剔除操作。还有半影角$\theta_p$(penumbra angle),定义了光锥内部具有完全光照强度的内椎部分。如图:

方向衰减函数的定义基本相似。例如,应用在寒霜引擎(Frostbite)中的函数$f_{\text{dir}_F}(\boldsymbol{l})$和应用在three.js中的$f_{\text{dir}_T}(\boldsymbol{l})$:

$$
\begin{align}
t&=(\frac{\cos\theta_s-\cos\theta_u}{\cos\theta_p-\cos\theta_u})^\mp,\\
f_{\text{dir}_F}(\boldsymbol{l})&=t^2,\\
f_{\text{dir}_T}(\boldsymbol{l})&=smoothstep(t)=t^2(3-2t)
\end{align}
\tag{5.18}
$$

smoothstep()是一个在着色中常用于平滑差值的函数,在多数着色语言中内置。

Other Punctual Lights(其他点源光)

还有许多其他方式可以使点源光的光值变化。$f_{\text{dir}}(\boldsymbol{l})$函数并不限于上述讨论的简单衰减函数,它可以表示任何类型的方向变化,包括从真实世界测量的以表格形式展示的复杂形势。照明工程学会(IES)为此类测量定义了标准格式。其配置文件可以从许多照明制造商处获得,并已经应用于游戏如Killzone: Shadow Fall等。古墓丽影中有一种点源光,它沿着x,y,z轴应用独立的衰减函数。同样是古墓丽影中,可以使用曲线随时间改变光强度,比如用来产生闪烁的火炬。

5.2.3 Other Light Types(其他光源)

定向光和点源光的主要区别在于如何计算光的方向$\boldsymbol{l}​$。通过使用其他方法来计算光的方向可以定义不同类型的光。例如古墓丽影中的胶囊光(capsule light)使用线段为源,对于每个着色点,使用线段上距离最近的点计算光源方向。

到目前为止讨论的光都为抽象光,但实际上光源是具有尺寸和形状的,他们从多个方向照射表面着色点。在渲染中,这种灯称作区域光照(Area-light),在实际使用中呈上升趋势。区域光照技术分为两类:使用区域光被部分遮挡来模拟阴影的边缘软化和模拟区域光对表面着色的影响。第二类照明对于光滑的镜面最为明显,其中光的形状和大小可以在其反射中清晰的辨别。考虑光域近似值的技术已经被开发出来了,相对效率更高,可以看到日后广泛的应用。


5.3 Implementing Shading Models(实现着色模型)

5.3.1 Frequency of Evaluation(求值频率)

在设计着色过程的执行时,计算需要根据他们的求值频率(frequency of evaluation)进行分割。首先确定给定计算结果在整个绘制过程中是否恒定。在这种情况下,计算可以在CPU上由应用程序执行并将结果以uniform shader输入的形式传递给图形API。

即使这一类别中,也有很多可能的frequency of evaluation,从"once ever"开始。这种情况最简单的一个例子是着色方程中常量子表达式,只要是很少有变化因素的计算都可以应用,如硬件配置和安装选项等。甚至在编译着色器时可以计算出结果的,就不需要设置一个uniform shader输入。或者,计算可以在离线的预处理pass中,在安装时或应用程序加载时进行。

另一种情况是着色计算的结果在应用程序运行时变化的,但是由于缓慢不需要每帧更新的。如,取决于虚拟世界中时间的照明因素。如果计算成本很高,可能需要在多帧上分摊计算压力。

再有其他情况包括每帧执行一次的计算,例如串联视图和透视矩阵;或每个模型一次,例如更新依赖于位置的模型照明参数;又或者每次绘制调用一次,如更新模型中每种材质的参数。按照求值频率对uniform shader输入进行分组对应用程序的执行效率很有用,并且还可以通过减少每帧更新提高GPU性能。

如果着色计算的结果在绘制调用中发生改变,则无法通过uniform shader输入将其传入着色器。相反,它必须由第三章中描述的一个可编程着色器阶段计算,如果需要,可以通过不同的着色器输入传递到其他阶段。理论上,阴影计算可以在任何可编程阶段上执行,每个阶段对应的求值频率:

  • Vertex Shader:每个曲面细分前的顶点求值一次
  • Hull Shader:每个表面patch求值一次
  • Domain Shader:每个曲面细分后的顶点求值一次
  • Geometry Shader:每个图元求值一次
  • Pixel Shader:每个像素求值一次

实践中,着色通常在逐像素的在Pixel Shader中计算,但计算着色器的使用现在正越来越普遍。其他阶段主要用于几何操作,如变换和变形。逐顶点的计算在低密度的顶点分布时容易产生问题,如下图(左为逐顶点计算结果,中间为逐像素计算结果,右图为顶点分布情况):

如果拆分到顶点和像素两个阶段分别计算高光和其余的部分,理论上可以节省计算也不会产生视觉上的失真问题。但在实践中并不好。

注意,虽然顶点着色器始终生成单位长度的表面法线,差值后也会改变其长度,如上图左侧,所以需要在像素着色器中将法线重新标准化。即使这样,顶点着色器生成的法线长度仍然很重要。如果法线长度在顶点间变化显著,这将使插值偏斜。所以要在插值前后各使用一次标准化,即在顶点着色器和像素着色器中。

与表面法线不同,指向特定位置的矢量通常不进行插值(如观察方向和光源方向)。相反,这里的插值在像素着色器中进行,然后标准化。如果需要使用这些向量的插值,请不要事先标准化,这将产生错误的结果,如图:

当在顶点着色器中进行坐标变换的时候,我们需要判断要统一成什么坐标系是适当的。这通常是基于整个渲染系统(考虑性能,灵活性,简单性等)做出的判断。如,若渲染场景包含大量灯光,则可以选择世界空间以避免灯光位置变换。或者,相机空间可能是首选,以更好的优化和观察方向相关的像素着色器操作,并可能提高精度。

虽然大多数着色器的实现遵循上述观点,但肯定还有例外。如有些应用使用逐图元的着色用于风格化表现,这种风格通常被称为平面着色(flat shading),如下图(肯塔基0号路):

原则上可以在几何着色器中执行平面着色,但近期的实现都通常是在顶点着色器中完成的。这是通过将每个图元的属性与其第一个顶点相关联,然后禁用顶点插值实现的。禁用插值导致了第一个顶点的值传递个图元中所有像素。

5.3.2 Implementation Example(实现示例)

这里的实现很像公式5.1,Gooch模型,但是处理成可以用于多光源的情况,如下:

$$
\boldsymbol{c}_{\text{shaded}}=\frac12\boldsymbol{c}_{\text{cool}}+
\sum\limits^n_{i=1}(\boldsymbol{l}_i\cdot\boldsymbol{n})^+\boldsymbol{c}_{\text{light}_i}(s_i\boldsymbol{c}_{\text{highlight}}+(1-s_i)\boldsymbol{c}_{\text{warm}})
\tag{5.19}
$$

以及中间过程:

$$
\begin{align}
\boldsymbol{c}_{\text{cool}}&=(0,0,0.55)+0.25\boldsymbol{c}_{\text{surface}},\\
\boldsymbol{c}_{\text{warm}}&=(0.3,0.3,0)+0.25\boldsymbol{c}_{\text{surface}},\\
\boldsymbol{c}_{\text{highlight}}&=(2,2,2),\\
\boldsymbol{r}_{\text{i}}&=2(\boldsymbol{n}\cdot\boldsymbol{l}_i)\boldsymbol{n}-\boldsymbol{l}_i,\\
s_i&=(100(\boldsymbol{r}_i\cdot\boldsymbol{v})-97)^\mp
\end{align}
\tag{5.20}
$$

这个等式符合公式5.6提供的多光源结构,这里重复一下:

$$
\boldsymbol{c}_{\text{shaded}}=f_{\text{unlit}}(\boldsymbol{n,v})
+
\sum\limits^n_{i=i}(\boldsymbol{l_i}\cdot\boldsymbol{n})^+
\boldsymbol{c}_{\text{light}_i}f_{\text{lit}}(\boldsymbol{l_i,n,v})
\tag{5.6}
$$

在这里:

$$
\begin{align}
f_{\text{unlit}}(\boldsymbol{n,v})&=\frac12\boldsymbol{c}_{\text{cool}},\\
f_{\text{lit}}(\boldsymbol{l_i,n,v})&=s_i\boldsymbol{c}_{\text{highlight}}+(1-s_i)\boldsymbol{c}_{\text{warm}}
\end{align}
\tag{5.20}
$$

在大多典型渲染应用中,诸如$\boldsymbol{c}_{\text{surface}}$这类的变化的属性值一般存在顶点数据中,或者更常见的存在纹理中。此示例为了简单假设这个值不变。

此实现将使用着色器的动态分支(dynamic branching)功能来遍历所有光源。虽然这种简单的方法可以很好地适用于简单场景,但是对于大型或者几何复杂的场景还是不行的(放到后面章节说)。为了简单,我们只支持点光源。

着色模型不是孤立实现的,而是在渲染框架的上下文中实现的。示例在一个简单的WebGL 2应用中实现。秉承由内向外的顺序,先是像素着色器,然后是顶点,最后是应用程序段的API调用。

// 首先定义输入输出
// 像素着色器输入匹配顶点着色器输入,也就是这些输入是差值后的结果
// 顶点坐标和顶点法线均为世界坐标
in vec3 vPos;
in vec3 vNormal;
out vec4 outColor;

// uniform inputs
// 这里为了简介期间,我们只显示两个与光源相关的
// 点光源,这里定义为vec4而不是vec3是为了符合GLSL std140标准

struct Light {
	vec4 position;
    vec4 color;
};
struct LightUBlock {
    Light uLights[MAXLIGHTS];
};

// draw call中实际有效的灯光数量
uniform uint uLightCount;

接下来我们看一下pixel shader的代码:

// 这里的uWarmColor和uFUnlit都是uniform inputs
// GLSL内置函数:
// - reflect(v1,v2) 得出v1在v2定义平面上的反射
// - clamp(x,min,max) 
// - mix(v1,v2,s) 基于第三个值在前两个值间插值
// - normalize(v) 归一化

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);
    }
}

现在我们来看一下vertex shader:

// 我们这里忽略uniform inputs,研究一下输入输出
// 注意这里的输出和像素着色器中的输入要匹配
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;
    
    // 注意这里vNormal并没有执行标准化,因为原始网格的长度就是1
    // 也并没有执行缩放或者顶点混合一类会改变法线的操作
    
    // gl\_position是由光栅化阶段使用的特殊变量
    // 也是所有顶点着色器的必要输出
    gl\_Position = viewProj * worldPosition; 
}

在应用程序中,每个着色器阶段都是单独设置的,然后绑定到程序对象上。

以下是pixel shader在应用程序中设置的代码(使用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);

5.3.3 Material Systems(材质系统)

渲染框架通常很少只实现一个着色器。需要专门的系统处理应用程序使用的各种材质,着色模型和着色器。材质有时也能描述非视觉属性,比如碰撞。

着色器和材质并不一定是一一对应关系。在不同渲染条件下,相同材质可能对应不同着色器。着色器也可以由多种材质共享。最简单的例子是参数化的材质,由材质模板和材质实例构成。模板可以表述为一个类,而实例对应每个参数应用后对应的特定材质。一些渲染框架允许更复杂的分层结构,材质模板由多个级别的模板生成(如虚幻)。

参数可以通过uniform输入来解析,或者在编译时替换值解析。常见的编译时参数是Boolean值,用于开关指定材质特征。艺术家可以通过材质面板进行操作,如减少远距离物体的一些效果。

材料系统最重要的任务之一是将各种着色器功能划分为单独的元素并控制它们的组合方式。在许多情况下,这种组合方式是有用的,比如:

  • 用刚性变换,顶点混合,变形,曲面细分,实例化和裁剪拆分处理几何过程,单独创作并按需组合。
  • 使用如像素舍弃或者像素融合来组合表面着色。
  • 着色模型本身用于组合。
  • 选择材质中独立的特征,选取逻辑,和其余着色器部分。
  • 先计算光源和参数的结果,然后和着色模型组合。

如果图形API提供着色器代码的模块化作为核心功能该多方便啊。遗憾的是与CPU代码不同,GPU着色器不允许代码片段的后编译链接。每个着色器阶段作为一个单元进行编译。这种阶段的分离提供了有限的模块化,这符合上述列表的第一条。但是符合得并不完美,因为每个着色器也执行其他操作,其他类型的合成也仍需处理。鉴于这些限制,材质系统实现这些组合的唯一方法是在源代码级别。这主要涉及字符串操作,例如链接和替换,通常通过c风格的预处理指令执行,如#include,#if#define

早期的渲染系统具有较少数量的着色器变体,并且通常每个都是手动编写的。随着变体数量的增加,这种方法变得不切实际。这就是模块化和组合性如此重要的原因。

设计用于处理着色器变体的系统时要解决的第一个问题是 选择在运行时通过动态分支执行还是在编译时通过条件预处理执行。旧的硬件可能不支持运行时,所以都通过编译时完成。如今,许多功能性变量,例如灯的数量,都是在运行时处理的。但向着色器过多会导致寄存器使用数量的增加以及性能的降低。所以编译时变量仍然很有价值。

举例说,我们设想一个支持3种不同类型灯光的应用程序。点光,方向光和聚光灯。聚光灯支持列表照明模式和其他复杂功能,需要大量的着色器代码实现。然而,假设聚光灯使用较少,应用中不到5%的灯使用这种类型。以前的处理方法是针对三种光每种可能的数量和组合进行编译,以避免动态分支。现在不需要这样,但是编译两个单独的变化量仍然是有益的,一个用于聚光灯数量大于等于1时,另一个用于聚光灯数量为0时。这样第二种情况不但经常被使用,而且有较低的寄存器占用,从而获得高性能。

现代材质系统同时采用两种方法,尽管不需要完全使用编译时,但总体复杂性和变体数量仍在增加,因此仍需要编译大量着色器变体。如Destiny: The Taken King中的某些区域,单帧中使用了超过9000个编译的着色器变体。可能的变体数量可能更多,如Unity渲染系统具有接近1000亿可能的着色器变体。只编译实际使用的变体,但必须重新设计着色器编译系统以处理大量可能的变体。

材质系统的设计人员采用不同的策略来实现这些设计目标。虽然这些策略有时在系统架构方面是互斥的,但这些策略可以在同一系统中进行组合。策略包括:

  • 代码重用:在共享文件中实现函数,使用#include访问。
  • 减法:一个着色器,通常称作übershader或者supershader,聚合了大量的功能,使用编译时和动态分支的组合删除未使用的部分,并在互斥的选择中切换。
  • 加法:各种功能被定义为具有输入和输出的连接器节点并组合在一起。类似于代码重用。(见下图,虚幻引擎中的材质编辑器)
  • 基于模板:定义一个接口。这比添加策略更正式,通常用于更大的功能块。接口一个常见例子是着色模型参数的计算和着色模型本身计算的分离。虚幻引擎具有不同的材质域,包括用于计算着色模型参数的Surface domain和Light Function域,给定光源计算一个标量值用于调整$\boldsymbol{c}_{\text{light}}$。Unity中也存在类似的表面着色器结构,注意延迟着色技术强制执行一个类似的结构,由G缓冲区作为接口。

除了组合之外,现代材质系统还有其他几个重要的设计考虑因素,例如需要支持多平台且着色器代码重复最少。这包括考虑平台,着色语音和API之间的性能和功能差异。Destiny的着色系统是这类问题的代表性解决方案。它使用专用的预处理器层,该层采用自定义着色语言方案编写的着色器。通过与平台无关的材质,自动转换为不同着色语言和实现。虚幻和Unity都有类似的系统。

材质系统还需要确保良好的性能。除了编辑着色变体外,还有些常见的优化方法。Destiny着色系统和虚幻引擎都自动检测draw call中的常量计算,并将其移出着色器。另一个例子是Destiny中使用的scoping system,用于区分不同频率更新的常量(如每帧一次,每个光照一次,每个对象一次),并在适当的时间更新每组常量以减少API开销。

5.4 Aliasing and Antialiasing(失真与抗锯齿)

三角形以像素显示时,在屏幕网格单元格中以存在或不存在显示,则产生锯齿(the jaggies)或动画时的the crawlies。这种问题更正式的叫法为失真(Aliasing),而抗锯齿技术是为了解决它存在的。

5.4.1 Sampling and Filtering Theory (采样和过滤器理论)

渲染图像本质上是一个采样任务。这是因为图像的生成是对三维场景进行采样以获得图像中每个像素(离散像素阵列)颜色值的过程。使用纹理映射,必须在不同条件下重新采样纹理像素以达到最好效果。为了生成动画的图片序列,需要以均匀时间间隔采样动画。为了简单起见,下述大多数材质以一维方式呈现。理论对2D维度同样适用。

上图展示了如何以均匀间隔对连续信号进行采样的过程,即离散化。采样过程的目标是以数字的方式表示信息。这样可以减少信息量。但是,需要重建采样信号以恢复原始信号,这是通过过滤采样信号实现的。失真出现于采样完成后。

一个典型的失真现象是旋转的车轮,由于轮辐移动速度比移动速度快得多,因此可能视觉上旋转不正确,可能向后或者不转,如下图所示,这称为时间混叠(temporal aliasing)。

一个常见的图形学例子是光栅化线段的锯齿闪烁,闪烁称作fireflies,以及棋盘格图案的贴图缩小时。

当信号以太低的频率采样时发生混叠,采样信号的频率看起来低于原始信号的频率,如下图所示。对于要正确采样的信号,采样频率必须要大于被采样信号最大频率的两倍。这被称为采样定理,采样频率叫做奈奎斯特率或奈奎斯特极限(Nyquist rate/limit)。使用了术语最大频率意味着信号必须有频带限制。

一个三维场景在以点采样渲染时几乎从没有频带限制。三角形边缘,阴影边界和其他现象的边缘产生不连续变化的信号,因此产生无限的频率。此外,无论采样的密度如何,物体还是可以足够小,以至于不会被采样。我们还总是用点采样,所以不可能完全避免混叠问题。不过有时我们可以知道信号何时有频带限制。一个例子是将纹理应用于表面,相对于像素的采样率,我们可以计算纹理的采样率。如果此频率低于奈奎斯特极限,则可以正确采样,若频率太高则需要算法限制纹理。

Reconstruction(重建)

给定有频带限制的采样信号,我们现在将讨论如何使用过滤器将采样信号重建为原始信号,三种常见的滤波器如下图所示,注意滤波器的面积始终为1,否则可能会使重建信号增大或减小(box filter,tent filter,sinc filter)。

box filter是最差的过滤器,如图,box filter放置在每个样本点上,进行缩放,使得最高点与样本点重合。所有这些缩放位移后的盒子函数总和为最终结果:

tent filter在点之间进行插值,由于结果是连续的,所以优于box filter:

tent filter的平滑度很差,为了获得完美重建,需要使用立项的低通滤波器(low-pass filter)。信号的频率分量是一个正弦波:$\sin(2\pi f)$,其中$f$是该分量的频率。所以,低通滤波器移除频率高于滤波器定义的特定频率的所有频率分量。 直观地,低通滤波器去除了信号的尖锐特征,即滤波器模糊了它。 理想的低通滤波器是sinc滤波器:

$$
sinc(x)=\frac{\sin(\pi x)}{\pi x}\tag{5.22}
$$

傅里叶分析理论(这里附上一篇文章)解释了为什么正弦滤波器是理想的低通滤波器。理想的低通滤波器是频域中的box filter,当它与信号相乘时,它会去除滤波器宽度以上的所有频率。 将box filter从频域转换为空间域会产生$sinc$函数。 同时,乘法运算被转换为卷积函数。

使用sinc filter后消除了频率高于采样率1/2的所有正弦波。假设采样频率是$f_s$,也就是采样间的距离为$1/f_s$,此时完美的过滤器是$sinc(f_s x)$,它消除了所有高于$f_s/2$的频率。但是sinc滤波器的宽度是无限的,在一些地方为负数,所以在实践中很少使用。

有一些广泛使用的滤波函数介于这些极端之间,如高斯滤波器。

在使用任何滤波器后,我们获得了连续的信号。然而我们不能直接显示连续信号,但我们可以使用它们将连续信号重新采样到另一种尺寸,即放大信号或减小信号。

Resampling(重采样)

重采样用于放大或缩小采样信号。 假设原始样本点位于整数坐标(0, 1, 2...),即样本之间的间隔为单位长度。此外,假设在重新采样之后,我们希望新样本点均匀地定位,样本之间的间隔为a。对于a> 1,进行缩小(下采样),对于a<1,进行放大(上采样)。

放大相对较容易,只需以期望的间隔重新采样重建的信号:

缩小时,就不能这么做了。原始信号频率对于采样频率过高以至于避免不了混叠的情况。这时需要用$sinc(x/a)$重构,然后对结果重采样。在图像上类似于先模糊它(去高频),然后以低分辨率重采样图像:

5.4.2 Screen-Based Antialiasing(基于屏幕的抗锯齿)

如果采样和过滤不当,三角形的边缘会产生明显的失真。阴影边界,镜面高光以及颜色快速变化的地方可能也会导致类似的问题。这些算法基于共同的线程,都是基于屏幕的,也就是他们仅对渲染管线输出的样本进行操作。没有最好的抗锯齿技术,它们在质量,捕获尖锐细节,移动期间的表现,内存消耗,GPU需求和速度方面各有不同优势。

之前提到的三角形边缘锯齿问题,如果每个屏幕网格单元使用更多样本并以某种方式混合这些样本,可以计算出更好的像素颜色:

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

$$
\boldsymbol{p}(x,y)=\sum\limits^n_{i=1}w_i\boldsymbol{c}(i,x,y)\tag{5.23}
$$

其中n是采样数量,$\boldsymbol{c}(i,x,y)$是样本颜色,$w_i$是在区间$[0,1]$中的权重,每份样本都会为最终的像素颜色提供影响。样本的位置取决于它的序号1,...,n,并且函数也可能会用到像素位置的整数位置$(x,y)$。换句话说,每个样本在屏幕网格中采样的位置是不同的,并且是可选的,采样模式在像素间变化。在实时渲染系统中以及大多数其他渲染系统中,样本通常是点样本。所以,函数$\boldsymbol{c}$可以看成是两个函数。首先,一个函数$\boldsymbol{f}(i,n)$检索需要采样的屏幕上浮点$(x_f,y_f)$的位置。然后对该位置进行采样,即检索该精确点处的颜色。选择了采样方案且渲染管线被配置去计算特定子像素位置处的样本,通常基于每帧(每应用)的设定。

抗锯齿中的另一个变量是$w_i$,即权重,这些权重的总和为1。实时渲染系统中使用的大多数方法给他们的样本提供了均匀的权重,即$w_i=\frac1n$。图形硬件的默认模式,即以像素中心为单个样本的模式,是上述方程最简单的情况。

对每个像素采集多于一个完整样本的抗锯齿算法称作超级采样(supersampling)或过采样(oversampling)。概念上最简单的全场景抗锯齿(full-scene antialiasing,FSAA)也称为超级抗锯齿(supersampling antialiasing,SSAA),以更高的分辨率渲染场景,然后过滤相邻的样本以创建图像。例如,假设需要1280×1024像素的图像。如果在屏幕外渲染2560×2048的图像,然后在屏幕上平均每个2×2像素区域,得到最终图像。每个像素有4个样本,使用box filter进行过滤。这种方法的成本很高,因为所有子样本都必须着色过滤,且每个样本携带着一个z-buffer。FSAA的主要优势是简洁。这个方法可以简化成仅在一个屏幕轴向以二倍采样,称为1×2或2×1超级采样。通常,使用2次幂分辨率和box filter是为了简单。NVIDIA的动态超分辨率(dynamic super resolution)功能是一种更精细的超级采样形式,场景以更高的分辨率渲染且应用了13样本的高斯滤波器用于生成显示的图像。

一个与超级采样相关的采样方法基于累计缓冲器(accumulation buffer)的想法。该方法使用一个和原始图像分辨率一样大小的缓冲区,但每个颜色通道都会多一些bits。为了获得2×2的采样,生成了4个图像,根据需要在屏幕x,y方向移动半个像素。每个生成的图像都是基于在网格单元内不同位置的采样。每帧重复渲染场景然后把结果拷贝到屏幕所花费的额外成本对于实时渲染系统而言非常昂贵。当性能不是那么重要的时候,这可以用来生成更高质量的图像,因为每个像素都可以使用任意数量的摆放在任何地方的样本。累积缓冲区曾经是一个独立的硬件,他直接在OpenGL API中受到支持,但在3.0版本中弃用。在现代GPU上,累积缓冲区可以通过pixel shader通过更高精度的颜色格式实现。

当物体边缘,镜面高光和锐利阴影等现象导致颜色突然变化时,需要额外的样本。可以让阴影边缘柔和,高光更平滑来避免失真。可以增加特定物体类型的尺寸,如电线,从而保证他们在任何方向都至少覆盖着1像素。物体边缘是主要的采样问题。可以通过渲染时检测对象边缘并考虑他们的影响,但这通常更慢且不稳定。但是,保守光栅化(conservative rasterization)和光栅顺序视图(rasterizer order views)开辟了新的可能性。

超级采样和累积缓冲之类的技术通过生成完全由单独计算着色和深度来实现的样本来工作。增益较低成本较高,因为每个样本都需要通过像素着色器。

多重采样抗锯齿(MSAA)减少了对每个像素一次的着色和样本间共享结果的消耗。例如,像素可以拥有每个片段四个$(x,y)$样本位置,每个样本具有他们自己的颜色和深度,但是对于应用于像素的每个片段,像素着色器只计算一次。如果片段覆盖了所有MSAA样本位置,则在像素中心处计算着色样本。相反如果覆盖样本位置较少,则可以移动着色样本的位置以更好表示覆盖位置。例如,这样可以避免着色采样从纹理边缘脱离。这种调整位置称为质心采样(centroid sampling) 或质心插值(centroid interpolation),如果启用,由GPU自动完成。质心采样避免了脱离三角形(off-triangle)的问题但可能导致微分计算返回不正确的值。

MSAA着重于更快速的对片段于像素中的覆盖进行着色以及共享着色结果。通过进一步解耦采样和覆盖操作,节省了更多内存。反过来说,更少的内存操作更快的渲染速度。NVIDIA于2006年推出了覆盖采样抗锯齿(coverage sampling antialiasing ,CSAA),AMD随后推出了增强质量抗锯齿(enhanced quality antialiasing,EQAA)。这些技术通过仅存储片段的覆盖范围来高速采样。例如,EQAA的"2f4x"模式存储两个颜色和深度,与四个采样位置共享:

颜色和深度不再为特定采样位置存储而是存储为一个表。然后每个位置只需要一个bit来指定两个存储位置中哪一个和它关联。指定覆盖位置的样本决定了每个片段对最终像素颜色的贡献值。如果超过了存储颜色数量,则逐出之前存储过的一个颜色值然后标记样本为未知。这种样本在最终颜色值中没有贡献。对于大多数场景,只有很少一部分像素包含3个或者更多的使用不同着色方式的可见不透明片段,所以这种方法在实际应用中表现良好。不过,为了获取最高质量,Forza Horizon 2采用了4×MSAA,而不是效率更好的EQAA。

当所有的几何体都渲染到多样本缓冲区后,执行解析(resolve)操作。此过程将样本颜色平均在一起以确定最终颜色。需注意,在高动态范围颜色值(HDR)时当使用multisampling会出现问题。在这种情况下,为了避免失真一般需要先把值进行色调映射(tone-map)再解析。这是十分昂贵的,所以可以使用更简单的近似色调映射方法。

默认情况下,MSAA使用box filter进行解析。2007年ATI推出了custom filter antialiasing(CFAA),具有使用tent filter的能力。这种模式已经被EQAA取代。在现代GPU上,pixel shader或者compute shader都可以访问MSAA样本并使用所需的任何重建滤波器,包括从周围的像素样本中采集的滤波器。较宽的滤波器可以减少混叠,但会丢失清晰的细节。使用自定义过滤器比默认的box filter更消耗性能,而且更宽的过滤器内核也会导致增加样本的访问成本。

NVIDIA的内置TXAA支持类似的方式,于一个比一像素更宽的范围使用更好的重建滤波器以得到更好的结果。它和较新的MFAA(multiframe antialiasing)方案都使用temporal antialiasing(TAA),这是一类使用先前帧的结果改善图像的技术。它允许程序员每帧设置MSAA的采样模式。这种技术可以解决诸如旋转车轮之类的混叠问题,并且可以改进边缘的渲染质量。

想象一下,通过生成一系列图像来“手动”执行采样模式,其中每个渲染使用像素内的不同位置采样。这种偏移是通过在投影矩阵上附加微小的平移来完成的。生成的图像越多结果越好。这种使用多个偏移图像的概念在TAA算法中使用。一个图像的生成,可能使用MSAA或其他方法,并混合先前的图像,通常2-4帧即可。较旧的图像可以指数级的减少权重,不过如果观看者和场景不动就会有发光的效果,所以一般仅对上一帧和当前帧进行相等的加权。对于每个帧在不同的子像素位置取样的样本,这些样本的加权比单帧更好的估算了边缘覆盖。因此,使用最近两帧平均的系统可以得到更好的结果。每帧都不需要额外的样本,这很吸引人。甚至可以使用temporal sampling生成较低分辨率的图像,该图像被放大到显示器的分辨率。另外需要许多样本以获得良好结果的照明方法或者其他技术,也可以改为每帧使用更少的样本,因为结果将在若干帧结果上混合。

虽然在没有额外采样成本的前提下为静态场景提供了抗锯齿,但是这种算法有一些问题。如果帧的权重不均匀,则静态场景中的对象可以呈现出微光。快速移动的物体或快速的相机移动可能导致重影,即由于先前帧的贡献而留在物体后面的拖尾。重影的一种解决方案是对缓慢移动的对象执行这种抗锯齿。另一个重要的方法是使用重投影(reprojection)来更好的关联前一帧和当前帧的对象。在这种方案中,对象生成运动矢量,它存储在单独的速度缓冲区中。从当前像素位置减去矢量以找到该对象的表面位置在前一帧的像素颜色。当前帧中的表面上的样本不太可能被丢失。由于不需要额外样本,所以需要较少的额外工作,因此近年来人们对这种算法采用广泛。还有需要注意的是,延迟渲染(deferred shading)与MSAA和其他一些多重采样方法不兼容。还有大量的根据使用场景和目标不同的改善质量与避免失真的技术(参照原书后引用)。

Sampling Patterns(采样模式)

有效的采样模式是减少失真的关键因素。Naiman发现水平方向和垂直方向的边缘失真是让人最难受的,45度的其次。旋转网格超级采样(rotated grid supersampling,RGSS)使用旋转的方形图案在像素内提供更多像素给垂直和水平方向。

RGSS模式是拉丁超立方体或N-rooks sampling,也就是n个样本放置在一个n×n的网格内,每行每列都有一个样本。与常规的2×2采样模式相比,这种模式和常规的2×2模式相比,对于捕获近乎垂直和水平的边缘更好,边缘更容易覆盖偶数量的样本,给出更少的影响级别。

N-rooks是创建一个好的采样模式的开端,但这还不够。例如,样本可以都是沿子像素网格的对角线排列,这会对几乎平行于该对角线的边缘给出不好的结果:

为了更好的采样,我们希望避免将两个样本放在彼此附近,且样本均匀分布在整个区域。为了形成这样的模式,诸如拉丁超立方采样(Latin hypercube sampling,LHS)的分层采样技术与诸如抖动(jittering),霍尔顿序列(Halton sequences)和泊松分布采样(Poisson disc sampling)等其他方法的结合。

在实践中,GPU制造商通常将采样模式硬连接到其硬件中以进行多重采样抗锯齿。下图显示了一些实践中使用的MSAA采样模式(从左到右依次是2x,4x,6x(AMD)和8x(NVDIA)采样):

对于Temporal antialiasing,覆盖模式是程序员想要的,因为样本位置可以逐帧变化。例如,Karis发现基本的Halton序列比任何GPU提供的MSAA模式都好使。Halton序列在空间中生成样本,这些样本看起来是随机的,但是差异很小,可就是说,它们很好的在空间中分布,没有一个聚簇在一起。虽然子像素网格的方式得到了更好的对每个三角形如何覆盖网格的近似,但它并不理想。场景可以由屏幕上任意小的对象组成,这就意味着没有采样率可以完美的捕获它们。如果这些小物体或特征形成了图案,则以恒定间隔采样会产生莫尔条纹(Moiré fringes )和其他干涉图案。

一种解决方案是使用随机取样(stochastic sampling),他提供更随机的模式。想象一下远处的细齿梳子,每个像素包含几根齿。一个普通的采样模式可能导致严重的失真。拥有比较无序的采样模式可以打破这些模式。随机化倾向于用噪声代替重复的失真效应。具有较少结构的图案有帮助,但当重复像素到像素时它仍会出现混叠。一种解决方案是在每个像素处使用不同的采样模式,或者随时间改变每个采样位置。交错采样,每组像素具有一个不同的采样模式,在过去是几十年中偶尔会在硬件中得到支持。例如ATI的SMOOTHVISION允许每个像素多达16个样本和多达16个不同的用户定义采样模式,这些模式可以打乱后重复使用。使用交错随机采样可以最大程度减少每个像素使用相同模式时形成的混叠伪影。

一些其他GPU支持的算法值得注意。NVDIA的"Quincunx"是一种允许样本影响多个像素的实施抗锯齿方案。"Quincunx"(梅花形)指的是五个物体的排列,四个形成一个正方形,第五个在中心。梅花形多重采样抗锯齿使用此模式,将四个外部样本放在像素的角落。 每个角样本值被分配给其四个相邻像素。每个角样本权重为1/8,中心权重为1/2。这种共享使得,每个像素平均只需要两个样本,但结果明显优于双样本FSAA方法。这种模式近似于2D中的tent filter,优于box filter。

Quincunx采样也可以用于temporal antialiasing,通过使用每个像素一个样本。每一帧从前一帧的每个轴平移半个像素,平移的方向在帧之间交替。前一帧提供像素角上的样本,然后使用双线性插值快速计算每个像素的贡献值。结果与当前帧平均。该方案在对齐移动对象上的问题依然存在,但是该方案本身很容易编码,且仅适用一个样本,就得到了很好的效果。

当在单帧中使用时,Quincunx的成本较低。RGSS模式更好的捕获水平和垂直边缘。FLIPQUAD模式结合了这两种优势,最初是为了移动图形开发的。每个像素两个样本,质量类似于RGSS。如下图所示(把RGSS模式下的采样点位移到边界和其他像素共享):

和Quincunx一样,双样本的FLIPQUAD模式也可以用于temporal antialiasing并扩展到两个帧。

Morphological Methods(形态学方法)

失真通常来自边缘,如几何形,锐利阴影或明亮的高光形成的。可以利用与失真相关的知识结构来处理失真问题,以得到更好的效果。2009年,Reshetov提出了形态抗锯齿(morphological antialiasing,MLAA)。"morphological"指"与结构或形状有关"。Reshetov的论文重新研究了多重采样方法的替代方案,强调搜索和重建边缘。这种形式的抗锯齿作为后处理来执行。 也就是说,渲染以通常的方式完成,然后将结果传送到生成抗锯齿结果的过程。 自2009年以来,已开发出多种技术。那些依赖于额外缓冲区(深度法线等)的那些可以提供更好的结果,例如子像素重建抗锯齿(subpixel reconstruction antialiasing,SRAA),但后来仅适用于几何边缘的抗锯齿。分析方法,如几何缓冲抗锯齿(geometry buffer antialiasing,GBAA)和距离边缘抗锯齿(distance-to-edge antialiasing,DEAA),让渲染器计算关于三角形边缘所在位置的附加信息,如,边缘离像素中心的距离。一般只需要颜色缓冲区,这意味着他们还可以改善阴影,高光、或者各种之前应用的后处理技术(post-processing,如轮廓渲染)的边缘。例如,定向局部抗锯齿(directionally localized antialiasing,DLAA)基于垂直边缘应该水平模糊,水平边缘应该垂直模糊。

更精细的边缘检测形式试图找到可能包含任何角度边缘的像素并确定其覆盖范围。检查潜在边缘周围的邻域,目标是尽可能重建原始边缘所在的位置。然后可以使用边缘对像素的影响来混合相邻像素的颜色:

Iourcha等人通过检查MSAA样本来提高边缘识别的结果。边缘检测和混合可以提供比基于样本的算法更好的结果。例如,每个像素使用4个样本的技术只能提供5个混合级别(覆盖了几个),检测到边缘的部分可以拥有更多样本位置以得到更好的结果。

基于图像的算法有一些坑。首先,如果两个对象之间的色差低于算法的阈值,则可能检测不到边缘。存在三个或更多个不同表面重叠的像素难以解释。具有高对比度或高频元素的表面,其颜色在像素之间快速变化,可能导致算法错过边缘。特别是,当对其应用形态抗锯齿时,文本质量通常会受到影响。对象的角落可能是一个挑战,一些算法赋予它们圆润的外观。假设边缘是直的也会对曲线产生不利影响。 单个像素的改变可能导致重建边缘的方式发生大的变化,这可能会在帧到帧之间产生明显的伪像。改善该问题的一种方法是使用MSAA来改善边缘检测。

形态抗锯齿方案仅使用提供的信息。 例如,宽度上比像素宽的物体(例如电线或绳索)如果覆盖不到中心位置,可能在屏幕上具有间隙。 在这种情况下,采用更多样本可以提高质量;仅使用基于图像的抗锯齿是不行的。 此外,执行时间可以根据查看的内容而变化。

综上,基于图像的方法可以通过适度的内存和处理成本提供抗锯齿支持,因此它们被用于许多应用程序中。color-only的版本也与渲染管道分离,使其易于修改或禁用,甚至作为GPU驱动程序选项公开。两种最流行的算法是快速近似抗锯齿(fast approximate antialiasing,FXAA)和亚像素形态抗锯齿(subpixel morphological antialiasing,SMAA),部分原因是它们都提供了健硕且免费的为了多种硬件实现的源代码实现。两种算法都仅使用颜色作为输入,SMAA具有能够访问MSAA样本的优点。每个都提供了丰富的设置,在速度和质量之间进行权衡。成本通常在每帧1到2毫秒的范围内。最后,两种算法也可以利用temporal antialiasing。 Jimenez 提出了一种改进的SMAA实现,比FXAA更快,并描述了时间抗锯齿方案。最后推荐Reshetov和Jimenez 对形态学技术的研究及其在游戏中应用。


5.5 Transparency, Alpha, and Compositing(透明,alpha,合成)

光线穿过半透明物体有许多不同的方式。 对于渲染算法,这些可以大致分为基于光的效果和基于视图的效果。 基于光的效果是指那些会使光线衰减或转向的物体,导致场景中的其他物体也被点亮并改变了渲染的方式。 基于视图的效果是半透明物体本身被渲染的效果。

本节将讨论最简单的基于视图的透明度,其中半透明对象充当其后面对象颜色的衰减器。 更精细的基于视觉和光的效果,例如磨砂玻璃,光的弯曲(折射),由于透明物体的厚度引起的光衰减,以及由于视角引起的反射率和透射率变化将在后面的章节中讨论。

透明效果的一种实现方法称为screen-door transparency。 我们的想法是使用像素对齐的棋盘填充的模式渲染透明三角形。 也就是说,三角形每隔一个像素渲染一次,从而使其后面的对象部分可见。 通常屏幕上的像素足够接近,棋盘图案本身不可见。 该方法的主要缺点是通常只能在屏幕的一个区域上令人信服地呈现一个透明对象。 例如,如果透明的红色对象和透明的绿色对象呈现在蓝色对象的顶部,则三种颜色中只有两种可以出现在棋盘图案上。 此外,50%的棋盘是有限的。 其他较大的像素掩模可用于提供其他百分比,但这些我们趋向于产生可检测的模式。 同样的想法用于剪切纹理边缘的抗锯齿,但在子像素级别,使用称为alpha to coverage的特征。

由Enderton等人介绍,随机透明度使用子像素screen-door遮罩结合随机采样。 通过使用随机点的模式来表示片段的α覆盖情况,创建合理但有噪声的图像。 见下图,每个像素需要大量样本才能使结果看起来合理,并且所有子像素样本都需要相当大的存储量。 有吸引力的是它不需要混合,并且抗锯齿,透明度和产生像素部分覆盖的任何步骤都由单一机制实现。

大多数透明度算法将透明对象的颜色与其后面对象的颜色混合在一起。 为此,需要α混合的概念。 当在屏幕上呈现对象时,RGB颜色和z缓冲深度与每个像素相关联。 还可以为对象覆盖的每个像素定义另一个称为alpha(α)的分量。 Alpha是描述给定像素的对象片段的不透明度和覆盖度的值。1.0的alpha表示对象是不透明的,完全覆盖了像素的感兴趣区域; 0.0表示像素根本不被遮挡,即片段完全透明。

像素的alpha可以表示不透明度,覆盖率或两者,取决于具体情况。 例如,肥皂泡的边缘可能覆盖了像素的四分之三,0.75,并且几乎是透明的,有9/10的光通过眼睛,也就是十分之一的不透明,0.1。 那么它的α将是0.75×0.1 = 0.075。 但是,如果我们使用MSAA或类似的抗锯齿方案,则样本本身会考虑覆盖范围。 四分之三的样品会受到肥皂泡的影响。 在这些样本中,我们将使用0.1不透明度的值作为alpha。

5.5.1 Blending Order(混合顺序)

要使对象显示为透明,它将在现有场景的顶部呈现,其alpha小于1.0。 由对象覆盖的每个像素将从像素着色器接收结果RGBα(也称RGBA)。 将此片段的值与原始像素颜色混合通常使用over运算符完成:

$$
\boldsymbol{c}_o=\alpha_s\boldsymbol{c}_s+(1-\alpha_s)\boldsymbol{c}_d\quad[\boldsymbol{over}\text{ operator}]\tag{5.24}
$$

其中$\boldsymbol{c}_s$是透明对象(source)的颜色,$\alpha_s$是对象的alpha,$\boldsymbol{c}_d$是混合前的像素颜色(destination),$\boldsymbol{c}_o$是由于将透明对象放在现有场景上而产生的颜色。 渲染管线传入$\boldsymbol{c}_s,\alpha_s$,像素的原始颜色$\boldsymbol{c}_d$被$\boldsymbol{c}_o$替换。 如果输入的RGBα实际上是不透明的($\alpha= 1.0$),则该等式简化为通过对象的颜色完全替换像素的颜色。

over运算符为渲染对象提供半透明的外观。使用over运算模拟薄纱面料的真实效果。 织物后面的物体的视图部分模糊:织物的线是不透明的。 在实践中,松散的织物具有随角度变化的α覆盖。

over运算符在模拟其他透明效果时并不好,最明显的是透过彩色玻璃或塑料观察。 在现实世界中保持在蓝色物体前面的红色滤光片通常使蓝色物体看起来很暗,因为该物体反射的光可以通过红色滤光片。 见下图(左为织物,右为滤光片)。当over用于混合时,结果是红色和蓝色的一部分加在一起。 最好将两种颜色相乘,并在透明物体上添加反射。

在基本的混合阶段运算符中,over是常用于透明效果的运算符。另一个操作是加法混合(additive blending),像素值被简单地求和:

$$
\boldsymbol{c}_o=\alpha_s\boldsymbol{c}_s+\boldsymbol{c}_d\tag{5.25}
$$

这种混合模式可以很好地适用于发光效果,如闪电或火花,只会使后面的像素变亮。但是,此模式计算出来的透明度看上去不正确,因为不透明的表面看起来没有过滤。对于若干层的半透明表面,如烟或火,additive混合具有使颜色更饱和的效果。

要正确渲染透明对象,我们需要在不透明对象之后绘制它们。 这是通过在关闭混合的状态下首先渲染所有不透明对象,然后使用over混合渲染透明对象来完成的。从理论上讲,我们总是可以打开over,因为1.0的不透明alpha会给出源颜色并隐藏目标颜色,但这样做会更消耗资源。

z-buffer的限制是每个像素仅存储一个物体对象。如果多个透明对象在相同的像素重叠,则单独的z-buffer无法等待并稍后解析所有可见对象。当在任何给定像素上对透明表面应用over时,通常需要以从后到前的顺序呈现。不这样做会给出错误的视觉感受。实现这种排序的一种方法是通过质心沿视图方向的距离对单个对象进行排序,但在一些情况下会有问题。首先,这种顺序只是一种近似,因此被排到更远的对象可能位于被排到更近的对象的前面。对于所有视角,互穿的物体不可能在per-mesh的层面上解决。

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

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

$$
\boldsymbol{c}_o=\alpha_d\boldsymbol{c}_d+(1-\alpha_d)\alpha_s\boldsymbol{c}_s\quad[\boldsymbol{under}\text{ operator}],\\
\boldsymbol{a}_o=\alpha_s(1-\alpha_d)+\alpha_d=\alpha_s-\alpha_s\alpha_d+\alpha_d\tag{5.26}
$$

请注意,under运算符要求目标保持alpha值,而over不是。换句话说,在下面混合的表面是透明的,因此需要具有$\alpha$值。 under的公式就像结束了,但是交换了source和destination。 另外,请注意计算$\alpha$的公式是与顺序无关的,因为source和destination $\alpha$可以交换,结果是相同的最终$\alpha$。

$\alpha$的等式来自于将片段的$\alpha$视为覆盖率。Porter和Duff 注意到,由于我们不知道任何一个片段的覆盖区域的形状,我们假设每个片段与其$\alpha$成比例地覆盖另一个片段。例如,如果$\alpha_s=0.7$,则像素以某种方式被分成两个区域,其中0.7被源片段覆盖而0.3不被覆盖。 无需其他知识,例如$\alpha_d=0.6$的destination片段将与source段成比例地重叠。 该公式具有几何解释,如下图:

5.5.2 Order-Independent Transparency(顺序无关的透明度)

under运算通过将所有透明对象绘制到单独的颜色缓冲区,然后使用over运算将这些透明对象绘制到场景上面。 under运算符的另一个用途是用于执行称为深度剥离(depth peeling)的顺序无关透明度(Order-Independent Transparency,OIT)算法。与顺序无关意味着应用程序不需要执行排序。深度剥离背后的想法是使用两个z-buffer和多个pass。首先,进行一个pass,使所有表面(透明的和不透明的)的z深度存储在第一个z-buffer中。在第二个pass中,渲染所有透明对象。

如果对象的z深度与第一个z-buffer中的值匹配,我们知道这是最近的透明对象,并将其RGBA保存到单独的颜色缓冲区。我们还通过保存超过第一个z深度且最近的透明对象的z深度来“剥离”这个层。这个z深度是第二最接近的透明对象的深度。连续传递继续剥离并使用under添加透明层。我们在经过一些次后停止,然后将透明图像混合到不透明图像的顶部。

已经开发了关于该方案的若干变体。 例如,Thibieroz给出了一种back to front工作的算法,其优点是能够立即混合透明值,这意味着不需要单独的alpha通道。 深度剥离的一个问题是需要知道要有多少pass可以捕获所有透明层。 一个硬件解决方案是提供一个像素绘制计数器,提供渲染过程中写入了多少像素;当没有像素通过pass时,渲染就完成了。 使用under的优点是眼睛首先看到的透明层先渲染。每个透明表面总是增加它所覆盖的像素的alpha值。如果像素的alpha值接近1.0,则混合的贡献使像素几乎不透明,因此更远的对象将具有可忽略的效果。 font-to-back剥离可以比back-to-font短,当通过渲染的像素数量低于某个最小值时,或者达到了指定数量的pass时。这对于从back-to-font的剥离不起作用,因为最接近的(通常是最重要的)层是最后绘制的,因此可能会因提前终止而丢失。

虽然深度剥离是有效的,但它可能很慢,因为每层剥离是所有透明物体的单独渲染通道。 Bavoil和Myers提出了双深度剥离(dual depth peeling),在每个pass中每次剥离两个深度层,最近和最远的,从而将渲染通道的数量减少一半。 刘等人,探索一种桶排序(bucket sort)方法,其在单个pass中捕获多达32个层。这种方法的一个缺点是它需要相当大的内存来保持所有层的排序顺序。 通过MSAA或类似方法进行抗锯齿会增加天文数字的成本。

以可以交互的速率正确地将透明对象混合在一起的问题不是我们所缺乏的算法问题,而将这些算法有效地映射到GPU是。1984年,Carpenter提出了A-buffer,另一种形式的多重采样。

在A-buffer中,每个被渲染的三角形都会为每个屏幕网格单元创建一个覆盖遮罩,可能是完全或者不完全覆盖的。每个像素存储所有相关片段的列表。不透明的片段可以剔除它们后面的片段,类似于z-buffer。所有片段都是为了透明表面存储的。一旦形成所有列表,通过遍历片段并解析每个样本产生最终结果。

通过DirectX 11 中公开的新功能,可以在GPU上创建片段的链接列表。使用的功能包括无序访问视图(unordered access views,UAV)和原子操作(atomic operations)。MSAA抗锯齿得以实现是因为具有访问覆盖遮罩和计算每个样本像素着色器的能力。该算法通过栅格化每个透明表面并在长数组中插入生成的片段来工作。除了颜色和深度之外,还生成一个单独的指针结构,将每个片段链接到前一个片段。然后执行单独的pass,渲染屏幕四边形,以便在每个像素处计算像素着色器。此着色器通过跟踪链接检索每个像素处的所有透明片段。检索到的每个片段依次与先前的片段一起排序。然后将该排序列表反向混合到前面以给出最终的像素颜色。由于通过像素着色器执行混合,因此如果需要,可以为每个像素指定不同的混合模式。 GPU和API的不断发展通过降低使用原子运算符的成本来提高性能。

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

GPU通常具有内存资源,例如预先分配的buffer和array,链表方法也不例外。用户需要确定内存够用,内存不足会导致明显的问题。Salvi和Vaidyanathan 提出了一种解决这个问题的方法,多层alpha混合(multi-layer alpha blending),使用英特尔引入的称为像素同步(pixel synchronization)的GPU功能。见下图,此功能提供可编程混合,比原子操作开销少。

他们的方法改进了存储和混合,以便在内存耗尽时优雅地降级。粗略的排序顺序可以使他们的计划受益。 DirectX 11.3引入了光栅化顺序视图(第3.8节,rasterizer order views),它是一种buffer,允许在支持此功能的任何GPU上实现此透明度方法。移动设备具有类似的技术,称为tile local storage,允许它们实现多层alpha混合。然而这种机制具有可能昂贵的性能成本。

这种方法建立在Bavoil等人引入的k-buffer的基础之上。 其中前几个可见层被保留并排序,更深的层被丢弃或合并。 Maule等人,使用k缓冲区并通过使用加权平均来处理这些更远的深层。 加权和(weighted sum)和加权平均(weighted average)透明度技术是与顺序无关的(order-independent),是单pass的,并且几乎在每个GPU上都可以运行。 问题是他们没有考虑对象的排序。 因此,例如,使用阿尔法来表示覆盖范围,一条薄纱蓝色围巾上面的薄纱红色围巾呈现紫罗兰色,而不是看到一条带有蓝色透过的红色围巾。 虽然近似不透明的物体会产生不好的结果,但这类算法对于可视化非常有用,并且适用于高度透明的表面和粒子:

在加权和透明度中:

$$
\boldsymbol{c}_o=\sum\limits^n_{i=1}(\alpha_i\boldsymbol{c}_i)+\boldsymbol{c}_d(1-\sum\limits^n_{i=1}\alpha_i)\tag{5.27}
$$

其中n是透明表面的数量,$\boldsymbol{c}_i$和$\alpha_i$表示透明度值的集合,$\boldsymbol{c}_d$是场景的不透明部分的颜色。 当渲染透明表面时,两个和被累积并分别存储,并且在透明度pass结束时,在每个像素处计算等式。 这种方法的问题是第一个和可能产生大于(1.0,1.0,1.0)的颜色值,背景颜色可能会有反色问题,因为α的总和可以超过1.0。

加权平均方程避免了这些问题:

$$
\boldsymbol{c}_{\text{sum}}=\sum\limits^n_{i=1}(\alpha_i\boldsymbol{c}_i),
\alpha_{\text{sum}}=\sum\limits^n_{i=1}\alpha_i,\\
\boldsymbol{c}_{\text{wavg}}=\frac{\boldsymbol{c}_{\text{sum}}}{\alpha_{\text{sum}}},
\alpha_{\text{avg}}=\frac{\alpha_{\text{sum}}}{n},\\
u=(1-\alpha_{\text{avg}})^n,\\
\boldsymbol{c}_o=(1-u)\boldsymbol{c}_{\text{wavg}}+u\boldsymbol{c}_d\tag{5.28}
$$

第一行表示在透明度渲染期间生成的两个单独buffer中的结果。每个表面对于$\boldsymbol{c}_{\text{sum}}$提供一个由透明度决定的权重;几乎不透明的表面会产生更多的颜色,而几乎透明的表面几乎没有影响。 通过将$\boldsymbol{c}_{sum}$除以$\alpha_{sum}$,我们得到加权平均透明度颜色。 值$\alpha_{avg}$是所有$\alpha$值的平均值。 对于n个透明表面,值u是对该平均$\alpha$应用n次之后目标的估计可见性(不透明场景)。 最后一行实际上是over运算符,$(1-u)$表示源的$\alpha$。

加权平均值的一个限制是,对于相同的alpha,无论顺序如何,它都会均匀地混合所有颜色。McGuire和Bavoil引入了加权混合顺序无关透明度,以提供更有说服力的结果。 在他们的公式中,到表面的距离也会影响权重,更接近的表面会产生更大的影响。 此外,不是对$\alpha$进行平均,而是通过将项$(1-\alpha_i)$相乘并从1减去来计算u,给出表面集的真实α覆盖。 这种方法可以产生更具视觉效果的结果,如下图(两个不同角度摄像机位置的效果):

缺点是在较大的场景中,彼此靠近的物体可能具有距离几乎相等的权重,使得结果与加权平均的方法不太一样。 此外,随着相机与透明物体的距离发生变化,深度权重也会随着变化,但这种变化是渐进的。

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

还有其他算法使用了此处介绍的几种技术中的元素。 例如,Wyman把先前的工作按照内存要求,插入和合并方法,是否使用了alpha或几何覆盖以及如何处理丢弃片段来进行分类。 他通过寻找先前研究中的差距,提出了两种新方法。 他的随机分层alpha混合方法使用k-buffers,加权平均和随机透明度。 他的另一种算法是Salvi和Vaidyanathan方法的变体,使用覆盖遮罩而不是alpha。

鉴于各种类型的透明内容,渲染方法和GPU功能,没有完美的渲染透明对象的解决方案。 我们将感兴趣的读者引用到Wyman的论文和Maule等人的交互式透明度算法的更详细的调查。McGuire的演示给出了更广泛的视野,贯穿其他相关现象,如体积照明,彩色透射和折射,本书稍后将对此进行更深入的讨论。

5.5.3 Premultiplied Alphas and Compositing(预乘透明度和合成)

over运算符还用于将照片或渲染物体混合在一起。此过程称为合成(Compositing)。在这种情况下,在每个像素的对象alpha值与RGB颜色值一起存储。由alpha通道形成的图像有时被称为遮罩(matte)。它显示了对象的轮廓形状。 然后可以使用该RGBA图像将其与其他这样的元素或背景混合。

使用合成RGBA数据的一种方法是使用预乘透明度(premultiplied alpha,也称为associated alpha)。也就是说,RGB值在使用之前乘以alpha值。 这使得合成方程更有效:

$$
\boldsymbol{c}_o=\boldsymbol{c}'_s+(1-\alpha_s)\boldsymbol{c}_d\tag{5.29}
$$

其中$\boldsymbol{c}'_s$是预乘后的源,取代公式5.25中的$\alpha_s\boldsymbol{c}_s$。 预乘alpha还可以在不改变混合状态的情况下使用over和additive混合,因为在混合期间添加了源颜色。请注意,对于预乘的RGBA值,RGB分量通常不大于alpha值,但可以这样做可以创建特别明亮的半透明颜色。

渲染合成图像与预乘alphas自然地吻合。在黑色背景上渲染的使用抗锯齿的不透明对象默认提供预乘值。 假设白色(1,1,1)三角形覆盖其边缘的一些像素的40%。执行(非常精确的)抗锯齿后,像素值将被设置为0.4的灰度,也就是我们将保存(0.4,0.4,0.4)作为该像素的颜色。如果存储,则α值也将是0.4,这是三角形覆盖的区域。 RGBA值将是(0.4,0.4,0.4,0.4),也就是预乘值。

存储图像的另一种方式是使用 unmultiplied alphas(也称unassociated alphas,nonpremultiplied)。也就是:RGB值没有乘以alpha值。对于白色三角形示例,颜色将为(1, 1, 1, 0.4)。 它具有存储三角形原始颜色的优点,但在显示之前,此颜色始终需要与存储的alpha相乘。 每当执行过滤和混合时,最好使用预乘,因为诸如线性插值之类的操作无法使用未经过相乘的alpha进行正确的操作,会产生诸如物体边缘周围的黑色条纹之类的伪像。预乘允许更干净的的理论处理。

对于图像处理程序,非预乘alpha可用于对照片遮罩而不会影响基础图像的原始数据。此外,非预乘alpha意味着可以使用颜色通道的完整精度范围。也就是说,必须注意将未经过相乘的RGBA值正确地与线性空间转换。 例如,没有浏览器可以正确地执行此操作,也不适合这样做,因为预计会出现错误的行为。 支持alpha的图像文件格式包括PNG(仅限非预乘alpha),OpenEXR(仅预乘alpha)和TIFF(两种类型的alpha)。

与alpha通道相关的概念是色相抠像(chroma-keying)。 这是一个来自视频制作的术语,其中演员在绿色或蓝色屏幕上拍摄并与背景混合。 在电影工业中,这个过程称为绿幕或蓝幕。 这里的想法是指定特定的色相(用于电影工作)或精确值(用于计算机图形)被认为是透明的;只要检测到背景,就会显示背景。 这允许通过仅使用RGB颜色给图像赋予轮廓形状;不需要存储alpha。 该方案的一个缺点是物体在任何像素处完全不透明或透明,即,α实际上仅为1.0或0.0。 例如,GIF格式允许一种颜色指定为透明。


5.6 Display Encoding(显示编码)

当我们计算照明,纹理或其他操作的效果时,假定使用的值是线性的。这意味着加法和乘法按预期工作。 但是,我们必须考虑的是,为了避免各种视觉伪像,显示缓冲区和纹理使用非线性编码。一个简单的解释是:着色器输出颜色在$[0,1]$范围内,并做1/2.2次幂,执行所谓的伽马校正(gamma correction)。对输入的纹理和颜色做相反的事情。在大多数情况下,可以让GPU帮你搞定。

我们从CRT显示器开始。在数字成像的早期,CRT显示器是主流。 这些器件在输入电压和显示辐射之间表现出幂次关系。 随着施加到像素的能量水平增加,发射的辐射不会线性增长,但与上升到大于1的幂成比例。 例如,假设幂为2。相对于一个设置为1.0的像素,设置为50%的像素将发出四分之一的光量,$0.5^2=0.25$。尽管LCD和其他显示技术具有与CRT不同的固有色调响应曲线,但它们是用转换电路制造的,这使得它们模仿CRT的响应。

该幂函数几乎匹配人类视觉的亮度敏感度的倒数。 这种幸运的巧合的结果是编码大致上是感知一致的(perceptually uniform)。也就是说,一对编码值N和N + 1之间的感知差异在可显示范围内大致恒定。使用阈值对比(threshold contrast)测量,我们可以在宽范围的条件下检测亮度差异约1%。 当颜色存储在有限精度的显示缓冲区中时,这种接近最佳的值分布可最大限度地减少条带伪影。纹理使用相同的编码,同样具备这样的优势。

显示传递函数(display transfer function)描述了display buffer中的数字值与显示器发出的辐射亮度之间的关系。因此,它也被称为电光传递函数(electrical optical transfer function, EOTF)。显示传输功能是硬件的一部分,计算机显示器,电视和电影放映机有不同的标准。 对于过程的最终,图像和视频捕获设备,还存在一个标准的传递函数,称为光电传递函数(optical electric transfer function, OETF)。

当编码线性颜色值用于显示时,我们的目标是抵消显示传递函数的作用,使得我们计算的任何值将发射相应的辐射水平。 例如,如果我们的计算值加倍,我们希望输出辐射亮度加倍。 为了保持这种关系,我们应用显示传递函数的反函数来抵消其非线性效应。这种使显示器响应曲线无效的过程也称为伽马校正(gamma correction)。 解码纹理值时,我们需要应用显示传递函数来生成用于着色的线性值:

个人计算机显示器的standard transfer function由称为sRGB的颜色空间规范定义。大多控制GPU的API可以设置为在从纹理读取值或写入颜色缓冲区时自动应用正确的sRGB转换。 如第6.2.2节所述,mipmap的生成也考虑sRGB编码。通过首先转换为线性值然后执行插值,纹理值之间的双线性插值将正常工作。通过将存储的值解码回线性值,混合新值,然后对结果进行编码,可以正确完成Alpha混合。

在渲染的最后阶段应用转换非常重要,此时将值写入frame buffer以进行显示。如果在显示编码后应用post-processing,则会以非线性值进行计算,这通常是不正确的,经常会造成失真。显示编码可以被认为是一种压缩的形式,最能保持值所对应的感知效果。一个好方法是我们使用线性值来执行物理计算,每当我们想要显示结果或访问可显示的图像(如颜色纹理)时,我们需要将数据转入或转出其显示编码形式,使用适当的编码或解码变换。

如果您确实需要手动应用sRGB,则可以使用标准转换公式或一些简化版本。实际上,显示器由每个颜色通道的多个位控制。例如,对于消费级显示器为8-bits,给出一组从0到255的范围。 这里我们将显示编码的级别表示为范围[0.0, 1.0],忽略浮点数。线性值也在这个区间[0.0, 1.0],表示浮点数。我们用x表示这些线性值,用y表示存储在帧缓冲器中的非线性编码值。要将线性值转换为sRGB非线性编码值,我们应用sRGB显示传递函数的反转:

$$
y=f^{-1}_{sRGB}(x)=
\begin{cases}
1.055x^{1/2.4}-0.055,\quad &where\quad x>0.0031308,\\
12.92x,&where \quad x\le0.0031308
\end{cases}\tag{5.30}
$$

x表示线性RGB三元组的通道。该等式应用于每个通道,这三个生成的值驱动显示。如果手动应用转换功能,请务必小心。 一个错误来源是使用编码颜色而不是其线性形式,另一个错误是使用解码或编码两次。

上面的两个变换表达式的下面那个是一个简单的乘法,它源于数字硬件的需要,使变换完全可逆。上面的表达式,将值提升为幂,几乎适用于整个x的输入范围[0.0, 1.0]。考虑到偏移和缩放,这个函数非常接近一个更简单的公式:

$$
y=f^{-1}_{\text{display}}(x)=x^{1/\gamma}\tag{5.31}
$$

其中$\gamma=2.2$。这就是gamma correction的基础。

正如必须对计算值进行编码以进行显示一样,拍摄的图像或者摄像机捕获的图像在用于计算之前必须转换为线性值。在显示器或电视上看到的任何颜色都有一些显示编码的RGB三元组,您可以从屏幕截图或颜色选择器中获取。这些值是以PNG,JPEG和GIF等文件格式存储的格式,可以直接发送到frame buffer以便在不进行转换的情况下显示在屏幕上。 换句话说,无论在屏幕上看到什么,都是按照定义显示编码数据。在着色计算中使用这些颜色之前,我们必须将此编码形式转换回线性值。我们需要从显示编码到线性值的sRGB变换:

$$
x=f_{sRGB}(y)=
\begin{cases}
(\frac{y+0.055}{1.055})^{2.4},&where\quad y\gt0.04045,\\
\frac{y}{12.92},&where\quad y\le0.04045,
\end{cases}\tag{5.32}
$$

y表示标准化的显示通道值,即存储在图像或frame buffer中的值,表示为范围[0.0, 1.0]。此解码函数与我们之前的sRGB公式相反。这意味着如果纹理被shader读取并且输出后没有变化,它将像预期的那样与处理前相同。解码功能与显示传输功能相同,因为存储在纹理中的值已被编码以正确显示。我们转换为了线性值,而不是显示。

更简单的gamma display transfer function是公式5.31的反函数:

$$
x=f_{display}(y)=y^\gamma\tag{5.33}
$$

有时会看到更简单的转换对,尤其是在移动应用或浏览器应用上:

$$
y = f^{-1}_{simpl}(x)=\sqrt{x},\\
x = f_{simpl}(y)=y^2\tag{5.34}
$$

也就是说,取平方根进行显示,并将该值乘以其自身的倒数。 虽然粗略近似,但这种转换比完全忽略问题更好。

如果我们不执行伽玛矫正,则较小的线性值在屏幕上会显得更暗淡。也就是色相会偏离。因为$\gamma= 2.2$。我们希望从显示的像素发出与线性计算值成比例的辐射,这意味着我们必须将线性值提高到1/2.2次幂。

线性值0.1给出0.351,0.2给出0.481,0.5给出0.730。 如果不进行编码,这些使用的值将导致显示器发出的辐射度低于所需的辐射率。请注意,0.0和1.0始终保持不变。 在使用伽马校正之前,暗表面颜色通常由建模场景的人员人为地提升,以抵消显示变换。

忽略伽马校正的另一个问题,是用非线性值执行了物理的线性辐射值正确的阴影计算。 如下图,两个光源叠加的效果,左侧的没有使用gamma矫正,导致中间叠加后的部分显得格外亮,而右边使用了伽马校正的显得很恰当:

忽略伽马校正也会影响抗锯齿边缘的质量。例如,假设三角形边缘覆盖四个屏幕网格单元。 三角形的归一化辐射亮度为1(白色),背景为0(黑色)。从左到右,网格单元分别被覆盖$\frac18,\frac38,\frac58,\frac78$。所以如果我们使用box filter,我们希望辐射度为0.125,0.375,0.625和0.875。正确的抗锯齿应该执行在线性值的基础上,如果不是,则会导致边缘变形:

sRGB标准创建于1996年,已成为大多数计算机显示器的标准。 然而,从那时起,显示技术已经发展。 已经开发出更亮并且可以显示更广泛颜色的监视器。 第8.1.3节讨论了彩色显示和亮度,第8.2.1节介绍了高动态范围显示的显示编码。 Hart的文章特别全面的讨论了有关高级显示的更多内容。


Further Reading and Resources

  • Pharr等人,更深入地讨论采样模式和抗锯齿。
  • Teschner的课程笔记显示了各种采样模式生成方法。
  • Drobot贯穿了之前关于实时抗锯齿的研究,解释了各种技术的属性和性能。
  • 关于各种形态抗锯齿方法的信息可以在相关SIGGRAPH课程的注释中找到。
  • Reshetov和Jimenez提供了游戏中使用的形态学和有关temporal antialiasing的最新内容。
  • 对于透明度研究,McGuire的演讲和Wyman的作品。
  • Blinn的文章“什么是像素“在讨论不同的定义时提供了几个计算机图形领域的精彩之旅。
  • Blinn的脏像素和符号,符号,符号书包括一些关于过滤和抗锯齿的介绍性文章。
  • 关于alpha,合成和伽马校正的文章.Jimenez的演讲详细介绍了用于抗锯齿的最先进技术。
  • Gritz和d'Eon对伽马校正问题进行了很好的总结。
  • Poynton的书给出了各种媒体中伽马校正的可靠报道,以及其他与颜色相关的主题。
  • Selan的白皮书是一个较新的来源,解释了显示编码及其在电影行业的应用,以及许多其他相关信息。

(Chapter 5 end.)