I. Introduction

Jason Jurecka travaille depuis plus de douze ans dans le secteur des jeux vidéo (maintenant chez Blizzard) et a contribué à plus de dix jeux. Dans cette présentation, il revient sur les fonctionnalités du C++11 intéressantes pour la conception d'un moteur de jeux vidéo.

Jason a réalisé cette présentation sur son temps libre et ne représente en aucun cas ce qui est réalisé chez Blizzard.

II. Vidéo


CppCon 2016 - Un moteur de jeux avec la bibliothèque standard et C++11


III. Résumé

III-A. Introduction

Certes la conférence porte sur C++11 alors que C++14 était déjà disponible. Toutefois, ce choix est fait par rapport à la disponibilité du standard au travers des différents compilateurs utilisés dans le monde du développement de jeux vidéo.

Au cours de la présentation, Jason effectuera un rappel de ce qu'est un moteur de jeux vidéo et expliquera comment C++11 peut nous aider à réaliser un tel projet.

III-B. Idées maîtresses du projet

Le moteur de jeux vidéo repose sur les concepts suivants :

  • il est hautement concurrent ;
  • il faut privilégier la bibliothèque standard et non tout refaire soi-même ;
  • il faut garder les choses simples ;
  • il ne faut pas rester bloqué dans les motifs spécifiques au C ;
  • il ne faut pas se concentrer sur les graphismes de dernière génération ;
  • il fonctionne à 60fps (soit 16 ms par image).

Faire un moteur de jeux vidéo est une bonne méthode pour s'améliorer et découvrir les fonctionnalités du C++.

III-C. Qu'est-ce qu'un moteur de jeux vidéo ?

Le moteur de jeux vidéo s'interface avec le matériel (graphismes, sons, réseaux…). Il gère aussi la création de données et le chargement de celles-ci, l'interprétation des données et leur présentation.

Actuellement, les moteurs de jeux n'utilisent pas vraiment la bibliothèque standard, car :

  • les moteurs héritent d'un code ancien qui a toujours bien fonctionné ;
  • le matériel a des limitations ;
  • la gestion de la mémoire ne correspond pas exactement au besoin des jeux vidéo ;
  • les templates augmentent la taille du programme.

III-D. Comment C++11 peut-il aider ?

Le C++11 apporte des fonctionnalités intéressantes telles que :

  • le versionnage des ressources au moment de la compilation grâce aux expressions constantes ;
  • la validation des types à la compilation grâce aux assertions statiques ;
  • la sérialisation et désérialisation des listes de fonctions.

III-D-1. Versionnage des ressources

Par exemple, si vous voulez créer un hachage d'une structure telle que :

 
Sélectionnez
struct Vector3
{
    float x = 0.0f;
    float y = 0.0f;
    float z = 0.0f;
};

ou

 
Sélectionnez
struct SpawnPoint
{
    Vector3 m_Position;
    Vector3 m_Direction;
}

il faudra prendre en compte :

  • le nom de la structure (car il est unique) ;
  • le type des membres (car la structure change de version si les types changent) ;
  • le nom des membres ;
  • l'ordre des membres ;
  • l'alignement.

Voici la solution de Jason :

 
Sélectionnez
using HashValue = unsigned int;

class constexprString [
    const char* p;
    std::size_t sz;
public:
    template<std::size_t N>
    constexpr constexprString(const char(&a)[N]) : p(a), sz(N - 1) {}
    constexpr char operator[](std::size_t n) const { return (n >= sz) ? '\0' : p[n]; }
    constexpr std::size_t size() const { return sz; }
};

//Expression constante utilisant la récursion pour créer une valeur de hashage
constexpr HashValue GetHash_constexprString(const constexprString& _toHash, const unsigned index = 0)
{
    return ( index >= _toHash.size() ) ? 0 : (static_cast<unsigned>(_toHash[index]) * (index+1)
        * s_PrimeTable[(static_cast<unsigned>(_toHash[index]) % s_PrimeNumCount)])
        + GetHash_constexprString(_toHash, index + 1);
}

template<std::size_t N>
constexpr HashValue GetHash_constexprString_Array(const constexprString(&_toHash)[N], const unsigned index = 0)
{
    return (index >= N) ? 0 : GetHash_constexprString(_toHash[index], 0) +
        (GetHash_constexprString_Array(_toHash, index + 1) << index);
}

III-D-2. Parallélisme massif

Le C++11 apporte un standard, donc multiplateforme, pour les threads et les types atomiques.
Afin que le moteur utilise au mieux les cœurs de la machine, il est nécessaire que :

  • les tâches soient indépendantes autant que possible ;
  • les manipulations de données soient thread safe ;
  • garder à l'esprit chaque fois qu'une synchronisation de données se produit ;
  • prendre en compte la vitesse de la liste des tâches ;
  • prendre en compte le changement de contexte des threads.

Jason propose ce simple système de tâches :

 
Sélectionnez
namespace TaskMaster
{
    bool Initialize();
    void Shutdown();
    void Process();
    
    void AddTask(std::unique_ptr<ITask> task);
    std::future<bool> AddTrackedTask(std::unique_ptr<ITask> task, ITask* parent);
}

void TaskMaster::AddTask(std::unique_ptr<ITask> task)
{
    assert(task);
    s_Tasks.push(std::move(task));
}

