Navigation

Tutoriel précédent : introduction aux textures   Sommaire   Tutoriel suivant : les bases de l'éclairage directionnel et ambiant

II. Introduction

Bienvenue dans ma sixième leçon WebGL, basée sur la septième leçon des tutoriels OpenGL de NeHe. Dans celle-ci, nous allons apprendre comment vous pouvez faire en sorte que la page WebGL accepte les entrées clavier. Nous allons ensuite les utiliser pour changer la vitesse et la direction de rotation d'un cube texturé ainsi que le type de filtre appliqué sur la texture. En changeant le filtrage le rendu sera de moindre qualité mais plus rapide ou la texture sera de haute qualité mais plus lente à l'affichage. (La septième leçon de Nehe couvre aussi l'éclairage, mais comme l'éclairage demande plus de travail dans WebGL que dans OpenGL, je n'en parle pas dans cette leçon. Nous allons voir cela plus tard.)

Voici le rendu de cette leçon lorsque exécutée dans un navigateur supportant WebGL :



Cliquez ici pour voir la version WebGL si votre navigateur supporte WebGL ou ici pour en avoir un. Une fois chargée, utilisez les touches « Page suivante » et « Page précédente » pour zoomer et dézoomer sur le cube. Utilisez les touches fléchées pour tourner le cube (plus vous maintenez appuyée la touche, plus le cube accélère). Vous pouvez aussi utiliser la touche F pour alterner trois types de filtre pour les textures, chose que vous allez clairement voir si vous zoomez assez près du cube ou lorsque le cube est loin. Plus tard, nous verrons la raison à cela.

Plus de détails sur comment cela fonctionne ci-dessous…

Ces leçons ciblent des personnes ayant une connaissance raisonnable de la programmation, mais aucune expérience dans la 3D. Le but est de vous former, en vous donnant une explication de ce qui se passe dans le code, afin que vous puissiez produire vos propres pages Web 3D aussi vite que possible. Si vous n'avez pas lu les précédents tutoriels, vous devriez le faire avant de lire celui-ci. Cette leçon ne détaille que les différences entre la cinquième leçon et le nouveau code.

Comme précédemment, il peut y avoir des problèmes de conception dans ce tutoriel. Si vous trouvez quoi que ce soit de faux, avertissez-moi aussi vite que possible.

Il y a deux façons de récupérer le code de cet exemple : soit en affichant le code « voir source » lorsque vous regardez la page de démonstration, soit en utilisant GitHub : vous pouvez le cloner (ainsi que les autres leçons) à partir du dépôt. Dans les deux cas, une fois que vous avez le code, chargez-le dans votre éditeur de code préféré et jetez-y un coup d'œil.

III. Leçon

Le plus grand changement entre cette leçon et la dernière est que nous gérons le clavier, mais il est plus simple d'expliquer comment cela fonctionne en commençant par le code sur lequel il agit. Si vous vous placez à la moitié du code, vous allez voir un certain nombre de variables globales définies :

 
Sélectionnez
  var xRot = 0; 
  var xSpeed = 0; 

  var yRot = 0; 
  var ySpeed = 0; 

  var z = -5.0; 

  var filter = 0;

xRot et yRot devraient être connues depuis la cinquième leçon. Les variables représentent la rotation actuelle du cube autour des axes X et Y. xSpeed et ySpeed devraient être aussi claires. Maintenant que nous permettons à l'utilisateur de changer la vitesse de rotation du cube avec les touches fléchées, nous y gardons le facteur de changement de xRot et yRot. z est, bien sûr, la coordonnée Z du cube. Elle indique la distance entre le cube et la caméra. Elle sera contrôlée par les touches « Page suivante » et « Page précédente ». Finalement, filter est un entier compris entre 0 et 2, qui spécifie lequel des trois filtres est utilisé sur la texture que nous appliquons au cube et donc sa qualité de rendu.

Regardons maintenant le code qui gère le filtre. La première modification est dans le code qui charge la texture, un peu plus haut à environ au tiers de la page. Le code est tellement modifié depuis la dernière fois que je ne mettrai rien en avant. Par contre, il devrait être similaire dans sa forme si ce n'est pas dans les détails :

 
Sélectionnez
  function handleLoadedTexture(textures) { 
    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); 

    gl.bindTexture(gl.TEXTURE_2D, textures[0]); 
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textures[0].image); 
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); 
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); 

    gl.bindTexture(gl.TEXTURE_2D, textures[1]); 
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textures[1].image); 
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); 
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); 

    gl.bindTexture(gl.TEXTURE_2D, textures[2]); 
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textures[2].image); 
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); 
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_NEAREST); 
    gl.generateMipmap(gl.TEXTURE_2D); 

    gl.bindTexture(gl.TEXTURE_2D, null); 
  } 

  var crateTextures = Array(); 

  function initTexture() { 
    var crateImage = new Image(); 

    for (var i=0; i < 3; i++) { 
      var texture = gl.createTexture(); 
      texture.image = crateImage; 
      crateTextures.push(texture); 
    } 

    crateImage.onload = function() { 
      handleLoadedTexture(crateTextures) 
    } 
    crateImage.src = "crate.gif"; 
  }

