前言

  最近抽空深入研究了一下 Unreal Python 的菜单扩展。
  扩展菜单的主要方法我之前的文章有提到过 Unreal PyToolkit 插件
  当时主要参考论坛的一篇帖子用 Python 实现下拉菜单 链接

  其实有个地方让我很困惑, menu 获取需要通过 ToolMenus 的 find_menu 实现

1
2
3
4
menus = unreal.ToolMenus.get()

# NOTE 获取主界面的主菜单位置
main_menu = menus.find_menu("LevelEditor.MainMenu")

  但是 find_menu 传入的 menu 字符串是从何而来的,完全就没有概念了。
  我最先想到的还是从 C++ 入手 ~

C++ 源码探索

  首先在 VScode 查 UToolMenus , 然后通过 F12 可以定位到头文件。
  头文件名为 ToolMenus.h , 可以用 ctrl+P 去定位 ToolMenus.cpp 脚本
  ToolMenus.cpp 脚本里面可以找到 FindMenu 的函数。

alt

  可以看到是通过 Menus 字典来记录引擎中所有的 Menu 名称。
  然而比较头疼的地方时 Menus 在头文件里面设置为了私有变量,无法通过 Python 亦或是 C++ 插件来获取到里面存储的值

alt

  迫不得已,我改了引擎源码,实现蓝图调用,然后通过 Python 获取字典存储的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
{
"ContentBrowser.AssetContextMenu": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_0"',
"ContentBrowser.FolderContextMenu": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_1"',
"MainFrame.MainMenu.Asset": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_2"',
"LevelEditor.ActorContextMenu": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_3"',
"ContentBrowser.AssetContextMenu.SoundWave": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_4"',
"LevelEditor.MainMenu.Window": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_5"',
"LevelEditor.MainMenu.Help": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_6"',
"AssetEditor.SkeletalMeshEditor.ToolBar": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_7"',
"LevelEditor.MainMenu.File": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_8"',
"ContentBrowser.AssetContextMenu.LevelSequence": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_9"',
"MediaPlayer.AssetPickerAssetContextMenu": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_10"',
"ContentBrowser.AssetContextMenu.CameraAnim": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_11"',
"LevelEditor.LevelEditorToolBar": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_12"',
"LevelEditor.MainMenu": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_13"',
"LevelEditor.MainMenu.Edit": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_14"',
"LevelEditor.LevelEditorToolBar.SourceControl": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_15"',
"LevelEditor.LevelEditorToolBar.Cinematics": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_16"',
"LevelEditor.LevelEditorToolBar.BuildComboButton": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_17"',
"LevelEditor.LevelEditorToolBar.BuildComboButton.LightingQuality": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_18"',
"LevelEditor.LevelEditorToolBar.BuildComboButton.LightingInfo.LightingDensity": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_19"',
"LevelEditor.LevelEditorToolBar.BuildComboButton.LightingInfo.LightingResolution": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_20"',
"LevelEditor.LevelEditorToolBar.BuildComboButton.LightingInfo": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_21"',
"LevelEditor.LevelEditorToolBar.EditorModes": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_22"',
"LevelEditor.LevelEditorToolBar.CompileComboButton": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_23"',
"LevelEditor.LevelEditorToolBar.LevelToolbarQuickSettings": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_24"',
"LevelEditor.LevelEditorToolBar.OpenBlueprint": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_25"',
"LevelEditor.LevelEditorSceneOutliner.ContextMenu": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_30"',
"MainFrame.MainMenu.File": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_31"',
"MainFrame.MainTabMenu.File": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_32"',
"MainFrame.MainMenu.Edit": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_33"',
"MainFrame.MainMenu.Window": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_34"',
"MainFrame.MainMenu.Help": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_35"',
"MainFrame.MainMenu": ToolMenu'"/Engine/Transient.ToolMenus_0:ToolMenu_36"',
}

  现在除了 LevelEditor.MainMenu 我们有了更多的 menu 选项了。
  比如上面显示的就有 ContentBrowser.AssetContextMenu 还有 ContentBrowser.FolderContextMenu 以及 LevelEditor.LevelEditorToolBar
  由此可以得出, 应该可以用 Python 扩展这里相应的菜单的。

