MAKE · WRITE · SHARE

Base-阴影ShadowMap相关

这篇记一下ShadowMap阴影的技术。

1. ShadowMap相关

ShadowMap的原理

从Light的方向渲染场景的深度图,因为具有遮挡关系,所以能做阴影用。https://zhuanlan.zhihu.com/p/384446688

ShadowMap的原理:从Light的方向渲染场景的深度图,因为具有遮挡关系,所以能做阴影用。https://zhuan

阴影投影的过程

需要投影的物体的转换过程,从Light方向画这些物体的深度:

World空间——

——>Light空间 先位移,再旋转

——>Light视锥体 (XY范围缩放,Z深度缩放) 范围-1~1

——>ShadowMap(CascadeTile划分,像素Border)

阴影的一些问题和解决办法

–Bias和阴影尖刺–

有DepthBias和NormalBias两种Bias,用来解决阴影尖刺问题。

在采样ShadowMap的时候,应用NormalBias,WorldPos往法线方向偏一些。

DepthBias则是直接修改灯光深度偏移。

阴影尖刺:
--Bias和阴影尖刺--:阴影尖刺:

应用NormalBias,在表面往法线方向Bias:
--Bias和阴影尖刺--:应用NormalBias,在表面往法线方向Bias:

–阴影平坠问题–

这里记录的是平行光正交阴影路径中的近面处理,用来避免部分投射物被不合适的近裁剪面切掉。深度钳制与 Shader 近面修正是否适用,还需看 API 和投影;不能据此断言所有其他光源都不存在相近技术。
--阴影平坠问题--:这里记录的是平行光正交阴影路径中的近面处理,用来避免部分投射物被不合适的近裁剪面切掉。深度钳制与

–Border–

因为有时候ShadowMap是一整个大ShadowMap。

为了防止边界被用串了,一般边界空出几个像素是空的。

–抗锯齿–

阴影的锯齿一般都很严重,需要使用抗锯齿来软化一下。

一般使用PCF算法。

2. CSM阴影

Cascade的划分

参考大佬文章:
https://zhuanlan.zhihu.com/p/379042993/

网上一个Cascade划分的办法,Light视锥体刚好包住每个Camera分段。https://blog.csdn.net/qq_39300235/article/details/107765941

Cascade的划分:网上一个Cascade划分的办法,Light视锥体刚好包住每个Camera分段。https://

UE4里我看是把视锥分为几个段,通过每段Camera视锥的八个点先计算出一个包围球。

CascadeDistributionExponent 控制级联分段分布,但不能仅凭“三级、指数三”推出 1/13 与 4/13。分段还要看具体分支的归一化公式、近远范围和过渡设置;这里删除未经推导的固定比例,实际边界应从 GetShadowSplitBounds 等生成路径核对。

级联边界由具体分段公式确定,不预设固定比例

然后包围球再计算出一个BoundBox,然后每段得到一个Light的视锥体。具体划分代码在GetShadowSplitBounds,大概如下:

Cascade的划分:然后包围球再计算出一个BoundBox,然后每段得到一个Light的视锥体。具体划分代码在Get

CSM的矩阵

CSM的矩阵和普通的平行光阴影差不多,就是上面的划分部分各不相同,主要是确定LightFrustum的XYZ缩放。

右乘的矩阵为:

CSM的WorldToShadowMapMatrix = TranslationMatrix(移到视锥段中心) * RotateMatrix(旋转到Light方向) * ScaleMatrix(XY缩放为LightFrustum范围确定,Z缩放一般定义一个深度范围再映射到01) * 分Tile缩放和Offset,移动到ShadowMap分块的中心

UE里的Forward阴影矩阵代码(WorldToShadowMartix):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
//比如3级Cascade 一个Tile 2048  ,大ShadowMap是6144x2048。实际每个Tile只画2040x2040,边界留4个像素。
//1 / 6144
const float InvBufferResolutionX = 1.0f / (float)ShadowBufferResolution.X;
//0.5 * 2040 * 1 / 6144
const float ShadowResolutionFractionX = 0.5f * (float)ResolutionX * InvBufferResolutionX;
//1 / 2048
const float InvBufferResolutionY = 1.0f / (float)ShadowBufferResolution.Y;
//0.5 * 2040 * 1 / 2048
const float ShadowResolutionFractionY = 0.5f * (float)ResolutionY * InvBufferResolutionY;

//上面可以算出来0级在ShadowMap的所在UV中心

