Faciliter la modification du format de point WebGL en JavaScript

Ceux qui programment directement WebGL, version 1 ou 2, avec JavaScript connaissent l'enfer que représente l'élaboration d'un shader. Qu'un format de point soit modifié, et plus généralement qu'un attribut de point, une composante variable ou une variable uniforme soit supprimé, rajouté ou modifié dans le code GLSL d'un shader, et il faut :
  • mettre à jour leur liste avant la fonction main () du shader ;
  • dans le cas d'uniformes, mettre à jour la liste des appels à getUniformLocation () ;
  • dans le cas d'attributs, ce qui résulte inévitable de la modification du format de point :
    • mettre à jour la liste des appels à getAttribLocation () ;
    • mettre à jour la liste des appels à enableVertexAttribArray (), enableVertexAttribArray (), vertexAttribPointer () et disableVertexAttribArray ().
Exemple d'animation modifiée à la volée avec les shaders
Pour soulager la vie du développeur, WebGL 2 met désormais à disposition un nouvel objet : le Vertex Array Object, ou VAO pour les intimes. Il permet d'éviter de répéter dans le code des séquences d'appels pour configurer les attributs avant chaque rendu.
Toutefois, cela ne répond que très partiellement au problème évoqué à l'instant, car les mises à jour évoquées restent toujours aussi nécessaires et fastidieuses, quand bien même les occurences des appels à modifier sont réduites. N'y a-t-il pas moyen de se simplifier la vie ? Modeste proposition.
NB : Cet article renoue avec le format abrégé de premiers articles de ce blog. C'est qu'au terme d'une série de prises en main de technologies Web (WebG 2, promises, fonction fléchées, Web Components, etc.), un certain de nombre de difficultés rencontrées dans le cadre du développement d'une application à base de JavaScript et de WebGL en donnent l'opportunité.

La solution

La solution repose sur deux objets helpers, un par type de shader qu'il est possible d'utiliser en WebGL : VertexShaderHelper pour tout vertex shader, et FragmentShaderHelper pour tout fragment shader. Comme la seule chose qui distingue ces shaders est que le premier utilise des attributs et l'autre non - les deux peuvent utiliser des variantes (ie : des variables interpolées) et des uniformes -, ces objets héritent d'un même objet, ShaderHelper.
Quand le format de point est modifié, tout ce qu'il s'agit de modifier n'a besoin de l'être qu'une fois pour toutes. Autrement dit, il n'est pas nécessaire de se soucier des ressources que la solution recherchée peut consommer, notamment de son temps d'exécution. Cela permet d'opter pour la solution qui procure le maximum de confort.

Le code JavaScript

En JavaScript, la solution se traduit par le code suivant :
//------------------------------------------------------------------------------
// Base de helper
//------------------------------------------------------------------------------

ShaderHelper = function (gl, glType) {
	this.gl = gl;
	this.glType = glType;
	this.error = "";
	this.reset ();
};

ShaderHelper.prototype.PRECISION_LOWP = 0;
ShaderHelper.prototype.PRECISION_MEDIUMP = 1;
ShaderHelper.prototype.PRECISION_HIGHP = 2;
ShaderHelper.prototype.TYPE_FLOAT = 0;
ShaderHelper.prototype.TYPE_VEC2 = 1;
ShaderHelper.prototype.TYPE_VEC3 = 2;
ShaderHelper.prototype.TYPE_VEC4 = 3;
ShaderHelper.prototype.TYPE_MAT3 = 4;
ShaderHelper.prototype.TYPE_MAT4 = 5;

ShaderHelper.prototype.reset = function () {
	this.inputs = new Array ();		// Pour un VertexShader, des attributs (attribute) ; pour un fragment shader, des variantes (varying)
	this.outputs = new Array ();	// Pour un VertexShader, des variantes (varying) ; pour un fragment shader, l'équivalent de gl_FragColor
	this.uniforms = new Array ();
	this.release ();
};

ShaderHelper.prototype.addInput = function (name, precision, type) {
	this.inputs.push ({ name: name, precision: precision, type: type, location: -1 });
};

ShaderHelper.prototype.addOutput = function (name, precision, type) {
	this.outputs.push ({ name: name, precision: precision, type: type, location: -1 });
};

