Introduction à l’Informatique Graphique

Généralités

Contexte

Les modèles 3D sont omniprésents dans de nombreux domaines :

Quelques exemples de ces applications sont présentés ci-dessous.

Exemples d'applications 3D : jeu vidéo (gauche), CAO (centre), imagerie médicale (droite).

La création et la manipulation de contenus 3D restent cependant des tâches complexes et coûteuses :

Interface du logiciel de modélisation 3D Blender.

Les outils automatiques basés sur l’IA générative commencent à émerger, mais restent très peu contrôlables et avec un coût environnemental important. Ils sont encore largement expérimentaux pour de vraies productions. La contrôlabilité, la qualité et l’efficience restent des défis majeurs.

Définition de l’informatique graphique

L’informatique graphique (Computer Graphics) désigne l’ensemble des sciences et techniques permettant de générer et manipuler des données visuelles :

Les applications sont nombreuses : loisirs, design, simulation de phénomènes naturels, médicaux, biologiques, etc.

L’informatique graphique se structure autour de trois grands domaines :

  1. Modélisation (Modeling) : création et représentation de formes 3D. Cela recouvre la conception de la géométrie des objets (leur forme, leur structure) ainsi que leurs attributs visuels (textures, matériaux). La modélisation peut être manuelle (artiste utilisant un logiciel comme Blender ou Maya) ou procédurale (génération algorithmique de formes).

  2. Animation : mise en mouvement des objets et personnages au cours du temps. L’animation englobe aussi bien les techniques cinématiques (déplacement explicite des objets image par image ou par interpolation de poses clés) que les techniques physiques (simulation de forces, gravité, collisions, fluides, tissus, etc. qui génèrent le mouvement automatiquement à partir de lois physiques).

  3. Synthèse d’images / Rendu (Rendering) : génération d’images 2D à partir d’une scène 3D. Le rendu prend en entrée la description de la géométrie, des matériaux, des lumières et de la caméra, puis calcule l’image résultante. On distingue le rendu temps réel (interactif, typiquement \(\geq\) 25-60 images par seconde, utilisé dans les jeux vidéos et la visualisation) et le rendu hors-ligne (photo-réaliste, pouvant prendre de quelques secondes à plusieurs heures par image, utilisé au cinéma et en architecture).

Ces trois domaines sont illustrés ci-dessous.

Modélisation (gauche), animation (centre) et rendu (droite).

Thèmes reliés et vocabulaire

Sous-thèmes

Autre vocabulaire

Concepts d’une scène 3D

Description d’une scène 3D

Une scène 3D est décrite par trois composantes fondamentales :

La sortie du processus de rendu est une image 2D correspondant à ce que la caméra “voit” de la scène 3D. Le schéma suivant résume l’agencement de ces composantes.

Schéma d’une scène 3D : objets, lumière et caméra dans l’espace monde.

Perception d’une scène 3D sur une image

Un enjeu fondamental de l’informatique graphique est de donner l’illusion de la profondeur et du volume sur un écran qui est intrinsèquement un support 2D. Plusieurs phénomènes visuels contribuent à cette perception.

Effet de perspective

Les objets éloignés apparaissent plus petits que les objets proches. Deux lignes parallèles dans la scène convergent vers un point de fuite sur l’image. Cet effet est directement lié à la projection perspective réalisée par la caméra (voir section sur les coordonnées généralisées).

Projection orthographique (gauche) vs projection perspective (droite).

Illumination et ombrage

La couleur d’un point de la surface varie en fonction de l’orientation de cette surface par rapport à la source lumineuse. Une surface faisant face à la lumière apparaît claire, tandis qu’une surface tournée de l’autre côté apparaît sombre. Ce dégradé de luminosité, appelé ombrage (shading), donne des indices essentiels sur la courbure et le volume des objets. Par opposition, un objet affiché avec une couleur uniforme (sans ombrage) paraît plat, comme un disque plutôt qu’une sphère.

Couleur uniforme (gauche) vs illumination diffuse (droite) : l'ombrage révèle la géométrie.

Occultation

Les objets proches masquent les objets situés derrière eux. Ce phénomène simple mais puissant fournit un indice direct de l’ordre en profondeur des objets dans la scène.

Ombres portées

L’ombre qu’un objet projette sur un autre objet ou sur le sol ancre visuellement l’objet dans la scène et donne des informations sur sa position relative et la direction de la lumière.

Définition d’une image

Une image est une grille 2D de pixels de dimension \(N_x \times N_y\).

Autres formats courants :

Remarque : l’espace RGB n’est pas perceptuellement uniforme (deux couleurs proches en distance RGB ne sont pas nécessairement proches visuellement). Le cube RGB est représenté sur la figure ci-dessous.

Visualisation du cube des couleurs RGB.

Représentation de modèles 3D

Un objet 3D peut être représenté de deux manières fondamentales :

En informatique graphique temps réel, on utilise majoritairement des représentations par surface. Les représentations volumiques sont réservées à des cas spécifiques (effets atmosphériques, données médicales, simulation physique). La figure suivante compare ces deux approches.

Représentation volumique (gauche) vs représentation surfacique (droite).

Surfaces explicites et implicites

Il existe deux familles fondamentales de description mathématique d’une surface :

Les deux types de représentation sont comparés sur le schéma ci-dessous.

Surface explicite paramétrique (gauche) vs surface implicite (droite).

En pratique, pour des formes complexes comme un personnage ou un véhicule, aucune formule analytique simple ne peut décrire la surface. On recourt alors à des approximations discrètes.

Discrétisation et primitives

En pratique, les surfaces arbitraires sont rarement décrites par une seule fonction analytique. On utilise des approximations par morceaux à l’aide de primitives et d’éléments discrets.

Les propriétés idéales d’une description sont :

Exemples de modèles :

Les cartes graphiques sont spécialisées pour le rendu de maillages triangulaires.

Rendu 3D

Deux approches de rendu

Lancé de rayons (Ray Tracing)

Le lancé de rayons simule le parcours physique de la lumière dans la scène. Le principe de base est le suivant : pour chaque pixel de l’image, on lance un rayon depuis la caméra à travers ce pixel et on cherche le premier objet de la scène que ce rayon intersecte. Une fois le point d’intersection trouvé, on calcule sa couleur en fonction de l’éclairage, du matériau et éventuellement des réflexions et réfractions (en lançant récursivement de nouveaux rayons).

En pratique, les rayons sont souvent tracés dans le sens inverse de la lumière (de la caméra vers la scène), car la grande majorité des rayons émis par une source lumineuse n’atteignent jamais la caméra. Ce traçage inversé est beaucoup plus efficace.

Avantages :

Inconvénients :

Le principe est résumé sur le schéma suivant.

Principe du lancé de rayons.

Projection / Rasterization

