MEGALOVANIA MEGALOVANIA

Audaces fortuna iuvat:命运眷顾勇敢之人

目录
图形学 渲染篇 其四 阴影
/  

图形学 渲染篇 其四 阴影

本章是 catlikeCoding 渲染系列的第七章对应

前置知识

好像都没什么前置知识,要不以后取消了好了

场景设置

首先设置好我们的场景

场景中有两个光,一个是比较强的白色光,还有一个从左侧射入的黄色的稍微弱一点的方向光。

大概这样

ShadowMapping 原理

注意!如果是看原文的朋友,这里要注意一个深渊巨坑

这里我们首先介绍的思路是 ShadowMapping。原文的编排结构里,虽然也上来也介绍了 ShadowMapping 的思路,介绍了很多关于级联阴影,Bias 之类的内容。

但是在接下来实际的代码实践中,包括 Unity5 的阴影实现方案,都用的 ScreenSpaceShadow 的方案。。。但是恕我眼拙,好像并没有说明这一点

不得不说这是相当令人迷惑的行为。。。

虽然如果理论归类来说,我们可以认为 ScreenSpaceShadow 的思路和我们接下来的 ShadowMapping 的思路差不多,但是我认为关键在于,具体转换的空间是不同的

ScreenSpaceShadow 的思路是将阴影贴图转换到屏幕空间,然后直接将顶点坐标转为屏幕空间坐标,然后再对屏幕空间下的 ShadowMap 采样

而我们先介绍的方案是:ShadowMap 是在光源空间下的深度贴图,通过将顶点坐标转到光源空间下,然后采样对比深度值

我认为这两者的思路是截然不同的。但是原文其实解释得并不清楚,这导致了一些理解上的偏差,以至于一开始我完全无法理解原文开始计算部分的数学原理

关于这一点在冯乐乐老师的《UnityShader 入门精要》也有所提及

我是看了这个才明白过来,这两者看似是同样的思路,但是事实上的实现计算,需要注意的问题,优化的方案是截然不同的

当然这只是我粗浅的理解,也许后面很快我就打脸自己了

不论如何,我们首先介绍传统的阴影映射方案,先把屏幕空间的阴影映射放到一边

我们首先介绍一下 ShadowMapping 的操作原理

简单来说,阴影的原理就是以光源的方向和位置渲染一张深度图,然后通过矩阵变换,将其他 Pass 的像素点变换到对应到这张贴图上,计算这个像素点在光源空间下的深度值,通过与深度贴图比较深度值,从而确定这一点是否被遮盖

我们会随着逐步制作的深入尽量讲解这个思路

ShadowCaster

首先我们需要投射阴影

我们通过定义 LightMode 来定义一个阴影投射器。

1    Tags {"LightMode" = "ShadowCaster"}

事实上这个 shader 几乎什么都不用写(暂时),我们甚至都不需要做任何的坐标变换。因为我们要的只是光源方向的深度贴图

 1Pass
 2{
 3    Name "ShadowCaster"
 4  
 5    Tags {"LightMode" = "ShadowCaster"}
 6  
 7    HLSLPROGRAM
 8  
 9    #pragma vertex vert
10    #pragma fragment frag
11
12    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
13    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
14  
15    struct appdata
16    {
17        float4 vertex : POSITION;
18    };
19
20    struct v2f
21    {
22        float4 positionCS : SV_POSITION;
23    };
24
25    v2f vert(appdata v)
26    {
27        v2f o;
28
29        return o;
30    }
31  
32    half4 frag(v2f i) : SV_Target
33    {
34        return 0.0;
35    }
36    ENDHLSL
37}

我们看一下 FrameDebugger

立马有一个绘制 ShadowCaster 的 Pass,绘制的就是从光源方向观察的的深度贴图

看这个贴图的形状和位置也看的出来是光源空间下的,而非屏幕空间下的

第一个阴影

投射阴影是非常简单的,当然,要让我们的阴影显示出来,我们需要着重写的是接收阴影的部分。

首先需要定义两个变体,一个用来支持 MainLight 阴影,一个用来支持软阴影

1#pragma multi_compile _ _MAIN_LIGHT_SHADOWS
2#pragma multi_compile _ _SHADOWS_SOFT

紧接着,我们只需要将我们第一个 Pass 获取 MainLight 的地方改成这样

1// 获取主光源信息
2Light mainLight = GetMainLight(TransformWorldToShadowCoord(i.worldPos));

回到场景,将主光源的阴影调成软阴影,你会发现,阴影已经有了!

