【UE】UE中的Shadow技术

PreShadow

PreShadow是针对Stationary Light的,Stationary Light表示光照的方向、位置等基本信息不回移动,因此静态物件caster在这类光源下的shadow是可以cache的,而cache的这部分shadow就称之为preshadow,有了preshadow,运行时就可以实现一次绘制,多次使用:

  1. Preshadow可以投射阴影到静态场景跟动态物体上
  2. 动态物体对场景的投影则可以在Preshadow的基础上做每帧的叠加来得到,当然也可以为动态物体添加modulated shadow或者per-object shadow来得到

Per-Object Shadow

所谓的Per-Object Shadow就是为每个需要创建这类阴影的物体分配一张Shadowmap(实际上在UE中是申请了一张很大分辨率的Shadowmap Atlas,如下图所示,当需要分配的时候就从中分割一块出来,如果可分配空间不够了,就再申请一张新的Atlas),之后从光源视角将物件投影到Shadowmap上,在需要使用的时候,通过Shadow Volume的方式进行应用。

Per-Object Shadow的开启条件是什么呢?

  1. Light的移动类型是Movable,且能够投射Dynamic Shadow
  2. 物件要能够Cast Dynamic Inset Shadow
  3. 渲染模式为PC

Shadow Volume的应用算法具体是如何的呢,总不能场景的每个像素都需要对每个Per-Object Shadow进行一次比对吧?实际上Per-Object Shadow的绘制Bounds是在光源视角下计算得到的物件的AABB,而这个AABB沿着光线传播的方向继续延伸一个长度,就得到一个AABB Volume,这个Volume就是对应物体在光源照射下的阴影覆盖范围,只需要计算出处于这个Volume中的屏幕空间像素即可,这样就可以极大的减少绘制的消耗。

这种方式是不是看起来很眼熟?这就是延迟渲染中用于降低Deferred Lighting计算消耗常用的方法,绘制一个Light Volume Primitive,获得其影响的屏幕空间像素,对这些像素进行Lighting。具体而言,就是通过Stencil操作进行标记,对Volume进行绘制的时候,开启双面绘制,正面跟背面的Stencil处理逻辑不同,如下图所示(注意UE使用的是Reversed Z,因此靠近近平面的位置Depth数值更大,正常剔除使用的是GreaterEqual):

pass 1
pass 1

这里的绘制分成两个pass完成,第一个pass用于标记出需要进行深度计算的像素,采用的是双面绘制,Stencil的判定函数是Always,也就是说只要通过Depth Test就进行写Stencil操作:

  1. 那么Back face如果没被遮挡就执行-1,Front face执行+1,这种情况对应的是场景中的像素处于Volume之后,不需要进行计算处理,Stencil结果为0
  2. 如果Back face被遮挡则测试失败,不写Stencil,Front face通过执行+1,这种情况对应的是场景中的像素处于Volume之中,需要进行计算处理,Stencil结果为1
  3. 如果Back face被遮挡则测试失败,不写Stencil,Front face失败不写Stencil,这种情况对应的是场景中的像素处于Volume之前,不需要进行计算处理,Stencil结果为0
  4. 如果Back face被遮挡则测试通过,Stencil -1,Front face失败不写Stencil,这种情况是不可能出现的。
pass 2
pass 2

第二个pass则是正常的渲染,执行背面剔除,在这个地方会进行Stencil判定,只有非0的像素才会通过检测,从而实现响应像素的筛选。

这里需要考虑的是相机位于Volume内部的情况,由于Front face会被近平面裁剪掉,因此会是的Stencil结果存在异常,比如Back face位于物件之后,理论上对应的是此像素处于Volume内部,但是在这种情况下,Stencil结果为0,因此也就不会计算对应的阴影。那么要怎么解决这个问题呢?

pass 1
pass 1

同样分成两个pass,第一个pass也是两面渲染,区别主要在于之前对Stencil的写操作在于Stencil Test Pass的时候完成,而现在改成Depth Test Failed(即对应的Volume像素被场景像素遮挡时)的时候进行,因为相机是在Volume中的,因此通过这种方式可以正确判定某个像素是否处于当前volume的范围之中。

pass 2
pass 2

第二个Pass的处理跟前面一致,需要注意的是,这里的Stencil等配置是逐Volume配置的,因此对于不同的volume其配置可能是不一样的,从而保证任何情况下,都能得到正确的结果。

Translucent Shadow

Translucent Shadow是为半透物体创建投影的方案,目前只支持SM5使用(即PC),这个方案也是在Per-Object Shadow的实现框架中的.

Translucent Shadow的开启条件是什么呢?

  1. Light的移动类型是Stationary或者Movable,且能够投射Dynamic Translucent Shadow
  2. 物件要能够Cast Dynamic Volumetric Translucent Shadow
  3. 渲染模式为PC

Translucent Shadow总共会创建两张带有颜色的贴图,如下图所示,分别是带有RGBA信息的Transmission0跟带有GBA信息的Transmission1。

Transmission0 - Cos Coefficients
Transmission0 - Cos Coefficients - with alpha
Transmission1 - Sin Coefficients
Transmission1 - Sin Coefficients - with alpha

下面来看看这两张贴图(理论上这里的贴图已经不是shadowmap了,不过沿用表述习惯,还是使用shadowmap来代指)是怎么生成的,又是如何应用的。

Translucent Shadowmap的创建

下面是Shadowmap创建的PS代码。

