Tutoriel 12 - Projection en perspective

Par OGLdev, traduit par DragonJoker

Introduction

Dans ce tutoriel, vous allez découvrir comment projeter vos objets 3D sur une fenêtre 2D.

Contexte

Nous avons enfin atteint l'élément qui représente le mieux les graphismes 3D : la projection du monde 3D au monde 2D tout en maintenant l'impression de profondeur. Un bon exemple est l'image d'une route ou d'un rail de chemin de fer qui converge vers un point distant à l'horizon.

Nous allons générer la transformation qui satisfait les contraintes ci-dessus et une contrainte supplémentaire : nous allons rendre la vie plus facile au clipper en représentant les coordonnées projetées dans l'espace normalisé de -1 à +1. Cela signifie que le clipper peut faire son travail sans avoir connaissance ni des dimensions de l'écran, ni de la position des plans Z (plan Z proche et plan Z lointain).

La transformation de projection en perspective va nous imposer de fournir quatre paramètres :

  1. Le ratio d'aspect : le ratio entre la largeur et la hauteur de la zone rectangulaire qui sera la cible de la projection ;
  2. Le champ vertical : l'angle vertical de la caméra au travers duquel nous regardons le monde ;
  3. La position du plan Z proche qui nous permet de découper les objets trop proches de la caméra ;
  4. La position du plan Z lointain qui nous permet de découper les objets trop loin de la caméra.

Le ratio d'aspect est requis, car nous allons représenter toutes les coordonnées dans un espace normalisé dans lequel la hauteur est égale à la largeur. Comme c'est rarement le cas avec un écran où la largeur est généralement plus grande que la hauteur, il faut les représenter dans la transformation en « condensant » les points sur la ligne horizontale par rapport à la ligne verticale. Cela nous permettra d'intégrer plus de coordonnées en termes de composante X dans l'espace normalisé, ce qui satisfera la contrainte de « voir » plus en largeur qu'en hauteur sur l'image finale.

Le champ vertical nous permet de zoomer en avant et en arrière sur le monde. Considérons l'exemple suivant. Dans l'image de gauche, l'angle est plus grand, ce qui rend les objets plus petits alors que sur l'image de droite, l'angle est plus petit et fait paraître le même objet plus grand. Notez que cela a un effet sur la position de la caméra, ce qui est un peu contre intuitif. Sur l'image de droite (où nous zoomons avec un plus petit champ), la caméra doit être placée plus loin, et sur l'image de gauche, elle est plus proche du plan de projection. Cependant, il faut savoir que cela n'a pas d'effet réel, car les coordonnées projetées sont mappées sur l'écran et la position de la caméra n'a aucun effet dans ce calcul.

Nous commençons par déterminer la distance du plan de projection par rapport à la caméra. Le plan de projection est parallèle au plan XY. Évidemment, le plan entier n'est pas visible, car il est trop grand. Nous voyons uniquement les objets dans une zone rectangulaire (appelée fenêtre de projection) qui a les mêmes proportions que notre écran. Le ratio d'aspect est calculé comme suit :

\begin{equation*} ar = {largeur\ écran \over hauteur\ écran} \end{equation*}

Pour plus de simplicité, nous définissons la hauteur de la fenêtre de projection à 2, ce qui signifie que la largeur vaut exactement deux fois le ratio d'aspect (selon l'équation ci-dessus). Si nous plaçons la caméra à l'origine et regardons la zone de derrière la caméra, nous voyons ceci :

Tout ce qui se trouve au-dehors de ce rectangle sera découpé et nous voyons déjà que les points à l'intérieur de ce rectangle auront leurs coordonnées dans l'intervalle voulu. La composante X est pour l'instant un peu plus grande, mais nous allons fournir une correction plus tard.

Maintenant, jetons un œil là-dessus, vu de côté (en regardant le plan YZ) :


\begin{equation*} tan( {\alpha \over 2} ) = {1 \over d} \implies d = {1 \over tan( {\alpha \over 2} )} \end{equation*}

