M1 synthèse d'images
2025

TP1 - prise en main openGL


Installation

mettez à jour (cf git pull) ou clonez gkit3 :
 
git clone https://forge.univ-lyon1.fr/JEAN-CLAUDE.IEHL/gkit3.git

puis dans le répertoire de gKit3, clonez gKit3GL, la partie openGL de gKit :

cd gkit3
git clone https://forge.univ-lyon1.fr/JEAN-CLAUDE.IEHL/gkit3GL
cd gkit3GL
premake5 gmake
make tp1
bin/tp1

vous pouvez compiler l'exemple tp1 et vérifier que tout est correct.

pour windows, et linux, il faut installer les librairies openGL, les différentes étapes sont résumées sur le dépot.


ce tp est volontairement rédigé avec pas mal de détails (ie il y a plein de texte...) pour que vous puissiez le faire en quasi-autonomie, à votre rythme, et me laisser le temps de régler les n problèmes rigolo d'installation...

Partie 1 : openGL et application

pour dessiner un objet avec openGL, il faut créer une fenêtre avec les bons paramètres pour qu'openGL puisse dessiner dedans. cette étape n'est pas très intéressante, mais surtout elle est différente d'un système à l'autre, ie X11 ne crée pas les fenêtres de la même manière que Wayland, ni que Windows et encore moins que Macos, ou Android ou WebGL, ou, etc...

on va donc utiliser une librairie pour faire ça de manière portable, par exemple : SDL2.

l'utilisation de la librairie n'est pas très intéressante non plus, il suffit de lire la doc sur la création des fenêtres et du contexte openGL et d'appeler les fonctions dans le bon ordre. et comme c'est toujours la même chose, il n'a pas été trop difficile de créer 2 fonctions utilitaires : create_window() et create_context() définies dans window.h

pour les curieux, il y a un résumé dans la doc en ligne, cf écrire une application openGL

maintenant que la fenêtre est créée, il suffit de dessiner dedans et de recommencer tant que l'application existe...
mais pour savoir que l'utilisateur veut fermer l'application, il faut le tester explicitement. ça s'appelle la gestion des évènements...

c'est un peu barbare, mais ce n'est pas compliqué, par contre, il y a pleins d'évènements, cf la doc.

pour savoir si la souris vient de cliquer sur le bouton 'fermer' de la fenêtre de l'application, il faut surveiller l'évènement SDL_WindowEvent, on peut aussi surveiller le clavier, ie SDL_KeyboardEvent, pour détecter que la touche 'echap' est enfoncée ou que 'ctrl'+'w' / 'ctrl'+'q' est enfoncé. ie toutes les manières classiques de fermer la fenêtre d'une application.

c'est SDL_PollEvent() qui permet de récupérer les évènements et de réagir, le code ressemblera à :

bool close= false;

// recuperer un evenement a la fois
// PollEvent() renvoie faux lorsque tous les evenements ont ete traite
SDL_Event event;
while(SDL_PollEvent(&event))
{
    if(event.type == SDL_QUIT)
        close= true;  // sortir si click sur le bouton 'fermer' de la fenetre

    else if(event.type == SDL_KEYDOWN && event.key.keysym.sym == SDLK_ESCAPE)
        close= true;  // sortir si la touche esc / echapp est enfoncee
}

projets/tp1.cpp est un exemple complet et fonctionnel.

une application openGL est en général composée de 3 parties. la boucle qui dessine toutes les images bien sur, mais il faut en général initialiser openGL, charger un objet, des textures, des shaders, etc. on regroupe tout ce code dans une fonction init(). sans surprises, toutes ces ressources doivent être libérées à la fin de l'exécution de l'application, il y a donc également une fonction quit() qui nettoye tout. l'organisation du code de projets/tp1.cpp doit être un peu plus claire.

en résumé, voila ce que fait tp1.cpp :
étape 1 : création de la fenêtre et du contexte openGL

    Window window= create_window(1024, 576);
    Context context= create_context(window);

étape 2 : initialisation, chargement d'un objet 3d au format wavefront, ie un fichier .obj

    std::vector<Point> positions;
    std::vector<unsigned> indices;
   
    // lit le fichier, et recupere un tableau de positions de sommets et un tableau d'indice des sommets des triangles de l'objet
    if(!read_indexed_positions("../data/robot.obj", positions, indices))
        return false;   // erreur de lecture

    count= indices.size();
   
    // utilitaire. alloue de la memoire gpu et transfere les tableaux positions et indices sur le gpu. necessaire pour dessiner...
    vao= create_buffers(positions, indices);

