Navigation

II. Introduction

Bienvenue dans mon dixième tutoriel WebGL basé sur le dixième tutoriel OpenGL de NeHe. Dans cette leçon, nous allons charger une scène 3D depuis un fichier (ce qui implique que nous pouvons facilement changer le fichier pour étendre la démonstration) et écrire du code simple pour se déplacer à l'intérieur - une sorte de mini Doom, avec son propre format de fichier WAD.

Voici à quoi ressemble la leçon lorsqu'elle est exécutée sur un navigateur qui prend en charge WebGL :



Cliquez ici pour voir la version live WebGL si vous avez un navigateur qui la prend en charge ; voici comment en obtenir un si vous n'en avez pas. Vous devriez vous retrouver dans une pièce avec des murs tapissés des photos de Lionel Brits qui a écrit le tutoriel OpenGL d'origine sur lequel cet article repose. Vous pouvez vous déplacer dans cette pièce ainsi qu'à l'extérieur à l'aide des touches fléchées ou WASD, regarder vers le haut ou vers le bas avec les touches Page précédente et Page suivante. Pour un peu plus de réalisme, vous pouvez observer que votre point de vue monte et descend lorsque vous vous déplacez.

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

Ces leçons sont destinées aux personnes ayant de bonnes connaissances en programmation, mais sans réelle expérience dans la 3D, le but est de vous donner les moyens de démarrer, avec une bonne compréhension de ce qui se passe dans le code, afin que vous puissiez commencer à produire vos propres pages Web 3D aussi rapidement que possible. Si vous n'avez pas lu les tutoriels précédents, vous devriez le faire avant de lire celui-ci. Je n'expliquerai ici que les nouveaux éléments.

Il y a deux façons d'obtenir le code de cet exemple, il suffit d'« Afficher la source » pendant que vous visionnez la version live, ou si vous utilisez GitHub, vous pouvez le cloner (ainsi que les autres leçons) à partir du dépôt.

Ce tutoriel charge les détails de la scène depuis un fichier annexe, donc si ce fichier est local à votre machine cela ne fonctionnera pas sous Chrome. Il s'agit d'une « fonctionnalité » de sécurité de Chrome. Donc si vous souhaitez visualiser une version sur votre machine, vous devrez soit utiliser Firefox bêta ou Minefield, soit installer un serveur Web et afficher la page par son biais.

III. Leçon

Tout comme dans les dernières leçons, la meilleure façon d'expliquer ce qui se passe est de commencer par la fin et remonter. Commençons avec le code HTML dans le corps des étiquettes du bas, qui (pour la première fois depuis la première leçon !) possède quelque chose d'intéressant dedans :

 
Sélectionnez
<body onload="webGLStart();">
    <a href="http://jeux.developpez.com/tutoriels/OpenGL/WebGL/10-chargement-monde-gestion-camera/">&lt;&lt;Apprendre WebGL — leçon 10</a><br>

    <canvas id="lesson10-canvas" style="border: none;" width="500" height="500"></canvas>

    <div id="loadingtext">Chargement du monde...</div>

    <br>
    Utilisez les touche flechées ou WASD pour explorer, et <code>Page suivante</code>/<code>Page précédente</code> pour regarder en haut et en bas.

    <br>
    <br>
    <a href="http://jeux.developpez.com/tutoriels/OpenGL/WebGL/10-chargement-monde-gestion-camera/">&lt;&lt;Apprendre WebGL — leçon 10</a>

</body></html>

Nous avons une division DIV HTML contenant un emplacement réservé au texte à afficher lors du chargement du monde ; si la connexion entre mon serveur et votre machine était lente lorsque vous avez chargé la démonstration ci-dessus, vous l'avez peut-être vu. Cependant, le message est apparu au-dessus du canvas, et non dessous comme on l'attendait. Ce mouvement a été géré par un morceau de code CSS plus haut, juste à la fin de l'entête HTML :

 
Sélectionnez
<style type="text/css">
    #loadingtext {
        position:absolute;
        top:250px;
        left:150px;
        font-size:2em;
        color: white;
    }
</style>

Voilà pour le HTML, passons au JavaScript.

Un premier changement à voir se trouve dans la fonction webGLStart ; en plus des configurations habituelles, elle appelle une nouvelle fonction qui charge le monde depuis le serveur.

 
Sélectionnez
  function webGLStart() {
    var canvas = document.getElementById("lesson10-canvas");
    initGL(canvas);
    initShaders();
    initTexture();
    loadWorld(); // NOUVEAU

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

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

    tick();
  }