void MainOpacityPS(
    FTranslucencyShadowDepthVSToPS Inputs,
    in float4 SvPosition : SV_Position,
    out float4 OutColor0 : SV_Target0,
    out float4 OutColor1 : SV_Target1
    )
{
    ResolvedView = ResolveView();

    FMaterialPixelParameters MaterialParameters = GetMaterialPixelParameters(Inputs.FactoryInterpolants, SvPosition);
    FPixelMaterialInputs PixelMaterialInputs;
    CalcMaterialParameters(MaterialParameters, PixelMaterialInputs, SvPosition, 1);

    GetMaterialClippingShadowDepth(MaterialParameters, PixelMaterialInputs);

    float Density = GetMaterialOpacity(PixelMaterialInputs) * GetMaterialTranslucentShadowDensityScale();

    Inputs.ShadowDepth += TranslucentShadowStartOffset;

    float3 FrequencyScales0 = 2.0 * PI * float3(1, 2, 3);

    float3 CosCoefficients0;
    float3 SinCoefficients0;
    sincos(FrequencyScales0 * Inputs.ShadowDepth, SinCoefficients0, CosCoefficients0);

    float IntegratedDensity = -2 * log(max(1.0 - Density, .00001f));

    OutColor0 = float4(IntegratedDensity, IntegratedDensity * CosCoefficients0);
    OutColor1 = float4(0, IntegratedDensity * SinCoefficients0);
}

可以看到,是在一个PS中借助MRT完成两张Shadowmap的创建的,其中每个通道都乘上了IntegratedDensity

Density = MaterialOpacity * TranslucentShadowDensityScale

TranslucentShadowDensityScale是材质面板上设置的对半透阴影强度进行调节的参数,通过它可以控制阴影的浓度:

IntegratedDensity = -2log(1-Density) IntegratedDensity = min(IntegratedDensity , 23.026)//-2log(0.00001)

这里相当于对浓度进行了一次映射,从线性增长更正为对数增长:

接下来看下两张RT中的关键数据,从代码中看到两张RT中存储的分别是某个角度的余弦跟正弦值,下面来看下这个角度代表的意义。

从角度计算公式来看,角度是一个float3,三个数值的比是1:2:3,我们只看下第一个分量的计算逻辑。

\theta = 2\pi * Depth

这里的Depth是在VS输出Depth插值后的结果上加上TranslucentShadowStartOffset。

VS输出的Depth的计算逻辑是:ShadowDepth = OutPosition.z * InvMaxSubjectDepth,InvMaxSubjectDepth是MaxSubjectDepth的倒数,MaxSubjectDepth也就是在当前的shadowmap渲染中输出的最大depth的数值,这个值通常就是1.0,所以简单来说VS输出的就是当前点在光源空间中的Shadow Depth。

后者则是材质面板上的StartOffset数值经过一个变换(即将世界单位转换为Shadow Depth中的[0,1]范围的大小)得到:

    const float TranslucentShadowStartOffsetValue = MaterialTranslucentShadowStartOffset * LocalToWorldScale;
    ShaderElementData.TranslucentShadowStartOffset = TranslucentShadowStartOffsetValue / (ShadowInfo->MaxSubjectZ - ShadowInfo->MinSubjectZ);

总的看来,PS中使用的Depth就是经过一个偏移后的Shadow Depth,之后乘上2Pi就得到了我们关心的角度数值,那么这里转换为角度的目的是什么呢?从后面的应用来看,应该是希望使用三角函数代替线性函数来得到周期性变化的阴影效果(模拟光线在玻璃上的流光溢彩的效果?)

Translucent Shadowmap的应用

float CalculateTranslucencyShadowingDensity(float2 ShadowUV, float ShadingDepth)
{
    float4 CosCoefficients0 = Texture2DSampleLevel( TranslucentSelfShadow_Transmission0 ,  View_SharedBilinearClampedSampler , ShadowUV, 0);
    float4 SinCoefficients0 = Texture2DSampleLevel( TranslucentSelfShadow_Transmission1 ,  View_SharedBilinearClampedSampler , ShadowUV, 0);

    float3 FrequencyScales0 = 2.0 * PI * float3(1, 2, 3);
    float3 CoefficientScales0 =  (exp(-RingingSuppressionFactor * Square(float3(1, 2, 3) / NumTermsForRingingSuppression)))  / FrequencyScales0;

    float3 ShadingSinCoefficient0;
    float3 ShadingCosCoefficient0;
    sincos(FrequencyScales0 * ShadingDepth, ShadingSinCoefficient0, ShadingCosCoefficient0);
    ShadingCosCoefficient0 = 1 - ShadingCosCoefficient0;

    float FinalDensity = (CosCoefficients0.x * ShadingDepth / 2.0) + dot(ShadingSinCoefficient0 * CosCoefficients0.yzw * CoefficientScales0, 1) + dot(ShadingCosCoefficient0 * SinCoefficients0.yzw * CoefficientScales0, 1);
    return FinalDensity;
}

变换总结一下,得到的结果大概类似于如下公式:

[sin(casterDepth) + sin(receiverDepth - casterDepth)] * CoefficientScales0 + receiverDepth/2

说实话,这个公式实际上是没什么物理意义的,虽然有点难以置信,但从表现效果上来看,确实存在较多的问题。

效果

Modulated Shadow

