RhinoFreak 发表于 2022-4-21 22:18

【Unity】202作业1-阴影-笔记

该篇笔记使用的是Unity还原202教的阴影算法,如果有做错的或者不太清晰的地方,请各位大佬指正!
基础Shdow Mapping

首先是基本的ShadowMapping完成硬阴影。方法是完成两次Pass,第一次pass从光源出发生成一张ShadowMap,记录光源摄像机看向场景的深度。然后第二次Pass通过这张ShadowMap来完成阴影的绘制,当转换到一个空间下之后,通过比对当前深度和ShadowMap上对应的深度,判断该点是否在阴影内。可以预见到的是,这种方案会在多光源的情况下增添性能开销,因为需要为每一个光源生成一个ShadowMap。


缺陷

缺点一,ShadowMap会发生自遮挡现象,即阴影失真。


这种情况产生的原因在于,每一个像素的深度值都是一个常数,但是当光源和平面产生夹角的时候,平面的深度值其实应该是一个连续变化的数值,而我们的深度图将每一个连续片元的深度值给离散化成了常数,就会产生这种现象。(引用一张闫老师画的图)


当光源和平面产生夹角的时候,SM上的数值就会是每一个红色的点,但是可以看到红色的点之间深度值也还是在变化的。这个时候摄像机看向平面,看到了一个红色点,但是这里记录的深度值可能是橙色位置的深度值,就会被判定被遮挡,从而造成偏差
解决方案:通过增加一个bias(偏置值)来解决。即只有当观测深度明显大于SM深度的时候,才让这个地方产生阴影。同时,我们可以让这个bias更科学。因为在光线垂直打入物体的时候是不会发生这种现象的,而光线和平面的夹角越大则越容易产生。所以我们可以根据夹角值来动态调整bias的大小。
缺点二:锯齿


阴影贴图的一大缺点就是阴影边缘的锯齿会比较严重,原因是阴影贴图的分别率低,在对阴影贴图采样的同时,多个顶点会对同一像素位置进行采样。为了解决这种问题,我们使用多张阴影贴图,离相机近的地方使用精细的阴影贴图,离相机远的地方使用粗糙的阴影贴图,这样不仅优化了阴影效果,还保证了渲染效率(听上去是不是很像MipMap和LOD。。。)相关方案有CSM级联阴影贴图。
或者是采用一些抗锯齿算法,比如本篇也会说到的PCF
Unity实现ShadowMapping

这里为了更好的理解ShadowMapping,我们采用绕过Unity提供的阴影三剑客,自己做一遍
根据ShadowMapping的原理,我们可以得知在Unity中复现ShadowMapping可以拆解成以下几个步骤

[*]在光源处摆放一个光源摄像机,由该相机产生一个深度图
[*]计算光源矩阵,方便将世界空间下的坐标转换到光源空间中,方便进行深度值的比较,Unity中该步骤有对应的API可以调用
[*]根据对深度图的采样结果和当前深度做比较,得出应当生成阴影的部分
1.光源摄像机生成深度图

这里采用的是动态生成的形式,首先在Unity中先创建一个RenderTexture纹理,将其命名为ShadowMap,用于接收光源产生的深度图,这里使用1024X1024的分辨率


public class ShadowCamera : MonoBehaviour
{

    private Camera _lightCamera;
    private RenderTexture lightDepthTexture;

    public GameObject lightObj;
    public Shader shader;
    public RenderTexture lightDepthTextureTest;

    private void Start() {
      _lightCamera = CreateLightCamera();
    }

    public Camera CreateLightCamera()
    {
      GameObject goLightCamera = new GameObject("Shadow Camera");
      Camera LightCamera = goLightCamera.AddComponent<Camera>();
      LightCamera.backgroundColor = Color.white;
      LightCamera.clearFlags = CameraClearFlags.SolidColor;
      LightCamera.orthographic = true;
      LightCamera.orthographicSize = 6f;
      LightCamera.nearClipPlane = 0.3f;
      LightCamera.farClipPlane = 50;
      LightCamera.enabled = false;


      if(!LightCamera.targetTexture)
      {
            LightCamera.targetTexture = lightDepthTextureTest;
      }

      lightDepthTexture = lightDepthTextureTest;
      
      float LightWidth = LightCamera.orthographicSize;
      float BlockerSearchWidth = LightWidth/LightCamera.orthographicSize;

      Shader.SetGlobalTexture("_LightDepthTexture",lightDepthTexture);
      Shader.SetGlobalFloat("_LightTexturePixelWidth",lightDepthTexture.width);
      Shader.SetGlobalFloat("_LightTexturePixelHeight",lightDepthTexture.height);
      Shader.SetGlobalFloat("_BlockerSearchWidth",BlockerSearchWidth);
      Shader.SetGlobalFloat("_LightWidth",LightWidth);

      return LightCamera;
    }

