《Real-Time Rendering》着色基础(一)

本章内容概览

  • 光照模型
  • 光源
  • 实现光照模型
  • 锯齿和抗锯齿
  • 透明,透明度和合成
  • 显示编码

当渲染三维物体时,模型不应该只有合适的几何外形,还要有所期望的颜色外表。取决于应用程序的使用,我们可以得到真实感渲染结果,也可以得到风格化渲染样式。下面是一些例子:



这一章会介绍真实感渲染和风格化渲染的所需要的着色概念。

光照模型

决定一个渲染物体的外表颜色的第一个步骤是选择一个光照模型来描述物体的颜色应该如何更根据表面朝向、观察方向和光源方向等参数来变化。

例如,我们使用Gooch光照模型,这是一个非真实感渲染的一个部分,Gooch光照模型被设计用来理论上提升光照细节的可分辨性。

Gooch光照模型的基础理念是将表面法线方向和光源的位置相比较,如果法线朝向光源,那么表面会使用暖色调,如果反向,就会使用冷色调,在两者之间的角度会在这些色调间插值,这基于用户提供的表面颜色。在这个例子中,我们给模型添加一个非真实感高光效果来给表面一种反光的效果,下图显示了使用这一光照模型的效果:



光照模型通常使用一些属性来控制外表的变化,设置这些属性的值就是决定物体外表的下一个步骤,我们的模型例子只有一个属性——表面颜色,在上图的下方材质球显示。

和大多数光照模型类似,这个光照模型例子由表面朝向、观察和光源方向影响。对于着色,这些方向经常表示为标准化矢量,这在下图显示:



现在我们定义所有的输入到我们的光照模型,下面是数学公式:

c_{shaded}=sc_{highlight}+(1-s)(tc_{warm}+(1-t)c_{cool})

上述等式中的中间计算如下:

c_{cool}=(0,0,0.55)+0.25c_{surface}

c_{warm}=(0.3,0.3,0)+0.25c_{surface}

c_{highlight}=(1,1,1)

t=\frac{(n \cdot 1) + 1} {2}

r=2(n-1)\cdot n - 1

s=(100(r\cdot v)-97)^{\mp}

在这一定义中的一些数学表达式也经常可以在一些其它的光照模型中发现。截取操作,通常截取到0以上或在0到11之间截取,这在光照模型中很常见,这里我们使用\mp来表示在0到1之间截取。点积操作出现了三次,每次出现在两个单位矢量间,这也是一个极其常见的操作。点积的值是两个矢量的长度积然后乘以夹角的余弦,所以,两个单位矢量的点积就是两个矢量间的夹角的余弦,这对于衡量两个矢量的夹角来说很有用。包含余弦的函数经常用在光照模型中,这对于衡量两个方向的关系来说很准确也很简洁,例如,光源方向和表面法线。

另一个通用的着色操作是基于一个0到1之间的标量值来在两个颜色值之间进行线性插值,这一操作的形式如tc_a+(1-t)c_b,它使用位于0到1之间的t值来对c_ac_b进行线性插值。这一操作在这一光照模型中出现了两次,首先在c_{cool}c_{warm}之间插值,然后是在高光c_{highlight}和上面的结果颜色之间进行插值。线性插值操作在着色器中经常出现,是一个内置的方法,称为lerpmix,在许多着色器语言中都有出现。

"r=2(n\cdot 1)n-1"这一行计算了反射光矢量,将l通过n反射,这一操作不像之前两个操作那样普遍使用,但在某些情况下使用挺普遍的,作为内置函数,一般称为reflect

通过使用不同的数学表达式和着色参数和不同的方式来结合这些操作,光照模型的定义的种类极其丰富。

光源

在我们的光照模型例子中,光照的影响很简单,它对着色提供了一个单一的方向,当然,在真实世界中,光照可以非常复杂。在场景中可以有多个光源,每一个光源有自己的大小、形状、颜色和强度,非直射光还有更多的变化。我们之后介绍的PBR的真实感光照模型就不要考虑更多的参数。

相反的,非真实感光照模型就会用许多不同的方式使用光照,取决于应用和视觉风格的选择。一些高度非真实感模型可能完全没有光照的概念,或者只提供一些简单的方向光源。

光照复杂性的下一步骤是光照模型的二元性,可能接受光照或不接受光照。一个表面使用这一模型进行光照的话,接受光照的部分有一种颜色,不接受光照的部分会有不同的颜色。这暗示了一些对于区分这两种情况的标准:离光源的距离,阴影,表面是否朝向光源,或者这些因数的结合。

