【UE】Unreal的实例化渲染

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 Nodes;
 TArray SortedInstances;
 TArray 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 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(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
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(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& 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(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的实例化渲染)