Unity Shader入门精要学习笔记 - 第9章 更复杂的光照

发表于2017-09-01
评论0 5.5k浏览
更复杂的光照

在实际的游戏幵发过程中, 我们往往需要处理数目更多、 类型更复杂的光源。更重要的是, 我们想要得到阴影。在学习这些之前,我们有必要知道Unity到底是如何处理这些光源的。当我们在场景里放置了各种类型的光源后,Unity的底层渲染引擎是如何让我们在Shader中访问到它们的。如何处理更多不同类型的光源,如点光源和聚光灯。光照的衰减处理,实现距离光源越远光强越弱的效果。Unity中阴影的实现方法,并学习在UnityShader中如何为不同类型的物体实现阴影效果。

Unity 的渲染路径

在Unity里,渲染路径决定了光照是如何应该到Unity Shader 中的。因此,如果要和光源打交道,我们需要为每个Pass指定它使用的渲染路径,只有这样才能让Unity知道,“哦,原来这个程序想要这种渲染路径,那么好的,我把光源和处理后的光照信息都放在这些数据里,你可以访问啦!”也就是说,我们只有为Shader 正确地选择和设置了需要的渲染路径,该Shader的光照计算才能被正确执行。

Unity支持多种类型的渲染路径。在Unity5.0版本之前,主要有3中:前向渲染路径(Forward Rendering Path)、延迟渲染路径(Deferred Rendering Path)和顶点照明渲染路径(Vertex Lit Rendering Path)。但在Unity 5.0 版本以后,Unity 做了很多更改,主要有两个变化:首先,顶点照明渲染路径已经被Unity抛弃(但目前仍然可以对之前使用了顶点照明渲染路径的Unity Shader兼容);其次,新的延迟渲染路径代替了原来的延迟渲染路径。

大多数情况下,一个项目只使用一种渲染路径,因此我们可以为整个项目设置渲染时的渲染路径。我们可以通过在Unity 的 Edit -> Project Srtting -> Player -> Other Setting -> Rendering Path 中选择项目所需的渲染路径。默认情况下,该摄者选择的是前向渲染路径,如下图所示。

但有时,我们希望可以使用多个渲染路径,例如摄像机A渲染的物体使用前向渲染路径,而摄像机B渲染的物体使用延迟渲染路径,这时,我们可以在每个摄像机的渲染路径设置中设置该摄像机使用的渲染路径,以覆盖Projec Setting 中的设置。如下图所示。

在上面的设置中,如果渲染了Use  Player Setting,那么这个摄像机会使用Project Setting 中的设置;否则就会覆盖掉Project Setting 中的设置。需要注意的是,如果当前的显卡并不支持所选择的渲染路径,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),那么一些光照变量很可能不会被正确赋值,我们计算出的效果也就很有可能是错误的。

前向渲染路径是传统的渲染方式,也是我们最常用的一种渲染路径。

