Des wrappers de shaders et de programme pour WebGL

Dans un article précédent, il a été question d'un moyen pour faciliter la conception de shaders lors de l'écriture d'applications WebGL en JavaScript.
A l'occasion de la reprise d'un projet fondé sur ces technologies, il est apparu possible de simplifier plus encore non seulement la conception, mais aussi l'utilisation, et ce non seulement des shaders, mais aussi des programmes de WebGL.
Exemple de rendu avec des wrappers de shaders et d'un programme pour WebGL
Comme les helpers, la solution proposée ici s'appuie sur WebGL 2, en mettant notamment à profit ce nouvel objet : le Vertex Array Object, ou VAO pour les intimes.
D'ailleurs, ce sera l'occasion de préciser la manière dont cet objet fonctionne exactement, tant il est vrai que les explications fournies par Khronos peuvent être un peu difficiles à suivre...

Une solution simple à utiliser

Les concepts de base de WebGL ont été assez détaillés ici et sur ce blog pour qu'il ne soit pas utile d'y revenir. On présumera donc que le lecteur les maîtrise, notamment qu'il sait créer un programme à partir d'un vertex shader et d'un fragment shader écrits en GLSL, et utiliser ce programme pour afficher au moins une primitive.
A ce titre, le lecteur aura fait l'expérience de la pénibilité de la conception des shaders. C'est pour faciliter cette tâche que des helpers avaient été proposés ici sur ce blog. Quoique fort utiles, ces helpers présentaient l'inconvénient d'être à cheval entre deux concepts, celui d'une aide durant l'écriture du code et celui d'une aide durant l'exécution du code en question. En tâchant de clarifier ce positionnement, il est apparu qu'il serait plus globalement utile d'intégrer une aide portant sur le programme, au sens de celui créé par un appel à gl.createProgram ().
La solution s'appuie sur plusieurs objets réunis au sein d'une bibliothèque, libWEBGL.js, dont le format a été présenté ici. Il y est fait appel très simplement par une simple inclusion de script :
<script type="text/javascript" src="libWEBGL.js"></script>
Cliquez ici pour accéder à une page de test minimaliste, et ici pour télécharger une archive contenant cette page et la bibliothèque libWEBGL sur laquelle le code JavaScript de la page s'appuie.
Comment cela fonctionne ? Après avoir récupéré le context WebGL d'un canvas, par la suite désigné par gl, il faut procéder en trois étapes.
La première étape consiste à créer un vertex shader. Pour cela, il convient d'appeler la fabrique libWEBGL.createVertexShader () pour récupérer un objet VertexShader. Ensuite, il faut appeler ses méthodes .addInput (), .addUniform () et .addOutput () pour fournir la liste des éléments correspondants du programme GLSL du shader. Un dernier appel à .build () pour fournir le code source de programme, et la création du vertex shader est terminée. Plutôt simple et flexible, non ?
vertexShader = libWEBGL.createVertexShader (gl);
vertexShader.setDebugLevel (libWEBGL.DEBUG_MESSAGE | libWEBGL.DEBUG_TRACE);
vertexShader.addInput ("A_xy", libWEBGL.PRECISION_LOWP, libWEBGL.TYPE_VEC2);
vertexShader.addInput ("A_mL", libWEBGL.PRECISION_LOWP, libWEBGL.TYPE_MAT3);
vertexShader.addInput ("A_rgb", libWEBGL.PRECISION_LOWP, libWEBGL.TYPE_VEC3);
vertexShader.addUniform ("U_mP", libWEBGL.PRECISION_LOWP, libWEBGL.TYPE_MAT3);
vertexShader.addOutput ("V_rgba", libWEBGL.PRECISION_LOWP, libWEBGL.TYPE_VEC4);
vertexShader.build ("void main (void) { gl_Position = vec4 (U_mP * A_mL * vec3 (A_xy, 1.0), 1.0); V_rgba = vec4 (A_rgb, 1.0); }");
Attention à l'ordre des appels à la méthode décrivant le format de point. En effet, ces appels à .addInput () doivent être effectués dans l'ordre dans lequel les éléments correspondants du format de point seront fournis dans le tableau des points qui sera chargé dans le programme. En l'espèce, chaque point se présente sous la forme d'une séquence de 14 éléments qui sont donc, dans l'ordre, les coordonnées du point, la matrice de transformation du point, et les composantes de la couleur du point.
Au passage, noter l'appel à .setDebugLevel () qui permet de spécifier le niveau de détail des informations affichées dans la console, utiles pour le débogage. Par défaut, le niveau de détail est libWEBGL.DEBUG_MESSAGE, ce qui affiche un message sur chaque erreur rencontrée. Ce niveau peut être combiné à libWEBGL.DEBUG_TRACE pour afficher de plus le contenu de la pile. Il est possible de spécifier le niveau de détail dès la création de l'objet VertexShader à l'aide du paramètre nommé debugLevel. Tout ceci vaut aussi pour les deux autres autres objets, FragmentShader et Program.
La seconde étape consiste à créer un fragment shader. Elle suit exactement le même modèle que l'étape précédente, puisqu'il s'agit d'appeler une fabrique libWEBGL.createFragmentShader () qui retourne un objet FragmentShader à configurer pareillement :
fragmentShader = libWEBGL.createFragmentShader (gl);
fragmentShader.setDebugLevel (libWEBGL.DEBUG_MESSAGE | libWEBGL.DEBUG_TRACE);
fragmentShader.addInput ("V_rgba", libWEBGL.PRECISION_LOWP, libWEBGL.TYPE_VEC4);
fragmentShader.addOutput ("rgba", libWEBGL.PRECISION_LOWP, libWEBGL.TYPE_VEC4);
fragmentShader.build ("void main (void) { rgba = V_rgba; }");
La troisième et dernière étape consiste à créer le programme. Un appel à la fabrique libWEBGL.createProgram () retourne un objet Program. Ce dernier doit être lié aux shaders, ce qui s'effectue en appelant sa méthode .build () :
program = libWEBGL.createProgram (gl);
program.setDebugLevel (libWEBGL.DEBUG_MESSAGE | libWEBGL.DEBUG_TRACE);
program.build (vertexShader, fragmentShader);
Dès lors, il est possible d'utiliser le programme à souhait, ce qui s'effectue selon cette séquence d'appels à ses méthodes :
  • .use () ou .use (true) pour activer le programme ;
  • .setUniform () pour affecter une valeur à une uniforme utilisée par un shader ;
  • .load () pour charger les points et les indices ;
  • .use (false) pour désactiver le programme après le rendu.
