GUIClip在IMGUI中的作用
简介
Unity中的IMGUI是一个独立于ugui的UI系统。IMGUI是事件(消息)驱动的UI系统,主要用于编写开发工具。
Unity官方目前并无GUIClip的相关文档,本篇文章的主要目的是描述GUIClip类在 IMGUI中的作用,给有需要的同学提供一些学习资料。
IMGUI
IMGUI是WinAPI和图形API(DirectX, OpenGL等)的简单拼装。
- 在
OnGUI函数回调中,我们需要根据不同的事件类型,做出不同的处理。由于需要围绕事件写代码,所以IMGUI是事件驱动的。 - 我们可以直接使用
GUI,GUILayout类中写好的控件。GUIUtility和EditorGUIUtility提供了一些功能函数。
IMGUI控件的渲染其实存在两个阶段,一个是Setup,另一个是Display。
- 在
Setup阶段,我们需要设置一些参数用于坐标系变换。GUIClip和GUI.matrix涉及坐标系的变化,对应到DirectX就是IDirect3DDevice9::SetTransform - 在
Display阶段,我们需要调用Draw函数来渲染图形。GUIStyle负责图像的渲染,对应到DirectX就是IDirect3DDevice9::DrawIndexedPrimitive
GUIClip
简单介绍了IMGUI,这里我们进入正题。就像上面说的,GUIClip在IMGUI系统中的作用涉及坐标系的变化。GUIClip在底层以栈Stack的形式保存。每次推入一个新的GUIClip,我们就进入了一个新的坐标系。
Push Pop Count
我们可以使用GUIClip.Internal_GetCount()获得push进去的GUIClip的个数,以及使用GUIClip.Push推入一个新的GUIClip。
如果遇到"internal函数无法调用"的错误提示,可以参考《Cecil修改UnityDll,不使用反射就能调用internal的函数》
private void OnGUI()
{
Debug.Log($"OnGUIStart: GUIClip.Count={GUIClip.Internal_GetCount()}");
var rect = new Rect(10,10,100,100);
GUIClip.Push(rect, default, default, false);
Debug.Log($"AfterPush: GUIClip.Count={GUIClip.Internal_GetCount()}");
GUIClip.Pop();
Debug.Log($"AfterPop: GUIClip.Count={GUIClip.Internal_GetCount()}");
}
//OnGUIStart: GUIClip.Count=1
//AfterPush: GUIClip.Count=2
//AfterPop: GUIClip.Count=1
运行代码,我们可以看到在OnGUI函数开始的时候,就已经有一个GUIClip被push进去。接着我们调用GUIClip.Push,日志打印的GUICLip个数会加1变成2。对应的,如果我们pop出最近的GUIClip,打印的个数会变为1。
局部坐标
上文提到了,每次推入一个新的GUIClip,我们就进入了一个新的坐标系。IMGUI中的坐标基本上都是相对当前坐标系的局部坐标。
比如,我们在当前坐标系中调用GUIStyle.Draw和在push一个新的GUIClip后调用GUIStyle.Draw,它们的位置是不同的。
StyleDraw中Rect点的位置
private void OnGUI()
{
var currentEvent = Event.current.mousePosition;
var localRect = new Rect(50,50,200,200);
//在原坐标系中 渲染 新的GUIClip的Rect
if(Event.current.type == EventType.Repaint)
GUI.skin.window.Draw(localRect, GUIContent.none, 0);
GUIClip.Push(localRect, default, default, false);
//在新坐标系中 渲染 相同的Rect
if(Event.current.type == EventType.Repaint)
GUI.skin.window.Draw(localRect, GUIContent.none, 0);
GUIClip.Pop();
}

