TP 2 : Gestion de la physique et fonctions de mise à jour

Florian Jeanne, Rémy Frenoy, Yann Soullard, Baptiste Wojtkowski (Maj 2020 & 2021),
Yohan Bouvet (Maj 2022 & 2023), Azzeddine Benabbou (2024)

Retour sur le TP 1

Au cours du TP1, vous avez appris à créer un GameObject simple comme une sphère et à la déplacer à l’aide de la méthode AddForce(). Dans ce deuxième TD, nous allons étudier comment fonctionne la physique dans Unity, et notamment les collisions entre objets.

Update – FixedUpdate – LateUpdate

Si vous reprenez votre code, dans le script BallController, les AddForce() étaient appelées dans la méthode KeyboardMovements(), qui était elle-même appelée dans la méthode Update(). Cependant, ce n’est pas tout à faire correct. Pourquoi ? La réponse dans la suite du texte.

Une frame est une image projetée à un instant donné et pour une certaine durée. Au cinéma par exemple, les films sont projetés avec une fréquence de 24 images par secondes, ou 24 frames per second en Anglais. Dans un projet Unity, une frame est donc une capture de votre scène à un instant donné.

Update() est appelée juste avant le rendu d’une frame. Aussi, si le temps de calcul pour une frame est plus long que celui de la frame suivante, l’intervalle de temps entre les appels de la méthode Update() sera différent.

FixedUpdate() quand à elle, est appelée à intervalles de temps réguliers (toutes les 0.02 seconde soit à 50Hz), indépendamment du rendu de la frame. Cette méthode est appelée juste avant les calculs liés à la physique. Vous voyez où je veux en venir ? En effet, c’est bien dans cette méthode qu’il va falloir appeler nos méthodes agissant sur la physique des GameObjects comme AddForce().

Reprenez maintenant votre script BallController, et appelez rb.AddForce(moveValue.x, 0, moveValue.y); dans FixedUpdate() au lieu de Update().

void Update()
{
}
void FixedUpdate()
{
     rb.AddForce(moveValue.x, 0, moveValue.y);
}

La question se pose également pour la gestion de notre caméra. En effet, on modifie la position de la caméra à chaque frame en fonction de la position de notre balle. L’utilisation dans ce cas de la méthode LateUpdate() est plus pertinent.

LateUpdate() est utilisée pour des caméras de suivi, l’animation procédurale, ou lorsque l’on souhaite connaître le(s) dernier(s) état(s) d’un objet. Elle est également appelée à chaque frame, une fois que tous les éléments présents dans Update() ont été traités. Ainsi, dans notre situation, on est sûr que la balle a bien été déplacée avant d’utiliser sa position actuelle pour modifier celle de la caméra.

Récapitulons
  • Start()est appelée une seule fois à la première exécution du script qui le contient ; -FixedUpdate() est appelée à intervalles réguliers avant le moindre calcul physique ;
  • Update() est appelée avant le rendu de la frame ;
  • LateUpdate() est appelée à la suite de Update().

Bien entendu, il existe beaucoup plus de méthodes événementielles dans Unity dont l’exécution est prédéterminée. Pour votre information : Execution Order of Event Functions.

Les Prefabs

Ouvrez la scène de votre projet du TP1 en important le package que vous aviez créé. Nous allons créer des murs pour empêcher notre balle de tomber. Pour cela, créez un cube à partir des GameObject Unity (GameObject > 3D Object > Cube). Modifiez les dimensions et la position du cube pour qu’il agisse comme un mur sur l’un des bords du terrain “ground”.

Une scène avec un mur, une balle et un sol

Il nous reste donc trois murs à faire. On pourrait tout simplement refaire la même manipulation trois fois de suite. Ou alors… On pourrait utiliser ce que l’on appelle un prefab. Un prefab est un objet Unity qui conserve un GameObject quelconque, avec ses propriétés et ses composants. Il agit comme un template (ou modèle) à partir duquel vous allez pouvoir créer différentes instances dans la scène. Ainsi, si vous modifiez par la suite votre prefab en changeant la valeur de l’une de ses propriétés, cela se répercutera sur toutes les instances présentes dans le projet.

