UE4性能优化:从Spawn到HISM,高效渲染大规模实例物体
2026/9/16 2:46:45 网站建设 项目流程

做 UE4 项目的朋友,大概率都遇到过这样的场景:要往关卡里撒几百个碎石、铺一片草地、放一地的金币或者道具。很多人第一反应是 Spawn,也就是循环 SpawnActor,把同一个 Static Mesh 的 Actor 复制出来。结果一跑起来,帧率从 60 直接掉到 20 多,打开 Profile 一看,满屏都是同一个 Mesh 的 Draw Call,CPU 侧的 Actor 数量也涨得吓人。其实这正是 Hierarchical Instanced Static Mesh(HISM)组件该出场的时候。

HISM 是 UE4 内置的一种实例化渲染组件,官方用它做植被系统,也就是 Foliage 的底层实现。它的核心思路是用一个组件、一次 Draw Call 搞定成千上万个相同 Mesh 的渲染,同时通过层级空间结构加速裁剪和 LOD 切换。这篇文章我会从 Spawn 为什么慢讲起,然后拆解 HISM 的原理,再给出一套从 Spawn 迁移到 HISM 的完整实操流程,最后把我踩过的坑和排查经验都列出来。适合正在做开放世界、大场景布置、大量拾取物或者任何需要批量摆放物体的开发者参考。

1. 先搞清楚:Spawn 为什么慢

1.1 Actor 不是“一个模型”那么简单

很多人想当然地认为,SpawnActor 出来一个带 Static Mesh 的 Actor,跟 HISM 里添加一个实例差不多。但实际差别非常大。每 SpawnActor 一次,你得到的是一个完整的 AActor 对象,它身上至少挂了一个 USceneComponent 和一个 UStaticMeshComponent,还可能带碰撞、网络复制、事件绑定等默认开销。哪怕你关闭了 Tick,这个 Actor 依然存在于引擎的级别里,要参与场景更新、GC 引用追踪、序列化、编辑器标记等一系列工作。

从渲染管线角度看,一个 Static Mesh Component 正常提交时至少要产生一次 Draw Call,如果这个 Mesh 内部有多个 Section 或者多个材质元素,Draw Call 会成倍增加。也就是说,你用 Spawn 复制 1000 个碎石,渲染线程收到的就是 1000 次 Draw Call,CPU 侧则要遍历这 1000 个组件各自的裁剪、遮挡剔除、距离计算。引擎再优化,也顶不住这种“一个实例一套完整组件”的架构。

1.2 一场“拾取物”压垮帧率的实测

我之前做过一个原型:在一个 500×500 的测试关卡里,用 SpawnActor 铺了 800 个金币模型,每个金币是一个 300 面左右的低模。场景里没有任何敌人、没有任何逻辑,只是让角色在中间跑。结果怎样呢?帧率从空场景的 144 帧降到了 45 帧左右,Stat GPU 显示 Draw Call 大概从几十涨到了接近 900。大部分开销并不是像素填充,而是 CPU 侧的组件提交和状态切换。

我又试过把数量加到 3000,帧率直接掉到 18 帧左右,而且内存上涨非常明显,因为每个 Actor 都有独立的对象头、组件列表、属性表。那一瞬间我就明白,用 Spawn 做“数量上规模的同构物体”是战略错误。它不是某个参数没调好,而是架构上就不适合。

1.3 什么时候 Spawn 仍可接受

当然,我不是让你把所有 Spawn 都废弃。反过来讲,如果物体需要独立逻辑、独立动画、独立物理模拟、独立交互事件,比如 NPC、怪物、可以被拾取并播放动画的钥匙,那就不适合用 HISM。每个实例在 HISM 里本质上是“一堆变换数据”,没有单独的事件或行为,你很难对某一个实例附加复杂逻辑。

我的经验是,当同屏同类物体数量超过一百,而且它们的行为趋同、不需要各自 Tick 时,就应该条件反射地想到实例化渲染。如果数量只有几十个,用 Spawn 完全没问题,别为了炫技强行上 HISM,反而增加不必要的复杂度。

2. HISM 到底快在哪

2.1 从一个 Static Mesh 渲染百万个实例

HISM 全称是 Hierarchical Instanced Static Mesh,核心是两个词:Instanced 和 Hierarchical。先说 Instanced。它本质上利用了 GPU Instancing 技术:同一个 Static Mesh 的顶点数据、索引缓冲、材质参数只在显存里存一份,渲染时把这批实例的变换矩阵打包成一个 Instance Buffer,GPU 在一个 Draw Call 内根据每个实例的矩阵重复绘制同一个 Mesh。

