UE5.8 Python 自动化与编辑器脚本

📑 目录

美术外包把两百张纹理一次性传回来时,小林盯着 Content Browser 叹了口气。每张图都要手动导入、改压缩格式、检查法线朝向,再按文件夹归类。按以往经验,这套流程至少吃掉半天。他打开 UE5.8 的 Output Log,输入了一行命令:

import unreal
unreal.log("Hello, UE Python")

日志里跳出熟悉的字符串。小林知道,这个下午可以换一种过法。他把杯子里的咖啡喝完,打开 VS Code,决定先把这批纹理的命运交给脚本。

虚幻引擎从 4.x 开始把 Python 嵌入编辑器,到 UE5.8 已经是一套可用的脚本层。它适合处理批量、重复、规则明确的工作,编译周期为零,改完立刻能跑。虽然性能敏感或需要深入引擎内部的地方仍然要用 C++,但日常工具链里有一大半需求用 Python 就够了。

小林那两百张图里什么都有:反照率、法线、ORM、Alpha、环境光遮蔽。每张图的规则不一样,手动点一遍 Properties 面板会眼花。写成脚本后,规则变成几行映射表,跑完再抽样检查三五张就行。

一、把 Python 环境跑起来

UE5.8 的 Python 支持由 Python Editor Script Plugin 提供。新建或打开项目后,去 Edit > Plugins > Scripting 里勾选它,同时顺手启用 Editor Script Utilities。改完插件要重启编辑器。小林第一次启用时忘了重启,结果 Output Log 里一直提示 unreal 模块不存在,白白排查了十分钟。

真正写脚本前,建议再做三件事:

  1. Project Settings > Plugins > Python 里勾选 Developer Mode,这样控制台会给出更完整的报错。
  2. 把常用脚本目录加到 Additional Paths,比如 Content/Scripts,避免每次都要输完整路径。
  3. 在编辑器偏好设置里指定外部 IDE,双击 .py 文件时直接用 VS Code 打开。

下图是 UE5.8 Python 脚本在编辑器里的位置关系。

flowchart TD
    py_plugin --> asset_lib
    py_plugin --> slate
    py_plugin --> utility_bp
    py_plugin --> vscode
    py_plugin --> cli

控制台位于 Window > Developer Tools > Output Log。把输入模式从 CMD 切换到 Python,就能直接执行 Python 语句。更常见的方式是把脚本存成 .py 文件,再用 py <path> 调用。小林习惯在 Content/Scripts 下建一个 tools 文件夹,所有脚本按功能分类放好。

执行脚本至少有四种方式:控制台逐行、执行 .py 文件、Startup Scripts 随编辑器启动、命令行无头模式。日常调试用前两种,正式发布给团队用时用后两种。小林把需要高频使用的脚本挂到 Startup,自己测试时则在 Output Log 里直接 py

引擎自带的 Python 版本随安装包绑定,目前 UE5.8 携带的是 Python 3.11。别用系统里的包管理器往引擎目录里乱装第三方库,升级后会被覆盖。真有需求时,可以建一个独立虚拟环境,通过 subprocess 调用。小林曾经图省事在引擎 Python 里装了 pandas,结果一次小版本更新后全部丢失,从那以后他只把虚拟环境放在项目目录外。

第一次执行脚本时最常见的问题是路径写错。Windows 下反斜杠要写成原始字符串,或者在字符串前面加 r 前缀。如果在 Output Log 里看到 FileNotFoundError,多半是源文件路径或者目标 Content 路径拼错了。小林的习惯是先用 os.path.exists 判断一下,再在导入前打印确认。

二、unreal 模块基础

脚本里一切从 import unreal 开始。这个模块是引擎暴露给 Python 的反射层,大量 C++ 类都能直接访问。下面这几个类和函数使用频率最高:

  • unreal.EditorAssetLibrary:读改资产、路径操作。
  • unreal.EditorLevelLibrary:操作关卡里的 Actor。
  • unreal.AssetToolsHelpers:批量导入、创建资产。
  • unreal.AssetImportTask:定义一次导入任务。
  • unreal.SystemLibrary:一些通用工具函数。

