FMX Island épisode 4 : détection des collisions

Grégory Bersegeay

Voici le quatrième et dernier épisode de la série de tutoriels consacrée à la création d’un monde virtuel en 3D avec Delphi et Firemonkey. Dans cet épisode, nous allons mettre en place la détection des collisions afin de rendre plus réaliste le déplacement du joueur.

Commentez

- Titre : FMX Island épisode 4 : détection des collisions

- Auteur : Grégory Bersegeay

- Parution : 18 février 2019

- Public visé :

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2019 Grégory Bersegeay. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Prérequis

- II. Gestion des collisions avec le sol

- II-A. Modification de l’interface

- II-B. Récupération des hauteurs des sommets de mSol

- II-C. Effet escalier

- II-D. La théorie

- II-E. Mise en pratique

- III. Gestion des collisions avec les autres objets

- IV. Amélioration du rendu de l’océan

- V. Donnons une identité à l’île

- VI. Dernier petit bonus

- VII. Affichage du nombre d’images par secondes (FPS)

- VIII. Conclusion

- IX. Remerciements

I - Prérequis

Les prérequis techniques sont identiques aux épisodes précédents que vous pouvez retrouver :

- Episode 1 : génération de mondes extérieurs en 3D.

- Episode 2 : déplacements et orientation.

- Episode 3 : diverses améliorations .

Comme d’habitude, nous débuterons ce quatrième épisode là où nous nous sommes arrêtés à la fin du troisième. Vous pouvez donc au choix :

- reprendre directement votre projet issu de l’épisode 3 ;

- faire une copie du projet de l’épisode 3 dans un nouveau répertoire dédié à l’épisode 4. Vous conserverez ainsi une version du projet pour chaque étape.

Quel que soit votre choix, ouvrez Delphi et chargez votre projet.

II - Gestion des collisions avec le sol

L’objectif de ce chapitre est de gérer le point de vue du joueur en fonction du sol afin d’obtenir un déplacement réaliste et de ne plus passer à travers le sol. Pour ce faire, nous aurons besoin de quelques formules mathématiques mais nous allons y aller progressivement !

II-A - Modification de l’interface

Commençons en douceur : jusqu’à présent, nous avions utilisé un TTrackBar pour gérer l’altitude du point de vue du joueur. Nous allons faire un peu de ménage, car nous n’en aurons plus besoin, nous supprimons donc le TTrackBar tbAltitude et tout ce qui lui est lié :

- son événement OnChange ;

- se TLayout layAltitude.

II-B - Récupération des hauteurs des sommets de mSol

Le joueur va pouvoir avancer, reculer, tourner à droite et tourner à gauche. Pour ce qui est de l’altitude, il va falloir la calculer en fonction des aspérités du sol.

Nous allons avoir besoin de connaître la hauteur du TMesh mSol à l’endroit où se trouve le joueur. Pour rappel, lors de l’épisode 1, nous avons modelé un TPlane à l’aide d’une image Heightmap (champ de hauteur). Chaque pixel de cette image a été associé à un sommet du maillage mSol de sorte que la position du pixel en x et en y de l’image heightmap corresponde respectivement aux coordonnées X et Z d’un sommet du maillage. Pour la coordonnée Y du sommet, elle est calculée en fonction de la couleur du pixel. Ceci est réalisé dans la procédure CreerIle.

Ce schéma représente le TPlane servant à alimenter le TMesh mSol. Je n’ai représenté que cinq lignes et cinq colonnes, mais dans le code, le TPlane est subdivisé en MaxSolMesh (c’est une constante initialisée à 511).

L’objet mSol contient toutes ces informations dans sa propriété Data.VertexBuffer.Vertices. En effet, cette propriété donne accès à tous les sommets (vertices) des mailles du composant TMesh.

Les sommets sont stockés sous forme d’une liste à une dimension et non à deux dimensions comme dans une grille, par exemple. Du coup, à partir de la position du joueur (coordonnées X et Z), nous allons devoir déterminer la hauteur de sa position.

Ce schéma est le même que précédemment auquel j’ai ajouté en orange les indices de mSol.Data.VertexBuffer.Vertices. Nous en déduisions donc la manière de déterminer le sommet dans cette liste en fonction des coordonnées X et Z de la position du joueur.

Exemple, l’indice du sommet à prendre en compte lorsque le joueur est à la position X = 2 et Z = 1 sera :

mSol.data.VertexBuffer.Vertices[grilleX + (grilleZ * 5)].Z

où :

- grilleX sera le nombre entier inférieur à X ;

- grilleZ sera le nombre entier inférieur à Z ;

- cinq, car nous avons dans cet exemple cinq carrés par ligne.

Nous renvoyons la propriété Z de Vertices, car notre TMesh mSol est orienté depuis le début d’une certaine façon. Nous obtenons ainsi la hauteur du point de coordonnées (2,1) sur le schéma que l’on trouve à la 7e position dans la liste des sommets. Il s’agit de la hauteur du coin supérieur gauche du carré à l’indice 7.

Nous savons maintenant récupérer la hauteur d’un sommet proche de la position du joueur. Nous allons donc implémenter cela. Créons une nouvelle fonction nommée CalculerHauteur qui va retourner la hauteur d’un point en fonction de ses coordonnées.

// Fonction qui renvoie la hauteur du sol pour la position P
function TfPrincipale.CalculerHauteur(P : TPoint3D):single;
var
grilleX, grilleZ : integer;
begin
grilleX := Math.Floor(P.X+moitieCarte);
grilleZ := Math.Floor(P.Z+moitieCarte);

result := -mSol.data.VertexBuffer.Vertices[grilleX + (grilleZ * MaxMeshPlus1)].Z + mSol.Depth/2;
end;

Nous utilisons la fonction Floor de l’unité Math qui permet de retourner l’entier inférieur le plus proche du résultat de la division. Il faut donc ajouter l’unité Math dans la clause uses.

À noter que nous ajoutons la moitié de la profondeur de mSol, car pour rappel, mSol est taillé à 50 dans la procédure CreerIle : la hauteur des sommets varies donc entre -25 et + 25. Comme nous souhaitons que la position du joueur soit toujours au-dessus du sol (et non sous le sol), nous ajoutons cette demi-hauteur.

Nous devons maintenant faire appel à CalculerHauteur dans notre boucle principale, la procédure faniPrincipaleProcess :

procedure TfPrincipale.faniPrincipaleProcess(Sender: TObject);
begin
if debut then
begin
CreerPlan; // Création de la carte
debut := false;
end
else
begin
dmyProchainePosition.Position.Point := dmyJoueurOrientation.Position.Point + direction * tbVitesse.value;

if mSol.Data.VertexBuffer.Length > 0 then
begin
dmyProchainePosition.Position.Y := CalculerHauteur(dmyProchainePosition.Position.Point) - demiHauteurJoueur - TailleJoueur;
end;

dmyProchainePosition.Position.Y := - 50;  // On place le dummy indiquant la position du joueur sur la carte au dessus
dmyPositionJoueurCarte.Position.Point := dmyProchainePosition.Position.Point; // Mise à jour du TCone représentant la position du curseur sur la carte
end;
end;

