在本文中,我将介绍很多内容。我提前为太长的篇幅道歉。但是,这篇文章的主题本质上是“如何使用虚幻引擎构建 3D 工具”,这是一个很大的话题。在本文结束时,我将介绍交互式工具框架,这是 Unreal Engine 4.26 中的一个系统,可以相对简单地构建多种类型的交互式 3D 工具。我将专注于“在运行时”使用这个框架,即在构建的游戏中。但是,我们使用这个完全相同的框架在虚幻编辑器中构建 3D 建模工具套件。而且,其中许多工具都可以在运行时直接使用!在你的游戏中雕刻!它太酷了。
下面有一个 ToolsFramworkDemo 应用程序的短视频和一些屏幕截图 - 这是一个构建的可执行文件,不在 UE 编辑器中运行(尽管也可以)。该演示允许你创建一组网格,可以通过单击进行选择(通过 shift-click/ctrl-click 支持多选),并为活动选择显示 3D 变换 Gizmo。左侧的一小组 UI 按钮用于执行各种操作。Add Bunny按钮将导入和附加一个兔子网格,Undo和Redo会按照你的预期进行。World按钮在World 和 Local 坐标系之间切换 Gizmo:
其余按钮启动各种建模工具,它们与 UE 4.26 编辑器的建模模式中使用的工具实现完全相同。PolyExtrude是绘制多边形工具,你可以在其中在 3D 工作平面上绘制一个封闭的多边形(可以通过 ctrl 单击重新定位),然后以交互方式设置挤出高度。PolyRevolve允许你在 3D 工作平面上绘制开放或封闭路径 - 双击或关闭路径到终点 - 然后编辑生成的旋转曲面。Edit Polygons是编辑器中的 PolyEdit 工具,在这里您可以选择面/边/顶点并使用 3D gizmo 移动它们 — 请注意,各种 PolyEdit 子操作,如 Extrude 和 Inset,不会在 UI 中公开,但可以工作。
所有这些几何图形是在演示中创建的。选择窗口并使用 GIZMO 旋转。
Plane Cut使用工作平面切割网格,Boolean执行网格布尔运算(需要两个选定对象)。Remesh重新对网格进行三角剖分(不幸的是,我无法轻松显示网格线框)。Vertex Sculpt允许您对顶点位置进行基本的 3D 雕刻,而DynaSculpt 进行自适应拓扑雕刻,这就是我在屏幕截图中展示的应用于 Bunny 的内容。最后,Accept和Cancel按钮应用或放弃当前的工具结果(这只是一个预览) - 我将在下面进一步解释。
兔子长出一些新的部分
这不是一个功能齐全的 3D 建模工具,它只是一个基本的演示。一方面,没有任何形式的保存或导出,不过,添加一个快速的 OBJ 导出并不难!不存在对分配材质的支持,您看到的材质是硬编码的或由工具自动使用,例如动态网格雕刻中的平面着色。同样,一个积极的 C++ 开发人员可以相对容易地添加类似的东西。2D 用户界面是一个非常基本的 UMG 用户界面。我假设这是一次性的,你将构建自己的 UI。再说一次,如果你想做一个非常简单的特定领域的建模工具,比如一个用于清理医学扫描的 3D 雕刻工具,你也许可以在稍加修改后摆脱这个 UI。
1、获取并运行示例项目
在开始之前,本教程适用于 UE 4.26,你可以从Epic Games Launcher安装它。本教程的项目位于 Github 上的 UnrealRuntimeToolsFrameworkDemo存储库(MIT 许可)。目前,该项目只能在 Windows 上运行,因为它依赖于MeshModelingToolset引擎插件,该插件目前仅适用于 Windows。让该插件在 OSX/Linux 上工作主要是选择性删除的问题,但它需要引擎源代码构建,这超出了本教程的范围。
进入顶级文件夹后,右键单击Windows 资源管理器中的ToolsFrameworkDemo.uproject ,然后从上下文菜单中选择Generate Visual Studio project files 。这将生成ToolsFrameworkDemo.sln,你可以使用它来打开 Visual Studio。也可以直接在编辑器中打开 .uproject — 它会要求编译,但可能需要参考 C++ 代码才能真正了解该项目中发生的情况。
构建解决方案并启动(按 F5),编辑器应打开到示例地图中。可以使用主工具栏中的大播放按钮在 PIE 中测试该项目,或者单击启动按钮来构建一个熟化的可执行文件。这将需要几分钟,之后构建的游戏将在单独的窗口中弹出。如果它以这种方式启动(我认为这是默认设置),可以点击 Escape 退出全屏。在全屏模式下,你必须按Alt+F4退出,因为没有菜单/UI。
2、概述
这篇文章太长了,需要一个目录。以下是我要介绍的内容:
首先,我将解释交互式工具框架(ITF) 作为一个概念的一些背景。它来自哪里,它试图解决什么问题。随意跳过这个 author-on-his-soapbox 部分,因为本文的其余部分不以任何方式依赖它。
接下来我将解释 UE4 交互工具框架的主要部分。我们将从工具、工具构建器和工具管理器开始,并讨论工具生命周期、接受/取消模型和基础工具。输入处理将在输入行为系统、通过工具属性集存储的工具设置和工具操作中进行介绍。
接下来我将解释Gizmos系统,用于实现视口内 3D 小部件,重点介绍上面剪辑/图像中显示的标准 UTransformGizmo 。
在 ITF 的最高级别,我们有Tools Context 和 ToolContext API,我将详细介绍 ITF 的客户端需要实现的 4 个不同的 API - IToolsContextQueriesAPI、IToolsContextTransactionsAPI、IToolsContextRenderAPI 和 IToolsContextAssetAPI。然后我们将介绍一些特定于网格编辑工具的细节,特别是Actor/Component Selections、FPrimitiveComponentTargets和FComponentTargetFactory。
到目前为止,一切都将与 UE4.26 附带的 ITF 模块有关。为了在运行时使用 ITF,我们将创建我们自己的运行时工具框架后端,其中包括一个基本的可选网格“场景对象”的 3D 场景、一个非常标准的 3D 应用程序变换 gizmo 系统以及 ToolsContext API 的实现 I上面提到的与这个运行时场景系统兼容的。本节主要解释了我们必须添加到 ITF 以在运行时使用它的额外位,因此您需要阅读前面的部分才能真正理解它。
接下来我将介绍一些特定于演示的材料,包括使演示工作所需的ToolsFrameworkDemo 项目设置、RuntimeGeometryUtils 更新,特别是对 USimpleDynamicMeshComponent 的碰撞支持,然后是一些关于在运行时使用建模模式工具的注释,因为这通常需要一些胶水代码才能使现有的网格编辑工具在游戏环境中发挥作用。
就是这样!让我们开始…
3、交互式工具框架 - 为什么
我不喜欢通过证明它的存在来开始一篇关于某事的文章的想法。但是,我想我需要。我花了很多年 - 基本上是我的整个职业生涯 - 构建 3D 创建/编辑工具。我的第一个系统是ShapeShop(它自 2008 年以来一直没有更新,但仍然可以工作——这是 Windows 向后兼容性的证明!)。我还构建了 Meshmixer,它成为 Autodesk 产品,下载数百万次,并被广泛使用至今。通过Twitter搜索,我不断惊讶于人们使用 Meshmixer 做的事情,很多数字牙医!!。我还构建了其他从未出现过的全功能系统,例如我们称之为手绘世界的 3D 透视草图界面 ,是我在 Autodesk Research 构建的。之后,我帮助构建了一些医疗 3D 设计工具,例如Archform 牙齿矫正器规划应用程序和NiaFit 小腿假肢插座设计工具(VR ),遗憾的是我在它有任何流行的希望之前就放弃了。
撇开自我祝贺不谈,在过去 15 多年制作这些 3D 工具的过程中,我学到的是,制造一个巨大的混乱是非常容易的。我开始研究后来成为 Meshmixer 的东西,因为 Shapeshop 已经到了无法添加任何东西的地步。然而,Shapeshop 的某些部分形成了一个非常早期的“工具框架”,我将其提取并用作其他各种项目的基础,甚至还有一些 Meshmixer(最终也变得非常脆弱!)。该代码仍在我的网站上。当我离开 Autodesk 时,我回到了如何构建工具的这个问题,并创建了frame3Sharp 库这使得在 C# 游戏引擎中构建运行时 3D 工具变得(相对)容易。这个框架围绕上面提到的 Archform、NiaFit 和 Cotangent 应用程序发展起来,并一直为它们提供动力。但是,后来我加入了 Epic,并重新开始使用 C++!
所以,这就是 UE4 交互式工具框架的起源故事。使用这个框架,一个小团队(6 人或更少的人,取决于月份)在 UE4 中构建了建模模式,它有 50 多个“工具”。有些非常简单,例如使用选项复制事物的工具,有些则非常复杂,例如整个 3D 雕刻工具。但关键点是,工具代码相对干净且很大程度上独立 - 几乎所有工具都是一个独立的 cpp/h 对。不是通过剪切和粘贴而独立,而是独立于这一点,我们尽可能地将“标准”工具功能移动到框架中,否则这些功能将不得不被复制。
3.1 让我们谈谈框架
我在解释交互式工具框架时遇到的一个挑战是我没有参考点来比较它。大多数 3D 内容创建工具在其代码库中都有一定程度的“工具框架”,但除非你尝试向 Blender 添加功能,否则可能从未与这些东西进行过交互。所以,我不能试图通过类比来解释。并且这些工具并没有真正努力提供类似的原型框架作为大写-F 框架。所以很难把握。(PS:如果您认为您知道类似的Framework,请联系并告诉我!)
但是,在其他类型的应用程序开发中,框架非常常见。例如,如果你想构建一个 Web 应用程序或移动应用程序,你几乎肯定会使用一个定义明确的框架,如 Angular 或 React 或本月流行的任何东西(实际上有数百个)。这些框架倾向于将“小部件”等低级方面与视图等高级概念混合在一起。我在这里关注视图,因为这些框架中的绝大多数都是基于视图的概念。通常,前提是你拥有数据,并且你希望将这些数据放入视图中,并带有一定数量的 UI,允许用户探索和操作该数据。甚至还有一个标准术语,“模型-视图-控制器”架构。XCode 界面生成器是我所知道的最好的例子,你实际上是在故事板上用户将看到的视图,并通过这些视图之间的转换来定义应用程序行为。我经常使用的每个手机应用程序都是这样工作的。
提高复杂性,我们有像 Microsoft Word 或 Keynote 这样的应用程序,它们与基于视图的应用程序完全不同。在这些应用程序中,用户将大部分时间花在单个视图中,并且直接操作内容而不是抽象地与数据交互。但大部分操作都是以Commands的形式进行的,例如删除文本或编辑Properties。例如,在 Word 中,当我不键入字母时,我通常要么将鼠标移动到命令按钮上以便我可以单击它——一个离散的操作——要么打开对话框并更改属性。我不做的是花费大量时间使用连续的鼠标输入(拖放和选择是明显的例外)。
现在考虑一个内容创建应用程序,如 Photoshop 或 Blender。同样,作为用户,您将大部分时间花在标准化视图中,并且你直接操作的是内容而不是数据。仍然有大量具有属性的命令和对话框。但是这些应用程序的许多用户——尤其是在创意环境中——也花费大量时间非常小心地在按住其中一个按钮的同时移动鼠标。此外,当他们这样做时,应用程序通常处于特定模式,其中鼠标移动(通常与修改热键结合使用)以特定模式的方式被捕获和解释。该模式允许应用程序在大量方式之间消除歧义,mouse-movement-with-button-held-down动作可以被解释,本质上是为了将捕获的鼠标输入引导到正确的位置。这与命令根本不同,命令通常是无模式的,并且在输入设备方面也是无状态的。
除了模式之外,内容创建应用程序的一个标志是我将称为Gizmos的东西,它们是附加的临时交互式视觉元素,它们不是内容的一部分,但提供了一种(半无模式)操作内容的方式。例如,可以单击拖动以调整矩形大小的矩形角上的小框或 V 形将是 Gizmo 的标准示例。这些通常被称为小部件,但我认为使用这个术语会让人感到困惑,因为它与按钮和菜单小部件重叠,所以我将使用 Gizmos。
所以,现在我可以开始暗示交互式工具框架的用途了。在最基本的层面上,它提供了一种系统的方法来实现捕获和响应用户输入的模态状态,为了简洁起见,我将其称为交互工具或工具,以及实现 Gizmos(我将假定它本质上是空间本地化的上下文敏感模式,但我们可以将讨论保存在 Twitter 上)。
3.2 为什么需要一个框架?
这是我被问过很多次的问题,主要是那些没有尝试构建复杂的基于工具的应用程序的人。简短的回答是,减少(但遗憾的是没有消除)你制造邪恶灾难的机会。但我也会做一个长的回答。
关于基于工具的应用程序需要了解的重要一点是,一旦你为用户提供以任何顺序使用工具的选项,他们就会这样做,这将使一切变得更加复杂。在基于视图的应用程序中,用户通常是“On Rails”,因为应用程序允许在 Y 之后而不是之前执行 X。当我启动 Twitter 应用程序时,我不能直接跳转到所有内容——我必须浏览一系列视图。这允许应用程序的开发人员对应用程序状态做出大量假设。特别是,尽管视图可能会操作相同的底层 DataModel(几乎总是某种形式的数据库),但我永远不必担心区分一个视图中的点击与另一个视图中的点击。在某种意义上,意见是模式,在特定视图的上下文中,通常只有命令,没有工具。
因此,在基于视图的应用程序中,谈论工作流非常容易。创建基于视图的应用程序的人往往会画很多类似这样的图表:
这些图可能是视图本身,但更多时候它们是用户通过应用程序所采取的步骤——如果你愿意的话,它们是用户故事。它们并不总是严格线性的,可能存在分支和循环(Google Image Search for Workflow 有很多更复杂的示例)。但总是有明确的进入和退出点。用户从一个任务开始,并通过工作流完成该任务。然后很自然地设计一个应用程序来提供用户可以完成任务的工作流。我们可以通过 Workflow 有意义地谈论 Progress,关联的 Data 和 Application State 也构成了一种 Progress。随着额外任务的添加,开发团队的工作是提出一种设计,以允许有效地完成这些必要的工作流程。
内容创建/编辑应用程序的根本复杂性在于,这种方法根本不适用于它们。我认为最终的区别在于内容创建/编辑工具中没有固有的进度概念。例如,作为 Powerpoint 用户,我可以(而且确实!)花几个小时重新组织我的幻灯片,调整图像大小和对齐方式,稍微调整文本。在我看来,我可能对进度有一些模糊的概念,但这并没有在应用程序中编码。我的任务在应用程序之外。如果没有明确的任务或进度衡量标准,就没有工作流程!
我认为内容创建/编辑应用程序更有用的心智模型就像右边的图像。绿色中央集线器是这些应用程序中的默认状态,通常你只是在其中查看你的内容。例如,在 Photoshop 中平移和缩放图像,或在 Blender 中浏览 3D 场景。这是用户花费大量时间的地方。蓝色辐条是工具。我会去一个工具一段时间,但我总是回到中心。
因此,如果我要随着时间的推移跟踪我的状态,那将是通过无数工具进出默认集线器的曲折路径。没有明确定义的顺序,作为用户,我通常可以按照我认为合适的任何顺序自由使用工具。在一个缩影中,我们可能能够找到定义明确的小型工作流来分析和优化,但在应用程序级别,工作流实际上是无限的。
看起来相对明显的是,你需要在此处采用的架构方法与在视图方法中的不同。通过以正确的方式眯眼看它,人们可能会争辩说每个工具基本上都是一个视图,那么这里真正不同的是什么?根据我的经验,不同之处在于我认为是Tool Sprawl。
如果你有明确定义的工作流程,那么很容易判断什么是必要的,什么是不必要的。与所需工作流程无关的功能不仅会浪费设计和工程时间,而且最终会使工作流程变得比必要的复杂——这会使用户体验变得更糟!现代软件开发的正统观念非常关注这个前提——构建最小可行的产品,然后迭代、迭代、迭代以消除用户的摩擦。
基于工具的应用程序根本不同,因为每增加一个工具都会增加应用程序的价值。如果我没有使用特定工具,那么除了启动该工具所需的附加工具栏按钮带来的小 UI 开销之外,它的添加几乎不会影响我。当然,学习新工具需要付出一些努力。但是,这种努力的回报是这个新工具现在可以与所有其他工具相结合!这导致了一种应用级网络效应,其中每个新工具都是所有现有工具的力量倍增器。如果观察几乎所有主要的内容创建/编辑工具,这一点就会立即显现出来,其中有无数的工具栏和工具栏菜单以及工具栏的嵌套选项卡,隐藏在其他工具栏后面。对局外人来说,这看起来很疯狂,但对用户来说,
许多来自面向工作流的软件世界的人都惊恐地看着这些应用程序。我观察到许多新项目,其中团队开始尝试构建一些“简单”的东西,专注于“核心工作流程”,也许是为“新手用户”绘制的,并且绘制了许多漂亮的线性工作流程图。但现实情况是,新手用户在掌握你的应用程序之前只是新手,然后他们会立即要求更多功能。因此,你将在这里和那里添加一个工具。几年后,你将拥有一套庞大的工具,如果没有系统的方法来组织它们,手上就会一团糟。
3.3 遏制伤害
混乱从何而来?据我所见,有几种常见的惹麻烦的方法。首先是低估了手头任务的复杂性。许多内容创建应用程序以“查看器”开始,其中所有应用程序逻辑(如 3D 相机控件)都直接在鼠标和 UI 按钮处理程序中完成。然后随着时间的推移,只需添加更多 if/else 分支或 switch case,就可以合并新的编辑功能。这种方法可以持续很长时间,而且我工作过的许多 3D 应用程序的核心仍然是这些残留的代码分支。但是你只是在挖掘一个更深的代码洞并用代码意大利面填充它。最终,将需要一些实际的软件架构,并且需要进行痛苦的重构工作(随后是多年的修复回归,
即使有一定数量的“工具架构”,如何处理设备输入也很棘手,而且往往最终导致混乱的架构锁定。鉴于“工具”通常由设备输入驱动,一个看似显而易见的方法是直接为工具提供输入事件处理程序,如 OnMouseUp/OnMouseMove/OnMouseDown 函数。这成为放置“做事”代码的自然位置,例如在鼠标事件上,你可以直接在绘画工具中应用画笔印章。在用户要求支持其他输入设备(如触摸、笔或 VR 控制器)之前,这似乎是无害的。怎么办?只是将呼叫转发给鼠标处理程序吗?压力或 3D 位置呢?然后是自动化,当用户开始要求能够为你的工具编写脚本时。它不是。绝对不。真的,不要)。
将重要代码放入输入事件处理程序还会导致诸如标准事件处理模式的猖獗复制粘贴之类的事情,如果需要进行更改,这可能会很乏味。而且,昂贵的鼠标事件处理程序实际上会使您的应用程序感觉不如应有的响应,这是由于称为鼠标事件优先级的东西。所以,你真的要小心处理工具架构的这一部分,因为看似标准的设计模式可能会引发一系列问题。
同时,如果工具架构定义过于严格,它可能成为扩展工具集的障碍,因为新的需求不“符合”初始设计的假设。如果许多工具都建立在初始架构之上,那么更改就变得棘手,然后聪明的工程师被迫想出变通办法,现在你有两个(或更多)工具架构。最大的挑战之一就是如何在工具实现和框架之间划分职责。
我不能声称交互式工具框架 (ITF) 会为你解决这些问题。最终,任何成功的软件最终都会被早期的设计决策所困,在这些决策之上已经建造了高山,而改变路线只能付出巨大的代价。我可以整天给你讲故事,关于我是如何对自己做到这一点的。我能说的是,在 UE4 中实现的 ITF 希望能从我过去的错误中受益。在过去的 2 年中,我们使用 ITF 在 UE4 编辑器中构建新工具的经验(到目前为止)相对轻松,我们一直在寻找消除任何摩擦点的方法。
4、工具、工具构建器和工具管理器
如上所述,交互工具是应用程序的模态状态,在此期间可以以特定方式捕获和解释设备输入。在交互式工具框架 (ITF) 中,UInteractiveTool基类表示模态状态,并具有你可能需要实现的非常小的 API 函数集。下面我总结了 psuedo-C++ 中的核心 UInteractiveTool API — 为简洁起见,我省略了虚拟、常量、可选参数等内容。我们稍后会在一定程度上介绍其他 API 函数集,但这些是关键的。在::Setup()中初始化您的工具,并在::Shutdown()中进行任何最终确定和清理,这也是你执行“应用”操作之类的地方。EToolShutdownType与HasAccept()和CanAccept()函数有关,我将在下面详细解释。最后,工具将有机会渲染()并勾选每一帧。请注意,还有一个 ::Tick() 函数,但你应该重写::OnTick()因为基类 ::Tick() 具有必须始终运行的关键功能。
UCLASS()
class UInteractiveTool : public UObject, public IInputBehaviorSource
{
void Setup();
void Shutdown(EToolShutdownType ShutdownType);
void Render(IToolsContextRenderAPI* RenderAPI);
void OnTick(float DeltaTime);
bool HasAccept();
bool CanAccept();
};
UInteractiveTool 不是一个独立的对象,你不能简单地自己生成一个。为了使其发挥作用,必须调用 Setup/Render/Tick/Shutdown,并传递诸如IToolsContextRenderAPI之类的适当实现,从而允许工具绘制线条/等。我将在下面进一步解释。但是现在你需要知道的是,要创建一个 Tool 实例,你需要从UInteractiveToolManager请求一个。要允许 ToolManager 构建任意类型,您需要向 ToolManager 注册一个 <String, UInteractiveToolBuilder > 对。UInteractiveToolBuilder 是一个非常简单的工厂模式基类,必须为每种工具类型实现:
UCLASS()
class UInteractiveToolBuilder : public UObject
{
bool CanBuildTool(const FToolBuilderState& SceneState);
UInteractiveTool* BuildTool(const FToolBuilderState& SceneState);
};
UInteractiveToolManager的主要 API总结如下。通常,你不需要实现自己的 ToolManager,基本实现功能齐全,应该完成使用工具所需的一切。但如有必要,你可以自由扩展子类中的各种功能。
下面的函数大致按照你调用它们的顺序列出。RegisterToolType()将字符串标识符与 ToolBuilder 实现相关联。然后应用程序使用SelectActiveToolType()设置一个活动的生成器,然后使用ActivateTool()创建一个新的 UInteractiveTool 实例。有 getter 可以访问活动工具,但实际上很少有人经常调用。应用程序必须在每一帧调用 Render() 和 Tick() 函数,然后应用程序调用活动工具的相关函数。最后DeactiveTool()用于终止活动工具。
UCLASS()
class UInteractiveToolManager : public UObject, public IToolContextTransactionProvider
{
void RegisterToolType(const FString& Identifier, UInteractiveToolBuilder* Builder);
bool SelectActiveToolType(const FString& Identifier);
bool ActivateTool();
void Tick(float DeltaTime);
void Render(IToolsContextRenderAPI* RenderAPI);
void DeactivateTool(EToolShutdownType ShutdownType);
};
4.1 工具生命周期
在高层次上,工具的生命周期如下
- ToolBuilder 注册到 ToolManager
- 一段时间后,用户表示他们希望启动工具(例如通过按钮)
- UI 代码集 Active ToolBuilder,请求工具激活
- ToolManager 检查 ToolBuilder.CanBuildTool() = true,如果是,则调用 BuildTool() 创建新实例
- ToolManager 调用 Tool Setup()
- 直到 Tool 被停用,它是 Tick()'d 和 Render()'d 每一帧
- 用户表示他们希望退出工具(例如通过按钮、热键等)
- ToolManager 使用适当的关闭类型调用 Tool Shutdown()
- 一段时间后,工具实例被垃圾收集
注意最后一步。工具是 UObject,因此你不能依赖 C++ 析构函数进行清理。你应该在 Shutdown() 实现中进行任何清理,例如销毁临时参与者。
4.2 EToolShutdownType 和接受/取消模型
工具可以以两种不同的方式支持终止,具体取决于工具支持的交互类型。更复杂的替代方案是可以接受 — EToolShutdownType::Accept 或取消 EToolShutdownType::Cancel 的工具。这通常在工具的交互支持某种操作的实时预览时使用,用户可能希望放弃该操作。例如,将网格简化算法应用于选定网格的工具可能具有用户可能希望探索的一些参数,但如果探索不令人满意,则用户可能更愿意根本不应用简化。在这种情况下,UI 可以提供按钮来接受或取消活动工具,这会导致使用适当的 EToolShutdownType 值调用 ToolManager::DeactiveTool()。
第二个终止选项 - EToolShutdownType::Completed - 更简单,因为它只是指示工具应该“退出”。这种类型的终止可用于处理没有明确的“接受”或“取消”操作的情况,例如在简单可视化数据的工具中,增量应用编辑操作的工具(例如基于点击点生成对象),等等。
需要明确的是,你在使用 ITF 时不需要使用或支持接受/取消式工具。这样做通常会导致更复杂的 UI。如果你在应用程序中支持 Undo,那么即使是具有 Accept 和 Cancel 选项的 Tools,也可以等效为 Complete-style Tools,如果用户不满意,也可以 Undo。但是,如果工具完成可能涉及冗长的计算或以某种方式具有破坏性,则支持接受/取消往往会带来更好的用户体验。在 UE 编辑器的建模模式中,我们通常在编辑静态网格体资源时使用 Accept/Cancel 正是出于这个原因。
你必须做出的另一个决定是如何处理工具的模态性质。通常,将用户视为“处于”工具中是有用的,即处于特定的模态状态。那么他们是如何“走出去”的呢?您可以要求用户明确单击接受/取消/完成按钮以退出活动工具,这是最简单和最明确的,但确实意味着需要单击,并且用户必须在心理上意识到并管理此状态。或者,当用户在工具工具栏/菜单/等中选择另一个工具时(例如),你可以自动接受/取消/完成。然而,这引发了一个棘手的问题,即应该自动接受还是自动取消。这个问题没有正确答案,你必须决定什么最适合你的特定环境 —虽然根据我的经验,当一个人意外误点击时,自动取消可能会非常令人沮丧!
4.3 基础工具
ITF 的主要目标之一是减少编写工具所需的样板代码量,并提高一致性。几个“工具模式”出现得如此频繁,以至于我们在 ITF 的 /BaseTools/ 子文件夹中包含了它们的标准实现。基本工具通常包括一个或多个 InputBehaviors(见下文),其操作映射到您可以覆盖和实现的虚拟功能。我将简要介绍这些基本工具中的每一个,因为它们既是构建您自己的工具的有用方式,也是如何做事的示例代码的良好来源:
USingleClickTool捕获鼠标单击输入,如果IsHitByClick()函数返回有效点击,则调用OnClicked()函数。您提供这两个的实现。请注意,此处的FInputDeviceRay结构包括 2D 鼠标位置和 3D 射线。
class INTERACTIVETOOLSFRAMEWORK_API USingleClickTool : public UInteractiveTool
{
FInputRayHit IsHitByClick(const FInputDeviceRay& ClickPos);
void OnClicked(const FInputDeviceRay& ClickPos);
};
UClickDragTool捕获并转发连续的鼠标输入,而不是单击。如果CanBeginClickDragSequence()返回 true —通常你会在此处进行命中测试,类似于 USingleClickTool,则将调用 OnClickPress() / OnClickDrag() / OnClickRelease(),类似于标准 OnMouseDown/Move/Up 事件模式。但是请注意,你必须在OnTerminateDragSequence()中处理序列中止但没有释放的情况。
class INTERACTIVETOOLSFRAMEWORK_API UClickDragTool : public UInteractiveTool
{
FInputRayHit CanBeginClickDragSequence(const FInputDeviceRay& PressPos);
void OnClickPress(const FInputDeviceRay& PressPos);
void OnClickDrag(const FInputDeviceRay& DragPos);
void OnClickRelease(const FInputDeviceRay& ReleasePos);
void OnTerminateDragSequence();
};
UMeshSurfacePointTool与 UClickDragTool 相似之处在于它提供了单击-拖动-释放输入处理模式。但是,UMesSurfacePointTool 假定它正在作用于一个目标 UPrimitiveComponent —它是如何获取这个 Component 的将在下面解释。下面HitTest()函数的默认实现将使用标准 LineTraces — 因此,如果足够的话,你不必重写此函数。UMeshSurfacePointTool 还支持悬停,并跟踪 Shift 和 Ctrl 修饰键的状态。对于简单的“表面绘图”类型工具,这是一个很好的起点,许多建模模式工具派生自 UMeshSurfacePointTool — 一个小提示:这个类也支持阅读手写笔压力,但是在 UE4.26 手写笔输入是 Editor-Only。
附注:虽然命名为 UMeshSurfacePointTool,但其实并不需要Mesh,只需要一个支持LineTrace的UPrimitiveComponent
class INTERACTIVETOOLSFRAMEWORK_API UMeshSurfacePointTool : public UInteractiveTool
{
bool HitTest(const FRay& Ray, FHitResult& OutHit);
void OnBeginDrag(const FRay& Ray);
void OnUpdateDrag(const FRay& Ray);
void OnEndDrag(const FRay& Ray);
void OnBeginHover(const FInputDeviceRay& DevicePos);
bool OnUpdateHover(const FInputDeviceRay& DevicePos);
void OnEndHover();
};
还有第四个基础工具,UBaseBrushTool,它扩展了 UMeshSurfacePointTool,具有各种特定于基于画笔的 3D 工具的功能,即表面绘画笔刷、3D 雕刻工具等。这包括一组标准画笔属性、一个 3D 画笔位置/大小/衰减指示器、“画笔印记”跟踪以及各种其他有用的位。如果你正在构建画笔式工具,可能会发现这很有用。
4.4 FToolBuilder状态
UInteractiveToolBuilder API 函数都采用 FToolBuilderState 参数。此结构的主要目的是提供选择信息 - 它指示工具将或应该采取的行动。结构的关键字段如下所示。ToolManager 将构造一个 FToolBuilderState 并将其传递给 ToolBuilders,然后 ToolBuilders 将使用它来确定它们是否可以对 Selection 进行操作。在 UE4.26 ITF 实现中,Actor 和 Components 都可以传递,但也只能传递 Actor 和 Components。请注意,如果一个组件出现在 SelectedComponents 中,那么它的 Actor 将在 SelectedActors 中。包含这些 Actor 的 UWorld 也包括在内。
struct FToolBuilderState
{
UWorld* World;
TArray<AActor*> SelectedActors;
TArray<UActorComponent*> SelectedComponents;
};
在建模模式工具中,我们不直接对组件进行操作,我们将它们包装在一个标准容器中,这样我们就可以,例如,3D 雕刻具有容器实现的“任何”网格组件。这在很大程度上是我可以编写本教程的原因,因为我可以让这些工具编辑其他类型的网格,例如运行时网格。但是在构建自己的工具时,你可以随意忽略 FToolBuilderState。你的 ToolBuilder 可以使用任何其他方式来查询场景状态,并且你的工具不限于作用于 Actor 或组件。
4.5 关于工具构建器
ITF 用户经常提出的一个问题是 UInteractiveToolBuilder 是否必要。在最简单的情况下,也就是最常见的情况下,你的 ToolBuilder 将是简单的样板代码 —不幸的是,因为它是一个 UObject,这个样板不能直接转换为 C++ 模板。当人们开始重新利用现有的 UInteractiveTool 实现来解决不同的问题时,ToolBuilders 的实用程序就会出现。
例如,在 UE 编辑器中,我们有一个用于编辑网格多边形组(实际上是多边形)的工具,称为 PolyEdit。我们还有一个非常相似的工具用于编辑网格三角形,称为 TriEdit。在引擎盖下,这些是相同的 UInteractiveTool 类。在 TriEdit 模式下,Setup() 函数将工具的各个方面配置为适合三角形。为了在 UI 中公开这两种模式,我们使用了两个独立的 ToolBuilder,它们在创建的 Tool 实例被分配之后、Setup() 运行之前设置了一个“bIsTriangleMode”标志。
我当然不会声称这是一个优雅的解决方案。但是,这是权宜之计。根据我的经验,随着你的工具集不断发展以处理新情况,这种情况总是会出现。通常可以通过一些自定义初始化、一些附加选项/属性等来填充现有工具来解决新问题。在理想世界中,人们会重构工具以通过子类化或组合来实现这一点,但我们很少生活在理想世界中。因此,破解工具以完成第二项工作所需的一些难看的代码可以放置在自定义 ToolBuilder 中,并(相对)封装在其中。
使用 ToolManager 注册 ToolBuilder 的基于字符串的系统可以允许你的 UI 级别(即按钮处理程序等)启动工具,而无需实际了解 Tool 类类型。这通常可以在构建 UI 时实现更清晰的关注点分离。例如,在我将在下面描述的 ToolsFrameworkDemo 中,工具是由 UMG 蓝图小部件启动的,它们只是将字符串常量传递给 BP 函数——它们根本不了解工具系统。 然而,在生成工具之前需要设置一个“活动”构建器有点像退化的肢体,这些操作可能会在未来结合起来。
5、输入行为系统
上面我说过“交互式工具是应用程序的模态状态,在此期间可以以特定方式捕获和解释设备输入”。但是 UInteractiveTool API 没有任何鼠标输入处理函数。这是因为输入处理(大部分)与工具分离。输入由工具创建并注册到UInputRouter的UInputBehavior对象捕获和解释, UInputRouter “拥有”输入设备并将输入事件路由到适当的行为。
这种分离的原因是绝大多数输入处理代码都是剪切和粘贴的,在特定交互的实现方式上略有不同。例如考虑一个简单的按钮点击交互。在一个常见的事件 API 中,您将拥有可以实现的 OnMouseDown()、OnMouseMove() 和 OnMouseUp() 等函数,假设你希望将这些事件映射到单个 OnClickEvent() 处理程序,以便按下按钮-释放动作。数量惊人的应用程序(尤其是 Web 应用程序)会触发 OnMouseDown 中的点击——这是错误的!但是,在 OnMouseUp 中盲目地触发 OnClickEvent 也是错误的!这里的正确行为实际上是相当复杂的。在 OnMouseDown() 中,你必须对按钮进行点击测试,并开始捕获鼠标输入。在 OnMouseUp 中,你必须点击测试按钮再次,如果光标仍在点击按钮,则仅触发 OnClickEvent。这允许取消点击,并且是所有严肃的 UI 工具包如何实现它(试试看!)。
如果你甚至拥有数十个工具,那么实现所有这些处理代码,特别是针对多个设备,将变得非常容易出错。因此,出于这个原因,ITF 将这些小的输入事件处理状态机移动到 UInputBehavior 实现中,这些实现可以在许多工具之间共享。事实上,一些简单的行为,如USingleClickInputBehavior、UClickDragBehavior和UHoverBehavior 可以处理大多数鼠标驱动交互的情况。然后,行为通过工具或 Gizmo 等可以实现的简单接口将其提炼的事件转发到目标对象。例如 USingleClickInputBehavior 可以作用于任何实现 IClickBehaviorTarget 的东西,它只有两个函数 - IsHitByClick() 和 OnClicked()。请注意,由于 InputBehavior 不知道它作用于什么——“按钮”可以是 2D 矩形或任意 3D 形状——Target 接口必须提供命中测试功能。
InputBehavior 系统的另一个方面是工具不直接与 UInputRouter 对话。他们只提供他们希望激活的 UInputBehavior 的列表。UInteractiveTool API 添加的支持此功能如下所示。通常,在工具的 ::Setup() 实现中,会创建和配置一个或多个输入行为,然后将其传递给 AddInputBehavior。然后,ITF 在必要时调用 GetInputBehaviors,将这些行为注册到 UInputRouter。注意:目前 InputBehavior 集不能在工具期间动态更改,但是您可以配置您的 Behaviors 以根据您希望的任何标准忽略事件。
class UInteractiveTool : public UObject, public IInputBehaviorSource
{
// ...previous functions...
void AddInputBehavior(UInputBehavior* Behavior);
const UInputBehaviorSet* GetInputBehaviors();
};
UInputRouter与UInteractiveToolManager的相似之处在于默认实现足以满足大多数用途。InputRouter 的唯一工作是跟踪所有活动的 InputBehavior 并调解捕获的输入设备。捕获是工具中输入处理的核心。当 MouseDown 事件进入 InputRouter 时,它会检查所有已注册的 Behaviors 以询问它们是否要开始捕获鼠标事件流。例如,如果您按下一个按钮,该按钮注册的 USingleClickInputBehavior 将表明是的,它想要开始捕获。一次只允许单个行为捕获输入,并且可能需要捕获多个行为(彼此不了解) - 例如,与当前视图重叠的 3D 对象。因此,每个 Behavior 返回一个 FInputCaptureRequest,指示“是”或“否”以及深度测试和优先级信息。UInputRouter 然后查看所有捕获请求,并根据深度排序和优先级,选择一个行为并告诉它捕获将开始。然后 MouseMove 和 MouseRelease 事件仅传递给该行为,直到 Capture 终止(通常在 MouseRelease 上)。
实际上,在使用 ITF 时,你很少需要与 UInputRouter 交互。一旦建立了应用程序级鼠标事件和 InputRouter 之间的连接,你就不需要再次触摸它了。该系统主要处理常见错误,例如由于捕获出错而导致鼠标处理“卡住”,因为 UInputRouter 最终控制鼠标捕获,而不是单个行为或工具。在随附的 ToolsFrameworkDemo 项目中,我已经实现了 UInputRouter 运行所需的一切。
基本的 UInputBehavior API 如下所示。FInputDeviceState是一个大型结构,包含给定事件/时间的所有输入设备状态,包括常用修饰键的状态、鼠标按钮状态、鼠标位置等。与许多输入事件的一个主要区别是还包括与输入设备位置相关的 3D 世界空间射线。
UCLASS()
class UInputBehavior : public UObject
{
FInputCapturePriority GetPriority();
EInputDevices GetSupportedDevices();
FInputCaptureRequest WantsCapture(const FInputDeviceState& InputState);
FInputCaptureUpdate BeginCapture(const FInputDeviceState& InputState);
FInputCaptureUpdate UpdateCapture(const FInputDeviceState& InputState);
void ForceEndCapture(const FInputCaptureData& CaptureData);
// ... hover support...
}
我在上面的 API 中省略了一些额外的参数,以简化事情。特别是如果你实现自己的行为,你会发现几乎到处都有一个 EInputCaptureSide 枚举,主要作为默认的 EInputCaptureSide::Any。这是为了将来使用,以支持行为可能特定于任一手的 VR 控制器的情况。
但是,对于大多数应用程序,你可能会发现实际上不必实现自己的行为。一组标准行为,例如上面提到的那些,包含在 InteractiveToolFramework 模块的 /BaseBehaviors/ 文件夹中。大多数标准行为都是从基类UAnyButtonInputBehavior 派生的,它允许它们使用任何鼠标按钮,包括由 TFunction(可能是键盘键)定义的“自定义”按钮!类似地,标准 BehaviorTarget 实现都派生自IModifierToggleBehaviorTarget,它允许在 Behavior 上配置任意修饰键并将其转发到 Target,而无需子类化或修改 Behavior 代码。
直接使用 UInputBehaviors
在上面的讨论中,我重点讨论了 UInteractiveTool 提供 UInputBehaviorSet 的情况。Gizmos 将类似地工作。但是,UInputRouter 本身根本不知道 Tools,完全可以单独使用 InputBehavior 系统。在 ToolsFrameworkDemo 中,我在USceneObjectSelectionInteraction类中以这种方式实现了点击选择网格交互。这个类实现了 IInputBehaviorSource 和 IClickBehaviorTarget 本身,并且只属于框架后端子系统。即使这不是绝对必要的 - 您可以直接使用 UInputRouter 注册您自己创建的 UInputBehavior (但是请注意,由于我对 API 的疏忽,在 UE4.26 中您无法显式注销单个行为,您只能通过源注销)。
5.1 非鼠标输入设备
UE4.26 ITF 实现中当前未处理其他设备类型,但是 frame3Sharp 中此行为系统的先前迭代支持触摸和 VR 控制器输入,并且这些应该(最终)在 ITF 设计中类似地工作。一般的想法是只有 InputRouter 和 Behaviors 需要明确了解不同的输入模式。IClickBehaviorTarget 实现应该与鼠标按钮、手指点击或 VR 控制器点击类似地工作,但也不排除为特定于设备的交互(例如,来自两指捏合、空间控制器手势等)定制的额外行为目标. 工具可以为不同的设备类型注册不同的行为,InputRouter 将负责处理哪些设备是活动的和可捕获的。
目前,可以通过映射到鼠标事件来完成对其他设备类型的某种程度的处理。由于 InputRouter 不直接监听输入事件流,而是由 ITF 后端创建和转发事件,这是做这种映射的自然场所,下面将解释更多细节。
5.2 限制 - 捕获中断
在设计交互时需要注意的这个系统的一个重要限制是,框架尚不支持主动捕获的“中断”。当人们希望进行单击或拖动的交互时,这种情况最常见,具体取决于鼠标是立即在同一位置释放还是移动了某个阈值距离。在简单的情况下,这可以通过UClickDragBehavior处理,由你的 IClickDragBehaviorTarget 实现做出决定。但是,如果单击和拖动动作需要去到彼此不知道的非常不同的地方,这可能会很痛苦。支持这种交互的一种更简洁的方法是允许一个 UInputBehavior “中断”另一个 - 在这种情况下,当满足先决条件(即足够的鼠标移动)时,拖动以“中断”单击的活动捕获。这是 ITF 未来可能会改进的一个领域。
6、工具属性集
UInteractiveTool 还有一组我没有介绍的 API 函数,用于管理一组附加的UInteractiveToolPropertySet对象。这是一个完全可选的系统,在某种程度上是为在 UE 编辑器中使用而量身定制的。对于运行时使用,它不太有效。本质上,UInteractiveToolPropertySet 用于存储你的工具设置和选项。它们是具有 UProperties 的 UObject,在编辑器中,这些 UObject 可以添加到 Slate DetailsView 以在编辑器 UI 中自动公开这些属性。
额外的 UInteractiveTool API 总结如下。一般在Tool ::Setup()函数中,会创建各种UInteractiveToolPropertySet子类并传递给AddToolPropertySource()。ITF 后端将使用 GetToolProperties() 函数初始化 DetailsView 面板,然后 Tool 可以使用 SetToolPropertySourceEnabled() 动态显示和隐藏属性集
class UInteractiveTool : public UObject, public IInputBehaviorSource
{
// ...previous functions...
public:
TArray<UObject*> GetToolProperties();
protected:
void AddToolPropertySource(UObject* PropertyObject);
void AddToolPropertySource(UInteractiveToolPropertySet* PropertySet);
bool SetToolPropertySourceEnabled(UInteractiveToolPropertySet* PropertySet, bool bEnabled);
};
在 UE 编辑器中,可以使用元标记来标记 UProperties 以控制生成的 UI 小部件 - 例如滑块范围、有效整数值以及基于其他属性的值启用/禁用小部件。建模模式中的大部分 UI 都是以这种方式工作的。
不幸的是,UProperty 元标记在运行时不可用,并且 UMG 小部件不支持 DetailsView 面板。结果,ToolPropertySet 系统变得不那么引人注目了。不过,它仍然提供了一些有用的功能。一方面,属性集支持使用属性集的 SaveProperties() 和 RestoreProperties() 函数跨工具调用保存和恢复其设置。您只需在 Tool Shutdown() 中设置的每个属性上调用 SaveProperties(),并在 ::Setup() 中调用 RestoreProperties()。
第二个有用的功能是 WatchProperty() 函数,它允许响应 PropertySet 值的更改而无需任何类型的更改通知。这对于 UObject 是必要的,因为 C++ 代码可以直接更改 UObject 上的 UProperty,这不会导致发送任何类型的更改通知。因此,可靠检测此类更改的唯一方法是通过轮询。是的,投票。这并不理想,但请考虑 (1) 工具必须具有有限数量的用户可以处理的属性,以及 (2) 一次只有一个工具处于活动状态。为了让您不必为 ::OnTick() 中的每个属性实现存储值比较,您可以使用以下模式添加观察者:
MyPropertySet->WatchProperty( MyPropertySet->bBooleanProp, [this](bool bNewValue) { // handle change! } );
在 UE4.26 中,有一些额外的警告(阅读:错误)必须解决,请参阅下文了解更多详细信息。
7、工具操作
最后,UInteractiveTool API 的最后一个主要部分是对Tool Actions的支持。这些在建模模式工具集中没有广泛使用,除了实现热键功能。但是,工具操作与热键没有特别的关系。它们允许工具公开可以通过整数标识符调用的“动作”(即无参数函数)。Tool 构造并返回一个FInteractiveToolActionSet,然后更高级别的客户端代码可以枚举这些操作,并使用下面定义的ExecuteAction函数执行它们。
class UInteractiveTool : public UObject, public IInputBehaviorSource
{
// ...previous functions...
public:
FInteractiveToolActionSet* GetActionSet();
void ExecuteAction(int32 ActionID);
protected:
void RegisterActions(FInteractiveToolActionSet& ActionSet);
};
下面的示例代码显示了两个正在注册的工具操作。请注意,尽管FInteractiveToolAction包含热键和修饰符,但这些只是对更高级别客户端的建议。UE 编辑器查询操作的工具,然后将建议的热键注册为编辑器热键,这允许用户重新映射它们。UE在运行时没有任何类似的热键系统,您需要自己手动映射这些热键
void UDynamicMeshSculptTool::RegisterActions(FInteractiveToolActionSet& ActionSet)
{
ActionSet.RegisterAction(this, (int32)EStandardToolActions::BaseClientDefinedActionID + 61,
TEXT("SculptDecreaseSpeed"),
LOCTEXT("SculptDecreaseSpeed", "Decrease Speed"),
LOCTEXT("SculptDecreaseSpeedTooltip", "Decrease Brush Speed"),
EModifierKey::None, EKeys::W,
[this]() { DecreaseBrushSpeedAction(); });
ActionSet.RegisterAction(this, (int32)EStandardToolActions::ToggleWireframe,
TEXT("ToggleWireframe"),
LOCTEXT("ToggleWireframe", "Toggle Wireframe"),
LOCTEXT("ToggleWireframeTooltip", "Toggle visibility of wireframe overlay"),
EModifierKey::Alt, EKeys::W,
[this]() { ViewProperties->bShowWireframe = !ViewProperties->bShowWireframe; });
}
最终,每个 ToolAction 有效负载都存储为 TFunction<void()>。如果你只是转发到另一个 Tool 函数,比如上面的 DecreaseBrushSpeedAction() 调用,你不一定受益于 ToolAction 系统,根本不需要使用它。然而,由于当前工具暴露于蓝图的限制,ToolActions(因为它们可以通过一个简单的整数调用)可能是一种将工具功能暴露给 BP 的有效方法,而无需编写许多包装函数。
8、小玩意儿
正如我所提到的,“Gizmo”是指我们在 2D 和 3D 内容创建/编辑应用程序中使用的那些在视口内点击的小东西,可以让你有效地操纵视觉元素或对象的参数。例如,如果您使用过任何 3D 工具,那么你几乎肯定使用过标准的平移/旋转/缩放 Gizmo。与工具一样,Gizmo 捕获用户输入,但不是完整的 Modal 状态,Gizmo 通常是瞬态的,即 Gizmo 可以来来去去,并且你可以同时激活多个 Gizmo,它们仅在你单击时捕获输入“开”他们(“开”的意思可能有点模糊)。正因为如此,Gizmo 通常需要一些特定的可视化表示,以允许用户指示他们何时想要“使用”Gizmo,但从概念上讲,你也可以拥有基于热键或应用程序状态(例如复选框)执行此操作的 Gizmo。
在 Interactive Tools Framework 中,Gizmo 被实现为UInteractiveGizmo的子类,它与 UInteractiveTool 非常相似:
UCLASS()
class UInteractiveGizmo : public UObject, public IInputBehaviorSource
{
void Setup();
void Shutdown();
void Render(IToolsContextRenderAPI* RenderAPI);
void Tick(float DeltaTime);
void AddInputBehavior(UInputBehavior* Behavior);
const UInputBehaviorSet* GetInputBehaviors();
}
同样,Gizmo 实例由UInteractiveGizmoManager管理,使用通过字符串注册的UInteractiveGizmoBuilder工厂。Gizmo 使用相同的 UInputBehavior 设置,并且由 ITF 每帧进行类似渲染和勾选。
在这个高层次上,UInteractiveGizmo 只是一个骨架,要实现自定义 Gizmo,你必须自己做很多工作。与工具不同,提供“基础”小玩意儿更具挑战性,因为它具有视觉表示方面。特别是,标准的 InputBehaviors 将要求你能够对 Gizmo 进行光线投射命中测试,因此不能只在 Render() 函数中绘制任意几何图形。也就是说,ITF 确实提供了一个非常灵活的标准 Translate-Rotate-Scale Gizmo 实现,可以重新利用它来解决许多问题。
8.1 标准 UTransformGizmo
如果 ITF 不包含标准的平移-旋转-缩放 (TRS) Gizmos,那么将 ITF 称为构建 3D 工具的框架将是非常有问题的。目前在 UE4.26 中可用的是一个名为UTransformGizmo的组合 TRS Gizmo(右侧屏幕截图) ,它支持轴和平面平移(轴线和中心人字形)、轴旋转(圆)、统一比例(中心框)、轴比例(外轴括号)和平面刻度(外人字形)。这些子 Gizmo 可以单独配置,因此你可以(例如)通过将某些枚举值传递给 Gizmo 构建器来创建仅具有 XY 平面平移和 Z 旋转的 UTransformGizmo 实例。
这个 TRS Gizmo 不是一个单一的整体 Gizmo,它是由一组可以重新用于许多其他用途的部件组成的。这个子系统足够复杂,值得单独写一篇文章,但总而言之,我上面提到的 UTransformGizmo 的每个元素实际上都是一个单独的 UInteractiveGizmo(所以,是的,你可以有嵌套/分层 Gizmo,你可以继承 UTransformGizmo 来添加额外的自定义控件)。例如,轴平移子 Gizmo(绘制为红/绿/蓝线段)是UAxisPositionGizmo的实例,旋转圆是UAxisAngleGizmo。
像 UAxisPositionGizmo 这样的子 Gizmo 并没有显式地绘制上图中的线条。相反,它们连接到提供视觉表示和命中测试的任意 UPrimitiveComponent。因此,如果你愿意,可以使用任何 UStaticMesh。默认情况下,UTransformGizmo 生成自定义 Gizmo 特定的 UPrimitiveComponents,在线条的情况下,它是一个UGizmoArrowComponent。这些 GizmoComponents 提供了一些细节,如恒定的屏幕空间尺寸、悬停支持等。但是你绝对不必使用它们,并且 Gizmo 外观可以完全根据你的目的进行定制(未来以 Gizmo 为重点的文章的主题!)。
因此,UAxisPositionGizmo 实际上只是“根据鼠标输入沿线指定位置”这一抽象概念的实现。3D 线、线位置到抽象参数的映射(默认情况下为 3D 世界位置)以及状态变化信息都通过 UInterfaces 实现,因此可以根据需要进行自定义。视觉表示只是为了通知用户,并为捕获鼠标的 InputBehavior 提供命中目标。这允许以最小的难度集成任意捕捉或参数约束等功能。
但是,这都是旁白。实际上,要使用 UTransformGizmo,你只需使用以下调用之一从 GizmoManager 请求一个:
class UInteractiveGizmoManager
{
// ...
UTransformGizmo* Create3AxisTransformGizmo(void* Owner);
UTransformGizmo* CreateCustomTransformGizmo(ETransformGizmoSubElements Elements, void* Owner);
}
然后创建一个UTransformProxy实例并将其设置为 Gizmo 的目标。Gizmo 现在将具有完整功能,你可以在 3D 场景中移动它,并通过 UTransformProxy::OnTransformChanged 委托响应变换更改。可以使用各种其他委托,例如开始/结束转换交互。基于这些委托,你可以变换场景中的对象、更新对象的参数等。
稍微复杂一点的用法是,如果你希望 UTransformProxy 直接移动一个或多个 UPrimitiveComponent,即实现几乎每个 3D 设计应用程序都有的普通“选择对象并使用 gizmo 移动它们”类型的界面。在这种情况下,可以将组件添加为代理的目标。Gizmo 仍然作用于 UTransformProxy,并且 Proxy 将单个变换重新映射到对象集上的相对变换。
UTransformGizmo 不必为工具所有。在 ToolsFrameworkDemo 中,USceneObjectTransformInteraction类监视运行时对象场景中的选择变化,如果存在活动选择,则生成合适的新 UTransformGizmo。代码只有几行:
TransformProxy = NewObject<UTransformProxy>(this);
for (URuntimeMeshSceneObject* SceneObject : SelectedObjects)
{
TransformProxy->AddComponent(SO->GetMeshComponent());
}
TransformGizmo = GizmoManager->CreateCustomTransformGizmo(ETransformGizmoSubElements::TranslateRotateUniformScale, this);
TransformGizmo->SetActiveTarget(TransformProxy);
在这种情况下,我将传递ETransformGizmoSubElements::TranslateRotateUniformScale以创建没有非均匀缩放子元素的 TRS gizmo。要销毁 Gizmo,代码只需调用 DestroyAllGizmosByOwner,传递创建期间使用的相同 void* 指针:
GizmoManager->DestroyAllGizmosByOwner(this);
UTransformGizmo 自动发出必要的撤消/重做信息,这将在下面进一步讨论。因此,只要使用中的 ITF 后端支持撤消/重做,Gizmo 转换也将支持。
8.2 本地与全球坐标系
UTransformGizmo 支持局部和全局坐标系。默认情况下,它从 ITF 后端请求当前的本地/全局设置。在 UE 编辑器中,其控制方式与默认 UE 编辑器 Gizmo 相同,方法是在主视口顶部使用相同的世界/本地切换。你也可以覆盖此行为,请参阅 UTransformGizmoBuilder 标头中的注释。
一个警告,不过。UE4 仅支持组件的局部坐标系中的非均匀缩放变换。这是因为在大多数情况下,不能将具有非均匀缩放的两个单独的 FTransform 组合成一个 FTransform。因此,在全局模式下,TRS Gizmo 将不会显示非均匀缩放手柄(轴括号和外角 V 形)。默认的 UE 编辑器 Gizmo 具有相同的限制,但通过仅允许在缩放 Gizmo 中使用本地坐标系(不与平移和旋转 Gizmo 组合)来处理它。
9、工具上下文和 ToolContext API
在这一点上,我们有 Tools 和 ToolManager,还有 Gizmos 和 GizmoManager,但谁来管理 Manager?为什么,当然是上下文。UInteractiveToolsContext是交互工具框架的最顶层。它本质上是工具和 Gizmo 所在的“宇宙”,并且还拥有 InputRouter。默认情况下,你可以简单地使用此类,这就是我在 ToolsFrameworkDemo 中所做的。在 ITF 的 UE 编辑器使用中,有一些子类可以调解 ITF 和更高级别的编辑器构造(如 FEdMode)之间的通信(例如,参见UEdModeInteractiveToolsContext)。
ToolsContext 还为 Managers 和 InputRouter 提供了各种 API 的实现,这些 API 提供了“类似编辑器”的功能。这些 API 的目的本质上是提供“编辑器”的抽象,这使我们能够防止 ITF 具有显式的虚幻编辑器依赖项。在上面的文字中,我多次提到“ITF 后端”——这就是我所指的。
如果仍然不清楚我所说的“编辑器的抽象”是什么意思,也许可以举个例子。我还没有提到任何关于对象选择的内容。这是因为选定对象的概念在很大程度上超出了 ITF 的范围。当 ToolManager 去构造一个新工具时,它会传递一个选定的 Actor 和组件的列表。但是它通过询问工具上下文来获得这个列表。而且工具上下文也不知道。工具上下文需要通过IToolsContextQueriesAPI询问创建它的应用程序。这个周围的应用程序必须创建 IToolsContextQueriesAPI 的实现并将其传递给构造时的 ToolsContext。
ITF 无法以通用方式解决“对象选择的工作原理”,因为这高度依赖于您的应用程序。在 ToolsFrameworkDemo 中,我实现了一个基本的网格对象和选择列表机制,其行为类似于大多数 DCC 工具。虚幻编辑器在主视口中有一个类似的系统。但是,在资产编辑器中,只有一个对象,根本没有选择。所以 Asset Editors 中使用的 IToolsContextQueriesAPI 是不同的。如果你在游戏环境中使用 ITF,您可能会对什么是“选择”,甚至什么是“对象”有一个非常不同的概念。
因此,我们使用 ToolContext API 的目标是需要最少的函数集,以允许工具在“类似编辑器的容器”中工作。随着需要查询编辑器容器的新情况的出现,这些 API 随着时间的推移而增长。它们在文件 ToolContextInterfaces.h 中定义并总结如下
9.1 IToolsContextQueriesAPI
该 API 提供了从 Editor 容器中查询状态信息的功能。最关键的是GetCurrentSelectionState(),ToolManager 将使用它来确定要传递给 ToolBuilder 的选定参与者和组件。在使用 ITF 时,你可能需要对此进行自定义实现。许多工具和 TRS Gizmo 也需要GetCurrentViewState()才能正常工作,因为它提供 3D 相机/视图信息。然而,ToolsFrameworkDemo 中的示例实现可能足以满足任何运行时使用,即标准全屏单一 3D 视图。这里的其他函数可以有简单的实现,只返回一个默认值。
class IToolsContextQueriesAPI
{
void GetCurrentSelectionState(FToolBuilderState& StateOut);
void GetCurrentViewState(FViewCameraState& StateOut);
EToolContextCoordinateSystem GetCurrentCoordinateSystem();
bool ExecuteSceneSnapQuery(const FSceneSnapQueryRequest& Request, TArray<FSceneSnapQueryResult>& Results );
UMaterialInterface* GetStandardMaterial(EStandardToolContextMaterials MaterialType);
}
9.2 IToolsContextTransactionsAPI
IToolsContextTransactionsAPI主要用于将数据发送回编辑器容器。DisplayMessage()由工具调用,其中包含各种用户信息消息、错误和状态消息等。如果愿意,可以忽略这些。PostInvalidation()用于指示需要重绘,在引擎以最大/固定帧速率持续重绘的运行时上下文中,通常可以忽略这一点。RequestSelectionChange()是某些工具提供的提示,通常在它们创建新对象时提供,可以忽略。
class IToolsContextTransactionsAPI
{
void DisplayMessage(const FText& Message, EToolMessageLevel Level);
void PostInvalidation();
bool RequestSelectionChange(const FSelectedOjectsChangeList& SelectionChange);
void BeginUndoTransaction(const FText& Description);
void AppendChange(UObject* TargetObject, TUniquePtr<FToolCommandChange> Change, const FText& Description);
void EndUndoTransaction();
}
追加更改()由想要发出 FCommandChange 记录 — 实际上是 FToolCommandChange 子类 — 的工具调用,这是 ITF 撤消/重做方法的核心组件。为了理解为什么这个设计是这样的,我必须解释一下撤销/重做是如何在 UE 编辑器中工作的。
编辑器不使用命令对象/模式方法来撤消/重做,这通常是大多数 3D 内容创建/编辑工具执行此操作的方式。相反,编辑器使用事务系统。打开事务后,对任何即将被修改的对象调用 UObject::Modify(),这会保存所有 UObject 当前 UProperty 值的副本。当 Transaction 关闭时,比较修改对象的 UProperties,并序列化任何更改。这个系统真的是对像 UObjects 这样的东西的唯一方法,可以通过 UProperties 拥有任意用户定义的数据。
然而,众所周知,事务系统在处理大型复杂数据结构(如网格)时表现不佳。例如,将任意部分更改存储为一个巨大的网格作为事务将涉及预先制作完整副本,然后搜索和编码对复杂网格数据结构(本质上是非结构化图)的更改。这是一个非常困难(阅读:慢)的计算问题。类似地,一个简单的 3D 平移将修改每个顶点,需要一个 Transaction 中所有位置数据的完整副本,但在 Change 中可以仅存储为平移向量和一些关于应用什么操作的信息,然后搜索并编码对复杂网格数据结构(本质上是非结构化图)的更改。这是一个非常困难(阅读:慢)的计算问题。
因此,在构建 ITF 时,我们添加了对在 UE 编辑器事务中嵌入 FCommandChange 对象的支持。这有点杂乱无章,但通常有效,并且有用的副作用是这些 FCommandChanges 也可以在不存在 UE 编辑器事务系统的运行时使用。当用户与工具交互时,我们的大多数建模模式工具都会不断调用 AppendChange(),而 Gizmo 也这样做。因此,我们可以构建一个基本的撤消/重做历史系统,只需按照它们进入的顺序存储这些更改,然后在撤消/重做列表中后退/前进,在每个 FToolCommandChange 对象上调用 Revert()/Apply() .
BeginUndoTransaction()和EndUndoTransaction()是相关函数,它们标记应分组的一组变更记录的开始和结束 - 通常 AppendChange() 将在其间被调用一次或多次。为了提供正确的用户体验 - 即单个撤消/重做热键/命令一次处理所有更改 - ToolsFrameworkDemo 有一个非常基本的系统,用于存储一组 FCommandChanges。
IToolsContextRenderAPI
此 API 被传递给 UInteractiveTool::Render() 和 UInteractiveGizmo::Render() 以提供常见渲染任务所需的信息。GetPrimitiveDrawInterface()返回抽象FPrimitiveDrawInterface API 的实现,这是一个标准的 UE 接口,提供线和点绘制功能(通常缩写为 PDI)。各种工具使用 PDI 来绘制基本的线反馈,例如在绘制多边形工具中绘制的当前多边形的边缘。但是请注意,运行时的 PDI 线图与编辑器中的 PDI 线图不同 - 它的质量较低,无法绘制编辑器可以绘制的隐藏线。
GetCameraState()、GetSceneView() 和 GetViewInteractionState() 返回有关当前视图的信息。这些在编辑器中很重要,因为用户可能有多个可见的 3D 视口(例如,在 4-up 视图中),并且工具必须在每个视口中正确绘制。在运行时,通常只有一个相机/视图,您应该可以使用 ToolsFramworkDemo 中的基本实现。但是,如果您想实现多个视图,则需要在此 API 中正确提供它们。
class IToolsContextRenderAPI
{
FPrimitiveDrawInterface* GetPrimitiveDrawInterface();
FViewCameraState GetCameraState();
const FSceneView* GetSceneView();
EViewInteractionState GetViewInteractionState();
}
9.3 IToolsContextAssetAPI
ITooslContextAssetAPI 可用于发出新对象。这是一个可选的 API,我只列出了下面的顶级函数,API 还包含其他一些特定于 UE 编辑器的函数。这是最难抽象的部分,因为它需要一些关于“对象”是什么的固有假设。但是,它也不是您必须在自己的工具中使用的东西。GenerateStaticMeshActor( )编辑器建模工具使用该函数来生成新的静态网格体资源/组件/Actor,例如在绘制多边形工具中,使用拉伸多边形(AssetConfig 参数的一部分)调用此函数来创建资源。此创建过程涉及查找位置(可能会产生对话框/等)、创建新包等。
class IToolsContextAssetAPI
{
AActor* GenerateStaticMeshActor(
UWorld* TargetWorld,
FTransform Transform,
FString ObjectBaseName,
FGeneratedStaticMeshAssetConfig&& AssetConfig);
}
在运行时,你不能创建资产,所以这个函数必须做“其他事情”。在 ToolsFrameworkDemo 中,我实现了 GenerateStaticMeshActor(),这样一些建模模式工具(如 Draw Polygon Tool)就可以正常工作了。但是,它会发出完全不同的 Actor 类型。
10、组件选择和PrimitiveComponentTargets
在上面的工具和工具构建器部分中,我描述了 FToolBuilderState,以及 ToolManager 如何构造一个选定的 Actor 和组件列表以传递给 ToolBuilder。如果你的工具应该作用于Actor或组件,可以将该选择传递给新的工具实例。但是,如果你浏览建模模式工具代码,会看到大多数工具都作用于称为FPrimitiveComponentTarget的东西,它是在 ToolBuilders 中基于选定的 UPrimitiveComponents 创建的。我们有基类USingleSelectionTool和UMultiSelectionTool,大多数建模模式工具都派生自它们,它们包含这些选择。
如果你是从头开始构建自己的工具,这不是你需要做的事情。但是,如果你想利用建模模式工具,你需要理解它,所以我会解释。FPrimitiveComponentTarget 的目的是为工具提供“可以编辑的网格”的抽象。这很有用,因为我们在 Unreal 中有许多不同的 Mesh 类型(您可能有自己的)。有 FMeshDescription(UStaticMesh 使用)、USkeletalMesh、FRawMesh、Cloth Meshes、Geometry Collections(即网格)等等。必须操纵低级网格数据结构的网格编辑工具本质上需要许多并行代码路径来支持其中的每一个。此外,在 Unreal 中更新网格的成本很高。正如我在之前的教程中所解释的,当您在 UStaticMesh 中修改 FMeshDescription 时,“构建”步骤是重新生成渲染数据所必需的,这在大型网格上可能需要几秒钟。这在例如用户期望即时反馈的 3D 雕刻工具中是不可接受的。
因此,通常建模模式工具不能直接编辑上面列出的任何 UE 组件网格格式。相反,ToolBuilder 将目标组件包装在 FPrimitiveComponentTarget 实现中,该实现必须提供 API 来读取和写入其内部网格(无论格式如何)作为 FMeshDescription。这允许想要编辑网格以支持单一标准输入/输出格式的工具,但需要(潜在)网格转换成本。在大多数建模模式工具中,我们随后将该 FMeshDescription 转换为 FDynamicMesh3 以进行实际编辑,并创建一个新的 USimpleDynamicMeshComponent 用于快速预览,并且仅在 Tool Accept 上写回更新的 FMeshDescription。但这被封装在 Tool 内部,与 FPrimtiveComponentTarget 没有真正的关系。
10.1 FComponentTargetFactory
我们需要允许交互式工具框架为它不知道的组件创建一个 FPrimitiveComponentTarget 子类包装器 —因为许多组件是 ITF 不可见的插件的一部分。例如,UProceduralMeshComponent 或 USimpleDynamicMeshComponent。为此,我们提供了一个 FComponentTargetFactory 实现,它有两个功能:
class INTERACTIVETOOLSFRAMEWORK_API FComponentTargetFactory
{
public:
virtual bool CanBuild( UActorComponent* Candidate ) = 0;
virtual TUniquePtr<FPrimitiveComponentTarget> Build( UPrimitiveComponent* PrimitiveComponent ) = 0;
};
这些一般都很简单,举个例子,见EditorComponentSourceFactory.cpp中的FStaticMeshComponentTargetFactory,它为UStaticMeshComponents构建了FStaticMeshComponentTarget实例。在这种情况下,FStaticMeshComponentTarget 也很简单。我们将利用这个 API 来解决下面运行时使用的一些问题。
最后,一旦 FComponentTargetFactory 可用,全局函数AddComponentTargetFactory()用于注册它。不幸的是,在 UE4.26 中,此函数将工厂存储在全局静态 TArray 中,该 TArray 对 ComponentSourceInterfaces.cpp 是私有的,因此无法以任何方式修改或操作。在启动时,编辑器将注册默认的 FStaticMeshComponentTargetFactory 以及处理 PMC 的 FProceduralMeshComponentTargetFactory。这两个工厂都存在无法在运行时用于网格编辑工具的问题,因此,在改进此系统之前,我们不能使用 SMC 或 PMC 进行运行时网格编辑。我们将为 USimpleDynamicMeshComponent 创建一个新的 ComponentTarget(有关此网格组件类型的详细信息,请参阅之前的教程)。
10.2 ToolBuilderUtil.h
如果你查看大多数工具的 ToolBuilders,会发现 CanBuildTool() 和 BuildTool() 实现通常调用ToolBuilderUtil命名空间中的静态函数,以及函数CanMakeComponentTarget()和MakeComponentTarget()。后两个函数通过已注册的 ComponentTargetFactory 实例列表进行枚举,以确定任何工厂是否可以处理特定的 UPrimitiveComponent 类型。ToolBuilderUtil 函数在很大程度上只是迭代FToolBuilderState中的选定组件(如上所述)并调用 lambda 谓词(通常是上述函数之一)。
我将在这里重申,你不需要在自己的工具中使用 FPrimitiveComponentTarget 系统,甚至不需要在 FToolBuilderState 中使用。你可以在 ToolBuilders 中轻松查询其他(全局)选择系统,检查目标组件类型的强制转换,并将 UPrimitiveComponent* 或子类传递给您的工具。然而,正如我所提到的,建模模式工具以这种方式工作,它将成为我现在将描述的运行时网格编辑工具框架设计的重要驱动力。
11、运行时工具框架后端
为交互式工具框架创建运行时后端并不是那么复杂。我们要弄清楚的主要事情是:
- 如何收集鼠标输入事件(即鼠标向下/移动/向上)并将此数据发送到UInputRouter
- 如何实现IToolsContextQueriesAPI和IToolsContextRenderAPI
- (可选)如何实现IToolsContextTransactionsAPI和IToolsContextAssetAPI
- 如何/何时 Render() 和 Tick() UInteractiveToolManager和UInteractiveGizmoManager
就是这样。一旦这些事情完成(甚至跳过第 3 步),那么基本的工具和Gizmo (甚至UTransformGizmo)就会起作用。
在这个示例项目中,完成上述所有相关代码都在RuntimeToolsSystem模块中,分为四个子目录:
- RuntimeToolsFramework\ - 包含核心工具框架实现
- MeshScene\ - 一个简单的网格对象“场景图”,这是我们的网格编辑工具将编辑的内容,以及一个基本的历史(即撤消/重做)系统
- Interaction\ - 使用 UTransformGizmo 进行对象选择和转换的基本用户界面交互,建立在 ITF 之上
- 工具\ - 几个 MeshModelingToolset UInteractiveTools 和/或 Builders 的子类,需要让它们在运行时正常运行
在高层次上,这里是所有东西的连接方式,用简单的英语(希望这会让下面的描述更容易理解)。自定义游戏模式AToolsFrameworkDemoGameModeBase在 Play 上初始化,这反过来又初始化了管理工具框架的URuntimeToolsFrameworkSubsystem和URuntimeMeshSceneSubsystem。后者管理一组URuntimeMeshSceneObject,它们是围绕网格 Actor 和组件的包装,可以通过单击选择并使用 UTransformGizmo 进行转换。URuntimeToolsFrameworkSubsystem 初始化并拥有 UInteractiveToolsContext,以及各种帮助类,如USceneObjectSelectionInteraction(实现单击选择)、USceneObjectTransformInteraction(管理变换 Gizmo 状态)和USceneHistoryManager(提供撤消/重做系统)。URuntimeToolsFrameworkSubsystem 还创建了一个UToolsContextRenderComponent,用于允许在工具和 Gizmo 中进行 PDI 渲染。在内部,URuntimeToolsFrameworkSubsystem 还定义了各种 API 实现,这些都完全包含在 cpp 文件中。最后一块是 Game Mode 的默认 Pawn,它是由 GameMode 在 Play 上生成的AToolsContextActor 。这个 Actor 监听各种输入事件并将它们转发到 URuntimeToolsFrameworkSubsystem。一种FSimpleDynamicMeshComponentTargetFactory也在 Play 上注册,它允许在 URuntimeMeshSceneObject 中使用的 Mesh 组件由现有的建模模式工具进行编辑。
哇!由于它相对独立于工具框架方面,让我们从网格场景方面开始。
11.1 URuntimeMeshSceneSubsystem 和 MeshSceneObjects
此演示的目的是展示在运行时通过 ITF 选择和编辑网格。可以想象,这样做可以编辑任何 StaticMeshActor/Component,类似于建模模式在 UE 编辑器中的工作方式。但是,正如我在之前的教程中所建议的,如果你正在构建某种建模工具应用程序或游戏关卡编辑器,我认为你不希望直接使用 Actor 和组件构建所有内容。至少,你可能需要一种序列化“场景”的方法。而且可能希望在你的环境中拥有不可编辑的可见网格(即使只是 3D UI 元素)。我认为拥有一个代表可编辑世界的独立数据模型是很有用的——一个“对象”的“场景”,不与特定的 Actor 或组件相关联。反而,
所以,这就是我在这个演示中所做的。URuntimeMeshSceneObject是一个场景对象,在 UE 级别由ADynamicSDMCActor 表示,我在之前的教程中对此进行了描述。此 Actor 是RuntimeGeometryUtils插件的一部分。它生成/管理一个子网格 USimpleDynamicMeshComponent,可以在需要时进行更新。在这个项目中,我们不会使用我之前开发的任何蓝图编辑功能,而是使用工具进行编辑,并且仅使用 SDMC 作为显示源网格的一种方式。
URuntimeMeshSceneSubsystem管理现有 URuntimeMeshSceneObjects 的集合,我将在此处(和代码中)将其缩写为“SO”。提供了生成新 SO、按 Actor 查找一个、删除一个或多个 SO 以及管理一组选定 SO 的功能。此外,FindNearestHitObject() 可用于将光线投射到场景中,类似于 LineTrace(但只会命中 SO)。
URuntimeMeshSceneSubsystem 还拥有选中时分配给 SO 的材质以及默认材质。在这个演示中只有对材料的基线支持,所有创建的 SO 都分配了 DefaultMaterial(白色),并且在选择时交换为 SelectedMaterial(橙色)。但是,SO 确实会跟踪分配的材料,因此你可以相对轻松地扩展现有的材料。
11.2 USceneHistoryManager
对场景的更改 - 场景对象的创建、删除和编辑、选择更改、变换更改等 - 由USceneHistoryManager存储。此类存储FChangeHistoryTransaction结构的列表,其中存储FChangeHistoryRecord的序列,它是一个元组(UObject*、FCommandChange、Text)。该系统大致近似于 UE 编辑器事务系统,但仅支持显式 FCommandChange 对象,而在编辑器中,对 UObjects 的更改可以自动存储在事务中。我在上面的 IToolsContextTransactionsAPI 部分中更详细地描述了 FCommandChange。本质上,这些对象具有 Apply() 和 Revert() 函数,它们必须“重做”或“撤消”它们对任何修改的全局状态的影响。
这里的使用模式是调用BeginTransaction(),然后AppendChange()一次或多次,然后是 EndTransaction()。IToolsContextTransactionsAPI 实现将为 ITF 组件执行此操作,并且诸如场景选择更改之类的操作将直接执行此操作。Undo()函数回滚到之前的历史状态/事务,Redo ()函数向前滚动。通常的想法是将所有更改分组到单个事务中以用于单个高级用户“操作”,因此不必多次撤消/重做即可“通过”复杂的状态更改。为了简化这一点,可以嵌套 BeginTransaction()/EndTransaction() 调用,这在需要调用多个单独的函数并且每个函数都需要发出自己的事务时经常发生。与任何支持 Undo/Redo 的应用程序一样,如果用户执行 Undo 一次或多次,然后执行推送新事务/更改的操作,History 序列将被截断。
11.3 AToolsContextActor
在虚幻引擎游戏中,玩家控制 Pawn Actor,而在第一人称视角游戏中,场景是从 Pawn 的视点渲染的。在 ToolsFrameworkDemo 中,我们将实现一个名为AToolsContextActor的自定义 ADefaultPawn 子类来收集用户输入并将其转发给 ITF。此外,此 Actor 将处理项目设置中定义的各种热键输入事件。最后,AToolsContextActor 是我实现标准鼠标右键飞行的地方(这是 ADefaultPawn 的标准行为,我只是将调用转发给它)和 Maya 风格的 alt-mouse 相机控制的初始步骤(但是围绕目标旋转)点尚未实施)。
所有事件连接设置都在AToolsContextActor::SetupPlayerInputComponent()中完成。这是在Project Settings的Input部分中定义的热键事件,以及硬编码的按钮 Action 和鼠标轴映射。大多数硬编码映射——可识别为对UPlayerInput::AddEngineDefinedActionMapping()的调用——可以在项目设置中替换为可配置的映射。
此 Actor 由游戏模式在启动时自动创建。我将在下面进一步描述这一点。
我将在这里只提到另一个选项,而不是让 Pawn 将输入转发到 ITF 的 InputRouter,而是使用自定义 ViewportClient。ViewportClient 是“高于”Actor 和 Pawn 的级别,并且在某种程度上负责将原始设备输入转换为 Action 和 Axis Mappings。由于就 ITF 而言,我们的主要目标只是收集设备输入并将其转发给 ITF,因此自定义 ViewportClient 可能是更自然的地方。然而,这不是我在这个演示中的做法。
11.4 URuntimeToolsFramework子系统
Runtime ITF 后端的核心部分是URuntimeToolsFrameworkSubsystem。这个 UGameInstanceSubsystem(本质上是一个 Singleton)创建并初始化 UInteractiveToolsContext、所有必要的 IToolsContextAPI 实现、USceneHistoryManager、Selection 和 Transform Interactions,以及将在下面描述的几个其他帮助对象。这一切都发生在::InitializeToolsContext()函数中。
子系统还具有用于启动工具和管理活动工具的各种蓝图功能。这些是必要的,因为 ITF 当前未向蓝图公开。最后它做了一点鼠标状态跟踪,在::Tick()函数中,为光标位置构造一个世界空间射线(这是一个相对晦涩的代码),然后将此信息转发给 UInputRouter,以及勾选和渲染 ToolManager 和 GizmoManager。
如果这感觉有点像功能性的抓包,那么它就是。URuntimeToolsFrameworkSubsystem 基本上是 ITF 和我们的“编辑器”之间的“粘合剂”,在这种情况下它非常小。唯一需要注意的其他代码是各种 API 实现,它们都在 .cpp 文件中定义,因为它们不是公共类。
FRuntimeToolsContextQueriesImpl是 IToolsContextQueriesAPI 的实现。此 API 为 ToolBuilders 提供 SelectionState,并支持对当前视图状态和坐标系状态的查询(详情如下)。ExecuteSceneSnapQuery() 函数未实现,仅返回 false。但是,如果您想支持可选的变换 Gizmo 功能,例如网格捕捉或捕捉到其他几何体,这将是开始的地方。
FRuntimeToolsContextTransactionImpl是 IToolsContextTransactionsAPI 的实现。在这里,我们只是将调用直接转发到 USceneHistoryManager。目前我还没有实现 RequestSelectionChange(),一些建模模式工具使用它来将选择更改为新创建的对象,并且还忽略了 PostInvalidation() 调用,它们在 UE 编辑器中用于强制在非实时模式下刷新视口. 构建的游戏始终实时运行,因此这在标准游戏中不是必需的,但如果您正在构建一个不需要恒定 60fps 重绘的应用程序,并且已经实施了避免重绘的方案,则此调用可以为您提供提示强制重绘以查看实时工具更新/等。
FRuntimeToolsFrameworkRenderImpl是 IToolsContextRenderAPI 的实现。此 API 的主要目的是为工具和 Gizmos 提供 FPrimitiveDrawInterface 实现。这是在运行时使用建模模式工具时最有问题的部分之一,我将在下面关于 UToolsContextRenderComponent 的部分中描述它是如何实现的。否则,这里的函数只是转发 RuntimeToolsFrameworkSubsystem 提供的信息。
最后是 FRuntimeToolsContextAssetImpl实现了 IToolsContextAssetAPI,在我们的 Runtime 案例中是非常有限的。此 API 中的许多功能旨在用于更复杂的编辑器使用,因为 UE 编辑器必须处理其中的 UPackage 和资产,可以执行诸如弹出内部资产创建对话框之类的操作,具有复杂的游戏资产路径系统,等等。此 API 中的一些函数可能不应该是基本 API 的一部分,因为工具不会直接调用它们,而是调用使用这些函数的实用程序代码。因此,我们只需要实现 Tools 调用的 GenerateStaticMeshActor() 函数来发射新对象(例如 DrawPolygon Tool,它可以绘制和挤出一个新的网格)。函数名称显然不合适,因为我们不想发出一个新的 AStaticMeshActor,而是一个新的 URuntimeMeshSceneObject。幸运的是,
就是这样!当我提到“ITF 后端”或“类似编辑器的功能”时,我所指的就是这些。800 多行极其冗长的 C++,其中大部分是不同系统之间相对简单的“粘合剂”。对于基本的 ITF 实现来说,甚至很多现有的部分都不是必需的,例如,如果您不想使用建模模式工具,则根本不需要 IToolsContextAssetAPI 实现。
11.5 USceneObjectSelectionInteraction 和 USceneObjectTransformInteraction
当我介绍 ITF 时,我将工具和 Gizmos 视为 ITF 的顶级“部分”,即实施用户输入的结构化处理(通过 InputBehaviors)、将动作应用于对象等的认可方法。但是,没有严格的理由使用工具或 Gizmos 来实现所有用户交互。为了证明这一点,我将“点击选择场景对象”交互实现为独立类USceneObjectSelectionInteraction。
USceneObjectSelectionInteraction是IInputBehaviorSource的子类,所以它可以注册到 UInputRouter,然后它的 UInputBehaviors 会被自动收集并允许捕获鼠标输入。USingleClickInputBehavior _实现了收集鼠标左键单击,并支持 Shift+Click 和 Ctrl+Click 修饰键,以添加到选择或切换选择。IClickBehaviorTarget 实现函数只是确定动作应该指示什么状态,并通过 URuntimeMeshSceneSubsystem API 函数将它们应用于场景。因此,整个点击选择交互只需要相对少量的代码。如果你想实现额外的选择交互,比如框选框选择,这可以通过切换到 UClickDragBehavior/Target 并通过鼠标移动阈值确定用户是否完成了点击和拖动来相对容易地完成。
URuntimeToolsFrameworkSubsystem 只是在启动时创建这个类的一个实例,将它注册到 UInputRouter,这就是系统其余部分所知道的一切。当然可以将选择实现为工具,尽管通常选择是“默认”模式,并且当任何其他工具开始退出时切换出/进入默认工具需要一点小心。或者,它可以使用没有场景内表示的 Gizmo 来完成,并且仅在支持选择更改时始终可用。这可能是我的偏好,因为 Gizmo 获得 Tick() 和 Render() 调用,这可能很有用(例如,可以通过 Render() 绘制选取框矩形)。
随着选择状态的变化,3D 变换 Gizmo 会不断更新 - 它在选定对象的原点之间移动,如果有多个选定对象则移动到共享原点,或者如果没有选定对象则消失。此行为在USceneObjectTransformInteraction中实现,它同样由 URuntimeToolsFrameworkSubsystem 创建。URuntimeMeshSceneSubsystem 的委托 OnSelectionModified 用于在修改场景选择时启动更新。生成的 UTransformGizmo 作用于 UTransformProxy,它被赋予当前选择集。请注意,任何选择更改都会生成一个新的 UTransformGizmo,而现有的 UTransformGizmo 会被销毁。这有点重,可以对其进行优化以重用单个 Gizmo(各种建模模式工具就是这样做的)。
最后一点是活动坐标系的管理。这主要在后台处理,UTransformGizmo 将查询可用的 IToolsContextQueriesAPI 以确定世界或局部坐标系。这可以是硬编码的,但是为了支持两者,我们需要在某个地方放置这个状态。目前我已将它放在 URuntimeToolsFrameworkSubsystem 中,并暴露了一些 BP 函数以允许 UI 切换选项。
11.6 UToolsContextRenderComponent
我在上面提到过,IToolsContextRenderAPI 实现需要返回一个可用于绘制线和点的 FPrimitiveDrawInterface(或“PDI”),这有点问题。这是因为在 UE 编辑器中,承载 ITF 的编辑器模式具有自己的 PDI,可以简单地传递给工具和 Gizmo。但是在运行时,这并不存在,我们可以访问 PDI 实现的唯一地方是在渲染线程上运行的 UPrimitiveComponent 的渲染代码中(哎呀!)。
如果这不完全有意义,那么基本上你需要了解的是,我们不能只是从 C++ 代码中的任何地方“渲染”。我们只能在“内部”渲染组件,例如 UStaticMeshComponent 或 UProceduralMeshComponent。但是,我们的工具和 Gizmo 具有运行在游戏线程上的 ::Render() 函数,并且与任何组件相距甚远。
所以,我所做的是制作一个自定义组件,称为UToolsContextRenderComponent,它可以充当桥梁。这个组件有一个函数::GetPDIForView(),它返回一个自定义的 FPrimitiveDrawInterface 实现(准确地说是 FToolsContextRenderComponentPDI,尽管它隐藏在组件内部)。URuntimeToolsFrameworkSubsystem 每帧创建一个此 PDI 的实例以传递给工具和 Gizmo。PDI DrawLine() 和 DrawPoint() 实现不是试图立即渲染,而是将每个函数调用的参数存储在一个列表中。然后,组件 SceneProxy 获取这些 Line 和 Point 参数集,并将它们传递给 FToolsContextRenderComponentSceneProxy::GetDynamicMeshElements() 实现内的标准 UPrimitiveComponent PDI(渲染器调用它来获取每帧动态几何图形以进行绘制)。
该系统是功能性的,并且允许建模模式工具通常像在编辑器中一样工作。然而,一个障碍是游戏和渲染线程并行运行。因此,如果什么都不做,我们可能会在工具和 Gizmo 完成绘制之前调用 GetDynamicMeshElements(),这会导致闪烁。目前我已经通过在 URuntimeToolsFrameworkSubsystem::Tick() 的末尾调用FlushRenderingCommands()来“修复”这个问题,这会强制渲染线程处理所有未完成的提交几何图形。但是,这可能无法完全解决问题。
另一个复杂之处在于,在 UE 编辑器中,PDI 线和点绘图可以绘制“隐藏线”,即在正面几何图形后面带有点画图案的线。这涉及将自定义深度/模板渲染与后处理通道结合使用。这在运行时也不存在。但是,在你自己的应用程序中,实际上有更多的能力来制作这些效果,因为你完全控制这些渲染系统,而在编辑器中,它们需要添加到任何游戏内效果的“顶部”因此必然受到更多限制。本文很好地概述了如何实现隐藏对象渲染,以及类似于 UE 编辑器的对象轮廓。
11.7 FSimpleDynamicMeshComponentTarget
正如我在 PrimitiveComponentTargets 部分中所描述的,为了允许在此演示中使用建模模式中的网格编辑工具,我们需要在我们要编辑的 UPrimitiveComponents 周围提供一种“包装器”。在这种情况下,这将是 USimpleDynamicMeshComponent。FSimpleDynamicMeshComponentTarget及其关联的 Factory的代码相对简单。如果您深入研究,您可能会注意到,SDMC 中的 FDynamicMesh3 正在转换为 FMeshDescription 以传递给工具,然后工具将其转换回 FDynamicMesh3 进行编辑。这是当前设计的一个限制,该设计专注于静态网格体。如果您正在构建自己的网格编辑工具,则无需进行此转换,但要使用建模模式工具集,则不可避免。
请注意,对网格的更改(存储在 ::CommitMesh() 中)在更改历史记录中保存为FMeshReplacementChange,其中存储了两个完整的网格副本。这对于大型网格来说并不理想,但是建模工具在内部创建的用于存储预览网格上的更改(例如在 3D 雕刻中)的网格“增量”当前不会“冒泡”。
最后,我将再次重申,由于 FPrimitiveComponentTarget 部分中描述的工厂注册问题,无法在 UE4.26 的运行时使用建模模式工具集直接编辑 UStaticMeshComponent 或 UProceduralMeshComponent。虽然,由于在很大程度上只有 ToolBuilders 使用 FPrimitiveComponentTargetFactory 注册表,您也许可以让它们与直接创建替代 FPrimitiveComponentTarget 实现的自定义 ToolBuilders 一起使用。这不是我探索过的路线。
11.8 AToolsFrameworkDemoGameModeBase
教程项目的最终 C++ 代码组件是AToolsFrameworkDemoGameModeBase。这是 AGameModeBase 的子类,我们将在编辑器中将其配置为默认游戏模式。本质上,这就是“启动”我们的运行时工具框架的原因。请注意,这不是 RuntimeToolsFramework 模块的一部分,而是基本游戏模块,你无需在自己的应用程序中以这种方式初始化。例如,如果你想实现某种游戏内关卡设计/编辑工具,可能会将此代码折叠到您现有的游戏模式中(或者可能会根据需要启动一个新模式)。你也不需要使用游戏模式来执行此操作,尽管在这种情况下复杂的是默认 pawn AToolsContextActor,它可能也需要替换。
在这种游戏模式中很少发生。我们将其配置为 Tick,在 Tick() 函数中,我们 Tick() URuntimeToolsFrameworkSubsystem。否则所有动作都在AToolsFrameworkDemoGameModeBase::InitializeToolsSystem()中,我们在其中初始化 URuntimeMeshSceneSubsystem 和 URuntimeToolsFrameworkSubsystem,然后将可用工具集注册到 ToolManager。所有这些代码都可以(也许应该)从游戏模式本身中移出,并移到一些实用功能中。
12、ToolsFrameworkDemo 项目设置
如果你打算根据本教程设置自己的项目或进行更改,则需要了解涉及的各种资产和项目设置。下面的内容浏览器屏幕截图显示了主要资产。DefaultMap是我使用的关卡,它只包含地平面并在关卡蓝图中初始化 UMG 用户界面(见下文)。
BP_ToolsContextActor是 AToolsContextActor 的蓝图子类,在游戏模式中被配置为默认 Pawn。在这个 BP Actor 中,我禁用了Add Default Movement Bindings设置,因为我在 Actor 中手动设置了这些绑定。DemoPlayerController是 AToolsFrameworkDemoPlayerController 的 Blueprint 子类,这再次存在只是为了在 BP 中配置一些设置,特别是我启用了Show Mouse Cursor以便绘制标准 Windows 光标(这是人们在 3D 工具中可能期望的)并禁用 Touch事件。最后DemoGameMode是我们AToolsFrameworkDemoGameModeBase的 BP 子类C++ 类,在这里我们配置游戏模式以生成我们的 DemoPlayerController 和 BP_ToolsContextActor,而不是默认值。
最后在Project Settings对话框中,我将Default GameMode配置为我们的DemoGameMode蓝图,并将DefaultMap配置为 Editor 和 Game 启动图。我还在Input部分添加了各种操作,我在 AToolsContextActor 的描述中显示了上面这些设置的屏幕截图。最后在Packaging部分,我添加了两条到 Materials 的路径到Additional Asset Directories to Cook部分。这对于强制将这些材料包含在构建的游戏可执行文件中是必要的,因为关卡中的任何资产都没有特别引用它们。
13、RuntimeGeometryUtils 更新
在我之前的教程中,我一直在 RuntimeGeometryUtils 插件中积累各种 Runtime 网格生成功能。为了实现本教程,我做了一个重要的补充,URuntimeDynamicMeshComponent。这是 USimpleDynamicMeshComponent (SDMC) 的子类,增加了对碰撞和物理的支持。如果您还记得之前的教程,建模模式工具使用 USimpleDynamicMeshComponent 来支持在编辑期间实时预览网格。在这种情况下,SDMC 针对原始渲染性能的快速更新进行了优化,并且由于它仅用于“预览”,因此不需要对碰撞或物理的支持。
但是,我们也一直在使用 SDMC 作为渲染运行时生成的几何图形的一种方式。在许多方面它与 UProceduralMeshComponent (PMC) 非常相似,但是 PMC 的一个显着优势是它支持碰撞几何,这意味着它可以与 UE 光线投射/线跟踪系统以及物理/碰撞正常工作系统。事实证明,支持这一点相对简单,所以我创建了 URuntimeDynamicMeshComponent 子类。SDMC的这个变种,我想我们可以称之为RDMC,支持简单和复杂的碰撞,还有一个函数SetSimpleCollisionGeometry()可用,它可以采用任意简单的碰撞几何(甚至 PMC 也不支持)。但是请注意,目前不支持异步物理烹饪。这不是要添加的主要内容,但我没有这样做。
我已将ADynamicSDMCActor中的组件类型切换为这个新组件,因为其他方面的功能是相同的,但现在基础 Actor 上的碰撞选项的工作方式与它们在 PMC 变体上的工作方式相同。最终结果是以前的教程演示,如兔子枪和程序世界,应该与 SDMC 以及 PMC 一起使用。这将为将来更有趣(或高性能)的运行时程序网格工具打开大门。
14、在运行时使用 ModelingMode 工具
这花费了相当长的时间,但我们现在可以在运行时游戏的 MeshModelingToolset 中公开现有的网格编辑工具,并使用它们来编辑选定的 URuntimeMeshSceneObject。从概念上讲,这个“正常工作”并添加工具工作的基本能力只需要在AToolsFrameworkDemoGameModeBase::RegisterTools()中注册一个 ToolBuilder ,然后添加一些方式(热键、UMG 按钮等)以通过URuntimeToolsFrameworkSubsystem 启动它: :BeginToolByName()。这适用于许多工具,例如 PlaneCutTool 和 EditMeshPolygonsTool 开箱即用。
但是,并非所有工具都能立即发挥作用。与全局 ToolTargetTargetFactory 系统类似,各种当时可能看起来微不足道的小设计决策可能会阻止工具在构建的游戏中工作。通常,通过一些实验,可以在基本工具的子类中使用少量代码来解决这些问题。我已经在几种情况下这样做了,我将解释这些,以便如果你尝试公开其他工具,你可能会有一个尝试做什么的策略。如果你发现自己陷入困境,请在评论中发布有关该工具不起作用的信息,我会尽力提供帮助。
请注意,要创建 Tool 子类,你还需要创建一个新的 ToolBuilder 来启动该子类。通常,这意味着子类化基础 Builder 并覆盖创建工具的函数,无论是基础 ::BuildTool() 还是基础 Builder 的调用 NewObject 的函数(这些通常更容易处理)。
在某些情况下,默认工具设置是有问题的。例如,URemeshTool默认启用仅编辑器的线框渲染。因此,有必要重写 Setup() 函数,调用基本的 Setup(),然后禁用此标志(不幸的是,目前在 Builder 中没有办法这样做,因为 Builder 没有机会接触分配新实例后的工具)。
创建新对象的工具,例如UDrawPolygonTool,通常在未经修改的情况下无法在运行时工作。在许多情况下,发出新对象的代码是#ifdef 出来的,而是用 check() 代替。但是,我们可以将这些工具子类化并替换 Shutdown() 函数或工具的内部函数,以实现新对象的创建(通常来自工具生成的 FDynamicMesh3)。URuntimeDrawPolygonTool::EmitCurrentPolygon()是为 UDrawPolygonTool 执行此操作的示例,而URuntimeMeshBooleanTool::Shutdown()为 UCSGMeshesTool 执行此操作的示例。在后一种情况下,覆盖执行基本工具代码的子集,因为我只支持替换第一个选定的输入对象。
这是我遇到的两个主要问题。第三个复杂因素是许多现有工具,尤其是旧工具,不使用 WatchProperty() 系统来检测其 UInteractiveToolPropertySet 设置对象的值何时被修改。它们不依赖于轮询,而是依赖于仅编辑器的回调,这在构建的游戏中不会发生。因此,如果你以编程方式更改这些 PropertSet 的设置,工具将不会更新以反映它们的值而无需轻推。不过,我已经将这些“轻推”与一种将工具设置公开给蓝图的方式相结合,我现在将对此进行解释。
14.1 蓝图公开的 ToolPropertySets
4.26 中工具框架的一个主要限制是,尽管它是由 UObject 构建的,但它们都没有暴露给蓝图。因此,你不能轻易地做一件微不足道的事情,例如将 UMG UI 连接到活动工具,以直接更改工具设置。但是,如果我们对现有工具进行子类化,我们可以将子类标记为UCLASS(BlueprintType),然后将活动工具(通过URuntimeToolsFrameworkSubsystem::GetActiveTool()访问)转换为该类型。类似地,我们可以定义一个新的 UInteractiveToolPropertySet,也就是 UCLASS(BlueprintType),并公开标记为 BlueprintReadWrite 的新 UProperties 以使它们可以从 BP 访问。
为了包含这个新的 Property Set,我们将继承 Tool ::Setup()函数,调用基类::Setup(),然后创建并注册我们的新 PropertySet。对于每个属性,我们将添加一个 WatchProperty() 调用,将更改从我们的新 PropertySet 转发到基本工具设置,然后在必要时调用一个函数来启动重新计算或更新(例如 URuntimeMeshBooleanTool 必须调用Preview->InvalidateResult () )。
一个复杂的问题是枚举值设置,它在编辑器中会自动生成下拉列表,但是这对于 UMG 是不可能的。因此,在这些情况下,我使用整数 UProperties 并将整数映射到自己的枚举。因此,例如,这里是 UDrawPolygonTool 的 URuntimeDrawPolygonTool 的所有 PropertySet 相关代码(我省略了上面提到的 EmitCurrentPolygon() 覆盖和 new ToolBuilder)。这是一种剪切和粘贴模式,我可以在我的所有工具覆盖中重复使用它来为我的 UMG UI 公开工具属性。
UENUM(BlueprintType)
enum class ERuntimeDrawPolygonType : uint8
{
Freehand = 0, Circle = 1, Square = 2, Rectangle = 3, RoundedRectangle = 4, HoleyCircle = 5
};
UCLASS(BlueprintType)
class RUNTIMETOOLSSYSTEM_API URuntimeDrawPolygonToolProperties : public UInteractiveToolPropertySet
{
GENERATED_BODY()
public:
UPROPERTY(BlueprintReadWrite)
int SelectedPolygonType;
};
UCLASS(BlueprintType)
class RUNTIMETOOLSSYSTEM_API URuntimeDrawPolygonTool : public UDrawPolygonTool
{
GENERATED_BODY()
public:
virtual void Setup() override;
UPROPERTY(BlueprintReadOnly)
URuntimeDrawPolygonToolProperties* RuntimeProperties;
};
void URuntimeDrawPolygonTool::Setup()
{
UDrawPolygonTool::Setup();
// mirror properties we want to expose at runtime
RuntimeProperties = NewObject<URuntimeDrawPolygonToolProperties>(this);
RuntimeProperties->SelectedPolygonType = (int)PolygonProperties->PolygonType;
RuntimeProperties->WatchProperty(RuntimeProperties->SelectedPolygonType,
[this](int NewType) { PolygonProperties->PolygonType = (EDrawPolygonDrawMode)NewType; });
AddToolPropertySource(RuntimeProperties);
}
14.2 ToolPropertySet Keepalive Hack
我在尝试让 MeshModelingToolset 工具在构建的游戏中工作时遇到的一个主要问题是,事实证明它们使用 UObjects 做了一些……非法的事情。这真的很麻烦,但我会简要解释一下,以防它与您相关。我之前提到 UInteractiveToolPropertySet 用于在几乎所有工具中以结构化的方式公开“工具设置”。像这样的系统的一个理想属性是能够保存工具调用之间的设置状态。为此,我们可以只保留属性集本身的一个实例,但我们需要将它保留在某个地方。
各种编辑器系统通过在其他一些 UObject 的 CDO 中保存指向已保存设置 UObject 的指针来执行此操作 - 每个 UObject 都有一个 CDO(类默认对象),它就像一个用于构造附加实例的“模板”。CDO 是全球性的,因此这是放置东西的好地方。然而,在编辑器中,CDO 将阻止这个 UObject 被垃圾收集(GC’d),但在运行时,它不会!事实上,在运行时,垃圾收集器会进行安全检查以确定这是否尚未完成,如果它检测到这一点,就会终止游戏(!)。这需要在 UE 的未来版本中修复,但要让这个演示在二进制 4.26 版本中运行,我们需要一个解决方法。
首先,我必须通过在 URuntimeToolsFrameworkSubsystem::InitializeToolsContext() 中设置全局GShouldVerifyGCAssumptions = false来禁用 GC 安全检查。这可以防止硬杀,但是当工具尝试访问它们并假定它们仍然存在时,保存的 PropertySet 仍将被垃圾收集并导致崩溃。因此,在 URuntimeToolsFrameworkSubsystem::OnToolStarted() 事件处理程序中,调用了 AddAllPropertySetKeepalives() 函数,该函数遍历新工具的所有已注册 PropertySet UObjects 的 CDO,并将这些“保存的设置”UObjects 添加到将防止他们被GC’d。
这是…一个严重的黑客攻击。但功能齐全,似乎没有任何有问题的副作用。但我打算在未来解决底层架构问题。
14.3 用户界面
本教程的重点是演示交互式工具框架和网格建模工具集的运行时使用,而不是实际构建功能运行时建模工具。然而,为了真正能够启动和使用演示工具,我必须构建一个最小的 UMG 用户界面。我不是 UMG 的专家(这是我第一次使用它)所以这可能不是最好的方法。但是,它有效。在/ToolUI子文件夹中,您将找到几个 UI 小部件资产。
ToolTestUI是主用户界面,位于左上角,右下角有一个图像。我在教程开始时描述了各种工具按钮。Accept、Cancel和Complete按钮根据活动工具状态动态更新可见性和启用性,此逻辑位于蓝图中。撤消和重做按照您的预期进行,并且“世界”按钮可在任何活动 Gizmo 的“世界”和“本地”帧之间切换。此 UI 由关卡蓝图在 BeginPlay 上生成,位于右下方。
还有几个每个工具的 UI 面板显示工具设置。这些每个工具的 UI 面板在它们启动工具后由 ToolUI 按钮生成,请参阅 ToolUI 蓝图,它非常简单。我只为一些工具添加了这些设置面板,并且只公开了一些设置。添加设置的工作并不多,但有点乏味,而且由于这是一个教程,我不太关心公开所有可能的选项。下面的截图来自DrawPolygonToolUI,显示游戏内面板(左)和 UI 蓝图(右)。本质上,在初始化时,Active Tool 被转换为正确的类型,我们提取 RuntimeProperties 属性集,然后初始化所有 UI 小部件(在这种情况下只有一个)。然后在小部件事件更新时,我们将新值转发到属性集。不涉及火箭科学。
15、结束语
我曾有很多人询问 UE Editor Modeling Mode Tools 和 Gizmos 是否可以在运行时使用,我的回答一直是“嗯,这很复杂,但可能”。我希望这个示例项目和文章能回答这个问题!这绝对是可能的,在 GeometryProcessing 库和 MeshModelingToolset 工具和组件之间,UE4.26中提供了大量可用于构建交互式 3D 内容创建应用程序的功能,从基本的“放置和移动对象”工具到从字面上看,一个功能齐全的 3D 网格雕刻应用程序。你真正需要做的就是设计和实现 UI。
根据我过去构建的设计工具,我可以肯定地说,当前的建模模式工具可能并不完全是您自己的应用程序所需要的。它们是一个不错的起点,但我认为它们提供的实际上是关于如何实现不同交互和行为的参考指南。你想要一个可以使用 Gizmo 移动的 3D 工作平面吗?查看UConstructionPlaneMechanic以及它是如何在各种工具中使用的。在该平面上绘制和编辑 2D 多边形怎么样?请参阅UDrawAndRevolveTool中的UCurveControlPointsMechanic用法。用于在网格上绘制最短边路径的界面?USeamSculptTool这样做。想要制作一个运行一些第三方几何处理代码的工具,具有设置和实时预览以及为您预先计算的各种有用的东西?只是子类UBaseMeshProcessingTool。需要在工具期间在后台线程中运行昂贵的操作,以便您的 UI 不会锁定?UMeshOpPreviewWithBackgroundCompute和TGenericDataBackgroundCompute实现模式,URemeshMeshTool 等工具展示了如何使用它。
我可以继续,很长一段时间。建模模式中有超过 50 种工具,它们可以做各种各样的事情,远远超过我可能有时间解释的。但是,如果您可以在 UE 编辑器中找到与您想要的内容相近的内容,则基本上可以复制 Tool .cpp 和 .h,重命名类型,然后开始根据您的目的对其进行自定义。
所以,玩得开心!