gKit2 light
interface C openGL

une api3d est...

un ensemble de fonctions... pour utiliser un pipeline 3D.

Dans un monde idéal, il y aurait un seul source, décrivant l'application et les shaders, des tableaux de triangles pour décrire les objets, et il n'y aurait pas de différence entre affecter une valeur à une variable / paramètre d'un shader et affecter une valeur à une variable de l'application...

La réalité est très légèrement différente. L'application s'exécute sur le processeur de l'ordinateur et les shaders s'exécutent sur les processeurs de la carte graphique, et les deux disposent de leur propre mémoire, qui ne partagent pas d'espace d'adressage... autrement dit, une variable de shader n'existe pas dans l'application, et réciproquement, une variable de l'application n'existe pas dans un shader. Le cpu et le gpu sont physiquement des machines différentes connectées par un réseau... et c'est au programmeur de compiler un programme pour le cpu, de compiler les shaders pour le gpu et de les faire communiquer / s'exécuter ensemble. Dernier point important, le pipeline propose pas mal d'options différentes, et il n'est pas envisageable d'affecter explicitement une valeur à l'ensemble des paramètres à chaque fois que l'on veut dessiner un objet. Ces 2 problèmes font qu'une api 3d est un très gros ensemble de fonctions / librairie, et que la totalité des paramètres sont implicites.

openGL est utilisable à travers un ensemble de fonctions C, ce qui signifie, pas de surchage, mais une famille de fonctions, des types opaques, pas d'objet, mais des identifiants.

une interface C++ pourrait ressembler à ça :

Context *context= new Context();
std::vector<Point> positions= { ... };
Buffer *buffer= context->createBuffer();
buffer->data(positions);

tout est explicite, on connait le contexte, le buffer, etc.

la version C pur pourrait ressembler à ça :

struct Context *context= create_context();
Point positions[]= { ... };
struct Buffer *buffer= context_createBuffer(context);
buffer_data(buffer, sizeof(positions), positions);
Context create_context(Window window)
cree et configure un contexte opengl
Definition: window.cpp:356
representation d'un point 3d.
Definition: vec.h:21

tout est explicite, mais un peu plus long à écrire.

l'interface d'openGL utilise une convention différente : au lieu de rendre explicite l'objet / la structure manipulée, elle est d'abord "sélectionnée", puis manipulée implicitement :

createContext(); // crée un contexte, et le sélectionne, devient le paramètre implicite de toutes les autres fonctions
uint buffer= createBuffer(); // crée un buffer, représenté par un identifiant numérique, parametre implicite, context
bindBuffer(buffer); // sélectionne le buffer, parametre implicite, context
Point positions[]= { ... };
bufferData(sizeof(positions), positions); // alloue le buffer et l'initialise avec le contenu de positions, parametre implicite, buffer, context.

ce qui permet d'obtenir une syntaxe assez compacte, mais peut être source de nombreuses erreurs, si l'on n'y prête pas attention.

pas de surcharge

autre détail important, il n'y a pas de surchage en C, on ne peut pas écrire ça pour modifier la valeur d'un paramètre de shader :

Program *program= context->createProgram( ... );
program->uniform("color", Color(1, 1, 0)); // affecter une couleur
program->uniform("time", 12); // affecter un float
representation d'une couleur (rgba) transparente ou opaque.
Definition: color.h:14

ni ça :

struct Program *program= context_createProgram( context, ... );
program_uniform(program, "color", make_color(1, 1, 0));
program_uniform(program, "time", 12);
void program_uniform(const GLuint program, const char *uniform, const std::vector< unsigned > &v)
affecte un tableau de valeurs a un uniform du shader program.
Definition: uniforms.cpp:94

mais plutot ça :

// creer le shader program
uint program= createProgram( ... );
// selectionner le shader program
useProgram(program);
// modifier les uniforms
uniform3f("color", 1, 1, 0); // parametre implicite, program, surcharge 3 float
uniform1f("time", 12); // parametre implicite, program, surcharge 1 float

chaque surcharge est nommée explicitement, puisque seul le nom de la fonction est utilisé. selon le type de valeur à manipuler, les noms de fonctions sont décorés par un suffixe indiquant le nombre de paramètres (1, 2, 3, ou 4) et leur type (f pour float, i pour int, u pour unsigned int, v pour vector, un pointeur sur une ou plusieurs valeurs, etc.)

création des objets openGL

pour dessiner, il faut en général créer plusieurs types d'objets openGL, la démarche est toujours la même :

exemple, pour créer plusieurs buffers openGL, cf glGenBuffers( ) :

GLuint buffers[4];
glGenBuffers(4, buffers);
MeshBuffer buffers(const MeshData &data)
construction a partir des donnees d'un maillage.
Definition: mesh_buffer.cpp:48

