Unity3D CustomSRP[译].4.平行光阴影[Directional Shadows]

Directional Shadows(平行光Shadow)

——级联Shadows贴图


本节内容

  • 渲染和采样ShadowMap
  • 支持多个平行光Shadows
  • 使用级联Shadows贴图
  • 混合、淡出和过滤Shadows



这是关于如何创建Custom SRP的系列教程的第四个部分,它添加了对级联Shadows贴图的支持。

这个教程使用的是Unity版本是2019.2.6f1.

阻止光线到达不该到达的地方

(ps:文章总被吞…最后偶然看到可能会被吞的一些词儿…尝试改了点但有些意思感觉不到位~)


1. 渲染Shadows(Rendering Shadows)

当绘制一些物体时,表面和光的信息就足以计算光照了。但可能在两者之间有某种物体挡住了光线,在我们绘制的物体上投下Shadows。为了使Shadows成为可能,我们必须以某种方式让着色器意识到Shadows投射到了对象。有多种方法可以做到这一点。最常见的方法是生成Shadows贴图,它可以存储光在到达表面之前从光源位置前进的距离。同一方向上更远处的任何物体都不应该被同一束光照亮。Unity的渲染管线使用了这种方法,我们也将如此。


1.1 Shadows设置(Shadow Settings)

在我们开始渲染Shadows之前,我们首先要做一些关于质量的决定,特别是关于我们将渲染Shadows的距离以及Shadows贴图的大小。

虽然我们可以渲染相机再远也能看到的Shadows,但这将需要大量的绘制和非常大的贴图来充分覆盖这个区域,这几乎是不现实的。所以我们将为Shadows引入最大距离收缩,最小值为0,默认设置为100个单位。创建新的可序列化的ShadowSettings类来包含此选项。这个类纯粹是配置选项的容器,因此我们将为它提供公共的maxDistance字段。

using UnityEngine;

[System.Serializable]
public class ShadowSettings {

    [Min(0f)]
    public float maxDistance = 100f;
}

对于地图大小,我们将引入TextureSize枚举类型嵌套在ShadowSettings中。使用它来定义允许的纹理大小,在256-8192范围内定义都是2的幂的尺寸。

public enum TextureSize {
    _256 = 256, _512 = 512, _1024 = 1024,
    _2048 = 2048, _4096 = 4096, _8192 = 8192
}

然后为Shadows映射添加大小字段,默认值为1024。 我们将使用单独的纹理来包含多个Shadows贴图,所以命名为atlasSize。 因为我们现在只支持平行光,所以我们现在也只支持平行光Shadows贴图。但我们将在未来支持其他灯光类型,它们将获得自己的Shadows设置。 把atlasSize放到Directional结构中。 这样我们就可以在面板中自动得到分层配置。

[System.Serializable]
    public struct Directional {

        public TextureSize atlasSize;
    }

    public Directional directional = new Directional {
        atlasSize = TextureSize._1024
    };

CustomRenderPipelineAsset中添加Shadows设置字段。

[SerializeField]
ShadowSettings shadows = default;

Shadows设置

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 传递Shadows设置(Passing Along Settings)

从现在开始,当我们调用它的Render方法时,我们将把这些设置传递给相机渲染器。这样就很容易在运行时添加对更改Shadows设置的支持,但我们不会在本教程中处理它。

    protected override void Render (
        ScriptableRenderContext context, Camera[] cameras
    ) {
        foreach (Camera camera in cameras) {
            renderer.Render(
                context, camera, useDynamicBatching, useGPUInstancing,
                shadowSettings
            );
        }
    }

CameraRenderer.Render 然后将它传递给Cull方法和Lighting.Setup

    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中设置,因为Shadows距离是通过裁剪参数设置的。

bool Cull (float maxShadowDistance) {
        if (camera.TryGetCullingParameters(out ScriptableCullingParameters p)) {
            p.shadowDistance = maxShadowDistance;
            cullingResults = context.Cull(ref p);
            return true;
        }
        return false;
    }

渲染比相机能看到的更远的Shadows是没有意义的,所以取最大Shadows距离的最小值和相机的远裁剪面。

    p.shadowDistance = Mathf.Min(maxShadowDistance, camera.farClipPlane);

为了代码能编译,我们还需要在Lighting.Setup中添加Shadows设置参数,但我们现在还不打算用它做什么。

public void Setup (
    ScriptableRenderContext context, CullingResults cullingResults,
    ShadowSettings shadowSettings
) { … }


1.3 Shadows类(Shadows Class)

虽然Shadows在逻辑上是照明的一部分,但它们相当复杂,所以让我们创建新的Shadows.cs用于Shadows。给它一些基本的字段和方法。

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实例,并在SetupLights中调用Shadows的设置方法。

    Shadows shadows = new Shadows();

    public void Setup (…) {
        this.cullingResults = cullingResults;
        buffer.BeginSample(bufferName);
        shadows.Setup(context, cullingResults, shadowSettings);
        SetupLights();
        …
    }


1.4 有Shadows的灯光(Lights with Shadows)

由于渲染Shadows需要额外的工作,这可能会降低帧率,所以我们将收缩平行光Shadows的数量,这与所支持的平行光的数量无关。为Shadows添加常量,最初设置为。

const int maxShadowedDirectionalLightCount = 1;

我们不知道哪些可见光会产生Shadows,所以我们必须追踪和检查。除此之外,我们还将追踪可以产生Shadows的灯光的更多的数据,所以让我们定义内部的ShadowedDirectionalLight结构,目前只包含索引,并声明该结构的数组。

    struct ShadowedDirectionalLight {
        public int visibleLightIndex;
    }

    ShadowedDirectionalLight[] ShadowedDirectionalLights =
        new ShadowedDirectionalLight[maxShadowedDirectionalLightCount];

为了弄清楚哪个光会产生Shadows,我们将添加公共的ReserveDirectionalShadows方法,带有光源和可见光索引参数。 它的工作是在Shadows图集中为光线的Shadows贴图预留空间,并存储渲染它们所需的信息。

    public void ReserveDirectionalShadows (Light light, int visibleLightIndex) {}

由于Shadows光的数量有限,我们必须追踪有多少已经被预留。在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
                };
        }
    }

但Shadows应该只留给可以产生Shadows的光。 如果光的Shadows模式设置为None或者它的Shadows强度为0,那么它没有Shadows,应该被忽略。

if (ShadowedDirectionalLightCount < maxShadowedDirectionalLightCount &&
    light.shadows != LightShadows.None && light.shadowStrength > 0f
) { … }

除此之外,可见光最终可能不会影响任何可以投射Shadows的物体,要么是因为它们的配置不影响Shadows,要么是因为光只影响到了最大Shadows距离以外的物体。我们可以通过对可见光索引的剔除结果调用GetShadowCasterBounds来检查这一点。它有第二个边界的输出参数(我们不需要它),并返回边界是否有效。如果没有,就没有Shadows来渲染这个光,它应该被忽略。

        if (
            ShadowedDirectionalLightCount < maxShadowedDirectionalLightCount &&
            light.shadows != LightShadows.None && light.shadowStrength > 0f &&
            cullingResults.GetShadowCasterBounds(visibleLightIndex, out Bounds b)
        ) { … }

现在我们可以在Lighting.SetupDirectionalLight中保留Shadows。

    void SetupDirectionalLight (int index, ref VisibleLight visibleLight) {
        dirLightColors[index] = visibleLight.finalColor;
        dirLightDirections[index] = -visibleLight.localToWorldMatrix.GetColumn(2);
        shadows.ReserveDirectionalShadows(visibleLight.light, index);
    }