D'abord, regardons la fonction initTexture et la variable globale crateTextures. Il devrait être clair que même si le code a changé, la seule différence est que nous créons trois objets WebGL texture dans un tableau au lieu d'un seul et que nous passons ce tableau à handleLoadedTexture dans la fonction callback lorsque l'image est chargée. Et, bien sûr, nous allons charger une image différente de la dernière fois, crate.gif à la place de nehe.gif.

Les changements dans handleLoadedTexture ne sont pas difficiles à comprendre. Précédemment, nous n'initialisions qu'une seule texture WebGL avec les données de l'image et nous définissions deux paramètres sur celle-ci : gl.TEXTURE_MAG_FILTER et gl.TEXTURE_MIN_FILTER tous deux à gl_NEAREST. Maintenant, nous initialisons trois textures dans notre tableau avec la même image, mais nous définissons des paramètres différents pour chacune et il y a un supplément de code pour la dernière. Voyons voir en détails les différences :

III-A. Filtre « nearest »

La première texture possède gl.TEXTURE_MAG_FILTER et gl.TEXTURE_MIN_FILTER tous deux définis à gl.NEAREST. C'est notre initialisation de base et cela signifie que pour les cas d'agrandissement et de réduction de la texture, WebGL doit utiliser un filtre qui détermine la couleur d'un point précis simplement en regardant le point le plus proche dans l'image source. Le rendu sera correct si la texture n'est pas redimensionnée mais plutôt limite lorsqu'elle est réduite. Par contre, lorsque la texture est agrandie, elle paraîtra « pixelisée », car l'algorithme effectue un redimensionnement des pixels de l'image source.

III-B. Filtre linéaire

Pour la seconde texture, gl.TEXTURE_MAG_FILTER et gl.TEXTURE_MIN_FILTER sont tous les deux à gl.LINEAR. Ici, nous utilisons encore le même filtre pour l'agrandissement et la réduction. Par contre, l'algorithme linéaire peut mieux fonctionner pour l'agrandissement des textures. Il utilise essentiellement une interpolation linéaire entre les pixels de l'image source. En général, un pixel se trouvant au milieu d'un noir et d'un blanc sera gris. Cela donne un effet plus doux, bien que (évidemment) les arêtes brutes deviennent un peu floues. (Pour être honnête, dès que vous agrandirez une image, cela ne sera jamais parfait. Vous ne pouvez pas avoir de détails là où il n'y en a pas.)

III-C. Mipmaps

Pour la troisième texture, gl.TEXTURE_MAG_FILTER est défini à la valeur gl.LINEAR et gl.TEXTURE_MIN_FILTER à la valeur gl.LINEAR_MIPMAP_NEAREST. C'est la plus complexe des trois options.

Le filtrage linéaire donne raisonnablement de bons résultats lorsque vous agrandissez la texture, mais n'est pas mieux que le filtre « nearest » lors d'un rétrécissement. En réalité, les deux filtres peuvent provoquer de très mauvais effets d'aliasing. Pour voir à quoi ils ressemblent, chargez la démonstration une nouvelle fois pour utiliser le filtrage « nearest » (ou cliquez sur le bouton de rechargement de la page afin de revenir à l'état initial) et maintenez la touche « Page suivante » pendant quelques secondes pour dézoomer. Au bout d'un moment, vous allez voir le cube qui « scintille » : les lignes verticales apparaissent et disparaissent. Une fois que vous avez remarqué cela, revenez à la taille initiale et zoomez un peu plus. Vous notez que vous obtenez un effet similaire. Appuyez maintenant sur la touche F pour utiliser le filtrage mipmap, zoomez et dézoomez une nouvelle fois. Vous ne devriez plus voir cet effet, ou celui-ci considérablement réduit.

