【Reference】:https://zhuanlan.zhihu.com/p/27572129
一:平面阴影(Planar Shadow)#

通过联立投影点在光线与平面的两个方程,可以求解出投影点的位置。
直线方程:
1p = l + t * (v - l)
平面方程:
1n * p + d = 0
其中,p 是投影点,l 是光源位置,v 是投影物体表面任意一点,n 是法线,d 是平面与原点的距离,t 是比例系数。
但是实际场景中并不会每次都联立两个方程求解,而是将整个阴影投射计算写成一个平面阴影矩阵。之后使用的时候,只需要将投影点乘上对应的平面阴影矩阵,即可得到投影在对应平面上的点的位置。
具体推导请见:Shadow Map Derivation
注意:平面阴影技术并不是将被投影平面上对应的点变黑,而是额外生成了一个投影几何体。
实现过程中可能出现的问题:
1. 半透明叠加#
产生原因:在实际渲染中,为了模拟全局光照的贡献,阴影并不会被设置为全黑,而是带有一定透明度的灰色。此时当三角面被投影至平面时,有可能会产生重叠,导致同一处阴影由于 alpha 值叠加变为黑色。

解决方法是模板测试:先绘制投影平面,并开启模板测试。模板缓冲可以设置为 1,测试函数设为 EQUAL。当模板测试通过一次后,模板值加 1,保证每个点只会执行一次阴影绘制。
2. Z-Fighting#
本质:深度缓冲精度不足导致的冲突。当两个面距离过近,甚至完全重合时,由于浮点数的精度误差,Z-Test 无法分辨哪一个平面在上,于是就出现了两个平面交替渲染的错误,表现在屏幕上就是“闪烁”“撕裂”的杂乱纹理。并且由于 Z 值的非线性,越远处的物体之间的深度值差距越小,因此也就更加容易出现 Z-Fighting。

两种解决方法:
- 先绘制平面,然后关闭深度测试与深度写入,进行平面阴影的绘制,最后再开启深度测试与写入,执行被投影模型的绘制。
- 添加一个小小的 bias:在计算中稍微加大或减少一点
d的值,具体取决于法线方向,让阴影与平面之间产生一点距离,也就是略高于平面。
二:阴影体(Shadow Volume)#
对于每一个受光照的物体,其自身会含有一个两端开口的棱台状的阴影体。位于阴影体中的点,就是需要绘制阴影的地方。
此时问题变成了:如何知道一个点是否位于阴影体之中?
解决方法是模板测试:对于从摄影机向屏幕上任意一点发出的视线,如果与阴影体正面相交,说明进入了阴影体,模板值加一;如果与阴影体背面相交,说明离开了阴影体,模板值减一;如果最终模板值不为零,也就是大于零,说明射线在此处受到了阻挡,只能进去但是出不来,因此可以判断此点一定位于阴影中。

具体实现:
- 首先关闭 Stencil Write、Stencil Test,渲染一遍不带阴影的场景,只写入深度与颜色。
- 关闭 Z Write,Z Test 保持为
Less Equal,将 Stencil Write 设置为 1,Stencil Test 设置为Always,绘制阴影体正面。 - Stencil Write 设置为 -1,绘制阴影体背面。
- 关闭 Stencil Write,Stencil Test 和 Z Test 设置为
Equal,对 stencil 为 0 的点进行阴影绘制。
以上操作都是基于“摄影机在阴影体外部”的假设进行的。如果摄影机一开始就在阴影体中,会导致明暗关系完全反转,此时需要使用 Z-Fail 来进行阴影绘制:
- 逻辑:之前的操作是从摄影机往外看,而 Z-Fail 的操作完全相反,它是从无限远处往摄影机回看。
- 操作:未通过深度测试时,如果遇到背面,Stencil++;如果遇到正面,Stencil–。
- 代价:需要保证阴影体完全闭合。之前的阴影体没有上盖和下盖,因此这会产生额外的几何处理开销。
缺点:大量物体重叠时会导致 Overdraw;不支持软阴影;Caster 必须是完全闭合的几何体;CPU / 几何管线开销过大。
三:阴影映射(Shadow Mapping)#
Shadow Mapping 是目前应用最多的阴影算法,因为它简单直观,并且与光栅化流程极度吻合。
核心思想:一个点能否被照亮,取决于从光源的视角看过去,这个点是否可见。
实现步骤:
- 光源视角:将摄影机放置在光源的位置上。平行光用正交投影,点光 / 聚光用透视投影,对场景进行绘制,生成一张深度图。
- 摄影机视角:再次绘制场景,得到当前投影空间的深度值
d,并使用光源投影矩阵的逆矩阵得到对应点在光源投影空间中的 uv 坐标。 - 深度值比较:利用之前得到的光源投影空间坐标,在光源视角深度图中查找对应深度值
z。如果d < z,说明当前点摄影机看得到,但是光源看不到,也就说明当前点被遮挡,处于阴影中。
可能出现的问题:
- Shadow Acne / 自阴影:由于 Shadow Map 的精度问题,在倾斜的表面上进行深度比较时,可能会有一部分片段的深度错误地大于记录的深度,出现“自己遮挡自己”的现象。解决方法是深度偏移:为采样得到的深度添加一个小小的偏移,也就是
d + bias。 - 彼得潘现象(Peter-Panning):当 bias 的值过大时,物体根部会与阴影发生断裂,这就是所谓的 Peter-Panning。一种解决方法是基于法线的偏移:根据法线与光线的点积,动态调整 bias 的大小,光线越平行于表面,与法线的点积越小,bias 越大;还有一种解决方案是正面剔除:在 Pass 1 中生成深度图时开启 Front Cull,强制渲染背面的深度;而在 Pass 2 中比较深度值时,还是使用正面的深度,前提是物体必须是闭合的、有厚度的。

