Shadow Bias

有关Shadow Bias的介绍我只推荐看这篇知乎文章自适应Shadow Bias算法,里面介绍了有关Shadow Bias的几乎所有需要了解的信息。同时这篇文章也指出了Unity目前正在使用的Shadow Caster Vertex Based Bias方法的不足之处。

如果是平时使用URP的话,Unity使用的在绘制Shadow Caster Pass时将ShadowBias添加到顶点的偏移上的方法,虽然称不上尽善尽美,但是也完全够用了。但是在二次元角色渲染的时候,为了营造丝袜勒肉的效果,会在腿部的模型和丝袜的模型交接的区域做一个向内凹陷的效果。这个区域两侧的模型是正常闭合的,其法线是相对的,此时如果使用Unity默认的使用ShadowBias去调整顶点位置的方式,ShadowBias中的NormalBias就会导致这个区域两侧的模型朝着法线的反方向偏移,导致这个区域的阴影在某些角度时会出现漏洞,可以在文章封面图的左边看到明显的瑕疵,右边则是在顶点着色器中使用了ShadowBias,看上去的效果就比较正常了,而且角色的阴影和角色的模型的大小也基本保持一致。当然了,把NormalBias设置成0就不会有这个问题了,但是失去了NormalBias则会带来其他角度的阴影的瑕疵。

本文使用的是Unity2022.3.43f1c1,URP版本是14.0.11。

关于Shadows.hlsl的碎碎念

我讨厌Unity URP Package里的Shadows.hlsl,因为它使用了一个LerpWhiteTo的方法,这个方法定义在CoreRP的CommonMaterial.hlsl里,而不知道为什么Shadows.hlsl并没有包含这个CommonMaterial,这就导致了我只想单独使用Shadows.hlsl时,必须还得手动包含一遍CommonMaterial.hlsl,我觉得这很不合理。

更要命的是,如果在hlsl中想要使用URP的Light结构体,就起码得包含RealtimeLights.hlsl,这个hlsl又包含了Shadows.hlsl。索性我就直接从这些文件中摘取了一套自己的uber_lights.hlsl和uber_shadows.hlsl,这也方便后续做修改。

对于URP的修改

虽然我的想法是尽量做到即插即用,也就是说尽量不去修改URP默认的代码,但是很遗憾为了将ShadowBias移动到片元着色器里,还是得稍作修改。

URP设置ShadowBias是在绘制级联阴影的每一个slice的时候,将其作为参数传给顶点着色器的,也就是说虽然顶点着色器中的参数只有_ShadowBias一个,实际上Unity会使用级联阴影的层级数个ShadowBias,我们需要将其保存起来,在渲染LitForwardPass的时候,根据当前像素的世界坐标找到对应的级联阴影的层级,再查找对应的ShadowBias进行计算。

MainLightShadowCasterPass.cs

主要是在MainLightShadowCasterPass.cs的RenderMainLightCascadeShadowmap方法中,计算得到了当前层级的ShadowBias之后,将其保存在一个数组中留待后续使用。

for (int cascadeIndex = 0; cascadeIndex < m_ShadowCasterCascadesCount; ++cascadeIndex)
{
    settings.splitData = m_CascadeSlices[cascadeIndex].splitData;

    Vector4 shadowBias = ShadowUtils.GetShadowBias(ref shadowLight, shadowLightIndex, ref renderingData.shadowData, m_CascadeSlices[cascadeIndex].projectionMatrix, m_CascadeSlices[cascadeIndex].resolution);
    m_MainLightShadowBiases[cascadeIndex] = shadowBias; // Shadow Biases for fragment shader.
    
    ShadowUtils.SetupShadowCasterConstantBuffer(cmd, ref shadowLight, shadowBias);
    CoreUtils.SetKeyword(cmd, ShaderKeywordStrings.CastingPunctualLightShadow, false);
    ShadowUtils.RenderShadowSlice(cmd, ref context, ref m_CascadeSlices[cascadeIndex],
        ref settings, m_CascadeSlices[cascadeIndex].projectionMatrix, m_CascadeSlices[cascadeIndex].viewMatrix);
}

ShadowUtils.cs

