FMX Island épisode 3 : Diverses améliorations

Grégory Bersegeay

Après avoir créé un monde virtuel en 3D dans le premier épisode, puis avoir appris à s’y déplacer dans le deuxième, ce troisième épisode va permettre d’agrémenter notre projet de diverses améliorations.

Commentez

- Titre : FMX Island épisode 3 : Diverses améliorations

- Auteur : Grégory Bersegeay

- Parution : 14 octobre 2018

- 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 ® 2018 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 de la hauteur du point de vue

- III. Ajout d’éléments de décors

- III-A. Objets géométriques

- III-A-1. Objets simples

- III-A-2. Objets composés

- III-B. Modèle 3D

- IV. Animations

- V. Cycle jour/nuit

- V-A. Mise en place de la skybox

- V-B. Mise en place du cycle jour/nuit

- V-C. Ajout d’une lampe torche

- VI. Fonctionnalité de capture d’écran

- VII. Carte d’orientation

- VIII. Gestion des nuages

- IX. Quelques améliorations

- X. Conclusion de cet épisode et la suite

- XI. Remerciements

I - Prérequis

Les prérequis techniques sont identiques aux épisodes précédents (disponibles ici et là).

Pour ce troisième épisode, nous allons partir du projet issu du second. Vous pouvez donc au choix :

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

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

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

II - Gestion de la hauteur du point de vue

Avant de rentrer dans le vif du sujet de cet épisode, nous allons ajouter la possibilité pour le joueur de modifier la hauteur du point de vue. Pour l’heure, il n’y a pas de détection de collision : l’utilisateur sera libre de choisir la hauteur du point de vue sans se soucier du contact avec le sol. Il sera possible de passer à travers le sol.

Pour ce faire, copions le TLayout layVitesse (qui contient le TTrackBar tbVitesse) et collons la copie en tant qu’enfant du RoundRect1 qui fait office de « tableau de bord ».

Renommons le TLayout ainsi collé en layAltitude et son TTrackbar enfant en tbAltitude.

Profitons d’être sur le tbAltitude pour fixer ses propriétés :

- Max : 40 ;

- Min : -100 ;

- Value : -30.

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

procedure TfPrincipale.tbAltitudeChange(Sender: TObject);
begin
dmyJoueurOrientation.position.Y := tbAltitude.Value;
end;

Nous affectons tout simplement la valeur du tbAltitude à la propriété position.y du dmyJoueurOrientation.

Exécutez le projet et vous constaterez que vous pouvez maintenant modifier la hauteur du point de vue grâce à ce nouveau TTrackbar.

III - Ajout d’éléments de décors

Comme promis, nous allons compléter notre monde en 3D en ajoutant différents objets tels que des bâtiments, des arbres, un phare et une éolienne. Pour ce faire, plusieurs solutions s’offrent à nous.

III-A - Objets géométriques

Nous avons déjà remarqué que Delphi fournissait en standard un certain nombre de composants 3D dans la rubrique « Formes 3D ». Nous avons déjà utilisé par exemple le TPlane et le TMesh. Il y a de nombreux autres objets géométriques tels que la sphère (TSphere), le rectangle (TRectangle3D), le cylindre (TCylinder), etc.

Nos pouvons utiliser ces composants pour ajouter des éléments à notre décor.

III-A-1 - Objets simples

Par ce terme d’« objets simples », j’indique tout simplement que l’élément du décor est composé d’un seul objet géométrique.

Nous allons par exemple ajouter des immeubles sur note île afin de créer de petites villes. L’architecte étant en congés, nous ne construirons que des immeubles rectangulaires.

Nous plaçons un TRectangle3D (rubrique « Formes 3D ») sur la fiche fPrincipale, et, via la vue structure, nous le plaçons en tant qu’enfant du TMesh mSol.

En effet, nous placerons tous les éléments du décor en tant qu’enfant de mSol puisque ces éléments sont liés (l’immeuble ne bougera jamais de manière différente du sol).

Via l’inspecteur d’objets, modifions les propriétés suivantes du TRectangle3D :

- Name : modeleBatiment ;

- Depth : 3 ;

- Height : 10 ;

- HitTest : false ;

- Locked : true ;

- Position : X = 180, Y = 100 et Z = -15  (ces positions ont été déterminées par tâtonnement) ;

- RotationAngle.X : 270 ;

- Width : 5.

Comme son nom l’indique, ce TRectangle3D nous servira de modèle pour les autres immeubles. Nous verrons cela plus tard. Pour le moment, nous allons habiller notre modeleBatiment de textures afin de le rendre un peu plus réaliste.

Plaçons deux nouveaux TLightMaterial : le premier sera nommé textureBatiment et le second textureCoteBatiment.

TextureBatiment sera associé aux propriétés MaterialSource et MaterialBackSource de modeleBatiment. Nous lui associons l’image batiment.jpg :

textureCoteBatiment sera associé à la propriété MaterialShaftSource de modeleBatiment. Nous lui associons l’image coteBatiment.png :

Dans les épisodes précédents, nous avions écrit la procédure ChargerTextures qui permet de charger les textures dans les TLightMaterialSource. Nous allons donc la modifier pour y ajouter le chargement de ses deux nouvelles textures :

procedure TfPrincipale.ChargerTextures; // Chargement des textures
begin
textureMesh.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'plan.png');
textureMer.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'mer.jpg');
textureBatiment.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'batiment.jpg');
textureCoteBatiment.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'coteBatiment.png');

maHeightMap:=TBitmap.Create;
maHeightMap.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'heightmap.jpg');
end;

Nous avons utilisé des TLightMaterialSource pour les textures appliquées à modeleBatiment. Afin que ces textures apparaissent correctement, nous devons ajouter à notre scène une source de lumière. Nous allons donc placer un TLight sur notre fiche. Ce TLight n’étant que temporaire (plus tard dans cet épisode, nous ajouterons une gestion du cycle jour/nuit), vous pouvez donc le placer et l’orienter comme bon vous semble pour éclairer la scène. Nous conservons également sa propriété LightType à « Directional » afin d’obtenir une lumière où tous les rayons lumineux sont parallèles et orientés dans la même direction.

Si vous exécutez le projet à présent, une fois l’application démarrée vous verrez que devant vous, sur la droite, un immeuble apparaît. Avec les TTrackbar tbVitesse et tbAltitude et le mécanisme d’orientation vu lors du second épisode, vous pouvez vous déplacer comme bon vous semble pour vous approcher du bâtiment :

Notre immeuble esseulé fait un peu pauvre pour donner l’impression d’une ville. Toutefois, vous aurez remarqué que nous avons nommé l’objet modeleBatiment. En effet, ce TRectangle3D va en fait nous servir de modèle pour générer les autres immeubles.

Plus précisément, nous allons utiliser un TProxyObject pour générer n bâtiments qui bénéficieront ainsi du rendu graphique de notre modèle. Tous nos immeubles vont se ressembler : ils utiliseront la même texture et réagiront donc de la même manière à la lumière mais nous pourrons leur affecter des positions, des orientations et des tailles différentes.

Pour notre projet, nous allons implémenter la procédure ConstructionObjets. Elle nous permettra de créer ces TProxyObject afin de nous permettre de dupliquer facilement les objets répétitifs tels que les immeubles et les arbres. Nous étudierons le cas un peu plus tard.

procedure TfPrincipale.ConstructionObjets(position, taille : TPoint3d; typeObjet : TTypeObjet; orientation : single = 0); // création d'un batiment
var
i: TProxyObject;  // Utilisation des TProxyObject
begin
I := TProxyObject.Create(nil);   // création
mSol.AddObject(I);               // on lui affecte le TMesh comme parent
case typeObjet of
batiment: I.SourceObject:=modeleBatiment;  // on indique l'objet qui sert de modèle au TProxyObject;
end;
I.Locked:=true;                  // pour ne plus modifier l'objet en mode conception
I.HitTest:=false;                // ainsi, l'objet n'est pas sélectionnable via la souris
I.SetSize(taille.x,taille.y,taille.z); // on taille l'objet aux dimensions passées en paramètre
I.Position.Point:=Position;            // de même pour la position
i.RotationAngle.X := 90;
i.RotationAngle.y := orientation;
i.Visible := true;                     // on rend l'objet visible
end;

La procédure ConstructionObjets prend en paramètres :

- position : il s’agit d’un TPoint3D qui correspondra à la position de l’objet ;

- taille : un autre TPoint3D qui permettra de déterminer la taille de l’objet sur les trois axes (x, y et z) ;

- typeObjet : il s’agit d’un type TTypeObjet que nous allons définir de la manière suivante :

TTypeObjet = (batiment, arbre);

- orientation : il s’agit d’un paramètre facultatif qui va permettre de jouer sur l’orientation de l’objet (rotation autour de l’axe y).

Pour notre projet FMX Island, nous n’allons disposer que de deux types d’objets qui seront reproduits à loisir : les immeubles (bâtiments) et les arbres. Il est évidemment possible d’ajouter tout ce que l’on veut : d’autres modèles d’immeubles, de plantes, de véhicules…

Le code de la procédure n’est pas compliqué :

1. Nous créons un TTProxyObject ;

2. Nous affectons sa propriété SourceObject à l’objet qui lui sert de modèle en fonction de typeObjet ;

3. Nous positionnons sa propriété Locked à True et HitTest à False ;

