Programmation Orientée Jeux

Moteur de jeu - Le Coeur

Qu'est ce que le 'Coeur' d'un Moteur de jeu ?

Le coeur peut être considéré comme l'élément moteur, là où la roue tourne. C'est ce qui lui permet de tourner, d'exister; il lui donne son 'rythme'. Nous verrons comment définir le frame rate (nombre d'image par seconde), et comment le contrôler.

1) Lanceur, Séquences

Lorsque l'on parle de Jeu, on y inclut son Introduction, le Menu, la partie ou l'on joue... Ceux-ci désignent une partie du jeu dans sa globalité, les séquences:

La séquence est l'endroit ou tournera un morceau du jeu, son squelette est simple:

La première (Load) doit se contenter de charger les ressources nécessaires au jeu. C'est donc ici, et seulement ici que seront chargées les ressources, telles que les Images, les Sons, la Musique... Les deux autres (update & render) sont censées tourner dans une boucle infinie (du moins, qui tournera jusqu'à ce que l'on quitte le jeu).

// Illustration
load();
while (1) {
    update();
    render();
}

En procédant de la sorte, notre programme sera 'bloqué' pendant le chargement des ressources. Cela implique que l'on accèdera à la boucle que lorsque la fonction Load aura été exécuté. Le comportement est donc strictement séquentiel. Dans la plupart des jeux modernes (contenant généralement plus de 3Go de ressources), une grande partie des ressources sont chargées pendant le jeu. Ce phénomène est facilement observable (GTA4, Unreal Tournament 3...) si l'on jette un coup d'oeil aux textures dès que la partie est lancée. Que la machine soit lente ou rapide, là n'est pas le problème. Il y a en fait plusieurs couches de textures (basse/moyenne/haute résolution). Dans un premier temps, les textures en basse résolution sont chargées, afin de ne pas se retrouver dans un jeu vide d'image. Une fois que le jeu tourne, les textures plus détaillées sont alors chargées, et mises à jour lorsque cela est possible. On note alors une amélioration subite des détails: les données sont encore chargées, même pendant la partie. Pour la note technique, il suffit de séparer les Threads (processus léger) gérant la boucle du jeu, et celle du chargement secondaire.

Cycle de vie Chargement différé
Cycle de vie Chargement différé (Unreal Tournament 3)

Les séquences sont liées les unes aux autres (quand l'introduction est terminée, le programme passe au menu). Il faut donc une couche supérieure, que l'on nommera Lanceur (Game), et qui s'occupera de gérer les passages d'une séquence à une autre. Cette couche est en fait le corps même du jeu, là ou l'on décide quelle partie lancer. Étant donné que les changements sont réalisés ici (Lanceur), il est important qu'il soit toujours actif, sans pour autant consommer inutilement des ressources (être actif seulement lorsqu'il y a un changement de séquence).

A travers une boucle, le Lanceur va regarder quelle séquence lancer. Si il n'y a aucune séquence, ou que l'ordre est la fermeture du programme, il quitte. Sinon, il lance la séquence dans un nouveau Thread, et se met en pause, jusqu'à ce que ce nouveau Thread le notifie de sa fermeture, avec en retour le nom de la prochaine séquence. Ainsi, la séquence lancée aura toutes les ressources à disposition, et le programme principal ne terminera pas tant qu'il n'en aura pas reçu l'ordre.

Schéma récapitulatif

Maintenant, notre moteur est capable de lancer des séquences. Regardons le fonctionnement de ce dernier une fois lancé. Nous avons vu précédemment, 3 fonctions primaires qui composent le squelette d'une séquence (Load(), Update(), Render()). Ces fonctions seront donc ensuite implémentées par une séquence définie par un jeu utilisant ce moteur. Mais que se passe t-il à l'intérieur même de la séquence du moteur ? Nous avons parlé de boucle infini, dans laquelle se trouve les fonctions Update() et Render().

Une notion importante pour tout jeu, est le Frame Rate; nombre de tours de boucle en une seconde, ou plus simplement, le nombre d'image par seconde. Nous allons voir comment contrôler celui-ci.

2) Frame Rate

Le Frame Rate d'un jeu est une notion un peu délicate. Pour éviter toute ambiguïté liée aux restrictions matérielles (vitesse de rafraichissement d'écran), nous parlerons de temps nécessaire pour réaliser un tour de boucle. Commençons par une formule magique, nous permettant de mettre en évidence les liens entre le nombre d'images par seconde, et le temps nécessaire pour un tour de boucle:

FPS = 1000 / T

FPS est le nombre d'images par secondes, T est le temps pour réaliser un tour de boucle, en milliseconde. Par exemple, un programme nécessitant 20ms pour réaliser un tour aura un taux de 50i/sec (1000 / 20 = 50). Ainsi, on peut connaitre le temps requis pour avoir un FPS souhaité: T = 1000 / FPS. Si l'on souhaite avoir un taux de 60i/sec, il faudra: 1000 / 60 ~= 16.66666667ms

L'idée est donc de faire 'attendre' la séquence pendant ce temps calculé, afin d'avoir la vitesse souhaitée. Un sleep() dans un while{} peut paraitre étrange, mais c'est une méthode qui marche bien ! Pour plus de précision, si la fonction sleep() utilisée supporte les nano secondes, il est recommandé de récupérer la valeur décimale du temps calculé, afin de gagner en précision. En effet, le temps est dans tous les cas approximatif, il sera donc impossible d'avoir exactement 60i/sec (dans notre exemple), car pas de chance, 1000 / 60 est irrationnel ! En revanche, en convertissant la partie décimale en nano seconde, on gagnera en précision !