ShaderHelper.prototype.addUniform = function (name, precision, type) {
	this.uniforms.push ({ name: name, precision: precision, type: type, location: -1 });
};

ShaderHelper.prototype.setProgram = function (program) {
	this.program = program;
	this.gl.attachShader (this.program, this.shader);
};

ShaderHelper.prototype.build = function (main) {
	var source, i, precisions, types;

	precisions = new Array ();
	precisions[this.PRECISION_LOWP] = "lowp";
	precisions[this.PRECISION_MEDIUMP] = "mediump";
	precisions[this.PRECISION_HIGHP] = "highp";
	types = new Array ();
	types[this.TYPE_FLOAT] = "float";
	types[this.TYPE_VEC2] = "vec2";
	types[this.TYPE_VEC3] = "vec3";
	types[this.TYPE_VEC4] = "vec4";
	types[this.TYPE_MAT3] = "mat3";
	types[this.TYPE_MAT4] = "mat4";
	source = "#version 300 es\n";
	for (i = 0; i != this.inputs.length; i ++)
		source += `in ${precisions[this.inputs[i].precision]} ${types[this.inputs[i].type]} ${this.inputs[i].name};`;
	for (i = 0; i != this.uniforms.length; i ++)
		source += `uniform ${precisions[this.uniforms[i].precision]} ${types[this.uniforms[i].type]} ${this.uniforms[i].name};`;
	for (i = 0; i != this.outputs.length; i ++)
		source += `out ${precisions[this.outputs[i].precision]} ${types[this.outputs[i].type]} ${this.outputs[i].name};`;
	source += main;
	this.shader = this.gl.createShader (this.glType);
	this.gl.shaderSource (this.shader, source);
	this.gl.compileShader (this.shader);
	if (!this.gl.getShaderParameter (this.shader, this.gl.COMPILE_STATUS)) {
		this.error = this.gl.getShaderInfoLog (this.shader);
		return (null);
	}
	return (this.shader);
};

ShaderHelper.prototype.getError = function () {
	return (this.error);
};

ShaderHelper.prototype.use = function () {
	var i;

	for (i = 0; i != this.uniforms.length; i ++)
		this.uniforms[i].location = this.gl.getUniformLocation (this.program, this.uniforms[i].name);
};

ShaderHelper.prototype.getUniformLocation = function (name) {
	var i;

	for (i = 0; i != this.uniforms.length; i ++) {
		if (this.uniforms[i].name == name) {
			if (this.uniforms[i].location == -1)
				console.log ("libWEBGL warning: Uniform location is -1. Check if you must call .getUniformLocation () after .use ().");
			return (this.uniforms[i].location);
		}
	}
	return (-1);
};

ShaderHelper.prototype.release = function () {
	if (this.program) {
		this.gl.detachShader (this.program, this.shader);
		this.program = null;
	}
	if (this.shader) {
		this.gl.deleteShader (this.shader);
		this.shader = null;
	}
};

//------------------------------------------------------------------------------
// Helper pour fragment shader
//------------------------------------------------------------------------------

FragmentShaderHelper = function (gl) {
	ShaderHelper.call (this, gl, gl.FRAGMENT_SHADER);
};
FragmentShaderHelper.prototype = Object.create (ShaderHelper.prototype);

//------------------------------------------------------------------------------
// Helper pour vertex shader
//------------------------------------------------------------------------------

VertexShaderHelper = function (gl) {
	ShaderHelper.call (this, gl, gl.VERTEX_SHADER);
};
VertexShaderHelper.prototype = Object.create (ShaderHelper.prototype);

