Streamingle_URP/Assets/Scripts/KindRetargeting/CustomRetargetingScript.cs
user 4f2ee68cb1 Fix : 캐릭터 root localScale 자동 정규화
- Initialize 시 transform.localScale을 Vector3.one으로 강제
- 비-1 scale prefab의 경우 LogWarning 출력
- avatarScale=1이 모든 캐릭터에서 일관되게 시각적 1배를 의미

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 00:16:08 +09:00

1579 lines
64 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
// root localScale을 1로 정규화 (avatarScale=1이 시각적으로 1배가 되도록)
NormalizeRootScale();
// 크기 조정 대상 오브젝트 캐싱
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("포즈와 회전 오프셋이 재설정되었습니다.");
}
/// <summary>
/// 캐릭터 root의 localScale을 Vector3.one으로 정규화합니다.
/// 이를 통해 avatarScale=1.0이 시각적으로 정확히 1배가 되도록 보장합니다.
/// 비-1 localScale의 캐릭터 크기 의도는 avatarScale 자체로 표현해야 합니다.
/// </summary>
private void NormalizeRootScale()
{
Vector3 currentScale = transform.localScale;
if (currentScale == Vector3.one) return;
Debug.LogWarning(
$"[CustomRetargetingScript] '{gameObject.name}'의 root localScale이 {currentScale}입니다. " +
"avatarScale=1이 시각적으로 1배가 되도록 자동으로 (1,1,1)로 정규화합니다. " +
"원래 크기 의도가 있다면 prefab 자식 메시에 적용하거나 avatarScale 슬라이더로 조정하세요.");
transform.localScale = Vector3.one;
}
// 크기 조정 대상 오브젝트 캐싱 메서드
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;
}
}
}
}