Passons au code de la fonction loadWorld juste au-dessus de drawScene, aux environs des du fichier, qui ressemble à ceci :

 
Sélectionnez
  function loadWorld() {
    var request = new XMLHttpRequest();
    request.open("GET", "world.txt");
    request.onreadystatechange = function() {
      if (request.readyState == 4) {
        handleLoadedWorld(request.responseText);
      }
    }
    request.send();
  }

Ce style de code peut vous sembler familier, car il est très similaire à celui que nous avons utilisé pour charger les textures. Nous créons un objet XMLHttpRequest qui va gérer l'ensemble du chargement, et lui disons d'utiliser une requête HTTP GET pour obtenir le fichier appelé world.txt depuis le même répertoire sur le même serveur que la page courante. Nous spécifions une fonction callback devant être appelée à différentes étapes du téléchargement, qui à son tour appelle handleLoadedWorld lorsque l'objet XMLHttpRequest signale que son readyState est à 4, ce qui est le cas lorsque le fichier est complètement chargé. Une fois que tout cela a été mis en place, nous demandons à l'objet XMLHttpRequest de démarrer le processus d'obtention du fichier en appelant sa méthode send.

Passons à la fonction handleLoadedWorld, située au-dessus de la fonction loadWorld.

 
Sélectionnez
  var worldVertexPositionBuffer = null;
  var worldVertexTextureCoordBuffer = null;
  function handleLoadedWorld(data) {

Le but de cette fonction est d'analyser le contenu du fichier chargé et de l'utiliser pour créer deux tampons du même genre que dans les leçons précédentes. Le contenu du fichier chargé est transmis par le paramètre de type string nommé data. La première partie du code effectue simplement son analyse. Le format de fichier utilisé pour cet exemple est très simple : il contient une liste de triangles, chacun spécifié par trois vertex. Chaque vertex est sur une même ligne qui contient cinq valeurs : les coordonnées X, Y et Z, et les coordonnées de texture S et T. Le fichier contient également des commentaires (lignes commençant par //) et des lignes vides, qui sont tous deux ignorés. La toute première ligne spécifie le nombre total de triangles (même si nous ne l'utilisons pas vraiment).

Maintenant, est-ce un bon format de fichier ? Eh bien, en fait non, il est même plutôt mauvais ! Il manque beaucoup d'informations à mettre dans une vraie scène. Par exemple, les normales ou les différentes textures des objets. Dans un monde réel, vous devez utiliser un format différent. Bien que j'ai eu du mal avec, le format JSON est celui utilisé dans la leçon OpenGL originale et il est agréable et simple à utiliser. Je n'expliquerai pas le code de sa lecture en détail. Le voici :

 
Sélectionnez
    var lines = data.split("\n");
    var vertexCount = 0;
    var vertexPositions = [];
    var vertexTextureCoords = [];
    for (var i in lines) {
      var vals = lines[i].replace(/^\s+/, "").split(/\s+/);
      if (vals.length == 5 && vals[0] != "//") {
        // C'est une ligne décrivant un vertex ; obtention de X, Y et Z en premier
        vertexPositions.push(parseFloat(vals[0]));
        vertexPositions.push(parseFloat(vals[1]));
        vertexPositions.push(parseFloat(vals[2]));

        // Puis les coordonnées de texture
        vertexTextureCoords.push(parseFloat(vals[3]));
        vertexTextureCoords.push(parseFloat(vals[4]));

        vertexCount += 1;
      }
    }

En fin de compte, on analyse chaque ligne constituée des cinq valeurs séparées par des espaces, supposons qu'elles contiennent les vertex, et construisons les tableaux vertexPositions et vertexTextureCoords. On conserve aussi le nombre de vertex dans vertexCount.

Le bout de code suivant devrait vous être familier maintenant :

 
Sélectionnez
    worldVertexPositionBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, worldVertexPositionBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexPositions), gl.STATIC_DRAW);
    worldVertexPositionBuffer.itemSize = 3;
    worldVertexPositionBuffer.numItems = vertexCount;

    worldVertexTextureCoordBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, worldVertexTextureCoordBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexTextureCoords), gl.STATIC_DRAW);
    worldVertexTextureCoordBuffer.itemSize = 2;
    worldVertexTextureCoordBuffer.numItems = vertexCount;

