UnityShader精要笔记十 阴影

本文继续对《UnityShader入门精要》——冯乐乐 第九章 更复杂的光照 9.4节阴影进行学习

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

一、知识回顾

可以先复习一下图形学的相关知识,参考图形学笔记九 阴影 光追一 求交,这里只回顾硬阴影部分:

先从光源看向场景,虚拟地放一个相机,做一遍光栅化,我们就会得到光源能看到什么。也就是通过zbuffer做深度测试,会有一部分遮挡。然后不进行着色,只是把所有的深度记下来。

然后从摄像机出发,再次看向这个场景。把现在看到的点,投影回光源刚才看到的投影平面上。进行深度比较,如果深度一致,就可以被看到。如果不一致,就看不到。


image.png

如上图,那根黄褐色的线,投影回去后,发现之前记录的深度不一致(之前记的是上图红圈那个遮挡物的深度),此时就是看不到,这里就是阴影。

二、Unity对上述shadowmap原理的实现

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

那么,在计算阴影映射纹理时,我们如何判定距离它最近的表面位置呢?一种方法是,先把摄像机放置到光源位置上,然后按正常的渲染流程,即调用Base Pass和Additional Pass来更新深度信息,得到阴影映射纹理。但这种方法会对性能造成一定的浪费,因为实际上我们仅仅需要深度信息而已,而Base Pass和Additional Pass往往会涉及很多复杂的光照模型计算。因此Unity选择使用一个额外的Pass来专门更新光源的阴影映射纹理,这个Pass就是LightMode标签被设置为ShadowCaster的Pass。

这个Pass的渲染目标不是帧缓存,而是阴影映射纹理(或深度纹理)。Unity首先把摄像机放置到光源位置上,然后调用该Pass,通过对顶点变换后得到光源空间下的位置,并据此来输出深度信息到阴影映射纹理中。因此,当开启了光源的阴影效果后,底层渲染引擎首先会在当前渲染物体的UnityShader中找到LightMode为ShadowCaster的Pass,如果没有,它就会在Fallback指定的Unity Shader中继续寻找,如果仍然没有找到,该物体就无法向其它物体投射阴影(但它仍然可以接收来自其它物体的阴影)。当找到一个LightMode为ShadowCaster的Pass后,Unity会使用该Pass来更新光源的阴影映射纹理。

然后从摄像机出发,再次看向这个场景。把现在看到的点,投影回光源刚才看到的投影平面上。

在传统的阴影映射纹理的实现中,我们会在正常渲染的Pass中把顶点位置变换到光源空间下,以得到它在光源空间中的三维位置信息。然后我们使用xy分量对阴影映射纹理进行采样,得到阴影映射纹理中该位置的深度信息。如果该深度值小于该顶点的深度值(通常由z分量得到),那么说明该点位于阴影中。

但在Unity5中,Unity使用了不同于这种传统的阴影采样技术,即屏幕空间的阴影映射技术(Screenspace Shadow Map)。屏幕空间的阴影映射原本是延迟渲染中产生阴影的方法。需要注意的是,并不是所有平台的Unity都会使用这种技术。这是因为,屏幕空间的阴影映射需要显卡支持MRT,而有些移动平台不支持这种特性。

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

个人理解:上面这段话中的黑体,意思就是如果显卡支持MRT,Unity用ShadowCaster的Pass一次性把光源的阴影映射纹理和摄像机的深度纹理都得到了。关于MRT,可以参考更多资料:
UnityShader:MRT多重渲染
OpenGL ES 多目标渲染(MRT)

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

  • 如果我们想要一个物体接收来自其它物体的阴影,就必须在Shader中对阴影映射纹理(包括屏幕空间的阴影图)进行采样,把采样结果和最后光照结果相乘来产生阴影效果。
  • 如果我们想要一个物体向其它物体投射阴影,就必须把该物体加入到光源的阴影映射纹理的计算中,从而让其它物体对阴影映射纹理采样时可以得到该物体的相关信息。在Unity中,这个过程是通过为该物体执行LightMode为ShadowCaster的Pass来实现的。如果使用了屏幕空间的投影映射技术,Unity还会使用这个Pass产生一张摄像机的深度纹理。在下面的章节中,我们会学习如何在Unity中实现上面两个过程。
三、不透明物体的阴影

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


图9.15 开启光源的阴影效果

注:关于软阴影,图形学笔记九 阴影 光追一 求交对应的Games101课程并未详细介绍,在Games202中会有更多相关讲解,暂时可以先读一下这个:图形学之 - 你的物体脱节了 —— 什么是软阴影,如何正确使用

1.让物体投射阴影

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

图9.16 Mesh Renderer组件的Cast Shadows和Receive Shadows属性可以控制该物体是否投射/接收阴影

