0%

Real-Time Rendering 4th Edition学习笔记(四)

p.s. 啃书太累了,尤其是对于自己来说相对还算陌生领域的内容。本人在游戏行业待了10年,转研发也有一半的时间了,对于基础的3D知识也算了解,但啃完一章也得半个多月。原书中一章可能就短短50页的内容,但其中某句话中涉及的一个专业词汇就得花至少半天时间去查资料看视频才能了解,再加上我不想随便机翻一下英文就当读书笔记,想要把内容吃透后用自己的语言写下来,就更是难上加难了。难怪不是水货的TA壁垒这么高。

Chapter 5: 着色基础(Shading Basics)

本章将介绍普适于真实和风格化渲染的相关着色内容。第9-14章会着重介绍真实渲染的部分,第15章会介绍风格化渲染。

着色模型(Shading Models)

决定渲染模型外观的第一步是挑选一个shading model,来描述物体的颜色应该随着旋转、朝向、光照等因素如何变化。

我们以古奇着色模型(Gooch shading model)的一个变体来举例。它属于15章中要介绍的非真实渲染的一种,旨在提高技术制图细节的易读性。

Gooch shading的基本思路是比较表面法线和光照位置。用户预定义一个表面颜色,物体表面法线越朝向光照(高光区域)则使用更暖的色调;反之(阴影区域)则使用更冷的色调。其他区域的冷暖色调根据表面角度进行插值。这样,就给物体添加了一种风格化的高光效果,突出了物体表面。

Gooch shading例子。用户只需要定义表面颜色,整个物体的颜色只使用这个颜色进行冷暖变化。

Shading model通常都有一些属性值来控制物体外观的变化。上述例子中,只有物体表面颜色这一个属性值。

和大多数shading models一样,上述例子中,物体颜色受到表面朝向相对于观察方向和光照方向的影响。这些朝向通常用归一化(normalized)或单位长度(unit length)向量来表示:

定义好这几个方向后,我们就能用数学公式表示上述例子中的物体颜色:

cshaded=schighlight+(1s)(tcwarm+(1t)ccool)\mathbf{c}_{shaded}=s\mathbf{c}_{highlight}+(1-s)(t\mathbf{c}_{warm}+(1-t)\mathbf{c}_{cool})

其中:

ccool=(0,0,0.55)+0.25csurface,\mathbf{c}_{\text{cool}}=(0,0,0.55)+0.25\mathbf{c}_{\text{surface}},

cwarm=(0.3,0.3,0)+0.25csurface,\mathbf{c}_{\text{warm}}=(0.3,0.3,0)+0.25\mathbf{c}_{\text{surface}},

chighlight=(1,1,1),\mathbf{c}_{\text{highlight}}=(1,1,1),

t=(nl)+12,t=\frac{(\mathbf{n} \cdot \mathbf{l})+1}{2},

r=2(nl)nl,\mathbf{r}=2(\mathbf{n} \cdot \mathbf{l})\mathbf{n}-\mathbf{l},

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

我们使用xx^{\mp}来表示clamp(0,1)函数(小于0取0,大于1取1,0到1之间取原值)。向量点乘也是很常用的一个方法,含义是两个向量夹角的cos值,值越大则夹角越小。

还有一个常用的操作是在两个颜色之间根据介于0-1的参数线性插值,形式为tca+(1t)cbt\mathbf{c}_a+(1-t)\mathbf{c}_b。当t从0过度到1时,这个值从cb\mathbf{c}_b过度到ca\mathbf{c}_a。在上述例子中,插值出现了两次,第一次是在冷暖色调之间进行插值,第二次是将第一次的结果和高光进行插值。线性插值在shader中很常用,因此已经变成了一个内置函数,在不同shader语言中分别为lerp或mix。

r=2(nl)nl\mathbf{r}=2(\mathbf{n} \cdot \mathbf{l})\mathbf{n}-\mathbf{l}用来计算l相对n的反射光。这在shader中也是一个内置函数,reflect。

光源(Light Sources)

在现实中,光照很复杂,可能存在多个光源,每个都有不同的形状、大小、颜色和强度,如果考虑间接光源,就更复杂了。第9章中介绍的基于物理的真实渲染需要将所有这些参数考虑进来。

与此相反的是,风格化渲染就不需要考虑这么多因素。一些高度风格化的着色模型甚至完全没有光照概念,或者和Gooch shading一样只使用光照提供一些简单的方向性。

光照的复杂性还在于如何让shading model对有无光照做出反应。物体表面应该在有光照和没有光照影响下拥有不同的外观。有一些规则来区分这两种情况:与光源的距离、阴影、是否背光、或以上因素的组合。

我们可以很自然的从有无光照过度到连续的光照强度指数,比如对无光照到最强光照做一个简单的插值,范围可以是0到1,也可以自定义一个范围。一个常用方法是给光照部分和无光照部分分别加一个参数,用光照强度klightk_{\text{light}}作为光照部分的参数:

cshaded=funlit(n,v)+klightflit(l,n,v)\mathbf{c}_{\text{shaded}}=f_{\text{unlit}}(\mathbf{n},\mathbf{v})+k_{\text{light}}f_{\text{lit}}(\mathbf{l},\mathbf{n},\mathbf{v})

如果是一个RGB光源,则可以扩展为:

cshaded=funlit(n,v)+clightflit(l,n,v)\mathbf{c}_{\text{shaded}}=f_{\text{unlit}}(\mathbf{n},\mathbf{v})+\mathbf{c}_{\text{light}}f_{\text{lit}}(\mathbf{l},\mathbf{n},\mathbf{v})

进一步扩展为多个光源:

cshaded=funlit(n,v)+n=1iclightiflit(li,n,v)\mathbf{c}_{\text{shaded}}=f_{\text{unlit}}(\mathbf{n},\mathbf{v})+\sum_{n=1}^{i}\mathbf{c}_{\text{light}_{i}}f_{\text{lit}}(\mathbf{l}_i,\mathbf{n},\mathbf{v})

其中,funlit(n,v)f_{\text{unlit}}(\mathbf{n},\mathbf{v})对应不受光照的部分,根据想要的风格它可以有不同形式,比如funlit()=(0,0,0)f_{\text{unlit}}()=(0,0,0)可以使所有不受光照的表面颜色显示为纯黑色。当然,也可以做一些风格化的效果,比如和Gooch shading类似的冷色调效果。这一部分通常用来表现非直射光的效果,比如天光或来自周围物体的反射光,这些会在第10、11章讲到。

照在物体表面的光线可以用一组射线来表示,射线的密度对应光照强度,如下图所示:

可以看到,射线在物体表面上的间距和ln之间的夹角的cos值成反比,也就是说,到达物体表面的光线强度和ln之间的夹角的cos值成正比。而ln之间的夹角的cos值就是ln的点乘。之所以把光照向量l定义为和光线实际传播方向相反,就是为了防止每次在计算点乘后都要再取负值。

更准确来说,当ln的点乘大于0时,光线在物体表面的分布强度和点乘成正比。当点乘小于0时,对应着光照来自物体表面的背部(内部),不产生影响。因此,在计算时,我们需要在乘以点乘之前先将小于0的点乘clamp到0:

cshaded=funlit(n,v)+n=1i(lin)+clightiflit(li,n,v)\mathbf{c}_{\text{shaded}}=f_{\text{unlit}}(\mathbf{n},\mathbf{v})+\sum_{n=1}^{i}(\mathbf{l}_i \cdot \mathbf{n})^{+}\mathbf{c}_{\text{light}_{i}}f_{\text{lit}}(\mathbf{l}_i,\mathbf{n},\mathbf{v})

支持多光源的着色模型通常会使用上一个公式的结构;如果是基于物理的模型,则必须使用这一个公式结构。风格化着色模型也能使用这个公式,因为它能确保光照的整体一致性,尤其是对于背光或阴影的表面。但是一些着色模型确实不适合使用这个公式,那就只能使用上面一个公式。

flit()f_{\text{lit}}()最简单的实现方式是使用固定颜色:

flit()=csurfacef_{\text{lit}}()=\mathbf{c}_{\text{surface}}

这样,上面的公式就变成了:

cshaded=funlit(n,v)+n=1i(lin)+clighticsurface\mathbf{c}_{\text{shaded}}=f_{\text{unlit}}(\mathbf{n},\mathbf{v})+\sum_{n=1}^{i}(\mathbf{l}_i \cdot \mathbf{n})^{+}\mathbf{c}_{\text{light}_{i}}\mathbf{c}_{\text{surface}}

