주요 내용으로 이동

동적 환경에서의 힘 피드백 튜토리얼

'기본 힘 피드백' 튜토리얼을 바탕으로, 이 가이드에서는 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);
}
}
}