Présentation des nuanceurs

Introduction

Lisez d'abord l'introduction à Three.js, car elle constitue la base de ce document.

Je souhaite maintenant parler des nuanceurs. WebGL est un outil formidable et, comme je l'ai déjà dit, Three.js (et d'autres bibliothèques) font un travail fantastique pour vous simplifier la tâche. Cependant, il peut arriver que vous souhaitiez obtenir un effet spécifique ou que vous vouliez approfondir la façon dont ces éléments incroyables sont apparus sur votre écran. Les nuanceurs feront presque certainement partie de cette équation. Vous voudrez peut-être passer des éléments de base du tutoriel précédent à quelque chose d'un peu plus complexe.

Je vais partir du principe que vous utilisez Three.js, car il fait une grande partie du travail pour nous en ce qui concerne le fonctionnement du nuanceur. Vous allez d'abord découvrir le contexte des nuanceurs, puis les étapes avancées plus loin dans ce document.

1. Nos deux nuanceurs

WebGL ne propose pas l'utilisation du pipeline fixe, ce qui signifie qu'il ne vous donne aucun moyen de rendre vos éléments prêts à l'emploi. En revanche, il propose le pipeline programmable, qui est plus puissant, mais aussi plus difficile à comprendre et à utiliser. En résumé, le pipeline programmable signifie qu'en tant que programmeur, vous êtes responsable de l'affichage des sommets et d'autres éléments à l'écran. Les nuanceurs font partie de ce pipeline et sont de deux types :

  1. Vertex shaders
  2. Nuanceurs de fragments

Vous conviendrez sans doute que ces deux termes ne signifient absolument rien en eux-mêmes. Ce que vous devez savoir, c'est qu'ils s'exécutent tous les deux entièrement sur le GPU de votre carte graphique. Cela signifie que nous voulons leur décharger tout ce que nous pouvons, laissant notre CPU faire d'autres tâches. Un GPU moderne est fortement optimisé pour les fonctions requises par les nuanceurs. Il est donc très utile de pouvoir l'utiliser.

2. Vertex shaders

Prenez une forme primitive standard, comme une sphère. Il est constitué de sommets, n'est-ce pas ? Un vertex shader reçoit chacun de ces sommets à tour de rôle et peut les modifier. Le nuanceur de sommets décide de ce qu'il fait de chacun d'eux, mais il a une responsabilité : il doit à un moment donné définir un élément appelé gl_Position, un vecteur flottant 4D, qui est la position finale du sommet à l'écran. En soi, il s'agit d'un processus assez intéressant, car nous parlons en fait d'obtenir une position 3D (un sommet avec x, y, z) sur un écran 2D ou projetée sur celui-ci. Heureusement, si nous utilisons un outil comme Three.js, nous pouvons définir gl_Position de manière abrégée sans que les choses ne deviennent trop lourdes.

3. Nuanceurs de fragments

Nous avons donc notre objet avec ses sommets, et nous les avons projetés sur l'écran 2D, mais qu'en est-il des couleurs que nous utilisons ? Qu'en est-il de la texturation et de l'éclairage ? C'est exactement à cela que sert le nuanceur de fragments. Tout comme le nuanceur de sommets, le nuanceur de fragments n'a qu'une seule tâche à accomplir : il doit définir ou supprimer la variable gl_FragColor, un autre vecteur flottant 4D, qui correspond à la couleur finale de notre fragment.

Mais qu'est-ce qu'un fragment ? Imaginez trois sommets qui forment un triangle. Chaque pixel de ce triangle doit être dessiné. Un fragment correspond aux données fournies par ces trois sommets pour dessiner chaque pixel de ce triangle. Par conséquent, les fragments reçoivent des valeurs interpolées de leurs sommets constitutifs. Si un sommet est rouge et que son voisin est bleu, les valeurs de couleur interpolent du rouge au bleu en passant par le violet.

4. Variables de nuanceur

