Unity3D编辑器扩展

本文为bbbbbbion(可以叫我六饼)原创总结,如有疏漏请各位拍砖留言。转载请尊重原作者成果,保留出处。

综述

编辑器扩展这部分包括很多内容,我这里给它分成以下几类:

  • 新编辑功能
  • 重写Inspector视图
  • Scene界面交互
  • Gizmos辅助显示

    新编辑功能

    一共三种类型,一种是使用MenuItem标识,另外两种是继承自ScriptableWizard、EditorWindow。需要注意的是MenuItem标识是后两种的基础。使用MenuItem标识可以为编辑器添加新的菜单,也可以为Inspector上下文添加菜单。只有静态方法可以使用该标识,该标识可以把静态方法转换为菜单命令。
    另外可以为菜单创建快捷方式(hotkey),你可以使用如下特殊修饰字符:%(Windows系统上的ctrl,OS X系统上的cmd),#(shift),&(alt)。比如快捷方式“shift-alt-g”的写法为[MenuItem(“XX/XX/XXX #&g”)]。如果不需要为hotkey提供修饰符需要用字符_修饰,如快捷方式“g”的写法为[MenuItem(“XX/XX/XXX _g”)]
    另外还可以支持一些特殊的字符:LEFT、RIGHT、UP、DOWN、F1…F12、HOME、END、PGUP、PGDN。

如下为示例代码:

using UnityEngine;  
using System.Collections;  
using UnityEditor;  

public class AddChild
{
    [MenuItem("Custom/Create Child For Selected GameObjects")]
    static void MenuAddChild()
    {
        Transform[] transforms = Selection.GetTransforms(SelectionMode.TopLevel | SelectionMode.OnlyUserModifiable);
        foreach (Transform transform in transforms)
        {
            GameObject aChild = new GameObject("_Child");
            aChild.transform.parent = transform;
        }
    }
    [MenuItem("Custom/Create Child For Selected GameObjects", true)]
    static bool ValidateMenuAddChild()
    {
        return Selection.activeGameObject != null;
    }
}

上述代码实现了为选择的GameObject添加子物体的功能。
函数为静态函数,函数名任意,但是第二个验证函数是固定的,必须以Validate开头,再加上前面函数的名字。
另外需要注意到第二个验证函数中第一个参数必须和第一个函数一致,第二个参数为true。当验证函数返回false的时候,则第一个函数对应的操作无法激活。代码所示即当没有选择GameObject的时候,Custom/Create Child For Selected GameObjects这个目录下的选项是灰化的。
另外MenuItem后面若是跟GameObject则选项出现在已经存在的GameObject选项栏下;
若跟Assets,则出现在Assets选项栏下,另外需要注意的是Assets选项栏下的操作都是可以通过在Project面板下右键具体assets来弹出选项的;
若跟Assets/Create则选项出现在Project视图的Create按钮下;
若跟”CONTEXT/具体Component名/XXX”则可以为这个具体组件的Inspector上下文添加菜单,菜单的打开方式为右键组件或者左键组件最右边的“齿轮”按钮,需要注意的是为具体组件的Inspector上下文添加菜单不支持多重层次。
以下代码为Inspector上下文菜单:

public class AddChild
{
    [MenuItem("CONTEXT/Transform/Create Child For Selected GameObjects")]
    static void MenuAddChild()
    {
        Transform[] transforms = Selection.GetTransforms(SelectionMode.TopLevel | SelectionMode.OnlyUserModifiable);
        foreach (Transform transform in transforms)
        {
            GameObject aChild = new GameObject("_Child");
            aChild.transform.parent = transform;
        }
    }
    [MenuItem("CONTEXT/Transform/Create Child For Selected GameObjects", true)]
    static bool ValidateMenuAddChild()
    {
        return Selection.activeGameObject != null;
    }
}

以上代码实现了把增加子节点的功能注入到Inspector面板上Transform组件的弹出菜单中。
另外有时候我们需要获得Inspector上下文菜单对应的组件,这个时候需要用到MenuCommand,如下:

[MenuItem("CONTEXT/Rigidbody/DoSomething")]
static void DoSomething(OnInspectorGUI command)
{
    Rigidbody body=command.context;
    body.mass=5;
}

补充:
MenuItem标识可以实现排序和分组的功能

[MenuItem("NewMenu/Option1", false, 1)]
private static void NewMenuOption()
{
}

[MenuItem("NewMenu/Option2", false, 2)]
private static void NewMenuOption2()
{
}

