Les moteurs de rendu des FPS en 2.5 D\Le moteur de Wolfenstein 3D

Un livre de Wikilivres.
Raycasting sur GameMaker.

Les tout premiers First Person Shooters, comme DOOM, Wolfenstein 3D, et autres jeux des années 1990 avaient un rendu relativement simpliste et anguleux. Et contrairement à ce que voient nos yeux, le rendu n'était pas en 3D. Le moteur de Wolfenstein, ainsi que de ses prédécesseurs, disposait de deux méthodes de rendu assez simples : un rendu purement 2D, pour le HUD, l'arme et les ennemis, et un rendu en 2.5D pour les murs.

Le rendu des murs est de loin la portion la plus compliquée du moteur. Aussi, nous allons d'abord parler du reste. Cependant, nous verrons que le rendu s'effectue ne plusieurs étapes, qui se font dans un ordre différent de celui du chapitre. L'ordre de ce rendu s'explique assez facilement quand on connait comme est effectué le rendu d'un jeu en 2 pure. Et je précise bien en 2D pure, car le principe de la 2.5D est assez similaire.

La base d'un rendu en pure 2D, comme un jeu Mario, superpose des images 2D pré-calculées les unes au-dessus des autres. Par exemple, on peut avoir une image pour l’arrière-plan (le décor), une image pour le monstre qui vous fonce dessus, une image pour le dessin de votre personnage, etc. On distingue généralement l'image pour l'arrière-plan, qui prend tout l'écran, des images plus petites qu'on superpose dessus et qui sont appelées des sprites. Le rendu des sprites doit s'effectuer de l'image la plus profonde (l'arrière-plan), vers les plus proches (les sprites qui se superposent sur les autres) : on parle d'algorithme du peintre.

Exemple de rendu 2D utilisant l'algorithme du peintre.

Dans un FPS en 2.5D, l'idée est de calculer séparément une image pour l'environnement, le décor, une image avec les ennemis, une image avec l'arme en avant-plan et une autre image sur le HUD. Le HUD est à l'avant-plan et prédomine tout le reste, ce qui fait qu'il doit être dessiné en dernier. L'arme est située juste après le HUD, elle est tenue par le personnage et se situe donc devant tout le reste : elle est dessinée en second. Pour l'environnement et les ennemis, tout dépend de la pièce dans laquelle on est et de la position des ennemis. Mais il est plus pratique de rendre le décor et de dessiner les ennemis dessus, l'environnement servant d'arrière-plan.

Le rendu s'effectue donc comme ceci :

  • d'abord le rendu des murs, du plafond et du sol ;
  • puis le rendu des ennemis ;
  • puis le rendu de l'arme ;
  • et enfin le rendu du HUD.

Le rendu du HUD[modifier | modifier le wikicode]

Cube screenshot 199627

Le HUD est simplement rendu en 2D, car le HUD est fondamentalement une structure en 2D. Il est mis à jour à chaque fois que le joueur tire (compteur de munitions), perd de la vie, prend de l'armure, etc. Il est idéalement rendu à la toute fin du rendu, son sprite étant superposé à tout le reste. Mais dans Wolfenstein 3D, le HUD était rendu au tout début du rendu.

La raison est que le HUD est plein, et non transparent. J'explique. Un HUD de FPS normal ressemble à ce qu'il y a dans le screenshot à droite : quelques chiffres et icônes superposées sur le reste du rendu. On voit l'icône pour la vie, sa valeur, idem pour l'armure et les munitions. Mais entre les icônes et les chiffres, on voit la scène, l'eau, la texture du sol, etc. Le HUD a donc des parties transparentes, dans le sens où seuls les chiffres et icônes sont visibles/opaques.

Advanced raycasting demo 2