Vous pouvez effectuer trois déclarations de variables : Uniforms, Attributes et Varyings. Cela peut prêter à confusion, car ils ne correspondent à aucun autre langage courant. Voici comment les envisager :

  1. Les uniformes sont envoyés à la fois aux nuanceurs de sommets et aux nuanceurs de fragments. Ils contiennent des valeurs qui restent identiques sur l'ensemble de l'image en cours d'affichage. La position d'une lumière en est un bon exemple.

  2. Les attributs sont des valeurs appliquées à des sommets individuels. Les attributs ne sont disponibles que pour le nuanceur de sommets. Par exemple, chaque sommet peut avoir une couleur distincte. Les attributs ont une relation de un à un avec les sommets.

  3. Les variables sont des variables déclarées dans le nuanceur de sommets que nous voulons partager avec le nuanceur de fragments. Pour ce faire, nous nous assurons de déclarer une variable de type "varying" du même type et du même nom dans le nuanceur de sommets et le nuanceur de fragments. Une utilisation classique de cette méthode serait la normale d'un sommet, car elle peut être utilisée dans les calculs d'éclairage.

Plus tard, nous utiliserons les trois types pour que vous puissiez vous faire une idée de la façon dont ils sont réellement appliqués.

Non, examinons l'un des nuanceurs les plus simples que vous pouvez créer.

5. Bonjourno World

Voici donc le Hello World des nuanceurs de sommets :

/**
* Multiply each vertex by the model-view matrix
* and the projection matrix (both provided by
* Three.js) to get a final vertex position
*/
void main() {
gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(position,1.0);
}   

et voici la même chose pour le nuanceur de fragments :

/**
* Set the color to a lovely pink.
* Note that the color is a 4D Float
* Vector, R,G,B and A and each part
* runs from 0.0 to 1.0
*/
void main() {
gl_FragColor = vec4(1.0, 0.0, 1.0, 1.0);
}

Ce n'est pas trop compliqué, n'est-ce pas ?

Dans le nuanceur de sommets, Three.js nous envoie quelques variables uniformes. Ces deux uniformes sont des matrices 4D, appelées "matrice de vue de modèle" et "matrice de projection". Vous n'avez pas besoin de savoir exactement comment ils fonctionnent, même s'il est toujours préférable de comprendre comment les choses fonctionnent si vous le pouvez. En résumé, elles permettent de projeter la position 3D du sommet sur la position 2D finale à l'écran.

Je les ai en fait omis de l'exemple d'extrait de code, car Three.js les ajoute lui-même en haut de votre code de nuanceur. Vous n'avez donc pas à vous en soucier. En réalité, il ajoute bien plus que cela, comme des données de lumière, des couleurs de vertex et des normales de vertex. Si vous n'utilisiez pas Three.js, vous devriez créer et définir vous-même tous ces uniformes et attributs. C'est une histoire vraie.

6. Utiliser un MeshShaderMaterial

Maintenant que nous avons configuré un nuanceur, comment l'utiliser avec Three.js ?

/**
* Assume we have jQuery to hand and pull out
* from the DOM the two snippets of text for
* each of our shaders
*/
var shaderMaterial = new THREE.MeshShaderMaterial({
vertexShader:   $('vertexshader').text(),
fragmentShader: $('fragmentshader').text()
});

À partir de là, Three.js compilera et exécutera vos nuanceurs associés au maillage auquel vous attribuez ce matériau. On ne peut pas faire plus simple. C'est probablement le cas, mais nous parlons de 3D exécutée dans votre navigateur. Je suppose donc que vous vous attendez à un certain niveau de complexité.

Nous pouvons ajouter deux autres propriétés à notre MeshShaderMaterial : les uniformes et les attributs. Ils peuvent tous deux accepter des vecteurs, des entiers ou des nombres à virgule flottante, mais comme je l'ai mentionné précédemment, les uniformes sont les mêmes pour l'ensemble de l'image ou pour tous les sommets. Ils ont donc tendance à être des valeurs uniques. En revanche, les attributs sont des variables par sommet. Ils doivent donc être un tableau. Il doit y avoir une relation un à un entre le nombre de valeurs dans le tableau d'attributs et le nombre de sommets dans le maillage.

