Une introduction à l'OpenGL "Moderne" - Chapitre 2.1: Les Buffers Et Les Textures

Sommaire
:longBar:

Dans la partie précédente, nous avions une fenêtre ouverte attendant les instructions qui afficheront notre programme "Hello World".

Mais avant de dessiner quoi que ce soit, nous allons avoir besoin d'envoyer des données à OpenGL en créant des "objets" de différents types.

Voyons ensemble les "objets" que nous allons avoir besoin de configurer.

:longBar:
Le pipeline revisité

gl2-pipeline-01.png

Revoir le pipeline graphique (cf chapitre précédent), en gardant à l'esprit notre programme "hello world", va nous permettre de définir clairement quels sont les "objets" dont nous allons avoir besoins.

En commençant par l'entrée, notre vertex array contiendra quatre vertices, que le vertex shader assignera aux coins de la fenêtre.

L'element array assemblera ces quatres vertices en deux triangles, se qui nous donnera un rectangle qui viendra couvrir notre fenêtre.

Nous fabriquerons des objets de type buffer (buffer objects) qui contiendront ces arrays dans la mémoire du GPU.

Notre "uniform state" (l'ensemble de paramètres/variables qui ne change pas durant le rendu) sera composé de deux images "Hello" et d'un "fade factor" pour les mélanger.

Chacune de ses images aura sa propre texture object.

En plus de mapper nos vertices aux coins de l'écran, le vertex shader assignera un set de coordonnées de texture à chaque vertex, mappant les vertices aux coins (de la texture) correspondant.

Le rasterizer interpolera ensuite ces coordonnées de texture le long de la surface du rectangle (Rappelez vous) . Une fois cela fait, notre fragment shader "samplera" les deux textures et les mélangera en utilisant le "fade factor".

Pour "connecter" les shaders à OpenGL, nous allons créer un "objet" de type program qui liera le vextex shader et le fragment shader.

Dans cet article, nous allons configurer le buffer et les textures. La prochaine fois, nous travaillerons sur les shaders.

:longBar:
Le typage C dans OpenGL

OpenGL dispose de son propre set de typedef qui sont en fait les équivalents GL* des typedefs C:

  • GLubyte
  • GLbyte
  • GLushort
  • GLshort
  • GLuint
  • GLint
  • GLfloat
  • GLdouble

Chacun correspondant à son type C comme vous vous en doutez.

En plus de ces types de base, OpenGL fournit des typedefs additionnels avec un sens plus sémantique:

  • GLchar*, utilisé par les fonctions qui gèrent les strings. C'est un pointeur vers une string ASCII "null-terminated".
  • GLclampf et GLclampd, typdedefs pour GLfloat et GLdouble utilisé quand les valeurs attendues doivent être comprise entre zéro et un.
  • GLsizei, un typedef integer utilisé pour récupérer/conserver la taille d'un memory buffer, équivalent à size_t de la bibliothèque standard C.
  • GLboolean, un typedef pour GLbyte prévu pour contenir un GL_TRUE ou GL_FALSE, similaire au bool du C++ ou C99
  • GLenum, un typedef de GLuint prévu pour contenir une constante GL_* prédéfini.
  • GLbitfield, un autre typedef de GLuint prévu pour contenir l'opérateur OR d'un masque ou plus de GL_*_BIT
:longBar:
Stocker nos données
static struct {
    GLuint vertex_buffer, element_buffer;
    GLuint textures[2];
 
    /* fields for shader objects ... */
} g_resources;

Une structure globale comme g_ressources est la manière la plus simple de partager des données entre notre code d'initialisation et notre contexte GLUT.

OpenGL utilise des valeurs GLunint pour la manipulation des objets.

Notre structure g_ressources contient deux variables GLuint que nous utiliserons pour conserver le nom de nos vertex buffer et element buffer. Ainsi qu'un array de deux GLuint pour nos deux textures.

Nous continuerons d'ajouter des variables à notre structure au fil des shaders que nous coderons dans la prochaine partie.

:longBar:
Le modèle objet d'OpenGL

La convention d'OpenGL pour manipuler les objets et un peut inhabituel.

Vous créez des objets en générant un ou plusieurs noms d'objet. Pour cela, vous utilisez une fonction glGen*s (exemple: glGenBuffers, glGenTextures, etc...).

Comme mentionné plus haut, ses noms sont des variables de type GLuint. N'importe quelles données fournit ou associé à l'objet est géré en interne par OpenGL.

Cette façon de faire est assez classique.

En revanche, la façon d'utiliser ses noms est inhabituelle:

Pour manipuler un objet, vous devez d'abord lier son nom à une cible OpenGL définie (on appelle ça le target binding), en appelant la fonction glBind* correspondante (exemple: glBindBuffer, glBindTexture, etc...).

Ensuite, vous passez cette cible à une fonction OpenGL en tant qu'argument, ce qui set ses propriétés ou charge des données dans l'objet attaché.

Les targets bindings affectent également les fonctions OpenGL qui ne prennent pas explicitement les dites cibles en argument, comme nous le verrons lorsque nous parlerons du rendu.

Pour l'instant, voyons comment tout ça se comporte quand nous construisons des buffer objects:

:longBar:
Les "buffer objects"
static GLuint make_buffer(
    GLenum target,
    const void *buffer_data,
    GLsizei buffer_size
) {
    GLuint buffer;
    glGenBuffers(1, &buffer);
    glBindBuffer(target, buffer);
    glBufferData(target, buffer_size, buffer_data, GL_STATIC_DRAW);
    return buffer;
}

Les buffers sont des pointeurs vers de la mémoire OpenGL.

En d'autres termes, ils sont utilisés pour stocker des vertex arrays (en utilisant la cible GL_ARRAY_BUFFER) et des element array(en utilisant la cible GL_ELEMENT_ARRAY_BUFFER)

Quand vous allouez un buffer avec glBufferData, vous précisez un "usage hint" (une "intention d'usage" en français, GL_STATIC_DRAW dans notre cas), qui indique combien de fois vous avez l'intention d'accéder et de modifier les données du buffer, et OpenGL choisi, entre la mémoire CPU ou celle du GPU, quel est le meilleur endroit pour stocker ces informations.

Le "hint" (l'intention) ne contraint en rien la façon dont le buffer est utilisé, mais utiliser les buffers d'une façon différente de celle précisé (via le "hint"), entraînera une perte de performance.

Pour notre programme, nous avons des vertex arrays et element arrays constant qui ne change jamais, donc nous donnons à glBufferData l'argument (le hint) GL_STATIC_DRAW.

Le mot STATIC indique que nous ne comptons jamais changer les données.

Les buffers peuvent également être "hinted" en:

  • DYNAMIC: Qui indique que nous comptons écrire dans le buffer fréquemment.
  • STREAM: Qui indique que nous comptons régulièrement remplacer entièrement le contenu du buffer.

Le mot DRAW indique que nous comptons lire le buffer uniquement depuis le GPU.

Les alternatives à DRAW sont:

  • READ, qui défini un buffer qui sera principalement lu par le CPU.
  • COPY, qui indique que le buffer sera un "intermédiaire" entre le CPU et le GPU et qu'aucune préférence n'est défini.

Les buffers de vertex array et d'element array utiliseront principalement le "hint" (l'intention) GL_*_DRAW.

La fonction glBufferData considère vos variables de la même façon que memcpy: Juste un bête flux d'octets.

Nous ne préciserons la structure de nos arrays à OpenGL qu'une fois qu'on s'en servira pour le rendu.

Ceci permet aux buffers de stocker des vertex attributes (et autres données) dans n'importe quel format, ou bien d'envoyer ces données d'une façon différente aux render jobs.

static const GLfloat g_vertex_buffer_data[] = { 
    -1.0f, -1.0f,
     1.0f, -1.0f,
    -1.0f,  1.0f,
     1.0f,  1.0f
};
static const GLushort g_element_buffer_data[] = { 0, 1, 2, 3 };

Ici, nous ne faisont que définir les coins de notre rectangle en tant que "set" de quatre vecteurs, composé de deux composant chacun.

gl2-vertex-array-01.pngNotre element array est également simple, un tableau de GLushorts servant à définir l'ordre des index des vertex de manière à ce qu'ils puissent être assemblé en rectangle sous la forme de "triangle strip" (voir chapitre précédent).

Sur les implémentations OpenGL standard, un element array peut être de type GLubyte (8bits), GLushort (16bits) ou GLuint (32bits); Pour OpenGL ES, seul GLubyte et GLushort peuvent être utilisés.

Nous allons maintenant remplir notre fonction make_ressources d'appels à make_buffer qui alloueront et remplieront nos buffers:

static int make_resources(void)
{
    g_resources.vertex_buffer = make_buffer(
        GL_ARRAY_BUFFER,
        g_vertex_buffer_data,
        sizeof(g_vertex_buffer_data)
    );
    g_resources.element_buffer = make_buffer(
        GL_ELEMENT_ARRAY_BUFFER,
        g_element_buffer_data,
        sizeof(g_element_buffer_data)
    );
    /* make textures and shaders ... */
}
:longBar:
Les "texture objects"
static GLuint make_texture(const char *filename)
{
    GLuint texture;
    int width, height;
    void *pixels = read_tga(filename, &width, &height);
 
    if (!pixels)
        return 0;

Comme mentionné dans la partie précédente, j'utilise le format TGA pour stocker nos images "hello world".

Je ne perdrais pas de temps à expliquer le code de parsing; Il est dans util.c dans le repo Github si vous voulez le voir.

Les données des pixels d'un TGA sont stocké sous forme de tableaux simples, non compressé, de pixels. Chaque pixel ayant trois valeurs: RGB (En fait, stocké dans l'ordre BGR). Chacune de ses valeur est composé d'un octets (2^8 = 256 valeurs possible). L'ordre des pixels commence du bas à gauche de l'image et avance vers la droite, en montant.

Ce format est parfait pour être utilisé conjointement avec les textures OpenGL, comme nous allons le voir tout de suite.

Si la lecture de l'image échoue, on renvoie zéro, qui est le code d'un "objet null" et ne sera jamais utilisé par un vrai objet OpenGL.

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

Les texture objects sont des pointeurs sur des tableaux de la mémoire GPU spécialisé dans le stockage des textures.

OpenGL supporte plusieurs types de textures, chacun dispose de son propre flag:

  • 1d (GL_TEXTURE_1D)
  • 2d (GL_TEXTURE_2D)
  • 3d (GL_TEXTURE_3D)

Il y a d'autres types de textures, plus spécialisé, dont nous parlerons peut être plus tard.

Les textures 2d sont de loin les plus couramment utilisé.

Ici, nous générons une GL_TEXTURE_2D pour une de nos images.

Les texture objects n'ont pas grand chose à voir avec les buffer objects dans la mesure ou le GPU gère la mémoire des textures d'une manière très différente de celle des buffers.

:longBar:
Le sampling et les paramètres des textures

gl2-texcoords-01.pngBien que les éléments des vertex arrays soit utilisé par le vertex shader un par un (et il n'y a aucun moyen, pour le vertex shader, d’accéder à d'autres éléments que ceux dont il dispose), les vertex shaders et fragments shaders peuvent accéder au contenu d'une texture quand il le souhaite.

Les shaders "samplent" (échantillonnent/prennent une partie de) la texture à un point de coordonné de la texture.

Les éléments du texture array sont répartis uniformément dans le texture space ("espace texture" en français) qui est en fait un carré couvrant les coordonnées (0, 0) (1, 1) (Ou une ligne (0, 1) pour une texture 1d, ou un cube (0, 0, 0) (1, 1, 1) pour une texture 3d).

Pour les distinguer des coordonnées x, y, z de l'object space, OpenGL nomme ses axes du texture space s, t, et r.

Le carré du texture space est divisé uniformément, le long de ses axes, en cellules rectangulaires, correspondant à la largeur et la hauteur de la texture array.

La cellule (0, 0) renvoie au premier élément du texture array, et les autres éléments sont répartis dans les cellules suivantes, le long de l'axe s et t.

"Sampler" la texture au centre d'une de ces cellules renvoie l'élément du texture array correspondant.

Note that the t axis can be thought of as increasing either upward or downward (or in any direction, really), depending on the representation of the underlying array.

Je n'ai pas réussi à traduire. Si vous avez une idée n'hésitez pas :baffed:

The other axes of texture space are similarly arbitrary.

Idem... Désolé... :pasClasse:

Dans la mesure ou les images TGA stockent leurs pixels de gauche à droite et de bas en haut, c'est de cette façon que je représenterai les axes.

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S,     GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T,     GL_CLAMP_TO_EDGE);