VertexShaderHelper.prototype.use = function () {
	var i, j, types, stride, offset;

	ShaderHelper.prototype.use.call (this);
	for (i = 0; i != this.inputs.length; i ++)
		this.inputs[i].location = this.gl.getAttribLocation (this.program, this.inputs[i].name);
	types = new Array ();
	types[this.TYPE_FLOAT] = {size: 1, stride: 1};
	types[this.TYPE_VEC2] = {size: 2, stride: 2};
	types[this.TYPE_VEC3] = {size: 3, stride: 3};
	types[this.TYPE_VEC4] = {size: 4, stride: 4};
	types[this.TYPE_MAT3] = {size: 3, stride: 9};
	types[this.TYPE_MAT4] = {size: 4, stride: 16};
	stride = 0;
	for (i = 0; i != this.inputs.length; i ++)
		stride += types[this.inputs[i].type].stride;
	offset = 0;
	for (i = 0; i != this.inputs.length; i ++) {
		switch (this.inputs[i].type) {
			case this.TYPE_FLOAT:
			case this.TYPE_VEC2:
			case this.TYPE_VEC3:
			case this.TYPE_VEC4:
				this.gl.vertexAttribPointer (this.inputs[i].location, types[this.inputs[i].type].size, this.gl.FLOAT, false, stride * Float32Array.BYTES_PER_ELEMENT, offset * Float32Array.BYTES_PER_ELEMENT);
				this.gl.enableVertexAttribArray (this.inputs[i].location);
				offset += types[this.inputs[i].type].size;
				break;
			case this.TYPE_MAT3:
				for (j = 0; j != 3; j ++) {
					this.gl.vertexAttribPointer (this.inputs[i].location + j, types[this.inputs[i].type].size, this.gl.FLOAT, false, stride * Float32Array.BYTES_PER_ELEMENT, offset * Float32Array.BYTES_PER_ELEMENT);
					this.gl.enableVertexAttribArray (this.inputs[i].location + j);
					offset += types[this.inputs[i].type].size;
				}
				break;
			case this.TYPE_MAT4:
				for (j = 0; j != 4; j ++) {
					this.gl.vertexAttribPointer (this.inputs[i].location + j, types[this.inputs[i].type].size, this.gl.FLOAT, false, stride * Float32Array.BYTES_PER_ELEMENT, offset * Float32Array.BYTES_PER_ELEMENT);
					this.gl.enableVertexAttribArray (this.inputs[i].location + j);
					offset += types[this.inputs[i].type].size;
				}
				break;
		}
	}
};

VertexShaderHelper.prototype.release = function () {
	var i;

	for (i = 0; i != this.inputs.length; i ++) {
		switch (this.inputs[i].type) {
			case this.TYPE_FLOAT:
			case this.TYPE_VEC2:
			case this.TYPE_VEC3:
			case this.TYPE_VEC4:
				this.gl.disableVertexAttribArray (this.inputs[i].location);
				break;
			case this.TYPE_MAT3:
				for (j = 0; j != 3; j ++)
					this.gl.disableVertexAttribArray (this.inputs[i].location + j);
				break;
			case this.TYPE_MAT4:
				for (j = 0; j != 4; j ++)
					this.gl.disableVertexAttribArray (this.inputs[i].location + j);
				break;
		}
	}
	ShaderHelper.prototype.release.call (this);
};

L'exemple

