《Unity3D高级编程之进阶主程》第七章,渲染管线与图形学(二) - 渲染管线2

继上篇讲解了渲染管线的应用阶段、几何阶段和光栅化阶段,这一节我们来讲讲最后的逐片元操作阶段,以及着色器中我们常见的一些概念和原理。

逐片元操作(Per-Fragment Operations)是OpenGL的说法,在DirectX称为输出合并阶段(Output-Merger),其实只是说法不同而已包含的内容都是相同的,它包括了,剪切测试(Scissor test)、多重采样的片元操作、模板测试(Stencil Test)、深度测试(Depth Test)、混合(blending)、以及最后还有个逻辑操作。

===

这几个节点都是以片元为基础的元素操作,它们大都决定了片元的去留问题。所以逐片元操作阶段是决定片元的可见性问题的一个重要阶段,如果片元在这几个节点上任意一个节点没有通过测试,管线就会停止并丢弃它,之后的测试或操作都不会被执行,反之执行测试全部通过就会进入帧缓存等待输出到屏幕。每个节点实际测试的过程是个相对复杂的过程,而且不同的图形接口实现的细节也不一样,但我们要理解到它们的基本原理和大致的过程则相对简单一些。

所有这些测试和操作其实都可以看做是以开关形式存在,因为他们的操作命令大都包含了On和Off操作指令,在OpenGL里以glEnable()和glDisable()来表示功能是否被开启或关闭,只是除了开关还需要我们指定参数。

第一步可见性测试就是剪切测试(Scissor),它主要针对的是片元是否在矩形范围内的测试判断,如果片元不在矩形范围内则被丢弃。这个范围是一个矩形的区域,我们可以通过OpenGL的函数调用来设置矩形位置和大小,我们称它为剪切盒。实际上我们可以设置很多个剪切盒,只是默认情况下所有渲染测试都在第一个剪切盒上完成,要访问其他剪切盒就需要几何着色器。剪切测试在Unity3D并不常用,它并不是视口设置不同,后者不会限制屏幕的清理操作。

第二步是多重采样的片元操作。普通的采样只采一个样本或者可以说一个像素,而多重采样则是分散取得多个样本,这些样本可能是附近的几个位置也可能是通过其他算法得到的。因此在多重采样中,采样的片元都有各自的颜色、深度值、纹理坐标,而不是只有一种(具体有多少个取决于子像素的样本数目)。这里的多重采样操作有所不同,它是我们可以自定义的操作模式,自定义部分为alpha影响的采样覆盖率,以及我们可以设置掩码与采样的片元进行‘与’操作。默认情况下,多重采样在计算片元的覆盖率时不会考虑alpha的影响。

第三步模板测试(Stencil Test),模板测试说白了和比大小无异,只是在模板测试中比的方式和比的数字我们可以自定义设置。

在模板测试中模板缓存块是必要的内存块,它与屏幕缓冲大小一致,每个片元在测试时都会先取得自己位置上的模版缓冲位置并与之比较,在通过测试后才被写入到模板缓存中,在整个渲染帧结束前它是不会被重置的,也就是说所有模板测试共享一个模板缓存块。

在模板测试中,开发者通常需要指定一个引用参考值(Reference value),这个参考值为当前物体的片元的提供了标准参考,然后这个参考值会与模板缓存(Stencil Buffer)中当前片元位置的模板值进行比较,模板缓存中的值是被前面的物体的片元通过测试时写入的值,比较两个值后根据比较的结果做判断是否抛弃片元,判断依据可以是大于、等于、小于或其他等,一旦判断失败片元将被抛弃反之则继续向下传递,另外即使判断成功后我们也可以对其值做其他操作,这类操作可以是替换旧的片元、或增加一定的参考值后再替换、或参考值置零等等。

我们来看看到底有多少种判断和多少种对模板缓存的操作。

	Greater	大于模板缓存时判断通过

	GEqual	大于等于模板缓存时判断通过

	Less	小于模板缓存时判断通过

	LEqual	小于等于模板缓存时判断通过

	Equal	等于模板缓存时判断通过

	NotEqual	不等于模板缓存时判断通过

	Always	总是通过

	Never	总是不通过

上述这些是对于是否通过测试的判断种类,看上去像是各种比大小的方式。其在Unity3D的Shader中的完整的模板测试写法如下:

	Stencil {
	    Ref 2 //指定的引用参考值
	    Comp Equal //比较操作
	    ReadMask 255 //读取模板缓存时的掩码
	    WriteMask 255 //写入模板缓存是的掩码
	    Pass Keep //通过后对模板缓存的操作
	    ZFail IncrSat //如果深度测试失败时对模板缓存的操作
	}

