Réalisation d'un moteur 3D en C++, partie VI : Gestion du texte / polices graphiques

Laurent Gomila

L'affichage de texte à l'écran est une fonctionnalité qui peut se révèler très utile pour la suite du développement : affichage d'informations diverses et de debugging, console, interface graphique, ...
Nous verrons donc dans cette partie une gestion efficace du texte via les polices graphiques.

- Titre : Réalisation d'un moteur 3D en C++, partie VI : Gestion du texte / polices graphiques

- Auteur : Laurent Gomila

- Parution : 25 août 2005

- Mise à jour : 1 septembre 2005

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2005 Laurent Gomila. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- 1. Introduction

- 2. Le gestionnaire de texte

- 3. Les polices graphiques

- 3-1. Les variantes

- 3-2. Chargement de polices

- 3-3. Rendu de texte

- 3-4. CGraphicString

- 4. Alpha-blending

- 4-1. Rappel

- 4-2. Mise en oeuvre

- 5. Unités de textures

- 6. Conclusion

- 7. Téléchargements

1 - Introduction

La première chose à préciser avant d'aborder ce tutoriel, est que cette partie du moteur repose en partie sur des fonctionnalités Windows. Impossible de faire autrement car on touche là à des fonctionnalités dépendantes du système ; ce tutoriel sera prochainement mis à jour avec la méthode correspondante pour les systèmes Unix.

Il existe bon nombre de méthodes pour afficher du texte à l'écran. Bien souvent, on est tenté d'utilisé des fonction GDI telles que SetTextColor, TextOut etc. En effet celle-ci sont très simples à utiliser, pour peu que les objets sur lesquels on veut dessiner puissent fournir un handle graphique (HDC), ce qui est le cas des textures OpenGL et DirectX. Mais en réalité ces fonctions sont très lentes : affichez plus de 100 caractères et votre application commencera déjà à ramer. La raison est que pour dessiner du texte sur une surface, on doit tout d'abord verrouiller celle-ci et ainsi bloquer complétement l'execution du programme. Le GDI est donc définitivement à éviter. Beaucoup de fonctions d'autres API sont basées sur des appels GDI, mais malheureusement on ne le sait pas toujours tant qu'on a pas constaté des performances médiocres. Par exemple, la classe D3DXFONT fournie avec DirectX utilise le GDI, et est donc tout aussi lente.

La seule et unique méthode efficace pour afficher du texte avec une API graphique utilise des polices graphiques (bitmap fonts). Le principe est simple : on crée une texture contenant tous les caractères d'une police donnée, puis on affiche notre texte sous forme de primitives comme tout le reste de notre géometrie. En pratique, nous allons rendre 1 quad par caractère à afficher, soit deux triangles. Cela peut vite faire grimper le compte de polygones me direz-vous, mais gardez en tête qu'au pire cela restera de l'ordre du millier, ce qui est une miette pour les cartes graphiques actuelles. Cette méthode est réellement la plus performante, car elle utilise les fonctions de rendu du hardware, et ne necessite pas de verrouillage de texture. En règle général, les méthodes utilisant au mieux le GPU seront toujours préférables aux solutions plus "software" utilisant le CPU et effectuant de nombreux transferts depuis ou vers la mémoire vidéo. La classe CD3DFont fournie avec le SDK de DirectX tire partie de la technique des polices graphiques. Un test interessant à effectuer est de comparer dans un même programme D3DXFONT et CD3DFont. En plus d'offrir beaucoup plus de possibilités, cette dernière se révèle vraiment plus performante pour afficher beaucoup de texte.

Vous l'aurez compris, c'est donc sur cette dernière technique que nous allons focaliser. Elle est certe un peu plus difficile à mettre en oeuvre que les techniques faisant appel à d'autres APIs, mais elle reste tout de même relativement simple à coder et permet vraiment d'offrir de nombreuses possibilités à l'utilisateur.
Pour gérer efficacement ces polices graphiques, nous aurons tout d'abord besoin d'un gestionnaire de texte (le FontManager), qui s'occupera en fait de tous les traitements relatifs à l'affichage de texte.
Nous verrons ensuite en détail comment générer automatiquement une texture à partir d'une police, et comment utiliser notre moteur pour afficher du texte à l'écran.
Enfin, nous construirons une abstraction à tout ça et une classe pratique et facile à utiliser pour représenter nos chaînes graphiques.

2 - Le gestionnaire de texte

Le gestionnaire de texte (classe CFontManager dans le moteur) devra, comme son nom l'indique, effectuer la gestion des polices graphiques et l'affichage du texte. Voyons plus en détail en quoi consisteront ces tâches :

- Comme tous les gestionnaires, le gestionnaire de texte devra être unique (singleton).

- Il devra charger les polices (ie. générer les textures automatiquement).

- Il devra assurer le stockage et gérer la durée de vie de ces polices. En d'autre termes, seul le gestionnaire de texte aura connaissance et accès aux polices graphiques.

- Il devra effectuer le rendu de chaînes de caractères à l'écran en utilisant les polices chargées.

- Il pourra enfin effectuer diverses tâches utiles en rapport avec les chaînes graphiques, comme par exemple calculer la longueur en pixels d'une chaîne de caractère.

Pour le stockage des polices, il va nous falloir une structure appropriée, ainsi que le conteneur qui va bien. Une police graphique n'est en fait rien d'autre qu'une texture, que l'on va accompagner de diverses informations selon la technique choisie. Dans notre moteur nous utilisons un pas fixe (N x N pixels pour chaque caractère), nous n'avons donc pas besoin de stocker la taille et la position de chaque caractère dans la texture. La taille des caractères (valeur de N) peut être retrouvée à partir des dimensions de la texture, donc rien à stocker non plus de ce côté. Par contre, chaque caractère aura une taille réelle qui sera inférieure au pas choisi (N), il nous faudra donc stocker cette taille pour avoir un affichage correct.
Voici donc la structure qui représentera une police graphique :

