Réalisation d'un moteur 3D en C++ - partie III : les vertex / index buffers

Laurent Gomila

Notre quête du premier polygone touche à sa fin : nous verrons dans cette partie comment créer, stocker puis afficher des triangles. En d'autres termes nous nous interesserons aux vertex et index buffers, ainsi qu'à un concept qui vous est peut-être étranger : les déclarations de vertex.

- Titre : Réalisation d'un moteur 3D en C++ - partie III : les vertex / index buffers

- Auteur : Laurent Gomila

- Parution : 21 décembre 2004

- 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 ® 2004 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. Les vertex et index buffers

- 2-1. Théorie

- 2-1-1. Les vertices

- 2-1-2. Les indices

- 2-2. Implémentation

- 2-2-1. Côté moteur

- 2-2-2. Côté API

- 3. Les déclarations de vertex

- 4. Affichage des triangles

- 5. Exemple d'utilisation

- 6. Conclusion

- 7. Téléchargements

- 8. Remerciements

1 - Introduction

Nous avons précédemment vu comment encapsuler nos API 3D en DLL, puis comment y accéder au travers d'une classe de base IRenderer. Cependant il nous faudra créer d'autres classes pour encapsuler totalement nos API et avoir une couche de rendu complète, comme les shaders, les textures ou encore les buffers. Dans cette partie nous verrons ce que sont les vertex buffers et leurs amis les index buffers, à quoi ils servent et surtout comment les intégrer à notre moteur. Nous détaillerons également un concept plus ou moins nouveau à DirectX, et inconnu à OpenGL : les déclarations de vertex.

Allez hop !

2 - Les vertex et index buffers

2-1 - Théorie

Ce chapitre revient en détail sur la notion de sommets et d'indices, ainsi que sur les buffers permettant de les manipuler. Si vous êtes déjà familier avec tout ça, vous pouvez si vous le souhaitez sauter au paragraphe suivant, qui explique la conception et l'intégration au moteur des classes correspondantes.

2-1-1 - Les vertices

Les vertices (pluriel de vertex, ou encore sommet en français) sont la base du rendu 3D polygonal, on peut les imaginer comme de simples points 3D. Ce sont eux qui nous permettront de construire les primitives de base (points, lignes, triangles), qui à leur tour nous permettront de construire des modèles plus complexes (maison, arbre, ...) puis finalement notre scène 3D toute entière. Prenons l'exemple classique d'un cube en 3D. Celui-ci se compose de 6 faces carrées, soit en fait 12 triangles acollés deux à deux, puisque le carré ne fait pas partie de nos primitives de base. Ces 12 triangles peuvent à leur tour être décomposés en triplets de 3 sommets, soit 36 vertices. Ainsi, pour afficher notre cube 3D à l'écran, il suffira de stocker nos 36 vertices dans l'ordre approprié, puis de demander à notre API de les prendre par groupes de trois pour former et afficher nos triangles, et ainsi notre fameux cube.

