Aller au contenu

Programmation Graphique : CM séance 04

7. Langage GLSL

Le langage GLSL est le principal langage de shaders d'OpenGL (il existe par exemple des langages spécifiques à NVidia et ATI).

Le langage s'inspire de C et de C++, avec du calcul vectoriel et matriciel intégré.

Les numéros de version suivent celles d'OpenGL depuis la version 3.3.

Pour une description complète de GLSL voir le wiki Khronos.

7.1. Les types de données

Le type de retour d'une fonction qui ne renvoie pas de valeur est void. Il n'y a ni pointeur ni référence.

De nombreux types sont disponibles pour faciliter les calculs des opérations graphiques.

7.1.1. Scalaires et littéraux

Les types scalaires :

  • bool un booléen valant true ou false ;
  • int un entier 32 bits signé ;
  • uint un entier 32 bits non signé ;
  • float un flottant simple précision (32 bits) ;
  • double un flottant double précision (64 bits, version 4.0+).

Les littéraux suivent les règles suivantes :

  • true et false sont des bool ;
  • toute valeur entière est un int par exemple 123 ;
  • pour déclarer un uint il faut le suffixer par u ou U, par exemple 123u ;
  • les valeurs entières peuvent aussi être exprimées en base 8 ou 16, en les préfixant par 0 ou 0x ;
  • une valeur avec un point est par défaut un float, par exemple 0., .0 ou 0.0 ; pour obtenir un float à partir d'un littéral entier il faut le suffixer par f ou F, par exemple 0f ;
  • un float peut aussi être exprimé en notation scientifique avec e ou E, par exemple 1e+3 qui vaut 100f ;
  • pour obtenir un double on suffixe par lf ou LF, par exemple 3.14lf (version 4.0+).

Certaines conversions implicites sont effectuées (dans les premières versions, il pouvait y avoir des erreurs selon le constructeur et le driver).

7.1.2. Les vecteurs

Les vecteurs sont des tableaux de taille 2 à 4, exprimés sous la forme [diub]vec[234] :

  • vec2, vec3, vec4 : un vecteur de float ;
  • dvec2, dvec3, dvec4 : un vecteur de double ;
  • ivec2, ivec3, ivec4 : un vecteur de int ;
  • uvec2, uvec3, uvec4 : un vecteur de uint ;
  • bvec2, bvec3, bvec4 : un vecteur de bool.

Les vecteurs peuvent être initialisés avec un constructeur, par exemple :

vec2 texcoord (0.0, 0.5);
vec3 couleur (1.0, 0.0, 0.0);
vec4 position (0.5, 0.0, -1.0, 1.0);

Pour accéder à un élément d'un vecteur on peut utiliser des indices, ou des champs dont les noms sont prédéfinis :

  • [0], [1], [2], [3] par indice ;
  • .x, .y, .z, .w pour les positions ;
  • .r, .g, .b, .a pour les couleurs ;
  • .s, .t, .p, .q pour les coordonnées de texture.

Par exemple :

texcoord.s = texcoord.t;
couleur.r = couleur.g = couleur.b = 1.0;
position.x = (position.y + position.z) / position.w;

On peut en fait utiliser indifféremment l'une des 4 notations (sans tenir compte de la quantité représentée). Cette notation s'appelle le swizzling (mélange) ; elle permet d'agir sur plusieurs dimensions en même temps avec un swizzle mask :

texcoord.st = texcoord.ts;
couleur = couleur.bgr;
position = vec4(position.yzx, 1.0);

Dans un swizzle mask,

  • la dimension est donnée par le nombre de lettres ;
  • on ne peut pas mélanger des notations, par exemple on ne peut pas écrire .xr.

Le swizzling fonctionne aussi sur les Lvalues :

texcoord.st = vec2(0.5, 0.7);
couleur.bg = vec2(0.0, 1.0);
position.zyx = vec3(0.0, 1.0, 0.5);

Un même champ ne peut pas apparaître plusieurs fois dans une Lvalue :

position.xxz = vec3 (1.0, 1.0, 3.0);  // erreur

7.1.3. Les matrices

Le langage dispose également de matrices, de dimension 2, 3 ou 4.

Le type s'écrit sous la forme : [d]mat[234] pour les matrices carrées :

  • les matrices mat sont des matrices de float ;
  • il y a aussi des matrices dmat de double.

Par exemple : mat4, dmat3.

Il y a aussi des matrices rectangulaires, dont le type s'écrit : [d]mat[234]x[234] (colonnes x lignes).

Par exemple : mat2x3 est une matrice de float de 2 colonnes et 3 lignes.

On accède à un élément par les indices [colonne][ligne]. Par exemple

mat3 M;
M[2][0] = 1.0;  // 3e colonne, 1ère ligne

Cet ordre, appelé column-major order, est l'inverse de l'ordre mathématique habituel lignes*colonnes, et permet de manipuler une matrice comme une liste de vecteurs-colonne :