是的,就是这么简单!

思路介绍

这里针对 urp 推荐这个参考,官方的文档。虽然没有特别清楚,但是常用的函数都列出来了。

目前对我们来说,主要的黑盒存在于 TransformWorldToShadowCoordGetMainLight 这两个函数之中

我们尝试用我们的理解重新构建这个过程,来达到理解的目的

思考一下,我们需要的东西是什么?

首先我们需要一张阴影贴图,这个倒是没什么难度,Unity 已经为我们处理好了。通过查阅资料我们得知,他就是这个()

1//Shadow.hlsl
2TEXTURE2D_SHADOW(_MainLightShadowmapTexture);

我们还缺少两个东西。一个是这个像素点在光照空间下坐标(其中包含了这一点在光照空间下的深度值),另一个是这个像素点对应的阴影贴图中采样出来的深度值(实际从光照空间观察时的深度值)

然后我们通过比大小,来确定这个像素点是否在光照空间下被遮盖,如果被遮盖,那么就是阴影部分

坐标转换

我们首先将这个像素点转换到光照空间下,我们所用的函数是 TransformWorldToShadowCoord。根据我们前面的参考,官方推荐使用的函数是 GetShadowCoord

看一下这个函数的实际定义

1float4 GetShadowCoord(VertexPositionInputs vertexInput)
2{
3#if defined(_MAIN_LIGHT_SHADOWS_SCREEN) && !defined(_SURFACE_TYPE_TRANSPARENT)
4    return ComputeScreenPos(vertexInput.positionCS);
5#else
6    return TransformWorldToShadowCoord(vertexInput.positionWS);
7#endif
8}

再次声明,我们首先介绍的是传统的阴影映射方案,而非屏幕空间的阴影映射(ScreenSpaceShadowMapping)

因此我们只需要 TransformWorldToShadowCoord 这个函数,看下它的定义,只需要看黄色部分:

 1float4 TransformWorldToShadowCoord(float3 positionWS)
 2{
 3#if defined(_MAIN_LIGHT_SHADOWS_SCREEN) && !defined(_SURFACE_TYPE_TRANSPARENT)
 4    float4 shadowCoord = float4(ComputeNormalizedDeviceCoordinatesWithZ(positionWS, GetWorldToHClipMatrix()), 1.0);
 5#else
 6    #ifdef _MAIN_LIGHT_SHADOWS_CASCADE
 7        half cascadeIndex = ComputeCascadeIndex(positionWS);
 8    #else
 9        half cascadeIndex = half(0.0);
10    #endif
11  
12    float4 shadowCoord = float4(mul(_MainLightWorldToShadow[cascadeIndex], float4(positionWS, 1.0)).xyz, 0.0);
13#endif
14    return shadowCoord;
15}

事实上就是一个矩阵乘法,_MainLightWorldToShadow[cascadeIndex],应该是世界空间到光源空间的变换矩阵

一些细节,可以跳过

为了佐证这一猜想,我们可以在 Urp 的代码里找到对应的部分:在 MainLightShadowCasterPass.cs 这个文件中

我们可以直接看到部分实现

 1private static class MainLightShadowConstantBuffer
 2{
 3    public static readonly int _WorldToShadow = Shader.PropertyToID("_MainLightWorldToShadow");
 4    public static readonly int _ShadowParams = Shader.PropertyToID("_MainLightShadowParams");
 5    public static readonly int _CascadeShadowSplitSpheres0 = Shader.PropertyToID("_CascadeShadowSplitSpheres0");
 6    public static readonly int _CascadeShadowSplitSpheres1 = Shader.PropertyToID("_CascadeShadowSplitSpheres1");
 7    public static readonly int _CascadeShadowSplitSpheres2 = Shader.PropertyToID("_CascadeShadowSplitSpheres2");
 8    public static readonly int _CascadeShadowSplitSpheres3 = Shader.PropertyToID("_CascadeShadowSplitSpheres3");
 9    public static readonly int _CascadeShadowSplitSphereRadii = Shader.PropertyToID("_CascadeShadowSplitSphereRadii");
10    public static readonly int _ShadowOffset0 = Shader.PropertyToID("_MainLightShadowOffset0");
11    public static readonly int _ShadowOffset1 = Shader.PropertyToID("_MainLightShadowOffset1");
12    public static readonly int _ShadowmapSize = Shader.PropertyToID("_MainLightShadowmapSize");
13    public static readonly int _MainLightShadowmapID = Shader.PropertyToID(k_MainLightShadowMapTextureName);
14}