前向渲染路径原理:每进行一次完整的前向渲染,我们需要渲染该对象的渲染图元,并计算两个缓冲区的信息:一个是颜色缓冲区,一个是深度缓冲区。我们利用深度缓冲来决定一个片元是否可见,如果可见就更新颜色缓冲区中的颜色值。我们可以用下面的伪代码来描述前向渲染路径的大致过程:
Pass {
	for(each primitive in this model){
		for(each fragment coverd 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数目也会很大。因此,渲染引擎通常会限制每个物体的逐像素光照的数目。

Unity中的前向渲染:事实上,一个Pass不仅仅可以用于计算逐像素光照,它也可以用于计算逐顶点等其他光照。这取决于光照计算所处流水线阶段以及计算时使用的数学模型。当我们渲染一个物体时,Unity 会计算那些光源照亮了它,以及这些光源照亮该物体的方式。

在Unity中,前向渲染路径有3种处理光照的方式:逐顶点处理、逐像素处理、球谐函数处理。而决定一个光源使用哪种处理模式取决于它的类型和渲染模式。光源类型指的是该光源是平行光还是其他类型的光源,而光源的渲染模式指的是该光源是否是重要的。如果我们把一个光照的模式设置为Important,意味着我们告诉Unity“这个光源很重要,把它当成一个逐像素光源来处理”。我们可以在光源的Light组件中设置这些属性,如下图

在前向渲染中,当我们渲染一个物体时,Unity会根据场景中各个光源的设置以及这些光源对物体的影响程度(例如,距离该物体的远近、光源强度等)对这些光源进行一个重要度排序。其中,一定数目的光源会按逐像素的方式处理,然后最多有4个光源按逐顶点的方式处理,剩下的光源可以按sH方式处理。Unity 使用的判断规则如下。

场景中最亮的平行光总是按逐像素处理的。

渲染模式被设置成Not Important的光源,会按逐顶点或者SH处理。

如果根据以上规则得到的逐像素光源数量小于Quality Setting 中的逐像素光源数量(Pixel Light Count),会有更多的光源以逐像素的方式进行渲染。

那么,在哪里进行光照计算呢?当然是在Pass里。前面提到过,前向渲染有两种Pass:Base Pass 和 Addition Pass。通常来说,这两种Pass 进行的标签和渲染设置以及常规光照计算如下图所示。

上图有几点需要说明的地方。

首先,可以发现在渲染设置中,我们除了设置了Pass 的标签外,还使用了#pragma multi_compile_fwdbase 这样的编译指令。虽然 #pragma multi_compile_fwdbase 和 #pragma multi_compile_fwdadd 在官方文档中还没有给出相关说明,但实验表明,只有分别为Base Pass 和 Addition Pass 使用这两个编译指令,我们才可以在相关的Pass 中得到一些正确的光照变量,例如光照衰减值等。

Base Pass 旁边的注释给出了Base Pass 中支持的一些光照特性。例如在Base Pass 中,我们可以访问光照纹理。

Base Pass 中渲染的平行光默认是支持阴影的(如果开启了光源的阴影功能),而Addtional Pass 中渲染的光源在默认情况下是没有阴影效果的,即便我们在它的Light组件中设置了有阴影的Shadow Type。但我们可以在Addtion Pass 中使用#pragma multi_compile_fwdadd_fullshadows 代替 #pragma multi_compile_fwdadd 编译指令,为点光源和聚光灯开启阴影效果,但这需要Unity 在内部使用更多的Shader 变种。

环境光和自发光也是在Base Pass 中计算的。这是因为,对于一个物体来说,环境光和自发光我们只希望计算一次即可,而如果我们在Addtional Pass 中计算这两种光照,就会造成多次叠加环境光和自发光,这不是我们想要的。

在Addtional Base 的渲染设置中,我们还开启和设置了混合模式。这是因为,我们希望每个Addtional Pass 可以与上次的光照结果在帧缓存中进行叠加,从而得到最终的有多个光照的渲染效果。如果我们没有开启和设置混合模式,那么Addtional Pass的渲染结果会覆盖掉之前的渲染结果,开起来就好像该物体只受该光源的影响。通常情况下,我们渲染的混合模式是Blend One One。

对于前向渲染来说,一个Unity Shader通常会定义一个Base Pass(Base Pass也可以定义多次,例如需要双面渲染等情况)以及一次Addtional Pass。一个Base Pass 仅会执行一次(定义了多个Base Pass的情况除外),而一个Addtional Pass 会根据影响该物体的其他逐像素光源的数目被多次调用,即每个逐像素光源会执行一次Addtional Pass。

上图给出的光照计算通常情况下我们在每种Pass中进行的计算。实际上,渲染路径的设置用于告诉Unity 该Pass 在前向渲染路径中的位置,然后底层的渲染引擎会进行相关计算并填充一些内置变量(如_LightColor0等),如何使用这些内置变量进行计算完全取决于开发者的选择。例如,我们完全可以利用Unity 提供的内置变量在Base Pass 中只进行逐顶点光照;同样,我们也完全可以在Additional Pass 中按逐顶点的方式进行光照计算,不进行任何逐像素光照计算。

前面说过,根据我们使用的渲染路径(即Pass标签中的LightMode 的值),Unity 会把不同的光照变量传递给Shader。

在Unity 5 中,对于前向渲染(即LightMode 为ForwardBase或forwardAdd)来说,下表给出了我们可以在Shader 中访问到的光照变量。

我们在之前已经给出了一些可以用于前向渲染路径的函数,例如WorldSpaceLightDir、UnityWorldSpaceLightDir 和 ObjSpaceLightDir。为了完整性,我们在下表再次给出了前向渲染中使用的内置光照函数。

需要说明的是,上面给出的变量和函数并不是完整的,一些前向渲染可以使用的内置变量和函数官方文档中并没有给出说明。在后面的学习中,我们会使用一些不在这些表中的变量和函数。

顶点照明渲染路径是对硬件配置要求最少、运算性能最高、但同时也是得到的效果最差的一种类型,它不支持那些逐像素才能得到的效果,例如阴影、法线映射、高精度的高光反射等。实际上,它仅仅是前向渲染路径的一个子集,也就是说,所有可以在顶点照明渲染路径中实现的功能都可以在前向渲染路径中完成。就如它的名字一样,顶点照明渲染路径只是使用了逐顶点的方式来计算光照,并没有什么神奇的地方。实际上,我们在上面的前向渲染路径中也可以计算一些逐顶点的光源。但如果选择使用顶点照明渲染路径,那么Unity会只填充那些逐顶点相关的光源变量,意味着我们不可以使用一些逐像素光照变量。

Unity中的顶点照明渲染:顶点照明渲染路径通常在一个Pass 中就可以完成对物体的渲染。在这个Pass中,我们会计算我们关心的所有光源对该物体的照明,并且这个计算是按逐顶点处理的。这是Unity中最快速的渲染路径,并且具有最广泛的硬件支持。

可访问的内置变量和函数:在Unity中,我们可以在一个顶点照明的Pass中最多访问到8个逐顶点光源。如果我们只需渲染其中两个光源对物体的照明,可以仅使用下表中内置光照数据的前两个。如果影响该物体的光源数目小于8个,那么数组剩下的光源颜色会设置成黑色。

可以看出,一些变量我们同样可以在前向渲染路径中使用,例如unity_LightColor。但这些变量数组的维度和数值在不同渲染路径中的值是不同的。

下表给出了顶点照明渲染路径中可以使用的内置函数。

前向渲染的问题是:当场景中包含大量实时光源时,前向渲染的性能会急速下降。例如,如果我们在场景的某一块区域放置了多个光源,这些光源影像的区域互相重叠,那么为了得到最终的光照效果,我们就需要为该区域内的每个物体执行多个Pass来计算不同光源对物体的光照结果,然后再颜色缓存中把这些结果混合起来得到最终的光照。然而,每执行一个Pass我们都需要重新渲染一遍物体,但很多计算实际上是重复的。

延迟渲染是一张更古老的渲染办法,但由于上述前向渲染可能造成的瓶颈问题,近几年又流行起来。除了前向渲染中使用的颜色缓冲和深度缓冲外,延迟渲染还会利用额外的缓冲区,这些缓冲区也bei称为G缓冲。G缓冲区存储了我们所关心的表面的其他信息,例如该表面的法线、位置、用于光照计算的材质属性等。

延迟渲染的原理:延迟渲染主要包含了两个Pass。在第一个Pass中,我们不进行任何光照计算,而是仅仅计算哪些片元是可见的,这主要是通过深度缓冲技术来实现,当发现一个片元是可见的,我们就把它的相关信息存储到G缓冲区中。然后,在第二个Pass中,我们利用G缓冲区的各个片元信息,例如表面法线、视角方向、漫发射系数等,进行真正的光照计算。

延迟渲染的过程大致可以用下面的伪代码来描述:
Pass1{
	//第一个Pass不进行真正的光照计算
	//仅仅把光照计算需要的信息存储到G缓冲中
	for (each primitive in this model){
		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缓冲中的信息
			readGBuffer(pixel,materialInfo,pos,normal,lightDir,viewDir);
			//根据读到的信息进行光照计算
			float4 color = Shading(materialInfo,pos,normal,lightDir,viewDir);
			//更新帧缓冲
			writeFrameBuffer(pixel,color);
		}
	}
}

可以看出,延迟渲染使用的Pass数目通常就是两个,这跟场景中包含的光源数目是没有关系的。换句话说,延迟渲染的效率不依赖于场景的复杂的,而是和我们使用的屏幕空间的大小有关。这是因为,我们需要的信息都存储在缓冲区中,而这些缓冲区可以理解成是一张张2D图像,我们的计算实际上就是在这些图像中进行的。

Unity有两种延迟渲染路径,一种是遗留的延迟渲染路径,即Unity5之前使用的延迟渲染路径,而另一种是Unity5.x中使用的延迟渲染路径。如果游戏中使用了大量的实时光照,那么我们可能希望选择延迟渲染路径,但这种路径需要一定的硬件支持。

新旧延迟渲染路径之间的差别很小,只是使用了不同的技术来权衡不同的需求。例如,较旧版本的延迟渲染路径不支持Unity 5 的基于物理的Standard Shader。以下我们仅讨论Unity5 后使用的延迟渲染路径。

对于延迟渲染路径来说,它最适合在场景中光源数目很多、如果使用前向渲染会造成性能瓶颈的情况下使用。而且,延迟渲染路径中的每个光源都可以按逐像素的方式处理。但是,延迟渲染也有一些缺点。

不支持真正的抗锯齿功能。

不能处理半透明物体。

对显卡有一定要求。如果要使用延迟渲染的话,显卡必须支持MRT、Shader Mode 3.0及以上、深度渲染纹理以及双面的模板缓冲。

当使用延迟渲染时,Unity 要求我们提供两个Pass:
1)第一个Pass用于渲染G缓冲。在这个Pass中,我们会把物体的漫反射颜色、高光发射颜色、平滑度、法线、自发光和深度等信息渲染到屏幕空间的G缓冲区中。对于每个物体来说,这个Pass仅会执行一次。

2)第二个Pass用于计算真正的光照模型。这个Pass会使用上一个Pass中渲染的数据来计算最终的光照颜色,再存储到帧缓冲中。