    void Update () {
    //    FitToScene;
      _lightCamera.transform.parent = lightObj.transform;
      _lightCamera.transform.localPosition = Vector3.zero;
       _lightCamera.transform.localRotation = new UnityEngine.Quaternion();
      Matrix4x4 projectionMatrix = GL.GetGPUProjectionMatrix(_lightCamera.projectionMatrix, false);
      Shader.SetGlobalMatrix("_worldToLightClipMat", projectionMatrix * _lightCamera.worldToCameraMatrix);
      Shader.SetGlobalFloat("_gShadowStrength", 0.5f);
      Shader.SetGlobalFloat("_gShadowBias", 0.005f);
      _lightCamera.RenderWithShader(shader,"");
      
    }

}
上述是光源摄像机的所有代码,它主要做了以下工作:

[*]创建了光源摄像机,设置对应的参数。这里需要注意的是:光源摄像机的背景最好生成为白色,因为白色是最大值,这样才方便深度值去做比较,后面才能不经过数值处理直接得出正确的结果。
[*]通过设置全局变量的方式,将所有的需要使用的变量传入着色器中,比如最重要的光源矩阵worldToCameraMatrix,另外GL.GetGPUProjectionMatrix的作用是统一图形接口带来的差异
[*]指定渲染深度所需要的Shader,渲染深度的Shader相对比较简单,只需要记录深度值在对应的位置即可,同时加上为了防止阴影失真的偏置值。
Shader "Unlit/Depth2"
{
    SubShader {
      Tags {         
            "RenderType" = "Opaque"
      }

      Pass {
            Fog { Mode Off }
            Cull front

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct v2f {
                float4 pos : SV_POSITION;
                float2 depth:TEXCOORD0;
            };

            uniform float _gShadowBias;

            v2f vert (appdata_full v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.pos.z += _gShadowBias;
                o.depth = o.pos.zw;

                return o;
            }

            fixed4 frag (v2f i) : SV_TARGET
            {
                float depth = i.depth.x/i.depth.y;
            #if defined (SHADER_TARGET_GLSL)
                depth = depth*0.5 + 0.5; //(-1, 1)-->(0, 1)
            #elif defined (UNITY_REVERSED_Z)
                depth = 1 - depth;       //(1, 0)-->(0, 1)
            #endif

                float depth2 = depth*depth;
                float depth3 = depth2 *depth;
                float depth4 = depth3*depth;

                // return float4(depth,depth2,depth3,depth4);
                return depth;
            }
            ENDCG
      }   
    }
}同时需要注意:有的平台使用的是反向深度来记录深度值,这样做的意义在于可以让深度点的分布更加均匀,我们这里只需要保证存储的深度值统一即可,具体为什么会有这个改动可以参考下面这个文章:
2.得到光源空间下的坐标

这一步就很简单了,直接将传入的光源空间矩阵和已经转换到世界坐标系下的位置相乘即可,我们在顶点着色器中完成这一步
v2f vert (appdata v)
{
v2f o;
float4 ShadowworldPos = mul(unity_ObjectToWorld,v.vertex);
float4 shadowClipPos = mul(_worldToLightClipMat,ShadowworldPos);
               
o.shadowPos = shadowClipPos;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.shadowUV, _MainTex);
return o;
}
3.比对计算阴影

            float HardShadow2(v2f i)
            {
                i.shadowPos.xy = i.shadowPos.xy/i.shadowPos.w;
                float2 uv = i.shadowPos.xy;
                uv = uv*0.5 + 0.5;

                float depth = i.shadowPos.z/i.shadowPos.w;
            #if defined (UNITY_REVERSED_Z)
                depth = 1 - depth;//(1, 0)-->(0, 1)
            #else
                depth = depth*0.5 + 0.5;   //(-1, 1)-->(0, 1)
            #endif

                float4 col = tex2D(_LightDepthTexture,uv);
                float sampleDepth = col.r;
                float shadow = (sampleDepth) > (depth) ? 1:0;
                return shadow;
            }
这里做的就是一个简单的采样对比工作了。我们传入的ShadowPos是光源空间下的坐标,我们需要将其转换成对应的UV,才能使用它去ShadowMap中正常进行采样,那么当采样的深度大于当前深度的时候,就说明该点不在阴影里面了!
效果:



只要保证正交相机能看见场景,调整旋转和位置可以发现阴影生成的位置是正确的!(闫老师:只要看上去它是对的那么它就是对的!)
PCSS(Percentage Closer Soft Shadow)

PCSS的做法是从PCF衍生而来,所以实现PCSS之前必须先实现PCF
PCF(Percentage Closer Filtering)

PCF本来是一种阴影抗锯齿方案,它并不是作用于阴影图的,而是作用于采样点和周围像素的:


算法的过程不算复杂,我简单将其理解为:将每一次ShadingPoint的比较从一次变成多次,从只比较当前点的深度值是否遮挡变成和周围比较是否遮挡然后取平均。体现在代码上就是我直接将ShadowMapping中的比较变成了PCF函数
             float ShadowWithPCF(v2f i )
            {
                i.shadowPos.xy = i.shadowPos.xy/i.shadowPos.w;
                float2 uv = i.shadowPos.xy;
                uv = uv*0.5 + 0.5;

                float depth = i.shadowPos.z/i.shadowPos.w;
            #if defined (UNITY_REVERSED_Z)
                depth = 1 - depth;//(1, 0)-->(0, 1)
            #else
                depth = depth*0.5 + 0.5;   //(-1, 1)-->(0, 1)
            #endif
                int _FilterSize = 1;
                float shadow = PCFSample(depth,uv,_FilterSize);
                return shadow;
            }
而PCF采样的具体做法如下,我这里使用的3X3滤波核,并且是逐点遍历而非随机均匀采样,记录每一个点是否遮挡,然后叠加最后返回平均值
            float PCFSample(float depth, float2 uv,int _FilterSize)
            {
                float shadow = 0.0;
                float2 texelSize = float2(_LightTexturePixelWidth,_LightTexturePixelHeight);
                texelSize = 1/texelSize;
                for(int x = -_FilterSize; x<= _FilterSize;x++)
                {
                  for(int y = -_FilterSize;y<= _FilterSize;y++)
                  {
                        float2 uv_offset = float2(x,y)*texelSize;
                        float Samepledepth = tex2D(_LightDepthTexture,uv+uv_offset).r;
                        shadow += Samepledepth > depth ? 1 : 0;
                  }
                }
                float total = (_FilterSize*2+1)*(_FilterSize*2+1);
                shadow /= total;

                return shadow;
            }


可以看到阴影的外圈锯齿已经很好的被平滑掉了~
利用PCF实现PCSS

通过对现实世界的观察,我们可以得出一个结论:在离物体较近的时候,阴影会显得比较“硬”,而离物体较远的时候,阴影会显得比较“软”。因此,阴影的软硬和遮挡物的距离相关,因此利用PCF制作软阴影的时候,应该是给不同距离的阴影不同大小的Filter,而不是整个阴影范围内都使用同一个大小。
所以PCSS实际上就是一个滤波核自适应大小的问题
PCSS一般分为三步:

[*]确定障碍物得平均距离
[*]根据确定的平均距离去计算半影的大小,根据大小得到滤波范围
[*]根据滤波范围执行PCF得到阴影结果
1.确定障碍物的平均距离

float findBlocker(float zReceiver,float2 uv)
{
    int step= 3.0;
    float average_depth = 0.0;
    float count = 0.0005;//防止除于0
    for(int i = -step ;i<=step ;i++)
    {
      for(int j = -step ;j<=step ;j++)
      {
            float2 uvOffset = float2(i,j)/step *_BlockerSearchWidth;

            float sampleDepth = tex2D(_LightDepthTexture,uv + uvOffset).r;
            if(sampleDepth < zReceiver)
            {
                count += 1;
                average_depth += sampleDepth;
            }
      }
    }
    float result = average_depth/count;
    return result;
}
这里的_BlockerSearchWidth是通过光源宽度除于正交相机的大小得到的一个近似搜索宽度。但是在这里我使用的是平行光,所以我假设光源宽度可以填满整个正交相机的视口大小,因此传入的值实际上是1(也可以自行假设一些其它光源查看效果)。并且这里计算平均深度使用的依旧是逐点遍历的方法
2.计算滤波范围

这里引用闫老师的一张图,通过障碍物,光源和接收面构成的相似三角形,可以得到计算半影大小的公式,那么第二步就是直接对公式的一个套用


float wPenumbra = (depth - zBlocker) * _LightWidth / zBlocker;
3.PCF

