Introduction aux Shaders

Starter pack

Il est enfin temps de plonger dans le monde des shaders. Ils sont essentiels pour créer toutes sortes d'effets visuels. Dans ce chapitre, nous allons apprendre ce que sont les shaders, ce que nous pouvons réaliser avec eux, et comment les utiliser dans React Three Fiber.

Prélude

Avant de commencer, je tiens à mentionner que les shaders peuvent prendre un certain temps avant que vous ne vous y habituiez. Ils fonctionnent différemment du reste du code que nous avons écrit jusqu'à présent. C'est une nouvelle façon de penser et de créer des effets visuels. Mais ne vous inquiétez pas, je vais vous guider à travers le processus. Nous commencerons par les bases, puis nous passerons progressivement à des sujets plus avancés.

Ne vous découragez pas si vous ne comprenez pas tout au début. C'est normal. C'est un nouveau concept, et il faut de la pratique pour s'y sentir à l'aise. Je vous recommande de prendre votre temps, d'expérimenter et de pratiquer, mais je vous le promets, cela en vaut la peine ! Les shaders sont incroyablement puissants et vous donneront le contrôle pour créer tout effet visuel que vous pouvez imaginer.

De plus, nous avons tous des styles d'apprentissage différents. Certaines personnes apprennent mieux en lisant, d'autres en regardant des vidéos, et d'autres encore en pratiquant. Pour ce sujet en particulier, il peut être très utile de croiser différentes sources. Je partagerai avec vous des ressources à la fin de ce chapitre pour consolider vos connaissances et aller au-delà de ce que nous couvrons ici.

J'espère ne pas vous avoir fait peur et que vous êtes excité d'apprendre les shaders. Allons-y !

Qu'est-ce qu'un Shader ?

Les shaders sont de petits programmes qui s'exécutent sur le GPU (Graphics Processing Unit). Ils sont écrits dans un langage appelé GLSL (OpenGL Shading Language), qui est similaire au C.

Ils sont utilisés pour positionner les vertices d'une mesh (Vertex Shader) et pour colorier chaque pixel des faces (Fragment Shader).

En fait, nous avons utilisé des shaders tout le long. Lorsque nous créons un material, nous utilisons un shader. Par exemple, lorsque nous créons un MeshBasicMaterial, nous utilisons un shader qui colorie la mesh avec une seule couleur. Lorsque nous créons un MeshStandardMaterial, nous utilisons un shader qui simule l'éclairage, les ombres et les reflets.

Vertex Shader

Un vertex shader est un programme exécuté pour chaque vertex d'une géométrie. Sa responsabilité principale est de transformer les vertices de l'espace 3D (notre monde 3D) à l'espace 2D (notre écran ou viewport). Il réalise cette transformation en utilisant plusieurs matrices :

  • View Matrix : Cette matrice reprĂ©sente la position et l'orientation de la camĂ©ra dans la scène. Elle transforme les vertices de l'espace monde Ă  l'espace camĂ©ra.
  • Projection Matrix : Cette matrice, soit perspective soit orthographique, convertit les vertices de l'espace camĂ©ra aux coordonnĂ©es de dispositifs normalisĂ©s (NDC), les prĂ©parant Ă  la projection finale sur l'Ă©cran 2D.
  • Model Matrix : Cette matrice encapsule la position, la rotation et l'Ă©chelle de chaque objet individuel dans la scène. Elle transforme les vertices de l'espace objet Ă  l'espace monde.

De plus, le vertex shader intègre également la position originale du vertex et tous les attribus qui lui sont associés.

Schéma du vertex shader

Pour chaque vertex de la géométrie, le vertex shader sera exécuté.

Enfin, la position transformée du vertex dans l'espace 2D est renvoyée via la variable prédéfinie gl_Position. Après que tous les vertices soient transformés, le GPU interpole les valeurs entre eux pour générer les faces de la géométrie, qui sont ensuite rasterisées et rendues sur l'écran.

Fragment Shader

Un fragment shader, également connu sous le nom de pixel shader, est un programme exécuté pour chaque fragment (ou pixel) généré par le processus de rasterisation. Sa tâche principale est de déterminer la couleur finale de chaque pixel à l'écran.

Schéma du fragment shader

Pour chaque fragment généré lors de la rasterisation, le fragment shader sera exécuté.

Le fragment shader reçoit des valeurs interpolées du vertex shader, telles que des couleurs, des coordonnées de texture, des normales, et tous les autres attributs associés aux sommets de la géométrie. Ces valeurs interpolées sont appelées varyings et fournissent des informations sur les propriétés de surface à chaque emplacement de fragment.