Cast Shadows可以被设置为开启(On)或关闭(Off)。如果开启了Cast Shadows属性,那么Unity就会把该物体加入光源的阴影映射纹理的计算中,从而让其它物体在对阴影映射纹理采样时可以得到该物体的相关信息。正如之前所说,这个过程是通过为该物体执行LightMode为ShadowCaster的Pass实现的。

Receive Shadows则可以选择是否让物体接收来自其它物体的阴影。如果没有开启Receive Shadows,那么当我们调用Unity的内置宏和变量计算阴影(在后面我们会看到如何实现)时,这些宏通过判断该物体没有开启接收阴影的功能,就不会在内部为我们计算阴影。

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


图9.17 开启Cast Shadows和Receive Shadows,从而让正方体可以投射和接收阴影
1.Fallback

从上图可以发现,尽管我们没有对正方体使用的Chapter9-ForwardRendering进行任何更改,但正方体仍然可以向下面的平面投射阴影。一些读者可能会有疑问,之前不是说Unity要用LightMode为ShadowCaster的Pass来渲染阴影映射纹理和深度图的吗?但是Chapter9-ForwardRendering中并没有这样一个Pass啊。

没错,我们在Chapter-ForwardRendering的SubShader只定义了两个Pass——一个Base Pass,一个Additional Pass。那为什么它还可以投射阴影呢?实际上,秘密就在于其中的Fallback语义。

Fallback"Specular"

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

//Pass to render object as a shadow caster
Pass{
Name"ShadowCaster"
Tags{"LightMode"="ShadowCaster"}
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_shadowcaster
#include "UnityCG.cginc"
struct v2f{
V2F_SHADOW_CASRER;
};
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的渲染目标可以是光源的阴影映射纹理,或是摄像机的深度纹理。

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

关于Fallback,我记得之前讲的是留条后路啊,怎么这里又有调用ShadowCaster的PASS这种奇怪的用处了?回头翻看3.3.4节,其实是有提过的,当时没注意:
我们可以通过一个字符串来告诉Unity这个最低级的Unity Shader是谁。我们也可以任性的关闭Fallback功能,但一旦你这么做,你的意思大概就是:如果一个显卡跑不了上面的所有SubShader,那就不要管它了。
下面给出了一个使用Fallback语句的例子:Fallback "VertexLit"
事实上,Fallback还会影响阴影的投射。在渲染阴影纹理时,Unity会在每个UnityShader中寻找一个阴影投射的Pass。通常情况下,我们不需要自己专门实现一个Pass,这是因为Fallback使用的内置Shader中包含了这样一个通用的Pass。因此,为每个UnityShader正确设置Fallback是非常重要的。更多关于Unity中阴影的实现,可以参见9.4节。

2.Plane的阴影

上图中还有一个有意思的现象,就是右侧的平面并没有向最下面的平面投射阴影,尽管它的Cast Shadows已经被开启了。


image.png

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

图9.18 把Cast Shadows设置为Two Sided可以让右侧平面的背光面也产生阴影

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

3.处理衰减

在9.3节已经讲过衰减,不过在Chapter9-ForwardRendering.shader中,又增加了SPOT类型的处理

#ifdef USING_DIRECTIONAL_LIGHT
    fixed atten = 1.0;
#else
    #if defined (POINT)
        float3 lightCoord = mul(unity_WorldToLight, float4(i.worldPos, 1)).xyz;
        fixed atten = tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;
    #elif defined (SPOT)
        float4 lightCoord = mul(unity_WorldToLight, 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
四、让物体接收阴影

为了让阴影出现在正方体上,我们首先新建一个Unity Shader,在本书资源中,它的名称为Chapter9-Shadow。我们把Chapter9-Shadow赋给正方体使用的材质ShadowMat。删除Chapter9-Shadow中的代码,把Chapter9-ForwardRendering的代码复制给它。当然,这样仍然不会有任何阴影出现在正方体上,因此我们需要对代码做一些修改。

1.Base Pass中包含内置文件
#include "AutoLight.cginc"

这是因为,我们下面计算阴影时所用的宏都是在这个文件中声明的。

2.SHADOW_COORDS
struct v2f {
    float4 pos : SV_POSITION;
    float3 worldNormal : TEXCOORD0;
    float3 worldPos : TEXCOORD1;
    SHADOW_COORDS(2)
};

这个宏的作用很简单,就是声明一个用于对阴影纹理采样的坐标。需要注意的是,这个宏的参数需要是下一个可用的插值寄存器的索引值,在上面的例子中就是2。

注:这个索引值,就是根据上面声明的变量占用情况,再加1。在本书的9.4.5节会有另一个例子:

struct v2f {
    float4 pos : SV_POSITION;
    float3 worldNormal : TEXCOORD0;
    float3 worldPos : TEXCOORD1;
    float2 uv : TEXCOORD2;
    SHADOW_COORDS(3)
};

由于我们已经占用了3个插值寄存器(使用TEXCOORD0、TEXCOORD1和TEXCOORD2修饰的变量),因此SHADOW_COORDS传入的参数是3,这意味着阴影纹理坐标将占用第四个插值寄存器TEXCOORD3。

3.TRANSFER_SHADOW

然后,我们在顶点着色器返回之前添加另一个内置宏TRANSFER_SHADOW:

v2f vert(a2v v) {
    v2f o;
    o.pos = UnityObjectToClipPos(v.vertex);
    
    o.worldNormal = UnityObjectToWorldNormal(v.normal);

    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
    
    // Pass shadow coordinates to pixel shader
    TRANSFER_SHADOW(o);
    
    return o;
}

这个宏用于在顶点着色器中计算上一步中声明的阴影纹理坐标。

4.SHADOW_ATTENUATION

接着,我们在片元着色器中计算阴影值,这同样使用了一个内置宏SHADOW_ATTENUATION:

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);
}
5.计算阴影的三剑客

