Meshes procéduraux : Plane

Salut à toutes et à tous !

Dans ce premier tutoriel sur Unity je vais vous expliquer la structure utilisée par ce moteur pour représenter les meshes et vous apprendrez comment créer un plan procéduralement.
Les bases du C# et une connaissance minimale de Unity sont requis.

Structure d’un mesh

Un mesh (dans Unity) est représenté par un tableau de vertices sous forme de Vector3 (une série de points 3d). Les autres informations (uv, normales, etc) sont stockées dans d’autres tableaux mais doivent être exactement dans le même ordre (la normale n°42 correspondra au vertex n°42 par exemple). Lorsque l’on a pas besoin de ces informations additionnelles, on laisse les tableaux vides.

Les tris sont représentés par un tableau d’indices (nombres entiers), chaque index « pointant » vers un vertex. Un tri correspond donc a 3 indices.
Depuis Unity 4, on peut également utiliser d’autres topologies, c’est-à-dire des quads (la triangulation est automatique), des lignes et des points. Le principe reste inchangé : un quad correspond à 4 indices, une ligne 2 et un point 1 seul index.

Submeshes

Dans le précédent article, je vous ai expliqué que les meshes étaient rendus par le gpu au moyen de shaders, mais si on veut plusieurs shaders différents sur un même maillage, comment on fait ? Et bien on utilise les submeshes : des « sous-parties » d’un même maillage qui pourront chacun avoir un material différent (un material contient un shader mais également ses propriétés comme la couleur, les textures, etc).

Mais pourquoi avoir des submeshes alors qu’on pourrait tout simplement séparer en plusieurs meshes ? Et bien car différents submeshes utilisent les mêmes vertices/normals/uvs/… : seules les faces sont spécifiques à chaque submesh, alors qu’avec des meshes différents on devrait dupliquer les vertices/normals/uvs/… entre chaque mesh.

En pratique

Pour commencer, nous avons besoin d’un script : Project View > Create > C# Script et nommons le « Plane.cs ».

Image : creation script plane

Ouvrez le avec votre IDE préféré, vous devriez obtenir le code par défaut qui est :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using UnityEngine;
using System.Collections;
 
public class Plane : MonoBehaviour {
 
	// Use this for initialization
	void Start () {
 
	}
 
	// Update is called once per frame
	void Update () {
 
	}
}

Supprimons (presque) tout pour garder seulement l’essentiel :

1
2
3
4
5
6
using UnityEngine;
 
public class Plane : MonoBehaviour
{
 
}

Pour générer le mesh final, nous aurons besoin d’une taille (Vector2), de la résolution du plan en hauteur et en largeur (le nombre de faces dans chaque direction, 2 int) et du mesh que l’on va modifier, ce qui donne :

1
2
3
4
5
6
7
8
9
10
11
using UnityEngine;
 
public class Plane : MonoBehaviour
{
	private Mesh mesh;
 
	// par défaut la taille est de 1, idem pour la résolution
	public Vector2 size = new Vector2 (1, 1);
	public int resolutionX = 1;
	public int resolutionY = 1;
}

Il faut maintenant générer le mesh à chaque fois qu’une de ces valeurs change, pour cela nous allons utiliser une fonction spéciale de Unity, OnValidate, qui sera appelée à chaque fois qu’une variable est modifiée depuis l’inspector, plutôt pratique non ?

Mais si on veut utiliser notre script ingame (donc sans passer par l’inspector), appeler OnValidate () pour générer le mesh n’est pas très explicite, on va donc créer une nouvelle fonction (UpdateMesh, CreateMesh, ce que vous voulez) publique et on passe OnValidate en privée :

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
using UnityEngine;
 
public class Plane : MonoBehaviour
{
	private Mesh mesh;
 
	public Vector2 size = new Vector2 (1, 1);
	public int resolutionX = 1;
	public int resolutionY = 1;
 
	/// <summary>
	/// called when the script is loaded or a value is changed in the inspector
	/// </summary>
	private void OnValidate ()
	{
		UpdateMesh ();
	}
 
	/// <summary>
	/// reconstruct mesh based on size and resolution
	/// </summary>
	public void UpdateMesh ()
	{
 
	}
}

