Aller au contenu principal

Retour de force dans une scène dynamique : tutoriel

Dans le prolongement du tutoriel d'initiation au retour de force, ce guide explique comment simuler des interactions dynamiques dans Unity afin que les utilisateurs puissent ressentir le retour de force émis par un objet en mouvement. Ce scénario montre clairement que le retour haptique nécessite des mises à jour à haute fréquence, qui dépassent largement les fréquences de rafraîchissement habituelles pour l'affichage visuel dans Unity.

Introduction​

Pour offrir une expérience haptique convaincante, en particulier dans les scènes dynamiques, il est essentiel d'effectuer des calculs à des fréquences supérieures à 1 kHz. Cela contraste fortement avec la boucle de mise à jour habituelle des jeux, qui fonctionne à environ 60 Hz. Le défi consiste à gérer ces mises à jour à haute fréquence en parallèle de la boucle principale du jeu et à garantir un échange de données sécurisé entre les threads, afin d'assurer un retour de force cohérent et précis.

Extension de la configuration de base du retour de force​

Commencez par la mise en place de la scène à partir du Tutoriel d'initiation au retour de force. Pour intégrer un comportement dynamique, nous adaptons le script SphereForceFeedback de manière à ce qu'il réagisse au mouvement de la bille et simule des interactions avec un objet en mouvement dynamique.

Modifications importantes​

  • Mouvement de l'objet dynamique: intégrez une logique qui met à jour la position et la vitesse de la balle en fonction des entrées de l'utilisateur ou de schémas de mouvement prédéfinis.
  • Échange de données sécurisé par fil: Utilisez un ReaderWriterLockSlim, afin de gérer l'accès simultané aux données partagées entre le thread principal et le thread haptique.
  • Ajustements du calcul des forces: Adaptez la méthode SphereForceFeedback.ForceCalculation de manière à ce que la vitesse de la balle soit prise en compte et qu'un retour d'information réaliste soit généré en fonction de sa position et de son mouvement.

Interaction dynamique​

Pour simuler le mouvement de la bille, vous pouvez soit définir manuellement sa position dans la méthode Update mettre à jour ou utiliser un composant distinct qui contrôle ses mouvements à l'aide de commandes clavier ou d'autres interactions. Dans cet exemple, nous nommons le GameObject Sphère dans Balle en mouvement et ajoutons celles de l'exemple Tutoriels composant contenu MovingObject hinzu.

Adaptation de ForceCalculation pour le mouvement​

Le calcul du retour de force doit désormais tenir compte de la vitesse de la bille en mouvement et adapter la force en fonction à la fois de la position et de la vitesse de l'interaction. Il en résulte une perception haptique plus nuancée et plus réaliste, qui reflète le caractère dynamique de l'interaction.

  • Ajoutez un paramètre à la méthode Vector3 otherVelocity hinzu
  • Remplacez force -= cursorVelocity * damping par force -= (cursorVelocity - otherVelocity) * damping
private Vector3 ForceCalculation(Vector3 cursorPosition, Vector3 cursorVelocity, float cursorRadius,
Vector3 otherPosition, Vector3 otherVelocity, float otherRadius)
{
var force = Vector3.zero;

var distanceVector = cursorPosition - otherPosition;
var distance = distanceVector.magnitude;
var penetration = otherRadius + cursorRadius - distance;

if (penetration > 0)
{
// Normalize the distance vector to get the direction of the force
var normal = distanceVector.normalized;

// Calculate the force based on penetration
force = normal * penetration * stiffness;

// Calculate the relative velocity
var relativeVelocity = cursorVelocity - otherVelocity;

// Apply damping based on the relative velocity
force -= relativeVelocity * damping;
}

return force;
}

Échange de données sécurisé au niveau des threads​

Dans les scènes dynamiques où des objets se déplacent et interagissent entre eux en temps réel, il est essentiel que les calculs du retour haptique reposent sur les données les plus récentes, sans que les accès simultanés ne provoquent de corruption des données. C'est là que l'échange de données sécurisé entre threads devient indispensable.

Principes fondamentaux de l'échange de données sécurisé entre threads​

  • Mécanismes résistants à la torsion: Utilisez ReaderWriterLockSlim, afin de gérer les accès simultanés aux données. Cela permet d'effectuer plusieurs opérations de lecture ou une seule opération d'écriture, garantissant ainsi l'intégrité des données.
  • Lecture et écriture de données:
    • Lecture: le thread haptique lit les positions et les vitesses des objets sous un verrou de lecture, sans affecter les mises à jour des données.
    • Écriture: les mises à jour des données d'objet effectuées par le thread principal se font sous un verrou d'écriture. Cela permet d'empêcher les opérations de lecture ou d'écriture simultanées qui pourraient entraîner des incohérences dans l'état des données.