SHADOW_COORDS、TRANSFER_SHADOW和SHADOW_ATTENUATION是计算阴影时的“三剑客”。我们可以在AutoLight.cginc中找到它们的声明:

// ----------
//Shadow helpers
//--------
//------Screen space shadows
#if defined(SHADOWS_SCREEN)
UNITY_DECLARE_SHADOWMAP(_ShadowMapTexture);
#define SHADOW_COORDS(idx1) unityShadowCoord4 _ShadowCoord:TEXCOORD##idx1;
#if defined (UNITY_NO_SCREENSPACE_SHADOWS)
#define TRANSFER_SHADOW_SHADOW(a)a._ShadowCoord=mul(unity_World2Shadow[0],mul(_Object2World,v.vertex));
inline fixed unitySampleShadow (unityShadowCoord4 shadowCoord)
{
...
}
#else//UNITY_NO_SCREENSPACE_SHADOWS
#define TRANSFER_SHADOW(a) a._ShadowCoord=ComputeScreenPos(a.pos);
inline fixed unitySampleShadow(unityShadowCoord4 ShadowCoord)
{
fixed shadow=tex2DProj(_ShadowMapTexture,UNITY_PROJ_COORD(shadowCoord)).r;
return shadow;
}
#endif
#define SHADOW_ATTENUATION(a)unitySampleShadow(a._ShadowCoord)
#endif
//---Spot light shadows
#if defined (SHADOWS_DEPTH)&&defined(SPOT)
...
#endif
//---Point light shadows
#if defined(SHADOWS_CUBE)
...
#endif
//---Shadows off
#if !defined(SHADOWS_SCREEN)&&!defined(SHADOWS_DEPTH)&&!defined(SHADOWS_CUBE)
#define SHADOW_COORDS(idx1)
#define TRANSFER_SHADOW(a)
#define SHADOW_ATTENUATION(a)1.0
#endif

上面的代码看起来很多很复杂,实际上只是Unity为了处理不同的光源类型、不同平台而定义了多个版本的宏。

  • 在前向渲染中,宏SHADOW_COORDS实际上就是声明了一个名为_ShadowCoord的阴影纹理坐标变量。
  • 而TRANSFER_SHADOW的实现会根据平台不同而有所差异。如果当前平台可以使用屏幕空间的阴影映射技术(通过判断是否定义了UNITY_NO_SCREENSPACE_SHADOWS来得到),TRANSFER_SHADOW会调用内置的ComputePos函数来计算_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。

6.只更改了Base Pass

在完成了上面的所有操作后,我们只需要把阴影值shadow和漫反射和漫反射以及高光颜色相乘即可。得到的结果如下图所示。


图9.19 正方体可以接收来自右侧平面的阴影

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

五、Frame Debugger查看阴影绘制过程

尽管我们在上面描述了阴影的产生过程,但如果有直观的方式看到阴影一步步的绘制过程那就太好了。幸运的是,Unity5添加了新的调试工具——帧调试器。我们曾在前面利用它查看过Pass的绘制过程,本节我们会通过它查看阴影的绘制过程。

Frame Debugger基本操作,参考[Unity Shader学习笔记]帧调试器(FrameDebugger)的使用

image.png

这些渲染事件可以分为4个部分:

  • UpdateDepthTexture,即更新摄像机的深度纹理;
  • RenderShadowmap,即渲染得到平行光的阴影映射纹理;
  • CollectShadows,即根据深度纹理和阴影映射纹理得到屏幕空间的阴影图;
  • 最后绘制渲染结果。
