Zum Hauptinhalt springen

Tutorial zur Kraftrückmeldung in dynamischen Szenarien

Aufbauend auf dem Tutorial zum Thema „Grundlagen der Kraftrückmeldung“ zeigt dieser Leitfaden, wie man in Unity dynamische Interaktionen simuliert, sodass Benutzer Kraftrückmeldungen von sich bewegenden Objekten wahrnehmen können. Diese Szene verdeutlicht, dass taktile Rückmeldungen eine hohe Aktualisierungsrate erfordern, die weit über der typischen Aktualisierungsrate der visuellen Darstellung in Unity liegt.

Einführung​

Um ein fesselndes haptisches Erlebnis zu erzielen, insbesondere in dynamischen Szenen, ist eine Berechnung mit Frequenzen über 1 kHz von entscheidender Bedeutung. Dies steht in starkem Kontrast zum üblichen Aktualisierungszyklus von Spielen, der mit etwa 60 Hz läuft. Die Herausforderung besteht darin, diese hochfrequenten Aktualisierungen parallel zum Hauptspielzyklus zu verwalten und einen threadsicheren Datenaustausch zu gewährleisten, um die Konsistenz und Genauigkeit des Force-Feedbacks aufrechtzuerhalten.

Erweiterte Einstellungen für die grundlegende Kraftrückmeldung​

von Grundlegende Kraftrückmeldung Beginnen wir mit der Einrichtung der Szene im Tutorial. Um dynamische Effekte hinzuzufügen, werden wir folgende Änderungen vornehmen: SphereForceFeedback Skript, damit es auf die Bewegung der Kugel reagieren und die Interaktion mit sich dynamisch bewegenden Objekten simulieren kann.

Wesentliche Änderungen​

  • Bewegung dynamischer Objekte: Fügen Sie Logik hinzu, um die Position und Geschwindigkeit der Kugel entsprechend der Benutzereingabe oder vordefinierten Bewegungsmustern zu aktualisieren.
  • Thread-sicherer Datenaustausch: Verwendung ReaderWriterLockSlim Verwaltung des parallelen Zugriffs auf gemeinsam genutzte Daten zwischen dem Hauptthread und dem Haptik-Thread.
  • Kraftberechnung und Anpassung: Ändern SphereForceFeedback.ForceCalculation Eine Methode, bei der die Geschwindigkeit der Kugel berücksichtigt wird, um so ein realistisches Feedback basierend auf Position und Bewegung zu liefern.

Dynamische Interaktion​

Um eine sich bewegende Kugel zu simulieren, können Sie in Update Die Position kann manuell im Code aktualisiert werden, es können aber auch separate Komponenten verwendet werden, um die Bewegung anhand von Tastatureingaben oder anderen Interaktionen zu steuern. In diesem Beispiel werden wir Sphäre Das Spielobjekt wurde umbenannt in Bewegter Ball, und füge hinzu Anleitungen Im Beispiel angegebenes MovingObject Komponenten.

Anpassung an den Sport ForceCalculation​

Bei der Berechnung der Kraftrückmeldung muss nun die Geschwindigkeit des „Moving Ball“ berücksichtigt und die Kraft entsprechend der Position und Geschwindigkeit der Interaktion angepasst werden. Dadurch wird ein feineres und realistischeres taktiles Erlebnis geboten, das die dynamischen Eigenschaften der Interaktion widerspiegelt.

  • Einen hinzufügen Vector3 Typen von otherVelocity Methodenparameter
  • wird force -= cursorVelocity * damping Ersetzen 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 Szenarien, in denen sich Objekte bewegen und in Echtzeit interagieren, ist es von entscheidender Bedeutung, sicherzustellen, dass die Berechnung des haptischen Feedbacks auf den aktuellsten Daten basiert und gleichzeitig Datenbeschädigungen durch parallele Zugriffe vermieden werden. Genau aus diesem Grund ist ein threadsicherer Datenaustausch unverzichtbar.