Il existe plusieurs manière de créer votre prefab. Un simple glisser/déposer depuis votre Hierarchy vers la fenêtre du projet créera automatiquement un prefab, ou alors en effectuant un clic droit dans le projet, Create -> Prefab. Dans ce cas, votre prefab sera vide. Il vous faudra effectuer un glisser/déposer de votre Hierarchy vers le prefab.

Créez donc un prefab de votre mur et générez trois instances de celui-ci par un glisser/déposer (encore et toujours) vers votre scène ou votre Hierarchy. Enfin, disposez les nouveaux murs tout autour du sol comme sur l’image ci-dessous. Vous pouvez également ajouter une texture à votre prefab. Elle s’appliquera automatiquement à toutes les instances du mur présentes sur la scène. (Vous pouvez utiliser une texture de briques de la bibliothèque de textures Pixar sous CC-BY 4, n’hésitez pas à y faire un tour pour vos projets).

Une scène contenant un mur, un sol et 4 murs

Aperçu de la hiérarchie après l’ajout des instances

Wall (1) ? Wall (2) ? Mais qu’est-ce que… ? Il va falloir réorganiser tout ça ! Cependant, il n’existe pas de “dossier” dans la Hierarchy à proprement parler, on utilise donc des GameObject vide à la place. Pour cela, GameObject > Create Empty.

Game object vide parent
Attention

Un GameObject vide possède lui aussi un composant Transform ! Veillez à le positionner correctement dans votre scène AVANT de lui attribuer ses GameObjects enfants. Dans notre cas, la position (0, 0.5, 0) sera très bien. Si vous aviez placé vos murs avec y = 0.5 pour qu’ils reposent sur le sol, vous remarquerez que maintenant pour chacun, y = 0. Le parent a une position absolue dans la scène, et maintenant ses enfants ont des positions relatives au parent.

AddForce vs Translate

Jusqu’à présent, nous déplacions notre balle à l’aide de la méthode AddForce(), qui s’applique sur le rigidbody de celle-ci. Cependant, il existe une autre méthode pour effectuer un déplacement. Celle-ci, au lieu d’appliquer des forces sur notre objet, modifie directement sa position en agissant sur son composant transform.

La méthode à appeler est Translate(). Elle s’applique sur un objet ne possédant pas de rigidbody. Créez donc un nouveau GameObject, un cube par exemple, auquel vous attachez un nouveau script CubeController. Ce script est assez similaire au BallController, sauf qu’au lieu d’appeler la fonction AddForce(), vous appelerez Translate() sur le transform pour le déplacement, et éventuellement des Rotate() si vous voulez faire des rotations. Utilisez par exemple les touches « Z », « Q », « S », « D » à la place des flèches directionnelles pour ne pas interférer avec le déplacement de la balle. Finalement, n’oubliez d’ajouter une variable speed pour controler la vitesse de déplacement de votre cube

Une fois la méthode terminée, sauvegardez. Pour ne pas qu’il y ait des collisions entre la balle et le cube, désactivez la balle. Pour cela, il faut la sélectionner, et dans l’inspector, décocher la checkbox située à côté du nom du GameObject.

Désactiver un GameObject

Désormais, votre balle n’apparaîtra plus dans la scène. Lancez donc l’application et déplacez votre cube.

Note

Les différences entre les deux méthodes sont assez significatives. Avec AddForce(), vous prenez en compte la physique de Unity. Ainsi, les collisions sont gérées et la gravité est prise en compte. Avec Translate(), vous modifiez la position de l’objet à chaque frame. Il n’y a donc pas réellement de déplacement. L’objet se “téléporte” à chaque frame.

Collisions

Si vous réactivez la balle, les collisions ont bien lieu entre celle-ci et le cube. Ceci est dû à la manière dont est gérée la physique dans Unity, et notamment les collisions.

Note

Pour qu’une collision ait lieu entre deux objets, il faut qu’ils aient tous les deux des composants Collider et qu’au moins l’un d’entre eux possède un Rigidbody

Ainsi, si l’on reprend notre balle et notre cube, les collisions sont bien gérées entre la balle et les autres éléments de la scène, puisqu’elle possède un Rigidbody et que par défaut tous les GameObjects non-vides ont un collider. Notre cube cependant, passe au travers des murs puisque ni le cube, ni les murs n’ont un Rigidbody.