Python 右键菜单扩展

  基于上面的 AssetContextMenuFolderContextMenu 的信息
  我们可以分别去扩展右键资弹出的源菜单以及右键文件夹弹出的菜单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import unreal
menus = unreal.ToolMenus.get()

menu_name = "ContentBrowser.FolderContextMenu"
menu = menus.find_menu(menu_name)

entry = unreal.ToolMenuEntry(type=unreal.MultiBlockType.MENU_ENTRY)
entry.set_label("测试 entry")
# NOTE 注册执行的命令
typ = unreal.ToolMenuStringCommandType.PYTHON
entry.set_string_command(typ, "", 'print "entry 触发测试"')
menu.add_menu_entry('AssetContextSourceControl',entry)

# menus.refresh_all_widgets()

alt

  本来 UI 修改需要 refresh_all_widgets 才会更新,因为右键菜单是右键生成时候会自动刷新,所以不执行也不影响。
  后面生成 Toolbar 扩展的时候踩了这个坑。

  另外 AssetContextSourceControl 这个名称是 从何而来 的
  这就需要在编辑器配置里面开启 UI 名称的显示

alt

  开启上面的选项之后,右键菜单就会多出 绿色 的文字标注菜单的名称。

alt

  文件夹菜单扩展也是同理,将菜单的名称修改并且找到菜单相关的 section 进行添加即可。

Python Toolbar 添加

  上面开启了 UI 显示之后,右键菜单可以显示了,但是 Toolbar 的显示依然是没有的。
  这就需要重启一下 引擎。

alt

  有了上面的标注就可以通过上面类似的方法添加 Toolbar 按钮

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import unreal
menus = unreal.ToolMenus.get()

menu_name = "LevelEditor.LevelEditorToolBar"
menu = menus.find_menu(menu_name)

# NOTE 生成类型改为 Toolbar 的按钮
entry = unreal.ToolMenuEntry(type=unreal.MultiBlockType.TOOL_BAR_BUTTON)
entry.set_label("测试 button")
# NOTE 注册执行的命令
typ = unreal.ToolMenuStringCommandType.PYTHON
entry.set_string_command(typ, "", 'print "entry 触发测试"')
menu.add_menu_entry('File',entry)

# NOTE 添加刷新才能立刻看到添加的效果
menus.refresh_all_widgets()

alt

  执行上面的代码就可以在动态给 Toolbar 添加按钮了。

通过 Python 获取引擎生成的菜单

  上面获取菜单名称的方式是通过 C++ 魔改引擎源码才能实现。
  这样限制非常大,有没有更加友好的获取方式呢?

  我看着上面 C++ 获取的字典,不由得计从心生~
  menu 的引擎临时路径是有一定规律的,通过这个规律应该可以用 Python 动态读取到生成的菜单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import unreal

def list_menu(num=1000):
menu_list = set()
for i in range(num):
obj = unreal.find_object(None,"/Engine/Transient.ToolMenus_0:ToolMenu_%s" % i)
if not obj:
continue
menu_name = str(obj.menu_name)
if menu_name != "None":
menu_list.add(menu_name)
return list(menu_list)

print(list_menu())

