原文:https://catlikecoding.com/unity/tutorials/scriptable-render-pipeline/directional-shadows/
- 支持多个方向光阴影
- 控制阴影距离
- 定义独立的主光源
- 渲染和采样级联阴影(cascaded shadow map)
- 使用球形剔除
1. Shadows for Directional Lights
直射光和聚光源在概念上没有什么本质不同。除了直射光来自无限远的地方,两者基本相同。所以只需要一些调整,就可以让聚光源阴影的方法适用于直射光。我们将进一步改进我们的渲染管线,让它能混合直射光和聚光源的阴影。
1.1 Configuring Shadows
目前ConfigureLights中只处理了聚光源的阴影数据。而处理直射光源阴影的代码和聚光源的是相同的。为了重复利用这些代码,我们对代码进行一些重构,把它们写成单独的方法。
与聚光灯相比,渲染方向光阴影贴图时有些不同,因此需要在处理时指明它是直射光。我们用阴影数据的z分量来作为判断的标识
1.2 Rendering Shadows
对于方向光,我们需要用ComputeDirectionalShadowMatricesAndCullingPrimitives函数来获得裁剪信息,然而聚光源是用ComputeSpotShadowMatricesAndCullingPrimitives函数。重构部分代码,声明一个bool变量,用该变量保存是否有有效的方向光或者聚光源。
如果阴影数据表明我们处理的是方向光,就调用ComputeDirectionalShadowMatricesAndCullingPrimitives
。这个方法有更多的参数,因为它支持阴影级联(这里我们先不用)。第一个参数要求提供一个光源序列,接着是级联的序列以及级联的数量。我们现在用不到级联,所以序列设为0,数量设为1。之后是定义了级联分级的三维向量,我们使用(1,0,0)。然后是整型的图块尺寸和阴影近平面值。最后是投影矩阵等输出参数。
splitData数据中包含了一个有效的剔除球体。该球体包裹了所有需要被渲染进直射光阴影贴图的物体。这对于直射光来说非常重要,因为直射光不像聚光源,他会影响所有物体,我们需要有一个剔除球体来限制渲染进阴影贴图的图形数量。对于聚广源该设置没有效果,没有什么影响。
1.3 Shadow Distance
此时我们应该可以得到方向光的阴影贴图了。但是看起来我们得到的贴图是空的,即使有也可能只是几个小点。造成这个现象的原因是这个贴图需要涵盖的范围太广了,覆盖了摄像机能够看到的所有东西,默认是1000单位,由相机的远平面控制。想看到贴图的内容就要大幅降低相机远平面距离。
但阴影的渲染范围并不应该取决于相机远平面,这只是默认的情况。阴影距离也能控制阴影的渲染范围。阴影距离通常远小于相机的远平面。两者综合考虑既可以限制阴影渲染的范围,又可以处理比阴影距离更小范围的情况。
在MyPipeline中为阴影距离添加一个字段,并在构造方法中设置。我们从相机中提取阴影距离并赋值给剔除参数。因为渲染相机可见范围外的阴影没有意义,所以使用阴影距离和相机远平面中的最小值更合理。
在MyPipelineAsset中为阴影距离添加一个配置选项,并设置一个合理的默认值,比如100。
1.4 Investigating Shadows
阴影距离降低到合理的值,直射光的阴影终于出现了。我们将光源的阴影偏移设为0并使用一个更大的平面作为地面。使用单个直射光照射。
因为偏移设为了0,我们可以大致看到阴影贴图覆盖的区域。和聚光源的阴影不同,方向光的阴影贴图随相机移动而改变。另外,阴影贴图的边缘也会影响超出阴影范围的场景物体,这是因为我们采样坐标会超出贴图边缘(结果会采样边缘的值,因为我们纹理设置的是clamp),这导致贴图的边缘被拉伸至无穷远。当我们的阴影光源多于一个时,拉伸消失了,因为我们为每个图块使用scissoring清理了边缘。
然而当采样多个超出范围的tiles时我们会得到错误的结果,tiles越多,结果越糟糕。
1.5 Clamping to Shadow Tile
方向光阴影贴图有些麻烦是因为当它们被采样时,无论物体是否在阴影贴图的覆盖范围内都会进行采样。解决方法就是限制阴影采样坐标在tile内。我们在缩放到正确的图块前将阴影空间位置限制在0-1之间。之前没有做限制我们可以在MyPipeline里完成整个转换矩阵的计算,现在我们不得不把这一步移到shader里了。
tile scale信息对于变换时是必须的,我们将其传入shader。声明一个全局的vector用于保存阴影信息,将其命名为_GlobalShadowData 并持有它的标识符。
offset信息也是必需的,我们将其存储在ZW分量中。
之后移除对图块矩阵的乘法计算并在shader中在阴影缓存区添加一个全局阴影向量
在ShadowAttenuation函数
中,在透视除法后对阴影位置的xy坐标做限制,将其限制在0-1范围内,之后再应用图块的坐标变换。
直射光阴影需要透视除法吗?
不需要,因为直射光阴影贴图用的是正交投影。阴影位置的w向量恒为1,但是我们要混合直射光和聚光源,所以我们统一执行透视除法。
1.6 Always Use Scissors
我们解决了在多个图块时的阴影杂乱( shadow soup)问题,但是当场景中仅有一个方向光时阴影贴图的边缘仍会被拉伸。我们在RenderShadows
中设置成无论几个光源都使用裁剪来解决这个问题。
1.7 Clipping Shadows Based on Distance
尽管阴影的距离是根据相机视角的距离,但是阴影并不是在当物体离开这个范围后立刻消失。这是因为阴影贴图覆盖了一个立方体的空间区域,只要这个区域的一部分在范围内,就会整个都渲染。方向光随着相机的移动阴影贴图也会重新渲染,所以对于这一问题方向光很合适,但是聚光源就不一样了,它的阴影空间区域和光源锁定,即使阴影距离只占空间的很小一部分,最终渲染的还是整个区域。结果就是聚光灯阴影贴图包含的所有阴影同时出现和消失。
我们可以使用配置的阴影距离来剪切阴影使阴影消失的边界线更统一。要想这么做,就得把阴影距离传给shader。我们将它放在全局阴影数据向量的第二个分量中。在我们实际裁剪时,我们用的是它的平方来进行比较,所以我们就直接存储阴影距离的平方。
在shader中,我们还需知道相机的位置。Unity在配置相机的时候就会自动提供这个信息。所以我们要做的只是在UnityPerCamera
缓存区中添加一个_WorldSpaceCameraPos
变量。
创建一个DistanceToCameraSqr函数,该函数输入世界位置,输出与相机的平方距离。
在ShadowAttenuation
中调用这个方法,检查是否超出了阴影距离,如果是就跳过阴影采样。
现在所有的阴影都在相同的距离消失,而不会突然的出现和消失了。
我们可以平滑的过渡阴影吗?
你可以添加一个渐变距离并使用一些过渡函数来实现,如线性插值,smoothstep等。
2. Cascaded Shadow Map
阴影贴图的缺点就是作为纹理,其分辨率必然是有限的。虽然你可以提高纹理分辨率来获得更好的效果,但仍没有摆脱这个限制。聚光源只覆盖了一小块区域,所以它的效果可以接受。但对于方向光,它的照射范围是无限大的。在视野远处的阴影效果也许还可以接受,但是近处的阴影却会显得非常块状。我们称之为透视锯齿(perspective aliasing)
我们需要给近处的阴影提供更高的分辨率,远处的可以分辨率低一些。我们可以根据距离使用不同的分辨率,解决方案就是为同一个光源渲染多张阴影贴图。我们在近处使用高分辨率阴影贴图,在远处使用低分辨率。这些阴影贴图称之为级联阴影(shadow cascade)
2.1 Cascade Amount
Unity通常为级联阴影数量提供三个选项:0、2、4。我们也一样,在MyPipelineAsset添加一个ShadowCascades枚举用于配置数量,默认为4。
2.2 Cascade Split
Untiy还允许指定级联在阴影距离中的分布情况。通过将整个阴影距离划分成二或四个部分来实现。如果是2个级联,就用一个值来决定在哪里划分两者。如果是四个级联,就用存储在向量中的三个值,将阴影距离划分成四个部分。我们使用与轻量级渲染管线相同的默认值。
但是Unity不会直接将这些值暴露在检视面板,而是显示一个特殊的GUI控件来允许你调整级联的区域。我们也来实现这种效果,先把这些属性隐藏起来。
我们需要创建一个自定义编辑器来显示联级划分的GUI,我们先创建一个最基础的。将它的脚本资源放在Editor文件夹中。获取三个相关属性,并绘制默认的检视器。我们还需要使用UnityEditor.Experimental.Rendering命名空间
在绘制默认检视器之后使用switch语句来决定我们绘制哪种级联的GUI。使用CoreEditorUtils.DrawCascadeSplitGUI
函数去绘制,之后调用序列化对象的ApplyModifiedProperties
方法来确保用户的修改可以应用到我们的资源中。
MyPipeline只需要知道要使用多少级联以及他们的分布值是多少。我们可以使用单个三维向量同时处理二段和四段级联的分布数据。按要求添加字段和构造参数。
当MyPipelineAsset调用渲染管线的构造方法是,总是要求传入一个分布情况向量,即使实际是二段级联。这这种情况下,我们将唯一的分布值作为向量的第一个分量,另外两个设为0。
2.3 Cascades for Main Directional Light Only
我们不为所有的方向光都提供级联阴影功能,因为渲染多个阴影贴图性能消耗很大。我们将最明亮最重要的一个方向光源作为主光源,为其提供级联阴影,其他方向光只提供单阴影贴图。
主光源总是可见光列表中的第一个元素。我们可以在ConfigureLights
中判断第一个光源是否符合标准,如果是方向光、阴影强度为正数,并且开启了阴影级联,那就说明是有效的主光源。我们用一个bool字段来记录这个情况。
我们会为主光源提供单独的渲染贴图,所以当我们拥有主光源时,让图块计数减1,并且在RenderShadows函数中将其从常规阴影贴图渲染中排除。
将级联阴影以tiles的形式渲染到一张单独的阴影贴图中,将其命名为_CascadedShadowMap。添加相关的标识符和字段。并在最后和其他阴影贴图一样释放纹理资源。
2.4 Reusing Code
渲染级联阴影和之前我们做的阴影渲染很相似,但是其中的差异还是有必要用一个单独的方法才能完成。然而这两个方法里许多代码都是重复的,我们把这部分代码重构成单独的方法。
首先是关于阴影渲染目标的设置,两者在这部分的代码是相同的。我们只需要用两个字段记录两者渲染目标对应的渲染纹理即可。
然后是设置阴影tiles。计算tiles偏移,设置视口以及剪裁,偏移值可以用二维向量返回值得到。
再然后,计算world-to-shadow矩阵这部分也可以放在一个单独的方法里,我们将视角和投影矩阵作为引用类型的参数传入,这样可以避免不必要的拷贝变量。同样,将world-to-shadow矩阵作为输出参数。
最后,调整RenderShadows函数使其使用重构后的函数。
2.5 Rendering Cascades
级联阴影的world-to-shadow矩阵需要单独存储在数组中,添加对应的字段,因为我们级联数量最多为4,所以数组大小设置为4。
创建一个RenderCascadedShadows
方法,首先复制RenderShadows
的代码。接下来就简单了,我们不需要考虑聚光源并且只会用到第一个光源。我们不需要处理每个光源的阴影数据,而且阴影设置肯定是开启级联的。级联不是四段就是两段,也就是说阴影贴图总是分为四个tile。
在调用ComputeDirectionalShadowMatricesAndCullingPrimitives时,我们光源序列为0,并使用for循环的迭代值作为级联序列。在这里我们就需要提供实际的级联数量和分布向量了。最后,我们再把图块的坐标转换附加到world-to-shadow中,在tile界限范围内渲染阴影是之后shader能正确采样到级联阴影的重要前提。
在ConfigureLights后,如果有主光源调用该函数
现在我们最终可能有0张,一张或者两张渲染纹理。如果只有主光源,只需要渲染级联阴影贴图。如果有另外带阴影的光源还需要渲染常规的阴影贴图。或者我们有阴影但没有主光源,那我们就只需要常规阴影贴图。如果你在frame debugger中检查级联阴影贴图,你会看到它由四个图块组成。它们内容是否可见取决于阴影距离和级联分布。
2.6 Sampling the Cascaded Shadow Map
在shader里使用级联阴影贴图,需要做一些事。首先,我们得知道使用软阴影还是硬阴影的方式采样贴图,这一点我们可以通过shader关键字来控制。我们使用两个关键字来区分级联的软硬阴影,省去了在shader中创建分支的需要。
接下来我们需要知道阴影贴图的尺寸和阴影强度,虽然我们可以直接用_ShadowMapSize但是为了让shader能分开处理两者的大小,我们使用单独的_CascadedShadowMapSize 来表示。
在RenderCascadedShadows函数末尾设置这些值和关键字。
同样也需要在RenderCascadedShadow
没有被调用的情况下关闭级联阴影的关键字
在Lit shader中为级联阴影关键字添加多重编译指令。共有三个选项:无/级联软阴影/级联硬阴影。
之后向shadow buffer中添加所需的变量,定义级联阴影贴图纹理和采样器
添加一个表明是否采样级联阴影的bool参数来使HardShadowAttenuation
既可以用于常规的阴影采样也可以用于级联阴影,参数默认为false。用这个bool值来决定具体使用哪张纹理和采样器。我们使用的bool参数是硬编码的,也就是说在实际编译时并不会产生条件分支。
SoftShadowAttenuation
也一样,不过在这里只需要选择正确的纹理就好了,其余的由HardShadowAttenuation
函数完成,没必要再写一遍。
创建一个CascadedShadowAttenuation
方法,他就像ShadowAttenuation
的简化版。如果没有级联阴影,衰减直接设为1,反之才会计算阴影位置获取软硬阴影的衰减值并应用应用强度。
选择正确的级联阴影贴图是下一节介绍的内容,这一节我们先硬编码统一使用第三张级联阴影贴图,也就是使用_WorldToShadowCascadeMatrices
中序列为2的转换矩阵。如果你用的是四段级联,使用第三张贴图可以让我们看到大部分区域的阴影。如果用第四张的话区域是大了,但是近处的阴影分辨率太低,影响观察。
接下来,创建一个MainLight函数来计算主光源,它和DiffuseLight方法做的事一样,但是限制了只计算索引为0的方向光,并且使用CascadedShadowAttenuation
来获取阴影
如果有级联阴影,就把主光源也加入LitPassFragment
计算漫反射总和中。
主光源的级联阴影现在终于能够看到了,但是主光源在光照循环中被计算了两次,这是错误的。我们不能简单地跳过循环中的第一个光源,因为对每个物体而言,无法确保主光源就是最重要的那个光源。对此我们要么在shader的循环中添加分支,要么渲染前就干脆将主光源移出可见光列表。我们选择后者,修改中ConfigureLight
的光源数量。这样的副作用就是当我们有主光源时,像素光数量上限变成了5个。
从可见光列表中移除主光源的问题是如果我们使用了级联阴影,每一帧都会修改可见光列表,从而导致临时内存的分配。现在也没什么好办法,除非以后会出一个不会分配新数组的GetLightIndexMap
方法。
2.7 Selecting the Correct Cascade
现在主光源的级联阴影贴图终于能用了,但用的都是同一级别的级联贴图。第三张级联贴图对于远处的阴影效果挺好,但是对于近处效果就很差。而第二张级联贴图恰恰相反,近处表现的很好,但是范围实在太小了,远处根本没阴影。
Unity使用级联分段值划分每个级联贴图负责的阴影空间区域。它使用一个剔除球体来定义每个级联贴图的范围。剔除球的半径依次增加,球的位置也同样。想知道我们使用哪一等级的级联贴图,我们就得找出片元处于哪个剔除球内部。
我们要把剔除球的信息传给shader。使用数组是最方便的方法。在MyPipeline中添加对应的标识符和字段。用四维向量表示每个球。xyz分量描述球的位置。w分量定义球的半径。
在RenderCascadedShadows函数
里我们可以获取每个级联的剔除球。我们只需要简单的把它拷贝到我们的数组然后再传给shader就ok了。因为在判断片元位于哪个剔除球时只会用到半径的平方,所以我们传入半径的平方来减少shader的运算。
shader中,将剔除球数组变量添加到阴影缓冲区
创建一个很便捷的方法,用于判断一个点是否在剔除球体内。
在CascadedShadowAttenuation里为四个剔除球各调用一次这个方法。返回1表示该点位于剔除球内,返回0就在球外面。返回值就是表示这些球是否有效的标志。在确定级联等级前将这四个值放在一个flaot4类型变量中。
一点位于一个球的同时,还躺在更大的球里面。我们最终可能得到五种情况: (1,1,1,1), (0,1,1,1), (0,0,1,1), (0,0,0,1),(0,0,0,0)。我们将这四个值加起来除以四得到的值返回来观察级联层次。也就是点乘¼。
我们使用第一张符合要求的贴图(所渲染的点所在范围最小的级联贴图),也就是说我们需要把其对应标志位后边的标志值清零。
一个点至少在一个剔除球里面时,结果是没问题的,但点如果在所有剔除球外面,结果为0,会错误的采样了第一张级联阴影贴图。Unity在这里用了一个小技巧,它在world-to-shadow中添加一个零矩阵作为第五个数组元素来表示不存在的那个联级贴图。零矩阵会将阴影位置转换到近平面,自然就不可能产生阴影了。我们也这样做,为MyPipeline的worldToShadowCascadeMatrices
数组添加第五个元素。
然而,如果z缓冲区反转,那我们就得将阴影空间的z坐标设为1才能表示近平面,我们在构造函数把这个矩阵的m33字段改为1即可。
增加shader中对应数组的长度,并完成(0,0,0,0) → 4的转换,我们该为将值和(4,3,2,1)点乘,让4减去它来得到级联等级
我们可以混合级联贴图吗?
和Unity的渲染管线一样,我们直接选择一个级联贴图采样。结果可能在每个级联之间会有不连续的图像。也就是阴影的像素突然发生变化。你也可以定义一个过渡区域,并在其中对两个相邻的级联贴图插值。这要求我们寻找两个级联贴图的序列,一个混合因子,以及双倍的阴影采样。
因为剔除球不会与相机和阴影距离对齐,所以级联阴影不会和其他阴影一样在同一距离消失。我们也一样可以在CascadedShadowAttenuation
中检查阴影距离来实现统一的效果。
Unity采样级联阴影贴图时,不是应该用一个屏幕空间的pass吗?
没错,Unity使用一个单独的屏幕空间pass,将级联阴影渲染到另一张纹理中去。这其实和我们做的一样,只不过它会有一个整体的显示。以便在forward pass的每个片元中采样其中的阴影数据。屏幕空间pass会比较迂回地完成这些工作,而逐片元的计算方式则更为直白简单,这也是我为什么会选择这种方法作为教程教学。
使用单独的全屏pass的一个原因是可以更快的采样阴影。在有大量重复绘制的情况下,效果会更好,因为此时可能会有多个片元对同一个位置采样。通过增加一个仅深度的pass存入深度缓存,来消除不透明物体的重复绘制以减少计算量。屏幕空间阴影的方法总是需要一个depth-only 的pass来提取片元的深度值。
另一个原因则是因为Unity的旧版渲染管线可以用它展现高质量的软阴影滤波结果。但是在轻量级渲染管线里就用不到了,他对于所有的阴影采样都使用相同的代码。
哪个方法最好呢?你可以自己测试这三种情况:逐片元,depth-only 的逐片元,以及depth-only 的屏幕空间