公司动态
Unity视图叠加技术:在Scene/Game视图上绘制自定义UI与调试信息
1. 项目概述理解Unity视图叠加的本质在Unity编辑器里摸爬滚打久了你一定会遇到这样的需求想在Scene视图或者Game视图上临时画点辅助线、显示一些调试信息或者做一个自定义的编辑器工具让它的操作界面直接悬浮在3D场景之上。这种“在现有视图上再画一层”的技术就是视图叠加View Overlay。它不同于创建一个全新的独立窗口而是与现有视图如Scene、Game、Inspector紧密耦合提供一种无侵入式的信息展示或交互方式。简单来说Unity视图叠加就像给你的编辑器视图贴了一层透明的“便利贴”。这层“便利贴”可以显示文字、绘制几何图形、响应鼠标事件但它本身不改变底层视图的渲染内容。这对于开发调试、工具制作、甚至是某些特定的游戏内编辑器如关卡编辑模式来说是极其高效的手段。很多资深的Unity开发者都会利用这个特性来打造专属的“生产力工具链”比如实时显示角色导航路径、可视化物理碰撞体、或者快速标注场景中的物件。2. 核心原理EditorWindow与GUI的深度结合要理解如何创建叠加首先要明白Unity编辑器扩展的两大基石EditorWindow和IMGUI即时模式GUI。我们通常通过继承EditorWindow来创建独立的工具窗口。而视图叠加本质上是在一个特殊的EditorWindow中将其GUI内容绘制到另一个视图的特定区域上。2.1 关键类Editor与HandleUtility实现视图叠加的核心并不直接是一个名为“Overlay”的类Unity在较新版本中引入了Overlay窗口类型但那是一种不同的、更模块化的浮动面板我们稍后会对比而是依赖于UnityEditor.Editor类和相关工具类。SceneView与GameView这两个类是EditorWindow的子类。我们可以通过SceneView.lastActiveSceneView或SceneView.currentDrawingSceneView获取当前正在绘制的Scene视图实例。GameView同理但通常更复杂因为可能存在多个。Handles与GUI在SceneView中绘制图形如线、立方体、GUI标签主要使用Handles类。而在任何EditorWindow包括叠加层中绘制2D UI则使用标准的GUI或EditorGUI类。HandleUtility这个工具类至关重要它提供了坐标转换的方法。例如HandleUtility.GUIPointToWorldRay可以将屏幕上的鼠标位置转换成一条世界空间中的射线用于3D拾取。反之HandleUtility.WorldToGUIPoint可以将一个世界坐标点转换为当前SceneView的2D GUI坐标这是将3D信息叠加到2D视图上的关键。2.2 实现模式OnSceneGUI与自定义EditorWindow有两种主流模式来实现视图叠加模式A通过OnSceneGUI方法。这是为场景中的特定GameObject或其组件添加叠加的最常见方式。你需要编写一个自定义的Editor类继承自UnityEditor.Editor并重写其OnSceneGUI方法。在这个方法里你可以使用Handles和GUI进行绘制这些绘制内容会自动出现在该GameObject所在的Scene视图中。这种方式与特定对象绑定非常适合制作针对某个组件如路径点、触发器的编辑工具。模式B创建常驻的叠加式EditorWindow。这种方式更灵活可以创建全局的、不与特定对象绑定的叠加层。你需要创建一个继承自EditorWindow的类然后通过脚本控制这个窗口的位置和大小使其“贴合”在目标视图如SceneView上并设置其hasFocus为false以避免窃取焦点。接着在其OnGUI方法中绘制内容并处理与底层视图的交互。这种方式更独立功能也更强大可以制作复杂的全局调试面板或编辑器增强工具。注意这里要区分Unity 2020 LTS之后引入的UI Toolkit Overlay。这是一种新的、基于UI Toolkit的覆盖层系统用于在编辑器主窗口的四个边角创建可停靠的浮动面板如Unity官方的“工具架”。它更适用于创建编辑器扩展的用户界面模块而不是我们这里讨论的、在3D视图上进行图形绘制和信息叠加。本文聚焦于传统的、基于IMGUI/Handles的视图叠加技术这是更底层、更灵活的做法。3. 实战演练两种方式创建你的第一个叠加层理论说再多不如动手试。下面我们分别用两种模式实现一个简单的功能在Scene视图中实时显示鼠标所指位置的世界坐标。3.1 模式A实战为任意物体添加坐标显示器假设我们有一个简单的MonoBehaviour脚本CoordinateDisplayTarget挂载在场景物体上。我们要为它制作一个编辑器叠加。步骤1创建目标脚本// CoordinateDisplayTarget.cs using UnityEngine; public class CoordinateDisplayTarget : MonoBehaviour { // 这是一个空脚本仅作为Editor类的目标 }步骤2创建对应的Editor类// CoordinateDisplayTargetEditor.cs using UnityEditor; using UnityEngine; [CustomEditor(typeof(CoordinateDisplayTarget))] public class CoordinateDisplayTargetEditor : Editor { private Vector3 lastWorldPoint Vector3.zero; private string displayText Hover over scene view; // 这是核心方法在Scene视图绘制该物体的Gizmo时调用 void OnSceneGUI() { CoordinateDisplayTarget target (CoordinateDisplayTarget)this.target; // 1. 获取当前事件和Scene视图 Event currentEvent Event.current; SceneView currentSceneView SceneView.currentDrawingSceneView; if (currentSceneView null) return; // 2. 计算鼠标位置对应的世界坐标在物体表面 Ray ray HandleUtility.GUIPointToWorldRay(currentEvent.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit)) { lastWorldPoint hit.point; displayText $World Pos: {lastWorldPoint.ToString(F2)}; } else { // 如果没击中任何物体显示射线在“地面”平面的交点假设Y0 float enterDistance 0.0f; Plane groundPlane new Plane(Vector3.up, Vector3.zero); if (groundPlane.Raycast(ray, out enterDistance)) { lastWorldPoint ray.GetPoint(enterDistance); displayText $Ground Pos: {lastWorldPoint.ToString(F2)}; } } // 3. 在鼠标位置附近绘制一个GUI标签 Handles.BeginGUI(); // 切换到GUI绘制上下文 GUIStyle style new GUIStyle(GUI.skin.label); style.normal.textColor Color.yellow; style.fontSize 12; style.padding new RectOffset(6, 6, 4, 4); style.normal.background MakeTex(2, 2, new Color(0.1f, 0.1f, 0.1f, 0.8f)); Vector2 guiPos HandleUtility.WorldToGUIPoint(lastWorldPoint); Rect labelRect new Rect(guiPos.x 20, guiPos.y, 200, 30); GUI.Label(labelRect, displayText, style); Handles.EndGUI(); // 结束GUI绘制 // 4. 在3D空间中也绘制一个点增强可视化 Handles.color Color.red; Handles.SphereHandleCap(0, lastWorldPoint, Quaternion.identity, 0.1f, EventType.Repaint); // 告诉Unity需要重绘Scene视图让显示实时更新 if (currentEvent.type EventType.MouseMove) { currentSceneView.Repaint(); } } // 辅助函数创建纯色纹理用于背景 private Texture2D MakeTex(int width, int height, Color col) { Color[] pix new Color[width * height]; for (int i 0; i pix.Length; i) pix[i] col; Texture2D result new Texture2D(width, height); result.SetPixels(pix); result.Apply(); return result; } }操作与解析OnSceneGUI是魔法发生的地方。只要选中带有CoordinateDisplayTarget组件的物体这个方法就会被调用。HandleUtility.GUIPointToWorldRay将鼠标的屏幕坐标转换为一条从摄像机出发的射线用于3D拾取。Handles.BeginGUI()和Handles.EndGUI()是关键。它们允许你在OnSceneGUI中使用GUI类进行2D绘制绘制的内容会叠加在3D场景之上。HandleUtility.WorldToGUIPoint将计算出的3D世界坐标点转换回2D GUI坐标这样我们就能在正确的位置显示标签。最后通过SceneView.Repaint()在鼠标移动时强制刷新视图实现坐标的实时更新。实操心得OnSceneGUI中的绘制开销需要留意。如果逻辑复杂或绘制元素过多可能会影响编辑器性能尤其是在复杂场景中。建议使用Event.current.type进行判断只在必要时执行重绘逻辑。Handles.BeginGUI()的调用成本较高应避免在一帧内多次调用。将所有GUI绘制代码放在一对BeginGUI/EndGUI之间。3.2 模式B实战创建全局常驻的调试信息窗口现在我们来创建一个独立的编辑器窗口它始终附着在Scene视图的右上角显示帧率、选中物体数量等全局信息。步骤1创建编辑器窗口类// SceneOverlayDebugWindow.cs using UnityEditor; using UnityEngine; public class SceneOverlayDebugWindow : EditorWindow { private static SceneOverlayDebugWindow _window; private float _lastUpdateTime; private int _frameCount; private float _fps; [MenuItem(Tools/Overlay/Show Debug Overlay)] public static void ShowWindow() { _window GetWindowSceneOverlayDebugWindow(Debug Overlay); _window.InitializeWindow(); } void InitializeWindow() { // 关键设置让窗口成为一个无边框、无法聚焦的浮动面板 this.wantsMouseMove true; this.autoRepaintOnSceneChange true; // 尝试移除窗口装饰使其更像一个叠加层效果因Unity版本而异 // this.titleContent new GUIContent(); // 清空标题 } void OnEnable() { // 订阅Scene视图GUI绘制事件这是实现“叠加”的关键 SceneView.duringSceneGui this.OnDuringSceneGui; _lastUpdateTime Time.realtimeSinceStartup; _frameCount 0; } void OnDisable() { // 务必取消订阅防止内存泄漏和错误 SceneView.duringSceneGui - this.OnDuringSceneGui; } void OnDuringSceneGui(SceneView sceneView) { // 这个方法会在每一个Scene视图绘制时被调用 // 但我们只在自己关注的Scene视图上绘制 if (SceneView.lastActiveSceneView ! sceneView) return; // 计算FPS _frameCount; float timeNow Time.realtimeSinceStartup; if (timeNow _lastUpdateTime 0.5f) // 每0.5秒更新一次 { _fps _frameCount / (timeNow - _lastUpdateTime); _frameCount 0; _lastUpdateTime timeNow; this.Repaint(); // 触发窗口自身的OnGUI重绘 } // 将窗口位置锁定在Scene视图的右上角 Rect sceneViewRect sceneView.position; float overlayWidth 200; float overlayHeight 100; this.position new Rect( sceneViewRect.xMax - overlayWidth - 10, sceneViewRect.yMin 10, overlayWidth, overlayHeight ); // 虽然窗口位置固定了但内容绘制还是在OnGUI中 // 这里可以绘制一些直接到SceneView的Handles内容如果需要 // Handles.Label(new Vector3(0,2,0), Global Overlay Text); } void OnGUI() { // 绘制窗口内容 EditorGUILayout.LabelField( DEBUG OVERLAY , EditorStyles.boldLabel); EditorGUILayout.Space(5); EditorGUILayout.LabelField($FPS: {_fps.ToString(F1)}); EditorGUILayout.LabelField($Selected Objects: {Selection.gameObjects.Length}); EditorGUILayout.LabelField($Scene: {EditorSceneManager.GetActiveScene().name}); // 添加一个简单的按钮示例 if (GUILayout.Button(Log Selection)) { foreach (var go in Selection.gameObjects) { Debug.Log(go.name, go); } } // 使窗口背景半透明 GUI.color new Color(1, 1, 1, 0.85f); } }步骤2使用与定位点击菜单栏Tools Overlay Show Debug Overlay窗口会弹出。手动将其拖动到Scene视图的标签栏上Unity可能会将其“吸附”为标签页。这不是我们想要的。真正的“叠加”效果依赖于OnDuringSceneGui中的代码。该脚本会自动计算Scene视图的位置并将自身窗口的position设置到其右上角。由于窗口被设置为wantsMouseMove且可能无焦点它看起来就像“贴”在了Scene视图上。核心解析SceneView.duringSceneGui委托这是模式B的灵魂。通过订阅这个事件你的代码可以在每一个Scene视图绘制时都被调用。在这里面你可以获取SceneView的矩形位置从而定位你自己的窗口。窗口定位技巧通过设置this.position你可以将窗口强制移动到屏幕的特定位置。结合SceneView的位置计算就能实现“跟随”效果。绘制分离注意我们在OnDuringSceneGui中做定位和逻辑更新而在OnGUI中做内容绘制。这是一种清晰的职责分离。你也可以在OnDuringSceneGui中使用Handles.BeginGUI()直接向Scene视图绘制但那样内容会受Scene视图的裁剪区域影响。实操心得这种方式的性能开销比模式A要大因为duringSceneGui每帧都会调用。务必在方法开始处进行快速判断如检查是否为当前活跃视图并避免繁重的计算。窗口的“置顶”和“防焦点”行为在不同操作系统和Unity版本上可能表现不一致。一个更稳健但更复杂的方法是使用EditorWindow.CreateWindow的特定参数或者利用UnityEditor.OverlayAPI如果面向高版本Unity。记得在OnDisable中取消事件订阅这是编辑器脚本开发中最常见的错误之一会导致订阅者列表膨胀和不可预测的行为。4. 高级技巧与性能优化掌握了基础创建方法后我们来看看如何让叠加层更强大、更高效。4.1 交互处理让叠加层可点击叠加层不仅仅是用来“看”的更需要能“操作”。处理交互的关键在于正确解读Event.current。// 在OnSceneGUI或叠加窗口的OnGUI中 Event evt Event.current; int controlID GUIUtility.GetControlID(FocusType.Passive); // 获取一个控件ID switch (evt.type) { case EventType.MouseDown: // 判断鼠标是否在你的叠加元素区域内 if (yourButtonRect.Contains(evt.mousePosition)) { GUIUtility.hotControl controlID; // 设置热点控件表示正在交互 evt.Use(); // 标记事件已使用防止传递到下层3D视图进行物体选择等操作 // 执行你的点击逻辑 Debug.Log(Overlay button clicked!); } break; case EventType.MouseUp: if (GUIUtility.hotControl controlID) { GUIUtility.hotControl 0; // 释放热点控件 evt.Use(); } break; case EventType.MouseDrag: if (GUIUtility.hotControl controlID) { // 处理拖拽逻辑 evt.Use(); } break; case EventType.Repaint: // 在这里进行绘制确保交互状态如按下能反映出来 EditorGUI.DrawRect(yourButtonRect, GUIUtility.hotControl controlID ? Color.gray : Color.white); GUI.Label(yourButtonRect, Click Me); break; }注意evt.Use()至关重要。在叠加层中处理鼠标事件后必须调用它来阻止事件继续向底层的Scene视图传递。否则你可能会同时触发叠加层按钮和选中场景中的物体。4.2 性能优化要点视图叠加尤其是全局性的很容易成为编辑器卡顿的元凶。按需重绘不要每一帧都调用Repaint()。使用条件判断void Update() // 在EditorWindow中可以使用Update { if (/* 数据确实发生了变化 */) { this.Repaint(); } } // 或者在OnDuringSceneGui中 if (Event.current.type EventType.MouseMove || /* 其他需要重绘的条件 */) { sceneView.Repaint(); }缓存计算结果像FPS更新、物体列表遍历等操作应缓存结果避免在OnGUI这类每帧调用的方法中进行昂贵计算。OnGUI调用频率极高。简化GUI布局避免在OnGUI中频繁使用GUILayout特别是嵌套的GUILayout区域。GUILayout会进行自动布局计算开销比使用绝对坐标的GUI方法大。对于静态或更新不频繁的叠加层优先使用GUI方法并手动计算Rect。纹理和样式缓存像上面例子中MakeTex创建的纹理以及自定义的GUIStyle应该在OnEnable中创建并缓存而不是在OnGUI中反复创建。private GUIStyle _cachedStyle; private Texture2D _cachedBgTex; void OnEnable() { _cachedBgTex MakeTex(1, 1, new Color(0,0,0,0.7f)); _cachedStyle new GUIStyle(GUI.skin.label); _cachedStyle.normal.background _cachedBgTex; // ... 其他样式设置 } void OnGUI() { GUI.Label(someRect, Text, _cachedStyle); // 使用缓存 }4.3 与Unity新Overlay系统的对比与桥接从Unity 2020 LTS开始官方推出了新的Overlay框架 (UnityEditor.Overlays)。它更标准化支持拖拽、停靠、保存布局。如果你的目标是较新版本并且叠加层更偏向于一个工具面板而非场景绘图建议研究新系统。传统IMGUI叠加 vs. 新Overlay系统特性传统IMGUI/Handles叠加新 Overlay 系统绘制能力强大。可直接在Scene视图使用Handles绘制3D Gizmo和2D GUI。受限。主要使用UI Toolkit绘制2D UI无法直接使用Handles在3D视图绘图。交互集成需要手动处理事件与Scene视图交互深度耦合灵活但复杂。集成在编辑器布局系统中交互行为统一但定制化程度较低。布局管理需手动编码控制位置或依赖窗口停靠体验不一。优秀。支持拖拽、停靠到编辑器四边、标签化、布局保存/加载。适用范围调试可视化、场景编辑工具、与3D视图强相关的自定义操作。工具架、数据面板、属性检查器扩展等常规编辑器UI模块。版本要求所有Unity版本。Unity 2020.1 及以上。如何选择如果你的叠加内容需要画线、框、3D标签或者需要与场景物体进行复杂的交互拾取坚持使用传统的OnSceneGUI或duringSceneGui方法。如果你的叠加层只是一个信息面板、按钮集合并且希望有更好的编辑器集成体验可以尝试迁移到新的Overlay系统。两者并非完全互斥复杂的工具可以同时使用两种技术。5. 常见问题与排查实录在实际开发中你肯定会遇到各种奇怪的问题。这里记录了一些典型坑位和解决方案。5.1 叠加层不显示或闪烁问题代码写了窗口也打开了但什么都看不到或者内容一闪一闪的。排查检查绘制代码是否在正确的回调中OnSceneGUI只在选中特定对象时调用duringSceneGui订阅是否正确窗口的OnGUI是否被调用在方法开始处加Debug.Log或打条件断点。检查绘制顺序和裁剪GUI绘制被其他元素遮挡尝试调整GUI.depth或Handles.BeginGUI()的调用时机。确保绘制代码在Repaint事件类型中。检查Alpha混合如果设置了透明色但背景是黑色可能因为混合模式不对。确保GUI.color或GUIStyle的透明度设置正确。窗口最小化或零尺寸检查窗口的position或minSize/maxSize是否设置异常导致窗口实际可视区域为0。5.2 鼠标事件穿透或冲突问题点击叠加层按钮却同时选中了后面的3D物体。解决确保在处理完鼠标事件MouseDown,MouseUp,MouseDrag后调用了Event.current.Use()。并正确设置GUIUtility.hotControl来声明事件所有权。更深层冲突如果叠加层覆盖了Scene视图的某些关键区域如坐标轴手柄你可能需要更精细地判断。有时可以通过检查Tools.current当前编辑器工具或Event.current.modifiers按键修饰符来决定是否拦截事件。5.3 性能突然下降问题打开叠加窗口后编辑器变得异常卡顿。排查清单无限重绘循环是否在OnGUI或duringSceneGui中无条件调用了Repaint()这会导致死循环。确保重绘有条件触发。昂贵的每帧计算是否在OnGUI中进行了FindObjectsOfType、复杂的物理查询或字符串拼接将这些计算移到Update中并降低频率或使用缓存。资源泄漏是否在每次OnGUI中都创建了新的GUIStyle、Texture2D或Material这些对象不会被自动垃圾回收会造成内存和性能问题。务必在OnEnable中创建并缓存。订阅事件未取消检查OnDisable方法确保所有事件订阅如SceneView.duringSceneGui,EditorApplication.update都已正确取消。未取消的订阅会导致方法被多次调用性能呈指数级下降。5.4 坐标转换错误问题在屏幕上画的东西位置完全不对。核心检查点确认坐标空间HandleUtility.WorldToGUIPoint传入的是世界坐标。你给的点是不是本地坐标是否需要Transform.TransformPoint确认当前视图SceneView.currentDrawingSceneView和SceneView.lastActiveSceneView可能不是同一个。多显示器或分屏时尤其要注意。你的逻辑是针对哪个视图的GUI矩阵干扰是否在绘制前改变了GUI.matrix或Handles.matrix这会影响所有后续的坐标转换。在绘制你的叠加内容前可以用GUI.matrix Matrix4x4.identity重置。高DPI缩放在EditorGUIUtility.pixelsPerPoint不为1的屏幕上GUI坐标和屏幕像素坐标需要转换。HandleUtility的方法通常已处理但手动计算Rect时可能需要考虑。5.5 编辑器重启后窗口状态丢失问题精心调整好位置的叠加窗口重启Unity后又回到了默认位置。解决对于EditorWindow可以使用EditorPrefs或ScriptableObject来保存和加载窗口位置、大小等状态。void OnEnable() { // 加载位置 string posKey DebugOverlayPos; if (EditorPrefs.HasKey(posKey)) { var posData EditorPrefs.GetString(posKey); // 解析并设置this.position } } void OnDestroy() { // 保存位置 string posKey DebugOverlayPos; EditorPrefs.SetString(posKey, this.position.ToString()); }对于更复杂的配置建议创建一个ScriptableObject作为配置资产。视图叠加是Unity编辑器扩展中一项非常实用且能极大提升开发效率的技术。它要求开发者对编辑器的运行机制、GUI系统以及坐标空间有较深的理解。从为特定组件添加小工具到创建全局的调试监控面板其应用场景广泛。关键在于理解两种实现模式OnSceneGUI绑定对象与EditorWindow全局浮动的适用场景熟练掌握事件处理与性能优化技巧并能够有效排查交互与显示中的各种问题。