Série de quatres tutoriaux consacré à la gestion intelligente des objets à l'écran.
- Le premier article sera consacré au caching (réduction de la charge mémoire).
- Le second à une gestion intelligente des objets à l'écran.
- Le troisième au culling pour améliorer les performances.
- Le quatrième à un système de messages pour décharger le traitement CPU
Le but étant au final de créer un moteur de jeu sur lequel nous allons nous déplacer.
Pour les débutants : Je ne saurais trop vous conseiller de lire les articles que j'ai traduit sur la MSDN fait par Derek Pierson. Si vous arrivez à suivre ces articles sans trop de difficultés, vous pouvez lire ce qui suit sans problèmes, pour les autres, posez vos questions ici.
Au commencent était un moteur vierge sans optimisation
Encore une fois, je pars pour le programme d'exemple d'un sample du sdk DirectX que j'ai vidé pour ne garder que le framework. A partir de là j'ai utilisé le code de mon propre moteur (que je développe en parallèle) pour créer une version épurée, lente, mal conçue, qui ne demande qu'à être améliorée.
Le cahier des charges au départ est le suivant :
- Créer et afficher un terrain.
- Afficher 640 arbres
- Afficher 5760 herbes
Rien de bien compliqué en somme. Environ deux heures de développement en Managed DirectX/C# (contre plusieurs jours en DirectX/C++ Standard
).
Si vous lancez le moteurs vous remarquerez trois énormes lacunes (voir image ci-dessous) :
- Le jeu freeze au lancement le temps du chargement.
- Le jeu est lent (4 à 5 fps sur mon PC).
- Il consomme une place mémoire très importante.

