Table des matières

WebGL2Fundamentals.org

Fix, Fork, Contribute

Matrices 2D en WebGL2

Cet article fait suite à une série d’articles sur WebGL. Le premier a commencé par les fondamentaux et le précédent portait sur la mise à l’échelle de géométrie 2D.

Mathématiques vs Programmation vs WebGL

Avant de commencer, si vous avez déjà étudié l'algèbre linéaire ou en général avez de l'expérience avec les matrices alors veuillez lire cet article avant de continuer ci-dessous..

Si vous avez peu ou pas d'expérience avec les matrices, n'hésitez pas à passer le lien ci-dessus pour l'instant et à continuer la lecture.

Dans les 3 derniers articles, nous avons vu comment translater la géométrie, faire pivoter la géométrie et mettre à l’échelle la géométrie. La translation, la rotation et la mise à l’échelle sont chacune considérées comme un type de ‘transformation’. Chacune de ces transformations nécessitait des modifications du shader et chacune des 3 transformations dépendait de l’ordre. Dans notre exemple précédent, nous avons mis à l’échelle, puis fait pivoter, puis translaté. Si nous appliquions celles-ci dans un ordre différent, nous obtiendrions un résultat différent.

Par exemple, voici une mise à l’échelle de 2, 1, une rotation de 30 degrés, et une translation de 100, 0.

Et voici une translation de 100,0, une rotation de 30 degrés et une mise à l’échelle de 2, 1

Les résultats sont complètement différents. Pire encore, si nous avions besoin du deuxième exemple, nous devrions écrire un shader différent qui appliquerait la translation, la rotation et la mise à l’échelle dans notre nouvel ordre souhaité.

Eh bien, des personnes bien plus intelligentes que moi ont découvert que vous pouvez faire tout cela avec les mathématiques matricielles. Pour la 2D, nous utilisons une matrice 3x3. Une matrice 3x3 est comme une grille avec 9 cases :

1.02.03.0
4.05.06.0
7.08.09.0

Pour faire les calculs, nous multiplions la position par les colonnes de la matrice et additionnons les résultats. Nos positions n’ont que 2 valeurs, x et y, mais pour faire ce calcul, nous avons besoin de 3 valeurs, donc nous utiliserons 1 pour la troisième valeur.

Dans ce cas, notre résultat serait

newX = 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 regardez probablement cela en pensant “QUEL EST L’INTÉRÊT ?” Eh bien, supposons que nous ayons une translation. Nous appellerons la quantité que nous voulons translater tx et ty. Faisons une matrice comme celle-ci

1.00.00.0
0.01.00.0
txty1.0

Et maintenant regardez ceci

newX = x * 1.0 +newY = 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 vous souvenez de votre algèbre, nous pouvons supprimer tout endroit qui multiplie par zéro. Multiplier par 1 ne fait effectivement rien, donc simplifions pour voir ce qui se passe

newX = x * 1.0 +newY = x * 0.0 +extra = x * 0.0 +
y * 0.0 +y * 1.0 + y * 0.0 +
1 * tx 1 * ty  1 * 1.0 

ou de manière plus concise

newX = x + tx;
newY = y + ty;

Et extra, on s’en fiche vraiment. Cela ressemble étonnamment au code de translation de notre exemple de translation.

De même, faisons une rotation. Comme nous l’avons souligné dans l’article sur la rotation, nous avons juste besoin du sinus et du cosinus de l’angle auquel nous voulons pivoter, donc

s = Math.sin(angleToRotateInRadians);
c = Math.cos(angleToRotateInRadians);

Et nous construisons une matrice comme celle-ci

c-s0.0
sc0.0
0.00.01.0

En appliquant la matrice, nous obtenons ceci

newX = x * c +newY = x * -s +extra = x * 0.0 +
y * s +y * c + y * 0.0 +
1 * 0.0 1 * 0.0  1 * 1.0 

En noircissant toutes les multiplications par 0 et 1, nous obtenons

newX = x * c +newY = x * -s +extra = x * 0.0 +
y * s +y * c + y * 0.0 +
1 * 0.0 1 * 0.0  1 * 1.0 

