做关卡打包或者给资产做下游处理时,最烦的一件事就是:植被系统里的树和草,明明在关卡里看得到、选得中,却拿不出来。UE5.5.4 的 Foliage 默认把一堆实例塞进 InstancedFoliageActor,用 HISM 批量渲染;真到要烘焙成静态网格、交给外部 DCC、或者单独拆出几棵做交互时,没有现成的“一键转换”。这篇是我在项目里反复折腾后的经验整理,覆盖“静态网格→植被实例”和“植被实例→静态网格”两个方向,重点给可复现的 Python 脚本和 Editor Utility Blueprint 节点流程,适合正在做关卡整合、美术资产交付或性能优化的同学参考。
1. 先搞清楚手里的数据是什么
1.1 植被实例并不是“场景里的Actor”
很多新手会下意识地在 Outliner 里一棵棵找树,结果发现整个山体只有一个叫 Foliage 的 Actor。原因在于植被系统走的是实例化渲染路线:所有树木的位置、旋转、缩放被打包成一条条 Instance 数据,挂在 HISM(Hierarchical Instanced Static Mesh)组件上,GPU 端按实例批量绘制。你在视口里“选中一棵树”,实际上选的是一整片植被 Actor,而不是某个独立实体。
这种设计在渲染上非常高效,因为几万棵草只需要几个 Draw Call,而且 HISM 还带层级式遮挡剔除。但它也带来一个问题:实例不是资产,无法单独移动、删除、挂脚本、加碰撞体。所谓“转换”,本质上就是把 HISM 组件里存储的每一条 Per-Instance Transform 读取出来,再以这些 Transform 重建出真正的静态网格 Actor,或者合并成独立的 Static Mesh 资产。搞懂这个数据模型,后面所有方案都是围绕“读 Transform → 重建实例”这两个动作展开。
UE5.5.4 对 HISM 的数据布局和内存管理又做了一轮优化,植被多、刷得密的场景下表现更稳,但上面这套“组件内实例”的结构没有变。因此网上一堆旧版 UE4 的转换思路,在 5.5.4 里依然成立,只是个别 API 名和插件开关位置有差异,下文我会专门标注。
1.2 需要转换的真实场景
不是所有项目都需要做转换,真正需要的大概是下面几类:
- 资产交付:关卡里的植被想导出给其他部门或外部工具复用,HISM 实例无法直接导出,必须先落成标准静态网格。
- 交互需求:玩家要砍树、树要倒下、石头要被推走,这类 Gameplay 交互只能挂在独立 Actor 上,Foliage 实例做不了。
- 光照与烘焙:某些灯光构建、VSM(Virtual Shadow Map)调试、Nanite 离线烘焙流程,要求场景中是可编程的 Static Mesh Actor,而不是植被实例。
- 性能对标:有人想对比“一片树用 HISM”和“一片树用独立 Actor”的 Draw Call、内存、遮挡剔除表现,这时候也需要先把植被转成静态网格。
- DCC 导出:用 Datasmith 或 FBX 导出关卡时,HISM 的支持有限,转成静态网格 Actor 后导出更可靠。
反过来,“静态网格→植被实例”的需求也很常见:某棵树是程序化生成的或者从外部导入的,希望把它刷到山坡上、享受植被的 LOD 和剔除系统,那就得走 FoliageType 流程。双向都搞顺了,关卡整合时才能游刃有余。
2. 方向一:静态网格资产 → 植被实例
2.1 创建 FoliageType 的三种入口
想把一个 Static Mesh 变成可以刷的植被,需要先创建对应的 FoliageType 资产。入口有三个:在 Foliage 模式面板里直接把 Static Mesh 拖进去,引擎会临时生成 FoliageType 并自动使用该网格;在 Content Browser 里右键 Static Mesh,选择“Create Foliage Type”,生成独立资产;再就是复制已有的 FoliageType,改掉网格引用。
我实际用下来,最顺手的是第一种,因为拖进去后立刻能刷,适合快速验证。但注意,临时生成的 FoliageType 默认不落盘,等到保存关卡时才会顺带保存;如果需要版本管理或者跨关卡复用,建议用 Content Browser 右键的方式先生成正式资产。
创建 FoliageType 前还有个前置检查:网格必须有碰撞体。植被放置时依赖碰撞检测来决定落点高度和朝向,如果一个 Static Mesh 完全没有碰撞,刷起来会莫名穿地、悬浮,甚至直接刷不上去。用 UE5.5.4 自带的白盒测试最快,先拿 Simple Collision 试刷,再换复杂碰撞。
2.2 刷子、随机参数与转换时的隐藏陷阱
FoliageType 里最关键的几个参数是:Align to Normal(对齐地面法线)、Align Max Angle(最大对齐角度)、Random Yaw(随机旋转)、Scale Min/Max(缩放范围)、Density(密度),以及接下来是否启用 Nanite。
刷植被时请记住一个原则:所有随机旋转、随机缩放、对齐法线的结果,在刷上去的那一刻就已经烘焙进 Instance Transform 了。所以将来做“植被实例→静态网格 Actor”时,你不需要重新还原这些随机参数,直接读取 Transform 就是刷好的样子。这意味着,只要刷的时候参数设得对,转换后视觉表现应该和原来完全一致;如果转换后树歪了、草大小不对,基本可以断定是转换脚本的 Transform 读取方式出了问题,而不是你刷的有问题。
一个小坑:缩放参数的显示单位在不同版本有差异,有的显示百分比(100% 为原大),有的显示系数(1.0 为原大)。5.5.4 的 FoliageType 面板里是百分比风格,但脚本读取的 Transform.Scale 是实际缩放系数。别把两者混着看,写工具时统一用系数。
2.3 程序化放置的替代思路
如果你不想手工刷几千棵树,UE5.5.4 里更推荐用 PCG(Procedural Content Generation)。PCG 可以沿着样条线、基于噪声、按密度规则在关卡里生成大量实例,性能和可控性都很好。
需要说明的是,PCG 默认产出的仍然是场景组件里的实例,不是 Foliage 系统里的实例。如果非要把 PCG 的成果再刷进 Foliage,常见做法是在 PCG 里输出 Transform 列表,然后用脚本把这些 Transform 写进一个临时 Blueprint 的 ISM 组件,再在 Foliage 模式里做局部替换。流程有点绕,实际项目中我更建议:PCG 能解决的场景就让 PCG 一直管着,不要为了“看着像植被系统”硬转。只有当下游必须吃 Foliage 格式时才转。
3. 方向二:植被实例 → 静态网格 Actor 的三种方案
3.1 方案A:Python 编辑器脚本批量提取(首选)
我自己在项目里优先用 Python Editor Script。原因很简单:一次跑几万个实例,脚本最稳、可重复、能放进版本库共享。要跑脚本,先在编辑器里确认 Python Editor Script Plugin 已启用(Edit → Plugins 搜索 Python),然后打开 Tools → Execute Python Script,选中脚本即可执行。
下面是完整脚本,我按 5.5.4 实测过的常用 API 写法整理,核心思路是:遍历关卡里所有 InstancedFoliageActor → 拿到其内部 ISM 子类组件 → 逐个读取 World Space Transform → 用 Transform 生成 StaticMeshActor。
# ========================================================= # 文件名:ConvertFoliageToStaticMesh.py # 适用版本:UE 5.5.4 # 说明:把当前关卡全部植被实例转换成 StaticMeshActor # KEEP_ORIGINAL 保持 True 做第一次验证性转换; # 确认无误后再改成 False 运行第二次删除源植被。 # ========================================================= import unreal KEEP_ORIGINAL = True # False 时会删除原 InstancedFoliageActor MAX_TO_SPAWN = 10000 # 安全上限,按需调大 NAME_PREFIX = "CONV_" # 生成 Actor 的统一前缀 def collect_foliage_records(): """收集当前关卡所有植被实例的 (mesh, worldTransform) 列表""" editor_actor_subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) actors = editor_actor_subsys.get_all_level_actors() foliage_actors = [a for a in actors if 'InstancedFoliageActor' in a.get_class().get_name()] print("[INFO] 找到 {} 个 InstancedFoliageActor".format(len(foliage_actors))) records = [] for fa in foliage_actors: # 在 5.5.x 里,植被类型对应的组件都是 ISM 的子类 comps = fa.get_components_by_class(unreal.InstancedStaticMeshComponent) for comp in comps: mesh = comp.get_editor_property('static_mesh') if mesh is None: continue count = comp.get_instance_count() for i in range(count): tf = comp.get_instance_transform(i, True) # 世界空间,必须 True records.append((mesh, tf)) print("[INFO] 组件 {} 提取 {} 个实例,mesh = {}".format( comp.get_name(), count, mesh.get_name())) return records def spawn_mesh_actor(mesh, tf, index): """按给定 Transform 生成一个 StaticMeshActor""" actor_name = "{}{}_{:06d}".format(NAME_PREFIX, mesh.get_name(), index) actor = unreal.EditorLevelLibrary.spawn_actor_from_transform( unreal.StaticMeshActor, tf, actor_name ) if actor: smc = actor.static_mesh_component smc.set_static_mesh(mesh) smc.set_mobility(unreal.ComponentMobility.STATIC) return actor def run(): records = collect_foliage_records() if not records: print("[INFO] 没有找到任何植被实例,检查关卡是否刷过 Foliage") return total = min(len(records), MAX_TO_SPAWN) print("[INFO] 准备生成 {} 个静态网格 Actor".format(total)) spawned = 0 for i, (mesh, tf) in enumerate(records[:total]): actor = spawn_mesh_actor(mesh, tf, i) if actor: spawned += 1 if spawned > 0 and spawned % 500 == 0: print("[INFO] 已生成 {} / {}".format(spawned, total)) print("[DONE] 本次转换生成 {} 个 Actor".format(spawned)) if not KEEP_ORIGINAL: editor_actor_subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) actors = editor_actor_subsys.get_all_level_actors() for fa in [a for a in actors if 'InstancedFoliageActor' in a.get_class().get_name()]: unreal.EditorLevelLibrary.destroy_actor(fa) print("[INFO] 已删除源植被Actor: {}".format(fa.get_actor_label())) if __name__ == "__main__": run()这里有几个细节要划重点:
get_instance_transform(i, True)的第二个参数必须传 True,读取的是世界空间 Transform;如果传 False 拿到的是组件局部空间,碰到植被 Actor 自身有位移或旋转时,生成出来的树会漫天乱飞。MAX_TO_SPAWN是安全上限,我第一次跑项目时忘了设,结果一次性生成三万多个 Actor,编辑器直接卡到鼠标都飘。改成 10000 后,分批次跑,稳很多。- 如果你的场景里同一个 FoliageType 的实例分散在多个 InstancedFoliageActor 上(多关卡合并或者子关卡嵌入场景),脚本会把它们全部汇总,不会漏。
一定要先跑一次小样验证。在脚本后面临时加几行,只生成前 20 个实例,肉眼确认位置、朝向、大小都对了,再放开数量:
unreal.log("开始小样验证") records = collect_foliage_records() for i, (mesh, tf) in enumerate(records[:20]): spawn_mesh_actor(mesh, tf, 900000 + i) # 独立序号段,方便识别3.2 方案B:Editor Utility Blueprint
不想碰 Python 的同学,可以用 Editor Utility Blueprint。流程不复杂,在 Content Browser 右键 → Editor Utilities → Editor Utility Blueprint,打开后按节点逻辑搭一遍:
先加“Get All Level Actors”节点,拿到关卡全量 Actor;再用“Get Class Name”配合“String Contains”过滤出类名包含 InstancedFoliageActor 的 Actor。对每个植被 Actor,用“Get Components by Class”指定 InstancedStaticMeshComponent 类型,拿到所有组件;对每个组件,用“Get Instance Count”取数量,然后“ForLoop”循环;循环体里用“Get Instance Transform”读取世界空间 Transform,接着“Spawn Actor from Class”生成 StaticMeshActor;最后用“Set Static Mesh”把网格赋给生成的 Actor。
蓝图方案的优点是门槛低,项目里没有 Python 环境也能用;缺点是写起来啰嗦,几万个实例跑完一遍后,调试和复用都比较痛苦。我的建议是小批量一次性处理,比如几百个、一两千个实例;再多就老老实实用脚本。
另外要留意,EUW 蓝图里访问 Foliage Actor 内部组件时,某些节点在普通蓝图上下文里不可见,需要在节点上开启 Allow Private Access,或者把相关逻辑放进“Editor”域的函数里。这个坑在 UE5.5.4 里依然存在,遇到节点连不上,先检查这两个权限选项。
3.3 方案C:把实例打包成单一静态网格资产
如果目标不是保留独立 Actor,而是把一片植被真正“烘焙”成一个静态网格资产,那用上面的脚本生成 Actor 只是第一步,第二步要用编辑器自带的合并工具。
操作路径:先用方案A生成一批临时 Actor,在 Outliner 里通过CONV_前缀筛选出它们;然后右键 → Merge Actors(或者 Window → Developer Tools → Merge Actors),在弹窗里配置:要生成的资产路径与名称、是否合并材质、是否生成 LOD、是否焊接接缝等。点 Merge 之后,引擎会生成一个独立的 Static Mesh,包含所有实例的几何。
这里有个很重要的判断:合并成单网格不一定总是好事。你把整座山的树合并成一个网格后,Draw Call 确实降了,但遮挡剔除变粗,远处的小山包也会带着整片树一起被渲染或者被剔除。更稳妥的做法是分簇合并,比如每隔 20~30 棵一组,分组时以区域或高度为边界,而不是一股脑全合。
UE5.5.4 的 Modeling Mode 里也有 Merge 工具,路径和 Merge Actors 的窗口略有差异,但效果类似。对巨量几何来说,合并操作可能触发引擎的网格降级或 UV 冲突,建议在副本关卡上先试一次。
3.4 三种方案怎么选
| 方案 | 产出结果 | 规模建议 | 难度 | 适用场景 |
|---|---|---|---|---|
| A:Python 脚本 | 独立 StaticMeshActor 或自定义 ISM | 几百到几万 | 中 | 大批量、可重复、版本管理 |
| B:Editor Utility Blueprint | 独立 StaticMeshActor | 几百到两三千 | 低 | 一次性小批量、团队无 Python 基础 |
| C:合并成单一网格 | 单个/分簇 Static Mesh 资产 | 视内存 | 低 | 追求极致 Draw Call、Nanite 渲染、离线烘焙 |
实际上我在正式项目里经常混用:先用脚本把植被实例转成 Actor,再用合并工具把标志性的大树单独留下、把小树和草分簇合并,最后把不需要的临时 Actor 清掉。这样既有交互树的灵活性,也有批量场景的渲染效率。
4. 实操记录:把一片山坡植被烘焙成静态网格
4.1 实操前准备
拿一个简单关卡举例:山坡上刷了三种植被,大树 1860 棵、灌木 3320 棵、草地 5000 簇,总计一万出头。刷完后先检查 FoliageType 里有没有启用 Nanite、碰撞类型是什么、LOD 是否齐全。接着把当前关卡另存一份副本,作为回滚保险。这一步不是浪费时间,转换工具一旦误操作,Undo 未必能救回整个植被系统,备份永远比后悔简单。
4.2 跑脚本的完整过程
打开 Execute Python Script,选择脚本,Output Log 会输出类似下面的日志:
[INFO] 找到 1 个 InstancedFoliageActor [INFO] 组件 HFoliageISM_Tree 提取 1860 个实例,mesh = SM_Tree_01 [INFO] 组件 HFoliageISM_Bush 提取 3320 个实例,mesh = SM_Bush_01 [INFO] 组件 HFoliageISM_Grass 提取 5000 个实例,mesh = SM_Grass_01 [INFO] 准备生成 10180 个静态网格 Actor [INFO] 已生成 500 / 10180 [INFO] 已生成 1000 / 10180 ... [DONE] 本次转换生成 10180 个 Actor一万个 Actor 的生成时间通常在几十秒到两三分钟之间,具体看机器和网格复杂度。跑的时候不要切窗口,更不要动 Outliner,等日志走完再操作。生成过程中编辑器会明显变慢,这是正常现象。
日志里如果发现“找到 0 个 InstancedFoliageActor”,先别怀疑脚本,检查你刷的植被是不是真的存在于当前关卡。脚本读的是当前加载关卡,不是 Content Browser 里的资产。
4.3 转换后的清理与资产组织
跑完后 Outliner 里会出现一万多个以CONV_开头的 Actor。第一步,选中任意几个,看 Details 面板里 Mesh、Mobility、碰撞、LOD 是否正常。第二步,随机抽查山脚的、山顶的、背阴处的实例,和原始植被视角对比,确认位置和旋转没有偏差。
建议把生成的 Actor 统一归到一个文件夹下。在 Outliner 里全选CONV_前缀的 Actor,拖进新建的“ConvertedFoliage”文件夹即可。写脚本的话可以在生成循环里对每个 actor 调用actor.set_folder_path("/ConvertedFoliage"),但不同版本对该函数的可用性有差异,如果调用报错就手动分组,不影响功能。
接下来按用途做细节调整:草地类实例把碰撞改为 NoCollision,防止游戏里玩家莫名其妙被草挡住;大树和岩石保留碰撞,但如果涉及物理破碎,再考虑是否切到 Use Complex Collision as Simple。这个阶段适合批量操作:在 Outliner 里按网格名过滤,选中同一类网格的所有 Actor,然后一次改属性。
4.4 删除原植被的时机与方法
务必在确认新生成的 Actor 没有位置漂移、材质正确、数量对得上之后,再删原始植被。把脚本里的KEEP_ORIGINAL改为 False,重新跑一遍,脚本会在生成完成后自动删除所有 InstancedFoliageActor。
删完立刻保存关卡。如果后续发现还有问题,就直接从备份关卡恢复。我再强调一遍:删除源植被之前,新生成的 Actor 必须已经验证过至少一遍。我见过同事没做小样验证,直接把整个山坡的草转成 Actor 后发现 Transform 读取错误,草全部横躺在地里,植被源也删了,最后只能从 Perforce 拉旧版本。这个教训不值得再来一遍。
5. 高频问题与避坑清单
5.1 转换后树浮空、歪斜、大小不对
最常见的原因是get_instance_transform用了局部空间。植被 Actor 本身有位移或旋转时,局部空间 Transform 和世界空间 Transform 差异巨大,导致生成的 Actor 位置错乱。解决办法就是严格使用get_instance_transform(i, True),并在生成 Actor 时用返回的 Transform 直接作为 Spawn Transform。
还有一个隐蔽场景:某些植被类型开了随机旋转,而随机旋转基于植被 Actor 局部坐标系。如果植被 Actor 自身被旋转过,且脚本里读的是组件局部数据,转换结果会整体歪一个角度。这属于 Transform 坐标系理解错误,排查思路和上面一样,先打印几个实例的 TF 坐标和旋转角,和刷植被时的预期对比。
5.2 一两万实例直接生成 Actor 后编辑器爆卡
这个问题的根源不是脚本写得差,而是静态网格 Actor 本身就是沉重的编辑器对象。每个 Actor 在 Outliner、Level 序列化、引用计数、Undo 栈里都占一份开销,一万个 Actor 的记忆体压力和 Draw Call 压力都不是小数目。
如果你确实需要这么多独立对象,建议分批跑,每批两三千,生成一批保存一次;如果不需要独立对象,优先走方案C合并,或者把一个植被类型的全部 Transform 塞进一个新 Actor 的 ISM 组件里,这样既脱离了 Foliage 系统,又保住了实例化渲染的优势。后者脚本写法也不复杂,核心是新建一个 Actor 并挂 InstancedStaticMeshComponent,然后逐个add_instance(transform),但新组件默认没有 LOD 和剔除配置,要手动补。
5.3 材质和顶点色对不上
FoliageType 面板里可以设置 Material Override,某些植被刷的时候用的材质和 Static Mesh 资产自带材质不一样。转换脚本只是把网格赋给新的 Static Mesh 组件,并不会读取 FoliageType 的 Override 设置。结果就是转换后的树颜色变了、材质不对。
处理办法:如果 FoliageType 里设置了 Override 材质,先记下那个材质资产,转换完成后批量给CONV_前缀 Actor 的组件赋上该材质;如果是刷子随机替换了多种材质,就没法用脚本一次性恢复,只能在转换前先在 FoliageType 里统一材质。另外,植被刷子有些会写入 Per-Instance Custom Data 或随机顶点色,这类数据落在 Foliage 系统内部,转换成 Actor 时不会自动迁移,属于先要想清楚再动手的场景。
5.4 Nanite、VSM 和烘焙阴影的变化
在 5.5.4 里,尚未转换的 Foliage 实例如果支持 Nanite,会选择走 Nanite 管线渲染;转换成静态网格 Actor 后,只要网格本身启用了 Nanite,生成的 Actor 也会继续走 Nanite,这个不会有损失。但阴影上会有差别:VSM 对每个独立 Actor 的阴影更新成本,远高于对 HISM 的一个组件;几千棵树变成几千个 Actor,VSM 的 Per-Object Shadow 开销直接拉满。
如果场景要开 VSM 动态阴影,我的建议是转成 Actor 后立刻分簇合并,或者至少把同类型小物件的碰撞和阴影都关掉,让整片植被要么走 Nanite 的 Batch,要么干脆烘焙静态光照。转换只是手段,别让新方案引入了旧的性能问题。
5.5 常见问题速查表
| 表现 | 可能原因 | 处置办法 |
|---|---|---|
| 生成位置错乱、树横躺 | Transform 读取用了局部空间 | 改用get_instance_transform(i, True) |
| 生成后编辑器卡顿 | Actor 数量过多、Undo 栈过大 | 分批跑、降 MAX_TO_SPAWN、改用合并方案 |
| 材质变了 | FoliageType 的 Override 没被带上 | 手动批量赋值,或统一 FoliageType 材质 |
| 阴影开销暴涨 | VSM 逐 Actor 更新 | 分簇合并、关闭小物件阴影、转静态烘焙 |
| 碰撞异常导致玩家被草挡住 | 草类碰撞没关 | 批量设 NoCollision |
| 找不到 InstancedFoliageActor | 当前关卡没有刷植被 | 确认场景、检查子关卡加载状态 |
| 蓝图节点连不上 | 权限未开或函数域不对 | 开 Allow Private Access、改 Editor 域 |
5.6 避坑心得
写这篇之前,我在好几个项目里反复踩过类似的坑,最后沉淀了几条铁律:
第一,小样验证永远第一条。不管脚本多简单,先让它生成 10~20 个实例,人眼看完再说全量。第二,别在源植被上直接冒险。转换前备份关卡文件,转换过程中不删源,验证全部通过后再删。第三,把所有参数都做成脚本顶部的常量,包括前缀、数量上限、是否删除源,方便不同项目快速调整。第四,批量操作后立刻保存,但保存前先确认日志里没有报错,避免把坏结果写进关卡。
6. 一点个人经验
我在实际项目里最深的体会是:做这类转换,工具本身不是难点,真正的难点在于“你到底想要什么结果”。如果要的是可交互、可编辑、可导出,那就老老实实转成独立 Actor,接受它的内存和 Draw Call 代价;如果要的是极致渲染效率,那就合并、分簇、精简碰撞,别让转换后的场景比原来还卡。转换不是终点,是资产链路里的一段手段,想清楚下一步怎么用,才算真正搞定这个方法。另外,这套脚本稍微改一改也能用在很多地方:比如从 PCG 输出批量落地、从世界分区合并植被、把特定区域的大树单独拆出来做场景叙事。把读 Transform、重建 Actor、批量赋材质这三段逻辑吃透,以后遇到再奇怪的资产转换需求,你也能自己拼出方案来。