"A good picture is equivalent to a good deed."—Vincent Van Gogh。(Patrick:看了几遍才看明白,主要是突然看到这句话的作者,梵高,这两年去欧洲看了不少梵高的作品。那么再看这句话就明白了,意思是,好的作品会说话,或者说是好的作品有力量。结合下面要说的不同风格的作品,就很好理解了。)

当绘制一幅3D图片的时候,不只是要把每个物件的几何形状表达清楚,还需要使用合适的外观。根据产品不同,可以使用写实风(几乎和实物照片完全相同的外观),也可以使用更有创造性的各种风格化外观。如图5.1所示

T05

上面的图片是来自UE渲染的真实风格场景,由Gokhan Karadayi提供。下面图片来自于Campo Santo的游戏Firewatch,使用的是风格化外观,由Campo Santo提供。

本章所介绍的内容同时适用于写实风和风格化渲染。第15章将介绍风格化渲染,然后本书第9-14章将集中介绍用于真实照片渲染的物理算法。

5.1 着色模型

首先,先要选择一个着色模型,用来描述对象的颜色如何根据表面方向normal、视图方向view和光照等因素的变化而变化。

我们以Gooch shading model(参考文献[561])作为例子来开始,它属于非真实渲染NPR,属于第15章的范畴。Gooch shading model旨在提高技术插图中细节的易读性。

Gooch shading的中心思想在于对比表面法线和光源位置。如果法线指向光源,则使用较暖色调为表面着色,如果法线远离光源,则使用较冷色调。当中间角度的时候,再基于用户提供的表面颜色,在冷暖之间做插值。在这个粒子中,我们增加了一个风格化的高光,是的曲面会有光泽外表。图5.2展示了着色模型。

T05

上图为一个结合了Gooch shading和高光效果的风格化找色模型。上面的图是一个使用了中立表面颜色的复杂物件。下面的图为使用不同表面颜色的球体。(中国龙的mesh由Computer Graphics Archive(参考文献[1172])提供,原始模型来自stanford 3D扫描库)

和大多数着色模型一样,此示例受到曲面方向normal、视图view、光照方向的影响。为了进行着色,这些方向通常表示为标准化(单位长度)向量,如图5.3所示。

T05

上图展示了用于着色模型的单位向量:表面法线normal、视图view、光照l。

这样的话,我们就定义了光照模型所需要的所有变量,下面为光照模型的公式:

T05

在这个公式中,我们使用了以下的中间计算

T05

该定义中的很多数学表达式也经常出现在其他着色模型中。clamp操作经常在着色中用于将结果限定在0到正无穷大,或者0到1之间。在这里,用于计算高光混合因子s的公式中可以看到-+符号,这个我们在1.2节介绍过,就是用于表示clapmp(0,1)。点积运算符出现了三次,是一种常见的用于计算两个单位向量夹角余弦的方式,它的结果很精确。比如在着色模型中计算normal和light夹角关系的时候经常会被使用。

还有一个经常被使用的着色运算是使用0-1之间的标量值在两个颜色之间进行线性插值。此操作的公式为tCa+(1-t)Cb,当t的值在0-1之间移动时,在Ca和Cb之间进行插值。该公式在着色模型中出现了两次,第一次是Cwarm和Ccool之间,第二次是上次插值的结果和Chightlight之间。线性插值经常在shading中被使用,所以它有一个内置函数lerp或mix。

公式r = 2(nl)n - l是用于计算光l相对于n的反射矢量。虽然不像前面两个那么常见,但是很多着色语言也会有对应的内置函数reflect。

通过将这些操作以各种不同的方式和参数结合,就得到了各种着色模型用于各种风格(风格化或者写实风)的渲染表面。

5.2 光源

在我们示例着色模型中的光照其实很简单,只是为着色提供了一个主方向。当然,现实世界中的照明可能相当复杂。可以同时存在多盏光源,每个光源都有自己的大小、形状、颜色和强度,如果考虑间接光照的话将会更加复杂。我们在第9章的基于物理的真实着色模型中,会使用到这些所有的因素。

相对比,风格化渲染可能会根据需要,以各种不同的方式使用照明。一些高度程式化的模型可能根本没有照明的概念,或者只使用它来提供一些简单的方向性(比如上面提到的Gooch shading)

光照中下一个比较复杂的地方是需要根据与光照的关系,选择不同的着色模型。这种表面在光下会有一个外观,在不受光的情况下会有另外一个外观。这也就意味着需要区分这两种情况的一些标准:与光源的距离、阴影(第七章)、表面是否背离光源(n和l的角度超过90度),或者是这些因素的合集。

