Unity 拓展编辑器入门指南

作者:sadi
2020-03-02
16 20 2

这篇文章由爱发电支持写作, 如果你喜欢我做的事情,可以考虑在那里支持我。

Unity 里面比较出色我也很喜欢的一个功能就是它易于拓展的编辑器。一般来说拓展编辑器对于游戏运行效率不是有什么大的帮助,但是有助于开发效率的提高。

毕竟工欲善其事,必先利其器。

这次介绍一共以下这些拓展编辑器的方法:

  • OnDrawGizmos
  • OnInspectorGUI
  • OnSceneGUI
  • MenuItem 与 EditorWindow
  • ScriptableWizard
  • ScriptObject
  • Attributes
  • AssetProcess 

OnDrawGizmos

OnDrawGizmos 是在 MonoBehaviour 下的一个方法,通过这个方法可以可以绘制出一些 Gizmos 来使得其一些参数方便在 Scene 窗口查看。

比如我们有一个沿着路点移动的平台,一般的操作可能是生成一堆新的子物体来确定和设置位置,但其实这样会有点赘余,我们需要的只是一个 Vector2 / Vector3 数组。而这个时候我们就可以通过 OnDrawGizmos 方法在编辑器绘制出这些Vector2 / Vector3 的数组点。


完整代码如下:

public class DrawGizmoTest : MonoBehaviour
{
    public Vector2[] poses;

    private void OnDrawGizmos() 
    {
        Color originColor = Gizmos.color;
        Gizmos.color = Color.red;
        if( poses!=null && poses.Length>0 )
        {
            //Draw Sphere
            for (int i = 0; i < poses.Length; i++)
            {
                Gizmos.DrawSphere( poses[i], 0.2f );
            }
            //Draw Line
            Gizmos.color = Color.yellow;
            Vector2 lastPos = Vector2.zero;
            for (int i = 0; i < poses.Length; i++)
            {
                if( i > 0 )
                {
                    Gizmos.DrawLine( lastPos, poses[i] );
                }
                lastPos = poses[i];
            }
        }
        Gizmos.color = originColor;
    } 
}

OnInspectorGUI

在开发过程中常常需要在编辑器上对某个特定的 Component 进行一些操作,比如在 Inspector 界面上有一个按钮可以触发一段代码。

这种属于编辑器的,所以一般是在 Editor 文件夹中新建一个继承自 Editor 的脚本:

之后编辑继承自 UnityEditor.Editor,这里注意是必须在类上加入 [CustomEditor(typeof(编辑器脚本绑定的 Monobehavior 类)] 然后重写它的 OnInspectorGUI 方法:

using UnityEditor;
[CustomEditor(typeof(InspectorTest))]
public class InspectorTestEditor : Editor 
{
    public override void OnInspectorGUI() 
    {
        base.OnInspectorGUI();
        if(GUILayout.Button("Click Me"))
        {
            //Logic
            InspectorTest ctr = target as InspectorTest;
        }
    }
}

而一般而言在 Editor 类中操作变量有两种方式,一种是通过直接访问或者函数调用改动 Monobehaviour 的变量,一种是通过 Editor 类中的 serializedObject 来改动对应变量

比如我要把 Monobehaviour 的一个公开的 Name 改成 Codinggamer

使用方法一,在 Editor 中可以这样写:

if(GUILayout.Button("Click Me"))
{
    //Logic
    InspectorTest ctr = target as InspectorTest;
    ctr.Name = "Codinggamer";
}

在编辑器中点击会发现 Hierarchy 界面没有出现一般改动之后会出现的小星星:

一般改动是会出现小星星:

如果这个时候你重新打开场景,会发现改动的值又便回原来的值,也就是你的改动并没有生效。

而此时,只需要再调用 EditorUtility.SetDirty( Object ) 方法即可。

如果要使用方法二,则需要在 Editor 代码中写:

if(GUILayout.Button("Click Me"))
{
    //Logic
    serializedObject.FindProperty("Name").stringValue = "Codinggamer";
    serializedObject.ApplyModifiedProperties();
}

这里不需要调用 EditorUtility.SetDirty( Object ) 方法,场景就已经会出现改动之后的小星星,保存重开场景之后也会发现对应值生效。

这两个方法孰优孰劣?

一般来说用第二个方法比较好,但实际上涉及逻辑比较多的时候我是用第一个方法。用第二个方法的好处在于它是内置了撤销功能,也就意味着你调用改动之后是可以直接撤销掉的,而第一个方法就不能。

OnSceneGUI

这个方法也是在 Editor 类中的一个方法,是用来在 Scene 视图上显示一个 UI 元素。其创建也是在 Editor 文件夹下新建一个继承自 Editor 的脚本:


OnSceneGUI 中可以做出和 OnDrawGizmo 类似的功能,比如绘制出 Vector2 数组的路点:


其代码如下:

using UnityEngine;
using UnityEditor;

[CustomEditor(typeof(SceneGUITest))]
public class SceneGUITestEditor : Editor 
{
    private void OnSceneGUI() 
    {
        Draw();
    }

    void Draw()
    {
        //Draw a sphere
        SceneGUITest ctr = target as SceneGUITest;
        Color originColor = Handles.color;
        Color circleColor = Color.red;
        Color lineColor = Color.yellow;
        Vector2 lastPos = Vector2.zero;
        for (int i = 0; i < ctr.poses.Length; i++)
        {
            var pos = ctr.poses[i];
            Vector2 targetPos = ctr.transform.position;
            //Draw Circle
            Handles.color = circleColor;
            Handles.SphereHandleCap(  GUIUtility.GetControlID(FocusType.Passive ) , targetPos + pos, Quaternion.identity, 0.2f , EventType.Repaint );
            //Draw line
            if(i > 0) 
            {
                Handles.color = lineColor;
                Handles.DrawLine( lastPos, pos );
            }
            lastPos = pos;
        }
        Handles.color = originColor;
    }
}

OnDrawGizmos 与 OnSceneGUI 的区别

因为 OnSceneGUI 是在 Editor 上的方法,而 Editor 一般都是对应 Monobehaviour,这意味它是只能是点击到对应物体才会生成的。而 OnDrawGizmos 则是可以全局可见。

而如果需要事件处理,比如需要在 Scene 界面可以直接点击增加或者修改这些路点,就需要在 OnSceneGUI处理事件来进行一些操作。

完整的代码如下,这里注意的是原来的 poses 为了方便改用成了 List 类型:

using UnityEngine;
using UnityEditor;

[CustomEditor(typeof(SceneGUITest))]
public class SceneGUITestEditor : Editor 
{
    protected SceneGUITest ctr;

    private void OnEnable() 
    {
        ctr = target as SceneGUITest;
    }
    private void OnSceneGUI() 
    {
        Event _event = Event.current;

        if( _event.type == EventType.Repaint )
        {
            Draw();
        }
        else if ( _event.type == EventType.Layout )
        {
            HandleUtility.AddDefaultControl( GUIUtility.GetControlID( FocusType.Passive ) );
        }
        else
        {
            HandleInput( _event );
            HandleUtility.Repaint();
        }
    }

    void HandleInput( Event guiEvent )
    {
        Ray mouseRay = HandleUtility.GUIPointToWorldRay( guiEvent.mousePosition );
        Vector2 mousePosition = mouseRay.origin;
        if( guiEvent.type == EventType.MouseDown && guiEvent.button == 0 )
        {
            ctr.poses.Add( mousePosition );
        }
    }

    void Draw()
    {
        //Draw a sphere
        Color originColor = Handles.color;
        Color circleColor = Color.red;
        Color lineColor = Color.yellow;
        Vector2 lastPos = Vector2.zero;
        for (int i = 0; i < ctr.poses.Count; i++)
        {
            var pos = ctr.poses[i];
            Vector2 targetPos = ctr.transform.position;
            //Draw Circle
            Handles.color = circleColor;
            Vector2 finalPos = targetPos + new Vector2( pos.x, pos.y);

            Handles.SphereHandleCap(  GUIUtility.GetControlID(FocusType.Passive ) , finalPos , Quaternion.identity, 0.2f , EventType.Repaint );
            //Draw line
            if(i > 0) 
            {
                Handles.color = lineColor;
                Handles.DrawLine( lastPos, pos );
            }
            lastPos = pos;
        }
        Handles.color = originColor;
    }
}

MenuItem 与 EditorWindow

MenuItem

MenuItem 可以说是用得最多的了,它的作用是编辑器上菜单项,一般用于一些快捷操作,比如交换两个物体位置:

由于是涉及编辑器的代码,所以依然可以放在 Editor 文件夹下面,具体代码如下:

using UnityEditor;
using UnityEngine;

public class MenuCommand
{
    [MenuItem("MenuCommand/SwapGameObject")]
    protected static void SwapGameObject()
    {
        //只有两个物体才能交换
        if( Selection.gameObjects.Length == 2 )
        {
            Vector3 tmpPos = Selection.gameObjects[0].transform.position;
            Selection.gameObjects[0].transform.position = Selection.gameObjects[1].transform.position;
            Selection.gameObjects[1].transform.position = tmpPos;
            //处理两个以上的场景物体可以使用 MarkSceneDirty
            UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty( UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene() );
        }
    }
}


EditorWindow

EditorWindow 在 Unity 引擎中的应用也算是比较多,比如 Animation、TileMap 和 Animitor 窗口应该就是用到了 EditorWindow。创建方法仍然是在 Editor 文件夹中创建一个继承自 EditorWindow 的脚本。EditorWindow 有一个 GetWindow 的方法,调用之后如果当前没有这个窗口会返回新的,如果有就返回当前窗口,之后调用 Show 即可展示这个窗口。可以使用 MenuItem 来显示这个 EditorWindow,重写 OnGUI 方法即可以写 Editor 的 UI:

using UnityEngine;
using UnityEditor;

namespace EditorTutorial
{
    public class EditorWindowTest : EditorWindow 
    {

        [MenuItem("CustomEditorTutorial/WindowTest")]
        private static void ShowWindow() 
        {
            var window = GetWindow();
            window.titleContent = new GUIContent("WindowTest");
            window.Show();
        }

        private void OnGUI() 
        {
            if(GUILayout.Button("Click Me"))
            {
                //Logic
            }
        }
    }
}

之后点击编辑器的 Menu 就会有这个 EditorWindow 出来:

EditorWindow 的 UI 的写法跟 OnInspectorGUI 的写法差不多,基本是 GUILayoutEditorGUILayout 这两个类。


EditorWindow 与 OnInspectorGUI 的差别

最主要的差别是 EditorWindow 可以停靠的在边栏上,不会因为你点击一个物体就重新生成。而 OnInspectorGUI 的 Editor 类在你每次切换点击时候都会调用 OnEnable 方法。


EditorWindow 如何绘制 Scene 界面 UI

EditorWindow 中如果需要对 Scene 绘制一些 UI,这个时候使用 Editor 那种 OnSceneGUI 是无效的,这个时候则需要在 Focus 或者 OnEnable 时候加入 SceneView 的事件回调中,并且在 OnDestroy 时候去除该回调:

private void OnFocus() 
{
    //在2019版本是这个回调
    SceneView.duringSceneGui -= OnSceneGUI;
    SceneView.duringSceneGui += OnSceneGUI;

    //以前版本回调
    // SceneView.onSceneGUIDelegate -= OnSceneGUI
    // SceneView.onSceneGUIDelegate += OnSceneGUI
}

private void OnDestroy() 
{
    SceneView.duringSceneGui -= OnSceneGUI;
}

private void OnSceneGUI( SceneView view ) 
{
}

ScriptWizard

Unity 引擎的中的 BuildSetting 窗口(Ctrl+Shift+B 弹出的窗口)就是使用了 SciptWizard,一般来开发过程中作为比较简单的生成器和初始化类型的功能来使用,比如美术给我一个序列帧,我需要直接生成一个带 SpriteRendererGameObject,而且它还有自带序列帧的 Animator。

默认的显示样式:

其创建过程还是在 Editor 文件夹下创建一个继承自 ScriptWizard 的脚本,调用 ScriptWizard.DisplayWizard 方法即可生成并显示这个窗口,点击右下角的 Create 会调用 OnWizardCreate 方法:

public class TestScriptWizard: ScriptableWizard 
{

    [MenuItem("CustomEditorTutorial/TestScriptWizard")]
    private static void MenuEntryCall() 
    {
        DisplayWizard("Title");
    }

    private void OnWizardCreate() 
    {

    }
}


ScriptWizard 与 EditorWindow 的区别

ScriptWizard 中如果你声明一个 Public 的变量,会发现在窗口可以直接显示,但是在 EditorWindow 则是不能显示。

ScriptObject

对于游戏中一些数据和配置可以考虑用 ScriptObject 来保存,虽然 XML 之流也可以,但是 ScriptObject 相对比较简单而且可以保存 UnityObject 比如 SpriteMaterial 这些。甚至你会发现上面说的几个类都是继承自 SctriptObject。 因为其不再是只适用编辑器,所以不必放在 Editor 文件夹下 。

ScriptWizard 类似,也是声明 Public 可以在窗口上直接看到,自定义绘制 GUI 也是在 OnGUI 方法里面:

[CreateAssetMenu(fileName = "TestScriptObject", menuName = "CustomEditorTutorial/TestScriptObject", order = 0)]
public class TestScriptObject : ScriptableObject 
{
    public string Name;
}

使用 CreateAssetMenuAttribute 的作用是使得其可以在 Project 窗口中右键生成:


ScriptObject 与 System.Serializable 的差别

初学者可能会对这两个比较困扰(我一开始就比较困扰),一开始我把 ScriptObject 拖拽到 Monobehaviour 上面发现其不会显示出 ScriptObject 的属性

然后我在 ScriptObject 上面加上 [System.Serializable],也是没用:

[CreateAssetMenu(fileName = "TestScriptObject", menuName = "CustomEditorTutorial/TestScriptObject", order = 0)]
[System.Serializable]
public class TestScriptObject : ScriptableObject 
{
    public string Name;
}

所以是在 ScriptObject 上面使用 [ System.Serializable ] 是不可取的,[ System.Serializable] 适合于普普通通的 Class,比如:

[System.Serializable]
public class Data
{
    string Name;
}


ScriptObject 上面调用编辑器修改需要调用 EditorUtility.SetDirty,不可调用 EditorSceneManager.MarkSceneDirty

因为 MarkSceneDirty 顾名思义是标记场景为已修改,但是编辑 ScriptObject 并不属于场景内数据,所以如果修改只可调用 EditorUtility.SetDirty,不然会造成数据改动未生效。

Attributes

Attributes 是 C# 的一个功能,它可以让声明信息与代码相关联,其与 C# 的反射联系很紧密。在 Unity 中诸如[System.Serializable], [Header], [Range] 都是其的应用。一般来说他它功能也可以通过 Editor 来实现,但是可以绘制对应的属性来说会更好复用。

拓展 Attribute 相对来说稍微复杂一点,它涉及两个类:PropertyAttribute PropertyDrawer,前者是定义它行为,后者主要是其在编辑器的显示效果。一般来说 Attribute 是放在 Runtime,而 Drawer 则是放在 Editor 文件夹下。这里的例子是加入 [Preview]Attribute,使得我们拖拽 Sprite 或者 GameObject 可以显示预览图:

使用时候的代码如下:

public class AttributeSceneController : MonoBehaviour 
{
    [Preview]
    public Sprite sprite;
}

我们现在 Runtime 层的文件夹加入继承自 PropertyAttributePreviewAttribute 脚本:

public class Preview : PropertyAttribute
{
    public Preview()
    {

    }
}

然后在 Editor 文件夹下加入继承自 PropertyDrawerPreviewDrawer 脚本:

using UnityEngine;
using UnityEditor;
namespace EditorTutorial
{
    [CustomPropertyDrawer(typeof(Preview))]
    public class PreviewDrawer: PropertyDrawer 
    {
        //调整整体高度
        public override float GetPropertyHeight( SerializedProperty property, GUIContent label )
        {
            return base.GetPropertyHeight( property, label ) + 64f;
        }
        public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) 
        {
            EditorGUI.BeginProperty(position, label, property);
            EditorGUI.PropertyField(position, property, label);

            // Preview
            Texture2D previewTexture = GetAssetPreview(property);
            if( previewTexture != null )
            {
                Rect previewRect = new Rect()
                {
                    x = position.x + GetIndentLength( position ),
                    y = position.y + EditorGUIUtility.singleLineHeight,
                    width = position.width,
                    height = 64
                };
                GUI.Label( previewRect, previewTexture );
            }
            EditorGUI.EndProperty();
        }

        public static float GetIndentLength(Rect sourceRect)
        {
            Rect indentRect = EditorGUI.IndentedRect(sourceRect);
            float indentLength = indentRect.x - sourceRect.x;

            return indentLength;
        }

        Texture2D GetAssetPreview( SerializedProperty property )
        {
            if (property.propertyType == SerializedPropertyType.ObjectReference)
            {
                if (property.objectReferenceValue != null)
                {
                Texture2D previewTexture = AssetPreview.GetAssetPreview(property.objectReferenceValue);
                return previewTexture;
                }
            return null;
            }
            return null;
        }
    }
}

