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.

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

到此,整个流程完成

多阴影

未完待续

不同光源类型

未完待续

屏幕坐标阴影

不一定写,看情况

阴影优化

半影(penumbra)

级联阴影

不一定写,看情况

Shadow Acne(阴影痤疮)

总觉得这个翻译非常搞笑

阴影偏移(ShadowBias)

抗锯齿

一些神奇优化

AAA 阴影优化

感觉应该单独开一章节

VirtualShadowMap

一个大佬的访谈,主要讲 VirtualShadowMap

https://www.bilibili.com/video/BV1Q24y1g7mq/?spm_id_from=333.1391.0.0&vd_source=fac8a31b7aac66f52e0568883e6013c7

原之阴影

好多年前的原神的主机端优化分享,阴影部分讲了一些东西

https://zhuanlan.zhihu.com/p/356435019


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