동적인 장면에서의 힘 감각 피드백 튜토리얼
기본적인 힘 피드백 튜토리얼을바탕으로, 이 가이드에서는 Unity 내에서 동적인 상호 작용을 시뮬레이션하고, 사용자가 이동하는 물체로부터 힘 피드백을 느낄 수 있도록 하는 방법을 소개합니다. 이 시나리오에서는 힘 피드백에 있어 높은 빈도의 업데이트가 필요하다는 점이 두드러지게 나타납니다. 이는 Unity에서 시각적 렌더링의 일반적인 업데이트 빈도를 훨씬 능가하는 수준입니다.
서문
설득력 있는 힘 감각 경험을 구현하기 위해서는, 특히 역동적인 장면에서 1kHz 이상의 주파수로 계산을 수행하는 것이 매우 중요합니다. 이는 일반적으로 약 60Hz로 작동하는 게임 업데이트 루프와는 크게 다릅니다. 여기서 과제는 이러한 고주파수 업데이트를 메인 게임 루프와 병행하여 관리하고, 일관성 있고 정확한 힘 감각 피드백을 유지하기 위해 스레드 안전성을 보장하는 데이터 교환을 확보하는 데 있습니다.
기본 힘 감각 피드백 설정의 확장
기본적인 힘 감각 피드백튜토리얼의 장면 설정부터 시작하겠습니다. 역동적인 동작을 도입하기 위해,SphereForceFeedback 스크립트를 적용하여 구체의 움직임에 반응하고, 동적으로 이동하는 객체와의 상호작용을 시뮬레이션할 수 있도록 합니다.
주요 변경 사항
- 동적 객체의 이동: 사용자 입력이나 미리 정의된 움직임 패턴에 따라 Sphere의 위치와 속도를 업데이트하는 로직을 구현합니다.
- 스레드 안전성이 보장된 데이터 교환:
ReaderWriterLockSlim을 사용하여 메인 스레드와 힘 감지 스레드 간에 공유 데이터에 대한 동시 액세스를 관리합니다. - 힘 계산 조정:
SphereForceFeedback.ForceCalculation메서드를 수정하고 구의 속도를 고려함으로써, 위치와 움직임 모두를 바탕으로 한 현실적인 피드백을 제공합니다.
동적인 상호작용
움직이는 구체를 시뮬레이션하려면,Update 메서드를 사용하여 위치를 수동으로 업데이트하거나, 키보드 입력이나 기타 상호작용을 기반으로 이동을 제어하는 다른 컴포넌트를 사용할 수 있습니다.
이 예제에서는,구체 게임 오브젝트의 이름을 움직이는 공 로 변경하고,튜토리얼 샘플에 포함된 MovingObject 컴포넌트를 추가합니다.
이동에 대응한 ForceCalculation 조정
힘 감각 피드백을 계산할 때는 ‘Moving Ball’의 속도를 고려해야 하며, 상호작용의 위치와 속도를 모두 바탕으로 힘을 조정합니다. 이를 통해 더욱 섬세하고 현실감 있는 힘 감각을 얻을 수 있으며, 상호작용의 동적인 특성이 반영됩니다.
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에서의 구현
-
씬 데이터용 구조체: 스레드 안전성을 보장하는 작업을 용이하게 하기 위해, 씬과 관련된 모든 필수 데이터를 보관하는 구조체를 정의합니다. 이 구조체에는Moving Ball과 커서의 위치 및 속도, 그리고 각각의 반지름이 포함됩니다. 이 데이터 구조는 스레드 안전성을 보장하는 데이터 교환의 기반이 됩니다.
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이 메서드는 쓰기 잠금 상태에서 씬 데이터를 업데이트합니다. 이를 통해 특정 스레드가 데이터를 업데이트하는 동안 다른 스레드는 읽기나 쓰기를 할 수 없게 되어, 데이터 경합이 방지되고 일관성이 보장됩니다.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 Package Manager의 ‘Tutorials’ 샘플에서 가져올 수 있습니다.
튜토리얼 이 샘플에는 여러 예제에서 사용되고 있는
MovableObject스크립트가 포함되어 있으며, 키보드 입력을 통해 연결된 게임 오브젝트의 이동을 제어합니다.
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);
}
}
}