mat3 M;
M[1] = vec3(1.0, 2.0, 1.5);
M[0].yxz = vec3(0.0, -1.0, 0.5);

7.1.4. Types opaque

Un type opaque représente un objet externe, dont la manipulation est limitée. Une variable de type opaque peut être déclarée comme :

  • une globale uniform (donnée partagée par tous les shaders) ;
  • un membre d'un struct ;
  • un paramètre d'entrée d'une fonction.

Une variable de type opaque ne peut pas être une Lvalue.

On peut définir un array de type opaque ; on accède alors aux éléments par l'indice [i] et à la longueur avec .length().

Les types sampler* pour désigner des textures, sont des exemples de types opaque (vus plus loin).

7.1.5. Tableaux

Des éléments de même type peuvent être regroupés dans un tableau (array).

Sauf exceptions, la taille doit être connue à la compilation. Elle peut être retrouvée avec la méthode length :

float values[8];
int n = values.length();

Les tableaux de tableaux sont possible à partir de GLSL 4.3+.

Les indices pour accéder à un élément de tableau peuvent être non constants (par exemple une variable entière dans une boucle), sauf si le tableau est opaque (i.e. il contient un type opaque, ou un struct qui contient un type opaque) :

  • en GLSL 3.3, on ne peut pas boucler sur un tableau opaque ;
  • en GLSL 4.0+, on peut boucler sur un tableau opaque dans le cas où l'indice est dynamiquement uniforme.

7.1.6. Structures

Les structures sont déclarés dans la syntaxe du C++, sans typedef :

struct MyData {
    float x, y;
    vec4 couleur;
};

Utilisation :

MyData d;
d.x = 1.0;
d.couleur = vec4(1.0, 0.0, 0.0, 1.0);

Un struct qui contient un type opaque est considéré comme opaque.

Les struct peuvent être utilisés partout (variables locales ou globales, paramètres de fonction), sauf comme variables d'entrée-sortie des shaders.

7.2. Éléments du langage

7.2.1. Préprocesseur

GLSL supporte la plupart des directives de précompilation du C, à l'exception notable de #include :

  • #define NOM définition, #undef NOM ;
  • #if, #ifdef NOM, #ifndef NOM ;
  • #else, #elif, #endif ;
  • #error : provoque une erreur ;
  • #pragma : indication au compilateur ;
  • #line : modifie le compteur de ligne ;
  • __LINE__ : ligne courante ;
  • __FILE__ : fichier courant, sous la forme d'un numéro ;
  • __VERSION__ : version actuelle de GLSL ;
  • #version numéro : version de GLSL demandée (obligatoire pour core profile) ;
  • #extension NOM : require | enable | warn |disable pour tester la présence ou activer une extension.

7.2.2. Structures de contrôle

GLSL utilise les structures de contrôle habituelles du C :

  • les branchements if-else et switch-case ;
  • les boucles for, while et do-while ;
  • les sauts break, continue, return mais pas goto.

On peut déclarer une variable dans un for mais pas dans un if.

Le nouveau mot clé discard est introduit, uniquement pour les fragment shader : il permet de sortir du shader en supprimant le fragment pour la suite du pipeline.

7.2.3. Fonctions

Les fonctions de GLSL ont les propriétés suivantes :

  • la première fonction appelée est main, elle ne prend pas d'arguments et ne renvoie rien ;
  • les fonctions peuvent renvoyer une valeur avec return, ou rien avec le type de retour void ;
  • les récursions sont interdites.

Le paramètres et la valeur de retour sont uniquement passés par recopie, ils ne peuvent pas être transmis par référence ou adresse. À la place il y a les qualificatifs suivants :

  • in pour les variable en entrée ;
  • const in pour les variables en entrée, ne pouvant être changée par la fonction ;
  • out pour les variables en sortie ;
  • inout pour les variables en entrée et en sortie.

Les variables out et inout doivent être des Lvalues.

Exemple :

void change_color (in float f, inout float r, inout float g, inout float b)
{
    r *= f; g *= f; b *= f;
}

float r = 1.0, g = 0.5, b = 0.0;
change_color (1.2, r, g, b);

7.2.4. Initialisation et constructeurs

Lors de la déclaration, les variables de type de base peuvent être initialisées par affectation :

float x = 0.5;
int i = 4;

Le cast explicite est possible avec la syntaxe type(valeur), par exemple int(3.2).

Les types plus élaboré sont initialisés avec un constructeur, de la forme

nom_du_type(valeur, ...);

par exemple

vec3 v = vec3(1.0, 2.0, 3.0);

Vecteurs

Les constructeurs de vecteur peuvent prendre une seule valeur, par exemple vec3(t) est équivalent à vec3(t,t,t).

Ils peuvent aussi prendre d'autres vecteurs ; les valeurs sont prises de gauche à droite. La dimension n'a pas besoin de correspondre, il suffit d'avoir suffisamment de valeurs :