1.更新摄像机的深度纹理

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


image.png

注:可能是Unity版本原因,我这里与书中截图不一致了。

2.RenderShadowmap
image.png

为什么是这样的,我没搞懂

3.CollectShadows
image.png

这张图已经包含了最终屏幕上所有阴影区域的阴影。

六、统一管理光照衰减和阴影

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

参考Chapter9-AttenuationAndShadowUseBuildInFunctions.shader

1.包含进需要的头文件
// Need these files to get built-in macros
#include "Lighting.cginc"
#include "AutoLight.cginc"
2.UNITY_LIGHT_ATTENUATION

之前计算阴影的三剑客用的是SHADOW_COORDS、TRANSFER_SHADOW和SHADOW_ATTENUATION,现在第三个要换成UNITY_LIGHT_ATTENUATION来计算光照衰减和阴影。

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 not only compute attenuation, but also shadow infos
    UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
    
    return fixed4((diffuse + specular) * atten, 1.0);
}

UNITY_LIGHT_ATTENUATION是Unity内置的用于计算光照衰减和阴影的宏,我们可以在内置的AutoLight.cginc里找到它们的相关声明。

它接受3个参数,它会将光照衰减和阴影值相乘后的结果存储到第一个参数中。注意到,我们并没有在代码中声明第一个参数atten,这是因为UNITY_LIGHT_ATTENUATION会帮我们声明这个变量。

它的第二个参数是结构体v2f,这个参数会传递给SHADOW_ATTENUATION,用来计算阴影值。

而第三个参数是世界空间的坐标,正如我们在前面讲的那样,这个参数会用于计算光源空间下的坐标,再对光照衰减纹理采样得到的光照衰减。

我们强烈建议读者查阅AutoLight.cginc中UNITY_LIGHT_ATTENUATION的声明,读者可以发现,Unity针对不同光源类型、是否启用cookie等不同情况声明了多个版本的UNITY_LIGHT_ATTENUATION。这些不同版本的声明是保证我们可以通过这样一个简单的代码来得到正确结果的关键。

由于我们使用了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。

七、透明物体的阴影

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

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

1.透明度测试

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

参考Scene9_4_5和Chapter9-AlphaTestWithShadow.shader,不同点在于FallBack:

FallBack "VertexLit"

我们可以得到类似下图的效果:


图9.24 可以投射阴影的使用透明度测试的物体

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

为了让使用透明度测试的物体得到正确的阴影效果,我们只需要在Unity Shader中更改一行代码,即把Fallback设置为Transparent/Cutout/VertexLit。读者可以在内置文件中找到该Unity Shader的代码,它的ShadowCaster Pass也计算了透明度测试,因此会把裁剪后的物体深度信息写入深度图和阴影映射纹理中。

但需要注意的是,由于Transparent/Cutout/VertexLit中计算透明度测试时,使用了名为_Cutoff的属性来进行透明度测试,因此,这要求我们的Shader中也必须提供名为_Cutoff的属性。否则,同样无法得到正确的阴影效果。在更改了Fallback后,我们可以得到下图的效果:


图9.25 正确设置了Fallback的使用透明度测试的物体

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

图9.26 正确设置了Cast Shadow属性的使用透明度测试的物体
2.透明度混合

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

图9.27 把使用了透明度混合的Unity Shader的Fallback设置为内置的Transparent/VertexLit。半透明物体不会向下方的平面投射阴影,也不会接收来自右侧平面的阴影,它看起来就像是完全透明一样

Unity会这样处理半透明物体是有它的原因的。由于透明度混合需要关闭深度写入,由此带来的问题也影响了阴影的生成。总体来说,要想为这些透明半透明物体产生正确的阴影,需要在每个光源空间下仍然严格按照从后往前的顺序进行渲染,这会让阴影处理变得非常复杂,而且会影响性能。

因此,在Unity中,所有内置的半透明Shader是不会产生任何阴影效果的。当然,我们可以使用一些dirty trick来强制为半透明物体生成阴影,这可以通过把它们的Fallback设置为VertexLit、Diffuse这些不透明物体使用的UnityShader,这样Unity就会在它的Fallback找到一个阴影投射的Pass,然后我们可以通过物体的Mesh Render组件上的Cast Shadows和Receive Shadows选项来控制是否需要向其他物体投射或接收阴影。下图显示了把Fallback设置为VertexLit并开启阴影投射和接收阴影后的半透明物体的渲染效果。

图9.28 把Fallback设为VertexLit来强制为半透明物体生成阴影
八、本书使用的标准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。

你可能感兴趣的:(UnityShader精要笔记十 阴影)