Unity自定义Handle开发:从原理到实战,解决8大核心问题

1. 项目概述:为什么我们需要关注自定义Handle的“坑”?

在Unity编辑器扩展开发中,自定义Handle(手柄)是提升场景编辑效率和视觉反馈直观性的利器。无论是为美术同学定制一个方便调节曲线控制点的工具,还是为策划设计一个可视化的事件触发器范围,一个设计良好的Handle都能让工作流事半功倍。然而,从“能用”到“好用且稳定”,中间隔着一道由各种编辑器生命周期、坐标系转换、事件处理和性能问题构成的鸿沟。这个“Unity自定义Handle示例项目常见问题解决方案”项目,正是为了填平这道鸿沟而生。它不是简单地展示如何画一个Gizmo,而是聚焦于那些官方文档一笔带过、但在实际项目中必然遇到的“坑”,并提供经过实战检验的解决方案。

如果你正在或计划开发任何形式的编辑器工具、自定义Inspector,或者需要为组件提供更友好的场景视图交互,那么理解并解决这些常见问题,将直接决定你的工具是“玩具”还是“生产力”。本文将围绕一个典型的自定义Handle开发流程,拆解从创建、交互到优化全链路中,开发者最容易踩雷的八个核心问题,并给出具体的代码示例和解决思路。我们假设你已经了解基础的EditorEditorWindow类以及HandlesAPI的基本用法,目标是带你深入细节,避开陷阱。

2. 核心问题一:Handle的绘制与交互为何“时灵时不灵”?

自定义Handle的核心在于OnSceneGUI方法。很多新手遇到的第一道坎就是:Handle画出来了,但鼠标移上去没反应,点击拖拽无效。这通常不是Handle本身画错了,而是生命周期与事件处理逻辑出了问题。

2.1 确保OnSceneGUI被正确调用

OnSceneGUIEditor类的方法,只有当你选中的游戏对象上挂载了对应自定义Editor脚本的组件时,它才会被调用。一个常见的疏忽是,自定义Editor脚本没有通过[CustomEditor(typeof(YourComponent))]属性正确关联到目标组件,或者关联后,脚本没有被放在Editor文件夹下。Unity会忽略不在Editor文件夹中的Editor派生类。

另一个更隐蔽的问题是多选支持。默认情况下,OnSceneGUI只会在单个对象被选中时调用。如果你希望Handle在多选时也能工作,需要在Editor类上添加[CanEditMultipleObjects]属性。但要注意,多选时处理Handle交互逻辑会复杂得多,因为你需要同时管理多个目标对象的数据。

2.2 理解并正确使用HandleUtility与事件系统

Handle的交互(如点击检测、拖拽)依赖于Unity的场景视图事件系统。你需要在OnSceneGUI中通过Event.current来获取当前事件。一个健壮的处理流程通常如下:

void OnSceneGUI() { YourComponent target = (YourComponent)target; // 1. 开始处理Handles,这会将GUI状态设置为处理3D手柄输入 Handles.BeginGUI(); // 注意:如果你混合使用GUI和Handles,可能需要这个 // 但实际上,对于纯3D Handle,通常不需要BeginGUI/EndGUI // 2. 记录对象的旧数据,用于Undo操作 EditorGUI.BeginChangeCheck(); // 3. 绘制Handle并获取新值 Vector3 newPosition = Handles.PositionHandle(target.transform.position, target.transform.rotation); // 4. 检查是否有更改发生 if (EditorGUI.EndChangeCheck()) { // 注册Undo操作 Undo.RecordObject(target.transform, "Move Handle"); // 应用更改 target.transform.position = newPosition; } // 5. 处理自定义的、非标准Handle的交互(例如一个自定义按钮或区域) HandleUtility.AddDefaultControl(GUIUtility.GetControlID(FocusType.Passive)); // 确保有Control ID接收事件 Event e = Event.current; if (e.type == EventType.MouseDown && e.button == 0) { // 进行射线检测等,判断是否点击了你的自定义图形 Ray ray = HandleUtility.GUIPointToWorldRay(e.mousePosition); if (YourCustomHitTest(ray, target)) { // 处理点击 e.Use(); // 标记事件已使用,防止其他Handle响应 } } }

关键点解析

  • EditorGUI.BeginChangeCheck()EditorGUI.EndChangeCheck():这对方法是实现Undo/Redo支持的关键。它们会包裹你的Handle绘制代码,如果Handle的值被用户改变了,EndChangeCheck()会返回true
  • Undo.RecordObject:在修改对象属性前调用,记录修改前的状态。这是实现编辑器操作可撤销的基础。
  • Event.current.Use():当你处理了一个事件(如鼠标点击),应该调用此方法“消耗”掉该事件,防止它被传递到其他编辑器逻辑(比如Unity默认的移动工具),造成冲突。
  • HandleUtility.AddDefaultControl:如果你绘制的是完全自定义的图形(不是用Handles.PositionHandle这类内置Handle),你需要为这个交互区域分配一个唯一的Control ID,否则它无法接收输入事件。

实操心得:调试Handle交互问题时,一个非常有效的方法是在OnSceneGUI里打印Event.current.type和鼠标位置。你可以清晰地看到鼠标移动、按下、拖拽等事件流,从而判断你的处理逻辑是否在正确的事件类型上被触发。

3. 核心问题二:坐标系混乱导致的“位置漂移”

这是自定义Handle中最令人头疼的问题之一。你明明在物体的本地坐标系下计算了一个点,画出来却跑到了世界坐标的某个角落。其根源在于对Handles.matrixtransform.localToWorldMatrix的理解不足。

3.1 矩阵状态的管理

Unity的Handle绘制默认是在世界坐标系下进行的。但很多时候,我们希望Handle能跟随物体旋转、缩放,即在物体的本地坐标系下操作。这时就需要用到矩阵。

void OnSceneGUI() { YourComponent target = (YourComponent)target; Transform t = target.transform; // 保存当前的Handles矩阵状态 Matrix4x4 originalMatrix = Handles.matrix; // 将Handles的矩阵设置为目标物体的本地到世界的变换矩阵 // 这意味着后续所有Handle的绘制坐标,都将被解释为相对于该物体本地坐标系的位置 Handles.matrix = t.localToWorldMatrix; // 现在,在本地坐标系下绘制一个立方体Handle // 假设localPos是组件中定义的一个本地坐标变量 Vector3 newLocalPos = Handles.PositionHandle(target.localPos, Quaternion.identity); // ... 处理变化和Undo // 恢复原始的Handles矩阵状态!非常重要,否则会影响后续其他绘制 Handles.matrix = originalMatrix; }

为什么必须恢复矩阵?因为Handles.matrix是一个全局状态。如果你在一个EditorOnSceneGUI中修改了它而没有恢复,那么同一个场景视图帧内,其他对象或Unity自身的Handle绘制都会受到影响,导致错乱。这是一个典型的“状态污染”问题。

3.2 使用Handles.DrawWireDisc等绘图API时的坐标

Handles.DrawWireDiscHandles.DrawLine这样的绘图API,其参数中的位置坐标,同样受到当前Handles.matrix的影响。如果你已经设置了Handles.matrix,那么传入的位置应该是本地坐标。如果你没有设置,那么传入的应该是世界坐标。

一个常见的错误混合是:用Handles.matrix设置了本地坐标系,却用HandleUtility.GUIPointToWorldRay(返回世界空间的射线)去做点击检测。这两者坐标系不匹配,检测必然失败。正确的做法是,将射线的碰撞检测也转换到同一个坐标系下,或者使用HandleUtility.GUIPointToWorldRay后,将射线和碰撞点都通过t.worldToLocalMatrix转换到本地空间进行计算。

避坑技巧:对于复杂的、涉及多个坐标系的Handle,我习惯在绘制和交互计算的开始,就明确注释当前代码块所使用的坐标系(World/Local/Object)。同时,可以编写一个简单的调试方法,在场景中用Handles.Label同时画出关键点的世界坐标和本地坐标,一目了然。

4. 核心问题三:如何实现自定义形状的Handle与精确的点击检测?

内置的PositionHandleRotationHandle等虽然方便,但功能有限。很多时候我们需要自定义形状,比如一个可拖拽的曲线点、一个可调节半径的圆形区域。

4.1 绘制自定义图形

Unity提供了Handles.DrawPolyLineHandles.DrawSolidDiscHandles.DrawAAPolyLine(抗锯齿)等丰富的绘图API。结合Handles.color可以设置颜色,Handles.zTest可以控制深度测试(决定被遮挡部分是否绘制)。

// 绘制一个带填充色的扇形区域 Handles.color = new Color(0.5f, 0.8f, 1.0f, 0.3f); // 半透明的蓝色 Vector3 center = target.transform.position; Vector3 normal = Vector3.up; Vector3 from = Quaternion.AngleAxis(target.startAngle, normal) * Vector3.forward; Handles.DrawSolidArc(center, normal, from, target.sweepAngle, target.radius); // 绘制扇形的边界线 Handles.color = Color.blue; Handles.DrawWireArc(center, normal, from, target.sweepAngle, target.radius); Handles.DrawLine(center, center + from * target.radius); Handles.DrawLine(center, center + Quaternion.AngleAxis(target.sweepAngle, normal) * from * target.radius);

4.2 实现点击与拖拽交互

对于非标准Handle,交互逻辑需要完全自己实现。核心是:

  1. 命中检测(Hit Test):判断鼠标是否点击在了你绘制的图形上。
  2. 拖拽逻辑:在鼠标拖拽过程中,如何根据鼠标移动来更新数据。

命中检测示例(以检测是否点击了一个圆盘为例):

bool IsMouseOverDisc(Vector3 center, float radius, Vector3 normal) { if (Event.current.type != EventType.Layout && Event.current.type != EventType.Repaint) { // 将鼠标位置转换为世界空间的射线 Ray ray = HandleUtility.GUIPointToWorldRay(Event.current.mousePosition); // 计算射线与以normal为法向量的无限大平面的交点 Plane plane = new Plane(normal, center); if (plane.Raycast(ray, out float enter)) { Vector3 hitPoint = ray.GetPoint(enter); // 判断交点是否在圆盘半径内 Vector2 hitPointOnPlane = GetPointOnPlane(hitPoint, center, normal); // 需要将3D点投影到2D平面计算距离 return hitPointOnPlane.magnitude <= radius; } } return false; }

拖拽逻辑示例(拖拽改变圆盘半径):

private int _discControlID; private bool _isDraggingRadius = false; private Vector3 _dragStartWorldPos; void OnSceneGUI() { YourComponent target = (YourComponent)target; _discControlID = GUIUtility.GetControlID(FocusType.Passive); // 获取一个控件ID HandleUtility.AddDefaultControl(_discControlID); // 添加默认控件,使其能接收事件 Event e = Event.current; switch (e.type) { case EventType.MouseDown: if (e.button == 0 && IsMouseOverDisc(target.center, target.radius, target.normal)) { _isDraggingRadius = true; _dragStartWorldPos = GetMouseWorldPosOnPlane(target.center, target.normal); // 获取鼠标在平面上的起始世界坐标 e.Use(); // 消费事件 // 设置热键控件,这样拖拽时其他操作(如视图旋转)会被屏蔽 GUIUtility.hotControl = _discControlID; } break; case EventType.MouseDrag: if (_isDraggingRadius && GUIUtility.hotControl == _discControlID) { Vector3 currentMouseWorldPos = GetMouseWorldPosOnPlane(target.center, target.normal); float delta = (currentMouseWorldPos - target.center).magnitude - (_dragStartWorldPos - target.center).magnitude; // 应用变化,记得加Undo Undo.RecordObject(target, "Change Radius"); target.radius = Mathf.Max(0.1f, target.radius + delta); // 不直接使用e.Use(),因为MouseDrag事件可能被多个逻辑处理,但我们已经通过hotControl控制了 // 强制重绘场景视图 HandleUtility.Repaint(); } break; case EventType.MouseUp: if (e.button == 0 && GUIUtility.hotControl == _discControlID) { _isDraggingRadius = false; GUIUtility.hotControl = 0; // 释放热键控件 e.Use(); } break; case EventType.Repaint: // 在这里绘制你的圆盘 Handles.DrawWireDisc(target.center, target.normal, target.radius); // 可以根据_isDraggingRadius改变颜色提供反馈 break; } }

关键点解析

  • GUIUtility.hotControl:这是编辑器输入系统的关键。当你开始一个拖拽操作时,将hotControl设置为你的控件ID,这告诉Unity“这个控件正在处理输入”,其他编辑器工具(如移动、旋转工具)会暂时失效。操作结束时,必须将其设回0。
  • HandleUtility.Repaint():在拖拽过程中,数据(如半径)不断变化,你需要手动调用这个方法来请求场景视图立即重绘,否则视觉反馈会有延迟。
  • 命中检测的精度:对于复杂形状,可能需要用到物理系统的Raycast或者更复杂的几何计算。对于性能要求高的场景,可以在EventType.Layout事件中只做简单的包围盒检测,在EventType.MouseDown时再做精确检测。

注意事项:自定义交互逻辑比使用内置Handle复杂得多,务必处理好各种事件状态(MouseDown, MouseDrag, MouseUp, MouseMove, Repaint, Layout)。状态管理不善是导致拖拽卡顿、无法结束或与其他工具冲突的主要原因。建议将不同功能的Handle(如移动点、调节半径)封装成独立的类,每个类管理自己的控件ID和交互状态,避免全局状态混乱。

5. 核心问题四:Undo/Redo支持缺失与数据同步

编辑器工具没有Undo功能是不可接受的。如前所述,EditorGUI.BeginChangeCheck()Undo.RecordObject是黄金组合。但这里有几个进阶问题:

5.1 同时修改多个对象属性

一个Handle的拖拽可能同时影响目标对象的多个字段(比如移动一个点,同时更新了它的position和关联的length属性)。你需要确保所有这些修改被包裹在同一个Undo.RecordObjectEditorGUI.EndChangeCheck()块中。

EditorGUI.BeginChangeCheck(); Vector3 newPos = Handles.PositionHandle(target.Position, Quaternion.identity); float newLength = Handles.RadiusHandle(Quaternion.identity, target.Position, target.Length); if (EditorGUI.EndChangeCheck()) { // 一次性记录所有要修改的对象 Undo.RecordObjects(new Object[] { target, target.transform }, "Move Point and Adjust Length"); target.Position = newPos; target.Length = newLength; // 可能还需要更新transform或其他关联数据 target.transform.position = newPos; }

使用Undo.RecordObjects可以一次记录多个对象的快照。

5.2 序列化对象与SerializedProperty

如果你的组件数据是通过SerializedObjectSerializedProperty来管理的(这在自定义Inspector中很常见),那么Undo处理会稍有不同。修改SerializedProperty并调用serializedObject.ApplyModifiedProperties()会自动处理Undo栈。

SerializedObject so = new SerializedObject(target); SerializedProperty positionProp = so.FindProperty("_position"); SerializedProperty lengthProp = so.FindProperty("_length"); EditorGUI.BeginChangeCheck(); Vector3 newPos = Handles.PositionHandle(positionProp.vector3Value, Quaternion.identity); float newLength = Handles.RadiusHandle(Quaternion.identity, positionProp.vector3Value, lengthProp.floatValue); if (EditorGUI.EndChangeCheck()) { // 直接修改SerializedProperty的值,然后应用 positionProp.vector3Value = newPos; lengthProp.floatValue = newLength; so.ApplyModifiedProperties(); // 这一步会自动注册Undo }

使用SerializedObject的好处是它能自动处理多选编辑和Prefab覆盖,但性能上稍逊于直接修改对象字段。

5.3 数据同步与即时更新

有时,修改一个Handle需要立即更新其他依赖此数据的部分(如同一个编辑器窗口中的其他UI,或其他对象的显示)。除了调用HandleUtility.Repaint()重绘场景视图,你可能还需要:

  • 调用EditorUtility.SetDirty(target)标记对象为“脏”,确保修改能被保存。
  • 如果是在EditorWindow中,可以调用window.Repaint()
  • 如果需要通知其他系统,可以考虑使用EditorApplication.delayCall或定义静态事件,但要小心循环引用和生命周期问题。

经验之谈:对于复杂的数据联动,我倾向于在EditorGUI.EndChangeCheck()返回true的那个代码块内,集中调用一个UpdateDependentData()方法。这个方法负责更新所有衍生数据、标记Dirty、触发重绘。这样逻辑清晰,也便于调试。

6. 核心问题五:性能优化与绘制调用管理

当场景中有大量对象带有自定义Handle时,性能可能成为问题。OnSceneGUI在每个对象被选中时都会调用,且每帧调用多次(EventType.Layout,Repaint等)。

6.1 减少不必要的绘制与计算

  • 条件绘制:只在必要时绘制复杂的Handle。例如,可以提供一个开关,或者只在对象被选中且某个工具模式激活时才绘制。
    void OnSceneGUI() { if (!showCustomHandle) return; // 通过一个布尔变量控制 // ... 复杂的绘制逻辑 }
  • 简化命中检测:在EventType.LayoutEventType.Repaint事件中,只做绝对必要的计算。复杂的几何相交检测可以放在EventType.MouseDown中,因为点击事件频率低得多。
  • 缓存计算结果:如果某些计算(如矩阵转换、几何数据)在单帧内多次使用且不变,可以将其缓存到成员变量中,避免重复计算。

6.2 使用EditorApplication.update的陷阱

有些人为了优化,会把一些不依赖于每帧事件的计算(比如更新一些状态数据)放到EditorApplication.update委托中。这要非常小心,因为EditorApplication.update在编辑器运行的每一帧都会调用,无论场景视图是否在渲染。如果注册了多个这样的委托,或者委托内有重操作,会严重拖慢编辑器整体速度。

更好的做法是,将这类更新与场景视图的渲染周期绑定。可以利用SceneView.duringSceneGui委托,它只在场景视图绘制时被调用。

[InitializeOnLoad] public static class SceneViewOptimizedUpdater { static SceneViewOptimizedUpdater() { SceneView.duringSceneGui += OnDuringSceneGui; } static void OnDuringSceneGui(SceneView sceneView) { // 在这里进行每“场景视图帧”一次的更新计算 // 比EditorApplication.update频率更低,更相关 } }

6.3 处理大量对象的策略

如果你需要为场景中成百上千个对象显示轻量级的Handle(比如路径点),可以考虑:

  1. 聚合绘制:编写一个管理类,在它的OnSceneGUI中批量绘制所有对象的Handle,而不是每个对象一个OnSceneGUI调用。这减少了Unity调用开销和绘制指令的提交次数。
  2. 细节层次(LOD):根据摄像机距离,决定绘制的Handle的复杂程度。远处只画一个点,近处再画完整的图形和交互区域。
  3. 异步计算:对于极其复杂的、非实时必需的运算(如生成网格预览),可以考虑放到后台线程或使用EditorApplication.delayCall分帧处理。

性能排查工具:善用Unity Profiler的编辑器模式。你可以清晰地看到每一帧中,各个OnSceneGUI方法的耗时,从而定位性能瓶颈。通常,Handles.DrawAAPolyLine、复杂的Mesh绘制以及高频率的物理射线检测是主要的性能消耗点。

7. 核心问题六:Handle样式、视觉反馈与用户体验

一个专业的Handle不仅功能正确,视觉体验也要流畅直观。这包括颜色、深度提示、鼠标悬停和拖拽反馈。

7.1 颜色与深度提示

  • Handles.color:设置绘制颜色。通常用不同颜色区分状态(默认、悬停、选中)。
  • Handles.zTest:这是一个UnityEngine.Rendering.CompareFunction枚举。默认是LessEqual(在深度小于等于当前深度时绘制),你可以设置为Always来让Handle始终绘制在最前面(忽略遮挡),或者设置为Greater只绘制在被遮挡的部分(用于特殊提示)。
    // 绘制一个始终在最前面的选中框 Handles.zTest = CompareFunction.Always; Handles.color = Color.green; Handles.DrawWireCube(target.position, Vector3.one); Handles.zTest = CompareFunction.LessEqual; // 记得恢复默认
  • Handles.lighting:设置为true时,绘制的图形会受到场景光照影响,更有立体感;false则使用纯色。对于简单的线框,通常设为false

7.2 悬停与选中反馈

EventType.Repaint事件中,根据当前交互状态改变绘制样式。

void OnSceneGUI() { bool isHovered = (GUIUtility.hotControl == 0 && HandleUtility.nearestControl == _myControlID); bool isActive = (GUIUtility.hotControl == _myControlID); Handles.color = isActive ? Color.red : (isHovered ? Color.yellow : Color.white); // ... 绘制Handle图形 // 在鼠标悬停时,可以额外绘制一些提示信息 if (isHovered) { Handles.BeginGUI(); // 使用GUIStyle绘制一个工具提示框 GUIStyle style = new GUIStyle(GUI.skin.box); style.normal.textColor = Color.white; style.padding = new RectOffset(5,5,5,5); Vector2 guiPos = HandleUtility.WorldToGUIPoint(target.position); Rect rect = new Rect(guiPos.x, guiPos.y - 40, 150, 30); GUI.Label(rect, $"值: {target.someValue:F2}", style); Handles.EndGUI(); } }
  • HandleUtility.nearestControl:这个静态属性给出了当前鼠标位置下,距离最近的那个控件的ID。你可以用它来判断鼠标是否悬停在自己的Handle上(前提是hotControl为0,即没有正在进行的拖拽)。
  • Handles.BeginGUI()/Handles.EndGUI():这对方法允许你在场景视图的3D空间中绘制2D GUI。常用于绘制标签、按钮和提示框。注意,GUI的坐标是屏幕像素坐标,需要使用HandleUtility.WorldToGUIPoint进行转换。

7.3 自定义光标与操作提示

对于特殊的操作(如旋转、缩放),可以改变鼠标光标,提供更好的引导。

void OnSceneGUI() { // 假设有一个用于旋转的Handle if (IsMouseOverRotationHandle()) { // 将编辑器光标设置为旋转光标 EditorGUIUtility.AddCursorRect(new Rect(0,0, Screen.width, Screen.height), MouseCursor.RotateArrow); // 或者,如果你想只在某个小区域改变光标: // Vector2 guiPos = HandleUtility.WorldToGUIPoint(handleCenter); // EditorGUIUtility.AddCursorRect(new Rect(guiPos.x - 10, guiPos.y - 10, 20, 20), MouseCursor.RotateArrow); } }

用户体验细节:好的视觉反馈应该是即时的、清晰的。拖拽时,可以实时绘制辅助线或数值变化;操作结束后,可以有一个短暂的“完成”视觉反馈(比如Handle闪动一下)。这些细节能极大提升工具的专业感和易用性。但要注意,所有额外的绘制都应有性能成本意识,避免过度设计。

8. 核心问题七:多选编辑与Prefab模式下的特殊处理

8.1 多选编辑的实现

如前所述,[CanEditMultipleObjects]属性是第一步。但在多选时,target变成了一个对象数组。你不能直接修改target,而是需要遍历targets

public override void OnSceneGUI() { // 绘制所有选中对象的Handle for (int i = 0; i < targets.Length; i++) { YourComponent t = (YourComponent)targets[i]; // 为每个对象绘制Handle DrawHandleForObject(t); } // 或者,如果你想实现一个“整体”操作(如同时移动所有选中的点) EditorGUI.BeginChangeCheck(); Vector3 avgPosition = CalculateAveragePosition(targets); Quaternion avgRotation = CalculateAverageRotation(targets); // 注意:旋转平均需要特殊处理(如四元数平均) Vector3 newAvgPos = Handles.PositionHandle(avgPosition, avgRotation); if (EditorGUI.EndChangeCheck()) { Vector3 delta = newAvgPos - avgPosition; Undo.RecordObjects(targets, "Move Multiple Objects"); foreach (YourComponent t in targets) { t.transform.position += delta; } } }

多选编辑的难点在于如何定义“合理”的交互。是每个对象独立一个Handle?还是提供一个基于所有对象中心或边界的总体Handle?这需要根据具体工具的功能来决定。

8.2 Prefab模式与孤立模式

在Prefab编辑模式或孤立模式下,场景中的对象可能是Prefab实例或嵌套Prefab的一部分。你的Handle操作需要正确处理Prefab覆盖。

  • 当你修改一个Prefab实例的属性时,Unity会创建该属性的一个覆盖。在Inspector中,属性名会变成粗体。你的Undo操作应该能正确反映这一点。
  • 使用SerializedObjectSerializedProperty可以自动处理大部分Prefab覆盖逻辑。直接修改SerializedProperty的值,ApplyModifiedProperties()会处理好覆盖的创建和应用。
  • 如果你直接修改Component的字段,则需要确保修改后调用PrefabUtility.RecordPrefabInstancePropertyModifications(target)来通知Unity记录这个覆盖。但这种方法不如SerializedObject可靠,尤其是在复杂的嵌套Prefab场景中。

推荐做法:在自定义编辑器工具中,除非有极特殊的性能要求,否则统一使用SerializedObject/SerializedProperty来读写组件数据。它能保证在多选、Prefab、数组属性等各种复杂情况下的行为一致性。

9. 核心问题八:工具集成与快捷键冲突

你的自定义Handle最好能与Unity原有的工具(移动、旋转、缩放)和谐共处,并支持自定义快捷键。

9.1 工具模式切换

你可以创建自己的EditorTool(继承自UnityEditor.EditorTool),这允许你的工具出现在Unity顶部的工具栏中,像移动工具一样被选择。在EditorToolOnToolGUI方法中绘制Handle。这种方式最为正式,工具状态管理也由Unity负责。

对于更轻量级的集成,可以在自定义EditorOnSceneGUI中,通过检查Tools.current(当前激活的Unity工具)来决定是否绘制你的Handle。或者,你可以定义一个自定义的EditorWindow作为工具面板,通过面板上的按钮来激活你的“编辑模式”。

9.2 快捷键处理

可以在OnSceneGUI中监听键盘事件来添加快捷键。

void OnSceneGUI() { Event e = Event.current; // 检查Ctrl/Cmd + 某个键 if (e.type == EventType.KeyDown && e.keyCode == KeyCode.G && e.control) { // 执行你的操作 ToggleMyHandleMode(); e.Use(); // 阻止快捷键传递到其他系统 } }

注意,快捷键可能会与Unity默认快捷键或其他插件冲突。一个良好的实践是,在你的工具设置或文档中说明快捷键,并考虑提供修改快捷键的选项。

9.3 与Unity Gizmo共存

有时,你既想保留Unity默认的Transform Gizmo,又想叠加自己的自定义Handle。这通常没有问题,因为它们是独立绘制的。但要注意交互冲突。如果你的自定义Handle消耗了鼠标事件(调用了e.Use()),那么默认的Gizmo可能就无法响应了。你需要根据设计意图来决定:是让你的Handle独占交互,还是与默认Gizmo共享(例如,只有点击到你绘制的特定图形时才消费事件)。

一个常见的模式是:当你的自定义工具模式激活时,隐藏或禁用默认的Transform Gizmo(可以通过设置Tools.hidden = true来实现),完全由你的Handle接管交互。当退出你的模式时,再恢复Tools.hidden = false

最后的小技巧:开发过程中,多使用Debug.LogHandles.Label来输出关键变量(如坐标、状态、事件类型)到场景视图或控制台。Handle调试很多时候是“可视化调试”,亲眼看到数据和状态流,比在代码里空想有效得多。另外,将你的自定义Handle代码模块化,把绘制、交互、数据管理分离到不同的方法甚至类中,会让代码更易维护和复用。毕竟,一个好的编辑器工具,其内部代码的清晰度,最终会反映在用户的使用体验上。