Mise en œuvre dans Unity​

  • Structure pour les données de scène: afin de permettre des opérations sans risque de conflit entre threads, nous définissons une structure contenant toutes les données nécessaires relatives à la scène. Cette structure comprend la position et la vitesse de la « Moving Ball » et du curseur, ainsi que leurs rayons. Cette structure de données constitue la base de notre échange de données sans risque de conflit entre threads.

    private struct SceneData
    {
    public Vector3 ballPosition;
    public Vector3 ballVelocity;
    public float ballRadius;
    public float cursorRadius;
    }

    private SceneData _cachedSceneData;
  • Initialisation du verrouillage: Une instance de ReaderWriterLockSlim est initialisé afin de gérer l'accès aux données de scène. Ce verrou permet à plusieurs threads de lire les données simultanément, ou verrouille les données de manière exclusive pour un seul thread en vue d'une écriture. Cela garantit ainsi l'intégrité des données lors d'opérations simultanées.

    private ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
  • Écriture dans le cache avec un verrou d'écriture: La méthode SaveSceneData met à jour les données de la scène sous verrou d'écriture. Cela garantit qu'aucun autre thread ne puisse lire ou écrire les données pendant qu'un thread les met à jour. Cela permet d'éviter les conflits d'accès (data races) et d'assurer la cohérence.

    private void SaveSceneData()
    {
    _cacheLock.EnterWriteLock();
    try
    {
    var t = transform;
    _cachedSceneData.ballPosition = t.position;
    _cachedSceneData.ballRadius = t.lossyScale.x / 2f;

    _cachedSceneData.cursorRadius = inverse3.Cursor.Radius;

    _cachedSceneData.ballVelocity = _movableObject.Velocity;
    }
    finally
    {
    _cacheLock.ExitWriteLock();
    }
    }
  • Lecture à partir du cache avec un verrou de lecture: La méthode GetSceneData récupère les données de scène sous un verrou de lecture. Cela permet à plusieurs threads de lire les données simultanément et en toute sécurité, sans perturber les opérations d'écriture. On garantit ainsi que les calculs du retour haptique reposent sur les données de scène les plus récentes.

    private SceneData GetSceneData()
    {
    _cacheLock.EnterReadLock();
    try
    {
    return _cachedSceneData;
    }
    finally
    {
    _cacheLock.ExitReadLock();
    }
    }
  • Mise à jour des données dans le thread principal: La méthode FixedUpdate est utilisé pour mettre à jour périodiquement les données de la scène dans le thread principal. Les calculs du retour haptique ont ainsi accès aux données les plus récentes, ce qui reflète le caractère dynamique de la scène.

    private void FixedUpdate()
    {
    SaveSceneData();
    }
  • Utilisation du calcul de force avec des données mises à jour: Je suis en attente de rappel OnDeviceStateChanged Les calculs de force sont effectués à partir des données de scène les plus récentes, récupérées via des méthodes compatibles avec les threads. Le retour de force est ainsi précis et réagit aux interactions dynamiques au sein de la scène.

    var sceneData = GetSceneData();

    var force = ForceCalculation(device.CursorLocalPosition, device.CursorLocalVelocity, sceneData.cursorRadius,
    sceneData.ballPosition, sceneData.ballVelocity, sceneData.ballRadius);

Expérience de jeu​

Ces extensions de script vous permettent d'interagir avec une balle qui se déplace activement dans la scène. Le retour haptique s'adapte dynamiquement à la trajectoire de la balle, offrant ainsi une expérience plus intense et plus riche sur le plan tactile.

ballon en mouvement

Fichiers source​

La scène complète et les fichiers associés à cet exemple peuvent être importés via la section « Exemples de tutoriels » du Gestionnaire de packages de Unity.

L'exemple Tutoriel le script contient MovableObject, qui est utilisé dans plusieurs exemples pour contrôler le déplacement du GameObject associé à l'aide du clavier.

SphereForceFeedback.cs​

/*
* Copyright 2024 Haply Robotics Inc. All rights reserved.
*/

using System.Threading;
using Haply.Inverse.DeviceControllers;
using Haply.Inverse.DeviceData;
using Haply.Samples.Tutorials.Utils;
using UnityEngine;