Nous conservons le calcul de la nouvelle position P par rapport à la position actuelle du joueur (dmyJoueurOrientation), de sa direction et de sa vitesse. Par contre, nous allons récupérer la valeur retournée par la fonction CalculerHauteur qui prend en paramètre la nouvelle position calculée P.

À noter :

dans la procédure faniPrincipaleProcess, nous récupérons la hauteur renvoyée par CalculerHauteur à laquelle nous ajoutons TailleJoueur. En effet, CalculerHauteur renvoie la hauteur du sol : il faut définir une taille à notre joueur afin que le point de vue ne soit pas au ras du sol. TailleJoueur est une constante déclarée avec les autres constantes :

const
MaxSolMesh = 511;  /// Nombre de mailles sur un côté du TMesh
SizeMap = 512;     // Taille du côté du TMesh
sizeHauteur = 50;  // Taille hauteur du TMesh
TailleJoueur = 1.4;// Taille du joueur

Exécutez à présent le projet. Vous constaterez très certainement deux choses :

- le point de vue joueur évolue en suivant le relief ! Très bien : c’est ce que nous voulions ;

- mais le déplacement en hauteur se fait par à-coups comme si nous montions des escaliers, ce qui n’est pas souhaitable.

II-C - Effet escalier

Cet effet d’escalier est tout à fait normal. En effet, reprenons le schéma vu précédemment :

Les coordonnées X et Z sont des flottants (single). Nous constatons sur le schéma que l’on se trouve sur un sommet lorsqu’une des coordonnées est entière. Entre deux sommets, les valeurs de X et Z sont des valeurs décimales.

Effectuons un zoom sur une des mailles pour illustrer cela.

Le déplacement en X et Z se fait donc de manière linéaire et précise.

En revanche, sur l’axe Y, nous ne disposons que des valeurs Y pour les sommets. Tant que le point P reste dans une maille, sa hauteur est inchangée et reste à la hauteur du sommet supérieur gauche de la maille. Par contre, lorsqu’il passe sur une maille voisine, la hauteur Y passe brutalement à la hauteur du sommet supérieur gauche de la nouvelle maille.

Nous avons l’explication de l’effet « escalier », voyons maintenant comment y remédier et rendre les déplacements verticaux plus doux. Je ne vous cache pas que nous allons faire un peu de mathématiques, car l’idée va être de calculer des valeurs de Y intermédiaires en fonction des valeurs de Y de chaque sommet. C’est ce qui s’appelle une interpolation.

II-D - La théorie

Jusqu’à présent, j’ai représenté par un quadrillage les subdivisions du TPlane ayant servi à modeler le TMesh mSol. Cela permet de visualiser facilement le quadrillage qui a servi à générer le mSol. En réalité, les moteurs graphiques n’utilisent pas des carrés mais des triangles.

Complétons le schéma précédent :

J’ai nommé les quatre sommets, j’ai placé deux points P1 et P2 et j’ai tracé une diagonale en orange.

Les sommets A, B, C et D du carré sont les quatre points pour lesquels nous connaissons exactement la hauteur Y via mSol.Data.VertextBuffer.Vertices.

En traçant la diagonale orange telle qu’elle est indiquée sur le schéma, elle découpe le carré en deux triangles ABD et BCD.

Pour calculer la hauteur d’un point quelconque appartenant au triangle ABD, nous utiliserons les hauteurs des points A, B et D. De même, pour calculer la hauteur d’un point quelconque appartenant au triangle BCD, nous utiliserons les hauteurs des points B, C et D.

Nous devons donc déterminer à quel triangle appartient le point. Pour ce faire, nous remarquons que les points situés sur la diagonale orange ont des coordonnées X et Z liées suivant la règle : X = 1 - Z.

Nous pouvons en déduire que si X < 1 – Z, alors le point est dans le triangle ABD sinon, il est dans le triangle BCD.

Exemple sur le schéma :

- le point P1 de coordonnées X1 = 0.6 et Z1 = 0.2 appartient au triangle ABD car X1 < 1 – Z1 (0.6 < 1 – 0.2) ;

- le point P2 de coordonnées X2 = 0.8 et Z2 = 0.85 appartient au triangle BCD car X2 > 1 – Z2 (0.8 > 1 – 0.85).

Sachant dans quel triangle se trouve le point, nous allons utiliser une méthode qui va nous permettre de déterminer une hauteur pour le point donné en fonction des hauteurs des trois sommets du triangle. La méthode mathématique que nous allons utiliser est celle des barycentres et est décrite (en anglais) à cette adresse : Barycentric coordinates on triangles.

Je ne m’étendrai pas plus sur les explications mathématiques et je vous invite à consulter cette page.

II-E - Mise en pratique

Nous venons de voir la théorie, passons maintenant à la pratique.

Vous l’aurez compris, nous allons réécrire complètement la fonction CalculerHauteur comme suit :

function TfPrincipale.CalculerHauteur(P: TPoint3D) : single;
var
grilleX, grilleZ : integer;
xCoord, zCoord, hauteurCalculee : single; // coordonnées X et Z dans le "carré"
begin
// Détermination des indices permettant d'accéder au sommet en fonction de la position du joueur
grilleX := Math.Floor(P.X+moitieCarte);
grilleZ := Math.Floor(P.Z+moitieCarte);

// Si on est en dehors du mSol, on force (arbitrairement) la hauteur à la hauteur de la mer
if (grilleX >= MaxSolMesh) or (grilleZ >= MaxSolMesh) or (grilleX < 0) or (grilleZ < 0) then
begin
result := -pMer.Position.Z;
end
else
begin
xCoord := Frac(P.X); // position X dans la maille courante
zCoord := Frac(P.Z); // position y dans la maille courante

// On calcule la hauteur en fonction des 3 sommets du triangle dans lequel se trouve le joueur
// On détermine dans quel triangle on est
if xCoord <= (1 - zCoord) then
begin
hauteurCalculee := Barycentre(TPoint3D.Create(0,-mSol.data.VertexBuffer.Vertices[grilleX + (grilleZ * MaxMeshPlus1)].Z,0),
TPoint3D.Create(1,-mSol.data.VertexBuffer.Vertices[grilleX +1+ (grilleZ * MaxMeshPlus1)].Z,0),
TPoint3D.Create(0,-mSol.data.VertexBuffer.Vertices[grilleX + ((grilleZ +1)* MaxMeshPlus1)].Z,1),
TPointF.Create(xCoord, zCoord));
end
else
begin
hauteurCalculee := Barycentre(TPoint3D.Create(1,-mSol.data.VertexBuffer.Vertices[grilleX +1+ (grilleZ * MaxMeshPlus1)].Z,0),
TPoint3D.Create(1,-mSol.data.VertexBuffer.Vertices[grilleX +1+ ((grilleZ +1) * MaxMeshPlus1)].Z,1),
TPoint3D.Create(0,-mSol.data.VertexBuffer.Vertices[grilleX + ((grilleZ +1)* MaxMeshPlus1)].Z,1),
TPointF.Create(xCoord, zCoord));
end;

hauteurCalculee := hauteurCalculee * miseAEchelle + demiHauteurSol;  // Hauteur calculée et mise à l'échelle (size 50 dans CreerIle et prise en compte des demi-hauteurs)
// Si la hauteur calculée est > à la hauteur de pMer, alors on retourne la hauteur de pMer
if hauteurCalculee > -pMer.Position.Z then result := -pMer.Position.z
else result := hauteurCalculee;
end;
end;