4. Nous renseignons sa taille, sa position et son orientation ;

5. Nous le rendons visible.

La procédure ConstructionObjets étant prête, nous allons l’appeler depuis une nouvelle procédure genererObjets. Cette dernière va faire n appels à ConstructionObjets afin d’ajouter à notre île des villes (et plus tard des arbres également).

procedure TfPrincipale.genererObjets;  // Création des objets
begin
// Ville 1
ConstructionObjets(TPoint3D.Create(170,110,-15),TPoint3D.Create(4,12,2), batiment,60);
ConstructionObjets(TPoint3D.Create(210,150,-17),TPoint3D.Create(20,6,3), batiment,90);
ConstructionObjets(TPoint3D.Create(215,130,-15),TPoint3D.Create(4,8,2), batiment,90);
ConstructionObjets(TPoint3D.Create(220,120,-11),TPoint3D.Create(5,20,3), batiment,90);
ConstructionObjets(TPoint3D.Create(225,140,-17),TPoint3D.Create(20,6,3), batiment,90);
ConstructionObjets(TPoint3D.Create(200,110,-11),TPoint3D.Create(5,20,3), batiment);
ConstructionObjets(TPoint3D.Create(200,120,-17),TPoint3D.Create(4,8,2), batiment);
ConstructionObjets(TPoint3D.Create(190,140,-17),TPoint3D.Create(20,6,3), batiment,45);
ConstructionObjets(TPoint3D.Create(170,150,-10),TPoint3D.Create(5,20,3), batiment,135);
ConstructionObjets(TPoint3D.Create(190,130,-15),TPoint3D.Create(4,8,2), batiment,90);
ConstructionObjets(TPoint3D.Create(170,140,-8),TPoint3D.Create(5,20,3), batiment,90);
ConstructionObjets(TPoint3D.Create(170,175,-11),TPoint3D.Create(5,20,3), batiment);
ConstructionObjets(TPoint3D.Create(170,160,-15),TPoint3D.Create(4,8,2), batiment,90);
// Ville 2
ConstructionObjets(TPoint3D.Create(-165,200,-15),TPoint3D.Create(4,20,2), batiment,90);
ConstructionObjets(TPoint3D.Create(-155,205,-15),TPoint3D.Create(4,8,2), batiment);
ConstructionObjets(TPoint3D.Create(-150,190,-12),TPoint3D.Create(20,6,3), batiment,45);
// Ville 3
ConstructionObjets(TPoint3D.Create(-165,-62,-14),TPoint3D.Create(4,9,2), batiment,90);
ConstructionObjets(TPoint3D.Create(-160,-62,-14),TPoint3D.Create(4,9,2), batiment,90);
ConstructionObjets(TPoint3D.Create(-155,-62,-14),TPoint3D.Create(4,9,2), batiment,90);
ConstructionObjets(TPoint3D.Create(-150,-62,-13),TPoint3D.Create(4,9,2), batiment,90);
ConstructionObjets(TPoint3D.Create(-145,-62,-12),TPoint3D.Create(4,9,2), batiment,90);
ConstructionObjets(TPoint3D.Create(-155,-40,-16),TPoint3D.Create(20,6,3), batiment);
ConstructionObjets(TPoint3D.Create(-160,-50,-15),TPoint3D.Create(4,20,2), batiment,90);
ConstructionObjets(TPoint3D.Create(-155,-55,-15),TPoint3D.Create(4,20,2), batiment);
ConstructionObjets(TPoint3D.Create(-135,-45,-15),TPoint3D.Create(4,20,2), batiment);
ConstructionObjets(TPoint3D.Create(-145,-50,-15),TPoint3D.Create(4,20,2), batiment);
end;

La position de chaque bâtiment a été faite arbitrairement en testant le rendu à chaque modification de valeur afin que le bâtiment semble bien ancré dans le sol et ne paraisse pas flottant dans les airs.

Enfin, il nous reste à invoquer genererObjets à la fin de la procédure existante CreerIle :

procedure TfPrincipale.CreerIle(const nbSubdivisions: integer); // Création du niveau
var
Basic : TPlane;             // TPlane qui va servir de base
SubMap : TBitMap;           // Bitmap qui va servir pour générer le relief à partir du heightmap
Front, Back : PPoint3D;
M: TMeshData;               // informations du Mesh
G, S, W, X, Y: Integer;
zMap : Single;
C : TAlphaColorRec;         // couleur lue dans la heightmap et qui sert à déterminer la hauteur d'un sommet
bitmapData: TBitmapData;    // nécessaire pour pouvoir accéder aux pixels d'un TBitmap
begin
if nbSubdivisions < 1 then exit;  // il faut au moins une subdivision

G:=nbSubdivisions + 1;
S:= G * G;  // Nombre total de maille

try
Basic := TPlane.Create(nil);    // création du TPlane qui va servir de base à la constitution du mesh
Basic.SubdivisionsHeight := nbSubdivisions; // le TPlane sera carré et subdivisé pour le maillage (mesh)
Basic.SubdivisionsWidth := nbSubdivisions;

M:=TMeshData.create;       // création du TMesh
M.Assign(TMEshHelper(Basic).Data); // les données sont transférées du TPlane au TMesh

SubMap:=TBitmap.Create(maHeightMap.Width,maHeightMap.Height);  // création du bitmap
SubMap.Assign(maHeightMap);    // on charge la heightmap

blur(SubMap.canvas, SubMap, 8); // on floute l'image afin d'avoir des montagnes moins anguleuses

if (SubMap.Map(TMapAccess.Read, bitmapData)) then  // nécessaire pour accéder au pixel du Bitmap afin d'en récupérer la couleur
begin
try
for W := 0 to S-1 do  // parcours de tous les sommets du maillage
begin
Front := M.VertexBuffer.VerticesPtr[W];    // récupération des coordonnées du sommet (TPlane subdivisé pour rappel : on a les coordonnées en X et Y et Z est encore à 0 pour l'instant)
Back := M.VertexBuffer.VerticesPtr[W+S];   // pareil pour la face arrière
X := W mod G; // abscisse du maillage en cours de traitement
Y:=W div G; // ordonnée du maillage en cours de traitement

C:=TAlphaColorRec(CorrectColor(bitmapData.GetPixel(x,y))); // On récupère la couleur du pixel correspondant dans la heightmap
zMap := (C.R  + C.G  + C.B ) / $FF * sizemap / 25; // détermination de la hauteur du sommet en fonction de la couleur

Front^.Z := zMap; // on affecte la hauteur calculée à la face avant
Back^.Z := zMap;  // pareil pour la face arrière
end;

M.CalcTangentBinormals; // calcul de vecteurs binormaux et de tangente pour toutes les faces (permet par exemple de mieux réagir à la lumière)
mSol.SetSize(sizemap, sizemap, 50);  // préparation du TMesh
mSol.Data.Assign(M);  // on affecte les données du meshdata précédemment calculées au composant TMesh
finally
SubMap.Unmap(bitmapData);  // on libère le bitmap
end;
end;

genererObjets; // génération des objets (bâtiments, arbres, autres...)

finally
FreeAndNil(SubMap);
FreeAndNil(M);
FreeAndNil(Basic);
end;
end;

Nous avons déjà vu cette procédure CreerIle dans les épisodes précédents : la nouveauté cette fois est simplement l’appel à genererObjets.

Vous pouvez exécuter à nouveau le projet et vous promener sur l’île. À présent, vous devriez avoir trois villes.

L’éclairage des immeubles peut être différent chez vous, car il dépend de l’orientation de la source lumineuse (TLight).

Nous allons ajouter un autre élément de décor simple : le lac d’altitude. Ce dernier sera matérialisé par un TPlane que nous nommerons pLac.

Plaçons donc un TPlane en tant qu’enfant du TMesh mSol, et affectons-lui les propriétés suivantes :

- Height : 35 ;

- HitTest : false ;

- Locked : true ;

- MaterialSource : textureMer (nous lui affectons la même texture que la mer) ;

- Name : pLac ;

- Opacity : 0,5 (nous le rendons un peu transparent afin de pouvoir voir le fond du lac) ;

- Position.X : -40 ;

- Position.Y : -41 ;

- Position.Z : 5 ;

- Width : 35.

En exécutant à nouveau le projet, par rapport à la position de départ, avancez-vous un peu en tournant sur la gauche : vous arriverez au-dessus du lac.

III-A-2 - Objets composés

Ce que je nomme « objets composés », ce sont en fait des objets composés d’un ensemble de formes géométriques simples fournies en standard avec Delphi. Dans notre projet FMX Island, il y aura deux objets composés : un phare et une éolienne. Vous pouvez créer tous les objets que vous voulez, la seule limite étant votre imagination.

Le phare est l’objet le plus simple : il sera constitué d’une sphère et d’un cylindre. Plus précisément, le cylindre sera le tronc du phare et la sphère sera placée à son sommet à moitié enfoncée dans le cylindre de telle sorte que seule une demi-sphère soit visible.

Voici un petit schéma représentant l’assemblage de la sphère et du cylindre que nous allons faire :

Seuls les contours pleins seront visibles.

Nous allons habiller ces deux nouveaux objets par deux matériaux :

- un TLightMaterialSource nommé TexturePhare auquel nous associons la texture phare.png suivante :

Ajoutons le chargement de cette texture dans la procédure ChargerTextures :

texturePhare.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'phare.png');

