Séance 6 · Cours
Passage à la 3D
Bonne nouvelle : vous savez déjà presque tout. Une coordonnée de plus, des matrices 4 × 4 au lieu de 3 × 3, et trois nouvelles idées : cacher ce qui est derrière, simuler la perspective, et placer une caméra.
À la fin de cette séance, vous saurez
- décrire un objet 3D par ses sommets et ses faces ;
- activer le test de profondeur et l'élimination des faces arrière, et dire à quoi ils servent ;
- expliquer la projection perspective et régler ses paramètres ;
- construire la chaîne projection · vue · modèle et placer une caméra avec
lookAt.
1. De la 2D à la 3D
Chaque sommet a maintenant trois coordonnées (x, y, z). Avec la coordonnée homogène, cela fait (x, y, z, 1), et les transformations deviennent des matrices 4 × 4. Elles se construisent exactement comme en 2D : les trois premières colonnes sont les images des axes, la quatrième est la translation.
Il y a désormais trois rotations de base, une autour de chaque axe : rotationX, rotationY, rotationZ. La rotation 2D de la séance 4 n'était autre que rotationZ. Les fonctions sont fournies dans m4.js au TP.
Repère utilisé par WebGL : x vers la droite, y vers le haut, z vers vous (sortant de l'écran). Un objet placé « devant » la caméra a donc un z négatif.
2. Décrire un objet : sommets et faces
Un cube a 8 coins, 6 faces carrées, donc 12 triangles. Si l'on veut une couleur (ou une texture) différente par face, un coin partagé par 3 faces a besoin de 3 couleurs différentes : on le duplique. Le cube compte alors 24 sommets (4 par face) et 36 indices (6 par face).
Un « sommet » pour le GPU, c'est l'ensemble de ses attributs : position, couleur, UV… Deux sommets à la même position mais avec des couleurs différentes sont deux sommets distincts.
3. Cacher ce qui est derrière
Dessinez un cube sans précaution : selon l'ordre des triangles dans le buffer, une face arrière peut être dessinée après une face avant et la recouvrir. Le résultat est incohérent. Deux outils règlent le problème.
Le test de profondeur (z-buffer)
En plus de l'image, le GPU tient à jour un second tableau de même taille, le tampon de profondeur. Pour chaque pixel, il retient la distance du fragment le plus proche dessiné jusqu'ici. Quand un nouveau fragment arrive, il n'est dessiné que s'il est plus proche ; sinon il est jeté. L'ordre de dessin n'a alors plus d'importance (pour les objets opaques).
gl.enable(gl.DEPTH_TEST); // une fois, à l'initialisation
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); // à chaque image : effacer aussi la profondeur
L'élimination des faces arrière (culling)
Les faces d'un objet fermé tournées vers l'arrière sont forcément cachées. Autant ne pas les calculer du tout. Pour savoir de quel côté une face est tournée, le GPU regarde l'ordre de ses sommets à l'écran : par convention, une face dont les sommets apparaissent dans le sens inverse des aiguilles d'une montre est vue de face. Il faut donc définir tous les triangles dans ce sens, vus de l'extérieur.
gl.enable(gl.CULL_FACE); // ignore les triangles vus « de dos »
Le culling divise par deux environ le travail du fragment shader, mais ne remplace pas le test de profondeur : avec plusieurs objets, ou un objet non convexe, des faces tournées vers la caméra peuvent quand même se recouvrir.
4. La projection perspective
Dans la réalité, un objet paraît d'autant plus petit qu'il est loin. Par le théorème de Thalès, un point à la distance d devant l'œil apparaît sur l'écran à une position proportionnelle à x / d :
xécran = x / (−z) yécran = y / (−z)
(−z car les objets visibles ont un z négatif.) Une division ne s'écrit pas avec une matrice. C'est là qu'intervient la quatrième coordonnée w : après le vertex shader, le GPU divise automatiquement x, y et z par w. Jusqu'ici w valait 1 et cette division ne changeait rien. La matrice de perspective est construite pour mettre −z dans w, et le GPU fait le reste.
On la règle avec quatre paramètres :
- fov (field of view) : l'angle d'ouverture vertical de la caméra. 45° à 60° pour une vue naturelle, plus pour un effet grand-angle ;
- aspect : largeur / hauteur du canvas. C'est lui qui corrige enfin la déformation des formes vue à la séance 1 ;
- near et far : les distances minimale et maximale visibles. Ce qui est plus près que near ou plus loin que far est découpé.
const projection = m4.perspective(60 * Math.PI / 180, canvas.width / canvas.height, 0.1, 100);
Sans division par la distance, les objets gardent leur taille quelle que soit leur profondeur : c'est la projection orthographique, utilisée pour les plans techniques, les jeux en vue isométrique, ou la 2D que nous faisions jusqu'ici.
Au départ, profondeur et culling sont désactivés : regardez les faces se recouvrir n'importe comment. Activez-les un par un.
5. La caméra
La matrice de perspective suppose que l'œil est à l'origine et regarde vers −z. Pour filmer la scène depuis un autre point de vue, plutôt que de déplacer la caméra, on déplace le monde entier dans le sens inverse. Avancer la caméra de 5 vers l'objet revient à reculer l'objet de 5 vers la caméra. Si la caméra est placée par une matrice C, on applique aux objets la matrice inverse C⁻¹, appelée matrice de vue.
lookAt : placer la caméra simplement
En pratique, on décrit une caméra par trois informations : sa position (eye), le point qu'elle regarde (target) et la direction du haut (up, en général (0, 1, 0)). La fonction lookAt en déduit les trois axes de la caméra :
- zc = normaliser(eye − target) : l'axe qui pointe vers l'arrière de la caméra, puisqu'elle regarde vers −z ;
- xc = normaliser(up × zc) : la droite de la caméra, perpendiculaire au haut et à l'axe de visée (le produit vectoriel × donne un vecteur perpendiculaire aux deux autres) ;
- yc = zc × xc : le vrai haut de la caméra, perpendiculaire aux deux premiers.
Ces trois axes et la position forment la matrice C de la caméra. m4.lookAt renvoie directement son inverse, la matrice de vue.
const vue = m4.lookAt([6, 4, 8], [0, 0, 0], [0, 1, 0]); // œil, cible, haut
6. La chaîne complète : modèle, vue, projection
Un sommet traverse quatre repères successifs, chacun par une matrice :
#version 300 es
in vec3 a_position;
in vec3 a_couleur;
uniform mat4 u_modele; // propre à chaque objet
uniform mat4 u_vue; // la caméra : la même pour toute l'image
uniform mat4 u_projection; // l'objectif : change seulement si le canvas change de taille
out vec3 v_couleur;
void main() {
v_couleur = a_couleur;
gl_Position = u_projection * u_vue * u_modele * vec4(a_position, 1.0);
}
On garde trois uniforms séparés pour la clarté : la projection et la vue sont fixées une fois par image, seule la matrice modèle change d'un objet à l'autre.
import * as m4 from "./m4.js";
// initialisation
const locModele = gl.getUniformLocation(program, "u_modele");
const locVue = gl.getUniformLocation(program, "u_vue");
const locProjection = gl.getUniformLocation(program, "u_projection");
gl.enable(gl.DEPTH_TEST);
gl.enable(gl.CULL_FACE);
// à chaque image
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.useProgram(program);
gl.bindVertexArray(vaoCube); // le cube : 24 sommets, 36 indices
gl.uniformMatrix4fv(locProjection, false, m4.perspective(Math.PI / 3, canvas.width / canvas.height, 0.1, 100));
gl.uniformMatrix4fv(locVue, false, m4.lookAt([6, 4, 8], [0, 0, 0], [0, 1, 0]));
// pour chaque objet : sa matrice modèle, puis le dessin
gl.uniformMatrix4fv(locModele, false, m4.mul(m4.translation(2, 0, 0), m4.rotationY(angle)));
gl.drawElements(gl.TRIANGLES, 36, gl.UNSIGNED_SHORT, 0);
7. Et après ?
Avec ces bases, vous avez toutes les briques d'un moteur 3D. Les étapes suivantes, hors du programme de ce module :
- L'éclairage : on ajoute à chaque sommet sa normale (le vecteur perpendiculaire à la surface), et le fragment shader calcule la luminosité selon l'angle avec la direction de la lumière (produit scalaire).
- Charger des modèles créés dans Blender (formats glTF ou OBJ) au lieu de taper les sommets à la main.
- Les bibliothèques comme Three.js ou Babylon.js font tout ce que vous avez codé ici (et bien plus). Vous savez maintenant ce qu'elles font à votre place.
Résumé
- 3D = (x, y, z, 1) et matrices 4 × 4. Trois rotations de base. L'axe z sort de l'écran.
DEPTH_TESTgarde le fragment le plus proche ; penser à effacerDEPTH_BUFFER_BIT.CULL_FACEignore les faces vues de dos : triangles dans le sens anti-horaire vus de l'extérieur.- Perspective : le GPU divise par w ;
m4.perspective(fov, aspect, near, far). - Caméra :
m4.lookAt(œil, cible, haut)donne la matrice de vue. gl_Position = projection · vue · modèle · position.