tutorial,

Modélisation de l'herbe dans un espace 3D

Modélisation de l'herbe dans un espace 3D
Prérequis
Cet article se base sur le langage C++, GLSL et l'API OpenGL. Malgré le fait que ce tutoriel tente à être le plus accessible possible, je vous recommande si vous n'avez aucune connaissance en pipeline graphique de commencer par les tutoriels de Joey de Vries sur learnopengl.com. L'environnement utilisé dans ce tutoriel pour compiler le programme est Linux mais la base de code proposée peut être compilée sous d'autres systèmes d'exploitation.

Lorsqu’on crée une scène 3D qui représente un paysage naturel, on a envie que celui-ci soit agréable à regarder (en tout cas, dans la plupart des cas ;D). C’est pour cela qu’il faut considérer tout particulièrement la verdure et notamment l’herbe !

L’une de ses particularités est qu’elle pousse en très grand nombre sur de plus ou moins grandes surfaces. Le réel challenge, lorsqu’on l’a modélise, est d’être capable de générer le moins de polygones possible à l’écran ce qui n’est pas une tâche simple. Il est donc impératif de penser très tôt à l’optimisation d’espace et de calculs pour que le résultat soit compatible avec le temps réel.

Il existe différentes approches pour réaliser une étendue d’herbes. Dans cette article nous allons voir comment modéliser des brins d’herbes à l’aide de billboards et comment l’optimiser via le LOD. Bien sûr cette méthode n’est pas parfaite et il possible de l’améliorer mais elle produit un résultat intéressant.

Pour commencer

Comme nous voulons optimiser au plus possible, nous allons considérer notre touffe d’herbes comme une particule. Généralement, les particules en informatique graphique sont traitées par le GPU qui va dessiner les polygones alors que le CPU ne va gérer que les positions à la base de la particule. Nous allons donc pour réaliser cela, nous focaliser sur l’écriture des shaders qui sont des programmes utilisés par le GPU pour afficher des objets à l’écran.

https://opengl.developpez.com/
Pipeline graphique d'OpenGL (opengl.developpez.com)

Nous allons donc générer des points pour la base de nos brins d’herbes. On peut faire une grille ou alors adapter en fonction de la surface du terrain. Dans cette article, nous allons faire une liste de points en grille et que l’on enverra ensuite au GPU. Chaque point va être traité par le vertex shader dont le résultat sera ensuite envoyé automatiquement par la carte graphique au geometry shader. C’est le geometry shader qui va avoir le rôle de vraiment créer nos brindilles. Ensuite ces formes seront envoyées par la carte graphique aux fragment shader pour que l’on puisse calculer la bonne couleur de chaque pixel. Entre chaque shader cité précédemment, il y en a d’autres mais nous ne les utiliserons pas.

Je vous propose une petite base de code qui affiche à l’écran des points (pour les positions à la base des particules) : grass-tutorial_codebase.

Si vous n’êtes pas familier avec OpenGL, je vous conseille vivement les tutorials de learnopengl pour débuter.

Dans la suite de l’article, nous modifierons essentiellement les fichiers grass.vs, grass.gs et grass.fs qui correspondent respectivement au vertex, geometry et fragment shader.

Sous Linux, après avoir décompresser le fichier, vous pouvez compiler le programme comme suit depuis le dossier /grass-tutorial_codebase:

mkdir build
cd build
cmake -DCMAKE_BUILD_TYPE=Release ..
make 
./program
Remarque
Sous windows, il faudra passer par le CMake GUI et produire une solution .sln pour visual studio par exemple. Faîtes cependant attention à l'endroit où sont stockés les shaders.

Si tout se passe bien, vous devrez avoir un résultat qui ressemble à celui-ci :

Premier résultat en lançant le programme grass-tutorial_codebase

C’est parti !

Comme expliqué plus haut, nous allons utiliser des billboards. Il s’agit d’une image 2D que l’on va placer dans notre environnement 3D en lui donnant une impression de volume. Cette technique permet de gagner en efficacité en diminuant le nombre de polygones affichés à l’écran et on sera donc plus en mesure de traiter une grande quantité d’herbes à la fois. De plus, cette image représente très souvent une touffe d’herbes et non simplement une brindille. Là encore, on va gagner en nombre de polygones contrairement en représentant chaque brindille une par une. Malheureusement, cette méthode a le défaut de ne pas être très réaliste et pour pallier à ce problème, nous allons devoir “tricher” pour rendre le rendu plus naturel.

