英文原文:https://catlikecoding.com/unity/tutorials/scriptable-render-pipeline/spotlight-shadows/
- 渲染并且读取纹理
- 从光空间(光源角度)渲染
- 为阴影投射(shadow casters)添加一个着色器pass
- 采样阴影贴图
- 支持软阴影和硬阴影混合
- 在单个图集中组合存储最多16个阴影贴图。
这是Unity可编程渲染管线教程的第四篇。在这篇里我们让聚光灯能投射阴影,并最多同时支持16个光源的阴影。该教程基于Unity2018.3.0.f2。
1. A Spotlight With Shadows
阴影非常重要,它不仅可以提升真实感,还可以让物体之间的空间层次关系更加明显。没有阴影,我们就很难分辨一个物体究竟是漂浮在表面上还是和表面相接触。
在这一章,我们只完成聚光灯的阴影,毕竟它是最简单的。我们先从支持单光源阴影开始。我们需要创建一个场景,其中包含一个聚光灯以及一些游戏物体。一个平面用于接受阴影。所有的物体都使用我们之前自己创建的Lit Opaque材质。
1.1 Shadow map
阴影的渲染有很多种方法,比如体阴影等,这里我们使用传统的阴影贴图方法(shadow map)。这意味着我们的需要从光源方向渲染场景,但我们只需渲染深度信息。深度会告诉我们光线在碰撞到物体前走了多久,在这距离之后的物体则处于阴影之中。
首先我们需要创建一个阴影贴图,相机将会将内容渲染只该贴图,为了以后能对阴影贴图采样,我们需要用一个独立的渲染纹理来存储渲染结果而不是帧缓冲中,在MyPipeline中添加一个RenderTextrue的字段来存储与阴影贴图的引用。
新建一个独立的函数来渲染阴影,用context作为参数。首先要获取一个渲染纹理。我们通过调用 RenderTexture.GetTemporary
来实现。如果有还未被清理的闲置纹理,该方法则会拿来它重复利用,不然就创建一个新纹理。因为我们的阴贴图几乎在每一帧都会用到,所以我们可以一直重复使用同一纹理。RenderTexture.GetTemporary
方法要求提供贴图的宽高,深度通道的存储位数以及纹理格式等参数。我们使用固定的512x512大小,并使用16位的深度通道提高精度。我们创建的是深度纹理,格式是RenderTextureFormat.Shadowmap
。
过滤模式设为双线性,纹理环绕模式设为Clamp
阴影贴图的渲染应当先于常规场景的渲染。因此在Render方法中,我们在配置常规相机操作前,剔除操作之后调用RenderShadows方法。
在我们传递上下文完成渲染后,要释放渲染纹理。将shadow map传给 RenderTexture.ReleaseTemporary
就可以释放纹理,同时清空引用。
1.2 Shadow Command Buffer
我们使用单独的command buffer完成阴影相关的工作,这样在frame debugger中我们就可以看到阴影渲染和常规渲染被分成了两个部分。
影的渲染应当放在BeginSample
和EndSample
之间
1.3 Setting the Render Target
在渲染阴影前,应当需要先让GPU渲染信息到阴影贴图中。我们可以调用CoreUtils.SetRenderTarget
来实现这一点,传入我们的command buffer以及shadow map作为参数即可。这个方法一开始会清理贴图,所以在BeginSample
之前调用,来避免frame debugger里出现额外一层Render Shadows嵌套。
我们只关注深度通道,为SetRenderTarget
添加第三个参数 ClearFlag.Depth
来指明这一点。
虽然不是必须的,但我们可以对纹理的加载和储存设置更加精确的需求。因为我们会清理这个纹理,所以我们并不关注它来自哪,可以用RenderBufferLoadAction.DontCare
来指明这一点,这将使得tile-based的GPU会有更高的执行效率。因为我们随后需要采样该纹理,所以需要将其存储在内存中,通过RenderBufferStoreAction.Store
指明这一点。
我们阴影贴图的清除操作现在能在frame debugger里看到了,位于常规渲染之前。
1.4 Configuring the View and Projection Matrices
我们从光源的视角渲染场景,就犹如我们将聚光灯看做是一个摄像机一样。因此,我们需要提供适当的视角投影矩阵。我们可以通过剔除结果中的 ComputeSpotShadowMatricesAndCullingPrimitives
方法得到该矩阵。该方法的第一个参数是光源序列,因为我们只有一个光源,所以就是0。视野矩阵和投影矩阵则是在后两个输出参数中。最后一个参数ShadowSplitData
我们用不到,但作为输出参数,我们必须提供。
当我们获得了该矩阵,调用阴影命令缓冲区的SetViewProjectionMatrices
方法,然后执行command buffer并清理。
1.5 Rendering Shadow Casters
有了正确的矩阵信息,我们现在可以渲染所有投射阴影的物体了。我们通过调用DrawShadows方法来实现。这个方法需要一个 DrawShadowsSettings
类型的引用参数。我们用剔除结果和光源索引作为参数来创建一个该实例。
只有我们把聚光灯的Shadow Type类型设为hard或者soft才有用。如果我们设为none,Unity会说这不是一个有效的投射阴影的光源。
2. Shadow Caster Pass
此时所有受光源影响的物体都应该渲染进阴影贴图中,但是frame debugger告诉我们这并没有发生。因为 DrawShadows
函数会使用着色器的ShadowCaster pass,但是目前我们的着色器并没有这个pass。
2.1 Shadow Include File
为了创建一个shadow-caster pass,我们复制Lit.hlsl文件并重命名为ShadowCaster.hlsl。我们只需要深度信息,所以移除所有和片元位置无关的东西。片元程序简单的输出0。重命名相应的方法以及导入guard define。
现在足够渲染阴影了,但是有可能会出现阴影投射物和近平面相交的情况,这时候就会导致阴影中有漏洞(想象一下,本应该在前面产生遮挡的部分,因为没在近平面范围内而被舍弃)为了避免这种情况,我们在顶点函数中,限制顶点不超出近平面。我们可以通过取裁减空间位置z,w分量中较大者的来完成这一操作。(为什么比较z和w就可以?这牵扯到比较深入的投影矩阵知识,请参考该文列出的几个文章https://blog.csdn.net/yinfourever/article/details/96481332,简单科普下基础知识,投影空间z值从齐次坐标转换为正常坐标后的范围为-1 到 1,也就是clipPos除以w之后的结果,这也是为什么比较z和w就可以确保其不超出近平面,因为当点在近平面时,z值为-1,当点在远平面时,z值为1)
然而,裁减空间的一些细节让情况变得复杂起来。我们往往很直观的将深度值为-1的地方想象为近平面,随着距离增加,值不断上升。但实际上,除了OpenGL之外的 API,情况与我们想象的相反,在近平面上值为1。而对于OpenGL,近平面则是-1。我们通过 UNITY_REVERSED_Z
和 UNITY_NEAR_CLIP_VALUE
这两个宏覆盖所有情况。我们导入Common.hlsl来获取这两个宏。
2.2 A Second Pass
在我们的Lit着色器中添加ShadowCaster pass,我们复制一个pass语句块,并将第二个pass中Tags中的 LightMode设为ShadowCaster。接着引入 ShadowCaster.hlsl而非Lit.hlsl 。并使用对应的顶点片元函数。
现在我们的物体能够渲染进阴影贴图里了。因为物体目前只受单个光源影响,所以GPU instancing的效率非常好。
选择Shadows.Draw项,你就能够看到最终的阴影贴图了。因为是仅深度贴图,frame debugger会为我们显示深度信息,白色为近处,黑色为远处。
因为阴影贴图是在聚光灯假设成相机的方式下渲染的,所以它的朝向和光源是相匹配的。如果发现阴影贴图是颠倒的,可能是你对光源进行了旋转,导致本地空间的向上方向在世界空间反而是向下的。
3. Sampling the Shadow Map
我们现在有了包含所需要数据的阴影贴图,但暂时还没有使用它。所以下一步就是采样阴影贴图
3.1 From World Space to Shadow Space
储存在深度贴图的中的深度信息,是依据在渲染该贴图时所使用的聚光灯当作摄像机的裁减空间计算的,我们把它叫做阴影空间。这与我们正常渲染场景所用到的坐标空间不匹配。想知道一个片元如果存储在深度贴图中深度值该是多少,我们要将片元的位置转从世界空间换到阴影空间。
首先我们得让我们的着色器可以访问阴影贴图。为此我们添加一个着色器材质变量 _ShadowMap。并在MyPipeline中持有指向它的标识符。
在RenderShadows
通函数中通过SetGlobalTexture
方法,来将阴影贴图和全局变量相绑定。
接着我们添加一个矩阵变量用于从世界空间转换至阴影空间,命名为_WorldToShadowMatrix。同样持有它的标识符。
通过阴影空间的视角矩阵和投影矩阵相乘可以得到改矩阵。用SetGlobalMatrix函数
将它传给GPU。
我们又会遇到裁减空间z轴是否反向这一问题,好在我们可以用SystemInfo.usesReversedZBuffer
来检查,如果反向,那就在相乘之前修改投影矩阵的z列分量(列序列号为2)。直接修改原矩阵的m20至m23字段即可。
我们现在有了世界空间至阴影空间的转换矩阵。裁减空间范围是-1到1,但我们的纹理坐标和深度范围在0到1。要映射至该范围就得就得再额外乘一个能在所有维度缩放和偏移 0.5个单位的转换矩阵。我们可以用Matrix4x4.TRS
方法来得到想要的缩放、旋转或偏移。
但是其实这是一个simple matrix,我们简单的在单位矩阵的基础上修改合适的分量即可。
3.2 Sampling Depth
在Lit.hlsl,中,新增一个缓存区并在其中定义float4x4 _WorldToShadowMatrix
。
纹理资源不属于buffer的一部分,我们得分开另外定义。我们可以用宏 TEXTURE2D_SHADOW
来定义 _ShadowMap
。
接下来,我们需定义采样器状态用于采样纹理。通常我们是用的是宏SAMPLER
,但是这里我们需要使用另外一个特殊的比较采样器,所以使用SAMPLER_CMP
。为了得到正确的采样器状态,应使用sampler前缀再加上贴图名字作为参数
什么是纹理采样器?
在旧的GLSL代码中,我们使用sampler2D来同时定义纹理和采样器状态。但其实这是两个分开的东西,都会占用资源。采样器状态可以从纹理中分离开来,就为混合使用两者提供了可能。典型的例子就是多张纹理重复利用同一个采样器状态。在我们的例子里,我们通过MyPipeline设置采样器状态的过滤模式为双线性以及纹理映射模式为clamping 我们使用的comparison sampler还会在双线性插值之前就为我们进行深度比较。这会比在插值之后才进行比较效果更好。
创建一个以世界位置作为参数的ShadowAttenuation
方法。它会返回我们光源阴影的衰减因子。在方法里首先要做的就是将世界位置转为阴影空间位置。
就像之前转换到裁减空间一样,得到的位置是定义在齐次坐标系中的。我们需要的是常规坐标,所以我们让xyz分量除以w分量。
现在我们可以通过SAMPLE_TEXTURE2D_SHADOW这个宏采样阴影贴图。它需要一张贴图,一个采样器状态,以及对应的阴影空间位置作为参数。如果该点位置的z值比在阴影贴图中对应点的值要小就会返回1,这说明他比任何投射阴影的物体离光源都要近。反之,在阴影投射物后面就会返回0。因为采样器会在双线性插值之前先进行比较,所以阴影边缘会混合阴影贴图的多个纹素(texels)。
3.3 Fading when Shadowed
让阴影产生影响,只需在DiffuseLight
函数中为阴影衰减添加一个参数。将它与其他的渐变因子一起作用于漫反射强度。
顶点光源现在不会有阴影,所以在LitPassVertex
.中将阴影衰减值设为1。
在 LitPassFragment
中,调用ShadowAttenuation
方法并传入世界位置,将返回值传给DiffuseLight
函数产生阴影。
现在阴影出现了,但是有非常严重的瑕疵。
4. Shadow Settings
影响阴影质量表现的因素有很多。我们暂时支持一部分:阴影分辨率、深度偏移、强度、软阴影。我们可以在每个光源的检视面板对这些以及其他选项进行配置。
4.1 Shadow Map Size
虽然光源的inspector中有设置阴影分辨率的选项但这只会间接的影响深度贴图的大小,真正是取决于项目设置中的quality settings,至少对于Unity默认的渲染管线是这样的。我们用的是自己的渲染管线,因此我们选择将阴影贴图大小的设置选项放到MyPipelineAsset中。
阴影贴图是正方形贴图,我们允许设为256x256到4096x4096之间的任意二次方大小。为此我们在MyPipelineAsset中定义一个名为ShadowMapSize的枚举类型,其中包含了256、512、1024、2048这几个枚举。因为枚举不能为数字,所以我们加一个下划线前缀,Unity编辑器在显示时会抹去下划线。我们用这个枚举类型添加一个配置字段用于设置阴影贴图尺寸。
枚举代表的整数默认从0开始。但如果枚举选项正好与相同大小的整数相通会很方便,因此我们对枚举项进行显示赋值。
默认值0将无法表示任何枚举项,所以我们需设置一个有效的默认值。
将该参数传入渲染管线的构造函数
在MyPipeline中添加一个变量,并在构造函数中初始化。
在RenderShadows中分配渲染纹理时,我们就可以使用该变量设置阴影贴图尺寸了。
4.2 Shadow Bias
阴影瑕疵的问题更详细的解释请看Rendering 7, Shadows,我们用最简单的方式掩盖这些瑕疵。那就是在渲染深度贴图时在深度上添加一点偏移。这个深度偏移在每个光源中单独配置,所以必须把它传给GPU。我们添加一个_ShadowBias着色器属性,并记录下它的标识符。
在RenderShadows中设置视角投影矩阵后,设置深度偏移。 VisibleLight
中不能直接得到该信息,但其中的light字段有深度偏移。
ShadowCaster.hlsl 文件中阴影Buffer中添加相应的变量。对裁减空间位置的z分量应用z分量。如果z轴是翻转的那就用减法,否则用加法。
shadow bias应当尽可能小,避免阴影偏移的太远引起peter-panning效果(看起来影子漂浮在地面上)
关于bias以及shadow map这篇博文写的非常好,强烈建议读一下https://blog.csdn.net/ronintao/article/details/51649664
根本原因就是 shadow depth map 的分辨率不够,因此多个 pixel 会对应 map 上的同一个点。
图中黄色箭头是照射的光线,黑色长方形是实际物体表面,黄色的波浪线是 shadow map中的对应值的情况。
可以看到,由于map是对场景的离散取样,所以黄色的线段呈阶梯状的波浪变化,相对于实际场景中的情况,就有一部分比实际场景中的深度要大(对应着黑色线段部分),着部分不会产生阴影(注意图画反了);一部分比实际场景中的深度要小(对应着黄色线段部分),这部分会产生阴影,所以就出现了条纹状的阴影。
由于这种情况,是物体的实际深度,与自己的采样深度,相比较不相等(实际深度大于采样深度)导致的,所以可谓是自己(采样的副本)遮挡了自己(实际的物体),所以被称为 self shadowing。
解决的方法很简单,其实只有实际深度大于采样深度的时候才有问题,那么我们在计算实际深度的时候,往灯光方向拉一点,让他减小一点就可以了
4.3 Shadow Strength
我们只有单个光源并且没有任何环境光,所以我们的阴影是纯黑的。但是我们可以调和一下阴影衰减的轻度,让他只淡化部分光源贡献而不是完全排除。这会让我们的阴影看起来是半透明的。我们 _ShadowStrength属性表示阴影强度,并记录下它的标识符。
采样阴影贴图时会用到阴影强度,所以将它与世界-阴影空间矩阵和阴影贴图在一块设置。和深度偏移一样,我们在 Light字段
中获取该值。
在阴影缓存区添加阴影强度。在ShadowAttenuation函数
中用它在1和采样得到的衰减值之间插值。
4.4 Soft Shadows
最后一个设置就是支持软硬阴影的切换。我们现在使用的是硬阴影,阴影边缘的平滑过渡全靠在采样阴影贴图时使用的双线性插值。当开启平滑的软阴影时,阴影和非阴影的过渡是模糊的,阴影中有很大的半影区域。但不像在现实世界中,半影的产生取决于光源、投射物,接受阴影物体之间的空间关系,在这里半影范围是固定统一的。
软阴影需要采样阴影贴图多次。次数越靠后,采样点越偏离原采样位置,贡献度也越低。我们使用5x5 tent filter,需要九次纹理采样。为此我们要用到在Shadow/ShadowSamplingTent.hlsl文件中的一个函数方法,将它导入Lit.hlsl。
tent filter需要知道阴影贴图的尺寸。该方法要求一个特定的向量,四个分量分别为宽的倒数、高的倒数、宽度、高度。我们将其添加到shadow buffer。
在MyPipeline中保存相应的标识符。
在 RenderShadows函数中
设置该变量。
当_SHADOWS_SOFT 关键字被定义时,在ShadowAttenuation
方法中我们用tent filter替换常规的阴影贴图采样。
不再是单次采样,我们创建一个5x5的tent filter用来叠加九次采样结果。SampleShadow_ComputeSamples_Tent_5x5方法会给分配好每次采样的权重和UV坐标,我们需要传入阴影贴图尺寸和阴影空间位置。权重和uv通过输出参数获取,一个是flaot数组,另一个是float2数组,两者都有9个元素。
然而,方法中的输出参数定义的类型为real而不是float。它不是一个实际的数字类型,而是一个宏,根据需要自动选择flaot或者half。我们通常可以忽略这个情况,但是为了避免在某些平台出现编译错误,最好还是为输出参数使用real类型。
现在我们可以在循环中,使用数组里的权重和uv坐标,对阴影贴图采样九次。这是一个固定次数的循环,所以shader编译器会会将循环展开。我们还需要阴影空间位置的z坐标,每次采样都用这三者构造一个flaot3变量作为参数。
为了设置使用软阴影,创建一个定义了_SHADOWS_SOFT关键字的着色器变种。在我们的Lit着色器的默认pass中添加一个多重编译指令。我们需要两个变种,一个有该关键字,一个没有,所以我们用下划线表示没有关键字,后面跟着_SHADOWS_SOFT关键字。
最后,我们在 RenderShadow
函数中基于光照的shadows属性设置关键字。如果设置为LightShadows.Soft
就会在我们的阴影缓冲区调用 EnableShaderKeyword
方法,否则调用DisableShaderKeyword
方法,Unity根据关键字状态决定在渲染时使用哪一个变种。
用一个bool值切换关键字很普遍,我们可以用方法CoreUtils.SetKeyword
代替。
5. More Lights With Shadows
目前我们只支持单光源投射阴影,但是我们的管线支持最多16个光源,接下来我们将实现最多支持16个聚光灯的阴影。
5.1 Shadow Data Per Light
我们目前的管线只能用单个pass完成所有的光源工作,所以如果我们想支持多光源阴影,我们就得确保每个光源的数据(如强度等)可同时访问。我们在ConfigureLights函数
中收集这些数据,就像设置其他的光源数据一样。所以我们将该方法移到RenderShadows
前,并且只在有可见光源时调用 RenderShadows。
我们用一个四维向量数组存储阴影数据,每个向量代表一个光源。在ConfigureLights
函数遍历光源的循环中先将每个向量初始化为0,就像之前设置衰减数据一样。
在光源是聚光灯类型的情况下获取Light
脚本。如果shadows属性没有被设置为LightShadows.None
,就将阴影强度存储在向量的x分量中。
我们用向量的y分量存储使用硬阴影或软阴影。1表示软阴影,0表示硬阴影。
5.2 Excluding Lights
一个光源可见并且开启了阴影并不能保证一定需要阴影贴图。如果在光源的视角中并没有任何阴影的投射物或接受物,自然不需要阴影贴图。我们可以调用剔除结果的 GetShadowCasterBounds
函数,传入一个光源索引来检查该光源是否需要阴影贴图。他会检查该光源的阴影体积是否在一个有效的范围内。如果没有,我们就跳过设置阴影数据。尽管我们没有用到输出结果,但是我们还是得提供一个阴影范围作为参数。
5.3 Rendering All Shadow Maps
我们把首次执行阴影缓冲区和设置阴影贴图纹理之间的代码用一个循环包括。用这个循环再次遍历所有可见光源,并在光源数量超过可支持最大光源数是打断循环。将其中所有原本固定的索引0,修改为迭代值变量。
跳过不需要阴影贴图的光源,我们用阴影数据中的阴影强度来判断。小于等于0(有可能原本的强度就这样,也有可能是我们之前设0来跳过)就直接用continue
跳到下个迭代。
omputeSpotShadowMatricesAndCullingPrimitives
方法返回是否可以生成有效的矩阵的布尔值。理论上应该和 GetShadowCasterBounds
方法的结果一致,但以防万一还是考虑在失败时将强度设为0并跳过此次迭代。
当我们开启多个光源的阴影(只要它们的位置能够让他们产生可见的阴影),frame debugger会显示我们确实渲染了多次阴影贴图。
然而,阴影显示地一团糟,我们还需要进一步做一些工作。
5.4 Using the Correct Shadow Data
不再是使用单一的ShadowStrength属性,我们需要传入shadow data数组。
同样的,我们也需要设置投影矩阵数组,将其传入GPU
在shader里,修改阴影缓存区使之匹配。
ShadowAttenuation
方法新增一个参数接受光源索引以便取得正确的数组元素。我们检查阴影强度是否为正数。如果不是,直接将1作为衰减值返回。代替依赖_SHADOWS_SOFT关键字判断,我们基于阴影数据的y分量来进行条件分支。
最后在 LitPassFragment
里调用ShadowAttenuation
时传入光源索引。
5.5 Shadow Map Atlas
虽然我们现在有了正确的用于渲染阴影所需要的阴影数据和矩阵信息,但是在超过一个光源有阴影时,最终产生的仍然是错误的阴影。这是因为所有的阴影贴图都渲染进了同一张纹理之中,多个信息混合在一起,导致得到的阴影贴图没有意义。Unity轻量级渲染管线通过阴影贴图图集解决这一问题。将渲染纹理分割为多个方形区域,每个光源个占据其一。我们也使用这种方法。
为什么不使用纹理数组?
这是可行的,但可惜使用阴影投射渲染纹理数组并不是一个普遍的做法。比如,在Metal上这是可行的,但是OpenGL core要求4.6的着色器等级,即使生效了,Unity也会打印一连串的断言错误。所以还是老老实实的用单个渲染纹理吧。
我们最多支持16个光源,所以就应该把单张阴影贴图分成4x4网格的平铺块(tiles)。每个平铺块的大小应该和阴影贴图除以4的大小一样。我们要将渲染时的视口约束在这个大小,所以在RenderShadows
一开始创建一个Rect
结构体,并填充合适的值。
在我们设置视口和投影矩阵前,用SetViewport
函数告诉GPU使用合适的视口大小。
现在所有的阴影贴图都渲染在渲染纹理一角的单个平铺块中。下一步就是偏移每个光源的视口。我们可以依据每个平铺块的xy序号得到视口位置。Y轴偏移序号通过光源序列除以四(整数除法)得到。x轴偏移序号通过整数取余得到。最终视口的xy位置等于序号乘以平铺块大小。
这样的图集有一个缺点,在一个平铺块边缘采样时,可能会在两个平铺块之间插值,从而导致错误的结果。当使用软阴影时效果会更加的糟糕,因为tent filter可能会在离原始采样点偏移最多4个纹素的地方采样。相比混合附近的平铺块,能够淡出阴影肯定更好。所以我们在每个平铺块周围添加一圈空值边缘,让GPU写入数据时使用比平铺块略小一点的视口。这称之为裁减矩形。我们可以使用 shadowBuffer.EnableScissorRect
方法,传递一个比视口略小的矩形来实现。我们需要边缘宽度为四个纹素,所以这个矩形位置应该是视口位置加4,大小为视口大小减8。
我们在渲染阴影后调用DisableScissorRect
关闭裁剪矩形,不然会影响到后面的常规渲染。
最后要做的就是调整world-to-shadow矩阵,让它能采样到正确的平铺块。我们可以乘以一个有适当xy偏移的转换矩阵。shader不需要关心我们是否使用了图集。
要记住我们现在每个物体最多支持4个像素光,所以你让第5个聚光源照射到平面时,其中一个光源会退化为顶点光源,进而无法接受该光源的阴影。(16个阴影指的是整体场景总的阴影来源,而不是单个物体的)
6. Dynamic Tiling
使用阴影贴图图集的优点是无论有多少阴影贴图,我们用的都是同一张渲染纹理,所以纹理占用的内存是固定的。缺点则是每个光源只占纹理的一部分,所以最终的阴影贴图分辨率会比我们想象的要低。并且最终可能有很大一部分的纹理面积没有利用。
我们可以更好的利用纹理,而不是固定的将纹理分成16块。我们可以用一个变量表示平铺块的大小,可以根据有多少平铺块决定值设为多大。这种方式可以确保我们至少能用到一半的纹理。
6.1 Counting Shadow Tiles
首先,我们需要明确我们需要多少个平铺块。我们可以在ConfigureLights
中记录我们有多少带阴影的光源。并用一个字段记录总数以便在之后使用。
6.2 Splitting the Shadow Map
接下来在RenderShadows里一开始就算好如何分割阴影贴图。我用一个整数变量来表示。如果我们只要一个平铺块,就不需要分割,所以split值设为1,否则如果是4个以下值为2,8个以下值为3,只有超过8个,值才为4。
平铺块的大小可以通过阴影贴图大小除以split值得到(整数除法)。这意味着在除以3的时候我们会舍弃部分纹素。平铺块的缩放值应该改为1/split(浮点数除法)。我们使用split值计算平铺块的缩放和偏移用于调整世界-阴影矩阵。
为了在可用的空间打包阴影贴图,我们需要在确实设置好一个平铺块后,再递增序列。因此我们使用独立的一个变量而不是直接使用光源索引。在没我们没有跳过的迭代的末尾自递增变量。
6.3 One Tile is No Tile
最后,如果我们最终只需要一个平铺块,那就没必要设置视口的裁减模式了。我们只需要在有多个平铺块时需要这么做。
6.4 Shader Keywords
目前,我们每个片元最多可以采样来自四个光源的阴影,它们可能是软硬光源的组合。最麻烦的情况就是4个软阴影,一共需采样36次。好在我们shader中的多个分支可以为我们很好的按要求采样阴影,因为来自同一物体的片元最终使用的是同一条分支。但是我们可以通过分离不同的阴影组合来切换复杂度更低的备选shader。
共有四种可能的组合,第一种是完全没有阴影,第二种只有硬阴影,第三种只有软阴影,最复杂的一种就是软硬阴影的组合。我们可以使用shader变种处理所有可能的情况,通过使用关键字_SHADOWS_HARD 和 _SHADOWS_SOFT。
在RenderShadows中,使用两个布尔变量记录是否使用了软硬阴影,我们依靠阴影信息的Y分量来判断。在循环之后使用这些布尔值切换关键字。
在shader添加另一个多重编译指令,这次是_SHADOWS_HARD
在ShadowAttenuation
方法中,如果两个关键字都没定义就在一开始直接返回1。这样就可以省方法的剩余部分,完全的消除阴影。
为了让代码更整洁优雅,我们将采样软阴影和硬阴影的代码各自分离成独立的函数。
现在我们用关键字为其他三种情况填写代码。最开始的分支在两个关键字都定义时才有。
最后,如果我们不需要阴影平铺块 ,在MyPipeline.Render
里直接跳过RenderShadows
方法。我们甚至都不需要清理阴影贴图了。如果跳过了,要确保两个阴影的关键字都关掉了。没有可见光时我们也要把两个关键字关掉。