struct TFont
{
CTexture  Texture;
TVector2I CharSize[256];
};

Voyons maintenant comment stocker nos polices (les instances de cette structure). Une police ne devra être chargée qu'une et une seule fois, et sera associée à un identifiant (son nom). Ce qui nous amène donc naturellement à std::map :

typedef std::map<std::string, TFont> TFontsMap;
TFontsMap m_Fonts;

Ainsi pour accéder à une texture, par exemple "Arial", il nous suffira d'écrire m_Fonts["Arial"]. Cette écriture sera beaucoup utilisée, car rappelez-vous que seul le gestionnaire de texte aura connaissance des instances de TFont. En d'autres termes, partout ailleurs dans le moteur, nous stockerons directement le nom de la police, par exemple "Arial". C'est le gestionnaire qui fera la correspondance avec la texture grace à sa table associative m_Fonts. Une conséquence intéressante à cela est que si un jour nous voulons changer de technique et oublier les bitmaps fonts, il suffira de modifier le gestionnaire de texte, ce sera totalement invisible pour le reste du moteur.

Voici donc à quoi ressemblerait notre gestionnaire de texte pour le moment. Nous étudierons par la suite en détail les techniques associées à la gestion des polices graphiques, et ainsi remplir nos fonctions.

class YES_EXPORT CFontManager : public CSingleton<CFontManager>
{
friend class CSingleton<CFontManager>;

public :

// Charge une police de caractères
void LoadFont(const std::string& FontName, int Quality = 16);

// Décharge toutes les polices chargées
void UnloadFonts();

// Affiche une chaîne graphique à l'écran
void DrawString(const CGraphicString& String);

// Renvoie la taille en pixels d'une chaîne graphique
TVector2I GetStringPixelSize(const CGraphicString& String);

private :

// Structure des polices graphiques
struct TFont
{
CTexture  Texture;
TVector2I CharSize[256];
};

// Type du conteneur des fonts
typedef std::map<std::string, TFont> TFontsMap;

// Données membres
TFontsMap m_Fonts; ///< Map contenant les textures des polices, associées à leur nom
};

La classe CGraphicString sera vue en détail plus tard, pour l'instant notez simplement qu'elle contient tout ce qu'il faut pour afficher une chaîne graphique (texte, police, couleur, ...).

3 - Les polices graphiques

Entrons à présent plus en détail dans le principe des polices graphiques et le moyen de les charger / utiliser.
Comme indiqué en introduction, le principe des polices graphiques est de générer les différents caractères affichables dans une texture, puis de plaquer celle-ci sur des quads représentant les caractères à afficher.
Voici un exemple de police graphique, générée avec la police "Cheeseburger" :

(Les petits rectangles sont les caractères non imprimables)

Il existe plusieurs méthodes pour générer ces textures. La plus naïve et fastidieuse est de coller soi-même les caractères, un par un, avec un logiciel de dessin style Paint Shop Pro. Inutile de préciser que cela prend un temps fou.
Une autre solution est d'utiliser l'un des nombreux logiciels ou codes sources gratuits que l'on peut trouver sur le net, et qui génèreront pour nous les textures correspondant à nos polices. L'inconvénient est que nous serons limité par les possibilités du logiciel, et que nous ne pourrons récupérer aucune information supplémentaire à la texture (taille des caractères, etc...). Nous ne pourrons également pas intégrer la génération de texture à un moteur par exemple, ce sera à l'utilisateur de générer ses textures.
La dernière et meilleure solution est donc encore d'intégrer la génération automatique des textures au moteur. Ainsi nous pourrons personnaliser cette génération pour coller aux besoins du moteur, et cela ne demandera aucun boulot de préparation à l'utilisateur, si ce n'est d'avoir installé les polices qu'il souhaite utiliser.

3-1 - Les variantes

Il n'existe pas une unique manière d'utiliser les polices graphiques, en fait chaque application va faire son petit mix. Par exemple, certains vont générer les caractères à la suite et stocker dans un tableau la position et taille de chaque caractère pour les retrouver par la suite, alors que d'autres vont choisir un pas fixe (16x16 par exemple) et ainsi retrouver la position de chaque caractère simplement à l'aide de son code ASCII. On peut encore imaginer une autre manière de placer les caractères : le moteur 3D Irrlicht par exemple place un pixel jaune dans le coin haut-gauche de chaque caractère, et un pixel rouge au coin bas-droit. Bref chacun son truc, l'important est au final de s'y retrouver.
Ensuite on peut également trouver des variantes pour le gras, l'italique ou le souligné. Certains vont supprimer certains caractères (non imprimables, accentués, ...) et mettre deux versions de la police sur une même texture : une version standard et une version en gras, par exemple.

La méthode détaillé ici utilisera un pas fixe (cases de N x N), et stockera la taille réelle de chaque caractère dans un tableau. La "qualité" de chaque police sera reglable via le paramètre N : plus celui-ci sera élevé, plus la précision sera fine. Au détriment bien sûr des performances, puisque la texture générée sera d'autant plus grande. Un bon compromis est de générer des caractères de 16x16, ce qui donne des textures de 256x256.

3-2 - Chargement de polices

Nous allons donc générer automatiquement nos textures, ce qui évitera bien des soucis à nos utilisateurs. Mais comment allons-nous faire ? Souvenez-vous de l'introduction, qui commençait en présentant une technique simple mais peu performante pour dessiner du texte : le GDI. Nous allons l'utiliser pour générer nos textures, ce sera lent mais cela ne gêne pas : tout ceci se fera une et une seule fois pendant la phase de chargement. L'inconvénient est que le code résultant ne sera pas portable, celui présenté ici ne fonctionnera que sous Windows.

Notre fonction de génération aura besoin de quelques paramètres dont la qualité souhaitée pour la texture, ainsi que du nom de la police à charger. La taille de la texture pourra être directement déduite de la qualité (qui est en fait la taille de chaque caractère dans celle-ci).

