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

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

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

===

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

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

第一个可见性测试就是剪切测试(Scissor),它主要针对的是片元是否在矩形范围内的测试判断,如果片元不在矩形范围内则被丢弃。

这个范围是一个矩形的区域,我们称它为剪切盒。实际上可以有很多个剪切盒存在,只是默认情况下所有渲染测试都在第一个剪切盒上完成,要访问其他剪切盒就需要几何着色器。

Unity3D并没有开放这个剪切测试的功能,它的实际应用比较少。

第二步是多重采样的片元操作。

普通采样只采一个样本或者说一个像素,而多重采样泽是分散取得多个样本,这些样本可能是附近的几个位置也可能是其他算法。因此在多重采样中,每个片元都有多种颜色,多个深度值和多组纹理坐标,而不是只有一种(具体有多少个取决于子像素的样本数目)。

如果没有开启多重采样的片元操作,多重采样在计算片元的覆盖比例时,不会考虑alpha值的影响。

一旦开启多重采样的片元操作,我们就可以用片元的alpha值来影响采样的覆盖率计算。

Unity3D的没有开放自定义设置多重采样的片元操作功能,但像素本身不是单单只是它本身的颜色和深度,而是由附近的像素一起决定的。具体的多重采样内容将在后面的章节中讲述。

前两步可能并没有引起我们足够的重视,但后面几步需要我们着重学习。

第三步模板测试(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 //如果深度测试失败时对模板缓存的操作
    }

模板测试的步骤简单明了,当前值与模板缓冲比较,通过的做一个指定的操作替换或者增加或者减少等,不通过的片元被抛弃。

上述Unity3D模板命令中 Pass 的操作就是通过测试后的操作种类,它有如下几种方式可选:

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

不同的物体有不同的引用值,每个比较操作都可以不一样,包括掩码值、成功后的操作动作,一个简简单单的比大小其实也能玩出这么多花样来。不止如此,除了比较和通过后的操作指令外,深度测试失败时还可以影响模板缓存中的值。我们用一幅图就能理解模板测试的美妙:

    缺图

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

如果片元幸运的通过了模板测试,则会来到第四步,深度测试。有没觉得这个片元很像某生理教课书上描述精子通过重重困难终于来到了卵子的的位置那样,课本上总是这么介绍,刚好片元也是如此。

深度测试主要目的是将被覆盖的片元丢弃,或者说将需要覆盖的片元绘制在屏幕上,其实这两个操作都是根据物体渲染的前后关系操作的。

深度测试工作分为两块,一块是比较即ZTest,一块是写入即ZWrite,只有比较并被判定通过的才有写入的资格,我们也可以把写入关了,让物体无法写入,这就会导致它片元深度无法与其他物体比较,这种写法在半透明中很常用,其他时候大部分都是默认开启深度值写入即ZWrite On。

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

与模板测试不同的是深度测试用的是深度值,而不是固定某个值,写入缓存也不没有那么多花样。

那么什么是深度值?这个深度值是从哪来的?

还记得前面顶点着色器中介绍的,顶点在变化坐标空间后z轴被翻转成为了视口前方的轴么。整个锥视体变成了立方体,x、y轴则成为了视口平面上的平面方向轴,原来的z轴转换成了顶点前后关系的深度值。我们再来看看这幅图片:

    缺图

是的,正是因为那次坐标系的转换和后面的归一化,使得所有顶点的坐标都在一个立方体的空间内,且这个立方体的大小被限制在了-1到1的大小,x、y成为了屏幕相对参考坐标,而z则成为了前后关系的深度参考值。

虽然这只是顶点上的坐标,但在后面的步骤中三角面被光栅化,并对每个像素背后形成了片元,每个片元根据三角形顶点信息的插值后z坐标也进入了片元中,这就是我们需要的片元的深度值。

深度值在片元中成为了片元在深度测试中判断的依据,如果输入片元的深度值即z值可以通过制定的深度测试环节,那么它就可以替换当前深度缓存中已经有的深度值(如果ZWrite 没有被关闭的话)。

深度测试的判断依据也有很多种,我们来看下它有多少中判断种类:

    ZTest Less | Greater | LEqual | GEqual | Equal | NotEqual | Always

上述中表达了所有判断种类,从左到右为,小于缓存中的就通过,大于缓存中的就通过,小于等于缓存中的就通过,大于等于缓存中的就通过,等于缓存中的就通过,不等于缓存中的就通过,以及总是通过与缓存无关。其中LEqual 最为常用,即越接近摄像机的越需要覆盖其他面片,越远离摄像机的越容易被覆盖或者说越容易通不过深度测试。

我们可以更加白话一点表达深度测试,一般情况下都是用LEqual 作为深度测试判断依据,即哪个片元离摄像机越近就用哪个,其他种类的类型判断类型也可以解释的类似与摄像机距离有关。

