Unity Shader-渲染队列,ZTest,ZWrite,Early-Z

简介

在渲染阶段,引擎所做的工作是把所有场景中的对象按照一定的策略(顺序)进行渲染。最早的是 画家算法,顾名思义,就是像画家画画一样,先画后面的物体,如果前面还有物体,那么就用前面的物体把物体覆盖掉,不过这种方式由于排序是针对物体来排序的,而物体之间也可能有重叠,所以效果并不好。所以目前更加常用的方式是 z-buffer 算法,类似颜色缓冲区缓冲颜色, z-buffer 中存储的是当前的深度信息,对于每个像素存储一个深度值,这样,我们屏幕上显示的每个像素点都会进行深度排序,就可以保证绘制的遮挡关系是正确的。而控制 z-buffer 就是通过 ZTest,和 ZWrite 来进行。但是有时候需要更加精准的控制不同类型的对象的渲染顺序,所以就有了渲染队列。今天就来学习一下渲染队列,ZTest,ZWrite 的基本使用以及分析一下 Unity 为了 Early-Z 所做的一些优化。

Unity中的几种渲染队列

首先看一下 Unity 中的几种内置的渲染队列,按照渲染顺序,从先到后进行排序, 队列数越小的,越先渲染,队列数越大的,越后渲染

  • Background(1000) 最早被渲染的物体的队列。
  • Geometry(2000) 不透明物体的渲染队列。大多数物体都应该使用该队列进行渲染,也是 Unity Shader 中默认的渲染队列。
  • AlphaTest(2450) 有透明通道,需要进行 Alpha Test 的物体的队列,比在 Geomerty 中更有效。
  • Transparent(3000) 半透物体的渲染队列。一般是不写深度的物体,Alpha Blend 等的在该队列渲染。
  • Overlay(4000) 最后被渲染的物体的队列,一般是覆盖效果,比如镜头光晕,屏幕贴片之类的。

Unity 中设置渲染队列也很简单,我们不需要手动创建,也不需要写任何脚本,只需要在 Shader 中增加一个 Tag 就可以了,当然,如果不加,那么就是 默认的渲染队列 Geometry。比如我们需要我们的物体在 Transparent 这个渲染队列中进行渲染的话,就可以这样写:

1
Tags { "Queue" = "Transparent"}

我们可以直接在 Shader 的 Inspector 面板上看到 Shader 的渲染队列:

另外,我们在写 Shader 的时候还经常有个 Tag 叫 RenderType,不过这个没有 Render Queue 那么常用,这里顺便记录一下:

  • Opaque: 用于大多数着色器(法线着色器、自发光着色器、反射着色器以及地形的着色器)。
  • Transparent:用于半透明着色器(透明着色器、粒子着色器、字体着色器、地形额外通道的着色器)。
  • TransparentCutout: 蒙皮透明着色器(Transparent Cutout,两个通道的植被着色器)。
  • Background: 天空盒着色器。
  • Overlay: GUITexture,镜头光晕,屏幕闪光等效果使用的着色器。
  • TreeOpaque: 地形引擎中的树皮。
  • TreeTransparentCutout: 地形引擎中的树叶。
  • TreeBillboard: 地形引擎中的广告牌树。
  • Grass: 地形引擎中的草。
  • GrassBillboard: 地形引擎何中的广告牌草。

相同渲染队列中不透明物体的渲染顺序

拿出 Unity,创建三个立方体,都使用默认的 bump diffuse Shader(渲染队列相同),分别给三个不同的材质(相同材质的小顶点数的物体引擎会动态合批),用 Unity5 带的 Frame Debugger 工具查看一下 Draw Call。(Unity5 真是好用得多了,如果用 4 的话,还得用 NSight 之类的抓帧)

可以看出,Unity 中对于不透明的物体,是采用了从前到后的渲染顺序进行渲染的,这样,不透明物体在进行完 vertex 阶段,进行 Z Test,然后就可以得到该物体最终是否在屏幕上可见了,如果前面渲染完的物体已经写好了深度,深度测试失败,那么后面渲染的物体就直接不会再去进行 fragment 阶段。(不过这里需要把三个物体之间的距离稍微拉开一些,本人在测试时发现,如果距离特别近,就会出现渲染次序比较乱的情况,因为我们不知道 Unity 内部具体排序时是按照什么标准来判定的哪个物体离摄像机更近,这里我也就不妄加猜测了)

相同渲染队列中半透明物体的渲染顺序