- un TLightColorMaterialSource nommé mCouleurToitPhare dont nous positionnons la propriété Color à #FF0490F2.

Ajoutons maintenant un TCylinder en tant qu’enfant du TMesh mSol et adaptons ses propriétés :

- Name : cPhare ;

- MaterialSource : TexturePhare ;

- HitTest : false ;

- Locked : true ;

- Depth : 1 ;

- Height:4 ;

- Width : 1 ;

- Position.X : 150 ;

- Position.Y : -195 ;

- Position.Z : -14 ;

- RotationAngle.X : 90 ;

- RotationAngle.Z : 180.

Il nous reste à ajouter un TSphere en tant qu’enfant du TCylinder cPhare. Adaptons de même ses propriétés :

- Name : sPhare ;

- MaterialSource : mCouleurToitPhare;

- HitTest : false ;

- Locked : true ;

- Position.Y : -2.

Exécutez une nouvelle fois le projet. Par rapport à notre position d’origine au démarrage de l’application, le phare est situé derrière nous, au bord de la mer, sur une petite pointe.

L’éolienne est un objet composé un peu plus complexe que le phare et nous lui ajouterons même une animation pour faire tourner ses pales.

Voici le schéma de l’éolienne :

Elle sera constituée comme le phare d’un TCylinder pour son tronc, ainsi que d’un TCone perpendiculaire au TCylinder et dont la base sera à l’intérieur du cylindre et la pointe à l’extérieur. Le cône symbolisera l’axe des pales. Enfin, trois TPlane représenteront les pales de l’éolienne et seront orientées de telle sorte que l’angle entre chaque pale fasse 120°.

Nous allons également ajouter à nouveau deux composants de type TLightMaterialSource :

- nommons le premier textureEolienne et affectons la couleur #FFEAE8E8 à sa propriété Ambient et la couleur Whitesmoke à sa propriété Diffuse ;

- nommons le second couleurNoire et affectons la couleur Black à ses propriétés Ambient et Diffuse.

Ajoutons maintenant un TCylinder en tant qu’enfant du TMesh mSol et adaptons ses propriétés :

- Name : cEolienne;

- MaterialSource : TextureEolienne ;

- HitTest : false ;

- Locked : true ;

- Depth : 1 ;

- Height:3 ;

- Width : 1 ;

- Position.X : 7;

- Position.Y : -145 ;

- Position.Z : 23,5 ;

- RotationAngle.X : 90 ;

- RotationAngle.Z : 180.

Bien que cela ne soit pas obligatoire (il s’agit d’une habitude personnelle), comme l’axe des pales et les pales vont plus tard bouger ensemble, nous allons les assembler dans un TDummy.

Plaçons donc un TDummy en tant qu’enfant du cEolienne et modifions les propriétés de la manière suivante :

- Name : dmyEolienne ;

- Position.Y : -1 ;

- Position.Z : 23,5 ;

- RotationAngle.Y : 285.

Ajoutons un TCone en tant qu’enfant du dmyEolienne et modifions les propriétés suivantes :

- Name : axeEolienne ;

- MaterialSource : textureNoire ;

- HitTest : false ;

- Locked : true ;

- Depth : 0,5 ;

- Height: 0,5 ;

- Width : 0,5 ;

- Position.Z : -0,5 ;

- RotationAngle.X : 90.

Il nous reste à ajouter les trois pales. Plaçons donc trois nouveaux TPlane que nous nommerons Pale1, Pale2 et Pale3 en tant qu’enfants de axeEolienne. Ces trois TPlane auront les mêmes valeurs pour les propriétés suivantes :

- MaterialSource : textureEolienne ;

- HitTest : false ;

- Locked : true ;

- Height : 0,5 ;

- Width : 2 ;

- RotationAngle.X : 90.

Par contre, elles auront des valeurs spécifiques pour d’autres propriétés :

- pour Pale1 :

- Position.X : 0,7 ;

- Position.Y : -0,1 ;

- pour Pale2 :

- Position.X : -0,4 ;

- Position.Y : -0,1 ;

- Position.Z : -0,7 ;

- RotationAngle.Z : 240 ;

- pour Pale3 :

- Position.X : -0,4 ;

- Position.Y : -0,1 ;

- Position.Z : 0,6 ;

- RotationAngle.Z : 120 ;

Exécutez le projet. Par rapport à la position d’origine au démarrage de notre application, faites un demi-tour et vous verrez l’éolienne sur un sommet droit devant vous !

Nous animerons ses pales dans le chapitre 4 de ce tutoriel.

Avec un peu d’imagination, il est donc possible de créer ses propres objets 3D à partir des formes géométriques standards !

III-B - Modèle 3D

Jusqu’à présent, si vous avez suivi mes précédents tutoriels, vous aurez remarqué que nous n’avons utilisé que des objets géométriques simples. Même le TMesh représentant l’île est issu à l’origine d’un TPlane. Il faut savoir que le framework Firemonkey fournit également le composant TModel3D qui permet de charger des objets 3D complexes issus de logiciels de modélisation 3D. Il est ainsi possible d’utiliser des fichiers au format .ase, .dae ou .obj.

Cela permet d’utiliser des objets 3D complexes modélisés via des logiciels spécialisés (Blender par exemple est complet et gratuit).