从上述看模板测试的步骤简单明了,当前值2与模板缓冲比较,2这个数值可以是从业务层传递进Shader内的参数,倘若2这个数值与模板缓存中的值比较后通过,则做Keep操作保留当前模板缓存中的值,倘若数值2并没通过测试,则片元被抛弃,并且在模板缓冲位置加上2这个数值。读取与写入掩码,意思就是读取模板缓存和写入模板缓存时都要与相应的数值进行与操作,这里的255就是16进制的0xFF,相当于2进制的8个1。

Unity3D模板命令中 Pass 的操作是对通过测试后的参考值与模板缓存做操作,它有如下几种方式可选:

	Keep	不做任何改变,保留当前缓存中的参考值
	Zero	当前Buffer中置零
	Replace	将当前的参考值写入缓存中
	IncrSat	增加当前的参考值到缓存中,最大为255
	DecrSat	减少当前的参考值到缓存中,最小为0
	Invert	翻转当前缓存中的值
	IncrWrap	增加当前的参考值到缓存中,如果到最值255时则变为0
	DecrWrap	减少当前的参考值到缓存中,如果到最小为0时则变为255

不同的物体可以自己有不同的引用值,我们可以通过Unity3D设置材质球参数的接口将数值传递进入着色器。比较操作也可以多种多样,包括掩码值、成功后的操作动作。这让本来看上去一个简简单单的比大小行为赋予了更多的花样。不止如此,模板缓存里的值除了比较和通过测试后的操作指令外,深度测试也可以影响模板缓存中的值。我们用一幅图就能理解模板测试的美妙:

	缺图

图中三个球一起叠加在一起却只显示了一个球的,并且在这个球上显示了三个球叠加的部分。因为其他两个球的模板测试并没有通过,但叠加部分则通过了模板测试。

片元幸运的通过了模板测试,则会来到第四步深度测试。深度测试主要作用是根据深度判断和覆盖在帧缓冲中的片元。片元中有深度信息,它的来源就是在归一化坐标后三角形顶点Z轴上的值,三角形经过光栅化后,三角内的片元的深度是三个顶点的z坐标的插值,深度测试依靠这个深度来判定是否需要覆盖已经写进帧缓冲里的片元(可别忘了我们说片元就是带着诸多信息的像素)。

我们说深度测试是为了判定最后片元是否写入帧缓存,那么在判定过程中深度测试自己也有自己的缓存,即深度缓存,它可读可写就是为片元深度信息判定而存在的。深度测试工作分为两块,其中一块是片元与缓存的比较即ZTest,另一块是片元信息写入缓存即ZWrite。所有片元只有在ZTest中与缓存中的数据比较并被判定通过的才有ZWrite写入深度信息的资格。

当然我们也可以把ZWrite写入权限关闭,这样渲染物体的所有片元都只能做比较操作判定是否覆盖前面的片元而无法写入深度信息,这也同时导致了它的片元深度无法与其他物体比较。这种写法在半透明物体中很常见,其他时候大部分都是默认开启深度值写入即ZWrite On。

深度测试是怎么比较的呢?还记得前面介绍的模板测试么,重点就是“比大小”,这次比的是片元上的深度与深度缓存中的深度信息,判定通过的就有权利写入深度缓存,深度测试的方法和模板测试的流程和方法简直就是一个妈生出的俩个孩子。深度测试的 ZTest 对应模板测试的 Comp指令,深度测试的 ZWrite 对应模板测试的 Pass指令,先拿当前片元比较缓存中的值再操作缓存,两者简直一模一样。我们来看看例子:

	Pass
	{
		ZTest LEqual
		ZWrite On
	}

这组深度测试参数表明了,深度判定规则为当物体的这个像素的Z值小于当前深度缓存中相同位置的深度时通过ZTest,通过ZTest后将该像素深度信息写入深度缓存。其中ZTest的可选参数为如下:

	Less 小于深度缓存中的值
	LEqual 小于等于深度缓存中的值
	Greater 大于深度缓存中的值
	GEqual 大于等于深度缓存中的值
	Equal 与深度缓存中的值相等
	NotEqual 与深度缓存中的值不相等
	Always 永远通过ZTest
	Never 永远不通过ZTest
	Off 等同于 ZTest Always
	On 等同于ZTest LEqual

