在這篇教程中你會學習如何擴展你的Unity3D編輯器,以便在你的項目中更好的使用它。你將會學習如何繪制你自己的gizmo,用代碼來實現創建和刪除物體,創建編輯器窗口,使用組件,並且允許用戶撤銷他們所作出的任何動作,這些全部都是用編輯器腳本來實現的。
這篇教程假設你已經熟悉Unity的基本工作流程。如果你知道如何在編輯器中創建物體、預設、場景並且知道如何移動它們,知道如何添加組件,那么你可以開始本教程的學習了。
最終結果預覽
讓我們看一下我們做出的最終結果是什么樣子:
如你所見,我們會創建一個編輯器窗口,里面有一個顏色拾取器,我們可以用所選取的顏色來繪制網格。我們也能夠創建和刪除物體,把他們對齊到網格,並且能夠撤銷這些動作。
Step 1: Gizmos
首先我們來學習如何使用gizmo。這里有一些內置gizmo的例子:
你可能會經常在Unity中看到這個gizmo,因為它會為每一個擁有transform
組件的物體繪制,因此基本上每一個被選中的物體都會有這個gizmo。
這是另一個gizmo,它能夠讓我們知道綁定在我們游戲對象上的BoxCollider
的大小。
Step 2: 創建一個Grid腳本
創建一個C#腳本Grid.cs,我們用它來為一個物體繪制我們自己的gizmo;我們會在編輯器中繪制一個簡單的網格(Grid)來作為一個例子。
using UnityEngine; using System.Collections; public class Grid : MonoBehaviour { void Start() { } void Update() { } }
對於一個網格,我們需要添加2個變量:width和height
public class Grid : MonoBehaviour { public float width = 32.0f; public float height = 32.0f; void Start() { } void Update() { } }
為了在編輯器中繪圖,我們需要使用OnDrawGizmo
回調函數,因此讓我們創建它。
public class Grid : MonoBehaviour { public float width = 32.0f; public float height = 32.0f; void Start() { } void Update() { } void OnDrawGizmos() { } }
Step 3: 繪制網格(Grid)
要繪制一個網格,我們需要一系列水平線和垂直線,並且還要知道編輯器攝像機(也叫場景攝像機)的當前位置以便我們知道我們應該把我們的網格繪制在什么地方。首先,把攝像機的位置保存在一個單獨的變量中
void OnDrawGizmos() { Vector3 pos = Camera.current.transform.position; }
如你所見,我們可以使用Camera.current
引用來獲取編輯器攝像機。
現在,我們需要2個for循環來繪制水平線和垂直線。
void OnDrawGizmos() { Vector3 pos = Camera.current.transform.position; for (float y = pos.y - 800.0f; y < pos.y + 800.0f; y += height) { Gizmos.DrawLine(new Vector3(-1000000.0f, Mathf.Floor(y/height) * height, 0.0f), new Vector3(1000000.0f, Mathf.Floor(y/height) * height, 0.0f)); } for (float x = pos.x - 1200.0f; x < pos.x + 1200.0f; x += width) { Gizmos.DrawLine(new Vector3(Mathf.Floor(x/width) * width, -1000000.0f, 0.0f), new Vector3(Mathf.Floor(x/width) * width, 1000000.0f, 0.0f)); } }
我們使用Gizmos.DrawLine()
來繪制線。注意Gizmos
類有很多繪制API,因此繪制一個cube或者sphere甚至是它們的線框這樣的幾何圖元(primitive)都不成問題。如果需要,你也可以繪制一幅圖片。
網格線應該是無限長的,但是float.positiveInfinity
和float.negativeInfinity
似乎在繪制直線時不能正常工作,因此我們只是簡單的放置一個任意的大數來替代它。同時,線的數量嚴格取決於我們在for
循環中使用的常數;技術上來說,我們不應該在代碼中放置這樣的常數,但這僅僅是一個測試代碼,所以無視它就好。
要觀察網格,你需要創建一個空物體,然后把我們的腳本Grid.cs綁定到它上面:
Step 4: 創建一個自定義的Inspector
接下來的工作就是自定義Inspector。我們需要創建一個編輯器腳本來做這件事。創建一個新的C#文件,命名為GridEditor。這個腳本需要放置在Editor文件夾中;如果你還沒有一個Editor文件夾,那么現在就創建它吧。
using UnityEngine; using UnityEditor; using System.Collections; [CustomEditor (typeof(Grid))] public class GridEditor : Editor { }
這一次我們也需要using UnityEditor
以便我們能夠使用編輯器的類和函數。為了覆蓋我們的Grid
物體的默認inspector,我們需要在我們的類聲明之前添加一個屬性,[CustomEditor(typeof(Grid))]
會告訴Unity我們想要自定義Grid
的inspector。為了能夠使用編輯器的回調函數,我們需要繼承Editor
類而不是MonoBehaviour
類。
要改變當前inspector,我們需要覆蓋掉舊的那個。
public class GridEditor : Editor { public override void OnInspectorGUI() { } }
現在如果你檢查編輯器中的Grid物體的inspector,你會發現即便它有一些public成員,inspector里面也是空空如也。這是因為通過覆蓋OnInspectorGUI()
我們丟棄了默認的inspector,取而代之的是一個自定義的inspector。
Step 5: 使用GUILayout來填充我們的自定義Inspector
在我們創建任何字段(field)之前,我們需要取得自定義inspector所對應的那個對象的引用。我們實際上已經有了該對象的引用——它的名字叫target
——但是為了方便我們會創建一個該對象上Grid
組件的引用。首先,讓我們先聲明它。
public class GridEditor : Editor { Grid grid;
我們應該在OnEnable()
函數中為它賦值,該函數會在inspector可用時調用。
public class GridEditor : Editor { Grid grid; public void OnEnable() { grid = (Grid)target; }
現在,讓我們為inspector創建一些字段。我們會使用GUILayout和EditorGUILayout類來做這件事。
public override void OnInspectorGUI() { GUILayout.BeginHorizontal(); GUILayout.Label("Grid Width"); grid.width = EditorGUILayout.FloatField(grid.width, GUILayout.Width(50)); GUILayout.EndHorizontal(); }
第一行的GUILayout.BeginHorizontal()
表示,我們想要把接下來的inspector元素彼此從左到右放置。最后一行GUILayout.EndHorizontal()
,如你所想的那樣,表示我們不想再那樣做了。實際添加的項目位於這兩行之間。第一個添加的是一個簡單的標簽(在我們的案例中,它會顯示為Grid Width文本),緊挨着它,我們創建了一個EditorGUILayout.FloatField
,如你所想的那樣,它是一個float字段。注意我們把FloatField
的值賦給了grid.width
,而FloatField本身又顯示grid.width
的值。我們設置它的寬度為50
像素。
Step 6: 填充inspector並且重繪場景
現在讓我們添加另一個項目到inspector中;這一次它是grid.height
public override void OnInspectorGUI() { GUILayout.BeginHorizontal(); GUILayout.Label("Grid Width"); grid.width = EditorGUILayout.FloatField(grid.width, GUILayout.Width(50)); GUILayout.EndHorizontal(); GUILayout.BeginHorizontal(); GUILayout.Label("Grid Height"); grid.height = EditorGUILayout.FloatField(grid.height, GUILayout.Width(50)); GUILayout.EndHorizontal(); }
這就是我們Grid對象的全部字段了,如果你想知道在inspector中還可以使用哪些字段和項目,你可以查看Unity手冊中的EditorGUILayout和GUILayout類。
注意我們在新inspector中所作出的改變,只有當我們選中了場景視圖窗口之后才會可見。為了讓它在作出改變之后立即可見,我們可以調用SceneView.RepaintAll()
public override void OnInspectorGUI() { GUILayout.BeginHorizontal(); GUILayout.Label("Grid Width"); grid.width = EditorGUILayout.FloatField(grid.width, GUILayout.Width(50)); GUILayout.EndHorizontal(); GUILayout.BeginHorizontal(); GUILayout.Label("Grid Height"); grid.height = EditorGUILayout.FloatField(grid.height, GUILayout.Width(50)); GUILayout.EndHorizontal(); SceneView.RepaintAll(); }
現在,我們不必再點擊inspector的外面就可以看到變化后的結果了。
Step 7: 處理編輯器輸入
現在,讓我們嘗試處理編輯器的輸入,就像我們在游戲中所做的那樣。任何按鍵或鼠標狀態對我們都可用。為了取得這樣的效果,我們需要在我們的SceneView
中添加一個onSceneGUIDelegate
回調函數。讓我們調用我們自己的更新函數GridUpdate()
.
public void OnEnable() { grid = (Grid)target; SceneView.onSceneGUIDelegate = GridUpdate; } void GridUpdate(SceneView sceneview) { }
現在,我們只需獲取輸入事件(input Event
)
void GridUpdate(SceneView sceneview) { Event e = Event.current; }
Step 8: 創建一個預設(Prefab)
為了進一步把弄編輯器腳本,我們需要一個能夠使用的游戲對象。讓我們創建一個簡單的cube並把它做成prefab。
你可以調整網格的大小來匹配cube或者相反(調整cube的大小來匹配網格),最終讓cube和網格對齊。
如你所見,Hierarchy面板中的cube文本變成藍色的,這意味着它鏈接到了一個prefab。你可以在Project面板中看到那個預設(prefab)。
Step 9: 從編輯器腳本中創建一個物體
現在,我們將從編輯器腳本中創建一個物體。讓我們回到我們的GridEditor.cs中來擴展我們的GridUpdate()
函數。
當鍵盤上的A鍵被按下時,我們就創建一個物體。
void GridUpdate(SceneView sceneview) { Event e = Event.current; if (e.isKey && e.character == 'a') { GameObject obj; } }
如你所見,我們簡單地檢查一下觸發的事件是否是一個按鍵的狀態改變事件並且按下的那個按鍵的字符是否是'a
'。然后我們為我們的新物體創建了一個引用。現在,讓我們實例化它。
void GridUpdate(SceneView sceneview) { Event e = Event.current; if (e.isKey && e.character == 'a') { GameObject obj; if (Selection.activeObject) { obj = (GameObject)Instantiate(Selection.activeObject); obj.transform.position = new Vector3(0.0f, 0.0f, 0.0f); } } }
Selection.activeObject
是編輯器中當前所選中物體的一個引用。如果選中任何一個物體,那么我們就簡單地克隆它,並把克隆體的位置設置為(0,0,0).
讓我們測試一下它是否正常工作。你必須知道的一件事:無論什么時候我們的資源(assets)被重新導入/刷新,我們的GridUpdate()都會停止工作。(這通常都是當我們修改完代碼后回到編輯器中時)這時候,你可以選中編輯器腳本所指向的對象(例如在Hierarchy視圖中選中該對象)—在我們的例子中它是Grid物體,然后重新激活一下就可以了。另一件你需要知道的事情是輸入事件只有在場景視圖被選中的情況下才會被捕捉到。(也就是場景視圖獲得焦點)
Step 10: 從編輯器腳本中實例化一個預設(prefab)
盡管我們想方設法想要克隆對象,但克隆的對象所鏈接的那個預設(prefab)並不存在。
正如你所看到的,Cube(Clone)物體的名字被顯示成純黑色字體,也就意味着它並沒有鏈接到原始立方體所鏈接的那個預設上。如果我們在編輯器中手動復制(duplicate)那個原始的立方體,那么克隆的立方體將會鏈接到Cube預設上。為了讓它按這樣的方式為我們工作,我們需要使用InstantiatePrefab()
函數,該函數來自EditorUtility
類。
在我們使用該函數之前,我們需要取得被選中物體的預設(prefab)。我們可以使用GetPrefabParent()
函數來做這件事,該函數同樣來自於EditorUtility
類。
void GridUpdate(SceneView sceneview) { Event e = Event.current; if (e.isKey && e.character == 'a') { GameObject obj; Object prefab = EditorUtility.GetPrefabParent(Selection.activeObject); if (prefab) { } } }
當Selection.activeObject
沒有預設(prefab)時,我們可以停止檢查,因為如果它的預設(prefab)不存在,那么prefab
變量會等於null
,因此我們可以僅僅使用一個prefab
引用來來避免檢查。
現在,讓我們實例化我們的預設並設置它的位置。
void GridUpdate(SceneView sceneview) { Event e = Event.current; if (e.isKey && e.character == 'a') { GameObject obj; Object prefab = EditorUtility.GetPrefabParent(Selection.activeObject); if (prefab) { obj = (GameObject)EditorUtility.InstantiatePrefab(prefab); obj.transform.position = new Vector3(0.0f, 0.0f, 0.0f); } } }
這就是它的真實面目—現在讓我們檢查一下克隆的立方體是否鏈接到了預設(prefab)。
Step 11: 把屏幕上的鼠標坐標轉換為世界坐標
Event
類不告訴我們鼠標在世界空間中的位置,它只告訴我們鼠標在屏幕空間中的坐標。下面展示了我們如何在這兩者之間進行轉換,以便我們可以得到一個近似的世界空間中的鼠標坐標。
void GridUpdate(SceneView sceneview) { Event e = Event.current; Ray r = Camera.current.ScreenPointToRay(new Vector3(e.mousePosition.x, -e.mousePosition.y + Camera.current.pixelHeight)); Vector3 mousePos = r.origin; }
首先,我們使用編輯器攝像機的ScreenPointToRay
方法來得到一個從屏幕坐標發出的射線,但不幸的是,在那之前我們需要把事件的屏幕空間轉換成ScreenPointToRay()
可接受的一個屏幕空間。 e.mousePosition
里面保存着鼠標在一個特殊屏幕空間中的位置,這個特殊的屏幕空間的左上角的坐標是(0,0)
點,右下角的坐標等於(Camera.current.pixelWidth, -Camera.current.pixelHeight)
。我們需要把它轉換成ScreenPointToRay()
可接受的屏幕空間,也就是左下角的坐標是(0,0)
,右上角的坐標是(Camera.current.pixelWidth, Camera.current.pixelHeight)
,這種轉換是很簡單的。
接下來我們應該做的就是把射線的原點存放在我們的mousePos
向量中,以便它可以很容易的訪問。
現在,我們可以把克隆物體的位置設置為鼠標所在的位置:
if (prefab) { obj = (GameObject)EditorUtility.InstantiatePrefab(prefab); obj.transform.position = new Vector3(mousePos.x, mousePos.y, 0.0f); }
注意 when the camera is set really flat then the approximation of mouse position on one of the axes is really really bad,那就是為什么我要手動設置克隆體的z
坐標。現在,cube應該會在鼠標所在的位置被創建。
Step 12: 把立方體對齊到網格
因為我們已經設置好了我們的網格,如果不用它的話那會是一種恥辱;就讓我們使用我們的鼠標位置來把創建的立方體對齊到網格上。
if (prefab) { obj = (GameObject)EditorUtility.InstantiatePrefab(prefab); Vector3 aligned = new Vector3(Mathf.Floor(mousePos.x/grid.width)*grid.width + grid.width/2.0f, Mathf.Floor(mousePos.y/grid.height)*grid.height + grid.height/2.0f, 0.0f); obj.transform.position = aligned; }
Step 13: 從編輯器腳本中銷毀一個對象物體
在這一步中,我們將在編輯器中編程來刪除物體。我們可以使用DestroyImmediate()
來實現。在這個案例中讓我們充分利用Selection
類,當'd
'鍵被按下時刪除所有被選中的物體。
if (e.isKey && e.character == 'a') { GameObject obj; Object prefab = EditorUtility.GetPrefabParent(Selection.activeObject); if (prefab) { obj = (GameObject)EditorUtility.InstantiatePrefab(prefab); Vector3 aligned = new Vector3(Mathf.Floor(mousePos.x/grid.width)*grid.width + grid.width/2.0f, Mathf.Floor(mousePos.y/grid.height)*grid.height + grid.height/2.0f, 0.0f); obj.transform.position = aligned; } } else if (e.isKey && e.character == 'd') { foreach (GameObject obj in Selection.gameObjects) DestroyImmediate(obj); }
當'd
'鍵被按下時,我們遍歷所有被選中的物體並且刪除它們中的每一個物體。當然,我們也可以在編輯器中按Delete鍵來刪除這些物體,但是這樣的話就不是由我們的腳本刪除的,而是系統刪除的。在編輯器中測試一下。
Step 14: 撤銷物體的實例化
在這一步中,我們會使用Undo
類,這個類會讓我們撤銷我們的編輯器腳本所作出的每一個動作。讓我們從撤銷對象創建開始。
為了能夠銷毀我們在編輯器中所創建的對象,我們需要調用Undo.RegisterCreatedObjectUndo()
函數。它接收2個參數:第一個參數是一個已經創建的對象,第二個參數是撤銷動作的名稱。這個撤銷動作的名稱總是顯示在Edit->Undo name
下面。
if (prefab) { obj = (GameObject)EditorUtility.InstantiatePrefab(prefab); Vector3 aligned = new Vector3(Mathf.Floor(mousePos.x/grid.width)*grid.width + grid.width/2.0f, Mathf.Floor(mousePos.y/grid.height)*grid.height + grid.height/2.0f, 0.0f); obj.transform.position = aligned; Undo.RegisterCreatedObjectUndo(obj, "Create " + obj.name); }
如果你使用a鍵創建了一些立方體,然后嘗試撤銷它們,那么現在你會注意到所有被創建的立方體都會被刪除。這是由於所有這些被創建的立方體都進入到同一個撤銷事件中。
Step 15: 撤銷單個物體的實例化
如果我們想把每一個被創建的物體放置在不同的撤銷事件中,使我們能夠逐個地撤銷創建過程,我們需要使用Undo.IncrementCurrentEventIndex()
.
if (prefab) { Undo.IncrementCurrentEventIndex(); obj = (GameObject)EditorUtility.InstantiatePrefab(prefab); Vector3 aligned = new Vector3(Mathf.Floor(mousePos.x/grid.width)*grid.width + grid.width/2.0f, Mathf.Floor(mousePos.y/grid.height)*grid.height + grid.height/2.0f, 0.0f); obj.transform.position = aligned; Undo.RegisterCreatedObjectUndo(obj, "Create " + obj.name); }
現在如果你測試這個腳本,你會看到通過撤銷對他們的創建,立方體會被逐個地刪除。
Step 16: 撤銷物體的刪除
要撤銷對物體的刪除動作,我們需要使用Undo.RegisterSceneUndo()
函數。它是一個非常慢的函數,因為它會保存場景的所有狀態,以便我們隨后可以通過執行撤銷動作來恢復到那個狀態。不幸的是,它似乎是當前我們能夠讓被刪除的對象回到場景中的唯一辦法了。
else if (e.isKey && e.character == 'd') { Undo.IncrementCurrentEventIndex(); Undo.RegisterSceneUndo("Delete Selected Objects"); foreach (GameObject obj in Selection.gameObjects) DestroyImmediate(obj); }
Undo.RegisterSceneUndo()
函數只接收一個參數,那就是撤銷動作的名稱。通過'd
'鍵刪除一些立方體之后,你可以撤銷那些刪除操作。
Step 17: 創建一個編輯器窗口腳本
創建一個新的腳本,並且讓它繼承EditorWindow
而不是Editor
。我們把它命名為GridWindow.cs
using UnityEngine; using UnityEditor; using System.Collections; public class GridWindow : EditorWindow { public void Init() { } }
讓我們創建一個對我們的Grid對象的引用,以便我們可以在窗口中訪問它。
public class GridWindow : EditorWindow { Grid grid; public void Init() { grid = (Grid)FindObjectOfType(typeof(Grid)); } }
現在,我們需要創建這個窗口了,我們可以在我們的GridEditor腳本中來創建。
Step 18: 創建GridWindow
在我們的OnInspectorGUI()
函數中添加一個按鈕,用來創建GridWindow
.
public override void OnInspectorGUI() { GUILayout.BeginHorizontal(); GUILayout.Label("Grid Width"); grid.width = EditorGUILayout.FloatField(grid.width, GUILayout.Width(50)); GUILayout.EndHorizontal(); GUILayout.BeginHorizontal(); GUILayout.Label("Grid Height"); grid.height = EditorGUILayout.FloatField(grid.height, GUILayout.Width(50)); GUILayout.EndHorizontal(); if (GUILayout.Button("Open Grid Window", GUILayout.Width(255))) { GridWindow window = (GridWindow) EditorWindow.GetWindow(typeof(GridWindow)); window.Init(); } SceneView.RepaintAll(); }
我們使用GUILayout
來創建一個按鈕,同時設置按鈕的名稱和寬度。當按鈕被按下時,GUILayout.Button()
返回true
,在這種情況下,我們打開我們的GridWindow
.
你可以返回到編輯器中,按下我們的Grid
對象的inspector面板中的按鈕。
Step 19: 在GridWindow中創建一個顏色字段(Color Field)
在我們編輯我們的窗口之前,讓我們添加一個顏色字段到我們的Grid
類中,以便我們可以在后面編輯它。
public class Grid : MonoBehaviour { public float width = 32.0f; public float height = 32.0f; public Color color = Color.white;
現在,在OnDrawGizmos()
函數中為Gizmos.color
賦值。
void OnDrawGizmos() { Vector3 pos = Camera.current.transform.position; Gizmos.color = color;
現在,讓我們回到GridWindow腳本中來,在窗口中創建一個顏色字段以便我們可以在窗口中拾取顏色。我們可以在OnGUI()
回調函數中做這件事。
public class GridWindow : EditorWindow { Grid grid; public void Init() { grid = (Grid)FindObjectOfType(typeof(Grid)); } void OnGUI() { grid.color = EditorGUILayout.ColorField(grid.color, GUILayout.Width(200)); } }
Step 20: 添加一個委托(Delegate)
現在,我們是使用符號=
來設置一個委托,這個委托用來從場景視圖中獲取輸入事件。這種做法並不是一個好的方法,因為它會覆蓋掉所有其他的回調函數。我們應該使用+=
符號來代替=
。讓我們回到我們的GridWindow.cs腳本中,改變這種做法。
public void OnEnable() { grid = (Grid)target; SceneView.onSceneGUIDelegate += GridUpdate; }
我們也需要創建一個OnDisable()
回調函數用來刪除我們的GridUpdate()
,如果我們不這樣做的話,它就會進行疊加,然后在一次事件中被調用多次。
public void OnEnable() { grid = (Grid)target; SceneView.onSceneGUIDelegate += GridUpdate; } public void OnDisable() { SceneView.onSceneGUIDelegate -= GridUpdate; }
結論:
這就是編輯器腳本的介紹。如果你想擴充你的知識,在Unity腳本手冊中有很多可閱讀的話題——根據你的需要你可能想要查看Resources
, AssetDatabase
或者FileUtil
類來了解更多內容。
不幸的是,一些類還沒有被寫進文檔。由於這個原因,很容易導致不能工作。例如,SceneView類和它的函數或者是Undo
類中的Undo.IncrementCurrentEventIndex()
函數。如果文檔中沒有提供你要找的答案,你可能需要在UnityAnswers或者Unity Forum中搜索一下了。
感謝您花時間閱讀這篇文章!
寫在最后的話
點擊這里,查看英文原版。
點擊這里,查看中文翻譯版。
該文章來源於Envato網站上的Tuts+,版權屬原作者所有!如果轉載,請保留到原文的鏈接!謝謝!