Graphiques 3D : un didacticiel WebGL
Publié: 2022-03-11Le monde des graphismes 3D peut être très intimidant. Que vous souhaitiez simplement créer un logo 3D interactif ou concevoir un jeu à part entière, si vous ne connaissez pas les principes du rendu 3D, vous êtes bloqué à l'aide d'une bibliothèque qui résume beaucoup de choses.
L'utilisation d'une bibliothèque peut être juste le bon outil, et JavaScript en a un étonnant open source sous la forme de three.js. Il y a cependant quelques inconvénients à utiliser des solutions préfabriquées :
- Ils peuvent avoir de nombreuses fonctionnalités que vous ne prévoyez pas d'utiliser. La taille des fonctionnalités minifiées de la base three.js est d'environ 500 Ko, et toute fonctionnalité supplémentaire (le chargement des fichiers de modèle réels en fait partie) rend la charge utile encore plus grande. Transférer autant de données juste pour afficher un logo tournant sur votre site Web serait un gaspillage.
- Une couche supplémentaire d'abstraction peut rendre difficiles les modifications autrement simples. Votre façon créative d'ombrager un objet à l'écran peut être simple à mettre en œuvre ou nécessiter des dizaines d'heures de travail pour être incorporée dans les abstractions de la bibliothèque.
- Bien que la bibliothèque soit très bien optimisée dans la plupart des scénarios, de nombreuses options peuvent être supprimées pour votre cas d'utilisation. Le moteur de rendu peut entraîner l'exécution de certaines procédures des millions de fois sur la carte graphique. Chaque instruction supprimée d'une telle procédure signifie qu'une carte graphique plus faible peut gérer votre contenu sans problème.
Même si vous décidez d'utiliser une bibliothèque graphique de haut niveau, avoir une connaissance de base des choses sous le capot vous permet de l'utiliser plus efficacement. Les bibliothèques peuvent également avoir des fonctionnalités avancées, comme ShaderMaterial
dans three.js
. Connaître les principes du rendu graphique vous permet d'utiliser de telles fonctionnalités.
Notre objectif est de donner une brève introduction à tous les concepts clés derrière le rendu des graphiques 3D et l'utilisation de WebGL pour les mettre en œuvre. Vous verrez la chose la plus courante qui est faite, qui est d'afficher et de déplacer des objets 3D dans un espace vide.
Le code final est disponible pour que vous puissiez bifurquer et jouer avec.
Représenter des modèles 3D
La première chose que vous devez comprendre est la manière dont les modèles 3D sont représentés. Un modèle est constitué d'un maillage de triangles. Chaque triangle est représenté par trois sommets, pour chacun des coins du triangle. Il existe trois propriétés les plus courantes attachées aux sommets.
Position du sommet
La position est la propriété la plus intuitive d'un sommet. C'est la position dans l'espace 3D, représentée par un vecteur 3D de coordonnées. Si vous connaissiez les coordonnées exactes de trois points dans l'espace, vous auriez toutes les informations nécessaires pour tracer un triangle simple entre eux. Pour que les modèles soient vraiment beaux lors du rendu, il y a quelques éléments supplémentaires qui doivent être fournis au moteur de rendu.
Normale au sommet
Considérez les deux modèles ci-dessus. Ils se composent des mêmes positions de sommet, mais semblent totalement différents lorsqu'ils sont rendus. Comment est-ce possible?
En plus de dire au moteur de rendu où nous voulons qu'un sommet soit situé, nous pouvons également lui donner un indice sur la façon dont la surface est inclinée dans cette position exacte. L'indication se présente sous la forme de la normale de la surface à ce point spécifique du modèle, représentée par un vecteur 3D. L'image suivante devrait vous donner un aperçu plus descriptif de la façon dont cela est géré.
Les surfaces gauche et droite correspondent respectivement aux boules gauche et droite de l'image précédente. Les flèches rouges représentent les normales spécifiées pour un sommet, tandis que les flèches bleues représentent les calculs du moteur de rendu sur l'apparence de la normale pour tous les points entre les sommets. L'image montre une démonstration pour l'espace 2D, mais le même principe s'applique en 3D.
La normale est une indication de la façon dont les lumières éclaireront la surface. Plus la direction d'un rayon lumineux est proche de la normale, plus le point est brillant. Des changements graduels dans la direction normale provoquent des gradients de lumière, tandis que des changements brusques sans changement entre les deux provoquent des surfaces avec un éclairage constant à travers elles et des changements soudains d'éclairage entre elles.
Coordonnées de texture
La dernière propriété importante sont les coordonnées de texture, communément appelées mappage UV. Vous avez un modèle et une texture que vous souhaitez lui appliquer. La texture comporte différentes zones, représentant des images que nous voulons appliquer à différentes parties du modèle. Il doit y avoir un moyen de marquer quel triangle doit être représenté avec quelle partie de la texture. C'est là qu'intervient le mappage de texture.
Pour chaque sommet, nous marquons deux coordonnées, U et V. Ces coordonnées représentent une position sur la texture, U représentant l'axe horizontal et V l'axe vertical. Les valeurs ne sont pas en pixels, mais en pourcentage dans l'image. Le coin inférieur gauche de l'image est représenté par deux zéros, tandis que le coin supérieur droit est représenté par deux uns.
Un triangle est simplement peint en prenant les coordonnées UV de chaque sommet du triangle et en appliquant l'image capturée entre ces coordonnées sur la texture.
Vous pouvez voir une démonstration de cartographie UV sur l'image ci-dessus. Le modèle sphérique a été pris et découpé en parties suffisamment petites pour être aplaties sur une surface 2D. Les coutures où les coupes ont été faites sont marquées de lignes plus épaisses. L'un des patchs a été mis en surbrillance, vous pouvez donc bien voir comment les choses correspondent. Vous pouvez également voir comment une couture au milieu du sourire place les parties de la bouche en deux parties différentes.
Les wireframes ne font pas partie de la texture, mais sont simplement superposés sur l'image afin que vous puissiez voir comment les choses se combinent.
Charger un modèle OBJ
Croyez-le ou non, c'est tout ce que vous devez savoir pour créer votre propre chargeur de modèle simple. Le format de fichier OBJ est suffisamment simple pour implémenter un analyseur en quelques lignes de code.
Le fichier répertorie les positions des sommets dans un format v <float> <float> <float>
, avec un quatrième flottant facultatif, que nous ignorerons, pour simplifier les choses. Les normales des sommets sont représentées de la même manière avec vn <float> <float> <float>
. Enfin, les coordonnées de texture sont représentées par vt <float> <float>
, avec un troisième flottant optionnel que nous ignorerons. Dans les trois cas, les flottants représentent les coordonnées respectives. Ces trois propriétés sont cumulées dans trois tableaux.
Les faces sont représentées par des groupes de sommets. Chaque sommet est représenté avec l'indice de chacune des propriétés, les indices commençant à 1. Il existe différentes façons de le représenter, mais nous nous en tiendrons au f v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3
, exigeant que les trois propriétés soient fournies et limitant à trois le nombre de sommets par face. Toutes ces limitations sont faites pour garder le chargeur aussi simple que possible, puisque toutes les autres options nécessitent un traitement trivial supplémentaire avant d'être dans un format que WebGL aime.
Nous avons mis beaucoup d'exigences pour notre chargeur de fichiers. Cela peut sembler limité, mais les applications de modélisation 3D ont tendance à vous donner la possibilité de définir ces limitations lors de l'exportation d'un modèle en tant que fichier OBJ.
Le code suivant analyse une chaîne représentant un fichier OBJ et crée un modèle sous la forme d'un tableau de faces.
function Geometry (faces) { this.faces = faces || [] } // Parses an OBJ file, passed as a string Geometry.parseOBJ = function (src) { var POSITION = /^v\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var NORMAL = /^vn\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var UV = /^vt\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var FACE = /^f\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)(?:\s+(-?\d+)\/(-?\d+)\/(-?\d+))?/ lines = src.split('\n') var positions = [] var uvs = [] var normals = [] var faces = [] lines.forEach(function (line) { // Match each line of the file against various RegEx-es var result if ((result = POSITION.exec(line)) != null) { // Add new vertex position positions.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = NORMAL.exec(line)) != null) { // Add new vertex normal normals.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = UV.exec(line)) != null) { // Add new texture mapping point uvs.push(new Vector2(parseFloat(result[1]), 1 - parseFloat(result[2]))) } else if ((result = FACE.exec(line)) != null) { // Add new face var vertices = [] // Create three vertices from the passed one-indexed indices for (var i = 1; i < 10; i += 3) { var part = result.slice(i, i + 3) var position = positions[parseInt(part[0]) - 1] var uv = uvs[parseInt(part[1]) - 1] var normal = normals[parseInt(part[2]) - 1] vertices.push(new Vertex(position, normal, uv)) } faces.push(new Face(vertices)) } }) return new Geometry(faces) } // Loads an OBJ file from the given URL, and returns it as a promise Geometry.loadOBJ = function (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(Geometry.parseOBJ(xhr.responseText)) } } xhr.open('GET', url, true) xhr.send(null) }) } function Face (vertices) { this.vertices = vertices || [] } function Vertex (position, normal, uv) { this.position = position || new Vector3() this.normal = normal || new Vector3() this.uv = uv || new Vector2() } function Vector3 (x, y, z) { this.x = Number(x) || 0 this.y = Number(y) || 0 this.z = Number(z) || 0 } function Vector2 (x, y) { this.x = Number(x) || 0 this.y = Number(y) || 0 }
La structure Geometry
contient les données exactes nécessaires pour envoyer un modèle à la carte graphique à traiter. Avant de faire cela, vous voudrez probablement avoir la possibilité de déplacer le modèle sur l'écran.
Effectuer des transformations spatiales
Tous les points du modèle que nous avons chargé sont relatifs à son système de coordonnées. Si nous voulons translater, faire pivoter et mettre à l'échelle le modèle, il nous suffit d'effectuer cette opération sur son système de coordonnées. Le système de coordonnées A, par rapport au système de coordonnées B, est défini par la position de son centre en tant que vecteur p_ab
, et le vecteur de chacun de ses axes, x_ab
, y_ab
et z_ab
, représentant la direction de cet axe. Ainsi, si un point se déplace de 10 sur l'axe x
du système de coordonnées A, alors, dans le système de coordonnées B, il se déplacera dans la direction de x_ab
, multiplié par 10.
Toutes ces informations sont stockées sous la forme matricielle suivante :
x_ab.x y_ab.x z_ab.x p_ab.x x_ab.y y_ab.y z_ab.y p_ab.y x_ab.z y_ab.z z_ab.z p_ab.z 0 0 0 1
Si on veut transformer le vecteur 3D q
, il suffit de multiplier la matrice de transformation par le vecteur :
qx qy qz 1
Cela provoque le déplacement du point de qx
le long du nouvel axe x
, de qy
le long du nouvel axe y
et de qz
le long du nouvel axe z
. Enfin, cela fait déplacer le point en plus du vecteur p
, raison pour laquelle nous utilisons un un comme élément final de la multiplication.
Le grand avantage d'utiliser ces matrices est le fait que si nous avons plusieurs transformations à effectuer sur le sommet, nous pouvons les fusionner en une seule transformation en multipliant leurs matrices, avant de transformer le sommet lui-même.
Il existe différentes transformations qui peuvent être effectuées, et nous allons examiner les principales.
Aucune transformation
Si aucune transformation ne se produit, alors le vecteur p
est un vecteur nul, le vecteur x
est [1, 0, 0]
, y
est [0, 1, 0]
et z
est [0, 0, 1]
. A partir de maintenant, nous nous référerons à ces valeurs comme valeurs par défaut pour ces vecteurs. L'application de ces valeurs nous donne une matrice d'identité :
1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1
C'est un bon point de départ pour enchaîner les transformations.
Traduction
Lorsque nous effectuons une traduction, tous les vecteurs, à l'exception du vecteur p
, ont leurs valeurs par défaut. Cela se traduit par la matrice suivante :
1 0 0 px 0 1 0 py 0 0 1 pz 0 0 0 1
Mise à l'échelle
La mise à l'échelle d'un modèle signifie la réduction de la contribution de chaque coordonnée à la position d'un point. Il n'y a pas de décalage uniforme causé par la mise à l'échelle, donc le vecteur p
conserve sa valeur par défaut. Les vecteurs d'axe par défaut doivent être multipliés par leurs facteurs d'échelle respectifs, ce qui donne la matrice suivante :
s_x 0 0 0 0 s_y 0 0 0 0 s_z 0 0 0 0 1
Ici s_x
, s_y
et s_z
représentent la mise à l'échelle appliquée à chaque axe.
Rotation
L'image ci-dessus montre ce qui se passe lorsque nous faisons pivoter le cadre de coordonnées autour de l'axe Z.
La rotation n'entraîne aucun décalage uniforme, de sorte que le vecteur p
conserve sa valeur par défaut. Maintenant, les choses deviennent un peu plus délicates. Les rotations entraînent un mouvement le long d'un certain axe dans le système de coordonnées d'origine pour se déplacer dans une direction différente. Donc, si nous faisons pivoter un système de coordonnées de 45 degrés autour de l'axe Z, le déplacement le long de l'axe x
du système de coordonnées d'origine provoque un mouvement dans une direction diagonale entre les axes x
et y
dans le nouveau système de coordonnées.
Pour simplifier les choses, nous allons simplement vous montrer comment les matrices de transformation recherchent les rotations autour des axes principaux.
Around X: 1 0 0 0 0 cos(phi) sin(phi) 0 0 -sin(phi) cos(phi) 0 0 0 0 1 Around Y: cos(phi) 0 sin(phi) 0 0 1 0 0 -sin(phi) 0 cos(phi) 0 0 0 0 1 Around Z: cos(phi) -sin(phi) 0 0 sin(phi) cos(phi) 0 0 0 0 1 0 0 0 0 1
Mise en œuvre
Tout cela peut être implémenté sous la forme d'une classe qui stocke 16 nombres, stockant les matrices dans un ordre de colonne majeure.
function Transformation () { // Create an identity transformation this.fields = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] } // Multiply matrices, to chain transformations Transformation.prototype.mult = function (t) { var output = new Transformation() for (var row = 0; row < 4; ++row) { for (var col = 0; col < 4; ++col) { var sum = 0 for (var k = 0; k < 4; ++k) { sum += this.fields[k * 4 + row] * t.fields[col * 4 + k] } output.fields[col * 4 + row] = sum } } return output } // Multiply by translation matrix Transformation.prototype.translate = function (x, y, z) { var mat = new Transformation() mat.fields[12] = Number(x) || 0 mat.fields[13] = Number(y) || 0 mat.fields[14] = Number(z) || 0 return this.mult(mat) } // Multiply by scaling matrix Transformation.prototype.scale = function (x, y, z) { var mat = new Transformation() mat.fields[0] = Number(x) || 0 mat.fields[5] = Number(y) || 0 mat.fields[10] = Number(z) || 0 return this.mult(mat) } // Multiply by rotation matrix around X axis Transformation.prototype.rotateX = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[5] = c mat.fields[10] = c mat.fields[9] = -s mat.fields[6] = s return this.mult(mat) } // Multiply by rotation matrix around Y axis Transformation.prototype.rotateY = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[10] = c mat.fields[2] = -s mat.fields[8] = s return this.mult(mat) } // Multiply by rotation matrix around Z axis Transformation.prototype.rotateZ = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[5] = c mat.fields[4] = -s mat.fields[1] = s return this.mult(mat) }
Regarder à travers une caméra
Voici l'élément clé de la présentation d'objets à l'écran : la caméra. Il y a deux composants clés dans une caméra ; à savoir sa position et la façon dont il projette les objets observés sur l'écran.
La position de la caméra est gérée avec une astuce simple. Il n'y a aucune différence visuelle entre déplacer la caméra d'un mètre vers l'avant et déplacer le monde entier d'un mètre vers l'arrière. Alors naturellement, nous faisons ce dernier, en appliquant l'inverse de la matrice comme une transformation.
Le deuxième élément clé est la manière dont les objets observés sont projetés sur l'objectif. Dans WebGL, tout ce qui est visible à l'écran est situé dans une boîte. La boîte s'étend entre -1 et 1 sur chaque axe. Tout ce qui est visible se trouve dans cette boîte. Nous pouvons utiliser la même approche des matrices de transformation pour créer une matrice de projection.
Projection orthographique
La projection la plus simple est la projection orthographique. Vous prenez une boîte dans l'espace, indiquant la largeur, la hauteur et la profondeur, en supposant que son centre est à la position zéro. Ensuite, la projection redimensionne la boîte pour l'adapter à la boîte décrite précédemment dans laquelle WebGL observe les objets. Puisque nous voulons redimensionner chaque dimension à deux, nous mettons à l'échelle chaque axe de 2/size
, où size
est la dimension de l'axe respectif. Une petite mise en garde est le fait que nous multiplions l'axe Z par un négatif. Ceci est fait parce que nous voulons inverser la direction de cette dimension. La matrice finale a cette forme :
2/width 0 0 0 0 2/height 0 0 0 0 -2/depth 0 0 0 0 1
Projection en perspective
Nous n'entrerons pas dans les détails de la conception de cette projection, mais utilisons simplement la formule finale, qui est à peu près standard maintenant. Nous pouvons le simplifier en plaçant la projection en position zéro sur les axes x et y, rendant les limites droite/gauche et haut/bas égales respectivement à width/2
et height/2
. Les paramètres n
et f
représentent les plans de détourage near
et far
, qui sont la plus petite et la plus grande distance qu'un point peut être capturé par la caméra. Ils sont représentés par les côtés parallèles du tronc dans l'image ci-dessus.
Une projection en perspective est généralement représentée avec un champ de vision (nous utiliserons le champ vertical), un rapport d'aspect et les distances des plans proche et lointain. Ces informations peuvent être utilisées pour calculer width
et height
, puis la matrice peut être créée à partir du modèle suivant :
2*n/width 0 0 0 0 2*n/height 0 0 0 0 (f+n)/(nf) 2*f*n/(nf) 0 0 -1 0
Pour calculer la largeur et la hauteur, les formules suivantes peuvent être utilisées :
height = 2 * near * Math.tan(fov * Math.PI / 360) width = aspectRatio * height
Le FOV (champ de vision) représente l'angle vertical que la caméra capture avec son objectif. Le rapport d'aspect représente le rapport entre la largeur et la hauteur de l'image et est basé sur les dimensions de l'écran sur lequel nous effectuons le rendu.
Mise en œuvre
Nous pouvons maintenant représenter une caméra comme une classe qui stocke la position de la caméra et la matrice de projection. Il faut aussi savoir calculer les transformations inverses. Résoudre des inversions de matrice générales peut être problématique, mais il existe une approche simplifiée pour notre cas particulier.
function Camera () { this.position = new Transformation() this.projection = new Transformation() } Camera.prototype.setOrthographic = function (width, height, depth) { this.projection = new Transformation() this.projection.fields[0] = 2 / width this.projection.fields[5] = 2 / height this.projection.fields[10] = -2 / depth } Camera.prototype.setPerspective = function (verticalFov, aspectRatio, near, far) { var height_div_2n = Math.tan(verticalFov * Math.PI / 360) var width_div_2n = aspectRatio * height_div_2n this.projection = new Transformation() this.projection.fields[0] = 1 / height_div_2n this.projection.fields[5] = 1 / width_div_2n this.projection.fields[10] = (far + near) / (near - far) this.projection.fields[10] = -1 this.projection.fields[14] = 2 * far * near / (near - far) this.projection.fields[15] = 0 } Camera.prototype.getInversePosition = function () { var orig = this.position.fields var dest = new Transformation() var x = orig[12] var y = orig[13] var z = orig[14] // Transpose the rotation matrix for (var i = 0; i < 3; ++i) { for (var j = 0; j < 3; ++j) { dest.fields[i * 4 + j] = orig[i + j * 4] } } // Translation by -p will apply R^T, which is equal to R^-1 return dest.translate(-x, -y, -z) }
C'est la dernière pièce dont nous avons besoin avant de pouvoir commencer à dessiner des choses à l'écran.
Dessiner un objet avec le pipeline graphique WebGL
La surface la plus simple que vous puissiez dessiner est un triangle. En fait, la majorité des choses que vous dessinez dans l'espace 3D consistent en un grand nombre de triangles.
La première chose que vous devez comprendre est la façon dont l'écran est représenté dans WebGL. Il s'agit d'un espace 3D, s'étendant entre -1 et 1 sur les axes x , y et z . Par défaut, cet axe z n'est pas utilisé, mais vous êtes intéressé par les graphiques 3D, vous devez donc l'activer immédiatement.
Ayant cela à l'esprit, ce qui suit sont les trois étapes nécessaires pour dessiner un triangle sur cette surface.
Vous pouvez définir trois sommets, qui représenteraient le triangle que vous souhaitez dessiner. Vous sérialisez ces données et les envoyez au GPU (unité de traitement graphique). Avec un modèle complet disponible, vous pouvez le faire pour tous les triangles du modèle. Les positions des sommets que vous donnez sont dans l'espace de coordonnées local du modèle que vous avez chargé. En termes simples, les positions que vous fournissez sont celles exactes du fichier, et non celles que vous obtenez après avoir effectué des transformations matricielles.
Maintenant que vous avez donné les sommets au GPU, vous indiquez au GPU quelle logique utiliser lors du placement des sommets sur l'écran. Cette étape sera utilisée pour appliquer nos transformations matricielles. Le GPU est très bon pour multiplier un grand nombre de matrices 4x4, nous allons donc utiliser cette capacité à bon escient.
Dans la dernière étape, le GPU pixellisera ce triangle. La rastérisation est le processus consistant à prendre des graphiques vectoriels et à déterminer quels pixels de l'écran doivent être peints pour que cet objet graphique vectoriel soit affiché. Dans notre cas, le GPU essaie de déterminer quels pixels sont situés dans chaque triangle. Pour chaque pixel, le GPU vous demandera de quelle couleur vous voulez qu'il soit peint.
Ce sont les quatre éléments nécessaires pour dessiner tout ce que vous voulez, et ils sont l'exemple le plus simple d'un pipeline graphique. Ce qui suit est un aperçu de chacun d'eux et une mise en œuvre simple.
Le framebuffer par défaut
L'élément le plus important pour une application WebGL est le contexte WebGL. Vous pouvez y accéder avec gl = canvas.getContext('webgl')
, ou utiliser 'experimental-webgl'
comme alternative, au cas où le navigateur actuellement utilisé ne prend pas encore en charge toutes les fonctionnalités WebGL. Le canvas
auquel nous nous sommes référés est l'élément DOM du canevas sur lequel nous voulons dessiner. Le contexte contient beaucoup de choses, parmi lesquelles le framebuffer par défaut.
Vous pourriez vaguement décrire un framebuffer comme n'importe quel tampon (objet) sur lequel vous pouvez dessiner. Par défaut, le framebuffer par défaut stocke la couleur de chaque pixel du canevas auquel le contexte WebGL est lié. Comme décrit dans la section précédente, lorsque nous dessinons sur le framebuffer, chaque pixel est situé entre -1 et 1 sur les axes x et y . Nous avons également mentionné le fait que, par défaut, WebGL n'utilise pas l'axe z . Cette fonctionnalité peut être activée en exécutant gl.enable(gl.DEPTH_TEST)
. Super, mais qu'est-ce qu'un test de profondeur ?
L'activation du test de profondeur permet à un pixel de stocker à la fois la couleur et la profondeur. La profondeur est la coordonnée z de ce pixel. Après avoir dessiné sur un pixel à une certaine profondeur z , pour mettre à jour la couleur de ce pixel, vous devez dessiner à une position z plus proche de la caméra. Sinon, la tentative de tirage au sort sera ignorée. Cela permet l'illusion de la 3D, puisque dessiner des objets qui se trouvent derrière d'autres objets entraînera l'occlusion de ces objets par des objets devant eux.
Tous les tirages que vous effectuez restent à l'écran jusqu'à ce que vous leur disiez d'être effacés. Pour ce faire, vous devez appeler gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
. Cela efface à la fois le tampon de couleur et de profondeur. Pour choisir la couleur sur laquelle les pixels effacés sont définis, utilisez gl.clearColor(red, green, blue, alpha)
.
Créons un moteur de rendu qui utilise un canevas et l'efface sur demande :
function Renderer (canvas) { var gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl') gl.enable(gl.DEPTH_TEST) this.gl = gl } Renderer.prototype.setClearColor = function (red, green, blue) { gl.clearColor(red / 255, green / 255, blue / 255, 1) } Renderer.prototype.getContext = function () { return this.gl } Renderer.prototype.render = function () { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) } var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) loop() function loop () { renderer.render() requestAnimationFrame(loop) }
Joindre ce script au code HTML suivant vous donnera un rectangle bleu vif à l'écran
<!DOCTYPE html> <html> <head> </head> <body> <canvas width="800" height="500"></canvas> <script src="script.js"></script> </body> </html>
L'appel requestAnimationFrame
provoque le rappel de la boucle dès que le rendu de l'image précédente est terminé et que toute la gestion des événements est terminée.
Objets Vertex Buffer
La première chose que vous devez faire est de définir les sommets que vous souhaitez dessiner. Vous pouvez le faire en les décrivant via des vecteurs dans l'espace 3D. Après cela, vous souhaitez déplacer ces données dans la RAM du GPU, en créant un nouvel objet Vertex Buffer (VBO).
Un objet tampon en général est un objet qui stocke un tableau de blocs de mémoire sur le GPU. Le fait d'être un VBO indique simplement à quoi le GPU peut utiliser la mémoire. La plupart du temps, les objets tampons que vous créez seront des VBO.

Vous pouvez remplir le VBO en prenant tous les N
sommets que nous avons et en créant un tableau de flotteurs avec 3N
éléments pour la position du sommet et les VBO normaux du sommet, et 2N
pour les coordonnées de texture VBO. Chaque groupe de trois flottants, ou deux flottants pour les coordonnées UV, représente les coordonnées individuelles d'un sommet. Ensuite, nous transmettons ces tableaux au GPU et nos sommets sont prêts pour le reste du pipeline.
Étant donné que les données se trouvent maintenant sur la RAM du GPU, vous pouvez les supprimer de la RAM à usage général. Autrement dit, à moins que vous ne souhaitiez le modifier ultérieurement et le télécharger à nouveau. Chaque modification doit être suivie d'un téléchargement, car les modifications dans nos tableaux JS ne s'appliquent pas aux VBO dans la RAM réelle du GPU.
Vous trouverez ci-dessous un exemple de code qui fournit toutes les fonctionnalités décrites. Une remarque importante à faire est le fait que les variables stockées sur le GPU ne sont pas ramassées. Cela signifie que nous devons les supprimer manuellement une fois que nous ne voulons plus les utiliser. Nous vous donnerons juste un exemple de la façon dont cela est fait ici, et nous ne nous concentrerons pas sur ce concept plus loin. La suppression de variables du GPU n'est nécessaire que si vous prévoyez d'arrêter d'utiliser certaines géométries tout au long du programme.
Nous avons également ajouté la sérialisation à notre classe Geometry
et aux éléments qu'elle contient.
Geometry.prototype.vertexCount = function () { return this.faces.length * 3 } Geometry.prototype.positions = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.position answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.normals = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.normal answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.uvs = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.uv answer.push(vx, vy) }) }) return answer } //////////////////////////////// function VBO (gl, data, count) { // Creates buffer object in GPU RAM where we can store anything var bufferObject = gl.createBuffer() // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, bufferObject) // Write the data, and set the flag to optimize // for rare changes to the data we're writing gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW) this.gl = gl this.size = data.length / count this.count = count this.data = bufferObject } VBO.prototype.destroy = function () { // Free memory that is occupied by our buffer object this.gl.deleteBuffer(this.data) }
Le type de données VBO
génère le VBO dans le contexte WebGL transmis, en fonction du tableau transmis comme second paramètre.
Vous pouvez voir trois appels au contexte gl
. L'appel createBuffer()
crée le tampon. L'appel bindBuffer()
indique à la machine d'état WebGL d'utiliser cette mémoire spécifique comme VBO actuel ( ARRAY_BUFFER
) pour toutes les opérations futures, sauf indication contraire. Après cela, nous définissons la valeur du VBO actuel sur les données fournies, avec bufferData()
.
Nous fournissons également une méthode destroy qui supprime notre objet tampon de la RAM GPU, en utilisant deleteBuffer()
.
Vous pouvez utiliser trois VBO et une transformation pour décrire toutes les propriétés d'un maillage, ainsi que sa position.
function Mesh (gl, geometry) { var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() }
A titre d'exemple, voici comment nous pouvons charger un modèle, stocker ses propriétés dans le maillage, puis le détruire :
Geometry.loadOBJ('/assets/model.obj').then(function (geometry) { var mesh = new Mesh(gl, geometry) console.log(mesh) mesh.destroy() })
Shaders
Ce qui suit est le processus en deux étapes décrit précédemment consistant à déplacer des points dans les positions souhaitées et à peindre tous les pixels individuels. Pour ce faire, nous écrivons un programme qui est exécuté plusieurs fois sur la carte graphique. Ce programme se compose généralement d'au moins deux parties. La première partie est un Vertex Shader , qui est exécuté pour chaque sommet et produit où nous devons placer le sommet sur l'écran, entre autres choses. La deuxième partie est le Fragment Shader , qui est exécuté pour chaque pixel qu'un triangle couvre à l'écran, et produit la couleur avec laquelle le pixel doit être peint.
Nuanceurs de vertex
Disons que vous voulez avoir un modèle qui se déplace de gauche à droite sur l'écran. Dans une approche naïve, vous pouvez mettre à jour la position de chaque sommet et le renvoyer au GPU. Ce processus est coûteux et lent. Alternativement, vous donneriez un programme pour que le GPU s'exécute pour chaque sommet, et effectueriez toutes ces opérations en parallèle avec un processeur conçu pour faire exactement ce travail. C'est le rôle d'un vertex shader .
Un vertex shader est la partie du pipeline de rendu qui traite les sommets individuels. Un appel au vertex shader reçoit un seul sommet et génère un seul sommet après que toutes les transformations possibles au sommet ont été appliquées.
Les shaders sont écrits en GLSL. Il y a beaucoup d'éléments uniques dans ce langage, mais la plupart de la syntaxe est très proche du C, elle devrait donc être compréhensible pour la plupart des gens.
Il existe trois types de variables qui entrent et sortent d'un shader de vertex, et toutes ont un usage spécifique :
-
attribute
— Ce sont des entrées qui contiennent des propriétés spécifiques d'un sommet. Auparavant, nous décrivions la position d'un sommet comme un attribut, sous la forme d'un vecteur à trois éléments. Vous pouvez considérer les attributs comme des valeurs décrivant un sommet. -
uniform
— Ce sont des entrées qui sont les mêmes pour chaque sommet dans le même appel de rendu. Disons que nous voulons pouvoir déplacer notre modèle, en définissant une matrice de transformation. Vous pouvez utiliser une variableuniform
pour décrire cela. Vous pouvez également pointer vers des ressources sur le GPU, comme des textures. Vous pouvez considérer les uniformes comme des valeurs qui décrivent un modèle ou une partie d'un modèle. -
varying
— Ce sont les sorties que nous transmettons au fragment shader. Puisqu'il y a potentiellement des milliers de pixels pour un triangle de sommets, chaque pixel recevra une valeur interpolée pour cette variable, en fonction de la position. Ainsi, si un sommet envoie 500 en sortie et un autre 100, un pixel situé au milieu recevra 300 en entrée pour cette variable. Vous pouvez considérer les variables comme des valeurs qui décrivent des surfaces entre des sommets.
So, let's say you want to create a vertex shader that receives a position, normal, and uv coordinates for each vertex, and a position, view (inverse camera position), and projection matrix for each rendered object. Let's say you also want to paint individual pixels based on their uv coordinates and their normals. “How would that code look?” vous pourriez demander.
attribute vec3 position; attribute vec3 normal; attribute vec2 uv; uniform mat4 model; uniform mat4 view; uniform mat4 projection; varying vec3 vNormal; varying vec2 vUv; void main() { vUv = uv; vNormal = (model * vec4(normal, 0.)).xyz; gl_Position = projection * view * model * vec4(position, 1.); }
Most of the elements here should be self-explanatory. The key thing to notice is the fact that there are no return values in the main
function. All values that we would want to return are assigned, either to varying
variables, or to special variables. Here we assign to gl_Position
, which is a four-dimensional vector, whereby the last dimension should always be set to one. Another strange thing you might notice is the way we construct a vec4
out of the position vector. You can construct a vec4
by using four float
s, two vec2
s, or any other combination that results in four elements. There are a lot of seemingly strange type castings which make perfect sense once you're familiar with transformation matrices.
You can also see that here we can perform matrix transformations extremely easily. GLSL is specifically made for this kind of work. The output position is calculated by multiplying the projection, view, and model matrix and applying it onto the position. The output normal is just transformed to the world space. We'll explain later why we've stopped there with the normal transformations.
For now, we will keep it simple, and move on to painting individual pixels.
Fragment Shaders
A fragment shader is the step after rasterization in the graphics pipeline. It generates color, depth, and other data for every pixel of the object that is being painted.
The principles behind implementing fragment shaders are very similar to vertex shaders. There are three major differences, though:
- There are no more
varying
outputs, andattribute
inputs have been replaced withvarying
inputs. We have just moved on in our pipeline, and things that are the output in the vertex shader are now inputs in the fragment shader. - Our only output now is
gl_FragColor
, which is avec4
. The elements represent red, green, blue, and alpha (RGBA), respectively, with variables in the 0 to 1 range. You should keep alpha at 1, unless you're doing transparency. Transparency is a fairly advanced concept though, so we'll stick to opaque objects. - At the beginning of the fragment shader, you need to set the float precision, which is important for interpolations. In almost all cases, just stick to the lines from the following shader.
With that in mind, you can easily write a shader that paints the red channel based on the U position, green channel based on the V position, and sets the blue channel to maximum.
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec2 clampedUv = clamp(vUv, 0., 1.); gl_FragColor = vec4(clampedUv, 1., 1.); }
The function clamp
just limits all floats in an object to be within the given limits. The rest of the code should be pretty straightforward.
With all of this in mind, all that is left is to implement this in WebGL.
Combining Shaders into a Program
The next step is to combine the shaders into a program:
function ShaderProgram (gl, vertSrc, fragSrc) { var vert = gl.createShader(gl.VERTEX_SHADER) gl.shaderSource(vert, vertSrc) gl.compileShader(vert) if (!gl.getShaderParameter(vert, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(vert)) throw new Error('Failed to compile shader') } var frag = gl.createShader(gl.FRAGMENT_SHADER) gl.shaderSource(frag, fragSrc) gl.compileShader(frag) if (!gl.getShaderParameter(frag, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(frag)) throw new Error('Failed to compile shader') } var program = gl.createProgram() gl.attachShader(program, vert) gl.attachShader(program, frag) gl.linkProgram(program) if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error(gl.getProgramInfoLog(program)) throw new Error('Failed to link program') } this.gl = gl this.position = gl.getAttribLocation(program, 'position') this.normal = gl.getAttribLocation(program, 'normal') this.uv = gl.getAttribLocation(program, 'uv') this.model = gl.getUniformLocation(program, 'model') this.view = gl.getUniformLocation(program, 'view') this.projection = gl.getUniformLocation(program, 'projection') this.vert = vert this.frag = frag this.program = program } // Loads shader files from the given URLs, and returns a program as a promise ShaderProgram.load = function (gl, vertUrl, fragUrl) { return Promise.all([loadFile(vertUrl), loadFile(fragUrl)]).then(function (files) { return new ShaderProgram(gl, files[0], files[1]) }) function loadFile (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(xhr.responseText) } } xhr.open('GET', url, true) xhr.send(null) }) } }
There isn't much to say about what's happening here. Each shader gets assigned a string as a source and compiled, after which we check to see if there were compilation errors. Then, we create a program by linking these two shaders. Finally, we store pointers to all relevant attributes and uniforms for posterity.
Actually Drawing the Model
Last, but not least, you draw the model.
First you pick the shader program you want to use.
ShaderProgram.prototype.use = function () { this.gl.useProgram(this.program) }
Then you send all the camera related uniforms to the GPU. These uniforms change only once per camera change or movement.
Transformation.prototype.sendToGpu = function (gl, uniform, transpose) { gl.uniformMatrix4fv(uniform, transpose || false, new Float32Array(this.fields)) } Camera.prototype.use = function (shaderProgram) { this.projection.sendToGpu(shaderProgram.gl, shaderProgram.projection) this.getInversePosition().sendToGpu(shaderProgram.gl, shaderProgram.view) }
Finally, you take the transformations and VBOs and assign them to uniforms and attributes, respectively. Since this has to be done to each VBO, you can create its data binding as a method.
VBO.prototype.bindToAttribute = function (attribute) { var gl = this.gl // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, this.data) // Enable this attribute in the shader gl.enableVertexAttribArray(attribute) // Define format of the attribute array. Must match parameters in shader gl.vertexAttribPointer(attribute, this.size, gl.FLOAT, false, 0, 0) }
Then you assign an array of three floats to the uniform. Each uniform type has a different signature, so documentation and more documentation are your friends here. Finally, you draw the triangle array on the screen. You tell the drawing call drawArrays()
from which vertex to start, and how many vertices to draw. The first parameter passed tells WebGL how it shall interpret the array of vertices. Using TRIANGLES
takes three by three vertices and draws a triangle for each triplet. Using POINTS
would just draw a point for each passed vertex. There are many more options, but there is no need to discover everything at once. Below is the code for drawing an object:
Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) }
The renderer needs to be extended a bit to accommodate all the extra elements that need to be handled. It should be possible to attach a shader program, and to render an array of objects based on the current camera position.
Renderer.prototype.setShader = function (shader) { this.shader = shader } Renderer.prototype.render = function (camera, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }
We can combine all the elements that we have to finally draw something on the screen:
var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Geometry.loadOBJ('/assets/sphere.obj').then(function (data) { objects.push(new Mesh(gl, data)) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) loop() function loop () { renderer.render(camera, objects) requestAnimationFrame(loop) }
This looks a bit random, but you can see the different patches of the sphere, based on where they are on the UV map. You can change the shader to paint the object brown. Just set the color for each pixel to be the RGBA for brown:
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); gl_FragColor = vec4(brown, 1.); }
It doesn't look very convincing. It looks like the scene needs some shading effects.
Adding Light
Lights and shadows are the tools that allow us to perceive the shape of objects. Lights come in many shapes and sizes: spotlights that shine in one cone, light bulbs that spread light in all directions, and most interestingly, the sun, which is so far away that all the light it shines on us radiates, for all intents and purposes, in the same direction.
Sunlight sounds like it's the simplest to implement, since all you need to provide is the direction in which all rays spread. For each pixel that you draw on the screen, you check the angle under which the light hits the object. This is where the surface normals come in.
You can see all the light rays flowing in the same direction, and hitting the surface under different angles, which are based on the angle between the light ray and the surface normal. The more they coincide, the stronger the light is.
If you perform a dot product between the normalized vectors for the light ray and the surface normal, you will get -1 if the ray hits the surface perfectly perpendicularly, 0 if the ray is parallel to the surface, and 1 if it illuminates it from the opposite side. So anything between 0 and 1 should add no light, while numbers between 0 and -1 should gradually increase the amount of light hitting the object. You can test this by adding a fixed light in the shader code.
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); gl_FragColor = vec4(brown * lightness, 1.); }
Nous avons réglé le soleil pour qu'il brille dans la direction avant-gauche-bas. Vous pouvez voir à quel point l'ombrage est lisse, même si le modèle est très irrégulier. Vous pouvez également remarquer à quel point le côté inférieur gauche est sombre. Nous pouvons ajouter un niveau de lumière ambiante, ce qui rendra la zone d'ombre plus lumineuse.
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); float ambientLight = 0.3; lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); }
Vous pouvez obtenir ce même effet en introduisant une classe de lumière, qui stocke la direction de la lumière et l'intensité de la lumière ambiante. Ensuite, vous pouvez modifier le shader de fragment pour tenir compte de cet ajout.
Maintenant le shader devient :
#ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); }
Ensuite, vous pouvez définir la lumière :
function Light () { this.lightDirection = new Vector3(-1, -1, -1) this.ambientLight = 0.3 } Light.prototype.use = function (shaderProgram) { var dir = this.lightDirection var gl = shaderProgram.gl gl.uniform3f(shaderProgram.lightDirection, dir.x, dir.y, dir.z) gl.uniform1f(shaderProgram.ambientLight, this.ambientLight) }
Dans la classe du programme shader, ajoutez les uniformes nécessaires :
this.ambientLight = gl.getUniformLocation(program, 'ambientLight') this.lightDirection = gl.getUniformLocation(program, 'lightDirection')
Dans le programme, ajoutez un appel à la nouvelle lumière dans le rendu :
Renderer.prototype.render = function (camera, light, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() light.use(shader) camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }
La boucle changera alors légèrement :
var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) }
Si vous avez tout fait correctement, l'image rendue doit être la même que dans la dernière image.
Une dernière étape à considérer serait d'ajouter une texture réelle à notre modèle. Faisons cela maintenant.
Ajout de textures
HTML5 a un excellent support pour le chargement d'images, il n'est donc pas nécessaire de faire une analyse d'image folle. Les images sont transmises à GLSL en tant que sampler2D
en indiquant au shader laquelle des textures liées échantillonner. Il y a un nombre limité de textures que l'on peut lier, et la limite est basée sur le matériel utilisé. Un sampler2D
peut être interrogé pour les couleurs à certaines positions. C'est là qu'interviennent les coordonnées UV. Voici un exemple où nous avons remplacé le marron par des couleurs échantillonnées.
#ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; uniform sampler2D diffuse; varying vec3 vNormal; varying vec2 vUv; void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }
Le nouvel uniforme doit être ajouté à la liste dans le programme shader :
this.diffuse = gl.getUniformLocation(program, 'diffuse')
Enfin, nous allons implémenter le chargement de texture. Comme indiqué précédemment, HTML5 fournit des fonctionnalités pour charger des images. Il suffit d'envoyer l'image au GPU :
function Texture (gl, image) { var texture = gl.createTexture() // Set the newly created texture context as active texture gl.bindTexture(gl.TEXTURE_2D, texture) // Set texture parameters, and pass the image that the texture is based on gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image) // Set filtering methods // Very often shaders will query the texture value between pixels, // and this is instructing how that value shall be calculated gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) this.data = texture this.gl = gl } Texture.prototype.use = function (uniform, binding) { binding = Number(binding) || 0 var gl = this.gl // We can bind multiple textures, and here we pick which of the bindings // we're setting right now gl.activeTexture(gl['TEXTURE' + binding]) // After picking the binding, we set the texture gl.bindTexture(gl.TEXTURE_2D, this.data) // Finally, we pass to the uniform the binding ID we've used gl.uniform1i(uniform, binding) // The previous 3 lines are equivalent to: // texture[i] = this.data // uniform = i } Texture.load = function (gl, url) { return new Promise(function (resolve) { var image = new Image() image.onload = function () { resolve(new Texture(gl, image)) } image.src = url }) }
Le processus n'est pas très différent du processus utilisé pour charger et lier les VBO. La principale différence est que nous ne lions plus un attribut, mais lions plutôt l'index de la texture à un entier uniforme. Le type sampler2D
n'est rien de plus qu'un pointeur décalé vers une texture.
Il ne reste plus qu'à étendre la classe Mesh
, pour gérer également les textures :
function Mesh (gl, geometry, texture) { // added texture var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.texture = texture // new this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() } Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.texture.use(shaderProgram.diffuse, 0) // new this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) } Mesh.load = function (gl, modelUrl, textureUrl) { // new var geometry = Geometry.loadOBJ(modelUrl) var texture = Texture.load(gl, textureUrl) return Promise.all([geometry, texture]).then(function (params) { return new Mesh(gl, params[0], params[1]) }) }
Et le script principal final ressemblerait à ceci :
var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Mesh.load(gl, '/assets/sphere.obj', '/assets/diffuse.png') .then(function (mesh) { objects.push(mesh) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) }
Même l'animation est facile à ce stade. Si vous vouliez que la caméra tourne autour de notre objet, vous pouvez le faire en ajoutant simplement une ligne de code :
function loop () { renderer.render(camera, light, objects) camera.position = camera.position.rotateY(Math.PI / 120) requestAnimationFrame(loop) }
N'hésitez pas à jouer avec les shaders. L'ajout d'une ligne de code transformera cet éclairage réaliste en quelque chose de caricatural.
void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = lightness > 0.1 ? 1. : 0.; // new lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }
C'est aussi simple que de dire à l'éclairage d'aller dans ses extrêmes selon qu'il a franchi ou non un seuil défini.
Où aller ensuite
Il existe de nombreuses sources d'informations pour apprendre toutes les astuces et les subtilités de WebGL. Et la meilleure partie est que si vous ne trouvez pas de réponse concernant WebGL, vous pouvez la rechercher dans OpenGL, car WebGL est à peu près basé sur un sous-ensemble d'OpenGL, certains noms étant modifiés.
Sans ordre particulier, voici quelques excellentes sources d'informations plus détaillées, à la fois pour WebGL et OpenGL.
- Fondamentaux de WebGL
- Apprendre WebGL
- Un tutoriel OpenGL très détaillé vous guidant à travers tous les principes fondamentaux décrits ici, de manière très lente et détaillée.
- Et il existe de nombreux autres sites dédiés à vous enseigner les principes de l'infographie.
- Documentation MDN pour WebGL
- Spécification Khronos WebGL 1.0 si vous souhaitez comprendre les détails plus techniques du fonctionnement de l'API WebGL dans tous les cas extrêmes.