1.5 创建Shadows图集(Creating the Shadow Atlas)

在预留Shadows之后,我们需要渲染它们。我们在SetupLights完成Lighting.Render后进行,通过调用新的Shadows.Render方法。

        shadows.Setup(context, cullingResults, shadowSettings);
        SetupLights();
        shadows.Render();

Shadows.Render方法将平行光Shadows的渲染委托给另RenderDirectionalShadows方法,但只有在灯光产生Shadows的情况下。

    public void Render () {
        if (ShadowedDirectionalLightCount > 0) {
            RenderDirectionalShadows();
        }
    }

    void RenderDirectionalShadows () {}

创建Shadows贴图是通过将Shadows投射对象绘制到纹理中来完成的。我们将使用_DirectionalShadowAtlas来引用平行光Shadows图集。从设置中以整数形式检索图集大小,然后在命令缓冲区上调用GetTemporaryRT,使用纹理标识符作为参数,加上宽度和高度的大小(以像素为单位)。

    static int dirShadowAtlasId = Shader.PropertyToID("_DirectionalShadowAtlas");
    
    …
    
    void RenderDirectionalShadows () {
        int atlasSize = (int)settings.directional.atlasSize;
        buffer.GetTemporaryRT(dirShadowAtlasId, atlasSize, atlasSize);
    }

它声明正方形渲染纹理,但默认情况下它是普通的ARGB纹理。 我们需要Shadows贴图,通过向方法添加另外三个参数来指定它。首先是深度缓冲区的位数。我们想让它尽可能大,所以我们用32。第二种是滤波模式,我们使用默认的双线性滤波。第三是渲染纹理类型,它必须是RenderTextureFormat.Shadowmap。 这给了我们适合渲染Shadows贴图的纹理,尽管确切的格式取决于目标平台。

        buffer.GetTemporaryRT(
            dirShadowAtlasId, atlasSize, atlasSize,
            32, FilterMode.Bilinear, RenderTextureFormat.Shadowmap
        );


我们会得到什么类型的纹理格式?
.
它通常是24或32位整数或浮点纹理。 你也可以选择16位,这也是Unity的渲染管线所用的。


当我们获得临时渲染纹理时,我们也应该在使用完成后释放它。我们必须保留着它,直到我们用相机完成渲染,之后我们可以通过调用命令缓冲区的ReleaseTemporaryRT来释放它,然后执行命令。我们将在新的公共Cleanup方法中完成此操作。

    public void Cleanup () {
        buffer.ReleaseTemporaryRT(dirShadowAtlasId);
        ExecuteBuffer();
    }

Lighting公共的清理方法。

    public void Cleanup () {
        shadows.Cleanup();
    }

然后CameraRenderer可以在Submit之前直接请求清理。

    public void Render (…) {
        …
        lighting.Cleanup();
        Submit();
    }

我们只能在首次声明的情况下释放纹理,目前我们只在有平行Shadows要渲染的情况下这么做。最明智的解决方案是,当我们有Shadows时,才释放纹理。然而,在WebGL 2.0中,不声明纹理会导致问题,因为它将纹理和采样器绑定在一起。当材质与我们的着色器加载而纹理缺失时,它将失败,因为它将得到默认的纹理,这将与Shadows采样器不兼容。我们可以通过引入着色器关键字来生成着色器变体来避免这一点,从而忽略Shadows采样代码。另一种方法是,当没有Shadows时,尝试获取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。纹理的目的是包含Shadows数据,所以我们需要使用RenderBufferStoreAction.Store为第三个参数。

        buffer.SetRenderTarget(
            dirShadowAtlasId,
            RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store
        );
        buffer.ClearRenderTarget(true, false, Color.clear);
        ExecuteBuffer();

清除两个渲染目标


为什么我得到关于尺寸不匹配的错误?
.
这可能会发生在Unity 2020,下一节我们将解决这个问题。


1.6 先绘制Shadows(Shadows First)

当我们在设置Shadows图集之前设置常规相机时,我们最终在渲染常规几何图形之前切换到Shadows图集,这不是我们希望的。 我们应该在调用CameraRenderer.Setup之前渲染Shadows。这样常规渲染就不会受到渲染Shadows的影响。

        //Setup();
        lighting.Setup(context, cullingResults, shadowSettings);
        Setup();
        DrawVisibleGeometry(useDynamicBatching, useGPUInstancing);

先Shadows

我们可以在帧调试器中保持Shadows入口嵌套在相机的内部,在设置光照之前开始采样,之后在清除相机的目标之前立即结束采样。

        buffer.BeginSample(SampleName);
        ExecuteBuffer();
        lighting.Setup(context, cullingResults, shadowSettings);
        buffer.EndSample(SampleName);
        Setup();
嵌套Shadows


1.7 渲染Shadows(Rendering)

为了渲染单个光线的Shadows,我们将添加RenderDirectionalShadows方法到Shadows.cs中,有两个参数:第是Shadows的光索引,第二个是它在图集中瓦片的大小。 然后在其他·RenderDirectionalShadows·方法中为所有Shadows光调用这个方法,由BeginSampleEndSample包装。 因为我们目前只支持单个Shadows灯,它的瓦片大小等于图集的大小。

    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) {}

为了渲染Shadows,我们需要ShadowDrawingSettings结构体的值。 我们可以通过调用具有筛选结果和可见光索引的构造函数来创建。

    void RenderDirectionalShadows (int index, int tileSize) {
        ShadowedDirectionalLight light = ShadowedDirectionalLights[index];
        var shadowSettings =
            new ShadowDrawingSettings(cullingResults, light.visibleLightIndex);
    }

Shadows贴图的理念是,我们从光的角度渲染场景,只存储深度信息。结果告诉我们光在撞击到物体前传播了多远。

然而,平行光被假设是无限远的,因此没有真正的位置。所以我们要做的是找出匹配光线方向的视图和投影矩阵,并给我们裁剪空间立方体,它覆盖了可以包含光线Shadows的相机可见区域。我们可以使用ComputeDirectionalShadowMatricesAndCullingPrimitives方法来为我们解决这个问题,向它传递九个参数。

        var shadowSettings =
            new ShadowDrawingSettings(cullingResults, light.visibleLightIndex);
        cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(
            light.visibleLightIndex, 0, 1, Vector3.zero, tileSize, 0f,
            out Matrix4x4 viewMatrix, out Matrix4x4 projectionMatrix,
            out ShadowSplitData splitData
        );

ShadowSplitData包含关于Shadows投射对象应该如何被剔除的信息,我们必须将这些信息拷贝到Shadows设置中。 我们必须通过调用缓冲区上的SetViewProjectionMatrices来应用视图和投影矩阵。

        cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(…);
        shadowSettings.splitData = splitData;
        buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);

我们最后通过执行命令缓冲区来调度Shadows投射的绘制,然后在context上调用DrawShadows,通过引用将Shadows设置传递给它。

        shadowSettings.splitData = splitData;
        buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);
        ExecuteBuffer();
        context.DrawShadows(ref shadowSettings);


1.8 Shadows投射通道(Shadow Caster Pass)