透明物体的渲染一直是图形学方面比较蛋疼的地方,对于透明物体的渲染,就不能像渲染不透明物体那样多快好省了,因为透明物体不会写深度,也就是说透明物体之间的穿插关系是没有办法判断的,所以半透明的物体在渲染的时候一般都是采用从后向前的方法进行渲染,由于透明物体多了,透明物体不写深度,那么透明物体之间就没有所谓的可以通过深度测试来剔除的优化,每个透明物体都会走像素阶段的渲染,会造成大量的 Over Draw。这也就是粒子特效特别耗费性能的原因。

我们实验一下 Unity 中渲染半透明物体的顺序,还是上面的三个立方体,我们把材质的 Shader 统一换成粒子最常用的 Particle/Additive 类型的 Shader,再用 Frame Debugger 工具查看一下渲染的顺序:

半透明的物体渲染的顺序是从后到前,不过由于半透相关的内容比较复杂,就先不在这篇文章中说了,打算另起一篇。

自定义渲染队列

Unity 支持我们自定义渲染队列,比如我们需要保证某种类型的对象需要在其他类型的对象渲染之后再渲染,就可以通过自定义渲染队列进行渲染。而且超级方便,我们只需要在写 Shader 的时候修改一下渲染队列中的 Tag 即可。比如我们希望我们的物体要在所有默认的不透明物体渲染完之后渲染,那么我们就可以使用 Tag{“Queue” = “Geometry+1”} 就可以让使用了这个 Shader 的物体在这个队列中进行渲染。

还是上面的三个立方体,这次我们分别给三个不同的 Shader ,并且渲染队列不同,通过上面的实验我们知道,默认情况下,不透明物体都是在 Geometry 这个队列中进行渲染的,那么不透明的三个物体就会按照 cube1,cube2,cube3 进行渲染。这次我们希望将渲染的顺序反过来,那么我们就可以让 cube1 的渲染队列最大,cube3 的渲染队列最小。贴出其中一个的 Shader :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Shader "Custom/RenderQueue1" {

SubShader
{
Tags { "RenderType"="Opaque" "Queue" = "Geometry+1"}

Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct v2f
{
float4 pos : SV_POSITION;
};

v2f vert(appdata_base v)
{
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
return o;
}

fixed4 frag(v2f i) : SV_Target
{
return fixed4(0,0,1,1);
}
ENDCG
}
}
//FallBack "Diffuse"
}

其他的两个 Shader 类似,只是渲染队列和输出颜色不同。

通过渲染队列,我们就可以自由地控制使用该 Shader 的物体在什么时机渲染。比如某个不透明物体的像素阶段操作较费,我们就可以控制它的渲染队列,让其渲染更靠后,这样可以通过其他不透明物体写入的深度剔除该物体所占的一些像素。

PS:这里貌似发现了个问题,我们在修改 Shader 的时候一般不需要什么其他操作就可以直接看到修改后的变化,但是本人改完渲染队列后,有时候会出现从 Shader 的文件上能看到渲染队列的变化,但是从渲染结果以及 Frame Debugger 工具中并没有看到渲染结果的变化,重启 Unity 也没有起到作用,直到我把 Shader 重新赋给材质之后,变化才起了效果…(猜测是个bug,因为看到网上还有和我一样的倒霉蛋被这个坑了,本人的版本是 5.3.2,害我差点怀疑昨天是不是喝了,刚实验完的结果就完全不对了…)

ZTest(深度测试)和ZWrite(深度写入)

上一个例子中,虽然渲染的顺序反了过来,但是物体之间的遮挡关系仍然是正确的,这就是 z-buffer 的功劳,不论我们的渲染顺序怎样,遮挡关系仍然能够保持正确。而我们对 z-buffer 的调用就是通过 ZTest 和 ZWrite 来实现的。