深度测试与模板测试一样有自己的缓存块,不同的是深度测使用的是像素深度值而不是固定某个值,写入缓存的操作没有模板测试那么多花样。

那么什么是深度值?这个深度值是从哪来的呢?还记得前面顶点着色器中介绍的,顶点在变化坐标空间后z轴被翻转成为了视口朝外的轴。摄像机裁剪空间从锥视体变成了长方体,x、y轴则成为了视口平面上的平面轴,z轴上的坐标成了顶点前后关系的深度值。我们再来看看这幅图片:

	缺图

正是因为空间坐标的转换和最后的的归一化,使得所有顶点的坐标都在一个长方体空间内,长方体的大小被限制在了-1到1的大小,于是x、y成为了屏幕相对参考坐标,而z则成为了前后关系的深度参考值。我们说的这些只是顶点上的坐标变化,在后面的步骤中三角面被光栅化,每个像素加入了更多信息形成了片元,三角形顶点信息在插值后z坐标也进入了片元数据中,于是z值成为了片元在深度测试中判断的依据。

如果片元很幸运冲破前面这么多种测试终于来到了第五步混合阶段,混合阶段实质上并没有丢弃任何片元,但却可以让片元消失不见。

如果一个片元通过了上面所有的测试,那么它就有资格与当前帧缓存中的内容进行混合了。最简单的混合方式就是直接覆盖已有的颜色缓冲中的值,实际上这样不算混合,只是覆盖而已,我们需要两个片元的真正混合。

那么什么叫混合?为什么要混合?混合是两个像素对颜色和alpha值的计算过程,其中一个像素来自要渲染的物体,另一个像素则来自已经写入的帧缓存。我们可以自己指定的操作来制定混合公式,通过配置的公式运算我们能得到想要的效果。

由于渲染物体的像素都是一个接一个的被写入缓冲中,当前物体网格被光栅化成为片元后要写入缓存时,前面已经渲染的物体的片元会被直接覆盖掉,这样的话前后两个片元就没有了任何关联性的操作,开启混合则可以对前后两个片元在颜色上做更多的操作,这可能是我们所期望的。

大多数情况混合与像素的 alpha 值有关,也可以只与颜色有关,混合阶段用的最多的是半透明材质。alpha 是颜色的第四个分量,OpenGL中片元的颜色都会带有 alpha 无论你是否需要它,无论是否你显性地设置了它,alpha默认为1不透明,我们可以用它实现各种半透明物体的模拟就像有色玻璃那样。

说白了混合就是当前物体的片元与前面渲染过的物体的片元之间的操作,那么混合具体有哪些操作呢?我们来看下Unity3D中的混合指令:

	Blend SrcFactor DstFactor

	Blend SrcFactor DstFactor, SrcFactorA DstFactorA

这里有两种操作方式,第一种是混合时颜色包括了alpha,第二种是将颜色和Alpha分开混合。其中 SrcFactor 这个因子(变量)会与刚刚通过测试的物体片元(即当前物体片元)上的颜色相乘,DstFactor 这个因子(变量)则会取已经在帧缓存中的片元(即缓存中的像素)的颜色相乘,类似的 SrcFactorA 这个因子(变量)会与刚刚通过测试的物体片元(即当前物体片元)上的 alpha 相乘,而 DstFactorA 这个因子(变量)会取帧缓存中的像素并与之 alpha相乘。

这个过程有两个步骤,第一步是相乘操作,第二步是相乘后的两个结果再相加(还可以选择其他操作模式)。我们称为混合方程
	即 Src * SrcFactor + Dst * DstFactor

	或 SrcColor * SrcFactor + DstColor * DstFactor, SrcAlpha * SrcFactorA + DstAlpha * DstFactorA

通常情况下相乘的结果再相加得到最终的混合片元。我们也可以改变这种方程式,让两个结果相减、或者调换位置后相减、取得最大值函数、取得最小值函数,来代替源数据与目标数据之间的操作符。即我们可以选择以下操作符:

	BlendOp Add 加法
	BlendOp Sub 减法
	BlendOp RevSub 置换后相减
	BlendOp Min 最小值
	BlendOp Max 最大值

上述5种操作符的修改就分别代表了因子相乘后相加、或相减、或置换后相减、或取得最小值、或取得最大值。我们这里拿Sub,Max来举个例子:

	当写入BlendOp Sub时,方程式就变成了:

	Src * SrcFactor - Dst * DstFactor

	当写入BlendOp Max时,方程式就变成了:

	Max(Src * SrcFactor, Dst * DstFactor)

