Zum Hauptinhalt springen

Force-Feedback in einer dynamischen Szene: Tutorial

Aufbauend auf dem Grundlagen-Tutorial zum Thema Force-Feedback zeigt diese Anleitung, wie Sie dynamische Interaktionen in Unity simulieren können, sodass Benutzer Force-Feedback von einem sich bewegenden Objekt spüren können. Dieses Szenario verdeutlicht, dass für haptisches Feedback Aktualisierungen mit hoher Frequenz erforderlich sind, die die typischen Aktualisierungsraten für die visuelle Darstellung in Unity deutlich übersteigen.

Einführung​

Für ein überzeugendes haptisches Erlebnis, insbesondere in dynamischen Szenen, ist es entscheidend, Berechnungen mit Frequenzen über 1 kHz durchzuführen. Dies steht in starkem Gegensatz zur üblichen Spiel-Update-Schleife, die mit etwa 60 Hz arbeitet. Die Herausforderung besteht darin, diese hochfrequenten Aktualisierungen parallel zur Hauptschleife des Spiels zu verwalten und einen threadsicheren Datenaustausch sicherzustellen, um ein konsistentes und präzises Force-Feedback zu gewährleisten.

Erweiterung des grundlegenden Force-Feedback-Setups​

Beginnen Sie mit dem Aufbau der Szene aus dem Einführungs-Tutorial zum Thema Force-Feedback. Um dynamisches Verhalten zu integrieren, passen wir das Skript an SphereForceFeedback so, dass es auf die Bewegung der Kugel reagiert und Interaktionen mit einem sich dynamisch bewegenden Objekt simuliert.

Wesentliche Änderungen​

  • Bewegung des dynamischen Objekts: Integrieren Sie eine Logik, die die Position und Geschwindigkeit der Kugel auf der Grundlage von Benutzereingaben oder vordefinierten Bewegungsmustern aktualisiert.
  • Sicherer Datenaustausch: Verwenden Sie einen ReaderWriterLockSlim, um den gleichzeitigen Zugriff auf gemeinsam genutzte Daten zwischen dem Haupt- und dem haptischen Thread zu verwalten.
  • Anpassungen bei der Kraftberechnung: Passen Sie die Methode an SphereForceFeedback.ForceCalculation damit die Geschwindigkeit der Kugel berücksichtigt wird und ein realistisches Feedback auf der Grundlage von Position und Bewegung entsteht.

Dynamische Interaktion​

Um die sich bewegende Kugel zu simulieren, können Sie ihre Position entweder manuell in der Methode Update aktualisieren oder eine separate Komponente verwenden, die ihre Bewegung anhand von Tastatureingaben oder anderen Interaktionen steuert. In diesem Beispiel benennen wir das GameObject Sphäre in Bewegter Ball um und fügen die im Beispiel Anleitungen enthaltene Komponente MovingObject hinzu.

Anpassung von ForceCalculation für die Bewegung​

Bei der Berechnung des Force-Feedbacks muss nun die Geschwindigkeit des Moving Ball berücksichtigt und die Kraft sowohl anhand der Position als auch der Geschwindigkeit der Interaktion angepasst werden. Dadurch entsteht eine differenziertere und realistischere haptische Wahrnehmung, die den dynamischen Charakter der Interaktion widerspiegelt.

  • Fügen Sie der Methode einen Parameter hinzu Vector3 otherVelocity hinzu
  • Ersetzen Sie force -= cursorVelocity * damping durch 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;
}

Thread-sicherer Datenaustausch​

In dynamischen Szenen, in denen sich Objekte bewegen und in Echtzeit miteinander interagieren, ist es entscheidend, dass die Berechnungen des haptischen Feedbacks auf den aktuellsten Daten basieren, ohne dass gleichzeitige Zugriffe zu Datenbeschädigungen führen. Hier ist der threadsichere Datenaustausch unverzichtbar.

