kyuskoj 发表于 2022-2-25 10:22

《Unity Shader入门精要》笔记(十二)

本文为《Unity Shader入门精要》第九章内容《更复杂的光照》的下半部分笔记,主要涉及光照衰减和光源阴影。
本文相关代码,详见:
原书代码,详见原作者github:

1. Unity的光照衰减

上一篇笔记中有提到使用一张纹理作为查找表(Look Up Table,LUT)在片元着色其中计算逐像素光照的衰减,这样做有好处也有坏处:

[*]好处
不依赖数学公式的复杂性,只需要一个参数对纹理进行采样即可;
一定程度上提升了性能。

[*]坏处
需要预处理,纹理大小会影响衰减的精度;
不直观,不方便,一旦使用LUT,就无法使用其他数学公式来计算衰减。

Unity默认使用这种纹理查找的方法来计算逐像素的点光源和聚光灯的衰减。

1.1 用于光照衰减的纹理

Unity内部使用一张名为_LightTexture0的纹理来计算光照衰减(使用cookie的光源的衰减查找纹理是_LightTextureB0)。
对_LightTexture0衰减纹理的采样基于定点在光源空间中的位置,会用到_LightMatrix0矩阵用于将定点的坐标由世界空间转到光源空间:
float3 lightCoord = mul(_LightMatrix0, float4(i.worldPosition, 1)).xyz;

然后使用这个坐标模的平方对衰减纹理进行采样,得到衰减值:
fixed atten = tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;
之所以使用模的平方是为了避免开方操作,然后使用宏UNITY_ATTEN_CHANNEL来得到衰减纹理中衰减值所在的分量,得到最终的衰减值。

1.2 使用数学公式计算衰减

代码例子:
float distance = length(_WorldSpaceLightPos0.xyz - i.worldPosition.xyz);
// 线性衰减
atten = 1.0 / distance;
Unity文档中没有给出内置衰减计算的相关说明,我们无法在Shader中通过内置变量得到光源的范围、聚光灯的朝向、张开角度等信息,因此使用公式计算衰减的效果有时不尽如人意,尤其在物体离开光源照明范围时,因为不再执行该光照的Additional Pass,所以光照效果会发生突变。

2. Unity的阴影

阴影让场景看起来更加真实,有深度信息。
2.1 阴影是如何实现的

生活中阴影的产生:一个光源发射的一条光线遇到一个不透明物体导致无法到达并照亮目标物体,就会在目标物体上产生阴影。
实时渲染中的Shadow Map技术:把相机放到与光源场合的位置,相机看不到的位置就是场景中阴影的区域。Unity使用的就是这种技术。
前向渲染中,如果场景中最重要的平行光开启了阴影,Unity就会为这个光源计算它的阴影映射纹理(Shadowmap),它本质是一张深度图,记录了从光源的位置出发、能看到的场景中距离它最近的表面位置(深度信息)。
为了得到映射纹理中的深度信息,需要把摄像机放到光源的位置,然后按照正常的渲染流程(调用Base Pass和Additional Pass)来更新深度信息。但是这样会造成一定的性能浪费,所以Unity使用了额外的Pass专门更新光源的阴影映射纹理,这个Pass的LightMode标签被设置为ShadowCaster。
因此当开启了光源的阴影效果后,底层渲染会优先找当前渲染物体的Unity Shader中LightMode标签为ShadowCaster的Pass,如果没有,会继续在Fallback中找,如果仍然没有找到,该物体就不会向其他物体投射阴影。


[*]传统的阴影映射纹理实现过程:
在正常渲染的Pass中把顶点位置变换到光源空间下,得到光源空间中顶点的位置,使用xy分量对阴影映射纹理进行采样得到记录的深度值,如果它小于这个顶点的z分量,则说明这个顶点在阴影中。

