第9章 更复杂的光照

从本章开始,我们就进入了中级篇的学习。在初级篇中,我们对实现的Unity Shader 中的每一行代码都进行了详细解释。我们相信通过初级篇的学习,读者已经对Shader 的基本语法有了一定了解,因此在中级篇以及之后的篇节,我们不再列出Unity Shader 中的每一行代码,而是选择其中的关键代码进行解释。读者可以在本书资源中找到完整的实现。需要注意的是,本章实现的代码太多是为了阐述一些计算的实现原理,并不可以直接用于项目中。我们会在9.5 节给出包含了完整光照计算的 Unity Shader.
在前面的学习中,我们的场景中都仅有一个光源且光源类型是平行光(如果你的场最不是这样的话,可能会得到错误的结果)。但在实际的游戏开发过程中,我们往往需要处理数目更多、类型更复杂的光源。更重要的是,我们想要得到阴影。在本章我们就会学习如何在Unity 中实现上面的功能。

在学习这些之前,我们有必要知道Unity 到底是如何处理这些光源的。也就是说,当我们在场景里放置了各种类型的光源后, Unity 的底层渲染引擎是如何让我们在Shader 中访问到它们的,

因此9.1 节首先介绍了Unity 的渲染路径。

之后,我们将在9.2 节中学习如何处理更多不同类型的光源,如点光源和聚光灯。

9.3 节将介绍如何在Unity Shader 中处理光照衰减,实现距离光源越远光强越弱的效果。

在9.4 节,我们将介绍Unity 中阴影的实现方法,并学习在Unity Shader 中如何为不同类型的物体实现阴影效果。

最后,我们会在 9.5 节给出本书使用的标准的Unity Shader,这些Unity Shader 包含了完整的光照计算,本书后面的章节中也会使用这些Shader 进行场景搭建。

9.1 Unity的渲染路径

