Sommaire
Les shaders sont un formidable outil mis à disposition des développeurs pour personnaliser le rendu 3D. Le but de ce tutoriel n'est pas d'entrer en détail dans les techniques les plus avancées de rendu 3D mais d'illustrer l'utilisation des shaders. Nous allons présenter deux exemples simples de shaders : un modèle d'illumination de Phong et l'utilisation de textures. Ces exemples, bien que basiques au regard de la puissance des shaders, sont un bon moyen de se familiariser avec certains concepts courants en programmation 3D et avec l'utilisation des shaders. Le lecteur désirant approfondir ce domaine se reportera aux références données.
Le modèle d'illumination de Phong est un modèle empirique décomposant la lumière en trois composantes :
(source : Wikimedia Commons)
Le détail des calculs mathématiques ne sera pas donné ici. Nous allons par contre expliquer à quoi correspondent les différents vecteurs utilisés. Pour chaque vertex, trois points dans l'espace 3D sont pris en compte : la position de l'observateur et la position de la lumière, qui sont constantes pour tous les vertices, et la positon de chaque vertex. Le vecteur observateur correspond au vecteur allant du vertex à l'observateur. Le vecteur lumière correspond au vecteur allant de la lumière au vertex. Le vecteur normal correspond au vecteur partant du vertex et perpendiculaire à la surface. Le vecteur réfléchi correspond au vecteur symétrique du vecteur lumière par rapport au vecteur normal. Tous ces vecteurs doivent être normalisés avant utilisation. Le langage GLSL fournit la fonction normalize() dans ce but.
Il faut donc fournir de nombreuses informations aux shaders pour ce modèle d'illumination : les couleurs des lumières (ambiante, diffuse et spéculaire) et des matériaux, la position des lumières, la position de l'observateur et les vecteurs normaux à la surface pour chaque vertex. Pour les vecteurs normaux, nous utiliserons une Normal Map, c'est-à-dire une image pour laquelle chaque composante de la couleur (rouge, vert, bleu) correspond aux composantes du vecteur normal (x, y, z), chaque pixel correspondant à un vertex. Dans la troisième partie de ce tutoriel, sur le calcul GPGPU, nous présenterons en détail le calcul de vecteurs normaux et la génération de cette Normal Map.
Chaque coordonnée étant stockée dans une image au format RGB donc normalisé entre 0 et 255, il suffit de normaliser ces coordonnées entre -1 et 1 pour avoir les coordonnées en 3D. Le code ne présente pas de difficulté particulière et est très similaire au code de chargement des vertices.
// dans vertex_shader.gl img = QImage(":/normals.png"); m_normals.reserve(m_indices.size()); for(int z = 0; z < vertices_by_z; ++z) { for(int x = 0; x < vertices_by_x; ++x) { QVector3D normal; QRgb rgb = img.pixel(x, z); normal.setX(1.0 * qRed(rgb) / 125.0 - 1.0); normal.setY(1.0 * qGreen(rgb) / 125.0 - 1.0); normal.setZ(1.0 * qBlue(rgb) / 125.0 - 1.0); normal.normalize(); m_normals.push_back(normal); } }
La génération de la Normal Map utilisée sera présentée dans la partie GPGPU de ce tutoriel. Voici à quoi elle ressemble :
Pour simplifier, nous n'allons présenter que le code des composantes ambiante et diffuse du modèle de Phong. La composante spéculaire permet d'ajouter un effet de reflet à la surface, ce qui est surtout intéressant pour des matériaux tels que l'eau ou le métal.
Pour calculer l'ombrage, il faut fournir au shader les informations suivantes : le vecteur normal pour chaque vertex et la direction de la lumière. Dans le Vertex Shader, on détermine le coefficient d'atténuation en calculant le produit scalaire entre le vecteur normal et le vecteur correspondant à la direction de la lumière avec la fonction dot. Les valeurs négatives du produit scalaire correspondent au cas où la lumière éclaire la face postérieure d'un triangle. On normalise donc entre 0 et 1 avec la fonction max. Le résultat est envoyé au Fragment Shader via la variable color_factor :
// Dans vertex_shader.gl #version 130 in vec4 normal; out float color_factor; uniform vec4 light_direction; void main(void) { color_factor = max(dot(normal, light_direction), 0.0); }
Dans le Fragment Shader, on récupère la variable color_factor. La couleur finale est le produit de la couleur ambiante et du coefficient d'atténuation. On affecte le résultat à la variable de sortie color.
// Dans fragment_shader.gl #version 130 in float color_factor; out vec4 color; uniform vec4 ambiant_color; void main(void) { color = color_factor * ambiant_color; }
Du côté des shaders, il ne reste plus qu'à calculer la position du vertex dans le repère de la caméra. Pour cela, il suffit de calculer le produit de la matrice de projection et la position du vertex et d'affecter le résultat dans la variable build-in gl_Position.
// Dans vertex_shader.gl in vec4 vertex; uniform mat4 matrixpmv; void main(void) { ... gl_Position = matrixpmv * vertex; }
Du côté de l'application, il nous faut calculer les données et les envoyer au GPU. La méthode des Vertex Buffer Object est utilisée pour transmettre les positions des vertices et les vecteurs normaux. Les paramètres constants (direction de la lumière, la couleur ambiante, la matrice de transformation) sont envoyés comme Uniform :
m_program.setUniformValue("ambiant_color", QVector4D(0.4, 0.4, 0.4, 1.0)); m_program.setUniformValue("light_position", QVector4D(1.0, 1.0, 1.0, 1.0)); m_program.setUniformValue("matrixpmv", projection * view * model);
Voici le résultat de la heightmap obtenu avec les composantes ambiante et diffuse :
L'ajout d'une texture sur notre exemple de heightmap nécessite de charger et lier la texture au contexte OpenGL, de définir pour chaque vertex une coordonnée dans le repère de la texture puis de modifier les shaders pour récupérer cette coordonnée et lire la couleur correspondante dans la texture. Voici la texture utilisée :
Pour lier une texture à un contexte OpenGL, on utilise habituellement les fonctions OpenGL glGenTextures, glBindTexture et glTexImage2D (voir le tutoriel d'introduction à OpenGL et Qt pour le détail de la méthode). Ici, nous allons utiliser une autre méthode. En effet, Qt fournit différentes fonctions pour manipuler les textures, en particulier une fonction pour lire directement un fichier image et le charger dans un contexte OpenGL : bindTexture(). Cette fonction retourne un identifiant de type GLuint pour cette texture, qui sera utilisé lors de l'affichage.
// Dans initializeGL() m_texture_location = bindTexture(":/texture.png");
Dans la fonction de rendu, un appel à la fonction OpenGL glBindTexture permet d'activer la texture à utiliser :
// Dans paintGL() glBindTexture(GL_TEXTURE_2D, m_texture_location);
Pour appliquer une texture, OpenGL a également besoin de pouvoir faire la correspondance entre un vertex et un point sur la texture. Pour cela, il faut fournir un tableau contenant les coordonnées (x, y) pour chaque vertex. Nous utiliserons un tableau de QVector2D pour les stocker :
QVector<Qvector2D> m_textures;
Les coordonnées de texture sont normalisées entre 0 et 1, c'est-à-dire que le coin en haut à gauche de la texture correspond au point (0, 0) et le point en bas à droite correspond au point (1, 1). Dans notre exemple de heightmap, le calcul des coordonnées de texture est relativement simple : chaque pixel de l'image de la texture correspond à un vertex, on modifie donc simplement la boucle de calcul des positions des vertices :
void HeightmapWidget::initializeGL() { QVector2D coordonnees; for(int z = 0; y < vertices_by_z; ++z) { for(int x = 0; x < vertices_by_x; ++x) { // calcul de la position des vertices coordonnees.setX(1.0 * x / quads_by_x); coordonnees.setY(1.0 - 1.0 * z / quads_by_z); m_textures.push_back(coordonnees); } }
Pour passer ces coordonnées au processeur graphique, on utilise encore un Vertex Buffer Object chargé au moment de l'initialisation.
m_texturebuffer.create(); m_texturebuffer.bind(); m_texturebuffer.allocate(m_textures.constData(), sizeof(QVector2D) * m_textures.size()); m_texturebuffer.release();
Dans le Vertex Shader, on prend en entrée les coordonnées de texture puis on récupère la couleur correspondante dans la texture avec la fonction texture. La texture elle-même est passée comme paramètre de type sampler2D.
// Dans vertex_shader.gl in vec2 texture_coordonnees; out vec4 texture_color; ... void main(void) { texture_color = texture(texture2d, texture_coordonnees.st); }
Le Fragment Shader reçoit en entrée la couleur interpolée à partir des couleurs de la texture envoyées par le Vertex Shader.
// Dans fragment_shader.gl #version 130 in vec4 texture_color; out vec4 color; void main(void) { color = color_ambiant * color_texture; }
Voici le rendu généré avec la texture choisie :
En appliquant la texture et les ombres, on obtient un effet 3D intéressant :
En fait, pour calculer la couleur de la texture pour un vertex, on a deux possibilités : soit on transmet les coordonnées de la texture entre le Vertex Shader et le Fragment Shader et on calcule dans ce dernier la couleur correspondante de la texture, soit on calcule la couleur de la texture dans le Vertex Shader et on transmet la couleur entre le Vertex Shader et le Fragment Shader. La première version donne le rendu suivant (détail après agrandissement) :
La seconde version donne le rendu suivant (même détail affiché) :
On observe que le résultat n'est pas identique. Il faut bien comprendre ce qui se passe lorsque l'on transmet des données entre le Vertex Shader et le Fragment Shader pour comprendre le rendu obtenu.
Pour chaque vertex que l'on affiche, le GPU crée une instance du Vertex Shader. Dans notre exemple, cela veut dire que l'on a 955206 vertices et donc 955206 threads pour le Vertex Shader. Le Fragment Shader est instancié pour chaque pixel de la fenêtre de rendu. Par exemple, pour une fenêtre de rendu de 800 par 600, on aura donc 480000 pixels et 480000 threads pour le Fragment Shader.
On comprend alors aisément que les données ne sont pas envoyées directement entre les shaders. Elles sont en fait interpolées : la valeur d'une variable in du Fragment Shader est en fait la combinaison de la variable out correspondante, provenant de plusieurs instances différentes du Vertex Shader. Le passage d'une primitive définie par des vertex à un ensemble de pixels visibles à l'écran est effectué par le moteur de rastérisation.
En fonction du traitement que l'on souhaite faire, le rendu sera meilleur s'il est fait dans le Vertex Shader ou dans le Fragment Shader. De même, les performances obtenues seront différentes selon le shader dans lequel on fait les calculs. Il sera parfois nécessaire de faire un compromis entre qualité du rendu et performances.