或者换一种方式来思考,我们不再考虑光照是否存在的二元性,而是考虑光照强度的连续渐变。这样的话,就可以从光源完全不存在到光源完全存在进行插值,强度的范围可以用0-1表示。或者还有另外一种方式,将各种影响着色的因素相加,常见的方式是:将着色模型分解成亮部和不亮部,灯光强度Klight线性的影响光照部分:

T05

针对RGB的光照Clight可以将公式扩展为

T05

针对多个光源的情况为:

T05

不受光的部分funlit(n,v)相当于二元性着色模型中处理表面不受光影响的部分。根据游戏的风格,可以使用多种风格。比如,funlit()=(0,0,0)也就代表着不受光的表面,表现为黑色。其他的,比如也可以类似Gooch model中那样,使用冷色来表示表面背离光照。通常,着色模型中的这一部分表示某种形式的照明,这些照明不是直接来自显示放置的光源,而是来自比如天光或者周围对象反射的光。这些形式的照明将在第10、11章中讨论。

我们前面提到,如果光照方向l与法线n的角度超过90度,则光照不会影响到表面,也就是说光照来自表面下方。这可以被看作是光照对表面的影响的一种特殊情况。尽管这种情况是基于物理的,但其实对很多非物理的风格化着色模型也很有用。

光对表面的影响可以被看作是一组光线,照射到表面的光线密度与用于表面着色的光照强度相对应。如图5.4所示,该图显示了照明表面的横截面。沿着横截面照射到表面上的光线的间距与L和N之间夹角的余弦值成反比。因此,照射到表面上的光线的总比密度与L和N之间夹角的余弦值成正比,余弦值为L和N两个单位长度向量的点积。在这里我,我们也就可以看到为什么光向量L与实际光的方向相反,因为不然的话,必须先对它求反(Patrick:原来如此)

T05

更准确的说,光线密度(或者说光线对着色的贡献程度),当点积为正数时,与点积成正比。当点积为负数时,也就是说,光线是从表面后面发出来的,这样没有效果。所以,当用点积计算光照着色的时候,首先先clamp到0,。在这里可以使用1.2节的符号x+,将负值限制到0,也就是如下公式

T05

支持多个光源的着色模型,可以使用公式5.5(更通用),或者5.6(基于物理)。这些公式同样也可以用于风格化模型,可以帮助确保整体照明的一致性,特别是对于背光或者阴影的表面。然而还是有一些光照模型不适合这些结构,一些模型要用5.5公式。

最简单的光照模型是将其看成一个常数颜色

T05

也就得到如下着色模型

T05

这个模型被称为lambertian光照模型,是由Johann Heinrich Lambert(参考文献[967])于1760年发布。该模型适用于理想情况下的漫反射表面,也就是毫无光泽的表面。这里我们先简单的介绍一下它,然后再第9章会更详细的介绍。Lambert模型本身可以用于简单的着色,同时也是很多着色模型中的关键组成部分。

从上述公式中,我们可以看到,光源是通过两个参数影响光照模型:光照的方向l,以及光照的颜色Clight。所以,可以根据光源的这两个参数,将光源分成各种不同类型。

下面我们将来聊一些常见的光源,它们有个共同点:在给定的表面位置,每个光源仅从一个方向l照亮表面。换句话说,从阴影表面位置看,光源是一个小点。对于现实世界中的光源,这并非完全正确,但是大多数光源相对于被其照亮的表面的距离来说,都很小,所以也是一个合理的近似值。在7.1.2节和10.1节中,会讨论那些从各个方向对一个点产生影响的光源,面光源。

5.2.1 方向光

方向光是光源中最简单的类型。l和clight在场景中都是恒定的,clight可以通过阴影减弱。方向光没有位置。当然,实际光源肯定是有位置的,然而由于方向光是抽象的,当与灯光距离比较大的时候,比如,一个很远的灯光照亮桌面上的一个小物件的时候,可以看成是方向光。另外一个例子是使用太阳的场景,基本都会认为太阳是方向光,除非讨论的场景是整个太阳系。

方向光的概念可以稍微扩展一下,以允许在光方向l保持不变的情况下,改变clight的值。这通常是想借助光效,实现场景中的一些特殊效果。比如,将一个区域定义成两个嵌套的区域,在这两个区域过度的时候进行平滑插值得到clight。

5.2.2 精准光源

精准光源是指有位置的光源。与现实世界的光源不同,这种光源也没有尺寸和形状。Punctual来自拉丁语中的Punctus,是点的意思。用来表示单一的,有位置的所有类型的光源。点光源代表着向各个方向均匀发光的光源。聚光灯和点光源是两种不同类型的精准光源。光的方向l取决于当前正在绘制的表面点P0,以及精准光源的位置Plighting

T05