Nous créons deux tampons qui vont contenir les vertex que nous venons de charger. Une fois tout ceci fait, nous effaçons la division HTML DIV qui affichait « Loading World… » :

 
Sélectionnez
    document.getElementById("loadingtext").textContent = "";
}

C'est tout le code nécessaire pour charger le monde à partir d'un fichier. Avant que nous regardions le code qui l'utilise, arrêtons-nous brièvement pour regarder le fichier world.txt. Les trois premiers vertex décrivant le premier triangle de la scène ressemblent à ceci :

 
Sélectionnez
// Sol 1
-3.0  0.0 -3.0 0.0 6.0
-3.0  0.0  3.0 0.0 0.0
 3.0  0.0  3.0 6.0 0.0

Rappelez-vous que ce sont les valeurs de X, Y, Z, S et T où S et T sont les coordonnées de texture. Vous pouvez voir que les coordonnées de texture sont comprises entre 0 et 6. Mais j'ai dit précédemment que les coordonnées de texture vont de 0 à 1. Que se passe-t-il donc ? La réponse est que lorsque vous cherchez un point dans une texture, les coordonnées S et T sont automatiquement prises modulo 1, donc 5.5 correspond au même point de l'image de texture que 0.5. Cela signifie que la texture est automatiquement tessellée de sorte qu'elle se répète autant de fois que nécessaire pour remplir le triangle. Ceci est évidemment très utile lorsque vous avez une petite texture à appliquer sur un grand objet - par exemple une texture de briques pour couvrir un mur.

OK, passons à la prochaine partie intéressante du code : la fonction drawScene. Pour commencer, elle vérifie si les tampons créés lorsque nous avons fini de charger notre monde ont été chargés ou non. S'ils ne le sont pas, on sort de la fonction :

 
Sélectionnez
  function drawScene() {
    gl.viewport(0, 0, gl.viewportWidth, gl.viewportHeight);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    if (worldVertexTextureCoordBuffer == null || worldVertexPositionBuffer == null) {
      return;
    }

Si les tampons sont bien chargés, nous effectuons notre configuration habituelle des matrices de projection et modèle-vue.

  javascript   0 1  
mat4.perspective(45, gl.viewportWidth / gl.viewportHeight, 0.1, 100.0, pMatrix);
mat4.identity(mvMatrix);

L'étape suivante consiste à gérer notre caméra - à savoir nous voulons que notre point de vue se déplace à travers la scène. La première chose à retenir est que, comme tant de choses, WebGL ne supporte pas directement les caméras, mais en simuler une est assez facile. Pour cet exemple simple, si nous avions une caméra, nous voudrions pouvoir la positionner à une coordonnée particulière X, Y, Z ; avoir une certaine inclinaison autour de l'axe X pour regarder vers le haut ou le bas (appelé tangage) et un certain angle autour de l'axe Y pour tourner à gauche ou à droite (appelé lacet). Parce que nous ne pouvons pas changer la position de la caméra - qui est toujours positionnée en (0, 0, 0) orientée vers le bas selon l'axe Z, nous voulons en quelque sorte dire à WebGL d'ajuster la scène à dessiner en utilisant les coordonnées X, Y, Z de l'espace monde dans un nouveau cadre de référence basé sur la position et la rotation de la caméra, que nous appelons l'espace caméra.

Je vais m'aider d'un exemple. Imaginons que nous avons une scène très simple, avec un cube de centre (1, 2, 3) dans l'espace monde. Rien d'autre. On veut simuler une caméra positionnée en (0, 0, 7), orientée vers le bas selon l'axe Z, sans tangage ni lacet. Pour ce faire, nous effectuons la transformée de l'espace monde vers l'espace caméra des coordonnées du centre du cube et nous nous retrouvons avec (1, 2, -4). La rotation complique un peu les choses, mais pas beaucoup.

Il est assez évident que c'est un cas d'utilisation des matrices. Nous pourrions garder une « matrice caméra » représentant la position et la rotation de la caméra. Mais pour cet exemple, nous pouvons faire encore plus simple en utilisant notre matrice modèle-vue existante.

Il s'avère que (cela peut être intuitif en extrapolant à partir de l'exemple précédent) nous pouvons simuler une caméra en « faisant défiler » la scène dans la direction opposée à laquelle irait la caméra, et la dessiner en utilisant notre système habituel de coordonnées relatives. Si nous nous mettions à la place de la caméra, nous nous positionnerions en nous déplaçant à la bonne position, puis tournerions de manière appropriée. Donc, pour « défiler », il nous suffit d'annuler la rotation puis le mouvement.

