Unity自定义SRP(四):平行光阴影

https://catlikecoding.com/unity/tutorials/custom-srp/directional-shadows/

渲染阴影

这里使用shadow map的方法生成阴影

阴影设置

在渲染前我们先进行相关的阴影配置,包括阴影质量,渲染阴影的距离,阴影贴图的大小。新建ShadowSettings类,添加最大距离:

[System.Serializable]
public class ShadowSettings
{
    [Min(0.001f)]
    public float maxDistance = 100f;
}

对于贴图大小,我们新建一个MapSize枚举:

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

接着新建一个结构体,用于包含针对平行光的阴影:

    [System.Serializable]
    public struct Directional
    {
        public MapSize atlasSize;
    }

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

CustomRenderPipelineAsset中加入可配置项:

    [SerializeField]
    ShadowSettings shadows = default;

CustomRenderPipeline实例构建时传入设置:

    protected override RenderPipeline CreatePipeline()
    {
        return new CustomRenderPipeline(useDynamicBatching, useGPUInstancing, useSRPBatcher, shadows);
    }

修改CustomRenderPipeline:

    ShadowSettings shadowSettings;

    public CustomRenderPipeline(bool useDynamicBatching, bool useGPUInstancing, bool useSRPBatcher, ShadowSettings shadowSettings)
    {
        this.shadowSettings = shadowSettings;
        ...
    }

传递设置

在调用CameraRenderer的Render时传递设置:

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

CameraRenderer.Render接着传递到Lighting.Setup和Cull方法:

    public void Render(ScriptableRenderContext context, Camera camera, bool useDynamicBatching, bool useGPUInstancing, ShadowSettings shadowSettings)
    {
        ...
        if (!Cull(shadowSettings.maxDistance))
        {
            return;
        }
        ...
        lighting.Setup(context, cullingResults, shadowSettings);
        ...
    }

在Cull中我们应用阴影渲染最大距离来进行剔除:

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

Lighting.Setup也添加上相应的参数:

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

Shadows类

我们模仿Lighting为阴影创建自己的类Shadows

using System.Collections;
using System.Collections.Generic;
using Unity.Collections;
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方法:

    Shadows shadows = new Shadows();

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

产生阴影的灯光

渲染阴影可能会降低帧率,因此最好限制一下产生阴影的平行光的数量,在Shadows中,一开始我们设为1进行测试:

    const int maxShadowedDirLightCount = 1;

我们并不清楚哪些可见光会产生阴影,因此需要追踪,为方便,我们定义一个ShadowedDirectonalLight结构体,内含索引属性:

    struct ShadowedDirectionalLight
    {
        public int visibleLightIndex;   
    }

    ShadowedDirectionalLight[] shadowedDirectionalLights = new ShadowedDirectionalLight[maxShadowedDirLightCount];

为找出哪些可见光会产生阴影,我们添加一个ReserveDirectionalShadows方法,它的任务是为灯光的阴影贴图在阴影图集中预留空间,并存储渲染阴影所需的信息:

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