除了操作符可以变化外,SrcFactor、DstFactor、SrcFactorA、DstFactorA 这四个变量的可以选择为:

	One 	代表1,就相当于完整的一个数据
	Zero	代表0,就相当于抹去了整个数据
	SrcColor	代表当前刚通过测试的片元上的颜色(即当前物体片元),相当于乘以当前物体片元的颜色
	SrcAlpha	代表当前刚通过测试的片元上的alpha(即当前物体片元),相当于乘以当前物体片元的alpha
	DstColor	代表已经在缓存中的颜色,相当于乘以当前缓存颜色
	DstAlpha	代表已经在缓存中的alpha,相当于乘以当前缓存alhpa
	OneMinusSrcColor	代表缓存上的片元做了 1 - SrcColor 的操作,再相乘
	OneMinusSrcAlpha	代表缓存上的片元做了 1 - SrcAlpha 的操作,再相乘
	OneMinusDstColor	代表当前刚通过测试的片元上的颜色做了 1 - DstColor 的操作,再相乘
	OneMinusDstAlpha	代表当前刚通过测试的片元上的颜色做了 1 - DstAlpha 的操作,再相乘

通过操作符号的选择,以及变量因子的选择,我们可以在Blend混合中玩出很多花样来。

SrcFactor、DstFactor、SrcFactorA、DstFactorA 这4个变量因子的选择和操作符的选择决定了混合后的效果,我们来看看常用的混合方法和效果:

1, 透明度混合Blend SrcAlpha OneMinusSrcAlpha,即常用半透明物体的混合方式。

这是最常用的半透明混合,首先要保证半透明绘制的顺序比实体的要后面,所以Queue标签是必要的Tags {“Queue” = “Transparent”}。Queue标签告诉着色器此物体是透半透明物体排序。至于渲染排序Queue的前因后果将在后面的文章介绍。

blend

Blend SrcAlpha OneMinusSrcAlpha 我们来解释下,以上图为例,图中油桶是带此Shader的混合目标。

当绘制油桶时,后面的实体BOX已经绘制好并且放入屏幕里了,所以ScrAlpha与油桶渲染完的图像相乘,部分区域Alpha为0即相乘后为无(颜色),这时正好另一部分由OneMinusSrcAlpha(也就是1-ScrAlpha)为1即相乘后原色不变,两个颜色相加后就相当于油桶的透明部分叠加后面实体Box的画面,于是就形成了上面的这幅画面。

反过来也是一样,当ScrAlpha为1时,源图像为不透明状态,则两个颜色在相加前最终变成了,源图像颜色+无颜色=源图像颜色,于是就有了上图中油桶覆盖实体Box的图像部分。

2,加白加亮叠加混合 Blend One One,即在原有的颜色上叠加屏幕颜色更加白或亮。

blend

第一参数One代表本物体的颜色。第二个参数代表缓存上的颜色。两种颜色没有任何改变并相加,导致形成的图像更加亮白。这样我们就看到了一个图像加亮加白的图像。

3,保留原图色彩Blend One Zero,即只显示自身的图像色彩不加任何其他效果。

blend

本物体颜色,加上,零,就是本物体颜色。

4,自我叠加(加深)混合Blend SrcColor Zero,即源图像与源图像自我叠加。

blend

与上面相比,加深了本物体的颜色。先是本物体的颜色与本物体的颜色相乘,加深了颜色,第二个参数为零,使得缓冲中的颜色不被使用。所以形成的图像为颜色加色的图像。

5,目标源叠加(正片叠底)混合Blend DstColor SrcColor,即把目标图像和源图像叠加显示。

blend

第一个参数,本物体颜色与缓存颜色相乘,颜色叠加。第二个参数,缓存颜色与本问题颜色相乘,颜色叠加。两种颜色相加,加亮加白。这个混合效果就如同两张图像颜色叠加后的效果。

6,软叠加混合Blend DstColor Zero,即把刚测试通过的图像与缓存中的图像叠加。

blend

与前面的叠加混合效果相似,这个只做一次叠加,并不做颜色相加操作,使得图像看起来在叠加部分并没有那么亮白的突出。因为第二个参数为零,表示后面的屏幕颜色与零相乘即为零。

7,差值混合BlendOp Sub,Blend One One,即注重黑白通道的差值。

blend