资产路径以 /Game/ 开头,后面跟着 Content 目录下的相对路径,不带扩展名。例如 /Game/Textures/T_Wall_D

EditorAssetLibrary 举个例子,列出某个目录下所有纹理:

import unreal

asset_lib = unreal.EditorAssetLibrary()
paths = asset_lib.list_assets("/Game/Textures/Incoming")
for p in paths:
    print(p)

执行后会在 Output Log 里打印出一串路径。小林第一次跑这段代码时,发现打印出来的是 Soft Object Path,后面调用 load_asset 才能拿到真正的 Texture2D 对象。这个细节很重要,很多 API 需要加载后的对象,而有些只需要路径字符串。

Python 里查看对象可用属性的最快方式是对它调用 dir()。比如 dir(unreal.Texture2D) 会列出一长串属性和方法,配合 help(unreal.Texture2D.set_editor_property) 就能摸清用法。小林在双屏工作时,左边放编辑器,右边放 Python 文档,查起来很快。

unreal 模块里的类名基本和 C++ 一致,所以看 C++ API 文档也能反推 Python 用法。构造函数参数一般按位置传,或者先 new_object 再设属性。小林在写 AssetImportTask 时经常混用这两种方式,哪种顺手用哪种。

三、批量导入与导出

回到那两百张纹理。小林想做成一条自动化流水线:把 D 盘 Textures_Export 里的 PNG 全部导进 /Game/Textures/Incoming,再按命名后缀自动设置类型。后缀 _N 的是法线,_ORM 是通道打包,_D 是反照率。

UE 的导入任务由 AssetImportTask 描述,核心字段包括文件名、目标路径、是否覆盖、是否自动保存。小林把脚本拆成两步:扫描文件、提交导入。

import unreal
import os

source_dir = r"D:\Textures_Export"
target_dir = "/Game/Textures/Incoming"
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()

for name in os.listdir(source_dir):
    if not name.lower().endswith(".png"):
        continue
    full_path = os.path.join(source_dir, name)
    base_name = os.path.splitext(name)[0]

    task = unreal.AssetImportTask()
    task.set_editor_property("filename", full_path)
    task.set_editor_property("destination_path", target_dir)
    task.set_editor_property("destination_name", base_name)
    task.set_editor_property("replace_existing", True)
    task.set_editor_property("save", True)

    asset_tools.import_asset_tasks([task])

这段代码块四十行不到,完成了一百多张 PNG 的导入。小林把它保存成 batch_import_textures.py,每次美术出新包就执行一次。后来他给脚本加了文件名白名单和跳过逻辑,避免重复导入没改动的文件。

有些资源导入时还需要调整工厂参数。比如 FBX 的坐标轴、单位缩放、是否自动生成碰撞,都可以通过 factory 字段覆盖。小林给 FBX 批量导入写过一个版本,先把 factory 设为 unreal.FbxImportUI 实例,再关掉 import_mesh 之外不需要的开关,避免每次导入都生成一堆空材质。

导出方向也类似。假设关卡里有一批 Static Mesh Actor,需要把它们的名称和位置导出成 CSV 给策划做 Excel 校验。可以用 Python 直接写文件:

import unreal
import csv

actors = unreal.EditorLevelLibrary().get_all_level_actors()
rows = []
for actor in actors:
    if actor.get_class().get_name() == "StaticMeshActor":
        loc = actor.get_actor_location()
        rows.append([actor.get_name(), loc.x, loc.y, loc.z])

