拓冰建站拓冰建站
首页 / 资讯中心 / 正文

UE4森林优化实战:HISM从8000DrawCall降到300的完整方案

去年接手了一个森林场景的优化需求一张大概一公里见方的地图密密麻麻摆了近两千棵树每一棵都是独立的 Static Mesh。场景本身不算复杂光照也压到了最低可运行起来帧率稳稳卡在 45 到 50 之间。GPU 的负载其实非常低真正拖后腿的是 Draw Call——CPU 的渲染线程要把七八千个渲染批次挨个提交给 GPU一个转身就是一次性能雪崩。后来我用 HISMHierarchical Instanced Static Mesh层级实例化静态网格把整个植被系统重构了一遍空间剔除和合批渲染两条线一起做最终效果是 Draw Call 从八千多降到三百以内帧时间从 46 毫秒砍到 14 毫秒稳定跑满 60 帧。这篇文章不打算重复引擎文档里那些概念而是把我在 4.27 里调 HISM、排查问题、踩坑回退的全过程拆开讲原理部分尽量用大白话配置部分直接给可抄的参数最后再列一份避坑清单。如果你手头正好有植被、岩石、建筑群这类大量重复物体的优化任务这篇应该能帮你省掉不少走弯路的时间。1. 为什么是 HISM 而不是 ISM先分清两代实例化方案1.1 从一棵树一个 Draw Call说起很多刚接触优化的同学会把 Draw Call 和多边形数量混在一起其实它们是两条完全独立的性能线。多边形数量压力在 GPUDraw Call 压力在 CPU。一棵 6000 三角形的树对于 GPU 来说只是很小的工作量。但如果你放 2000 棵独立的 Static Mesh ActorCPU 渲染线程要为每一棵生成一个独立的绘制命令包括变换矩阵、材质绑定、渲染状态切换再逐个提交给 RHI。这个过程在引擎里以同步或异步的方式执行瓶颈非常典型GameThread 只有 10msDrawThread 却要花 25ms。ISMInstanced 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 ScreenSize0.6距离较近时使用完整精度LOD1 ScreenSize0.25距离中等时切换到低模LOD2 ScreenSize0.08远距离极低模Cull Distance12000超过这个距离直接不渲染这套配置的思路是LOD0 不要设得太窄否则近距离会频繁跳 LODCull 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 20701080p 窗口模式项目使用 Forward Shading。地形 1000×1000植被 2000 棵树树的高模 6000 三角形共 3 级 LOD。角色在林中移动时我用stat SceneRendering取了一次中位数数据指标优化前说明Mesh Draw Calls7486绝大部分来自植被SetPass Calls412渲染状态频繁切换GameThread 耗时17.8 ms每帧 CPU 逻辑 剔除DrawThread 耗时25.3 ms渲染线程提交命令GPU 耗时2.9 msGPU 几乎在空闲帧率47 fps卡在 CPU 瓶颈这个数据很典型GPU 只用了 3msCPU 的渲染线程却要 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 Calls7486187-97.5%SetPass Calls41239-90.5%GameThread 耗时17.8 ms7.2 ms-59.5%DrawThread 耗时25.3 ms6.4 ms-74.7%GPU 耗时2.9 ms4.1 ms41%GPU 终于开始干活了帧率47 fps60 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 OcclusionDFAO或 Distance Field Shadow调整完 HISM 的实例分布后需要重新构建 Distance Field否则阴影网格不匹配出现树影虚浮、漂移的现象。还有种情况是构建 NavMesh 后HISM 的碰撞体参与导 Nav 数据导致导航网格异常庞大。检查方式是打开 Navigation 的 Debug 显示如果发现某个 HISM 把整片森林都纳入了阻挡就要考虑单独为这个实例组配置碰撞响应或者在 Navigation 系统里排除该 Actor 的阻挡。写在最后的个人体会HISM 这套方案在 4.27 里已经非常成熟它把空间剔除和合批渲染两条线打通真正做到让 CPU 干更少的事让 GPU 干更多的活。但它的收益从来不是自动发生的需要你把网格、材质、LOD、碰撞、距离参数作为一个整体去调任何一个环节脱节效果都会大打折扣。我自己的习惯是每次优化前先明确瓶颈在哪一端用stat SceneRendering和ProfileGPU拉基线优化后保留优化前的数据截图方便对比。如果你也正被大量重复物体的渲染性能折磨按这篇文章的顺序走一遍大概率能从几十帧的泥潭里爬出来。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门