LogPython: ['LevelEditor.LevelEditorToolBar', 'ContentBrowser.AssetContextMenu.LevelSequence', 'MediaPlayer.AssetPickerAssetContextMenu', 'ContentBrowser.AssetContextMenu', 'LevelEditor.LevelEditorToolBar.CompileComboButton', 'MainFrame.MainMenu', 'LevelEditor.MainMenu.Edit', 'LevelEditor.LevelEditorToolBar.BuildComboButton.LightingInfo.LightingResolution', 'LevelEditor.MainMenu', 'LevelEditor.MainMenu.File', 'AssetEditor.SkeletalMeshEditor.ToolBar', 'MainFrame.MainMenu.Edit', 'ContentBrowser.AssetContextMenu.CameraAnim', 'LevelEditor.MainMenu.Window', 'LevelEditor.LevelEditorToolBar.BuildComboButton', 'LevelEditor.LevelEditorToolBar.BuildComboButton.LightingQuality', 'MainFrame.MainMenu.File', 'LevelEditor.LevelEditorToolBar.BuildComboButton.LightingInfo.LightingDensity', 'LevelEditor.ActorContextMenu', 'ContentBrowser.AssetContextMenu.SoundWave', 'MainFrame.MainTabMenu.File', 'LevelEditor.LevelEditorToolBar.SourceControl', 'LevelEditor.LevelEditorToolBar.BuildComboButton.LightingInfo', 'LevelEditor.LevelEditorSceneOutliner.ContextMenu', 'MainFrame.MainMenu.Window', 'LevelEditor.LevelEditorToolBar.LevelToolbarQuickSettings', 'MainFrame.MainMenu.Asset', 'LevelEditor.LevelEditorToolBar.Cinematics', 'LevelEditor.MainMenu.Help', 'LevelEditor.LevelEditorToolBar.EditorModes', 'MainFrame.MainMenu.Help', 'ContentBrowser.FolderContextMenu', 'LevelEditor.LevelEditorToolBar.OpenBlueprint']

  我想要的效果实现了~
  这样就不需要魔改引擎代码,也可以获取出到大部分的 menu 名称。
  而且上面的数组只是默认开启引擎下的菜单,如果多打开一些编辑窗口,还可以获取到更多的 菜单 。

  由于不知道到底有多少个菜单生成了,所以我默认定的循环数是 1000 ,基本是够用的,而且遍历速度很快。
  另外还有部分的菜单是 None 无名氏,估计是注册的时候没有给定名称,也不好判断是哪里的菜单,所以我就过滤掉了。

通过 Python 扩展 AddNewContextMenu

  AddNewContextMenu 就是没有选择任何资源的时候在 资源浏览器 右键弹出的菜单。
  刚好我遇到了在这个菜单上进行扩展的需求,所以为了用 Python 实现踩了不少坑(:з」∠)

  从这个名字可以知道,默认开启引擎的时候并没有加载到这个菜单。
  使用上面写道 list_menu 函数是获取不到的,除非在 资源浏览器 进行右键触发。
  这个时候 list_menu 就会多出这个 ContentBrowser.AddNewContextMenu 的菜单名称。
  这就产生了很严重的问题,无法实现 C++ 插件的菜单嵌入效果。
  不可能让使用者手动右键生成一下菜单,再让他点击什么按钮触发,将需要的 entry 添加到菜单里呀(:з」∠)

  后来为了能够完成需求,我还是用 C++ 来解决了这个问题 参考知乎这篇文章

  不过搞定了需求,周末还是抽空研究怎么通过 Python 来解决这个问题。
  这个过程还实现了一些有趣的效果,比如先注册生成 menu ,导致右键菜单变成了我自己自定义的菜单了。

1
2
3
4
5
6
7
8
9
10
11
12
13
menus = unreal.ToolMenus.get()

menu_name = "ContentBrowser.AddNewContextMenu"
menu = menus.find_menu(menu_name)

# NOTE 如果已经注册则删除 | 否则无法执行 register_menu
if menus.is_menu_registered(menu_name):
menus.remove_menu(menu_name)

menu = menus.register_menu(menu_name)
entry = unreal.ToolMenuEntry(type=unreal.MultiBlockType.MENU_ENTRY)
entry.set_label("测试 entry")
menu.add_menu_entry('',entry)

alt

  其他菜单也可以尝试着这样魔改成自己的菜单。
  如果可以配合引擎快捷键触发不同的菜单,还是有点意思的。

  上面魔改的菜单要恢复也不难,将自己做的菜单 删除掉 ,默认右键就会生成回正常的菜单了。


  问题是这个折腾依然没能解决实现我想要的效果(:з」∠)
  后面还是想往 C++ 的方向入手,能不能在右键菜单事件添加 回调事件,配合触发 Python 脚本。
  也的确在 C++ 文档里面找到相关的回调函数 链接
  但是要如何接入 Python 还不是很确定。

  后面思路一转,还有更加简单的实现方式,可以用定时器来做。
  虽然定时器不是个好点子,听着就比较浪费资源,但是考虑到 Python 遍历 menu 的速度快到没感觉。
  定时执行嵌入操作的卡顿应该感知不到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import unreal