默认的G缓冲区(注意,不同Unity版本的渲染纹理存储内容会有所不同)包含了以下几个渲染纹理(Render Texture,RT)。
RT0:格式是ARGB32,RGB通道用于存储漫反射颜色额,A通道没有被使用。
RT1:格式是ARGB32,RGB通道用于存储高光反射颜色,A通道同于用于存储高光反射的指数部分。
RT2:格式是ARGB2101010,RGB通道用于存储法线,A通道没有被使用。
RT3:格式是ARGB32,用于存储自发光 lightmap 反射探针

深度缓冲和模板缓冲。

当在第二个Pass中计算光照时,默认情况下仅可以使用Unity内置的Standard 光照模型。

下表给出了处理延迟渲染路径可以使用的光照变量。这些变量都可以在UnityDeferredLiabrary.cg.inc文件中找到它们的声明。

Unity的官方文档中给出了4中渲染路径(前向渲染路径、延迟渲染路径、遗留的延迟渲染路径和顶点照明渲染路径)的详细比较,包含它们的特性比较(是否支持逐像素光照、半透明物体、实时阴影等)、性能比较以及平台支持。

总体来说,我们需要根据游戏发布的目标平台来选择渲染路径。如果当前显卡不支持所选渲染路径,那么Unity会自动使用比其低一级的渲染路径。

我们主要使用前向渲染路径。

Unity的光源类型

Unity一共支持4种光源类型:平行光:点光源、聚光灯和面光源。面光源仅在烘焙时才可发挥总用,因此我们不作讨论。由于每种光源的几何定义不同,因此它们对应的光源属性也就各不相同。

我们来看一下光源类型的不同到底会给Shader 带来哪些影响。我们可以考虑Shader 中使用了光源的哪些属性。最常用的光源属性有光源的位置、方向、颜色、强度以及衰减这5个属性。而这些属性和它们的几何意义息息相关。

平行光:对于我们之前使用的平行光来说,它的几何定义是最简单的。平行光可以照亮的范围是没有限制的,它通常是作为太阳这样的角色在场景中出现的。下图给出了Unity中平行光在Scene视图中的表示以及Light组件的面板。

平行光之所以简单,是因为它没有一个唯一的位置,也就是说,它可以放在场景中的任意位置。它的几何属性只有方向,我们可以调整平行光的Transform组件中的Rotation属性来改变它的光源方向,而且平行光到场景中所有点的方向都是一样的,这也是平行光名字的由来。除此之外,由于平行光没有一个具体的位置,因此也就没有衰减的概念,也就是说,光照强度不会随着距离而发生改变。

点光源:点光源的照亮空间是有限的 ,它是由空间中的一个球体定义的。点光源可以表示由一个点发出的、向所有方向延伸的感觉。下图给出了Unity中点光源在Scene视图中的表示以及Light组件的面板。

需要提醒的是,我们需要再Scene视图中开启光照才能看到预览光源是如何影响场景中的物体的。下图给出了开启Scene视图光照的按钮。

球体的半径可以由面板中的Range属性来调整,也可以在Scene视图中直接拖拉点光源的线框来修改它的属性。点光源是由位置属性的,它是由点光源的Transform组件中的Position属性定义的。对于方向属性,我们需要用点光源的位置减去眸点的位置来得到它到该点的方向。而点光源的颜色和强度可以在Light组件面板中调整。同时,点光源也是会衰减的,随着物体逐渐远离点光源,它接收到的光照强度也会逐渐减小。点光源球心处的光照强度最强,球体边界处的最弱,值为0。其中间的衰减值可以由一个函数定义。

聚光灯:聚光灯是这3种光源类型中最复杂的一种。它的照亮空间同样是有限的,但不再是简单的球体,而是由空间中的一块锥形区域定义的 。聚光灯可以用于表示由一个特定位置触发、向特定方向延伸的光。下图给出了Unity中聚光灯在Scene视图中的表示以及Light组件的面板。

这块锥形区域的半径由面板中的Range属性决定,而锥形的张开角度由Spot Angle属性决定。我们同样也可以在Scene视图中直接拖拉聚光灯的线框(如中间的黄色控制点以及四周的黄色控制点)来修改它的属性。聚光灯的位置同样是由Transform 组件中的Position 属性定义的。对于方向属性,我们需要使用聚光灯的位置减去某点的位置来得到它到该点的方向。聚光灯的衰减也是随着物体逐渐远离点光源而逐渐减小,在锥形的顶点处光照强度最强,在锥形的边界处强度为0。其中间的衰减值可以由一个函数定义,这个函数相对于点光源衰减计算公式更加复杂,因为我们需要判断一个点是否在椎体的范围内。

在了解了3中光源的几何定义后,我们看一下Unity Shader中访问它的5个属性:位置、方向、颜色、强度以及衰减。需要注意的是,我们是建立在使用前向渲染路径的基础上。

我们通过Shader的使用可以得到如下结果。

