Hello !
Aujourd’hui nous nous retrouvons pour un mini tuto sur la personnalisation de l’inspector dans Unity, grâce à la classe Editor, suivi d’un petit exemple qui va permettre d’afficher quelques infos sur les meshes (utile par exemple si vous faites de la création procédurale).
Voici ce que donne l’exemple :
La classe Editor permet donc de personnaliser l’apparence de l’inspector sur les components que l’on veut, même ceux de Unity (Transform, Rigidbody, Collider, Camera, vraiment tout !). On peut également dessiner dans la SceneView (la fenêtre scène) :

Les lignes en bleu clair représentent la normal de chaque vertex du mesh.

Les lignes en bleu foncé représentent la normal de chaque tri du mesh, interpolée à partir des normals de ses vertices.
Le script de base est le suivant, à mettre dans un dossier nommé « Editor » :
1 2 3 4 5 6 7 8 9 10 11 | using UnityEditor; using UnityEngine; [CustomEditor (typeof (MyScript))] public class MyScriptEditor : Editor { public override void OnInspectorGUI () { base.OnInspectorGUI (); } } |
Évidement il faut créer le script « MyScript.cs » pour que l’on puisse le référencer dans « MyScriptEditor.cs » avec l’opérateur typeof (MyScript), inutile de le mettre dans un dossier Editor par contre :
1 2 3 4 5 6 | using UnityEngine; public class MyScript : MonoBehaviour { public int myInt; } |
L’attribut CustomEditor permet d’indiquer à Unity la classe visée par le script, cette classe devant hériter de Component. Pour modifier l’affichage nous allons donc déclarer la fonction OnInspectorGUI (n’oubliez pas l’override !), base.OnInspectorGUI () étant l’affichage par défaut.
Si vous ajoutez MyScript à un gameObject, vous verrez ceci apparaitre dans l’inspector :
Alors que celui par défaut donne :
Mais pourquoi cette différence ?
En fait Unity appelle la fonction LookLikeControls juste avant d’afficher les CustomEditor, ce qui change le style dans lesquels ils vont être affichés. Il suffit simplement d’appeler EditorGUIUtility.LookLikeInspector au début de la fonction OnInspectorGUI pour revenir au style par défaut.
Pour ajouter du GUI c’est pareil que la fonction OnGUI en jeu mis à part qu’on a en plus la classe EditorGUI (et EditorGUILayout qui va avec) qui permettent principalement l’édition de valeurs (nombres, textes, énumérations, etc). Notez que l’on peut très bien mélanger GUI et EditorGUI (ou leur équivalent GUILayout et EditorGUILayout) :
1 2 3 4 5 6 7 8 9 10 11 | public override void OnInspectorGUI () { EditorGUIUtility.LookLikeInspector (); base.OnInspectorGUI (); if (GUILayout.Button ("I'm a button !")) Debug.Log ("clicked !"); // http://docs.unity3d.com/Documentation/ScriptReference/EditorGUILayout.HelpBox.html EditorGUILayout.HelpBox ("I'm a useless help box !", MessageType.Info); } |
Mais si on veut faire autre chose que des boutons, on a besoin de récupérer l’instance de MyScript en train d’être inspecté grâce à la propriété target de la classe Editor. Cependant target est du type UnityEngine.Object et on voudrait récupérer un objet de type MyScript… mais comme le C# est bien fait, on peut masquer la propriété Editor.target grâce au mot clé new !
Il suffit ensuite de l’assigner en OnEnable, c’est une fonction qui est appelée lorsque le component inspecté change (concrètement Unity réutilise notre Editor : la même instance est utilisé pour plusieurs GameObjects, on n’a donc pas une instance différente de notre Editor pour chaque component) :
1 2 3 4 5 6 | private new MyScript target; private void OnEnable () { target = base.target as MyScript; } |
On peut donc récupérer notre variable myInt et la modifier, ici j’utilise un IntField et un Slider mais il existe pleins de possibilités, n’hésitez pas à regarder la doc :
1 2 3 4 5 6 7 8 9 10 11 12 13 | public override void OnInspectorGUI () { EditorGUIUtility.LookLikeInspector (); base.OnInspectorGUI (); if (GUILayout.Button ("I'm a button !")) Debug.Log ("clicked !"); // modification par un field (identique à l'UI standard) : target.myInt = EditorGUILayout.IntField ("IntField", target.myInt); // modification par un slider : target.myInt = Mathf.RoundToInt (EditorGUILayout.Slider ("Slider", target.myInt, 0, 10)); } |
Ça donne ça :
Il existe également une autre technique pour éditer toutes nos variables au moyen des classes SerializedObject et SerializedProperty mais ceci fera l’objet d’un prochain article .
SceneView
Passons maintenant à la SceneView : nous allons pouvoir dessiner directement dans l’éditeur !
Tout comme OnInspectorGUI, nous devons déclarer une fonction spéciale, OnSceneGUI, et au lieu d’appeler des fonctions GUI à l’intérieur nous allons utiliser la classe Handles. Celle-ci est très simple d’utilisation du moins pour la plupart des fonctions, il suffit par exemple d’une ligne de code pour dessiner une ligne en 3d :
1 2 3 4 | public void OnSceneGUI () { Handles.DrawLine (new Vector3 (0, 0, 0), new Vector3 (0, 1, 0)); } |
Actuellement le point de référence est l’origine du monde (c’est à dire que le point (0, 0, 0) est à l’origine). Mais si on veut créer la ligne par rapport au transform de notre objet, on fait comment ? On va quand même pas transformer chaque point avec TransformPoint !
Et bien on va utiliser la propriété matrix de la classe Handles qui permet de multiplier automatiquement tout ce qu’on va afficher par une matrice de transformation. Ces matrices servent à passer d’un espace à un autre, dans notre cas de l’espace local de l’objet à l’espace global grâce à Transform.localToWorldMatrix :
1 2 3 4 5 | public void OnSceneGUI () { Handles.matrix = target.transform.localToWorldMatrix; Handles.DrawLine (new Vector3 (0, 0, 0), new Vector3 (0, target.myInt, 0)); } |
La ligne est donc bien en « enfant » de notre objet, vous pouvez même vérifier que la rotation et le scale sont bien pris en compte.
On peut également dessiner du GUI directement dans la SceneView en entourant les appels aux classes GUI/EditorGUI par Handles.BeginGUI et EndGUI (bon, seulement quand on veut du GUI exotique, mais ça peut être marrant) :
1 2 3 4 5 6 7 8 9 10 | public void OnSceneGUI () { Handles.matrix = target.transform.localToWorldMatrix; Handles.DrawLine (new Vector3 (0, 0, 0), new Vector3 (0, target.myInt, 0)); Handles.BeginGUI (); if (GUI.Button (new Rect (10, 10, 100, 20), "Hello World !")) Debug.Log ("clicked from SceneView"); Handles.EndGUI (); } |
Je vous laisse apprécier le rendu vous-même, et rien ne vous retient de faire plus qu’un seul bouton ! (sachez que gamedraw et les autres tools du genre utilisent cette technique).
MeshInspector
Passons maintenant à l’exemple, pour que vous voyez concrètement a quoi peut ressembler un custom editor avec plus d’un bouton . J’espère que les commentaires seront suffisants mais si vous ne comprenez pas une fonction ou autre, n’hésitez pas à demander !
Ce script affiche le nombre de vertices, d’uv (set 1 et 2), de normales, de tangentes et de vertex colors puis itère sur chaque submesh pour afficher son « type », c’est à dire ce dont il est constitué (quads, triangles, lignes, etc) et son nombre d’éléments et d’indices (ex: 1 triangle = 3 indices) tout ça dans la fonction DrawMeshInfo.
Ensuite en OnSceneGUI il affiche les normales, les normales des faces et les tangentes si les options correspondantes sont activées (et si elles existent bien dans le mesh of course).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 | using UnityEditor; using UnityEngine; [CustomEditor (typeof (MeshFilter))] public class MeshInspector : Editor { private bool displayNormals; private bool displayFaceNormals; private bool displayTangents; private bool _displayInfo; // permet de faire persister la valeur entre plusieurs instances ou plusieurs sessions d'Unity private bool displayInfo { get { return _displayInfo; } set { if (value != _displayInfo) EditorPrefs.SetBool ("MeshInspector_display", _displayInfo = value); } } private void OnEnable () { _displayInfo = EditorPrefs.GetBool ("MeshInspector_display", true); } public void OnSceneGUI () { Mesh mesh = (target as MeshFilter).sharedMesh; if (mesh == null) return; // permet de dessiner dans l'espace local du gameobject Handles.matrix = (target as Component).transform.localToWorldMatrix; if (displayNormals) DrawNormals (mesh.vertices, mesh.normals); if (displayFaceNormals) DrawFaceNormals (mesh.triangles, mesh.vertices, mesh.normals); if (displayTangents) DrawTangents (mesh.vertices, mesh.tangents); Handles.color = Color.white; // réassigne la couleur par défaut } public override void OnInspectorGUI () { EditorGUIUtility.LookLikeInspector (); base.OnInspectorGUI (); Mesh mesh = (target as MeshFilter).sharedMesh; --EditorGUI.indentLevel; // permet de modifier "l'indentation" des champs de l'inspector (modification uniquement visuelle) if (mesh != null && (displayInfo = EditorGUILayout.Foldout (displayInfo, "Mesh Info"))) { ++EditorGUI.indentLevel; DrawMeshInfo (mesh); } } private void DrawMeshInfo (Mesh mesh) { ++EditorGUI.indentLevel; Label ("Vertices", mesh.vertexCount); ++EditorGUI.indentLevel; ColoredLabel ("UV", mesh, mesh.uv.Length); ColoredLabel ("UV 2", mesh, mesh.uv2.Length); ColoredLabel ("Normals", mesh, mesh.normals.Length); ColoredLabel ("Tangents", mesh, mesh.tangents.Length); ColoredLabel ("Vertex Colors", mesh, mesh.colors.Length); --EditorGUI.indentLevel; Label ("Sub Meshes", mesh.subMeshCount); ++EditorGUI.indentLevel; // itère sur tous les submeshes pour afficher leur type (triangles, quads, lignes, ...) // plus le nombre d'éléments et d'indices (ex : 1 triangle correspond à 3 indices, une ligne à 2) for (int i = 0; i < mesh.subMeshCount; i++) { int length = mesh.GetIndices (i).Length; switch (mesh.GetTopology (i)) { case MeshTopology.Triangles: Label ("Triangles", length / 3, length); break; case MeshTopology.Quads: Label ("Quads", length / 4, length); break; case MeshTopology.LineStrip: Label ("Line Strip", length - 1, length); break; case MeshTopology.Lines: Label ("Lines", length / 2, length); break; case MeshTopology.Points: Label ("Points", length, length); break; } } --EditorGUI.indentLevel; EditorGUILayout.LabelField ("Display"); ++EditorGUI.indentLevel; EditorGUI.BeginChangeCheck (); EditorGUI.BeginDisabledGroup (mesh.normals.Length == 0); displayNormals = EditorGUILayout.Toggle ("Normals", displayNormals); displayFaceNormals = EditorGUILayout.Toggle ("Face Normals", displayFaceNormals); EditorGUI.EndDisabledGroup (); EditorGUI.BeginDisabledGroup (mesh.tangents.Length == 0); displayTangents = EditorGUILayout.Toggle ("Tangents", displayTangents); EditorGUI.EndDisabledGroup (); if (EditorGUI.EndChangeCheck ()) SceneView.RepaintAll (); // repeint la sceneview lors d'un changement de displayNormals, displayFaceNormals ou displayTangents } private static void Label (string label, int value) { EditorGUILayout.LabelField (label, value.ToString ()); } private static void Label (string label, int value, int value2) { EditorGUILayout.LabelField (label, value + " (" + value2 + ")"); } private static void ColoredLabel (string label, Mesh mesh, int value) { // affiche le label en rouge si value est différent du nombre de vertices Color backup = GUI.color; if (value != mesh.vertexCount) GUI.color = Color.red; EditorGUILayout.LabelField (label, value.ToString ()); GUI.color = backup; } private static void DrawNormals (Vector3[] vertices, Vector3[] normals) { // affiche les normales en cyan en prenant en compte le backface culling Handles.color = Color.cyan; for (int i = 0; i < normals.Length; i++) DrawRay (vertices[i], normals[i]); } private static void DrawFaceNormals (int[] tris, Vector3[] vertices, Vector3[] normals) { // affiche les normales des faces en bleu en prenant en compte le backface culling // on considère que la normale d'une face est la moyenne des normales de ses vertices Handles.color = Color.blue; for (int i = 0; i < tris.Length && tris[i] < normals.Length; i += 3) { Vector3 faceCenter = vertices[tris[i]] + vertices[tris[i + 1]] + vertices[tris[i + 2]]; Vector3 faceNormal = normals[tris[i]] + normals[tris[i + 1]] + normals[tris[i + 2]]; DrawRay (faceCenter / 3, faceNormal / 3); } } private static void DrawTangents (Vector3[] vertices, Vector4[] tangents) { // affiche les tangentes du mesh en rouge (avec x y z comme direction et w comme longueur) Handles.color = Color.red; for (int i = 0; i < tangents.Length; i++) DrawRay (vertices[i], ((Vector3)tangents[i]).normalized * tangents[i].w); } private static void DrawRay (Vector3 start, Vector3 dir) { // affiche un rayon dans la sceneview en prenant en compte le backface culling var cam = SceneView.currentDrawingSceneView.camera; // recupère la camera de la sceneview actuelle var tr = cam.transform; if (cam.orthographic ? Vector3.Dot (-tr.forward, Handles.matrix.MultiplyVector (dir)) > -0.01f : Vector3.Dot (tr.position - start, Handles.matrix.MultiplyVector (dir)) >= 0) Handles.DrawLine (start, start + dir); // si vous n'êtes pas habitué à l'opérateur ? : les lignes suivantes font la même chose // if (cam.orthographic && Vector3.Dot (-tr.forward, Handles.matrix.MultiplyVector (dir)) > -0.01f) // Handles.DrawLine (start, start + dir); // else if (!cam.orthographic && Vector3.Dot (tr.position - start, Handles.matrix.MultiplyVector (dir)) >= 0) // Handles.DrawLine (start, start + dir); } } |
Très bon tuto Arté, que pas mal devraient bien lire d’ailleurs

Et très bon blog au passage
Max.