étape 3 : boucle principale et gestion des évènements

    bool close= false;
    while(!close)
    {
        SDL_Event event;
        while(SDL_PollEvent(&event))
        {
            if(event.type == SDL_QUIT)
                close= true;  // sortir si click sur le bouton 'fermer' de la fenetre
            else if(event.type == SDL_KEYDOWN && event.key.keysym.sym == SDLK_ESCAPE)
                close= true;  // sortir si la touche esc / echapp est enfoncee
        }
       
        // dessiner
        draw();

        // presenter / montrer le resultat
        SDL_GL_SwapWindow(window);
    }

étape 4 : libération des ressouces / nettoyage

    release_buffers(vao);

et bien sur, (enfin ! cf la fonction draw() qui est appelée pour dessiner / remplir la fenetre) l'affichage de l'objet crée dans init() :

    /* utilitaire. dessine des TRIANGLES avec count indices (ie indices/3 triangles...)
        en appliquant les transformations model, view et projection aux coordonnées des sommets.

     */
    draw(vao, GL_TRIANGLES, count, model, view, projection);


Partie 2 : openGL et géométrie

pour dessiner un objet, il faut décrire sa forme avec des points, des lignes ou des triangles, le placer dans le monde, ainsi que placer et définir une camera pour l'observer.

par exemple, pour décrire un objet composé d'un seul triangle, il suffit d'indiquer les coordonnées de ses sommets :

std::vector<Point> triangle= {
    Point(0, 0, 0),
    Point(1, 0, 0),
    Point(0, 1, 0)
};
ou, équivalent, mais plus rapide à taper en utilisant l'initialisation par défaut :
std::vector<Point> triangle= {
    { 0, 0, 0 },
    { 1, 0, 0 },
    { 0, 1, 0 }
};

avec ce tableau de points, on peut maintenant créer le buffer openGL et un Vertex Array Object (VAO) qui décrit les informations stockées dans le buffer. pour l'instant, on va utiliser un utilitaire de gkit3 :

GLuint vao= create_buffers( triangle );

ce qui permettra de le dessiner avec un autre utilitaire de gkit3 :

draw(vao, GL_TRIANGLES, 3 );


pour les curieux : écrire le code openGL correspondant est décrit dans la doc en ligne. on verra cette partie plus tard.

