# 平行光阴影优化 之前在SRP里通过ShadowMap的方式实现了平行光阴影[(前置内容-平行光阴影实现)](https://github.com/wlgys8/SRPLearn/wiki/MainLightShadow),但是并未对其做过优化。遗留的问题是: > 随着摄像机FarClip增大,阴影质量急剧下降。 本篇将通过**ShadowDistance**和**Cascaded Shadow Map**来解决这个问题。 # 1. 问题产生的原因 假如摄像机从如下角度去观察场景: 平行光从左上角照射整个场景,其通过ShadowCaster Pass生成整个场景的ShadowMapTexture。对比观察ShadowMapTexture在不同的摄像机视距下呈现的样子: 从左到右,摄像机的FarClip依次为10,50,100 可以看出,随着视距增大,摄像机近处的东西在ShadowMapTexture中所占据的有效比例越来越低。因而导致阴影的采样精度也急剧下降。 那么为了解决这个问题,最直接的一个方案自然是控制单张ShadowMapTexture的有效范围。没必要让其随着摄像机视距无限的增大。 通常来说,近处物体在画面视觉中占比较大,因此阴影质量要求较高。而远处物体在画面中占比低,于是阴影质量要求低,甚至可以没有。 从这个角度出发,我们首先来保障近处物体的阴影质量,而超出一定距离的远处物体,则不产生阴影。 # 2. ShadowDistance限制 在SRP中,要控制ShadowMap的有效范围很简单。只要在场景裁剪那一步,配置一个shadowDistance: ```csharp camera.TryGetCullingParameters( out var cullingParams); cullingParams.shadowDistance = 100; ``` `shadowDistance = 100` 的意思是,我们强制按照100的距离来生成阴影映射贴图。否则的话,默认取的摄像机远裁剪平面距离。 实际上,我们可以再优化一下: ```csharp 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~10`、`10~30`、`30~60`、`60~100`生成4张1024的ShadowMapTexture。 这意味着近距离使用了高分辨率的阴影映射贴图,远距离使用了低分辨率的阴影映射贴图。通常来说,我们会将这四张贴图并在一张大贴图上,形成Atlas。 下面使用SRP来实现,基于之前的代码进行改造。 首先看接口`CullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives` - [API文档传送](https://docs.unity3d.com/2019.1/Documentation/ScriptReference/Rendering.CullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives.html) ```csharp 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`中的关键代码如下: ```csharp 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`中。 然后再看额外新增的两个参数: ```csharp /// /// 类型Matrix4x4[4],表示每级Cascade从世界到贴图空间的转换矩阵 /// public static readonly int WorldToMainLightCascadeShadowMapSpaceMatrices = Shader.PropertyToID("_XWorldToMainLightCascadeShadowMapSpaceMatrices"); /// /// 类型Vector4[4],表示每级Cascade的空间裁剪包围球 /// public static readonly int CascadeCullingSpheres = Shader.PropertyToID("_XCascadeCullingSpheres"); ``` - _XCascadeCullingSpheres 用来记录每个级别的Cascade空间裁剪包围球,利用这个数据,我们可以在Shader的Fragment中利用像素世界坐标来计算每个像素到底应该属于哪一级的Cascade。 - _XWorldToMainLightCascadeShadowMapSpaceMatrices 用来记录每个级别的Cascade世界坐标到贴图坐标转化矩阵。 CascadeCullingSpheres表示每级Cascade的空间裁剪包围球,可以直接从`shadowSplitData`中直接获取: ```csharp _cascadeCullingSpheres[i] = shadowSplitData.cullingSphere; ``` CascadeShadowMapSpaceMatrix表示每级Cascade从世界空间到贴图空间的转换矩阵,计算方式如下: ```csharp ///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`函数如下: ```hlsl #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