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.

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

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

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.

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