四:SDF 阴影(Signed Distance Field Shadows)#
SDF(有向距离场):一种用空间体积数据表示几何体的方法。在这个场中,空间里的每一个点都记录着一个浮点数,代表该点与最近的物体表面的最短距离。
- 值为正:该点在几何体外部。
- 值为 0:该点正好贴在几何体表面上。
- 值为负:该点在几何体内部。
在 SDF 中计算阴影使用到的是 Ray Marching(射线步进),更具体的说法是 Sphere Tracing(球体追踪)。

具体实现:
- 从 Shading Point 出发,向光源方向发射一条阴影射线,开始步进。
- 首先在出发点对 3D 纹理进行一次查询,得到一个距离值
D。D的含义是:在以当前点为圆心、D为半径的圆内,不存在任何遮挡物。因此,可以大胆地沿着射线向前步进D的距离。 - 重复上述检测,直到发生以下情况之一:
- 抵达光源 / 超出最大距离:说明光线一路畅通无阻,该点不在阴影中。
- 查询到的 SDF 距离极小,小于设定的 epsilon 阈值:说明光线撞到了表面,该点在阴影中。
SDF 阴影不仅能够避免复杂的光线与物体求交运算,还可以在单次步进中就得到软阴影的效果。我们可以利用“光线与障碍物的最小靠近程度”来估算半影区(Penumbra)的可见度,其估算公式为:
1res = min(res, k * D / t)
- D:当前步进点查询到的最近表面距离,也就是 SDF 值。
- t:光线从起点出发已经走过的总距离。
- k:控制光源大小,也就是阴影软硬程度的常数参数。
缺点:
- 对动态物体支持极差:因为 SDF 通常是预计算得到的。
- 显存与精度问题:高精度的 3D 有向距离场非常吃显存,因此通常不会使用精度太高的 SDF。
五:光线追踪阴影(Ray-Traced Shadows)#
光线追踪阴影是所有阴影技术中最“物理正确”的一个,毕竟要用于离线渲染。
光线追踪阴影的本质是:可见性测试。
对于直接光照来说,从像素点向光源发出一条射线,如果能够直接击中光源,说明此点未被任何物体遮挡;反之,如果打中了某个物体,说明这一点并不能被光源直射到。
为什么没有考虑多次弹射后打到光源的情况?
在实际应用中,渲染方程往往被拆成 Emitted Light + Direct Light + Indirect Light 三项来计算。光线弹射的情况属于 GI 的部分,也就是第三项 Indirect Light,而 Ray-Traced Shadows 属于第二项,也就是直接光照部分 Direct Light。这样的拆分在工程上是合理的,因为可以节约算力。
光线追踪阴影同样完美支持软阴影:
- 我们从着色点向面光源的表面随机发射多条射线,也就是蒙特卡洛积分。
- 统计这些射线的“存活率”。
- 如果 100% 的射线被挡住,这里就是本影(Umbra),绝对黑暗。
- 如果只有 40% 的射线被挡住,这里就是半影(Penumbra),呈现柔和的灰度。
由于遵循严格的几何关系,光追软阴影会自动呈现出“距离遮挡物越近,阴影越锐利;距离越远,阴影越模糊”的渐变。
六:光照贴图(Light Map)#
以上说到的五种计算阴影的方法都是在运行期进行的,是动态的阴影;但是对于一些静态的物体(e.g. 房子、山体….),他们的阴影完全不会变化,为什么不提前进行计算呢?
Light Map 方法指的就是,在游戏发行前,通过离线的路径追踪算法,提前渲染出极高质量的阴影与间接光,将其存成一张图片,在游戏运行时,只需要进行一次极低成本的纹理采样即可
缺点也很明显:场景中的物体一定不能产生任何变化,否则就会出现“只有阴影,没有投影体”的情况