图形引擎实战:8级风格化级联阴影
阴影是画面表现光影的重要元素之一,高质量的阴影会对游戏画面产生不小的贡献。对于大世界来说,高质量的阴影往往意味着低走样和较远的绘制距离。Unity的URP管线中自带了最大级联数为4级的级联阴影,然而4级的级联数在阴影绘制距离较大时,近处的阴影会出现难以接受的锯齿,出现较大的走样。解决走样主要有两个方向,提高采样频率和降低信号频率。
在本篇文章中,我们选择了一个较为通用的解决方案。对于前者,我们选择提高级联数,拓展4级到8级,对于后者,我们则选择在采样前进行PCF滤波。同时,也在PCF的基础上做了PCSS,以及通过PCF模拟的软阴影搭配Ramp图实现随TOD变化的阴影着色。
1. 编辑器拓展
在开始对管线进行拓展之前,我们需要大致了解下URP管线的处理流程。
URP首先会对UniversalRendererData、UniversalRenderPipelineAsset进行序列化。其中,UniversalRendererData会进行一些资源的加载(Shader、Texture等)和RenderFeature的配置。UniversalRenderPipelineAsset则会配置一系列UniversalRendererData,并设置一些UniversalRenderPipeline所需要的数据。
UniversalRenderPipeline继承自RenderPipeline,是组织整体管线逻辑的地方,它会使用UniversalRenderPipelineAsset初始化管线。
在初始化CameraData的时候,通过UniversalRendererData创建UniversalRenderer,之后在RenderSingleCamera函数中进行UniversalRenderer的设置(主要是设置一系列将被执行的Pass,如MainLightShadowCasterPass),并将对应Pass添加至管线中。
最后,在UniversalRenderPipeline的Render函数中调用相关函数进行渲染。
了解完这些后,我们需要修改的内容就很明确了。首先,我们需要在UniversalRenderPipelineAsset下为8级级联添加相关的变量,并在其Editor中添加对应的UI。URP中,级联阴影相关的数据会由ShadowData的结构体进行组织,因此我们需要修改该结构体(处于UniversalRenderPipelineCore,该文件包含一些与管线相关的结构体、ShaderPropertyId、Keyword,以及一系列对管线数据做处理的函数),并在UniversalRenderPipeline通过UniversalRenderPipelineAsset对ShadowData进行初始化时,添加级联阴影相关的数据。修改完后,我们就可以在主光源阴影的Pass中获取级联阴影的相关数据。
2. 级联数拓展
URP中主光源阴影的设置主要在MainLightShadowCasterPass中进行,在该步骤中,我们需要修改级联阴影的计算,主要计算各级联的包围盒以及对应投影相机的V、P矩阵,并将一部分信息传递到Shader中。该Pass中通过ShadowUtils.ExtractDirectionalLightMatrix计算各级联的包围球信息和变换矩阵等,其中起到关键作用的是CullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives函数,但是该函数目前最多只支持4级级联,而且该函数的底层实现并未暴露给我们,所以我们需要自己实现级联阴影的包围球,在此处我们选择最小包围球来进行计算。相关推导可看Calculate Minimal Bounding Sphere of Frustum - Eric's Blog (lxjk.github.io)。计算完成后将相关信息传递给Shader即可。
在计算包围球的过程中需要注意几个问题。当摄影机移动时,方向光阴影的包围球和矩阵将会发生变化。当产生锯齿时,阴影边缘往往会由于投影相机的连续位移而产生抖动。
解决方法为在摄影机移动时,投影相机在光源本地空间的X、Y方向上中以纹素(Texel)大小进行移动。
...... ...... // Remove shimmering shadow edges // ref: https://learn.microsoft.com/en-us/windows/win32/dxtecharts/common-techniques-to-improve-shadow-depth-maps#moving-the-light-in-texel-sized-increments float delta = 2.0f * sphereRadius / shadowResolution; Vector3 sphereCenterSnappedOS = light.transform.worldToLocalMatrix.MultiplyVector(sphereCenter); sphereCenterSnappedOS.x /= delta; sphereCenterSnappedOS.x = Mathf.Floor(sphereCenterSnappedOS.x); sphereCenterSnappedOS.x *= delta; sphereCenterSnappedOS.y /= delta; sphereCenterSnappedOS.y = Mathf.Floor(sphereCenterSnappedOS.y); sphereCenterSnappedOS.y *= delta; sphereCenter = light.transform.localToWorldMatrix.MultiplyVector(sphereCenterSnappedOS);
其次,Unity为了防止阴影的裁剪,使用了阴影平坠(Shadow Pancaking)技术。在渲染ShadowMap时,在顶点着色器中会将超出裁剪空间近平面的顶点钳制到近平面的深度。
#if UNITY_REVERSED_Z positionCS.z = min(positionCS.z, UNITY_NEAR_CLIP_VALUE); #else positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE); #endif
这样处理后可以防止三角形顶点的裁切,但会带来一定的形变,并且当使用PCSS时,由于其半影区域的大小受Blocker在光源空间的深度的影响,当我们钳制了深度后,可能会造成部分软阴影的软硬程度的错误。因此,我们需要引入一定的offset,使投影相机的近平面适当地向后偏移,但代价是偏移过多后阴影精度会略微降低,更容易发生shadow acne。
float shadowNearPlaneOffset = light.shadowNearPlane * sphereRadius * 10.0f; Vector3 eyeCenter = sphereCenter - lightForwardDir * (sphereRadius + shadowNearPlaneOffset); Vector3 eyeLookAt = sphereCenter + lightForwardDir * sphereRadius;
由于我们的绘制距离远,绘制物体多,ShadowMap的生成会有较大的开销。因此,我们可以分帧绘制ShadowMap的不同级联。
修改完Pass后,我们需要修改Shader中对阴影的采样。主要增加修改8级级联阴影的变量,以及CascadeIndex的计算。
half ComputeCascadeIndex(float3 positionWS) { float3 fromCenter0 = positionWS - _CascadeShadowSplitSpheres0.xyz; float3 fromCenter1 = positionWS - _CascadeShadowSplitSpheres1.xyz; float3 fromCenter2 = positionWS - _CascadeShadowSplitSpheres2.xyz; float3 fromCenter3 = positionWS - _CascadeShadowSplitSpheres3.xyz; float3 fromCenter4 = positionWS - _CascadeShadowSplitSpheres4.xyz; float3 fromCenter5 = positionWS - _CascadeShadowSplitSpheres5.xyz; float3 fromCenter6 = positionWS - _CascadeShadowSplitSpheres6.xyz; float3 fromCenter7 = positionWS - _CascadeShadowSplitSpheres7.xyz; float4 distances2 = float4(dot(fromCenter0, fromCenter0), dot(fromCenter1, fromCenter1), dot(fromCenter2, fromCenter2), dot(fromCenter3, fromCenter3)); float4 distancesFar2 = float4(dot(fromCenter4, fromCenter4), dot(fromCenter5, fromCenter5), dot(fromCenter6, fromCenter6), dot(fromCenter7, fromCenter7)); half4 weights = half4(distances2 < _CascadeShadowSplitSphereRadii); half4 weightsFar = half4(distancesFar2 < _CascadeShadowSplitSphereRadii2); half4 origWeights = weights; half4 origWeightsFar = weightsFar; weights.yzw = saturate(weights.yzw - weights.xyz); weightsFar.x = saturate(weightsFar.x - origWeights.w); weightsFar.yzw = saturate(weightsFar.yzw - origWeightsFar.xyz); half nearWeight = dot(weights, half4(8, 7, 6, 5)); half farWeight = dot(weightsFar, half4(4, 3, 2, 1)); half index = half(8.0) - lerp(nearWeight, farWeight, step(nearWeight, 1)); return index; }
3. 屏幕空间软阴影
由于PCF/PCSS的性能开销并不算低,更适合在屏幕空间计算。因此我们选择通过深度重建世界空间坐标后进行一次屏幕空间的阴影收集,屏幕空间阴影的计算将会在Compute Shader中进行。为近一步降低性能开销,我们将采样降低至8spp,搭配blur和Temporal filter进行降噪,对于采样分布未选择普通的随机旋转的泊松圆盘,而是一种螺旋生成的分布,这种分布大概长这样(等差增加半径和角度),在https://www.iryoku.com/next-generation-post-processing-in-call-of-duty-advanced-warfare/中被提出。这种分布在旋转后相较于泊松圆盘有更好的覆盖率且样本之间不会重叠,因此对其进行滤波的效果也会更好。
static half2 _SampleKernel8[] = { half2(-0.7071, 0.7071), half2(-0.0, -0.875), half2(0.5303, 0.5303), half2(-0.625, -0.0), half2(0.3536, -0.3536), half2(-0.0, 0.375), half2(-0.1768, -0.1768), half2(0.125, 0.0) };
对于旋转采样点的噪声,则采用了在同一篇论文中提出的Interleaved Gradient Noise,该噪声已内置于Unity中。
ShadingPointCascadeData GetShadingPointPCFCascadeData(float2 uv, float3 positionWS, float3 normalWS) { ShadingPointCascadeData data; ...... data.randomRotateValue = InterleavedGradientNoise(uv * _ScreenParams.xy, _FrameCount); ...... return data; }
之后,就是常规的PCSS计算Blocker平均深度值和PCF采样。其中,PCF的采样距离与包围球半径呈负相关,并通过计算得到的半影大小对其进行缩放。当采样点落到ShadowMap外时,则直接跳过(或可转换到世界空间进行重映射)。
float CalculatePCFUnit(float cullingSphereRadius, float hardness) { float sphereRadius = cullingSphereRadius; float scaleFactor = 1.0 / sphereRadius; return scaleFactor * lerp(PCF_UNIT_MAX, PCF_UNIT_MIN, hardness); } float Sample_PCF(ShadingPointCascadeData data) { float4 shadowCoord = data.shadowCoord; int cascadeIndex = data.cascadeIndex; float offsetUnit = data.PCFoffsetUnit; offsetUnit *= data.penumbraSize; float2 texelSize = GetShadowMapTexelFromAtlas(_CascadeCount, _MainLightShadowResolution); float shadow = 0, count = 0; float shadingPointDepth = shadowCoord.z; float random = data.randomRotateValue; UNITY_UNROLL for (int i = 0; i < ShadowSampleCount; i++) { float2 rotatedOffset = RotatePoint(_SampleKernel8[i], float2(0.0, 0.0), random * 2.0 * PI); float2 offset = rotatedOffset * offsetUnit * texelSize; float4 offsetShadowCoord = shadowCoord; offsetShadowCoord.xy += offset; float offsetShadingPointDepth = shadingPointDepth + data.deltaZPerTexel * offsetUnit * (abs(rotatedOffset.x) + abs(rotatedOffset.y)); if (IsSampleSameCascade(cascadeIndex, offsetShadowCoord, _CascadeCount) == 0) { continue; } shadow += _MainLightShadowmapTexture.SampleCmpLevelZero(s_Shadow_Point_Clamp_Compare_Sampler, offsetShadowCoord.xy, offsetShadingPointDepth); count += 1.0; } float finalShadow = shadow / max(count, 0.001); return saturate(finalShadow); }
由于只有8spp,原始的阴影会有较大噪声。在完成基础的屏幕空间软阴影后,我们需要对其进行降噪处理。这边采用的是双边滤波+Temporal filter。在使用双边滤波时,如使用分离高斯核的形式,可以考虑通过Compute Shader中TGSM进行加速。实际测试中,在阴影柔化程度不高时,如搭配Temporal filter,使用3*3的kernel在1 pixel左右的偏移下做滤波也能得到较高质量的阴影。这样可以保证在需要的地方(如树木之间)仍有较锐利的阴影,且有较高的贴图命中率。
之后,可以参考https://zhuanlan.zhihu.com/p/316138540创建半影区域的Mask,只对标记部分进行PCF/PCSS计算。半影Mask的生成方法对于不同的工程有着不同的解法,分享中通过Tile表示4*4的pixel,再通过少量采样点创建Mask后Blur的方法在创建柔化程度不大的软阴影中有很好的优化效果,但该方法对一些细碎或柔化程度较大的阴影则可能会产生一定的瑕疵。
在URP 12.1.7的Package中,虽然官方尚未完整实现屏幕空间阴影,但相关的关键字和Shader中相关的采样计算都已相对完整。因此在Dispatch完一系列Compute Shader后开启现有的关键字即可。我们需要在屏幕空间阴影的Render Feature里需要开启对应的关键字,以确保不透明物体在支持屏幕空间阴影时以正确的形式采样。
CoreUtils.SetKeyword(cmd, ShaderKeywordStrings.MainLightShadows, false); CoreUtils.SetKeyword(cmd, ShaderKeywordStrings.MainLightShadowCascades, false); CoreUtils.SetKeyword(cmd, ShaderKeywordStrings.MainLightShadowScreen, true);
但是,半透明物体不会写入深度,也无法使用屏幕空间阴影,因此我们需要在BeforeRenderingTransparents插入ScreenSpaceShadowsPostPass来处理相关关键字,让半透明物体以原先的形式进行采样。
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { CommandBuffer cmd = CommandBufferPool.Get(); using (new ProfilingScope(cmd, m_ProfilingSampler)) { // Toggle light shadows enabled based on the renderer setting set in the constructor CoreUtils.SetKeyword(cmd, ShaderKeywordStrings.MainLightShadowScreen, false); if (renderingData.shadowData.mainLightShadowCascadesCount == 1) { CoreUtils.SetKeyword(cmd, ShaderKeywordStrings.MainLightShadows, m_shouldReceiveShadows); CoreUtils.SetKeyword(cmd, ShaderKeywordStrings.MainLightShadowCascades, false); } else { CoreUtils.SetKeyword(cmd, ShaderKeywordStrings.MainLightShadows, false); CoreUtils.SetKeyword(cmd, ShaderKeywordStrings.MainLightShadowCascades, m_shouldReceiveShadows); } CoreUtils.SetKeyword(cmd, ShaderKeywordStrings.AdditionalLightShadows, m_shouldReceiveShadows); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); }
4. 阴影风格化
我们需要在应用衰减(Shadow strength、fade、光源距离衰减、云影等)前应用Ramp图以实现风格化阴影。同时,可通过工具将Ramp图合并为图集以实现和TOD系统的联动。
half3 SampleRamp(half shadowAttenuation, half todTime=0, int RampCount=1) { #if defined(_SHADOW_RAMP) float perRampV = 1.0 / (float)RampCount; int rampIndex = floor(todTime * RampCount); float2 rampUV = float2(shadowAttenuation, ((float)rampIndex + 0.5) * perRampV); half s = sign(todTime - rampUV.y); float2 rampNeighborUV = frac(rampUV + s * float2(0, perRampV)); half3 RampColor01 = SAMPLE_TEXTURE2D_LOD(_ShadowRampTex, sampler_Linear_Clamp_ShadowRampTex, rampUV, 0).rgb; half3 RampColor02 = SAMPLE_TEXTURE2D_LOD(_ShadowRampTex, sampler_Linear_Clamp_ShadowRampTex, rampNeighborUV, 0).rgb; return lerp(RampColor01, RampColor02, saturate(abs(todTime - rampUV.y)/abs(rampNeighborUV.y - rampUV.y)) ); #else return shadowAttenuation; #endif }
欢迎加入我们!
感兴趣的同学可以投递简历至:CYouEngine@cyou-inc.com
#我的成功项目解析##图形引擎实战##游戏引擎##技术美术#