CPE 4 ITI 2017-2018


TP2 - shaders et openGL



Résumé des épisodes précédents

Dans le TP1 vous avez construit quelques maillages (repère, grille, terrain, etc.) et appris à les afficher, d'abord avec les utilitaires de gKit, puis avec un shader qui vous avez paramétré.
L'objectif de ce tp est de comprendre comment décrire complètement un maillage à openGL et de le dessiner directement, sans utiliser les utilitaires.


attention :
vérifiez que les différents tests (profondeur, orientation des faces, etc.) sont correctement configurés avant de passer à la suite, plus de détails dans le tuto.


Partie 1 : openGL

Pour décrire un maillage à openGL, il faut fournir les données qui décrivent les sommets et les primitives (triangles, lignes, etc) et les associer aux entrées déclarées par le vertex shader : les attributs.

C'est relativement simple à faire, en 2 étapes : la première alloue de la mémoire sur la carte graphique et transfère toutes les données, la deuxième associe les données aux attributs déclarés dans le shader.

(pourquoi ? le shader est une fonction et les attributs de sommets sont des paramètres de cette fonction... le pipeline exécute les vertex shaders en parallèle, et il doit avoir accès à la totalité des données)

étape 1 : buffers, allocation et transfert des données

la carte graphique possède sa propre mémoire qui n'est pas accessible directement par le processeur principal. openGL permet d'allouer de la memoire graphique, et renvoie un identifiant (ce n'est pas un pointeur, les espaces d'adressage ne sont pas les memes...) sur la zone mémoire allouée, ce qui permet par exemple d'initialiser son contenu (copier des données). cet identifiant s'appelle un buffer. il suffit de le créer avec glGenBuffers(), de le sélectionner avec glBindBuffer() puis de transférer les données (les coordonnées des sommets du maillage dans notre cas) avec glBufferData() ou glBufferSubData().

GLuint buffer;

init( )
    // construit n sommets
    std::vector<Point> sommets;
    { ... }

    // cree un buffer
    glGenBuffers(1, &buffer);
    // selectionne le buffer
    glBindBuffer(GL_ARRAY_BUFFER, buffer);

    // dimensionne le buffer et l'initialise / transfere les donnees du maillage, le vecteur sommets
    glBufferData(GL_ARRAY_BUFFER, /* size */ sommets.size() * sizeof(Point), /* data */ (const float*) sommets.data(), /* usage */ GL_STATIC_DRAW);
    // la taille du buffer est exprimee en octets !!

tous les détails sont dans le tuto, section buffers.

étape 2 : configuration d'un vertex array

maintenant que les données sont dans la mémoire de la carte graphique, il faut les associer aux attributs déclarés dans le vertex shader. Le pipeline affecte automatiquement la valeur de l'attribut lors de l'exécution du vertex shader. Dans le tp précédent, le vertex shader déclare :

in vec3 position;

ce qui indique que la variable 'position' est une entrée du shader, et qu'elle est de type vec3, c'est à dire 3 floats ou un Point.
c'est glVertexAttribPointer( ) qui indique dans quel buffer se trouvent les coordonnées de chaque sommet du maillage. il faut également préciser ou se trouve la première valeur par rapport au début du buffer et comment trouver les valeurs suivantes, cf paramètres offset et stride de glVertexAttribPointer.

mais avant de pouvoir utiliser glVertexAttribPointer, il faut créer un objet openGL qui conserve cet ensemble de paramètres (ou se trouve chaque attribut), c'est un vertex array. La démarche est la même que pour créer un buffer :

GLuint vertex_array;

init( )
    // cree le vertex array
    glGenVertexArrays(1, &vertex_array);
    // selectionne le vertex array pour l'initialiser
    glBindVertexArray(vertex_array);

pour associer le contenu d'un buffer à une variable d'un shader, il faut récupérer l'identifiant de la variable (comme pour affecter une valeur à un uniform), cf glGetAttribLocation( ), et sélectionner le buffer contenant les données sur GL_ARRAY_BUFFER :

