Custom Editor par l’exemple : MeshFilter

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 :

Image : inspector mesh custom

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) :

Image : inspector mesh vertex normals

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

Image : inspector mesh face normals

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 :

Image : MyScriptEditor LookLikeControls

Alors que celui par défaut donne :

Image : MyScriptEditor LookLikeInspector

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 :
Image : MyScriptEditor button, field et slider

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 :-D :

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 :-P . 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);
	}
}
Bookmark the permalink.

One Comment

  1. Très bon tuto Arté, que pas mal devraient bien lire d’ailleurs ;)
    Et très bon blog au passage :)

    Max.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Vous pouvez utiliser ces balises et attributs HTML : <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>