在学习这些之前,我们有必要知道Unity 到底是如何处理这些光源的。也就是说,当我们在场景里放置了各种类型的光源后, Unity 的底层渲染引擎是如何让我们在Shader 中访问到它们的,
因此9.1 节首先介绍了Unity 的渲染路径。
之后,我们将在9.2 节中学习如何处理更多不同类型的光源,如点光源和聚光灯。
9.3 节将介绍如何在Unity Shader 中处理光照衰减,实现距离光源越远光强越弱的效果。
在9.4 节,我们将介绍Unity 中阴影的实现方法,并学习在Unity Shader 中如何为不同类型的物体实现阴影效果。
最后,我们会在 9.5 节给出本书使用的标准的Unity Shader,这些Unity Shader 包含了完整的光照计算,本书后面的章节中也会使用这些Shader 进行场景搭建。
在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 所示。
但有时,我们希望可以使用多个渲染路径,例如摄像机A 渲染的物体使用前向渲染路径,而摄像机B 渲染的物体使用延迟渲染路径。这时,我们可以在每个摄像机的渲染路径设置中设置该摄像机使用的渲染路径,以覆盖Project Settings 中的设置,如图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 标签支持的渲染路径设置选项。
那么指定渲染路径到底有什么用呢?如果一个Pass 没有指定任何渲染路径会有什么问题吗?通俗来讲,指定渲染路径是我们和Unity 的底层渲染引擎的一次重要的沟通。例如,如果我们为一个Pass 设置了前向渲染路径的标签,相当于会告诉Unity :“ 嘿,我准备使用前向渲染了,你把那些光照属性都按前向渲染的流程给我准备好,我一会儿要用!”随后,我们可以通过Unity提供的内置光照变量来访问这些属性。如果我们没有指定任何渲染路径(实际上,在Unity 5.x 版本中如果使用了前向渲染又没有为Pass 指定任何前向渲染适合的标签,就会被当成一个和顶点照明渲染路径等同的Pass ),那么一些光照变量很可能不会被正确赋值,我们计算出的效果也就很有可能是错误的。
那么,Unity 的渲染引擎是如何处理这些渲染路径的呢?下面,我们会对这些渲染路径进行更加详细的解释。
对于每个逐像素光源,我们都需要进行上面一次完整的渲染流程。如果一个物体在多个逐像素光源的影响区域内,那么该物体就需要执行多个Pass,每个Pass 计算一个逐像素光源的光照结果,然后在帧缓冲中把这些光照结果混合起来得到最终的颜色值。假设,场景中有N个物体,每个物体受M 个光源的影响,那么要渲染整个场景一共需要 N*M 个Pass 。可以看出,如果有大量逐像素光照, 那么需要执行的Pass 数目也会很大。因此,渲染引擎通常会限制每个物体的逐像素光照的数目。
2. Unity 中的前向渲染
事实上,一个Pass 不仅仅可以用来计算逐像素光照,它也可以用来计算逐顶点等其他光照。这取决于光照计算所处流水线阶段以及计算时使用的数学模型。当我们渲染一个物体时,Unity会计算哪些光源照亮了它,以及这些光源照亮该物体的方式。
在Unity 中,前向渲染路径有3 种处理光照〈即照亮物体)的方式: 逐顶点处理、逐像素处理,球谐函数( Spherical Harmonics, SH )处理。而决定一个光源使用哪种处理模式取决于它的类型和渲染模式。光源类型指的是该光源是平行光还是其他类型的光源,而光源的渲染模式指的是该光源是否是重要的(Important )。如果我们把一个光照的模式设置为Important , 意味着我们告诉Unity,“嘿老兄,这个光源很重要,我希望你可以认真对待它,把它当成一个逐像素光源来处理!”我们可以在光源的Light 组件中设置这些属性,如图 9.3 所示。
在前向渲染中,当我们渲染一个物体时, Unity 会根据场景中各个光源的设置以及这些光源对物体的影响程度(例如,距离该物体的远近、光源强度等〉对这些光源进行一个重要度排序。其中, 一定数目的光源会按逐像素的方式处理,然后最多有4 个光源按逐顶点的方式处理,剩下的光源可以按SH 方式处理。Unity 使用的判断规则如下:
那么,在哪里进行光照计算呢?当然是在Pass 里。前面提到过, 前向渲染有两种Pass: Base Pass 和Additional Pass。通常来说,这两种Pass 进行的标签和渲染设置以及常规光照计算如图9.4所示。
图9.4 中有几点需要说明的地方。
图9.4 给出的光照计算是通常情况下我们在每种Pass 中进行的计算。实际上,渲染路径的设置用于告诉Unity 该Pass 在前向渲染路径中的位置,然后底层的渲染引擎会进行相关计算并填充一些内置变量(如 _LightColor0 等),如何使用这些内置变量进行计算完全取决于开发者的选择。例如,我们完全可以利用Unity 提供的内置变量在Base Pass 中只进行逐顶点光照;同样,我们也完全可以在Additional Pass 中按逐顶点的方式进行光照计算, 不进行任何逐像素光照计算。
3. 内置的光照变量和函数
前面说过,根据我们使用的渲染路径(即Pass 标签中LightMode 的值) , Unity 会把不同的光照变量传递给Shader 。
在Unity 5 中,对于前向渲染(即LightMode 为ForwardBase 或ForwardAdd )来说,表9.2给出了我们可以在Shader 中访问到的光照变量。
我们在6.6 节中已经给出了一些可以用于前向渲染路径的函数,例如WorldSpaceLightDir、UnjtyWorldSpaceLightD让和ObjSpaceLightDir。为了完整性,我们在表9.3 中再次列出了前向渲染中可以使用的内置光照函数。
需要说明的是,上面给出的变量和函数并不是完整的, 一些前向渲染可以使用的内置变量和函数官方文档中并没有给出说明。在后面的学习中,我们会使用到一些不在这些表中的变量和函数,那时我们会特别说明的。
顶点照明渲染路径是对硬件配置要求最少、运算性能最高,但同时也是得到的效果最差的一种类型,它不支持那些逐像素才能得到的效果,例如阴影、法线映射、高精度的高光反射等。实际上,它仅仅是前向渲染路径的一个子集,也就是说,所有可以在顶点照明渲染路径中实现的功能都可以在前向渲染路径中完成。就如它的名字一样,顶点照明渲染路径只是使用了逐顶点的方式来计算光照,并没有什么神奇的地方。实际上,我们在上面的前向渲染路径中也可以计算一些逐顶点的光源。但如果选择使用顶点照明渲染路径,那么Unity 会只填充那些逐顶点相关的光源变量,意味着我们不可以使用一些逐像素光照变量。
1. Unity 中的顶点照明渲染
顶点照明渲染路径通常在一个Pass 中就可以完成对物体的谊染。在这个Pass 中,我们会计算我们关心的所有光源对该物体的照明,并且这个计算是按逐顶点处理的。这是Unity 中最快速的渲染路径,并且具有最广泛的硬件支持(但是游戏机上并不支持这种路径〉。由于顶点照明渲染路径仅仅是前向渲染路径的一个子集,因此在Unity 5 发布之前, Unity 在论坛上发起了一个投票,让开发者选择是否应该在Unity 5.0 中抛弃顶点照明渲染路径。在这个投票中,很多开发人员表示了赞同的意见。结果是, Unity 5 中将顶点照明渲染路径作为一个遗留的渲染路径,在未来的版本中,顶点照明渲染路径的相关设定可能会被移除。
2. 可访问的内置变量和函数
在Unity 中,我们可以在一个顶点照明的Pass 中最多访问到8 个逐顶点光源。如果我们只需要渲染其中两个光源对物体的照明,可以仅使用表9.4 中内置光照数据的前两个。如果影响该物体的光源数目小于8,那么数组中剩下的光源颜色会设置成黑色。
可以看出,一些变量我们同样可以在前向渲染路径中使用,例如unity_LightColor。但这些变量数组的维度和数值在不同渲染路径中的值是不同的。
表9.5 给出了顶点照明渲染路径中可以使用的内置函数。
前向渲染的问题是: 当场景中包含大量实时光源时,前向渲染的性能会急速下降。例如,如果我们在场景的某一块区域放置了多个光源,这些光源影响的区域互相重叠,那么为了得到最终的光照效果,我们就需要为该区域内的每个物体执行多个Pass 来计算不同光源对该物体的光照结果,然后在颜色缓存中把这些结果混合起来得到最终的光照。然而,每执行一个Pass 我们都需要重新渲染一遍物体,但很多计算实际上是重复的。
延迟渲染是一种更古老的渲染方法,但由于上述前向渲染可能造成的瓶颈问题,近几年又流行起来。除了前向渲染中使用的颜色缓冲和深度缓冲外,延迟渲染还会利用额外的缓冲区,这些缓冲区也被统称为G 缓冲(G-buffer ),其中G 是英文Geometry 的缩写。G 缓冲区存储了我们所关心的表面(通常指的是离摄像机最近的表面〉的其他信息,例如该表面的法线、位置、用于光照计算的材质属性等。
1. 延迟渲染的原理
延迟渲染主要包含了两个Pass。在第一个Pass 中,我们不进行任何光照计算,而是仅仅计算哪些片元是可见的,这主要是通过深度缓冲技术来实现,当发现一个片元是可见的,我们就把它的相关信息存储到G 缓冲区中。然后,在第二个Pass 中,我们利用G 缓冲区的各个片元信息,例如表面法线、视角方向、漫反射系数等,进行真正的光照计算。
延迟渲染的过程大致可以用下面的伪代码来描述:
可以看出,延迟渲染使用的Pass 数目通常就是两个,这跟场景中包含的光源数目是没有关系的。换句话说,延迟渲染的效率不依赖于场景的复杂度,而是和我们使用的屏幕空间的大小有关。
这是因为,我们需要的信息都存储在缓冲区中,而这些缓冲区可以理解成是一张张2D 图像,我们的计算实际上就是在这些图像空间中进行的。
2. Unity 中的延迟渲染
Unity 有两种延迟渲染路径, 一种是遗留的延迟渲染路径,即Unity 5 之前使用的延迟渲染路径,而另一种是Unity5.x 中使用的延迟渲染路径。如果游戏中使用了大量的实时光照,那么我们可能希望远择延迟渲染路径,但这种路径需要一定的硬件支持。
新旧延迟渲染路径之间的差别很小,只是使用了不同的技术来权衡不同的需求。例如,较旧版本的延迟渲染路径不支持Unity 5 的基于物理的Standard Shader。以下我们仅讨论Unity 5 后使用的延迟渲染路径。对于遗留的延迟渲染路径,读者可以在官方文档
( http://docs.unity3d.com/Manual/RenderTech-DeferredLighting.html )找到更多的资料。
对于延迟渲染路径来说,它最适合在场景中光源数目很多、如果使用前向渲染会造成性能瓶颈的情况下使用。而且,延迟渲染路径中的每个光源都可以按逐像素的方式处理。但是,延迟渲染也有一些缺点。
当使用延迟渲染时, Unity 要求我们提供两个Pass 。
( 1)第一个Pass 用于渲染G 缓冲。在这个Pass 中,我们会把物体的漫反射颜色、高光反射颜色、平滑度、法线、自发光和深度等信息渲染到屏幕空间的 G 缓冲区中。对于每个物体来说,这个Pass 仅会执行一次。
( 2 )第二个Pass 用于计算真正的光照模型。这个Pass 会使用上一个Pass 中渲染的数据来计算最终的光照颜色,再存储到帧缓冲中。
默认的G 缓冲区(注意,不同Unity 版本的渲染纹理存储内容会有所不同)包含了以下几个渲染纹理(Render Texture , RT )。
( http ://docs.unity3d.com/Manual/RenderTech-DeferrdShading.html )。
3. 可访问的内置变量和函数
表9.6 给出了处理延迟渲染路径可以使用的光照变量。这些变量都可以在 UnityDeferredLibrary.cginc 文件中找到它们的声明。
Unity 的官方文档( http://docs.unity3d.com/Manual/RenderingPaths.html )中给出了4 种渲染路径(前向渲染路径、延迟渲染路径、遗留的延迟渲染路径和顶点照明渲染路径〉的详细比较,包括它们的特性比较(是否支持逐像素光照、半透明物体、实时阴影等)、性能比较以及平台支持。
总体来说,我们需要根据游戏发布的目标平台来选择渲染路径。如果当前显卡不支持所选渲染路径, 那么Unity 会自动使用比其低一级的渲染路径。
在本书中,我们主要使用Unity 的前向渲染路径。
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 中使用光照衰减等光照变量可以被正确赋值。这是不可缺少的。
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
我们希望环境光计算一次即可,因此在后面的Additional Pass 中就不会再计算这个部分。与之类似, 还有物体的自发光, 但在本例中,我们假设胶囊体没有自发光效果。
// 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 的工作就完成了。
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
#ifdef USING_DIRECTIONAL_LIGHT
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
#else
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos.xyz);
#endif
在上面的代码中,我们首先判断了当前处理的逐像素光源的类型, 这是通过使用#ifdef 指令判断是否定义了
#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 节。
float3 lightCoord = mul(_LightMatrix0, float4(i.worldPos, 1)).xyz;
然后,我们可以使用这个坐标的模的平方对衰减纹理进行采样,得到衰减值:
fixed atten = tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;
可以发现,在上面的代码中,我们使用了光源空间中顶点距离的平方(通过dot 函数来得到)来对纹理采样,之所以没有使用距离值来采样是因为这种方法可以避免开方操作。然后,我们使用宏UNITY_ATTEN_CHANNEL 来得到衰减纹理中衰减值所在的分量,以得到最终的衰减值。
float distance = length( _WorldSpaceLightPosO.xyz - i.worldPosition.xyz);
atten = 1.0 / distance; // linear attenuation
可惜的是, Unity 没有在文档中给出内置衰减计算的相关说明。尽管我们仍然可以在片元着色器中利用一些数学公式来计算衰减,但由于我们无法在Shader 中通过内置变量得到光源的范围、聚光灯的朝向、张开角度等信息,因此得到的效果往往在有些时候不尽如人意,尤其在物体离开光源的照明范围时会发生突变(这是因为,如果物体不在该光源的照明范围内,Unity 就不会为物体执行一个Additional Pass ) 。当然,我们可以利用脚本将光源的相关信息传递给Shader, 但这样的灵活性很低。我们只能期待未来的版本中Unity 可以完善文档并开放更多的参数给开发者使用。
从图9.17 可以发现,尽管我们没有对正方体使用的Chapter9-ForwardRendering 进行任何更改,但正方体仍然可以向下面的平面投射阴影。一些读者可能会有疑问:“之前不是说Unity 要使用LightMode为ShadowCaster的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。
// 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 的渲染目标可以是光源的阴影映射纹理, 或是摄像机的深度纹理。
图9.17 中还有一个有意思的现象,就是右侧的平面并没有向最下面的平面投射阴影,尽管它的Cast Shadows 已经被开启了。在默认情况下,我们在计算光源的阴影映射纹理时会剔除掉物体的背面。但对于内置的平面来说,它只有一个面,因此在本例中当计算阴影映射纹理时,由于右侧的平面在光源空间下没有任何正面(frontface),因此就不会添加到阴影映射纹理中。我们可以将
Cast Shadows 设置为Two Sided 来允许对物体的所有面都计算阴影信息。图9.18 给出了当把右侧平面的Cast Shadows 设置为Two Sided 后的结果。
在本例中,最下面的平面之所以可以接收阴影是因为它使用了内置的Standard Shader,而这 个内置的Shader 进行了接收阴影的相关操作。但由于正方体使用的Chapter9-ForwardRendering 并 没有对阴影进行任何处理,因此它不会显示出右侧平面投射来的阴影。在下一节中,我们将学习 如何让正方体也可以接收阴影。2. 让物体接收阴影
为了让正方体可以接收阴影,我们首先新建一个Unity Shader,在本书资源中,它的名称为Chapter9-Shadow。我们把
Chapter9-Shadow 赋给正方体使用的材质ShadowMat。删除Chapter9-Shadow 中的代码,把Chapter9-ForwardRendering 的代码复制给它。当然,这样仍然不会有任何阴影出现在正方体上,因此我们需要对代码进行一些更改。
(1)首先,我们在Base Pass 中包含进一个新的内置文件:
#include "AutoLight.cginc"
这是因为, 我们下面计算阴影时所用的宏都是在这个文件中声明的。
struct v2f {
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
SHADOW_COORDS(2)
};
这个宏的作用很简单,就是声明一个用于对阴影纹理采样的坐标。需要注意的是, 这个宏的参数需要是下一个可用的插值寄存器的索引值,在上面的例子中就是2 。
v2f vert(a2v v) {
v2f o;
……
// Pass shadow coordinates to pixel shader
TRANSFER_SHADOW(o);
return o;
}
这个宏用于在顶点着色器中计算上一步中声明的阴影纹理坐标。
// Use shadow coordinates to sample shadow map
fixed shadow = SHADOW_ATTENUATION(i);
SHADOW_ COORDS 、TRANSFER_SHADOW 和
SHADOW_ATTENUATION 是计算阴影时的“三剑客”。这些内置宏帮助我们在必要时计算光源的阴影。我们可以在AutoLight.cginc 中找到它们的声明:
上面的代码看起来很多、很复杂, 实际上只是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所示。
尽管我们在上面描述了阴影的产生过程, 但如果有直观的方式看到阴影一步步的绘制过程那就太好了! 幸运的是, Unity 5 添加了一个新的工具一一帧调试器。我们曾在9.2.2 节中利用它查看过Pass 的绘制过程, 在本节我们会通过它来查看阴影的绘制过程。
首先,我们需要在Window -> Frame Debugger 中打开帧调试器。图9.20 给出了Scene_9_4_2在帧调试器中的分析结果。
我们首先来看第一个部分:更新摄像机的深度纹理, 这是前4 个渲染事件的工作。我们可以单击这些事件查看它们的绘制结果。图9.2.1 给出了正方体对深度纹理的更新结果。
从帧调试器右侧的面板我们可以了解这一渲染事件的详细信息。在图9.2.1 中,我们可以发现,Unity 调用了
Shader: Unity Shader Book/Chapter9 Shadow pass #3 来更新深度纹理,即Chapter9-Shadow 中的第三个Pass 。尽管Chapter9-Shadow 中只定义了两个Pass,但正如我们之前所说, Unity 会在它的Fallback 中找到第三个Pass ,即LightMode 为ShadowCaster 的Pass 来更新摄像机的深度纹理。同样,在第二个部分,即渲染得到平行光的阴影映射纹理的过程中, Unity
也是调用了这个Pass 来得到光源的阴影映射纹理。
在第三个部分中, Unity 会根据之前两步的结果得到屏幕空间的阴影图,如图9.22 所示。
// 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。 这些不同版本的声明是保证我们可以通过这样一个简单的代码来得到正确结果的关键。
#pragma multi_compile_fwdadd 指令。这样一来, Unity 也会为这些额外的逐像素光源计算阴影,并传递给Shader。
我们从一开始就强调,想要在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.
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 中的效果。
细心的读者可以发现,镂空区域出现了不正常的阴影, 看起来就像这个正方体是一个普通的正方体一样。而这并不是我们想要得到的,我们希望有些光应该是可以通过这些楼空区域透过来的,这些区域不应该有阴影。出现这样的情况是因为,我们使用的是内置的VertexLit 中提供的ShadowCaster 来投射阴影, 而这个Pass 中并没有进行任何透明度测试的计算, 因此,它会把整个物体的深度信息渲染到深度图和阴影映射纹理中。因此,如果我们想要得到经过透明度测试后的阴影效果, 就需要提供一个有透明度测试功能的ShadowCaster Pass 。当然, 我们可以自行编写一个这样的Pass , 但这里我们仍然选择使用内置的Unity Shader 来减少代码量。可以看出,此时右侧平面的阴影投射到了半透明的立方体上,但它不会再穿透立方体把阴影投射到下方的平面上,这其实是不正确的。同时,立方体也可以把自身的阴影投射到下面的平面上。