[MenuItem("NewMenu/Option3", false, 3)]
private static void NewMenuOption3()
{
}

[MenuItem("NewMenu/Option4", false, 51)]
private static void NewMenuOption4()
{
}

[MenuItem("NewMenu/Option5", false, 52)]
private static void NewMenuOption5()
{
}

如上述代码所示,最后的阿拉伯数字即排序所用,越小的排在越上,UnityEditor会自动分组,50为单位。

继承自ScriptableWizard

通过继承ScriptableWizard可以创建编辑器向导,Unity3D已经为我们封装好了一些变量、方法、消息。如:

  • 变量:errorString(设置向导的错误提示信息)、helpString(设置向导的帮助提示)、isValid(可以控制向导的Create Button和Other Button能否点击)。

  • 静态方法:DisplayWizard

  • 消息:OnDrawGizmos(当向导可见的时候每帧执行,可用gizmos来实现预览的效果)、OnWizardCreate(当点击Create按钮的时候触发)、OnWizardOtherButton(当点击Other按钮的时候)、OnWizardUpdate(当向导打开的瞬间或者向导中有输入参数的变化时触发)

下面看一个实例代码:

public class CreateACube : ScriptableWizard
{
    public float size = 1f;//声明为public可以被向导序列化显示在界面上,从而可以自由改变它的值
    [MenuItem("Custom/CreateACube")]
    static void CreateACubeWizard()
    {
        //ScriptableWizard.DisplayWizard<CreateACube>("创建一个Cube", "确定", "取消");//这两种写法均可,要点是提供一个type,type类型为类的名字
        ScriptableWizard.DisplayWizard("创建一个Cube", typeof(CreateACube), "确定", "取消");
    }

    void OnDrawGizmos()
    {
        if (size < 3)
        {
            return;
        }
        else
        {
            Gizmos.color = Color.red;
            Gizmos.DrawCube(Vector3.zero, new Vector3(size, size, size));
        }
    }

    void OnWizardCreate()
    {
        GameObject obj = GameObject.CreatePrimitive(PrimitiveType.Cube);
        obj.transform.localScale = new Vector3(size, size, size);
    }

    void OnWizardOtherButton()
    {
        Close();
    }

    void OnWizardUpdate()
    {
        helpString = "输入Cube的Size,Size要大于等于3,创建一个Cube";

        if (size < 3)
        {
            errorString = "size要大于等于3";
            isValid = false;
        }
        else
        {
            errorString = "";
            isValid = true;
        }
    }
}

上述代码中OnDrawGizmos()没有生效,我使用的是4.5.2f1版本的Unity,还请知道原因的告知!
下图为上述代码实现的向导:
"ScriptableWizard向导"

继承自EditorWindow

继承自EditorWindow的类,可以实现编辑器窗口的功能。类似于Inspector、Project界面等等复杂的功能,而且可以自由镶嵌到Unity界面内,构成一定的layout。
如下代码为在集成自EditorWindow的类中实现上述创建一个Size>=3的Cube的功能:

public class CreateACube2 : EditorWindow
{
    float size = 1f;
    string helpString = "输入Cube的Size,Size要大于等于3,创建一个Cube";
    string errorString = "size需要大于等于3";
    bool enableConfromButton = false;
    static Color originColor;
    [MenuItem("Custom/CreateACube2")]
    static void Init()
    {
        //获取已经打开的window,如果不存在则new一个
        CreateACube2 window = EditorWindow.GetWindow(typeof(CreateACube2)) as CreateACube2;
        originColor = GUI.color;
    }

    void OnGUI()
    {
        GUILayout.Label(helpString, EditorStyles.boldLabel);
        size = EditorGUILayout.FloatField("size:", size);

        enableConfromButton = size >= 3 ? true : false;
        if (enableConfromButton)
        {
            GUI.enabled = true;
        }
        else
        {
            GUI.enabled = false;//设置error的时候Button按钮不可点击
            GUI.color=Color.red;//设置errorString的颜色为红色
            GUILayout.Label(errorString);
        }
        GUI.color = originColor;
        if (GUILayout.Button("确定"))
        {
            DoCreate();
        }
    }

    void DoCreate()
    {
        GameObject obj = GameObject.CreatePrimitive(PrimitiveType.Cube);
        obj.transform.localScale = new Vector3(size, size, size);
    }
} 

