最大的问题是unity看不到源码,网上多多少少有一些散在知识点,但是看了很抽象,有点像使用工具手册,知其然不知其所以然。不像cocos2d-x的渲染方式在网上有详细解释,更有类似《我所知道的cocos2d-x》来详细解读cocos2d-x源码中的渲染部分。请教下有没有大神能够提供些专业书籍解读UGUI的渲染流程

柴柴爱 Coding

最近游戏行业寒冬,已经很久没发游戏版号了,小柴觉得这对于游戏开发者来说不是一件好事,要做好行业洗牌的准备。为了随时能够跳槽,保持随时炒掉老板的能力,我们得随时 UGUI 是 Unity 开发者跳槽时必问的内容。

UGUI 源码下载地址:https://github.com/Unity-Technologies/uGUI

本次理解重点内容结构

Are you ready? Let‘s go !

在游戏运行过程中是如何显示一个 UI,并捕获用户的输入和操作从而驱动应用程序作出反应的呢?其中涉及到显示、事件检测、事件调度、事件处理。

  • 显示:简单来说在计算机中所有的形状都是由点和面形成的,由点构成三角面,三角面构成我们所看到的 UI,显示不是这篇文章的重点。
  • 捕获:通过射线检测,从摄像机出发出射线穿过当前 Pointer 或者 Touch 所在位置,获得碰撞的 GameObject 列表。
  • 调度:发生在 EventSystem 的 Update 中,找到当前的 Module 并处理,处理中包含了对移动点击拖动事件的处理。
  • 处理:对于发生的事件,会由 ExecuteEvents 中的 Execute 方法找到合适的方法处理。

上面这张图是一次点击事件到响应的完整调用链,我们可以整理得到一下

以上是 UGUI 对事件相应的整体流程,基于 StandalongInputModule 输入,TouchInputModule 也是类似的。

一、事件数据

BaseEventData:是事件数据类的父类,其中包括 EventSystem、InputModule 和当前选中 GameObject 的引用

AxisEventData:滚轮事件数据,只记录滚动的方向数据。

PointerEventData:点位事件数据,其中包含当前位置,滑动距离,点击时间以及不同状态下 GameObject 的引用

当点击事件发生时,UGUI 可以获得点位事件数据,这是后续处理该事件重要的依据,在整个事件处理流程中进行传递。打个比方说,

你要处理一件事情,总得需要知道事情发生在哪里,发生在什么时候,事件本身的信息必须要记录下来才能讨论如何处理。

二、输入模块

输入检测模块规定了对事件的处理逻辑和细节,如处理鼠标点击事件,拖拽和移动等,其中 TouchInputModule 主要是面向触摸平台和移动设备的输入检测模块,StandaloneInputModule 主要是面向标准鼠标键盘的。具体处理方式和细节我们不需要去关心,我们来观察其中几个重要的方法。(源码模块比较枯燥,不过咱们不必要拘泥于细节,就看看大概还是比较简单的)

Process 方法如下:

public override void Process()
    {
        if (!eventSystem.isFocused && ShouldIgnoreEventsOnNoFocus())
        //额外条件判断是否要处理
             return;
        bool usedEvent = SendUpdateEventToSelectedObject();
        // 断点①进入ProcessTouchEvents
        if (!ProcessTouchEvents() && input.mousePresent)  
            //判断是否是处理TouchEvent,return Input.touch>0;
            ProcessMouseEvent();
            //处理鼠标的所有事件
        if (eventSystem.sendNavigationEvents)
        {
            if (!usedEvent)
            //SendMoveEventToSelectedObject方法会判断是否满足移动条件
                usedEvent |= SendMoveEventToSelectedObject();
​
            if (!usedEvent)
            //SendSubmitEventToSelectedObject方法判断是否发生提交或取消事件
                SendSubmitEventToSelectedObject();
        }
    }

Process 处理流程会进行非常复杂的判断,以确定当前发生的事件类型并通知 GameObject 执行相应的方法,我们只要清楚大概的逻辑就可以了,下面我们具体来看几个方法。

