IMGUI
在Unity中,IMGUI全称为Immediate Mode GUI(即时模式GUI),区别于UGUI、NGUI等基于GameObject的运行时UI框架,其被广泛的用于编辑器扩展开发 。"即时"意味着IMGUI完全基于代码驱动,不会保存任何关于UI控件的状态信息。也就是说,IMGUI中每一帧都是一个全新的开始,每一次视图刷新都需要依赖于用户提供的信息来决定是否要显示UI控件,以及在哪里显示UI控件。关于即时模式GUI的详细设计思想,可以参考开发者CASEY MURATORI 的演讲 : youtu.be/Z1qyvQsjK5Y 。
以绘制一个关卡加载界面为例 :
C++ /* 关卡加载 */ using UnityEngine; using System.Collections; public class GUITest : MonoBehaviour { void OnGUI () { // 绘制一个半灰的透明背景 GUI.Box(new Rect(10,10,100,90), "Loader Menu"); // 第一个按钮,按下后加载关卡1 if(GUI.Button(new Rect(20,40,80,20), "Level 1")) { Application.LoadLevel(1); } // 第二个按钮,按下后加载关卡2 if(GUI.Button(new Rect(20,70,80,20), "Level 2")) { Application.LoadLevel(2); } } } |
---|
在每一帧中,OnGUI都会被调用,IMGUI的每一次绘制,都需要依赖Box和Button提供的Rect位置信息,来决定控件的绘制区域。最终效果如下图所示:
OnGUI 的调用机制****
在前面,我们说过OnGUI会被每帧调用,具体来说,其实OnGUI每帧都会被至少调用两次,这是由IMGUI自身的事件机制来决定的。
事件的分类
在IMGUI中,触发每帧更新的事件可以被分为以下几类 :
•布局事件:布局事件对应于EventType.Layout,它是每帧的事件流中发生的第一个事件,其余的事件均发生在它之后。这个事件被用于自动布局的计算,诸如Control的大小与位置均在此期间被确定。当我们将useGUILayout设置为false后,每帧将不再接收这个事件,这也意味着disable了IMGUI的自动布局系统。
•鼠标事件:诸如MouseDown, MouseUp, MouseMove(editor only), MouseDrag, ScrollWheel,这些都是鼠标事件。
• 键盘事件:键盘事件包括 KeyDown和KeyUp ,它们直接匹配操作系统本身的真实键盘响应频率,这是有别于Input.GetKeyDown / Up 的一点 。
• 绘制事件:EventType.Repaint(),该事件发生时,意味着所有的界面GUI元素将会被绘制 。
• 已使用事件: EventType.used 。在一个实际的用户界面中,由用户触发的事件在被某个Control处理后,通常不希望该事件再次被其他Control响应处理,于是我们就可以将当前事件的类型设置为EventType.Used。通常来说,在IMGUI中提供的标准控件中,对used事件都是直接忽略的 ,我们编写自己的Control时也应该遵循这种规范。
事件的优先级
在IMGUI中,一帧其实就是一串事件流,这个事件流至少包含了两个事件:Layout和Repaint。例如,当鼠标指针在界面上拖拽时,将会触MouseDrag事件。但是在当前帧,OnGUI将会被MouseDrag唯一触发嘛 ? 其实并不是,在这一帧中,事件流是按如下的先后顺序组织的 :
1.首先,IMGUI将会广播Layout事件,该事件将会触发第一次OnGUI的执行,用于计算布局相关的事项。
-
其次才轮到MouseDrag事件,该事件触发第二次OnGUI更新的执行。
-
最后轮到Repaint()事件,该事件触发第三次OnGUI的执行,并且基于前两轮的计算结果,更新用户视图的显示。
上述由用户操作引起的MouseDrag事件,在一帧中,引起了三次OnGUI更新。但是在某些由IMGUI本身判定需要触发帧更新的情况下,事件流只会包含1,3 ,也就是两次OnGUI更新。
ControlID 及其相应的衍生概念*
什么是 ControlID ?
IMGUI是即时性的,在帧的流逝中不会记录任何关于控件的状态信息。但是,即时性指的是不同帧之间 ,对帧内来说,保持控件的状态信息是十分必要的,那么在帧内是如何维系跨多个OnGUI之间的衔接状态的呢? 答案就是ControlID 。对于每一个控件而言 ,都具有在IMGUI中标识自己的唯一ID。这个ID本身不会被记录,也是在每个事件所触发的OnGUI内生成的,但是却能保持帧内跨多个OnGUI间的一致性。在每帧的事件流中 ,它的大致流程如下 :
- 任何事件被触发前,IMGUI会清除所有的ControlID。
2.在每个事件回调函数的执行过程中(包括OnGUI、OnSceneGUI等),每个control都需要调用GUIUtility.GetControlID()来为自己分配id。但是,我们必须确保该方法在帧内多个OnGUI之间的调用顺序是一致的,才能确保id的一致性。例如,考虑一个MouseDown事件引起的帧更新,事件流的执行顺序是: Layout,MouseDown,Repaint ,将会触发一帧三次的OnGUI,我们必须确保在这三次OnGUI中,所有GetControlID()调用顺序的一致性。
-
在Layout事件发生时,我们可以通过HandleUtility.AddControl()来通知IMGUI,计算control和鼠标之间的相对位置,这也是为什么control能响应事件的关键所在。例如,当鼠标点击时,如何确定哪个控件可以响应鼠标点击?自然是离鼠标最近的控件才可以响应。
-
Event.current对应的是全局事件,而在某些情况下,我们需要的是全局事件下每一个具体的Contro所对应的局部事件。例如,对Button来说,点击并且开始拖拽,对应的事件是MouseDrag,但是对其它控件来说,其实只是一个MouseMove。所以,我们可以通过 Event.GetTypeForControl(controlID)来获取在当前的全局事件下,具体的controlID所对应的局部事件。
在IMGUI中,内置控件大体上按如下流程实现, :
C#
int controlID = GUIUtility.GetControlID(FocustType.Passive);
Vector3 screenPosition = Handles.matrix.MultiplyPoint(handlePosition);
//对全局事件进行过滤
switch (Event.current.GetTypeForControl(controlID))
{
//计算布局
case EventType.Layout:
HandleUtility.AddControl(
controlID,
HandleUtility.DistanceToCircle(screenPosition, 1.0f)
);
case EventType.MouseDown:
if (HandleUtility.nearestControl == controlID)
{
//设置界面的热键,即聚焦的中心控件
GUIUtility.hotControl = controlID;
Event.current.Use();
}
break;
case EventType.MouseUp:
if (GUIUtility.hotControl == controlID)
{
// 释放热键
GUIUtility.hotControl = 0;
Event.current.Use();
}
break;
case EventType.MouseDrag:
if (GUIUtility.hotControl == controlID)
{
// 进行拖拽事件的处理
GUI.changed = true;
Event.current.Use();
}
break;
}
ControlID 的衍生概念****
• GUIUtility.hotControl :这是一个全局共享的状态变量,记录controlId。将控件指定为hotControl后,控件将变成整个界面的聚焦中心,拥有对事件的独家处理权,即其它控件在此期间不会再对事件进行响应。
• HandleUtility.nearestControl 【文档未记录】 :记录离鼠标指针最近的控件Id,通过HandleUtility.AddControl()来计算。
• HandleUtility.AddControl() : 在每帧的Layout事件,实时的更新鼠标与控件的相对位置 ,Unity内部的实现源码如下图所示 :
c#
/*unity 2021内部源码 */
public static void AddControl(int controlId, float distance)
{
if (distance < s_CustomPickDistance && distance > 5f)
{
distance = 5f;
}
if (distance <= s_NearestDistance)
{
s_NearestDistance = distance;
s_NearestControl = controlId;
}
}
• HandleUtility.AddDefaultControl():其实就是对AddControl的一个包裹,无论鼠标是不是在这个Control的命中范围内,都将该Control作为离鼠标最近的控件 。源码如下所示 :
c#
*Unity 2021内部源码*/
public static void AddDefaultControl(int controlId)
{
AddControl(controlId, 5f);
}
这个方法常用于处理与场景直接相关的操作,例如笔刷等自定义场景工具 ,可以屏蔽掉场景内置工具(移动、缩放旋转等)对操作的影响,让笔刷这类自定义工具能独占对事件的处理。
• Event.GetTypeForControl() :根据ControlId过滤事件,Unity5.X之前有源码,5.x之后的版本被移动到了Native Code,无法直接查看 :
c#
/*5.x之前的源码 */
public EventType GetTypeForControl(int controlID)
{
if (GUIUtility.hotControl == 0)
{
return this.type;
}
switch (this.m_Type)
{
case EventType.MouseDown:
case EventType.MouseUp:
case EventType.MouseMove:
case EventType.MouseDrag:
if (!GUI.enabled)
{
return EventType.Ignore;
}
if (GUIClip.enabled || GUIUtility.hotControl == controlID)
{
return this.m_Type;
}
return EventType.Ignore;
case EventType.KeyDown:
case EventType.KeyUp:
if (!GUI.enabled)
{
return EventType.Ignore;
}
if (GUIClip.enabled || GUIUtility.hotControl == controlID || GUIUtility.keyboardControl == controlID)
{
return this.m_Type;
}
return EventType.Ignore;
case EventType.ScrollWheel:
if (!GUI.enabled)
{
return EventType.Ignore;
}
if (GUIClip.enabled || GUIUtility.hotControl == controlID || GUIUtility.keyboardControl == controlID)
{
return this.m_Type;
}
return EventType.Ignore;
default:
return this.m_Type;
}
}
基本上来看 ,GetTypeForControl就是一个事件过滤器,如果hotControl或keyControl为0,则返回原来的全局事件。
自定义 UI 控件
接下来,我们将通过实现一个自定义的Button Control,来对上述的概念进行综合的应用。虽然是自定义的Button,但是我们将遵照IMGUI的标准规范 ,这意味着内置Button大体上也遵循类似的实现方式。
在IMGUI中,每一个Control都对应着两个不同版本,并且都是用函数的方式实现:
-
基于GUI界面坐标系的手动布局,需要指定Rect参数
-
基于Unity GUI系统的自动布局机制 。
我们将首先实现手动布局的版本,因为在手动版本的基础上,十分容易实现自动布局的版本 ,反之则不然。
对于手动版本的Button而言,需要我们为其指定位置和大小 :
js
using UnityEditor;
using UnityEngine;
public static class MyGUI
{
public static bool MyButton(Rect position, string label)
{
......
}
}
每一个Control都需要具备一个唯一标识符,即ControlID,MyButton也不例外 :
js
private static readonly int s_ButtonHint ="MyGUI.Button".GetHashCode();
private static bool MyButton(Rect position, GUIContent label)
{
//为MyButton获取一个ControlID
int controlID = GUIUtility.GetControlID(s_ButtonHint, FocusType.Passive, position);
bool result = false;
...
return result;
}
类似于网页开发中为HTML指定样式的CSS,在IMGUI中 ,我们也可以为control指定自定义的风格样式,这是通过GUIStyle来实现的 。GUI.skin内置了许多现成的GUIStyle,这里使用button现成的样式。需要注意的是,一切有关图形相关的计算都应该由Repaint事件处理。
js
//进行全局事件的过滤
switch (Event.current.GetTypeForControl(controlID)) {
case EventType.Repaint:
GUI.skin.button.Draw(position, label, controlID);
break;
}
上述代码中,为button样式指定了controlID,这是为了能让IMGUI系统能对我们的控件保持状态追踪,以便根据每帧的状态自动切换样式。例如,如果在当前帧,系统判定到hotControl与ControlID相等,这就代表按钮被按下 ,在执行上述代码时,系统将会绘制与按下状态相对应的样式。
接下来,最重要的是能让MyButton检测到输入事件:
js
case EventType.MouseDown:
//只有在GUI系统处于激活态,并且鼠标指针位于MyButton的矩形范围之内时,才进行事件处理
if (GUI.enabled && position.Contains(Event.current.mousePosition)) {
// 将MyButton设置为全局热键,全局界面聚焦于此
GUIUtility.hotControl = controlID;
// 将事件消耗,其它控件不再响应。会触发一个Repaint
Event.current.Use();
}
break;
case EventType.MouseDrag:
if (GUIUtility.hotControl == controlID) {
//将拖拽事件消耗掉 ,以便让其它控件在MouseDown与MouseUp之间,
//没有机会对这个一直被触发的拖拽事件做出任何响应,
//于是就会一直保持按钮被按下的样式,增强用户体验。
Event.current.Use();
}
break;
case EventType.MouseUp:
if (GUIUtility.hotControl == controlID) {
//按钮抬起后,应该将hotControl设置为0,以释放MyButton
//如果 在这一步不释放的话 ,将会导致这个界面被冻结住,其它控件无法响应事件 。
GUIUtility.hotControl = 0;
// 如果鼠标抬起的那一刻,指针还位于Mybutton的空间位置内,
//那么就意味着 MyButton是被成功点击的
if (position.Contains(Event.current.mousePosition)) {
result = true;
//MyButton独占MouseUp事件。
Event.current.Use();
}
}
break;
现在,手动版本的Mybutton大体上完成了,将其接入IMGUI的自动布局机制 :
js
public static bool Button(GUIContent label, params GUILayoutOption[] options)
{
//获取由系统自动布局的排布位置
Rect position = GUILayoutUtility.GetRect(label, GUI.skin.button, options);
return Button(position, label);
}
public static bool Button(string label, params GUILayoutOption[] options)
{
return Button(TempContent(label), options);
}
最终的显示效果如下图所示 :.

参考资料****
目前官方文档对ControlID相关概念的解释十分缺乏,上文主要参考了以下几篇可靠的文档源 :
www.gamedeveloper.com/programming...
discussions.unity.com/t/how-does-...