WebGL pas à pas

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.

Durée 30 minTP associé TP 2 · Couleurs et formes

À 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 stride et offset ;
  • 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é GLSLNomQui l'écrit ?Qui le lit ?Valeur
in (vertex shader)AttributJavaScript, via un buffervertex shaderune par sommet
uniformUniformJavaScript, via gl.uniform*les deux shadersla même pour tout un dessin
out → inVaryingvertex shaderfragment shaderinterpolé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.

DémoL'interpolation des varyings

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) :

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);
Attention

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).

Variable inutilisée = variable supprimée

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.

DémoAtelier fragment shader
Uniforms disponibles : 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 :

DémoMêmes sommets, autre primitive
PrimitiveRègleUsage typique
gl.TRIANGLESchaque groupe de 3 sommets forme un trianglele cas général
gl.TRIANGLE_STRIPchaque nouveau sommet forme un triangle avec les 2 précédentsrectangle en 4 sommets, rubans
gl.TRIANGLE_FANtous les triangles partagent le sommet 0disques, polygones convexes
gl.LINES, LINE_STRIP, LINE_LOOPsegments par paires, enchaînés, ou en boucle ferméecontours, grilles, débogage
gl.POINTSun carré par sommet, de côté gl_PointSize pixelsparticules

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é