从有无光照的二元性到光照强度的连续缩放是一个小的步骤,这可以通过无光照和完全光照进行简单的插值得到,这暗示了光照强度的一个边界范围,如0-1,或者其它的非边界量方式,后者使用的而一种通用的方式是将光照模型分为光照和非光照部分,使用一个因数k_{light}线性缩放光照部分:

c_{shaded}=f_{unlit}(n,v)+k_{light}f_{lit}(l,n,v)

这一公式可以很简单的扩展为RGB单光源:

c_{shaded}=f_{unlit}(n,v)+c_{light}f_{lit}(l,n,v)

以及多光源:

c_{shaded}=f_{unlit}(n,v)+\sum^n_{i=1}c_{light_i}f_{lit}(l_i,n,v)

非光照部分与二元性光照模型的非光照外表部分绑定,可以有多种形式,取决于所希望的视觉效果和应用程序的需要,例如,f_{unlit}()=(0,0,0)会让不被光照影响的部分显示为纯白,相反地,非光照部分可以将不被光照影响的地方表示为一些非真实感结果,和Gooch光照模型类似。通常,光照模型的这部分并不是直接受某一确切光源的直射光的影响,如天空或散射过的光。

我们之前提到过,如果光源方向和法线夹角超过90°角,那么这个光源不会影响表面的顶点,实际上产生影响的是来自表面以下的光。这一点可以被考虑为一种光源方向和表面关系的特殊情况,即使是基于物理的渲染,这个关系也可以由简单的几何规则得到,对于许多不同种的非PBR和非真实感渲染都适用。

一束光源在表面的效果可以被可视化为一系列光线,碰撞到表面的光线密度与计算光照模型的光强挂钩。下图显示了这一过程的横截面:


沿横截面要碰撞到表面的光线间的间距与ln间的夹角的余弦成反比,这样的话,可以得出全部碰撞到表面的光线的密度和ln间的夹角的余弦成正比,而这个余弦为两个单位矢量的点乘。注意这里为了方便,将光源方向定义为了反方向。

更准确地来说,光线密度和正的点积成正比,点积为负相当于光线从表面背面射入,不产生影响。所以,在使用点积进行多光源着色前,需要先截断点积值大于0,这里使用x^+标记,即让小于0的点积值赋为0:

c_{shaded}=f_{unlit}(n,v)+\sum^n_{i=1}(l_i\cdot n) ^+c_{light_i}f_{lit}(l_i,n,v)

这一光照模型适用于PBR,对于非真实感渲染也有利,因为它能帮助确定光照的完整连续性,特别是不在光照范围和阴影内的表面。然而,对于这一规则,有部分光照模型并不适用,这样的光照模型会适用未截断至0的版本的等式。

对于f_{lit}(),最简单的方式是使用一个常量颜色:

f_{lit}=c_{surface}

这样会得到光照模型:

c_{shaded}=f_{unlit}(n,v)+\sum^n_{i=1}(l_i\cdot n) ^+c_{light_i}c_{surface}

这一模型的光照部分与兰伯特光照模型有关,由Johann Heinrich Lambert在1760年提出,这一模型在理想漫反射光照表面的背景下适用,即表面是完全粗糙的。我们这里使用的是简化版本,之后讲PBR的时候会详细讲述。兰伯特模型自己本身可以用来简单着色,也是许多光照模型的重要构成部分。

从上面我们推导的一些光照模型等式可以看处,一个光源通过两个参数影响光照模型:一个由片段指向光源的方向矢量l,和一个光源颜色c_{light}。存在许多不同类型的光源,虽然会由许多不同,但这两个参数是最主要的。

接下来我们会讨论一些比较流行的光源,它们的共同点在于:在一个给定的表面位置上,每个光源从一个特定方向l照亮表面,换句话说,光源从被照亮区域的角度观察,就是一个无线小的点。这对于真实世界的光源来说并不严谨,这只是一个令人信服的近似结果。

平行光

平行光是光源中最简单的模型,场景中的lc_{light}都是常量,其中c_{light}可能会因为阴影而衰减。平行光没有位置,当然,特定的光源在空间中是由位置的。平行光是一种抽象光,当距离光源的距离相对于场景的尺寸很大时,效果不错。例如,一个小的桌面立体模型被20英尺外的照明灯照亮,这相当于一个平行光源的效果。另一个例子是太阳光照亮场景,除非是考虑太阳系中天体之间的光照。

平行光的概念有时可以扩展为方向l保持不变,同时颜色c_{light}不停变化,这往往在特定的场景限制使用,例如,可以使用两个嵌套的立方体定义一个区域,在外围的立方体外的c_{light}=(0,0,0),内部立方体等于某个常量,在这两个立方体间的区域使用线性插值得到。

精确光源