on peut également associer d'autres informations aux sommets d'un objet en complément de sa position. une couleur, par exemple, ou une normale (utile pour les calculs d'éclairage de la fin du tp...) ou des coordonnées de texture... il suffit de passer les paramètres dans le bon ordre à la fonction create_buffers().
il est également possible d'indexer les sommets pour décrire la géométrie. dans un maillage classique chaque sommet est partagé par 3 voire  6 triangles, l'indexation évite de stocker et de transformer plusieurs fois le même sommet. le tableau d'indices est aussi un paramètre de create_buffers().

exemple, un quad avec 2 triangles, en indexant leurs sommets :

std::vector<Point> positions= {
    {  0.5, -0.5, -0.5 },
    { -0.5, -0.5, -0.5 },
    { -0.5,  0.5, -0.5 },
    {  0.5,  0.5, -0.5 }
};
   
std::vector<unsigned> indices= {
    0, 1, 2,        // premier triangle
    2, 3, 0         // suivant
};

GLuint vao= create_buffers( positions, indices, /* texcoords */ {}, /* normals */ {}, /* colors */ {} );

si l'on souhaite associer une couleur aux 3 sommets du triangle du tout premier exemple, il suffit de fournir un tableau de Color à create_buffer() :

std::vector<Color> couleurs= {
    Red(),      // ou Color(1, 0, 0)
    Green(),
    Blue()
};

GLuint vao= create_buffers( triangle, /* indices */ {}, /* texcoords */ {}, /* normals */ {}, /* colors */ couleurs );

il faut faire attention à l'ordre des paramètres, mais ce n'est pas difficile.


on peut constuire les axes d'un repère, ce sera assez pratique pour expérimenter avec les transformations pour placer plusieurs objets dans la scène... par exemple :

std::vector<Point> positions;
std::vector<Color> colors;

colors.push_back( Red() );   positions.push_back( { 0, .1, 0 } );
colors.push_back( Red() );   positions.push_back( { 2, .1, 0 } );

colors.push_back( Green() ); positions.push_back( { 0, .1, 0 } );
colors.push_back( Green() ); positions.push_back( { 0,  2, 0 } );

colors.push_back( Blue() );  positions.push_back( { 0, .1, 0 } );
colors.push_back( Blue() );  positions.push_back( { 0, .1, 2 } );


GLuint vao= create_buffers( positions, {}, {}, {}, colors );


on peut aussi matérialiser le sol en construisant une grille de n par n cases autour de l'origine du repère :

std::vector<Point> positions;
std::vector<Color> colors;

// grille
for(int x= 0; x < n; x++)
{
    float px= float(x) - float(n-1)/2;
   
    positions.push_back( { px, 0, - float(n-1)/2 } );
    positions.push_back( { px, 0, float(n-1)/2 } );
}

for(int z= 0; z < n; z++)
{
    float pz= float(z) - float(n-1)/2;
   
    positions.push_back( { - float(n-1)/2, 0, pz });
    positions.push_back( { float(n-1)/2, 0, pz } );
}



une fois la description de la géométrie des objets terminée, il reste à placer les objets dans le monde et à les dessiner...
il y a plusieurs solutions pour décrire les transformations / changements de repères qui permettent de placer et d'orienter des objets dans le monde, mais les api 3d comme openGL, Vulkan, DirectX ou Metal imposent d'utiliser des matrices 4x4 homogènes. c'est un outil qu'il faut apprendre à manipuler.

pour placer un objet dans le monde, on modifie la matrice model : avec une translation, une rotation, un changement de taille, ou la composition de plusieurs transformations. mat.h définit les transformations usuelles, cf Translation, Rotation, Scale...

pour décrire une camera, on utilise aussi une matrice, view, pour représenter le passage entre le repère du monde et le repère de la camera. on construit ce changement de repère comme pour placer les objets dans le monde, ie avec des translations, rotations, etc. la projection réalisée par la camera est également décrite par une matrice de projection, cf Perspective, par exemple.

la suite de la lecture dans la doc en ligne : premiers objets et transformations, à partir de la section "placer un objet dans le monde". le début du tuto portant sur la description des objets n'est pas adapté à gkit3.

remarque : la doc en ligne et ses exemples sont rédigés pour gkit2light, il y a donc quelques différences. par exemple, la classe Mesh n'existe plus dans gkit3, elle est remplacée par la fonction create_buffers(). les paramètres de la fonction draw(), qui affiche les objets, sont également différents.

voici comment adapter les exemples de la doc, par exemple le 1er :

// code gkit2
Transform model= Translation(0, 2, 0);
draw(m_objet, model, camera());

// version gkit3
Transform model= Translation(0, 2, 0);
draw(vao, GL_TRIANGLES, count, model, view, projection);

m_objet est de type Mesh et contient, entre autre, le vao, count, le nombre d'indices de sommets et le type de primitives à dessiner, des GL_TRIANGLES, en général.
la fonction camera() renvoie les 2 matrices qui décrivent la camera, view et projection.

gkit3 est plus explicite... il y a moins de raccourcis, passer les exemples vers les fonctions de gkit3 ne devrait pas poser de probleme.

par contre, il n'est pas très difficile de créer une mini structure pour stocker ces informations :
struct Mesh
{
    GLenum primitives;
    GLuint vao;
    unsigned count;
};

// utilisation
Mesh mesh= { ... };
draw(mesh.vao, mesh.primitives, mesh.count, model, view, projection);
et d'en profiter pour écrire des fonctions utilitaires pour construire une grille avec les axes XYZ comme dans l'illustration au dessus :
Mesh make_grid( const int n= 10 )
{
    std::vector<Point> positions;
    std::vector<Color> colors;
   
    // grille
    for(int x= 0; x < n; x++)
    {
        float px= float(x) - float(n-1)/2;
       
        colors.push_back( White() ); positions.push_back( { px, 0, - float(n-1)/2 } );
        colors.push_back( White() ); positions.push_back( { px, 0, float(n-1)/2 } );
    }
   
    for(int z= 0; z < n; z++)
    {
        float pz= float(z) - float(n-1)/2;
       
        colors.push_back( White() ); positions.push_back( { - float(n-1)/2, 0, pz });
        colors.push_back( White() ); positions.push_back( { float(n-1)/2, 0, pz } );
    }
   
    // axes XYZ en rouge / vert / bleu
    colors.push_back( Red() ); positions.push_back( { 0, .1, 0 } );
    colors.push_back( Red() ); positions.push_back( {1, .1, 0 } );
   
    colors.push_back( Green() ); positions.push_back( { 0, .1, 0 } );
    colors.push_back( Green() ); positions.push_back( { 0, 1, 0 } );
   
    colors.push_back( Blue() ); positions.push_back( { 0, .1, 0 } );
    colors.push_back( Blue() ); positions.push_back( { 0, .1, 1 } );
   
    glLineWidth(2);
    return { GL_LINES, create_buffers( positions, {}, {}, {}, colors ), positions.size() };
}


vous pouvez utiliser les utilitaires de objets.cpp pour reproduire les figures du tuto sur les transformations. cf make_grid(), make_frame() et make_camera().

exercice : comment décrire un cube ? utilisez des sommets partagés (ie avec des positions et des indices).
utilisez des coordonnées entre -0.5 et 0.5 pour les positions des 8 sommets du cube. reste le plus difficile, décrire les 6 faces en respectant l'orientation des sommets des triangles. dans le sens trigo. cette figure devrait vous aider :



on peut trianguler la face avant avec les sommets dans l'ordre 4, 5, 6, 7. et pour la face arrière ? quel est le bon ordre ? 0, 1, 2, 3 ou 1, 0, 3, 2 ?
rappel : l'ordre des sommets des triangles est important, cf le test d'orientation du pipeline graphique. par défaut, le test est configuré pour conserver les triangles dans le sens trigo lorsqu'ils sont face à la camera.

si vous craquez... vous pouvez charger le fichier cube.obj... mais c'est triché.


Partie 3 : openGL et transformations

faites le tuto dans la doc en ligne : premiers objets et transformations, à partir de la section "placer un objet dans le monde". les parties précédentes portant sur la description des objets ne sont pas adaptées à gkit3. par contre, vous avez maintenant les mêmes outils.

une fois que l'essentiel des transformations et de leur composition est compris, comment écrire une camera simplifiée qui permet de tourner autour du centre d'un objet (ou de l'origine pour commencer)  ? en contrôlant un seul angle de rotation (autour de l'axe Y, par exemple) ?
et avec 2 angles de rotation, ce serait plus intuitif, non ?

comment pourrait on approcher la camera du centre de l'objet ? ou l'éloigner ?
et comment serait-il possible de déplacer le centre de rotation de la camera (au lieu de tourner autour des pieds du robot, de l'origine) ?
comment calculer la distance de la camera pour voir la totalité de l'objet dans la fenêtre ?


bravo ! vous venez d'écrire une camera de type orbiter.
et une camera type première personne ? que faut-il changer ? est ce que ce type de camera est adaptée à l'observation d'un seul objet ?
vous connaissez probablement au moins 2 autres types de cameras couramment utilisées dans les jeux video, comment les realiser ?

remarque : pour utiliser key_state() et clear_key_state(), il faut utiliser la fonction utilitaire de gestion d'évènements events() définie dans window.h.
il n'est pas trop difficile d'écrire une fonction générique pour gérer les évènements.
projets/tp3.cpp donne un exemple complet qui utilise key_state() pour déplacer un objet.

remarque : il est aussi très simple de récupérer la position de la souris, son mouvement et l'état de ces boutons (cliqués ou pas...), cf SDL_GetMouseState() ou SDL_GetRelativeMouseState()
    int x, y;
    unsigned buttons= SDL_GetMouseState(&x, &y);
    
    if(buttons & SDL_BUTTON(1))
    { 
// on vient de cliquer avec le bouton gauche de la souris sur le pixel (x, y) de la fenetre
...
}

Partie 4 : openGL et shaders

maintenant que vous savez afficher des objets à peu près comme vous voulez, il est temps de regarder comment fonctionnent les shaders.

projets/tp4.cpp et tp4.glsl fournissent un premier exemple minimaliste.

que faut-il modifier pour colorier les pixels avec une autre couleur ?
que faut-il faire pour que l'application modifie la couleur ? rappel : les shaders ont plusieurs types de paramètres...

dessinez chaque objet avec une couleur différente.

c'est quand même moche ces aplats de couleurs constante. que faut-il faire pour utiliser un modèle de matière, Lambert par exemple ?
il faut faire un peu de gymnastique, mais la démarche est la même que d'habitude.
que veut-on calculer ?
quels paramètres ?
quelles données ? quels résulats ?
comment déclarer les différents paramètres ?

rappel : voila la situation, un point p éclairé par une source de lumière visible dans la direction l, et une matière toute simple, juste une couleur.





que doit on calculer ?

il suffit de calculer le cosinus de l'angle entre la normale en p et la direction vers la lumière, et de se rappeler que les calculs sur les points et les vecteurs doivent se faire avec des coordonnées dans le même repère.

en résumé, il faut connaitre, dans le même repère :

quel shader fait le calcul ? et oui, il faudra probablement transformer certaines coordonnées.

solution 1 : vertex shader
on va commencer par faire les calculs dans le vertex shader. 

comment récupérer toutes ces informations dans le vertex shader ? une fois que les coordonnées de tous ces points et vecteurs sont connues dans le même repère, il ne reste plus qu'à faire le calcul...

dernière question, comment transmettre la couleur que l'on vient de calculer au fragment shader ? et pourquoi vouloir faire ça ? y-a-t-il une autre solution ?

solution 2 : fragment shader
que faut-il modifier pour faire les calculs pixel par pixel, dans le fragment shader ?

le résultat est-il différent ? ou, pourquoi ça marche ?


quelle est la meilleure solution ?