虽然代码中都是调用GUIStyle.Draw渲染Rect(50,50,200,200) 这个长方形位置的图片,但由于Rect是局部坐标,并且GUIClip的绝对位置不同,因此它们渲染的位置不同。
鼠标位置
除了GUIStyle.Draw传入的Rect是局部坐标。Event.mousePositin返回的也是局部坐标。
如果我们修改上面的代码,让其在当前坐标系的(0,0)打印鼠标的位置(红点标记),我们可以发现鼠标位置在不同GUIClip中的局部坐标也是不同的。
private void OnGUI()
{
var currentEvent = Event.current.mousePosition;
var localRect = new Rect(50,50,200,200);
if(Event.current.type == EventType.Repaint)
GUI.skin.window.Draw(localRect, GUIContent.none, 0);
LabelMousePos();
GUIClip.Push(localRect, default, default, false);
if(Event.current.type == EventType.Repaint)
GUI.skin.window.Draw(localRect, GUIContent.none, 0);
LabelMousePos();
GUIClip.Pop();
}
private void LabelMousePos()
{
string content = $"{Event.current.mousePosition}";
var size = GUI.skin.label.CalcSize(GUIContent.Temp(content));
GUI.Label(new Rect(0, 0, size.x, size.y), $"{Event.current.mousePosition}");
}

绝对坐标
既然有局部坐标,那么就有绝对坐标。绝对坐标通俗点说就是相对窗口左上角的坐标。
对于当前GUIClip的Rect,我们可以使用GUIClip.topmostRect获取的绝对坐标。
注意
GUIClip.GetTopRect()返回的是当前GUIClip在上一个GUIClip中的相对位置。
我们可以使用下面的代码,在当前GUIClip原点的位置显示当前GUICLip的绝对坐标。
private void OnGUI()
{
var currentEvent = Event.current.mousePosition;
var localRect = new Rect(50,50,200,200);
if(Event.current.type == EventType.Repaint)
GUI.skin.window.Draw(localRect, GUIContent.none, 0);
LabelAtOrigin(GUIClip.topmostRect.ToString());
GUIClip.Push(localRect, default, default, false);
if(Event.current.type == EventType.Repaint)
GUI.skin.window.Draw(localRect, GUIContent.none, 0);
LabelAtOrigin(GUIClip.topmostRect.ToString());
Debug.Log(GUIClip.GetTopRect());
GUIClip.Pop();
}
private void LabelAtOrigin(string labelContent)
{
var size = GUI.skin.label.CalcSize(GUIContent.Temp(labelContent));
GUI.Label(new Rect(0, 0, size.x, size.y), labelContent);
}

可以发现刚进入OnGUI函数时就存在的GUIClip的绝对坐标的位置是(0,21),这是因为EditorWindow实际上只是DockArea(GUIView)的一个pane。在WinAPI中注册的窗口属于GUIView这个类,DockArea派生自GUIView,可以有多个EditorWindow。DockArea调用OldOnGUI函数时,会调用GUIView.BeginOffsetArea推入一个GUIClip,之后才会接着调用当前EditorWindow的OnGUI函数。
ScrollOffset对局部坐标的影响
我们修改代码,对push的GUIClip添加ScrollOffset参数。
我们在第一个GUIClip的局部坐标原点显示ScrollOffset参数的值,在第二个的GUIClip的局部坐标原点显示第二个GUIClip的绝对坐标。
以及在当前GUIClip的mousePosition位置打印mousePosition。
private void OnGUI()
{
var localRect = new Rect(50,50,200,200);
if(Event.current.type == EventType.Repaint)
GUI.skin.window.Draw(localRect, GUIContent.none, 0);
LabelAtOrigin("scrollOffset="+scrollOffset.ToString());
GUIClip.Push(localRect, scrollOffset, default, false);
if(Event.current.type == EventType.Repaint)
GUI.skin.window.Draw(localRect, GUIContent.none, 0);
LabelAtOrigin(GUIClip.topmostRect.ToString());
LabelAt(Event.current.mousePosition, $"{Event.current.mousePosition}");
GUIClip.Pop();
if (Event.current.isScrollWheel)
{
var delta = Event.current.delta; //向下滚动滚轮, delay.y>0
if (Event.current.alt)
scrollOffset.x += delta.y; //scrollOffset.y<0时ui往上移动
else
scrollOffset.y -= delta.y; //scrollOffset.y<0时ui往上移动
Event.current.Use();
}
}
private void LabelAtOrigin(string labelContent)
{
LabelAt(Vector2.zero, labelContent);
}
private void LabelAt(Vector2 pos, string labelContent)
{
var size = GUI.skin.label.CalcSize(GUIContent.Temp(labelContent));
GUI.Label(new Rect(pos.x, pos.y, size.x, size.y), labelContent);
}
可以看到随着scrollOffset的变化, 第二个GUIClip的局部坐标的位置都随之发生了变化。
但是GUIClip的绝对坐标并没有因为scrollOffset的变化而发生变化。