init( )
    // sélectionner le vertex array, si necessaire, parametre implicite de glVertexAttribPointer
    // glBindVertexArray(vertex_array);

    // sélectionner le buffer contenant les donnees, si necessaire, parametre implicite de glVertexAttribPointer
    // glBindBuffer(GL_ARRAY_BUFFER, buffer);

    // récuperer l'identifiant de l'attribut declare dans le vertex shader
    GLint attribute= glGetAttribLocation(program, "position");

    // décrit un attribut composé de 3 floats, placé au début du buffer actuellement sélectionné sur GL_ARRAY_BUFFER et "espacé" de sizeof(Point) octets.
    glVertexAttribPointer(attribute, /* size */ 3, /* type */ GL_FLOAT,
        /* normalize */ GL_FALSE,
        /* stride */ sizeof(Point),
        /* offset */ 0);

    // !! attention : le buffer contenant les donnees est un parametre implicite, c'est le buffer selectionne sur GL_ARRAY_BUFFER,
    // cf glBindBuffer(GL_ARRAY_BUFFER, buffer); dans l'étape 1 !!
   
    glEnableVertexAttribArray(attribute);
    // attention : ne pas oublier d'activer l'attribut !!


tous les détails sont dans le tuto, section vertex array.

remarque : si votre vertex shader a "besoin" de plusieurs attributs pour fonctionner, il faut les configurer un par un, cf tuto, section vertex array.


exercice 1 :
modifiez votre tp (cf fonction init()) pour créer un buffer pour stocker les coordonnées des sommets du terrain.
la classe Mesh fournit un std::vector de vec3 contenant les coordonnées des sommets, cf Mesh::positions(), ainsi que les normales, cf Mesh::normals() et d'autres fonctions utilitaires pour simplifier cette partie, cf doc de la classe Mesh, section manipulation de buffers d'attributs.

créez et configurez un vertex array pour l'attribut déclaré dans votre vertex shader.

avant de pouvoir dessiner (cf fonction draw( )), il faudra bien sur sélectionner le vertex array, le shader program et affecter une valeur à ses uniforms. ensuite, il faudra utiliser la bonne version de glDraw( ) en fonction de la description du maillage. si le maillage est indexé il faut utiliser glDrawElements( ), sinon, c'est glDrawArrays( ).

draw( )
    glUseProgram(program);
    // affecter une valeur aux uniforms
    { ...}

    // selectionne les parametres decrivant l'organisation memoire des attributs dans les buffers...
    // vertex array est plus court comme formulation...
    glBindVertexArray(vertex_array);

    glDrawArrays(GL_TRIANGLES, /* first */ 0, /* count */ terrain.vertex_count());

les différentes versions de glDraw() sont décrites dans le tuto.


et voila, votre application dessine directement avec openGL !!


Partie 2 : plus !

vous savez afficher un objet, et avec plusieurs ? qu'est ce qui change ?



vous pouvez charger des objets au format wavefront .obj avec :
#include "mesh.h"
#include "wavefront.h"

Mesh objet;

init( )
    objet= read_mesh("fichier.obj")
   
    // verifie que le maillage contient au moins un sommet..
    if(objet.positions().size() == 0)
        // erreur, rien a afficher...
        return -1;


    // creer un buffer, un vertex array, etc.
    // compiler un shader program

exercice 1 :
chargez un ou plusieurs objets 3d, placez les dans le monde (quelle transformation utiliser ?) et affichez les.
modifiez leur position / orientation / taille en fonction du temps. comment faire ?

rappel : global_time() dans window.h


exercice 2 :
comment créer une camera qui suit les déplacements d'un objet ?

comment créer une camera qui reste toujours à la même altitude au dessus du terrain, quelque soit le relief du terrain ?

