这两节终于翻译完毕,不得不说原文篇幅是真的长,花了不少时间。

另外,以后引用的具体文章标题不会再列出来,一是为了节省时间,二是感觉列出来会过于冗余。所以如果想看具体引用文章标题的话,请在原书里手动搜索。

业余翻译,若有不周到之处,还请多多指教。

实时渲染(第四版)Real-Time Rendering (Fourth Edition)

第5章 着色基础 Chapter 5 Shading Basics

5.3 实现着色模型 Implementing Shading Models

为了发挥作用,这些着色和光照的方程当然必须在代码中实现。在本节中,我们将仔细介绍设计和编写此类实现的一些关键注意事项。此外,我们也会介绍一个简单的实现示例。

5.3.1 计算频率 Frequency of Evaluation

(注:frequency of evaluation 似乎是某个特别名词,暂未查到正式翻译,联系上下文后,发现译为 “计算频率” 更易理解。)

当设计一个着色实现时,需要根据他们的计算频率(frequency of evaluation)对计算进行划分。首先,要决定给出的计算结果是否总是在一个绘制调用(draw call)中保持恒定。在这种情况下,虽然 GPU 的计算着色器能够用于特别昂贵的计算,但是计算一般可由应用程序在 CPU 上执行。其计算的结果通过统一的着色器输入传到图形 API 中。

从“一次”开始,即使在这个类别中,也存在有大范围可能的计算频率。最简单的例子就是着色方程中一个常量子表达式,但是这可以应用到那些很少更改参数的例如硬件配置和安装选项的计算中。这种着色计算可能在着色器编译的时候就完成了,这种情况下甚至不需设置一个统一的着色器输入。或者,在安装阶段或当应用被加载时,计算就会被一个离线的预计算 Pass 所执行。

另一种情况是,着色计算的结果在应用程序执行时不断变化,但是这个变化很慢,以至于不需要在每一帧都进行更新。例如基于虚拟游戏世界时间的光照参数。如果计算的消耗是昂贵的,那么它应该被平摊到多帧。

其他情况下,包括每帧执行一次的计算,例如连接视图和透视矩阵;或每个模型执行一次的计算,例如根据位置更新模型的光照参数;或每次绘制调用(draw call)执行一次,例如,更新模型中每种材质的参数。通过计算频率,我们将统一的着色器输入分组,这样有助于提高应用程序的效率,并且还可以通过最小化常量更新(minimizing constant updates)来提高 GPU 的性能 [1165]

如果着色计算的结果在一个绘制调用中不断变化,那么它就不能由一个统一的着色器输入传到着色器中。取而代之的是,它必须在第 3 章提到的可编程着色阶段之一中被计算,并且如果需要的话,会通过不同的着色器输入传到其他阶段。理论上,着色计算能在任何一个可编程阶段上执行,其中每个都对应着不同的计算频率:

顶点着色器(Vertex shader) —— 计算每个曲面细分前的顶点。

外壳着色器(Hull shader) —— 计算每个表面补丁。

域着色器(Domain shader) —— 计算每个曲面细分后的顶点。

几何着色器(Geometry shader)—— 计算每个图元。

像素着色器(Pixel shader)—— 计算每个像素。

图 5.9 对于来自公式 5.19 的案例着色模型的逐像素和逐顶点的计算结果的比较,展示了三个不同顶点密度的模型。左侧展示了逐像素计算的结果,中间展示了逐顶点计算的结果,以及右边呈现了每个模型的线框渲染以展示顶点的密度。(来自计算机图形学档案的中国龙网格模型 [1172],原模型来自斯坦福 3D 扫描存储库)

实际上,大部分着色计算是逐像素执行的。尽管这些通常是在像素着色器中实现的,但是计算着色器的实现正变得越来越普遍;相关的一些例子将在第 20 章中讨论。其他阶段主要用于几何操作,例如变换和变形。为了理解为什么是这种情况,我们会对比逐顶点和逐像素着色计算的结果。在旧版的文本里,它们有时会被称作 Gouraud 着色(Gouraud shading)[578] 和 Phong 着色(Phong Shading)[1414],尽管这些术语如今已不常使用。对比中使用的着色模型在某些方面与公式 5.1 中的较为相似,但是经过修改,它可以与多个光源一起使用。当我们详细讲解案例的实现时,会在之后给出完整的着色模型。

图 5.9 展示了不同顶点密度模型的逐像素和逐顶点着色的结果。对于龙,这个顶点密度极高的模型网格,逐顶点与逐像素之间的区别是很小的。但是对于茶壶,顶点着色计算导致了例如高光棱角分明的视觉错误,并且再两个三角面组成的平面上,顶点着色的版本很明显是不对的。导致这些错误的原因是着色方程,尤其是高光部分,在模型网格表面有着非线性变化的值。这使得它们不适合用于顶点着色器,其计算结果会在递交给像素着色器之前在三角面进行线性插值。

原则上来说,可以在像素着色器中仅计算着色模型的镜面高光部分(specular highlight),而在顶点着色器中计算其余部分。这可能不会导致视觉伪像(visual artifacts),并且理论上将节省一些计算。然而在实践中,这种混合实现通常不是最佳的。着色模型的线性变化部分往往在计算上花费最少,并且以这种方式拆分着色计算往往会增加相当多的开销,例如重复计算和额外的变化输入,从而导致弊大于利。

(注:visual artifacts 在此不是很好翻译,翻译为视觉人造物会莫名其妙,考虑到指的是之前提到的某种虚假感的视觉错误,且错误原因是与现实世界有差异,因此翻译为视觉伪像)

正如我们之前提到的,在大部分实现中,顶点着色器负责非着色操作,例如几何变换和变形。生成的几何表面属性,转换到合适的坐标系统中,并被顶点着色器写入,在三角面上进行线性插值,然后作为变化的着色器输入传入像素着色器。这些属性通常包括表面的位置,表面法线,以及可选的表面切线向量(如果需要法线贴图的话)。

需要注意的是,即使顶点着色器总是生成单位长度表面法线,插值也是能改变其长度的。见图 5.10 左侧。因此,法线需要在像素着色器中重新归一化(缩放至长度为 1)。然而,顶点着色器生成的法线的长度仍然很重要。如果法线长度在顶点间是明显不同的,例如,作为顶点混合的副作用,这就会使插值倾斜。此情况可见图 5.10 右侧。由于这两个副作用,具体的实现通常会在插值之前与之后,即在顶点着色器和像素着色器中,去归一化插值后的向量。

图 5.10 在左侧,我们看到跨越表面的单位法线的线性插值将导致插值后的向量长度小于1。在右侧,我们看到法线的线性插值有着明显不同的长度,这导致了插值后的方向朝着两个法线中较长的倾斜。

与表面法线不同,指向特殊位置的向量,例如视图向量(view vector)和精确光的光向量(light vector),通常是不进行插值的。取而代之的是,在像素着色器中插值后的表面位置将被用来计算这些向量。除了在任何情况下都需要在像素着色器中执行的归一化操作外,每个向量都会用向量减法运算,这是很快的。如果因为一些原因,需要对这些向量进行插值的话,不要事先对它们进行归一化。这会导致错误的结果,见图 5.11。

图 5.11 两个光向量间的插值。在左侧,在插值前将它们归一化将导致归一化后方向不正确。在右侧,对未归一化向量插值,得到了正确的结果。

之前我们有提到顶点着色器变换表面几何体到“合适的坐标系”。通过统一变量传递到像素着色器的相机与光源的位置,通常被应用程序变换到相同的坐标系。这样可以最大程度减少像素着色器将所有的着色模型向量带入相同的坐标空间的工作。但是究竟哪个坐标系是“合适”的呢?可能的答案包括全局世界空间以及相机的局部坐标系,或者更罕见的,是当前渲染模型的局部坐标系。这通常是基于系统性的考虑,例如性能,灵活性和简单性,为整个渲染系统做出选择。举个例子,如果渲染的场景预计包含大量的光源,那么就应该选择世界空间以避免光源位置的变换。或者,最好使用相机空间,这样可以更好地优化与视图向量相关的像素着色器操作,并且提高精确度。(第16.6节)

虽然大部分的着色器实现,包括我们将要讨论的案例实现,都遵循上述一般概述,但是总有例外。举个例子,一些应用程序出于美术风格的原因选择了基于逐图元着色计算的多面外观。这种风格被称为平面着色(flat shading)。如图 5.12 是两个平面着色的例子。

原则上,可以在几何着色器中执行平面着色(flat shading),但是近年来相关的实现通常是使用顶点着色器。这是通过将每个图元的属性与其第一个顶点相关联并禁用顶点值插值来完成的。禁用插值(可以为每个顶点值分别处理)将导致第一个顶点的值传递到图元中的所有像素。