我们这里通过GUIClip.visibleRect获得应用scrollOffset之前的Rect
// m_VisibleRect = Rectf (-topmost.scrollOffset.x, -topmost.scrollOffset.y, topmost.physicalRect.width, topmost.physicalRect.height);
// visibleRect 抵消了 scrollOffset的影响
局部坐标和绝对坐标的相互转化
上一小节,我们可以观察到虽然鼠标没有挪动,但是随着scrollOffset的变化,鼠标局部坐标的数值也发生变化,从而得出scrollOffset可以影响到局部坐标的数值。
除此之外,GUI.matrix也可以影响到绝对坐标和局部坐标的相互转化。
调用
GUI.Draw函数时传入的vector2或者Rect,我们称为draw space(canvas space)。 刚进入EditorWindow.OnGUI的函数时获取的点vector或者Rect,我们称为view space(viewport?)。
draw space和view space可能是同一个,比如你刚进OnGUI函数就调用了GUI.Draw函数。
但通常我们会调用GUIClip.Push,或者设置了GUI.Matrix,这时draw space 和 view space 就不一样了。
这里给出示例代码以及对应的源码
private void OnGUI()
{
var localRect = new Rect(50,50,200,200);
if(Event.current.type == EventType.Repaint)
GUI.skin.window.Draw(localRect, GUIContent.none, 0);
LabelAtOrigin("scale=" + scale.ToString());
GUIClip.Push(localRect, scrollOffset, default, false);
GUI.matrix = Matrix4x4.Scale(new Vector3(scale, scale, 1));
if(Event.current.type == EventType.Repaint)
GUI.skin.window.Draw(localRect, GUIContent.none, 0);
LabelAtOrigin(GUIClip.topmostRect.ToString());
LabelAt(Event.current.mousePosition, $"{Event.current.mousePosition}");
GUI.matrix = Matrix4x4.identity;
GUIClip.Pop();
if (Event.current.isScrollWheel)
{
var delta = Event.current.delta; //向下滚动滚轮, delay.y>0
scale += delta.y *0.01f;
Event.current.Use();
}
}
可以看到LabelAtOrigin(GUIClip.topmostRect.ToString()); 打印了GUIClip的Rect的数值和显示的不符,
这是因为GUIClip存储的时候是不考虑matrix的。但是显示的时候又应用了matrix。

/// Clips /absolutePos/ to drawing coordinates
Vector2 Clip(Vector2 absolutePos, Vector2 scrollOffset) // m_AbsoluteMousePosition
{
if (GUIClip.Internal_GetCount() == 0)
{
return default;
}
var inverseMatrix = Matrix4x4.Inverse(GUIClip.GetMatrix());
Vector2 transformedPoint = inverseMatrix.MultiplyPoint(absolutePos);
Vector2 result = transformedPoint - scrollOffset - GUIClip.topmostRect.position;
return result;
}
//Unity的 UnClip函数的实现
Vector2 UnClip_F__M_(Vector2 pos, Vector2 scrollOffset)
{
if (GUIClip.Internal_GetCount() == 0)
{
return default;
}
var matrix = GUIClip.GetMatrix();
Vector2 transformedPoint = matrix.MultiplyPoint(new Vector3(pos.x, pos.y, 0.0F));
return transformedPoint + scrollOffset + GUIClip.topmostRect.position;
}
裁剪
GUIClip的裁剪分成几部分,
- 在push进一个新的
GUIClip的时候,底层函数会保证新的GUIClip的4个点的绝对坐标不超过当前的GUIClip。 GUIStyle调用Draw的时候,会将局部坐标在m_VisibleRect 之外的点都裁剪掉。- 渲染管道的Clipping裁剪阶段会将位于
View Volume之外的点裁剪掉。
m_VisibleRect = Rectf (-topmost.scrollOffset.x, -topmost.scrollOffset.y, topmost.physicalRect.width, topmost.physicalRect.height);
实战训练
在没有缩放的情况下,我们将鼠标悬浮在NodeCanvas的节点上,鼠标会变为MouseCursor.Link手指的样式,这是因为在DrawNodeWindow函数中调用了EditorGUIUtility.AddCursorRect。
if (zoomFactor == 1f)
{
EditorGUIUtility.AddCursorRect(new Rect(node.rect.x, node.rect.y, node.rect.width, node.rect.height), MouseCursor.Link);
}
但是这个鼠标样式的修改只会发生在zoomfactor为1的时候,如果我们去掉上面代码中的if判断,我们会发现,如果我们缩小画布, CursorRect会发生错位。