L’approche par rasterization adopte une stratégie radicalement différente du ray tracing. Au lieu de partir de chaque pixel et de chercher quel objet est visible, on part de chaque objet (triangle) et on détermine quels pixels il recouvre. Cette approche suppose que la scène est composée de triangles. Le processus se déroule en deux étapes :

  1. Projection : chaque sommet de triangle est projeté depuis l’espace 3D sur le plan 2D de la caméra. Cette opération est réalisée par une multiplication matricielle dans un espace projectif : \(p' = M \, p\), où \(M\) est la matrice combinant la transformation de la scène, le placement de la caméra et la projection perspective. Cette opération est extrêmement rapide car elle se résume à un produit matrice-vecteur par sommet.

  2. Rasterization : une fois les triangles projetés en 2D, chaque triangle est “rempli” pixel par pixel. Pour chaque pixel couvert par le triangle, on calcule ses coordonnées barycentriques et on interpole les attributs des sommets (couleur, normale, coordonnées de texture, etc.) pour obtenir la valeur au pixel. Chaque pixel ainsi traité est appelé un fragment.

Ces deux étapes sont illustrées ci-dessous.

Projection des sommets sur le plan caméra (gauche) et rasterization des triangles en pixels (droite).

Un mécanisme de z-buffer (tampon de profondeur) permet de gérer l’occultation : pour chaque pixel, on ne conserve que le fragment le plus proche de la caméra, assurant que les objets en avant masquent ceux en arrière.

Avantages :

Inconvénients :

La rasterization est le standard du rendu temps réel sur GPU. C’est l’approche utilisée dans tous les jeux vidéos, les applications de visualisation interactive et les logiciels de CAO.

Maillages triangulaires

Structure

Un maillage triangulaire est la représentation la plus simple et la plus répandue pour approximer une surface continue par un ensemble de triangles. Plus le nombre de triangles est élevé, plus l’approximation est fidèle à la surface originale, mais plus le coût de stockage et de rendu est important. En pratique, un modèle 3D typique dans un jeu vidéo contient de quelques milliers à plusieurs centaines de milliers de triangles. Un modèle haute résolution pour le cinéma peut contenir plusieurs millions de triangles.

Un maillage est décrit par trois types d’éléments :

Des exemples concrets de maillages sont visibles ci-dessous.

Exemples de maillages triangulaires : modèle de voiture (gauche) et modèle de dragon (droite).

Propriétés des triangles

Un triangle \(T\) est défini par trois sommets \((p_1, p_2, p_3)\). Le triangle est l’élément de base de la rasterization et possède des propriétés mathématiques très utiles que nous détaillons ci-dessous.

Paramétrisation

Tout point \(p\) à l’intérieur du triangle peut être exprimé comme une combinaison linéaire des deux arêtes issues de \(p_1\) :

\[ p \in T \Leftrightarrow S(u,v) = p_1 + u\,(p_2 - p_1) + v\,(p_3 - p_1), \quad u \geq 0, \; v \geq 0, \; u+v \leq 1 \]

Les paramètres \((u,v)\) forment un système de coordonnées local sur le triangle. Le sommet \(p_1\) correspond à \((0,0)\), \(p_2\) à \((1,0)\) et \(p_3\) à \((0,1)\).

Coordonnées barycentriques

Une manière équivalente et souvent plus pratique de paramétrer un triangle est d’utiliser les coordonnées barycentriques \((\alpha, \beta, \gamma)\) :

\[ p \in T \Leftrightarrow p = \alpha \, p_1 + \beta \, p_2 + \gamma \, p_3, \quad \alpha, \beta, \gamma \in [0,1], \; \alpha + \beta + \gamma = 1 \]

Les coordonnées barycentriques ont une interprétation géométrique intuitive : \(\alpha\) représente le “poids” ou l’influence du sommet \(p_1\) sur le point \(p\). Plus \(p\) est proche de \(p_1\), plus \(\alpha\) est grand (proche de 1) et plus \(\beta\) et \(\gamma\) sont petits. Aux sommets, on a respectivement \((\alpha, \beta, \gamma) = (1,0,0)\), \((0,1,0)\) et \((0,0,1)\). Au barycentre du triangle, les trois coordonnées valent \(1/3\).

Le calcul des coordonnées barycentriques pour un point \(p\) se fait via le rapport des aires des sous-triangles :

\[ \alpha = \frac{A_{p23}}{A_{123}}, \quad \beta = \frac{A_{p31}}{A_{123}}, \quad \gamma = \frac{A_{p12}}{A_{123}} \]

avec \(A_{123} = \frac{1}{2} \| (p_2 - p_1) \times (p_3 - p_1) \|\) l’aire totale du triangle, et \(A_{p23}\), \(A_{p31}\), \(A_{p12}\) les aires des sous-triangles formés par \(p\) avec les arêtes opposées :

\[ \begin{aligned} A_{p23} &= \tfrac{1}{2} \| (p_2 - p) \times (p_3 - p) \| \\ A_{p31} &= \tfrac{1}{2} \| (p_3 - p) \times (p_1 - p) \| \\ A_{p12} &= \tfrac{1}{2} \| (p_1 - p) \times (p_2 - p) \| \end{aligned} \]

Cette construction géométrique est illustrée sur la figure ci-dessous.

Coordonnées barycentriques : le point p est localisé par les aires des sous-triangles.

Applications

Interpolation barycentrique (linéaire)

Soit un triangle \(T\) avec des couleurs \((c_1, c_2, c_3)\) associées aux sommets \((p_1, p_2, p_3)\). La couleur interpolée en un point \(p\) intérieur au triangle est :

\[ c(p) = \alpha \, c_1 + \beta \, c_2 + \gamma \, c_3 \]

\((\alpha, \beta, \gamma)\) sont les coordonnées barycentriques de \(p\).

Cette interpolation est fondamentale en rendu : elle est utilisée pour interpoler les couleurs, les normales, les coordonnées de texture, etc., à l’intérieur de chaque triangle. Un exemple d’interpolation de couleurs est montré ci-dessous.

Interpolation barycentrique des couleurs à l’intérieur d’un triangle.

Test d’intersection

Pour tester si un point \(p\) est à l’intérieur d’un triangle \(T = (p_1, p_2, p_3)\) :

  1. Condition nécessaire : \(p\) doit être dans le plan du triangle. On vérifie que \((p - p_1) \cdot n = 0\) avec \(n = (p_2 - p_1) \times (p_3 - p_1)\) (normale au triangle).

  2. Condition suffisante : calcul des coordonnées barycentriques \((\alpha, \beta, \gamma)\). On vérifie que \(0 \leq \alpha, \beta, \gamma \leq 1\) et \(\alpha + \beta + \gamma = 1\).

Le principe de ce test est représenté sur le schéma suivant.

Test d’intersection rayon-triangle.

Description de la connectivité

Considérons un tétraèdre \((p_0, p_1, p_2, p_3)\) comme exemple.

1ère solution : soupe de triangles

Chaque triangle est décrit par ses trois coordonnées, sans partage de sommets :

triangles = [(0.0,0.0,0.0), (1.0,0.0,0.0), (0.0,0.0,1.0),
             (0.0,0.0,0.0), (0.0,0.0,1.0), (0.0,1.0,0.0),
             (0.0,0.0,0.0), (0.0,1.0,0.0), (1.0,0.0,0.0),
             (1.0,0.0,0.0), (0.0,1.0,0.0), (0.0,0.0,1.0)]

2ème solution : géométrie + connectivité (topologie)

On sépare les positions des sommets et les indices des faces :

geometry = [(0.0,0.0,0.0), (1.0,0.0,0.0), (0.0,1.0,0.0), (0.0,0.0,1.0)]
connectivity = [(0,1,3), (0,3,2), (0,2,1), (1,2,3)]

Cette seconde approche est bien plus efficace en mémoire car les sommets partagés ne sont stockés qu’une seule fois. Dans l’exemple du tétraèdre, la première solution stocke \(4 \times 3 = 12\) triplets de coordonnées (soit 36 flottants), tandis que la seconde ne stocke que 4 sommets (12 flottants) plus 4 triplets d’indices (12 entiers). Pour des maillages de grande taille, le gain est considérable. C’est la représentation standard utilisée en pratique et par les APIs graphiques (OpenGL, Vulkan, etc.).

Remarque : l’ordre des indices dans chaque face définit l’orientation de la face. Par convention (dite de la main droite ou counterclockwise), lorsqu’on regarde la face depuis l’extérieur de l’objet, les sommets apparaissent dans le sens trigonométrique (anti-horaire). Cette orientation permet de calculer la normale sortante de la face par un produit vectoriel : \(n = (p_{i_2} - p_{i_1}) \times (p_{i_3} - p_{i_1})\). Le GPU utilise cette information pour le back-face culling : les triangles vus de dos (dont la normale est orientée à l’opposé de la caméra) ne sont pas affichés, ce qui réduit le coût de rendu de moitié environ. Cette convention est illustrée ci-dessous.

Convention d’orientation des faces (counterclockwise).

Format de fichier : OBJ

Le format OBJ (Wavefront) est l’un des formats texte les plus répandus pour stocker des maillages 3D. Sa simplicité le rend facile à lire et à écrire, aussi bien par des humains que par des programmes. Un fichier OBJ est un fichier texte où chaque ligne commence par un mot-clé suivi de valeurs :

Exemple minimal (tétraèdre) :

v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.0 1.0 0.0
v 0.0 0.0 1.0
f 1 2 4
f 1 4 3
f 1 3 2
f 2 3 4

Coordonnées généralisées

Transformations affines

Les transformations affines sont les opérations fondamentales pour positionner, orienter et dimensionner des objets dans l’espace 3D. En informatique graphique, on les utilise en permanence : pour placer un objet dans la scène, pour animer un personnage (rotation des articulations), pour positionner la caméra, etc.

Translation

La translation déplace un point d’un vecteur constant \((t_x, t_y, t_z)\) :

\[ t(p) = (x + t_x, \; y + t_y, \; z + t_z) \]

La translation n’est pas une transformation linéaire (elle ne peut pas être représentée par une multiplication par une matrice \(3 \times 3\), car \(t(0) \neq 0\) en général). Cette propriété est à l’origine du besoin des coordonnées homogènes décrites plus loin.

Homothétie (Scaling)

Le scaling multiplie chaque coordonnée par un facteur d’échelle :

\[ s(p) = (s_x \, x, \; s_y \, y, \; s_z \, z) \]

En notation matricielle :

\[ S = \begin{pmatrix} s_x & 0 & 0 \\ 0 & s_y & 0 \\ 0 & 0 & s_z \end{pmatrix} \]

Si \(s_x = s_y = s_z\), le scaling est uniforme (l’objet garde ses proportions). Sinon, il est non uniforme (l’objet est déformé, par exemple étiré selon un axe).

Rotation

Une rotation préserve les distances et les angles (c’est une isométrie). Elle est décrite par une matrice \(3 \times 3\) orthogonale :

\[ R = \begin{pmatrix} a & b & c \\ d & e & f \\ g & h & j \end{pmatrix}, \quad R\,R^T = I \text{ et } \det(R) = 1 \]

Les colonnes (et lignes) de \(R\) forment une base orthonormée : elles sont mutuellement orthogonales et de norme 1. La condition \(\det(R) = 1\) distingue les rotations des réflexions (qui ont un déterminant de \(-1\)).

Il existe plusieurs manières de paramétrer une rotation en 3D : angles d’Euler (trois angles successifs autour des axes), axe-angle (un axe et un angle de rotation autour de cet axe), ou quaternions (représentation à 4 composantes, très utilisée en animation pour son efficacité et l’absence de singularités).

Transvection / Cisaillement (Shearing)

Le cisaillement décale une coordonnée proportionnellement à une autre. Par exemple :

\[ sh_{xy}(p) = (x + \lambda \, y, \; y, \; z) \]

En notation matricielle :

\[ Sh_{xy} = \begin{pmatrix} 1 & \lambda & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \end{pmatrix} \]

Le cisaillement préserve les volumes (\(\det(Sh) = 1\)) mais ne préserve pas les angles (ce n’est pas une isométrie). Il est rarement utilisé volontairement en informatique graphique, mais peut apparaître comme sous-produit de certaines décompositions matricielles. Son effet géométrique est visible sur la figure suivante.

Effet d’un cisaillement sur un carré.

Coordonnées homogènes

La rotation et le scaling sont des transformations linéaires, représentables par des matrices \(3 \times 3\). La translation, en revanche, est une transformation non linéaire qui ne peut pas être encodée directement par une matrice \(3 \times 3\).

Cela pose un problème pratique : en informatique graphique, on enchaîne en permanence des rotations, scalings et translations pour positionner les objets dans la scène. Par exemple, pour placer un objet dans le monde, on peut lui appliquer un scaling (pour ajuster sa taille), puis une rotation (pour l’orienter), puis une translation (pour le positionner). Si seules les rotations et scalings étaient des matrices, il faudrait maintenir deux représentations séparées (une matrice pour la partie linéaire et un vecteur pour la translation), ce qui compliquerait considérablement le code.

Idée : ajouter une coordonnée supplémentaire pour obtenir une représentation unifiée en 4D, dans laquelle toutes les transformations affines (y compris la translation) s’expriment comme des produits de matrices.

La transformation affine unifiée s’écrit alors comme un produit matriciel \(4 \times 4\) :

\[ \begin{pmatrix} p' \\ 1 \end{pmatrix} = \begin{pmatrix} L & t \\ 0 & 1 \end{pmatrix} \begin{pmatrix} p \\ 1 \end{pmatrix} = \begin{pmatrix} L\,p + t \\ 1 \end{pmatrix} \]

avec \(L\) la partie linéaire (\(3 \times 3\)) et \(t\) le vecteur de translation.

Principe en 2D

Pour un point \(p = (x, y)\), on ajoute une coordonnée : \(p = (x, y, 1)\).

La translation devient une opération linéaire :

\[ \begin{pmatrix} x + t_x \\ y + t_y \\ 1 \end{pmatrix} = \begin{pmatrix} 1 & 0 & t_x \\ 0 & 1 & t_y \\ 0 & 0 & 1 \end{pmatrix} \begin{pmatrix} x \\ y \\ 1 \end{pmatrix} \]

De même pour les rotations et le scaling, qui s’expriment comme des matrices \(3 \times 3\) en coordonnées homogènes 2D.

Extension en 3D

En 3D, on utilise des vecteurs à 4 composantes et des matrices \(4 \times 4\).

Toute séquence de translations, rotations et scalings peut se factoriser en un unique produit de matrices :

\[ M = T_0 \, R_0 \, S_0 \, T_1 \, R_1 \, S_1 \, \dots \]

L’avantage est considérable : quelle que soit la complexité de la chaîne de transformations, le résultat est toujours une unique matrice \(4 \times 4\). Appliquer cette transformation à un point revient à une seule multiplication matrice-vecteur. Cette propriété est exploitée massivement par le GPU, qui peut transformer des millions de sommets en appliquant la même matrice \(M\) à chacun d’eux en parallèle.

[Attention] : l’ordre des multiplications est important. Les transformations matricielles ne sont pas commutatives en général : \(T \, R \neq R \, T\). Par convention, la transformation la plus à droite est appliquée en premier. Ainsi, \(M = T \, R \, S\) signifie : d’abord scaling, puis rotation, puis translation.

Points et vecteurs

L’intérêt des coordonnées généralisées est la différenciation naturelle entre points et vecteurs :

Point : \((x, y, z, \mathbf{1})\) — la translation s’applique.

\[ M \begin{pmatrix} p \\ 1 \end{pmatrix} = \begin{pmatrix} L & t \\ 0 & 1 \end{pmatrix} \begin{pmatrix} p \\ 1 \end{pmatrix} = \begin{pmatrix} L\,p + t \\ 1 \end{pmatrix} \]

Vecteur : \((x, y, z, \mathbf{0})\) — la translation ne s’applique pas.

\[ M \begin{pmatrix} v \\ 0 \end{pmatrix} = \begin{pmatrix} L & t \\ 0 & 1 \end{pmatrix} \begin{pmatrix} v \\ 0 \end{pmatrix} = \begin{pmatrix} L\,v \\ 0 \end{pmatrix} \]

Ce comportement est cohérent avec les opérations géométriques :

Cette distinction est résumée sur le schéma ci-dessous.

Distinction entre points et vecteurs en coordonnées généralisées.

Espace projectif

Considérons le cas d’un point généralisé avec coordonnée \(w \neq 1\) : \(p_{4D} = (x, y, z, w)\).

La “renormalisation” (ou projection) sur l’espace des points 3D donne : \(p_{3D} = (x/w, y/w, z/w, 1)\).

Exemple :

La somme de deux points donne :

\[ (x_1, y_1, z_1, 1) + (x_2, y_2, z_2, 1) = (x_1+x_2, \; y_1+y_2, \; z_1+z_2, \; 2) \]

Après homogénéisation :

\[ p_{3D} = \left(\frac{x_1+x_2}{2}, \; \frac{y_1+y_2}{2}, \; \frac{z_1+z_2}{2}, \; 1\right) \]

Ce qui correspond au barycentre des deux points.

L’intérêt de l’espace projectif est d’encoder des opérations rationnelles (comme la perspective) par une simple multiplication matricielle.

Application à la perspective

La perspective est modélisée par une division par la profondeur.

En 2D (projection 1D), pour un point \((x, y)\) projeté sur un plan focal à distance \(f\) :

\[ y' = f \, \frac{y}{x} \]

Ce modèle non linéaire s’écrit de manière linéaire en coordonnées homogènes :

\[ \begin{pmatrix} f \, y \\ x \end{pmatrix} = \begin{pmatrix} 0 & f \\ 1 & 0 \end{pmatrix} \begin{pmatrix} x \\ y \end{pmatrix} \]

Après homogénéisation (division par la dernière composante \(x\)), on obtient bien \(y' = f\,y/x\).

Les points réels (2D ou 3D) sont ceux dont la dernière composante vaut \(w = 1\) (obtenus après homogénéisation). Les vecteurs correspondent à \(w = 0\). Ce mécanisme de projection est représenté sur la figure suivante.

Projection perspective en 2D : le point est projeté sur le plan focal par division par la profondeur.

OpenGL et notion de Shaders

CPU et GPU

Un ordinateur dispose de deux processeurs principaux pour le calcul :

Les figures ci-dessous montrent l’aspect physique et l’architecture interne de ces deux types de processeurs.

CPU (gauche) et GPU (droite).

Comparaison des architectures CPU et GPU.

Architecture interne d'un CPU (gauche) et d'un GPU (droite).

Le GPU est en résumé un supercalculateur optimisé pour réaliser la même opération sur un grand nombre de données en parallèle. C’est exactement ce dont on a besoin pour le rendu graphique : transformer des millions de sommets (vertex shader) puis colorier des millions de pixels (fragment shader). Le CPU, quant à lui, se charge de la logique du programme (gestion de la scène, chargement des données, interactions utilisateur) et envoie les commandes de rendu au GPU.

OpenGL

OpenGL (Open Graphics Library) est une API (Application Programming Interface) pour communiquer avec le GPU, orientée graphique 3D.

Caractéristiques :

Autres APIs graphiques : Vulkan, WebGL, WebGPU, DirectX (Windows), Metal (Mac).

Communication CPU/GPU avec OpenGL

Le CPU et le GPU possèdent chacun leur propre mémoire (RAM et VRAM respectivement) et ne peuvent pas accéder directement à la mémoire de l’autre. Le processus de rendu nécessite donc une communication explicite entre les deux, orchestrée par l’API OpenGL. Ce processus suit trois grandes étapes :

  1. Préparation des données (CPU) : le programme C++ (s’exécutant sur le CPU) charge ou calcule les données géométriques de la scène : positions des sommets, couleurs, normales, coordonnées de textures, indices de connectivité, etc. Ces données sont organisées dans des tableaux en RAM.

  2. Envoi des données (CPU \(\rightarrow\) GPU) : les données sont transférées par blocs depuis la RAM vers la VRAM via des appels OpenGL. Ce transfert est relativement coûteux (le bus entre CPU et GPU a un débit limité), c’est pourquoi on cherche à le minimiser : idéalement, les données statiques (maillages qui ne changent pas) ne sont envoyées qu’une seule fois au démarrage. Seules les données qui changent à chaque image (matrices de transformation, paramètres d’animation) sont transmises à chaque frame.

  3. Exécution des Shaders (GPU) : une fois les données en VRAM, le GPU exécute les programmes de shaders en parallèle sur chaque sommet puis sur chaque pixel. Le CPU n’intervient plus pendant cette phase : il se contente d’envoyer la commande “dessine” et le GPU fait le reste de manière autonome. Le résultat est l’image finale, stockée dans le framebuffer de la VRAM, qui est ensuite affichée à l’écran.

Les schémas suivants détaillent ce pipeline de communication.

Schéma simplifié de la communication CPU/GPU via OpenGL.
Vue d’ensemble du pipeline de rendu OpenGL.

Shaders

Les shaders sont de petits programmes qui s’exécutent directement sur le GPU. Ils sont écrits dans un langage dédié appelé GLSL (OpenGL Shading Language), dont la syntaxe est proche du C. Le terme “shader” vient de “shading” (ombrage), car leur rôle initial était de calculer l’ombrage des surfaces, mais ils sont aujourd’hui utilisés pour toutes sortes de calculs graphiques.

La particularité fondamentale des shaders est qu’ils sont exécutés massivement en parallèle : le même programme est lancé simultanément sur des milliers de sommets ou de pixels. Le programmeur écrit le code pour un seul sommet ou pixel, et le GPU se charge de l’exécuter pour tous.

Deux types de shaders principaux interviennent dans le pipeline de rendu :

Vertex Shader

Exécuté une fois pour chaque sommet du maillage. Son rôle principal est de transformer la position du sommet depuis l’espace 3D de la scène vers l’espace 2D de l’écran (en appliquant les matrices de transformation et de projection). Il peut aussi calculer et transmettre des attributs (couleurs, normales transformées, coordonnées de texture, etc.) qui seront utilisés par le fragment shader.

#version 330 core

layout(location = 0) in vec4 position;
layout(location = 1) in vec4 color;

out vec4 vertexColor;

void main()
{
    gl_Position = position;
    vertexColor = color;
}

Dans cet exemple :

Fragment Shader

Exécuté une fois pour chaque pixel (fragment) couvert par un triangle après la rasterization. Son rôle est de déterminer la couleur finale du pixel en fonction des attributs interpolés (couleur, normale, coordonnées de texture), de l’éclairage, des textures, etc. C’est dans le fragment shader que l’on implémente les modèles d’illumination et les effets visuels.

#version 330 core

in vec4 vertexColor;
out vec4 fragColor;

void main()
{
    fragColor = vertexColor;
}

Dans cet exemple :

Variables uniformes

En plus des attributs (propres à chaque sommet) et des variables interpolées (propres à chaque fragment), les shaders peuvent recevoir des variables uniformes (uniform) : des valeurs constantes pour l’ensemble des sommets ou fragments d’un même appel de rendu. Elles sont définies côté CPU et envoyées au GPU. Les exemples typiques sont les matrices de transformation, la position de la caméra, la direction de la lumière, le temps courant, etc.

uniform mat4 modelViewProjection; // matrice de transformation (4x4)
uniform vec3 lightDirection;      // direction de la lumière

Le pipeline complet fonctionne ainsi :

  1. Le vertex shader est exécuté en parallèle sur chaque sommet, appliquant les transformations géométriques.
  2. Les triangles projetés sont rasterisés : découpés en fragments (pixels).
  3. Le fragment shader est exécuté en parallèle sur chaque fragment, calculant la couleur finale.
  4. Le résultat est l’image finale affichée à l’écran.

Les attributs transmis du vertex shader au fragment shader (comme vertexColor) sont automatiquement interpolés de manière barycentrique entre les sommets du triangle, ce qui correspond exactement à l’interpolation barycentrique décrite précédemment. L’ensemble de ce pipeline est résumé sur le schéma ci-dessous.

Pipeline de rendu : vertex shader, rasterization, fragment shader.

Pipeline de Rendu et Illumination

Vue d’ensemble du pipeline

De la scène 3D à l’image

Comme vu au chapitre précédent, une scène 3D est décrite par des modèles (surfaces), des sources de lumière et une caméra. Le rendu par rasterization produit une image 2D de cette scène.

Cependant, au niveau du GPU et d’OpenGL, il n’existe pas de notion explicite de caméra ni de lumière. Le GPU ne manipule que des sommets avec leurs attributs (position, couleur, normale, etc.) en entrée du vertex shader, et des fragments (pixels) colorés en sortie du fragment shader. Tout le travail du programmeur consiste à traduire les concepts de haut niveau (caméra, lumière, matériaux) en opérations sur les sommets et les fragments via les shaders.

Le pipeline de rendu se décompose en trois grandes étapes. La première est la transformation des sommets, réalisée par le vertex shader : chaque sommet est projeté depuis l’espace 3D de la scène vers l’espace 2D de l’écran en appliquant les matrices de transformation et de projection. La deuxième étape est la rasterization : les triangles projetés en 2D sont convertis en fragments (pixels) par un processus de discrétisation automatique. Enfin, la troisième étape est le calcul de la couleur, effectué par le fragment shader : pour chaque fragment, on détermine sa couleur finale en fonction de l’éclairage, du matériau et des textures.

Transformation des sommets — Projection et perspective

Principe de la projection

OpenGL n’affiche que les triangles dont les sommets se trouvent dans le cube \([-1, 1]^3\). Cet espace normalisé est appelé Normalized Device Coordinates (NDC). Les deux premières composantes \((x_{\text{ndc}}, y_{\text{ndc}})\) correspondent aux coordonnées écran, tandis que \(z_{\text{ndc}}\) encode la profondeur, c’est-à-dire la distance à la caméra dans l’espace image. Ce cube normalisé est représenté sur la figure suivante.

Espace NDC : le cube normalisé dans lequel OpenGL affiche les triangles.

L’objectif de la projection est de convertir les coordonnées globales (world space) en coordonnées NDC. Cette conversion doit d’une part définir un modèle de caméra qui spécifie quelle partie de l’espace 3D est visible, et d’autre part appliquer la perspective pour que les objets éloignés apparaissent plus petits que les objets proches.

Le modèle de perspective le plus courant utilise un volume de visibilité en forme de tronc de pyramide (frustum), délimité par un plan proche (\(z_{\text{near}}\)) et un plan lointain (\(z_{\text{far}}\)), comme illustré ci-dessous.

Le frustum : volume de visibilité de la caméra en perspective.

Formulation de la projection

Soit un point de coordonnées \((x, y, z)\) dans l’espace monde. Les coordonnées NDC \((x_{\text{ndc}}, y_{\text{ndc}}, z_{\text{ndc}})\) sont calculées comme suit :

Coordonnées \(x\) et \(y\) :

\[ x_{\text{ndc}} = \frac{z_{\text{near}}}{-z} \cdot \frac{x}{w} = \frac{1}{\tan(\theta/2)} \cdot \frac{x}{-z} \]

\[ y_{\text{ndc}} = \frac{z_{\text{near}}}{-z} \cdot \frac{y}{h} = \frac{r}{\tan(\theta/2)} \cdot \frac{y}{-z} \]

\(\theta\) est le champ de vision (Field of View, FOV) et \(r = h/w\) est le ratio d’aspect.

La division par \(-z\) produit l’effet de perspective : les objets éloignés (grand \(|z|\)) sont réduits en taille.

Coordonnée \(z\) (profondeur) :

La profondeur NDC est une fonction non linéaire de \(z\) :

\[ z_{\text{ndc}} = \frac{1}{-z} \left( -\frac{z_{\text{far}} + z_{\text{near}}}{z_{\text{far}} - z_{\text{near}}} \cdot z - \frac{2 \, z_{\text{near}} \, z_{\text{far}}}{z_{\text{far}} - z_{\text{near}}} \right) \]

avec les correspondances : \(z = -z_{\text{near}} \rightarrow z_{\text{ndc}} = -1\) et \(z = -z_{\text{far}} \rightarrow z_{\text{ndc}} = 1\).

La variation en \(1/z\) implique une précision plus fine près de la caméra et plus grossière au loin. C’est un choix délibéré : les objets proches nécessitent une meilleure résolution en profondeur pour éviter les artefacts visuels.

Matrice de projection

En coordonnées homogènes, la projection s’exprime comme un produit matriciel \(4 \times 4\) :

\[ p_{\text{ndc}} = \text{Proj} \times p \]

avec :

\[ \text{Proj} = \begin{pmatrix} \frac{1}{\tan(\theta/2)} & 0 & 0 & 0 \\ 0 & \frac{r}{\tan(\theta/2)} & 0 & 0 \\ 0 & 0 & -\frac{z_{\text{far}} + z_{\text{near}}}{z_{\text{far}} - z_{\text{near}}} & -\frac{2 \, z_{\text{near}} \, z_{\text{far}}}{z_{\text{far}} - z_{\text{near}}} \\ 0 & 0 & -1 & 0 \end{pmatrix} \]

Après multiplication, le vecteur obtenu a une composante \(w \neq 1\). L’homogénéisation (division par \(w\)) produit les coordonnées NDC finales. C’est exactement le mécanisme de l’espace projectif vu au chapitre précédent.

Le résultat est un espace déformé dans le cube \([-1, 1]^3\). L’image finale correspond à la vue \((x_{\text{ndc}}, y_{\text{ndc}})\) de ce cube, tandis que \(z_{\text{ndc}}\) représente la profondeur vue depuis la caméra dans l’espace image. Tout ce qui se trouve en dehors du cube \([-1, 1]^3\) est automatiquement écarté par le GPU (clipping).

Z-fighting

La variation en \(1/z\) de la profondeur NDC a une conséquence importante : la précision dépend fortement du choix de \(z_{\text{near}}\). Le schéma suivant montre cette distribution non uniforme de la précision.

Distribution de la précision en profondeur selon la distance à la caméra.

Si \(z_{\text{near}}\) est trop proche de 0, toute la précision est concentrée sur les distances très proches de la caméra. Les objets éloignés se retrouvent avec des valeurs de profondeur presque identiques après discrétisation. Cela provoque un artefact visuel appelé z-fighting (ou depth-fighting) : deux surfaces proches en profondeur “clignotent” de manière aléatoire car le GPU ne peut pas déterminer laquelle est devant. Le graphique ci-dessous illustre la courbe de \(z_{\text{ndc}}\) en fonction de \(z\).

Graphique de la distribution des valeurs de profondeur z_{\text{ndc}} en fonction de z.

Règle pratique : choisir \(z_{\text{near}}\) aussi grand que possible (tout en gardant les objets visibles dans le frustum) pour maximiser la précision en profondeur sur toute la scène.

Matrice de vue (View)

La matrice de vue transforme les coordonnées de l’espace monde vers l’espace caméra (view space). Elle positionne et oriente la caméra dans la scène, selon le principe illustré ci-dessous.

Transformation de l’espace monde vers l’espace caméra.

La caméra est décrite par une matrice \(4 \times 4\) contenant ses axes locaux et sa position :

\[ \text{Cam} = \begin{pmatrix} \text{right}_x & \text{up}_x & \text{back}_x & \text{eye}_x \\ \text{right}_y & \text{up}_y & \text{back}_y & \text{eye}_y \\ \text{right}_z & \text{up}_z & \text{back}_z & \text{eye}_z \\ 0 & 0 & 0 & 1 \end{pmatrix} = \begin{pmatrix} O & \text{eye} \\ 0 & 1 \end{pmatrix} \]

\(O = (\text{right}, \text{up}, \text{back})\) est la matrice de rotation de la caméra (base orthonormée) et \(\text{eye}\) est sa position dans le monde.

La matrice de vue est l’inverse de la matrice caméra :

\[ \text{View} = \text{Cam}^{-1} = \begin{pmatrix} O^T & -O^T \cdot \text{eye} \\ 0 & 1 \end{pmatrix} \]

Cette inversion est très efficace à calculer car \(O\) est orthogonale (\(O^{-1} = O^T\)). La matrice de vue envoie la position de la caméra sur l’origine et aligne ses axes sur les axes du repère.

Matrice de modèle (Model)

La matrice de modèle positionne un objet dans le monde. Elle transforme les coordonnées locales de l’objet vers les coordonnées de l’espace monde :

\[ \text{Model} = \begin{pmatrix} R & t \\ 0 & 1 \end{pmatrix} \]

\(R\) est la matrice de rotation (orientation de l’objet) et \(t\) est le vecteur de translation (position de l’objet). La figure suivante montre ce passage des coordonnées locales à l’espace monde.

La matrice de modèle place l’objet dans le monde.

L’intérêt de séparer la géométrie de l’objet de sa position est fondamental. Les coordonnées géométriques de l’objet (maillage) sont chargées une seule fois en VRAM, et pour déplacer ou orienter l’objet, il suffit de modifier la matrice Model, soit une simple matrice \(4 \times 4\) de 16 flottants. Cette séparation permet également l’instanciation : le même maillage peut être affiché à plusieurs endroits de la scène en appliquant des matrices Model différentes, sans dupliquer les données géométriques en mémoire. Par exemple, une forêt composée de milliers d’arbres peut ne stocker qu’un seul maillage d’arbre, chaque instance ayant sa propre matrice Model (position, rotation, échelle).

Synthèse : la chaîne de transformations

La transformation complète d’un sommet, depuis ses coordonnées locales jusqu’à l’espace NDC, est la composition des trois matrices :

\[ p_{\text{ndc}} = \text{Proj} \times \text{View} \times \text{Model} \times p \]

Le sommet part de ses coordonnées locales \(p = (x, y, z, 1)\) dans l’espace objet, où la géométrie est définie relativement à l’origine de l’objet. La matrice Model le place dans l’espace monde : \(p_{\text{world}} = \text{Model} \times p\), en appliquant la rotation, l’échelle et la translation de l’objet. La matrice View transforme ensuite cette position dans l’espace caméra : \(p_{\text{view}} = \text{View} \times p_{\text{world}}\), où la caméra se trouve à l’origine et regarde dans la direction \(-z\). Enfin, la matrice de Projection convertit les coordonnées en NDC : \(p_{\text{ndc}} = \text{Proj} \times p_{\text{view}}\), en appliquant la perspective et en normalisant les coordonnées dans le cube \([-1, 1]^3\) après homogénéisation (division par \(w\)).

Cette chaîne constitue la première étape du pipeline graphique, réalisée par le vertex shader.

#version 330 core

layout (location = 0) in vec3 vertex_position;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main() {
    gl_Position = projection * view * model * vec4(vertex_position, 1.0);
}

Les trois matrices sont envoyées au GPU comme variables uniformes depuis le code C++. Le GPU applique ensuite cette transformation en parallèle sur tous les sommets. Cela est bien plus efficace que de calculer les nouvelles positions sur le CPU : au lieu de transférer \(N\) positions modifiées à chaque image, on ne transfère que trois matrices \(4 \times 4\) (soit 48 flottants).

void main_loop() {
    // Mise à jour des matrices
    glUniform(shader, Model);
    glUniform(shader, View);
    glUniform(shader, Projection);

    draw(mesh_drawable);
}

Rasterization

Principe

La rasterization (ou rastérisation, ou facétisation) est la conversion de données vectorielles (triangles définis par des sommets) en éléments discrets : des pixels (ou fragments). C’est une étape automatique et non programmable dans le pipeline OpenGL.

L’opération fondamentale est la suivante : étant donné un triangle défini par 3 points 2D (après projection), déterminer l’ensemble des pixels qu’il recouvre, comme on peut le voir sur la figure suivante.

Rasterization d’un triangle : conversion de la forme vectorielle en pixels.

Rasterization de primitives simples

Avant d’aborder la rasterization des triangles, considérons des primitives plus simples pour comprendre le principe de discrétisation.

Point : un seul pixel.

im(x0, y0) = c;

Rectangle : deux boucles imbriquées.

for(int kx = x0; kx < x1; kx++)
    for(int ky = y0; ky < y1; ky++)
        im(kx, ky) = c;

Segment horizontal, vertical ou diagonal : une seule boucle suffit.

// Segment horizontal
for(int kx = x0; kx < x1; kx++)
    im(kx, y0) = c;

Pour un segment quelconque, la discrétisation n’est plus triviale : plusieurs approximations pixelisées sont possibles, comme le montre l’image ci-dessous. Il faut choisir un algorithme efficace et déterministe.

Discrétisation d’un segment : plusieurs approximations possibles.

Algorithme de Bresenham

L’algorithme de Bresenham est un algorithme classique et très efficace pour rasteriser un segment. Il repose uniquement sur des opérations entières, ce qui le rend rapide et exact.

Principe : on avance le long de l’axe \(x\) pixel par pixel. À chaque pas, on évalue si l’erreur accumulée en \(y\) dépasse \(0.5\) :

La progression de l’algorithme est visualisée sur la figure suivante.

Algorithme de Bresenham : progression le long du segment avec correction d’erreur.
int dx = x1 - x0, dy = y1 - y0;
float a = float(dy) / float(dx);
float erreur = 0.0f;

int y = y0;
for(int x = x0; x <= x1; x++)
{
    im(x, y) = c;
    erreur += a;
    if(erreur >= 0.5f)
    {
        y = y + 1;
        erreur = erreur - 1.0f;
    }
}

Remarque : en multipliant toutes les valeurs par \(2 \, dx\), on peut éliminer les divisions et les flottants pour obtenir un algorithme purement entier. L’algorithme ci-dessus traite le cas \(0 \leq dy \leq dx\) ; les autres octants se traitent par symétrie.

Rasterization d’un triangle : algorithme scanline

La rasterization d’un triangle \((p_0, p_1, p_2)\) se fait par l’algorithme scanline :

  1. Discrétiser les arêtes : pour chaque arête du triangle (\((p_0, p_1)\), \((p_1, p_2)\), \((p_2, p_0)\)), appliquer l’algorithme de Bresenham pour obtenir les pixels de contour.

  2. Stocker les bornes horizontales : pour chaque ligne \(y\), stocker les valeurs \(x_{\min}\) et \(x_{\max}\) des pixels de contour.

  3. Remplir ligne par ligne : pour chaque ligne \(y\), colorier tous les pixels de \(x_{\min}\) à \(x_{\max}\).

Les deux étapes sont illustrées ci-dessous : discrétisation des arêtes puis remplissage.

Rasterization d'un triangle par scanline : discrétisation des arêtes (gauche) et remplissage horizontal (droite).

Exemple de table scanline pour un triangle :

\(y\) \(x_{\min}\) \(x_{\max}\)
0 3 5
1 0 5
2 1 4
3 2 4
4 3 3

Lors du remplissage, les coordonnées barycentriques de chaque fragment sont calculées pour interpoler les attributs des sommets (couleur, normale, coordonnées de texture, profondeur) de manière linéaire à l’intérieur du triangle.

Illumination et ombrage (Shading)

Modèle d’illumination de Phong

Le modèle de Phong est le modèle d’illumination le plus classique en rendu temps réel. Il repose sur l’observation que l’apparence d’une surface éclairée peut être décomposée en trois phénomènes physiques distincts. Le premier est la composante ambiante, qui représente une couleur de base uniforme, indépendante de la géométrie et de la position de la lumière : elle modélise de manière grossière la lumière indirecte qui baigne l’ensemble de la scène. Le deuxième est la composante diffuse, qui dépend de l’orientation locale de la surface par rapport à la lumière : une surface tournée vers la lumière est claire, une surface tournée dans l’autre sens est sombre. Le troisième est la composante spéculaire, qui modélise le reflet brillant de la source lumineuse visible sur les surfaces lisses, et qui dépend du point de vue de l’observateur. Ces trois contributions sont représentées sur la figure suivante.

Les trois composantes du modèle de Phong : ambiante, diffuse et spéculaire.

La couleur finale d’un point est la somme de ces trois composantes :

\[ C = C_a + C_d + C_s \]

Le calcul de chaque composante fait intervenir deux propriétés de couleur : la couleur de la source lumineuse \(C_\ell = (r_\ell, g_\ell, b_\ell) \in [0, 1]^3\) et la couleur de la surface (ou albedo) \(C_o = (r_o, g_o, b_o) \in [0, 1]^3\). Leur multiplication composante par composante (\((r_\ell \cdot r_o, \, g_\ell \cdot g_o, \, b_\ell \cdot b_o)\)) modélise le filtrage de la lumière par le matériau : une surface rouge éclairée par une lumière blanche apparaît rouge, tandis que la même surface éclairée par une lumière verte apparaît noire (car la composante rouge de la lumière est nulle).

Composante ambiante

La composante ambiante modélise une illumination uniforme de la scène (lumière indirecte approximée). Elle ne dépend ni de la position ni de l’orientation de la surface :

\[ C_a = \alpha_a \, C_\ell \, C_o \]

avec \(\alpha_a \in [0, 1]\) le coefficient ambiant. Ce terme évite que les surfaces non éclairées directement soient complètement noires.

Composante diffuse

La composante diffuse modélise la lumière réfléchie de manière uniforme dans toutes les directions par une surface mate (réflexion lambertienne). Elle dépend de l’angle entre la normale \(n\) à la surface et la direction de la lumière \(u_\ell\) :

\[ C_d = \alpha_d \, (n \cdot u_\ell)_+ \, C_\ell \, C_o \]

Le coefficient \(\alpha_d \in [0, 1]\) contrôle l’intensité de la réflexion diffuse du matériau. Le terme central est le facteur diffus \((n \cdot u_\ell)_+ = \max(n \cdot u_\ell, \, 0)\), qui est le produit scalaire entre le vecteur normal unitaire \(n\) à la surface et la direction unitaire \(u_\ell\) du point vers la source lumineuse. Ce produit scalaire mesure le cosinus de l’angle entre la normale et la direction de la lumière. Lorsque la surface fait directement face à la lumière (\(n\) parallèle à \(u_\ell\)), le facteur vaut 1 et l’éclairage est maximal. Lorsque la surface est rasante (\(n\) perpendiculaire à \(u_\ell\)), le facteur vaut 0. Lorsque la surface est tournée à l’opposé de la lumière, le produit scalaire est négatif, et la troncature \(\max(\cdot, 0)\) force la contribution à zéro, évitant un éclairage physiquement absurde.

Ce mécanisme géométrique est schématisé ci-dessous.

Composante diffuse : la luminosité dépend de l’angle entre la normale et la direction de la lumière.

Composante spéculaire

La composante spéculaire modélise le reflet de la source lumineuse sur la surface. Contrairement à la composante diffuse, elle dépend du point de vue de la caméra :

\[ C_s = \alpha_s \, (u_r \cdot u_v)_+^{s_{\exp}} \, C_\ell \]

Le coefficient \(\alpha_s \in [0, 1]\) contrôle l’intensité du reflet. L’exposant de brillance \(s_{\exp}\) (typiquement entre 64 et 256) détermine la taille du reflet : plus il est élevé, plus le reflet est concentré en un point étroit et net (surface très polie), tandis qu’un exposant faible produit un reflet large et diffus (surface légèrement rugueuse).

Le vecteur \(u_r\) est la direction de réflexion de la lumière par rapport à la normale, calculée par la formule \(u_r = 2 \, (u_\ell \cdot n) \, n - u_\ell\) (en GLSL : reflect(-u_l, n)). Le vecteur \(u_v\) est la direction unitaire du point de la surface vers la caméra. Le produit scalaire \((u_r \cdot u_v)\) mesure l’alignement entre la direction de réflexion et la direction de vue : le reflet est maximal lorsque l’observateur se trouve exactement dans la direction de réflexion miroir de la lumière.

La composante spéculaire ne dépend pas de la couleur de la surface \(C_o\) mais uniquement de \(C_\ell\), ce qui correspond au comportement physique des reflets sur les matériaux diélectriques (plastique, céramique, etc.) : le reflet d’une lumière blanche sur une surface rouge reste blanc. La géométrie de la réflexion spéculaire et l’influence de l’exposant de brillance sont détaillées dans les figures suivantes.

Composante spéculaire : le reflet dépend de la direction de réflexion et du point de vue.
Effet de l’exposant de brillance : plus s_{\exp} est élevé, plus le reflet est concentré.

Interpolation de l’illumination : Flat, Gouraud, Phong

Le modèle de Phong définit la formule d’illumination en un point de la surface, mais reste la question de et à quelle fréquence cette formule est évaluée. Trois stratégies classiques existent, avec un compromis entre coût de calcul et qualité visuelle.

Le flat shading est l’approche la plus simple : la couleur est calculée une seule fois par triangle, en utilisant la normale géométrique de la face. Tous les pixels du triangle reçoivent exactement la même couleur. Le résultat a un aspect facetté caractéristique où chaque triangle est clairement visible, ce qui peut être acceptable pour du rendu non photo-réaliste mais est inadapté pour des surfaces lisses.

Le Gouraud shading améliore la qualité en calculant l’illumination aux sommets du triangle (dans le vertex shader), puis en interpolant linéairement la couleur résultante à l’intérieur du triangle lors de la rasterization. Les transitions de couleur entre triangles adjacents deviennent douces, donnant une apparence de surface lisse. Cependant, cette approche présente un défaut important : si un reflet spéculaire tombe au milieu d’un grand triangle, loin de tout sommet, il sera complètement manqué car aucun sommet n’a “vu” le reflet.

Le Phong shading résout ce problème en interpolant non pas la couleur, mais la normale entre les sommets du triangle. L’illumination est ensuite calculée pour chaque fragment (dans le fragment shader) avec cette normale interpolée. Le reflet spéculaire est ainsi correctement calculé en chaque point de la surface, même au centre d’un grand triangle. La différence visuelle entre ces trois approches est comparée sur la figure suivante.

Comparaison des trois modèles d’ombrage : Flat, Gouraud et Phong.

En pratique, le Phong shading est le standard. Le surcoût par rapport au Gouraud shading est négligeable sur les GPU modernes (le fragment shader effectue quelques opérations supplémentaires par pixel, mais le GPU dispose de milliers de cœurs pour les exécuter en parallèle), et la qualité visuelle est nettement supérieure.

Lumières multiples et effets

Lumières multiples

Le modèle de Phong s’étend naturellement à plusieurs sources lumineuses en sommant les contributions de chaque lumière :

\[ C = \sum_i \left( \alpha_a \, C_{\ell_i} \, C_o + \alpha_d \, (n \cdot u_{\ell_i})_+ \, C_{\ell_i} \, C_o + \alpha_s \, (u_{r_i} \cdot u_v)_+^{s_{\exp}} \, C_{\ell_i} \right) \]

Atténuation par la distance

Dans la réalité physique, l’intensité lumineuse d’une source ponctuelle diminue proportionnellement à l’inverse du carré de la distance (\(1/r^2\)). En rendu temps réel, on utilise souvent un modèle d’atténuation simplifié qui décroît linéairement jusqu’à une distance maximale, au-delà de laquelle la lumière n’a plus d’effet :

\[ C_\ell(p) = \left(1 - \min\left(\frac{\|p - p_\ell\|}{d_{\text{att}}}, \, 1\right)\right) \, C_\ell^0 \]

Le vecteur \(p\) est la position du point sur la surface, \(p_\ell\) la position de la lumière, \(d_{\text{att}}\) la distance caractéristique d’atténuation (au-delà de laquelle la contribution est nulle), et \(C_\ell^0\) la couleur de la lumière à la source. Ce modèle linéaire est moins réaliste que la loi en \(1/r^2\), mais il a l’avantage de garantir que la lumière s’éteint complètement au-delà de \(d_{\text{att}}\), ce qui permet d’optimiser le rendu en ignorant les lumières trop éloignées.

Effet de brouillard (Fog)

Un effet de brouillard simule l’absorption et la diffusion de la lumière par l’atmosphère. Le principe est de mélanger progressivement la couleur calculée avec une couleur de brouillard uniforme en fonction de la distance à la caméra :

\[ C_{\text{final}} = (1 - \gamma(p)) \, C + \gamma(p) \, C_{\text{fog}} \]

Le facteur de mélange \(\gamma(p) = \min\left(\frac{\|p - p_{\text{eye}}\|}{d_{\text{fog}}}, \, 1\right)\) varie de 0 (objet proche, couleur inchangée) à 1 (objet lointain, complètement noyé dans le brouillard). Le paramètre \(d_{\text{fog}}\) contrôle la distance à laquelle le brouillard devient totalement opaque, et \(C_{\text{fog}}\) est la couleur du brouillard (souvent un gris clair ou la couleur du ciel). En pratique, cet effet donne une impression de profondeur atmosphérique et masque naturellement le plan de clipping lointain (\(z_{\text{far}}\)), évitant la disparition brutale des objets à l’horizon.

Exemple d’effet : Toon Shading

Le toon shading (ou cel shading) est un effet non photo-réaliste qui donne un aspect dessin animé. Le principe est de discrétiser les valeurs de couleur en paliers :

\[ C_{\text{toon}} = \frac{\text{floor}(C \times N)}{N} \]

\(N\) est le nombre de paliers souhaités. Ce sous-échantillonnage crée des bandes de couleur discrètes caractéristiques du style cartoon, dont on voit un exemple ci-dessous.

Toon shading : discrétisation des couleurs pour un rendu stylisé.

Buffer de profondeur (Depth Buffer)

Problème de l’occultation

Lorsque plusieurs triangles se chevauchent à l’écran, il faut déterminer lequel est visible pour chaque pixel. Sans mécanisme d’occultation, l’ordre d’affichage des triangles détermine le résultat, ce qui est incorrect, comme le met en évidence l’image suivante.

L’ordre de dessin des triangles affecte le résultat sans mécanisme de profondeur.

Algorithme du Z-buffer

Le Z-buffer (ou depth buffer) est une image auxiliaire de la même résolution que l’image finale, qui stocke la valeur de profondeur \(z_{\text{ndc}}\) du fragment le plus proche de la caméra pour chaque pixel.

L’algorithme est simple : pour chaque fragment à dessiner, on compare sa profondeur avec la valeur stockée dans le Z-buffer :

def draw(position, couleur, z, image, zbuffer):
    if z < zbuffer(position):
        image(position) = couleur
        zbuffer(position) = z
    # sinon: ne rien dessiner (fragment masqué)

On peut visualiser le contenu du Z-buffer sous forme d’image en niveaux de gris.

Visualisation du depth buffer : les zones claires sont proches de la caméra, les zones sombres sont éloignées.

Le Z-buffer est initialisé à la valeur maximale de profondeur (1.0, correspondant au plan lointain) à chaque début de frame. Chaque fragment met à jour le Z-buffer si sa profondeur est inférieure (plus proche de la caméra) à la valeur stockée. Cela garantit que seul le fragment le plus proche est affiché, indépendamment de l’ordre de dessin des triangles.

Cette étape est réalisée automatiquement par le GPU (non programmable), à condition d’activer le test de profondeur (glEnable(GL_DEPTH_TEST) en OpenGL).

Shaders : synthèse du pipeline

Pipeline complet

Le pipeline de rendu complet enchaîne plusieurs étapes en séquence. Le vertex shader transforme d’abord chaque sommet en appliquant la chaîne de matrices Projection \(\times\) View \(\times\) Model, et transmet les attributs transformés (position en espace monde, normale, couleur) vers les étapes suivantes. Les sommets transformés sont ensuite regroupés en triangles lors de l’assemblage des primitives, en utilisant la table de connectivité envoyée depuis le CPU.

La rasterization prend chaque triangle projeté en 2D et détermine l’ensemble des pixels qu’il recouvre. Pour chaque pixel couvert (appelé fragment), les attributs des trois sommets du triangle sont interpolés de manière barycentrique. Le fragment shader reçoit ces attributs interpolés et calcule la couleur finale du fragment en appliquant le modèle d’illumination de Phong. Le test de profondeur (Z-buffer) compare ensuite la profondeur du fragment avec la valeur stockée pour ce pixel : seul le fragment le plus proche de la caméra est conservé, les autres sont écartés. Le résultat de l’ensemble de ces étapes est l’image finale, stockée dans le framebuffer et affichée à l’écran.

L’enchaînement complet de ces étapes est résumé dans le schéma ci-dessous.

Pipeline complet de rendu : du sommet à l’image finale.

Exemple de code complet

Vertex shader : transforme les positions et normales, transmet les attributs au fragment shader.

#version 330 core

layout (location = 0) in vec3 vertex_position;
layout (location = 1) in vec3 vertex_normal;
layout (location = 2) in vec3 vertex_color;

out struct fragment_data {
    vec3 position;
    vec3 normal;
    vec3 color;
} fragment;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main() {
    vec4 position = model * vec4(vertex_position, 1.0);

    mat4 modelNormal = transpose(inverse(model));
    vec4 normal = modelNormal * vec4(vertex_normal, 0.0);

    fragment.position = position.xyz;
    fragment.normal   = normal.xyz;
    fragment.color    = vertex_color;

    gl_Position = projection * view * position;
}

Remarque : la normale est transformée par la transposée de l’inverse de la matrice Model (transpose(inverse(model))), et non par Model directement. En effet, si Model contient un scaling non uniforme, la multiplication directe déformerait les normales et l’ombrage serait incorrect.

Fragment shader : calcule l’illumination de Phong pour chaque fragment.

#version 330 core

in struct fragment_data {
    vec3 position;
    vec3 normal;
    vec3 color;
} fragment;

uniform vec3 light_position;
uniform vec3 light_color;

layout(location = 0) out vec4 FragColor;

void main() {
    vec3 N = normalize(fragment.normal);
    vec3 L = normalize(light_position - fragment.position);

    float diffuse_magnitude = max(dot(N, L), 0.0);

    vec3 c = 0.1 * fragment.color * light_color;                    // ambiante
    c = c + 0.7 * diffuse_magnitude * fragment.color * light_color;  // diffuse
    // + composante spéculaire (omise pour simplifier)

    FragColor = vec4(c, 1.0);
}

Les variables fragment.position, fragment.normal et fragment.color sont automatiquement interpolées de manière barycentrique par le GPU entre les valeurs des trois sommets du triangle courant.

Maillages et Textures

Géométrie des maillages

Structure d’un maillage

Un maillage (mesh) est un ensemble de polygones partageant des sommets. Il est décrit par :

Selon le type de polygones utilisés, on distingue :

Ces trois catégories sont illustrées ci-dessous.

Types de maillages : triangulation, quad mesh et poly mesh.

Un maillage représente une variété (manifold) si chaque arête est partagée par au plus 2 faces, comme illustré sur la figure suivante. Cette propriété garantit que la surface est localement équivalente à un plan (ou un demi-plan au bord), ce qui est nécessaire pour la plupart des algorithmes de traitement de maillage.

Propriété de variété : chaque arête est partagée par au plus 2 faces.

Structure de données

L’encodage standard d’un maillage triangulaire en C++ utilise deux tableaux :

struct vec3 { float x, y, z; };
struct int3 { int i, j, k; };

std::vector<vec3> position;      // positions des sommets
std::vector<int3> connectivity;  // indices des faces (triplets)

L’utilisation de std::vector garantit la contiguïté mémoire, essentielle pour le transfert efficace vers le GPU.

Exemple : tétraèdre

std::vector<vec3> position = { {0,0,0}, {1,0,0}, {0,1,0}, {0,0,1} };
std::vector<int3> connectivity = { {0,1,2}, {0,1,3}, {0,2,3}, {1,2,3} };

Exemple : maillage plan

std::vector<vec3> position = { p0, p1, p2, p3, p4, p5, p6, p7, p8 };
std::vector<int3> connectivity = { {0,1,3}, {1,2,3}, {0,3,4}, {3,5,4},
                                   {2,5,3}, {2,6,5}, {5,6,7}, {5,7,8}, {4,5,8} };

Le schéma suivant montre la correspondance entre les indices et la géométrie du maillage.

Structure d’un maillage triangulaire : sommets et connectivité.

Remarque sur l’orientation : les triplets \((0, 1, 3)\), \((1, 3, 0)\) et \((3, 0, 1)\) sont équivalents (permutation cyclique). En revanche, \((0, 1, 3)\) a une orientation inverse à \((3, 1, 0)\), \((1, 0, 3)\) ou \((0, 3, 1)\). L’orientation détermine la direction de la normale et donc le sens de la face (voir chapitre précédent).

Flux de données CPU/GPU

Le flux de travail typique pour afficher un maillage avec OpenGL suit les étapes suivantes :

  1. Définir une variable mesh_drawable (partagée entre les méthodes).
  2. Initialiser une structure mesh contenant les tableaux de données en RAM (CPU).
  3. Transférer les données du mesh vers le mesh_drawable en VRAM (GPU).
  4. À chaque image :

Ce flux est résumé dans le diagramme ci-dessous.

Flux de données du CPU au GPU pour le rendu d’un maillage.

Attributs de sommets

En pratique, chaque sommet porte des attributs supplémentaires en plus de sa position : normales, coordonnées de texture, couleurs, etc.

std::vector<vec3> position     = { p_0, p_1, p_2, p_3 };
std::vector<vec3> normal       = { n_0, n_1, n_2, n_3 };
std::vector<vec3> color        = { c_0, c_1, c_2, c_3 };
std::vector<vec2> uv           = { uv_0, uv_1, uv_2, uv_3 };
std::vector<int3> connectivity = { {0,1,2}, {0,1,3}, {0,2,3}, {1,2,3} };

avec \(p_i = (x_i, y_i, z_i)\), \(n_i = (n_{x_i}, n_{y_i}, n_{z_i})\) avec \(\|n_i\| = 1\), et \(c_i = (r_i, g_i, b_i)\). La figure ci-après représente ces différents attributs sur un maillage.

Attributs de sommets : position, normale, couleur et coordonnées de texture.

Points importants :

Duplication de sommets

Parfois, il est nécessaire de dupliquer un sommet lorsqu’il occupe la même position mais avec des attributs différents selon la face considérée. Le cas le plus courant est celui des arêtes vives (sharp edges).

Considérons un sommet situé à une position \(p_A\) où deux groupes de faces se rencontrent à angle droit. Si l’on souhaite un rendu avec une arête nette (non lissée), il faut que ce sommet porte deux normales différentes, une pour chaque groupe de faces. Comme la table de connectivité est unique, on doit créer deux entrées distinctes dans le tableau de sommets, partageant la même position mais avec des normales différentes.

// Avant duplication : sommet v4 à la position pA avec une seule normale
std::vector<vec3> position = { p0, p1, p2, p3, pA, pB };
std::vector<vec3> normal   = { n0, n0, n1, n1, n2, n2 };

// Après duplication : v4 et v6 partagent pA mais avec des normales différentes
std::vector<vec3> position = { p0, p1, p2, p3, pA, pB, pA, pB };
std::vector<vec3> normal   = { n0, n0, n1, n1, n1, n1, n0, n0 };

Le principe est visible sur le schéma suivant.

Duplication de sommets pour les arêtes vives : même position, normales différentes.

Pour un cube, chaque sommet géométrique est partagé par 3 faces perpendiculaires. Comme chaque face nécessite une normale différente, chaque sommet est dupliqué 3 fois, soit \(8 \times 3 = 24\) sommets au lieu de 8.

Surfaces paramétriques et grilles

Un cas fréquent de maillage est la discrétisation de surfaces paramétriques.

Soit une surface \(S(u, v) = (S_x(u,v), \, S_y(u,v), \, S_z(u,v))\) avec \((u, v) \in [0, 1]^2\), échantillonnée uniformément sur une grille \(N_u \times N_v\), dont la structure est représentée ci-dessous.

Structure de grille pour une surface paramétrique.

La construction du maillage se fait en deux étapes :

Positions des sommets :

for(int ku = 0; ku < Nu; ku++) {
    for(int kv = 0; kv < Nv; kv++) {
        float u = ku / (Nu - 1.0f);
        float v = kv / (Nv - 1.0f);
        S.position[kv + Nv * ku] = { Sx(u,v), Sy(u,v), Sz(u,v) };
    }
}

Connectivité (deux triangles par cellule de la grille) :

for(int ku = 0; ku < Nu - 1; ku++) {
    for(int kv = 0; kv < Nv - 1; kv++) {
        unsigned int idx = kv + Nv * ku;
        uint3 triangle_1 = { idx, idx + 1 + Nv, idx + 1 };
        uint3 triangle_2 = { idx, idx + Nv, idx + 1 + Nv };
        S.connectivity.push_back(triangle_1);
        S.connectivity.push_back(triangle_2);
    }
}

Ce schéma s’applique à de nombreuses surfaces, comme le montrent les exemples suivants.

Exemples de surfaces paramétriques : cylindre (gauche) et terrain (droite).

Calcul des normales

Les normales ne sont pas toujours fournies (fichiers sans normales, maillages procéduraux, déformations en temps réel). Il est alors nécessaire de les calculer à partir de la géométrie.

La méthode standard consiste à calculer la moyenne non pondérée des normales des triangles voisins de chaque sommet :

  1. Pour chaque sommet \(i\), identifier les triangles voisins \(t \in \mathcal{N}_i\) (appelés le 1-voisinage ou 1-ring du sommet).
  2. Calculer la normale de chaque triangle voisin : \(n_t = (p_b - p_a) \times (p_c - p_a)\).
  3. Sommer et normaliser :

\[ n_i = \frac{\sum_{t \in \mathcal{N}_i} n_t}{\left\| \sum_{t \in \mathcal{N}_i} n_t \right\|} \]

Ce procédé est illustré ci-dessous.

Calcul de la normale d’un sommet par moyenne des normales des triangles voisins.

Implémentation :

std::vector<vec3> normal(position.size(), {0,0,0});

// Accumulation par triangle
for(int t = 0; t < connectivity.size(); t++) {
    int a = connectivity[t][0];
    int b = connectivity[t][1];
    int c = connectivity[t][2];

    vec3 n = cross(position[b] - position[a], position[c] - position[a]);
    n = normalize(n);

    normal[a] += n;
    normal[b] += n;
    normal[c] += n;
}

// Normalisation finale
for(int k = 0; k < normal.size(); k++) {
    normal[k] = normalize(normal[k]);
}

Si des sommets sont dupliqués (arêtes vives), les normales calculées seront naturellement différentes pour chaque copie, car elles ne partagent pas les mêmes triangles voisins, comme on peut le voir sur la figure suivante.

Effet de la duplication de sommets sur les normales calculées.

1-voisinage (1-ring)

Le 1-ring d’un sommet est l’ensemble des triangles qui partagent ce sommet. Cette structure de voisinage est utile pour de nombreux algorithmes (calcul de normales, lissage, subdivision, etc.).

std::vector<std::vector<int>> one_ring;
one_ring.resize(position.size());
for(int k_tri = 0; k_tri < connectivity.size(); k_tri++) {
    for(int k = 0; k < 3; k++) {
        one_ring[connectivity[k_tri][k]].push_back(k_tri);
    }
}

Par exemple :

one_ring[235] = {842, 120, 108, 110, 20, 114};
one_ring[236] = {108, 112, 851, 850, 120, 722};

La figure ci-dessous visualise cette notion de voisinage.

Visualisation du 1-ring : l’ensemble des triangles voisins d’un sommet.

Structure en demi-arête (half-edge)

La structure en demi-arête (half-edge) est une structure de données plus riche que la simple table de connectivité. Elle encode les arêtes de manière orientée : chaque arête est représentée par deux demi-arêtes de directions opposées, comme le montre le schéma ci-après. Les faces apparaissent comme des boucles le long des demi-arêtes.

Structure en demi-arête : chaque arête orientée pointe vers son opposée.

Avantages :

Angles aux coins des triangles, utiles pour les pondérations cotangentes.

Limitations :

Exemple avec CGAL :

typedef CGAL::Cartesian<double> Kernel;
typedef CGAL::Polyhedron_3<Kernel> Polyhedron;

int main() {
    Polyhedron mesh;
    std::ifstream stream("mesh.off");
    stream >> mesh;

    int face_number = 0;
    for(auto it_face = mesh.facets_begin();
        it_face != mesh.facets_end(); ++it_face)
    {
        auto halfedge = it_face->halfedge();
        auto const halfedge_end = halfedge;
        do {
            const auto p = halfedge->vertex()->point();
            std::cout << p << std::endl;
            halfedge = halfedge->next();
        } while(halfedge != halfedge_end);
        face_number++;
    }
}

Textures

Principe de l’UV-mapping

L’objectif des textures est de fournir un niveau de détail en couleur plus fin que la géométrie. Un triangle peut avoir une couleur uniforme ou interpolée entre ses sommets, mais cela reste limité. Les textures permettent de plaquer une image 2D détaillée sur la surface 3D.

Le cas classique est l’UV-mapping : une image 2D (la texture) est associée à la surface 3D via des coordonnées de texture \((u, v)\) (aussi notées \((s, t)\)).

Le mécanisme est illustré ci-dessous.

Principe de l'UV-mapping : association entre coordonnées 2D de la texture et surface 3D.

La convention est que \((0, 0)\) correspond au coin inférieur gauche de l’image et \((1, 1)\) au coin supérieur droit.

Dépliage UV

Pour des modèles complexes, le dépliage UV se fait par morceaux (patches ou îlots). Le processus comprend les étapes suivantes :

  1. Découpage en patches : la surface est découpée le long de coutures (seams) choisies par l’artiste ou un algorithme automatique. Les coutures sont placées dans des zones peu visibles (plis, arrière de l’objet).

  2. Dépliage : chaque patch est déplié dans le plan 2D en minimisant les déformations. Des algorithmes classiques incluent ABF (Angle-Based Flattening), LSCM (Least Squares Conformal Maps) et Slim (Scalable Locally Injective Mappings).

  3. Packing : les patches dépliés sont disposés dans l’espace \([0, 1]^2\) en maximisant l’occupation de l’espace (pour éviter de gaspiller la résolution de texture).

  4. Peinture : l’artiste peint la texture directement dans l’espace 2D déplié, ou utilise des outils de peinture 3D (comme Substance Painter) qui projettent directement sur la surface.

Les coutures entre les patches correspondent aux endroits où la texture peut présenter des discontinuités (le sommet à la couture est dupliqué avec des coordonnées UV différentes de chaque côté). Un exemple de dépliage est visible ci-dessous.

Dépliage UV en pratique : la surface est découpée en patches disposés dans l’espace texture.

Déformations et limites

L’UV-mapping consiste à “déplier” la surface 3D sur un plan 2D. Ce dépliage introduit nécessairement des déformations en longueur et en angle pour toute surface dont la courbure de Gauss est non nulle.

En particulier, il est impossible de plaquer une image plane sur une sphère sans déformation. C’est un résultat fondamental de géométrie différentielle : la courbure de Gauss est un invariant intrinsèque de la surface (theorema egregium de Gauss). Une sphère a une courbure de Gauss positive constante (\(K = 1/R^2\)), tandis qu’un plan a une courbure nulle (\(K = 0\)). Aucune déformation sans étirement ne peut transformer l’une en l’autre.

Seules les surfaces développables (courbure de Gauss nulle, comme les cylindres et les cônes) peuvent être dépliées sans distorsion. En pratique, on cherche à minimiser les déformations lors du dépliage UV, en acceptant un compromis entre la distorsion des angles (conforme) et la distorsion des aires (authalique). La figure suivante montre un cas typique de déformation sur une sphère.

Déformation lors du placage d'une texture sur une sphère : étirement aux pôles.

Types de textures

Au-delà de la couleur (diffuse map), les textures sont utilisées pour stocker de nombreux types d’informations sur la surface :

Textures en OpenGL

Mise en place

Les coordonnées de texture sont stockées comme attribut de sommet, au même titre que les positions et les normales :

std::vector<vec2> uv = { uv_0, uv_1, uv_2, ... };

Côté C++/OpenGL, la texture est chargée et envoyée au GPU :

// Chargement de l'image
int width, height, channels;
unsigned char* data = stbi_load("texture.png", &width, &height, &channels, 4);

// Création de la texture OpenGL
GLuint texture_id;
glGenTextures(1, &texture_id);
glBindTexture(GL_TEXTURE_2D, texture_id);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0,
             GL_RGBA, GL_UNSIGNED_BYTE, data);

// Activation dans le fragment shader (unité de texture 0)
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture_id);
glUniform1i(glGetUniformLocation(shader, "texture_image"), 0);

Interpolation et échantillonnage

Lors du rendu, le fragment shader reçoit les coordonnées de texture \((u, v)\) interpolées de manière barycentrique entre les trois sommets du triangle courant. Il lit ensuite la couleur dans l’image de texture à cette position grâce à la fonction texture() :

uniform sampler2D texture_image;  // la texture, envoyée depuis le CPU

in vec2 fragment_uv;              // coordonnées interpolées
out vec4 FragColor;

void main() {
    vec4 color = texture(texture_image, fragment_uv);
    FragColor = color;
}

La variable sampler2D représente une référence vers une texture stockée en VRAM. La fonction texture(sampler, uv) réalise l’échantillonnage : elle convertit les coordonnées normalisées \((u, v) \in [0, 1]^2\) en coordonnées pixel dans l’image, puis renvoie la couleur correspondante.

Filtrage de texture

Les coordonnées \((u, v)\) interpolées tombent rarement exactement sur le centre d’un pixel de la texture (appelé texel). Le GPU doit donc interpoler entre les texels voisins. Ce processus est appelé filtrage de texture. Il intervient dans deux cas :

Les modes de filtrage les plus courants sont :

Configuration en OpenGL :

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);

Mipmaps

Les mipmaps sont des versions pré-calculées de la texture à résolutions décroissantes (chaque niveau divise la résolution par 2). Lors de la minification, le GPU choisit le niveau de mipmap dont la résolution est la plus adaptée à la taille du triangle à l’écran, puis interpole si nécessaire.

Pour une texture de \(1024 \times 1024\), la chaîne de mipmaps contient les niveaux : \(1024^2\), \(512^2\), \(256^2\), …, \(2^2\), \(1^2\) (soit 11 niveaux). L’espace mémoire total est seulement \(\frac{4}{3}\) de la texture originale.

Les mipmaps réduisent l’aliasing (scintillement des textures vues de loin) et améliorent les performances (les textures lointaines sont lues dans des niveaux de résolution faible, mieux exploités par le cache mémoire du GPU).

Génération automatique en OpenGL :

glGenerateMipmap(GL_TEXTURE_2D);

Modes de répétition (Wrapping)

Lorsque les coordonnées de texture sortent de l’intervalle \([0, 1]\), le comportement dépend du mode de wrapping :

Configuration en OpenGL :

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);