此处的光照部分对应的就是Lambertian shading mode。这个模型适用于理想的漫反射表面,即物体表面是完全哑光的/非镜面的。

从上面的几个公式可以看出,光源通过两个参数与着色模型产生交互:指向光源的向量l,和光源颜色clight\mathbf{c}_{\text{light}}。光源有很多种类型,它们的不同点主要就在这两个参数上。

接下来,我们会讨论几个常见的光源类型。它们都有一个共同点:对于任意一个物体表面位置,同一个光源只会来自一个方向l。换句话说,从物体表面来看,光源是一个无穷小的点。这仅仅是一个假设,因为相对于光源到物体的距离,光源的大小通常很小。在以后的章节我们还会介绍区域光。

直射光(Directional Lights)

直射光是最简单的光源模型,它的l和光源颜色clight\mathbf{c}_{\text{light}}在整个场景中都是固定值(除了clight\mathbf{c}_{\text{light}}会被阴影减弱)。直射光是个抽象的概念,它在虚拟场景中并没有具体所在的位置,它到物体的距离远大于场景大小。比如,距离桌面西洋镜20英尺远的一个泛光灯可以被抽象为一个直射光源。再比如,几乎所有场景中的太阳光都可以看作直射光,除非这个场景描述的是太阳系内部的行星。

在一些使用场景中,也不一定让clight\mathbf{c}_{\text{light}}保持恒定值。比如,可以让其在某个点的颜色为(0,0,0),在另一个点的颜色为某个值,它们中间的颜色则为两个颜色的插值。

精确光源(Punctual Lights)

相比于直射光,精确光源是指有具体位置的光源,它分为点光源(point light)和聚光灯(spotlight)。光照方向l取决于物体表面的位置p0\mathbf{p}_{0}和光源位置plight\mathbf{p}_{\text{light}}

l=plightp0plightp0\mathbf{l}=\frac{\mathbf{p}_{\text{light}}-\mathbf{p}_0}{\|\mathbf{p}_{\text{light}}-\mathbf{p}_0 \|}

即两个点之间的归一化向量。当然,也可以用另一种方法表示:

d=plightp0\mathbf{d}=\mathbf{p}_{\text{light}}-\mathbf{p}_0

r=ddr=\sqrt{\mathbf{d} \cdot \mathbf{d}}

l=dr\mathbf{l}=\frac{\mathbf{d}}{r}

其中的r除了用来计算l外,还能用来计算光照强度随距离变远而减弱的程度。

点光源(Point/Omni Lights)

点光源是向各个方向均匀发射光线的光源。它的clight\mathbf{c}_{\text{light}}随着r变化。下图说明了为什么随着r增加,光线会变暗:

可以看到,如果r增加一倍,光线的分布会分散到4倍的表面积上,因此,点光源的强度和距离的平方成反比。如果用clight0\mathbf{c}_{\text{light}_0}来表示距离r0r_0上的颜色,则在距离r处的颜色为:

clight(r)=clight0(r0r)2\mathbf{c}_{\text{light}}(r)=\mathbf{c}_{\text{light}_0}(\frac{r_0}{r})^2

这个公式通常被称为平方反比光衰减(inverse-square light attenuation)。尽管从技术上来讲这个公式没什么问题,但从实际使用上来讲还是会有一些问题。

第一个问题是当r很小时,光线强度会非常大。当r为0时,会出现分母为0的情况。我们通常在分母上加一个很小的数ɛ来解决这个问题:

clight(r)=clight0r02r2+ϵ\mathbf{c}_{\text{light}}(r)=\mathbf{c}_{\text{light}_0}\frac{r_0^2}{r^2+\epsilon}

不同的应用会定义不同的ɛ,比如UE中它被定义为1cm。

而CryEngine和寒霜引擎(Frostbite)则使用另一种方式来解决这个问题,即将r clamp到一个最小值rminr_{\min}

clight(r)=clight0(r0max(r,rmin))2\mathbf{c}_{\text{light}}(r)=\mathbf{c}_{\text{light}_0}(\frac{r_0}{\max (r, r_{\min})})^2

不同于抽象的ɛ,这里使用的rminr_{\min}有一个具象的解释:发光物体的半径。如果r比rminr_{\min}还小,可以理解为物体表面穿透近了光源内部,这是不应该/不可能发生的。

第二个问题发生在r很大的时候。这个问题会影响性能,因为根据公式,光线强度永远不可能达到0,而为了使渲染更有效率,我们希望光线强度在某个有限的距离达到0。理想情况下,我们希望公式能变动的越少越好,同时为了避免生硬的过度,我们希望在某个距离时光线强度和其变化导数同时为0。有一个解决方式是将公式再乘以一个窗函数(window function)。UE和寒霜引擎都使用如下窗函数:

fwin(r)=(1(rrmax)4)+2f_{\text{win}}(r)=(1-(\frac{r}{r_{\max}})^4)^{+2}

其中的+2的意思是如果值为负数,则直接输出结果0,而不取平方。下图是原公式、窗口函数、原公式乘以窗口函数的三条曲线,其中rmax=3r_{\max}=3

窗函数(window function):指一种除在给定区间之外取值均为0的函数。任何函数与窗函数之积仍为窗函数,所以相乘的结果就像透过窗口“看”其他函数一样。

使用什么方法来解决这个问题取决于应用自身的需求。举例来说,如果采用较低的空间频率对函数进行采样(如使用Lightmap或顶点照明时),则rmaxr_{\max}处的导数为0就变得非常重要。CryEngine就不使用Lightmap和顶点照明,因此它使用了一个更简单的方法来处理这个问题:当r在0.8rmax rmax0.8r_{\max}~r_{\max}之间时,切换为线性衰减。

对于有些应用来说,满足平方反比曲线的优先级并不高,它们会使用一个完全不同的公式:

clight(r)=clight0fdist(r)\mathbf{c}_{\text{light}}(r)=\mathbf{c}_{\text{light}_0}f_{\text{dist}}(r)

其中的fdist(r)f_{\text{dist}}(r)就是和距离相关的某个函数。这种函数被称为距离衰减函数(distance falloff functions)。在某些情况下,使用非平方反比的衰减函数是由性能限制决定的。比如,在Just Cause 2中,需要使用性能消耗很低的光线,因此它采用了一个简单的衰减函数,同时曲线又足够平滑,可以避免顶点照明失真:

fdist(r)=(1(rrmax)2)+2f_{\text{dist}}(r)=(1-(\frac{r}{r_{\max}})^2)^{+2}

在另外一些情况下,使用何种衰减函数取决于创意方面的考量。UE中就有两种衰减函数,一种是前面介绍的平方反比函数,另一种则使用了指数衰减,可以自行调整以创建不同的衰减曲线。古墓丽影(2013)的开发者还使用了曲线编辑器来自定义衰减曲线。

聚光灯(Spotlights)

和点光源相比,真实世界的光照会随着方向和距离的不同都产生变化。它可以通过方向衰减函数fdir(l)f_{\text{dir}}(\mathbf{l})和距离衰减函数的组合来表示:

clight=clight0fdist(r)fdir(l)\mathbf{c}_{\text{light}}=\mathbf{c}_{\text{light}_0}f_{\text{dist}}(r)f_{\text{dir}}(\mathbf{l})

使用不同的fdir(l)f_{\text{dir}}(\mathbf{l})可以获得不同的光照效果。聚光灯就是很重要的一种效果,它将光投射到一个圆锥体中。聚光灯的方向衰减围绕其中心朝向s具有对称性,因此可以用s和-l的夹角θs\theta_s来表示。

大多数聚光灯函数包含了cos(θs)\cos (\theta_s),还通常拥有一个本影角(umbra angle) θu\theta_u,使得当θsθu\theta_s \geq \theta_u时,fdir(l)=0f_{\text{dir}}(\mathbf{l})=0。这个umbra angle和上文点光源中提到的rmaxr_{\max}一样起到剔除光线的作用。聚光灯还通常拥有一个半影角(penumbra angle) θp\theta_p,在这个角度内的光线强度不会衰减。

聚光灯的不同方向衰减函数都大相径庭,比如,寒霜引擎使用的函数fdirF(l)f_{\text{dir}_{\text{F}}}(\mathbf{l})和three.js浏览器图形库中的函数fdirT(l)f_{\text{dir}_{\text{T}}}(\mathbf{l})分别为:

t=(cosθscosθucosθpcosθu)          (clamp到0,1之间)t=(\frac{\cos \theta_s-\cos \theta_u}{\cos \theta_p-\cos \theta_u})^{\mp}\;\;\;\;\;\text{(clamp到0,1之间)}