from Qt import QtCore
from functools import partial

def add_menu(timer):
print("timer running...")
menu = menus.find_menu("ContentBrowser.AddNewContextMenu")

if not menu:
return

# NOTE 如果存在停止计时器
timer.stop()
print("timer stop")

entry = unreal.ToolMenuEntry(type=unreal.MultiBlockType.MENU_ENTRY)
entry.set_label("测试 entry")
menu.add_menu_entry('ContentBrowserNewAdvancedAsset',entry)

menus.refresh_all_widgets()

# NOTE 使用 Qt 的定时器实现 js setInterval 函数的效果
# NOTE Python 原生实现比较麻烦,用 Qt 的 Timer 比较简洁
timer = QtCore.QTimer()
timer.timeout.connect(partial(add_menu,timer))
timer.start(1000)

alt

  完美通过定时器实现动态嵌入菜单。

PyToolkit Json 配置优化

  通过上面的一轮折腾,之前 PyToolkit 提供的菜单配置完全可以更加灵活。
  于是我又重写了之前 json 配置的读取和生成,通过递归的方法,自动处理多重嵌套菜单的效果。

  具体的配置方法我写到了 PyToolkit 的帮助文档里面 链接

alt

  上面四种嵌入可以完全通过 json 配置来完成~

总结

  利用 Python 做菜单扩展的确方便了很多,但是 Python 也并不是万能的。
  例如我之前研究过的 Sequencer 菜单工具栏嵌入 扩展就只能通过 C++ 来实现
  因为 list_menu 没有找到相关的菜单名称。

  最近博客因为各种原因写好了文章却没有更新,更新频率也下降了(:з」∠)
  希望后续可以整理规划好时间,继续坚持做博客的记录。

2020-9-21 修复引擎崩溃 BUG

  记录上面方案的问题,如果引擎加载地图之类的大文件,需要比较长的时间。
  如果在这个过程中触发定时器的 嵌入 操作,会直接导致引擎崩溃。

  最开始我查了 C++ 源码,打算做一个 C++ 函数判断引擎是否处在加载状态。
  我参考报错的 FUObjectThreadContext::Get().IsRoutingPostLoad 进行判断,结果还是产生了错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
LoginId:9f3ca733407852eca305588adf6ea300
EpicAccountId:6d3892f3bd8843aea3ad36ed181ea5b8

Assertion failed: !FUObjectThreadContext::Get().IsRoutingPostLoad [File:G:/UnrealEngine425/Engine/Source/Runtime/CoreUObject/Private/UObject/ScriptCore.cpp] [Line: 1755] Cannot call UnrealScript (RedArtToolkitBPLibrary /Engine/Transient.RedArtToolkitBPLibrary_206 - Function /Script/RedArtToolkit.RedArtToolkitBPLibrary:IsRoutingPostLoad) while PostLoading objects

