百人计划-阴影

参考资料:
urp管线的自学hlsl之路
《Unity Shader入门精要》 第9章 更复杂的光照
Games202

代码标注为C++只是为了会有高亮好看,实际是ShaderLab

阴影的实现原理

在实时渲染中,阴影一般通过shadow map实现,具体来说就是先将摄像机移到要投射阴影的光源位置,进行一次渲染得到从光源处看到的一张深度图,我们叫这张图为shadow map;之后在正常的渲染pass中,将顶点坐标变换到光源坐标系中,将深度与shadow map中的深度进行比较,如果比shadow map中的深度大,就说明该点应该在阴影中。

在unity中阴影的实现分为两部分,接收阴影(接收别的物体投射出的阴影)和投射阴影(将自身阴影投射到别的物体)

  1. 接收阴影:接收阴影就是要计算那些点在应该在阴影里,也就是需要在正常的光照计算中将顶点在光源坐标系中的深度与shadow map中的深度进行对比,得到阴影衰减。
  2. 投射阴影:根据上面描述的阴影的实现原理,如果一个物体要投射阴影就需要将让这个物体参与shadow map的生成,在unity中使用一个带有 Tags { “LightMode” = “ShadowCaster”} 的pass来实现。

接收阴影

下面给出在URP中实现接收阴影的关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE
#pragma multi_compile _ _SHADOWS_SOFT
v2f vert(in a2v v){
//...
}
real4 frag(in v2f i) : SV_TARGET{
// compute ambient, albedo
// ...
// compute diffuse
float4 shodowCoord = TransformWorldToShadowCoord(worldPos);
Light mainLight = GetMainLight(shadowCoord);
half3 diffuse = mainLight.color * albedo * saturate(dot(worldNormal, mainLight.direction)) * mainLight.shodowAttenuation;
// ...
}
ENDHLSL

其中 float4 TransformWorldToShadowCoord(float3 positionWS) 函数(定义在Shadows.hlsl中)用将顶点坐标从世界坐标转换到光源坐标:

可以看出函数需要一个宏 _MAIN_LIGHT_SHADOWS_CASCADE 所以我们需要在shader中也定义这个宏,用来确定使用哪个尺度的shadow map(在URP资产中可以设置生成不同尺度shadow map的个数),选择的shadow map分辨率越大阴影越细致,这个尺度的选择由函数 half ComputeCascadeIndex(float3 positionWS) 实现,离摄像机越远选择分辨率越低的shadow map,之后函数直接用矩阵乘法计算出光源坐标系中的顶点坐标。

其中的 Light GetMainLight(float4 shadowCoord) 函数是原始的 Light GetMainLight() 的一个重载:

相比原函数多出一个计算光照衰减的步骤,通过函数 half MainLightRealtimeShadow(float4 shadowCoord) 实现:

由这个函数就可以看出我们需要定义宏 _MAIN_LIGHT_COMPUTE_SHADOWS 来采样shadow map计算阴影衰减。并且如果我们希望生成软阴影也需要定义宏 _SHADOWS_SOFT,这个宏会在 SampleShadowmap 函数中决定是否生成软阴影。


至此,我们的物体已经可以接收别的物体投射的阴影了,但是可以看到地面上也有一个正方体的阴影,这是因为我们的物体还不能投射阴影,在shadow map中根本没有我们的胶囊体,因此地面去采样shadow map时还是会接收到正方体的阴影,接下来实现投射阴影来解决这个问题。

投射阴影

在上面的阴影实现原理中,已经得知投射阴影需要将物体写入shadow map,在unity中实现需要单独实现一个pass并标明其 LightModeShadowCaster,这样unity就会在生成shadow map时考虑该物体,也就是将光源坐标下该物体的深度写入shadow map。

下面给出在URP中实现投射阴影的关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pass{
Tags {"LightMode"="UniversalForward"}
// 正向渲染计算光照并实现接收阴影
// ...
}
pass{
Tags {"LightMode"="ShadowCaster"}
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag

v2f vert(in a2v v)
{
v2f o;
o.positionCS = TransformObjectToHClip(v.positionOS.xyz);
return o;
}
real4 frag(in v2f i) : SV_TARGET
{
return 0;
}
ENDHLSL
}

LightMode 设置为 ShadowCaster 后,只需要实现最简单的顶点和片元着色器就可以投射阴影了,因为将深度写入shadow map本质就是将场景渲染到shadow map上,借助渲染流水线中的深度写入就可以实现渲染出一张深度图了。已经可以渲染出正确的阴影了:

Shadow Mapping的问题

由于shadow map是有分辨率的,那么每个像素就会对应一片区域,且这片区域的深度是相同的,这样就会导致将连续的深度离散化,就会产生自遮挡的问题。为解决这个问题,我们需要添加一个偏移,忽略一小段深度变化,也就是只有深度相差较大的时候,才认为产生了遮挡。这种解决方案叫做 深度偏移

自适应Shadow Bias算法

深度偏移

如下图所示,深度偏移就是将 D点 移动到 G点,将 C点 移动到 H点,这样D点的深度就不会被认为比G点低而被遮挡了。在实际应用中,我们不会为CD线段上的每个点计算准确的偏移,而是直接将每个点都向光源方向平移DG的距离,这一步操作在顶点着色器中进行。

法线偏移

还有一种解决方案是将 DG 向上平移一段距离,原理如下:

实现

在URP管线下,shadow.hlsl中的函数 float3 ApplyShadowBias(float3 positionWS, float3 normalWS, float3 lightDirection) 同时实现了这两种偏移,避免了“漏光”现象:

函数中使用的深度偏移值和法线偏移值均在URP资产中设置:

至此我们可以自己实现一个带阴影偏移的投射阴影的pass:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pass {
Tags {"LightMode"="ShadowCaster"}

HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag

half3 _LightDirection; // 应该是unity定义的,这里只需要声明即可
v2f vert(in a2v v)
{
v2f o;
o.worldNormal = TransformObjectToWorldNormal(v.normalOS);
o.worldPos = TransformObjectToWorld(v.positionOS.xyz);
// 注意这里是从世界空间转换到裁剪空间!
o.positionCS = TransformWorldToHClip(ApplyShadowBias(o.worldPos, o.worldNormal, _LightDirection));
return o;
}
real4 frag(in v2f i) : SV_TARGET
{
return 0;
}
ENDHLSL
}

十分光滑了!

多光源阴影

多光源shader

这里先复习一下多光源的处理,在Build-in管线中多光源需要一个主光源pass(**Tags{“LightMode”=”ForwardBase”})和一个其他光源pass(Tags{“LightMode”=”AddForward”}**)两个pass来实现,对于URP管线,多光源被整合进一个pass中实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pass {
Tags {"LightMode"="UniversalForward"}
// ...
real4 frag(in v2f i) : SV_TARGET{
// mainlight
// ...
// add light color
real3 color = real3(0, 0, 0);
int addLightCount = GetAdditionalLightsCount();
for (int index; index < addLightCount; index++)
{
Light addLight = GetAdditionalLight(index, i.worldPos);
color += addLight.color * albedo * saturate(dot(addLight.direction, worldNormal)) * addLight.distanceAttenuation;
}
return real4(mainLightColor + color + ambient, 1.0);
}
}

多光源阴影

和主光源阴影的实现一样,多光源阴影也分为接收阴影和投射阴影两部分,其实现如下:

多光源阴影接收

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pass {
Tags {"LightMode"="UniversalForward"}
#pragma multi_compile _ _ADDITIONAL_LIGHT_SHADOWS
/*
...
*/
real4 frag(in v2f i) : SV_TARGET{
// main light color
// ...
// add light color
real3 color = real3(0, 0, 0);
int addLightCount = GetAdditionalLightsCount();
for (int index; index < addLightCount; index++)
{
// 这里使用GetAdditionalLight的重载
Light addLight = GetAdditionalLight(index, i.worldPos, half4(1,1,1,1));
// 这里乘上阴影衰减
color += addLight.color * albedo * saturate(dot(addLight.direction, worldNormal)) * addLight.distanceAttenuation * addLight.shadowAttenuation;
}
return real4(mainLightColor + color + ambient, 1.0);
}
}

可以看出其实现几乎和不带阴影的多光源一样,只有几处不同:

  1. 定义 _ADDITIONAL_LIGHT_SHADOWS 关键字,这个关键字决定了在shadow.hlsl中是否计算其他光源的阴影:
  2. 使用 GetAddtionalLight 函数的重载,这个重载中才计算了阴影衰减,参数中的 shadowMask 与计算烘焙光照有关;
  3. 在计算其他光源的光照时乘上阴影衰减;

多光源阴影投射

每个点光源其shadow map为六个方向:

每个聚光灯其shadow map为其朝向的方向:

unity中的面光源只能用在烘焙中,就不关心其shadow map了。

使用和主光源阴影shader中相同的 ShadowCaster pass 效果如下:

可以看出效果还可以,已经可以清晰的看到可以投射阴影了,但是其实还有问题,当我们把点光和遮挡物体放到胶囊体的漫反射阴影区域时会看到明显的artifact:

这和我们之前提到的深度偏移有关,我们为了解决模型投射阴影时的自遮挡问题,引入了深度偏移,正常的深度偏移需要根据光源方向决定向哪个方向进行偏移:

但是在我们之前实现的ShadowCaster中,深度偏移的函数 ApplyShadowBias 中使用的是unity提前定义好的一个光源方向 _LightDirection,这个变量是主光源的方向,我们在计算其他光源时也用这个变量显然是不对的,看 ShadowCaster.hlsl中的注释也不知道这两个值是如何得到的;

在URP的Lit shader中专门定义了一个宏来确定深度偏移中的光源方向应该是哪个,当光源方向不是主光源时,手动计算出光源方向:

这里如何得到正在参与计算的光源位置还不知道怎样自己实现,故就直接使用URP自带的ShadowCaster解决这个问题:

1
UsePass "Universal Render Pipeline/Lit/ShadowCaster" 

透明物体的阴影

对于透明物体我们主要处理的是使用 Alpha Test 的透明物体的阴影,而使用 Alpha Blend 的半透明物体的阴影直接当作不透明物体处理。

对于接收阴影,透明物体和不透明物体完全相同,对于投射阴影,只需要在shadow caster pass中将不能通过 Alpha Test 的片元舍弃,这样他们在shadow map中的深度就是无限了,从而不会投射阴影。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pass{
Tags {"LightMode"="ShadowCaster"}
HLSLPRAGMA
#pragma vertex vert
#pragma fragment frag

v2f vert(in a2v v){
v2f o;
o.positionCS = TransformObjectToHClip(v.positionOS.xyz);
o.uv = TRANSFORM_TEX(v.texcoord, _BaseMap);
return o;
}
real4 frag(in v2f i) : SV_TARGET{
half4 texColor = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv);
clip(texColor.a - _Cutoff);
return 0;
}
ENDHLSL
}


百人计划-阴影
https://kenny-hoho.github.io/2023/09/08/百人计划-阴影/
作者
Kenny-hoho
发布于
2023年9月8日
许可协议