Séance 5 · Cours
Textures et sprites
Colorier des triangles sommet par sommet ne suffit pas pour afficher un personnage, un décor ou du texte. On colle donc des images sur les triangles : ce sont les textures.
À la fin de cette séance, vous saurez
- associer des coordonnées de texture (UV) aux sommets ;
- charger une image dans une texture et la lire dans le fragment shader ;
- choisir le filtrage et la répétition adaptés ;
- afficher des images transparentes et animer une planche de sprites.
1. Les coordonnées de texture (UV)
Une texture est une image stockée sur le GPU. Pour la lire, on n'utilise pas des pixels mais des coordonnées normalisées, appelées u (horizontale) et v (verticale), qui vont de 0 à 1 quelle que soit la taille de l'image. (0, 0) est le coin bas-gauche, (1, 1) le coin haut-droit.
On donne à chaque sommet, en plus de sa position, la coordonnée UV de l'image qui doit s'y trouver. C'est un attribut de plus, transmis au fragment shader par un varying : le GPU interpole les UV entre les sommets, exactement comme les couleurs de la séance 2.
Les shaders
#version 300 es
in vec2 a_position;
in vec2 a_uv; // coordonnée de texture du sommet
uniform mat3 u_matrice;
out vec2 v_uv;
void main() {
v_uv = a_uv;
gl_Position = vec4((u_matrice * vec3(a_position, 1.0)).xy, 0.0, 1.0);
}
#version 300 es
precision highp float;
in vec2 v_uv;
uniform sampler2D u_image; // la texture
out vec4 couleur;
void main() {
couleur = texture(u_image, v_uv); // lire la couleur de l'image en (u, v)
}
La fonction texture(sampler, uv) lit la texture. On appelle cette lecture un échantillonnage. Le résultat est un vec4 RGBA qu'on peut ensuite modifier : texture(u_image, v_uv) * u_teinte colore l'image, par exemple en rouge quand le personnage est touché.
2. Créer une texture en JavaScript
// loadImage vient de gl-utils.js ; await n'est permis que dans une fonction async
const image = await loadImage("img/caisse.png"); // attendre le chargement (asynchrone)
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture); // machine à états, encore
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); // voir l'encadré ci-dessous
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image); // copie vers le GPU
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); // filtrage
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); // répétition en u
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); // répétition en v
Dans un fichier image, la première ligne est celle du haut. WebGL, lui, place la première ligne en v = 0, c'est-à-dire en bas. Sans précaution, l'image s'affiche retournée. gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true), appelé avant texImage2D, retourne l'image au moment de la copie.
Une image met du temps à se charger. Il faut attendre son évènement load avant d'appeler texImage2D. La fonction loadImage de gl-utils.js renvoie une promesse : on écrit await loadImage(...) dans une fonction async. Et la page doit être servie par Live Server : en file://, le navigateur refuse de donner l'image à WebGL.
Les unités de texture
Un shader peut lire plusieurs textures à la fois. Le GPU possède pour cela plusieurs unités de texture numérotées (au moins 16). L'uniform sampler2D contient simplement un numéro d'unité. Avec une seule texture, l'unité 0 est utilisée par défaut et ces lignes sont facultatives, mais il est bon de les connaître :
const locImage = gl.getUniformLocation(program, "u_image"); // initialisation
gl.activeTexture(gl.TEXTURE0); // choisir l'unité 0
gl.bindTexture(gl.TEXTURE_2D, texture); // y placer notre texture
gl.uniform1i(locImage, 0); // le sampler u_image lit l'unité 0
3. Filtrage et répétition
La texture n'a presque jamais exactement la taille du rectangle à l'écran. Le filtrage décide comment calculer un pixel qui tombe entre les texels (les pixels de la texture) :
gl.NEAREST: prend le texel le plus proche. Rendu net et « pixelisé », idéal pour le pixel art.gl.LINEAR: mélange les 4 texels voisins. Rendu lisse, adapté aux photos.
La répétition (wrap) décide ce qui se passe pour des UV hors de [0, 1] :
gl.REPEAT(par défaut) : l'image se répète, comme un carrelage. Avec des UV de 0 à 4, on obtient 4 × 4 copies.gl.CLAMP_TO_EDGE: la couleur du bord est prolongée.gl.MIRRORED_REPEAT: répétition en miroir.
La texture ne fait que 16 × 16 texels. Comparez NEAREST et LINEAR, puis montez les UV et changez la répétition. Décochez FLIP_Y : le visage se retrouve la tête en bas.
4. Transparence
Une image PNG peut contenir un canal alpha : 0 pour un pixel transparent, 1 pour un pixel opaque. Par défaut, WebGL écrit pourtant la couleur telle quelle, alpha compris, et le fond apparaît noir ou blanc autour du sprite. Il faut activer le mélange (blending) :
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
// couleur finale = couleurSprite × alpha + couleurDéjàPrésente × (1 − alpha)
Conséquence : l'ordre de dessin compte. Il faut dessiner d'abord le décor, puis les objets transparents par-dessus.
5. Les planches de sprites
Un personnage animé n'est pas une vidéo : c'est une suite d'images fixes (frames) rangées côte à côte dans une seule image, la planche de sprites (sprite sheet). Charger une image plutôt que huit est plus rapide, et on change d'image en changeant seulement les UV.
Si la planche contient N images en ligne, chacune occupe une largeur 1/N en u. Pour afficher l'image numéro i, on transforme les UV du rectangle (de 0 à 1) dans le vertex shader :
// dans le vertex shader de la section 1, on ajoute :
uniform float u_frame; // numéro de l'image à afficher (0, 1, 2…)
uniform float u_nbFrames; // nombre d'images dans la planche
void main() {
v_uv = vec2((a_uv.x + u_frame) / u_nbFrames, a_uv.y);
// ...
}
const NB_FRAMES = 8;
const anim = { temps: 0, frame: 0 };
const locFrame = gl.getUniformLocation(program, "u_frame");
const locNbFrames = gl.getUniformLocation(program, "u_nbFrames");
// dans update(dt) : 10 images par seconde
anim.temps += dt;
anim.frame = Math.floor(anim.temps * 10) % NB_FRAMES;
// dans render(), avant de dessiner le sprite
gl.uniform1f(locFrame, anim.frame);
gl.uniform1f(locNbFrames, NB_FRAMES);
En bas : la planche complète et, encadrée, l'image affichée. Décochez BLEND : les zones transparentes deviennent noires.
Résumé
- Coordonnées UV de 0 à 1, un attribut par sommet, interpolées vers le fragment shader.
texture(u_image, v_uv)lit la couleur. Une texture se charge de façon asynchrone et doit être retournée avecUNPACK_FLIP_Y_WEBGL.NEARESTpour le pixel art,LINEARpour le lisse.REPEATpour carreler.- Transparence :
gl.enable(gl.BLEND)et dessiner le fond en premier. - Animation de sprite : changer la portion d'UV lue dans la planche.