Optimisation des indices de vertices
Optimisation des indices de vertices
Date de publication : 30/10/2007 , Date de mise à jour : 30/10/2007
Par
Michaël Gallego (bakura.developpez.com)
Ce tutoriel livre une implémentation d'un algorithme d'optimisation des indices de
vertices, pour un affichage plus rapide.
I. Introduction
II. Les méthodes
III. Optimisation des indices de vertices
IV. La méthode
V. Optimiser les vertices
VI. Resultats
VII. Remerciements
I. Introduction
L'une des préocupations majeures des programmeurs de jeux vidéo, qu'ils soient
amateurs ou professionnels, est d'obtenir un framerate le plus haut possible. Pour
ceci, de nombreuses méthodes s'offrent à vous : diminuer le nombre de triangles des
modèles 3D, simplifier les shaders, diminuer les calculs d'éclairages, ou encore
réécrire les algorithmes d'intelligence artificielle afin de les rendre plus
performant. Il existe pourtant une autre technique, très simple à mettre en oeuvre,
et qui peut sensiblement améliorer les performances, tout du moins sur les cartes
graphiques les plus récentes : réorganiser les indices afin de favoriser
l'utilisation du cache.
Tout comme les CPU, les GPU modernes disposent d'un cache, pouvant contenir entre 8 et 32
éléments sur les cartes graphiques récentes, ces éléments étant accessibles très rapidement. Il
devient donc clair qu'il est important de réordonner les indices afin d'utiliser au maximum ce
cache. Imaginons par exemple la liste d'indices suivantes : 0 - 1 - 2 et 4 - 3 - 2, avec un
cache de 2 éléments. Le vertex 0 sera insérée dans le cache, puis le vertex 1, puis le
vertex 2 (la vertice 0 sortira quand à elle du cache), puis la vertice 4, la 3 et enfin la
2. Et bien ici, on a un exemple très simple d'indices mal organisés. Le vertex 2 est en
effet utilisée deux fois, mais la seconde fois qu'on l'utilisera, elle ne sera plus dans
le cache et ne sera pas dessinée de la manière la plus rapide possible. C'est ce qu'on
appelle un "défaut de cache" (ou "cache miss" en anglais). On peut donc réordonner les indices afin de profiter de ce
concept : 0 - 1 - 2 et 2 - 3 - 4. Ainsi, le vertex 2 sera ajoutée dans le cache,
puis le vertex 2 sera de nouveau demandée pour créer le second triangle, mais étant
déjà dans le cache, elle sera dessinée plus rapidement.
Bien sûr, avec des cartes graphiques pouvant traiter des millions de triangles à la seconde,
l'exemple ci-dessus sera imperceptible. Toutefois, avec des scènes de plusieurs centaines de
milliers de triangle, réorganiser des modèles permettra sans aucun doute de gagner en fluidité.
II. Les méthodes
Il existe deux principales méthodes afin de favoriser l'utilisation du cache. Tout
d'abord l'utilisation des triangles strips ou, mieux, l'utilisation des triangles strips couplée aux indices de vertices. Toutefois, les triangles strips sont à
présent moins efficaces sur les cartes graphiques récentes, où il est plutôt conseillé (notamment par les concepteurs de cartes graphiques)
d'utiliser d'envoyer à la carte graphique des triangles normaux, couplée aux indices de vertices. La seconde méthode consiste donc à optimiser ces
indices de vertices, et c'est ce que nous allons étudier dans ce tutoriel.
Cette méthode sera donc inutile sur des cartes graphiques anciennes ne disposant pas ou très peu
de cache, où il est conseillé d'utiliser des triangles strips (il existe de nombreux
algorithmes permettant de créer des triangles strips sur Internet).
III. Optimisation des indices de vertices
Comme dit plus haut, nous allons donc optimiser les indices de vertices. Il
existe de nombreux algorithmes, plus ou moins complexes, pour arriver à cette
objectif. Tout d'abord la méthode de Hugues Hoppe
(
ici, voir l'article "Optimization of mesh
locality for transparent vertex caching"). Son principal désavantage est qu'elle
optimise les modèles à une seule taille de cache. Par exemple, si les indices
sont optimisés pour une taille de cache de 16, et que finalement la machine ne
dispose que d'un cache de 12, les performances ne seront finalement pas
meilleures, voires pires, qu'avec les indices non optimisés. La méthode de
Hoppe est cependant idéal pour les consoles, ou la taille du cache est fixe.
DirectX vous propose une fonction automatique, D3DXOptimizeFaces, qui repose sur l'algorithme de
H. Hoppe citée juste au dessus, tout en apportant plusieurs améliorations. Toutefois, elle
semble disposer des mêmes défauts que la méthode sur laquelle cette fonction repose, à savoir
une optimisation optimale pour une seule taille de cache.
Il existe d'autres méthodes, plus complexes, expliqués dans différents papiers, que vous pouvez
implémenter si le coeur vous en dit, comme celui-ci :
par là
("Cache Oblivious Mesh Layouts", de Sung-Eui Yoon, Peter Lindstrom, Valerio Pascucci et Dinesh
Manocha).
Toutefois, la méthode que nous allons implémenter repose sur un algorithme trouvé par Tom Forsyth
, et expliqué ici :
cliquez ici. Ses
principaux avantages sont qu'elle est assez rapide (environ 60ms pour optimiser un modèle de
5500 triangles, sur mon Athlon XP 3000+), très facile à implémenter et, surtout, contrairement
à la méthode de Hoppe, efficace quelque soit la taille du cache. C'est à dire que l'algorithme
n'optimise pas les modèles de façon optimale pour une taille de cache donnée, mais l'optimise
de manière à ce soit suffisamment efficace pour n'importe quelle taille de cache. Je vous
conseille donc dans un premier temps de bien lire le papier de la méthode, puisque cet
article sera surtout un exemple d'implémentation de la méthode.
IV. La méthode
Vous avez à présent lu le papier de la méthode, et avant de foncer tête baissé dans son
implémentation, voici un pseudo-code de l'algorithme :
OptimizeIndicesOrder (indices, tailleDuCache, nombreDeVertices) :
Initialisation :
nombreDeTriangles : indices / 3
Pour chaque triangle :
On ajoute, à chaque vertex qui le compose, une référence à ce triangle (en l'occurence l'indice)
Pour chaque vertex :
On calcule son score
Pour chaque triangle :
On calcule son score (somme des trois scores de ses vertices)
On récupère l'indice du premier triangle ayant le plus haut score
Corps de l'algorithme :
Tant qu'il reste des triangles à ajouter :
Si le score du triangle à ajouter est trop bas, on fait une recherche dans tous les triangles non ajoutés
On supprime, pour chaque vertex qui compose le triangle à ajouter, la référence à ce triangle
On met à jour les indices, et on réduit de 1 le nombre de triangles à ajouter
On ajoute dans le cache (virtuel) les trois vertices du triangle qui vient d'être ajouté
On recalcule leur score, et on met à jour le score de tous les triangles qui utilisent ces trois vertices
Enfin, on recherche parmi les triangles qui utilisent les vertices présentes dans le cache, celui qui a le meilleur score, et on récupère son indice
Voilà, nous avons à présent tout le nécessaire pour commencer l'implémentation de l'algorithme.
La première chose que nous allons implémenter est un cache LRU. Comme vous l'avez constaté,
l'algorithme utilise un cache "virtuel" de type LRU (Least Recently Used), qui fonctionne de la
manière suivante : lorsqu'un élément est ajouté dans le cache, il est placé à la tête de
celui-ci, et si la taille maximale est atteinte, l'élément le plus ancien sort du cache. Si
l'élément à ajouté est déjà présent dans le cache (on n'autorise pas les doublons), alors on
replace cet élément à la tête. J'ai décidé de l'implémenter en utilisant un container de type
std::deque. Voici l'implémentation de ce cache :
#ifndef LRUCACHE_HPP
#define LRUCACHE_HPP
#include <iostream>
#include <cassert>
#include <vector>
#include <algorithm>
template <typename Value_Type>
class LRUCache
{
public:
LRUCache (const std::size_t maxSize)
: maxSize (maxSize)
{
}
LRUCache (const std::size_t maxSize, const Value_Type & val)
: maxSize (maxSize), LRUList (maxSize, val)
{
}
private:
typedef typename std::vector<Value_Type>::iterator vec_iterator;
typedef typename std::vector<Value_Type>::const_iterator vec_const_iterator;
std::size_t maxSize;
std::vector<Value_Type> LRUList;
public:
vec_iterator find (const Value_Type & value)
{
vec_iterator vec_iter = std::find (LRUList.begin(), LRUList.end(),
value);
if (vec_iter == LRUList.end())
return vec_iter;
Value_Type val = *vec_iter;
LRUList.erase (vec_iter);
LRUList.insert (LRUList.begin(), val);
return (LRUList.begin());
}
Value_Type insert (const Value_Type & value)
{
vec_iterator vec_iterator = find (value);
if (vec_iterator == LRUList.end())
LRUList.insert (LRUList.begin(), value);
if (LRUList.size() > maxSize)
{
Value_Type val = LRUList.back();
LRUList.pop_back();
return val;
}
return (LRUList.front());
}
const Value_Type & operator[] (const std::size_t idx) const
{
assert (idx < maxSize);
return LRUList[idx];
}
void Write () const
{
for (vec_const_iterator vec_const_iter = LRUList.begin(),
itEnd = LRUList.end() ; vec_const_iter != itEnd ; ++vec_const_iter)
{
std::cout << *vec_const_iter << std::endl;
}
}
};
#endif // LRUCACHE_HPP
|
Le code est pour le moins simplissime. On propose deux constructeurs, dont un qui permet d'initialiser le cache à une valeur donnée, alors que le cache n'autorise théoriquement pas les doublons. Cela permet juste de simplifier les choses. En effet, lors de l'implémentation de l'algorithme, on aura besoin de récupérer les indices des vertices qui seront sortis du cache. Comme les indices sont des valeurs positives, ceci permettra de manière très simple de vérifier s'il s'agit bien d'indices de vertices (valeurs positives ou nulle), ou bien des indices dûs à l'initialisation du cache (valeurs négatives).
Les fonctions find et insert permettent d'imiter les containers de la STL. find se charge de trouver si un élément est présent dans le cache. S'il l'est, cet élément est replacé au début du cache, et un itérateur vers cet élément est renvoyé, sinon, find renvoit un itérateur vers LRUCache.end(). insert permet, quant à elle, d'insérer un nouvel élément.
L'utilisation d'un vector s'explique par le fait d'un accès aléatoire via l'opérateur [] très rapide. Même si utiliser un std::deque, qui propose une insertion constante aux deux extrémités du container (via push_back et push_front), mes petits tests ont montrés que je gagne 10 ms sur un petit modèle de 5500 indices en utilisant un vector plutôt qu'un deque. Sur un temps d'éxecution de 60 ms, les 10 ms gagnés rien qu'en utilisant un autre container sont donc les bienvenus (et plus le nombre d'indices à réorganiser sera grand, plus le gain sera important). L'utilisation d'un std::unordered_set aurait pu être intéressant car il offre un temps d'insertion constant, mais en plus une implémentation de find en complexité log N au lieu de N, mais comme nous travaillons sur des petits caches (maximum 32 éléments), ceci n'est pas trop un problème. De plus, std::unordered_set n'est pas disponible sur toutes les implémentations de la STL et, surtout, n'offre pas d'accès direct aux éléments.
Nous allons à présent implémenter l'algorithme en lui-même. Commençons par plusieurs constantes et deux structures :
const float FindVertexScore_CacheDecayPower = 1.5f;
const float FindVertexScore_LastTriScore = 0.75f;
const float FindVertexScore_ValenceBoostScale = 2.0f;
const float FindVertexScore_ValenceBoostPower = 0.5f;
struct TriData
{
bool isAdded;
float score;
std::size_t indices[3];
};
struct VertData
{
float score;
std::set<std::size_t> remainingTriangles;
float CalculateScore (const std::size_t positionInCache, const std::size_t cacheSize)
{
if (remainingTriangles.empty())
{
score = -1.0f;
return score;
}
if (positionInCache < 0)
{
}
else if (positionInCache < 3)
{
score = FindVertexScore_LastTriScore;
}
else
{
assert (positionInCache < cacheSize);
const float scaler = 1.0f / (cacheSize - 3);
score = std::powf (1.0f - (positionInCache - 3) * scaler, FindVertexScore_CacheDecayPower);
}
float valenceBoost = std::powf (remainingTriangles.size(),
-FindVertexScore_ValenceBoostPower);
score += FindVertexScore_ValenceBoostScale * valenceBoost;
return score;
}
};
|
Les constantes sont directement pris du papier de Tom Forsyth, tout comme la fonction de calcul du score. Pas grand chose à ajouter ici.
bool OptimizeIndicesOrder (std::vector<std::size_t> & triIndices,
const std::size_t cacheSize,
const std::size_t numberOfVertices)
{
if ((triIndices.size() < 3) || (triIndices.size() % 3 != 0) || (cacheSize < 4))
return false;
std::size_t numTriangles = triIndices.size() / 3;
std::size_t trianglesLeft = numTriangles;
std::size_t numVertices = numberOfVertices;
if (numVertices == 0)
{
numVertices = *std::max_element (triIndices.begin(), triIndices.end());
++numVertices;
}
|
Rentrons à présent dans le vif du sujet. La fonction prend donc en paramètre les indices du modèle 3D, la taille du cache (de base 32), ainsi que le nombre de vertices. On commence par vérifier si, notamment, le cache n'est pas inférieur à 4, puis on calcule plusieurs variables qui nous seront utiles. Si le nombre de vertices n'a pas été spécifiée (non connue par exemple), on le calcule directement ici en utilisant std::max_element.
LRUCache<int> cacheIdx (cacheSize, -1);
std::vector<TriData> triData (numTriangles);
std::vector<VertData> vertData (numVertices);
|
Voici notre fameux cache LRU en action. Il est de taille cacheSize (normalement 32), et toutes les valeurs sont initialisées à -1. On créé enfin deux vector contenant nos structures précédemment définies.
for (std::size_t i = 0 ; i != numTriangles ; ++i)
{
triData.at(i).isAdded = false;
triData.at(i).score = 0.0f;
triData.at(i).indices[0] = triIndices.at(i * 3);
triData.at(i).indices[1] = triIndices.at(i * 3 + 1);
triData.at(i).indices[2] = triIndices.at(i * 3 + 2);
vertData.at(triData.at(i).indices[0]).remainingTriangles.insert (i);
vertData.at(triData.at(i).indices[1]).remainingTriangles.insert (i);
vertData.at(triData.at(i).indices[2]).remainingTriangles.insert (i);
}
|
Cette boucle est très simple. Elle initialise chaque structure TriData contenue dans le vector : on initialise son score à 0.0f, puis chaque indice est défini en utilisant les indices passés en paramètre de la fonction. Enfin, pour chacune des trois vertices qui composent ce triangle, on ajoute dans le std::set l'indice vers ce triangle. Plus un vertex aura de triangles qui l'utilise, plus son score sera haut, et donc par la même occasion le score de son triangle associé.
for (std::vector<VertData>::iterator it = vertData.begin(),
itEnd = vertData.end(); it != itEnd ; ++it)
{
it->score = std::powf (it->remainingTriangles.size(), -FindVertexScore_ValenceBoostPower)
* FindVertexScore_ValenceBoostScale;
}
|
Une fois que les vertices ont correctement été initialisées en ajoutant les références vers les triangles qui les utilisaient, on calcule leur score. Les plus attentifs remarqueront que ce code n'est rien d'autre que la fin de la fonction CalculateScore contenue dans le structure VertData. En effet, à l'heure actuelle, aucun vertex n'est dans le cache, et plutôt que d'appeler la fonction CalculateScore et avoir plusieurs embranchements if/else, j'ai préféré calculer le score directement ici.
float bestScore = 0.0f;
int bestIdx = -1;
for (std::size_t i = 0 ; i != numTriangles ; ++i)
{
triData.at(i).score = vertData.at(triData.at(i).indices[0]).score +
vertData.at(triData.at(i).indices[1]).score +
vertData.at(triData.at(i).indices[2]).score;
if (triData.at(i).score > bestScore)
{
bestScore = triData.at(i).score;
bestIdx = i;
}
}
|
On se charge ensuite de calculer le score de chaque triangle (qui est la somme des scores des trois vertices que le triangle utilise), puis on stocke le score ainsi que l'indice du meilleur triangle.
while (trianglesLeft != 0)
{
if (bestScore < 0.05f)
{
bestScore = 0.0f;
bestIdx = -1;
for (std::size_t i = 0 ; i < numTriangles ; ++i)
{
if ((!triData.at(i).isAdded) && (triData.at(i).score > bestScore))
{
bestScore = triData.at(i).score;
bestIdx = i;
}
}
}
|
On arrive à présent dans une boucle while, qui ne sera quittée que lorsque tous les triangles auront été traités et réorganisés. On vérifie tout d'abord si le meilleur score n'est pas anormalement bas. En effet, à part durant l'initialisation de l'algorithme, ensuite, seuls les triangles qui utilisent les vertices présents dans le cache seront vérifiés, dans un but de performance (pour un modèle de 8000 triangles, ça nous obligerait à vérifier 8000 triangles à chaque boucle, alors que là, on ne se contentera de vérifier que les triangles qui composent les 32 vertices du cache, donc au maximum, en assumant que chaque vertex est utilisée par 6 triangles, 32 * 6 = 192 triangles. De plus, cette approximation est plutôt efficace.
std::size_t vertToAdd[3];
for (std::size_t i = 0 ; i != 3 ; ++i)
{
vertToAdd[i] = triData.at(bestIdx).indices[i];
vertData.at(vertToAdd[i]).remainingTriangles.erase (bestIdx);
triIndices.at((numTriangles - trianglesLeft) * 3 + i) = vertToAdd[i];
}
triData.at(bestIdx).isAdded = true;
--trianglesLeft;
|
Pas grand chose à dire. Les trois vertices du triangle qui vient d'être ajouté sont stockées dans un tableau, l'indice du triangle est supprimé du std::set, puis les indices des vertices sont ajoutées dans le std::vector qui contient les indices réorganisés. Enfin, on règle la variable isAdded du triangle sur true, et pour terminer, on réduit de 1 le nombre de triangles à ajouter. Facile.
bestScore = 0.0f;
bestIdx = -1;
for (std::size_t i = 0 ; i != 3 ; ++i)
{
int rejectedVertice = cacheIdx.insert (vertToAdd[i]);
if ((rejectedVertice >= 0) && (rejectedVertice != vertToAdd[i]))
{
float oldScore = vertData.at(rejectedVertice).score;
float newScore = std::powf (vertData.at(rejectedVertice).remainingTriangles.size(),
-FindVertexScore_ValenceBoostPower)
* FindVertexScore_ValenceBoostScale;
vertData.at(rejectedVertice).score = newScore;
float newScoreMinOldScore = newScore - oldScore;
for (std::set<std::size_t>::const_iterator iter = vertData.at(rejectedVertice).remainingTriangles.begin(),
itEnd = vertData.at(rejectedVertice).remainingTriangles.end() ;
iter != itEnd ; ++iter)
{
triData.at(*iter).score += newScoreMinOldScore;
if (triData.at(*iter).score > bestScore)
{
bestScore = triData.at(*iter).score;
bestIdx = *iter;
}
}
}
}
|
Ca se complique un petit peu à présent. Le meilleur triangle a déjà été ajouté, il faut donc à présent trouver le meilleur triangle suivant. Comme nous l'avons dit plus haut, le meilleur triangle ne sera pas cherché à partir de TOUS les triangles du modèles 3D, mais juste à partir des triangles qui utilisent les vertices dans le cache. Cette approximation est plutôt bonne, car les scores des vertices présents dans le cache sont généralement bien supérieurs à ceux qui ne sont pas dans le cache, d'où le score de leur triangle sont, la plupart du temps, largement supérieurs aux autres triangles.
Ici, on récupère les trois vertices qui sont sorties du cache (oui, on ne teste donc pas 32 vertices pour une taille de cache de 32, mais de 32 + 3 éléments). Souvenez-vous que notre implémentation du cache LRU renvoit l'élément qui est sortie du cache si la taille maximale a été atteinte, ou alors l'élément qui vient d'être ajouté. On récupère donc cet indice, et on vérifie s'il n'est pas inférieur ou égal à 0 (souvenez-vous qu'on a initialisé toutes les valeurs à -1 au début), ou bien qu'il ne soit pas égale à le vertex qui vient d'être ajoutée.
Une fois récupérée, le score de cet vertex est mise à jour, tout comme celui de chaque triangle qui l'utilise, et on vérifie si ce score est le plus haut. Si oui, on récupère l'indice et le score.
Cette opération est imbriquée dans une boucle for qui itérera trois fois (puisque trois vertices sont ajoutées dans le cache, il y a potentiellement trois anciennes vertices qui sortent du cache).
for (std::size_t i = 0 ; i != cacheSize ; ++i)
{
int vertIdx = cacheIdx[i];
if (vertIdx >= 0)
{
float oldScore = vertData.at(vertIdx).score;
float newScore = vertData.at(vertIdx).CalculateScore (i, cacheSize);
vertData.at(vertIdx).score = newScore;
float newScoreMinOldScore = newScore - oldScore;
for (std::set<std::size_t>::const_iterator iter = vertData.at(vertIdx).remainingTriangles.begin(),
itEnd = vertData.at(vertIdx).remainingTriangles.end() ;
iter != itEnd ; ++iter)
{
triData.at(*iter).score += newScoreMinOldScore;
if (triData.at(*iter).score > bestScore)
{
bestScore = triData.at(*iter).score;
bestIdx = *iter;
}
}
}
}
}
return true;
|
Dernière partie de l'algorithme. Nous avons mis à jour les scores des trois vertices potentiellement sorties du cache, on met à présent à jour le score des 32 vertices présents dans le cache, en appelant la fonction membre CalculateScore. Le score dépendant de la position dans le cache du vertex, on envoit donc le paramètre i, qui est la position dans le cache.
Puis on met à jour les scores des triangles, et on vérifie si le score est le meilleur. De la même façon, si oui, on récupère le score et l'indice du triangle. Et voilà, that's done !
V. Optimiser les vertices
Nos indices ont été à présent optimisés, mais il nous reste une ultime étape, qui peut apporter autant en terme de performance que l'optimisation des indices. En effet, il faut réorganiser également les vertices de manière à ce qu'elles soient en accord avec les indices, mais il ne faut surtout pas modifier l'ordre des indices que l'on a calculé auparavant. L'algorithme est plutôt simple : pour chaque indices, on vérifie si le vertex associée a déjà été ajoutée. Si oui, on l'ajoute dans un nouveau tableau, et on met à jour le numéro de le vertex vers lequel le tableau d'indices pointait. Une fois cette étape faite, on échange l'ancien tableau de vertices avec le nouveau :
void ReorderVertices (std::vector<std::size_t> & triIndices,
std::vector<float> & vertices)
{
std::vector<float> newIndices;
std::map<std::size_t, std::size_t> indices;
std::size_t counter = 0;
for (std::size_t i = 0 ; i != triIndices.size() ; ++i)
{
std::size_t idx = triIndices[i];
if (indices.find (idx) == indices.end())
{
newIndices.push_back (vertices[idx * 3 + 0]);
newIndices.push_back (vertices[idx * 3 + 1]);
newIndices.push_back (vertices[idx * 3 + 2]);
indices[idx] = counter;
triIndices[i] = counter++;
}
else
triIndices[i] = indices[idx];
}
vertices.swap (newIndices);
}
|
Un méchanisme similaire doit être fait pour les normales et les indices de texture.
VI. Resultats
Nous allons à présent apprécier les résultats que l'algorithme nous apporte. Pour cela, il existe un nombre, l'ACMR : Average Cache Miss Ratio, qui permet de nous indiquer, comme son nom l'indique, une moyenne du défaut de cache. Le score maximum théorique est de 0.5 (mais ne reste que théorique. Pour l'atteindre, il faudrait en effet un cache extrêmement gros), tandis que le pire score possible est de 3.0.
Voici les résultats de l'ACMR pour un modèle de 5544 triangles (les mesures de temps ont été prises sur un AMD Athlon XP 3000+).
| Taille du cache : |
4 |
8 |
12 |
20 |
24 |
32 |
| Non optimisé : |
1.099 |
1.033 |
1.028 |
1.012 |
1.004 |
0.954 |
| Optimisé : |
1.136 |
0.756 |
0.697 |
0.664 |
0.643 |
0.624 |
| Temps d'optimisation (ms) : |
35 |
38 |
41 |
49 |
52 |
59 |
VII. Remerciements
Merci à Tom Forsyth pour son algorithme, et à loneshock de Gamedev.net pour son implémentation de l'algorithme.