void CFontManager::LoadFont(const std::string& FontName, int Quality)
{
// Si la police a déjà été chargée, on quitte
if (m_Fonts.find(FontName) != m_Fonts.end())
return;

// Calcul de la taille de la texture à générer
int TexSize = Quality * 16;

La première chose à faire est de vérifier que la texture n'a pas déjà été chargée, auquel cas nous pourrons quitter la fonction sans rien faire.
Si vous vous demandez pourquoi la taille de la texture est 16 fois la qualité, c'est en fait que nous allons dessiner 256 caractères, soit 16x16 pour avoir une texture bien carrée.

Pour travailler avec le GDI, nous allons avoir besoin d'un HDC (Handle to Device Context). En gros, un HDC est une zone mémoire contenant des informations, des objets graphiques, leurs attributs, tout cela ayant pour but de dessiner sur une surface. On pourra définir pour un HDC par exemple : un "pen" pour le tracé de lignes, un "brush" pour le remplissage de formes, un "bitmap" pour avoir une zone de dessin, une palette de couleurs, etc... Lorsque nous dessinerons avec un HDC, tous les objets qui lui sont associés seront automatiquement pris en compte et définiront les caracteristiques de notre dessin.

La création d'un HDC se fait très simplement à l'aide de la fonction CreateCompatibleDC :

HDC Hdc = CreateCompatibleDC(NULL);

N'oubliez pas de toujours tester les retours des fonctions ! Les tests d'erreur ne figurent pas dans ce tutoriel afin d'aller à l'essentiel, mais il est primordial de toujours vérifier qu'un appel de fonction a bien réussi, surtout avec ce genre d'API.

Pour utiliser cette fonction et celles qui suivent, n'oubliez pas d'inclure <Windows.h> et de lier avec la bibliothèque gdi32 (fait par défaut sous VC++) !

Maintenant que nous avons un HDC, nous allons créer et lui associer tous les objets dont nous aurons besoin pour dessiner notre police.
Le premier est l'objet bitmap, qui va définir une zone de pixels dans laquelle nous irons dessiner nos caractères :

BITMAPINFO BitmapInfo;
memset(&BitmapInfo, 0, sizeof(BITMAPINFO));
BitmapInfo.bmiHeader.biSize     = sizeof(BITMAPINFOHEADER);
BitmapInfo.bmiHeader.biWidth    = TexSize;
BitmapInfo.bmiHeader.biHeight   = TexSize;
BitmapInfo.bmiHeader.biBitCount = 24;
BitmapInfo.bmiHeader.biPlanes   = 1;

unsigned char* Data = NULL;
HBITMAP BitmapHandle = CreateDIBSection(Hdc, &BitmapInfo, DIB_RGB_COLORS,
reinterpret_cast<void**>(&Data), NULL, 0);

Les champs de la structure BITMAPINFO sont facilement compréhensibles : nous allons créer ici une bitmap dont les dimensions sont TexSize x TexSize, et dont les pixels seront codés sur 24 bits (RGB).
Le pointeur Data pointera après la création de la bitmap sur les pixels de celle-ci. Pratique pour récupérer ses pixels et en créer une texture plus tard.
L'objet bitmap que nous allons associer à notre HDC est de type HBITMAP et est crée par la fonction CreateDIBSection, qui va prendre en paramètre ce que nous avons pris soin de définir juste avant.

Vient ensuite la création de l'objet "font", qui représentera donc la police avec laquelle nous allons dessiner les caractères :

HFONT FontHandle = CreateFont(Quality, 0, 0, 0, FW_NORMAL, FALSE,
FALSE, FALSE, DEFAULT_CHARSET,
OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,
ANTIALIASED_QUALITY, DEFAULT_PITCH,
FontName.c_str());

La fonction CreateFont prend de nombreux paramètres (principalement pour ajuster les propriétés visuelles de la police), si vous n'êtes pas familiers avec ceux-ci vous pourrez trouver leur description détaillée dans la MSDN.

Ces deux objets (bitmap et font) seront les seuls dont nous aurons besoin pour dessiner notre police. Il ne reste plus qu'à les associer à notre HDC :

SelectObject(Hdc, BitmapHandle);
SelectObject(Hdc, FontHandle);

La fonction SelectObject sert, comme vous l'aurez compris, à associer un objet à un HDC. Elle détermine automatiquement le type de l'objet passé en paramètre (font, bitmap, pen, brush, ...) et va mettre celui-ci au bon endroit dans notre HDC. Si un objet s'y trouvait déjà il sera remplacé ; d'ailleurs dans ce cas, le HDC de l'objet remplacé sera renvoyé par la fonction, au cas où vous voudriez le récupérer. Ainsi même si vous associez plusieurs polices à un HDC seule la dernière sera prise en compte, par exemple.

Avant de démarrer l'affichage des caractères, il reste à définir quelques attributs de notre HDC, notamment les couleurs d'écriture :

SetBkColor(Hdc, RGB(0, 0, 0));
SetTextColor(Hdc, RGB(255, 255, 255));

Rien de mystique ici : couleur de dessin blanche, couleur d'arrière-plan noire. Nous aurons donc du texte blanc sur fond noir.

Nous avons maintenant tout ce qu'il faut pour afficher du texte sur notre bitmap, et nous pouvons effectuer le dessin de notre police :

// Tableau servant à stocker la taille de chaque caractère
TVector2I CharSize[256];

char Character = 0;
for (int j = 0; j < 16; ++j)
for (int i = 0; i < 16; ++i, ++Character)
{
// Stockage de la taille du caractère
SIZE Size;
GetTextExtentPoint32(Hdc, &Character, 1, &Size);
CharSize[i + j * 16].x = Size.cx;
CharSize[i + j * 16].y = Size.cy;

// Affichage du caractère
RECT Rect = {i * Quality, j * Quality, (i + 1) * Quality, (j + 1) * Quality};
DrawText(Hdc, &Character, 1, &Rect, DT_LEFT);
}

Nous commençons par parcourir tous les caractères, 16 par ligne et 16 par colonne dans notre texture. Pour chaque, nous récupérons sa taille réelle à l'aide de la fonction GetTextExtentPoint32, puis nous l'affichons avec la fonction DrawText. Comme prévu rien de bien sorcier, avec le GDI et de manière générale avec les APIs Win32 il existe toujours la fonction qui va bien pour réaliser directement la tâche qui nous interesse.
Note : il est important de dessiner les caractères dans l'ordre ASCII, de manière à pouvoir retrouver leur position facilement par la suite.

A ce stade, nous avons une zone de pixels remplie avec nos caractères. La prochaine étape est donc de créer un CImage de taille et de format adéquat, pour y copier nos pixels. Qui plus est, notre bitmap a la tête à l'envers, il faudra la retourner.

// Création d'une image contenant les pixels de la bitmap
// on est obligé de copier les pixels un à un pour ajouter la composante alpha
CImage ImgFont(TVector2I(TexSize, TexSize), PXF_A8L8);
for (int i = 0; i < TexSize; ++i)
for (int j = 0; j < TexSize; ++j)
{
unsigned char* CurPixel = Data + (i + j * TexSize) * 3;
ImgFont.SetPixel(i, j, CColor(CurPixel[0], CurPixel[1], CurPixel[2], CurPixel[0]));
}
ImgFont.Flip();

La composante alpha que nous devons ajouter à la main représente la transparence. En l'occurence, toute la partie noire de la texture (le fond) doit être transparente (alpha = 0) si nous ne voulons pas la voir apparaître lors du rendu.

Nous avons maintenant collé nos pixels dans une image, et nous n'avons plus besoin à ce stade du GDI. Il faut donc bien penser à détruire tout ce que nous avons créé, en l'occurence le HDC et ses objets :

DeleteObject(FontHandle);
DeleteObject(BitmapHandle);
DeleteDC(Hdc);

Toujours détruire les objets avant le HDC auquel ils sont associés !

Dernière étape, nous allons créer et stocker une instance de TFont, puis la remplir correctement avec ce que nous venons de dessiner / calculer. Comme notre texture sera monochrome avec un canal alpha, le format PXF_A8L8 sera parfait et nous fera gagner un peu en place et en performances. De plus, ce genre de texture étant destiné à un affichage 2D il est inutile de lui faire calculer ses niveaux de mipmapping ; ce que l'on fait avec le flag TEX_NOMIPMAP.

// Création de la texture
m_Fonts[FontName].Texture.CreateFromImage(ImgFont, PXF_A8L8, TEX_NOMIPMAP, FontName);

// Copie de la taille des caractères
std::copy(CharSize, CharSize + 256, m_Fonts[FontName].CharSize);
}

