# 自适应的Shadow Bias计算 在先前的ShadowMap实现中,已经简单的提到了shadow bias。可参考如下: [平行光实时阴影](./MainLightShadow.md) 当时只实现了一个简单的版本: >Bias的问题,目前是直接取了Light组件的设置,更好的方式是要结合ShadowMap分辨率来计算恰当的偏移。 本篇准备详细的展开聊聊shadow bias,以及实际实现中的自适应优化算法。将会包含以下内容: - 阴影瑕疵(shadow acne)的成因及其数学模型 - shadow bias数值的精确计算方法 - 级联阴影(cascade shadow)对shadow bias的影响 - PCF阴影对shadow bias的影响 - 两种不同的shadow bias实现方式 - 自适应的bias算法 # 1. 阴影瑕疵(shadow acne)的成因及其数学模型 shadow bias是为了解决自遮挡阴影瑕疵(shadow acne)而提出的。 由于shadowmap技术自身的原因,当bias没有加入时,场景中会出现条状阴影纹路,这便是自遮挡阴影瑕疵。 引起shadow acne的原因如下: - ShadowMap的分辨率有限 - 因此ShadowMap中的一个像素,对应到场景上是一片区域。 - 在ShadowMap生成阶段(Shadow Caster),该区域内的点(假设都暴露在光照下)最终会汇集到ShadowMap上的一个像素,形成一个平均深度值D。 - 在阴影计算阶段(Shadow Receiver),当我们将该区域内的点投影到ShadowMap中进行深度对比时,会发现其中一些点的深度大于D,而其中一些小于D,因此形成了亮暗条纹。 用一张图来解释: >箭头为入射的平行光,每一条斜黄线断代表ShadowMap贴图中的一个像素,对应到水平地面上可能覆盖多个像素。多个像素里有些深度较大,有些深度较小,因而产生了条状瑕疵。 我们可以使用一个Shadow Debug Pass,将ShadowMap的分辨率以红黑格子的形式,投影到场景中绘制出来如下: 可以看得出来,自遮挡引起的阴影瑕疵条纹与shadowmap的分辨率格子大小是一致的。 凑近了观察一下 红黑格子为shadowmap投下的分辨率(平行光以45度照射,因此格子呈现1:2的长方形)。黑白条纹会自遮挡引起的阴影瑕疵。我们可以看出,从左至右,一个分辨率格子内包含了黑白条纹各一条。 接下来我们会将以上的阴影自遮挡问题,转化为一个纯粹的几何问题,来用数学进行精确描述。这将解释: - 为什么瑕疵条纹与ShadowMap的分辨率会呈现以上的关系 - 如何精确的计算Bias的数值来修正自遮挡问题。 ## 1.2 几何模型 首先看下图: - 橙色线条为平行光视角的近平面,记为L,这个近平面最终将映射为一张ShadowMap深度图。 - 蓝色线条为接受光照的平面。 我们暂且假设Light对应的正交矩阵其宽高相同(实际上URP中就是这样的),记为frustumSize。ShadowMap贴图的尺寸记为shadowMapSize。 设AB代表ShadowMap贴图上单位像素在Light近平面上对应的尺寸。那么我们有以下公式: ``` |AB| = frustumSize / shadowMapSize ``` 从AB作橙线的垂线,相交场景蓝色平面于CD两点。易知,CD范围内的所有像素均投影到平面L上的AB区域。 由于AB代表了ShadowMap上的单个像素,因此AB像素中存储的深度值应当是CD中点F到平面L的距离,即|FE|(实际上是归一的)。 我们记: `Distance(X,L)` - 表示任意点X到光源近平面L的距离。 那么显然: - 对任意点X属于CF,有Distance(X,L) < |FE|,因此判定为不在阴影中 - 对任意点X属于DF,有Distance(X,L) > |FE|,因此判定为在阴影。 于是在CD区域中会呈现出一半白,一半黑的阴影瑕疵,这就是Shadow Acne,其以CD长度为周期在平面上循环交替,而|CD|正是ShadowMap中单个像素投射在场景上覆盖的区域。 # 2. Shadow Bias 既然已经知道了问题所在,我们就可以在深度对比阶段,来修正这个问题。 ## 2.1 Depth Bias 过F点做L的平行线p,如下图: 我们只要将CD中的点,往光照方向平移一定距离到GH上即可修正误差。这个移动的距离即称为Depth Bias。不妨先考察D点。 |DG|即是要修正的Depth Bias,于是我们有: $$ \begin{aligned} DepthBias(D) &= |DG| \\ &= |FG| * \tan(\theta) \\ &= \frac{|AB|}{2} * \tan(\theta) \\ &= \frac{frustumSize * \tan(\theta)}{shadowMapSize * 2} \end{aligned} $$ 那么对于CD上的任意点X,我们可以使用相似三角形的原理,从DepthBias(D)乘以对应的比例就可以了。 伪代码如下: ``` shadowUV = shadowProj(X); percent = (frac(shadowUV) - 0.5) / 0.5; DepthBias_X = DepthBias_D * percent; ``` 实际上在大多数引擎的实现中,并不会这么精确的去计算每个点的bias数值,而是对所有的点执行一个固定的Depth Bias。很明显,D点的误差是最大的,因此只要对所有的点都使用D点的bias数值,就可以修正自遮挡的问题。 但是固定的depth bias也会引起其他的问题。 ### 2.1.1 漏光问题 考虑点C前面有个遮挡物。 本来C点应该是处于阴影中的。但是我们通过depth bias将C点往光源方向进行了长度为|DG|的偏移,那么C点就变到了遮挡物前面去了。这就是固定depth bias引起的`漏光现象`。 ### 2.1.2 bias趋向无穷大的问题 当入射光线与平面夹角趋于0,即$\theta$趋于90度时,线段DG长度会趋于无穷大。从|DG|的公式也可以看出: $$ |DG| = \frac{frustumSize * \tan(\theta)}{shadowMapSize * 2} $$ $tan(\theta)$在角度趋于90度时,会变得无穷大。我们不能对像素应用过大的偏移,否则漏光现象会变得非常严重。因此固定尺度的depth bias在$\theta$趋于90度时,将会趋于失效。 如下图:过大的depth bias引起漏光问题 ## 2.2 Normal Bias 为了解决Depth Bias的缺陷,于是人们提出了Normal Bias。顾名思义,既然往光源方向偏移有问题,那么我就往法线方向偏移呗。 如下图所示: 将CD平面按照法线方向,平移到C'D',平移距离为G到CD的距离即|GM|。从图中可以看出,经过法线方向平移后: - C'G段的点,深度均小于|EF|,因此光亮 - D'G段的点,移动到了隔壁的像素区间,易知也是小于隔壁像素深度的,同样呈现光亮。 这样同样可以解决shadow acne。通过简单的几何知识,我们可以写出关于Normal Bias的公式,即对任意点X属于CD有: $$ \begin{aligned} NormalBias(X) &= |GM| \\ &= |GF| * \sin(\theta) \\ &= \frac{frustumSize * \sin(\theta)}{shadowMapSize * 2} \end{aligned} $$ 于Depth Bias相比,其优点在于当$\theta$趋于90度时,bias趋于`{frustumSize/(2 * shadowMapSize)}`,而不是无穷大。也即,normal bias的最大值只与shadowMap的分辨率有关。 ### 2.1.2 Normal Bias的漏光问题 Normal Bias同样会存在漏光问题,并且是两头漏光。考虑有个遮挡物如下: 该遮挡本应该在CD区域投下阴影。但由于normal bias的存在,我们在进行深度判定的时候将CD移到了C'D',因此CD平面左侧会有少部分位于遮挡物上方,从而漏光。而右侧的GD'部分,也因为进入了隔壁的像素地盘,形成了漏光。 但在实际应用中,由于Normal Bias的最大值相对可控(只要提升ShadowMap分辨率即可),因此漏光问题并不太严重。 # 3. 级联阴影对Bias的影响 根据前面推导的公式,我们已知影响最大Bias的两个因素如下: - ShadowMap的分辨率 - 平行光的入射角 级联阴影的策略是将摄像机视锥按照不同的距离区间划分成多块,每块使用不同分辨率的ShadowMap,称为一级。因此,在使用级联阴影时,如要计算场景中某个点的Bias,必须考虑到它属于哪一级别(cascadeIndex),计算出该级别的ShadowMap分辨率,然后再根据分辨率求Bias。 看下图: 在开启4级CSM的情况下,由近及远,由于每级ShadowMap分辨率不一样,因此自遮挡引起的条纹也呈现出不同的分布密度。假如我们对整个场景都使用相同的bias而没有针对CSM每个级别单独计算,那么会出现以下情况: 近处的瑕疵消失了,但远处依旧会出现。如若我们为了照顾最远级别的CSM而将Bias调的很大,那么近处的漏光现象就会变得严重。 因此自适应的Bias算法需要考虑场景像素所在的CSM级别,并按照相应的CSM分辨率进行计算。 # 4. PCF对Bias的影响 PCF会采样一个范围内的像素,因此我们必须考虑这个范围内距离光源最近的那个像素。 例如对于PCF1,它会采样ShadowMap上的4个像素,依次进行深度对比测试。 如下图: 当我们计算CF区间内的像素阴影时,除了会采样AB对应的ShadowMap像素,同时还会采样到AN对应的ShadowMap像素。 此时,取|GD|作为Depth Bias已无法满足要求。根据相似三角形原理易知,只有将Depth Bias取为2 * |GD|,才能令CF内的所有点到L的距离小于|OP|,从而修正acne。 易知,该推导可以扩展到任意采样半径R。即: $$ DepthBias = (1 + ceil(R)) * \frac{frustumSize * \tan(\theta)}{shadowMapSize * 2} $$ - ceil为向上取整函数 同样的,对于Normal Bias我们也可以推出其满足这个比例系数,即: $$ NormalBias = (1 + ceil(R)) * \frac{frustumSize * \sin(\theta)}{shadowMapSize * 2} $$ # 5. Shadow Caster Vertex Based Bias 以上我们提到的Bias,都是默认发生在Shadow Receive计算阶段,并且是Per Pixel的。这时候就有人提出,那我可不可以反过来,在Shadow Caster阶段,让遮蔽物进行反向偏移呢? 不得不说,这个逆向思维非常好。这就是Shadow Caster阶段基于顶点的Bias,我们不妨称之为SCVBB(ShadowCaster Vertex Based Bias) - 这也是URP中采用的实现方式。我们可以看下Unity文档中的说明: [Light.shadowBias](https://docs.unity3d.com/ScriptReference/Light-shadowBias.html)(即depth bias) >Shadow caster surfaces are pushed by this world-space amount away from the light, to help prevent self-shadowing ("shadow acne") artifacts. [Light.shadowNormalBias](https://docs.unity3d.com/ScriptReference/Light-shadowNormalBias.html) >Shadow caster surfaces are pushed inwards along their normals by this amount, to help prevent self-shadowing ("shadow acne") artifacts. Units of normal-based bias are expressed in terms of shadowmap texel size; typically values between 0.3-0.7 work well. 下面我们来分析一下SCVBB的优缺点。 优点自然是计算廉价,因为是基于顶点的。而缺点有两个。 其一是偏移精度的问题。我们知道,Bias的值与光线和法线夹角相关。SCVBB只考虑顶点处的法线,因此在整体偏移上可能会存在一定误差,不过这个误差在大多数情况下都还好。 另一个缺点比较严重,即Caster阶段的反向Normal Bias不完全等价于Receive阶段的Normal Bias。 考虑如下一种情形: 因为ShadowMap精度问题,AH区域会呈现出阴影(被AB区域在Caster阶段写入的深度所遮挡)。 假如我们在Receive阶段进行Normal Bias,那么AH会往法线方向平移到A'H',从而消除阴影。 但是假如在Caster阶段进行反向Normal Bias,那么AB面需要向右移动超过H的位置,才能不对AH形成遮挡。当光线入射角度与AD接近于平行时,这个移动距离将趋于无穷大,因而导致瑕疵无法再被消除。这个情况在我们使用了PCF软阴影后,将会变得更加明显。 各位不妨在URP管线中试验一下,开启软阴影,然后让平行光以接近水平的方式照射一个Cube,将会呈现出如下效果: 以上测试效果图使用的参数是: - Distance 10 - 关闭Cascades - 开启Soft Shadows - Directional Light Rotation = (2,30,0) 可以看到顶部出现了如我们预测的阴影瑕疵。这里尝试将Depth Bias和Normal Bias均调到了最大值,依旧无法消除瑕疵(瑕疵仅向内部进行了移位),并且底部出现了漏光。 我的疑问是,仅仅为了Fragment阶段省下这么一点Bias计算量,而采用Shadow Caster Bias,值得吗? # 6. 常见的计算优化 在实际的实现中,为了避免三角函数运算,通常可以使用 `1 - dot(lightDir,normal)`来近似代替tan和sin,然后暴露出两个系数$C_{depth}$ 和$C_{normal}$,给予使用者进行调整。 于是bias的公式就变成如下形式: $$ \begin{aligned} &A = (1 + ceil(R)) * \frac{frustumSize}{shadowMapSize * 2} \\ &B = 1 - dot(lightDir,normal) \\ &DepthBias = C_{depth} * A * B \\ &NormalBias = C_{normal} * A * B \end{aligned} $$ 其中A可以在CPU端完成计算,以上的实现在大多数情况下都可以取得不错的效果。