Dans le test, cela donne :
// Activer le programme (il est possible de ne le faire qu'une fois dans Demo () si c'est le seul programme utilisé)

program.use ();

// Spécifier la matrice de projection

matrixP = libWEBGL.createMatrix2D ();
if (wglContext.width > wglContext.height)
	matrixP.setScaling (wglContext.height / wglContext.width, 1.0);
else
	matrixP.setScaling (1.0, wglContext.width / wglContext.height);
program.setUniform ("U_mP", matrixP.m);

// Récupérer les points et indices du quad

vertices = new Array ();
indices = new Array ();
quad.load (vertices, indices);
nbVerticesPerPrimitives = quad.nbVerticesPerPrimitives;
nbPrimitives = quad.nbPrimitives;

// Charger les points et les indices

program.load (vertices, indices);

// Demander le rendu

gl.clearColor (0.0, 0.0, 0.0, 1.0);
gl.clear (gl.COLOR_BUFFER_BIT);
gl.drawElements (gl.TRIANGLES, nbVerticesPerPrimitives * nbPrimitives, gl.UNSIGNED_SHORT, 0);

// Désactiver le programme (il est possible de s'en passer si c'est le seul programme utilisé)

program.use (false);
A la fin de l'application, il est de bon ton de faire le ménage :
vertexShader.destroy ();
fragmentShader.destroy ();
program.destroy ();

Des règles d'utilisation pas trop contraignantes

