Débuter en réalité virtuelle avec Unity et Oculus Quest - Système de grab “from scratch” (partie 4)

Dans un projet en réalité virtuelle, comme abordé en partie 2, une partie critique est la stack d'interaction : comment gérer le fait d’attraper des objets, se déplacer/téléporter, avoir des pointeurs, interagir avec l’UI, animer les mains, …

Il est courant dans un projet de s’appuyer sur une stack existante (VRTK, XRInteraction toolkit, …) ou une stack fournie par un constructeur spécifique en accompagnement de ses couches basses de détection de casque (Sample Framework dans Oculus Integration, SteamVR Interaction System dans SteamVR, …)

Cependant, à la fois pour la curiosité, mais aussi pour bien arriver à maîtriser ces stacks, il est intéressant de faire une fois “à la main” ces composants, au moins dans leur version naïve, pour comprendre les enjeux liés au moteur Unity, et pouvoir plus facilement debugger. C’est également utile plus tard dans la courbe de progression, si on commence à vouloir s’orienter vers des choses plus fines, car ces stacks peuvent limiter vos capacités en ayant trop “en dur” certains choix (autoriser ou non d’attraper plusieurs objets à la fois dans une même main, d’attraper à 2 mains, d’attraper main déjà fermée, de permettre à la physique d’interagir avec les mains et les objets tenus, …).

Néanmoins, si cela vous semble trop compliqué/abstrait pour le moment, n’hésitez pas à ignorer cette partie complètement : le but est plus d’attiser la curiosité que d’effrayer, et il est tout à fait possible de commencer à faire une application VR sans comprendre ce qu’on aborde ici.

Choix sur l’interaction attendue

Nous allons donc voir ici comment très naïvement permettre avec juste les composants de base d’attraper en VR des objets (faire un “grab”).

Concernant les choix d'interaction, j’ai fait une sélection dans l’idée d’arriver au code le plus simple possible tout en gardant quelque chose d’utilisable réellement :

  • on veut pouvoir attraper librement un objet, pas sur une position précise
  • si on attrape un objet avec une seconde main, il change de main (et on force l’utilisateur à arrêter de fermer la première main avant qu’elle ne puisse ré-attrapper, pour éviter une bataille de main :) )
  • on ne permet donc pas d’attraper un objet à deux mains simultanément (pour le scaler, l’orienter, ou autre)
  • une main ne peut attraper qu’un objet à la fois
  • on ne tente pas de gérer avec le moteur physique les mains, ni les objets pendant qu’on les tient

Avec ces choix, on peut uniquement se concentrer sur la base de la physique d’un “grab”.

Remarque : Il y a de TRÈS nombreuses manières pertinentes de faire un grab, celle exposée ici n’est ni la plus élégante, ni le plus “puissante” (donnant le plus de possibilités), elle est vraiment essentiellement exposée à titre éducatif, n’hésitez pas à vous en éloigner fortement.

Physique d’un grab

Un grab se décompose en :

  1. détecter qu’une main (le grabber) est sur un objet attrapable (grabbable) à attraper (en hover)
  2. pendant que la main est en hover, si on appuie sur le bouton de grab, considérer le grabbable comme attrapé
  3. tant qu’il est attrappé, forcer le grabbable à suivre le grabber
  4. lorsque le bouton est lâché, déverrouiller le grabbable et arrêter le suivi

Configuration des mains

TL;DR

J’explique le pourquoi de la configuration des mains dans ce long chapitre, mais si le sujet vous effraye/ennuie, la conclusion en est simple, et directement utilisable :

  • mettre un RigidBody sur l’objet représentant la main, le mettre en mode isKinematics
  • mettre un Collider (quelconque : BoxCollider, …) sur la main, le mettre en mode isTrigger

Version longue

Unity permet facilement de gérer les collisions, mais aussi les chevauchements. Il propose de donner une forme détectable pour cela aux objets (pas nécessairement liée à leur forme graphique).

Ces colliders permettent 2 choses:

  • de détecter des collisions : 2 objets qui se touchent et vont potentiellement (suivant leur configuration) bouger de par les forces liées à ce contact, via le moteur physique d’Unity
  • de déclencher un message de chevauchement

A côté de cela, suivant que l’on veuille ou non qu’un objet soit pris en compte dans les forces gérées par le moteur physique, il est possible de mettre un composant RigidBody en plus sur un objet.