追着看,我们找到 DirectionalLight 的矩阵计算

 1/// <summary>
 2/// Extracts the directional light matrix.
 3/// </summary>
 4/// <param name="cullResults">The results of a culling operation.</param>
 5/// <param name="shadowData">Data containing shadow settings.</param>
 6/// <param name="shadowLightIndex">The visible light index.</param>
 7/// <param name="cascadeIndex">The cascade index.</param>
 8/// <param name="shadowmapWidth">The shadow map width.</param>
 9/// <param name="shadowmapHeight">The shadow map height.</param>
10/// <param name="shadowResolution">The shadow map resolution.</param>
11/// <param name="shadowNearPlane">Near plane value to use for shadow frustums.</param>
12/// <param name="cascadeSplitDistance">The culling sphere for the cascade.</param>
13/// <param name="shadowSliceData">The struct container for shadow slice data.</param>
14/// <returns>True if the matrix was successfully extracted.</returns>
15public static bool ExtractDirectionalLightMatrix(ref CullingResults cullResults, UniversalShadowData shadowData, int shadowLightIndex, int cascadeIndex, int shadowmapWidth, int shadowmapHeight, int shadowResolution, float shadowNearPlane, out Vector4 cascadeSplitDistance, out ShadowSliceData shadowSliceData)
16{
17    bool success = cullResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(shadowLightIndex,
18        cascadeIndex, shadowData.mainLightShadowCascadesCount, shadowData.mainLightShadowCascadesSplit, shadowResolution, shadowNearPlane, out shadowSliceData.viewMatrix, out shadowSliceData.projectionMatrix,
19        out shadowSliceData.splitData);
20
21    cascadeSplitDistance = shadowSliceData.splitData.cullingSphere;
22    shadowSliceData.offsetX = (cascadeIndex % 2) * shadowResolution;
23    shadowSliceData.offsetY = (cascadeIndex / 2) * shadowResolution;
24    shadowSliceData.resolution = shadowResolution;
25    shadowSliceData.shadowTransform = GetShadowTransform(shadowSliceData.projectionMatrix, shadowSliceData.viewMatrix);
26
27    // It is the culling sphere radius multiplier for shadow cascade blending
28    // If this is less than 1.0, then it will begin to cull castors across cascades
29    shadowSliceData.splitData.shadowCascadeBlendCullingFactor = 1.0f;
30
31    // If we have shadow cascades baked into the atlas we bake cascade transform
32    // in each shadow matrix to save shader ALU and L/S
33    if (shadowData.mainLightShadowCascadesCount > 1)
34        ApplySliceTransform(ref shadowSliceData, shadowmapWidth, shadowmapHeight);
35
36    return success;
37}

我还没搞清楚这个 shadowSliceData** **的来源,姑且认为是光源的一些变换矩阵吧

里面的计算

 1static Matrix4x4 GetShadowTransform(Matrix4x4 proj, Matrix4x4 view)
 2{
 3    // Currently CullResults ComputeDirectionalShadowMatricesAndCullingPrimitives doesn't
 4    // apply z reversal to projection matrix. We need to do it manually here.
 5    if (SystemInfo.usesReversedZBuffer)
 6    {
 7        proj.m20 = -proj.m20;
 8        proj.m21 = -proj.m21;
 9        proj.m22 = -proj.m22;
10        proj.m23 = -proj.m23;
11    }
12
13    Matrix4x4 worldToShadow = proj * view;
14
15    var textureScaleAndBias = Matrix4x4.identity;
16    textureScaleAndBias.m00 = 0.5f;
17    textureScaleAndBias.m11 = 0.5f;
18    textureScaleAndBias.m22 = 0.5f;
19    textureScaleAndBias.m03 = 0.5f;
20    textureScaleAndBias.m23 = 0.5f;
21    textureScaleAndBias.m13 = 0.5f;
22    // textureScaleAndBias maps texture space coordinates from [-1,1] to [0,1]
23
24    // Apply texture scale and offset to save a MAD in shader.
25    return textureScaleAndBias * worldToShadow;
26}

事实上也很简单,除去 ReverseZBuffer 的计算之外,就是将 PV 矩阵相乘,然后再乘上一个缩放 + 平移的 textureScaleAndBias 矩阵来让坐标从-1,1 变换到 0,1(整体*0.5+0.5)

采样对比

之后,我们进行一个采样

1mainLight.shadowAttenuation = SAMPLE_TEXTURE2D_SHADOW(_MainLightShadowmapTexture, sampler_LinearClampCompare , TransformWorldToShadowCoord(i.worldPos).xyz);

