4.1 Introduction : l’architecture au service de la performance
Le choix de l’architecture logicielle d’un moteur de jeu n’est pas une question esthétique, c’est une question de performance. L’organisation des données en mémoire, la manière dont les systèmes communiquent entre eux et le degré de couplage entre les composants déterminent directement si le moteur peut soutenir 60 ticks de simulation par seconde avec des milliers d’entités.
Ce chapitre examine les paradigmes architecturaux retenus pour LplKernel et montre comment chacun répond à une contrainte précise du cahier des charges.
4.2 Data-Oriented Design (DOD) et ECS
4.2.1 Le problème de l’OOP classique
L’approche orientée objet classique (Object-Oriented Programming, OOP) organise le code autour d’entités : une classe Player hérite de Character, qui hérite de Entity, chaque classe portant ses données et ses méthodes. Cette approche est intuitive pour le développeur, mais catastrophique pour le cache CPU.
Concrètement, lorsque le moteur itère sur 10 000 entités pour mettre à jour leur position, il accède à entity->position, un champ situé au milieu d’un objet volumineux, entouré de données non pertinentes (nom, inventaire, état IA). Chaque accès charge une ligne de cache complète (64 octets) dont seuls 12 octets (un Vec3) sont utiles. Le taux d’utilisation du cache (cache utilization rate) peut chuter sous les 20 %.1
4.2.2 L’architecture ECS (Entity-Component-System)
L’ECS (Entity-Component-System) est l’antithèse architecturale de l’OOP pour les moteurs de jeu :
- Entity : Un simple identifiant numérique (un
uint32_t). Aucune donnée, aucune méthode. - Component : Un bloc de données pur, sans logique. Exemple :
struct Position { float x, y, z; }. - System : Une fonction qui itère sur tous les composants d’un type donné et applique la logique.
// Composants : données pures, contigus en mémoire
struct Position { float x, y, z; };
struct Velocity { float vx, vy, vz; };
// Système : logique pure, itération linéaire
void MovementSystem(Position* positions, const Velocity* velocities,
size_t count, float dt) {
for (size_t i = 0; i < count; ++i) {
positions[i].x += velocities[i].vx * dt;
positions[i].y += velocities[i].vy * dt;
positions[i].z += velocities[i].vz * dt;
}
}
Les composants du même type sont stockés dans des tableaux contigus (Struct of Arrays, SoA) : l’itération sur 10 000 positions accède ainsi à 10 000 × 12 octets = 120 Ko de données parfaitement contiguës. Le cache prefetcher du CPU détecte le pattern d’accès linéaire et pré-charge les lignes suivantes, ce qui porte le taux d’utilisation du cache à près de 100 %.2
Cette organisation linéaire est aussi naturellement vectorisable par le compilateur : les trois additions par entité deviennent une seule instruction SIMD opérant sur 4 ou 8 entités simultanément.
4.2.3 Structure de stockage : SoA vs AoS
| Layout | Définition | Cache-Friendly ? | Vectorisable ? |
|---|---|---|---|
| AoS (Array of Structs) | struct Entity { Pos p; Vel v; }; Entity entities[N]; | Moyen | Difficile |
| SoA (Struct of Arrays) | Pos positions[N]; Vel velocities[N]; | Excellent | Excellent |
| AoSoA (Hybride) | Blocs de 8 entités en SoA | Excellent + SIMD friendly | Optimal |
Le layout SoA est le défaut recommandé pour tous les composants ECS itérés par les systèmes de simulation.
4.3 Composition over inheritance
Le principe de composition sur l’héritage (Composition over Inheritance) est le fondement architectural de l’ECS : les capacités d’une entité sont définies par la combinaison de ses composants, et non par sa position dans une hiérarchie de classes.
// OOP classique — fragile, rigide
class FlyingEnemy : public Enemy { /* ... */ };
class SwimmingEnemy : public Enemy { /* ... */ };
class FlyingSwimmingEnemy : public ??? { /* L'héritage multiple est un cauchemar */ };
// ECS — flexible, extensible
auto flyingEnemy = world.CreateEntity();
world.AddComponent<Position>(flyingEnemy, {0, 100, 0});
world.AddComponent<AI>(flyingEnemy, {AIBehavior::Aggressive});
world.AddComponent<FlyAbility>(flyingEnemy, {maxAltitude: 500});
auto amphibian = world.CreateEntity();
world.AddComponent<FlyAbility>(amphibian, {maxAltitude: 200});
world.AddComponent<SwimAbility>(amphibian, {maxDepth: 50});
// Pas de hiérarchie à modifier !
Autrement dit, le problème du « losange de la mort » (diamond inheritance) est structurellement impossible en ECS.
4.4 Dependency Injection et testabilité
La Dependency Injection (DI) consiste à passer les dépendances d’un système via ses arguments plutôt que de les créer en interne. Elle rend chaque système testable indépendamment du reste du moteur :
// MAUVAIS : couplage fort
class PhysicsSystem {
void Update() {
auto& world = World::GetInstance(); // Singleton global !
}
};
// BON : injection de dépendance
class PhysicsSystem {
void Update(ComponentArray<Position>& positions,
const ComponentArray<Velocity>& velocities,
float dt) {
// Testable avec des données synthétiques
}
};
Avec la DI, le système de physique peut être testé unitairement avec des données synthétiques, sans initialiser le moteur complet, une propriété indispensable pour le débogage des désynchronisations réseau.
4.5 Programmation fonctionnelle et déterminisme
Les principes de la programmation fonctionnelle (fonctions pures, immuabilité, absence d’effets de bord) s’alignent naturellement sur les exigences de déterminisme. Une fonction pure ne dépend que de ses arguments, ce qui garantit la reproductibilité. L’immuabilité interdit de modifier les données d’entrée : le système en produit toujours de nouvelles. La composition, enfin, assemble les systèmes ECS en fonctions pures chaînées dans un pipeline.
// Pipeline de simulation fonctionnel (conceptuel)
auto newState = state
| ProcessInputs(inputBuffer)
| SimulatePhysics(FIXED_DT)
| ResolveCollisions()
| UpdateAI()
| HashState(); // Pour la vérification de synchronisation
L’absence d’effets de bord garantit que le pipeline est rejouable : même state + même inputBuffer = même newState, tick après tick, machine après machine.
4.6 Patterns de conception pour le moteur
Au-delà de ces paradigmes architecturaux, plusieurs design patterns du GoF (Gang of Four) et des Game Programming Patterns sont fondamentaux pour l’architecture de LplKernel. Le code compilable complet de ces patterns est fourni en Annexe A.
4.6.1 Patterns de création
| Pattern | Problème Résolu | Usage dans LplKernel |
|---|---|---|
| Factory Method | Créer des objets polymorphes sans coupler le code | Création de paquets réseau (SyncPacket, InputPacket) |
| Abstract Factory | Instancier des familles d’objets compatibles | Sélection du sous-système matériel (PCVR vs FullDive) |
| Builder | Construire des objets complexes étape par étape | Configuration d’avatars avec mesh, physique, signature neuronale |
| Prototype | Cloner des objets lourds sans réinstanciation | Copie d’état de PNJ pour la prédiction réseau |
| Singleton | Source unique de vérité | Horloge déterministe du moteur |
4.6.2 Patterns structurels
| Pattern | Problème Résolu | Usage dans LplKernel |
|---|---|---|
| Adapter | Intégrer une API incompatible | Wrapping d’une lib physique flottante vers du fixed-point |
| Bridge | Séparer abstraction et implémentation | Interface BCI découplée du hardware (OpenBCI, Galea, etc.) |
| Composite | Traiter un arbre comme un objet unique | Octree / Scene Graph pour le partitionnement spatial |
| Decorator | Ajouter des responsabilités dynamiquement | Compression + chiffrement des flux réseau en couches |
| Facade | API simplifiée pour un sous-système complexe | EngineFacade::BootSequence() initialise physique, rendu, réseau |
| Flyweight | Partager les données communes | Voxels / arbres partageant le même mesh géométrique |
| Proxy | Représentation locale d’un objet distant | Prédiction de mouvement (Dead Reckoning) pour joueurs distants |
4.6.3 Patterns comportementaux
| Pattern | Problème Résolu | Usage dans LplKernel |
|---|---|---|
| Chain of Responsibility | Traiter un signal par priorité | Signal neuronal : sécurité → UI → logique de jeu |
| Command | Encapsuler une action réversible | Inputs réseau pour le Rollback Netcode |
| Memento | Sauvegarder/restaurer un état | Snapshots de frame pour le Rollback |
| Observer | Notification découplée | Pics de stress cardiaque → mise à jour UI |
| State | Machine à états propre | Connexion BCI (calibration → active → pause) |
| Strategy | Interchanger des algorithmes | Prédiction : Dead Reckoning vs Hermite Spline |
| Template Method | Garantir l’ordre des opérations | Boucle de tick : ReadInputs → Simulate → Validate |
4.6.4 Patterns spécifiques moteur
| Pattern | Problème Résolu | Usage dans LplKernel |
|---|---|---|
| Object Pool | Éviter new/delete pendant la simulation | Recyclage de projectiles, particules, effets |
| Double Buffer | Éviter lectures/écritures concurrentes | Buffer d’écriture séparé du buffer de lecture |
| Dirty Flag | Éviter les recalculs inutiles | Matrice de transformation recalculée si modifiée |
| Spatial Partition | Requêtes de proximité efficaces | Grille spatiale, Octree pour collisions |
4.7 Synthèse architecturale
L’architecture de LplKernel repose sur un empilement cohérent :
- ECS + SoA pour la localité mémoire et la vectorisation automatique.
- Composition pour la flexibilité sans hiérarchie fragile.
- DI pour la testabilité et l’isolation des systèmes.
- Fonctionnel pour le déterminisme et la rejouabilité.
- Patterns GoF pour la résolution de problèmes récurrents.
In fine, le code compilable de l’Annexe A illustre chacun de ces patterns dans le contexte spécifique du moteur FullDive.
Notes de bas de page du chapitre 4
Footnotes
-
Mike Acton, « Data-Oriented Design and C++ », CppCon 2014, présentation fondatrice du DOD (Insomniac Games). ↩
-
Le prefetcher matériel Intel (L2 Streamer) pré-charge les lignes de cache jusqu’à 512 octets en avance pour les accès séquentiels, réduisant la latence de ~100 cycles (L2 miss) à ~4 cycles (L1 hit). ↩