vec3(vec2(1.0, 2.0), 3.0);       // ok, vec3(1.0, 2.0, 3.0)
vec4(vec3(1.0, 2.0, 3.0));       // erreur, terme manquant
vec3(vec4(1.0, 2.0, 3.0, 4.0));  // ok, vec3(1.0, 2.0, 3.0)

Matrices

  • Si on fournit une seule valeur, elle est copiée sur la diagonale, les autres cases sont mises à 0 ; par exemple mat4(1.0) est la matrice identité.

  • Si on fournit plusieurs valeurs, elles remplissent la matrice dans l'ordre colonne-ligne ; les cases non fournies sont remplies avec la matrice identité.

  • Les valeurs peuvent aussi êtres passées sous forme de vecteur.

Par exemple :

mat2( 2.0, 3.0,    // première colonne
      4.0, 5.0 );  // deuxième colonne

fabrique la matrice \(\left(\begin{array}{cc} 2.0 & 4.0 \\ 3.0 & 5.0 \end{array}\right)\) ,  et

mat3( vec3( 2.0, 3.0,  4.0),    // première colonne
      vec2( 5.0, 6.0), 7.0,     // deuxième colonne
            8.0, 9.0, 10.0 );   // troisième colonne

construit la matrice \(\left(\begin{array}{ccc} 2.0 & 5.0 & 8.0 \\ 3.0 & 6.0 & 9.0 \\ 4.0 & 7.0 & 10.0 \end{array}\right)\) .

On peut aussi fabriquer une matrice à partir d'une autre matrice, de dimension inférieur ou supérieur, selon le principe que seule la partie qui correspond est copiée, et le reste est rempli par la matrice identité. Par exemple

mat3( mat4x2( 1.0, 2.0,     // colonne 1
              3.0, 4.0,     // colonne 2
              5.0, 6.0,     // colonne 3
              7.0, 8.0 ));  // colonne 4
donne la matrice \(\left(\begin{array}{ccc} 1.0 & 3.0 & 5.0 \\ 2.0 & 4.0 & 6.0 \\ 0.0 & 0.0 & 1.0 \end{array}\right)\) .

Tableaux et structures

Les tableaux peuvent être initialisées avec la syntaxe du constructeur, en rajoutant les [] :

float a[3] = float[3]( 1.0, 2.0, 3.0 );
float a[3] = float[]( 1.0, 2.0, 3.0 );     // variante

Pour les structures, on donne les paramètres dans l'ordre de leur déclaration :

struct Foo {
    float bar;
    vec3 baz;
};
Foo foo = Foo( 1.0, vec3(2.0) );

Pour initialiser un tableau de structures on peut écrire, de façon assez verbeuse :

Foo foo[3] = Foo[3]( Foo( 1.0, vec3(2.0) ),
                     Foo( 3.0, vec3(4.0) ),
                     Foo( 5.0, vec3(6.0) ) );

À partir de GLSL 4.2+ on peut aussi employer la syntaxe des listes d'initialisation du C++11 :

Foo foo[3] = { { 1.0, {2.0} },
               { 3.0, {4.0} },
               { 5.0, {6.0} } };

Les listes d'initialisation sont valables sur tous les types :

Foo foo = { 1.0, {2.0, 3.0} };
vec2 v = {1.0, 2.0};
float pi = {3.14};

7.2.5. Qualificatifs de type

Toute variable a une portée, comme en C/C++ ; une variable déclarée en dehors d'une fonction est globale au shader.

Les variables peuvent recevoir un qualificatif de type, pour indiquer la manière dont la variable doit être gérée ou stockée. Par exemple, const indique que la variable ne peut pas changer de valeur après son initialisation.

Les variables globales peuvent recevoir des qualificatifs pour spécifier les entrées-sorties du shader : in, out, uniform, etc.

  • Les globales in reçoivent une donnée du client (vertex shader) ou de l'étape précédente dans le pipeline, possiblement interpolée (fragment shader). Ces variables, bien que n'étant pas const, ne peuvent pas être modifiées par le shader.

  • Les globales out fournissent une donnée pour la suite du pipeline. Toutes les variables out doivent posséder une valeur à la fin du shader, sauf cas particuliers (par exemple un fragment shader faisant un discard).

  • Les globales in ou out peuvent en général être de n'importe quel type, sauf des struct et des types opaques. Il y a des limitations supplémentaires selon les shaders.

  • Dans les premières versions de GLSL, les mots clés employés étaient attribute et varying.

Les globales uniform sont passées par le programme client à tous les shaders, et sont stockées dans le shader program :

  • Elles ne peuvent pas être données aux shader par le client OpenGL avant que le program shader ne soit lié.

  • Elles peuvent être de n'importe quel type, y compris un struct ou un type opaque.

  • Elles ne peuvent pas être modifiées par un shader ; toutefois elles peuvent avoir une valeur par défaut dans le shader, qui sera écrasée par la valeur donnée par le client.

Il existe de nombreux autres qualificateurs, par exemple des qualificateurs pour préciser l'interpolation (flat, noperspective, smooth, centroid, sample).

