[ue4] 级联阴影CSM

Posted ZJU_fish1996

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[ue4] 级联阴影CSM相关的知识,希望对你有一定的参考价值。

        本文主要讨论ue4中,CSM阴影深度图渲染流程以及一些细节实现,不包括阴影绘制的流程。

灯光初始化数据

计算Cascade外接包围球

计算精确阴影凸包

计算阴影灯光视图空间矩阵

计算阴影灯光裁剪空间矩阵

计算接收和投影阴影的视锥体

降采样相机平滑处理

阴影图元的收集

阴影深度的绘制

        阴影数据初始化的整体入口为:InitDynamicShadows,包含了所有类型阴影的数据准备和初始化,在InitViews中调用。

        其中的AddViewDependentWholeSceneShadowsForView收集了CSM相关的数据。

        ① 根据灯光信息初始化阴影数据

        相关函数:FDirectionalLightSceneProxy::GetViewDependentWholeSceneProjectedShadowInitializer(DirectionalLightComponent.cpp)

        CSM相关的数据,包括阴影距离、级联个数、衰减系数、偏移系数等都记录在方向光中,相关数据通过灯光代理传给渲染线程。

        灯光代理会预计算一些阴影所需的数据,比如计算每级Cascade的深度以及包围球大小,世界到灯光的转换矩阵等等。

        ② 根据阴影数据构造灯光视锥体和灯光变换矩阵

        相关函数:FProjectedShadowInfo::SetupWholeSceneProjection(ShadowSetup.cpp)

        ③ 收集需要绘制阴影的几何体,生成动态几何体信息

         相关函数:FSceneRenderer::GatherShadowPrimitives / FilterPrimitiveForShadows(ShdowSetup.cpp) 

         该函数所做的事情就是对于场景的所有物体,先判断它是否落在阴影的灯光视锥体中,再并行地判断它产生的阴影可能落在阴影灯光视锥体中的哪几级Cascade。

         收集得到的动态物体记录在DynamicSubjectPrimitives中,静态物体可能的话从缓存中读取MeshDrawCommand(MDC)。所有指令都会记录在ShadowDepthPassVisibleCommands。

         FSceneRenderer::GatherShadowDynamicMeshElements(ShadowSetup.cpp)

         根据DynamicSubjectPrimitives,调用通用的添加阴影的GetDynamicMeshElements函数,并添加动态物体阴影的MeshDrawCommand。

        ④ 分配阴影渲染目标

        相关函数:FSceneRenderer::AllocateCSMDepthTargets

         FSceneRenderer::AllocateMobileCSMAndSpotLightShadowDepthTargets(ShdowSetup.cpp)

         默认情况下,多级CSM贴图大小一致,会沿着x轴方向合并,这意味着对于多级阴影只需要分配一个比较大的渲染目标。

灯光初始化数据

       

       FaceDirection描述的是默认灯光方向(旋转值为0时灯光朝向),该参数用于后续交换灯光方向为z轴方向。

       SubjectBounds计算了包围盒与包围球,包围球的计算方式会在下个模块介绍。包围盒半边长是简单地从包围球计算得到。

       Scales是用于辅助计算世界到灯光裁剪空间矩阵。它只缩放了y,z方向为原来的1/SphereRadiusx轴为默认灯光方向,不做缩放,但并不是没有缩放,这个计算放到了后面一步计算。相当于裁剪了在yz方向分布在包围球的外包围盒的物体。

       PreShadowTranslation描述了阴影灯光空间变换前进行的平移变换,ue4有一个PreViewTranslation的概念,这个就是与它对应的一个参数。这个值是被独立出来的,后续的很多运算也因此就没有考虑到位移信息。相关文章:https://www.52vr.com//extDoc/ue4/CHN/Engine/Basics/CoordinateSpace/index.html

计算Cascade外接包围球

        大致算法是:

        ① 根据阴影视距,Cascade个数,分割比例,求出每个Cascade的最小深度和最大深度SplitNear, SplitFar)

        ② 计算Cascade视锥体的包围球

            具体做法是,先计算理想球心点;

            求出Cascade视锥体的八个顶点,取到球心的最大距离作为半径;

// 求理想球心点的公式:
// Fx  = (Db^2 - da^2) / 2Fl + Fl / 2 
// (where Da is the far diagonal, and Db is the near, and Fl is the frustum length)

        外接包围球的具体计算,相关公式推导,以及每个符号代表的含义可参考下图:

        

        简单而言,利用了两个三角形的斜边长相等(都为球的半径)联立等式,计算得到o2的值(球心离视锥体远裁剪面的距离),再由o2计算得到球心坐标。

        深度划分相关代码位于GetSplitDistance(DirectionalLightComponent.cpp);包围球相关代码位于:GetShadowSplitBounds(DirectionalLightComponent.cpp)