Détaillons cette nouvelle version de la fonction CalculerHauteur.

Tout d’abord, la fonction conserve sa signature et reçoit toujours en paramètre la future position du joueur sous la forme d’un TPoint3D.

Ensuite, nous contrôlons si la position du joueur est toujours dans les limites du TMesh mSol. En effet, notre petit monde virtuel est plus grand que le TMesh : la mer et la sphère représentant le ciel sont bien plus grandes que le TMesh. Si le joueur se déplace au-delà du TMesh, nous ne disposons plus d’information sur la hauteur, car nous sommes au-delà de la taille de notre mSol.

Dans ce cas-là, nous allons arbitrairement fixer la hauteur de la position du joueur à la hauteur de la mer.

// Si on est en dehors du mSol, on force (arbitrairement) la hauteur à la hauteur de la mer
if (grilleX >= MaxSolMesh) or (grilleZ >= MaxSolMesh) or (grilleX < 0) or (grilleZ < 0) then
begin
result := -pMer.Position.Z;
end

Par contre, si le joueur est sur le TMesh, alors nous allons déterminer sa hauteur en fonction de la hauteur du TMesh à cet endroit. Voici donc le bloc else du if précédent :

else
begin
xCoord := Frac(P.X); // position X dans la maille courante
zCoord := Frac(P.Z); // position y dans la maille courante

// On calcule la hauteur en fonction des 3 sommets du triangle dans lequel se trouve le joueur
// On détermine dans quel triangle on est
if xCoord <= (1 - zCoord) then
begin
hauteurCalculee := Barycentre(TPoint3D.Create(0,-mSol.data.VertexBuffer.Vertices[grilleX + (grilleZ * MaxMeshPlus1)].Z,0),
TPoint3D.Create(1,-mSol.data.VertexBuffer.Vertices[grilleX +1+ (grilleZ * MaxMeshPlus1)].Z,0),
TPoint3D.Create(0,-mSol.data.VertexBuffer.Vertices[grilleX + ((grilleZ +1)* MaxMeshPlus1)].Z,1),
TPointF.Create(xCoord, zCoord));
end
else
begin
hauteurCalculee := Barycentre(TPoint3D.Create(1,-mSol.data.VertexBuffer.Vertices[grilleX +1+ (grilleZ * MaxMeshPlus1)].Z,0),
TPoint3D.Create(1,-mSol.data.VertexBuffer.Vertices[grilleX +1+ ((grilleZ +1) * MaxMeshPlus1)].Z,1),
TPoint3D.Create(0,-mSol.data.VertexBuffer.Vertices[grilleX + ((grilleZ +1)* MaxMeshPlus1)].Z,1),
TPointF.Create(xCoord, zCoord));
end;

hauteurCalculee := hauteurCalculee * miseAEchelle + demiHauteurSol;  // Hauteur calculée et mise à l'échelle (size 50 dans CreerIle et prise en compte des demi-hauteurs)
// Si la hauteur calculée est > à la hauteur de pMer, alors on retourne la hauteur de pMer
if hauteurCalculee > -pMer.Position.Z then result := -pMer.Position.z
else result := hauteurCalculee;
end;

Rappelez-vous ce que nous avons vu dans le chapitre précédent, nous savons dans quel « carré » du quadrillage nous nous trouvons. Il s’agit du carré ayant pour coin supérieur gauche le point aux coordonnées grilleX et grilleZ. Nous allons travailler dans ce carré et déterminer dans lequel des deux triangles nous nous trouvons. Je remets le schéma pour permettre de bien fixer la situation :

Nous commençons donc par récupérer les coordonnées X et Z de la position du joueur dans ce carré de côté de longueur 1. Pour cela, il suffit de récupérer respectivement les valeurs décimales des coordonnées X et Z de la position du joueur.

xCoord := Frac(P.X); // position X dans la maille courante
zCoord := Frac(P.Z); // position y dans la maille courante

À l’aide de ces coordonnées, nous pouvons déterminer à quel triangle appartient la position du joueur : si xCoord est inférieur à 1 – zCoord alors le point appartient au triangle ABD, sinon il appartient au triangle BCD.

// On détermine dans quel triangle on est
if xCoord <= (1 - zCoord) then
begin
hauteurCalculee := Barycentre(TPoint3D.Create(0,-mSol.data.VertexBuffer.Vertices[grilleX + (grilleZ * MaxMeshPlus1)].Z,0),
TPoint3D.Create(1,-mSol.data.VertexBuffer.Vertices[grilleX +1+ (grilleZ * MaxMeshPlus1)].Z,0),
TPoint3D.Create(0,-mSol.data.VertexBuffer.Vertices[grilleX + ((grilleZ +1)* MaxMeshPlus1)].Z,1),
TPointF.Create(xCoord, zCoord));
end

La fonction Barycentre qui est appelée prend en paramètre quatre éléments : les coordonnées des trois sommets du triangle sur lequel nous nous situons (des TPoint3D) et les coordonnées xCoord et zCoord sous la forme d’un TPointF (un point en 2D, mais dont les composantes X et Y sont des flottants).

Si nous sommes dans le triangle ABC, alors nous appelons la fonction Barycentre avec les paramètres suivants :

- TPoint3D.Create(0,-mSol.data.VertexBuffer.Vertices[grilleX + (grilleZ * MaxMeshPlus1)].Z,0) : un TPoint3D avec son X à 0, son Y à -mSol.data.VertexBuffer.Vertices[grilleX + (grilleZ * MaxMeshPlus1)].Z et son Z à 0. Sur le schéma précédent, il s’agit du sommet A ;

- TPoint3D.Create(1,-mSol.data.VertexBuffer.Vertices[grilleX +1+ (grilleZ * MaxMeshPlus1)].Z,0): un TPoint3D avec son X à 1, son Y à -mSol.data.VertexBuffer.Vertices[grilleX +1+ (grilleZ * MaxMeshPlus1)].Z et son Z à 0. Sur le schéma précédent, il s’agit du sommet B ;

- TPoint3D.Create(0,-mSol.data.VertexBuffer.Vertices[grilleX + ((grilleZ +1)* MaxMeshPlus1)].Z,1) : un TPoint3D avec son X à 0, son Y à -mSol.data.VertexBuffer.Vertices[grilleX + ((grilleZ +1)* MaxMeshPlus1)].Z et son Z à 1. Sur le schéma précédent, il s’agit du sommet C ;

- TPointF.Create(xCoord, zCoord) : un TPointF consitué de xCoord et zCoord vus précédemment.

Si le point est dans le triangle BCD, on appelle également la fonction Barycentre mais avec les coordonnées des sommets B, C et D.

Avant de voir la fonction Barycentre, notons une dernière petite chose dans la fonction CalculerHauteur. Nous ne renvoyons pas directement la hauteur calculée, car nous faisons :

hauteurCalculee := hauteurCalculee * miseAEchelle + demiHauteurSol;  // Hauteur calculée et mise à l'échelle (size 50 dans CreerIle et prise en compte des demi-hauteurs)
// Si la hauteur calculée est > à la hauteur de pMer, alors on retourne la hauteur de pMer
if hauteurCalculee > -pMer.Position.Z then result := -pMer.Position.z
else result := hauteurCalculee;