Les méthodes d'un objet VertexShader ou FragmentShader sont les suivantes :
.setDebugLevel ()Modifie le niveau de détail des informations affichées dans la console en cas d'erreur, sur la base d'une combinaison des drapeaux libWEBGL.DEBUG_MESSAGE et libWEBGL.DEBUG_TRACE, ou libWEBGL.DEBUG_NONE.
.getError ()Retourne le message décrivant la dernière erreur rencontrée.
.reset ()Rénitialise le shader : les entrées, les sorties, les uniformes et le programme GLSL sont supprimés.
.destroy ()Détruit le shader, qui ne doit dès lors plus être utilisé. Attention, car cette destruction n'est pas vérifiée lors d'une tentative de réutilisation.
.addInput ()Ajoute une entrée - un attribut du point de vue du programme GLSL d'un vertex shader, un varying du point de vue de celui d'un fragment shader. Attention, car l'ordre dans lequel les attributs sont ajoutés doit correspondre à celui dans lequel les données qui les alimentent sont fournies dans le tableau des points fourni à Program.load ().
.addOutput ()Ajoute une sortie - un varying du point de vue du programme GLSL d'un vertex shader, l'équivalent de gl_FragColor du point de vue de celui d'un fragment shader.
.addUniform ()Ajoute une uniforme.
.build ()Compile le shader pour qu'il puisse être fourni à Program.build ().
Les méthodes .addInput (), .addOutput () et .addUniform () prennent trois paramètres :
name Le nom, sous forme d'une chaîne de caractères.
precision
La précision, décrite par une constante correspondant à une précision GLSL :
libWEBGL.PRECISION_NONEAucune. Utiliser si type est libWEBGL.TYPE_SAMPLER2D
libWEBGL.PRECISION_LOWPlowp
libWEBGL.PRECISION_MEDIUMPmediump
libWEBGL.PRECISION_HIGHPhighp
type
Le type, décrit par une constante correspondant à un type GLSL :
libWEBGL.TYPE_FLOATfloat
libWEBGL.TYPE_VEC2vec2
libWEBGL.TYPE_VEC3vec3
libWEBGL.TYPE_VEC4vec4
libWEBGL.TYPE_MAT3mat3
libWEBGL.TYPE_MAT4mat4
libWEBGL.TYPE_SAMPLER2Dsampler2D
Qui regarde dans le code de libWEBGL.js s'étonnera qu'il n'existe aucune différence notable entre les objets VertexShader et FragmentShader, qui dérivent d'un objet Shader. C'est que les fonctionnalités de WebGL ne nécessitaient pas de les distinguer plus que cela. Si ces objets existent, c'est simplement pour ne pas insulter l'avenir.
Les méthodes d'un objet Program sont les suivantes :
.setDebugLevel ()Modifie le niveau de détail des informations affichées dans la console en cas d'erreur, sur la base d'une combinaison des drapeaux libWEBGL.DEBUG_MESSAGE et libWEBGL.DEBUG_TRACE, ou libWEBGL.DEBUG_NONE.
.getError ()Retourne le message décrivant la dernière erreur rencontrée.
.reset ()Rénitialise le programme : les shaders sont détachés, et le programme GLSL est supprimé.
.destroy ()Détruit le programme, qui ne doit dès lors plus être utilisé. Attention, car cette destruction n'est pas vérifiée lors d'une tentative de réutilisation.
.build ()Lie le programme à partir d'un vertex shader et d'un fragment shader compilés qui lui sont attachés à cette occasion.
.detachShader ()Détache un shader du programme.
.setUniform ()Modifier la valeur d'une uniforme.
.use ()Active ou désactive le programme.
.load ()Charge les points et les indices depuis des tableaux en vue d'un appel à gl.drawElements ().
Les règles d'utilisation sont plus strictes que ce que permet WebGL. En particulier, ce dernier permet de modifier / détacher / attacher un shader à un programme utilisé, manoeuvres qui sont interdites dans libWEBGL aussitôt que le programme est lié.
Concernant les shaders, les règles sont :
  • un shader peut être attaché à N >= 0 programmes ;
  • un shader ne peut pas être détaché d'un programme utilisé ;
  • un shader ne peut pas être réinitialisé tant qu'il est attaché à un programme (a fortiori à plusieurs) ;
  • un shader ne peut pas être détruit tant qu'il ne peut pas être réinitialisé ;
  • un shader ne peut pas être compilé tant qu'il est attaché à un programme (a fortiori à plusieurs).
