把近场散摆静物合成 HISM 削 draw-call:一个编辑器批处理工具的实现与踩坑
把近场散摆静物合成 HISM 削 draw-call:一个编辑器批处理工具的实现与踩坑
上一篇按屏幕占比裁剪局部光动态阴影记的是同一个密集城市街道场景里,针对光的那一刀。这篇记另一条杠杆:那个场景里 30,518 个独立 StaticMeshComponent 直接推高的 draw-call 体量。我写了个编辑器工具把同 mesh 的散摆静物折成 HISM,全图跑通一次把 21,483 个 SMC 合进了 494 个 HISM。这篇讲为什么要合、怎么用、以及一路上崩过的几次和绕不开的坑。
背景:draw-call 是三线程并列里能压两条线的一刀
先接上篇的判断。这个场景稳态是 GameThread / RenderThread / RHIThread 三条线并列卡住,各约 88 ms,GPU 只忙 53 ms,有约 36 ms 余量。这种形态下单削一条线不动帧时——被削的那条只会掉到另外两条之下,帧仍由更高的两条决定。要提平均 FPS,只能找能同时压多条线的动作。
30,518 个独立 SMC 恰好是这样一个来源。它同时吃两条瓶颈线:
- RenderThread:draw command 打包(
MeshDrawCommandPassSetupTask,全 trace 12,250 ms / 平均 1.12 ms/帧) - RHIThread:命令翻译加提交(
FRHICommandDrawIndexedPrimitive全 trace 151 万次、CommandList_Submit最差帧 16 ms)
World Partition 的 HLOD 只帮远场——actor 卸载之后才用简化代理顶替。它管不到这批一直驻留在近场的 SMC。而实测又发现,把 MainGrid 的 Loading Range 从 128m 砍到 64m,draw-call 只回收 12%、帧时基本不动:说明约 6000 个 draw-call 集中在 64m 以内的贴脸密度,收距离既伤画质(LOD pop 前移)又撬不动。
所以对这批近场重复静物,真正零画质代价的削 draw-call 手段只有一个:同 mesh 同材质的实例合批。不动任何 LOD 距离,视觉零变化。这就是这个工具要做的事。
思路:按 mesh + DataLayer 分组,每组折一个 HISM
核心逻辑是:扫当前世界所有独立的 Static SMC,按"StaticMesh 资产 + 所属 DataLayer 集合"两个键分组;够阈值(默认 MinGroupSize = 10)的安全组,每组 spawn 一个承载 actor,上面挂一个 HISM,把源组件的世界变换逐个 AddInstance 进去,然后销毁源组件——一个 unique mesh 一个 draw call,同时保留 per-instance 剔除。
工具默认是 dry-run:只写一份候选报告,一个 actor 都不动;要真正改场景得显式调 commit,而且 commit 会弹窗二次确认。这个"报告先行"的习惯救过我很多次——每次改工具后先看报告数字对不对,再决定要不要落地。
三个入口做成了控制台命令风格的静态函数:
| 命令 | 作用 |
|---|---|
BatchActor.MergeSMCToISM_DryRun | 只出候选报告,不改场景 |
BatchActor.MergeSMCToISM_Commit | 弹窗确认后执行合并 |
BatchActor.UnmergeSMCFromISM_Commit | 反向:把选中的 ISM_Merged_* 拆回独立 actor |
为什么 DataLayer 是分组键的一部分
一开始我只按 mesh 分组,很快意识到不对。合并 actor 只能继承一套 DataLayer 集合;如果把分属不同 DataLayer 的同 mesh 实例塞进同一个 HISM,那些原本随各自图层加载/卸载/显隐的实例就全被绑死到这一套图层上了——per-layer 的行为直接坏掉。
所以分组键是 mesh 路径 + 排序后的 DataLayer 名字串。DataLayer 名字先排序再拼 key,保证同一组 actor 无论枚举顺序如何都落进同一个 key。合并 actor 从代表源 actor 上 AddDataLayer 把这套图层继承过来。
TArray<FName> LayerNames = Actor->GetDataLayerInstanceNames();
LayerNames.Sort(FNameLexicalLess()); // 排序,让 key 与枚举顺序无关
FString LayerKey;
for (const FName& LayerName : LayerNames)
{
LayerKey += LayerName.ToString();
LayerKey += TEXT("|");
}
// ...
const FString Key = Mesh->GetPathName() + TEXT("##") + LayerKey;
用法:别一上来就全量 Commit
真正的落地节奏,我建议这样(每一步都是被坑之后加进来的):
- 先 DryRun:出报告
Saved/BP_Dumps/_SMC_merge_candidates.txt,看候选组数、每组数量、涉及哪些 mesh。 - P4 checkout:World Partition 关卡每个 actor 是独立的 external 文件,合并会删源 actor、建新 actor——commit 前必须先 checkout 关卡 umap 和相关
__ExternalActors__目录,否则改动落不了盘。 - Load All Regions:工具用
TActorIterator遍历,只作用于当前已加载的 region。全图合并前必须在 World Partition 面板Load All Regions,否则只合了一部分。 - 单组验证:先 Commit 一个头部大户(比如 340 个的道路铺装),目视核对位置零偏移、收益符合预期、视觉无变化。
- 分批 Commit:按报告从大到小铺开,每批
stat rhi看DrawPrimitive calls验证。 - 重烘 HLOD:
Build → Build HLODs。源 actor 没了,要确保远景仍有 HLOD 代理顶替,且不会源 + HLOD 双绘。
全图跑通那次的结果:Merged 21483 SMC into 494 HISM。P4 侧对上了——494 个新增 ISM_Merged_* actor、21,438 个源 actor 删除、53 个 WP cell 编辑,共约 2 万文件,全是目标关卡的,零无关文件混入。
四层防护:每一层都是崩出来或错出来的
这工具最难的部分不是合并逻辑,是"哪些绝对不能合"。四层过滤全都是踩坑之后加的。
1. 精确类型排除:一次进 PIE 崩溃换来的
最惨的一次是合并后进 PIE 直接 EXCEPTION_ACCESS_VIOLATION。根因是 GetComponents<UStaticMeshComponent>() 是多态查询——它会把 landscape spline 的 USplineMeshComponent(IS-A UStaticMeshComponent)也一并抓进来。我把它当普通 SMC 销毁后,ULandscapeSplineSegment::PostLoad() 还持着那个组件的引用,访问已释放内存直接崩。
修法是一刀切精确类型,而不是 IsA:
// GetComponents<T>() 是多态的,会带出 USplineMeshComponent / ISM / HISM 等所有派生类。
// 精确类型比对把它们一次全挡在外面。
if (Comp->GetClass() != UStaticMeshComponent::StaticClass())
{
continue;
}
这一行同时排掉了 SplineMesh、ISM、HISM 以及任何引擎/插件的 SMC 子类。教训很直白:凡是要销毁别的系统可能还引用着的组件,宁可精确类型比对,别用 IsA 图省事。
2. 数据驱动黑名单:改 ini 不重编译
有些 mesh 从类型上是干净的 UStaticMeshComponent,但语义上绝不能合:
- 引擎 greybox 占位体(Cube / Sphere / Cylinder / Cone / Plane / Torus)——常被体积雾、程序生成之类的蓝图拿来当不可见载体,折进可见 HISM 会让它们开始渲染。
- 发光 / facing 辅助体(灯箱、GodRay 面片)——它们的材质是特殊的,合批后要么变黑板、要么朝向逻辑坏掉。
我把黑名单放进工具配置 ini(UToolsConfig → DefaultTools.ini),这样改黑名单不用重编译。两条规则:
MergeMeshNameBlacklist:按 mesh 资产名精确匹配。MergeMaterialNameBlacklist:按材质名子串匹配。后者是关键——发光/facing 辅助体在美术侧命名各异,但共用几个材质,按材质名一抓一大片,不用逐个列 mesh 名。
// 材质名子串命中就跳过整个 mesh(一次抓掉所有用了发光/facing 材质的辅助体)
for (const FStaticMaterial& StaticMat : Mesh->GetStaticMaterials())
{
const UMaterialInterface* Mat = StaticMat.MaterialInterface;
if (!Mat) continue;
const FString MatName = Mat->GetName();
for (const FString& Banned : Config->MergeMaterialNameBlacklist)
{
if (!Banned.IsEmpty() && MatName.Contains(Banned))
{
return true; // blacklisted
}
}
}
3. 可见性过滤:只信 bHiddenInGame,别信 GetVisibleFlag()
游戏里隐藏的静物不能折进可见 HISM,否则会错误地开始渲染。所以要跳过隐藏组件——但判据只能用 bHiddenInGame。
我一开始用了 GetVisibleFlag(),结果候选清单从 2 万骤降到 44。原因是在 World Partition 编辑器世界里,bVisible / GetVisibleFlag() 会被流式和 HLOD 状态影响,给一堆在游戏里其实正常渲染的 actor 也标成不可见。bHiddenInGame 才是稳定的游戏可见性判据。
// 只用 bHiddenInGame。GetVisibleFlag()/bVisible 在 WP 编辑器世界里受流式/HLOD 状态干扰,
// 会把几乎所有街道静物错误滤掉。
if (Comp->bHiddenInGame)
{
continue;
}
4. 渲染标志继承:别给出一个裸 HISM
新建的 HISM 如果什么都不设,会丢掉源组件的阴影投射和 per-material 覆盖,合并后一眼能看出不一样。CommitGroup 从代表源组件 copy CastShadow 和 OverrideMaterials 到 HISM。组内所有组件共享同一个 mesh、又都过了同一套可见性过滤,假定它们的渲染设置也一致,取一个代表即可。
原理细节
HISM 而不是 ISM
用 UHierarchicalInstancedStaticMeshComponent 而不是普通 UInstancedStaticMeshComponent,是为了保留 per-instance 的层级剔除——和这个关卡里已有的那批 ISM 的写法一致。合批省了 draw-call,但如果丢掉逐实例剔除,近场那么多实例反而会拖累。
世界变换加实例,配合销毁源 actor
CommitGroup 里对每个源组件取世界变换、以 bWorldSpace=true 加进 HISM;然后销毁源组件,如果源 actor 是个只剩这一个 mesh 的 AStaticMeshActor 占位,把 actor 也销毁:
HISM->AddInstance(Comp->GetComponentTransform(), /*bWorldSpace=*/true);
AActor* SourceActor = Comp->GetOwner();
Comp->DestroyComponent();
if (SourceActor && SourceActor != MergeActor)
{
TArray<UStaticMeshComponent*> Remaining;
SourceActor->GetComponents<UStaticMeshComponent>(Remaining);
if (Remaining.Num() == 0 && SourceActor->IsA<AStaticMeshActor>())
{
World->DestroyActor(SourceActor);
}
}
这里有个我特意先单组验证的点:合并 actor spawn 出来后我没显式设它的变换,直接以世界变换加实例。如果 HISM 组件的本地原点非零,理论上可能有偏移。所以第一次一定先 Commit 单组、目视确认位置零偏移,再铺开。实测没问题,但这种"理论上可能出错"的地方,单组验证的成本远低于全量返工。
只搬变换,不保留 per-instance 覆盖
工具只搬 GetComponentTransform() 和代表组件的渲染标志。源 SMC 上如果有逐件的材质覆盖或自定义 bCastShadow,这些会丢。候选绝大多数是纯装饰静物,风险低;但含逐件材质覆盖的组要人工从报告里排除。
合并后美术怎么再调位置
这是合并最大的固有代价,得跟美术讲清楚:合并后原来的独立 StaticMeshActor 没了,那些静物变成了 ISM_Merged_* actor 里的 HISM 实例。美术原来"outliner 选中→gizmo 拖"的独立 actor 手感不再适用。按调整量分三档:
- 少量微调:用 UE5 自带的 ISM Editor(建模模式
Shift+5→ XForm → ISM Editor),能像独立 actor 一样在视口里点选、拖动、删除单个实例。零代码,首选。 - 要精确数值:用脚本改 HISM 的
PerInstanceSMData。难点是合并后实例只有索引没名字,美术得能说清是第几个/在什么位置。 - 某组要反复大改:用
UnmergeSMCFromISM_Commit把那个ISM_Mergedactor 拆回独立StaticMeshActor,调完再单独 re-merge 这组。比全量拆合并轻。
两个必须提醒美术的隐患:
- 重跑合并会覆盖手动调整。之后若再 Commit(比如新增静物要合),工具从源 actor 重新生成 HISM——但手动调过的实例的源 actor 已不存在,手动改动会全丢。
- 上百个实例手点很痛苦。
所以推荐把合并当作"内容锁定后的最后一道工序":还在活跃调位置的区块先别合、留独立 actor,美术定稿再合;已合区块要大改就走拆回,别硬编辑实例。
工具开发本身的两个坑
这两个和渲染无关,是改这类编辑器工具时反复咬我的:
- Live Coding 对新增 UPROPERTY / file-static 数据不可靠。我遇到过 dry-run 干净(跑的是新代码)但 commit 脏(跑的是旧代码)的分裂,根因是 Live Coding 没接住黑名单那次改动。改工具后必须走完整 UBT 构建(关编辑器重新 Build),确保 dry-run 和 commit 用的是同一份二进制。
- P4 全通配 revert 会误伤同事。清理关卡改动时,
revert //depot/...这种全通配会把别人正在 checkout 的文件一起 revert 掉。必须用-x <精确文件列表>只圈目标关卡的 external actor 文件。带 bug 的 commit 还会在关卡里叠加ISM_Merged残留(我一度累积到上千个),重跑前得先 revert__ExternalActors__回干净未合并态、确认残留为 0 再合。
什么时候这条路到头了
老实说一下这个工具的收益边界。全图第一遍合完之后,我又跑了一次只读候选审计想看还能不能续合,结论是基本到头了:
- 剩余约 1.6 万个独立 SMC 里,可合并池散在 2000 多个组、平均每组只有 6 个——高度碎片化,大户第一遍就吃完了。
count≥10的候选只剩十几组,而且几乎全在黑名单里(greybox、灯箱、程序生成的墙、带逻辑的物件)。去掉黑名单后真正能合的不到 300 个,还都带发光材质疑点。
所以续合的真实可回收量约等于零,不值得再冒 WP external actor 落盘 + 重烘 HLOD 的风险。draw-call 剩下的大头(MeshDrawCommandPassSetupTask 仍是全 trace 自时间最大项)已经不是"再合并"能解的了——它来自那 50 多万个 ISM/HISM 实例的 per-instance 剔除,以及道路铺装那种 LandscapeSpline 生成、天然无法实例化的 SplineMeshComponent。那属于更深的渲染管线优化,不在"静物合并"范围内。
一点通用体会:批量改场景的编辑器工具,真正的难度全在边界条件——哪些不能碰、World Partition 的分 region 语义、OFPA 的落盘、以及"改工具后二进制到底新不新"。合并逻辑本身十几行就写完了,但那四层防护和落地节奏,是崩了几次、脏了几次关卡才长出来的。dry-run 默认 + 单组先验 + 可回退(反向拆分),这三条是我现在写任何破坏性批处理工具都会先搭的脚手架。