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

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

为什么要有渲染顺序

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

渲染管线中的深度测试最大的好处是帮助我们尽早的发现不需要渲染的片元,及时抛弃它们以节省GPU开销从而提高了效率。大部分情况下我们都使用 ZTest LEqual 来做深度测试的判断,也就是离摄像机越近的物体绘制的偏远越容易遮挡住离得远的物体。从这个角度看渲染机制,如果能先把离屏幕近的物体放前面渲染,那么离屏幕远的物体则能在深度测试的机制下早早的闭屏掉很多片元的渲染,提升不少的GPU效率。

我们发现从上述角度看,渲染顺序就成了提高GPU效率的关键,Unity3D引擎对所有不透明物体在渲染前都做了排序的工作,离摄像机近的排在前面渲染,离的远的排在后面渲染,这个渲染队列就有了排序规则。

那么半透明物体怎么办呢?因为半透明物体需要Blend混合,Blend混合就需要先将不透明物体先渲染完成再做Blend混合操作,因此它通常被引擎安排在所有不透明物体渲染后才渲染,只有这样才能发挥出它半透明的效果。在半透明物体中,ZWrtie通常都是关闭状态,如果将ZWrite开启后半透明部分在深度测试时就会抛弃比它深度高的像素,这导致多个半透明物体在叠加渲染时由于深度测试而被抛弃,丢失了Blend混合的效果使得画面有点错乱,这也是半透明物体通常不开启ZWrite的原因,它的主要方式还是混合而非测试。

Unity3D引擎在提交渲染时就有这么条规则,即对所有半透明物体的渲染都排在了不透明物体的后面,这样就确保了半透明物体能在不透明物体渲染完毕后才开始渲染,以保证半透明物体的Blend混合效果。其半透明物体的队列也同样使用排序算法在渲染前排序,只是排序负责与不透明物体相反,即离摄像机越远的物体越先渲染。

那么怎么判定物体是不透明还是半透明呢,虽然Blend是半透明的特色但也不是唯一标准,不透明物体同样可以使用Blend混合增强效果。Unity3D引擎为了解决这个问题在Shader中使用了标记功能,将渲染顺序放在Shader中去标记,即用Shader中的 Queue 标签来决定我们的模型归于哪个渲染队列。

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

	Background,背景层,索引号1000

	Geometry,不透明物体层,索引号2000

	AlphaTest,AlphaTest物体层,索引号2450

	Transparent,半透明物体层,索引号3000

	Overlay,覆盖层,索引号4000

Shader中我们选择Queue标签就会指定索引类型。我们来看一个例子:

Shader "Transparent Queue Example"
{
     SubShader
     {
        Tags { "Queue" = "Transparent" }
        Pass
        {
            // rest of the shader body...
        }
    }
}

这里例子中将物体标记为半透明队列,当被标记为标记为半透明物体时,Unity3D引擎就会将这些物体放在不透明物体渲染之后做渲染。

我们前面了解到,不透明物的排序与半透明物的排序是相反的,因为半透明物需要Blend混合,必须先绘制远处的物体,这样Blend混合的效果才正确。Unity3D在渲染队列标签中,每个标签都有一个索引号,Unity3D规定2500以下的索引号,排序规则以根据摄像机的距离由近到远顺序渲染,2500索引号以上的渲染队列标号则相反,排序规根据摄像机的距离由远到近顺序渲染排列。

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

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

Shader中的Queue标签在Transparent半透明索引号下,相同索引号是从远到近渲染的,在粗糙颗粒的排序上尚可以解决部分叠加问题,即两个物体模型没有相交部分,前后关系的blend混合是可以依靠模型中点离摄像机的远近做排序的,Unity3D引擎就是这么做的。但是如果两个物体网格面片相交,或者同一个物体中面片相互交错,则无法再区分片元的前后关系了。原因是他们没有写入片元的深度值,即ZWrite为关闭状态,不能用深度值去判定片元是否覆盖或被覆盖,倘若开起来则又会出现Blend混合失效,因为片元底下的覆盖的片元被彻底抛弃了,就无从Blend混合一说了。

因此使用Blend混合做半透明物体,在复杂的半透明交叉情况下通常很难做到前后关系有秩序,特别是当模型物体有交集的时候。此时我们通常都采用手动排序的方法来纠正排序问题,例如在Queue标签上用+1的方式表明层级被优先渲染,即Tag{ Queue = “Transparent+1” } 的形式。

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

