去年接手了一个森林场景的优化需求:一张大概一公里见方的地图,密密麻麻摆了近两千棵树,每一棵都是独立的 Static Mesh。
场景本身不算复杂,光照也压到了最低,可运行起来帧率稳稳卡在 45 到 50 之间。GPU 的负载其实非常低,真正拖后腿的是 Draw Call——CPU 的渲染线程要把七八千个渲染批次挨个提交给 GPU,一个转身就是一次性能雪崩。
后来我用 HISM(Hierarchical Instanced Static Mesh,层级实例化静态网格)把整个植被系统重构了一遍,空间剔除和合批渲染两条线一起做,最终效果是 Draw Call 从八千多降到三百以内,帧时间从 46 毫秒砍到 14 毫秒,稳定跑满 60 帧。
这篇文章不打算重复引擎文档里那些概念,而是把我在 4.27 里调 HISM、排查问题、踩坑回退的全过程拆开讲:原理部分尽量用大白话,配置部分直接给可抄的参数,最后再列一份避坑清单。如果你手头正好有植被、岩石、建筑群这类大量重复物体的优化任务,这篇应该能帮你省掉不少走弯路的时间。
1. 为什么是 HISM 而不是 ISM:先分清两代实例化方案
1.1 从"一棵树一个 Draw Call"说起
很多刚接触优化的同学会把 Draw Call 和多边形数量混在一起,其实它们是两条完全独立的性能线。多边形数量压力在 GPU,Draw Call 压力在 CPU。
一棵 6000 三角形的树,对于 GPU 来说只是很小的工作量。但如果你放 2000 棵独立的 Static Mesh Actor,CPU 渲染线程要为每一棵生成一个独立的绘制命令,包括变换矩阵、材质绑定、渲染状态切换,再逐个提交给 RHI。这个过程在引擎里以同步或异步的方式执行,瓶颈非常典型:GameThread 只有 10ms,DrawThread 却要花 25ms。
ISM(Instanced Static Mesh)就是把多个相同网格合并到一个 Actor 里渲染,CPU 只需要提交一次实例列表,GPU 通过实例 ID 区分每个物体的位置和变换。相当于从"每个快递单独派送"变成"一车直接拉到驿站"。
ISM 解决了 Draw Call 的数量问题,但它有一个明显短板:实例越多,CPU 提交给 GPU 的数据就越庞大。2000 个实例时还好,两万个实例时每个实例的 Transform、材质参数、随机数据都要从 CPU 上传到 GPU,这部分开销会重新变成瓶颈。而且 ISM 对实例可见性的判断是"逐个来"的,2000 个实例就是 2000 次包围盒判断,CPU 消耗依然可观。
1.2 HISM 的"层级"到底多了什么
HISM 和 ISM 只差一个单词,但这个单词背后是整套算法层面的差异。
HISM 在内部维护了一个空间层级结构,你可以把它理解成场景里一棵四叉树或 BVH(包围体层级结构)。它先给所有实例计算包围体,然后把相近的实例递归归并到父级节点,形成一棵层级树。
这样剔除的时候就聪明了:先检测根节点是否在视锥内,如果根节点都不可见,整个子树所有实例直接跳过。只有当父节点部分可见时,才继续向下检查子节点。换句话说,通常只需要检查几十个节点,就能决定几千个实例的可见性。
4.27 的 HISM 更进一步,渲染路径里已经包含 GPU 驱动的剔除逻辑。CPU 只负责维持树结构和更新脏数据,真正的实例可见性判断在 GPU 端用一个 compute shader 完成,结果直接用来做间接绘制。也就是说,当相机转过去的时候,看向远处的 1500 棵树在 GPU 侧就被标记为不可见,CPU 根本不会再为它们做任何状态切换。
1.3 什么时候该上 HISM,什么时候别硬上
做了这么多年优化,我最大的感受是:工具本身没有好坏,关键是选对场景。
| 场景类型 | 推荐方案 | 原因 |
|---|---|---|
| 少量高模物体,每个都不同 | 独立 Static Mesh / ISM | 实例化对网格一致性的要求高,零散物体合批收益低 |
| 大量重复网格,形状高度统一 | HISM | 空间层级和 GPU 剔除收益最大,比如森林、草地、岩石群 |
| 需要运行时频繁增删实例 | HISM(动态维护) | 提供 AddInstances / UpdateInstanceTransform 等批量接口 |
| 逐实例骨骼动画 | 不适用 | 骨骼动画需要蒙皮,HISM 只支持静态网格 |
| 需要复杂物理模拟/植物碰撞反馈 | 谨慎使用 | 碰撞体查询可能成为新的瓶颈,见第 5.1 节 |
如果你项目里有一片树林,Shape 还都长得差不多,不换 HISM 基本等于眼睁睁看着 CPU 白烧。反过来,如果场景里只有三五块石头,强行做 HISM 反而增加管理成本,没太大必要。
2. 空间剔除实战:让看不见的实例不再消耗任何性能
2.1 引擎默认剔除到底做了哪些事
UE4 的剔除分为三层:视锥剔除、距离剔除、遮挡剔除。普通 Static Mesh Actor 也是这几层,但粒度是"一个 Actor 一个判定"。
- 视锥剔除:每个 Actor 的包围体与相机视锥做相交测试,在屏幕之外的物体直接不渲染。
- 距离剔除:距离相机超过阈值的 Actor 不渲染,但你需要自己在每个 Actor 上配 Max Draw Distance,或者用 World Settings 里的全局距离。
- 遮挡剔除:通过遮挡查询判断物体是否被山体、建筑等挡住。
这套机制对小场景没问题,但当你有 2000 个 Actor 时,单是"每个 Actor 做一次视锥包围盒测试"的循环本身就有成本。有人会问:"才两千次测试,至于吗?" 问题在于这只是第一步,后面每次测试通过还要走 LOD 选择、材质绑定、Draw Command 提交,每个环节都是乘数累积,卡的主要是后段。
2.2 为什么 HISM 的剔除只消耗 1/N 的成本
HISM 的剔除分两级:先树后实例。
当一个实例树有 2000 个实例时,引擎会按照空间分布把这 2000 个点组织到层级节点里。我实测的项目里,一次相机旋转后,引擎往往只需要检查 20 到 30 个节点包围盒,就能确定剩余 95% 的实例不可见。
这个数量级差别很大:一个平面的循环次数从 2000 降到了 30,同时每个被跳过的子树不需要进入渲染线程的各种阶段,省掉的 CPU 开销非常可观。
还要注意 HISM 的包围体精度问题。一个常见的坑是:实例的包围体太大,明明视角里已经看不到某些树了,剔除却判定它们可能可见,于是全部提交。你可以用控制台命令r.VisualizeOccludedPrimitives 1查看当前帧里哪些物体被遮挡剔除,如果发现远处大片半透明高亮区域,基本上就是包围体或者遮挡查询出了问题。
2.3 Cull Distance Volume 和 LOD 距离的正确搭法
HISM 的剔除不能只靠视锥,距离剔除和 LOD 缩放是另外两根支柱。我通常的配置顺序是:
- 在关卡里拖一个Cull Distance Volume,把范围包住整个森林区域。
- 在 Volume 的 Details 面板里添加 Cull Distance 条目,指定 Mesh 和 Max Draw Distance。
- 回到 HISM 的 LOD 设置页,把每一级 LOD 的 ScreenSize 调整到合理值。
给你一组我实际用在 4.27 的参考值(单位厘米,树高约 600 到 900):
| 参数 | 数值 | 说明 |
|---|---|---|
| LOD0 ScreenSize | 0.6 | 距离较近时使用完整精度 |
| LOD1 ScreenSize | 0.25 | 距离中等时切换到低模 |
| LOD2 ScreenSize | 0.08 | 远距离极低模 |
| Cull Distance | 12000 | 超过这个距离直接不渲染 |
这套配置的思路是:LOD0 不要设得太窄,否则近距离会频繁跳 LOD;Cull Distance 不要设得太大,否则远端低模实例叠加起来的像素量会拖累 GPU 填充率。数值需要根据场景比例微调,不要照抄。
2.4 预计算可见性在 HISM 上的取舍
4.27 还提供一种预计算可见性方案,通常配合小体积遮挡(Precomputed Visibility Volumes)。它的原理是在构建时预先计算静态场景内每个位置能看到的物体集合,运行时直接查询,替代一部分动态遮挡查询。
但我在 HISM 场景里对它持保留态度。原因是 HISM 的实例数量巨大,预计算可见性如果覆盖所有实例,体积数据会非常臃肿,构建时间也成倍上涨。对于植被、树林这类高频分布物体,我更推荐用视锥加距离的组合拳,把预计算可见性留给场景里的建筑、墙体等大块遮挡物。
如果你的项目在移动端,遮挡查询的成本更高,可以考虑把距离阈值再收紧一些,毕竟远距离低模的视觉收益已经很低,强行保留意义不大。
3. 合批渲染的底层逻辑:一个 Draw Call 怎么装下几千棵树
3.1 Draw Call 为什么这么贵
渲染一个物体不是一个简单的"画三角形"命令,它包含一堆 GPU 状态准备:绑定顶点缓冲、绑定索引缓冲、设置着色器、设置贴图、设置混合模式、设置深度缓冲状态……每切换一次状态都可能触发 GPU 流水线停顿。
2000 棵树就是 2000 次状态绑定,哪怕材质和网格是一模一样的,CPU 也要把这些状态重新设置一遍。所以优化界老话叫"让 GPU 忙起来,让 CPU 闲下来",合批的目标正是减少 CPU 提交次数。
有个容易混淆的概念必须提一下:引擎里统计的 Draw Call 和 SetPassCall 不一样。SetPassCall 是真正切换了渲染状态的次数,它更接近性能瓶颈的真实指标。你可以在stat SceneRendering中同时看到两者,优化时重点盯着 SetPassCall。
3.2 HISM 合批的最小单元不是越多越好
HISM 的合批单位是"同一网格 + 同一材质 + 同一 LOD 等级 + 同一渲染路径"。只要这四个条件任意一个不满足,就无法合到同一个批次里。
我遇到过一个非常典型的情况:森林里 1800 棵树用了同一个网格,但材质球分成了四份,每份只是颜色参数不同。结果就是 1800 个实例被拆成四个批次,虽然比逐棵树提交要好,但跟"一个批次装下所有树"差得很远。
尽量让森林用同一个材质实例,差异通过顶点色或 PerInstanceCustomData 来表达,这是最省批次的做法。颜色冷暖差异、风摆振幅差异、随机缩放差异,都可以用实例数据在材质里算,不需要真开一个材质变体。
3.3 静态实例化与动态实例化的取舍
HISM 在渲染上可以分成两条路:静态构建和动态维护。
静态构建适合场景搭建期间就把位置定死的情况,运行时实例不会增加也不会移动。引擎可以在构建时优化加速结构,渲染时完全走 GPU 驱动路径,CPU 开销最小。
动态维护适合运行时生成大量物体的玩法,比如种树、建造、刷怪。4.27 提供了AddInstances和UpdateInstanceTransform这类接口,但要注意批量调用,不要一个实例一个实例地 Add。我见过有人写循环每帧 AddInstances 几百次,引擎每次都要重建节点信息,最终卡成幻灯片。
正确做法是把所有实例的 Transform 先收集到一个数组,然后一次性AddInstances传入。运行时如果只改某一棵树的朝向,用UpdateInstanceTransform单点更新,让内部标记脏数据,而不是删掉重加。
3.4 用统计工具验证合批是否生效
配置完之后,一定要用数据说话。我常用的验证流程是:
stat SceneRendering重点看这几项:
- Mesh Draw Calls:总绘制调用数。
- SetPass Calls:渲染状态切换次数,越小代表合批越成功。
- DrawPrimitive Calls:实际图元绘制调用。
另外用ProfileGPU在 GPU 时间轴里看实例化绘制的执行时间,如果看到DrawIndexedInstancedIndirect一条记录处理了几千棵树,说明 GPU 实例化路径已经生效。
还有一个查合批失败的技巧:stat RHI旁边配合材质调试视图r.ShaderComplexity 1,如果同一个网格出现多个 Shader 变体,说明材质状态不统一。
4. 一个 2000 棵树的森林场景完整优化链路
4.1 优化前的数据基线
我先交代一下场景和机器的基线,方便你对比自己的情况。
硬件是普通的 i7-9700K + GTX 2070,1080p 窗口模式,项目使用 Forward Shading。地形 1000×1000,植被 2000 棵树,树的高模 6000 三角形,共 3 级 LOD。角色在林中移动时,我用stat SceneRendering取了一次中位数数据:
| 指标 | 优化前 | 说明 |
|---|---|---|
| Mesh Draw Calls | 7486 | 绝大部分来自植被 |
| SetPass Calls | 412 | 渲染状态频繁切换 |
| GameThread 耗时 | 17.8 ms | 每帧 CPU 逻辑 + 剔除 |
| DrawThread 耗时 | 25.3 ms | 渲染线程提交命令 |
| GPU 耗时 | 2.9 ms | GPU 几乎在空闲 |
| 帧率 | 47 fps | 卡在 CPU 瓶颈 |
这个数据很典型:GPU 只用了 3ms,CPU 的渲染线程却要 25ms,说明瓶颈完全在 CPU 侧的 Draw Call 和状态切换,属于教科书级的"CPU 渲染瓶颈"。
4.2 三步完成 HISM 化改造
第一步:整理资产。
把所有树的网格引到一个统一的静态网格资产,材质全部替换成同一个材质实例。如果树本身有不同的颜色变化,我用顶点色 Mask 来区分,不再拆材质。
第二步:生成 HISM Actor。
关卡里放一个 HISM Actor,把树的 Static Mesh 指过去,然后在蓝图里把原来的树木 Actor 位置批量采集出来,存成 Transform 数组,一次性AddInstances。注意不要手工去摆几千个位置,要写个小工具脚本批量搬移。
第三步:配置剔除和碰撞。
把碰撞预设改成 NoCollision(除非你的玩法需要打树),设置 LOD 距离,放置 Cull Distance Volume。这一步做完,基本性能就已经有质的飞跃。
4.3 从筛选实例到剔除距离的调优节奏
很多人一上来就把 Cull Distance 拉满、LOD 全都设成极低,结果远处山上的树像糊掉的纸片。我建议按下面节奏迭代:
- 先只开视锥剔除:看 Draw Call 降了多少,确认 HISM 化本身的效果。
- 再逐级加 LOD ScreenSize:用 Shader Complexity 观察远处像素密度,找到视觉可接受的最低 ScreenSize。
- 最后加 Cull Distance:从最近的有效距离开始拉远,直到出现明显可见的"树突然消失"再回退一点。
实际跑下来,我把 Cull Distance 定在 12000 厘米。再往下调就刚好会在玩家移动时出现明显的植被跳变,12000 是视觉和性能的平衡点。
4.4 优化后的实测数据
经过上面几步,同一场景同一视角再次统计:
| 指标 | 优化前 | 优化后 | 降幅 |
|---|---|---|---|
| Mesh Draw Calls | 7486 | 187 | -97.5% |
| SetPass Calls | 412 | 39 | -90.5% |
| GameThread 耗时 | 17.8 ms | 7.2 ms | -59.5% |
| DrawThread 耗时 | 25.3 ms | 6.4 ms | -74.7% |
| GPU 耗时 | 2.9 ms | 4.1 ms | +41%(GPU 终于开始干活了) |
| 帧率 | 47 fps | 60+ fps | 稳定满帧 |
有趣的是 GPU 耗时反而上涨了,这其实是好事。说明之前 GPU 一直在等 CPU 喂数据,现在数据喂得又快又集中,GPU 的绘制能力才真正被用起来。整体上 CPU 两端的时间合计从 43ms 降到了 13.6ms,空出来的预算可以留给玩法逻辑和特效。
5. 实战排雷:HISM 调优中最容易翻车的 5 个位置
5.1 碰撞体是隐形杀手
HISM 默认的碰撞行为有时候很坑。如果你给树设置了复杂的碰撞体(Complex as Simple),当玩家发射射线检测、物理扫描或角色移动碰撞查询时,引擎会对树的高模三角形做精确相交测试。2000 棵树叠加起来,物理查询可以把 GameThread 的帧时间拖上去好几个毫秒。
我曾经排查过一个诡异问题:HISM 化之后 Draw Call 和渲染线程都降了,但 GameThread 反而比优化前更慢。查了stat Engine才发现是物理查询耗时暴增。
解决方案:如果树不需要碰撞,直接 NoCollision;如果必须碰撞,用很粗糙的球体/胶囊体近似,不要让引擎对每棵树的复杂三角形做检测。
5.2 运行时增删实例不要用循环逐个做
我在 4.2 节已经提过,这里再强调一次。AddInstances内部会重新组织层级树,如果你在循环里调用 500 次,等于把这棵树的拓扑重排了 500 次。
正确方式始终是:构造一个 Transform 数组,一次调用传入所有实例。如果后期要修改单个实例,用UpdateInstanceTransform,它会只标记被修改节点的包围体,重新归并局部数据,成本低得多。
5.3 材质里的隐藏合批杀手
HISM 虽然牛,但它不是万能胶水。下面这些材质特性会直接破坏合批或增加额外 pass:
- 使用半透明混合模式的材质,实例化会退化为逐实例排序,不建议用 HISM。
- 材质开了折射或自定义深度写入,也会产生额外的渲染 pass。
- 每个实例使用不同的材质参数但没走 PerInstanceCustomData,而是拆不同材质实例,合批就被打断。
- Dithered LOD Transition 开启时,会多一个 mask pass,实例数很大时注意控制视觉距离。
我建议植被类材质统一走 Opaque 或 Masked,把树叶边缘的透明效果放进 Alpha Mask 里,这样合批最干净。
5.4 Editor 下测试和打包后表现不一致
这是新手最容易踩的坑:在编辑器中看着性能报表不佳,怀疑 HISM 没生效;或者编辑器里一切正常,打包后发现场景变了。
Editor 默认很多优化是没有开启的,比如某些 GPU 驱动剔除、烘焙后的静态光照切换。所以验证 HISM 性能千万别在 PIE 模式下只看编辑器窗口的数字,一定要用 Standalone 模式或直接打包,再挂stat SceneRendering和ProfileGPU。
我在不同版本里反复吃过这个亏,现在验证性能的规范动作统一是:点击运行旁边的下拉箭头,选择 Standalone,再去看统计,这样数据才有参考意义。
5.5 构建后阴影异常或不生效
4.27 里 HISM 配合 Distance Field 阴影和全局距离场时,偶尔会遇到问题。如果场景开了 Distance Field Ambient Occlusion(DFAO)或 Distance Field Shadow,调整完 HISM 的实例分布后,需要重新构建 Distance Field,否则阴影网格不匹配,出现树影虚浮、漂移的现象。
还有种情况是构建 NavMesh 后,HISM 的碰撞体参与导 Nav 数据,导致导航网格异常庞大。检查方式是打开 Navigation 的 Debug 显示,如果发现某个 HISM 把整片森林都纳入了阻挡,就要考虑单独为这个实例组配置碰撞响应,或者在 Navigation 系统里排除该 Actor 的阻挡。
写在最后的个人体会
HISM 这套方案在 4.27 里已经非常成熟,它把空间剔除和合批渲染两条线打通,真正做到"让 CPU 干更少的事,让 GPU 干更多的活"。但它的收益从来不是自动发生的,需要你把网格、材质、LOD、碰撞、距离参数作为一个整体去调,任何一个环节脱节,效果都会大打折扣。
我自己的习惯是:每次优化前先明确瓶颈在哪一端,用stat SceneRendering和ProfileGPU拉基线;优化后保留优化前的数据截图,方便对比。如果你也正被大量重复物体的渲染性能折磨,按这篇文章的顺序走一遍,大概率能从几十帧的泥潭里爬出来。