Mais dans Wolfenstein 3D, et dans d'autres jeux du même genre, on a la même chose que ce qui est illustré dans l'animation de droite. Le HUD est un gros rectangle qui prend tout le bas de l'écran. On peut bouger autant qu'on veut, le bas de l'écran associé au HUD reste le même. Le HUD n'a pas de transparence, les textures et l'environnement se se voient pas à travers. Avec cette contrainte, on peut dessiner le HUD et le reste de l'image séparément. Cela signifie aussi que l'image calculée est en réalité plus petite. Si le HUD prend 10% de l'écran, alors on a juste à dessiner les 90% restants. Sans cette contrainte, on doit calculer 100% de l'image, pour ensuite superposer un HUD partiellement transparent.

Le rendu de l'arme[modifier | modifier le wikicode]

Le rendu de l'arme est assez particulier. L'arme est un vulgaire sprite qui est dessiné par-dessus l'image calculée. L'arme est animée comme les ennemis, il y a un sprite pour chaque frame de l'animation.

Il faut savoir que tous les jeux vidéos, même récents, utilisent un rendu séparé pour l'arme et le reste du monde. Pour les jeux en 2.5D, c'est parce que c'est plus simple de procéder ainsi. Pour les jeux en 3D, la raison est que cela évite de nombreux problèmes. Si l'arme était en 3D et était calculée comme le reste du rendu, on aurait des problèmes du genre : l'arme passe à travers les murs quand on se rapproche trop d'eux. Il y a bien des exceptions, mais elles sont assez rares.

Le tout est bien expliqué dans cette vidéo Youtube sur la chaine de "Scrotum de Poulpe" (ca ne s'invente pas, je sais) :

Le rendu des ennemis et items[modifier | modifier le wikicode]

Les objets sont rendus avec de simples images placées au-dessus du décor, qui ne sont ni plus moins que des sprites. Le meilleur moyen pour s'en rendre compte étant de tourner autour d'un objet : la forme de l'objet ne change pas du tout. Les objets sont de simples pancartes sur lesquelles on plaque une image.

Anarch short gameplay

Les ennemis sont généralement animés : ils bougent, ils sont "stun" quand on tire dessus, ils peuvent tirer des projectiles, ils ont une animation de mort, etc. Et il y a une animation pour chaque action. Pareil pour certains objets de l'environnement : pensez aux fameux barils explosifs qui explosent quand on tire dessus ! Le tout est illustré ci-contre, avec quelques exemples. Reste à la simuler avec des sprites. Pour cela, rien de plus simple : chaque animation est toujours la même, elle correspond à une succession de sprites qui est toujours identique. Il suffit de dérouler la bonne succession de sprite et le tour est joué !

L'ordre de dessin des sprites[modifier | modifier le wikicode]

Le rendu des sprites se fait une fois que l'environnement a été dessiné, c'est à dire après les murs, le sol et les plafonds. Les sprites des ennemis et items sont donc superposé sur l'arrière-plan calculée par l'étape précédente. Cependant, certains sprites peuvent se recouvrir : il faut impérativement que le sprite le plus proche soit affiché au-dessus de l'autre. Pour cela, les sprites sont superposés suivant l'algorithme du peintre : on commence par intégrer les sprites des objets les plus lointains dans l'image, et on ajoute des sprites de plus en plus proches. Faire cela demande évidemment de trier les sprites à rendre en fonction de la profondeur des objets/ennemis dans le champ de vision (qu'il faut calculer).

Un autre point est qu'il ne suffit pas de superposer un sprites d'item ou d'ennemi pour que cela fonctionne. Cela marche dansun jeu en pure 2D, mais la 3D impose de gérer la profondeur. Rappelons que plus un objet/ennemi est loin, plus il nous parait petit, plus il est petit à l'écran. Les sprites doivent donc être mis à l'échelle suivant la distance : rapetissés pour les ennemis lointains, et zoomés pour les ennemis proches. Pour cela, on utilise une relation mathématique très simple : la loi de Thalès.

La mise à l'échelle des sprites[modifier | modifier le wikicode]