Il existe également des interface block qui sont des regroupements de variables d'entrées, de sorties, de variables uniform et de buffers de stockage.

7.2.6. Qualificatifs de disposition

Les layout qualifiers permettent de préciser la disposition (le stockage) en mémoire des variables, ainsi que d'autres propriétés. La syntaxe est

layout(qualifier1, qualifier2 = value, ...) variable_definition

Le layout permet en particulier de préciser dans les vertex shaders pour les variables in (également appelées attributes) leur location (encore appelée attribute index).

Par exemple si dans le vertex shader on déclare :

layout(location = 1) in vec3 vPos;

alors dans le programme OpenGL, au lieu d'écrire

Guint vPos_loc = glGetAttribLocation (m_program, "vPos");
glVertexAttribPointer (vPos_loc, 3, GL_FLOAT, ...);
glEnableVertexAttribArray (vPos_loc); 

on pourra directement écrire

glVertexAttribPointer (1, 3, GL_FLOAT, ...);
glEnableVertexAttribArray (1); 

Remarque : il est aussi possible de fixer la location d'un vertex attribute depuis le programme client avec glBindAttribLocation ; mais en cas de contradiction avec la déclaration du layout du shader, c'est toujours le layout qui gagne.

Certains types de données peuvent "consommer" plusieurs locations, par exemple si on déclare

layout(location = 2) in vec3 values[4];

alors les locations utilisées seront 2, 3, 4, 5, donc la prochaine location disponible sera 6. Le nombre de locations est limité et architecture-dépendant ; la limite ne peut être consultée, mais elle est \(\geqslant\) GL_MAX_VARYING_COMPONENTS/4 \(\geqslant\) 60/4, voir glGet.

Certains types n'occupent pas toute la place dans une location, et il est possible de regrouper des variables en utilisant le mot-clé component dans le layout.

On peut préciser avec le layout la location et plein d'autres propriétés pour toutes sortes d'objets et de shaders, les variables out, les uniform, etc, voir la documentation.

7.2.7. Variables prédéfinies

