深入了解 IMGUI 和编辑器定制

你可能会觉得时间很奇怪。既然有了新的用户界面系统,为什么还要在乎旧的用户界面系统呢?虽然新的 UI 系统旨在覆盖游戏中的所有用户界面情况,但 IMGUI 仍在使用,尤其是在一种非常重要的情况下:Unity 编辑器本身。如果您有兴趣使用自定义工具和功能来扩展 Unity 编辑器,那么您很可能需要做的一件事就是与 IMGUI 进行交锋。
那么,第一个问题为什么叫 "IMGUI"?IMGUI 是即时模式 GUI 的简称。好吧,那是什么?GUI 系统有两种主要方法:即时 "和 "保留"。
保留模式 GUI是指 GUI 系统 "保留 "有关 GUI 的信息:您设置好各种 GUI 部件(标签、按钮、滑块、文本字段等)后,这些信息就会被保留下来,并被系统用于渲染屏幕、响应事件等。当你想更改标签上的文字或移动按钮时,你就在操作存储在某个地方的信息,当你完成更改后,系统就会以新的状态继续工作。当用户更改数值和移动滑块时,系统只需存储其更改,然后由你来查询数值或响应回调。新的 Unity UI 系统就是保留模式 GUI 的一个范例;你将 UI.Label、UI.Buttons 等创建为组件,设置好它们,然后就让它们放在那里,新的 UI 系统就会处理剩下的事情。
同时,即时模式 GUI是指 GUI 系统一般不会保留 GUI 的相关信息,而是反复要求您重新指定控件的内容、位置等。当你以函数调用的形式指定用户界面的每个部分时,它就会立即被处理--绘制、点击等--并且任何用户交互的结果都会直接返回给你,而不需要你去查询。这对于游戏用户界面来说效率很低,对于美工人员来说也很不方便,因为一切都变得非常依赖代码,但对于非实时情况(如编辑器面板)来说却非常方便,因为这些情况严重依赖代码(如编辑器面板),并希望根据当前状态轻松更改显示的控件(如编辑器面板!),所以对于重型建筑设备等来说,这是一个不错的选择。不,等等我的意思是,它是编辑面板的不错选择。
如果你想了解更多,Casey Muratori 有一段精彩的视频,他在视频中讨论了即时模式 GUI 的一些优点和原理。或者您可以继续阅读!
每当 IMGUI 代码运行时,都会有一个当前 "事件 "被处理--可能是 "用户点击了鼠标按钮",也可能是 "图形用户界面需要重新绘制"。您可以通过检查 Event.current.type 来了解当前事件是什么。
想象一下,如果你在某个窗口中设置一组按钮,而你必须分别编写代码来响应 "用户点击了鼠标按钮 "和 "GUI 需要重新绘制",那会是什么样子。在区块层面上,情况可能是这样的:

为每个独立的 GUI 事件编写这些函数有点繁琐;但你会发现,这些函数在结构上有一定的相似性。每一步,我们都在做与同一个控件(按钮 1、按钮 2 或按钮 3)相关的事情。我们具体要做什么取决于活动,但结构是一样的。这意味着我们可以这样做:

我们只有一个 OnGUI 函数,它调用GUI.Button 等库函数,而这些库函数会根据我们处理的事件做不同的事情。简单!
有 5 种事件类型是最常用的:
EventType.MouseDownSet:当用户刚刚按下鼠标按钮时;EventType.MouseUpSet:当用户刚刚释放鼠标按钮时;EventType.KeyDownSet:当用户刚刚按下按键时;EventType.KeyUpSet:当用户刚刚释放按键时;EventType.RepaintSet:当 IMGUI 需要重绘屏幕时。
这并不是一个详尽的列表,请查看EventType 文档了解更多信息。
标准控件(如 GUI.Button)如何响应其中的一些事件?
EventType.Repaint在提供的矩形区域内绘制按钮。EventType.MouseDown检查鼠标是否位于按钮的矩形区域内。事件类型.MouseUpUnflag按钮为 "down "并触发重绘,然后检查鼠标是否仍在按钮的矩形范围内:如果是,则返回 true,以便调用者可以响应按钮被点击。
实际情况比这更复杂--按钮也会对键盘事件做出响应,而且有代码确保只有你最初点击的按钮才能对 MouseUp 做出响应--但这给了你一个大致的概念。只要您在代码的相同点为每个事件调用 GUI.Button,并使用相同的位置和内容,那么不同的行为将共同提供按钮的所有功能。
为了帮助在不同事件下将这些不同行为联系在一起,IMGUI 提供了 "控件 ID "的概念。控件 ID 的设计理念是在每种事件类型中以一致的方式引用给定控件。用户界面中具有非简单交互行为的每个不同部分都会请求一个控件 ID;它用于跟踪当前键盘焦点在哪个控件上,或存储与控件相关的少量信息。控件 ID 只是按照控件要求的顺序授予控件的,因此,只要在不同的事件下以相同的顺序调用相同的 GUI 函数,它们最终就会被授予相同的控件 ID,不同的事件也会同步。
如果您想创建自己的自定义编辑器类、EditorWindow 类或 PropertyDrawer 类,GUI类(以及EditorGUI类)提供了一个有用的标准控件库,您可以在整个 Unity 中看到这些控件。
(对于编辑器新手来说,忽略 GUI 类是一个常见错误,但该类中的控件与 EditorGUI 中的控件一样,可以在扩展编辑器时自由使用。GUI 和 EditorGUI 并没有什么特别之处--它们只是两个供您使用的控件库--但区别在于,EditorGUI 中的控件不能在游戏构建中使用,因为它们的代码是编辑器的一部分,而 GUI 是引擎本身的一部分)。
但是,如果您想做的事情超出了标准库的范围怎么办?
让我们探索一下如何创建自定义用户界面控件。试试点击并拖动这个小演示中的彩色方框:
(注:此处嵌入的原始 WebGL 应用程序已无法在浏览器中运行)。
(您需要使用支持 WebGL 的浏览器才能观看演示,如当前版本的 Firefox)。
这些自定义滑块分别驱动一个介于 0 和 1 之间的 "浮动 "值。您可能希望在检查器中使用这样的东西作为另一种显示方式,例如,显示飞船对象不同部分的船体完整性,其中 1 代表 "无损坏",0 代表 "完全损坏"--用条形图表示颜色值可能更容易让人一眼看出飞船处于什么状态。将其构建为自定义 IMGUI 控件的代码非常简单,您可以像使用其他控件一样使用它。
第一步是确定我们的函数签名。为了涵盖所有不同的事件类型,我们的控件需要三样东西:
- 是一个 Rect,它定义了应在何处绘制自身以及应在何处响应鼠标点击。
- 条形图所代表的当前浮点数值。
- 一个 GUIStyle,其中包含控件所需的间距、字体、纹理等必要信息。在我们的例子中,这包括我们用来绘制条形图的纹理。稍后将详细介绍这一参数。
它还需要返回用户通过拖动条形图设置的值。这只对某些事件有意义,如鼠标事件,而对重绘事件则没有意义;因此默认情况下,我们将返回调用代码传入的值。我们的想法是,调用代码可以直接执行 "value = MyCustomSlider(... value ...)",而无需关心正在发生的事件,因此如果我们不返回用户设置的新值,就需要保留当前的值。
因此,得到的签名是这样的
public static float MyCustomSlider(Rect controlRect, float value, GUIStyle style)
现在我们开始执行该功能。第一步是检索控制 ID。在响应鼠标事件时,我们会用它来做一些事情。不过,即使正在处理的事件不是我们真正关心的事件,我们仍然必须请求一个 ID,以确保它不会被分配给其他控件来处理这个特定事件。请记住,IMGUI 只是按照请求的顺序提供 ID,因此如果您不请求 ID,它最终会提供给下一个控件,导致该控件在不同事件中使用不同的 ID,这很可能会破坏它。因此,在请求 ID 时,要么全要,要么不要--要么为每种事件类型都请求一个 ID,要么从不为任何事件类型请求 ID(如果您创建的控件非常简单或非交互式,那么这样做也未尝不可)。
{
int controlID = GUIUtility.GetControlID (FocusType.Passive);
作为参数传递的 FocusType.Passive 会告诉 IMGUI 该控件在键盘导航中扮演什么角色--该控件是否有可能成为当前对按键做出反应的控件。我的自定义滑块完全不响应按键,因此它指定了 "被动",但响应按键的控件可以指定 "本地 "或 "键盘"。有关它们的更多信息,请查看FocusType 文档。
接下来,我们要做的是大多数自定义控件在实现过程中都会做的事情:使用 switch 语句根据事件类型进行分支。我们不会直接使用 Event.current.type,而是使用Event.current.GetTypeForControl(),并将控件 ID 传递给它;这将过滤事件类型,以确保在某些情况下不会将键盘事件发送到错误的控件。但它并不能过滤一切,所以我们还需要自己进行一些检查。
switch (Event.current.GetTypeForControl(controlID))
{
现在,我们可以开始为不同的事件类型实施特定的行为。让我们从绘制控件开始:
case EventType.Repaint:
{
// Work out the width of the bar in pixels by lerping
int pixelWidth = (int)Mathf.Lerp (1f, controlRect.width, value);
// Build up the rectangle that the bar will cover
// by copying the whole control rect, and then setting the width
Rect targetRect = new Rect (controlRect){ width = pixelWidth };
// Tint whatever we draw to be red/green depending on value
GUI.color = Color.Lerp (Color.red, Color.green, value);
// Draw the texture from the GUIStyle, applying the tint
GUI.DrawTexture (targetRect, style.normal.background);
// Reset the tint back to white, i.e. untinted
GUI.color = Color.white;
break;
}
至此,您就可以完成该函数,并拥有一个可视化 0 和 1 之间浮点数值的 "只读 "控件。但让我们继续,让控制互动起来。
要为控件实现令人愉悦的鼠标操作,我们需要:一旦点击控件并开始拖动,就不需要将鼠标停留在控件上。对于用户来说,只需关注光标水平方向的位置,而不必担心垂直方向的移动就好得多了。这意味着用户在拖动时可能会将鼠标移到其他控件上,而我们需要这些控件忽略鼠标,直到用户再次释放按钮。
解决方法是使用GUIUtility.hotControl。这只是一个简单的变量,用于保存捕捉到鼠标的控件 ID。IMGUI 会在 GetTypeForControl() 中使用该值;当该值不为 0 时,除非传入的控件 ID 是 hotControl,否则鼠标事件会被过滤掉。
因此,设置和重置 hotControl 非常简单:
case EventType.MouseDown:
{
// If the click is actually on us...
if (controlRect.Contains (Event.current.mousePosition)
// ...and the click is with the left mouse button (button 0)...
&& Event.current.button == 0)
// ...then capture the mouse by setting the hotControl.
GUIUtility.hotControl = controlID;
break;
}
case EventType.MouseUp:
{
// If we were the hotControl, we aren't any more.
if (GUIUtility.hotControl == controlID)
GUIUtility.hotControl = 0;
break;
}
请注意,如果其他控件是热点控件,即 GUIUtility.hotControl 不是 0,也不是我们自己的控件 ID,那么这些情况就不会被执行,因为 GetTypeForControl() 将返回 "忽略",而不是鼠标上/下事件。
设置 hotControl 没问题,但当鼠标向下时,我们仍然没有做任何改变值的实际操作。最简单的方法实际上是关闭开关,然后表示当我们是 hotControl 时发生的任何鼠标事件(单击、拖动或释放)(因此是在单击+拖动过程中--但不是释放,因为在这种情况下我们已将 hotControl 清零)都会导致值发生变化:
if (Event.current.isMouse && GUIUtility.hotControl == controlID) {
// Get mouse X position relative to left edge of the control
float relativeX = Event.current.mousePosition.x - controlRect.x;
// Divide by control width to get a value between 0 and 1
value = Mathf.Clamp01 (relativeX / controlRect.width);
// Report that the data in the GUI has changed
GUI.changed = true;
// Mark event as 'used' so other controls don't respond to it, and to
// trigger an automatic repaint.
Event.current.Use ();
}
最后两个步骤--设置GUI.changed和调用Event.current.Use()--尤为重要,不仅能使该控件正确运行,还能使其与其他 IMGUI 控件和功能完美配合。特别是,将 GUI.changed 设置为 true 将允许调用代码使用EditorGUI.BeginChangeCheck()和EditorGUI.EndChangeCheck()函数来检测用户是否真正更改了控件的值;但也应避免将 GUI.changed 设置为 false,因为这样做可能会掩盖之前控件的值被更改的事实。
最后,我们需要从函数中返回一个值。你应该记得,我们说过会返回修改后的浮点数值,如果没有任何变化,则返回原来的数值,而大多数情况下都是这样:
return value;
}
我们就大功告成了。MyCustomSlider 现在是一个功能简单的 IMGUI 控件,可用于自定义编辑器、属性拖拉器、编辑器窗口等。我们还可以做得更多,比如支持多重编辑,但我们将在下文讨论。
IMGUI 还有一个特别重要的非显而易见之处,那就是它与场景视图的关系。相信大家对场景视图中绘制的辅助 UI 元素都不陌生,这些元素包括正交箭头、圆环和盒盖线,您可以点击并拖动这些元素来操作对象。这些用户界面元素被称为 "手柄"。
不明显的是,Handles 也由 IMGUI 支持!
毕竟,到目前为止,我们所说的有关 IMGUI 的内容都不是专门针对 2D 或编辑器/编辑窗口的。当然,GUI 和 EditorGUI 类中的标准控件都是 2D 的,但 EventType 和控件 ID 等基本概念完全不依赖于 2D。因此,GUI 和 EditorGUI 提供了针对编辑窗口(EditorWindows)和检查器(Inspector)中组件的编辑器(Editor)的 2D 控制,而Handles类则提供了用于场景视图的 3D 控制。就像EditorGUI.IntField会绘制一个让用户编辑单个整数的控件一样,我们也有类似的功能:
Vector3 PositionHandle(Vector3 位置,Quaternion 旋转);
通过在场景视图中提供一组交互式箭头,用户可以直观地编辑 Vector3 值。和以前一样,你也可以定义自己的 Handle 函数来绘制自定义的用户界面元素;鼠标交互的处理要稍微复杂一些,因为仅仅检查鼠标是否在矩形内已经不够了--HandleUtility类可能会对你有所帮助--但基本结构和概念都是一样的。
如果在自定义编辑器类中提供OnSceneGUI函数,就可以使用其中的 Handle 函数绘制到场景视图中,而且它们会如您所料在世界空间中正确定位。不过请记住,在自定义编辑器等 2D 上下文中使用处理程序,或在场景视图中使用图形用户界面函数也是可行的,只是在使用前可能需要设置 GL 矩阵或调用Handles.BeginGUI()和Handles.EndGUI()来设置上下文。
就 MyCustomSlider 而言,我们需要跟踪的信息实际上只有两条:滑块的当前值(由用户传入并返回给用户)以及用户是否正在更改滑块值(我们使用 hotControl 来跟踪)。但是,如果控制需要掌握的信息比这更多怎么办?
IMGUI 为与控件相关联的 "状态对象 "提供了一个简单的存储系统。您可以定义自己的存储值类,然后要求 IMGUI 管理该类的一个实例,并与您的控件 ID 关联。每个控件 ID 只允许有一个状态对象,而且不能自己实例化,IMGUI 会使用状态对象的默认构造函数为你实例化。在重载编辑器代码时,状态对象也不会序列化(每次重新编译代码时都会发生这种情况),因此你只能将它们用于短时间的内容。(请注意,即使将状态对象标记为 [可序列化],情况也是如此--序列化器根本不会访问堆的这个特殊角落)。
这里有一个例子。假设我们想要一个按钮,只要按下就会返回 true,但如果按住超过两秒,还会闪烁红色。我们需要跟踪按钮最初被按下的时间;为此,我们将把它存储在一个状态对象中。这就是我们的状态对象类:
public class FlashingButtonInfo
{
private double mouseDownAt;
public void MouseDownNow()
{
mouseDownAt = EditorApplication.timeSinceStartup;
}
public bool IsFlashing(int controlID)
{
if (GUIUtility.hotControl != controlID)
return false;
double elapsedTime = EditorApplication.timeSinceStartup - mouseDownAt;
if (elapsedTime < 2f)
return false;
return (int)((elapsedTime - 2f) / 0.1f) % 2 == 0;
}
}
当调用 MouseDownNow() 时,我们会在 "mouseDownAt "中存储鼠标被按下的时间,然后使用 IsFlashing 函数来告诉我们 "按钮现在是否应该被染成红色"--正如你所看到的,如果它不是热控件,或者从它被点击到现在的时间少于 2 秒,它肯定不会是红色的,但在此之后,我们会让它每 0.1 秒改变一次颜色。
下面是实际按钮控件本身的代码:
public static bool FlashingButton(Rect rc, GUIContent content, GUIStyle style)
{
int controlID = GUIUtility.GetControlID (FocusType.Native);
// Get (or create) the state object
var state = (FlashingButtonInfo)GUIUtility.GetStateObject(
typeof(FlashingButtonInfo),
controlID);
switch (Event.current.GetTypeForControl(controlID)) {
case EventType.Repaint:
{
GUI.color = state.IsFlashing (controlID)
? Color.red
: Color.white;
style.Draw (rc, content, controlID);
break;
}
case EventType.MouseDown:
{
if (rc.Contains (Event.current.mousePosition)
&& Event.current.button == 0
&& GUIUtility.hotControl == 0)
{
GUIUtility.hotControl = controlID;
state.MouseDownNow();
}
break;
}
case EventType.MouseUp:
{
if (GUIUtility.hotControl == controlID)
GUIUtility.hotControl = 0;
break;
}
}
return GUIUtility.hotControl == controlID;
}
非常简单--你应该能认出 mouseDown/mouseUp 情况下的代码与我们在上文自定义滑块中捕捉鼠标的代码非常相似。唯一的区别是按下鼠标时调用 state.MouseDownNow(),以及在重绘事件中更改 GUI.color。
眼尖的人可能已经注意到,重绘事件还有一个关键区别,那就是调用 style.Draw()。这是怎么回事?
在构建自定义滑块控件时,我们使用了GUI.DrawTexture来绘制条形图本身。这样就可以了,但我们的 FlashingButton 上除了按钮本身的 "圆角矩形 "图像外,还需要有一个标题。我们可以尝试用 GUI.DrawTexture 来绘制按钮图像,然后再用GUI.Label来绘制标题......但我们可以做得更好。我们可以使用 GUI.Label 用来绘制自身的相同技术,省去中间环节。
GUIStyle包含有关 GUI 元素可视化属性的信息--既包括字体或文本颜色等基本属性,也包括更微妙的布局属性,如间距的大小。所有这些信息都存储在 GUIStyle 中,同时还存储了使用该样式计算某些内容的宽度和高度的函数,以及将内容实际绘制到屏幕上的函数。
事实上,GUIStyle 并不只是为控件提供一种样式:它还能在 GUI 元素可能遇到的各种情况下提供样式--在控件被悬停、键盘焦点、禁用以及 "激活"(例如,按钮正在被按下)时以不同的方式绘制。您可以为所有这些情况提供颜色和背景图像信息,GUIStyle 将在绘制时根据控件 ID 选择合适的颜色和背景图像。
获取 GUIStyles 的主要方法有四种,您可以使用它们来绘制控件:
- 在代码中构建一个(new GUIStyle()),并在其上设置值。
- 使用EditorStyles类中的一种内置样式。如果你想让自己的自定义控件看起来与内置控件一样--绘制自己的工具栏、检查器风格的控件等--那么这里就是你要找的地方。
- 如果你只是想在现有样式的基础上创建一个小的变化,例如,一个普通的按钮,但文本是右对齐的,那么你可以克隆 EditorStyles 类中的样式(new GUIStyle(existantStyle)),然后只需更改你想更改的属性即可。
- 从GUISkin 获取。
GUISkin 本质上是一大捆 GUIStyle 对象;重要的是,它可以作为资产在项目中创建,并通过检查器自由编辑。如果您创建了一个控件并进行查看,您会看到所有标准控件类型的插槽--方框、按钮、标签、切换等--但作为自定义控件的作者,请将注意力放在底部的 "自定义样式 "部分。在这里,您可以设置任意数量的自定义 GUIStyle 条目,给每个条目起一个唯一的名称,然后可以使用GUISkin.GetStyle("nameOfCustomStyle") 来检索它们。如果将皮肤保存在 "编辑器默认资源 "文件夹中,可以使用EditorGUIUtility.LoadRequired();或者,也可以使用AssetDatabase.LoadAssetAtPath () 等方法从项目的其他地方加载。(只要不把编辑器专用资产错放到资产包或资源文件夹中就可以了!)。
有了 GUIStyle,您就可以使用GUIStyle.Draw() 来绘制GUIContent(文本、图标和工具提示的混合体),将您要绘制的矩形、要绘制的 GUIContent 以及控件 ID 传递给它,以确定该内容是否具有键盘焦点等功能。
您一定注意到了,我们迄今为止讨论和编写的所有 GUI 控件都包含一个 Rect 参数,用于确定控件在屏幕上的位置。既然我们已经讨论了 GUIStyle,当我说 GUIStyle 包含 "布局属性,比如它需要多少间距 "时,你可能会停顿一下。你可能会想"哦。这是否意味着我们必须做大量的工作来计算我们的 Rect 值,从而使间距值得到尊重?
这当然是我们可以采用的一种方法;但还有一种更简单的方法。IMGUI 包含一种 "布局 "机制,可以自动为我们的控件计算适当的 Rect 值,并将间距等因素考虑在内。那么,它是如何工作的呢?
诀窍在于为控件提供一个额外的 EventType 值,以便做出响应:EventType.Layout.IMGUI 会将事件发送给 GUI 代码,而您调用的控件会通过调用 IMGUI 布局函数(GUILayoutUtility.GetRect()、GUILayout. BeginHorizonal/Vertical 和GUILayout.EndHorizontal/Vertical 等)做出响应,IMGUI 会记录这些函数,从而有效地建立起布局中的控件树及其所需的空间。一旦完成,树形结构完全建立,IMGUI 就会对树形结构进行递归传递,计算元素的实际宽度和高度,以及它们相互之间的位置关系,将相邻的控件依次定位,等等。
然后,当需要执行 EventType.Repaint 事件或任何其他类型的事件时,控件会调用相同的 IMGUI 布局函数。只是这一次,IMGUI 不再记录调用,而是 "回放 "之前在布局事件中记录的调用,返回它计算出的矩形;在布局事件中调用 GUILayoutUtility.GetRect() 注册您需要的矩形后,您在重绘事件中再次调用它,它就会实际返回您应该使用的矩形。
与控件 ID 一样,这意味着在 "布局 "事件和其他事件之间进行的布局调用必须保持一致,否则最终将检索到错误控件的计算矩形。这也意味着,GUILayoutUtility.GetRect() 在布局事件中返回的值是无用的,因为 IMGUI 在事件结束和布局树处理完毕之前,不会真正知道它应该给你的矩形。
对于我们的自定义滑块控件来说,这看起来像什么?实际上,我们可以非常容易地编写一个支持布局的 Version Control 版本,因为一旦我们从 IMGUI 获取了一个矩形,就可以直接调用我们已经编写的代码:
public static float MyCustomSlider(float value, GUIStyle style)
{
Rect position = GUILayoutUtility.GetRect(GUIContent.none, style);
return MyCustomSlider(position, value, style);
}
对 GUILayoutUtility.GetRect 的调用将做两件事:在布局事件中,它将记录我们希望使用给定的样式绘制一些空的内容(空是因为没有特定的文本或图像需要腾出空间);在其他事件中,它将检索一个实际的矩形供我们使用。这确实意味着在布局事件中,我们使用一个假矩形调用 MyCustomSlider,但这并不重要--我们仍然需要这样做,以确保对 GetControlID() 进行常规调用,并且在布局事件中,矩形不会实际用于任何用途。
您可能想知道,在内容为 "空 "且只有一个样式的情况下,IMGUI 如何计算出滑块的大小。这些信息并不多--我们依赖于样式中指定的所有必要信息,IMGUI 可以利用这些信息来确定要分配的矩形。但是,如果我们想让用户来控制,或者说,使用样式中的固定高度,但让用户来控制宽度,那该怎么办呢?我们该怎么做?
答案就在GUILayoutOption类中。该类的实例表示对布局系统的指令,即应以特定方式计算特定矩形;例如,"高度应为 30 "或 "应水平展开以填充空间 "或 "宽度必须至少为 20 像素"。我们通过调用 GUILayout 类中的工厂函数(如GUILayout.ExpandWidth()、GUILayout.MinHeight() 等)来创建它们,并将它们作为数组传递给 GUILayoutUtility.GetRect()。它们会被存储到布局树中,并在布局事件结束后处理布局树时被考虑在内。
为了方便用户提供任意数量的 GUILayoutOption 实例,而无需创建和管理自己的数组,我们利用了 C# 的 "params "关键字,它可以让您调用传递任意数量参数的方法,并将这些参数自动打包到数组中。这是我们修改后的滑块:
public static float MyCustomSlider(float value, GUIStyle style, params GUILayoutOption[] opts)
{
Rect position = GUILayoutUtility.GetRect(GUIContent.none, style, opts);
return MyCustomSlider(position, value, style);
}
正如你所看到的,我们只是将用户给我们的信息传递给 GetRect。
我们在这里使用的方法--将手动定位的 IMGUI 控件函数封装在自动布局版本中--适用于几乎所有的 IMGUI 控件,包括 GUI 类中的内置控件。事实上,GUILayout类正是使用这种方法来提供 GUI 类中控件的自动布局版本(我们还提供了相应的EditorGUILayout类来封装 EditorGUI 类中的控件)。在构建自己的 IMGUI 控件时,您可能需要遵循这种孪生类约定。
混合使用自动布局和手动定位控制也是完全可行的。您可以调用 GetRect 预留一块空间,然后自己计算将矩形分割成子矩形,再用这些子矩形绘制多个控件;布局系统不会以任何方式使用控件 ID,因此每个布局矩形拥有多个控件(甚至每个控件拥有多个布局矩形)不会有任何问题。这有时比完全使用布局系统要快得多。
此外,请注意,如果要编写 PropertyDrawer,则不应使用布局系统;而应使用传递给 PropertyDrawer.OnGUI() 覆盖的矩形。其原因是,出于性能考虑,编辑器类本身实际上并不使用布局系统;它只是自己计算一个简单的矩形,并为每个连续的属性向下移动矩形。因此,如果你在 PropertyDrawer 中使用布局系统,它就不会知道在你之前绘制的任何属性,并最终将你定位在它们之上。这不是你想要的!
到目前为止,我们所讨论的一切都能让你建立自己的 IMGUI 控件,而且运行起来相当流畅。还有几件事需要讨论,那就是当你真的想把自己制作的东西打磨到与 Unity 内置控件相同的水平时。
首先是序列化属性的使用。我不想在这篇文章中过多地讨论序列化属性系统--我们下次再讨论这个问题--只想快速总结一下:序列化属性(SerializedProperty)"封装 "了由 Unity 序列化(加载和保存)系统处理的单个变量。你编写的每个脚本上的每个变量,以及你在检查器中看到的每个引擎对象上的每个变量,都可以通过序列化属性 API 进行访问,至少在编辑器中是这样。
SerializedProperty 非常有用,因为它不仅能让您访问变量的值,还能提供一些信息,例如变量的值是否与它来自的预制件上的值不同,或者带有子字段(如结构体)的变量在检查器中是展开还是折叠。它还会将您对数值所做的任何更改整合到撤消和场景清除系统中。它还可以让你在不实际创建对象的托管版本的情况下完成此操作,从而大大提高性能。因此,如果我们希望我们的 IMGUI 控件能轻松自如地使用一系列编辑器功能(撤销、场景脏化、预制件重写等),我们就应该确保我们支持 SerializedProperty。
如果查看将 SerializedProperty 作为参数的 EditorGUI 方法,就会发现签名略有不同。启用 SerializedProperty 的 IMGUI 控件不采用之前自定义滑块的 "接收浮点数,返回浮点数 "的方法,而是只接收一个 SerializedProperty 实例作为参数,不返回任何内容。这是因为他们需要对值进行的任何更改都会直接应用到 SerializedProperty 本身。因此,我们之前自定义的滑块现在看起来就像这样:
public static void MyCustomSlider(Rect controlRect, SerializedProperty prop, GUIStyle style)
以前的 "value "参数和返回值都不见了,取而代之的是用于传递序列化属性的 "prop "参数。要获取属性的当前值以绘制滑块条,我们只需访问prop.floatValue,当用户更改滑块位置时,我们只需赋值给 prop.floatValue。
不过,在 IMGUI 控制代码中使用整个 SerializedProperty 还有其他好处。例如,预制件实例中修改过的属性会以粗体显示。只需检查 SerializedProperty 上的prefabOverride属性,如果该属性为真,就可以采取任何必要的措施,以不同的方式显示控件。令人欣慰的是,如果您只想将文本加粗,那么只要您在绘制时不在 GUIStyle 中指定字体,IMGUI 就会自动帮您完成这项工作。(如果您在 GUIStyle 中指定了字体,那么您就需要自己处理这个问题--拥有普通字体和粗体字体版本,并在绘制时根据 prefabOverride 在它们之间进行选择)。
您需要的另一个主要功能是支持多对象编辑,即当控件需要同时显示多个值时,可以优雅地处理。通过检查EditorGUI.showMixedValue 的值来测试这一点;如果该值为真,则说明您的控件被用于同时显示多个不同的值,因此请采取任何必要的措施来表明这一点。
加粗预置覆盖(bold-on-prefabOverride)和显示混合值( showMixedValue )机制都要求使用EditorGUI.BeginProperty()和EditorGUI.EndProperty() 设置属性的上下文。推荐的模式是,如果您的控制方法将 SerializedProperty 作为参数,那么它将自行调用 BeginProperty 和 EndProperty,而如果它处理的是 "原始 "值(类似于 EditorGUI.IntField 直接获取和返回 int,而不处理属性),那么调用代码将负责调用 BeginProperty 和 EndProperty。(这其实是有道理的,因为如果您的控件处理的是 "原始 "值,那么无论如何它都没有可以传递给 BeginProperty 的 SerializedProperty 值)。
public class MySliderDrawer : PropertyDrawer
{
public override float GetPropertyHeight (SerializedProperty property, GUIContent label)
{
return EditorGUIUtility.singleLineHeight;
}
private GUISkin _sliderSkin;
public override void OnGUI (Rect position, SerializedProperty property, GUIContent label)
{
if (_sliderSkin == null)
_sliderSkin = (GUISkin)EditorGUIUtility.LoadRequired ("MyCustomSlider Skin");
MyCustomSlider (position, property, _sliderSkin.GetStyle ("MyCustomSlider"), label);
}
}
// Then, the updated definition of MyCustomSlider:
public static void MyCustomSlider(Rect controlRect, SerializedProperty prop, GUIStyle style, GUIContent label)
{
label = EditorGUI.BeginProperty (controlRect, label, prop);
controlRect = EditorGUI.PrefixLabel (controlRect, label);
// Use our previous definition of MyCustomSlider, which we’ve updated to do something
// sensible if EditorGUI.showMixedValue is true
EditorGUI.BeginChangeCheck();
float newValue = MyCustomSlider(controlRect, prop.floatValue, style);
if(EditorGUI.EndChangeCheck())
prop.floatValue = newValue;
EditorGUI.EndProperty ();
}
我希望这篇文章能让你对 IMGUI 的一些核心部分有所了解,如果你想真正将编辑器定制提升到一个新的水平,你就需要了解这些核心部分。在成为编辑器大师之前,您还需要了解更多内容--序列化对象(SerializedObject)/序列化属性(SerializedProperty)系统、自定义编辑器(CustomEditor)的使用、编辑器窗口(EditorWindow)与属性抽屉(PropertyDrawer)、撤消(Undo)的处理等--但是,IMGUI 在释放 Unity 创建自定义工具的巨大潜力方面发挥了重要作用--既可以在资产商店(Asset Store)上销售,也可以为您自己团队的开发人员赋能。
请在评论中向我提出您的问题和反馈!