第五章 着色基础
在渲染三维物体的图像时,场景中的模型不仅仅需要有正确的几何形状,还应当具备想要的材质外观。根据应用程序的不同,这些外观具有非常广泛的范围,从真实感渲染(即物体外观几乎和真实世界中的一模一样),到各种各样的由于创造性而选择的风格化外观等。图5.1同时展示了这两种不同的风格。
图5.1 第一行是使用虚幻引擎渲染的写实风景场景。第二行来自Campo Santo的游戏《看火人(Firewatch)》,它采用了一种插画式(illustrative art)的艺术风格。
着色模型
想要确定渲染对象的材质外观,第一步是选择一个着色模型(shading model),这个模型用于描述物体的颜色是如何随着表面朝向、观察方向和光照等因素的变化而变化的。
例如:现在将使用Gooch着色模型的一个变体来渲染物体,Gooch着色模型是一种非真实感渲染(non-photorealistic rendering,NPR)的着色模型。Gooch 着色模型被设计用于增加技术插图(工程制图)中细节的易读性。
Gooch着色模型的基本思想是比较表面法线和光源的位置:如果法线直接指向了光源,那么就会使用一种暖色调来给表面着色;如果法线没有指向光源,则会使用一种冷色调来给表面着色;如果法线位于这两个状态之间,则会在冷暖色调之间进行插值,当然用户也可以自定义设置表面的颜色。在这个例子中,还会给表面添加一个风格化的高光效果,从而使得物体表面上的某些地方变得更加闪亮。图5.2展示了Gooch着色模型的渲染结果。
着色模型中通常会包含一些用于控制外观表现的属性,确定物体材质外观的下一步,就是设定这些属性的具体数值。刚才提到的Gooch着色模型只有一个属性,即表面颜色,如图5.2底部所示。
图5.2 一种风格化的着色模型,由Gooch着色模型和高光效果组成。顶部的图像展示了一个使用这个着色模型渲染的复杂对象(中国龙),它具有一个中性色调的表面颜色。下部的图像中展示了一系列具有不同表面颜色的材质球。
和大多数着色模型一样,相对于观察方向和光照方向的表面朝向,也会对Gooch着色模型产生影响。为了便于着色计算,这些方向通常都会使用归一化向量(单位向量)来进行描述,如图5.3所示。
图5.3 Gooch着色模型中输入的单位向量:表面法线 n \mathbf{n} n,观察方向 v \mathbf{v} v以及光照方向 l \mathbf{l} l。这些单位向量在其他着色模型也被大量使用。
&msp;有了定义着色模型的全部输入参数,现在可以看看Gooch着色本身的数学定义:
c shaded = s c highlight + ( 1 − s ) ( t c warm + ( 1 − t ) c cool ) . (5.1) \mathbf{c}_{\text {shaded }}=s \mathbf{c}_{\text {highlight }}+(1-s)\left(t \mathbf{c}_{\text {warm }}+(1-t) \mathbf{c}_{\text {cool }}\right). \tag{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}_{\mathrm{cool}} &=(0,0,0.55)+0.25 \mathbf{c}_{\mathrm{surface}}, \\ \mathbf{c}_{\mathrm{warm}} &=(0.3,0.3,0)+0.25 \mathbf{c}_{\mathrm{surface}}, \\ \mathbf{c}_{\mathrm{highlight}} &=(1,1,1), \\ t &=\frac{(\mathbf{n} \cdot \mathbf{l})+1}{2}, \\ \mathbf{r} &=2(\mathbf{n} \cdot \mathbf{l}) \mathbf{n}-\mathbf{l}, \\ s &=(100(\mathbf{r} \cdot \mathbf{v})-97)^{\mp} . \end{aligned} \tag{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、或0到1,它在着色计算中十分常用。这里使用了 x ∓ x^{\mp} x∓来代表clamp操作,曾在章节1.2的符号约定中提到过,这里是将高光混合系数的计算结果限制到0-1范围内。上述计算过程中出现了三次单位向量点乘的操作,这在着色计算中也十分常见;两个向量的点乘结果,就是各自长度与它们之间夹角余弦值的乘积,而单位向量的模长为1,因此两个单位向量点积的结果,就是这两个向量夹角的余弦值,这个余弦值可以用于衡量两个向量之间的对齐程度。由余弦组成的简单函数是一种令人愉快的数学表达式,它可以精确描述两个方向之间的夹角关系,例如着色模型中光线方向和表面法线之间的关系。
另一个常见的着色操作是在两个颜色之间,使用一个0-1之间的标量来进行线性插值。这个操作的基本形式为 t c a + ( 1 − t ) c b t \mathbf{c}_{\mathrm{a}}+(1-t) \mathbf{c}_{\mathrm{b}} tca+(1−t)cb,其中 t ∈ [ 0 , 1 ] t \in [0,1] t∈[0,1],当t在0-1之间变化的时候,最终的结果会在 c a \mathbf{c}_{\mathrm{a}} ca和 c b \mathbf{c}_{\mathrm{b}} cb之间进行插值。在这里的着色模型中,包含了两次线性插值的操作,第一次是在 c w a r m \mathbf{c}_{\mathrm{warm}} cwarm和 c c o o l \mathbf{c}_{\mathrm{cool}} ccool之间进行插值,从而获得表面颜色;第二次是在插值出来的表面颜色和高光颜色 c h i g h l i g h t \mathbf{c}_{\mathrm{highlight}} chighlight之间进行插值,从而获得最终输出的颜色。线性插值是一个十分常用的操作,通常着色器都会提供内置(built-in)的函数来进行线性插值,一般叫做 l e r p \mathsf{lerp} lerp或者 m i x \mathsf{mix} mix。
表达式 r = 2 ( n ⋅ l ) n − l \mathbf{r} =2(\mathbf{n} \cdot \mathbf{l}) \mathbf{n}-\mathbf{l} r=2(n⋅l)n−l计算了光线方向 l \mathbf{l} l相对于表面法线 n \mathbf{n} n的反射向量(简单画图就可以推导出来)。尽管反射操作并不像之前的clamp操作和线性插值那样用的那么频繁,但是也较为常见,因此大多数着色器语言也都内置了一个 r e f l e c t \mathsf{reflect} reflect函数来计算反射向量。
通过将这些操作,与各种各样的数学公式以及着色参数以不同的方式组合在一起,就可以获得各种各样的着色模型,这些着色模型可以是风格化的,也可以是写实的。
光源
在上一小节中的示例着色模型中,光源对着色结果的影响是很简单的,它只提供了一个用于着色的主要方向。但是现实世界中的光照是非常复杂的,可能会有很多个光源,每个光源的大小、形状、颜色以及强度都可能会不相同;而间接光照的情况就更加复杂了,基于物理的真实感渲染,需要将所有这些参数都纳入考虑。
相反,风格化的着色模型可能会以许多不同的方式来计算光照,这取决于应用程序和视觉风格的需要。而对于一些高度风格化的着色模型而言,可能完全没有光照的概念,或者只是用它来提供一些简单的方向性(例如上一小节中提到的Gooch着色模型)。
对于着色模型而言,光照的复杂性还体现在于,如何使得表面以二元方式(表面材质只有有光和无光两种情况),来对无光或者有光环境做出相应的反应;使用这种着色模型进行渲染的物体表面,在有光照的时候会表现出一种外观,而在没有光照的时候则会表现出另一种外观。这也意味着有一些标准可以用来区分这两种情况:距离光源的距离,阴影,表面是否背对光源(即表面法线 n \mathbf{n} n和光照方向 l \mathbf{l} l之间的夹角大于 9 0 ∘ 90^{\circ} 90∘),或者以上这些因素的组合。
这里首先使用一个连续变化的光照强度,来替代有光还是无光的二元条件。这可以通过对完全有光和完全无光之间进行插值获得,但是这也意味着,能够设定的光照强度是有限的,即从0到1;或者使用一个没有范围限制的光照强度,并使用其他一些方式来对着色过程产生影响。后者的一个常见做法是将着色模型分为lit(受到光照)和unlit(没有受到光照)两部分,并使用光强系 k l i g h t k_{\mathrm{light}} klight来对有光部分进行缩放,具体形式如下:
c shaded = f unlit ( n , v ) + k light f lit ( l , n , v ) (5.3) \mathbf{c}_{\text {shaded }}=f_{\text {unlit }}(\mathbf{n}, \mathbf{v})+k_{\text {light }} f_{\text {lit }}(\mathbf{l}, \mathbf{n}, \mathbf{v}) \tag{5.3} cshaded =funlit (n,v)+klight flit (l,n,v)(5.3)
方程5.3中的 k l i g h t k_{\mathrm{light}} klight只是代表了光照的强度系数,如果还想要表示光源的RGB颜色 c light \mathbf{c}_{\text {light }} clight 的话,可以将其扩展成如下形式:
c shaded = f unlit ( n , v ) + c light f lit ( l , n , v ) , (5.4) \mathbf{c}_{\text {shaded }}=f_{\text {unlit }}(\mathbf{n}, \mathbf{v})+\mathbf{c}_{\text {light }} f_{\text {lit }}(\mathbf{l}, \mathbf{n}, \mathbf{v}), \tag{5.4} cshaded =funlit (n,v)+clight flit (l,n,v),(5.4)
如果模型会接收场景中的多个光照的话,则:
c shaded = f unlit ( n , v ) + ∑ i = 1 n c light i f lit ( l i , n , v ) . (5.5) \mathbf{c}_{\text {shaded }}=f_{\text {unlit }}(\mathbf{n}, \mathbf{v})+ \sum_{i=1}^{n}\mathbf{c}_{\text {light }_i} f_{\text {lit }}(\mathbf{l}_i, \mathbf{n}, \mathbf{v}). \tag{5.5} cshaded =funlit (n,v)+i=1∑nclight iflit (li,n,v).(5.5)
其中unlit部分 f unlit ( n , v ) f_{\text {unlit }}(\mathbf{n}, \mathbf{v}) funlit (n,v)对应“完全不受光照影响时的外观”,此时着色模型会将光照信息二元化,即分为完全有光条件和完全无光条件。根据所希望获得的视觉风格,以及应用程序的需要,这个unlit部分的着色可以有着各种不同的形式。例如当 f unlit ( ) = ( 0 , 0 , 0 ) f_{\text {unlit }}()=(0,0,0) funlit ()=(0,0,0)的时候,会使得物体表面在不接受光照时表现为纯黑色;或者,unlit的部分也可以表现出某种风格化的外观,例如上文中提到的Gooch着色模型,它对于没有接收到光照的表面,会显示一个冷色调的颜色。通常来说,着色模型中的这部分会表现出间接光照或者叫做环境光照的效果(即不是直接由光源直接照射到物体表面上所产生的光照效果),例如来自天空盒的光照,或者是光线在周围物体之间进行弹射而形成的光照。
前文中提到,如果表面上一点的法线 n \mathbf{n} n,与光照方向 l \mathbf{l} l之间的夹角大于 9 0 ∘ 90^{\circ} 90∘的话,则说明光源发出的光线来自表面的背后,那么这个光源就不会对这个点产生任何光照效果。一般来说,光线方向与表面法线方向之间的关系,会对着色过程产生影响,而这个例子则可以认为是一般情况中的一个特例。虽然光线方向和表面法线之间的关系是基于物理的,但是这个关系也可以通过简单的几何原理推导出来;而且这个关系对于很多非基于物理的、风格化着色模型也很有用。
为了对表面进行着色,可以将光照对表面的影响可视化为一组平行的射线(ray),照射到表面上的射线密度代表了光照的强度。图5.4中展示了受光表面的横截面,光线(射线)之间的间距 d d d,与表面法线 n \mathbf{n} n和光照方向 l \mathbf{l} l之间夹角的余弦值成反比。也就是说,照射到表面上的光线总强度(总能量),与表面法线 n \mathbf{n} n和光照方向 l \mathbf{l} l之间夹角的余弦值成反比,之前已经提到了,两个单位向量之间夹角的余弦值,就等于这两个向量点乘的结果。在这里可以看到为什么要将光线的方向向量 l \mathbf{l} l定义为从表面碰撞点指向光源,而不是沿着真实的光线方向;如果让光线方向指向表面碰撞点的话,那么就要在每次点乘运算之前,将光线方向取反。
图5.4 上面一行的图展示了光线照射到一个表面上的横截面示意图。其中左图是光线垂直照射在表面上,中图是光线以一个倾斜角度照射在表面上,右图中使用向量点乘来计算了夹角的余弦值。下面一行的图展示了这个横截面(包含光线方向和表面法线)与整个表面的相对位置。
更准确的说,当点乘结果为正的时候,光线强度才与点乘结果成正比;当点乘结果为负的时候,对应了光线来自于表面背后的情况,此时光线对于表面的着色并没有影响。所以在将光线的着色结果与点乘结果相乘之前,需要将点乘结果限制到0,使用符号 x + x^+ x+来表示限制到0的操作,它代表了如果输入的数值为负数,则直接返回0。考虑光线方向和表面法线之间的关系对着色结果的影响,将获得以下方程:
c shaded = f unlit ( 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}_{\text {shaded }}=f_{\text {unlit }}(\mathbf{n}, \mathbf{v})+\sum_{i=1}^{n}\left(\mathbf{l}_{i} \cdot \mathbf{n}\right)^{+} \mathbf{c}_{\mathrm{light}_{i}} f_{\mathrm{lit}}\left(\mathbf{l}_{i}, \mathbf{n}, \mathbf{v}\right) \tag{5.6} cshaded =funlit (n,v)+i=1∑n(li⋅n)+clightiflit(li,n,v)(5.6)
支持多光源的着色模型,通常具有类似于方程5.5或者是方程5.6的结构,前者形式更加普遍一些,后者则是基于物理的着色模型所需要的。方程5.6也可以用于构建风格化的着色模型,因为它有助于确保光照效果的一致性,尤其是对于那些背对光源,或者是处于阴影中的表面。但是有些着色模型确实不太适合这种结构,对于这样的模型,可以使用方程5.5中的结构。
对于接受光照的lit部分,最简单的选择就是直接为其设定一个恒定的颜色,即:
f lit ( ) = c surface (5.7) f_{\text {lit }}()=\mathbf{c}_{\text {surface }} \tag{5.7} flit ()=csurface (5.7)
将其带入方程5.6中的,可以获得如下的着色模型:
c shaded = f unlit ( n , v ) + ∑ i = 1 n ( l i ⋅ n ) + c light i c surface (5.8) \mathbf{c}_{\text {shaded }}=f_{\text {unlit }}(\mathbf{n}, \mathbf{v})+\sum_{i=1}^{n}\left(\mathbf{l}_{i} \cdot \mathbf{n}\right)^{+} \mathbf{c}_{\text {light }_{i}} \mathbf{c}_{\text {surface }} \tag{5.8} cshaded =funlit (n,v)+i=1∑n(li⋅n)+clight icsurface (5.8)
上述方程中的lit部分,其实就是Lambertian(兰伯特)着色模型,该模型由Johann Heinrich Lambert 于1760年提出!(原文中特地使用了感叹号)。Lambertian模型可以用于对理想漫反射表面的着色,例如完美的哑光表面等。Lambertian模型本身可以用于生成简单的着色,同时它也是许多着色模型中所用到的关键构件。
从方程5.3-5.6可以看到,一个光源可以通过两个关键参数来与着色模型进行相互作用:指向光源的光线方向 l \mathbf{l} l,以及光线的颜色 c light \mathbf{c}_{\text {light }} clight 。有各种不同类型的光源,其主要区别在于这两个参数在场景中的变化方式。
接下来将讨论几种常见类型的光源,它们都有一个共同点:对于一个给定的表面位置,每个光源都只会从单一方向 l \mathbf{l} l照射到表面上。换句话说,从着色点的位置看向光源,光源是一个无穷小的点。现实世界中的光源严格上来说并不是这样的,但是大多数光源自身的尺寸,相对于它们与着色表面之间距离来说都非常小,因此将光源抽象成一个点是一个合理的近似。
方向光
方向光(Directional light)是光源模型中最简单的一个。对于场景中的方向光,其光线方向 l \mathbf{l} l和颜色 c light \mathbf{c}_{\text {light }} clight 都是恒定的,除了光线的颜色 c light \mathbf{c}_{\text {light }} clight 可能会被场景中的阴影所减弱。真实世界中的光源都是具有确切位置的,但是在渲染中,方向光是没有位置这个概念的。方向光是一个抽象的概念,当场景到光源的距离相对于场景尺寸而言很大的时候,方向光的效果会很好。例如:一个20英尺外的泛光灯对桌面上的一个立体模型进行照明,我们便可以把这个泛光灯看作是一个方向光。方向光的另一个例子就是地球上被太阳所照亮的场景,但是如果讨论的是太阳系内的行星是如何被太阳照亮的话,那么就不太能将太阳看成一个方向光了。
方向光的概念可以在某种程度上加以扩展,即在保持光线方向 l \mathbf{l} l不变的同时,改变光线的颜色 c light \mathbf{c}_{\text {light }} clight 。这通常是出于场景演出或者其他的一些创造性目的,从而将光线的效果绑定到场景中的特定部分。例如:一个区域可能会有两个嵌套(一个在另一个的内部)的方形空间,其中会将外部空间的 c light \mathbf{c}_{\text {light }} clight 设定为 ( 0 , 0 , 0 ) (0,0,0) (0,0,0),即纯黑;将内部空间的 c light \mathbf{c}_{\text {light }} clight 设定为另一个恒定的颜色,然后当角色在场景中移动的时候,会通过对这两个空间的颜色进行平滑插值,从而获得方向光的颜色。
精确光源
精确光源是英文是punctual light,其中punctual本身有守时、精确的意思,但是这里并不是指光源会准时赴约,而是指灯光有一个确定的位置,并不是像方向光那样没有位置概念,也可以叫做定点光源。精确光源和现实世界中的光源不同,它没有面积、形状和尺寸的概念。 “punctual”来自于拉丁语“punctus”,其意思是“点(point)”;这里使用精确光源来描述那些从单一局部位置上发出光线的光源。使用点光源来描述那些向各个方向均匀发射光线的光源。也就是说,点光源和聚光灯是两种不同形式的精确光源。对于表面上的着色点 p 0 \mathbf{p}_0 p0,以及位于点 p light \mathbf{p}_{\text {light }} plight 的精确光源,其光线方向 l \mathbf{l} l为从点 p 0 \mathbf{p}_0 p0指向点 p light \mathbf{p}_{\text {light }} plight 的向量,具体计算方程如下:
l = p light − p 0 ∥ p light − p 0 ∥ . (5.9) \mathbf{l}=\frac{\mathbf{p}_{\text {light }}-\mathbf{p}_{0}}{\left\|\mathbf{p}_{\text {light }}-\mathbf{p}_{0}\right\|}. \tag{5.9} l=∥plight −p0∥plight −p0.(5.9)
上述方程是向量归一化的一个例子:一个向量除以其模长,可以生成一个相同方向的单位向量。与前文所提到的着色操作类似,向量归一化在着色计算中也经常使用,大多数着色语言都会提供内置的函数来完成这个操作。但是有时候可能会需要这个计算中的一些中间结果,那么就要将这个归一化过程使用更加基础的操作来分步执行。对于精确光源,其光线方向的分步计算过程如下所示:
d = p light − p 0 , r = d ⋅ d , l = d r . (5.10) \begin{aligned} \mathbf{d} &=\mathbf{p}_{\text {light }}-\mathbf{p}_{0}, \\ r &=\sqrt{\mathbf{d} \cdot \mathbf{d}}, \\ \mathbf{l} &=\frac{\mathbf{d}}{r} . \end{aligned} \tag{5.10} drl=plight −p0,=d⋅d,=rd.(5.10)
由于两个向量点乘的结果等于两个向量的长度再乘以其夹角的余弦值,而 0 ∘ 0^{\circ} 0∘的余弦值是1.0,那么一个向量点乘自身的结果就等于其长度的平方。因此,我们可以让一个向量点乘自身再开平方,从而计算其长度。
通常所需要的中间数据是 r r r,即精确光源和当前着色点之间的距离。除了使用 r r r来对光线方向进行归一化之外,还可以用于计算光线颜色随着距离的衰减。
点光源/泛光灯
向所有方向都均匀发射光线的精确光源被叫做点光源(point light)或者泛光灯(omni light)。对于点光源而言,光线的强度 c light \mathbf{c}_{\text {light }} clight 会随着距离 r r r的变化而变化,其中唯一的变化源就是上文提到的距离衰减。图5.5展示了这个颜色变暗的原因,这与图5.4中的几何推导过程相类似,都使用了余弦因子。对于一个给定的表面,从一个点光源发出的光线,其间距与光源到表面的距离成正比。
图5.5 从点光源发出的光线,其间距随着距离 r r r的增大而增大,由于这个间距会在两个维度上同时增长,因此光线的强度(即光线的颜色)与 1 / r 2 1 / r^2 1/r2成正比。
与图5.4中的余弦因子略有不同的是,这个间距会沿着表面上的两个方向维度同时增加,因此光线的强度(即光线的颜色 c light \mathbf{c}_{\text {light }} clight )与平方距离的倒数成正比,即与 1 / r 2 1/r^2 1/r2成正比。这个特性使得可以使用一个单一的光线参数,来指定 c light \mathbf{c}_{\text {light }} clight 在空间中发生的变化,这个参数记为 c light 0 \mathbf{c}_{\text {light}_0} clight0,即 c light \mathbf{c}_{\text {light }} clight 在固定参考距离 r 0 r_0 r0处的值:
c light ( r ) = c light 0 ( r 0 r ) 2 (5.11) \mathbf{c}_{\text {light }}(r)=\mathbf{c}_{\text {light }_{0}}\left(\frac{r_{0}}{r}\right)^{2} \tag{5.11} clight (r)=clight 0(rr0)2(5.11)
方程5.11通常被称作光线的平方反比衰减(inverse-square light attenuation),虽然这个方程在从技术上来说,的确正确描述了一个点光源的距离衰减,但是它仍然存在一些问题,会使得它在实际着色计算的使用中并不理想。
第一个问题发生在相对较小的距离上。当距离 r r r的值趋近于0的时候,光线颜色 c light \mathbf{c}_{\text {light }} clight 的值会迅速无界增长;当距离 r r r为0的时候,此时方程就出现了除以0的情况。为了解决这个问题,一个常用的优化方式是给分母加上一个较小的数值 ϵ \epsilon ϵ,即:
c light ( r ) = c light 0 r 0 2 r 2 + ϵ . (5.12) \mathbf{c}_{\text {light }}(r)=\mathbf{c}_{\text {light }_{0}} \frac{r_{0}^{2}}{r^{2}+\epsilon}. \tag{5.12} clight (r)=clight 0r2+ϵr02.(5.12)
数值 ϵ \epsilon ϵ的取值取决于应用程序本身的设定,例如,虚幻引擎中的设定为 ϵ = 1 c m 2 \epsilon = 1 \text cm^2 ϵ=1cm2。
CryEngine 和寒霜引擎则使用了另一种优化方式,即将距离 r r r限制到一个最小的值 r m i n r_{min} rmin,即:
c light ( r ) = c light 0 ( r 0 max ( r , r min ) ) 2 . (5.13) \mathbf{c}_{\text {light }}(r)=\mathbf{c}_{\text {light }_{0}}\left(\frac{r_{0}}{\max \left(r, r_{\min }\right)}\right)^{2}. \tag{5.13} clight (r)=clight 0(max(r,rmin)r0)2.(5.13)
与前一种方法中使用一个有点随意的数值 ϵ \epsilon ϵ不同,方程5.13中的 r m i n r_{min} rmin有一个具体的物理解释,即发光物体的物理半径。比 r m i n r_{min} rmin还要小的距离 r r r,对应了位于光源内部的着色表面,这在现实中是不可能发生的。
和第一个问题相反的是,平方反比衰减的第二个问题会发生在相对较大的距离上。这个问题与视觉效果无关,而是与性能有关。尽管光线强度会随着距离的增大而不断减小,但是它永远也不会变成0;为了提高渲染效率,希望光线强度会在某个有限的距离处衰减到0。有很多不同的方法可以对平方反比方程进行修改,从而达到这一目的,在理想情况下,这个修改方法应当尽可能少地引入变化。同时,为了避免在光线影响范围的边界处出现尖锐的截断,最好是在修正过后,函数的值及其导数会在同一位置处达到0。一种解决方案是将平方反比方程乘以具有所需属性的窗函数(window function)。虚幻引擎和寒霜引擎所采用的窗函数方程如下:
f win ( r ) = ( 1 − ( r r max ) 4 ) + 2 (5.14) f_{\text {win }}(r)=\left(1-\left(\frac{r}{r_{\max }}\right)^{4}\right)^{+2} \tag{5.14} fwin (r)=(1−(rmaxr)4)+2(5.14)
方程中的 + 2 +2 +2意味着,在进行平方操作之前,需要对值进行限制,如果值为负数的话,则将其设置为0。图5.6展示了三条曲线,分别是平方反比曲线,方程5.14中的窗函数曲线,以及二者相乘后的结果。
图5.6 图中展示了三条曲线,其中蓝色曲线是平方反比曲线(使用了 ϵ \epsilon ϵ 来避免分母为0 ,其中 ϵ = 1 \epsilon = 1 ϵ=1),绿色曲线是方程5.14中描述的窗函数(其中 r m a x = 3 r_{max} = 3 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 light ( r ) = c light 0 f dist ( r ) , (5.15) \mathbf{c}_{\text {light }}(r)=\mathbf{c}_{\text {light }_{0}} f_{\text {dist }}(r), \tag{5.15} clight (r)=clight 0fdist (r),(5.15)
其中 f dist ( r ) f_{\text {dist }}(r) fdist (r)是一些关于距离的函数,这样的函数被称为距离衰减函数(distance falloff function)。在某些情况下,由于性能开销受到限制,因此需要使用一些非平方反比的衰减函数。例如:在《正当防卫2》中,需要性能开销很低的光照计算,这就要求所使用的距离衰减函数必须简单易算,同时其过渡也要足够平滑,不能出现逐顶点的光照瑕疵(Artifacts),它所使用的距离衰减函数如下所示:
f dist ( r ) = ( 1 − ( r r max ) 2 ) + 2 (5.16) f_{\text {dist }}(r)=\left(1-\left(\frac{r}{r_{\max }}\right)^{2}\right)^{+2} \tag{5.16} fdist (r)=(1−(rmaxr)2)+2(5.16)
在其他情况下,衰减函数的选择也可能会取决于一些艺术风格的考虑。例如:虚幻引擎可以用于制作写实游戏和风格化游戏,对于这两种完全不同的画面风格,虚幻引擎提供了两种光线衰减的模式:一种是方程5.12中所描述的平方反比衰减模式;另一种是指数衰减模式,在这种模式中,可以通过调整参数,来创建各种各样的衰减曲线。《古墓丽影(2013)》的开发者使用了一个样条线编辑工具来绘制衰减曲线,从而可以更好地控制曲线的形状。
聚光灯
与点光源不同的是,现实世界中几乎所有光源的光照,不仅会随着距离的改变而发生变化,同样也会随着方向的改变而发生变化,这种变化可以表示为一个方向性的衰减函数 f dir ( l ) f_{\text {dir }}(\mathbf{l}) fdir (l),它与距离衰减函数组合在一起,便完整定义了光照强度在空间中的变化:
c light = c light 0 f dist ( r ) f dir ( l ) (5.17) \mathbf{c}_{\text {light }}=\mathbf{c}_{\text {light }_{0}} f_{\text {dist }}(r) f_{\text {dir }}(\mathbf{l}) \tag{5.17} clight =clight 0fdist (r)fdir (l)(5.17)
选择不同的 f dir ( l ) f_{\text {dir }}(\mathbf{l}) fdir (l)可以生成不同的光照效果,其中一种很重要的效果是聚光灯(spotlight),它会将光线投射在一个圆锥体的内部。聚光灯的方向衰减函数围绕其方向向量 s \mathbf{s} s具有旋转对称性,因此这个衰减可以表示为向量 s \mathbf{s} s,与到达表面的反向光线向量 − l \mathbf{-l} −l之间夹角 θ s \theta_{s} θs的函数。这里的光线向量需要进行取反,这是因为是在着色表面上定义光线向量 l \mathbf{l} l的,该向量会指向光源,而这里需要这个向量指向远离光源的方向。
大多数聚光灯函数都会使用包含夹角 θ s \theta_{s} θs的余弦表达式,这是着色计算中表达角度最常用的方式(正如之前所看到的一些着色方程)。通常聚光灯都会有一个本影角(umbra angle) θ u \theta_{u} θu,对于 θ s ≥ θ u \theta_{s} \ge \theta_{u} θs≥θu的所有光线,会将其距离衰减函数限制为 f dir ( l ) = 0 f_{\text {dir }}(\mathbf{l})=0 fdir (l)=0。这个角度可以用于剔除,其原理类似于之前在点光源所提到的最大衰减距离 r max r_{\text {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 dir F ( l ) f_{\text {dir}_{F}}(\mathbf{l}) fdirF(l),而three.js(一个浏览器图形库)所使用的则是 f dir T ( l ) f_{\text {dir}_{T}}(\mathbf{l}) fdirT(l),它们的具体形式如下所示:
t = ( cos θ s − cos θ u cos θ p − cos θ u ) ∓ , f dir F ( 1 ) = t 2 , f dir T ( l ) = smoothstep ( t ) = t 2 ( 3 − 2 t ) . (5.18) \begin{aligned} t & =\left(\frac{\cos \theta_{s}-\cos \theta_{u}}{\cos \theta_{p}-\cos \theta_{u}}\right)^{\mp}, \\ f_{\operatorname{dir}_{\mathrm{F}}}(\mathbf{1}) & =t^{2}, \\ f_{\operatorname{dir}_{\mathrm{T}}}(\mathbf{l}) & =\text { smoothstep }(t)=t^{2}(3-2 t) .\end{aligned} \tag{5.18} tfdirF(1)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之间。其中的smoothstep函数是一个三次多项式,通常用于着色计算中的平滑插值,在大多数着色语言中,它都是一个内置的函数。
图5.8展示了我们到目前为止讨论过的一些光照类型。
图5.8 上图展示了三种不同的光照类型,从左到右分别是:方向光;没有距离衰减的点光源;有平滑过渡的聚光灯。请注意在第二幅表示点光源的图中,由于光线和表面之间夹角的变化,因此会在边缘的地方变暗。
其他精确光源
还有很多其他的方式可以让一个精确光源的光线强度 c light \mathbf{c}_{\text {light }} clight 发生变化。
f dir ( l ) f_{\text {dir }}(\mathbf{l}) fdir (l)函数也不仅仅局限于上面所讨论的简单聚光灯衰减函数,它可以表示任何类型的方向变化,包括从真实世界光源测量而来的复杂表格数据模式。照明工程学会(Illuminating Engineering Society, IES)为此类度量定义了标准的文件格式,这些IES配置文件可以从许多照明设备的制造商处获得,并且已经在游戏《杀戮地带:暗影坠落》中进行了实际应用,除此之外也在例如虚幻和寒霜等游戏引擎中进行了应用。Lagarde给出了一个有关解析和使用该文件格式的良好总结。
《古墓丽影(2013)》中有着一类特殊的精确光源,它可以对沿世界方向的 x , y , z x,y,z x,y,z轴应用独立的衰减函数。除此之外,还可以使用自定义编辑的曲线来让光线强度随时间发生变化,例如生成一个闪烁的火炬效果。
其他光源类型
方向光和精确光源的主要特征是如何计算光线方向 l \mathbf{l} l,可以使用其他一些方法来计算光线方向,从而定义不同类型的光源。例如:除了上文中所提到的光源类型之外,《古墓丽影(2013)》中还有一种特殊的胶囊光,它使用了一个线段来作为光源,而不是一个点。对于每个需要进行着色的像素,会将该像素与距离线段上最近点的方向作为光线方向 l \mathbf{l} l。
只要着色器使用了光线方向 l \mathbf{l} l和光线强度 c light \mathbf{c}_{\text {light }} clight 来计算着色方程,那么可以使用任何方法来计算这些值。
到目前为止,讨论的光源类型都是抽象的;而在现实世界中,光源具有大小和形状,它们会从多个方向照亮表面上的一个点。在渲染领域中,这种光源类型被称为面光源(area light),它们在实时应用程序中的应用正在稳步增加。面光源渲染技术可以分为两类:一类是模拟光线被部分遮挡,从而导致阴影边缘软化(软阴影)的方法;另一类是模拟面光源对表面着色影响的方法。第二类光照效果在光滑镜面上体现得最为明显,可以通过镜面反射,清晰得看到面光源的大小和形状。尽管方向光和精确光源已经不再像以前那样无处不再了,但是它们也不太可能被废弃。对面光源的近似方法已经有了一定的发展,其实现成本也相对便宜,因此得到了广泛的应用。同时,不断提高的GPU性能也允许使用比过去更加复杂的技术。
实现着色模型
计算频率
当设计一个着色实现的时候,需要评估它的计算频率(frequency of evaluation),并对计算进行划分。首先,需要确定一个给定的计算结果在整个draw call中是否总为常数。尽管GPU的计算着色器可以用于一些开销很大的计算,但是如果这个计算结果是常数的话,那么可以让应用程序来完成(即在CPU上完成),然后通过统一的着色器输入传递给图形API,这样可以大大减少重复计算量。
即使在这一类常数计算结果中,计算频率也会有很大的变化范围,从“只计算一次”开始讨论。最简单的情况就是着色方程中的常数子表达式,这种情况也适用于那些包含极少变化的计算因子,例如硬件配置和安装选项等。这样的着色计算可能会在着色器编译阶段就直接完成了,在这种情况下,甚至不需要设置一个统一的着色器输入。又或者,这些计算可以在安装和加载应用程序时,在离线预计算的过程中完成。
另一种情况是,一个着色计算的结果会在应用程序运行的过程中发生变化,但是其变化的频率很慢,因此并不需要每一帧都进行更新。例如:在一个虚拟的游戏世界中,光照效果取决于一天中所处的时间,如果这个计算开销很大的话,则可以将其分摊到多个帧中进行计算。
其他的情况包括每帧仅执行一次的计算,例如连接观察变换和透视变换矩阵;或者每个模型一次的计算,例如对依赖于模型位置信息的光照参数进行更新;或者每个draw call一次的计算,例如更新模型上的材质参数。根据计算频率对统一的着色器输入进行分组,可以大大提高应用程序的效率;还可以通过最小化常量更新,来提升GPU的性能。
如果一个着色计算的结果在一次draw call中会发生变化,那么它就无法通过统一的着色器输入来将其传递给着色器。相反,它必须由第3章中所描述的可编程着色器阶段中的一个来进行计算,如果需要的话,还可以使用可变着色器输入,来将其传递给其他的着色器阶段。理论上而言,着色计算可以在任意一个可编程阶段中执行,每个阶段都对应着一个不同的计算频率:
- 顶点着色器(Vertex Shader)——在每个曲面细分前的顶点上进行计算。
- 壳着色器(Hull Shader)——在每个表面面片上进行计算。
- 域着色器(Domain shader)——在每个曲面细分后的顶点上进行计算。
- 几何着色器(Geometry shader)——在每个图元上进行计算。
- 像素着色器(Pixel shader)——在每个像素上进行计算。
在实际实现中,大部分着色计算都是逐像素执行的,虽然这些计算通常都是在像素着色器中完成,但是使用计算着色器来实现的例子也越来越多;而其他的几个阶段主要用于几何操作,例如变换和变形等。为了理解为什么要这么做,现在对逐顶点(per-vertex)和逐像素(per-pixel)的着色计算结果进行一些比较。在一些较老的书籍中,这两种不同的着色方式有时被称为Gouraud shading和Phong shading,但是现在这些术语已经不常使用了。这次对比测试使用了一个类似于方程5.1中描述的着色模型,不同之处在于,对其进行了修改,使其可以支持多个光源,完整的着色模型会在稍后给出,届时会详细介绍这个示例实现。
图5.9展示了在不同顶点密度的模型上,进行逐像素着色和逐顶点着色的结果。对于最底部的龙模型而言,它是一个非常密集的网格,它的顶点数量很大,逐像素着色和逐顶点着色的结果差别很小;但是对于中间的茶壶而言,顶点着色会导致可见的着色错误,例如棱角形状的高光;对于最上面的三角形平面而言,顶点着色的结果是明显错误的。这些错误的原因是由于着色方程中的一部分项(尤其是高光部分),在网格表面上具有非线性变化的值。这使得它们并不适合在顶点着色器中进行实现,因为顶点着色器的计算结果,会在光栅化阶段被线性插值,然后再输入到像素着色器中。
图5.9 使用方程5.19中所描述的着色模型,来对比逐顶点着色和逐像素着色之间的区别,其结果分别显示在了三个不同顶点密度的模型上。最左侧的一列图片展示的是逐像素计算的结果;中间一列图片展示的是逐顶点计算的结果;最右侧一列图片展示了模型的线框渲染结果,用于显示顶点密度。
原则上来说,可以在像素着色器中只计算着色模型的高光(specular highlight)部分,并在顶点着色器中计算着色模型的剩余部分,这样应该就不会产生视觉瑕疵了,而且在理论上会节省一些计算量。但是在实践中,这种混合的实现方式往往并不是最优选择。首先,着色模型线性变化的部分往往是计算成本最小的部分,并且以这种方式将计算过程分离开来,也会带来额外的性能开销,例如重复的计算和额外的可变输入等,从而导致弊大于利。
正如上文中所提到的,在大多数实现中,顶点着色器负责的操作都是非着色的,例如几何变换和变形操作。几何表面上的属性在被转换为合适的坐标空间后,最终会被顶点着色器输出,并在三角形上进行线性插值,然后作为可变着色器输入,被传递到像素着色器中,这些属性通常包含了表面位置、表面法线、以及可选的表面切向量(如果需要进行法线映射的话)等。
需要注意的是,即使顶点着色器总是会输出单位长度的的表面法线,但是光栅化插值也可能会改变它们的长度,如图5.10左侧所示,因此需要在像素着色器中重新将法线进行归一化(将长度缩放为1)。虽然如此,在顶点着色器中输出单位长度的法线仍然十分重要,如果顶点之间的法线长度变化很大的话(例如顶点混合的副作用),这将会使得法线插值的结果发生倾斜,如图5.10的右侧所示。由于这两种影响,因此在实际的实现中,通常会对插值前和插值后的向量都进行归一化,即在顶点着色器和像素着色器中进行归一化。
图5.10 左图中的两个顶点法线 n 0 \mathbf{n}_0 n0和 n 1 \mathbf{n}_1 n1都是单位向量,我们可以看到,表面上插值而来的中间向量,其长度均小于单位长度。右图中法线 n 0 \mathbf{n}_0 n0的长度明显小于 n 1 \mathbf{n}_1 n1,这会导致插值结果偏向于两个法线中长度较长的那个。
与表面法线不同,指向特定位置的向量(例如观察向量和光线向量),通常并不会进行插值。相反,在像素着色器中, 会使用插值而来的表面位置来计算这些向量。除了对向量进行归一化之外(正如看到的,在任何情况下,像素着色器中的向量在使用之前,都需要进行归一化),这些向量都会使用向量减法来进行计算,这样做的速度很快。如果出于某种原因,需要对这些向量进行插值的话,那么在插值之前,一定不要对它们进行归一化,因为这样会得到错误的结果,如图5.11所示。
图5.11 上图展示的是对两个光线向量进行插值。左图中,在进行插值操作之前,对光线向量进行了归一化,这会导致插值出来的光线方向是错误的;右图中,对两个没有进行归一化的光线向量进行插值,会得到正确的插值向量。
在上文中提到,顶点着色器会将表面的几何属性转换到“合适的坐标系”中,最后再输入到像素着色器中。而相机位置和光源位置,通常也需要由应用程序来将其转换到相同的坐标系中,然后再通过统一变量传递给像素着色器。这样做的好处是显而易见的,不需要在像素着色器中,再对这些向量进行大量的坐标变换,这样可以最小化像素着色器中的重复计算。但是现在有一个问题,到底哪个坐标系才是“合适的坐标系”呢?可能的坐标系包括全局的世界空间,或者是相机的局部坐标系(观察空间),或者是更为罕见的、当前渲染模型的局部坐标系。这个坐标系的选择,通常需要将整个渲染系统作为一个整体,综合考虑系统的性能、灵活性和简单性。例如:如果渲染场景中包含大量的光源,那么可以选择全局的世界空间来避免改变光源的位置;或者最好是选择相机空间,这样可以优化与观察方向相关的像素着色器操作,并提高渲染精度。
尽管大多数的着色器实现(包括我们即将要讨论的示例实现),都遵循了上面描述的大纲,但是当然也有一些例外情况。例如:一些应用程序出于风格上的原因,选择了基于逐图元的着色计算,这样的风格通常被称作平面着色(flat shading),图5.12展示了两个平面着色的例子。
图5.12 两个使用平面着色作为画面风格的游戏:上图来自《肯塔基零号公路》,下图来自《癌症似龙》。
原则上来说,平面着色的效果可以在几何着色器中完成,但是最近的实现通常都是使用顶点着色器来完成的。这是通过将每个图元的属性和第一个顶点绑定起来,同时禁用顶点插值来实现的。禁用顶点插值(可以对每个顶点单独执行)将会导致第一个顶点的属性会被应用到该图元中的每个像素上。
实现实例
这里将展示一个着色模型的实现示例。在上文中提到,将要实现的着色模型类似于方程5.1中所描述的Gooch着色模型,不同的是这个模型进行了扩展,可以支持多个光源,其数学描述如下:
c shaded = 1 2 c cool + ∑ i = 1 n ( l i ⋅ n ) + c light i ( s i c highlight + ( 1 − s i ) c warm ) , (5.19) \mathbf{c}_{\text {shaded }}=\frac{1}{2} \mathbf{c}_{\text {cool }}+\sum_{i=1}^{n}\left(\mathbf{l}_{i} \cdot \mathbf{n}\right)^{+} \mathbf{c}_{\text {light }_{i}}\left(s_{i} \mathbf{c}_{\text {highlight }}+\left(1-s_{i}\right) \mathbf{c}_{\text {warm }}\right), \tag{5.19} cshaded =21ccool +i=1∑n(li⋅n)+clight i(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 highlight = ( 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}_{\mathrm{cool}} & =(0,0,0.55)+0.25 \mathbf{c}_{\mathrm{surface}}, \\ \mathbf{c}_{\mathrm{warm}} & =(0.3,0.3,0)+0.25 \mathbf{c}_{\mathrm{surface}}, \\ \mathbf{c}_{\text {highlight }} & =(2,2,2), \\ \mathbf{r}_{i} & =2\left(\mathbf{n} \cdot \mathbf{l}_{i}\right) \mathbf{n}-\mathbf{l}_{i}, \\ s_{i} & =\left(100\left(\mathbf{r}_{i} \cdot \mathbf{v}\right)-97\right)^{\mp} .\end{aligned} \tag{5.20} ccoolcwarmchighlight risi=(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.19的形式符合在方程5.6中描述的多光源结构,为了方便对照,这里回顾一下方程5.6的具体形式:
c shaded = f unlit ( 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}_{\text {shaded }}=f_{\text {unlit }}(\mathbf{n}, \mathbf{v})+\sum_{i=1}^{n}\left(\mathbf{l}_{i} \cdot \mathbf{n}\right)^{+} \mathbf{c}_{\mathrm{light}_{i}} f_{\mathrm{lit}}\left(\mathbf{l}_{i}, \mathbf{n}, \mathbf{v}\right) cshaded =funlit (n,v)+i=1∑n(li⋅n)+clightiflit(li,n,v)
对于方程5.19而言,其中的lit项和unlit项分别是:
f unlit ( n , v ) = 1 2 c c o o l , f l i t ( l i , n , v ) = s i c highlight + ( 1 − s i ) c warm , (5.21) \begin{aligned} f_{\text {unlit }}(\mathbf{n}, \mathbf{v}) & =\frac{1}{2} \mathbf{c}_{\mathrm{cool}}, \\ f_{\mathrm{lit}}\left(\mathbf{l}_{i}, \mathbf{n}, \mathbf{v}\right) & =s_{i} \mathbf{c}_{\text {highlight }}+\left(1-s_{i}\right) \mathbf{c}_{\text {warm }},\end{aligned} \tag{5.21} funlit (n,v)flit(li,n,v)=21ccool,=sichighlight +(1−si)cwarm ,(5.21)
调整unlit项中的冷色贡献值,可以让结果看起来更像原来的方程。
在大多数典型的渲染应用程序中,诸如 c s u r f a c e \mathbf{c}_{\mathrm{surface}} csurface之类的可变材质属性都会存储在顶点数据中,或者更加常见的做法是存储在一张纹理中。这里为了让这个示例的简单易懂,假设 c s u r f a c e \mathbf{c}_{\mathrm{surface}} csurface在整个模型中都是恒定的。
这个着色模型的实现将会使用着色器的动态分支功能,来对所有的光源进行遍历;这是一种很简单直接的方法,它在一些比较简单的场景中可以正常工作,但是对于一些拥有很多光源的大型复杂场景而言,它的效率就很低了。此外,为了简单起见,这里只会支持点光源这一种光源类型。虽然本小节中的实现非常简单,但是它遵循了前面我们提到的最佳实践原则。
着色模型通常并不是单独实现的,而是在一个更大的渲染上下文框架(context)中实现的。这个示例是在一个简单的WebGL 2应用程序中实现的,它由Tarek Sherif所开发的“Phong-shaded Cube”WebGL 2修改而来,虽然它很简单,但是同样的原理也适用于更加复杂的渲染框架。
将讨论一些GLSL着色器代码,以及JavaScript WebGL调用的示例,本小节的目的并不是教授WebGL API的细节,而是为了展示通用的实现原则。将从“由内而外”的顺序来讲解整个实现过程,首先是从像素着色器开始,然后是顶点着色器,最后是应用程序端的图形API调用。
着色器源文件中应当包含对着色器输出的定义,这样的着色器代码才是正确完整的。正如在章节3.3所讨论的,使用GLSL的术语来进行描述,即着色器输入会分为两类:第一类是一组统一输入,这些值是由应用程序进行设置的,并且在一次draw call的过程中会保持不变;第二类会包含一系列的可变输入,这些值可以在着色器调用(像素着色器或者顶点着色器)之间发生变化。下面给出了一些像素着色器的可变输入(在GLSL中使用 i n \mathsf{in} in来进行标记),以及像素着色器的输出值(使用 o u t \mathsf{out} out来进行标记):
in vec3 vPos ;
in vec3 vNormal ;
out vec4 outColor ;
这个像素着色器只有一个输出,即最终的着色颜色。而像素着色器的输入与顶点着色器的输出是相匹配的,这些参数在输入像素着色器之前,会在三角形上进行插值。这个像素着色器有两个可变输入:表面位置和表面法线,二者均位于应用程序的世界空间坐标系中。统一输入的数量要大得多,所以简单起见,这里只展示两个与光源有关的定义:
struct Light {
vec4 position ;
vec4 color ;
};
uniform LightUBlock {
Light uLights [ MAXLIGHTS ];
};
uniform uint uLightCount ;
由于这些光源都是点光源,因此每个光源定义中都只包含一个位置和一个颜色。这两个数据都被定义成了 v e c 4 \mathsf{vec4} vec4类型而不是 v e c 3 \mathsf{vec3} vec3类型,以符合GLSL s t d 140 \mathsf{std140} std140数据布局标准的限制。虽然在这个例子中, s t d 140 \mathsf{std140} std140布局会浪费一些空间,但是这简化了确保CPU和GPU之间数据布局一致性的任务,这也是为什么在这个示例中使用它的原因。 L i g h t \mathsf{Light} Light数组是在一个被标记为 u n i f o r m \mathsf{uniform} uniform的代码块内部定义的,这是GLSL的一个特性,用于将一组统一变量绑定到一个缓冲区对象中,从而加快数据传输的速度。 L i g h t \mathsf{Light} Light数组的长度被定义为应用程序在一次draw call中所允许的最大光源数量。将在下文中看到,应用程序会在着色器编译之前,使用正确的数值(在这个例子中是10)来替换着色器源代码中的字符串 M A X L I G H T S \mathsf{MAXLIGHTS} MAXLIGHTS。统一的整数 u L i g h t C o u n t \mathsf{uLightCount} uLightCount代表了在这次draw call中实际可用的光源数量。
接下来,来看一下像素着色器的具体代码:
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);
}
}
有一个计算 l i t \mathsf{lit} lit项的函数定义,它会在 m a i n ( ) \mathsf{main()} main()函数中进行调用,总的来说,这是方程5.20和方程5.21的一个简单GLSL实现。请注意, f u n l i t ( ) f_{unlit}() funlit()和 c w a r m c_{warm} cwarm的值是通过统一变量输出的,由于这些变量的值在一次draw call中都是常数,因此可以由应用程序来计算这些值,从而节省一些GPU的计算周期。
这里展示的像素着色器使用了一些内置的GLSL函数。其中 r e f l e c t ( ) \mathsf{reflect()} reflect()函数会在第二个输入向量(平面法线)所定义的平面上,将第一个输入向量进行反射;在本例中,被反射的是光线方向。由于希望光线向量和反射向量都是指向远离表面的方向,因此需要将先光线方向取反,然后再输入到 r e f l e c t ( ) \mathsf{reflect()} reflect()函数中。 c l a m p ( ) \mathsf{clamp()} clamp()函数有三个输入参数,后两个参数决定了第一个参数被限制的范围。一个特殊的限制范围是0-1之间(对应HLSL中的 s a t u r a t e ( ) \mathsf{saturate()} saturate()函数),这个运算的速度是很快的,对于大多数GPU而言,几乎没有什么开销。这也是在这里使用这个函数的原因,尽管只需要将这个参数限制到0,因为已经知道了它的结果不会超过1。函数 m i x ( ) \mathsf{mix()} mix()同样包含三个输入参数,它会根据第三个参数(位于0-1之间),在前两个参数之间进行线性插值;在这个例子中是根据参数 s s s,在暖色和高光颜色之间进行插值。在HLSL中这个函数叫做 l e r p ( ) \mathsf{lerp()} lerp(),意思是“线性插值(linear interpolation)”。最后,函数 n o r m a l i z e ( ) \mathsf{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 ;
}
这些都是顶点着色器中的常见操作,它将表面位置和法线转换到世界空间中,并将其传递给像素着色器以用于着色计算。最终,表面位置会被变换到裁剪空间中,并传递给 g l _ P o s i t i o n \mathsf{gl\_Position} gl_Position,$\mathsf{gl_Position} $是一个光栅化器所使用的、特殊的系统定义变量,它是任何顶点着色器所必须的输出。
需要注意的是,法线向量并没有在顶点着色器中进行归一化,这是因为原始网格数据中的法线长度就已经为1了,并且应用程序没有执行任何可能会改变法线长度的操作,例如顶点混合或者非均匀缩放等。模型变换矩阵中确实包含一个均匀缩放的比例系数,但是它会按比例改变所有法线的长度,因此并不会导致图5.10右侧所展示的问题。
示例中的应用程序使用WebGL API来进行各种渲染设置和着色器设置。每个可编程的着色器阶段都可以单独进行设置,然后它们会被绑定到一个程序对象上。以下是像素着色器设置的代码:
var fSource = document.getElementById ("fragment").text .trim () ;
var maxLights = 10;
fSource = fSource.replace(/MAXLIGHTS/g, maxLights.toString () );
var fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl. shaderSource ( fragmentShader , fSource );
gl. compileShader ( fragmentShader );
请注意代码中提到的“片元着色器(fragment shader)”,这个术语被WebGL(以及它所基于的OpenGL)所使用。但是正如本书前面所提到的那样,虽然“像素着色器(pixel shader)”在某些方面确实不那么精确,但是它是更加常见的用法,因此会在整本书中都使用像素着色器这个名称。这段代码也将 M A X L I G H T S \mathsf{MAXLIGHTS} MAXLIGHTS字符串替换为适合的值,大多数渲染框架会都执行类似这样的着色器预编译操作。
还有很多应用程序端的代码会用于设置统一输入变量、初始化顶点数组、清除、绘制等,这些代码可以都可以在示例程序中找到,并且有很多API指南它们进行了说明解释。本小节的目标是,了解着色器是如何被视为独立处理器的,以及它们自身独特的编程环境。因此有关示例着色程序的讨论到此就结束了。
材质系统
渲染框架很少只会实现一个单独的着色器,因此通常需要一个专门的系统,来处理应用程序中用到的各种各样的材质、着色模型和着色器。
正如前面章节中所介绍的那样,着色器是某个GPU可编程着色阶段中的一个程序。因此着色器是一个底层(low-level)的图形API资源,而不是艺术家可以直接进行交互的东西;相比之下,材质(material)对表面的视觉外观进行了封装,它才是直接面向艺术家的资源。材质有时候也会描述一些非视觉方面的内容,例如碰撞特性(物理材质)等,但是并不会对其进行进一步讨论,因为它超出了本书的范围。
虽然材质是通过着色器实现的,但是这并不是简单的一对一关系。在不同的渲染场景中,有时候相同的材质可能也会使用不同的着色器,一个着色器也可能会被多种材质所共享。最常见的情况就是参数化材质,在最简单形式中,材质参数化需要两种材质实体:材质模板(material template)和材质实例(material instance)。每个材质模板都描述了一类材质,并且包含了一组参数,根据参数类型的不同,可以为其指定具体的数值、颜色或者纹理贴图等。每个材质实例对应了一个材质模板,以及一组包含所有参数的特定值。一些渲染框架(例如虚幻引擎)允许构建一个更加复杂的层次材质结构,其中的材质模板可以由其他的多层次模板派生而来。
这些参数可以在运行时,通过统一输入来传递给着色器程序;或者是在着色器编译阶段,通过替换着色器中的一些值来完成。最常见的编译时参数类型就是一个布尔开关,使用这个布尔值来控制一个给定的材质特性是否会被激活。这样的开关可以由艺术家通过材质GUI中的一个勾选框来进行设置;也可以通过材质系统在程序上进行设置,例如:材质系统可以自动对渲染质量进行调整,来降低远处物体的着色成本,而这个修改在视觉效果上则可以忽略不记。
虽然材质参数可能与着色模型中的参数一一对应,但是情况也并非总是如此的。材质可以对给定的着色模型参数进行修改,例如将表面颜色修改成一个固定的值。又或者,可能会将多个材质参数,以及顶点插值结果和纹理值作为输入,通过一系列复杂的操作,来计算着色模型中的某一个参数。在某些情况下,诸如表面位置、表面朝向甚至是时间等参数,都会对计算产生影响。基于表面位置和表面朝向的着色,在地形材质中尤其常见,例如:可以使用高度和表面法线来控制积雪的效果,具体实现方式是在高海拔的水平表面以及近乎水平的表面上,混合叠加一个白色。基于时间的着色在动画材质中十分常见,例如闪烁的霓虹灯标志。
材质系统最重要的任务之一,就是将各种着色器功能划分为独立的元素,并控制这些元素的组合方式。这种组合是十分有用的,例如以下几种情况:
- 将表面着色和几何处理结合在一起,例如刚体变换、顶点混合、曲面细分、实例化以及裁剪等。这些功能是独立变化的:表面着色依赖于材质,而几何处理则依赖于模型网格。因此,将这些功能分开编写,并使用材质系统对它们进行按需组合是十分方便的。
- 将表面着色和一些合并操作组合在一起,例如像素丢弃(discard)和像素混合(blending),这与移动端的GPU尤其相关,因为其中的混合通常是在像素着色器中执行的。通常我们都希望独立于着色材质来选择这些特殊操作。
- 将计算着色模型参数的操作,与计算着色模型本身的操作组合在一起。这样做可以在计算其他着色模型的时候,复用之前已经实现过的操作和函数。
- 将独立可选的材质特性相互组合,并与逻辑选择、着色器的剩余部分组合在一起。这种方式允许独立编写每个材质特性的实现。
- 将着色模型及其参数计算,与光源计算组合在一起:即计算每个光源在着色点上的颜色 c l i g h t \mathbf{c}_{light} clight和方向 l \mathbf{l} l。例如延迟渲染等技术可以改变这种组合的结构。在支持多种此类技术的渲染框架中,这增加了额外的复杂性。
如果图形API能提供这种着色器代码模块化的核心功能,那就太方便了。然而悲伤的是,与CPU代码不同,GPU着色器并不允许在编译后再去链接代码片段,即每个着色器阶段的程序都会被编译成一个独立的单元。着色器阶段之间的分离确实提供了有限的模块化,这有点像上述清单中的第一项:将表面着色(通常在像素着色器中执行)与几何处理(通常在其他着色器阶段执行)相结合。但是这种类比是不完美的,因为每个着色器还会执行其他的操作,并且还需要处理其他类型的组合。考虑到这些限制的存在,材质系统能够实现上述这些组合类型的唯一方法,就是在源代码级别进行组合。这主要涉及字符串操作(例如连接和替换),这通常是通过类似于C语言风格的预处理指令来完成,例如 # i n c l u d e \mathsf{\#include} #include, # i f \mathsf{\#if} #if和 # d e f i n e \mathsf{\#define} #define。
早期的渲染系统包含相对较少的着色器变体,并且通过都是手动编写的。这样做有一定的好处,例如:可以在充分了解着色器程序的基础上去对每个变体都进行优化。但是随着变体数量的不断增加,这种方法很快就变得不切实际了。考虑到着色器中的所有不同部分和选项时,着色器变体的数量可能会十分巨大,这也是为什么模块化和可组合性是如此重要的原因。
当设计一个用于处理着色器变体的系统时,需要解决的第一个问题就是:不同选项之间的选择,是在运行时使用动态分支来完成的,还是在编译时通过条件预处理来完成。在一些较老的GPU硬件上,动态分支通常是不可能的,或者是非常低效的,因此在运行时进行选择是不可取的。因此所有变体都是在编译时进行处理的,包括不同光源类型的所有可能组合。
相比之下,当前的GPU硬件可以很好的支持动态分支,尤其是当该分支在一次draw call中对所有的像素做相同处理的时候。如今很多功能变体,例如光源数量,都是在运行时处理的。但是,向着色器中添加大量功能变体会带来另一种不同的开销:寄存器计数的增加,以及占用率的降低,从而导致性能下降。因此在编译阶段处理变体仍然是很有价值的,它能够避免包含那些从不被执行的复杂逻辑。
作为一个例子,想象一个支持三种不同类型光源的应用程序。其中有两个光源类型是很简单的:点光源和方向光。第三种光源则是一个广义的聚光灯,它支持表格照明模式以及其他复杂的特性,这需要使用大量的着色器代码来实现。但是这种广义的聚光灯在应用程序中使用的很少,只有大概不到5%。在过去,为了避免动态分支,会为三种光源类型的(可能出现的)每种计数组合都生成一个独立的着色器变体。尽管现在可能并不需要这种方式了,但是编译两个独立的变体仍然是很有用的,其中一个用于聚光灯数量大于等于1的情况,另一种用于聚光灯数量恰好为0的情况。第二种情况的着色器代码更加简单,也更加常用(对应95%的使用场景),其寄存器使用量也更低(意味着可以实现更高的占用率),因此具有更好的性能表现。
现代的材质系统会同时使用运行时着色器变体和编译时着色器变体。尽管现在并不只在编译阶段进行处理,但是总体的复杂性和变体数量仍在不断增加,因此仍然有大量的着色器变体需要进行编译。例如:在游戏《命运:邪神降临》(命运的一个大型DLC)的某些区域中,在一帧内使用了超过9000个预编译的着色器变体。可能存在的变体数量则更为巨大,例如:Unity 渲染系统中有着接近 1000 亿个可能的着色器变体。虽然只有那些实际用到的变体才会进行编译,但是必须要对着色器编译系统进行重新设计,才能处理数量如此巨大的潜在变体。
材质系统的设计者采用了不同的策略来解决这些设计目标,虽然这些策略有时候会表现出互斥的系统结构,但是这些策略确实可以被整合在同一个系统中。这些策略包含以下内容:
- 代码复用——在共享文件中实现可复用的函数,使用预处理指令 # i n c l u d e \mathsf{\#include} #include,可以在任何需要的着色器中访问这些函数。
- 做减法——如果一个着色器中聚合了大量的功能特性,它通常会被称作为“超级着色器(ubershader or supershader)”,灵活使用编译时的预处理指令与动态分支的组合,来移除那些无用的部分,并在互斥的备选方案中进行切换。
- 做加法——将各种单位功能定义成具有输入输出连接器的节点,这些功能节点可以被组合在一起。这有点类似于代码复用策略,但是更加结构化。这些节点的组合可以通过使用文本或者一个可视化编辑器来完成,后者旨在让非工程师的成员(例如技术美术)更易于编写新的材质模板。但是这种可视化方案也存在一些缺陷,即只能访问部分的着色器,例如:虚幻引擎中的材质图形编辑器,只能作用于着色模型输入参数的相关计算,如图5.13所示。
- 基于模板——现在有一个定义好了的接口,只要符合这个接口的定义,那么就可以接入不同的实现。这要比加法策略更加正式一点,通常会用于更大的模块中。这种接口的一个常见例子是,将着色模型参数的计算与着色模型本身的计算分离开来,例如:虚幻引擎中有着不同的“材质域(material domain)”,它包含了用于计算着色模型参数的表面域(Surface domain),以及用于计算缩放系数(这个系数用于对一个给定光源的 c l i g h t c_{light} clight进行调整)的光照函数域(Light Function domain)。在Unity引擎中也存在一个类似的“表面着色器(surface shader)”结构。值得注意的是,延迟渲染技术会使用G-buffer来作为接口,强制要求执行一个类似的着色器结构(即统一的着色模型)。
图5.13 虚幻引擎的材质编辑器。请注意右侧最长的那个节点,该节点的输入连接器对应了渲染引擎所使用的各种着色输入,包括着色模型所需的所有参数。
对于更加具体的例子,《WebGL Insights》这本书中的一些章节,讨论了各种引擎是如何控制其着色器管线的。除了组合之外,现代的材质系统还有几个重要的设计考虑事项,例如:如何以最小的着色器代码重复来支持多个平台。这会产生一些额外的着色器变体,以解决平台、着色器语言以及API之间的性能差异和功能差异。《命运》的着色器系统是这类问题的一个代表性解决方案,它使用了一个专门的预处理层,能够使用自定义的着色器语言来进行着色器编写。这允许开发人员编写与平台无关的材质,并将其自动转换为不同的着色语言和着色实现。虚幻引擎和Unity引擎也都具有类似的系统。
材质系统还需要保证良好的性能,除了专门的着色器变体编译之外,材质系统还可以执行一些常见的其他优化。《命运》和虚幻引擎的着色器系统,会自动检测在一次draw call中结果为常数的计算(例如章节5示例程序中的暖色和冷色计算),并将其移动到着色器之外进行。另外一个例子是《命运》中所使用的作用域系统(scope system),它用来区分不同更新频率的常量(例如:每帧更新一次,每个光源更新一次,每个物体更新一次),并在适当的时候对每一组常量进行更新,从而减少API的开销。
正如所看到的,实现一个着色方程是很简单的,重要的是决定哪些部分是能够简化的,如何计算各种表达式的出现频率,以及用户如何能够对材质外观进行修改和控制。渲染管线最终输出的是一个颜色值和混合值,本章的剩余内容包含了有关抗锯齿、透明度以及图像显示的部分,这些内容会详细介绍如何对这些输出的值进行修改和组合,并最终进行显示。