这个公式也是向量归一化的一个示例:向量除以向量的长度,以得到一个相同方向的单位长度向量。这也是一个常见的操作,在大多数着色语言中有对应的内置函数。但是,有时也需要此操作的中间结果,这样就需要把这个公式拆成好几步,如下所示:

T05

我们知道点积就是两个向量的长度相乘,然后乘以它们夹角的余弦值。而0°的余弦值为1,所以向量和自身的点积等于向量长度的平方。所以,为了计算出向量的长度,我们通常对其自身进行点积运算,然后再进行开方。

通常我们需要中间变量r,也就是光源和当前着色点之间的距离。因为我们有时候会用r距离作为衰减因子,这个将在后面进行讨论。

Point/Omni Lights

在所有方向均匀发光的精确光源被称为点光源或者Omni光源。对点光源来说,Clight的值随着距离r的变化而变化,唯一的变化源是上面提到的距离衰减。图5.5显示了这种变暗现象发生的原因,原理和图5.4的余弦系数差不多。但是与5.4的余弦系数不同,针对平行光,垂直于平行光方向移动物件,物件上的点与光源的夹角始终不变,但是点光源不一样。来自点光源的光线之间的间距与从表面到光源的距离成比例关系,所以该点与光源的夹角也在变化。(Patrick:原来如此)所以随着间距的增加,变化是分为两个维度的,所以光线密度与距离的平方成反比。这样,我们就可以通过r0点光源Clight0计算出最终的Clight

T05 T05

如上图所示,光线之间的距离随着距离r的变大而变大。所以光线密度/强度与r的平方成反比。

上面这个公式被称为inverse-square light attenuation。尽管技术上没毛病,但是在实际着色中可能会出问题。

首先,第一个问题出现在短距离的情况下。当r趋近于0的时候,clight将无限大。当r等于0的时候,就会遇到除0的问题。通常的解决方案是加一个很小的值做除0保护(参考文献[861]):

T05

这个很小的值的具体取值取决于应用程序,比如Unreal取极小值为1厘米。(参考文献[861])

在CryEngine(参考文献[1591])和Frostbit(参考文献[960])中,将r clamp到Rmin中:

T05

在这里,Rmin有一个物理意义:物理光源的半径,当r小于rmin的时候,物体表面在光源中,这不可能(Patrick:这个比较有道理,R0=Rmin,然后无限靠近光源的时候,Clight=Clight0)

相反,平方反比衰减的第二个问题在相对较大的距离上。问题并非在视觉上,而是性能上。因为尽管光强度随着距离的增加而减小,但它永远都不会到达0。而为了有效的渲染,我们希望灯光在某个有限距离达到0强度(详见第20章)。有许多不同的方法可以对平方反比方程进行修改来实现这一点。理想情况下,修改应当尽可能少。而为了避免在光的有效边界处出现尖锐的切口,在相同的距离处,修正函数的导数和值最好同时达到0。一种解决方案是用具有所需特性的开窗函数乘以平方反比方程。UE(参考文献[861])Frostbite(参考文献[960])都使用了这样的功能(参考文献[860])

T05

公式中的+2意思为,先将值clamp到0,然后再对其进行平方。图5.6中显示了平方反比曲线的例子,方程式5.14中窗函数的例子,以及它们相乘的结果

T05

上图中将公式中的Rmax设置为3。

需要根据应用程序的需要选择使用的方法。比如,当距离衰减函数以相对较低的空间频率(比如lightmap或顶点光源)采样时,在Rmax处导数等于0尤为重要。Cryengine不使用lightmap或顶点光照,因此它使用了更简单的调整,在0.8*Rmax到Rmax的范围内采用线性衰减。

在某些应用中,平方反比衰减并非最优选,而选择了其他的一些函数,所以我们将5.11-5.14概括成下述公式:

T05

其中Fdist(r)是距离函数。这种函数被称为距离衰减函数。在某些情况下,是由于性能原因而不使用平方反比衰减函数。比如Just Cause 2这个游戏就需要计算量比较少的光源。从而引入了一些计算量比较少,而同样足够平滑以避免顶点光照瑕疵的函数(参考文献[1379])

T05

还有一些别的情况,是为了实现一些比较有意思的效果,才选择不同的衰减函数。比如UE中就有两种衰减函数,用于真实和风格化的游戏:一个是平方反比模型,正如5.12公式中一样,另外一个是指数衰减函数用于创建各种各样的衰减曲线(参考文献[1802])。Tomb Raider古墓丽影(2013)的开发者就使用了spline-editing工具(参考文献[953]),从而对衰减曲线进行更强的控制

聚光灯

与点光源不同,真实世界中所有的光源的照明强度,都随距离和方向不同而不同。这种变换可以通过一个方向性衰减函数Fdir(l)表示,它与距离衰减函数相结合,来定义光强度的整体空间变化。

T05