Étape 1. Création d’un quad simple

Commençons par créer un rectangle. Les positions de la base des particules sont crées et envoyées en tant que points à la carte graphique grâce à la commande GL_POINTS d’OpenGL dans le fichier main.cpp. Nous allons ainsi créer un rectangle par position.

Vertex shader

Dans le vertex shader grass.vs, nous ferons attention de ne pas multiplier la position des points par les matrices de view et de projection car on souhaite garder les positions dans le repère monde. Ainsi, lorsqu’on créera nos billboards, ils se feront dans le repère monde et ce n’est qu’après avoir créer les coordonnées du quad que l’on pourra les projeter dans le repère de l’écran.

gl_Position = vec4(aPos, 1.0); 
Geometry shader

L’idée va être la suivante : on va prendre notre position de base et ajouter des points relatifs à la base pour former un quad. Ces points sont souvent appelés des points offset.

Nous allons créer pour cela une fonction createQuad dans le grass.gs qui va prendre en argument la position de base de notre particule et donc de notre quad que l’on va créer.

Nous allons ensuite utiliser un tableau de 4 valeurs correspondants aux coordonnées des points offset relatifs à la position de base. Ces valeurs peuvent bien évidemment varier et je vous invite à tester des coordonnées différentes.

vec4 vertexPosition[4];
vertexPosition[0] = vec4(-0.2, 0.0, 0.0, 0.0); 	// down left
vertexPosition[1] = vec4( 0.2, 0.0, 0.0, 0.0);  // down right
vertexPosition[2] = vec4(-0.2, 0.2, 0.0, 0.0);	// up left
vertexPosition[3] = vec4( 0.2, 0.2, 0.0, 0.0);  // up right

Nous allons ensuite faire une boucle for pour itérer sur ce tableau. C’est à ce moment là que l’on va créer nos vertex. Il ne faut pas oublier de multiplier par les matrices de vue et de projection !

for(int i = 0; i < 4; i++) {
	gl_Position = u_projection * u_view * 
				 (base_position + vertexPosition[i]);
	EmitVertex();
}
EndPrimitive();
Remarque
N'oublier pas de changer les layout du geometry shader pour pouvoir afficher quatre points au lieu d'un seul précédemennt : layout (triangle_strip, max_vertices = 4) out;

Vous pouvez trouver tout le code en cliquant sur grass.gs. Normalement, vous verrez une étendu verte dans votre fenêtre en lançant l’application.

Fragment shader

Le fragment shader ici ne fait que donner la même couleur pour chaque rectangle que l’on créer. Bien évidemment, nous allons compléter au fur et à mesure ce shader pour qu’il soit adapté à nos besoins.

Étape 2. Application d’une texture

Bon ce n’est pas très intéressant d’avoir des rectangles verts alors ajoutons une texture sur nos quads. L’idée va être de plaquer une texture sur nos quads pour afficher notre champ d’herbe.

Nous allons devoir modifier le fichier main.cpp. Heureusement, dans la base de code donnée, vous avez accès à une bibliothèque qui s’appelle stb_image qui va nous permettre de récuper les informations de l’image.

Avant la boucle de rendu, nous allons charger notre image en utilisant la fonction loadTextureFromFile. Cette fonction, déjà définie à la fin du fichier main.cpp, nous rend directement l’indice de la texture. Il faut ensuite indiquer au shader qu’il s’agit de la première texture que l’on va utiliser.

unsigned int texture1 = 
	loadTextureFromFile("../assets/textures/grass_texture.png");
glUseProgram(shaderID);
glUniform1i(glGetUniformLocation(shaderID, "u_textgrass"), 0);

Ensuite, dans la boucle de rendu avant la partie de glDrawArrays, il faut dire à OpenGL que l’on active la première texture et que celle-ci est d’indice texture1.

// bind textures
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture1);

Il va donc falloir dans le geometry shader indiquer les coordonnées de texture sur notre quad. Tout comme on l’a fait avec les positions offset, nous allons crée un tableau de taille quatre pour indiquer les coordonnées de textures pour chaque point.

vec2 textCoords[4];
textCoords[0] = vec2(0.0, 0.0); // down left
textCoords[1] = vec2(1.0, 0.0); // down right
textCoords[2] = vec2(0.0, 1.0); // up left
textCoords[3] = vec2(1.0, 1.0); // up right

On va devoir ajouter une variable vec2 textCoord que l’on va envoyer au fragment shader via la structure GS_OUT.

