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.
Dans le moteur, ce stockage prend corps dans la Partition : des chunks SoA à double buffer, un tampon d’écriture et un tampon de lecture basculés par un swapBuffers() atomique à la fin du tick. Le double buffer coûte 2× la mémoire, mais uniquement sur les données chaudes : compromis accepté, et mesuré. La contention est distribuée de la même façon : un SpinLock par chunk plutôt qu’un verrou global, si bien que deux threads qui travaillent sur des chunks différents ne se croisent jamais. La migration d’une entité entre chunks, elle, s’appuie sur le swap-and-pop avec itération à rebours ; l’itération avant provoquait un bug subtil (l’élément permuté à la place du supprimé échappait au parcours), leçon apprise une fois et pour de bon.
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.
Ce pipeline conceptuel a une incarnation directe : le SystemScheduler du moteur. Chaque système ECS déclare les composants qu’il lit et ceux qu’il écrit, et l’ordonnanceur en déduit automatiquement un graphe de dépendances (DAG) : deux systèmes sans conflit d’accès s’exécutent en parallèle sur le ThreadPool, une latch synchronisant chaque étage du graphe. Côté client, le pipeline résolu enchaîne typiquement la consommation du ring réseau, les entrées/sorties client, la physique puis les métriques neuronales ; côté serveur, la même mécanique produit un ordre différent, déduit des mêmes déclarations. Personne ne maintient d’ordre d’exécution à la main : c’est le graphe qui fait foi, et il reste déterministe puisque la topologie ne dépend que des déclarations, pas du timing.
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 |
Le pattern Spatial Partition mérite qu’on s’y attarde, parce que son implémentation dans le moteur condense plusieurs idées de ce chapitre. La structure d’indexation est une FlatAtomicsHashMap, table de hachage plate à opérations atomiques, sans nœuds chaînés ni allocation dynamique. Sa clé est un code de Morton : les bits des coordonnées spatiales sont entrelacés (part1by1 en 2D, part1by2 en 3D, avec un biais pour ramener les coordonnées négatives dans le domaine non signé), ce qui produit un ordre en Z (Z-order curve) où la proximité spatiale se traduit par la proximité numérique des clés. Le tout est empaqueté sur 64 bits, code de Morton et indice de pool compris : une seule valeur atomique suffit pour localiser une entité et sa cellule.
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.
Cet empilement se reflète jusque dans le découpage physique du code : le moteur est une architecture plate de 20 bibliothèques statiques indépendantes (lpl-core, lpl-math, lpl-ecs, lpl-physics, lpl-net, lpl-render, lpl-bci…), chacune déclarant explicitement ses dépendances dans son propre xmake.lua. Aucun couplage caché : si un module a besoin d’un autre, ça se lit dans son manifeste de build, et le graphe de dépendances du projet est celui du linker. L’expérience a tranché en faveur de ce modèle plat contre le monolithe engine/ initial : il passe à l’échelle, et il force chaque frontière à être pensée.
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). ↩