Il s'agit là d'un problème typique de tout bon débutant en 3D : on veut afficher le maximum, épater la gallerie pour finir malheureusement sur un programme lent, inexploitable.
A la fin de cet article nous aurons un chargement bien plus rapide (et asynchrone), une consommation mémoire réduite au maximum. Nous utiliserons pour cela un système de cache.
Il faut profiter de cette série d'articles pour bien assmiler le fonctionnement du moteur 3D afin de ne pas être trop perdu au fil du temps. Nous verrons étapes par étapes les différentes classes qui composent le framework sur lequel il repose (le moteur au final compte à peu pres 400 classes ...).
Cache ?
Le problème actuel
Quel avantage va nous fournir un système de cache ?
Si vous avez déjà développé en 2D vous devez savoir qu'un jeu du type Mario est composé de petites cases ou "tiles" qui sont placées à l'écran pour former un monde de jeu. Bien souvent une partie de ces tiles sont répétées plusieurs fois à l'écran. Dans un moteur 3D la done est à peu près identique. Si vous prennez l'image du jeu ci dessus vous vous rendrez compte que je n'affiche que deux sortes d'arbres, un très "feuillu" et un peu "feuillu". Chacun d'eux est répété à peu près 300 fois. Chaque arbre de compose d'un tronc et d'un feuillage. Pour chacun d'eux est associé un IndexBuffer et un VertexBuffer. Le moteur se voit donc obligé d'afficher à chaque Frame :
300* 2 Arbres = 600 objets 3D = 600 Feuillages et 600 troncs = 1200 VertexBuffers et 1200 IndexBuffers !!
(bon certes j'ai exagéré la nullité du moteur ...)
Il y'a une solution beaucoup plus efficace en terme de mémoire : Nous pourrions juste instancier deux vertexbuffers/indexbuffers correspondant aux models feuillu et non feuillu et répéter leur affichage dans le monde aux différentes positions et tailles voulues.
Ainsi, chaque classe Arbre ne possederai pas son propre le vertexbuffer et l'indexBuffer pour le tronc et le feuillage mais pointerai vers une classe du genre Mesh ou ProgressiveMesh mutualisée.
Solution
Un classe devra servir de manager afin de servir d'interlocuteur à tous nos objets métiers (arbre, Herbe, etc.) dans leur demande d'allocation de données 3D. Ce maager recevra une demande sous la forme d'un identifiant. S'il possède déjà un objet avec cet identifiant (l'identifiant peut être un path par exemple vers un fichier X) il le renvoie, sinon il le créé, puis le renvoie.
Une autre classe ItemCache devra servir d'encapsuleur vers les données 3D. Ces données peuvent être comme déjà précisé un objet de type Mesh ou ProgressiveMesh. Tous les objets métier demandant au manager un lien vers un même identifier pointeront vers le meme objet ItemCache.
La classe ItemCache possède une propriété Item qui pointe vers les données à mutualiser (un objet de type Mesh ou Texture par exemple). A ce stade Item vaut null. Deux possibilités pour l'allouer :
Soit en appellant spécifiquement LoadInCache pour ItemCache
Soit en accédant à la propriété Item pour la première fois. Au premier accès à l'accesseur Get de la propriété Item est créé, aux accès suivant l'objet créé est simplement renvoyée. Un système de délégate a été créé pour cela. Au premier accès à Item le delegate pointe vers une méthode de création, aux appels suivants le delegate pointe vers une méthode qui renvoie l'objet.
Là encore, ce système permet un chargement asynchrone : les traitements non synchrones sont placés dans le constructeur d'ItemBase, et les traitements synchrones (liés au device) dans une méthode spéciale nommée AllocateValue (vers laquelle pointe justement notre fameux delegate au départ).
La classe ItemBase est générique. L'argument T de specificité correspond à l'objet à mettre en cache (correspondant à la propriété Item dont je parlais plus haut). De même pour la classe manager.
Le projet contient une classe nommée OptimizationEngineCache (à télécharger ici) qui vous montre ce que donne le nouveau moteur : un chargement à la fois avec une fenêtre de progression et beaucoup plus rapide : seuls 5 modèles 3D sont chargés en mémoire en plus des données du terran : 2 types d'arbres et 3 types d'herbe (contre plusieurs milliers de vertexbuffers et indexbuffers auparavant je le rappel :) ).
Etude du code
Classes liées au cache
Tout d'abord commandez par télécharger les samples associés à ces articles (lien en fin de post). Ouvrez le premier projet.
Etudions tout d'abord la classe qui correspond à la classe mère générique de notre manager.
/// <summary>
/// <para>Base caching system class.</para>
/// </summary>
/// <typeparam name="T"></typeparam>
public abstract class CachingSystemManagerBase<T>
{
+#region Private members
#region Properties
public Device Device
{
get
{
return this._device;
}
set
{
this._device = value;
}
}
public List<CachedItemBase<T>> Items
{
get
{
return this._cache;
}
}
internal class LastUtilisationComparer : IComparer
{
#region IComparer Members
public int Compare(object x, object y)
{
CachedItemBase<T> c1 = x as CachedItemBase<T>;
CachedItemBase<T> c2 = y as CachedItemBase<T>;
if (c1.LastUtilisation < c2.LastUtilisation)
return 1;
if (c1.LastUtilisation > c2.LastUtilisation)
return -1;
else
return 0;
}
#endregion
}
#endregion
#region Constructor
/// <summary>
/// <para>Hidden constructor.</para>
/// </summary>
protected CachingSystemManagerBase()
{
this._dictionnary = new Dictionary<string, CachedItemBase<T>>();
this._cache = new List<CachedItemBase<T>>();
this._sortedList = new SortedList(new LastUtilisationComparer());
}
#endregion
#region Public methods
/// <summary>
/// <para>Allate space for a specified item.</para>
/// </summary>
/// <param name="identifier">Identifier of the item to allocate.</param>
public CachedItemBase<T> AllocateItem(string identifier)
{
CachedItemBase<T> item;
//if the item is already in cache
if (this._dictionnary.ContainsKey(identifier))
{
//return it.
item = this._dictionnary[identifier];
}
else
{
item = this.AllocateNewItem(identifier);
this._dictionnary.Add(identifier, item);
this._cache.Add(item);
item.IndexedValue = this.currentIndex++;
}
item.NumberOfObjectsCurrentlyUsingThisItem++;
return item;
}
public static void Clean()
{
//_sortedList.sor
}
#endregion
#region Protected Methods
/// <summary>
/// <para>To be implemented in order to create the item in cache.</para>
/// </summary>
/// <returns></returns>
protected abstract CachedItemBase<T> AllocateNewItem(string identifer);
#endregion
}
Deux méthodes importantes ici. Tout d'abord AllocateItem(string). Cette méthode renvoie un item mutualisé. Elle consulte un dictionnaire contenant tous les items mutalisés pour savoir si l'item dont on fourni l'identifiant est déjà présent. Dans ce cas elle le renvoie. Dans le cas contraire elle créé l'objet, place une préférence dans le dictionnaire et le renvoie. La propriété
NumberOfObjectsCurrentlyUsingThisItem permet de détemriner le nombre de références faites vers un item en cache. De cette manière il est possible de savoir quand libérer un objet si celle-ci vaut 0 (à la manière du GarbageCollector). Vient ensuite la méthode AllocateNewItem qui a pour charge de créer un nouvel item et de le renvoyer. Cette méthode est bien entendu abstraite afin de laisser la possibilité au développeur d'implémenter lui-même la création des Items mutualisées. L'argument de spécificité T correspond au type des données à mutualiser.
Rien de bien compliqué ici. Voyons maintenant la classe CachedItemBase.
/// <summary>
/// <para>Base cached item class.</para>
/// </summary>
/// <typeparam name="T"></typeparam>
public abstract partial class CachedItemBase<T> : IDisposable
{
#region Private members
[System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)]
private string _accessPath;
[System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)]
private CachingSystemManagerBase<CachedItemBase<T>> _cacheSystem;
[System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)]
private T _item;
[System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)]
private int _indexedValue;
[System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)]
private bool _disposed;
[System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)]
private DateTime _lastUtilisation;
[System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)]
protected bool _loaded;
[System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)]
protected int _numberOfObjectsCurrentlyUsingThisItem;
#endregion
#region Properties
/// <summary>
/// <para>Index value if the current item is not in cache.</para>
/// </summary>
public const int NotInCache = -1;
/// <summary>
/// <para>Called when the current item is disposed.</para>
/// </summary>
public event EventHandler Disposed;
/// <summary>
/// <para>Called when the current item is allocated.</para>
/// </summary>
public event EventHandler Allocated;
/// <summary>
/// <para>Shunter to the value.</para>
/// </summary>
private ItemPeekerHandler<T> ShuntingToValue;
/// <summary>
/// <para>Gets the number of reference to the current item.</para>
/// </summary>
public int NumberOfObjectsCurrentlyUsingThisItem
{
get
{
return this._numberOfObjectsCurrentlyUsingThisItem;
}
internal set
{
this._numberOfObjectsCurrentlyUsingThisItem = value;
}
}
/// <summary>
/// <para>Gets the last item's utilisation.</para>
/// </summary>
public DateTime LastUtilisation
{
get
{
return this._lastUtilisation;
}
}
/// <summary>
/// <para>Gets or sets the item's path.</para>
/// </summary>
public string Path
{
get
{
return this._accessPath;
}
set
{
this._accessPath = value;
}
}
/// <summary>
/// <para>Gets the Indexed value of the current item inside the cache array.</para>
/// </summary>
public int IndexedValue
{
get
{
return this._indexedValue;
}
internal set
{
this._indexedValue = value;
}
}
/// <summary>
/// <para>Gets or sets the item.</para>
/// </summary>
public T Item
{
get
{
this._lastUtilisation = DateTime.Now;
return this.ShuntingToValue();
}
}
public CachingSystemManagerBase<CachedItemBase<T>> CachingSystem
{
get
{
return this._cacheSystem;
}
set
{
this._cacheSystem = value;
}
}
public bool Loaded
{
get
{
return this._loaded;
}
}
#endregion
#region Constructor
/// <summary>
/// <para>Instanciate a sub class inherited from CachedItemBase.</para>
/// </summary>
/// <param name="CachingSystem"></param>
/// <param name="path"></param>
public CachedItemBase(string path)
{
this._accessPath = path;
this._indexedValue = NotInCache;
this._disposed = true;
}
/// <summary>
/// <para>Destructor.</para>
/// </summary>
~CachedItemBase()
{
// Simply call Dispose(false).
Dispose(false);
}
#endregion
#region IDisposable Members
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (!this._disposed)