网格合批:减少 Draw Call 之前,先定义谁失去 Actor
自动关联目录:网格合批:减少 Draw Call 之前,先定义谁失去 Actor
核心判断:在 UE 中,合批不是把一组 Actor 换成一个 ISM,而是把可编辑对象编译成另一种运行时表示。编译前必须回答四个问题:谁保留逻辑身份,谁拥有 Transform,谁负责碰撞,发生编辑或玩法变化时怎样退回。否则 Actor 数下降了,Draw Call 未必下降,剔除、物理和交互语义却可能已经错了。
一、先把三个成本分开:Actor 少、Component 少、Draw Call 少不是一回事
实际业务矛盾很直接:创作者会用大量基础形状和重复网格搭场景,按“一个内容对象一个 Actor”物化,游戏线程要承担 Actor 生命周期、Component 注册、Transform 传播、GC、网络与脚本绑定;全部保留为独立 UStaticMeshComponent,渲染线程又要承担 Primitive、SceneProxy、可见性和提交成本。
但“合并”至少有三种不同收益:
| 操作 | Actor 数 | MeshComponent 数 | Draw Call | 仍保留的语义 |
|---|---|---|---|---|
| 多对象挂到一个容器 Actor | 降低 | 基本不变 | 基本不变 | 每个子网格仍可独立显示和换材质 |
| 同 Mesh、同渲染状态转 ISM/HISM | 降低 | 降低 | 可显著降低 | 实例 Transform 与少量 Per-Instance Data |
| 生成新的代理网格 | 降低 | 降低 | 可降低 | 只保留代理允许的远景外观 |
工程里这三条路径都真实存在,而且边界并不相同。早期静态批次按网格、材质和部分渲染/碰撞状态分桶,满足条件才生成 ISM,不满足时仍在一个 Actor 下创建多个 SMC。后续的组合对象把多个可编辑对象冻结成一个逻辑对象,但内部仍是一组 MeshComponent。远景 LOD 则使用独立代理网格,在代理就绪后才隐藏源对象。
因此,评审“合批是否成功”不能只看 World Outliner 或 Actor 数。至少要同时看 ActorCount、PrimitiveComponentCount、DrawPrimitiveCalls、可见实例数、碰撞 Shape 数和表示切换成本。
二、实际方案为何有效:先判定语义资格,再选择表示
静态批次没有把所有 Static Mesh 无条件塞进 ISM。实际资格判断会排除需要复制、事件、物理、交互、脚本引用、移动或改色能力的对象,也会排除粒子、骨骼网格和复杂自定义组件;只有被判定为静态、可失去独立 Actor 身份的对象,才在加载阶段直接生成批次表示。批次数据保存网格、材质、Transform、碰撞/阴影状态和自定义数据;资产或批次数据无效时回退到原对象路径,而不是生成一个半有效批次。
这条思路是对的:是否可批处理是业务语义判断,不是 Mobility == Static 的渲染判断。 Mobility 只说明 UE 如何处理变换和渲染状态,不能证明对象没有脚本寻址、交互事件、独立网络身份或运行时编辑需求。
批次内部也处理了几个 UE 特有约束:
- 只有单材质且存在兼容材质映射的重复网格才进入 ISM;其余退回 SMC,避免为了批次破坏多材质外观。
- 负尺度会改变三角形绕序,因此镜像实例必须与普通实例分开,并对镜像批次开启反向剔除。
- 单个实例组件不会无限增大,而是按有界块拆分,控制 Bounds、上传和重建的影响范围。
- 每个实例保留 Transform 与自定义浮点数据,使颜色等有限差异不必拆回独立组件。
- DS 不生成视觉组件,只消费碰撞表示;渲染批次和权威碰撞从一开始就不是同一个对象。
这比“遍历 Actor,把相同 Mesh 放进 ISM”多做了一层表示编译:输入是逻辑对象快照,输出可能是 ISM 批次、SMC 容器、碰撞代理或原对象。真正有价值的不是某个组件类型,而是这层资格判定和可回退选择。
flowchart LR
A[可编辑对象快照] --> B{语义资格}
B -->|可独立变化 / 可交互| C[独立 Actor]
B -->|冻结组合但外观不同| D[一个 Actor + 多个视觉组件]
B -->|同网格同渲染状态| E[空间分块的实例批次]
B -->|远景可降级| F[代理网格层级]
A --> G[碰撞制品]
G --> H[客户端 / DS 碰撞代理]
D -.视觉组件 NoCollision.-> H
E -.按玩法契约选择.-> H
F -.纯视觉 NoCollision.-> H三、组合对象的关键价值不是 Draw Call,而是冻结编辑边界
工程中的组合能力把若干子对象序列化为不可变快照,记录每个子对象的本地 Transform、外观和碰撞描述,然后销毁原对象,生成一个新的逻辑对象。反向拆分时,再从快照重建原对象。
它带来的确定收益是:多个独立 Actor 变为一个 Actor;脚本、存档和选择都只面向组合后的逻辑身份;子对象不再参与独立 Tick、网络和沙箱寻址。它没有天然减少到一次绘制:普通形状、静态网格和骨骼网格仍可能各自对应一个 MeshComponent,材质槽也继续存在。把这种能力命名为“合并”容易让性能预期失真,更准确的概念是“冻结组合”。
这条实现里最值得保留的是视觉与碰撞彻底分离:
- 子 MeshComponent 全部
NoCollision,只负责显示。 - 根组件持有唯一碰撞代理,把每个子体的
FKAggregateGeom按本地 Transform 变换到组合对象空间。 - 碰撞可以选择合并全部可碰撞子体,或只保留一个主子体;无效索引有确定性回退。
- 编辑端可从已加载网格提取简单碰撞并写回快照;DS 不加载网格,直接消费烘焙碰撞,缺失时使用确定性简化形状。
- 异步网格回调同时校验对象弱引用、快照 Generation、槽位和资产身份,旧快照的迟到结果不能覆盖新组合。
这解决了一个真实的 headless 问题:如果碰撞只能从 UStaticMesh::BodySetup 临时提取,DS 就被迫加载本不需要的视觉资产。把 AggGeom 作为发布制品的一部分后,客户端视觉缺失也不妨碍 DS 建立权威碰撞,测试甚至覆盖了“视觉网格不存在但碰撞快照仍有效”的情况。
同时,这里也暴露过一个典型 UE 生命周期故障:异步碰撞几何可能在编辑态先到达并完成 BodySetup 提交,随后切到运行态时却没有重新激活 Physics State,结果对象保持 kinematic。修复不能放进通用的可见性重算,因为运行中每次修改 Visible 都重建刚体会丢速度并打断物理所有权;最终只能把“纯可见性刷新”和“Edit -> Play 的物理提交”拆成两个入口。
这个故障说明:几何已经存在、BodySetup 已经提交、物理正在模拟,是三个不同状态。 Transform、Visibility 和 Scene Mode 也不是可以相互代替的脏标记。
四、动态实例更新的硬边界:不重建 SceneProxy,就必须自己维护 Bounds
工程里还存在可动态更新实例 Transform 的自定义实例组件。它直接修改 PerInstanceSMData,需要物理时同步更新 Instance Body,再用 Render Command 把新 Transform 送到渲染线程。这样可以避开每次 MarkRenderStateDirty() 导致的 SceneProxy 重建。
代价也写在实现里:不重建渲染状态,SceneProxy 的 Bounds 不会随实例移动而更新,实例可能被错误剔除。可选项只有三个:
- 更新 Bounds 并重建渲染状态,正确但主线程和渲染线程成本较高;
- 为批次预留覆盖运动范围的保守 Bounds,减少重建但降低粗粒度剔除效率;
- 把组件设为近似无界,最省维护却基本放弃组件级剔除。
这意味着“动态合批”不是静态 ISM 加一个 UpdateInstanceTransform。动态资格还要证明实例的运动包络可预测、更新频率可预算、碰撞和导航是否需要同步,以及批次 Bounds 不会扩张到让整组实例常驻可见。若对象可以跨空间单元长距离移动,更合理的操作是从旧 Cell 退出、进入新 Cell,而不是把全世界放进一个无界实例组件。
另一个隐藏契约是实例自定义数据宽度。现有分桶以第一个实例的数据长度作为整批布局,后续实例只截断或补零。它能工作,但 Schema 是隐式的:对象顺序变化就可能改变批次解释。正确做法是由材质/表示类型声明固定 InstanceDataLayoutId,布局不一致时拒绝入批,而不是让第一个实例定义 ABI。
五、代理 LOD 是表示替换,不是源对象的附属开关
远景代理走了独立链路:描述文件绑定内容修订与多个代理层级;运行时按距离和迟滞选择目标层级,并限制每帧代理请求数。代理 Actor 是临时对象,关闭 Tick、碰撞、Overlap、导航和阴影,只承担远景视觉。异步完成同时校验场景 Generation、组身份、请求序号、目标层级和资产身份;加载失败时保留当前可用表示。
最重要的不是距离公式,而是提交顺序:
- 目标代理未驻留时,继续保留源对象;
- 代理可用后,才显示代理并隐藏源对象;
- 切回源对象时,要等源视觉组件注册且网格已绑定,再恢复源对象;
- 恢复时使用源 Actor 原本的 Hidden 状态,不能无条件显示;
- 边界使用迟滞,避免相机往返时反复创建、销毁和请求资产。
这和静态批次是不同问题。实例化优化“许多相同对象如何提交”,代理 LOD 优化“同一组内容在不同距离用哪种表示”。两者可以组合,但不能用一个 bBatched 布尔值表达。
六、哪里做得不够:表示规则散落,优化结果无法被证明
现有方案已经解决了大量实际问题,但边界散落在序列化、脚本资格判断、批次创建、碰撞组件和 LOD 管理器中,形成了四个结构性缺陷。
第一,批次键不是完整契约。 实际 Key 已包含网格、首材质、碰撞、遮挡和阴影,但渲染等价性还可能受材质槽布局、自定义数据 Schema、反向剔除、可见性策略和平台能力影响。漏一个维度就会把本不等价的对象放进同一 Primitive;加太多维度又会碎片化批次。Key 应是由表示编译器生成的版本化 Fingerprint,而不是多个路径各自拼字段。
第二,Transform 所有权没有统一表达。 有的路径保存 World Transform,有的以组合根为局部空间,有的运行时再 GetRelativeTransform;代理 Actor 又持有组根 Transform。只要父子关系、负尺度或异步替换介入,重复换算就容易产生漂移或错位。每种表示应明确唯一 Transform Owner,并在编译点完成一次坐标空间转换。
第三,运行时碰撞 Cook 过重且脆弱。 早期代码为了避免 UBodySetup 随机 GUID 造成非确定缓存失效,按组件稳定名称构造 GUID;又因为某条 Chaos 运行时路径无法稳定取得 Cook 数据,手动创建 Convex 数据。这是有效止血,但它证明“加载时从渲染资产重建碰撞”不应是常规路径。应在编辑/构建阶段生成带版本和哈希的 headless 碰撞制品,运行时只验证、反序列化和提交。
第四,性能指标没有按表示拆开。 SMC 容器、ISM、代理网格和自定义分层几何的 CPU/GPU 瓶颈完全不同。只统计“合批对象数”无法说明优化有效,还可能掩盖更大的 Bounds、更差的遮挡剔除、实例 Buffer 重写和 Collision Recreate 成本。
七、目标架构:把表示选择做成可验证的编译结果
目标不是建立一个更大的 BatchManager,而是增加一个窄边界的 RepresentationCompiler。它读取不可变逻辑快照和目标能力,输出明确的渲染表示、碰撞制品和回退原因;运行时只维护句柄与增量变化。
struct FRepresentationContract {
FLogicalObjectId LogicalId;
uint64 SourceRevision;
uint64 EligibilityFingerprint;
ERepresentation Kind; // Individual, ComponentGroup, InstanceCell, Proxy, CollisionOnly
ETransformOwner TransformOwner;
FInstanceDataLayoutId InstanceLayout;
FCollisionArtifactId Collision;
FSpatialCellId Cell;
uint64 Generation;
};
FCompileResult Compile(const FObjectSnapshot& Object, const FTargetCaps& Target) {
if (Target.IsDedicatedServer())
return CollisionOnly(RequireBakedCollision(Object));
if (Object.IsEditable() || Object.HasIndependentGameplayIdentity()
|| Object.HasDynamicBehavior())
return Individual(Object);
if (Object.IsFrozenComposition())
return ComponentGroup(Object, /*visualCollision=*/false);
if (CanShareMeshAndRenderState(Object)
&& HasDeclaredInstanceLayout(Object))
return InstanceCell(Object, ResolveSpatialCell(Object));
return Individual(Object).WithReason("semantic-or-render-state-mismatch");
}
void ApplyMutation(FLogicalObjectId Id, const FMutation& M) {
FRepresentationHandle H = Handles.FindChecked(Id); // 逻辑 ID 不等于 InstanceIndex
if (M.OnlyChangesTransform() && H.CanUpdateWithinBounds(M.Transform)) {
H.UpdateTransform(M.Transform);
return;
}
RecompileAffectedCell(H.Cell, H.Generation + 1); // 新表示 ready 后再交换
}这里必须保留稳定的 LogicalId -> RepresentationHandle 映射。ISM 的数组下标只是当前存储位置,删除、压缩或换批后都可能变化,不能成为玩法身份。句柄至少包含 Component、InstanceIndex、Cell 和 Generation;发生 swap-remove 时同时更新反向映射,旧 Generation 的异步结果直接丢弃。
表示变化也应按字段决定动作,而不是全部重建:
| 变化 | 首选动作 | 必须升级为重编译的条件 |
|---|---|---|
| 实例 Transform | 原位更新并维护 Bounds/物理 | 跨 Cell、运动超出包络、负尺度类别改变 |
| Per-Instance Data | 更新实例 Buffer | 布局 ID 或材质排列改变 |
| 材质参数 | 同布局内更新 | Shader/材质槽/混合模式改变 |
| 碰撞属性 | 更新独立碰撞代理 | 几何或碰撞模式改变 |
| 获得脚本、交互或网络身份 | 从批次拆出独立对象 | 总是 |
| 进入远景层级 | 异步准备代理并两阶段交换 | 代理失败或源对象尚未稳定 |
运行时几何也因此有明确位置:编辑中的 Dynamic Mesh、样条或布尔结果保留可编辑源表示;提交时生成可缓存的静态渲染制品、可选 LOD/代理和独立碰撞制品。只有冻结且满足资格的结果进入实例批次。不是所有几何都要提前转 Static Mesh,也不是所有 Dynamic Mesh 都应永久留在运行时。
八、验证:既测性能,也测语义不变量
现有自动化已经覆盖组合碰撞的全子体/主子体模式、越界回退、坏快照拒绝、确定性简化、视觉缺失时碰撞仍存在、空快照清理旧碰撞,以及 LOD 描述校验、版本匹配、层级选择迟滞、失败回退和性能统计。这些测试证明了数据与生命周期契约,而不仅是“能显示”。
还需要把表示编译本身变成可回归对象:
- 同一输入快照在同一目标能力下生成相同 Fingerprint、批次划分和碰撞哈希;
- 镜像、材质槽、自定义数据布局或碰撞模式不同的对象不得误入同批;
- 动态实例越过原 Bounds 后仍可见,跨 Cell 后旧句柄失效且玩法身份不变;
- 代理加载失败、迟到或场景切换时,不能同时隐藏源与代理;
- DS 在不加载 Mesh、Material、Texture 的条件下仍能建立同语义碰撞;
- 组合、拆分、再组合后,本地 Transform、子体顺序、可见性和碰撞保持一致。
性能指标也要按成本模型拆分:游戏线程记录 Actor/Component 创建和批次重编译时间;渲染线程记录 Primitive 数、Draw Call、可见实例、实例 Buffer 上传量和被大 Bounds 连带绘制的比例;物理记录 Body/Shape 数、Cook/提交时间;内存记录源几何、代理几何、实例数据和碰撞制品的独立占用。
我的反思是:早期“静态对象直接跳过 Spawn,改为批次数据”击中了海量 Actor 的主要矛盾,严格资格判断、镜像拆批、SMC 回退和 DS 去视觉都值得保留。后来组合对象和代理 LOD 又补上了编辑冻结、headless 碰撞和远景替换。但这些能力仍是多个局部系统,各自维护 Key、Transform 和回退逻辑。
下一阶段应把它们统一为一种思考方式:逻辑对象是事实,Actor、SMC、ISM、代理网格和碰撞体只是可替换表示;每次表示编译都必须显式声明失去的能力、保留的身份和回退路径。
前置的异步驻留与可见提交见:运行时驻留:Actor 已生成不等于画面已就绪。