Modulated Shadow的开启条件是什么呢?

  1. 光源为Directional Light,且移动类型是Static或Stationary,能够投射Modulated Shadow
  1. 物件移动类型为Static,要能够Cast Static Shadow & Cast Dynamic Inset Shadow
  2. 渲染模式为Mobile

Modulated Shadow是在移动端上使用的动态阴影,相对于CSM绘制的动态阴影,其优点在于渲染性能较低,但是我们翻看代码,其渲染逻辑跟Per-Object也是一样的,那么为什么要另起一个名字,且需要单独列出来进行介绍呢?此外,从它的使用条件来看,这个阴影应该是每帧不变的,如果是将shadow cache住倒是能够解释这个阴影方案用于移动端的原因,但是通过Renderdoc在PC上截帧发现,Shadow也是每帧绘制的,这是为什么呢?

阴影渲染逻辑

float PerObjectDistanceFadeFraction = 1.0f - saturate((ShadowPosition.z - PerObjectShadowFadeStart) * InvPerObjectShadowFadeLength);

float Shadow = ManualPCF(ShadowPosition.xy, Settings);
Shadow = saturate( (Shadow - 0.5) * ShadowSharpen + 0.5 );

float FadedShadow = lerp(1.0f, Square(Shadow), ShadowFadeFraction * PerObjectDistanceFadeFraction);
OutColor.rgb = lerp(ModulatedShadowColor.rgb, float3(1, 1, 1), FadedShadow);

如下图所示,PerObjectDistanceFadeFraction是一个两头受限的线性递减函数,随着到光源距离越远,此变量越小。

PerObjectDistanceFadeFraction

而FadedShadow则是使用PerObjectDistanceFadeFraction(ShadowFadeFraction是一个常量)对PCF Shadow进行lerp后的结果,也就是距离光源越远,阴影效果也就越淡,最后使用FadedShadow得到ModulatedShadowColor调制后的阴影效果,如下图所示:

这里Modulated大概有如下几重含义:

  1. 可以通过ModulatedShadowColor调节阴影颜色
  2. 可以通过PerObjectShadowFadeStart、InvPerObjectShadowFadeLength(在FProjectedShadowInfo::SetupPerObjectProjection中计算得到)以及ShadowFadeFraction来调节阴影浓度:
PerObjectShadowFadeStart = (MaxReceiverZ - MinSubjectZ - FadeLength) / ShadowSubjectRange;
InvPerObjectShadowFadeLength = ShadowSubjectRange / FMath::Max(0.000001f, FadeLength);

最后,说明一下Modulated Shadow的一些问题吧:

  1. Modulated Shadow在阴影重叠区域会存在异常,如下图所示:

为啥会出现这种情况呢,这是因为其选择的blend方式是srcColor跟dstColor相乘:

上图中的SrcBlend跟DestBlend指的是BlendFactor,BlendOp对应的是混合算法,在上图状态下,最终的Color输出计算公式为:
FinalColor = SrcColor * 0 + DestColor * SrcColor = DestColor * SrcColor
而SrcColor则是每帧绘制的时候输出的Color,在这里对应的是ModulatedShadowColor(1.0, 0.0648, 0.0865),DestColor则是在当前RT中存储的Color,从ModulatedShadowColor的RGB分布可以看到,随着乘法次数的增加,R通道保留的份额相对于GB而言是逐步增加的,因此就越来越红。

对于这个问题,可行的解决方案为,将所有Modulated Shadow单独绘制到一张RT中,不做Blend,而是直接写入一个Output Color,完成之后,再将这个RT跟此前场景渲染的RT按照Multiply的方式进行混合,不过这样做,就相当于多了一个pass,实际渲染消耗就增加了,已有人做过验证,详情可以参考【知乎】UE4移动端Modulated Shadow渲染管线改进,而前面叙述的Per-Object Shadow跟Translucent Shadow由于都是在延迟管线上完成的,因此其实际上采用的就是延迟阴影的绘制模式,即将Shadow Map投影到一张单独的Shadow RT上,之后完成与Scene Color的Blend,采用的是颜色相加,混合模式见下图,因此也就不存在Modulated Shadow上的颜色重叠的问题了:

Contact Shadow

Contact Shadow的开启条件是什么?

  1. 光源需要勾选Cast Shadow跟Cast Dynamic Shadow两个开关,并调节Contact Shadow Length到合适的范围,默认为0,表示不开启此阴影
  2. 物件需要勾选Cast Shadow跟Contact Shadow两个开关
  3. 渲染模式为PC

Contact Shadow是一种什么样的阴影技术呢?从UE官网中Contact Shadows的陈述来看,如下图所示,这是一种为物件添加轮廓阴影细节的技术方案

这种阴影模式并不局限于方向光,而是会对满足前面开启条件的每盏光源,都会进行一次屏幕空间的render pass,在这个pass中会对屏幕空间的每个像素都进行一次沿着深度buffer的ray marching计算,从而判定当前像素与Contact Shadow的光源是否是遮挡关系。

实现原理

下面通过截帧来看一下更为具体的实现原理,比如屏幕空间pass是针对全屏幕的,还是光源覆盖范围,比如屏幕空间pass中对于那些没有标注为Cast Contact Shadow的物件是否需要进行计算等。

从截帧来看,对每盏光的屏幕空间渲染是按照标准的延迟光照模式来进行的:

也就是说,只会对在光源覆盖范围内的像素进行处理,这里有一个细节需要注意一下,通过对RenderDoc中Mesh Viewer的查看,如下图所示:

这里并没有使用Stencil来提取出受光照影响的屏幕空间像素,而是直接将光源primitive绘制到屏幕空间,并对其光栅化后的像素进行处理,那么这里的一个问题是,如果光源没有投射在任何一个物件上,不就造成大量的浪费吗,尤其是在需要进行Ray Marching的情况下。

通过对代码的Debug追踪我们发现,PS是真真实实的执行了,这种做法相对于Stencil的处理策略也确实是会有一定的损耗,但是点光使用Stencil的模式会有一些问题,即如果使用的面片过大,就会导致边缘上处理会存在问题,得到错误的结果,如果面片过小,又会增加VS处理的消耗,因此这里使用的是另一种策略:

    if (LightData.bRadialLight)
    {
        LightMask = GetLocalLightAttenuation( WorldPosition, LightData, ToLight, L );
    }
    if(LightMask<=0)
        return;
  

也就是说,根据当前像素的坐标与径向光源的位置坐标进行光源的衰减幅度判定,如果已经衰减到0,那么就不做后续的光照以及阴影计算了,通过这种方式来降低消耗,前面说的Raymarching的计算在这里就省掉了。

最终Contact Shadow的计算代码给出如下:

    if (ContactShadowLength > 0.0)
    {
        float StepOffset = Dither - 0.5;
        bool bHitCastContactShadow = false;
        float HitDistance = ShadowRayCast( WorldPosition + View_PreViewTranslation, L, ContactShadowLength, 8, StepOffset, bHitCastContactShadow );

        if ( HitDistance > 0.0 )
        {
            float ContactShadowOcclusion = bHitCastContactShadow ? 1.0 : LightData.ContactShadowNonShadowCastingIntensity;



            [branch]
            if (ContactShadowOcclusion > 0.0 &&
                IsSubsurfaceModel(GBuffer.ShadingModelID) &&
                GBuffer.ShadingModelID !=  7  &&
                GBuffer.ShadingModelID !=  9  &&
                GBuffer.ShadingModelID !=  5 )
            {



                float Opacity = GBuffer.CustomData.a;
                float Density = SubsurfaceDensityFromOpacity( Opacity );
                ContactShadowOcclusion *= 1.0 - saturate( exp( -Density * HitDistance ) );
            }

            float ContactShadow = 1.0 - ContactShadowOcclusion;

            Shadow.SurfaceShadow *= ContactShadow;
            Shadow.TransmissionShadow *= ContactShadow;
        }

    }

float ShadowRayCast(
    float3 RayOriginTranslatedWorld, float3 RayDirection, float RayLength,
    int NumSteps, float StepOffset, out bool bOutHitCastContactShadow )
{
    float4 RayStartClip = mul( float4( RayOriginTranslatedWorld, 1 ), View_TranslatedWorldToClip );
    float4 RayDirClip = mul( float4( RayDirection * RayLength, 0 ), View_TranslatedWorldToClip );
    float4 RayEndClip = RayStartClip + RayDirClip;

    float3 RayStartScreen = RayStartClip.xyz / RayStartClip.w;
    float3 RayEndScreen = RayEndClip.xyz / RayEndClip.w;

    float3 RayStepScreen = RayEndScreen - RayStartScreen;

    float3 RayStartUVz = float3( RayStartScreen.xy * View_ScreenPositionScaleBias.xy + View_ScreenPositionScaleBias.wz, RayStartScreen.z );
    float3 RayStepUVz = float3( RayStepScreen.xy * View_ScreenPositionScaleBias.xy, RayStepScreen.z );

    float4 RayDepthClip = RayStartClip + mul( float4( 0, 0, RayLength, 0 ), View_ViewToClip );
    float3 RayDepthScreen = RayDepthClip.xyz / RayDepthClip.w;

    const float Step = 1.0 / NumSteps;


    const float CompareTolerance = abs( RayDepthScreen.z - RayStartScreen.z ) * Step * 2;

    float SampleTime = StepOffset * Step + Step;

    float FirstHitTime = -1.0;

    [unroll]
    for( int i = 0; i < NumSteps; i++ )
    {
        float3 SampleUVz = RayStartUVz + RayStepUVz * SampleTime;
        float SampleDepth = SceneTexturesStruct_SceneDepthTexture.SampleLevel(  SceneTexturesStruct_PointClampSampler , SampleUVz.xy, 0 ).r;

        float DepthDiff = SampleUVz.z - SampleDepth;
        bool Hit = abs( DepthDiff + CompareTolerance ) < CompareTolerance;

        FirstHitTime = (Hit && FirstHitTime < 0.0) ? SampleTime : FirstHitTime;

        SampleTime += Step;
    }

    float HitDistance = -1.0;
    bOutHitCastContactShadow = false;
    if ( FirstHitTime > 0.0 )
    {

        float3 SampleUVz = RayStartUVz + RayStepUVz * FirstHitTime;
        FGBufferData SampleGBuffer = GetGBufferData( SampleUVz.xy );
        bOutHitCastContactShadow = CastContactShadow( SampleGBuffer );


        float3 HitUVz = RayStartUVz + RayStepUVz * FirstHitTime;
        bool bValidUV = all( 0.0 < HitUVz.xy && HitUVz.xy < 1.0 );
        HitDistance = bValidUV ? ( FirstHitTime * RayLength ) : -1.0;
    }

    return HitDistance;
}