Concernant les programmes, les règles sont :
  • un programme ne peut pas charger des points et des indices s'il n'est pas utilisé ;
  • un programme peut toujours être réinitialisé ;
  • lors de la réinitialisation d'un programme, les shaders qui lui sont attachés en sont détachés ;
  • un programme peut toujours être détruit ;
  • lors de la destruction d'un programme, ce programme est réinitialisé ;
  • un programme ne peut pas être lié si un vertex shader et un fragment shader compilés ne lui sont pas attachés ;
  • un programme ne peut pas être utilisé s'il n'a pas été lié ;
  • un programme ne peut pas être utilisé si un vertex shader et un fragment shader compilés ne lui sont pas attachés ;
  • un programme ne peut affecter de valeur à une uniforme que s'il est utilisé.
Le respect de ces règles est assuré par des tests systématiques dans les différentes méthodes qu'elles concernent. Si elle détecte une infraction, une telle méthode retourne un booléen à false, et affiche un message explicite dans la console.
Attention ! Il n'existe aucune mécanique générale pour s'assurer qu'un unique objet Program est en cours d'utilisation. Cela pourrait poser un problème lors de l'appel à Program.load () car cette méthode charge les points et les indices dans les VBOs présumés liés à ARRAY_BUFFER et à ELEMENT_ARRAY_BUFFER lors d'un précédent appel à Program.use (). Il faut donc éviter une séquence telle que :
program0.use ();
program1.use ();
program0.load (vertices, indices);	// Chargera dans les VBOs liés par program1.use () et non par program0.use () !

Précision sur le fonctionnement d'un VAO

Comme c'était déjà le cas des helpers que ces wrappers viennent donc remplacer, ces wrappers s'appuient sur un Vertex Array Object, ou VAO.
Pour rappel, il ne faut pas confondre le Vertex Buffer Object, ou VBO, et le Vertex Array Buffer, ou VAO :
  • Un VBO est un buffer destiné à être lié par un appel à gl.bindBuffer () à l'un des buffers internes : ARRAY_BUFFER pour les points, et ELEMENT_ARRAY_BUFFER pour les indices.
  • Un VAO est un moyen pratique pour éviter d'avoir à écrire la longue suite d'appels à différentes fonctions de WebGL nécessaire pour décrire comment WebGL doit lire les points et les indices lors d'un rendu à venir.
Le VAO est documenté par Khronos ici, mais il faut bien reconnaître que les explications données peuvent être assez difficiles à suivre. Elles deviennent bien plus intelligibles et intéressantes à lire après avoir saisi en quoi consiste exactement ce fameux état que la VAO est censé permettre de mémoriser et de restaurer si facilement, et comment utiliser le VAO à cette fin.
Pour comprendre en quoi l'état d'un VAO consiste, il faut se référer au tableau 6.2, page 247 de la spécification d'OpenGL ES 3.0.6 qui, comme chacun sait, constitue le standard de référence pour WebGL 2. Ce tableau recense les variables qui composent cet état. Il mérite toutefois d'être repris en mettant en face de chaque variable la ou les fonctions de WebGL dont l'effet de l'appel sur une variable est mémorisé par le VAO :
VERTEX ATTRIB ARRAY ENABLEDgl.enableVertexAttribArray ()
gl.disableVertexAttribArray ()
VERTEX ATTRIB ARRAY SIZEgl.vertexAttribPointer (..., size, ...)
gl.vertexAttribIPointer (..., size, ...) // WebGL 2
VERTEX ATTRIB ARRAY STRIDEgl.vertexAttribPointer (..., stride, ...)
gl.vertexAttribIPointer (..., stride, ...) // WebGL 2
VERTEX ATTRIB ARRAY TYPEgl.vertexAttribPointer (..., type, ...)
gl.vertexAttribIPointer (..., type, ...) // WebGL 2
VERTEX ATTRIB ARRAY NORMALIZEDgl.vertexAttribPointer (..., normalized, ...)
gl.vertexAttribIPointer (..., normalized, ...) // WebGL 2
VERTEX ATTRIB ARRAY INTEGERgl.vertexAttribI4[u]i[v] () // WebGL 2
VERTEX ATTRIB ARRAY DIVISORgl.vertexAttribDivisor () // WebGL 2
VERTEX ATTRIB ARRAY POINTERgl.vertexAttribPointer (index, size, type, normalized, stride, offset)
ELEMENT ARRAY BUFFER BINDINGgl.bindBuffer (gl.ELEMENT_ARRAY_BUFFER, buffer)
VERTEX ATTRIB ARRAY BUFFER BINDINGVoir les explications ci-après.
En effet, un VAO se crée ainsi... :
// Créer une fois pour toute le VAO