OnGUI消息是继承自EditorWindow类的最重要的消息,在其内部可以自己来创建GUI。另外还有一些比较重要的消息:

  • OnDestroy()当EditorWindow关闭的时候触发。
  • OnFocus()当Window获得焦点触发。
  • OnHierarchyChange()当Hierarchy面板内有改变的时候触发。
  • OnInspectorUpdate()每秒十帧的触发,来判断Inspector界面是否有改变。
  • OnLostFocus()当Window失去焦点触发。
  • Update()每秒100次的触发,在所有可见的Windows。
    还有一些好用的消息,变量,静态方法,方法…具体查看文档。

重写Inspector视图

重写Inspector视图,是最强大的一种编辑器扩展。下面以一个最简单的实例来说明如何使用。
我们定义一个Player:

public class Player : MonoBehaviour
{    
    public int armor = 75;
    public int damage = 20;
    public GameObject gun;
}    

把脚本拖拽到GameObject作为Component后其对应的Inspector界面如下:
"普通状态下的Inspector界面"
为了实现重写Inspector视图的目地,我们新建一个脚本PlayerEditor.cs,继承自Editor,且把脚本放到Editor目录下。代码如下:

[CanEditMultipleObjects]
[CustomEditor(typeof(Player))]
public class PlayerEditor : Editor
{
    SerializedProperty armorProp;
    SerializedProperty damageProp;
    SerializedProperty gunProp;

    void OnEnable()
    {
        armorProp = serializedObject.FindProperty("armor");
        damageProp = serializedObject.FindProperty("damage");
        gunProp = serializedObject.FindProperty("gun");
    }

    public override void OnInspectorGUI()
    {
        //更新serializedProperty,一般在OnInspector函数的一开始使用。
        serializedObject.Update();

        //依次重写Player.cs脚本中的armor,damage,gun
        EditorGUILayout.IntSlider(armorProp, 0, 100, new GUIContent("Armor"));
        if (!armorProp.hasMultipleDifferentValues)//当该值相同的时候才显示ProgressBar
        {
            ProgressBar(armorProp.intValue / 100.0f, "ArmorCount");
        }

        EditorGUILayout.IntSlider(damageProp, 0, 100, new GUIContent("Damage"));
        if (!damageProp.hasMultipleDifferentValues)
        {
            ProgressBar(damageProp.intValue / 100.0f, "DamagePower");
        }

        EditorGUILayout.PropertyField(gunProp, new GUIContent("Gun Object"));

        //使更改生效(对于使用了SerializedProperty、SerializedObject)
        serializedObject.ApplyModifiedProperties();
    }

    private void ProgressBar(float value, string str)
    {
        Rect rect = GUILayoutUtility.GetRect(18, 18, "TextField");
        EditorGUI.ProgressBar(rect, value, str);
        EditorGUILayout.Space();
    }
}

下图为以上代码重写后的界面:
"重写Inspector界面# 2 #"
实现Inspector重写的方式有多种,上述代码使用了推荐的SerializedProperty和SerializedObject来实现
上述代码需要解释的地方有如下几点:

  • [CustomEditor(typeof(Player))] 这个编辑器属性可以把Editor脚本和原始cs脚本建立关系,从而实现重写。

  • [CanEditMultipleObjects] 使用了这个编辑器属性,可以确保当选择的多个物体具有相同组件的时候不会报错“Multi-object editing not supported”,而且可以支持多个物体编辑。

  • OnInspectorGUI() 重写该方法可以实现对默认Inspector界面的重写。该方法也是最常用的一个方法。

  • GUILayoutUtility.GetRect(18, 18, “TextField”) 该方法的作用??????

另一种不是通过SerializedProperty、SerializedObject方法来实现的代码如下:

[CanEditMultipleObjects]
[CustomEditor(typeof(Player))]
public class PlayerEditor : Editor
{
    Player player;

    void OnEnable()
    {
        player = target as Player;
    }

    public override void OnInspectorGUI()
    {
        player.armor = EditorGUILayout.IntSlider("Armor", player.armor, 0, 100);
        ProgressBar(player.armor / 100f, "ArmorCount");

        player.damage = EditorGUILayout.IntSlider("Damage", player.damage, 0, 100);
        ProgressBar(player.damage / 100f, "DamagePower");

        bool allowSceneObjects = !EditorUtility.IsPersistent(player);
        player.gun = EditorGUILayout.ObjectField("Gun Object", player.gun, typeof(GameObject), allowSceneObjects) as GameObject;
    }

    private void ProgressBar(float value, string str)
    {
        Rect rect = GUILayoutUtility.GetRect(18, 18, "TextField");
        EditorGUI.ProgressBar(rect, value, str);
        EditorGUILayout.Space();
    }
}