pourquoi ? tous ces objets / ressources openGL permettent de manipuler des données qui sont en fait stockées dans la mémoire de la carte graphique. Il n'y a pas d'espace d'adressage commun entre le cpu et le gpu : l'application ne peut pas obtenir un pointeur sur une structure allouée sur le gpu. Par contre, toutes les api 3d proposent la même abstraction. Par exemple, allouer de la mémoire gpu pour stocker les coordonnées des sommets des triangles à dessiner est caché derrière les buffers.

créer un buffer

pour créer un seul buffer, il suffit de passer le pointeur sur l'identifiant :

GLuint buffer;
glGenBuffers(1, &buffer);

le buffer sera ensuite sélectionné avec glBindBuffer( ). cf configurer un format de sommet, vertex array object pour plus de détails sur l'utilisation des buffers.

c'est l'équivalent d'une allocation dynamique, cf new[] ou malloc(), mais au lieu de récupérer un pointeur, on obtient un identifiant de la zone mémoire allouée sur le gpu.

créer un vertex array object

GLuint vao;
glGenVertexArrays(1, &vao);

le vao sera ensuite sélectionné avec glBindVertexArray( ). cf configurer un format de sommet, vertex array object pour plus de détails

cet objet permet de décrire la structure d'un sommet, c'est l'équivalent openGL d'une déclaration :

struct vertex
{
vec3 position;
vec3 normale;
vec3 couleur;
};
vecteur generique, utilitaire.
Definition: vec.h:146
representation de l'indexation complete d'un sommet
Definition: wavefront.cpp:176

pour chaque membre, il y aura un attribut de sommet openGL déclaré en utilisant glVertexAttribPointer(). cf configurer un format de sommet, vertex array object

créer une texture

GLuint texture;
glGenTextures(1, &texture);

la texture sera ensuite sélectionnée avec glBindTexture( ), cf textures, samplers et pipeline.

c'est la même idée que pour les buffers, une texture est un tableau de couleurs, les fonctions glTexImageXXX() permettent de déclarer les dimensions de l'image et de décrire la structure qui représente une couleur.

créer un shader, un shader program

les shaders et les programs sont un peu à part, leur fonction de création ne crée qu'un seul objet à la fois :

GLuint vertex_shader= glCreateShader(GL_VERTEX_SHADER);
GLuint shader_program= glCreateProgram();

le shader program sera sélectionné avec glUseProgram( ). cf compiler et linker un shader program pour les détails.

openGL inclut un compilateur et un linker de shaders, ainsi qu'un ensemble de fonctions permettant de récupérer les symboles / l'interface des shaders. Ce sont ces informations qui permettent d'affecter une valeur à chaque paramètre avant de dessiner des objets. c'est équivalent à :

// en c++ on pourrait écrire un vertex shader simple :
std::vector<vec4> vertex_shader( const std::vector<vec3>& positions, const Transform matrix )
{
std::vector<vec4> t;
for(auto const& p : positions)
t.push_back( matrix * vec4(p, 1) );
return t;
}
representation d'une transformation, une matrice 4x4, organisee par ligne / row major.
Definition: mat.h:21
vecteur generique 4d, ou 3d homogene, utilitaire.
Definition: vec.h:168

mais le pipeline parallèlise l'execution de la boucle, et le vertex shader ne transforme qu'un seul sommet, il est exécuté par N threads :

// en c++ on pourrait écrire un vertex shader simple :
vec4 vertex_shader( const std::vector<vec3>& positions, const Transform matrix, const int id )
{
return matrix * vec4(positions[id], 1);
}
// exemple de parallelisation avec openMP en c++
std::vector<vec3> positions= { ... }; // valeurs fournies par l'application
std::vector<vec4> shader_output(positions.size());
#pragma omp parallel for
for(int vertex_id= 0; vertex_id < positions.size(); vertex_id++)
shader_output[vertex_id]= vertex_shader(positions, matrix, vertex_id);

et les api 3d décrivent les paramètres du vertex shader différemment :

// en c++ on pourrait écrire un vertex shader simple :
vec4 vertex_shader( const vec3 position, const Transform matrix, const int id )
{
return matrix * vec4(position, 1);
}
// exemple de parallelisation avec openMP en c++
std::vector<vec3> positions= { ... }; // valeurs fournies par l'application
std::vector<vec4> shader_output(positions.size());
#pragma omp parallel for
for(int vertex_id= 0; vertex_id < positions.size(); vertex_id++)
{
vec3 position= positions[vertex_id];
shader_output[vertex_id]= vertex_shader(position, matrix, vertex_id);
}

le vertex shader et ses paramètres s'écrivent au final comme ça :