我们新建一个Shader实现上述效果。
Shader "Unity Shader Books/Chapter9-ForwardRedering"{
	Properties{
		_Diffuse("Diffuse Color",Color)=(1,1,1,1)
		_Specular("Specular",Color)=(1,1,1,1)
		_Gloss("Gloss",Range(8.0,256))=20
	}
	SubShader{
		Tags{"RenderType"="Opaque"}
		Pass{
			Tags{"LightMode"="ForwardBase"}
			CGPROGRAM
			//#pragma multi_compile_fwdbase可以保证我们在Shader中使用光照衰减等
			//光照变量可以被正确赋值。这是不可缺少的
			#pragma multi_compile_fwdbase
			#pragma vertex vert
			#pragma fragment frag
			#include "Lighting.cginc"
			fixed4 _Diffuse;
			fixed4 _Specular;
			float _Gloss;
			struct a2v{
				float4 vertex : POSITION;
				float3 normal : NORMAL;
			};
			struct v2f{
				float4 pos : SV_POSITION;
				float3 worldNormal : TEXCOORD0;
				float3 worldPos : TEXCOORD1;
			};
			v2f vert(a2v v){
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP,v.vertex);
				o.worldNormal = UnityObjectToWorldNormal(v.normal);
				o.worldPos=mul(_Object2World,v.vertex).xyz;
				return o;
			}
			fixed4 frag(v2f i):SV_Target{
				fixed3 worldNormal = normalize(i.worldNormal);
				//得到平行光的方向
				fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
				//计算了环境光
				fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
				//_LightColor0可以得到平行光的强度和颜色
				fixed3 diffuse = _LightColor0.rgb*_Diffuse.rgb*max(0,dot(worldNormal,worldLightDir));
				fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
				fixed3 halfDir = normalize(worldLightDir + viewDir);
				fixed3 specular = _LightColor0.rgb*_Specular.rgb*pow(max(0,dot(worldNormal,halfDir)),_Gloss);
				//平行光没有衰减,故衰减值为0
				fixed atten = 1.0;
				return fixed4(ambient + (diffuse+specular)*atten,1.0);
			}
			ENDCG
		}
		//通常来说,Addtional Pass的光照处理和Base Pass的处理方式是一样的,因此我们只需要把
		//Base Pass的顶点和片元着色器代码复制到Additional Pass中,稍微修改一下即可。
		Pass{
			Tags{"LightMode"="ForwardAdd"}
			//开启和设置了混合模式
			//希望Additional Pass 计算得到的光照结果与之前的光照结果进行叠加。
			//没有使用Blend命令的话,Additional Pass会直接覆盖掉之前的光照结果
			//我们也可以选择其他Blend命令,如Blend SrcAlpha One
			Blend One One
			CGPROGRAM
			//这个指令保证我们再Additional Pass中访问正确的光照变量
			#pragma multi_compile_fwdadd
			#pragma vertex vert
			#pragma fragment frag
			#include "Lighting.cginc"
			#include "AutoLight.cginc"
			fixed4 _Diffuse;
			fixed4 _Specular;
			float _Gloss;
			struct a2v{
				float4 vertex : POSITION;
				float3 normal : NORMAL;
			};
			struct v2f{
				float4 pos : SV_POSITION;
				float3 worldNormal : TEXCOORD0;
				float3 worldPos : TEXCOORD1;
			};
			v2f vert(a2v v){
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP,v.vertex);
				o.worldNormal = UnityObjectToWorldNormal(v.normal);
				o.worldPos = mul(_Object2World,v.vertex).xyz;
				return o;
			}
			fixed4 frag(v2f i):SV_Target{
				fixed3 worldNormal = normalize(i.worldNormal);
				//如果是平行光的话
				#ifdef USING_DIRECTIONAL_LIGHT
					fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
				#else
					fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos.xyz);
				#endif
				fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb*max(0,dot(worldNormal,worldLightDir));
				fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz-i.worldPos.xyz);
				fixed3 halfDir = normalize(worldLightDir+viewDir);
				fixed3 specular = _LightColor0.rgb*_Specular.rgb*pow(max(0,dot(worldNormal,halfDir)),_Gloss);
				//如果是平行光的话
				#ifdef USING_DIRECTIONAL_LIGHT
					fixed atten = 1.0;
				#else
					//如果是点光源
					#if defined(POINT)
						float3 lightCoord = mul(_LightMatrix0,float4(i.worldPos,1)).xyz;
						fixed atten = tex2D(_LightTexture0,dot(lightCoord,lightCoord).rr).UNITY_ATTEN_CHANNEL;
					//如果是聚光灯
					#elif defined(SPOT)
						float4 lightCoord = mul(_LightMatrix0,float4(i.worldPos,1));
						fixed atten = (lightCoord.z > 0)*tex2D(_LightTexture0,lightCoord.xy/lightCoord.w+0.5).w*
							tex2D(_LightTextureB0,dot(lightCoord,lightCoord).rr).UNITY_ATTEN_CHANNEL;
					#else
						fixed atten = 1.0
					#endif
				#endif
				return fixed4((diffuse+specular)*atten,1.0);
			}
			ENDCG
		}
	}
	Fallback "Diffuse"
}

我们新建一个场景。新建4个点光源,调整它们的颜色为相同的红色。可以得到类似下图的结果。

那么,这个效果是怎么来的呢?当我们创建一个光源时,默认情况下它的Render Mode(可以在Light组件中设置)是Auto。这意味着,Unity会在背后为我们判断哪些光源会按逐像素处理,而哪些按逐顶点或SH的方式处理。由于我们没有更改Edit -> Project Setting ->Quality ->Pixel Light Count 中的数值,因此默认情况下一个物体可以接受最亮的平行光外的4个逐像素光照。在这个例子中,场景中共包含了5个光源,其中一个是平行光,它会在Chapter9-Forward Rendering 的Base Pass 中按逐像素的方式被处理:其余4个都是点光源,由于它们的Render Mode 为Auto且数目正好等于4,因此都会在Chapter9-ForwardRendering 的Additional Pass 中逐像素的方式被处理,每个光源会调用一次Additional Pass。

在Unity 5中,我们还可以使用帧调试器工具来查看场景的绘制过程。使用方法是:在Window ->Frame Debugger 中打开帧调试器。如下图所示。

从帧调试器可以看出,渲染这个场景Unity一共进行了6个渲染时间,由于本例中只包含了一个物体,因此这6个物体几乎都是用于渲染该物体的光照结果。我们可以通过依次点击帧调试器中的渲染时间,来查看Unity是怎样渲染物体的。下图给出了本例中Unity进行的6个渲染事件。

从上图可以看出,Unity是如何一步一步将不同的光照渲染到物体的:在第一个渲染事件中,Unity首先清除颜色、深度和模板缓冲,为后面的渲染做准备;在第二个渲染事件中,Unity利用Chapter9-ForwardRendering的第一个Pass,即Base Pass,将平行光的光照渲染到帧缓存中;在后面的4个渲染事件中,Unity使用第二个Pass,即Additional Pass,一次将4个点光源的光照应用到物体上,得到最后的渲染结果。

可以注意到,Unity处理这些点光源的顺序是按照它们的重要度排序的。在这个例子中,由于所有光源的颜色和强度都相同,因此它们的重要度取决于它们距离胶囊体的远近,因此上图首先绘制的 是距离胶囊体最近的点光源。但是光源的强度度和颜色互不相同,那么距离就不再是唯一的衡量标准。例如,如果我们把现在距离最近的点光源的强度设为0.2,那么从帧调试器中我们可以发现绘制顺序发生了变化,此时首先绘制的是距离胶囊体第二近的点光源,最近的点光源则会在最后被渲染。Unity官方文档中并没有给出光源强度、颜色和距离物体的远近是如何具体影响光源的重要度排序的,我们仅知道排序结果和这三者都有关系。

对于场景中的一个物体,如果它不在一个光源的光照范围内,Unity是不会为这个物体调用Pass来处理这个光源的。我们可以把本例中距离最远的点光源的范围调小,使得胶囊体在它的照亮范围外。此时再查看帧调试器,我们可以发现渲染时间比之前少了一个,如下图所示。同样,如果一个物体不在某个聚光灯的范围内,Unity也是不会为该物体调用相关的渲染事件的。

我们知道,如果逐像素光源的数目很多的话,该物体的Additional Pass就会被调用很多次,影响性能。我们可以通过把光源的Render Mode 设为Not Important 来告诉Unity,我们不希望把该关顾炎当成逐像素处理。我们把4个点光源的Render Mode 都设为 Not Important,可以得到如下结果:

由于我们在Chapter9-ForwardRendering中没有在Base Pass 中计算逐顶点和SH光源,因此场景中的4个点光源实际上不会对物体造成任何影响。同样,如果我们把平行光的Render Mode 也设为Not Important。物体就会仅显示环境光的光照结果。

Unity 的光照衰减

使用纹理查找计算衰减有一些弊端:
需要预处理得到采样纹理,而且纹理的大小也会影响衰减的精度。

不直观,同时也不方便,因此一旦把数据存储到查找表中,我们就无法使用其他数学公式来计算衰减。