bool CastContactShadow(FGBufferData GBufferData)
{
    uint PackedAlpha = (uint)(GBufferData.PerObjectGBufferData * 3.999f);
    bool bCastContactShadowBit = PackedAlpha & 1;

    bool bShadingModelCastContactShadows = (GBufferData.ShadingModelID !=  9 );
    return bCastContactShadowBit && bShadingModelCastContactShadows;
}

简单解释一下,整个过程分成如下几步:

  1. 首先通过ShadowRayCast进行Depth Buffer的RayMarching,尝试从当前像素朝着光源出发,找到第一个碰撞点,并根据第一个碰撞点,通过CastContactShadow判断是否需要输出Contact Shadow,而这个接口中则会进行当前碰撞点对应的物件是否需要进行Contact Shadow输出的判断。实际上,这里的逻辑有一个问题,由于我们进行RayMarching的时候只统计第一个碰撞点,而如果第一个碰撞点对应的物件没有开启Contact Shadow,而其后的碰撞点开启了,就会导致这个信息的丢失,从而引起Contact Shadow的丢失。
  2. 如果碰撞点对应物件开启了Contact Shadow,那么就会进入Contact Shadow的计算逻辑,具体计算就一个公式:ContactShadow = saturate( exp( -Density * HitDistance ) )。即Contact Shadow的浓度与碰撞距离成指数反比关系,距离越远,阴影越淡。

效果对比

关闭
开启

Self Shadow

Self Shadow指的是不对场景其他物件投射阴影,只对自身的面片投射阴影的技术,这种技术常常在FPS中用于枪械等需要高精质量的场景中,在UE中如果对一个物件勾选Self Shadow选项,就会默认开启Dynamic Inset Shadow,但是我们在测试的时候发现,开启前后的效果却不如预期,如下图所示:

Dynamic Inset Shadow - Self Shadow Off
Dynamic Inset Shadow - Self Shadow On

可以看到,这个由两个cube组成的物体,在打开Self Shadow之后,上面的cube在下面cube上的投影也跟着消失了,这跟我们理解的self shadow不一致,

关闭 self shadow
开启 self shadow

进一步发现,如果是一个角色(跟前面通过两个cube通过merge actor功能得到的物件不同),那么我们是可以得到正确的self shadow表现的。

这是为什么?这个方案的实现原理是什么呢?其目的是什么呢?

实现原理

从RenderDoc截帧看到,相对于Per-Object Shadow,Self Shadow Only的阴影贴图绘制方式是相同的,而使用方式则有所不同,前面说过Per-Object Shadow的使用是通过两个pass的stencil来减少绘制消耗,并完成shadow factor的输出的,而Self Shadow Only这里则有所不同,这里同样是两个pass。

NestedCube的绘制中,前面一个pass没有绘制任何的面片,pass名称为Stencil Mask Subjects,开启了Scissor Test,但是Depth/Stencil Test是关闭的,从名称上来看,意图似乎是想要将开启Self Shadow的物件的Stencil标记出来,方便后面一个pass进行渲染,但是貌似没有达到效果。而SK_Mannequin的绘制中,前面一个pass则执行了两个drawcall,从mesh上来看,分别对应角色模型的两个材质。

后面一个pass跟Per-Object shadow一样,也是绘制物件的bounding box,之后打开stencil test,function是 not equal with zero(推测前面一个pass会将物件在屏幕空间上的像素的stencil标记为非0),之后的shadow map取用逻辑就跟Per-Object Shadow是一样的了。

可以看到,两者主要的区别在于前一个pass中,这里两者的绘制逻辑为什么不同呢?为了探寻背后的具体细节,这里从代码上进行分析,FProjectedShadowInfo::SetupProjectionStencilMask是第一个pass的调用入口。

断点查看显示ProjectionStencilingPass.VisibleMeshDrawCommands中只有角色的绘制数据,而NestedCube的绘制数据是缺失的,而这个是在FProjectedShadowInfo::SetupMeshDrawCommandsForProjectionStenciling中进行赋值的,而用于赋值的结构SubjectMeshCommandBuildRequests & DynamicSubjectMeshElements只有DynamicSubjectPrimitives中有角色的数据,而NestedCube经过追踪发现是会加个数据写入到SubjectMeshCommandBuildRequests中的,但是在FParallelMeshDrawCommandPass::DispatchPassSetup接口中经过一次MemSwap之后就被清理掉了。

继续追踪发现给DynamicSubjectPrimitives进行赋值的接口有FProjectedShadowInfo::FinalizeAddSubjectPrimitive跟FProjectedShadowInfo::AddSubjectPrimitive,实际上角色的数据是通过FProjectedShadowInfo::AddSubjectPrimitive完成的,前述两个物体的区别在于NestedCube执行的是bDrawingStaticMeshes分支,而角色执行的是!bDrawingStaticMeshes分支,因此导致了两者的结果不一致,而之所以会存在这种区别,是因为两者的PrimitiveSceneInfo->StaticMeshes数据不同,NestedCube具有这个数据,而角色这个数据是空的,这个数据对应的是StaticMesh的数据,角色添加的是SkeletalMesh,因此这里是空的,那么StaticMesh就不能打开Self Mesh了吗?