Nous calculons la distance entre la caméra et le plan de projection en utilisant le champ vertical (représenté par l'angle alpha) :

L'étape suivante est de calculer les coordonnées projetées pour X et Y. Considérons l'image suivante (là aussi en regardant le plan YZ) :

Nous avons un point dans le monde 3D avec les coordonnées (x, y, z). Nous voulons trouver (xp, yp) qui représente les coordonnées projetées sur le plan de projection. Comme la composante X n'est pas dans la portée de ce diagramme (il pointe hors de la page), nous allons commencer par Y. À partir du théorème de Thalès, nous pouvons déterminer l'équation suivante :

\begin{equation*} {Y_p \over d} = {y \over z} \implies y_p = {{y \times d} \over z} = {y \over {z \times tan( {\alpha \over 2} )}} \end{equation*}

De la même manière, pour la composante X :

\begin{equation*} {x_p \over d} = {x \over z} \implies x_p = {{x \times d} \over z} = {x \over {z \times tan( {\alpha \over 2} )}} \end{equation*}

Comme les dimensions de notre fenêtre de projection sont 2 * ar (largeur) par 2 (hauteur), nous savons qu'un point du monde 3D dans la fenêtre est projeté sur un point dont la composante X projetée est entre -ar et +ar et la composante Y projetée entre -1 et +1. Donc, la composante Y est normalisée, mais pas la composante X. Nous pouvons normaliser la composante Xp en la divisant par le ratio d'aspect. Cela signifie qu'un point dont la composante X projetée valait +ar vaut maintenant +1, ce qui la place tout à droite de la boîte normalisée. Si sa composante X projetée valait +0,5 et le ratio valait 1,333 (ce que l'on obtient lorsque nous avons une résolution de 1024 x 768), la nouvelle coordonnée X projetée vaut 0,375. En résumé, la division par le ratio d'aspect a pour effet de condenser les points sur l'axe X.

Nous avons atteint les équations suivantes pour la projection des composantes X et Y.

\begin{equation*} x_p = {x \over {ar \times z \times tan( {\alpha \over 2} )}}\\ y_p = {y \over {z \times tan( {\alpha \over 2} )}} \end{equation*}

Avant d'aller plus loin, essayons de voir à quoi ressemble la matrice de projection à ce stade. Cela signifie que nous allons représenter les équations ci-dessus en utilisant une matrice. Nous rencontrons maintenant un problème. Dans les deux équations, nous avons besoin de diviser X, Y et Z qui font partie du vecteur position. Cependant, la valeur de Z change d'un sommet à l'autre, donc nous ne pouvons pas le placer dans une seule matrice afin de projeter tous les sommets. Pour mieux comprendre cela, prenons le premier vecteur ligne de la matrice (a, b, c, d). Nous avons besoin de choisir les valeurs de ce vecteur de manière à ce que l'équation suivante se vérifie :

\begin{equation*} a \times x + b \times y + c \times z + d \times w = {x \over {z \times tan( {\alpha \over 2} )}} \end{equation*}

C'est le produit scalaire entre le premier vecteur ligne de la matrice avec le vecteur position qui calcule la composante X finale. Nous pouvons définir « b » et « d », mais nous ne pouvons pas trouver de « a » ou de « c » qui puisse correspondre. La solution adoptée par OpenGL est de séparer la transformation en deux parties : la multiplication par la matrice de projection suivie de la division par Z en étape indépendante. La matrice fournie par l'application et le shader doivent inclure la multiplication de la position par Z. La division par la composante Z est effectuée en dur au niveau du GPU et prend effet dans le rasterizer (quelque part entre le vertex shader et le fragment shader). Comment le GPU connaît-il la sortie du vertex shader qu'il doit diviser par la composante Z ? Très simplement grâce à la variable gl_Position qui est là pour cela. Maintenant, nous devons juste trouver une matrice qui représente les équations de projection de X et Y ci-dessus.