Multiples textures dans le fragment shader

Toutes les textures (couleur, normales, spéculaire, etc.) partagent les mêmes coordonnées UV et utilisent le même mécanisme d’échantillonnage. Elles sont simplement lues dans des sampler2D différents dans le fragment shader :

uniform sampler2D diffuse_map;
uniform sampler2D normal_map;
uniform sampler2D specular_map;

void main() {
    vec3 color    = texture(diffuse_map,  fragment.uv).rgb;
    vec3 normal   = texture(normal_map,   fragment.uv).rgb * 2.0 - 1.0;
    float spec    = texture(specular_map, fragment.uv).r;
    // ...
}

Effets avancés de textures

Skybox

Une skybox est une boîte texturée représentant l’environnement distant (ciel, paysage). Elle est toujours centrée sur la position de la caméra, ce qui donne l’impression d’un paysage infini, comme on le voit ci-dessous.

Skybox : boîte texturée simulant un environnement infini.

Le principe d’implémentation est le suivant :

  1. Afficher la skybox en premier, en désactivant l’écriture dans le depth buffer.
  2. Afficher ensuite tous les autres objets de la scène (qui apparaissent toujours devant la skybox).
void display() {
    glDepthMask(GL_FALSE);   // désactiver l'écriture dans le depth buffer
    draw(skybox, environment);

    glDepthMask(GL_TRUE);    // réactiver l'écriture
    // dessiner les autres objets ...
}