Dans un jeu vidéo, et comme dans le monde réel, si on multiplie la distance d'un objet par deux, trois ou quatre, celui-ci devient respectivement deux, trois ou quatre fois plus petit. Dit autrement, un objet de hauteur H situé à une distance D aura une hauteur perçue identique à celle d'un objet de hauteur double/triple/quadruple situé deux/trois/quatre fois plus loin. En clair, pour un objet de hauteur , situé à une distance , et un autre objet de hauteur et de distance , les deux auront la même hauteur perçue :

Tout sprite est une image, avec une résolution. Elle a un certain nombre de pixels à l'horizontale, un autre à la verticale. La taille verticale en pixel du sprite dépend du sprite, mettons qu'elle est égale à 50 pixels de large pour 60 de haut. Il s'agit de la taille réelle du sprite déterminée lors de la conception du jeu (aussi bien en vertical et horizontal). Nous allons noter sa taille en vertical comme suit : .

Cette taille correspond à une distance précise. Pour un sprite 50 pixels de large pour 60 de haut, le sprite aura la même taille à l'écran à une certaine distance, que nous allons noter .

Maintenant, d'après la relation vue plus haut, on peut calculer la taille affichée à l'écran du sprite, notée H. Pour cela, il suffit de connaitre la distance D, et on a la relation :

On peut la reformuler comme suit :

Quelques multiplications, et le tour est joué. Le terme peut même être mémorisé à l'avance pour chaque sprite, ce qui économise quelques calculs.

Le rendu de l'environnement : murs, sol et plafond[modifier | modifier le wikicode]

Pour les murs, la 3D des murs est simulée par un mécanisme différent de celui utilisé pour les objets et ennemis. Le principe utilisé pour rendre les murs s'appelle le ray-casting. Il s'agit d'un rendu foncièrement différent de celui des sprites. Formellement, le ray-casting est une version en 2D d'une méthode de rendu 3D appelée le lancer de rayons. Mais avant de détailler cette méthode, parlons de la caméra et des maps de jeux vidéo.

La map et la caméra[modifier | modifier le wikicode]

Une map dans un FPS en 2.5D est un environnement totalement en deux dimensions, dans lequel le moteur d’un jeu vidéo place des objets et les fait bouger. Cette scène est, en première approche, un simple rectangle. Un des coins de ce rectangle sert d’origine à un système de coordonnées : il est à la position (0, 0), et les axes partent de ce point en suivant les arêtes.

Dans cette scène 2D, on place des murs infranchissables, des objets, des ennemis, etc. Les objets sont placés à des coordonnées bien précises dans ce parallélogramme, pareil pour les murs. Le fait que la scène soit en 2D fait que la carte n'a qu'un seul niveau : pas d'escalier, d'ascenseur, ni de différence de hauteur. Du moins, sans améliorations notables du moteur graphique. Il est en fait possible de tricher et de simuler des étages, mais nous en parlerons plus tard. Pour donner un exemple, voici un exemple de map dans le jeu libre FreeDOOM :

Exemple d'une map de Freedoom.

Mais le rendu en 2.5D est très gourmand en calculs, surtout pour les ordinateurts de l'époque. Aussi, pour Wolfenstein 3D, la map a quelques contraintes pour rendre les calculs d'intersection plus simples. Déjà, la carte est un labyrinthe, avec des murs impossibles à traverser. Les murs sont fixes, on ne peut pas les changer à la volée. Mais surtout : tout mur est composé en assemblant des polygones tous identiques, généralement des carrés de taille fixe. Si elle respecte ces contraintes, on peut la représenter en 2D, avec un tableau à deux dimensions, dont chaque case indique la présence d'un mur avec un bit (qui vaut 1 si le carré est occupé par un mur, et 0 sinon).

Carte d'un niveau de Wolfenstein 3D.

Le ray-casting de Wolfenstein 3D[modifier | modifier le wikicode]

Outre les objets proprement dit, on trouve une caméra, qui représente les yeux du joueur. Cette caméra est définie par :

  • une position ;
  • par la direction du regard (un vecteur) ;
  • le champ de vision (un angle) ;
  • un plan qui représente l'écran du joueur.

