欢迎光临
我们一直在努力

Unity UGUI 场景视图快速选择

当场景中有多个UI重叠时,通常不好快速选择需要修改的UI

此脚本能够帮助你快速选择到鼠标位置的UI

使用Ctrl+右键弹出鼠标所在位置可见UI列表

并且在Hover列表项时对绘制区域高亮

核心原理是:从 Scene 视图的鼠标位置发射一条射线,检测它穿过了哪些 uGUI 网格,再把结果显示在弹窗中。

它主要由两个类组成:

类职责
UGUIScenePicker 捕获快捷键、检测 UI、高亮对象
UGUIPickerPopup 显示列表、响应悬停、选择对象和关闭

代码粘贴如下

using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.UIElements;

namespace UGUISourceTools
{
/// <summary>Scene 视图中的 uGUI 拾取入口,编辑器加载或脚本重载后自动注册。</summary>
[InitializeOnLoad]
internal static class UGUIScenePicker
{
/// <summary>命中信息:渲染组件及用于高亮的世界坐标矩形四角。</summary>
internal sealed class Hit
{
internal Graphic Graphic;
internal Vector3[] Corners;
}

private static UGUIPickerPopup popup;
// 只在打开弹窗的 Scene 视图中绘制高亮,避免多个场景窗口相互干扰。
private static SceneView owner;
private static Hit highlighted;

static UGUIScenePicker()
{
SceneView.duringSceneGui += OnSceneGUI;
// 脚本重载或切换播放状态时关闭弹窗,避免保留失效引用。
AssemblyReloadEvents.beforeAssemblyReload += Close;
EditorApplication.playModeStateChanged += _ => Close();
}

/// <summary>处理 Ctrl+右键输入,并在重绘事件中绘制当前高亮。</summary>
private static void OnSceneGUI(SceneView view)
{
var evt = Event.current;
if (evt.type == EventType.MouseDown && evt.button == 1 && evt.control && !evt.alt)
{
var mouse = evt.mousePosition;
// Scene 鼠标位置属于 GUI 坐标,独立弹窗使用屏幕坐标。
var screen = GUIUtility.GUIToScreenPoint(mouse);
Close();
// 先刷新布局和图形网格,再沿场景相机的鼠标射线检测。
Canvas.ForceUpdateCanvases();
var hits = FindHits(HandleUtility.GUIPointToWorldRay(mouse));
owner = view;
// 首次打开就高亮渲染排序最靠前的命中对象。
highlighted = hits.Count > 0 ? hits[0] : null;
// 消费本次输入,避免继续触发场景工具的右键操作。
evt.Use();
popup = ScriptableObject.CreateInstance<UGUIPickerPopup>();
popup.Open(hits, screen + new Vector2(16, 0));
view.Repaint();
}

if (view != owner || highlighted == null || !highlighted.Graphic || evt.type != EventType.Repaint)
return;

// 将网格边界投影到场景 GUI,保留物体旋转产生的四边形。
// 不直接采用可能包含空白区域的 RectTransform 范围。
Handles.BeginGUI();
var points = highlighted.Corners.Select(p => (Vector3)HandleUtility.WorldToGUIPoint(p)).ToArray();
if (points.All(p => float.IsFinite(p.x) && float.IsFinite(p.y)))
{
var old = Handles.color;
Handles.DrawSolidRectangleWithOutline(points,
new Color(0.15f, 0.75f, 1, 0.12f), new Color(0.2f, 0.85f, 1, 1));
Handles.color = old;
}
Handles.EndGUI();
}

/// <summary>检测射线命中的 UI 网格;不进行纹理或 Shader 的逐像素可见性判断。</summary>
internal static List<Hit> FindHits(Ray ray)
{
var hits = new List<Hit>();
// 限定当前编辑阶段,例如 Prefab 模式下不拾取主场景中的对象。
var stage = StageUtility.GetCurrentStageHandle();
foreach (var graphic in UnityEngine.Object.FindObjectsByType<Graphic>(FindObjectsSortMode.None))
{
// 排除禁用、非当前阶段、资源文件、场景隐藏、被剔除和整体透明的对象。
if (!graphic.IsActive() || !graphic.canvas || !graphic.canvas.isActiveAndEnabled ||
!stage.Contains(graphic.gameObject) || EditorUtility.IsPersistent(graphic) ||
SceneVisibilityManager.instance.IsHidden(graphic.gameObject) ||
graphic.canvasRenderer.cull || graphic.canvasRenderer.GetInheritedAlpha() <= 0.001f)
continue;

var transform = graphic.rectTransform;
// 与 UI 所在平面求交,再转换到网格顶点使用的局部坐标系。
var plane = new Plane(transform.forward, transform.position);
if (!plane.Raycast(ray, out var distance) || distance < 0) continue;
var world = ray.GetPoint(distance);
var local = (Vector2)transform.InverseTransformPoint(world);

// 是否可见与是否接收交互无关,因此不调用 Graphic.Raycast。
// raycastTarget 或 CanvasGroup.blocksRaycasts 关闭时仍应列出可见图形。
// 仅 maskable 开启的组件接受父级遮罩的几何过滤。
var clips = graphic is MaskableGraphic clipGraphic && clipGraphic.maskable
? graphic.GetComponentsInParent<RectMask2D>(false).Where(m => m.IsActive()).ToArray()
: Array.Empty<RectMask2D>();
if (clips.Any(m => !ClipRect(m).Contains(m.rectTransform.InverseTransformPoint(world)))) continue;
// 普通 Mask 使用遮罩网格检查交点,不依赖交互射线过滤器。
if (graphic is MaskableGraphic masked && masked.maskable &&
graphic.GetComponentsInParent<Mask>(false).Any(m => m.transform != transform &&
m.IsActive() && !MaskContains(m, world))) continue;
// 遮罩关闭 showMaskGraphic 时只参与裁剪,自身不作为可见图形列出。
if (graphic is MaskableGraphic && graphic.TryGetComponent<Mask>(out var ownMask) &&
ownMask.IsActive() && !ownMask.showMaskGraphic) continue;

// 当前网格由 Unity 管理,读取后不能自行销毁。空网格不会列入结果。
var mesh = graphic.canvasRenderer.GetMesh();
if (!mesh || mesh.vertexCount == 0) continue;
var vertices = mesh.vertices;
var colors = mesh.colors32;
var triangles = mesh.triangles;
bool found = false;
var min = new Vector2(float.PositiveInfinity, float.PositiveInfinity);
var max = new Vector2(float.NegativeInfinity, float.NegativeInfinity);
for (int i = 0; i + 2 < triangles.Length; i += 3)
{
int a = triangles[i], b = triangles[i + 1], c = triangles[i + 2];
// 三个顶点全部透明的三角形不参与命中及边界计算。
if (colors.Length == vertices.Length && colors[a].a == 0 && colors[b].a == 0 && colors[c].a == 0)
continue;
min = Vector2.Min(min, Vector2.Min(vertices[a], Vector2.Min(vertices[b], vertices[c])));
max = Vector2.Max(max, Vector2.Max(vertices[a], Vector2.Max(vertices[b], vertices[c])));
// 即使命中,也继续遍历,以累计整个图形的网格边界。
found |= InsideTriangle(local, vertices[a], vertices[b], vertices[c]);
}
if (!found) continue;

// 将矩形遮罩转换到图形局部空间,再与高亮边界求交。
// 旋转遮罩转换后取轴对齐包围矩形,因此这里是矩形边界近似。
foreach (var clip in clips)
{
var rect = ClipRect(clip);
var clipMin = new Vector2(float.PositiveInfinity, float.PositiveInfinity);
var clipMax = new Vector2(float.NegativeInfinity, float.NegativeInfinity);
foreach (var p in RectCorners(rect))
{
var q = (Vector2)transform.InverseTransformPoint(clip.rectTransform.TransformPoint(p));
clipMin = Vector2.Min(clipMin, q);
clipMax = Vector2.Max(clipMax, q);
}
min = Vector2.Max(min, clipMin);
max = Vector2.Min(max, clipMax);
}
if (min.x >= max.x || min.y >= max.y) continue;
// 保存世界坐标,绘制时再投影到所属的 Scene 视图。
hits.Add(new Hit { Graphic = graphic, Corners = RectCorners(Rect.MinMaxRect(min.x, min.y, max.x, max.y))
.Select(transform.TransformPoint).ToArray() });
}

// 按 Canvas 排序层、排序值和 Graphic 深度排列,最上层优先。
hits.Sort((a, b) =>
{
var ca = SortingCanvas(a.Graphic);
var cb = SortingCanvas(b.Graphic);
int order = SortingLayer.GetLayerValueFromID(cb.sortingLayerID).CompareTo(SortingLayer.GetLayerValueFromID(ca.sortingLayerID));
if (order == 0) order = cb.sortingOrder.CompareTo(ca.sortingOrder);
if (order == 0) order = b.Graphic.depth.CompareTo(a.Graphic.depth);
return order;
});
return hits;
}

/// <summary>向上查找决定排序的 Canvas,兼容嵌套 Canvas 的独立排序。</summary>
private static Canvas SortingCanvas(Graphic graphic) =>
graphic.GetComponentsInParent<Canvas>(false).FirstOrDefault(c => c.overrideSorting || c.isRootCanvas)
?? graphic.canvas;

/// <summary>应用左、下、右、上 padding,计算遮罩局部空间内的有效矩形。</summary>
private static Rect ClipRect(RectMask2D mask)
{
var r = mask.rectTransform.rect;
var p = mask.padding;
return Rect.MinMaxRect(r.xMin + p.x, r.yMin + p.y, r.xMax – p.z, r.yMax – p.w);
}

/// <summary>将世界交点转换到 Mask 局部空间,检查是否落在遮罩网格三角形内。</summary>
private static bool MaskContains(Mask mask, Vector3 world)
{
if (!mask.graphic) return false;
var mesh = mask.graphic.canvasRenderer.GetMesh();
if (!mesh) return false;
var p = (Vector2)mask.transform.InverseTransformPoint(world);
var vertices = mesh.vertices;
var indices = mesh.triangles;
for (int i = 0; i + 2 < indices.Length; i += 3)
if (InsideTriangle(p, vertices[indices[i]], vertices[indices[i + 1]], vertices[indices[i + 2]]))
return true;
return false;
}

/// <summary>依次返回左下、左上、右上、右下四角,供坐标转换和高亮绘制使用。</summary>
private static Vector3[] RectCorners(Rect r) => new[] {
new Vector3(r.xMin, r.yMin), new Vector3(r.xMin, r.yMax),
new Vector3(r.xMax, r.yMax), new Vector3(r.xMax, r.yMin) };

/// <summary>使用二维叉积判断点是否在三角形内,兼容顺时针和逆时针顶点。</summary>
private static bool InsideTriangle(Vector2 p, Vector2 a, Vector2 b, Vector2 c)
{
float Cross(Vector2 u, Vector2 v) => u.x * v.y – u.y * v.x;
// 忽略退化三角形;后面的符号比较保留少量浮点误差容差。
if (Mathf.Abs(Cross(b – a, c – a)) < 0.00001f) return false;
float x = Cross(b – a, p – a), y = Cross(c – b, p – b), z = Cross(a – c, p – c);
return (x >= -0.0001f && y >= -0.0001f && z >= -0.0001f) ||
(x <= 0.0001f && y <= 0.0001f && z <= 0.0001f);
}

/// <summary>切换高亮并请求场景重绘;传入 null 表示清除高亮。</summary>
internal static void Highlight(Hit hit)
{
highlighted = hit;
if (owner) owner.Repaint();
}

/// <summary>弹窗销毁后的清理回调,先检查归属,避免旧窗口清理新窗口的状态。</summary>
internal static void PopupClosed(UGUIPickerPopup window)
{
if (popup != window) return;
popup = null;
Highlight(null);
owner = null;
}

/// <summary>统一关闭入口,同时移除场景高亮。</summary>
private static void Close()
{
if (popup) popup.Close();
highlighted = null;
if (owner) owner.Repaint();
owner = null;
}
}

/// <summary>UI Toolkit 无标题栏列表弹窗,负责悬停、选择和关闭交互。</summary>
internal sealed class UGUIPickerPopup : EditorWindow
{
private readonly List<VisualElement> rows = new List<VisualElement>();
private readonly Color hoverColor = new Color(0.12f, 0.42f, 0.65f, 1);

/// <summary>根据命中结果构建列表;screenPosition 是鼠标右侧的屏幕位置。</summary>
internal void Open(List<UGUIScenePicker.Hit> hits, Vector2 screenPosition)
{
var root = rootVisualElement;
root.style.backgroundColor = EditorGUIUtility.isProSkin ? new Color(.12f, .12f, .12f) : new Color(.85f, .85f, .85f);
root.style.paddingTop = root.style.paddingBottom = 4;
var scroll = new ScrollView(ScrollViewMode.Vertical);
root.Add(scroll);
if (hits.Count == 0) scroll.Add(new Label("当前位置没有可见的 uGUI") { style = { paddingLeft = 10, height = 26 } });

// 列表按层级顺序展示,让父子项相邻;初始高亮仍采用 hits 的渲染排序结果。
var set = new HashSet<Transform>(hits.Select(h => h.Graphic.transform));
// 名称路径用于悬停提示;兄弟索引路径用于稳定地还原层级顺序。
string Path(Transform t) => t.parent ? Path(t.parent) + "/" + t.name : t.name;
string Order(Transform t) => t.parent ? Order(t.parent) + "/" + t.GetSiblingIndex().ToString("D6") : t.gameObject.scene.handle + "/" + t.GetSiblingIndex().ToString("D6");
foreach (var hit in hits.OrderBy(h => Order(h.Graphic.transform), StringComparer.Ordinal))
{
var t = hit.Graphic.transform;
// 只统计同样被命中的祖先,空父节点不占列表层级。
int indent = 0;
for (var parent = t.parent; parent; parent = parent.parent) if (set.Contains(parent)) indent++;
var label = new Label(t.name) { tooltip = Path(t) };
label.style.height = 25;
label.style.unityTextAlign = TextAnchor.MiddleLeft;
label.style.paddingLeft = 10 + indent * 14;
label.style.paddingRight = 12;
label.style.flexShrink = 0;
label.style.overflow = Overflow.Hidden;
label.style.textOverflow = TextOverflow.Ellipsis;
// 鼠标悬停:同步列表背景和场景中的矩形高亮。
label.RegisterCallback<MouseEnterEvent>(_ =>
{
foreach (var row in rows) row.style.backgroundColor = Color.clear;
label.style.backgroundColor = hoverColor;
UGUIScenePicker.Highlight(hit);
});
// 左键选择:更新 Unity Selection,让 Hierarchy 和未锁定的 Inspector 同步。
label.RegisterCallback<MouseDownEvent>(evt =>
{
if (evt.button != 0 || !hit.Graphic) return;
Selection.activeGameObject = hit.Graphic.gameObject;
// Ping 负责在层级中提示对象,真正的选择由上一行完成。
EditorGUIUtility.PingObject(hit.Graphic.gameObject);
evt.StopPropagation();
Close();
});
if (hit == hits[0]) label.style.backgroundColor = hoverColor;
rows.Add(label);
scroll.Add(label);
}
root.RegisterCallback<KeyDownEvent>(evt => { if (evt.keyCode == KeyCode.Escape) Close(); });
// 无标题栏弹窗;高度有上限,超出后通过 ScrollView 滚动。
ShowPopup();
position = new Rect(screenPosition, new Vector2(300, Mathf.Clamp(hits.Count * 25 + 8, 34, 360)));
Focus();
}

// 点击其它窗口或场景导致失焦时关闭,销毁时再通知拾取入口清理高亮。
private void OnLostFocus() => Close();
private void OnDisable() => UGUIScenePicker.PopupClosed(this);
}
}

赞(0)
未经允许不得转载:171主机测评 » Unity UGUI 场景视图快速选择
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址