D'un point de vue plus mathématique, on peut simuler une caméra positionnée en (x, y, z), tournée d'un lacet de ψ et un tangage de θ en effectuant une première rotation de -θ autour de l'axe X, puis de -ψ autour l'axe Y, et se déplaçant enfin de (-x, -y, -z). À partir de là, la matrice modèle-vue permettra de facilement utiliser les coordonnées de l'espace monde qui seront automatiquement converties en coordonnées de l'espace caméra par la magie de la multiplication de matrices dans le vertex shader.

Il y a d'autres façons d'effectuer le positionnement des caméras, nous les passerons en revue dans les leçons suivantes. Pour l'instant, voici le code de celle-ci :

 
Sélectionnez
    mat4.rotate(mvMatrix, degToRad(-pitch), [1, 0, 0]);
    mat4.rotate(mvMatrix, degToRad(-yaw), [0, 1, 0]);
    mat4.translate(mvMatrix, [-xPos, -yPos, -zPos]);

OK, une fois ceci fait, tout ce qu'il nous reste à faire est de dessiner la scène, contenue dans les tampons chargés plus tôt. Voici le code qui devrait être assez semblable à celui des leçons précédentes :

 
Sélectionnez
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, mudTexture);
    gl.uniform1i(shaderProgram.samplerUniform, 0);

    gl.bindBuffer(gl.ARRAY_BUFFER, worldVertexTextureCoordBuffer);
    gl.vertexAttribPointer(shaderProgram.textureCoordAttribute, worldVertexTextureCoordBuffer.itemSize, gl.FLOAT, false, 0, 0);

    gl.bindBuffer(gl.ARRAY_BUFFER, worldVertexPositionBuffer);
    gl.vertexAttribPointer(shaderProgram.vertexPositionAttribute, worldVertexPositionBuffer.itemSize, gl.FLOAT, false, 0, 0);

    setMatrixUniforms();
    gl.drawArrays(gl.TRIANGLES, 0, worldVertexPositionBuffer.numItems);
  }

Nous avons maintenant couvert l'essentiel du nouveau code de cette leçon. Le dernier point concerne celui utilisé pour contrôler notre mouvement, à savoir le mouvement de « secousse » lorsque vous courez. Comme dans les leçons précédentes, les actions du clavier dans cette page sont conçues pour donner à chacun la même vitesse de déplacement, que votre machine soit lente ou rapide. Les propriétaires de machines plus rapides obtiennent seulement l'avantage d'un meilleur taux de rafraîchissement des images, pas d'un mouvement plus rapide !

Dans notre fonction handleKeys, nous utilisons le jeu de touches actuellement pressées par l'utilisateur pour travailler sur notre vitesse, constituée par une variation de position, une variation du tangage, et une variation du lacet. Celles-ci seront mises à zéro si aucune touche n'est pressée, ou mises à des valeurs fixées, en unités par milliseconde selon les touches pressées. Voici le code, que vous trouverez aux environs des deux tiers du fichier :

 
Sélectionnez
  var pitch = 0;
  var pitchRate = 0;

  var yaw = 0;
  var yawRate = 0;

  var xPos = 0;
  var yPos = 0.4;
  var zPos = 0;

  var speed = 0;

  function handleKeys() {
    if (currentlyPressedKeys[33]) {
      // Page précédente
      pitchRate = 0.1;
    } else if (currentlyPressedKeys[34]) {
      // Page suivante
      pitchRate = -0.1;
    } else {
      pitchRate = 0;
    }

    if (currentlyPressedKeys[37] || currentlyPressedKeys[65]) {
      // Flèche gauche ou A
      yawRate = 0.1;
    } else if (currentlyPressedKeys[39] || currentlyPressedKeys[68]) {
      // Flèche droite ou D
      yawRate = -0.1;
    } else {
      yawRate = 0;
    }

    if (currentlyPressedKeys[38] || currentlyPressedKeys[87]) {
      // Flèche du haut ou W
      speed = 0.003;
    } else if (currentlyPressedKeys[40] || currentlyPressedKeys[83]) {
      // Flèche du bas ou S
      speed = -0.003;
    } else {
      speed = 0;
    }

  }