private bool ProcessTouchEvents()
    {
      //touchCount是指当前触摸的点数,由系统提供
        for (int i = 0; i < input.touchCount; ++i)
        {
            Touch touch = input.GetTouch(i);           
            if (touch.type == TouchType.Indirect)
                continue;
​
            bool released;
            bool pressed;
           //获取PointEventData,是否按下,是否释放等信息
            var pointer = GetTouchPointerEventData(touch, out pressed, out released);
            //// 断点②进入ProcessTouchPress,主要处理按下和松开
            ProcessTouchPress(pointer, pressed, released);
​
            if (!released)
            {
              //如果没有释放,判断是否要处理移动和拖拽事件
                ProcessMove(pointer);
                ProcessDrag(pointer);
            }
            else
                RemovePointerData(pointer);
        }
        return input.touchCount > 0;
    }

进入断点②

​
        protected void ProcessTouchPress(PointerEventData pointerEvent, bool pressed, bool released)
        {
            var currentOverGo = pointerEvent.pointerCurrentRaycast.gameObject;
​
            //发生触摸,按下
            if (pressed)
            {
                pointerEvent.eligibleForClick = true;
                pointerEvent.delta = Vector2.zero;
                pointerEvent.dragging = false;
                pointerEvent.useDragThreshold = true;
                pointerEvent.pressPosition = pointerEvent.position;
                pointerEvent.pointerPressRaycast = pointerEvent.pointerCurrentRaycast;
​
                DeselectIfSelectionChanged(currentOverGo, pointerEvent);
​
                if (pointerEvent.pointerEnter != currentOverGo)
                {
                    //这种情况请试想:手指第一次触摸屏幕按下的位置,发生移动,由一个物体进入到另一个物体上
                    HandlePointerExitAndEnter(pointerEvent, currentOverGo);
                    pointerEvent.pointerEnter = currentOverGo;
                }
              
                //寻找是否有能够处理PointerDown事件的
                var newPressed = ExecuteEvents.ExecuteHierarchy(currentOverGo, pointerEvent,               ExecuteEvents.pointerDownHandler);
                if (newPressed == null)
                //未找到能够处理PointerDown的,则交给PointerClick处理
                    newPressed = ExecuteEvents.GetEventHandler<IPointerClickHandler>(currentOverGo);
                float time = Time.unscaledTime;
​
                if (newPressed == pointerEvent.lastPress)
                {
                    var diffTime = time - pointerEvent.clickTime;
                    if (diffTime < 0.3f)
                        //双击
                        ++pointerEvent.clickCount;
                    else
                        pointerEvent.clickCount = 1;
​
                    pointerEvent.clickTime = time;
                }
                else
                {
                    //点击次数主要用于双击的判断
                    pointerEvent.clickCount = 1;
                }
                pointerEvent.pointerPress = newPressed;
                pointerEvent.rawPointerPress = currentOverGo;
                pointerEvent.clickTime = time;
                pointerEvent.pointerDrag = ExecuteEvents.GetEventHandler<IDragHandler>(currentOverGo);
                if (pointerEvent.pointerDrag != null)
                  ExecuteEvents
                    .Execute(pointerEvent.pointerDrag, pointerEvent, ExecuteEvents.initializePotentialDrag);
                m_InputPointerEvent = pointerEvent;
            }
​
           
            if (released)
            {
            //如果释放,也要通知发出某些事件
                ExecuteEvents.Execute(pointerEvent.pointerPress, pointerEvent, ExecuteEvents.pointerUpHandler);
                var pointerUpHandler = ExecuteEvents.GetEventHandler<IPointerClickHandler>(currentOverGo);
                if (pointerEvent.pointerPress == pointerUpHandler && pointerEvent.eligibleForClick)
                {
                    ExecuteEvents
                      .Execute(pointerEvent.pointerPress, pointerEvent, ExecuteEvents.pointerClickHandler);
                }
                else if (pointerEvent.pointerDrag != null && pointerEvent.dragging)
                {
                    ExecuteEvents
                      .ExecuteHierarchy(currentOverGo, pointerEvent, ExecuteEvents.dropHandler);
                }
​
                pointerEvent.eligibleForClick = false;
                pointerEvent.pointerPress = null;
                pointerEvent.rawPointerPress = null;
​
                if (pointerEvent.pointerDrag != null && pointerEvent.dragging)
                    ExecuteEvents
                      .Execute(pointerEvent.pointerDrag, pointerEvent, ExecuteEvents.endDragHandler);
​
                pointerEvent.dragging = false;
                pointerEvent.pointerDrag = null;
                ExecuteEvents
                  .ExecuteHierarchy(pointerEvent.pointerEnter, pointerEvent, ExecuteEvents.pointerExitHandler);
                pointerEvent.pointerEnter = null;
​
                m_InputPointerEvent = pointerEvent;
            }
        }