Maintenant, même si le cube est très loin, disons, 10 % de la largeur/hauteur du canvas, alternez les différents filtres sans bouger. Avec les filtres linéaires et « nearest », vous remarquez que les lignes sombres constituant le bois de la texture sont très claires, alors que d'autres ont disparu. Le cube apparaît un peu « tacheté ». Le filtrage « nearest » est très mauvais, mais le filtrage linéaire n'est pas beaucoup mieux. Seul un filtrage par mipmap fonctionne correctement.

Ce qu'il se passe avec les filtres linéaires et « nearest » est que lorsque la texture est rétrécie à (disons) un dixième de sa taille, le filtre utilise chaque dixième pixel de l'image originale pour créer la version rétrécie. Imaginons une texture représentant du bois, qui est donc constituée majoritairement de marron clair mais contenant de fines lignes sombres et qui possède une ligne d'un pixel de largeur, autrement dit une trace sombre apparaît tous les dix pixels horizontalement. Si l'image est rétrécie à un dixième, alors il y a une chance sur dix qu'un pixel soit sombre et neuf chances sur dix de l'avoir marron clair. Ou, dit autrement, une chance sur dix d'avoir le pixel de l'image d'origine représenté correctement par rapport à l'image en taille réelle et les autres sont complètement cachés. Cela provoque cet effet « tacheté » et rajoute aussi le scintillement lors du changement d'échelle. En effet, les lignes sombres qui sont choisies peuvent être complètement différentes lorsque le redimensionnement est de 9.9, 10.0 et 10.1.

Ce que nous aimerions faire dans cette situation où l'image est redimensionnée à un dixième de sa taille d'origine, est que chaque pixel soit colorié suivant la moyenne du bloc des dix pixels redimensionnés alentours. Faire cela continuellement est trop coûteux pour les graphismes temps réel et c'est là que le filtrage mipmap entre en scène.

Le filtrage par mipmap résout le problème en générant un nombre d'images supplémentaires (appelée niveaux de mip) pour la texture. Cela est fait pour un redimensionnement de moitié, d'un quart, d'un huitième et ainsi de suite jusqu'à atteindre une version d'un pixel sur un. L'ensemble de ces niveaux de mip est appelé une mipmap. Chaque niveau de mip est une version moyennée de la version de taille supérieure et donc la version adéquate peut être choisie pour le redimensionnement en cours. L'algorithme dépend de la valeur utilisée pour gl.TEXTURE_MIN_FILTER, sachant que celle que nous avons choisie signifie « trouve le niveau de mip le plus proche et effectue un filtrage linéaire pour obtenir le pixel ».

Maintenant que j'ai expliqué tout cela, il est clair que la ligne que nous avons ajoutée pour notre texture :

 
Sélectionnez
    gl.generateMipmap(gl.TEXTURE_2D);

C'est la ligne nécessaire pour indiquer à WebGL de générer la mipmap.

Bien, cela constitue beaucoup plus que ce que j'avais prévu d'écrire sur les mipmaps, mais je pense que c'est assez clair Image non disponible Dites-le moi dans les commentaires s'il reste des choses inexpliquées.

Revenons au reste du code. Jusqu'ici, nous avons analysé les variables globales et vu comment les textures sont chargées et initialisées. Maintenant, voyons comment les variables globales et les textures sont utilisées lorsque nous dessinons la scène.

drawScene est à environ au trois quarts de la page et ne possède que trois modifications. La première est que lorsque nous nous positionnons pour dessiner le cube, au lieu d'utiliser un point fixe, nous utilisons la variable globale z :

 
Sélectionnez
    mat4.translate(mvMatrix, [0.0, 0.0, z]);

