Zum Hauptinhalt springen

Force-Feedback in einer dynamischen Szene: Tutorial

Aufbauend auf dem Grundlagen-Tutorial zu Force-Feedback zeigt diese Anleitung, wie Sie dynamische Interaktionen innerhalb von Unity simulieren, sodass Benutzer Force-Feedback von einem bewegten Objekt spüren können. Dieses Szenario verdeutlicht, dass für haptisches Feedback Aktualisierungen mit hoher Frequenz notwendig 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 1kHz durchzuführen. Dies steht in starkem Gegensatz zur üblichen Game-Update-Schleife, die mit etwa 60Hz 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 Grundlagen-Force-Feedback-Setups​

Beginnen Sie mit dem Szenenaufbau aus dem Grundlagen-Tutorial zu Force-Feedback. Um dynamisches Verhalten einzubauen, passen wir das Skript SphereForceFeedback an, sodass es auf die Bewegung der Kugel reagiert und Interaktionen mit einem sich dynamisch bewegenden Objekt simuliert.

Wesentliche Änderungen​

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

Dynamische Interaktion​

Um die bewegte 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 Sphere in Moving Ball um und fügen die im Beispiel Tutorials enthaltene Komponente MovingObject hinzu.

Anpassung von ForceCalculation für die Bewegung​

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

  • Fügen Sie der Methode einen Parameter 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 neuesten Daten beruhen, ohne dass gleichzeitige Zugriffe zu Datenbeschädigungen führen. Hier wird der thread-sichere Datenaustausch unverzichtbar.

Grundkonzepte des thread-sicheren Datenaustauschs​

  • Thread-sichere Mechanismen: Verwenden Sie ReaderWriterLockSlim, um gleichzeitige Datenzugriffe zu verwalten. Dies erlaubt mehrere Lesevorgänge oder einen einzelnen Schreibvorgang und gewährleistet so die Datenintegrität.
  • Lesen und Schreiben von Daten:
    • 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. So werden gleichzeitige Lese- oder Schreibvorgänge verhindert, die zu inkonsistenten Datenzuständen führen könnten.

Implementierung in Unity​

  • Struct für Szenendaten: Um thread-sichere Operationen zu ermöglichen, definieren wir eine Struktur, die alle notwendigen Daten über die 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 thread-sicheren 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 erlaubt 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, während ein Thread die Daten aktualisiert, kein anderer Thread lesen oder schreiben kann. Dies verhindert Datenrennen (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();
    }
    }
  • Lesen aus dem Cache 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 beruhen.

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

    private void FixedUpdate()
    {
    SaveSceneData();
    }
  • Anwenden der Kraftberechnung mit aktualisierten Daten: Im Callback OnDeviceStateChanged werden Kraftberechnungen mit den neuesten Szenendaten durchgeführt, die über thread-sichere 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​

Diese Skripterweiterungen ermöglichen es Ihnen, mit einer Kugel zu interagieren, die sich aktiv durch die Szene bewegt. Das haptische Feedback passt sich dynamisch an die Flugbahn der Kugel an und bietet so ein intensiveres und taktil reichhaltigeres Erlebnis.

bewegter Ball

Quelldateien​

Die vollständige Szene und die zugehörigen Dateien für dieses Beispiel können über das 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 per Tastatureingabe 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);
}
}
}