La manière dont le sampling se comporte quand une texture est samplé:

  • entre les centres des cellules voisines
  • à l’extérieur du texture space (aux coordonnées (-0.1, -0.1) par exemple)

est contrôlé par des paramètres de texture qui sont setté par la fonction glTexParameteri.

Les paramètres GL_TEXTURE_MIN_FILTER et GL_TEXTURE_MAG_FILTER contrôlent respectivement comment les points samplés "entre deux" sont traités quand la texture est samplé à une résolution inférieure ou supérieur que la résolution de l'écran.

gl2-texture-filter-01.pngNous settons ces paramètres sur GL_LINEAR pour dire au GPU d'utiliser une interpolation linéaire (ce qui mélange les quatre éléments les plus proches du point samplé).

Si l'utilisateur redimensionne notre fenêtre, la texture s'étirera en douceur.

Setter le filtre à GL_NEAREST dirait au GPU de renvoyer le texture element le plus proche du point samplé. Ce qui pixeliserai notre texture (effet Playstation).

Les paramètres GL_TEXTURE_WRAP_S et GL_TEXTURE_WRAP_T contrôlent de quelle façon les bords extérieurs de la texture sont considérés.

Dans la mesure ou nous ne prévoyions pas de d'aller chercher des informations à l’extérieur des champs S et T (0-1), nous utilisons GL_CLAMP_TO_EDGE, qui ramène les coordonnées en dessous de zéro à zéro et au-dessus de un à un. (Comme si les pixels du bord se repetaient)