这是 hlsl 为我们提供的一个方法,其实是融合了阴影贴图的采样,它的宏这样定义

1#define SAMPLE_TEXTURE2D_SHADOW(textureName, samplerName, coord3)                    textureName.SampleCmpLevelZero(samplerName, (coord3).xy, (coord3).z)

看这个函数定义 textureName.SampleCmpLevelZero(samplerName, (coord3).xy, (coord3).z),其实就是用 xy 坐标采样了阴影贴图之后得到深度,与顶点自己的深度 z 来做对比

(深度可能不准确,但是大概这么个意思)

如果采样出来的点比转换过去的顶点更靠近光源,那么说明这一点就被遮盖在阴影之中了,返回 0.反之则返回 1.

之后再将这个给光源强度,用来实现阴影。

到此,整个流程完成

一些邪门的东西:神奇的 ScreenSpaceShadow

发现在 Renderfeature 里加了这个 feature 以后,会导致这里失效

1#if !defined(MAIN_LIGHT_CALCULATE_SHADOWS)
2    return half(1.0);

这个 screenSpaceShadow 好像我还真不知道到底该怎么用目前,但是暂时我们不管它

我还以为我自己写出 bug 来了,我靠

多光源阴影

注意,这里有个比较坑的地方:你的 ADDITIONAL_LIGHTS 如果是 directionallight 的话,是没有办法产生阴影的!

要出阴影很简单,我们首先要定义两个宏

1#pragma multi_compile _ _ADDITIONAL_LIGHTS
2#pragma multi_compile _ _MAIN_LIGHT_SHADOWS
3#pragma multi_compile _ _SHADOWS_SOFT
4#pragma multi_compile _ _ADDITIONAL_LIGHT_SHADOWS                          //额外光源阴影
5#pragma multi_compile _ ADDITIONAL_LIGHT_CALCULATE_SHADOWS        

然后找到我们的 addctional light 的部分

1for (int lightIndex = 0; lightIndex < additionalLightsCount; lightIndex++)
2{
3    // 获取额外光源(点光源、聚光灯等)
4    Light light = GetAdditionalLight(lightIndex, i.worldPos, half4(1,1,1,1));
5    // 计算这个光源的贡献
6    color += LightingPhysicallyBased(brdfData, light, i.normal, viewDir);
7}

使用 GetAdditionalLight 的另一个重载

重载里面干了什么呢?

 1Light GetAdditionalLight(uint i, float3 positionWS, half4 shadowMask)
 2{
 3#if USE_FORWARD_PLUS
 4    int lightIndex = i;
 5#else
 6    int lightIndex = GetPerObjectLightIndex(i);
 7#endif
 8    Light light = GetAdditionalPerObjectLight(lightIndex, positionWS);
 9
10#if USE_STRUCTURED_BUFFER_FOR_LIGHT_DATA
11    half4 occlusionProbeChannels = _AdditionalLightsBuffer[lightIndex].occlusionProbeChannels;
12#else
13    half4 occlusionProbeChannels = _AdditionalLightsOcclusionProbes[lightIndex];
14#endif
15    light.shadowAttenuation = AdditionalLightShadow(lightIndex, positionWS, light.direction, shadowMask, occlusionProbeChannels);
16#if defined(_LIGHT_COOKIES)
17    real3 cookieColor = SampleAdditionalLightCookie(lightIndex, positionWS);
18    light.color *= cookieColor;
19#endif
20
21    return light;
22}

其实最核心就是这个**shadowAttenuation **的计算

然后我们来看一下这个 AdditionalLightShadow

 1half AdditionalLightShadow(int lightIndex, float3 positionWS, half3 lightDirection, half4 shadowMask, half4 occlusionProbeChannels)
 2{
 3    half realtimeShadow = AdditionalLightRealtimeShadow(lightIndex, positionWS, lightDirection);
 4
 5#ifdef CALCULATE_BAKED_SHADOWS
 6    half bakedShadow = BakedShadow(shadowMask, occlusionProbeChannels);
 7#else
 8    half bakedShadow = half(1.0);
 9#endif
10
11#ifdef ADDITIONAL_LIGHT_CALCULATE_SHADOWS
12    half shadowFade = GetAdditionalLightShadowFade(positionWS);
13#else
14    half shadowFade = half(1.0);
15#endif
16
17    return MixRealtimeAndBakedShadows(realtimeShadow, bakedShadow, shadowFade);
18}