Je suis pour ma part bien incapable de modéliser correctement le moindre objet. Par conséquent j’ai utilisé pour ce tutoriel un modèle 3D gratuit d’arbre trouvé sur Internet (https://free3d.com/3d-model/low-poly-tree-73217.html). En plus d’être gratuit, il est composé de peu de polygones : nous pouvons donc le cloner de nombreuses fois sans impact sensible sur les performances.

Les fichiers .obj et .mtl sont fournis dans le zip contenant les sources du tutoriel. Je ne rentrerai pas dans les détails des formats de fichiers, car leur gestion des données géométriques et des matériels à appliquer à l’objet nécessiterait un tutoriel spécifique. Pour un peu plus de détails, je vous renvoie vers les liens Wikipédia des formats OBJ et MTL.

Plaçons un TModel3D en tant qu’enfant de mSol et renseignons ses propriétés comme indiqué ci-après :

- Name : modeleArbre ;

- HitTest : false ;

- Locked : true ;

- Depth : 10 ;

- Height : 10 ;

- Width : 10 ;

- Position.X : -20 ;

- Position.Y : 20 ;

- Position.Z : 14 ;

- RotationAngle.X : 90.

Nous allons charger l’objet 3D en alimentant la propriété MeshCollection du TModel3D. Double-cliquez sur cette propriété dans l’inspecteur d’objets. Une boîte de dialogue vous permet de charger un fichier 3D et de l’afficher. Sélectionnez le fichier lowpolytree.obj et validez la boîte de dialogue.

L’arbre apparaît légèrement en haut à gauche de la fiche : c’est dû à son positionnement. Ne vous inquiétez pas s’il semble être dans le ciel : à l’exécution il sera bien ancré dans le sol de notre TMesh.

Vous remarquerez que quatre TLightMaterialSource ont été automatiquement ajoutés. Ils sont nommés modeleArbreMatXX où XX est un nombre. Ils sont en fait issus du fichier .mtl lié au fichier .obj. Delphi a automatiquement traduit les deux matériaux définis dans le .mtl en TLightMaterialSource.

En exécutant le projet, à partir de la position initiale, sans bouger, orientez simplement le point de vue vers la gauche et en bas et vous verrez l’arbre !

Comme précédemment pour les bâtiments, nous allons cloner ce modèle d’arbre pour en générer plusieurs à différents endroits. Nous allons simplement adapter légèrement la procédure ConstructionObjets afin qu’elle prenne en compte le type d’objet « arbre » en ajoutant uniquement la ligne qui va bien dans le case :

procedure TfPrincipale.ConstructionObjets(position, taille : TPoint3d; typeObjet : TTypeObjet; orientation : single = 0);
var
i: TProxyObject;  // utilisation des TProxyObject
begin
I := TProxyObject.Create(nil);   // création
mSol.AddObject(I);               // on lui affecte le TMesh comme parent
case typeObjet of
batiment: I.SourceObject:=modeleBatiment;  // on indique l'objet qui sert de modèle au TProxyObject;
arbre: I.SourceObject:=modeleArbre;  // on indique l'objet qui sert de modèle au TProxyObject;
end;
I.Locked:=true;                  // pour ne plus modifier l'objet en mode conception
I.HitTest:=false;                // ainsi, l'objet n'est pas sélectionnable via la souris
I.SetSize(taille.x,taille.y,taille.z); // on taille l'objet aux dimensions passées en paramètre
I.Position.Point:=Position;            // de même pour la position
i.RotationAngle.X := 90;
i.RotationAngle.y := orientation;
i.Visible := true;                     // on rend l'objet visible
end;

Ajoutons ensuite des appels à cette procédure dans la procédure genererObjets afin de rajouter quelques arbres (encore une fois, j’ai mis en gras les lignes ajoutées) :

procedure TfPrincipale.genererObjets;  // création des objets
begin
// Ville 1
ConstructionObjets(TPoint3D.Create(170,110,-15),TPoint3D.Create(4,12,2), batiment,60);
ConstructionObjets(TPoint3D.Create(210,150,-17),TPoint3D.Create(20,6,3), batiment,90);
ConstructionObjets(TPoint3D.Create(215,130,-15),TPoint3D.Create(4,8,2), batiment,90);
ConstructionObjets(TPoint3D.Create(220,120,-11),TPoint3D.Create(5,20,3), batiment,90);
ConstructionObjets(TPoint3D.Create(225,140,-17),TPoint3D.Create(20,6,3), batiment,90);
ConstructionObjets(TPoint3D.Create(200,110,-11),TPoint3D.Create(5,20,3), batiment);
ConstructionObjets(TPoint3D.Create(200,120,-17),TPoint3D.Create(4,8,2), batiment);
ConstructionObjets(TPoint3D.Create(190,140,-17),TPoint3D.Create(20,6,3), batiment,45);
ConstructionObjets(TPoint3D.Create(170,150,-10),TPoint3D.Create(5,20,3), batiment,135);
ConstructionObjets(TPoint3D.Create(190,130,-15),TPoint3D.Create(4,8,2), batiment,90);
ConstructionObjets(TPoint3D.Create(170,140,-8),TPoint3D.Create(5,20,3), batiment,90);
ConstructionObjets(TPoint3D.Create(170,175,-11),TPoint3D.Create(5,20,3), batiment);
ConstructionObjets(TPoint3D.Create(170,160,-15),TPoint3D.Create(4,8,2), batiment,90);
// Ville 2
ConstructionObjets(TPoint3D.Create(-165,200,-15),TPoint3D.Create(4,20,2), batiment,90);
ConstructionObjets(TPoint3D.Create(-155,205,-15),TPoint3D.Create(4,8,2), batiment);
ConstructionObjets(TPoint3D.Create(-150,190,-12),TPoint3D.Create(20,6,3), batiment,45);
// Ville 3
ConstructionObjets(TPoint3D.Create(-165,-62,-14),TPoint3D.Create(4,9,2), batiment,90);
ConstructionObjets(TPoint3D.Create(-160,-62,-14),TPoint3D.Create(4,9,2), batiment,90);
ConstructionObjets(TPoint3D.Create(-155,-62,-14),TPoint3D.Create(4,9,2), batiment,90);
ConstructionObjets(TPoint3D.Create(-150,-62,-13),TPoint3D.Create(4,9,2), batiment,90);
ConstructionObjets(TPoint3D.Create(-145,-62,-12),TPoint3D.Create(4,9,2), batiment,90);
ConstructionObjets(TPoint3D.Create(-155,-40,-16),TPoint3D.Create(20,6,3), batiment);
ConstructionObjets(TPoint3D.Create(-160,-50,-15),TPoint3D.Create(4,20,2), batiment,90);
ConstructionObjets(TPoint3D.Create(-155,-55,-15),TPoint3D.Create(4,20,2), batiment);
ConstructionObjets(TPoint3D.Create(-135,-45,-15),TPoint3D.Create(4,20,2), batiment);
ConstructionObjets(TPoint3D.Create(-145,-50,-15),TPoint3D.Create(4,20,2), batiment);

// chargement de quelques arbres un peu partout sur le plateau
ConstructionObjets(TPoint3D.Create(-25,18,15.5),TPoint3D.Create(10,10,10), arbre);
ConstructionObjets(TPoint3D.Create(-25,22,14.9),TPoint3D.Create(10,12,10), arbre);
ConstructionObjets(TPoint3D.Create(-24,25,14.3),TPoint3D.Create(10,12,10), arbre);
ConstructionObjets(TPoint3D.Create(-15,23,15.55),TPoint3D.Create(10,12,10), arbre);
ConstructionObjets(TPoint3D.Create(-19,28,14.4),TPoint3D.Create(10,12,10), arbre);
ConstructionObjets(TPoint3D.Create(-23,30,14.7),TPoint3D.Create(10,12,10), arbre);
end;

Je vous laisse le soin d’ajouter autant d’arbres que vous le souhaitez et le plaisir de les placer convenablement !

Voici ce que notre projet donne avec une petite forêt :

IV - Animations

Nous avons désormais généré un décor virtuel. Nous allons maintenant l’améliorer un peu plus en apportant quelques animations.

Tout d’abord, la mer est immobile, ce qui n’est pas très réaliste. Nous allons remédier à ce défaut en lui appliquant une animation qui va faire monter et descendre le niveau de la mer indéfiniment.

Plaçons un TFloatAnimation en tant qu’enfant de pMer. Affectons-lui les propriétés suivantes :

- Enabled : true ;

- AutoReverse : true ;

- Duration : 3 ;

- Interpolation : Sinusoidal ;

- Loop : true ;

- Name : aniVagues ;

- PropertyName : Position.Z ;

- StartValue : -22,5 ;

- StopValue : -22,8.

Dorénavant, le niveau de la mer va légèrement monter et descendre de manière sinusoïdale.

Comme promis, nous allons également animer notre éolienne. Comme pour la mer, nous allons placer un nouveau TFloatAnimation, mais cette fois-ci en tant qu’enfant du dmyEolienne. Voici les propriétés à renseigner :

- Enabled : true ;

- Duration : 6 ;

- Loop : true ;

- Name : aniEolienne ;

- PropertyName : RotationAngle.Z ;

- StartValue : 0 ;

- StopValue : 360.

À présent, les pales de l’éolienne tournent !

V - Cycle jour/nuit

Continuons d’améliorer le réalisme de notre petit monde virtuel : nous allons maintenant implémenter un cycle jour/nuit.

Pour ce faire, le ciel devra être bleu en journée et étoilé la nuit. Il faut donc être en mesure d’appliquer une texture au ciel. Or, nous n’avons pas d’objet représentant le ciel !

Nous allons donc englober notre monde dans une TSphere : cela sera notre skybox.

V-A - Mise en place de la skybox

Commençons par placer un nouveau TLightMaterialSource et un TColorMaterialSource. Le premier servira à afficher une texture du ciel nocturne et le second une couleur unie du ciel en journée.

Nommons le TLightMaterialSource TextureCielNuit. Appliquons ensuite la superbe texture du ciel nocturne que j’ai faite :

Ajoutons le chargement de cette texture 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+'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');
maHeightMap:=TBitmap.Create;
maHeightMap.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'heightmap.jpg');
end;

Nommons le TColorMaterialSource CouleurCielJour et affectons la valeur #FF5BD2FF à sa propriété Color.

Ajoutons maintenant un TSphere en tant qu’enfant de viewport avec les propriétés suivantes :

- Name : sCiel ;

- Depth : 950 ;

- Height : 950 ;

- HitTest : false ;

- Locked : true ;

- MaterialSource : CouleurCielJour ;

- SubdivisionsAxes : 32 ;

- SubdivisionsHeight : 32 ;

- TwoSide : true (pour que le material s’applique aussi sur la face intérieure de la sphère, car notre monde virtuel est à l’intérieur de la sphère) ;

- Width : 950.

En l’état, il y a peu de changement si on exécute le projet. On constate simplement qu’on ne voit plus les limites rectangulaires du TPlane pMer et que si on avance suffisamment pour sortir de l’intérieur de la sphère puis qu’on se retourne, on se retrouve avec une sphère légèrement plus petite que le pMer qui la « coupe » en deux.

V-B - Mise en place du cycle jour/nuit

La gestion du cycle jour/nuit va consister en trois éléments :

- faire tourner un TSphere jaune symbolisant le Soleil ;

- gérer la texture à appliquer à sCiel en fonction du jour, le crépuscule, la nuit et l’aube (nous ferons également tourner sCiel pour simuler la rotation de la Terre et ainsi assister aux levers et couchers des étoiles) ;

- gérer la source de lumière ambiante.

Pour gérer ce cycle, nous allons utiliser un nouveau TFloatAnimation. Un jour ne durera pas 24 heures réelles mais 4 minutes (valeur arbitraire). À l’initialisation de l’application, nous déterminerons qu’il est midi et que le Soleil est au zénith. Notre île virtuelle est située sur l’équateur, car le Soleil passe pile à la verticale :)

Tout d’abord, nous allons supprimer le TLight light1 que nous avions placé lors du chapitre sur l’ajout des bâtiments. De même, nous supprimons les deux TTextureMaterialSource textureMer et textureMesh. Nous les remplaçons par deux TLightMaterialSource qui auront les mêmes noms textureMer et textureMesh. Ayant supprimé les composant d’origine, il faut réaffecter textureMer à la propriété MaterialSource de pMer et de pLac, et textureMesh à la propriété MaterialSource de mSol.

Nous allons maintenant ajouter le Soleil. Pour cela, nous allons ajouter un TDummy en tant qu’enfant du dmyMonde en positionnant les propriétés suivantes :

- Name : dmySoleil ;

- HitTest : false ;

- Locked : true ;

- RotationAngle.Y : 180.

Ce composant est situé au centre de notre monde (position X=0, Y=0 et Z=0) : la sphère représentant le Soleil et la source de lumière que nous allons créer juste après vont être rattachées à ce TDummy que nous ferons tourner sur lui-même. Il doit donc être placé au centre de la rotation que nous souhaitons donner. Dans notre cas, c’est le centre de notre monde virtuel.

Plaçons un nouveau TColorMaterialSource que nous nommons couleurSoleil et affectons la couleur #FFFEDC07 à sa propriété Color. Cette couleur jaune sera la couleur de notre Soleil.

Ajoutons maintenant le TSphere, qui servira de Soleil, en tant qu’enfant de dmySoleil avec les propriétés suivantes :