Voilà qui clôt notre fonction LoadFont, nous avons à présent dans m_Fonts[FontName] une police graphique correctement chargée et prête à servir pour afficher du texte ! C'est cette tâche que nous allons donc maintenant étudier en détail.

3-3 - Rendu de texte

Notre texte étant affiché comme n'importe quel autre objet 3D, il lui faudra tout le tsoin-tsoin utile au rendu de géometrie. A savoir un vertex buffer, un index buffer, et la déclaration de vertex associée à tout ça.

void CFontManager::Initialize()
{
// Création de la déclaration de vertex
TDeclarationElement Decl[] =
{
{0, ELT_USAGE_POSITION,  ELT_TYPE_FLOAT2},
{0, ELT_USAGE_DIFFUSE,   ELT_TYPE_COLOR},
{0, ELT_USAGE_TEXCOORD0, ELT_TYPE_FLOAT2}
};
m_Declaration = Renderer.CreateVertexDeclaration(Decl);

// Création du vertex buffer
m_VertexBuffer = Renderer.CreateVertexBuffer<TVertex>(NbCharMax * 4, BUF_DYNAMIC);

// Création de l'index buffer
std::vector<TIndex> Indices;
for (TIndex i = 0; i < NbCharMax; ++i)
{
Indices.push_back(i * 4 + 0);
Indices.push_back(i * 4 + 1);
Indices.push_back(i * 4 + 2);
Indices.push_back(i * 4 + 2);
Indices.push_back(i * 4 + 1);
Indices.push_back(i * 4 + 3);
}
m_IndexBuffer = Renderer.CreateIndexBuffer((int)Indices.size(), 0, &Indices[0]);
}

Rien de bizarre dans la déclaration de vertex : ceux-ci auront besoin d'une position 2D, d'une couleur diffuse, et d'une paire de coordonnées de texture.

Le vertex buffer quant à lui nécessite un peu plus d'explications. Comme la géometrie à afficher (le texte) changera sans cesse, on ne peut ni prévoir la taille du buffer ni le remplir une bonne fois pour toute. On crée donc un buffer de taille arbitraire (en l'occurence, faire une série de tests pour trouver la taille optimale n'est pas une mauvaise idée), et on indique que celui-ci sera dynamique via le flag BUF_DYNAMIC. Ce flag sera par la suite interprété par le renderer et permettra d'optimiser la création selon l'utilisation que l'on va faire du buffer.

L'index buffer peut par contre ne pas être dynamique et être rempli une bonne fois pour toute : comme il s'agit de rendre des quads, les indices peuvent être précalculés.

Cette fonction ne devra être appelée qu'une seule fois et avant tout appel à DrawString, à vous de trouver le moment opportun parmi les diverses initialisations de votre moteur. Ici, on place un test permettant d'appeler Initialize lors du premier appel à DrawString.

Une fois nos buffers prêts à recevoir du triangle, nous pouvons nous intéresser au rendu du texte en lui-même.