我们现在是 realtime Shadow,不考虑 bakedShadow 的情况,主要的代码其实是在这里 AdditionalLightRealtimeShadow 以及**GetAdditionalLightShadowFade **这两个函数中

首先来看第一个函数

 1// returns 0.0 if position is in light's shadow
 2// returns 1.0 if position is in light
 3half AdditionalLightRealtimeShadow(int lightIndex, float3 positionWS, half3 lightDirection)
 4{
 5    #if defined(ADDITIONAL_LIGHT_CALCULATE_SHADOWS)
 6        ShadowSamplingData shadowSamplingData = GetAdditionalLightShadowSamplingData(lightIndex);
 7
 8        half4 shadowParams = GetAdditionalLightShadowParams(lightIndex);
 9
10        int shadowSliceIndex = shadowParams.w;
11        if (shadowSliceIndex < 0)
12            return 1.0;
13
14        half isPointLight = shadowParams.z;
15
16        UNITY_BRANCH
17        if (isPointLight)
18        {
19            // This is a point light, we have to find out which shadow slice to sample from
20            float cubemapFaceId = CubeMapFaceID(-lightDirection);
21            shadowSliceIndex += cubemapFaceId;
22        }
23
24        #if USE_STRUCTURED_BUFFER_FOR_LIGHT_DATA
25            float4 shadowCoord = mul(_AdditionalLightsWorldToShadow_SSBO[shadowSliceIndex], float4(positionWS, 1.0));
26        #else
27            float4 shadowCoord = mul(_AdditionalLightsWorldToShadow[shadowSliceIndex], float4(positionWS, 1.0));
28        #endif
29
30        return SampleShadowmap(TEXTURE2D_ARGS(_AdditionalLightsShadowmapTexture, sampler_LinearClampCompare), shadowCoord, shadowSamplingData, shadowParams, true);
31    #else
32        return half(1.0);
33    #endif
34}

如果是 pointlight,是一张 cubemap 采样。如果是 spotlight,采样的方法与我们前面所讲述的方法是类似的。

至于这个贴图怎么生成,这里就看不到了。现在我也懒得管了,已经被折磨得不想再看了,等写到自己搭管线的时候再说吧。

关于 GetAdditionalLightShadowFade 的计算很简单,其实就是通过距离和 additionalLight 的一些参数,对阴影的强度做一些修正

 1//float4      _AdditionalShadowFadeParams; 
 2// x: additional light fade scale, y: additional light fade bias, z: 0.0, w: 0.0)
 3
 4half GetAdditionalLightShadowFade(float3 positionWS)
 5{
 6    #if defined(ADDITIONAL_LIGHT_CALCULATE_SHADOWS)
 7        float3 camToPixel = positionWS - _WorldSpaceCameraPos;
 8        float distanceCamToPixel2 = dot(camToPixel, camToPixel);
 9
10        float fade = saturate(distanceCamToPixel2 * float(_AdditionalShadowFadeParams.x) + float(_AdditionalShadowFadeParams.y));
11        return half(fade);
12    #else
13        return half(1.0);
14    #endif
15}

其实在 FrameDebugger 里能看到一张采样:

不同光源类型处理

聚光灯与平行光

相信前面的展示里平行光已经解释得比较清楚了,我们看一下 spotLight 的问题。

SpotLight 的问题在于,它是有确切位置的,因此,它没有办法像平行光那样做阴影级联来提高远处的阴影质量。

SpotLight 的阴影贴图生成和 DirectionalLight 的生成,可以粗浅理解为透视相机和正交相机的区别。

点光源

点光源的阴影是昂贵的。因为它必须渲染六个贴图,渲染一个 CubeMap 出来,这样才能计算各个方向上的阴影。

至于具体如何计算,我这里就不展开讲了,也许我还没完全理解参透。

小结

我完全没有讲完阴影的所有内容。因为我越看越意识到:阴影是一个比较庞大繁杂的话题,针对阴影的优化方案,处理方法,不太适合在入门的时候直接全部讲解。对于阴影级联,ShadowAcne 和 ShadowBias 这种比较常见的概念,其实自己去查一下资料也大概能搞清楚。

本来还打算提一下一下 B 站大佬讲 VirtualShadowMap 以及原神的分享里针对阴影的性能优化,但是现在感觉这个篇幅太过长了,至少对于我这样的新手入门来说。

我打算把阴影的剩余内容,作为一个进阶的篇章,等到有时间了再慢慢填充。


标题:图形学 渲染篇 其四 阴影
作者:matengli110
地址:https://www.sunsgo.world/articles/2025/04/30/1745984647400.html