# SRP - 平行光实时阴影
# 1. 前言
这一章节主要内容是在SRP中实现平行光源(Directional Light)的实时阴影渲染。
# 2. 理论准备
这里使用Shadow Mapping技术来实现。 相关理论文章可以参考:
[传送门 - Shadow Mapping ](https://learnopengl.com/Advanced-Lighting/Shadows/Shadow-Mapping)
要实现平行光阴影,一般分如下两个步骤:
- Shadow Caster (阴影投射)
- Shadow Receiver (阴影接收)
## 2.1 Shadow Caster
阴影投射从平行光的角度,以正交摄像机绘制场景,得到一张深度图,作为ShadowMapTexture
如上图所示,蓝色线条为灯光照亮区域,而黑色部分由于被遮挡形成阴影区域。其中蓝色部分会渲染到ShadowMapTexture中。
## 2.2 Shadow Receiver:
从摄像机角度绘制场景时,在Fragment里,我们重建每个像素的世界坐标,然后重投影到灯光的ShadowMap空间,与ShadowMapTexture中记录的深度值做比较。从而得出像素是否在阴影区域。
如上图,在ShadowCaster阶段,C点深度`0.4`会被记录到ShadowMapTexture中。 然后当摄像机渲染P点时,算得P点重投影到ShadowMap后的深度为`0.9`, 意味着被C点遮挡,因此判定在阴影区域。
# 3. SRP实现
下面使用SRP来分别实现Shadow Caster和Shadow Receiver。
## 3.1 Shadow Caster
要实现Shadow Caster, 可以细分为如下几个步骤:
- 对场景中的灯光进行裁剪,仅保留对当前摄像机可见区域有影响的灯光.
- 计算灯光的View矩阵和Project矩阵
- 根据计算好的View、Project矩阵,从灯光角度渲染场景,得到ShadowMapTexture
这里我们专门为Shadow Caster定义一个Pass类:
```csharp
public class ShadowCasterPass{
public void Execute(ScriptableRenderContext context,Camera camera,ref CullingResults cullingResults,ref LightData lightData){
}
}
```
### 3.1.1 灯光裁剪
在SRP中,复杂的裁剪工作,引擎已经做好了内部实现,我们只需要通过调用`ScriptableRenderContext.Cull`这个API来进行进行。
在这个系列的前两章中,我们已经完成了裁剪工作,并得到了`CullingResult`对象和`LightData`对象。我们将这两个对象从RenderPipeline中,通过`ShadowCasterPass.Execute`传递过来。其中LightData对象定义如下:
```csharp
public struct LightData{
public int mainLightIndex;
public VisibleLight mainLight;
}
```
在Shadow Caster Pass里我们需要进一步判定,该灯光是否会对可视区域投下阴影。 在SRP中有如下API:
```csharp
public bool CullingResults.GetShadowCasterBounds(int lightIndex, out Bounds outBounds);
```
该接口会根据摄像机视锥裁剪结果,针对指定灯光计算其Shadow Caster影响范围,并返回一个BoundingBox。 假如在范围中没有一个阴影投射对象,那么返回false。
因此我们使用此接口来做前置判断:
```csharp
//false表示该灯光对场景无影响,直接返回
if(!cullingResults.GetShadowCasterBounds(lightData.mainLightIndex,out var lightBounds)){
return;
}
```
### 3.1.2 View&Project矩阵计算
SRP中提供了如下接口来做这件事:
```csharp
public bool CullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(int activeLightIndex, int splitIndex, int splitCount, Vector3 splitRatio, int shadowResolution, float shadowNearPlaneOffset, out Matrix4x4 viewMatrix, out Matrix4x4 projMatrix, out Rendering.ShadowSplitData shadowSplitData);
```
可以看到,这个接口的参数非常之多。在这里,我们暂且不去管split相关的参数。因此只需要关注如下三个输入参数:
- activeLightIndex 即我们前面拿到的平行光源索引
- shadowResolution 为ShadowMap贴图的分辨率
- shadowNearPlaneOffset 灯光角度对场景进行深度渲染时的近平面
shadowResolution 可以从Light组件中获取,我们定义如下:
```csharp
private static int GetShadowMapResolution(Light light){
switch(light.shadowResolution){
case LightShadowResolution.VeryHigh:
return 2048;
case LightShadowResolution.High:
return 1024;
case LightShadowResolution.Medium:
return 512;
case LightShadowResolution.Low:
return 256;
}
return 256;
}
```
shadowNearPlaneOffset同样可以从Light组件获取: [API Doc - Light.shadowNearPlane](https://docs.unity3d.com/ScriptReference/Light-shadowNearPlane.html)
最终调用:
```csharp
cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(lightData.mainLightIndex,0,1,
new Vector3(1,0,0),shadowMapResolution,lightComp.shadowNearPlane,out var matrixView,out var matrixProj,out var shadowSplitData);
```
我们可以得到三个输出对象:
- matrixView
- matrixProj
- shadowSpliteData
### 3.1.3 ShadowMapTexture渲染
SRP提供如下API供我们渲染ShadowMapTexture:
```csharp
public void ScriptableRenderContext.DrawShadows(ref Rendering.ShadowDrawingSettings settings);
```
这个API会按照如下条件对Renderers进行过滤:
- 在灯光裁剪区域内
- 开启了Shadow Caster
- 材质包含`"LightMode" = "ShadowCaster"`Pass
在调用这个API进行渲染前,还需要做如下系列准备工作:
- 申请临时的RenderTexture作为ShadowMapTexture
- 配置管线参数,包括View&Proj矩阵和RenderTarget
在代码上大致为:
```csharp
//生成ShadowMapTexture
_shadowMapHandler.AcquireRenderTextureIfNot(shadowMapResolution);
//设置投影相关参数
SetupShadowCasterView(context,shadowMapResolution,ref matrixView,ref matrixProj);
//绘制阴影
context.DrawShadows(ref shadowDrawSetting);
```
为了让物体能正确投射阴影,我们还需要为物体的Shader实现 ShadowCaster Pass
这里我们将改造之前的`BlinnPongSpecular.shader`,加入`ShadowCaster` Pass:
```hlsl
Pass
{
Name "ShadowCaster"
Tags{"LightMode" = "ShadowCaster"}
ZWrite On
ZTest LEqual
ColorMask 0
Cull Back
HLSLPROGRAM
#pragma vertex ShadowCasterVertex
#pragma fragment ShadowCasterFragment
ENDHLSL
}
```
其中`ShadowCasterVertex`和`ShadowCasterFragment`定义在`Shadow.hlsl`中:
```hlsl
struct ShadowCasterAttributes
{
float4 positionOS : POSITION;
};
struct ShadowCasterVaryings
{
float4 positionCS : SV_POSITION;
};
ShadowCasterVaryings ShadowCasterVertex(ShadowCasterAttributes input)
{
ShadowCasterVaryings output;
float4 positionCS = UnityObjectToClipPos(input.positionOS);
output.positionCS = positionCS;
return output;
}
half4 ShadowCasterFragment(ShadowCasterVaryings input) : SV_Target
{
return 0;
}
```
我们使用这个ShadowCasterPass去渲染一遍场景后,可以在FrameDebugger里查看灯光角度生成的深度图:
## 3.2 Shadow Receiver
Shadow Receiver 是在渲染物体的时候,利用ShadowMap贴图来计算每一个像素是否在阴影空间中。
### 3.2.1 WorldToShadowMap矩阵计算
按照`2.2`中所述,我们需要为Shader准备一个变化矩阵以将世界坐标投影到ShadowMap空间. 这个矩阵计算过程如下:
```csharp
///
/// 通过ComputeDirectionalShadowMatricesAndCullingPrimitives得到的投影矩阵,其对应的x,y,z范围分别为均为(-1,1).
/// 因此我们需要构造坐标变换矩阵,可以将世界坐标转换到ShadowMap齐次坐标空间。对应的xy范围为(0,1),z范围为(1,0)
///
static Matrix4x4 GetWorldToShadowMapSpaceMatrix(Matrix4x4 proj, Matrix4x4 view)
{
//检查平台是否zBuffer反转,一般情况下,z轴方向是朝屏幕内,即近小远大。但是在zBuffer反转的情况下,z轴是朝屏幕外,即近大远小。
if (SystemInfo.usesReversedZBuffer)
{
proj.m20 = -proj.m20;
proj.m21 = -proj.m21;
proj.m22 = -proj.m22;
proj.m23 = -proj.m23;
}
// uv_depth = xyz * 0.5 + 0.5.
// 即将xy从(-1,1)映射到(0,1),z从(-1,1)或(1,-1)映射到(0,1)或(1,0)
Matrix4x4 worldToShadow = proj * view;
var textureScaleAndBias = Matrix4x4.identity;
textureScaleAndBias.m00 = 0.5f;
textureScaleAndBias.m11 = 0.5f;
textureScaleAndBias.m22 = 0.5f;
textureScaleAndBias.m03 = 0.5f;
textureScaleAndBias.m23 = 0.5f;
textureScaleAndBias.m13 = 0.5f;
return textureScaleAndBias * worldToShadow;
}
```
ShadowMap空间的坐标分布为xy范围从`0~1`,z范围从`0~1`。但是在反向深度的情况下,z返回从1~0。[传送门 - 什么是反向深度](https://zhuanlan.zhihu.com/p/254625827)
然后我们然后将此矩阵传递给Shader
### 3.2.2 Shader实现
同样改`BlinnPongSpecular.shader`,使其加入阴影接收的支持。
首先使用一个函数,来计算阴影强度:
```hlsl
///检查世界坐标是否位于主灯光的阴影之中(0表示不在阴影中,大于0表示在阴影中,数值代表了阴影强度)
float GetMainLightShadowAtten(float3 positionWS){
//利用3.2.1中的矩阵,将世界坐标投影到ShadowMap空间
float3 shadowMapPos = WorldToShadowMapPos(positionWS);
float depthToLight = shadowMapPos.z;
float2 sampeUV = shadowMapPos.xy;
float depth = UNITY_SAMPLE_TEX2D(_XMainShadowMap,sampeUV);
#if UNITY_REVERSED_Z
// depthToLight < depth 表示在阴影之中
return clamp(step(depthToLight,depth), 0,_ShadowParams.z);
#else
// depthToLight > depth表示在阴影之中
return clamp(step(depth,depthToLight), 0,_ShadowParams.z);
#endif
}
```
其中_ShadowParams.z为阴影强度,直接取自Light组件.
在Frag中,将输出颜色与阴影强度做相乘:
```hlsl
return (1 - GetMainLightShadowAtten(positionWS,normalWS)) * color;
```
效果图:
可以看到,虽然方块和球体在平面上成功投下了阴影,但是非阴影区域却也出现了许多阴影条纹。这是由于自阴影引起的阴影瑕疵(shadow acne)。
自阴影是由于Shadow Receiver阶段,像素和自身在ShadowCaster产生的ShadowMap深度进行对比,由于是同一像素,理论深度应该是一样的。但因为ShadowMap贴图本身分辨率有限,Camera视角的多个像素投影到ShadowMap上可能是同一个位置,如下图所示:
每一条斜黄线断代表ShadowMap贴图中的一个像素,对应到水平地面上可能覆盖多个像素。多个像素里有些深度较大,有些深度较小,因而产生了条状瑕疵。
解决这个问题,通常引入Bias。 而Bias又分为Depth Bias和 Normal Bias.
### a. Normal Bias
Normal Bias是在将像素世界坐标投影到ShadowMap之前,先将其按照法线方向做一定的偏移:
代码如下:
```hlsl
float3 shadowMapPos = WorldToShadowMapPos(positionWS + normalWS * _ShadowParams.y);
```
_ShadowParams.y为normal bias数值,目前直接从Light组件中读取
### b. Depth Bias.
深度偏移则是直接在深度比较时,对深度值加入一定的Bias数值。 代码实现如下:
```hlsl
//使用使用_ShadowParams.x做深度bias
#if UNITY_REVERSED_Z
// depthToLight < depth 表示在阴影之中
return clamp(step(depthToLight + _ShadowParams.x,depth), 0,_ShadowParams.z);
#else
// depthToLight > depth表示在阴影之中
return clamp(step(depth,depthToLight - _ShadowParams.x), 0,_ShadowParams.z);
#endif
```
其中_ShadowParams.x为depth bias的数值,也是直接取自Light组件。
修正后的效果:
# 4.问题
到目前位置,我们已经大致在SRP中实现了平行光阴影的计算渲染。 但还存在如下问题:
- 在编辑器的SceneView中无法看到阴影.
- 如果把摄像机的far clip plane调大,阴影质量也会变得越来愈差.
- Bias的数值计算需要优化
其中1、2两点,都是由于ShadowMap的贴图精度无法满足要求而引起的。 因为摄像机视野越远,ShadowMap贴图需要覆盖的场景范围就越广,导致单位面积精度下降。 而SceneView中的摄像机,其far clip plane通常是好几万级别的,所以直接阴影就消失不见了。
为了解决这个问题,通常要使用`Cascaded Shadow Mapping`技术. 这会在后续实现。
而Bias的问题,目前是直接取了Light组件的设置,更好的方式是要结合ShadowMap分辨率来计算恰当的偏移。这暂且不在此文讨论范围了。