7. Étapes suivantes

Nous allons maintenant passer un peu de temps à ajouter une boucle d'animation, des attributs de sommet et un uniforme. Nous allons également ajouter une variable "varying" pour que le nuanceur de sommets puisse envoyer des données au nuanceur de fragments. Notre sphère rose va donc sembler éclairée par le haut et par le côté, et elle va pulser. C'est un peu déroutant, mais nous espérons que cela vous permettra de bien comprendre les trois types de variables, ainsi que leur relation entre eux et avec la géométrie sous-jacente.

8. Fausse lumière

Maintenant, modifiez la couleur pour que l'objet ne soit pas d'une couleur unie. Nous pourrions examiner comment Three.js gère l'éclairage, mais comme vous pouvez l'imaginer, c'est plus complexe que ce dont nous avons besoin. Nous allons donc simuler l'éclairage. Vous devriez absolument examiner les fantastiques nuanceurs qui font partie de Three.js, ainsi que ceux de l'incroyable projet WebGL de Chris Milk et Google, Rome. Revenons à nos nuanceurs. Nous allons mettre à jour notre nuanceur de sommets pour fournir chaque normale de sommet au nuanceur de fragments. Pour ce faire, nous utilisons une variable :

// create a shared variable for the
// VS and FS containing the normal
varying vec3 vNormal;

void main() {

// set the vNormal value with
// the attribute value passed
// in by Three.js
vNormal = normal;

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(position,1.0);
}

Dans le nuanceur de fragments, nous allons configurer le même nom de variable, puis utiliser le produit scalaire de la normale du sommet avec un vecteur qui représente une lumière provenant du haut et de la droite de la sphère. Le résultat net de cette opération nous donne un effet similaire à une lumière directionnelle dans un package 3D.

// same name and type as VS
varying vec3 vNormal;

void main() {

// calc the dot product and clamp
// 0 -> 1 rather than -1 -> 1
vec3 light = vec3(0.5,0.2,1.0);
    
// ensure it's normalized
light = normalize(light);

// calculate the dot product of
// the light to the vertex normal
float dProd = max(0.0, dot(vNormal, light));

// feed into our frag color
gl_FragColor = vec4(dProd, dProd, dProd, 1.0);

}

Le produit scalaire fonctionne, car il permet d'obtenir un nombre qui indique le degré de "similarité" entre deux vecteurs. Avec des vecteurs normalisés, s'ils pointent exactement dans la même direction, vous obtenez une valeur de 1. S'ils pointent dans des directions opposées, vous obtenez -1. Nous prenons ce nombre et l'appliquons à notre éclairage. Ainsi, un sommet en haut à droite aura une valeur proche ou égale à 1 (complètement éclairé), tandis qu'un sommet sur le côté aura une valeur proche de 0 et un sommet à l'arrière aura une valeur de -1. Nous limitons la valeur à 0 pour tout ce qui est négatif, mais lorsque vous branchez les chiffres, vous obtenez l'éclairage de base que nous voyons.

Étape suivante Il serait intéressant d'essayer de modifier la position de certains sommets.

9. Attributs

