“A good picture is equivalent to a good deed.”
——Vincent Van Gogh
(译者解读的意思:好的图像和真实的好的事物是等价的。——梵高)
当你渲染三维物体的图像时,模型不应该仅有几何形状,他们应该也拥有所期望的视觉外观。依据着应用的不同,这个外观可以是写实主义的——外观几乎和真实物体的摄影别无二致,也可以出于一些创造性的原因而选择风格化的外观。图5.1中展示了这两者。
图 5.1. 上部的图片是由Unreal Engine虚幻引擎渲染的写实的风景。下部的图片出自游戏Firewatch,由Campo Santo制作。它是以一种插画艺术风格来设计的。(上面的图片由Gokhan karadayi提供,虾米那的图片由Campo Santo提供)
本章将讨论同样适用于照片级真实感和样式化渲染的着色层面。15章节会专门讨论风格化渲染,而本书的一大块儿内容,从第9章到第14章,将会集中于写实渲染中应用的基于物理的方法。
决定一个待渲染物体外观的第一步就是选择一个着色模型(shading model),它是用来描述,基于表面朝向、观察方向、以及光照等因素下物体颜色是如何变化的。
例如,我们会使用Gooch着色模型(Gooch shading model)的变种。这是非写实渲染的一种形式(第15章的主题)。Gooch着色模型被设计用来提高技术说明中细节的易读性。
Gooch着色背后的基本思想是去比较表面法线和光的位置。如果法线指向光源,会使用一个较暖的色调来为表面着色;否则的话,则使用一个较冷的色调。中间的角度在这些色调之间进行插值,这些色调则会基于用户提供的表面颜色。在这个例子中,我们增加一种风格化的“高光”效果到模型上,从而给表面一种闪闪的外观。图5.2显示了实际的着色模型。
图 5.2. 一种结合了Gooch着色和高光效果的风格化着色模型。顶部图片用一种中性表面颜色展现了一个复杂物体。底部图片展示了不同表面颜色的球。(中国龙网格体由计算机图形档案提供,原始模型出自斯坦福3D扫描库)
着色模型中经常有一些用来控制外观变化的属性。配置这些属性就是决定这些物体外观的另一个步骤。我们的例子中的模型仅有一项属性,表面颜色,这点可以参考图5.2中底部的颜色球。
像大多数着色模型一样,这个例子受到表面相对于观察者的方向和光照方向的影响。出于着色的目的,这些方向都通常会表示为标准化的(单位长度的)向量,参考图5.3中的显示。
图 5.3. 单位长度向量作为样例着色模型(以及其他)的输入:表面法线 n \mathbf{n} n,观察向量 v \mathbf{v} v以及光的方向 l \mathbf{l} l。
现在我们已经定义了我们着色模型的输入,我们可以看向模型自身的数学定义:
c s h a d e d = s c h i g h l i g h t + ( 1 − s ) ( t c w a r m + ( 1 − t ) c c o o l ) . ( 5.1 ) \mathbf{c}_{shaded} = s\mathbf{c}_{highlight} + (1-s)(t\mathbf{c}_{warm}+(1-t)\mathbf{c}_{cool}).\qquad(5.1) cshaded=schighlight+(1−s)(tcwarm+(1−t)ccool).(5.1)
在这个公式中,我们使用了下面的中间计算过程:
c c o o l = ( 0 , 0 , 0.55 ) + 0.25 c s u r f a c e , c w a r m = ( 0.3 , 0.3 , 0 ) + 0.25 c s u r f a c e , c h i g h l i g h t = ( 1 , 1 , 1 ) , t = ( n ⋅ l ) + 1 2 , r = 2 ( n ⋅ l ) n − l , s = ( 100 ( r ⋅ v ) − 97 ) ∓ . ( 5.2 ) \begin{aligned} \mathbf{c}_{cool} & = (0,0,0.55)+0.25\mathbf{c}_{surface}, & \\ \mathbf{c}_{warm} &= (0.3,0.3,0) + 0.25\mathbf{c}_{surface}, & \\ \mathbf{c}_{highlight} &= (1,1,1), & \\ t &=\frac{(\mathbf{n}·\mathbf{l})+1}{2}, & \\ \mathbf{r} &= 2(\mathbf{n}·\mathbf{l})\mathbf{n}-\mathbf{l}, &\\ s &= (100(\mathbf{r}·\mathbf{v})-97)^{\mp}. \end{aligned}\qquad(5.2) ccoolcwarmchighlighttrs=(0,0,0.55)+0.25csurface,=(0.3,0.3,0)+0.25csurface,=(1,1,1),=2(n⋅l)+1,=2(n⋅l)n−l,=(100(r⋅v)−97)∓.(5.2)
这个着色模型中的多个数学表达式也都经常在其他的着色模型中使用到。clamp操作(通常clamp到0或clamp在0和1之间)在着色中很常见(译者注:clamp(0,1)相当于只保留这个值中0到1的部分,如果这个值小于0则取0,大于1则取1)。这里我们使用 x ∓ x^{\mp} x∓的符号(第1.2小节中有介绍),表示clamp到0到1,这在高光混合因子 s s s的计算中用到了。点乘操作出现了三次,每次都是在两个单位向量之间;这是极其常见的样式。两个向量的点乘结果是他们的长度和夹角余弦的乘积。所以两个单位向量的点乘结果就是夹角余弦值,这在衡量两个向量的重合程度时非常有用(译者注:结果为1,完全重合;结果为-1,相反;结果为0,互相垂直)。在着色模型中,由余弦组成的简单函数通常是最令人满意和最精确的数学表达式,用来解释两个方向之间的关系,例如光方向和曲面法线。
另一个常用的着色操作是在两个颜色之间进行插值,插值的依据是0到1的一个标量。这个操作采用的形式是 t c a + ( 1 − t ) c b t\mathbf{c}_a + (1-t)\mathbf{c}_b tca+(1−t)cb,在 t t t的值在0到1之间移动的过程中,插值结果从 c a \mathbf{c}_a ca到 c b \mathbf{c}_b cb之间变化。这个样板在着色模型中出现了两次,第一次是在 c w a r m \mathbf{c}_{warm} cwarm和 c c o o l \mathbf{c}_{cool} ccool之间进行插值,第二次是在前一次的基础上和 c h i g h l i g h t \mathbf{c}_{highlight} chighlight之间进行的插值。线性插值在着色器中非常常见,它已经成为了其内置的一个函数,称为lerp或者mix,在每种着色器语言中都能见到它的身影。
r = 2 ( n ⋅ l ) n − l \mathbf{r} = 2(\mathbf{n}·\mathbf{l})\mathbf{n}-\mathbf{l} r=2(n⋅l)n−l这行计算了反射光的向量,依据 n \mathbf{n} n反射 l \mathbf{l} l。即便这个操作并没有前面的两个那么常见,但是其使用率还是足以让其称为大多数着色语言的内置函数。
通过将这些操作和多种多样的数学表达式和着色参数进行组合,可以出服务于大量的风格化的和写实的外观的着色模型。
光照在我们样例着色模型上的影响是非常简单的;它提供了着色的主导方向。当然,现实中的光照要复杂得多。会存在有多个光源,每个光源得大小、形状、颜色和强度都各有不同;间接光照更是会增加更多的细节和变化。在第9章中会提到,基于物理的、写实风格的着色模型会将上面的所有参数都考虑在内。
相反的,风格化着色模型可能会以多种不同的方式来使用光照,这取决于应用程序的需求和视觉风格。一些高度风格化的模型可能都不会有光照的概念,或者(像我们的Gooch着色案例)可能只是用它来提供一些简单的方向感。
光照复杂性的下一步是着色模型以二进制方式对光的存在或不存在作出反应。使用这样的模型的着色表面在被照亮时将具有一种外观,而在不受灯光影响时具有不同的外观。这引出区分两种情况的一些标准:离光源的距离,阴影(将在第7章进行讨论),表面是否背离光源(即表面法线 n \mathbf{n} n和光向量 l \mathbf{l} l的夹角大于90度),或者这些因素的某种组合。
从光的有无到光强的连续性缩放又是一个小步。这可以表达成是一个简单的从完全无光到光完全表达出来的一个中间的插值,即对光照强度的一个有节范围的插值,也许就是0到1,或者是某个无界的量以某种其他的方式影响着着色。对后者来说一个通常的选项是将着色模型分为受光照和不受光照的两部分,对于光照部分来说使用光照强度 k l i g h t k_{light} klight来进行光的线性缩放:
c s h a d e d = f u n l i t ( n , v ) + k l i g h t f l i t ( l , n , v ) . ( 5.3 ) \mathbf{c}_{shaded}=f_{unlit}(\mathbf{n},\mathbf{v})+k_{light}f_{lit}(\mathbf{l},\mathbf{n},\mathbf{v}). \qquad(5.3) cshaded=funlit(n,v)+klightflit(l,n,v).(5.3)
在这基础上可以轻松进行RBG光照颜色 c l i g h t \mathbf{c}_{light} clight的扩展,
c s h a d e d = f u n l i t ( n , v ) + c l i g h t f l i t ( l , n , v ) . ( 5.4 ) \mathbf{c}_{shaded} = f_{unlit}(\mathbf{n},\mathbf{v})+\mathbf{c}_{light}f_{lit}(\mathbf{l},\mathbf{n},\mathbf{v}). \qquad(5.4) cshaded=funlit(n,v)+clightflit(l,n,v).(5.4)
并去叠加多个光源,
c s h a d e d = f u n l i t ( n , v ) + ∑ i = 1 n c l i g h t i f l i t ( l i , n , v ) . ( 5.5 ) \mathbf{c}_{shaded} = f_{unlit}(\mathbf{n},\mathbf{v})+\sum_{i=1}^{n}{\mathbf{c}_{light}}_i f_{lit}(\mathbf{l}_i,\mathbf{n},\mathbf{v}). \qquad(5.5) cshaded=funlit(n,v)+i=1∑nclightiflit(li,n,v).(5.5)
对于将光照作为二进制来对待的着色模型来讲,非光照部分 f u n l i t ( n , v ) f_{unlit}(\mathbf{n},\mathbf{v}) funlit(n,v)对应着“不被光照影响的外观”。它可以有多种形式,这取决于期望的视觉风格和应用程序的需要。例如, f u n l i t ( ) = ( 0 , 0 , 0 ) f_{unlit}() =(0,0,0) funlit()=(0,0,0)将会导致所有不被光照影响的表面都被着色成纯黑色。或者,无光部分可以表示无光对象的某种形式的风格化外观,类似于Gooch模型背向灯光的表面的冷色。通常,着色模型的这一部分表示某种形式的照明,这些照明不是直接来自明确放置的光源(例如来自天空的光或从周围对象反射的光)。这些其他形式的光照将会在第10和11章进行讨论。
早些时候我们提到,光源在光照方向 l \mathbf{l} l和表面法线 n \mathbf{n} n之间的夹角大于90°时(即光照效果是从表面下面来的),就不会再影响表面上的点。这可以看作是光相对于曲面的方向与其对着色处理的影响之间的常见关系的特例。尽管是基于物理的,这种关系可以从简单的几何原理推论出来,并且在许多类型的非基于物理的、风格化的着色模型上也非常有用。
光在表面上的效果可以看作是一组射线,撞击表面的射线的密度对应着光的强度,从而应用于表面着色。参考图5.4,其中展示了一个受光照表面的截面。沿该横截面照射到表面的光线之间的间距与 l \mathbf{l} l和 n \mathbf{n} n之间夹角的余弦成反比。所以,撞击到表面的光线的总体密度与 l \mathbf{l} l和 n \mathbf{n} n之间夹角的余弦值成正比(译者注:余弦值越大,间距越小,代表着密度变大),而这个余弦值的算法,就像我们之前介绍过的那样,是两个单位向量的点积。这里我们认识到为什么去定义与光的传播方向相反的光向量会更加的方便;否则的话我们就不得不在执行点积操作之前就进行一步求反操作。
图 5.4. 上面的一行展示了光打在平面上的截面视图。左侧是光直直得打在表面上,中间是光以一定得角度打在表面上,右侧我们看到的是使用向量点积来计算角度的余弦值。下方的图显示的是截面平面(包含了光和观察向量)和整个表面的关系。
更进一步,射线密度(即光对于着色的贡献程度)在点积结果为正值时与其成正比。负值的话对应着从表面背面射来的光线。所以,在将光着色和光照点积相乘之前,我们首先需要clamp点积结果到0。使用1.2小节引入的符号 x + x^+ x+,来表示将负值clamp到0,从而有
c s h a d e d = f u n l i t ( n , v ) + ∑ i = 1 n ( l i ⋅ n ) + c l i g h t i f l i t ( l i , n , v ) . ( 5.6 ) \mathbf{c}_{shaded} = f_{unlit}(\mathbf{n},\mathbf{v})+\sum_{i=1}^{n}(\mathbf{l}_{i}·\mathbf{n})^+{\mathbf{c}_{light}}_i f_{lit}(\mathbf{l}_i,\mathbf{n},\mathbf{v}). \qquad(5.6) cshaded=funlit(n,v)+i=1∑n(li⋅n)+clightiflit(li,n,v).(5.6)
支持多光源的着色模型通常会使用公式5.5中的结构之一,这更加通用,当然或者公式5.6也可以,它是专供给基于物理的模型。它对风格化模型也是适用的,因为它可以帮助确保光照的一致性,特别是对于那些不朝向光照或者是阴影的表面。但是,也会有一些模型并不适用;这些模型可以使用公式5.5中的结构。
函数 f f i t ( ) f_{fit}() ffit()的可能的最简单的选项是将它设置为一个恒定的颜色,即
f f i t ( ) = c s u r f a c e , ( 5.7 ) f_{fit}() = \mathbf{c}_{surface}, \qquad(5.7) ffit()=csurface,(5.7)
着色模型会变化如下:
c s h a d e d = f u n l i t ( n , v ) + ∑ i = 1 n ( l i ⋅ n ) + c l i g h t i c s u r f a c e . ( 5.8 ) \mathbf{c}_{shaded} = f_{unlit}(\mathbf{n},\mathbf{v})+\sum_{i=1}^{n}(\mathbf{l}_{i}·\mathbf{n})^+{\mathbf{c}_{light}}_i \mathbf{c}_{surface}. \qquad(5.8) cshaded=funlit(n,v)+i=1∑n(li⋅n)+clighticsurface.(5.8)
这个模型中的光照部分对应着Lambertian着色模型(译者注:即常说的兰伯特着色模型),是由Johann Heinrich Lambert在1960年发表的。这个模型的使用场景是理想漫反射表面,即表面是极致得不光滑(译者注:完美的磨砂表面)。我们这里介绍的是经过一定程度简化的Lambert模型的解释,这在后面第9章会更加严谨的讨论。Lambertian模型本身可以用于简单着色,同时在许多着色模型中它是一个关键的构建块。
从公式5.3到5.6我们可以看出光源通过两个参数来实现与着色模型的交互:指向光源的向量 l \mathbf{l} l和光的颜色 c l i g h t \mathbf{c}_{light} clight。实际上存在着多种不同类型的光源,其区别就是在于这两个参数是如何在场景中变化的。
下面我们将会讨论几个常见的光源类型,他们都有一个共同点:在给定的表面位置,每个光源只从一个方向 l \mathbf{l} l来照亮表面。换句话说,从着色表面位置看过去的光源,是一个无限远的小点。现实世界中的光并不完全是这样,但是大多数光源以他们和被照亮表面之间的距离来说确实相对来说较小。在7.1.2和10.1小节中,我们将讨论从一定范围方向照亮表面位置的光源,即“区域光”。
平行光是光源的最简单的形式。两个参数 l \mathbf{l} l和 c l i g h t \mathbf{c}_{light} clight在场景种都是恒定的,当然 c l i g h t \mathbf{c}_{light} clight会受到阴影的影响而减弱。平行光里没有位置的概念。当然,实际的光源在空间种也并没有特定的位置。平行光是抽象的,这在距离光源的距离相对于场景大小表现得很大时更加合适。例如,一个20英尺远的泛光灯照亮了一个小桌面透视图,可以表示为一个平行光。另一个例子就是任何被太阳照亮的场景,当然大到太阳系这样的范围内可能就不使用了。
平行光的概念可以在一定程度上进行扩展,可以允许保持光照方向 l \mathbf{l} l不变的情况下,变化 c l i g h t \mathbf{c}_{light} clight的值。这通常是为了将灯光效果绑定到场景的某个特定部分,以用于表演或创作。例如,可以定义一个两层盒子嵌套在一起,其中 c l i g h t \mathbf{c}_{light} clight在外层盒子外部等于 ( 0 , 0 , 0 ) (0,0,0) (0,0,0)(纯黑),在内层盒子内部等于一个恒定值,并且在两个盒子中间的区域在两个极值之间进行插值。
punctual light并不是说这个光源很守时,而是说光源有位置属性,不像是平行光。这样的光源没有尺寸的概念,没有形状和大小,不像是现实世界中的光源那样。我们使用了术语“punctual”,源自拉丁语punctus,意思是“point”(点),用来描述这样一类的光源:来自单个局部位置的照明源。我们使用术语“point light”(点光源)来表示一种特定类型的发射器,它在所有方向上的光都是相等的。所以,点光源和聚光灯是punctual光源的两种不同的形式。光的方向向量 l \mathbf{l} l依据当前着色表面的点 p 0 \mathbf{p}_0 p0相对于punctual光源 p l i g h t \mathbf{p}_{light} plight的位置而进行变化:
l = p l i g h t − p 0 ∣ ∣ p l i g h t − p 0 ∣ ∣ . ( 5.9 ) \mathbf{l} = \frac{\mathbf{p}_{light}-\mathbf{p}_{0}}{||\mathbf{p}_{light}-\mathbf{p}_{0}||}. \qquad(5.9) l=∣∣plight−p0∣∣plight−p0.(5.9)
这个公式是向量标准化的样例:将向量除以它的长度来得到一个单位长度向量,他们共享着同一个方向。这是另一个常见的着色操作,并且和我们前面看到的着色操作一样,在很多语言中都有内置的实现函数。但是,有些时候我们可能需要用到这个操作的中间值,这需要使用更加基础的操作,以多个步骤明确得执行标准化的过程。将这个应用到punctual光源上面,可以得到下面的:
d = p l i g h t − p 0 , r = d ⋅ d , l = d r . ( 5.10 ) \begin{aligned} \mathbf{d} & = \mathbf{p}_{light} - \mathbf{p}_{0}, & \\ r &= \sqrt{\mathbf{d}·\mathbf{d}}, & \\ \mathbf{l} &= \frac{\mathbf{d}}{r}. & \\ \end{aligned}\qquad(5.10) drl=plight−p0,=d⋅d,=rd.(5.10)
因为两个向量的点积等于两个向量的长度乘以他们之间夹角的余弦,0°的余弦是1,向量和自己的点乘结果就是它长度的平方。所以,去求任意向量的长度,我们只需要让它点乘自己然后求平方根就可以了。
我们需要的中间值就是 r r r,即punctual光源和当前着色点之间的距离。除了在标准化向量方面的作用,也需要 r r r值来计算光的颜色 c l i g h t \mathbf{c}_{light} clight的衰减(是一个距离的函数)。我们会在后面的小节继续深入讨论。
向所有方向等量得射出光的punctual光源即是我们熟知的point light(点光源)或者说omni light(全方位光源)。对于点光源来讲, c l i g h t \mathbf{c}_{light} clight是距离 r r r的函数,其中变化源就是上面提到的距离的衰减。图5.5展示了为什么会产生暗化,使用相似几何推理作为图5.4中余弦因子的证明。在给定表面上,射线之间的空间距离与表面到光源的距离成正比。与图5.4中的余弦不同的是,空间距离的增长是在两个维度上的,因此射线密度(即光的颜色 c l i g h t \mathbf{c}_{light} clight)和距离平方成反比。这可以让我们通过一个单独的光源属性 c l i g h t 0 \mathbf{c}_{light_0} clight0来确立出 c l i g h t \mathbf{c}_{light} clight的空间变化,而 c l i g h t 0 \mathbf{c}_{light_0} clight0就是 c l i g h t \mathbf{c}_{light} clight在一个固定参考距离 r 0 r_0 r0的值:
c l i g h t ( r ) = c l i g h t 0 ( r 0 r ) 2 . ( 5.11 ) \mathbf{c}_{light}(r)=\mathbf{c}_{light_0}(\frac{r_0}{r})^2.\qquad(5.11) clight(r)=clight0(rr0)2.(5.11)
图 5.5. 点光源的光线的空间间距与距离r成比例增加。由于空间的增加是发生在两个维度上面的,射线的密度(即光照强度)将会减小到 1 / r 2 1/r^2 1/r2。
公式5.11通常被称为平方反比光衰减(inverse-square light attenuation)。即便从技术上讲这就是点光源的正确的距离衰减,然而在实际着色中存在一些问题使这个方程的使用不太理想。
第一个问题发生在距离相对较小的情况下。当 r r r趋向于0时, c l i g h t \mathbf{c}_{light} clight的值将会无限得增长。当 r r r等于0时,我们会有一个以0作为分母的极端情况。为了修正这一点,一个常用的完善是去在分母上加上一个小值 ϵ \epsilon ϵ:
c l i g h t ( r ) = c l i g h t 0 r 0 2 r 2 + ϵ . ( 5.12 ) \mathbf{c}_{light}(r)=\mathbf{c}_{light_0}\frac{{r_0}^2}{r^2+\epsilon}.\qquad(5.12) clight(r)=clight0r2+ϵr02.(5.12)
ϵ \epsilon ϵ的确切的值取决于应用程序;例如,虚幻引擎使用的 ϵ = 1 c m \epsilon = 1 cm ϵ=1cm。
另一个可选项,应用在了CryEngine和寒霜引擎中,是将 r r r的值clamp到一个最小的 r m i n r_{min} rmin:
c l i g h t ( r ) = c l i g h t 0 ( r 0 max ( r , r m i n ) ) 2 . ( 5.13 ) \mathbf{c}_{light}(r)=\mathbf{c}_{light_0}(\frac{{r_0}}{\max{(r,r_{min})}})^2.\qquad(5.13) clight(r)=clight0(max(r,rmin)r0)2.(5.13)
和前面的方法中使用某种任意的 ϵ \epsilon ϵ值不同的是, r m i n r_{min} rmin有一个实际的插值:实际上发射光的物体的半径。小于 r m i n r_{min} rmin的 r r r的值对应着着色表面穿透到实际光源的部分,而这是不可能发生的。
相反,平方反比的第二个问题发生在相对距离较大时。问题不是视觉表现上的,而是性能方面的。尽管光照强度一直持续随着距离而降低,但是它无法降至0。对于高效渲染来说,我们希望光源在某个无限远处到达0光照强度(第20章)。可以有许多种修改平方反比公式的方法来达到这种效果。理想状况下我们希望修改要尽量得小。为了避免在光传播上产生剧烈的断层,我们希望修正函数的导数和函数值在相同距离处达到0。一种方法是将平方反比公式乘以含有期望属性的窗口函数(windowing function)。这样的一个函数在虚幻引擎和寒霜引擎中都有用到:
f w i n ( r ) = ( 1 − ( r r m a x ) 4 ) + 2 . ( 5.14 ) f_{win}(r) = (1-(\frac{r}{r_{max}})^4)^{+2}. \qquad(5.14) fwin(r)=(1−(rmaxr)4)+2.(5.14)
其中 + 2 +2 +2意思是将值做一个clamp操作,即如果是负值的话,就在平方操作前先归零。图5.6展示了一个平方反比曲线,和公式5.14的窗口函数,以及二者的乘积。
图 5.6. 这幅图展示了平方反比曲线(使用 ϵ \epsilon ϵ的方法来避免分母等于0,其中 ϵ = 1 \epsilon = 1 ϵ=1),公式5.14中描述的窗口函数( r m a x r_{max} rmax设置为3),以及窗口曲线。
应用的需求将会影响到方法的选择。例如,当对距离衰减函数的采样以一个相对低的空间频率(如,在光照贴图或者逐顶点)进行时,导数在 r m a x r_{max} rmax处等于0就是及其重要的。CryEngine没有使用光照贴图或者是顶点光照,它采用了一种更简单的调整,在 0.8 r m a x 0.8r_{max} 0.8rmax和 r m a x r_{max} rmax之间切换到线性衰减。
对某些应用来说,去匹配平方反比曲线并不是第一优先级的,所以会使用一些其他的函数。这可以很快得将公式5.11到5.14生成成如下的公式:
c l i g h t ( r ) = c l i g h t 0 f d i s t ( r ) , ( 5.15 ) \mathbf{c}_{light}(r)=\mathbf{c}_{light_0}f_{dist}(r),\qquad(5.15) clight(r)=clight0fdist(r),(5.15)
其中 f d i s t ( r ) f_{dist}(r) fdist(r)是某种距离函数。这样的函数我们称为距离衰减函数。在某些情况下,非平方反比衰减函数的使用是由性能所限制的。例如,游戏Just Cause 2并不想要非常高消耗的光照计算。这决定了一个非常简单的光照衰减计算,当然它同时也足够得平滑,避免了逐顶点得光照缺陷:
f d i s t ( r ) = ( 1 − ( r r m a x ) 2 ) + 2 . ( 5.16 ) f_{dist}(r) = (1-(\frac{r}{r_max})^2)^{+2}.\qquad(5.16) fdist(r)=(1−(rmaxr)2)+2.(5.16)
在其他情况下,衰减函数的选择可能是受到了创作性考虑的影响。例如,虚幻引擎可以用来做写实和风格化两种类型的游戏,也有两种光衰减模式:一种平方反比模式,正如公式5.12描述的那样,以及一种指数衰减模式,它可以调整以创建各种衰减曲线。游戏Tomb Raider(2013)(古墓丽影)使用了样条线编辑工具来牵引衰减曲线,这允许了对曲线形状的更大程度的掌控。
不像是点光源那样,几乎所有现实世界的光源照明除了在距离上,在方向上也会产生变化。这个变化可以用一个方向性衰减函数 f d i r ( l ) f_{dir}(\mathbf{l}) fdir(l)来描述,它与距离衰减函数相结合去定义总体上的光照强度空间变换:
c l i g h t ( r ) = c l i g h t 0 f d i s t ( r ) f d i r ( l ) , ( 5.17 ) \mathbf{c}_{light}(r)=\mathbf{c}_{light_0}f_{dist}(r)f_{dir}(\mathbf{l}),\qquad(5.17) clight(r)=clight0fdist(r)fdir(l),(5.17)
f d i r ( l ) f_{dir}(\mathbf{l}) fdir(l)的不同也会产生不同的光照效果。其中一种重要的类型就是聚光灯(spotlight),它是将光打成一个圆锥形的形状。聚光灯的方向衰减函数围绕聚光灯方向向量 s \mathbf{s} s具有旋转对称性,因此可以表示为角度的函数 θ s \theta_s θs( s \mathbf{s} s和光向量相反向量 − l -\mathbf{l} −l之间的夹角)。这里光向量需要取反,因为我们定义 l \mathbf{l} l时是以表面为出发点指向光源,而这里我们需要从光源发出的向量。
大部分聚光灯函数使用了包含 θ s \theta_s θs的余弦值的表达式,这也是着色中最常见的角度的使用形式。聚光灯通常拥有一个本影角(umbra angle,张开角) θ u \theta_u θu,它起到限定光照范围的作用,即对所有的 θ s ≥ θ u \theta_s \geq \theta_u θs≥θu都有 f d i r ( l ) f_{dir}(\mathbf{l}) fdir(l)。此角度可用于裁剪,其方式与前面看到的最大衰减距离 r m a x r_max rmax类似。聚光灯中还有一个常见的半影角(penumbra angle) θ p \theta_p θp,它定义了一个更小的内部圆锥,而其中光源的强度是其最大光强。参考图5.7.。
图 5.7. 聚光灯: θ s \theta_s θs是光的方向 s \mathbf{s} s和光源到物体表面的向量 − l -\mathbf{l} −l的夹角; θ p \theta_p θp表示的是半影角; θ u \theta_u θu表示的是光源的本影角。
聚光灯可以使用多种方向衰减函数,但是这些函数趋向于类似。例如,寒霜游戏引擎使用的就是函数 f d i r F ( l ) f_{dir_F}(\mathbf{l}) fdirF(l),three.js浏览器图形库使用是函数 f d i r T ( l ) f_{dir_T}(\mathbf{l}) fdirT(l):
t = ( cos θ s − cos θ u cos θ p − cos θ u ) ∓ , f d i r F ( l ) = t 2 , f d i r T ( l ) = smoothstep ( t ) = t 2 ( 3 − 2 t ) . ( 5.18 ) \begin{aligned} t & = (\frac{\cos{\theta_s} - \cos{\theta_u}}{\cos{\theta_p} - \cos{\theta_u}})^{\mp}, & \\ f_{dir_F}(\mathbf{l}) &= t^2, & \\ f_{dir_T}(\mathbf{l}) &= \text{smoothstep}(t) = t^2(3-2t). & \\ \end{aligned}\qquad(5.18) tfdirF(l)fdirT(l)=(cosθp−cosθucosθs−cosθu)∓,=t2,=smoothstep(t)=t2(3−2t).(5.18)
需要回顾一下, x ∓ x^{\mp} x∓符号的作用是将 x x x限制到0到1之间,可以参考1.2小节中的介绍。smoothstep函数是一个三次多项式,它经常用于着色中的平滑插值。它也是大多数的着色语言的内置函数。
图5.8展示了一些我们到现在为止讨论过的光源类型。
图 5.8. 一些类型的光源。从左到右:方向光源,无衰减的点光源,以及平滑过渡的聚光灯光源。注意点光源向边缘逐渐减弱是意味光源和表面之间夹角的改变。
还有很多其他的punctual光源的 c l i g h t \mathbf{c}_{light} clight值改变的方式。
f d i r ( l ) f_{dir}(\mathbf{l}) fdir(l)函数不单单限于聚光灯衰减函数;它可以表示任何类型的方向变换,包括从真实世界光源测量的复杂表格模式。照明工程协会(Illuminating Engineering Society,IES)针对这样的测量定义了标准文件格式。IES配置文件可从许多照明制造商获得,并已用于游戏Killzone:Shadow Fall,以及虚幻和寒霜等游戏引擎。Lagarde对与解析和使用此文件格式有关的问题进行了很好的总结。
游戏Tomb Raider(古墓丽影)中有一种punctual光源,其应用了在x,y和z轴上相互独立的距离衰减函数。该游戏里的曲线还可以应用到让光照强度随着时间进行变化,从而可以产生闪烁的火炬的效果。
6.9小节中,我们将会讨论光照强度和颜色通过纹理的使用而进行变化。
方向光源和点光源的不同主要是由其光的方向 l \mathbf{l} l是如何计算的这一特性决定的。不同类型的光源可以通过他们使用的计算呢光方向的方法来进行定义。例如,除了我们上面提到的一些光源类型,Tomb Raider还使用了胶囊光源,它使用的是一个线段而不是一个点作为源。对每一个着色的像素,从线段上到最近的点的方向就是光的方向 l \mathbf{l} l。
只要着色器有 l \mathbf{l} l和 c l i g h t \mathbf{c}_{light} clight的值去用来计算着色方程,就可以使用任何的方法来进行计算。
到现在我们讨论过的光源都是抽象的。实际上,光源是有大小和形状的,并且他们是从多个不同方向上照亮表面上一点的。在渲染中,这样的光源称为区域光源(area light,或者面光源),并且这样的光源在实时应用中的使用也在平稳增加。区域光源渲染技术分为两类:模拟因区域光源被部分遮挡而导致的阴影边缘软化和模拟区域光源对表面着色的影响。第二类照明对于光滑的镜面最为明显,在镜面上,光线的形状和大小可以在其反射中清晰地辨别出来。方向光源和punctual光源不太可能被淘汰,尽管它们不再像过去那样无处不在。区域光源的近似计算已经得到开发,其实现起来消耗相对较低,因此得到了更广泛的应用。GPU性能的提高也允许使用比过去更复杂的技术。
出于使用的目的,这些着色和照明公式必须以代码的形式实现。本节我们将会重温一些在设计和书写这些实现的关键考虑的点。我们还将会过一遍一个简单的实现的例子。
当设计一个着色实现的时候,需要根据计算的计算频率(frequency of evaluation)来进行计算的拆分。首先,决定给定计算的结果是否在整个drawcall(绘制调用)上都是恒定不变的。在这种情况下,计算可以由应用(通常在CPU上)执行,尽管GPU的计算着色器也可以用来做一些高消耗的计算。结果通过统一着色器输入进入图形API。
即使在这一类中,也有从“曾经”开始的各种可能的计算频率。最简单的这样的情况是着色公式中的常量子表达式,但这可能仅适用于一些很少改变的因素(如硬件配置和安装选项)的计算。这样的着色计算可以在着色器编译时候进行,这种情况下甚至没有必要去设置一个统一着色器输入。另外,计算可能会在一个离线预计算通道里执行,在安装时间或者使当应用加载的时候。
另外一种情况是当着色计算的结果在应用运行时发生变化,但是非常缓慢,并且没有必要去每帧进行更新。例如,在虚拟游戏世界中与一天内时间相关的光照因子。如果计算的消耗较高,可能需要在多帧上进行消耗的分摊。
其他一些情况还包括:需要每帧去执行的计算,例如级联组合观察矩阵和透视矩阵;或者在每个模型上执行的计算,例如与位置相关的模型光照参数的更新;或者在每个drawcall上执行的计算,如对一个模型上的每个材质的参数进行更新。通过计算频率将统一着色输入进行分组对于提升程序效率非常有用,并且可以通过最小化持续的更新来提升GPU性能。
如果着色计算的结果在一个drawcall内发生了变化,它就不可以通过统一着色器输入进入着色器。相对的,它必须由可编程着色器阶段(第三章中讨论的)进行计算,并且如果需要的话,通过改变着色器输入进入其他的阶段。理论上,着色器计算可以在任何可编程阶段上执行,每一个阶段也对应着一个不同的计算频率:
实际上大部分的着色计算是逐像素进行的。由于这些通常在像素着色器中实现,计算着色器实现也越来越常见;第20章将讨论几个例子。其他几个阶段优先在几何操作时(例如变换和变形)使用。为了明白为什么是这样的,我们将会比较逐顶点和逐像素的着色计算的结果。在旧一点的文章中,偶尔会提到Gouraud shading(高洛德着色)和Phong Shading(冯着色),相当于的,这些术语在当下已经不常用到了。这个比较的过程用到了一种某种程度上和公式5.1中很相似的着色模型,但是经过了一定的调整以适应多光源的情况。后面当我们涉及到详细的实现的例子时,我们会给到完整的模型。
图5.9显示了在具有大阈值顶点密度的模型上进行逐像素和逐顶点着色的结果。对于龙模型来说,网格密度极高,两个着色的结果相差不大。但是在茶壶上,顶点着色计算导致了显示的错误,例如棱角状的highlight,并且在两个三角形组成的平面上顶点着色的结果显然是不正确的。这些错误的原因在于着色公式中特别是highlight的那部分,在网格表面上并不是线性过渡的。这使得它们不适合顶点着色器,其结果在输入到像素着色器之前在三角形上进行了线性插值。
图 5.9. 从公式5.19出发的样例着色模型的逐像素和顶点的计算比较,三个模型上分别有着不同的顶点密度。最左侧的一列展示了逐像素的计算,中间的一列展示了逐顶点的计算,右侧的一列显示了每个模型的线框渲染以展示顶点密度。(中国龙网格来自于Computer Graphics Archive,原始模型来自于斯坦福3D扫描库。)
理论上,可以在像素着色器中只计算着色模型中的高光部分,而其余的在顶点着色器中进行计算。这可能不会导致一些视觉上的缺陷,并且理论上可以节约计算资源。实际上,这种混合实现通常不是最优的。着色模型的线性变化部分往往是计算成本最低的,并且以这种方式拆分着色计算往往会增加非常大的开销,例如重复计算和额外的变化输入,使得弊大于利。
正如我们之前提到的,在多数实现中顶点着色器是负责非着色计算,例如几何变换和变形。变换到合适的坐标系统中的输出的几何表面属性,由几何着色器进行写入,并在三角形上进行线性插值,然后进入到像素着色器中作为着色器输入。这些属性通常包括表面的位置,表面法线,表面切向量(如果需要法线映射的话)。
注意,即使顶点着色器总是生成的是单位长度的表面法线,插值依旧会改变他们的长度。参照图5.10中左侧部分。出于这个原因,法线需要在像素着色器中进行重新的归一化(缩放到单位长度1)。然而,顶点着色器生成的法线的长度依旧很重要。如果顶点间的法线长度变化很剧烈,例如,作为顶点混合的副作用,这将使插值倾斜。这可以参考图5.10的右半部分。由于这两种效果,实现通常会在插值前后归一化插值向量,即分别在顶点和像素着色器中进行向量的归一化。
图 5.10. 左侧,我们观察到单位向量在表面上进行线性插值会使得插值得到的向量长度小于1。右侧,我们观察到长度极度不同的法线间的线性插值会导致插值得到的方向偏向于两个法线中较长的那一个。
不像表面法线那样,指向特定方向的向量,例如punctual光源中的观察向量和光向量,通常是不进行插值的。相对的,会使用插值的表面位置来在像素着色器中对这些向量进行计算。正如我们所看到的,在任何情况下都需要在像素着色器中执行归一化,除此之外,这些向量中的每一个都是用向量减法计算的,这非常快。如果出于某种原因必须对这些向量进行插值,不要事先将其归一化。这可能会导致错误的结果,参照图5.11.。
图 5.11. 两个光向量之间的插值。左侧,在插值前进行归一化操作会导致插值后向量方向不正确。右侧,在非标准化的向量间进行插值可以得到正确的结果。
早些时候我们提到,顶点着色器将表面几何变换到“合适的坐标系统”。通过统一变量传递给像素着色器的相机和灯光位置通常由应用程序变换到相同的坐标系中。这将最小化像素着色器将所有着色模型向量带入同一坐标空间所做的工作。但是哪一个坐标系统是“合适”的呢?可能的选项有世界空间,相机的局部坐标空间,以及当前被渲染模型的局部坐标空间。这种选择通常是针对整个渲染系统进行的,并基于系统性的考虑,例如性能、灵活性和简单性等。例如,被渲染的场景如果包含大量的光源,可能会选择世界空间以避免光源位置的变换。另外,也可以选相机空间,以更好地优化与视图向量相关的像素着色器操作,并可能提高精度(第16.6小节)。
即便大多数着色器的实现,包括我们将要讨论的这个样例实现中,都遵循着我们上面描述的常用框架,但是仍然有一些例外。例如,一些应用程序出于风格的原因选择逐图元的着色计算得到的面状外观。这个风格通常被称为时flat shading(平面着色,后文采用“flat着色”的翻译方式)。图5.12中展示了两个样例。
图 5.12. 两个应用了flat着色作为风格的游戏:上半部的Kentucky Route Zero,和下半部的Taht Dragon, Cancer。(上半部的图片由Cardboard Computer贡献,下半部的由Numinous Games提供。)
原则上,flat着色可以在几何着色器中进行,但是最近的实现方案中通常会使用顶点着色器。这是通过将每个图元的属性和它的第一个顶点进行关联并且禁用掉顶点插值而实现的。禁用插值(可以对每个顶点值单独处理)会导致第一个顶点的值流向图元中所有的像素。
我们现在会介绍一个着色模型实现的样例。正如之前提到的,我们要实现的着色模型类似于从公式5.1中扩展出的Gooch模型,当然也做了一定的修正以适应多光源的环境。它可以描述为:
c s h a d e d = 1 2 c c o o l + ∑ i = 1 n ( l i ⋅ n ) + c l i g h t i ( s i c h i g h l i g h t + ( 1 − s i ) c w a r m ) , ( 5.19 ) \mathbf{c}_{shaded} = \frac{1}{2}\mathbf{c}_{cool} + \sum^n_{i=1}(\mathbf{l}_{i}·\mathbf{n})^{+}\mathbf{c}_{light_i}(s_{i}\mathbf{c}_{highlight}+(1-s_i)\mathbf{c}_{warm}),\qquad(5.19) cshaded=21ccool+i=1∑n(li⋅n)+clighti(sichighlight+(1−si)cwarm),(5.19)
其中:
c c o o l = ( 0 , 0 , 0.55 ) + 0.25 c s u r f a c e , c w a r m = ( 0.3 , 0.3 , 0 ) + 0.25 c s u r f a c e , c h i g h l i g h t = ( 2 , 2 , 2 ) , r i = 2 ( n ⋅ l i ) n − l i , s i = ( 100 ( r i ⋅ v ) − 97 ) ∓ . ( 5.20 ) \begin{aligned} \mathbf{c}_{cool} & = (0,0,0.55)+0.25\mathbf{c}_{surface}, & \\ \mathbf{c}_{warm} &= (0.3,0.3,0) + 0.25\mathbf{c}_{surface}, & \\ \mathbf{c}_{highlight} &= (2,2,2), & \\ \mathbf{r}_i &= 2(\mathbf{n}·\mathbf{l}_i)\mathbf{n}-\mathbf{l}_i, &\\ s_i &= (100(\mathbf{r}_i·\mathbf{v})-97)^{\mp}. \end{aligned}\qquad(5.20) ccoolcwarmchighlightrisi=(0,0,0.55)+0.25csurface,=(0.3,0.3,0)+0.25csurface,=(2,2,2),=2(n⋅li)n−li,=(100(ri⋅v)−97)∓.(5.20)
这个公式也符合公式5.6中的多光源结构,这里再回忆一下这个公式:
c s h a d e d = f u n l i t ( n , v ) + ∑ i = 1 n ( l i ⋅ n ) + c l i g h t i f l i t ( l i , n , v ) . \mathbf{c}_{shaded} = f_{unlit}(\mathbf{n},\mathbf{v})+\sum_{i=1}^{n}(\mathbf{l}_{i}·\mathbf{n})^+{\mathbf{c}_{light}}_i f_{lit}(\mathbf{l}_i,\mathbf{n},\mathbf{v}). cshaded=funlit(n,v)+i=1∑n(li⋅n)+clightiflit(li,n,v).
光照部分和非光照部分分别对应着:
f u n l i t ( n , v ) = 1 2 c c o o l , f l i t ( l i , n , v ) = s i c h i g h l i g h t + ( 1 − s i ) c w a r m , ( 5.21 ) \begin{aligned} f_{unlit}(\mathbf{n},\mathbf{v}) &= \frac{1}{2}\mathbf{c}_{cool}, & \\ f_{lit}(\mathbf{l}_i,\mathbf{n},\mathbf{v}) &= s_{i}\mathbf{c}_{highlight}+(1-s_i)\mathbf{c}_{warm}, \end{aligned}\qquad(5.21) funlit(n,v)flit(li,n,v)=21ccool,=sichighlight+(1−si)cwarm,(5.21)
其中冷色的非光照贡献进行了一定的调整,使结果看起来更像原始方程式。
在大多数通用渲染应用中,材质属性中变化的值如 c s u r f a c e \mathbf{c}_{surface} csurface会存储到顶点数据中,更常见的,会存储在纹理中(第六章会讲到)。当然,为了使这个样例更加得简化,我们会假设 c s u r f a c e \mathbf{c}_{surface} csurface在整个模型上是恒定的。
这个样例实现将会用到着色器的动态分支能力来在所有光源上进行循环。虽然这个粗暴的方法可以处理好相当简单的场景,但是在场景变得巨大且复杂同时拥有非常多的光源时表现得并不适应。高效掌控巨大光源数量得渲染技术将会在第20章中谈到。同样,出于简洁性的考虑,我们将会仅支持一种光源类型:点光源。尽管样例非常的简单,但是却是对我们前面涉及到的内容的最好的一次实践。
着色模型并不是独立实现的,而是在一个更大的渲染框架下进行工作。这个样例是在一个简单的WebGL 2应用程序中实现的,它是从由Tarek Sherif的WebGL 2样例的“冯氏着色立方体”修改而来,当然,应用到更加复杂的框架上的原理还是一致的。
我们将会讨论一些应用中一些GLSL着色代码和JavaScript WebGL的调用命令。目的并不是特定去教授WebGL API,而是展示通用的实现原理。我们将会以从内而外的顺序来浏览整个实现样例,从像素着色器开始,然后是顶点着色器,最后是程序内的图形API调用。
在真正的着色器代码之前,着色器源包括了着色器输入和输出。正如我们在第3.3小节中讨论的那样,使用GLSL技术,着色器输入会归为两类。其中首先就是uniform input集(统一输入),他们拥有由应用程序进行设置的值,且在一个drawcall内保持不变。第二类包括varying input(变化输入),他们的值在着色器(像素或者顶点着色器)调用之间会发生改变。这里我们看到像素着色器变化输入的定义,在GLSL中用in来标记,以及它的输出:
in vec3 vPos;
in vec3 vNormal;
out vec4 outColor;
这个像素着色器有一个单独的输出,即最终着色的颜色。像素着色器的输入对应顶点着色器的输出,在进入到像素着色器之前,这些值会在三角形上进行插值。这个像素着色器有着两个变换的输入:表面位置和表面法线,两者都位于应用程序的世界空间坐标系中。统一输入的数量则更多,所以为简洁起见,我们将仅展示其中两个与光源相关的统一输入的定义:
struct Light{
vec4 position;
vec4 color;
};
uniform LightUBlock{
Light uLights[MAXLIGHTS];
};
uniform uint uLightCount;
由于这些是点光源,每个光源的定义都报刊了一个位置和颜色。他们都定义为vec4而不是vec3,这是为了符合GLSL std140的数据布局标准的要求。尽管,在这种情况下,std140布局可能会导致一些空间上的浪费,它简化了确保 CPU 和 GPU 之间数据布局一致的任务,这就是我们在本示例中使用它的原因。Light结构体数组被定义到一个统一命名块之中,这也是GLSL的特性,将一组统一变量绑定到一组来缓冲物体,以更快得进行数据传输。数组的长度被定义为应用程序在一个drawcall内允许的最大光源数量。后面我们将会见到,在着色器编译之前。应用程序会在着色器源中将字符串MAXLIGHTS用正确的值(本例中是10)替换掉。统一整型uLightCount是在一个drawcall上活动的光源数。
下面,我们将会观察一下像素着色器代码:
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 = Ou; 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);
}
}
对于光照部分,我们有对应的函数定义,它会有main()函数进行调用。总的来说,这只是简单的将公式5.20和5.21用GLSL实现了一下。注意 f u n l i t ( ) f_{unlit}() funlit()和 c w a r m \mathbf{c}_{warm} cwarm的值是作为统一变量输入的。因为这些在整个drawcall上都是恒定的,应用程序可以计算出这些值来节省GPU。
像素着色器使用了几个内置的GLSL函数。 reflect ( ) \text{reflect}() reflect()函数可以将一个向量以第二个向量定义的平面进行反射,在本例中就是光向量在表面法线定义的表面上进行的反射。因为我们希望光向量和反射向量都指向远离表面的方向,我们需要在执行 reflect ( ) \text{reflect}() reflect()函数之前对光向量进行取反。 clamp ( ) \text{clamp}() clamp()函数有三个输入。其中的两个用来定义对第三个输入进行夹取操作的空间范围。clamp操作的特殊情况就是以0和1为界(这对应着HLSL中 saturate ( ) \text{saturate}() saturate()函数),它快速,高效甚至在大多数GPU上无消耗。这就是我们在这里使用它的原因,尽管我们只需要将值从0的那一侧进行截取,因为它不可能到达1。函数 mix ( ) \text{mix}() mix()函数同样有着三个输入,它是在其中的两个(warm color 和 highlight color)上进行线性插值,插值的因子是第三个输入。在HLSL中这个函数称为 lerp ( ) \text{lerp}() lerp(),即“线性插值”的意思。最后, normalize ( ) \text{normalize}() normalize()函数是将向量除以其长度 ,使其长度变为1.
现在,让我们看向顶点着色器。我们并不会展示它的任何的统一的定义,因为我们已经在像素着色器中看到一些统一定义的样例了。但是变化输入和输出的定义还是值得一看的:
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变量是任何顶点着色器都需要的输出。
注意,法向量在顶点着色器中并没有被归一化。他们不需要进行这个操作,因为他们在原始的网格数据中的长度就为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)。正如本书前面提到的,虽然“像素着色器”这种叫法在某些方面不太精确,但它是更常见的用法,我们在本书中也会一直遵循这种用法。在这段代码中,MAXLIGHTS也会最终使用合适的数值进行替换。大部分渲染框架都会执行一个预计算着色器操作。
还有更多的程序向的代码用来做配置统一输入,初始化顶点数组,清理,绘制,等等,这些你在程序项目中基本都可以看到,并且他们也会在相应的API指引中得到解释。我们这里的目标是让大家对于着色器在他们自己的编程环境下作为单独的处理器有一个初步的认知。在此之上,在这个点上我们就不再纠结。
渲染框架很少像我们简单例子中一样只实现一个单独的着色器。通常,我们需要一个专门的系统来掌控多个材质,着色模型,以及应用程序使用的着色器。’
正如前面章节解释的那样,一个程序中的一个着色器对应着GPU可编程着色阶段中的其中一个。正因如此,它属于是一种低级图形 API 资源,且不是艺术家可以直接与之交互的某种东西。相对应的,材质(material)是一种面向艺术家的对表面外观的封装。材质在某些时候也描述了非可视化的层面,例如碰撞属性,针对这一点我们并不会进行深入,因为他们不在本书的涉及范围内。
虽然材质是通过着色器来实现的,但是两者间并不是一一对应的关系。在不同的渲染条件下,同样的材质可能应用不同的着色器。一个着色器也可以在多个材质上进行共享。最普遍的情况就是参数化材质。以最简单的形式来看,材质参数化需要两个类型的材质实体:材质模板(material template)和材质实例(material instance)。每个材质模板描述的是一类材质,并且拥有着一组参数,依照参数类型他们可以被赋予数值,颜色或者是纹理值。每个材质实例对应着一个材质模板外加一组特定的参数值。一些渲染框架(如Unreal Engine)允许更加复杂的层级架构,其中材质模板可以从其他的模板中继承。
参数可以在运行时进行解算,手段是通过将统一输入流送到着色器程序,或者也可以在编译期进行解算,手段是通过在着色器编译之前对值进行替换。一种常见的编译期参数类型是布尔开关,它控制给定材料特性是否激活。这可以让艺术家通过材质用户界面里的一个勾选框来进行控制,或者直接由材质系统来程序性得调控,例如,在物体对象的视觉效果可以忽略不计的情况下,可以减少远距离物体的着色器消耗。
虽然材质参数可能与着色模型的参数一一对应,但情况并非总是如此。材质可能会将一个给定着色模型的参数值固定到某一具体值,例如表面颜色。可选地,着色模型参数可能以一系列材质参数参与的复杂计算结果,以及插值后的顶点和纹理值作为输入。在某些情况下,像表面位置、表面朝向甚至于事件这些参数都可以直接参与计算。基于表面位置和朝向的着色在地面材质中尤为常见。例如,高度和表面法线可以可以用来控制雪的效果,通过在高海拔水平面和几乎水平的表面上混合白色。基于时间的着色通常应用在动画材质中,例如闪烁的霓虹灯。
材质系统的其中一项重要任务是将多种着色器函数分成单独的元素并且控制他们的编译方式。这种类型的组合在很多情况下都是有用的,包括以下内容:
如果图形API中以核心特性提供了这种类型的着色器代码模块的话会非常的方便。不过,不像是CPU代码,GPU着色器不允许代码片段的编译后链接。每个着色器阶段程序是按单元进行编译的。着色器阶段间的隔离性提供了一些有限制的模块特性,这在某种程度上适配我们列表上的第一项:将表面着色(通常在像素着色器上执行)和几何处理(通常在其他的着色器阶段)。但是适配并不完美,因为每个着色器也要执行其他操作,并且其他类型的组合也需要去控制。在这些限制之下,材质系统实现所有类型的组合的唯一方法就在源码级别。这主要涉及到一些字符串操作(如级联和替换),经常是通过C风格的预处理指令(如#include, #if, #define)。
早期的渲染系统的着色器变体数量相对较少,而且通常每个都是手动编写的。这有一些优势。例如,每个变体都可以在充分了解最终着色器程序的情况下进行优化。然而,随着变体数量的增长这个方法很快变得不切实际。当把所有不同的部分和选项考虑在内时,可能的不同着色器变体的数量将会非常巨大。这就是为什么模块化和可组合性如此重要。
当设计一个控制着色器变体的系统时,第一个要解决的问题就是,不同选项之间的选择是在运行时通过动态分支执行,还是在编译时通过条件预处理执行。在老一点的硬件上,动态分支经常是不可行或者及其缓慢的,因此运行时选择在当时并不构成一个选项。那时,变体时在编译期进行控制的,包括不同光源类型计数的所有可能组合。
相对的,现代GPU对动态分支的掌握已经非常良好了,特别是当分支在一个drawcall上对所有像素都表现一样时。如今许多的功能变化,如光源数量,是运行时控制的。然而,向着色器增加相当大数目的功能性变化会有不同的代价:寄存器数量的增加和占用率的相应减少,从而影响性能。更多内容参考第18.4.5小节。因此,编译期变化依然有价值。它可以避免一些永远不会执行的复杂逻辑。
例如,让我们设想一个支持三种不同类型光源的应用程序。其中的两种光源类型很简单:点光源和方向光。第三种类型是聚光灯,支持表格照明模式和其他复杂功能,需要大量着色器代码来实现。但是,假设聚光灯在该应用内的使用相对稀少,少于5%所有光源数。在过去,可能会针对每个可能的三种光源以某个数目进行的组合编译一个单独的着色器变体。虽然这种做法在现如今不再需要了,编译两个单独的变体可能仍然是有益的,一个用于聚光灯的计数等于或大于 1 的情况,另一个用于此类灯的计数正好为 0 的情况。由于其更简单的代码,第二个变体(最常用)可能具有较低的寄存器占用率,从而具有较高的性能。
现代材质系统将运行时和编译器着色器变化两个都实现了。尽管不再仅在编译时处理全部负担,但整体复杂性和变体数量不断增加,因此仍然需要编译大量着色器变体。例如,在游戏Destiny:The Taken King中的某些地方,在一个单独帧内会用到超过9000个编译的着色器变化。可能的变化数量是非常巨大的,例如,Unity渲染系统拥有接近1000亿的着色器变体。只有现在在使用的变体会被编译,但是着色器编译系统必须重写设计来管理可能变体的巨大数量。
材质系统的设计者尝试实施多种不同策略来实现这个设计目的。尽管这些策略有时表现为相互排斥的系统架构,但这些策略可以——而且通常是——组合在同一个系统中。这些策略如下:
图 5.13. 虚幻引擎材质编辑器。注意节点图中右边的竖列长条节点。这个节点的输入连接对应着渲染引擎使用的各种着色输入,包括了所有的着色模型参数。(材质样例由Epic Games提供)
有关更具体的示例,WebGL Insights 中的几章讨论了各种引擎如何控制其着色器管线。除了组合外,对现代材质系统来说还有几种其他的重要的设计考虑,例如以最少的着色器代码重复支持多个平台的需求。这包括功能上的变化,以解决平台、着色语言和 API 之间的性能和功能差异。Destiny着色器系统是这个类型问题的代表解决方案。它使用专有的预处理器层,该层采用以自定义着色语言方言编写的着色器。这可以允许以各种不同的着色器语言和实现,来书写与平台无关的材质,当然中间存在一个自动翻译的过程。虚幻引擎和Unity引擎都有着类似的系统。
材质系统也需要保障性能。除了对着色变体的专门编译,还有一些其他常见的材质系统可以执行的优化。Destiny着色器系统和虚幻引擎自动检测在一个drawcall上恒定的计算(例如warm color和cool color的计算)并将他们移出着色器之外。另一个例子是 Destiny 中使用的范围系统来区分以不同频率更新的常量(例如,每帧一次,每盏灯一次,每个对象一次),并在适当的时间更新每组常量以减少 API 开销。
如我们所见,实现着色方程是决定哪些部分可以简化、计算各种表达式的频率以及用户如何修改和控制外观的问题。渲染管线的最终输出是颜色和混合值。关于抗锯齿、透明度和图像显示的其余部分详细说明了如何组合和修改这些值以进行显示。
试想,一个大的黑色三角形在白色背景下缓慢得移动。当某个屏幕上的网格单元被三角形覆盖时,代表该单元的像素值应该平滑地降低强度。在基本渲染器中通常会发生的情况是,当网格单元的中心被覆盖时,该像素的颜色立即从白色变为黑色。标准的GPU渲染也不例外。参考图5.14中最左边的一列。
图 5.14. 上边的一行显示了对三角形、线和一些点的抗锯齿的不同等级。下边的一行是上边那行的放大版。最左边的一列是完全没有使用抗锯齿技术,即每个像素进行一次采样。中间的一列是在每个像素上进行四次采样(以网格模式),右边一列每个像素使用 8 个采样点(在 4 × 4 棋盘格中,采样了一半的方块)。
三角形以像素为单位显示为占用或者没有占用该像素。线的绘制有着同样的问题。这种类型的问题导致边上总是参差不齐的样子,因此会将这种视觉上的缺陷称为是“锯齿”(jaggies),当画面动起来时就会呈现处一种“爬虫”(crawlies)的样子(译者注:应该是想表达当锯齿动起来时会有闪烁的现象,就像蠕动的虫子)。这种类型的问题更加正式的命名就是锯齿(aliasing),而试图去解决这种问题的技术就叫做抗锯齿技术(antialiasing)。
采样理论和数字滤波这些已经是非常大的课题,关于他们都有专门的书籍。因为这是渲染中的一个关键领域,下面也会对采样和滤波理论进行一定程度的介绍。我们将会在后面看看在实时渲染中现在对抗锯齿能够做到什么。
图像渲染过程的从本质上讲是一项采样任务。所以图像的生成即是对三维场景进行采样,以获得图像上每个像素(离散像素数组)的颜色值。为了使用纹理映射(第6章),需要对纹素进行重新采样以在多种不同情况下获得优质的效果。为了以动画的形式生成图像序列,通常动画间时间间隔都是一致的。这个小节就是来介绍采样、重构以及滤波。出于简洁性的考虑,大部分材料的介绍都是从一个维度上切入。这些概念可以很自然得扩展到2维,从而可以应用于处理2维图像。
图5.15.展示了连续信号是如何以均匀的空间间隔进行采样的,即,离散化。这个采样(sampling)过程的目的是表示数字信息。在这种做法之下,信息的数量会被减少。然而,采样得到的信号需要被重构来重新得到原始信号。这是通过对采样信号进行滤波(filtering)来完成的。
不论采样是何时完成的,都有可能会产生锯齿问题。这是一个我们不希望看到的缺陷,是需要我们去与锯齿对抗以生成满意的图像。在老西部片中看到的一个典型的锯齿的例子是电影摄影机拍摄的旋转的马车轮子。因为轮辐的运动远比摄像机的记录要快,轮子反而看起来像是旋转得更慢了(正向和负向的),或者看起来像是它完全没有进行旋转。这个现象可以参考图5.16.。这个现象的产生的原因是轮子的图像是以一定的时间间隔来进行获取的,所以得名由采样时间所产生的混叠现象(temporal aliasing)。
图 5.16. 最上面的一行表示的是轮子的旋转(作为原始信号)。第二行是对第一行的不充分采样,这使他看起来好像是在反方向移动。这是由采样率过低导致的一种混叠现象的例子。在第三行,采样率为每个旋转采样两次,在这种情况下我们是没有办法判断轮子在往哪个方向旋转。这即是Nyquist limit。在第四行,采样率是高于每个旋转两次的,这样我们立马就能够判断出轮子在以顺时针的方向旋转。
计算机图形中的常见的混叠的例子是光栅化的线或三角形边的“锯齿”(jaggies),被称为“萤火虫”的闪烁的高光,以及当带有棋盘格图案的纹理被缩小时产生的闪烁(6.2.2小节)。
混叠现象总是在信号被以一个极低的频率采样时发生。被采样的信号会呈现出比原始信号更加低得频率。参考图5.17.。为了使得信号被“合理地”采样(即,从采样信号中可以重构出原始信号),采样频率必须得是原始被采样信号频率的两倍以上。这就是采样定理(sampling theorem),而采样频率称为是奈奎斯特频率或者奈奎斯特极限(Nyquist rate,Nyquist limit,为防止信号混叠需要定义最小采样频率),这是由Harry Nyquist(1889-1976),一位瑞典科学家在1928年发现的。Nyquist limit在图5.16中也有表示。该定理使用术语“最大频率”,这意味着信号必须是有带限(band-limited)的,这意味着没有任何频率可以高于某个限制。 换句话说,相对于相邻采样点之间的间距,信号必须足够平滑。
5.17. 蓝色实线表示原始信号,红色的圆圈表示均匀的空间采样点,绿色虚线是重构之后的信号。上半边的图像展示的是一个极低的采样率。进而可以看到,重构后的信号也呈现一个更低的频率,即原始信号的混叠。下半边展示了一个两倍于原始信号频率的采样率,而重构出的信号是一个水平线。可以证明,如果采样率再稍微提高一点,完美的重建是可能的。
当一个三维场景是以点采样进行渲染时通常是没有带限的。三角形的边缘、阴影边界和其他现象会产生不连续变化的信号,因此会产生无限大的频率。此外,无论采样如何紧密得执行,对象仍然可以小到根本不会被采样。因此,使用点采样的方法来渲染场景是没有可能完全避免混叠问题的,而且我们总是在采用点采样。但是,有时可以知道信号何时是带限的。一个例子是当把一个纹理应用于一个表面上。与像素的采样率相比,可以计算纹理样本的频率。如果这个频率低于Nyquist limit,那么在纹理采样上就无须再做额外的事。如果频率过高,那么就需要很多的算法来对纹理进行带限(6.2.2小节)。
给定一个带限的采样信号,我们现在回去讨论如果从采样信号中重构出原始信号。为此需要用到过滤器。图5.18展示了三种常用的滤波器。注意,滤波器的区域应该总是1,否则的话重构后的信号可能会被放大或者缩小。
图5.18. 左上角显示箱式滤波器,右上角显示帐篷滤波器。底部显示了 sinc 滤波器(此处已夹在 x 轴上)
在图5.19.中,箱式滤波器(最近邻)用于重建采样信号。这是最糟糕的一种滤波器,因为结果信号呈现出非连续的阶梯状。当然,出于它的简单性,在计算机图形学中会经常用到它。从图中可以看出,盒式滤波器放置在每个采样点上,然后进行缩放,使滤波器的最高点与采样点重合。 所有这些缩放和转换的函数的总和即是右侧显示的重构信号。
图5.19. 采样信号(左)被利用箱式滤波器进行重构。这是通过放置箱式滤波器在每个采样点而实现的,并且通过在y方向上进行缩放使得滤波器的高度和采样点的高度一致。最后这一切行为的总和即是重构出的信号(右)。
箱式滤波器可以由其他任何滤波器进行替代。在图5.20.中,帐篷滤波器,也称为是三角滤波器,是被用来左采样信号的重构。注意,这个滤波器在采样点间进行了线性插值,从而使它优于箱式滤波器,因为在这种方式下重构后的信号是连续的了。
图5.20. 采样信号(左)被使用帐篷滤波器进行了重构。重构后的信号被展示在了右边。
但是,单单使用帐篷滤波器而呈现出的重构信号的平滑度依然是不足够的;采样点处有突然的斜率变化。这说明单单帐篷滤波器并不是完美的重构滤波器。为了获得完美的重构需要用到理想的低通滤波器(low-pass filter)。信号的频率分量是正弦波: sin 2 π f \sin{2\pi f} sin2πf,其中 f f f是该分量的频率。鉴于此,低通滤波器移除频率高于滤波器定义的特定频率的所有频率分量。直观地说,低通滤波器去除信号的尖锐特征,即滤波器使其模糊。理想的低通滤波器是sinc滤波器(图5.18底部):
sinc ( x ) = sin ( π x ) π x . ( 5.22 ) \text{sinc}(x) = \frac{\sin{(\pi x)}}{\pi x}.\qquad(5.22) sinc(x)=πxsin(πx).(5.22)
傅里叶分析理论解释了为什么sinc滤波器是理想的低通滤波器。简言之,其缘由如下。理想的低通滤波器是频域中的箱型滤波器,当它与信号相乘时,它会去除滤波器宽度以上的所有频率。将箱型滤波器从频域变换到空域,得到了sinc函数。同时,乘法运算被转换成卷积函数,这就是我们在本节中使用的函数。
使用sinc过滤器来重构信号会带来一个更加平滑的结果,如图5.21.。采样过程会带入信号中高频分量(突变),而低通滤波器的工作就是移除这些分量。实际上,sinc滤波器会删除掉所有频率高于1/2采样率的正弦波。当采样频率是1.0时,公式5.22中展示的sinc函数是完美的重构滤波器(即,采样信号的最大频率必须小于1/2)。更一般地,假设采样频率是 f s f_s fs,即采样点之间地间隔是 1 / f s 1/f_s 1/fs。对于这种情况来说,完美地重构滤波器是 sinc ( f s x ) \text{sinc}(f_sx) sinc(fsx),他会删除掉所有高于 f s / 2 f_s/2 fs/2的频率。这在对信号进行重采样时是有用的(下一节)。但是,sinc滤波器的宽度是无限的并且在某些地方是负值,所以它在实际使用中用途较少。
图5.21. 这里,sinc滤波器被用来做信号的重构。sinc滤波器是理想的低通滤波器。
一个层面是低质量的box和tent滤波器,另一层面是不太实际的sinc滤波器,两个层面之间有一个有用的中间层。大部分被广泛应用的滤波器函数都是处在这两个极端之间的。所有的这些函数在某些维度上都接近于sinc函数,但是会在他们可以影响的像素数上有一些限制。最接近sinc函数的滤波器在其部分域中可能会出现负值。对于那些不期望有负值的应用程序,通常会去使用不会产生负值的滤波器(这类滤波器通常称为高斯滤波器 Gaussian filter,因为它们源自或类似于高斯曲线)。12.1小节进一步讨论了滤波器函数及其细化的应用。
无论使用了哪种滤波器,我们都得到了一种连续的信号。然而,在计算机图形学中我们无法直接显示连续信号,但是我们可以使用他们来将连续信号重新采样到另一个大小,即或者去放大信号,或者去消减信号。这也是我们接下来要讨论的内容。
重采样被用来去放大或者缩小采样信号。假设原始采样信号点位于整型坐标(0,1,2,…)上,即采样点之间是单位间隔的。进一步,假设我们希望新的采样点均匀得以 α \alpha α为间隔分布。对于 α > 1 \alpha>1 α>1,会进行缩小(下采样,downsampling)操作,而对于 α < 1 \alpha<1 α<1,则进行放大(上采样,upsampling)操作。
其中,放大操作是两者中相对较为简单的一个,那我们就从它开始介绍。假设被采样的信号经历像前面小节介绍的那样的重构。直观上,由于信号现在已经被完美重构并且连续,剩下需要做的就是将重构后的信号以希望的间隔进行重采样。这个过程可以参考图5.22.。
图 5.22. 左侧是被采样的信号,以及被重构后的信号。右侧,重构信号被以2倍的采样率进行重采样,即,进行放大操作。
但是,这种技术无法应用于缩小操作。原始信号的频率对于采样率来说太高而无法避免混叠。相反,它表明应该使用使用 sinc(x/a) 的滤波器从采样信号中创建连续信号。之后,就可以以期望的间隔进行重新采样。可以参考图5.23.。换句话说,通过使用 sinc(x/a) 的滤波器,低通滤波器的宽度增加,从而移除掉信号较高频率的部分。如图片中所示,滤波器宽度加倍以减少重采样率以达到原始采样率的一半。将这个应用到一个数字图像上,这就类似于一个模糊操作(移除高频部分)然后对图像以一个较低的解析度进行重采样。
图 5.23. 左侧是被采样的信号,以及被重构后的信号。右侧,采样双倍的滤波器宽度从而使得采样点之间的间隔也变成两倍,从而得到缩小操作。
在采样和滤波理论作为框架的支撑下,实时渲染中就可以使用多种多样的算法来减少走样现象。
如果采样和滤波过程进行得不顺利,那么三角形边缘就会产生可见的瑕疵。阴影边界,高光,以及一些其他的颜色会剧烈改变的现象可能会导致类似的问题。本小节讨论的一些算法可以帮助改善这些情况下的渲染质量问题。它们具有基于屏幕的公共线程,即它们仅对管线的输出样本进行操作。没有最好的反走样技术,因为每种技术在质量,捕捉清晰细节或其他现象的能力,移动过程中的外观,内存成本,GPU要求和速度方面都有不同的优势(译者注:以及相对而言的劣势)。
在图5.14中的黑色三角形例子中,透漏出低采样率的问题。此时,在每个像素晶格中心进行了一次单独的采样,所以关于这个晶格我们最需要知道的就是晶格中心是否被三角形覆盖到。通过在每个晶格上使用更多的采样点并且以一些方式对他们进行混合,可以获得效果更好的像素颜色。参考图5.24.。
图 5.24. 左侧,是一个红色三角形以及准备对它进行采样的一个像素,一个单独的采样点位于像素中心。由于三角形并没有覆盖到采样点,即便像素的很大一部分都被三角形覆盖到。右侧,每个像素使用了四个采样点,并且其中的两个被红色三角形覆盖,从而混合出一个粉色的像素颜色。
基于屏幕的反走样方法的通用策略是对屏幕使用采样模板,为采样点分配权重并求和从而产生出一个像素的颜色 p \mathbf{p} p:
p ( x , y ) = ∑ i = 1 n w i c ( i , x , y ) . ( 5.23 ) \mathbf{p}(x,y) =\sum^{n}_{i=1} w_i \mathbf{c}(i,x,y).\qquad(5.23) p(x,y)=i=1∑nwic(i,x,y).(5.23)
其中 n n n是对一个像素放置的采样点的数量。函数 c ( i , x , y ) \mathbf{c}(i,x,y) c(i,x,y)即采样颜色, w i w_i wi则是权重,范围是 [ 0 , 1 ] [0,1] [0,1],即采样点对最终颜色的贡献程度。采样的位置是依据其在1到n中的排序序号,并且函数也会可选得选择使用像素位置 ( x , y ) (x,y) (x,y)的整型部分。换句话说,采样点在屏幕上的位置均是不同的,并且可能像素与像素间的采样模板也是不同的。采样通常在实时渲染系统(以及大多数其他渲染系统)中是点采样。因此,函数 c \mathbf{c} c可以被认为是两个函数。首先,函数 f ( i , n ) \mathbf{f}(i,n) f(i,n)获取到屏幕上的一个需要进行采样的浮点位置 ( x f , y f ) (x_f,y_f) (xf,yf)。然后对这个位置进行采样,即获取这个点上的颜色。选用采样方法以及配置好的渲染管线来计算特定子像素位置的采样,这个通常是可以进行逐帧(或者逐应用)的设置。
反走样中的另一个变量是 w i w_i wi,即每个采样点的权重。这些权重值求和到1。实时渲染中使用的大部分方法都是去均匀分配采样点的权重,即 w i = 1 n w_i = \frac{1}{n} wi=n1。图形硬件的默认模式,即像素中心唯一一个采样点,即是上面反走样公式的最简单的情况。其中仅有一项,而这一项的权重是1,而采样函数 f \mathbf{f} f总是返回被采样像素的中心。
在单个像素上计算多个采样点的反走样算法被称为是超采样(supersampling,或oversampling)法。从概念上出发最简单地,全场景反走样(full-scene antialiasing, 即FSAA,也被称为是supersampling antialiasing,即SSAA),即是以一个更高的分辨率去渲染整个场景,然后过滤邻近采样点来渲染出图。例如,假设目标分辨率为1280 x 1024的一张图像。如果你渲染一张2560 x 2048的图像,然后对屏幕上2 x 2的像素区域求平均,那样一来实际上每个像素即使用了四个采样点,即使用了一个箱型滤波器,来生成最终的图像。参考图5.25.中的2 x 2的格子采样。这种方法有一定的代价,因为所有的子采样点必须进行完整的渲染和填充,且每个采样点还带有一个z-buffer深度值。FSAA的主要优势就是简单。另外,这个方法的低质量版本即是以两倍的采样率对屏幕的其中一个轴进行采样,因此也被称为是1 x 2或者是2 x 1超采样。通常,处于简洁性的考虑会直接采用2的倍数的分辨率以及一个箱型过滤器。NVIDIA的动态超分辨率(dynamic super resolution)特性即是从超采样的一种演变形式,其中场景被以一个更高的分辨率渲染,并且使用了13个采样点的Gaussian过滤器来生成显示的图像。
图 5.25. 一些采样方式之间的比较,每个像素的采样点由最少到最多。梅花形(Quincunx)共享角点采样,并将其中心采样的权重设置为像素最终颜色的一半。旋转的2×2晶格比直线的2×2晶格捕捉到更多接近水平边缘的灰度。类似的,与4×4网格相比,8 rooks模式捕获的线的灰度级更多,尽管使用的样本更少。
还有一个和超采样有关联的采样法,是基于累积缓冲区(accumulation buffer)的想法。为了替换掉更大的屏幕外缓冲区,这个方法使用了一个和目标图像有着相同分辨率的缓冲区,但是每个通道的颜色有着更多的位数。为了得到对场景的2 x 2的采样,会生成四张图像,随着视图的移动,根据需要在屏幕x或y方向上移动半个像素。每张生成的图像都是基于晶格内的不同采样位置。这样的作法下额外的代价即是每帧需要去对同样的场景渲染额外的次数,并且把结果拷贝到屏幕,这使得这个算法相对于实时渲染系统来说消耗有点承受不起。在生成较高质量图像且性能并不是瓶颈时,这个方法是比较有用的,因为任意数目的采样点,无论放置在哪里,都可以在每个像素上使用。累积缓冲区曾是一个独立的硬件。它受到OpenGL API直接的支持,但是在3.0版本中被弃用。在现代GPU上,累积缓冲区的概念可以通过像素着色器对输出缓冲使用一个更高精度的颜色格式来实现。
当类似于物体边缘,高光,硬阴影等现象导致的颜色剧烈变化时,就需要额外的采样点了。为了避免走样,从阴影方面来讲的话,可以使其更加的柔和;而对于高光的话,可以使其更加得平滑。可以增加特定对象类型的大小,例如电线,以确保它们在其长度上的每个位置至少覆盖一个像素。物体边缘的走样依旧是一个主要的采样问题。可以使用分析方法,在渲染过程中检测对象边缘并考虑其影响,但这些方法通常比简单地获取更多采样消耗更大,同时其鲁棒性也不好。但是,GPU的类似保守光栅化和光栅化顺序视图等功能提供了新的可能性。
像超采样和累积缓冲区这样的技术通过生成完全由单独计算的着色和深度指定的样本来运行。总体上收益较低而消耗颇高,因为每个采样点都需要走一遍完整的像素着色流程。
多重采样反走样(multisampling antialiasing,MSAA)通过逐片元得计算表面着色并且在采样点间分享这个结果来减少计算消耗。我们假设像素可能有四个采样位置 ( x , y ) (x,y) (x,y),每个都拥有各自得颜色和z-buffer,但是像素着色器仅仅在一个片元对象上计算一次。如果所有的MSAA的放置的采样点都被片元所覆盖,那么着色采样将在像素的中心进行计算。而如果片元仅覆盖一小部分采样位置,则可以移动着色样本的位置,以更好地表示覆盖的位置。这样的作法可以避免着色采样到纹理边缘以外的部分。这个位置调整被称为是质心采样或质心插值(centroid sampling 或 centroid interpolation),它是由GPU自动完成的,当然前提是激活这项功能。质心采样避免了非三角形的问题,但是可能会导致导数计算所导致的错误结果。参考图5.26.。
图 5.26. 中间,一个像素上有两个物体覆盖到了它。红色的物体覆盖了3个采样点,蓝色的仅仅只有一个。像素着色器计算出的位置用绿色来表示。因为红色三角形覆盖了像素的中心,这个位置被用到了着色器计算中。蓝色物体的像素着色器则是在采样位置进行计算。在MSAA中,颜色和深度分别独立得存在了所有四个位置上。右侧展示了EQAA的2f4x模式。四个采样点现在有着4个ID值。它记录着包含颜色和深度构成的表中的索引。
MSAA比单纯的超采样模式要快,因为片元仅仅被着色了一次。它集中于以更高的采样率对片元的像素覆盖进行采样,并共享计算出的着色。通过进一步解耦采样和覆盖,可以节省更多内存,这反过来又可以使反走样速度更快——越少接触内存,那么渲染就越快。NVIDIA在2006年引入了覆盖采样反走样(coverage sampling antialiasing,CSAA),随之AMD推出了增强质量反走样(enhanced quality antialiasing,EQAA)。这些技术是通过以一个较高的采样率存储片元的覆盖。例如,EQAA的2f4x模式存储了颜色和深度两个值,而被四个采样位置进行共享。颜色和深度不再存储于特定位置,而是存储到一个表中。这样一来,每个采样位置仅需要一位来识别两个存储的值中的哪一个是和其位置相关联。参考图5.26.。覆盖的采样点指定了每个片元对于最终像素颜色的贡献程度。如果存储的颜色数量超出了,那么就会移除一个存储的颜色而它的岩本将会标记为为止。这些样本并不会对最终的颜色有任何贡献。对大多数场景来说,包含三个或三个以上可见不透明片元的像素相对较少,这些片元在着色方面有根本的不同,因此该方案在实践中表现良好。然而,为了获得最高质量,Forza Horizon 2采用了4 × MSAA,尽管EQAA在性能上有优势。
一旦所有的几何被渲染到一个多样本的缓冲区,那么就会开始执行一个解算(resolve)操作。这个过程会对样本颜色求一个均值来决定最终像素的颜色。值得注意的是,使用具有高动态范围颜色值的多重采样时可能会出现问题。这种情况下,为了避免这种类型的瑕疵,你通常需要在结算前对值进行一部色调映射。这通常是有一些消耗的,因此也可以使用一些色调映射函数的更简单的近似或者一些其他的方法。
默认情况下,MSAA是使用箱型滤波器来进行解算的。在2007年ATI介绍了自定义滤波器反走样(custom filter antialiasing,CFAA),它可以使用窄和宽的帐篷式过滤器,具备了稍微延伸到其他像素单元的能力。该模式现已被EQAA所取代。在现代GPU上,像素或者计算着色器可以访问MSAA的并且使用任意需要的重构滤波器,包括那种可以从周围像素样本中进行采样的滤波器。更宽的滤波器可以减少走样,尽管会失去一些细节。Pettineo发现立方平滑阶梯和宽度为2或者3个像素的B样条线滤波器能够总体上带来最佳的结果。这当然也是存在性能消耗的,因为即使模拟默认的长方体过滤器,使用自定义着色器解析也需要更长的时间,而更宽的过滤器内核意味着增加了样本访问成本。
类似地,NVIDIA的内置TXAA支持在比单个像素更宽的区域使用更好的重建过滤器,以提供更好的结果。TXAA和更新的MFAA(multi-frame antialiasing)方法都使用了TAA(temporal antialiasing),这是一种常见的使用前面的帧的结果来提升图像质量的技术。在某种程度上讲,这种技术之所以成为可能,是因为其功能允许编程者设置每帧的MSAA采样模式。这种技术可以攻略一些走样问题,如旋转的马车轮子,它也可以提升边缘的渲染质量。
试想现在要手动首先一个采样模式,生成一系列的图像,其中每次渲染都使用像素中的不同位置作为样本。这种偏移是通过在投影矩阵上附加一个微小的平移来完成的。生成的和求平均的图像数量越多,结果自然就越好。这个使用多个偏移的图像的概念已经被使用在了TAA算法中了。生成一个单独的图像,可能使用了MSAA或者是其他的方法,然后把前面的几帧图像混合进去。通常会使用2到4帧。旧图像的权重可能会以指数形式减小,但如果观众和场景不移动,这可能会产生帧微光的效果,因此通常只对最后一帧和当前帧使用相等的权重。当每个帧的样本位于不同的子像素位置时,这些样本的加权会比单个帧提供更好的边缘平均估算。因此,使用最新两帧平均值的系统可以提供更好的结果。每个帧不需要额外的样本,这正是这种方法如此吸引人的原因。甚至可以使用TAA技术来生成可以提升到显示器分辨率的低分辨率图像。此外,照明方法或其他需要许多采样才能获得良好结果的技术可以在每帧使用较少的采样,因为结果将在多个帧上混合。
虽然可以在没有额外采样成本的情况下为静态场景提供抗锯齿,但这种类型的算法在用于TAA时却存在一些问题。如果帧的权重不相等,则静态场景中的对象可能会呈现出微光的效果。快速移动的对象或快速移动摄影机可能会导致重影,即由于前一帧的贡献而在对象后面留下轨迹。重影的一种解决方案是仅对缓慢移动的对象执行这种反走样方法。另一个重要的方法是使用重投影(reprojection,第12.2节)更好地关联先前和当前帧的对象。在这种方案中,对象生成的运动矢量存储在单独的“速度缓冲器”(velocity buffer,第12.5节)中。这些向量用于将前一帧与当前帧相关联,即,从当前像素位置减去该向量,以找到该对象表面位置的前一帧颜色像素。那些不太可能是当前帧中表面一部分的样本将被舍弃。由于TAA不需要额外的样本,因此所需的额外工作相对较少,因此近年来这类算法受到了广泛的关注和应用。部分原因是延迟着色技术(第20.1节)与MSAA和其他多重采样支持不兼容。方法可以是千变万化的,且根据应用的背景和目标,已经发展演变了一系列避免缺陷和提高质量的技术。例如,Wihlidal的演示展示了EQAA、TAA和应用于棋盘采样模式的各种过滤技术如何结合起来,在降低像素着色器调用次数的同时保持质量。Iglesias-Guitian及其同事们总结了前人的工作并且展示了他们自己的方法去使用像素历史记录和预测来最小化滤波缺陷。Patney等人扩展了Karis和Lottes在虚幻引擎4中实现的TAA方法,以用于虚拟现实应用程序,增加了可变大小的采样以及对眼球运动的补偿(第21.3.2小节)。
有效的采样模式是减少走样(时间上的走样以及其他)的关键因素。Naiman表明,人类最容易受到近水平和近竖直边缘上走样的干扰。其次就是近45度的斜坡边缘。旋转网格超采样(rotated grid supersampling,RGSS)使用了一个旋转的四边形模板来在像素内提供更高的水平和垂直分辨率。图5.25展示了一个这种模板的样例。
RGSS模式是拉丁超立方体或N-rooks采样的一种形式,其中n个样本放置在n×n的网格中,每行每列有且仅有一个样本。使用RGSS,四个样本分别位于4×4子像素网格的单独的行和列中。相较于常用的2×2采样模板(这些边缘可能覆盖偶数数量的样本,因此表现效果有限),这样的模板在捕捉近水平和近竖直边缘是非常有优势的。
N-rooks是创建一个好的采样模式的开始,但仅仅这样还不足够。例如,采样点都沿着子像素的对角线分布,所以这对于那些平行于这条对角线的边缘效果就并不怎么令人满意了。参考图5.27.。
图 5.27. N-rooks采样。左侧即是N-rooks采样模板,但是它在捕捉平行于网格对角线的三角形边缘的时候表现不好,因为所有的采样点要么就都在三角形里面,要么就都在三角形外面。右侧的模板则在处理这种三角形边缘时更加有效。
为了更好得采样,我们希望避免将两个采样点放置得距离过近。我们同样也需要均匀分布样本,即在整个区域内均匀得散布样本。为了构建这样的模板,可以把分层抽样(stratified sampling)技术(如拉丁超立方体抽样)与其他方法(如抖动、Halton序列和Poisson圆盘抽样)相结合。
实际上GPU制造商经常把这种采样模板写死到他们的硬件上。图5.28.展示了一些实际中应用的MSAA的模板。对于TAA来说,覆盖模式即是编程者希望的,因为采样的位置可以帧到帧的变化。例如,Karis发现基本的Halton序列(Halton sequence)比任何GPU提供的MSAA模板都要表现良好。Halton序列在空间中生成的样本看起来是随机的,但相互之间差异性很小,也就是说,它们在空间中分布均匀,并没有没有明显的聚集。
图 5.28. AMD和NVIDIA图形加速器的MSAA采样模板。绿色圆形是着色的位置,红色方块是计算并保存的采样位置。从左到右依次:2×,4×,6×(AMD),8×(NVIDIA)采样。(由D3D FSAA查看器生成)
虽然子像素网格模式可以更好地近似每个三角形如何覆盖网格单元,但它也并非完全理想的。一个场景可以由任意大小(在屏幕上的大小)的物体组成,这意味着可能不存在可以完美捕捉他们的采样率。如果以这些微小的物体或特性形成一个模板,再以恒定的间隔采样可能会产生莫尔纹(Moir´e fringe)和其他干涉图案。
一个解决方案是使用随机采样(stochastic sampling),它提出了一个随机化的模板。图5.28.中那些模板肯定符合需求。想象一下远处有一个细齿梳子,而每个像素上都有几个齿。当采样模式与齿频率同步或不同步时,常用的这些采样模板可能会产生严重的走样。用一个不那么有序的采样模板可以打破这些模板。随机化倾向于用噪声代替重复的走样,而人类视觉系统相比于走样,对噪声显然会更宽容。结构较少的模板会有所帮助,但在逐像素重复时仍会出现走样。一个解决方案是在每个像素上使用不同的采样模板,或者去随着时间改变采样位置。交错采样(interleaved sampling)在过去数十年里在一些硬件上已经有所支持了,它即是在一个像素集中对每个个体使用不同的采样模板。例如ATI的SMOOTHVISION允许每个像素最多16个采样,以及最多16个不同的用户自定义采样模板,这些模板可以混合在重复模板中(例如以4×4像素的瓦片格)。Molnar以及Keller和Heidrich发现,对每个像素使用相同的模板时,使用交错随机采样可以最大限度地减少走样缺陷。
还有一些GPU支持的其他的算法值得一聊。其中,NVIDIA的Quincunx法(梅花形,后面就直接用原词Quincunx)会让采样不止影响一个像素。其中的“Quincunx”一词意味着五个对象的排布集合(四个位于方形上,而第五个位于中心),就像是骰子上五点的那个面。Quincunx多重采样反走样即是采用这个模板,把四个外侧采样点放置在像素的四角。参考图5.25.。每个角的采样的值又会对它相邻的四个像素有贡献。这里的权重不再平均分配(其他的实时渲染的方法中大多都会使用平均的权重),中心的采样会给一个 1 2 \frac{1}{2} 21的权重,而每个角的采样则是 1 8 \frac{1}{8} 81。在这种分配模式下,每个像素平均下来仅需要进行两次采样,而结果则会比2-sample的FSAA方法要好很多。这种模式近似了一种二维的帐篷式滤波器(而正如我们前文提到的,它是优先于箱式滤波器的)。
Quincunx采样还可以通过在单个像素上使用一个采样点来应用到TAA上。具体是,每一帧在每个轴上从前一帧偏移半个像素的位置(译者注:相当于是沿着对角线的方向移动从中心点到角上的点的距离),其中偏移方向会在帧之间交替。前一帧提供了像素的角的采样,然后用双线性插值来逐像素得计算贡献。结果会与当前帧会算一次均值。每帧权重相等意味着在静态视角下不会有闪烁的缺陷。对齐移动对象的问题依旧是存在的,但该方案本身编码简单,并且在每帧每像素仅使用一个样本的情况下提供了更好的外观。
当在一个单帧中使用时,Quincunx通过在像素边界上的采样共享实现了平均只有两个采样的低消耗。RGSS模式更适合捕捉更多接近水平和垂直边缘的渐变。FLIPQUAD模式最初是为移动图形而开发的,它结合了这两种理想的功能。它的优势是在每个像素上只有两次采样的消耗,而质量则和RGSS(每个像素四次采样)一样。这个采样模式可以参考图5.29.。Hasselgren等人还探讨了利用样本共享的其他廉价采样模式。
图 5.29. 左侧展示的是RGSS的采样模式。这在每个像素上需要四次采样。通过将这些位置移到像素边缘上,就可以实现像素间的采样共享。但是为了使这种模式能够运行,需要每个其他的像素都有个与之对应(镜像)的采样模板,可以参照右侧的图。这种最终的采样模式称为了FLIPQUAD,而仅需要每个像素上平均两个采样的消耗。
类似Quincunx,两点采样的FLIPQUAD模式也可以应用到TAA上并且在两帧之间分布。Drobot在他的混合重建抗锯齿(HRAA)工作中解决了哪种双样本模式最好的问题。对TAA,他探索了不同的采样模式,最终发现FLIPQUAD模式是五个测试过的模式中最好的一个。棋盘格模式(checkerboard pattern)也曾被与TAA结合起来讨论。El Mansouri讨论了使用两点采样MSAA来创建棋盘格渲染来减少着色器消耗,同时也能就解决走样问题。Jimenez使用SMAA,TAA和一连串其他技术来提供一种反走样质量可以随着渲染引擎负载改变的解决方案。Carpentier和Ishiyama是在边缘上进行采样,并且选软采样网格45°。他们将这种TAA的方法与FXAA结合起来来在更高分辨率的显示器上进行渲染。
走样经常是来自于边缘的,例如那些几何边缘,阴影边缘,或者高光的边缘。可以利用走样具有与其关联的结构这一知识来提供更好的反走样结果。2009年,Reshetov提出了一种针对这些线的算法,称之为形态学反走样(morphological antialiasing,MLAA)。“morphological”的意思即是“与结构或者形状相关的”。之前这个领域也有一些已经完成的研究,最早可以推回到1983年的Bloomenthal。Reshetov的论文重启了对多采样方法替代方案的研究,其更加强调边缘搜索和边缘重建。
这个形式的反走样运行起来和后处理类似。即,渲染就按通常的模式来进行,然后渲染的结果会进入到一个生成反走样结果的流程。从2009年以来已经发展演化出了非常多种的相关技术。那些依赖于额外缓冲区(例如深度和法线)的技术,可以提供一个更好的结果,例如子像素重建反走样(subpixel reconstruction antialiasing,SRAA),但也仅适用于几何边的反走样。还有一些分析性的方法,例如几何缓冲区反走样(geometry buffer antialiasing,GBAA)以及边缘距离反走样(distance-to-edge antialiasing,DEAA),会让渲染器去计算关于三角形边缘位置的额外的信息,即边缘距离像素的中心的距离。
最普遍的作法仅需要颜色缓冲区,这意味着它们还可以改善阴影、高光或各种先前应用的后处理技术(如轮廓边缘渲染,silhouette edge rendering)的边缘(第15.2.3节)。例如,定向局部化抗锯齿(directionally localized antialiasing,DLAA)基于一种观察现象,即几乎垂直的边应水平模糊,同样,几乎水平的边应与其相邻边垂直模糊。
更复杂的边缘检测方法试图找到可能包含任何角度边缘的像素,并确定其覆盖范围。潜在边缘周围的邻域会被进行检测,目的是尽可能重建原始边缘所在的位置。然后可以使用边缘对像素的影响来混合相邻像素的颜色。流程的概念视图见图5.30。
图 5.30. 形态学反走样。左侧是走样图像。目标是判断形成这个图像的可能的边缘朝向。中间,该算法通过检查邻域来记录边缘的可能性。通过给定的采样结果,共显示出两种可能的边缘位置结果。。在右侧,猜测的最符合的边缘用于按照估计的覆盖率比例将相邻颜色混合到中心像素中。整个过程需要对图像中的每个像素重复。
Iourcha等人通过检测像素内的MSAA采样来计算一个更佳的结果,从而提升了边缘查找。注意,边缘预测和混合可以带来一个更高精度的结果,相比于基于样本的算法。例如,每个像素使用四个样本的技术可以呈现出一个物体边缘混合的五个级别:没有样本覆盖,一个样本覆盖,两个覆盖,三个覆盖,四个覆盖。估算出的边缘可以有更多的位置,从而可以提供更好的结果。
有一些基于图像的方法可能会误入歧途。首先,如果两个物体之间颜色的区别低于算法的阈值,那么边缘可能就不会检测到。有三个或三个以上不同表面重叠的像素很难去描述。带有高对比度或者高频的元素的表面,其颜色可能会在像素与像素之间剧烈改变,可能会导致算法错过一些边缘。尤其,当应用形态学反走样时,文本质量通常会受到影响。物体的角可能也是一个问题,在一些算法下他们可能会呈现出“圆化”的外观。假设边是直的,曲线也会受到不利影响。单个像素的更改可能会导致边缘重建方式发生重大变化,从而在帧与帧之间产生明显的瑕疵。改善这个问题可以用到的一个方法即是去使用MSAA覆盖遮罩来改进边缘的确定流程。
形态学反走样的方法只使用到了提供的信息。例如,一个宽度比像素还要细的物体,例如一根电线或者绳子,在屏幕上没有覆盖像素中心位置的任何位置都会有表现的空缺(译者注:想象一根很细的绳子,其上有些部分被像素采样捕捉到了,表现出来了,而另一些部分没有被像素采样捕捉到,那么这部分像素就不会表现绳子的部分,从而形成了“断裂”)。使用多个采样点可以在这种情况下改善质量问题;而如果单单使用基于图像的反走样的话则无法解决这个问题。此外,根据看到的内容,执行时间可能会随之变化。例如,一个草地田野的画面需要的反走样的时间可能是天空画面的3倍之多。
综上,基于图像的方法可以提供反走样的服务,代价仅仅是些许的内存和处理成本,因此在许多应用中都会用到。而颜色的部分更是被从渲染管线中拆出来,从而使其更容易去修改或禁用,甚至可以作为GPU驱动程序选项而公开。两个最广泛的算法是:快速趋近反走样(fast approximate antialiasing,FXAA),和子像素形态学反走样(subpixel morphological antialiasing,SMAA),部分原因是两者都为各种机器提供了可靠(和免费)的源代码实现。两个算法都仅使用了颜色作为输入,其中SMAA更是可以去访问MSAA的采样结果。每个算法都有它自己的一堆可用的配置,从而在速度和质量之间取舍。通常,消耗基本都在每帧1到2毫秒的范围内,而视频游戏很乐于见到这种程度的消耗。最后,两个算法都可以利用到TAA。Jimenez展示了一个SMAA的改良实现,甚至比FXAA还要快速,并且描述了TAA的方法。总结来说,我们建议读者去广泛得阅读Reshetov和Jimenez得形态学技术,及其在视频游戏中的使用。
对于半透明物体的透光处理来说,还存在很多不同的方式方法。从渲染算法的角度,这些方法可以粗略得分为基于光的和基于视图的效果。基于光的效果是指光在物体的影响下而产生的衰减或偏移,进而导致场景中的其他物体以不同方式被照亮和渲染的效果。基于视图的效果则是半透明物体本身被渲染所用的效果。
在本小节中,我们将会处理最简单形式的基于试图的透明度问题,在这种形式下,半透明物体主要就是会领位于其后的物体的颜色产生衰减。其他更加复杂的基于视图的和基于光的效果,例如磨砂玻璃,光的混合(折射),基于透明物体的厚度所产生的光的衰减,以及反射率和透射率因视角而发生的变化,这些主题将在后面的章节中进一步讨论。
有一种让人产生透明的错觉的方法叫做纱窗式透明(screen-door transparency,后面直接使用原词)。这个方法的核心思想是用与像素对齐的棋盘格填充图案来去渲染透明的三角形。即,对三角形中的每隔一个的像素进行渲染,这样就使得其后的物体部分可见。通常屏幕上的像素之间足够得靠近,使得棋盘格图案本身并不可见。这种方法的一个主要缺陷就是,通常只能在屏幕的同一片区域上渲染唯一的一个透明对象(译者注:即如果透明物体后还有另一个透明物体,那么效果就会很差)。例如,如果一个红色的透明物体和一个绿色的透明物体在一个蓝色的物体上进行渲染,那么那么棋盘格图案上就仅会显示三种颜色中的其中两个。此外,50%的棋盘格是比较有限的。其他较大的像素遮罩可用于给出其他百分比,但这些遮罩往往会产生可检测的图案。
这种技术的一个重要的优势就是其简单性。透明物体可以在任何时间以任何顺序进行渲染,而且并不需要特殊的硬件支持。把所有物体都设置为不透明,那么透明度的有关问题也就不复存在了。同样的思路也用于消除剪切纹理边缘的锯齿,但在子像素级别,使用的是被称为alpha to coverage(A2C)的特性(第6.6小节会介绍)。
Enderton等人介绍了随机透明度(stochastic transparency),这是一种将子像素纱窗遮罩方法和随机采样法结合起来的技术方法。通过使用随机点画模式来表示片元的alpha覆盖率,可以创建一个合理但有噪声的图像。参考图5.31.。为了使结果看起来比较合理,每个像素上需要大量的采样,以及相应的相当一部分内存。而这种方式吸引人的是,不需要混合,而反走样、透明度处理和任何其他产生部分覆盖像素的现象都可以由单一机制所覆盖解决。
图 5.31. 随机透明度。可以在右下角中的放大图中看到产生的噪声效果。(图像来自NVIDIA SDK 11中的样例,由NVIDIA公司提供。)
大多数的透明度的算法都是将透明物体的颜色他所遮挡物体的颜色进行混合。为此,就需要用到透明通道混合(alpha blending,后面我们用alpha值混合的说法)的概念了。当一个物体被渲染到屏幕上时,每个像素会关联一个RGB颜色和一个z-buffer深度。而对于物体所覆盖的每一个像素又可以定义另一个组成部分,可以称其为alpha(α)。alpha是用来针对给定像素描述不透明程度和物体的片元的覆盖程度的值。alpha值为1.0时代表着物体是完全不透明的且完全覆盖了像素区域;alpha值为0.0时代表着完全不会有遮挡的效果,即,片元是完全透明的。
像素的alpha值根据具体情形可以表示不透明度,或者覆盖率,亦或两者都有。例如,肥皂泡边缘可能占像素的四分之三(0.75),并且可能是接近透明的,会允许十分之九的光通过,所以可以说它是十分之一的不透明(不透明程度为0.1)。他的alpha值就是 0.75 × 0.1 = 0.075 0.75 × 0.1 = 0.075 0.75×0.1=0.075。但是,如果我们使用MSAA或者类似的反走样方法,覆盖率也会被采样本身考虑进去。四分之三的样本会受到肥皂泡的影响。对于其中的每个样本,我们都会给它一个0.1的不透明度值作为alpha值。
为了让一个物体表现出透明的外观效果,可以在已有场景的基础上以小于1.0的alpha值将其进行渲染。物体所覆盖的每个像素将会从像素着色器收到一个输出的RGBα(也称为是RGBA)。通常是用over运算器(over operator)来对片元的值和原始像素的颜色进行混合,如下:
c o = α s c s + ( 1 − α s ) c d [ over operator ] , ( 5.24 ) \mathbf{c}_o = \alpha_s \mathbf{c}_s + (1-\alpha_s)\mathbf{c}_d [\textbf{over} \ \text{operator}],\qquad(5.24) co=αscs+(1−αs)cd[over operator],(5.24)
其中 c s \mathbf{c}_s cs是透明物体的颜色(称作是source), α s \alpha_s αs是物体的alpha值, c d \mathbf{c}_d cd是混合前像素的颜色(称作是destination), c o \mathbf{c}_o co就是将透明物体放置到已有场景之上的输出的颜色。在渲染管道发送 c s \mathbf{c}_s cs和 α s \alpha_s αs的情况下,像素的原始颜色 c d \mathbf{c}_d cd将被结果 c o \mathbf{c}_o co替换。如果输入的RGBα是完全不透明的,即 α s = 1.0 \alpha_s = 1.0 αs=1.0,那么依据方程像素的颜色会被完全替换为物体的颜色。
**样例:混合。**红色半透明物体被放到蓝色背景上进行渲染。假设在某个像素上物体的RGB着色为 ( 0.9 , 0.2 , 0.1 ) (0.9,0.2,0.1) (0.9,0.2,0.1),背景是 ( 0.1 , 0.1 , 0.9 ) (0.1,0.1,0.9) (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.6(0.9,0.2,0.1)+(1-0.6)(0.1,0.1,0.9), 0.6(0.9,0.2,0.1)+(1−0.6)(0.1,0.1,0.9),
所给出的结果是 ( 0.58 , 0.16 , 0.42 ) (0.58,0.16,0.42) (0.58,0.16,0.42)。
over运算器给出了被渲染物体的半透明的外观。通过这种方式实现的透明度是有效的,因为只要后面的物体可以透过它看到,我们就可以感觉到它是透明的。使用over运算器还可以模拟薄纱织物在真实世界中的效果。织物后面的物体被部分遮挡——织物的线其实并不透明。实际上,松散的织物的alpha覆盖率随角度而变化。我们这里的观点是alpha模拟的是材质所覆盖像素的程度。
over运算器在模拟其他透明效果时是不太令人信服的,最明显的情况就是透过彩色玻璃或塑料去进行观察。在现实世界中,蓝色物体前面的红色滤光片通常会使蓝色对象看起来很暗,因为蓝色物体对能够通过红色滤光片的光的反射性并不是很好。参考图5.32.。当用over运算器来做混合时,结果是红色光和蓝色光按照一定的比例进行混合。而实际则是让两者做乘法运算更为合适,并加上透明物体自身的反射效果。第14.5.1节和第14.5.2节讨论了这种类型的物理透射。
5.32. 红色的方形丝织物和红色塑料滤光片,分别得到了不同的透明效果。注意影子的效果也是不同的。(图像摄影由Morgan McGuire提供。)
在基本混合阶段的运算器中,over运算器是透明效果的常用运算器之一。另一个有一些应用的运算就是加法混合运算,其中像素的值只是简单得进行求和。即,
c o = α s c s + c d . ( 5.25 ) \mathbf{c}_o = \alpha_s \mathbf{c}_s + \mathbf{c}_d .\qquad(5.25) co=αscs+cd.(5.25)
当我们需要一些发光的效果时,例如闪电或者火花,这些不需要去让像素衰减,而是相反需要让他们变得更亮,此时这种模式就表现得很好了。但是,这种模式从透明度的角度来讲其实效果并不是正确的,因为不透明的表面并没有透过透明物体显示出来。对于多层半透明表面,如烟或火,加法混合具有使现象颜色的效果变得饱和的效果。
为了正确得渲染透明物体,我们需要去在不透明物体之后再去渲染他们。这是通过现在在关闭混合的情况下先渲染所有的不透明物体,然后再开启over运算器来渲染透明物体。理论上我们可以一直开着over运算器,因为不透明物体的alpha值为1.0,这会显示source的颜色而完全遮挡调destination的颜色(译者注:source——遮挡别人的物体,destination——被遮挡的物体),但是这样做没有任何价值,而且还具有一定的消耗。
z-buffer的一个限制是,每个像素仅存储一个物体。如果有多个半透明物体都覆盖了同一个像素的话,单单一个z-buffer并无法承受去解算所有可见物体的效果。当使用over运算器时,在任意给定像素的透明物体表面通常需要以从后向前的顺序进行渲染。不这样做可能会产生错误的效果。达成这种顺序的其中一个方法就是按其质心沿视图方向的距离对相互独立的对象进行排序。这种粗略的排序运行良好,但在一些情况下可能会有一些问题。首先,顺序仅仅是一种近似,因此,被归类为较远的物体可能实际上是在被认为较近的物体前面。对于所有视图角度,互相穿插的物体无法在每个网格的基础上解算,除非将每个网格分解为单独的片段。参考图5.33.中左侧图像的例子。即使是单个带有凹面的网格,当在屏幕某个视图角度上出现重叠,也会出现排序问题。
5.33. 左侧的模型是使用z-buffer渲染得透明度效果。以任意得顺序去渲染网格会带来一系列严重的问题。右侧,深度剥离提供出了正确的外观效果,但是是以额外的通道的消耗为代价的。(图像由NVIDIA提供)
尽管如此,由于它的简单性和速度,以及不需要额外的内存或是特殊的GPU支持,以一个粗略的顺序来去进行透明度的解算依旧非常常见。一旦确定了使用这种方法,通常最好去关掉z-buffer替换。也就是说,z-buffer仍然正常测试,但幸存曲面不会改变存储的z-depth;最接近的不透明曲面的深度保持不变。在这种方式下,所有的透明物体都至少会以某种形式出现,而不是当摄像机的旋转改变排序时物体突然得出现或者消失。其他还有一些技术可以帮助提高外观效果,例如在移动过程中绘制每个透明网格两次,即首先渲染背面,然后渲染正面。
over运算方程也可以稍做调整,从而让从前到后的混合也可以给出相同的结果。这个混合模式被称为是under运算器:
c o = α d c d + ( 1 − 1 α d ) α s c s [ under operator ] a o = α s ( 1 − α d ) + α d = α s − α s α d + α d . ( 5.26 ) \mathbf{c}_o = \alpha_d \mathbf{c}_d + (1 -1 \alpha_d)\alpha_s \mathbf{c}_s \quad [\textbf{under} \ \text{operator}] \\ \mathbf{a}_o = \alpha_s(1-\alpha_d) + \alpha_d = \alpha_s - \alpha_s \alpha_d +\alpha_d.\qquad(5.26) co=αdcd+(1−1αd)αscs[under operator]ao=αs(1−αd)+αd=αs−αsαd+αd.(5.26)
注意under运算器要求destination去维护一个alpha值,而在over运算器中则没有。换句话说,被混合的更近的透明曲面——destination,不是不透明的,因此需要有个alpha值。under公式和over类似,但是source和destination是对调了的。此外,注意计算alpha的公式是和顺序无关的,里面source和destination的alpha是可以互换的,而结果不受影响。
alpha的公式来自于将片元的alpha视为覆盖率。Porter和Duff注意到,既然我们不知道每个片元的覆盖范围的形状,那么我们假设每个片元对其他的覆盖和它的alpha值成比例。例如,如果 α s = 0.7 \alpha_s = 0.7 αs=0.7。像素就会以某种方式划分成两个区域,其中0.7的部分被source片元覆盖,而0.3的则没有。除非另有所知,destination片元覆盖的,假设为 α d = 0.6 \alpha_d = 0.6 αd=0.6,将会被source片元以一定的比例覆盖,该公式具有几何解释,如图5.34所示。
5.34. 一个像素和两个片元s和d。通过沿不同的轴对齐两个片段,每个片段以一定的比例覆盖另一个片段,即其是互不相关的。两个片元片覆盖的面积相当于under的输出的alpha值 α s − α s α d + α d \alpha_s - \alpha_s \alpha_d +\alpha_d αs−αsαd+αd。这可以转化为将两个区域相加,然后减去它们重叠的区域。
可以使用under公式将所有透明对象绘制到单独的颜色缓冲区,然后使用over运算器将此颜色缓冲区合并到场景不透明视图的顶部。另外一个对under运算器的使用是运行一个与顺序无关的透明度算法(order-independent transparency,OIT),也被称为是深度剥离(depth peeling)。顺序无关意味着程序不需要去执行排序过程。深度剥离背后的核心思想是去使用两个z-buffer以及多个通道。首先就是一个渲染通道,从而令所有表面(包括透明表面)的z-depth,都位于第一个z-buffer中。在第二个通道中去渲染所有的透明物体。如果物体的z-depth与第一个z-buffer的值匹配,那么我们就知道这是最近的透明物体,然后保存其RGBα到一个单独的颜色缓冲中。任何透明对象,如果其超出第一个z-depth的话,我们还会保存其的z-depth来“剥离”掉该层。这个z-depth就是第二近的透明物体对象的距离。后续过程继续使用under运算器剥离和添加透明层。我们在经过一定次数后停止,然后在不透明图像上混合透明图像。参考图5.35。
图 5.35. 每个深度剥离通道都进行于其中一个透明层的绘制。左侧是第一个通道,展示了眼睛最直观能够看到的那一层。中间的图像是第二层,显示了每个像素上第二近的透明物体,图示情况即是物体的背面。右侧是第三层,是第三近的透明表面。最终结果可以参考图 14.33.(图像由 Louis Bavoil提供。)
从这个方法上还发展出了多种变体。例如,Thibieroz提供了一种从后向前的算法,其优势在于可以立即混合透明值,这意味着无需单独的alpha通道。深度剥离的一个问题就是去考虑到底需要多少层才足以捕捉所有的透明层。有一个硬件的解决方案是提供一个像素绘制的计数器,让其来记录渲染期间有多少像素被进行了写入;当一个通道上没有像素被渲染,那就意味着渲染可以结束掉了。使用under运算器的优点即是最重要的透明层——即眼睛最直观能够观察到的——是最早进行渲染的。每个透明表面总是在增加它所覆盖的像素的alpha值。如果像素的alpha值接近1.0,那么即是混合过程令像素变得越来越接近不透明,因此,距离更远的物体对画面的影响越发的小。当一个通道渲染的像素数量低于某个最小值时,从前到后的剥离过程可以缩短,或者可以指定固定数量的通道。而从后向前的剥离并无法和这个一样运行良好,因为最接近的(通常也是最重要的)层是最后绘制的,所以可能会在前面的决策中被丢弃掉。
尽管深度剥离是非常有效的,但是它可能会很慢,因为所有透明物体中每个剥离的层都是作为一个单独的渲染通道。Bavoil和Myers提出了双深度剥离,其中在每个通道中剥离两个深度剥离层,最近和最远的层,从而将渲染过程的数量减少一半。Liu等人研究了一种桶排序法,其可以在一个单独的通道中捕捉最高32个层。这个类型方法的一个缺陷就是其需要相当一定量的内存来存储所有层的排序。如果再通过MSAA或类似方式进行反走样的话那么消耗就可能会剧增。
以交互速率将透明物体适当地混合在一起,相关算法我们并不缺乏,但是如何有效地将这些算法映射到GPU确实是一个关键问题。再1984年Carpenter提出了A-buffer,是另一种形式的多重采样。在A-buffer中,每个渲染的三角形会为它所完全或者部分覆盖的屏幕网格创建一个覆盖遮罩(coverage mask)。每个像素上都会存储所有相关片元的列表。不透明片元可以剔除掉在其后的片元,这和z-buffer是类似的。所有的片元被存储起来。一旦所有的列表都形成过后,就会通过便览片元和解析每个采样而得出一个最终的结果。
通过DirectX 11暴露出来的新的功能,使得在GPU上创建片元的关联列表这种想法变得可能。用到的特性包含无序访问视图(unordered access view,UAV)和原子操作,3.8小节有描述到。通过访问覆盖遮罩和在每个样本上计算像素着色器的能力可以激活通过MSAA的反走样。这种算法的工作原理是光栅化每个透明表面,并将生成的片元插入一个长数组中。除了颜色和深度外,还会生成一个单独的指针结构来将每个片元连接到像素中存储的前一个片元。然后去执行一个单独的通道,其中会进行一次填充整个屏幕的四边形的渲染,以便在每个像素处都进行像素着色器的计算。这个着色器通过追踪链接来获取到每个像素处的所有透明片元。然后对这个排序列表以从后向前的顺序进行混合,以得到最终的像素颜色。由于混合是由像素着色器执行的,不同的混合模式可以根据意愿去逐像素得指定。GPU和API也在不断发展,可以通过降低使用原子操作符的成本而进一步提高性能。
A-buffer的优势在于,与GPU上的链表实现一样,只分配每个像素所需的片段。某种程度上,这也算是一个缺陷,因为在渲染某一帧之前是无法确切知道所需的内存大小。一个布满了头发、烟雾以及其他潜在的可能会有很多重叠透明表面的物体可能会生成大量的片元。Andersson注意到,对于复杂的游戏场景,最多50个物体透明网格(如植被)以及最多200个半透明粒子可以发生重叠。
GPU通常会对内存资源(例如缓冲区和数组)进行提前的分配,对于使用链表的方法也不例外。使用者需要去决定多少内存才够,而在内存消耗光时也会导致明显的瑕疵。Salvi和Vaidyanathan提出了一种解决这个问题的办法,名为多层alpha混合(multi-layer alpha blending),是使用Intel引入的GPU的特性——像素同步(pixel synchronization)。参考图5.36.。这种能力提供了可编程的混合,而消耗还比原子法还小。他们的方法重新设计了存储和混合,以便在内存耗尽时能够优雅地降级。一个粗略的排序可以从他们的方法中收益。DirectX 11.3引入了光栅化顺序视图(3.8小节),是一种缓冲区,它允许这种透明度方法可以在任意支持这项特性的GPU上部署。移动设备有着类似的技术,名为平铺本地存储(tile local storage),它也允许去部署多层alpha混合。然而,这样的机制有性能代价,因此这种类型的算法可能会很昂贵。
图 5.36. 左上角执行的是传统的从后向前的alpha混合,因为不正确的排序而导致了渲染的问题。在右上角,使用A-buffer来给出一个完美的、不可交互的结果。左下角展示了多层alpha混合的渲染结果。右下角展示了A-buffer和多层图像(出于可见性的考虑将结果乘以了4).(图像由Intel的Marco Salvi 和Karthik Vaidyanathan提供。)
这种方法是在由Bavoil等人提出的k-buffer的想法的基础上构建出来的,其中,前几个可见层被保存并尽可能地排序,而较深的层则被丢弃并尽可能地合并。Maule等人使用k-buffer且使用加权平均法计算这些较远的深层。加权和和加权平均的透明技术是顺序无关的,是单通道的,并且几乎可以在任何GPU上运行。问题是他们没有将物体的顺序考虑在内。因此,比如说,在使用alpha来表示覆盖率,一条浅蓝色丝巾上的浅红色丝巾呈现出紫色,而正确的情况是看到一条红色围巾上露出一点蓝色。虽然几乎不透明的物体效果不佳,但这类算法对于可视化非常有用,并且适用于高度透明的表面和粒子。参考图5.37.。
图 5.37. 随着不透明度的增加,物体的顺序变得愈发重要。
加权和透明度公式:
c o = ∑ i = 1 n ( α i c i ) + c d ( 1 − ∑ i = 1 n α i ) , ( 5.27 ) \mathbf{c}_o = \sum^n_{i=1} (\alpha_i \mathbf{c}_i) + \mathbf{c}_d(1 - \sum^n_{i = 1} \alpha_i), \qquad(5.27) co=i=1∑n(αici)+cd(1−i=1∑nαi),(5.27)
其中,n代表透明表面的数量, c i \mathbf{c}_i ci和 α i \alpha_i αi表示透明度值的集合,而 c d \mathbf{c}_d cd是场景不透明部分的颜色。在渲染透明表面时,这两个总和将分别进行累加,,并且在透明通道的最后,这个公式会在每个像素上进行计算。此方法的问题是,第一个总和可能会饱和,即生成大于 ( 1.0 , 1.0 , 1.0 ) (1.0,1.0,1.0) (1.0,1.0,1.0)的颜色值,并且背景色可能会产生负面影响,因为alpha值的和可能超过1.0。
加权平均方程通常会更受到欢迎就是因为其可以避免这些问题:
c s u m = ∑ i = 1 n ( α i c i ) , α s u m = ∑ i = 1 n α i , c w a v g = c s u m α s u m , α a v g = α s u m n , u = ( 1 − α a v g ) n , c o = ( 1 − u ) c w a v g + u c d . ( 5.28 ) \mathbf{c}_{sum} = \sum^n_{i=1}(\alpha_i \mathbf{c}_i), \alpha_{sum} = \sum^n_{i=1}\alpha_i,\\ \mathbf{c}_{wav\ g} = \frac{ \mathbf{c}_{sum}}{\alpha_{sum}},\ \alpha_{av\ g} = \frac{\alpha_{sum}}{n},\\ u = (1 - \alpha_{av\ g})^n,\\ \mathbf{c}_o = (1 - u)\mathbf{c}_{wav\ g} + u\mathbf{c}_d. \qquad(5.28) csum=i=1∑n(αici),αsum=i=1∑nαi,cwav g=αsumcsum, αav g=nαsum,u=(1−αav g)n,co=(1−u)cwav g+ucd.(5.28)
第一行表示在透明渲染期间生成的两个单独的缓冲区的结果。每个表面都对 c s u m \mathbf{c}_{sum} csum有一定的贡献值,即根据其alpha值从而施加一个带权重的影响;几乎不透明的表面会贡献更多的颜色,而接近透明的表面则影响甚微。通过 c s u m \mathbf{c}_{sum} csum除以 α s u m \alpha_{sum} αsum可以得到一个加权平均的透明颜色。 α a v g \alpha_{av\ g} αav g时所有alpha值的平均。对于n个透明表面,值u是应用该平均alpha值 n次后destination(不透明场景)的可见性估算。最后一行是over运算器, ( 1 − u ) (1 - u) (1−u)代表了source的alpha值。
加权平均的一个限制是,对于相同的alpha,它会将所有的颜色均等得混合,而没有顺序之分。McGuire和Bavoil引入了加权混合顺序无关的透明度,从而给出了一个更加可信的结果。在他们的公式中,到表面的距离也影响着权重,而更近一些的表面会有更多的影响。此外,u的计算并不是求alpha的平均,而是通过乘以项 ( 1 − α i ) (1-\alpha_i) (1−αi)来并被1减,从而得到表面集合的真正的alpha覆盖率。这个方法可以产生一个视觉上更加可信的结果,如图5.38所示。
图 5.38. 观察同一个引擎模型的两个不同的相机位置,两者都是用加权混合顺序无关透明度来渲染的。在权重中引入距离的影响帮助我们分清了哪个表面距离观察者更近。(图片由Morgan McGuire提供。)
缺陷就是在一个大环境中,那些相对之间比较接近的物体在距离上的权重基本相等,使得结果和加权平均的结果区别并不大。此外,因为相机到透明物体的距离改变,深度权重可能会发生变化,但这种变化是渐进的。
McGuire和Mara将此方法进行了拓展,从而有一个看起来令人满意透射颜色效果。正如前文中提到,本节中讨论的所有的透明算法是将多种颜色进行混合而不是去过滤,从而模拟像素覆盖。为了给出一个颜色过滤的效果,不透明的场景被像素着色器读入,并且每个透明表面用它本身的颜色乘以它在场景中所覆盖的像素,最后把结果保存到另外一片缓冲区中。这片缓冲区中,不透明物体会受到透明物体的影响,然后在解算透明度缓冲区时替换调不透明场景。不像基于覆盖率的透明度,颜色投射是顺序无关的。
还有其他一些算法使用了本文介绍的几种技术中的部分元素。例如,Wyman将前人的工作依照内存要求、插入和合并方法、是否使用alpha或者几何覆盖、以及怎样舍弃处理的片元等来进行分类。他通过寻找前人研究之间的差别,他发现了两种新的方法。他的随机分层alpha混合方法使用k-buffer、加权平均和随机透明度。他的另一个算法则是Salvi和Vaidyanathan的方法的变体,使用了覆盖遮罩来代替alpha值。
尽管现在已经有大量的透明度相关的研究内容,渲染方法以及更丰富的GPU特性,但是在渲染透明物体方面依旧不存在完美的解决方案。如果您有兴趣的话,我们强烈建议去阅读Wyman的研究以及Maule等人的更加详细的关于透明算法的研究报告。McGuire的介绍提供了更广阔的视野,贯穿了其他相关现象,如体积照明、彩色透射和折射,本书后面将更深入地讨论这些现象。
over运算器也可以用来混合照片或者是物体的合成渲染。这个过程称为是合成(compositing)。在这种情况下,每个像素上的alpha值和RGB颜色值存在一起。alpha通道形成的图像有时被称为蒙版(matte)。它显示出物体的剪影的形状。参考图6.27。这个RGBα图像可以用来与其他这样的元素进行混合或者是与背景进行混合。
图 6.27. 左侧是一个植被纹理和它的1位的alpha通道纹理。右侧,先渲染一个单独的植被,对这份渲染进行拷贝然后旋转90度,我们构建出一套消耗较低的3维植被。
使用合成RGBα数据的一种方法是使用预乘alphas(premultiplied alphas,也称为associated alpha)。即,在使用之前,将RGB值与alpha值相乘。这会使合成over公式更加的高效:
c o = c s ′ + ( 1 − α s ) c d , ( 5.29 ) \mathbf{c}_o = \mathbf{c}'_s + (1 - \alpha_s)\mathbf{c}_d, \qquad(5.29) co=cs′+(1−αs)cd,(5.29)
其中 c s ′ \mathbf{c}'_s cs′是预计算的source通道,替换了公式5.25中的 α s c s \alpha_s \mathbf{c}_s αscs。预乘alpha还可以在不更改混合状态的情况下使用叠加和相加混合,因为现在在混合过程中添加了source的颜色。请注意,对于预乘的RGBα值,RGB分量通常不大于alpha值,尽管它们可以这样做以创建特别明亮的半透明值。
渲染合成图像和预乘alpha的操作是自然吻合的。一个渲染在黑色背景之上的反走样过的不透明物体提供了一个默认的预乘值。假设一个白色 ( 1 , 1 , 1 ) (1,1,1) (1,1,1)三角形沿着边缘覆盖了某些像素的40%。在反走样(及其细致的)的情况下,像素值将被设置到灰度为0.4,即我们将保存该像素的颜色 ( 0.4 , 0.4 , 0.4 ) (0.4,0.4,0.4) (0.4,0.4,0.4)。其alpha值,如果存下来的话,也将会是0.4,因为这就是三角形覆盖的区域。RGBα的值即是 ( 0.4 , 0.4 , 0.4 , 0.4 ) (0.4,0.4,0.4,0.4) (0.4,0.4,0.4,0.4),也就是预乘的值。
另一种存储图像的方式是使用未乘alpha(unmultiplied alpha,后面用英文术语),也被称为非关联alpha(unassociated alpha),甚至还有一种叫法——非预乘alpha(nonpremultilied alpha)。unmultiplied alpha即是它字面的意思:RGB值不和其alpha值做乘法运算。以上面的那个白色三角形为例,unmultiplied color为 ( 1 , 1 , 1 , 0.4 ) (1,1,1,0.4) (1,1,1,0.4)。这种表示法的优势在于可以存储三角形原始的颜色,但是缺点就是显示之前需要颜色都需要与存储的alpha值相乘。在执行过滤和混合时,最好使用预乘数据,因为诸如线性插值之类的操作不能正确使用unmultiplied alpha。这样可能会导致对象边缘周围出现诸如黑色条纹之类的瑕疵。第6.6小节中会进行进一步的讨论。预乘alpha也允许进行更加干净的理论上的处理。
对于图像处理应用程序,unassociated alpha可以在遮罩图像的同时不影响底层图像的原始数据,这一点非常有用。此外,unassociated alpha意味着可以使用颜色通道的全精度范围。这也就是说,必须注意将unmultiplied的RGBα值正确地转换为计算机图形计算所用的线性空间。例如,没有浏览器能够正确地执行此操作,也不太可能这样做,因为预期中这样会出现不正确的行为。支持alpha的图像文件格式包括了PNG(仅支持unaasociated alpha),OpenEXR(仅支持associated alpha),以及TIFF(两种alpha都支持)。
与alpha通道相关的一个概念是色度键控(chroma-keying,后文使用原词)。这个术语出自影像产品,即那些使用绿幕或者蓝幕将演员和背景进行混合的产品。在电影工业中这个过程被称作是绿色屏蔽(green-screening)或者蓝色屏蔽(blue-screening)。这里的核心思想是,一个特定的颜色色调(对于电影作品)或具体精确的值(对于计算机图形)被指定为是透明的;只要检测到背景,就会显示背景。这使得图像只需使用RGB颜色即可获得轮廓形状而不需要存储alpha。这种方法的一种弊端是物体在某个像素上可能是完全不透明的或是完全透明的,即alpha值只能是1.0或者0.0。例如,GIF格式就允许将一种颜色指定为透明。
当我们计算光照、纹理或者其他一些操作的效果时,都会假设使用的这些值是线性的。这意味着加法和乘法可以按预期工作。然而,为了避免多种视觉上的瑕疵,显示缓冲和纹理会使用非线性的编码,这是我们必须考虑在内的。简单来说就是:获取着色器输出颜色(在 [ 0 , 1 ] [0,1] [0,1]范围内),并将其提高1/2.2倍,执行被称为是gamma校正(gamma correction)的操作。对输入的纹理和颜色值执行相反的操作。在大部分情况下你都可以指令GPU去为你做这些事。本节将会对如何这样操作和为什么这样操作做出一个快速的解释。
我们首先从阴极射线管(cathode-ray tube,CRT)开始讲起。在数字图像技术的早些年间,CRT显示器是比较常见的。这些设备会在输入电压和显示亮度之间表现出幂次方的关系。即,随着施加到像素的能量级别的升高,亮度不是线性增加的,而是与能量级别的大于1的幂次方的值成比例。例如,假设幂是2。像素设定到50%(设定满像素为1)将会发射出四分之一的光量, 0. 5 2 = 0.25 0.5^2 = 0.25 0.52=0.25。尽管LCD和其他显示技术与CRT具有不同的固有色调响应曲线,但它们是用转换电路制造的,这会使他们在某种程度上会模拟CRT响应。
这种幂函数与人类视觉的亮度敏感度的相反数几乎完全匹配。这种幸运的巧合的结果就是编码在感知上大致一致(perceptually uniform)。即,在可显示范围内,一对编码值N和N+1之间的感知差异大致恒定的。我们可以在通用的条件下检测到约1%的亮度差异,以阈值对比度进行测量。当颜色存储在有限精度的显示缓冲区中时,这种近似最优的值的分布可以最大限度地减少带状瑕疵(23.6小节)。对于纹理这也同样适用,因为其通常也是使用相同的编码。
显示传递函数(display transfer function)描述显示缓冲区中的数字值与显示器发出的亮度水平之间的关系。出于这个原因,它也被称为是电光传递函数(electrical optical transfer function,EOTF)。显示传递函数是硬件的一部分,而对于计算机显示器、电视机和电影放映机来说这一点上存在不同的标准。而在处理的另一头上,如图像和视频捕捉设备,也存在一个标准传递函数,称为是光电传递函数(optical electrical transfer function,OETF)。
当为显示器编码线性颜色值时,我们的目标时抵消显示传递函数的效果,从而无论我们计算的是什么值都将传出一个对应的亮度等级。例如,如果我们计算出来的值加倍,那么我们希望输出的亮度也加倍。为了维持这个连接,我们反着应用显示传递函数从而抵消掉它的非线性的效果。这种消除显示器响应曲线的过程也称为伽马校正(gamma correction)。当解码纹理值时,我们需要去应用显示传递函数来生成一个线性的值来供着色过程使用。图5.38展示了显示过程中的解码和编码。
图 5.39. 左侧,GPU着色器对一个PNG格式的颜色纹理进行访问,其非线性编码的值转换为了线性值。在经过了着色和色调映射(8.2.2小节)后,最终计算出的值又被编码后存到了帧缓冲之中。这个值和显示转换函数决定了放射出的亮度的量。绿色和红色函数组合抵消,因此发射的亮度与线性计算值成比例。
个人计算机显示器的标准传递函数由称为sRGB的颜色空间的规范定义。当值被从纹理中读取或者时写入到颜色缓冲中时,大部分的API控制的GPU都可以设置为自动应用合适的sRGB转换。mipmap生成也会将sRGB编码考虑在内,这在6.2.2小节会进一步讨论到。双线性插值可以在纹理值上正常工作,通过首先转换为线性值然后执行插值操作。通过解码存储的值到其线性值,alpha混合和正确完成,混合到一个新的值,然后对结果进行编码,
重要的是,需要在渲染的最终阶段,当值被写入到显示器的帧缓冲区时,才去应用转换。如果后处理位于显示编码之后,这些效果会在非线性值上进行计算,这通常是不正确的,并且将会产生缺陷。显示编码可以理解为一种压缩形式,一种最能保持值的感性效果的方式。可以这样思考这个领域:我们会去用线性值来进行物理计算,而无论何时我们希望去显示结果或者访问可显示的图像(例如颜色纹理),那么我们就需要将数据从显示编码中取出或者将数据放入显示编码(通过使用合适的编码或者解码变换)。
如果你需要去手动应用sRGB,有一个标准的转换公式或者一些简化版本可以使用。从实际情况来看,显示是由每个通道的位数来控制的,即,消费级的显示器是8位,对应了[0, 255]范围内的一组等级。这里我们将显示编码映射到[0.0, 1.0]上,而忽略了位数。线性值也在[0, 1]范围内,以表示浮点数。我们将这些线性值记作x,将存储在framebuffer中的非线性编码值记作y。为了将线性值转换为sRGB非线性编码值,我们对sRGB显示转移函数做逆运算:
y = f s R G B − 1 ( x ) = { 1.055 x 1 / 2.4 − 0.055 , w h e r e x > 0.0031308 12.92 x , w h e r e x < = 0.0031308 . ( 5.30 ) y = f^{-1}_{sRGB}(x) = \begin{cases} 1.055x^{1/2.4} - 0.055&, where\ x > 0.0031308 \\ 12.92x&, where\ x <= 0.0031308 \\ \end{cases}.\qquad(5.30) y=fsRGB−1(x)={1.055x1/2.4−0.05512.92x,where x>0.0031308,where x<=0.0031308.(5.30)
其中x表示线性RGB的一个通道。这个方程应用到每个通道上,生成的值再进而驱动显示。你在手动去应用这个转换函数时要格外谨慎。错误的来源可能时使用了一个编码的颜色而没有用它的线性形式,而另外一个就是对颜色解码或者编码了两次。
两个转换表达式的下面的那个是一个简单的乘法,这是因为数字硬件需要使变换完全可逆。顶部的表达式涉及到幂运算,几乎适用于整个输入范围 [0.0, 1.0]。再将偏移和缩放也考虑进去,这个函数可以近似成一个更加简单的公式:
y = f s R G B − 1 ( x ) = x 1 / γ , ( 5.31 ) y = f^{-1}_{sRGB}(x) = x^{1/\gamma}, \qquad (5.31) y=fsRGB−1(x)=x1/γ,(5.31)
其中 γ = 2.2 \gamma = 2.2 γ=2.2。希腊字母 γ \gamma γ即是指伽马矫正(gamma correction)。
就和计算值必须为显示进行编码一样,由视频相机捕获的图像在用于计算之前也必须转换为线性值。任何你在显示器或者电视机上看到的颜色都有一些显示编码的RGB,这些你都可以从屏幕截图或者颜色拾取器上获取到。这些值就是图像文件(例如PNG,JPEG,GIF,这些格式可以直接发到显示器屏幕的framebuffer而不需要进行转换)中存储的东西。换句话说,无论你在屏幕上看到什么,从定义上来看,那都是显示编码的数据。在使用这些值进行着色运算时,我们必须将其从这种编码形式转换回线性值。我们需要的从显示编码到线性值的sRGB变换就是
x = f s R G B ( y ) = { ( y + 0.055 1.055 ) 2.4 , w h e r e y > 0.04045 , y 12.92 w h e r e y ⩽ 0.04045 , ( 5.32 ) x = f_{sRGB}(y) = \begin{cases} (\frac{y+0.055}{1.055})^{2.4},& where\ {y>0.04045},\\ \frac{y}{12.92}& where\ {y\leqslant0.04045}, \end{cases}\qquad(5.32) x=fsRGB(y)={(1.055y+0.055)2.4,12.92ywhere y>0.04045,where y⩽0.04045,(5.32)
其中y表示归一化显示通道值,即存在图像或者framebuffer中的值,其表现形式就是[0.0, 0.1]范围内的一个值。这个解码函数是我们前面sRGB公式的逆运算。这意味着如果一个纹理在被着色器访问后正常输出而没有发生变化,那么它和在被处理前是没有任何区别的。解码函数和显示转移函数是一样的,因为纹理中存储的值已经被正确得编码到显示。我们不是通过转换来给出线性响应的显示,而是通过转换来给出线性值。
简化的伽马显示转移函数是公式5.31的逆运算:
x = f d i s p l a y ( y ) = y γ . ( 5.33 ) x = f_{display}(y) = y^{\gamma}.\qquad(5.33) x=fdisplay(y)=yγ.(5.33)
有时你会看到一个更加简化的转换的形式,尤其是在移动和浏览器应用上:
y = f s i m p l − 1 ( x ) = x , x = f s i m p l ( y ) = y 2 ( 5.34 ) \begin{aligned} y & = f^{-1}_{simpl}(x) = \sqrt{x}, \\ x & = f_{simpl}(y)= y^2 \end{aligned}\qquad(5.34) yx=fsimpl−1(x)=x,=fsimpl(y)=y2(5.34)
即是,取线性值的平方根进行换算,以便于显示,只需将该值乘以自身,即可得到逆值。虽然这只是一个粗略的近似,但这种转换比完全忽略这个问题要好。
如果我们不去关注伽马值,较低的线性值在屏幕上会显得太暗。有一个相关的错误就是,如果不进行伽马校正,一些颜色的色调会发生偏移。假设有 γ = 2.2 \gamma = 2.2 γ=2.2。我们希望从显示的像素发出的辐射度与线性计算值成正比,这意味着我们必须将线性值提高到(1/2.2)幂次方。即如果线性值为0.1的话得到0.351,0.2的话得到0.481,0.5的话得到0.730。如果未进行编码,使用这些值会导致显示器发出的辐射度比需要的少。注意,0.0和1.0在这些变换中是不发生任何变化的。在使用伽马校正之前,深色表面的颜色往往会被建模的人人为地提升,在反向显示变换中折叠。
忽略伽马校正的另一个问题是,对物理线性辐射度值正确的阴影计算是根据非线性值进行的。相关例子可以参考图5.40.。
图 5.40. 图片中是两个聚光灯同时照亮一块平面。在左侧图像中,两个光的值分别为0.6和0.4,在叠加后并没有执行伽马矫正。直接在两个非线性值上执行加法运算导致了问题的出现。注意到左边的光要远远亮于右边的光,而两者重叠的部分更是亮得异乎寻常。在右侧图像中,在进行加法运算后对值进行了伽马矫正。光本身的相对的亮度看起来更加舒服,而在交叠的部分看起来也更加合理了。
忽略伽马矫正也会影响边缘抗锯齿的质量。例如,假设一个三角形边缘覆盖到了4个屏幕上的网格(图5.41)。三角形归一化的辐射度为1(白色);背景的话是0(黑色)。从左到右,网格的覆盖面积分别为1/8,3/8,5/8,7/8。因此,如果我们使用盒式过滤器。那么各个像素的归一化的线性辐射度可以表示为0.125,0.375,0.625,和0.875。正确的做法是在线性值上进行抗锯齿,然后再在四个结果值上应用编码函数。而如果没有这样去做的话,像素的辐射度将会变得过于深,导致边缘在感知上发生变形,如图中右侧所示。这个问题被称为是绳结(roping),因为边缘看起来就像是某个拧着的绳子。图5.42中也展示了这个效果。
图 5.41. 左侧,一个白色三角形应对着黑色的背景,其某边覆盖了4个像素,其实际覆盖面积如图中所示。如果不去进行gamma矫正,中间色调的变暗会导致边缘有被扭曲掉的感觉,如右图所示。
图 5.42. 左侧,抗锯齿的线都经过了伽马矫正;中间,部分经过了矫正;右侧,完全没有经过矫正。(图像由Scott R. Nelson提供)
sRBG标准最先在1996年被创造出来,并且随后成为了大多数计算机显示器所使用的术语。然而,显示技术从那之后已经又发展了许久。也发展出了更亮的、可以显示更宽颜色范围的显示器。颜色显示和亮度将在第8.1.3小节进行讨论,高动态阈显示器的显示编码将在8.2.1小节中介绍。如果您寻求关于高级显示器的更多信息,那么在Hart的相关文章中可以找到您所有想要的。
Pharr等人更加深入得讨论了采样模板和反走样。Teschner的课程笔记展示了多种采样模式的生成方法。Drobot梳理了以前关于实时抗锯齿的研究,解释了各种技术的属性和性能。关于各种形态学抗锯齿方法的更多信息可以在相关的SIGGRAPH课程的笔记中找到。Reshetov和Jimenez提供了一分对在游戏中可以使用的形态学和相关的时TAA技术的最新回顾文章。
针对透明度相关的研究,我们推荐感兴趣的读者去到McGuire的演讲和Wyman的工作成果。Blinn的文章“What Is a Pixel”在讨论不同定义的同时,对计算机图形的几个领域进行了一次很好的阅览。Blinn的Dirty Pixels和Notation, Notation, Notation两本书包含了一些关于滤波和反走样的介绍性的文章,以及一些涉及到alpha,组合和伽马矫正的文章。Jimenez的演讲详细介绍了用于抗锯齿的最先进技术。
Gritz和d’Edon做了一个关于伽马校正问题的出色总结。Poynton的书对各种媒体中的伽马校正以及其他与色彩有关的主题作了扎实的介绍。Selan的白皮书是一个较新的资料,解释了显示编码及其在电影业中的应用,以及许多其他相关信息。