- Name : sSoleil ;

- HitTest : false ;

- Locked : true ;

- Depth : 20 ;

- Height : 20 ;

- Opacity : 0,8 ;

- Position.Y : -425 (nous la plaçons loin de nous tout en restant à l’intérieur de notre skybox) ;

- Width : 20.

Cette sphère symbolisant le Soleil va être la source de lumière qui éclaire notre monde virtuel. Nous lui ajoutons donc un TLight en tant qu’enfant du sSoleil avec les propriétés suivantes :

- Color : white ;

- HitTest : false ;

- Locked : true ;

- Name: lSoleil ;

- RotationAngle.X: 270 (→ -90° afin qu’elle éclaire vers le centre du dmySoleil auquel elle appartient).

Nous laissons le type de lumière par défaut (à savoir directional), car le Soleil est tellement éloigné que tous les rayons qui nous parviennent peuvent être considérés comme parallèles.

Maintenant que nos composants visuels sont en place, il nous reste à les animer. Nous allons pour cela utiliser un TFloatAnimation que nous plaçons sur notre fiche en tant qu’enfant du dmySoleil. Affectons-lui les propriétés suivantes :

- Name : faniJourNuit ;

- Duration : 240 (un jour durera 4 minutes - 240 secondes – dans notre monde virtuel) ;

- Enabled : true ;

- Loop : true ;

- PropertyName : RotationAngle.Z ;

- StartValue : 0 ;

- StopValue : 360.

L’animation va donc faire tourner dmySoleil indéfiniment sur son axe Z et un tour complet durera 4 minutes.

En exécutant le projet, vous constaterez au départ que le Soleil se trouve au-dessus de notre position initiale et qu’il se décale vers l’horizon ouest. Plus il descend sur l’horizon Ouest, plus nous voyons les ombres se dessiner sur le relief du TMesh.

À cet instant, vous allez me dire :

- le ciel reste bleu comme en journée alors que le Soleil s’est couché : nous allons traiter ce point juste après ;

- le Soleil « tombe » dans l’océan : je sais ce n’est pas réaliste, mais il faudra s’en satisfaire pour ce tutoriel :) ;

- les bâtiments ne projettent pas d’ombre : comme Firemonkey ne dispose pas en standard de mécanisme de gestion des ombres projetées, il n’y aura pas de projection des ombres.

Nous allons maintenant améliorer un peu le passage du jour à la nuit en jouant sur la texture (le material) appliquée à notre skybox et en paramétrant une période de crépuscule et d’aube.

Pour cela, sélectionnons l’animation faniJourNuit et via l’inspecteur d’objets, générons son événement OnProcess. En plus d’effectuer la rotation du dmySoleil, nous allons effectuer des actions lors de chaque itération de l’animation.

procedure TfPrincipale.faniJourNuitProcess(Sender: TObject);
begin
// 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
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;
end
else
begin
viewport.Color := TAlphaColors.Cornflowerblue;
sCiel.Opacity := 1;
sCiel.MaterialSource := CouleurCielJour;
end;
end;
end;

En fonction de l’angle sur l’axe Z du dmySoleil, nous définissons les « zones » de jour, de crépuscule, de nuit et de l’aube. En fonction de la zone, nous modifions la couleur du viewport (nécessaire pour plus tard dans la gestion des nuages, nous y reviendrons), l’opacité du sCiel et l’activation/désactivation de la source lumineuse lSoleil.

Exécutons le projet et observons ce qu’il se passe. Le cycle jour/nuit est actif.

Il reste un petit détail : la voûte céleste ne tourne pas. Dans la réalité, c’est la Terre qui tourne sur elle-même et qui fait que les objets se « lèvent » à l’Est et se « couchent » à l’Ouest. Nous allons donc ajouter un nouveau TFloatAnimation en tant qu’enfant de sCiel et lui affecter les propriétés suivantes :

- Name : faniCiel ;

- Duration : 240 (même durée que faniJourNuit) ;

- Enabled : true ;

- Loop : true ;

- Inverse : true (pour que la rotation se fasse en décrémentant l’angle sur l’axe Z) ;

- PropertyName : RotationAngle.Z ;

- StartValue : 0 ;

- StopValue : 360.

Notre cycle jour/nuit est maintenant opérationnel, mais il manque la notion d’heure. Nous allons ajouter un TLabel dans notre IHM afin d’afficher l’heure courante du monde virtuel.

Ajoutons un TLabel en tant qu’enfant du roundRect1 présent dans le layIHM et positionnons ses propriétés :

- Name : lblHeure ;

- Align : MostLeft ;

- Hint : Heure dans le jeu ;

- HitTest : false ;

- Locked : true.

Adaptons la procédure faniJourNuitProcess pour y mettre à jour le libellé à afficher dans lblHeure :

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
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;
end
else
begin
viewport.Color := TAlphaColors.Cornflowerblue;
sCiel.Opacity := 1;
sCiel.MaterialSource := CouleurCielJour;
end;
end;
end;

Les lignes ajoutées sont en gras. Nous calculons l’heure en fonction de l’angle de rotation du dmySoleil. Ce dernier évolue de 0 à 360. À l’initialisation de l’application, il est midi et l’angle de rotation est à 0, c’est-à-dire qu’à 12h (720 minutes), l’angle est à 0. À minuit, l’angle est à 180. Par conséquent, lorsque l’angle est à 180, il faut afficher l’heure à 00:00.

Ce raisonnement explique pourquoi nous ajoutons 720 à la variable minute et nous soustrayons 720 lorsque l’angle est supérieur 180° et inférieur à 360°.

Exécutez le projet. Désormais, l’heure s’affiche à gauche dans l’interface.

V-C - Ajout d’une lampe torche

À présent qu’il peut faire nuit dans notre monde virtuel, il est utile de disposer d’une lampe torche.

Comme vous l’aurez certainement deviné, nous allons ajouter un nouveau TLight en tant qu’enfant de camera1 (étant lui-même enfant de dmyJoueur). Ainsi, la source lumineuse sera directement orientée de la même manière que la caméra (point de vue du joueur).

Affectons les propriétés suivantes du nouveau TLight :

- Color : white ;

- HitTest : false ;

- Locked : true ;

- LightType : Spot ;

- Opacity : 0,7 ;

- Name : lJoueur ;

- SpotCutOff:20 ;

- SpotExponent : 60.

Le type de lumière Spot correspond à une source lumineuse de type projecteur : la source de lumière est ponctuelle et les rayons lumineux sont « contenus » dans un cône dirigé dans une direction donnée. La source de lumière est le sommet du cône. Nous pouvons jouer sur les propriétés SpotCutOff (l’angle d’ouverture du projecteur, c’est-à-dire l’angle du sommet du cône) et SpotExponent (focalisation de la source lumineuse).

La source de lumière étant placée, il ne reste plus qu’à ajouter un bouton dans l’interface pour allumer ou éteindre la lampe.

Ajoutons un TLayout en tant qu’enfant de RoundRect1 avec les propriétés suivantes :

- Name : layOptions ;

- Align : Right ;

- HitTest : false.

layOptions contiendra plusieurs options plus tard. Nous allons donc ajouter à nouveau un TLayout, mais cette fois en tant qu’enfant de layOptions. Voici les propriétés de ce nouveau TLayout :

- Name : layLumiere ;

- Align : Top ;

- HitTest : false.

Nous n’utiliserons pas de TButton pour activer/désactiver la lampe torche, mais un TImage sur lequel l’utilisateur pourra cliquer. Ajoutons un TImage en tant qu’enfant de layLumiere et affectons-lui les propriétés suivantes :

- Name : imgLumiere ;

- Cursor : crHandPoint ;

- Align : Client ;

- MultiResBitmap : j’ai chargé l’image torche.png présente dans le zip des sources dans plusieurs résolutions.

Les images ajoutées dans le MultiResBitmap sont blanches. Pour les coloriser, nous allons ajouter un effet TFillRGBEffect en tant qu’enfant de imgLumiere. Affectons à sa propriété Color la valeur #FFAE8220. Il s’agit d’un orange foncé/marron clair.

Terminons en codant l’événement OnClick du imgLumiere :

procedure TfPrincipale.imgLumiereClick(Sender: TObject);
begin
if FillRGBEffect2.Color = $FFAE8220 then
begin
lJoueur.Enabled := true;
FillRGBEffect2.color := $FFD01414;
end
else
begin
lJoueur.Enabled := false;
FillRGBEffect2.color := $FFAE8220;
end;
end;

Pas de difficulté particulière dans cette procédure : nous nous servons de la couleur active du TFillRGBEffect pour savoir s’il faut activer ou désactiver la lampe torche. Nous modifions également la couleur du TFillRGBEffect afin que l’utilisateur se rende compte si la torche est activée ou non.

Voici un exemple de ce que donne la lampe torche lorsqu’elle est activée la nuit :

VI - Fonctionnalité de capture d’écran

L’utilisateur peut se déplacer librement sur notre île. Il pourrait trouver un coin agréable et vouloir prendre en photo un coucher de Soleil, un lac d’altitude, un point de vue qu’il trouve agréable… Il va très certainement vouloir prendre cela en photo !

Ajoutons un TLayout en tant qu’enfant de layOptions. Voici les propriétés de ce nouveau TLayout :

- Name : layCapture ;

- Align : Top ;

- HitTest : false.