在Unity 里, 渲染路径( Rendering Path ) 决定了光照是如何应用到Unity Shader 中的。因此,如果要和光源打交道,我们需要为每个Pass 指定它使用的渲染路径,只有这样才能让Unity 知道,“哦,原来这个程序员想要这种渲染路径,那么好的,我把光源和处理后的光照信息都放在这些数据里,你可以访问啦!”也就是说,我们只有为Shader 正确地选择和设置了需要的渲染路径,该
Shader 的光照计算才能被正确执行。
Unity 支持多种类型的渲染路径。在Unity 5.0 版本之前, 主要有3 种:前向渲染路径(Forward Rendering Path ),延迟渲染路径( Deferred Rendering Path ) 和顶点照明渲染路径(Vertex Lit Rendering Path )。但在Unity 5.0 版本以后, Unity 做了很多更改,主要有两个变化:首先,顶点照明渲染路径已经被Unity 抛弃〈但目前仍然可以对之前使用了顶点照明渲染路径的Unity Shader兼容〉;其次,新的延迟渲染路径代替了原来的延迟渲染路径(同样,目前也提供了对较旧版本的兼容〉。
大多数情况下, 一个项目只使用一种渲染路径,因此我们可以为整个项目设置渲染时的渲染路径。我们可以

通过在Unity 的Edit → Project Settings → Player → Other Settings → Rendering Path 中选择项目所需的渲染路径。默认情况下,该设置选择的是前向渲染路径,如图9.1 所示。

第9章 更复杂的光照_第1张图片

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

第9章 更复杂的光照_第2张图片

在上面的设置中,如果选择了Use Player Settings,那么这个摄像机会使用Project Settings 中的设置: 否则就会覆盖掉

Project Settings 中的设置。需要注意的是,如果当前的显卡并不支持所选择的渲染路径,Unity 会自动使用更低一级的渲染路径。例如,如果一个GPU 不支持延迟渲染,那么Unity 就会使用前向渲染。
完成了上面的设置后,我们就可以在每个Pass 中使用标签来指定该Pass 使用的渲染路径。这是通过设置Pass 的 LightMode 标签实现的。不同类型的渲染路径可能会包含多种标签设置。
例如,在们之前在代码中写的:

Pass {
	Tags { "LightMode"="ForwardBase" }
上面的代码将告诉Unity, 该Pass 使用前向渲染路径中的 ForwardBase路径。而前向渲染路径还有一种路径叫做 ForwardAdd。表9.1 给出了Pass 的LightMode 标签支持的渲染路径设置选项。

第9章 更复杂的光照_第3张图片

那么指定渲染路径到底有什么用呢?如果一个Pass 没有指定任何渲染路径会有什么问题吗?通俗来讲,指定渲染路径是我们和Unity 的底层渲染引擎的一次重要的沟通。例如,如果我们为一个Pass 设置了前向渲染路径的标签,相当于会告诉Unity :“ 嘿,我准备使用前向渲染了,你把那些光照属性都按前向渲染的流程给我准备好,我一会儿要用!”随后,我们可以通过Unity提供的内置光照变量来访问这些属性。如果我们没有指定任何渲染路径(实际上,在Unity 5.x 版本中如果使用了前向渲染又没有为Pass 指定任何前向渲染适合的标签,就会被当成一个和顶点照明渲染路径等同的Pass ),那么一些光照变量很可能不会被正确赋值,我们计算出的效果也就很有可能是错误的。
那么,Unity 的渲染引擎是如何处理这些渲染路径的呢?下面,我们会对这些渲染路径进行更加详细的解释。

9.1.1 前向渲染路径

前向渲染路径是传统的渲染方式,也是我们最常用的一种渲染路径。在本节,我们首先会概括前向渲染路径的原理,然后再给出Unity 对于前向渲染路径的实现细节和要求,最后给出Unity Shader 中哪些内置变量是用于前向渲染路径的。
1 . 前向渲染路径的原理
每进行一次完整的前向渲染,我们需要渲染该对象的渲染图元,并计算两个缓冲区的信息:一个是颜色缓冲区, 一个是深度缓冲区。我们利用深度缓冲来决定一个片元是否可见,如果可见就更新颜色缓冲区中的颜色值。我们可以用下面的伪代码来描述前向渲染路径的大致过程:

第9章 更复杂的光照_第4张图片

对于每个逐像素光源,我们都需要进行上面一次完整的渲染流程。如果一个物体在多个逐像素光源的影响区域内,那么该物体就需要执行多个Pass,每个Pass 计算一个逐像素光源的光照结果,然后在帧缓冲中把这些光照结果混合起来得到最终的颜色值。假设,场景中有N个物体,每个物体受M 个光源的影响,那么要渲染整个场景一共需要 N*M 个Pass 。可以看出,如果有大量逐像素光照, 那么需要执行的Pass 数目也会很大。因此,渲染引擎通常会限制每个物体的逐像素光照的数目。


2. Unity 中的前向渲染
事实上,一个Pass 不仅仅可以用来计算逐像素光照,它也可以用来计算逐顶点等其他光照。这取决于光照计算所处流水线阶段以及计算时使用的数学模型。当我们渲染一个物体时,Unity会计算哪些光源照亮了它,以及这些光源照亮该物体的方式。
在Unity 中,前向渲染路径有3 种处理光照〈即照亮物体)的方式: 逐顶点处理、逐像素处理,球谐函数( Spherical Harmonics, SH )处理。而决定一个光源使用哪种处理模式取决于它的类型和渲染模式。光源类型指的是该光源是平行光还是其他类型的光源,而光源的渲染模式指的是该光源是否是重要的(Important )。如果我们把一个光照的模式设置为Important , 意味着我们告诉Unity,“嘿老兄,这个光源很重要,我希望你可以认真对待它,把它当成一个逐像素光源来处理!”我们可以在光源的Light 组件中设置这些属性,如图 9.3 所示。

第9章 更复杂的光照_第5张图片

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

  • 场景中最亮的平行光总是按逐像素处理的。
  • 渲染模式被设置成Not Important 的光源,会按逐顶点或者SH 处理。
  • 渲染模式被设置成Important 的光源,会按逐像素处理。
  • 如果根据以上规则得到的逐像素光源数量小于Quality Setting 中的逐像素光源数量(Pixel Light Count),会有更多的光源以逐像素的方式进行渲染。

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

第9章 更复杂的光照_第6张图片

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

  • 首先,可以发现在渲染设置中,我们除了设置了Pass 的标签外,还使用了#pragma multi_compile_fwdbase 这样的编译指令。虽然#pragma multi_compile_fwdbase 和 # pragma multi_compile_fwdadd 在官方文档中还没有给出相关说明,但实验表明,只有分别为Bass Pass 和Additional Pass 使用这两个编译指令,我们才可以在相关的Pass 中得到一些正确的光照变量,例如光照衰减值等。
  • Base Pass 旁边的注释给出了Base Pass 中支持的一些光照特性。例如在Base Pass 中,我们可以访问光照纹理( lightmap )。
  • Base Pass 中渲染的平行光默认是支持阴影的(如果开启了光源的阴影功能〉,而Additional Pass 中渲染的光源在默认情况下是没有阴影效果的,即便我们在它的Light 组件中设置了有阴影的Shadow Type。但我们可以在Additional Pass 中使用#pragma multi_compile_fwdadd_ fullshadows 代替 #pragma multi_compile_fwdadd 编译指令,为点光源和聚光灯开启阴影效果,但这需要Unity 在内部使用更多的Shader 变种。
  •  环境光和自发光也是在Base Pass 中计算的。这是因为,对于一个物体来说,环境光和自发光我们只希望计算一次即可,而如果我们在Additional Pass 中计算这两种光照,就会造成叠加多次环境光和自发光,这不是我们想要的。
  • 在Additional Pass 的渲染设置中,我们还开启和设置了混合模式。这是因为,我们希望每个Additional Pass 可以与上一次的光照结果在帧缓存中进行叠加,从而得到最终的有多个光照的渲染效果。如果我们没有开启和设置混合模式,那么Additional Pass 的渲染结果会覆盖掉之前的渲染结果, 看起来就好像该物体只受该光源的影响。通常情况下,我们选择的混合模式是Blend One One
  • 对于前向渲染来说,一个Unity Shader 通常会定义一个Base Pass ( Base Pass 也可以定义多次,例如需要双面渲染等情况〉以及一个Additional Pass。一个Base Pass 仅会执行一次(定义了多个Base Pass 的情况除外),而一个Additional Pass 会根据影响该物体的其他逐像素光源的数目被多次调用,即每个逐像素光源会执行一次Additional Pass 。

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

3. 内置的光照变量和函数

前面说过,根据我们使用的渲染路径(即Pass 标签中LightMode 的值) , Unity 会把不同的光照变量传递给Shader 。
在Unity 5 中,对于前向渲染(即LightMode 为ForwardBase 或ForwardAdd )来说,表9.2给出了我们可以在Shader 中访问到的光照变量。

第9章 更复杂的光照_第7张图片

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

第9章 更复杂的光照_第8张图片

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

9.1.2 顶点照明渲染路径

顶点照明渲染路径是对硬件配置要求最少、运算性能最高,但同时也是得到的效果最差的一种类型,它不支持那些逐像素才能得到的效果,例如阴影、法线映射、高精度的高光反射等。实际上,它仅仅是前向渲染路径的一个子集,也就是说,所有可以在顶点照明渲染路径中实现的功能都可以在前向渲染路径中完成。就如它的名字一样,顶点照明渲染路径只是使用了逐顶点的方式来计算光照,并没有什么神奇的地方。实际上,我们在上面的前向渲染路径中也可以计算一些逐顶点的光源。但如果选择使用顶点照明渲染路径,那么Unity 会只填充那些逐顶点相关的光源变量,意味着我们不可以使用一些逐像素光照变量。
1. Unity 中的顶点照明渲染
顶点照明渲染路径通常在一个Pass 中就可以完成对物体的谊染。在这个Pass 中,我们会计算我们关心的所有光源对该物体的照明,并且这个计算是按逐顶点处理的。这是Unity 中最快速的渲染路径,并且具有最广泛的硬件支持(但是游戏机上并不支持这种路径〉。由于顶点照明渲染路径仅仅是前向渲染路径的一个子集,因此在Unity 5 发布之前, Unity 在论坛上发起了一个投票,让开发者选择是否应该在Unity 5.0 中抛弃顶点照明渲染路径。在这个投票中,很多开发人员表示了赞同的意见。结果是, Unity 5 中将顶点照明渲染路径作为一个遗留的渲染路径,在未来的版本中,顶点照明渲染路径的相关设定可能会被移除。
2. 可访问的内置变量和函数
在Unity 中,我们可以在一个顶点照明的Pass 中最多访问到8 个逐顶点光源。如果我们只需要渲染其中两个光源对物体的照明,可以仅使用表9.4 中内置光照数据的前两个。如果影响该物体的光源数目小于8,那么数组中剩下的光源颜色会设置成黑色。

第9章 更复杂的光照_第9张图片

可以看出,一些变量我们同样可以在前向渲染路径中使用,例如unity_LightColor。但这些变量数组的维度和数值在不同渲染路径中的值是不同的。
表9.5 给出了顶点照明渲染路径中可以使用的内置函数。

第9章 更复杂的光照_第10张图片

9.1.3 延迟渲染路径

前向渲染的问题是: 当场景中包含大量实时光源时,前向渲染的性能会急速下降。例如,如果我们在场景的某一块区域放置了多个光源,这些光源影响的区域互相重叠,那么为了得到最终的光照效果,我们就需要为该区域内的每个物体执行多个Pass 来计算不同光源对该物体的光照结果,然后在颜色缓存中把这些结果混合起来得到最终的光照。然而,每执行一个Pass 我们都需要重新渲染一遍物体,但很多计算实际上是重复的。
延迟渲染是一种更古老的渲染方法,但由于上述前向渲染可能造成的瓶颈问题,近几年又流行起来。除了前向渲染中使用的颜色缓冲和深度缓冲外,延迟渲染还会利用额外的缓冲区,这些缓冲区也被统称为G 缓冲(G-buffer ),其中G 是英文Geometry 的缩写。G 缓冲区存储了我们所关心的表面(通常指的是离摄像机最近的表面〉的其他信息,例如该表面的法线、位置、用于光照计算的材质属性等。
1. 延迟渲染的原理
延迟渲染主要包含了两个Pass。在第一个Pass 中,我们不进行任何光照计算,而是仅仅计算哪些片元是可见的,这主要是通过深度缓冲技术来实现,当发现一个片元是可见的,我们就把它的相关信息存储到G 缓冲区中。然后,在第二个Pass 中,我们利用G 缓冲区的各个片元信息,例如表面法线、视角方向、漫反射系数等,进行真正的光照计算。
延迟渲染的过程大致可以用下面的伪代码来描述:

第9章 更复杂的光照_第11张图片

第9章 更复杂的光照_第12张图片

可以看出,延迟渲染使用的Pass 数目通常就是两个,这跟场景中包含的光源数目是没有关系的。换句话说,延迟渲染的效率不依赖于场景的复杂度,而是和我们使用的屏幕空间的大小有关。
这是因为,我们需要的信息都存储在缓冲区中,而这些缓冲区可以理解成是一张张2D 图像,我们的计算实际上就是在这些图像空间中进行的。
2. Unity 中的延迟渲染
Unity 有两种延迟渲染路径, 一种是遗留的延迟渲染路径,即Unity 5 之前使用的延迟渲染路径,而另一种是Unity5.x 中使用的延迟渲染路径。如果游戏中使用了大量的实时光照,那么我们可能希望远择延迟渲染路径,但这种路径需要一定的硬件支持。
新旧延迟渲染路径之间的差别很小,只是使用了不同的技术来权衡不同的需求。例如,较旧版本的延迟渲染路径不支持Unity 5 的基于物理的Standard Shader。以下我们仅讨论Unity 5 后使用的延迟渲染路径。对于遗留的延迟渲染路径,读者可以在官方文档

( http://docs.unity3d.com/Manual/RenderTech-DeferredLighting.html )找到更多的资料。
对于延迟渲染路径来说,它最适合在场景中光源数目很多、如果使用前向渲染会造成性能瓶颈的情况下使用。而且,延迟渲染路径中的每个光源都可以按逐像素的方式处理。但是,延迟渲染也有一些缺点。

  •  不支持真正的抗锯齿( anti-aliasing )功能。
  •  不能处理半透明物体。
  •  对显卡有一定要求。如果要使用延迟渲染的话,显卡必须支持 MRT ( Multiple Render Targets )、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 (非HDR)或ARGBHalf ( HDR ),用于存储自发光+lightmap+反射探针(reflection probes )。
  •  深度缓冲和模板缓冲。
当在第二个Pass 中计算光照时,默认情况下仅可以使用Unity 内置的Standard 光照模型。如果我们想要使用其他的光照模型,就需要替换掉原有的 lnternal-DeferredShading.shader 文件。更详细的信息可以访问官方文档

( http ://docs.unity3d.com/Manual/RenderTech-DeferrdShading.html )。

3. 可访问的内置变量和函数
表9.6 给出了处理延迟渲染路径可以使用的光照变量。这些变量都可以在 UnityDeferredLibrary.cginc 文件中找到它们的声明。

第9章 更复杂的光照_第13张图片

9.1.4 选择哪种渲染路径

Unity 的官方文档( http://docs.unity3d.com/Manual/RenderingPaths.html )中给出了4 种渲染路径(前向渲染路径、延迟渲染路径、遗留的延迟渲染路径和顶点照明渲染路径〉的详细比较,包括它们的特性比较(是否支持逐像素光照、半透明物体、实时阴影等)、性能比较以及平台支持。
总体来说,我们需要根据游戏发布的目标平台来选择渲染路径。如果当前显卡不支持所选渲染路径, 那么Unity 会自动使用比其低一级的渲染路径。
在本书中,我们主要使用Unity 的前向渲染路径。

9.2 Unity的光源类型

在前面的例子中,我们的场景中都仅仅有一个光源且光源类型是平行光(如果你的场景不是这样的话,可能会得到错误的结果)。只有一个平行光的世界很美好,但美梦总有醒的一天,这时,我们就需要在Unity Shader 中处理更复杂的光源类型以及数目更多的光源。在本节中, 我们将会学习如何在Unity 中处理点光源(point light )聚光灯( spot light )
Unity 一共支持4 种光源类型: 平行光、点光源、聚光灯和面光源(area light )。面光源仅在烘焙时才可发挥作用, 因此不在本节讨论范围内。由于每种光源的几何定义不同,因此它们对应的光源属性也就各不相同。这就要求我们要区别对待它们。幸运的是, Unity 提供了很多内置函数来帮我们处理这些光源, 在本章的最后我们会介绍这些函数,但首先我们需要了解它们背后的原理。

9.2.1 光源类型有什么影响

我们来看一下光源类型的不同到底会给Shader 带来哪些影响。我们可以考虑Shader 中使用了光源的哪些属性。最常使用的光源属性有光源的位置、方向(更具体说就是,到某点的方向〉、颜色、强度以及衰减(更具体说就是,到某点的衰减,与该点到光源的距离有关〉这5 个属性。而这些属性和它们的几何定义息息相关。
1. 平行光
对于我们之前使用的平行光来说,它的几何定义是最简单的。平行光可以照亮的范围是没有限制的,它通常是作为太阳这样的角色在场景中出现的。图9.5 给出了Unity 中平行光在Scene 视图中的表示以及Light 组件的面板。
第9章 更复杂的光照_第14张图片
平行光之所以简单,是因为它没有一个唯一的位置,也就是说,它可以放在场景中的任意位置(回忆一下,我们小时候是不是总感觉太阳跟着我们一起移动〉。它的几何属性只有方向,我们可以调整平行光的Transform 组件中的Rotation 属性来改变它的光源方向,而且平行光到场景中所有点的方向都是一样的,这也是平行光名字的由来。除此之外,由于平行光没有一个具体的位置,因此也没有衰减的概念,也就是说,光照强度不会随着距离而发生改变。
2. 点光源
点光源的照亮空间则是有限的,它是由空间中的一个球体定义的。点光源可以表示由一个点发出的、向所有方向延伸的光。图9.6 给出了Unity 中点光源在Scene 视图中的表示以及Light 组件的面板。
第9章 更复杂的光照_第15张图片
需要提醒读者的一点是,我们需要在Scene 视图中开启光照才能看到预览光源是如何影响场景中的物体的。图9.7 给出了开启Scene 视图光照的按钮。
第9章 更复杂的光照_第16张图片
球体的半径可以由面板中的Range 属性来调整,也可以在Scene 视图中直接拖拉点光源的线框(如球体上的黄色控制点〉来修改它的属性。点光源是有位置属性的,它是由点光源的Transform组件中的Position 属性定义的。对于方向属性,我们需要用点光源的位置减去某点的位置来得到它到该点的方向。而点光源的颜色和强度可以在Light 组件面板中调整。同时,点光源也是会衰减的,随着物体逐渐远离点光源,它接收到的光照强度也会逐渐减小。点光源球心处的光照强度最强,球体边界处的最弱,值为0 。其中间的衰减值可以由一个函数定义。
3. 聚光灯
聚光灯是这3 种光源类型中最复杂的一种。它的照亮空间同样是有限的,但不再是简单的球体,而是由空间中的一块锥形区域定义的。聚光灯可以用于表示由一个特定位置出发、向特定方向延伸的光。图9.8 给出了Unity 中聚光灯在Scene 视图中的表示以及Light 组件的面板。
第9章 更复杂的光照_第17张图片
这块锥形区域的半径由面板中的Range 属性决定,而锥体的张开角度由Spot Angle 属性决定。我们同样也可以在Scene 视图中直接拖拉聚光灯的线框(如中间的黄色控制点以及四周的黄色控制点〉来修改它的属性。聚光灯的位置同样是由Transform 组件中的Position 属性定义的。对于方向属性,我们需要用聚光灯的位置减去某点的位置来得到它到应点的方向。聚光灯的衰减也是随着物体逐渐远离点光源而逐渐减小,在锥形的顶点处光照强度最强,在锥形的边界处强度为0 。其中间的衰减值可以由一个函数定义,这个函数相对于点光源衰减计算公式要更加复杂,因为我们需要判断一个点是否在锥体的范围内。

9.2.2 在前向渲染中处理不同的光源类型

在了解了3 种光源的几何定义后,我们来看一下如何在Unity Shader 中访问它们的5 个属性:位置、方向、颜色、强度以及衰减。需要注意的是,本节均建立在使用前向渲染路径的基础上。
在学习完本节后,我们可以得到类似图9.9 中的效果。
第9章 更复杂的光照_第18张图片
1. 实践
为了实现上述效果,我们首先做如下准备工作。
(1)在Unity 中新建一个场景。在本书资源中,该场景名为Scene_9_2_2_1 。在Unity 5.2 中,默认情况下场景将包含一个摄像机和一个平行光,并且使用了内置的天空盒子。在Window→Lighting → Sky box 中去掉场景中的天空盒子。
( 2)新建一个材质。在本书资源中,该材质名为ForwardRenderingMat。
( 3)新建一个Unity Shader。在本书资源中,该Shader 名为Chapter9-ForwardRendering 。把新的Unity Shader 赋给第2 步中创建的材质。
( 4)在场景中创建一个胶囊体,并把第2 步中的材质赋给该胶囊体。
( 5)为了让物体受多个光源的影响,我们再新建一个点光源,把其颜色设为绿色,以和平行光进行区分。
( 6 )保存场景。
我们编号的代码使用了Blinn-Phong 光照模型,并为前向渲染定义了Base Pass 和Additional
Pass 来处理多个光源。在这里我们只给出其中关键的代码,而省略与之前章节中重复的代码。完
整的代码读者可以在本书资源中找到。关键代码如下。
( 1 )我们首先定义第一个Pass——Base Pass 。为此,我们需要设置该Pass 的渲染路径标签:
	Pass {
		// Pass for ambient light & first pixel light (directional light)
		Tags { "LightMode"="ForwardBase" }
		
		CGPROGRAM
			
		// Apparently need to add this declaration 
		#pragma multi_compile_fwdbase	
需要注意的是,我们除了设置渲染路径外, 还使用了#pragma 编译指令。 #pragma multi_compile_fwdbase指令可以保证我们在Shader 中使用光照衰减等光照变量可以被正确赋值。这是不可缺少的。
( 2)在Base Pass 的片元着色器中,我们首先计算了场景中的环境光:
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
我们希望环境光计算一次即可,因此在后面的Additional Pass 中就不会再计算这个部分。与之类似, 还有物体的自发光, 但在本例中,我们假设胶囊体没有自发光效果。
(3)然后,我们在Base Pass 中处理了场景中的最重要的平行光。在这个例子中,场景中只有一个平行光。如果场景中包含了多个平行光, Unity 会选择最亮的平行光传递给Base Pass 进行逐像素处理, 其他平行光会按照逐顶点或在Additional Pass 中按逐像素的方式处理。如果场景中没有任何平行光, 那么Base Pass 会当成全黑的光源处理。我们提到过,每一个光源有5 个属性: 位置、方向、颜色、强度以及衰减。对于Base Pass 来说,它处理的逐像素光源类型一定是平行光。
我们可以使用 _WorldSpaceLightPos0 来得到这个平行光的方向(位置对平行光来说没有意义),使用 _LightColor0 来得到它的颜色和强度(_LightColor0 已经是颜色和强度相乘后的结果),由于平行光可以认为是没有衰减的,因此这里我们直接令衰减值为1.0 。相关代码如下:
	// Compute diffuse term
	fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * max(0, dot(worldNormal, worldLightDir));
	……
	// Compute specular term
	fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss);

	// The attenuation of directional light is always 1
	fixed atten = 1.0;
				
	return fixed4(ambient + (diffuse + specular) * atten, 1.0);
至此, Base Pass 的工作就完成了。
( 4)接下来,我们需要为场景中其他逐像素光源定义Additional Pass 。为此,我们首先需要设置Pass 的渲染路径标签:
	Pass {
		// Pass for other pixel lights
		Tags { "LightMode"="ForwardAdd" }
			
		Blend One One
		
		CGPROGRAM
			
		// Apparently need to add this declaration
		#pragma multi_compile_fwdadd
除了设置渲染路径标签外,我们同样使用了 #pragma multi_compile_fwdadd 指令,如前面所说,这个指令可以保证我们在Additional Pass 中访问到正确的光照变量。与Base Pass 不同的是,我们还使用Blend 命令开启和设置了混合模式。这是因为,我们希望Additional Pass 计算得到的光照结果可以在帧缓存中与之前的光照结果进行叠加。如果没有使用Blend 命令的话, Additional
Pass 会直接覆盖掉之前的光照结果。在本例中,我们选择的混合系数是 Blend One One,这不是必需的,我们可以设置成Unity 支持的任何混合系数。常见的还有 Blend SrcAlpha One
( 5 )通常来说, Additional Pass 的光照处理和Base Pass 的处理方式是一样的,因此我们只需要把Base Pass 的顶点和片元着色器代码粘贴到Additional Pass 中, 然后再稍微修改一下即可。这些修改往往是为了去掉Base Pass 中环境光、自发光、逐顶点光照、SH 光照的部分,并添加一些对不同光源类型的支持。因此,在Additional Pass 的片元着色器中,我们没有再计算场景中的环
境光。由于Additional Pass 处理的光源类型可能是平行光、点光源或是聚光灯,因此在计算光源的5 个属性一一 位置、方向、颜色、强度以及衰减时,颜色和强度我们仍然可以使用 _LightColor0来得到,但对于位置、方向和衰减属性,我们就需要根据光源类型分别计算。首先,我们来看如何计算不同光源的方向:
	#ifdef USING_DIRECTIONAL_LIGHT
		fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
	#else
		fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos.xyz);
	#endif
在上面的代码中,我们首先判断了当前处理的逐像素光源的类型, 这是通过使用#ifdef 指令判断是否定义了
USING_DIRECTIONAL_LIGHT 来得到的。如果当前前向渲染Pass 处理的光源类型是平行光,那么Unity 的底层渲染引擎就会定义USING_DIRECTIONAL_LIGHT。如果判断得知是平行光的话,光源方向可以直接由 _WorldSpaceLightPos0.xyz 得到;如果是点光源或聚光灯,那么 _WorldSpaceLightPos0.xyz 表示的是世界空间下的光源位置,而想要得到光源方向的话,我们就需要用这个位置减去世界空间下的顶点位置。
( 6 )最后,我们需要处理不同光源的衰减:
	#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
我们同样通过判断是否定义了USING_DIRECTIONAL_LIGHT 来决定当前处理的光源类型。如果是平行光的话,衰减值为1.0。如果是其他光源类型,那么处理更复杂一些。尽管我们可以使用数学表达式来计算给定点相对于点光源和聚光灯的衰减,但这些计算往往涉及开根号、除法等计算量相对较大的操作,因此Unity 选择了使用一张纹理作为查找表( Lookup Table, LUT ),以在片元着色器中得到光源的衰减。我们首先得到光源空间下的坐标,然后使用该坐标对衰减纹理进行采样得到衰减值。关于Unity 中衰减纹理的细节可以参见9.3 节。
我们可以在场景中添加更多的逐像素光源来照亮胶囊体。 需要注意的是,本节只是为了讲解处理其他类型光源的实现原理,上述代码并不会用于真正的项目中,我们会在9.5 节给出包含了完整光照计算的UnityShader.
2. 实验: Base Pass 和Additional Pass 的调用
我们在9.1.1 节中给出了前向渲染中Unity 是如何决定哪些光源是逐像素光,而哪些是逐顶点或SH 光。为了让读者有更加直观的理解,我们可以在Unity 中进行一个实验。实验的准备工作如下。
(1)在Unity 中新建一个场景。在本书资源中,该场景名为Scene_9_2_2_2 。在Unity 5.2 中,默认情况下场景将包含一个摄像机和一个平行光, 并且使用了内置的天空盒子。在Window ->Lighting-> Skybox 中去掉场景中的天空盒子。
( 2 )调整平行光的颜色为绿色。
( 3 )在场景中创建一个胶囊体,并把上一节中的ForwardRenderingMat 材质赋给该胶囊体。
( 4 )新建4 个点光源,调整它们的颜色为相同的红色。
( 5 ) 保存场景。
我们可以得到类似图9.10 中的效果。
第9章 更复杂的光照_第19张图片
那么,这样的结果是怎么来的呢?当我们创建一个光源时,默认情况下它的Render Mode (可以在Light 组件中设置〉是Auto 。这意味着, Unity 会在背后为我们判断哪些光源会按逐像素处理,而哪些接逐顶点或SH 的方式处理。由于我们没有更改
Edit → Project Settings → Quality →Pixel Light Count 中的数值,因此默认情况下一个物体可以接收除最亮的平行光外的4 个逐像素光照。在这个例子中,场景中共包含了5 个光源,其中一个是平行光,它会在Chapter9-ForwardRendering 的Base Pass 中按逐像素的方式被处理;其余4 个都是点光源,由于它们的Render Mode为Auto 且数目正好等于4,因此都会在
Chapter9-ForwardRendering 的Additional Pass 中逐像素的方式被处理,每个光源会调用一次Additional Pass 。
在Unity 5 中,我们还可以使用帧调试器(Frame Debugger ) 工具来查看场景的绘制过程。使用方法是:
 在Window -> Frame Debugger 中打开帧调试器,如图9.11 所示。
第9章 更复杂的光照_第20张图片
从帧调试器中可以看出,渲染这个场景Unity 一共进行了6 个渲染事件,由于本例中只包含了一个物体,因此这6 个渲染事件几乎都是用于渲染该物体的光照结果。我们可以通过依次单击帧调试器中的渲染事件,来查看Unity 是怎样渲染物体的。图 9.12 给出了本例中Unity 进行的 6个渲染事件。
第9章 更复杂的光照_第21张图片
从图9.12 可以看出, Unity 是如何一步步将不同光照渲染到物体上的:在第一个渲染事件中,Unity 首先清除颜色、深度和模板缓冲,为后面的渲染做准备: 在第二个渲染事件中, Unity 利用Chapter9-ForwardRendering 的第一个Pass, 即 Base Pass,将平行光的光照渲染到帧缓存中; 在后面的4 个渲染事件中, Unity 使用Chapter9-ForwardRendering 的第二个Pass, 即 Additional Pass,
依次将4 个点光源的光照应用到物体上,得到最后的渲染结果。
可以注意到, Unity 处理这些点光源的顺序是按照它们的重要度排序的。在这个例子中,由于所有点光源的颜色和强度都相间,因此它们的重要度取决于它们距离胶囊体的远近,因此图9.12中首先绘制的是距离胶囊体最近的点光源。但是,如果光源的强度和颜色互不相同,那么距离就不再是唯一的衡量标准。例如,如果我们把现在距离最近的点光源的强度设为0.2 ,那么从帧调试器中我们可以发现绘制顺序发生了变化,此时首先绘制的是距离胶囊体第二近的点光源,最近的点光源则会在最后被渲染。Unity 官方文档中并没有给出光源强度、颜色和距离物体的远近是如何具体影响光源的重要度排序的,我们仅知道排序结果和这三者都有关系。
对于场景中的一个物体,如果它不在一个光源的光照范围内, Unity 是不会为这个物体调用Pass 来处理这个光源的。我们可以把本例中距离最远的点光源的范围调小,使得胶囊体在它的照亮范围外。此时再查看帧调试器,我们可以发现渲染事件比之前少了一个,如图9.13 所示。同样,如果一个物体不在某个聚光灯的范围内, Unity 也是不会为该物体调用相关的渲染事件的。
第9章 更复杂的光照_第22张图片
我们知道,如果逐像素光源的数目很多的话,该物体的Additional Pass 就会被调用多次,影响性能。我们可以通过把光源的Render Mode 设为Not Important 来告诉Unity,我们不希望把该光源当成逐像素处理。在本例中,我们可以把4 个点光源的Render Mode 都设为Not Important, 可以得到图9.14 中的结果。
第9章 更复杂的光照_第23张图片
由于我们在Chapter9-ForwardRendering 中没有在Bass Pase 中计算逐顶点和SH 光源,因此场景中的4 个点光源实际上不会对物体造成任何影响。同样,如果我们把平行光的Render Mode 也设为Not Important,那么读者l可以猜测一下结果会是什么。没错, 物体就会仅显示环境光的光照结果。
那么,我们如何在前向渲染路径的Base Pass 中计算逐顶点和SH 光呢?我们可以使用9.1.1节 中提到的内置变量和函数来计算这些光源的光照效果。

9.3 Unity的光照衰减

在9.2 节中,我们提到Unity 使用一张纹理作为查找表来在片元着色器中计算逐像素光照的衰减。这样的好处在于,计算衰减不依赖于数学公式的复杂性,我们只要使用一个参数值去纹理中采样即可。但使用纹理查找来计算衰减也有一些弊端。
  •  需要预处理得到采样纹理,而且纹理的大小也会影响衰减的精度。
  •  不直观,同时也不方便,因此一旦把数据存储到查找表中,我们就无法使用其他数学公式来计算衰减。
但由于这种方法可以在一定程度上提升性能,而且得到的效果在大部分情况下都是良好的,因此Unity 默认就是使用这种纹理查找的方式来计算逐像素的点光源和聚光灯的衰减的。

9.3.1 用于光照衰减的纹理

Unity 在内部使用一张名为 _LightTexture0 的纹理来计算光源衰减。需要注意的是,如果我们对该光源使用了cookie,那么衰减查找纹理是 _LightTextureB0,但这里不讨论这种情况。我们通常只关心  _LightTexture0 对角线上的纹理颜色值,这些值表明了在光源空间中不同位置的点的衰减值。例如,(0, 0)点表明了与光源位置重合的点的衰减值,而( 1, 1 )点表明了在光源空间中所关心的距离最远的点的衰减。
为了对 _LightTexture0 纹理来样得到给定点到该光源的衰减值,我们首先需要得到该点在光源空间中的位置,这是通过 _LightMatrix0 变换矩阵得到的。在9.1.1 节中,我们已经知道 _LightMatrix0 可以把顶点从世界空间变换到光源空间。因此,我们只需要把 _LightMatrix0 和世界空间中的顶点坐标相乘即可得到光源空间中的相应位置:
float3 lightCoord = mul(_LightMatrix0, float4(i.worldPos, 1)).xyz;
然后,我们可以使用这个坐标的模的平方对衰减纹理进行采样,得到衰减值:
fixed atten = tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;
可以发现,在上面的代码中,我们使用了光源空间中顶点距离的平方(通过dot 函数来得到)来对纹理采样,之所以没有使用距离值来采样是因为这种方法可以避免开方操作。然后,我们使用宏UNITY_ATTEN_CHANNEL 来得到衰减纹理中衰减值所在的分量,以得到最终的衰减值。

9.3.2 使用数学公式计算衰减

尽管纹理采样的方法可以减少计算衰减时的复杂度,但有时我们希望可以在代码中利用公式来计算光源的衰减。例如,下面的代码可以计算光源的线性衰减:
	float distance = length( _WorldSpaceLightPosO.xyz - i.worldPosition.xyz);
	atten = 1.0 / distance;  // linear attenuation
可惜的是, Unity 没有在文档中给出内置衰减计算的相关说明。尽管我们仍然可以在片元着色器中利用一些数学公式来计算衰减,但由于我们无法在Shader 中通过内置变量得到光源的范围、聚光灯的朝向、张开角度等信息,因此得到的效果往往在有些时候不尽如人意,尤其在物体离开光源的照明范围时会发生突变(这是因为,如果物体不在该光源的照明范围内,Unity 就不会为物体执行一个Additional Pass ) 。当然,我们可以利用脚本将光源的相关信息传递给Shader, 但这样的灵活性很低。我们只能期待未来的版本中Unity 可以完善文档并开放更多的参数给开发者使用。

9.4 Unity的阴影

为了让场景看起来更加真实,具有深度信息,我们通常希望光源可以把一些物体的阴影投射在其他物体上。在本节,我们就来学习如何在Unity 中让一个物体向其他物体投射阴影,以及如何让一个物体接收来自其他物体的阴影。

9.4.1 阴影是如何实现的

我们可以先考虑其实生活中阴影是如何产生的。当一个光源发射的一条光线遇到一个不透明物体时,这条光线就不可以再继续照亮其他物体(这里不考虑光线反射)。因此,这个物体就会向它旁边的物体投射阴影,那些阴影区域的产生是因为光线无法到达这些区域。
在实时渲染中,我们最常使用的是一种名为Shadow Map 的技术。这种技术理解起来非常简单,它会首先把摄像机的位置放在与光源重合的位置上,那么场景中该光源的阴影区域就是那些摄像机看不到的地方。而Unity 就是使用的这种技术。
在前向渲染路径中, 如果场景中最重要的平行光开启了阴影, Unity 就会为该光源计算它的阴影映射纹理( shadowmap )。这张阴影映射纹理本质上也是一张深度图,它记录了从该光源的位置出发、能看到的场景中距离它最近的表面位置(深度信息〉。
那么,在计算阴影映射纹理时,我们如何判定距离它最近的表面位置呢?一种方法是, 先把摄像机放置到光源的位置上,然后按正常的渲染流程,即调用Base Pass 和 Additional Pass 来更新深度信息,得到阴影映射纹理。但这种方法会对性能造成一定的浪费, 因为我们实际上仅仅需要深度信息而己, 而Base Pass 和Addjtional Pass 中往往涉及很多复杂的光照模型计算。因此, Unity选择使用一个额外的Pass 来专门更新光源的阴影映射纹理,这个Pass 就是LightMode 标签被设置为SbadowCaster 的Pass。这个Pass 的渲染目标不是帧缓存,而是阴影映射纹理(或深度纹理〉。
Unity 首先把摄像机放置到光源的位置上,然后调用该Pass,通过对顶点变换后得到光源空间下的位置,并据此来输出深度信息到阴影映射纹理中。因此,当开启了光源的阴影效果后,底层渲染引擎首先会在当前渲染物体的Unity Shader 中找到LightMode 为ShadowCaster 的Pass , 如果没有, 它就会在Fallback 指定的Unity Shader 中继续寻找,如果仍然没有找到, 该物体就无法向其他物体投射阴影〈但它仍然可以接收来自其他物体的阴影)。当找到了一个LightMode 为ShadowCaster 的Pass 后, Unity 会使用该Pass 来更新光源的阴影映射纹理。

在传统的阴影映射纹理的实现中,我们会在正常渲染的Pass 中把顶点位置变换到光源空间下,以得到它在光源空间中的三维位置信息。然后,我们使用xy 分量对阴影映射纹理进行采样,得到阴影映射纹理中该位置的深度信息。如果该深度值小于该顶点的深度值(通常由z 分量得到〉,那么说明该点位于阴影中。但在Unity 5 中, Unity 使用了不同于这种传统的阴影采样技术,即屏幕空间的阴影映射技术( Screenspace Shadow Map )。屏幕空间的阴影映射原本是延迟渲染中产生阴影的方法。需要注意的是,并不是所有的平台Unity 都会使用这种技术。这是因为,屏幕空间的阴影映射需要显卡支持MRT,而有些移动平台不支持这种特性。
当使用了屏幕空间的阴影映射技术时, Unity 首先会通过调用LightMode 为ShadowCaster 的Pass 来得到可投射阴影的光源的阴影映射纹理以及摄像机的深度纹理。然后, 根据光源的阴影映射纹理和摄像机的深度纹理来得到屏幕空间的阴影图。如果摄像机的深度图中记录的表面深度大于转换到阴影映射纹理中的深度值,就说明该表面虽然是可见的,但是却处于该光源的阴影中。通过这样的方式,阴影图就包含了屏幕空间中所有有阴影的区域。如果我们想要一个物体接收来自其他物体的阴影,只需要在Shader 中对阴影图进行采样。由于阴影图是屏幕空间下的, 因此,我们首先需要把表面坐标从模型空间变换到屏幕空间中,然后使用这个坐标对阴影图进行采样即可。
总结一下,一个物体接收来自其他物体的阴影,以及它向其他物体投射阴影是两个过程。
 如果我们想要一个物体接收来自其他物体的阴影,就必须在Shader 中对阴影映射纹理(包括屏幕空间的阴影图)进行采样,把采样结果和最后的光照结果相乘来产生阴影效果。
 如果我们想要一个物体向其他物体投射阴影,就必须把该物体加入到光源的阴影映射纹理的计算中,从而让其他物体在对阴影映射纹理采样时可以得到该物体的相关信息。在Unity中,这个过程是通过为该物体执行LightModeShadowCaster的Pass 来实现的。如果使用了屏幕空间的投影映射技术, Unity 还会使用这个Pass 产生一张摄像机的深度纹理。
在下面的章节中,我们会学习如何在Unity 中实现上面两个过程。

9.4.2 不透明物体的阴影

我们首先进行如下的准备工作。
(1)在Unity 中新建一个场景。在本书资源中,该场景名为Scene_9_4_2。在Unity 5.2 中,默认情况下场景将包含一个摄像机和一个平行光,并且使用了内置的天空盒子。在Window →Lighting → Sky box 中去掉场景中的天空盒子。
(2)新建一个材质。在本书资源中,该材质名为ShadowMat。 我们把9.2 节中的Chapter9-ForwardRendering 赋给它。
(3)在场景中创建一个正方体、两个平面,并把第2 步中的材质赋给正方体,但不改变两个平面的材质(默认情况下,它们会使用内置的Standard 材质)。
(4 )保存场景。
为了让场景中可以产生阴影,我们首先需要让平行光可以收集阴影信息。这需要在光源的Light 组件中开启阴影,如图9.15 所示。
在本例中,我们选择了软阴影( Soft Shadows )。
第9章 更复杂的光照_第24张图片
1. 让物体投射阴影
在Unity 中,我们可以选择是否让一个物体投射或接收阴影。这是通过设置Mesh Renderer组件中的Cast ShadowsReceive Shadows 属性来实现的,如图9.16 所示。
第9章 更复杂的光照_第25张图片
Cast Shadows 可以被设置为开启( On )或关闭( Off) 。如果开启了Cast Shadows属性,那么Unity 就会把该物体加入到光源的阴影映射纹理的计算中,从而让其他物体在对阴影映射纹理采样时可以得到该物体的相关信息。正如之前所说,这个过程是通过为该物体执行LightMode 为ShadowCaster的Pass 来实现的。Receive Shadows 则可以选择是否让物体接收来自其他物体的阴
影。如果没有开启Receive Shadows,那么当我们调用Unity 的内置宏和变量计算阴影(在后面我们会看到如何实现)时,这些宏通过判断该物体没有开启接收阴影的功能,就不会在内部为我们计算阴影。
我们把正方体和两个平面的Cast ShadowsReceive Shadows 都设为开启状态,可以得到图9.17 中的结果。
第9章 更复杂的光照_第26张图片

从图9.17 可以发现,尽管我们没有对正方体使用的Chapter9-ForwardRendering 进行任何更改,但正方体仍然可以向下面的平面投射阴影。一些读者可能会有疑问:“之前不是说Unity 要使用LightModeShadowCaster的Pass 来渲染阴影映射纹理和深度图吗?但是Chapter9- ForwardRendering 中并没有这样一个Pass 啊。”没错,我们在Chapter9-ForwardRendering 的SubShader 只定义了两个Pass—— 一个Base Pass, 一个Additional Pass。那么为什么它还可以投射阴影呢?实际上,秘密就在于

Chapter9-ForwardRendering 中的Fallback 语义:

	FallBack "Specular"
在Chapter9-ForwardRendering 中,我们为它的Fallback 指定了一个用于回调Unity Shader , 即内置的Specular。虽然Specular 本身也没有包含这样一个Pass, 但是由于它的Fallback 调用了VertexLit, 它会继续回调,并最终回调到内置的VertexLit。
我们可以在Unity 内置的着色器里找到它: builtin-shaders-xxx->DefaultResourceExtra->NormalVertexLit.shader.。打开它,我们就可以看到“传说中”的 LigbtModeShadowCaster的Pass 了:

	// Pass to render object as a shadow caster
	Pass {
		Name ”ShadowCaster”
		Tags {”LightMode” = ”ShadowCaster”}

		CG PROGRAM
		#pragma vertex vert
		#pragma fragment frag
		#pragma multi_compile_shadowcaster
		#include ”UnityCG.cginc”
		
		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
	}
上面的代码非常短,尽管有一些宏和指令是我们之前没有遇到过的,但它们的用处实际上就是为了把深度信息写入渲染目标中。在Unity 5 中,这个Pass 的渲染目标可以是光源的阴影映射纹理, 或是摄像机的深度纹理。
如果我们把Chapter9-ForwardRendering 中的Fallback 注释掉,就可以发现正方体不会再向平面投射阴影了。当然,我们可以不依赖Fallback , 而自行在SubShader 中定义自己的 LightModeSbadowCaster的Pass. 这种自定义的Pass 可以让我们更加灵活地控制阴影的产生。但由于这个Pass 的功能通常是可以在多个Unity Shader 间通用的,因此直接Fall back 是一个更加方便的用法。在之前的章节中,我们有时也在Fallback 中使用内置的Diffuse,虽然Diffuse 本身也没有包含这样一个Pass,但是由于它的Fallback 调用了VertexLit,因此Unity 最终还是会找到一个LightMode 为ShadowCaster 的Pass , 从而可以让物体产生阴影。在下面的9.4.2 节中,我们将继续看到 LightModeShadowCaster 的Pass 对产生正确的阴影的重要性。

图9.17 中还有一个有意思的现象,就是右侧的平面并没有向最下面的平面投射阴影,尽管它的Cast Shadows 已经被开启了。在默认情况下,我们在计算光源的阴影映射纹理时会剔除掉物体的背面。但对于内置的平面来说,它只有一个面,因此在本例中当计算阴影映射纹理时,由于右侧的平面在光源空间下没有任何正面(frontface),因此就不会添加到阴影映射纹理中。我们可以将

Cast Shadows 设置为Two Sided 来允许对物体的所有面都计算阴影信息。图9.18 给出了当把右侧平面的Cast Shadows 设置为Two Sided 后的结果。

第9章 更复杂的光照_第27张图片
在本例中,最下面的平面之所以可以接收阴影是因为它使用了内置的Standard Shader,而这 个内置的Shader 进行了接收阴影的相关操作。但由于正方体使用的Chapter9-ForwardRendering 并 没有对阴影进行任何处理,因此它不会显示出右侧平面投射来的阴影。在下一节中,我们将学习 如何让正方体也可以接收阴影。

2. 让物体接收阴影

为了让正方体可以接收阴影,我们首先新建一个Unity Shader,在本书资源中,它的名称为Chapter9-Shadow。我们把

Chapter9-Shadow 赋给正方体使用的材质ShadowMat。删除Chapter9-Shadow 中的代码,把Chapter9-ForwardRendering 的代码复制给它。当然,这样仍然不会有任何阴影出现在正方体上,因此我们需要对代码进行一些更改。
(1)首先,我们在Base Pass 中包含进一个新的内置文件:

#include "AutoLight.cginc"
这是因为, 我们下面计算阴影时所用的宏都是在这个文件中声明的。
(2)我们在顶点着色器的输出结构体v2f 中添加了一个内置宏 SHADOW_COORDS:

	struct v2f {
		float4 pos : SV_POSITION;
		float3 worldNormal : TEXCOORD0;
		float3 worldPos : TEXCOORD1;
		SHADOW_COORDS(2)
	};
这个宏的作用很简单,就是声明一个用于对阴影纹理采样的坐标。需要注意的是, 这个宏的参数需要是下一个可用的插值寄存器的索引值,在上面的例子中就是2 。
(3)然后,我们在顶点着色器返回之前添加另一个内置宏 TRANSFER_SHADOW:

	v2f vert(a2v v) {
	 	v2f o;
		…… 	
	 	// Pass shadow coordinates to pixel shader
		TRANSFER_SHADOW(o);
			 	
	 	return o;
	}
这个宏用于在顶点着色器中计算上一步中声明的阴影纹理坐标。
( 4 )接着,我们在片元着色器中计算阴影值,这同样使用了一个内置宏 SHADOW_ATTENUATION:

	// Use shadow coordinates to sample shadow map
	fixed shadow = SHADOW_ATTENUATION(i);
SHADOW_ COORDS 、TRANSFER_SHADOWSHADOW_ATTENUATION 是计算阴影时的“三剑客”。这些内置宏帮助我们在必要时计算光源的阴影。我们可以在AutoLight.cginc 中找到它们的声明:

第9章 更复杂的光照_第28张图片

上面的代码看起来很多、很复杂, 实际上只是Unity 为了处理不同光源类型、不同平台而定义了多个版本的宏。在前向渲染中, 宏SHADOW_COORDS实际上就是声明了一个名为 _ShadowCoord的阴影纹理坐标变量。而TRANSFER_SHADOW的实现会根据平台不同而有所差异。如果当前平台可以使用屏幕空间的阴影映射技术(通过判断是否定义了UNITY_NO_SCREENSPACE_SHADOWS来得到) , TRANSFER_SHADOW 会调用内置的ComputeScreenPos 函数来计算
_ShadowCoord ;如果该平台不支持屏幕空间的阴影映射技术, 就会使用传统的阴影映射技术,TRANSFER_SHADOW 会把顶点坐标从模型空间变换到光源空间后存储到ShadowCoord 中。然后, SHADOW_ATTENUATION 负责使用ShadowCoord 对相关的纹理进行采样,得到阴影信息。
注意到,上面内置代码的最后定义了在关闭阴影时的处理代码。可以看出, 当关闭了阴影后,SHADOW_COORDS

TRANSFER_SHADOW 实际没有任何作用,而SHADOW_ATTENUATION会直接等同于数值1。
需要读者注意的是, 由于这些宏中会使用上下文变量来进行相关计算, 例如TRANSFER_SHADOW 会使用v.vertex 或a.pos 来计算坐标, 因此为了能够让这些宏正确工作, 我们需要保证自定义的变量名和这些宏中使用的变量名相匹配。我们需要保证: a2f 结构体中的顶点坐标变量名必须是vertex, 顶点着色器的输出结构体v2f必须命名为v,且v2f 中的顶点位置变量必须命名为pos

( 5)在完成了上面的所有操作后, 我们只需要把阴影值shadow 和漫反射以及高光反射颜色相乘即可。
保存文件, 返回Unity 我们可以发现,现在正方体也可以接收来自右侧平面的阴影了,如图9.19所示。

第9章 更复杂的光照_第29张图片
需要注意的是, 在上面的代码里我们只更改了Base Pass 中的代码, 使其可以得到阴影效果, 而没有对Additional Pass 进行任何更改。大体上,Additional Pass 的阴影处理和Base Pass 是一样的。
我们将在9.4.4 节看到如何处理这些阴影。本节实现的代码仅是为了解释如何让物体接收阴影, 但不可以直接应用到项目中。我们会在9.5 节中给出包含了完整的光照处理的Unity Shader。

9.4.3 使用帧调试器查看阴影绘制过程

尽管我们在上面描述了阴影的产生过程, 但如果有直观的方式看到阴影一步步的绘制过程那就太好了! 幸运的是, Unity 5 添加了一个新的工具一一帧调试器。我们曾在9.2.2 节中利用它查看过Pass 的绘制过程, 在本节我们会通过它来查看阴影的绘制过程。
首先,我们需要在Window -> Frame Debugger 中打开帧调试器。图9.20 给出了Scene_9_4_2在帧调试器中的分析结果。

第9章 更复杂的光照_第30张图片
从图9.20 中可以看出, 绘制该场景共需要花费20 个渲染事件。这些渲染事件可以分为4 个部分: UpdateDepthTexture , 即更新摄像机的深度纹理: RenderShadowmap , 即渲染得到平行光的阴影映射纹理: CollectShadows , 即根据深度纹理和阴影映射纹理得到屏幕空间的阴影图; 最后绘制渲染结果。

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

第9章 更复杂的光照_第31张图片

从帧调试器右侧的面板我们可以了解这一渲染事件的详细信息。在图9.2.1 中,我们可以发现,Unity 调用了

Shader: Unity Shader Book/Chapter9 Shadow pass #3 来更新深度纹理,即Chapter9-Shadow 中的第三个Pass 。尽管Chapter9-Shadow 中只定义了两个Pass,但正如我们之前所说, Unity 会在它的Fallback 中找到第三个Pass ,即LightModeShadowCaster 的Pass 来更新摄像机的深度纹理。同样,在第二个部分,即渲染得到平行光的阴影映射纹理的过程中, Unity
也是调用了这个Pass 来得到光源的阴影映射纹理。
在第三个部分中, Unity 会根据之前两步的结果得到屏幕空间的阴影图,如图9.22 所示。

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

第9章 更复杂的光照_第33张图片

9.4.4 统一管理光照衰减和阴影

在9.2 节和9.3 节中,我们己经讲过如何在Unity Shader 的前向渲染路径中计算光照衰减一一在Base Pass 中,平行光的衰减因子总是等于1,而在Additional Pass 中,我们需要判断该Pass 处理的光源类型,再使用内置变量和宏计算衰减因子。实际上,光照衰减和阴影对物体最终的渲染结果的影响本质上是相同的一一我们都是把光照衰减因子和阴影值及光照结果相乘得到最终的渲染结果。那么,是不是可以有一个方法可以同时计算两个信息呢?好消息是, Unity 在Shader 里提供了这样的功能,这主要是通过内置的UNITY_LIGHT_ATTENUATION 宏来实现的。
为此,我们做如下准备工作。
(1)复制9.4.2 节中同样的场景,在本书资源中该场景名为Scene_9_4_4 。
( 2 )新建一个材质。在本书资源中,该材质名为AttenuationAndShadowUseBuildlnFunctionsMat。
( 3 )新建一个Unity Shader。在本书资源中,该Shader 名为Chapter9-AttenuationAndShadowUseBuildlnFunctions。把新的Shader 赋给第2 步中创建的材质。
( 4 )把第2 步中的材质赋给一个正方体。
( 5 〕保存场景。
打开Chapter9-AttenuationAndShadowUseBuildlnFunctions ,把Chapter9-Shadow 中的代码粘贴进去。尽管Chapter9-Shadow 中的代码可以让我们得到正确的阴影,但在实践中我们通常会使用Unity 的内置宏和函数来计算衰减和阴影, 从而隐藏一些实现细节。关键代码如下。
( 1 )首先包含进需要的头文件。

	// Need these files to get built-in macros
	#include "Lighting.cginc"
	#include "AutoLight.cginc"
( 2 )在v2f 结构体中使用内置宏 SHADOW_COORDS声明阴影坐标:

	struct v2f {
		float4 pos : SV_POSITION;
		float3 worldNormal : TEXCOORD0;
		float3 worldPos : TEXCOORD1;
		SHADOW_COORDS(2)
	};
(3 )在顶点着色器中使用内置宏 TRANSFER_SHADOW计算并向片元着色器传递阴影坐标:

	v2f vert(a2v v) {
	 	v2f o;
		…… 	
	 	// Pass shadow coordinates to pixel shader
		TRANSFER_SHADOW(o);
			 	
	 	return o;
	}
( 4 )和9.4.2 节中的方式不同, 这次我们在片元着色器中使用内置宏 UNITY_LIGHT_ATTENUATION来计算光照衰减和阴影:

	fixed4 frag(v2f i) : SV_Target {
		……
		// UNITY_LIGHT_ATTENUATION not only compute attenuation, but also shadow infos
		UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
				
		return fixed4(ambient + (diffuse + specular) * atten, 1.0);
	}
UNITY_LIGHT_ATTENUATION是Unity 内置的用于计算光照衰减和阴影的宏,我们可以在内置的AutoLight.cginc 里找到它的相关声明。它接受3 个参数, 它会将光照衰减和阴影值相乘后的结果存储到第一个参数中。注意到,我们并没有在代码中声明第一个参数 atten,这是因为 UNITY_LIGHT_ATTENUATION会帮我们声明这个变量。它的第二个参数是结构体v2f,这个参数会传递给9.4.2 节中使用的 SHADOW_ATTENUATION , 用来计算阴影值。而第三个参数是世界空间的坐标, 正如我们在9.3 节中看到的一样, 这个参数会用于计算光源空间下的坐标, 再对光照衰减纹理采样来得到光照衰减。我们强烈建议读者查阅AutoLight.cginc 中 UNlTY_LIGHT_ATTENUATION的声明,读者可以发现, Unity 针对不同光源类型、是否启用cookie 等不同情况声明了多个版本的 UNITY_LIGHT_ATTENUATION。 这些不同版本的声明是保证我们可以通过这样一个简单的代码来得到正确结果的关键。
( 5 )由于使用了 UNITY_LIGHT_ATTENUATION ,我们的Base Pass 和Additional Pass 的代码得以统一 一一我们不需要在Base Pass 里单独处理阴影,也不需要在Additional Pass 中判断光源类型来处理光照衰减, 一切都只需要通过 UNITY_LIGHT_ATTENUATION来完成即可。这正是Unity 内置文件的魅力所在。如果我们希望可以在Additional Pass 中添加阴影效果,就需要使用 #pragma multi_compile_fwdadd_fullshadows 编译指令来代替Additional Pass 中的

#pragma multi_compile_fwdadd 指令。这样一来, Unity 也会为这些额外的逐像素光源计算阴影,并传递给Shader。

9.4.5 透明度物体的阴影

我们从一开始就强调,想要在Unity 里让物体能够向其他物体投射阴影, 一定要在它使用的Unity Shader 中提供一个LightMode ShadowCaster 的Pass。在前面的例子中,我们使用内置的VertexLit 中提供的ShadowCaster 来投射阴影。VertexLit 中的ShadowCaster 实现很简单,它会正常渲染整个物体,然后把深度结果输出到一张深度图或阴影映射纹理中。读者可以在内置文件中找到相关的文件。
对于大多数不透明物体来说,把Fallback 设为VertexLit 就可以得到正确的阴影。但对于透明物体来说,我们就需要小心处理它的阴影。透明物体的实现通常会使用透明度测试或透明度混合,我们需要小心设置这些物体的Fallback。
透明度测试的处理比较简单,但如果我们仍然直接使用VertexLit、Diffuse、Specular 等作为回调,往往无法得到正确的阴影。这是因为透明度测试需要在片元着色器中舍弃某些片元, 而VertexLit 中的阴影投射纹理并没有进行这样的操作。我们在本书资源的Scene_9_4_5_a 中提供了这样一个测试场景。我们使用了之前学习的透明度测试+阴影的方法来渲染一个正方体,它使用的材质和Unity Shader 分别是AlphaTestWithShadowMat 和Chapter9-AlphaTestWithShadow 。Chapter9-AlphaTestWithShadow 使用了和8.3 节透明度测试中几乎完全相同的代码, 只是添加了关于阴影的计算。
( 1) 首先包含进需要的头文件:

	#include "Lighting.cginc"
	#include "AutoLight.cginc"
( 2 ) 在v2f 中使用内置宏SHADOW_COORDS 声明阴影纹理坐标:

	struct v2f {
		float4 pos : SV_POSITION;
		float3 worldNormal : TEXCOORD0;
		float3 worldPos : TEXCOORD1;
		float2 uv : TEXCOORD2;
		SHADOW_COORDS(3)
	};
注意到,由于我们已经占用了3 个插值寄存器(使用TEXCOORDO 、TEXCOORD1 和TEXCOORD2 修饰的变量〉,因此 SHADOW_COORDS 中传入的参数是3 , 这意味着, 阴影纹理坐标将占用第四个插值寄存器TEXCOORD3.
( 3 )然后,在顶点着色器中使用内置宏 TRANSFER_SHADOW 计算阴影纹理坐标后传递给片元着色器:

    v2f vert(a2v v) {
	 	v2f o;
		…… 	
	 	// Pass shadow coordinates to pixel shader
	 	TRANSFER_SHADOW(o);
			 	
	 	return o;
	}
( 4 )在片元着色器中,使用内置宏 UNITY_LIGHT_ATTENUATION 计算阴影和光照衷减:

	fixed4 frag(v2f i) : SV_Target {
		……
							 	
	 	// 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);
	}
( 5 )这次,我们更改它的Fallback , 使用VertexLit 作为它的回调Shader:

FallBack "VertexLit"
我们仍然使用transparent_texture.psd 纹理,把它赋给新的材质后,就可以得到类似图9.24 中的效果。

第9章 更复杂的光照_第34张图片
细心的读者可以发现,镂空区域出现了不正常的阴影, 看起来就像这个正方体是一个普通的正方体一样。而这并不是我们想要得到的,我们希望有些光应该是可以通过这些楼空区域透过来的,这些区域不应该有阴影。出现这样的情况是因为,我们使用的是内置的VertexLit 中提供的ShadowCaster 来投射阴影, 而这个Pass 中并没有进行任何透明度测试的计算, 因此,它会把整个物体的深度信息渲染到深度图和阴影映射纹理中。因此,如果我们想要得到经过透明度测试后的阴影效果, 就需要提供一个有透明度测试功能的ShadowCaster Pass 。当然, 我们可以自行编写一个这样的Pass , 但这里我们仍然选择使用内置的Unity Shader 来减少代码量。
为了让使用透明度测试的物体得到正确的阴影效果,我们只需要在Unity Shader 中更改一行代码,即把Fallback 设置为Transparent/Cutout/VertexLit,正如我们在8.3 节中实现的一样。读者可以在内置文件中找到该Unity Shader 的代码, 它的ShadowCaster Pass 也计算了透明度测试,因此会把裁剪后的物体深度信息写入深度图和阴影映射纹理中。但需要注意的是, 由于
Transparent/Cutout/VertexLit 中计算透明度测试时, 使用了名为 _Cutoff 的属性来进行透明度测试,因此, 这要求我们的Shader 中也必须提供名为 _Cutoff 的属性。否则, 同样无法得到正确的阴影结果。
在更改了Fallback 后,我们可以得到图9.25 中的效果。

第9章 更复杂的光照_第35张图片
但是, 这样的结果仍然有一些问题,例如出现了一些不应该透过光的部分。出现这种情况的原因是,默认情况下把物体渲染到深度图和阴影映射纹理中仅考虑物体的正面。但对于本例的正方体来说,由于一些面完全背对光源,因此这些面的深度信息没有加入到阴影映射纹理的计算中。
为了得到正确的结果,我们可以将正方体的Mesh Renderer 组件中的Cast Shadows 属性设置为Two Sided, 强制Unity 在计算阴影映射纹理时计算所有面的深度信息。图9.26 给出了正确设置后的渲染结果。
第9章 更复杂的光照_第36张图片
与透明度测试的物体相比,想要为使用透明度混合的物体添加阴影是一件比较复杂的事情。事实上,所有内置的透明度混合的Unity Shader,如Transparent/VertexLit 等,都没有包含阴影投射的Pass。这意味着,这些半透明物体不会参与深度图和阴影映射纹型的计算,也就是说,它们不会向其他物体投射阴影,同样它们也不会接收来自其他物体的阴影。我们在本书资源的Scene_9_4_5_b 中提供了这样一个测试场景。我们使用了之前学习的透明度混合+阴影的方法来渲染一个正方体,它使用的材质和Unity Shader 分别是AlphaBlendWithShadowMat 和Chapter9-AlphaBlendWithShadow 。Chapter9-AlphaBlendWithShadow 使用了和8.4 节透明度混合中几乎完全相同的代码,只是添加了关于阴影的计算,并且它的Fallback 是内置的TransparenνVertexLit. 图9.27 显示了渲染果。

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

第9章 更复杂的光照_第38张图片

可以看出,此时右侧平面的阴影投射到了半透明的立方体上,但它不会再穿透立方体把阴影投射到下方的平面上,这其实是不正确的。同时,立方体也可以把自身的阴影投射到下面的平面上。

9.5 本书使用的标准Unity Shader

到了实现诺言的时候了!我们在之前的实现中一直强调,这些代码仅仅是为了阐述Unity 中的各种光照实现原理,由于缺少一些光照计算,因此不可以直接使用到项目中。截止到本节,我们已经学习了Unity 中所有的基础光照计算,如多光源、阴影和光照衰减等。现在是时候把它们整合到一起来实现一个标准光照着色器了!我们在本书资源的Assets/ Shaders/Common 文件夹下提供了两个这样标准的Unity Shader——BumpedDiffuse 和BumpedSpecular。 这两个Unity Shader都包含了对法线纹理、多光源、光照衰减和阴影的相关处理,唯一不同的是, BumpedDiffuse 使用了Phong 光照模型,而BumpedSpecular 使用了Blinn-Phong 光照模型。读者可以打开这两个文件,此时可以发现里面的代码都是我们学习过的。我们使用这两个Unity Shader 创建了多个材质
(在Assets/Material/Objects 和Assets/Material/WalIs 文件夹下),这些材质将被用于后面章节的场景搭建中。读者可以参考这两个Unity Shader 来实现透明版本的Unity Shader。















你可能感兴趣的:(读书笔记之《Unity,Shader入门精要》,Unity,Shader入门精要)