现在Shadows投射者应该被绘制,但Shadows图集仍然是空的。这是因为DrawShadows只渲染带有ShadowCaster通道的材质的对象。所以添加第二个Pass块到我们的Lit着色器中,将其灯光模式设置为ShadowCaster。使用相同的目标级别,给它实例化的支持,加上_CLIPPING着色器特性。然后让它使用特殊的Shadows投射函数,我们将在新的ShadowCasterPass.hlsl文件中定义它。另外,因为我们只需要写入深度,添加ColorMask 0禁止写入颜色数据。

    SubShader {
        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
        }
    }

创建ShadowCasterPass.hlsl文件,拷贝LitPass.hlsl并删除所有不需要Shadows投射的物体。我们只需要裁剪空间的位置,加上用于裁剪的基色。片元函数没有返回任何物体,因此就变成了没有语义的空函数。它唯一能做的就是在一些情况下裁剪片元。

#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

我们现在可以渲染Shadows投射者。 我创建了简单的测试场景,在平面的上面包含一些不透明的物体,用有Shadows的平行光来测试它。光线是设置为硬Shadows还是软Shadows并不重要。

Shadows测试场景

Shadows还不会影响最终渲染的图像,但我们已经可以通过帧调试器看到渲染到Shadows图集中的内容。它通常被视为单色纹理,随着距离的增加从白色到黑色,但使用OpenGL时它是红色的,而且是相反的变化。

图集大小512,最大距离100

随着最大Shadows距离设置为100,我们最终只渲染了纹理的一小部分。减少最大距离有效地使Shadows地图放大在相机前面的物体。

最大距离20、10

注意,Shadows投射器是用正投影渲染的,因为我们渲染的是平行光。


1.9 多光源(Multiple Lights)

我们最多可以有4个平行光,所以让我们支持最多4个有Shadows的平行光。

const int maxShadowedDirectionalLightCount = 4;

作为快速测试,我使用了四个一样的平行光,Y轴的旋转依次为0、90、180、270。

叠加的四个光的Shadows投射

虽然我们最终正确地渲染了所有灯光的Shadows投射者,但当我们为每个灯光渲染整个地图集时,它们是叠加在一起的。 我们必须分割我们的地图集,这样我们就可以给每个灯使用它自己的贴图来绘制。

我们最多支持四个有Shadows的光,我们将在我们的正方形图集中给每个灯正方形瓦片。因此,如果我们有不止Shadows,我们必须将图集分成四个瓦片,通过将瓦片大小减半。 在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) { … }

我们可以通过调整渲染视口来渲染单个瓦片。为此创建具有瓦片索引和分割数作为参数的新方法。并在里面先计算瓦片偏移。

    void SetTileViewport (int index, int split) {
        Vector2 offset = new Vector2(index % split, index / split);
    }

然后调用buffer的SetViewPort方法。

    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);

使用四个瓦片的Shadows图集


2. 采集Shadows(Sampling Shadows)

现在我们渲染了Shadows投射者,但这还不会影响最终的图像。为了让Shadows显示出来,我们必须在CustomLit通道中采样Shadows贴图,并确定表面片元是否被Shadows覆盖。


2.1 Shadows矩阵(Shadow Matrices)

对于每个片元,我们必须从Shadows图集中适当的瓦片中采样深度信息。所以我们必须为给定的世界空间位置找到Shadows纹理坐标。我们将通过为每个平行光Shadows创建Shadows变换矩阵并将它们发送到GPU。 添加_DirectionalShadowMatrices着色器属性标识符和静态矩阵数组到Shadows.cs中。

    static int
        dirShadowAtlasId = Shader.PropertyToID("_DirectionalShadowAtlas"),
        dirShadowMatricesId = Shader.PropertyToID("_DirectionalShadowMatrices");
        
    static Matrix4x4[]
        dirShadowMatrices = new Matrix4x4[maxShadowedDirectionalLightCount];

我们可以创建从世界空间到光源空间的转换矩阵,通过在RenderDirectionalShadows中乘以光的Shadows投影矩阵和视图矩阵。

    void RenderDirectionalShadows (int index, int split, int tileSize) {
        …
        SetTileViewport(index, split, tileSize);
        dirShadowMatrices[index] = projectionMatrix * viewMatrix;
        buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);
        …
    }

然后,一旦所有可以投射Shadows的灯光被渲染后,通过调用buffer上的SetGlobalMatrixArray将矩阵发送给GPU。

    void RenderDirectionalShadows () {
        …

        buffer.SetGlobalMatrixArray(dirShadowMatricesId, dirShadowMatrices);
        buffer.EndSample(bufferName);
        ExecuteBuffer();
    }

然而,这忽略了事实,即我们使用的是Shadows图集。 让我们创建ConvertToAtlasMatrix方法,它接受光照矩阵、贴图偏移量和分割参数,并返回从世界空间转换为Shadows贴图空间的矩阵。

    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缓冲区,则对矩阵的Z维取反。我们可以通过SystemInfo.usesReversedZBuffer检查。

    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;
    }


为什么Z缓冲区是反向的?
.
*用0表示0深度,用1表示最大深度是最直观的,这就是OpenGL所采用的方法。但是由于深度缓冲区的精度是有限的,而且它是非线性存储的,我们可以通过反转来更好地利用比特位数。其他图形API使用了相反的方法。我们通常不需要担心,除非我们显式地处理裁剪空间。 *


其次,裁剪空间在立方体内部定义,其坐标从−1到1,中心为0。但是纹理坐标和深度是从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 逐光源存储Shadows数据(Storing Shadow Data Per Light)

为了对光进行Shadows采样,我们需要知道它的贴图在Shadows图集中的索引,如果它有的话。这是每个光都需要存储的物体,所以让我们让ReserveDirectionalShadows返回所需的数据。我们将提供两个值:Shadows强度和Shadows瓦片偏移量,存储在Vector2中。 如果光没有Shadows,那么结果是零向量。

    public Vector2 ReserveDirectionalShadows (…) {
        if (…) {
            ShadowedDirectionalLights[ShadowedDirectionalLightCount] =
                new ShadowedDirectionalLight {
                    visibleLightIndex = visibleLightIndex
                };
            return new Vector2(
                light.shadowStrength, ShadowedDirectionalLightCount++
            );
        }
        return Vector2.zero;
    }

通过_DirectionalLightShadowData矢量数组将这些数据添加到shader中。

    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缓冲区中。

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文件(Shadows HLSL File)

我们还将为Shadows采样创建专用的Shadows hlsl文件。定义相同的最大平行光Shadows计数,以及_DirectionalShadowAtlas纹理,以及_CustomShadows缓冲区中的_DirectionalShadowMatrices数组。

#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

因为Shadows贴图不是常规的纹理,我们可以通过TEXTURE2D_SHADOW宏来定义它,尽管它对我们支持的平台没有影响。我们将使用特殊的SAMPLER_CMP宏来定义采样状态,因为这定义了一种不同的方式来采样Shadows贴图,因为常规双线性过滤对深度数据没有意义。

TEXTURE2D_SHADOW(_DirectionalShadowAtlas);
SAMPLER_CMP(sampler_DirectionalShadowAtlas);

事实上,只有一种合适的方法来采样Shadows贴图,所以我们可以定义显式的采样状态,而不是依赖于Unity为我们的渲染纹理推断。采样器状态可以通过在其名称中创建特定的单词来内联定义。我们可以使用sampler_linear_clamp_compare。让我们也为它定义简写的SHADOW_SAMPLER宏。