处理触摸事件也是通过一些列判断,决定是否发出移动,按下,释放等事件。回到断点②继续看 ProcessMouseEvent 方法

protected void ProcessMouseEvent(int id)
  {
      //处理所有鼠标事件
      var mouseData = GetMousePointerEventData(id);
      var leftButtonData = mouseData.GetButtonState(PointerEventData.InputButton.Left).eventData;
      m_CurrentFocusedGameObject = leftButtonData.buttonData.pointerCurrentRaycast.gameObject;
      ProcessMousePress(leftButtonData);
      ProcessMove(leftButtonData.buttonData);
      ProcessDrag(leftButtonData.buttonData);
      ProcessMousePress(mouseData.GetButtonState(PointerEventData.InputButton.Right).eventData);
      ProcessDrag(mouseData.GetButtonState(PointerEventData.InputButton.Right).eventData.buttonData);
      ProcessMousePress(mouseData.GetButtonState(PointerEventData.InputButton.Middle).eventData);
      ProcessDrag(mouseData.GetButtonState(PointerEventData.InputButton.Middle).eventData.buttonData);
​
      if (!Mathf.Approximately(leftButtonData.buttonData.scrollDelta.sqrMagnitude, 0.0f))
      {
        //判断鼠标左键按下期间是否发生移动决定是否发出滚动事件,可想滑动列表,鼠标左键按下并移动鼠标,列表滑动
          var scrollHandler = ExecuteEvents
          .GetEventHandler<IScrollHandler(leftButtonData.buttonData.pointerCurrentRaycast.gameObject);
          ExecuteEvents
            .ExecuteHierarchy(scrollHandler, leftButtonData.buttonData, ExecuteEvents.scrollHandler);
       }
    }

这个鼠标处理逻辑很清楚,就是处理按下,拖拽和移动事件。

protected void ProcessMousePress(MouseButtonEventData data)
 {
        //该方法和ProcessTouchPress相似
        var pointerEvent = data.buttonData;
        var currentOverGo = pointerEvent.pointerCurrentRaycast.gameObject; 
        //鼠标键按下
        if (data.PressedThisFrame())
        {
            pointerEvent.eligibleForClick = true;
            pointerEvent.delta = Vector2.zero;
            pointerEvent.dragging = false;
            pointerEvent.useDragThreshold = true;
            pointerEvent.pressPosition = pointerEvent.position;
            pointerEvent.pointerPressRaycast = pointerEvent.pointerCurrentRaycast;
            //如果按下的物体发生了变化
            DeselectIfSelectionChanged(currentOverGo, pointerEvent);
            
            var newPressed = ExecuteEvents
                            .ExecuteHierarchy(currentOverGo, pointerEvent, ExecuteEvents.pointerDownHandler);
            if (newPressed == null)
                newPressed = ExecuteEvents.GetEventHandler<IPointerClickHandler>(currentOverGo);
            float time = Time.unscaledTime;
            if (newPressed == pointerEvent.lastPress)
            {
            //这次按下的和上次按下的物体是相同的
                var diffTime = time - pointerEvent.clickTime;
                if (diffTime < 0.3f)
                    //双击
                    ++pointerEvent.clickCount;
                else
                    pointerEvent.clickCount = 1;
​
                pointerEvent.clickTime = time;
            }
            else
            {
                pointerEvent.clickCount = 1;
            }
​
            pointerEvent.pointerPress = newPressed;
            pointerEvent.rawPointerPress = currentOverGo;
            pointerEvent.clickTime = time;
            pointerEvent.pointerDrag = ExecuteEvents.GetEventHandler<IDragHandler>(currentOverGo);
​
            if (pointerEvent.pointerDrag != null)
                ExecuteEvents
                  .Execute(pointerEvent.pointerDrag, pointerEvent, ExecuteEvents.initializePotentialDrag);
C
            m_InputPointerEvent = pointerEvent;
        }
​
        //鼠标键松开
        if (data.ReleasedThisFrame())
        {
            ReleaseMouse(pointerEvent, currentOverGo);
        }
    }