比较神秘的是,Unity还额外设置了全局的DepthBias,由于我们直接在片元着色器里计算ShadowBias,这个地方可以不进行设置。如果不注释掉这一行,当我们将DepthBias和NormalBias都设置成0时,我们也几乎不能观察到条纹状的阴影瑕疵,这会影响我们对实际使用的ShadowBias的判断。

public static void RenderShadowSlice(CommandBuffer cmd, ref ScriptableRenderContext context,
    ref ShadowSliceData shadowSliceData, ref ShadowDrawingSettings settings,
    Matrix4x4 proj, Matrix4x4 view)
{
    // cmd.SetGlobalDepthBias(1.0f, 2.5f); // these values match HDRP defaults (see https://github.com/Unity-Technologies/Graphics/blob/9544b8ed2f98c62803d285096c91b44e9d8cbc47/com.unity.render-pipelines.high-definition/Runtime/Lighting/Shadow/HDShadowAtlas.cs#L197 )

    cmd.SetViewport(new Rect(shadowSliceData.offsetX, shadowSliceData.offsetY, shadowSliceData.resolution, shadowSliceData.resolution));
    cmd.SetViewProjectionMatrices(view, proj);
    context.ExecuteCommandBuffer(cmd);
    cmd.Clear();
    context.DrawShadows(ref settings);
    cmd.DisableScissorRect();
    context.ExecuteCommandBuffer(cmd);
    cmd.Clear();

    cmd.SetGlobalDepthBias(0.0f, 0.0f); // Restore previous depth bias values
}

uber_shadow.hlsl

这里节选了比较关键的部分,大部分是和Unity的Shadows.hlsl一致的,但是需要注意片元中的ApplyShadowBias的方向和顶点中的ApplyShadowBias的方向是相反的,顶点需要远离光源,而片元则需要靠近光源,法线也是同理。采样器使用的是sampler_LinearClampCompare,似乎是将双线性对应的四个像素点的深度和当前深度进行比较之后,再对比较的结果进行线性插值,能够得到一些小小的渐变,配合PCF就能得到比较好看的软阴影了。

至于要不要使用PCSS,我的想法是并不完全必要。首先PCSS的计算比较复杂,采样数比较多,对性能会有影响,如果使用Dither的方法的话,在使用二次元渲染经常使用的Ramp图时不一定能够得到理想的结果,而使用Ramp图制作皮肤在受光和阴影交接处的次表面散射效果时,需要比较大的阴影变化范围才能做出比较好看的散射效果,使用了PCSS在被较近物体遮挡时就没有很大的半影区域了。

float3 ApplyShadowBias(float3 positionWS, float3 normalWS, float3 lightDirection, float2 shadowBias)
{
    float invNdotL = 1.0 - saturate(dot(lightDirection, normalWS));
    float scale = invNdotL * shadowBias.y;

    // normal bias is negative since we want to apply an inset normal offset
    positionWS = -lightDirection * shadowBias.xxx + positionWS;
    positionWS = -normalWS * scale.xxx + positionWS;
    return positionWS;
}

half MainLightShadow(float3 positionWS, float3 normalWS)
{
#if !defined(MAIN_LIGHT_CALCULATE_SHADOWS)
    return 1.0f;
#else

#ifdef _MAIN_LIGHT_SHADOWS_CASCADE
    int cascadeIndex = (int)ComputeCascadeIndex(positionWS);
#else
    int cascadeIndex = 0;
#endif

    float3 lightDirWS = _LightDirection;
    float2 shadowBias = _MainLightShadowBiases[cascadeIndex].xy;
    positionWS = ApplyShadowBias(positionWS, normalWS, lightDirWS, shadowBias);

    float4 shadowCoord = mul(_MainLightWorldToShadow[cascadeIndex], float4(positionWS, 1.0));
    half shadow = SampleShadowmap(_MainLightShadowmapTexture, sampler_LinearClampCompare, shadowCoord, _MainLightShadowmapSize);

    return shadow;
#endif
}

后记

非常简单的一篇小文章,写这篇文章的主要目的其实是告诉大家我还在做新的东西。。。文章中使用的角色是少前2追放里的绛雨,这里也放一个小小的图透。祝大家国庆节快乐捏!

CharacterRenderingPreview