vao = gl.createVertexArray ();

// Lier le VAO pour commencer à lui faire mémoriser la manière de lire les points et les indices

gl.bindVertexArray (vao);

// Procéder à des appels qui décrivent cette manière de lire en prenant pour exemple un format de point (x, y, r, g, b, a)

gl.bindBuffer (gl.ARRAY_BUFFER, bufferV);	// Attention, ce lien n'est pas mémorisé par le VAO (cf. plus loin)
gl.bindBuffer (gl.ELEMENT_ARRAY_BUFFER, bufferI);
gl.enableVertexAttribArray (this.A_xy);
gl.vertexAttribPointer (this.A_xy, 2, gl.FLOAT, false, 6 * Float32Array.BYTES_PER_ELEMENT, 0);
gl.enableVertexAttribArray (this.A_rgba);
gl.vertexAttribPointer (this.A_rgba, 4, gl.FLOAT, false, 6 * Float32Array.BYTES_PER_ELEMENT, 2 * Float32Array.BYTES_PER_ELEMENT);

// Délier le VAO pour qu'il arrête de mémoriser

gl.bindVertexArray (null);
...et il s'utilise ainsi :
// Lier le VAO, car en plus de lui faire mémoriser, cela commence par lui faire rétablir tout ce qu'il avait mémorisé

gl.bindVertexArray (vao);

// Commander le rendu

gl.drawElements (gl.TRIANGLES, nbVerticesPerPrimitives * nbPrimitives, gl.UNSIGNED_SHORT, 0);

// Délier le VAO

gl.bindVertexArray (null);

// A la toute fin, quand le vao ne sera plus utilisé, le détruire

gl.deleteVertexArray (vao);
On le voit, le VAO semble répéter les appels aux fonctions qui permettent de spécifier la manière dont les points et les indices doivent être lus lors du rendu. Tout cela est fort simple à comprendre, à une subtilité près, qui concerne la mémorisation du lien aux buffers internes.
Dans le cas d'un ELEMENT_ARRAY_BUFFER, ou buffer des indices, le VAO mémorise le buffer actuellement lié à ELEMENT_ARRAY_BUFFER. Autrement dit, pour l'utiliser :
// Démarrer la mémorisation

gl.bindVertexArray (vao);

// Lier bufferA à ELEMENT_ARRAY_BUFFER

gl.bindBuffer (gl.ELEMENT_ARRAY_BUFFER, bufferA);

// Arrêter la mémorisation

gl.bindVertexArray (null);

// Lier bufferA à ELEMENT_ARRAY_BUFFER

gl.bindBuffer (gl.ELEMENT_ARRAY_BUFFER, bufferB);

// Rappeler la mémorisation

gl.bindVertexArray (vao);

// Vérifier que bufferA est de nouveau lié à ELEMENT_ARRAY_BUFFER

if (gl.getParameter (gl.ELEMENT_ARRAY_BUFFER_BINDING) === bufferA)
	console ("Binding restored");
Au passage, noter l'existence de la fonction gl.getParameter () qui permet de consulter une variable de l'état de WebGL.
Dans le cas d'un ARRAY_BUFFER, ou buffer des points, c'est plus complexe. Pour comprendre, il faut se rappeler comment gl.vertexAttribPointer () fonctionne. Cette fonction permet de décrire la manière dont les données qui alimentent un attribut doivent être lues depuis un VBO, ce dernier étant celui qui est lié à ARRAY_BUFFER au moment où la fonction est appelée. Par exemple, pour décrire la manière dont les coordonnées (x, y) doivent être lues de bufferVxyz pour alimenter un attribut A_xyz, et les composantes (r, g, b, a) des couleurs doivent être lues de bufferVrgba pour alimenter un attribut A_rgba :
// Récupérer les indices des attributs à configurer