TouchInputModule 的代码作用和上面的类似,只不过是处理细节不同而已,这里不再赘述。

三、射线碰撞检测

处理事件最后需要交给对应的 GameObject 处理,那么如何找到这个 GameObject 的呢?比如说现在屏幕上有一个按钮,点了这个按钮,UGUI 是如何知道需要把点击事件交给这个按钮的 OnClick 去响应的呢?这就需要射线碰撞检测啦。

射线碰撞检测模块的主要工作是从摄像机的屏幕位置上进行射线碰撞检测并获取碰撞结果,将结果返回给事件处理逻辑类,交由事件处理模块处理。射线检测在 Unity 中分为 PhysicsRaycaster,Physics2DRaycaster 以及 GraphicRaycaster。

2D 射线碰撞检测、3D 射线碰撞检测相对比较简单,采用射线的形式进行碰撞检测,区别在于 2D 射线碰撞检测结果里预留了 2D 的层级次序,以便在后面的碰撞结果排序时,以这个层级次序为依据进行排序,而 3D 射线碰撞检测结果则是以距离大小为依据进行排序的。GraphicRaycaster 类为 UGUI 元素点位检测的类,它被放在 Core 渲染块里。它主要针对 ScreenSpaceOverlay 模式下的输入点位进行碰撞检测,因为这个模式下的检测并不依赖于射线碰撞,而是通过遍历所有可点击的 UGUI 元素来进行检测比较,从而判断该响应哪个 UI 元素的。因此 GraphicRaycaster 类是比较特殊的。

在获取一个点位事件数据时,会计算当前点位事件对应的 GameObject

protected PointerEventData GetTouchPointerEventData(Touch input, out bool pressed, out bool released)
 {
        PointerEventData pointerData;
        //省略一些代码
        if (input.phase == TouchPhase.Canceled)
        {
            pointerData.pointerCurrentRaycast = new RaycastResult();
        }
        else
        {
            //调用射线检测
            eventSystem.RaycastAll(pointerData, m_RaycastResultCache);
            //返回第一个有效的射线检测结果
            var raycast = FindFirstRaycast(m_RaycastResultCache);
            pointerData.pointerCurrentRaycast = raycast;
            m_RaycastResultCache.Clear();
        }
        //返回点位数据
        return pointerData;
    }

看一下 PhysicsRaycaster 的检测算法

