扩展Unity编辑器的技巧
Unity Labs的Authoring Tools Group开发工具团队正在开发VR/AR工具Project MARS,它可以让用户构建并测试稳定的AR体验。
本文将介绍开发Project MARS时,使用的一些扩展编辑器的方法和技巧,其中一些方法是普遍适用的,另一些则取决于具体用例,许多方法在GitHub上SuperScience代码库提供相应示例以供参考。
访问SuperScience代码库:
https://github.com/Unity-Technologies/SuperScience
在编辑模式下运行
MARS场景通常包含真实数据必须满足的条件,以及在条件满足时出现的数字内容。为了支持该工作流程,MARS拥有Simulation View模拟视图。
Simulation View模拟视图允许用户针对不同环境测试自定义设置,并在这些环境内调整条件和内容。由于在Simulation View模拟视图进行的调整需要保存到场景,因此模拟过程必须发生在编辑模式下。
有不同方法可以在编辑模式下运行MonoBehaviour,ExecuteInEditMode和ExecuteAlways属性会让MonoBehaviour的所有实例在编辑模式下运行,但它们不适合我们的用例,因为我们只想运行和模拟过程相关的实例,所以我们使用了runInEditMode属性。
请注意:ExecuteAlways支持预制件模式,但ExecuteInEditMode不支持预制件模式。在Unity 2018.3中,runInEditMode也支持预制件模式。
当MonoBehaviour的runInEditMode属性首次设为True时,它会开始在运行模式发生的相同脚本生命周期,例如:Awake,Start等。在此之后,把runInEditMode设为False不会阻止对象接收更新信息,但它不会触发OnDisable函数。只有在下次把runInEditMode设为True,并且对象已经启用时,对象才会禁用和重新启用。
在MARS项目中,我们发现的有效做法是:创建MonoBehaviour扩展方法StopRunInEditMode,它会禁用MonoBehaviour,将其runInEditMode设为False,仅当MonoBehaviour已经启用时,再重新启用runInEditMode。
为了保证连贯性,我们也使用StartRunInEditMode方法,但该方法只会把runInEditMode设为True,如果MonoBehaviour启用的话,它会触发OnEnable函数。
默认情况下,只有在场景的内容发生变化时,运行在编辑模式的对象才会获得更新,但我们也可以使用EditorApplication.QueuePlayerLoopUpdate来强制更新。如果想让对象在编辑模式下持续更新,我们可以把QueuePlayerLoopUpdate关联到EditorApplication.update委托。
为了查看runInEditMode的实际效果,我们可以使用RunInEditHelper,该脚本会提供编辑器窗口,让开发者在此修改运行的对象并调整Player Loop玩家循环的开关。
响应场景中的更改
Simulation View模拟视图的部分功能是改进设置和测试AR场景的迭代时间,当用户修改场景条件时,我们会重启模拟过程,使用户得到新条件设置运行效果的即时反馈。
在MARS项目中,我们检测用户修改场景的方法是使用Undo.postprocessModifications和Undo.undoRedoPerformed。
postprocessModifications回调会接收UndoPropertyModification的数组,因此如果只想检测特定类型的改动,我们可以检查每个UndoPropertyModification的currentValue字段,例如:在MARS项目中,我们检查了currentValue.target的Type类型,这样我们只会对MARS特定组件的相关变化做出响应。
Undo.undoRedoPerformed不接收任何参数,因此很难通过它知道什么发生了变化,当该回调发生时,改动对象会包含Selection.activeGameObject或Selection.gameObjects,但只有当对象通过未锁定的检视窗口修改时,才会确定发生这种情况。改动也可能通过自定义用户代码发生,例如:通过SerializedProperty类。
延时响应
很多情况下,我们可能会对不适合立即由Undo回调引起的改动做出响应,例如:用户把滑块拖到检视窗口会触发很多次对Undo.postprocessModifications的调用,而在MARS项目中,我们不想为每次这种调用而重启模拟过程。
如果只想在用户“完成”持续改动后触发响应,合理的解决方案是加入短时间的计时器,例如0.3秒,它会在检测到改动时重置和重启,只有在计时器达到设定时间时,它才会触发响应。
下图展示了这种方法的实际效果。
实现方法,请访问:
https://github.com/Unity-Technologies/SuperScience/blob/master/ModificationResponse/Editor/ModificationResponseExampleWindow.cs
保存场景元数据
对于每个MARS场景,我们需要保存特定信息为元数据,例如:场景要求的真实数据。
我们有很多方法为场景保存元数据,这些方法都各有利弊。
方法
优点
缺点
对每个场景都使用一个ScriptableObject资源
不必打开场景就能访问元数据。
如果把编辑器元数据保存在运行时元数据外的独立资源,我们能在构建版本外保留仅用于编辑器的元数据。
如果把该资源保存在场景的相同目录,我们很容易就能找到它。
必须确保该资源和场景保持同步。
必须检查场景的复制情况,使元数据也能得到复制。
该资源会使项目变大,因为每个场景都需要一个新资源。
保存所有场景元数据到一个主要的ScriptableObject资源
不必打开场景就能访问元数据。
如果把编辑器元数据保存在运行时元数据外的独立资源,我们能在构建版本外保留仅用于编辑器的元数据。
每个项目只有一个元数据资源,而不是每个场景各有一个元数据资源。
必须确保该资源和场景保持同步。*
必须检查场景的复制情况,使元数据也能得到复制。
版本控制过程会变得更复杂,对一个场景的改动会影响整个主要元数据资源。
场景无法在不同资源包中分发,因为所有场景的元数据都保存在一个资源中。
使用场景内的MonoBehaviour
容易和场景保持同步,保存场景会同时保存元数据。
复制场景会同时复制元数据。
由于它就在场景中,因此很容易找到。
仅能通过打开场景来访问元数据。
用户体验会变差,用户可能会有疑问:为什么该对象会在我的场景中
解决方法:根据具体用例,我们可以在层级窗口隐藏元数据对象或组件,并使用自定义窗口来查看和编辑元数据。
对于MARS项目,我们最初选择了只用一个主要ScriptableObject资源的方法。之后我们改为使用MonoBehaviours,因为只使用一个主要资源的缺点比优点更加明显,特别是合并冲突和同步元数据的时候,另一个原因是此时我们要在场景出于其它目的使用MARS专用组件。
*如果采取只使用单个ScriptableObject资源的方法,请在和场景同步元数据时小心。如果在场景未保存时污染了元数据资源,元数据有可能会在场景保存前进行保存,因为保存项目时不会保存打开的场景。
如果推迟污染元数据资源的时间直到保存场景的时候,我们需要确保在保存场景时,元数据也得到保存。
如果使用OnWillSaveAssets,我们可以通过检查特定路径是否包含场景路径来实现这种效果,如果包含的话,则污染元数据资源,并将其路径包含在返回的字符串数组中。
实现该过程的示例,请参考:
https://github.com/Unity-Technologies/SuperScience/blob/master/SceneMetadata/Editor/MetadataAssetModificationProcessor.cs
程序集定义文件
编写的任何扩展都要和其它扩展及用户代码有合适的运行效果。使用程序集定义文件意味着,用户不必在每次Unity编译时重新编译扩展。这种文件可以使用户更轻松地在代码中定义依赖。
资源包或编辑器扩展有三个标准程序集。
Runtime程序集
它包含需要加入Player构建版本的任何代码,仅用于编辑器的扩展可能不需要使用Runtime程序集。
任何需要加入场景对象的组件,无论它是否会加入构建版本中,它都必须进入Runtime程序集中。
Runtime运行时程序集无法引用Editor程序集,就和Editor文件夹内不带有程序集定义文件的脚本一样。
Editor程序集
该程序集应该包含所有自定义检视窗口代码和仅在编辑器内使用的非组件C#代码。
Editor程序集定义文件应该仅把编辑器作为目标平台。
Editor程序集几乎都会引用Runtime程序集。
Tests程序集
该程序集应该包含仅用于测试扩展的所有代码,我们不必和扩展一起分发该文件夹,除非想让其他人能修改它,但有的资源包会包含该程序集。
如果同时有编辑模式和运行模式的测试文件,我们需要二个不同的程序集:用于编辑模式的Extension.Tests.Editor 和用于运行模式的Extension.Tests.Runtime。
运行时程序集的名称就是资源包的名称。每个其它程序集定义文件应该使用的命名格式为:资源包名称.{后缀}。
在MARS项目中,程序集定义文件的名称分别为MARS,MARS.Editor和MARS.Tests。扩展代码的顶层命名空间应该和程序集定义文件的前缀一致。
在运行时使用Editor程序集代码
有时需要在运行时程序集中引用编辑器代码,例如:有的MonoBehaviour可能只为了编辑时的功能而存在,但由于Editor程序集中针对MonoBehaviours的规则,它必须在运行时程序集内使用。
在这种情况下,我们通常使用的方法是在#if UNTY_EDITOR指令内定义一些静态委托字段,然后Editor类可以指定自己的方法到这些委托,在运行时程序集提供对Editor类的访问。
你可以在SuperScience代码库找到这种做法的示例,该代码库有EditorWindow类,运行时程序集内的带有Editor委托的类,以及使用这些委托的MonoBehaviour。
生命周期事件和扩展性
为了允许扩展和用户项目的其它部分集成,我们使用的一个重要做法是为每个较小系统内的重要状态变更提供要关联的事件。
如果事件为系统或对象的重要改动而提供,我们通常把这种事件称为生命周期事件。重要的是记住,不可能考虑到用户为集成你的工具而做的所有事情,因此理想情况下,事件签名不应该有太大限制,通常返回的是void。
例如,当我们打开和关闭MARS中的模拟场景时,我们有一个事件会允许任何自定义设置或清除用户的项目要求,开发者可以使用它给MARS模拟过程添加自定义功能。
该设置意味着不必考虑用户会为自己用例而做的所有事情,因为我们不可能全部都考虑到,但仍然允许用户高度灵活地实现自己想要的内容。
因此,我们有生命周期的最简单版本:它只有创建和销毁的事件,没有会被传递到事件函数的参数。
class SimulationSceneModule
{
public static event Action simulationSceneCreated;
public static event Action simulationSceneDestroyed;
}
class SimulationLifecycleEventUser : MonoBehaviour
{
public void OnEnable()
{
SimulationSceneModule.simulationSceneCreated += Setup;
SimulationSceneModule.simulationSceneDestroyed += TearDown;
}
public void OnDisable()
{
SimulationSceneModule.simulationSceneCreated -= Setup;
SimulationSceneModule.simulationSceneDestroyed -= TearDown;
}
void Setup() { /* 项目的设置过程在此实现*/ }
void TearDown() { /* 项目的清除过程在此实现*/}
}
一旦我们不想响应该事件时,请务必取消订阅该事件。如果没有取消,我们可能会在使用事件的对象被销毁后得到很多触发事件。
生命周期事件通常需要传递一些状态变更数据到最终用户。如果想要传递对象组被销毁或改动的信息,我们需要使用的事件代码如下。
class ComponentEvents
{
public event Action<List<Component>> componentsChanged;
}
上面的示例代码使用了Action行为,如果想在检视窗口能关联事件,我们可以把Action替换为UnityEvent。
小结
我们很喜欢看到来自Unity社区的开发者给编辑器加入的有趣而特别的方法。Authoring Tools Group开发工具团队的使命是更好地帮助创作者构造3D的未来,我们很高兴分享在此期间所得到的经验。
如果你对我们有任何疑问或反馈,请发送邮件至labs@unity3d.com。
更多Unity技术文章分享,尽在Unity Connect平台(Connect.unity.com)。
推荐阅读
Unite Shanghai 2019
5月10日-12日上海,Unite大会强势回归。技术门票正在热销中,购票即获指定Asset Store资源商店精品21款资源的5折优惠券。
购票请访问:Unite2019.csdn.net
点击“阅读原文”访问Unity Connect