BlueRoseNote/07-Other/Unity/Unity通用渲染管线(URP)系列(四)——方向阴影(Cascaded Shadow Maps).md
2023-06-29 11:55:02 +08:00

14 KiB
Raw Blame History

级联阴影

阴影贴图的缺点:阴影边缘的锯齿严重。原因是阴影贴图的分辨率低,在对阴影贴图采样时,多个不同的顶点对同一个像素采样,导致生成锯齿。为了解决这种问题,我们使用多张阴影贴图,离相机近的地方使用精细的阴影贴图,离相机远的地方使用粗糙的阴影贴图,这样不仅优化了阴影效果,还保证了渲染效率因此,级联阴影的关键就是生成和使用不同精细度的阴影贴图

阴影贴图的原理:在灯光方向架一台摄像机获取深度图然后再正常渲染自己的场景再在正常渲染场景的时候把fragment转换到光源空间把它和之前渲染的Shadowmap中高度深度作比较看它是否在影子里如果是就返回0不是就返回1

主要的步骤是取得场景中灯光的设置并且传递到Shader中之后在RenderDirectionalShadows中取得GetTemporaryRT在设置完绘制属性后将渲染结果传递至RT而不是摄像机上通过RenderDirectionalShadows绘制各个方向光的阴影。

RenderDirectionalShadows主要是通过cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives来计算视图矩阵、投影矩阵与ShadowSplitData结构该函数第一个参数是可见光指数。接下来的三个参数是两个整数和一个Vector3它们控制阴影级联。稍后我们将处理级联因此现在使用零一和零向量。然后是纹理尺寸我们需要使用平铺尺寸。第六个参数是靠近平面的阴影我们现在将其忽略并将其设置为零。之后操作为

shadowSettings.splitData = splitData;
//设置矩阵
buffer.SetViewProjectionMatrices(viewMatrix,projectionMatrix);
ExecuteBuffer();
//根据ShadowDrawingSettings绘制阴影
context.DrawShadows(ref shadowSettings);

之后开始往Lit.Shader添加绘制阴影Pass

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
}

其中FragmentShader只负责裁剪

#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

clip()是HLSL内置函数当传入数值小于0时丢弃当前像素。_Cutoff是一个设定的浮点值默认为0。不是0就是1。在ShadowCasterPass中是为了正确渲染Alpha物体阴影。 ShadowCasterPass是用来渲染阴影贴图灯光空间的深度贴图如果相机空间物体表面深度大于阴影贴图中深度则代表物体处于阴影。取得阴影值在GetDirectionalShadowAttenuation()=>FilterDirectionalShadow()里面,采样完阴影后GetDirectionalShadowAttenuation()里进行一次插值,return lerp(1.0,shadow,directional.strength);,最后在GetLighting()取得计算完的阴影值。

FilterDirectionalShadow()中调用了SAMPLE_TEXTURE2D_SHADOW宏。SAMPLE_TEXTURE2D_SHADOW宏本质是SampleCmpLevelZero()函数会对指定的纹理坐标进行采样将采样的结果与传入的参数z当前texel在光源空间的深度进行比较小于等于z视为通过说明此texel没被遮挡否则视为不通过说明此texel位于阴影中

另外注意SamplerComparisonState是用来进行深度采样比较的采样器。需要与SampleCmpLevelZero一起使用。

添加级联效果

由于定向光会影响最大阴影距离范围内的所有物体,因此它们的阴影贴图最终会覆盖较大的区域。由于阴影贴图使用正交投影,因此阴影贴图中的每个纹理像素都具有固定的世界空间大小。如果该尺寸太大,则清晰可见单个阴影纹理,从而导致锯齿状的阴影边缘和小的阴影可能消失。可以通过增加图集大小来缓解这种情况,但仅限于一定程度。

添加设置

ShadowSettings类

添加ShadowSettings类:

using UnityEngine;