La prochaine est en fait la ligne que nous avions retirée du code de la cinquième leçon. Maintenant, nous ne tournons plus du tout autour de l'axe des Z, les rotations s'effectuent uniquement autour des axes X et Y :

 
Sélectionnez
    mat4.rotate(mvMatrix, degToRad(xRot), [1, 0, 0]); 
    mat4.rotate(mvMatrix, degToRad(yRot), [0, 1, 0]);

Finalement, lorsque nous dessinons le cube, nous devons spécifier quelle texture nous souhaitons utiliser :

 
Sélectionnez
    gl.bindTexture(gl.TEXTURE_2D, crateTextures[filter]); // NOUVEAU

C'est tout pour les changements de drawScene. Il y a aussi d'autres changements mineurs dans la fonction animate : à la place de changer les variables xRot et yRot à des vitesses constantes, nous utilisons maintenant nos nouvelles variables xSpeed et ySpeed.

 
Sélectionnez
      xRot += (xSpeed * elapsed) / 1000.0; 
      yRot += (ySpeed * elapsed) / 1000.0;

Et c'est fini pour les modifications du code sauf pour la partie qui prend en charge les actions de l'utilisateur et la mise à jour des variables globales. Regardons, maintenant cela.

IV. La gestion du clavier

La première modification significative se situe tout en bas de la page dans webGLStart, où nous avons ajouté deux nouvelles lignes :

 
Sélectionnez
  function webGLStart() { 
    var canvas = document.getElementById("lesson06-canvas"); 
    initGL(canvas); 
    initShaders(); 
    initBuffers(); 
    initTexture(); 

    gl.clearColor(0.0, 0.0, 0.0, 1.0); 
    gl.enable(gl.DEPTH_TEST); 

    document.onkeydown = handleKeyDown; // NOUVEAU
    document.onkeyup = handleKeyUp;         // NOUVEAU

    tick(); 
  }

Très simplement, nous indiquons à JavaScript la routine qui doit être appelée lorsqu'une touche est appuyée (lorsque le focus est sur la page). Lorsque la touche est enfoncée, la fonction handleKeyDown est appelée et lorsqu'elle est relâchée, la fonction handleKeyUp est appelée.

Regardons ensuite ces fonctions. Elles se trouvent à la moitié de la page, juste en dessous des variables globales que nous avons vues plus tôt et ressemblent à cela :

 
Sélectionnez
  var currentlyPressedKeys = {}; 

  function handleKeyDown(event) { 
    currentlyPressedKeys[event.keyCode] = true; 

    if (String.fromCharCode(event.keyCode) == "F") { 
      filter += 1; 
      if (filter == 3) { 
        filter = 0; 
      } 
    } 
  } 

  function handleKeyUp(event) { 
    currentlyPressedKeys[event.keyCode] = false; 
  }

Ce que nous faisons ici est de maintenir un dictionnaire (que vous pouvez aussi connaître sous le nom de « table de hachage » (« hashtable ») ou encore sous le nom « tableau associatif »), qui, suivant une clé donnée - ici, un identifiant JavaScript pour la touche du clavier - nous indiquera si cette touche est actuellement appuyée par l'utilisateur ou pas. Si vous n'êtes pas familier avec la façon dont JavaScript fonctionne, vous pouvez trouver intéressant de noter que n'importe quel objet peut être utilisé tel un dictionnaire comme ceci. Bien que la syntaxe que nous avons utilisé pour initialiser currentlyPressedKeys ressemble à un dictionnaire en Python, ce n'est ici qu'une instance « vide » du type de l'objet de base.

En plus de maintenir un dictionnaire de touches qui sont actuellement appuyées, nous avons d'autres trucs dans le gestionnaire d'appui de touches spécifique à la touche 'F'. Dans ce code, nous alternons les valeurs du filtre filter aux valeurs 0, 1 et 2 à chaque appui sur la touche.

