条带状阴影
不知道你曾经在Unity中遇到过阴影异常没有,比如近处马赛克状的阴影块?亦或是脱离投影物体的“悬浮”阴影?还是稍远处阴影的突然消失?多块阴影互相叠加处颜色异常的亮度等等。我感觉最让人无法直观理解,也相对常见的一种阴影异常要数“条带状阴影”了。请允许我暂时如此称呼它,因为这很形象。
复现方式
要重现“条带状阴影”其实很简单,我们甚至可以在不修改任何Unity默认设置的情况下获得这种现象。做个实验,新建一个Unity工程(2019),创建2个Quad对象,然后上下堆叠起来,设置顶层Quad的高度(Y轴世界坐标)为0.05,设置底层高度为0,调节一下主方向光,使之保持垂直朝向的姿势,既Rotation: (90, 0, 0),一切OK的话大概能得到下图所示的表现,看起来还不错,姑且称之为现象<0>。
接下来有趣的一幕要发生了,我们调节主光方向角,比如设置其Rotation中的x值为30,其他数值暂时保持为0即可,好让光线倾斜着打到图中的水平Quad上。这时我们得到了下图所示的样例。
注意我们没有调节任何Unity有关阴影生成的默认参数,只是配置了一种特殊的光照和遮挡关系而已。到此为止,我们暂且称呼这种状态下出现的“条状阴影”为现象<1>
我们保持光照和平面位置不变,接下来我们要调节2个重要参数,叫bias
和normal bias
,Unity在标准渲染管线模式下,将这2个参数暴露在了Light组件有关shadow的设置参数中;URP模式则可以在 Render Feature的设置栏中找到它们俩。总之找到并设置bias = 0
,normal bias = 0
,我们再看看有什么变化:
上图中的效果我们暂且称之为现象<2>
盘点几种现象
简单盘点下,在具有一定倾斜角度直接光照下,观察两块相聚非常近的面片中,上方面板向下方面片的投影,我们会发现“条带状阴影”;在此基础上,我们将bias
和normal bias
这两个参数归零,面片间投射的阴影恢复正常,但是面片上原本非阴影区域又出现了“条带状阴影”。
分析现象2
无论如何,在解读之前,我们都应该明确这种现象是Unity在处理阴影效果时产生的,因为我们全程只调节了2个控制阴影显示的参数而已。而对于Unity或者一般游戏引擎是如何渲染物体投影效果这一部分,考虑到网上相关内容的资料非常丰富,这里就不再详细阐述了,凝练一下,可以把通用渲染逻辑整理为3个步骤,罗列如下:
- 构建光源空间的深度图(所谓的ShadowMap);
- 将片元(视空间)或者像素(屏幕空间)空间中的坐标点转换到光源空间中,然后和(step 1)中的对应位置的深度信息做比较;
- 坐标点转换后的z轴(深度)值大于ShadowMap中对应位置深度值,则判定为处于阴影中,反之亦然。
步骤1略微展开一下,它涉及到将在光源位置放置摄像机,然后基于光源的视角生成深度图,场景中光源的阴影区也就是光源位置摄影机看不到的地方。ShadowMap本质上是一张深度图,它记录了从该光源的位置出发,能看到的场景中,距离光源最近的表面的深度值。
上图左侧为深度图。右侧在相机位置绘制场景时,能够看到场景中的点Va,其对应的ShadowMap中的位置为a,Va的深度不大于深度图中a位置存储的深度,因此Va不处于阴影
自阴影
考虑到上述采用shadowmap技术渲染阴影的技术最早可以追述到1978年,人们已经对这项技术能给图形渲染带来的利与弊有了充分的认识。回到现象<2>来,在图形学中描述这类现象自然也有其惯用的术语,叫做“Shadow Acne”或者“Self-Shadowing”。这两个英文单词,前者可以翻译为“阴影粉刺”,后者则一般翻译为“自阴影”,一个表形,一个表意,可以说描述得挺好,后续我们还会提到,如果有兴趣了解更多细节,这里有一篇对各种阴影异常表现总结得很好的技术博文可以作为参考。
在有了一定的阴影纹理算法基础,以及对所使用数据结构有一定了解的前提下,我们不妨从复盘光照模型开始研究现象<2>的成因。
(一)直角光照
上图表示的是,从垂直光照下记录的ShadowMap中获取物体表面某个像素所处的深度值。有点拗口,还是先解释下图中几个关键节点:上部每一个黄色线段代表了阴影纹理中的一个纹素(Texel),在这些纹素中存放的是光源空间中所见之物的深度信息,这个深度值在Unity引擎中,会被从原始的[0-1]区间
Float
类型编码到纹理的 RGBA
4个通道中去,每组 RGBA
色彩通道就是阴影纹理中的一个纹素。图中下方灰黑色线段被标记为“PL”,表示一个物体的平面(Plane),而2条蓝色线条中间夹着的区域,就是之前说到的光源空间中的可见区域,更加准确的说,是ShadowMap纹素(对应上方最右侧黄色线段)的可见区域。不难发现,PL上的红色斑点正好落在了区域中,如果其间别无他物可以遮挡,那么这个红色斑点就是黄色纹素在光源空间中的“所见之物”,而图中贯彻上下的红色线段表示它们之间的距离,正常情况下这个距离会被编码成深度信息,存入纹素中。
在PL上红色斑点的右侧标记了另一个黑色斑点,毫无疑问它也落在了可见区域中,而且由于光照是垂直向下的,光源到这个黑色斑点的最短距离和一旁的红色斑点应当是一致的,数值上等于图中红色连线的长度,既{Depth}。
(二)斜角光照
当倾斜光照,使之与物体平面(PL)成一定角度相交后,事情就变得有意思起来了。我们都知道,由于透视原理,摄像机近处的物体(PL)相较于远处的物体(光源)会挤占更多的屏幕空间像素,而生成阴影纹理(ShadowMap)的光源空间也有类似的现象,只不过不是因为透视投影导致的(直接光照使用的是正交投影),而是由角度和分辨率共同作用导致的:
- 角度,可以参考上图左侧的阴影纹理纹素(黄色粗线段),由于倾角的存在,原本在垂直状态下只会作用在线段CD区域,现在的实际作用范围变成了更加长的BE区间。
- 分辨率,也就是在物理上我们能为阴影纹理提供多少资源存储光源空间深度信息,理论上只要资源无穷多,我们完全可以为物体上每一个原子存一份深度值,然而现实中是不行的,一旦规定了一套分辨率,那么每一个纹素对应多少实际空间的大小就能计算出来,这个值可大可小,一般在若干米到若干厘米的数量级上。要知道,即便只有区区几厘米,一旦让摄像机聚焦在这块区域,那么整个视窗的像素就都要由一个ShadowMap的纹素提供深度距离了。
现在考察上图中黄色虚线填充的BCP三角形,在这块区域内的物体(比如顶点P1),到光源处的最短距离必然小于图中红色实线表示的深度值{Depth};同样的在黑色虚线填充的三角形PED中,所有点到光源的距离必然大于深度值{Depth}。根据光照投影算法的定义,在BCP三角形中的点P1,转换到光源空间后的深度值小于该处记录的光源深度,所以P1不在阴影中,这显然是期望的结果;然而另一侧三角形PED中的P2却在相同的算法中被分配到了阴影中,这显然是错误的结论。
(三)解决方案
通过前面的分析,我们看到,本该处于非阴影状态的三个平面PL上的点 P1、P和P2,在一定光照角度外加一定的纹理分辨率条件下,经过标准光照投影算法处理后会得出错误的结论(三角形PED区域的投影结果),而且导致投影错误的原因也以作图的方法进行的展示。为了能更直观,更量化的反应问题,我们不妨对上述图例作如下简化(图片来自网络):
在上图中,黑色粗线表示场景中的平面,黄色粗线表示为光源所对应的近平面。该近平面会对应产生的ShadowMap。AB表示为这个近平面上对应ShadowMap上一个纹素的区域。假设近平面是一个规范尺寸的正方形,且边长为FSize。对应的ShadowMap分辨率被设置为SSize。那么AB所对应的坐标尺寸通过如下计算可获得: AB = FSize / SSize
我们希望通过某些方式,让纹素AB对应区域在物体平面上的投影CD部分,能够完整的处于图中蓝色虚线的左侧。解决的方法也很朴素直观,按道理只需将ShadowMap中存放的深度值稍微增大点即可,最好是大得刚刚好,恰巧确保CD段处于非阴影区域。
->bias
这是第一种修正,针对光线的方向进行偏移:
观察线段(向量)GD,这是我们按照光线方向的最短平移距离,蓝色虚线在平移后恰好位于线段CD的右侧,从而确保了CD段在计算阴影深度时,其在光源空间的深度能够小于ShadowMap采样深度(既图中的BD线段长度)。
求解GD:
GD = FG * tanθ = (AB / 2) * tanθ
而θ角与光线和法线的夹角α又有如下的关系:α + θ = π
所以GD一般可以这样表示:GD = (AB / 2) * (-tanα)
->normalbias
除了沿着光线方向直接增加深度之外,还可以沿着法线方向作偏移,间接影响深度值,这就是我们接下来要研究的normal bias法线偏移。
法线偏移其实还分两种实现方式,一种是在将物体顶点转换到光源空间之前,先沿着法线正方向作移动,达到减小片元的深度,具体参考下图:
另一种是在生成ShadowMap时,将物体的顶点沿着法线反方向进行偏移,从而变相得增大阴影的深度值,具体参考下图:
先说第一种方式,它的作用机制不是直接修改阴影纹理的深度值,而是尝试缩小物体在光源空间的深度,因此需要在片元着色器中实现。参考图[normal 1],物体表面沿着自身法线方向移动了距离GM,获得新的位置C'G。将原本CM段的区域整体提升到了蓝色虚线的左侧。我们知道C'G区域的深度取值必然小于等于F点的深度值EF,现在会被光源正常照亮了;至于点G右侧的部分线段可以不用担心,它们将会被相邻的ShadowMap覆盖和处理。
normal bais = GM = FG * sinθ = (AB / 2) * sinα
此处的α指的还是光线方向和法线方向形成的夹角。
对于第二种方式,理解起来会直观一些,因为它直接作用于ShadowMap,修改(增加)纹素中的深度值。参考图[normal 2],修正后蓝色虚线向左下方偏移了距离FN, 从而确保CD段区域处于阴影深度线的左侧,被正常照亮。至于偏移量MN的计算,需要分为2步:
GD = FG * sinθ = (AB / 2) * sinα
normal bais = MN = GD * cosφ = (AB / 2) * sinα * cosφ
其中α指光线方向与法线的夹角;φ指光源方向与法线的夹角。
->为什么要2个bias
Unity引入2个bias变量作为控制阴影深度参数的理由,我想至少有2层。其一是增加了灵活性,让用户可以依据场景本身的特质调整光源方向或者法线方向的深度,试想如果Unity只提供了normal bias作为参数,在某些极端情况下,过大的normal bias会导致物体投射的阴影与物体本体脱离的现象(Peter Panning)。
至于另一层理由,可以参考上图,bias对于图中的tanθ曲线,normal bias对于sinθ曲线,θ是光源平面与物体平面的夹角。当光线方向与物体表面越加趋向平行时,光源平面越加垂直于物体平面,θ角越接近90度(π/2),此时使用bias计算得出的修正距离也将随着tan一起趋向无穷大(图中斜向上蓝色箭头)。若此时引入sinθ,则可以平衡bias的影响,这是因为很朴素的道理:sin函数是有上界的(图中蓝色水平箭头),我们可以在θ角小于45度(π/4)时优先使用bias的计算结果,而当θ角超过45度后,将计算偏移距离的权重倒向normal bias一边。
也许有人会问,既然bias计算出的偏移会趋于无穷大,为何不只用normal bias呢?对此之前已经有了回答:我们需要避免Peter Panning现象。
->Normal bias的2种实现比较
Normal Bias的两种移动方式,在实际效果上并不完全相同。采用在顶点着色器中改变ShadowMap深度值的方式,会在某些情况下导致Normal Bias失效。参考上图,最左侧为产生阴影的普通情况,由于整个单元内采用C点的深度进行比较,所以ED段会错误产生阴影。中间示意图为顶点着色器增加 ShadowMap 深度的做法,在这种方法中,已知从C到D的深度差距记为d,光线和AB段的法线角度为θ,则最小的消除Shadow Acne的沿法线反方向的距离计算为:
MinDistance = d / cosθ
在这种情况下,当θ趋向于90°(即光线与AD近乎平行时),最小的距离趋向于无穷大,Normal Bias就很难达到消除Acne的大小。右侧示意图为片元着色器移动片元位置的计算方法,在角度变化的情况下也不会出现最小距离趋近无穷大的情况。但在片元着色器上进行这部分计算,就比前一种方式要更多的计算量。
(一)平坦表面(软阴影)
条带状的“亮暗”渐变,在Unity2019.4.13版本下,使用标注渲染管线,开启软阴影后截屏。
(二)平坦表面(硬阴影)
“粉刺”状或者“锉刀”状的暗斑,在Unity2019.4.13版本下,使用标注渲染管线,关闭软阴影后截屏。
吐槽:曾经我也很好奇,为何自阴影的别名叫做 Shadow Acne(阴影粉刺),而不是更加常见的阴影条带。直到我关闭了软阴影并清空了bias修正后看到上图。确实这才是二维格子状纹理出问题后该有的表现,就像下图这样,每个独立的纹素所对应的区域应当被分割成了正方形(正交投影),在二维平面上遇到一定角度的入射光,形成了上图这种规则排列的“粉刺”状暗斑。
分析现象1
进过了上面的分析,我们知道现象<2>的出现本质是由于标注阴影算法的缺陷,导致物体自己产生的阴影纹理遮蔽了自己(部分),也就是所谓的“自阴影”。现在我们来看看现象<1>的成因,不妨先从2种现象在条件上的差异开始入手。
倾斜光照+层叠
当场景中存在正常产生阴影的物体时(上图蓝色矩形表示障碍物),全局添加一个Bias,在上图情况中解决了 Self Shadow 的问题,但是正常应该产生的阴影也被消除了。当Bias从小到大逐渐变化时,对一个正常产生阴影的物体来说,它的阴影表现为由靠近物体的一端开始,逐渐消失。这种现象有个专用术语,叫“漏光”(Light Bleeding)。
接下来,我们通过上图复现文章开头引入现象<1>的场景布局。还是倾斜入射,这次在物体平面(PL)的正下方多了一层新的平面(UD)。如果不对阴影深度进行修正(bias皆为0),那么来自阴影纹理中像素A投射的等深线CD会与PL平面相交于点P,此时我们在PL上能看到自阴影效果,既图中由点BCP包围的黄色三角形以及由点PDH围成的黑色三角形区域。当开启bias后,等深线CD分别沿着光线方向和物体法线的反方向做了2次偏移(图中红色箭头),并来带了线段GI所在之处。当UD平面与PL平面距离足够近时,线段GI将于UD平面相交于点P2,同时形成由点EGP2和IJP2围成了2块不同区域。此时我们说在平面UD上出现了“漏光”,既本来不该被照亮的区域(EP2)现在被错误的照亮了。
推论1
取消2个bias,有可能消除下层平面UD上投影异常效果。参考上图线段CD所代表的阴影纹理等深平面,并没有出现在平面UD上EJ段的右侧(或者说与线段EJ没有相交),可见这种情况下,UD上能正确显示来自PL的投影。
推论2
还是在取消bias前提下,当我们进一步缩小2个平面之间的距离,可以再次复现异常,此时上下层平面可以近似认为是一个平面,下层平面UD会产生源自PL的自阴影。
解决方案
现象<1>成因可以说是Unity为了解决现象<2>,在引入bias后间接导致的。下面是我总结的一些有助于减缓现象<1>的办法,和大家一起分享,当然问题的解决方案是开放的,如果大家有其他好方案,希望不要吝啬得分享出来。
(一)增加等效分辨率
- 首先如果条件允许,不妨直接增加ShadowMap的分辨率,或者减小ShadowDistance的距离;
- 其次可以考虑使用Unity Lighting提供的烘焙光照(阴影),在中远距离处使用烘焙阴影,当切换到近处时再使用实时阴影,这也变相增加阴影纹理的分辨率。
- 最后是考虑更高级别的级联阴影(CSM),并将更多资源分配给近处的阴影纹理,同样也能提高近处的阴影质量,减缓 现象<1>
(二)避免倾斜角入射
从现象<1>的成因分析,它只会出现在光照方向与物体表面成较小夹角时,我们可以通过合理的布置场景中会导致此类现象的物件位置,优化它们的迎光角度,从而避免出现透光现象。
(三)使用投影代理产生投影
如果我们对出现现象<1>的问题物体投影质量要求不高,完全可以取消该物体的投影选项(不参与阴影纹理的生成,但是可以被动接受阴影),然后使用结构相对简单的投影代理来为该物体实际生成阴影。简言之,我们解决不了问题,那就解决产生问题的物体就好。
(四)追加一次法线偏移
如上图中绿色箭头,我们需要通过某种方法,在片原阶段,将平面UD沿着其法线方向的反方向做一次小偏移,使之远离被下压过来的等深线段GI。这里面有个难点,我们如何判断一个像素需要做额外偏移?或者说我们该如何知道物体平面UD需要做额外偏移,而不是物体平面PL呢?一种最简单的方法是把问题丢给CPU,在预处理阶段对不同顶点进行标记,运行时带入到GPU中参与计算。