Optimisations et Bonnes Pratiques
par Théo Richart
Tools Programmer
Ubisoft Montréal
Tools Programmer
Ubisoft Montréal
XNA est un framework intéressant pour le développement amateur de jeux vidéo en C#. Mais le langage lui-même fonctionne sur une machine virtuelle et est garbage-collecté ; si cela peut paraître un gain de simplicité de développement, ces points impliquent une rigueur renouvelée. De plus, Le gamedev en général passe toujours par une phase d'optimisation. Voici quelques axes de recherche pour gagner en performances.
Gestion de la mémoire
Contrairement aux langages comme le C ou le C++, il n'y a pas d'opérateur de libération de la mémoire en C# : le garbage collector (GC) s'occupe de libérer les objets qui ne sont plus utilisés. Mais le fonctionnement intrinsèque du GC impose une attention renouvelée.
Garbage Collector
Sur PC, le GC est générationnel. Mais sur Xbox, ce n'est pas le cas : dès que la quantité de mémoire allouée dépasse 1 Mo, une GC complète est lancée. Cela implique de mettre en pause tous les threads, parcourir tous les objets pour déterminer si ils possèdent au moins une référence et les libérer si ce n'est pas le cas.
L'allocation de variable à chaque frame est donc à proscrire, au profit de la réutilisation d'objets existants. Typiquement, imaginons le code suivant :
L'allocation de variable à chaque frame est donc à proscrire, au profit de la réutilisation d'objets existants. Typiquement, imaginons le code suivant :
void Update(Gametime time) { Vector3 delta = new Vector3(1,1,0); float speed = time.ElapsedGametime.TotalSeconds; this.pos_ = delta * speed; } |
A chaque update, deux vecteurs sont alloués : delta et le produit de delta et de speed. Premièrement, delta est une constante et peut donc être stockée comme un champ constant de la classe. Ensuite, au lieu d'utiliser la surcharge de l'opérateur de multiplication, il existe une méthode statique de la classe Vector3 qui modifie un vecteur existant pour y placer le résultat de la multiplication.
private const Vector3 delta_ = new Vector3(1,1,0); void Update(Gametime time) { float speed = time.ElapsedGametime.TotalSeconds; Vector3.Multiply(ref this.delta_, speed, out this.pos_); } |
Une allocation est ainsi épargnée.
Structures et classes
En C#, les types sont classés en deux catégories : les types valeur et les types référence. Les types fondamentaux (float, char, ...) et les structures sont des types valeur ; les classes, des types référence.
L'une des différences entre les deux apparaît lors des affectations : un type valeur est systématiquement copié, contrairement aux types référence. Les vecteurs et les matrices, en XNA, sont des types valeurs.
Ce comportement peut donc être problématique si il n'est pas compris. Ceci est un exemple simple :
L'une des différences entre les deux apparaît lors des affectations : un type valeur est systématiquement copié, contrairement aux types référence. Les vecteurs et les matrices, en XNA, sont des types valeurs.
Ce comportement peut donc être problématique si il n'est pas compris. Ceci est un exemple simple :
var x = new Data(); var y = x; |
Selon que Data soit une classe ou une structure, le nombre d'allocations mémoire varie. A l'identique, dans l'exemple précédent, le simple fait d'affecter un vecteur provoque une allocation superflue.
Mise en cache et pooling
De fait, il est intéressant de réutiliser des objets existants. Les munitions d'une arme sont un exemple typique : imaginons qu'un ennemi tire sur le joueur. La solution intuitive consiste à recréer un projectile à chaque fois. Mais, en réalité, on peut limiter le nombre de projectiles à un instant t, les allouer tous une fois à l'initialisation et activer un projectile désactivé quand l'ennemi tire :
// Classe représentant un projectile class Bullet { public bool Enabled; // valeur par défaut : false public void Update(){} } class Enemy { public void Enemy() { Bullets = new Bullet[10]; // Initialisation des projectiles for(int i = 0; i < 10; i++) Bullets[i] = new Bullet(); } public void Update() { if(mustShoot) // en cas de tir { // On cherche le premier projectile désactivé Bullet b = Bullets.FirstOrDefault(x => !x.Enabled); // Si tous sont activés, on prend le premier, quie st aussi le premier activé b = b ?? Bullets.First(); b.Enabled = true; } foreach(Bullet b in Bullets) if(b.Enabled) b.Update(); } } |
De cette façon, le nombre d'allocations baisse drastiquement : elles sont toutes faites à l'initialisation.
Algorithmes
Les allocations mémoires ne sont pas l'unique cause d'une perte de performances. Souvent, optimiser algorithmiquement un programme peut amener des améliorations conséquentes.
Détermination des objets visibles
L'une premières optimisations à effectuer est de restreindre le nombre d'objets dessinés. Par exemple, dessiner un objet derrière la caméra est une perte de temps. Il faut donc déterminer si un objet, ou, pour simplifier les calculs, une boîte dans laquelle inscrire l'objet, la bounding box (BB), se trouve dans le champ de la caméra. Ce champ de vision est représenté sous la forme d'une pyramide tronquée, qui donne son nom à cette famille d'algorithmes : le frustrum culling.
Malheureusement, parcourir tous les objets pour déterminer leur visibilité peut prendre énormément de temps. Le moyen le plus simple de réduire ce nombre est l'utilisation d'algorithmes de partitionnement de l'espace. Si on coupe le monde en quatre zones et que l'on détermine à quelle zone appartient chaque objet, on peut éliminer rapidement plusieurs objets sans les tester individuellement si la zone entière est invisible.
Malheureusement, parcourir tous les objets pour déterminer leur visibilité peut prendre énormément de temps. Le moyen le plus simple de réduire ce nombre est l'utilisation d'algorithmes de partitionnement de l'espace. Si on coupe le monde en quatre zones et que l'on détermine à quelle zone appartient chaque objet, on peut éliminer rapidement plusieurs objets sans les tester individuellement si la zone entière est invisible.
Quadtree with point data
De plus, chaque zone peut elle-même être subdivisée en quatre zones plus petites, ... La subdivision en quatre zones est appelée quadtree, octree s'il y en a huit.
Cette solution peut être un peu plus complexe à mettre en oeuvre dans le cas d'objets dynamiques, qui peuvent potentiellement changer de zone. La maintenance de l'arbre devient critique, mais reste malgré tout négligeable par rapport au gain apporté.
Cette solution peut être un peu plus complexe à mettre en oeuvre dans le cas d'objets dynamiques, qui peuvent potentiellement changer de zone. La maintenance de l'arbre devient critique, mais reste malgré tout négligeable par rapport au gain apporté.
Occlusion Culling
Les algorithme de partitionnement permettent de filtrer les objets hors champ, mais n'excluent pas les objets cachés derrière d'autres objets.
L'occlusion culling est un algorithme de détermination des objets visibles qui utilise une fonctionnalité de Directx9, les occlusion queries.
Ces requêtes permettent de déterminer si une render target a changé entre les instants t et t+1. L'objectif est donc de dessiner tous les objets après le filtrage par partitionnement et de déterminer, grâce aux requêtes, si le dessin d'un objet a modifié la target. Si ce n'est pas le cas, l'objet est derrièere un autre objet et n'a donc pas passé le Z test. Il faut dont dessiner les objets après les avoir triés selon leur distance croissante à la caméra.
Attention cependant : l'efficacité de cette méthode est très dépendante du matériel et doit être implémentée avec soin.
L'occlusion culling est un algorithme de détermination des objets visibles qui utilise une fonctionnalité de Directx9, les occlusion queries.
Ces requêtes permettent de déterminer si une render target a changé entre les instants t et t+1. L'objectif est donc de dessiner tous les objets après le filtrage par partitionnement et de déterminer, grâce aux requêtes, si le dessin d'un objet a modifié la target. Si ce n'est pas le cas, l'objet est derrièere un autre objet et n'a donc pas passé le Z test. Il faut dont dessiner les objets après les avoir triés selon leur distance croissante à la caméra.
Attention cependant : l'efficacité de cette méthode est très dépendante du matériel et doit être implémentée avec soin.
Hardware instancing
Le Hardware Instancing (HI) est une technique permettant de dessiner plusieurs fois le même mesh en un unique draw call. Pour cela, en plus du vertex buffer contenant les vertices du mesh, on fournit en même temps un second buffer contenant un vertex par instance du mesh : chaque vertex contiendra les matrices de position, rotation, ... qui seront utilisées pour dessiner un objet en particulier.
Cette méthode amène des gains non négligeables, mais peut provoquer des refactorings conséquents. Dans le cas où certains meshes sont dessinés un grand nombre de fois, ne pas utiliser le HI serait dommage.
L'un des exemples XNA permet ainsi de dessiner plus de 500 000 objets à 60FPS, quand le frame rate tombe à 5FPS sans HI.
Enfin, cette technique peut poser problème dans le cas d'objets transparents : il faut alors séparer les objets transparents ou non, et dessiner d'abord tous les objets opaques.
Cette méthode amène des gains non négligeables, mais peut provoquer des refactorings conséquents. Dans le cas où certains meshes sont dessinés un grand nombre de fois, ne pas utiliser le HI serait dommage.
L'un des exemples XNA permet ainsi de dessiner plus de 500 000 objets à 60FPS, quand le frame rate tombe à 5FPS sans HI.
Enfin, cette technique peut poser problème dans le cas d'objets transparents : il faut alors séparer les objets transparents ou non, et dessiner d'abord tous les objets opaques.
Conclusion
En conclusion, les voies d'optimisation ne manquent pas : au niveau du langage, des algorithmes, des communications CPU/GPU, ... Cet article n'est qu'une introduction visant à présenter certaines des pistes existantes.