[*]Unity 5及以后使用的屏幕空间的阴影映射技术(Screenspace Shadow Map):
调用LightMode为ShadowCaster的Pass得到光源的阴影映射纹理和相机的深度纹理,如果相机深度纹理记录的深度大于转换到光源阴影映射纹理中的深度值,则说明这个物体表面的该点处于阴影中。通过这样的方式,生成的阴影图包含了屏幕空间中所有有阴影的区域。我们只需要对这个阴影图进行采样就可以计算场景中哪些物体会呈现阴影了。

关于阴影,其实涉及投射和接收两个过程。举个例子,在太阳光下物体A挡住了物体B,在物体B上产生了阴影。那么对于物体A,就是投射阴影的过程,对于物体B就是接收阴影的过程。
光源的阴影映射纹理的生成过程会计算投射物体在光源空间中的位置;
而接收物体的Shader实现中会对光源的阴影映射纹理进行采样,以确认它是否会被挡住并产生阴影。

2.2 不透明物体的阴影

接下来通过代码实践了解不透明物体阴影的投射和接收。
准备工作:

[*]新建名为Scene_9_4_2的场景,并去掉天空盒子;
[*]新建名为ShadowMat的材质,并将上一篇笔记里的Shader ForwardRendering赋给它;
[*]在场景中创建1个正方体、2个平面,并把上一步创建的材质赋给正方体,平面的材质保持默认;
[*]保存场景。
详细操作详见《<Unity Shader入门精要>笔记(四)》里的案例常用操作说明。

此时场景是这样的:



本例中平行光选择了软阴影的Shadow Type:



2.2.1 让物体投射阴影

物体的Mesh Renderer组件中的Cast Shadows和Receive Shadows属性,可以控制物体是否投射和接收阴影。



当Cast Shadows被设置为开启(On),Unity会把该物体加入光源的阴影映射纹理的计算中。
这个过程需要该物体执行LightMode为ShadowCaster的Pass来实现,但是ForwardRenderingMat材质的Shader里没有定义ShadowCaster:



但它依然投射了阴影:



这是因为我们将内置的Specular Shader作为Fallback,如果当前Shader找不到ShadowCasterPass,那么Unity会继续在Fallback里面继续找,直到找到它为止。
当然我们也可以当前Shader中自己写一个ShadowCasterPass,来灵活地对阴影进行控制。
另外我们可以看到竖起来的平面没有投射阴影:



这是因为此时竖着的平面朝向太阳的一面是背面,因此它不会被添加到阴影纹理映射中,我们可以将它的Cast Shadows设置为Two Sided,这样即使它背对太阳,也会投射阴影:





2.2.2 让物体接收阴影