这里是对属性的一些绘制,实际开发过程中我们常常需要一些可交互的 UI,比如在方法上面加一个 [Button] 然后在编辑器暴露出一个按钮出来,具体的例子可以参考 NaughtyAttribute

AssetPostprocessor

在开发过程中常常会遇到资源导入问题,比如我制作像素游戏图片要求是 FilterModePoint,图片不需要压缩,PixelsPerUnit16,如果每次复制到一个图片到项目再修改会很麻烦。这里一个解决方案是可以用 MenuItem 来处理,但还需要多点几下,而使用 AssetPostprocessor 则可以自动处理完成。

在 Editor 文件夹下新建一个继承自 AssetPostprocessorTexturePipeLine

public class TexturePipeLine : AssetPostprocessor
{
    private void OnPreprocessTexture()
    {
        TextureImporter importer = assetImporter as TextureImporter;
        if( importer.filterMode == FilterMode.Point ) return;

        importer.spriteImportMode = SpriteImportMode.Single;

        importer.spritePixelsPerUnit = 16;
        importer.filterMode = FilterMode.Point;
        importer.maxTextureSize = 2048;
        importer.textureCompression = TextureImporterCompression.Uncompressed;

        TextureImporterSettings settings = new TextureImporterSettings();
        importer.ReadTextureSettings( settings );
        settings.ApplyTextureType( TextureImporterType.Sprite );
        importer.SetTextureSettings( settings ) ;
    }
}

