Aller au contenu principal

PARTIE II : Architecture et rendu

Chapitre 4 : Paradigmes architecturaux et design patterns

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

LayoutDéfinitionCache-Friendly ?Vectorisable ?
AoS (Array of Structs)struct Entity { Pos p; Vel v; }; Entity entities[N];MoyenDifficile
SoA (Struct of Arrays)Pos positions[N]; Vel velocities[N];ExcellentExcellent
AoSoA (Hybride)Blocs de 8 entités en SoAExcellent + SIMD friendlyOptimal

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

PatternProblème RésoluUsage dans LplKernel
Factory MethodCréer des objets polymorphes sans coupler le codeCréation de paquets réseau (SyncPacket, InputPacket)
Abstract FactoryInstancier des familles d’objets compatiblesSélection du sous-système matériel (PCVR vs FullDive)
BuilderConstruire des objets complexes étape par étapeConfiguration d’avatars avec mesh, physique, signature neuronale
PrototypeCloner des objets lourds sans réinstanciationCopie d’état de PNJ pour la prédiction réseau
SingletonSource unique de véritéHorloge déterministe du moteur

4.6.2 Patterns structurels

PatternProblème RésoluUsage dans LplKernel
AdapterIntégrer une API incompatibleWrapping d’une lib physique flottante vers du fixed-point
BridgeSéparer abstraction et implémentationInterface BCI découplée du hardware (OpenBCI, Galea, etc.)
CompositeTraiter un arbre comme un objet uniqueOctree / Scene Graph pour le partitionnement spatial
DecoratorAjouter des responsabilités dynamiquementCompression + chiffrement des flux réseau en couches
FacadeAPI simplifiée pour un sous-système complexeEngineFacade::BootSequence() initialise physique, rendu, réseau
FlyweightPartager les données communesVoxels / arbres partageant le même mesh géométrique
ProxyReprésentation locale d’un objet distantPrédiction de mouvement (Dead Reckoning) pour joueurs distants

4.6.3 Patterns comportementaux

PatternProblème RésoluUsage dans LplKernel
Chain of ResponsibilityTraiter un signal par prioritéSignal neuronal : sécurité → UI → logique de jeu
CommandEncapsuler une action réversibleInputs réseau pour le Rollback Netcode
MementoSauvegarder/restaurer un étatSnapshots de frame pour le Rollback
ObserverNotification découpléePics de stress cardiaque → mise à jour UI
StateMachine à états propreConnexion BCI (calibration → active → pause)
StrategyInterchanger des algorithmesPrédiction : Dead Reckoning vs Hermite Spline
Template MethodGarantir l’ordre des opérationsBoucle de tick : ReadInputs → Simulate → Validate

4.6.4 Patterns spécifiques moteur

PatternProblème RésoluUsage dans LplKernel
Object PoolÉviter new/delete pendant la simulationRecyclage de projectiles, particules, effets
Double BufferÉviter lectures/écritures concurrentesBuffer d’écriture séparé du buffer de lecture
Dirty FlagÉviter les recalculs inutilesMatrice de transformation recalculée si modifiée
Spatial PartitionRequêtes de proximité efficacesGrille 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 :

  1. ECS + SoA pour la localité mémoire et la vectorisation automatique.
  2. Composition pour la flexibilité sans hiérarchie fragile.
  3. DI pour la testabilité et l’isolation des systèmes.
  4. Fonctionnel pour le déterminisme et la rejouabilité.
  5. 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

  1. Mike Acton, « Data-Oriented Design and C++ », CppCon 2014, présentation fondatrice du DOD (Insomniac Games).

  2. 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).