Environment mapping

L’environment mapping simule la réflexion de l’environnement (skybox) sur une surface brillante. Cela donne l’impression que l’objet reflète le monde autour de lui.

Le principe, schématisé ci-dessous, est de calculer, pour chaque fragment, la direction de réflexion du vecteur vue par rapport à la normale, puis de lire la couleur correspondante dans la texture de la skybox.

Environment mapping : réflexion de la skybox sur une surface.
vec3 V = normalize(camera_position - fragment.position);
vec3 R_skybox = reflect(-V, N);
vec4 color_env = texture(image_skybox, R_skybox);

Normal mapping

Le normal mapping consiste à modifier les normales de la surface à l’aide d’une image (la normal map) pour simuler des détails géométriques plus fins que les triangles du maillage. La géométrie réelle du triangle ne change pas : seule la normale utilisée dans le calcul d’illumination est modifiée, ce qui donne l’illusion de relief visible sur la comparaison suivante.

Normal mapping : la géométrie reste plane (gauche) mais l’illumination simule du relief (droite).
Espace tangent

Les normales d’une normal map ne sont pas stockées en coordonnées monde (qui dépendraient de l’orientation de l’objet), mais dans un repère local au triangle appelé espace tangent \((T, B, N)\) :

Ce repère est défini par les coordonnées de texture \((u, v)\) et les positions des sommets du triangle :

\[ \begin{cases} p_0 - p_1 = (u_0 - u_1) \, T + (v_0 - v_1) \, B \\ p_2 - p_1 = (u_2 - u_1) \, T + (v_2 - v_1) \, B \\ N = T \times B \end{cases} \]

Ce système de deux équations vectorielles (soit 6 équations scalaires) permet de résoudre \(T\) et \(B\) (6 inconnues). En notation matricielle, pour les composantes \(x\) par exemple :

\[ \begin{pmatrix} T_x \\ B_x \end{pmatrix} = \frac{1}{(u_0 - u_1)(v_2 - v_1) - (u_2 - u_1)(v_0 - v_1)} \begin{pmatrix} v_2 - v_1 & -(v_0 - v_1) \\ -(u_2 - u_1) & u_0 - u_1 \end{pmatrix} \begin{pmatrix} (p_0 - p_1)_x \\ (p_2 - p_1)_x \end{pmatrix} \]

et de même pour les composantes \(y\) et \(z\). Les vecteurs \(T\) et \(B\) sont ensuite normalisés.

Encodage de la normal map

Chaque pixel de la normal map stocke une normale perturbée en composantes \((r, g, b) \in [0, 1]^3\), encodant les composantes dans l’espace tangent sur \([-1, 1]\) :

\[ n_{\text{tangent}} = 2 \begin{pmatrix} r \\ g \\ b \end{pmatrix} - \begin{pmatrix} 1 \\ 1 \\ 1 \end{pmatrix} \]

La composante \(b\) (bleue) correspond à la direction de la normale géométrique \(N\). C’est pourquoi les normal maps apparaissent bleutées : la plupart des normales sont proches de \((0, 0, 1)\) dans l’espace tangent, ce qui donne \((0.5, 0.5, 1.0)\) en RGB. On peut observer cet encodage sur les images ci-dessous.

Normal map : les normales sont encodées en couleur dans l'espace tangent (gauche). Application sur un mur en briques (droite).

La normale finale en coordonnées monde est obtenue par changement de base :

\[ n_{\text{world}} = \text{TBN} \times n_{\text{tangent}} = T \cdot n_x + B \cdot n_y + N \cdot n_z \]

\(\text{TBN} = (T \mid B \mid N)\) est la matrice \(3 \times 3\) dont les colonnes sont les vecteurs du repère tangent.

Implémentation GLSL

Vertex shader : calcul du repère tangent et passage au fragment shader.

layout(location = 0) in vec3 vertex_position;
layout(location = 1) in vec3 vertex_normal;
layout(location = 2) in vec2 vertex_uv;
layout(location = 3) in vec3 vertex_tangent;

out struct fragment_data {
    vec3 position;
    vec2 uv;
    mat3 TBN;
} fragment;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main() {
    vec4 pos_world = model * vec4(vertex_position, 1.0);
    mat3 normalMatrix = mat3(transpose(inverse(model)));

    vec3 T = normalize(normalMatrix * vertex_tangent);
    vec3 N = normalize(normalMatrix * vertex_normal);
    vec3 B = cross(N, T);

    fragment.position = pos_world.xyz;
    fragment.uv = vertex_uv;
    fragment.TBN = mat3(T, B, N);

    gl_Position = projection * view * pos_world;
}

Fragment shader : lecture de la normal map et calcul de l’illumination.

in struct fragment_data {
    vec3 position;
    vec2 uv;
    mat3 TBN;
} fragment;

uniform sampler2D normal_map;
uniform vec3 light_position;

out vec4 FragColor;

void main() {
    // Lire la normale dans la normal map et la convertir de [0,1] à [-1,1]
    vec3 n_tangent = texture(normal_map, fragment.uv).rgb * 2.0 - 1.0;

    // Transformer vers l'espace monde
    vec3 N = normalize(fragment.TBN * n_tangent);

    // Illumination avec la normale perturbée
    vec3 L = normalize(light_position - fragment.position);
    float diffuse = max(dot(N, L), 0.0);

    // ...
}

L’avantage de l’espace tangent est que la même normal map peut être réutilisée sur différents objets ou différentes parties d’un même objet, indépendamment de leur orientation dans le monde.

Parallax mapping

Le parallax mapping va plus loin que le normal mapping en déformant les coordonnées de texture elles-mêmes en fonction d’une carte de hauteur (height map). Alors que le normal mapping ne modifie que l’illumination (la silhouette reste plate), le parallax mapping crée une impression de décalage géométrique : les zones hautes semblent avancer et les zones basses reculer, en particulier lorsque l’on observe la surface sous un angle rasant.

Principe géométrique

Considérons un fragment sur une surface plane. Sans parallax mapping, on lirait la texture aux coordonnées \((u, v)\) de ce fragment. Mais si la surface avait réellement du relief, le rayon de vue aurait intersecté la surface à un point différent, décalé horizontalement. Le parallax mapping approxime ce décalage, comme le montre le schéma ci-dessous.

Parallax mapping : le rayon de vue (en rouge) devrait intersecter la surface en relief au point B, mais le fragment est en A. On décale les coordonnées de texture pour lire au point B.

Soit \(V\) le vecteur de vue exprimé dans l’espace tangent et \(h\) la hauteur lue dans la height map au point courant. Le décalage des coordonnées de texture est :

\[ (u', v') = (u, v) + h \cdot \frac{(V_x, V_y)}{V_z} \]

La division par \(V_z\) amplifie le décalage aux angles rasants (quand \(V_z\) est petit), ce qui correspond au comportement physique attendu : la parallaxe est plus visible sous un angle oblique.

Implémentation GLSL

Fragment shader avec parallax mapping :

in struct fragment_data {
    vec3 position;
    vec2 uv;
    mat3 TBN;
} fragment;

uniform sampler2D diffuse_map;
uniform sampler2D height_map;
uniform sampler2D normal_map;
uniform vec3 camera_position;
uniform float height_scale;  // contrôle l'amplitude du relief (~0.05-0.1)

out vec4 FragColor;

void main() {
    // Vecteur de vue dans l'espace tangent
    vec3 V_world = normalize(camera_position - fragment.position);
    vec3 V_tangent = normalize(transpose(fragment.TBN) * V_world);

    // Lire la hauteur et calculer le décalage
    float h = texture(height_map, fragment.uv).r;
    vec2 uv_offset = h * height_scale * V_tangent.xy / V_tangent.z;
    vec2 uv_parallax = fragment.uv + uv_offset;

    // Utiliser les coordonnées décalées pour la texture et la normal map
    vec3 color = texture(diffuse_map, uv_parallax).rgb;
    vec3 n_tangent = texture(normal_map, uv_parallax).rgb * 2.0 - 1.0;
    vec3 N = normalize(fragment.TBN * n_tangent);

    // Illumination avec la normale et la couleur décalées
    // ...
}
Steep Parallax Mapping

Le parallax mapping simple est une approximation du premier ordre qui fonctionne bien pour des reliefs faibles. Pour des reliefs plus prononcés, il produit des artefacts (glissements, distorsions). Le Steep Parallax Mapping (ou Parallax Occlusion Mapping) améliore la qualité en parcourant le rayon de vue pas à pas à travers la carte de hauteur :

  1. Discrétiser le rayon de vue en \(N\) étapes dans l’espace tangent.
  2. À chaque étape, comparer la hauteur courante du rayon avec la hauteur lue dans la height map.
  3. S’arrêter lorsque le rayon passe en dessous de la surface (la hauteur du rayon devient inférieure à la hauteur de la height map).
  4. Interpoler entre les deux dernières étapes pour obtenir un point d’intersection précis.
vec2 steep_parallax(vec2 uv, vec3 V_tangent, float height_scale) {
    const int num_steps = 32;
    float step_size = 1.0 / float(num_steps);
    float current_depth = 0.0;
    vec2 delta_uv = V_tangent.xy / V_tangent.z * height_scale / float(num_steps);

    vec2 current_uv = uv;
    float current_height = texture(height_map, current_uv).r;

    for(int i = 0; i < num_steps; i++) {
        if(current_depth >= current_height) break;
        current_uv += delta_uv;
        current_height = texture(height_map, current_uv).r;
        current_depth += step_size;
    }

    return current_uv;
}

Cette méthode est plus coûteuse (une lecture de texture par étape), mais produit des résultats visuellement convaincants même pour des reliefs importants, avec une gestion correcte de l’auto-occultation des détails du relief.

Particules et Bruit Procédural

Systèmes de particules

Principe

De nombreux phénomènes visuels — fumée, feu, eau qui jaillit, explosions, étincelles, neige — ne possèdent pas de surface nette et bien définie. Contrairement à un objet solide (un personnage, un bâtiment), ces effets sont diffus : ils sont composés d’un grand nombre d’éléments minuscules qui évoluent de manière partiellement aléatoire. Tenter de les modéliser par un maillage classique serait à la fois coûteux et inadapté, car leur forme change constamment et ne correspond pas à une surface géométrique stable.

Un système de particules répond à ce besoin. Le concept, introduit par William Reeves en 1983 pour les effets du film Star Trek II, repose sur une idée simple : modéliser ces phénomènes comme un ensemble d’éléments indépendants (les particules), chacun suivant une règle de comportement simple. C’est le comportement collectif de milliers de particules qui produit l’effet visuel recherché.

Chaque particule est caractérisée par :

Le cycle de vie d’une particule suit quatre phases. L’émission : la particule est créée à une position initiale avec une vitesse et des attributs initiaux (éventuellement aléatoires). La simulation : à chaque pas de temps, sa position est mise à jour selon une équation de mouvement, et ses attributs peuvent évoluer (par exemple, la transparence augmente progressivement pour simuler la dissipation de la fumée). La mort : lorsque la durée de vie est écoulée, la particule est désactivée. Le recyclage : en pratique, on pré-alloue un tableau de particules de taille fixe. Lorsqu’une particule meurt, son emplacement est réutilisé pour une nouvelle particule, évitant les allocations dynamiques coûteuses.

La représentation géométrique la plus simple est la sphère : chaque particule est affichée comme une petite sphère dans la scène 3D. Cette approche est intuitive mais coûteuse en géométrie pour un grand nombre de particules. Pour des effets plus détaillés et plus performants, on utilise des billboards (voir section suivante).

Trajectoire et équation du mouvement

Le cas le plus courant est la chute libre sous l’effet de la gravité. En physique, la deuxième loi de Newton donne l’accélération \(a = g\) (constante), ce qui, par double intégration, fournit l’équation du mouvement :

\[ p(t) = \frac{1}{2} \, g \, t^2 + v_0 \, t + p_0 \]

avec :

Cette équation décrit une parabole : la particule monte (si \(v_0\) a une composante verticale positive), ralentit sous l’effet de la gravité, puis retombe. C’est exactement la trajectoire d’un projectile.

Pour créer un effet de fontaine ou de jet, on introduit de la randomisation dans les conditions initiales. C’est cette variabilité entre les particules qui donne un aspect naturel à l’ensemble : si toutes les particules avaient exactement les mêmes paramètres, elles suivraient toutes la même trajectoire et l’effet serait visuellement pauvre.

// Exemple : émission d'une particule
vec3 p0 = emitter_position + rand_vec3(-0.1f, 0.1f);
vec3 v0 = vec3(0, 5, 0) + rand_vec3(-1.0f, 1.0f);
float lifetime = rand(1.0f, 3.0f);