Il faut également changer la boucle d’itérations pour qu’elle prenne en compte les coordonnées de texture en fonction des points que l’on crée.

for(int i = 0; i < 4; i++) {
	gl_Position = u_projection * u_view * 
				  (base_position + vertexPosition[i]);
	gs_out.textCoord = textCoords[i];
	EmitVertex();
}
EndPrimitive();

Dans le fragment shader, il faut ensuite ajouter vec2 textCoord dans la structure FS_IN. Il ne faut pas oublier d’ajouter notre nouvelle uniform correspondant à la texture que l’on utilise.

uniform sampler2D u_textgrass;

On va pouvoir maintenant récupérer le pixel qui se trouve sur la texture aux coordonnées de texture qui ont été indiqué par le geometry shader puis interpolé par le GPU.

void main(){
	vec4 color = texture(u_textgrass, fs_in.textCoord);
	if (color.a < 0.05 ) discard;
	FragColor = color;
}
Remarque
Attention de bien supprimer les zones transparentes pour que nos brins d'herbers s'affichent bien : if (color.a < 0.05 ) discard;

Si tout se passe bien, vous devriez avoir un résultat qui ressemble à celui-ci :

Étape 3. Modifications qui ajoute du réalisme

Disposition des quads en croix

On veut représenter une touffe d’herbes qui soit garnie. Ainsi pour rajouter cette effet de volume, nous allons dessiner deux autres quads que nous allons disposer en croix.

Pour cela, on peut ajouter une fonction createGrass qui appelera trois fois la fonction createQuad.

// creer une croix d'herbe
void createGrass()
{
	mat4 model0, model45, modelm45;
	model0 = mat4(1.0f);
	model45 = rotationY(radians(45));
	modelm45 = rotationY(-radians(45));

	createQuad(gl_in[0].gl_Position.xyz, model0);
	createQuad(gl_in[0].gl_Position.xyz, model45);
	createQuad(gl_in[0].gl_Position.xyz, modelm45);	
}

Il faudra penser à changer les arguments de createQuad en lui ajoutant une matrice mat4 crossmodel en paramètre de la fonction. On n’oubliera pas de multiplier la position du point avec la matrice crossmodel comme suit :

gl_Position = u_projection * u_view * (gl_in[0].gl_Position 
		+ crossmodel * vertexPosition[i]);

Dans le main, il suffit maintenant d’appeler la fonction createGrass. Si tout se passe bien, vous devriez avoir un résultat similaire à la capture d’écran ci-dessous.

Rotation aléatoire sur Y

Nos brins d’herbes sont plus en volume mais cela ne semble pas naturel. Pour améliorer le visuel et afficher un rendu plus réaliste, nous allons tourner chaque croix d’herbes en une valeur aléatoire sur l’axe Y.

Pour cela, nous aurons besoin d’opération de rotation, vous pouvez récupérer les fonctions ici.

Nous avons également besoin d’une fonction random. En GLSL, il n’existe pas vraiment de valeur aléatoire à proprement parler. Nous sommes donc obligés de “tricher” en simulant de l’aléatoire en faisant des opérations qui vont créer un nombre en fonction des valeurs maximales des types de GLSL.

const float PI = 3.141592653589793;
...
createQuad(vec3 base_position, mat4 crossmodel){
	...
	mat4 modelRandY = rotationY(random(base_position.zx)*PI);
	...
	gl_Position = u_projection * u_view * (gl_in[0].gl_Position + 
            	modelRandY*crossmodel*vertexPosition[i]);
}
Remarque
Les fonctions de rotations rotationX, rotationY et rotationZ prennent en paramètre des angles en radians. C'est pour cela qu'il faut multiplier par PI pour que le résultat soit cohérent.

Vous devriez obtenir le résultat suivant :

Taille aléatoire des touffes d’herbe

Dépendamment de l’endroit où se trouve l’herbe, elle ne va pas pousser de la même façon et de la même vitesse. On pourraît utiliser une fonction de bruit pour lui donner une taille aléatoire mais pour simplifier, nous allons utiliser la fonction float random (vec2 st).

Dans le main du vertex shader, nous allons créer une variable grass_size à laquelle on va assigner la valeur :

float grass_size;
const float c_min_size = 0.4f;
...
void main() {
    ...
    grass_size = random(gl_in[0].gl_Position.xz) * (1.0f - c_min_size) 
    		+ c_min_size;
    ...
}

