【Unity3D编辑器开发】从零构建自定义编辑器:核心类与绘制流程全解析

📅 2026/6/28 20:37:44
【Unity3D编辑器开发】从零构建自定义编辑器:核心类与绘制流程全解析
1. 为什么需要自定义Unity编辑器第一次接触Unity编辑器扩展时我完全被各种绘制方法搞晕了。为什么有的在OnGUI里画界面有的却在OnInspectorGUI里操作后来踩过几次坑才明白原来Unity针对不同场景提供了不同的绘制方案。举个例子就像装修房子刷墙用滚筒描边用刷子不同工具干不同的活。Unity编辑器扩展主要解决三个痛点一是提升开发效率把重复操作自动化二是定制专属工具满足项目特殊需求三是优化工作流程让团队成员更专注创意。我做过一个角色编辑器把原本需要修改十几个参数的繁琐操作变成了直观的滑块调节美术同事的效率直接翻倍。2. 核心类解析与使用场景2.1 EditorWindow独立窗口的基石创建自定义工具窗口就像搭积木。先新建脚本继承EditorWindow记得一定要放在Editor文件夹里。我常用的模板是这样的using UnityEditor; using UnityEngine; public class MyToolWindow : EditorWindow { [MenuItem(Tools/我的工具)] static void ShowWindow() { var window GetWindowMyToolWindow(); window.titleContent new GUIContent(装备编辑器); } void OnGUI() { GUILayout.Label(这是标题, EditorStyles.boldLabel); if(GUILayout.Button(生成装备)) { Debug.Log(正在生成...); } } }这里有个实用技巧用EditorStyles可以快速调用编辑器内置样式。比如EditorStyles.miniButton适合做工具栏按钮EditorStyles.helpBox用来显示提示信息。最近项目里我做了个批量重命名工具就是靠这些样式组件快速搭建的UI。2.2 Editor改造检视面板的利器当需要定制组件的Inspector面板时Editor类就是最佳选择。上周我刚用这个特性给动画组件加了关键帧预览功能。关键代码结构如下[CustomEditor(typeof(MyComponent))] public class MyComponentEditor : Editor { SerializedProperty importantValue; void OnEnable() { importantValue serializedObject.FindProperty(value); } public override void OnInspectorGUI() { serializedObject.Update(); EditorGUILayout.PropertyField(importantValue); if(GUILayout.Button(重置数值)) { importantValue.intValue 0; } serializedObject.ApplyModifiedProperties(); } }特别注意serializedObject的使用流程先Update获取最新数据修改后一定要Apply。有次我忘记Apply调试了半天为什么数值没保存这个教训记忆犹新。2.3 PropertyDrawer精细化控制属性显示遇到自定义数据结构时PropertyDrawer能让属性面板更友好。比如我们项目的技能系统有个伤害范围结构体[System.Serializable] public struct DamageArea { public ShapeType shape; public float radius; public Vector3 size; } [CustomPropertyDrawer(typeof(DamageArea))] public class DamageAreaDrawer : PropertyDrawer { public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { EditorGUI.BeginProperty(position, label, property); var shapeProp property.FindPropertyRelative(shape); var shapeRect new Rect(position.x, position.y, position.width, 16); EditorGUI.PropertyField(shapeRect, shapeProp); if(shapeProp.enumValueIndex (int)ShapeType.Circle) { var radiusRect new Rect(position.x, position.y 20, position.width, 16); EditorGUI.PropertyField(radiusRect, property.FindPropertyRelative(radius)); } else { var sizeRect new Rect(position.x, position.y 20, position.width, 16); EditorGUI.PropertyField(sizeRect, property.FindPropertyRelative(size)); } EditorGUI.EndProperty(); } public override float GetPropertyHeight(SerializedProperty property, GUIContent label) { return 40; } }这个Drawer会根据选择的形状类型动态显示对应的参数控件。GetPropertyHeight必须正确返回总高度否则会出现布局错乱。建议在复杂Drawer里使用EditorGUIUtility.singleLineHeight来计算行高。3. 绘制流程深度解析3.1 OnGUI自由布局的画笔EditorWindow的OnGUI就像一块空白画布。我习惯先用GUILayout.BeginVertical/Horizontal划分区域再用GUILayout.Space控制间距。最近做的对话编辑器就采用了这种布局方式void OnGUI() { GUILayout.BeginVertical(GUI.skin.box); { // 顶部工具栏 GUILayout.BeginHorizontal(); if(GUILayout.Button(新建, EditorStyles.miniButtonLeft)) { CreateNewDialog(); } if(GUILayout.Button(保存, EditorStyles.miniButtonRight)) { SaveDialog(); } GUILayout.EndHorizontal(); // 内容区域 scrollPos EditorGUILayout.BeginScrollView(scrollPos); DrawDialogTree(); EditorGUILayout.EndScrollView(); } GUILayout.EndVertical(); }遇到性能问题时可以改用GUI而非GUILayout因为后者会自动计算布局。在需要绘制大量元素时如地图编辑器这个优化很关键。3.2 OnInspectorGUI改造原有检视器重写OnInspectorGUI时我通常会保留默认绘制作为基础public override void OnInspectorGUI() { base.OnInspectorGUI(); // 先绘制默认内容 EditorGUILayout.Space(); EditorGUILayout.LabelField(扩展功能, EditorStyles.boldLabel); var script target as MyBehaviour; if(script null) return; if(GUILayout.Button(初始化配置)) { script.Initialize(); } }特别注意target对象需要类型转换它保存着当前正在编辑的组件实例。有次我忘记转换直接调用方法导致编辑器崩溃这个错误现在想起来都觉得好笑。3.3 OnSceneGUI场景中的可视化交互场景视图的交互最考验想象力。常用的Handles类提供了各种3D控件void OnSceneGUI() { var t target as Waypoint; if(t null) return; EditorGUI.BeginChangeCheck(); Vector3 newPos Handles.PositionHandle(t.position, Quaternion.identity); if(EditorGUI.EndChangeCheck()) { Undo.RecordObject(t, Move Waypoint); t.position newPos; } Handles.color Color.green; Handles.DrawWireDisc(t.position, Vector3.up, t.radius); Handles.BeginGUI(); if(Handles.Button(t.position Vector3.up, Quaternion.identity, 0.5f, 0.5f, Handles.SphereHandleCap)) { Debug.Log(点击了路点); } Handles.EndGUI(); }Undo.RecordObject千万别漏这是实现撤销操作的关键。Handles.BeginGUI/EndGUI组合能在3D场景中嵌入2DUI非常适合做标记提示。4. 实战构建角色属性编辑器4.1 需求分析与结构设计最近项目需要个角色属性编辑器主要功能包括可视化调整基础属性技能配置与预览装备槽位管理决定采用EditorWindow作为主界面结合PropertyDrawer处理特殊数据类型。先定义数据结构[System.Serializable] public class CharacterStats { public string characterName; [Range(1,100)] public int level; public AttributeSet baseAttributes; public Skill[] skills; } [System.Serializable] public class AttributeSet { public int strength; public int agility; public int intelligence; } [System.Serializable] public class Skill { public string skillName; public Texture2D icon; public float cooldown; public DamageArea effectArea; }4.2 主窗口实现主窗口采用标签页布局核心代码如下public class CharacterEditor : EditorWindow { CharacterStats currentCharacter; int selectedTab 0; Vector2 scrollPos; [MenuItem(Tools/RPG/角色编辑器)] static void Init() { GetWindowCharacterEditor(角色编辑器); } void OnGUI() { DrawToolbar(); scrollPos EditorGUILayout.BeginScrollView(scrollPos); { selectedTab GUILayout.Toolbar(selectedTab, new[]{基础, 技能, 装备}); if(currentCharacter null) { EditorGUILayout.HelpBox(请先创建或加载角色, MessageType.Info); return; } switch(selectedTab) { case 0: DrawBasicTab(); break; case 1: DrawSkillsTab(); break; case 2: DrawEquipmentTab(); break; } } EditorGUILayout.EndScrollView(); } void DrawToolbar() { GUILayout.BeginHorizontal(EditorStyles.toolbar); if(GUILayout.Button(新建, EditorStyles.toolbarButton)) { currentCharacter new CharacterStats(); } if(GUILayout.Button(保存, EditorStyles.toolbarButton)) { SaveCharacter(); } GUILayout.EndHorizontal(); } }4.3 特殊Drawer实现为DamageArea实现可视化绘制[CustomPropertyDrawer(typeof(DamageArea))] public class DamageAreaDrawer : PropertyDrawer { public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { EditorGUI.BeginProperty(position, label, property); position EditorGUI.PrefixLabel(position, label); var shapeProp property.FindPropertyRelative(shape); var shapeRect new Rect(position.x, position.y, position.width, 16); EditorGUI.PropertyField(shapeRect, shapeProp, GUIContent.none); if(shapeProp.enumValueIndex (int)ShapeType.Circle) { var radiusRect new Rect(position.x, position.y 18, position.width, 16); EditorGUI.PropertyField(radiusRect, property.FindPropertyRelative(radius)); } else { var sizeRect new Rect(position.x, position.y 18, position.width, 16); EditorGUI.PropertyField(sizeRect, property.FindPropertyRelative(size)); } EditorGUI.EndProperty(); } public override float GetPropertyHeight(SerializedProperty property, GUIContent label) { return 36; } }4.4 场景视图交互为角色添加场景预览功能[CustomEditor(typeof(CharacterData))] public class CharacterDataEditor : Editor { void OnSceneGUI() { var data target as CharacterData; if(data null) return; Handles.color Color.blue; foreach(var skill in data.skills) { if(skill.shape ShapeType.Circle) { Handles.DrawWireDisc(data.transform.position, Vector3.up, skill.radius); } else { Matrix4x4 matrix Matrix4x4.TRS( data.transform.position, Quaternion.identity, skill.size ); Handles.DrawWireCube(Vector3.zero, Vector3.one); } } } }5. 性能优化与调试技巧5.1 编辑器脚本性能优化编辑器脚本的卡顿往往源于不必要的重绘。我总结了几条经验将静态内容缓存到Texture2D中使用EditorGUIUtility.isProSkin处理不同皮肤复杂计算放在EditorApplication.update外Texture2D cachedBackground; void OnGUI() { if(cachedBackground null) { cachedBackground new Texture2D(1, 1); cachedBackground.SetPixel(0, 0, EditorGUIUtility.isProSkin ? new Color(0.2f, 0.2f, 0.2f) : new Color(0.8f, 0.8f, 0.8f)); cachedBackground.Apply(); } GUI.DrawTexture(new Rect(0, 0, position.width, position.height), cachedBackground); }5.2 常见问题排查遇到编辑器不刷新时可以检查脚本是否放在Editor文件夹确认类名与文件名匹配尝试调用EditorUtility.SetDirty强制保存void OnInspectorGUI() { if(GUILayout.Button(强制保存)) { EditorUtility.SetDirty(target); AssetDatabase.SaveAssets(); } }5.3 扩展编辑器功能通过反射可以访问一些未公开的API但要谨慎使用void AccessInternalMethod() { var type typeof(EditorWindow).Assembly.GetType(UnityEditor.InspectorWindow); var method type.GetMethod(RebuildContentsContainers, BindingFlags.NonPublic | BindingFlags.Instance); method.Invoke(EditorWindow.focusedWindow, null); }