首先看一下 ZTest,ZTest 即深度测试,所谓测试,就是针对当前对象在屏幕上(更准确的说是 frame buffer)对应的像素点,将对象自身的深度值与当前该像素点缓存的深度值进行比较,如果通过了,本对象在该像素点才会将颜色写入颜色缓冲区,否则否则不会写入颜色缓冲。ZTest 提供的状态较多。 ZTest Less(深度小于当前缓存则通过, ZTest Greater(深度大于当前缓存则通过),ZTest LEqual(深度小于等于当前缓存则通过),ZTest GEqual(深度大于等于当前缓存则通过),ZTest Equal(深度等于当前缓存则通过),ZTest NotEqual(深度不等于当前缓存则通过),ZTest Always(不论如何都通过)。注意,ZTest Off 等同于 ZTest Always,关闭深度测试等于完全通过。

下面再看一下 ZWrite,ZWrite 比较简单,只有两种状态, ZWrite On(开启深度写入)和ZWrite Off(关闭深度写入)。当我们开启深度写入的时候,物体被渲染时针对物体在屏幕(更准确地说是frame buffer)上每个像素的深度都写入到深度缓冲区;反之,如果是 ZWrite Off,那么物体的深度就不会写入深度缓冲区。但是,物体是否会写入深度,除了 ZWrite 这个状态之外,更重要的是需要深度测试通过,也就是 ZTest 通过,如果 ZTest 都没通过,那么也就不会写入深度了。就好比默认的渲染状态是 ZWrite On 和 ZTest LEqual,如果当前深度测试失败,说明这个像素对应的位置,已经有一个更靠前的东西占坑了,即使写入了,也没有原来的更靠前,那么也就没有必要再去写入深度了。所以上面的 ZTest 分为通过和不通过两种情况,ZWrite 分为开启和关闭两种情况的话,一共就是四种情况:

  1. 深度测试通过,深度写入开启:写入深度缓冲区,写入颜色缓冲区;
  2. 深度测试通过,深度写入关闭:不写深度缓冲区,写入颜色缓冲区;
  3. 深度测试失败,深度写入开启:不写深度缓冲区,不写颜色缓冲区;
  4. 深度测试失败,深度写入关闭:不写深度缓冲区,不写颜色缓冲区;

Unity 中默认的状态(写 Shader 时什么都不写的状态)是 ZTest LEqual 和 ZWrite On,也就是说默认是开启深度写入,并且深度小于等于当前缓存中的深度就通过深度测试,深度缓存中原始为无限大,也就是说离摄像机越近的物体会更新深度缓存并且遮挡住后面的物体。如下图所示,前面的正方体会遮挡住后面的物体:

写几个简单的小例子来看一下 ZTest,ZWrite 以及 Render Queue 这几个状态对渲染结果的控制。

让绿色的对象不被前面的立方体遮挡,一种方式是关闭前面的蓝色立方体深度写入:

通过上面的实验结果,我们知道,按照从前到后的渲染顺序,首先渲染蓝色物体,蓝色物体深度测试通过,颜色写入缓存,但是关闭了深度写入,蓝色部分的深度缓存值仍然是默认的 Max,后面渲染的绿色立方体,进行深度测试仍然会成功,写入颜色缓存,并且写入了深度,因此蓝色立方体没有起到遮挡的作用。

另一种方式是让绿色强制通过深度测试:

这个例子中其他立方体的 Shader 使用默认的渲染方式,绿色的将 ZTest 设置为 Always,也就是说不管怎样,深度测试都通过,将绿色立方体的颜色写入缓存,如果没有其他覆盖了,那么最终的输出就是绿色的了。

那么如果红色的也开了 ZTest Always 会怎么样?

在红色立方体也用了 ZTest Always 后,红色遮挡了绿色的部分显示为了红色。如果我们换一下渲染队列,让绿色在红色之前渲染,结果就又不一样了:

更换了渲染队列,让绿色的渲染队列+1,在默认队列 Geometry 之后渲染,最终重叠部分又变回了绿色。可见,当 ZTest 都通过时,上一个写入颜色缓存的会覆盖上一个,也就是说最终输出的是最后一个渲染的对象颜色。

再看一下 Greater 相关的部分有什么作用,这次我们其他的都使用默认的渲染状态,绿色的立方体 Shader 中 ZTest 设置为 Greater:

这个效果就比较好玩了,虽然我们发现在比较深度时,前面被蓝色立方体遮挡的部分,绿色的最终覆盖了蓝色,是想要的结果,不过其他部分哪里去了呢?简单分析一下,渲染顺序是从前到后,也就是说蓝色最先渲染,默认深度为 Max,蓝色立方体的深度满足 LEqual 条件,就写入了深度缓存,然后绿色开始渲染,重叠的部分的深度缓存是蓝色立方体写入的,而绿色的深度值满足大于蓝色深度的条件,所以深度测试通过,重叠部分颜色更新为绿色;而与红色立方体重合的部分,红色立方体最后渲染,与前面的部分进行深度测试,小于前面的部分,深度测试失败,重叠部分不会更新为红色,所以重叠部分最终为绿色。而绿色立方体没有与其他部分重合的地方为什么消失了呢?其实是因为绿色立方体渲染时,除了蓝色立方体渲染的地方是有深度信息的,其他部分的深度信息都为 Max,蓝色部分用 Greater 进行判断,肯定会失败,也就不会有颜色更新。

有一个好玩的效果其实就可以考 ZTest Greater 来实现,就是游戏里面经常出现的,当玩家被其他场景对象遮挡时,遮挡的部分会呈现出 X-光 的效果;其实是在渲染玩家时,增加了一个 Pass,默认的 Pass 正常渲染,而增加的一个 Pass 就使用 Greater 进行深度测试,这样,当玩家被其他部分遮挡时,遮挡的部分才会显示出来,用一个描边的效果渲染,其他部分仍然使用原来的 Pass 即可。

Early-Z 技术

传统的渲染管线中,ZTest 其实是在 Blending 阶段,这时候进行深度测试,所有对象的像素着色器都会计算一遍,没有什么性能提升,仅仅是为了得出正确的遮挡结果,会造成大量的无用计算,因为每个像素点上肯定重叠了很多计算。因此 现代 GPU 中运用了 Early-Z 的技术,在 Vertex 阶段和 Fragment 阶段之间(光栅化之后,fragment 之前)进行一次深度测试,如果深度测试失败,就不必进行 fragment 阶段的计算了,因此在性能上会有很大的提升。但是最终的 ZTest 仍然需要进行,以保证最终的遮挡关系结果正确。前面的一次主要是 Z-Cull 为了裁剪以达到优化的目的,后一次主要是 Z-Check,为了检查,如下图:

Early-Z 的实现,主要是通过一个 Z-pre-pass 实现,简单来说,对于所有不透明的物体(透明的没有用,本身不会写深度),首先用一个超级简单的 Shader 进行渲染,这个 Shader 不写颜色缓冲区,只写深度缓冲区,第二个pass关闭深度写入,开启深度测试,用正常的 Shader 进行渲染。其实这种技术,我们也可以借鉴,在渲染透明物体时,因为关闭了深度写入,有时候会有其他不透明的部分遮挡住透明的部分,而我们其实不希望他们被遮挡,仅仅希望被遮挡的物体半透,这时我们就可以用两个 pass 来渲染,第一个 pass 使用 Color Mask 屏蔽颜色写入,仅写入深度,第二个 pass 正常渲染半透,关闭深度写入。

关于 Early-Z 技术可以参考 ATI 的论文 Applications of Explicit Early-Z Culling以及 PPT,还有一篇 Intel 的 文章

Unity渲染顺序总结

如果我们先绘制后面的物体,再绘制前面的物体,就会造成 over draw;而通过 Early-Z 技术,我们就可以先绘制较近的物体,再绘制较远的物体(仅限不透明物体),这样,通过先渲染前面的物体,让前面的物体先占坑,就可以让后面的物体深度测试失败,进而减少重复的 fragment 计算,达到优化的目的。Unity 中默认应该就是按照最近距离的面进行绘制的,我们可以看一下 Unity 官方的文档中显示的:

从文档给出的流程来看,这个 Depth-Test 发生在 Vertex 阶段和 Fragment 阶段之间,也就是上面所说的 Early-Z 优化。
简单总结一下 Unity 中的渲染顺序: 先渲染不透明物体,顺序是从前到后;再渲染透明物体,顺序是从后到前。

Alpha Test(Discard)在移动平台消耗较大的原因

从本人刚刚开始接触渲染,就开始听说移动平台 Alpha Test 比较费,当时比较纳闷,直接 discard 了为什么会费呢,应该更省才对啊?这个问题困扰了我好久,今天来刨根问底一下。还是跟我们上面讲到的 Early-Z 优化。正常情况下,比如我们渲染一个面片,不管是否是开启深度写入或者深度测试,这个面片的光栅化之后对应的像素的深度值都可以在 Early-Z(Z-Cull)的阶段判断出来了;而如果开启了 Alpha Test(Discard)的时候,discard 这个操作是在 fragment 阶段进行的,也就是说这个面片光栅化之后对应的像素是否可见,是在 fragment 阶段之后才知道的,最终需要靠 Z-Check 进行判断这个像素点最终的颜色。其实想象一下也能够知道,如果我们开了 Alpha Test 并且还用 Early-Z 的话,一块本来应该被剃掉的地方,就仍然写进了深度缓存,这样就会造成其他部分被一个完全没东西的地方遮挡,最终的渲染效果肯定就不对了。所以,如果我们开启了 Alpha Test,就不会进行 Early-Z,Z Test 推迟到 fragment 之后进行,那么这个物体对应的 Shader 就会完全执行 vertex Shader 和 fragment Shader ,造成 over draw。有一种方式是使用 Alpha Blend 代替 Alpha Test,虽然也很费,但是至少 Alpha Blend 虽然不写深度,但是深度测试是可以提前进行的,因为不会在 fragment 阶段再决定是否可见,因为都是可见的,只是透明度比较低罢了。不过这样只是权宜之计,Alpha Blend 并不能完全代替 Alpha Test。

关于 Alpha Test 对于 Power VR 架构的 GPU 性能的影响,简单引用一下官方的 链接以及一篇 讨论帖

最后再附上两篇参考文章

http://blog.csdn.net/candycat1992/article/details/41599167
http://blog.csdn.net/arundev/article/details/7895839

原文链接:https://blog.csdn.net/puppet_master/article/details/53900568