- TwoBoneIK: cosine law → FABRIK 6회 반복 (역관절 안정 + 본 길이 제약) - 자동 힙 상하 보정 매 프레임 적용, 수동 hipsOffsetX/Y/Z 제거 - kneeFrontBackWeight/InOutWeight, GetAvatarScale 등 dead code 정리 - FingerShapedController GC 제거 (HumanPose/Transform 캐싱) - IK 본 길이 / FieldInfo / Chair prop / 가중치 배열(List→float[]) 캐싱 - localAxisForWorldRight/Forward, IKJoints 다리 필드 등 미사용 정리 - 매직 넘버 55 → BoneCount 상수 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1558 lines
63 KiB
C#
1558 lines
63 KiB
C#
using System.Collections.Generic;
|
||
using UnityEngine;
|
||
using UniHumanoid;
|
||
using System.IO;
|
||
using System;
|
||
namespace KindRetargeting
|
||
{
|
||
/// <summary>
|
||
/// 이 스크립트는 원본 아바타(Source)의 포즈 손가락 움직임을 대상 아바타(Target)에 리타게팅(Retargeting)합니다.
|
||
/// 또한 IK 타겟을 생성하여 대상 아바타의 관절 움직임을 자연스럽게 조정합니다.
|
||
/// </summary>
|
||
//[DefaultExecutionOrder(-200)]
|
||
public class CustomRetargetingScript : MonoBehaviour
|
||
{
|
||
#region 필드
|
||
|
||
[Header("원본 아바타 (OptiTrack)")]
|
||
[SerializeField] public OptitrackSkeletonAnimator_Mingle optitrackSource;
|
||
[HideInInspector] public Animator sourceAnimator; // 하위 호환용 (외부 스크립트 참조)
|
||
[HideInInspector] public Animator targetAnimator; // 대상 아바타의 Animator
|
||
|
||
// IK 컴포넌트 참조
|
||
[SerializeField] public TwoBoneIKSolver ikSolver = new TwoBoneIKSolver();
|
||
|
||
[HideInInspector] public float HipsWeightOffset = 1f;
|
||
[HideInInspector] public float ChairSeatHeightOffset = 0f; // 의자 좌석 높이 오프셋 (월드 Y 기준)
|
||
|
||
// 캐릭터 기준 "위(Up)" 방향을 담당하는 로컬 축 (자동 힙 보정에서 사용)
|
||
// 예: (0,1,0) 이면 로컬 Y축이 월드 Up. 부호는 방향(0,-1,0이면 로컬 -Y가 Up)
|
||
private Vector3 localAxisForWorldUp = Vector3.up;
|
||
|
||
[Header("축 정규화 정보 (읽기 전용)")]
|
||
[SerializeField]
|
||
public Vector3 debugAxisNormalizer = Vector3.one;
|
||
|
||
/// <summary>
|
||
/// 소스 본 Transform 접근 래퍼 (OptiTrack 매핑 사용)
|
||
/// </summary>
|
||
private Transform GetSourceBoneTransform(HumanBodyBones bone)
|
||
{
|
||
if (optitrackSource != null)
|
||
return optitrackSource.GetBoneTransform(bone);
|
||
return null;
|
||
}
|
||
|
||
// OptiTrack 스파인/넥 분배용 캐시
|
||
private List<Transform> sourceSpineChain; // 소스 스파인 체인
|
||
private List<Transform> sourceNeckChain; // 소스 넥 체인
|
||
private List<Transform> targetSpineBones; // 타겟 스파인 본들
|
||
private List<Transform> targetNeckBones; // 타겟 넥 본들
|
||
private List<Quaternion> spineOffsets; // T-포즈 기준: Inv(가상본 월드회전) * 타겟본 월드회전
|
||
private List<Quaternion> neckOffsets; // 넥용 오프셋
|
||
private bool useOptiTrackSpineDistribution = false;
|
||
|
||
// HumanPoseHandler를 이용하여 대상 아바타의 포즈를 관리
|
||
private HumanPoseHandler targetPoseHandler;
|
||
private HumanPose targetPose;
|
||
|
||
// 최적화: 프레임당 한 번만 GetHumanPose 호출하기 위한 플래그
|
||
private bool isTargetPoseCachedThisFrame = false;
|
||
|
||
// 본별 회전 오프셋을 저장하는 딕셔너리
|
||
private Dictionary<HumanBodyBones, Quaternion> rotationOffsets = new Dictionary<HumanBodyBones, Quaternion>();
|
||
|
||
// HumanBodyBones 본 순회 범위 (0~54: 몸체 + 손가락 전부)
|
||
private const int lastBoneIndex = 55;
|
||
|
||
[Header("발 IK 위치 조정")]
|
||
[SerializeField, Range(-1f, 1f)]
|
||
private float footFrontBackOffset = 0f; // 발 앞뒤 오프셋 (+: 앞, -: 뒤)
|
||
[SerializeField, Range(-1f, 1f)]
|
||
private float footInOutOffset = 0f; // 발 안쪽/바깥쪽 오프셋 (+: 벌리기, -: 모으기)
|
||
|
||
[Header("바닥 높이 조정")]
|
||
[SerializeField, Range(-1f, 1f)] public float floorHeight = 0f; // 바닥 높이 조정값
|
||
|
||
[Header("머리 회전 오프셋")]
|
||
[SerializeField, Range(-180f, 180f)] public float headRotationOffsetX = 0f; // 머리 좌우 기울기 (Roll)
|
||
[SerializeField, Range(-180f, 180f)] public float headRotationOffsetY = 0f; // 머리 좌우 회전 (Yaw)
|
||
[SerializeField, Range(-180f, 180f)] public float headRotationOffsetZ = 0f; // 머리 상하 회전 (Pitch)
|
||
|
||
[Header("설정 저장/로드")]
|
||
[SerializeField] private string settingsFolderName = "RetargetingSettings";
|
||
|
||
private float initialHipsHeight; // 초기 힙 높이
|
||
|
||
// T-포즈에서의 머리 정면 방향 (캘리브레이션용)
|
||
[HideInInspector] public Vector3 tPoseHeadForward = Vector3.forward;
|
||
[HideInInspector] public Vector3 tPoseHeadUp = Vector3.up;
|
||
|
||
[Header("프랍 부착")]
|
||
[SerializeField] public PropLocationController propLocation = new PropLocationController();
|
||
|
||
[Header("사지 가중치")]
|
||
[SerializeField] public LimbWeightController limbWeight = new LimbWeightController();
|
||
|
||
[Header("손가락 셰이핑")]
|
||
[SerializeField] public FingerShapedController fingerShaped = new FingerShapedController();
|
||
|
||
[Header("아바타 크기 조정")]
|
||
[SerializeField, Range(0.1f, 3f)] private float avatarScale = 1f;
|
||
private float previousScale = 1f;
|
||
private List<Transform> scalableObjects = new List<Transform>();
|
||
|
||
[Header("머리 크기 조정")]
|
||
[SerializeField, Range(0.1f, 3f)] private float headScale = 1f;
|
||
private Transform headBone;
|
||
private Vector3 originalHeadScale = Vector3.one;
|
||
|
||
// 필드 추가
|
||
private Dictionary<Transform, Vector3> originalScales = new Dictionary<Transform, Vector3>();
|
||
private Dictionary<Transform, Vector3> originalPositions = new Dictionary<Transform, Vector3>();
|
||
|
||
[System.Serializable]
|
||
private class RotationOffsetData
|
||
{
|
||
public int boneIndex;
|
||
public float x, y, z, w;
|
||
|
||
public RotationOffsetData(int bone, Quaternion rotation)
|
||
{
|
||
boneIndex = bone;
|
||
x = rotation.x;
|
||
y = rotation.y;
|
||
z = rotation.z;
|
||
w = rotation.w;
|
||
}
|
||
|
||
public Quaternion ToQuaternion()
|
||
{
|
||
return new Quaternion(x, y, z, w);
|
||
}
|
||
}
|
||
|
||
[System.Serializable]
|
||
private class RetargetingSettings
|
||
{
|
||
public float footFrontBackOffset; // 발 앞뒤 오프셋
|
||
public float footInOutOffset; // 발 안쪽/바깥쪽 오프셋
|
||
public float floorHeight;
|
||
public List<RotationOffsetData> rotationOffsetCache;
|
||
public float initialHipsHeight;
|
||
public float avatarScale;
|
||
// 의자 앉기 높이 오프셋 (LimbWeightController)
|
||
public float chairSeatHeightOffset;
|
||
// 머리 회전 오프셋
|
||
public float headRotationOffsetX;
|
||
public float headRotationOffsetY;
|
||
public float headRotationOffsetZ;
|
||
// 머리 크기
|
||
public float headScale;
|
||
}
|
||
|
||
|
||
// IK 조인트 캐시 구조체 (팔꿈치 bendGoal 위치 결정용)
|
||
private struct IKJoints
|
||
{
|
||
public Transform leftLowerArm;
|
||
public Transform rightLowerArm;
|
||
}
|
||
private IKJoints sourceIKJoints;
|
||
|
||
#endregion
|
||
|
||
#region 초기화
|
||
|
||
/// <summary>
|
||
/// 초기화 메서드. T-포즈 설정, IK 타겟 생성, HumanPoseHandler 초기화 및 회전 오프셋 계산을 수행합니다.
|
||
/// </summary>
|
||
void Start()
|
||
{
|
||
targetAnimator = GetComponent<Animator>();
|
||
// 설정 로드
|
||
LoadSettings();
|
||
|
||
// IK 모듈은 InitializeIKJoints에서 초기화
|
||
|
||
// IK 타겟 생성 (무릎 시각화 오브젝트 포함)
|
||
CreateIKTargets();
|
||
|
||
// T-포즈 전에 축 정규화 계수 계산
|
||
CalculateAxisNormalizer();
|
||
|
||
// 원본 및 대상 아바타를 T-포즈로 복원
|
||
// OptiTrack 소스는 Humanoid가 아니므로 현재 포즈를 기준으로 캐싱
|
||
if (optitrackSource != null)
|
||
{
|
||
optitrackSource.CacheRestPose(); // 현재 포즈(T-포즈)를 기준으로 캐싱
|
||
}
|
||
SetTPose(targetAnimator);
|
||
|
||
// T-포즈에서의 머리 정면 방향 캐싱 (캘리브레이션용)
|
||
CacheTPoseHeadDirection();
|
||
|
||
// HumanPoseHandler 초기화
|
||
InitializeHumanPoseHandlers();
|
||
|
||
// 회전 오프셋 초기화 (캐시 사용 또는 새로 계산)
|
||
InitializeRotationOffsets();
|
||
|
||
// 초기 힙 높이 저장
|
||
if (targetAnimator != null)
|
||
{
|
||
Transform hips = targetAnimator.GetBoneTransform(HumanBodyBones.Hips);
|
||
if (hips != null)
|
||
{
|
||
initialHipsHeight = hips.position.y;
|
||
}
|
||
}
|
||
|
||
InitializeIKJoints();
|
||
|
||
// 크기 조정 대상 오브젝트 캐싱
|
||
CacheScalableObjects();
|
||
|
||
previousScale = avatarScale;
|
||
|
||
ApplyScale();
|
||
|
||
// 머리 본 초기화
|
||
if (targetAnimator != null)
|
||
{
|
||
headBone = targetAnimator.GetBoneTransform(HumanBodyBones.Head);
|
||
if (headBone != null)
|
||
{
|
||
originalHeadScale = headBone.localScale;
|
||
Debug.Log($"[CustomRetargetingScript] 머리 본 초기화 완료: {headBone.name}, 원본 스케일: {originalHeadScale}");
|
||
}
|
||
else
|
||
{
|
||
Debug.LogWarning("[CustomRetargetingScript] 머리 본을 찾을 수 없습니다!");
|
||
}
|
||
}
|
||
|
||
// OptiTrack 스파인 분배 초기화
|
||
InitializeOptiTrackSpineDistribution();
|
||
|
||
// 프랍 부착 모듈 초기화
|
||
if (targetAnimator != null)
|
||
propLocation.Initialize(targetAnimator);
|
||
|
||
// 사지 가중치 모듈 초기화
|
||
limbWeight.Initialize(ikSolver, this, transform);
|
||
|
||
// 손가락 셰이핑 모듈 초기화
|
||
if (targetAnimator != null)
|
||
fingerShaped.Initialize(targetAnimator);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 타겟 아바타의 로컬 축과 월드 축의 관계를 분석하여 축 매핑을 계산합니다.
|
||
/// T-포즈 상태에서 힙의 각 로컬 축이 월드의 어느 방향을 가리키는지 분석합니다.
|
||
///
|
||
/// 예시:
|
||
/// - 아바타 A: 로컬 Y가 월드 Up을 가리킴 → localAxisForWorldUp = (0, 1, 0)
|
||
/// - 아바타 B: 로컬 Z가 월드 Up을 가리킴 → localAxisForWorldUp = (0, 0, 1)
|
||
/// - 아바타 C: 로컬 -Z가 월드 Up을 가리킴 → localAxisForWorldUp = (0, 0, -1)
|
||
///
|
||
/// 이를 통해 자동 힙 보정은 항상 캐릭터 기준 "위/아래" 방향으로 작동합니다.
|
||
/// </summary>
|
||
private void CalculateAxisNormalizer()
|
||
{
|
||
if (targetAnimator == null) return;
|
||
|
||
Transform hips = targetAnimator.GetBoneTransform(HumanBodyBones.Hips);
|
||
if (hips == null) return;
|
||
|
||
// 힙의 각 로컬 축을 월드 공간으로 변환
|
||
Vector3 localXInWorld = hips.TransformDirection(Vector3.right).normalized;
|
||
Vector3 localYInWorld = hips.TransformDirection(Vector3.up).normalized;
|
||
Vector3 localZInWorld = hips.TransformDirection(Vector3.forward).normalized;
|
||
|
||
// 월드 Right/Up/Forward 에 가장 가까운 로컬 축을 분석 (디버그 표시 + Up 매핑용)
|
||
Vector3 localAxisForWorldRight = FindBestLocalAxisForWorld(localXInWorld, localYInWorld, localZInWorld, Vector3.right, out string rightAxisName);
|
||
localAxisForWorldUp = FindBestLocalAxisForWorld(localXInWorld, localYInWorld, localZInWorld, Vector3.up, out string upAxisName);
|
||
Vector3 localAxisForWorldForward = FindBestLocalAxisForWorld(localXInWorld, localYInWorld, localZInWorld, Vector3.forward, out string forwardAxisName);
|
||
|
||
// 디버그용: X=좌우, Y=상하, Z=앞뒤 매핑 (1=X, 2=Y, 3=Z, 부호=방향)
|
||
debugAxisNormalizer = new Vector3(
|
||
GetAxisIndex(localAxisForWorldRight),
|
||
GetAxisIndex(localAxisForWorldUp),
|
||
GetAxisIndex(localAxisForWorldForward)
|
||
);
|
||
|
||
Debug.Log($"[{gameObject.name}] 축 매핑 분석 완료:\n" +
|
||
$" 월드 Right(좌우) ← 로컬 {rightAxisName} → 매핑: {localAxisForWorldRight}\n" +
|
||
$" 월드 Up(상하) ← 로컬 {upAxisName} → 매핑: {localAxisForWorldUp}\n" +
|
||
$" 월드 Forward(앞뒤) ← 로컬 {forwardAxisName} → 매핑: {localAxisForWorldForward}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 축 벡터를 인덱스로 변환합니다 (디버그용).
|
||
/// X축=1, Y축=2, Z축=3, 부호는 방향을 나타냄
|
||
/// </summary>
|
||
private float GetAxisIndex(Vector3 axisVector)
|
||
{
|
||
if (Mathf.Abs(axisVector.x) > 0.5f)
|
||
return 1f * Mathf.Sign(axisVector.x); // X축
|
||
else if (Mathf.Abs(axisVector.y) > 0.5f)
|
||
return 2f * Mathf.Sign(axisVector.y); // Y축
|
||
else
|
||
return 3f * Mathf.Sign(axisVector.z); // Z축
|
||
}
|
||
|
||
/// <summary>
|
||
/// 세 로컬 축 중에서 목표 월드 방향과 가장 일치하는 축을 찾아 로컬 축 벡터를 반환합니다.
|
||
/// </summary>
|
||
/// <param name="localXInWorld">로컬 X축의 월드 방향</param>
|
||
/// <param name="localYInWorld">로컬 Y축의 월드 방향</param>
|
||
/// <param name="localZInWorld">로컬 Z축의 월드 방향</param>
|
||
/// <param name="worldDirection">비교할 월드 방향</param>
|
||
/// <param name="matchedAxisName">매칭된 축 이름 (출력용)</param>
|
||
/// <returns>해당 월드 방향을 담당하는 로컬 축 벡터 (부호 포함)</returns>
|
||
private Vector3 FindBestLocalAxisForWorld(Vector3 localXInWorld, Vector3 localYInWorld, Vector3 localZInWorld, Vector3 worldDirection, out string matchedAxisName)
|
||
{
|
||
float dotX = Vector3.Dot(localXInWorld, worldDirection);
|
||
float dotY = Vector3.Dot(localYInWorld, worldDirection);
|
||
float dotZ = Vector3.Dot(localZInWorld, worldDirection);
|
||
|
||
float absDotX = Mathf.Abs(dotX);
|
||
float absDotY = Mathf.Abs(dotY);
|
||
float absDotZ = Mathf.Abs(dotZ);
|
||
|
||
// 가장 큰 내적값을 가진 축이 해당 월드 방향과 가장 일치하는 축
|
||
if (absDotX >= absDotY && absDotX >= absDotZ)
|
||
{
|
||
matchedAxisName = dotX > 0 ? "+X (Right)" : "-X (Left)";
|
||
return Vector3.right * Mathf.Sign(dotX); // 로컬 X축 (부호 포함)
|
||
}
|
||
else if (absDotY >= absDotX && absDotY >= absDotZ)
|
||
{
|
||
matchedAxisName = dotY > 0 ? "+Y (Up)" : "-Y (Down)";
|
||
return Vector3.up * Mathf.Sign(dotY); // 로컬 Y축 (부호 포함)
|
||
}
|
||
else
|
||
{
|
||
matchedAxisName = dotZ > 0 ? "+Z (Forward)" : "-Z (Back)";
|
||
return Vector3.forward * Mathf.Sign(dotZ); // 로컬 Z축 (부호 포함)
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// HumanPoseHandler를 초기화합니다.
|
||
/// </summary>
|
||
private void InitializeHumanPoseHandlers()
|
||
{
|
||
if (targetAnimator != null && targetAnimator.avatar != null)
|
||
{
|
||
targetPoseHandler = new HumanPoseHandler(targetAnimator.avatar, targetAnimator.transform);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 타겟 포즈를 캐싱하여 반환합니다. 프레임당 한 번만 GetHumanPose 호출.
|
||
/// </summary>
|
||
private void EnsureTargetPoseCached()
|
||
{
|
||
if (!isTargetPoseCachedThisFrame && targetPoseHandler != null)
|
||
{
|
||
targetPoseHandler.GetHumanPose(ref targetPose);
|
||
isTargetPoseCachedThisFrame = true;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 원본과 대상 아바타의 각 본 간 회전 오프셋을 계산하여 저장합니다.
|
||
/// </summary>
|
||
private void CalculateRotationOffsets(bool isIPose = false)
|
||
{
|
||
if (optitrackSource == null || targetAnimator == null)
|
||
{
|
||
Debug.LogError("소스 OptiTrack 또는 타겟 Animator가 설정되지 않았습니다.");
|
||
return;
|
||
}
|
||
|
||
// Dictionary가 null이면 초기화
|
||
if (rotationOffsets == null)
|
||
{
|
||
rotationOffsets = new Dictionary<HumanBodyBones, Quaternion>();
|
||
}
|
||
|
||
// 모든 본에 대해 오프셋 계산 (기본 몸체 본 + 손가락 본 + UpperChest)
|
||
for (int i = 0; i <= (isIPose ? 23 : 54); i++)
|
||
{
|
||
HumanBodyBones bone = (HumanBodyBones)i;
|
||
Transform sourceBone = GetSourceBoneTransform(bone);
|
||
Transform targetBone = targetAnimator.GetBoneTransform(bone);
|
||
|
||
if (sourceBone != null && targetBone != null)
|
||
{
|
||
if (rotationOffsets.ContainsKey(bone))
|
||
{
|
||
rotationOffsets[bone] = Quaternion.Inverse(sourceBone.rotation) * targetBone.rotation;
|
||
}
|
||
else
|
||
{
|
||
rotationOffsets.Add(bone, Quaternion.Inverse(sourceBone.rotation) * targetBone.rotation);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 현재 설정을 JSON 파일로 저장합니다.
|
||
/// </summary>
|
||
public void SaveSettings()
|
||
{
|
||
if (targetAnimator == null)
|
||
{
|
||
Debug.LogWarning("타겟 아바타가 설정되지 않았습니다.");
|
||
return;
|
||
}
|
||
|
||
var offsetCache = new List<RotationOffsetData>();
|
||
foreach (var kvp in rotationOffsets)
|
||
{
|
||
offsetCache.Add(new RotationOffsetData((int)kvp.Key, kvp.Value));
|
||
}
|
||
|
||
// LimbWeightController에서 의자 높이 오프셋 가져오기
|
||
float chairOffset = limbWeight.chairSeatHeightOffset;
|
||
|
||
var settings = new RetargetingSettings
|
||
{
|
||
footFrontBackOffset = footFrontBackOffset,
|
||
footInOutOffset = footInOutOffset,
|
||
floorHeight = floorHeight,
|
||
rotationOffsetCache = offsetCache,
|
||
initialHipsHeight = initialHipsHeight,
|
||
avatarScale = avatarScale,
|
||
chairSeatHeightOffset = chairOffset,
|
||
headRotationOffsetX = headRotationOffsetX,
|
||
headRotationOffsetY = headRotationOffsetY,
|
||
headRotationOffsetZ = headRotationOffsetZ,
|
||
headScale = headScale,
|
||
};
|
||
|
||
string json = JsonUtility.ToJson(settings, true);
|
||
string filePath = GetSettingsFilePath();
|
||
|
||
try
|
||
{
|
||
File.WriteAllText(filePath, json);
|
||
//너무 자주 출력되어서 주석처리
|
||
//Debug.Log($"설정이 저장되었습니다: {filePath}");
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.LogError($"설정 저장 중 오류 발생: {e.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// JSON 파일에서 설정을 로드합니다.
|
||
/// </summary>
|
||
public void LoadSettings()
|
||
{
|
||
if (targetAnimator == null)
|
||
{
|
||
Debug.LogWarning("타겟 아바타가 설정되지 않았습니다.");
|
||
return;
|
||
}
|
||
|
||
string filePath = GetSettingsFilePath();
|
||
|
||
if (!File.Exists(filePath))
|
||
{
|
||
Debug.LogWarning($"저장된 설정 파일이 없습니다: {filePath}");
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
string json = File.ReadAllText(filePath);
|
||
var settings = JsonUtility.FromJson<RetargetingSettings>(json);
|
||
|
||
// 설정 적용
|
||
footFrontBackOffset = settings.footFrontBackOffset;
|
||
footInOutOffset = settings.footInOutOffset;
|
||
floorHeight = settings.floorHeight;
|
||
initialHipsHeight = settings.initialHipsHeight;
|
||
avatarScale = settings.avatarScale;
|
||
previousScale = avatarScale;
|
||
headScale = settings.headScale;
|
||
|
||
// LimbWeightController에 의자 높이 오프셋 적용
|
||
limbWeight.chairSeatHeightOffset = settings.chairSeatHeightOffset;
|
||
|
||
// 머리 회전 오프셋 로드
|
||
headRotationOffsetX = settings.headRotationOffsetX;
|
||
headRotationOffsetY = settings.headRotationOffsetY;
|
||
headRotationOffsetZ = settings.headRotationOffsetZ;
|
||
|
||
//너무 자주 출력되어서 주석처리
|
||
//Debug.Log($"설정을 로드했습니다: {filePath}");
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.LogError($"설정 로드 중 오류 발생: {e.Message}");
|
||
}
|
||
}
|
||
|
||
private string GetSettingsFilePath()
|
||
{
|
||
// Unity AppData 경로 가져오기
|
||
string appDataPath = Path.Combine(
|
||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||
"Unity",
|
||
Application.companyName,
|
||
Application.productName,
|
||
settingsFolderName
|
||
);
|
||
|
||
// 타겟 아바타 이름으로 파일명 생성
|
||
string fileName = $"{targetAnimator?.gameObject.name}_settings.json";
|
||
|
||
// 설정 폴더가 없으면 생성
|
||
if (!Directory.Exists(appDataPath))
|
||
{
|
||
Directory.CreateDirectory(appDataPath);
|
||
}
|
||
|
||
return Path.Combine(appDataPath, fileName);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 캐시된 회전 오프셋을 로드하거나 새로 계산합니다.
|
||
/// </summary>
|
||
private void InitializeRotationOffsets()
|
||
{
|
||
string filePath = GetSettingsFilePath();
|
||
bool useCache = false;
|
||
|
||
// 캐시된 데이터가 있는지 확인
|
||
if (File.Exists(filePath))
|
||
{
|
||
try
|
||
{
|
||
string json = File.ReadAllText(filePath);
|
||
var settings = JsonUtility.FromJson<RetargetingSettings>(json);
|
||
|
||
if (settings.rotationOffsetCache != null && settings.rotationOffsetCache.Count > 0)
|
||
{
|
||
rotationOffsets.Clear();
|
||
foreach (var offsetData in settings.rotationOffsetCache)
|
||
{
|
||
rotationOffsets.Add((HumanBodyBones)offsetData.boneIndex, offsetData.ToQuaternion());
|
||
}
|
||
useCache = true;
|
||
Debug.Log("캐시된 회전 오프셋을 로드했습니다.");
|
||
}
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.LogWarning($"회전 오프셋 캐시 로드 실패: {e.Message}");
|
||
}
|
||
}
|
||
|
||
// 캐시된된 데이터가 없거나 로드 실패 시 새로 계산
|
||
if (!useCache)
|
||
{
|
||
CalculateRotationOffsets();
|
||
|
||
//너무 자주 출력되어서 주석처리
|
||
//Debug.Log("새로운 회전 오프셋을 계산했습니다.");
|
||
}
|
||
}
|
||
|
||
public void I_PoseCalibration()
|
||
{
|
||
if (targetAnimator == null)
|
||
{
|
||
Debug.LogError("타겟 Animator가 설정되지 않았습니다.");
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
// 손가락 본들의 현재 로컬 회전을 저장 (I-포즈 적용 전)
|
||
// 손가락은 24~54번 본 (LeftThumbProximal ~ RightLittleDistal)
|
||
var savedFingerRotations = new Dictionary<HumanBodyBones, Quaternion>();
|
||
for (int i = 24; i <= 54; i++)
|
||
{
|
||
HumanBodyBones bone = (HumanBodyBones)i;
|
||
Transform fingerBone = targetAnimator.GetBoneTransform(bone);
|
||
if (fingerBone != null)
|
||
{
|
||
savedFingerRotations[bone] = fingerBone.localRotation;
|
||
}
|
||
}
|
||
|
||
// 타겟 아바타를 I-포즈로 설정 (몸체만 필요하지만 전체가 변경됨)
|
||
SetIPose(targetAnimator);
|
||
|
||
// 손가락 본들의 로컬 회전을 복원 (I-포즈 적용 후)
|
||
foreach (var kvp in savedFingerRotations)
|
||
{
|
||
Transform fingerBone = targetAnimator.GetBoneTransform(kvp.Key);
|
||
if (fingerBone != null)
|
||
{
|
||
fingerBone.localRotation = kvp.Value;
|
||
}
|
||
}
|
||
|
||
// 몸체 본들의 오프셋만 계산 (0~23번)
|
||
CalculateRotationOffsets(true);
|
||
|
||
// 손가락 본들의 오프셋 별도 계산 (24~54번)
|
||
// 손가락은 복원된 상태에서 오프셋 계산 (Rotation 모드용)
|
||
CalculateFingerRotationOffsets();
|
||
|
||
SaveSettings(); // 캘리브레이션 후 설정 저장
|
||
Debug.Log("I-포즈 캘리브레이션이 완료되었습니다. (손가락 포즈 유지)");
|
||
}
|
||
catch (System.Exception e)
|
||
{
|
||
Debug.LogError($"I-포즈 캘리브레이션 중 오류가 발생했습니다: {e.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 손가락 본들의 회전 오프셋을 계산합니다.
|
||
/// I-포즈 캘리브레이션 시 손가락 포즈가 복원된 상태에서 호출되어야 합니다.
|
||
/// </summary>
|
||
private void CalculateFingerRotationOffsets()
|
||
{
|
||
if (optitrackSource == null || targetAnimator == null)
|
||
return;
|
||
|
||
// 손가락 본들 (24~54)의 오프셋 계산
|
||
for (int i = 24; i <= 54; i++)
|
||
{
|
||
HumanBodyBones bone = (HumanBodyBones)i;
|
||
Transform sourceBone = GetSourceBoneTransform(bone);
|
||
Transform targetBone = targetAnimator.GetBoneTransform(bone);
|
||
|
||
if (sourceBone != null && targetBone != null)
|
||
{
|
||
Quaternion offset = Quaternion.Inverse(sourceBone.rotation) * targetBone.rotation;
|
||
|
||
if (rotationOffsets.ContainsKey(bone))
|
||
{
|
||
rotationOffsets[bone] = offset;
|
||
}
|
||
else
|
||
{
|
||
rotationOffsets.Add(bone, offset);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private void InitializeIKJoints()
|
||
{
|
||
// IK 루트 찾기
|
||
Transform sourceIKRoot = optitrackSource.transform.Find("IK");
|
||
if (sourceIKRoot == null)
|
||
{
|
||
Debug.LogError("소스 아바타에서 IK 루트를 찾을 수 없습니다.");
|
||
return;
|
||
}
|
||
|
||
// IK 조인트 캐싱 (팔꿈치 bendGoal 용)
|
||
sourceIKJoints = new IKJoints
|
||
{
|
||
leftLowerArm = sourceIKRoot.Find("LeftLowerArm"),
|
||
rightLowerArm = sourceIKRoot.Find("RightLowerArm")
|
||
};
|
||
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 업데이트
|
||
|
||
/// <summary>
|
||
/// 매 프레임마다 원본 아바타의 포즈와 손가락 움직임을 대상 아바타에 리타게팅하고, IK 타타겟을 업데이트합니다.
|
||
/// </summary>
|
||
void Update()
|
||
{
|
||
isTargetPoseCachedThisFrame = false;
|
||
|
||
CopyPoseToTarget();
|
||
|
||
UpdateIKTargets();
|
||
|
||
// 손가락은 SyncBoneRotations에서 함께 처리됨 (lastBoneIndex=55)
|
||
|
||
fingerShaped.OnUpdate();
|
||
limbWeight.OnUpdate();
|
||
ikSolver.OnUpdate();
|
||
|
||
if (!Mathf.Approximately(previousScale, avatarScale))
|
||
{
|
||
ApplyScale();
|
||
previousScale = avatarScale;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// LateUpdate에서 머리 회전 오프셋과 머리 크기를 적용합니다 (IK 이후 실행).
|
||
/// </summary>
|
||
void LateUpdate()
|
||
{
|
||
ApplyHeadRotationOffset();
|
||
ApplyHeadScale();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 머리 크기를 적용합니다.
|
||
/// 애니메이션이 매 프레임 스케일을 리셋할 수 있으므로 매 프레임 적용합니다.
|
||
/// </summary>
|
||
private void ApplyHeadScale()
|
||
{
|
||
if (headBone == null)
|
||
{
|
||
// headBone이 없으면 다시 찾기 시도
|
||
if (targetAnimator != null)
|
||
{
|
||
headBone = targetAnimator.GetBoneTransform(HumanBodyBones.Head);
|
||
if (headBone != null)
|
||
{
|
||
originalHeadScale = headBone.localScale;
|
||
Debug.Log($"[HeadScale] 머리 본 재초기화: {headBone.name}");
|
||
}
|
||
}
|
||
if (headBone == null) return;
|
||
}
|
||
|
||
// 매 프레임 스케일 적용 (애니메이션이 리셋할 수 있음)
|
||
headBone.localScale = originalHeadScale * headScale;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 머리 본에 회전 오프셋을 적용합니다. headBone 멤버 캐시 재사용 (ApplyHeadScale과 일관).
|
||
/// </summary>
|
||
private void ApplyHeadRotationOffset()
|
||
{
|
||
if (headBone == null) return;
|
||
|
||
// 오프셋이 모두 0이면 스킵
|
||
if (Mathf.Approximately(headRotationOffsetX, 0f) &&
|
||
Mathf.Approximately(headRotationOffsetY, 0f) &&
|
||
Mathf.Approximately(headRotationOffsetZ, 0f))
|
||
return;
|
||
|
||
// 오프셋 적용 (로컬 회전에 오일러 각도 추가)
|
||
Quaternion offsetRotation = Quaternion.Euler(headRotationOffsetX, headRotationOffsetY, headRotationOffsetZ);
|
||
headBone.localRotation *= offsetRotation;
|
||
}
|
||
|
||
/// <summary>
|
||
/// T-포즈 상태에서 머리의 정면 방향을 캐싱합니다.
|
||
/// 이 값은 정면 캘리브레이션에서 기준점으로 사용됩니다.
|
||
/// </summary>
|
||
private void CacheTPoseHeadDirection()
|
||
{
|
||
if (targetAnimator == null) return;
|
||
|
||
Transform headBone = targetAnimator.GetBoneTransform(HumanBodyBones.Head);
|
||
if (headBone == null) return;
|
||
|
||
// T-포즈 상태에서의 머리 월드 방향 저장
|
||
tPoseHeadForward = headBone.forward;
|
||
tPoseHeadUp = headBone.up;
|
||
|
||
Debug.Log($"[{gameObject.name}] T-포즈 머리 방향 캐싱 완료 - Forward: {tPoseHeadForward}, Up: {tPoseHeadUp}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 정면 캘리브레이션을 실행합니다. (웹 리모컨에서 호출)
|
||
/// </summary>
|
||
public void CalibrateHeadToForward()
|
||
{
|
||
if (targetAnimator == null) return;
|
||
|
||
Transform targetHead = targetAnimator.GetBoneTransform(HumanBodyBones.Head);
|
||
if (targetHead == null) return;
|
||
|
||
if (tPoseHeadForward.sqrMagnitude < 0.001f)
|
||
{
|
||
Debug.LogWarning("T-포즈 머리 방향이 캐싱되지 않았습니다.");
|
||
return;
|
||
}
|
||
|
||
// 1. 기존 오프셋 제거하여 원본 로컬 회전 계산
|
||
Quaternion currentLocalRot = targetHead.localRotation;
|
||
Quaternion prevOffset = Quaternion.Euler(headRotationOffsetX, headRotationOffsetY, headRotationOffsetZ);
|
||
Quaternion baseLocalRot = currentLocalRot * Quaternion.Inverse(prevOffset);
|
||
|
||
// 2. 부모 월드 회전
|
||
Transform headParent = targetHead.parent;
|
||
Quaternion parentWorldRot = headParent != null ? headParent.rotation : Quaternion.identity;
|
||
|
||
// 3. 오프셋 제거된 원본 상태의 월드 회전
|
||
Quaternion baseWorldRot = parentWorldRot * baseLocalRot;
|
||
|
||
// 4. 오프셋 제거된 원본 상태에서의 머리 forward/up 방향 계산
|
||
Vector3 currentHeadForward = baseWorldRot * Vector3.forward;
|
||
Vector3 currentHeadUp = baseWorldRot * Vector3.up;
|
||
|
||
// 5. 현재 머리 forward → T-포즈 forward로의 회전 계산
|
||
Quaternion forwardCorrection = Quaternion.FromToRotation(currentHeadForward, tPoseHeadForward);
|
||
|
||
// 6. forward 보정 후 up 방향도 맞춰야 함
|
||
Vector3 correctedUp = forwardCorrection * currentHeadUp;
|
||
float rollAngle = Vector3.SignedAngle(correctedUp, tPoseHeadUp, tPoseHeadForward);
|
||
Quaternion rollCorrection = Quaternion.AngleAxis(rollAngle, tPoseHeadForward);
|
||
|
||
// 7. 전체 월드 보정 회전
|
||
Quaternion worldCorrection = rollCorrection * forwardCorrection;
|
||
|
||
// 8. 보정된 월드 회전
|
||
Quaternion correctedWorldRot = worldCorrection * baseWorldRot;
|
||
|
||
// 9. 보정된 로컬 회전
|
||
Quaternion correctedLocalRot = Quaternion.Inverse(parentWorldRot) * correctedWorldRot;
|
||
|
||
// 10. 오프셋 계산
|
||
Quaternion offsetQuat = Quaternion.Inverse(baseLocalRot) * correctedLocalRot;
|
||
|
||
// 11. 오일러로 변환
|
||
Vector3 euler = offsetQuat.eulerAngles;
|
||
if (euler.x > 180f) euler.x -= 360f;
|
||
if (euler.y > 180f) euler.y -= 360f;
|
||
if (euler.z > 180f) euler.z -= 360f;
|
||
|
||
// 12. 적용
|
||
headRotationOffsetX = Mathf.Clamp(euler.x, -180f, 180f);
|
||
headRotationOffsetY = Mathf.Clamp(euler.y, -180f, 180f);
|
||
headRotationOffsetZ = Mathf.Clamp(euler.z, -180f, 180f);
|
||
|
||
Debug.Log($"[{gameObject.name}] 정면 캘리브레이션 완료 - Offset X: {euler.x:F1}°, Y: {euler.y:F1}°, Z: {euler.z:F1}°");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 회전값을 사용하여 손가락 포즈를 복제합니다.
|
||
/// </summary>
|
||
private void CopyFingerPoseByRotation()
|
||
{
|
||
for (int i = 24; i <= 53; i++)
|
||
{
|
||
HumanBodyBones bone = (HumanBodyBones)i;
|
||
Transform sourceBone = GetSourceBoneTransform(bone);
|
||
Transform targetBone = targetAnimator.GetBoneTransform(bone);
|
||
|
||
if (sourceBone != null && targetBone != null)
|
||
{
|
||
Quaternion targetRotation;
|
||
if (rotationOffsets.TryGetValue(bone, out Quaternion offset))
|
||
{
|
||
targetRotation = sourceBone.rotation * offset;
|
||
}
|
||
else
|
||
{
|
||
targetRotation = sourceBone.rotation;
|
||
}
|
||
|
||
targetBone.rotation = targetRotation;
|
||
}
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 포즈 동기화
|
||
|
||
/// <summary>
|
||
/// 원본 아바타의 포즈를 대상 아바타에 오프셋을 적용하여 복사합니다.
|
||
/// 힙 위치와 회전을 동기화하고, 나머지 본의 회전을 오프셋을 적용하여 동기화합니다.
|
||
/// </summary>
|
||
private void CopyPoseToTarget()
|
||
{
|
||
// 힙(루트 본) 동기화
|
||
Transform sourceHips = GetSourceBoneTransform(HumanBodyBones.Hips);
|
||
Transform targetHips = targetAnimator.GetBoneTransform(HumanBodyBones.Hips);
|
||
|
||
if (sourceHips != null && targetHips != null)
|
||
{
|
||
// 1. 힙 회전 먼저 동기화 (회전 오프셋 적용)
|
||
Quaternion finalHipsRotation = sourceHips.rotation;
|
||
if (rotationOffsets.TryGetValue(HumanBodyBones.Hips, out Quaternion hipsOffset))
|
||
{
|
||
finalHipsRotation = sourceHips.rotation * hipsOffset;
|
||
targetHips.rotation = finalHipsRotation;
|
||
}
|
||
|
||
// 2. 다리 길이 차 + Hips↔UpperLeg 갭으로 힙 상하 자동 보정 (캐릭터 Up 방향)
|
||
Vector3 characterUp = finalHipsRotation * localAxisForWorldUp;
|
||
float autoHipsOffsetY = ComputeAutoHipsOffsetY();
|
||
Vector3 characterOffset = characterUp * (autoHipsOffsetY * HipsWeightOffset);
|
||
|
||
// 3. 힙 위치 동기화 + 자동 보정 오프셋 적용
|
||
Vector3 adjustedPosition = sourceHips.position + characterOffset;
|
||
|
||
// 4. 바닥 높이 추가 (월드 Y축 - 바닥은 항상 월드 기준)
|
||
adjustedPosition.y += floorHeight;
|
||
|
||
// 5. 의자 좌석 높이 오프셋 추가 (월드 Y축 - 로컬 보정과 별개)
|
||
adjustedPosition.y += ChairSeatHeightOffset;
|
||
|
||
targetHips.position = adjustedPosition;
|
||
}
|
||
|
||
// 스파인/넥 분배를 먼저 실행하여 부모 계층 확정 후 자식 본(Head/Shoulder/Arm) 세팅
|
||
ApplyOptiTrackSpineNeckDistribution();
|
||
|
||
// 힙을 제외한 본들의 회전 동기화
|
||
SyncBoneRotations(skipBone: HumanBodyBones.Hips);
|
||
}
|
||
|
||
// 자동 힙 보정 캐시 (다리 본 길이는 스케일 변경 시에만 갱신됨)
|
||
private float cachedAutoHipsOffsetY = 0f;
|
||
private bool autoHipsOffsetCacheValid = false;
|
||
|
||
/// <summary>
|
||
/// 매 프레임 호출되는 자동 힙 보정값. 본 길이는 변하지 않으므로 캐시된 값을 반환.
|
||
/// avatarScale 변경 시 ApplyScale → RefreshAutoHipsOffsetCache 로 자동 갱신.
|
||
/// </summary>
|
||
private float ComputeAutoHipsOffsetY()
|
||
{
|
||
if (!autoHipsOffsetCacheValid) RefreshAutoHipsOffsetCache();
|
||
return cachedAutoHipsOffsetY;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 자동 힙 보정 캐시를 재계산합니다 (Initialize / avatarScale 변경 시 호출).
|
||
/// = (타겟 다리길이 - 소스 다리길이) + (타겟 Hips↔UpperLeg 갭) × avatarScale
|
||
/// </summary>
|
||
private void RefreshAutoHipsOffsetCache()
|
||
{
|
||
cachedAutoHipsOffsetY = 0f;
|
||
autoHipsOffsetCacheValid = true;
|
||
|
||
if (optitrackSource == null || targetAnimator == null) return;
|
||
|
||
Transform sUp = optitrackSource.GetBoneTransform(HumanBodyBones.LeftUpperLeg);
|
||
Transform sLo = optitrackSource.GetBoneTransform(HumanBodyBones.LeftLowerLeg);
|
||
Transform sFt = optitrackSource.GetBoneTransform(HumanBodyBones.LeftFoot);
|
||
Transform tUp = targetAnimator.GetBoneTransform(HumanBodyBones.LeftUpperLeg);
|
||
Transform tLo = targetAnimator.GetBoneTransform(HumanBodyBones.LeftLowerLeg);
|
||
Transform tFt = targetAnimator.GetBoneTransform(HumanBodyBones.LeftFoot);
|
||
Transform tHi = targetAnimator.GetBoneTransform(HumanBodyBones.Hips);
|
||
|
||
if (sUp == null || sLo == null || sFt == null
|
||
|| tUp == null || tLo == null || tFt == null || tHi == null)
|
||
return;
|
||
|
||
float sourceLeg = Vector3.Distance(sUp.position, sLo.position) + Vector3.Distance(sLo.position, sFt.position);
|
||
float targetLeg = Vector3.Distance(tUp.position, tLo.position) + Vector3.Distance(tLo.position, tFt.position);
|
||
if (sourceLeg < 0.01f || targetLeg < 0.01f) return;
|
||
|
||
float hipsToLegGap = tHi.position.y - tUp.position.y;
|
||
cachedAutoHipsOffsetY = (targetLeg - sourceLeg) + hipsToLegGap * avatarScale;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 힙을 제외한 모든 본의 회전을 오프셋을 적용하여 동기화합니다.
|
||
/// </summary>
|
||
/// <param name="skipBone">동기화에서 제외할 본</param>
|
||
private void SyncBoneRotations(HumanBodyBones skipBone)
|
||
{
|
||
for (int i = 0; i < lastBoneIndex; i++)
|
||
{
|
||
HumanBodyBones bone = (HumanBodyBones)i;
|
||
|
||
if (bone == skipBone)
|
||
continue;
|
||
|
||
if (useOptiTrackSpineDistribution && IsOptiTrackDistributedBone(bone))
|
||
continue;
|
||
|
||
Transform sourceBone = GetSourceBoneTransform(bone);
|
||
Transform targetBone = targetAnimator.GetBoneTransform(bone);
|
||
|
||
if (sourceBone != null && targetBone != null)
|
||
{
|
||
Quaternion targetRotation;
|
||
if (rotationOffsets.TryGetValue(bone, out Quaternion offset))
|
||
{
|
||
targetRotation = sourceBone.rotation * offset;
|
||
}
|
||
else
|
||
{
|
||
targetRotation = sourceBone.rotation;
|
||
}
|
||
|
||
targetBone.rotation = targetRotation;
|
||
}
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region OptiTrack 스파인/넥 분배
|
||
|
||
/// <summary>
|
||
/// OptiTrack 소스 감지 및 스파인/넥 분배 초기화
|
||
/// </summary>
|
||
private void InitializeOptiTrackSpineDistribution()
|
||
{
|
||
if (optitrackSource == null || targetAnimator == null)
|
||
{
|
||
useOptiTrackSpineDistribution = false;
|
||
return;
|
||
}
|
||
|
||
// 소스 스파인/넥 체인 가져오기
|
||
sourceSpineChain = optitrackSource.GetSpineChainTransforms();
|
||
sourceNeckChain = optitrackSource.GetNeckChainTransforms();
|
||
|
||
// 타겟 Humanoid 스파인 본들 수집 (존재하는 본만)
|
||
targetSpineBones = new List<Transform>();
|
||
HumanBodyBones[] spineEnums = { HumanBodyBones.Spine, HumanBodyBones.Chest, HumanBodyBones.UpperChest };
|
||
foreach (var bone in spineEnums)
|
||
{
|
||
Transform t = targetAnimator.GetBoneTransform(bone);
|
||
if (t != null)
|
||
targetSpineBones.Add(t);
|
||
}
|
||
|
||
// 타겟 Humanoid 넥 본 수집
|
||
targetNeckBones = new List<Transform>();
|
||
HumanBodyBones[] neckEnums = { HumanBodyBones.Neck };
|
||
foreach (var bone in neckEnums)
|
||
{
|
||
Transform t = targetAnimator.GetBoneTransform(bone);
|
||
if (t != null)
|
||
targetNeckBones.Add(t);
|
||
}
|
||
|
||
useOptiTrackSpineDistribution = sourceSpineChain.Count > 0 && targetSpineBones.Count > 0;
|
||
|
||
if (!useOptiTrackSpineDistribution) return;
|
||
|
||
// ── T-포즈 기준으로 가상 본 오프셋 계산 (OffsetTransfer 패턴) ──
|
||
// 소스와 타겟 모두 T-포즈 상태에서 호출되어야 함
|
||
spineOffsets = CalculateVirtualBoneOffsets(sourceSpineChain, targetSpineBones);
|
||
neckOffsets = CalculateVirtualBoneOffsets(sourceNeckChain, targetNeckBones);
|
||
|
||
Debug.Log($"[Retargeting] OptiTrack 스파인 분배 활성화: " +
|
||
$"소스 스파인 {sourceSpineChain.Count}본 → 타겟 {targetSpineBones.Count}본, " +
|
||
$"소스 넥 {sourceNeckChain.Count}본 → 타겟 {targetNeckBones.Count}본");
|
||
}
|
||
|
||
/// <summary>
|
||
/// T-포즈 기준으로 가상 본의 월드 회전과 타겟 본의 월드 회전 사이 오프셋을 계산.
|
||
/// offset = Inv(가상본 월드회전) * 타겟본 월드회전
|
||
/// </summary>
|
||
private List<Quaternion> CalculateVirtualBoneOffsets(List<Transform> sourceChain, List<Transform> targetBones)
|
||
{
|
||
var offsets = new List<Quaternion>();
|
||
int srcCount = sourceChain.Count;
|
||
int tgtCount = targetBones.Count;
|
||
|
||
for (int t = 0; t < tgtCount; t++)
|
||
{
|
||
// 이 타겟 본이 담당할 소스 범위의 마지막 본의 월드 회전을 가상 본 회전으로 사용
|
||
// 예) 5본→2본: 가상본0 = sourceChain[2], 가상본1 = sourceChain[4]
|
||
int lastSrcIdx = Mathf.Min(Mathf.RoundToInt((float)(t + 1) / tgtCount * srcCount) - 1, srcCount - 1);
|
||
Quaternion virtualBoneWorldRot = sourceChain[lastSrcIdx].rotation;
|
||
Quaternion targetWorldRot = targetBones[t].rotation;
|
||
|
||
// OffsetTransfer 패턴: offset = Inv(source) * target
|
||
offsets.Add(Quaternion.Inverse(virtualBoneWorldRot) * targetWorldRot);
|
||
}
|
||
|
||
return offsets;
|
||
}
|
||
|
||
/// <summary>
|
||
/// OptiTrack 분배 대상 본인지 확인 (스파인 + 넥)
|
||
/// </summary>
|
||
private bool IsOptiTrackDistributedBone(HumanBodyBones bone)
|
||
{
|
||
return bone == HumanBodyBones.Spine ||
|
||
bone == HumanBodyBones.Chest ||
|
||
bone == HumanBodyBones.UpperChest ||
|
||
bone == HumanBodyBones.Neck;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 소스 스파인/넥 체인을 가상 본으로 그룹핑하여 월드 회전 + 오프셋으로 타겟에 적용
|
||
/// </summary>
|
||
private void ApplyOptiTrackSpineNeckDistribution()
|
||
{
|
||
if (!useOptiTrackSpineDistribution) return;
|
||
|
||
// 스파인 분배
|
||
if (sourceSpineChain.Count > 0 && targetSpineBones.Count > 0 && spineOffsets != null)
|
||
{
|
||
ApplyVirtualBoneRotations(sourceSpineChain, targetSpineBones, spineOffsets);
|
||
}
|
||
|
||
// 넥 분배
|
||
if (sourceNeckChain.Count > 0 && targetNeckBones.Count > 0 && neckOffsets != null)
|
||
{
|
||
ApplyVirtualBoneRotations(sourceNeckChain, targetNeckBones, neckOffsets);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 소스 체인을 가상 본으로 그룹핑 → 가상 본 월드 회전 계산 → 오프셋 적용하여 타겟에 세팅
|
||
/// </summary>
|
||
private void ApplyVirtualBoneRotations(List<Transform> sourceChain, List<Transform> targetBones, List<Quaternion> offsets)
|
||
{
|
||
int srcCount = sourceChain.Count;
|
||
int tgtCount = targetBones.Count;
|
||
|
||
for (int t = 0; t < tgtCount; t++)
|
||
{
|
||
// 가상 본 = 이 타겟이 담당하는 소스 그룹의 마지막 본 월드 회전
|
||
int lastSrcIdx = Mathf.Min(Mathf.RoundToInt((float)(t + 1) / tgtCount * srcCount) - 1, srcCount - 1);
|
||
Quaternion virtualBoneWorldRot = sourceChain[lastSrcIdx].rotation;
|
||
|
||
// OffsetTransfer 패턴: 타겟.rotation = 가상본월드회전 * offset
|
||
targetBones[t].rotation = virtualBoneWorldRot * offsets[t];
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region IK 타겟 생성 및 관관리
|
||
|
||
/// <summary>
|
||
/// IK 타겟(끝과 중간)을 생성하고 TwoBoneIKSolver 컴포넌트를 설정합니다.
|
||
/// </summary>
|
||
private void CreateIKTargets()
|
||
{
|
||
ikSolver.animator = targetAnimator;
|
||
|
||
// IK 타겟들을 담을 부모 오브젝트 생성
|
||
GameObject ikTargetsParent = new GameObject("IK Targets");
|
||
ikTargetsParent.transform.parent = targetAnimator.transform;
|
||
ikTargetsParent.transform.localPosition = Vector3.zero;
|
||
ikTargetsParent.transform.localRotation = Quaternion.identity;
|
||
|
||
// 팔 타겟 (Hand + BendGoal)
|
||
GameObject leftHand = CreateIKTargetObject("LeftHand", targetAnimator.GetBoneTransform(HumanBodyBones.LeftHand), ikTargetsParent);
|
||
ikSolver.leftArm.target = leftHand.transform;
|
||
GameObject rightHand = CreateIKTargetObject("RightHand", targetAnimator.GetBoneTransform(HumanBodyBones.RightHand), ikTargetsParent);
|
||
ikSolver.rightArm.target = rightHand.transform;
|
||
|
||
GameObject leftArmGoal = CreateIKTargetObject("LeftArmGoal", targetAnimator.GetBoneTransform(HumanBodyBones.LeftLowerArm), ikTargetsParent);
|
||
ikSolver.leftArm.bendGoal = leftArmGoal.transform;
|
||
GameObject rightArmGoal = CreateIKTargetObject("RightArmGoal", targetAnimator.GetBoneTransform(HumanBodyBones.RightLowerArm), ikTargetsParent);
|
||
ikSolver.rightArm.bendGoal = rightArmGoal.transform;
|
||
|
||
// 다리 타겟 (Foot + BendGoal)
|
||
GameObject leftFoot = CreateIKTargetObject("LeftFoot", targetAnimator.GetBoneTransform(HumanBodyBones.LeftFoot), ikTargetsParent);
|
||
ikSolver.leftLeg.target = leftFoot.transform;
|
||
GameObject rightFoot = CreateIKTargetObject("RightFoot", targetAnimator.GetBoneTransform(HumanBodyBones.RightFoot), ikTargetsParent);
|
||
ikSolver.rightLeg.target = rightFoot.transform;
|
||
|
||
GameObject leftLegGoal = CreateIKTargetObject("LeftLegGoal", targetAnimator.GetBoneTransform(HumanBodyBones.LeftLowerLeg), ikTargetsParent);
|
||
ikSolver.leftLeg.bendGoal = leftLegGoal.transform;
|
||
GameObject rightLegGoal = CreateIKTargetObject("RightLegGoal", targetAnimator.GetBoneTransform(HumanBodyBones.RightLowerLeg), ikTargetsParent);
|
||
ikSolver.rightLeg.bendGoal = rightLegGoal.transform;
|
||
|
||
// TwoBoneIKSolver 본 캐싱 초기화
|
||
ikSolver.Initialize(targetAnimator);
|
||
|
||
// IK에 소스 본 참조 설정 (소스 관절 위치 기반 IK용)
|
||
if (optitrackSource != null)
|
||
{
|
||
ikSolver.leftLeg.sourceUpper = GetSourceBoneTransform(HumanBodyBones.LeftUpperLeg);
|
||
ikSolver.leftLeg.sourceLower = GetSourceBoneTransform(HumanBodyBones.LeftLowerLeg);
|
||
ikSolver.leftLeg.sourceEnd = GetSourceBoneTransform(HumanBodyBones.LeftFoot);
|
||
|
||
ikSolver.rightLeg.sourceUpper = GetSourceBoneTransform(HumanBodyBones.RightUpperLeg);
|
||
ikSolver.rightLeg.sourceLower = GetSourceBoneTransform(HumanBodyBones.RightLowerLeg);
|
||
ikSolver.rightLeg.sourceEnd = GetSourceBoneTransform(HumanBodyBones.RightFoot);
|
||
|
||
// 팔은 소스 참조 없이 bendGoal 기반 cosine law 사용
|
||
// (180° 특이점/역관절 이슈가 없고, bendGoal 힌트 방향이 더 정확)
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// IK 타겟 오브젝트를 생성하고 기본 Gizmo 설정을 적용합니다.
|
||
/// </summary>
|
||
/// <param name="name">타겟 오브젝트의 이름</param>
|
||
/// <param name="bone">타겟 오브젝트를 부모로 설정할 본</param>
|
||
/// <returns>생성된 타겟 게임 오브젝트</returns>
|
||
private GameObject CreateIKTargetObject(string name, Transform bone, GameObject parent)
|
||
{
|
||
GameObject target = new GameObject(name);
|
||
target.transform.position = bone.position;
|
||
target.transform.rotation = bone.rotation;
|
||
|
||
// Gizmo 시각화를 위한 컴포넌트 추가
|
||
IKTargetGizmo gizmo = target.AddComponent<IKTargetGizmo>();
|
||
gizmo.gizmoSize = 0.05f;
|
||
gizmo.gizmoColor = UnityEngine.Color.yellow;
|
||
|
||
target.transform.parent = parent.transform;
|
||
|
||
return target;
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region IK 중간 타겟 업데이트
|
||
|
||
/// <summary>
|
||
/// 매 프레임마다 IK 중간 타겟의 위치를 대상 아바타의 본 위치로 업데이트합니다.
|
||
/// </summary>
|
||
private void UpdateIKTargets()
|
||
{
|
||
if (targetAnimator == null) return;
|
||
|
||
// 손과 발 타겟 업데이트
|
||
UpdateEndTarget(ikSolver.leftArm.target, HumanBodyBones.LeftHand);
|
||
UpdateEndTarget(ikSolver.rightArm.target, HumanBodyBones.RightHand);
|
||
UpdateEndTarget(ikSolver.leftLeg.target, HumanBodyBones.LeftFoot);
|
||
UpdateEndTarget(ikSolver.rightLeg.target, HumanBodyBones.RightFoot);
|
||
|
||
// 팔 bendGoal만 갱신 (다리는 ComputeKneePosFromSource가 소스 무릎으로 풀이하므로 bendGoal 사용 안 함)
|
||
updatejointTarget(ikSolver.leftArm.bendGoal, HumanBodyBones.LeftLowerArm);
|
||
updatejointTarget(ikSolver.rightArm.bendGoal, HumanBodyBones.RightLowerArm);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 손과 발 타겟의 위치와 회전을 업데이트합니다.
|
||
/// </summary>
|
||
private void UpdateEndTarget(Transform endTarget, HumanBodyBones endBone)
|
||
{
|
||
if (endTarget == null) return;
|
||
|
||
Transform sourceBone = GetSourceBoneTransform(endBone);
|
||
Transform targetBone = targetAnimator.GetBoneTransform(endBone);
|
||
|
||
if (sourceBone != null && targetBone != null)
|
||
{
|
||
// 1€ 필터 적용 전 raw 위치 사용 (접지력 보존)
|
||
// raw가 없으면 필터된 Transform.position fallback
|
||
// 1€ 필터 적용 전 raw 위치/회전 사용 (접지력 보존)
|
||
Vector3 targetPosition;
|
||
if (optitrackSource != null && optitrackSource.TryGetRawWorldPosition(endBone, out Vector3 rawPos))
|
||
targetPosition = rawPos;
|
||
else
|
||
targetPosition = sourceBone.position;
|
||
|
||
// raw 회전 + 리타게팅 오프셋 적용 (필터 스무딩 없는 회전)
|
||
Quaternion targetRotation;
|
||
if (optitrackSource != null
|
||
&& optitrackSource.TryGetRawWorldRotation(endBone, out Quaternion rawRot)
|
||
&& rotationOffsets.TryGetValue(endBone, out Quaternion endOffset))
|
||
{
|
||
targetRotation = rawRot * endOffset;
|
||
}
|
||
else
|
||
{
|
||
targetRotation = targetBone.rotation;
|
||
}
|
||
|
||
// 발 본인 경우 오프셋 적용
|
||
if (endBone == HumanBodyBones.LeftFoot || endBone == HumanBodyBones.RightFoot)
|
||
{
|
||
// 바닥 높이 적용
|
||
targetPosition.y += floorHeight;
|
||
|
||
// 발 본의 로컬 좌표계 기준으로 오프셋 적용
|
||
Vector3 localOffset = Vector3.zero;
|
||
localOffset.z = footFrontBackOffset;
|
||
|
||
if (endBone == HumanBodyBones.LeftFoot)
|
||
localOffset.x = footInOutOffset;
|
||
else
|
||
localOffset.x = -footInOutOffset;
|
||
|
||
targetPosition += sourceBone.TransformDirection(localOffset);
|
||
}
|
||
|
||
endTarget.position = targetPosition;
|
||
endTarget.rotation = targetRotation;
|
||
}
|
||
}
|
||
|
||
private void updatejointTarget(Transform target, HumanBodyBones bone)
|
||
{
|
||
Vector3 targetBone = targetAnimator.GetBoneTransform(bone).position;
|
||
Vector3 sourceIKpoint;
|
||
float zOffset = 0f;
|
||
|
||
// bone의 이름에 따라 적절한 IK 조인트 오프셋 선택
|
||
switch (bone)
|
||
{
|
||
case HumanBodyBones.LeftLowerArm:
|
||
case HumanBodyBones.RightLowerArm:
|
||
sourceIKpoint = bone == HumanBodyBones.LeftLowerArm ?
|
||
sourceIKJoints.leftLowerArm.position :
|
||
sourceIKJoints.rightLowerArm.position;
|
||
zOffset = 0.1f; // 팔꿈치는 뒤로 고정
|
||
break;
|
||
default:
|
||
Debug.LogError($"Unsupported bone type: {bone}");
|
||
return;
|
||
}
|
||
|
||
Quaternion LookatIK = Quaternion.LookRotation(sourceIKpoint - targetBone, Vector3.up);
|
||
|
||
target.position = targetBone;
|
||
target.rotation = LookatIK;
|
||
|
||
// LookatIK 기준으로 z축 오프셋 적용 (팔꿈치 뒤쪽)
|
||
target.position += LookatIK * new Vector3(0f, 0f, zOffset);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region T-포즈 설정
|
||
|
||
/// <summary>
|
||
/// 지정된 Animator의 포즈를 T-포즈로 설정합니다.
|
||
/// </summary>
|
||
/// <param name="animator">T-포즈를 설정할 Animator</param>
|
||
public void SetTPose(Animator animator)
|
||
{
|
||
if (animator == null || animator.avatar == null)
|
||
return;
|
||
|
||
Avatar avatar = animator.avatar;
|
||
Transform transform = animator.transform;
|
||
|
||
// HumanPoseClip에 저장된 T-포즈 데이터를 로드하여 적용
|
||
var humanPoseClip = Resources.Load<HumanPoseClip>(HumanPoseClip.TPoseResourcePath);
|
||
if (humanPoseClip != null)
|
||
{
|
||
var pose = humanPoseClip.GetPose();
|
||
HumanPoseTransfer.SetPose(avatar, transform, pose);
|
||
}
|
||
else
|
||
{
|
||
Debug.LogWarning("T-Pose 데이터가 존재하지 않습니다.");
|
||
}
|
||
}
|
||
|
||
public static void SetIPose(Animator animator)
|
||
{
|
||
if (animator == null || animator.avatar == null)
|
||
return;
|
||
|
||
Avatar avatar = animator.avatar;
|
||
Transform transform = animator.transform;
|
||
|
||
// HumanPoseClip에 저장된 T-포즈 데이터를 로드하여 적용
|
||
var humanPoseClip = Resources.Load<HumanPoseClip>(HumanPoseClip.IPoseResourcePath);
|
||
if (humanPoseClip != null)
|
||
{
|
||
var pose = humanPoseClip.GetPose();
|
||
HumanPoseTransfer.SetPose(avatar, transform, pose);
|
||
}
|
||
else
|
||
{
|
||
Debug.LogWarning("I-Pose 데이터가 존재하지 않습니다.");
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 파괴 시 처리
|
||
|
||
/// <summary>
|
||
/// 오브브젝트가 파괴될 때 HumanPoseHandler를 정리합니다.
|
||
/// </summary>
|
||
void OnDestroy()
|
||
{
|
||
targetPoseHandler?.Dispose();
|
||
fingerShaped.Cleanup();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Unity가 종료될 때 호출되되는 메서드
|
||
/// </summary>
|
||
private void OnApplicationQuit()
|
||
{
|
||
SaveSettings();
|
||
}
|
||
|
||
#endregion
|
||
|
||
/// <summary>
|
||
/// 캐시된 설정 파일이 존재하는지 확인합니다.
|
||
/// </summary>
|
||
public bool HasCachedSettings()
|
||
{
|
||
if (targetAnimator == null) return false;
|
||
string filePath = GetSettingsFilePath();
|
||
return File.Exists(filePath);
|
||
}
|
||
|
||
public void ResetPoseAndCache()
|
||
{
|
||
// 캐시 파일 삭제
|
||
string filePath = GetSettingsFilePath();
|
||
if (File.Exists(filePath))
|
||
{
|
||
try
|
||
{
|
||
File.Delete(filePath);
|
||
Debug.Log("캐시 파일이 삭제되었습니다.");
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.LogError($"캐시 파일 삭제 중 오류 발생: {e.Message}");
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (!Application.isPlaying)
|
||
{
|
||
Debug.LogWarning("이 기능은 실행 중에만 사용할 수 있습니다.");
|
||
return;
|
||
}
|
||
|
||
// 포즈 복원
|
||
if (optitrackSource != null)
|
||
optitrackSource.RestoreRestPose();
|
||
|
||
SetTPose(targetAnimator);
|
||
|
||
// HumanPoseHandler 초기화
|
||
InitializeHumanPoseHandlers();
|
||
|
||
// 회전 오프셋 다시 계산
|
||
CalculateRotationOffsets();
|
||
|
||
Debug.Log("포즈와 회전 오프셋이 재설정되었습니다.");
|
||
}
|
||
|
||
// 크기 조정 대상 오브젝트 캐싱 메서드
|
||
private void CacheScalableObjects()
|
||
{
|
||
scalableObjects.Clear();
|
||
originalScales.Clear();
|
||
originalPositions.Clear();
|
||
|
||
Transform parentTransform = optitrackSource.transform.parent;
|
||
|
||
if (parentTransform != null)
|
||
{
|
||
for (int i = 0; i < parentTransform.childCount; i++)
|
||
{
|
||
Transform child = parentTransform.GetChild(i);
|
||
// optitrackSource를 제외한 모든 자식 오브젝트 추가
|
||
if (child != optitrackSource.transform)
|
||
{
|
||
scalableObjects.Add(child);
|
||
// 초기 스케일과 위치 저장
|
||
originalScales[child] = child.localScale;
|
||
originalPositions[child] = child.position - parentTransform.position;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 스케일 적용 메서드
|
||
private void ApplyScale()
|
||
{
|
||
foreach (Transform obj in scalableObjects)
|
||
{
|
||
if (obj != null && obj.parent != null)
|
||
{
|
||
// 원본 스케일을 기준으로 새로운 스케일 계산
|
||
if (originalScales.TryGetValue(obj, out Vector3 originalScale))
|
||
{
|
||
obj.localScale = originalScale * avatarScale;
|
||
}
|
||
|
||
// 원본 위치를 기준으로 새로운 위치 계산
|
||
if (originalPositions.TryGetValue(obj, out Vector3 originalOffset))
|
||
{
|
||
obj.position = obj.parent.position + (originalOffset * avatarScale);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 스케일 변경으로 본 길이가 바뀌었으니 IK 캐시 + 자동 힙 보정 캐시 갱신
|
||
ikSolver?.RefreshLimbLengths();
|
||
RefreshAutoHipsOffsetCache();
|
||
}
|
||
|
||
// 리셋 기능 추가
|
||
public void ResetScale()
|
||
{
|
||
avatarScale = 1f;
|
||
previousScale = 1f;
|
||
|
||
foreach (Transform obj in scalableObjects)
|
||
{
|
||
if (obj != null && originalScales.TryGetValue(obj, out Vector3 originalScale))
|
||
{
|
||
obj.localScale = originalScale;
|
||
|
||
if (obj.parent != null && originalPositions.TryGetValue(obj, out Vector3 originalOffset))
|
||
{
|
||
obj.position = obj.parent.position + originalOffset;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 외부에서 스케일을 설정할 수 있는 public 메서드
|
||
public void SetAvatarScale(float scale)
|
||
{
|
||
avatarScale = Mathf.Clamp(scale, 0.1f, 3f);
|
||
}
|
||
|
||
// 외부에서 머리 크기를 설정할 수 있는 public 메서드
|
||
public void SetHeadScale(float scale)
|
||
{
|
||
headScale = Mathf.Clamp(scale, 0.1f, 3f);
|
||
ApplyHeadScaleImmediate();
|
||
}
|
||
|
||
// 현재 머리 크기를 가져오는 메서드
|
||
public float GetHeadScale()
|
||
{
|
||
return headScale;
|
||
}
|
||
|
||
// 손가락 셰이핑 활성화/비활성화
|
||
public void SetFingerShapedEnabled(bool enabled)
|
||
{
|
||
fingerShaped.enabled = enabled;
|
||
}
|
||
|
||
// 머리 크기 리셋
|
||
public void ResetHeadScale()
|
||
{
|
||
headScale = 1f;
|
||
ApplyHeadScaleImmediate();
|
||
}
|
||
|
||
// 머리 크기 즉시 적용 (headBone이 없으면 찾아서 적용)
|
||
private void ApplyHeadScaleImmediate()
|
||
{
|
||
// headBone이 없으면 찾기
|
||
if (headBone == null && targetAnimator != null)
|
||
{
|
||
headBone = targetAnimator.GetBoneTransform(HumanBodyBones.Head);
|
||
if (headBone != null)
|
||
{
|
||
originalHeadScale = headBone.localScale;
|
||
Debug.Log($"[HeadScale] 머리 본 찾음: {headBone.name}, 원본 스케일: {originalHeadScale}");
|
||
}
|
||
}
|
||
|
||
if (headBone != null)
|
||
{
|
||
headBone.localScale = originalHeadScale * headScale;
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|