Pour finir de faire le tour de nos options, il est aussi possible de dire au Rigidbody que nous gérerons la position de l’objet par script (il ne sera plus déplacé par les collisions, les articulations, …) mais qu’il “causera” tout de même des forces aux autres : ces objets pilotés à la main ont l’option “isKinematics” sur leur RigidBody.

Nos choix soulignant que l’on ne veut pas gérer la physique, et que l’on s’intéresse à des chevauchements mais pas à des collisions, la sélection a faire semblerait être de ne mettre que des colliders, sans rigid body.

Le souci est que les callback d’Unity ne nous arriveront pas si on ne fait que cela.

Si vous voulez creuser ce sujet, une page de la documentation d’Unity explique dans quels cas les callbacks sont déclenchés, et souligne aussi la philosophie derrière cela et les différents cas que l’on a décrit avant. Dès que l’on commence à faire des choses un peu avancées avec Unity, elle est à garder dans ses bookmarks :) : https://docs.unity3d.com/Manual/CollidersOverview.html

Donc, chaque main va devoir avoir un RigidBody.

Mais, on compte la déplacer via script (lié à la position physique des contrôleurs hardware), donc il faut qu’il soit en mode isKinematics: nous sommes en contrôle de la position.

N’étant pas intéressé par déclencher des collisions (il devient difficile d’attraper des objets si on les pousse en approchant la main), nos colliders de mains seront en mode isTrigger : on ne fait que détecter les chevauchements.

Avec cette configuration des mains, on pourra détecter des objets à attraper dans différentes configurations de leur côté (avec ou sans RigidBody, avec des collider en mode isTrigger ou non).

Position relative des objets

Une fois un objet attrapé, il doit suivre la main.

Un des choix possible (et souvent adapté) aurait été de dire que quand on attrappe un objet, celui-ci se loge automatiquement au centre de la main. A partir de la, suivre la main pour le grabbable aurait juste été prendre la position et la rotation de la main.

Mais ici, on veut pouvoir attrapper naturellement un objet, donc n’importe où, sans effet de magnétisation au creux de la main, de snap.

Il faut donc enregistrer le décalage par rapport au centre de la main au moment du grab, et appliquer ensuite en permanence ce décalage lors du suivi.

Pour la position, Unity propose des méthodes pour trouver les coordonnées relatives par rapport à un référentiel d’un point en coordonnées globales :

  • pour enregistrer le décalage de position, en coordonnées relative donc : InverseTransformPoint
  • pour appliquer un décalage et obtenir de nouveau des coordonnées globales : TransformPoint

Pour la rotation, le fait qu’elle soit exprimée en Quaternion (pour résumer/simplifier : des matrices que l’on peut multiplier pour cumuler les rotations qu’elles représentent) dans Unity permet par calcul sur ceux-ci d’obtenir directement ce que l’on veut :

  • pour enregistrer le décalage de position : Rdécalage = Rréférentiel-1 x Rgrabbable
  • pour appliquer un décalage et obtenir de nouveau des coordonnées globales : Rgrabbable = Rréférentiel x Rdécalage

<optionnel>

Remarque sur le calcul :

Pour mieux les comprendre/mémoriser, il vaut mieux commencer par lire le calcul pour ré-appliquer le décalage sauvé. Rgrabbable = Rréférentiel * Rdécalage revient juste à dire qu’une rotation en absolu est obtenue en prenant la rotation d’un référentiel, et en y ajoutant la rotation relative à ce référentiel.

Le calcul pour obtenir comment sauver le décalage en découle directement. En effet, si on prend cette première expression, et qu’on multiplie à gauche chaque membre de l’égalité par Rréférentiel-1, (Rréférentiel-1 x Rréférentiel) devient la matrice identité et disparaît, nous donnant la formule finale.

</optionnel>

Code

Une fois ces 2 points durs traités, le code le plus naïf répondant à nos choix est assez simple, vous pourrez le trouver ci-dessous.

Grabber

using UnityEngine;
using UnityEngine.InputSystem;

public interface IGrabbable
{
    Grabber CurrentGrabber { get; }
    bool IsGrabbed { get; }
    void OnGrab(Grabber newGrabber);
    void OnUngrab();
}