为了让立方体可以接收阴影,我们新建一个Unity Shader,命名为Chapter9-Shadow,复制Chapter9-ForwardRendering的代码,并做部分修改(修改部分已用注释说明):
// 修改Shader命名
Shader "Unity Shaders Book/Chapter 9/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 { "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;

                // 内置宏,声明一个用于对阴影纹理采样的坐标
                // 因为TEXCOORD0和TEXCOORD1都被用掉了,所以这里传入2
                SHADOW_COORDS(2)
            };

            v2f vert(a2v v)
            {
                v2f o;

                o.pos = UnityObjectToClipPos(v.vertex);
                o.worldNormal = UnityObjectToWorldNormal(v.normal);
                o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;

                // 内置宏,计算v2f结构中声明的阴影纹理坐标
                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 pos : SV_POSITION;
                float3 worldNormal : TEXCOORD0;
                float3 worldPos : TEXCOORD1;
            };

            v2f vert(a2v v)
            {
                v2f o;

                o.pos = UnityObjectToClipPos(v.vertex);
                o.worldNormal = UnityObjectToWorldNormal(v.normal);
                o.worldPos = mul(unity_ObjectToWorld, 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(unity_WorldToLight, 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"
}

保存代码,新建一个材质并将该Shader赋给它,将该材质赋给正方体,将正方体拖到阴影边界处,就可以看到立方体接收阴影的效果:



SHADOW_COORDS、TRANSFER_SHADOW、SHADOW_ATTENUATION是计算阴影的“三剑客”。这些内置宏帮我们计算了光源的阴影。
AutoLight.cginc可以找到它们的声明:
// ---- Screen space direction light shadows helpers (any version)
#if defined (SHADOWS_SCREEN)

    #if defined(UNITY_NO_SCREENSPACE_SHADOWS)
      UNITY_DECLARE_SHADOWMAP(_ShadowMapTexture);
      #define TRANSFER_SHADOW(a) a._ShadowCoord = mul( unity_WorldToShadow, mul( unity_ObjectToWorld, v.vertex ) );
      inline fixed unitySampleShadow (unityShadowCoord4 shadowCoord)
      {
            #if defined(SHADOWS_NATIVE)
                fixed shadow = UNITY_SAMPLE_SHADOW(_ShadowMapTexture, shadowCoord.xyz);
                shadow = _LightShadowData.r + shadow * (1-_LightShadowData.r);
                return shadow;
            #else
                unityShadowCoord dist = SAMPLE_DEPTH_TEXTURE(_ShadowMapTexture, shadowCoord.xy);
                // tegra is confused if we use _LightShadowData.x directly
                // with "ambiguous overloaded function reference max(mediump float, float)"
                unityShadowCoord lightShadowDataX = _LightShadowData.x;
                unityShadowCoord threshold = shadowCoord.z;
                return max(dist > threshold, lightShadowDataX);
            #endif
      }

    #else // UNITY_NO_SCREENSPACE_SHADOWS
      UNITY_DECLARE_SCREENSPACE_SHADOWMAP(_ShadowMapTexture);
      #define TRANSFER_SHADOW(a) a._ShadowCoord = ComputeScreenPos(a.pos);
      inline fixed unitySampleShadow (unityShadowCoord4 shadowCoord)
      {
            fixed shadow = UNITY_SAMPLE_SCREEN_SHADOW(_ShadowMapTexture, shadowCoord);
            return shadow;
      }

    #endif

    #define SHADOW_COORDS(idx1) unityShadowCoord4 _ShadowCoord : TEXCOORD##idx1;
    #define SHADOW_ATTENUATION(a) unitySampleShadow(a._ShadowCoord)
#endif

宏的作用:

[*]SHADOW_COORDS
声明一个名为_ShadowCoord的阴影坐标变量;

[*]TRANSFER_SHADOW
根据不同平台,计算_ShadowCoord的值;

[*]SHADOW_ATTENUATION
使用_ShadowCoord对相关的纹理进行采样,得到阴影信息。

需要注意的是:
宏的计算过程中有些会用到我们定义的结构体的变量名,比如下面的a.pos:
#define TRANSFER_SHADOW(a) a._ShadowCoord = ComputeScreenPos(a.pos);

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

在Window > Analysis > Frame Debugger中打开帧调试器,可以看到当前场景一共花费21个渲染事件,这些事件可以分为4个主要部分:

[*]UpdateDepthTexture
更新摄像机的深度纹理;

[*]RenderShadowMap
渲染得到平行光的阴影映射纹理;

[*]CollectShadows
根据深度纹理和阴影映射纹理得到屏幕空间的阴影图;

[*]RenderLoopJob
绘制渲染结果。



可以看到,第一部分中,Unity调用了Shader: Unity Shader Book/Chapter9/Shadow中的ShadowCaster Pass来更新摄像机的深度纹理:



同样第二部分渲染得到平行光的阴影映射纹理的过程中,Unity也是调用这个Pass来得到光源的映射效果的:



第三部分根据前两部分的计算结果,得到屏幕空间的阴影图:



最后绘制渲染结果:



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

前面的例子实现中,片元着色器的返回结果同时乘上了光照的衰减值和阴影对物体,最终渲染效果的影响。在Unity中Shader提供了一个宏用来统一管理光照衰减和阴影——UNITY_LIGHT_ATTENUATION。
接下来通过代码实践了解下这个宏的使用。

2.4.1 准备工作

完成如下准备工作:

[*]使用之前的场景;
[*]新建一个材质,命名为UseUnityAttenuationMat;
[*]新建名为Chapter9-UseUnityAttenuation,并赋给上一步创建的材质;
[*]将上一步的材质赋给场景的正方体;
[*]保存场景。

2.4.2 编写Shader代码

复制上一节Chapter9-Shadow代码,并做部分修改(修改部分已用注释说明):
// 修改Shader命名
Shader "Unity Shaders Book/Chapter 9/Use Unity Light Attenuation"
{
    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 = UnityObjectToClipPos(v.vertex);
                o.worldNormal = UnityObjectToWorldNormal(v.normal);
                o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;

                // 内置宏,计算v2f里的_ShadowCoord
                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);

                // 直接使用内置宏UNITY_LIGHT_ATTENUATION计算光照衰减值和阴影
                // atten无需声明,因为UNITY_LIGHT_ATTENUATION宏已经帮我们声明了这个变量
                UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
                // 只需要使用atten一个变量即可
                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;
            };

            v2f vert(a2v v)
            {
                v2f o;

                o.pos = UnityObjectToClipPos(v.vertex);
                o.worldNormal = UnityObjectToWorldNormal(v.normal);
                o.worldPos = mul(unity_ObjectToWorld, 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(unity_WorldToLight, 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"
}

最终场景的效果与上一节无异。

Unity针对不同光源类型、是否启用cookie等不同情况声明了多个版本的UNITY_LIGHT_ATTENUATION,而我们只需要简单地调用它就可以得到正确的结果。
对于Additional Pass,如果我们想要添加阴影效果,只需要将指令#pragma multi_compile_fwdadd改成#pragma multi_compile_fwdadd_fullshadows即可。

2.5 透明物体的阴影

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

2.5.1 透明度测试

透明度测试的处理比较简单,但是如果我们仍然直接使用VertexLig、Diffuse、Specular等作为回调,往往无法得到正确的阴影,因为透明度测试需要在片元着色器中舍弃某些片元,而VertexLit中的阴影映射并没有透明度测试的计算,所以会导致被舍弃的片元依然接收了阴影:



而当我们把Fallback改为Transparent.Cutout/VertexLit(之前透明度测试时候使用过的),让ShadowCaster Pass处理透明度测试,就可以可到相对正确的效果:



但是我们依然可以看到一些背对光源的面没有遮挡光线:



这时只需要将Mesh Renderer中的CastShadows属性设置为Two Sided,就可以修复这个问题:



透明度测试的案例及代码详见Scene_9_4_2-Shadow-AlphaTest场景。

2.5.2 透明度混合

透明度混合添加阴影相比透明度测试更复杂。所有Unity内置的透明度混合,比如:Transparent/VertexLit等,都没有阴影投射的Pass,这意味着版透明物体不参与深度图和阴影映射纹理的计算,即:不会向其他物体投射阴影,同时也不会接受其他物体的阴影。
Scene_9_4_2-Shadow-AlphaTest提供了透明度混合的测试场景,当我们使用Transparent/VertexLit作为Fallback时,正方体不会接收阴影,也不会投射阴影:



其实半透明物体要想产生正确的阴影也是可以的,但是需要在每个光源空间下严格按照从后往前的顺序进行渲染,这会阴影的处理变得非常复杂,而且也会影响性能,所以Unity中,所有内置的半透明Shader是不会产生任何阴影效果的。

以上是本篇笔记的所有内容,下一篇笔记我们讲学习高级纹理的相关知识。

写在最后

本文内容会同步发在笔者的公众号上,欢迎大家关注交流!
公众号:程序员叨叨叨(ID:i_coder)
页: [1]
查看完整版本: 《Unity Shader入门精要》笔记(十二)