不同的Fdir(l)会产生不同的光照效果。比如聚光灯,是将光线投射到一个圆形的圆锥体中。聚光灯的方向衰减函数围绕聚光灯方向向量s具有旋转对称性,可以通过s和光照方向l的夹角Θ的函数表示。这里的光矢量需要被颠倒,因为我们将光照方向l定义为物体表面到光源,而在这里,我们需要光源到物体表面的矢量。

大多数聚光灯函数会使用由Θs的余弦值组成的表达式。聚光灯通常由一个umbra angle Θu,意味着当Θu>Θs的时候,Fdir(l)=0。该角度可用于与前面看到的最大衰减距离Rmax类似的方式进行剔除。聚光灯还通常会有一个penumbra angleΘp,定义了一个inner cone,在该点光照是完全强度的。如图5.7

T05

如上图所示,Θs为光线方向-l与聚光灯方向s之间的夹角。Θp代表着penumbra,Θu代表着umbra angle。

聚光灯可以使用不同的方向性衰减函数,虽然它们大致相同。比如Frostbite(参考文献[960])使用了函数Fdirf(l),three.js浏览器图形库(参考文献[218])中用到了函数Fdirt(l):

T05

符号X-+代表着clamp到[0,1]。而smoothstep函数则是一个三元多项式,常被用于平滑插值,是大多数着色语言的内置函数。

T05

上图展示了刚才我们所说的光源类型。从左到右,方向光、没有衰减的点港元、平滑过度的聚光灯。注意,当灯光和表面之间的角度发生变化的时候,点光源会逐渐变暗。

其它精准光源

除此之外,还有许多其它的精准光源。

Fdir(l)将不仅限于上述的简单的聚光灯衰减函数。还可以通过测量真实世界中的光源得到的复杂列表模式,来表示Fdir(l)。照明协会Illuminating Engineering Society(IES)为此类测量定义了标准文件格式。可以从很多照明制造商处获取IES配置文件,而且已经被游戏KillZone:Shadow Fall(参考文献[379、380])所使用的,除此之外,还有Unreal(参考文献[861])和Frostbite(参考文献[960])游戏引擎等等都使用了该技术。Lagarde(参考文献[961])总结了如何解析和使用该文件格式。

游戏古墓丽影 Tomb Raider 2013(参考文献[953])有一种精准光源,它的衰减函数是根据世界坐标轴X、Y、Z轴不同区分的。在古墓丽影中,光的强度也可以随着时间变换而沿着一条曲线变化,比如,拾取一个火把。

在6.9节中,我们还会聊一下,如何通过纹理改变光的强度和颜色。

5.2.3 其它光源类型

方向光和精准光源主要是通过如何光照方向L来进行区分。除此之外,还可以使用其它方法来计算光照方向以定义不同类型的灯光。例如,Tomb Raider古墓丽影定义了胶囊灯,使用线段来作为光源,而非点(参考文献[953])。对于每个着色像素来说,光照方向为该点到光源线段中最近的点的矢量。

着色方程式还需要l和Clight作为参数,而这些参数可以通过很多方法得到。

以上聊到的光源都是抽象的。真实世界中的光源都是有尺寸和形状的,且针对物体表面上的照射是从多个方向的。在渲染行业,这种光源被称为面光源,在实时渲染领域,其使用频率也越来越高。面光源的使用主要集中在两个方面:模拟部分遮挡面光源导致的阴影边缘软化技术(详见7.1.2节),模拟面光源对表面着色效果的影响技术(详见10.1节)。其中第二个方面针对光滑的镜面表面影响最为明显,在这些表面上,光线的形状和大小都可以通过其反射清晰的辨别出来。方向光和精准光源虽然不像过去那样无处不在了,但是不太可能被废弃。目前,已经开发出了比较简单的公式模拟面光源的近似值,这种公式耗能低,所以可以得到更广泛的应用。GPU性能的提高也使得可以使用越来越复杂的技术。

5.3 实现着色模型

然后这些着色和光照方程需要在代码中实现。被接种,我们讲讨论一些设计和编写此类实现的关键注意事项。然后会介绍一个简单的实现示例。

5.3.1 运算频率

当设计一个着色模型的时候,需要先根据运算频率将一些运算进行区分。首先,针对在整个DC中,都保持不变的运算,可以将其由应用程序在CPU端执行,尽管GPU也比较擅长运算。然后将CPU运算的结果,通过图形API中的uniform传入。

即使这个类型的数据,也可能分为很多不同的可能性。最简单的例子是着色方程中的常量表达式,然而其实这些可以通过硬件配置或者安装选项等表示。这些计算应当在shader编译的时候就完成,这种情况下甚至不需要通过uniform传入。或者,也可以在离线预计算的时候、安装的时候或者应用程序加载的时候运算。(Patrick:这是指啥?静态分支?)