void CFontManager::DrawString(const CGraphicString& String)
{
// Si le texte est vide, on quitte
if (String.Text == "")
return;

// Si la partie rendering n'a pas été initialisée, on le fait
if (!m_Declaration)
Initialize();

// Si la police n'a pas été chargée, on le fait
if (m_Fonts.find(String.Font) == m_Fonts.end())
LoadFont(String.Font);

// Variables intermédiaires utilisées pour les calculs
float         Ratio   = String.Size * 16.0f / CurFont.Texture.GetSize().x;
float         Off     = 0.5f / CurFont.Texture.GetSize().x;
float         TexUnit = 1.0f / 16.0f;
float         x       = static_cast<float>(String.Position.x);
float         y       = static_cast<float>(String.Position.y);
unsigned long Color   = Renderer.ConvertColor(String.Color);

// Verrouillage du vertex buffer
TVertex* Vertices = m_VertexBuffer.Lock(0, 0, LOCK_WRITEONLY);

// Parcours de la chaîne de caractères
int NbChars = 0;
for (std::string::const_iterator i = String.Text.begin();
(i != String.Text.end()) && (NbChars < NbCharMax);
++i)
{

On commence par quelques tests de routine : chaîne vide (dans ce cas on peut quitter), polices non chargée, ...
Puis on calcule quelques variables intermédiaires, histoire d'éviter les calculs inutiles dans la boucle de parcours ; celles-ci sont expliquées au fur et à mesure. On touche ici à une fonction pouvant être executée des milliers de fois par seconde, il est important d'optimiser tout ce qui peut l'être.
Ensuite on verrouille le vertex buffer, afin de pouvoir le remplir par la suite avec nos quads. Là encore, on place le flag qui va bien (LOCK_WRITEONLY, puisqu'on fait un accès en écriture uniquement) afin de permettre au renderer d'optimiser encore un poil. On parcours ensuite la chaîne caractère par caractère, en stoppant bien sûr si l'on dépasse la limite de caractère fixée.

// Conversion du caractère en unsigned
// (pour gérer correctement les caractères ASCII étendus (code > 127))
unsigned char c = *i;

// Gestion des caractères spéciaux
switch (c)
{
// Saut de ligne
case '\n' :
x  = static_cast<float>(String.Position.x);
y += CurFont.CharSize['\n'].y * Ratio;
continue;

// Retour au début de ligne
case '\r' :
x = static_cast<float>(String.Position.x);
continue;

// Tabulation horizontale
case '\t' :
x += 4 * CurFont.CharSize[' '].x * Ratio;
continue;

// Tabulation verticale
case '\v' :
y += 4 * CurFont.CharSize['\n'].y * Ratio;
continue;

// Espace
case ' ' :
x += CurFont.CharSize[' '].x * Ratio;
continue;
}

La première chose à faire dans notre boucle est de gérer les caractères spéciaux, en l'occurence les caractères invisibles. Inutile de s'encombrer de calculs et de triangles pour des caractères qui ne sont pas affichables. Ainsi, pour les caractères de ce genre (tabulations, saut de ligne, ...) on incrémente simplement la position d'écriture courante et on passe au caractère suivant sans rien faire d'autre.

// On va pointer sur les vertices du quad courant
TVertex* V = Vertices + NbChars * 4;

// Position
V[0].Position.Set(x,               y);
V[1].Position.Set(x + String.Size, y);
V[2].Position.Set(x,               y + String.Size);
V[3].Position.Set(x + String.Size, y + String.Size);

// Couleur
V[0].Diffuse = Color;
V[1].Diffuse = Color;
V[2].Diffuse = Color;
V[3].Diffuse = Color;

// Coordonnées de texture
V[0].TexCoords.Set(TexUnit * ((c % 16) + 0) + Off, TexUnit * ((c / 16) + 0) + Off);
V[1].TexCoords.Set(TexUnit * ((c % 16) + 1) - Off, TexUnit * ((c / 16) + 0) + Off);
V[2].TexCoords.Set(TexUnit * ((c % 16) + 0) + Off, TexUnit * ((c / 16) + 1) - Off);
V[3].TexCoords.Set(TexUnit * ((c % 16) + 1) - Off, TexUnit * ((c / 16) + 1) - Off);

// Incrémentation de la position et du nombre de caractères
x += CurFont.CharSize[c].x * Ratio;
++NbChars;
}

Vien ensuite le code exécuté pour les caractères "normaux". Il s'agit de créer un quad à la position courante, de la couleur de la chaîne, et ayant pour coordonnées de texture la position du caractère dans la bitmap font. Le calcul est simple : c % 16 pour retrouver X, et c / 16 pour retrouver Y. Le décalage infime d'un demi pixel (Off) est une technique couramment utilisée en 2D : cela permet de ne pas avoir de débordement des pixels avoisinants, ce qui pourrait se produire avec le filtrage de texture par exemple.
Finalement, on décale la position courante de la largeur du caractère que l'on vient de traiter, et on peut passer au suivant. On n'oublie pas également de maintenir à jour le nombre de caractères dans le buffer.

// Déverrouillage du vertex buffer
m_VertexBuffer.Unlock();

Une fois tous les caractères traités et les quads créés, on peut déverrouiller le buffer et rendre la main au GPU. Il est important de limiter au maximum le temps de verrouillage, car celui-ci bloque l'execution de la carte graphique et casse le parallèlisme GPU / CPU.

// Paramètrage du rendu
Renderer.SetupAlphaBlending(BLEND_SRCALPHA, BLEND_INVSRCALPHA);
Renderer.SetupTextureUnit(0, TXO_COLOR_MODULATE, TXA_TEXTURE, TXA_DIFFUSE);
Renderer.SetupTextureUnit(0, TXO_ALPHA_MODULATE, TXA_TEXTURE, TXA_DIFFUSE);
Renderer.Enable(RENDER_ALPHABLEND, true);
Renderer.Enable(RENDER_ZWRITE, false);

// Affichage du texte
Renderer.SetDeclaration(m_Declaration);
Renderer.SetTexture(0, CurFont.Texture.GetTexture());
Renderer.SetVertexBuffer(0, m_VertexBuffer);
Renderer.SetIndexBuffer(m_IndexBuffer);
Renderer.DrawIndexedPrimitives(PT_TRIANGLELIST, 0, NbChars * 2);

// Rétablissement des options de rendu
Renderer.Enable(RENDER_ZWRITE, true);
Renderer.Enable(RENDER_ALPHABLEND, false);
}

Enfin, on paramètre le rendu et on envoie au renderer la géometrie fraîchement créée afin qu'il l'affiche. Le paramètrage des options de rendu consiste en trois choses : activer l'alpha-blending (la transparence), régler correctement le mixage des unités de texture, et désactiver l'écriture dans le Z-Buffer (cela poserait problème de la laisser car tous les quads ont la même profondeur : 0). Tout ceci est davantage expliqué et développé dans les sections 4 et 5.

3-4 - CGraphicString

Comme vous l'avez vu, le rendu d'une chaîne nécessite plusieurs paramètres. Couleur, police, taille, ... et bien entendu le texte en lui-même. Avec toutes ces propriétés nous allons ainsi créer une classe représentant les chaînes graphiques : CGraphicString. Son rôle est extrêmement simple : rassembler toutes les propriétés d'une chaîne graphique au sein d'une même classe, et offrir les quelques fonctionnalités sympathiques que l'on est en droit d'attendre d'une classe de chaînes graphiques.

Nous venons de le voir, elle possèdera donc tout d'abord les propriétés définissant une chaîne graphique : position à l'écran, texte, couleur, police de caractères, et taille. Avec bien sûr un constructeur pour initialiser tout cela correctement.

class YES_EXPORT CGraphicString
{
public :

// Constructeur par défaut
CGraphicString(const TVector2I& StringPosition = TVector2I(0, 0),
const std::string& StringText = "",
const CColor& StringColor = CColor::Black,
const std::string& StringFont = "Arial",
int StringSize = 16);

// Données membres
TVector2I   Position; ///< Position du texte à l'écran
std::string Text;     ///< Chaîne de caractères
CColor      Color;    ///< Couleur du texte
std::string Font;     ///< Police de caractères
int         Size;     ///< Taille du texte

Ici tout est en accès publique : les chaînes graphiques n'ont rien à cacher. Plus sérieusement, les données membres seront accédées aussi bien en lecture qu'en écriture, et ceci sans aucun invariant à vérifier ou traitement spécial à effectuer. Inutile donc de cacher tout cela au fin fond de la classe et de la blinder d'accesseurs ; notre code source n'en sera que plus clair, et les instances de la classe beaucoup plus faciles à manipuler.

Viennent ensuite les fonctions "utiles" :

// Affiche la chaîne de caractères à l'écran
void Draw() const;

// Renvoie la taille du texte en pixels
TVector2I GetPixelSize() const;

// Modifie l'alignement du texte
void Align(unsigned long Mode, const CRectangle& Rect);
};

Bien entendu il faut une fonction Draw chargée d'afficher la chaîne ; celle-ci ne faisant que relayer l'appel à CFontManager. GetPixelSize ne fait elle aussi que déléguer le boulot à sa petite soeur de chez CFontManager. Comme vous le voyez rien de bien méchant, simplement des facilités pour avoir au final du code plus simple et plus clair.

La fonction Align, comme son nom l'indique, sert à aligner visuellement le texte. Pas indispensable, mais plutôt pratique comme fonction. Elle prend en paramètre une combinaison de flags indiquant comment aligner, et un rectangle définissant la zone d'alignement. L'implémentation est relativement simple, cela se résume à quelques calculs de longueurs :

void CGraphicString::Align(unsigned long Mode, const CRectangle& Rect)
{
// Calcul de la taille en pixels
TVector2I PSize = GetPixelSize();

// Alignement horizontal
if (Mode & ALIGN_RIGHT)
{
Position.x = Rect.Right() - PSize.x;
}
else if (Mode & ALIGN_HCENTER)
{
Position.x = Rect.Left() + (Rect.Width() - PSize.x) / 2;
}
else // par défaut : ALIGN_LEFT
{
Position.x = Rect.Left();
}

// Alignement vertical
if (Mode & ALIGN_BOTTOM)
{
Position.y = Rect.Bottom() - PSize.y;
}
else if (Mode & ALIGN_VCENTER)
{
Position.y = Rect.Top() + (Rect.Height() - PSize.y) / 2;
}
else // par défaut : ALIGN_TOP
{
Position.y = Rect.Top();
}
}

4 - Alpha-blending

4-1 - Rappel

L'alpha-blending (mélange alpha) est une technique aujourd'hui largement répandue, mais voici tout de même un rapide rappel pour ceux qui n'en auraient jamais entendu parler.

En gros, c'est un terme qui désigne la manière dont les pixels écrits sur le color-buffer vont se mélanger avec ceux déjà présents. Par défaut, lorsque l'alpha-blending est inactif, les nouveaux pixels vont simplement venir écraser les anciens. Par contre si l'on active l'alpha-blending on peut jouer sur ce comportement : addition, modulation, transparence, ...

En fin de compte, l'alpha-blending n'est qu'une équation :

CouleurFinale = CouleurSource * FacteurSource + CouleurDest * FacteurDest

CouleurSource est la couleur du pixel que l'on est en train d'écrire, et CouleurDest est la couleur du pixel déjà présent dans le color-buffer, sur lequel on va écrire. Les facteurs sont quant à eux des coefficients qui vont déterminer comment ces deux couleurs vont être mélangées. Voici les facteurs utilisés dans le moteur, et qui sont les plus courants :

enum TBlend
{
BLEND_SRCALPHA,     ///< Transparence source
BLEND_INVSRCALPHA,  ///< Inverse de la transparence source
BLEND_DESTALPHA,    ///< Transparence de destination
BLEND_INVDESTALPHA, ///< Inverse de la transparence de destination
BLEND_SRCCOLOR,     ///< Couleur source
BLEND_INVSRCCOLOR,  ///< Inverse de la couleur source
BLEND_DESTCOLOR,    ///< Couleur de destination
BLEND_INVDESTCOLOR, ///< Inverse de la couleur de destination
BLEND_ONE,          ///< Un
BLEND_ZERO          ///< Zéro
};

Avec cette panoplie de facteurs, on peut utiliser l'alpha-blending pour toute sorte d'applications :

- SRCALPHA et INVSRCALPHA = paramètrage habituel, pour rendre transparente la source selon son alpha

- ONE et ONE = addition des couleurs, utile par exemple pour les générateurs de particules

- ZERO et ONE = désactivation de l'écriture dans le color-buffer

- ONE et ZERO = alpha-blending désactivé (oui, cela n'a aucun intérêt)

- ZERO et SRCCOLOR = modulation (multiplication) des deux couleurs

- ...

4-2 - Mise en oeuvre

Afin de paramètrer l'alpha-blending (plus précisément les facteurs de mélange), nous allons doter notre renderer de la fonction qui va bien :

virtual void SetupAlphaBlending(TBlend Src, TBlend Dest) const = 0;

Les APIs 3D étant bien prévues pour ce genre de fonctionnalité, les spécilialisations ne sont guères compliquées ; il suffit de faire la correspondance entre nos constantes maison et les constantes propres à l'API. Comme à l'accoutumée, ce sont nos classes magiques CxxxEnum qui vont s'en charger :

void CDX9Renderer::SetupAlphaBlending(TBlend Src, TBlend Dest) const
{
m_Device->SetRenderState(D3DRS_SRCBLEND,  CDX9Enum::Get(Src));
m_Device->SetRenderState(D3DRS_DESTBLEND, CDX9Enum::Get(Dest));
}

void COGLRenderer::SetupAlphaBlending(TBlend Src, TBlend Dest) const
{
glBlendFunc(COGLEnum::Get(Src), COGLEnum::Get(Dest));
}

L'activation (ou la désactivation) de l'alpha-blending sera quant à elle effectuée via Enable, qui sert justement à ça : activer ou désactiver les paramètres qui peuvent l'être. Ca tombe bien.

virtual void Enable(TRenderParameter Param, bool Value) const = 0;

Voici ce que l'on aurait obtenu sans alpha-blending :

Et la même chose en l'activant :

S'il ne s'agit que de ne pas afficher les pixels ayant un alpha nul, on peut également utiliser l'alpha-test au lieu de l'alpha-blending. L'alpha-test ne sert qu'à cela : il élimine du rendu tout pixel ayant un alpha inférieur à un certain seuil (paramètrable).

5 - Unités de textures

De la même manière que l'alpha-blending, il est courant d'avoir à jouer avec les unités de texture dans ses applis 3D. Notamment lorsqu'on utilise le multi-texturing. Cela permet également de jouer sur le pixel final, mais d'une autre manière. Là où l'alpha-blending permettait de parametrer le mélange entre le pixel source et le pixel destination, les paramètres des unités de texture vont nous permettre de choisir la ou les sources de couleur qui vont être prises en compte pour un pixel donné. Cela ne vous parle pas ? Prenons un petit exemple.

Imaginez un triangle sur lequel on a collé :
- Deux ou trois textures
- Une couleur diffuse
- Une couleur spéculaire
- Du bump-mapping
- ...

Maintenant, comment dire à notre bienaimée API 3D qu'il faut utiliser le canal alpha de la seconde texture avec la couleur de la seconde, puis moduler tout cela avec la couleur diffuse, appliquer la couleur spéculaire, et ... Vous voyez le topo.

Maintenant que les choses sont plus claires, rentrons un peu plus dans les détails. Qu'allons nous exactement paramètrer ? Tout d'abord, les règlages que nous allons faire porteront toujours sur une seule unité de texture à la fois. Pour cette unité, nous pourrons choisir : deux sources (opérandes) de couleur, une opération définissant la manière de les mixer, et même chose pour la couche alpha (2 opérandes + 1 opérateur).

Les opérandes (sources de couleur ou d'alpha) peuvent être, par exemple, les suivantes :

enum TTextureArg
{
TXA_DIFFUSE,  ///< Couleur diffuse
TXA_TEXTURE,  ///< Texture
TXA_PREVIOUS, ///< Résultat de l'unité de texture précédente
TXA_CONSTANT  ///< Constante
};

Quant aux opérateurs, voici les plus fréquents :

enum TTextureOp
{
TXO_COLOR_FIRSTARG, ///< Sélection du premier paramètre de couleur
TXO_COLOR_ADD,      ///< Addition entre les deux paramètres de couleur
TXO_COLOR_MODULATE, ///< Modulation entre les deux paramètres de couleur
TXO_ALPHA_FIRSTARG, ///< Sélection du premier paramètre de transparence
TXO_ALPHA_ADD,      ///< Addition entre les deux paramètres de transparence
TXO_ALPHA_MODULATE  ///< Modulation entre les deux paramètres de transparence
};

Afin de faire une bonne tambouille avec tous ces paramètres, nous allons construire dans notre renderer la fonction qui va bien et qui va tout faire en un seul appel :

virtual void SetupTextureUnit(unsigned int Unit, TTextureOp Op, TTextureArg Arg1,
TTextureArg Arg2 = TXA_DIFFUSE,
const CColor& Constant = 0x00) const = 0;

Même combat que pour l'alpha-blending, comme il s'agit de fonctionnalités de base des APIs troidé, les spécialisations sont relativement simples à écrire. Il y a juste un peu plus d'appels de fonctions.

void CDX9Renderer::SetupTextureUnit(unsigned int Unit, TTextureOp Op,
TTextureArg Arg1, TTextureArg Arg2,
const CColor& Constant) const
{
// Opérateur et arguments
if (Op < TXO_ALPHA_FIRSTARG)
{
m_Device->SetTextureStageState(Unit, D3DTSS_COLOROP,   CDX9Enum::Get(Op));
m_Device->SetTextureStageState(Unit, D3DTSS_COLORARG1, CDX9Enum::Get(Arg1));
m_Device->SetTextureStageState(Unit, D3DTSS_COLORARG2, CDX9Enum::Get(Arg2));
}
else
{
m_Device->SetTextureStageState(Unit, D3DTSS_ALPHAOP,   CDX9Enum::Get(Op));
m_Device->SetTextureStageState(Unit, D3DTSS_ALPHAARG1, CDX9Enum::Get(Arg1));
m_Device->SetTextureStageState(Unit, D3DTSS_ALPHAARG2, CDX9Enum::Get(Arg2));
}

// Constante
if ((Arg1 == TXA_CONSTANT) || (Arg2 == TXA_CONSTANT))
{
m_Device->SetRenderState(D3DRS_TEXTUREFACTOR, ConvertColor(Constant));
}
}

void COGLRenderer::SetupTextureUnit(unsigned int Unit, TTextureOp Op,
TTextureArg Arg1, TTextureArg Arg2,
const CColor& Constant) const
{
// Activation de l'unité de texture à paramétrer
glEnable(GL_TEXTURE_2D);
glActiveTextureARB(GL_TEXTURE0_ARB + Unit);
glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_COMBINE_EXT);

// Opérateur et arguments
if (Op < TXO_ALPHA_FIRSTARG)
{
glTexEnvi(GL_TEXTURE_ENV, GL_COMBINE_RGB_EXT, COGLEnum::Get(Op));
glTexEnvi(GL_TEXTURE_ENV, GL_SOURCE0_RGB_EXT, COGLEnum::Get(Arg1));
glTexEnvi(GL_TEXTURE_ENV, GL_SOURCE1_RGB_EXT, COGLEnum::Get(Arg2));
glTexEnvi(GL_TEXTURE_ENV, GL_OPERAND0_RGB_EXT, GL_SRC_COLOR);
glTexEnvi(GL_TEXTURE_ENV, GL_OPERAND1_RGB_EXT, GL_SRC_COLOR);
}
else
{
glTexEnvi(GL_TEXTURE_ENV, GL_COMBINE_ALPHA_EXT, COGLEnum::Get(Op));
glTexEnvi(GL_TEXTURE_ENV, GL_SOURCE0_ALPHA_EXT, COGLEnum::Get(Arg1));
glTexEnvi(GL_TEXTURE_ENV, GL_SOURCE1_ALPHA_EXT, COGLEnum::Get(Arg2));
glTexEnvi(GL_TEXTURE_ENV, GL_OPERAND0_ALPHA_EXT, GL_SRC_ALPHA);
glTexEnvi(GL_TEXTURE_ENV, GL_OPERAND1_ALPHA_EXT, GL_SRC_ALPHA);
}

// Constante
if ((Arg1 == TXA_CONSTANT) || (Arg2 == TXA_CONSTANT))
{
float Color[4];
Constant.ToFloat(Color);
glTexEnvfv(GL_TEXTURE_ENV, GL_TEXTURE_ENV_COLOR, Color);
}
}

Pour avoir une description détaillée des fonctions et des paramètres disponibles (car ils sont nombreux), n'oubliez pas de lire et relire votre documentation de référence préférée (aide du SDK pour DirectX, Red / Blue book pour OpenGL) !

Revenons à présent à ce qui nous intéresse : le paramètrage pour afficher correctement nos polices graphiques. Ici pas encore de multi-texturing, mais nous souhaitons tout de même choisir correctement les sources de couleur et d'alpha. En l'occurence, la couleur définie par l'utilisateur sera aussi importante que la texture, autant au niveau de la couleur que de l'alpha. Nous allons donc dire à notre renderer de prendre ces deux sources et de les moduler :

Renderer.SetupTextureUnit(0, TXO_COLOR_MODULATE, TXA_TEXTURE, TXA_DIFFUSE);
Renderer.SetupTextureUnit(0, TXO_ALPHA_MODULATE, TXA_TEXTURE, TXA_DIFFUSE);

Et voilou les loulous. Voici ce que l'on obtiendrait sans avoir paramètré les unités de texture :

Et la même chose en paramètrant correctement les unités de texture :

6 - Conclusion

Les polices graphiques sont finalement une excellente technique. Elles représentent la seule solution à offrir des performances acceptables pour l'affichage de texte, et sont relativement simples à implémenter. Le désavantage le plus certain est qu'il faut quelques efforts supplémentaires pour générer les textures (ainsi que du code non portable), mais cela reste au final bien plus pratique et agréable pour l'utilisateur.

Autre avantage de taille : comme elles utilisent un rendu 3D classique, on peut leur appliquer toute sorte d'effets tels que l'alpha-blending, le multi-texturing, les shaders, etc. Ainsi rien ne nous empêche de mettre sur pied un mécanisme de rendu de texte très puissant, et de construire toute sorte de fonctionnalités selon le besoin.

7 - Téléchargements

Les sources fournies dans les précédents tutoriels sont entièrement intégrées à celles-ci, ainsi qu'une petite démo reprenant tous les concepts vus, de manière à ce que vous ayiez toujours un package complet et à jour.

Les codes sources livrés tout au long de cette série d'articles ont été réalisé sous Visual Studio.NET 2003 ; des test ont également été effectués avec succès sur DevC++ 4.9.9.1. Quant aux autres compilateurs vous ne devriez pas rencontrer de problème (pour peu qu'ils soient suffisamment récents), le code respectant autant que possible le standard.

Télécharger les sources de cet article (zip, 1.90 Mo)

Screenshot de l'application exemple

Pour compiler, vous aurez également besoin des bibliothèques externes (zip)

Une version PDF de cet article est disponible : Télécharger (348 Ko)

Si vous avez des suggestions, remarques, critiques, si vous avez remarqué une erreur, ou bien si vous souhaitez des informations complémentaires, n'hésitez pas à me contacter !