但由于这种方法可以在一定程度上提升性能,而且得到的效果在大部分情况下都是良好的,因此Unity默认就是使用这种纹理查找的方式来计算逐像素的点光源和聚光灯的衰减的。

Unity在内部使用一张名为_LightTexture0的纹理来计算光源衰减。需要注意的是,如果我们对该光源使用了cookie,那么衰减查找纹理是_LightTextureB0,但这里不讨论这种情况。我们通常只关心_LightTexture0对角线上的纹理颜色值,这些值表明了再光源空间中不同位置的点的衰减值。例如(0,0)点表明了与光源位置重合的点的衰减值,而(1,1)点表明了再光源空间中所关心的距离最远的点的衰减。

为了对_LightTexture0纹理采样得到给定点到该光源的衰减值,我们首先需要得到该点在光源空间中的位置,这是通过LightMatrix0变换矩阵得到。在之前,我们已经知道LightMatrix0可以把顶点从世界空间变换到光源空间。因此,我们只需要把_LightMatrix0和世界空间中的顶点坐标相乘即可得到光源空间中的相应位置:
float3 lightCoord = mul(_LightMatrix0,float4(i.worldPos,1)).xyz;

然后,我们可以使用这个坐标的模的平方对衰减纹理进行采样,得到衰减值:
fixed atten = tex2D(_LightTexture0,dot(lightCoord,lightCoord).rr).UNITY_ATTEN_CHANNEL;

可以发现,在上面的代码中,我们使用了光源空间中顶点距离的平方(通过dot函数来得到)来对纹理采样,之所以没有使用距离值来采样是因为这样可以避免开方操作。然后,我们使用宏UINITY_ATTEN_CHANNEL来得到衰减纹理中的衰减值所在的分量,以得到最终的衰减值。

尽管纹理采样的方法可以减少计算衰减的复杂度,但有时我们希望可以在代码中利用公式来计算光源的衰减。例如,下面的代码可以计算光源的线性衰减。
float distance = length(_WorldSpaceLightPos0.xyz - i.worldPosition.xyz);
atten = 1.0/distance;

Unity 的阴影

为了让场景看起来更加真实,具有深度信息,我们通常希望光源可以把一些物体的阴影投射在其他物体上。

我们可以先考虑真实生活中阴影是如何产生的。当一个光源发射的一条光线遇到一个不透明物体时,这条光线就不可以再继续照亮其他物体。因此,这个物体就会向它旁边的物体投射阴影,那些阴影区域的产生是因为光线无法到达这些区域。

在实时渲染中,我们最常用的是一种名为Shadow Map 的技术。这种技术很简单,它会首先把摄像机位置放在与光源重合的位置上,那么场景中该光源的阴影区域就是摄像机看不到的地方。而Unity就是使用这种技术。

在前向渲染路径中,如果场景中最重要的平行光开启了阴影,Unity就会为该光源计算它的阴影映射纹理。这张阴影映射纹理本质上也是一张深度图,它记录了从该光源的位置出发、能看到的场景中距离它最近的表面位置(深度信息)。

那么,在计算阴影映射纹理时,我们如何判断距离它最近的表面位置呢?一种方法是,先把摄像机放置到光源的位置上,然后按正常的渲染流程,即调用Base Pass 和 Additional Pass 来更新深度信息,得到阴影映射纹理。但这种方法会对性能造成一定的浪费,因为我们实际上仅仅需要深度信息而已,而Base Pass 和 Additional Pass 中往往涉及很多复杂的光照模型计算。因此,Unity选择使用一个额外的Pass来专门更新光源的阴影映射纹理,这个Pass就是LightMode标签被设置为ShadowCaster的Pass。这个Pass的渲染目标不是帧缓存,而是阴影映射纹理(或深度纹理)。Unity首先把摄像机放置到光源的位置上,然后调用该Pass,通过对顶点变换后得到光源空间下的位置,并据此来输出深度信息到阴影映射纹理中。因此,当开了光源的阴影效果后,底层渲染引擎首先会在当前渲染物体的Unity Shader 中找到LightMode 为 ShadowCaster 的Pass,如果没有,它就会在Fallback 指定的Unity Shader中继续寻找,如果仍然没有找打,该物体就无法向其他物体投射阴影(但它仍然可以接收来自其他物体的阴影)。当找到了一个LightMode 为ShadowCaster 的Pass后,Unity会使用该Pass来更新光源的阴影映射纹理。

在传统的阴影映射纹理的实现中,我们会在正常渲染的Pass中把顶点位置变换到光源空间下,以得到它在光源空间中的三维位置信息。然后,我们使用xy分量对阴影映射纹理进行采样,得到阴影映射纹理中该位置的深度信息。如果该深度值小于该顶点的深度值(通常由z分量得到),那么说明该点位于阴影中。但在Unity5中,Unity 使用了不同于这种传统的阴影采样技术,即屏幕空间的阴影映射技术。屏幕空间的阴影映射原本是延迟渲染中产生阴影的方法。需要注意的是,并不是所有的平台Unity都会使用这种技术。这是因为,屏幕空间的阴影映射需要显卡支持MRT,而有些移动平台不支持这种特性。

当使用了屏幕空间的阴影映射技术时,Unity首先会通过调用LightMode 为 ShadowCaster的Pass来得到可投射阴影是光源的阴影映射纹理以及摄像机的深度纹理。然后,根据光源的阴影映射纹理和摄像机的深度纹理来得到屏幕空间的阴影图。如果摄像机的深度图中记录的表面深度大于转换到阴影映射纹理中的深度值,就说明该表面虽然是可见的,但是却出于该光源的阴影中。通过这样的方式,阴影图就包含了屏幕空间中所有阴影的区域。如果我们想要一个物体接收来自其他物体的阴影,只需要在Shader中对阴影图进行采样。由于阴影图是屏幕空间下的,因此我们首先需要把表面坐标从模型空间变换到屏幕空间中,然后使用这个坐标对阴影图进行采样即可。

总结一下,一个物体接收来自其他物体的阴影,以及它向其他物体投射阴影是两个过程。

如果我们想要一个物体接收来自其他物体的阴影,就必须在Shader中对阴影映射纹理(包括屏幕空间的阴影图)进行采样,把采样结果和最后的光照结果相乘来产生阴影效果。

如果我们想要一个物体向其他物体投射阴影,就必须把该物体加入到光源的阴影映射纹理的计算中,从而让其他物体在对阴影映射纹理采样时可以得到该物体的相关信息。在Unity 中,这个过程通过为该物体执行LightMode 为ShadowCaster 的 Pass 来实现的。如果使用了屏幕空间的投射映射技术,Unity还会使用这个Pass 产生一张摄像机的深度纹理。

不透明物体的阴影

准备工作:
1)新建场景,去掉天空盒子
2)新建材质,将之前的Chapter9-ForwardRendering 这个Shader赋给它。
3)场景中创建一个正方体和两个平面,材质赋给正方体