另一种情况是,着色运算的结果在应用程序运行期间会发生变化,但速度太慢,不需要每帧都更新。例如,光照取决于虚拟游戏世界中的时间。如果光照计算的成本很高,可以尝试通过多帧进行分摊(Patrick:不同频率的cascade shadowmap我明白,多帧一起算OC我也明白。但是光照运算也可以分摊?比如光线追踪中每帧发一条射线,然后多帧混合)

其它的还有,逐帧更新的运算,比如V和P矩阵。逐物件更新的运算,比如依赖位置运算的光照参数。逐DC更新的运算,比如材质球属性。按照频率对uniform进行分组可以有效的提高性能,通过最小化变量更新的方式降低GPU开销。

当某个被需要的运算在一个DC中会发生变化的时候,则不能通过uniform将该运算的结果传入shader,而是需要用到第3章中提到的着色器来计算,如果需要,还可以通过varying来进行不同着色器之间的数据传输。理论上,着色计算可以在任何可编程阶段进行,而每个阶段对应不同的运算频率:

  • Vertex Shader:逐pre-tesselation顶点运算。
  • Hull Shader:逐surface patch运算。
  • Domain Shader:逐post-tesselation顶点运算。
  • Geometry Shader:逐图元运算。
  • Pixel Shader:逐像素运算。

实际上,大多数着色计算是逐像素进行的。虽然这些通常在pixel shader中实现,但computer shader的使用也越来越多,在第20章会给出一些例子。其他阶段主要用于几何运算,比如transform和变形。为了理解为什么着色计算要逐像素计算,下面我们对比一下逐顶点和逐像素计算的结果。在旧的文本中,也被称为Gouraud shading(参考文献[578])和Phong shading(参考文献[1414]),这些术语现在已经不用了。这个比较中使用的着色模型类似公式5.1,但是经过修改,可以使用多个光源。等我们说完细节之后,会给出完整的模型。

下图5.9可以看出两种顶点密度的模型,分别使用逐顶点着色和逐像素着色的区别。对于龙的那个模型,顶点密度很高,两种着色的差别很小。而对于茶壶,使用顶点着色的话会出现明显的错误,比如高光部分。而对于两个三角形平面来说,顶点着色完全不正确。这些错误的原因是因为着色方程的某些部分,特别是高光部分,在网格表面呈非线性变化。这使得,如果使用顶点着色,以线性插值的方式得到结果的方式,结果是不对的。

T05

上图为逐顶点和逐像素,以5.19着色模型,针对三种不同顶点密度模型所得到的对比图。左侧为逐像素的结果,中间为逐顶点的结果,右侧为模型的wireframe,可以看到其顶点密度。(中国龙的模型来自Computer Graphics Archive(参考文献[1172]),而原始模型来自Stanford 3D Scanning Repository)

理论上,可以在PS中只计算高光部分,其他部分在VS中进行计算。这样的话效果没区别,性能上理论上还会有所节省。但实际上,这种混合方式往往并非最优解。着色模型中线性变换的部分,往往是计算量最小的部分,以这种方式拆分着色计算,往往会导致更多的开销,比如重复计算以及额外的varying变量,这些消耗甚至可能超过节省的部分。

如前所述,VS主要负责非着色部分,比如几何体transform和变形。将生成的几何曲面属性转换为适当的坐标系,由VS输出,在三角形中线性插值后,作为varying输入传给PS。这些属性通常包括位置、法线以及可选的切线(如果需要做法线映射)

需要注意的是,即使VS中对法线做了归一化处理,但是插值后的向量依然可能是非归一化的。如图5.10所示。所以在PS中还需要对法线进行重新归一化。而VS中的法线归一化依然不可缺少。因为如果顶点之间的法线长度变换很大,比如做顶点混合的时候,这个将会扭曲插值。这可以在图5.10的右侧看到。所以,需要在插值之前和之后,也就是在VS和PS中都进行归一化。

T05

在左侧,我们可以看到,单位法线在曲面上的线性插值会产生长度小于1的插值向量。在右侧,我们看到长度显著不同的法线的线性插值,会导致插值方向向两条法线中较长的方向倾斜。

与法线不同,指向特定位置的向量(比如view向量、精准光源的灯光向量)通常不进行插值。而在PS中通过插值得到的曲面位置来计算这些向量。这些计算中,只有必须在ps中进行的归一化之外,只需要一个向量减法即可,这个计算量很小。如果出于什么原因,比如将这个运算放到VS中,则不要对其进行归一化,否则将会出现不正确的结果,如图5.11所示。

T05

上图为对两个灯光向量进行插值。左图为,在插值之前先归一化,会导致插值后方向错误。右图可以看到,直接对非归一化的向量进行插值,结果是对的。(Patrick:这个挺有意思的)