const FMatrix WorldToShadowMatrix =
// Translate to the origin of the shadow's translated world space
FTranslationMatrix(PreShadowTranslation) *
// Transform into the shadow's post projection space
// This has to be the same transform used to render the shadow depths
SubjectAndReceiverMatrix *
// Scale and translate x and y to be texture coordinates into the ShadowInfo's rectangle in the shadow depth buffer
// Normalize z by MaxSubjectDepth, as was done when writing shadow depths
FMatrix(
FPlane(ShadowResolutionFractionX,0, 0, 0),
FPlane(0, -ShadowResolutionFractionY,0, 0),
FPlane(0, 0, InvMaxSubjectDepth, 0),
FPlane(
(X + BorderSize) * InvBufferResolutionX + ShadowResolutionFractionX, //第一级CasadeX偏移就是(2048 + 4) * 1 / 6144 + 0级在ShadowMap的所在UV.X
(Y + BorderSize) * InvBufferResolutionY + ShadowResolutionFractionY, //第一级CasadeY偏移就是(0 + 4) * 1 / 2048 + 0级在ShadowMap的所在UV.Y
0,
1
)
);

UE还有个延迟渲染使用屏幕空间阴影的版本(ScreenToShadowMatrix):

ScreenToShadow = ScreenToWorld * WorldToShadow

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
FMatrix FProjectedShadowInfo::GetScreenToShadowMatrix(const FSceneView& View, uint32 TileOffsetX, uint32 TileOffsetY, uint32 TileResolutionX, uint32 TileResolutionY) const
{
const FIntPoint ShadowBufferResolution = GetShadowBufferResolution();
const float InvBufferResolutionX = 1.0f / (float)ShadowBufferResolution.X;
const float ShadowResolutionFractionX = 0.5f * (float)TileResolutionX * InvBufferResolutionX;
const float InvBufferResolutionY = 1.0f / (float)ShadowBufferResolution.Y;
const float ShadowResolutionFractionY = 0.5f * (float)TileResolutionY * InvBufferResolutionY;
// Calculate the matrix to transform a screenspace position into shadow map space

FMatrix ScreenToShadow;
FMatrix ViewDependentTransform =
// Z of the position being transformed is actually view space Z,
// Transform it into post projection space by applying the projection matrix,
// Which is the required space before applying View.InvTranslatedViewProjectionMatrix
FMatrix(
FPlane(1,0,0,0),
FPlane(0,1,0,0),
FPlane(0,0,View.ViewMatrices.GetProjectionMatrix().M[2][2],1),
FPlane(0,0,View.ViewMatrices.GetProjectionMatrix().M[3][2],0)) *
// Transform the post projection space position into translated world space
// Translated world space is normal world space translated to the view's origin,
// Which prevents floating point imprecision far from the world origin.
View.ViewMatrices.GetInvTranslatedViewProjectionMatrix() *
FTranslationMatrix(-View.ViewMatrices.GetPreViewTranslation());

FMatrix ShadowMapDependentTransform =
// Translate to the origin of the shadow's translated world space
FTranslationMatrix(PreShadowTranslation) *
// Transform into the shadow's post projection space
// This has to be the same transform used to render the shadow depths
FMatrix(TranslatedWorldToClipInnerMatrix) *
// Scale and translate x and y to be texture coordinates into the ShadowInfo's rectangle in the shadow depth buffer
// Normalize z by MaxSubjectDepth, as was done when writing shadow depths
FMatrix(
FPlane(ShadowResolutionFractionX,0, 0, 0),
FPlane(0, -ShadowResolutionFractionY,0, 0),
FPlane(0, 0, InvMaxSubjectDepth, 0),
FPlane(
(TileOffsetX + BorderSize) * InvBufferResolutionX + ShadowResolutionFractionX,
(TileOffsetY + BorderSize) * InvBufferResolutionY + ShadowResolutionFractionY,
0,
1
)
);

if (View.bIsMobileMultiViewEnabled && View.Family->Views.Num() > 0)
{
// In Multiview, we split ViewDependentTransform out into ViewUniformShaderParameters.MobileMultiviewShadowTransform
// So we can multiply it later in shader.
ScreenToShadow = ShadowMapDependentTransform;
}
else
{
ScreenToShadow = ViewDependentTransform * ShadowMapDependentTransform;
}
return ScreenToShadow;
}

哪些物体需要投射阴影?阴影裁剪剔除问题

投射物集合应覆盖能够向该级联接收区域投影的物体,不能直接复用主相机可见列表;屏外物体仍可能投下可见阴影。具体裁剪体构建与光源方向、接收范围和分支实现有关。

阴影采样哪一级Cascade?

接下来的问题是采样的时候,当前WorldPos采样哪一级Cascade。方法应该也有很多种。

阴影采样哪一级Cascade?:接下来的问题是采样的时候,当前WorldPos采样哪一级Cascade。方法应该也有很多种。

1.在Unity里,SRP里当时写的根据WorldPos到周围BoundSphere球心的距离进行选择,如果同时在两个中间,采样两次,进行混合。