Après la multiplication par cette matrice, le GPU peut diviser par Z automatiquement pour nous et nous récupérons le résultat que nous voulons. Mais il y a une autre complexité : si nous multiplions la matrice par la position du sommet, puis le divisons par Z, nous perdons complètement la valeur Z, car elle devient égale à 1 pour tous les sommets. La valeur Z originale doit être sauvegardée afin de pouvoir effectuer le test de profondeur par la suite. L'astuce est de copier la valeur Z originale dans la composante W du vecteur résultat, et de diviser seulement X, Y et Z par W au lieu de Z. W garde alors la valeur originale de Z qui peut être utilisée pour le test de profondeur. L'étape automatique de division de gl_Position par sa composante W est appelée « perspective divide ».

\begin{equation*} \left(\begin{matrix} { 1 \over {ar \times tan( {\alpha \over 2} )}} & 0 & 0 & 0\\ 0 & { 1 \over tan( {\alpha \over 2} )} & 0 & 0\\ 0 & 0 & 0 & 0\\ 0 & 0 & 1 & 0\\ \end{matrix}\right) \end{equation*}

Nous pouvons maintenant générer une matrice intermédiaire qui représente nos deux équations ainsi que la copie de Z dans la composante W :

Comme je l'ai dit plus tôt, nous voulons aussi inclure la normalisation de la valeur Z, car c'est plus facile pour le clipper de travailler sans connaître les plans Z proche et lointain. Cependant, la matrice ci-dessus met Z à zéro. Sachant qu'après la transformation du vecteur, le système va automatiquement faire l'étape de « perspective divide », nous avons besoin de définir les valeurs de la troisième ligne de la matrice afin que la division par Z dans l'intervalle de vue (c'est-à-dire : NearZ <= Z <= FarZ) mappe ces valeurs dans l'intervalle [-1, 1]. Cette opération de mappage est décomposée en deux parties. Tout d'abord, nous réduisons l'intervalle [NearZ, FarZ] à un intervalle de largeur 2. Puis, nous déplaçons cet intervalle de manière à ce qu'il débute à -1. La mise à l'échelle de la valeur Z, puis son déplacement sont représentés par la fonction générale :

\begin{equation*} f(z) = A \times z + B \end{equation*}

Mais après l'étape de « perspective divide », la partie droite de la fonction devient :

\begin{equation*} A + {B \over z} \end{equation*}

Maintenant, nous devons trouver les valeurs de A et de B qui vont effectuer le mappage vers [-1, 1]. Nous savons que si Z vaut NearZ, le résultat doit valoir -1, et si Z vaut FarZ, le résultat doit valoir 1. Donc nous pouvons écrire :

\begin{equation*} A + {B \over NearZ} = -1 \implies A = -1 - {B \over NearZ} \end{equation*} \begin{equation*} A + {B \over FarZ} = 1 \implies {B \over FarZ} - 1 - {B \over NearZ} = 1 \implies {{B \times NearZ - B \times FarZ} \over {FarZ \times NearZ}} = 2 \implies\\ {{B \times (NearZ - FarZ)} \over {FarZ \times NearZ}} = 2 \implies B _s ( NearZ - FarZ ) = 2 \times FarZ \times NearZ \implies\\ \color{red}{B = {{2 \times FarZ \times NearZ} \over {NearZ- FarZ}}}\\ \end{equation*} \begin{equation*} A = 1 - {B \over NearZ} = -1 - {{2 \times Far \times NearZ} \over {NearZ \times (NearZ - FarZ)}} \implies\\ A = -1 - {{2 \times Far} \over {NearZ - FarZ}} = {{-NearZ + FarZ - 2 \times FarZ} \over {NearZ - FarZ}} \implies\\ \color{red}{A = {{-NearZ - FarZ} \over {NearZ - FarZ}}} \end{equation*}

Maintenant, nous devons définir la troisième ligne de la matrice et le vecteur (a, b, c, d) qui satisfait l'équation suivante :

\begin{equation*} a \times X + b \times Y + c \times Z + d \times W = A \times Z + B \end{equation*}