这里我们得到了半影的范围大小,因此根据此范围大小去计算滤波核的大小,进行PCF滤波。这里我换了一种采样方式,直接将202作业的泊松采样搬了过来,生成了一系列的随机采样点采样并计算结果
float textureSize = float(_LightTexturePixelWidth);
float filterRange = 1.0 / textureSize * wPenumbra;
int noShadowCount = 0;
for( int i = 0; i < NUM_SAMPLES; i ++ ) {
    float2 sampleCoord = poissonDisk * filterRange + uv;

    float closestDepth = tex2D(_LightDepthTexture, sampleCoord).r;
    if(depth < closestDepth){
      noShadowCount += 1;
    }
}
float shadow = float(noShadowCount) / float(NUM_SAMPLES);

return shadow;
}
效果


效果上来看和202作业比较近似,因为采样点的个数也就那么点,并且PCSS中有很多都是参数化的东西,要调整到比较好看的软阴影还是有些难度的
一个小坑

可以注意到我在计算PCSS的时候,最后一步并没有使用逐点的PCF采样,而是使用了和202作业相同的泊松随即采样。这不是因为我不想逐点采样,而是因为可变的滤波核大小输入到For循环之后是会出现无法展开的问题的!也就是说针对每一个片元,我自己PCF采样方法的FilterSize是不能够改变的。
花费了一些时间查询了相关资料,得出的大致原因和要少用if条件语句的原因是差不多的。因为如果For循环的次数对每一个片元来说可变,那么就会编译很多套不同的指令,而在GPU高度并行的环境中,这样不同的指令是会大大拖慢GPU的并行速度的,因此在着色器层面上Unity是禁止你这么做的!因此含泪换了采样方法。

VSSM(Variance Soft Shadow Mapping)

基础做法

从PCSS的步骤中可以出,第一步找寻障碍物深度和第三步做滤波是非常非常消耗性能的行为。因此,VSSM算是在这个基础上进行的一个加速。我们可以试想一下,我们在其实并不需要精准的得到它的比例,而是可以去得到一个近似值即可。那么我们就并不需要去计算每一个分区中的精确比例,而是通过分布图或者分布函数来得到一个近似的估计值,这样就可以大大节省卷积中所消耗的性能。
因此,VSSM就是采用这种思想,通过计算一块区域内的均值和方差,从而快速得到一个ShadingPoint所对应的比例
均值是通过SAT(Summed Area Tables)快速求得的。
而方差是通过公式


来快速得到。(我们可以在生成深度图的同时生成深度平方图,写入同一个纹理的不同通道里面,这样就即节省了内存,也少跑一趟Pass)
估算的时候是计算CDF,即通过对应数值的积分(面积)得到该点的占比,同时引入了切比雪夫不等式来做快速计算,只需要知道期望和方差,就可以快速得到一个它积分的上界(然后用这个上界来表示近似值)
PS:切比雪夫不等式还要求查询值在平均值的右边


那么针对第一步,同样我们是要求一个障碍物的平均深度,所以也可以使用切比雪夫不等式去求得,具体步骤就直接上截图了


我们需要求得的是蓝色部分障碍物的平均深度,而红色部分的平均深度是我们不需要的。在使用切比雪夫不等式去求得这个值的过程中需要注意,我们是不知道红色部分的平均深度值的,因此,我们将整个红色部分的平均深度值都用ShadingPoint这一点的深度值去代替(这种做法在接收投影的部分是平面的时候是比较有效的),然后就可以通过比例关系和等式得出我们需要的Zocc
SAT(Summed Area Tables)

SAT是一种数据结构,存储一维数组的前缀和,这种数据结构可以快速计算出一个范围内的和,比如求图中的Sum,则可以通过下方红色数字相减即可。


那么在二维情况下:


任意矩阵可以通过一整块绿色矩阵减去左边和上方的橙色部分再加上绿色部分(多减去了这么一块)来得到。并且这些矩阵的起始位置都在左上角,因此我们的二维SAT图就是通过记录从左上角加到当前位置的和来完成前缀操作的,这样上述步骤就会被拆解成几次简单的查表加减即可。
PS:二维SAT表里面每一个元素的值代表从左上角开始一直加到该元素的值(即这块矩阵的和)。生成方法也很简单,每一行做一遍一维SAT计算出结果,然后再针对每一列再做一次一维SAT即可生成。
但是由于我还没有学好怎么使用计算着色器,所以暂时还不会去计算这样一个SAT,只能暂时搁置VSSM了(其实已经照葫芦画瓢试过了,但是莫名其妙把电脑内存爆了两次,所以实在不敢再来了哈哈哈哈哈,这个坑以后有机会再填把~)
GitHub上传了代码
参考文章:

pc8888888 发表于 2022-4-21 22:21

效果测试图不够明显,应使用更明显的图出来
页: [1]
查看完整版本: 【Unity】202作业1-阴影-笔记