本文继续对《UnityShader入门精要》——冯乐乐 第九章 更复杂的光照 9.1节进行学习
在Unity里,渲染路径(Rendering Path)决定了光照是如何应用到Unity Shader中的。因此,如果要和光源打交道,我们需要为每个Pass指定它使用的渲染路径,只有这样才能让Unity知道“哦,原来这个程序员想要用这种渲染路径,那么好的,我把光源和处理后的光照信息都放在这些数据里,你可以访问啦”也就是说,我们只有为Shader正确的选择和设置了需要的渲染路径,该Shader的光照计算才能被正确执行。
Unity支持多种类型的渲染路径,在Unity5.0版本之前,主要有三种:
- 前向渲染路径(Forward Rendering Path)
- 延迟渲染路径(Deferred Rendering Path)
- 顶点照明渲染路径(Vertex Lit Rendering Path)。
但在Unity5.0版本以后,Unity做了很多修改,主要有两个变化:首先,顶点照明渲染路径已经被Unity抛弃(但目前仍然可以对之前使用了顶点照明渲染路径的UnityShader兼容);其次,新的延迟渲染路径代替了原来的延迟渲染路径(同样,目前也提供了对较旧版本的兼容)。
一、设置
1.Graphics Setting
大多数情况下,一个项目只能使用一种渲染路径,因此我们可以为整个项目渲染时的渲染路径。我们可以通过在Unity的Edit->Project Settings->Player->Other Settings->Rendering Path中选择项目所需的渲染路径。默认情况下,该设置选择的是前向渲染路径。
注:Unity2019已经找不到冯乐乐说的设置,而是挪到了Graphics当中的Tier Settings部分:
2.camera Rendering Path
有时,我们希望可以使用多个渲染路径,例如摄像机A渲染的物体使用前向路径,而摄像机B渲染的物体使用延迟渲染路径。这时,我们可以在每个摄像机的渲染路径设置中设置该摄像机使用的渲染路径,以覆盖Project Settings中的设置,如图所示:
在上面的设置中,如果选择了Use Player Settings,那么这个摄像机就会使用Project Settings中的设置;否则就会覆盖掉Project Settings中的设置。需要注意的是,如果当前的显卡并不支持所选择的渲染路径,Unity会自动使用更低一级的渲染路径。例如,如果一个GPU不支持延迟渲染,那么Unity就会使用前向渲染。
完成了上面的设置后,我们就可以在每个Pass中使用标签来指定该Pass使用的渲染路径。这是通过设置Pass的LightMode标签来实现的。不同类型的渲染路径可能会包含多种标签设置。例如,我们之前在代码中写的:
Pass{
Tags{"LightMode"=''ForwardBase''}
}
上面的代码告诉Unity,该Pass使用前向渲染路径中的ForwardBase路径。而前向渲染路径还有一种路径叫做ForwardAdd。下表给出了Pass的LightMode标签支持的渲染路径设置选项。
那么指定渲染路径到底有什么用呢?如果一个Pass没有指定任何渲染路径会有什么问题吗?
通俗来讲,指定渲染路径是我们和Unity的底层渲染引擎的一次重要的沟通。例如,如果我们为一个Pass设置了前向渲染路径的标签,相当于会告诉Unity:“嘿,我准备使用前向渲染了,你把那些光照属性都按前向渲染的流程给我准备好,我一会儿要用”。随后,我们可以通过Unity提供的内置光照变量来访问这些属性。
如果我们没有指定任何的渲染路径(实际上,在Unity5.x版本中如果使用了前向渲染又没有为Pass指定任何前向渲染适合的标签,就会被当成一个和顶点照明渲染路径等同的Pass),那么一些光照变量很可能不会被正确赋值,我们计算出的效果很有可能是错误的。
二、前向渲染Forward
前向渲染路径是传统的渲染方式,也是我们最常用的一种渲染路径。在本节,我们首先会概括前向渲染路径的原理,然后再给出Unity对于前向渲染路径的实现细节和要求,最后给出UnityShader中哪些变量是用于前向渲染路径的。
1.前向渲染路径的原理
每进行一次完整的前向渲染,我们需要渲染该对象的渲染图元,并计算两个缓冲区中的信息:一个是颜色缓冲区,一个深度缓冲区。我们利用深度缓冲区来决定一个片元是否可见,如果可见就更新颜色缓冲区中的颜色值。我们可以使用下面的伪代码来描述前向渲染路径的大致过程:
Pass{
for(each primitive in this model){
for(each fragment covered by this primitive){
if(failed in depth test){
//如果没有通过深度测试,说明该片元是不可见的
discard;
}
else{
//如果该片元可见
//就进行光照计算
float4 color = Shading(materialInfo,pos,normal,lightDir,viewDir);
//更新帧缓冲
writeFrameBuffer(fragment,color);
}
}
}
}
对于每个逐像素光源,我们都需要进行上面一次完整的渲染流程。如果一个物体在多个逐像素光源的影响区域内,那么该物体就需要执行多个Pass,每个Pass计算一个逐像素光源的光照结果,然后在帧缓冲中把这些光照结果混合起来得到最终的颜色值。
假设场景中有N个物体,每个物体受M个光源的影响,那么渲染整个场景需要N*M个Pass。可以看出,如果有大量的逐像素光照,那么需要执行的Pass数目也会很大。因此渲染引擎通常会限制每个物体的逐像素光照数目。
2.Unity中的前向渲染
事实上,一个Pass不仅仅可以用来计算逐像素光照,它也可以用来计算逐顶点等其它光照。这取决于光照计算所处流水线阶段以及计算时使用的数学模型。当我们渲染一个物体时,Unity会计算哪些光源照亮了它,以及这些光源照亮该物体的方式。
在Unity中,前向渲染路径有三种处理光照(即照亮物体)的方式:
- 逐顶点处理
- 逐像素处理
- 球谐函数(Spherical Harmonics,SH)处理。
而决定一个光源使用哪种处理模式取决于它的类型和渲染模式。
- 光源类型指的是该光源是平行光还是其他类型光源
- 而光源的渲染模式指的是该光源是否是重要的(Important)。
如果我们把一个光照的模式设置为Important,意味着我们告诉Unity,嘿老兄,这个光源很重要,我希望你可以认真对待它,把它当成一个逐像素光源来处理 我们可以在光源的Light组件中设置这些属性,如下图所示:
在前向渲染中,当我们渲染一个物体时,Unity会根据场景中各个光源的设置以及这些光源对物体的影响程度(例如,距离该物体的远近、光源强度等)对这些光源进行一个重要度排序。其中,一定数目的光源会按逐像素的方式处理,然后最多有4个光源按逐顶点的方式处理,剩下的光源可以按SH方式处理。Unity使用的判断规则如下。
- 场景中最亮的平行光总是按逐像素处理的
- 渲染模式被设置成Not Important的光源,会按逐顶点或者SH处理
- 渲染模式被设置成Important的光源,会按逐像素处理。
- 如果根据以上规则得到的逐像素光源数量小于Quality Setting中的逐像素光源数量(Pixel Light Count),会有更多的光源以逐像素的方式进行渲染。
那么,在哪里进行光照计算呢?当然是在Pass里。前面提到过,前向渲染有两种Pass:BasePass和Additional Pass。通常来说,这两种Pass进行的标签渲染设置以及常规光照计算如图所示:
上图有几点需要说明的地方
(1)首先,可以发现在渲染设置中,我们除了设置Pass的标签外,还使用了#pragma multi_compile_fwdbase
这样的编译指令。虽然#pragma multi_compile_fwdbase
和#pragma multi_compile_fwdadd
在官方文档中还没有给出相关说明,但实验表明,只有分别为BasePass和Additional Pass使用这两个编译指令,我们才能在相关的Pass中得到一些正确的光照变量,例如光照衰减值等。
(2)Base Pass旁边的注释给出了Base Pass中支持的一些光照特性。例如在Base Pass中我们可以访问光照纹理(lightmap)
(3)Base Pass中渲染的平行光默认是支持阴影的(如果开启了光源的阴影功能),而Additional Pass中渲染的光源在默认情况下是没有阴影效果的,即便我们在它的Light组件中设置了有阴影的Shadow Type。但我们可以在Additional Pass中使用#pragma multi_compile_fwdadd_fullshadows代替#pragma multi_compile_fwdadd编译指令,为点光源和聚光灯开启阴影效果,但这需要在Unity内部使用更多的Shader变种。
(4)环境光和自发光也是在Base pass中计算的。这是因为对于一个物体来说,环境光和自发光我们只希望计算一次即可,而如果我们在Additional Pass中计算这两种光照,就会造成叠加多次环境光和自发光,这不是我们想要的。
(5)在Additional Pass的渲染设置中,我们还开启和设置了混合模式。这是因为我们希望每个Additional Pass可以与上一次的光照结果在帧缓存中进行叠加,从而得到最终有多个光照的渲染效果。如果我们没有开启和设置混合模式,那么Additional Pass的渲染结果会覆盖掉之前的渲染结果,看起来好像该物体只受该光源的影响。通常情况下,我们选择的混合模式是Blend One One
(6)对于前向渲染来说,一个UnityShader通常会定义一个Base Pass(Base Pass也可以被定义多次,例如需要双面渲染的情况)以及一个Additional Pass。一个Base Pass仅会执行一次(定义了多个Base Pass的情况除外),而一个Additional Pass会根据影响该物体的其他逐像素光源数目被多次调用,即每个逐像素光源会执行一次Additional Pass。
上图给出的光照计算是通常情况下我们在每种Pass中进行的计算。实际上,渲染路径的设置用于告诉Unity该Pass在前向渲染路径中的位置,然后底层的渲染引擎会进行相关计算并填充一些内置变量(如_LightColor0等),如何使用这些内置变量进行计算完全取决于开发者的选择。例如我们完全可以利用Unity提供的内置变量在Base Pass中只进行逐顶点光照;同样,我们也可以完全在Additional Pass中按逐顶点的方式进行光照计算,不进行任何逐像素计算。
3.内置的光照变量和函数
前面说过,根据我们使用的渲染路径(即Pass标签中LightMode的值),Unity会把不同的光照变量传递给Shader。在Unity5中,对于前向渲染(即LightMode为ForwardBase或ForwardAdd)来说,下表给出了我们可以在Shader中访问到的光照变量。
我们在以前已经给出了一些可以用于前向渲染路径的函数,例如WorldSpaceLightDir、UnityWorldSpaceLightDir和ObjSpaceLightDir。为了完整性,我们在下面再次列出前向渲染中可以使用的内置光照函数。
需要说明的是,上面给出的变量和函数并不是完整的,一些前向渲染可以使用的内置变量和函数官方文档并没有给出说明。在后面的学习中,我们会使用到一些不在这些表中的变量和函数,那时我们会特别说明。
三、顶点照明渲染Vertex Lit
顶点照明渲染路径是对硬件配置要求最少、运算性能最高,但同时也是得到的效果最差的一种类型,它不支持那些逐像素才能得到的效果,例如阴影、法线映射、高精度的高光反射等。实际上,它仅仅是前向渲染的一个子集,也就是说,所有可以在顶点照明渲染路径中实现的功能都可以在前向渲染路径中完成。就如它的名字一样,顶点照明渲染路径只是使用了逐顶点的方式来计算光照,并没有什么神奇的地方。实际上,我们在上面的前向渲染路径中也可以计算一些逐顶点的光源。但如果选择使用顶点照明渲染路径,那么Unity会只填充那些逐顶点相关的光源变量,意味着我们不可以使用一些逐像素光照变量。
1.Unity中的顶点照明渲染
顶点照明渲染路径通常在一个Pass中就可以完成对物体的渲染。在这个Pass中,我们会计算我们关心的所有光源对该物体的照明,并且这个计算是按逐顶点处理的。这是Unity中最快速的渲染路径,并且具有最广泛的硬件支持(但游戏机上并不支持这种路径)
2.可访问的内置变量和函数
在Unity中,我们可以在一个顶点照明的Pass中最多访问到8个逐顶点光源。如果我们只需要渲染其中两个光源对物体的照明,可以仅使用下表中内置光照数据的前两个。如果影响该物体的光源数目小于8,那么数组中剩下的光源颜色会设置成黑色。
可以看出,一些变量我们同样可以在前向渲染路径中使用,例如unity_LightColor。但这些变量数组的维度和数值在不同渲染路径中的值是不同的。
下表给出了顶点照明渲染路径中可以使用的内置函数。
四、延迟渲染Deferred
前向渲染的问题是:当场景中包含大量实时光源时,前向渲染的性能会急速下降。例如,如果我们在场景的某一块区域放置了多个光源,这些光源影响的区域互相重叠,那么为了得到最终的光照效果,我们就需要为该区域内的每个物体执行多个Pass来计算不同光源对该物体的光照结果,然后在颜色缓存中把这些结果混合起来得到最终的光照。然而,每执行一个Pass我们都需要重新渲染一遍物体,但很多计算实际上是重复的。
延迟渲染是一种更古老的渲染方法,但由于上述前向渲染可能会造成的瓶颈问题,近几年又流行起来。除了前向渲染中使用的颜色缓冲和深度缓冲外,延迟渲染还会利用额外的缓冲区,这些缓冲也被称为G缓冲(G-buffer),其中G是英文Geometry的缩写。G缓冲区存储了我们所关心的表面(通常指的是离计算机最近的表面)的其他信息,例如该表面的法线、位置、用于光照计算的材质属性等。
1.延迟渲染的原理
延迟渲染主要包含了两个Pass。在第一个Pass中,我们不进行任何光照计算,而是仅仅计算哪些片元是可见的,这主要是通过深度缓冲技术来实现,当发现一个片元是可见的,我们就把它的相关信息存储到G缓冲区中。然后,在第二个Pass中,我们利用G缓冲区的各个片元信息,例如表面法线、视角方向、漫反射系数等,进行真正的光照计算。
延迟渲染的大致过程可以用下面的伪代码来描述:
Pass1{
//第一个Pass不进行真正的光照计算
//仅仅把光照计算需要的信息存储到G缓存中
for(each primitive in this moadel){
for(each fragment covered by this primitive){
if(failed in depth test){
//如果没有通过深度测试,说明该片元是不可见的
discard;
}else{
//如果该片元可见
//就需要把信息存储到G缓冲区中
writeGBuffer(materialInfo,pos,normal,lightDir,viewDir);
}
}
}
}
Pass2{
//利用G缓冲区中的信息进行真正的光照计算
for(each pixel in the screen){
if(the pixel is valid){
//如果该像素是有效的
//读取它对应的G缓冲中的信息
readBuffer(pixel,materialInfo,pos,normal,lightDir,viewDir);
//根据读取到的信息尽行光照计算
float4 color=shading(materialInfo,pos,normal,lightDir,viewDir);
//更新帧缓冲
writeFrameBuffer(pixel,color);
}
}
}
可以看出,延迟渲染使用的Pass数目通常就是两个,这跟场景中包含的光源数目是没有关系的。换句话说,延迟渲染的效率不依赖于场景的复杂度,而是和我们使用的屏幕空间大小有关。这是因为,我们需要的信息都存储在缓冲区中,而这些缓冲区可以理解成是一张张2D图像,我们的计算实际上就是在这些图像空间中进行的。
2.Unity中的延迟渲染
Unity有两种延迟渲染路径,一种是遗留的延迟渲染路径,即Unity5之前使用的延迟渲染路径,而另一种是Unity5.x中使用的延迟渲染路径。如果游戏中使用了大量的实时光照,那么我们可能希望选择延迟渲染路径,但这种路径需要一定的硬件支持。
新旧延迟渲染路径之间的差别很小,只是用了不同的技术来权衡不同的需求。例如,较旧版本的延迟渲染路径不支持Unity5的基于物理的Standard Shader。以下我们只讨论Unity5后使用的延迟渲染路径。
对于延迟渲染路径来说,它最适合在场景中光源数目很多、如果使用前向渲染会造成性能瓶颈的情况下使用。而且,延迟渲染路径中的每个光源都可以按照逐像素的方式处理。但是,延迟渲染也有一些缺点。
- 不支持真正的抗锯齿(anti-aliasing)功能。
- 不能处理半透明物体
- 对显卡有一定要求。如果要使用延迟渲染的话,显卡必须支持MRT(Multiple Render Targets)、Shader Mode3.0及以上、深度渲染纹理以及双面的模板缓冲。
当使用延迟渲染时,Unity要求我们提供两个Pass。
- 第一个Pass用于渲染G缓冲。在这个Pass中,我们会把物体的漫反射颜色、高光反射颜色、平滑度、法线、自发光和深度等信息渲染到屏幕空间的G缓冲区中。对于每个物体来说,这个Pass仅会执行一次。
- 第二个Pass用于计算真正的光照模型。这个Pass会使用上一个Pass中渲染的数据来计算最终的光照颜色,再存储到帧缓冲中。
默认的G缓冲区(注意,不同Unity版本的渲染纹理存储内容会有所不同)包含了以下几个渲染纹理(Render Texture,RT)。
- RT0:格式是AGRB32,RGB通道用于存储漫反射颜色,A通道没有被使用
- RT1:格式是AGRB32,RGB通道用于存储高光反射颜色,A通道用于存储高光反射的指数部分。
- RT2:格式是ARGB2101010,RGB通道用于存储法线,A通道没有使用
- RT3:格式是ARGB32(非HDR)或ARGBHalf(HDR),用于存储自发光+lightmap+反射探针(reflection probes)
当在第二个Pass计算光照时,默认情况下仅可以使用Unity内置的Standard光照模型。如果我们想要使用其它的光照模型,就需要替换掉原有的Internal-DefferedShading.shader文件。
3.可访问的内置变量和函数
下表给出了处理延迟渲染路径可以使用的光照变量。这些变量都可以在UnityDefferedLibrary.cginc文件中找到它们的声明。
4.选择哪种渲染路径
Unity的官方文档给出了4种渲染路径(前向渲染路径、延迟渲染路径、遗留的延迟渲染路径和顶点照明渲染路径)的详细比较,包括它们的性能比较(是否支持逐像素光照、半透明物体、实时阴影等)、性能比较以及平台支持。
总体来说,我们需要根据游戏发布的目标平台来选择渲染路径。如果当前显卡不支持所选渲染路径,那么Unity会自动使用比其低一级的渲染路径。
在本书中,我们主要使用Unity的前向渲染路径。
5.延迟渲染的不足之处
延迟渲染有什么不足?一般也会有过得去的答案:1、不能用硬件AA(抗锯齿)。2、透明渲染不行。
能够这么回答的,应该都看过了,有准备的。这个时候,要判断是不是有真功夫,就要问更深入的了。例如可以直接问,除了这两个不足,还有其他的吗?这个时候,如果直接说没有的,那么这个就是教科书答案。如果能回答出:占用了大量的显存带宽的,那么显然水平更高一筹,估计是实战过的,又或者看得更多。如果还能更深入的说出:延迟渲染只能用同一套lighting pass,那么,要么是取巧到了极致,要么是大概率有真才实学的了。
延迟渲染的不足之处
- 显而易见,延迟渲染需要一个临时缓冲区GBuffer,这样就会多浪费空间
- 又因为GBuffer的存在,延迟渲染不适合MSAA抗锯齿算法,因为多重采样需要使用更大的缓存空间,是GBuffer的四倍或更高,这样一般显卡都没办法提供那么大的空间,因此不适应MSAA。
- 延迟渲染无法处理混合,即渲染透明物体,因为G缓冲中所有的数据都是从一个单独的片段中来的,而混合需要对多个片段的组合进行操作。
- 延迟渲染只能对所有模型做统一的光照着色,使用一套光照算法。
1、不能使用硬件AA(MSAA),这个,我在其他地方应该讲过,render to texture,是不能用MSAA的,这个跟AA的原理有关。所以使用了延迟渲染之后,UE4只支持FXAA跟TXAA。
2、不支持透明物体的渲染。为什么呢?很简单啊,因为之前渲染Mesh的时候,是一个一个迭代的,一个一个做深度测试之类的,但是,延迟渲染要先渲染到Gbuffer。可想而知,Gbuffer只是把当前能看到的像素记录下来,但是透明的,同一个像素点,可能需要记录更多!所以,延迟渲染的时候,一般都是先渲染非透明的Mesh,后续再单独渲染透明Mesh。
3、占用大量的显存带宽。这个其实也好理解,说穿了,就是显存的读写,是非常受限的,我应该在“认识显卡”章节,有单独介绍过显存位宽的计算。现在主流DDR5显卡,带宽大概是200GBit好像,假设渲染窗口是1920 * 1080 * 4(RGBA)* 4(MRT)* 8(Byte to bit) * 60(FPS),那么,光是这个Gbuffer,占用的带宽,我算了一下,就是15G。这其实已经是极大的消耗,在台式机还能勉强承受,在移动端设备,我估计就这个就坑逼了。
4、只能使用同一个光照pass。这个很好理解,因为如果是一个一个mesh迭代,我一个mesh用一个material,里面用自己的光照算法。另外一个mesh我不想用光照,直接一个白板pass,当然是可以的。但是,渲染到Gbuffer之后,你其实已经不知道哪个像素点属于哪个Mesh了,自然就只能使用同一套光照算法了。这个为什么一般的书里都不提呢,主要是大多数时候,本来一个场景的光照算法就是要统一的,不统一的反而是少数。能理解到这一步,证明至少是深入思考过的。