uniform mat4 matrix;
in vec3 position;
out vec4 gl_Position;
void main( void )
{
gl_Position= matrix * vec4(position, 1);
}

les mot clés uniform, in, out indiquent si chaque paramètre est une entrée, un résultat, ou si c'est un élément d'un tableau, indexé implicitement par l'identifiant du thread qui exécute la fonction...

résultat, pour exécuter la fonction, au lieu d'écrire :

// c++
std::vector<vec3> positions= { .... };
Transform matrix= { ... };
std::vector<vec4> projections= vertex_shader(positions, matrix);

il faut indiquer, paramètre par paramètre, quel objet openGL contient sa valeur : un vertex array object, un uniform, ou un buffer. cf afficher plusieurs triangles, modifier les paramètres uniform d'un shader program

// selectionne les shaders / la fonction
useProgram(...);
programUniform("matrix", ...); // affecte une valeur au parametre 'matrix'
bindVertexArray(...); // affecte un tableau au parametre 'positions'
draw(GL_TRIANGLES, n); // dessine n/3 triangles et execute les vertex shaders pour transformer n sommets.

et c'est encore un peu petit plus décomposé : pour affecter une valeur à un paramètre uniform, il faut d'abord récupérer son identifiant / symbole, avec glGetUniformLocation() puis utiliser la bonne surcharge de glProgramUniform() / glUniform() pour affecter une valeur. cf afficher plusieurs triangles, modifier les paramètres uniform d'un shader program.

Affecter les paramètres uniform un par un n'est pas la seule solution, il est aussi possible et plus performant de les grouper dans un buffer, cf openGL 3.3 : uniform buffers et ressources cpu / gpu et transfert de données pour la mise à jour efficace des buffers.

documentation complète

l'api complète et sa documentation est consultable sur opengl.org, le wiki propose un résumé des fonctionnalités et ainsi que les détails d'utilisation.

pour les curieux

Oui, il est très tentant d'écrire une abstraction c++ pour manipuler tout ça... malheureusement, les paramètres implicites empechent de faire une conception propre.

Autre point pénible, openGL est une api historique, conçue par SGI (IrisGL) et rendu publique en 1992, d'ou le nom openGL. Les conventions de programmation, ainsi que les cartes graphiques étaient très différentes à l'époque. Les shaders n'existaient pas, par exemple, la première version ne permettait pas de manipuler de textures, etc. Un premier gros travail de nettoyage est arrivé avec la version 3.3 core profile. Cette version est la base d'openGL ES utilisé sur tablette et téléphone, ainsi que de WebGL. Chaque version d'openGL correspond à l'ajout de nouvelles fonctionnalités pour profiter de chaque génération de carte graphique. Certaines fonctions sont ajoutées pour raison de performances, mais les anciennes sont toujours présentes dans l'api... De même, plusieurs ajouts permettent de simplifier le portage des applications écrites avec Direct3d. Et l'api est très chargée, par exemple : pour modifier le contenu d'un buffer, il y a au moins 4 solutions différentes, il y a aussi 2 manières de décrire les attributs de sommets, la "nouvelle" a été introduite en partie pour raison de performance et en partie parce que la notion de vertex array n'existe pas en Direct3d...

Par contre, openGL 4.5 inclus l'extension direct_state_access qui permet de se rapprocher d'un modèle objet, mais comme cette api co-existe avec la version historique, le nommage des fonctions est pénible. Cette dernière version de l'api élimine la sélection d'un objet pour modifier ses propriétés. par exemple, au lieu de :

// openGL 3.3
GLuint buffer;
glGenBuffers(1, &buffer);
glBindbuffer(GL_ARRAY_BUFFER, buffer);
glBufferData(GL_ARRAY_BUFFER, ... );

on peut écrire directement :

// openGL 4.5, direct state
GLuint buffer;
glCreateBuffers(1, &buffer);
glNamedBufferData(buffer, .... );

Reste aussi un problème plus fondamental, la gestion des ressources openGL, l'application ne fait que référencer les objets openGL, le contexte reste le propriétaire, et dernière subtilité, un contexte doit être initialisé pour créer et manipuler des objets openGL.

Mais dans tous les cas, il faut commencer par écrire quelques applications openGL pour comprendre comment le manipuler avant de se lancer dans un exercice d'abstraction / conception.

remarque : après quelques tps, Mesh::draw( ) devrait apparaitre clairement comme la solution simple et assez naive qu'elle est réellement.

exercice : pourquoi ? proposez une construction plus interressante, ou pour commencer : quelle est la principale limite, ou hypothèse de Mesh ?

exercice : uniformisez le nommage des fonctions de openGL 4.5 direct state en éliminant toutes les fonctions historiques et groupez les dans un namespace c++