En effet, lors de la création du TMesh à partir du TPlane, nous avons taillé le TMesh à 50 dans sa propriété Depth. Les hauteurs calculées doivent donc être mise à cette échelle. Cette correction n’est pas suffisante. La propriété Depth à 50 signifie que l’objet TMesh « s’étale » équitablement de -25 à 25 son axe Y. C’est pour cela que nous ajoutons la demi-hauteur du sol.

Vous constaterez que nous avons ajouté trois variables, miseAEchelle, demiHauteurSol et demiHauteurJoueur que nous n’avons pas encore déclarées. Nous allons le faire dès à présent en tant qu’attributs de la classe TfPrincipale :

demiHauteurJoueur, miseAEchelle, demiHauteurSol : single;
moitieCarte : integer;

- demiHauteurJoueur correspondra à la moitié de la hauteur du dmyJoueurOrientation ;

- miseAEchelle sera le facteur de mise à l’échelle de la hauteur calculée demiHauteurSol correspondra à la moitié de la hauteur du mSol.

Comme ces variables seront alimentées après avoir créé le TMesh et que leurs valeurs n’évolueront plus par la suite, nous passons par des variables pour stocker les résultats de ces petits calculs afin de ne pas les effectuer à chaque itération de CalculerHauteur. Comme nous l’avons fait précédemment pour moitieCarte.

Nous allons alimenter ces variables dans la procédure CreerIle, après la génération du TMesh et avant l’appel à genererObjets qui devient maintenant :

procedure TfPrincipale.CreerIle(const nbSubdivisions: integer);
[…]
moitieCarte := math.Floor(SizeMap/2);
demiHauteurJoueur := dmyJoueurOrientation.Height/2;
miseAEchelle := sizeHauteur / (-hauteurMin);
demiHauteurSol := mSol.Depth/2;
genererObjets; // Génération des objets (bâtiments, arbres, autres...)
[...]
end;

À présent, il nous reste à écrire la fonction Barycentre. Pour ce faire, je n’ai rien inventé et j’ai repris telles quelles, les formules indiquées sur la page Wikipédia que je vous ai indiquée au chapitre précédent. Plus précisément, j’ai repris ce qui est mentionné dans le paragraphe Conversion between barycentric and Cartesian coordinates :

// https://en.wikipedia.org/wiki/Barycentric_coordinate_system#Conversion_between_barycentric_and_Cartesian_coordinates
function TfPrincipale.Barycentre(p1, p2, p3 : TPoint3D; p4 : TPointF):single;
var
det, l1, l2, l3 : single;
begin
det := (p2.z - p3.z) * (p1.x - p3.x) + (p3.x - p2.x) * (p1.z - p3.z);
l1  := ( (p2.z - p3.z) * (p4.x - p3.x) + (p3.x - p2.x) * (p4.y - p3.z) ) / det;
l2  := ( (p3.z - p1.z) * (p4.x - p3.x) + (p1.x - p3.x) * (p4.y - p3.z) ) / det;
l3  := 1 - l1 - l2;
result := l1 * p1.y + l2 * p2.y + l3 * p3.y;
end;

Je ne détaillerai pas cette fonction, car je n’ai fait que retranscrire les formules indiquées sur la page Wikipédia.

Enfin, via l’inspecteur d’objets, positionnons les propriétés suivantes de la caméra camera1 :

- position.X : 0 ;

- position.Y : 0 ;

- position.Z : -1.

La caméra sera légèrement derrière le dmyJoueurOrientation : il est possible ainsi de placer un objet 3D, tel un TCylinder, pour symboliser le joueur. De plus, en prévision de la suite (la collision du joueur avec les obstacles), il est préférable d’avoir la caméra un peu en arrière, sinon lors d’une collision avec un immeuble ou un arbre, la caméra serait si proche de l’obstacle que certains polygones de celui-ci disparaissent. Il est à noter également que lorsque le joueur se déplace en suivant une courbe de niveau sur des portions abruptes, nous parvenons à voir ce qu’il y a sous le sol.

La gestion de la caméra est un des nombreux points pouvant être améliorés, mais nous n’irons pas plus loin dans ce tutoriel.

Exécutez à nouveau le projet. Cette fois-ci, vous devriez constater que le déplacement du joueur suit fidèlement et sans à-coups les aspérités du TMesh.

Vous constaterez qu’il arrive parfois que l’on voie sous le sol. Effectivement, cela dépend de l’orientation de la caméra dans les zones abruptes. Pour y remédier, une des possibilités serait d’interdire le déplacement dans cette direction si la maille sur laquelle se trouve le joueur est trop inclinée. Ce n’est pas fait dans ce tutoriel : il faudra donc nous contenter de ce petit défaut !

III - Gestion des collisions avec les autres objets

Nous allons maintenant voir la détection de collision avec les objets (immeubles et arbres). Nous allons utiliser la même méthode que celle présentée dans mon tutoriel sur le jeu FMX Corridor.

Rappel de la méthode

Nous allons utiliser une méthode simple de détection des collisions en 3D. Simple mais peu précise, elle sera suffisante pour notre projet. Nous allons considérer le rectangle en 3D englobant l’objet et tester la distance le séparant du TDummy dmyJoueurOrientation.

Représentons une collision vue de dessus entre le TDummy représentant la position du joueur (carré bleu) et le TRectangle3D englobant un obstacle (rectangle vert) :

Il y a collision lorsque la distance entre le centre G1 de l’obstacle et le centre G2 du TDummy est la moitié de la largeur de l’obstacle, à laquelle nous ajoutons la moitié de la largeur du TDummy. Avec les TRectangle3D, la méthode est précise.

Voyons maintenant ce que donne cette méthode avec une collision entre le TDummy et un TCylinder constituant le phare ou l’éolienne dans notre projet :

Dans ce schéma, nous retrouvons notre TDummy (le carré bleu). Le cercle vert représente le TClynder vu de dessus. Le carré vert englobant le cercle vert représente le TRectangle3D imaginaire de notre méthode de détection des collisions.

Une nouvelle fois, il y a collision lorsque la distance entre le centre G1 de l’obstacle et le centre G2 du TDummy est la moitié de la largeur de l’obstacle à laquelle nous ajoutons la moitié de la largeur du TDummy. Cette fois-ci nous perdons en précision, car nous détecterons une collision si un sommet du TDummy se trouve dans les zones rouges (la différence entre le TCylinder et le TRectangle3D englobant).

Cette perte de précision est toutefois acceptable.

Enfin, regardons ce qui se passe avec une collision entre le TDummy et un TModel3D. Le TModel peut avoir n’importe quelle forme :

La zone rouge d’imprécision est bien plus grande ! Malgré cette imprécision, nous allons implémenter cette méthode de détection en reprenant du code déjà présent dans FMX Corridor.

Nous allons toutefois effectuer quelques modifications au code existant afin de limiter cette imprécision.

Pour ce faire, la propriété WrapMode du TModel3D permet de spécifier la manière dont l’objet 3D s’insère dans le cadre l’entourant. Nous allons lui affecter la valeur Original. Le modèle 3D sera ainsi au centre du rectangle 3D englobant l’objet.

