Séance 2 · Cours
Envoyer des données au GPU
Un triangle orange, c'est un début. Pour colorer chaque sommet différemment, changer une couleur depuis un bouton ou dessiner un cercle, il faut savoir faire passer des données du JavaScript vers les shaders, et d'un shader à l'autre.
À la fin de cette séance, vous saurez
- choisir entre attribut, uniform et varying pour transmettre une donnée ;
- décrire un buffer entrelacé avec
strideetoffset; - utiliser les différentes primitives et les indices (
drawElements) ; - générer une forme (un disque) par programme.
1. Trois façons de transmettre une donnée
| Mot-clé GLSL | Nom | Qui l'écrit ? | Qui le lit ? | Valeur |
|---|---|---|---|---|
in (vertex shader) | Attribut | JavaScript, via un buffer | vertex shader | une par sommet |
uniform | Uniform | JavaScript, via gl.uniform* | les deux shaders | la même pour tout un dessin |
out → in | Varying | vertex shader | fragment shader | interpolée entre les sommets |
Exemples : la position et la couleur de chaque sommet sont des attributs. Le temps écoulé, une couleur de teinte globale ou la taille du canvas sont des uniforms. Pour que la couleur d'un sommet arrive jusqu'au fragment shader, on la fait passer par un varying.
2. Plusieurs attributs et les varyings
Ajoutons une couleur à chaque sommet. Le vertex shader reçoit deux attributs et recopie la couleur dans une variable de sortie v_couleur. Le fragment shader déclare une entrée du même nom.
#version 300 es
in vec2 a_position;
in vec3 a_couleur; // nouvel attribut : une couleur par sommet
out vec3 v_couleur; // varying : envoyé au fragment shader
void main() {
v_couleur = a_couleur;
gl_Position = vec4(a_position, 0.0, 1.0);
}
#version 300 es
precision highp float;
in vec3 v_couleur; // même nom que la sortie du vertex shader
out vec4 couleur;
void main() {
couleur = vec4(v_couleur, 1.0);
}
Le vertex shader n'est appelé que 3 fois, mais le fragment shader des centaines de fois. Quelle valeur de v_couleur reçoit un pixel situé entre les sommets ? Le GPU la calcule automatiquement par interpolation : un pixel proche du sommet rouge reçoit une couleur proche du rouge, un pixel au centre reçoit un mélange des trois.
Survolez le triangle pour lire la couleur reçue par le fragment shader.
3. Organiser les données dans les buffers
Un buffer par attribut
La solution la plus simple : un buffer pour les positions, un autre pour les couleurs. Pour chacun, on lie le buffer, puis on décrit l'attribut avec vertexAttribPointer, qui mémorise le buffer lié à ce moment-là.
// emplacements des attributs dans le programme (une fois, après createProgram)
const locPosition = gl.getAttribLocation(program, "a_position");
const locCouleur = gl.getAttribLocation(program, "a_couleur");
const positions = new Float32Array([-0.5, -0.5, 0.5, -0.5, 0.0, 0.5]);
const couleurs = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); // rouge, vert, bleu
// deux buffers, un par attribut
const bufferPositions = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, bufferPositions);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
const bufferCouleurs = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, bufferCouleurs);
gl.bufferData(gl.ARRAY_BUFFER, couleurs, gl.STATIC_DRAW);
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, bufferPositions);
gl.enableVertexAttribArray(locPosition);
gl.vertexAttribPointer(locPosition, 2, gl.FLOAT, false, 0, 0); // 2 flottants par sommet
gl.bindBuffer(gl.ARRAY_BUFFER, bufferCouleurs);
gl.enableVertexAttribArray(locCouleur);
gl.vertexAttribPointer(locCouleur, 3, gl.FLOAT, false, 0, 0); // 3 flottants par sommet
Un buffer entrelacé
On peut aussi ranger toutes les données d'un sommet côte à côte : x, y, r, g, b, puis le sommet suivant. C'est la disposition la plus courante dans les moteurs de jeu. Il faut alors préciser deux valeurs, en octets (un float occupe 4 octets) :
- stride (pas) : la distance entre le début d'un sommet et le début du suivant, ici 5 × 4 = 20 octets ;
- offset (décalage) : où commence l'attribut à l'intérieur d'un sommet. 0 pour la position, 2 × 4 = 8 octets pour la couleur.
Octets 0 à 39 du buffer : deux sommets de 20 octets. La couleur commence 8 octets après le début de chaque sommet.
const sommets = new Float32Array([
// x y r g b
-0.5, -0.5, 1.0, 0.0, 0.0, // sommet 0 : rouge
0.5, -0.5, 0.0, 1.0, 0.0, // sommet 1 : vert
0.0, 0.5, 0.0, 0.0, 1.0, // sommet 2 : bleu
]);
// locPosition et locCouleur : récupérés comme dans l'exemple précédent
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
// UN SEUL buffer pour les deux attributs
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, sommets, gl.STATIC_DRAW);
// les deux attributs lisent ce même buffer (lié ci-dessus),
// chacun à son propre décalage dans le sommet
const F = Float32Array.BYTES_PER_ELEMENT; // 4
gl.enableVertexAttribArray(locPosition);
gl.vertexAttribPointer(locPosition, 2, gl.FLOAT, false, 5 * F, 0);
gl.enableVertexAttribArray(locCouleur);
gl.vertexAttribPointer(locCouleur, 3, gl.FLOAT, false, 5 * F, 2 * F);
Avec un stride à 0, WebGL considère que les valeurs sont collées les unes aux autres. C'est juste pour un buffer qui ne contient qu'un attribut, faux pour un buffer entrelacé.
4. Les uniforms
Un uniform est une variable globale, constante pendant tout un appel de dessin. On récupère son emplacement une fois à l'initialisation, puis on lui donne une valeur avant de dessiner, après useProgram (l'uniform appartient au programme actif).
// GLSL : uniform vec3 u_teinte;
const locTeinte = gl.getUniformLocation(program, "u_teinte"); // initialisation
gl.useProgram(program); // dessin
gl.uniform3f(locTeinte, 1.0, 0.8, 0.2); // 3 flottants séparés
// ou bien : gl.uniform3fv(locTeinte, [1.0, 0.8, 0.2]); // un tableau
Le nom de la fonction indique le type : uniform1f (un float), uniform2f (vec2), uniform4fv (vec4 depuis un tableau), uniform1i (un entier), uniformMatrix3fv (une mat3, séance 4).
Le compilateur GLSL supprime les attributs et uniforms qui ne servent pas au calcul final. getAttribLocation renvoie alors -1 et getUniformLocation renvoie null, sans erreur. Si une valeur « ne passe pas », vérifiez qu'elle est réellement utilisée dans le shader.
Le fragment shader connaît sa position
Dans le fragment shader, la variable prédéfinie gl_FragCoord.xy donne la position du pixel, en pixels, depuis le coin bas-gauche. Combinée à des uniforms comme la résolution ou le temps, elle permet de calculer des motifs sans aucune géométrie : on dessine simplement deux triangles qui couvrent tout le canvas.
u_resolution (vec2, taille du canvas en pixels) et u_temps (float, en secondes).5. Les primitives
Le premier paramètre de drawArrays indique comment relier les sommets. Avec les mêmes 6 sommets, on obtient des dessins très différents :
| Primitive | Règle | Usage typique |
|---|---|---|
gl.TRIANGLES | chaque groupe de 3 sommets forme un triangle | le cas général |
gl.TRIANGLE_STRIP | chaque nouveau sommet forme un triangle avec les 2 précédents | rectangle en 4 sommets, rubans |
gl.TRIANGLE_FAN | tous les triangles partagent le sommet 0 | disques, polygones convexes |
gl.LINES, LINE_STRIP, LINE_LOOP | segments par paires, enchaînés, ou en boucle fermée | contours, grilles, débogage |
gl.POINTS | un carré par sommet, de côté gl_PointSize pixels | particules |
6. Partager les sommets : les indices
Un rectangle, ce sont 2 triangles, donc 6 sommets avec gl.TRIANGLES, dont 2 sont répétés. Pour un modèle 3D de milliers de triangles, chaque sommet est partagé par 5 ou 6 triangles en moyenne : les répéter gaspille de la mémoire.
La solution : un tableau de sommets uniques, et un tableau d'indices qui dit quels sommets forment chaque triangle.
// 3 ─── 2
// │ ╱ │ 4 sommets uniques,
// │ ╱ │ 2 triangles : (0, 1, 2) et (0, 2, 3)
// 0 ─── 1
const sommets = new Float32Array([
-0.5, -0.5, // 0
0.5, -0.5, // 1
0.5, 0.5, // 2
-0.5, 0.5, // 3
]);
// 2 triangles, décrits par les numéros de leurs sommets
const indices = new Uint16Array([0, 1, 2, 0, 2, 3]);
const vao = gl.createVertexArray();
gl.bindVertexArray(vao); // tout ce qui suit est mémorisé dans ce VAO
// 1. les sommets : comme d'habitude, un buffer et la description de l'attribut
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, sommets, gl.STATIC_DRAW);
const locPosition = gl.getAttribLocation(program, "a_position");
gl.enableVertexAttribArray(locPosition);
gl.vertexAttribPointer(locPosition, 2, gl.FLOAT, false, 0, 0); // le VAO retient : « a_position est dans vbo »
// 2. les indices : un second buffer, sur la cible ELEMENT_ARRAY_BUFFER
const ibo = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo); // lié pendant que le VAO est lié : il s'en souvient
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
// au dessin : on lie le VAO, puis drawElements au lieu de drawArrays
gl.bindVertexArray(vao);
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0); // 6 indices de type Uint16, à partir du début
Pour chacun des 6 indices, le GPU lit un numéro de sommet i dans ibo, puis va chercher l'attribut a_position de ce sommet dans vbo, à l'octet offset + i × stride. Le VAO contient ces deux informations : quel buffer d'indices utiliser, et où lire chaque attribut.
7. Générer une forme par programme
Aucune primitive ne dessine de cercle. On l'approche par un polygone régulier de n côtés, découpé en triangles autour du centre : c'est exactement ce que fait TRIANGLE_FAN. Le sommet i du bord est à l'angle θ = 2π · i / n, donc aux coordonnées (cx + r·cos θ, cy + r·sin θ).
function disque(cx, cy, r, n) {
const pts = [cx, cy]; // le centre, partagé par tous les triangles
for (let i = 0; i <= n; i++) { // <= : on répète le premier point pour fermer
const a = 2 * Math.PI * i / n;
pts.push(cx + r * Math.cos(a), cy + r * Math.sin(a));
}
return new Float32Array(pts); // n + 2 sommets
}
// gl.drawArrays(gl.TRIANGLE_FAN, 0, n + 2);
Avec n = 6 on obtient un hexagone, avec n = 64 l'œil ne voit plus la différence avec un vrai cercle. Vous programmerez ce disque au TP, avec une couleur par sommet calculée d'après l'angle.
Résumé
- Attribut : une valeur par sommet, lue dans un buffer. Uniform : une valeur globale fixée par
gl.uniform*. Varying : du vertex au fragment shader, interpolé. - Buffer entrelacé :
stride= taille d'un sommet en octets,offset= position de l'attribut dans le sommet. - Les uniforms se règlent après
useProgram. Une variable inutilisée disparaît à la compilation. - Les indices (
drawElements) évitent de dupliquer les sommets partagés.