下图为以上代码重写后的界面:
"重写Inspector界面2"

上述代码中需要说明的地方:

  • EditorUtility.IsPersistent(player) 该方法获得目标物体是否存在disk上。官方的解释为:Typically assets like prefabs, textures, audio clips, animation clips, materials are stored on disk.Returns false if the object lives in the scene. Typically this is a game object or component but it could also be a material that was created from code and not stored in an asset but instead stored in the scene.因此EdiotrUtility.IsPersistent(player)返回结果为false。

补充一些在OnInspectorGUI()方法中常用到的方法:

  • DrawDefaultInspector() 绘制默认的对应脚本中的Inspector,常用于扩展一个已经存在的组件:比如想在Camera组件上添加个按钮,实现特殊功能,则可以写个类用[CustomEditor(typeof(Camera))]和Camera建立关系,然后在OnInspector()中先调用DrawDefaultInspector(),然后再添加需要的按钮之类的。

  • EditorGUILayout.Separator() 一个比较大的空行

  • EditorGUILayout.Space() 一个比较小的空行

  • GUILayout.Space(10f) 可控具体空多少行

  • GUILayout.BeginHorizontal()和GUILayout.EndHorizontal() 二者一起可以让内部GUI水平排列。同理还有Vertical。

Scene界面交互

Scene界面内的交互在上述Inspector重写的基础上进行,前戏步骤同上,用到的要点姿势有如下一些:
在上述OnEnable()函数中提供一个委托SceneView.OnSceneGUIDelegate=SomeFuction;
在方法SomeFuction(SceneView sceneView)中进行当Scene窗口获得焦点后的具体操作。通常需要获得一个当前交互的事件,比如Event e= Event.current;然后通过判断e.isKey、e.character==”p”之类的来获得键盘操作。
有时我们会在Inspector界面内点击了某个按钮后触发Scene界面的交互,这个时候需要强制获得Scene的焦点。可以使用(SceneView.sceneViews[0] as SceneView).Focus();
必要的地方需要使用SceneView.RepaintAll();来即时刷新Scene窗口。
Scene界面内获得当前鼠标所在位置发射的射线:Ray ray=HandleUtility.GUIPointToWorldRay(e.mousePosition);
Scene内世界坐标转换为屏幕GUI坐标:HandleUtility.WorldToGUIPoint(worldPos);

OnSceneGUI

OnSceneGUI()是进行Scene界面交互一个重要消息,可以用GUI的形式在其内部绘制Scene界面交互结果信息。一个简单的例子如下,注意必须当选中带有该脚本对应的组件物体时,该消息才触发:

void OnSceneGUI()
{
    Handles.BeginGUI();//绘制必须使用BeginGUI()和EndGUI(),否则不显示。
    var guiPoint = HandleUtility.WorldToGUIPoint(Vector3.zero);
    GUI.Box(new Rect(guiPoint.x - 50, guiPoint.y - 30, 100, 60), "你好");
    GUI.Button(new Rect(200, 200, 200, 100), "Button");
    Handles.EndGUI();
}

Gizmos辅助显示

绘制Gizmos一般使用void OnDrawGizmos()方法,继承自MonoBehaviour和Scriptwizard都有该方法。
另外还有一种DrawGizmo方法,也是一种通过编辑器属性来设定的,如下建立一个TestDrawGizmo.cs的脚本,放到Editor目录下。和OnSceneGUI()不同的是可以做到不需要选中物体便可以显示相关信息:

public class TestDrawGizmo
{
    [DrawGizmo(GizmoType.NotSelected | GizmoType.Pickable | GizmoType.SelectedOrChild)]
    static void RenderLightGizmo(Light light, GizmoType gizmoType)
    {
        Handles.Label(light.transform.position, light.transform.gameObject.name);
        Gizmos.DrawIcon(light.transform.position + Vector3.up, "Light.png");        
        if ((gizmoType & GizmoType.SelectedOrChild) != 0)
        {
            if ((gizmoType & GizmoType.Active) != 0)
            {
                Gizmos.color = Color.red*0.4f;
            }
            else
                Gizmos.color = Color.red * 0.5f;

            Gizmos.DrawSphere(light.transform.position, light.range);
        }
    }
}    

结果如下:
"DrawGizmo结果"

补充知识
类的继承关系
ScriptableWizard:EditorWindow:ScriptableObject:Object:UnityEngine
Editor:ScriptableObject:Object:UnityEngine