Les colliders sont représentés en vert dans votre scène lorsque vous sélectionnez un gameObject. Ils peuvent avoir une forme primitive comme une sphère, une box ou une capsule. Pour des formes plus complexes, deux approches principales sont possibles :

  1. Prenons l’exemple d’une table : si vous appliquez un BoxCollider englobant la table entière, il sera impossible de faire passer une balle en dessous, car elle entrera en collision avec le collider de la table. Une solution consiste à décomposer la table en plusieurs parties — les quatre pieds et le plateau — et à assigner un collider adapté à chaque élément. Ainsi, la balle pourra passer sous la table en évitant les obstacles.
  2. La deuxième solution consiste à appliquer un MeshCollider qui viendra épouser la forme définie par le mesh (objet 3D). Cependant, cette solution peut devenir coûteuse en ressources si plusieurs GameObjects complexes sont dans une même scène.

Collider sphérique

Par défaut, Unity gère les collisions entre deux objets de manière assez simple. Cependant, il est possible de déclencher des actions spécifiques que vous aurez définies au préalable. Prenons un exemple concret : dans notre scène, une collision entre la balle et le cube pourrait entraîner la destruction du cube. Pour cela, reprenons le script de la balle BallController. Nous allons redéfinir la méthode OnCollisonEnter() qui est appelée lorsqu’il  y a une collision :

void OnCollisionEnter(Collision collideEvent)
{
    // instructions à réaliser lorsqu'il y a une collision
}

Ici, le paramètre _collideEvent_ est de type Collision. En consultant la documentation, vous constaterez qu’il est possible de récupérer l’objet avec lequel notre GameObject entre en collision via collideEvent.gameObject.

Cela est particulièrement utile, car nous pouvons ensuite utiliser la méthode Destroy() pour détruire cet objet. Cependant, nous ne souhaitons pas détruire tous les objets entrant en collision avec la balle, mais uniquement le cube.

Pour cela, avant de détruire un objet, nous vérifierons qu’il s’agit bien d’un GameObject dont le nom est "Cube". Cette vérification est possible grâce aux propriétés de la classe GameObject.

Note

Ce type de méthode est appelé automatiquement en réponse à un événement spécifique qui s’est déclenché (une collision, dans ce cas). Il n’est donc pas nécessaire de l’appeler manuellement dans votre méthode Update().

Triggers

Les Triggers sont très similaires aux collisions. En réalité, les Triggers constituent un type particulier de Colliders. Contrairement aux collisions, ils ne détectent pas d’interactions physiques entre les objets. Leur rôle est simplement de détecter lorsqu’un Collider entre dans l’espace d’un autre Collider, sans provoquer de collision ni de réaction physique, même si un RigidBody est présent sur l’un des deux objets.

Pour créer un Trigger, il suffit de cocher la case IsTrigger dans l’Inspector ou de modifier directement la propriété _IsTrigger_ d’un Collider en la définissant sur true.

Ajoutez la méthode OnTriggerEnter() à votre CubeController par exemple, et affichez le nom de l’objet qui est entré en contact avec.

void OnTriggerEnter(Collider collider)
{
    // instructions à réaliser lorsqu'il y a une collision
}

Time.DeltaTime

Maintenant que les collisions sont gérées, revenons à notre déplacement. Vous avez sûrement remarqué que si l’on augmente un peu la valeur de la variable speed, le cube se déplace très rapidement. La raison est simple. Le cube se déplace en fonction des appels d’Update(). Ainsi, si la variable speed est égale à 2, le cube se déplacera de 2m par frame. Ce qui est assez rapide : la durée d’une frame est en moyenne d’environ 17ms (16,666666666.. ms pour les plus puristes d’entre vous) pour 60fps, je vous laisse calculer le déplacement du cube en m/s pour speed = 2.

Pour connaître la durée d’une frame, il suffit d’afficher le paramètre Time.deltaTime dans la console grâce à la méthode Debug.Log() en l’appelant dans Update(). Si vous l’appelez dans FixedUpdate(), la valeur sera toujours égale à 20ms, puisqu’elle est appelée indépendamment du rendu des frames.

Mais quel est le rapport avec le déplacement du cube ?