En conséquence de ce changement, nous devons adapter les propriétés suivantes de modeleArbre comme suit :

- Depth : 1 ;

- Height : 1 ;

- Position.X : -20 ;

- Position.Y : 20 ;

- Position.Z : 18,5 ;

- Width : 1.

Modifions également la création des arbres dans la procédure genererObjets :

[...]
// Chargement de quelques arbres un peu partout sur le plateau
ConstructionObjets(TPoint3D.Create(-25,18,19.5),TPoint3D.Create(1,1,1), arbre);
ConstructionObjets(TPoint3D.Create(-25,22,19.9),TPoint3D.Create(1,1,1), arbre);
ConstructionObjets(TPoint3D.Create(-24,25,19.3),TPoint3D.Create(1,1,1), arbre);
ConstructionObjets(TPoint3D.Create(-15,23,17.5),TPoint3D.Create(1,1,1), arbre);
ConstructionObjets(TPoint3D.Create(-19,28,17.4),TPoint3D.Create(1,1,1), arbre);
ConstructionObjets(TPoint3D.Create(-25,33,18.5),TPoint3D.Create(1,1,1), arbre);
[...]

Nous adaptons la position des arbres et leur taille à height = 1, depth = 1 et width = 1.

Ces modifications permettent de limiter l’imprécision puisque le cadre englobant l’objet 3D sera taillé en fonction des largeur, hauteur et profondeur maximales de l’objet.

Ensuite, passons à la détection proprement dite. Ajoutons quelques composants supplémentaires :

- un TDummy nommé dmyProchainePosition qui va servir à déterminer la prochaine position du joueur. C’est cette future position qui nous servira à tester une éventuelle collision avec les obstacles. S’il y a collision, alors le joueur restera à sa position actuelle, sinon, la position du joueur pourra se déplacer à sa future position ;

- un TLabel nommé lblCollision servira à afficher le nom de l’objet avec lequel le joueur est entré en collision.

Plaçons dmyProchainePosition en tant qu’enfant de dmyMonde. Conservons toutes ses propriétés par défaut.

Plaçons lblCollision en tant qu’enfant de viewport et modifions ses propriétés :

- Align à Top ;

- TextSettings.Font.FontColor à #FFFA2525.

Ensuite, reprenons la fonction SizeOf3D du projet FMX Corridor qui renvoie les dimensions de l’objet 3D passé en paramètres :

// Renvoie les dimensions de l'objet 3D
function TfPrincipale.SizeOf3D(const unObjet3D: TControl3D): TPoint3D;
begin
Result :=NullPoint3D;
if unObjet3D <> nil then
result := Point3D(unObjet3D.Width, unObjet3D.Height, unObjet3D.Depth);
end;

Reprenons le contenu de la procédure ObstacleRender du projet FMX Corridor dans une nouvelle fonction que nous nommerons DetectionCollisionObstacle :

function TfPrincipale.DetectionCollisionObstacle:boolean;
var
unObjet3D:TControl3D; // l'objet en cours de rendu
DistanceEntreObjets,Direction,distanceMinimum: TPoint3D;
i : integer;
begin
result := false;
lblCollision.Text := '';
for I := 0 to mSol.ChildrenCount-1 do
begin
if (mSol.Children[i] is TRectangle3D) or ((mSol.Children[i] is TModel3D) or
(mSol.Children[i] is TCylinder) or (mSol.Children[i] is TProxyObject)) then
begin
// On travail sur l'objet qui est en train d'être calculé
unObjet3D := TControl3D(mSol.Children[i]);
DistanceEntreObjets := unObjet3D.AbsoluteToLocal3D(TPoint3D(dmyProchainePosition.AbsolutePosition)); // Distance entre l'objet 3d et la balle
distanceMinimum := (SizeOf3D(unObjet3D) + SizeOf3D(dmyProchainePosition)) / 2; // distanceMinimum : on divise par 2 car le centre de l'objet est la moitié de la taille de l'élément sur les 3 composantes X, Y, Z

// Test si la valeur absolue de position est inférieure à la distanceMinimum calculée sur chacune des composantes
if ((Abs(DistanceEntreObjets.X) < distanceMinimum.X) and (Abs(DistanceEntreObjets.Y) < distanceMinimum.Y) and
(Abs(DistanceEntreObjets.Z) < distanceMinimum.Z)) then
begin
result := true;
lblCollision.Text := 'Collision avec '+unObjet3D.Name;
break;
end;
end;
end;
end;

Je vous invite à lire mon tutoriel sur FMX Corridor pour comprendre cette procédure. Quelques petits aménagements ont été réalisés :

- le text de lblCollision est initialisé à vide en début de boucle. Il est renseigné avec le nom de l’obstacle percuté en cas de collision ;

- nous bouclons sur tous les composants enfants de mSol :

Enfin, nous allons faire appel à cette procédure DetectionCollisionObstacle dans la boucle principale du projet de la procédure faniPrincipaleProcess qui devient :

procedure TfPrincipale.faniPrincipaleProcess(Sender: TObject);
begin
if debut then
begin
CreerPlan; // Création de la carte
debut := false;
end
else
begin
dmyProchainePosition.Position.Point := dmyJoueurOrientation.Position.Point + direction * tbVitesse.value;

if mSol.Data.VertexBuffer.Length > 0 then
begin
dmyProchainePosition.Position.Y := CalculerHauteur(dmyProchainePosition.Position.Point) - demiHauteurJoueur - TailleJoueur;
if not(DetectionCollisionObstacle) then dmyJoueurOrientation.Position.Point := dmyProchainePosition.Position.Point;
end;

dmyProchainePosition.Position.Y := - 50;  // On place le dummy indiquant la position du joueur sur la carte au dessus
dmyPositionJoueurCarte.Position.Point := dmyProchainePosition.Position.Point; // Mise à jour du TCone représentant la position du curseur sur la carte
end;
end;

Nous n’avons plus dans cette version la variable locale P que nous remplaçons par dmyProchainePosition.Position.Point. Une fois la hauteur de la prochaine position calculée, nous invoquons DetectionCollisionObstacle et, si aucune collision n’est détectée, nous pouvons déplacer la position du joueur à sa future position, sinon on ne fait rien : le joueur reste à sa place actuelle.

Exécutez à nouveau le projet et dirigez-vous vers les bâtiments des villes, les arbres, l’éolienne et le phare pour constater les différents comportements. Vous verrez que la détection des arbres n’est pas précise…

IV - Amélioration du rendu de l’océan

Dans l’épisode 3, nous avons ajouté une TFloatAnimation pour faire monter et descendre le pMer. Ce fut très simple à mettre en place mais ce n’est pas très réaliste. Nous allons améliorer ce rendu en simulant des vagues.

Nous l’avons vu dans le premier épisode, nous avons généré le sol de l’île à partir d’un TPlane que nous avons subdivisé en mailles. Nous avons ensuite calculé des hauteurs de chacun des sommets du maillage pour affecter le tout à un TMesh. Pour simuler des vagues sur la mer, nous allons procéder de la même manière sauf que nous calculerons une hauteur, non pas en fonction de la couleur d’un pixel d’une heightmap, mais d’une fonction mathématique.

