前言
最近抽空深入研究了一下 Unreal Python 的菜单扩展。 扩展菜单的主要方法我之前的文章有提到过 Unreal PyToolkit 插件 当时主要参考论坛的一篇帖子用 Python 实现下拉菜单 链接
其实有个地方让我很困惑, menu 获取需要通过 ToolMenus 的 find_menu 实现
1 2 3 4 menus = unreal.ToolMenus.get() main_menu = menus.find_menu("LevelEditor.MainMenu" )
但是 find_menu 传入的 menu 字符串是从何而来的,完全就没有概念了。 我最先想到的还是从 C++ 入手 ~
C++ 源码探索
首先在 VScode 查 UToolMenus , 然后通过 F12 可以定位到头文件。 头文件名为 ToolMenus.h , 可以用 ctrl+P 去定位 ToolMenus.cpp 脚本 ToolMenus.cpp 脚本里面可以找到 FindMenu 的函数。
可以看到是通过 Menus 字典来记录引擎中所有的 Menu 名称。 然而比较头疼的地方时 Menus 在头文件里面设置为了私有变量,无法通过 Python 亦或是 C++ 插件来获取到里面存储的值
迫不得已,我改了引擎源码,实现蓝图调用,然后通过 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 右键菜单扩展
基于上面的 AssetContextMenu
和 FolderContextMenu
的信息 我们可以分别去扩展右键资弹出的源菜单以及右键文件夹弹出的菜单。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import unrealmenus = 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" ) typ = unreal.ToolMenuStringCommandType.PYTHON entry.set_string_command(typ, "" , 'print "entry 触发测试"' ) menu.add_menu_entry('AssetContextSourceControl' ,entry)
本来 UI 修改需要 refresh_all_widgets 才会更新,因为右键菜单是右键生成时候会自动刷新,所以不执行也不影响。 后面生成 Toolbar 扩展的时候踩了这个坑。
另外 AssetContextSourceControl 这个名称是 从何而来 的 这就需要在编辑器配置里面开启 UI 名称的显示
开启上面的选项之后,右键菜单就会多出 绿色 的文字标注菜单的名称。
文件夹菜单扩展也是同理,将菜单的名称修改并且找到菜单相关的 section 进行添加即可。
上面开启了 UI 显示之后,右键菜单可以显示了,但是 Toolbar 的显示依然是没有的。 这就需要重启一下 引擎。
有了上面的标注就可以通过上面类似的方法添加 Toolbar 按钮
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import unrealmenus = unreal.ToolMenus.get() menu_name = "LevelEditor.LevelEditorToolBar" menu = menus.find_menu(menu_name) entry = unreal.ToolMenuEntry(type =unreal.MultiBlockType.TOOL_BAR_BUTTON) entry.set_label("测试 button" ) typ = unreal.ToolMenuStringCommandType.PYTHON entry.set_string_command(typ, "" , 'print "entry 触发测试"' ) menu.add_menu_entry('File' ,entry) menus.refresh_all_widgets()
执行上面的代码就可以在动态给 Toolbar 添加按钮了。
通过 Python 获取引擎生成的菜单
上面获取菜单名称的方式是通过 C++ 魔改引擎源码才能实现。 这样限制非常大,有没有更加友好的获取方式呢?
我看着上面 C++ 获取的字典,不由得计从心生~ menu 的引擎临时路径是有一定规律的,通过这个规律应该可以用 Python 动态读取到生成的菜单。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import unrealdef 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 无名氏,估计是注册的时候没有给定名称,也不好判断是哪里的菜单,所以我就过滤掉了。
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) 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)
其他菜单也可以尝试着这样魔改成自己的菜单。 如果可以配合引擎快捷键触发不同的菜单,还是有点意思的。
上面魔改的菜单要恢复也不难,将自己做的菜单 删除掉 ,默认右键就会生成回正常的菜单了。
问题是这个折腾依然没能解决实现我想要的效果(:з」∠) 后面还是想往 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 unrealfrom Qt import QtCorefrom functools import partialdef add_menu (timer ): print ("timer running..." ) menu = menus.find_menu("ContentBrowser.AddNewContextMenu" ) if not menu: return 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() timer = QtCore.QTimer() timer.timeout.connect(partial(add_menu,timer)) timer.start(1000 )
完美通过定时器实现动态嵌入菜单。
通过上面的一轮折腾,之前 PyToolkit 提供的菜单配置完全可以更加灵活。 于是我又重写了之前 json 配置的读取和生成,通过递归的方法,自动处理多重嵌套菜单的效果。
具体的配置方法我写到了 PyToolkit 的帮助文档里面 链接
上面四种嵌入可以完全通过 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 global last_ticklast_tick = time.time() def timer_add_menu (menu_dict,timer ): 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 if __tick_menu_elapsed__ < 0.5 : return __tick_menu_elapsed__ = 0 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 menu_dict.pop(tool_menu) flag = True config.setdefault("menu" , menu) handle_menu(config) if flag: menus.refresh_all_widgets() 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__)