精确光源拥有一个位置,这不同于平行光。这样的光源同样没有维度,没有形状和大小,这不同于现实生活中的光源。我们使用术语“精确”(punctual),这一单词来自拉丁语puntus,意思是点,这用来表示来自一个单一位置的光照。我们使用术语点光源来代表一类特定的发光物,其向所有方向发射光线,因此点光源和聚光灯是两种不同类型的精确光源。光源方向l取决于光源位置p_{light}和要照亮平面点的位置p_0:

l = \frac{p_{light}-p_0} {||p_{light}-p_0||}

上面的等式是矢量标准化的一个例子:将矢量除以它的模来得到同方向单位矢量,这个等式在许多着色器语言中都有内建的方法。然而,有时需要来自等式的中间结果,需要使用多个步骤来精确的得到标准结果。将这一观点用于精确光源方向等式得到:

d = p_{light}-p_0,\\r=\sqrt{d\cdot d},\\l=\frac{d} {r}

d是指向光源的光照方向,r通过数学定义得到矢量模。

点光源/泛光灯

统一向不同方向发光的精确发光源被称为点光源或泛光灯。对于点光源,c_{light}与距离r呈函数关系,这与衰减有关。下图显示了衰减产生的原因:


在给定的平面上,来自点光源的光线间的间距与光源到平面的距离呈正比,且光线间的间距会不停增长,这样的话光线的密度相当于降低了,并与\frac{1} {r^2}呈正比,然后我们可以使用一个常量c_{light_0}来定义随空间变化的c_{light},它定义了距离为r_0处的颜色值:

c_{light}(r) = c_{light_0}\left(\frac{r_0} {r}\right)^2,\quad 5.11

上面的等式常指代平方反比灯光衰减。虽然理论上这一正确的光源衰减并不等于实际,往往存在一些问题。

首先的问题出现在小距离处,如果距离r的值接近0,那么c_{light}的值会接近无穷大,而且如果r的值为0,那么就会遇到除以0的错误。为解决这一问题,我们可以在分母加上一个小的值来进行保护:

c_{light}(r)=c_{light_0}\frac{r^2_0} {r^2+ \epsilon},\quad 5.12

\epsilon的值取决于实际应用程序。比如,虚幻引擎使用的是\epsilon = 1cm

另一种解决方式在尖叫引擎和寒霜引擎中使用,即使用一个截断操作,让分母大于0:

c_{light}(r) = c_{light_0}\left(\frac{r_0} {max(r,r_{min})}\right)^2,\quad 5.13

这种方法不同于第一种方法,它有自己的物理解释,r_{min}即发光物自己的半径,r的值小于r_{min}表明光照着色平面位于光源内部,但这种情况是不可能的。

相反地,第二个问题出在大距离处,这一问题与视觉效果无关,主要是性能方面。尽管光源的光会随距离不断衰减,但它不会等于0,只会无限接近零,为了渲染效率,我们可以在某一距离时让光衰减为0,为实现这一目的有许多方法存在。理论上来说,这一瞬间的改变需要尽可能的不起眼,为避免锐利的边缘剪切,最好让上述函数的导数和值在同一距离处均为0。一种解决方法是将平方反比等式乘以一个窗口函数,下面是虚幻和寒霜引擎使用的方法:

f_{win}(r)=\left(1-\left(\frac{r} {r_{max}}\right)^4\right)^{+2},\quad 5.14

+2表明在平方运算前先将值截断至0。下面的曲线图显示了平方反比函数、窗口函数和两者相乘的结果:

应用程序的需求会影响方法的选择,例如,当距离衰减方法的空间采样频率低时(例如光照贴图或顶点光照),让在r_{max}处的导数等于0是很重要的。尖叫引擎不使用光照贴图会顶点光照,所以它使用一个更简单的调整,在0.8r_{max}r_{max}间线性衰减。

对于一些应用程序,匹配平方反比曲线并不是必须的,因此一些其它的方法会被使用,5.11-5.14等式可以被简写为:

c_{light}(r) = c_{light_0}f_{dist}(r)

其中f_{dist}()是一些距离函数,这些函数被称为距离衰减函数。在一些情况下,使用非平方反比衰减函数是出于性能的限制,例如,游戏《正当防卫2》需要的光源并不需要多么昂贵的计算。这表明了一个简单的衰减函数,为避免逐顶点光照的粗糙性来说足够了:

f_{dist}(r)=\left(1-\left(\frac{r} {r_{max}}\right)^2\right)^{+2}

在其它情况下,衰减函数的选择会受可创造性考虑的影响,例如,虚幻引擎,真实感和非真实感渲染有两种衰减模式:平方反比模式和指数衰减模式,前者已经介绍过了,后者可以用来拉伸创建不同的衰减曲线。《古墓丽影》的开发者使用样条线编辑器来控制衰减曲线,允许许多曲线控制操作。