Le ray-casting colorie une colonne de pixels à la fois sur l'écran. Pour cela, on émet des lignes droites, des rayons qui partent de la caméra et qui passent chacun par une colonne de pixel de l'écran.

Raycasting 2D
Illustration de l'agorithme de Ray-casting.

Les rayons font alors intersecter les objets, les items, mais aussi les murs, comme illustré ci-contre. Le moteur du jeu détermine alors, pour chaque rayon, quel est le point d'intersection le plus proche.

Détermination du point d'intersection adéquat.

Une fois le point d'intersection connu, on peut alors déterminer la distance de l'ennemi/item/mur. Rien de plus simple : il suffit de récupérer la coordonnée de ce point d'intersection, celle du joueur, et d'appliquer le théorème de Pythagore. Si le joueur est à la position de coordonnées (x1 ,y1), et l'intersection aux coordonnées (x2, y2), la distance D se calcule avec cette équation :

La position du joueur est connue : elle est initialisée par défaut à une valeur bien précise au chargement de la carte (on ne réapparait pas n'importe où), et est mise à jour à chaque appui sur une touche de déplacement. Les coordonnées du point d'intersection sont calculées à l'aide d'un algorithme nommé Digital Differential Analyser.

Une fois la distance connue, on peut déterminer la taille des sprites, des murs, et de tout ce qu'il faut afficher.

Le rendu de l'environnement : murs, sol et plafond[modifier | modifier le wikicode]

Le rendu du sol et des plafonds est assez simple et est effectué avant tout le reste. Le sol a une couleur bien précise, pareil pour le plafond. Elle est la même pour tout le niveau, elle ne change pas quand on change de pièce ou qu'on se déplace dans la map. Le sol et le plafond sont dessinés en premier, et on superpose les murs dessus. Le dessin du sol et du plafond est très simple : on colorie la moitié haute de l'écran avec la couleur du plafond, on colorie le bas avec la couleur du sol, et on ajoute les murs ensuite.

Passons maintenant au rendu des murs. Et il faut maintenant préciser que le rendu est simplifié par plusieurs contraintes. La première est que l'on part du principe que tous les murs ont la même hauteur. En conséquence, le sol et le plafond sont plats, les murs font un angle de 90° avec le sol et le plafond. La seconde contrainte est que le regard du joueur est à une hauteur fixe au-dessus du sol, généralement au milieu de l'écran. Cela garantit que le plafond est situé au sommet de l'écran, le sol est en bas de l'écran, et les murs sont au milieu. Mais cela implique l'impossibilité de sauter, s'accroupir, lever ou baisser le regard. À partir de ces contraintes et de la carte en 2D, le moteur graphique peut afficher des graphismes de ce genre :

Exemple de rendu en raycasting

Le mur est centré au milieu de l'écran, vu que le regard est au milieu de l'écran et que tous les murs ont la même hauteur. Par contre, la hauteur du mur perçue sur l'écran dépend de sa distance, par effet de perspective. C'est comme pour les sprites : plus ils sont loin, plus ils semblent petits. Et bien plus un mur est proche, plus il paraîtra « grand ». La formule pour calculer la taille d'un mur à l'écran est la même que pour les sprites : on utilise le théorème de Thalés pour calculer la taille du mur à l'écran.

Détermination de la hauteur perçue d'un mur en raycasting 2D

Vu qu'on a supposé plus haut que la hauteur du regard est égale à la moitié de la hauteur d'un mur, on sait que le mur sera centré sur l'écran. Les pixels situés au-dessus de cet intervalle correspondent au plafond : ils sont coloriés avec la couleur du plafond, souvent du bleu pour simuler le ciel. Les pixels dont les coordonnées verticales sont en dessous de cet intervalle sont ceux du sol : ils sont coloriés avec la couleur du sol.

Hauteur d'un mur en fonction de la distance en raycasting 2D
Ciel et sol en raycasting 2D

La taille du mur est calculée pour chaque colonne de pixel de l'écran. Ainsi, un même mur vu d'un certain angle n'aura pas la même taille perçue : chaque colonne de pixel donnera une hauteur perçue différente, qui diminue au fur et à mesure qu'on s'éloigne du mur.

La correction d'effet fisheyes[modifier | modifier le wikicode]

L'algorithme utilisé ci-dessus donne ce genre de rendu :

Simple raycasting without fisheye correction

Le rendu est assez bizarre, mais vous l'avez peut-être déjà rencontré. Il s'agit d'un rendu en œil de poisson (fish-eye), assez désagréable à regarder. Si ce rendu porte ce nom, c'est parce que les poissons voient leur environnement ainsi. Et certaines caméras ou certains appareils photos peuvent donner ce genre de rendu avec une lentille adaptée.

Pour comprendre pourquoi, imaginons que nous regardions un mur sans angle, le regard à la perpendiculaire d'un mur plat. Les rayons du bord du regard parcourent une distance plus grande que les rayons situés au centre du regard. Si on regarde un mur à la perpendiculaire, les bords seront situés plus loin que le centre : ils paraîtront plus petits. Prenons un joueur qui regarde un mur à la perpendiculaire (pour simplifier le raisonnement), tel qu'illustré ci-dessous : le rayon situé au centre du regard sera le rayon rouge, et les autres rayons du champ de vision seront en bleu.

Origine du rendu fisheye en raycasting 2D

Pourtant, nous sommes censés voir que tous les points du mur sont situés à la même hauteur. C'est parce que les humains ont une lentille dans l'œil (le cristallin) pour corriger cet effet d'optique, lentille qu'il faut simuler pour obtenir un rendu adéquat.

Simple raycasting with fisheye correction
Simulation du raycasting face à un mur

Pour comprendre quel calcul effectuer, il faut faire un peu de trigonométrie. Reprenons l'exemple précédent, avec un regard perpendiculaire à un mur.

Distance-position

Or, vous remarquerez que le rayon bleu et le rayon rouge forment un triangle rectangle avec un pan de mur.

Détermination taille d'un mur en raycasting

Pour éliminer le rendu en œil de poisson, les rayons bleus doivent donner l'impression d'avoir la même longueur que le rayon rouge. Dans un triangle rectangle, le cosinus de l'angle a est égal au rapport entre le côté adjacent et l'hypoténuse, qui correspondent respectivement au rayon rouge et au rayon bleu. On en déduit qu'il faut corriger la hauteur perçue en la multipliant par le cosinus de l'angle a.

Les textures des murs[modifier | modifier le wikicode]

Le ray-casting permet aussi d'ajouter des textures sur les murs, le sol, et le plafond. Comme dit précédemment, les murs sont composés de pavés ou de cubes juxtaposés. Une face d'un mur a donc une hauteur et une largeur. Pour se simplifier la vie, les moteurs de ray-casting utilisent des textures dont la hauteur est égale à la hauteur d'un mur, et la largeur est égale à la largeur d'une face de pavé/cube.

Textures de FreeDoom. Vous voyez qu'elles sont toutes carrées et ont les mêmes dimensions.

En faisant cela, chaque colonne de pixels d'une texture correspond à une colonne de pixels du mur sur l'écran (et donc à un rayon lancé dans les étapes du dessus).

Texturing en raycasting

Reste à trouver à quelle colonne de texture correspond l'intersection avec le rayon, et la mettre à l'échelle (pour la faire tenir dans la hauteur perçue). L'intersection a comme coordonnées x et y, et est située soit sur un bord horizontal, soit sur un bord vertical d'un cube/pavé. On sait que les murs, et donc les textures, se répètent en vertical et en horizontal toutes les lmur (largeur/longueur d'un mur).

Application des textures en raycasting 2D

En conséquence, on peut déterminer la colonne de pixels à afficher en calculant :

  • le modulo de x avec la longueur du mur, si l'intersection coupe un mur horizontal ;
  • le modulo de y avec la largeur d'un mur si l'intersection coupe un mur vertical.