Ici on assigne une valeur aléatoire à la touffe d’herbes en la multipliant par un nombre aléatoire (entre 0 et 1) sur la taille maximale de la plante. Puis on lui ajoute la taille minimale que l’on souhaite.

Ensuite dans la fonction createQuad, on multiplie vertexPosition[i] avec grass_size.

Maintenant, vous devriez obtenir un meilleur résultat !

Travail sur les couleurs

Tout comme nous avons changer la taille de l’herbe de façon aléatoire, nous pouvons créer une variation des couleurs. Pour cela, nous allons utiliser la fonction fbm correspondant au Fractal Brownian Motion qui correspond à un bruit ayant des amplitudes variables.

Nous allons transférer un float au fragment shader grâce à la structure gs_out en lui assignant sa valeur dans createGrass :

gs_out.colorVariation = fbm(gl_in[0].gl_Position.xz);

Puis dans le fragment shader, on applique la valeur de la variation en mixant la couleur de la texture avec sa version plus sombre avant de passer la couleur dans FragColor.

color.xyz = mix(color.xyz, 0.5*color.xyz, fs_in.colorVariation);

Niveau de détail (Level Of Detail - LOD)

Les objets affichés loin de la caméra n’ont pas besoin d’être aussi détaillés que ceux affichés proche. On peut donc économiser en nombre de polygones à l’écran de façon dynamique par rapport à la distance avec la caméra.

L’idée va être de garder un maximum de précision pour les touffes d’herbes qui sont proches. On va donc les dessiner avec trois quads et plus elles seront loin, plus on lui enlèvera des quads.

Si cela est bien fait, on ne devrait pas remarquer de différence par rapport au résultat précédent.

Comme on peut le voir sur la photographie, pour réaliser au mieux le LOD il faudra penser à créer des zones de transitions entre les différents niveaux de LOD.

A gauche une photo d'une étendue d'herbes, au centre le shéma du LOD, à droite le LOD appliqué à la photo

Pour faire cela, nous allons devoir modifier la fonction createGrass pour que celle-ci prenne en paramètre le nombre de quads que l’on souhaite dessiner.

switch(numberQuads) {
	case 1:	{ // dernier niveau de LOD (le plus loin)
		createQuad(gl_in[0].gl_Position.xyz, model0);
		break;
	}
	case 2:	{ // niveau intermediaire du LOD
		createQuad(gl_in[0].gl_Position.xyz, model45);
		createQuad(gl_in[0].gl_Position.xyz, modelm45);
		break;
	}
	case 3:	{ // niveau du LOD le plus proche
		createQuad(gl_in[0].gl_Position.xyz, model0);
		createQuad(gl_in[0].gl_Position.xyz, model45);
		createQuad(gl_in[0].gl_Position.xyz, modelm45);
		break;
	}
}

Donc dans le main du geometry shader, il faut maintenant prendre en compte le LOD, pour cela nous allons devoir définir les seuils du LOD. C’est-à-dire que nous devrons dire à partir de quelle distance, nous allons passer d’un niveau de LOD vers un autre.

const float LOD1 = 5.0f;
const float LOD2 = 10.0f;
const float LOD3 = 20.0f;
...
void main(){
	vec3 distance_with_camera = gl_in[0].gl_Position.xyz 
					- u_cameraPosition;
	// distance de la position avec la camera
	float dist_length = length(distance_with_camera); 
	// taille aléatoire de la touffe d'herbe
	grass_size = random(gl_in[0].gl_Position.xz) * (1.0f - c_min_size) 
			+ c_min_size; 	
	// on crée un quad dépendamment de la distance avec la camera
	if (dist_length < LOD1) { createGrass(3); }
	if (dist_length >= LOD1 && dist_length < LOD2){ createGrass(2); }
	if (dist_length >= LOD2 && dist_length < LOD3){ createGrass(1); }
}

Mais cette méthode naïve ne donne pas un bon résultat. En effet, on peut voir clairement une transition entre chaque niveau de LOD. Et avouons-le, ça pique les yeux ! :D

Nous allons donc penser à une autre façon de faire. Nous allons devoir gérer une zone de transition et permettre ainsi à certaines touffes d’herbes d’être situées à un niveau de LOD où elle n’aurait initialement pas dû être.

// distance de la position avec la caméra avec transition
float t = 6.0f; if (dist_length > LOD2) t *= 1.5f;
dist_length += (random(gl_in[0].gl_Position.xz)*t - t/2.0f);