Kernkonzepte des threadsicheren Datenaustauschs​

  • Mechanismen zur Thread-Sicherheit: Verwendung ReaderWriterLockSlim Verwaltung des parallelen Datenzugriffs. Dies ermöglicht die gleichzeitige Ausführung mehrerer Lesevorgänge oder eines einzelnen Schreibvorgangs und gewährleistet so die Datenintegrität.
  • Daten lesen und schreiben:
    • Lesen: Der taktile Thread liest die Position und Geschwindigkeit des Objekts unter dem Schutz einer Lesesperre, um sicherzustellen, dass er die Datenaktualisierung nicht stört.
    • Schreiben: Die Aktualisierung der Objektdaten durch den Hauptthread erfolgt unter dem Schutz einer Schreibsperre, um zu verhindern, dass gleichzeitig stattfindende Lese- und Schreibvorgänge zu Inkonsistenzen im Datenzustand führen.

Implementierung in Unity​

  • Szenen-Datenstruktur: Um threadsichere Operationen zu ermöglichen, haben wir eine Struktur definiert, in der alle für die Szene erforderlichen Daten gespeichert werden. Diese Struktur enthält die Positionen und Geschwindigkeiten des „Moving Ball“ und des Cursors sowie deren jeweilige 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 des Schlosses: Initialisiere ein ReaderWriterLockSlim Beispiel: Zur Verwaltung des Zugriffs auf Szenendaten. Diese Sperre ermöglicht es mehreren Threads, Daten gleichzeitig zu lesen, oder einem einzelnen Thread, die Daten exklusiv zu sperren und zu schreiben, wodurch die Datenintegrität bei parallelen Vorgängen gewährleistet wird.

    private ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
  • Schreiben in den Cache mit einer Schreibsperre:SaveSceneData Die Methode aktualisiert die Szenendaten innerhalb einer Schreibsperre. Dadurch wird sichergestellt, dass andere Threads keine Lese- oder Schreibvorgänge durchführen können, während ein Thread die Daten aktualisiert. So werden Datenkonflikte verhindert und die Konsistenz gewährleistet.

    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();
    }
    }
  • Den Cache mit einer Lesesperre auslesen:GetSceneData Methode zum Abrufen von Szenendaten unter Lesesperrschutz. Dadurch können mehrere Threads die Daten sicher parallel lesen, ohne Schreibvorgänge zu stören, wodurch sichergestellt wird, dass die Berechnung des haptischen Feedbacks auf den aktuellsten Szenendaten basiert.

    private SceneData GetSceneData()
    {
    _cacheLock.EnterReadLock();
    try
    {
    return _cachedSceneData;
    }
    finally
    {
    _cacheLock.ExitReadLock();
    }
    }
  • Aktualisierung der Daten im Hauptthread:FixedUpdate Diese Methode dient dazu, die Szenendaten im Hauptthread regelmäßig zu aktualisieren. Dadurch wird sichergestellt, dass die Berechnung des haptischen Feedbacks auf die aktuellsten Daten zurückgreifen kann und somit die dynamischen Veränderungen der Szene widerspiegelt.

    private void FixedUpdate()
    {
    SaveSceneData();
    }
  • Kraftberechnungen unter Verwendung der aktualisierten Daten: in OnDeviceStateChanged Im Callback werden die Kräfte anhand der aktuellsten Szenendaten berechnet, die über eine threadsichere Methode abgerufen wurden. Dadurch wird sichergestellt, dass das Force-Feedback präzise ist und zeitnah auf dynamische Interaktionen im Szenario reagiert.

    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 Kugeln interagieren, die sich aktiv in der Szene bewegen. Das haptische Feedback passt sich dynamisch an die Bewegungsbahn der Kugeln an und sorgt so für ein noch intensiveres und haptisch reichhaltigeres Erlebnis.

Die sich bewegende Kugel

Quelldatei​

Die vollständige Szene dieses Beispiels sowie die zugehörigen Dateien können über den Unity Package Manager aus den „Tutorials“-Beispielen importiert werden.

Tutorial Das Beispiel enthält MovableObject Ein Skript, das in mehreren Beispielen verwendet wird und dazu dient, die Bewegung der zugehörigen Spielobjekte ü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);
}
}
}