Introduction

Pour "dessiner" des objets en 3D sur une surface 2D (votre écran), il est nécéssaire d'appliquer une projection. En réalité OpenGL a très peu de connaissances sur votre 3D: l'algorithme de rasterization est appliqué sur les coordonnées projetées avec l'ajout de la profondeur simplement pour décider si un fragment vient masquer un fragment déjà présent dans le framebuffer. Nous allons donc commencer par une partie un peu théorique pour détailler chaque espace de coordonnée.

Coordonnées homogènes

Bien que l'on travaille en 3D, on utilisera le plus souvent des vecteur 4D pour représenter nos points / vecteurs et des matrices 4x4 pour représenter nos transformations.

Si l'on ne fait pas ça, on ne peut pas 1) représenter la translation avec une matrice et 2) représenter la projection avec une matrice. Or il est interessant de pouvoir faire cela afin de combiner toutes les transformations simplement en multipliant les matrices.

Notre application enverra des vecteurs 3D au GPU et dans les shaders nous les transformerons en vecteur 4D en respectant la règle suivante: si l'entité est un point (une position) on ajoute 1 comme quatrième coordonnées, si c'est un vecteur (une normale par exemple) on ajoute 0 comme quatrième coordonnées.

Position $(x, y, z)$ -> $(x, y, z, 1)$
Vecteur $(x, y, z)$ -> $(x, y, z, 0)$

De cette manière une position est correctement translaté par une matrice de translation et un vecteur ne bouge pas (ce qui est cohérent car un vecteur n'a pas de position).

Soit la matrice de translation 4x4 $T_{tx,ty,tz} = \begin{pmatrix} 1&0&0&tx \\ 0&1&0&ty \\ 0&0&1&tz \\ 0&0&0&1 \end{pmatrix}$. Vérifiez sur papier qu'en multipliant $T_{tx,ty,tz}$ par $P_1 = (x, y, z, 1)$ on obtient bien $P_2 = (x + tx, y + ty, z + tz, 1)$ et qu'en multipliant $T_{tx,ty,tz}$ par $V_1 = (x, y, z, 0)$ on obtient bien $V_2 = (x, y, z, 0)$. Attention à l'ordre de multiplication: $P_2 = T_{tx,ty,tz}P_1$ (toujours l'entité à transformer à droite).

Vue globale des transformations

Ce schéma donne une vision globale de la suite de transformations appliquées sur un vertex. La partie en jaune doit être implantée dans votre vertex shader. Il est très facile de transformer le sommet d'entrée: il suffit d'appliquer une ou plusieurs multiplication matricielle pour calculer la variable de sortie gl_Position.

Model Coordinates

Les Model Coordinates (MC) sont les coordonnées des sommets dans l'espace local à l'objet courant. Ce sont celles qui sont fournies par l'application en les stockant dans des VBOs. Par exemple dans les TP précédents les MC de notre triangle étaient: $P_1=(-1,-1)$, $P_2=(1, -1)$ et $P_3=(0, 1)$.

Les fichiers générés par les logiciels de modélisation 3D contiennent les MC des objets.

World Coordinates

Les World Coordinates (WC) sont les coordonnées des sommets dans l'espace du monde. Chaque objet possède une Model Matrix $M$ permettant d'obtenir les WC de ses sommets à partir des MC. Si on note $P_m$ les MC d'un sommet et $P_w$ ses WC, on a la relation suivante:

$P_w = M \times P_m$

Chaque colonne de la Model Matrix d'un objet est un vecteur du repère de ce dernier exprimé le monde. La 4ème colonne est l'origine du repère. Voici un schéma (coordonnées en 2D pour la lisibilité) pour mieux comprendre:

View Coordinates

Les View Coordinates (VC) sont les coordonnées des sommets dans l'espace de la caméra. On les obtient en multipliant les WC par la View Matrix $V$:

$P_v = V \times P_w = V \times M \times P_m$

La matrice $MV = V \times M$ est appelée la ModelView Matrix. Attention: on la nomme dans l'ordre inverse de la multiplication.

