Le Handle
Un identifiant qui survit à la mort de ce qu'il désigne — et qui le sait.
- Comprendre pourquoi un pointeur, une référence et un index nu échouent tous les trois à désigner une ressource dans la durée.
- Construire un Handle (index + génération) qui détecte l'accès à une ressource déjà libérée.
- Recycler les emplacements libres avec une free list sans jamais réutiliser un ancien identifiant.
- Centraliser la validation dans un point unique, plutôt que de dupliquer un test de bornes partout.
Le problème : comment désignes-tu une ressource ?
Ton moteur charge un modèle, une texture, un son. Il faut bien que le reste du programme puisse dire « celui-là ». La question paraît triviale, elle ne l'est pas : la ressource peut déménager en mémoire, ou disparaître, et l'identifiant que tu as distribué survit à ces deux événements.
Trois candidats, trois échecs.
1 — Le pointeur brut
/// C++ — le piège classique
std::vector<Blender> m_models;
Blender* load(const std::string& path)
{
m_models.push_back(parse(path));
return &m_models.back(); // ← bombe à retardement
}
Blender* perso = load("perso.fbx");
Blender* arbre = load("arbre.fbx"); // realloc → le vector a déménagé
perso->transform = M; // perso pointe dans un bloc libéré
push_back peut réallouer le tampon interne du vector. Tous les pointeurs et références vers les éléments deviennent invalides d'un coup. Le programme ne plante pas forcément : la mémoire libérée reste souvent lisible quelques instants. C'est pire qu'un crash.
reserve() ne sauve pas. Il repousse la première réallocation, il ne la supprime pas. Et un code dont la correction dépend d'un reserve écrit trois fichiers plus loin n'est pas un code correct, c'est un code chanceux.
2 — La référence
Même mécanisme, même issue. Une Blender& est un pointeur avec une syntaxe plus agréable et l'interdiction d'être nulle — ce qui la rend plus dangereuse ici, puisque tu ne peux même pas la tester.
3 — L'index nu
L'index résiste à la réallocation : m_models[3] reste le quatrième élément où que le vector ait déménagé. C'est déjà un vrai progrès, et c'est exactement ce que fait SpriteRenderer aujourd'hui :
/// C++ — SpriteRenderer.cpp — l'état actuel
const uint32_t id = static_cast<uint32_t>(m_textures.size());
m_textures.push_back(entry);
m_lookup.emplace(resourcesPath, id);
// …et la lecture :
MTL::Texture* SpriteRenderer::texture(uint32_t id) const
{
return id < m_textures.size() ? m_textures[id].texture.get() : nullptr;
}
Ce code est correct tant que rien n'est jamais déchargé. Le jour où tu ajoutes un unloadTexture(), il devient silencieusement faux :
/// C++ — le use-after-free silencieux
uint32_t explosion = sprites.loadTexture("fx/explosion.png"); // id = 3
uint32_t fumee = sprites.loadTexture("fx/smoke.png"); // id = 4
sprites.unloadTexture(explosion); // slot 3 libéré
uint32_t sang = sprites.loadTexture("fx/blood.png"); // recycle le slot 3
sprites.texture(explosion);
// → renvoie la texture de sang.
// id = 3, 3 < m_textures.size(), le test passe. Aucune erreur, aucun warning.
// À l'écran : du sang à la place de l'explosion.
C'est le bug qu'on ne trouve pas au débogueur. Il n'y a rien à casser : la structure est valide, l'index est dans les bornes, la texture existe. Ce n'est simplement pas la bonne. Tu le découvres au capture GPU, ou pire, dans un rapport de joueur.
Un index nu répond à la question « où ? » mais jamais à « quoi ? ». Il désigne un emplacement, pas un contenu. Recycler l'emplacement recycle donc l'identifiant.
id < m_textures.size() ne détecte-t-il pas l'accès à une texture déchargée ?La solution : ajouter une identité à l'emplacement
L'idée tient en une phrase : on garde l'index, et on lui adjoint un compteur de réutilisations de l'emplacement. Le handle porte les deux. À la lecture, on compare : si le compteur du handle diffère de celui du slot, c'est que le slot a été recyclé depuis — le handle est périmé.
/// C++ — AssimpBlender.hpp
// c'est littéralement le même principe que le système d'InstanceID d'Unity
struct ModelHandle
{
uint32_t index = UINT32_MAX;
uint32_t generation = 0;
bool isValid() const { return index != UINT32_MAX; }
bool operator==(const ModelHandle& other) const
{
return index == other.index && generation == other.generation;
}
bool operator!=(const ModelHandle& other) const { return !(*this == other); }
};
// Handle nul explicite, pratique pour initialiser des membres ailleurs dans le moteur.
static constexpr ModelHandle kInvalidModelHandle{};
Huit octets. Copiable, comparable, passable par valeur sans remords. Aucune allocation, aucun compteur de références, aucune indirection à la construction.
UINT32_MAX et pas 0 ?Parce que 0 est un index parfaitement légitime — le premier modèle chargé. Si le handle par défaut valait { 0, 0 }, un membre non initialisé désignerait ton premier modèle au lieu de rien. UINT32_MAX est un index qu'aucun tableau réaliste n'atteindra jamais : il peut donc servir de sentinelle. La valeur par défaut de la structure est ainsi déjà le handle nul, sans que personne ait à y penser.
Le slot, côté gestionnaire
/// C++ — AssimpBlender.hpp — section private
struct ModelSlot
{
Blender model;
uint32_t generation = 0;
bool alive = false;
}; // m_slots[5] alive = true generation = 3
std::vector<ModelSlot> m_slots;
std::vector<uint32_t> m_freeList;
std::unordered_map<std::string, ModelHandle> m_modelNameToHandle;
size_t m_aliveCount = 0;
Trois choses à remarquer.
aliveetgenerationvivent dans le slot, pas dans le modèle- Ce sont des données de gestion, pas des données de modèle. Un
Blendern'a aucune raison de savoir qu'il est stocké dans un tableau à trous. - Le slot n'est jamais retiré du vector
- Un
erase()décalerait tous les éléments suivants et invaliderait tous les index au-dessus. On marquealive = falseet on laisse le trou en place. Le vector ne rétrécit jamais. m_aliveCountest maintenu à la main- Parce que
m_slots.size()compte les trous.getModelCount()doit renvoyer le nombre de modèles vivants, pas le nombre d'emplacements jamais alloués.
Le point de vérité unique
Toute la validation tient en une fonction privée. C'est le cœur du chapitre.
/// C++ — AssimpBlender.cpp
// Résout un handle vers son slot, ou nullptr si invalide/périmé.
// Toutes les méthodes publiques passent par ici — un seul point de vérité
// pour la validation, pas de borne-check dupliqué partout.
AssimpBlender::ModelSlot* AssimpBlender::resolveSlot(ModelHandle handle)
{
if (!handle.isValid() || handle.index >= m_slots.size())
return nullptr;
ModelSlot& slot = m_slots[handle.index];
if (!slot.alive || slot.generation != handle.generation)
return nullptr; // handle périmé : slot recyclé pour un autre modèle
return &slot;
}
Trois refus, trois causes distinctes :
- Handle jamais initialisé
index == UINT32_MAX— quelqu'un a déclaré un membreModelHandleet ne l'a jamais assigné.- Handle hors bornes
index >= m_slots.size()— un handle d'un autre gestionnaire, ou de la partie précédente. Rare, mais gratuit à tester.- Handle périmé
slot.generation != handle.generation— c'est le cas qui n'existait pas avec un index nu. Le slot est vivant, il est dans les bornes, mais il contient autre chose.
constLa version const de resolveSlot est écrite deux fois, à l'identique au const près. C'est le prix normal à payer en C++17 ; en C++23, deducing this permet de n'en écrire qu'une. Ne cède pas à la tentation du const_cast pour économiser six lignes : la duplication est ici plus lisible que l'astuce.
Le bénéfice se voit immédiatement dans les accesseurs, qui deviennent des une-ligne :
/// C++ — AssimpBlender.cpp
bool AssimpBlender::isValid(ModelHandle handle) const
{
return resolveSlot(handle) != nullptr;
}
Blender* AssimpBlender::getModel(ModelHandle handle)
{
ModelSlot* slot = resolveSlot(handle);
return slot ? &slot->model : nullptr;
}
Et dans les vingt-et-quelques méthodes publiques du gestionnaire — playAnimation, drawBlender, debugBones, setAnimationSpeed… — le préambule est toujours le même :
void AssimpBlender::stopAnimation(ModelHandle handle)
{
ModelSlot* slot = resolveSlot(handle);
if (!slot)
return;
// …à partir d'ici, slot->model est garanti vivant et correct.
}
Une seule ligne à corriger le jour où la politique de validation change. Compare avec la version où chaque méthode refait son propre if (index < m_slots.size() && m_slots[index].alive) : vingt occasions de se tromper, et une seule oubliée suffit.
Le cycle de vie
Décharger : incrémenter, puis recycler
/// C++ — AssimpBlender.cpp
void AssimpBlender::unloadModel(ModelHandle handle)
{
ModelSlot* slot = resolveSlot(handle);
if (!slot)
return; // déjà déchargé, ou handle jamais valide -> no-op silencieux
m_modelNameToHandle.erase(slot->model.name);
slot->model.release(); // libère les MTL::Buffer et MTL::Texture
slot->model = Blender{}; // vide les vecteurs CPU (vertices, indices, animations...)
slot->alive = false;
slot->generation++; // périme tous les handles existants vers ce slot
m_freeList.push_back(handle.index);
m_aliveCount--;
}
La ligne slot->generation++ est la seule qui compte vraiment. Tout le reste est du ménage. Cette incrémentation invalide, en une instruction et en temps constant, tous les handles vers ce slot — qu'il y en ait un ou quatre cents, qu'ils soient dans un vector, un composant d'entité ou une lambda capturée. Tu n'as aucune liste d'observateurs à parcourir.
Décharger deux fois le même modèle ne fait rien la seconde fois, et c'est délibéré. Le second appel passe par resolveSlot, qui refuse le handle périmé. Un double unload devient donc inoffensif — là où un delete deux fois est un comportement indéfini.
Noter aussi l'ordre : release() libère les objets Metal, puis = Blender{} réassigne un objet vide pour vider les std::vector CPU (les vertices d'un modèle skinné pèsent lourd, il serait dommage de les garder dans un slot mort). C'est pour ça que le commentaire de Blender::release() précise qu'il ne réinitialise pas les vecteurs : la responsabilité est ici.
Charger : réutiliser un trou, ou en creuser un nouveau
/// C++ — AssimpBlender.cpp — fin de loadModel()
ModelHandle handle;
if (!m_freeList.empty())
{
uint32_t idx = m_freeList.back();
m_freeList.pop_back();
ModelSlot& slot = m_slots[idx];
slot.model = std::move(model);
slot.alive = true;
// slot.generation a déjà été incrémentée par unloadModel()
handle = { idx, slot.generation };
}
else
{
ModelSlot slot;
slot.model = std::move(model);
slot.alive = true;
slot.generation = 0;
m_slots.push_back(std::move(slot));
handle = { static_cast<uint32_t>(m_slots.size() - 1), 0 };
}
m_modelNameToHandle[m_slots[handle.index].model.name] = handle;
m_aliveCount++;
return handle;
Le détail qui fait tout : la génération n'est pas incrémentée ici. Elle l'a déjà été au déchargement. Si tu l'incrémentais aux deux endroits, elle avancerait de deux à chaque cycle — ça marcherait quand même, mais tu perdrais la moitié de ta plage utile et tu aurais deux endroits à maintenir cohérents au lieu d'un. Une seule règle : la génération avance à la mort, jamais à la naissance.
La free list est un simple vector utilisé en pile — back() / pop_back(), donc dernier libéré, premier réutilisé. C'est O(1) et ça favorise la localité de cache : le slot qu'on vient de libérer est probablement encore chaud.
Ce que ça donne dans le temps
m_slots[3]
t0 load("explosion") alive=true gen=0 handle A = { 3, 0 } ✓
t1 unload(A) alive=false gen=1 handle A = { 3, 0 } ✗ périmé
t2 load("sang") alive=true gen=1 handle B = { 3, 1 } ✓
handle A = { 3, 0 } ✗ toujours périmé
resolveSlot(A) → slot.generation (1) != handle.generation (0) → nullptr
resolveSlot(B) → slot.generation (1) == handle.generation (1) → &slot
Le handle A ne redeviendra jamais valide. Il ne peut redevenir valide qu'après 2³² cycles de charge/décharge sur ce slot précis, soit environ quatre milliards. Si ton jeu décharge un modèle par frame à 60 Hz, il faut deux ans et deux mois de fonctionnement continu, sur le même emplacement, pour croiser une collision. On considère raisonnablement que ça n'arrive pas.
Les moteurs qui prennent la question au sérieux réservent moins de bits à la génération (12 ou 16) et refusent de recycler un slot dont la génération sature. On sacrifie un emplacement — au pire quelques dizaines sur une session — contre une garantie absolue. Pour un moteur de jeu à 32 bits pleins, le calcul ci-dessus suffit largement.
unloadModel incrémente-t-il la génération plutôt que de simplement mettre alive = false ?La table des noms
Chercher un modèle par son nom est pratique au chargement d'un niveau, désastreux dans la boucle de rendu : un unordered_map hache la chaîne, suit un pointeur, compare les caractères. Le handle, lui, est un accès direct au tableau.
/// C++ — AssimpBlender.cpp
ModelHandle AssimpBlender::getHandle(const std::string& name) const
{
auto it = m_modelNameToHandle.find(name);
return it != m_modelNameToHandle.end() ? it->second : kInvalidModelHandle;
}
D'où le motif d'usage : tu paies le nom une fois, tu utilises le handle ensuite.
/// C++ — dans ton Renderer
// Au chargement de la scène — une fois.
m_perso = m_blender.getHandle("perso");
m_arbre = m_blender.getHandle("arbre");
// Dans update(), 60 fois par seconde — aucun hachage, aucune chaîne.
m_blender.setAnimationSpeed(m_perso, 1.5f);
Noter que unloadModel retire l'entrée de la map avant de libérer le modèle : sans ça, getHandle("perso") continuerait à distribuer un handle périmé — techniquement inoffensif, puisque resolveSlot le refuserait, mais déroutant à déboguer.
loadModel avertit si le nom existe déjà et laisse le nouveau modèle prendre la main sur la recherche par nom. L'ancien modèle reste parfaitement vivant et accessible par son handle — il devient juste anonyme. C'est un choix défendable, mais qui mérite d'être connu : si tu charges deux fois "arbre", getHandle("arbre") ne te rendra jamais le premier.
Intégration : passer SpriteRenderer aux handles
Le gestionnaire de textures a exactement le même besoin, et le même trou. Voici la transposition, en gardant ton cache par chemin.
1 — Le type
/// C++ — SpriteRenderer.hpp
struct TextureHandle
{
uint32_t index = UINT32_MAX;
uint32_t generation = 0;
bool isValid() const { return index != UINT32_MAX; }
bool operator==(const TextureHandle&) const = default; // C++20 : == et != d'un coup
};
static constexpr TextureHandle kInvalidTextureHandle{};
Ce type remplace ton static constexpr uint32_t kInvalidTexture = ~0u;. Note au passage que ~0u et UINT32_MAX sont la même valeur — ta sentinelle était déjà la bonne, il ne lui manquait que le compteur.
2 — Le slot
/// C++ — SpriteRenderer.hpp — section private
struct GPUTexture
{
NS::SharedPtr<MTL::Texture> texture;
uint32_t width = 0;
uint32_t height = 0;
Filter filter = Filter::Linear;
std::string path; // pour retirer l'entrée de m_lookup au déchargement
uint32_t generation = 0;
bool alive = false;
};
std::vector<GPUTexture> m_textures;
std::vector<uint32_t> m_freeTextures;
std::unordered_map<std::string, TextureHandle> m_lookup;
Ton GPUTexture tient déjà sa texture dans un NS::SharedPtr. Réassigner slot.texture = {} au déchargement relâche donc la référence Metal toute seule — tu n'as pas l'équivalent du Blender::release() manuel à écrire. C'est exactement l'argument pour RAII : la classe qui gère la durée de vie et celle qui possède la ressource sont deux problèmes séparés, et un seul des deux doit être écrit à la main.
3 — La résolution et le chargement
/// C++ — SpriteRenderer.cpp
SpriteRenderer::GPUTexture* SpriteRenderer::resolve(TextureHandle handle)
{
if (!handle.isValid() || handle.index >= m_textures.size())
return nullptr;
GPUTexture& slot = m_textures[handle.index];
if (!slot.alive || slot.generation != handle.generation)
return nullptr;
return &slot;
}
TextureHandle SpriteRenderer::loadTexture(const std::string& resourcesPath,
Filter filter, bool sRGB, bool mipmaps)
{
if (auto it = m_lookup.find(resourcesPath); it != m_lookup.end())
return it->second; // déjà en VRAM : on ne recharge pas
// … tout ton chargement stb + blit, inchangé …
TextureHandle handle;
if (!m_freeTextures.empty())
{
uint32_t idx = m_freeTextures.back();
m_freeTextures.pop_back();
GPUTexture& slot = m_textures[idx];
uint32_t generation = slot.generation; // on la préserve avant d'écraser le slot
slot = entry;
slot.generation = generation;
slot.alive = true;
handle = { idx, generation };
}
else
{
entry.alive = true;
entry.generation = 0;
m_textures.push_back(std::move(entry));
handle = { static_cast<uint32_t>(m_textures.size() - 1), 0 };
}
m_lookup.emplace(resourcesPath, handle);
return handle;
}
void SpriteRenderer::unloadTexture(TextureHandle handle)
{
GPUTexture* slot = resolve(handle);
if (!slot)
return;
m_lookup.erase(slot->path);
slot->texture = {}; // SharedPtr : relâche la texture Metal
slot->alive = false;
slot->generation++;
m_freeTextures.push_back(handle.index);
}
La ligne uint32_t generation = slot.generation; avant slot = entry; n'est pas de la coquetterie. GPUTexture contient maintenant generation et alive : écraser le slot entier avec un entry fraîchement construit remettrait la génération à zéro et ressusciterait tous les handles périmés de ce slot. C'est précisément le bug qu'on cherchait à supprimer, réintroduit par la porte de service. AssimpBlender l'évite en n'écrasant que slot.model, jamais le slot entier — c'est plus sûr, et tu peux structurer GPUTexture pareil en sortant les pixels dans un sous-struct.
4 — Ce que ça change aux appelants
Le uint32_t id qui circule dans draw, drawSprite, drawFrame3D, SpriteSheet::texture et BatchEntry::texture devient un TextureHandle. Le compilateur te guide : chaque erreur est un endroit qui manipulait un index nu.
Deux points d'attention dans flush() :
- Le tri du batch
- Tu regroupes les sprites par texture pour minimiser les draw calls. Un
TextureHandlene se compare pas avec<. Trie surhandle.index— deux handles de même index sont forcément la même texture vivante, puisqu'un handle périmé n'atteint jamais le batch. - La résolution par entrée
m_textures[head.texture]devientresolve(head.texture), avec unif (!slot) continue;. Un sprite dont la texture a été déchargée entre ledrawet leflushest simplement sauté — au lieu d'afficher n'importe quoi.
Un seul type pour tout le moteur
À ce stade tu as deux structures identiques au nom près. Un ModelHandle compile parfaitement là où on attend un TextureHandle, et inversement : même disposition mémoire, mêmes membres. Le jour où tu inverses deux arguments, le compilateur ne dit rien.
Un paramètre de gabarit fantôme règle ça sans coût à l'exécution :
/// C++ — includes/Handle.hpp
#ifndef Handle_hpp
#define Handle_hpp
#include <cstdint>
template <typename Tag>
struct Handle
{
uint32_t index = UINT32_MAX;
uint32_t generation = 0;
bool isValid() const { return index != UINT32_MAX; }
bool operator==(const Handle&) const = default;
};
// Les tags ne sont jamais instanciés : ils n'existent que pour le typage.
struct ModelTag {};
struct TextureTag {};
struct SoundTag {};
using ModelHandle = Handle<ModelTag>;
using TextureHandle = Handle<TextureTag>;
using SoundHandle = Handle<SoundTag>;
#endif /* Handle_hpp */
// Désormais :
ModelHandle perso = blender.loadModel("perso.fbx");
TextureHandle fumee = sprites.loadTexture("fx/smoke.png");
sprites.unloadTexture(perso); // ← erreur de compilation. Parfait.
Handle<ModelTag> et Handle<TextureTag> sont deux types distincts pour le compilateur, alors qu'ils produisent le même code machine. Le tag ne coûte rien : ce n'est ni un membre, ni une base, juste une étiquette dans le système de types. sizeof vaut toujours 8.
= default pour operator==En C++20, un operator== par défaut compare tous les membres dans l'ordre de déclaration et synthétise operator!= à partir de lui. Deux lignes de moins que la version manuelle d'AssimpBlender, et impossible d'oublier un membre le jour où tu en ajoutes un. Si ton projet est resté en C++17, garde les deux opérateurs écrits à la main — c'est ce que fait ModelHandle aujourd'hui.
Le coût, honnêtement
- Mémoire
- 8 octets par handle au lieu de 4, plus 8 octets par slot (
generation+alive, alignement compris). Sur mille modèles : 8 Ko. Négligeable devant un seul vertex buffer. - Accès
- Trois comparaisons de plus qu'un index nu, sur des données déjà dans le cache. Le prédicteur de branchement les prend toujours de la même façon (le handle est valide 99,99 % du temps). C'est indétectable au profileur.
- Ce que tu gagnes
- Un accès à une ressource détruite renvoie
nullptrau lieu d'afficher une autre ressource. Un bug visible immédiatement, à la place d'un bug qu'on découvre trois mois plus tard sur la machine de quelqu'un d'autre.
Un handle ne remplace pas un shared_ptr quand plusieurs propriétaires doivent maintenir en vie une ressource. Il dit « c'est mort », pas « ne meurs pas ». Ces deux problèmes sont différents et se résolvent différemment : le gestionnaire décide de la durée de vie, les utilisateurs constatent. Si tu as besoin qu'un utilisateur empêche la libération, il te faut un compteur de références — mais demande-toi d'abord si c'est vraiment le cas, la réponse est le plus souvent non dans un moteur de jeu, où c'est le niveau qui décide de ce qui vit.
Erreurs classiques
- Incrémenter la génération au chargement
- Elle avance alors de deux par cycle. Ça fonctionne, mais tu as deux endroits à garder cohérents. Règle : à la mort, uniquement.
- Faire un
erase()sur le vector de slots - Tous les index au-dessus se décalent, tous les handles au-dessus deviennent silencieusement faux — y compris ceux dont la génération correspond. Le trou reste, toujours.
- Écraser le slot entier au recyclage
- La génération repart à zéro et les vieux handles ressuscitent. Voir l'encadré de la section
SpriteRenderer. - Stocker un
Blender*obtenu pargetModel - Le pointeur retourné est valide maintenant, pas la frame prochaine. C'est le problème du début, réintroduit à la sortie de l'API. Garde le handle, appelle
getModelà chaque usage — c'est un accès tableau, pas une recherche. - Oublier de retirer le nom de la table
getHandlecontinue à distribuer un handle périmé. Inoffensif, mais déroutant.
make re après le passage de SpriteRenderer aux handlesPour aller plus loin
Le motif se généralise. Un moteur à entités-composants n'est rien d'autre qu'un handle par entité, avec des tableaux de composants indexés par le même index. C'est aussi ce que fait Metal derrière ton dos : un MTL::Texture* est un objet à compteur de références, mais un argument buffer stocke des identifiants de ressources, pas des adresses — pour les mêmes raisons que ce chapitre.
Le prochain chapitre, Assimp, utilise ModelHandle de bout en bout. Tu sais maintenant pourquoi loadModel ne renvoie pas un Blender*.