Skip to content

CascadeShadowMapping

zilch edited this page Apr 26, 2021 · 1 revision

平行光阴影优化

之前在SRP里通过ShadowMap的方式实现了平行光阴影(前置内容-平行光阴影实现),但是并未对其做过优化。遗留的问题是:

随着摄像机FarClip增大,阴影质量急剧下降。

本篇将通过ShadowDistanceCascaded Shadow Map来解决这个问题。

1. 问题产生的原因

假如摄像机从如下角度去观察场景:

平行光从左上角照射整个场景,其通过ShadowCaster Pass生成整个场景的ShadowMapTexture。对比观察ShadowMapTexture在不同的摄像机视距下呈现的样子:

从左到右,摄像机的FarClip依次为10,50,100

可以看出,随着视距增大,摄像机近处的东西在ShadowMapTexture中所占据的有效比例越来越低。因而导致阴影的采样精度也急剧下降。

那么为了解决这个问题,最直接的一个方案自然是控制单张ShadowMapTexture的有效范围。没必要让其随着摄像机视距无限的增大。

通常来说,近处物体在画面视觉中占比较大,因此阴影质量要求较高。而远处物体在画面中占比低,于是阴影质量要求低,甚至可以没有。

从这个角度出发,我们首先来保障近处物体的阴影质量,而超出一定距离的远处物体,则不产生阴影。

2. ShadowDistance限制

在SRP中,要控制ShadowMap的有效范围很简单。只要在场景裁剪那一步,配置一个shadowDistance:

camera.TryGetCullingParameters( out var cullingParams);
cullingParams.shadowDistance = 100;

shadowDistance = 100 的意思是,我们强制按照100的距离来生成阴影映射贴图。否则的话,默认取的摄像机远裁剪平面距离。 实际上,我们可以再优化一下:

cullingParams.shadowDistance = Mathf.Min(100,camera.farClipPlane - camera.nearClipPlane);

加上shadowDistance限制后就会发现,在SceneView里已经可以出现阴影了。(因为SceneView的摄像机远裁剪面通常会到好几万,没限制shadowDistance的时候,是无法正常生成阴影的)

但是虽然阴影可以显示,效果却差强人意。从前面的ShadowMapTexture效果图也能看出来,即便是100的视距,摄像机近处也只能占据一小块。 下面使用Cascaded Shadow Mapping技术做进一步优化。

3. Cascaded Shadow Mapping(级联阴影)

既然一张ShadowMap不够用,那就多来几张呗。级联阴影采用的就是这个思路。我们前面说了,近处对阴影质量高,远处对阴影质量低,因此级联阴影会针对不同距离,生成几张不同分辨率的ShadowMap。 大致如下图:

上图箭头代表光照方向,根据将摄像机视锥根据距离分成几个不同区域,每个区域对应一张ShadowMap。分区的数量和距离都是可以通过参数动态调整的。 例如我们可以在累计100米的视距范围内,分别按0~1010~3030~6060~100生成4张1024的ShadowMapTexture。 这意味着近距离使用了高分辨率的阴影映射贴图,远距离使用了低分辨率的阴影映射贴图。通常来说,我们会将这四张贴图并在一张大贴图上,形成Atlas。

下面使用SRP来实现,基于之前的代码进行改造。

首先看接口CullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives - API文档传送

public bool ComputeDirectionalShadowMatricesAndCullingPrimitives(int activeLightIndex, int splitIndex, int splitCount, Vector3 splitRatio, int shadowResolution, float shadowNearPlaneOffset, out Matrix4x4 viewMatrix, out Matrix4x4 projMatrix, out Rendering.ShadowSplitData shadowSplitData);

解释一下之前被我们忽视的split相关的几个参数:

  • splitIndex 表示Cascade的级别索引
  • splitCount 表示当前最多几级Cascade
  • splitRatio Vector3类型,x,y,z分别代表了1、2、3级Cascade针对视距的分割比例,剩余的(1 - x - y - z)表示4级Cascade占据的比例。 所以x+y+z不能超过1.
  • shadowSplitData 会返回一些额外的Cascade信息,例如每个Cascade的CullingSpehere。