même question avec un objet ? comment déplacer un objet à la surface du terrain, par exemple ?

remarque : la transformation qui place et oriente la camera dans la scène est l'inverse de la transformation View.

Partie 3 : shaders

remarque : pour mettre au point vos shaders plus facilement, utilisez la fonction reload_program() qui permet de re-compiler les shaders sans quitter l'application.

GLuint program;

init( )
    program= read_program("tutos/...glsl");
    program_print_errors(program);

draw( )
    if(key_state('r'))
    {
        clear_key_state('r');
        reload_program(program, "tutos/...glsl");
        program_print_errors(program);
    }


exercice 1 :
comment éclairer "correctement" un objet / un point d'un triangle ?

une solution correcte calcule le cosinus de l'angle entre la normale du triangle et la direction du soleil (par exemple, ou d'une autre source de lumière).
il est très simple de calculer le cosinus de l'angle entre 2 vecteurs, c'est le produit scalaire des 2 vecteurs normalisés (de longueur 1) :

shader( )
    vec3 N= ... ; // normale du triangle

    vec3 L= ... ; // direction du soleil
    float cos_theta= dot(normalize(N), normalize(L));
    vec3 color= vec3(1, 0, 0) * cos_theta;

    gl_FragColor= vec4(color, 1);

que faut-il modifier dans l'application et éventuellement dans le vertex shader pour que le fragment shader puisse faire ce calcul ?
quelles sont les informations supplémentaires à transmettre au fragment shader ?
comment obtenir les coordonnées du point du triangle associé à un fragment ?

attention : les coordonnées des différents vecteurs doivent être exprimées dans le même repère...

remarque : pour calculer les normales des sommets / triangles d'un Mesh, cf Annexe, à la fin du sujet.

éclairez vos objets de cette manière.
vous pouvez aussi donner une couleur de base différente à chaque objet. comment ? que faut-il modifier ?

plus de détails sur les calculs d'éclairage / de reflets dans ce cours : cf CPE5 / cm3

exercice 2 :
calculer réellement les ombres projettées par des objets est peu plus compliqué. mais on peut très bien utiliser une approximation...

l'idée est de remplacer la forme de l'objet qui projette une ombre par une forme simple, une sphère, par exemple, et de construire la forme de l'ombre crée par cette sphère. dans le cas d'une source directionnelle comme le soleil, l'ombre crée par une sphère sera un cylindre. il ne reste plus qu'a déterminer si un point d'un objet est inclus dans le cylindre, ou pas, pour déterminer qu'il est à l'ombre ou éclairé.

connaissant le centre et le rayon d'une sphère, la direction principale du soleil, comment décrire le cylindre ?
connaissant les coordonnées d'un point, comment déterminer qu'il appartient au cylindre et qu'il est à l'ombre ?

modifiez votre application et vos shaders.

comment obtenir les coordonnées du point du triangle associé à un fragment ?

exercice 3 :
il est également possible de décrire d'autres types de source de lumière. un spot ou une source ponctuelle / petite ampoule.

comment éclairer un point par un spot ? c'est le même principe que précédement, si le point est dans le cone representant le volume éclairé par le spot, le point est éclairé.
un cone est décrit par un sommet, une direction et un angle. comment déterminer qu'un point est à l'intérieur d'un cone ?

quelle est la forme de l'ombre projettée par une sphère lorsqu'elle est éclairée par un cone ? ou par un point ?


exercice 4 :
et avec plusieurs ombres projettées et plusieurs spots ? qu'est ce qui change ?
comment fournir toutes ces informations aux shaders ?



Annexe : calculer les normales...

dans la premiere partie vous avez construit un maillage représentant la surface d'un terrain, ou chargé un fichier .obj. Pour calculer la couleur des fragments en fonction de leur orientation par rapport à une source de lumière, il faut connaitre les normales des sommets, mais elles ne sont pas toujours définies.