La vitesse se déduit par dérivation :

\[ v(t) = g \, t + v_0 \]

À chaque pas de temps, les particules dont la durée de vie est écoulée sont supprimées, et de nouvelles particules sont émises. En pratique, l’émetteur produit un flux constant (par exemple 100 particules par seconde), ce qui maintient un nombre stable de particules actives dans la scène.

Rebonds

Lorsqu’une particule rencontre un obstacle (par exemple le sol à \(y = 0\)), il est possible de simuler un rebond. L’idée est de détecter l’instant exact de la collision, puis de redémarrer la simulation avec une vitesse modifiée. Le principe est le suivant :

  1. Détection de l’impact : déterminer le temps \(t_i\) auquel la particule atteint le sol. Pour un sol horizontal à \(y = 0\), on résout :

\[ p_y(t_i) = \frac{1}{2} g_y \, t_i^2 + v_{0,y} \, t_i + p_{0,y} = 0 \]

  1. Calcul de la vitesse à l’impact :

\[ v(t_i) = g \, t_i + v_0 \]

  1. Inversion de la composante normale : après le rebond, la composante de la vitesse perpendiculaire à la surface est inversée et atténuée par un coefficient de restitution \(\epsilon \in [0, 1]\) :

\[ v_y^{\text{après}} = -\epsilon \, v_y(t_i) \]

Le coefficient \(\epsilon\) contrôle le comportement du rebond : \(\epsilon = 1\) correspond à un rebond parfaitement élastique (la particule repart avec la même énergie), tandis que \(\epsilon = 0\) signifie que la particule est totalement absorbée par la surface (elle s’arrête). En pratique, on utilise des valeurs intermédiaires (\(\epsilon \approx 0.6\text{-}0.8\)) pour obtenir un effet réaliste.

La particule repart alors avec cette nouvelle vitesse comme condition initiale, produisant une trajectoire par morceaux. En répétant ce processus, on obtient une séquence de rebonds d’amplitude décroissante jusqu’à ce que la particule s’immobilise ou que sa durée de vie expire.

Ce principe se généralise à des obstacles quelconques : pour un plan oblique défini par sa normale \(\hat{n}\), la composante inversée est la projection de la vitesse sur cette normale : \(v^{\text{après}} = v(t_i) - (1 + \epsilon)(v(t_i) \cdot \hat{n})\,\hat{n}\).

Mouvements génériques

Les trajectoires ne sont pas nécessairement contraintes par la physique. En informatique graphique, l’objectif est souvent de produire un effet visuel convaincant plutôt qu’une simulation fidèle. On peut donc définir des mouvements procéduraux arbitraires, guidés par l’esthétique plutôt que par les lois de Newton.

Quelques exemples classiques :

La différence avec la simulation physique est la liberté artistique : le créateur peut ajuster les paramètres de mouvement pour obtenir exactement l’effet souhaité, sans être contraint par le réalisme. Un vortex de particules dans un jeu vidéo n’a pas besoin de respecter les équations de Navier-Stokes pour être visuellement convaincant.

Billboards et imposteurs

Principe des billboards

Afficher chaque particule comme une sphère 3D est coûteux : une sphère lisse nécessite des centaines de triangles, et un système de 10 000 particules représenterait alors des millions de triangles. Plutôt que de dessiner de la géométrie 3D, on utilise des billboards (ou imposteurs) : des quadrangles texturés qui font toujours face à la caméra.

L’idée est de tromper l’oeil : puisqu’un quadrangle orienté face à la caméra apparaît toujours de face (sans perspective révélatrice), il suffit d’y appliquer une texture réaliste pour donner l’illusion d’un objet plus complexe. Le principe repose sur trois éléments :

  1. Un quadrangle (deux triangles formant un rectangle) est placé à la position de chaque particule.
  2. Une texture avec canal alpha est appliquée : les zones transparentes (\(\alpha = 0\)) de la texture rendent les parties correspondantes du quadrangle invisibles, ne laissant apparaître que la forme souhaitée (flamme, nuage, feuillage, etc.). L’oeil ne perçoit pas le rectangle sous-jacent, seulement la silhouette définie par la texture.
  3. L’orientation face caméra : le quadrangle est constamment réorienté pour faire face à la caméra, quelle que soit la direction de vue. C’est cette propriété qui définit un billboard. En pratique, on aligne le plan du quadrangle perpendiculairement au vecteur vue \(V = \text{normalize}(\text{camera\_position} - \text{particle\_position})\), en utilisant les colonnes de la matrice de vue pour construire le repère local du billboard.

Billboards : texture de fumée avec canal alpha (gauche), arbre composé de quadrangles texturés avec ses textures de feuillage (centre), effet de feu obtenu par accumulation de billboards (droite).

On distingue deux types de billboards selon leur axe de liberté :

Plusieurs extensions du concept existent :

Utilisation en production

Les billboards sont très courants en production pour le rendu de :

L’avantage principal est le rapport qualité/coût : un billboard ne nécessite que 4 sommets (2 triangles) par particule, contre des centaines pour une géométrie 3D complète. Pour un système de 10 000 particules, cela représente 20 000 triangles au lieu de plusieurs millions.

Transparence en OpenGL

Le rendu des billboards repose sur la transparence, qui est un problème fondamentalement difficile en rastérisation. En ray tracing, la transparence est naturelle : un rayon qui traverse un objet semi-transparent continue son chemin et accumule les couleurs des objets rencontrés. En rastérisation, en revanche, chaque triangle est dessiné indépendamment dans le framebuffer, sans connaissance des triangles qui seront dessinés ensuite. La gestion de la transparence nécessite donc des mécanismes explicites. Il n’existe pas de solution parfaite, mais plusieurs approches complémentaires.

Activation du blending

La transparence en OpenGL fonctionne par mélange de couleurs (blending) : la couleur du fragment courant est combinée avec la couleur déjà présente dans le framebuffer, en utilisant le canal alpha (\(\alpha\)) comme facteur de pondération.

L’activation se fait explicitement :

glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
// ... appels de dessin des objets transparents ...
glDisable(GL_BLEND);

La formule de mélange résultante est :

\[ C_{\text{new}} = \alpha \cdot C_{\text{current}} + (1 - \alpha) \cdot C_{\text{previous}} \]

avec :

Principe du blending : mélange de la couleur source avec la couleur déjà présente.

Point important : l’ordre de dessin compte. La couleur \(C_{\text{previous}}\) dépend de ce qui a été dessiné avant le fragment courant. Si deux objets transparents se superposent, le résultat dépend de l’ordre dans lequel ils ont été rendus. C’est une différence majeure avec les objets opaques, pour lesquels le depth buffer garantit un résultat correct quel que soit l’ordre de dessin.

Il existe d’autres modes de blending, définis par des combinaisons différentes de glBlendFunc. Par exemple, le blending additif (GL_SRC_ALPHA, GL_ONE) additionne les couleurs sans atténuer la destination : \(C_{\text{new}} = \alpha \cdot C_{\text{current}} + C_{\text{previous}}\). Ce mode est particulièrement adapté aux effets lumineux (feu, étincelles, lasers), car les couleurs s’accumulent et produisent des zones de forte luminosité.

Algorithme de rendu transparent

Pour obtenir un rendu correct des objets transparents, il faut respecter une contrainte fondamentale : les objets transparents doivent être dessinés du plus loin au plus proche (back-to-front). En effet, la formule de blending mélange la couleur du fragment courant avec ce qui est déjà dans le framebuffer. Pour que le résultat soit physiquement cohérent (un objet rouge semi-transparent devant un objet bleu), il faut que l’objet bleu (le plus loin) soit déjà dans le framebuffer quand on dessine l’objet rouge (le plus proche).

L’approche standard suit ces étapes :

  1. Dessiner tous les objets opaques normalement (avec le depth buffer activé en lecture et écriture). Cette étape remplit le depth buffer, ce qui empêchera les objets transparents d’apparaître devant les objets opaques qui les occultent.

  2. Activer le blending et désactiver l’écriture dans le depth buffer (mais pas la lecture) :

glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glDepthMask(false);  // désactiver l'écriture dans le depth buffer
  1. Trier les objets transparents par profondeur par rapport à la caméra, du plus éloigné au plus proche :
// Calcul de la profondeur pour le tri
vec4 p_proj = projection * modelview * vec4(position, 1.0);
float z_depth = p_proj.z / p_proj.w;
  1. Dessiner les objets transparents dans l’ordre trié (du plus loin au plus proche).

  2. Rétablir l’état :

glDisable(GL_BLEND);
glDepthMask(true);

Le code complet de l’algorithme :

// 1. Objets opaques
draw_opaque_objects();

// 2-3. Objets transparents
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glDepthMask(false);

sort_by_depth(transparent_objects, camera_position);  // du plus loin au plus proche

for(auto& obj : transparent_objects) {
    draw(obj);
}

glDisable(GL_BLEND);
glDepthMask(true);

La désactivation de l’écriture dans le depth buffer est essentielle : un objet transparent ne doit pas masquer les objets situés derrière lui. En revanche, la lecture du depth buffer reste active, ce qui empêche les objets transparents d’être dessinés devant les objets opaques qui les occultent.

Erreurs courantes

Deux erreurs fréquentes lors du rendu transparent :

Erreur 1 : depth buffer en écriture sans tri. Si l’écriture dans le depth buffer reste activée et que les objets ne sont pas triés, un objet transparent proche peut écrire dans le depth buffer et empêcher le rendu des objets situés derrière lui. Le depth buffer “bloque” les fragments plus éloignés, même si l’objet au premier plan est semi-transparent et devrait laisser voir ce qui se trouve derrière. Le résultat est des artefacts visuels marqués : des morceaux d’objets disparaissent de manière incohérente, avec des “trous” dans la scène qui dépendent de l’ordre de soumission des triangles au GPU.

Erreur 2 : pas de tri, depth buffer en écriture désactivée. Sans tri mais avec l’écriture depth buffer désactivée, les objets transparents sont mélangés dans un ordre arbitraire. Pour des objets dont les couleurs sont proches et les formes diffuses (fumée, nuages, brouillard), l’ordre de mélange a peu d’impact visuel et le résultat est approximativement acceptable. Cette approche est parfois utilisée en pratique pour éviter le coût du tri — qui peut être significatif pour des milliers de particules — quand la qualité visuelle n’a pas besoin d’être parfaite. En revanche, pour des objets transparents aux couleurs vives et contrastées (vitraux, cristaux), l’absence de tri produit des artefacts visibles.

Discard dans le fragment shader

Pour les billboards dont la texture est soit entièrement opaque, soit entièrement transparente (pas de semi-transparence), une approche plus simple existe : le discard dans le fragment shader.

Le mot-clé discard arrête l’exécution du fragment shader : rien n’est écrit dans le framebuffer ni dans le depth buffer pour ce fragment. Il suffit de tester la valeur alpha de la texture :

uniform sampler2D image_texture;
in vec2 uv;
out vec4 FragColor;

void main() {
    vec4 color = texture(image_texture, uv);
    float alpha_limit = 0.6;

    if(color.a < alpha_limit) {
        discard;  // fragment entièrement transparent : on l'ignore
    }

    FragColor = color;
}

Avantages :

Inconvénients :

Instancing GPU

Principe

Un système de particules peut contenir des milliers, voire des centaines de milliers d’éléments partageant la même géométrie (par exemple un quadrangle pour un billboard) mais avec des paramètres différents (position, couleur, taille). L’approche naïve consiste à appeler glDrawElements pour chaque particule :

for(int k = 0; k < N; ++k) {
    update_uniforms(k);   // position, couleur, taille...
    draw(shape);          // glDrawElements(...)
}

Cette boucle est très inefficace. Pour comprendre pourquoi, il faut considérer l’architecture CPU/GPU : chaque appel à glDrawElements ne se contente pas de lancer le rendu. Il provoque une synchronisation entre le CPU et le GPU : le driver OpenGL doit valider l’état courant (shader actif, uniforms, textures, buffers), préparer la commande, et l’envoyer au GPU. Cette surcharge par appel est à peu près constante, quelle que soit la taille de la géométrie. Le graphique ci-dessous, issu de la documentation NVIDIA, illustre le coût relatif des différents changements d’état : même les opérations les moins coûteuses (mise à jour d’uniforms) sont limitées à environ 10 millions par seconde.

Coût relatif des changements d’état OpenGL (source : NVIDIA). Les mises à jour d’uniforms sont les moins coûteuses, mais restent limitées à environ 10M/s.

Pour 15 000 particules composées de 50 triangles chacune, cela représente 15 000 appels de dessin et 750 000 sommets, mais c’est le nombre d’appels (et non le nombre de sommets) qui constitue le goulot d’étranglement côté CPU.

L’instancing résout ce problème en remplaçant les \(N\) appels de dessin par un unique appel qui demande au GPU de dessiner \(N\) copies de la même géométrie :

drawInstanced(shape, N);  // glDrawElementsInstanced(..., N)

Implémentation

L’instancing repose sur deux éléments :

Côté CPU : remplacement de glDrawElements par glDrawElementsInstanced :

// Au lieu de :
// glDrawElements(GL_TRIANGLES, count, GL_UNSIGNED_INT, 0);

// On utilise :
glDrawElementsInstanced(GL_TRIANGLES, count, GL_UNSIGNED_INT, 0, N);

\(N\) est le nombre d’instances (particules) à dessiner.

Côté shader : la variable built-in gl_InstanceID permet de différencier chaque instance. Elle contient l’indice de l’instance courante (de 0 à \(N-1\)) et permet de lire les paramètres spécifiques à chaque particule :

// Vertex shader
uniform vec3 positions[MAX_PARTICLES];  // ou via un SSBO / texture
uniform vec3 colors[MAX_PARTICLES];

void main() {
    vec3 offset = positions[gl_InstanceID];
    vec3 color  = colors[gl_InstanceID];

    gl_Position = projection * view * vec4(vertex_position + offset, 1.0);
    // ...
}

Les gains de performance sont considérables. À titre d’exemple :

Le gain est important car l’instancing élimine le goulot d’étranglement CPU : au lieu de 15 000 aller-retours CPU→GPU, un seul suffit, et le GPU — dont l’architecture est conçue pour le traitement massivement parallèle — peut traiter les millions de sommets résultants sans difficulté.

L’instancing est la technique standard pour tout rendu nécessitant un grand nombre de copies d’une même géométrie : particules, végétation (herbe, arbres), foules, astéroïdes, etc. Pour des cas encore plus avancés (nombre d’instances variable, paramètres complexes), on peut remplacer les tableaux d’uniformes par des Shader Storage Buffer Objects (SSBOs) ou des textures de données, qui ne sont pas limités en taille comme les tableaux d’uniformes.

Bruit procédural

Définition

En informatique graphique, la création de formes naturelles (terrains, nuages, textures de pierre, de bois, d’eau) pose un défi : ces formes sont trop complexes et irrégulières pour être modélisées manuellement, mais elles ne sont pas non plus purement aléatoires — elles possèdent une structure reconnaissable. Un amas de bruit blanc (valeurs aléatoires indépendantes à chaque pixel) ne ressemble à rien de naturel ; une texture peinte à la main est coûteuse à produire et limitée en résolution.

Le bruit procédural répond à ce besoin : une fonction mathématique qui génère un motif pseudo-aléatoire “à la demande”, avec des propriétés contrôlées :

Les bruits procéduraux les plus courants sont illustrés ci-dessous.

Exemples de bruits procéduraux : Perlin (gauche), Worley (centre), Flow (droite).

Les bruits procéduraux sont utilisés pour la génération de terrains, de textures, d’animations, et plus généralement pour toute forme complexe nécessitant un aspect naturel sans stockage explicite de données.

Construction d’un bruit continu

La construction d’un bruit procédural 1D repose sur deux ingrédients :

  1. Valeurs pseudo-aléatoires aux entiers : une fonction de hachage associe à chaque entier \(n\) une valeur pseudo-aléatoire déterministe. La fonction de hachage doit produire des valeurs qui semblent aléatoires tout en étant parfaitement reproductibles. Un exemple classique en GLSL :
float hash(float n) {
    return fract(sin(n) * 1e4);
}

Cette fonction exploite le fait que \(\sin(n) \times 10^4\) produit des valeurs dont les décimales varient de manière apparemment chaotique d’un entier à l’autre, et fract extrait la partie fractionnaire pour obtenir un résultat dans \([0, 1]\).

  1. Interpolation lisse entre les entiers : pour obtenir une fonction continue \(f(x)\) pour tout \(x \in \mathbb{R}\), on interpole les valeurs aux entiers voisins. Le choix de la fonction d’interpolation est important : une interpolation linéaire produit une fonction continue (\(C^0\)) mais avec des cassures visibles aux entiers (la dérivée est discontinue). Une courbe de type smoothstep \(s(t) = 3t^2 - 2t^3\) garantit une continuité \(C^1\) (dérivée continue), ce qui élimine les artefacts visuels aux points de jonction. Pour une qualité encore meilleure, on utilise le polynôme \(s(t) = 6t^5 - 15t^4 + 10t^3\) qui est \(C^2\) (dérivée seconde continue).

L’algorithme pour évaluer \(f(x)\) est :

  1. Calculer \(n = \lfloor x \rfloor\) (partie entière).
  2. Évaluer la fonction de hachage aux entiers \(n\) et \(n + 1\).
  3. Interpoler ces deux valeurs à la position fractionnaire \(x - n\).
Construction d’un bruit continu : valeurs pseudo-aléatoires aux entiers, interpolées par une courbe lisse.

La fonction résultante est :

Ce principe se généralise directement en 2D et 3D. En 2D, on utilise une grille d’entiers \((i, j)\) avec une valeur de hachage \(h(i, j)\) à chaque noeud, et on interpole bilinéairement dans chaque cellule. En 3D, l’interpolation est trilinéaire. Le coût de calcul augmente avec la dimension (4 évaluations de hash en 2D, 8 en 3D), mais reste raisonnable pour un calcul en temps réel.

Fractales et auto-similarité

Un bruit continu avec une fréquence fixe produit un motif trop régulier pour simuler des formes naturelles. La nature présente des détails à toutes les échelles : une montagne vue de loin a une silhouette dentelée ; en se rapprochant, on découvre des crêtes, puis des rochers, puis des cailloux, puis des grains de sable. Chaque niveau de zoom révèle de nouveaux détails. De même, une côte maritime est découpée à l’échelle du kilomètre, du mètre et du centimètre. Un nuage possède des volutes à toutes les tailles.

Le concept de fractale formalise cette idée : une forme est fractale si elle présente une auto-similarité, c’est-à-dire que sa structure se répète (approximativement) à différentes échelles. Des exemples classiques sont le flocon de Koch (dont le périmètre augmente à chaque itération mais dont l’aire reste bornée), le triangle de Sierpinski, ou encore les formes naturelles comme le chou romanesco dont les spirales se répètent à plusieurs niveaux d’échelle.

Flocon de Koch : un exemple classique de fractale obtenue par ajout récursif de détails.

L’intérêt des fractales en informatique graphique est qu’une règle simple appliquée récursivement peut produire des formes d’une grande complexité visuelle, rappelant les structures naturelles. Cependant, les fractales mathématiques classiques (Mandelbrot, Sierpinski) sont difficiles à contrôler pour un usage artistique. En pratique, on utilise plutôt la sommation de bruits à différentes fréquences, comme dans le bruit de Perlin.

Bruit de Perlin

Le bruit de Perlin (Ken Perlin, An Image Synthesizer, SIGGRAPH 1985) est le premier algorithme de bruit procédural et reste le plus utilisé en informatique graphique. Cette contribution lui a valu un Academy Award technique en 1997. Son idée centrale est de reproduire le principe fractal de la nature en sommant des fonctions pseudo-aléatoires avec des fréquences croissantes et des amplitudes décroissantes :

\[ P(x) = \sum_{k=0}^{N} \alpha^k \, f(\omega^k \, x) \]

avec :

La figure ci-dessous montre la décomposition en octaves : la première octave donne la forme globale, les suivantes ajoutent des détails de plus en plus fins.

Décomposition du bruit de Perlin en octaves : chaque couche ajoute des détails à une fréquence plus élevée et une amplitude plus faible.

En 2D, la formule devient :

\[ P(u, v) = \sum_{k=0}^{N} \alpha^k \, f(2^k \, u, \, 2^k \, v) \]

Les images ci-dessous illustrent l’accumulation progressive des octaves en 2D.

Accumulation des octaves du bruit de Perlin en 2D : de 1 octave (gauche) à 5 octaves (droite).

Les paramètres typiques sont \(N \approx 5\text{-}9\), \(\alpha \approx 0.3\text{-}0.6\) et \(\omega = 2\). L’ajustement de ces paramètres permet de contrôler l’aspect du bruit : plus de détails avec \(N\) grand, détails plus marqués avec \(\alpha\) proche de 1.

Utilisations du bruit de Perlin

Le bruit de Perlin est utilisé pour la génération de quasiment toutes les formes complexes en informatique graphique. Sa simplicité et sa versatilité expliquent son succès : en modifiant les paramètres ou en combinant le bruit avec d’autres opérations mathématiques, on obtient une grande variété d’effets.

Terrain procédural : l’application la plus directe consiste à utiliser le bruit comme carte de hauteur (height map). On définit une surface plane \((x, y)\) et on déplace chaque point verticalement selon la valeur du bruit :

\[ z(x, y) = P(x, y) \]

La hauteur de chaque point du terrain est directement donnée par la valeur du bruit. Les basses fréquences (premières octaves) définissent les grandes structures (vallées, montagnes), tandis que les hautes fréquences ajoutent les détails (rochers, aspérités). Le résultat ressemble à un paysage montagneux naturel, car les vrais terrains sont eux aussi formés par des processus multi-échelles (tectonique, érosion, gel).

Terrain généré par bruit de Perlin.

Texture de crête (ridge) : en prenant la valeur absolue du bruit de base :

\[ \text{ridge}(x) = \sum_k \alpha^k \, |f(\omega^k \, x)| \]

La valeur absolue crée des plis (cusps) aux endroits où le bruit passe par zéro : au lieu d’une transition douce positive→négative, on obtient un V pointu. Ces plis forment des arêtes vives qui rappellent les crêtes montagneuses, les veines d’une feuille, ou les nervures du bois.

Texture de marbre : en utilisant le bruit comme perturbation d’une fonction sinusoïdale :

\[ \text{marble}(x) = \sin\!\Big(x + \sum_k \alpha^k \, |f(\omega^k \, x)|\Big) \]

L’idée est que le sinus produit naturellement des bandes régulières (comme les strates d’une pierre), et le bruit déforme ces bandes de manière irrégulière. Les ondulations du sinus, perturbées par le bruit, produisent un motif veiné similaire au marbre. En ajustant l’amplitude de la perturbation, on contrôle l’irrégularité des veines.

Texture de marbre générée par perturbation sinusoïdale avec du bruit de Perlin.