De nombreuses variables sont prédéfinies et sont spécifiques à chaque type de shader, voir builtin variables. De nombreuses builtin des premières versions de GLSL ont été dépréciées, et remplacées par de nouvelles (qui n'ont rien à voir).

Les vertex shaders possèdent en entrée les variables :

  • in int gl_VertexID; l'indice du sommet courant ;
  • in int gl_InstanceID; le numéro d'instance dans le cas de dessins multiples.
  • etc.

Et en sortie :

  • out vec4 gl_Position; la position du sommet ;
  • out float gl_PointSize; la taille du point pour le dessin de GL_POINTS.
  • etc.

Les fragment shaders ont les variables suivantes en entrée :

  • in vec4 gl_FragCoord; les coordonnées du fragment par rapport à la fenêtre. Les coordonnées z et w ont une signification particulière. L'origine des coordonnées peut être modifiée par un layout.
  • in bool gl_FrontFacing; est false si le fragment est situé sur la face arrière d'une primitive ;
  • etc.

Et en sortie :

  • out float gl_FragDepth; la profondeur du fragment pour le Z-buffer ; écrase la valeur de gl_FragCoord.z.
  • etc.

7.2.8. Fonctions standard

De nombreuses fonctions sont fournies :

  • trigonométrie : sin, cos, atan...
  • exponentielle : exp, log, log2...
  • math: sqrt, abs, sign, floor...
  • min, max, clamp, mix...
  • géométrie : length, distance, dot, cross...
  • matrices : *, transpose...

Voir les pages de référence (à gauche cliquer sur accordion style puis GLSL).

Pour aller plus loin :

7.3. Exemples

7.3.1. Fichiers de shaders

Pour tester le langage GLSL on se donne l'exemple fw41-file.cpp, qui peut charger des fichiers de shaders via la ligne de commande, et recharger les fichiers de shaders avec la touche U (pour update).

L'usage est :

$ ./fw41-file [-vs vertex.glsl] [-fs fragment.glsl]

Comme point de départ on peut prendre les fichiers vs01.glsl et fs01.glsl.

Voici quelques explications sur l'évolution du code par rapport au dernier exemple du CM 03.

On passe argc et argv au constructeur de MyApp :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class MyApp
{
    ...
    MyApp (int argc, char* argv[])
    {
        if (!parse_args (argc, argv)) return;

        ...
    }

}; // MyApp


int main(int argc, char* argv[]) 
{
    MyApp app {argc, argv};
    app.run();
}

On analyse la ligne de commande dans la méthode parse_args :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
bool parse_args (int argc, char* argv[])
{
    int i = 1;
    while (i < argc) {
        if (strcmp(argv[i], "-vs") == 0 && i+1 < argc) {
            m_vertex_shader_path = argv[i+1]; 
            i += 2; continue;
        }
        if (strcmp(argv[i], "-fs") == 0 && i+1 < argc) {
            m_fragment_shader_path = argv[i+1]; 
            i += 2; continue;
        }
        if (strcmp(argv[i], "--help") == 0) {
            std::cout << "Options: -vs vs_file -fs fs_file\n";
            return false;
        }
        std::cerr << "Error, bad arguments. Try --help" << std::endl;
        return false;
    }
    return true;
}

Elle modifie les données membres, par défaut vides :

std::string m_vertex_shader_path, m_fragment_shader_path;

Dans initGL, tout le code relatif aux shaders est remplacé par :

1
2
3
4
5
6
7
void initGL()
{
    ...
    m_program = load_and_compile_program (m_vertex_shader_path, 
        m_fragment_shader_path);
    ...
}

La méthode load_and_compile_program appelle load_shader_code, puis compile les shaders, lie le shader program et renvoie son identifiant :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
GLuint load_and_compile_program (const std::string vertex_shader_path, 
                                 const std::string fragment_shader_path)
{
    std::string vertex_shader_code = load_shader_code (vertex_shader_path,
        m_default_vertex_shader_text);
    std::string fragment_shader_code = load_shader_code (fragment_shader_path,
        m_default_fragment_shader_text);

    const char* vertex_shader_text = vertex_shader_code.c_str();
    const char* fragment_shader_text = fragment_shader_code.c_str();

    const GLuint vertex_shader = glCreateShader (GL_VERTEX_SHADER);
    glShaderSource (vertex_shader, 1, &vertex_shader_text, NULL);
    compile_shader (vertex_shader, "vertex");

    const GLuint fragment_shader = glCreateShader (GL_FRAGMENT_SHADER);
    glShaderSource (fragment_shader, 1, &fragment_shader_text, NULL);
    compile_shader (fragment_shader, "fragment");
 
    GLuint program = glCreateProgram();
    glAttachShader (program, vertex_shader);
    glAttachShader (program, fragment_shader);
    link_program (program);

    // Marque les shaders pour suppression lorsque glDeleteProgram sera appelé
    glDeleteShader (vertex_shader);
    glDeleteShader (fragment_shader);

    return program;
}

La méthode load_shader_code reçoit le chemin du fichier, et en second paramètre le texte du shader par défaut, puis renvoie le code chargé si le chemin est non vide et la lecture a réussi, sinon le code par défaut :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
std::string load_shader_code (const std::string path, const char* default_text)
{
    if (path == "") return std::string(default_text);

    std::string shader_code;
    std::ifstream shader_file;
    std::stringstream shader_stream;

    shader_file.exceptions (std::ifstream::failbit | std::ifstream::badbit);
    try {
        std::cout << "Loading " << path << " ..." << std::endl;
        shader_file.open (path);
        shader_stream << shader_file.rdbuf();
        shader_file.close();
        shader_code = shader_stream.str();
    }
    catch (...) {
        std::cerr << "### Load error: " << strerror(errno) << std::endl;
        return std::string(default_text);
    }

    return shader_code;
}

Les shaders par défaut sont déclarés dans la classe MyApp :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
const char* m_default_vertex_shader_text =
    "#version 330\n"
    "in vec4 vPos;\n"
    "in vec3 vCol;\n"
    "out vec3 color;\n"
    "uniform mat4 matMVP;\n"
    "\n"
    "void main()\n"
    "{\n"
    "    gl_Position = matMVP * vPos;\n"
    "    color = vCol;\n"
    "}\n";

const char* m_default_fragment_shader_text =
    "#version 330\n"
    "in vec3 color;\n"
    "out vec4 fragColor;\n"
    "\n"
    "void main()\n"
    "{\n"
    "    fragColor = vec4(color, 1.0);\n"
    "}\n";

Enfin, l'appel de la touche U déclenche la méthode load_and_compile_program :

1
2
3
4
5
6
void reload_program ()
{
    glDeleteProgram (m_program);
    m_program = load_and_compile_program (m_vertex_shader_path, 
        m_fragment_shader_path);
}

Exemples de manipulations

En modifiant les shaders vs01.glsl ou fs01.glsl puis en appuyant sur le touche U pour les recharger :

  • remplacer la couleur par une constante ;
  • multiplier la couleur par un scalaire dans l'un des deux shaders ;
  • changer les couleurs avec le swizzling ;
  • enlever la multiplication avec la matrice MVP ;
  • utiliser gl_FrontFacing pour la couleur avant ou arrière (exemple fs02.glsl) ;
  • supprimer le fragment avec discard pour la face arrière ;
  • etc.

7.3.2. Position de la souris

L'exemple fw42-mouse.cpp récupère la position de la souris et la taille de la fenêtre, et mémorise le tout dans une variable uniform vec4 mousePos sous la forme (mouse_x, mouse_y, width, height).

Le but est de pouvoir faire des effets de couleurs selon la position de la souris, et en utilisant le fait que les coordonnées d'un fragment sont accessibles dans gl_FragCoord.

On rajoute les données membres suivantes dans MyApp :

vmath::vec4 m_mousePos;  // mouse_x, mouse_y, width, height
GLint m_mousePos_loc;

puis on récupère la location de l'uniform dans le shader program :

m_mousePos_loc = glGetUniformLocation (m_program, "mousePos");

Pour chaque rendu, on mémorise dans la méthode displayGL la variable uniform si la location est trouvée :

if (m_mousePos_loc != -1)
    glUniform4fv (m_mousePos_loc, 1, m_mousePos);

On rajoute également ce test à la fin de reload_program car d'une part, la situation a pu changer, et d'autre part, les variables uniforms sont mémorisées dans le shader program, or il vient d'être reconstruit.

Quant à m_mousePos, ce vecteur vmath::vec4 est peuplé par une nouvelle callback :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
static void on_mouse_func (GLFWwindow* window, double xpos, double ypos)
{
    //std::cout << __func__ << " " << xpos << " " << ypos << std::endl;

    MyApp* that = static_cast<MyApp*>(glfwGetWindowUserPointer (window));
    if (!that->m_ok) return;

    int width, height;
    glfwGetWindowSize (window, &width, &height);
    that->m_mousePos[0] = xpos;
    that->m_mousePos[1] = height - ypos;  // origine en bas à gauche
    that->m_mousePos[2] = width;
    that->m_mousePos[3] = height;
}

qui est appelée à chaque déplacement de la souris, et qui est enregistrée dans le constructeur de MyApp par :

glfwSetCursorPosCallback (m_window, on_mouse_func);

Exemples de manipulations

Pour ces différents exemples on conserve le vertex shader vs01.glsl.

Avec le fragment shader fs03.glsl, la couleur générale varie selon la position de la souris dans la fenêtre :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#version 330
in vec3 color;
out vec4 fragColor;
uniform vec4 mousePos;

void main()
{   float w = mousePos[2], 
          h = mousePos[3],
          r = mousePos.x/w,
          g = mousePos.y/h;
    fragColor = vec4(r, g, 0.0, 1.0);
}

Dans l'exemple de fragment shader fs04.glsl, les couleurs varient selon la position du fragment dans la fenêtre :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#version 330
in vec3 color;
out vec4 fragColor;
uniform vec4 mousePos;

void main()
{
    float w = mousePos[2],
          h = mousePos[3],
          xf = gl_FragCoord.x / w,
          yf = gl_FragCoord.y / h;
    fragColor = vec4(xf, yf, 0.0, 1.0);
}

Enfin dans le fragment shader fs05.glsl on dessine des ondes autour de la souris, en calculant la distance euclidienne d entre le fragment et la souris, puis en multipliant les couleurs par sin(d) :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#version 330
in vec3 color;
out vec4 fragColor;
uniform vec4 mousePos;

void main()
{
    float dx = gl_FragCoord.x - mousePos.x,
          dy = gl_FragCoord.y - mousePos.y,
          d = sqrt(dx*dx + dy*dy),
          c = sin(d*0.4)*0.25 + 0.75;  // 0.5 <= c <= 1
    fragColor = vec4 (color*c, 1.0);
}

8. Textures

Une texture est une image en 1, 2 ou 3 dimensions, qui peut être stockée dans la mémoire du serveur puis "plaquée" sur des primitives graphiques.

Les textures permettent également de stocker des données arbitraires.

Dans la suite on s'intéresse principalement aux textures 2D.

8.1. Chargement des images

Il y a de très nombreux formats d'image, et OpenGL ne dispose pas de fonctions pour les charger, aussi on a besoin d'une librairie externe.

Dans ce cours nous utiliserons la librairie "tout-en-un-fichier" stb_image.h, qui permet de lire les formats d'images principaux (mais pas de les écrire).

Le module stb_image.c sera obtenu simplement en écrivant

stb_image.c
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"

Pour charger une image, il suffit alors d'écrire :

#include "stb_image.h"

int width, height, n_comp;
unsigned char *data = stbi_load ("my-image.png", &width, &height, &n_comp, 0); 

Les variables width et height seront remplies avec la taille de l'image lue, et n_comp par le nombre de canaux de l'image.

En cas d'échec, la valeur renvoyée sera NULL ; on peut alors obtenir un message d'erreur en invoquant stbi_failure_reason().

Pour libérer l'image il suffit d'appeler stbi_image_free (data).

8.2. Génération d'une texture

On commence par générer un identifiant de texture avec glGenTextures, puis on rend la texture courante avec glBindTexture en la liant au contexte OpenGL. C'est l'occasion de préciser le type de texture (target) que l'on souhaite, ici GL_TEXTURE_2D :

GLuint texture_id;
glGenTextures (1, &texture_id);
glBindTexture (GL_TEXTURE_2D, texture_id);

Toutes les opérations sur textures que l'on fera concerneront cette texture, jusqu'à ce qu'on rende active une autre texture (ou 0 pour désactiver la texture courante).

Chaque fois que l'on aura besoin de cette texture, il suffira de rappeler glBindTexture, avec la même target et l'identifiant.

Maintenant que l'image est chargée dans le tableau data, on peut générer une texture 2D en invoquant glTexImage2D :

    glTexImage2D (GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, 
        GL_UNSIGNED_BYTE, data);

C'est cette étape qui mémorise l'image dans la mémoire du serveur.

L'étape suivante consiste à invoquer glGenerateMipmap pour générer un mipmap (Multum In Parvo MAPping) ; un mipmap est une collection de copies de l'image à différentes résolution, et qui permet d'afficher une texture plus efficacement selon la taille relative dans la fenêtre.

    glGenerateMipmap (GL_TEXTURE_2D);

On peut enfin libérer data (puisque la texture est mémorisée dans la mémoire du serveur) en appelant

    stbi_image_free (data);

La texture est maintenant prête à être utilisée.

Pour résumer, voici une méthode qui fait tout le travail : elle prend en paramètre le chemin d'une image, la charge, crée une texture 2D puis renvoie son ID :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
    GLuint load_texture (const char* path)
    {
        GLuint texture_id;

        glGenTextures (1, &texture_id);
        glBindTexture (GL_TEXTURE_2D, texture_id);

        // Options de filtrage pour le mipmap
        glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
        glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

        // Charge l'image et génère la texture
        int width, height, n_comp;
        std::cout << "Loading texture \"" << path << "\" ..." << std::endl;
        unsigned char *data = stbi_load (path, &width, &height, &n_comp, 0);

        if (!data) {
            std::cout << "### Loading error: " << stbi_failure_reason() << std::endl;
            glDeleteTextures (1, &texture_id);
            return 0;
        }

        glTexImage2D (GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, 
            GL_UNSIGNED_BYTE, data);
        glGenerateMipmap (GL_TEXTURE_2D);
        stbi_image_free (data);

        return texture_id;
    }

8.3. Exemple des triangles

Dans l'exemple fw43-texture.cpp nous chargeons deux textures, puis elles sont plaquée chacune sur un triangle, dans une nouvelle version de la classe Triangles.

Les images sont chargées avec la méthode load_texture de la classe MyApp, présentée dans la section précédente. Les identifiants des textures sont mémorisés dans m_texture_id1 et m_texture_id2 :

class MyApp
{
    ...
    GLuint m_texture_id1, m_texture_id2;
    const char* m_texture_path1 = "side1.png";
    const char* m_texture_path2 = "side2.png";

    void initGL()
    {
        ...
        // Création des textures
        m_texture_id1 = load_texture (m_texture_path1);
        m_texture_id2 = load_texture (m_texture_path2);
        ...

Par la suite dans le vertex shader, on recevra en entrée les coordonnées de texture du sommet courant dans la variable vTex. On mémorise dès à présent sa location m_vTex_loc dans initGL, de manière à la transmettre à l'instanciation de la classe Triangles :

class MyApp
{
    ...
    GLint m_vPos_loc, m_vTex_loc;

    void initGL()
    {
        ...
        m_program = load_and_compile_program (...)
        
        // Récupère l'identifiant des "variables" dans les shaders
        m_vPos_loc = glGetAttribLocation (m_program, "vPos");
        m_vTex_loc = glGetAttribLocation (m_program, "vTex");
    
        // Création des objets graphiques
        m_triangles = new Triangles {m_vPos_loc, m_vTex_loc};
        ...
    

L'instance m_triangles de la classe Triangles est libérée dans tearGL ; on y détruit également les textures :

    void tearGL()
    {
        // Destruction des objets graphiques
        delete m_triangles;
        ...

        glDeleteTextures (1, &m_texture_id1);
        glDeleteTextures (1, &m_texture_id2);
        glDeleteProgram (m_program);
    }

Pour afficher les triangles dans displayGL, on a juste besoin de passer les identifiants des textures :

    void displayGL()
    {
        ...
        // Dessins
        m_triangles->draw (m_texture_id1, m_texture_id2);

8.4. Rendu avec une texture

Passons au rendu dans la classe Triangles. Les données sont mémorisées dans deux tableaux : dans positions on mémorise les coordonnées 3D des sommets dans l'espace, et dans tex_coords les coordonnées 2D des sommets dans la texture.

    Triangles (...)
    {
        // Données
        GLfloat positions[] = {
           -0.7, -0.5, -0.1,
            ...
        };

        GLfloat tex_coords[] = {
            0.0, 1.0,
            ...
        };

Le principe des coordonnées de texture est le suivant : indépendamment de la taille en pixels de l'image originale, le coin inférieur gauche de l'image a les coordonnées de texture (0.0, 0.0), et le coin supérieur droit a les coordonnées de texture (1.0, 1.0).

La texture sera "plaquée" par le vertex shader sur les primitives graphiques à partir des coordonnées de texture de chaque sommet, indépendamment de la position des sommets dans l'espace, et les points à l'intérieur des primitives graphiques seront obtenus par le fragment shader en interpolant les coordonnées pour aller chercher la couleur du pixel le plus proche dans la texture.

Pour transmettre les 3+2 coordonnées de chaque sommet on fabrique un vecteur avec les données à la suite :

        // Création d'une structure de données à plat
        std::vector<GLfloat> vertices;
        for (int i = 0; i < 6; i++) {
            for (int j = 0; j < 3; j++)
                vertices.push_back (positions[i*3+j]);
            for (int j = 0; j < 2; j++)
                vertices.push_back (tex_coords[i*2+j]);
        }
    ...

Ensuite on copie les données dans le VBO courant à l'aide de 2 VAA, un par variable in de shader. Notez les offsets, et le stride de 5 GLfloat utilisés :

    Triangles (GLint vPos_loc, GLint vTex_loc)
        : m_vPos_loc {vPos_loc}, m_vTex_loc {vTex_loc}
    {
        // Données, données à plat ...
        // Création du VAO ...
        // Création du VBO ...
        
        // VAA associant les données à la variable vPos du shader, avec l'offset 0
        glVertexAttribPointer (m_vPos_loc, 3, GL_FLOAT, GL_FALSE, 
            5*sizeof(GLfloat), reinterpret_cast<void*>(0*sizeof(GLfloat)));
        glEnableVertexAttribArray (m_vPos_loc);  

        // VAA associant les données à la variable vTex du shader, avec l'offset 3
        glVertexAttribPointer (m_vTex_loc, 2, GL_FLOAT, GL_FALSE, 
            5*sizeof(GLfloat), reinterpret_cast<void*>(3*sizeof(GLfloat)));
        glEnableVertexAttribArray (m_vTex_loc);  

Pour faire le rendu on a juste besoin des identifiants de texture, pour rendre active la bonne texture avant le dessin de chaque triangle :

    void draw (GLuint texture_id1, GLuint texture_id2)
    {
        glBindVertexArray (m_VAO_id);

        glBindTexture (GL_TEXTURE_2D, texture_id1);
        glDrawArrays (GL_TRIANGLES, 0, 3);

        glBindTexture (GL_TEXTURE_2D, texture_id2);
        glDrawArrays (GL_TRIANGLES, 3, 3);

        glBindVertexArray (0);
    }

8.5. Shaders pour texture

On s'attaque enfin aux shaders. Dans l'exemple fw43-texture.cpp, les deux shaders ci-dessous sont déjà les shaders par défaut, donc vous pouvez directement exécuter le programme sans fournir de shader via les options de ligne de commande -vs et -fs.

Vertex shader

Dans le vertex shader on reçoit en entrée dans la variable in vec2 vTex les coordonnées de texture du sommet courant ; on se contente de les recopier dans la variable en sortie out vec2 texCoord.

Le nom de la variable en sortie n'a pas d'importance, il suffit d'utiliser la même variable dans le fragment shader (comme pour toutes les variables out) pour que sa valeur soit transmise, interpolée, dans le fragment shader pour chaque fragment.

#version 330
in vec4 vPos;
in vec2 vTex;
out vec2 texCoord;
uniform mat4 matMVP;

void main()
{
    gl_Position = matMVP * vPos;
    texCoord = vTex;
}

Fragment shader

C'est ici que l'on va déterminer la couleur du fragment en allant "piocher" dans la texture.

La coordonnée de texture interpolée est reçue dans la variable in vec2 texCoord.

La texture est mémorisée dans un type opaque appelé sampler2D, accessible en lecture seule dans les shaders sous la forme d'une variable uniform. Il n'y a pas besoin de chercher le nom de la variable dans le shader program, il suffit de déclarer la variable uniform avec le type sampler2D, et le linker OpenGL établit la correspondance. (Il peut y avoir 2 textures accessibles en même temps mais il faut alors utiliser des layout(location=...) explicites).

On récupère ensuite la couleur dans la texture uTex tout simplement en la lui demandant, au moyen de la fonction dédiée texture2D, en lui appliquant les coordonnées texCoord :

#version 330
in vec2 texCoord;
out vec4 fragColor;
uniform sampler2D uTex;

void main()
{
    fragColor = texture2D (uTex, texCoord);
}

Exemples de manipulations

Enregistrer le fragment shader dans un fichier, puis le passer en ligne de commande avec l'option -fs avec les variantes :

  • fragColor = texture2D (uTex, texCoord*0.5);
  • fragColor = texture2D (uTex, texCoord*3.0);
  • fragColor = texture2D (uTex, vec2(texCoord.s*2.0, texCoord.t*3.5));
  • fragColor = texture2D (uTex, texCoord.ts);
  • fragColor = texture2D (uTex, texCoord)*0.5;
  • fragColor = clamp(texture2D (uTex, texCoord)*1.5, 0.0, 0.8);

La manière d'afficher la texture lorsque les coordonnées dépassent de l'intervalle \([0,\ldots 1]\) peut être configuré à l'aide la la fonction glTexParameter et les paramètres GL_TEXTURE_WRAP_S et GL_TEXTURE_WRAP_T, voir des exemples dans learnopengl.