阅读视图

发现新文章,点击刷新页面。

【UE5】使用 InstancedStaticMesh 实现海量实例动态渲染的性能优化实践

目标

尝试使用 InstancedStaticMesh(ISM) 实现大量物体的实例化渲染,并在每帧 Tick 时修改每个实例的旋转值,测试在海量物体持续运动场景下的性能表现。

实现

在实现过程中,我最初使用的是 HierarchicalInstancedStaticMesh(HISM)
但实际测试发现,在频繁更新 Transform 的情况下,它的更新开销非常大。

如果没有层级裁剪等特殊需求,且实例需要大量、动态更新时,InstancedStaticMesh 的性能通常更有优势

另外,使用 ISM 进行大规模每帧更新时,建议将CollisionEnabled设置为NoCollision,否则碰撞相关的更新会带来明显的额外性能消耗。我发现在 UE 中,一个常见的优化思路是:关闭不必要的功能,只保留真正需要的部分

方案一:InstancedStaticMesh 配合蓝图实现实例更新

下面是 Tick 事件蓝图的关键逻辑:

企业微信截图_17695677182998.png

在编辑器中运行游戏时,帧率表现并不理想。通过 Unreal Insights 分析发现,大部分时间都消耗在循环更新 Transform Array 的逻辑上。

企业微信截图_17695678043349.png

但这部分本质上只是简单的数学计算。仅 10K 个实例循环更新 Transform 就产生了较高的耗时,因此基本可以判断,瓶颈主要来自蓝图自身的运行开销

方案二:InstancedStaticMesh配合C++实现的实例更新

既然蓝图性能不足,于是改用 C++ 实现。

我编写了一个 AActor 子类,在 BeginPlay 中创建 InstancedStaticMesh 组件并挂载到根节点,然后在 Tick 中更新实例 Transform。

Tick部分的实现代码如下:

void ABatchedBoxes::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    const float AngleRad = FMath::DegreesToRadians(RotationSpeed * DeltaTime);

    const FQuat DeltaQuat(FVector::UpVector, AngleRad);

    const int32 Count = CachedTransforms.Num();

    for (int32 i = 0; i < Count; ++i)
    {
        FTransform& T = CachedTransforms[i];

        FQuat Q = T.GetRotation();

        T.SetRotation(DeltaQuat * Q);
    }

    ISM->BatchUpdateInstancesTransforms(
        0,
        CachedTransforms,
        false,  // local space
        true,   // mark render dirty
        false   // teleport
    );
}

C++ 版本的性能有明显提升。

但通过 Unreal Insights 进一步分析发现,Game Thread 中
UInstancedStaticMeshComponent::CalcBoundsImpl 占用了较大比例时间,看起来是在计算组件整体包围盒。

image.png

在当前场景下,整体包围盒变化不大,因此这部分计算并非必要。

于是我对初始化逻辑进行了优化:

  • 添加一个固定包围盒组件
  • 将 ISM 的 bUseAttachParentBound 设置为 true

这样可以复用父节点包围盒,避免每帧重新计算。

下面是构造函数与BeginPlay的逻辑实现:

// Sets default values
ABatchedBoxes::ABatchedBoxes()
{
     // Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
    PrimaryActorTick.bCanEverTick = true;

    UBoxComponent* BoundsRoot = CreateDefaultSubobject<UBoxComponent>(TEXT("BoundsRoot"));
    BoundsRoot->SetCollisionEnabled(ECollisionEnabled::NoCollision);
    BoundsRoot->SetGenerateOverlapEvents(false);
    BoundsRoot->SetCanEverAffectNavigation(false);
    BoundsRoot->SetMobility(EComponentMobility::Movable);
    RootComponent = BoundsRoot;

    ISM = CreateDefaultSubobject<UInstancedStaticMeshComponent>(TEXT("ISM"));
    ISM->SetupAttachment(BoundsRoot);

    ISM->bUseAttachParentBound = true;

    ISM->SetCollisionEnabled(ECollisionEnabled::NoCollision);
    ISM->SetGenerateOverlapEvents(false);
    ISM->SetCanEverAffectNavigation(false);
    ISM->bCastDynamicShadow = false;
    ISM->bCastStaticShadow = false;
    ISM->bAffectDistanceFieldLighting = false;
    ISM->bReceivesDecals = false;
    ISM->SetMobility(EComponentMobility::Movable);

    ISM->NumCustomDataFloats = 0;
}

// Called when the game starts or when spawned
void ABatchedBoxes::BeginPlay()
{
    Super::BeginPlay();

    if (InstanceStaticMesh)
    {
        ISM->SetStaticMesh(InstanceStaticMesh);
    }

    const int32 Grid = FMath::CeilToInt(FMath::Sqrt((float)InstanceCount));
    const float HalfGrid = (Grid - 1) * Spacing * 0.5f;

    if (UBoxComponent* BoundsRoot = Cast<UBoxComponent>(RootComponent))
    {
        BoundsRoot->SetBoxExtent(FVector(HalfGrid + Spacing * 0.5f, HalfGrid + Spacing * 0.5f, Spacing), true);
    }

    CachedTransforms.SetNumUninitialized(InstanceCount);

    int32 Index = 0;

    for (int32 x = 0; x < Grid && Index < InstanceCount; ++x)
    {
        for (int32 y = 0; y < Grid && Index < InstanceCount; ++y)
        {
            FVector Pos(x * Spacing - HalfGrid,
                        y * Spacing - HalfGrid,
                        0.f);

            FTransform T;
            T.SetLocation(Pos);
            T.SetScale3D(FVector(1));

            CachedTransforms[Index] = T;

            ISM->AddInstance(T);

            Index++;
        }
    }
}

再次使用 Unreal Insights 分析后发现,当前主要耗时集中在:UInstancedStaticMeshComponent::BatchUpdateInstancesTransformsDeferredRenderUpdates_GameThread这两个函数。

它们主要对应:

  • 大量 Transform 数据的批量更新
  • 向 GPU 上传实例数据

也就是大量实例的同步成本。

将实例数量提升到 100K 后,仍然可以稳定在 60FPS 左右运行,基本符合预期。

image.png

结论

可以看到,使用 InstancedStaticMesh 实现大量物体实例化渲染并进行实时更新时,性能压力主要集中在 CPU(Game Thread)

主要经验总结如下:

  • 蓝图执行效率明显低于 C++,高频、大规模更新逻辑建议使用 C++ 实现
  • 关闭不必要的功能(如 Collision)可以显著减少开销
  • 如果包围盒变化不大,可以避免默认 Bounds 计算,使用自定义或固定方案
  • 当实例数量达到较大规模时,Transform 批量更新与 GPU 数据上传是不可避免的主要成本

总体来说,在合理优化后,10 万级实例的实时更新依然可以达到较好的运行表现

需要说明的是,本文的主要目的是探索 InstancedStaticMesh 组件在“海量实例 + 高频动态更新”场景下的性能边界,属于一次偏实验性质的性能测试与优化记录。

在真实项目中,如果确实存在大规模(例如数十万甚至更多)物体的持续更新需求,更推荐优先考虑:

  • GPU Driven Rendering(GPU Instance / Compute / Niagara / 自定义 Instance Buffer 等方案)
  • 或基于 ECS / 数据驱动架构的批处理更新方式

这类方案能够将大量计算从 CPU(Game Thread)转移到 GPU 或更高效的数据管线中,整体扩展性会更好。

因此,本文方案更适用于:

  • 中等规模实例(几万~十万级)
  • 或需要快速工程落地的 ISM 场景优化

而不是极限规模场景下的最终解决方案。

❌