En plus des valeurs interpolées, le fragment shader peut également échantillonner des textures et accéder à des variables uniformes, qui sont constantes pour tous les fragments. Ces variables uniformes peuvent représenter des paramètres tels que les positions de lumière, les propriétés des matériaux, ou toute autre donnée nécessaire aux calculs de shading.

Nous reviendrons sur les attributs et uniforms plus tard dans cette leçon.

En utilisant les données d'entrée, le fragment shader effectue divers calculs pour déterminer la couleur finale du fragment. Cela peut impliquer des calculs d'éclairage complexes, du texture mapping, des effets de shading, ou tout autre effet visuel souhaité dans la scène.

Une fois le calcul de la couleur terminé, le fragment shader produit la couleur finale du fragment en utilisant la variable prédéfinie gl_FragColor.

J'ai essayé autant que possible d'expliquer les shaders de manière simple et j'ai volontairement omis certains détails techniques, mais je comprends que cela peut encore être un peu abstrait. Créons un shader simple pour voir comment cela fonctionne en pratique.

Votre Premier Shader

Lançons le starter pack. Vous devriez voir ce frame avec un plan noir au centre de l'écran :

Un frame avec un plan noir

Ouvrez le fichier ShaderPlane.jsx, il contient un mesh simple avec une géométrie de plan et un material basique. Nous allons remplacer ce material par un shader material personnalisé.

shaderMaterial

Pour créer un shader material, nous utilisons la fonction shaderMaterial de la Drei library.

Elle prend 3 paramètres :

  • uniforms: Un objet contenant les variables uniformes utilisĂ©es dans le shader. Laissez-le vide pour le moment.
  • vertexShader: Une chaĂ®ne de caractères contenant le code GLSL pour le vertex shader.
  • fragmentShader: Une chaĂ®ne de caractères contenant le code GLSL pour le fragment shader.

En haut de notre fichier, déclarons un nouveau shader material nommé MyShaderMaterial :

import { shaderMaterial } from "@react-three/drei";

const MyShaderMaterial = shaderMaterial(
  {},
  `
  void main() {
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }`,
  `
  void main() {
    gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
  }
  `
);

Nous allons plonger dans le code du shader dans un instant.

Pour pouvoir l'utiliser de manière déclarative avec React Three Fiber, nous utilisons la méthode extend :

import { extend } from "@react-three/fiber";
// ...

extend({ MyShaderMaterial });

Nous pouvons maintenant remplacer le <meshBasicMaterial> par notre nouveau shader material :

import { shaderMaterial } from "@react-three/drei";
import { extend } from "@react-three/fiber";

const MyShaderMaterial = shaderMaterial(
  {},
  `
  void main() {
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }`,
  `
  void main() {
    gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
  }
  `
);

extend({ MyShaderMaterial });

export const ShaderPlane = ({ ...props }) => {
  return (
    <mesh {...props}>
      <planeGeometry args={[1, 1]} />
      <myShaderMaterial />
    </mesh>
  );
};

Vous devriez voir le même plan noir qu'avant. Nous n'avons encore rien changé, mais nous utilisons maintenant un shader material personnalisé.

Pour vérifier que cela fonctionne, changeons la couleur que nous retournons dans le fragment shader. Remplacez la ligne gl_FragColor par la suivante :

gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);

gl_FragColor est une variable prédéfinie qui représente la couleur du fragment. C'est un vec4 (un vecteur à 4 composants) qui représente les canaux rouge, vert, bleu, et alpha de la couleur. Chaque composant est un float entre 0 et 1.

En mettant le premier composant à 1.0, nous réglons le canal rouge à sa valeur maximale, ce qui donnera une couleur rouge.

Vous devriez voir un plan rouge au centre de l'Ă©cran :

Un frame avec un plan rouge

Félicitations ! Vous venez de créer votre premier shader material. C'est un shader simple, mais c'est un début.

Code de Shader

Avant de continuer, configurons notre environnement de développement pour écrire des shaders plus confortablement.

Vous avez deux options pour Ă©crire du code de shader :

  • Inline : Vous pouvez Ă©crire le code du shader directement dans le fichier JavaScript.
  • Externe : Vous pouvez Ă©crire le code du shader dans un fichier sĂ©parĂ© avec l'extension .glsl et l'importer dans votre fichier JavaScript.

Je préfère généralement l'approche inline à l'intérieur des fichiers de material appropriés, de cette manière, le code du shader est proche de la déclaration du material.