Plaçons un TImage en tant qu’enfant de layCapture et affectons-lui les propriétés suivantes :

- Name : captureImageBTN ;

- Cursor : crHandPoint ;

- Align : Client ;

- MultiResBitmap : j’ai repris une image d’un appareil photo fournie dans un exemple Embarcadero.

Comme précédemment, nous allons ajouter un effet TFillRGBEffect en tant qu’enfant de captureImageBTN. Affectons à sa propriété Color la valeur #FFAE8220.

Gérons à présent le clic sur l’image :

procedure TfPrincipale.CaptureImageBTNClick(Sender: TObject);
var
b : TBitmap;
begin
b := TBitmap.Create(width, height);                        // Création du TBitmap
viewport.Context.CopyToBitmap(b,Rect(0,0,width, Height));  // permet de copier dans le TBitmap ce qui est affiché dans le viewport

if not(DirectoryExists('.'+PathDelim+'captures')) then ForceDirectories('.'+PathDelim+'captures'); // création du sous répertoire "captures" où sera enregistrée l'image

b.SaveToFile('.'+PathDelim+'captures'+PathDelim+'capture'+indicePhoto.ToString+'.png');
inc(indicePhoto);
b.free;
end;

Nous créons un TBitmap auquel nous affectons le contenu de viewport, puis nous sauvegardons le TBitmap dans le sous-répertoire captures du répertoire où est notre exécutable.

Une nouvelle variable indicePhoto est utilisée dans cette procédure. Elle servira à indicer le nom des fichiers .png que l’utilisateur va produire. Cette variable de type integer est à déclarer en tant qu’attribut public de TfPrincipale :

indicePhoto : integer;   // indice pour la sauvegarde des photos prises

Nous l’initialisons dans l’événement OnCreate de la fiche :

procedure TfPrincipale.FormCreate(Sender: TObject);
begin
indicePhoto := 1;
ChargerTextures; // charge les différentes textures
CreerIle(MaxSolMesh);  // création du niveau (heightmap, immeubles, arbres et autres objets)
end;

L’utilisateur peut maintenant prendre des photos du monde virtuel.

VII - Carte d’orientation

Notre île n’est pas grande et nous en faisons vite le tour. Toutefois, il serait intéressant de disposer d’une carte afin d’aider l’utilisateur à s’orienter.

La carte sera affichée dans notre zone interface graphique entre l’heure et les boutons que nous venons de placer. Avant d’arriver à la solution que nous allons implémenter, j’avais fait plusieurs tentatives dont il peut être intéressant de faire le tour pour que vous puissiez profiter de mes expériences.

Tout d’abord, j’ai cherché à afficher le contenu d’une caméra présente dans viewport dans un composant extérieur au viewport. Je n’y suis pas parvenu.

Lors du webinaire du 20 octobre 2018 animé par Paul Toth et consacré à la 3D, Paul a montré une solution tout à fait convaincante : nous avons un TViewport3D contenant la scène 3D et un second TViewport3D dans lequel nous souhaitons afficher cette même scène sous un angle différent. Dans le second TViewport3D, il suffit de placer un TProxyObject et de positionner sa propriété SourceObject à l’objet (un TDummy par exemple) que l’on souhaite afficher dans ce second TViewport3D.
Dans le cas du projet FMX Island, je n’ai pas réussi à mettre en place cette solution, car dès que le joueur changeait d’orientation, la carte changeait également d’orientation dans le second TViewport3D. Je n’ai pas eu le temps d’approfondir le sujet pour ce tutoriel.

J’ai ensuite ajouté un nouveau TViewport3D et j’y ai dupliqué la scène présente dans viewport, mais affichée depuis une nouvelle caméra située bien au-dessus de l’île et orientée vers le bas. Cette solution fonctionnait très bien, mais elle comportait un inconvénient de taille sur mon ordinateur dépourvu de carte graphique 3D dédiée : le nombre de polygones à gérer doublait !

J’ai donc finalement opté pour la solution suivante : utiliser une nouvelle caméra sur le viewport existant qui sera placée en hauteur au-dessus de l’île (tout en restant dans la skybox). Nous utiliserons cette caméra pour prendre une photo de l’île et cette image sera utilisée comme fond de notre carte. Le rendu est moins précis que de dupliquer la scène 3D d’un TViewport3D dans un autre (plus on s’éloigne du centre de la carte, moins la précision est grande), mais cette technique est moins coûteuse en performance.

Pour notre petite île, les défauts de précision ne sont pas trop pénalisants. En revanche, si vous faites un monde plus grand, cette technique ne donnera pas entière satisfaction.

Voilà pour la théorie, passons à la pratique.

Commençons par ajouter le cadre dans lequel nous afficherons la carte. Tout d’abord, ajoutons un TLayout en tant qu’enfant du RoundRect1 avec les propriétés suivantes :

- Name : layCarte ;

- Align : left ;

- Width : 225.

Ajoutons un cadre avec un TRectangle en tant qu’enfant du layCarte et les propriétés suivantes :

- Align : client ;

- Stroke.Color : black ;

- Stroke.Thickness : 3.

Plaçons un TViewport3D en tant qu’enfant du TRectangle que nous venons de créer, avec les propriétés :

- Name : viewportCarte ;

- Align : client.

Ajoutons un Timage en tant qu’enfant du TRectangle et chargeons dans sa propriété MultiResBitmap l’image nord.png suivante :

Plaçons l’image dans le coin supérieur gauche du viewportCarte. Cette image indiquera le Nord à l’utilisateur.

Comme les éléments 2D de l’interface sont placés, nous allons maintenant nous occuper des composants 3D de la carte qui seront dans le viewportCarte.

Ajoutons un TDummy en tant qu’enfant du viewportCarte, qui correspondra à la position du joueur dans le monde virtuel rapporté sur la carte. Nous nommons donc ce TDummy dmyPositionJoueurCarte.

Ajoutons également un TImage3D en tant qu’enfant du viewportCarte et avec les propriétés :

- Name : imgCarte ;

- Height : 550 ;

- RotationAngle.X : 270 ;

- Width : 978.

Nous chargerons dynamiquement dans sa propriété Bitmap la photo aérienne que nous prendrons de l’île.

Plaçons maintenant un TCamera en tant qu’enfant de dmyPositionJoueurCarte : cette caméra sera le point de vue du viewportCarte. Elle sera située bien au-dessus du imgCarte. Affectons-lui les propriétés suivantes :

- Position.Y : -100 ;

- Position.Z: 1 ;

- RotationAngle.X : 270.

Revenons, via l’inspecteur d’objets, sur le composant viewportCarte afin de modifier ses propriétés :

- Camera : camera2 ;

- UsingDesignCamera : false.

Pour rappel, la valeur affectée à cette dernière propriété force l’utilisation de la caméra plutôt que celle par défaut d’un TViewport3D.

Ajoutons un TCone en tant qu’enfant de dmyPositionJoueurCarte. Ce cône symbolisera la position du joueur et son orientation. Il sera situé au-dessus de imgCarte, mais bien sûr en dessous de la caméra. Voici les propriétés à renseigner :

- Name : sPositionJoueur ;

- Depth : 0,5 ;

- Height : 0,8 ;

- Position.Y : -90 ;

- Position.Z : 1 ;

- RotationAngle.X : 270 ;

- Width : 0,5.

Nous ne lui affectons pas de texture particulière, car la couleur rouge par défaut convient parfaitement.

Ajoutons un TTrackBar en tant qu’enfant du viewportCarte. Il servira à zoomer sur la carte. Affectons-lui les propriétés suivantes :

- Align : Right ;

- Name : tbZoomCarte ;

- Max : 100 ;

- Min : 11 ;

- Value : 100.

Enfin, dernier élément, nous allons ajouter une nouvelle caméra dans viewport. C’est elle qui servira à prendre la photo aérienne de l’île qui servira ensuite à afficher la carte.

Ajoutons un TCamera en tant qu’enfant du dmyMonde et affectons-lui les propriétés suivantes :

- Position.Y : -630 ;

- Position.Z : -5 ;

- RotationAngle.X : 270.

Modifions la propriété Camera de viewport en sélectionnant cette nouvelle caméra (camera3). Après avoir pris la photo aérienne, nous réaffecterons dynamiquement camera1 à la propriété camera de viewport.

Ouf ! Tous les composants sont installés. Il nous reste à coder :

- l’initialisation de la carte ;

- la gestion de la synchronisation de la position du joueur dans le monde virtuel et le pointeur représentant le joueur sur la carte ;

- la gestion du zoom de la carte.

Commençons par l’initialisation de la carte. Pour cela, nous allons créer la procédure CreerPlan :

procedure TfPrincipale.CreerPlan; // permet de créer le plan (la carte)
var
b : TBitmap;
begin
b := TBitmap.Create(round(viewport.width), round(viewport.height));
viewport.Context.CopyToBitmap(b,Rect(0,0,round(viewport.Width), round(viewport.Height)));

viewport.Camera := camera1;
sSoleil.Visible := true;
dmySoleil.visible := true;
sCiel.Visible := true;
faniCiel.Start;
imgCarte.Bitmap.Assign(b);
b.Free;
end;

Nous créons en mémoire un TBitmap à partir du contexte de viewport puis nous assignons ce TBitmap à la propriété Bitmap de imgCarte. Nous repositionnons camera1 en tant que point de vue de viewport, et nous rendons visibles la skybox et le Soleil.