Nous devons, avant de créer le maillage, nous assurer que les données sont valides, par exemple la taille et la résolution ne doivent pas être nulles ou négatives et que la variable mesh contient bien le mesh de notre objet.

On peut également utiliser l’attribut RequireComponent pour être sûr que l’objet ayant le script a bien un MeshFilter (component qui contient le mesh de l’objet) et un MeshRenderer (component qui permet le rendu du mesh, il contient par ailleurs le(s) material(s) du mesh).

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
using UnityEngine;
 
[RequireComponent (typeof (MeshFilter), typeof (MeshRenderer))]
public class Plane : MonoBehaviour
{
	[...]
 
	/// <summary>
	/// ensure that data is valid (ex : size is positive)
	/// </summary>
	private void ValidateData ()
	{
		if (mesh == null)
		{
			// si le meshfilter ne contient pas de mesh, on en crée un
			if (gameObject.GetComponent<MeshFilter> ().sharedMesh == null)
				gameObject.GetComponent<MeshFilter> ().sharedMesh = new Mesh ();
			// on récupère le mesh du meshfilter
			mesh = gameObject.GetComponent<MeshFilter> ().sharedMesh;
			mesh.name = "Procedural Plane";
		}
 
		// la limite peut être abaissée mais il faut éviter une taille nulle car le mesh deviendra invisible
		if (size.x < 0.1f)
			size.x = 0.1f;
		if (size.y < 0.1f)
			size.y = 0.1f;
 
		resolutionX = Mathf.Clamp (resolutionX, 1, 250);
		resolutionY = Mathf.Clamp (resolutionY, 1, 250);
	}
 
	/// <summary>
	/// reconstruct mesh based on size and resolution
	/// </summary>
	public void UpdateMesh ()
	{
		ValidateData ();
	}
}

Maintenant, place à la génération du maillage !

Premièrement, il faut créer une grille de vertices qui ressemblera à cette image (dans l’exemple resolutionX et Y sont à 6) :

Image : plane, placement des vertices

Il y aura donc resolutionX + 1 vertices en largeur et resolutionY + 1 vertices en longueur (et donc autant d’uvs et de normals).

Les uvs seront disposés de la même façon, à la différence que la longueur et la largeur totate seront de 1 au lieu de size (pour que l’uv set englobe toute la texture) et les normales seront toutes orientés vers le haut car le plan crée est horizontal.

On déclare tout ça dans UpdateMesh :

1
2
3
Vector3[] vertices = new Vector3[(resolutionX + 1) * (resolutionY + 1)];
Vector2[] uv = new Vector2[vertices.Length];
Vector3[] normals = new Vector3[vertices.Length];

On itère sur ces tableaux pour assigner les positions :

1
2
3
4
5
6
7
8
9
10
11
12
13
// int i sert juste à accéder aux éléments des tableaux simplement
// on utilise l'opérateur <= et non < car la taille des tableaux est de (resolutionX + 1) * (resolutionY + 1)
for (int i = 0, y = 0; y <= resolutionY; y++)
{
	for (int x = 0; x <= resolutionX; x++)
	{
		vertices[i] = new Vector3 (x * size.x / resolutionX, 0, y * size.y / resolutionY);
		// le cast en float sert à éviter la division entière de 2 int
		uv[i] = new Vector2 ((float)x / resolutionX, (float)y / resolutionY);
		// toutes les normales pointent vers le haut
		normals[i++] = Vector3.up;
	}
}

Voici donc l’ordre des vertices que l’on vient de créer :

Image plane : vertex order

Ensuite il faut tout relier avec des quads, il y aura donc resolutionX * resolutionY quads, ce qui fait resolutionX * resolutionY * 2 tris, et comme un tri correspond à 3 indices, il y a… resolutionX * resolutionY * 6 indices !

On ajoute donc la déclaration du tableau d’indices :

1
int[] tris = new int[resolutionX * resolutionY * 6];

Les faces doivent être organisées dans le sens des aiguilles d’une montre (on est pas obligé mais c’est plus propre) car les normales peuvent être générées à partir de ces faces automatiquement, dans ce cas leur sens est déterminée par l’ordre des vertices de la face (qui varie selon les programmes, soit cw (clockwise, sens des aiguilles d’une montre) soit ccw (counter-clockwise, sens contraire des aiguilles d’une montre)).

