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 :
struct
Vector3
{
float
x =
0.0
f;
float
y =
0.0
f;
float
z =
0.0
f;
}
;
ou
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 :
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 :
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.
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.
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 :
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.