En effet, afin de parvenir à prendre la photo aérienne de l’île entière, la caméra est placée au-dessus de notre skybox. Via l’inspecteur d’objets, mettons à false les propriétés visible des objets sCiel, sSoleil et dmySoleil.

Attention :
Lorsque nous modifions la visibilité d’une forme 3D via l’inspecteur d’objets, cette modification ne semble valable qu’à la conception. Si nous sélectionnons à nouveau la forme, sa propriété visible est systématiquement remise à true. Petit bug ou comportement voulu ? En tout cas, je trouve cela assez pénible.
J’ai donc forcé la valeur de cette propriété dans l’événement OnCreate de la fiche :

sCiel.visible:= false ;

J’ai remarqué qu’en positionnant ces propriétés à false via le code dans la procédure CreerPlan avant d’invoquer CopyToBitmap, ça ne fonctionnait pas, car le bitmap généré correspondait au point de vue de camera1…

Maintenant, il faut invoquer la procédure CreerPlan une seule fois au démarrage de l’application, mais malgré tout une fois l’île générée. Nous allons donc l’appeler dans l’événement OnProcess de faniPrincipale qui devient :

procedure TfPrincipale.faniPrincipaleProcess(Sender: TObject);
var
P: TPoint3D; // point en 3D qui sera la position du joueur
begin
if debut then
begin
CreerPlan; // création de la carte
debut := false;
end;

P := dmyJoueurOrientation.Position.Point + direction * tbVitesse.value;
dmyJoueurOrientation.Position.Point:=P;
end;

La variable debut est un boolean déclaré dans les déclarations publiques de la classe TfPrincipale :

debut : boolean;

Elle est initialisée dans l’événement FormCreate qui devient maintenant :

procedure TfPrincipale.FormCreate(Sender: TObject);
begin
debut := true;
indicePhoto := 1;
ChargerTextures; // charge les différentes textures
CreerIle(MaxSolMesh);  // création du niveau (heightmap, immeubles, arbres et autres objets
end;

La carte est initialisée. Nous allons à présent la mettre à jour en fonction des déplacements du joueur (déplacement de la carte) et de son orientation (orientation du TCone représentant le joueur).

Pour prendre en compte les déplacements du joueur, nous allons compléter l’événement OnProcess de faniPrincipale qui devient :

procedure TfPrincipale.faniPrincipaleProcess(Sender: TObject);
var
P: TPoint3D; // point en 3D qui sera la position du joueur
begin
if debut then
begin
CreerPlan; // création de la carte
debut := false;
end;

P := dmyJoueurOrientation.Position.Point + direction * tbVitesse.value;
dmyJoueurOrientation.Position.Point:=P;
P.Y := - 50;  // on place le dummy indiquant la position du joueur sur la carte au-dessus
dmyPositionJoueurCarte.Position.Point :=P; // mise à jour du TCone représentant la position du curseur sur la carte
end;

Nous déplaçons dmyPositionJoueurCarte en même temps que la position du joueur évolue. La seule différence est que le marqueur du joueur sur la carte doit être au-dessus de l’image de la carte, d’où le fait que nous forcions à -50 la propriété P.Y (la hauteur de la nouvelle position du joueur).

Il nous reste maintenant à orienter le TCone en fonction de la direction dans laquelle observe le joueur. Pour cela, il nous faut modifier la procédure SetAngleDeVue qui devient :

procedure TfPrincipale.SetAngleDeVue(const Value: TPointF);
var
ptA,ptD,S : TPointF; // ptA point d'arrivé, ptD point de départ, S la sensibilité
begin
S.X := 180 / Viewport.Width; // réglage de la sensibilité pour l'orientation droite/gauche
S.Y := 180 / Viewport.Height;// réglage de la sensibilité pour l'orientation haut/bas
ptA := Value * S;            // point d'arrivée adapté à la sensibilité
ptD := posDepartCurseur * S; // point de départ adapté à la sensibilité
// vue droite/gauche
with dmyJoueurOrientation.RotationAngle do
begin
y:= y + (ptA.X - ptD.X); // orientation droite/gauche (axe y) en fonction du déplacement de la souris en X
sPositionJoueur.RotationAngle.Z := y; // orientation du cône représentant la position du joueur sur la carte
end;
// Vue Haut/Bas
with dmyJoueur.RotationAngle do x:= x + (ptD.Y - ptA.Y); // de même pour l'orientation haut/bas en adaptant (rotation sur l'axe x, en fonction du déplacement de la souris en Y)
posDepartCurseur := Value;   // la position du curseur lorsque l'utilisateur a cliqué (l'origine de la direction) est mise à jour avec la nouvelle position du curseur : au prochain appel de OnMouseMove, la position de départ doit être la position d'arrivée du coup précédent)
end;

Nous affectons la même direction à sPositionJoueur que la nouvelle orientation droite/gauche calculée pour le joueur.

En exécutant le projet une nouvelle fois, nous avons désormais une carte de l’île, un repère de la position du joueur. Le tout est dynamique et s’adapte aux déplacements du joueur.

Il nous reste une petite chose à faire : la gestion du zoom sur la carte. Pour gérer cette fonctionnalité, générons l’événement OnTracking de tbZoomCarte et plaçons-y le code suivant :

procedure TfPrincipale.tbZoomCarteTracking(Sender: TObject);
begin
Camera2.Position.Y := - tbZoomCarte.Value;
sPositionJoueur.Position.Y := Camera2.Position.Y + 10;
end;

Nous jouons donc sur la hauteur de la caméra par rapport à la valeur de tbZoomCarte tout en nous assurant que le TCone symbolisant la position du joueur soit toujours à la même distance de la caméra.

Exécutons le projet et notre carte est pleinement fonctionnelle !

VIII - Gestion des nuages

Nous arrivons au dernier élément graphique de ce tutoriel. Toujours dans le but d’améliorer le rendu, nous allons ajouter des nuages dans le ciel.

La technique que nous allons implémenter consiste à représenter les nuages par des TPlane orientés parallèlement au sol, associés à des textures de nuage et se déplaçant dans le ciel à une altitude variable. Nous allons donc créer dynamiquement un certain nombre de TPlane avec beaucoup d’aléatoire, notamment :

- leur positionnement ;

- leur altitude ;

- leur orientation ;

- leur opacité ;

- le choix de la texture de nuage ;

- leur taille.

Ces TPlane se déplaceront d’Ouest en Est à une vitesse dépendante de leur altitude : plus le nuage sera haut, moins sa vitesse sera élevée.

Commençons par placer trois nouveaux TLightMaterialSource qui contiendront trois textures de nuage différentes (cloud1.png, cloud2.png et cloud3.png). Modifions la procédure ChargerTextures comme suit :

procedure TfPrincipale.ChargerTextures; // chargement des textures
begin
textureMesh.Texture.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'plan.png');
textureMer.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;
maHeightMap.LoadFromFile('.'+PathDelim+'textures'+PathDelim+'heightmap.jpg');
end;

Nous allons permettre à l’utilisateur de définir le nombre maximum de nuages. Plaçons un nouveau TLayout en tant qu’enfant du layOptions et positionnons ses propriétés :

- Name : layNuages ;

- Align : Top ;

- Height : 24.

Ajoutons un TImage en tant qu’enfant de layNuages et affectons-lui les propriétés suivantes :

- Name : imgNuages ;

- Cursor : crHandPoint ;

- Align : Left ;

- Width : 50 ;

- MultiResBitmap: chargeons l’image nuage.png.

Comme pour les autres TImage, nous ajoutons un effet TFillRGBEffect en tant qu’enfant de imgNuages. Affectons à sa propriété Color la valeur #FFAE8220.

Enfin, ajoutons un TTrackBar en tant qu’enfant de layNuages avec les propriétés :

- Name : tbNuages ;

- Align : Client ;

- Max : 500 (cette limite est arbitraire) ;

- Value : 100 (au démarrage de l’application, nous générerons 100 TPlane, donc 100 nuages).

Les composants de l’interface graphique sont prêts.

Revenons maintenant sur le viewport et ajoutons-lui un TDummy en tant qu’enfant. Il sera le composant parent des TPlane que nous générerons dynamiquement. Modifions la propriété Name du nouveau TDummy en dmyNuages.

Passons maintenant au codage de la gestion des nuages. Pour cela, j’ai repris ce que j’avais fait dans une petite démonstration de feu de camp présente sur le site Developpez.

Nous allons créer la procédure genererNuages et placer le code suivant :

procedure TfPrincipale.genererNuages;
var
s:TPlane;      // pour création des TPlane
P:TFmxObject;  // va servir d'itérateur pour parcourir tous les objets enfants du dmyNuages
taille : integer;
begin
if dmyNuages.ChildrenCount-1 < tbNuages.Value then // création des TPlane pour les nuages
begin
s:=TPlane.Create(nil);
s.parent := dmyNuages; // le parent du TPlane sera dmyNuages
taille := random(500); // taille aléatoire de chaque nuage
case random(3) mod 3 of    // affectation aléatoire d'une des 3 textures de nuage disponibles
0 : begin
s.MaterialSource:=textureNuage2;
s.SetSize(taille,taille/2,0.001);
end;
1 : begin
s.MaterialSource:=textureNuage;       // on lui affecte la texture
s.SetSize(taille,taille/3,0.001);
end;
2 : begin
s.MaterialSource:=textureNuage3;       // on lui affecte la texture
s.SetSize(taille,taille/1.5,0.001);
end;
end;