如果片元很幸运冲破前面这么多种测试终于来到了第五步混合阶段,这一步从测试上来说其实是最后一个阶段。

混合阶段实质上并没有丢弃任何片元,但却可以让片元消失不见。

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

那么什么叫混合?为什么要混合?

混合是两个片元的从颜色上和alpha值上相加或相乘的算法过程,通过我们自己指定的数学公式来确定混合后的像素颜色,这个公式不复杂,其实就是颜色和因子的加减乘的基本运算,通过这种运算我们能得到想要的效果。

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

大多数情况混合与片元的 alpha 值有关,但不是硬性要求一定要与alpha有关,也可以只与颜色有关,只是这种类型比较多而已。

alpha 是颜色的第四个分量,OpenGL中片元的颜色都会带有 alpha 无论你是否需要它,无论是否你显性地设置了它,alpha默认为1不透明。

但是 alpha 太抽象,我们无法从肉眼看到它,只能在脑袋中想象它。alpha 代表了片元的透明程度,是颜色的第四个分量,我们可以用它实现各种半透明物体的模拟就像有色玻璃那样。

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

    Blend SrcFactor DstFactor

    Blend SrcFactor DstFactor, SrcFactorA DstFactorA

这里有两种操作方式,第一种是混合颜色包括alpha,第二种是分开混合即,RGB颜色和RGB颜色混合(alpha不参与)用一种方式,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混合可以玩出很多花样来。

这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中每个图层都可以选择混合模式,混合模式决定了该层与下层图层的混合结果,而我们看到的都是是混合后的图片。

逻辑操作

在混合结束后,片元将被写入缓存中去,在写入缓冲中去时,还有一步逻辑操作,这是片元的最后一个操作。

它作用于当前刚通过测试的片元数据以及当前颜色缓存中的数据,在它们之间进行一次操作,再写入到缓存中覆盖原来的数据。

由于这个过程的实现代价对于硬件来说是非常低廉的,因此很多系统都允许这种做法。这种逻辑操作的方式很像上面的混合(Blend),但是更加简单,不再有因子,只是两种颜色之间的数字操作,例如,XOR异或操作,AND与操作,OR或操作等。

Unity3D中并不能自定义设置逻辑操作,这里不重点讲解。

双缓冲机制

片元最后都会以像素颜色的形式写入缓冲中,但是如果只有一个缓冲,那么我们会时常见到绘制的中间状态即图形的形成过程,这对画面呈现效果很不友好,所以GPU通常采用双缓冲机制,即前置缓存用于呈现画面,而后置缓存则继续由GPU继续工作。

当整个画面绘制完成时,后置缓冲与前置缓冲进行调换,这时后置缓存可以成为了前置缓冲并呈现在屏幕上,而原来的前置缓存则成为后置缓冲交由GPU作为缓冲内存继续绘制下一帧,由此我们可以保证看到的图像是连续的最终状态。

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

整个渲染管线从大体上分,应用阶段,几何阶段,光栅化阶段。渲染数据从应用阶段生成开始。

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

几何阶段着重于处理顶点的数据,顶点着色器是其中最为重要的一个着色器,它不但需要计算顶点在空间上的转换,还要为下一个阶段光栅化阶段做准备。

在顶点着色器中,计算和记录了片元着色器计算颜色需要的数据,这些数据都会被放入顶点(图元)数据内,这些数据在下一个阶段会被做插值后放入片元中。

光栅化阶段主要任务是将三角形面转化为实实在在的像素,并且根据顶点上的数据做插值得到片元信息,一个片元就相当于一个像素附带了很多插值过的顶点信息。

片元着色器在光栅化阶段起了重要的作用,它为我们提供了自定义计算片元颜色的可编程节点,不但如此,我们还可以根据自己的喜好抛弃某些片元。

除了片元着色器外,片元在片元着色器后还需要经过好几道测试才能最终呈现在画面上,包括判断片元前后顺序的深度测试,可以自定义条件的模板测试,以及常用来做半透明的像素混合,片元只有经过这几道关卡才最终被写入缓存中。

我们讲解了很多,但还是有很多很多细节被忽略,我们会在后面的章节中详细为大家解剖,这些细节可能在各个图形编程接口(OpenGL和DirectX)的实现中不尽相同,但大体上都是运用了同一种原理和概念。

Unity3D为我们封装了很多东西,使得我们能很快的上手去运用,但也屏蔽了很多原理上的知识,使得我们在面对底层原理时感到迷茫。本书虽然不是致力于Shader的教学,但也将尽最大的努力使读者们从根本上理解GPU的工作原理,从而在面对工作上的困难时能一眼看透问题的本质,从根本上解决麻烦并优化效率。

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

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

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

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号