Nous définissons tout de suite « a » et « b » à zéro, car nous ne voulons pas que X et Y aient un effet quelconque sur la transformation de Z. Donc, notre valeur A peut devenir « c » et notre valeur B peut devenir « d » (car W vaut 1).

\begin{equation*} \left(\begin{matrix} {1 \over {ar \times tan({\alpha \over 2})}} & 0 & 0 & 0 &\\ 0 & {1 \over tan({\alpha \over 2})} & 0 & 0 &\\ 0 & 0 & {{-NearZ - FarZ} \over {NearZ - FarZ}} & {{2 \times NearZ \times FarZ} \over {NearZ - FarZ}} &\\ 0 & 0 & 1 & 0 &\\ \end{matrix}\right) \end{equation*}

Ainsi, la matrice de transformation finale est :

Après la multiplication du vecteur position par la matrice projection, les coordonnées sont dites dans le « Clip Space ». Après l'étape de « perspective divide », les coordonnées sont dans le « NDC Space » (Normalized Device Coordinates).

Le chemin que nous avons suivi dans cette série de tutoriels devrait maintenant être clair. Sans faire de projection, nous pouvons simplement sortir du vertex shader des sommets dont les composantes XYZ (du vecteur position) sont dans l'intervalle [-1, 1]. Cela permet d'être certain qu'ils finissent quelque part sur l'écran. En s'assurant que W vaut toujours 1, nous nous prémunissons contre le fait que l'étape de « perspective divide » ait un effet. Après que les coordonnées soient transformées en coordonnées écran, nous aurons fini. En utilisant la matrice de projection, l'étape de « perspective divide » devient partie intégrante du processus de projection de 3D vers 2D.

Explication du code

void Pipeline::InitPerspectiveProj(Matrix4f& m) const
{
	const float ar = m_persProj.Width / m_persProj.Height;
	const float zNear = m_persProj.zNear;
	const float zFar = m_persProj.zFar;
	const float zRange = zNear - zFar;
	const float tanHalfFOV = tanf(ToRadian(m_persProj.FOV / 2.0));

	m.m[0][0] = 1.0f / (tanHalfFOV * ar); 
	m.m[0][1] = 0.0f; 
	m.m[0][2] = 0.0f; 
	m.m[0][3] = 0.0f;

	m.m[1][0] = 0.0f; 
	m.m[1][1] = 1.0f / tanHalfFOV; 
	m.m[1][2] = 0.0f; 
	m.m[1][3] = 0.0f;

	m.m[2][0] = 0.0f; 
	m.m[2][1] = 0.0f; 
	m.m[2][2] = (-zNear - zFar) / zRange; 
	m.m[2][3] = 2.0f * zFar * zNear / zRange;

	m.m[3][0] = 0.0f; 
	m.m[3][1] = 0.0f; 
	m.m[3][2] = 1.0f; 
	m.m[3][3] = 0.0f;
}

Une structure appelée m_persProj a été ajoutée à la classe Pipeline. Cette structure contient la configuration de la projection en perspective. La méthode ci-dessus génère la matrice que nous avons développée dans la section « Contexte ».

m_transformation = PersProjTrans * TranslationTrans * RotateTrans * ScaleTrans;

Nous ajoutons la matrice de projection en tant que première opérande de la multiplication qui génère la transformation complète. Souvenez-vous que comme le vecteur position est multiplié de droite à gauche, cette matrice est donc en fait la dernière. D'abord, nous mettons à l'échelle, puis nous tournons, déplaçons et enfin projetons.

p.SetPerspectiveProj(30.0f, WINDOW_WIDTH, WINDOW_HEIGHT, 1.0f, 1000.0f);

Dans la fonction de rendu, nous définissons les paramètres de la projection. Vous pouvez jouer avec ceux-ci afin de constater leur effet.

Remerciements

Merci à Etay Meiri de me permettre de traduire ses tutoriels.

Résultat :
resultat

Article d'origine