s.TwoSide := true;   // pour que la texture s'applique des deux côtés du TPlane
s.RotationAngle.X := 90;  // pour orienter les TPlanes parallèlement au sol
s.Opacity := random;   // opacité aléatoire pour améliorer le rendu
s.Opaque := false;
s.ZWrite := false;     // pour éviter que le rectangle "cadre" du TPlane soit visible => mais du coup la profondeur n'est plus gérée : le Soleil passe devant les nuages...
s.HitTest := false;    // pour ne pas pouvoir cliquer dessus
s.Position.Point:=Point3D(random*2000-1000,-100*random-50,random*1000-500);  // on positionne le nuage arbitrairement et aléatoirement partout au-dessus de notre monde
s.RotationAngle.Z := random * 360; // orientation aléatoire du nuage
end;

for P in dmyNuages.Children do // parcours des objets enfants du dmyNuages
begin
if P is TPlane then // si l'objet est un TPlane
begin
s := TPlane(P);   // on va travailler sur ce TPlane
s.position.x := s.position.x + 50 / ( -s.Position.Y); // on le décale sur l'axe X (d'ouest en est) en fonction de son altitude (les nuages les plus bas se déplaceront plus rapidement que ceux d'altitude)
if s.position.x > 1000  then      // si la position en X du nuage > 1000, alors on repositionne le nuage à la position x = -1000 et Y et Z valeurs aléatoires
s.Position.point := Point3D(-1000,-100*random-50,random*1000-500);
end;
end;
end;

Le code de cette procédure est commenté, mais voici quelques explications supplémentaires.

Dans cette procédure, nous créons n TPlane (n étant la valeur du tbNuages). Nous les taillons, orientons et plaçons de manière aléatoire. Ils sont tous enfants du dmyNuages.

Ensuite, nous parcourons tous les enfants de dmyNuages pour les déplacer sur l’axe X en tenant compte de l’altitude du TPlane afin que les nuages les plus hauts se déplacent moins vite que les nuages plus bas.

Une fois les nuages arrivés à la position +1000 sur l’axe X (que nous considérons suffisamment loin sur l’horizon Est), nous le repositionnons aléatoirement sur les axes Y et Z mais à -1000 sur l’axe X, c’est-à-dire suffisamment loin sur l’horizon Ouest.

L’appel à cette procédure va se faire dans l’événement OnRender du dmyNuages. Générons cet événement et plaçons-y le code suivant :

procedure TfPrincipale.dmyNuagesRender(Sender: TObject; Context: TContext3D);
begin
genererNuages;
end;

Ce qui est intéressant ici, c’est que les TPlane ont une gestion de la transparence « curieuse ». En effet, l’image du nuage au format png dispose du canal de transparence. Or, cette partie transparente de l’image va prendre la couleur du fond du TViewport3D parent… Cela donne un effet indésirable du rendu bien visible lorsque deux TPlane sont superposés.
La seule solution de contournement que j’ai trouvée est de positionner à false la propriété ZWrite des TPlane. Cela entraîne un autre effet : les nuages sont visibles même lorsqu’ils sont à l’extérieur de la skybox et le Soleil semble passer devant les nuages… On s’en contentera !
Si vous souhaitez voir cet effet indésirable, commentez la ligne dans la procédure genererNuages :

s.ZWrite := false;

Exécutez le projet et vous aurez maintenant des nuages dans le ciel. Les nuages vont s’afficher au fur et à mesure au démarrage de l’application. Une fois que nous aurons atteint la limite (propriété value du tbNuages), il n’y aura plus ce scintillement dû à la création des TPlane.

IX - Quelques améliorations

Nous arrivons au terme de cet épisode. Nous allons terminer avec quelques petites améliorations diverses.

Tout d’abord, vous aurez peut-être remarqué que certaines interactions avec des composants de l’interface font que les animations paraissent saccadées. Pour remédier à cela, créons la procédure interactionIHM comme suit :

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

Nous allons ensuite invoquer cette procédure dans les événements qui provoquent les ralentissements.

Générons l’événement OnTracking du tbVitesse et plaçons-y le code suivant :

procedure TfPrincipale.tbVitesseTracking(Sender: TObject);
begin
interactionIHM;
end;

Sélectionnons le tbAltitude et via l’inspecteur d’objets, affectons à son événement OnTracking le tbVitesseTracking. Il faut faire de même avec le tbNuages. Pour le tbZoomCarte, nous avons déjà un événement OnTracking. Il suffit alors de lui ajouter l’appel à la procédure interactionIHM :

procedure TfPrincipale.tbZoomCarteTracking(Sender: TObject);
begin
Camera2.Position.Y := - tbZoomCarte.Value;
sPositionJoueur.Position.Y := Camera2.Position.Y + 10;
interactionIHM;
end;

Enfin, nous ferons un appel à interactionIHM également dans l’événement OnMouseMove du viewport :

procedure TfPrincipale.viewportMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Single);
begin
if ssLeft in shift then angleDeVue := PointF(X,Y);
interactionIHM;
end;

Autre amélioration pour le confort de l’utilisateur, nous allons l’autoriser à utiliser les flèches du clavier pour se déplacer et la touche ECHAP pour arrêter « brutalement » le mouvement.

Générons l’événement FormKeyDown de la fiche et plaçons-y le code suivant :

procedure TfPrincipale.FormKeyDown(Sender: TObject; var Key: Word; var KeyChar: Char; Shift: TShiftState);
begin
if key = vkup then tbVitesse.Value := tbVitesse.Value - tbVitesse.Frequency;  // la flèche Haut permet d'avancer
if key = vkdown then tbVitesse.Value := tbVitesse.Value + tbVitesse.Frequency;// la flèche Bas permet de reculer
if key = vkEscape then tbVitesse.Value := 0; // Echap permet de s'arrêter
if key = vkLeft then dmyJoueurOrientation.RotationAngle.y:= dmyJoueurOrientation.RotationAngle.y - 1;  // orientation droite/gauche (axe y) en fonction du déplacement de la souris en X
if key = vkRight then  dmyJoueurOrientation.RotationAngle.y:= dmyJoueurOrientation.RotationAngle.y + 1; // orientation droite/gauche (axe y) en fonction du déplacement de la souris en X
sPositionJoueur.RotationAngle.Z := dmyJoueurOrientation.RotationAngle.y; // orientation du cône représentant la position du joueur sur la carte
end;

Nous allons offrir une dernière fonctionnalité à l’utilisateur. Nous avons déjà vu cela lors de l’épisode 1 : l’utilisateur va pouvoir afficher ou non le dessin du maillage du TMesh mSol.

Pour obtenir cet effet, nous ajoutons un TChekBox en tant qu’enfant du layOptions et lui affectons les propriétés suivantes :

- Name : cbGrille ;

- Align : bottom.

Générons maintenant l’événement OnRender du mSol et plaçons-y le code suivant :

procedure TfPrincipale.mSolRender(Sender: TObject; Context: TContext3D);
begin
// permet de tracer le maillage du TMesh
if cbGrille.IsChecked then Context.DrawLines(mSol.Data.VertexBuffer, mSol.Data.IndexBuffer, TMaterialSource.ValidMaterial(couleurMaillage),0.25);
end;

Nous dessinons ainsi le maillage de mSol lorsque cbGrille est cochée.

Pour terminer cet épisode, nous allons finaliser notre interface en lui appliquant une texture en fond et un contour en dégradé avant d’appliquer un thème sombre à notre application.

Sélectionnons le RoundRect1 et chargeons l’image ihm.bmp dans sa propriété Fill.Bitmap.Bitmap. Positionnons ses propriétés Fill.Bitmap.WrapMode à TileStretch et Fill.Kind à Bitmap. Ensuite, positionnons ses propriétés Stroke.Kind à Gradient et Stroke.Gradient avec les couleurs de votre choix (il s’agit des couleurs de départ et de fin du dégradé). J’ai choisi du noir à un bleu foncé.

Pour ajouter un thème, il nous suffit d’ajouter un TStyleBook sur notre fiche et de faire référence à ce TStyleBook, via l’inspecteur d’objets, dans la propriété StyleBook de la fiche.

Double-cliquez sur le composant StyleBook1 et choisissez le style que vous souhaitez. J’ai choisi le thème Dark.

Exécutez le projet et admirez le résultat !

X - Conclusion de cet épisode et la suite

Ce troisième épisode a permis d’améliorer notre monde virtuel en le complétant à l’aide d’objets et d’animation afin de le rendre plus réaliste. La seule limite devient votre imagination. J’espère que cette série de tutoriels va vous donner envie de jouer avec la 3D. Je serais d’ailleurs ravi de voir vos productions.

Je vous donne rendez-vous dans le prochain épisode dans lequel nous aborderons la détection de collision afin de ne plus passer à travers le décor ou les bâtiments !

Vous pouvez retrouver les sources de cet épisode 3 :

https://gbegreg.developpez.com/tutoriels/delphi/firemonkey/FMXIsland/episode3/src/episode3_src.zip

XI - Remerciements

Je vous remercie d’avoir suivi cet épisode. J’espère qu’il vous a plu.

Merci aux relecteurs et correcteurs de ce tutoriel : SergioMaster, GVasseur58 et f-leb

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