Cela ne s'arrête pas là. En effet, le temps calculé précédemment est le temps qu'il faudrait pour un tour de boucle. Cependant, le programme ne met jamais le même temps. De plus, il pourrait avoir mis un certain temps avant d'arriver à la fin, il serait donc inutile de rajouter le temps calculé... car on le dépasserait forcément. Il faut donc calculer le temps de pause afin d'avoir un temps total T (calculé précédemment). Calculons d'abord le temps d'un tour de boucle: (on suppose que timer() nous retourne un temps un milliseconde, et sleep(time) 'endors' le programme pendant le temps passé en paramètre, en milliseconde).

Voici un code qui 'endort' un programme en fonction du frame rate voulu:

// nombre d'images par seconde
int desiredRate = 60;

// temps d'execution requis pour atteindre la valeur desiredRate
long timeNeeded = 1000 / desiredRate;

while (1) {
    // sauvegarde du temps
    long time = timer();

    update();
    render();

    // temps d'execution actuel
    long executionTime = timer() - time;

    // temps restant a attendre pour atteindre la valeur desiredRate
    long remainingTime = timeNeeded - executionTime;
    
    /*
    si remainingTime est negatif,
    cela veut dire que le temps d'execution a ete plus long que prevu,
    et donc le frame rate sera plus bas que celui voulu
    */
    if (remainingTime > 0) {
        sleep(remainingTime);
    }
}

Maintenant, le frame rate est bridé à 60i/sec. Par contre, rien ne garantit qu'il ne baisse pas, selon les machines, ou selon la gourmandise de l'éventuel jeu. Cela peut poser problème dès lors qu'une variable est incrémentée ou décrémentée. En effet, le mouvement sera ralenti, puisqu'il dépend encore de la machine et du temps de tour de boucle.

Dépendance au frame rate

Nous allons maintenant voir comment briser la dépendance au frame rate, et étudier les contraintes liées à cette optimisation.

3) Extrapolation

L'extrapolation est une méthode consistant à modifier le pas d'incrémentation (ou de décrémentation) de sorte que la vitesse de modification soit la même quelque soit la vitesse d'exécution. Prenons un exemple sur ce code:

int i = 0;

while (1) {
    i++;
}

Sur une machine rapide, l'incrémentation sera rapide. Sur une vielle machine, l'incrémentation sera plus lente. Sur 1 seconde, les valeurs seront donc différentes. C'est ce qui fait qu'un jeu qui n'utilise pas cette méthode risque de tourner lentement sur une machine lente (en plus du frame rate réduit). L'extrapolation repose sur une formule tout aussi magique:

extrapolation = (desiredRate / 1000) * elapsedTime

En reprenant ce que nous avons vu avant, il est facile de calculer le temps écoulé; modifions la boucle pour supporter l'extrapolation:

int desiredRate = 60;
long timeNeeded = 1000 / desiredRate;
double i = 0.0;

// au debut elle vaut 1.0, c'est le meilleur cas
double extrapolation = (desiredRate / 1000.0) * timeNeeded;

while (1) {
    long time = timer();

    /*
    incrementation avec l'extrapolation,
    toujours en multipliant la vitesse de changement par l'extrapolation
    */
    i = i + 1.0 * extrapolation;

    update(extrapolation);     render();

    long executionTime = timer() - time;
    long remainingTime = timeNeeded - executionTime;

    if (remainingTime > 0) {
        sleep(remainingTime);
    }

    /*
    si l'extrapolation est < 1.0, cela veut dire que le programme va trop vite,
    si c'est > 1.0, c'est qu'il va trop lentement,
    si c'est 1.0, c'est parfait !
    */
    extrapolation = (desiredRate / 1000.0) * executionTime;
}

Ici, i augmentera toujours à la même vitesse, quelque soit la vitesse d'exécution. Si le programme est lent, la valeur d'extrapolation sera grande (pour compenser le retard), et si le programme est rapide, l'extrapolation sera faible, afin de ne pas augmenter trop vite. On constate un changement du coté de notre variable de test i. Ce n'est plus un int, mais un double. Même dans la programmation le monde n'est pas rose... il y a aussi des compromis ! i étant un double, il y aura forcément une perte de précision par rapport à un simple i++. Selon les applications, ce n'est pas grave, mais dans un cas comme une simulation ou la précision est exigée, cette méthode est à éviter. Affichez les valeur de l'extrapolation... et constatez par vous-même la provenance du problème.

(Réponse: 1.0 + 1.000000000001 nous donne presque 2.0, mais sur une certaine durée d'exécution, ça tend plutôt vers 1.999999999999 ou 2.000000000001... au meilleur des cas, contrairement à 1 + 1 qui nous donne toujours 2 !)

Dépendance au frame rate brisée

L'extrapolation permet de briser la dépendance au frame rate, et ainsi d'avoir une vitesse d'exécution théoriquement constante en faisant office de régulateur.

4) Conclusion

En résumé, nous avons vu 3 méthodes de fonctionnement de la boucle principale d'une séquence, chacune ayant des avantages et des inconvénients:

Méthode: Avantage(s): Inconvénient(s):
Frame Rate libre
Pas d'extrapolation
Simple à intégrer
Code moins lourd
Aucune perte de précision
Programme totalement dépendant de la machine
Consommation des ressources (CPU)
Frame Rate fixé
Pas d'extrapolation
Vitesse fixe sur une machine rapide
Aucune perte de précision
Sur une machine lente la vitesse sera toujours dépendante
Frame Rate fixé
Extrapolation
Vitesse indépendante de la machine Pas toujours stable, notamment lors de gros écarts de vitesse
Perte de précision, à ne pas utiliser dans un cas critique

Lire la suite: Moteur de jeu - Les Mains

Programmation Orientée Jeux ©2010-2014
Byron 3D Games Studio