TEXTURE2D_SHADOW(_DirectionalShadowAtlas);
#define SHADOW_SAMPLER sampler_linear_clamp_compare
SAMPLER_CMP(SHADOW_SAMPLER);

LitPass中引用它。

#include "../ShaderLibrary/Surface.hlsl"
#include "../ShaderLibrary/Shadows.hlsl"
#include "../ShaderLibrary/Light.hlsl"


2.4 采样Shadows(Sampling Shadows)

为了采样Shadows,我们需要知道每个光的Shadows数据,所以让我们在Shadows.hlsl中为平行光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);

添加SampleDirectionalShadowAtlas函数,通过SAMPLE_TEXTURE2D_SHADOW宏对Shadows图集进行采样,传递图集、Shadows采样器和Shadows纹理空间中的位置。

float SampleDirectionalShadowAtlas (float3 positionSTS) {
    return SAMPLE_TEXTURE2D_SHADOW(
        _DirectionalShadowAtlas, SHADOW_SAMPLER, positionSTS
    );
}

然后添加GetDirectionalShadowAttenuation函数,它返回Shadows衰减、给定的方向Shadows数据和应该在世界空间中定义的表面。它使用瓦片偏移量来查找正确的矩阵,将表面位置转换为Shadows贴图空间,然后对图集进行采样。

float GetDirectionalShadowAttenuation (DirectionalShadowData data, Surface surfaceWS) {
    float3 positionSTS = mul(
        _DirectionalShadowMatrices[data.tileIndex],
        float4(surfaceWS.position, 1.0)
    ).xyz;
    float shadow = SampleDirectionalShadowAtlas(positionSTS);
    return shadow;
}

Shadows贴图的采样结果决定了在只考虑Shadows的情况下,有多少光线到达表面。它是0-1范围内的值,称为衰减因子。如果片元被完全遮挡,那么我们得到0,而当它完全没有遮蔽时,我们得到1。介于两者之间的值表明片元部分被遮挡。

此外,光的Shadows强度可以降低,无论是出于艺术原因,还是为了表现半透明表面的Shadows。 当强度降低到零,那么衰减不受Shadows的影响,值应该是1。 因此,最终的衰减是通过基于强度在1和采样的衰减值之间的线性插值得到的。

    return lerp(1.0, shadow, data.strength);

但是当Shadows强度为0时,就完全不需要对Shadows进行采样,因为它们没有效果,甚至还没有被渲染。在这种情况下,我们有无Shadows的光,应该总是返回1。

float GetDirectionalShadowAttenuation (DirectionalShadowData data, Surface surfaceWS) {
    if (data.strength <= 0.0) {
        return 1.0;
    }
    …
}


在着色器中使用分支结构是好主意吗?
.
分支曾经是低效的,但现代GPU可以很好地处理它们。但你必须记住的是,这些片元块是并行着色的。如果片元以特定的方式进行分支,那么整个块都会这样做,即使其他所有片元都忽略了该代码路径产生的结果。在这种情况下,我们根据光的强度进行分支,至少在目前,针对所有片元的光的强度都是一样的。


2.5 光衰减(Attenuating Light)

我们将在Light结构中存储光的衰减。

struct Light {
    float3 color;
    float3 direction;
    float attenuation;
};

Light.hlsl添加获取方向Shadows数据的函数。

DirectionalShadowData GetDirectionalShadowData (int lightIndex) {
    DirectionalShadowData data;
    data.strength = _DirectionalLightShadowData[lightIndex].x;
    data.tileIndex = _DirectionalLightShadowData[lightIndex].y;
    return data;
}

然后添加世界空间中的表面参数到GetDirectionalLight,让它检索平行光Shadows数据,并使用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;
}

现在照明中的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;
}

使Shadows工作的最后一步是将衰减应用到到光的强度。

float3 IncomingLight (Surface surface, Light light) {
    return
        saturate(dot(surface.normal, light.direction) * light.attenuation) *
        light.color;
}

平行光和其Shadows,最大距离10、图集尺寸512

我们终于有了Shadows,但它看起来很糟糕。 不应该被Shadows覆盖的表面也会被Shadows覆盖,形成像素化的条纹。这些都是由自Shadows造成的,Shadows贴图的分辨率有限。使用不同的分辨率会改善情况,但不会消除它们。表面最终部分会被自身遮挡,但我们稍后会处理这个问题。我们现在可以很容易看到Shadows贴图覆盖的区域,所以暂时保留它们。

两个有Shadows的光,强度都是0.5

稍后我们将正确地在最大距离切断Shadows。


3. 级联Shadows贴图(Cascaded Shadow Maps)

因为平行光会影响到最大Shadows距离,所以它的Shadows贴图最终会覆盖大片区域。由于Shadows贴图使用正投影,Shadows贴图中的每个纹素都有固定的世界空间大小。 如果这个尺寸太大,那么单独的Shadows纹素就会清晰可见,导致锯齿状的Shadows边缘以及小的Shadows会消失。这可以通过增加图集的大小来减轻这种现象,但也只能达到一定的程度。

当使用透视相机时,远处的物体看起来更小。在某些视觉距离,Shadows贴图纹素将映射到单个像素上,这意味着Shadows分辨率理论上是最佳的。离镜头越近,我们就需要更高的Shadows分辨率,而离镜头越远,分辨率就越低。这表明,理想情况下,我们应该使用可变的Shadows贴图分辨率,基于Shadows接收者的视野距离。

级联Shadows贴图是解决这个问题的方法。这个想法是,Shadows投射者被渲染不止一次,所以每个光在图集中得到多个瓦片,称为级联。第级联只覆盖靠近相机的小区域,而后续的级联逐渐放大到覆盖更大的区域,并且有相同数量的纹素。之后shader为每个片元采样最佳的可用级联Shadows贴图。


3.1 设置(Settings)

Unity自己的Shadows代码为每个平行光支持多达四个Shadows级联。到目前为止,我们只使用了单个级联来覆盖所有距离的Shadows。为了支持更多,我们将在平行光Shadows设置中添加级联计数滑块。虽然我们可以为每个不同方向的光使用不同的级联数量,但为所有平行光Shadows使用相同的数量比较有意义。

每个级联覆盖Shadows区域的一部分,直到最大Shadows距离。我们将通过为前三个级联添加比例滑块使每个部分可配置。 最后级联覆盖整个范围,所以不需要滑动条。级联计数默认为4,级联比为0.1、0.25、0.5。

    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中。

        public Vector3 CascadeRatios =>
            new Vector3(cascadeRatio1, cascadeRatio2, cascadeRatio3);


3.2 渲染级联(Rendering Cascades)

每个级联需要自己的变换矩阵,所以Shadows矩阵数组的大小必须乘以每个光的最大级联数。

    const int maxShadowedDirectionalLightCount = 4, maxCascades = 4;

    …

    static Matrix4x4[]
        dirShadowMatrices = new Matrix4x4[maxShadowedDirectionalLightCount * maxCascades];

在Shadows.hlsl中添加数组的大小。

#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会抱怨着色器的数组大小已经改变,但它不能使用新的大小。这是因为一旦固定数组被着色器声明,他们的大小不能在统一会话期间内被改变。我们必须重启Unity来重新初始化它。

完成后,在Shadows.ReserveDirectionalShadows中通过配置的级联数量乘以返回的瓦片偏移。

        return new Vector2(light.shadowStrength,
            settings.directional.cascadeCount * ShadowedDirectionalLightCount++
        );