var A_xyz = gl.getAttribLocation (program, "A_xyz");
var A_rgba = gl.getAttribLocation (program, "A_rgba");

// Démarrer la mémorisation

gl.bindVertexArray (vao);

// Faire de bufferVxyz l'ARRAY_BUFFER actuel

gl.bindBuffer (gl.ARRAY_BUFFER, bufferVxyz);

// Mémoriser où lire de quoi alimenter A_xyz à partir de l'ARRAY_BUFFER actuel, donc de bufferVzyz

gl.vertexAttribPointer (A_xyz, ...);

// Faire de bufferVxyz l'ARRAY_BUFFER actuel

gl.bindBuffer (gl.ARRAY_BUFFER, bufferVrgba);

// Mémoriser où lire de quoi alimenter A_rgba à partir de l'ARRAY_BUFFER actuel, donc de bufferVrgba

gl.vertexAttribPointer (A_rgba, ...);

// Arrêter la mémorisation

gl.bindVertexArray (null);
Le VAO ne mémorise pas quel VBO est lié à ARRAY_BUFFER en général. Il le mémorise par attribut. Autrement dit, il mémorise la configuration des attributs, en mémorisant pour chaque attribut :
  • la configuration telle que décrite par les valeurs fournies à gl.vertexAttribPointer () ;
  • le VBO qui était lié à ARRAY_BUFFER lors de l'appel à cette fonction, d'où le sens à donner à la variable "VERTEX ATTRIB ARRAY BUFFER BINDING" du tableau 6.2 présenté plus tôt.
Il ne faut donc pas s'attendre à ce que le rappel d'un VAO re-lie un VBO à ARRAY_BUFFER comme ce rappel re-lie un VBO à ELEMENT_ARRAY_BUFFER. Ce n'est vrai qu'à l'échelle d'un attribut, pas à celle du programme, comme il est possible de le vérifier :
// Lier bufferV0 à ARRAY_BUFFER et bufferI0 à ELEMENT_ARRAY_BUFFER

gl.bindBuffer (gl.ARRAY_BUFFER, bufferV0);
gl.bindBuffer (gl.ELEMENT_ARRAY_BUFFER, bufferI0);

// Utiliser le VAO pour mémoriser de nouveaux liens de bufferV1 et bufferI1 à ces buffers

gl.bindVertexArray (vao);
gl.bindBuffer (gl.ARRAY_BUFFER, bufferV1);
gl.bindBuffer (gl.ELEMENT_ARRAY_BUFFER, bufferI1);
gl.bindVertexArray (null);

// Lier bufferV2 à ARRAY_BUFFER et bufferI2 à ELEMENT_ARRAY_BUFFER

gl.bindBuffer (gl.ARRAY_BUFFER, bufferV2);
gl.bindBuffer (gl.ELEMENT_ARRAY_BUFFER, bufferI2);

// Rappeler la mémorisation

gl.bindVertexArray (vao);

// Vérifier que bufferV2 est toujours lié à ARRAY_BUFFER, mais que bufferI1 est de nouveau lié à ELEMENT_ARRAY_BUFFER

if (gl.getParameter (gl.ARRAY_BUFFER_BINDING) === bufferV2)
	console ("bufferV2 binding to ARRAY_BUFFER restored");
if (gl.getParameter (gl.ELEMENT_ARRAY_BUFFER_BINDING) === bufferI1)
	console ("bufferI1 bindind to ELEMENT_ARRAY_BUFFER restored");
Pour terminer, noter l'existence de la fonction gl.getVertexAttrib () qui permet de visualiser la configuration d'un attribut telle que mémorisée par le VAO, notamment le buffer qui était lié à ARRAY_BUFFER quand gl.vertexAttribPointer () a été appelée.