动态场景中的力反馈教程
本指南以 "基本力反馈"教程为基础,介绍如何在 Unity 中模拟动态交互,让用户感受来自移动物体的力反馈。 此场景强调了触觉反馈高频更新的必要性,这大大超过了 Unity 中视觉渲染的典型更新率。
导言
要获得引人注目的触觉体验,尤其是在动态场景中,以高于 1kHz 的频率进行计算至关重要。 这与通常以 60Hz 左右频率运行的游戏更新循环形成了鲜明对比。 难点在于如何在管理主游戏循环的同时管理这些高频更新,确保线程安全的数据交换,以保持一致和准确的力反馈。
扩展基本力反馈设置
从 基本力反馈 教程。为了加入动态行为,我们将调整 SphereForceFeedback
脚本,使其能够响应球体的运动,并模拟与动态运动物体的交互。
主要修改
- 动态物体运动:根据用户输入或预定义的运动模式,整合更新球体位置和速度的逻辑。
- 线程安全数据交换:使用
ReaderWriterLockSlim
来管理主线程和触觉线程对共享数据的并发访问。 - 力计算调整:修改
SphereForceFeedback.ForceCalculation
该方法考虑了球体的速度,可根据位置和运动提供逼真的反馈。
动态互动
要模拟移动球体,可以在 Update
方法,或者使用一个单独的组件,根据键盘输入或其他交互来控制其移动。
在本例中,我们将重命名 球体 游戏对象为 移动球 并添加 MovingObject
中给出的 教程 样品
调整 ForceCalculation
用于运动
力反馈计算现在需要考虑移动球的速度,根据交互的位置和速度调整力。 这将提供更加细微和逼真的触觉感受,反映互动的动态性质。
- 添加一个
Vector3
otherVelocity
方法参数 - 更换
force -= cursorVelocity * damping
由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; -
锁定初始化:A
ReaderWriterLockSlim
实例初始化,以管理对场景数据的访问。 这种锁允许多个线程同时读取数据,或专门锁定数据供单个线程写入,从而确保并发操作期间的数据完整性。private ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
-
使用写入锁定写入缓存:""""""""""""等字样。
SaveSceneData
方法在写入锁内更新场景数据。 这样可以确保在一个线程更新数据时,其他线程无法读取或写入,从而防止数据竞赛并确保一致性。private void SaveSceneData()
{
_cacheLock.EnterWriteLock();
try
{
var t = transform;
_cachedSceneData.ballPosition = t.position;
_cachedSceneData.ballRadius = t.lossyScale.x / 2f;
_cachedSceneData.cursorRadius = inverse3.Cursor.Model.transform.lossyScale.x / 2f;
_cachedSceneData.ballVelocity = _movableObject.CursorVelocity;
}
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
脚本,用于在多个示例中通过键盘输入控制所附游戏对象的移动。
SphereForceFeedback.cs
/*
* Copyright 2024 Haply Robotics Inc. All rights reserved.
*/
using System.Threading;
using Haply.Inverse.Unity;
using Haply.Samples.Tutorials.Utils;
using UnityEngine;
namespace Haply.Samples.Tutorials._4A_DynamicForceFeedback
{
public class SphereForceFeedback : MonoBehaviour
{
// must assign in inspector
public Inverse3 inverse3;
[Range(0, 800)]
// Stiffness of the force feedback.
public float stiffness = 300f;
[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.Model.transform.lossyScale.x / 2f;
_cachedSceneData.ballVelocity = _movableObject.CursorVelocity;
}
finally
{
_cacheLock.ExitWriteLock();
}
}
#endregion
/// <summary>
/// Saves the initial scene data cache.
/// </summary>
private void Start()
{
_movableObject = GetComponent<MovableObject>();
SaveSceneData();
}
/// <summary>
/// Update scene data cache.
/// </summary>
private void FixedUpdate()
{
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;
}
/// <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="device">The Inverse3 device instance.</param>
private void OnDeviceStateChanged(Inverse3 device)
{
var sceneData = GetSceneData();
// Calculate the moving ball force.
var force = ForceCalculation(device.CursorLocalPosition, device.CursorLocalVelocity, sceneData.cursorRadius,
sceneData.ballPosition, sceneData.ballVelocity, sceneData.ballRadius);
// Apply the force to the cursor.
device.CursorSetLocalForce(force);
}
}
}