同样地,在RenderDirectionalShadows中使用的瓦片的数量会成倍增加,这意味着我们最终可能会有16个贴图,需要将它们分成4组。

        int tiles = ShadowedDirectionalLightCount * settings.directional.cascadeCount;
        int split = tiles <= 1 ? 1 : tiles <= 4 ? 2 : 4;
        int tileSize = atlasSize / split;


为什么不支持分割三份?
.
我们将收缩在2的幂上,这与我们对图集大小的收缩是一样的。这样整数除法总是可行的,否则我们就会遇到不对齐的问题。这意味着一些光的配置不会使用所有可用的贴图,浪费了纹理空间。如果这对你是问题,那么你可以添加不需要是正方形的矩形图集的支持。 然而,你更可能受限于你可以渲染的瓦片数量,而不是纹理空间。

现在RenderDirectionalShadows必须为每个级联绘制Shadows。 为每个配置的级联将ComputeDirectionalShadowMatricesAndCullingPrimitivesDrawShadows的代码放到循环中,且ComputeDirectionalShadowMatricesAndCullingPrimitives的第二个参数现在变成了级联索引,接着是级联计数和级联比率。同时需要重新调整瓦片索引。

    void RenderDirectionalShadows (int index, int split, int tileSize) {
        ShadowedDirectionalLight light = shadowedDirectionalLights[index];
        var shadowSettings = new ShadowDrawingSettings(cullingResults, light.visibleLightIndex);
        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);
        }
    }

1个、4个平行光和4个级联(0.3、0.4、0.5),最大距离30


3.3 裁剪球体(Culling Spheres)

Unity通过为每个级联创建裁剪区域来确定级联所覆盖的区域。由于Shadows投影是正投影且是正方形的,它们最终会紧密地匹配它们的裁剪球体,但也会覆盖它们周围的一些空间。这就是为什么在裁剪区域外可以看到一些Shadows。同时,光的方向对球体无关紧要,所以所有平行光最终都使用相同的裁剪球体。

使用透明球体的可视化裁剪球体

这些球体也需要确定从哪个级联采样,所以我们必须将它们发送给GPU。为级联计数和级联裁剪球体数组添加标识符,并为球体数据添加静态数组。 它们由四个分量向量定义,包含它们的XYZ位置加上存在W分量中的半径。

    static int
        dirShadowAtlasId = Shader.PropertyToID("_DirectionalShadowAtlas"),
        dirShadowMatricesId = Shader.PropertyToID("_DirectionalShadowMatrices"),
        cascadeCountId = Shader.PropertyToID("_CascadeCount"),
        cascadeCullingSpheresId = Shader.PropertyToID("_CascadeCullingSpheres");

    static Vector4[] cascadeCullingSpheres = new Vector4[maxCascades];

级联的剔除球是ComputeDirectionalShadowMatricesAndCullingPrimitives输出的splitData的一部分。将它分配给RenderDirectionalShadows循环中的球体数组。 但我们只需要对第一束光这样做,因为所有光的级联是一样的。

        for (int i = 0; i < cascadeCount; i++) {
            cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(…);
            shadowSettings.splitData = splitData;
            if (index == 0) {
                cascadeCullingSpheres[i] = splitData.cullingSphere;
            }
            …
        }

我们需要在着色器中的球体来检查表面片段是否位于它们内部,这可以通过比较距离球体坐标的距离的平方和它的半径的平方来完成。 让我们存储半径的平方,这样我们就不必在shader中计算它。

                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.hlsl

CBUFFER_START(_CustomShadows)
    int _CascadeCount;
    float4 _CascadeCullingSpheres[MAX_CASCADE_COUNT];
    float4x4 _DirectionalShadowMatrices
        [MAX_SHADOWED_DIRECTIONAL_LIGHT_COUNT * MAX_CASCADE_COUNT];
CBUFFER_END

级联索引是逐片元而不是逐光源确定的。所以让我们引入包含它的全局ShadowData结构体。 我们稍后会添加更多的数据。 还要添加GetShadowData函数,用于返回世界空间表面的Shadows数据,刚开始将级联索引设置为零。

struct ShadowData {
    int cascadeIndex;
};

ShadowData GetShadowData (Surface surfaceWS) {
    ShadowData data;
    data.cascadeIndex = 0;
    return data;
}

将新数据作为参数添加到GetDirectionalShadowData中,这样它就可以通过将级联索引和光的Shadows贴图偏移量相加来选择正确的瓦片索引。

DirectionalShadowData GetDirectionalShadowData (
    int lightIndex, ShadowData shadowData
) {
    DirectionalShadowData data;
    data.strength = _DirectionalLightShadowData[lightIndex].x;
    data.tileIndex =
        _DirectionalLightShadowData[lightIndex].y + shadowData.cascadeIndex;
    return data;
}

也添加同样的参数到GetDirectionalLight,这样它就可以转发数据到GetDirectionalShadowData。将平行光Shadows数据变量重命名。

Light GetDirectionalLight (int index, Surface surfaceWS, ShadowData shadowData) {
    …
    DirectionalShadowData dirShadowData =
        GetDirectionalShadowData(index, shadowData);
    light.attenuation = GetDirectionalShadowAttenuation(dirShadowData, surfaceWS);
    return light;
}

GetLighting中获取Shadows数据并传递它。

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;
}

总是使用第级联和总是使用最后级联的对比

为了选择正确的级联,我们需要计算两点之间的平方距离。让我们为Common.hlsl添加方便的函数。

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-cascade.png

我们现在得到的Shadows有更好的纹素密度分布。由于自Shadows伪影,级联之间的弯曲过渡边界也看得见。我们可以通过用级联索引除以4来替换Shadows衰减,使它们更明显。

Light GetDirectionalLight (int index, Surface surfaceWS, ShadowData shadowData) {
    …
    light.attenuation = GetDirectionalShadowAttenuation(dirShadowData, surfaceWS);
    light.attenuation = shadowData.cascadeIndex * 0.25;
    return light;
}

用级联索引产生Shadows


3.5 剔除Shadows采样(Culling Shadow Sampling)

如果我们最终超出了最后的级联,很可能没有有效的Shadows数据,那我们就不应该采样Shadows。 简单的方法是通过添加强度字段到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中将全局Shadows的强度考虑到平行光Shadows的强度。这将剔除最后一级级联之外的所有Shadows。

    data.strength = _DirectionalLightShadowData[lightIndex].x * shadowData.strength;

GetDirectionalLight中恢复正确的衰减。

    light.attenuation = GetDirectionalShadowAttenuation(dirShadowData, surfaceWS);
    //light.attenuation = shadowData.cascadeIndex * 0.25;
被裁剪的Shadows;最大距离12


3.6 最大距离(Max Distance)

调整最大Shadows距离,会出现一些Shadows投射者仍在最后一层级联范围内,却突然消失的情况。这是因为最外层的裁剪球体并没有完全在配置的最大距离处结束,而是向外延伸了一点。这种差异在配置了较小的最大距离时最为明显。

我们可以通过在最大距离停止采样Shadows来修复间接出现Shadows的问题。为了使这种方案克星,我们必须在Shadows中向GPU发送最大距离。

    static int
        …
        cascadeCullingSpheresId = Shader.PropertyToID("_CascadeCullingSpheres"),
        shadowDistanceId = Shader.PropertyToID("_ShadowDistance");

    …

    void RenderDirectionalShadows () {
        …
        buffer.SetGlobalFloat(shadowDistanceId, settings.maxDistance);
        buffer.EndSample(bufferName);
        ExecuteBuffer();
    }