std::future<bool> TaskMaster::AddTrackedTask(std::unique_ptr<ITask> task, ITask* /*parent*/)
{
    assert(task);
    std::unique_ptr<TrackedTask> wrapper = std::make_unique<TrackedTasks>(task);
    std::future<bool> status = wrapper->GetFuture();
    AddTask(std::move(wrapper));
    return status;
}

// Utilise std::thread::hardware_concurrency() pour peupler le conteneur de threads à l'initialisation
std::atomic_bool s_RunThreads;
std::vector< std::unique_ptr< std::thread > > s_ThreadPool;

void runTask()
{
    std::thread::id id = std::this_thread::get_id();
    while(s_RunThreads)
    {
        std::unique_ptr<ITask> task = s_Tasks.pop();
        if (task != nullptr)
        {
            if (!s_RunThreads)
            {
                // plus besoin de garder la tâche ... on la supprime
                break;
            }
            task->Run(); // Exécution de la tâche
            if (!task->IsComplete())
            {
                if (!s_RunThreads)
                {
                    // plus besoin de garder la tâche ... on la supprime
                    break;
                }
                s_Tasks.push(std::move(task));
            }
        }
        else
        {
            std::this_thread::sleep_for(std::chrono::milliseconds(3));
        }
    }
}

III-D-3. Mise en avant des fonctionnalités du standard

Le standard C++11 apporte :

  • la sémantique de mouvement ;
  • la bibliothèque de threads ;
  • la bibliothèque d'algorithmes (qui fonctionne aussi sur les tableaux bruts) ;
  • std::function ;
  • les assertions statiques ;
  • les expressions constantes ;
  • la vérification à la compilation et les traits de type ;
  • std::tuple ;
  • std::atomic ;
  • les pointeurs intelligents ;
  • std::array, std::unordered_map, std::unordered_set.

Ces fonctionnalités aident à simplifier le code.

III-D-3-a. Pointeurs intelligents

Les pointeurs intelligents permettent d'assurer la libération des ressources dans tous les cas (même en cas d'arrêt inattendu). Deux types existent :

  • les pointeurs uniques (unique_ptr), pour les gestionnaires ou les tâches ;
  • les pointeurs partagés (shared_ptr) pour les ressources.
III-D-3-b. std::future

Jason l'utilise pour obtenir le résultat d'une tâche.

 
Sélectionnez
template <typename t>
bool futureReady(std::future<t>& toCheck)
{
    return toCheck.valid() && 
           toCheck._Is_ready(); // Spécifique MS
}

std::future<std::unique_ptr< SystemActionGather::Result > > m_SystemActionGatherResult;
std::future<bool> m_SystemActionApply;

void PrepForSimulate::Execute()
{
    // Rassemble toutes les actions de la frame précédente
    if (!m_TriggeredGather)
    {
        std::unique_ptr<SystemActionGather> temp = std::make_unique<SystemActionGather>(m_FrameContext);
        m_SystemActionGatherResult = temp->GetResultFuture();
        TASK_MASTER::AddTask(std::move(temp));
        m_TriggeredGather = true;
    }
    else if (!m_TriggeredApply && futureReady(m_SystemActionGatherResult))
    {
        m_SystemActionApply = TASK_MASTER::AddTrackedTask(std::move(std::make_unique< SystemActionApply >(m_FrameContext, m_SystemActionGatherResult.get())), this);
        m_TriggeredApply = true;
    }
    else if (m_TriggeredGather && m_TriggeredApply && futureReady(m_SystemActionApply)
        )
    {
        TASK_MASTER::AddTask(std::move(std::make_unique< Simulate >(m_FrameContext)));
        return;
    }
    WantToExecuteAgain();
}
III-D-3-c. std::chrono

La gestion du temps est très utile pour les jeux vidéo, notamment pour les animations et la logique du jeu. De plus, la bibliothèque apporte des types spécifiques au temps et rend évidentes les conversions entre les unités.

 
Sélectionnez
std::chrono::seconds test(1);
std::cout << " " << test.count() << "s\n" 
    << std::chrono::duration_cast<std::chrono::millisecondes>(test).count() << "ms\n"
    << std::chrono::duration_cast<std::chrono::nanoseconds>(test).count() << "ns\n";

Pour la boucle de jeu, vous pouvez obtenir le temps entre deux itérations comme suit :

 
Sélectionnez
std::chrono::high_resolution_clock::time_point now = std::chrono::high_resolution_clock::now();
std::chrono::milliseconds diff = std::chrono::duraction_cast<std::chrono::milliseconds>(now - internal::s_LastFrame);
internal::s_LastFrame = now;

IV. Futur

Dans la norme C++17 et suivante, les fonctionnalités suivantes seront intéressantes pour les développeurs de moteurs de jeux vidéo :

  • la bibliothèque de systèmes de fichiers ;
  • std::variant (pour la sérialisation, ou comme machine à états) ;
  • les pointeurs intelligents atomiques ;
  • std::optional ;
  • une bibliothèque pour le réseau (repoussée pour C++20).

V. Ressources

Vous pouvez retrouver l'intégralité des ressources des conférences CppCon 2016 sur GitHub.

VI. Commenter

Vous pouvez commenter et donner vos avis dans la discussion associée sur le forum.