fdirF(l)=t2f_{\text{dir}_{\text{F}}}(\mathbf{l})=t^2

fdirT(l)=smoothstep(t)=t2(32t)f_{\text{dir}_{\text{T}}}(\mathbf{l})=\text{smoothstep}(t)=t^2(3-2t)

其他光源类型

直射光和精确光源的主要不同在于如何计算光线方向l。不同的计算l的方向还可以引申出其他的光源类型。比如,古墓丽影中有一种光源是胶囊光源(capsule light),它的光源是一条线而不是一个点,其光线方向l是物体表面到光源上最近一点的朝向。

只要有lclight\mathbf{c}_{\text{light}}作为shader的输入参数,我们就可以使用任何函数来计算光照结果。

到现在为止,我们介绍的光源类型都是抽象的。在现实中,光源有其自身的大小和形状,它通过多个方向照亮物体表面。这种光源在渲染中被称为区域光(area lights),在实际应用中被越来越广泛地采用。区域光渲染技术分为两类:

  • 模拟区域光被部分遮挡产生的柔和阴影边缘
  • 模拟区域光对表面着色的影响

第二种在光滑、类似镜面的表面尤其明显,因为光源的形状和大小在光线反射后具有很高的透明度。直射光和精确光源不太可能被完全弃用,但已经不如以前用的那么广泛了。大致计算光线的区域已经不再那么消耗性能,加上GPU性能的提高,区域光正在被越来越广泛地使用。

实现着色模型(Implementing Shading Models)

这一节我们会过一下如果将上文中的方程用代码来实现。

求值频率(Frequency of Evaluation)

在设计着色程序时,应该按照求值频率来划分计算过程。

首先,需要确定某个计算过程的结果是否在一个完整的draw call中保持不变。如果是的话,这个计算过程可以在应用阶段通过CPU来完成,将结果通过uniform shader inputs输入图形API。当然,如果计算过程很复杂,也可以通过Compute shader让GPU来计算。

即使在上述这种情况下,不同计算过程的求值频率也千差万别:

  • 比如在计算硬件配置或初始化设置时,计算过程只需要一次就足够了。类似这种的计算只需要在编译shader时就能完成,甚至不需要通过uniform shader inputs输入。与此同时,这种计算也能在离线预计算通道(offline precomputation pass)、安装阶段或应用加载阶段完成。
  • 计算结果会在整个应用运行阶段持续变化,但是变化很慢,完全不需要每一帧都进行更新,比如随着游戏内一天的时间变化而改变的光照因素。如果计算的消耗很高,那可以尝试每隔一些帧数再更新。
  • 每一帧都需要进行计算,比如级联视图和透视矩阵
  • 针对每个模型需要进行计算,比如更新模型基于位置的光照参数
  • 每次draw call进行计算,比如更新模型的材质参数

将uniform shader inputs按照求值频率来分组可以提高应用运行效率,降低GPU频繁更新频率。

如果计算结果在一个draw call中会变化,就不能通过uniform shader inputs输入到shader中了。必须使用第三章中介绍的可编程shader阶段进行计算,如果有需要通过varying shader inputs将结果输入到其他阶段。不同阶段的计算求值频率分别为:

  • Vertex shader:每个曲面细分前的顶点一次
  • Hull shader:每个surface patch一次
  • Domain shader:每个曲面细分后的顶点一次
  • Geometry shader:每个primitive一次
  • Pixel shader:每个像素一次

在实际运用过程中,大多数计算过程都是逐像素进行。尽管这些计算通常在pixel shader阶段进行,computer shader也越来越多参与进来,我们会在第20章讨论一些案例。其他阶段的计算通常用来进行几何操作,比如变换和变形。为了理解为什么是这种状况,我们来比较一下逐像素和逐顶点的着色结果。这个比较采用了前文介绍的Gooch shading model,只不过为其增加了多光源支持。下图为比较结果:

从上到下三个物体的顶点密度逐渐增加,左侧为逐像素着色,中间为逐顶点着色,右侧为网格视图。龙的顶点密度很高,逐像素和逐顶点着色的结果相差不大。茶壶的逐顶点着色产生了一些错误,比如成角度的高光。而两个三角形组成的平面的逐顶点着色显然是错误的。产生这种错误的原因是着色方程中的部分内容(比如高光)并不是沿着网格线性变化的,而vertex shader就是沿着顶点线性插值,因此不适用。

理论上来说,可以把高光部分的计算放到pixel shader中,其他计算放到vertex shader中,这样又不会出现不真实的渲染效果,也能节省一点计算开销。但是在实践中,这种混合计算方式并不是最优解,因为线性变化的部分在shader计算中是非常节省性能的,而分开计算产生的重复计算和额外的varying shader inputs等代价反而不划算。

之前提到过,在大多数情况下,vertex shader只负责和着色无关的操作,比如变换和变形。它会将计算出的几何体表面属性转换到合适的坐标系、沿三角面做线性插值、再以varying shader inputs的形式传入pixel shader。这些属性通常包括表面坐标、法线、可选的表面切线向量等。

要注意的是,在vertex shader做线性插值时,法线向量的长度往往不再是单位长度,因此在pixel shader阶段需要重新normalize。在实践中,往往会同时在vertex shader和pixel shader中对插值生成的法线向量做normalize。

和法线向量不同,指向特定位置的向量(比如观察方向、指向精确光源的光源方向等)不需要插值,而是在pixel shader中用插值出的表面位置坐标计算这些方向向量,只需要做一个简单的向量减法就能算出来了。如果真的要用到插值计算,那也要注意不要在计算前先normalize,否则会出错,如下图所示:

左侧为先normalize再插值,得出的结果指向是错误的

前文我们还提到vertex shader会将几何体表面转换到合适的坐标系。相机和光源的位置通常也会被转换到这个坐标系,然后以uniform shader inputs的形式输入到pixel shader。这个“合适的坐标系”可以是世界坐标系,也可以是相机的本地坐标系,还可以是不太常用的当前渲染目标的本地坐标系,这取决于对性能、稳定性、易用性方面的考量。假如要渲染一个含有大量光源的场景,可能会使用世界坐标系,防止对光源坐标做转换。同时,也可以选择相机本地坐标系,可以优化与观察方向相关的pixel shader操作,还能提高精确度。

尽管大多数shader都遵循上述规则,也会有一些例外,比如平面着色(flat shading),效果如下图所示:

flat shading的做法是将图元的属性和其第一个顶点的属性绑定,并关闭顶点插值。这样,图元中的所有像素的属性都来自其第一个顶点的属性,产生对应的渲染效果。

例子

这一节中我们将示例如何实现前文提到的Gooch shading加多光源的shading model。它的公式如下:

cshaded=12ccool+n=1i(lin)+clighti(sichighlight+(1si)cwarm)\mathbf{c}_{\text{shaded}}=\frac{1}{2}\mathbf{c}_{\text{cool}}+\sum_{n=1}^{i}(\mathbf{l}_i \cdot \mathbf{n})^{+}\mathbf{c}_{\text{light}_{i}}(s_i\mathbf{c}_{\text{highlight}}+(1-s_i)\mathbf{c}_{\text{warm}})

其中:

ccool=(0,0,0.55)+0.25csurface,\mathbf{c}_{\text{cool}}=(0,0,0.55)+0.25\mathbf{c}_{\text{surface}},

cwarm=(0.3,0.3,0)+0.25csurface,\mathbf{c}_{\text{warm}}=(0.3,0.3,0)+0.25\mathbf{c}_{\text{surface}},

chighlight=(2,2,2),\mathbf{c}_{\text{highlight}}=(2,2,2),

r=2(nli)nli,\mathbf{r}=2(\mathbf{n} \cdot \mathbf{l}_i)\mathbf{n}-\mathbf{l}_i,

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

它符合前文中的多光源通用公式结果:

cshaded=funlit(n,v)+n=1i(lin)+clightiflit(li,n,v)\mathbf{c}_{\text{shaded}}=f_{\text{unlit}}(\mathbf{n},\mathbf{v})+\sum_{n=1}^{i}(\mathbf{l}_i \cdot \mathbf{n})^{+}\mathbf{c}_{\text{light}_{i}}f_{\text{lit}}(\mathbf{l}_i,\mathbf{n},\mathbf{v})

其中:

funlit(n,v)=12ccoolf_{\text{unlit}}(\mathbf{n},\mathbf{v})=\frac{1}{2}\mathbf{c}_{\text{cool}}