En recherchant une telle fonction, je suis tombé sur l’article suivant, d’Anders Ohlsson sur un forum Embarcadero : http://edn.embarcadero.com/article/42012.

Nous allons donc reprendre ce code, l’adapter et l’intégrer au projet FMX Island.

Tout d’abord, nous allons supprimer l’animation AniVagues qui ne sera plus nécessaire et supprimer sa référence dans la procédure interactionIHM :

procedure TfPrincipale.interactionIHM;
begin
faniCiel.ProcessTick(0,0); // Permet de ne pas bloquer les animations pendant que l'utilisateur interagit avec l'interface graphique
faniJourNuit.ProcessTick(0,0);
end;

Ajoutons un nouveau TLightMaterial que nous nommerons textureLac. Modifions la procédure ChargerTextures afin de charger l’image mer.jpg dans ce nouveau material et nous profitons de l’occasion pour charger l’image oceans.jpg dans textureMer (le rendu me paraît meilleur ainsi) :

procedure TfPrincipale.ChargerTextures; // Chargement des textures
begin
textureMesh.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'plan.png');
textureMer.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'oceans.jpg');
textureLac.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'mer.jpg');
textureBatiment.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'batiment.jpg');
textureCoteBatiment.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'coteBatiment.png');
texturePhare.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'phare.png');
TextureCielNuit.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'cielnuit.png');
textureNuage.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'cloud1.png');
textureNuage2.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'cloud2.png');
textureNuage3.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'cloud3.png');
maHeightMap:=TBitmap.Create;
end;

Le TPlane pMer que nous utilisions représentera la surface de la mer et c’est lui que nous animerons avec les vagues. Nous allons le rendre légèrement transparent en positionnant sa propriété Opacity à 0,6 via l’inspecteur d’objets.

Nous allons ajouter un nouveau TPlane qui représentera le fond de l’océan. Voici ses caractéristiques :

- Name : pMerFond ;

- Material : textureMer ;

- HitTest : false ;

- Locked : true ;

- Position.Z : -22,8 ;

- Height : 960 (même taille que pMer) ;

- Width : 960 (même taille que pMer).

Notre pMer va être subdivisé en maillage. Il est de forme carrée, nous allons donc arbitrairement lui affecter 50 mailles de côté. Pour ce faire, déclarons une nouvelle constante :

MaxMerMesh = 50;   // Nombre de mailles sur un côté du pMer

Nous allons également avoir besoin d’une variable qui évoluera en fonction du temps. Déclarons la variable temps de type single dans les déclarations publiques de TfPrincipale.

temps : single;

Toujours dans les déclarations publiques de TfPrincipale, nous avons également besoin de disposer d’un TPoint3D qui indiquera le point de départ des ondes des vagues. Il sera arbitrairement au centre du pMer.

Center : TPoint3D;

Nous allons initialiser ces nouvelles variables dans FormCreate qui devient :

procedure TfPrincipale.FormCreate(Sender: TObject);
begin
debut := true;
temps := 0;
Center := Point3D(MaxMerMesh / pMer.Width, MaxMerMesh / pMer.Height, 0);
pMer.SubdivisionsHeight := MaxMerMesh;
pMer.SubdivisionsWidth := MaxMerMesh;
indicePhoto := 1;
sCiel.visible := false;
ChargerTextures; // Charge les différentes textures
CreerIle(MaxSolMesh);  // Création du niveau (heightmap, immubles, arbres et autres objets
end;

Nous initialisons temps à 0, Center au centre du pMer et nous subdivisons pMer en « MaxMerMesh » mailles en X et en Y.

L’animation des vagues va se produire dans l’événement OnRender du pMer. Via l’inspecteur d’objets, générons cet événement et plaçons-y le code suivant :

procedure TfPrincipale.pMerRender(Sender: TObject; Context: TContext3D);
begin
CalcMesh;
if cbGrille.IsChecked then Context.DrawLines(TMeshHelper(pMer).Data.VertexBuffer, TMeshHelper(pMer).Data.IndexBuffer, TMaterialSource.ValidMaterial(mCouleurToitPhare),0.25);
end;

Nous allons étudier la procédure CalcMesh juste après. On invoque également le traçage des polygones du pMer si la case cbGrille est cochée.

La procédure CalcMesh va servir à calculer la hauteur des sommets des mailles à partir d’une fonction mathématique. J’ai repris le code d’Anders Ohlsson en l’adaptant. Cette méthode permet même d’avoir plusieurs sources d’ondulation. Pour FMX Island, nous nous contenterons d’une seule source située au centre de notre petit monde.

// Exemple trouvé : http://edn.embarcadero.com/article/42012
procedure TfPrincipale.CalcMesh(aPlane : TPlane; origine, P, W : TPoint3D; maxMesh : integer);
var
M:TMeshData;
i,x,y,MaxMerMeshPlus1, lgMoins1 : integer;
somme: single;  // Permet de cumuler les hauteurs calculer en cas de plusieurs ondes
front, back : PPoint3D;
F : array of TWaveRec;  // Tableau d'ondes
begin
M:=TMeshHelper(aPlane).Data; // affectation du aPlane au TMeshData afin de pouvoir travailler avec ses mailles

MaxMerMeshPlus1 := MaxMesh + 1;
System.setLength(F,1);  // Nous n'utiliserons qu'une seule onde mais le code permet d'en gérer plusieurs...
F[System.Length(F)-1].origine := origine;
F[System.Length(F)-1].p := P;
F[System.Length(F)-1].w := W;
lgMoins1 := system.Length(F)-1;

for y := 0 to MaxMesh do  // Parcours toutes les "lignes" du maillage
for x := 0 to MaxMesh do // Parcours toutes les "colonnes" du maillage
begin
front := M.VertexBuffer.VerticesPtr[X + (Y * MaxMerMeshPlus1)];
back := M.VertexBuffer.VerticesPtr[MaxMerMeshPlus1 * MaxMerMeshPlus1 + X + (Y * MaxMerMeshPlus1)];
somme := 0; // initialisation de la somme
for i := 0 to lgMoins1 do somme:=F[i].Wave(somme, x, y,temps); // Calcul de la hauteur du sommet de la maille
somme := somme * 100;
Front^.Z := somme;
Back^.z := somme;
end;
M.CalcTangentBinormals;
temps := temps + 0.01; // Incrémentation arbitraire du temps
end;

Cette procédure déclare un tableau de TWaveRec que nous verrons juste après. Cela permet de gérer plusieurs ondes (pour générer des vagues plus complexes). Nous n’allons en gérer qu’une seule : le tableau n’aura donc qu’un seul élément.

Nous fixons cet élément avec les valeurs suivantes :

- son origine (le départ de l’onde) au centre de notre monde à la hauteur du pMer ;

- son p (position par rapport à l’origine de l’onde) ;

- son w (TPoint3D permettant de positionner l’amplitude de l’onde sur la valeur X, la longueur de l’onde sur Y et la vitesse sur Z).

Pour ajouter d’autres ondes, il suffit d’ajouter un nouvel élément au tableau et d’en fixer les valeurs.

Ensuite, pour chaque sommet du maillage, nous invoquons la fonction wave de chaque élément du tableau afin de calculer la hauteur du sommet en fonction des ondes.

La procédure CalcMesh se termine par l’incrémentation arbitraire de la variable temps.