最大距离是基于视图空间(view-space)深度,而不是到相机坐标的距离。为了进行裁剪我们需要知道表面的深度。向Surface添加字段。

struct Surface {
    float3 position;
    float3 normal;
    float3 viewDirection;
    float depth;
    …
};

深度可以在LitPassFragment中找到,通过TransformWorldToView从世界空间(world-space)转换到视图空间(view-space),并对Z坐标取反。因为这个转换只是相对于世界空间的旋转和偏移,所以无论在视图空间还是世界空间,深度都是相同的。

    surface.viewDirection = normalize(_WorldSpaceCameraPos - input.positionWS);
    surface.depth = -TransformWorldToView(input.positionWS).z;

现在,不要总是在GetShadowData中把初始化强度为1,只有当表面深度小于最大距离时才这样做,否则将其设置为零。

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;
    …
}

基于深度进行裁剪


3.7 Shadows淡出(Fading Shadows)

在最大距离上突然切断Shadows是非常突兀的,所以让我们通过线性淡出使过渡更加平滑。淡出开始于最大值之前的一段距离,直到达到最大值时的强度为零。我们可以使用公式(1 - d / m) / f来将其收缩与0-1之间,其中d代表表面深度,m是最大Shadows距离,f是淡出范围,是最大距离的一小部分范围。

distance-fade-graph.png

在Shadows设置中为淡出距离添加滑块。由于淡出和最大距离值都会被用作除数,所以它们不应该是0,所以将它们的最小值设置为0.001。

    [Min(0.001f)]
    public float maxDistance = 100f;
    
    [Range(0.001f, 1f)]
    public float distanceFade = 0.1f;

将Shadows中的Shadows距离标识符替换掉,新的标识符同时用于距离值和淡出值。

        //shadowDistanceId = Shader.PropertyToID("_ShadowDistance");
        shadowDistanceFadeId = Shader.PropertyToID("_ShadowDistanceFade");

当将它们作为向量的XY分量发送给GPU时,使用1分别除以这两个值,这样我们就可以避免在着色器中做除法运算,因为乘法更快。

        buffer.SetGlobalFloat(shadowDistanceId, settings.maxDistance);
        buffer.SetGlobalVector(
            shadowDistanceFadeId,
            new Vector4(1f / settings.maxDistance, 1f / settings.distanceFade)
        );

调整Shadows.hlsl中的_CustomShadows缓冲区。

    //float _ShadowDistance;
    float4 _ShadowDistanceFade;

现在我们可以计算淡出Shadows强度了,公式为saturate( (1 - ds) f )1 / m用于s1 / 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
    );
    …
}

根据距离淡出


3.8 淡出级联(Fading Cascades)

我们也可以使用同样的方法,在最后级联的边缘淡出Shadows,而不是将它们突然剔除。添加级联淡出Shadows设置滑块。

    public struct Directional {

        …

        [Range(0.001f, 1f)]
        public float cascadeFade;
    }

    public Directional directional = new Directional {
        …
        cascadeRatio3 = 0.5f,
        cascadeFade = 0.1f
    };

唯一的区别是,我们为级联淡出处理的是距离的平方和半径,而不是线性深度和最大距离值。这意味着转换因子变成了非线性的公式:(1 - square(d) / square(r) ) / fr是裁剪球体的半径。差别并不大,但是为了保持配置的淡出比率相同,我们需要把f替换为1 - square(1 - f)。然后我们将它存储到Shadows距离淡出向量的Z分量中,同样被1除。

使用距离平方,f 分别为0.1、0.2、0.5的曲线

        float f = 1f - settings.directional.cascadeFade;
        buffer.SetGlobalVector(
            shadowDistanceFadeId, new Vector4(
                1f / settings.maxDistance, 1f / settings.distanceFade,
                1f / (1f - f * f)
            )
        );

为了执行级联淡出,在GetShadowData的循环中检查我们是否在最后级联中。如果是,计算级联的淡出Shadows强度,并将其纳入最终强度。

    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;
        }
    }

同时使用级联淡出和距离淡出


4. 阴影质量(Shadow Quality)

现在我们有了级联Shadows图集功能,让我们专注于改善Shadows的质量。我们一直以来观察到的伪影被称为Shadows暗斑(shadow-acne),它是由表面不正确的自Shadows引起的,这些自Shadows没有完美地与光线方向对齐,随着表面逐渐接near-plane行于光的方向时,暗斑会变得越来越糟糕。

Shadows暗斑

增加图集大小会减少纹素的世界空间大小,所以暗斑的伪影会变小。然而,伪影的数量也会增加,因此不能通过简单地增加图集大小来解决这个问题。


4.1 深度偏移(Depth Bias)

有各种各样的方法来减轻Shadows暗斑。最简单的方法是在Shadows投射者的深度中添加固定的偏差,将他们在光的方向上推远,这样错误的自Shadows就不会发生了。添加这种技术的最快方法是在渲染时使用全局深度偏差,在绘制Shadows之前调用缓冲区的SetGlobalDepthBias,然后将其设置为0。 这是应用于裁剪空间的深度偏差和非常小的值的倍数,具体取决于Shadows贴图使用的格式。 我们可以通过使用大的值,比如50000,来了解它是如何工作的。 还有第二个关于斜率补偿偏移的参数,但我们暂时把它设为0。

buffer.SetGlobalDepthBias(50000f, 0f);
ExecuteBuffer();
context.DrawShadows(ref shadowSettings);
buffer.SetGlobalDepthBias(0f, 0f);

固定的深度偏移

固定的偏移很简单,但只能去除主要是正面照明的表面的伪影。去除所有暗斑需要更大的偏差。

            buffer.SetGlobalDepthBias(500000f, 0f);

更大的深度偏移

然而,当深度偏差推动Shadows投射器远离光线时,采样的Shadows也会向相同的方向移动。偏移足够大,可以消除大多数暗斑不变的移动Shadows太远,他们似乎脱离了他们的Shadows投射者,造成视觉伪影,这种现象被称为Shadows平移(Peter Panning,这个词源于一本儿童小说中的人物,他也可以飞,他的影子可以分离)。

深度偏差引起的Shadows平移

另一种方法是应用斜率补偿偏移,这是通过对SetGlobalDepthBias的第二个参数使用非零值来实现的。 该值用于沿X和Y维度缩放绝对裁剪空间深度导数的最大值。 因此,对于正面被光照的表面,它是0,当光线以至少维度的45度角照射时,它是1,当表面法向和光方向的点积达到0时,它接近无穷大。所以当需要更大的偏差时,它会自动增加,但没有上限。

    buffer.SetGlobalDepthBias(0f, 3f);

斜率补偿偏移

斜率补偿偏移是有效的,但不是直观的。 需要进行实验,以达到可接受的结果。 因此,让我们暂时禁用它,寻找一种更直观的方法。

            //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)

产生不正确的自Shadows是因为Shadows施法者的深度纹素覆盖了多个片元,这导致施法者的体积"戳破"了它的表面。所以如果我们把施法者缩小到一定程度,这种情况就不会再发生了。 然而,收缩Shadows施法者将使Shadows比他们应该有的尺寸的更小,并且会产生不应该存在的洞。