Cliquez ici pour accéder à une page de test minimaliste. Vous pourrez visualiser le code et le récupérer pour travailler avec.
La mise en oeuvre de la solution permet de constater à quel point - c'est le mot ! - elle simplifie la vie. Prenons l'exemple du format de point suivant :
vec2 Coordonnées dans le plan
mat3 Matrice de transformation dans le plan
vec4 Couleur
Un tel format de point est totalement typique quand WebGL est utilisé pour travailler en 2D, comme cela a été généralement déjà expliqué ici, et de manière beaucoup plus détaillée ici et .
Prenons l'exemple du helper pour vertex shader. Tout d'abord, il faut créer le helper :
vsHelper = libWEBGL.createVertexShader (gl);
Ensuite, il faut spécifier les variantes, uniformes et attributs, en précisant chaque fois le nom, la précision et le type de la variable. La nouvelle terminologie de WebGL 2 est adoptée, dans laquelle, pour un vertex shader, les attributs sont des entrées (in), et le variantes sont des sorties (out).
Concernant ces les attributs, ils doivent impérativement être ajoutés dans l'ordre dans lequel ils figurent dans le format de point. Dans cet exemple, WebGL s'attendra à lire 2 flottants pour les coordonnées, puis 9 flottants pour la matrice de transformation, et enfin 4 flottants pour la couleur :
vsHelper.addInput ("A_xy", libWEBGL.PRECISION_LOWP, libWEBGL.TYPE_VEC2);
vsHelper.addInput ("A_mL", libWEBGL.PRECISION_LOWP, libWEBGL.TYPE_MAT3);
vsHelper.addInput ("A_rgba", libWEBGL.PRECISION_LOWP, libWEBGL.TYPE_VEC4);
vsHelper.addUniform ("U_mW", libWEBGL.PRECISION_LOWP, libWEBGL.TYPE_MAT3);
vsHelper.addOutput ("V_rgba", libWEBGL.PRECISION_LOWP, libWEBGL.TYPE_VEC4);
Il faut alors fournir le code GLSL du shader. Ce code est automatiquement préfixé d'une déclaration de toutes les variables ajoutées, un vertex shader est créé sur cette base, et ce vertex shader est compilé.
Dans cet exemple, ce code transforme les coordonnées (A_xy) un point par une matrice spécifique (A_mL), puis par une commune pour tous les points (U_mW) - raison pour laquelle cette matrice est stockées dans une uniforme. Le code associe aussi au point sa couleur spécifique (A_rgba) :
shader = vsHelper.build ("void main (void) { gl_Position = vec4 (U_mW * A_mL * vec3 (A_xy.xy, 1.0), 1.0); V_rgba = A_rgba; }");
Il faut ensuite créer un programme, et l'associer au shader helper. Ainsi, ce dernier peut attacher le shader au programme :
program = gl.createProgram ();
vsHelper.setProgram (program);
Ceci fait, il faut lier le programme :
gl.linkProgram (program);
Tout étant en place, il faut utiliser le shader helper. Cela lui permet de récupérer les emplacements de toutes variables du shader dans le programme, afin de pouvoir notamment configurer les attributs pour que WebGL puisse lire les points dans le bon format :
vsHelper.use ();
Noter que ce dernier appel peut être effectué dans le contexte d'un VAO. Par exemple, s'il s'agit de rendre une primitive dont les points - stockés dans bufferV - sont indexés - indices stockés dans bufferI :
vertexArray = gl.createVertexArray ();
gl.bindVertexArray (vertexArray);
	gl.bindBuffer (gl.ELEMENT_ARRAY_BUFFER, bufferI);
	gl.bindBuffer (gl.ARRAY_BUFFER, bufferV);
	vsHelper.use ();
gl.bindVertexArray (null);
Par la suite, il est possible de récupérer une référence sur les uniformes pour les modifier :
gl.uniformMatrix3fv (vsHelper.getUniformLocation ("U_mW"), false, new Float32Array ([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]));
Au terme de son utilisation, il convient de détacher le shader du programme et de le supprimer, ce qui peut ici encore être assuré par le helper :
vsHelper.release ();
Le helper peut être réinitialisé en cours de route, ce qui implique un appel à la méthode précédente. Comme réinitialiser le helper conduit donc à détacher et détruire le vertex shader du programme, mieux vaut avoir désactivé et détruit ce dernier avant :
gl.useProgram (null);
gl.deleteProgram (program);
vsHelper.reset ();
Ce qui vient d'être présenté vaut pour un vertex shader. C'est la même chose pour un fragment shader, sauf que la méthode .use () de son helper n'a pas à être appelée dans le contexte d'un VAO, puisqu'un tel shader n'utilise pas d'attributs.
Une fois ce code mis en place, toute modification du format de point nécessite simplement de modifier la liste des appels à .addInput (), .addOutput () et .addUniform () pour modifier la liste des variables utilisées par le code GLSL. C'est un vrai soulagement !

La logique

Le code des helpers est trivial. Comme sa consommation des ressources ne présente aucun enjeu, il a été rédigé sans se prendre la tête.
La mutualisation de .build () au niveau de Shader exploite le fait que WebGL 2 substitue les concepts de variables d'entrée (in) et de sortie (out) à ceux d'attributs et de variantes respectivement dans le cas d'un vertex shader, et à ceux de variantes et de gl_FragColor dans le cas d'un fragment shader.
Ainsi, une variable ajoutée en entrée à l'aide de Shader.addInput () se traduit toujours par une déclaration de variable préfixée par in dans le code GLSL d'un shader, qu'il s'agisse d'un vertex shader ou d'un fragment shader. De même pour une variable ajoutée en sortie à l'aide de Shader.addOutput (), traduite par une déclaration préfixée par out.
Faciliter la modification du format de point WebGL en JavaScript