[System.Serializable]
public class ShadowSettings
{
    [Min(0f)] 
    public float maxDistance = 100f;
    
    public enum TextureSize
    {
        _256=256,_512=512,_1024=1024,_2048=2048,_4096=4096,_8192=8192
    }

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

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

CustomRenderPipelineAssetCustomRenderPipelineCameraRendererLighting依次添加ShadowSettings变量以及对应函数中的形参。

Shadow类

public class Shadows
{
    const int maxShadowedDirectionalLightCount = 1;
    int ShadowedDirectionalLightCount;
    const string bufferName = "shadows";

    CommandBuffer buffer = new CommandBuffer
    {
        name = bufferName
    };

    ScriptableRenderContext context;

    CullingResults cullingResults;

    ShadowSettings settings;

    struct ShadowedDirectionalLight
    {
        public int visibleLightIndex;
    }

    private ShadowedDirectionalLight[] ShadowedDirectionalLights =
        new ShadowedDirectionalLight[maxShadowedDirectionalLightCount];
    
    public void Setup(
        ScriptableRenderContext context,CullingResults cullingResults,
        ShadowSettings settings
    )
    {
        ShadowedDirectionalLightCount = 0;
        this.context = context;
        this.cullingResults = cullingResults;
        this.settings = settings;
    }

    void ExecuteBuffer()
    {
        context.ExecuteCommandBuffer(buffer);
        buffer.Clear();
    }
    /*
    * 存储方向光阴影信息
    * 灯光处于阴影有效且处于可见状态时将信息存储在ShadowedDirectionalLights[]中
    */
    public void ReserveDirectionalShadows(Light light, int visibleLightIndex)
    {
        if (ShadowedDirectionalLightCount < maxShadowedDirectionalLightCount &&
            light.shadows!=LightShadows.None && light.shadowStrength>0f && 
            cullingResults.GetShadowCasterBounds(visibleLightIndex,out Bounds b)) 
        {
            ShadowedDirectionalLights[ShadowedDirectionalLightCount++] = new ShadowedDirectionalLight() { visibleLightIndex = visibleLightIndex};
        }
    }
}
  • 之后在Lighting类中添加Shadow类变量shadow并且在Setup中添加shadows.Setup(context,cullingResults,shadowSettings);。以及在SetupDirectionalLight中添加shadows.ReserveDirectionalShadows(visibleLight.light,index);

渲染

阴影图集

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

3 级联阴影贴图

现在终于得到阴影,但它们看起来很糟糕。不应被阴影化的表面最终会被形成像素化带的阴影伪影所覆盖。这些是由于阴影贴图的有限分辨率导致的自我阴影化。使用不同的分辨率会更改伪影模式,但不会消除它们。

球形剔除

Unity通过为其创建一个选择球来确定每个级联覆盖的区域。由于阴影投影是正交的且呈正方形因此它们最终会紧密契合其剔除球但还会覆盖周围的一些空间。这就是为什么可以在剔除区域之外看到一些阴影的原因。同样光的方向与球无关因此所有定向光最终都使用相同的剔除球。

剔除球是ComputeDirectionalShadowMatricesAndCullingPrimitives函数中计算出的ShadowSplitData分离出的数据。传递_CascadeCount级联数以及_CascadeCullingSpheres以及剔除球的位置数据。

创建ShadowData结构体以传递级联级别以及阴影硬度。将会在GetLighting中通过像素的世界坐标是否在球体内来计算ShadowData结构体中的级联级别。

最大距离

此时阴影会在超过最后一个剔除球后消失,为了解决这个问题,会设置一个最大距离,超过最大距离阴影才会消失。具体操作是在GetShadowData中比较像素深度以及阴影最大距离值,如果超过则将strength设置为0。

给阴影添加衰减与级联渐变

阴影衰减见git

级联衰减公式: 其中f为下面式子的倒数 RenderDirectionalShadows中计算fade因子后传递给_ShadowDistanceFade

清除阴影的摩尔纹

简单的清除方法
  1. 最简单的方法是向阴影投射器的深度添加恒定的偏差,这虽然会产生不精确的阴影但可以消除摩尔纹。
  2. 另一种方法是应用斜率比例偏差方法是对SetGlobalDepthBias的第二个参数使用非零值。
法线偏差

新建级联数据变量用来传递级级联数据x为剔除球半径倒数y为使用√2*2f*cullingSphere.w/tileSize算出来的级联纹理大小,因为最坏的情况是以像素对角方向进行偏移,所以前面有乘以√2,存入shader后乘以Normal取得偏移值之后对surfaceWS进行偏移。

可配置的偏差

从剔除数据中获取Light以及其shadowBias之后传递给ShadowedDirectionalLight。在绘制阴影前将偏移值传递给buffer.SetGlobalDepthBias(0,light.slopeScaleBiase);从剔除数据中获取Light以及其normalBias传递到Shader中乘以上一步中的normalBias

解决阴影裁剪问题

当摄像机处于物体中间时,会出现阴影被裁剪的问题。解决方法是在顶点着色器中添加:

#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

原理是当物体z坐标小于近剪裁面时将顶点挤压or贴在近剪裁面上。

对于大三角产生问题的原因不明白。解决方法是取得灯光的ShadowNearPlane之后传递给计算剔除球形参中。

PCF过滤

到目前为止我们仅对每个片段采样一次阴影贴图且使用了硬阴影。阴影比较采样器使用特殊形式的双线性插值在插值之前执行深度比较。这被称为百分比紧密过滤percentage closer filtering 简称PCF因为其中包含四个纹理像素所以一般指是2×2 PCF过滤器。

