II. Affichage de texte▲
Lors de certaines étapes de votre aventure graphique, vous souhaiterez afficher du texte avec OpenGL. Contrairement à ce que vous pourriez penser, afficher une simple chaîne de caractères à l’écran est assez difficile avec une bibliothèque de bas niveau comme OpenGL. S’il vous suffit d’afficher les 128 premiers caractères, ce n’est pas trop compliqué. Les choses se corsent dès que chaque caractère peut avoir ses propres largeur, hauteur et marge. Selon votre langue, vous pourrez avoir besoin de plus de 128 caractères, ou encore vouloir afficher des expressions mathématiques ou des partitions de musique, voire écrire de haut en bas. Si vous prenez tout cela en compte, cela ne vous surprendra pas qu’une API de bas niveau comme OpenGL ne propose pas ces fonctionnalités.
Puisqu’il n’existe pas dans OpenGL d’outils pour le texte, il nous appartient de définir un système pour afficher du texte à l’écran. Nous n’avons pas de primitive graphique pour les caractères, il va falloir être créatif. Voici quelques idées : tracer des lettres avec GL_LINES, créer des mailles 3D pour les lettres ou afficher des textures de caractères dans un environnement 3D.
Le plus souvent, les développeurs choisissent d’utiliser des textures pour afficher les caractères dans des rectangles 2D. Cela n’est pas trop difficile, mais obtenir les bons caractères sur une texture peut s’avérer délicat. Dans ce chapitre, nous verrons plusieurs méthodes et nous implémenterons une technique plus évoluée, mais aussi plus souple pour afficher du texte, avec la bibliothèque FreeType.
II-A. Affichage classique de texte : images de fontes▲
Auparavant, afficher du texte consistait à choisir une fonte (ou en créer une) pour votre application puis en extraire les caractères et les coller dans une seule grande texture. Une telle texture, que l’on peut appeler une image de fonte (bitmap font) contient tous les symboles de caractères dans des zones prédéfinies de la texture. Ces symboles sont appelés des glyphes. Chaque glyphe est positionné à un endroit précis de la texture. Quand vous souhaitez afficher un caractère, vous sélectionnez le glyphe correspondant en affichant cette partie de l’image de fonte dans un rectangle 2D.
Vous voyez ici comment afficher le texte « OpenGL » avec une telle fonte, en sélectionnant chacun des glyphes de la texture (au moyen de leurs coordonnées relatives à la texture), pour être affichés dans des petits rectangles. En activant le blending et avec un fond transparent, on obtient juste une chaîne affichée à l’écran. Cette fonte graphique a été générée avec le générateur de fonte de Codehead.
Cette approche a ses avantages et inconvénients. En premier lieu, c’est assez facile à mettre en œuvre et comme les images sont déjà prérendues, c’est assez performant. Cependant, ce n’est pas très souple. Si vous voulez changer de fonte, vous devez recompiler une fonte complète et de plus, ce système est limité à une seule résolution ; zoomer donnera très vite une image pixelisée. Enfin, on est limité à un petit ensemble de caractères et donc les caractères étendus ou Unicode ne sont pas envisageables.
Cette approche était (et reste) très courante, car elle est rapide et fonctionne sur toutes les machines, mais aujourd’hui des techniques plus souples existent. L’une d’entre elles consiste à charger des fontes TrueType grâce à la bibliothèque FreeType.
II-B. Affichage moderne de texte : FreeType▲
FreeType est une bibliothèque de développement logiciel capable de charger des fontes, de les afficher dans une image bitmap et d’offrir un support pour les opérations relatives à ces fontes. C’est une bibliothèque largement diffusée, utilisée dans Mac OS X, Java, les consoles PlayStation, Linux et Android pour ne citer que ceux-là. Ce qui rend FreeType très intéressante est sa capacité à charger les fontes TrueType.
Une fonte TrueType est un ensemble de glyphes de caractères, non pas définis par des pixels ni par toute sorte de solutions statiques, mais par des équations mathématiques (des combinaisons de splines). Comme des images vectorielles, les images d’une fonte peuvent être générées de la taille que l’on veut, permettant d’obtenir des glyphes de taille quelconque sans perte de qualité.
FreeType peut être téléchargée à partir de ce site web. Vous pouvez compiler la bibliothèque vous-même à partir de leur code source ou utiliser leurs bibliothèques précompilées si votre système cible est dans la liste. Assurez-vous de lier freetype.lib et que votre compilateur pourra trouver les fichiers d’en-tête.
Incluez les en-têtes prévus :
#include
<ft2build.h>
#include FT_FREETYPE_H
De la façon dont FreeType a été construite (du moins à l’heure où j’écris), vous ne pouvez pas placer les fichiers en-tête dans un nouveau répertoire, il faut les placer dans le répertoire racine de vos fichiers d’en-tête.
#include <FreeType/ft2build.h> causera sans doute de sérieux conflits.
FreeType charge ces fontes TrueType et pour chaque glyphe génère une image bitmap et calcule plusieurs tailles. On peut extraire ces images pour générer des textures et positionner chaque glyphe de caractère en bonne place selon les tailles choisies.
Pour charger une fonte, il suffit d’initialiser la bibliothèque FreeType et charger la fonte en tant que face, comme FreeType les nomme. Ici nous chargeons la fonte TrueType arial.ttf qui a été copiée depuis le répertoire Windows/Fonts.
FT_Library ft;
if
(FT_Init_FreeType(&
ft))
std::
cout <<
"ERROR::FREETYPE: Could not init FreeType Library"
<<
std::
endl;
FT_Face face;
if
(FT_New_Face(ft, "fonts/arial.ttf"
, 0
, &
face))
std::
cout <<
"ERROR::FREETYPE: Failed to load font"
<<
std::
endl;
Chacune de ces fonctions FreeType retourne un entier non nul si une erreur survient.
Après avoir chargé la face, on doit définir la taille que nous voulons extraire de cette face :
FT_Set_Pixel_Sizes(face, 0
, 48
);
La fonction détermine les paramètres de hauteur et largeur de la fonte. Si width est laissé à 0, la face calcule dynamiquement la largeur en fonction de la hauteur.
Une face FreeType contient un ensemble de glyphes. On peut choisir un de ces glyphes comme étant le glyphe courant en appelant FT_Load_Char(). Ici, on choisit le glyphe du caractère ‘X’ :
if
(FT_Load_Char(face, 'X'
, FT_LOAD_RENDER))
std::
cout <<
"ERROR::FREETYTPE: Failed to load Glyph"
<<
std::
endl;
En utilisant FT_LOAD_RENDER comme un des paramètres de chargement, nous demandons à FreeType de créer une image bitmap 8 bits en niveaux de gris, que nous pourrons accéder par face->glyph->bitmap.
Les glyphes que nous chargeons avec FreeType n’ont cependant pas la même taille (ce qui était le cas avec les images de fontes). L’image générée par FreeType est juste assez large pour contenir la partie visible du caractère. Par exemple, l’image du caractère ‘.’ est bien plus petite que celle du caractère ‘X’. Pour cette raison, FreeType charge aussi plusieurs tailles en spécifiant la largeur de chaque caractère et comment les positionner correctement. Ci-dessous une image FreeType qui montre toutes les dimensions calculées pour chaque glyphe.
Chaque glyphe est défini par rapport à une ligne horizontale (figurée par la flèche horizontale), certains sont placés exactement sur cette ligne de base (comme ‘X’), et d’autres sont légèrement en dessous (comme ‘g’ ou ‘p’). Ces dimensions définissent exactement les décalages pour placer chaque glyphe sur la ligne de base, la largeur de chaque glyphe, et combien de pixels sont nécessaires avant le glyphe suivant. Ci-dessous une courte liste des propriétés dont on aura besoin :
- width : la largeur (en pixels) de l’image, accessible par face->glyph->bitmap.width.
- height : la hauteur (en pixels) de l’image, accessible par face->glyph->bitmap.rows.
- bearingX : le repère gauche, c’est-à-dire la position horizontale (en pixels) de l’image relative à l’origine, accessible par face->glyph->bitmap_left.
- bearingY : l’œil, c’est-à-dire la position verticale (en pixels) de l’image relative à l’origine, accessible par face->glyph->bitmap_top.
- Advance : l’empattement, c’est-à-dire la distance horizontale (en 1/64e de pixels) de l’origine du caractère au caractère suivant, accessible par face->glyph->advance.x.
On pourrait charger un glyphe, retrouver ses dimensions et générer une texture à chaque fois que l’on voudrait afficher un caractère à l’écran, mais ce serait peu efficace de faire cela à chaque image. Il vaut mieux mémoriser les données quelque part et les reprendre à chaque affichage du caractère. Nous définissons une structure que nous mémorisons dans une map.
struct
Character {
GLuint TextureID; // Identificateur de la texture du glyphe
glm::
ivec2 Size; // Taille du glyphe
glm::
ivec2 Bearing; // Approche gauche et œil du glyphe
GLuint Advance; // Empattement du glyphe
}
;
std::
map<
GLchar, Character>
Characters;
Dans ce tutoriel, nous simplifions en nous limitant aux 128 premiers caractères de la table ASCII. Pour chaque caractère, nous générons une texture et mémorisons les données dans une structure que nous ajoutons à la table de hachage Characters. De cette façon, toutes les données nécessaires pour afficher chaque caractère seront disponibles pour un usage ultérieur.
glPixelStorei(GL_UNPACK_ALIGNMENT, 1
); // Désactiver la restriction d’alignement d’octets
for
(GLubyte c =
0
; c <
128
; c++
)
{
// Chargement du glyphe du caractère
if
(FT_Load_Char(face, c, FT_LOAD_RENDER))
{
std::
cout <<
"ERROR::FREETYTPE: Failed to load Glyph"
<<
std::
endl;
continue
;
}
// Générer la texture
GLuint texture;
glGenTextures(1
, &
texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D( GL_TEXTURE_2D,
0
,
GL_RED,
face→glyph→bitmap.width,
face→glyph→bitmap.rows,
0
,
GL_RED,
GL_UNSIGNED_BYTE,
face→glyph→bitmap.buffer);
// Fixer les options de la texture
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// Mémoriser le caractère pour un usage ultérieur
Character character =
{
texture,
glm::
ivec2(face->
glyph->
bitmap.width, face→glyph→bitmap.rows),
glm::
ivec2(face->
glyph->
bitmap_left, face→glyph→bitmap_top),
face→glyph→advance.x
}
;
Characters.insert(std::
pair<
GLchar, Character>
(c, character));
}
On boucle sur les 128 caractères de la table ASCII et on retrouve leur glyphe. Pour chaque caractère, on génère une texture, on positionne les options et les dimensions. À noter que l’on utilise GL_RED pour l’argument internalFormat de la texture, ainsi que pour l’argument format. L‘image générée à partir du glyphe est une image 8 bits en niveaux de gris où chaque couleur est représentée sur un octet. Pour cette raison, nous mémoriserons chaque octet de l’image comme la valeur d’une couleur de texture. Nous réalisons cela en créant une texture dans laquelle chaque octet correspond à la composante rouge (premier octet du vecteur couleur). Puisque nous n’utilisons qu’un seul octet pour représenter les couleurs de la texture, il faut prendre garde à une restriction d’OpenGL :
glPixelStorei(GL_UNPACK_ALIGNMENT, 1
);
OpenGL requiert que les textures aient une taille multiple de 4 octets (pour des raisons d’alignement). En principe ce n’est pas un problème puisque la plupart des textures ont une taille multiple de 4 et/ou utilisent 4 octets par pixel, mais puisque nous n’utilisons qu’un octet par pixel, elles peuvent avoir n’importe quelle taille. En paramétrant l’absence d’alignement à 1, nous assurons qu’il n’y aura pas de problème d’alignement (ce qui provoquerait des erreurs en mémoire).
Assurez-vous de supprimer les ressources FreeType quand vous avez fini de traiter les glyphes :
FT_Done_Face(face);
FT_Done_FreeType(ft);
II-B-1. Les shaders▲
Pour effectivement afficher les glyphes, nous utiliserons le vertex shader suivant :
#
version
330
core
layout
(
location =
0
) in
vec4
vertex; // <vec2 pos, vec2 tex>
out
vec2
TexCoords;
uniform
mat4
projection;
void
main
(
)
{
gl_Position
=
projection *
vec4
(
vertex.xy, 0
.0
, 1
.0
);
TexCoords =
vertex.zw;
}
Nous combinons la position et les cordonnées de texture dans un vec4. Le vertex shader multiplie les coordonnées par une matrice de projection et transmet les coordonnées de texture au fragment shader :
#
version
330
core
in
vec2
TexCoords;
out
vec4
color;
uniform
sampler2D
text;
uniform
vec3
textColor;
void
main
(
)
{
vec4
sampled =
vec4
(
1
.0
, 1
.0
, 1
.0
, texture
(
text, TexCoords).r);
color =
vec4
(
textColor, 1
.0
) *
sampled;
}
Le fragment shader utilise deux variables uniformes : l’une pour l’image du glyphe en niveaux de gris, l’autre est une couleur pour ajuster la couleur finale du texte. Nous échantillonnons d’abord la couleur de la texture. Cette couleur est codée sur la seule composante rouge, nous utilisons la composante r de la texture comme valeur de transparence. En jouant sur la valeur alpha de la couleur, la couleur résultante sera transparente pour la couleur du fond des glyphes et opaque pour la couleur des pixels du caractère. Nous multiplions aussi les couleurs RGB par la variable uniforme textColor pour la couleur du texte.
Il faut activer le blending pour que cela fonctionne :
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
Pour la matrice de projection, nous choisissons une projection orthogonale. Pour afficher du texte, nul besoin de perspective (en général) et l’utilisation d’une projection orthogonale nous permet de spécifier les coordonnées des sommets directement en coordonnées d’affichage si nous la définissons ainsi :
glm::
mat4 projection =
glm::
ortho(0.0
f, 800.0
f, 0.0
f, 600.0
f)
Nous fixons le bas de la projection à 0.0f et le haut égal à la hauteur de la fenêtre d’affichage. Par conséquent, nous spécifions les coordonnées avec des valeurs de y s’étendant du bas de la fenêtre (0.0f) jusqu’en haut (600.0f). Et donc le point (0.0, 0.0) correspond au coin en bas à gauche.
Il reste à créer un VBO et un VAO pour le rendu des rectangles. Pour l’instant, nous réservons assez de mémoire en initialisant le VBO de façon à pouvoir mettre à jour la mémoire du VBO lors du rendu des caractères.
GLuint VAO, VBO;
glGenVertexArrays(1
, &
VAO);
glGenBuffers(1
, &
VBO);
glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof
(GLfloat) *
6
*
4
, NULL
, GL_DYNAMIC_DRAW);
glEnableVertexAttribArray(0
);
glVertexAttribPointer(0
, 4
, GL_FLOAT, GL_FALSE, 4
*
sizeof
(GLfloat), 0
);
glBindBuffer(GL_ARRAY_BUFFER, 0
);
glBindVertexArray(0
);
Les rectangles 2D nécessitent 6 sommets de 4 réels chacun, on réserve ainsi 6*4 réels en mémoire. Étant donné que nous mettrons à jour très souvent le contenu du VBO, on alloue la mémoire avec GL_DYNAMIC_DRAW.
II-B-2. Afficher une ligne de texte▲
Pour afficher un caractère, on extrait la structure correspondante de la table de hachage Characters et on calcule les dimensions du rectangle en utilisant les dimensions du caractère. On peut ensuite générer dynamiquement un ensemble de six sommets que nous utiliserons pour rafraîchir le contenu de la mémoire gérée par le VBO avec glBufferSubData().
On crée une fonction RenderText() pour afficher une chaîne de caractères :
void
RenderText(Shader &
s, std::
string text, GLfloat x, GLfloat y, GLfloat scale, glm::
vec3 color)
{
// Activation du rendu
s.Use();
glUniform3f(glGetUniformLocation(s.Program, "textColor"
), color.x, color.y, color.z);
glActiveTexture(GL_TEXTURE0);
glBindVertexArray(VAO);
// Boucle sur tous les caractères
std::string::
const_iterator c;
for
(c =
text.begin(); c !=
text.end(); c++
)
{
Character ch =
Characters[*
c];
GLfloat xpos =
x +
ch.Bearing.x *
scale;
GLfloat ypos =
y -
(ch.Size.y -
ch.Bearing.y) *
scale;
GLfloat w =
ch.Size.x *
scale;
GLfloat h =
ch.Size.y *
scale;
// Mise à jour du VBO
GLfloat vertices[6
][4
] =
{
{
xpos, ypos +
h, 0.0
, 0.0
}
,
{
xpos, ypos, 0.0
, 1.0
}
,
{
xpos +
w, ypos, 1.0
, 1.0
}
,
{
xpos, ypos +
h, 0.0
, 0.0
}
,
{
xpos +
w, ypos, 1.0
, 1.0
}
,
{
xpos +
w, ypos +
h, 1.0
, 0.0
}
}
;
// Rendu du glyphe sur le rectangle
glBindTexture(GL_TEXTURE_2D, ch.textureID);
// Mise à jour de la mémoire du VBO
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferSubData(GL_ARRAY_BUFFER, 0
, sizeof
(vertices), vertices) ;
glBindBuffer(GL_ARRAY_BUFFER, 0
);
// Rendu du rectangle
glDrawArrays(GL_TRIANGLES, 0
, 6
);
// Avancer le curseur au glyphe suivant (noter que l’avance est calculée en 1/64e pixels)
x +=
(ch.Advance >>
6
) *
scale; // Décalage à droite de 6 pour obtenir la valeur en pixels
}
glBindVertexArray(0
);
glBindTexture(GL_TEXTURE_2D, 0
);
}
Le contenu de la fonction devrait parler de lui-même : nous calculons la position de l’origine du rectangle (xposyposwh et ) ainsi que sa taille ( et ) puis on génère un ensemble de six sommets pour former le rectangle ; noter que nous effectuons une mise à l’échelle avec scale. On met à jour le contenu du VBO et on affiche le rectangle.
Le code suivant mérite cependant un peu d’attention :
GLfloat ypos =
y -
(ch.Size.y -
ch.Bearing.y);
Certains caractères (comme ‘p’ ou ‘g’) sont affichés légèrement en dessous de la ligne de base, et le rectangle doit aussi être positionné légèrement décalé en y. La valeur de ce décalage de ypos peut être déterminée avec les dimensions du glyphe :
Cette distance, c’est-à-dire le décalage que l’on doit appliquer vers le bas, est figurée par la flèche rouge. Comme on le voit, cette distance se calcule comme la différence entre la hauteur du glyphe et la dimension bearingY. Cette valeur est nulle pour les caractères qui sont sur la ligne de base alors qu’elle est positive pour les caractères comme ‘g’ ou ‘j’.
Si vous avez tout exécuté correctement, vous devriez pouvoir afficher des chaînes de caractères avec les lignes suivantes :
RenderText(shader, "This is sample text"
, 25.0
f, 25.0
f, 1.0
f, glm::
vec3(0.5
, 0.8
f, 0.2
f));
RenderText(shader, "(C) LearnOpenGL.com"
, 540.0
f, 570.0
f, 0.5
f, glm::
vec3(0.3
, 0.7
f, 0.9
f));
Vous devriez obtenir l’image suivante :
Vous trouverez le code de cet exemple ici.
Pour vous donner une idée du calcul des sommets des rectangles, on peut désactiver le blending pour mieux voir leurs dimensions et leur position :
Ici, on voit bien que la plupart des rectangles reposent sur la ligne de base tandis que certains sont décalés vers le bas.
II-C. Pour aller plus loin▲
Ce chapitre montre une technique d’affichage de texte avec les fontes TrueType en utilisant la bibliothèque FreeType. Cette approche est souple, permet la mise à l’échelle, et fonctionne avec beaucoup de codes de caractères. Cependant, elle peut s’avérer excessive pour votre application, car elle requiert de générer et rendre une texture pour chaque glyphe.
Les images de fontes sont plus simples et rapides, car elles ne requièrent qu’une seule texture pour tous les caractères. La meilleure approche est de combiner les deux façons en générant dynamiquement une texture d’image de fonte représentant tous les glyphes de caractères chargés avec FreeType. Cela décharge le moteur de rendu de tous ces changements de texture et selon comment les glyphes sont rangés peut améliorer nettement les performances.
Une autre question avec les fontes FreeType vient de ce que les textures sont mémorisées avec une fonte de taille fixe et une mise à l’échelle importante conduit à des bords crénelés. De plus, les rotations appliquées aux glyphes les rendront flous. Cela peut être atténué : au lieu de mémoriser la couleur réelle des pixels, on mémorise la distance au contour le plus proche du glyphe pour chaque pixel. Cette technique est appelée « signed distance fields », et Valve a publié un article voilà quelques années sur sa mise en œuvre qui fonctionne très bien pour des applications de rendus 3D.
II-D. Remerciements▲
Ce tutoriel est une traduction réalisée par Jean-Michel Fray dont l’original a été écrit par Joey de Vries et qui est disponible sur le site Learn OpenGL.