也不是,对应于FProjectedShadowInfo::AddSubjectPrimitive,需要将最终的bDrawingStaticMeshes标记修正为false即可,但是从上到下的所有条件,都没有一种是可以通过正常操作为StaticMesh打开Self Shadow的方法,因此基本可以确认Self Shadow的开启有如下的条件:

  1. 光照是方向光
  2. Shading Model为SM5.0(PC)
  3. 模型为蒙皮模型

而其原理与前面的Per-Object shadow类似,只是前面一个pass不再通过boundingbox来进行stencil标记,而是直接绘制self(当然使用的是简易版的shader)。

Cinematic Shadow

Cinematic Shadow从代码中的注释来看,应该是一种提供更为精细的阴影的技术,且只有当光源跟物件都开启Cinematic Shadow开关时,这个物件才会受到这个光源的影响,但是通过截帧发现,事实跟这里的理解有所不同。

从截帧来看,无论是否打开Cinematic Shadow开关,其渲染的方式都没有区别,原来是CSM现在还是CSM,原来是Inset Shadow,现在还是Inset Shadow,shadowmap的分辨率也没有变化。

那么这个开关是用来干啥的呢,通过代码追踪发现,当光源打开这个开关而物件没有打开这个开关,那么在FGatherShadowPrimitivesPacket.FilterPrimitiveForShadows的接口中调用FLightSceneInfoCompact::AffectsPrimitive时返回false,那么就不会将这个物件加入到阴影绘制列表(从这个角度考虑,如果光源关闭开关,那么物件依然会参与绘制)。

也就是说,这个开关主要是用来对某盏Cinematic光源投射阴影的物件进行过滤。

此外需要注意的是,不论是光源还是物件,Cinematic Shadow的开启条件是移动类型是Movable。

Volumetric Shadow

光源上有一个标记是Cast Volumetric Shadow,这个阴影从tips上说明是用于对volumetric fog提供阴影,这里为了测试效果,从volumetric fog中做了一个简单的demo。

在方向光上打开这个开关,通过模型投射阴影,但是发现好像并没有得到fog shadow效果:

经过代码追踪发现,Volumetric Shadow的开关有两个引用地方:

  1. FDeferredShadingSceneRenderer::RenderLightFunctionForVolumetricFog
  2. LightNeedsSeparateInjectionIntoVolumetricFogForOpaqueShadow

第一个引用对应的是方向光,渲染使用的shader是VolumetricFog.usf中的LightScatteringCS函数,从名字上来看,这是通过CS来完成Volumetric上每个sample点的color计算,其中在UseDirectionalLightShadowing开关打开之后,就会通过ComputeDirectionalLightStaticShadowing以及ComputeDirectionalLightDynamicShadowing计算对应采样点的阴影,而这里是通过对CSM进行采样来求得的,而之前没有效果,是因为物件的阴影模式为Inset Shadow,将Inset Shadow关闭之后就能得到正确效果:

总结一下,这种阴影打开的条件给出如下:

  1. 方向光开启动态投影,CSM覆盖volumetric fog,且勾选Cast Volumetric Shadow
  2. 物件关闭Inset Shadow,开启Dynamic Shadow
  3. Preview模式为SM5

第二个引用对应的是点光、聚光以及rect light,开启条件为,光源需要打开Movable Light且Cast Dynamic Shadow,Preview模式为SM5:

由于Volumetric Fog是通过RayMarching方式渲染出来的,因此开启Volumetric Shadow,就是需要在RayMarching的时候逐一计算每个采样点的Shadow Factor,因此对于消耗的影响还是挺大的,不过由于Volumetric Fog本身目前只在PC上才能开启,所以这点消耗在PC上或许是可以接受的。

Whole Scene Shadow

Cascaded Shadow Map

Cascaded Shadow Map的缩写是CSM,这是一种为平行光而设计的多级Shadow Map技术,这是一种广为人知的阴影实现方案,具体的原理网上有大把介绍,如Cascaded Shadow Maps(CSM)实时阴影的原理与实现,这里就不做赘述了。

UE中,方向光将移动属性设置为Stationary或者Movable之后,其动态阴影默认使用的就是CSM,如果物件不勾选InsetShadow,那么其Dynamic Shadow就是使用CSM实现的,CSM相关的属性在方向光的Cascaded Shadow Maps Section下,主要需要关注的有两个:

Dynamic Shadow Distance,用于控制CSM的覆盖范围,这个覆盖范围指的是从相机近平面沿着相机空间Z轴往前推,多大范围内的物件会接受到CSM的影响;
Num Dynamic Shadow Cascades,指的是CSM的级数,也就是CSM的Shadow Map数目。

Far Shadow

UE中的Far Shadow是CSM的一个延伸,指的是将CSM中Dynamic Shadow Distance到Far Shadow Distance范围的Z轴范围交由Far Shadow Cascade Count张Far Cascade Shadow Map进行覆盖。

Far Shadow跟CSM不同的地方在于,只有那些Far Shadow标记打开的物件才会添加到Far Shadow Map的投影列表中。

Far Shadow开启的条件为:

  1. 光源为方向光,且移动类型为Movable,需要打开Cast Dynamic Shadow以及Far Shadow的开关
  2. 物件需要打开Cast Shadow,Cast Dynamic Shadow以及Far Shadow的开关
  3. 渲染模式为SM5

下图中,box打开了Far Shadow,角色没有打开Far Shadow,可以对比下效果:

角色的部分阴影是通过CSM绘制的,但是在较远的范围处,已经超出了CSM的覆盖范围,又没有打开Far Shadow,因此阴影就逐渐消失了。