聚光灯

不同于点光源,除了随距离衰减光照外,还可以沿某一特定方向进行衰减,使用函数f_{dir}(l),我们将两者结合起来:

c_{light}=c_{light_0}f_{dist}(r)f_{dir}(l)

f_{dir}(l)的不同选择可以得到不同的光照效果,其中一种重要的效果是聚光灯,它将光照投影到一个圆锥里。一个聚光灯的方向性衰减函数绕方向s有旋转对称性,这一点可以用方向s和反光源方向-l的夹角{\theta}_s来表示。

大多数的聚光灯函数使用复合{\theta}_s的余弦值的表达式。聚光灯通常有一个本影角{\theta}_u,让所有{\theta}_s>{\theta}_uf_{dir}(l)=0。这个角度使用的最大距离衰减法则可以和点光源的最大距离衰减法则类似。对于聚光灯,通常还有一个半影角{\theta}_p,它定义一个内圆锥,在这之中的光强最大。这在下图显示:


聚光灯可以使用许多不同的衰减函数,但它们比较接近。例如,下面的f_{dir_F}(l)在寒霜引擎中使用,f_{dir_T}(l)在three.js网页图形库中使用:

t = (\frac{cos\theta_s-cos\theta_u} {cos\theta_p-cos\theta_u})^{\mp}

f_{dir_F}(l)=t^2

f_{dir_T}(l)=smoothstep(t) = t^2(3-2t)

\mp表示将值截断在0-1之间。平滑插值函数是一个三次多项,经常用于进行平滑插值。

下图显示了我们至今位置使用过的光源:


其它的精确光源

有许多不同的其它方式来让精确光源的c_{value}值变化。f_{dir}(l)并不限制在上述的聚光灯讨论中,它可以表示许多不同类型的方向变化,包括从真实世界光源测量到的列表模块。美国照明工程学会(IES)对这些测量指定了一个标准的文件格式,IES文件可以从许多光源制造商那里得到,曾经用于游戏《杀戮地带:暗影坠落》,同时虚幻和寒霜引擎等也使用了。

《古墓丽影》游戏有一种精确光源类型,它沿x、y、z轴使用不同的衰减函数。在《古墓丽影》中,曲线可以用来随时间改变光强,例如,可以用来制作闪烁的火把。

其它光源类型

平行光和精确光源,它们是通过光源方向计算方式主导的。不同的光源类型可以通过使用其它的计算光源方向的方法来定义,例如,《古墓丽影》有一种胶囊体光源,使用线段来代替点作为光源,对于每个着色像素,其与自身到距离线段上最近点的方向用作光源方向。

只要着色器使用lc_{light}值来估计光照等式,任何方法都可用来计算这些值。

至今为止讨论的光源都是抽象的,显示生活中,光源有大小和形状,并在不同的方向上照明表面上的点。在渲染中,这样的光源被称为区域光,它们在实时渲染中的使用频率正稳定上升。区域光渲染的技术有两类:模拟来自区域光部分遮挡结果的平滑阴影的边,模拟在平面上进行区域光光照。第二类光照常见于那些可以识别反射方向上的形状和大小的平滑镜面。平行光和精确光源并没有不再使用,只是没有以前那么普遍了。对于一个光源的区域的模拟计算得到了发展,实现时花费的开销比较少,所以现在正广泛使用。GPU性能的提高也允许使用更多在以前看来开销大的技术。

实现光照模型

为了能够使用,这些光照和着色等式必须用代码来实现,这里我们讨论一些重要的设计和编写实现的思路。

估值频率

当设计一个着色实现时,需要按照它们的估值的频率进行分类计算。首先决定给定计算的结果是否在整个绘制命令中都是常量,在这种情况下,计算需要通过应用程序来执行,通常在CPU上,尽管GPU的计算着色器也可以用来进行某些特定的计算。结果通过统一着色器输入传递至图形API。

即使是在这一分类下,仍存在一个广范围的可能的估值频率。最简单的相关情况就是一个着色等式中的次级表达式,可以赋予基于很少的变化参数的计算,例如硬件配置和安装选项。这样的着色计算可能会在着色器编译后进行,这种情况下并不需要设置统一着色器输入。相反地,计算可能会在一个预计算的离线过程中进行,在安装期间或应用程序加载期间。

另一种情况是当着色计算的结果随应用程序改变时,但改变很慢,并不需要每帧都去更新。例如,基于真实时间的光照因数改变。如果计算开销很大,最好多帧分摊计算。