Sachant que le cube se déplace en mètres par frame et que nous connaissons la durée d’une frame, nous pouvons ajuster notre code pour que le déplacement s’effectue en mètres par seconde. Pour cela, il suffit de multiplier notre variable de vitesse dans les appels à Translate() et Rotate() par la durée d’une frame, représentée par Time.deltaTime.

Par exemple, si speed = 2 et que Time.deltaTime = 0.017, alors avec l’appel de la méthode Translate(), le cube se déplacera de :

2 * 0.017 = 0.034 mètres par frame

Au bout d’une seconde (1 000 ms), sachant que 1 / 0.017 ≈ 58.8, soit environ 59 appels à Update(), le cube aura parcouru :

0.034 * 58.8 = 1.9992 mètres, soit environ 2 mètres par seconde (ou 7,2 km/h).

La multiplication par Time.deltaTime permet donc de garantir que la vitesse est exprimée en mètres par seconde, peu importe la durée d’une frame. De plus, cela assure que les déplacements restent visuellement constants et fluides, indépendamment du taux de rafraîchissement (frame rate) de la scène.

Un tag « Target »

On souhaite désormais ajouter des cibles sur la scène que l’on devra “manger” avec notre balle.

Commençons par ajouter un cube sur la scène. Il ne peut cependant y avoir qu’un objet nommé “Cube”, alors que l’on souhaite avoir de nombreuses cibles à détruire. Plutôt que de détruire les objets nommés “Cube”, nous allons créer un tag “Target”, et nous détruirons tous les objets ayant ce tag.

Pour cela, sélectionnez le cube, puis sélectionnez Add Tag dans la liste déroulante Tag.

Ajout d’un nouveau tag (1)

Il faut ensuite créer un nouveau tag, que vous appellerez “Target”.

Tag target)

Vous pouvez maintenant retourner dans l’Inspector du Cube et lui attribuer le tag que vous venez de créer.

Passons à présent à la modification du code de la balle, et plus précisément de la méthode _OnCollisionEnter_. L’objectif est de détruire non pas les objets portant le nom "Cube", mais ceux qui possèdent le tag "Target". Une lecture rapide des méthodes publiques d’un GameObject pourrait vous être utile pour accomplir cela.

Une fois la méthode mise à jour, relancez votre application : le cube devrait être détruit lorsqu’il entre en collision avec la balle. Désormais, nous sommes capables de détruire n’importe quel objet présent dans la scène, à condition qu’il porte le tag "Target". Plutôt sympa, non ?

Mais… pour l’instant, il n’y a qu’un pauvre cube à détruire. Peut-être que nous pourrions rendre les choses un peu plus intéressantes ?

Instanciation automatique des cubes

On souhaite avoir de nombreux cubes sur la scène, et surtout on souhaite que de nouveaux cubes apparaissent lorsque l’on en détruit.

  1. Créez un prefab “Cube” à partir de l’objet Cube, puis supprimez l’objet Cube de la scène.
  2. Créez un gameObject vide que l’on nommera CubeFactory. Ce manager sera chargé de construire des cubes à des positions aléatoires sur la scène, tout en ne dépassant pas une limite de cubes que nous fixerons à 10.
  3. Créez donc un script “CubeGenerator” et ajoutez-le à l’objet CubeFactory.

Dans ce script, nous souhaitons faire les choses suivantes :

  • Au démarrage de l’application, le script doit récupérer les bornes du plan et la taille du prefab Cube.
  • A chaque mise à jour, le script doit générer un cube s’il y en a moins de 10 sur la scène. Le cube doit être placé à une position aléatoire sur le sol. En d’autres termes, le script doit instancier le cube avec une valeur aléatoire de x (comprise entre le min et le max du x du sol) et une valeur aléatoire de z (comprise entre le min et le max du z du plan).
Note

C’est le centre du cube qui sera positionné. Par conséquent, il faudra prendre en compte aussi la taille du cube dans le calcul du x et du z.

