Cet article est la suite d'une série de posts à propos de WebGL. Le premier évoquait les bases et le précédent parlait de changement d'échelle de géométries.
Dans les 3 derniers posts on a parlé des translations, rotations et changement d'échelle. Translation, rotation et changement d'échelle sont les 3 types de 'transformation'. Chacune de ces transformations demande des changements dans le shader de vertex et on a vu que le résultat dépend de l'ordre dans lequel elles sont appliquées : certaines sont non-commutatives. Dans le précédent exemple on a changé l'échelle, puis tourné et déplacé. Dans un autre ordre on aurait eu un résultat différent. Par exemple, voici la suite de transformations suivantes : échelle de (2,1), rotation de 30 degrés et translation de (100, 0) :
Et voici un déplacement de (100,0) suivi d'une rotation de 30 degrés et un changement d'échelle de (2,1) :
Les résultats sont complètement différents. Pire, si on veut aboutir au second exemple il nous faut écrire un autre shader qui applique les transformations dans l'ordre qu'on souhaite.
Eh bien, des personnes plus futées que moi ont réalisé qu'on peut faire la même chose avec des matrices. Pour la 2D on utilise une matrice carrée d'ordre 3 (3x3). Une matrice 3x3 est comme une grille de 9 cases :
1.0 | 2.0 | 3.0 |
4.0 | 5.0 | 6.0 |
7.0 | 8.0 | 9.0 |
Pour trouver le résultat d'une transformation matricielle on dispose les composantes du vecteur à la verticale devant la matrice et pour chaque colonne, on multiplie les valeurs des composantes à leur niveau. Comme en 2D on a deux composantes x et y, et que les colonnes ont 3 valeurs, on ajoute une composante à la position, de valeur 1.
Dans ce cas notre résultat serait :
nouveauX = | x * | 1.0 | + | newY = | x * | 2.0 | + | extra = | x * | 3.0 | + |
y * | 4.0 | + | y * | 5.0 | + | y * | 6.0 | + | |||
1 * | 7.0 | 1 * | 8.0 | 1 * | 9.0 |
Vous êtes probablement en train de fixer l'écran en vous disant "Non mais allô !". Hé bien, imaginons qu'on a une translation. Elle peut être décomposée par les valeurs tx et ty. Faisons une matrice pour ça :
1.0 | 0.0 | 0.0 |
0.0 | 1.0 | 0.0 |
tx | ty | 1.0 |
Et maintenant vérifiez :
nouveauX = | x | * | 1.0 | + | nouveauY = | x | * | 0.0 | + | extra = | x | * | 0.0 | + |
y | * | 0.0 | + | y | * | 1.0 | + | y | * | 0.0 | + | |||
1 | * | tx | 1 | * | ty | 1 | * | 1.0 |
Si vous avez quelques souvenirs d'algèbre, on peut supprimer les termes multipliés par zéro. Multiplier par 1 ne change rien au terme initial alors simplifions pour voir ce qui se passe :
nouveauX = | x | * | 1.0 | + | nouveauY = | x | * | 0.0 | + | extra = | x | * | 0.0 | + |
y | * | 0.0 | + | y | * | 1.0 | + | y | * | 0.0 | + | |||
1 | * | tx | 1 | * | ty | 1 | * | 1.0 |
c'est-à-dire :
nouveauX = x + tx; nouveauY = y + ty;
Et l'extra on s'en fiche. Etonnament, ça revient au code de notre exemple sur les translations.
Passons aux rotations. Comme on a vu dans le post sur les rotations on a juste besoin du sinus et du cosinus de l'angle de rotation, donc
s = Math.sin(angleEnRadian); c = Math.cos(angleEnRadian);
Et on écrit une matrice comme celle-ci
c | -s | 0.0 |
s | c | 0.0 |
0.0 | 0.0 | 1.0 |
On applique la matrice :
nouveauX = | x | * | c | + | nouveauY = | x | * | -s | + | extra = | x | * | 0.0 | + |
y | * | s | + | y | * | c | + | y | * | 0.0 | + | |||
1 | * | 0.0 | 1 | * | 0.0 | 1 | * | 1.0 |
En supprimant ce qui est multiplié par zéro et en gardant ce qui est multiplié par 1 :
nouveauX = | x | * | c | + | nouveauY = | x | * | -s | + | extra = | x | * | 0.0 | + |
y | * | s | + | y | * | c | + | y | * | 0.0 | + | |||
1 | * | 0.0 | 1 | * | 0.0 | 1 | * | 1.0 |
Simplifions :
nouveauX = x * c + y * s; nouveauY = x * -s + y * c;
C'est exactement ce qu'on a vu dans les rotations.
Enfin l'échelle. Appelons nos deux facteurs d'échelle sx et sy et construisons la matrice :
sx | 0.0 | 0.0 |
0.0 | sy | 0.0 |
0.0 | 0.0 | 1.0 |
On l'applique :
nouveauX = | x | * | sx | + | nouveauY = | x | * | 0.0 | + | extra = | x | * | 0.0 | + |
y | * | 0.0 | + | y | * | sy | + | y | * | 0.0 | + | |||
1 | * | 0.0 | 1 | * | 0.0 | 1 | * | 1.0 |
Ce qui est en fait
nouveauX = | x | * | sx | + | nouveauY = | x | * | 0.0 | + | extra = | x | * | 0.0 | + |
y | * | 0.0 | + | y | * | sy | + | y | * | 0.0 | + | |||
1 | * | 0.0 | 1 | * | 0.0 | 1 | * | 1.0 |
Ce qui revient à
nouveauX = x * sx; nouveauY = y * sy;
Et c'est pareil que ce qu'on avait dans l'article sur le changement d'échelle !
Maintenant je parie que vous fixez toujours l'écran en pensant "Alors quoi ?! C'est quoi le truc ?" Ca a l'air d'un beaucoup plus gros boulot pour faire ce qu'on faisait déjà.
C'est là que la magie arrive. Il se trouve qu'on peut multiplier des matrices ensemble. Et qu'on peut appliquer toutes les transformations d'un coup. Imaginons qu'on a une fonction, multiplierMatrices
, qui prend deux matrices, les multiplie et retourne la matrice finale.
Pour rendre les choses plus claires faisons des fonctions pour fabriquer des matrices pour le déplacement, la rotation et l'échelle :
function deplacer(tx, ty) {
return [
1, 0, 0,
0, 1, 0,
tx, ty, 1
];
}
function tourner(angleEnRadians) {
var c = Math.cos(angleEnRadians);
var s = Math.sin(angleEnRadians);
return [
c,-s, 0,
s, c, 0,
0, 0, 1
];
}
function changerEchelle(sx, sy) {
return [
sx, 0, 0,
0, sy, 0,
0, 0, 1
];
}
Maintenant changeons le shader. Le shader actuel est :
<script id="shader-de-vertex-2d" type="x-shader/x-vertex">
attribute vec2 a_position;
uniform vec2 u_resolution;
uniform vec2 u_deplacement;
uniform vec2 u_rotation;
uniform vec2 u_echelle;
void main() {
// Change l'échelle
vec2 positionEchelle = a_position * u_echelle;
// Tourne
vec2 positionTournee = vec2(
positionEchelle.x * u_rotation.y + positionEchelle.y * u_rotation.x,
positionEchelle.y * u_rotation.y - positionEchelle.x * u_rotation.x);
// Déplace
vec2 position = positionTournee + u_deplacement;
...
Notre nouveau shader va être beaucoup plus simple
<script id="shader-de-vertex-2d" type="x-shader/x-vertex">
attribute vec2 a_position;
uniform vec2 u_resolution;
uniform mat3 u_matrice;
void main() {
// Multiplie la position par la matrice
vec2 position = (u_matrice * vec3(a_position, 1)).xy;
...
Et voilà comment on l'utilise :
// Rend la scène
function rendreScene() {
// Efface le canvas
gl.clear(gl.COLOR_BUFFER_BIT);
// Calcule les matrices
var matriceDeplacement = deplacer(deplacement[0], deplacement[1]);
var matriceRotation = tourne(angleEnRadians);
var matriceEchelle = changerEchelle(echelle[0], echelle[1]);
// Multiplie les matrices
var matrice = multiplierMatrices(matriceEchelle, matriceRotation);
matrice = multiplierMatrices(matrice, matriceDeplacement);
// Transmet la valeur au programme
gl.uniformMatrix3fv(emplacementMatrice, false, matrice);
// Dessine le rectangle
gl.drawArrays(gl.TRIANGLES, 0, 18);
}
Voilà un exemple avec le nouveau code. Les sliders sont les mêmes, déplacement, rotation, échelle. Mais la façon dont le shader les utilise est beaucoup plus simple.
Mais, vous vous demandez peut-être encore, alors quoi ? Ca n'a pas l'air plus pratique. Mais maintenant si on veut changer l'ordre des transformations on n'a pas besoin d'écrire un nouveau shader. On a juste à changer l'ordre de nos fonctions.
...
// Multiplie les matrices
var matrice = multiplierMatrices(matriceDeplacement, matriceRotation);
matrice = multiplierMatrices(matrice, matriceEchelle);
...
Voilà cette version :
Pouvoir multiplier par des matrices comme ça est particulièrement important dans les hiérarchies d'animation comme des bras sur un corps, des lunes autour d'une planète autour d'un soleil, ou les branches d'un arbre. Pour un exemple simple avec une animation hiérarchique dessinons notre "F" cinq fois mais chaque fois, partons de la matrice du F précédent.
// Rend la scène
function rendreScene() {
// Efface le canvas
gl.clear(gl.COLOR_BUFFER_BIT);
// Calcule les matrices
var matriceDeplacement = deplacer(deplacement[0], deplacement[1]);
var matriceRotation = tourner(angleEnRadians);
var matriceEchelle = changerEchelle(echelle[0], echelle[1]);
// initialise la matrice à l'identité
var matrice = matriceIdentite();
for (var i = 0; i < 5; ++i) {
// Multiplie les matrices
matrice = multiplierMatrices(matrice, matriceDeplacement);
matrice = multiplierMatrices(matrice, matriceRotation);
matrice = multiplierMatrices(matrice, matriceEchelle);
// Transmet la valeur au programme
gl.uniformMatrix3fv(emplacementMatrice, false, matrice);
// Dessine la géométrie
gl.drawArrays(gl.TRIANGLES, 0, 18);
}
}
Pour faire ça on a eu besoin de la fonction matriceIdentite
, qui retourne une matrice identité. Une matrice identité est une matrice qui représente "1.0", c'est-à-dire qu'en la multipliant, il ne se passe rien. Tout comme
de même
Voilà le code pour faire une matrice identité :
function matriceIdentite() {
return [
1, 0, 0,
0, 1, 0,
0, 0, 1
];
}
Et voilà nos cinq F.
Voyons un autre exemple. Jusque là dans tous les exemples notre "F" tourne autour de son coin gauche. C'est parce que les opérations qu'on a utilisées faisaient des rotations autour de l'origine et que ce coin gauche, c'est l'origine (0,0).
Mais maintenant, parce qu'on sait faire des opérations matricielles on peut choisir un ordre d'application des transformations et déplacer l'origine avant le reste des opérations :
// créé une matrice qui va déplacer l'origine du F vers son centre :
var matriceDeplacementOrigine = deplacer(-50, -75);
...
// Multiply the matrices.
var matrice = multiplierMatrices(matriceDeplacementOrigine, matriceEchelle);
matrice = multiplierMatrices(matrice, matriceRotation);
matrice = multiplierMatrices(matrice, matriceDeplacement);
Et voilà. Le F tourne et change d'échelle depuis son centre.
Avec cette technique vous pouvez tourner et changer d'échelle depuis n'importe quel point. Maintenant vous savez comment Photoshop ou Flash vous laissent changer un centre de rotation.
Allons plus loin dans cette folie. Si on revient au premier article WebGL - Les bases vous vous rappelez peut-être qu'on avait un code dans nos shaders pour convertir des coordonnées d'écran aux coordonnées d'espace de projection (clipspace). Ca ressemblait à ça :
...
// convertit le rectangle de l'espace des pixels à 0.0 > 1.0
vec2 zeroAUn = position / u_resolution;
// convertit de 0->1 à 0->2
vec2 zeroADeux = zeroAUn * 2.0;
// convertit de 0->2 à -1->+1 (clipspace)
vec2 clipSpace = zeroADeux - 1.0;
gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
Si vous regardez chaque étape, en fait, "convertit le rectangle de l'espace des pixels à 0.0 > 1.0", est un changement d'échelle. Pareil pour la seconde étape. La troisième est un déplacement et la dernière un changement d'échelle par -1. On peut du coup faire ceci dans une matrice qu'on envoie au shader. On pourrait faire deux matrices d'échelle, une pour l'échelle 1.0 / résolution, une autre pour l'échelle 2.0, une troisième pour le déplacement (-1.0,-1.0) et une quatrième pour changer l'échelle Y à -1.0, enfin multiplier tout ça. Mais à la place, parce que les maths c'est sensé être simple, on va juste écrire une fonction qui retourne une matrice de 'projection' pour une résolution donnée directement.
function projeter2D(largeur, hauteur) {
// Note: Cette matrice inverse l'axe Y, il regarde vers le bas
return [
2 / largeur, 0, 0,
0, -2 / hauteur, 0,
-1, 1, 1
];
}
Maintenant on peut simplifier le shader davantage. Voici le nouveau shader de vertex :
<script id="shader-de-vertex-2d" type="x-shader/x-vertex">
attribute vec2 a_position;
uniform mat3 u_matrice;
void main() {
// Multiplie la position par la matrice
gl_Position = vec4((u_matrice * vec3(a_position, 1)).xy, 0, 1);
}
</script>
Et en javascript il reste à multiplier par la matrice de projection :
// Rend la scène
function rendreScene() {
...
// Calcule les matrices
var matriceProjection = projeter2D(
canvas.clientWidth, canvas.clientHeight);
...
// Multiplie les matrices
var matrice = multiplierMatrices(matriceEchelle, matriceRotation);
matrice = multiplierMatrices(matrice, matriceDeplacement);
matrice = multiplierMatrices(matrice, matriceProjection);
...
}
On a aussi supprimé le code qui indique la résolution. Avec cette dernière étape on est parti d'un shader compliqué avec 6 ou 7 étapes à un shader simplifié à une seule étape, tout ça grâce à la magie des matrices.
J'espère que ces posts aident à démystifier les matrices. On peut passer à la 3D. En 3D les matrices suivent les mêmes principes. J'ai démarré avec la 2D pour rendre ça plus facile à comprendre.