flit(li,n,v)=sichighlight+(1si)cwarmf_{\text{lit}}(\mathbf{l}_i,\mathbf{n},\mathbf{v})=s_i\mathbf{c}_{\text{highlight}}+(1-s_i)\mathbf{c}_{\text{warm}}

通常,可变的材质属性比如csurface\mathbf{c}_{\text{surface}}会被存储在vertex data或贴图中。为了方便,我们假设csurface\mathbf{c}_{\text{surface}}是一个固定值。

在实际的shader代码之前,我们需要先定义它的输入和输出。下面我们定义了pixel shader的varying inputs(在GLSL中以in标记)和输出:

1
2
3
in vec3 vPos;
in vec3 vNormal;
out vec4 outColor;

这个pixel shader只输出最终的着色颜色值。它的输入值来自于vertex shader沿着三角面插值的输出值。它的varying inputs分别是表面的坐标和法线向量,使用世界坐标系。它的uniform inputs有很多,简单起见我们只展示和光照相关的:

1
2
3
4
5
6
7
8
struct Light {
vec4 position;
vec4 color;
};
uniform LightUBlock {
Light uLights[MAXLIGHTS];
};
uniform uint uLightCount;

由于我们使用的是点光源,我们给它们定义了位置和颜色。LightUBlock中定义了一个Light数组,这是GLSL的特性,可以把一组统一变量绑定到一个buffer对象上,实现更快的数据传输。Light数组的长度为应用一次draw call允许的最大光源数量。后面我们可以看到,在shader编译之前,MAXLIGHTS会被替换成正确的值(在这里是10)。uLightCount是本次draw call中实际生效的光源数量。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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);
}
}

我们定义了一个lit函数,它会在main()函数中被调用。这段代码就是用来实现上面数学公式的。需要注意的是,funlit()f_{\text{unlit}}()cwarm\mathbf{c}_{\text{warm}}都是以uniform变量传入的,分别是代码中的uFUnlit和uWarmColor,它们在一个完整的draw call中保持不变,因此可以由应用阶段计算并传入,节省GPU消耗。

上述代码中使用了几个GLSL的内置函数,包括reflect、clamp(在HLSL中叫做saturate)、mix(在HLSL中叫做lerp)、normalize,在此不多做解释。

接下来我们看一下vertex shader的代码。它的uniform变量写法和pixel shader类似,但是varying input和输出需要注意一下,它的输出是pixel shader的输入:

1
2
3
4
layout(location=0) in vec4 position;
layout(location=1) in vec4 normal;
out vec3 vPos;
out vec3 vNormal;

接下来是vertex shader的代码部分:

1
2
3
4
5
6
void main() {
vec4 worldPosition = uModel * position;
vPos = worldPosition.xyz;
vNormal = (uModel * normal).xyz;
gl_Position = viewProj * worldPosition;
}

这些都是vertex shader的一些常用操作。它首先将位置和法线转换到世界坐标系,然后传给pixel shader使用。最后,位置被转换到裁剪坐标系,被赋给gl_Position,它是系统定义的变量名称,必须作为vertex shader的输出,用在后续的光栅化阶段。

注意这里法线向量没有normalize的必要,因为在原本的网格数据中它的长度就是1,而物体也没有做非统一的缩放。

这个应用使用WebGL API进行渲染和shader设置。每个可编程shader都进行单独的设置,最后统一到同一个程序中。pixel shader的设置代码如下:

1
2
3
4
5
6
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);

WebGL和OpenGL使用fragment shader来称呼pixel shader。这里也定义了pixel shader中MAXLIGHTS的对应数值。

代码举例就到这里,我们的目标只是对如何写shader有一个大概的感觉。

材质系统(Material Systems)

shader是底层程序相关的东西,而艺术家可以通过材质来描述物体表面的视觉表现。材质也有非视觉表现相关的,比如和物理碰撞相关,但这不在本书的讨论范围内。

材质通过shader来实现,但它们不是一对一的关系。同样的材质可以通过不同的shader来实现,不同的材质也可以对应同一个shader。最常见的是参数化的材质,其中最简单的形式是两个主体:材质模板(material template)和材质示例(material instance)。前者定义了一种材质类型和它能赋予的参数类型(数字、颜色、贴图等),后者则是某个模板和一组特定参数值的搭配。UE中使用了更复杂的层次结构,允许材质模板继承自其他材质模板。

参数可以在运行时通过uniform inputs获得,也能在编译时被替换。常见的在编译时被替换的参数是一个是否使用某种材质特性的bool值。

材质很重要的一个功能是将shader的各种功能打散再重组。但是由于种种限制,我们只能在代码层面实现这种组合,也就是shader代码中的#include#if#define等标记。

早期的渲染系统中往往使用很少的shader变体,所以通常都是手写它们的代码。但是随着需要用到的shader变体越来越多,全部手写代码变得不切实际,因此shader的模块化和组合性变得很重要。

在设计一个处理shader变体的系统时,首要问题是不同的变体是在运行时通过动态分支进行选择还是在编译时选择。在以前的硬件上,动态分支往往不受支持或效率很低,因此变体都是在编译时进行处理。而现代的GPU可以很好地处理动态分支,因此现在很多的功能性变体,比如光源数量,都是在运行时进行处理的。但是如果给shader加入太多的功能性变体会导致占用太多的寄存器数量,从而导致GPU占有率降低,运行效率降低。因此,编译时变体依然很重要。

假设有一个应用支持三种类型的光源,其中两种是简单的点光源和直射光。第三种是一个支持很多复杂特性的聚光灯,需要用大量代码来实现,但是只有不到5%的光源属于这种类型。在过去,三种光源的每种不同的数量组合都需要一个单独的shader,以防止出现动态分支。尽管现在已经不需要这么做,但我们最好还是生成两个shader变体,其中一个shader至少包括一个聚光灯光源,另一个shader则不包括聚光灯光源。因为大部分组合的情况都是后者,它的代码又很简单,因此会占用更少的寄存器,从而提高GPU占有率和运行效率。

在现代的材质系统中,往往需要编译大量的shader变体。在Unity的渲染系统中,有些shader甚至拥有将近1000亿个变体。尽管只有被用到的变体才会被编译,但shader的编译系统需要重新设计以处理巨量的可能的变体。

设计的原则包括:

  • 代码复用:将常用方法放到共享文件中,通过#include调用
  • 减法:在一个shader中集合多种功能,使用编译预处理和动态分支来移除不需要的部分,可以在互相冲突的变体间进行切换,这种shader被称为übershadersupershader
  • 加法:不同的功能块可以被定义为节点,通过输入输出连接器组合起来。这种做法类似于代码复用,但更结构化。类似于UE中的shader编辑器。
  • 基于模板:定义一个接口,只要某个功能实现符合这个接口规范,就能将其加入进来。这种接口的一个常见用法是将shader的参数计算和shader本身的计算分离开。UE中就有不同的material domain,比如surface domain用来计算shader的参数,Light Function domain用来计算某个光源的clight\mathbf{c}_{\text{light}}值。Unity中也有类似的结构,叫做surface shader。延迟渲染强制使用类似的结构,它使用G-buffer作为接口。

设计材质系统时还需要考虑其他一些因素,比如用最少的重复代码支持多平台。UE和Unity就支持将shader代码自动转译到各个平台自己的shader语言和函数。

此外,还需要考虑运行效率。比如UE会自动检测在一次draw call中保持不变的计算,将其移出shader。

混叠和抗锯齿(Aliasing and Antialiasing)

设想有个黑色三角形缓慢划过一个白色的背景。在一个屏幕网格被三角形覆盖的过程中,它应该是缓慢变化的。但是在最基础的渲染器(和标准的GPU渲染)中,屏幕像素会在其中心被三角形覆盖的瞬间从白色变成黑色。

上图中展示了使用不同的抗锯齿等级下,三角形、线条和点的渲染效果。其中,左侧没有使用抗锯齿,中间每个像素用了4个采样点,右侧每个像素用了8个采样点。

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

下图展示了如何均匀地对一个连续信号进行采样,目标是数字化地展示信息。通过采样可以减少信息量。

但是采样后的信号需要通过重构才能恢复原始信号,这是通过对采样信息滤波(filtering)完成的。

不管采样频率如何,都有可能出现混叠(aliasing)的问题。一个典型的例子是电影相机拍摄的车轮。由于轮子辐条转动速度比相机拍摄图片速度快很多,在影片中轮子看起来可能转的比较慢,也可能往后转,甚至可能完全不转。这种现象发生的原因是车轮的图片是间隔一段时间拍摄的,被称为时间混叠(temporal aliasing)。