为了让场景中可以产生阴影,我们首先需要让平行光可以收集阴影信息。这需要在光源的Light组件中开启阴影,如下图所示:

本例中我们选择软阴影。

在Unity中,我们可以渲染是否让一个物体投射或者接收阴影。这是通过设置Mesh Renderer组件的Cast Shadows和Receive Shadows 属性来实现的,如下图所示:

Cast Shadows 可以被设置为开启或关闭,如果开启了Cast Shadows属性,那么Unity就会把该物体加入到光源的阴影映射纹理的计算中,从而让其他物体在对阴影映射纹理采样时可以得到该物体的相关信息。正如之前所说,这个过程是通过为物体执行LightMode 为ShadowCaster的Pass来实现的。Receive Shadows则可以选择是否让物体接收来自其他物体的阴影。如果没有开启Receive Shadows,那么当我们调用Unity的内置宏和变量计算阴影时,这些宏通过判断该物体没有开启接收阴影的功能,就不会再内部为我们计算阴影。

我们把正方体和两个平面的Cast Shadows和Receive Shadows 都设为开启状态,可以得到下图结果。

从上图可以看出,尽管我们没有对正方体使用的Chapter9-ForwardRendering进行任何更改,但正方体仍然可以向下面的平面投射阴影。在Chapter9-ForwardRendering 中,我们为它的Fallback指定了一个回调Unity Shader,即内置的Specular。虽然Specular 本身也没有包含这样的一个Pass,但是由于它的Fallback 调用了VertexLit,它会继续回调,并最终回调到内置的vertexLit。我们可以在Unity 内置的着色器里找到它:builtin-shaders-xxx->DefaultResourcesExtra->Normal-VertexLit.Shader。打开它,我们可以看到LightMode 为ShadowsCaster 的 Pass了:
Pass{
	Name "ShadowCaster"
	Tags {"LightMode"="ShadowCaster"}
	CGPROGRAM
	#pragma fragment frag
	#pragma vertex vert
	#pragma multi_compile_shadowcaster
	struct v2f{
		V2F_SHADOW_CASTER;
	};
	v2f vert (appdata_base v){
		v2f o;
		TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
		return o;
	}
	float4 frag(v2f i) : SV_Target{
		SHADOW_CASTER_FRAGMENT(I)
	}
	ENDCG
}

上面的代码非常短,尽管有一些指令和宏是我们之前没有遇到过的,但它们的用处实际上就是为了把深度信息写入到渲染目标中。在Unity5 中,这个Pass的渲染目标可以使光源的阴影映射纹理,或是摄像机的深度纹理。

如果我们把Chapter9-ForwardRendering中的Fallback注释掉,就可以发现正方体不会再向平面投射阴影了。当然,我们可以不依赖于Fallback,而自行在SubShader 中定义自己的LightMode 为 ShadowCaster 的 Pass。这种自定义的Pass可以让我们更加灵活地控制阴影的产生。但由于这个Pass 的功能通常是可以在多个Unity Shader 间通用的,因此直接Fallback 是一个更加方便的用法。在这之前的章节中,我们有时也在Fallback 中使用内置的Diffuse,虽然Diffuse 本身也没有包含这样一个Pass,但是由于它的Fallback 调用了VertexLit,因此Unity 最终还是会找到一个LightMode 为ShadowCaster 的Pass,从而可以让物体产生阴影。

在默认的情况下,我们再计算光源的阴影映射纹理时会剔除物体的背面。但对于内置平面来说,它只有一个面,因此在本例中当计算阴影映射纹理时,由于右侧的平面在光源空间下没有任何正面,因此就不会添加到阴影映射纹理中。我们可以将Cast Shadows 设置为Two Sided 来允许对物体的所有面都计算阴影信息。下图给出了当把右侧平面的Cast Shadows 设置为Two Sided 后的结果。

在本例中,最下面的平面之所以可以接收阴影是因为它使用了内置的Standard Shader,而这个内置的Shader进行了接收阴影的相关操作。但由于正方体使用的Chapter9-ForwardRendering 并没有对阴影进行任何处理,因此它不会显示右侧平面投射来的阴影。

为了让正方体接收阴影,我们新建一个Unity Shader,代码如下:
Shader "Unity Shaders Book/Chapter9-Shadow"{
	Properties{
		_Diffuse("Diffuse",Color)=(1,1,1,1)
		_Specular("Specular",Color)=(1,1,1,1)
		_Gloss("Gloss",Range(8.0,256)) = 20
	}
	SubShader{
		// Tags{"LightMode"="ShaderCaster"}
		Pass{
			Tags{"LightMode"="ForwardBase"}
			CGPROGRAM
			#pragma multi_compile_fwdbase
			#pragma vertex vert
			#pragma fragment frag
			#include "Lighting.cginc"
			#include "AutoLight.cginc"
			fixed4 _Diffuse;
			fixed4 _Specular;
			float _Gloss;
			struct a2v{
				float4 vertex : POSITION;
				float3 normal : NORMAL;
			};
			struct v2f{
				float4 pos : SV_POSITION;
				float3 worldNormal : TEXCOORD0;
				float3 worldPos : TEXCOORD1;
				//作用是声明一个用于阴影纹理采样的坐标。
				//这个宏的参数需要是下一个可用的插值寄存器的索引值
				SHADOW_COORDS(2)
			};
			v2f vert(a2v v){
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP,v.vertex);
				o.worldNormal = UnityObjectToWorldNormal(v.normal);
				o.worldPos = mul(_Object2World,v.vertex).xyz;
				//用于在顶点着色器中计算上一步中声明的阴影纹理坐标
				TRANSFER_SHADOW(o);
				return o;
			}
			fixed4 frag(v2f i) : SV_Target{
				fixed3 worldNormal = normalize(i.worldNormal);
				fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
				fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
				fixed3 diffuse = _LightColor0.rgb*_Diffuse.rgb*max(0,dot(worldNormal,worldLightDir));
				fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz-i.worldPos.xyz);
				fixed3 halfDir = normalize(worldLightDir+viewDir);
				fixed3 specular = _LightColor0.rgb*_Specular.rgb*pow(max(0,dot(worldNormal,halfDir)),_Gloss);
				fixed atten = 1.0;
				//计算阴影值
				fixed shadow = SHADOW_ATTENUATION(i);
				return fixed4(ambient+(diffuse+specular)*atten*shadow,1.0);
			}
			ENDCG
		}
		Pass{
			Tags{"LightMode"="ForwardAdd"}
			Blend One One
			CGPROGRAM
			#pragma multi_compile_fwdadd
			#pragma vertex vert
			#pragma fragment frag
			#include "Lighting.cginc"
			#include "AutoLight.cginc"
			fixed4 _Diffuse;
			fixed4 _Specular;
			float _Gloss;
			struct a2v{
				float4 vertex : POSITION;
				float3 normal : NORMAL;
			};
			struct v2f{
				float4 position : SV_POSITION;
				float3 worldNormal : TEXCOORD0;
				float3 worldPos : TEXCOORD1;
			};
			v2f vert(a2v v){
				v2f o;
				o.position = mul(UNITY_MATRIX_MVP,v.vertex);
				o.worldNormal = UnityObjectToWorldNormal(v.normal);
				o.worldPos = mul(_Object2World,v.vertex).xyz;
				return o;
			}
			fixed4 frag(v2f i):SV_Target{
				fixed3 worldNormal = normalize(i.worldNormal);
				#ifdef USING_DIRECTIONAL_LIGHT
					fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
				#else
					fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos.xyz);
				#endif
				fixed3 diffuse = _LightColor0.rgb*_Diffuse.rgb*max(0,dot(worldNormal,worldLightDir));
				fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
				fixed3 halfDir = normalize(worldLightDir+viewDir);
				fixed3 specular = _LightColor0.rgb*_Specular.rgb*pow(max(0,dot(worldNormal,halfDir)),_Gloss);
				#ifdef USING_DIRECTIONAL_LIGHT
					fixed atten = 1.0;
				#else
					float3 lightCoord = mul(_LightMatrix0,float4(i.worldPos,1)).xyz;
					fixed atten = tex2D(_LightTexture0,dot(lightCoord,lightCoord).rr).UNITY_ATTEN_CHANNEL;
				#endif
				return fixed4((diffuse+specular)*atten,1.0);
			}
			ENDCG
		}
	}
	Fallback "Specular"
}