Je voudrais maintenant que nous associions un nombre aléatoire à chaque sommet à l'aide d'un attribut. Nous utiliserons ce nombre pour pousser le sommet le long de sa normale. Le résultat net sera une sorte de boule à pointes bizarre qui changera chaque fois que vous actualiserez la page. Il ne sera pas encore animé (ce sera l'étape suivante), mais si vous actualisez la page plusieurs fois, vous verrez qu'il est aléatoire.

Commencez par ajouter l'attribut au nuanceur de sommets :

attribute float displacement;
varying vec3 vNormal;

void main() {

vNormal = normal;

// push the displacement into the three
// slots of a 3D vector so it can be
// used in operations with other 3D
// vectors like positions and normals
vec3 newPosition = position +
                    normal *
                    vec3(displacement);

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(newPosition,1.0);
}

Comment se présente-t-il ?

Pas grand-chose, en réalité. En effet, l'attribut n'a pas été configuré dans MeshShaderMaterial. Par conséquent, le nuanceur utilise une valeur nulle à la place. C'est un peu comme un espace réservé pour le moment. Dans un instant, nous ajouterons l'attribut à MeshShaderMaterial dans le code JavaScript, et Three.js liera automatiquement les deux.

Notez également que j'ai dû attribuer la position mise à jour à une nouvelle variable vec3, car l'attribut d'origine, comme tous les attributs, est en lecture seule.

10. Mettre à jour MeshShaderMaterial

Passons directement à la mise à jour de notre MeshShaderMaterial avec l'attribut nécessaire pour alimenter notre déplacement. Pour rappel, les attributs sont des valeurs par sommet. Nous avons donc besoin d'une valeur par sommet dans notre sphère. Exemple :

var attributes = {
displacement: {
    type: 'f', // a float
    value: [] // an empty array
}
};

// create the material and now
// include the attributes property
var shaderMaterial = new THREE.MeshShaderMaterial({
attributes:     attributes,
vertexShader:   $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});

// now populate the array of attributes
var vertices = sphere.geometry.vertices;
var values = attributes.displacement.value
for(var v = 0; v < vertices.length; v++) {
values.push(Math.random() * 30);
}

Nous voyons maintenant une sphère déformée, mais l'avantage est que tout le déplacement se produit sur le GPU.

11. Animer

Nous devrions absolument l'animer. Comment faisons-nous ? Pour cela, nous devons mettre en place deux éléments :

  1. Un uniforme pour animer la quantité de déplacement à appliquer dans chaque frame. Nous pouvons utiliser le sinus ou le cosinus pour cela, car ils vont de -1 à 1.
  2. Une boucle d'animation dans le code JS

Nous allons ajouter l'uniforme à MeshShaderMaterial et au nuanceur de sommets. Tout d'abord, le nuanceur de sommets :

uniform float amplitude;
attribute float displacement;
varying vec3 vNormal;

void main() {

vNormal = normal;

// multiply our displacement by the
// amplitude. The amp will get animated
// so we'll have animated displacement
vec3 newPosition = position +
                    normal *
                    vec3(displacement *
                        amplitude);

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(newPosition,1.0);
}

Ensuite, nous mettons à jour le MeshShaderMaterial :

// add a uniform for the amplitude
var uniforms = {
amplitude: {
    type: 'f', // a float
    value: 0
}
};

// create the final material
var shaderMaterial = new THREE.MeshShaderMaterial({
uniforms:       uniforms,
attributes:     attributes,
vertexShader:   $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});

Nos nuanceurs sont terminés pour l'instant. Mais pour l'instant, nous semblons avoir fait un pas en arrière. Cela est principalement dû au fait que notre valeur d'amplitude est de 0 et que, comme nous la multiplions par le déplacement, nous ne voyons aucun changement. Nous n'avons pas non plus configuré la boucle d'animation, donc nous ne voyons jamais ce 0 changer.

Dans notre code JavaScript, nous devons maintenant encapsuler l'appel de rendu dans une fonction, puis utiliser requestAnimationFrame pour l'appeler. Nous devons également y mettre à jour la valeur de l'uniforme.

var frame = 0;
function update() {

// update the amplitude based on
// the frame value
uniforms.amplitude.value = Math.sin(frame);
frame += 0.1;

renderer.render(scene, camera);

// set up the next call
requestAnimFrame(update);
}
requestAnimFrame(update);

Conclusion

Et voilà ! Vous pouvez maintenant voir qu'il s'anime d'une manière étrange (et légèrement psychédélique).

Il y a encore beaucoup de choses à dire sur les nuanceurs, et j'espère que cette introduction vous a été utile. Vous devriez maintenant être en mesure de comprendre les nuanceurs lorsque vous les voyez et d'en créer vous-même.