前面我们提到VS将几何体转换到适当的坐标系中。摄像机和光源的位置由应用程序转换到相同的坐标系中。这样的话,将最大程度降低PS的计算量。但是哪个坐标系是合适的?可能是世界坐标系,也可能是相机的视图坐标系,甚至可能是模型的局部坐标系。具体使用哪个坐标系,通常是基于系统性考虑(比如性能、灵活性和简单性)。比如,如果渲染场景中包含大量灯光,则可以选择世界空间以避免过多的变化灯光位置。或者,可以使用相机坐标系,来更好的优化依赖view向量的PS操作,这样的话精度可能还会得到提高(详见16.6节)

尽管大多数着色器都是按照上述描述的原则,但是凡事均有意外。比如,一些应用程序使用逐图元的渲染以实现风格化的效果。这种风格被称为flat shading。图5.12显示了两个例子。

T05

以上是两个使用flat shading的风格化效果:上面是Kentucky Route Zero,下面是That Dragon,cancer(上图来自Cardboard Computer,下面来自Numinous Games)

理论上,flat shading可以在GS中实现,但是一般都是通过VS实现的。通过将每个图元的属性与其第一个顶点关联,并禁用顶点值插值来完成。禁用插值(可以分别对对每个顶点进行设置)会将第一个顶点的值传给图元中的每个像素。(Patrick:这个厉害了)

5.3.2 例子

下面我们将介绍一个着色模型实例。该实例的着色模型类似5.1公式的扩展Gooch model,但是经过了修改,可以接受多个光源。模型为:

T05 T05

该公式也符合多光源的5.6公式,如下所示

T05

其中光照部分和非光照部分为

T05

一般情况下,材质属性,比如Csurface的值被存储在顶点数据或者纹理中。但是在这里为了简单,我们假设Csurface是常量。

此实现会使用到shader的动态分支功能来循环所有光源。(Patrick:这个居然是动态分支?)虽然这种方式在简单场景效果不错,但是不能很好的扩展到具有许多光源的大型和几何复杂的场景。第20章将专门介绍处理大量光照的渲染技术。此外,为了简单起见,我们只支持一种光源:点光源。

着色模型并非单独实现,而是要在更大的渲染框架的上下文中才能实现。当前例子是在一个简单的WebGL 2 的应用程序中实现,它是由Tarek Sherif(参考文献[1623])的"Phong-Shaded Cube" WebGL 2模型改编而来,当然,该模型还适用于更复杂的框架中。

下面我们来讨论一些GLSL的shader code以及JavasScript的WebGL API。目的并非是学习WebGL API,而是展示一般的实现原则。我们将按照"由内到外"的顺序实现,先PS,然后VS,最后看图形API调用。

在看shader代码之前,先看下shader的输入和输出。正如3.3节所描述的那样,GLSL的输入分为两类。一类是uniform,由应用程序传入,在一个DC中保持常量。第二类是varying,这些变量可以在VS和PS之间传输。下面是PS中的varying输入,在GLSL中通过in来表示,对应的PS的输出由out表示

T05

该PS只有一个输出,就是最终的颜色。PS的输入与VS的输出相对应,并在光栅化的时候被插值。当前PS有两个varying输入,曲面位置和法线,都是位于世界空间坐标系中。uniform的数量就会多很多,为了简洁期间,我们就展示两个光源相关的uniform

T05

由于这些都是点光源,所以每个点光源的定义都包括位置和颜色。为了符合GLSL std140 data layout数据布局标准,它们被定义成了vec4,而非vec3。这样的话,虽然在std140 layout下会导致浪费一些空间,但是这样的话,就确保了CPU和GPU布局一致,避免了如果布局不一致可能会导致的性能消耗,这就是我们在本实例中使用vec4的原因。(Patrick:这个厉害了,而且UBO在metal和vulkan中布局的不是很好,见下图)。光源的结构数组是由一个uniform block定义,这是glsl的特性,这样就可以将一组uniform变量绑定成一个buffer,这样传输会更快一些。数组长度为光源的最大限制数量。这样的话在shader编译之前,会将shader源代码中的MAXLIGHTS替换成对应的数值(比如10)。而uLightCount这个uniform才是当前DC光源的真正数量。

T05

下面我们来看PS部分

T05

可以看到,有一个lit函数,被main函数调用。总的来说,上述是5.20和5.21两个公式的GLSL实现。注意,Funlit()和Cwarm是作为uniform常量被传入,因为在整个DC中,它们都保持常量,这样的话通过应用程序计算,可以节省一些GPU运算。