效果如下图所示

我们通过帧调试器查看绘制过程,如下图所示

从上图可以看出,绘制该场景共需要花费20个渲染事件。这些渲染事件可以分成4个部分:UpdateDepthTexture,即更新摄像机的深度纹理;RenderShadowmap,即渲染得到平行光的阴影映射纹理:CollectShadows,即根据深度纹理和阴影映射纹理得到屏幕空间的阴影图;最后得到渲染效果。

我们看第一个部分:更新摄像机的深度纹理,这是前4个渲染事件的工作。我们可以单击这些事件来查看她们的绘制结果。下图给出了正方体对深度纹理的更新结果。

从帧调试器右侧的面板我们可以了解这一渲染事件的详细信息。从上图我们可以发现,Unity调用了Shader:Unity Shader Book/Chapter9 Shadow Pass #3 来更新深度纹理,即Chapter9-Shadow 中的第三个Pass。尽管Chapter9-Shadow只定义了两个Pass,但正如我们之前所说,Unity会在它的Fallback 中找到第三个Pass,即LightMode 为 ShadowCaster 的Pass 来更新摄像机的深度纹理。同样,在第二个部分,即渲染得到平行光的阴影映射纹理的过程中,Unity 也是调用了这个Pass来得到广元的 阴影映射纹理。

在第三个部分中,Unity会根据之前两步的结果得到屏幕的阴影图,如下图所示。

这张图已经包含了最终屏幕上所有有阴影区域的阴影,在最后一个部分中,如果物体所使用的Shader包含了对这张阴影图的采样就会得到阴影效果,下图给出了这个部分Unity是如何一步步绘制出有阴影的画面效果的。

在这之前,我们已经讲过如何在Unity Shader 的前向渲染路径中计算光照衰减——在Base Pass 中,平行光的衰减因子总是等于1,而在Additional Pass中,我们需要判断该Pass处理的光源类型,再使用内置变量和宏计算衰减因子。实际上,光照衰减和阴影对物体最终的渲染结果的影响本质上是相同的——我们都是把光照衰减因子和阴影值即光照结果相乘得到最终的渲染结果。那么,是不是可以有一个方法可以同时计算两个信息呢?好消息是,Unity在Shader里提供了这样的功能,主要是通过内置的UNITY_LIGHT_ATTENUATION宏来实现的。

为此,我们写过一个shader来实现:
Shader "Unity Shaders Book/Chapter 9/Attenuation And Shadow Use Build-in Functions" {
	Properties {
		_Diffuse ("Diffuse", Color) = (1, 1, 1, 1)
		_Specular ("Specular", Color) = (1, 1, 1, 1)
		_Gloss ("Gloss", Range(8.0, 256)) = 20
	}
	SubShader {
		Tags { "RenderType"="Opaque" }
		Pass {
			Tags { "LightMode"="ForwardBase" }
			CGPROGRAM
			#pragma multi_compile_fwdbase	
			#pragma vertex vert
			#pragma fragment frag
			#include "Lighting.cginc"
			#include "AutoLight.cginc"
			fixed4 _Diffuse;
			fixed4 _Specular;
			float _Gloss;
			struct a2v {
				float4 vertex : POSITION;
				float3 normal : NORMAL;
			};
			struct v2f {
				float4 pos : SV_POSITION;
				float3 worldNormal : TEXCOORD0;
				float3 worldPos : TEXCOORD1;
				SHADOW_COORDS(2)
			};
			v2f vert(a2v v) {
			 	v2f o;
			 	o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
			 	o.worldNormal = UnityObjectToWorldNormal(v.normal);
			 	o.worldPos = mul(_Object2World, v.vertex).xyz;
			 	TRANSFER_SHADOW(o);
			 	return o;
			}
			fixed4 frag(v2f i) : SV_Target {
				fixed3 worldNormal = normalize(i.worldNormal);
				fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
				fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
			 	fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * max(0, dot(worldNormal, worldLightDir));
			 	fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
			 	fixed3 halfDir = normalize(worldLightDir + viewDir);
			 	fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss);
				//UNITY_LIGHT_ATTENUATION 是Unity 内置的用于计算光照衰减和阴影的宏
				//第一个参数是atten,第二个参数是结构体v2f ,第三个参数是世界空间的坐标
				UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
				return fixed4(ambient + (diffuse + specular) * atten, 1.0);
			}
			ENDCG
		}
		Pass {
			Tags { "LightMode"="ForwardAdd" }
			Blend One One
			CGPROGRAM
			#pragma multi_compile_fwdadd
			#pragma vertex vert
			#pragma fragment frag
			#include "Lighting.cginc"
			#include "AutoLight.cginc"
			fixed4 _Diffuse;
			fixed4 _Specular;
			float _Gloss;
			struct a2v {
				float4 vertex : POSITION;
				float3 normal : NORMAL;
			};
			struct v2f {
				float4 pos : SV_POSITION;
				float3 worldNormal : TEXCOORD0;
				float3 worldPos : TEXCOORD1;
				SHADOW_COORDS(2)
			};
			v2f vert(a2v v) {
			 	v2f o;
			 	o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
			 	o.worldNormal = UnityObjectToWorldNormal(v.normal);
			 	o.worldPos = mul(_Object2World, v.vertex).xyz;
			 	TRANSFER_SHADOW(o);
			 	return o;
			}
			fixed4 frag(v2f i) : SV_Target {
				fixed3 worldNormal = normalize(i.worldNormal);
				fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
			 	fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * max(0, dot(worldNormal, worldLightDir));
			 	fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
			 	fixed3 halfDir = normalize(worldLightDir + viewDir);
			 	fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss);
				UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
				return fixed4((diffuse + specular) * atten, 1.0);
			}
			ENDCG
		}
	}
	FallBack "Specular"
}

