美文网首页
【UE】Unreal的实例化渲染

【UE】Unreal的实例化渲染

作者: 离原春草 | 来源:发表于2022-12-01 22:56 被阅读0次

    UE提供了两套用于实现实例化渲染的方案,分别是ISM(Instanced Static Mesh)与HISM(Hierarchical ISM),下面对这两套方案的实现原理做一个大致的介绍。

    ISM

    ISM是Instanced Static Mesh Rendering的缩写,这个方案的做法是,将大量(甚至一个场景内所有)的相同实例合并成一个DrawCall进行绘制,这个方案存在如下的一些问题:

    1. 在离线状态下确定哪些mesh需要被放进一个批次进行渲染,导致在运行时因为视角转动,会有部分实例不可见,但依然需要进入渲染造成的浪费

    2. 处于同一个批次中的实例需要具备相同的LOD,这就导致,如果一个批次覆盖范围较大,本来可以通过LOD优化的实例,此时依然需要使用较高LOD,导致浪费

    3. ISM中每个Instance的LOD切换是以整个ISM的Boundingbox尺寸来的,只有当整个ISM的boundingbox尺寸达到跟Instance上设定的屏占比时才会触发LOD切换,这就导致了如果Instance分布比较散,那么Instance的LOD就会永远处于一个高精层面,浪费较大

    4. ISM中不会针对Instance做剔除,只会针对整个ISM做剔除,对于每个Instance虽然设定了两个参数,但是这两个参数是用于Dither LOD处理的:

    /** 从这个距离开始,实例开始渐隐 */
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Culling)
    int32 InstanceStartCullDistance;
    
    /** 从这个距离开始,实例完全被剔除 */
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Culling)
    int32 InstanceEndCullDistance;
    

    这两个参数的使用逻辑如下所示:

    // Distance需要乘以一个Scale
    const float MaxDrawDistanceScale = GetCachedScalabilityCVars().ViewDistanceScale;
    const float StartDistance = InstancingUserData->StartCullDistance * MaxDrawDistanceScale;
    const float EndDistance = InstancingUserData->EndCullDistance * MaxDrawDistanceScale;
    
    // 作为参数传入shader
    InstancingFadeOutParams.X = StartDistance; // start cull distance
    InstancingFadeOutParams.Y = 1.f / (float)(EndDistance - StartDistance); // cull ratio
    
    // 在Vertex Shader中计算剔除概率
    Intermediates.PerInstanceParams.y = 1.0 - saturate((length(InstanceTranslatedLocation) - InstancingFadeOutParams.x) * InstancingFadeOutParams.y); // per-instance fade out amount(remaining)
    
    // 在pixel shader中作为材质的GetPerInstanceFadeAmount属性进行访问
    /** Get the per-instance fade-out amount when instancing */
    float GetPerInstanceFadeAmount(FMaterialPixelParameters Parameters)
    {
    #if USE_INSTANCING || USE_INSTANCE_CULLING
        return float(Parameters.PerInstanceParams.y);
    #else
        return float(1.0);
    #endif
    }
    

    关于这部分的分析,在[4]中已经有比较详细的论述,这里就不再展开

    HISM

    HISM是Hierarchical ISM的缩写,这是基于Cluster聚类的N叉树(称之为Cluster Tree),树上各个节点之间存在层级关系(方便降低剔除消耗),最终提交渲染的只有叶子节点,其他节点的存在则是为了方便剔除。

    原理

    这个方案的大致原理是将需要渲染的实例用一棵N叉树(ClusterTree)来表示,在渲染的时候,我们会有一个InstanceBuffer,其中存储了待渲染物件的实例信息,在HISM下,可以为每个叶子节点分别提供一个InstanceBuffer,也可以更简单一点,借助图形API可以指定Buffer偏移与长度的能力,使用同一个InstanceBuffer[1]

    这棵树每个节点中存储了此节点中对应的实例的起始InstanceID与终止InstanceID,基于双面的InstanceBuffer共用逻辑,我们可以非常方便的实现Cluster(节点)的剔除与渲染数据的提交。

    对应到UE的数据结构,这里的整棵树用ClusterTree来表示,其中最关键的FClusterNode代表了树的一个节点:

    struct FClusterTree
    {
     TArray<FClusterNode> Nodes;
     TArray<int32> SortedInstances;
     TArray<int32> InstanceReorderTable;
     int32 OutOcclusionLayerNum = 0;
    };
    struct FClusterNode
    {
     // 当前节点的BoundingBox信息
     FVector3f BoundMin;
     FVector3f BoundMax;
     //当前节点的子节点在ClusterTree的Nodes数组中的索引范围
     int32 FirstChild;
     int32 LastChild;
     // 当前节点所包含的Instance在PerInstanceSMData的索引范围
     int32 FirstInstance;
     int32 LastInstance;
    
     FVector3f MinInstanceScale;
     FVector3f MaxInstanceScale;
    }
    
    //截取部分
    class ENGINE_API UInstancedStaticMeshComponent : public UStaticMeshComponent, public ISMInstanceManager
    {
     /** Array of instances, bulk serialized. */
     TArray<FInstancedStaticMeshInstanceData> PerInstanceSMData;
    }
    

    针对这个方案,还有一些细节需要澄清,总的来说,有两个问题需要回答:

    1. N叉树如何构造

    2. 运行时N叉树要如何使用

    1. N叉树的构造

    给定一套物件实例,我们需要构建出对应的树,这个计算逻辑在UHierarchicalInstancedStaticMeshComponent::BuildTree中可以找到。

    按照自顶向下的逻辑,这些实例数据可以组成我们整棵树的根节点,我们首先需要判断当前的实例数据是否需要划分出子节点,如果需要,该怎么划分。

    一个节点是否需要划分,在UE中由一个叫做BranchingFactor的参数控制,这个参数表明了一个节点的最大子节点数目(在第一步实例划分的时候,这个数值表明叶子节点中可以装载的实例数目,在后续步骤自底向上构建ClusterTree的时候,这个就对应于父节点下子节点的最大数目),在实例划分(构建叶子节点)的时候,这个数值等于叶子节点的最大顶点数目(每批顶点数目)除以实例(LOD0)顶点数,并Clamp到[1, 1024]:

    int32 UHierarchicalInstancedStaticMeshComponent::DesiredInstancesPerLeaf()
    {
     int32 LOD0Verts = GetVertsForLOD(0);
     int32 VertsToSplit = CVarMinVertsToSplitNode.GetValueOnAnyThread();
     if (LOD0Verts)
     {
     return FMath::Clamp(VertsToSplit / LOD0Verts, 1, 1024);
     }
     return 16;
    }
    

    如果某个节点包含的实例数目大于BranchingFactor,那么就要进行二分(这里的二分还不是建树,而是在进行聚类,因此跟我们前面说的N叉树不是一个事情),这里二分是基于所有实例的位置进行的,统计所有实例的XYZ三个坐标轴的跨度,取跨度最大的轴,按照实例数目均分为两个cluster(如果是离线的计算,等分逻辑是否还可以优化一下,比如基于PCA?):

    void Split(int32 Start, int32 End)
    {
     int32 NumRange = 1 + End - Start;
     FBox ClusterBounds(ForceInit);
     // 计算所有实例组成的boundingbox,每个实例用一个点表示,数据存于SortPoints
     for (int32 Index = Start; Index <= End; Index++)
     {
     ClusterBounds += SortPoints[SortIndex[Index]];
     }
     //判断是否需要继续细分,不需要就直接返回
     if (NumRange <= BranchingFactor)
     {
     Clusters.Add(FRunPair(Start, NumRange));
     return;
     }
    
     // 寻找实例分布的最长跨度轴,根据boundingbox来判断
     SortPairs.Reset();
     int32 BestAxis = -1;
     float BestAxisValue = -1.0f;
     for (int32 Axis = 0; Axis < 3; Axis++)
     {
     float ThisAxisValue = ClusterBounds.Max[Axis] - ClusterBounds.Min[Axis];
     if (!Axis || ThisAxisValue > BestAxisValue)
     {
     BestAxis = Axis;
     BestAxisValue = ThisAxisValue;
     }
     }
    
     // 根据最长轴进行实例排序
     for (int32 Index = Start; Index <= End; Index++)
     {
     FSortPair Pair;
    
     Pair.Index = SortIndex[Index];
     Pair.d = SortPoints[Pair.Index][BestAxis];
     SortPairs.Add(Pair);
     }
     SortPairs.Sort();
    
     // 为二分做准备
     for (int32 Index = Start; Index <= End; Index++)
     {
     SortIndex[Index] = SortPairs[Index - Start].Index;
     }
     int32 Half = NumRange / 2;
     int32 EndLeft = Start + Half - 1;
     int32 StartRight = 1 + End - Half;
     if (NumRange & 1)
     {
     if (SortPairs[Half].d - SortPairs[Half - 1].d < SortPairs[Half + 1].d - SortPairs[Half].d)
     {
     EndLeft++;
     }
     else
     {
     StartRight--;
     }
     }
    
     // 递归对二分后的实例Cluster进行进一步细分处理
     Split(Start, EndLeft);
     Split(StartRight, End);
    }
    

    经过上面的递归拆分之后,我们得到了若干个子节点,但同时我们也注意到,在需要二分的时候,实际上在当前函数中并没有添加新的节点,也就是说,经过上述拆分得到的节点(存储于Clusters中)相互之间并没有层级关系,为什么不在拆分的同时把层级关系一并建立起来呢?

    这是因为聚类(不论是聚类出叶子节点还是父节点)是按照(空间)二分方式进行的,如果在聚类出叶子节点的同时就计算出对应的父节点,就会存在如下两个问题:

    1. 父节点到子节点就不再能遵循空间二分(比如是N叉树,就要N分)的方式进行,其聚类的精确程度会有所下降(想象100个实例在X轴上具有最大跨度,但是这些实例实际上分别聚类于X轴上的两个端点的情况,如果基于X轴N分,可能会出现中间节点为空的情况)

    2. 普通节点的子节点数目与叶子节点的子节点(实例)数目是不同的,需要做特殊处理

    为了构建出这棵树,我们还需要基于这些已有的节点做一次层级的聚类,这个逻辑在FClusterBuilder::BuildTree接口中完成,整体分为如下几步:

    1. 基于之前的Cluster信息(存储了哪些实例分到一个Cluster中)构建节点,得到当前层(叶子)的节点
    for (int32 Index = 0; Index < NumRoots; Index++)
    {
         FClusterNode& Node = Result->Nodes[Index];
         Node.FirstInstance = Clusters[Index].Start;
         Node.LastInstance = Clusters[Index].Start + Clusters[Index].Num - 1;
         FBox NodeBox(ForceInit);
         for (int32 InstanceIndex = Node.FirstInstance; InstanceIndex <= Node.LastInstance; InstanceIndex++)
         {
         const FMatrix& ThisInstTrans = Transforms[SortedInstances[InstanceIndex]];
         FBox ThisInstBox = InstBox.TransformBy(ThisInstTrans);
         NodeBox += ThisInstBox;
        
         if (GenerateInstanceScalingRange)
         {
         FVector3f CurrentScale(ThisInstTrans.GetScaleVector());
        
         Node.MinInstanceScale = Node.MinInstanceScale.ComponentMin(CurrentScale);
         Node.MaxInstanceScale = Node.MaxInstanceScale.ComponentMax(CurrentScale);
         }
         }
         Node.BoundMin = (FVector3f)NodeBox.Min;
         Node.BoundMax = (FVector3f)NodeBox.Max;
    }
    
    1. 将当前层的节点看成是一个个的实例,按照之前的划分(聚类)逻辑进行聚类,聚类的粒度跟此前实例聚类的粒度不同,数值从MaxInstancesPerLeaf(叶子节点最大实例数)变成了InternalNodeBranchingFactor(ClusterTree中节点的最大子节点数,也是我们前面说过N叉树的N,通过CVarFoliageSplitFactor配置),聚类后就得到一系列的父节点,重复这个过程,直到最终我们只得到了一个节点(根节点)。在完成聚类或者建树之后,还需要对数据进行重排,保证父子节点在InstanceBuffer中的数据是可重用的。
    // 自底向上,逐层处理
    while (NumRoots > 1)
    {
         // 将当前层的每个节点(boundingbox的中心)当成一个点(实例)
         SortIndex.Reset();
         SortPoints.Reset();
         SortIndex.AddUninitialized(NumRoots);
         SortPoints.AddUninitialized(NumRoots);
         for (int32 Index = 0; Index < NumRoots; Index++)
         {
         SortIndex[Index] = Index;
         FClusterNode& Node = Result->Nodes[Index];
         SortPoints[Index] = (FVector)(Node.BoundMin + Node.BoundMax) * 0.5f;
         }
         // 对上面的点(实例),重复此前划分(聚类)逻辑,不过这里每个cluster的实例数目从MaxInstancesPerLeaf变成了InternalNodeBranchingFactor,这个决定了分支的数目,比如当前层有100个节点,而InternalNodeBranchingFactor = 20,那么我们就得到了5个父节点,也就是说,每个父节点有20个子节点,那么这棵树就是20叉树
         BranchingFactor = InternalNodeBranchingFactor;
         if (BranchingFactor > 2 && OcclusionLayerTarget && NumRoots / BranchingFactor <= OcclusionLayerTarget)
         {
         BranchingFactor = FMath::Max<int32>(2, (NumRoots + OcclusionLayerTarget - 1) / OcclusionLayerTarget);
         OcclusionLayerTarget = 0;
         bIsOcclusionLayer = true;
         }
         Split(NumRoots);//NumRoots是当前层的节点数目
         if (bIsOcclusionLayer)
         {
         Result->OutOcclusionLayerNum = Clusters.Num();
         bIsOcclusionLayer = false;
         }
        
         InverseSortIndex.Reset();
         InverseSortIndex.AddUninitialized(NumRoots);
         for (int32 Index = 0; Index < NumRoots; Index++)
         {
         InverseSortIndex[SortIndex[Index]] = Index;
         }
        
         // 对instances进行重排,从而在此前节点顺序调整后,实现数据匹配,达到一个InstanceBuffer实现任意drawcall的目的
         {
         RemapSortIndex.Reset();
         RemapSortIndex.AddUninitialized(Num);
         int32 OutIndex = 0;
         for (int32 Index = 0; Index < NumRoots; Index++)
         {
         FClusterNode& Node = Result->Nodes[SortIndex[Index]];
         for (int32 InstanceIndex = Node.FirstInstance; InstanceIndex <= Node.LastInstance; InstanceIndex++)
         {
         RemapSortIndex[OutIndex++] = InstanceIndex;
         }
         }
         InverseInstanceIndex.Reset();
         InverseInstanceIndex.AddUninitialized(Num);
         for (int32 Index = 0; Index < Num; Index++)
         {
         InverseInstanceIndex[RemapSortIndex[Index]] = Index;
         }
         for (int32 Index = 0; Index < Result->Nodes.Num(); Index++)
         {
         FClusterNode& Node = Result->Nodes[Index];
         Node.FirstInstance = InverseInstanceIndex[Node.FirstInstance];
         Node.LastInstance = InverseInstanceIndex[Node.LastInstance];
         }
         OldInstanceIndex.Reset();
         Swap(OldInstanceIndex, SortedInstances);
         SortedInstances.AddUninitialized(Num);
         for (int32 Index = 0; Index < Num; Index++)
         {
         SortedInstances[Index] = OldInstanceIndex[RemapSortIndex[Index]];
         }
         }
         //同样,对节点进行重排
         {
         RemapSortIndex.Reset();
         int32 NewNum = Result->Nodes.Num() + Clusters.Num();
         // RemapSortIndex[new index] == old index
         RemapSortIndex.AddUninitialized(NewNum);
         LevelStarts.Reset();
         LevelStarts.Add(Clusters.Num());
         for (int32 Index = 0; Index < NodesPerLevel.Num() - 1; Index++)
         {
         LevelStarts.Add(LevelStarts[Index] + NodesPerLevel[Index]);
         }
        
         for (int32 Index = 0; Index < NumRoots; Index++)
         {
         FClusterNode& Node = Result->Nodes[SortIndex[Index]];
         RemapSortIndex[LevelStarts[0]++] = SortIndex[Index];
        
         int32 LeftIndex = Node.FirstChild;
         int32 RightIndex = Node.LastChild;
         int32 LevelIndex = 1;
         while (RightIndex >= 0)
         {
         int32 NextLeftIndex = MAX_int32;
         int32 NextRightIndex = -1;
         for (int32 ChildIndex = LeftIndex; ChildIndex <= RightIndex; ChildIndex++)
         {
         RemapSortIndex[LevelStarts[LevelIndex]++] = ChildIndex;
         int32 LeftChild = Result->Nodes[ChildIndex].FirstChild;
         int32 RightChild = Result->Nodes[ChildIndex].LastChild;
         if (LeftChild >= 0 && LeftChild <  NextLeftIndex)
         {
         NextLeftIndex = LeftChild;
         }
         if (RightChild >= 0 && RightChild >  NextRightIndex)
         {
         NextRightIndex = RightChild;
         }
         }
         LeftIndex = NextLeftIndex;
         RightIndex = NextRightIndex;
         LevelIndex++;
         }
         }
         checkSlow(LevelStarts[LevelStarts.Num() - 1] == NewNum);
         InverseChildIndex.Reset();
         // InverseChildIndex[old index] == new index
         InverseChildIndex.AddUninitialized(NewNum);
         for (int32 Index = Clusters.Num(); Index < NewNum; Index++)
         {
         InverseChildIndex[RemapSortIndex[Index]] = Index;
         }
         for (int32 Index = 0; Index < Result->Nodes.Num(); Index++)
         {
         FClusterNode& Node = Result->Nodes[Index];
         if (Node.FirstChild >= 0)
         {
         Node.FirstChild = InverseChildIndex[Node.FirstChild];
         Node.LastChild = InverseChildIndex[Node.LastChild];
         }
         }
         {
         Swap(OldNodes, Result->Nodes);
         Result->Nodes.Empty(NewNum);
         for (int32 Index = 0; Index < Clusters.Num(); Index++)
         {
         Result->Nodes.Add(FClusterNode());
         }
         Result->Nodes.AddUninitialized(OldNodes.Num());
         for (int32 Index = 0; Index < OldNodes.Num(); Index++)
         {
         Result->Nodes[InverseChildIndex[Index]] = OldNodes[Index];
         }
         }
         int32 OldIndex = Clusters.Num();
         int32 InstanceTracker = 0;
         for (int32 Index = 0; Index < Clusters.Num(); Index++)
         {
         FClusterNode& Node = Result->Nodes[Index];
         Node.FirstChild = OldIndex;
         OldIndex += Clusters[Index].Num;
         Node.LastChild = OldIndex - 1;
         Node.FirstInstance = Result->Nodes[Node.FirstChild].FirstInstance;
         checkSlow(Node.FirstInstance == InstanceTracker);
         Node.LastInstance = Result->Nodes[Node.LastChild].LastInstance;
         InstanceTracker = Node.LastInstance + 1;
         checkSlow(InstanceTracker <= Num);
         FBox NodeBox(ForceInit);
         for (int32 ChildIndex = Node.FirstChild; ChildIndex <= Node.LastChild; ChildIndex++)
         {
         FClusterNode& ChildNode = Result->Nodes[ChildIndex];
         NodeBox += (FVector)ChildNode.BoundMin;
         NodeBox += (FVector)ChildNode.BoundMax;
        
         if (GenerateInstanceScalingRange)
         {
         Node.MinInstanceScale = Node.MinInstanceScale.ComponentMin(ChildNode.MinInstanceScale);
         Node.MaxInstanceScale = Node.MaxInstanceScale.ComponentMax(ChildNode.MaxInstanceScale);
         }
         }
         Node.BoundMin = (FVector3f)NodeBox.Min;
         Node.BoundMax = (FVector3f)NodeBox.Max;
         }
         NumRoots = Clusters.Num();
         NodesPerLevel.Insert(NumRoots, 0);
         }
        }
    

    2. HISM运行时管理

    HISM的运行时管理介绍的是HISM中cluster或者节点的剔除与渲染。

    由于HISM为一棵N叉树,因此剔除的时候可以自顶向下,从粗到精进行,主要逻辑存放在FHierarchicalStaticMeshSceneProxy::GetDynamicMeshElements接口中:

    • 从根节点开始遍历处理:FHierarchicalStaticMeshSceneProxy::Traverse

    • 先判断当前节点是否可见(基于boundingbox)

      • 如果当前节点不可见,不需要进入子节点处理逻辑,直接返回
    • 节点可见状态下,再进行一系列的判断

      • 是否超出需要显示的最大LOD

      • 是否被遮挡剔除

      • 是否当前节点不可拆分、,不可拆分就不需要拆出子节点

    • 如果上述判断都通过了,就进入子节点递归处理

    template<bool TUseVector>
    void FHierarchicalStaticMeshSceneProxy::Traverse(const FFoliageCullInstanceParams& Params, int32 Index, int32 MinLOD, int32 MaxLOD, bool bFullyContained) const
    {
     const FClusterNode& Node = Params.Tree[Index];
     if (!bFullyContained)
     // 判断当前节点是否被剔除(视锥剔除)
     if (CullNode<TUseVector>(Params, Node, bFullyContained))
     return;
    
     // 更新当前节点的LOD
     if (MinLOD != MaxLOD)
     {
     // 根据boundingbox的距离以及width(尺寸)判断当前节点的最小(高精)最大(低精)LOD
     CalcLOD(MinLOD, MaxLOD, (FVector)Node.BoundMin, (FVector)Node.BoundMax, Params.ViewOriginInLocalZero, Params.ViewOriginInLocalOne, Params.LODPlanesMin, Params.LODPlanesMax);
    
     // 超出设定的最高LOD,不予显示?
     if (MinLOD >= Params.LODs)
     return;
     }
    
     // 遮挡剔除判断
     if (Index >= Params.FirstOcclusionNode && Index <= Params.LastOcclusionNode)
     {
     check(Params.OcclusionResults != NULL);
     const TArray<bool>& OcclusionResultsArray = *Params.OcclusionResults;
     if (OcclusionResultsArray[Params.OcclusionResultsStart + Index - Params.FirstOcclusionNode])
     {
     INC_DWORD_STAT_BY(STAT_OcclusionCulledFoliageInstances, 1 + Node.LastInstance - Node.FirstInstance);
     return;
     }
     }
    
     // 节点不可拆分
     bool bShouldGroup = Node.FirstChild < 0
     || ((Node.LastInstance - Node.FirstInstance + 1) < Params.MinInstancesToSplit[MinLOD]
     && CanGroup((FVector)Node.BoundMin, (FVector)Node.BoundMax, Params.ViewOriginInLocalZero, Params.ViewOriginInLocalOne, Params.LODPlanesMax[Params.LODs - 1]));
     bool bSplit = (!bFullyContained || MinLOD < MaxLOD || Index < Params.FirstOcclusionNode)
     && !bShouldGroup;
    
     if (!bSplit)
     {
     MaxLOD = FMath::Min(MaxLOD, Params.LODs - 1);
     // 连续Index的Cluster节点会在AddRun后进行合并,比如[0, 31], [32, 50]合并成[0, 50]
     Params.AddRun(MinLOD, MaxLOD, Node);
     return;
     }
    
     // 对子节点进行递归处理
     for (int32 ChildIndex = Node.FirstChild; ChildIndex <= Node.LastChild; ChildIndex++)
     {
     Traverse<TUseVector>(Params, ChildIndex, MinLOD, MaxLOD, bFullyContained);
     }
    }
    
    • 最后遍历可渲染列表的InstanceIndex分段,比如[0, 19] [30, 59]等等,来进行提交MeshBatch:FHierarchicalStaticMeshSceneProxy::FillDynamicMeshElements

    特点

    1. DrawCall数目相对于ISM有所提升(分得更细了)

    2. 聚类粒度缩小,可以实现更为精细的裁剪剔除(为了提高剔除效率,还做了层级处理)

    3. N叉树的N可控,可以配置:CVarFoliageSplitFactor

    参考

    [1]. [ue4] HISM 大规模植被渲染解决方案

    [2]. (UE4 4.27) UHierarchicalInstancedStaticMesh(HISM)原理分析

    [3]. UE4的HISM深入探究

    [4]. UE4 ISM(实例化静态网格体)理解

    相关文章

      网友评论

          本文标题:【UE】Unreal的实例化渲染

          本文链接:https://www.haomeiwen.com/subject/ubuyfdtx.html