Distance Field Shadow

Distance Field Shadow的缩写是DFS,这是通过将场景使用Signed Distance Field来表示,之后对于屏幕空间的每个像素而言,会朝着光源发射一条射线,沿着射线根据Signed Distance Field的数据使用Sphere Tracing的方式求取当前射线上的光照被遮蔽的程度,最后将这个程度转化为阴影从而得到十分柔软的阴影效果,关于其更为详细的介绍,可以参考UE的官方文档:Distance Field Soft Shadows,此外之前分享的Unreal在Siggraph2016上的技术展示也有一些介绍:【Siggraph 2015】Dynamic Occlusion with Signed Distance Fields

这种方式由于需要将场景使用SDF进行表达,因此会有较高的内存消耗,且由于DX11及以上的API才有类似支持,因此目前移动端暂不支持,而由于其阴影是通过Sphere Tracing的方式来求得的,不需要进行Shadow map的绘制,在计算效率上要更高一点,且CSM覆盖范围越广,这个效率提升比例也就越大。另外,由于阴影的计算会考虑遮蔽程度,因此可以十分容易的得到较好的软影效果,右下角的图可以很好的说明这个问题。

此外,在使用上还有一些点需要注意一下:

  1. 对于方向光而言, Light Source Angle参数的设置不能太大,这个数值太大会导致Sphere Tracing的过程中需要对较多物件的SDF进行处理从而导致计算效率下降
  2. DFS有一个覆盖范围Distance Field Shadow Distance,这个范围起点的是前面3. CSM的Dynamic Shadow Distance,这个覆盖范围越大,就意味着越多的像素与物件需要加入计算,计算消耗也会同步增加(甚至是指数增加)
  3. 此外,如果物件在构建SDF的时候开启了双面Distance Field的生成开关,也会导致DFS的计算消耗增加,这个特性通常用于植被等需要双面渲染的物件,这种物件需要考虑到内部的复杂的层级关系,因此需要更高的SDF存储消耗,同时在Raymarching上的消耗也会相应增加。
  4. 除此之外,还有一些其他的优化方式,比如开启SDF的压缩,使用八位取代16位的存储方式等,时间关系这里就不展开了

SDF的不足之处在于:

  1. DX11及以上的API才能支持
  2. 不支持蒙皮模型
  3. 因为SDF是离线计算的,因此开启了World Offset或者使用了Displacement的材质会导致自阴影的瑕疵

DFS的开启条件为:

  1. 在Project Settings下打开Mesh Distance Field生成开关:
  1. 光源移动类型为Movable,需要Cast Dynamic Shadow,且勾选Distance Field Shadow:

我们注意到,Distance Field Shadow上有个DistanceField Shadow Distance参数,这个参数是方向光独有的,其含义跟前面Far Shadow的Distance含义一样,指的是DFS覆盖的是从方向光的Dynamic Shadow Distance到DistanceField Shadow Distance的这一段距离,至于从相机近平面到Dynamic Shadow Distance这一段,还是通过CSM来覆盖。

  1. Mesh开关设置:

调整Distance Field Resolution Scale,将之调整为一个非零的数值(通常是1),这个数值表示的是分配给这个物体的Distance Volume Texture的分辨率,数值越大,阴影越清晰,内存消耗与计算消耗越高,调整完成之后记得点击 Apply Changes按钮。

需要勾选Generate Mesh Distance Field选项以为这个物件创建Distance Field Texture。

  1. Preview Mode为SM5,也就是说,目前只支持PC端的DFS。

DFS的其他参数设置详情可以参考官方文档:Mesh Distance Fields Settings

下面给出效果对比:

CSM Shadow
Distance Field Shadow

Capsule Shadow

Capsule Shadow是一种动态阴影,只能对蒙皮模型开启,需要为其设置Shadow Physics Asset,具体可以参考官方文档

开启条件为:

  1. 光源能够投射Dynamic Shadow
  2. 物件为蒙皮模型,且勾选Capsule Shadow
  3. 渲染模式为SM5

在官方文档中介绍说到,这种阴影是通过使用一些胶囊体(capsule)将蒙皮模型进行包裹以降低阴影消耗,且这是一种逐物件绘制的阴影,我们想当然的会以为这种阴影实际上使用的还是Per-Object Shadow那一套方案,只是参与绘制的mesh从精准模型变成了capsules,而之所以使用capsules是为了降低计算消耗,但实际上我们认真思考就会发现,将模型用capsules替代并不能降低什么消耗,顶点数会有所减少,但是对于消耗的影响并不明显。

所以这种方案的背后原理是什么呢?

原理

通过截帧我们发现,Capsule Shadow的渲染分成两个pass,TiledCapsuleShadowing pass是一个Compute Pass,这是capsule shadow计算的最主要的pass,其对应的shader入口为CapsuleShadowShaders.usf文件中的CapsuleShadowingCS函数,第二个pass则是传统的VS/PS渲染逻辑,入口为CapsuleShadowingUpsampleVS跟CapsuleShadowingUpsamplePS函数,从Pass名字上来看像是对前一个pass的输出进行上采样得到屏幕尺寸的shadow factor buffer,经过shader代码简单浏览之后发现也确实是在PS中通过四次采样并进行混合来实现双线性插值以完成上采样,之所以不用硬件自带的上采样,是因为双线性混合时的权重有一些附加计算。