Le type TWaveRec est défini de la manière suivante :

type TWaveRec = record
P, W, origine : TPoint3D;
function Wave(aSum, aX, aY, aT : single):Single;
end;

Enfin, la fonction Wave du TWaveRec contient la formule mathématique    f(x,y) = A*sin(1/L*r-v*t) :

// Formule et explications : http://edn.embarcadero.com/article/42012
function TWaveRec.Wave(aSum, aX, aY, aT: single): Single;
var l : single;
begin
l := P.Distance(Point3d(aX,aY,0));
Result:=aSum;
if w.Y > 0  then Result:=Result +w.x * sin (1/w.y*l-w.z*at);
end;

Exécutez le projet et allez observer les mouvements de la mer, c’est un peu mieux non ?

V - Donnons une identité à l’île

Nous allons placer un drapeau au sommet d’une montagne. Pour ce faire nous avons besoin d’un TCylinder qui fera office de mat et d’un TPlane pour le drapeau. Nous animerons celui-ci grâce à la procédure CalcMesh pour donner l’impression qu’il flotte au vent.

Plaçons un TCylinder en tant qu’enfant de mSol avec les propriétés suivantes :

- Depth : 0,2 ;

- Height : 10 ;

- HitTest : false ;

- Locked : true ;

- MaterialSource : testureEolienne ;

- Name : cDrapeau ;

- Position.X : 7 ;

- Position.Y : 28 ;

- Position.Z : 29 ;

- RotationAngle.X : 90 ;

- Width : 0,2.

Ajoutons un TtextureMaterialSource, nommons le textureDrapeau et chargeons lui la texture delphi.png dans la procédure ChargerTextures :

procedure TfPrincipale.ChargerTextures; // Chargement des textures
begin
textureMesh.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'plan.png');
textureMer.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'oceans.jpg');
textureLac.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'mer.jpg');
textureBatiment.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'batiment.jpg');
textureCoteBatiment.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'coteBatiment.png');
texturePhare.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'phare.png');
TextureCielNuit.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'cielnuit.png');
textureNuage.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'cloud1.png');
textureNuage2.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'cloud2.png');
textureNuage3.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'cloud3.png');
textureDrapeau.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'delphi.png');
maHeightMap:=TBitmap.Create;
maHeightMap.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'heightmap.jpg');
end;

Plaçons maintenant un nouveau TPlane en tant qu’enfant de cDrapeau avec les propriétés suivantes :

- Height : 3 ;

- HitTest : false ;

- Locked : true ;

- MaterialSource : textureDrapeau ;

- Name : pDrapeau ;

- Position.X : 1,5;

- Position.Y : 3 ;

- SubdivisionHeight : 6 ;

- SubdivisionWidth : 6 ;

- TwoSide : True ;

- Width : 3.

Le drapeau sera ainsi placé au sommet de la petite montagne devant le joueur et au démarrage de l’application.

Il nous reste à animer le drapeau. Pour ce faire, nous allons faire un nouvel appel à la procédure CalcMesh dans l’événement OnRender du pMer. Cet événement existe déjà : on évite ainsi de devoir générer un nouvel OnRender.

procedure TfPrincipale.pMerRender(Sender: TObject; Context: TContext3D);
begin
CalcMesh(pMer, Point3D(0,0,pMer.Position.z), Point3D(MaxMerMesh, MaxMerMesh, 0) * 0.5 + Point3D(0,0,pMer.Position.z) * center, Point3D(0.007, 0.1, 5), MaxMerMesh);  // Animation de la mer
if cbGrille.IsChecked then Context.DrawLines(TMeshHelper(pMer).Data.VertexBuffer, TMeshHelper(pMer).Data.IndexBuffer, TMaterialSource.ValidMaterial(mCouleurToitPhare),0.25);
CalcMesh(pDrapeau, Point3D(0,0,0), Point3D(pDrapeau.SubdivisionsWidth, pDrapeau.SubdivisionsHeight, 0) * 0.5 + Point3D(0,0,0) * center, Point3D(0.001, 0.9, 20), pDrapeau.SubdivisionsHeight); // Animation du drapeau
end;

Exécutez le projet et vous constaterez le drapeau devant vous.

Nous allons apporter une petite modification à la procédure pMerRender afin de gagner quelques images par secondes (FPS : Frame Per Second) :

procedure TfPrincipale.pMerRender(Sender: TObject; Context: TContext3D);
begin
TTask.Create( procedure
begin
CalcMesh(pMer, Point3D(0,0,pMer.Position.z), Point3D(MaxMerMesh, MaxMerMesh, 0) * 0.5 + Point3D(0,0,pMer.Position.z) * center, Point3D(0.007, 0.1, 5), MaxMerMesh);  // Animation de la mer
end).start;
if cbGrille.IsChecked then Context.DrawLines(TMeshHelper(pMer).Data.VertexBuffer, TMeshHelper(pMer).Data.IndexBuffer, TMaterialSource.ValidMaterial(mCouleurToitPhare),0.25);
TTask.Create( procedure
begin
CalcMesh(pDrapeau, Point3D(0,0,0), Point3D(pDrapeau.SubdivisionsWidth, pDrapeau.SubdivisionsHeight, 0) * 0.5 + Point3D(0,0,0) * center, Point3D(0.001, 0.9, 20), pDrapeau.SubdivisionsHeight); // Animation du drapeau
end).start;
end;

La modification consiste simplement à faire appel à CalcMesh via des TTask afin d’effectuer les calculs dans des threads en parallèle. Vous trouverez à la fin de ce tutoriel un tableau indiquant les FPS obtenus sur mon PC dans plusieurs configurations.

VI - Dernier petit bonus

Nous allons ajouter un dernier élément graphique. Nous allons éclairer les trois villes la nuit.

Pour cela, ajoutons quatre nouvelles source de lumière (TLight) en tant qu’enfants du TMesh mSol. Ces lumières seront de type Spot, situées au dessus des villes (la plus grande ville disposera de deux lumières pour mieux couvrir sa surface) et orientée vers le bas.

Voici les propriétés communes à ces quatre TLight :

- LightType : Spot ;

- Enabled : False ;

- RotationAngle.X : 180 ;

- Color : Goldenrod ;

- SpotExponent : 4 ;

- Position.Z : 30.

Voyons maintenant les propriétés spécifiques à chaque lumière (nom et positionnement) :

- Name : l1Ville1 ;

- Position.X : 200 ;

- Position.Y : 130.

- Name : l2Ville1 ;

- Position.X : 170 ;

- Position.Y : 150.

- Name : l1Ville2 ;

- Position.X : -157 ;

- Position.Y : 200.

- Name : l1Ville3 ;

- Position.X : -150 ;

- Position.Y : -50.

Les lumières étant placées, il nous reste à les gérer dans la procédure faniJourNuitProcess (animation qui gère le cycle jour/nuit).