图 5.12 风格使用平面着色的两个游戏:肯塔基 0 号路(Kentucky Route Zero,上图)与 癌症似龙(That Dragon, Cancer,下图)(上图由 Cardboard Computer 提供,下图由Numinous Games 提供

5.3.2 实现案例 Implementation Example

我们现在会展示一个着色模型实现的案例。正如之前提到的,我们正在实现的着色模型与来自公式 5.1 的扩展的 Gooch 模型是相似的,但是我们经过了修改以让其能够支持多个光源。它可以被描述为

\textbf{c}_{\textrm{shaded}}=\frac{1}{2}\textbf{c}_{\textrm{cool}}+\sum ^{n}_{i=1}(\textbf{l}\cdot \textbf{n})^{+}\textbf{c}_{\textrm{light}_{i}}(s_{i}\textbf{c}_{\textrm{highlight}}+(1-s_{i})\textbf{c}_{\textrm{warm}}),\;\;\;\;\;\;\;\;\;\;(5.19)

通过以下中间计算:

\\ \\\textbf{c}_{\textrm{cool}}=(0,0,0.55)+0.25\textbf{c}_{\textrm{surface}}, \\\textbf{c}_{\textrm{warm}}=(0.3,0.3,0)+0.25\textbf{c}_{\textrm{surface}}, \\\textbf{c}_{\textrm{highlight}}=(2,2,2),\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;(5.20) \\\textbf{r}_{i}=2(\textbf{n}\cdot\textbf{l}_{i})\textbf{n}-\textbf{l}_{i}, \\s_{i}=(100(\textbf{r}_{i}\cdot\textbf{v})-97)^{\mp }. \\

此公式适合公式 5.6 中的多光源结构,为方便起见,在此重复:

\textbf{c}_{\textrm{shaded}}=f_{\textrm{unlit}}(\textbf{n},\textbf{v})+\sum ^{n}_{i=1}(\textbf{l}_{i}\cdot\textbf{n})^{+}\textbf{c}_{\textrm{light}_{i}}f_{\textrm{lit}}(\textbf{l}_{i},\textbf{n},\textbf{v}).

在此案例中,lit 和 unlit 项具体为

\\f_{\textrm{unlit}}(\textbf{n},\textbf{v})=\frac{1}{2}\textbf{c}_{\textrm{cool}}, \\f_{\textrm{lit}}(\textbf{l}_{i},\textbf{n},\textbf{v})=s_{i}\textbf{c}_{textrm{highlight}}+(1-s_{i})\textbf{c}_{\textrm{warm}},\;\;\;\;\;\;\;\;\;\;(5.21)

调整冷色的 unlit 贡献值,使得结果看起来更像原始方程。

在大部分通常的渲染应用程序中,材质球属性的变化值诸如 \textbf{c}_{\textrm{surface}} 会被存储在顶点数据,或者,更普遍的做法是存在纹理中(第六章)。然而,为了让这个案例的实现保持简单。我们会假设在整个模型中 \textbf{c}_{\textrm{surface}} 是一个常数。

此实现方案会使用着色器的动态分支功能去循环处理所有的光源。然而尽管这种直接的方法可以很好地处理还算简单的场景,但是它对于庞大、具有复杂几何体,且拥有许多光源的场景并不合适。有效处理大量光源的渲染技术将会在第 20 章详细介绍。并且,为了简单起见,我们只支持一种类型的光源:点光源。虽然这个实现方案是相当简单的,但是它遵循了我们之前提到的最佳实践。

(注:“最佳实践”应该是指前文中“然而在实践中,这种混合实现通常不是最佳的。着色模型的线性变化部分往往在计算上花费最少,并且以这种方式拆分着色计算往往会增加相当多的开销,例如重复计算和额外的变化输入,从而导致弊大于利。”)

着色模型并不是单独实现的,而是在更大的渲染框架环境(context)中实现。该案例在一个简单的 WebGL 2 应用程序中实现,修改自Tarek Sherif [1623] 的“Phong-shaded Cube”WebGL 2 案例,但是其他更复杂的框架也是运用相同的原则。

(注:context 在很多书籍中译为“上下文”,对于初学者会很费解,个人觉得译为“语境”、“环境”更好理解)

我们将讨论应用程序调用的 GLSL着色器代码和 JavaScript WebGL 的一些示例。这里的目的并不是讲述 WebGL API 的细节,而是要展示一般的实现原理。我们将以“由内到外”的顺序来讲解实现过程,首先是像素着色器,然后是顶点着色器,最后是应用程序侧的图形 API 调用。

着色器源文件应包含着色器输入与输出的定义,这样的着色器代码才是正确的。正如我们在第 3.3 节谈到的,使用 GLSL 术语,着色器输入会分为两类。其中之一就是统一输入集,它有着应用程序所设置的值,并且在一个绘制调用(draw call)中保持不变。第二种类型由变化的输入组成,它可以在着色器调用(像素或顶点)之间改变。以下是 GLSL 语言中像素着色器的各种输入及其输出的定义:

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

(注:最后一句直译太冗长拗口了,直接意译,原文 Here we see the defifinitions of the pixel shader’s varying inputs, which in GLSL are marked in, as well as its outputs:

像素着色器有单独的输出,其内容为最终的着色颜色。像素着色器的输入与顶点着色器的输出相匹配,顶点着色器的输出在被输入到像素着色器之前会在整个三角面进行插值。像素着色器有两个不同的输入:表面位置与表面法线,且这两者都是在应用程序的世界空间坐标系内。当然,统一输入的数据数量还有很多,为了简洁,我们仅展示这两个的定义,且这两者都是与光源相关的:

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

由于这些是点光源,因此每个光源的定义都包含位置和颜色。为了符合 GLSL std140 数据布局标准的限制,我们将它们定义为 vec4 而不是 vec3。尽管在这种情况下, std140 布局可能导致一些空间的浪费,但是它简化了确保 CPU 与 GPU 之间的数据布局一致的任务,这也就是我们为什么在该例子中使用它的原因。Light 结构体的数组是在一个已命名的统一代码块内定义的,该代码块是 GLSL 的功能,用于将一组统一变量绑定到缓冲区对象,以加快数据传输速度。数组长度被定义为与应用程序允许的在单个绘制调用中光源的最大数量。稍后我们将看到,应用程序在编译着色器之前将着色器源文件中的 MAXLIGHTS 字符串替换为正确的值(本例中为10)。统一的整数 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 , 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() 函数调用。总的来说,这是公式 5.20 与 公式 5.21 的 GLSL 简单实现。

需要注意, f_{\textrm{unlit}}() 和 \textbf{c}_{\textrm{warm}} 是作为统一的变量被传入。由于这些值在整个绘制调用中是恒定不变的(constant),因此应用程序可以计算这些值,从而节省一些 GPU 的周期。

该像素着色器使用了一些的内置的 GLSL 函数。reflect() 函数在第二个向量(此例中为表面法线)定义的平面上反射一个向量(此例中为光向量)。由于我们想要光向量和反射向量都指向远离表面的位置,因此我们需要在将前者传递给 reflect() 之前对其取反。clamp() 函数有三个输入值。其中的两个输入值定义了第三个输入值被 clamped 的范围。一个特别的 clamping 例子是范围在 0 和 1之间 (对应了 HLSL 的 saturate() 函数),这个运算是很快的,在大部分 GPU 上是没有什么消耗的。这也是我们在这里使用它的原因,虽然我们只需要 clamp 值到 0,因为我们知道它不会超过1。mix() 函数也有三个输入值,基于第三个值(一个在 0 与 1 之间的混合参数)在其中两个值之间进行线性插值,在此例中,指的是暖色与高光色。在 HLSL 中这个函数称为 lerp(),意思是“线性插值”(linear interpolation)。最后是 normalize() 函数,它会将向量除以其长度,将其缩放为1。

现在让我们看一下顶点着色器。我们不会展示任何它的统一定义,因为我们已经在像素着色器里看过一些统一定义的例子了,但是不同的输入和输出的定义还是值得查看一下的:

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

需要注意的是,正如之前提到的,顶点着色器的输出对应像素着色器的不同输入。输入中包含指令,这些指令决定了怎样在顶点

数组中布置数据。顶点着色器的代码如下:

void main () {
vec4 worldPosition = uModel * position ;
vPos = worldPosition . xyz;
vNormal = ( uModel * normal ) .xyz;
gl_Position = viewProj * worldPosition ;
}

这些是顶点着色器的普遍操作。着色器变换表面位置与法线到世界空间,并且将它们传入像素着色器以便在着色过程中使用。最终,表面位置被变换到裁剪空间并且传入 gl_Position,一个被光栅器使用的特殊系统定义变量。gl Position 变量是任何顶点着色器的必需的输出。

需要注意的是,法线向量在顶点着色器中并没有归一化。他们不需要被归一化是因为他们在原始网格模型中的长度为1,并且应用程序没有执行任何可能会非均匀地改变他们的长度的操作,例如顶点混合或非均匀缩放。模型矩阵可以有一个统一的缩放因子,但是那会按比例地改变所有法线的长度,因此不会导致出现图 5.10 右侧中的问题。

应用程序为了多种不同的渲染和着色器设置而使用 WebGL API。每个可编程的着色器阶段都被单独地设置,并且他们都被绑定到程序对象上。以下是像素着色器的设置代码:

var fSource = document . getElementById (" fragment ") . text . trim () ;
var maxLights = 10;
fSource = fSource . replace (/ MAXLIGHTS /g, maxLights . toString () ) ;
var fragmentShader = gl. createShader (gl. FRAGMENT_SHADER ) ;
gl. shaderSource ( fragmentShader , fSource ) ;
gl. compileShader ( fragmentShader ) ;

请注意提到的“片元着色器”,是WebGL(以及它所基于的OpenGL)中使用的术语。正如我们之前在此书中提到的,虽然“像素着色器”在某些方面描述不够精确,但它是更普遍的称呼方式,所以我们将继续在本书中使用“像素着色器”这个称呼。此代码也是将 MAXLIGHTS 字符串替换成合适的数值的地方。大部分渲染框架执行类似的预编译着色器操作。

还有更多的应用程序代码用于设置统一,初始化顶点数组,清除,绘制等,你可以在程序 [1623] 中查看这些代码,并且许多API指南对此进行了说明。在此我们的目标是通过它们自身的编程环境,了解着色器是怎样被当作单独的处理器。因此,我们在此结束本小节。

5.3.3 材质系统 Material Systems

正如我们的简单案例里,渲染框架很少只实现单个着色器。通常来说,需要一个专用的系统来处理大量的材质,着色模型,以及应用程序所使用的着色器。

正如之前章节里面所揭示的,一个着色器是用于 GPU 的可编程着色阶段之一的一个程序。因此,着色器是低级的图形 API 资源,并且不是美术人员会直接接触的。相反,材质是面向美术人员封装的表面的视觉表现。材质有时也描述非视觉部分,比如碰撞属性,我们不会继续深入此话题,因为它已经超出了该书的范围。

虽然材质通过着色器被实现,但这不是一个简单的一对一对应。在不同的渲染情况下,相同的材质可能使用不同的着色器。一个着色器也可能被多种材质共用。最普遍的情况是材质参数化。在它的最简单形式中,材质参数化需要两种类型的材质实体: 材质模板(material templates)与材质实例(material instances)。每个材质模板描述一类材质并且有一个参数的集合,它可以根据参数类型的不同去分配不同的值,有数值,颜色,或者贴图。每个材质实例对应着一个材质模板与所有参数的一组特定值。一些渲染框架例如虚幻引擎 [1802] 允许更复杂的、分层的结构,其中材质模板派生自多层次的其他模板。

参数可以在运行时被解析,通过统一的输入传递到其他着色器程序,或者也可以在编译时,通过在着色器编译前替换值来解析参数。一个常见的编译时参数类型是布尔开关,它用来控制激活给定材质的特征。这可以由美术人员通过材质 UI 的勾选框去设置,或者由材质系统在程序上去设置,例如远处的物体,它们的视觉效果特征可以忽略不计,此情况设置关闭可以减少着色器消耗。

尽管材质参数可以与着色模型参数一对一匹配,但我们不是总会遇到这种情况。一个材质可能会修改一个给定着色模型参数的值,例如表面颜色,可修改为一个常量。或者,可以将多个材质参数以及插值的顶点或纹理值作为输入,通过一系列复杂的操作来计算着色模型参数。在地形材质中,基于表面位置与方向的着色是尤为普遍的。举个例子,高度与表面法线可以被用来控制积雪特效,做法是在高处的水平面和接近水平面的表面以白色表面颜色做混合。基于时间的着色通常用于动画材质,例如闪烁的霓虹灯标志。

材质系统的最重要任务之一是将多种着色器函数划分为单独的元素,并控制这些元素的组合方式。在许多情况下,这种组合是很有用的,包括以下几种情况:

  • 将表面着色与几何处理组合在一起,例如刚体变换,顶点混合,变形,曲面细分,实例化,以及裁剪。这些功能都是各不相同的:表面着色器依赖于材质,几何处理依赖于模型网格。所以,分开编写他们以及让材质系统根据需求组合他们是很方便的。
  • 将表面着色与一些组合操作例如像素丢弃(discard)与混合(blending)组合在一起。这与移动端 GPU 是尤为相关的,在移动端 GPU 的像素着色器里,混合是一种普遍执行的操作。通常我们希望独立于表面着色用材质来选择这些操作。
  • 将用来计算着色模型参数的操作与着色模型自身的计算组合在一起。这种方式允许编写一次着色模型的实现,并且与多种不同计算着色模型参数的方式组合在一起去重用它。
  • 将独立可选的材质特征相互组合,以及与选择逻辑、剩余的着色器组合在一起。这种方式允许分开编写每个特征的实现。
  • 将着色模型、其参数的计算与光源计算组合在一起:在每个光源的着色点计算 \textbf{c}_{\textrm{light}} 与 \textbf{l} 的值。例如延迟渲染的技术(在第 20 章讨论)改变了这个组合的结构体。在支持该技术的渲染框架里,这种方式将额外增加复杂度。

如果图形API提供这种类型的着色器代码模块作为核心功能,将会很方便。然而悲伤的是,不像 CPU 代码,GPU 着色器不允许对代码片段进行后编译链接。每个着色器阶段的程序都作为一个单元被编译。着色器阶段之间的分离确实提供了一些受限制的模块,这些模块某种程度上与我们列表上的第一项相匹配:将表面着色(通常在像素着色器上执行)与几何处理(通常在其他着色阶段执行)组合在一起。但是这个匹配并不完美,因为每个着色器也会执行其他操作,并且其他类型的组合仍然需要被处理。有了这些限制,材质系统能实现所有类型组合方式的唯一办法在于源代码层级。这个主要包括例如并列和替换的字符串操作,通常通过 C 风格的预处理指令例如 #include,#if 和 #define 去执行。

早期的渲染系统有着相对较小数量的着色器变体,并且通常是手动去编写每个变体。这也有一些好处的。例如,可以在充分了解最终的着色器程序的基础上去优化每个变体。然而,这种手动编写的方法随着着色器变体数量的增加而变得不切实际。当我们将所有不同部分和选项都纳入考虑时,所有可能的不同着色器变体数量是巨大的。这就是为什么模块化和可组合性是如此的关键。

当设计一个系统用来处理着色器变体时,第一个需要解决的问题是,不同选项间的选择是否是通过动态分支(dynamic branching)在运行时执行,或者是在编译时通过条件预处理(conditional preprocessing)执行。在较老的硬件上,动态分支通常是不可能的或者是速度极慢的,所以运行时的选择是不可行的。随后所有变体都在编译阶段被处理,包括不同光源类型计数的所有可能组合 [1193]

相反,如今的 GPU 能够很好地处理动态分支,尤其是当分支行为对于一个绘制调用中所有像素都是相同的情况下。现在许多功能性变体,例如光源的数量,都在运行时被处理。然而,为一个着色器添加大量的功能变体将产生一个不同的消耗:寄存器计数(register count)的增加和占用率的相应降低,进而导致性能下降。详情可见第 18.4.5 节。所以编译时变体仍然是有价值的,它能避免包含那些从不执行的复杂逻辑。

举个例子,让我们想象一个支持三种不同类型光源的应用程序。其中两种光源类型很简单:点光源与方向光。第三种类型是通用的聚光灯,它支持列表照明模式以及其他复杂的功能,这需要大量的着色器代码去实现。然而,这个通用的聚光灯使用率相对较小,此应用程序中只有不到 5% 的光源是这种类型。在过去,一个单独的着色器变体会为每个可能的三类光源计数的组合去编译,以避免动态分支。尽管在如今这种方式已不再需要,但是编译两个单独的变体仍然是有好处的,一个变体适用于通用聚光灯数量大于等于一时,另一个变体适用于此类聚光灯数量正好为 0 时。由于它更简单的代码,第二个变体(更常使用)可能有着更低的寄存器占用率,并且因此有着更高的性能表现。

现代材质系统同时使用了运行时着色器变体和编译时着色器变体。即使完整的负载已经不会仅在编译时处理,但总体的复杂度与变体的数量仍然保持增长,所以还是需要编译大量的着色器变体。举个例子,在游戏《命运:被夺走的国王》(Destiny: The Taken King)中,在单帧内使用了超过 9000 个编译的着色器变体 [1750]。可能的变体数量还可以变得更为巨大,例如, Unity 渲染系统有着接近 1000 亿可能的变体。只有确确实实被使用的变体才会被编译,但是着色器编译系统必须被重新设计以处理大量可能的变体 [1439]

材质系统设计者使用不同的策略去解决这些设计目标。虽然这些策略有时候表现为互斥的系统体系结构 [342],但这些战略能够被——以及通常是——合并到相同的系统中。这些策略包含以下内容:

  • 代码重用——在共享的文件中实现函数,使用 #include 预处理指令去访问那些来自任意着色器的所需函数。
  • 减法——一个着色器,通常被称为超级着色器(ubershader 或 supershader[1170,1784],聚集了一大批功能,使用编译时预处理器与动态分支的组合去移除无用的部分并且在互斥的备选方案之间切换。
  • 加法——各种功能被定义为具有输入输出连接器的节点,并且这些都会组成到一起。这与代码重用策略相似,但是更结构化。节点的组合可以通过文本 [342] 或一个可视图形编辑器来完成。后者旨在使非工程师(例如技术美术)更容易编写新的材质模板 [1750,1802]。通常来说,可视图形编写只能访问到着色器的一部分。举个例子,在虚幻引擎的可视图形编辑器中只能作用到着色模型输入相关的计算。见图 5.13。
  • 基于模板——定义了一个接口,只要符合该接口,就可以接入不同的实现。这与加法策略相比显得更加正式,并且通常被用于更大的功能块中。该接口的一个普遍案例是着色模型参数计算与着色模型自身计算的分离。虚幻引擎 [1802] 有着不同的“材质域”,包括计算着色模型参数的表面域与计算一个缩放值的光照函数域(该缩放值为给定光源调整 \textbf{c}_{\textrm{light}} )。一个与此相似的“表面着色器”结构也存在于 Unity 中 [1437]。注意,延迟着色技术(将在第 20 章讨论)强制采用了一个类似的结构,其 G 缓冲区用作接口。

图 5.13 虚幻引擎材质编辑器。注意在节点图右侧的高节点。这个节点的输入连接器对应着渲染引擎的多种着色输入,包括所有的着色模型参数。(材质示例由 Epic Games 提供)

更具体的案例,书籍《WebGL Insights》(现在免费)中的一些章节讨论了各种引擎是怎样控制他们的渲染管线。除了组合外,现代材质系统还有一些其他重要的设计注意事项,例如以最少的着色器代码重复去支持多平台的需求。这包括功能的变体,以解决平台,着色语言和 API 之间的性能和功能差异。游戏《命运》的着色器系统(The Destiny shader system) [1750] 是对这种问题的最具代表性的解决方案。它采用了一个专有的预处理器层从而使着色器能够由一个自定义的着色器语言书写。这允许我们编写与平台无关的材质,然后能够自动翻译成不同的着色语言与具体实现。虚幻引擎 [1802] 与 Unity [1436] 有着相似的系统。

材质系统也需要确保具有好的性能表现。除了着色变体的专门编译外,材质系统还能执行一些少数其他普遍的优化。《命运》着色器系统以及虚幻引擎会自动检测那些在一次绘制调用中保持恒定的计算(例如在之前实现案例中提到的暖色与冷色计算),并且将它们移到着色器之外。另一个例子是在《命运》中使用的作用域系统(scoping system),为了减少 API 开销,它用来区分以不同频率更新的常数(例如每帧一次,每个光源一次,每个物体一次)的操作和在合适的时间更新每个常数设置的操作。

正如我们所见过的,实现一个着色方程是一个选择哪个部分该简化,以怎样的频率去计算各种表达式,并且用户应该怎样修改与控制外观的问题。渲染管线的最终输出是颜色和混合值。剩余的反走样,半透明,与图像显示的部分详细介绍了这些值将怎样合并与修改以进行显示。

5.4 走样与反走样 Aliasing and Antialiasingtexture mapping

想象一个巨大的黑色三角形缓缓地穿过一个白色背景。因为屏幕网格单元(screen grid cell)被三角形覆盖,代表这个网格单元的像素值应该在强度上平稳下降。然而通常发生在所有类型的基础渲染中的情况是,当网格单元的中心被覆盖的那一刻,像素颜色立即从白色变为黑色。标准 GPU 的渲染也不例外。见图 5.14 的最左列。

图 5.14 上排图像展示了三个不同反走样级别的三角形、线、点。下排图像是上排图像的放大。最左列每个像素仅用一个采样,这意味着没有使用反走样技术。中间列的图像以每像素四个采样(以网格模式)的方式渲染,最右列则是使用每像素八个采样(在4×4 的棋盘格中,一半的正方形被采样)。

三角形在像素里的显示是要么存在,要么不存在。线的绘制也有类似的问题。因此,边缘有着锯齿状的外观,这种视觉伪像(visual artifact)被称作“锯齿”(the jaggies),当物体运动时则被称作“爬虫”(crawlies)。关于此问题的更正式的称呼为“走样”(aliasing),并且,旨在避免这个问题的相关技术我们称为“反走样”(antialiasing)。关于采样理论与数字滤波的话题已经足够另外写一本书了 [559,1447,1729]。因为这是渲染的关键领域,我们会在这里阐述采样和滤波的基础理论。接下来我们会专注于当前在实时渲染中我们能做一些什么以减轻走样伪像(aliasing artifacts)

5.4.1 采样与滤波理论 Sampling and Filtering Theory

渲染图像的处理本身便是一个采样任务。之所以这样是因为图像的生成就是三维场景采样的处理过程,其目的是为图像中的每个像素(一个离散的像素数组)获取相应的颜色值。为了使用纹理映射(第 6 章),纹素(texels)必须能被重采样以在各种条件下获得好的结果。为了在动画文件中生成图像的序列,动画文件通常以均匀的时间间隔采样。本节将介绍采样,重建,以及滤波的话题。为了简单起见,大部分材质将以一维呈现。这些概念也将自然地推广到二维,并且因此能够在处理二维图像时使用。

(注:纹素(英语:Texel,即textureelement或texture pixel的合成字)是纹理元素的简称,它是计算机图形纹理空间中的基本单元。如同图像是由像素排列而成,纹理是由纹素排列表示的。)

图 5.15 展示了一个连续的信号是怎样以均匀的间隔被采样的,即离散化(discretized)。采样处理的目标是数字化地去呈现信息。这样做可以减少信息量。然而采样的信号需要被重建(reconstructed)以恢复原始信号。这是通过对采样信号进行滤波来完成的。

图 5.15 一个连续信号(左图)被采样(中图),并且接下来原始信号通过重建以恢复(右图)。

无论何时进行采样,都可能出现走样。这是我们不想造成的伪像(artifacts),并且我们需要与走样进行战斗以生成令人满意的图像。老的西方人见过的一个关于走样的经典案例,是电影摄像机拍摄的一个旋转的马车车轮。由于车轮辐条移动得比摄像机记录图像的速度快得多,车轮看起来像是在向后或向前缓慢旋转,或者甚至有可能看起来根本没有转动。见图 5.16。之所以会出现这种现象,是因为车轮的图像是以一系列时间步长被记录的,这被称作时间走样(temporal aliasing)。

在计算机图形中走样的普遍案例有光栅化的线与三角形边的“锯齿”,被称为“萤火虫”(fireflies)的闪烁的高光,以及缩小具有方格图案的纹理时发生的走样(见 6.2.2 节)。

当一个信号被以过慢的频率采样时,走样就会出现。如图 5.17 所示。为了使一个信号被合适地采样(换句话说,这样就能够从样本中重建原始信号),采样频率必须大于被采样信号最大频率的两倍。这通常被称作采样定理(sampling theorem),并且该采样频率以一位在 1928 年发现此频率的瑞典科学家 哈里·奈奎斯特(Harry Nyquist(1889–1976)命名,被称为奈奎斯特率(Nyquist rate[1447] 或奈奎斯特极限(Nyquist limit)。奈奎斯特极限如图 5.16 所示。该定理使用术语“最大频率”的这一事实暗示着信号应该受到频带限制(band-limited),这仅仅意味着任何频率都不能超过特定限制。换句话说,信号相对于相邻样本间的间隔应该足够平滑。

当一个三维场景以点样本渲染时(注:即像素渲染),正常情况下是不会有频带限制的。三角形的边缘,阴影边界,以及其他现象会产生变化不连续的信号,因此会产生无限的频率 [252]。同样,无论样本被打包得多紧密,物体仍然能足够小,以至于他们根本不能被采样。因此,当我们使用点样本渲染场景时,完全避免走样的问题是不可能的,并且我们几乎总是使用点采样。然而,有时候我们可以知道信号在何时有频带限制。其中一个例子是当纹理被应用到表面的时候。此情况下是可以计算与像素采样率相比的纹理采样频率的。如果该频率低于奈奎斯特极限,那么就不用做特殊的操作去对纹理进行合适的采样。如果频率过高的话,那么就会使用各种算法对纹理进行频带限制(第 6.2.2 节)。

重建 Reconstruction

给定一个频带限制的采样信号后,我们现在来讨论原始信号是怎样从采样信号去重建的。为了做到这点,我们必须用到一个滤波器。三个普遍使用的滤波器如图 5.18 所示。需要注意的是,滤波器的区域应该总是为单个的,否则重建的信号可能会扩大或收缩。

图 5.18 左上图为 box 滤波器(box filter),右上图为 tent 滤波器(tent filter)。底部为 sinc 滤波器(在这里已经 clamped 在 x 轴上)

sinc

图 5.19 采样后的信号(左侧)使用 box 滤波器进行重建。这是通过以下步骤完成的:首先在每个采样点上放置 box 滤波器,并且在 y方向上将其缩放,这样滤波器的高度与采样点就是相同的。之后求出的和就是重建后的信号(右侧)。

在图 5.19中,box 滤波器(最近的相邻处)被用于重建一个采样信号。这是所使用的最糟糕的滤波器,因为产生的信号是一个非连续的阶梯状。然而由于它很简单,所以仍然经常在计算机图形学中使用。如图所示,box 滤波器被放置在每个采样点上,并且之后会被缩放,这样滤波器最上方的点就可以与样本上的点重合。所有这些缩放与平移后的 box 函数之和就是右侧所示的重建后的信号。

box 滤波器可以替换成任意其他滤波器。在图 5.20 中,tent 滤波器,也被称作三角形滤波器,被用来重建采样后的信号。需要注意的是这个滤波器在相邻采样点之间实现了线性插值,所以它比 box 滤波器要更好,因为重建的信号现在是连续的。

图 5.20 采样后的信号(左侧)使用 tent 滤波器去进行重建。重建后的信号如右侧所示。

然而,使用 tent 滤波器重建的信号的平滑程度并不好;在采样点有着突然的斜率改变。这与以下事实有关: tent 滤波器并不是一个完美的重建滤波器。为了得到完美的重建,必须使用理想的低通滤波器。其中信号的频率分量是正弦波:\textrm{sin}(2\pi f)f 是该分量的频率。鉴于此,低通滤波器将去除频率高于滤波器定义的某个频率的所有频率分量。直觉上来看,低通滤波器移除了信号的尖锐特征,即滤波器对信号进行了模糊处理。理想的低通滤波器是 sinc 滤波器(见图 5.18 底部)

图 5.21 这里 sinc 滤波器被用来重建信号。sinc 滤波器是理想的低通滤波器。

\textrm{sinc}(x)=\frac{\textrm{sin}(\pi x)}{\pi x}\;\;\;\;\;\;\;\;\;\;(5.22)

傅里叶分析 [1447] 的理论解释了为什么 sinc 滤波器是理想的低通滤波器。简单来说,理由如下。理想的低通滤波器是频率域的 box 滤波器,当它与信号相乘时,移除了所有高于滤波器宽度的频率。将 box 滤波器从频率域转到空间域会得到 sinc 函数。与此同时,乘法操作被转换为了卷积(convolution)函数,卷积是我们在本节中一直使用的,但没有实际描述过的术语。

正如图 5.21 所示,使用 sinc 滤波器去重建信号能得到更平滑的结果。采样过程在信号中引入了高频部分(突变),并且低频滤波器的任务是移除这些高频部分。事实上, sinc 滤波器用频率高于 1/2 的采样率计算了所有的正弦波。sinc 函数,如公式 5.22 所示,当采样频率是 1.0 时(即采样信号的最大频率必须小于 1/2),它是完美的重建滤波器。更普遍地来说,假设采样频率是 f_{s} ,也就是说,相邻样本间隔为 1/f_{s}。对这种情况来说,完美的重建滤波器是 \textrm{sinc}(f_{s}x) ,并且它计算了所有高于 f_{s}/2 的频率。这在下一节的重采样信号中是很有用的。然而 sinc 的滤波器宽度是无限的,并且在某些区域是负值,所以它在实践中很少有用。

在低品质的 box 与 tent 滤波器之间存在有用的中间区域,另外,不实用的 sinc 滤波器也存在中间区域。大部分广泛使用的滤波器函数 [1214,1289,1413,1793] 处于这些极端情况之间。所有这些滤波器函数都有一些对 sinc 函数的近似,但是对于它们影响的像素数量有所限制。最接近 sinc 函数的滤波器在它们的部分域上有负值。对于应用程序而言,负滤波器值是不可取或不实用的,我们通常使用有着非负瓣的滤波器 [1402](通常被称作高斯滤波器,因为他们源于或类似于高斯曲线)。第 12.1 节更详细地讨论了滤波器函数以及它们的使用。

在使用任意滤波器之后,便得到了一个连续的信号。然而,在计算机图形学中我们不能直接显示一个连续的信号,但是我们可以使用它们去对连续信号进行重采样并得到另一个大小,即放大或缩小信号。这个话题将在接下来讨论。

重采样 Resampling

重采样被用来放大后者缩小一个采样信号。将设原采样点位于整数坐标系内(0,1,2,...),即样本间的间隔是单位整数。更进一步的,假设在重采样后,我们想要新的采样点以样本的间隔 a 均匀地放置。对于 a > 1,使用缩小(下采样),对于 a < 1,使用放大(上采样)。

放大是两种情况中较为简单的一个,所以我们从放大开始讲解。假设采样信号如上一节所示那样被重建。直观上看,因为信号现在已经被完美重建并且是连续的,所需要的便是以我们期望的间隔去重采样重建后的信号。这个过程在图 5.22 中有描述。

然而,当缩小时,这个技术不起作用。原始信号的频率对采样率来说过高,以至于无法避免走样。取而代之的是,已经证明了使用 \textrm{sinc}(x/a) 的滤波器应该被用来从采样信号中创建连续信号 [1447,1661]。之后便可以期望的间隔进行重采样。如图 5.23 所示。换句话说,通过在此使用 \textrm{sinc}(x/a) 作为滤波器,低通滤波器的宽度增加了,以至于更多的信号高频率内容被移除了。正如图中所示,(独立 sinc 函数的)滤波器宽度被翻倍以减少重采样率,并使原采样率减半。将此与数字图像联系起来,这与一开始进行模糊操作(为了移除高频率部分)是相似的,然后以低分辨率对图像进行重采样。

图 5.22 在左侧,是采样信号与重建新号。在右侧,重建新号已经以两倍的采样频率进行重采样,即进行了放大。

图 5.23 在左侧是采样信号与重建信号,在右侧,滤波器宽度已经放大为原来的两倍以使样本间隔也变为原来的两倍,即进行了缩小。

以采样和过滤理论为框架,我们现在开始讨论在实时渲染中用于减少走样的各种算法。

5.4.2 基于屏幕空间的反走样 Screen-Based Antialiasing

如果采样与滤波的效果不好,三角形的边缘会产生明显的伪像(artifacts)。阴影边缘,高光,以及颜色迅速变化的其他现象都可能导致类似的问题。在本小节中讨论的算法会帮助前述的这些案例提升渲染品质。它们的共同点是,它们都是基于屏幕空间的,即他们只在管线输出的采样样本上进行操作。并没有最佳的反采样技术,因为对画面品质而言,这些技术都有各自的有点,例如捕捉清晰的细节或者其他现象的能力,运动时的表现,内存消耗,GPU 要求,以及速度等等。

图 5.24。在左侧,以像素中心的一个样本去渲染一个红色三角形。因为三角形并没有覆盖样本,像素是白色,即使像素的大部分已经被红色三角形覆盖。在右侧,对每个像素使用了四个采样点,正如我们所见,其中两个采样点被红色三角形所覆盖,因此像素为粉红色。

在如图 5.14 所示的黑色三角形案例中,其中一个问题就是低采样率。在每个像素的网格单元中心进行单个采样,因此关于该网格单元最通常被了解的是,它是否被三角形所覆盖。通过在每个屏幕网格单元使用更多的采样并以一些方式将它们混合,就能计算出更好的像素颜色。如图 5.24 中所示。

基于屏幕的反走样方案的一般策略是使用一个针对屏幕的采样模式,并且对这些样本进行加权与求和,以得出像素的颜色,\textbf{p}

\textbf{p}(x,y)=\sum ^{n}_{i=1}w_{i}\textbf{c}(i,x,y),\;\;\;\;\;\;\;\;\;\;(5.23)

其中 n 是用于单个像素的采样数。函数 \textbf{c}(i,x,y) 是一个采样颜色,w_{i} 是权重,范围是 [0,1],样本对整个的像素颜色有所贡献。样品的位置根据其在序列中的顺序来确定,如1,……,n,并且可选的函数也是使用像素位置 (x,y) 的整数部分。换句话说,每个样本在屏幕网格的采样位置都是不同的,并且可选的采样模式可以对每个像素都不同。在实时渲染系统(以及大多数其他渲染系统)中,样本通常是点样本。所以,函数 \textbf{c} 可以被认为是两个函数。首先,函数 \textbf{f}(i,n) 检索屏幕上需要样本的浮点 (x_{f},y_{f}) 。然后对屏幕上的该位置进行采样,即检索该精确点处的颜色。选择采样方案,并且配置渲染管线以计算特定子像素位置的采样,这通常基于逐帧(或逐应用)设置。

在反走样中的另一个变量是 w_{i},每个样本的权重。这些权重的和为 1。大部分用于实时渲染系统的方法都对它们的样本给出了统一权重,即 w_{i}=\frac{1}{n}。图形硬件的默认模式,像素中心的单个采样,是上述反走样方程的最简单情况。只有一个项,该项的权重为 1,并且采样函数 \textbf{f} 总是返回被采样像素的中心。

反走样算法计算每个像素时,如果使用超过一个完整的采样,就被称作超级采样(或过采样)方法。概念上最简单地说,全场景反走样(full-scene antialiasing, FSAA),又名“超级采样反走样”(supersampling antialiasing, SSAA),以更高的分辨率渲染场景,然后对相邻的样本进行滤波以得到图像。举个例子,假设我们需要一张 1280 x 1024 像素的图像。如果你在屏幕外渲染一个 2560 x 2048 像素的图像,然后对每 2 x 2 的像素区域取平均值,之后在屏幕上显示,我们需要的图像就会以每像素四个采样,并使用 box 滤波器去进行滤波。需要注意的是,这相当于图 5.25 中的 2 x 2 网格采样。此方法较为消耗性能,因为所有的子采样必须被完整地着色与填充,其中每个样本都具有 z 缓冲区的深度信息。FSAA 的主要优点在于简单。这种方法的其他低质量版本只在一个屏幕轴向上以两倍的速率采样,因此被称为 1 x 2 或 2 x 1 超级采样。通常来说,为了简化起见,使用二次幂分辨率和 box 滤波器。英伟达(NVIDIA)的动态超分辨率(dynamic super resolution)功能是一个更加复杂的超级采样形式,其中以更高的分辨率渲染场景,并且使用 13 个采样的高斯滤波器去生成显示图像 [1848]

图 5.25。一些像素采样方案的对比,按照逐像素采样数从少到多排列。Quincunx 共享角落的样本以及中心样本进行加权,以使其值达到像素最终颜色的一半。2 × 2 旋转网格比 2 × 2 直形网格在几乎水平的边缘上会捕获更多的灰度级。类似地,尽管使用的样本较少,但 8 rooks 图案捕获的此类线条比 4 × 4 网格捕获的灰度级别更多。

一个与超级采样相关的采样方法是基于累积缓冲区(accumulation buffer)的 [637,1115]。该方法不使用一个大的屏幕外缓冲区,而是使用一个与最终期望图像具有相同分辨率的缓冲区,但是每个颜色通道使用更多的字节位。为了得到一个场景的 2 x 2 采样,生成四幅图像,视图根据需要在屏幕 x 轴或 y 轴上移动半个像素。每个生成的图像都是基于网格单元内的不同采样位置。每帧必须重新渲染场景几次,并将结果复制到屏幕上,这种额外费用使该算法在实时渲染系统中成本很高。当性能问题不关键时,这种方法对生成高质量的图像来说是很有用的,因为每个像素可以使用任何数量的样本,并且可以放置在任何地方 [1679]。累积缓冲区曾经是硬件中单独的一部分。它直接被 OpenGL API 所支持 ,但是在 3.0 版本中被弃用。在现代 GPU 中,累积缓冲区这个概念可以通过在输出缓冲区使用高精度的颜色格式,从而在像素着色器中实现。

当物体边缘、镜面高光和锐利阴影等现象引起突变的颜色变化时,需要额外的采样样本。阴影通常能够变得更软,以及高光可以变得更平滑以避免走样。可以增加特定对象的大小,例如电线,以确保它们在长度上每个位置覆盖至少一个像素 [1384]。物体边缘的走样仍然是一个主要的采样问题。在渲染时物体边缘被检测以及它们的影响被考虑在内时,可以使用分析方法,但是这些方法通常更为昂贵,并且相比简单地进行更多的采样,它的鲁棒性要更低。然而,GPU 的功能例如保守光栅化和光栅化顺序视图开启了新的可能性 [327]

例如超级采样与累积缓冲区等技术,它们通过生成完全由单独计算的阴影和深度指定的样本来工作。由于每个样本都必须通过像素着色器,因此总体增益相对较低,性能消耗也较高。

多重采样反走样(Multisampling antialiasing,MSAA)通过一次的逐像素计算表面着色,并在样本间共享计算结果,从而降低了高额的计算成本。像素可能有,我们说,每个片元有四个 (x,y) 样本位置,每个都有它们自己的颜色与 z 深度值,但是对于像素的每个物体的片元,像素着色器只计算一次。如果所有的 MSAA 位置样本都被片元覆盖,那么着色样本就会在像素的中心被计算。相反,如果片元覆盖较少的位置样本,则着色样本的位置可以移动,以更好地表示所覆盖的位置。举个例子,这么做可以避免纹理边缘之外的着色采样。这种位置调整方法被称作质心采样(centroid sampling)或质心插值(centroidinterpolation),并且如果开启该功能的话,该过程会由 GPU 自动完成。质心采样可以避免出现三角形外的问题(off-triangle problems),但会导致导数计算返回不正确的值 [530,1041]。参见图 5.26。

图 5.26。在中间,一个像素中的两个物体重叠。红色物体覆盖了三个样本,蓝色物体只有一个。像素着色器计算位置以绿色显示。因为红色三角形覆盖了像素的中心,这个位置被用作着色器计算。用于蓝色物体的像素着色器在对应的样本位置进行计算。对于 MSAA 来说,分离的颜色与深度值被储存在所有四个位置中。在右侧展示了 EQAA 的 2f4x 模式。四个样本现在有四个 ID 值,这些 ID 索引了一张存储起来的表,表内有两种颜色和深度的信息。

MSAA 比纯粹的超级采样方案快是因为片元只进行一次着色。它致力于以高频率对片元的像素覆盖区域进行采样,以及共享计算出的着色数据。通过进一步分离采样和覆盖范围,可以节省更多的内存,这反过来又可以使反走样的速度更快——使用的内存越少,渲染速度就越快。英伟达(NVIDIA)在 2006 年推出了覆盖采样反走样(coverage sampling antialiasing,CSAA),并且 AMD 随后推出了增强质量反走样(enhanced quality antialiasing,EQAA)。这些技术通过以更高的采样率并且仅储存片元的覆盖范围来实现。举个例子,EQAA 的 “2f4x” 模式存储了两个颜色与深度值,在四个样本位置之间共享。颜色与深度值信息不再储存在特定的位置里,而是储存在一张表中。四个样本每个只需要一位(bit)空间用来指定两个存储值中的哪个与其位置相关联。见图 5.26。覆盖样本明确规定了每个片元对最终像素颜色的贡献。如果储存的颜色数量超出了,一个储存的颜色就会被移除并且对应的样本会被标记为未知。这些样本对最终颜色不产生贡献 [382,383]。对大多数场景来说,相对较少的像素会包含三个或更多的在着色上完全不同的可见不透明片元,所以这个方案在实践中表现良好 [1405]。然而对于最高品质来说,虽然 EQAA 有更好的性能优势 [1002],但是游戏极限竞速:地平线2 (Forza horizon 2) 运行时会使用 4倍 MSAA。

一旦所有的几何体被渲染到一个多重采样缓冲区时,会执行一个解析(resolve)操作。这段程序会将样本颜色总体进行平均以决定像素的颜色。值得注意的是,当使用具有高动态范围颜色值的多重采样时,可能会出现一个问题。在这种情况下,为了避免伪像(artifacts),在进行解析操作前,你通常需要对值进行色调映射 [1375]。这个开销可能很昂贵,所以可以使用更简单的色调映射函数的近似函数或者其他方法 [862,1405]

默认的情况下,MSAA 通过 box 滤波器进行解析。在 2007,ATI 推出了自定义滤波器反走样(CFAA)[1625],它能够使用更狭窄或更宽的 tent 滤波器并且稍微拓展到其他像素格。之后支持了EQAA,从而取代了这个模式。在现代 GPU 上,像素或者计算着色器能够访问 MSAA 的样本并且使用任何我们所期望的重建滤波器,包括从周围像素中采样的样本。虽然一个更宽的滤波器会丢失锐利的细节,但它能够减少走样。佩蒂诺(Pettineo)[1402,1405] 发现立方体的 smoothstep 以及有着 2 或 3 像素宽度的 B 样条滤波器在总体上得出了最好的结果。当然还有性能消耗,因为即使使用自定义着色器模拟默认的 box 滤波器解析也会花费很长的时间,而一个更宽的滤波器核心意味着增加了样本的访问成本。

英伟达(NVIDIA)的内置支持的 TXAA ,类似地,在比单个像素更大的区域上使用了更好的重建滤波器,以提供更好的结果。它和更新的 MFAA(多帧反走样,multiframe antialiasing)方案都使用了 TAA(时间性反走样,temporal antialiasing),这是一类通用技术,它可以使用之前帧的结果用来改进图像。由于程序员能够逐帧设置 MSAA 采样模式的功能 [1406],这种技术在某种程度上成为了可能。这种技术可以解决例如旋转的马车车轮等反走样问题,并且能够改进边缘渲染质量。

想象通过生成一系列图像来“手动”执行采样模式,其中每次渲染使用不同的位置进行采样。这种偏移是通过在投影矩阵上附加一个微小的平移来完成的 [1938]。生成和取平均的图像越多,结果就越好。这种使用多个偏移图像的概念被用于时间性反走样算法。可能使用 MSAA 或其他方法生成单个图像,然后将之前的图像做混合。通常只有 2 ~ 4 帧被使用 [382,836,1405]。较旧的图像被赋予的权重可能呈指数减小 [862],尽管如果观看者和场景不移动,这可能会导致帧闪烁,因此通常只对前一帧和当前帧赋予相同的权重。由于每帧的样本位于不同的子像素位置,因此这些样本的权重总和估计的边缘覆盖率比单帧更好。因此,使用前两帧平均的系统可以提供更好的结果。每帧都不需要额外得样本,这就是此方法如此吸引人的原因。我们甚至可以使用时间性采样来生成较低分辨率的图像,该图像将放大到显示器的分辨率的大小 [1110]。此外,需要很多样本才能获得良好结果的照明方法,或者其他的技术,这两者可以用每帧使用更少样本的方法来代替,因为其结果将在多帧中混合 [1938]

在不增加额外采样成本的情况下为静态场景提供反走样功能时,这种类型的算法在用于时间性反走样功能时会遇到一些问题。如果没有对帧进行均等的加权,则静态场景中的对象可能会出现微光(shimmer)。快速移动的物体或快速的摄像机移动会导致鬼影(ghosting),即由于先前帧的影响而在物体后方留下痕迹。鬼影的一种解决方案是仅对缓慢移动的对象执行这种反走样处理[1110]。另一个重要的方法是使用重投影(reprojection)(第12.2节)来更好地关联先前和当前帧的对象。在这种方案中,对象生成运动向量,这些运动向量存储在单独的“速度缓冲区”(velocity buffer)中(第12.5节)。这些向量用于将前一帧与当前帧相关联,即从当前像素位置减去该向量,以找到该对象表面位置前一帧的彩色像素。在当前帧中不太可能成为表面一部分的样本将被丢弃 [1912]。由于时间性反走样不需要额外的样本,因此也就不需要多少额外的工作,因此近年来人们对这种算法产生了浓厚的兴趣,并且该算法也得到了广泛的应用。对于该算法,有些人的关注是因为延迟着色技术(第20.1节)与 MSAA 和其他多采样支持不兼容 [1486]。时间性反走样的实现方法各不相同,并且根据应用程序的内容和目标,已经开发了避免伪像(artifacts)和提高质量的一系列技术 [836,1154,1405,1533,1938]以Wihlidal的演讲 [1885] 为例,它展示了如何将 EQAA,时间性反走样和应用于棋盘采样模式的各种过滤技术结合起来,以保持画面质量,同时减少像素着色器调用次数。Iglesias-Guitian 等。[796] 总结了以前的工作,并提出了他们的方案,以使用像素的历史信息并预测,从而使滤波伪像filtering artifacts)最小化。Patney 等人 [1357] 扩展了 Karis 和 Lottes 在 虚幻 4 引擎的实现中 [862] 用于虚拟现实应用程序的 TAA 工作,增加了可变大小的采样以及对眼睛运动的补偿(第21.3.2节)。

采样模式 Sampling Patterns

有效的采样模式是减少走样、时间以及其他方面的关键要素。Naiman [1257] 表明,在水平和垂直边缘附近的走样对人类视觉的影响最大。斜度接近 45 度的边缘是第二个最令人困扰的地方。旋转栅格超级采样(Rotated grid supersampling,RGSS)使用旋转正方形图案来在像素内提供更多垂直和水平分辨率。图 5.25 显示了此模式的一个示例。

RGSS 模式是一种拉丁超立方体(Latin hypercube)或 N-rooks 采样的形式,其中 n 个采样放置在 n × n 的网格中,每行和每列一个采样 [1626]。使用 RGSS时,这四个样本分别位于 4 × 4 的子像素网格中的单独行和列中。与常规 2 × 2 的采样模式相比,此类模式特别适合捕获几乎水平和垂直的边缘,在常规采样模式下,此类边缘可能覆盖偶数个样本,因此有效程度较低。

N-rooks 是创建良好采样模式的开始,但这还不够。例如这些样本可能都沿着子像素网格的对角线放置,因此对于几乎平行于该对角线的边缘,会得出较差的结果。见图 5.27。

图 5.27。N-rooks 采样。左侧是一个符合规则的 N-rooks 图案,但是它在捕捉沿对角线的三角形边缘上表现较差。因为随着该三角形的移动,所有采样位置都将位于三角形的内部或外部。右侧是一种图案,它可以更有效地捕获此边缘和其他边缘。

为了获得更好的采样,我们要避免将两个采样彼此靠近。我们还希望分布均匀,将样本均匀分布在整个区域。为了形成这样的图案,我们会将例如拉丁超立方体采样的分层采样技术,与其他例如抖动、霍尔顿序列和泊松磁盘采样的方法相结合 [1413,1758]

实际上,GPU 制造商通常将此类采样模式硬连接到其硬件中,以进行多重采样反走样。对于时间性反走样,因为样本位置会逐帧变化,所以其覆盖范围是程序员无论如何都想获取的。例如,Karis [862] 发现基本的 Halton 序列(Halton sequence)比 GPU 提供的任何 MSAA 模式效果更好。霍尔顿序列会在空间中生成样本,这些样本看起来是随机的,但差异很小,也就是说,它们在空间中分布均匀,并且没有聚集(clustered)的现象 [1413,1938]

虽然子像素网格图案可以更好地近似每个三角形如何覆盖网格单元,但这并不是理想的。场景可以由屏幕上任意小的物体组成,这意味着没有采样率可以完美地捕获它们。如果这些微小的物体或特征形成图案,则以恒定间隔进行采样可能会导致莫尔条纹和其他干涉图案。超级采样中使用的网格图案特别容易产生走样。

一种解决方案是使用随机采样,这样可以提供更加随机的图案。如图 5.28 所示的模式肯定可以使用。想象一下,远处有一个拥有漂亮梳齿的梳子,每个像素覆盖几根梳齿。当采样模式与梳齿频率异相时,规则图案就会产生严重的伪像(artifacts)。具有较少顺序的采样模式可以分解这些图案。随机化倾向于用噪声代替重复的走样效果,因为人类视觉系统对此更为宽容 [1413]。结构较少的图案是对此有益的,但当像素间重复时,它仍会出现走样。一种解决方案是在每个像素上使用不同的采样模式,或者随时间更改每个采样位置。在过去的几十年中,偶尔会在硬件中支持交错采样(Interleaved sampling)、索引采样(index sampling),其中一组像素的每个像素都有不同的采样模式。(这句原文乱码了,Interleaved samplingindexsampling!interleaved,之后会找其他版本对照并纠正一下)例如,ATI 的 SMOOTHVISION 允许每个像素最多 16 个样本和最多 16 个用户定义的采样模式,这些模式能以重复模式(例如 4 × 4 像素图块)的方式混合在一起。 Molnar [1234] 以及 Keller 和 Heidrich [880] 发现,对于每个像素使用相同的模式时,使用交错式随机采样可以最大程度地减少走样伪像。

值得注意的是其他一些 GPU 支持的算法。 NVIDIA 的较早的 Quincunx 方法 [365] 是一种使样本影响一个以上像素的实时反走样方案。“ Quincunx” 是指五个对象的排列,四个在正方形中,第五个在中心,例如在六面模具上的五个点的图案。Quincunx 多重采样反走样使用此模式,将四个外部采样置于像素的角落。见图 5.25 。每个角落的采样值被分发给它相邻的四个像素。与其像其他大多数实时方案一样对每个样本进行平均加权,不如对中心样本赋予 1/2 的权重,对每个角落样本赋予 1/8 的权重。由于这种共享,每个像素平均只需要两个样本,其结果比 two-sample FSAA 方法要好得多 [1678]。这种模式近似于二维 tent 过滤器,如上一节所述,该过滤器优于 box 过滤器。

通过每像素使用单个样本的方法,也可以将 Quincunx 采样应用于时间性反走样上 [836,1677]。其中每帧在每个轴上都比之前的帧偏移半个像素,偏移方向在帧与帧之间交替。前一帧提供像素角落的点样本,并且使用双线性插值来快速计算每个像素的贡献值。 将结果与当前帧取平均值。将结果与当前帧取平均值。 每个帧的权重相等意味着静态视图没有闪烁的伪像。对齐移动物体的问题仍然存在,但是该方案本身易于用代码编写,并且在每帧每像素仅使用一个样本的情况下具有更好的视觉表现。

当在单帧中使用时,Quincunx 通过在像素边界共享样本而具有仅两样本的低成本消耗。RGSS 模式更适合捕获更多水平和垂直边缘的灰度。FLIPQUAD 模式最初是为移动图形设备开发的,结合了这两个理想的功能 [22]。它的优点是成本仅为每像素两样本,并且质量类似于 RGSS(每像素四样本)。 这种采样模式如图 5.29 所示。另外,Hasselgren 等人 [677] 探索了其他一些利用样本共享方式的廉价采样模式。

图5.29。 左侧显示了 RGSS 采样模式。 每像素花费四个样本。 通过将这些位置移到像素边缘,可以跨边缘进行样本共享。 但是,要解决此问题,每个其他像素必须具有镜像的采样模式,如右图所示。 所得的样本模式称为 FLIPQUAD,每个像素花费两个样本。

与 Quincunx 一样,双样本的 FLIPQUAD 模式也可以与时间性反走样一起使用,并分布在两个帧上。Drobot [382,383,1154] 解决了在他关于混合重建反走样(hybrid reconstruction antialiasing ,HRAA)的研究工作中哪种双样本模式最好的问题。他探索了用于时间性反走样的不同采样模式,并发现 FLIPQUAD 模式是所测试的五种模式中最好的。 棋盘格图案还可以用于时间性反走样。 El Mansouri [415] 讨论了使用两个样本 MSAA 创建棋盘渲染,以减少着色器成本,同时解决走样问题。 Jimenez [836]使用 SMAA,时间性反走样技术以及多种其他技术来提供一种解决方案,其中反走样质量可以根据渲染引擎负载来改变。 Carpentier 和 Ishiyama [231] 在边缘采样,将采样网格旋转了 45°。 他们将此时间性反走样方案与 FXAA(稍后讨论)结合在一起,以在更高分辨率的显示器上进行有效的渲染。

形态学方法 Morphological Methods

走样通常是由边缘引起的,例如由几何形状,尖锐阴影或明亮高光形成的边缘。 走样具有与边缘相关的结构,可以利用这些知识来提供更好的反走样结果。2009年,Reshetov [1483] 沿着这些思路提出了一种算法,称其为形态学反走样(morphological antialiasing ,MLAA)。其中“形态”是指“与结构或形状有关”。早在1983年,Bloomenthal [170] 就在这一领域做了较早的工作[830]。之后 Reshetov 的论文重新激发了对多采样方法替代方法的研究,强调搜索和重建边缘 [1486]

这种反走样形式是在后处理(post-process)中执行的。 也就是说,以通常的方式进行渲染,然后将结果反馈到生成反走样结果的过程中去。自 2009 年以来,已经开发出了多种技术。那些依赖于其他缓冲区(例如深度和法线)的缓冲区可以提供更好的结果,例如子像素重建反走样(subpixel reconstruction antialiasing ,SRAA)[43,829],但仅适用于对几何边缘进行反走样。诸如几何缓冲区反走样(geometry buffer antialiasing,GBAA)和距离边缘反走样(distance-to-edge antialiasing,DEAA)之类的分析方法,会使渲染器计算有关三角形边缘位于何处的附加信息,例如边缘距像素中心的距离有多少 [829]

最通用的方案只需要颜色缓冲区,这意味着它们还可以从阴影,高光或之前应用的各种后处理技术(如轮廓边缘渲染)中改善边缘(见 15.2.3 节)。 例如,方向局部反走样(directionally localized antialiasing,DLAA)[52,829] 是基于以下观察结果:接近垂直的边缘应水平模糊,同样,接近水平的边缘也应与其相邻像素垂直模糊。

边缘检测的更复杂形式尝试寻找可能包含任意角度的边缘的像素并确定其覆盖范围。 检查潜在边缘周围的邻域,目标是尽可能地重建原始边缘所在的位置。 然后可以使用边缘对像素的效果来融合相邻像素的颜色。 有关过程的概念视图,请参见图 5.30。

图5.30。 形态学反走样。左侧是走样图像。我们的目的是确定形成边缘的边缘的可能方向。中间,该算法通过检查相邻像素来记录其为边缘的可能性。给定样本后,显示了两个可能的边缘位置。右侧,使用最佳的推测边缘将相邻的颜色与估计的覆盖率成比例地混合到中心像素中。之后对图像中的每个像素重复此过程。

Iourcha 等人 [798] 通过检查像素中的 MSAA 样本来计算更好的结果,从而改善了边缘查找。请注意,边缘预测和融合可以比基于样本的算法提供更高的精度。例如,一种使用每像素四样本的技术只能为对象的边缘提供以下五个混合级别:无样本覆盖,一个样本覆盖,两个样本,三个样本和四个样本。估测出的边缘位置可以具有更多的位置,因此可以提供更好的结果。

基于图像的算法中有几种可能会误入歧途。 首先,如果两个对象之间的色差低于算法的阈值,则可能无法检测到边缘。 具有三个或更多不同表面重叠的像素很难进行转换。颜色在像素间快速变化的,具有高对比度或高频率元素的表面,会导致算法错过边缘。特别地,当对其应用形态学反走样时,文本显示的质量通常会受到影响。 对象的角落部分可能是一个挑战,有些算法可以使它们具有圆润的外观。假设边缘是直的,曲线也会受到其不利影响。单个像素变化可能会导致边缘重建方式发生很大变化,从而在帧与帧之间产生明显的伪像(artifacts)。解决此问题的一种方法是使用 MSAA 覆盖蒙版来改善边缘确定性 [1484]

形态学反走样方案仅使用所提供的信息。例如,宽度小于像素的物体(例如电线或绳索)在屏幕上的任何位置都会出现缝隙,而不会覆盖像素的中心位置。在这种情况下,采集更多的样本可以提高质量; 仅基于图像的反走样不能。此外,执行时间可以根据查看的内容而变化。 例如,一片草地的视野所需的反走样时间是天空的三倍 [231]

综上所述,基于图像的方法可以为较小的内存和处理成本提供反走样支持,因此它们被用于许多应用程序中。仅颜色的版本还与渲染管线分离,使其更容易修改或禁用,并且甚至可以公开为 GPU 驱动程序选项。两种最流行的算法是快速近似反走样(fast approximate antialiasing,FXAA)[1079、1080、1084] 和子像素形态反走样(subpixel morphological antialiasing,SMAA)[828、830、834],部分原因是它们都为各种设备提供了可靠的(以及免费的)源代码实现。两种算法都使用仅颜色的输入,SMAA 具有能够访问 MSAA 样本的优势。 每个算法都有自己可用的各种设置,以便在速度和质量之间进行权衡。 每帧消耗通常在 1-2 毫秒的范围内,主要是因为这是视频游戏所愿意花费的时间。 最后,两种算法都可以应用时间性反走样功能 [1812]。 Jimenez [836] 提出了一种改进的 SMAA 实现,比 FXAA 更快,并描述了一种时间性反走样方案。 总之,我们向读者推荐 Reshetov 和 Jimenez [1486] 对形态学技术及其在视频游戏中的使用的广泛评论。

《Real-Time Rendering 4th Edition》全文翻译 - 第5章 着色基础(中)5.3 ~ 5.4相关推荐

  1. 《Real-Time Rendering 4th Edition》读书笔记--简单粗糙翻译 第五章 着色基础 Shading Basics

    写在前面的话:因为英语不好,所以看得慢,所以还不如索性按自己的理解简单粗糙翻译一遍,就当是自己的读书笔记了.不对之处甚多,以后理解深刻了,英语好了再回来修改.相信花在本书上的时间和精力是值得的. -- ...

  2. 《Real-Time Rendering 4th Edition》全文翻译 - 第1章 引言

    为了追赶同事(或者在公司装X),不得不开始啃RTR4(滑稽) 如题,用笨办法硬上,就是莽! 然后是概括一下每小节大致内容,最后翻译全文. 这样把全书过一遍的话应该能算认真读完了吧-- 全文翻译,逐字逐 ...

  3. 《Real-Time Rendering 4th Edition》全文翻译 - 第6章 纹理化(上)6.1 ~ 6.3

    由于工作变动原因,这次翻译拖的时间比较长--抱歉啦! 其实也是由于每章的内容越来越多了,很难在短时间内翻译完,是个很磨人的事情. 不过我会坚持下去的!希望能更多地帮到大家吧! 业余翻译,若有不周到之处 ...

  4. 《Real-Time Rendering 4th Edition》全文翻译 - 第15章 非真实感渲染(下)15.3 ~ 15.5

    连更两篇,冲鸭! 业余翻译,若有不周到之处,还请多多指教! 实时渲染(第四版)Real-Time Rendering (Fourth Edition) 第15章 非真实感渲染  Chapter 15  ...

  5. 《Real-Time Rendering 4th Edition》全文翻译 - 第2章 图形渲染管线(上)2.1 ~ 2.3(20200720翻新)

    如题,笨办法继续莽! 部分段落的论述过于冗长,自己做了分段处理. ------分割线 2020.7.20------ 翻新了一遍译文,统一了名词,补充了漏译的部分. 实时渲染(第四版)Real-Tim ...

  6. 《Real-Time Rendering 4th Edition》全文翻译 - 第15章 非真实感渲染(上)15.1 ~ 15.2

    好久没更新了~ 由于对NPR方面比较感兴趣,所以任性了一下,先翻译了这一章~ 业余翻译,若有不周到之处,还请多多指教! 实时渲染(第四版)Real-Time Rendering (Fourth Edi ...

  7. 《Real-Time Rendering 4th Edition》全文翻译 - 第6章 纹理化(下)6.7 ~ 6.9

    最近比较有动力,再来一篇!~ 实时渲染(第四版)Real-Time Rendering (Fourth Edition) 第6章 纹理化 Chapter 6 Texturing 6.7 凹凸映射 Bu ...

  8. 《Real-Time Rendering 4th Edition》全文翻译 - 第4章 变换(下)4.5 ~ 4.7

    第四章终于结束了--接下来会休息一段时间,祝各位五一劳动节快乐! -- 想了想还是不休息了,继续继续!! 实时渲染(第四版)Real-Time Rendering (Fourth Edition) 第 ...

  9. 《Real-Time Rendering 4th Edition》全文翻译 - 第3章 图形处理单元(GPU)(下)3.7 ~ 3.10

    赶在 2019 结束之前把第三章结束,提前祝大家新年快乐! 实时渲染(第四版)Real-Time Rendering (Fourth Edition) 第3章 图形处理单元(GPU) Chapter ...

最新文章

  1. iOS - Flutter混合开发
  2. Keil调试局部变量显示not in scope的问题解决
  3. python 获取文件列表
  4. 大学生php实训心得1500_【有奖征文】第五届大学生国际学术研讨会
  5. linux weblogic 内存溢出,weblogic 安装升级补丁出现内存溢出问题解决
  6. 如何成为一名优秀的高级C/C++程序员
  7. msp430入门编程46
  8. 标准exception类层次图
  9. GCC 编译 --sysroot
  10. python3安装包是说解压数据出错怎么办_无法修复“zipimport.zipimporter错误:无法解压缩数据;键入python3.6时zlib不可用获取pip.py...
  11. jquery选择器小知识点们
  12. CocoaPods 简易教程 Alamofire请求数据 Swift
  13. ado全称_JDBC、ODBC、OLE DB、ADO、ADOMD区别与联系
  14. 3种夸克有多少组合?
  15. java.lang.ClassCastException: de.odysseus.el.ExpressionFactoryImpl cannot be cast to javax.el.Expres
  16. 微信小程序跳转第三方H5的方法
  17. [574]tf.nn.xw_plus_b
  18. python物流领域应用
  19. win7和win10硬件要求各多少
  20. Django REST Framework——4. 请求与响应

热门文章

  1. CCNA考试三部曲总结
  2. (毕设2)esp8266+dht11+mysql+flask+echarts单片机温湿度数据采集网页实时可视化(附源码)
  3. web基础设施知识;web前端安全***,客户端安全基础
  4. 《Flocking for Multi-Agent Dynamic Systems:Algorithms and Theory》仿真展示
  5. 免费好用的录音转文字软件
  6. C语言实现扫雷(含展开,附源码)
  7. 第一届线上加密狂欢节,这次来点「不一样」
  8. 滴答顺风车怎么抢90%以上的订单_顺风车,又来了!!!
  9. Error: setup script specifies an absolute path
  10. 简述计算机联锁设备三取二制式的工作原理,车站信号