Mais pour rendre l'écriture et la lecture plus faciles, je recommande d'utiliser un surligneur de syntaxe pour GLSL. Vous pouvez utiliser l'extension Comment tagget templates pour Visual Studio Code. Elle surlignera le code GLSL à l'intérieur des chaînes de caractères du template.

GLSL syntax highlighter

Une fois installée, pour activer le surligneur de syntaxe, vous devez ajouter le commentaire suivant au début du code du shader :

const MyShaderMaterial = shaderMaterial(
  {},
  /* glsl */ `
  void main() {
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }`,
  /* glsl */ `
  void main() {
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
  }
  `
);

Vous devriez voir le code GLSL surligné dans les chaînes de caractères du template :

GLSL syntax highlighter in action

Le vertex shader en haut a maintenant le surlignage syntaxique correct et est plus facile Ă  lire.

C'est tout ce dont vous avez besoin pour du code de shader inline. Vous pouvez toujours décider d'utiliser un fichier externe si vous préférez garder votre code de shader séparé. Voyons comment faire.

Importation de fichiers GLSL

Tout d'abord, créez un nouveau dossier nommé shaders dans le dossier src. À l'intérieur de ce dossier, créez deux fichiers: myshader.vertex.glsl et myshader.fragment.glsl et copiez le code shader respectif dans chaque fichier.

myshader.vertex.glsl:

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

myshader.fragment.glsl:

void main() {
  gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

N'hésitez pas à utiliser la convention de nommage que vous préférez et à regrouper vos shaders dans des sous-dossiers si vous en avez beaucoup.

Ensuite, pour pouvoir importer ces fichiers dans notre fichier JavaScript, nous devons installer le plugin vite-plugin-glsl en tant que dépendance de développement :

yarn add vite-plugin-glsl --dev

Ensuite, dans votre fichier vite.config.js, importez le plugin et ajoutez-le au tableau plugins :

import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import glsl from "vite-plugin-glsl";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), glsl()],
});

Vous pouvez maintenant importer les fichiers GLSL dans votre fichier JavaScript et les utiliser comme code shader:

import myShaderFragment from "./shaders/myshader.fragment.glsl";
import myShaderVertex from "./shaders/myshader.vertex.glsl";

const MyShaderMaterial = shaderMaterial({}, myShaderVertex, myShaderFragment);

Maintenant que nous avons une manière confortable d'écrire et d'importer du code shader, nous pouvons commencer à explorer les différentes parties du code shader.

GLSL

Le code du shader est Ă©crit en GLSL (OpenGL Shading Language). C'est un langage ressemblant au C, voyons les bases.

Types

GLSL possède plusieurs types, mais les plus courants sont :

  • bool : Une valeur boolĂ©enne (true ou false).
  • int : Un nombre entier.
  • float : Un nombre Ă  virgule flottante.
  • vectors : Une collection de nombres. vec2 est une collection de 2 nombres Ă  virgule flottante (x et y), vec3 est une collection de 3 nombres Ă  virgule flottante (x, y et z), et vec4 est une collection de 4 nombres Ă  virgule flottante (x, y, z et w). Au lieu d'utiliser x, y, z et w, vous pouvez aussi utiliser r, g, b et a pour les couleurs, ils sont interchangeables.
  • matrices : Une collection de vecteurs. Par exemple, mat2 est une collection de 2 vecteurs, mat3 est une collection de 3 vecteurs, et mat4 est une collection de 4 vecteurs.

Swizzling et manipulations

Vous pouvez accéder aux composants d'un vecteur en utilisant le swizzling. Par exemple, vous pouvez créer un nouveau vecteur en utilisant les composants d'un autre vecteur :

vec3 a = vec3(1.0, 2.0, 3.0);
vec2 b = a.xy;

Dans cet exemple, b sera un vecteur avec les composants x et y de a.

Vous pouvez Ă©galement utiliser le swizzling pour changer l'ordre des composants :

vec3 a = vec3(1.0, 2.0, 3.0);
vec3 b = a.zyx;

Dans cet exemple, b sera Ă©gal Ă  vec3(3.0, 2.0, 1.0).

Pour créer un nouveau vecteur avec tous les mêmes composants, vous pouvez utiliser le constructeur :

vec3 a = vec3(1.0);

Dans cet exemple, a sera Ă©gal Ă  vec3(1.0, 1.0, 1.0).

Opérateurs