Grundkonzepte des threadsicheren Datenaustauschs​

  • Thread-sichere Mechanismen: Verwenden Sie ReaderWriterLockSlim, um gleichzeitige Datenzugriffe zu verwalten. Dies ermöglicht mehrere Lesevorgänge oder einen einzelnen Schreibvorgang und gewährleistet so die Datenintegrität.
  • Daten lesen und schreiben:
    • Lesen: Der haptische Thread liest Objektpositionen und -geschwindigkeiten unter einer Lesesperre, ohne die Datenaktualisierungen zu beeinträchtigen.
    • Schreiben: Aktualisierungen der Objektdaten durch den Hauptthread erfolgen unter einer Schreibsperre. Dadurch werden gleichzeitige Lese- oder Schreibvorgänge verhindert, die zu inkonsistenten Datenzuständen führen könnten.

Implementierung in Unity​

  • Struktur für Szenendaten: Um threadsichere Operationen zu ermöglichen, definieren wir eine Struktur, die alle notwendigen Daten zur Szene enthält. Diese Struktur umfasst die Position und Geschwindigkeit sowohl des Moving Ball als auch des Cursors sowie deren Radien. Diese Datenstruktur bildet die Grundlage für unseren threadsicheren Datenaustausch.

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

    private SceneData _cachedSceneData;
  • Initialisierung der Sperre: Eine Instanz von ReaderWriterLockSlim wird initialisiert, um den Zugriff auf die Szenendaten zu verwalten. Diese Sperre ermöglicht es mehreren Threads, Daten gleichzeitig zu lesen, oder sperrt die Daten exklusiv für einen einzelnen Thread zum Schreiben. So wird die Datenintegrität bei gleichzeitigen Vorgängen gewährleistet.

    private ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
  • Schreiben in den Cache mit einer Schreibsperre: Die Methode SaveSceneData aktualisiert die Szenendaten innerhalb einer Schreibsperre. Dadurch wird sichergestellt, dass kein anderer Thread die Daten lesen oder schreiben kann, während ein Thread die Daten aktualisiert. Dies verhindert Datenkonflikte (Data Races) und gewährleistet die Konsistenz.

    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();
    }
    }
  • Aus dem Cache lesen mit einer Lesesperre: Die Methode GetSceneData ruft die Szenendaten unter einer Lesesperre ab. Dadurch können mehrere Threads die Daten gleichzeitig und sicher lesen, ohne Schreibvorgänge zu beeinträchtigen. So wird gewährleistet, dass die Berechnungen des haptischen Feedbacks auf den neuesten Szenendaten basieren.

    private SceneData GetSceneData()
    {
    _cacheLock.EnterReadLock();
    try
    {
    return _cachedSceneData;
    }
    finally
    {
    _cacheLock.ExitReadLock();
    }
    }
  • Datenaktualisierung im Hauptthread: Die Methode FixedUpdate wird verwendet, um die Szenendaten im Hauptthread regelmäßig zu aktualisieren. Dadurch können die Berechnungen des haptischen Feedbacks auf die aktuellsten Daten zugreifen, was den dynamischen Charakter der Szene widerspiegelt.

    private void FixedUpdate()
    {
    SaveSceneData();
    }
  • Anwendung der Kraftberechnung mit aktualisierten Daten: Ich rufe zurück OnDeviceStateChanged Es werden Kraftberechnungen mit den neuesten Szenendaten durchgeführt, die über threadsichere Methoden abgerufen werden. Dadurch ist das Kraftfeedback präzise und reagiert auf die dynamischen Interaktionen innerhalb der Szene.

    var sceneData = GetSceneData();

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

Spielerlebnis​

Mit diesen Skript-Erweiterungen können Sie mit einer Kugel interagieren, die sich aktiv durch die Szene bewegt. Das haptische Feedback passt sich dynamisch an die Flugbahn der Kugel an und sorgt so für ein intensiveres und taktil reichhaltigeres Erlebnis.

sich bewegender Ball

Quelldateien​

Die vollständige Szene und die dazugehörigen Dateien für dieses Beispiel können über die Beispiel-Tutorials im Package Manager von Unity importiert werden.

Das Beispiel Tutorial enthält das Skript MovableObject, das in mehreren Beispielen verwendet wird, um die Bewegung des angehängten GameObjects über Tastatureingaben zu steuern.

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);
}
}
}