Donc, en prenant l'exemple précédent, si la flèche gauche est enfoncée alors notre yawRate est fixé à 0.1°/ms (ou 100°/s) en d'autres termes, nous commençons à tourner à gauche à la vitesse d'un tour toutes les 3.6 secondes.

Ces taux de variation sont, comme on pouvait s'y attendre des leçons précédentes, utilisés dans la fonction animate pour définir les valeurs de xPos et zPos, de même que le lacet et le tangage. yPos est également fixé dans animate, mais selon une logique légèrement différente. Jetons un œil à tout cela. Vous pouvez voir le code juste en dessous de la fonction drawScene, près de la fin du fichier. Voici les premières lignes :

 
Sélectionnez
  var lastTime = 0;
// Utilisé pour nous faire secouer de haut en bas lorsque nous avançons
  var joggingAngle = 0; // NOUVEAU
  function animate() {
    var timeNow = new Date().getTime();
    if (lastTime != 0) {
      var elapsed = timeNow - lastTime;

Ceci est notre code normal servant à retrouver le nombre de millisecondes écoulées depuis le dernier appel de la fonction animate. Intéressons-nous à la variable joggingAngle. Notre effet de « secousse » est obtenu en suivant une onde sinusoïdale selon notre position Y autour d'une valeur centrale à « hauteur de tête » à l'état de repos. joggingAngle est l'angle que nous rentrons dans la fonction sinus afin d'obtenir notre position actuelle.

Regardons le code qui effectue cela, et ajuste également xPos et zPos pour permettre notre mouvement :

 
Sélectionnez
      if (speed != 0) {
        xPos -= Math.sin(degToRad(yaw)) * speed * elapsed;
        zPos -= Math.cos(degToRad(yaw)) * speed * elapsed;

        joggingAngle += elapsed * 0.6;  // 0.6 « bricolage » pour obtenir un effet réaliste :)
        yPos = Math.sin(degToRad(joggingAngle)) / 20 + 0.4
      }

Évidemment, le changement de position et l'effet de secousse ne doivent avoir lieu que lorsque nous sommes réellement en mouvement. Donc si la vitesse n'est pas nulle, xPos et zPos sont réglés par la vitesse actuelle moyennant un peu de trigonométrie (en utilisant la fonction degToRad pour pouvoir convertir nos angles des degrés (pour plus de lisibilité) vers les radians que les fonctions JavaScript utilisent). Ensuite, joggingAngle est modifiée et utilisée pour calculer notre yPos actuel. Tous les chiffres que nous utilisons sont multipliés par le nombre de millisecondes écoulé depuis le dernier appel, qui prennent tout leur sens en termes de vitesse, à savoir en unités par milliseconde. La valeur 0.6, servant à pondérer le nombre de millisecondes écoulées sur joggingAngle, a été déterminée par des essais pour avoir un bel effet réaliste.

Une fois ceci fait, nous devons ajuster le lacet et le tangage par leur variation respective, ce qui peut être fait, même si l'on ne bouge pas :

 
Sélectionnez
      yaw += yawRate * elapsed;
      pitch += pitchRate * elapsed;

Il nous reste à sauvegarder le temps actuel pour pouvoir obtenir le temps écoulé au prochain appel de la fonction animate :

 
Sélectionnez
    }
    lastTime = timeNow;
  }

Et c'est fini ! Maintenant vous savez tout de cette leçon : charger simplement une scène à partir d'un fichier texte, et mettre en œuvre une caméra.

La leçon suivante vous montrera comment afficher une sphère et la faire tourner en utilisant les événements de la souris, et vous expliquera également comment utiliser les matrices de rotation pour éviter un problème irritant appelé blocage de cardan.

IV. Remerciements

La version originale de ce tutoriel a été écrite par Lionel Brits alias βetelgeuse, et a été publiée sur le site de NeHe (et traduite par developpez.com). Cette question sur Stack Overflow m'a montré comment charger un fichier texte à partir de JavaScript, et cette page m'a aidé à déboguer le code qui en résulte.

Merci à LittleWhite pour sa relecture attentive et Max pour sa relecture orthographique.

Navigation