通过对第一个pass的分析,我们发现,Capsule Shadow是通过解析式(Analytical)的方式求得的,简单来说,首先将屏幕空间划分成多个tile,通过粗略的sphere intersection判断剔除掉一些capsules,以降低后续计算的消耗;之后再后面针对每个tile中的每个像素,发射一条指向光源的射线,根据光源的尺寸以及各个capsule的数据(比如像素到capsule的方向以及capsule的尺寸)来判定当前capsule对这个像素的遮挡关系,当完成所有capsule对当前像素的遮挡关系计算之后,就能够顺势输出当前像素的阴影值。

当然,上面只是大概介绍其原理,实际上其中还有很多的细节处理,这里就不做展开了。

最后,需要注意的是,因为计算消耗还是比较高,因此这个shadow方案只在PC的模式下才生效。

效果

Default CSM
Capsule Direct Shadow

Raytracing Shadows

我们在光源上会发现一个Cast Raytracing Shadow的选项,但是勾选之后并没有任何变化,这是为什么呢?

因为这是一项为Raytracing Render Pipeline专设的选项,而Raytracing Renderer是需要打开一系列的设置的,具体可以参考这个视频:Dive Into Real-Time Ray Tracing for Archviz with Unreal Engine

Raytracing Renderer是在4.24.3就启用的特性,但是通常认为在4.25.1才算稳定可用,因此在测试这项特性的时候最好使用较高一点的版本。

由于Raytracing Renderer还是一项PC专用的技术,因此这里就不对这项技术做展开了,有兴趣的同学麻烦根据视频中的内容进行尝试。

Masked Shadow

这是一项为材质设定的参数,对于Translucent Material(只有这种类型的材质可以开启这个阴影)而言,其参数面板上有一项参数:

只有勾选了Cast Dynamic Shadow as Masked选项,后续的两项参数才会变为可用,这个选项的意思是,在阴影绘制的时候,会将alpha blend的材质当成alpha test来使用,其中第一个参数用于指定alpha test的clip value,以确定哪些像素是可以产生阴影的,哪些不能。

之后,理论上使用这个材质的物件在创建动态阴影的时候,就会触发这个阴影效果了,但实际我们测试发现并没有得到预期的阴影,这是为什么呢?

经过追踪发现,半透物件会被塞入到FProjectedShadowInfo::SubjectTranslucentPrimitives(FProjectedShadowInfo::FinalizeAddSubjectPrimitive)中,条件是bTranslucentRelevance为true,而这个变量的赋值入口为FProjectedShadowInfo::AddSubjectPrimitive_AnyThread,赋值逻辑为:
bTranslucentRelevance = ViewRelevance.HasTranslucency() && !ViewRelevance.bMasked;

这里之所以为True,是因为ViewRelevance.bMasked为false,而bMasked的赋值入口为UMaterialInterface::GetRelevance_Internal,赋值语句为:
MaterialRelevance.bMasked = IsMasked();

而IsMasked这个接口的返回值随着MaterialInterface的具体实现不同,又有不同的结果,比如UMaterialInstance的返回值为GetBlendMode() == EBlendMode::BLEND_Masked;,而UMaterial的返回值则为GetBlendMode() == BLEND_Masked || (GetBlendMode() == BLEND_Translucent && GetCastDynamicShadowAsMasked());GetCastDynamicShadowAsMasked对应的就是材质面板上的Cast Dynamic Shadow as Masked选项,也就是说,如果我们将材质实例赋给Mesh,那么我们可能就的不到Masked Shadow,而如果赋给母材质,就可以看到正确的阴影,经过测试,确实可以得到预期效果:

image.png

从代码看,这个设计逻辑还挺复杂晦涩的, 不知道Epic的大佬们到底是如何思考Masked Shadow的定位的。总结一下Masked Shadow的开启条件为:

  1. 光源Cast Shadow
  2. 物件Cast Dynamic Shadow,且使用木材质作为模型材质

Shadow Cache

Shadow Cache也叫Shadow Map Caching,是一种将渲染的阴影缓存下来以避免每帧重复绘制所导致的消耗的技术。

UE中的Shadow Cache是针对点光以及聚光灯的,在Shadow Map Caching中说到,将需要进行开启Shadow Cache功能的点光或者聚光的移动类型设置为Movable,并且勾选Cast Shadow选项,就能够启用这项功能了,实际上,这项功能在UE中是默认开启的。

从测试数据可以看到,三盏点光在开启Shadow Cache前后的渲染消耗分别为14.89ms与0.9ms,相差16倍。

但是文档中也说了,Shadow Caching不是万灵药,是有着极大的限制的:

  • 模型的移动类型需要被设置成Static或Stationary.
  • 场景中的材质不能使用World Position Offset,否则就会出现位置偏移,Shadow结果就不对了
  • 只支持点光跟聚光,且移动类型需要设置为Movable,并且开启Cast Shadow选项
  • 光源不能移动
  • 使用animated Tessellation或者Pixel Depth Offset特性的材质,参与到这种方案中会导致瑕疵。

除了上面点光跟聚光的Shadow Cache之外,还有针对方向光的Shadow Cache技术,即每帧只更新那些相机移动或者旋转后新增的阴影部分,不过这个技术在UE中没有实现,因此这里就不展开了。

参考

[1] UE4 级联阴影(CSM)和逐物件阴影https://zhuanlan.zhihu.com/p/78174277
[2] Contact Shadow

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

推荐阅读更多精彩内容