WebGL pas à pas

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.

Durée 30 minTP associé TP 6 · Scène 3D

À 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.

T =
100tx010ty001tz0001
 Ry(θ) =
cos θ0sin θ00100−sin θ0cos θ00001

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).

À retenir

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 :

const projection = m4.perspective(60 * Math.PI / 180, canvas.width / canvas.height, 0.1, 100);
Et la projection orthographique ?

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.

DémoProfondeur, culling, perspective

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 :

  1. zc = normaliser(eye − target) : l'axe qui pointe vers l'arrière de la caméra, puisqu'elle regarde vers −z ;
  2. 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) ;
  3. 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 :

Objetle cube seul Mondeplacé dans la scène Caméravu depuis l'œil Découpagede −1 à 1 modèle vue projection gl_Position = projection · vue · modèle · (x, y, z, 1)
#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);
DémoCaméra lookAt
La caméra tourne sur un cercle et regarde toujours le cube multicolore au centre (les autres sont grisés). Seule la matrice de vue change ; les matrices modèles des cubes sont fixes.

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 :

Résumé