Alpha Test

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

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

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

这种方式被大量用在节省面片数量的渲染上,因为它的制作简单,调整容易,被众多模型设计师和开发人员所喜爱。其渲染的过程也相对比较简单,在片元着色器中判断该片元 Alpha 值是否小于了某个阈值,一旦判定小于某个阈值就调用clip或者discard丢弃该片元,该片元的不再进行后面的流水线。

Shader "Example Alpha Test"
{
	Properties
	{
        _MainTex ("Base (RGB)", 2D) = "white" {}
        _Cutoff("Cut off",range(0,1))=0.5
    }

    ...

    SubShader
    {
     	...

        //Alpha Test示例
		Pass
        {
            struct v2f {
                float4 pos : SV_POSITION;
                float4 uv:TEXCOORD0;
            };

        	v2f vert(appdata_base v)
        	{
        		v2f o;
        		o.pos = UnityObjectToClipPos(v.vertex); //转换顶点空间
        		o.uv = v.texcoord; // 传递uv值
        		return o;
        	}

        	fixed4 frag(v2f i) : SV_Target
        	{
        		fixed4 _color = tex2D(_MainTex,i.uv.xy); // 根据uv获取纹理上的纹素

        		//clip函数非常简单,就是检查它的参数是否小于0。如果是,就调用discard舍弃该fragment;否则就放过它。
        		clip(_color.a - _Cutoff);

        		return _color;
        	}
        }
    }

    ...
}

上述Shader剥离了干扰因素,代码极简的表现了Alpha Test。先将顶点uv从顶点着色器上传递到片元着色器上,再用uv坐标数据取出纹理中的颜色,使用clip函数判定片元是否通过测试。clip函数非常简单,就是检查它的参数是否小于0。如果是,就调用discard舍弃该fragment,否则就放过它。我们来看看用Alpha Test制作草地的画面效果:

	缺图

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

这种方式的裁剪片元对于只需要不透明和全透明的物体来说很好用,而且 Alpha Test 不需要混合,它完全可以开启 ZTest 的深度测试,和 ZWrite 的深度写入,在渲染遮挡问题上完全没有问题。不过它并不是万能的,也存在很多缺陷,我们下面就要讲讲在现代GPU中Alpha Test出现的问题。

Early-Z GPU硬件优化技术

前面我们介绍过些深度测试的知识,即深度测试在片元着色器之后对片元之间的前后做了遮挡测试,这使得GPU对哪些片元需要绘制又有哪些片元因被遮挡而不需要绘制有了数据依据。不过深度测试是在片元计算完毕后才做的测试,使得大部分被遮挡的片元在被剔除前就已经经历过了着色器的计算,这使得当片元重叠遮挡比较多时许多片元的前期计算浪费的较为严重,被遮挡部分的片元计算完就被抛弃,浪费了算力。

这种情况频繁发生,特别是在摄像机需要渲染很多物体的时候,相互叠加遮挡的情况会越来越严重,每个物体生成的片元无论是否被遮挡都经过了差不多是一整个的渲染流程,深度测试前的渲染计算几乎全部浪费掉。

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

我们来看下它的具体流程,我们来看看如下图:

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

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

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

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

片元在着色器中被主动抛弃后,Early-Z 前置深度测试的结果就会出现问题,因为测试通过的可见片元被抛弃后,被它遮挡的片元就成为了可见片元,导致前置的深度测试结果出现问题。因此GPU在优化算法中,对片元着色器抛弃片元和修改深度值的操作做了检测,如果检查到片元着色器中存在抛弃片元和改写片元深度的操作时,Early-Z 将被放弃使用。

简单来说,Early-Z 对遮挡处理做了很大的优化,但是如果我们使用了Alpha Test 来渲染物体时,Early-Z 的优化功能将被弃用。

Mipmap的原理

Mipmap是目前应用最为广泛的纹理映射技术之一,Mip来源于拉丁文中的multum in parvo,意思是“在一个小区域里的很多东西”。引擎将Mipmap技术与材质贴图技术结合,根据物体距摄像机远近距离的不同,分别使用不同分辨率的纹理贴图,不仅提高了画面效果还提高了GPU渲染效率。Mipmap功能在3D游戏中非常常见,但很多人还是不太了解Mipmap的来龙去脉,我们在这里详细的讲一讲。

	缺图(Mipmap分级生成的图)