On le devine aisément, pour afficher nos triangles la carte 3D aura besoin de notre tableau de vertices. Selon l'API utilisée, il existe différents moyens d'envoyer nos vertices au hardware.
Sous DirectX il n'en existe que deux : soit sous forme de vertex buffer, soit directement avec un pointeur sur un tableau classique, auquel cas c'est l'API qui se chargera de construire un vertex buffer interne à chaque envoi. Vous l'aurez deviné, il vaut donc mieux utiliser directement un vertex buffer sous peine de dégrader sérieusement les performances.
OpenGL est lui un peu plus varié : on peut envoyer nos vertices directement un à un (très peu efficace), sous forme de display lists (permet à l'API d'optimiser un peu), les stocker au préalable dans des vertex arrays (plus efficace), ou en utilisant des vertex buffer objects (ce qu'on appelle communément VBO). Cette dernière approche est celle qui se rapproche le plus de nos VB de chez DirectX, et est sans doute la plus performante également. Ce sont donc sur les VBO que notre implémentation OpenGL des vertex buffers va se baser.

Dans la suite de cet article, les abréviations VB et IB seront simplement des raccourcis pour "vertex buffer" et "index buffer".

Nous parlons sans cesse de vertex buffers depuis le début de cet article, mais qu'est-ce donc exactement ? Et bien rien d'autre que ce que leur nom indique : des zones mémoires contenant des sommets. Mais attention, les vertex buffers ne sont pas des zones mémoires comme les autres, et il faudra les manipuler d'une manière bien particulière. La principale raison à cela est que ce sera l'API 3D qui gèrera de manière efficace nos buffers, souvent directement en mémoire vidéo, c'est pourquoi nous ne pourrons pas faire n'importe quoi en les manipulant. Globalement, nous ne pourrons accéder à de tels buffers qu'en les ayant au préalable verrouillés, pour signaler à l'API que nous sommes en train de farfouiller dedans. Une fois que nous aurons fini nos manipulations il faudra les déverrouiller, et rendre ainsi la main à l'API. Bref vous l'aurez compris, la manipulation des vertex buffers est délicate. Si votre ordinateur reboot subitement lors de l'affichage de votre magnifique mouton 3D ou bien vous affiche un écran bleu, ne vous étonnez pas, c'est que vous avez mis le boxon en mémoire vidéo ! Bon mais rassurez-vous, la plupart du temps nous ne toucherons plus à nos buffers une fois remplis.

Mais revenons donc à nos mout... euh vertices. On a dit qu'on pouvait les voir comme de simples points 3D, ce qui est en fait incomplet. Les vertices doivent bien posséder une position, mais ils peuvent également stocker d'autres attributs de notre sommet : sa normale, sa couleur, ses coordonnées de texture, voire même n'importe quelles données persos. Ainsi il n'existera pas un unique type de vertex dans notre moteur, mais toute une multitude. Chaque objet possédant de la géometrie (en gros, tout ce qui s'affiche à l'écran) pourra avoir sa propre structure de vertices, selon les informations qu'il aura besoin de stocker. Par exemple nous n'allons pas stocker la normale d'un vertex pour un affichage 2D, ou encore des coordonnées de texture pour un objet non-texturé. Mais rassurez-vous, le vertex buffer ne se soucie pas de ce genre de considération. Tout ce qu'il aura à faire sera stocker nos vertices, peu importe ce qu'on leur mettra dedans. Pour renseigner l'API sur le contenu effectif de nos vertices, nous utiliserons un autre concept : les déclarations de vertices. Mais nous n'en sommes pas encore là, voyons à présent plutôt les copains des vertices : les indices.

2-1-2 - Les indices

Dans le rendu troidé, les indices (pluriel d'index, ou encore indice en français ...bon ok c'était facile là) vont souvent de paire avec les vertices. Pour illustrer leur utilité, reprenons l'exemple de notre cube. Celui-ci est composé de 12 triangles, soit 36 vertices donc. Mais si on fait le compte, seuls 8 sommets suffisent à définir un cube, chacun sera donc dupliqué un certain nombre de fois avec l'approche précédente. Pas génialement efficace n'est-ce pas ? Les indices sont là pour pallier à ce problème : ils permettent d'utiliser un même vertex dans plusieurs triangles sans dupliquer celui-ci dans le vertex buffer. Ainsi notre VB ne sera plus composé que de 8 vertices différents, et c'est maintenant notre index buffer qui comportera 36 éléments. Les indices n'étant rien de plus que des entiers (16 ou 32 bits), le gain est non-négligeable. Il faut toujours garder en tête que la mémoire vidéo est limitée, et que les transferts entre celle-ci et la mémoire centrale le sont aussi. A noter également que l'utilisation des indices permet de tirer partie du cache vertex de notre carte 3D, c'est-à-dire qu'un même vertex utilisé dans plusieurs triangles ne sera transformé qu'une seule fois, ce qui constitue encore une belle économie.
Le principe de fonctionnement des indices est très simple : plutôt que de prendre les vertices dans leur ordre d'apparition, la carte les prendra dans l'ordre dans lequel ils apparaissent dans l'index buffer. Par exemple si notre VB contient les vertices [V0, V1, V2, V3] et que notre IB contient les indices [0, 1, 2, 2, 1, 3], les 2 triangles résultant seront formés des vertices (V0, V1, V2) et (V2, V1, V3).

Au niveau du fonctionnement, on peut assimilier les index buffers aux vertex buffers. Leur gestion sera à peu de choses près identique, à savoir qu'il faudra les verrouiller / déverrouiller pour accéder à leurs éléments. Cette constatation est importante pour la suite, car cela va notamment nous permettre d'utiliser une seule et même classe pour nos VB et nos IB. Voyons dès à présent comment intégrer ces classes à notre moteur, et comment réaliser efficacement la spécialisation pour chaque API.

2-2 - Implémentation

2-2-1 - Côté moteur

La première chose à faire pour tirer un code commun à toutes les API, est d'examiner le fonctionnement de celles-ci. Pour les buffers, la tâche sera heureusement aisée : nos VB/IB DirectX et nos VBO OpenGL se manipulent plus ou moins de la même manière, à savoir comme dit précédemment via verrouillage / déverrouillage. Cette petite constatation effectuée, nous pouvons mettre sur pied une classe de base abstraite pour les buffers de notre moteur:

Classe de base pour nos buffers

class YES_EXPORT IBufferBase
{
public :

// Constructeur prenant en paramètre la taille du buffer
IBufferBase(unsigned long Count);

// Destructeur virtuel - nous allons utiliser cette classe polymorphiquement
virtual ~IBufferBase();

// Verrouille le buffer
virtual void* Lock(unsigned long Offset, unsigned long Size, unsigned long Flags) = 0;

// Déverouille le buffer
virtual void Unlock() = 0;

// Données membres
unsigned long m_Count; // Nombre d'éléments du buffer
};

La taille des buffers étant fixée à leur création et irrécupérable par la suite (ce sont des contraintes liées aux API 3D), il convient donc de passer cette taille au constructeur de nos IBufferBase et de la stocker en donnée membre. La fonction Lock va servir à verrouiller nos buffers, nous pourrons spécifier en paramètre à partir d'où et quelle taille verrouiller. Nous pourrons également passer des flags pour "personnaliser" le type de verrouillage, en gros ce ne sera que le reflet des différents flags disponibles dans les API 3D (read only, write only, ...). Enfin, Unlock servira à déverrouiller nos buffers.
Vous aurez peut-être remarqué ce void* renvoyé par Lock, et vous vous demandez ce qu'il signifie. Notre fonction de verrouillage va en fait retourner un pointeur vers la zone mémoire verrouillée, mais comme le type des éléments stockés est a priori inconnu (int ou short pour les indices, structures persos pour les sommets), on ne peut rien renvoyer de plus précis qu'un void*. Cependant rappelez-vous, nous faisons ici du C++ le plus propre possible, et les manipulations de void* sont à bannir à tout prix. Pourquoi ? Tout simplement parce qu'on peut écrire ou lire n'importe quoi avec du void*, et plus particulièrement autre chose que ce qui est réellement pointé, ce qui mène immanquablement à un plantage. Nous verrons plus tard comment nous en débarasser.

Maintenant que nous avons une classe de base pour nos buffers, il faut la dériver pour chaque API. A ce stade nous pouvons faire une nouvelle (et très interessante) constatation : que ce soit avec DirectX ou OpenGL, ce seront exactement les mêmes fonctions qui nous serviront à créer / détruire / verrouiller / déverrouiller nos buffers, VB ou IB. Pour DirectX seul le type du buffer change (IDirect3DVertexBuffer9 pour les VB et IDirect3DIndexBuffer9 pour les IB), pour OpenGL ce sera la même chose au détail près que ce sera cette fois sous forme de constante (GL_ARRAY_BUFFER_ARB pour les VB et GL_ELEMENT_ARRAY_BUFFER_ARB pour les IB), le type du buffer étant lui toujours un simple identifiant (unsigned int). N'oubliez pas qu'OpenGL n'est absolument pas orienté objet contrairement à son acolyte DirectX.

Tout ceci nous ménèra donc pour DirectX à une classe template prenant en paramètre le type de buffer :

Spécialisation DirectX9 des buffers

template <class T>
class CDX9Buffer : public IBufferBase
{
public :

// Constructeur prenant en paramètre la taille et le pointeur sur le buffer
CDX9Buffer(unsigned long Count, T* Buffer);

// Destructeur
~CDX9Buffer();

// Renvoie un pointeur sur le buffer
T* GetBuffer() const;

private :

// Verrouille le buffer
virtual void* Lock(unsigned long Offset, unsigned long Size, unsigned long Flags);

// Déverouille le buffer
virtual void Unlock();

// Données membres
T* m_Buffer; // Pointeur sur le buffer Dx9
};

Et pour OpenGL, à une classe également template mais prenant cette fois une constante entière comme paramètre :

Spécialisation OpenGL des buffers

template <int Type>
class COGLBuffer : public IBufferBase
{
public :

// Constructeur prenant en paramètre la taille et le pointeur sur le buffer
COGLBuffer(unsigned long Count, unsigned int Buffer);

// Destructeur
~COGLBuffer();

// Renvoie un pointeur sur le buffer
unsigned int GetBuffer() const;

private :

// Verrouille le buffer
virtual void* Lock(unsigned long Offset, unsigned long Size, unsigned long Flags);

// Déverouille le buffer
virtual void Unlock();

// Données membres
unsigned int m_Buffer; // Identifiant du buffer OGL
};

Vous remarquerez que les fonctions Lock et Unlock sont cette fois définies privées, en effet nous n'aurons besoin d'y accéder qu'au niveau du moteur, c'est-à-dire via la classe de base IBufferBase. L'API quant à elle accédera directement au buffer interne, à savoir aux IDirect3D***Buffer9 pour DirectX et aux buffers identifiés par des unsigned int du côté d'OpenGL.

Pour plus de simplicité dans la compréhension et l'écriture du code futur, nous pouvons maintenant définir explicitement le type des deux classes que nous allons manipuler (vertex buffer et index buffer) :

// DirectX
typedef CDX9Buffer<IDirect3DVertexBuffer9> CDX9VertexBuffer;
typedef CDX9Buffer<IDirect3DIndexBuffer9>  CDX9IndexBuffer;

// OpenGL
typedef COGLBuffer<GL_ARRAY_BUFFER_ARB>         COGLVertexBuffer;
typedef COGLBuffer<GL_ELEMENT_ARRAY_BUFFER_ARB> COGLIndexBuffer;

Une petite précision qui n'a peut-être pas encore été faite : à chaque fois que nous devrons spécialiser une classe pour DirectX et pour OpenGL, nous mettrons toujours ces spécialisations dans la DLL associée à l'API, aux côtés du renderer. Ainsi CDX9Buffer sera dans DirectX9Renderer.dll, et COGLBuffer sera elle dans OpenGLRenderer.dll, puisque seul le renderer devra y avoir accès. Le moteur lui se contentera de manipuler les classes abstraites qui servent de base à ces spécialisations. Cela sera de même pour les shaders, les textures, les declarations de vertex, etc...

Arrivé à ce stade, nous avons une classe de base abstraite et ses dérivées pour chaque API. Nous avons donc a priori tout ce qu'il faut pour manipuler les buffers dans notre moteur. Cependant nous n'avons aucune information sur le type des éléments stockés dans nos buffers, ce qui est plutôt gênant. C'est-à-dire qu'on pourra donner à manger tout et n'importe quoi à un même buffer, avec bien entendu aucun moyen de connaître le type "vraiment" stocké. Pour pallier à ce problème, nous allons donc encapsuler notre classe IBufferBase dans une autre classe plus sympathique : CBuffer.
Celle-ci nous permettra de contrôler le type des éléments stockés dans le buffer via un paramètre template, et puisqu'on y est on pourra également en profiter pour lui donner une sémantique de valeur (ie. pouvoir copier et assigner ses instances en tout sécurité, et sans dupliquer la représentation interne bien sûr), en encapulant le pointeur brut dans un pointeur intelligent à base de comptage de références. La technique utilisée ici peut s'apparenter à l'idiome enveloppe-lettre, (ou encore pimpl idiom pour nos amis anglais). Pour résumer, cela consiste à encapsuler (et cacher) un pointeur vers une ressource dans une classe. Pour plus de précisions, je vous invite à consulter le GOTW correspondant. On ne le répètera jamais assez : le C++ c'est bon, mangez-en ! Si vous n'êtes pas convaincu de l'interêt de cette classe et de toutes ces manipulation, voici un petit récapitulatif :

- Nous allons pouvoir nous débarrasser de tous ces affreux void*, ainsi lorsque nous essayerons de mettre des VertexX dans un buffer de VertexY, c'est une erreur de compilation qui nous le signalera et non un reboot de la machine.

- En encapsulant notre pointeur brut, nous pourrons ajouter quelques tests pour être sûrs de ne jamais manipuler de pointeur nul.

- Via le comptage de référence nous pourrons nous débarrasser de la gestion du buffer pointé, celui-ci sera automatiquement détruit lorsque plus personne n'en aura besoin.

- Cette classe possédant une sémantique de valeur, toute classe contenant un buffer sera elle-même potentiellement copiable (en gros, nous n'aurons pas besoin de créer le constructeur par défaut, par copie, le destructeur et l'opérateur d'affectation juste pour gérer notre buffer). Même combat que pour les pointeurs intelligents que nous avons détaillés dans la toute première partie.

Cette petite parenthèse C++ienne effectuée, revenons à nos CBuffer. Pour être certain que seul un CBuffer pourra manipuler un IBufferBase, nous allons tout mettre protected dans ce dernier, et lui coller la classe CBuffer comme copine. Nous laisserons juste son destructeur public, histoire que le pointeur intelligent encapsulant notre buffer puisse y accéder lorsqu'il voudra le détruire.
Voici donc le détail de cette classe, qui possède en gros la même interface que IBufferBase :

Classe CBuffer

template <class T>
class CBuffer
{
public :

// Constructeur prenant un pointeur sur le buffer encapsulé
CBuffer(IBufferBase* Buffer = NULL);

// Verrouille le buffer
T* Lock(unsigned long Offset = 0, unsigned long Size = 0, unsigned long Flags = 0) const;

// Déverrouille le buffer
void Unlock() const;

// Remplit le buffer
void Fill(const T* Data, std::size_t Count) const;

// Détruit le buffer
void Release();

// Renvoie un pointeur sur le buffer
const IBufferBase* GetBuffer() const;

// Renvoie le nombre d'éléments du buffer
unsigned long GetCount() const;

private :

// Données membres
CSmartPtr<IBufferBase> m_Buffer; // Buffer interne
};

// ...Et on n'oublie pas le copinage pour que tout cela fonctionne
class YES_EXPORT IBufferBase
{
protected :

// ...

template <class T> friend class CBuffer;
};

Le code résultant sera maintenant plus sympathique pour nous : nous allons pouvoir créer des CBuffer<TIndice>, CBuffer<TVertex> et autres, et n'y stocker que le type spécifié (notez bien les T* qui remplacent les void* de IBufferBase). Toute tentative d'y mettre autre chose conduira à une erreur de compilation, ce qui est tout de même beaucoup plus gérable qu'un gros plantage des familles ! En plus, grace au comptage de référence, nous pourrons manipuler des objets et non des pointeurs bruts, ce qui nous offrira tous les avantages des pointeurs intelligents, vus dans l'article précédent. Enfin je radote un peu là.
Puisque cette classe est là pour nous simplifier la vie, nous en profitons également pour lui ajouter quelques fonctions utiles : Release pour "libérer" le buffer (mais si d'autres CBuffer pointent sur la même représentation interne, celle-ci ne sera pas détruite), Fill pour remplir entièrement le buffer en un seul coup sans s'embarrasser du verrouillage, et GetCount pour récupérer la taille du buffer.

Pour conclure et clarifier les choses, voilà un petit schéma UML résumant notre hiérarchie de classes pour les buffers, et la manière dont elles seront utilisées dans le moteur :

2-2-2 - Côté API

Tout ceci est bien chouette, nous avons des interfaces bien codées et sympathiques à manipuler, mais elles souffrent cependant d'un gros problème : pour l'instant elles ne font rien ! Descendons donc d'un cran, allons cotoyer le bas niveau et voyons comment nous allons implémenter ces différentes fonctions pour chaque API. Commençons par le commencement : la création des buffers. Dans tout le code précédent, les mêmes classes étaient utilisées pour manipuler à la fois les vertex buffers et les index buffers. Cependant au niveau de l'API cela ne se passe pas comme ça, c'est pourquoi il nous faudra cette fois deux fonctions bien distinctes : une pour créer les vertex buffers, une autre pour créer les index buffers.

Nous aurons donc dans notre renderer deux nouvelles fonctions : CreateVB et CreateIB, qui vont toutes deux renvoyer un pointeur sur un IBufferBase. Et comme la vie est plus gaie avec les templates, nous allons également ajouter deux fonctions : CreateVertexBuffer et CreateIndexBuffer. Ce seront ces fonctions qui seront appelées par le moteur, elles appeleront bien sûr les deux fonctions précédentes pour créer le buffer "brut", encapsuleront le IBufferBase* renvoyé dans un CBuffer<T>, rempliront celui-ci si nécessaire, et calculeront au passage les paramètres qui peuvent être déduits automatiquement. Dur à suivre ? Ne vous inquiétez pas, avec le code source qui suit et l'exemple d'utilisation qui clot cette partie, vous ne devriez pas avoir de souci à assimiler tout ça.

Voici donc CreateVertexBuffer, son acolyte CreateIndexBuffer est identique à la différence près que nous appelons CreateIB au lieu de CreateVB :

template <class T>
inline CBuffer<T> IRenderer::CreateVertexBuffer(unsigned long Size, unsigned long Flags, const T* Data) const
{
CBuffer<T> Buffer(CreateVB(Size, sizeof(T), Flags));
if (Data)
Buffer.Fill(Data, Size);

return Buffer;
}

La paramètre Data servira à remplir notre buffer directement lors de sa création. Si son contenu n'est pas connu à ce moment, il suffira de ne pas spécifier ce paramètre (il vaut NULL par défaut). Attention cependant : c'est le seul moyen pour le compilateur de déduire le paramètre template T automatiquement, il faudra donc écrire celui-ci explicitement si l'on n'utilise pas le paramètre Data. Cette subtilité est illustrée dans le code d'exemple donné dans le chapitre 5.

Notez que ce sont les deux premières fonctions non virtuelles (et non statiques) de IRenderer. Nous aurons comme ceci quelques autres fonctions non virtuelles "pratiques", qui ne serviront qu'à englober de manière plus sympathique d'autres fonctions (elles virtuelles) du renderer.

Descendons encore un peu et voyons cette fois l'implémentation de CreateVB, tout d'abord pour DirectX9. On ne détaillera pas ici CreateIB, à part un ou deux paramètres c'est exactement la même chose. Vous trouverez de toute façon le code complet dans le zip qui accompagne cet article.

Création des buffers DirectX9

IBufferBase* CDX9Renderer::CreateVB(unsigned long Size, unsigned long Stride, unsigned long Flags) const
{
// Création du buffer
LPDIRECT3DVERTEXBUFFER9 VertexBuffer;
if (FAILED(m_Device->CreateVertexBuffer(Size * Stride, CDX9Enum::BufferFlags(Flags),
0, D3DPOOL_DEFAULT, &VertexBuffer, NULL)))
throw CDX9Exception("CreateVertexBuffer", "CreateVB");

return new CDX9VertexBuffer(Size, VertexBuffer);
}

La fonction IDirect3DDevice9::CreateVertexBuffer prend en paramètre :

- La taille totale du buffer (= taille des éléments x nombre d'éléments).

- Un flag décrivant l'utilisation que nous ferons de notre buffer, que nous transformons en flag Dx9 à l'aide de notre fameuse classe magique CDX9Enum.

- Un entier décrivant la structure des vertices stockés, que nous laisserons toujours à 0 car nous utiliserons plutôt les declarations de vertex pour cela.

- Un flag indiquant où placer en mémoire notre buffer.

- Un pointeur pour récupérer le buffer créé.

- Et finalement un "handle" que la documentation nous dit de toujours laisser à NULL.

Nous construisons ensuite un CDX9VertexBuffer à partir du buffer créé par Dx9, puis nous le renvoyons.

Voyons maintenant pour OpenGL :

Création des buffers OpenGL

IBufferBase* COGLRenderer::CreateVB(unsigned long Size, unsigned long Stride, unsigned long Flags) const
{
// Création du buffer
unsigned int VertexBuffer = 0;
glGenBuffersARB(1, &VertexBuffer);

// Remplissage
glBindBufferARB(GL_ARRAY_BUFFER_ARB, VertexBuffer);
glBufferDataARB(GL_ARRAY_BUFFER_ARB, Size * Stride, NULL, COGLEnum::BufferFlags(Flags));

return new COGLVertexBuffer(Size, VertexBuffer);
}

Ici non plus pas de difficulté majeure : nous demandons à OpenGL de générer un buffer, nous récupérons son identifiant, puis nous initialisons son contenu à l'aide de glBufferDataARB en lui donnant à manger la taille totale, un pointeur nul puisqu'aucune donnée n'est spécifiée, puis toujours ce fameux flag, qui dit à OpenGL comment créer notre buffer.

Le but de ces articles n'étant pas de reprendre de A à Z les explications de base relatives aux API 3D, si cela vous pose des difficultés vous pourrez trouver de l'excellente documentation par exemple chez NeHe pour OpenGL, ou dans la doc du SDK pour DirectX.

Toujours au niveau du renderer, la seconde étape est l'utilisation de ces buffers. Il nous faudra une fonction SetVB et sa copine SetIB, qui nous servirons à spécifier les buffers à utiliser lors du rendu. Là encore nous allons encapsuler tout ça dans deux fonctions templates : SetVertexBuffer et SetIndexBuffer. La raison est la même que pour les fonctions de création : il faudra prendre en paramètre un CBuffer<T>, extraire son IBufferBase* et la taille des éléments stockés, et donner tout ceci à manger à SetVB et SetIB, qui seront elles spécifiques à l'API.

La fonction SetVertexBuffer prend 4 paramètres : le buffer bien sûr, mais aussi le "stream" et la fourchette de vertices à prendre en compte. En effet, lorsqu'on envoie un buffer à l'API on n'aura pas toujours besoin de tous ses élements. L'intervalle passé en paramètre indique donc explicitement quels vertices seront utilisés par la suite pour le rendu des triangles, ce qui permet notamment quelques optimisations de la part de l'API. Quant au paramètre stream, nous verrons plus tard à quoi il sert, lorsque nous étudierons les déclarations de vertex. SetIndexBuffer ne prend elle rien de plus qu'un buffer en paramètre, rien de magique.
Voici donc à quoi ressemblent ces fonctions :

template <class T>
inline void IRenderer::SetVertexBuffer(unsigned int Stream, const CBuffer<T>& Buffer,
unsigned long MinVertex, unsigned long MaxVertex)
{
SetVB(Stream, Buffer.GetBuffer(), sizeof(T), MinVertex, MaxVertex ? MaxVertex : Buffer.GetCount() - 1);
}

template <class T>
inline void IRenderer::SetIndexBuffer(const CBuffer<T>& Buffer)
{
SetIB(Buffer.GetBuffer(), sizeof(T));
}

Ces fonctions ne font qu'extraire le buffer interne et la taille des éléments de nos VB / IB, puis appellent les fonctions "bas niveau" appropriées. Chose très importante : une fonction membre template ne peut pas être virtuelle (car ce n'est pas vraiment une fonction, mais plutôt un modèle qui génèrera une fonction pour chaque type T utilisé), le passage par cette fonction intermédiaire est donc obligatoire pour traiter nos CBuffer<T>. Autre détail : les paramètres MinVertex et MaxVertex ont comme valeur par défaut 0, auquel cas c'est la totalité du buffer qui sera prise en compte, ce qui se révèlera plus pratique par la suite.

Puis nous avons comme d'hab les fonctions spécifiques à l'API, en commençant par DirectX9 :

void CDX9Renderer::SetVB(unsigned int Stream, const IBufferBase* Buffer, unsigned long Stride,
unsigned long MinVertex, unsigned long MaxVertex)
{
// Récupération du buffer Dx
const CDX9VertexBuffer* VertexBuffer = static_cast<const CDX9VertexBuffer*>(Buffer);

// Envoi à l'API
m_Device->SetStreamSource(Stream, VertexBuffer ? VertexBuffer->GetBuffer() : NULL, 0, Stride);

// Sauvegarde des MinVertex et VertexCount
m_MinVertex   = MinVertex;
m_VertexCount = MaxVertex - MinVertex + 1;
}

void CDX9Renderer::SetIB(const IBufferBase* Buffer, unsigned long Stride)
{
// Récupération du buffer Dx
const CDX9IndexBuffer* IndexBuffer = static_cast<const CDX9IndexBuffer*>(Buffer);

// Envoi à l'API
m_Device->SetIndices(IndexBuffer ? IndexBuffer->GetBuffer() : NULL);
}

Arrêtons nous quelques instants pour expliquer un peu plus en détail ces deux fonctions. La première chose à faire dans chacune de ces fonctions est de transformer notre pointeur sur la classe de base (IBufferBase), en pointeur sur la classe dérivée associée à l'API (CDX9VertexBuffer). En temps normal, il faut utiliser dynamic_cast pour réaliser une telle conversion, mais ici nous sommes sûrs que notre buffer pointe bien sur un CDX9VertexBuffer, on peut donc utiliser un simple static_cast sans risque, et en évitant la perte de performance induite par dynamic_cast. Une fois converti en CDX9VertexBuffer, nous pouvons accéder à son buffer interne (de type IDirect3DVertexBuffer9 ...ouf, enfin !) et nous pouvons donc envoyer celui-ci à notre API. Bien sûr tout ce qui est dit ici pour SetVertexBuffer est valable pour SetIndexBuffer, le fonctionnement est le même. Cela sera également la même chose du côté d'OpenGL d'ailleurs. La fonction SetVertexBuffer stocke également l'intervalle de vertices, car celui-ci ne sera utilisé que lors de l'appel à DrawIndexedPrimitive, pour effectuer le rendu des primitives. Il faut donc les garder en mémoire jusqu'à cet appel. Mais alors pourquoi les passer en argument à SetVertexBuffer et non à DrawPrimitives ? Tout simplement car c'est comme cela que ça fonctionne du côté d'OpenGL, nous avons donc choisi l'option qui permettait de gérer les deux API au mieux. A noter également que du côté du SetIB de DirectX, le stride (taille des indices en octets) est inutilisé, en effet il est spécifié lors de la création de l'index buffer. Ce n'est pas le cas pour OpenGL, c'est pourquoi il nous faut tout de même ce paramètre.

L'implémentation OpenGL est quant à elle plus ou moins identique (cast de la classe de base, récupération du buffer interne, puis envoi à l'API), mais on ne donnera son détail que plus tard, car celle-ci est un peu spéciale. Il faudra notamment y mettre des choses spécifiques aux déclarations de vertex, ce que nous verrons immédiatement après ce chapitre.

Troisième et dernière étape : l'implémentation pour chaque API de nos classes de buffers, à savoir CDX9Buffer et COGLBuffer. Du côté de DirectX9, on utilise les fonctions Lock et Unlock pour le verrouillage / déverrouillage, et Release pour libérer le buffer. Au niveau d'OpenGL c'est kif kif : glMapBufferARB et glUnmapBufferARB pour verrouiller / déverrouiller, et glDeleteBuffersARB pour libérer le buffer. Le code complet n'est pas donné ici car il se limite à l'appel de ces fonctions, mais vous pourrez comme d'hab trouver celui-ci dans le zip accompagnant l'article.

3 - Les déclarations de vertex

Si ces mots ne signifient rien pour vous c'est normal : les déclarations de vertex ont été introduites avec la version 8 de DirectX (pour remplacer avantageusement les FVF, ou Flexible Vertex Format), et n'existent pas avec OpenGL. A quoi servent-elles ? Rappelez-vous, les vertices n'ont pas une structure fixe : on peut leur ajouter les composantes que l'on souhaite. Souvenez-vous aussi que les vertex buffers ne se soucient pas de cela, ils ne savent absolument rien quant à la composition des vertices qu'ils stockent. Par contre lorsque nous envoyons des vertices à l'API, celle-ci doit savoir avec précision de quoi ils sont composés. C'est-à-dire l'utilisation de leurs différentes composantes (position, normale, couleur, ...), leur type (triplet de floats, unsigned long, ...) et leur position (exprimée en octets par rapport au début de la structure de vertex). C'est à cela que vont servir les déclarations de vertex : stocker toutes ces informations et les restituer en temps voulu à l'API 3D. Ainsi tout vertex buffer (ou groupe de vertex buffer, nous y reviendrons dans un instant) devra être accompagné d'une declaration de vertex décrivant les vertices qu'il contient.

L'implémentation DirectX sera des plus simples : puisque tout est déjà prévu, il suffira d'encapsuler un objet IDirect3DVertexDeclaration9, de le construire correctement puis de le donner à manger au renderer le moment venu. Pour OpenGL par contre, il n'y a strictement rien de prévu à ce niveau, il faudra donc tout gérer nous-même. On peut tout de même remarquer qu'en fin de compte, même si ce concept n'existe pas, on retrouve des similitudes au niveau du fonctionnement. En effet au travers des fonctions glVertexPointer, glNormalPointer et toutes leurs copines, nous allons bien envoyer à l'API le type des composantes de vertex, leur taille, leur fonction et leur position. Sauf que nous allons encapsuler tout cela dans une zolie claclasse.

Mais commençons tout d'abord par écrire la classe abstraite qui servira de base à nos spécialisations DirectX et OpenGL :

Classe de base pour les déclarations de vertex

class YES_EXPORT IDeclaration
{
public :

// Destructeur virtuel - cette classe sera utilisée polymorphiquement
virtual ~IDeclaration() = 0 {}
};

Voilà une magnifique classe pleine de vide, comme nous les adorons ! La seule utilité des IDeclaration étant d'être créées puis envoyées au renderer, notre classe de base n'aura donc rien à faire, et les fonctions / données utiles seront stockées au niveau des classes dérivées. ...Ne pas oublier tout de même de déclarer le destructeur, car si celui-ci n'est pas virtuel notre moteur va avoir un problème lorsque nous détruirons nos IDeclaration.

Rappel de C++ : pour que le destructeur d'une classe dérivée utilisée polymorphiquement (ie. via un pointeur sur une classe de base) soit correctement appelé, il convient de déclarer virtuel le destructeur de toutes les classes dont dérive cette classe. Dans le cas contraire il se produit un comportement indéterminé, qui se traduit la plupart du temps par une fuite mémoire ou un plantage.

Comme nous n'aimons pas manipuler directement des pointeurs bruts (pour toutes les raisons déjà expliquées dans cet article et les précédents), nous allons systèmatiquement encapsuler nos IDeclaration non pas dans une nouvelle classe (cela ne servirait à rien ici), mais dans un pointeur intelligent :

typedef CSmartPtr<IDeclaration> CDeclarationPtr;

En n'utilisant que des CDeclarationPtr nous nous assurerons ainsi d'éviter les problèmes de mémoire, de copie ou encore d'exceptions.

Voici la spécialisation DirectX9, elle aussi extrêmement simple :

class CDX9Declaration : public IDeclaration
{
public :

// Constructeur par défaut
CDX9Declaration(IDirect3DVertexDeclaration9* Declaration);

// Destructeur
~CDX9Declaration();

// Renvoie la déclaration Dx9
IDirect3DVertexDeclaration9* GetDeclaration() const;

private :

// Données membres
IDirect3DVertexDeclaration9* m_Declaration; // Déclaration Dx9
};

...Et la spécialisation OpenGL, un peu plus lourde mais toujours rien de bien compliqué. Comme nous ne disposons de rien niveau API, il faut juste tout stocker nous même, et ajouter les fonctions permettant de construire / consulter ce qu'on a stocké :

class COGLDeclaration : public IDeclaration
{
public :

// Types
struct TElement
{
TElementUsage Usage;
TElementType  Type;
unsigned int  Offset;
};
typedef std::vector<TElement>      TElementArray;
typedef std::vector<TElementArray> TElementMatrix;

// Constructeur par défaut
COGLDeclaration();

// Destructeur
~COGLDeclaration();

// Ajoute la description d'un élément
void AddElement(unsigned int Stream, const TElement& Element);

// Renvoie la description d'un stream de la déclaration
const TElementArray& GetStreamElements(unsigned int Stream) const;

private :

// Données membres
TElementMatrix m_Elements; // Description de la déclaration
};

Voici finalement le petit diagramme UML résumant la hiérarchie de nos classes, rien de bien compliqué il s'agit ici d'un héritage simple, et pas de classe magique nous facilitant leur utilisation :

Vous vous demandez peut-être depuis tout à l'heure ce que signifient ces fameux streams qui accompagnent les vertex buffers et les declarations, une petite explication s'impose donc. Dans certaines situations, on voudrait séparer les composantes de nos vertices, par exemple la position et la normale d'un côté et les coordonnées de texture de l'autre, chacunes dans un vertex buffer différent. Pourquoi faire ? Imaginez un modèle qui contient 4 niveaux de textures, donc 4 paires de coordonnées de textures distinctes. Dans votre application vous n'en utiliserez toujours qu'une à la fois, mais serez tout de même obligé de trimballer les 4 dans votre structure de vertices, ce qui n'est clairement pas optimal. Les streams permettent de pallier simplement à ce problème. Créez un vertex buffer contenant les données de votre modèle (position + normale), un autre contenant la première paire de coordonnées de textures, un autre contenant la seconde paire, etc pour les quatre. Vous vous retrouvez ainsi avec quelque chose de ce genre :

Les composantes des vertices sont maintenant scindées dans plusieurs vertex buffers. Pour choisir quelle paire de coordonnées de texture vous voulez utiliser, il suffira de mettre le premier VB (contenant la position et la normale) sur le stream 0, et le vertex buffer correspondant aux UVs choisies sur le stream 1. Les trois autres ne seront ainsi pas utilisés : gain de mémoire, gain de performances, et facilité d'utilisation. Ainsi, pour en revenir à nos declarations de vertex, chaque composante de vertex se verra adjoindre un paramètre supplémentaire : le stream sur lequel elle apparaîtra. Dans l'exemple précédent, la position et la normale seront déclarées sur le stream 0, la paire de coordonnées de textures sur le stream 1.

Voyons maintenant comment construire et utiliser ces déclarations de vertex. Si votre mémoire est bonne, vous vous rappelez peut-être quelles informations celles-ci devront stocker : le type, l'utilisation et le numéro de stream de chaque composante de vertex. Nous avons également parlé d'un offset par rapport au début de la structure, mais celui-ci pourra être calculé à partir de la taille des composantes (pour peu que vous ne vous trompiez pas dans leur ordre). Par exemple pour des vertices contenant une position 3D, une normale, une couleur, puis une paire de coordonnées de texture sur un autre stream :

Exemple de déclaration de vertex

// Les structures correspondant à nos vertices
struct Vertex_PositionNormalColor
{
float x, y, z;
float nx, ny, nz;
unsigned long color;
};
struct Vertex_TexCoord
{
float u, v;
};

// La declaration qui va avec (nous verrons les détails immédiatement après)
TDeclarationElement Decl[] =
{
{0, ELT_USAGE_POSITION,  ELT_TYPE_FLOAT3}, // position sur 3 floats, stream 0
{0, ELT_USAGE_NORMAL,    ELT_TYPE_FLOAT3}, // normale sur 3 floats, stream 0
{0, ELT_USAGE_DIFFUSE,   ELT_TYPE_COLOR},  // couleur sur un entier 32 bits, stream 0
{1, ELT_USAGE_TEXCOORD0, ELT_TYPE_FLOAT2}  // coordonnées de textures sur 2 floats, stream 1
};

// Création de l'objet déclaration à partir de la description ci-dessus
CDeclarationPtr Declaration = Renderer.CreateVertexDeclaration(Decl);

Le contenu de la declaration doit bien sûr décrire parfaitement la structure de vertex correspondante, dans le cas contraire vous aurez un comportement indeterminé (qui, on le rappelle, se traduisent la plupart du temps par une implosion de votre écran). Attention donc aux fautes de frappe et autres étourderies !

Comme vous le voyez dans cet exemple, pour décrire la structure de nos vertices (type et utilisation) il nous faudra les constantes énumérées qui vont bien ; vous pourrez les trouver en intégralité dans le code source livré avec cet article.

Le reste est assez simple : vous créez un tableau contenant la description de vos composantes, une ligne pour chaque (et dans le bon ordre tant qu'à faire), puis vous demandez à votre renderer de vous créer un objet IDeclaration à partir de tout ça. Vous aurez peut-être remarqué que nous ne passons pas la taille du tableau en paramètre, tout comme nous ne marquons pas sa fin par une ligne spéciale. En fait pour récupérer la taille de notre tableau, nous allons une fois de plus utiliser les templates. Ce petit raccourci découle de la constatation que nos tableaux de TDeclarationElement seront toujours statiques, s'ils étaient dynamiques nous ne pourrions bien sûr pas procéder de la sorte. Nous allons donc (encore une fois) encapsuler la fonction de création de déclaration, CreateDeclaration, dans une autre fonction, CreateVertexDeclaration, dont le seul et unique but sera de récupérer à notre place la taille du tableau passé en paramètre. Bien entendu ce n'est nullement une nécessité, et si vous préférez vous passer de cette fonction cela ne changera rien au bon déroulement du programme. En plus de simplifier l'ecriture du code, cela permet juste de s'assurer que la taille de nos tableaux sera toujours la bonne.

template <std::size_t N>
CDeclarationPtr IRenderer::CreateVertexDeclaration(const TDeclarationElement (&Elements)[N]) const
{
return CreateDeclaration(Elements, N);
}

Si vous n'aviez jamais rencontré cette syntaxe un peu particulière ne prenez pas peur, c'est juste la manière de procéder pour faire détecter au compilo la taille d'un tableau statique via les templates.

Penchons-nous à présent sur l'implémentation DirectX et OpenGL de CreateDeclaration. Ces fonctions, bien que plutôt simple dans leur fonctionnement, sont relativement longues (enfin, par rapport aux fonctions assez courtes que nous avons déjà vues), c'est pourquoi nous ne les donnerons pas en détail ici ; vous pourrez vous reporter au code source correspondant pour y voir plus clair.

Du côté de DirectX la manière de procéder est la même que dans notre moteur : il faut remplir un tableau de D3DVERTEXELEMENT9, puis demander au device d'en créer une IDirect3DVertexDeclaration9, que nous donnerons à manger à la CDX9Declaration que nous renverrons. Il suffit donc de parcourir les éléments de la declaration (TDeclarationElement) et de les convertir en éléments Dx9 (D3DVERTEXELEMENT9). Ce ne sera donc en gros que quelques switchs imbriqués dans une boucle parcourant les éléments du tableau. L'offset de chaque composante est quant à lui calculé à partir de la taille des composantes la précédant.

Au niveau d'OpenGL ce sera la même chose : nous allons parcourir le tableau passé en paramètre et cette fois non pas convertir ses éléments en éléments OpenGL (puisqu'il n'y en a pas), mais les stocker dans COGLDeclaration, qui si vous vous rappelez a justement été conçue pour cela.

La seconde et dernière chose à faire avec nos déclarations est de les utiliser bien sûr, pour cela nous ajouterons simplement à notre renderer une fonction SetDeclaration, qui prendra en paramètre (vous avez deviné... ?) un IDeclaration*. La spécialisation reste très basique chez DirectX, sans surprise :

void CDX9Renderer::SetDeclaration(const IDeclaration* Declaration)
{
// Récupération de la déclaration Dx
const CDX9Declaration* DxDeclaration = static_cast<const CDX9Declaration*>(Declaration);

// Envoi à l'API
m_Device->SetVertexDeclaration(DxDeclaration ? DxDeclaration->GetDeclaration() : NULL);
}

Pour OpenGL par contre c'est assez spécial et il va falloir quelques explications. En effet, la description des composantes de vertex se fait en même temps que l'envoi du vertex buffer (via les fonctions glVertexPointer et compagnie), c'est pourquoi nous ferons tout le boulot dans SetVertexBuffer. SetDeclaration quant à elle ne fera que remettre à zéro les différents états précédents (via quelques glDisable***), et stocker la déclaration pour sa future utilisation. Bien sûr cela nous oblige à appeler SetDeclaration avant SetVertexBuffer, attention donc.
La fonction SetVertexBuffer devient donc assez volumineuse, mais ce n'est en fait rien de moins qu'un gros switch : pour chaque composante de la déclaration, selon son utilisation, nous allons appeler la bonne fonction d'OpenGL :

void COGLRenderer::SetVB(unsigned int Stream, const IBufferBase* Buffer, unsigned long Stride,
unsigned long MinVertex, unsigned long MaxVertex)
{
// Envoi du buffer OGL à l'API
const COGLVertexBuffer* VertexBuffer = static_cast<const COGLVertexBuffer*>(Buffer);
glBindBufferARB(GL_ARRAY_BUFFER_ARB, VertexBuffer->GetBuffer());

// Tables de correspondance
static const unsigned int Size[] = {1, 2, 3, 4, 4};
static const unsigned int Type[] = {GL_FLOAT, GL_FLOAT, GL_FLOAT, GL_FLOAT, GL_UNSIGNED_BYTE};

// Paramètrage des glPointer
const COGLDeclaration::TElementArray& StreamDesc = m_CurrentDeclaration->GetStreamElements(Stream);
for (COGLDeclaration::TElementArray::const_iterator i = StreamDesc.begin(); i != StreamDesc.end(); ++i)
{
switch (i->Usage)
{
// Position
case ELT_USAGE_POSITION :
glEnableClientState(GL_VERTEX_ARRAY);
glVertexPointer(Size[i->Type], Type[i->Type], Stride, BUFFER_OFFSET(i->Offset + MinVertex * Stride));
break;

// Normale
case ELT_USAGE_NORMAL :
glEnableClientState(GL_NORMAL_ARRAY);
glNormalPointer(Type[i->Type], Stride, BUFFER_OFFSET(i->Offset + MinVertex * Stride));
break;

// Pareil pour tous les types de composantes...
}
}
}

BUFFER_OFFSET est une macro que l'on retrouve souvent lorsqu'on manipule les buffers object d'OpenGL, elle permet de spécifier un décalage dans le buffer (ie. la position de la composante, ici du premier vertex).

#define BUFFER_OFFSET(n) ((char*)NULL + (n))

Voilà, nous avons finalement fait le tour des concepts, techniques et classes nécessaires à l'intégration des vertex buffers, des index buffers et des déclarations. Notre moteur dispose à présent de tout ce qu'il faut pour effectuer le rendu de triangles... à l'exception du plus important !

4 - Affichage des triangles

Il manque une fonction essentielle à notre moteur pour pouvoir afficher notre géometrie. Nous avons certes des vertices et des indices, de quoi les stocker, les décrire et les envoyer à l'API, mais il manque la fonction qui fera bon usage de tout cela, à savoir celle qui effectuera le rendu de primitives : DrawPrimitives et sa copine DrawIndexedPrimitives. Pourquoi deux fonctions ? La première effectue un rendu non indicé (sans tenir compte d'un éventuel index buffer), alors que la seconde va utiliser vertices et indices.

Mais entrons un peu plus dans le détail. Comment marchent ces fonctions et que permettent-elles de faire exactement ? Elles vont simplement aller prendre le vertex buffer spécifié avec SetVertexBuffer, l'index buffer spécifié avec SetIndexBuffer dans le cas d'un rendu indicé, puis en former des primitives de base (points, lignes ou triangles) et les envoyer à la carte graphique pour effectuer leur rendu et les afficher à l'écran. Elles devront prendre en paramètre le type de primitive à construire, le nombre de celles-ci, puis l'indice du premier vertex (ou indice pour DrawIndexedPrimitive) utilisé pour le rendu. Leur implémentation est quasi-directe :

Spécialisation pour DirectX9

void CDX9Renderer::DrawPrimitives(TPrimitiveType Type, unsigned long FirstVertex, unsigned long Count) const
{
m_Device->DrawPrimitive(CDX9Enum::Get(Type), FirstVertex, Count);
}

void CDX9Renderer::DrawIndexedPrimitives(TPrimitiveType Type, unsigned long FirstIndex, unsigned long Count) const
{
m_Device->DrawIndexedPrimitive(CDX9Enum::Get(Type), 0, m_MinVertex, m_VertexCount, FirstIndex, Count);
}

On convertit le type de primitive en type Dx9 toujours à l'aide de notre classe magique, puis on passe les bons paramètres, soit pris par la fonction directement soit stockés au préalable par SetVertexBuffer. Le détail des paramètres de IDirect3DDevice9::DrawIndexedPrimitive peut sembler obscur, si c'est le cas n'oubliez pas de consulter la doc du SDK !

Spécialisation pour OpenGL

void COGLRenderer::DrawPrimitives(TPrimitiveType Type, unsigned long FirstVertex, unsigned long Count) const
{
// Affichage des primitives
switch (Type)
{
case PT_TRIANGLELIST :  glDrawArrays(GL_TRIANGLES,      FirstVertex, Count * 3); break;
case PT_TRIANGLESTRIP : glDrawArrays(GL_TRIANGLE_STRIP, FirstVertex, Count + 2); break;
case PT_TRIANGLEFAN :   glDrawArrays(GL_TRIANGLE_FAN,   FirstVertex, Count + 1); break;
case PT_LINELIST :      glDrawArrays(GL_LINES,          FirstVertex, Count * 2); break;
case PT_LINESTRIP :     glDrawArrays(GL_LINE_STRIP,     FirstVertex, Count + 1); break;
case PT_POINTLIST :     glDrawArrays(GL_POINTS,         FirstVertex, Count);     break;
}
}

void COGLRenderer::DrawIndexedPrimitives(TPrimitiveType Type, unsigned long FirstIndex, unsigned long Count) const
{
// Quelques calculs
unsigned long IndicesType = (m_IndexStride == 2 ? GL_UNSIGNED_SHORT : GL_UNSIGNED_INT);
const void*   Offset      = BUFFER_OFFSET(FirstIndex * m_IndexStride);

// Affichage des primitives
switch (Type)
{
case PT_TRIANGLELIST :  glDrawElements(GL_TRIANGLES,      Count * 3, IndicesType, Offset); break;
case PT_TRIANGLESTRIP : glDrawElements(GL_TRIANGLE_STRIP, Count + 2, IndicesType, Offset); break;
case PT_TRIANGLEFAN :   glDrawElements(GL_TRIANGLE_FAN,   Count + 1, IndicesType, Offset); break;
case PT_LINELIST :      glDrawElements(GL_LINES,          Count * 2, IndicesType, Offset); break;
case PT_LINESTRIP :     glDrawElements(GL_LINE_STRIP,     Count + 1, IndicesType, Offset); break;
case PT_POINTLIST :     glDrawElements(GL_POINTS,         Count,     IndicesType, Offset); break;
}
}

Le spécialisation OpenGL est à peine différente : il faut juste spécifier le nombre de vertices plutôt que le nombre de primitives, ce qui nous oblige à les calculer selon le type des primitives (c'est aussi pour cela que nous n'utilisons pas COGLEnum ici, par exemple).

Vous aurez reconnu parmi les types de primitives les triangles, lignes et points, mais que signifient donc ces "strip" et "fan" ? C'est en fait une autre manière de prendre les vertices pour former un triangle ou une ligne. Ces cas pourront toujours être contournés en choisissant judicieusement nos indices, mais cela nous permettra de réduire leur nombre et parfois même de nous en passer. Par exemple pour afficher un quad (carré), plutôt que de définir 4 vertices et 6 indices, nous n'utiliserons pas d'index buffer mais nous choisirons comme type de primitive un "triangle strip" (D3DPT_TRIANGLESTRIP ou GL_TRIANGLE_STRIP), et toujours avec seulement 4 vertices. Economie de mémoire, de bande passante, de code et également d'erreurs potentielles !

Voici quelques petits schémas résumant l'ordre dans lequel sont pris les vertices pour chaque type de primitive :

Les "Triangles Lists"

Les triangles sont simplement pris à la suite : (v1, v2, v3) (v4, v5, v6) (v7, v8, v9).

Les "Triangles Strips"

Les triangles sont "collés bout à bout" et partagent toujours 2 vertices : (v1, v2, v3) (v2, v4, v3) (v3, v4, v5) (v4, v6, v5) ...

Les "Triangles Fans"

Les triangles sont acollés, similairement aux strips, mais cette fois ils partagent tous un même sommet : (v1, v2, v3) (v1, v3, v4) (v1, v4, v5) ...

Niveau pratique, les triangles fans sont très rarement utilisés, les triangles strips sont eux utilisés pour le rendu de terrain ou de quads, mais peuvent souvent être remplacés (à performance égale) par des triangles lists bien indicés. Ces derniers quant à eux sont les plus utilisés, ils conviendront dans la plupart des cas.
Pour les "line strip" c'est exactement le même procédé, mais avec des lignes qui se suivent bout à bout (les "lines fans" n'existent pas, car ce serait tout simplement impossible comme configuration).

5 - Exemple d'utilisation

Pour clarifier les choses et remettre un peu d'ordre dans nos têtes après toutes ces explications, voici un bout de code illustrant l'utilisation de nos différentes classes dans le moteur :

Initialisation

// Définition du type des vertices et des indices
// On sépare les vertices en 2 streams juste pour illustrer ce fonctionnement
struct TVertex1
{
float x, y, z;
unsigned long Color;
};
struct TVertex2
{
float tu, tv;
};
typedef unsigned short TIndex;

// Création des vertices et des indices
TVertex1 Vertices1[] =
{
{0.0f, 0.0f, 0.0f, Renderer.ConvertColor(0xFFFF00FF)},
{0.0f, 5.0f, 0.0f, Renderer.ConvertColor(0xFFFF00FF)},
{5.0f, 0.0f, 0.0f, Renderer.ConvertColor(0xFFFFFF00)},
{5.0f, 5.0f, 0.0f, Renderer.ConvertColor(0xFFFFFF00)}
};
TVertex2 Vertices2[] =
{
{0.0f, 1.0f},
{0.0f, 0.0f},
{1.0f, 1.0f},
{1.0f, 0.0f}
};
TIndex Indices = {0, 1, 2, 2, 1, 3};

// Création des buffers

// Version 1
CBuffer<TVertex1> VertexBuffer1 = Renderer.CreateVertexBuffer(4, 0, Vertices1);

// Version 2
CBuffer<TVertex2> VertexBuffer2 = Renderer.CreateVertexBuffer<TVertex2>(4, 0);
VertexBuffer2.Fill(Vertices2, 4);

// Version 3
CBuffer<TIndex> IndexBuffer = Renderer.CreateIndexBuffer<TVIndex>(6, 0);
TIndex* I = IndexBuffer.Lock();
std::copy(Indices, Indices + 6, I);
IndexBuffer.Unlock();

// Création de la déclaration de vertex correspondant à notre structure de vertices
TDeclarationElement Elements[] =
{
{0, ELT_USAGE_POSITION,  ELT_TYPE_FLOAT3},
{0, ELT_USAGE_NORMAL,    ELT_TYPE_FLOAT3},
{1, ELT_USAGE_TEXCOORD0, ELT_TYPE_FLOAT2}
};
CDeclarationPtr Declaration = Renderer.CreateVertexDeclaration(Elements);

Utilisation

Renderer.BeginScene();

Renderer.SetDeclaration(Declaration);
Renderer.SetVertexBuffer(0, VertexBuffer1);
Renderer.SetVertexBuffer(1, VertexBuffer2);
Renderer.SetIndexBuffer(IndexBuffer);
Renderer.DrawIndexedPrimitives(PT_TRIANGLELIST, 0, 2);

Renderer.EndScene();

Plusieurs détails sont à noter dans ce bout de code :

- Voilà notre fameuse fonction ConvertColor en action, ne l'oubliez pas !

- Dans les versions 2 et 3 de création des buffers, nous spécifions le paramètre template explicitement. C'est obligatoire, car le compilateur ne pourrait le déduire automatiquement qu'à l'aide du troisième paramètre, que nous n'utilisons pas.

- Aucune des ressources créées ne nécessitera d'être détruite explicitement. Tout est encapsulé dans des objets automatiques, qui gèrent pour nous la copie et la destruction.

- On peut remarquer que l'index buffer aurait pu être ici supprimé avantageusement au profit d'un triangle strip.

6 - Conclusion

Avec cette partie nous avons fait un grand pas en avant : nous avons vu en détail les bases du rendu, comment créer et stocker notre géometrie, puis enfin comment l'afficher. Nous pouvons maintenant dessiner et animer de manière relativement simple des triangles, d'ailleurs si vous n'en êtes pas convaincus une petite démo reprenant tout ce qui a été vu jusqu'à présent accompagne cet article, binaires et sources inclus.

Nous avons maintenant une base solide et fonctionnelle pour notre moteur, bien sûr il faudra rendre celui-ci beaucoup plus riche mais nous pouvons maintenant afficher des choses à l'écran, et de manière totalement indépendante de l'API choisie. Nous pourrons à présent construire des fonctionnalités plus riches sur cette base : sprites 2D, modèles 3D, systèmes de particules, ... Dans les prochains articles, nous tenterons d'égayer un peu notre scène à l'aide des textures, et nous étudierons également la gestion efficace des ressources.

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 demo 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és 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, 932 Ko)

Screenshot de l'application exemple

Une version PDF de cet article est disponible : Télécharger (207 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 !

8 - Remerciements

Un grand merci à toutes les personnes qui m'aident, de près ou de loin, à réaliser ou améliorer cette série d'article : Stoomm, Nico, Vincent, toute l'équipe de developpez.com, les lecteurs bien sûr, et enfin Mathieu pour sa grande contribution.