之后再导入一个像素图片发现就已经全部设置好了:

可以想象的一些使用场景是可以根据 XML 和 SpriteSheet 来实现自动生成动画或者自动切图,解析 PSD 或 ASE 自动导入 PNG。

其他一些值得注意的地方

Undo

在之前说过在 Editor 里面直接改动原来的 Monobehaviour 脚本是变量是无法撤销的,但是使用 serializedObject 来修改则可以撤销。这里可以自己写一个 Undo 来记录使其可以撤销,代码如下:

if(GUILayout.Button("Click Me"))
{
    InspectorTest ctr = target as InspectorTest;
    //记录使其可以撤销
    Undo.RecordObject( ctr ,"Change Name" );
    ctr.Name = "Codinggamer";
    EditorUtility.SetDirty( ctr );
}


SelectionBase

当你的类中使用 [SelectionBase]Attribute 时候,如果你点击其子节点下的物体,其仍然只会聚焦这个父节点。


不在 Editor 文件夹里面写编辑器代码

有的时候我们 Monobehaviour 本身很短小,拓展 InspectorGUI 的代码也很短小,没有必要在 Editor 上创建一个新的脚本,可以直接使用 #UNITY_EDITOR 的宏来创建一个拓展编辑器,比如之前的拓展 InspectorGUI 可以这样写:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif

namespace EditorTutorial
{
    public class InspectorTest : MonoBehaviour
    {
        public string Name = "hello";
    }

    #if UNITY_EDITOR
    [CustomEditor(typeof(InspectorTest))]
    public class InspectorTestEditor : Editor 
    {
        public override void OnInspectorGUI() 
        {
            base.OnInspectorGUI();
            if(GUILayout.Button("Click Me"))
            {

                InspectorTest ctr = target as InspectorTest;
                //记录使其可以撤销
                Undo.RecordObject( ctr ,"Change Name" );
                ctr.Name = "Codinggamer";
                EditorUtility.SetDirty( ctr );
            }
        }
    }
    #endif
}


EditorWindow 和 Editor 保存数据

这里需要使用 EditorPrefs 来保存和读取数据,在需要保存的数据上面加上 [System.SerializeField] Attribute,然后在 OnEnableOnDisable 时候可以保存或者度序列化 json:

[SerializeField]
public string Name = "Hi";

private void OnEnable() 
{
    var data = EditorPrefs.GetString( "WINDOW_KEY", JsonUtility.ToJson( this, false ) );
    JsonUtility.FromJsonOverwrite( data, this );
}

private void OnDisable() 
{
    var data = JsonUtility.ToJson( this, false  );
    EditorPrefs.SetString("WINDOW_KEY", data);
}