comment les calculer, connaissant les coordonnées des sommets des triangles ?

il suffit de parcourir les triangles du mesh, de calculer 2 aretes du triangle et un produit vectoriel permet de calculer un 3ieme vecteur perpendiculaire aux 2 aretes.
vous pouvez écrire une fonction :

void build_normals( Mesh& mesh )
{
    printf("building normals...\n");
    
    std::vector<vec3> positions= mesh.positions();
    
    // reconstruit completement l'objet
    mesh= Mesh(GL_TRIANGLES);
    
    // calcule les normales geometriques des triangles
    for(int i= 0; i +2 < (int) positions.size(); i+= 3)
    {
        // recupere les sommets du triangle
        Point a= Point(positions[i]);
        Point b= Point(positions[i +1]);
        Point c= Point(positions[i +2]);
        
        // calcule 2 aretes du triangle
        Vector ab(a, b);
        Vector ac(a, c);
        // produit vectoriel des aretes == normale
        Vector n= normalize(cross(ab, ac));
        // attention a l'orientation...

        
        // re-insere les sommets et leurs normales
        mesh.normal(n).vertex(a);
        mesh.normal(n).vertex(b);
        mesh.normal(n).vertex(c);
    }
    
    printf("  positions %d, normals %d\n", (int) mesh.positions().size(), (int) mesh.normals().size());
}

cette fonction recalcule les normales géométriques des triangles et les objets éclairés auront un aspect très facetisé. il est aussi possible de construire la normale des sommets en calculant, par exemple, la moyenne des normales des triangles adjacents à chaque sommet. c'est un peu plus long, mais pas très compliqué, les détails sont dans le tuto.

exemple des différences :


à gauche : normales par triangle, à droite : normales par sommet


Annexe 2 : plusieurs couleurs par objet...

les objets décrits par un fichier wavefront .obj ne sont pas toujours d'une couleur uniforme. Un objet peut etre découpé en plusieurs parties, chacune associée à une couleur. Une solution simple pour afficher un objet de ce type consiste à affecter la couleur de chaque partie directement aux sommets.

// affecte la couleur des matieres aux sommets des triangles
void build_colors( Mesh &mesh )
{
    printf("building colors...\n");
 
    std::vector<vec3> positions= mesh.positions();
    std::vector<vec3> normals= mesh.normals();
    std::vector<Material> materials= mesh.mesh_materials();
    std::vector<unsigned int> triangle_materials= mesh.materials();
    
    // reconstruit completement l'objet
    mesh= Mesh(GL_TRIANGLES);
    
    for(int i= 0; i +2 < (int) positions.size(); i+= 3)
    {
        // recupere les sommets du triangle
        Point a= Point(positions[i]);
        Point b= Point(positions[i +1]);
        Point c= Point(positions[i +2]);
        
        Vector na;
        Vector nb;
        Vector nc;
        // recalcule aussi les normales, si necessaire
        if(normals.size() != positions.size())
        {
            // calcule 2 aretes du triangle
            Vector ab(a, b);
            Vector ac(a, c);
            // produit vectoriel des aretes == normale
            Vector n= normalize(cross(ab, ac));
            na= n;
            nb= n;
            nc= n;
        }
        else
        {
            // ou recupere les normales, si possible
            na= Vector(normals[i]);
            nb= Vector(normals[i +1]);
            nc= Vector(normals[i +2]);
        }
        
        // recupere la couleur du triangle
        Color diffuse= materials[triangle_materials[i / 3]].diffuse;
        
        // re-insere les sommets, leurs normales et leurs couleurs
        mesh.color(diffuse).normal(na).vertex(a);
        mesh.color(diffuse).normal(nb).vertex(b);
        mesh.color(diffuse).normal(nc).vertex(c);
    }
    
    printf("  positions %d, normals %d, colors %d\n", (int) mesh.positions().size(), (int) mesh.normals().size(), (int) mesh.colors().size());
}