透明度物体的阴影:我们从一开始就强调,想要在Unity里让物体能够向其他物体投射阴影,一定要在它使用的Unity Shader 中提供一个LightMode 为 ShadowCaster的Pass。在前面的例子中,我们使用内置的VertexLit中提供的ShadowCaster来投射阴影。VertexLit中的ShadowCaster实现很简单,它会正常渲染整个物体,然后把深度结果输出到一张深度图或阴影映射纹理中。

对于大多数不透明物体来说,把Fallback设为VertexLit就可以直接得到正确的阴影。但对于透明物体来说,我们就需要小心处理它的阴影。透明物体的实现通车会使用透明度测试或透明度混合,我们需要小心设置这些物体的Fallback。

透明度测试的处理比较简单,但如果我们仍然直接使用VertexLit、Diffuse、Specular等作为回调,往往无法得到正确的阴影。这是因为透明度测试需要再片元着色器中舍弃某些片元,而VertexLit中的阴影投射纹理并没有做这样从操作。

我们们使用一个新的Shader  Chapter9-AlphaTestWithShadow完成上述功能,代码和透明度测试几乎一样,只是添加了关于阴影的计算。
Shader "Unity Shaders Book/Chapter 9/Alpha Test With Shadow" {
	Properties {
		_Color ("Color Tint", Color) = (1, 1, 1, 1)
		_MainTex ("Main Tex", 2D) = "white" {}
		_Cutoff ("Alpha Cutoff", Range(0, 1)) = 0.5
	}
	SubShader {
		Tags {"Queue"="AlphaTest" "IgnoreProjector"="True" "RenderType"="TransparentCutout"}
		Pass {
			Tags { "LightMode"="ForwardBase" }
			Cull Off
			CGPROGRAM
			#pragma multi_compile_fwdbase
			#pragma vertex vert
			#pragma fragment frag
			#include "Lighting.cginc"
			#include "AutoLight.cginc"
			fixed4 _Color;
			sampler2D _MainTex;
			float4 _MainTex_ST;
			fixed _Cutoff;
			struct a2v {
				float4 vertex : POSITION;
				float3 normal : NORMAL;
				float4 texcoord : TEXCOORD0;
			};
			struct v2f {
				float4 pos : SV_POSITION;
				float3 worldNormal : TEXCOORD0;
				float3 worldPos : TEXCOORD1;
				float2 uv : TEXCOORD2;
				SHADOW_COORDS(3)
			};
			v2f vert(a2v v) {
			 	v2f o;
			 	o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
			 	o.worldNormal = UnityObjectToWorldNormal(v.normal);
			 	o.worldPos = mul(_Object2World, v.vertex).xyz;
			 	o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
			 	// Pass shadow coordinates to pixel shader
			 	TRANSFER_SHADOW(o);
			 	return o;
			}
			fixed4 frag(v2f i) : SV_Target {
				fixed3 worldNormal = normalize(i.worldNormal);
				fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
				fixed4 texColor = tex2D(_MainTex, i.uv);
				clip (texColor.a - _Cutoff);
				fixed3 albedo = texColor.rgb * _Color.rgb;
				fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
				fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(worldNormal, worldLightDir));
			 	// UNITY_LIGHT_ATTENUATION not only compute attenuation, but also shadow infos
				UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
				return fixed4(ambient + diffuse * atten, 1.0);
			}
			ENDCG
		}
	} 
	FallBack "VertexLit"
}

得到如下效果:

我们可以发现,镂空区域出现了不正常的阴影,看起来就像这个正方体是一个普通的正方体一样。而这样并不是我们想要得到的,我们希望有些光应该是可以通过这些镂空区域偷过来的,这些区域不应该有阴影。出现这样的情况是因为,我们使用的是内置的VertexLit中提供的ShadowCaster来投射阴影,而这个Pass中并没有进行任何透明度测试的计算,因此,它会把整个物体的深度信息渲染到深度图和阴影映射纹理中。因此,如果我们想要得到经过透明度测试后的阴影效果,就需要提供一个有透明度测试功能的ShadowCaster Pass。当然,我们可以自行编写这样的Pass。

为了让使用透明度测试的物体得到正确的阴影效果,我们只需要在Unity Shader 中更改一行代码,即把Fallback 设置为"Transparent/Cutout/VertexLit",它的ShadowCaster Pass也计算了透明度而是,因此会把裁剪后的物体深度信息写入深度图和阴影映射纹理中。但需要注意的是,由于"Transparent/Cutout/VertexLit"中计算透明度测试,使用了名为_Cutoff的属性来进行透明度测试,因此,这要求我们的Shader中也必须提供名为_Cutoff的属性。否则,同样无法得到正确的阴影结果。

更改了Fallback之后,我们可以得到下图中的结果。

但是,这样的结果仍然有一些问题,例如出现了一些不应该透过光的部分。出现这种情况的原因是,默认情况下把物体渲染到深度图和阴影映射纹理中仅考虑物体的正面。但对于本例的正方体来说,由于一些面完全背对光源,因此这些面的深度信息没有加入到阴影映射纹理的计算中。为了得到正确的结果,我们可以将正方体的Mesh Renderer组件的Cast Shadows属性设置为Two Sided,强制Unity在计算阴影映射纹理时计算所有面的深度信息。下图给出了正确设置后的渲染结果。

与透明度测试的物体相比,想要为使用透明度混合的物体添加阴影是一件比较复杂的事情。事实上,所有内置的透明度混合的Unity Shader,如Transparent/VertexLit等,都没有包含阴影投射的Pass。这意味着,这些半透明物体不会参与深度图和阴影映射纹理的计算,也就是说,它们不会向其他物体投射阴影,同样它们也不会接收来自其他物体的阴影。我们使用之前学习的透明度混合 阴影的方法来渲染一个正方体,它使用的材质和Unity Shader分别是AlphaBlendWithShadowMat 和 Chapter9-AlphaBlendWithShadow。Chapter9-AlphaBlendWithShadow使用了之前透明度混合中几乎完全相同的代码,只是添加了关于阴影的计算,并且他的Fallback 是内置的Transparent/VertexLit。下图显示了渲染结果。

Unity会这样处理半透明物体是有它的原因的。由于透明度混合需要关闭深度写入,由此带来的问题也影响了阴影的生成。总体来说,要想为这些半透明物体产生正确的阴影,需要在每个光源空间下仍然严格按照从后往前的顺序进行渲染,这会让阴影处理变得非常复杂,而且也会影响性能。由此,在Unity中,所有内置的半透明Shader是不会产生任何阴影效果的。当然,我么可以使用一些dirty trick来强制为半透明物体生成阴影,这可以通过把它们的Fallback设置为VertexLit、Diffuse这些不透明物体使用的Unity Shader,这样Unity就会在它的Fallback找到一个阴影投射的Pass。然后,我们可以通过物体的Mesh Renderer组件上的Cast Shadows 和Reeive Shadows选项来控制是否需要向其他物体投射或接收阴影。下图显示了把Fallback 设为VertexLit并开启阴影投射和接收阴影后的半透明物体的渲染效果。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引