自定义管线:方向阴影 (翻译四)
渲染阴影
当绘制物体时,表面和光照信息就足以计算光照了。但是两者之间可能有东西阻挡光线,在我们正在绘制的表面上投下阴影。为了使阴影成为可能,我们必须以某种方式让着色器知道阴影投射物体。有多种技术可以做到这一点。最常见的方法是生成一个阴影贴图,存储光线从光源出发在击中表面之前可以传播多远。同一方向上更远的任何东西都不能被同一个光源照亮。Unity 的渲染管线使用这种方法,我们也一样。
阴影设置
在我们开始渲染阴影之前,我们首先需要就质量做出一些决定,具体来说是我们将渲染多远的阴影以及我们的阴影贴图有多大。
虽然我们可以渲染到相机能看到的距离那么远,但这需要大量的绘制和一个非常大的贴图来充分覆盖该区域,这几乎永远不实用。所以我们将引入一个最大阴影距离,最小值为零,默认设置为 100 个单位。创建一个新的可序列化 ShadowSettings 类来包含这个选项。这个类纯粹是配置选项的容器,所以我们给它一个 maxDistance 公共字段。
1
2
3
4
5
6
7
using UnityEngine;
[System.Serializable]
public class ShadowSettings {
[Min(0f)]
public float maxDistance = 100f;
}
对于贴图大小,我们将在 ShadowSettings 内部引入一个 TextureSize 枚举类型。使用它来定义允许的贴图大小,都是在 256—8192 范围内的 2 的幂。
1
2
3
4
public enum TextureSize {
_256 = 256, _512 = 512, _1024 = 1024,
_2048 = 2048, _4096 = 4096, _8192 = 8192
}
然后添加阴影贴图的大小字段,默认为 1024。我们将使用单个纹理包含多个阴影贴图,所以将其命名为 atlasSize。因为目前我们只支持方向光,所以在这个点我们只适用于方向阴影贴图。但我们将在未来支持其他光源类型,它们将获得自己的阴影设置。所以把 atlasSize 放到一个内部 Directional 结构中。这样我们就自动获得了检查器中的层次配置。
1
2
3
4
5
6
7
8
[System.Serializable]
public struct Directional {
public TextureSize atlasSize;
}
public Directional directional = new Directional {
atlasSize = TextureSize._1024
};
向 CustomRenderPipelineAsset 添加一个阴影设置字段。
1
2
[SerializeField]
ShadowSettings shadows = default;
在构造 CustomRenderPipeline 实例时传递这些设置。
1
2
3
4
5
protected override RenderPipeline CreatePipeline () {
return new CustomRenderPipeline(
useDynamicBatching, useGPUInstancing, useSRPBatcher, shadows
);
}
让它跟踪它们。
1
2
3
4
5
6
7
8
9
ShadowSettings shadowSettings;
public CustomRenderPipeline (
bool useDynamicBatching, bool useGPUInstancing, bool useSRPBatcher,
ShadowSettings shadowSettings
) {
this.shadowSettings = shadowSettings;
...
}
传递设置
从现在开始,当我们调用它的 Render 方法时,我们将这些设置传递给相机渲染器。这样添加运行时更改阴影设置的支持很容易,但我们不会在本教程中处理这个。
1
2
3
4
5
6
7
8
9
10
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 方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
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 中的设置,因为阴影距离是通过剔除参数设置的。
1
2
3
4
5
6
7
8
bool Cull (float maxShadowDistance) {
if (camera.TryGetCullingParameters(out ScriptableCullingParameters p)) {
p.shadowDistance = maxShadowDistance;
cullingResults = context.Cull(ref p);
return true;
}
return false;
}
渲染比相机能看到的更远的阴影没有意义,所以取最大阴影距离和相机远剪裁平面的最小值。
1
p.shadowDistance = Mathf.Min(maxShadowDistance, camera.farClipPlane);
为了让代码编译,我们还必须添加阴影设置参数给 Lighting.Setup,但我们暂时不会对它们做任何操作。
1
2
3
4
public void Setup (
ScriptableRenderContext context, CullingResults cullingResults,
ShadowSettings shadowSettings
) { ... }
Shadows 类
虽然阴影在逻辑上是光照的一部分,但它们相当复杂,所以让我们创建一个新的 Shadows 类专门处理它们。它开始是 Lighting 的一个剥离存根副本,有自己的缓冲区、上下文字段、剔除结果和设置字段,一个 Setup 方法来初始化字段,以及一个 ExecuteBuffer 方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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 之前调用它的 Setup 方法。
1
2
3
4
5
6
7
8
9
Shadows shadows = new Shadows();
public void Setup (...) {
this.cullingResults = cullingResults;
buffer.BeginSample(bufferName);
shadows.Setup(context, cullingResults, shadowSettings);
SetupLights();
...
}
带阴影的光
因为渲染阴影需要额外工作,它会降低帧率,所以我们将限制有多少阴影方向光,独立于有多少方向光受支持。为那个添加一个常量到 Shadows,最初只设置为一个。
1
const int maxShadowedDirectionalLightCount = 1;
我们不知道哪个可见光会获得阴影,所以我们必须跟踪它。除此之外,我们稍后还要跟踪每个阴影光的一些更多数据,所以让我们定义一个内部 ShadowedDirectionalLight 结构,目前只包含索引,并跟踪它们的数组。
1
2
3
4
5
6
struct ShadowedDirectionalLight {
public int visibleLightIndex;
}
ShadowedDirectionalLight[] ShadowedDirectionalLights =
new ShadowedDirectionalLight[maxShadowedDirectionalLightCount];
为了找出哪个光获得阴影,我们将添加一个公共 ReserveDirectionalShadows 方法,带有光和可见光索引参数。它的工作是为光的阴影贴图在阴影贴集中保留空间,并存储渲染它们所需的信息。
1
public void ReserveDirectionalShadows (Light light, int visibleLightIndex) {}
因为阴影光的数量有限,我们必须跟踪已经保留了多少。在 Setup 中将计数重置为零。然后在 ReserveDirectionalShadows 中检查我们是否还没有达到最大值。如果还有空间则存储光的可见索引并增加计数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int ShadowedDirectionalLightCount;
...
public void Setup (...) {
...
ShadowedDirectionalLightCount = 0;
}
public void ReserveDirectionalShadows (Light light, int visibleLightIndex) {
if (ShadowedDirectionalLightCount < maxShadowedDirectionalLightCount) {
ShadowedDirectionalLights[ShadowedDirectionalLightCount++] =
new ShadowedDirectionalLight {
visibleLightIndex = visibleLightIndex
};
}
}
但阴影应该只为有阴影的光保留。如果光的阴影模式设置为无或者它的阴影强度为零那么它没有阴影,应该被忽略。
1
2
3
4
if (
ShadowedDirectionalLightCount < maxShadowedDirectionalLightCount &&
light.shadows != LightShadows.None && light.shadowStrength > 0f
) { ... }
除此之外,一个可见光可能最终不影响任何投射阴影的物体,要么是因为配置为不投射,要么是因为光只影响最大阴影距离之外的物体。我们可以通过为剔除结果的可见光索引调用 GetShadowCasterBounds 来检查这个。它有一个第二个输出参数用于边界——我们不需要——并返回边界是否有效。如果不是那么这个光没有阴影需要渲染,应该被忽略。
1
2
3
4
5
if (
ShadowedDirectionalLightCount < maxShadowedDirectionalLightCount &&
light.shadows != LightShadows.None && light.shadowStrength > 0f &&
cullingResults.GetShadowCasterBounds(visibleLightIndex, out Bounds b)
) { ... }
现在我们可以在 Lighting.SetupDirectionalLight 中保留阴影。
1
2
3
4
5
void SetupDirectionalLight (int index, ref VisibleLight visibleLight) {
dirLightColors[index] = visibleLight.finalColor;
dirLightDirections[index] = -visibleLight.localToWorldMatrix.GetColumn(2);
shadows.ReserveDirectionalShadows(visibleLight.light, index);
}
创建阴影贴集
保留阴影之后我们需要渲染它们。我们在 Lighting.Render 中的 SetupLights 完成后做这个,通过调用一个新的 Shadows.Render 方法。
1
2
3
shadows.Setup(context, cullingResults, shadowSettings);
SetupLights();
shadows.Render();
Shadows.Render 方法将把方向阴影的渲染委托给另一个 RenderDirectionalShadows 方法,但只有当有任何阴影光时。
1
2
3
4
5
6
7
public void Render () {
if (ShadowedDirectionalLightCount > 0) {
RenderDirectionalShadows();
}
}
void RenderDirectionalShadows () {}
创建阴影贴图是通过将阴影投射物体绘制到纹理来完成的。我们将使用 _DirectionalShadowAtlas 来引用方向阴影贴集。从设置中检索贴图大小为整数,然后以纹理标识符为参数在命令缓冲区上调用 GetTemporaryRT,加上它宽度的高度像素数大小。
1
2
3
4
5
6
7
8
static int dirShadowAtlasId = Shader.PropertyToID("_DirectionalShadowAtlas");
...
void RenderDirectionalShadows () {
int atlasSize = (int)settings.directional.atlasSize;
buffer.GetTemporaryRT(dirShadowAtlasId, atlasSize, atlasSize);
}
这声明了一个方形渲染纹理,但默认是一个普通 ARGB 纹理。我们需要一个阴影贴图,我们通过添加三个更多参数到调用来指定。第一个是深度缓冲区的位数。我们要尽可能高,所以让我们使用 32。第二个是过滤模式,对此我们使用默认双线性过滤。第三个是渲染纹理类型,必须是 RenderTextureFormat.Shadowmap。这给了我们一个适合渲染阴影贴图的纹理,虽然确切格式取决于目标平台。
1
2
3
4
buffer.GetTemporaryRT(
dirShadowAtlasId, atlasSize, atlasSize,
32, FilterMode.Bilinear, RenderTextureFormat.Shadowmap
);
当我们获得一个临时渲染纹理时,我们也应该在完成后释放它。我们必须保持它直到我们用相机完成渲染,之后我们可以通过在缓冲区上调用 ReleaseTemporaryRT 并执行它来释放它。我们将通过在一个新的公共 Cleanup 方法中做这个来完成。
1
2
3
4
public void Cleanup () {
buffer.ReleaseTemporaryRT(dirShadowAtlasId);
ExecuteBuffer();
}
给 Lighting 一个公共 Cleanup 方法,它将调用转发给 Shadows。
1
2
3
public void Cleanup () {
shadows.Cleanup();
}
然后 CameraRenderer 可以在提交之前直接请求清理。
1
2
3
4
5
public void Render (...) {
...
lighting.Cleanup();
Submit();
}
我们只能释放一个纹理,如果我们首先声明了它,我们目前只在有方向阴影需要渲染时才做那个。显而易见的解决方案是只有当我们有阴影时才释放纹理。然而,不声明纹理将导致 WebGL 2.0 的问题,因为它绑定纹理和采样器在一起。当一个使用我们着色器的材质在纹理缺失时加载时它会失败,因为它会获得一个与阴影采样器不兼容的默认纹理。我们可以通过引入着色器关键字来生成省略阴影采样代码的着色器变体来避免这个。一个替代方法是当不需要阴影时获取一个 1×1 虚纹理,避免额外的着色器变体。让我们做那个。
1
2
3
4
5
6
7
8
9
10
11
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 作为第三个参数。
1
2
3
4
5
buffer.GetTemporaryRT(...);
buffer.SetRenderTarget(
dirShadowAtlasId,
RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store
);
一旦那个完成我们可以像清除相机目标一样使用 ClearRenderTarget,在这种情况下只关心深度缓冲区。通过执行缓冲区完成。如果你有至少一个阴影方向光活动那么你会看到阴影贴集的清除动作显示在帧调试器中。
1
2
3
4
5
6
buffer.SetRenderTarget(
dirShadowAtlasId,
RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store
);
buffer.ClearRenderTarget(true, false, Color.clear);
ExecuteBuffer();
阴影优先
因为我们在阴影贴集之前设置常规相机,我们最终在渲染常规几何体之前切换到阴影贴集,这不是我们想要的。我们应该在 CameraRenderer.Render 中调用 CameraRenderer.Setup 之前渲染阴影,这样常规渲染将不受影响。
1
2
3
4
//Setup();
lighting.Setup(context, cullingResults, shadowSettings);
Setup();
DrawVisibleGeometry(useDynamicBatching, useGPUInstancing);
我们可以通过在设置光照之前开始一个采样并在清除相机目标之前立即结束它来将阴影入口保留在相机内部的帧调试器中。
1
2
3
4
5
buffer.BeginSample(SampleName);
ExecuteBuffer();
lighting.Setup(context, cullingResults, shadowSettings);
buffer.EndSample(SampleName);
Setup();
渲染
要为单个光渲染阴影,我们将添加一个变体 RenderDirectionalShadows 方法到 Shadow,带有两个参数:第一个是阴影光索引,第二个是它在贴集中的瓦片的大小。然后在另一个 RenderDirectionalShadows 方法中为所有阴影光调用这个方法,被 BeginSample 和 EndSample 调用包裹。因为我们目前只支持一个阴影光,它的瓦片大小等于贴集大小。
1
2
3
4
5
6
7
8
9
10
11
12
13
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 结构值。我们可以通过使用剔除结果和适当的可见光索引调用它的构造方法来创建一个正确配置的,这是我们之前存储的。
1
2
3
4
5
void RenderDirectionalShadows (int index, int tileSize) {
ShadowedDirectionalLight light = ShadowedDirectionalLights[index];
var shadowSettings =
new ShadowDrawingSettings(cullingResults, light.visibleLightIndex);
}
Unity 2022 也要求我们提供一个额外参数来指示我们使用正交投影,虽然这个要求在 2023 又被移除了。
1
2
3
4
var shadowSettings = new ShadowDrawingSettings(
cullingResults, light.visibleLightIndex,
BatchCullingProjectionType.Orthographic
);
阴影贴图的想法是我们从光的角度渲染场景,只存储深度信息。结果告诉我们光线在击中某物之前传播多远。
然而,方向光假设为无限远,因此没有真正的位置。所以我们所做的替代是找出视图和投影矩阵,它们匹配光的方向并给我们一个裁剪空间立方体,它与包含光阴影的相机可见区域重叠。与其自己想出这个,我们可以使用剔除结果的 ComputeDirectionalShadowMatricesAndCullingPrimitives 方法为我们做这个,传递它九个参数。
第一个参数是可见光索引。接下来三个参数是两个整数和一个 Vector3,它们控制阴影cascaded。我们稍后将处理cascaded,所以现在使用零、一和零向量。之后是纹理大小,对此我们需要使用瓦片大小。第六个参数是阴影近剪裁平面,我们将忽略并现在设置为零。
那些是输入参数,剩余三个是输出参数。第一个是视图矩阵,然后是投影矩阵,最后一个参数是 ShadowSplitData 结构。
1
2
3
4
5
6
7
var shadowSettings = ...;
cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(
light.visibleLightIndex, 0, 1, Vector3.zero, tileSize, 0f,
out Matrix4x4 viewMatrix, out Matrix4x4 projectionMatrix,
out ShadowSplitData splitData
);
分割数据包含关于如何剔除阴影投射物体的信息,我们必须把它复制到阴影设置。我们必须通过在缓冲区上调用 SetViewProjectionMatrices 来应用视图和投影矩阵。
1
2
3
cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(...);
shadowSettings.splitData = splitData;
buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);
我们最终通过执行缓冲区然后在上下文上调用 DrawShadows 来调度阴影投射体的绘制,阴影设置通过引用传递给它们。
1
2
3
4
shadowSettings.splitData = splitData;
buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);
ExecuteBuffer();
context.DrawShadows(ref shadowSettings);
Shadow Caster Pass
在这个点阴影投射体应该被渲染,但贴集保持空白。这是因为 DrawShadows 只渲染材质有 ShadowCaster 通道的物体。所以给我们的 Lit 着色器添加第二个 Pass 块,设置它的光照模式为 ShadowCaster。使用相同的目标级别,给它实例化支持,加上 _CLIPPING 着色器特性。然后让它使用特殊阴影投射体函数,我们将在一个新的 ShadowCasterPass HLSL 文件中定义它们。也因为我们要只写深度,通过在 HLSL 程序之前添加 ColorMask 0 来禁用写颜色数据。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Pass {
Tags {
"LightMode" = "CustomLit"
}
...
}
Pass {
Tags {
"LightMode" = "ShadowCaster"
}
ColorMask 0
HLSLPROGRAM
#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。它唯一做的是可能剪裁片段。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#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
我们现在能够渲染阴影投射体。我创建了一个简单的测试场景,包含一些在一个平面上的不透明物体,有一个以全强度启用阴影的方向光来尝试它。光被设置为使用硬阴影还是软阴影没关系。
阴影还没有影响最终渲染的图像,但我们已经可以通过帧调试器看到被渲染到阴影贴集中的东西了。它通常可视化为单色纹理,随着距离增加从白色到黑色,但当使用 OpenGL 时是红色并向另一个方向。
当最大阴影距离设置为 100 时,我们最终得到所有东西只被渲染到纹理的一小部分。减少最大距离有效地使阴影贴图放大到相机前面的东西。
注意阴影投射体是用正交投影渲染的,因为我们为方向光渲染。
多个光
我们可以有最多四个方向光,所以让我们也支持最多四个阴影方向光。
1
const int maxShadowedDirectionalLightCount = 4;
作为一个快速测试我使用了四个等效的方向光,除了我把它们的 Y 旋转调整了 90° 增量。
虽然我们最终正确为所有光渲染了阴影投射体,但它们是叠加的,因为我们对每个光渲染到整个贴集。我们必须分割我们的贴集,这样我们可以给每个光自己的瓦片来渲染。
我们支持最多四个阴影光,我们将在我们的方形贴集中给每个光一个方形瓦片。所以如果我们最终有多于一个阴影光我们必须将贴集分割为四个瓦片,通过将瓦片大小减半。在 Shadows.RenderDirectionalShadows 中确定分割数量和瓦片大小并每光传递给另一个方法。
1
2
3
4
5
6
7
8
9
10
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。
1
2
3
void SetTileViewport (int index, int split) {
Vector2 offset = new Vector2(index % split, index / split);
}
然后在缓冲区上以一个 Rect 调用 SetViewPort,带有偏移缩放瓦片大小,瓦片大小应该立即成为一个浮点数。
1
2
3
4
5
6
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。
1
2
SetTileViewport(index, split, tileSize);
buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);
采样阴影
现在我们渲染阴影投射体,但这还不影响最终图像。为了让阴影显示,我们必须在 CustomLit 通道中采样阴影贴图并使用它来确定一个表面片段是否被阴影。
阴影矩阵
对于每个片段我们必须从阴影贴集中的适当瓦片采样深度信息。所以我们必须找到一个给世界空间位置的阴影纹理坐标。我们将通过为每个阴影方向光创建一个阴影变换矩阵并把它们发送到 GPU 使这成为可能。为这个给 Shadows 添加一个 _DirectionalShadowMatrices 着色器属性标识符和静态矩阵数组。
1
2
3
4
5
6
static int
dirShadowAtlasId = Shader.PropertyToID("_DirectionalShadowAtlas"),
dirShadowMatricesId = Shader.PropertyToID("_DirectionalShadowMatrices");
static Matrix4x4[]
dirShadowMatrices = new Matrix4x4[maxShadowedDirectionalLightCount];
我们可以在 RenderDirectionalShadows 中通过乘以光的阴影投影矩阵和视图矩阵来创建从世界空间到光空间的转换矩阵。
1
2
3
4
5
6
7
void RenderDirectionalShadows (int index, int split, int tileSize) {
...
SetTileViewport(index, split, tileSize);
dirShadowMatrices[index] = projectionMatrix * viewMatrix;
buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);
...
}
然后一旦所有阴影光被渲染,通过在缓冲区上调用 SetGlobalMatrixArray 把矩阵发送到 GPU。
1
2
3
4
5
6
void RenderDirectionalShadows () {
...
buffer.SetGlobalMatrixArray(dirShadowMatricesId, dirShadowMatrices);
buffer.EndSample(bufferName);
ExecuteBuffer();
}
然而,这忽略了我们正在使用阴影贴集的事实。让我们创建一个 ConvertToAtlasMatrix 方法,它接受一个光矩阵、瓦片偏移和分割,并返回一个从世界空间转换到阴影瓦片空间的矩阵。
1
2
3
Matrix4x4 ConvertToAtlasMatrix (Matrix4x4 m, Vector2 offset, int split) {
return m;
}
我们已经在 SetTileViewport 中计算瓦片偏移,所以让它返回那个。
1
2
3
4
Vector2 SetTileViewport (int index, int split, float tileSize) {
...
return offset;
}
然后调整 RenderDirectionalShadows 使它调用 ConvertToAtlasMatrix。
1
2
3
4
5
//SetTileViewport(index, split, tileSize);
dirShadowMatrices[index] = ConvertToAtlasMatrix(
projectionMatrix * viewMatrix,
SetTileViewport(index, split, tileSize), split
);
在 ConvertToAtlasMatrix 中我们应该做的第一件事是如果使用反转 Z 缓冲区则否定 Z 维度。我们可以通过 SystemInfo.usesReversedZBuffer 检查这个。
1
2
3
4
5
6
7
8
9
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;
}
其次,裁剪空间定义在内部一个立方体中,坐标从 −1 到 1,零在中心。但纹理坐标和深度从零到一。我们可以通过缩放和偏移 XYZ 维度一半来把这个转换烤入矩阵中。我们可以用矩阵乘法做这个,但它会导致很多与零的乘法和不必要的加法。所以让我们直接调整矩阵。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;
最后,我们必须应用瓦片偏移和缩放。我们可以再次直接做这个以避免很多不必要的计算。
1
2
3
4
5
6
7
8
9
10
11
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;
每光存储阴影数据
要为一个光采样阴影我们需要知道它在阴影贴集中的瓦片索引,如果它有一个。这是必须每个光存储的东西,所以让 ReserveDirectionalShadows 返回所需数据。我们将提供两个值:阴影强度和阴影瓦片偏移,打包在一个 Vector2 中。如果光不获得阴影那么结果是零向量。
1
2
3
4
5
6
7
8
9
10
11
12
public Vector2 ReserveDirectionalShadows (...) {
if (...) {
ShadowedDirectionalLights[ShadowedDirectionalLightCount] =
new ShadowedDirectionalLight {
visibleLightIndex = visibleLightIndex
};
return new Vector2(
light.shadowStrength, ShadowedDirectionalLightCount++
);
}
return Vector2.zero;
}
让 Lighting 通过一个 _DirectionalLightShadowData 向量数组使这些数据可用于着色器。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
static int
dirLightCountId = 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 缓冲区中。
1
2
3
4
5
6
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
Shadows HLSL 文件
我们还将创建一个专用的 Shadows HLSL 文件用于阴影采样。为方向光定义相同的最大阴影方向光计数,加上 _DirectionalShadowAtlas 纹理,加上 _CustomShadows 缓冲区中的 _DirectionalShadowMatrices 数组。
1
2
3
4
5
6
7
8
9
10
11
12
#ifndef CUSTOM_SHADOWS_INCLUDED
#define CUSTOM_SHADOWS_INCLUDED
#define MAX_SHADOWED_DIRECTIONAL_LIGHT_COUNT 4
TEXTURE2D(_DirectionalShadowAtlas);
SAMPLER(sampler_DirectionalShadowAtlas);
CBUFFER_START(_CustomShadows)
float4x4 _DirectionalShadowMatrices[MAX_SHADOWED_DIRECTIONAL_LIGHT_COUNT];
CBUFFER_END
#endif
因为贴集不是普通纹理,让我们通过 TEXTURE2D_SHADOW 宏观定义它以更清晰,即使这对我们支持的平台没有区别。我们将使用一个特殊的 SAMPLER_CMP 宏观来定义采样器状态,因为这确实为阴影贴图定义了一个不同的采样方式,因为普通双线性过滤对深度数据没有意义。
1
2
TEXTURE2D_SHADOW(_DirectionalShadowAtlas);
SAMPLER_CMP(sampler_DirectionalShadowAtlas);
事实上,只有一种适当的方式采样阴影贴图,所以我们可以定义一个明确的采样器状态而不是依赖 Unity 为我们的渲染纹理推导的那个。采样器状态可以通过创建一个名称中有特定词的内联来定义。我们可以使用 sampler_linear_clamp_compare。让我们也为它定义一个简写 SHADOW_SAMPLER 宏观。
1
2
3
4
TEXTURE2D_SHADOW(_DirectionalShadowAtlas);
#define SHADOW_SAMPLER sampler_linear_clamp_compare
SAMPLER_CMP(SHADOW_SAMPLER);
在 LitPass 中的 Light 之前包含 Shadows。
1
2
3
#include "../ShaderLibrary/Surface.hlsl"
#include "../ShaderLibrary/Shadows.hlsl"
#include "../ShaderLibrary/Light.hlsl"
采样阴影
要采样阴影我们需要知道每光阴影数据,所以让我们在 Shadows 中定义一个结构,专门用于方向光。它包含强度和瓦片偏移,但 Shadows 中的代码不知道它存储在哪里。
1
2
3
4
struct DirectionalShadowData {
float strength;
int tileIndex;
};
我们还需要知道表面位置,所以把它添加到 Surface 结构。
1
2
3
4
struct Surface {
float3 position;
...
};
并在 LitPassFragment 中分配它。
1
2
3
Surface surface;
surface.position = input.positionWS;
surface.normal = normalize(input.normalWS);
给 Shadows 添加一个 SampleDirectionalShadowAtlas 函数来通过 SAMPLE_TEXTURE2D_SHADOW 宏观采样阴影贴集,传递给它贴图、阴影采样器和阴影纹理空间中的位置,这是一个相应的参数。
1
2
3
4
5
float SampleDirectionalShadowAtlas (float3 positionSTS) {
return SAMPLE_TEXTURE2D_SHADOW(
_DirectionalShadowAtlas, SHADOW_SAMPLER, positionSTS
);
}
然后添加一个 GetDirectionalShadowAttenuation 函数,它返回阴影衰减,给定方向阴影数据和应该在世界空间定义的表面。它使用瓦片偏移来检索正确的矩阵,把表面位置转换到阴影瓦片空间,然后采样贴集。
1
2
3
4
5
6
7
8
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 范围中的值,称为衰减因子。如果片段被完全阴影我们得到零,当完全没有被阴影我们得到一。之间的值表示片段被部分阴影。
除此之外,光的阴影强度可以被降低,要么为了艺术原因要么表示半透明表面的阴影。当强度被降低为零那么衰减不受阴影影响,应该是一。所以最终衰减是基于强度在一和采样衰减之间线性插值找到的。
1
return lerp(1.0, shadow, data.strength);
但当阴影强度为零则不需要采样阴影,因为它们没有影响甚至没有被渲染。在那个情况我们有一个无阴影光并应该总返回一。
1
2
3
4
5
6
float GetDirectionalShadowAttenuation (DirectionalShadowData data, Surface surfaceWS) {
if (data.strength <= 0.0) {
return 1.0;
}
...
}
衰减光
我们将把光的衰减存储在 Light 结构中。
1
2
3
4
5
struct Light {
float3 color;
float3 direction;
float attenuation;
};
给 Light 添加一个获取方向阴影数据的函数。
1
2
3
4
5
6
DirectionalShadowData GetDirectionalShadowData (int lightIndex) {
DirectionalShadowData data;
data.strength = _DirectionalLightShadowData[lightIndex].x;
data.tileIndex = _DirectionalLightShadowData[lightIndex].y;
return data;
}
然后给 GetDirectionalLight 添加一个世界空间表面参数,让它检索方向阴影数据并使用 GetDirectionalShadowAttenuation 来设置光的衰减。
1
2
3
4
5
6
7
8
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 不关心光和表面的空间,只要它们匹配。
1
2
3
4
5
6
7
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;
}
让阴影工作的最后一步是将衰减因子考虑到光的强度中。
1
2
3
4
5
float3 IncomingLight (Surface surface, Light light) {
return
saturate(dot(surface.normal, light.direction) * light.attenuation) *
light.color;
}
我们最终得到了阴影,但它们看起来很糟糕。不应该被阴影的表面最终被阴影伪影覆盖,形成像素化带。这些是由错误自阴影引起的,由于阴影贴图的有限分辨率。使用不同的分辨率改变伪影模式但不会消除它们。表面最终部分阴影自己,但我们稍后将处理这个问题。伪影使看到阴影贴图覆盖的区域变得容易,所以让我们暂时保留它们。
例如,我们可以看到阴影贴图只覆盖可见区域的一部分,由最大阴影距离控制。改变最大值增长或收缩区域。阴影贴图与光方向对齐,不与相机。一些阴影在最大距离之外可见,但一些缺失,并且当阴影在贴图边缘之外采样时变得奇怪。如果只有一个阴影光活动那么结果被钳位,否则采样可以穿越瓦片边界,一个光最终使用来自另一个光的阴影。
我们稍后将正确在最大距离切除阴影,但目前这些无效阴影保持可见。
cascaded阴影贴图
因为方向光影响最大阴影距离内的所有东西,它们的阴影贴图最终覆盖一个大区域。因为阴影贴图使用正交投影,阴影贴图中的每个纹素都有固定的世界空间大小。如果这个大小太大那么个别阴影纹素清晰可见,导致锯齿状阴影边缘和小阴影可以消失。这可以通过增加贴集大小来缓解,但只能达到一个点。
当使用透视相机时,更远的东西看起来更小。在某视觉距离一个阴影贴图纹素将映射到单个显示像素,这意味着阴影分辨率理论上是最佳的。更接近相机我们需要更高的阴影分辨率,而更远更低的分辨率就够了。这建议理想情况下我们使用一个变量阴影贴图分辨率,基于阴影接收器的视图距离。
cascaded阴影贴图是这个问题的解决方案。想法是阴影投射体被渲染不止一次,所以每个光在贴集中获得多个瓦片,称为cascaded。第一个cascaded只覆盖接近相机的小区域,随后的cascaded放大来用相同数量的纹素覆盖一个越来越大的区域。着色器然后为每个片段采样可用的最佳cascaded。
设置
Unity 的阴影代码支持每个方向光最多四个cascaded。到目前为止我们只使用一个cascaded,覆盖最大阴影距离内的所有东西。为了支持更多我们将向方向阴影设置添加一个cascaded计数滑块。虽然我们可以每个方向光使用不同数量,但为所有阴影方向光使用相同的最有意义。
每个cascaded覆盖阴影区域的一部分,直到最大阴影距离。我们将通过为前三个cascaded添加比例滑块来使确切部分可配置。最后一个cascaded总是覆盖整个范围所以不需要滑块。将cascaded计数设置默认为四,cascaded比例为 0.1、0.25 和 0.5。这些比例应该每cascaded增加,但我们不会在 UI 中强制这个。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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
};
ComputeDirectionalShadowMatricesAndCullingPrimitives 方法要求我们提供一个打包在 Vector3 中的比例,所以让我们向设置添加一个方便的属性以那种形式检索它们。
1
2
public Vector3 CascadeRatios =>
new Vector3(cascadeRatio1, cascadeRatio2, cascadeRatio3);
渲染cascaded
每个cascaded需要它自己的变换矩阵,所以 Shadows 的阴影矩阵数组大小必须被每个光最大cascaded数量乘,这是四。
1
2
3
4
5
6
const int maxShadowedDirectionalLightCount = 4, maxCascades = 4;
...
static Matrix4x4[]
dirShadowMatrices = new Matrix4x4[maxShadowedDirectionalLightCount * maxCascades];
在 Shadows 中也增加数组的大小。
1
2
3
4
5
6
7
8
9
#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 中乘以返回的瓦片偏移被配置的cascaded数量,因为每个方向光现在将声明多个连续瓦片。
1
2
3
4
return new Vector2(
light.shadowStrength,
settings.directional.cascadeCount * ShadowedDirectionalLightCount++
);
同样,使用的瓦片数量在 RenderDirectionalShadows 中被乘,这意味着我们最终可能总共十六个瓦片,需要分割为四。
1
2
3
int tiles = ShadowedDirectionalLightCount * settings.directional.cascadeCount;
int split = tiles <= 1 ? 1 : tiles <= 4 ? 2 : 4;
int tileSize = atlasSize / split;
为什么也不支持分割为三?
我们将自己限制为 2 的幂,我们应用于贴集大小的相同限制。这样整数除法总是可能的,否则我们可以得到错位问题。这意味着一些光配置不会使用所有可用瓦片,浪费纹理空间。如果这是一个问题那么你可以添加对不需要是方形的矩形贴集的支持。然而,你更可能被你可以渲染的瓦片数量而不是纹理空间限制。
现在 RenderDirectionalShadows 必须为每个cascaded绘制阴影。把从 ComputeDirectionalShadowMatricesAndCullingPrimitives 到并包括 DrawShadows 的代码放在每个配置cascaded的循环中。ComputeDirectionalShadowMatricesAndCullingPrimitives 的第二个参数现在成为cascaded索引,然后是cascaded数量和cascaded比例。也调整瓦片索引使它成为光的瓦片偏移加cascaded索引。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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);
}
}
剔除球体
Unity 通过为它创建一个剔除球体来确定每个cascaded覆盖的区域。因为阴影投影是正交和方形的,它们最终紧密拟合它们的剔除球体但也覆盖一些周围的空间。那就是为什么一些阴影可以在剔除区域之外可见。还有,光方向对球体不重要,所以所有方向光最终使用相同的剔除球体。
这些球体也需要来确定从哪个cascaded采样,所以我们必须把它们发送到 GPU。为cascaded计数添加一个标识符和一个cascaded剔除球体数组,加上一个球体数据的静态数组。它们被四分量定义,包含它们的 XYZ 位置加上 W 组件中的半径。
1
2
3
4
5
6
7
static int
dirShadowAtlasId = Shader.PropertyToID("_DirectionalShadowAtlas"),
dirShadowMatricesId = Shader.PropertyToID("_DirectionalShadowMatrices"),
cascadeCountId = Shader.PropertyToID("_CascadeCount"),
cascadeCullingSpheresId = Shader.PropertyToID("_CascadeCullingSpheres");
static Vector4[] cascadeCullingSpheres = new Vector4[maxCascades];
cascaded的剔除球体是 ComputeDirectionalShadowMatricesAndCullingPrimitives 输出的分割数据的一部分。在 RenderDirectionalShadows 的循环中把它赋值给球体数组。但我们只需要对第一个光做这个,因为所有光的cascaded是等效的。
1
2
3
4
5
6
7
8
for (int i = 0; i < cascadeCount; i++) {
cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(...);
shadowSettings.splitData = splitData;
if (index == 0) {
cascadeCullingSpheres[i] = splitData.cullingSphere;
}
...
}
我们需要着色器中的球体来检查表面片段是否位于它们内部,这可以通过比较从球体中心到它的半径平方距离来完成。所以让我们存储平方半径而不是,这样我们不必在着色器中计算它。
1
2
3
Vector4 cullingSphere = splitData.cullingSphere;
cullingSphere.w *= cullingSphere.w;
cascadeCullingSpheres[i] = cullingSphere;
在渲染cascaded后将cascaded计数和球体发送到 GPU。
1
2
3
4
5
6
7
8
9
10
void RenderDirectionalShadows () {
...
buffer.SetGlobalInt(cascadeCountId, settings.directional.cascadeCount);
buffer.SetGlobalVectorArray(
cascadeCullingSpheresId, cascadeCullingSpheres
);
buffer.SetGlobalMatrixArray(dirShadowMatricesId, dirShadowMatrices);
buffer.EndSample(bufferName);
ExecuteBuffer();
}
采样cascaded
向 Shadows 添加cascaded计数和剔除球体数组。
1
2
3
4
5
6
CBUFFER_START(_CustomShadows)
int _CascadeCount;
float4 _CascadeCullingSpheres[MAX_CASCADE_COUNT];
float4x4 _DirectionalShadowMatrices
[MAX_SHADOWED_DIRECTIONAL_LIGHT_COUNT * MAX_CASCADE_COUNT];
CBUFFER_END
cascaded索引是每片段确定的,不是每光。所以让我们引入一个全局 ShadowData 结构包含它。我们稍后将给它添加更多数据。也添加一个 GetShadowData 函数,它返回世界空间表面的阴影数据,最初cascaded索引总是设置为零。
1
2
3
4
5
6
7
8
9
struct ShadowData {
int cascadeIndex;
};
ShadowData GetShadowData (Surface surfaceWS) {
ShadowData data;
data.cascadeIndex = 0;
return data;
}
添加新数据作为参数到 GetDirectionalShadowData,所以它可以通过加cascaded索引到光的阴影瓦片偏移来选择正确的瓦片索引。
1
2
3
4
5
6
7
8
9
DirectionalShadowData GetDirectionalShadowData (
int lightIndex, ShadowData shadowData
) {
DirectionalShadowData data;
data.strength = _DirectionalLightShadowData[lightIndex].x;
data.tileIndex =
_DirectionalLightShadowData[lightIndex].y + shadowData.cascadeIndex;
return data;
}
也给 GetDirectionalLight 添加相同参数,这样它可以转发数据给 GetDirectionalShadowData。相应地重命名方向阴影数据变量。
1
2
3
4
5
6
7
Light GetDirectionalLight (int index, Surface surfaceWS, ShadowData shadowData) {
...
DirectionalShadowData dirShadowData =
GetDirectionalShadowData(index, shadowData);
light.attenuation = GetDirectionalShadowAttenuation(dirShadowData, surfaceWS);
return light;
}
在 GetLighting 中获取阴影数据并传递它。
1
2
3
4
5
6
7
8
9
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;
}
要选择正确的cascaded我们需要计算两点之间的平方距离。让我们给 Common 添加一个方便的函数。
1
2
3
float DistanceSquared(float3 pA, float3 pB) {
return dot(pA - pB, pA - pB);
}
在 GetShadowData 中循环遍历所有cascaded剔除球体直到我们找到一个包含表面位置的。一旦找到就跳出循环然后使用当前循环迭代器作为cascaded索引。这意味着如果片段位于所有球体之外,我们最终得到一个无效索引,但目前我们将忽略这个。
1
2
3
4
5
6
7
8
9
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;
我们现在得到阴影,纹素密度分布好得多。cascaded之间弯曲的过渡边界也由于自阴影伪影可见,虽然我们可以通过用cascaded索引除以四替换阴影衰减使它们更容易发现。
剔除阴影采样
如果我们最终在最后一个cascaded之后那么很可能没有有效阴影数据,我们应该根本不采样阴影。强制这个的一个简单方法是在 ShadowData 中添加一个强度字段,设置默认为一,如果在最后一个cascaded之后则设置为零。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 中将全局阴影强度考虑到方向阴影强度。这剔除了最后一个cascaded之外的所有阴影。
1
2
data.strength =
_DirectionalLightShadowData[lightIndex].x * shadowData.strength;
也在 GetDirectionalLight 中恢复正确的衰减。
最大距离
一些对最大阴影距离的实验将揭示一些阴影投射体突然消失而仍然位于最后一个cascaded的剔除球体内部。这发生是因为最外层剔除球体没有精确结束在配置的最大距离但稍微延伸到它之外。这个差异在小最大距离时最明显。
我们也可以在最大距离停止采样阴影来修复阴影的突然出现。要使这成为可能我们必须把最大距离发送到 GPU 中的 Shadows。
1
2
3
4
5
6
7
8
9
10
11
12
13
static int
...
cascadeCullingSpheresId = Shader.PropertyToID("_CascadeCullingSpheres"),
shadowDistanceId = Shader.PropertyToID("_ShadowDistance");
...
void RenderDirectionalShadows () {
...
buffer.SetGlobalFloat(shadowDistanceId, settings.maxDistance);
buffer.EndSample(bufferName);
ExecuteBuffer();
}
最大距离基于视图空间深度,不是到相机位置的距离。所以为了执行这个剔除我们需要知道表面的深度。为此给 Surface 添加一个字段。
1
2
3
4
5
6
7
struct Surface {
float3 position;
float3 normal;
float3 viewDirection;
float depth;
...
};
深度可以通过 TransformWorldToView 从世界空间转换到视图空间并取否定 Z 坐标在 LitPassFragment 中找到。因为这个转换只是相对于世界空间的旋转和偏移,深度在视图空间和世界空间中都是相同的。
1
2
surface.viewDirection = normalize(_WorldSpaceCameraPos - input.positionWS);
surface.depth = -TransformWorldToView(input.positionWS).z;
现在总是在 GetShadowData 中初始化强度为一,只有当表面深度小于最大距离时,否则设置为零。
1
2
3
4
5
6
7
8
9
10
11
12
CBUFFER_START(_CustomShadows)
...
float _ShadowDistance;
CBUFFER_END
...
ShadowData GetShadowData (Surface surfaceWS) {
ShadowData data;
data.strength = surfaceWS.depth < _ShadowDistance ? 1.0 : 0.0;
...
}
衰减阴影
突然在最大距离切除阴影可能非常明显,所以让我们通过线性衰减它们使过渡更平滑。衰减在最大之前一些距离开始,直到我们在最大达到零强度。我们可以使用函数 clamped 到 0–1,其中 $d$ 是表面深度,$m$ 是最大阴影距离,$f$ 是衰减范围,表示为最大距离的一部分。
\[\text{饱和度}( \frac{1- \dfrac{d}{m}}{f} )\] \[f = 0.1, 0.2, 和 0.5\]向阴影设置添加一个距离衰减滑块。因为衰减和最大值都用作除数它们不应该为零,所以设置它们的最小值为 0.001。
1
2
3
4
[Min(0.001f)]
public float maxDistance = 100f;
[Range(0.001f, 1f)]
public float distanceFade = 0.1f;
在 Shadows 中用距离值和衰减值都的标识符替换阴影距离标识符。
1
2
//shadowDistanceId = Shader.PropertyToID("_ShadowDistance");
shadowDistanceFadeId = Shader.PropertyToID("_ShadowDistanceFade");
当把它们作为向量的 XY 组件发送到 GPU 时,使用一除以值这样我们可以在着色器中避免除法,因为乘法更快。
1
2
3
4
5
buffer.SetGlobalFloat(shadowDistanceId, settings.maxDistance);
buffer.SetGlobalVector(
shadowDistanceFadeId,
new Vector4(1f / settings.maxDistance, 1f / settings.distanceFade)
);
调整 Shadows 中的 _CustomShadows 缓冲区来匹配。
1
2
//float _ShadowDistance;
float4 _ShadowDistanceFade;
现在我们可以使用 $(1-ds)f \text{ 饱和度 }$来计算衰减阴影强度,使用$\frac{1}{m}$作为缩放,使用 $s$作为新的褪色乘数$\dfrac{1}{f}$。为此创建一个 FadedShadowStrength 函数并在 GetShadowData 中使用它。
1
2
3
4
5
6
7
8
9
10
11
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
);
...
}
衰减cascaded
我们可以在最后一个cascaded的边缘也衰减阴影而不是切除它们,使用相同方法。为此添加一个cascaded衰减阴影设置滑块。
1
2
3
4
5
6
7
8
9
10
11
public struct Directional {
...
[Range(0.001f, 1f)]
public float cascadeFade;
}
public Directional directional = new Directional {
...
cascadeRatio3 = 0.5f,
cascadeFade = 0.1f
};
唯一的区别是我们对cascaded使用平方距离和半径而不是线性深度和最大。这意味着过渡变得非线性$\dfrac{1-\dfrac{d^2}{r^2}}{f}$,其中 $r$ 是剔除球体半径。区别不是很大,但为了保持配置的衰减比例相同,我们必须用 $1-(1-f)^2$ 替换 $f$。然后我们将它存储在阴影距离衰减向量的 $Z$ 组件中,再次反转。
1
2
3
4
5
6
7
float f = 1f - settings.directional.cascadeFade;
buffer.SetGlobalVector(
shadowDistanceFadeId, new Vector4(
1f / settings.maxDistance, 1f / settings.distanceFade,
1f / (1f - f * f)
)
);
要执行cascaded衰减,检查我们在循环中的最后一个cascaded同时仍在内部。如果是,为cascaded计算衰减阴影强度并把它因子到最终强度。
1
2
3
4
5
6
7
8
9
10
11
12
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;
}
}
阴影质量
现在我们有功能性的cascaded阴影贴图,让我们专注于改善阴影的质量。我们观察到的所有时间的伪影称为阴影波纹,这是由表面与光方向不完全对齐时的错误自阴影引起的。当表面更接近与光方向平行时,波纹变得更糟。
增加贴集大小减少阴影纹素的世界空间大小,所以波纹伪影变得更小。然而,伪影数量也增加,所以问题不能简单地通过增加贴集大小解决。
深度偏移
有各种方法可以缓解阴影波纹。最简单的是给阴影投射体的深度添加一个恒定偏移,把它们推离光使错误自阴影不再发生。添加这个技术最快的方法是在渲染时通过在 DrawShadows 之前在缓冲区上调用 SetGlobalDepthBias 来应用一个全局深度偏移,之后设置它为零。这是一个在裁剪空间应用的深度偏移,是一个非常小值的倍数,确切取决于用于阴影贴图的精确格式。我们可以通过使用一个大值比如 50000 来了解它如何工作。对斜率-缩放偏移也有第二个参数,但目前我们将保持它为零。
1
2
3
4
buffer.SetGlobalDepthBias(50000f, 0f);
ExecuteBuffer();
context.DrawShadows(ref shadowSettings);
buffer.SetGlobalDepthBias(0f, 0f);
恒定偏移简单但只能移除大部分被直射照亮的表面的伪影。移除所有波纹需要大得多的偏移,比如大一个数量级。
1
buffer.SetGlobalDepthBias(500000f, 0f);
然而,因为深度偏移把阴影投射体推离光,采样的阴影也在同一方向被移动。大到足以移除大多数波纹,但这种偏移虽然不会把阴影移得太远,却会使它们看起来与它们的投射体分离,导致称为 Peter-Panning 的视觉伪影。
替代方法是应用斜率-缩放偏移,这是通过给 SetGlobalDepthBias 的第二个参数使用非零值来完成的。这个值用于缩放 $X$ 和 $Y$ 维度上的绝对裁剪空间深度导数的最大值。所以对于直射照亮的表面它是零,当光在两个维度的至少一个中以 $45°$ 角度击中时它是 $1$,当表面法线和光方向的点积达到零时它接近无穷大。所以偏移在需要更多时自动增加,但没有上界。结果需要低得多的因子来消除波纹,比如 $3$ 而不是 $500000$。
1
buffer.SetGlobalDepthBias(0f, 3f);
斜率-缩放偏移有效但不直观。需要实验来找到一个可接受的结果,它用波纹换取彼得平移。所以让我们现在禁用它并寻找一个更直观和可预测的方法。
1
2
3
4
//buffer.SetGlobalDepthBias(0f, 3f);
ExecuteBuffer();
context.DrawShadows(ref shadowSettings);
//buffer.SetGlobalDepthBias(0f, 0f);
cascaded数据
因为波纹的大小取决于世界空间纹素大小,一个在所有情况下都工作的一致方法必须考虑这个。因为纹素大小每个cascaded变化,这意味着我们必须把更多cascaded数据发送到 GPU。为此给 Shadows 添加一个通用cascaded数据向量数组。
1
2
3
4
5
6
7
8
9
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。
1
2
3
4
buffer.SetGlobalVectorArray(
cascadeCullingSpheresId, cascadeCullingSpheres
);
buffer.SetGlobalVectorArray(cascadeDataId, cascadeData);
我们可以做的一件事是把这些向量的 X 组件中剔除球体半径的倒数放入。这样我们不必在着色器中执行这个除法。在一个新的 SetCascadeData 方法中做这个,同时存储剔除球体并在 RenderDirectionalShadows 中调用它。向它传递cascaded索引、剔除球体和作为浮点的瓦片大小。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;
}
添加cascaded数据到 Shadows 中的 _CustomShadows 缓冲区。
1
2
3
4
5
6
CBUFFER_START(_CustomShadows)
int _CascadeCount;
float4 _CascadeCullingSpheres[MAX_CASCADE_COUNT];
float4 _CascadeData[MAX_CASCADE_COUNT];
...
CBUFFER_END
并在 GetShadowData 中使用新的预计算倒数。
1
2
3
data.strength *= FadedShadowStrength(
distanceSqr, _CascadeData[i].x, _ShadowDistanceFade.z
);
法线偏移
错误自阴影发生是因为阴影投射体深度纹素覆盖多于一个片段,这导致投射体的体积从它的表面突出。所以如果我们足够缩小投射体这不应该再发生。然而,缩小阴影投射体会使阴影比它们应该的小,并可以引入不应该存在的孔。
我们也可以做相反:在采样阴影时膨胀表面。然后我们离表面采样一点点,刚好足够避免错误自阴影。这将调整阴影位置一点点,可能导致边缘错位并添加假阴影,但这些伪影远比彼得平移不明显。
我们可以通过为采样阴影的目的沿表面法线向量移动表面位置一点点来做这个。如果我们只考虑单维那么等于世界空间纹素大小的偏移应该足够。我们可以通过用瓦片大小除剔除球体直径在 SetCascadeData 中找到纹素大小。把它存储在cascaded数据向量的 Y 组件中。
1
2
3
4
5
6
7
8
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 缩放它。
1
texelSize * 1.4142136f
在着色器端,给 GetDirectionalShadowAttenuation 添加全局阴影数据参数。乘以表面法线与偏移来找到法线偏移并在计算阴影瓦片空间位置之前把它加到世界位置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 中传递额外数据给它。
1
2
light.attenuation =
GetDirectionalShadowAttenuation(dirShadowData, shadowData, surfaceWS);
可配置偏移
法线偏移移除了阴影波纹而不引入明显的新伪影,但它不能消除所有阴影问题。例如,有可见的阴影线在地板下的墙上不应该在那里。这不是自阴影,而是从墙突出的阴影影响下面的地板。添加一点斜率-缩放偏移可以处理那些,但没有完美的值。所以让我们使用它们现有的 Bias 滑块每个光配置它。给 Shadows 中的 ShadowedDirectionalLight 结构添加一个字段。
1
2
3
4
struct ShadowedDirectionalLight {
public int visibleLightIndex;
public float slopeScaleBias;
}
光的偏移通过它的 shadowBias 属性可用。在 ReserveDirectionalShadows 中把数据添加到它。
1
2
3
4
5
shadowedDirectionalLights[ShadowedDirectionalLightCount] =
new ShadowedDirectionalLight {
visibleLightIndex = visibleLightIndex,
slopeScaleBias = light.shadowBias
};
并在 RenderDirectionalShadows 中使用它来配置斜率-缩放偏移。
1
2
3
4
buffer.SetGlobalDepthBias(0f, light.slopeScaleBias);
ExecuteBuffer();
context.DrawShadows(ref shadowSettings);
buffer.SetGlobalDepthBias(0f, 0f);
让我们也使用光现有的 Normal Bias 滑块来调制我们应用的法线偏移。让 ReserveDirectionalShadows 返回一个 Vector3 并为新 Z 组件使用光的 shadowNormalBias。
1
2
3
4
5
6
7
8
9
10
11
12
13
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 中应用它。
1
2
3
4
5
6
7
8
9
10
11
12
13
struct DirectionalShadowData {
float strength;
int tileIndex;
float normalBias;
};
...
float GetDirectionalShadowAttenuation (...) {
...
float3 normalBias = surfaceWS.normal *
(directional.normalBias * _CascadeData[global.cascadeIndex].y);
...
}
并在 Light 的 GetDirectionalShadowData 中配置它。
1
2
3
data.tileIndex =
_DirectionalLightShadowData[lightIndex].y + shadowData.cascadeIndex;
data.normalBias = _DirectionalLightShadowData[lightIndex].z;
我们现在可以每个光调整两个偏移。斜率-缩放偏移为零和法线偏移为一是好默认。如果你增加第一个你可以减少第二个。但记住我们以不同方式解释这些光设置而不是它们原本目的。它们过去是裁剪空间深度偏移和世界空间缩小法线偏移。所以当你创建一个新光你会得到重彼得平移直到你调整偏移。
阴影平移
另一个可能导致伪影的潜在问题是 Unity 应用阴影平移。想法是当为方向光渲染阴影投射体时近剪裁平面尽可能向前移动。这增加深度精度,但意味着不在相机视图中的阴影投射体可能最终在近剪裁平面前面,导致它们不应该时被剪裁。
这是通过在 ShadowCasterPassVertex 中钳位顶点位置到近剪裁平面来解决的,有效展平位于近剪裁平面前面的阴影投射体,把它们粘在近剪裁平面上。我们通过取裁剪空间 Z 和 W 坐标的最大值,或当 UNITY_REVERSED_Z 被定义时的最小值来做这个。要为 W 坐标使用正确符号用 UNITY_NEAR_CLIP_VALUE 乘它。
1
2
3
4
5
6
7
8
output.positionCS = TransformWorldToHClip(positionWS);
#if UNITY_REVERSED_Z
output.positionCS.z =
min(output.positionCS.z, output.positionCS.w * UNITY_NEAR_CLIP_VALUE);
#else
output.positionCS.z =
max(output.positionCS.z, output.positionCS.w * UNITY_NEAR_CLIP_VALUE);
#endif
这对于完全在近剪裁平面任一侧的阴影投射体完美工作,但穿越平面的阴影投射体变得变形,因为只有它们的一些顶点受影响。这对小三角形不可见,但大三角形可以最终变形很多,弯曲它们并经常导致它们下沉到表面。
这个问题可以通过把近剪裁平面拉回一点来缓解。那是光的 Near Plane 滑块的用途。给 ShadowedDirectionalLight 添加一个近剪裁平面偏移字段。
1
2
3
4
5
struct ShadowedDirectionalLight {
public int visibleLightIndex;
public float slopeScaleBias;
public float nearPlaneOffset;
}
并把光的 shadowNearPlane 属性复制给它。
1
2
3
4
5
6
shadowedDirectionalLights[ShadowedDirectionalLightCount] =
new ShadowedDirectionalLight {
visibleLightIndex = visibleLightIndex,
slopeScaleBias = light.shadowBias,
nearPlaneOffset = light.shadowNearPlane
};
我们通过填写 ComputeDirectionalShadowMatricesAndCullingPrimitives 的最后一个参数来应用它,我们之前给了一个固定值零。
1
2
3
4
5
cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(
light.visibleLightIndex, i, cascadeCount, ratios, tileSize,
light.nearPlaneOffset, out Matrix4x4 viewMatrix,
out Matrix4x4 projectionMatrix, out ShadowSplitData splitData
);
PCF 过滤
到目前为止我们只使用硬阴影,每片段采样一次阴影贴图。阴影比较采样器使用一种特殊形式的双线性插值,在插值之前执行深度比较。这称为百分比近距过滤——简称 PCF——具体是一个 2×2 PCF 过滤器,因为涉及四个纹素。
但这不是我们可以过滤阴影贴图的唯一方式。我们也可以使用更大的过滤器,使阴影更柔和且更少锯齿,虽然也不太准确。让我们添加 2×2、3×3、5×5 和 7×7 过滤的支持。我们不会使用现有的软阴影模式来每个光控制这个。我们将反而让所有方向光使用相同的过滤器。
为那个给 ShadowSettings 添加一个 FilterMode 枚举,加上一个过滤器选项到 Directional,默认设置 $2×2$。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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,
...
};
我们将为新过滤器模式创建着色器变体。给 Shadows 添加三个关键字的静态数组。
1
2
3
4
5
static string[] directionalFilterKeywords = {
"_DIRECTIONAL_PCF3",
"_DIRECTIONAL_PCF5",
"_DIRECTIONAL_PCF7",
};
创建一个 SetKeywords 方法,它启用或禁用适当的关键字。在执行缓冲区之前在 RenderDirectionalShadows 中调用它。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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]);
}
}
}
更大的过滤器需要更多纹理采样。我们需要知道着色器中的贴集大小和纹素大小来做这个。为此添加一个着色器标识符。
1
2
3
cascadeDataId = Shader.PropertyToID("_CascadeData"),
shadowAtlasSizeId = Shader.PropertyToID("_ShadowAtlasSize"),
shadowDistanceFadeId = Shader.PropertyToID("_ShadowDistanceFade");
并在着色器端的 _CustomShadow 添加它。
1
2
3
4
5
CBUFFER_START(_CustomShadows)
...
float4 _ShadowAtlasSize;
float4 _ShadowDistanceFade;
CBUFFER_END
把大小存储在它的 X 组件和纹素大小在它的 Y 组件。
1
2
3
4
SetKeywords();
buffer.SetGlobalVector(
shadowAtlasSizeId, new Vector4(atlasSize, 1f / atlasSize)
);
给 Lit 的 CustomLit 通道添加 #pragma multi_compile 指令用于三个关键字,加上下划线用于无关键字选项匹配 2×2 过滤器。
1
2
3
#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 纹素及帐篷过滤器,中心比边缘有更强权重。
帐篷过滤器如何工作?
Bloom 教程涵盖利用双线性纹理采样的过滤器内核,而 Depth of Field 教程包含一个 3×3 帐篷过滤器的例子。
1
2
3
4
5
6
7
8
9
#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 过滤器用十六个采样,加上适当命名的函数。
1
2
3
4
5
6
7
8
9
10
#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 来满足。
1
2
3
4
5
6
7
8
float FilterDirectionalShadow (float3 positionSTS) {
#if defined(DIRECTIONAL_FILTER_SETUP)
float shadow = 0;
return shadow;
#else
return SampleDirectionalShadowAtlas(positionSTS);
#endif
}
过滤器设置函数有四个参数。第一个是 float4 中的大小,前两个组件中有 X 和 Y 纹素大小,Z 和 W 中有总纹理大小。然后是原始采样位置,后面是每个采样的权重和位置的输出参数。它们被定义为 float 和 float2 数组。之后我们可以循环遍历所有采样,累积它们被它们的权重调制。
1
2
3
4
5
6
7
8
9
10
11
12
13
#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 中调用这个新函数而不是直接去 SampleDirectionalShadowAtlas。
1
2
float shadow = FilterDirectionalShadow(positionSTS);
return lerp(1.0, shadow, directional.strength);
增加过滤器大小使阴影更柔和,但也导致波纹再次出现。我们必须增加法线偏移来匹配过滤器大小。我们可以在 SetCascadeData 中通过乘以纹素大小与一加过滤器模式来自动做这个。
1
2
3
4
5
6
7
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
);
除此之外,增加采样区域也意味着我们最终可能采样在cascaded的剔除球体之外。我们可以通过在平方之前用过滤器大小减少球体的半径来避免那个。
1
2
cullingSphere.w -= filterSize;
cullingSphere.w *= cullingSphere.w;
这再次解决阴影波纹,但增加的过滤器大小加剧了应用法线偏移的缺点,并使我们在之前看到的墙阴影问题更糟。一些斜率-缩放偏移或更大的贴集大小需要来缓解这些伪影。
混合cascaded
更柔和的阴影看起来更好,但也使cascaded之间的突然过渡更明显。
我们可以通过在cascaded之间添加一个过渡区域,我们在那里混合两者,使过渡不那么明显——虽然不能完全隐藏。我们已经有一个cascaded衰减因子可以用于这个目的。
首先,给 Shadows 中的 ShadowData 添加一个cascaded混合值,我们将用它来插值相邻cascaded之间。
1
2
3
4
5
struct ShadowData {
int cascadeIndex;
float cascadeBlend;
float strength;
};
最初在 GetShadowData 中把混合设置为 1,指示选择的cascaded在全强度。然后当在循环中找到cascaded时总是计算衰减因子。如果我们最后一个cascaded因子把它像之前一样考虑为强度,否则使用它为混合。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 中检查cascaded混合是否小于一,在检索第一个阴影值之后。如果是那么我们在过渡区域,必须也从下一个cascaded采样并在两者之间插值。
1
2
3
4
5
6
7
8
9
10
11
12
13
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);
注意cascaded衰减比例应用于每个cascaded的整个半径,不只是它的可见部分。所以确保比例不一直延伸到下个cascaded。一般这不是问题,因为你想要保持过渡区域小。
抖动过渡
虽然cascaded之间混合看起来更好,但它也使我们在混合区域中必须采样阴影贴图的次数加倍。替代方法是总是基于一个抖动模式从一个cascaded采样。这不看起来好但更便宜,特别是当使用大过滤器时。
给 Directional 添加一个cascaded混合模式选项,支持硬、软或抖动方法。
1
2
3
4
5
6
7
8
9
10
11
12
public enum CascadeBlendMode {
Hard, Soft, Dither
}
public CascadeBlendMode cascadeBlend;
}
public Directional directional = new Directional {
...
cascadeFade = 0.1f,
cascadeBlend = Directional.CascadeBlendMode.Hard
};
给 Shadows 添加软和抖动cascaded混合关键字的静态数组。
1
2
3
4
static string[] cascadeBlendKeywords = {
"_CASCADE_BLEND_SOFT",
"_CASCADE_BLEND_DITHER"
};
调整 SetKeywords 使它为任意关键字数组和索引工作,然后也设置cascaded混合关键字。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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) {
for (int i = 0; i < keywords.Length; i++) {
if (i == enabledIndex) {
buffer.EnableShaderKeyword(keywords[i]);
}
else {
buffer.DisableShaderKeyword(keywords[i]);
}
}
}
给 CustomLit 通道添加所需的多编译方向。
1
2
#pragma multi_compile _ _CASCADE_BLEND_SOFT _CASCADE_BLEND_DITHER
#pragma multi_compile_instancing
要执行抖动我们需要一个抖动浮点值,我们可以添加到 Surface。
1
2
3
4
struct Surface {
...
float dither;
};
在 LitPassFragment 中生成抖动值有多种方法。最简单的是使用 Core RP 库的 InterleavedGradientNoise 函数,它给定一个屏幕空间 XY 位置生成一个旋转的瓦片抖动模式。在片段函数中这等于裁剪空间 XY 位置。它还需要一个用于动画的第二个参数,我们不需要并可以保持为零。
1
2
3
surface.smoothness =
UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Smoothness);
surface.dither = InterleavedGradientNoise(input.positionCS.xy, 0);
在 GetShadowData 中设置cascaded索引之前,当不使用软混合时设置cascaded混合为零。这样整个分支将从那些着色器变体中消除。
1
2
3
4
5
6
7
if (i == _CascadeCount) {
data.strength = 0.0;
}
#if !defined(_CASCADE_BLEND_SOFT)
data.cascadeBlend = 1.0;
#endif
data.cascadeIndex = i;
当使用抖动混合时,如果我们不在最后一个cascaded,如果混合值小于抖动值则跳到下一个cascaded。
1
2
3
4
5
6
7
8
9
10
11
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
可接受的抖动混合有多好取决于我们渲染帧的分辨率。如果使用一个涂抹最终结果的后效果那么它可以相当有效,例如与时间抗锯齿和动画抖动模式结合。
透明度
我们将通过考虑透明阴影投射体来结束本教程。剪裁、淡化和透明材质可以像不透明材质一样接收阴影,但目前只有剪裁材质自己投射正确阴影。透明物体表现得好像它们是固体阴影投射体。
阴影模式
有几种我们可以修改阴影投射体的方法。因为涉及写深度缓冲区,我们的阴影是二元的,要么存在要么不存在,但这仍然给我们一些灵活性。它们可以开启和完全固体,剪裁,抖动或完全关闭。我们可以独立于其他材质属性做这个,以支持最大灵活性。所以让我们为它添加一个单独的 _Shadows 着色器属性。我们可以使用 KeywordEnum 属性为它创建一个关键字下拉菜单,默认阴影开启。
1
[KeywordEnum(On, Clip, Dither, Off)] _Shadows ("Shadows", Float) = 0
为这些模式添加着色器特性,替换现有的 _CLIPPING 特性。我们只需要三个变体,使用无关键字用于开启和关闭,_SHADOWS_CLIP 和 _SHADOWS_DITHER。
1
2
//#pragma shader_feature _CLIPPING
#pragma shader_feature _ _SHADOWS_CLIP _SHADOWS_DITHER
在 CustomShaderGUI 中为阴影创建一个设置器属性。
1
2
3
4
5
6
7
8
9
10
11
12
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);
}
}
}
然后在预设方法中适当设置阴影。那将是不透明开启,剪裁剪裁,让我们淡化和透明都使用抖动。
剪裁阴影
在 ShadowCasterPassFragment 中,用 _SHADOWS_CLIP 的检查替换 _CLIPPED 的检查。
1
2
3
#if defined(_SHADOWS_CLIP)
clip(base.a - UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Cutoff));
#endif
现在可能给透明材质剪裁阴影,这对于有大部分完全不透明或透明但需要 alpha 混合的表面可能是适当的。
注意剪裁阴影不像固体阴影稳定,因为阴影矩阵当视图移动时改变,这导致片段移动一点点。这可能导致阴影贴图的一个纹素突然从剪裁到不剪裁过渡。
抖动阴影
抖动阴影工作像剪裁阴影一样,除了标准不同。在这种情况下,我们从表面 alpha 减去一个抖动值并基于那个剪裁。我们可以再次使用 InterleavedGradientNoise 函数。
1
2
3
4
5
6
#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
抖动可以用于近似半透明阴影投射体,但它是一个相当粗糙的方法。硬抖动阴影看起来糟,但当使用更大 PCF 过滤器时可能看起来可接受。
因为抖动模式每个纹素固定,重叠半透明阴影投射体不投射一个组合更暗的阴影。效果和最不透明阴影投射体一样强。还有,因为结果模式有噪声,它当阴影矩阵改变时遭受更多时间伪影,这可以使阴影看起来颤抖。这个方法对有固定投影的其他光源类型工作更好,只要物体不移动。对半透明物体使用剪裁阴影或根本没有阴影通常更实用。
无阴影
每个物体可以通过调整物体的 MeshRenderer 组件的 Cast Shadows 设置来关闭阴影投射。然而,如果你想为所有使用相同材质的东西关闭阴影这不实用,所以我们也支持每材质禁用它们。我们通过禁用材质的 ShadowCaster 通道来做这个。
给 CustomShaderGUI 添加一个 SetShadowCasterPass 方法,它首先通过检查 _Shadows 着色器属性是否存在来开始。如果是这样,也通过它的 hasMixedValue 属性检查所有选择的材质是否设置为相同模式。如果没有模式或混合则中止。否则,对每个材质启用或禁用 ShadowCaster 通道,通过在它们上调用 SetShaderPassEnabled,传递通道名称和启用状态作为参数。
1
2
3
4
5
6
7
8
9
10
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 来做这个。后者方法返回自我们开始检查以来是否有东西更改。如果是这样,设置阴影投射体通道。
1
2
3
4
5
6
7
8
9
public override void OnGUI (
MaterialEditor materialEditor, MaterialProperty[] properties
) {
EditorGUI.BeginChangeCheck();
...
if (EditorGUI.EndChangeCheck()) {
SetShadowCasterPass();
}
}
无光照阴影投射体
虽然无光照材质不受光照影响,你可能想让它们投射阴影。我们可以通过简单地从 Lit 复制 ShadowCaster 通道到 Unlit 着色器来支持那个。
接收阴影
最后,我们也可以让光照表面忽略阴影,这对全息图之类的东西或只是为了艺术目的可能有用。给 Lit 添加一个 _RECEIVE_SHADOWS 关键字切换属性为此。
1
[Toggle(_RECEIVE_SHADOWS)] _ReceiveShadows ("Receive Shadows", Float) = 1
加上在 CustomLit 通道的伴随着色器特性。
1
#pragma shader_feature _RECEIVE_SHADOWS
我们所需要做的是在定义了关键字时在 GetDirectionalShadowAttenuation 中强制将阴影衰减为一。
1
2
3
4
5
6
float GetDirectionalShadowAttenuation (...) {
#if !defined(_RECEIVE_SHADOWS)
return 1.0;
#endif
...
}
下一篇Baked Light


























