GLSL possède les opérateurs arithmétiques classiques : +, -, *, /, +=, /=, *= et les opérateurs de comparaison classiques : ==, !=, >, <, >=, <=.

Ils doivent être utilisés avec les types corrects. Par exemple, vous ne pouvez pas ajouter un entier à un float, vous devez d'abord convertir l'entier en float :

int a = 1;
float b = 2.0;
float c = float(a) + b;

Vous pouvez également effectuer des opérations sur les vecteurs et les matrices :

vec3 a = vec3(1.0, 2.0, 3.0);
vec3 b = vec3(4.0, 5.0, 6.0);
vec3 c = a + b;

Ce qui Ă©quivaut Ă  :

vec3 c = vec3(a.x + b.x, a.y + b.y, a.z + b.z);

Fonctions

Le point d'entrée des vertex et fragment shaders est la fonction main. C'est la fonction qui sera exécutée lorsque le shader sera appelé.

void main() {
  // Votre code ici
}

void est le type de retour de la fonction. Cela signifie que la fonction ne retourne rien.

Vous pouvez également définir vos propres fonctions :

float add(float a, float b) {
  return a + b;
}

Vous pouvez ensuite appeler cette fonction dans la fonction main :

void main() {
  float result = add(1.0, 2.0);
  // ...
}

GLSL fournit de nombreuses fonctions intégrées pour des opérations courantes comme sin, cos, max, min, abs, round, floor, ceil, et bien d'autres utiles comme mix, step, length, distance, et plus encore.

Nous découvrirons les fonctions essentielles et nous les pratiquerons dans la prochaine leçon.

Boucles et Conditions

GLSL supporte les boucles for et les instructions if. Elles fonctionnent de manière similaire à JavaScript :

for (int i = 0; i < 10; i++) {
  // Votre code ici
}

if (condition) {
  // Votre code ici
} else {
  // Votre code ici
}

Journalisation / DĂ©bogage

Comme les programmes de shader s'exécutent en parallèle pour chaque vertex et fragment, il n'est pas possible d'utiliser console.log pour déboguer votre code ni d'ajouter des points d'arrêt. C'est ce qui rend difficile le débogage des shaders.

Une manière courante de déboguer les shaders est d'utiliser le gl_FragColor pour visualiser les valeurs de vos variables.

Erreurs de compilation

Si vous faites une erreur dans votre code de shader, vous verrez une erreur de compilation dans la console. Elle vous indiquera la ligne et le type d'erreur. Ce n'est pas toujours facile à comprendre, mais c'est un bon moyen de savoir où chercher le problème.

Supprimons le canal alpha du gl_FragColor et voyons ce qui se passe :

void main() {
  gl_FragColor = vec4(1.0, 0.0, 0.0);
}

Vous devriez voir une erreur de compilation dans la console :

Compilation error

Nous indiquant que le gl_FragColor attend 4 composants, mais nous n'en avons fourni que 3.

N'oubliez pas de restaurer le canal alpha Ă  1.0 pour supprimer l'erreur.

Uniforms

Pour transmettre des données du code JavaScript au shader, nous utilisons les uniforms. Ils sont constants pour tous les vertices et fragments.

Le projectionMatrix, modelViewMatrix et la position sont des exemples d'uniforms intégrés qui sont automatiquement transmis au shader.

Créons un uniform personnalisé pour transmettre une couleur au shader. Nous l'utiliserons pour colorier le plane. Nous allons l'appeler uColor. C'est une bonne pratique de préfixer le nom de l'uniform avec un u pour indiquer clairement qu'il s'agit d'un uniform dans notre code.

D'abord, déclarons-le dans l'objet uniforms de shaderMaterial :

import { Color } from "three";
// ...
const MyShaderMaterial = shaderMaterial(
  {
    uColor: new Color("pink"),
  }
  // ...
);

// ...

Ensuite, nous pouvons l'utiliser dans le fragment shader :

uniform vec3 uColor;

void main() {
  gl_FragColor = vec4(uColor, 1.0);
}

Vous devriez voir le plane colorié en rose :

A frame with a pink plane

Ici, la couleur rose est la valeur par défaut de l'uniform. Nous pouvons la changer directement sur le material :

<MyShaderMaterial uColor={"lightblue"} />

A frame with a light blue plane

Le plane est maintenant colorié en bleu clair.

Les shaders de vertex et de fragment peuvent accéder aux uniforms. Ajoutons le temps comme deuxième uniform au vertex shader pour déplacer le plane de haut en bas :

End of lesson preview

To get access to the entire lesson, you need to purchase the course.