with open(r"D:\export\actor_positions.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.writer(f)
    writer.writerow(["Name", "X", "Y", "Z"])
    writer.writerows(rows)

脚本跑完,策划桌面上多了一个 CSV。小林觉得这类小工具最值得写:五分钟脚本,省下几十分钟复制粘贴。他还把 csv 换成了带 BOM 的 UTF-8,保证 Excel 打开中文不乱码。

四、自动设置资产属性

导入只是第一步,纹理参数错了同样要命。UE 默认会把所有 PNG 当成颜色纹理处理,法线贴图如果保留 Default 压缩会丢失精度,灰度图如果留在 Color 模式会多占内存。

小林写了一个后处理脚本,按文件名后缀批量修正 Texture2D 的属性。这里的关键是 set_editor_property

import unreal

asset_lib = unreal.EditorAssetLibrary()
paths = asset_lib.list_assets("/Game/Textures/Incoming")

for p in paths:
    tex = asset_lib.load_asset(p)
    if not isinstance(tex, unreal.Texture2D):
        continue

    name = tex.get_name()
    if name.endswith("_N"):
        tex.set_editor_property("compression_settings", unreal.TextureCompressionSettings.TC_NORMALMAP)
        tex.set_editor_property("sRGB", False)
    elif name.endswith("_ORM"):
        tex.set_editor_property("compression_settings", unreal.TextureCompressionSettings.TC_MASKS)
        tex.set_editor_property("sRGB", False)
    elif name.endswith("_D"):
        tex.set_editor_property("compression_settings", unreal.TextureCompressionSettings.TC_DEFAULT)
        tex.set_editor_property("sRGB", True)

    asset_lib.save_loaded_asset(tex)

TextureCompressionSettings 是一个枚举,直接访问成员名即可。如果记不住字段,可以在 Output Log 里对对象调用 dir(tex),Python 会列出可用属性和方法。小林还发现 TextureGroup 设置对移动平台打包影响很大,后续又在脚本里加了 lod_group 分支。

移动端项目里,TextureGroup 决定了不同平台下的最大分辨率。如果反照率贴图被分到 World 组,某些包体里会被压到 512,而 UI 贴图分到 UI 组才能保持原尺寸。小林把组信息也写进文件名后缀映射表,脚本跑完会按规则统一调整。

不带 Alpha 的颜色贴图如果忘记关闭 Alpha Channel,会按 RGBA 存储,多占四分之一内存。小林在脚本里额外判断文件名是否带 _A 后缀,没有就强制把 compress_without_alpha 打开。这个细节让他在一次包体审查里少了两百多兆。

纹理内存的估算可以用一个简单公式。假设一张宽度 ww、高度 hh、每像素 bppbpp bit 的纹理,开启完整 Mipmap 链后总内存 MM 为:

M=whbppl=014l=43whbppM = w \cdot h \cdot bpp \cdot \sum_{l=0}^{\infty} \frac{1}{4^{l}} = \frac{4}{3} \cdot w \cdot h \cdot bpp

也就是说,完整 Mipmap 会在原图基础上多占约 33%33\% 内存。把颜色纹理错误地存成未压缩格式,bpp 会从 8 冲到 32,内存直接翻两番。小林每次跑完脚本都会顺手检查一下 Texture Properties 里的 ImportedCurrent 尺寸。

五、扩展编辑器菜单

脚本越写越多,每次都要去 Output Log 输路径有点烦。UE5.8 允许用 Python 往编辑器菜单里加按钮,把常用脚本挂到 Tools 菜单下。

思路是拿到 unreal.ToolMenus 实例,找到主菜单栏,添加一个入口,再绑定回调函数。下面是一个最小示例:

import unreal

def run_batch_import():
    unreal.log("执行批量导入脚本")
    # 这里调用实际导入函数

tool_menus = unreal.ToolMenus.get()
main_menu = tool_menus.find_menu("LevelEditor.MainMenu")
my_menu = main_menu.add_sub_menu(
    "MyTools",
    "Batch",
    "MyTools",
    "我的小工具"
)
entry = unreal.ToolMenuEntry(
    name="BatchImportTextures",
    type=unreal.MultiBlockType.MENU_ENTRY,
    inserted=True
)
entry.set_label("批量导入纹理")
entry.set_string_command(
    unreal.ToolMenuStringCommandType.PYTHON,
    "",
    "run_batch_import()"
)
my_menu.add_menu_entry("Batch", entry)
tool_menus.refresh_all_widgets()

加上之后,菜单栏会多出一个 我的小工具 > 批量导入纹理。点一下就能跑脚本,比输命令省事。小林给菜单项按功能分了组:导入导出、检查清理、报表生成,同事找起来也方便。

有一点要注意:菜单注册通常在编辑器启动时完成。可以把注册代码放到项目的 Startup Scripts 里,或者写成一个 Editor Utility Blueprint 在启动时调用。否则每次重启编辑器都要手动执行一次注册脚本。小林吃过一次亏,重启后发现菜单不见了,排查半天才发现是注册脚本没自动加载。

除了菜单栏,也可以往工具栏加按钮。LevelEditor.Toolbar 同样通过 ToolMenus 访问,只是入口位置不同。小林把最常用的导入按钮放在工具栏显眼位置,美术不用翻菜单就能点。

六、与外部工具集成

Python 脚本不仅能操作 UE 内部,还能把外部工具串起来。小林的项目里经常要做三件事:用 ImageMagick 压缩源图、用自定义 FBX 检查器校验模型、用 Python 的 pandas 生成资产报告。

整个流程可以用下图表示:

flowchart TD
    A["美术提交资源包"] --> B{文件类型}
    B -->|PNG| C["ImageMagick 压缩"]
    B -->|FBX| D["FBX 检查器校验"]
    C --> E["Python 批量导入 UE"]
    D --> F["Python 修正或标记问题"]
    E --> G["自动设置纹理参数"]
    F --> H["生成 CSV 报告"]
    G --> H
    H --> I["策划/美术查看报告"]

调用外部程序直接用标准库的 subprocess

import subprocess

def compress_png(src, dst):
    cmd = [
        "magick", src,
        "-resize", "2048x2048>",
        "-define", "png:compression-level=9",
        dst
    ]
    subprocess.run(cmd, check=True)

如果外部工具输出的是 JSON,可以用 json.loads 解析,再把结果写回 UE 资产的 Editor Properties。小林的小组有一个自研 FBX 检查器,输出每个模型有多少个 UV 通道、是否有平滑组、骨骼权重是否超过上限,Python 脚本读取后直接在编辑器里标红问题资产。

pandas 这类库他并不直接装进引擎 Python,而是放在一个独立虚拟环境里。脚本用 subprocess 调用虚拟环境的解释器,把 UE 导出的 CSV 传进去,生成带透视表的 Excel。需要画图表时,再让虚拟环境里的 matplotlib 输出 PNG。这样既享受了第三方库,又不污染引擎目录。

另一个常见需求是给项目生成资产清单。小林用 AssetRegistry 遍历所有 StaticMesh,统计面数和 LOD 数量,汇总到 CSV:

import unreal
import csv

registry = unreal.AssetRegistryHelpers.get_asset_registry()
assets = registry.get_assets_by_class(unreal.StaticMesh.static_class())

with open(r"D:\export\mesh_report.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.writer(f)
    writer.writerow(["Path", "Triangles", "LODs", "Bounds"])
    for asset in assets:
        mesh = asset.get_asset()
        tris = mesh.get_num_triangles(0)
        lods = mesh.get_num_lods()
        bounds = mesh.get_bounds().box_extent
        writer.writerow([asset.object_path, tris, lods, str(bounds)])

这份 CSV 每周末跑一遍,策划和 TA 一起看有没有异常 asset。小林还给报表加了阈值高亮:面数超过五千的单元格标红,没有 LOD 的标黄。

七、自动化测试

脚本写多了,团队会担心某个导入规则改坏旧资产。小林用 Python 写了几条一致性检查,当作轻量级回归测试:

  • 所有法线贴图必须以 _N 结尾,且 compression_settingsTC_NORMALMAP
  • 所有反照率贴图必须开启 sRGB
  • 所有 StaticMesh 必须有碰撞体,且 LOD0 面数不超过 5000。
  • 蓝图类名前缀必须是 BP_

这些检查不需要 Gauntlet 或复杂的测试框架,直接在 Python 里跑就行。发现异常就打印错误并写入日志文件:

import unreal

asset_lib = unreal.EditorAssetLibrary()
errors = []

for p in asset_lib.list_assets("/Game/Textures"):
    tex = asset_lib.load_asset(p)
    if isinstance(tex, unreal.Texture2D) and tex.get_name().endswith("_N"):
        if tex.get_editor_property("compression_settings") != unreal.TextureCompressionSettings.TC_NORMALMAP:
            errors.append(f"{p} 法线贴图压缩设置错误")

if errors:
    for e in errors:
        unreal.log_error(e)
else:
    unreal.log("纹理检查全部通过")

小林把这条脚本绑到了持续集成里。每次有人提交资源,CI 都会拉起一个 UE 编辑器进程执行检查脚本,失败就阻断合并。成本很低,却能拦住一大半低级错误。他在 CI 日志里把每个错误按资产路径排序,方便对应到提交人。

CI 机器上跑 UE 需要提前完成一次初始化,否则首次启动会弹许可协议窗口导致命令卡住。小林用一台专用机器装好引擎并登录过 Epic 账号,之后所有检查任务都在这台机器上排队执行。他还给脚本加了超时控制,超过十分钟没跑完就杀掉进程,避免挂死占用构建资源。

检查报告他统一输出成 Markdown 表格,直接贴到 Merge Request 描述里。每个异常资产一条链接,点击就能跳转到编辑器。这样策划和美术不用翻日志也能看懂问题。

UE5.8 也支持通过命令行执行 Python:

UE5Editor-Cmd.exe "D:\Project\MyProject.uproject" -ExecutePythonScript="D:\Scripts\ci_check.py" -stdout -fullstdoutlogoutput

这条命令没有 UI,适合放在 CI 机器上跑。小林建议在脚本末尾用 sys.exit(1) 返回非零值,这样 Jenkins 或 GitLab CI 能正确识别失败。

八、几个常见脚本示例

下面再列三个小林团队里用得最频繁的脚本片段。

1. 批量重命名

外包资源命名混乱,统一改成项目规范:

import unreal

asset_lib = unreal.EditorAssetLibrary()
for p in asset_lib.list_assets("/Game/Props/Temp"):
    old_name = p.split("/")[-1]
    new_name = old_name.replace(" ", "_").replace("-", "_")
    if new_name != old_name:
        asset_lib.rename_asset(p, "/Game/Props/Temp/" + new_name)

2. 自动设置 LOD

对一批 StaticMesh 自动生成 LOD 链:

import unreal

asset_lib = unreal.EditorAssetLibrary()
for p in asset_lib.list_assets("/Game/Meshes/Environment"):
    mesh = asset_lib.load_asset(p)
    if isinstance(mesh, unreal.StaticMesh):
        mesh.set_editor_property("lod_group", unreal.Name("LargeProp"))
        asset_lib.save_loaded_asset(mesh)

3. 生成每日资产变更 CSV

结合 Git 状态或文件修改时间,列出今天新增或修改的 UE 资产:

import unreal
import os
import time
import csv

content_dir = unreal.Paths.project_content_dir()
cutoff = time.time() - 86400
rows = []

for root, _, files in os.walk(content_dir):
    for f in files:
        if f.endswith(".uasset") or f.endswith(".umap"):
            full = os.path.join(root, f)
            if os.path.getmtime(full) > cutoff:
                rows.append([full, time.ctime(os.path.getmtime(full))])

with open(r"D:\export\daily_changes.csv", "w", newline="", encoding="utf-8") as f:
    csv.writer(f).writerows(rows)

小林每天上班先扫一眼这个 CSV,知道昨天又有哪些资产变动。他还把文件大小也加进了列,超过 50MB 的资产会被单独高亮,提醒 TA 检查是否合理。

4. 批量创建材质实例

场景里有一百个道具需要共用同一个主材质的不同颜色实例,手动创建太枯燥。小林用 Python 批量生成 MaterialInstanceConstant,并设置父材质和参数:

import unreal

asset_lib = unreal.EditorAssetLibrary()
factory = unreal.MaterialInstanceConstantFactoryNew()
parent = asset_lib.load_asset("/Game/Materials/M_Master")

for color_name, color in [("Red", (1, 0, 0, 1)), ("Blue", (0, 0, 1, 1))]:
    path = f"/Game/Materials/Instances/MI_{color_name}"
    mic = factory.create_new(path)
    mic.set_editor_property("parent", parent)
    mic.set_vector_parameter_value("BaseColor", unreal.LinearColor(*color))
    asset_lib.save_loaded_asset(mic)

这个脚本让小林十分钟内搞定了原本要花一下午的实例创建。参数名必须和主材质里声明的一致,否则设置不会生效。

如果主材质里还有 Scalar 参数,可以用 set_scalar_parameter_value 设置。小林用同一套逻辑做了肤色、金属度、粗糙度三套实例。策划在 Excel 里填好数值后,脚本一键生成对应材质实例,省去手动复制粘贴。

写在最后

Python 脚本层不是 UE5.8 里最高效的方案,却是最灵活的方案。它让 TA 和工具程序能快速响应美术需求,不需要每次都编译 C++ 插件。小林那个下午写好的几段脚本,后来在项目里跑了整整一年。

如果你刚接触这块,建议从 批量导入批量属性设置 入手。这两块需求几乎每个项目都有,改起来也最快。等熟悉了 unreal 模块的反射风格,再尝试菜单扩展和 CI 集成。

写脚本时不要一次贪多。小林的经验是先把一个具体痛点解决掉,比如只处理 _N 后缀的法线贴图,跑通了再扩展到其他后缀。每一步都保持可验证,出错了也容易定位。

最后再提醒一个坑:Python 脚本操作资产时一定要养成 保存 的习惯。save_loaded_assetsave_directory 别忘了调用,否则编辑器崩溃后脚本改过的属性可能全部丢失。小林吃过一次亏,现在每段脚本末尾都会加一行保存。

sequenceDiagram
    participant A as "美术"
    participant B as "共享盘"
    participant C as "Python 导入脚本"
    participant D as "UE5.8 编辑器"
    participant E as "CI 检查脚本"

    A->>B: 上传 PNG / FBX 包
    B->>C: 扫描新文件
    C->>D: 批量导入资产
    D->>D: 自动设置纹理/模型参数
    D->>E: 触发一致性检查
    E-->>A: 输出 CSV 报告
graph LR
    A["Python 脚本"] --> B[EditorAssetLibrary]
    A --> C[AssetToolsHelpers]
    A --> D[ToolMenus]
    A --> E[subprocess]
    B --> F["读改资产"]
    C --> G["批量导入"]
    D --> H["菜单扩展"]
    E --> I["外部工具"]

把重复劳动交给脚本,省下来的时间可以用来做更值得的事。比如,再喝一杯咖啡,然后想想下一个可以自动化的环节。

小林越来越觉得,脚本的价值不在于省掉几分钟,而在于把规则固化下来。新人入职后不需要再问“法线贴图该用什么压缩格式”,直接运行脚本就能得到一致结果。

小林最近在看能不能把 Python 脚本和编辑器里的 Dataprep 结合起来。有些场景导入流程更复杂,Dataprep 的可视化节点加上 Python 的自定义脚本,或许能把整条资产管线再简化一层。工具链这件事,永远没有终点,只有下一个痛点。