《Unity3D高级编程之进阶主程》第七章,渲染管线与图形学(三) - 渲染原理与知识1

前面的几篇非常详尽的讲述了渲染管线的整个流程以及渲染管线上的每个节点的来龙去脉。这节我们来说说,一些渲染概念和原理,以及上几章中对渲染管线上没有说到的细节,或者在现代GPU中已经被优化的流程。

===

为什么要有渲染顺序

前面章节中我们介绍了深度测试这个模块,它用片元的深度值与深度缓存中的值比较得到测试结果,再决定是否要写入深度缓存中,如果判断失败则抛弃片元不再继续下面的流程。这其中涉及到了 ZTest On/Off 状态开关,和,ZWrite On/Off 状态开关,其中ZTest 用于控制是否开启测试,ZWrite 用于控制是否写入深度缓存。

渲染管线中的深度测试节点最大的好处是帮助我们尽早的发现不需要渲染的片元,并抛弃它们以节省GPU消耗提高效率。

其实大部分情况下我们都使用 ZTest LEqual 做判断,即离摄像机越近的物体越容易遮挡住离得远的物体。

从这个角度看渲染机制,如果能先把离屏幕近的物体放前面渲染,那么后面的物体虽然不能完全在CPU层面判定它是否被掩盖而剔除,但能在深度测试的机制下早早的抛弃掉很多片元,会提升不少的GPU效率。

于是渲染顺序就成了关键,Unity3D引擎对所有不透明物体在渲染前做了排序工作,即离摄像机近的排在前面渲染,离的远的排在后面渲染,这个渲染队列又有了新的排序规则。

那么半透明物体怎么办呢?因为半透明物体需要Blend混合,ZWrtie开关一般都是关的,因为如果开启来的话半透明部分在深度测试时就变成了完全的遮挡,从而没有了半透的效果。因此它需要在所有不透明物体渲染后再渲染才能真正发挥出它半透明的效果。

在Unity3D引擎在提交渲染时增加了这么条规则,即对所有半透明物体的渲染都排在了不透明物体的后面,这样就确保了半透明物体能在不透明物体渲染完毕后才开始渲染,以保证半透明物体的渲染效果。

那么怎么标记物体是不透明还是半透明呢,Unity3D引擎为了解决这个问题,将渲染顺序放在Shader中实现,即 Queue 标签来决定我们的模型将归于哪个渲染队列。

Unity3D在内部使用了一系列整数索引来表示渲染的次序,且索引越小越表示排在前面被渲染。

Queue 标签:

    Background,索引号1000

    Geometry,索引号2000

    AlphaTest,索引号2450

    Transparent,索引号3000

    Overlay,索引号4000

Shader中我们选择任意Queue标签就会指定那个索引类型,Unity3D还有一个规则是2500以下的索引号,当多个物体拥有相同索引号时,排序规则以根据摄像机的距离由近到远顺序渲染,如果是2500索引号以上的包括2500,当多个物体拥有相同索引号时,则排序规则以根据摄像机的距离由远到近顺序渲染(与2500以下的物体相反)。

为什么要这么排序呢?因为2500以下物体都是不透明物体,渲染在深度测试阶段越早剔除掉越好,所以对摄像机由近及远的渲染方式对早早的剔除不需要渲染的片元有莫大的帮助,提高GPU效率。而2500索引以上的物体,通常都是半透明物体或者置顶的物体(例如UI),如果依然保持由近到远的渲染规则,半透明物体就无法混合被它覆盖的物体。因此2500索引及以后的物体与2500索引以前的物体,在相同索引号时渲染排序的规则是相反的。

半透明的排序问题通常是头疼的,为什么呢?因为前面我们说的它是需要由blend混合完成半透明部分的操作,而blend操作必须在前面物体已经绘制好的条件下才能有blend混合后成为半透明或全透明效果。

Queue在Transparent半透明索引号下,相同索引号是从远到近渲染的,在粗糙颗粒的排序上还是可以解决的,即两个物体模型没有相交部分,前后关系的blend混合是可以依靠模型中点离摄像机的远近做排序的,Unity3D引擎也是这么做的。但是如果两个物体相交,或者本物体中模型有前后叠加关系时则无法再区分片元的前后关系了。

因此使用Blend混合做半透明物体,通常情况下很难做到前后关系有秩序,特别是当模型物体有交集的时候。而且通常都采用手动排序,例如在Queue上+1,即Tag{ Queue = "Transparent+1" } 的形式,这也是为什么很多特效Shader要有好几个一模一样Shader功能只是Queue不同。