这个PS中使用到了一些内置的GLSL函数。reflect函数将一个向量(在这里是光照向量)根据另外一个向量表示的平面(在这里是表面法线)进行反射。由于我们希望光向量和反射向量都指向远离曲面的地方,所以在其传递到reflect函数之前,对前者取反。clamp函数有三个输入,其中两个定义了第三个变量将被clamp的范围。在大多数GPU中,当clamp到0到1之间内,都有对应的函数(比如hlsl中的saturate),这样的话可以快速处理,且基本是无消耗的,免费的。这也就是为什么我们在这里使用clamp的原因(虽然我们只需要将其clamp到0即可,我们知道它不会超过1)。函数mix有三个输入,将在其中两个输入之间进行线性插值,在本例中是对warm颜色和highlight高光颜色,基于第三个参数(范围0-1)进行线性插值。在HLSL中这个函数是lerp,用于线性插值。最后,normalize是用于归一化,实现方式就是将向量除以向量长度。

下面来看下VS。由于上面已经介绍了不少uniform,这里就不再赘述了。这里看下varying的输入和输出的定义

T05

正如前面所提到的那样,VS的输出与PS的varying输入相匹配。而VS的输入包含了数据在顶点数组中布局的指令。下面是VS的代码:

T05

这些都是VS中的一些常见操作。shader中先将位置和法线转换到世界空间,然后将其传给PS着色使用。然后,顶点位置被转换到clip空间,赋给了gl_Position,这是光栅化使用的一个特殊的内置变量。gl_Position是VS中必须有的一个输出变量。

另外,可以看到在VS中并没有对法线进行归一化。因为在原始mesh中存储的法线长度本身就为1,而在VS中并没有对其进行比如顶点混合、不均匀缩放等会改变其长度的操作。模型矩阵中有缩放因子可能会改变法线长度,但是是统一的改变所有法线的长度,所以不会出现5.10右侧图片导致的问题。

此例子中的应用程序使用WebGL的API进行渲染和着色设置。其中每个shader都会独立被设置,然后绑定到一个program object上。下面是PS的构建代码:

T05

这里使用到了fragment shader。这个属于被WebGL(和它所基于的OpenGL)所使用。正如本书前面提到的,尽管Pixel shader不太精确,但是使用面积比较广,所以本书使用这个术语。此代码中也用于将MAXLIGHTS 字符串替换成适当的数值。大多数渲染引擎都会执行类似预编译的着色操作。

应用程序还会执行更多的操作,比如设置uniform,初始化顶点数组,clear、draw等,这些都可以在program(参考文献[1623])中查看这些代码,有很多的API指南会去解释这些代码。我们这里的目标是解释shader是如何在它们的编程环境中被当做一个个单独的处理单元。因此,我们的演练到此结束。

5.3.3 材质系统

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

正如前面所说,shader适用于GPU可编程着色。因此,它是一个底层的图形API资源,而不是艺术家直接交互的东西。相对应的,艺术家一般直接操作材质球。虽然材质球有时候还会描述一些非视觉方面的属性,比如碰撞等(Patrick:材质球还可以干这个事情?),但是本书中我们不对这一块进行讨论。

虽然材质球是通过shader实现的,但并非一一对应关系。在不同的渲染情况下,同一个材质可能使用不同的shader,而同一个shader也可以被多个材质球共享。最常见的情况就是参数化材质球。通常情况下,参数化材质球需要两种类型的材质:材质模板和材质实例。每个材质模板描述一类材质,并具有一组参数,这些参数可以根据参数类型指定数值、颜色、纹理等。每个材质实例对应一个材质模板以及一套默认值。一些渲染框架,比如UE(参考文献[1802])包含一套更复杂的层次结构,其中材质模板可以从多个级别的其他模板中派生。

参数可以通过uniform在运行时传入shader,也可以在编译前通过替换值的方式传入shader(Patrick:不知道Unity是替换的,还是通过uniform的,但无论是哪个,其实都是静态分支吧)。常见的编译时参数是布尔开关,用它来控制材质球中使用哪个分支。可以通过材质球中的UI界面设置,也可以由程序直接控制。比如,可以在视觉效果可以忽略不计的情况下,降低远处对象的着色运算量。

虽然材质参数可以与着色模型的参数一一对应,但是情况往往并非如此。比如可以将给定着色模型的某个参数(比如表面颜色)设置成常量值,这样将减少一个材质参数。相应的,着色模型的参数也可能会需要通过一系列复杂的操作来计算得到,这些操作以多个材质参数,比如插值得到的顶点参数或者纹理作为输入。而在某些情况下,比如表面位置、法线方向甚至时间等都会影响到着色计算。基于表面位置和法线方向的着色在地形材质中尤为常见。比如基于高度和法线可以用于控制雪的效果,在高海拔和几乎水平的表面混合白色的表面颜色。基于时间的着色在动画材质中很常见,比如闪烁的霓虹灯标志。