UE4Editor_Core!AssertFailedImplV() [g:\unrealengine425\engine\source\runtime\core\private\misc\assertionmacros.cpp:100]
UE4Editor_Core!FDebug::CheckVerifyFailedImpl() [g:\unrealengine425\engine\source\runtime\core\private\misc\assertionmacros.cpp:450]
UE4Editor_CoreUObject!DispatchCheckVerify<void,<lambda_2781c71baf617450f2ef557efebba817> >() [g:\unrealengine425\engine\source\runtime\core\public\misc\assertionmacros.h:161]
UE4Editor_CoreUObject!UObject::ProcessEvent() [g:\unrealengine425\engine\source\runtime\coreuobject\private\uobject\scriptcore.cpp:1755]
UE4Editor_PythonScriptPlugin!PyUtil::InvokeFunctionCall() [g:\unrealengine425\engine\plugins\experimental\pythonscriptplugin\source\pythonscriptplugin\private\pyutil.cpp:579]
UE4Editor_PythonScriptPlugin!FPyWrapperObject::CallFunction_Impl() [g:\unrealengine425\engine\plugins\experimental\pythonscriptplugin\source\pythonscriptplugin\private\pywrapperobject.cpp:254]
UE4Editor_PythonScriptPlugin!FPyWrapperObject::CallMethodNoArgs_Impl() [g:\unrealengine425\engine\plugins\experimental\pythonscriptplugin\source\pythonscriptplugin\private\pywrapperobject.cpp:326]
UE4Editor_PythonScriptPlugin!FPyMethodWithClosureDef::Call() [g:\unrealengine425\engine\plugins\experimental\pythonscriptplugin\source\pythonscriptplugin\private\pymethodwithclosure.cpp:180]
python27
python27
python27
python27
python27
python27
python27
python27
python27
python27
pyside_python2_7
pyside_python2_7
QtCore4
QtCore4
QtCore
QtCore4
QtCore
QtGui4
QtGui4
QtGui
QtCore4
QtCore4
QtCore4
user32
user32
...... 省略

  因此我需要找一个方法,判断 引擎 处于卡顿状态,然后阻断 timer 函数的执行。
  好在通过 tick 函数可以捕捉到时间差,判断到引擎是否真的卡顿。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# NOTE 这里代码归纳 省略了中间代码
global last_tick
last_tick = time.time()

def timer_add_menu(menu_dict,timer):
# NOTE 判断当前是否卡顿状态 | 如果卡顿就跳过执行
# NOTE 避免处于加载状态导致引擎崩溃 !FUObjectThreadContext::Get().IsRoutingPostLoad -> Cannot call UnrealScript while PostLoading objects
global last_tick
tick_elapsed = time.time() - last_tick
if (tick_elapsed > 0.01):
return

# ...

def __QtAppTick__(delta_seconds):

global last_tick
last_tick = time.time()

unreal.register_slate_post_tick_callback(__QtAppTick__)

  如果 tick_elapsed 变量超过 0.01s 说明当前 tick 卡顿,就可以跳过后续的代码的执行。
  但是项目里实测不太稳定,推荐还是用 C++ 的方案较为稳妥。

2021-4-11 补充

  上面触发 C++ 的 BUG 原因找到了。
  主要怪我,应该用 unreal 内置的 tick 函数来触发而不应该用 QTimer 做定时触发。
  QTimer 触发有可能在引擎卡顿加载状态,导致在 Tick 之中触发了查询函数出错崩溃。
  只要也用 register_slate_post_tick_callback 的方法来触发菜单寻找就不会崩溃了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
fail_menus = create_menu()
if fail_menus:
global __tick_menu_elapsed__
__tick_menu_elapsed__ = 0

def timer_add_menu(menu_dict, delta):
global __tick_menu_elapsed__
__tick_menu_elapsed__ += delta

# NOTE 大于 .5s 执行 | 避免频繁执行
if __tick_menu_elapsed__ < 0.5:
return

__tick_menu_elapsed__ = 0

# NOTE 如果 menu_dict 清空则停止计时器
if not menu_dict:
global __red_add_menu_tick__
unreal.unregister_slate_post_tick_callback(__red_add_menu_tick__)
return

flag = False
for tool_menu, config in copy.deepcopy(menu_dict).items():
menu = menus.find_menu(tool_menu)
if not menu:
continue
# NOTE 清除找到的menu
menu_dict.pop(tool_menu)
flag = True
config.setdefault("menu", menu)
handle_menu(config)

if flag:
menus.refresh_all_widgets()

# NOTE 注册添加菜单功能
callback = partial(timer_add_menu, fail_menus)
global __red_add_menu_tick__
__red_add_menu_tick__ = unreal.register_slate_post_tick_callback(callback)
__QtAppQuit__ = partial(
unreal.unregister_slate_post_tick_callback, __red_add_menu_tick__
)
unreal_app.aboutToQuit.connect(__QtAppQuit__)