所有的渲染顺序都是引擎自主排列的,而不是由GPU排序的,GPU只知道渲染、测试、裁切,完全不会去管物体的前后次序,这也是为什么称它叫“流水线”的原因,它就像工厂里的作业流水线一样,每个工人都只是一个节点的螺丝钉,他们很多时候只要记住一个动作就可以“无脑”的重复劳动,GPU里也是一样。

Alpha Test

上面和前几章讲了好多关于半透明物体的知识,而Alpha Test其实也是属于半透明物体的特征,不过它不是混合,而是裁切。

我们在制作模型过程中,很多模型的边角都需要极其细微的面片,比如树上的叶子,一堆乱糟糟的草,还有许许多多圆形的洞等,这些如果用网格模型来制作的话会多出很多很多面片,制作时间长,调整起来慢,同屏面数高,这些问题滚滚而来。

怎么办呢,Alpha Test能很好的解决这些问题,Alpha Test 用纹理图片中的 Alpha 来测试判定该片元是否需要绘制,即当我们展示一些很细节的模型时,用一张图片和两三个面片就能代替巨量的面片制作方式。如果有需要调整的地方,也只是需要调整纹理图片和少量顶点就可以完成工作。

这种方式被大量用在节省面片渲染数量上,因为它的制作简单,调整容易,被众多开发人员所喜爱。

其渲染的过程也比较简单,在片元着色器中判断该片元 Alpha 值是否小于了某个阈值,一旦判定小于某个阈值就调用clip或者discard丢弃该片元,该片元流水线停止。

我们来开如图所示:

    缺图

图中这些小草都只是一个个面片,GPU在渲染片元时会先去判定该片元的 Alpha 是否小于某个阀值,如果小于则不渲染该片云,否则继续渲染。

这种方式的裁剪片元对于只需要不透明和全透明的物体来说很好用,而且 Alpha Test 不需要混合,它完全可以开启 ZTest 的深度测试,和 ZWrite 的深度写入,在渲染遮挡问题上完全没有问题。

不过它并不是万能的,也存在很多缺陷,我们下面就要讲讲在现代GPU中它的问题。

Early-Z GPU硬件优化技术

前面说了ZTest 深度测试在片元着色器之后做了片元(即像素带些信息)之间的前后遮挡测试,使得GPU对哪些片元需要绘制又有哪些片元被遮挡而不需要绘制有了依据。

不过深度测试只是在所有片元都基本成型时才做的测试,使得大部分被遮挡的片元在被剔除时就已经经过一轮的着色器计算,这使得当片元重叠遮挡比较多时导致了大量的GPU算力的浪费。

这种情况常常发生,特别是在摄像机需要渲染更多物体的时候,遮挡的情况会越来越严重,每个物体生成的片元无论是否被遮挡都会被经过一次差不多是一整个的渲染流程,那么这时的ZTest 深度测试前的渲染计算就几乎全部浪费掉了宝贵的GPU算力。

Early-Z 技术就专门为这种情况做了优化,我们可以称它为前置深度测试。由于渲染管线中,ZTest 深度测试其实发生在片元着色器之后,这时候再进行深度测试时,就是对所有渲染对象的像素都计算一遍,几乎没有性能提升,仅仅是为了得出正确的遮挡结果,造成大量的无用计算算力浪费,因为每个像素点上重叠了许许多多次计算。
因此现代GPU中运用了Early-Z的技术,在几何阶段与片元着色器之间(光栅化之后,片元着色器之前)先进行一次ZTest 深度测试,如果深度测试失败,就跳过片元阶段的计算,节省了大量的GPU算力。

那么具体它是怎么个流程呢,我们来看看如下图:

    Early-Z--|
    |        |no
    |yes     |
    |    片元着色计算
    |        |
    |        |
    ZTest 深度测试 -- 抛弃
    |
    |
    屏幕像素缓冲

上图中展示了Early-Z 前置深度测试的流程,当光栅化后的片元先进入Early-Z 前置深度测试阶段,如果片元测试被遮挡,则直接跳过片元着色计算,如果没有被遮挡则继续片元着色的计算,无论是否通过Early-Z 前置深度测试,最终都会汇集到ZTest 深度测试再测试一次,由ZTest来最终觉得是否抛弃该片云,由于前置测试已经测试完毕了片元的前后关系,因此所有跳过片元着色计算的片元都会在ZTest 节点被抛弃,反之则会继续渲染流程最终进入屏幕像素缓冲区。

Early-Z的实现是GPU硬件自动调用的,它主要是通过两个pass来实现,即第一个是Z-pre-pass,对于所有写入深度数据的物体,先用一个超级简单的pass不写入像素缓存,只写深度缓存,第二个pass关闭深度写入,开启深度测试,用正常渲染流程进行渲染。

由于我们在片元着色器中可以自主的抛弃片元,因此问题又出现了。