3.1 绘制Cascaded ShadowMap Texture

流程如下:

  • 遍历级联阴影每个级别
  • 计算该级别在Atlas对应的Viewport
  • 通过CommandBuffer设置好ViewPort和View&Project矩阵
  • 绘制ShadowMap到Atlas上

ShadowCasterPass.Execute中的关键代码如下:

for(var i = 0; i < shadowSetting.cascadeCount; i ++){

    var x = i % cascadeAtlasGridSize;
    var y = i / cascadeAtlasGridSize;

    //计算当前级别的级联阴影在Atlas上的偏移位置
    var offsetInAtlas = new Vector2(x * cascadeResolution,y * cascadeResolution);

    //get light matrixView,matrixProj,shadowSplitData
    cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(lightData.mainLightIndex,i,shadowSetting.cascadeCount,
    cascadeRatio,cascadeResolution,lightComp.shadowNearPlane,out var matrixView,out var matrixProj,out var shadowSplitData);
    
    //generate ShadowDrawingSettings
    ShadowDrawingSettings shadowDrawSetting = new ShadowDrawingSettings(cullingResults,lightData.mainLightIndex);
    shadowDrawSetting.splitData = shadowSplitData;
    
    //设置cascade相关参数
    SetupShadowCascade(context,offsetInAtlas,cascadeResolution,ref matrixView,ref matrixProj);

    //绘制阴影
    context.DrawShadows(ref shadowDrawSetting);

    ///
    /// More Code..
    ///
}
  • 级联阴影以nxn的方式,组成一张Atlas。例如4张级联阴影贴图,就以2x2的方式组成Atlas。 我们可以根据cascadeCount,来计算出n,表示为cascadeAtlasGridSize。
  • cascadeResolution是级联阴影的分辨率,等于shadowMapResolution/cascadeAtlasGridSize
  • 计算出每级cascade在Atlas上的偏移位置offsetInAtlas

这样我们就得到了一张Cascaded ShadowMap Texture:

可以看出,由1到4,CascadeShadowMap所包含的场景范围越来越大。

有疑问如下:

使用Unity中ComputeDirectionalShadowMatricesAndCullingPrimitives接口所产生的多级Cascade投影矩阵,似乎并不是按分段的方式进行区域分配的,而是一级套一级的方式,例如:

2级CascadeShadowMap的范围,实际上包含了1级的所有区域。而4级则包含了整个场景。暂不清楚这么做的原因。

3.2 传递相关参数给Shader

为了能正确从Cascaded ShadowMap Texture中采样,我们需要准备一些数据给Shader。

首先,我们把当前使用的级联阴影级数放在_ShadowParams.w中。

然后再看额外新增的两个参数:

/// <summary>
/// 类型Matrix4x4[4],表示每级Cascade从世界到贴图空间的转换矩阵
/// </summary>
public static readonly int WorldToMainLightCascadeShadowMapSpaceMatrices = Shader.PropertyToID("_XWorldToMainLightCascadeShadowMapSpaceMatrices");

/// <summary>
/// 类型Vector4[4],表示每级Cascade的空间裁剪包围球
/// </summary>
public static readonly int CascadeCullingSpheres = Shader.PropertyToID("_XCascadeCullingSpheres");
  • _XCascadeCullingSpheres 用来记录每个级别的Cascade空间裁剪包围球,利用这个数据,我们可以在Shader的Fragment中利用像素世界坐标来计算每个像素到底应该属于哪一级的Cascade。
  • _XWorldToMainLightCascadeShadowMapSpaceMatrices 用来记录每个级别的Cascade世界坐标到贴图坐标转化矩阵。

CascadeCullingSpheres表示每级Cascade的空间裁剪包围球,可以直接从shadowSplitData中直接获取:

_cascadeCullingSpheres[i] = shadowSplitData.cullingSphere;

CascadeShadowMapSpaceMatrix表示每级Cascade从世界空间到贴图空间的转换矩阵,计算方式如下:

///cascadeOffsetAndScale是归一化后的,cascade在atlas上的offset和scale参数
static Matrix4x4 GetWorldToCascadeShadowMapSpaceMatrix(Matrix4x4 proj, Matrix4x4 view,Vector4 cascadeOffsetAndScale)
{
    //检查平台是否zBuffer反转,一般情况下,z轴方向是朝屏幕内,即近小远大。但是在zBuffer反转的情况下,z轴是朝屏幕外,即近大远小。
    if (SystemInfo.usesReversedZBuffer)
    {
        proj.m20 = -proj.m20;
        proj.m21 = -proj.m21;
        proj.m22 = -proj.m22;
        proj.m23 = -proj.m23;
    }
    
    Matrix4x4 worldToShadow = proj * view;
    
    // xyz = xyz * 0.5 + 0.5. 
    // 即将xy从(-1,1)映射到(0,1),z从(-1,1)或(1,-1)映射到(0,1)或(1,0)
    var textureScaleAndBias = Matrix4x4.identity;
    //x = x * 0.5 + 0.5
    textureScaleAndBias.m00 = 0.5f;
    textureScaleAndBias.m03 = 0.5f;

    //y = y * 0.5 + 0.5
    textureScaleAndBias.m11 = 0.5f;
    textureScaleAndBias.m13 = 0.5f;

    //z = z * 0.5 = 0.5
    textureScaleAndBias.m22 = 0.5f;
    textureScaleAndBias.m23 = 0.5f;

    //再将uv映射到cascadeShadowMap的空间
    var cascadeOffsetAndScaleMatrix = Matrix4x4.identity;

    //x = x * cascadeOffsetAndScale.z + cascadeOffsetAndScale.x
    cascadeOffsetAndScaleMatrix.m00 = cascadeOffsetAndScale.z;
    cascadeOffsetAndScaleMatrix.m03 = cascadeOffsetAndScale.x;

    //y = y * cascadeOffsetAndScale.w + cascadeOffsetAndScale.y
    cascadeOffsetAndScaleMatrix.m11 = cascadeOffsetAndScale.w;
    cascadeOffsetAndScaleMatrix.m13 = cascadeOffsetAndScale.y;

    return cascadeOffsetAndScaleMatrix * textureScaleAndBias * worldToShadow;
}

3.3 Shader计算

Shader中,我们只需要依次从近到远,根据包围球来判定像素的世界坐标属于哪个级联阴影,并返回其采样深度。改造后的WorldToShadowMapPos函数如下:

#define ACTIVED_CASCADE_COUNT _ShadowParams.w

///将世界坐标转换到ShadowMapTexture空间,返回值的xy为uv,z为深度
float3 WorldToShadowMapPos(float3 positionWS){
    for(int i = 0; i < ACTIVED_CASCADE_COUNT; i ++){
        float4 cullingSphere = _XCascadeCullingSpheres[i];
        float3 center = cullingSphere.xyz;
        float radiusSqr = cullingSphere.w * cullingSphere.w;
        float3 d = (positionWS - center);
        //计算世界坐标是否在包围球内。
        if(dot(d,d) <= radiusSqr){
            //如果是,就利用这一级别的Cascade来进行采样
            float4x4 worldToCascadeMatrix = _XWorldToMainLightCascadeShadowMapSpaceMatrices[i];
            float4 shadowMapPos = mul(worldToCascadeMatrix,float4(positionWS,1));
            shadowMapPos /= shadowMapPos.w;
            return shadowMapPos;
        }
    }
    //表示超出ShadowMap. 不显示阴影。
    #if UNITY_REVERSED_Z
    return float3(0,0,1);
    #else
    return float3(0,0,0);
    #endif
}

4. 效果图

最后上一下对比图,在Camera远裁剪面为1000的情况下,分别:

  • 无Shadow Distance限制
  • Shadow Distance限制到200
  • Shadow Distance限制到200并增加4级Cascaded Shadow Mapping
上图无ShadowDistance限制时,可以看出,近处和中间方块的阴影的形状都不太对

上图ShadowDistance限制200,中间方块投影的形状边缘出来了,但近处方块边缘还是比较糙。

上图使用了4级Cascaded Shadow Mapping,近处和中间的投影质量都明显好转

5. 参考

http://ogldev.atspace.co.uk/www/tutorial49/tutorial49.html