因为可产生阴影的光源的数量是被限制住的,因此我们需要追踪哪些光源已经被保留了。在Setup方法中将数量置为0,接着在ReserveDirectionalShadows中判断是否达到最大数量,若无,则存储灯光索引,并增加数量:

    int shadowedDirLightCount;

    public void Setup(...)
    {
        ...
        shadowedDirLightCount = shadowedOtherLightCount = 0;
    }

    public void ReserveDirectionalShadows(Light light, int visibleLightIndex)
    {
        // 若平行光数量未达到最大值
        if (shadowedDirLightCount < maxShadowedDirLightCount)
        {
            shadowedDirectionalLights[shadowedDirLightCount++] = new ShadowedDirectionalLight
            {
                visibleLightIndex = visibleLightIndex,
            };
        }

不过只有灯光开启产生阴影时才应该存储信息:

        if (shadowedDirLightCount < maxShadowedDirLightCount &&
            light.shadows != LightShadows.None && light.shadowStrength > 0f)

同时,一个可见光不会影响其范围外的物体,我们可以使用GetShadowCasterBounds来获取一个可见光的阴影产生边界,该方法返回边界是否合法:

        if (shadowedDirLightCount < maxShadowedDirLightCount &&
            light.shadows != LightShadows.None && light.shadowStrength > 0f && 
            cullingResults.GetShadowCasterBounds(visibleLightIndex, out Bounds b))

现在我们可以在Lighting.SetupDirectionalLight中预设阴影:

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

创建阴影图集

我们在Shadows类中创建Render方法,在Lighting.Setup中调用:

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

Shadows.Render方法如下,其内部调用RenderDirectionalShadows方法:

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

    void RenderDirectionalShadows()
    {
    }

创建阴影贴图即将阴影投射物体写入纹理中。我们使用_DirectionalShadowAtlas来引用平行光阴影图集,其大小来自于设置中,然后调用命令缓冲中的GetTemporaryRT方法:

    static int
        dirShadowAtlasId = Shader.PropertyToID("_DirectionalShadowAtlas");

    void RenderDirectionalShadows()
    {
        int atlasSize = (int)settings.directional.atlasSize;
        buffer.GetTemporaryRT(dirShadowAtlasId, atlasSize, atlasSize);
    }

这样会声明一张四边形渲染纹理,默认为ARGB。我们需要的是阴影贴图,因此我们还需要深度缓冲的位数,滤波模式以及渲染纹理的类型:

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

在结束后我们需要删除掉临时的渲染贴图,在Cleanup中完成:

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

我们在Lighting中创建一个Cleanup调用:

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

CameraRenderer提交渲染前调用:

        lighting.Cleanup();
        Submit();

目前我们只能释放那些声明过纹理,但很明显我们应该在有阴影时才释放纹理。然而,不声明纹理在某些平台会导致纹理,例如WebGL2.0,它会将纹理和采样器绑定在一起。当shader加载时,缺失纹理就会编译失败,因为其默认纹理并不适配采样器,因此我们可以在没有阴影时手动声明一个1x1大小的纹理来避免这种情况:

    public void Render()
    {
        if (shadowedDirLightCount > 0)
        {
            RenderDirectionalShadows();
        }
        else
        {
            buffer.GetTemporaryRT(dirShadowAtlasId, 1, 1, 32, FilterMode.Bilinear, RenderTextureFormat.Shadowmap);
        }

在声明渲染纹理后,Shadows.RenderDirectionalShadows必须告知GPU渲染到纹理中,而不是摄像机目标,我们可以使用命令缓冲中的SetRenderTarget方法,确定渲染纹理,同时配置数据如何加载和存储:

        buffer.GetTemporaryRT(dirShadowAtlasId, atlasSize, atlasSize, 32, FilterMode.Bilinear, RenderTextureFormat.Shadowmap);理
        buffer.SetRenderTarget(dirShadowAtlasId, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store);

之后我们清除渲染目标,这里我们只关注深度缓冲。并执行命令缓冲:

        buffer.ClearRenderTarget(true, false, Color.clear);
        ExecuteBuffer();

先渲染阴影

我们应在调用CameraRenderer.Setup前渲染阴影,毕竟和普通的几何体的渲染目标不同:

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

接着在frame debugger中我们将阴影内嵌在摄像机中:

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

渲染阴影

我们在Shadows中添加一个渲染单个灯光阴影的RenderDirectionalShadows的变体,然后在针对所有灯光的RenderDiretionalShadows中按索引调用上面的变体,使用BeginSampleEndSample包裹:

    void RenderDirectionalShadows()
    {
        ...
        buffer.ClearRenderTarget(true, false, Color.clear);
        buffer.BeginSample(bufferName);
        ExecuteBuffer();

        for (int i = 0; i < shadowedDirLightCount; i++)
        {
            RenderDirectionalShadows(i, atlasSize);
        }
        buffer.EndSample(bufferName);
        ExecuteBuffer();
    }
    void RenderDirectionalShadows(int index, int tileSize)
    {
    }

为渲染阴影,我们需要一个ShadowDrawingSetting结构体,传入剔除结构和灯光索引:

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

阴影贴图的理念是从灯光视角渲染场景,只存储深度信息,其结果是灯光到阴影投射物的距离。然而,平行光并没有一个确切的位置,只有一个方向,因此我们可以找出匹配灯光朝向的view和projection矩阵,然后给定一个裁剪空间立方体,覆盖包含灯光阴影的摄像机可见区域。我们可以使用ComputeDirectionalShadowMatricesAndCullingPrimitives方法来完成。其包含9个参数,首先是灯光索引,接下来三个控制阴影的级联级别,然后是纹理大小,阴影的近裁剪面距离,最后是三个输出参数,view和projection矩阵,以及一个ShadowSplitStruct结构体变量:

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

splitData包含阴影投射物该如何被剔除的信息,我们需要复制到阴影设置中。我们可以调用SetVieProjectionMatrices来设置view和projection矩阵:

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

最后执行缓冲,并调用context上的DrawShadows方法来绘制阴影:

            ExecuteBuffer();
            context.DrawShadows(ref shadowSettings);

Shadow Caster Pass

目前阴影投射物不会渲被染,这是因为DrawShadows之渲染那些有ShadowCasterpass的材质的物体。这里我们添加相应的pass,tags进行相关设置。注意,这里不写入颜色。

        Pass 
        {
            Tags {"LightMode" = "ShadowCaster"}

            ColorMask 0

            HLSLPROGRAM
            #pragma target 3.5
            #pragma multi_compile_instancing
            #pragma vertex ShadowCasterPassVertex
            #pragma fragment ShadowCasterPassFragment
            #include "ShadowCasterPass.hlsl"
            ENDHLSL
        }

ShadowCaster.hlsl中,我们只需要裁剪空间的位置,基础颜色,片元着色器不需要返回值:

#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

多个光源

我们可以有至多4个平行光。

    const int maxShadowedDirLightCount =  4;

针对所有光源,我们的确渲染出了所有的阴影投射物,不过它们是合成在一起的,我们需要将图集拆分开,这样我们可以让每个灯光有自己的一块图集位置。

因为我们支持至多4个平行光,因此,若灯光数超过1个,我们需要将图集拆分为4份,也就是将每一块拼贴的大小调整为原本大小的一半即可,这样图集就会被分为4个等大的四边形区域。我们在Shadows.RenderDirectionalShadows中决定拆分数量和拼贴大小:

    void RenderDirectionalShadows()
    {
        ...
        int split = shadowedDirLightCount <= 1 ? 1 : 2;
        int tileSize = atlasSize / split;

        for (int i = 0; i < shadowedDirLightCount; i++)
        {
            RenderDirectionalShadows(i, split, tileSize);
        }
    }

    void RenderDirectionalShadows(int index, int split, int tileSize){...}

我们可以调整渲染视图的大小来渲染到单个拼贴中。设置相应的方法SetTileViewport,首先计算拼贴的偏移,xy轴均计算:

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

接着我们调用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(tileIndex, split, tileSize);
            buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);

阴影采样

为渲染阴影,我们需要在shader的pass中采样阴影贴图,用于判断某一片段是否被阴影影响。

阴影矩阵

对于每个片段,我们需要从阴影图集中恰当的拼贴处采样到深度信息,因此我们需要根据一个世界空间的位置得到阴影纹理的坐标。我们可以对每个阴影平行光创建一个阴影变换矩阵,传入GPU中。添加对应的属性:

    static int
        dirShadowAtlasId = Shader.PropertyToID("_DirectionalShadowAtlas"),  // 平行光阴影图集索引
        dirShadowMatricesId = Shader.PropertyToID("_DirectionalShadowMatrices"),    //平行光空间矩阵索引

    static Matrix4x4[]
        dirShadowMatrices = new Matrix4x4[maxShadowedDirLightCount * maxCascades],  //平行光空间矩阵

我们在RenderDirectionalShadows中创建从世界空间到灯光空间的矩阵:

    void RenderDirectionalShadows(int index, int split, int tileSize)
    {
            ...
            dirShadowMatrices[index] = projectionMatrix * viewMatrix;// 设置世界到灯光空间的变换矩阵

在总RenderDirectionalShadows中一次传入所有的灯光空间变换矩阵到GPU:

        buffer.SetGlobalMatrixArray(dirShadowMatricesId, dirShadowMatrices);

不过我们在使用一个阴影图集,因此创建一个专门的变换矩阵方法进行替换:

    Matrix4x4 ConvertToAtlasMatrix(Matrix4x4 m, Vector2 offset, float scale)
    {
        if (SystemInfo.usesReversedZBuffer)
        {
            m.m20 = -m.m20;
            m.m21 = -m.m21;
            m.m22 = -m.m22;
            m.m23 = -m.m23;
        }
        return m;
    }

同时让SetViewport返回拼贴偏移值,在上述方法的参数中调用。

注意SystemInfo.usesReversedZBuffer,即使用反转深度缓冲,0表示0,-1表示最大。OpenGL不使用该方案,0表示0,1表示最大,其它API使用反转策略。

另外,裁剪空间定义在-1到1的立方体中,0在中心,而纹理坐标以及深度范围为0-1,我们可以手动映射一下(每个坐标应用对应维度的平移量,然后值减半):

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

最后,应用偏移和缩放:

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

第三列不偏移和缩放是因为代表平行光的方向。

逐灯光存储阴影数据

为了为某一灯光采样阴影,我们需要知道在阴影图集中它的拼贴的索引,而该信息需要逐灯光存储。目前我们返回灯光的阴影强度和阴影拼贴的偏移,如果灯光不产生阴影就返回空:

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

Shadows HLSL文件

我们创建一个专门的Shadows.hlsl来进行阴影采样。创建最大阴影平行光数量宏定义,定义阴影图集纹理,以及灯光空间矩阵变量:

#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来定义一个采样器状态,针对深度数据有着不同的滤波模式:

TEXTURE2D_SHADOW(_DirectionalShadowAtlas);
SAMPLER_CMP(sampler_DirectionalShadowAtlas);

针对阴影贴图的采样设置只有一种较为恰当的:

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

采样阴影

为采样阴影,我们需要知道逐灯光阴影数据,因此我们首先定义一个针对平行光的阴影数据结构体,包含阴影强度和拼贴偏移:

struct DirectionalShadowData
{
    float strength;
    int tileIndex;
};

同时记得在Surface中定义一个位置属性。

添加一个SampleDirectionalShadowAtlas方法采样阴影图集。可以使用SAMPLE_TEXTURE2D_SHADOW宏,传入阴影图集,阴影采样器,以及阴影纹理空间中的位置:

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

接着添加一个GetDirectionalShadowAttenuation方法,返回阴影的衰减。我们首先得到阴影纹理空间的位置,然后采样阴影图集得到阴影:

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

阴影图集的采样结果是一个因数,其决定了有多少光会到达表面,0-1即衰减值。

如果我们人为将阴影强度置为0的话,那么衰减就应该是1。因此最终的结果应该是根据阴影强度在1和衰减值间线性插值:

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

不过阴影强度为0的话,就不需要采样了,可直接返回1:

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

衰减光

我们在Light结构体中添加衰减属性:

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

同时添加一个获取平行光阴影数据的方法:

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

然后增加一个Surface参数,使用GetDirectionalShadowData获得阴影数据,然后是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;
}

最后在IncomingLight中应用灯光衰减,调整光颜色:

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

目前就可以得到阴影了,不过会有阴影痤疮问题,条带感很重,这是阴影贴图分辨率导致的问题。此外,由于目前的阴影贴图与灯光方向绑定,可能会有阴影缺失的问题。同时,如果开启多个平行光的话,阴影贴图的边界采样可能会有重合。

级联阴影贴图

在最大阴影产生距离的范围内,平行光可以影响所有的东西,即阴影贴图会覆盖很大一片区域。因为阴影贴图使用正交投影,也就是说阴影贴图中的每个纹素有着固定的世界空间尺寸,如果尺寸过大,那么单个阴影纹素会清晰可见,那么阴影的边界会很粗糙,同时尺寸小的阴影会消失。可以通过提升图集大小来缓解,但总有限制。

当使用一个透视摄像机观察物体时,越远越小。假设在某一可视距离下,一个阴影贴图纹素可能会映射到单个像素上,这就意味着此时阴影分辨率是理论最佳的。那么越靠近摄像机,我们就需要更高分辨率的阴影,反之越远离摄像机,分辨率要求越低,这表明了我们可以基于到阴影接受体的观察距离使用一个变化的阴影贴图分辨率。

级联阴影贴图就是类似的解决方案。阴影投射物会被渲染不止一次,所有每个灯光会在图集中得到多次的拼贴,也可称为级联。第一级只覆盖靠近摄像机的一小片区域,接下来的级别依次远离摄像机,使用相同数量的纹素覆盖更大的区域,shader接着为每个片元采样可获得的最佳级别。

设置

Unity的阴影代码对每个平行光支持至多4个级联级别。目前我们只使用了单个级联级别,为此,我们为平行光阴影设置添加级联数量属性。每个级别覆盖阴影范围的一部分,我们为前三个级别配置其所占部分,最后一个级别覆盖全部范围:

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

渲染级联

每个级联级别要求其自己的变换矩阵,因此我们需要扩展相应的数组大小:

    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

之后,在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;

现在RenderDirectionalShadows需要针对每个级别绘制阴影,因此构建一个循环,遍历所有级别。在循环开始前,获得当前级别数,据此得到拼贴偏移量,从设置中获得级联比率:

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

剔除球

Unity通过创建一个剔除球体来决定每个级联级别的覆盖区域。当阴影投影是正交时,范围会覆盖整个球体,以及周围的一些区域,这也是为什么有些阴影会在剔除范围外可见。当然,灯光的方向不会影响剔除求,因此所有的平行光使用同一剔除球。

这些球体可以被用来决定采样哪一级联级别,因此我们需要将这一数据送往GPU。添加级联数量和级联剔除球数组属性,同时定义一个存储球体数据的静态数组,类型为Vector4,对应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输出的split数据的一部分,我们在循环中赋予数据,一次即可:

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

采样级联

Shadows.hlsl中声明相应变量:

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

级联索引逐片段决定,为此我们额外定义一个阴影数据的结构体。同时针对世界空间中的表面数据创建一个获取阴影数据的方法:

struct ShadowData 
{
    int cascadeIndex;
};

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

GetDirectionalShadowData中应用:

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

GetDirectionalLightGetLighting同理:

Light GetDirectionalLight (int index, Surface surfaceWS, ShadowData shadowData) 
{
    …
    DirectionalShadowData dirShadowData =
        GetDirectionalShadowData(index, shadowData);
    light.attenuation = GetDirectionalShadowAttenuation(dirShadowData, surfaceWS);
    return light;
}
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;

剔除阴影采样

如果在最后一个级联的范围外,我们就不应该进行阴影采样,最简单的方法是为ShadowData结构体添加一个强度属性,默认设为1,最后一个级联级别时设为0:

struct ShadowData 
{
    int cascadeIndex;
    float strength;
};

ShadowData GetShadowData (Surface surfaceWS) 
{
    ShadowData data;
    data.strength = 1.0;
    int i;
    for (i = 0; i < _CascadeCount; i++) 
    {
        …
    }

    if (i == _CascadeCount) 
    {
        data.strength = 0.0;
    }

    data.cascadeIndex = i;
    return data;
}

接着将该强度应用于GetDirectionalShadowData中的平行光阴影强度:

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

最大距离

使用最大阴影距离来进行剔除的话,一些阴影投射物可能会在最后的级联级别的球体范围内,但还是会剔除,这是因为该球体的半径会稍稍大于最大阴影距离。

我们可以在最大距离处也停止阴影采样,为此,将最大阴影距离数据送往GPU:

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

    …

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

最大阴影距离是基于观察空间的深度,而不是到摄像机的距离,为此,我们需要知道观察空间表面的深度:

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

深度可以在片元着色器中使用TransformWorldToView来变换世界空间的Z坐标并取反来得到:

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

然后,我们可以使用该深度来确定阴影数据中的强度:

CBUFFER_START(_CustomShadows)
    …
    float _ShadowDistance;
CBUFFER_END

…
ShadowData GetShadowData (Surface surfaceWS) 
{
    ShadowData data;
    data.strength = surfaceWS.depth < _ShadowDistance ? 1.0 : 0.0;
    …
}

渐变阴影

在最大距离处直接裁剪阴影是很明显和突兀的,所以说我们最好进行线性渐变。渐变从快到达最大距离处开始,在最大距离处变为0。我们可以使用\frac {1 - \frac {d} {m} } {f}来计算,并将范围限制在0-1,其中d是表面深度,m是最大阴影距离,f是渐变范围。

在阴影设置中添加一个渐变属性:

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

将最大阴影距离属性替换为两者都存储的属性:

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

将数据送往GPU前提前进行倒数操作:

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

现在,我们可以计算渐变阴影强度,创建一个FadedShadowStrength方法:

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

渐变级联

对于最后一个级联级别的边界我们可以使用类似的渐变。添加阴影设置属性:

    public struct Directional 
    {

        …

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

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

不过有一点区别,我们现在使用的是平方距离和平方半径,也就是说\frac {1-\frac {d^2} {r^2} } {f},这是非线性的,为此,我们将分母f替换为1-(1-f)^2,这样结果会比较接近线性变化:

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

在渲染级联的循环中,判断当前的级联索引是否是最后一个,然后执行相应的操作:

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

阴影质量

现在来改善阴影痤疮的质量问题。

深度偏移

最简单的方法是对阴影投射物的深度添加一个偏移量,但想要解决所有问题的话,就需要很大的偏移量,这就会造成彼得平移的问题。因此我们使用梯度缩放偏移,梯度值用于缩放裁剪空间中沿x和y求导得到的最大深度值,所以说灯光在表面头顶的话,值就为0,灯光在45°夹角时值为1,如果光与表面平行那就无限大。我们可以使用SetGlobalDepthBias来设置,第一个参数为偏移量,第二个参数为缩放值,举个例子():

            buffer.SetGlobalDepthBias(0f, 3f);

但该方法还不够直接。

级联数据

因为痤疮的大小取决于世界空间纹素的大小,我们需要一个能够在任何情况下都能够起作用的方法。纹素大小逐级联级别变化,我们需要传入更多的级联数据到GPU:

    static int
        …
        cascadeCullingSpheresId = Shader.PropertyToID("_CascadeCullingSpheres"),
        cascadeDataId = Shader.PropertyToID("_CascadeData"),
        shadowDistanceFadeId = Shader.PropertyToID("_ShadowDistanceFade");

    static Vector4[]
        cascadeCullingSpheres = new Vector4[maxCascades],
        cascadeData = new Vector4[maxCascades];
        buffer.SetGlobalVectorArray(
            cascadeCullingSpheresId, cascadeCullingSpheres
        );
        buffer.SetGlobalVectorArray(cascadeDataId, cascadeData);

我们设置一个单独的方法SetCascadeData,用于设置级联数据:

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

shader中也一并替换:

CBUFFER_START(_CustomShadows)
    int _CascadeCount;
    float4 _CascadeCullingSpheres[MAX_CASCADE_COUNT];
    float4 _CascadeData[MAX_CASCADE_COUNT];
    …
CBUFFER_END
                data.strength *= FadedShadowStrength(
                    distanceSqr, _CascadeData[i].x, _ShadowDistanceFade.z
                );

法线偏移

自阴影产生的原因是一个阴影投射物的深度纹素覆盖了不止一个片元,那么投射物的体就会超过表面,那么只要我们缩小投射物的话,就不会发生这样的情况了,然而,这样会让阴影变小。

我们可以反向思考,在采样阴影时膨胀表面,这样在采样时会稍微远离表面,这就足够避免不正确的自阴影情况了。这样子会稍微修改阴影的位置,但还是在可接受范围内的。

我们可以在采样阴影前,将表面的位置沿法线扩张一段距离,如果我们只考虑1个维度的话,那么等价与世界空间纹素大小的偏移就足够了, 可以在SetCascadeData中将剔除球的直径与拼贴尺寸相除来得到:

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

不过这也不大够,因为纹素是正方形的,也就是说最差的情况就相当于沿正方形的对角线偏移,我们将其缩放:

            texelSize * 1.4142136f

在shader中,我们使用该数据沿法线偏移,并使用偏移后的位置采样:

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

可配置偏移

法线偏移解决了阴影痤疮问题,但不能消除所有的阴影问题。我们可以额外添加一个梯度缩放偏移来双重缓解。该属性逐灯光配置,:

    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滑条来配置法线偏移属性:

    public Vector3 ReserveDirectionalShadows (
        Light light, int visibleLightIndex
    ) 
    {
        if (…) 
        {
            …
            return new Vector3(
                light.shadowStrength,
                settings.directional.cascadeCount * ShadowedDirectionalLightCount++,
                light.shadowNormalBias
            );
        }
        return Vector3.zero;
    }

在shader中应用:

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

…

float GetDirectionalShadowAttenuation (…) 
{
    …
    float3 normalBias = surfaceWS.normal *
        (directional.normalBias * _CascadeData[global.cascadeIndex].y);
    …
}
    data.tileIndex =
        _DirectionalLightShadowData[lightIndex].y + shadowData.cascadeIndex;
    data.normalBias = _DirectionalLightShadowData[lightIndex].z;

阴影平坠

另一个导致阴影问题的可能因素是Unity使用的阴影平坠技术,即当对平行光渲染阴影投射物时,进裁剪面会尽量移得靠前,排除不可见物体,这可以提高深度精度,但这也意味着阴影投射物可能会被裁剪。

我们可以在ShadowCasterPassVertex中夹紧顶点坐标值到近裁剪平面。

    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滑条中的值:

    struct ShadowedDirectionalLight {
        public int visibleLightIndex;
        public float slopeScaleBias;
        public float nearPlaneOffset;
    }
            shadowedDirectionalLights[ShadowedDirectionalLightCount] =
                new ShadowedDirectionalLight {
                    visibleLightIndex = visibleLightIndex,
                    slopeScaleBias = light.shadowBias,
                    nearPlaneOffset = light.shadowNearPlane
                };

ComputeDirectionalShadowMatricesAndCullingPrimitives中应用:

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

PCF滤波

ShadowSettings中添加一个FilterMode的枚举,默认设置为2 \times 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,
        …
    };

针对这些滤波模式我们要创建对应的shader变体。添加关键字数组:

    static string[] directionalFilterKeywords = 
    {
        "_DIRECTIONAL_PCF3",
        "_DIRECTIONAL_PCF5",
        "_DIRECTIONAL_PCF7",
    };

我们创建SetKeywords函数,用于设置关键字:

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

在shader中声明:

CBUFFER_START(_CustomShadows)
    …
    float4 _ShadowAtlasSize;
    float4 _ShadowDistanceFade;
CBUFFER_END

送往GPU:

        SetKeywords();
        buffer.SetGlobalVector(
            shadowAtlasSizeId, new Vector4(atlasSize, 1f / atlasSize)
        );

在pass中添加对应的multi_compile命令:

            #pragma shader_feature _PREMULTIPLY_ALPHA
            #pragma multi_compile _ _DIRECTIONAL_PCF3 _DIRECTIONAL_PCF5 _DIRECTIONAL_PCF7
            #pragma multi_compile_instancing

无关键字对应2 \times 2

我们使用在Core RPShadow/ShadowSamplingTent.hlsl的方法。我们对每种滤波模式定义滤波采样数和对应的滤波初始化方法,比如4,对应SampleShadow_ComputeSamples_Tent_3x3,我们只需要4个采样,因为每个使用二次线性2\times 2滤波器。

#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,那么多次采样,反之使用一次采样:

float FilterDirectionalShadow (float3 positionSTS) 
{
    #if defined(DIRECTIONAL_FILTER_SETUP)
        float shadow = 0;
        return shadow;
    #else
        return SampleDirectionalShadowAtlas(positionSTS);
    #endif
}

滤波器初始化方法有四个参数,首先是大小float4,XY纹素大小,ZW总纹理大小,然后是最开始采样的位置,接着是每个采样的权重和位置的输出,定义为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中调用:

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

提升滤波级别会导致痤疮问题,我们需要提升法线偏移量来匹配滤波器大小:

    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;

混合级联

在级联级别变化时,我们可以进行一定的混合。首先在Shadows.hlslshadowData中添加级联混合值:

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

高频振动

尽管在级联间混合的效果不错,但采样次数会翻倍。替代的方法是基于一个高频震动模块采样一个级别。

为级联混合模式添加新值:

        public enum CascadeBlendMode 
        {
            Hard, Soft, Dither
        }

        public CascadeBlendMode cascadeBlend;
    }

    public Directional directional = new Directional {
        …
        cascadeFade = 0.1f,
        cascadeBlend = Directional.CascadeBlendMode.Hard
    };

