跳转至主要内容

动态场景中的力反馈:教程

本指南以力反馈基础教程为基础,演示了如何在 Unity 中模拟动态交互,使用户能够感受到来自移动物体的力反馈。 该场景说明,触觉反馈需要高频更新,其更新频率远高于 Unity 中视觉渲染的典型更新率。

引言​

为了提供令人信服的触觉体验,特别是在动态场景中,进行频率高于1kHz的计算至关重要。 这与通常以约60Hz运行的游戏更新循环形成了鲜明对比。 面临的挑战在于,如何在与游戏主循环并行运行的同时管理这些高频更新,并确保线程安全的数据交换,从而保证力反馈的一致性和精准度。

基础力反馈系统的扩展​

请从以下场景设置开始: 力反馈入门教程. 为了实现动态行为,我们对脚本进行了调整 SphereForceFeedback ,从而使其能够对球体的运动作出反应,并模拟与动态移动物体之间的交互。

主要变更​

  • 动态物体的运动:集成相关逻辑,根据用户输入或预定义的运动模式更新球体的位置和速度。
  • 安全可靠的数据交换: 请使用一个 ReaderWriterLockSlim,用于管理主线程与触觉线程之间对共享数据的并发访问。
  • 力计算的调整: 调整该方法 SphereForceFeedback.ForceCalculation 以便将球体的速度纳入考虑,并根据位置和运动生成逼真的反馈。

动态交互​

为了模拟运动中的球体,您可以在方法中手动设置其位置 Update 进行更新,或者使用一个单独的组件,该组件根据键盘输入或其他交互来控制其移动。 在此示例中,我们将该 GameObject 命名为 球体 在 移动球 嗯,然后将示例中的内容 教程 所含成分 MovingObject hinzu。

调整自 ForceCalculation 用于运动​

现在,力反馈的计算必须考虑移动球的速度,并根据交互的位置和速度来调整作用力。 由此产生的触觉感知更加细腻且逼真,能够反映出交互的动态特性。

  • 为该方法添加一个参数 Vector3 otherVelocity hinzu
  • 请替换 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;
}

线程安全的数据交换​

在物体移动并实时相互交互的动态场景中,至关重要的是,触觉反馈的计算必须基于最新数据,同时确保并发访问不会导致数据损坏。 在此情况下,线程安全的数据交换变得不可或缺。

线程安全数据交换的基本概念​

  • 线程安全的机制: 请使用 ReaderWriterLockSlim,用于管理并发数据访问。这允许进行多个读取操作或单个写入操作,从而确保数据完整性。
  • 数据的读取与写入:
    • 读取:该触觉线程在读取锁定状态下读取对象的位置和速度,同时不会影响数据更新。
    • 写入:主线程对对象数据的更新是在写锁的保护下进行的。这样可以防止同时发生的读写操作,从而避免数据状态出现不一致。

在 Unity 中的实现​

  • 场景数据结构:为了实现线程安全操作,我们定义了一个包含场景所有必要数据的结构体。 该结构体包含“移动球”和光标的位置、速度及其半径。 该数据结构是我们实现线程安全数据交换的基础。

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

    private SceneData _cachedSceneData;
  • 锁的初始化: 一个实例 ReaderWriterLockSlim 进行初始化,以管理对场景数据的访问。 该锁允许多个线程同时读取数据,或将数据排他性地锁定供单个线程写入。这样可以确保在并发操作中数据的完整性。

    private ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
  • 在写保护状态下将数据写入缓存: 该方法 SaveSceneData 在写锁的作用下更新场景数据。 这可确保当一个线程正在更新数据时,其他线程无法进行读写操作。这样既能防止数据竞争(Data Races),又能保证数据的一致性。

    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();
    }
    }
  • 使用读锁从缓存中读取数据: 该方法 GetSceneData 在读锁保护下读取场景数据。 这样,多个线程就可以同时且安全地读取数据,而不会影响写入操作。从而确保触觉反馈的计算基于最新的场景数据。

    private SceneData GetSceneData()
    {
    _cacheLock.EnterReadLock();
    try
    {
    return _cachedSceneData;
    }
    finally
    {
    _cacheLock.ExitReadLock();
    }
    }
  • 在主线程中更新数据: 该方法 FixedUpdate 用于在主线程中定期更新场景数据。 这样,触觉反馈的计算就能访问最新数据,从而体现场景的动态特性。

    private void FixedUpdate()
    {
    SaveSceneData();
    }
  • 使用更新后的数据进行力计算: 我是回调 OnDeviceStateChanged 力计算基于通过线程安全方法获取的最新场景数据进行。因此,力反馈既精准,又能对场景内的动态交互做出响应。

    var sceneData = GetSceneData();

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

游戏体验​

这些脚本扩展功能使您能够与一个在场景中主动移动的球体进行交互。 触觉反馈会根据球体的飞行轨迹动态调整,从而带来更沉浸且触感更丰富的体验。

移动的球

源文件​

该示例的完整场景及相关文件可通过 Unity 包管理器中的“示例教程”进行导入。

这个例子 教程 该脚本包含 MovableObject,该功能在多个示例中被用于通过键盘输入来控制关联的 GameObject 的移动。

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