另一个包含每帧执行计算的例子,例如结合视图和透视矩阵,或者每个模型执行一次,例如更新取决于位置的光照因数,或者每个绘制命令执行一次,例如更新一个模型的每个材质的因数。通过估值的频率来组合统一着色器输入可以让应用程序更有效率,也可以通过减少常量更新来帮助CPU提升性能。

如果一个着色等式的结果因为一个绘制命令改变,它就不会通过一个统一着色器输入传递给着色器,反而,它必须通过一个可编程着色器阶段来计算,如果允许,通过变化着色器输入传递到其它的阶段。在理论上,着色计算可以在任何一个可编程阶段进行,每一个阶段都与一个不同的估值频率绑定:

  • 顶点着色器——每个预处理细分曲面顶点估值一次。
  • 外壳着色器——每个平面片估值一次。
  • 域着色器——每个后处理细分曲面顶点估值一次。
  • 几何着色器——每个基本体估值一次。
  • 像素着色器——每个像素估值一次。

实际中,大多数的着色计算每像素执行一次。在这些着色计算都通常实现在像素着色器中时,计算着色器的实现也普遍的增长起来。其它的一些阶段主要用于例如平移和变形的几何操作。为理解这一情况,我们可以比较逐顶点着色和逐像素着色估计。在较老的材料中,这通常会牵涉到高洛德光照模型和Phong光照模型,这些光照模型使用的等式和之前讲过的很像,但是修改了一些以便适应多光源情况。

下图展示了逐顶点和逐像素着色的区别:



对于龙这种点密集的模型,区别很小,但对于茶壶模型,顶点着色估计造成了一种棱角感高光的视觉错误,在两个三角形构成的平面,逐顶点着色也明显错误。造成问题的原因是光照等式的一部分,尤其是高光部分,它的值的分布在平面网格上是非线性的。这让它们非常不适合用在顶点着色器上,因为结果会沿着三角形进行线性插值,然后送进像素着色器。

原则上,其实可以只在像素着色器中计算高光部分,在顶点着色器中计算剩余的部分。这样结果通常不会有手工视觉感,理论上也可以节省计算开销。实际中,这样的混杂实现通常不是最理想的。光照模型的线性变化的部分趋向于使用最少的计算开销,趋向于增加将这一部分剥离出来趋向于增加充足的开支,例如重复的计算,额外的变化输入,这反而没什么好处。

就像我们之前提到过的那样,在大多数实现中,顶点着色器负责那些非着色计算,例如几何变换和变形,作为结果的几何表面属性,会被转换到一个合适的坐标系中,由顶点着色器输出,沿着三角形线性插值后传入像素着色器作为变化着色器输入。这些属性通常包含表面位置,表面法线,有时还包括表面切线。

注意,即使顶点着色器总是生成单位长度的表面法线,插值也可以改变其长度。见下图左侧:


因此,在像素着色器中也需要对法线进行重新标准化。然而,顶点着色器生成的法线的长度仍然有问题,如果法线长度在顶点间变化明显,例如,顶点混合的副作用,这会让插值的结果方向歪斜,这在上图右侧显示了。由于这两种效果,实现通常会在插值前和后对矢量都进行标准化,同时在顶点和像素着色器中。

不同于表面法线,指向特定位置的矢量,如观察矢量和精确光源的方向,通常不会进行插值,反而,表面位置的插值会用来在像素着色器中计算上面这两个矢量。除了标准化,我们至今所看到的,需要在像素着色器中执行的,每个矢量都通过矢量减法得到。如果对于某些理由,需要对这些矢量进行插值的话,就不要先手动进行标准化的,这会造成类似于下面的问题:


之前我们提到过顶点着色器会将表面几何体变换到一个合适的坐标系,通过统一变换传入到像素着色器的摄像机和光源位置,就是通过应用程序变换到相同坐标系的。但什么算是合适的坐标系,所有的可能包括全局世界坐标系,摄像机的局部坐标系或当前渲染模型的坐标系。这一选择通常对于渲染系统来说是完整的,基于对例如性能、变换性和简单性等系统层面的考量。例如,如果一个要渲染场景包含大量的光源,世界空间可能会避免转换到灯光空间,相反,摄像机空间反而更合适,可以基于观察矢对像素着色器操作进行优化,也可能提高精度。

虽然大多数着色器实现,包括我们接下来要讨论的实现例子,基本都遵循我们所讲述过的框架,但还是有一些例外的。例如,一些应用程序选择面方式的逐基本体着色估计,出于实现风格化渲染的原因。这一风格通常称为扁平化渲染,下图显示了两个例子:



原则上,扁平化渲染可以在几何着色器中完成,但最近的相关实现通常使用顶点着色器,这通过将基本体属性绑定在第一个顶点上,并禁止顶点值插值来完成。进行插值让第一个顶点的值传递到基本体的所有像素上。