我们也可以做相反的事情:在采样Shadows时使表面膨胀。 然后我们在离表面稍远的地方采样,刚好足够避免不正确的自Shadows。这将稍微调整Shadows的位置,可能会导致沿边缘的错位,并添加伪影,但这些伪影往往远没有Peter-Panning明显。

我们可以通过沿着表面的法向量移动一点位置来实现采样Shadows的目的。如果我们只考虑维度,那么等于世界空间纹素大小的偏移量就足够了。我们可以在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

在shader方面,在GetDirectionalShadowAttenuation中为全局Shadows数据添加参数。 将表面法线与偏移量相乘从而计算法线偏差,并将其结合世界空间坐标,然后计算Shadows贴图空间中的位置。

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);

法线偏移与纹素大小相同


4.4 可配置的偏移(Configurable Biases)

法线偏移可以在目前的情况下可以做到消除Shadows暗斑,但它不能消除所有的Shadows问题。 例如,墙壁下面的地板上有一些不应该出现的Shadows线。这并不是自Shadows,而是从墙上延伸的Shadows影响着墙下的地板。 添加一点斜率补偿偏移可以解决这些问题,但它没有完美的值,需要我们动态调整。所以我们将使用他们现有的偏移滑块来配置每个光源。为它添加字段到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);

让我们也使用光的Normal Bias滑块来调节我们应用的法线偏移。 让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,以及在ShadowsGetDirectionalShadowAttenuation中使用它。

struct DirectionalShadowData {
    float strength;
    int tileIndex;
    float normalBias;
};

…

float GetDirectionalShadowAttenuation (…) {
    …
    float3 normalBias = surfaceWS.normal *
        (directional.normalBias * _CascadeData[global.cascadeIndex].y);
    …
}

并在LightGetDirectionalShadowData中配置它。

    data.tileIndex =
        _DirectionalLightShadowData[lightIndex].y + shadowData.cascadeIndex;
    data.normalBias = _DirectionalLightShadowData[lightIndex].z;

我们现在可以调整每个光的偏移。 斜率补偿偏差为0,法线偏差为1是看起来不错的默认值。如果增加第的值,第二个值就应该减少。但请记住,我们对这些光设置的解释与它们最初的用途不同。它们曾经是裁剪空间(clip-space)的深度偏差和世界空间的收缩法线偏移。因此,当你创建新的光源时,你会得到很重的Peter-Panning,直到你调整偏移。

偏差设置为 0.6


4.5 Shadows平坠(Shadow Pancaking)

另可能的潜在问题是Shadows平坠。这个问题的原因是,当为定向光渲染Shadows投射者时,near-plane尽可能向前移动。这增加了深度精度,但这意味着不在相机机视野中的Shadows投射者可能会出现在near-plane面前,这导致它们在错误情况下被剔除。

Shadows被剔除

这是通过在ShadowCasterPassVertex中收缩顶点位置到near-plane来解决的,有效地压扁位于near-plane面前面的Shadows投射者,将它们变成粘在near-plane上的煎饼。为此,我们取裁剪空间Z和W坐标的最大值,或者在定义UNITY_REVERSED_Z时取它们的最小值。要为W坐标使用正确的符号,将其与UNITY_NEAR_CLIP_VALUE相乘。

    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

Shadows被收缩

这对于完全位于near-plane面两侧的Shadows投射器来说是非常有效的,但穿过平面的Shadows投射者会因为只有一些顶点受到影响而变形。这对于小三角形来说是不明显的,但大三角形最终会变形很多,被玩去而且经常导致它们陷入表面。

很长立方体的畸形Shadows

这个问题可以通过将near-plane向后拉一点来缓解。这就是灯光的near-plane滑块的作用。 添加near-plane偏移字段到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的最后参数来让它生效,我们仍然给它固定的0值。

        cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(
                light.visibleLightIndex, i, cascadeCount, ratios, tileSize,
                light.nearPlaneOffset, out Matrix4x4 viewMatrix,
                out Matrix4x4 projectionMatrix, out ShadowSplitData splitData
            );

near-plane偏移


4.6 百分比渐进过滤(PCF Filtering)

到目前为止,我们只使用了硬Shadows,每个片元对Shadows贴图采样一次。Shadows对照采样器使用一种特殊形式的双线性插值,在插值之前执行深度比较。 这被称为百分比接近过滤(Percentage-Closer-Filtering,简称PCF),具体来说就是2×2 PCF过滤器涉及到4个纹素等等。

但这并不是我们过滤Shadows贴图的唯一方法。我们也可以使用更大的滤波器,使Shadows更柔和,虽然同样是也不太准确。让我们添加对2×2、3×3、5×5和7×7滤波器的支持。我们不会使用现有的软Shadows模式来控制每个光,我们将为所有平行光使用相同的滤波器。为此添加FilterMode枚举到ShadowSettings,以及滤波器选项到Directional,默认设置为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,
        …
    };

滤波器设置为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)
        );

#pragma multi_compile指令添加到LitCustomLit通道中,用于三个关键字,并为匹配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关键字,我们总共需要4个过滤器采样,我们将使用SampleShadow_ComputeSamples_Tent_3x3函数进行设置。我们只需要取4个采样,因为每个采样都使用双线性2×2滤波器。在所有方向上偏移半个纹素的正方形,覆盖了3×3的纹素,使用帐篷滤波器(译注:建立宽为1,高为采样点值的图表,将值连在一起形象像帐篷…其实我觉得更像个勺子~),中间比边缘有更大的权重。


帐篷滤波器是如何工作的?
.
Bloom教程介绍了利用双线性纹理采样的过滤器内核,而Depth of Field教程包含了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滤波器的9个采样和7×7过滤器的16个采样,以及适当命名的函数。

#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函数用于Shadows瓦片空间位置。 当DIRECTIONAL_FILTER_SETUP被定义时,它需要采样多次,否则调用一次SampleDirectionalShadowAtlas就足够了。

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数组。 之后,我们可以循环遍历所有的采样器,根据它们的权重对它们进行调整。

    #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

    float shadow = FilterDirectionalShadow(positionSTS);
    return lerp(1.0, shadow, directional.strength);

PFC 2X2、3X3、5X5、7X7

增加滤镜的大小使Shadows更平滑,但也会导致Shadows暗斑再次出现。我们必须增加法线偏移,以匹配滤波器的大小。 我们可以通过在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、7x7,使用偏移缩放

这再次解决了Shadows暗斑,但增加的滤波器尺寸加重了应用法线偏移的瑕疵,使我们之前看到的墙壁Shadows问题也变得更糟。需要一些斜率补偿偏移或更大的Shadows图集大小来减轻这些伪影。


难道我们不应该为法线偏移减小半径吗?
.
法线是按光定义的,所以不能按级联设置。幸运的是,只有当偏移将Shadows采样推到选定的级联之外时,它才成为问题。这只适用于指向相机外的法线,这意味着它几乎总是局限于不可见的表面。如果偏移确实会引起问题那么你可以通过一些可配置的因素来增加半径缩减。


4.7 级联混合(Blending Cascades)

柔和的阴影看起来更好,但它们也使级联之间的突然过渡更明显。

突兀的级联过度

我们可以通过在级联之间添加一个过度区域,将两级联混合起来,使变化不那么引人注目——尽管不是完全隐藏。我们已经有了一个可以用于此目的的级联衰减因子。

首先,添加一个级联混合值到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);

柔和的级联过度

请注意,级联衰减比适用于每个级联的整个半径,而不仅仅是其可见部分。所以要确保这个比率不会一直延伸到更低的级联。 一般来说,这不是一个问题,因为你会希望保持较小的过渡区域。

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
    };