procedure TfPrincipale.faniJourNuitProcess(Sender: TObject);
var
minute: integer;   // sert pour afficher l'heure dans le jeu
begin
// Initilisation de la scène à 12h : dmySoleil.RotationAngle.Z sera à 0
if (dmySoleil.RotationAngle.Z >= 0) and (dmySoleil.RotationAngle.Z < 180) then  // lorsque l'angle Z est compris dans cette plage, on ajoute 720 à minutes
minute := Round(dmySoleil.RotationAngle.Z*4) + 720  // fAniJourNuit est paramétrée pour qu'une journée dure 4 minutes (240 secondes cf fAniJourNuit.Duration)
else  // sinon, on soustrait les 720 minutes
minute := Round(dmySoleil.RotationAngle.Z*4)-720;

lblHeure.text := Format('%.2d:%.2d', [minute div 60, minute mod 60]); // Affichage de l'heure dans le jeu en fonction de la rotation du Soleil

// Aube ou crépuscule
if ((dmySoleil.RotationAngle.Z > 80) and (dmySoleil.RotationAngle.Z < 100)) or
((dmySoleil.RotationAngle.Z > 260) and (dmySoleil.RotationAngle.Z < 280)) then
begin
viewport.Color := TAlphaColors.Darkblue;   // Couleur du fond en bleu foncé
sCiel.MaterialSource := textureCielNuit;
sCiel.Opacity := 0.5;
lSoleil.Enabled := true;  // activation de la lumière du Soleil
l1Ville1.Enabled := false;
l2Ville1.Enabled := false;
l1Ville2.Enabled := false;
l1Ville3.Enabled := false;
end
else
begin
// Nuit
if (dmySoleil.RotationAngle.Z >= 100) and (dmySoleil.RotationAngle.Z <= 260) then
begin
viewport.Color := TAlphaColors.Black;
sCiel.Opacity := 1;
lSoleil.Enabled := false;
l1Ville1.Enabled := true;
l2Ville1.Enabled := true;
l1Ville2.Enabled := true;
l1Ville3.Enabled := true;
end
else
begin
viewport.Color := TAlphaColors.Cornflowerblue;
sCiel.Opacity := 1;
sCiel.MaterialSource := CouleurCielJour;
end;
end;
end;

Il suffit simplement d’allumer ces lumières la nuit et de les éteindre à l’aube.

Voici un exemple du rendu final nocturne :

VII - Affichage du nombre d’images par secondes (FPS)

Dernière chose avant de terminer cet épisode, nous allons indiquer le nombre d’images par seconde dans le titre de la fenêtre. Pour cela, déclarons une nouvelle variable nommée fps, de type integer en tant qu’attribut public de fPrincipale.

Initialisons fps à zéro dans le FormCreate :

procedure TfPrincipale.FormCreate(Sender: TObject);
begin
debut := true;
temps := 0;
fps := 0;
Center := Point3D(MaxMerMesh / pMer.Width, MaxMerMesh / pMer.Height, 0);
pMer.SubdivisionsHeight := MaxMerMesh;
pMer.SubdivisionsWidth := MaxMerMesh;
indicePhoto := 1;
sCiel.visible := false;
ChargerTextures; // Charge les différentes textures
CreerIle(MaxSolMesh);  // Création du niveau (heightmap, immeubles, arbres et autres objets
end;

Nous allons placer un TTimer sur la fiche avec les paramètres suivants :

- Interval : 1000 ;

- Name : tmFPS ;

- Enabled : true.

Générons son événement OnTimer et plaçons-y le code suivant :

procedure TfPrincipale.tmFPSTimer(Sender: TObject);
begin
fPrincipale.Caption := 'FMX Island [FPS : '+fps.ToString+']';
fps := 0;
end;

On va indiquer simplement le nombre d’images par seconde dans la barre de titre de la fenêtre. Ensuite, on réinitialise fps à zéro.

Nous allons ensuite incrémenter la variable fps dans l’événement OnPaint du TViewport3D :

procedure TfPrincipale.viewportPaint(Sender: TObject; Canvas: TCanvas; const ARect: TRectF);
begin
inherited;
inc(fps);
end;

J’ai fait quelques essais avec mon PC portable : un core i5 7200U sans carte graphique dédiée sous Windows 10. La carte graphique est intégrée au CPU et il s’agit d’une Intel HD Graphics 620. Les tests suivants ont été réalisés en activant les performances maximales au niveau du driver graphique Intel.

Voici les résultats obtenus :

Résolution

Anticrénelage

Nombre de nuages

FPS obtenu avec Tokyo (*)

FPS obtenu avec Rio (*)

800x480

4X

30

33 / 37

34 / 37

800x480

Aucun

30

36 / 40

37 / 41

1920x1080

4X

30

17 / 18

18 / 19

1920x1080

Aucun

30

28 / 30

28 / 30

800x480

4X

250

26 / 28

26 / 28

800x480

Aucun

250

27 / 30

28 / 30

1920x1080

4X

250

14 / 15

14 / 15

1920x1080

Aucun

250

22 / 23

22 / 24

(*) le premier nombre indiqué est celui obtenu sans utiliser les TTask, le second avec.

La résolution 800x480 est celle de la fenêtre au démarrage de l’application. 1920X1080 est la résolution lorsque je mets l’application en plein écran (Full HD).

L’anticrénelage est paramétrable via la propriété MultiSample du TViewport3D. Il est à FourSamples (4X) par défaut. En le désactivant, nous constatons une amélioration de la fluidité.

Autres points que j’ai remarqués :

- les résultats du tableau ont été obtenus avec la version compilée en mode DEBUG. En compilant en mode RELEASE, je ne constate pas de différence notable ;

- en diminuant le MaxMerMesh, le pMer sera constitué de mailles plus grandes : il y aura donc moins de sommets à calculer, mais les vagues seront moins précises. En passant de 50 à 10 le MaxMerMesh, je gagne 2 à 3 FPS en fonction des cas.

Dans le projet FMX Island, j’ai tenté d’être le plus simple et le plus compréhensible possible. Il y a très certainement de nombreuses optimisations à faire mais ce n’était pas l’objectif de cette série de tutoriels.

Je serai curieux de connaître les FPS obtenus avec d’autres configurations, alors je vous invite à indiquer vos résultats dans les commentaires de ce tutoriel !

VIII - Conclusion

Nous voici au terme de cette petite série de tutoriels consacrée à la 3D avec Delphi et Firemonkey. Elle a permis de fournir un petit aperçu des possibilités offertes et le tout dans un projet qui contient environ 740 lignes de code, commentaires inclus ! Le projet qui nous a servi de fil rouge tout au long de la série est largement perfectible. À l’avenir, je ferai évoluer FMX Island et vous pourrez retrouver les sources sur mon dépôt Github.

Je suis persuadé qu’il y a encore de nombreuses choses à découvrir (les shaders, par exemple). Je trouve cela passionnant et addictif !

Comme indiqué, j’espère que cette série de tutoriels va vous donner envie de jouer avec la 3D. Si c’est le cas, n’hésitez pas à me faire part de vos réalisations (des captures d’écran, par exemple).

Vous pouvez retrouver les sources de cet épisode 4 :

https://gbegreg.developpez.com/tutoriels/delphi/firemonkey/FMXIsland/episode4/src/episode4_src.zip.

IX - Remerciements

Je vous remercie d’avoir suivi cette série de tutoriels.

Merci aux relecteurs et correcteurs de ce tutoriel : BeanzMaster, SergioMaster et jacques_jean.

Enfin, je remercie l'équipe de Developpez.com pour son travail et son soutien.