计算精确阴影凸包

       精确阴影的凸包包含了可能投射阴影到当前视锥体的区域。计算精确阴影的凸包,是为了用于后续阴影几何体的裁剪。

       几何体的裁剪一部分是在收集阴影图元中完成的,还有一部分是在GetDynamicMeshElements中进一步裁剪,比如HISM结构。因此,这两处都会依赖这个凸包的结果。

       计算方式

       输入为灯光方向和灯光视锥体的八个顶点。

       ① 根据顶点构造视锥体的六个平面;

       ② 取六个平面中的背光面(反向灯光方向与平面法线的点积小于0),加入到凸包集合;

       ③ 取背光面的相邻向光面,得到相邻面的交线,交线与光线得到新的平面,加入到凸包集合;

        假设有三个背光面(也可能没有那么多),那么将产生六条交线,即六个平面,一共得到九个平面。

        实际上的视锥体是棱台状的透视视锥体,此处为了方便使用正方体来演示。我们认为不可见的三个面为背光面。

        

        图中六条红色的边为交线,绿色的线为灯光方向,新生成的六个面就是六条交线分别与灯光方向构成的平面。因此,可以想象到生成的平面相交后得到的交线都平行于光线,且这六个面沿着逆光的方向延展。

计算阴影灯光空间矩阵

        阴影灯光空间矩阵是描述从世界空间转换到灯光视图空间的矩阵,它在ShadowDepth着色器中对应的参数是ViewMatrix。

        它的计算分为如下两步:

        ① 计算转换到灯光空间的矩阵,这等价于灯光旋转矩阵的逆矩阵(姿态矩阵与坐标空间矩阵互逆):

WorldToLight = FInverseRotationMatrix(LightSceneInfo.Proxy->GetDirection().GetSafeNormal().Rotation());

        ② 交换轴,使得x' = y, y' = z, z' = x。

        默认情况下灯光朝向x轴(可参考FaceDirection变量被初始化为(1,0,0)),此处是希望将灯光朝向作为灯光坐标系的z轴方向,并同时保持左手坐标系。

ShadowViewMatrix = WorldToLight * FMatrix((0,0,1,0),(1,0,0,0),(0,1,0,0,),(0,0,0,1));

        

计算阴影灯光裁剪空间矩阵

      阴影灯光裁剪空间是描述从世界空间转换到灯光视图裁剪空间的矩阵,它在ShadowDepth着色器中对应的参数是ProjectionMatrix,也就是说它虽然叫投影矩阵,但是做了不止投影的操作。

const FMatrix WorldToLightScaled = WorldToLight * FScaleMatrix(Initializer.Scales);
const FMatrix WorldToFace = WorldToLightScaled * FBasisVectorMatrix(-XAxis, YAxis, Initializer.FaceDirection.GetSafeNormal(), FVector::ZeroVector);
WorldToClip = WorldToFace * FShadowProjectionMatrix(MinSubjectZ, MaxSubjectZ, Initializer.WAxis);

WorldToClip = FTranslationMatrix(PreShadowTranslation - PreViewTranslation) * WorldToClip; // Add translation

        上述代码的操作大致就是世界空间->灯光视图空间->灯光裁剪空间。

        最终的正交投影可以认为等效于Cascade包围球在灯光方向的AABB包围盒,只在特殊情况下可能会有点差异。

 

        这里的WorldToFace同样是为了做轴交换,使得灯光方向作为z轴,不过另外个轴是自动计算的正交向量。

        FShadowProjectionMatrix是一个正交投影矩阵计算方式,将z方向[MinSubjectZ, MaxSubjectZ]区间的数据归一化,这里取的MinSubjectZ就是-SphereRadius,MaxSubjectZ就是SphereRadius。

        但为了处理次表面阴影这种可能在范围外的投影,这个深度值限制了最小值,也就是说,在包围球半径小于最小值的时候会用最小值替换。

        这里的投影矩阵分了两部分计算,在第一步乘以Scales矩阵时,就已经把非灯光方向的轴都通过除以包围球半径归一化了,最后一步乘以FShadowProjectionMatrix时,再去归一化灯光方向轴的值。ue4分离计算两者可能是为了灯光阴影数据初始化与阴影矩阵计算的解耦。

计算接收和投射阴影的视锥体

        Receiver Frustum : 接收阴影的视锥体

        由ReceiverMatrix,即接收阴影的世界到正交投影矩阵构造得到,即刚才求得的阴影灯光裁剪空间矩阵

        取z轴方向处于[MinSubjectZ, MaxSubjectZ]区间的值归一化

 

        Caster Frustum : 投射阴影的视锥体

        由CasterMatrix,即投射阴影的世界到正交投影矩阵构造得到

        取z轴方向处于[MinLightW, MaxSubjectZ]区间的值归一化,其余和阴影灯光裁剪空间矩阵一致