Informations utiles :

  • Les informations concernant les bornes du sol peuvent être récupérées avec le bounds du renderer. Le Renderer d’un gameObjet étant récupérable avec la méthode GetComponent() d’un gameObject.
        groundBounds = ground.GetComponent<Renderer>().bounds;
  • Il est possible de générer une variable aléatoire comprise entre deux valeurs avec la méthode Range de la classe Random.
  • La fonction qui permet d’instancier un objet à partir d’un prefab est la fonction Instantiate. En troisième paramètre de cette fonction, utilisez Quaternion.identity).
  • Dans le script CubeGenerator, nous utiliserons une variable cubeCounter qui contiendra le nombre de cubes présents dans la scène. A chaque instanciation de cube, on incrémentera cette variable.
  • Dans CubeGenerator, nous créerons une méthode OnCubeDestroyed() qui décrémentera le compteur de Cube. Cette méthode publique sera appelée dans la méthode OnCollisionEnter() du BallController qui est responsable de la destruction des cubes (SendMessage pourrait être utilisée).
  • Prenez l’habitude d’utiliser la console pour debugger votre application. Il peut être judicieux d’écrire un message lorsque l’on détruit un cube, ainsi que lorsque l’on en crée un nouveau.
Debug.Log("Création d'un cube en (" + x + ", " + y + " , " + z + ")");

Exemple de message à afficher dans la console

Pour aller plus loin

Boule de démolition

Nous supposons à ce stade que vous avez une scene avec une balle qui permet de détruire des cubes.

Nous allons créer une boule de démolition que votre balle devra éviter. Pour cela, nous allons utiliser une sphère qui sera attachée à l’aide d’une chaîne. Comment créer une chaîne ? C’est ce que nous allons voir tout de suite. Tout d’abord, supprimez le cube de votre scène, nous n’avons plus besoin.

La boule de démolition est composée d’une sphère et de plusieurs (ici quatre) cylindres ou capsules (au choix), et d’un cube qui sert de socle. Commencez par positionner une sphère légèrement au dessus du plan, elle ne doit pas le toucher. Puis, créez un cylindre (ou une capsule évidemment) que vous positionnez de façon à ce qu’il soit légèrement enfoncé dans la sphère. Vous devrez bien évidemment modifier sa taille pour qu’il soit plus petit que la sphère. Enfin, ajoutez lui un rigidBody.

Ajoutez un composant Fixed Joint (Component > Physics > Fixed Joint) à votre sphère. Pour fonctionner, ce composant a besoin d’un autre rigidBody. Faites donc glisser le cylindre sur la variable Connected Body pour connecter la sphère et le cylindre. Les deux objets sont maintenant liés, mais nous devons également créer les autres maillons de la chaîne.

Créez donc un second cylindre. Vous pouvez dupliquer le premier à l’aide du raccourci Ctrl + D. Cette fois-ci au lieu d’avoir un Fixed Joint, nous allons utiliser un Hinge Joint sur le premier cylindre. Faites glisser le second cylindre sur la variable correspondante comme précédemment. Répétez ces opérations plusieurs fois pour avoir par exemple quatre cylindre liés entre eux et une chaîne conséquente. Ajoutez un cube à la fin de la chaîne, avec un rigidBody et liez-le au dernier cylindre. Seulement, à cause du rigidBody, notre cube tombe à cause de son poids. Néanmoins, on peut figer sa position et ses rotations dans les contraintes du rigidBody. Cochez donc toutes les cases.

Les Fixed Joint créent des liaisons fortes entre les objets (de la même manière que la hiérarchie). Les Hinge Joint permettent de créer des liaisons pivot (un seul degré de liberté). Pour plus de réalisme, vous pouvez par exemple faire un Hinge Joint sur X pour le cylindre 2, sur Y pour le cylindre 3, et à nouveau sur X sur le cylindre 4.

capsule 2

capsule 3

Votre boule de démolition est terminée. Seulement, elle ne bouge pas… On ne va pas démolir grand chose. Créez donc un nouveau script qui va nous servir à faire bouger la sphère et que l’on appellera DemolitionBallController. Cette fois-ci, on ne bougera pas la sphère manuellement. La sphère doit constamment recevoir une force pour qu’elle se déplace sans interruption. De plus, lorsqu’elle entre en collision avec notre balle, cette dernière doit retourner à sa position initiale avec une vitesse nulle. On pourrait par exemple, générer une force aléatoire à l’aide de la méthode Random.Range().

Solution du TP

  • Téléchargez et importez le package suivant : Package TP02