IMGUI编辑器开发——基于ControlID的事件交互机制

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的执行,用于计算布局相关的事项。

  1. 其次才轮到MouseDrag事件,该事件触发第二次OnGUI更新的执行。

  2. 最后轮到Repaint()事件,该事件触发第三次OnGUI的执行,并且基于前两轮的计算结果,更新用户视图的显示。

上述由用户操作引起的MouseDrag事件,在一帧中,引起了三次OnGUI更新。但是在某些由IMGUI本身判定需要触发帧更新的情况下,事件流只会包含1,3 ,也就是两次OnGUI更新。

ControlID 及其相应的衍生概念*

什么是 ControlID

IMGUI是即时性的,在帧的流逝中不会记录任何关于控件的状态信息。但是,即时性指的是不同帧之间 ,对帧内来说,保持控件的状态信息是十分必要的,那么在帧内是如何维系跨多个OnGUI之间的衔接状态的呢? 答案就是ControlID 。对于每一个控件而言 ,都具有在IMGUI中标识自己的唯一ID。这个ID本身不会被记录,也是在每个事件所触发的OnGUI内生成的,但是却能保持帧内跨多个OnGUI间的一致性。在每帧的事件流中 ,它的大致流程如下 :

  1. 任何事件被触发前,IMGUI会清除所有的ControlID。

2.在每个事件回调函数的执行过程中(包括OnGUI、OnSceneGUI等),每个control都需要调用GUIUtility.GetControlID()来为自己分配id。但是,我们必须确保该方法在帧内多个OnGUI之间的调用顺序是一致的,才能确保id的一致性。例如,考虑一个MouseDown事件引起的帧更新,事件流的执行顺序是: Layout,MouseDown,Repaint ,将会触发一帧三次的OnGUI,我们必须确保在这三次OnGUI中,所有GetControlID()调用顺序的一致性。

  1. 在Layout事件发生时,我们可以通过HandleUtility.AddControl()来通知IMGUI,计算control和鼠标之间的相对位置,这也是为什么control能响应事件的关键所在。例如,当鼠标点击时,如何确定哪个控件可以响应鼠标点击?自然是离鼠标最近的控件才可以响应。

  2. 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都对应着两个不同版本,并且都是用函数的方式实现:

  1. 基于GUI界面坐标系的手动布局,需要指定Rect参数

  2. 基于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...

caseymuratori.com/blog_0001

discussions.unity.com/t/how-does-...

github.com/Bunny83/Uni...

forum.unity.com/threads/how...

discussions.unity.com/t/onscenegu...

相关推荐
Thomas游戏开发3 小时前
Unity3D TextMeshPro终极使用指南
前端·unity3d·游戏开发
Thomas游戏开发1 天前
Unity3D 逻辑代码性能优化策略
前端框架·unity3d·游戏开发
Thomas游戏开发2 天前
Unity3D HUD高性能优化方案
前端框架·unity3d·游戏开发
陈哥聊测试3 天前
游戏公司如何同时管好上百个游戏项目?
游戏·程序员·游戏开发
一名用户4 天前
unity随机生成未知符号教程
c#·unity3d·游戏开发
Be_Somebody9 天前
计算机图形学——Games101深度解析_第二章
游戏开发·计算机图形学·games101
GameTomato9 天前
【IOS】【OC】【应用内打印功能的实现】如何在APP内实现打印功能,连接本地打印机,把想要打印的界面打印成图片
macos·ios·objective-c·xcode·游戏开发·cocos2d
Be_Somebody10 天前
计算机图形学——Games101深度解析_第一章
游戏开发·计算机图形学·games101
飞起的猪21 天前
【虚幻引擎】UE5独立游戏开发全流程(商业级架构)
ue5·游戏引擎·游戏开发·虚幻·独立开发·游戏设计·引擎架构
北冥没有鱼啊1 个月前
UE 像素和线框盒子 材质
c++·ue5·游戏开发·虚幻·材质