在计算机图形学中,常见的混叠现象包括光栅化后线条或三角形边缘出现的锯齿、闪烁的高光(又称为“萤火虫(fireflies)”)、将一个棋盘图样的贴图缩小的时候。

当一个信号采样率过低的时候,就会出现混叠现象。采样的信号频率会比原始信号低,如下图所示:

蓝色实线为原始信号,红点为采样时间点,绿色虚线为采样信号。下方的采样频率=原始信号频率的2倍

如果想要复原原始信号,采样频率必须高于原始信号最高频率的两倍。这通常被称为采样定理(sampling theorem),采样频率被称为Nyquist rateNyquist limit。该定理使用了“最高频率”一词,意味着原始信号必须有波段限制(band-limited),不能超过一个限定的频率。换句话说,它的曲线斜率是相对比较平滑的。

如果用点采样对一个三维场景进行渲染,它的波段通常都不会有限制。三角形边缘、阴影交界处还有其他情景都会导致原始信号的不连续变化,从而产生无限大的频率。另外,无论采样点如何接近,总会有很小的物体没有被采样到。因此,使用点采样渲染三维场景时无法完全避免混叠问题,而我们最常用的就是点采样。但是,在有些情况下我们还是能知道采样信号是有带宽限制的。比如,在使用贴图时,我们可以计算贴图的采样频率,如果它低于Nyquist rate,不需要进行什么操作;如果它高于Nyquist rate,则需要运用一些算法来限制贴图的采样频率。

重构(reconstruction)

给定一个波段受限的采样信号,我们接下来讨论如何将其重构为原始信号。

box filter

上图是使用盒式滤波器(box filter)来重构信号。就是简单地把一个宽为1,高为采样点值的方形放在采样点上,最后将结果相加。这是效果最差的采样方式,但因为简单,所以在计算机图形学中经常被使用。

tent filter

上图则是帐篷滤波器(tent filter),又称为triangle filter。它将box filter中的方形替换成了三角形。由于它在相邻点之间使用了线性插值,因此效果更好,相对于box filter来说它是连续的了,就是平滑度还不够。

为了获得理想的重构结果,需要使用低通滤波器(low-pass filter)。一段信号的频率分量(振幅)是一条正弦函数sin(2πf)\sin (2\pi f),其中_f_是这一段信号的频率。low-pass filter会将频率高于某个值的频率分量去掉。理想的low-pass filter是sinc filter:

sinc(x)=sin(πx)πx\text{sinc}(x)=\frac{\sin(\pi x)}{\pi x}

使用sinc filter的效果如下图所示:

sinc filter在采样频率为1时(即原始信号最大频率不高于1/2时)最完美。如果采样频率为fsf_s,则理想的公式为sinc(fsx)\text{sinc}(f_sx),它将滤去所有频率高于fs/2f_s/2的部分。

由于sinc filter的宽度是无限的,而且存在负值,因此在实践中并不常用。

为了避免负值,会使用没有负数lobe的滤波器,通常称为高斯滤波器(Gaussian filter)。经过滤波后获得了一个连续信号,之后将使用重采样(resample)来放大或缩小信号。

重采样(resampling)

重采样用来放大或缩小采样信号。假设原本的采样点坐标为正整数,而我们希望重采样之后新的采样点间距为a。如果a>1,为缩小信号(downsampling),如果a<1,为放大信号(upsampling)。

信号放大相对比较容易,既然已经完美地重构了原始信号,只需要在新的采样间隔上重新采样就行了,如下图所示:

使用双倍频率重新采样

但是这种方式不适用于信号缩小。对于新的采样率来说,原始信号的频率太高了,必然会出现混叠问题。因此,在重构原始信号时,需要使用sinc(x/a)\text{sinc}(x/a),之后再重新采样,如下图所示:

放到图片上,相当于先对图片做模糊处理(移除频率过高的部分),再进行采样。

屏幕空间抗锯齿(Screen-based Antialiasing)

在前面的黑色三角形例子中,有一个问题就是采样率太低了,每个像素格子上只有它中心的一个采样点。如果可以增加采样点,并将采样结果用某种方式进行混合,就可以得到一个更准确的像素颜色结果,如下图所示:

右侧为使用4个采样点,如果两个采样点被红色三角形覆盖,则像素颜色为粉色

屏幕空间抗锯齿的通用方案就是加权每个采样点的颜色,得到像素颜色:

p(x,y)=i=1nwic(i,x,y)\mathbf{p}(x,y)=\sum_{i=1}^{n}w_i\mathbf{c}(i,x,y)

其中n是一个像素点中的采样数量。c(i,x,y)\mathbf{c}(i,x,y)是采样点颜色,wiw_i是单个采样点的权重,范围为[0,1],所有点权重总和为1,大多数情况下所有点的权重相同,都为1/n。(x,y)为像素点在屏幕上的坐标。函数c可以看作两个部分:首先通过方法f(i,n)得到所需的采样点浮点数坐标(xf,yf)(x_f,y_f)。然后通过渲染管线计算出该位置的颜色。

在默认的一个采样点的设置下,f方法总是返回像素正中心的坐标。

一个像素点超过1个采样点的抗锯齿算法叫做超级采样(supersampling)或oversampling。最简单的是全屏幕抗锯齿(full-scene antialiasing, FSAA),也叫做supersampling antialiasing(SSAA),即先渲染一个高分辨率图形,再过滤像素点临近样本生成一个新的图像。假设现在需要渲染一张1280x1024的图像。我们可以离屏渲染一张2560x2048的图像,屏幕上的每个像素点都用2x2的像素进行采样,并用box filter进行滤波。这个方法很耗性能,因为每个采样点都要经过着色处理,主要的优点是简单。还有一种低配版本的FSAA是只在一个轴上做双倍采样,也被称为1x2或2x1超级采样。为了方便起见,这种方法使用的分辨率倍数通常是2的幂次方,采用的滤波器通常是box filter。NVIDIA的dynamic super resolution功能是超级采样的一种更佳方式,它使用13次采样和高斯滤波器来渲染图形。下图为使用不同采样点数量的渲染效果:

有一种与超级采样相关的方法,基于累积缓存(accumulation buffer)的思路而来。它使用与生成图像分辨率相同的buffer,只不过这个buffer中颜色位深更高。为了生成4次采样所需要的4张图片,观察者会沿着屏幕空间的x、y正负方向分别移动半个像素的位置。这个方法需要在每一帧都额外对场景渲染几次,因此很耗性能,不适用于实时渲染,但是在不介意性能开销、需要生成高质量图片的应用场景中很适用。

超级采样中,每个采样点都需要过一遍pixel shader的计算。多重采样抗锯齿(Multisampling antialiasing, MSAA)则只对每个像素过一遍pixel shader,并将结果在对应的采样点中共享。假如一个像素对应的每个fragment都有4个采样点,这些采样点每个都有自己的颜色和深度值,但是一个fragment只会走一遍pixel shader。如果fragment覆盖了所有的采样点,则pixel shader会计算像素中心位置的着色信息;否则pixel shader计算时就可以根据采样点的覆盖率来适当偏移位置。这样子偏移位置叫做质心采样(centroid sampling)或质心插值(centroid interpolation),如果GPU支持的化,由GPU自动完成。

红色物体覆盖了该像素的中心点,因此使用该位置作为pixel shader计算颜色的采样点。蓝色物体只覆盖了1号采样点,因此使用它作为pixel shader计算颜色时的采样点

由于多重采样只走一遍pixel shader,因此它的速度比超级采样快。它的重点在于对fragment在像素上的覆盖率进行采样,并共享计算出的着色结果。如果可以降低颜色采样和覆盖率采样的耦合度,可以进一步降低内存,提高抗锯齿速度。NVIDIA和AMD先后提出了coverage sampling antialiasing(CSAA)和enhanced quality antialiasing(EQAA),它们只会以更高的采样率存储fragment在像素上的覆盖信息。比如EQAA的"2f4x"模式存储两个颜色和深度信息,它们在4个采样点之间共享。其中颜色和深度信息不再与某个特定的位置绑定,而是存储在一个表中,这样4个采样点就只需要1 bit来记录它们的颜色和深度值与表中的哪个记录对应,如下图:

如果表中的颜色和深度值超出了两个,则会舍弃原先的一个颜色,并将对应它的采样点标记为未知,这些采样点将不会参与计算最终像素颜色。在大多数情况下,很少出现一个像素被两个不同的不透明shader物体覆盖的情况,因此EQAA在实践中表现还不错。但是,为了获得更好的效果,还是有游戏不使用EQAA,比如Forza Horizon 2就用了4xMSAA。

在所有几何体都被渲染到一个多重采样buffer之后,就需要根据采样颜色解析出像素上最终显示的颜色。需要注意的是,如果使用的是HDR颜色值,可能会出现问题。这时候就需要在解析最终颜色前对颜色进行色调映射(tone mapping)。但是这种计算是很耗性能的,因此需要一个类似色调映射但是更简单的方法。

默认情况下,MSAA解析像素颜色时使用box filter。2007年ATI引入了custom filter antialiasing(CFAA),允许使用tent filter将当前像素和其他像素的颜色进行略微混合,但是这项技术后来被EQAA取代了。在现代GPU中,pixel shader和computer shader可以访问MSAA采样点,并使用任意滤波对采样进行重构。一个更宽的滤波器可以减少锯齿问题,但是同样也会减少尖锐的细节。Pettineo发现使用宽度为2-3像素的_三次平滑步幅(cubic smoothstep)_和_B样条(B-spline)_滤波器能实现最佳的效果。但是更宽的滤波宽度意味着更多的采样点访问消耗,因此更消耗性能。

类似的,NVIDIA内置的TXAA也使用了一个更宽、效果更好的重构滤波器,来从周围像素解析当前像素的颜色值。它和后面推出的多帧抗锯齿(multi-frame antialiasing, MFAA)都用到了时间抗锯齿(temporal antialiasing, TAA)技术,也即用之前帧渲染出来的图像来提高当前帧的效果。

设想如果我们每次渲染时都对投影矩阵做一个微小的偏移,因而采样点都是像素内部不同的点,这样获得的渲染图片越多,我们对它们进行平均后获得的颜色就越准确。TAA算法就是使用了这样的概念。在使用MSAA或其他方式渲染出当前帧的图像后,再和之前帧的图像进行混合,通常只会使用2-4帧,旧图像的权重会指数级降低。但是如果观察者和场景都没有移动,就会出现画面闪烁的问题,因此更常见的做法是只使用当前帧和前一帧的图像,它们的权重相等。TAA中同一个像素在不同帧的采样点不一样(相邻帧不同,间隔帧相同),因此相对于一帧而言,多帧的结果可以更好地预测物体边缘在当前像素的覆盖率

这种做法的好处就是不需要额外的采样点,但是也带来了一些问题:如果使用的多帧的图像权重不同,在静止的场景就会出现闪烁;高速移动的物体会由于前一帧的影响出现拖影。解决拖影的一个办法是只对缓慢移动的物体采用这种抗锯齿方案。另外有个办法是重投影(reprojection):移动的物体会生成一个移动向量,保存在单独的速度缓冲(velocity buffer)中,用当前像素所在的坐标减去这个向量,就能得到上一帧时同一位置对应的像素位置和颜色。这个算法不会增加采样点,增加的计算量不大,加上延迟渲染和MSAA不兼容,因此在最近几年被越来越广泛地采用。

采样模式(Sampling Patterns)

减少锯齿的一个关键因素是使用有效的采样模式。在接近水平线、接近竖直线、接近45度斜线出现的锯齿是处理起来最麻烦的。

旋转栅格超级采样(Rotated grid supersampling, RGS)使用了一个旋转的正方形图案,如下图所示:

RGSS是拉丁超立方(Latin hypercube)采样或N-rook采样的一种形式,也即在ntimesnn \\times n的格子中放n个采样点,每一行每一列都仅有一个采样点。这种采样模式很容易识别接近水平或竖直的边缘。

下图是最常规的N-rook类型,但是当物体边缘和采样点连线接近平行时,就会出现物体要么覆盖像素要么不覆盖像素的极端情况:

我们希望采样模式可以更平均地在一片区域内分布采样点,比如使用Latin hypercube这种分层采样(stratified sampling)技术,与其他诸如抖动(jittering)、Halton Sequence、泊松圆盘(Poisson disk)等随机生成算法一起使用。

GPU生产商在制造硬件时就将采样模式固定了下来。下图是实际中常用的一些采样模式:

从左到右分别是2x、4x、6x(AMD)、8x(NVIDIA)采样使用的采样模式

对于TAA,覆盖率采样模式由开发者自己设定,因为不同帧的采样点位置不一样。事实上,Karis发现一个基本的Halton sequence算法生成的采样模式比GPU提供的任何采样模式都好用。

如果一个场景中有一些很小的物体,那不管采样率有多高,都无法精确地呈现这些物体。如果这些小物体组成了某种图案,周期性的采样就可能导致出现莫尔条纹(Moiré frienges)。网格状的采样模式尤其容易出现这种问题。

莫尔条纹:简单来说,就是空间频率相近的两组图案相互干涉,会有更低频率(更宽间距)的图案显示出来。 比如在两张透明塑料纸上分别画一排竖线,上面那张每隔 1 mm 画一条,下面那张每隔 mm 画一条,很容易发现,竖线每隔 11 mm 就会重叠一次。细线重叠位置附近,露出的间隙较大,显得明亮;而细线不重叠的位置附近,露出的间隙较小,显得灰暗。这样就形成了周期为 11 mm 的明暗分布来,整体看上去就是一个间距更大的粗条纹,从而很容易被眼睛感受到。

莫尔条纹

倾斜的莫尔条纹

一种解决方法是使用随机采样(stochastic sampling),也就是生成更随机的采样模式,比如说前面提到的GPU自带的采样模式。如果像上图所示,采样模式很有序的话,就容易出现问题。随机采样模式会将重复的混叠变成噪点,更容易被人眼忽略。但即使采用很随机的采样模式,如果每个像素的采样模式都一样的话,还是容易出现混叠。一种解决办法是每个像素都采用不同的采样模式,或者采样点的位置随着时间变化。曾经有一些已经会支持交错采样(Interleaved sampling),也就是一组像素中的每个像素采样模式都不一样。比如ATI的SMOOTHVISION就支持在一组像素中使用最多16种自定义的采样模式,每个像素最大支持16个采样点。

还有其他一些GPU支持的算法值得注意。NVIDIA早期有一个五点梅花排列法(Quincunx),可以让一个采样点影响多个像素。Quincunx的采样模式类似骰子上的5的点数,外部的四个点会被放到像素边缘上,如下图所示:

其中,中间采样点的权重是1/2,其他每个点的权重是1/8。由于采样点在两个像素交界处,平均每个像素使用的采样点只有两个,而它的效果比同样使用两个采样点的FSAA效果好很多。这个采样模式近似于使用了二维的tent filter,当然比box filter效果好。

Quincunx同样能应用于TAA。这种情况下,每个像素只需要一个采样点,每一帧都会在上一帧基础上往某个方向交替偏移半个像素,这样前一帧的相邻2x2像素在经过双线性插值后(bilinear interpolation),就能作为当前帧某个像素四个角上的采样点结果,这个结果会和当前帧的结果再做一次平均。如下图所示:

每一帧的权重相同意味着不会在静态场景中出现闪烁。尽管高速移动物体的拖影问题依然存在,但这种方法本身代码简单,在每个像素只使用一个采样点的情况下效果还不错。

FLIPQUAD采样模式最早是为移动端而生。它综合了RGSS和Quincunx的优点,平均一个像素只需要两个采样点,但是效果和RGSS相近,如下图所示:

形态学方法(Morphological Methods)

锯齿通常是由几何形状、阴影、高光等的边缘形成的。形态学抗锯齿(morphological antialiasing, MLAA)认为,锯齿都有一个结构和它相对应,计算好这个结构可以更好地抗锯齿。

MLAA属于后处理,即先按传统方式进行渲染,再额外进行一步抗锯齿操作。有一些方法比如SRAA(subpixel reconstruction antialiasing)依赖于深度值和法线buffer,可以得到更好的效果,但是只适用于几何体。还有一些方法如GBAA(geometry buffer antialiasing)和DEAA(distance-to-edge antialiasing)会计算边缘距离像素中心有多远。

最常用的MLAA方法只需要用到颜色buffer,因此也可以用来提高阴影和高光边缘的效果。比如,DLAA(Directionally localized antialiasing)就基于这样一个认知:接近垂直的边缘应该在水平方向进行模糊,而接近水平的边缘应该在垂直方向进行模糊。

更精确的边缘检测尝试找到覆盖任意边缘的像素,并计算其覆盖率。这些算法会检测潜在边缘周围的像素,以确定原始边缘的位置,再将边缘的效果和周围像素的颜色进行混合,大概原理如下图所示:

和基于采样的算法相比,这种算法可以使用更多位置的颜色来预测边缘,因而得到的结果可以更准确。但是这种算法也有缺点,比如当两个物体的颜色相差很小、大于三个物体表面重叠在一起、物体表面颜色在相邻像素间快速变化时,它就不好处理。另外,它在显示文字、物体边角、曲线时也会出现偏差,这些情况可以使用MSAA覆盖遮挡来改善这个问题。

基于图像的抗锯齿算法由于消耗适中而被广泛使用,尤其是只使用颜色buffer的算法。最流行的两种算法是FXAA(fast approximate antialiasing)和SMAA(subpixel morphological antialiasing),其中SMAA还可以访问MSAA的采样点数据。它们都提供很多设置,用来平衡效率和效果,对于游戏来说,能接受的处理时间是每帧1-2ms。另外,它们也都能使用TAA。

透明、Alpha和混合(Transparency, Alpha and Compositing)

半透明的物体有很多方式让光线穿过自己。在渲染算法中,大致可以分为基于光线和基于视角的效果。基于光线的效果是指物体使光线减弱或改变方向,从而影响场景中的其他物体;基于视角的效果则是指半透明物体本身会被渲染。

在这一节中,我们只讨论最简单的基于视角的效果,即半透明物体减弱它后面的物体的颜色。

一种令人产生透明感觉的方法叫做透明纱窗(screen-door transparency),也就是按照棋盘形状对透明三角面进行渲染,即相邻两个像素一个被渲染一个不被渲染,这样它后面的物体就部分可见了。由于屏幕上的像素大小和像素间隔大小都很小,这个棋盘形状是不会被看出来的。这个方法的缺点是同一个区域只能渲染出一个半透明物体,如果一个半透明的红色物体和一个半透明的绿色物体都在一个蓝色物体的前面,那就只会渲染出两种颜色。另外,这种方法只能得到50%的透明度,如果要表示出其他透明度,就不能使用棋盘形状,需要更换一个形状遮罩,而其他形状就很容易被观察出来。

这种技术的一个优点就是简单。半透明物体的渲染可以不管时间和顺序,也不受硬件限制。它通过将半透明物体当成不透明物体,从而解决了渲染半透明物体产生的问题。这个思路也被用于处理镂空纹理,会在第六章介绍。

大多数透明算法都是将半透明物体的颜色和它后面物体的颜色混合起来,因此就有了alpha blend的概念。当渲染一个物体时,它对应的每个像素都有一个RGB颜色值和深度值。此外,也可以定义一个alpha(ɑ)值,用来描述一个像素的不透明度和fragment在像素上的覆盖程度。alpha为1时,表示物体是完全不透明的,并且完全覆盖了这个像素。alpha为0时,表示fragment是完全透明的。

一个像素的alpha值可以只表示不透明度或覆盖程度,或两个都表示。比如,一个肥皂泡的边缘可能遮住了一个像素3/4的区域,并且能使90%的光都通过,也就是0.1的不透明度,那它的alpha值就是0.75*0.1=0.075。但如果使用MSAA等抗锯齿方法,覆盖程度由采样点本身决定,也就是一个像素有3/4的采样点被肥皂泡所影响,那这些采样点的不透明度就是0.1。

混合顺序(Blend Order)

为了使物体显得半透明,它会被以小于1的alpha值渲染到当前场景的前面。被它覆盖的每个像素都会在pixel shader后获得一个RGBA值。将它和原本的像素颜色混合通常使用如下的over操作:

co=αscs+(1αs)cd\mathbf{c}_o=\alpha_s\mathbf{c}_s+(1-\alpha_s)\mathbf{c}_d

其中,cs\mathbf{c}_s是半透明物体(source)的颜色,αs\alpha_s是它的alpha值,cd\mathbf{c}_d是原本像素(destination)的颜色,co\mathbf{c}_o则是最终颜色,它将替代像素原本的颜色。如果alpha值为1,则像素颜色会完全被物体的颜色取代。

over操作在模拟薄纱时效果不错,但是在模拟有色玻璃或塑料时效果就不是很好。被有色玻璃或塑料遮挡的物体应该看起来更暗,因为物体反射的光线只有很少能穿过它们,如下图所示:

左侧为纤维布,右侧为塑料

这种情况下,就应该将两个物体颜色相乘,再加上半透明物体反射的颜色。这种方法会在后面章节讨论。

除了over操作外,还有一种操作叫做additive blending,它只是将颜色简单相加:

co=αscs+cd\mathbf{c}_o=\alpha_s\mathbf{c}_s+\mathbf{c}_d

它适用于发光效果,比如闪电或火花,不仅不会使后面的物体变暗,反而会使其变亮。对于多层的半透明表面,比如烟、火,这种方法可以提高其色彩饱和度。

为了正确渲染半透明物体,我们需要首先渲染不透明物体。因此,需要先关掉blending,渲染好所有的不透明物体,再把over打开,渲染半透明物体。理论上我们也可以一直开着over,因为不透明物体alpha是1,没有什么影响,但是这会更加消耗性能。

z-buffer的缺点是一个像素只能存储一个物体。如果一个像素被多个半透明物体覆盖,就会出现问题。在使用over操作时,必须按照从后往前的顺序渲染物体。其中一种排序方式是比较物体质心和观察点的距离。但是这种粗略的排序在有些情况下也会出现问题,如果物体相互穿插,就可能出现远处物体排在近处物体前面的情况。

尽管如此,这种粗略的排序由于简单快捷、不需太多内存消耗、不需要特殊GPU支持,还是被广泛使用。在使用时,通常最好关闭z-buffer写入,即照常进行深度测试,但是半透明物体不替换原先存储的深度值,保存的深度值始终是距离观察点最近的不透明物体的深度值。这样就能保证在旋转视角时不会出现半透明物体突然出现或消失的情况。还有其他办法可以提高渲染效果,比如分两次渲染半透明物体,第一次渲染背面,第二次渲染正面。

我们也可以修改over操作,使得从前往后渲染也能得到一样的结果,这种操作叫做under:

co=αdcd+(1αd)αscd\mathbf{c}_o=\alpha_d\mathbf{c}_d+(1-\alpha_d)\alpha_s\mathbf{c}_d

ao=αs(1αd)+αd=αs+αdαsαd\mathbf{a}_o=\alpha_s(1-\alpha_d)+\alpha_d=\alpha_s+\alpha_d-\alpha_s\alpha_d

需要注意的是,under操作需要destination的alpha值,而over不需要。也就是说,destination是更靠近观察点的物体表面,它是半透明的。

上述公式中,计算新的alpha值的公式可以用fragment对像素的覆盖率来理解,如下图:

无序透明(Order-Independent Transparency)

under操作可以先将所有半透明物体渲染到一个单独的颜色buffer上,再用over操作与后面的不透明场景进行混合。它也可以用来执行无序透明算法(Order-independent Transparency, OIT),即深度剥离(depth peeling),这也就意味着无需进行排序。它需要使用两个z-buffer和多通道。它的步骤如下:

  1. 使用一个渲染通道将所有物体表面(包括半透明物体)的深度值记录到第一个z-buffer中。
  2. 在第二个通道中渲染所有的半透明物体。
  3. 如果2中一个物体的深度值和第一个z-buffer中的一样,我们知道它是距离观察点最近的半透明物体,将它的RGBA存储到单独的一个颜色buffer中。
  4. 寻找下一个半透明物体,它的深度值大于且最接近于上一步中的深度值,则将它的深度值记录下来,并将它的RGBA与上一步中的颜色buffer做under操作。
  5. 重复第4步n次,直到找不到更远的半透明物体,或达到设定的通道上限。
  6. 将上述步骤得到的半透明物体颜色buffer和不透明场景进行over混合。

深度剥离有不同的变体,其中一种是从后往前,这样就可以直接把半透明颜色和不透明场景进行混合,不需要额外的颜色buffer。但深度剥离有个问题是不知道需要多少通道才能把所有的半透明层都囊括进来。有个硬件层面的解决办法是提供一个像素绘制计数器,当在一个通道中没有像素被渲染时,就说明渲染过程结束了。使用under操作的好处在于最靠近观察点的半透明物体会被最先渲染。它们会被最先观察到,因此优先级是最高的。每一层半透明物体都会增加当前像素的alpha值,当alpha值越来越接近于1时,再往后的物体其实就微不足道了。因此,如果使用从前到后的剥离,就可以设定当一个通道绘制的像素小于某个阈值、或通道数量达到某个值时提前停止渲染。而从后到前的剥离就不能提前终止。