Comme nous pouvons le voir sur cette image (et avec l’aide de la précédente) :

Image plane : quad

Le premier quad est défini comme ceci : 0 7 8 1 (0 7 8, 8 1 0 si on prend les 2 tris).

Si on itère sur toutes les faces avec x et y comme variables d’itération, on arrive à cette formule pour déterminer les indices d’un quad :

1
2
3
4
y * (resolutionX + 1) + x
(y + 1) * (resolutionX + 1) + x
(y + 1) * (resolutionX + 1) + x + 1
y * (resolutionX + 1) + x + 1

On double juste l’index n°2 et n°0 pour avoir nos 2 tris :

1
2
3
4
5
6
y * (resolutionX + 1) + x
(y + 1) * (resolutionX + 1) + x
(y + 1) * (resolutionX + 1) + x + 1
(y + 1) * (resolutionX + 1) + x + 1
y * (resolutionX + 1) + x + 1
y * (resolutionX + 1) + x

Ce qui donne :

1
2
3
4
5
6
7
8
9
10
11
12
13
for (int i = 0, y = 0; y < resolutionY; y++)
{
	for (int x = 0; x < resolutionX; x++)
	{
		tris[i    ] = y * (resolutionX + 1) + x;
		tris[i + 1] = (y + 1) * (resolutionX + 1) + x;
		tris[i + 2] = (y + 1) * (resolutionX + 1) + x + 1;
		tris[i + 3] = (y + 1) * (resolutionX + 1) + x + 1;
		tris[i + 4] = y * (resolutionX + 1) + x + 1;
		tris[i + 5] = y * (resolutionX + 1) + x;
		i += 6;
	}
}

On optimise un tout petit peu, ça fait jamais de mal :P :

1
2
3
4
5
6
7
8
9
10
11
12
13
for (int i = 0, y = 0; y < resolutionY; y++)
{
	for (int x = 0; x < resolutionX; x++)
	{
		tris[i + 5] =
		tris[i    ] = y * (resolutionX + 1) + x;
		tris[i + 1] = (y + 1) * (resolutionX + 1) + x;
		tris[i + 2] =
		tris[i + 3] = (y + 1) * (resolutionX + 1) + x + 1;
		tris[i + 4] = y * (resolutionX + 1) + x + 1;
		i += 6;
	}
}

Il nous ne reste plus qu’a assigner tous ces tableaux au mesh :

1
2
3
4
5
6
7
8
9
10
11
12
13
mesh.Clear ();
// cette ligne sert à nettoyer les données du mesh
// Unity vérifie si les indices des tris ne sont pas en dehors du tableau
// de vertices, ce qui peut facilement se produire si on en assigne de
// nouveaux alors que le mesh contient toujours les anciens tris
// (vous obtiendrez une jolie exception dans ce cas !)
mesh.vertices = vertices;
mesh.uv = uv;
mesh.normals = normals;
mesh.SetIndices (tris, MeshTopology.Triangles, 0);
// identique à mesh.triangles = tris; ou mesh.SetTriangles (tris, 0);
// 0 est l'index du submesh
mesh.RecalculateBounds ();

C’est fini !

Bon, faudrait quand même le tester ce script, non ?
Créez donc un nouvel objet (Ctrl-Shift-N), ajoutez lui le script Plane.cs, et… c’est tout rose !
Il lui manque un material : Project View > Create > Material, glissez le sur le gameObject, et…
Voilà ! Vous avez un plane. Vous vous attendiez à quoi d’autre ? :D

Bonus

Vous avez du remarquer que le plane n’est pas centré sur son origine, alors que diriez vous d’ajouter une option pour choisir de le centrer ou non ?

Hop, aussitôt dit, aussitôt fait :

1
public bool center = true; // au début du script

Il ne nous reste plus qu’à décaler les vertices de la moitié de la taille du plane :

1
2
3
4
5
// après cette ligne :
vertices[i] = new Vector3 (x * size.x / resolutionX, 0, y * size.y / resolutionY);
// rajoutez :
if (center)
	vertices[i] -= new Vector3 (size.x / 2, 0, size.y / 2);

Script final

Voici le script complet :

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
using UnityEngine;
 
[RequireComponent (typeof (MeshFilter), typeof (MeshRenderer))]
public class Plane : MonoBehaviour
{
	private Mesh mesh;
 