计算处于哪个Cascade。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
MyShadowData data;
data.shadowMask.distance = false;
data.shadowMask.shadows = 1.0;
data.shadowMask.always = false;
data.cascadeBlend = 1.0;
//最大距离之外无阴影,做渐变
//这个距离衰减基于视角空间,作为3种阴影的全局衰减
data.strength = FadeShadowStrength(surfaceWS.depth, _ShadowDistanceFade.x, _ShadowDistanceFade.y);
int i = 0;
//计算出应该采样哪一级cascade,最后i就是级联层级
for(i = 0; i< _CascadeCount; i++){
float4 sphere = _CascadeCullingSpheres[i];
float distanceSqr = DistanceSquared(surfaceWS.position, sphere.xyz);
//平方对比
if(distanceSqr < sphere.w){
float fade = FadeShadowStrength(
distanceSqr, _CascadeData[i].x, _ShadowDistanceFade.z
);

if(i == _CascadeCount - 1)
{
data.strength *= fade; //最大距离的
}
else
{
data.cascadeBlend = fade; //级联的Fade
}
break;
}
}

采样的时候发现如果有CascadeBlend就会采样两次进行混合。
阴影采样哪一级Cascade?:采样的时候发现如果有CascadeBlend就会采样两次进行混合。

2.在UE里直接转换WholeSceneShadowMap转换为ShadowMaskTexture

阴影采样哪一级Cascade?:2.在UE里直接转换WholeSceneShadowMap转换为ShadowMaskTextur

每个Cascade转换一次,转换到屏幕空间。
阴影采样哪一级Cascade?:每个Cascade转换一次,转换到屏幕空间。

原记录图解

用相机视锥分段及其包围体确定级联阴影的覆盖区域

原笔记配图:用相机视锥分段及其包围体确定级联阴影的覆盖区域。

把一列齐次坐标分量缩放的矩阵例子,只说明线性变换,不用于推导固定级联比例

原笔记配图:把一列齐次坐标分量缩放的矩阵例子,只说明线性变换,不用于推导固定级联比例。

原 UE4 分支中的阴影投影矩阵构造代码

原笔记配图:原 UE4 分支中的阴影投影矩阵构造代码。

原记录中查看级联阴影资源与采样的抓帧界面

原笔记配图:原记录中查看级联阴影资源与采样的抓帧界面。

延迟灯光阶段中的屏幕位置与深度相关输入,不能与顶点阶段裁剪位置混淆

原笔记配图:延迟灯光阶段中的屏幕位置与深度相关输入,不能与顶点阶段裁剪位置混淆。

不同级联的覆盖和过渡区示意,重叠宽度由实现与设置决定

原笔记配图:不同级联的覆盖和过渡区示意,重叠宽度由实现与设置决定。

旧版 URP 的主光级联阴影相关代码,不能直接套用到所有当前管线

原笔记配图:旧版 URP 的主光级联阴影相关代码,不能直接套用到所有当前管线。

3. PointLight和SpotLight的阴影

一般PointLight和SpotLight的阴影被划分到Other类别里,在美术制作流程里需要控制他们的数量。

在一些地方,点光源和聚光灯的阴影做效果还是必不可少的。

一般申请额外的ShadowMap,放Other类别的阴影,分成Tile,SpotLight占一格,PointLight占6格(这就能看出来点光源阴影比较费了)。

不同点:PointLight和SpotLight都是有位置和范围的,平行光没位置和范围(所以需要阴影范围)

点光源:往6个方向投射阴影,相当于整了个深度的CubeMap。

SpotLight:投影矩阵相对平行光变成了透视矩阵,NormalBias也需要动态计算。

具体接口示例可对照原 SRP 教程与项目版本:

https://catlikecoding.com/unity/tutorials/custom-srp/point-and-spot-lights/

4. PerObjectShadow

如果角色等需要高精度的阴影,使用CSM有时候不太行。可以使用PerObjectShadow。

PerObjectShadow就是按照角色的BoundBox大小,构建一个光的视锥体。在这个锥体里投射比如512或者1024分辨率的阴影,多个PerObjectShadow可以画在一张大ShadowMap上。

在UE里,通过开启DynamicInsetShadow来应用PerObjectShadow。

1.先画高精度ShadowMapAtlas
PerObjectShadow:1.先画高精度ShadowMapAtlas

2.Light阶段转换合并到屏幕空间阴影
PerObjectShadow:2.Light阶段转换合并到屏幕空间阴影

稳定性与 PerObjectShadow 的条件

级联边界混合减少切换感,投影中心按阴影纹素对齐则减少相机移动造成的投影抖动;二者不是同一个问题。Bias 需要平衡自阴影痤疮、悬浮与漏光,并结合分辨率和坡度观察。

PerObjectShadow 按相关物体/接收范围构造更局部的阴影视图,但 UE4 的 Inset Shadow 开关还受灯光、移动性和其他设置约束,并非所有场景勾选后都走相同路径。

上文矩阵片段保留 UE 原分支的行向量乘法习惯。点光源六面与 Spot 单面是常见表示,具体图集分配和渲染次数仍由后端与路径决定。

总结

ShadowMap 的正确性来自投射范围、深度比较与采样空间一致。CSM 提供分辨率分配,局部阴影提供特定对象精度,稳定化与过滤则处理不同来源的伪影。