Et en simplifiant, nous obtenons

newX = x *  c + y * s;
newY = x * -s + y * c;

Ce qui est exactement ce que nous avions dans notre exemple de rotation.

Et enfin la mise à l’échelle. Nous appellerons nos 2 facteurs d’échelle sx et sy

Et nous construisons une matrice comme celle-ci

sx0.00.0
0.0sy0.0
0.00.01.0

En appliquant la matrice, nous obtenons ceci

newX = x * sx +newY = 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 vraiment

newX = x * sx +newY = 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 simplifié donne

newX = x * sx;
newY = y * sy;

Ce qui est identique à notre exemple de mise à l’échelle.

Maintenant, je suis sûr que vous pourriez toujours penser “Et alors ? Quel est l’intérêt ?” Cela semble beaucoup de travail juste pour faire la même chose que nous faisions déjà.

C’est là que la magie opère. Il s’avère que nous pouvons multiplier les matrices ensemble et appliquer toutes les transformations en une seule fois. Supposons que nous ayons une fonction, m3.multiply, qui prend deux matrices, les multiplie et retourne le résultat.

var m3 = {
  multiply: function(a, b) {
    var a00 = a[0 * 3 + 0];
    var a01 = a[0 * 3 + 1];
    var a02 = a[0 * 3 + 2];
    var a10 = a[1 * 3 + 0];
    var a11 = a[1 * 3 + 1];
    var a12 = a[1 * 3 + 2];
    var a20 = a[2 * 3 + 0];
    var a21 = a[2 * 3 + 1];
    var a22 = a[2 * 3 + 2];
    var b00 = b[0 * 3 + 0];
    var b01 = b[0 * 3 + 1];
    var b02 = b[0 * 3 + 2];
    var b10 = b[1 * 3 + 0];
    var b11 = b[1 * 3 + 1];
    var b12 = b[1 * 3 + 2];
    var b20 = b[2 * 3 + 0];
    var b21 = b[2 * 3 + 1];
    var b22 = b[2 * 3 + 2];

    return [
      b00 * a00 + b01 * a10 + b02 * a20,
      b00 * a01 + b01 * a11 + b02 * a21,
      b00 * a02 + b01 * a12 + b02 * a22,
      b10 * a00 + b11 * a10 + b12 * a20,
      b10 * a01 + b11 * a11 + b12 * a21,
      b10 * a02 + b11 * a12 + b12 * a22,
      b20 * a00 + b21 * a10 + b22 * a20,
      b20 * a01 + b21 * a11 + b22 * a21,
      b20 * a02 + b21 * a12 + b22 * a22,
    ];
  }
}

Pour rendre les choses plus claires, créons des fonctions pour construire des matrices pour la translation, la rotation et la mise à l’échelle.

var m3 = {
  translation: function(tx, ty) {
    return [
      1, 0, 0,
      0, 1, 0,
      tx, ty, 1,
    ];
  },

  rotation: function(angleInRadians) {
    var c = Math.cos(angleInRadians);
    var s = Math.sin(angleInRadians);
    return [
      c,-s, 0,
      s, c, 0,
      0, 0, 1,
    ];
  },

  scaling: function(sx, sy) {
    return [
      sx, 0, 0,
      0, sy, 0,
      0, 0, 1,
    ];
  },
};

Maintenant, modifions notre shader. L’ancien shader ressemblait à ceci

#version 300 es

in vec2 a_position;

uniform vec2 u_resolution;
uniform vec2 u_translation;
uniform vec2 u_rotation;
uniform vec2 u_scale;

