Directional Shadows Cascaded Shadow Maps
渲染与采样阴影贴图
支持多个带阴影的定向光源
使用级联阴影贴图技术
实现阴影的混合、淡化和过滤
这是关于创建自定义可编程渲染管线的教程系列第四部分,主要介绍如何增加对级联阴影贴图的支持。
本教程基于Unity 2019.2.14f1版本创建,并已升级至2022.3.5f1版本。
Preventing light from reaching where it shouldn't.
1 Rendering Shadows
在绘制物体时,表面和光照信息足以计算光照效果。但可能存在某些物体阻挡了光线,在我们绘制的表面上投射阴影。为了实现阴影效果,我们必须通过某种方式让着色器感知到这些投射阴影的物体。实现这一目标有多种技术,最常用的方法是生成阴影贴图,用于存储光线在击中表面前从光源出发所能传播的距离。在同一方向上距离更远的物体将无法被该光源照亮。Unity的渲染管线采用这种方法,我们也将遵循这一方案。
1.1 Shadow Settings
在开始渲染阴影之前,我们首先需要就质量方面做出一些决策,具体包括阴影的渲染最远距离以及阴影贴图的尺寸。
虽然我们可以将阴影渲染到相机可视范围的最远处,但这需要大量的绘制工作,并且需要非常大的贴图来充分覆盖该区域,这几乎是不切实际的。因此,我们将为阴影引入一个最大距离限制,该值最小为零,默认设置为100个单位。创建一个新的可序列化ShadowSettings类来包含此选项。这个类纯粹是配置选项的容器,因此我们将为其添加一个公共的maxDistance字段。
using UnityEngine;[System.Serializable]
public class ShadowSettings {[Min(0f)]public float maxDistance = 100f;
}
对于贴图尺寸,我们将在ShadowSettings内部嵌套定义一个MapSize枚举类型。使用该枚举来限定允许的纹理尺寸,所有尺寸均为256至8192范围内的2的幂次方值。
public enum MapSize {_256 = 256, _512 = 512, _1024 = 1024,_2048 = 2048, _4096 = 4096, _8192 = 8192}
然后为阴影贴图添加一个默认值为1024的尺寸字段。我们将使用单一纹理来容纳多个阴影贴图,因此将其命名为atlasSize。由于目前仅支持定向光源,此时我们仅处理定向光源阴影贴图。但未来我们将支持其他光源类型,它们会有各自的阴影设置。因此请将atlasSize放在内部的Directional结构体中,这样就能在检查器中自动获得分层配置结构。
[System.Serializable]public struct Directional {public MapSize atlasSize;}public Directional directional = new Directional {atlasSize = MapSize._1024};
为 CustomRenderPipelineAsset 添加阴影设置字段。
[SerializeField]ShadowSettings shadows = default;
Shadow settings.
在构建 CustomRenderPipeline 实例时,将这些设置传递给它。
protected override RenderPipeline CreatePipeline () {return new CustomRenderPipeline(useDynamicBatching, useGPUInstancing, useSRPBatcher, shadows);}
并让该实例持续维护这些配置参数。
ShadowSettings shadowSettings;public CustomRenderPipeline (bool useDynamicBatching, bool useGPUInstancing, bool useSRPBatcher,ShadowSettings shadowSettings) {this.shadowSettings = shadowSettings;…}
1.2 Passing Along Settings
从现在开始,我们将在调用相机渲染器的Render方法时传递这些设置。这样便于将来支持运行时更改阴影设置的功能,不过本教程暂不涉及这部分内容
protected override void Render (ScriptableRenderContext context, List<Camera> cameras) {for (int i = 0; i < cameras.Count; i++) {renderer.Render(context, cameras[i], useDynamicBatching, useGPUInstancing,shadowSettings);}}
CameraRenderer.Render 随后将其传递给 Lighting.Setup 方法,同时也传递给自身的 Cull 方法
public void Render (ScriptableRenderContext context, Camera camera,bool useDynamicBatching, bool useGPUInstancing,ShadowSettings shadowSettings) {…if (!Cull(shadowSettings.maxDistance)) {return;}Setup();lighting.Setup(context, cullingResults, shadowSettings);…}
我们需要在Cull方法中使用这些设置,因为阴影距离是通过剔除参数来配置的。
bool Cull (float maxShadowDistance) {if (camera.TryGetCullingParameters(out ScriptableCullingParameters p)) {p.shadowDistance = maxShadowDistance;cullingResults = context.Cull(ref p);return true;}return false;}
渲染超出相机可视范围的阴影没有意义,因此应取阴影最大距离与相机远裁剪平面之间的较小值。
p.shadowDistance = Mathf.Min(maxShadowDistance, camera.farClipPlane);
为了让代码能够编译通过,我们还需要为Lighting.Setup方法添加一个阴影设置参数,不过目前我们暂时不会对这些设置进行任何操作
public void Setup (ScriptableRenderContext context, CullingResults cullingResults,ShadowSettings shadowSettings) { … }
1.3 Shadows Class
虽然阴影在逻辑上是光照系统的一部分,但其实现相当复杂,因此我们创建一个新的Shadows类专门处理阴影。这个类最初是Lighting类的精简副本,包含自己的命令缓冲区、上下文字段、剔除结果和设置参数,以及用于初始化字段的Setup方法和执行缓冲区的ExecuteBuffer方法。
using UnityEngine;
using UnityEngine.Rendering;public class Shadows {const string bufferName = "Shadows";CommandBuffer buffer = new CommandBuffer {name = bufferName};ScriptableRenderContext context;CullingResults cullingResults;ShadowSettings settings;public void Setup (ScriptableRenderContext context, CullingResults cullingResults,ShadowSettings settings) {this.context = context;this.cullingResults = cullingResults;this.settings = settings;}void ExecuteBuffer () {context.ExecuteCommandBuffer(buffer);buffer.Clear();}
}
那么 Lighting 类只需维护一个 Shadows 实例,并在其自身的 Setup 方法中,于调用 SetupLights 之前调用 Shadows 的 Setup 方法即可。
Shadows shadows = new Shadows();public void Setup (…) {this.cullingResults = cullingResults;buffer.BeginSample(bufferName);shadows.Setup(context, cullingResults, shadowSettings);SetupLights();…}
1.4 Lights with Shadows
由于渲染阴影需要额外的工作量,可能会降低帧率,因此我们将限制带阴影的定向光源数量(这与支持的定向光源总数无关)。在Shadows类中添加一个常量来限制数量,初始值设为1。
const int maxShadowedDirectionalLightCount = 1;
我们无法预知哪个可见光源会产生阴影,因此需要记录这些信息。此外,后续还需要为每个带阴影的光源记录更多数据,因此我们先定义一个内部的ShadowedDirectionalLight结构体(目前仅包含光源索引),并维护该结构体的数组。
struct ShadowedDirectionalLight {public int visibleLightIndex;}ShadowedDirectionalLight[] ShadowedDirectionalLights =new ShadowedDirectionalLight[maxShadowedDirectionalLightCount];
为了确定哪些光源需要生成阴影,我们将添加一个带有光源索引和可见光索引参数的公共ReserveDirectionalShadows方法。其职责是在阴影图集中为该光源的阴影贴图预留空间,并存储渲染所需的信息。
public void ReserveDirectionalShadows (Light light, int visibleLightIndex) {}
由于带阴影的光源数量有限,我们需要记录已预留的数量。在Setup方法中将计数重置为零。然后在ReserveDirectionalShadows方法中检查是否已达到最大数量限制。如果还有剩余空间,则存储该光源的可见索引并增加计数。
int ShadowedDirectionalLightCount;…public void Setup (…) {…ShadowedDirectionalLightCount = 0;}public void ReserveDirectionalShadows (Light light, int visibleLightIndex) {if (ShadowedDirectionalLightCount < maxShadowedDirectionalLightCount) {ShadowedDirectionalLights[ShadowedDirectionalLightCount++] =new ShadowedDirectionalLight {visibleLightIndex = visibleLightIndex};}}
但仅当光源确实需要阴影时才应为其预留空间。如果光源的阴影模式设置为"None"或其阴影强度为零,则该光源不产生阴影,应被忽略。
if (ShadowedDirectionalLightCount < maxShadowedDirectionalLightCount &&light.shadows != LightShadows.None && light.shadowStrength > 0f) { … }
此外,有可能某个可见光源最终并未影响任何投射阴影的物体,这可能是因为这些物体被设置为不投射阴影,或者因为该光源仅影响超出最大阴影距离的物体。我们可以通过调用剔除结果的GetShadowCasterBounds方法(传入可见光源索引)来检查这一点。该方法第二个输出参数是边界框(我们不需要),返回值表示边界是否有效。如果无效,则说明该光源无需渲染阴影,应被忽略。
if (ShadowedDirectionalLightCount < maxShadowedDirectionalLightCount &&light.shadows != LightShadows.None && light.shadowStrength > 0f &&cullingResults.GetShadowCasterBounds(visibleLightIndex, out Bounds b)) { … }
现在我们可以在 Lighting.SetupDirectionalLight 方法中预留阴影空间了
void SetupDirectionalLight (int index, ref VisibleLight visibleLight) {dirLightColors[index] = visibleLight.finalColor;dirLightDirections[index] = -visibleLight.localToWorldMatrix.GetColumn(2);shadows.ReserveDirectionalShadows(visibleLight.light, index);}
1.5 Creating the Shadow Atlas
预留阴影空间后,我们需要渲染它们。在Lighting.Render中,当SetupLights执行完毕后,通过调用新的Shadows.Render方法来实现。
shadows.Setup(context, cullingResults, shadowSettings);SetupLights();shadows.Render();
Shadows.Render 方法会将定向光源的阴影渲染工作委托给另一个 RenderDirectionalShadows 方法处理,但仅当存在带阴影的光源时才会执行。
public void Render () {if (ShadowedDirectionalLightCount > 0) {RenderDirectionalShadows();}}void RenderDirectionalShadows () {}
创建阴影贴图需要将投射阴影的物体绘制到纹理上。我们将使用_DirectionalShadowAtlas来引用定向阴影图集。从设置中获取图集尺寸(整数类型),然后在命令缓冲区上调用GetTemporaryRT方法,传入纹理标识符参数以及其宽度和高度的像素尺寸。
static int dirShadowAtlasId = Shader.PropertyToID("_DirectionalShadowAtlas");…void RenderDirectionalShadows () {int atlasSize = (int)settings.directional.atlasSize;buffer.GetTemporaryRT(dirShadowAtlasId, atlasSize, atlasSize);}
这样申请的是一个方形渲染纹理,但默认情况下是普通的ARGB纹理。我们需要的是阴影贴图,为此需要在调用时再添加三个参数:第一个是深度缓冲区的位数,我们希望尽可能高,所以使用32位;第二个是过滤模式,我们使用默认的双线性过滤;第三个是渲染纹理类型,必须设置为RenderTextureFormat.Shadowmap。这样就能获得适合渲染阴影贴图的纹理,不过具体格式取决于目标平台。
buffer.GetTemporaryRT(dirShadowAtlasId, atlasSize, atlasSize,32, FilterMode.Bilinear, RenderTextureFormat.Shadowmap);
What kind of texture format do we get? 我们会得到哪种纹理格式?
通常是24位或32位的整数或浮点纹理。你也可以选择16位格式——这也是Unity渲染管线采用的方案。
当我们获取临时渲染纹理时,使用完毕后需要将其释放。我们必须保持对该纹理的持有直到相机渲染完成,之后可以通过在缓冲区上调用ReleaseTemporaryRT并传入纹理标识符来释放它,最后执行该命令。我们将在一个新的公共Cleanup方法中实现这一过程。
public void Cleanup () {buffer.ReleaseTemporaryRT(dirShadowAtlasId);ExecuteBuffer();}
同样为 Lighting 添加一个公共的 Cleanup 方法,该方法会将调用转发给 Shadows。
public void Cleanup () {shadows.Cleanup();}
然后 CameraRenderer 就可以在提交之前直接请求清理。
public void Render (…) {…lighting.Cleanup();Submit();}
只有在我们先前申请了纹理的情况下才能释放它,而目前我们仅在需要渲染定向阴影时才会申请。最直接的解决方案是仅在存在阴影时才释放纹理。然而,不申请纹理会导致 WebGL 2.0 出现问题,因为它会将纹理和采样器绑定在一起。如果在纹理缺失时加载带有我们着色器的材质,将会失败,因为此时会使用默认纹理,而该纹理与阴影采样器不兼容。我们可以通过引入着色器关键字来生成省略阴影采样代码的着色器变体,从而避免此问题。另一种替代方法是,在不需要阴影时获取一个 1×1 的虚拟纹理,从而避免额外的着色器变体。我们采用后一种方法
public void Render () {if (shadowedDirLightCount > 0) {RenderDirectionalShadows();}else {buffer.GetTemporaryRT(dirShadowAtlasId, 1, 1,32, FilterMode.Bilinear, RenderTextureFormat.Shadowmap);}}
在申请渲染纹理之后,Shadows.RenderDirectionalShadows 还必须指示 GPU 将内容渲染到此纹理而非相机目标。这需要通过调用缓冲区上的 SetRenderTarget 方法来实现,指定渲染纹理以及其数据的加载和存储方式。由于我们会立即清除纹理,因此不关心其初始状态,所以使用 RenderBufferLoadAction.DontCare。而该纹理的目的是存储阴影数据,因此第三个参数需要使用 RenderBufferStoreAction.Store。
buffer.GetTemporaryRT(…);buffer.SetRenderTarget(dirShadowAtlasId,RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store);
完成此操作后,我们可以使用与清除相机目标相同的方式调用ClearRenderTarget,这里我们只需关注深度缓冲区。最后执行缓冲区命令。如果场景中至少有一个带阴影的定向光源处于活动状态,你将在帧调试器中看到阴影图集的清除操作。
buffer.SetRenderTarget(dirShadowAtlasId,RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store);buffer.ClearRenderTarget(true, false, Color.clear);ExecuteBuffer();
Clearing two render targets.
Why am I getting an error about dimensions not matching? 为什么会出现维度不匹配的错误?
在Unity 2020中此时可能会出现此问题。请继续操作,该问题将在下一节得到解决。
1.6 Shadows First
由于我们在设置常规相机之前就配置了阴影图集,导致在渲染常规几何体之前就切换到了阴影图集,这不符合我们的需求。我们应当在CameraRenderer.Render中调用CameraRenderer.Setup之前渲染阴影,这样常规渲染就不会受到影响。
//Setup();lighting.Setup(context, cullingResults, shadowSettings);Setup();DrawVisibleGeometry(useDynamicBatching, useGPUInstancing);
Shadows first.
我们可以在设置光照前开始采样,并在光照设置完成后、清除相机目标前立即结束采样,这样就能将阴影条目嵌套在相机条目内显示在帧调试器中。
buffer.BeginSample(SampleName);ExecuteBuffer();lighting.Setup(context, cullingResults, shadowSettings);buffer.EndSample(SampleName);Setup();
Nested shadows.
1.7 Rendering
为了渲染单个光源的阴影,我们将在Shadow类中添加一个重载的RenderDirectionalShadows方法,它包含两个参数:第一个是带阴影的光源索引,第二个是其在图集中的瓦片尺寸。然后在另一个RenderDirectionalShadows方法中,通过BeginSample和EndSample调用的包裹,为所有带阴影的光源调用此方法。由于当前仅支持单个带阴影的光源,其瓦片尺寸等于图集尺寸。
void RenderDirectionalShadows () {…buffer.ClearRenderTarget(true, false, Color.clear);buffer.BeginSample(bufferName);ExecuteBuffer();for (int i = 0; i < ShadowedDirectionalLightCount; i++) {RenderDirectionalShadows(i, atlasSize);}buffer.EndSample(bufferName);ExecuteBuffer();} void RenderDirectionalShadows (int index, int tileSize) {}
要渲染阴影,我们需要一个ShadowDrawingSettings结构体值。可以通过调用其构造函数并传入剔除结果和之前存储的相应可见光源索引,来创建正确配置的实例。
void RenderDirectionalShadows (int index, int tileSize) {ShadowedDirectionalLight light = ShadowedDirectionalLights[index];var shadowSettings =new ShadowDrawingSettings(cullingResults, light.visibleLightIndex);}
Unity 2022 版本还要求我们提供一个额外参数来表明使用正交投影,不过这个要求在 2023 版本中又被移除了。
var shadowSettings = new ShadowDrawingSettings(cullingResults, light.visibleLightIndex,BatchCullingProjectionType.Orthographic);
阴影贴图的原理是从光源视角渲染场景,仅存储深度信息。结果会告诉我们光线在击中物体前传播了多远。
然而,定向光被假定为无限远,因此没有真实的位置。所以我们改为计算与光源方向匹配的视图和投影矩阵,得到一个与相机可见区域重叠的裁剪空间立方体,该区域可包含光源的阴影。我们可以使用剔除结果的ComputeDirectionalShadowMatricesAndCullingPrimitives方法来自动计算,该方法需要传入九个参数。
第一个参数是可见光源索引。接下来三个参数是两个整数和一个Vector3,用于控制阴影级联。我们稍后再处理级联,目前使用零、一和零向量。然后是纹理尺寸,这里需要使用瓦片尺寸。第六个参数是阴影近平面,我们暂时忽略并设为零。
这些是输入参数,其余三个是输出参数:首先是视图矩阵,其次是投影矩阵,最后一个参数是ShadowSplitData结构体。
var shadowSettings = …;cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(light.visibleLightIndex, 0, 1, Vector3.zero, tileSize, 0f,out Matrix4x4 viewMatrix, out Matrix4x4 projectionMatrix,out ShadowSplitData splitData);
分割数据包含了关于如何剔除阴影投射物体的信息,我们必须将这些信息复制到阴影设置中。同时,我们需要通过调用缓冲区上的SetViewProjectionMatrices方法来应用视图和投影矩阵。
cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(…);shadowSettings.splitData = splitData;buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);
我们最终通过执行缓冲区命令,并在上下文上调用DrawShadows方法(通过引用传递阴影设置)来安排阴影投射物的绘制。
shadowSettings.splitData = splitData;buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);ExecuteBuffer();context.DrawShadows(ref shadowSettings);
1.8 Shadow Caster Pass
此时阴影投射物应该被渲染了,但图集仍然为空。这是因为DrawShadows只会渲染带有ShadowCaster通道的材质对象。因此,在我们的Lit着色器中添加第二个Pass块,将其光照模式设置为ShadowCaster。使用相同的目标级别,支持实例化功能,并添加_CLIPPING着色器特性。然后使其使用特殊的阴影投射函数,这些函数将在新的ShadowCasterPass HLSL文件中定义。另外,由于我们只需要写入深度数据,请在HLSL程序之前添加ColorMask 0来禁用颜色数据写入。
SubShader {Pass {Tags {"LightMode" = "CustomLit"}…}Pass {Tags {"LightMode" = "ShadowCaster"}ColorMask 0HLSLPROGRAM#pragma target 3.5#pragma shader_feature _CLIPPING#pragma multi_compile_instancing#pragma vertex ShadowCasterPassVertex#pragma fragment ShadowCasterPassFragment#include "ShadowCasterPass.hlsl"ENDHLSL}}
通过复制LitPass文件创建ShadowCasterPass文件,并移除阴影投射器不需要的所有内容。这样我们只需要裁剪空间位置和用于裁剪的基础颜色。片段函数无需返回任何内容,因此变为无语义的void类型,其唯一功能是可能对片段进行裁剪。
#ifndef CUSTOM_SHADOW_CASTER_PASS_INCLUDED
#define CUSTOM_SHADOW_CASTER_PASS_INCLUDED#include "../ShaderLibrary/Common.hlsl"TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap);UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)UNITY_DEFINE_INSTANCED_PROP(float4, _BaseMap_ST)UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)UNITY_DEFINE_INSTANCED_PROP(float, _Cutoff)
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)struct Attributes {float3 positionOS : POSITION;float2 baseUV : TEXCOORD0;UNITY_VERTEX_INPUT_INSTANCE_ID
};struct Varyings {float4 positionCS : SV_POSITION;float2 baseUV : VAR_BASE_UV;UNITY_VERTEX_INPUT_INSTANCE_ID
};Varyings ShadowCasterPassVertex (Attributes input) {Varyings output;UNITY_SETUP_INSTANCE_ID(input);UNITY_TRANSFER_INSTANCE_ID(input, output);float3 positionWS = TransformObjectToWorld(input.positionOS);output.positionCS = TransformWorldToHClip(positionWS);float4 baseST = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseMap_ST);output.baseUV = input.baseUV * baseST.xy + baseST.zw;return output;
}void ShadowCasterPassFragment (Varyings input) {UNITY_SETUP_INSTANCE_ID(input);float4 baseMap = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.baseUV);float4 baseColor = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor);float4 base = baseMap * baseColor;#if defined(_CLIPPING)clip(base.a - UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Cutoff));#endif
}#endif
我们现在已经能够渲染阴影投射物了。我创建了一个简单的测试场景,包含位于平面上方的一些不透明物体,以及一个启用了全强度阴影的定向光源来进行测试。无论光源设置为使用硬阴影还是软阴影都不会影响结果。
Shadow test scene.
阴影目前还不会影响最终渲染的图像,但我们已经可以通过帧调试器查看渲染到阴影图集的内容。它通常以单色纹理的形式呈现,随着距离增加从白色渐变为黑色,但在使用OpenGL时则会显示为红色且颜色变化方向相反。
512 atlas; max distance 100.
将最大阴影距离设置为100时,最终所有内容只会渲染到纹理的一小部分区域。减小最大距离实际上会让阴影贴图聚焦在相机前方的景物上。
Max distance 20 and 10.
请注意,阴影投射物是使用正交投影渲染的,因为这是为定向光源进行的渲染。
1.9 Multiple Lights
我们最多可以支持四个定向光源,因此让我们也最多支持四个带阴影的定向光源。
const int maxShadowedDirectionalLightCount = 4;
作为快速测试,我使用了四个相同的定向光源,只是将它们的Y轴旋转角度依次调整了90°。
Shadow casters for four lights, superimposed.
虽然我们最终正确渲染了所有光源的阴影投射物,但由于每个光源都渲染到整个图集,它们会相互叠加。我们必须分割图集,以便为每个光源提供独立的瓦片进行渲染。
我们最多支持四个带阴影的光源,并将在方形图集中为每个光源分配一个方形瓦片。因此,当存在多个带阴影的光源时,我们需要将图集分割为四个瓦片——通过将瓦片尺寸减半来实现。在Shadows.RenderDirectionalShadows中确定分割数量和瓦片尺寸,并将这两个参数逐个传递给每个光源的对应方法。
void RenderDirectionalShadows () {…int split = ShadowedDirectionalLightCount <= 1 ? 1 : 2;int tileSize = atlasSize / split;for (int i = 0; i < ShadowedDirectionalLightCount; i++) {RenderDirectionalShadows(i, split, tileSize);}}void RenderDirectionalShadows (int index, int split, int tileSize) { … }
我们可以通过调整渲染视口来渲染到单个瓦片。为此创建一个新方法,该方法包含瓦片索引和分割数量参数。首先计算瓦片偏移量:X偏移量为索引对分割数取模,Y偏移量为索引除以分割数(取整)。这些虽然是整数运算,但最终需要定义Rect,因此将结果存储为Vector2类型。
void SetTileViewport (int index, int split) {Vector2 offset = new Vector2(index % split, index / split);}
然后在缓冲区上调用SetViewPort方法,传入一个Rect参数——该Rect的偏移量需乘以瓦片尺寸(瓦片尺寸应作为第三个参数传入,且可直接使用浮点数类型)。
void SetTileViewport (int index, int split, float tileSize) {Vector2 offset = new Vector2(index % split, index / split);buffer.SetViewport(new Rect(offset.x * tileSize, offset.y * tileSize, tileSize, tileSize));}
在设置矩阵时,于 RenderDirectionalShadows 方法中调用 SetTileViewport。
SetTileViewport(index, split, tileSize);buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);
Shadow atlas with four tiles in use.
2. Sampling Shadows
现在我们已经能渲染阴影投射物了,但这还不会影响最终图像。要让阴影显现出来,我们必须在CustomLit通道中对阴影贴图进行采样,并用它来判断表面片段是否处于阴影之中。
2.1 Shadow Matrices
对于每个片段,我们需要从阴影图集的相应瓦片中采样深度信息。因此,我们必须为给定的世界空间位置找到对应的阴影纹理坐标。我们将通过为每个带阴影的定向光源创建阴影变换矩阵,并将它们发送到GPU来实现这一点。在Shadows类中添加_DirectionalShadowMatrices着色器属性标识符和静态矩阵数组来实现此功能。
static intdirShadowAtlasId = Shader.PropertyToID("_DirectionalShadowAtlas"),dirShadowMatricesId = Shader.PropertyToID("_DirectionalShadowMatrices");static Matrix4x4[]dirShadowMatrices = new Matrix4x4[maxShadowedDirectionalLightCount];
我们可以在RenderDirectionalShadows中通过将光源的阴影投影矩阵和视图矩阵相乘,来创建一个从世界空间到光源空间的转换矩阵。
void RenderDirectionalShadows (int index, int split, int tileSize) {…SetTileViewport(index, split, tileSize);dirShadowMatrices[index] = projectionMatrix * viewMatrix;buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);…}
然后在所有带阴影的光源渲染完成后,通过调用缓冲区上的SetGlobalMatrixArray方法将矩阵数组发送到GPU。
void RenderDirectionalShadows () {…buffer.SetGlobalMatrixArray(dirShadowMatricesId, dirShadowMatrices);buffer.EndSample(bufferName);ExecuteBuffer();}
然而,这忽略了我们在使用阴影图集的事实。让我们创建一个ConvertToAtlasMatrix方法,该方法接收光源矩阵、瓦片偏移量和分割数量作为参数,返回一个从世界空间转换到阴影瓦片空间的矩阵。
Matrix4x4 ConvertToAtlasMatrix (Matrix4x4 m, Vector2 offset, int split) {return m;}
我们已经在SetTileViewport中计算了瓦片偏移量,因此让它返回该偏移量。
Vector2 SetTileViewport (int index, int split, float tileSize) {…return offset;}
然后调整RenderDirectionalShadows方法,使其调用ConvertToAtlasMatrix。
//SetTileViewport(index, split, tileSize);dirShadowMatrices[index] = ConvertToAtlasMatrix(projectionMatrix * viewMatrix,SetTileViewport(index, split, tileSize), split);
在ConvertToAtlasMatrix中,我们首先应该检查是否使用了反向Z缓冲区(通过SystemInfo.usesReversedZBuffer判断),如果是的话就对Z维度取反。
Matrix4x4 ConvertToAtlasMatrix (Matrix4x4 m, Vector2 offset, int split) {if (SystemInfo.usesReversedZBuffer) {m.m20 = -m.m20;m.m21 = -m.m21;m.m22 = -m.m22;m.m23 = -m.m23;}return m;}
Why are Z buffers reversed? 为什么Z缓冲区要反向?
最直观的方式是让0表示零深度,1表示最大深度——这也是OpenGL的做法。但由于深度缓冲区精度有限且以非线性方式存储,通过反转深度值可以更有效地利用比特位。其他图形API采用了这种反向方案。通常我们无需担心这个问题,除非显式处理裁剪空间时。
其次,裁剪空间定义在坐标范围从-1到1的立方体内,原点位于立方体中心。但纹理坐标和深度值的范围是从0到1。我们可以通过将XYZ维度缩放并偏移一半来将这个转换过程嵌入矩阵中。虽然可以通过矩阵乘法实现,但这会导致大量与零的乘法和不必要的加法运算。因此,我们直接调整矩阵本身。
m.m00 = 0.5f * (m.m00 + m.m30);m.m01 = 0.5f * (m.m01 + m.m31);m.m02 = 0.5f * (m.m02 + m.m32);m.m03 = 0.5f * (m.m03 + m.m33);m.m10 = 0.5f * (m.m10 + m.m30);m.m11 = 0.5f * (m.m11 + m.m31);m.m12 = 0.5f * (m.m12 + m.m32);m.m13 = 0.5f * (m.m13 + m.m33);m.m20 = 0.5f * (m.m20 + m.m30);m.m21 = 0.5f * (m.m21 + m.m31);m.m22 = 0.5f * (m.m22 + m.m32);m.m23 = 0.5f * (m.m23 + m.m33);return m;
最后,我们必须应用瓦片偏移和缩放。同样地,我们可以直接操作矩阵来避免大量不必要的计算。
float scale = 1f / split;m.m00 = (0.5f * (m.m00 + m.m30) + offset.x * m.m30) * scale;m.m01 = (0.5f * (m.m01 + m.m31) + offset.x * m.m31) * scale;m.m02 = (0.5f * (m.m02 + m.m32) + offset.x * m.m32) * scale;m.m03 = (0.5f * (m.m03 + m.m33) + offset.x * m.m33) * scale;m.m10 = (0.5f * (m.m10 + m.m30) + offset.y * m.m30) * scale;m.m11 = (0.5f * (m.m11 + m.m31) + offset.y * m.m31) * scale;m.m12 = (0.5f * (m.m12 + m.m32) + offset.y * m.m32) * scale;m.m13 = (0.5f * (m.m13 + m.m33) + offset.y * m.m33) * scale;
2.2 Storing Shadow Data Per Light
要为光源采样阴影,我们需要知道其在阴影图集中的瓦片索引(如果存在的话)。这个数据需要按光源存储,因此让ReserveDirectionalShadows方法返回所需数据。我们将提供两个值:阴影强度和阴影瓦片偏移量,打包在一个Vector2中。如果光源没有阴影,则返回零向量。
public Vector2 ReserveDirectionalShadows (…) {if (…) {ShadowedDirectionalLights[ShadowedDirectionalLightCount] =new ShadowedDirectionalLight {visibleLightIndex = visibleLightIndex};return new Vector2(light.shadowStrength, ShadowedDirectionalLightCount++);}return Vector2.zero;}
让Lighting类通过_DirectionalLightShadowData向量数组将这些数据提供给着色器使用。
static intdirLightCountId = Shader.PropertyToID("_DirectionalLightCount"),dirLightColorsId = Shader.PropertyToID("_DirectionalLightColors"),dirLightDirectionsId = Shader.PropertyToID("_DirectionalLightDirections"),dirLightShadowDataId =Shader.PropertyToID("_DirectionalLightShadowData");static Vector4[]dirLightColors = new Vector4[maxDirLightCount],dirLightDirections = new Vector4[maxDirLightCount],dirLightShadowData = new Vector4[maxDirLightCount];…void SetupLights () {…buffer.SetGlobalVectorArray(dirLightShadowDataId, dirLightShadowData);}void SetupDirectionalLight (int index, ref VisibleLight visibleLight) {dirLightColors[index] = visibleLight.finalColor;dirLightDirections[index] = -visibleLight.localToWorldMatrix.GetColumn(2);dirLightShadowData[index] =shadows.ReserveDirectionalShadows(visibleLight.light, index);
同时将其添加到Light HLSL文件的_CustomLight缓冲区中。
CBUFFER_START(_CustomLight)int _DirectionalLightCount;float4 _DirectionalLightColors[MAX_DIRECTIONAL_LIGHT_COUNT];float4 _DirectionalLightDirections[MAX_DIRECTIONAL_LIGHT_COUNT];float4 _DirectionalLightShadowData[MAX_DIRECTIONAL_LIGHT_COUNT];
CBUFFER_END
2.3 Shadows HLSL File
我们还将创建一个专门的Shadows HLSL文件用于阴影采样。在其中定义相同的最大阴影方向光源数量,以及_DirectionalShadowAtlas纹理,同时在_CustomShadows缓冲区中定义_DirectionalShadowMatrices数组。
#ifndef CUSTOM_SHADOWS_INCLUDED
#define CUSTOM_SHADOWS_INCLUDED#define MAX_SHADOWED_DIRECTIONAL_LIGHT_COUNT 4TEXTURE2D(_DirectionalShadowAtlas);
SAMPLER(sampler_DirectionalShadowAtlas);CBUFFER_START(_CustomShadows)float4x4 _DirectionalShadowMatrices[MAX_SHADOWED_DIRECTIONAL_LIGHT_COUNT];
CBUFFER_END#endif
由于图集不是常规纹理,我们改用TEXTURE2D_SHADOW宏来明确定义(尽管这对我们支持的平台没有实际影响)。同时我们将使用特殊的SAMPLER_CMP宏来定义采样器状态,因为这确实定义了一种不同的阴影贴图采样方式——常规的双线性滤波对深度数据没有意义。
TEXTURE2D_SHADOW(_DirectionalShadowAtlas);
SAMPLER_CMP(sampler_DirectionalShadowAtlas);
实际上,采样阴影贴图只有一种合适的方式,因此我们可以定义一个显式的采样器状态,而不依赖Unity为渲染纹理推导的采样器。采样器状态可以通过在名称中包含特定词汇来内联定义。我们可以使用sampler_linear_clamp_compare。同时再为其定义一个简写的SHADOW_SAMPLER宏。
TEXTURE2D_SHADOW(_DirectionalShadowAtlas);
#define SHADOW_SAMPLER sampler_linear_clamp_compare
SAMPLER_CMP(SHADOW_SAMPLER);
在LitPass中包含Shadows文件,顺序要在Light文件之前。
#include "../ShaderLibrary/Surface.hlsl"
#include "../ShaderLibrary/Shadows.hlsl"
#include "../ShaderLibrary/Light.hlsl"
2.4 Sampling Shadows
要采样阴影,我们需要知道每个光源的阴影数据,因此让我们在Shadows中专门为定向光源定义一个结构体。它包含强度和瓦片偏移量,但Shadows中的代码并不知道这些数据存储在哪里。
struct DirectionalShadowData {float strength;int tileIndex;
};
我们还需要知道表面位置,因此将其添加到Surface结构体中。
struct Surface {float3 position;…
};
并在LitPassFragment中进行赋值。
Surface surface;surface.position = input.positionWS;surface.normal = normalize(input.normalWS);
在Shadows中添加SampleDirectionalShadowAtlas函数,通过SAMPLE_TEXTURE2D_SHADOW宏对阴影图集进行采样,传入图集、阴影采样器以及阴影纹理空间中的位置(作为对应参数)。
float SampleDirectionalShadowAtlas (float3 positionSTS) {return SAMPLE_TEXTURE2D_SHADOW(_DirectionalShadowAtlas, SHADOW_SAMPLER, positionSTS);
}
然后添加GetDirectionalShadowAttenuation函数,该函数根据给定的定向阴影数据和世界空间中的表面信息返回阴影衰减值。它使用瓦片偏移量获取正确的矩阵,将表面位置转换到阴影瓦片空间,然后对图集进行采样。
float GetDirectionalShadowAttenuation (DirectionalShadowData data, Surface surfaceWS) {float3 positionSTS = mul(_DirectionalShadowMatrices[data.tileIndex],float4(surfaceWS.position, 1.0)).xyz;float shadow = SampleDirectionalShadowAtlas(positionSTS);return shadow;
}
阴影图集采样的结果是一个仅考虑阴影后到达表面的光照比例因子,这个在0-1范围内的值称为衰减因子。如果片段完全处于阴影中则得到0,完全不受阴影影响则得到1,中间值表示片段受到部分阴影影响。
此外,光源的阴影强度可能会被降低(出于艺术效果考虑或为了表现半透明表面的阴影)。当强度降为零时,衰减应完全不受阴影影响而保持为1。因此,最终衰减值需要通过基于强度在1和采样衰减值之间进行线性插值来计算。
return lerp(1.0, shadow, data.strength);
但当阴影强度为零时,就完全不需要进行阴影采样了,因为此时阴影既不会产生效果,甚至根本不会被渲染。在这种情况下,我们面对的是无阴影光源,应该始终返回1。
float GetDirectionalShadowAttenuation (DirectionalShadowData data, Surface surfaceWS) {if (data.strength <= 0.0) {return 1.0;}…
}
Is it a good idea to branch in shaders? 在着色器中使用分支是个好主意吗?
分支过去效率很低,但现代GPU已经能很好地处理它们了。需要注意的是,片段是以块为单位并行处理的。即使只有一个片段进入特定分支,整个块都会执行该分支,即使其他所有片段都会忽略该分支的代码结果。在当前情况下,我们基于光源强度进行分支——至少目前所有片段的这个值都是相同的。
2.5 Attenuating Light 光照衰减
我们将把光源的衰减值存储在Light结构体中。
struct Light {float3 color;float3 direction;float attenuation;
};
在Light中添加一个获取定向光源阴影数据的函数。
DirectionalShadowData GetDirectionalShadowData (int lightIndex) {DirectionalShadowData data;data.strength = _DirectionalLightShadowData[lightIndex].x;data.tileIndex = _DirectionalLightShadowData[lightIndex].y;return data;
}
然后在GetDirectionalLight中添加一个世界空间表面参数,让它获取定向光源阴影数据,并使用GetDirectionalShadowAttenuation来设置光源的衰减值。
Light GetDirectionalLight (int index, Surface surfaceWS) {Light light;light.color = _DirectionalLightColors[index].rgb;light.direction = _DirectionalLightDirections[index].xyz;DirectionalShadowData shadowData = GetDirectionalShadowData(index);light.attenuation = GetDirectionalShadowAttenuation(shadowData, surfaceWS);return light;
}
现在Lighting中的GetLighting也必须将表面参数传递给GetDirectionalLight。而且表面现在需要在世界空间中定义,因此相应重命名该参数。只要光照和表面的空间匹配,只有BRDF不关心它们的空间坐标系。
float3 GetLighting (Surface surfaceWS, BRDF brdf) {float3 color = 0.0;for (int i = 0; i < GetDirectionalLightCount(); i++) {color += GetLighting(surfaceWS, brdf, GetDirectionalLight(i, surfaceWS));}return color;
}
让阴影生效的最后一步是将衰减因子计入光源的强度计算。
float3 IncomingLight (Surface surface, Light light) {returnsaturate(dot(surface.normal, light.direction) * light.attenuation) *light.color;
}
One light with shadows; max distance 10; atlas size 512.
我们终于得到了阴影,但效果很差。本不应产生阴影的表面出现了像素化带状阴影伪影。这些是由阴影贴图分辨率有限引起的自阴影导致的。使用不同分辨率会改变伪影图案,但无法消除它们。表面最终会部分自阴影,我们稍后会处理这个问题。这些伪影能清晰显示阴影贴图的覆盖范围,因此我们暂时保留它们。
例如,我们可以看到阴影贴图仅覆盖了部分可见区域,这个范围由最大阴影距离控制。调整最大距离会扩大或缩小该区域。阴影贴图是与光源方向对齐的,而非相机方向。在最大距离之外仍能看到部分阴影,但有些阴影会缺失,当采样超出贴图边界时会出现异常。如果只有一个带阴影的光源处于活动状态,采样结果会被钳制;否则采样可能跨越瓦片边界,导致一个光源错误地使用了其他光源的阴影数据。
Two lights with shadows, both at half intensity.
我们稍后会正确处理在最大距离处截断阴影的问题,但目前这些无效的阴影仍然可见。
3. Cascaded Shadow Maps
由于定向光源会影响最大阴影距离内的所有物体,其阴影贴图最终会覆盖很大区域。由于阴影贴图使用正交投影,每个阴影纹理像素在世界空间中具有固定尺寸。如果这个尺寸过大,单个阴影纹理像素会清晰可见,导致阴影边缘出现锯齿,小阴影也可能消失。可以通过增大图集尺寸来缓解这个问题,但效果有限。
使用透视相机时,远处物体会显得更小。在某个视觉距离上,一个阴影纹理像素会映射到单个显示像素,这意味着此时阴影分辨率在理论上达到最优。靠近相机时需要更高阴影分辨率,而远处较低分辨率就足够了。这表明理想情况下,我们应该根据阴影接收者的视距使用可变的阴影贴图分辨率。
级联阴影贴图是解决这个问题的方案。其原理是阴影投射物会被多次渲染,因此每个光源在图集中获得多个瓦片(称为级联)。第一级联仅覆盖相机附近的较小区域,后续级联用相同数量的纹理像素逐渐放大覆盖更大区域。然后着色器为每个片段采样可用的最佳级联。
3.1 Settings
Unity的阴影代码支持每个定向光源最多四个级联。目前我们只使用了单个级联来覆盖最大阴影距离内的所有内容。为了支持更多级联,我们将在定向阴影设置中添加级联数量滑块。虽然我们可以为每个定向光源设置不同的数量,但对所有带阴影的定向光源使用相同的级联数量是最合理的做法。
每个级联覆盖阴影区域的一部分,直至最大阴影距离。我们将通过为前三个级联添加比例滑块来配置具体覆盖范围。最后一个级联始终覆盖整个范围,因此不需要滑块。默认将级联数量设置为四,级联比例分别为0.1、0.25和0.5。这些比例应该逐级递增,但我们不会在UI中强制要求。
public struct Directional {public MapSize atlasSize;[Range(1, 4)]public int cascadeCount;[Range(0f, 1f)]public float cascadeRatio1, cascadeRatio2, cascadeRatio3;}public Directional directional = new Directional {atlasSize = MapSize._1024,cascadeCount = 4,cascadeRatio1 = 0.1f,cascadeRatio2 = 0.25f,cascadeRatio3 = 0.5f};
Cascade counts and ratios.
ComputeDirectionalShadowMatricesAndCullingPrimitives方法要求我们以Vector3的形式提供这些比例值,因此让我们在设置中添加一个便捷属性来以这种形式获取它们。
public Vector3 CascadeRatios =>new Vector3(cascadeRatio1, cascadeRatio2, cascadeRatio3);
3.2 Rendering Cascades
每个级联都需要自己的变换矩阵,因此Shadows中的阴影矩阵数组大小必须乘以每个光源的最大级联数量(即4)。
const int maxShadowedDirectionalLightCount = 4, maxCascades = 4;…static Matrix4x4[]dirShadowMatrices = new Matrix4x4[maxShadowedDirectionalLightCount * maxCascades];
在Shadows中也相应增加数组的大小。
#define MAX_SHADOWED_DIRECTIONAL_LIGHT_COUNT 4
#define MAX_CASCADE_COUNT 4…CBUFFER_START(_CustomShadows)float4x4 _DirectionalShadowMatrices[MAX_SHADOWED_DIRECTIONAL_LIGHT_COUNT * MAX_CASCADE_COUNT];
CBUFFER_END
完成此操作后,Unity会报错说着色器的数组大小已更改但无法使用新尺寸。这是因为固定数组一旦被着色器申请后,在同一会话期间无法在GPU上更改其大小。我们必须重启Unity来重新初始化。
重启后,在Shadows.ReserveDirectionalShadows中将返回的瓦片偏移量乘以配置的级联数量,因为现在每个定向光源将占用多个连续的瓦片。
return new Vector2(light.shadowStrength,settings.directional.cascadeCount * ShadowedDirectionalLightCount++);
同样地,RenderDirectionalShadows中使用的瓦片数量也会成倍增加,这意味着我们最终可能会得到总共十六个瓦片,需要按四进行分割。
int tiles = ShadowedDirectionalLightCount * settings.directional.cascadeCount;int split = tiles <= 1 ? 1 : tiles <= 4 ? 2 : 4;int tileSize = atlasSize / split;
Why not also support a split by 3? 为什么不也支持按3分割呢?
我们限制自己只使用2的幂次方,这与我们对图集尺寸的限制相同。这样可以确保始终能进行整数除法,否则可能会出现对齐问题。这意味着某些光源配置不会使用所有可用瓦片,从而浪费纹理空间。如果这是个问题,你可以添加对非方形矩形图集的支持。不过,你更可能受限于可渲染的瓦片数量,而不是纹理空间。
现在RenderDirectionalShadows必须为每个级联绘制阴影。将ComputeDirectionalShadowMatricesAndCullingPrimitives开始直到DrawShadows的代码放入一个循环中,遍历每个配置的级联。ComputeDirectionalShadowMatricesAndCullingPrimitives的第二个参数现在变为级联索引,后面跟着级联数量和级联比例。同时调整瓦片索引,使其变为光源的瓦片偏移量加上级联索引。
void RenderDirectionalShadows (int index, int split, int tileSize) {ShadowedDirectionalLight light = shadowedDirectionalLights[index];var shadowSettings = …;int cascadeCount = settings.directional.cascadeCount;int tileOffset = index * cascadeCount;Vector3 ratios = settings.directional.CascadeRatios;for (int i = 0; i < cascadeCount; i++) {cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(light.visibleLightIndex, i, cascadeCount, ratios, tileSize, 0f,out Matrix4x4 viewMatrix, out Matrix4x4 projectionMatrix,out ShadowSplitData splitData);shadowSettings.splitData = splitData;int tileIndex = tileOffset + i;dirShadowMatrices[tileIndex] = ConvertToAtlasMatrix(projectionMatrix * viewMatrix,SetTileViewport(tileIndex, split, tileSize), split);buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);ExecuteBuffer();context.DrawShadows(ref shadowSettings);}}
One and four lights with four cascades; max distance 30; ratios 0.3, 0.4, 0.5.
3.3 Culling Spheres
Unity通过为每个级联创建剔除球体来确定其覆盖区域。由于阴影投影是正交且方形的,它们会紧密贴合各自的剔除球体,但也会覆盖球体周围的一些空间。这就是为什么在剔除区域外也能看到部分阴影的原因。此外,光源方向对球体没有影响,因此所有定向光源最终都使用相同的剔除球体。
Culling spheres visualized with transparent spheres.
这些球体数据也是确定采样级联所必需的,因此我们需要将它们发送到GPU。添加级联数量的标识符和级联剔除球体数组,同时为球体数据添加静态数组。它们由四分量向量定义,其中XYZ分量表示位置,W分量存储半径。
static intdirShadowAtlasId = Shader.PropertyToID("_DirectionalShadowAtlas"),dirShadowMatricesId = Shader.PropertyToID("_DirectionalShadowMatrices"),cascadeCountId = Shader.PropertyToID("_CascadeCount"),cascadeCullingSpheresId = Shader.PropertyToID("_CascadeCullingSpheres");static Vector4[] cascadeCullingSpheres = new Vector4[maxCascades];
级联的剔除球体是ComputeDirectionalShadowMatricesAndCullingPrimitives输出的分割数据的一部分。在RenderDirectionalShadows的循环中将其赋值给球体数组。但我们只需要对第一个光源执行此操作,因为所有光源的级联都是等效的。
for (int i = 0; i < cascadeCount; i++) {cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(…);shadowSettings.splitData = splitData;if (index == 0) {cascadeCullingSpheres[i] = splitData.cullingSphere;}…}
我们需要在着色器中获取球体数据来检查表面片段是否位于球体内部,这可以通过比较片段到球体中心的平方距离与球体半径的平方来实现。因此,我们直接存储半径的平方值,这样就不必在着色器中再进行计算。
Vector4 cullingSphere = splitData.cullingSphere;cullingSphere.w *= cullingSphere.w;cascadeCullingSpheres[i] = cullingSphere;
渲染完级联后,将级联数量和球体数据发送到GPU。
void RenderDirectionalShadows () {…buffer.SetGlobalInt(cascadeCountId, settings.directional.cascadeCount);buffer.SetGlobalVectorArray(cascadeCullingSpheresId, cascadeCullingSpheres);buffer.SetGlobalMatrixArray(dirShadowMatricesId, dirShadowMatrices);buffer.EndSample(bufferName);ExecuteBuffer();}
3.4 Sampling Cascades
将级联数量和剔除球体数组添加到Shadows中。
CBUFFER_START(_CustomShadows)int _CascadeCount;float4 _CascadeCullingSpheres[MAX_CASCADE_COUNT];float4x4 _DirectionalShadowMatrices[MAX_SHADOWED_DIRECTIONAL_LIGHT_COUNT * MAX_CASCADE_COUNT];
CBUFFER_END
级联索引是按片段确定的,而非按光源。因此我们引入一个全局的ShadowData结构体来存储它(后续还会添加更多数据)。同时添加一个GetShadowData函数,该函数返回世界空间表面的阴影数据,目前暂时将级联索引始终设为零。
struct ShadowData {int cascadeIndex;
};ShadowData GetShadowData (Surface surfaceWS) {ShadowData data;data.cascadeIndex = 0;return data;
}
将新数据作为参数添加到GetDirectionalShadowData中,这样它就可以通过将级联索引与光源的阴影瓦片偏移量相加来选择正确的瓦片索引。
DirectionalShadowData GetDirectionalShadowData (int lightIndex, ShadowData shadowData
) {DirectionalShadowData data;data.strength = _DirectionalLightShadowData[lightIndex].x;data.tileIndex =_DirectionalLightShadowData[lightIndex].y + shadowData.cascadeIndex;return data;
}
同时向GetDirectionalLight添加相同参数,以便其将数据转发给GetDirectionalShadowData。相应重命名定向阴影数据的变量名。
Light GetDirectionalLight (int index, Surface surfaceWS, ShadowData shadowData) {…DirectionalShadowData dirShadowData =GetDirectionalShadowData(index, shadowData);light.attenuation = GetDirectionalShadowAttenuation(dirShadowData, surfaceWS);return light;
}
在GetLighting中获取阴影数据并传递下去。
float3 GetLighting (Surface surfaceWS, BRDF brdf) {ShadowData shadowData = GetShadowData(surfaceWS);float3 color = 0.0;for (int i = 0; i < GetDirectionalLightCount(); i++) {Light light = GetDirectionalLight(i, surfaceWS, shadowData);color += GetLighting(surfaceWS, brdf, light);}return color;
}
Always using the first vs. the last cascade.
为了选择正确的级联,我们需要计算两点之间的平方距离。让我们在Common中添加一个便捷的函数来实现这个功能。
float DistanceSquared(float3 pA, float3 pB) {return dot(pA - pB, pA - pB);
}
在GetShadowData中循环遍历所有级联剔除球体,直到找到包含表面位置的球体。一旦找到就跳出循环,然后使用当前循环迭代器作为级联索引。这意味着如果片段位于所有球体之外,我们将得到一个无效索引,但暂时忽略这种情况。
int i;for (i = 0; i < _CascadeCount; i++) {float4 sphere = _CascadeCullingSpheres[i];float distanceSqr = DistanceSquared(surfaceWS.position, sphere.xyz);if (distanceSqr < sphere.w) {break;}}data.cascadeIndex = i;
Selecting the best cascade.
现在我们获得了纹理像素密度分布更合理的阴影。由于自阴影伪影,级联之间的弯曲过渡边界也清晰可见——不过我们可以通过用级联索引除以四来替换阴影衰减值,使这些边界更容易被观察到。
Light GetDirectionalLight (int index, Surface surfaceWS, ShadowData shadowData) {…light.attenuation = GetDirectionalShadowAttenuation(dirShadowData, surfaceWS);light.attenuation = shadowData.cascadeIndex * 0.25;return light;
}
Shadowing with cascade indices.
3.5 Culling Shadow Sampling
如果我们超出最后一个级联范围,很可能就没有有效的阴影数据,此时根本不应该采样阴影。一个简单的实现方法是在ShadowData中添加强度字段,默认设为1,如果超出最后一个级联则设为0。
struct ShadowData {int cascadeIndex;float strength;
};ShadowData GetShadowData (Surface surfaceWS) {ShadowData data;data.strength = 1.0;int i;for (i = 0; i < _CascadeCount; i++) {…}if (i == _CascadeCount) {data.strength = 0.0;}data.cascadeIndex = i;return data;
}
然后将全局阴影强度纳入GetDirectionalShadowData中的定向阴影强度计算。这样会剔除最后一个级联之外的所有阴影。
data.strength =_DirectionalLightShadowData[lightIndex].x * shadowData.strength;
同时,在GetDirectionalLight中恢复正确的衰减计算。
light.attenuation = GetDirectionalShadowAttenuation(dirShadowData, surfaceWS);//light.attenuation = shadowData.cascadeIndex * 0.25;
Culled shadows; max distance 12.
3.6 Max Distance
通过调整最大阴影距离进行实验会发现,一些阴影投射物在仍处于最后一级级联的剔除球体内时突然消失。这是因为最外层级联的剔除球体并不会恰好在配置的最大距离处终止,而是会略微超出该范围。当最大距离设置较小时,这种差异最为明显。
我们可以通过让阴影在最大距离处停止采样来解决阴影突然消失的问题。为实现这一点,需要将最大距离值在Shadows中发送到GPU。
static int…cascadeCullingSpheresId = Shader.PropertyToID("_CascadeCullingSpheres"),shadowDistanceId = Shader.PropertyToID("_ShadowDistance");…void RenderDirectionalShadows () {…buffer.SetGlobalFloat(shadowDistanceId, settings.maxDistance);buffer.EndSample(bufferName);ExecuteBuffer();}
最大距离是基于视图空间深度而非到相机位置的距离。因此要执行这种剔除,我们需要知道表面的深度。为此在Surface中添加一个深度字段。
struct Surface {float3 position;float3 normal;float3 viewDirection;float depth;…
};
深度可以在LitPassFragment中通过TransformWorldToView将世界空间坐标转换到视图空间,然后取负的Z坐标得到。由于这种转换只是相对于世界空间的旋转和偏移,因此视图空间和世界空间中的深度是相同的。
surface.viewDirection = normalize(_WorldSpaceCameraPos - input.positionWS);surface.depth = -TransformWorldToView(input.positionWS).z;
现在在GetShadowData中不再总是将强度初始化为1,而是仅当表面深度小于最大距离时才这样做,否则将其设置为0。
CBUFFER_START(_CustomShadows)…float _ShadowDistance;
CBUFFER_END…
float FadedShadowStrength (float distance, float scale, float fade) {return saturate((1.0 - distance * scale) * fade);
}ShadowData GetShadowData (Surface surfaceWS) {ShadowData data;data.strength = surfaceWS.depth < _ShadowDistance ? 1.0 : 0.0;…
}
Also culled based on depth.
3.7 Fading Shadows
在最大距离处突然截断阴影会非常明显,因此我们通过线性淡入淡出来使过渡更平滑。淡出在最大距离之前的一段距离开始,直到在最大距离处强度降为零。我们可以使用函数 (1 - d/m) / f 并将其限制在0-1范围内来实现,其中 d 是表面深度,m 是最大阴影距离,f 是淡出范围(以最大距离的分数表示)。
f 0.1, 0.2, and 0.5.
在阴影设置中添加距离淡出的滑块。由于淡出值和最大值都用作除数,它们不应为零,因此将它们的最小值设置为0.001。
[Min(0.001f)]public float maxDistance = 100f;[Range(0.001f, 1f)]public float distanceFade = 0.1f;
将Shadows中的阴影距离标识符替换为同时包含距离值和淡出值的标识符。
//shadowDistanceId = Shader.PropertyToID("_ShadowDistance");shadowDistanceFadeId = Shader.PropertyToID("_ShadowDistanceFade");
将它们作为向量的XY分量发送到GPU时,使用这些值的倒数,这样我们就可以在着色器中避免除法运算,因为乘法运算速度更快。
buffer.SetGlobalFloat(shadowDistanceId, settings.maxDistance);buffer.SetGlobalVector(shadowDistanceFadeId,new Vector4(1f / settings.maxDistance, 1f / settings.distanceFade));
调整Shadows中的_CustomShadows缓冲区以匹配此更改。
//float _ShadowDistance;float4 _ShadowDistanceFade;
现在我们可以使用 的饱和值来计算渐隐阴影强度,其中 1/m 用作比例因子 s,1/f 用作新的渐隐乘数 f。为此创建一个 FadedShadowStrength 函数,并在 GetShadowData 中使用它。
float FadedShadowStrength (float distance, float scale, float fade) {return saturate((1.0 - distance * scale) * fade);
}ShadowData GetShadowData (Surface surfaceWS) {ShadowData data;data.strength = FadedShadowStrength(surfaceWS.depth, _ShadowDistanceFade.x, _ShadowDistanceFade.y);…
}
Distance fade.
3.8 Fading Cascades
我们也可以采用相同方法在最后一级级联的边缘对阴影进行淡出处理,而不是直接截断。为此添加一个级联淡出阴影设置的滑块。
public struct Directional {…[Range(0.001f, 1f)]public float cascadeFade;}public Directional directional = new Directional {…cascadeRatio3 = 0.5f,cascadeFade = 0.1f};
唯一的区别在于,我们处理的是级联的平方距离和半径,而非线性深度和最大值。这意味着过渡变为非线性:1 - (d² / r²) f,其中 r 是剔除球体半径。这种差异并不显著,但为了保持配置的淡出比例不变,我们需要将 f 替换为 1 - (1 - f)²。然后我们将其存储在阴影距离淡出向量的 Z 分量中,同样使用倒数形式存储。
f 0.1, 0.2, and 0.5 with square distance.
float f = 1f - settings.directional.cascadeFade;buffer.SetGlobalVector(shadowDistanceFadeId, new Vector4(1f / settings.maxDistance, 1f / settings.distanceFade,1f / (1f - f * f)));
要在级联中执行淡出效果,请在GetShadowData的循环内部检查是否处于最后一个级联。如果是,则计算该级联的淡出阴影强度,并将其纳入最终强度计算。
for (i = 0; i < _CascadeCount; i++) {float4 sphere = _CascadeCullingSpheres[i];float distanceSqr = DistanceSquared(surfaceWS.position, sphere.xyz);if (distanceSqr < sphere.w) {if (i == _CascadeCount - 1) {data.strength *= FadedShadowStrength(distanceSqr, 1.0 / sphere.w, _ShadowDistanceFade.z);}break;}}
Both cascade and distance fade. 级联淡出和距离淡出同时生效。
4. Shadow Quality
现在我们有了可正常工作的级联阴影贴图,接下来重点提升阴影质量。我们一直观察到的这些瑕疵被称为阴影痤疮(shadow acne),这是由于与光线方向不完全垂直的表面错误地产生自阴影所导致的。当表面越接近与光线方向平行时,痤疮现象会越严重。
Shadow acne.阴影痤疮
增大图集尺寸会减小纹理像素在世界空间中的大小,从而使痤疮伪影变小。然而,伪影的数量也会增加,因此不能单纯通过增大图集尺寸来解决这个问题。
4.1 Depth Bias 深度偏移
有多种方法可以减轻阴影痤疮。最简单的是为阴影投射物的深度添加恒定偏移量,将其推离光源以避免错误的自阴影。最快捷的实现方式是在渲染时应用全局深度偏移——在DrawShadows之前调用缓冲区的SetGlobalDepthBias方法,之后重置为零。这是在裁剪空间中应用的深度偏移,其值为一个极小值的倍数(具体取决于阴影贴图使用的精确格式)。我们可以使用较大值(如50000)来理解其工作原理。该方法还有第二个参数用于斜率缩放偏移,但目前我们将其保持为零。
buffer.SetGlobalDepthBias(50000f, 0f);ExecuteBuffer();context.DrawShadows(ref shadowSettings);buffer.SetGlobalDepthBias(0f, 0f);
Constant depth bias.
恒定偏移量虽然简单,但只能消除大部分正对光源表面的伪影。要完全消除痤疮则需要更大的偏移量,比如增加一个数量级。
buffer.SetGlobalDepthBias(500000f, 0f);
Larger depth bias.
然而,当深度偏移将阴影投射物推离光源时,采样的阴影也会沿相同方向移动。足以消除大部分痤疮的偏移量,总会使阴影过度偏离其投射物,导致出现被称为"小飞侠效应"的视觉伪影。
Bias causes peter-panning. 偏移量会导致小飞侠效应。
另一种方法是应用斜率缩放偏移,这需要通过为SetGlobalDepthBias的第二个参数设置非零值来实现。该值用于缩放沿X和Y维度的绝对裁剪空间深度导数的最大值。因此,对于正对光源的表面该值为零;当光线至少在一个维度上以45°角照射时值为1;而当表面法线与光线方向的点积接近零时,该值会趋近无穷大。这样就能在需要时自动增加偏移量,但没有上限。因此消除痤疮所需的系数要低得多,例如使用3而不是500000。
buffer.SetGlobalDepthBias(0f, 3f);
Slope scale bias.
斜率缩放偏移虽然有效但不够直观。需要通过反复试验才能在痤疮伪影和小飞侠效应之间找到可接受的平衡点。因此我们暂时禁用它,寻找更直观可控的解决方案。
//buffer.SetGlobalDepthBias(0f, 3f);ExecuteBuffer();context.DrawShadows(ref shadowSettings);//buffer.SetGlobalDepthBias(0f, 0f);
4.2 Cascade Data 级联数据
由于痤疮伪影的大小取决于世界空间中的纹素尺寸,一个能在所有情况下保持一致的解决方案必须考虑这一点。由于每个级联的纹素尺寸各不相同,这意味着我们需要向GPU发送更多级联数据。为此在Shadows中添加一个通用的级联数据向量数组。
static int…cascadeCullingSpheresId = Shader.PropertyToID("_CascadeCullingSpheres"),cascadeDataId = Shader.PropertyToID("_CascadeData"),shadowDistanceFadeId = Shader.PropertyToID("_ShadowDistanceFade");static Vector4[]cascadeCullingSpheres = new Vector4[maxCascades],cascadeData = new Vector4[maxCascades];
将其与其他所有数据一起发送到GPU。
buffer.SetGlobalVectorArray(cascadeCullingSpheresId, cascadeCullingSpheres);buffer.SetGlobalVectorArray(cascadeDataId, cascadeData);
我们可以在这些向量的X分量中存储级联半径平方的倒数,这样就不必在着色器中进行除法运算。在一个新的SetCascadeData方法中实现这一点,同时存储剔除球体数据,并在RenderDirectionalShadows中调用该方法。传入级联索引、剔除球体和瓦片尺寸(浮点数)作为参数。
void RenderDirectionalShadows (int index, int split, int tileSize) {…for (int i = 0; i < cascadeCount; i++) {…if (index == 0) {SetCascadeData(i, splitData.cullingSphere, tileSize);}…}}void SetCascadeData (int index, Vector4 cullingSphere, float tileSize) {cascadeData[index].x = 1f / cullingSphere.w;cullingSphere.w *= cullingSphere.w;cascadeCullingSpheres[index] = cullingSphere;}
将级联数据添加到Shadows中的_CustomShadows缓冲区。
CBUFFER_START(_CustomShadows)int _CascadeCount;float4 _CascadeCullingSpheres[MAX_CASCADE_COUNT];float4 _CascadeData[MAX_CASCADE_COUNT];…
CBUFFER_END
并在GetShadowData中使用新的预计算倒数。
data.strength *= FadedShadowStrength(distanceSqr, _CascadeData[i].x, _ShadowDistanceFade.z);
4.3 Normal Bias
不正确的自阴影出现的原因是阴影投射物的深度纹素覆盖了多个片段,导致投射物体积从其表面凸出。因此,如果我们足够缩小投射物,这个问题就应该能解决。但缩小阴影投射物会使阴影变得比应有尺寸更小,并可能产生本不存在的空洞。
我们也可以反其道而行:在采样阴影时膨胀表面。这样我们就在离表面稍远的位置采样,刚好足以避免不正确的自阴影。这会轻微调整阴影的位置,可能导致边缘错位和产生虚假阴影,但这些伪影通常远不如小飞侠效应明显。
我们可以通过沿表面法线向量轻微移动表面位置来实现阴影采样。如果只考虑单一维度,那么偏移量等于世界空间纹素尺寸就足够了。我们可以在SetCascadeData中通过将剔除球体直径除以瓦片尺寸来计算纹素尺寸,并将其存储在级联数据向量的Y分量中。
float texelSize = 2f * cullingSphere.w / tileSize;cullingSphere.w *= cullingSphere.w;cascadeCullingSpheres[index] = cullingSphere;//cascadeData[index].x = 1f / cullingSphere.w;cascadeData[index] = new Vector4(1f / cullingSphere.w,texelSize);
然而,这并不总是足够的,因为纹素是正方形的。在最坏的情况下,我们需要沿着正方形的对角线进行偏移,因此将其乘以√2进行缩放
texelSize * 1.4142136f
在着色器端,为GetDirectionalShadowAttenuation添加全局阴影数据参数。将表面法线乘以偏移量得到法线偏移值,在计算阴影瓦片空间中的位置之前,将其加到世界空间位置上。
float GetDirectionalShadowAttenuation (DirectionalShadowData directional, ShadowData global, Surface surfaceWS
) {if (directional.strength <= 0.0) {return 1.0;}float3 normalBias = surfaceWS.normal * _CascadeData[global.cascadeIndex].y;float3 positionSTS = mul(_DirectionalShadowMatrices[directional.tileIndex],float4(surfaceWS.position + normalBias, 1.0)).xyz;float shadow = SampleDirectionalShadowAtlas(positionSTS);return lerp(1.0, shadow, directional.strength);
}
在GetDirectionalLight中传递额外的数据给它
light.attenuation =GetDirectionalShadowAttenuation(dirShadowData, shadowData, surfaceWS);
Normal bias equal to texel size.
4.4 Configurable Biases 可配置的偏移量
法线偏移能消除阴影痤疮且不会引入明显的新瑕疵,但无法解决所有阴影问题。例如,在不应有阴影的墙壁下方地板上会出现可见的阴影线。这不是自阴影,而是从墙壁渗出的阴影影响了下方地板。添加少量斜率缩放偏移可以处理这种情况,但不存在完美的固定值。因此我们将按光源配置该值,使用它们现有的"Bias"滑块。在Shadows的ShadowedDirectionalLight结构体中为此添加字段。
struct ShadowedDirectionalLight {public int visibleLightIndex;public float slopeScaleBias;}
光源的偏移量可通过其shadowBias属性获取。将其添加到ReserveDirectionalShadows的数据中。
shadowedDirectionalLights[ShadowedDirectionalLightCount] =new ShadowedDirectionalLight {visibleLightIndex = visibleLightIndex,slopeScaleBias = light.shadowBias};
并用它在RenderDirectionalShadows中配置斜率缩放偏移。
buffer.SetGlobalDepthBias(0f, light.slopeScaleBias);ExecuteBuffer();context.DrawShadows(ref shadowSettings);buffer.SetGlobalDepthBias(0f, 0f);
我们还可以使用光源现有的"法线偏移"滑块来调节我们应用的法线偏移量。让ReserveDirectionalShadows返回Vector3类型,并使用光源的shadowNormalBias作为新的Z分量。
public Vector3 ReserveDirectionalShadows (Light light, int visibleLightIndex) {if (…) {…return new Vector3(light.shadowStrength,settings.directional.cascadeCount * ShadowedDirectionalLightCount++,light.shadowNormalBias);}return Vector3.zero;}
将新的法线偏移量添加到DirectionalShadowData中,并在Shadows的GetDirectionalShadowAttenuation中应用它。
struct DirectionalShadowData {float strength;int tileIndex;float normalBias;
};…float GetDirectionalShadowAttenuation (…) {…float3 normalBias = surfaceWS.normal *(directional.normalBias * _CascadeData[global.cascadeIndex].y);…
}
并在Light的GetDirectionalShadowData中进行配置。
data.tileIndex =_DirectionalLightShadowData[lightIndex].y + shadowData.cascadeIndex;data.normalBias = _DirectionalLightShadowData[lightIndex].z;
现在我们可以针对每个光源调整这两种偏移量。将斜率缩放偏移设为0,法线偏移设为1是个不错的默认值。如果增加前者,可以相应减少后者。但请注意,我们对这些光源设置的理解与其原始用途不同——它们原本是作为裁剪空间深度偏移和世界空间收缩法线偏移使用的。因此当你创建新光源时,会出现严重的小飞侠效应,直到你调整这些偏移量。
Both biases set to 0.6.
4.5 Shadow Pancaking
另一个可能导致伪影的问题是Unity应用的阴影压平技术(shadow pancaking)。其原理是在渲染定向光的阴影投射物时,将近平面尽可能向前移动。这样做可以提高深度精度,但也意味着不在相机视野内的阴影投射物可能会出现在近平面之前,导致它们在不该被裁剪时被裁剪掉。
Shadows get clipped.
这个问题可以通过在ShadowCasterPassVertex中将顶点位置钳制到近平面来解决,实际上是将位于近平面之前的阴影投射物压平,使它们变成粘在近平面上的薄饼。我们通过取裁剪空间Z和W坐标的最大值(当定义了UNITY_REVERSED_Z时则取最小值)来实现这一点。为了对W坐标使用正确的符号,需要将其乘以UNITY_NEAR_CLIP_VALUE。
output.positionCS = TransformWorldToHClip(positionWS);#if UNITY_REVERSED_Zoutput.positionCS.z =min(output.positionCS.z, output.positionCS.w * UNITY_NEAR_CLIP_VALUE);#elseoutput.positionCS.z =max(output.positionCS.z, output.positionCS.w * UNITY_NEAR_CLIP_VALUE);#endif
Shadows get clamped.
这对于完全位于近平面任意一侧的阴影投射物效果很好,但跨越该平面的阴影投射物会发生变形,因为只有部分顶点受到影响。对于小三角形来说这并不明显,但大三角形可能会严重变形,发生弯曲并经常下沉到表面之下。
Deformed shadows of very long cube. 超长立方体的变形阴影。
这个问题可以通过将近平面向后拉回一些来缓解。这就是光源的"近平面"滑块的作用。在ShadowedDirectionalLight中添加近平面偏移字段。
struct ShadowedDirectionalLight {public int visibleLightIndex;public float slopeScaleBias;public float nearPlaneOffset;}
并将光源的shadowNearPlane属性赋值给它。
shadowedDirectionalLights[ShadowedDirectionalLightCount] =new ShadowedDirectionalLight {visibleLightIndex = visibleLightIndex,slopeScaleBias = light.shadowBias,nearPlaneOffset = light.shadowNearPlane};
我们通过填充ComputeDirectionalShadowMatricesAndCullingPrimitives的最后一个参数来应用它,这个参数我们之前一直固定设置为零。
cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(light.visibleLightIndex, i, cascadeCount, ratios, tileSize,light.nearPlaneOffset, out Matrix4x4 viewMatrix,out Matrix4x4 projectionMatrix, out ShadowSplitData splitData);
With near plane offset. 使用近平面偏移。
4.6 PCF Filtering PCF滤波
到目前为止,我们仅通过每个片段采样一次阴影贴图来使用硬阴影。阴影比较采样器使用一种特殊形式的双线性插值,在插值前执行深度比较。这被称为百分比渐近滤波(简称PCF),具体来说是2×2 PCF滤波器,因为涉及四个纹素。
但这并不是我们过滤阴影贴图的唯一方法。我们也可以使用更大的滤波器,使阴影更柔和、减少锯齿,但精度也会降低。让我们添加对2×2、3×3、5×5和7×7滤波的支持。我们不会使用现有的软阴影模式按光源控制这个选项,而是让所有定向光使用相同的滤波器。为此在ShadowSettings中添加FilterMode枚举,同时在Directional中添加filter选项,默认设置为2×2。
public enum FilterMode {PCF2x2, PCF3x3, PCF5x5, PCF7x7}…[System.Serializable]public struct Directional {public MapSize atlasSize;public FilterMode filter;…}public Directional directional = new Directional {atlasSize = MapSize._1024,filter = FilterMode.PCF2x2,…};
Filter set to PCF 2x2.
我们将为新的滤波模式创建着色器变体。为此在Shadows中添加包含三个关键字的静态数组。
static string[] directionalFilterKeywords = {"_DIRECTIONAL_PCF3","_DIRECTIONAL_PCF5","_DIRECTIONAL_PCF7",};
创建一个SetKeywords方法,用于启用或禁用相应的关键字。在RenderDirectionalShadows中执行缓冲区之前调用该方法。
void RenderDirectionalShadows () {…SetKeywords();buffer.EndSample(bufferName);ExecuteBuffer();}void SetKeywords () {int enabledIndex = (int)settings.directional.filter - 1;for (int i = 0; i < directionalFilterKeywords.Length; i++) {if (i == enabledIndex) {buffer.EnableShaderKeyword(directionalFilterKeywords[i]);}else {buffer.DisableShaderKeyword(directionalFilterKeywords[i]);}}}
更大的滤波器需要更多的纹理采样。我们需要在着色器中同时知道图集尺寸和纹素尺寸才能实现这一点。为此添加一个着色器标识符。
cascadeDataId = Shader.PropertyToID("_CascadeData"),shadowAtlasSizeId = Shader.PropertyToID("_ShadowAtlasSize"),shadowDistanceFadeId = Shader.PropertyToID("_ShadowDistanceFade");
并将其添加到着色器端的_CustomShadow中。
CBUFFER_START(_CustomShadows)…float4 _ShadowAtlasSize;float4 _ShadowDistanceFade;
CBUFFER_END
将尺寸存储在X分量中,纹素尺寸存储在Y分量中。
SetKeywords();buffer.SetGlobalVector(shadowAtlasSizeId, new Vector4(atlasSize, 1f / atlasSize));
在Lit的CustomLit通道中添加#pragma multi_compile指令,对应三个关键字,同时为匹配2×2滤波器的无关键字选项添加下划线。
#pragma shader_feature _PREMULTIPLY_ALPHA#pragma multi_compile _ _DIRECTIONAL_PCF3 _DIRECTIONAL_PCF5 _DIRECTIONAL_PCF7#pragma multi_compile_instancing
我们将使用Core RP库中Shadow/ShadowSamplingTent HLSL文件定义的函数,因此请在Shadows文件顶部引入它。如果定义了3×3关键字,我们总共需要四个滤波样本,这些样本将通过SampleShadow_ComputeSamples_Tent_3x3函数来设置。我们只需要进行四次采样,因为每次采样都使用双线性2×2滤波器。将这些采样点沿所有方向偏移半个纹素形成的正方形,就能以帐篷滤波器覆盖3×3纹素区域,其中中心的权重比边缘更高。
How does a tent filter work? 帐篷滤波器如何工作?
Bloom教程介绍了利用双线性纹理采样的滤波核,而景深教程包含了一个3×3帐篷滤波器的示例。
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Shadow/ShadowSamplingTent.hlsl"#if defined(_DIRECTIONAL_PCF3)#define DIRECTIONAL_FILTER_SAMPLES 4#define DIRECTIONAL_FILTER_SETUP SampleShadow_ComputeSamples_Tent_3x3
#endif#define MAX_SHADOWED_DIRECTIONAL_LIGHT_COUNT 4
#define MAX_CASCADE_COUNT 4
出于同样的原因,5×5滤波器我们只需九个采样点,7×7滤波器只需十六个采样点,同时使用相应命名的函数即可。
#if defined(_DIRECTIONAL_PCF3)#define DIRECTIONAL_FILTER_SAMPLES 4#define DIRECTIONAL_FILTER_SETUP SampleShadow_ComputeSamples_Tent_3x3
#elif defined(_DIRECTIONAL_PCF5)#define DIRECTIONAL_FILTER_SAMPLES 9#define DIRECTIONAL_FILTER_SETUP SampleShadow_ComputeSamples_Tent_5x5
#elif defined(_DIRECTIONAL_PCF7)#define DIRECTIONAL_FILTER_SAMPLES 16#define DIRECTIONAL_FILTER_SETUP SampleShadow_ComputeSamples_Tent_7x7
#endif
为阴影瓦片空间位置创建一个新的FilterDirectionalShadow函数。当定义了DIRECTIONAL_FILTER_SETUP时,它需要进行多次采样,否则只需调用一次SampleDirectionalShadowAtlas即可。
float FilterDirectionalShadow (float3 positionSTS) {#if defined(DIRECTIONAL_FILTER_SETUP)float shadow = 0;return shadow;#elsereturn SampleDirectionalShadowAtlas(positionSTS);#endif
}
滤波器设置函数有四个参数:首先是一个float4类型的尺寸参数,其中X和Y分量是纹素尺寸,Z和W分量是总纹理尺寸;然后是原始采样位置;接着是权重和每个采样位置的输出参数(分别定义为float和float2数组)。之后我们可以遍历所有样本,按权重调制后累加结果。
#if defined(DIRECTIONAL_FILTER_SETUP)float weights[DIRECTIONAL_FILTER_SAMPLES];float2 positions[DIRECTIONAL_FILTER_SAMPLES];float4 size = _ShadowAtlasSize.yyxx;DIRECTIONAL_FILTER_SETUP(size, positionSTS.xy, weights, positions);float shadow = 0;for (int i = 0; i < DIRECTIONAL_FILTER_SAMPLES; i++) {shadow += weights[i] * SampleDirectionalShadowAtlas(float3(positions[i].xy, positionSTS.z));}return shadow;#else
在GetDirectionalShadowAttenuation中调用这个新函数,而不是直接调用SampleDirectionalShadowAttenuation。
float shadow = FilterDirectionalShadow(positionSTS);return lerp(1.0, shadow, directional.strength);
PCF 2x2, 3x3, 5x5, and 7x7.
增大滤波器尺寸会使阴影更平滑,但也会导致痤疮伪影再次出现。我们必须相应增加法线偏移量来匹配滤波器尺寸。我们可以在SetCascadeData中通过将纹素尺寸乘以(1+滤波器模式值)来自动实现这一点。
void SetCascadeData (int index, Vector4 cullingSphere, float tileSize) {float texelSize = 2f * cullingSphere.w / tileSize;float filterSize = texelSize * ((float)settings.directional.filter + 1f);…1f / cullingSphere.w,filterSize * 1.4142136f);}
除此之外,增大采样区域还意味着我们最终可能会采样到级联剔除球体之外。我们可以通过在平方之前将球体半径减去滤波器尺寸来避免这种情况。
cullingSphere.w -= filterSize;cullingSphere.w *= cullingSphere.w;
PCF 5x5 and 7x7 with scaled bias.
这再次解决了阴影痤疮问题,但增大的滤波器尺寸加剧了应用法线偏移的缺点,并使我们之前看到的墙壁阴影问题更加严重。需要一些斜率缩放偏移或更大的图集尺寸来减轻这些伪影。
Shouldn't we also decrease the radius to account for the normal bias? 难道我们不应该同时减小半径来适应法线偏移吗?
法线偏移是按光源定义的,因此无法按级联设置。幸运的是,只有当偏移会将阴影采样推到所选级联之外时才会成为问题。这种情况通常只发生在背离相机的法线上,这意味着几乎总是限于不可见的表面。如果偏移确实导致问题,你可以通过某个可配置因子来增加半径的减小量。
4.7 Blending Cascades 级联混合
更柔和的阴影看起来更好,但它们也使得级联之间的突然过渡更加明显。
Hard cascade transitions; PCF 7x7 硬级联过渡;PCF 7x7
我们可以通过在级联之间添加过渡区域来混合两者,使过渡不那么明显(尽管不能完全隐藏)。我们已经有一个可用于此目的的级联淡入淡出因子。
首先,在Shadows的ShadowData中添加一个级联混合值,我们将使用它在相邻级联之间进行插值。
struct ShadowData {int cascadeIndex;float cascadeBlend;float strength;
};
首先在GetShadowData中将混合值初始化为1,表示所选级联处于完全强度。然后在循环中找到级联时始终计算淡出因子。如果处于最后一个级联,则像之前那样将其应用于强度值;否则将其用于混合值。
data.cascadeBlend = 1.0;data.strength = FadedShadowStrength(surfaceWS.depth, _ShadowDistanceFade.x, _ShadowDistanceFade.y);int i;for (i = 0; i < _CascadeCount; i++) {float4 sphere = _CascadeCullingSpheres[i];float distanceSqr = DistanceSquared(surfaceWS.position, sphere.xyz);if (distanceSqr < sphere.w) {float fade = FadedShadowStrength(distanceSqr, _CascadeData[i].x, _ShadowDistanceFade.z);if (i == _CascadeCount - 1) {data.strength *= fade;}else {data.cascadeBlend = fade;}break;}}
现在在GetDirectionalShadowAttenuation中检查,在获取第一个阴影值后级联混合值是否小于1。如果是,说明我们处于过渡区域,必须同时从下一个级联采样并在两个值之间进行插值。
float shadow = FilterDirectionalShadow(positionSTS);if (global.cascadeBlend < 1.0) {normalBias = surfaceWS.normal *(directional.normalBias * _CascadeData[global.cascadeIndex + 1].y);positionSTS = mul(_DirectionalShadowMatrices[directional.tileIndex + 1],float4(surfaceWS.position + normalBias, 1.0)).xyz;shadow = lerp(FilterDirectionalShadow(positionSTS), shadow, global.cascadeBlend);}return lerp(1.0, shadow, directional.strength);
Soft cascade transitions 柔和的级联过渡
请注意,级联淡出比例适用于每个级联的整个半径,而不仅仅是其可见部分。因此请确保该比例不会一直延伸到较低的级联。通常这不是问题,因为你会希望保持过渡区域较小。
4.8 Dithered Transition 抖动过渡
虽然在级联之间进行混合看起来效果更好,但这也会使我们在混合区域中采样阴影贴图的次数增加一倍。另一种方法是基于抖动模式始终从一个级联采样。虽然效果不如前者,但性能开销要小得多,尤其是在使用大滤波器时。
在Directional中添加级联混合模式选项,支持硬过渡、软过渡或抖动方法。
public enum CascadeBlendMode {Hard, Soft, Dither}public CascadeBlendMode cascadeBlend;}public Directional directional = new Directional {…cascadeFade = 0.1f,cascadeBlend = Directional.CascadeBlendMode.Hard};
Cascade blend mode.
在Shadows中添加用于软混合和抖动级联混合关键字的静态数组
static string[] cascadeBlendKeywords = {"_CASCADE_BLEND_SOFT","_CASCADE_BLEND_DITHER"};
调整SetKeywords方法,使其适用于任意关键字数组和索引,同时也要设置级联混合关键字。
void RenderDirectionalShadows () {SetKeywords(directionalFilterKeywords, (int)settings.directional.filter - 1);SetKeywords(cascadeBlendKeywords, (int)settings.directional.cascadeBlend - 1);buffer.SetGlobalVector(shadowAtlasSizeId, new Vector4(atlasSize, 1f / atlasSize));buffer.EndSample(bufferName);ExecuteBuffer();}void SetKeywords (string[] keywords, int enabledIndex) {//int enabledIndex = (int)settings.directional.filter - 1;for (int i = 0; i < keywords.Length; i++) {if (i == enabledIndex) {buffer.EnableShaderKeyword(keywords[i]);}else {buffer.DisableShaderKeyword(keywords[i]);}}}
将所需的多重编译指令添加到CustomLit通道中
#pragma multi_compile _ _CASCADE_BLEND_SOFT _CASCADE_BLEND_DITHER#pragma multi_compile_instancing
要实现抖动效果,我们需要一个抖动浮点值,可以将其添加到Surface中
struct Surface {…float dither;
};
在LitPassFragment中有多种生成抖动值的方法。最简单的是使用Core RP库中的InterleavedGradientNoise函数,它根据屏幕空间XY位置生成旋转的平铺抖动图案。在片段函数中,这等同于裁剪空间的XY位置。它还需要第二个参数用于动画效果,我们不需要这个功能,可以保留为零
surface.smoothness =UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Smoothness);surface.dither = InterleavedGradientNoise(input.positionCS.xy, 0);
在GetShadowData中设置级联索引之前,当未使用软混合时将级联混合值设为零。这样就能从这些着色器变体中消除整个分支。
if (i == _CascadeCount) {data.strength = 0.0;}#if !defined(_CASCADE_BLEND_SOFT)data.cascadeBlend = 1.0;#endifdata.cascadeIndex = i;
当使用抖动混合时,如果我们不在最后一个级联中,且混合值小于抖动值,则跳转到下一个级联
if (i == _CascadeCount) {data.strength = 0.0;}#if defined(_CASCADE_BLEND_DITHER)else if (data.cascadeBlend < surfaceWS.dither) {i += 1;}#endif#if !defined(_CASCADE_BLEND_SOFT)data.cascadeBlend = 1.0;#endif
Dithered cascades 抖动级联
抖动混合的可接受程度取决于我们渲染帧的分辨率。如果使用了会模糊最终结果的后期效果(例如结合时间性抗锯齿和动画抖动图案),那么它会相当有效
Dithered zoomed in 抖动放大视图
4.9 Culling Bias 剔除偏移
使用级联阴影贴图的一个缺点是,每个光源的同一阴影投射物会被多次渲染。如果能保证较小级联始终覆盖这些投射物的结果,那么尝试从较大级联中剔除部分阴影投射物是有意义的。Unity通过将分割数据的shadowCascadeBlendCullingFactor设置为1来实现这一点。在RenderDirectionalShadows中将其应用到阴影设置之前进行此操作。
splitData.shadowCascadeBlendCullingFactor = 1f;shadowSettings.splitData = splitData;
Culling bias 0 and 1
该值是用于调制执行剔除的前一级联半径的系数。Unity在剔除时相当保守,但我们应该将其减去级联淡出范围并额外减少一点,以确保过渡区域的阴影投射物永远不会被剔除。因此,我们使用0.8减去淡出范围,最小值为零。如果你在级联过渡处看到阴影出现空洞,则必须进一步减小该值
float cullingFactor =Mathf.Max(0f, 0.8f - settings.directional.cascadeFade);for (int i = 0; i < cascadeCount; i++) {…splitData.shadowCascadeBlendCullingFactor = cullingFactor;…}
5. Transparency 透明度
我们将在本教程结束时讨论透明阴影投射物。无论是裁剪、淡出还是透明材质,都能像不透明材质一样接收阴影,但目前只有裁剪材质能正确投射阴影。透明物体的行为就像它们是实心阴影投射物一样
Clipped and transparent with shadows. 带阴影的裁剪和透明效果
5.1 Shadow Modes 阴影模式
我们有几种修改阴影投射物的方法。由于涉及写入深度缓冲区,我们的阴影是二元的——要么存在要么不存在——但这仍然提供了一定的灵活性。阴影可以开启并完全实心、被裁剪、抖动或完全关闭。我们可以独立于其他材质属性来实现这一点,以支持最大灵活性。因此,让我们为此添加一个单独的_Shadows着色器属性。我们可以使用KeywordEnum属性为其创建关键字下拉菜单,默认开启阴影
[KeywordEnum(On, Clip, Dither, Off)] _Shadows ("Shadows", Float) = 0
Shadows enabled.
为这些模式添加一个着色器特性,替换现有的 _CLIPPING 特性。我们只需要三个变体:无关键字表示开启和关闭,_SHADOWS_CLIP 表示裁剪,_SHADOWS_DITHER 表示抖动
//#pragma shader_feature _CLIPPING#pragma shader_feature _ _SHADOWS_CLIP _SHADOWS_DITHER
在CustomShaderGUI中为阴影创建一个设置器属性
enum ShadowMode {On, Clip, Dither, Off}ShadowMode Shadows {set {if (SetProperty("_Shadows", (float)value)) {SetKeyword("_SHADOWS_CLIP", value == ShadowMode.Clip);SetKeyword("_SHADOWS_DITHER", value == ShadowMode.Dither);}}}
然后在预设方法中适当设置阴影:不透明材质设为开启,裁剪材质设为裁剪,淡出和透明材质都使用抖动
5.2 Clipped Shadows 裁剪阴影
在ShadowCasterPassFragment中,将对_CLIPPED的检查替换为对_SHADOWS_CLIP的检查
#if defined(_SHADOWS_CLIP)clip(base.a - UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Cutoff));#endif
现在可以为透明材质设置裁剪阴影,这可能适用于那些部分区域几乎完全不透明或透明但需要alpha混合的表面
Transparent with clipped shadows 带裁剪阴影的透明效果
请注意,裁剪阴影不如实心阴影稳定,因为当视角移动时阴影矩阵会发生变化,导致片段产生轻微偏移。这可能导致阴影贴图的某个纹素突然从裁剪状态切换到未裁剪状态
5.3 Dithered Shadows 抖动阴影
抖动阴影的工作原理与裁剪阴影类似,只是判断标准不同。这种情况下,我们从表面alpha值中减去一个抖动值,并据此进行裁剪。我们可以再次使用InterleavedGradientNoise函数
#if defined(_SHADOWS_CLIP)clip(base.a - UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Cutoff));#elif defined(_SHADOWS_DITHER)float dither = InterleavedGradientNoise(input.positionCS.xy, 0);clip(base.a - dither);#endif
Dithered shadows 抖动阴影
抖动可用于模拟半透明阴影投射物,但这是一种相当粗略的方法。硬抖动阴影看起来效果不佳,但在使用较大的PCF滤波器时可能会显得可接受
Dithered with PCF7x7
由于每个纹素的抖动图案是固定的,重叠的半透明阴影投射物不会投射出组合的更深阴影。效果取决于最不透明的阴影投射物。此外,由于生成的图案具有噪点,当阴影矩阵变化时会产生更多的时间伪影,可能导致阴影出现颤动。这种方法对于具有固定投影的其他光源类型效果更好(只要物体不移动)。对于半透明物体,通常更实用的做法是使用裁剪阴影或完全不使用阴影
5.4 No Shadows 无阴影
可以通过调整物体MeshRenderer组件的"投射阴影"设置来禁用每个物体的阴影投射。但是,如果你想对使用相同材质的所有物体禁用阴影,这种方法就不实用了,因此我们还将支持按材质禁用阴影。我们通过禁用材质的ShadowCaster通道来实现这一点。
在CustomShaderGUI中添加SetShadowCasterPass方法,首先检查_Shadows着色器属性是否存在。如果存在,再通过其hasMixedValue属性检查所有选中的材质是否设置为相同的模式。如果没有模式或模式不统一则中止。否则,通过调用SetShaderPassEnabled方法(参数为通道名称和启用状态)来对所有材质启用或禁用ShadowCaster通道
void SetShadowCasterPass () {MaterialProperty shadows = FindProperty("_Shadows", properties, false);if (shadows == null || shadows.hasMixedValue) {return;}bool enabled = shadows.floatValue < (float)ShadowMode.Off;foreach (Material m in materials) {m.SetShaderPassEnabled("ShadowCaster", enabled);}}
确保通道正确设置的最简单方法是,当通过GUI更改材质时始终调用SetShadowCasterPass。我们可以在OnGUI开始时调用EditorGUI.BeginChangeCheck,在其结束时调用EditorGUI.EndChangeCheck。后者会返回自开始检查以来是否有任何更改。如果有,就设置阴影投射器通道
public override void OnGUI (MaterialEditor materialEditor, MaterialProperty[] properties) {EditorGUI.BeginChangeCheck();…if (EditorGUI.EndChangeCheck()) {SetShadowCasterPass();}}
Not casting shadows 不投射阴影
5.5 Unlit Shadow Casters 无光照阴影投射物
虽然无光照材质不受光照影响,但您可能希望它们投射阴影。我们可以通过简单地将ShadowCaster通道从Lit着色器复制到Unlit着色器来实现这一点
Unlit but casting shadows. 无光照但投射阴影
5.6 Receiving Shadows
最后,我们也可以让受光照的表面忽略阴影,这可能对全息图之类的东西很有用,或者仅仅是为了艺术目的。为此在 Lit 中添加一个 _RECEIVE_SHADOWS 关键字切换属性
[Toggle(_RECEIVE_SHADOWS)] _ReceiveShadows ("Receive Shadows", Float) = 1
以及在 CustomLit 通道中添加相应的着色器特性
#pragma shader_feature _RECEIVE_SHADOWS
Receiving shadows
我们只需要在定义关键字时,在 GetDirectionalShadowAttenuation 中将阴影衰减强制设为1即可
float GetDirectionalShadowAttenuation (…) {#if !defined(_RECEIVE_SHADOWS)return 1.0;#endif…
}
Casting but not receiving shadows 投射但不接收阴影
The next tutorial is Baked Light. 下一章教程是烘焙光照。