感觉这种方法也可以在运行时序列化脚本保存到本地。


在代码中检索对应 MonoBehaviour 的 Editor 类

EditorWindow 的使用过程中,有的时候可能需要调用到对应拓展 MonoBehaviourEditor 代码,这个时候可以使用 Editor.CreateEditor 方法来创建这个 Editor


在编辑器代码中生成 SerializedObject

上面说过,在编辑器代码中一般比较多使用 SerializedObject,像 Editor 类中就内置了 serializedObject。实际上所有继承自 ScriptObject 或者 Monobehaviour 的脚本都可以生成 SerializedObject。其生成方式很简单只需要 new 的时候传入你需要序列化的组件即可:

SerializedObject serialized = new SerializedObject(this);

后记

这篇文章中并没有涉及比较多的 API 的使用,更多的是想展现出可以用拓展编辑器来做什么以及当你想做一些拓展时候需要从哪里入手。比如如果你想给美术人员做一个快捷生成角色的工具,就可以使用 ScriptWizard。如果你需要让美术人员和设计师更加方便地调整人物属性,则可以考虑使用 Editor。如果你需要给关卡设计师制作一个关卡编辑器,那可以考虑使用 EditorWindow

写得比较多,以上这个就是我在使用 Unity 拓展编辑器的总结与遇到的一些问题的经验。

示例 Github 仓库: UnityEditorTutorial


Codinggamer

2019 年 2 月 27 日


推荐资源


注:题图来自 Unity 官方 Blog

近期点赞的会员

 分享这篇文章

sadi 

小小翻译,小小开发者 

您可能还会对这些文章感兴趣

参与此文章的讨论

  1. makegames4fun 2020-03-03

    作者可以看下odin,写编辑器界面简直爽歪歪

    • Codinggamer 2020-03-05

      @makegames4fun:最近刚开始用这个,比如button的attribute就是看它才去想怎么做到的。确实很方便的插件。

您需要登录或者注册后才能发表评论

登录/注册