Textures animées : le bruit peut être étendu au temps pour créer des animations :

Ces techniques sont omniprésentes dans les jeux vidéo et les films : la surface de l’eau, le mouvement des nuages, la déformation de la lave, les effets de distorsion thermique sont généralement basés sur des variantes de bruit de Perlin animé.

Améliorations : Gradient Noise et Simplex Noise

L’algorithme original de Perlin présente certaines limitations qui ont motivé des améliorations successives.

Gradient Noise

Dans l’algorithme original (appelé value noise), chaque noeud de la grille stocke une valeur scalaire pseudo-aléatoire \(h(i, j) \in [0, 1]\), et on interpole ces valeurs. Le problème est que si deux noeuds voisins ont des valeurs proches (par exemple \(h = 0.7\) et \(h = 0.72\)), le bruit varie très peu dans cette zone, produisant une fréquence locale plus basse que prévu. La fréquence du bruit est donc irrégulière d’une cellule à l’autre.

Le gradient noise (Ken Perlin, 2002) corrige ce problème en associant à chaque noeud \((i, j)\) un vecteur gradient pseudo-aléatoire \(g_{i,j}\) (un vecteur unitaire de direction aléatoire) plutôt qu’un scalaire. L’algorithme pour évaluer le bruit en un point \(p = (x, y)\) est :

  1. Identifier la cellule \((i, j)\) contenant \(p\) (avec \(i = \lfloor x \rfloor\), \(j = \lfloor y \rfloor\)).
  2. Pour chacun des 4 coins de la cellule (en 2D), calculer le vecteur déplacement \(d\) du coin vers \(p\).
  3. Calculer la contribution de chaque coin par le produit scalaire \(c = g_{i,j} \cdot d\).
  4. Interpoler les 4 contributions avec une courbe smoothstep.

La propriété clé est que le produit scalaire \(g_{i,j} \cdot d\) est nul au noeud lui-même (car \(d = 0\) au noeud). Le bruit passe donc par zéro à chaque noeud de la grille, ce qui garantit exactement une oscillation par unité dans chaque direction. La fréquence est ainsi parfaitement contrôlée et homogène sur tout le domaine, ce qui donne un résultat visuellement plus régulier que le value noise.

En pseudocode :

float gradient_noise(float x, float y) {
    int i = floor(x), j = floor(y);
    float u = x - i, v = y - j;  // coordonnées locales dans [0,1]²

    // Gradients aux 4 coins
    vec2 g00 = gradient(i,   j  );
    vec2 g10 = gradient(i+1, j  );
    vec2 g01 = gradient(i,   j+1);
    vec2 g11 = gradient(i+1, j+1);

    // Produits scalaires (contribution de chaque coin)
    float d00 = dot(g00, vec2(u,   v  ));
    float d10 = dot(g10, vec2(u-1, v  ));
    float d01 = dot(g01, vec2(u,   v-1));
    float d11 = dot(g11, vec2(u-1, v-1));

    // Interpolation smoothstep
    float sx = smoothstep(u), sy = smoothstep(v);
    return mix(mix(d00, d10, sx), mix(d01, d11, sx), sy);
}

Simplex Noise

Le Simplex Noise (Ken Perlin, 2001) remplace la grille cartésienne par un pavage de simplexes. Un simplexe est le polytope le plus simple en dimension \(d\) : un segment en 1D, un triangle en 2D, un tétraèdre en 3D. En 2D, l’espace est donc pavé par des triangles équilatéraux au lieu de carrés.

L’algorithme comporte trois étapes principales :

1. Localisation du simplexe (skewing). Pour déterminer dans quel triangle se trouve un point \((x, y)\), on applique une transformation de déformation (skew) qui transforme la grille de triangles en une grille carrée, facilitant la localisation :

\[ (x', y') = \Big(x + \frac{\sqrt{3} - 1}{2}(x + y), \;\; y + \frac{\sqrt{3} - 1}{2}(x + y)\Big) \]

Dans l’espace déformé, on identifie le carré \((\lfloor x' \rfloor, \lfloor y' \rfloor)\), puis on détermine dans quel triangle du carré on se trouve (diagonal supérieure ou inférieure) en testant si \(x' - \lfloor x' \rfloor > y' - \lfloor y' \rfloor\).

2. Calcul des contributions. Chaque simplexe a \(d + 1\) sommets (3 en 2D). Pour chaque sommet, on calcule une contribution locale en utilisant une fonction d’atténuation radiale au lieu d’une interpolation :

\[ \text{contribution}_k = \max(0, \; r^2 - \|d_k\|^2)^4 \cdot (g_k \cdot d_k) \]

\(d_k\) est le vecteur déplacement du sommet \(k\) vers le point évalué, \(g_k\) est le gradient pseudo-aléatoire au sommet, et \(r\) est un rayon d’influence (typiquement \(r^2 = 0.5\) en 2D). La puissance 4 assure une décroissance rapide et lisse, avec continuité \(C^2\).

3. Sommation. Le bruit final est la somme des contributions des \(d + 1\) sommets :

\[ \text{simplex}(p) = \sum_{k=0}^{d} \text{contribution}_k \]

Les avantages par rapport au bruit sur grille sont les suivants :

Décomposition d’un cube (grille 3D) en simplexes (tétraèdres) : chaque cellule cubique est partitionnée en 6 tétraèdres.

Pour aller plus loin : A. Lagae et al., A Survey of Procedural Noise Functions, Computer Graphics Forum (Eurographics STAR), 2010.

Transformations et Rotations

Représentations des rotations 3D

Problématique

En informatique graphique, les rotations interviennent constamment : orienter un personnage, faire tourner la caméra autour d’un objet, animer l’ouverture d’une porte, interpoler entre deux poses d’un squelette d’animation. Or, contrairement aux translations (qui sont naturellement décrites par un simple vecteur \(\mathbf{t} \in \mathbb{R}^3\)), les rotations 3D posent un problème de représentation non trivial.

Une rotation en 3D possède 3 degrés de liberté : intuitivement, on peut tourner autour de l’axe \(x\), de l’axe \(y\), et de l’axe \(z\), ce qui donne trois paramètres indépendants. Pourtant, les rotations ne forment pas un espace vectoriel — on ne peut pas simplement additionner deux rotations comme on additionne deux vecteurs.

Il n’existe pas de représentation parfaite : chaque formalisme fait un compromis entre compacité, facilité de calcul, stabilité numérique et capacité d’interpolation. Les quatre représentations principales utilisées en pratique sont :

Matrice de rotation

La représentation la plus directe d’une rotation est la matrice de rotation \(R \in \mathbb{R}^{3 \times 3}\), satisfaisant deux contraintes :

\[ R^T R = I \quad \text{et} \quad \det(R) = 1 \]

La première condition (\(R^T R = I\)) signifie que les colonnes de \(R\) forment une base orthonormée : elles sont de norme 1 et mutuellement orthogonales. Géométriquement, la rotation transforme le repère canonique \((\mathbf{e}_x, \mathbf{e}_y, \mathbf{e}_z)\) en un nouveau repère orthonormé \((R \mathbf{e}_x, R \mathbf{e}_y, R \mathbf{e}_z)\), qui correspond aux colonnes de \(R\). La seconde condition (\(\det(R) = 1\)) distingue les rotations propres des réflexions (qui vérifient \(\det = -1\)).