void main() {
  // Met à l'échelle la position
  vec2 scaledPosition = a_position * u_scale;

  // Effectue la rotation de la position
  vec2 rotatedPosition = vec2(
     scaledPosition.x * u_rotation.y + scaledPosition.y * u_rotation.x,
     scaledPosition.y * u_rotation.y - scaledPosition.x * u_rotation.x);

  // Ajoute la translation.
  vec2 position = rotatedPosition + u_translation;

Notre nouveau shader sera beaucoup plus simple.

#version 300 es

in vec2 a_position;

uniform vec2 u_resolution;
uniform mat3 u_matrix;

void main() {
  // Multiplie la position par la matrice.
  vec2 position = (u_matrix * vec3(a_position, 1)).xy;
  ...

Et voici comment nous l’utilisons

  // Dessine la scène.
  function drawScene() {
    webglUtils.resizeCanvasToDisplaySize(gl.canvas);

    // Indique à WebGL comment convertir de l'espace de découpage en pixels
    gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);

    // Efface le canvas
    gl.clearColor(0, 0, 0, 0);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    // Indique d'utiliser notre programme (paire de shaders)
    gl.useProgram(program);

    // Lie l'ensemble attribut/tampon que nous voulons.
    gl.bindVertexArray(vao);

    // Passe la résolution du canvas pour pouvoir convertir
    // des pixels vers l'espace de découpage dans le shader
    gl.uniform2f(resolutionUniformLocation, gl.canvas.width, gl.canvas.height);

*    // Calcule les matrices
*    var translationMatrix = m3.translation(translation[0], translation[1]);
*    var rotationMatrix = m3.rotation(rotationInRadians);
*    var scaleMatrix = m3.scaling(scale[0], scale[1]);
*
*    // Multiplie les matrices.
*    var matrix = m3.multiply(translationMatrix, rotationMatrix);
*    matrix = m3.multiply(matrix, scaleMatrix);
*
*    // Définit la matrice.
*    gl.uniformMatrix3fv(matrixLocation, false, matrix);

    // Définit la couleur.
    gl.uniform4fv(colorLocation, color);

    // Dessine le rectangle.
    var primitiveType = gl.TRIANGLES;
    var offset = 0;
    var count = 18;
    gl.drawArrays(primitiveType, offset, count);
  }

Voici un exemple utilisant notre nouveau code. Les curseurs sont les mêmes, translation, rotation et mise à l’échelle. Mais la façon dont ils sont utilisés dans le shader est beaucoup plus simple.

Pourtant, vous pourriez vous demander, et alors ? Cela ne semble pas être un grand avantage. Mais maintenant, si nous voulons changer l’ordre, nous n’avons pas besoin d’écrire un nouveau shader. Nous pouvons simplement changer les calculs.

    ...
    // Multiplie les matrices.
    var matrix = m3.multiply(scaleMatrix, rotationMatrix);
    matrix = m3.multiply(matrix, translationMatrix);
    ...

Voici cette version.

Être capable d’appliquer des matrices comme ceci est particulièrement important pour l’animation hiérarchique comme des bras sur un corps, des lunes sur une planète autour d’un soleil, ou des branches sur un arbre. Pour un exemple simple d’animation hiérarchique, dessinons notre ‘F’ 5 fois mais à chaque fois commençons avec la matrice du ‘F’ précédent.

    // Dessine la scène.
    function drawScene() {

      ...

      // Calcule les matrices
      var translationMatrix = m3.translation(translation[0], translation[1]);
      var rotationMatrix = m3.rotation(rotationInRadians);
      var scaleMatrix = m3.scaling(scale[0], scale[1]);

      // Matrice de départ.
      var matrix = m3.identity();

      for (var i = 0; i < 5; ++i) {
        // Multiplie les matrices.
        matrix = m3.multiply(matrix, translationMatrix);
        matrix = m3.multiply(matrix, rotationMatrix);
        matrix = m3.multiply(matrix, scaleMatrix);

        // Définit la matrice.
        gl.uniformMatrix3fv(matrixLocation, false, matrix);

        // Dessine la géométrie.
        var primitiveType = gl.TRIANGLES;
        var offset = 0;
        var count = 18;
        gl.drawArrays(primitiveType, offset, count);
      }
    }

Pour ce faire, nous avons introduit la fonction m3.identity qui crée une matrice identité. Une matrice identité est une matrice qui représente effectivement 1.0, de sorte que si vous multipliez par l’identité, rien ne change. Tout comme

X * 1 = X

de même

matrixX * identity = matrixX

Voici le code pour créer une matrice identité.

var m3 = {
  identity: function () {
    return [
      1, 0, 0,
      0, 1, 0,
      0, 0, 1,
    ];
  },
...

Voici les 5 F.

Voyons un autre exemple. Dans tous les exemples jusqu’à présent, notre ‘F’ tourne autour de son coin supérieur gauche. C’est parce que les calculs que nous utilisons tournent toujours autour de l’origine et que le coin supérieur gauche de notre ‘F’ est à l’origine, (0, 0).

Mais maintenant, parce que nous pouvons faire des calculs matriciels et que nous pouvons choisir l’ordre dans lequel les transformations sont appliquées, nous pouvons effectivement déplacer l’origine avant que le reste des transformations ne soit appliqué.

    // crée une matrice qui déplacera l'origine du 'F' vers son centre.
    var moveOriginMatrix = m3.translation(-50, -75);
    ...

    // Multiplie les matrices.
    var matrix = m3.multiply(translationMatrix, rotationMatrix);
    matrix = m3.multiply(matrix, scaleMatrix);
+    matrix = m3.multiply(matrix, moveOriginMatrix);

Voici cet exemple. Remarquez que le F tourne et se met à l’échelle autour du centre.

En utilisant cette technique, vous pouvez pivoter ou mettre à l’échelle depuis n’importe quel point. Maintenant vous savez comment Photoshop ou Flash vous permettent de déplacer le point de rotation d’une image.

Allons encore plus loin. Si vous revenez au premier article sur les fondamentaux de WebGL, vous vous souvenez peut-être que nous avons du code dans le shader pour convertir des pixels vers l’espace de découpage qui ressemble à ceci.

  ...
  // convertit le rectangle de pixels vers 0.0 à 1.0
  vec2 zeroToOne = position / u_resolution;

  // convertit de 0->1 vers 0->2
  vec2 zeroToTwo = zeroToOne * 2.0;

  // convertit de 0->2 vers -1->+1 (espace de découpage)
  vec2 clipSpace = zeroToTwo - 1.0;

  gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);

Si vous regardez chacune de ces étapes à tour de rôle, la première étape, “convertir de pixels vers 0.0 à 1.0”, est en réalité une opération de mise à l’échelle. La deuxième est également une opération de mise à l’échelle. La suivante est une translation et la toute dernière met à l’échelle Y par -1. Nous pouvons en fait faire tout cela dans la matrice que nous passons au shader. Nous pourrions créer 2 matrices de mise à l’échelle, une pour mettre à l’échelle par 1.0/resolution, une autre pour mettre à l’échelle par 2.0, une 3ème pour translater de -1.0,-1.0 et une 4ème pour mettre à l’échelle Y par -1 puis les multiplier toutes ensemble mais à la place, parce que les calculs sont simples, nous allons simplement créer une fonction qui crée une matrice de ‘projection’ pour une résolution donnée directement.

var m3 = {
  projection: function (width, height) {
    // Note : Cette matrice inverse l'axe Y pour que 0 soit en haut.
    return [
      2 / width, 0, 0,
      0, -2 / height, 0,
      -1, 1, 1,
    ];
  },
  ...

Maintenant nous pouvons simplifier le shader encore plus. Voici le nouveau vertex shader complet.

#version 300 es

in vec2 a_position;

uniform mat3 u_matrix;

void main() {
  // Multiplie la position par la matrice.
  gl_Position = vec4((u_matrix * vec3(a_position, 1)).xy, 0, 1);
}

Et en JavaScript, nous devons multiplier par la matrice de projection

  // Dessine la scène.
  function drawScene() {
    ...
-    // Passe la résolution du canvas pour pouvoir convertir
-    // des pixels vers l'espace de découpage dans le shader
-    gl.uniform2f(resolutionUniformLocation, gl.canvas.width, gl.canvas.height);

    ...

    // Calcule les matrices
+    var projectionMatrix = m3.projection(
+        gl.canvas.clientWidth, gl.canvas.clientHeight);
    var translationMatrix = m3.translation(translation[0], translation[1]);
    var rotationMatrix = m3.rotation(rotationInRadians);
    var scaleMatrix = m3.scaling(scale[0], scale[1]);

    // Multiplie les matrices.
*    var matrix = m3.multiply(projectionMatrix, translationMatrix);
*    matrix = m3.multiply(matrix, rotationMatrix);
    matrix = m3.multiply(matrix, scaleMatrix);
    ...
  }

Nous avons également supprimé le code qui définissait la résolution. Avec cette dernière étape, nous sommes passés d’un shader plutôt compliqué avec 6-7 étapes à un shader très simple avec seulement 1 étape, tout cela grâce à la magie des mathématiques matricielles.

Avant de continuer, simplifions un peu. Bien qu’il soit courant de générer différentes matrices et de les multiplier séparément, il est également courant de simplement les multiplier au fur et à mesure. Effectivement, nous pourrions avoir des fonctions comme ceci

var m3 = {

  ...

  translate: function(m, tx, ty) {
    return m3.multiply(m, m3.translation(tx, ty));
  },

  rotate: function(m, angleInRadians) {
    return m3.multiply(m, m3.rotation(angleInRadians));
  },

  scale: function(m, sx, sy) {
    return m3.multiply(m, m3.scaling(sx, sy));
  },

  ...

};

Cela nous permettrait de changer 7 lignes de code matriciel ci-dessus en seulement 4 lignes comme ceci

// Calcule la matrice
var matrix = m3.projection(gl.canvas.clientWidth, gl.canvas.clientHeight);
matrix = m3.translate(matrix, translation[0], translation[1]);
matrix = m3.rotate(matrix, rotationInRadians);
matrix = m3.scale(matrix, scale[0], scale[1]);

Et voici cela

Une dernière chose, nous avons vu ci-dessus que l’ordre importe. Dans le premier exemple, nous avions

translation * rotation * scale

et dans le deuxième, nous avions

scale * rotation * translation

Et nous avons vu comment ils sont différents.

Il y a 2 façons de regarder les matrices. Étant donné l’expression

projectionMat * translationMat * rotationMat * scaleMat * position

La première façon, que beaucoup de gens trouvent naturelle, est de commencer à droite et de travailler vers la gauche

D’abord, nous multiplions la position par la matrice de mise à l’échelle pour obtenir une position mise à l’échelle

scaledPosition = scaleMat * position

Ensuite, nous multiplions scaledPosition par la matrice de rotation pour obtenir une rotatedScaledPosition

rotatedScaledPosition = rotationMat * scaledPosition

Ensuite, nous multiplions rotatedScaledPosition par la matrice de translation pour obtenir une translatedRotatedScaledPosition

translatedRotatedScaledPosition = translationMat * rotatedScaledPosition

Et finalement, nous multiplions cela par la matrice de projection pour obtenir les positions dans l’espace de découpage

clipspacePosition = projectionMatrix * translatedRotatedScaledPosition

La 2ème façon de regarder les matrices est de lire de gauche à droite. Dans ce cas, chaque matrice change “l’espace” représenté par le canvas. Le canvas commence par représenter l’espace de découpage (-1 à +1) dans chaque direction. Chaque matrice appliquée de gauche à droite change l’espace représenté par le canvas.

Étape 1 : aucune matrice (ou la matrice identité)

espace de découpage

La zone blanche est le canvas. Le bleu est en dehors du canvas. Nous sommes dans l’espace de découpage. Les positions passées doivent être dans l’espace de découpage

Étape 2 : matrix = m3.projection(gl.canvas.clientWidth, gl.canvas.clientHeight);

de l'espace de découpage à l'espace en pixels

Nous sommes maintenant dans l’espace en pixels. X = 0 à 400, Y = 0 à 300 avec 0,0 en haut à gauche. Les positions passées en utilisant cette matrice doivent être dans l’espace en pixels. Le flash que vous voyez se produit lorsque l’espace bascule de Y positif = vers le haut à Y positif = vers le bas.

Étape 3 : matrix = m3.translate(matrix, tx, ty);

déplacer l'origine vers tx, ty

L’origine a maintenant été déplacée vers tx, ty (150, 100). L’espace s’est déplacé.

Étape 4 : matrix = m3.rotate(matrix, rotationInRadians);

rotation de 33 degrés

L’espace a été pivoté autour de tx, ty

Étape 5 : matrix = m3.scale(matrix, sx, sy);

L’espace précédemment pivoté avec son centre en tx, ty a été mis à l’échelle de 2 en x, 1.5 en y

Dans le shader, nous faisons ensuite gl_Position = matrix * position;. Les valeurs position sont effectivement dans cet espace final.

Utilisez la méthode que vous trouvez la plus facile à comprendre.

J’espère que ces articles ont aidé à démystifier les mathématiques matricielles. Si vous voulez rester en 2D, je vous suggère de consulter la recréation de la fonction drawImage de canvas 2d et de continuer avec la recréation de la pile de matrices de canvas 2d.

Sinon, ensuite nous passerons à la 3D. En 3D, les mathématiques matricielles suivent les mêmes principes et la même utilisation. J’ai commencé par la 2D pour, je l’espère, garder les choses simples à comprendre.

De plus, si vous voulez vraiment devenir un expert en mathématiques matricielles, regardez ces vidéos incroyables.

Que sont clientWidth et clientHeight ?

Jusqu'à présent, chaque fois que je faisais référence aux dimensions du canvas, j'utilisais canvas.width et canvas.height mais ci-dessus, lorsque j'ai appelé m3.projection, j'ai utilisé à la place canvas.clientWidth et canvas.clientHeight. Pourquoi ?

Les matrices de projection se préoccupent de la façon de prendre l'espace de découpage (-1 à +1 dans chaque dimension) et de le reconvertir en pixels. Mais, dans le navigateur, il y a 2 types de pixels avec lesquels nous travaillons. L'un est le nombre de pixels dans le canvas lui-même. Donc par exemple un canvas défini comme ceci.

  <canvas width="400" height="300"></canvas>

ou un défini comme ceci

  var canvas = document.createElement("canvas");
  canvas.width = 400;
  canvas.height = 300;

contiennent tous deux une image de 400 pixels de large sur 300 pixels de haut. Mais cette taille est séparée de la taille à laquelle le navigateur affiche réellement ce canvas de 400x300 pixels. Le CSS définit la taille à laquelle le canvas est affiché. Par exemple, si nous créons un canvas comme ceci.


  <style>
  canvas {
    width: 100%;
    height: 100%;
  }
  </style>
  ...
  <canvas width="400" height="300"></canvas>

Le canvas sera affiché quelle que soit la taille de son conteneur. Ce n'est probablement pas 400x300.

Voici deux exemples qui définissent la taille d'affichage CSS du canvas à 100% pour que le canvas soit étiré pour remplir la page. Le premier utilise canvas.width et canvas.height. Ouvrez-le dans une nouvelle fenêtre et redimensionnez la fenêtre. Remarquez comment le 'F' n'a pas le bon rapport d'aspect. Il est déformé.

Dans ce deuxième exemple, nous utilisons canvas.clientWidth et canvas.clientHeight. canvas.clientWidth et canvas.clientHeight rapportent la taille à laquelle le canvas est réellement affiché par le navigateur donc dans ce cas, même si le canvas n'a toujours que 400x300 pixels, puisque nous définissons notre rapport d'aspect en fonction de la taille à laquelle le canvas est affiché, le F a toujours l'air correct.

La plupart des applications qui permettent à leurs canvas d'être redimensionnés essaient de faire en sorte que canvas.width et canvas.height correspondent à canvas.clientWidth et canvas.clientHeight car elles veulent qu'il y ait un pixel dans le canvas pour chaque pixel affiché par le navigateur. Mais, comme nous l'avons vu ci-dessus, ce n'est pas la seule option. Cela signifie que, dans presque tous les cas, il est plus techniquement correct de calculer le rapport d'aspect d'une matrice de projection en utilisant canvas.clientHeight et canvas.clientWidth. Ainsi, vous obtiendrez le bon rapport d'aspect que la largeur et la hauteur du canvas correspondent ou non à la taille à laquelle le navigateur dessine le canvas.

Problème ou bug ? Créez un ticket sur github.
Utilisez <pre><code>le code ici</code></pre> pour les blocs de code
comments powered by Disqus