在这篇文章中,我将介绍很多内容。我提前为太长的空间道歉。然而,本文的主题本质上是如何使用虚幻引擎构建 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 开发人员可以相对容易地添加类似的东西。D 用户界面是一个非常基本的 UMG 用户界面。假设这是一次性的,你会建立自己的 UI。再说一次,如果你想在特定领域做一个非常简单的建模工具,比如 33,用于清洁医学扫描D 雕刻工具,稍加修改就可以摆脱这个 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 F因为没有菜单//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,我们将在运行过程中创建自己的工具框架后端,包括 3,一个基本的可选网格场景对象D 场景,非常标准的 3D 应用程序转换 gizmo 系统和 ToolsContext API 的实现 I上面提到的与运行时的场景系统兼容。本节主要解释了我们必须添加到 ITF 使用它的额外位置,所以你需要阅读前面的部分才能真正理解它。
接下来,我将介绍一些特定于演示的材料,包括演示工作所需的材料ToolsFrameworkDemo 项目设置,RuntimeGeometryUtils 更新,尤其是对 USimpleDynamicMeshComponent 碰撞支持,然后是一些关于在运行过程中使用建模工具的注释,因为现有的网格编辑工具通常需要一些胶代码才能在游戏环境中发挥作用。
就这样!让我们开始吧!…
3.为什么交互式工具框架
我不喜欢通过证明它的存在来开始一篇关于某件事的文章。但是,我想我需要它。我花了很多年的时间 - 基本上是我的整个职业生涯 - 构建 3D 创建/编辑工具。我的第一个系统是ShapeShop(它自2008年以来一直没有更新,但仍然可以工作——这是 Windows 向后兼容性证明!)。我还建造了 Meshmixer,它成为 Autodesk 产品下载数百万次,至今广泛使用。Twitter搜索时,我不断惊讶于人们使用 Meshmixer 做了很多数字牙医!!。我还建立了其他从未出现过的全功能系统,比如 33,我们称之为手绘世界D 透视草图界面 Autodesk Research 建造。之后,我帮助建造了一些医疗 3D 设计工具,例如Archform 牙齿矫正器规划应用程序NiaFit 小腿假肢插座设计工具(VR )不幸的是,我在它有任何流行的希望之前就放弃了。
撇开自我祝贺不谈,这些 33已经在过去的 15 生产多年了D 在工具的过程中,我学到了很容易造成巨大的混乱。我开始研究,后来变成了 Meshmixer因为 Shapeshop 已经到了任何东西。Shapeshop 的某些部分形成了一个非常早期的工具框架。我把它提取并作为其他项目的基础,甚至是 Meshmixer(最于变得很脆弱了!)。代码还在我的网站上。当我离开 Autodesk 时,我回到了如何构建工具的问题,并创建了它frame3Sharp 库这使得在 C# 3D 工具变得容易(相对)。这个框架围绕着上面提到的 Archform、NiaFit 和 Cotangent 应用程序发展起来,并一直为它们提供动力。但是,后来我加入了 Epic,并重新开始使用 C !
所以,这就是 UE4 交互式工具框架的起源故事。使用这个框架,一个小团队(6 或更少的人取决于月份)在 UE建模模式在4 中构建,有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() 中