打个生活化的比方:普通 Spawn 相当于你复印了 1000 张图纸,叫 1000 次快递分别送出去;编辑器里每个快递都要单独分拣、单独立单。而 Instancing 就像一张订单写清楚“这 1000 个东西都送到同一个地址”,快递车一次拉过去,收货点按地址逐个分发。所以 Draw Call 从 N 次直接降到了大约 1 次,这个收益在几百上千个实例时极其明显。

2.2 层级结构是“更聪明”的部分

如果只是简单 Instancing,那叫 ISM(Instanced Static Mesh)。HISM 在此基础上多了一个“层级”结构:引擎会把场景中的实例按照空间位置聚成一簇一簇的 cluster,再对 cluster 构建一棵树。剔除的时候,如果整棵子树在视锥外或遮挡关系不成立,这棵子树下的所有实例就可以一次性跳过,不用逐个判断。

这个优化最典型的收益体现在大面积草地的场景。一簇草可能在屏幕里只占几个像素,HISM 直接整簇剔除;如果按照 ISM 逐实例遍历,哪怕实例本身很便宜,CPU 遍历和提交的总量也会撑爆。HISM 还有一套基于簇的 LOD 切换机制,远处簇直接切低模,甚至成簇消失,比逐个实例计算 LOD 高效得多。这也是为什么 UE4 的植被系统最终选择 HISM 作为底层。

2.3 HISM vs ISM vs 常规 Actor 的取舍表

我自己做选型时习惯画一张表,把需求往里套。下面这个表基本覆盖了常见判断维度:

对比维度SpawnActor + StaticMeshISMHISM
CPU 裁剪开销高,逐个组件遍历中,逐实例遍历低,按簇递归裁剪
Draw CallN 个实例 ≈ N 次每个 Mesh/材质 1 次每个 Mesh/材质 1 次
内存开销高,每个 Actor 一套对象中,只有实例数据中上,实例数据 + 树结构
动态增删实例灵活但开销大支持,但更新效率一般支持,批量操作最优
LOD 支持每个组件独立 LOD整体只能用固定 LOD支持按簇混合 LOD
有无碰撞每个组件自带,灵活支持实例碰撞支持实例碰撞,但代价较高
适合场景少量交互物、NPC静态批量物体超大数量、植被、开放世界

看到这个表你应该明白了,HISM 不是在所有维度上都是最优的。它最大的代价是内部那棵树的维护。如果你频繁微调单个实例的 Transform,触发树重建的代价会很高。因此,静态且数量巨大的物体是它的主战场,动态批量更新的场景需要谨慎设计。

3. 实操:从 Spawn 改成 HISM 的完整流程

3.1 创建 HISM 组件的两种姿势

蓝图里最直接的方式是给 Actor 添加组件,搜索 Hierarchical Instanced Static Mesh,拖进来后把 Static Mesh 设置成目标模型。然后在 BeginPlay 里调用 Add Instance,传入 FTransform 即可。

C++ 项目里更推荐在构造函数里创建和配置组件。下面是一个最小可用的 Actor 类示例:

// MyHISMTestActor.h #pragma once #include "CoreMinimal.h" #include "GameFramework/Actor.h" #include "MyHISMTestActor.generated.h" UCLASS() class MYPROJECT_API AMyHISMTestActor : public AActor { GENERATED_BODY() public: AMyHISMTestActor(); UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HISM") class UHierarchicalInstancedStaticMeshComponent* HISMComponent; };
// MyHISMTestActor.cpp #include "MyHISMTestActor.h" #include "Components/HierarchicalInstancedStaticMeshComponent.h" AMyHISMTestActor::AMyHISMTestActor() { PrimaryActorTick.bCanEverTick = false; HISMComponent = CreateDefaultSubobject<UHierarchicalInstancedStaticMeshComponent>(TEXT("HISMComponent")); RootComponent = HISMComponent; // 这里建议先不设置 StaticMesh,留给蓝图或代码在运行时指定, // 但如果你确定模型资源,也可以在构造函数里直接加载。 }

PrimaryActorTick.bCanEverTick = false顺手关掉 Tick,因为 HISM 场景根本不需要这个 Actor 每一帧去刷逻辑。

3.2 核心 API:添加、删除、更新实例

配置好组件后,最常用的接口就是 AddInstance、AddInstances、RemoveInstance、UpdateInstanceTransform 和 ClearInstances。我直接给一段批量添加 10000 个实例的参考代码:

void AMyHISMTestActor::InitInstances() { if (!HISMComponent || !HISMComponent->GetStaticMesh()) { return; } TArray<FTransform> Transforms; Transforms.Reserve(10000); FRandomStream Stream(12345); for (int32 Index = 0; Index < 10000; ++Index) { FVector Location( Stream.FRandRange(-5000.0f, 5000.0f), Stream.FRandRange(-5000.0f, 5000.0f), StartHeight ); FRotator Rotation(0.0f, Stream.FRandRange(0.0f, 360.0f), 0.0f); FVector Scale(Stream.FRandRange(0.8f, 1.2f)); Transforms.Add(FTransform(Rotation, Location, Scale)); } // 批量添加,一次性交给引擎 HISMComponent->AddInstances(Transforms, false); // 添加完成后手动重建内部树,确保后续裁剪、LOD 立即生效 HISMComponent->BuildTreeIfOutdated(); }

这里有个细节很多人容易忽略:AddInstances 的第二个参数是 bWorldSpace,意思是传入的 Transform 是相对于世界空间还是相对于组件。如果你把 HISMComponent 挂在一个会整体移动的根组件下面,千万想清楚用哪种模式,或者统一在局部空间里更新。

关于移除,RemoveInstance 会移除指定索引的实例。要注意的是,删除后剩下的实例索引会重新排列,如果你的业务逻辑里保存了实例索引数组,删除后必须做索引同步。我习惯的做法是“池化”:不真正删除,而是把不用的实例移动到很远的坐标并隐藏,等需要的时候再重新设置 Transform。这样绕开了树重建的代价。

3.3 关键参数与推荐配置

HISM 的参数不少,但真正影响性能的就那么几个。我在项目里常用的配置如下:

参数推荐值说明
MinInstanceCount50低于这个数量时组件退化为普通 ISM 逻辑;数值越小越早构建树,但重建也更频繁
bAutoRebuildTreeOnInstanceChangesfalse运行时大量增删时建议关闭,手动批量操作后重建
InstanceStartCullDistance / InstanceEndCullDistance按场景距离设置实例级裁剪距离;建议从近到远分层
bUseDynamicInstanceBuffer视情况开启如果材质使用 World Position Offset 或要频繁移动实例,可以开启
CollisionEnabledQueryOnly 或 NoCollision对纯展示对象建议 NoCollision,否则实例数量大了很亏

MinInstanceCount是我第一次用 HISM 时踩过的坑。默认值是 50,也就是说实例数量少于 50 时,组件可能不会真正构建层级树,直接按 ISM 处理。当时我做了个测试,加了 30 个实例,发现性能跟普通 ISM 没差别,一度怀疑 HISM 没用。后来把数量拉到几百个,差别才体现出来。所以如果你的目标实例数很小,用 HISM 反而没有意义。

3.4 常见应用落地案例

先说说我最常用的三个场景。第一个是草地。做法是准备两三种草的 Static Mesh,建一个 HISM 组件,然后按地面采样点随机旋转、缩放,批量添加。草不需要碰撞,直接 NoCollision,开启双面材质,配合 wind 的 World Position Offset,效果能打。

第二个是场景里的装饰性碎石、垃圾、瓶瓶罐罐。它们数量多但玩家不会交互,也用 HISM。第三类是拾取物,比如金币。严格来说这些物体需要交互,但拾取行为可以这样设计:先用 HISM 批量生成,玩家触发拾取后把对应实例移到地图外并隐藏,等刷新时间到了再移回来。这样比每拾取一个就 DestroyActor 一个要平滑得多。

关于每个实例的随机颜色或随机参数,可以用 PerInstanceCustomData 或者 CustomPrimitiveData,把随机浮点数组传给材质。这个我后面章节会讲到,但先说结论:HISM 自带实例随机化变量的能力,不需要每实例拆材质。

4. 常见问题与坑

4.1 运行时动态增删性能突然卡顿

这是 HISM 最大的“隐藏坑”。很多人发现,明明渲染节省了 Draw Call,但运行时 AddInstances 一大批次,帧率会突然卡一下。原因在于引擎需要重建内部的层级树,特别是实例数很大时,聚类计算和 LOD 分配会占用一定 CPU 时间。

我的处理经验有三条。第一,尽量在加载阶段或 Enter 关卡时一次性批量添加,不要每帧零星添加。第二,如果确实要运行时大量生成,把 bAutoRebuildTreeOnInstanceChanges 临时设为 false,等整批操作完成后再调用 BuildTreeIfOutdated。第三,动态移除时优先用“位移+隐藏”代替 RemoveInstance,通过 SetVisibility 或把实例移到超远距离,避免频繁改动树结构。

4.2 碰撞与物理的坑

HISM 的碰撞分为简单碰撞和复杂碰撞,但不管哪种,实例数量大之后碰撞查询开销都不低。纯展示的草、碎石,直接把 CollisionEnabled 设为 NoCollision。如果必须碰撞,我只给一部分实例开启,或者用简化盒体,而不是每个实例都用复杂碰撞体。

还有一个常见误解:HISM 循环里你写 Physics Simulation,每个实例并不会自动获得刚体物理模拟能力。它只是渲染实例化,物理世界不会因为你在 HISM 里塞了模型就为每个实例生成物理体。如果你确实需要物体可被推动、掉落,那就别硬用 HISM,老实回到 SpawnActor。

4.3 材质、LOD 与渲染相关的坑

材质方面,很多刚上手的人会疑惑为什么 HISM 里的世界位置偏移不生效。这个跟 bUseDynamicInstanceBuffer 有关。如果你在材质里用了 World Position Offset,建议开启这个选项,否则部分平台会表现异常。另外,任何逐实例动画如果不能完全放进材质,就不适合 HISM。

LOD 方面,务必确认 Static Mesh 已设置 LOD,并且在 HISM 组件上开启 LOD 相关选项。HISM 对 LOD 的处理是簇粒度的,它会根据距离给不同簇分配不同 LOD 等级。如果 Mesh 只有一个 LOD,那 HISM 的 LOD 优化就无从谈起。还有,实例距离裁剪的 Start 和 End 之间建议给一个过渡带,避免物体在远处突然全部消失或者像纸片一样闪没。

5. 实际项目中的优化扩展

5.1 与 Foliage 模式的关系

UE4 编辑器内置的植被刷工具,底层用的就是 HISM 组件。你手动刷出来的每一棵草,本质上是给同一个 HISM 组件添加了一个实例。知道这层关系后,很多操作就通透了:你可以先在编辑器里用 Foliage 刷好布局,运行时再把新物体实例添加进同一个组件;也可以完全在代码里动态生成,两者可以混用。官方之所以敢让你刷几万棵草,就是因为它把整个草地收拢在若干 HISM 组件里。

5.2 结合 Nanite 与移动端注意事项

UE5 的 Nanite 发布后,很多人问 HISM 是不是被淘汰了。实际上 Nanite 解决的是三角形提交和像素层面剔除的问题,而 HISM 解决的是批量实例管理的问题。两者可以配合:Nanite 网格也可以放进 HISM 组件使用。不过要注意,Nanite 的几何体在低端硬件上有额外开销,移动端还是要谨慎。

移动端使用 HISM 时,我建议把实例数控制在一万以内,并且不要开太高的阴影。移动 GPU 的实例缓冲带宽有限,实例数过大会把内存带宽打满,Draw Call 虽然降下来了,但性能依然上不去。如果项目有包体或显存预算要求,先做真机测试再决定实例规模。

5.3 性能验证方法:Stat GPU / ProfileGPU

不要只凭感觉判断优化效果。添加 HISM 前后,打开控制台输入Stat GPU,看 Draw Call 和渲染线程耗时;输入ProfileGPU抓一帧,看 HISM 对应的 Mesh 提交只占多少毫秒。我习惯再开Stat Scenerendering查看实例数、裁剪掉的实例比例。如果裁剪比例很低,说明你的实例可能过于集中,或者层级树没有真正生效。

还有一个容易被忽略的验证点:看 CPU 帧时间。HISM 的收益主要体现在两个层面,一个是渲染线程的 Draw Call 下降,另一个是游戏线程的组件遍历下降。只盯着 GPU 时间看可能会漏掉真实收益。用 Unreal Insights 或简单的stat unit都能看到各线程耗时占比。

另外想说一个小技巧:调试时可以在 HISM 组件上开启 Debug 绘制,把每一簇的包围盒画出来。这样能直观看到层级树是否合理,比如是否出现了异常大的簇导致裁剪失效。这个在开发阶段非常管用,能帮你快速定位到底是资源问题还是配置问题。

我在实际项目里最深的体会是:HISM 并不是一个“性能黑魔法”,它的所有优势都建立在“大量同构、静态、无需独立逻辑”这个前提上。用对地方,它是一个组件撑起整个场景;用错地方,你会在动态更新和碰撞上付出更高成本。所以遇到批量物体需求时,先冷静做一次选型判断,把物体分成“要逻辑的”和“纯摆件的”两类,然后再决定哪些走 Spawn、哪些走 HISM。

最后分享一个我常用的落地思路:任何新场景,默认把地面石子、墙面装饰、远处建筑群、大量可交互的非角色道具全部规划到 HISM 实例池里;只有需要独立动画和事件的对象才留下 SpawnActor 路径。然后把批量的添加、更新、隐藏都封装成一个简单的实例池管理类,业务层只跟索引打交道。这样既拿到了性能,又不会把代码写成一团乱麻。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询