级联混合模式

Shadows添加一个静态数组,用于Soft、Dither模式的级联混合关键字。

    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]);
            }
        }
    }

将所需的multi_compile添加到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位置。 它还需要第二个参数,这个参数是用来动画化它的,我们不需要它,可以把它设为0。

    surface.smoothness =
        UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Smoothness);
    surface.dither = InterleavedGradientNoise(input.positionCS.xy, 0);

GetShadowData中设置级联索引之前,当不使用Soft混合时,将级联混合设置为零。 这样整个分支就会从那些Shader变体中消失。

    if (i == _CascadeCount) {
        data.strength = 0.0;
    }
    #if !defined(_CASCADE_BLEND_SOFT)
        data.cascadeBlend = 1.0;
    #endif
    data.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

噪声化的级联

噪声混合的可接受程度取决于我们渲染帧的分辨率。 如果使用后期效果来覆盖最终结果,那么它可以非常有效,例如随机采样抗锯齿(temporal anti-aliasing)和动画化的噪声模式。

4.9 剔除偏移(Culling Bias)

使用级联阴影贴图的一个缺点是,我们最终会为每个光源渲染多次相同的阴影投射。如果结果能够保证较小的级联覆盖到阴影投射者,那么尝试从较大的级联中剔除一些阴影投射者是有意义的。Unity通过将shadowCascadeBlendCullingFactorshadowCascadeBlendCullingFactor设置为1来实现这一点。 在应用到阴影设置之前,在RenderDirectionalShadows中这样做。

            splitData.shadowCascadeBlendCullingFactor = 1f;
            shadowSettings.splitData = splitData;

裁剪偏移0、1

该值是一个调节用于执行剔除的前一级联半径的因子。 Unity在剔除时是相当保守的,但我们应该通过级联渐变比例来降低它,并额外增加一些,以确保过度区域的阴影投射者不会被剔除。 所以我们使用0.8减去渐变范围,最小值为0。 如果你看到洞出现在级联过度的阴影中,那么这个值必须进一步减少。

        float cullingFactor =
            Mathf.Max(0f, 0.8f - settings.directional.cascadeFade);
        
        for (int i = 0; i < cascadeCount; i++) {
            …
            splitData.shadowCascadeBlendCullingFactor = cullingFactor;
            …
        }


5. 透明(Transparency)

我们将通过包含透明的阴影投射者来结束本章。ClipFadeTransparent材质都可以像不透明材质一样接收阴影,但是目前只有Clip材质可以投射正确的阴影。透明物体的表现就像它们是实心的阴影投射者。

裁剪阴影的透明

5.1 阴影模式(Shadow Modes)

有几种方法可以修饰阴影。由于涉及到写入深度缓冲区,我们的阴影是二进制的,无论是否存在,但这仍然给了我们一些灵活性。它们可以是固态的、剔除的、噪声的、或者完全关闭。 我们可以独立于其他材质特性来做这个,以支持最大的灵活性。 所以让我们为它添加一个单独的_Shadows属性。 我们可以使用KeywordEnum属性为它创建一个关键字菜单,默认是启用阴影。

        [KeywordEnum(On, Clip, Dither, Off)] _Shadows ("Shadows", Float) = 0

启用阴影

为这些模式添加一个shader_feature,取代现有的_CLIPPING。 我们只需要三种变体,__SHADOWS_CLIP_SHADOWS_DITHER

            //#pragma shader_feature _CLIPPING
            #pragma shader_feature _ _SHADOWS_CLIP _SHADOWS_DITHER

CustomShaderGUI中为阴影创建一个setter属性。

    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混合的部分。

使用阴影裁剪的透明材质

注意,裁剪的阴影不像固态阴影一样稳定,因为阴影矩阵在视图移动时发生了变化,这导致片段移动了一点。 这可能会导致阴影贴图的纹素突然从被剪切过度到未被剪切。

5.3 噪声阴影(Dithered Shadows)

噪声阴影的原理与裁剪阴影相似,只是尺度不同。在这个例子中,我们将表面透明度减去一个噪声值,裁剪就依靠这个。我们可以再次使用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

噪声化的阴影

噪声可以用于近似于半透明阴影,但这是一种相当粗糙的方法。 硬噪声的阴影看起来不好,但当使用较大的PCF滤波器时,它可能看起来可以接受。

使用 PCF7X7 的噪声阴影

因为噪声模式是逐纹素固定的,重叠的半透明阴影投射者不会产生组合的更暗的阴影。这种表现就像只绘制了最不透明的阴影投射者一样。 此外,由于噪声模式是杂乱的,当阴影矩阵变化时,它会遭受更多的临时伪影,这可能会使阴影看起来在抖动。只要物体不移动,那这种方法对其他有固定投影更有效。对于半透明的物体,使用裁剪的阴影或完全不使用阴影通常更实用。

5.4 无阴影(No Shadows)

通过调整对象MeshRenderer组件的Cast Shadows设置,可以逐对象关闭阴影投射。然而,如果你想禁用所有使用相同材质的阴影,这是不友好的,所以我们也将支持对每个材质禁用阴影。 我们可以通过关闭材质的ShadowCaster来实现。

CustomShaderGUI中添加一个SetShadowCasterPass方法,检查_Shadows着色器属性是否存在。 如果是,还要检查所有选择的材质是否通过它的hasMixedValue属性设置为相同的模式。 如果没有,或者是中止混合,则启用或禁用所有材质的ShadowCaster通道,通过调用SetShaderPassEnabled,以通道名称和启用状态作为参数。

    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正确设置。我们可以通过调用EditorGUI.BeginChangeCheckEditorGUI.EndChangeCheck来实现。后一个方法返回在开始检查后是否有什么变化。 如果是,设置阴影投射者通道。

    public override void OnGUI (
        MaterialEditor materialEditor, MaterialProperty[] properties
    ) {
        EditorGUI.BeginChangeCheck();
        …
        if (EditorGUI.EndChangeCheck()) {
            SetShadowCasterPass();
        }
    }

没有投射阴影

5.5 无光照的阴影投射者(Unlit Shadow Casters)

虽然无光照的材质不受灯光影响,但你可能希望它们投射阴影。我们可以通过简单地将ShadowCaster通道从光照着色器复制到无光照着色器来支持这个。

无光照但投射了阴影

5.6 接收阴影(Receiving Shadows)

最后,我们还可以让有光照的表面忽略阴影,这可能对全息影响之类的技术有用,或者只是为了一些艺术目的。为此添加一个_RECEIVE_SHADOWS关键字到Lit

        [Toggle(_RECEIVE_SHADOWS)] _ReceiveShadows ("Receive Shadows", Float) = 1

CustomLit通道中加上相应的着色器特性。

#pragma shader_feature _RECEIVE_SHADOWS

接收阴影

我们所要做的就是在GetDirectionalShadowAttenuation定义关键字时,强制阴影衰减为1。

float GetDirectionalShadowAttenuation (…) {
    #if !defined(_RECEIVE_SHADOWS)
        return 1.0;
    #endif
    …
}

投射但不接收阴影


下一个章节是 烘焙光(Baked Light)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,491评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,856评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,745评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,196评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,073评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,112评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,531评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,215评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,485评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,578评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,356评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,215评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,583评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,898评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,497评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,697评论 2 335

推荐阅读更多精彩内容