在这个混合中使用了混合操作改变,从默认的加法改成了减法,使得两个颜色从加法变为了减法,不再是变白变亮的操作,而是反其道成为了色差的操作。

除了对源片元和目标片元,相乘再相加的操作,还可以改变相乘后的加法操作。比如减法,取最大值,取最小值等。
Blend混合如同 Photoshop 中对图层操作,Photoshop中每个图层都可以选择混合模式,混合模式决定了该层与下层图层的混合结果,而我们看到的都是是混合后的图片。

逻辑操作

在像素混合结束后,片元将被写入缓存中去,在写入缓冲前还会做一次逻辑操作,这是片元的最后一个操作。它作用于当前刚通过测试的片元和当前帧缓存中的数据之间,逻辑操作会在它们之间进行一次操作,最后再写入到帧缓存。

由于这个过程的实现代价对于硬件来说是非常低廉的,因此很多系统都会允许这种做法。逻辑操作不再有因子,只在两个像素之间操作,操作可以选择为,异或(XOR)操作,与(AND)操作,或(OR)等。只是由于它使用的比较少,也可以由其他方式代替,因此Unity3D中并没有自定义设置逻辑操作的功能。

双缓冲机制

片元最后都会以像素的形式写入帧缓冲中,帧缓冲一边由GPU不断写入,一边由显示器不断输出,这会导致画面还没形成前就绘制到屏幕的情况,所以GPU通常采用双缓冲机制,即前置缓存用于呈现画面,后置缓存则继续由GPU不断写入,写入所有像素后再置换两个缓存,置换时只要置换指针地址就可以了。

当整个画面绘制完成时,后置缓冲与前置缓冲进行调换,于是后置缓存成为了前置缓冲并呈现到屏幕上,原来的前置缓存成为了后置缓冲交由GPU作为帧缓冲继续绘制下一帧,这样就可以保证显示与绘制不会相互干扰。

整个渲染管线已经全部呈现在这里了,我们来总结一下。

整个渲染管线从大体上分,应用阶段,几何阶段,光栅化阶段。

从应用阶段开始,数据在应用阶段被记录、筛选(或者也可以叫裁剪)、合并。在筛选、合并中,有些运用了算法来达到裁剪的目的,有些放大了颗粒度以使得加速筛选(裁剪),有些则利用了GPU工作原理合并了渲染数据提高了GPU工作效率。

几何阶段则着重于处理顶点的数据,顶点着色器是其中最为重要的一个着色器,它不但需要计算顶点在空间上的转换,还要为下一个阶段光栅化阶段做了充分的准备。在顶点着色器中,计算和记录了片元着色器需要的数据,这些数据都会被放入顶点(图元)数据中,顶点上数据会在下一个阶段经过插值计算后放入片元中,每个像素上的数据都是三角形顶点上的数据做插值后所得。

光栅化阶段主要任务是将三角形面转化为实实在在的像素,并且根据顶点上的数据做插值得到片元信息,一个片元就相当于一个像素附带了很多插值过的顶点信息。片元着色器在光栅化阶段起了重要的作用,它为我们提供了可自定义计算片元颜色的可编程节点,不但如此,我们还可以根据自己的喜好抛弃(discard)某些片元。

片元在片元着色器计算后还需要经过好几道测试才能最终呈现在画面上,包括判断片元前后覆盖的深度测试,可以自定义条件的模板测试,以及常用来做半透明的像素混合操作,片元只有经过这几道’关卡‘才能最终被写入帧缓存中,双缓冲机制使得GPU可以尽情的写缓存而无需关心是否会因为写了一半被呈现到画面的问题。

关于渲染管线我们讲解了很多,但还是有很多很多细节被忽略,我们会在后面的章节中详细为大家解剖,这些细节可能会根据各个图形编程接口(OpenGL和DirectX)背后的实现原理而来,也可能是某种渲染方法和技巧其背后的原理。
Unity3D为我们封装了很多东西,使得我们能很快的上手去运用,但也由于太方便导致其本身隔离了很多原理上的知识,使得我们在面对底层问题时会感到迷茫。本书虽然不是致力于Shader的教学,但也将尽最大的努力使读者们从根本上理解GPU的工作原理,以及引擎背后的理论知识,从而面对工作上的困难时能一眼看透问题的本质,从根本上解决问题。
· 书籍著作, Unity3D, 前端技术

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    《Unity3D高级编程之进阶主程》第七章,渲染管线与图形学(二) - 渲染管线2

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解

    QQ交流群: 777859752 (高级程序书友会)