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 推荐这个参考,官方的文档。虽然没有特别清楚,但是常用的函数都列出来了。

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

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

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

首先我们需要一张阴影贴图,这个倒是没什么难度,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