材质系统最重要的任务之一是将各种材质球函数划分为单独的元素,并控制这些元素的组合方式。在许多情况下,这种类型的组合是有用的,比如:

  • 组合表面着色和几何处理,比如刚体变换、顶点混合、morphing变形、表面细分、instancing、clip等。这些功能各不相同:表面着色取决于材质,几何处理取决于网络。因此,可以将它们分开编写,然后由材质系统组合。
  • 组合表面着色和合成操作,比如alpha test、alpha blend。这与移动GPU尤其相关,后者通常在PS中执行。通常需要将这些操作独立于表面着色所使用的材质。
  • 组合着色模型和得到着色模型参数的计算。这样的话,就可以只实现着色模型一次,然后与各种计算着色模型参数的方法结合使用。
  • 组合分支和shader的其他部分。这样可以将每个feature都独立实例。
  • 组合光照模型和根据光源得到其参数的计算:针对每个光源计算当前片元的Clight和L。延迟渲染(详见20章)等技术改变了这个结构。支持多种类似技术的渲染框架的复杂度更高了。

如果图形API提供这种类型的着色程序代码模块作为核心功能,将会非常方便。但是遗憾的是,与CPU不同,GPU shader不允许代码片段编译后链接。每个program都会被编译成一个单元。着色器阶段的分离可以实现一些有限的模块化,但这只是我们上面列表中的第一条:表面着色(通常在PS中执行)组合几何处理(通常在其他shader中执行)。但是由于每个材质球还需要执行其他操作,所以这样并不完美。考虑到这些限制,材质系统实现所有这些类型组合的唯一方法就是在源码级别。这里主要将设计字符串操作,比如连接和替代,通常通过C样式的预处理指令执行,比如include、if、define。

早期的渲染系统中shader变体很少,基本都是全部手写的。这样的好处是,可以根据每个shader的特点来优化每个变量。但是随着变体数量的增加,这种方法很快变的不切实际。当考虑到所有不同的部分和选项的时候,变体的数量是巨大的。这也就是为什么模块化和可组合型如此重要。

在设计处理shader变体的系统的时候,第一个要解决的问题是:是选择在运行时通过动态分支,还是在编译时通过条件预处理进行分支选择。在旧的硬件上,动态分支特别慢,基本不可能,所以不会选择动态分支。(Patrick:加入一个wave对应64个thread,有动态分支的时候,会有32个thread走其中一个分支,另外一个thread走另外一个分支。wave和thread的概念见下图)所有的变量将在编译时处理,甚至是不同类型不同数量的光(参考文献[1193])。

T05 T05 T05 T05 T05

相比之下,当前GPU可以很好的处理动态分支,尤其在当前DC下,针对所有像素都使用相同分支的时候。所以现在,比如灯的数量,都是在运行时处理的。(Patrick:灯的数量不是uniform么,这个不是常量是静态分支么?静态分支和直接替换shader源代码还不一样?)但是,向shader中添加大量的变量也会产生其他的一些成本:寄存器计数增加,占用率相应降低,从而降低性能。有关更多详细信息,详见18.4.5。因此编译时变量仍然有用,因为它可以避免一些永远不会执行到的复杂逻辑。

举个例子,假如一个应用程序中支持三种不同类型的灯。其中两种很简单:点光源和方向光。第三种类型是通用聚光灯,它支持表格照明模式和其他复杂功能,需要大量的代码实现。但是很少使用聚光灯。在过去,会根据这三种灯光的类型和计数做出各种排列组合编译不同的shader变体,以避免动态分支。虽然现在不这样做了,但是还是可以拆成两个变体,一个适用于有聚光灯的时候,另外一个适用于没有聚光灯的时候。这样第二种变体的代码比较简单,就可以得到更低的寄存器占用率,得到更高的性能。(Patrick:所以使用uniform的静态分支和shader源代码替换的唯一区别就是:使用uniform的话,某个分支虽然永远不走,但是其中用到的变量还是需要占用寄存器的。)

现代的材质系统同时使用运行时和编译时shader变体。虽然整个负担不再只在编译时处理,但是总体的复杂性和变化的数量在不断增加。因此,仍需要编译大量的shader变体。比如,在Destiny中:The Taken King中一帧中会超过9000个已编译的着色变体(参考文献[1750])。Unity中由接近1000亿个可能出现的变体。即使是只编译实际使用的变体,但依然要重新设计shader编译系统,来处理大量可能的变体(参考文献[1439])


虽然并非全部原创,但还是希望转载请注明出处:电子设备中的画家|王烁 于 2019 年 7 月 22 日发表,原文链接(http://geekfaner.com/shineengine/Translation5_RealTime_Rendering_4th_Edition1.html)