5_627_d022a97d643b682.jpg

Un exemple d'utilisation de GL_CLAMP_TO_EDGE. Sur cette image, on voit que les pixels des bords de la texture sont répétés

Si nous avions appliqué la valeur GL_WRAP à un ou aux deux axes, c'est la texture entière qui aurait été répété le long des axes du bord.

Si on pouvait résumer, je dirais que le "texture sampling" (l'échantillionnage de texture) ressemble à un tableau d'indexation 2d dans lequel on peut récupérer des valeurs entre les entrées.

Tout cela prendra plus de sens si on regarde de quelle manière le fragment shader sample la texture:

gl2-texture-rasterization-01.png

Dans notre vertex shader, nous allons assigner les coins de nos coordonnées de texture aux vertex du rectangle.

  • Quand la taille rasterisé du rectangle est la même que celle de la texture (Ce qui arrive quand votre fenêtre est de la même résolution que l'image), le centre des fragments (les petites croix sur le schéma) correspond au centre des cellules des textures (les petits cercles). Ainsi, le fragment shader va sampler les images au pixel près, comme vous pouvez le voir sur le schéma de gauche.
  • Si la taille rasterisé du rectangle n'est pas strictement égale à la taille de la texture (99,9% des cas), chaque fragment samplera entre plusieurs cellules et le filtrage linéaire va s'assurer que nous ayons un dégradé lisse entre les textures elements (pixels), comme le montre le schéma de droite.
:longBar:
Allouer les textures
glTexImage2D(
        GL_TEXTURE_2D, 0,           /* target, level of detail */
        GL_RGB8,                    /* internal format */
        width, height, 0,           /* width, height, border */
        GL_BGR, GL_UNSIGNED_BYTE,   /* external format, type */
        pixels                      /* pixels */
    );
    free(pixels);
    return texture;
}

La fonction glTexImage2D (ou *1D/*3D) alloue de la mémoire pour une texture.

Les textures peuvent avoir plusieurs niveaux de détails (Voir le mipmapping) quand elles sont samplées à une faible résolution. Mais dans notre cas, nous fournirons uniquement le niveau de base: Zéro.

A l'inverse de glBufferData, la fonction glTexImage2D nécessite que toutes les informations de format soit défini pour allouer la mémoire.

Le "format interne" précise au GPU le nombre et la précision des composants de couleur à stocker par texture element.

OpenGL supporte toutes sortes de format d'image; Je ne ferai allusion qu'à ceux dont on va se servir.

Nos images TGA sont de type RGB 24bits. En d'autres mots elles contiennent trois composants (de 8bits chacun) par pixel.

Ce qui correspond au format interne GL_RGB8.

Le width et le height permettent de définir le nombre de texture elements (pixels) le long des axes S et T. (L'argument "border" n'est plus utilisé et doit toujours être à zéro).

Le "format externe" ainsi que le type définissent l'ordre des composants et le type de pixel fournit par l’argument "pixels" (qui lui, pointe sur une texture de taille width x height et du même format que celui donné).

Le format TGA stock les composants de ces pixels (de type unsigned byte) au format BGR. Donc nous utilisons GL_BGR pour le format externe et GL_UNSIGNED_BYTE pour le type des composants de l'image.

Maintenant que nous avons fini avec la fonction make_texture, ajoutons la à notre fonction make_resources pour créer des textures objects:

static int make_resources(void)
{
    /* ... make buffers */
    g_resources.textures[0] = make_texture("hello1.tga");
    g_resources.textures[1] = make_texture("hello2.tga");
 
    if (g_resources.textures[0] == 0 || g_resources.textures[1] == 0)
        return 0;
    /* make shaders ... */
}
:longBar:
Partie suivante: Les shaders

Nous avons maintenant nos vertex et images prêts à partir pour le pipeline graphique.

La prochaine étape de ce tutorial va être d'écrire les shaders qui manipuleront ses données au travers du GPU et les afficheront à l'écran.

C'est ce que nous verrons dans la prochaine partie de ce chapitre.

Vous pouvez retrouver l'article original sur la page de Joe Groff, une fois de plus: Merci à lui!