添加对应的关键字:

    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:

            #pragma multi_compile _ _CASCADE_BLEND_SOFT _CASCADE_BLEND_DITHER

在表面添加一个dither属性:

struct Surface 
{
    …
    float dither;
};

生成dither值的方式有很多,最简单的是使用InterleavedGradientNoise,它根据给定的屏幕空间XY坐标生成旋转的拼贴dither值,第二个参数即是否需要动起来,这里置为0:

    surface.dither = InterleavedGradientNoise(input.positionCS.xy, 0);

GetShadowData中,如果级联混合值小于设置的dither值,那么跳转到下一级联:

    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

剔除偏移

使用级联阴影贴图的缺点在于我们多次渲染同一阴影投射体。如果可以确保阴影投射物一直被较小的级联级别覆盖,我们可以尝试从更大的级联级别中剔除这些投射物。Unity使用splitdata的shadowCascadeBlendCullingFactor来配置:

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

这个值是一个用于调节前一用于执行剔除的级联半径的因数,我们可以将该因数通过级联渐变比率和其它的操作来减少该值,确保在级联变化时,附近区域的阴影投射物不会被剔除:

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

如果在级联变化时看见阴影有洞,我们需要将该值减得更小。

透明

裁剪和透明模式下也应该正确显示阴影。