在我们为3D物体渲染纹理贴图时,经常出现物体离摄像机很远的情况,屏幕像素与纹理大小的比率变得非常低,此时纹理采样点的变化会非常大,这样会导致渲染图像上的瑕疵。

我们举例来说,假设我们要渲染一面墙,这面墙纹理有 1024 x 1024像素的大小,当摄像机距离墙适当时渲染的图像是没有问题的,因为每个像素都有各自对应的纹理贴图上合理的像素。但是当摄像机向这面墙渐渐远离,慢慢它在屏幕上的像素范围越来越小时就出现问题了,原因是物体所呈现的像素点越来越少,这使得纹理采样的坐标变化比较大,可能会在某个过度点上发生突然的变化导致图像产生瑕疵。特别是在屏幕上不断前后运动的物体可能会使得屏幕上渲染产生类似闪烁的劣质效果。

Mipmap为了修正这种劣质效果,将纹理贴图提前存储成不同级别大小的纹理贴图,并在渲染时将它们传入OpenGL,OpenGL会判断当前应当使用纹理贴图的哪个层级大小的贴图,判断的依据是基于物体在屏幕上所渲染的像素大小决定的。

除了能更好的平滑渲染远近物体像素上的瑕疵和闪烁问题外,Mipmap还能很好的提高采样的效率,由于采用从已经缓存的不同分辨率纹理的采样对象,那些远离摄像机的物体采用了更小分辨率的纹理贴图,这使得采样时内存与GPU缓存之间传输的带宽减轻了不少压力从而获得更高的效率。实际项目中大部分物体都离摄像机较远,这使得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轴方向上的覆盖率后,再取得dx和dy的最大值(dot(dx,dx)其实就是dx的平方,同理dy),再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层级越高,纹理贴图选取的分辨率就越小。

从显存里看问题

显存经常被我们忽视,因为近几年流行的手机端的游戏项目比较多,手机设备上的没有显存的概念,它让GPU与系统共用一块内存,所以通常显存被理解为只在PC端存在。显卡除了有图像传给处理单元GPU外,还拥有自己的内存,即显存VRAM(Video Random Access Memory),像安卓和IOS这样的架构的设备中,虽然没有大块独立的显存但GPU仍然有自己的缓存。

GPU可以在显存中存储很多数据,包括贴图、网格、着色器实例等,除了这些渲染所必须的资源,缓存自然是更接近GPU内核的地方,顶点缓存、深度缓存、模版缓存、帧缓存大都存放在那里。GPU自己的缓存就相当于GPU内部的共享缓存部分,GPU中有很多个独立的处理单元,每个处理单元都有自己的缓存以存储一部分自己需要处理的数据。

除了这几个必要的缓存外,显卡中存放着渲染时需要用到的贴图纹理、网格数据等,这些内容都需要从系统内存中拷贝过来的。在调用渲染前,应用程序可以调用图形应用接口OpenGL将数据从系统内存中拷贝到显卡内存中,当然这个过程只存在于PC端和主机端,因为只有它们拥有显存。显存更接近GPU处理器,这直接导致存取数据会更快,因此从系统内存中拷贝过来是值得的。

手机端就没有这样的拷贝过程,手机端大都是ARM架构,芯片中嵌入了各种各样的硬件系统,包括SoC(即芯片级系统,包含了完整系统并有嵌入软件的全部内容)、图像处理GPU、音频处理器等。而显存由于种种限制没有被设计加入到ARM中去,因此在手机端中CPU和GPU共用同一个内存控制器,也就是说没有独立的显存只有系统内存。不过GPU仍然需要将数据拷贝到自己的缓存当中,只是这一步原本是从显存拷贝的,现在从系统内存中拷贝而已,GPU中每个处理单元也仍然要从共享缓存中拷贝自己需要处理的数据。

由此可见,GPU处理数据前拷贝的过程仍然存在,只是原来从显存拷贝到缓存的过程变成了系统内存直接拷贝到缓存,速度自然没有原来的快,这种拷贝的过程每帧都在进行,当然也有缓存命中的情况,但仍避免不了重复拷贝,图片大小、网格大小也会成为拷贝的瓶颈点,我们通常称它们为带宽压力,由此看来压缩纹理贴图、使用大小适中的纹理贴图、减少网格数据也是优化性能的一个重要部分。

· 书籍著作, Unity3D, 前端技术

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

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

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

    Copyright attention

    Please don't reprint without authorize.

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

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