深度剥离虽然效果很好,但速度慢,因为每个半透明层都要用到一个渲染通道。为此,有人提出了双深度剥离(dual depth peeling),可以将通道数量减少一半。还有一种桶排序(bucket sort)方法,可以在一个通道中最多捕获32层。但是它对于内存消耗很大,如果使用MSAA的话,内存消耗将是天文数字。

双深度剥离:也就是在一次通道中同时剥离最前和最后一层,或者说同时执行over和under操作。显然,这需要两个颜色buffer,一个用来记录over操作的半透明颜色混合,一个用来记录under操作的颜色混合。最后将两个颜色buffer和不透明场景进行混合。

桶排序:简单来说就是先在大范围内排序,再在每个大范围内局部排序,如下图。

制约半透明物体渲染的从来都不是算法,而是如何让GPU有效地执行这些算法。早在1984年,就有了基于CPU的A-buffer算法。这是在没有MSAA的年代的一种多重采样算法,其主要思想为:在每个像素存储一个链表,此链表存储着这个像素重叠的多个fragment的信息,包括光栅化时生成的fragment对像素的覆盖遮罩(coverage mask)。但是直到 DX11推出了Linked Lists新特性,A-buffer才能用于现代图形渲染管线中。生成Linked Lists的示意如下图:

比如上图中viewport中位于第二行第二列的像素,它在Start Offset Buffer中对应的值是3,那我们先去Fragment and Link Buffer中找到index为3的fragment信息,也即黄色、对应的前一个fragment的index是0而非-1,那说明在记录这个fragment前还被记录了一个fragment,那就再去Fragment and Link Buffer中找index为0的fragment,就是橙色、对应的前一个index是-1,说明没有更多被记录的fragment了。这样,我们就知道这个像素被两个fragment覆盖了。

Linked List使用的两个表格都是无序访问视图(UAVs),因为pixel shader执行是无序的,为了防止对这两个表操作时出现读写冲突,还需要使用原子操作(atomatic operation)。UAV和原子操作的介绍可参见第三章的介绍。

在经过遍历不透明物体生成不透明场景颜色、遍历半透明物体生成A-buffer这两个通道后,还需执行第三个通道,即将不透明和半透明颜色进行混合。显然,在A-buffer中仅仅记录fragment的颜色是不够的,实际使用中,至少还会记录深度值,这样在第三个通道中获取了像素对应的fragment后,可以根据它们的深度值对它们进行排序,然后按照从后到前的顺序将颜色进行混合。

A-buffer的优点是每个像素对应多少fragment就记录多少值,但这同样是个缺点,因为在渲染开始前并不知道需要存储多少内容。如果场景里面有大量烟雾、头发等物体,就会产生大量的fragment。

GPU通常会为buffer、数组等预先分配内存,链表也不例外,因此开发者需要提前规划好需要使用多少内存,否则就会出现内存不足的问题。多层alpha混合(multi-layer alpha blending, MLAB)就是为了解决这个问题的,它使用了Intel推出的一个GPU功能,叫做像素同步(pixel synchronization)。它的原理简单来说就是在内存不足时,先将深度值相差在一定范围内的fragment进行颜色混合,再存储到A-buffer中,这样可以减少A-buffer的存储量。这个“一定范围”可以根据内存进行调节。由于提前混合多个fragment时顺序未知,因此可能会出现一点小问题。但由于它们离得比较近,因此问题不大。当然,如果能进行一个粗略的排序就能获得更好的效果,比如ROV(rasterizer order views,详见第三章)。在移动端,也有类似MLAB的功能,叫做图块本地存储(tile local storage)。这种类型的算法对性能消耗比较大。

此段在原文中的描述对新手很不友好,上述解释是我参考了很多文章后的总结,如有谬误,欢迎大佬指正。

MLAB是基于k-buffer而来的。k-buffer的主要思路就是尽可能对前面的图层进行排序和存储,对后面的图层进行混合和丢弃。基于此,衍生出了Weighted Sum和Weighted Average算法。Weighted Sum算法公式如下:

co=i=1n(αici)+cd(1i=1nαi)\mathbf{c}_o=\sum_{i=1}^{n}(\alpha_i\mathbf{c}_i)+\mathbf{c}_d(1-\sum_{i=1}^{n}\alpha_i)

其中,αi\alpha_ici\mathbf{c}_i分别为某一层的alpha值和颜色值,cd\mathbf{c}_d是下面不透明场景的颜色,n是层数。从公式可知,Weighted Sum有两个问题,一是前半部分的颜色加总会超过(1,1,1);二是后半部分的alpha加总会超过1,从而导致整个后半部分为负值,即背景色起了负作用。

Weighted Average可以避免上述问题:

csum=i=1n(αici),      αsum=i=1nαi\mathbf{c}_{\text{sum}}=\sum_{i=1}^{n}(\alpha_i\mathbf{c}_i),\;\;\;\alpha_{\text{sum}}=\sum_{i=1}^{n}\alpha_i

cwavg=csumαsum,      αavg=αsumn\mathbf{c}_{\text{wavg}}=\frac{\mathbf{c}_{\text{sum}}}{\alpha_{\text{sum}}},\;\;\;\alpha_{\text{avg}}=\frac{\alpha_{\text{sum}}}{n}

u=(1αavg)nu=(1-\alpha_{\text{avg}})^n

co=(1u)cwavg+ucd\mathbf{c}_o=(1-u)\mathbf{c}_{\text{wavg}}+u\mathbf{c}_d

但是它也有一个问题,就是对于相同的alpha值,它将所有颜色都均匀混合了,没有考虑排序的问题。weighted blended order-independent transparency算法则将深度值也作为影响颜色的权重,并且u是用1减去所有1αi1-\alpha_i的乘积,这样获得的渲染效果更准确。但这个方法也有缺点,比如在大场景中两个相近的物体在深度值上相差不大,因而结果和weighted average没什么区别。另外,当相机和半透明物体距离变化时,深度值权重也会逐渐变化。

预乘Alpha和合成(Premultiplied Alphas and Compositing)

over操作还可以用来混合图片或合成物,也即合成(Compositing),alpha值会和每个像素的RGB值一起被存储。由alpha通道形成的图片有时被称为遮罩(matte),它显示了物体的轮廓信息。

使用RGBA值的一种方法是使用预乘alpha(premultiplied alphas),或称关联alpha(associated alphas),在使用RGB颜色之前先乘以它的alpha值,这样over操作效率会更高:

co=cs+(1αs)cd\mathbf{c}_o=\mathbf{c}_s'+(1-\alpha_s)\mathbf{c}_d

cs=αscs\mathbf{c}_s'=\alpha_s\mathbf{c}_s

预乘Alpha还可以在不改变混合状态的情况下使用over和additive blending,因为src的操作就是One,不用考虑SrcAlpha、OneMinusSrcColor等模式了。通常预乘alpha中的RGB值不会大于alpha值(也即alpha不会大于1),否则会产生很亮的半透明效果。

渲染合成图像天生与预乘alpha相吻合。在一个黑色背景上渲染一个抗锯齿的不透明物体默认就能获得预乘alpha值。比如一个白色三角形的边缘覆盖了某个像素的40%,如果抗锯齿很精确的话,这个像素的颜色值就是灰色的(0.4,0.4,0.4)。它的alpha值根据覆盖率也是0.4,那它的RGBA值就是(0.4,0.4,0.4,0.4),这就是一个预乘alpha值。

与预乘alpha对应的另外一种颜色存储方式就是未乘积的alpha(unmultiplied alphas),或称作unassociated alphas、nonpremultiplied alphas。顾名思义,它的RGB值并没有预先乘以alpha值。拿上面的白色三角形距离,例子中像素的颜色就被记为(1,1,1,0.4)。它的优点是记录了三角形原始的颜色,但每次使用之前都要先乘以alpha值。我们建议始终使用预乘alpha值,否则在线性插值等操作时获得的结果不准确,导致在物体边缘产生黑色条纹。

对于图像处理应用而言,未乘积alpha有助于在不影响底层图片的原始颜色的基础上给它加一个遮罩。它也能使用颜色通道中的所有精度范围,也就是说,我们需要谨慎处理未乘积alpha颜色和计算机图形学中线性空间的转换。

显示编码(Display Encoding)

这一段其实就是关于颜色空间的介绍,可以参考这篇文章,更浅显易懂。