-
Notifications
You must be signed in to change notification settings - Fork 41
CascadeShadowMapping
之前在SRP里通过ShadowMap的方式实现了平行光阴影(前置内容-平行光阴影实现),但是并未对其做过优化。遗留的问题是:
随着摄像机FarClip增大,阴影质量急剧下降。
本篇将通过ShadowDistance和Cascaded Shadow Map来解决这个问题。
假如摄像机从如下角度去观察场景:
平行光从左上角照射整个场景,其通过ShadowCaster Pass生成整个场景的ShadowMapTexture。对比观察ShadowMapTexture在不同的摄像机视距下呈现的样子:
从左到右,摄像机的FarClip依次为10,50,100
可以看出,随着视距增大,摄像机近处的东西在ShadowMapTexture中所占据的有效比例越来越低。因而导致阴影的采样精度也急剧下降。
那么为了解决这个问题,最直接的一个方案自然是控制单张ShadowMapTexture的有效范围。没必要让其随着摄像机视距无限的增大。
通常来说,近处物体在画面视觉中占比较大,因此阴影质量要求较高。而远处物体在画面中占比低,于是阴影质量要求低,甚至可以没有。
从这个角度出发,我们首先来保障近处物体的阴影质量,而超出一定距离的远处物体,则不产生阴影。
在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
技术做进一步优化。
既然一张ShadowMap不够用,那就多来几张呗。级联阴影采用的就是这个思路。我们前面说了,近处对阴影质量高,远处对阴影质量低,因此级联阴影会针对不同距离,生成几张不同分辨率的ShadowMap。 大致如下图:
上图箭头代表光照方向,根据将摄像机视锥根据距离分成几个不同区域,每个区域对应一张ShadowMap。分区的数量和距离都是可以通过参数动态调整的。 例如我们可以在累计100米的视距范围内,分别按0~10
、10~30
、30~60
、60~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。
流程如下:
- 遍历级联阴影每个级别
- 计算该级别在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级则包含了整个场景。暂不清楚这么做的原因。
为了能正确从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;
}
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
}
最后上一下对比图,在Camera远裁剪面为1000的情况下,分别:
- 无Shadow Distance限制
- Shadow Distance限制到200
- Shadow Distance限制到200并增加4级Cascaded Shadow Mapping
上图ShadowDistance限制200,中间方块投影的形状边缘出来了,但近处方块边缘还是比较糙。
上图使用了4级Cascaded Shadow Mapping,近处和中间的投影质量都明显好转