片元在着色器中被主动抛弃后,Early-Z 前置深度测试的结果就会出现问题,因为如果可见片元被抛弃后,被它遮挡的片元就成为了可见片元,导致前置的深度测试结果失效。

因此GPU在优化算法中,对片元着色器抛弃片元和修改深度值的操作做了检测,如果在片元着色器中存在抛弃片元和改写片元的操作,则Early-Z 将被放弃使用。

简单来说,Early-Z 对遮挡处理做了很大的优化,但是如果我们使用了Alpha Test 来渲染物体时要注意,Early-Z 的优化功能将被弃用。同样的在修改深度值时也要引起注意,Early-Z也同样会被关闭。

Mipmap的原理

Mipmap是目前应用最为广泛的纹理映射技术之一。Mip来源于拉丁文中的multum in parvo,意思是“在一个小区域里的很多东西”。Mipmap技术与材质贴图技术结合,根据物体距摄像机远近距离的不同,以不同分辨率的纹理贴图,将单一的材质贴图以多重图像的形式表现出来。

Mipmap功能在3D游戏中非常常见,但很多人还是不太了解Mipmap的来龙去脉,我们在这里详细的讲一讲。

在我们为物体渲染纹理贴图时,经常会出现物体离摄像机很远的情况,屏幕像素与纹理之间的比率会变得非常低,因此纹理采样的频率也会变得非常低,这样会导致渲染图像上的瑕疵。我们举例来说:

    如果要渲染一面墙,假设这面墙纹理有 1024 x 1024 这样的大小,当摄像机距离墙很近时渲染的图像是没有问题的,因为每个像素都有各自对应的纹理贴图上合理的像素。但是当摄像机向这面墙渐渐远离,直到它在屏幕上变成一个像素点时就出现问题了,因为纹理采样的结果可能会在某个过度点上发生突然的变化导致图像产生瑕疵。

    特别是在屏幕上不断前后运动的物体可能会使得屏幕上渲染产生闪烁的问题。

Mipmap为了降低这个效果的影响,对纹理贴图进行了提前的滤波,并且将滤波后的图像存储为连续的不同分辨率的纹理贴图。OpenGL在开启Mipmap后会自动判断当前应当使用纹理贴图的哪层分辨率贴图,判断的依据是基于物体在屏幕上渲染的像素大小来决定的。

除了能更好的平滑渲染远近物体像素上的瑕疵和闪烁问题外,Mipmap还能很好的提高采样的效率,由于采用从已经缓存的不同分辨率纹理的采样对象,那些远离摄像机的物体采用了更小分辨率的纹理贴图,使得采样时宽带的使用降低从而获得更高的效率,其实大部分物体都离摄像机较远,这使得Mipmap的采样效率提升在渲染中发挥了重要的作用。

一般在使用Mipmap的时候,OpenGL会负责计算细节层次并得到Mipmap层级的结果,再将采样结果返回给用户。不过我们也可以自己取代这个计算过程再通过OpenGL纹理获取函数(textureLod)来选取指定的纹理层次。

那么在OpenGL中到底 Mipmap 是怎么决定采用哪层分辨率的贴图的呢?我们来详细的讲解一下。

这里有2个概念要复习一下:

    1.屏幕上的颜色点叫像素,纹理贴图上的颜色点叫纹素。

    2.屏幕坐标系我们用的是XY坐标系,纹理贴图坐标系用的是UV坐标系。

在片元着色器中,每个片元即屏幕空间XY上的像素都会找到对应的纹理贴图中的纹素来确定像素的颜色。

这个查找纹素的过程就是一个从XY空间到UV空间的一个映射过程。我们可以通过分别求x和y偏导数来求屏幕单个像素宽度纹理坐标的变化率。

在屏幕上,某区域上的像素,对应到实际的纹理贴图中可能是一个长方形的区域。

那么x轴方向上的纹理贴图大小和屏幕上的像素区域大小有一个比例,y轴方向上的也同样有一个比例。

例如,获取到的纹理贴图上的纹素大小为 64x64,屏幕上的像素区域大小为32x32,那么它们在x轴上的纹素和像素大小比例为 2.0 (即64/32),y轴上的也同样是 2.0 (即64/32)。又比如,纹理贴图上的纹素大小为 64x32,屏幕上的像素区域大小为 8x16,那么它们在x轴上的纹素和像素大小比例为 8.0(即64/8),在y轴上的纹素和像素大小比例为2.0(即32/16)。

这个比例就是纹素的覆盖率,当物体离摄像机很远时,纹素的覆盖率就很大,当物体离摄像机很近时则很小,甚至小于1(当纹素覆盖率小于1时则会调用纹理放大滤波器,反之则用到了Mipmap,如果刚好等于1则使用原纹理)。