  • 添加2x2、3x3、5x5,3种过滤枚举、传递shadowAtlasSize到Shader以及对应的Shader宏并且修改SetKeywords()
  • 为每种过滤器设置不同的采样次数与宏设置

DIRECTIONAL_FILTER_SETUP为SampleShadow_ComputeSamples_Tent_xxx通过Size与positionSTS.xy计算权重以及uv。之后对阴影进行对应次数的采样。

float FilterDirectionalShadow (float3 positionSTS)
{
#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
	return SampleDirectionalShadowAtlas(positionSTS);
#endif
}

Shadows.cs中修改SetCascadeData()。增大滤镜大小可使阴影更平滑但也会导致粉刺再次出现。我们需要增加法向偏置以匹配滤波器尺寸。可以通过将纹理像素大小乘以1加上SetCascadeData中的过滤器模式来自动执行此操作。

void SetCascadeData(int index, Vector4 cullingSphere, float tileSize)
{
    float texelSize = 2f * cullingSphere.w / tileSize;
    float filterSize = texelSize * ((float)settings.directional.filter + 1f);
    cullingSphere.w -= filterSize;
    cullingSphere.w *= cullingSphere.w;
    cascadeCullingSpheres[index] = cullingSphere;
    cascadeData[index] = new Vector4(
        1f / cullingSphere.w, 
        filterSize*1.4142136f);
}
级联过渡

GetShadowData()中根据距离计算fade如果不是最后一个球就把fade赋值给cascadeBlend。最后一个球的·strength=strength*fade`

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,_CascadeData[i].x,_ShadowDistanceFade.z);
			}
			break;;
		}
	}
过渡抖动

提高级联过渡效果。在LitPassFragment中给像素计算抖动值

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

当使用抖动混合时,如果我们不在上一个级联中,则当混合值小于抖动值时,跳到下一个级联。

#if defined(_CASCADE_BLEND_DITHER)
	else if(data.cascadeBlend < surfaceWS.dither)
	{
		i+=1;
	}
#endif
其他功能效果
  • 透明度
  • 阴影模式
  • 裁切阴影
  • 抖动阴影
  • 无阴影
  • 不受光阴影投射器
  • 接受阴影