阴影模式

我们在shader中设置阴影模式属性:

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

设置对应的shader特性:

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

CustomShaderGUI中设置对应的属性:

    enum ShadowMode 
    {
        On, Clip, Dither, Off
    }

    ShadowMode Shadows 
    {
        set {
            if (SetProperty("_Shadows", (float)value)) 
            {
                SetKeyword("_SHADOWS_CLIP", value == ShadowMode.Clip);
                SetKeyword("_SHADOWS_DITHER", value == ShadowMode.Dither);
            }
        }
    }

裁剪的阴影

ShadowCasterPassFragment中替换关键字:

    #if defined(_SHADOWS_CLIP)
        clip(base.a - UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Cutoff));
    #endif

高频抖动阴影

同理:

    #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

无阴影

逐物体关闭阴影投射是可行的,可以调节MeshRenderer中的Cast Shadows来实现。不过如果想逐材质关闭就做不到了,我们可以关闭材质的ShadowCasterpass。

CustomShaderGUI中添加一个SetShadowCasterPass方法,检查是否有_Shadows属性,然后检查所有选定的材质是否设置为相同的模式,即hasMixedValue。我们通过SetShaderPassEnabled来开启和关闭一个pass:

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

OnGUI中,我们检查是否有什么变化,有的话就设置:

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

忽略阴影

我们可以让表面不接受阴影。添加_RECEIVE_SHADOWS关键字:

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

GetDirectionalShadowAttenuation中,直接返回1:

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

推荐阅读更多精彩内容