实现例子

接下来我们展示一个光照模型实现的实例。在之前提到过的,我们要实现的光照模型和扩展Gooch光照模型很像,但修改了一些来适应多光源情况:

c_{shaded}=\frac{1} {2}c_{cool}+\sum^n_{i=1}(l_i \cdot n)^+c_{light_i}(s_ic_{highlight}+(1-s_i)c_{warm})

使用的中间等式:
c_{cool}=(0,0,0.55)+0.25c_{surface}

c_{warm}=(0.3,0.3,0)+0.25c_{surface}

c_{highlight}=(2,2,2)

r=2(n-l_i)n - l_i

s=(100(r_i\cdot v)-97)^{\mp}

这一等式符合下面提到过的多光源架构:

c_{shaded}=f_{unlit}(n,v)+\sum^n_{i=1}(l_i\cdot n)^+c_{light_i}f_{lit}(l_i,n,v)

光照和非光照部分:

f_{unlit}(n,v)=\frac{1} {2}c_{cool}

f_{lit}(l_I,n,v)=s_ic_{highlight}+(1-s_I)c_{warm}

在大多数渲染应用程序中,例如c_{surface}的材质变化变量可以存储在顶点数据中,或者存储在纹理中。然而,为了让这个实现例子变得比较简单,我们假设c_{surface}永远是常量。

这个实现会对所有的光源使用着色器的动态分支循环,不过这个方法也只对这个简单案例有用,在复杂案例上,如光源很多的场景,就不那么适用了。为了便捷,这里我们也只涉及点光源。

着色器模型并不是独立实现的,而是一个更大渲染框架的一部分。这个例子根据一个简单的WebGL2程序的Phong模型改编而来,基本的规则是相同的。

我们会讨论一些GLSL代码的案例,并通过JavaScript WebGL调用。这里并不会教授WebGL的特定用法,只是展示一般的实现规则。我们会从里到外进行实现,从一个像素着色器开始,然后是顶点着色器,最后进行应用程序内部的API调用。

在着色器正式的代码开始前,着色器的资源包括着色器输入和输出的定义。之前介绍过,GLSL术语中,着色器的输入被分为两类,第一类是统一输入,它通过应用程序设置,并在整个绘制命令中保持为常量,第二类是变化输入,它的值会在着色器调用间变化。这里我们首先定义像素着色器的输入,GLSL使用in进行标识,同时使用out进行输出标识:

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

这个像素着色器只有一个简单的输出,即最终的颜色。像素着色器的输入匹配顶点着色器的输出,它通过沿整个三角形插值得到。这个像素着色器有两个变化输入:表面位置和表面法线,两者都在应用程序的世界坐标系中。统一输入的数量要更多一些,所以为了简洁,我们只展示其中两个与光源相关的定义:

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

由于光源是点光源,所以光源的定义包括位置和颜色,它们被定义为vec4是为了符合GLSL的std140数据布局标准。尽管,在这个例子中,std140布局会导致空间的浪费,但它简化了在CPU和GPU间确定连续数据布局的任务,这可以让我们很简单地使用。Light结构体数组被定义在一个统一块中,这是GLSL的一个特性,用来绑定一组统一变量到一个缓冲对象上,这样就可以更快地传输数据。数组长度被定义为一个应用程序允许的最大光源数,我们之后会看到应用程序会在着色器编译前将这个字符串替换为数字,也就是预处理命令。统一整型变量uLightCount是绘制命令中要使用的确定数量光源。

接下来,我们看一下像素着色器的代码:

vec3 lit(vec3 l, vec3 n, vec3 v){
        vec3 r_l = reflect(-l, n);
        float s = clamp(100.0 * dot(r_l, v) - 97.0, 0.0, 1.0);
        vec3 highlightColor = vec3(2, 2, 2);
        return mix(uWarmColor, hightlightColor, 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 = nomalize(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函数调用。注意我们使用的光照模型完全是上面提到过的。其中的f_{unlit}()c_{warm}作为统一值传入,它们是整个绘制命令中的常量,应用程序可以计算这些值,节省一些GPU周期。

这个像素着色器使用一些内置的GLSL函数,refelct()函数可以用来反射一个矢量,clamp()函数有三个参数,可以将第一个参数的值阶段在后面两个参数设置的范围内,对应HLSL方法是saturate()。mix()函数是一个线性插值函数,拥有三个参数,可以让前两个值根据第三个值设置的比例进行线性插值,对应HLSL方法是lerp()。最后,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了,并且应用程序并没有执行例如顶点混合或非统一缩放的额外的操作,这些操作会不均衡地改变它们的长度。模型矩阵可以有一个统一缩放因数,但这会成比例的改变所有法线的长度。

应用程序使用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);

注意fragmentShader的引用,这是WebGL的术语,和pixelShader一样,不过前者在原理上更为正确,但我们在本书还是使用后者作为常用术语。这个代码中也将MAXLIGHTS替换为常量值,大多数的渲染框架执行类似的预处理着色器操作。

这里介绍的代码并不完整,还存在许多例如设置统一变量、初始化顶点数组、清除缓冲、绘制等操作,这些可以在API文档中看到。我们这里的目的只是给出着色器是如何被对待为一些独立的处理器的这一概念。

材质系统

渲染框架通常不会只实现一个着色器,通常,目标系统需要处理大量不同的材质,光照模型和着色器。

在之前的章节提到过,一个着色器是一个GPU可编程阶段的一个程序,就其本身而言,它是一个低等的图形API资源,对艺术家来说并不友好,不能直接操作。相反,一个材质是一个表面外表的面向艺术家的封装结果,材质有时也可以描述为非视觉慨念,例如碰撞属性,这不在本书的范围内。

因为材质实现自着色器,它们并不是一个简单的一对一的关系。在不同的渲染场景中,同样的材质可能使用相同的着色器,一个着色器可以被多个材质共享。最常见的例子是参数化材质,其最简单的组成,材质参数化需要两种材质实体:材质模板和材质实例。每个材质模板描述一类材质,并且拥有一系列可用于赋予数字,颜色或纹理值的参数,每个材质实例绑定一个材质模板加上一组它的参数。一些渲染框架,例如虚幻引擎,允许一个更复杂的层阶式架构,材质模板可以来自不同等级的其它模板。

参数可能在运行时处理,通过将统一输入传递给着色器程序,或在编译阶段,通过在着色器编译前替换值。一种编译阶段参数的通用类型时使用布尔值来切换给定材质特性的激活状态,这可以让艺术家通过复选框在材质系统中实现,例如关闭在远处物体的某些可以忽略的视觉效果。

因为材质的参数可能与光照模型的参数一对一绑定,这通常不是一个问题。一个材质可能会固定一个给定的光照模型的参数,例如将表面颜色变为一个常量值。相反,一个光照模型的参数可能是使用多种材质参数、插值点或纹理数据等经过一系列复杂操作计算出来的结果。在一些例子中,例如表面位置、表面朝向甚至是事件都可能作为因数加入计算。基于表面位置和朝向的光照模型在地形材质中非常普遍,例如,高度和表面法线可以被用来控制雪效果,在高海拔水平处和接近水平表面处混合进一个白色表面颜色。基于时间的材质通常用于动画材质,例如闪烁的霓虹标志。

材质系统其中一个最重要的任务是将不同的着色器函数分类为独立的元素,然后控制它们的结合方式。在许多情况下,这种类型的合成非常有用:

  • 使用几何处理过程来合成表面着色,例如刚体变换、顶点混合、变形、细分曲面、实例化和裁剪。这些功能独立分布:表面着色器取决于材质,几何处理过程取决于网格,所以分别控制它们并在材质系统中合成会非常方便。
  • 通过一些例如像素裁剪和混合操作来混合表面着色。这与移动GPU的关联性很大,其中的混合通常在像素着色器中执行。通常是自由选择这些材质操作用于表面着色器。
  • 合成操作过去常用于让光照模型自己计算光照模型的参数。这允许只控制一次光照模型的实现,就可以使用针对所计算的光照模型参数的不同方法来重复进行结合。
  • 将每个独立可选择的材质特性合成在一起,这允许每种特性独立的进行实现。
  • 将光照模型和所计算的光源估计的参数进行合成:在着色点对每个光源计算c_{light}l的值。例如延后渲染的技术可以改变这种合成结构。

如果图形API提供这种类型的着色器代码模块性作为一种核心特性会很方便。然而,不同于CPU代码,GPU着色器不允许对代码片段进行后置编译连接,每个着色器阶段的程序会被编译为一个单元。着色器阶段间的分离提供一些模块性限制,这会有些契合我们在上面提到过的第一个元素:使用几何处理过程(在非像素着色器阶段进行)来合成表面着色(在像素着色器阶段进行)。但这并不是完美契合,因为每个着色器也会执行其它的操作,其它类型的合成仍需要被处理。给定这些限制,能够让材质系统实现所有类型合成的唯一方法在源代码级别处,这个方法主要包括一些字符串操作,例如几何和替换,通常根据一些C风格的指令,#include,#if,#define完成。

早期,渲染系统有一个相当少数量的着色器变体,通常每个着色器可以被手工重写,这种方式有一些优点,例如,每中变体可以通过最终着色器程序的完整知识来优化。然而,这种方式会因为变体数量的增长而变得不切实际,当将不同的部分和选项考虑在内的话,不同可能存在的变体的数量会非常大。这就是为什么模块性和可结合性非常重要的原因。

当设计一个处理着色器变体的系统时第一个需要解决的问题是在不同选项间的选择是通过动态分支在运行期间执行还是通过条件预处理在编译期间执行。在较老的硬件中,动态分支不太可能实现,或者非常慢,所以运行期间的选择并不能作为一个选项。因此变体总是在编译期间处理,包括不同数量和类型灯光的所有可能结合。

相反,当前的GPU可以很好地处理动态分支,尤其是在一个绘制命令时对所有的像素,分支的行为一致。如今大多数的功能性变体,例如光源数量,通常在运行期间处理。然而,向一个着色器中添加大量的功能性变体会造成不同的开销:寄存器数量的增加和相应占用量的减少,因此性能会降低。所以,编译期间的变体仍是有价值的,它避免包含了那些不会被执行的复杂逻辑。

举个例子,想象一下一个应用程序支持三种不同的光源,其中两种光源类型很简单:点光源和平行光,第三个类型的光源是普遍的聚光灯,支持平面光照和其它复杂的特性,需要相当数量的着色器代码去实现。然而,普遍的聚光灯在应用程序中的使用相对较少。在过去,一个独立的着色器变体会对这三种光源的不同排列组合进行编译,其中一种情况是普遍聚光灯的数量大于或等于1,另一种情况是聚光灯的数量为0。由于它的更简单的代码,第二种变体更倾向于使用更少的寄存器,因此性能更好。

现代材质系统同时使用运行期间和编译期间着色器变体。即使任务并不仅仅在编译期间完成,整体的复杂性和变体的数量会保持增长,所以仍有大量的着色器变体需要编译。例如游戏《命运:被夺走的国王》中的部分领域,超过9000个编译变体会在单帧使用。变体可能的数量也会非常大,例如,Unity渲染系统的每个着色器由超过数千亿的变体。只有实际使用的变体才会被编译,但着色器编译系统需要进行重新设计来处理大量可能的变体。

材质系统设计者使用不同的策略来实现这些目的。虽然这些策略常表现为相互排斥的系统架构,但仍可以在一个系统中结合在一起,这些策略包括:

  • 代码重用——在分享文件中实现函数,使用#include预处理命令来从任何需要的着色器中定位函数。
  • 相减——一种着色器,经常称为超级着色器,集合了大量的功能,使用了编辑期间预处理器条件语句和动态分支的组合来移除不使用的部分,并在互相排斥的替换品间切换。
  • 相加——部分不同的功能被定义为拥有输入和输出连接的节点,然后将这些结合起来。这和代码重用结构类似,但更加的结构化。节点的结合可以通过文本或可视化图标编辑器完成,后者适合非工程师使用。通常只有着色器的一部分可以暴露在可视化图表种,例如,在虚幻引擎的图标编辑器中,只可以影响光照模型的输入。
  • 基于模板——一个界面被定义,只要符合界面的设计的话就可以插入不同的实现。这比相加策略更加的正式,通常用于更大块的功能。一个这种界面的通用的例子是将光照模型参数的计算和光照模型的计算本身分离。虚幻引擎拥有不同的材质域,包括计算光照模型参数的表面域和计算标量值的光源函数域。一种类似的表面着色器架构同样存在于Unity中。注意延迟渲染技术使用类似的架构,将G缓冲用作界面。

对于更多的特殊例子,《WebGL Insights》这本书的部分章节讨论了不同的引擎如何控制它们的着色器管线。除合成外,也存在一些其它对于现在材质系统的重要设计,例如使用最少复用代码来支持不同的平台的需求。这包括使用不同方法变体来对不同平台、着色器语言和API来解释性能。游戏《命运》的着色器系统是解决这类问题的代表方法。它使用一种专门的预处理器层,其可以使用自定义的着色器语言来编写着色器。这允许通过自动转换为不同的着色器语言和实现来适应不同的平台。虚幻和Unity拥有类似的系统。

材质系统同样需要确保性能,除了特殊化着色变体的编译,也存在一些其它的公用优化材质系统的方法。《命运》材质系统和虚幻引擎可以检测绘制命令中常量的计算,然后将它放在着色器外。另一个例子是在《命运》中使用的作用范围系统,用来区别不同更新频率的常量,并在合适的时间更新每个常量集来节省API的开支。

正如我们所看到的,实现一个着色等式要决定以下的问题:哪一部分应该要简化,计算不同等式的频率如何,用户要如何修改和控制表现。最终渲染管线的输出是颜色和混合值,剩下的小节会讲解抗锯齿、透明和图像显示。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容