在着色器中我们可以用ddx和ddy求偏导的方式分别求这个两个方向上的覆盖率,然后取较大的覆盖率。

为什么ddx和ddy偏导函数就能计算覆盖率呢,这里稍微复习一下。我们知道在光栅化的时刻,GPU会在同一时刻并行运行很多片元着色器,但是并不是一个像素一个像素的去执行,而是将其组织成 2x2 为一组的像素块,再去并行执行。而偏导数就正好能计算这一块像素中的变化率。

我们来看下偏导的真相:

    ddx(p(x,y)) = p(x+1,y) - p(x,y)

    ddy(p(x,y)) = p(x,y+1) - p(x,y)

x轴上的偏导就是 2x2 像素块中 x轴方向上附近的数值之差。同理,y轴上的偏导就是 2x2 像素块中 y轴方向上附近的数值之差。

因此MipMap层级的计算可以描述为

float MipmapLevel(float2 uv, float2 textureSize)
{
    float dx = ddx(uv * textureSize.x);
    float dy = ddy(uv * textureSize.y);
    float d = max(dot(dx, dx), dot(dy, dy));  
    return 0.5 * log2(d);
} 

求出x轴和y轴方向上的覆盖率后,取最大值d(dot(dx,dx)其实就是dx的平方),再log2后获得Mipmap层级,这里0.5是技巧,本来应该是d的平方。

大部分时候OpenGL已经帮我们做了Mipmap层级的计算,也就是说我们在Shader中使用tex2D(tex, uv)获取颜色的时候就相当于在GPU内部展开成了如下面所示:

tex2D(sampler2D tex, float4 uv)
{
    float lod = CalcLod(ddx(uv), ddy(uv));
    uv.w= lod;
    return tex2Dlod(tex, uv);
}

这里的意思是uv所求的导数越大,在屏幕中占用的纹理范围就越大。简单来说就是我们在片元计算中发现uv导数很大时,就说明这个片元离摄像机很远,从这个方面来理解uv的在片元着色器中的求偏导就可以明白,我们只需要通过uv的求偏导就能间接计算出x轴和y轴方向的覆盖率。

在OpenGL中Mipmap的计算其实依赖于片元中的uv求偏导值,也就是说,片元所映射的uv范围越大,计算出来的Mipmap层级越高,纹理贴图选取的分辨率越小。

显存的存在

显存经常被我们忽视,因为近几年流行的都是手机端的游戏项目,其实它在PC端存在的意义很大。一个显卡除了有图像传给处理单元GPU外,还拥有自己的内存,即显存VRAM(Video Random Access Memory)。

GPU可以在显存中存储任何数据,例如图像缓存、深度缓存、模板缓存、着色器实例等。

除了这几个必要的缓存外,显卡中还存放着渲染时需要用到的贴图纹理、顶点缓存等,这些内容都是需要从CPU内存中拷贝过来的,在调用渲染前,应用程序可以调用图形应用接口OpenGL将数据从普通内存中拷贝到显卡内存中,这个过程只存在于PC端和主机端,因为它们都拥有显卡的存在。

因此PC端在渲染前还有进一步拷贝数据的过程,显存并不多即使现代显存成本变低的情况下仍然捉襟见肘。

手机端就没有这样的拷贝过程。手机端大都是ARM架构,芯片中嵌入了各种各样的硬件系统,包括SoC(即芯片级系统,包含了完整系统并有嵌入软件的全部内容)、图像处理GPU、音频等。而显存由于种种限制没有被设计加入到ARM中去,因此在手机端中CPU和GPU共用同一个内存控制器,也就是说CPU和GPU是共用内存带宽的,没有独立显存只有普通内存,不过即便这样GPU也有自己的独立内存部分,例如上面的缓存和后面的可写数据的拷贝都需要一份独立的“共享显存”。

手机端没有独立显存,因此OpenGL ES就设计了映射缓冲区对象,它在不需要拷贝内存的前提下可以通过共享内存来实现数据的提取。如果贴图或顶点数据是只读状态的话完全没有必要为渲染而重新拷贝一份,而是可以选择建立起映射缓冲区对象,直接读取内存来获取数据。不过也有不做建立映射缓冲区对象的时候,当数据、纹理被开启可写状态时则必须重新拷贝一份在GPU独立内存中,因为原数据随时会被CPU更改,而当时可能GPU还在渲染中,如果仍然并行处理同一个数据则会造成显示问题,因此两个并行的脑袋不能共享一份可写的数据。

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

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

    《Unity3D高级编程之进阶主程》第七章,渲染管线与图形学(三) - 渲染原理与知识1

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号