[RequireComponent(typeof(Rigidbody))]
public class Grabber : MonoBehaviour
{
    public InputAction grabAction;
    bool isGrabbing = false;
    Rigidbody rigidBody;
    IGrabbable grabbedObject;

    void Awake()
    {
        // Input action
        grabAction.performed += OnGrabStart;
        grabAction.canceled += OnGrabEnd;
        grabAction.Enable();

        // Rigidbody settings
        rigidBody = GetComponent<Rigidbody>();
        rigidBody.isKinematic = true; // Our controller is driven by IRL inputs, and hence cannot be driven by engine physics
    }

    #region Input action callabcks
    private void OnGrabStart(InputAction.CallbackContext obj)
    {
        isGrabbing = true;
    }

    private void OnGrabEnd(InputAction.CallbackContext obj)
    {
        isGrabbing = false;
    }
    #endregion

    #region Collision handling
    private void OnTriggerStay(Collider other)
    {
        if (grabbedObject != null)
        {
            // It is already the grabbed object or another, but we don't allow shared grabbing here
            return;
        }
        // Note: in real life exemple, we should not GetComponent on each Stay, for performance issues
        // A more relevant implementation would require to store a list of hovered colliders and matching IGrabbables,
        //  detected during OnTriggerEnter
        var grabbable = other.GetComponent<Grabbable>();
        if(grabbable != null && isGrabbing)
        {
            if (grabbable.IsGrabbed)
            {
                // Force hand swap
                grabbable.OnUngrab();
            }
            grabbable.OnGrab(this);
            grabbedObject = grabbable;
        }
    }
    #endregion

    private void Update()
    {
        if(grabbedObject != null && !isGrabbing && grabbedObject.IsGrabbed)
        {
            // Object released by this hand
            grabbedObject.OnUngrab();
        }
        if(grabbedObject != null && grabbedObject.CurrentGrabber != this)
        {
            // Hand swap
            grabbedObject = null;
            // We force isGrabbing a flase to avoid this hand to catch any object again while it has not be opened and closed again
            isGrabbing = false;
        }
    }
}

Grabbable

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Grabbable : MonoBehaviour, IGrabbable
{
    Grabber grabber = null;
    Vector3 positionOffset;
    Quaternion rotationOffset;


    #region IGrabbable
    public bool IsGrabbed => grabber != null;

    public Grabber CurrentGrabber => grabber;


    public void OnGrab(Grabber newGrabber)
    {
        grabber = newGrabber;
        // Find grabbable position/rotation in grabber referential
        positionOffset = newGrabber.transform.InverseTransformPoint(transform.position);
        rotationOffset = Quaternion.Inverse(newGrabber.transform.rotation) * transform.rotation;
    }

    public void OnUngrab()
    {
        grabber = null;
    }
    #endregion

    private void Update()
    {
        if (!IsGrabbed) return;
        // Follow grabber, adding position/rotation offsets
        transform.position = grabber.transform.TransformPoint(positionOffset);
        transform.rotation = grabber.transform.rotation * rotationOffset;
    }
}

Mise en place

En repartant de ce qu’on avait partie 2, il faut ici :

  • ajouter un composant Grabber sur les objets représentant chaque main
  • cocher isKinematics sur le RigidBody qui s’est ajouté sur les mains (le script Grabber le fait aussi si vous oubliez)
  • ajouter un collider en mode isTrigger sur les mains (le plus rapide : ajouter un cube, passer en isTrigger son BoxCollider, et réduire sa taille, par exemple à 0.05/0.05/0.05)
  • créer des objets à manipuler, ayant un collider, et leur ajouter le script Grabbable

Remarque : gestion physique du grabbable une fois relâché

Si jamais le grabbable que l’on attrape est sensible aux forces “en temps normal” (quand il n’est pas tenu en main), donc avec un rigodBody et pas en mode isKinematics, il faudra penser à la mettre en isKinematics tant qu’il est tenu en main (et le remettre dans son état initial une fois relaché).

En effet, comme dans l’implémentation de suivi qu’on a fait ici, on modifie manuellement sa position, le laisser subir en même temps les forces du moteur physiques arriverait à un résultat incohérent.

Il est bien sûr possible d'implémenter un mode de suivi lui permettant de respecter sa gestion des forces physiques.

XRInteraction Toolkit, en version preview pour le moment mais réalisé par Unity, propose par exemple un mode de suivi (velocity tracking) permettant cela.

Accès aux commentaires

links

social