La matrice s’applique directement à un vecteur : \(\mathbf{v}' = R \, \mathbf{v}\). La composition de deux rotations correspond au produit matriciel \(R_{\text{total}} = R_1 \, R_2\), et l’inverse d’une rotation est simplement la transposée : \(R^{-1} = R^T\) (conséquence directe de \(R^T R = I\)).

Avantages : le calcul est direct (un produit matrice-vecteur), la composition est un simple produit matriciel, et c’est le format utilisé nativement par le GPU dans le pipeline de rendu.

Inconvénients : la matrice contient 9 coefficients pour seulement 3 degrés de liberté — les 6 contraintes d’orthogonalité sont implicites. Cela pose deux problèmes en pratique. D’une part, les coefficients ne correspondent pas à des paramètres intuitifs (il est difficile de “lire” une matrice de rotation pour comprendre quelle rotation elle représente). D’autre part, les erreurs d’arrondi accumulées au fil des multiplications peuvent faire dériver la matrice hors de \(SO(3)\) : après de nombreuses compositions, \(R^T R\) n’est plus exactement \(I\), et la matrice introduit progressivement du scaling ou du shearing parasite. Il est alors nécessaire de réorthogonaliser périodiquement la matrice (par exemple via une décomposition polaire ou une SVD).

Angles d’Euler

L’idée des angles d’Euler est de décomposer une rotation quelconque en trois rotations successives, chacune autour d’un axe fixe du repère. Cette approche est intuitive : plutôt que de manipuler 9 coefficients couplés, on travaille avec trois angles indépendants, chacun contrôlant une rotation autour d’un axe clairement identifié.

Les matrices de rotation élémentaires autour de chaque axe sont :

\[ R_x(\alpha) = \begin{pmatrix} 1 & 0 & 0 \\ 0 & \cos\alpha & -\sin\alpha \\ 0 & \sin\alpha & \cos\alpha \end{pmatrix}, \quad R_y(\beta) = \begin{pmatrix} \cos\beta & 0 & \sin\beta \\ 0 & 1 & 0 \\ -\sin\beta & 0 & \cos\beta \end{pmatrix}, \quad R_z(\gamma) = \begin{pmatrix} \cos\gamma & -\sin\gamma & 0 \\ \sin\gamma & \cos\gamma & 0 \\ 0 & 0 & 1 \end{pmatrix} \]

Chacune de ces matrices correspond à une rotation dans le plan orthogonal à l’axe considéré : \(R_x\) tourne dans le plan \((y, z)\), \(R_y\) dans le plan \((x, z)\), et \(R_z\) dans le plan \((x, y)\). On peut le vérifier en observant que la ligne et la colonne correspondant à l’axe de rotation contiennent les coefficients de l’identité (l’axe reste fixe).

La rotation résultante est le produit de ces trois matrices, par exemple \(R = R_z(\gamma) \, R_y(\beta) \, R_x(\alpha)\), qui signifie : d’abord une rotation d’angle \(\alpha\) autour de \(x\), puis d’angle \(\beta\) autour de \(y\), puis d’angle \(\gamma\) autour de \(z\) (rappel : les matrices s’appliquent de droite à gauche).

Il existe de multiples conventions pour l’ordre des axes. Les conventions dites Proper Euler (z-x-z, x-y-x, y-z-y, …) utilisent le même axe pour la première et la troisième rotation, tandis que les conventions Tait-Bryan (x-y-z, z-y-x, x-z-y, …) utilisent trois axes distincts. Au total, il existe 12 conventions possibles (6 Proper Euler + 6 Tait-Bryan). Il faut être particulièrement vigilant lors de l’import/export entre différents logiciels (Blender, Unity, Unreal, Maya…), car la convention utilisée n’est pas toujours la même, et une confusion conduit à des orientations incohérentes.

Avantages : paramètres compréhensibles (3 degrés de liberté), les animateurs peuvent interagir directement avec les courbes angulaires (en ajustant chaque angle indépendamment dans une timeline), largement utilisé en robotique (pour décrire l’orientation de bras articulés par rapport à des axes mécaniques).

Inconvénients : problème du gimbal lock, et l’interpolation ne suit pas le chemin le plus court.

Gimbal lock

Le gimbal lock (blocage de cardan) est une perte d’un degré de liberté qui survient lorsque deux des trois axes de rotation s’alignent pour certaines valeurs angulaires. Le nom provient des cardans (gimbals), ces anneaux concentriques utilisés dans les gyroscopes et les compas de marine : lorsque deux anneaux s’alignent, le dispositif perd la capacité de mesurer ou d’imposer une rotation dans l’une des directions.

Montrons ce phénomène mathématiquement en utilisant la même convention que ci-dessus. Considérons \(R = R_z(\gamma) \, R_y(\beta) \, R_x(\alpha)\) et posons \(\beta = \pi/2\). En développant le produit :

\[ R_z(\gamma) \, R_y(\pi/2) \, R_x(\alpha) = \begin{pmatrix} 0 & -\sin(\gamma - \alpha) & \cos(\gamma - \alpha) \\ 0 & \cos(\gamma - \alpha) & \sin(\gamma - \alpha) \\ -1 & 0 & 0 \end{pmatrix} \]

La matrice résultante ne dépend que de la différence \(\gamma - \alpha\) : modifier \(\alpha\) de \(+5°\) et \(\gamma\) de \(+5°\) produit exactement la même rotation. Deux des trois axes de rotation se sont alignés, et les paramètres \(\alpha\) et \(\gamma\) ne contrôlent plus des rotations indépendantes — ils agissent dans la même direction. On a perdu un degré de liberté : au lieu de 2 degrés de liberté effectifs (pour \(\alpha\) et \(\gamma\), \(\beta\) étant fixé), on ne dispose que d’un seul paramètre libre (\(\gamma - \alpha\)).

Gimbal lock : lorsque deux des trois anneaux de rotation s’alignent, un degré de liberté est perdu.

Ce problème est inhérent à toute décomposition en angles d’Euler, quelle que soit la convention choisie : il existe toujours une configuration singulière. En animation, cela se manifeste par des mouvements erratiques ou des “sauts” lorsque l’orientation passe au voisinage de la singularité.

L’autre limitation des angles d’Euler concerne l’interpolation : interpoler linéairement les trois angles entre deux orientations produit bien une rotation à chaque instant, mais le chemin suivi ne correspond pas nécessairement à la trajectoire de rotation la plus naturelle (le plus court chemin sur la sphère des orientations). Un objet qui tourne de A à B peut, avec les angles d’Euler, suivre un détour par des orientations intermédiaires non intuitives.

Représentation axe-angle

Le théorème de rotation d’Euler (à ne pas confondre avec les angles d’Euler) affirme que toute rotation en 3D peut être décrite par un axe unitaire \(\mathbf{n}\) (\(\|\mathbf{n}\| = 1\)) autour duquel s’effectue la rotation, et un angle \(\theta\) qui mesure l’amplitude de cette rotation. Ce résultat est intuitif : tout mouvement qui laisse un point fixe (l’origine) et préserve les distances est nécessairement une rotation autour d’un axe passant par ce point.

Cette représentation est particulièrement naturelle pour décrire la rotation qui amène un vecteur \(\mathbf{u}_1\) sur un vecteur \(\mathbf{u}_2\) (tous deux supposés unitaires). L’axe de rotation est perpendiculaire au plan formé par les deux vecteurs, et l’angle est celui qui les sépare :

Cas dégénérés : cette formule suppose que \(\mathbf{u}_1\) et \(\mathbf{u}_2\) ne sont pas colinéaires. Si \(\mathbf{u}_1 = \mathbf{u}_2\) (vecteurs parallèles), la rotation est l’identité. Si \(\mathbf{u}_1 = -\mathbf{u}_2\) (vecteurs antiparallèles, \(\theta = \pi\)), le produit vectoriel est nul et l’axe de rotation n’est pas déterminé de manière unique — n’importe quel vecteur perpendiculaire à \(\mathbf{u}_1\) convient. En pratique, on choisit un axe arbitraire perpendiculaire (par exemple via un produit vectoriel avec un vecteur de référence).

La rotation de twist autour de \(\mathbf{u}_2\) (rotation autour de l’axe d’arrivée après alignement) reste libre et peut être choisie arbitrairement — la paire \((\mathbf{n}, \theta)\) ci-dessus correspond au choix de twist nul, c’est-à-dire à la rotation “la plus simple” entre les deux vecteurs.

Avantages : représentation concise (3 degrés de liberté effectifs, encodés dans \(\mathbf{n}\) et \(\theta\), ou de manière équivalente dans le vecteur \(\theta \, \mathbf{n} \in \mathbb{R}^3\)), paramètres géométriquement compréhensibles.

Inconvénients : composer deux rotations axe-angle (c’est-à-dire trouver l’axe et l’angle de \(R_2 \circ R_1\) à partir de \((\mathbf{n}_1, \theta_1)\) et \((\mathbf{n}_2, \theta_2)\)) n’admet pas de formule simple — il faut passer par les matrices ou les quaternions. L’interpolation directe en axe-angle est également moins naturelle et moins robuste que via les quaternions lorsque les axes diffèrent : interpoler séparément l’axe et l’angle ne produit pas en général le chemin de rotation le plus court.

Formule de Rodrigues

La question centrale est : étant donnés un axe \(\mathbf{n}\) et un angle \(\theta\), comment calculer le vecteur \(\mathbf{v}'\) obtenu en tournant \(\mathbf{v}\) ? L’idée est de décomposer \(\mathbf{v}\) en une composante parallèle à l’axe (qui ne bouge pas) et une composante perpendiculaire (qui tourne dans le plan orthogonal) :

\[ \mathbf{v}_\parallel = (\mathbf{v} \cdot \mathbf{n}) \, \mathbf{n}, \quad \mathbf{v}_\perp = \mathbf{v} - (\mathbf{v} \cdot \mathbf{n}) \, \mathbf{n} \]

La composante parallèle \(\mathbf{v}_\parallel\) est la projection de \(\mathbf{v}\) sur l’axe \(\mathbf{n}\). Elle est inchangée par la rotation (un vecteur aligné avec l’axe de rotation ne bouge pas). La composante perpendiculaire \(\mathbf{v}_\perp\) vit dans le plan orthogonal à \(\mathbf{n}\), et c’est elle qui tourne d’un angle \(\theta\).

Dans ce plan, on dispose de deux vecteurs orthogonaux : \(\mathbf{v}_\perp\) et \(\mathbf{n} \times \mathbf{v}_\perp = \mathbf{n} \times \mathbf{v}\) (ce dernier est bien perpendiculaire à la fois à \(\mathbf{n}\) et à \(\mathbf{v}_\perp\), et de même norme que \(\mathbf{v}_\perp\)). La rotation de \(\mathbf{v}_\perp\) d’un angle \(\theta\) dans ce plan donne :

\[ \mathbf{v}'_\perp = \cos(\theta) \, \mathbf{v}_\perp + \sin(\theta) \, \mathbf{n} \times \mathbf{v} \]

En recombinant \(\mathbf{v}' = \mathbf{v}_\parallel + \mathbf{v}'_\perp\) et en substituant les expressions de \(\mathbf{v}_\parallel\) et \(\mathbf{v}_\perp\) :

\[ \mathbf{v}' = \cos(\theta) \, \mathbf{v} + \sin(\theta) \, \mathbf{n} \times \mathbf{v} + (1 - \cos(\theta)) \, (\mathbf{v} \cdot \mathbf{n}) \, \mathbf{n} \]

C’est la formule de Rodrigues.

Décomposition d’un vecteur \mathbf{v} en composantes parallèle et perpendiculaire à l’axe de rotation \mathbf{n}.

On peut vérifier les cas limites : pour \(\theta = 0\), on obtient \(\mathbf{v}' = \mathbf{v}\) (pas de rotation) ; pour \(\theta = \pi/2\), on obtient \(\mathbf{v}' = \sin(\pi/2) \, \mathbf{n} \times \mathbf{v} + (1 - \cos(\pi/2))(\mathbf{v} \cdot \mathbf{n}) \mathbf{n} = \mathbf{n} \times \mathbf{v} + (\mathbf{v} \cdot \mathbf{n}) \mathbf{n}\), c’est-à-dire un quart de tour de la composante perpendiculaire (remplacement par le vecteur orthogonal dans le plan).

Forme matricielle

La formule de Rodrigues exprime \(\mathbf{v}'\) comme une fonction linéaire de \(\mathbf{v}\) (chaque terme — produit scalaire, produit vectoriel — est linéaire en \(\mathbf{v}\)). Il existe donc une matrice \(R\) telle que \(\mathbf{v}' = R \, \mathbf{v}\). Pour l’obtenir, on réécrit chaque opération sous forme matricielle.

Le produit vectoriel \(\mathbf{n} \times \mathbf{v}\) correspond à la multiplication par la matrice antisymétrique :

\[ K = \begin{pmatrix} 0 & -n_z & n_y \\ n_z & 0 & -n_x \\ -n_y & n_x & 0 \end{pmatrix} \]

telle que \(K \, \mathbf{v} = \mathbf{n} \times \mathbf{v}\) pour tout \(\mathbf{v}\). Le terme \((\mathbf{v} \cdot \mathbf{n}) \, \mathbf{n}\) correspond à la projection sur \(\mathbf{n}\), qui s’écrit \(\mathbf{n} \mathbf{n}^T \mathbf{v}\). En utilisant l’identité \(\mathbf{n} \mathbf{n}^T = I + K^2\) (valable lorsque \(\|\mathbf{n}\| = 1\), car \(K^2 = \mathbf{n}\mathbf{n}^T - I\)), on peut réécrire la formule de Rodrigues entièrement en termes de \(K\) :

\[ R(\mathbf{n}, \theta) = \cos(\theta) \, I + \sin(\theta) \, K + (1 - \cos(\theta)) \, (I + K^2) \]

ce qui se simplifie en :

\[ R(\mathbf{n}, \theta) = I + \sin(\theta) \, K + (1 - \cos(\theta)) \, K^2 \]

En développant les produits matriciels, on obtient explicitement :

\[ R = \begin{pmatrix} \cos\theta + n_x^2 (1 - \cos\theta) & n_x n_y (1 - \cos\theta) - n_z \sin\theta & n_x n_z (1 - \cos\theta) + n_y \sin\theta \\ n_x n_y (1 - \cos\theta) + n_z \sin\theta & \cos\theta + n_y^2 (1 - \cos\theta) & n_y n_z (1 - \cos\theta) - n_x \sin\theta \\ n_x n_z (1 - \cos\theta) - n_y \sin\theta & n_y n_z (1 - \cos\theta) + n_x \sin\theta & \cos\theta + n_z^2 (1 - \cos\theta) \end{pmatrix} \]

On peut vérifier que cette matrice est bien orthogonale et de déterminant 1. Par exemple, pour \(\mathbf{n} = (0, 0, 1)\) (rotation autour de \(z\)), on retrouve \(R_z(\theta)\) définie plus haut.

Quaternions

Les quaternions sont une généralisation des nombres complexes à quatre dimensions. Là où un nombre complexe \(a + b \, \mathbf{i}\) possède une unité imaginaire \(\mathbf{i}\) (avec \(\mathbf{i}^2 = -1\)), un quaternion possède trois unités imaginaires \(\mathbf{i}\), \(\mathbf{j}\), \(\mathbf{k}\) :

\[ q = x \, \mathbf{i} + y \, \mathbf{j} + z \, \mathbf{k} + w \]

avec la notation courte \(q = (x, y, z, w)\). La partie réelle est \(w\), la partie imaginaire (ou quaternion pur) est le triplet \((x, y, z)\). On note aussi \(q = (\mathbf{s}, w)\) avec \(\mathbf{s} = (x, y, z)\).

Les bases imaginaires satisfont les règles de multiplication suivantes :

\[ \mathbf{i}^2 = \mathbf{j}^2 = \mathbf{k}^2 = -1, \quad \mathbf{i}\mathbf{j} = \mathbf{k}, \quad \mathbf{j}\mathbf{k} = \mathbf{i}, \quad \mathbf{k}\mathbf{i} = \mathbf{j} \]

et les produits dans l’ordre inverse changent de signe : \(\mathbf{j}\mathbf{i} = -\mathbf{k}\), \(\mathbf{k}\mathbf{j} = -\mathbf{i}\), \(\mathbf{i}\mathbf{k} = -\mathbf{j}\). On a aussi la relation \(\mathbf{i}\mathbf{j}\mathbf{k} = -1\). La ressemblance avec les règles du produit vectoriel n’est pas un hasard — le produit vectoriel peut se voir comme la partie imaginaire du produit de deux quaternions purs.

Attention : ne pas confondre la notation \((x, y, z, w)\) d’un quaternion avec un vecteur 4D en coordonnées homogènes. Les quaternions vivent dans un espace algébrique muni de son propre produit, très différent de \(\mathbb{R}^4\).

Opérations de base

Les opérations sur les quaternions sont analogues à celles des nombres complexes :

Le produit de deux quaternions se calcule en développant le produit formel et en appliquant les règles de multiplication des bases. En notant \(q_i = (\mathbf{s}_i, w_i)\) avec \(\mathbf{s}_i = (x_i, y_i, z_i)\), on obtient la formule compacte :

\[ q_1 \, q_2 = (\mathbf{s}_1 w_2 + \mathbf{s}_2 w_1 + \mathbf{s}_1 \times \mathbf{s}_2, \;\; w_1 w_2 - \mathbf{s}_1 \cdot \mathbf{s}_2) \]

La partie imaginaire du produit combine un produit vectoriel (\(\mathbf{s}_1 \times \mathbf{s}_2\)) et des termes croisés scalaire-vecteur, tandis que la partie réelle combine un produit scalaire et un produit de réels. On peut aussi écrire le produit composante par composante :

\[ q_1 \, q_2 = \begin{pmatrix} x_1 w_2 + w_1 x_2 + y_1 z_2 - z_1 y_2 \\ y_1 w_2 + w_1 y_2 + z_1 x_2 - x_1 z_2 \\ z_1 w_2 + w_1 z_2 + x_1 y_2 - y_1 x_2 \\ w_1 w_2 - x_1 x_2 - y_1 y_2 - z_1 z_2 \end{pmatrix} \]

Le produit de quaternions est associatif (\(q_1 (q_2 q_3) = (q_1 q_2) q_3\)) mais non commutatif (\(q_1 q_2 \neq q_2 q_1\)), comme le produit matriciel. La non-commutativité reflète le fait que les rotations 3D ne commutent pas : tourner d’abord autour de \(x\) puis autour de \(y\) ne donne pas le même résultat que l’ordre inverse.

Une propriété importante du produit est que le conjugué d’un produit s’inverse : \((q_1 q_2)^* = q_2^* q_1^*\) (comme pour la transposée d’un produit de matrices). De plus, le produit de deux quaternions unitaires est unitaire : \(\|q_1 q_2\| = \|q_1\| \cdot \|q_2\|\).

Quaternion et rotation

Le lien entre quaternions et rotations repose sur une construction qui rappelle les nombres complexes. En 2D, un nombre complexe unitaire \(e^{i\theta} = \cos\theta + i \sin\theta\) représente une rotation d’angle \(\theta\), et la rotation d’un vecteur \(z\) s’obtient par le produit \(z' = e^{i\theta} \cdot z\). En 3D, le mécanisme est similaire mais nécessite un produit “sandwich” à cause de la non-commutativité.

Soit \(q = (\mathbf{s}, w)\) un quaternion unitaire (\(\|q\| = 1\)), et \(\mathbf{v}\) un vecteur 3D assimilé au quaternion pur \(q_v = (\mathbf{v}, 0)\) (partie réelle nulle). Alors :

\[ q_{v'} = q \, q_v \, q^* = (\mathbf{v}', 0) \]

est un quaternion pur (la partie réelle est bien nulle — ce n’est pas évident a priori), et \(\mathbf{v}'\) est le vecteur \(\mathbf{v}\) après rotation d’angle \(2 \arccos(w)\) autour de l’axe \(\mathbf{n} = \mathbf{s} / \|\mathbf{s}\|\).

Le produit “sandwich” \(q \, q_v \, q^*\) (multiplication à gauche par \(q\) et à droite par \(q^*\)) est nécessaire pour que le résultat soit un quaternion pur : le simple produit \(q \, q_v\) aurait une partie réelle non nulle et ne correspondrait pas à un vecteur 3D.

Démonstration. En développant le triple produit à l’aide de la formule du produit de quaternions :

\[ \mathcal{R}_q(\mathbf{v}) = q \, q_v \, q^* = ((w^2 - \|\mathbf{s}\|^2) \, \mathbf{v} + 2(\mathbf{s} \cdot \mathbf{v}) \, \mathbf{s} + 2w \, \mathbf{s} \times \mathbf{v}, \;\; 0) \]

Comme \(\|q\| = 1\), on peut paramétrer \(q = (\mathbf{n} \sin\phi, \cos\phi)\) avec \(\|\mathbf{n}\| = 1\). En substituant \(\mathbf{s} = \mathbf{n} \sin\phi\) et \(w = \cos\phi\) :

\[ \mathcal{R}_q(\mathbf{v}) = \underbrace{(\cos^2\phi - \sin^2\phi)}_{\cos(2\phi)} \mathbf{v} + \underbrace{2 \sin^2\phi}_{1 - \cos(2\phi)} (\mathbf{n} \cdot \mathbf{v}) \, \mathbf{n} + \underbrace{2 \cos\phi \sin\phi}_{\sin(2\phi)} \, \mathbf{n} \times \mathbf{v} \]

On reconnaît exactement la formule de Rodrigues pour l’axe \(\mathbf{n}\) et l’angle \(2\phi\). Le facteur 2 explique le demi-angle dans la correspondance :

Le quaternion unitaire \(q = (\mathbf{n} \sin(\theta/2), \cos(\theta/2))\) représente la rotation d’angle \(\theta\) autour de l’axe \(\mathbf{n}\).

Ce demi-angle a une conséquence topologique importante : une rotation de \(2\pi\) (un tour complet) correspond au quaternion \((\mathbf{n} \sin(\pi), \cos(\pi)) = (0, 0, 0, -1) = -1\), et il faut un angle de \(4\pi\) pour revenir au quaternion \(+1\). L’espace des quaternions unitaires constitue donc un double recouvrement de \(SO(3)\) : deux quaternions \(+q\) et \(-q\) représentent la même rotation (nous y reviendrons dans la section sur l’interpolation).

Composition de rotations

Soient deux rotations associées aux quaternions unitaires \(q_1\) et \(q_2\). Le produit \(q_1 \, q_2\) représente la composition \(R_1 \circ R_2\).

La démonstration est directe :

\[ \mathcal{R}_{q_1 q_2}(\mathbf{v}) = (q_1 q_2) \, \mathbf{v} \, (q_1 q_2)^* = q_1 (q_2 \, \mathbf{v} \, q_2^*) q_1^* = q_1 \, \mathcal{R}_{q_2}(\mathbf{v}) \, q_1^* = \mathcal{R}_{q_1} \circ \mathcal{R}_{q_2}(\mathbf{v}) \]

en utilisant la propriété \((q_1 q_2)^* = q_2^* q_1^*\).

Correspondance avec la matrice de rotation

Le quaternion unitaire \(q = (x, y, z, w)\) correspond à la matrice de rotation :

\[ R = \begin{pmatrix} 1 - 2(y^2 + z^2) & 2(xy - wz) & 2(xz + wy) \\ 2(xy + wz) & 1 - 2(x^2 + z^2) & 2(yz - wx) \\ 2(xz - wy) & 2(yz + wx) & 1 - 2(x^2 + y^2) \end{pmatrix} \]

Inversement, à partir d’une matrice de rotation \(M\), on peut extraire le quaternion correspondant. Lorsque la trace de \(M\) est positive (\(1 + M_{xx} + M_{yy} + M_{zz} > 0\)), on utilise :

\[ q = \left(\frac{M_{zy} - M_{yz}}{2r}, \; \frac{M_{xz} - M_{zx}}{2r}, \; \frac{M_{yx} - M_{xy}}{2r}, \; \frac{r}{2}\right), \quad r = \sqrt{1 + M_{xx} + M_{yy} + M_{zz}} \]

Lorsque la trace est proche de zéro ou négative (ce qui arrive pour des rotations proches de \(\pi\)), cette formule devient numériquement instable (\(r \to 0\)). En pratique, on utilise une version par cas qui choisit la composante dominante de la diagonale pour éviter les divisions par des valeurs proches de zéro.

Résumé des représentations

Représentation Avantages Inconvénients
Matrice Calcul direct, composition simple Paramètres non explicites (9 coeff. pour 3 DOF)
Angles d’Euler Paramètres compréhensibles, interaction directe Gimbal lock, interpolation inadaptée
Axe-angle Concis, intuitif Pas de composition simple
Quaternion Composition, interpolation, pas de gimbal lock Composantes moins intuitives

Le tableau suivant donne une indication comparative du coût des opérations courantes selon la représentation ; les performances réelles dépendent de l’implémentation et du matériel :

Opération Matrice Quaternion
Stockage 9 flottants 4 flottants
Rotation d’un vecteur rapide modéré
Composition modéré rapide
Inversion négligeable (transposition) négligeable (conjugué)
Interpolation coûteux (exp/log matriciel) rapide (SLERP)

La rotation d’un vecteur est plus rapide par matrice, mais la composition de rotations et l’interpolation sont plus efficaces en quaternion. En pratique, on stocke et interpole les orientations en quaternions, puis on convertit en matrice au moment du rendu (le GPU attend des matrices).

Interpolation de rotations

Problème

L’interpolation de rotations intervient dès qu’on souhaite animer une orientation : transition entre deux poses d’un personnage, rotation progressive de la caméra, interpolation entre les key-frames d’une animation. Étant données deux rotations \(r_1\) et \(r_2\), on cherche une rotation \(r(t)\) variant continûment avec \(t \in [0, 1]\) telle que \(r(0) = r_1\) et \(r(1) = r_2\).

La difficulté est que les rotations ne vivent pas dans un espace vectoriel : le résultat intermédiaire doit être une rotation valide à chaque instant \(t\), sans scaling ni shearing parasite. Toutes les représentations ne se prêtent pas également à l’interpolation :

LERP (Linear Interpolation)

L’approche la plus simple consiste à interpoler linéairement les quaternions dans \(\mathbb{R}^4\), puis à renormaliser le résultat pour revenir sur la sphère unitaire :

\[ q(t) = \frac{(1 - t) \, q_1 + t \, q_2}{\|(1 - t) \, q_1 + t \, q_2\|} \]

Géométriquement, la combinaison linéaire \((1-t) q_1 + t q_2\) produit un point à l’intérieur de la sphère (sur la corde reliant \(q_1\) à \(q_2\)), et la normalisation le reprojette sur la sphère. Le chemin résultant sur la sphère suit bien le grand cercle (le plus court chemin), mais la vitesse le long de ce chemin n’est pas constante : la paramétrisation est plus rapide aux extrémités (\(t\) proche de 0 ou 1) et plus lente au milieu. En effet, la reprojection radiale “concentre” les points vers le milieu de l’arc.

LERP : interpolation linéaire suivie d’une reprojection sur la sphère. La vitesse le long de l’arc n’est pas constante.

Le LERP peut être généralisé à des courbes paramétriques (splines de quaternions, etc.), ce qui en fait un outil utile pour les animations avec plus de deux key-frames. En revanche, la vitesse angulaire non constante peut poser problème pour des applications nécessitant un mouvement régulier.

SLERP (Spherical Linear Interpolation)

Pour obtenir une vitesse angulaire constante (la rotation progresse uniformément avec \(t\)), on utilise l’interpolation sphérique linéaire (SLERP). L’idée est d’interpoler directement sur la sphère plutôt que de passer par une corde puis reprojeter.

Soit \(\Omega\) l’angle entre \(q_1\) et \(q_2\) dans l’espace 4D, défini par \(\cos(\Omega) = q_1 \cdot q_2\) (produit scalaire des 4 composantes) :

\[ q(t) = \frac{\sin((1 - t) \, \Omega)}{\sin(\Omega)} \, q_1 + \frac{\sin(t \, \Omega)}{\sin(\Omega)} \, q_2 \]

SLERP : interpolation directement sur l’arc de la sphère, à vitesse angulaire constante.

Démonstration. On construit un repère orthonormé dans le plan \((q_1, q_2)\), avec \(q_1\) comme premier vecteur et \(q_1^\perp = \frac{q_2 - \cos(\Omega) \, q_1}{\sin(\Omega)}\) comme second. Le vecteur à l’angle \(\theta = \Omega \, t\) de \(q_1\) s’écrit \(q(t) = q_1 \cos(\Omega t) + q_1^\perp \sin(\Omega t)\). En substituant \(q_1^\perp\) et en utilisant l’identité \(\sin(\Omega) \cos(\Omega t) - \cos(\Omega) \sin(\Omega t) = \sin((1-t)\Omega)\), on retrouve la formule du SLERP. On vérifie que \(q(0) = q_1\), \(q(1) = q_2\), et \(\|q(t)\| = 1\) pour tout \(t\).

Avantages du SLERP : chemin le plus court sur la sphère, vitesse angulaire constante, résultat toujours unitaire.

Inconvénient : ne se généralise pas directement à l’interpolation entre plus de deux quaternions. Pour des animations avec \(N\) key-frames, on utilise des méthodes dérivées, souvent basées sur le LERP normalisé (nLERP) qui, bien que n’ayant pas une vitesse angulaire constante, se prête plus facilement à la construction de splines.

Cas dégénéré : lorsque \(\Omega\) est très petit (\(q_1 \approx q_2\)), le dénominateur \(\sin(\Omega)\) tend vers 0 et la formule devient numériquement instable. En pratique, on bascule sur le LERP (avec renormalisation) lorsque \(\Omega\) est inférieur à un seuil (typiquement \(10^{-4}\)).

Attention à l’opposé des quaternions

Comme mentionné précédemment, les quaternions \(+q\) et \(-q\) représentent la même rotation (puisque \(\mathbf{n} \to -\mathbf{n}\) et \(\theta \to 2\pi - \theta\), le produit “sandwich” reste identique). Cependant, sur la sphère \(S^3\), \(+q\) et \(-q\) sont deux points antipodaux, et ils correspondent à des chemins d’interpolation différents.

L’interpolation SLERP entre \(q_1\) et \(q_2\) suit le plus court arc de grand cercle sur la sphère. Mais il existe deux arcs possibles entre \(q_1\) et \(q_2\) (le court et le long), et l’arc entre \(q_1\) et \(-q_2\) est le complémentaire de celui entre \(q_1\) et \(q_2\). Concrètement :

En pratique, si on oublie cette vérification, l’objet peut tourner “par le chemin long” — une rotation de 350° au lieu de 10° — ce qui produit un mouvement visuellement aberrant. On corrige en inversant \(q_2\) lorsque nécessaire :

if (dot(q1, q2) < 0)
    q2 = -q2;

q_t = slerp(q1, q2, t);

Interpolation de mouvements rigides

En pratique, un objet 3D est caractérisé par une orientation \(r\) (rotation) et une position \(\mathbf{p}\) (translation). Pour interpoler entre deux configurations \((r_1, \mathbf{p}_1)\) et \((r_2, \mathbf{p}_2)\) — par exemple pour animer un objet qui se déplace en tournant — on interpole les deux composantes séparément :

La combinaison des deux donne un mouvement rigide (sans déformation) qui varie régulièrement entre les deux configurations. C’est l’interpolation standard utilisée dans les moteurs d’animation pour les key-frames de position et d’orientation.

Interpolation de transformations affines générales

Lorsque les transformations incluent du scaling ou de la déformation (shearing) en plus de la rotation et de la translation, une simple interpolation SLERP + linéaire ne suffit plus. Il faut d’abord séparer les différentes composantes de la transformation pour les interpoler chacune de manière appropriée.

L’outil mathématique clé est la décomposition polaire : toute matrice \(3 \times 3\) inversible se décompose en \(M = R \, D\), où \(R\) est une matrice de rotation (\(R \in SO(3)\)) et \(D\) est une matrice semi-définie positive symétrique (qui encode le scaling et le shearing). Cette décomposition est l’analogue matriciel de l’écriture d’un nombre complexe en forme polaire \(z = r \, e^{i\theta}\). Elle peut être calculée par SVD (\(M = U \Sigma V^T\), alors \(R = U V^T\) et \(D = V \Sigma V^T\)) ou par le schéma itératif \(R_{i+1} = \frac{1}{2}(R_i + (R_i^{-1})^T)\) en partant de \(R_0 = M\).

L’interpolation linéaire de \(M\) sans décomposition préalable produit des artefacts : un objet qui devrait tourner proprement se déforme temporairement puis revient à sa forme initiale. La décomposition polaire permet d’interpoler chaque composante avec la méthode adaptée.

L’algorithme complet pour interpoler entre deux matrices \(4 \times 4\) générales \(M_1\) et \(M_2\) est :

  1. Extraire les translations \(\mathbf{p}_1\), \(\mathbf{p}_2\) de \(M_1\), \(M_2\) (dernière colonne de la matrice \(4 \times 4\)).
  2. Calculer les matrices de rotation \(R_1\), \(R_2\) et les matrices de mise à l’échelle/déformation \(D_1\), \(D_2\) par décomposition polaire des sous-matrices \(3 \times 3\).
  3. Interpoler linéairement la position et la déformation : \(\mathbf{p}(t) = (1 - t) \, \mathbf{p}_1 + t \, \mathbf{p}_2\) et \(D(t) = (1 - t) \, D_1 + t \, D_2\).
  4. Convertir \(R_1\), \(R_2\) en quaternions \(q_1\), \(q_2\).
  5. Interpoler par SLERP : \(q(t) = \text{SLERP}(q_1, q_2, t)\).
  6. Reconvertir \(q(t)\) en matrice de rotation \(R(t)\).
  7. Recomposer la matrice finale \(M(t) = R(t) \, D(t)\) avec la translation \(\mathbf{p}(t)\).

Transformation des normales

Le problème

Soit une transformation affine \(M\) appliquée aux positions d’un objet : \(\mathbf{p}' = M \mathbf{p}\). Les vecteurs tangents à la surface, définis comme des différences de positions voisines, se transforment par la même matrice : \(\mathbf{t}' = M \mathbf{t}\). La question est de déterminer comment se transforment les normales \(\mathbf{n}\) sous l’action de \(M\).

Pour une rotation \(R\), la normale se transforme par \(\mathbf{n}' = R \mathbf{n}\) : l’orthogonalité est préservée car \(R\) conserve les angles. Pour un scaling uniforme \(sI\), la direction de la normale est inchangée (seule sa norme est affectée, corrigée par renormalisation). Dans ces deux cas, appliquer \(M\) directement aux normales produit un résultat correct (à renormalisation près).

Le problème apparaît en présence de scaling non uniforme ou de shearing. Considérons un plan horizontal avec une normale verticale \(\mathbf{n} = (0, 1, 0)\), et appliquons un étirement horizontal \(S = \text{diag}(2, 1, 1)\) (doublement de la coordonnée \(x\)). La surface s’étire horizontalement, mais la normale reste verticale — elle ne devrait pas changer. Or \(S \mathbf{n} = (0, 1, 0) = \mathbf{n}\), ce qui est correct dans ce cas. Maintenant, prenons un plan incliné à 45° avec \(\mathbf{n} = (1, 1, 0)/\sqrt{2}\). Après l’étirement horizontal, le plan est “aplati” et sa normale devrait s’incliner vers la verticale. Mais \(S \mathbf{n} = (2, 1, 0)/\sqrt{5}\), qui s’incline au contraire vers l’horizontale — le résultat est faux. La normale transformée par \(M\) n’est plus perpendiculaire à la surface transformée.

Transformation des normales : appliquer directement M aux normales produit un résultat incorrect en présence de scaling non uniforme (gauche). La matrice (M^{-1})^T corrige le problème (droite).

Dérivation de la matrice de transformation

La propriété fondamentale d’une normale est son orthogonalité aux tangentes de la surface. Soit \(\mathbf{t}\) un vecteur tangent à la surface en un point \(\mathbf{p}\). Avant transformation : \(\mathbf{n} \cdot \mathbf{t} = 0\). Après transformation, les tangentes se transforment par \(\mathbf{t}' = M \mathbf{t}\), et on cherche la matrice \(N\) telle que les normales transformées \(\mathbf{n}' = N \mathbf{n}\) satisfassent \(\mathbf{n}' \cdot \mathbf{t}' = 0\) pour tout tangent \(\mathbf{t}\).

En développant la condition d’orthogonalité après transformation :

\[ (N \mathbf{n})^T (M \mathbf{t}) = \mathbf{n}^T N^T M \mathbf{t} = 0 \]

On sait que \(\mathbf{n}^T \mathbf{t} = 0\). Une condition suffisante (et la plus simple) pour que \(\mathbf{n}^T (N^T M) \mathbf{t} = 0\) pour tout \(\mathbf{t}\) orthogonal à \(\mathbf{n}\) est que \(N^T M = I\), c’est-à-dire :

\[ N = (M^{-1})^T \]

La matrice de transformation des normales est la transposée de l’inverse de la matrice de transformation des positions (parfois notée \(M^{-T}\)).

En GLSL, on travaille avec la sous-matrice \(3 \times 3\) de la matrice Model (qui contient la rotation et le scaling, sans la translation — les normales sont des directions, pas des positions) :

mat3 normalMatrix = transpose(inverse(mat3(Model)));
vec3 n = normalize(normalMatrix * vertex_normal);

L’appel mat3(Model) extrait la sous-matrice \(3 \times 3\) supérieure gauche. La renormalisation finale est nécessaire car \((M^{-1})^T\) ne préserve pas la norme en général.

Cas particuliers

Lorsque \(M\) est une rotation pure (ou une transformation rigide : rotation + translation), on a \(M^{-1} = M^T\) (car \(R^{-1} = R^T\) pour les rotations), donc \(N = (M^{-1})^T = M\). La transformation des normales coïncide avec celle des positions : les normales se transforment par la même matrice, sans traitement particulier.

Lorsque \(M\) contient un scaling uniforme \(s\) en plus de la rotation (\(M = sR\)), on obtient \(N = (s^{-1} R^{-1})^T = s^{-1} R\). Le facteur \(s^{-1}\) modifie la norme de la normale, mais pas sa direction. Après renormalisation, le résultat est identique à \(R \mathbf{n}\).

Le cas non trivial apparaît uniquement en présence de scaling non uniforme (facteurs différents selon les axes) ou de shearing. C’est dans ces cas que l’utilisation de \((M^{-1})^T\) est indispensable pour obtenir un éclairage correct.

Note de performance : le calcul de transpose(inverse(Model)) à chaque fragment est coûteux. En pratique, cette matrice est pré-calculée côté CPU et envoyée au shader comme un uniform, au même titre que la matrice Model elle-même.

Ordre des transformations

Non-commutativité

L’ordre dans lequel les transformations sont appliquées est fondamental et constitue une source fréquente d’erreurs en programmation graphique. Pour une rotation \(R\) et une translation \(\mathbf{t}\), on a en général :

\[ T \, R \neq R \, T \]

Les matrices de transformation (en coordonnées homogènes \(4 \times 4\)) s’appliquent de droite à gauche : dans le produit \(M \, \mathbf{p} = (A \, B) \, \mathbf{p}\), c’est \(B\) qui est appliqué en premier au point \(\mathbf{p}\), puis \(A\) au résultat. Cet ordre “inversé” par rapport à la lecture est une convention mathématique standard, mais il faut s’y habituer.

Détaillons les deux cas sur un exemple concret.

Cas 1 : \(M_1 = T \, R\) (d’abord rotation, puis translation)

\[ M_1 = \begin{pmatrix} I & \mathbf{t} \\ 0 & 1 \end{pmatrix} \begin{pmatrix} R & 0 \\ 0 & 1 \end{pmatrix} = \begin{pmatrix} R & \mathbf{t} \\ 0 & 1 \end{pmatrix} \]

L’objet est d’abord tourné autour de l’origine (ses sommets sont multipliés par \(R\)), puis l’ensemble est déplacé de \(\mathbf{t}\). Le résultat est un objet tourné, dont le centre est à la position \(\mathbf{t}\). C’est le cas typique du placement d’un objet dans la scène : on l’oriente d’abord (rotation), puis on le positionne (translation).

Cas 2 : \(M_2 = R \, T\) (d’abord translation, puis rotation)

\[ M_2 = \begin{pmatrix} R & 0 \\ 0 & 1 \end{pmatrix} \begin{pmatrix} I & \mathbf{t} \\ 0 & 1 \end{pmatrix} = \begin{pmatrix} R & R \, \mathbf{t} \\ 0 & 1 \end{pmatrix} \]

L’objet est d’abord déplacé de \(\mathbf{t}\) (éloigné de l’origine), puis l’ensemble est tourné autour de l’origine. La rotation s’appliquant autour de l’origine, l’objet décrit un arc de cercle. La translation effective n’est plus \(\mathbf{t}\) mais \(R \, \mathbf{t}\) : le vecteur de translation lui-même a été tourné. C’est le cas typique de l’orbite : un objet éloigné de l’origine qui tourne autour d’elle.

À gauche : $M_1 = T \, R$ (rotation puis translation). À droite : $M_2 = R \, T$ (translation puis rotation autour de l'origine).

La différence entre les deux cas est frappante : \(M_1\) produit un objet tourné sur place puis translaté, tandis que \(M_2\) produit un objet qui orbite autour de l’origine. La confusion entre ces deux cas est l’une des erreurs les plus courantes en programmation 3D.

Attention : certaines bibliothèques (ancien OpenGL en mode fixed pipeline, Three.js) utilisent une convention de multiplication matricielle transposée par rapport à la convention mathématique standard. Dans ces bibliothèques, les transformations s’appliquent de gauche à droite dans le code. Par exemple, en ancien OpenGL, le code glTranslate(); glRotate(); applique d’abord la translation, puis la rotation — ce qui correspond à \(M_2 = R \, T\) dans notre convention. Il faut consulter la documentation de chaque bibliothèque pour connaître la convention utilisée.

Rotation autour d’un point arbitraire

Les rotations exprimées par des matrices \(R\) s’effectuent toujours autour de l’origine. Pour appliquer une rotation autour d’un point arbitraire \(\mathbf{p}_0\), le schéma classique consiste en trois étapes : (1) translater pour amener \(\mathbf{p}_0\) à l’origine, (2) effectuer la rotation, (3) retranslater pour remettre \(\mathbf{p}_0\) à sa position :

\[ M = T(\mathbf{p}_0) \, R \, T(-\mathbf{p}_0) \]

En développant le produit des trois matrices \(4 \times 4\) :

\[ M = \begin{pmatrix} I & \mathbf{p}_0 \\ 0 & 1 \end{pmatrix} \begin{pmatrix} R & 0 \\ 0 & 1 \end{pmatrix} \begin{pmatrix} I & -\mathbf{p}_0 \\ 0 & 1 \end{pmatrix} = \begin{pmatrix} R & \mathbf{p}_0 - R \, \mathbf{p}_0 \\ 0 & 1 \end{pmatrix} \]

On peut vérifier que \(M \, \mathbf{p}_0 = R \, \mathbf{p}_0 + \mathbf{p}_0 - R \, \mathbf{p}_0 = \mathbf{p}_0\) : le point \(\mathbf{p}_0\) est bien fixe, comme attendu.

Ce schéma est omniprésent en pratique. Par exemple, pour faire tourner un maillage autour de son barycentre \(\bar{\mathbf{p}} = \frac{1}{N} \sum_{i=1}^{N} \mathbf{p}_i\), on utilise \(M = T(\bar{\mathbf{p}}) \, R \, T(-\bar{\mathbf{p}})\). Pour faire tourner un bras articulé autour de l’épaule, on translate d’abord l’épaule à l’origine, on applique la rotation, puis on retranslate. Ce motif “translate-rotate-untranslate” apparaît à chaque articulation d’un squelette d’animation.

Inversion d’une transformation affine

Une transformation affine paramétrée par un scaling uniforme \(s\), une rotation \(R\) et une translation \(\mathbf{t}\) s’écrit sous forme de matrice bloc \(4 \times 4\) :

\[ M = T \, R \, S = \begin{pmatrix} s \, R & \mathbf{t} \\ 0 & 1 \end{pmatrix} \]

Son inverse est \((AB)^{-1} = B^{-1} A^{-1}\), donc \(M^{-1} = S^{-1} \, R^{-1} \, T^{-1}\). En développant :

\[ M^{-1} = \begin{pmatrix} \frac{1}{s} R^T & -\frac{1}{s} R^T \mathbf{t} \\ 0 & 1 \end{pmatrix} \]

Chaque composante s’inverse simplement : la translation en \(-\mathbf{t}\), la rotation en \(R^T\), le scale en \(1/s\). Mais il faut appliquer ces inverses dans l’ordre opposé à celui de la composition originale, ce qui explique le terme \(-\frac{1}{s} R^T \mathbf{t}\) (et non simplement \(-\mathbf{t}\)) dans le bloc de translation.

Hiérarchies de transformations et graphe de scène

Transformations hiérarchiques

En informatique graphique, les objets d’une scène ne sont presque jamais indépendants les uns des autres. Un personnage a des bras rattachés au torse, un véhicule a des roues rattachées au châssis, un système solaire a des lunes qui orbitent autour de planètes qui orbitent autour d’une étoile. Dans chacun de ces cas, le mouvement d’un objet enfant est défini relativement à un objet parent : la main bouge par rapport à l’avant-bras, qui bouge par rapport au bras, qui bouge par rapport au torse.

Cette organisation hiérarchique se formalise par une hiérarchie de repères (ou frame hierarchy). Chaque objet possède une transformation locale \(M_{\text{local}}\) qui décrit sa position et son orientation par rapport à son parent. La transformation globale (ou world transform) qui place l’objet dans le repère mondial est obtenue en composant toutes les transformations locales depuis la racine de la hiérarchie :

\[ M_{\text{global}} = M_{\text{parent}}^{\text{global}} \; M_{\text{local}} \]

En développant récursivement :

\[ M_{\text{global}}^{(i)} = M_{\text{local}}^{(0)} \; M_{\text{local}}^{(1)} \; \ldots \; M_{\text{local}}^{(i)} \]

\(M_{\text{local}}^{(0)}\) est la transformation de la racine (souvent l’identité), \(M_{\text{local}}^{(1)}\) celle de son enfant, etc. Les matrices se composent de gauche à droite dans la hiérarchie, ce qui correspond à l’application de droite à gauche sur les points : un point \(\mathbf{p}\) de l’objet \((i)\), exprimé dans le repère local de \((i)\), est transformé d’abord par \(M_{\text{local}}^{(i)}\), puis par \(M_{\text{local}}^{(i-1)}\), etc.

Composition en matrices par blocs

Chaque transformation locale est une transformation rigide (rotation + translation), représentée en coordonnées homogènes par une matrice \(4 \times 4\) de la forme :

\[ M = \begin{pmatrix} R & \mathbf{t} \\ 0 & 1 \end{pmatrix} \]

\(R \in SO(3)\) est la matrice de rotation \(3 \times 3\) et \(\mathbf{t} \in \mathbb{R}^3\) le vecteur de translation. La composition de deux telles transformations se calcule par blocs :

\[ M_{\text{global}} = M_{\text{parent}} \; M_{\text{local}} = \begin{pmatrix} R_p & \mathbf{t}_p \\ 0 & 1 \end{pmatrix} \begin{pmatrix} R_l & \mathbf{t}_l \\ 0 & 1 \end{pmatrix} = \begin{pmatrix} R_p R_l & R_p \mathbf{t}_l + \mathbf{t}_p \\ 0 & 1 \end{pmatrix} \]

La rotation globale est donc le produit des rotations \(R_g = R_p R_l\), et la translation globale est \(\mathbf{t}_g = R_p \mathbf{t}_l + \mathbf{t}_p\). Le terme \(R_p \mathbf{t}_l\) traduit le fait que la translation locale de l’enfant est exprimée dans le repère du parent — elle doit être tournée par la rotation du parent avant d’être ajoutée à la translation du parent.

Si l’on inclut un scaling uniforme \(s\) (ce qui est courant dans les graphes de scène), la transformation locale prend la forme :

\[ M = \begin{pmatrix} s R & \mathbf{t} \\ 0 & 1 \end{pmatrix} \]

et la composition donne :

\[ M_{\text{global}} = \begin{pmatrix} s_p R_p \; s_l R_l & s_p R_p \mathbf{t}_l + \mathbf{t}_p \\ 0 & 1 \end{pmatrix} = \begin{pmatrix} s_p s_l \; R_p R_l & s_p R_p \mathbf{t}_l + \mathbf{t}_p \\ 0 & 1 \end{pmatrix} \]

Le scaling global est le produit \(s_g = s_p \, s_l\), la rotation globale reste \(R_g = R_p R_l\), et la translation locale est à la fois tournée et mise à l’échelle par le parent : \(\mathbf{t}_g = s_p R_p \mathbf{t}_l + \mathbf{t}_p\).

Les formules ci-dessus s’appliquent aux transformations rigides et au scaling uniforme. En présence de scaling non uniforme ou de shearing, la décomposition en rotation / translation / échelle devient plus délicate et la propagation hiérarchique ne se réduit plus à ces formules simples.

Propriété fondamentale : lorsqu’on modifie la transformation locale d’un nœud, tous ses descendants sont automatiquement affectés. Si le torse tourne de 30°, les bras, avant-bras, mains et doigts suivent le mouvement sans qu’il soit nécessaire de modifier leurs transformations locales. C’est exactement le comportement attendu : tourner le torse d’un personnage entraîne naturellement tout le haut du corps.

Objets articulés

Un objet articulé est un cas particulier de hiérarchie de repères dans lequel les nœuds sont des articulations (joints) reliées par des segments rigides. Le squelette d’un personnage en est l’exemple le plus courant : chaque articulation (épaule, coude, poignet, hanche, genou, cheville…) définit un repère local, et la transformation locale de chaque articulation est typiquement une rotation (les os ne se déforment pas, ils pivotent autour des articulations).

Considérons l’exemple d’un bras articulé simplifié à deux segments (bras + avant-bras) dans le plan :

La transformation globale du coude est :

\[ M_{\text{coude}} = T(\mathbf{p}_0) \; R(\alpha) \; T(L_1, 0, 0) \]

La transformation globale de la main est :

\[ M_{\text{main}} = \underbrace{T(\mathbf{p}_0) \; R(\alpha) \; T(L_1, 0, 0)}_{M_{\text{coude}}} \; R(\beta) \; T(L_2, 0, 0) \]

On retrouve le motif répétitif : à chaque articulation, on compose une rotation locale (l’angle de l’articulation) suivie d’une translation le long du segment (la longueur de l’os). Pour animer le bras, il suffit de faire varier les angles \(\alpha(t)\) et \(\beta(t)\) au cours du temps — les positions globales de tous les points se mettent à jour automatiquement par propagation dans la chaîne.

Ce modèle se généralise directement en 3D : chaque articulation possède une rotation 3D (typiquement stockée sous forme de quaternion pour l’interpolation), et la transformation locale inclut la translation le long de l’os.

Graphe de scène

À l’échelle d’une scène complète, la hiérarchie de repères se généralise en un graphe de scène (scene graph). C’est une structure arborescente (ou plus généralement un DAG — graphe acyclique orienté) dans laquelle chaque nœud porte une transformation locale et peut contenir de la géométrie, des sources de lumière, des caméras, ou d’autres nœuds enfants.

Un graphe de scène typique pourrait avoir la structure suivante :

Scène (racine)
├── Terrain
├── Personnage
│   ├── Torse
│   │   ├── Bras_gauche
│   │   │   ├── Avant_bras_gauche
│   │   │   │   └── Main_gauche
│   │   │   ...
│   │   └── Tête
│   └── Jambe_gauche
│       └── Pied_gauche
├── Véhicule
│   ├── Châssis
│   ├── Roue_avant_gauche
│   ├── Roue_avant_droite
│   ...
└── Caméra

La mise à jour des coordonnées globales consiste à parcourir les nœuds dans l’ordre de la hiérarchie (les parents avant les enfants) et à composer chaque transformation locale avec la transformation globale du parent.

En pratique, une implémentation efficace représente chaque nœud par sa géométrie, un identifiant, l’identifiant de son parent, et sa transformation locale :

struct node {
    mesh geometry;              // géométrie associée au nœud
    std::string name;           // identifiant du nœud
    std::string name_parent;    // identifiant du parent
    mat4 transform_local;       // transformation par rapport au parent
    mat4 transform_global;      // transformation dans le repère monde
};

Les nœuds sont stockés dans un tableau ordonné de sorte que chaque parent apparaisse avant ses enfants, et une table de correspondance nom \(\to\) index permet de retrouver rapidement un nœud :

struct hierarchy {
    std::vector<node> elements;        // parents avant enfants
    std::map<std::string, int> index;  // accès rapide par nom
};

La propagation des transformations se réduit alors à un simple parcours linéaire du tableau :

int const N = elements.size();
for (int k = 0; k < N; ++k) {
    auto& element = elements[k];

    if (element est un nœud racine) {
        // La transformation globale est la transformation locale
        element.transform_global = element.transform_local;
    }
    else {
        // Composition avec la transformation globale du parent
        auto const& local  = element.transform_local;
        auto const& global_parent
            = elements[index_parent].transform_global;
        element.transform_global = global_parent * local;
    }
}

L’algorithme est en \(O(N)\) : chaque nœud est visité une seule fois, et la transformation globale du parent est déjà calculée grâce à l’ordre du tableau. Pour animer la hiérarchie, il suffit de modifier les transformations locales des nœuds souhaités, puis de relancer cette propagation avant le rendu.

Avantages du graphe de scène :

Matrice de vue et caméra

Rappel : matrice de vue

La matrice View transforme les coordonnées du world space (repère global de la scène) en coordonnées du view space (repère attaché à la caméra, dans lequel la caméra est à l’origine et regarde dans la direction \(-z\)). Cette transformation est nécessaire car le pipeline de rendu travaille dans l’espace caméra pour la projection perspective (voir le chapitre sur le pipeline de rendu).

La caméra est définie dans le world space par sa position \(\mathbf{eye}\) et son repère local constitué de trois vecteurs orthonormés : \(\mathbf{right}\) (direction droite), \(\mathbf{up}\) (direction haut) et \(\mathbf{back}\) (direction opposée à la direction de visée). La matrice caméra \(\text{Cam}\) est la matrice \(4 \times 4\) qui transforme les coordonnées du view space en coordonnées du world space — c’est la matrice dont les colonnes sont les vecteurs du repère caméra exprimés dans le repère monde :

\[ \text{Cam} = \begin{pmatrix} | & | & | & | \\ \mathbf{right} & \mathbf{up} & \mathbf{back} & \mathbf{eye} \\ | & | & | & | \\ 0 & 0 & 0 & 1 \end{pmatrix} = \begin{pmatrix} O & \mathbf{eye} \\ 0 & 1 \end{pmatrix} \]

\(O = (\mathbf{right} \;|\; \mathbf{up} \;|\; \mathbf{back})\) est la matrice \(3 \times 3\) d’orientation. La matrice de vue est son inverse (elle effectue la transformation dans l’autre sens : world \(\to\) view) :

\[ \text{View} = \text{Cam}^{-1} = \begin{pmatrix} O^T & -O^T \, \mathbf{eye} \\ 0 & 1 \end{pmatrix} \]

L’inverse se calcule simplement car \(\text{Cam}\) est une transformation rigide (rotation + translation) : la rotation s’inverse par transposition (\(O^{-1} = O^T\)) et la translation s’inverse en \(-O^T \mathbf{eye}\). On peut vérifier que \(\text{View} \times \mathbf{eye} = (0, 0, 0, 1)\) : la position de la caméra est bien à l’origine dans le view space.

En pratique, la matrice de vue est souvent construite via une fonction lookAt(eye, center, up_hint) qui calcule le repère caméra à partir de la position de la caméra (\(\mathbf{eye}\)), du point visé (\(\mathbf{center}\)) et d’un vecteur “haut” approximatif (\(\mathbf{up\_hint}\), typiquement l’axe \(y\)). L’algorithme est :

  1. \(\mathbf{back} = \text{normalize}(\mathbf{eye} - \mathbf{center})\) (direction opposée à la visée).
  2. \(\mathbf{right} = \text{normalize}(\mathbf{up\_hint} \times \mathbf{back})\) (produit vectoriel pour obtenir la direction droite).
  3. \(\mathbf{up} = \mathbf{back} \times \mathbf{right}\) (recalculé pour garantir l’orthogonalité).

Interaction caméra : coordonnées sphériques

Une approche simple pour contrôler la caméra dans un visualiseur 3D consiste à paramétrer sa position en coordonnées sphériques \((\theta, \varphi, r)\) autour d’un point central \(\mathbf{center}\). La caméra orbite sur une sphère de rayon \(r\), et l’utilisateur contrôle la latitude \(\varphi\) et la longitude \(\theta\) avec la souris, le rayon \(r\) avec la molette.

La position de la caméra est alors :

\[ \mathbf{eye} = \mathbf{center} + r \, (\cos\varphi \cos\theta, \; \sin\varphi, \; \cos\varphi \sin\theta) \]

et la matrice de vue se reconstruit à chaque frame via lookAt(eye, center, up).

Avantages : implémentation simple, pratique pour orbiter autour d’une structure avec une orientation naturelle (bâtiment, personnage debout).

Inconvénients : il manque un degré de liberté — l’orientation de la caméra est entièrement déterminée par sa position sur la sphère, sans possibilité de rotation de twist (roulis autour de l’axe de visée). De plus, aux pôles (\(\varphi = \pm \pi/2\)), le vecteur \(\mathbf{up\_hint}\) devient parallèle à la direction de visée, ce qui provoque une dégénérescence (le produit vectoriel dans lookAt donne un vecteur nul). Enfin, si l’objet observé a une orientation initiale qui ne correspond pas à la convention “haut = \(y\)”, les mouvements de souris ne correspondent plus aux rotations attendues.

Interaction caméra : ArcBall / Trackball

L’approche ArcBall (ou Trackball), proposée par K. Shoemake (ARCBALL: A User Interface for Specifying Three-Dimensional Orientation Using a Mouse, Graphics Interface, 1992), offre 3 degrés de liberté pour l’orientation de la caméra et résout les limitations des coordonnées sphériques.

L’idée est la suivante : on imagine une sphère virtuelle (le trackball) centrée au milieu de l’écran. Lorsque l’utilisateur clique et fait glisser la souris, les deux positions du curseur (début et fin du drag) sont projetées sur cette sphère, produisant deux points 3D. La rotation qui amène le premier point sur le second définit la rotation à appliquer à la caméra (ou à l’objet).

Projection ArcBall : le curseur 2D est projeté sur une sphère virtuelle (centre) avec extension hyperbolique (bords).

L’algorithme est le suivant. Soient \(\mathbf{p}_1 = (p_{1x}, p_{1y})\) et \(\mathbf{p}_2 = (p_{2x}, p_{2y})\) deux positions du curseur en coordonnées écran normalisées (dans \([-1, 1]\)) :

  1. Projeter chaque position sur le trackball : \(\mathbf{q}_{1,2} = \text{ProjectionArcBall}(\mathbf{p}_{1,2})\).
  2. Calculer la rotation entre les vecteurs \(\mathbf{q}_1\) et \(\mathbf{q}_2\) (axe = \(\mathbf{q}_1 \times \mathbf{q}_2\), angle = \(\arccos(\mathbf{q}_1 \cdot \mathbf{q}_2)\)).

La projection sur le trackball utilise une sphère pour les positions proches du centre et une extension hyperbolique pour les positions éloignées (afin de couvrir tout l’écran) :

\[ \text{ProjectionArcBall}(\mathbf{p}) = \begin{cases} (\mathbf{p}, \sqrt{1 - d^2}) & \text{si } d < 1/\sqrt{2} \quad \text{(sphère)} \\ (\mathbf{p}, 1/(2d)) & \text{sinon} \quad \text{(hyperbole)} \end{cases} \]

avec \(d = \|\mathbf{p}\|\). Le raccord entre la sphère et l’hyperbole se fait à \(d = 1/\sqrt{2}\), point auquel les deux fonctions (\(\sqrt{1-d^2}\) et \(1/(2d)\)) coïncident en valeur et en dérivée, assurant une transition lisse.

Avantages : rotation naturelle autour des formes, pas d’axe privilégié (contrairement aux coordonnées sphériques qui singularisent les pôles), comportement invariant quelle que soit l’orientation initiale de l’objet.

Inconvénients : la rotation obtenue est moins “propre” qu’un contrôle individuel de chaque axe (pitch, yaw, roll) — ce qui peut gêner pour des mouvements de caméra très précis. De plus, des mouvements de va-et-vient rapides de la souris provoquent un léger drift (dérive de l’orientation), car la discrétisation des positions du curseur introduit de petites erreurs cumulatives.