	public bool center = true;
	public Vector2 size = new Vector2 (1, 1);
	public int resolutionX = 1;
	public int resolutionY = 1;
 
	/// <summary>
	/// called when the script is loaded or a value is changed in the inspector
	/// </summary>
	private void OnValidate ()
	{
		UpdateMesh ();
	}
 
	/// <summary>
	/// ensure that data is valid (ex : size is positive)
	/// </summary>
	private void ValidateData ()
	{
		if (mesh == null)
		{
			if (gameObject.GetComponent<MeshFilter> ().sharedMesh == null)
				gameObject.GetComponent<MeshFilter> ().sharedMesh = new Mesh ();
			mesh = gameObject.GetComponent<MeshFilter> ().sharedMesh;
			mesh.name = "Procedural Plane";
		}
 
		if (size.x < 0.1f)
			size.x = 0.1f;
		if (size.y < 0.1f)
			size.y = 0.1f;
 
		resolutionX = Mathf.Clamp (resolutionX, 1, 250);
		resolutionY = Mathf.Clamp (resolutionY, 1, 250);
	}
 
	/// <summary>
	/// reconstruct mesh based on size and resolution
	/// </summary>
	public void UpdateMesh ()
	{
		ValidateData ();
 
		Vector3[] vertices = new Vector3[(resolutionX + 1) * (resolutionY + 1)];
		Vector2[] uv = new Vector2[vertices.Length];
		Vector3[] normals = new Vector3[vertices.Length];
		int[] tris = new int[resolutionX * resolutionY * 6];
 
		for (int i = 0, y = 0; y <= resolutionY; y++)
		{
			for (int x = 0; x <= resolutionX; x++)
			{
				vertices[i] = new Vector3 (x * size.x / resolutionX, 0, y * size.y / resolutionY);
				if (center)
					vertices[i] -= new Vector3 (size.x / 2, 0, size.y / 2);
				uv[i] = new Vector2 ((float)x / resolutionX, (float)y / resolutionY);
				normals[i++] = Vector3.up;
			}
		}
 
		for (int i = 0, y = 0; y < resolutionY; y++)
		{
			for (int x = 0; x < resolutionX; x++)
			{
				tris[i + 5] =
				tris[i    ] = y * (resolutionX + 1) + x;
				tris[i + 1] = (y + 1) * (resolutionX + 1) + x;
				tris[i + 2] =
				tris[i + 3] = (y + 1) * (resolutionX + 1) + x + 1;
				tris[i + 4] = y * (resolutionX + 1) + x + 1;
				i += 6;
			}
		}
 
		mesh.Clear ();
		mesh.vertices = vertices;
		mesh.uv = uv;
		mesh.normals = normals;
		mesh.SetIndices (tris, MeshTopology.Triangles, 0);
		mesh.RecalculateBounds ();
	}
}

That’s all Folks !

N’hésitez pas à commenter, et à bientôt pour le prochain tutoriel !

Bookmark the permalink.

2 Comments

  1. Bonjour artemisart,

    Je suis entrain de lire la totalité de tes articles, je les trouve vraiment très bien rédigé et simple mais avant tout ils sont très intéressant et on y apprend plein de chose.

    Ton approche m’aide beaucoup à comprendre la 3D en général, je trouve ca assez génial que tu sois si doué à ton âge (j’ai cru lire que tu avais 16 ans).

    Je suis aussi un passioné d’Unity mais mon point faible est les mathématiques, et la compréhension du GPU, en gros ce que tu ne cesse de développer dans tes articles ce qui est tout à mon avantage.

    Je pense qu’il serait petêtre intéressant par la suite que l’on travail ensemble.

    Je suis entrain de réaliser un MMORPG tout entier sur Unity, parcontre je ne suis pas arivvé au niveau d’un professionnel, j’ai commencé la programmation il y a tout juste un an, je te laisse y jeter un coup d’oeil et continu tes tutoriels car ils sont vraiment très intéressant.

    https://www.facebook.com/bloodofevilgame

    Enfin je suis un peu dans la même optique d’aider les gens via des tutoriels et des cours, ce que je fais beaucoup mais directement à travers facebook. Je pense que par la suite je ferai comme toi et que je me crérai un site pour que l’aspect visuel soit plus évolué.

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>