public override void Raycast(PointerEventData eventData, List<RaycastResult> resultAppendList)
  {
        Ray ray = new Ray();
        float distanceToClipPlane = 0;
        if (!ComputeRayAndDistance(eventData, ref ray, ref distanceToClipPlane))
             return;      
        int hitCount = 0;
        if (m_MaxRayIntersections == 0)
        {
            if (ReflectionMethodsCache.Singleton.raycast3DAll == null)
                return;
            //调用底层方法去获取碰撞碰撞结果
            m_Hits = ReflectionMethodsCache.Singleton.raycast3DAll(ray, distanceToClipPlane, finalEventMask);
            hitCount = m_Hits.Length;
        }
        else
        {
            if (ReflectionMethodsCache.Singleton.getRaycastNonAlloc == null)
                return;
​
            if (m_LastMaxRayIntersections != m_MaxRayIntersections)
            {
                m_Hits = new RaycastHit[m_MaxRayIntersections];
                m_LastMaxRayIntersections = m_MaxRayIntersections;
            }
            //调用底层方法去获取碰撞碰撞结果
            hitCount = ReflectionMethodsCache.Singleton.getRaycastNonAlloc(ray, m_Hits, distanceToClipPlane, finalEventMask);
        }
​
        if (hitCount > 1)
        //排序方式是距离越近越优先
            System.Array.Sort(m_Hits, (r1, r2) => r1.distance.CompareTo(r2.distance));
​
        if (hitCount != 0)
        {
            for (int b = 0, bmax = hitCount; b < bmax; ++b)
            {
                var result = new RaycastResult
                {
                    gameObject = m_Hits[b].collider.gameObject,
                    module = this,
                    distance = m_Hits[b].distance,
                    worldPosition = m_Hits[b].point,
                    worldNormal = m_Hits[b].normal,
                    screenPosition = eventData.position,
                    index = resultAppendList.Count,
                    sortingLayer = 0,
                    sortingOrder = 0
                };
                resultAppendList.Add(result);
            }
        }
    }
}

四、事件调度

事件逻辑处理模块的主要逻辑都集中在 EventSystem 类中,其余类都只对它起辅助作用。EventInterfaces 类、EventTrigger 类、EventTriggerType 类定义了事件回调函数,ExecuteEvents 类编写了所有执行事件的回调接口。EventSystem 主要逻辑基本上都在处理由射线碰撞检测后引起的各类事件。比如,判断事件是否成立,若成立,则发起事件回调,若不成立,则继续轮询检查,等待事件的发生。EventSystem 类是事件处理模块中唯一继承 MonoBehavior 类并在 Update 帧循环中做轮询的。也就是说,所有 UI 事件的发生都是通过 EventSystem 轮询监测并且实施的。EventSystem 类通过调用输入事件检测模块、检测碰撞模块来形成自己的主逻辑部分,因此 EventSystem 是整个事件模块的入口。最主要的还是 Update 里面的逻辑。

protected virtual void Update() {
            if (current != this)
                return;
            //触发module进行更新
            TickModules();
​
            bool changedModule = false;
            for (var i = 0; i < m_SystemInputModules.Count; i++)
            {
                var module = m_SystemInputModules[i];
                if (module.IsModuleSupported() && module.ShouldActivateModule())
                {
                    if (m_CurrentInputModule != module)
                    {
                        ChangeEventModule(module);
                        changedModule = true;
                    }
                    break;
                }
            }
​
            // no event module set... set the first valid one...
            if (m_CurrentInputModule == null)
            {
                for (var i = 0; i < m_SystemInputModules.Count; i++)
                {
                    var module = m_SystemInputModules[i];
                    if (module.IsModuleSupported())
                    {
                        ChangeEventModule(module);
                        changedModule = true;
                        break;
                    }
                }
            }
​
            if (!changedModule && m_CurrentInputModule != null)
                //调用当前module的处理逻辑
                m_CurrentInputModule.Process();

五、总结

对于 UGUI 的底层运行原理有一定理解,可以帮助开发者更好的去开发游戏中的 UI,大部分情况下我们用不到这里的知识,甚至也不需要掌握,但是对于这样的设计架构却有一定的借鉴之处,本篇中并没有具体的讲述 UI 组件的代码,比如像 Button、Image 这些。下次有机会再介绍一下~

欢迎搜索关注柴柴爱 Coding 微信公众号,这里有 免费的学习资源、全方位的进阶路线、各岗位面试资源、程序设计源码 一只会 Coding 的柴柴等你哦~

肖某

UGUI 有源码哎,虽然还是有些底层是 c++ 写的看不到,但是大概能猜歌差不多的也

知乎用户

直接看源码啊