Les colonnes de la matrice View sont les vecteurs du repère du monde exprimé dans le repère de la caméra (la 4eme colonne est l'origine du monde exprimé dans le repère de la caméra). On peut voir la View Matrix comme une Model Matrix pour le monde complet (vu comme un unique objet).

Clip Coordinates

Les Clip Coordinates (CC) sont les coordonnées qu'il faut placer dans la variable gl_Position du fragment shader. Le GPU prend ensuite le relai. On les obtient en multipliant les VC par la Projection Matrix $P$:

$P_{clip} = P \times P_w = P \times V \times M \times P_m$

La matrice $MVP = P \times V \times M$ est appelée la ModelViewProjection Matrix.

Les Normalized Device Coordinates (NDC)

Les NDC sont calculées par le GPU en divisant les CC par la quatrième composante. Soit $P_{clip} = (x_{clip}, y_{clip}, z_{clip}, w_{clip})$ un point exprimé en Clip Coordinates. Alors on a:

$P_{ndc} = (x_{ndc}, y_{ndc}, z_{ndc}) = (x_{clip} / w_{clip}, y_{clip} / w_{clip}, z_{clip} / w_{clip})$

On remarque qu'a partir de ce point, le GPU ne travaille plus en coordonnées homogènes.

On a de plus une propriété interessante: sur chacun des axes les NDC sont comprisent entre -1 et 1 pour les points visibles depuis la caméra. Si un fragment a ses NDC non comprisent entre -1 et 1 alors GPU ne le traitera pas.

Window Coordinates

Les Window Coordinates (WC) sont les coordonnées de fragments exprimés en pixels. OpenGL utilise les données passées en utilisant la fonction glViewport(X, Y, W, H) (qui définit la zone de la fenêtre dans laquelle dessiner) et la fonction glDepthRange(N, F) (qui définit la range de profondeur) pour obtenir les WC à partir des NDC:

$(x_{wc}, y_{wc}, z_{wc}) = (\frac{W}{2}x_{ndc} + (X + \frac{W}{2}), \frac{H}{2}y_{ndc} + (Y + \frac{H}{2}), \frac{F - N}{2}z_{ndc} + \frac{F + N}{2})$

$(x_{wc}, y_{wc})$ définit les coordonnées du pixel qui recevra la couleur du fragment et $z_{wc}$ est la profondeur qui sera écrite dans le Depth Buffer si le Depth Test est activé.

La Normal Matrix

Il existe un piège dans tout ce bel univers de transformations: la transformation des normales. Tout d'abord petit rappel: la normale d'un vertex est le vecteur unitaire perpendiculaire à la surface au point considéré. Les normales sont utilisés pour faire des calculs de lumière.

Lorsque l'on effectue des scales non-uniformes (c'est à dire dont le coefficient de scaling est différent sur chacun des axes), alors l'application brute de la transformation sur les normales d'un objet à pour effet de supprimer la relation d'orthogonalité: les normales ne sont alors plus des normales.

Afin de remédier à ce problème on utilise la NormalMatrix définit de la manière suivante:

$NormalMatrix = (MV^{-1})^{T}$

Pour rappel, $MV$ est la ModelViewMatrix. Pour obtenir la NormalMatrix on l'inverse et on prend la transposée du résultat.

Ainsi, toutes les positions seront transformées en utilisant la ModelViewMatrix (et la ModelViewProjectionMatrix pour gl_Position) et toutes les normales seront transformées en utilisant la NormalMatrix.

Un exemple de Vertex Shader pour la 3D

La plupart des calculs de transformation ont lieu dans le Vertex Shader puisque c'est lui qui calcule la variable gl_Position utilisée par OpenGL. Voici un exemple commenté de Vertex Shader simple et standard pour faire de la 3D:


#version 330 core

// Attributs de sommet
layout(location = 0) in vec3 aVertexPosition; // Position du sommet
layout(location = 1) in vec3 aVertexNormal; // Normale du sommet
layout(location = 2) in vec2 aVertexTexCoords; // Coordonnées de texture du sommet

// Matrices de transformations reçues en uniform
uniform mat4 uMVPMatrix;
uniform mat4 uMVMatrix;
uniform mat4 uNormalMatrix;

// Sorties du shader
out vec3 vPosition_vs; // Position du sommet transformé dans l'espace View
out vec3 vNormal_vs; // Normale du sommet transformé dans l'espace View
out vec2 vTexCoords; // Coordonnées de texture du sommet

void main() {
    // Passage en coordonnées homogènes
    vec4 vertexPosition = vec4(aVertexPosition, 1);
    vec4 vertexNormal = vec4(aVertexNormal, 0);

    // Calcul des valeurs de sortie
    vPosition_vs = vec3(uMVMatrix * vertexPosition);
    vNormal_vs = vec3(uNormalMatrix * vertexNormal);
    vTexCoords = aVertexTexCoords;

    // Calcul de la position projetée
    gl_Position = uMVPMatrix * vertexPosition;
}

La bibliothèque GLM

Le calcul des matrices se fera dans le code C++. On enverra ensuite à chaque tour de la boucle principale les matrices MV, MVP et NormalMatrix au shader en utilisant des variables uniformes. On pourrait s'amuser à recoder toute une bibliothèque de gestion de vecteur / matrices mais, heureusement, d'autres l'ont fait pour nous :D

Nous allons utiliser la bibliothèque glm. Celle ci a pris le partit d'utiliser les même nom de types / fonctions que le langage GLSL. Ainsi le type glm::vec3 est sémantiquement identique au type vec3 de GLSL. De cette manière vous n'aurez quasiement rien à apprendre de plus.

La documentation en ligne donne une description de chacune des fonctions. Quand le mot clef genType est utilisé, cela représente n'importe quel type numérique. Par exemple sur la page Common functions, les lignes:

template<typename genType>
genType abs(genType const &x);

indiquent qu'il existe une fonction glm::abs pour calculer la valeur absolue de n'importe quel nombre quel que soit son type numérique (int, float, double, mais également les types vecteurs comme glm::vec3).