Il est nécessaire de prendre du temps pour expliquer la raison d'une gestion différente pour différentes touches. Dans un jeu vidéo, ou dans tout autre système 3D, les appuis sur les touches peuvent fonctionner dans l'un de ces deux cas :

  1. Ils peuvent correspondre à une action immédiate : « Tirer un coup de feu ». Les appuis de touches comme ceci peuvent se répéter automatiquement après un temps donné, par exemple toutes les 500 ms ;
  2. Ils peuvent effectuer une action selon le temps de l'appui. Par exemple, lorsque vous appuyez sur une touche pour avancer, vous vous attendez à ce que le personnage avance tant que vous appuyez sur la touche.

Le plus important avec la seconde solution, c'est que vous voulez pouvoir appuyer sur plusieurs touches au même moment, donc que vous pouvez (par exemple) courir vers l'avant puis tourner à un coin et tirer sans vous arrêter de courir. Cette méthode est complètement différente de la méthode pour lire le clavier utilisée dans les « éditeurs de texte ». Si vous maintenez appuyé la touche 'A' dans un éditeur vous allez obtenir une suite de 'A', mais si vous appuyez sur 'B' alors que vous maintenez la touche 'A' vous allez avoir un 'B' et le flux de 'A' s'arrêtera. Ce comportement appliqué à un jeu serait que vous vous arrêteriez de courir à chaque fois que vous tourneriez, ce qui pourrait être très frustrant.

Donc, dans le code que nous venons de voir, la touche 'F' est gérée avec la première méthode. Le dictionnaire est utilisé par le code utilisant la seconde méthode. Il garde une trace de toutes les touches qui sont actuellement appuyées et non pas que de la dernière.

Le dictionnaire est actuellement utilisé dans une autre fonction, handleKeys, qui vient dans la prochaine page. Avant de l'analyser, jetez un coup d'œil au bas de la page et vous allez voir qu'elle est appelée dans la fonction tick, tout comme drawScene et animate :

 
Sélectionnez
  function tick() { 
    requestAnimFrame(tick); 
    handleKeys(); // NOUVEAU
    drawScene(); 
    animate(); 
  }

Voici à quoi ressemble la fonction handleKeys :

 
Sélectionnez
  function handleKeys() { 
    if (currentlyPressedKeys[33]) { 
      // Page précédente 
      z -= 0.05; 
    } 
    if (currentlyPressedKeys[34]) { 
      // Page suivante
      z += 0.05; 
    } 
    if (currentlyPressedKeys[37]) { 
      // Flèche gauche
      ySpeed -= 1; 
    } 
    if (currentlyPressedKeys[39]) { 
      // Flèche droite
      ySpeed += 1; 
    } 
    if (currentlyPressedKeys[38]) { 
      // Flèche haut
      xSpeed -= 1; 
    } 
    if (currentlyPressedKeys[40]) { 
      // Flèche bas
      xSpeed += 1; 
    } 
  }

C'est une autre longue mais simple fonction. Tout ce qu'elle fait est de vérifier les différentes touches actuellement appuyées et met à jour nos variables globales. De plus, si (disons) les touches fléchées « Haut » et « Droite » sont appuyées, la fonction mettra à jour les deux variables xSpeed et ySpeed qui réagiront donc comme nous le souhaitons.

Et c'est tout pour cette fois ! Maintenant vous savez tout ce qu'il y a à savoir de cette leçon : vous devez avoir une bonne compréhension des différents filtres affectant les textures suivant les redimensionnements et vous savez comment lire les actions sur le clavier des utilisateurs afin que cela fonctionne correctement avec les animations 3D.

Si vous avez une question quelconque, des commentaires ou des corrections, veuillez laisser un commentaire.

Dans la prochaine leçon, nous allons commencer l'éclairage.

V. Remerciements

Le guide de programmation OpenGL ES a été une source d'informations non négligeable à propos des textures et du mipmapping. Le billet de Matthew Casperson sur Bright Hub a été une bonne source d'indices pour faire fonctionner les interactions avec le clavier et ce Kata JavaScript m'a appris à coder quelque chose comme un dictionnaire. Comme toujours, je suis extrêmement redevable envers NeHe pour son tutoriel OpenGL à l'origine du script de cette leçon.

Merci à Winjerome pour ses corrections et _Max_ pour sa relecture orthographique.

Navigation

Tutoriel précédent : introduction aux textures   Sommaire   Tutoriel suivant : les bases de l'éclairage directionnel et ambiant