namespace Haply.Samples.Tutorials._4A_DynamicForceFeedback
{
public class SphereForceFeedback : MonoBehaviour
{
// must assign in inspector
public Inverse3Controller inverse3;

[Range(0, 800)]
// Stiffness of the force feedback.
public float stiffness = 500f;

[Range(0, 3)]
public float damping = 1f;

#region Thread-safe cached data

/// <summary>
/// Represents scene data that can be updated in the Update() call.
/// </summary>
private struct SceneData
{
public Vector3 ballPosition;
public Vector3 ballVelocity;
public float ballRadius;
public float cursorRadius;
}

/// <summary>
/// Cached version of the scene data.
/// </summary>
private SceneData _cachedSceneData;

private MovableObject _movableObject;

/// <summary>
/// Lock to ensure thread safety when reading or writing to the cache.
/// </summary>
private readonly ReaderWriterLockSlim _cacheLock = new();

/// <summary>
/// Safely reads the cached data.
/// </summary>
/// <returns>The cached scene data.</returns>
private SceneData GetSceneData()
{
_cacheLock.EnterReadLock();
try
{
return _cachedSceneData;
}
finally
{
_cacheLock.ExitReadLock();
}
}

/// <summary>
/// Safely updates the cached data.
/// </summary>
private void SaveSceneData()
{
_cacheLock.EnterWriteLock();
try
{
var t = transform;
_cachedSceneData.ballPosition = t.position;
_cachedSceneData.ballRadius = t.lossyScale.x / 2f;

_cachedSceneData.cursorRadius = inverse3.Cursor.Radius;

_cachedSceneData.ballVelocity = _movableObject.Velocity;
}
finally
{
_cacheLock.ExitWriteLock();
}
}

#endregion

/// <summary>
/// Saves the initial scene data cache.
/// </summary>
private void Start()
{
inverse3 ??= FindFirstObjectByType<Inverse3Controller>();
inverse3.Ready.AddListener((device, args) => SaveSceneData());
_movableObject = GetComponent<MovableObject>();
}

/// <summary>
/// Update scene data cache.
/// </summary>
private void FixedUpdate()
{
if (inverse3.IsReady)
{
SaveSceneData();
}
}

/// <summary>
/// Subscribes to the DeviceStateChanged event.
/// </summary>
private void OnEnable()
{
inverse3.DeviceStateChanged += OnDeviceStateChanged;
}

/// <summary>
/// Unsubscribes from the DeviceStateChanged event.
/// </summary>
private void OnDisable()
{
inverse3.DeviceStateChanged -= OnDeviceStateChanged;
inverse3.Release();
}

/// <summary>
/// Calculates the force based on the cursor's position and another sphere position.
/// </summary>
/// <param name="cursorPosition">The position of the cursor.</param>
/// <param name="cursorVelocity">The velocity of the cursor.</param>
/// <param name="cursorRadius">The radius of the cursor.</param>
/// <param name="otherPosition">The position of the other sphere (e.g., ball).</param>
/// <param name="otherVelocity">The velocity of the other sphere (e.g., ball).</param>
/// <param name="otherRadius">The radius of the other sphere.</param>
/// <returns>The calculated force vector.</returns>
private Vector3 ForceCalculation(Vector3 cursorPosition, Vector3 cursorVelocity, float cursorRadius,
Vector3 otherPosition, Vector3 otherVelocity, float otherRadius)
{
var force = Vector3.zero;

var distanceVector = cursorPosition - otherPosition;
var distance = distanceVector.magnitude;
var penetration = otherRadius + cursorRadius - distance;

if (penetration > 0)
{
// Normalize the distance vector to get the direction of the force
var normal = distanceVector.normalized;

// Calculate the force based on penetration
force = normal * penetration * stiffness;

// Calculate the relative velocity
var relativeVelocity = cursorVelocity - otherVelocity;

// Apply damping based on the relative velocity
force -= relativeVelocity * damping;
}

return force;
}

/// <summary>
/// Event handler that calculates and send the force to the device when the cursor's position changes.
/// </summary>
/// <param name="sender">The Inverse3 data object.</param>
/// <param name="args">The event arguments containing the device data.</param>
private void OnDeviceStateChanged(object sender, Inverse3EventArgs args)
{
var inverse3 = args.DeviceController;
var sceneData = GetSceneData();

// Calculate the moving ball force.
var force = ForceCalculation(inverse3.CursorLocalPosition, inverse3.CursorLocalVelocity, sceneData.cursorRadius,
sceneData.ballPosition, sceneData.ballVelocity, sceneData.ballRadius);

// Apply the force to the cursor.
inverse3.SetCursorLocalForce(force);
}
}
}