这是因为EditorGUIUtility.AddCursorRect调用了GUIClip.UnClip(Rect)。而这个函数的底层代码其实是有问题的,它没有将GUI.matrix考虑进去。
public static void AddCursorRect(Rect position, MouseCursor mouse, int controlID)
{
if (Event.current.type == EventType.Repaint)
{
Rect rect = GUIClip.Unclip(position);
Rect topmostRect = GUIClip.topmostRect;
Rect r = Rect.MinMaxRect(Mathf.Max(rect.x, topmostRect.x), Mathf.Max(rect.y, topmostRect.y), Mathf.Min(rect.xMax, topmostRect.xMax), Mathf.Min(rect.yMax, topmostRect.yMax));
if (!(r.width <= 0f) && !(r.height <= 0f))
{
Internal_AddCursorRect(r, mouse, controlID);
}
}
}
//没有考虑m_Matrixd
Rectf GUIClipState::Unclip (const Rectf& rect)
{
if (!m_GUIClips.empty())
{
GUIClip& topmost = m_GUIClips.back();
return Rectf (rect.x + topmost.scrollOffset.x + topmost.physicalRect.x,
rect.y + topmost.scrollOffset.y + topmost.physicalRect.y,
rect.width, rect.height);
}
else
{
return Rectf (0,0,0,0);
}
}
unity的GUI.matrix是作用于绝对坐标的,这里我们只需将Rect的点min和点max,都用UnClip函数将其转化为绝对坐标即可。
public static void AddCursorRect(Rect position, MouseCursor mouse)
{
if (Event.current.type == EventType.Repaint)
{
// unclip the local position
Vector2 min = UnClip(position.min, GraphEditorWindow.current.ScrollOffset);
Vector2 max = UnClip(position.max, GraphEditorWindow.current.ScrollOffset);
Rect rect = Rect.MinMaxRect(min.x, min.y, max.x, max.y);
//Rect topmostRect = GUIClip.topmostRect;
//Rect r = Rect.MinMaxRect(Mathf.Max(rect.x, topmostRect.x), Mathf.Max(rect.y, topmostRect.y), Mathf.Min(rect.xMax, topmostRect.xMax), Mathf.Min(rect.yMax, topmostRect.yMax));
//if (!(r.width <= 0f) && !(r.height <= 0f))
{
Internal_AddCursorRect(rect, mouse, 0);
}
}
}
//带matrix的UnClip源码
Vector2f GUIClipState::Unclip (const Vector2f& pos)
{
if (!m_GUIClips.empty())
{
GUIClip& topmost = m_GUIClips.back();
Vector3f res;
m_Matrix.PerspectiveMultiplyPoint3 (Vector3f (pos.x, pos.y, 0.0F), res);
return Vector2f(res.x, res.y) + topmost.scrollOffset + Vector2f (topmost.physicalRect.x, topmost.physicalRect.y);
}
else
{
return Vector2f (0,0);
}
}


浙公网安备 33010602011771号