阴影图元的收集

        如前概述,我们需要收集每个级联所需绘制的几何体,这包含两个步骤,第一步初步筛选出所有和阴影视锥体相交的几何体,第二步是判断几何体需要被哪些级联绘制。

        级联收集分布

        一个物体可能会被多个级联收集,这是基于如下的原因:

       ① 物体的阴影比较长,跨越了多个级联;

       ② 收集阴影时并不知道投射物的信息,因此所有可能在当前Cascade位置产生阴影的对象都应该被考虑,哪怕它们最终并不会真正在该区域产生阴影;

        举例而言,下图是同一物体,同一光照,不同投射物的阴影分布:

        

        尤其在灯光方向与视线方向比较接近的时候,此时大部分视野内物体都会出现在多个Cascade中,由此可能带来一部分冗余结果。

       ③ 物体的视锥体裁剪基于灯光视锥体的包围球计算,而物体和视锥体外包围球都不是精确的。

        常见的FOV分布在60~90度,从下图可以看出这个范围内外包围球是非常浪费的。这也导致了多个Cascade的外包围球之间存在重叠。

       

       判断阴影投射到哪个Cascade中

       核心思想是:计算光照与几何体产生的光圆柱体是否与灯光视锥体相交。

        

        其中,光圆柱体描述了对于特定光源方向,特定几何体下,阴影可能出现的位置。它在严格意义上并不是封闭几何体,而是类似射线,可在光源方向无限延展。ue4中定义了阴影投射的最远距离,来限制这一长度。

        因此,Cascade的视锥体剔除和传统的几何体视锥体剔除的差异一方面在于视锥体是正交的,另一方面就在于剔除的对象比较特殊。

        

        设视锥体包围球中心到光圆柱体中心轴的距离为d,几何体中心到视锥体中心距离为t,几何体半径为r1,视锥体包围球半径r2。

        该几何体可能落在当前Cascade视锥体中,应该满足的条件:

1)满足d1 < r1 + r2

       这样可以确保光圆柱体所在的无限圆柱与视锥体相交。

2)当几何体指向视锥体中心的连线与灯光方向点积为负时,满足t < r1 + r2

        点积为负意味着光柱处在偏离视锥体的位置,例如上图中,几何体处在黄色位置时;

        t < r1 + r2意味着几何体包围盒与视锥体包围盒相交;

        这样可以确保光圆柱体所在的向无限圆柱与视锥体相交。

        相关代码位于:FilterPrimitiveForShadows(ShadowSetup.cpp)

(3)几何体与精确阴影凸包相交

阴影深度绘制

        在CPU中,我们已经完成了两个矩阵的构造:

        一个是ProjectionMatrix,它描述的是从世界空间转换到灯光裁剪空间的矩阵;

        另一个是VIewMatrix,它描述的是从世界空间转换到灯光视图空间的矩阵。

        此时我们将物体位置乘以ProjectionMatrix,就能完成将物体转换到灯光空间的操作。

        深度偏移

        在阴影绘制的时候,我们需要将当前像素转换到灯光空间,并比较当前深度与深度图(shadowmap)记录的深度的大小来判断当前像素是否处于阴影中。当计算得到的深度和深度图的深度非常接近的时候,由于浮点数精度的问题,这种比较的做法会产生物体表面的走样,表现为条纹状的阴影。

        一种常见的思路就是在绘制深度图的时候,将深度进行一定偏移,减少浮点数比较的误差。但是,偏移过大会导致物体深度不准确从而阴影产生偏移,比如导致地面物体的阴影与物体产生偏移使得物体看起来在空中这样的效果。这意味着深度偏移不能太小也不能太大。因此u4使用了一种自适应的算法来计算深度偏移:

        

        NoL即物体法线与灯光方向的点积,当顶点法线与光线方向越接近垂直时,偏移值越大,NoL为0时取最大偏移值(MaxSlopeDepthBias)。

        最终的偏移值为:

float DepthBias = ConstantDepthBias + Slope * SlopeDepthBias;

        其中,ConstDepthBias,SlopeDepthBias和MaxSlopeDepthBias都是用户预设的经过一定换算的常量。

        相关参考资料:https://docs.microsoft.com/en-us/windows/win32/direct3d11/d3d10-graphics-programming-guide-output-merger-stage-depth-bias

        输出归一化线性深度

        经过矩阵计算后物体的深度是裁剪空间的线性深度。但为了让其深度分布在更合理的区间,我们预计算当前Cascade下可以取到的最大深度的倒数(InvMaxSubjectDepth),并将其深度映射到这个区间:

const float InvMaxSubjectDepth = MobileShadowDepthPass_ShadowParams.w;
ShadowDepth = OutPosition.z * InvMaxSubjectDepth + DepthBias;
OutPosition.z = ShadowDepth * OutPosition.w;

       不考虑深度偏移时,原位置齐次坐标为(x,y,z,w),此时深度为z/w;换算后的位置齐次坐标为为(x,y,z * w/MaxSubjectDepth, w),此时深度为z/MaxSubjectDepth。

       相关代码位于ShadowDepthVertexShader.usf。

以上是关于[ue4] 级联阴影CSM的主要内容,如果未能解决你的问题,请参考以下文章

[ue4] 级联阴影CSM

[ue4] 级联阴影CSM

《CSM and PCF》

基于CSM和PCF的软阴影实现

ue4太阳光下阴影有断点

级联阴影贴图意外行为