// on change le nombre en fonction de la distance
int lessDetails = 3;
if (dist_length > LOD1) lessDetails = 2;
if (dist_length > LOD2) lessDetails = 1;
if (dist_length > LOD3) lessDetails = 0;

// on crée l'herbe
if (lessDetails != 1
  || (lessDetails == 1 && (int(gl_in[0].gl_Position.x * 10) % 1) == 0 
  || (int(gl_in[0].gl_Position.z * 10) % 1) == 0)
  || (lessDetails == 2 && (int(gl_in[0].gl_Position.x * 5) % 1) == 0 
  || (int(gl_in[0].gl_Position.z * 5) % 1) == 0)
)
{ createGrass(lessDetails); }

Maintenant si la caméra bouge, le changement de niveaux de LOD ne se voit plus beaucoup. Bien évidemment, on peut améliorer cette effet en ajoutant plus de conditions pour les passages entre niveaux.

L’animation du vent

L’herbe est bien bien joli avec son LOD mais elle reste statique. On va ajouter l’animation du vent. On pourrait utiliser une formule mathématique pour modéliser son déplacement mais il peut être plus simple d’utiliser une texture de flux. Cette texture se différencie par ses valeurs. En effet dans notre texture de flux, la valeur du rouge de l’image représente la direction en x, la valeur verte de l’image correspond à la direction y et parfois, dans certaines de ces textures, il y a une valeur de bleue qui encode la force du vent.

Nous allons prendre une texture qui possède des valeurs rouges et vertes (donc celle de la direction du vent). Comme notre image va se répéter telle une mozaïque sur notre terrain, nous pouvons utiliser les coordonnées des quads comme coordonnées de texture.

A gauche une texture de mouvement, à droite le quadrillage des coordonnées de texture

En calculant la matrice qui permettra de faire incliner notre quad dans la fonction createQuad après la déclaration du tableau de texture, on peut calculer les inclinaisons de la sorte :

vec2 windDirection = vec2(1.0, 1.0); // direction du vent
float windStrength = 0.15f;	// force du vent
// coordonnées de textures du vent qui se déplace
vec2 uv = base_position.xz/10.0 + windDirection * windStrength * u_time ;
uv.x = mod(uv.x,1.0); // on ramère la coordonnée modulo 1
uv.y = mod(uv.y,1.0);

vec4 wind = texture(u_wind, uv); // on récupère la valeur rgba
// on calcule la matrice qui permet d'incliner le quad en fonction de la 
// direction et force du vent
mat4 modelWind =  (rotationX(wind.x*PI*0.75f - PI*0.25f) * 
		  	rotationZ(wind.y*PI*0.75f - PI*0.25f)); 

Ensuite, dans la boucle d’itération, nous allons appliquer cette matrice modèle du vent aux coins supérieurs de nos quads. Ainsi, la base de l’herbe ne bougera pas et cela donnera une sensation naturelle du vent.

mat4 modelWindApply = mat4(1);
// pour chaque coin du quad
for(int i = 0; i < 4; i++) {
	// pour appliquer le vent seulement sur les coins du dessus
	if (i == 2 ) modelWindApply = modelWind;
	// calcul de la position finale
	gl_Position = u_projection * u_view * 
        (gl_in[0].gl_Position + modelWindApply*modelRandY*crossmodel*
        			(vertexPosition[i]*grass_size));

	gs_out.textCoord = textCoords[i];
	EmitVertex();
}
EndPrimitive();

Le code source est disponible sur grass-tutorial_finalcode.

Améliorations possibles

Il est possible d’améliorer le rendu en ajoutant une couche d’herbes dessinée brindille par brindille dans le geometry shader pour le premier niveau de LOD. Dans le second niveau, on pourrait mettre des billboards comme dans ce tutoriel. Puis pour le dernier niveau, on ne plaquerait qu’une texture sur le terrain. Cette méthode a été proposé lors de la soutenance de thèse de Kévin Boulanger et présentée au Siggraph 2006.

Amélioration possible (Kévin Boulanger)

Aussi on pourrait avoir envie d’ajouter d’autres textures sans impacter les performances du rendu. Pour cela, il serait plus simple de mettre en place un atlas de textures.

Un exemple d'atlas de textures


Svetlana
Écrit par Svetlana
Salut ! Je suis passionnée par l'informatique graphique! J'aime particulièrement tout ce qui est attrait au rendu 3D temps réel et j'espère que vous trouverez ces articles intéressants :D