using System; using System.Collections.Generic; using UnityEngine; using System.Collections; [System.Serializable] public class OptiTrackBoneMapping { public string optiTrackBoneName; // OptiTrack 본 이름 (예: "Hip", "Ab") public string fbxNodeName; // FBX 노드 이름 (예: "001_Hips", "001_Spine") [HideInInspector] public Transform cachedTransform; // 런타임 캐시 public bool applyPosition = true; public bool applyRotation = true; public bool isMapped = false; // 매핑 성공 여부 } [DefaultExecutionOrder(-100)] public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour { [Header("OptiTrack 설정")] [Tooltip("OptiTrackStreamingClient가 포함된 오브젝트")] public OptitrackStreamingClient StreamingClient; [Tooltip("Motive의 스켈레톤 에셋 이름")] public string SkeletonAssetName = "Skeleton1"; // 런타임 본 매핑 — SO(RestPoseAsset)에서 로드됨. 직접 편집하지 말고 설정 창 사용. [HideInInspector] public List boneMappings = new List(); [Header("T-포즈 (기준 포즈)")] [Tooltip("에디터에서 캡처한 T-포즈 데이터. 비어 있으면 런타임 CacheRestPose()를 사용합니다.")] public OptitrackRestPoseData RestPoseAsset; [Header("본 1€ 필터 (속도 적응형 저역통과)")] [Tooltip("활성화 시 빠른 움직임은 그대로, 정지/느린 움직임의 노이즈를 제거합니다.\n단순 EMA보다 모션 보존이 훨씬 우수합니다.")] public bool enableBoneFilter = true; [Tooltip("최소 차단 주파수 (Hz). 정지 시 노이즈 제거 강도. 낮을수록 강함. 권장: 2~4 Hz")] [Range(0.1f, 10f)] public float filterMinCutoff = 3.0f; [Tooltip("속도 계수. 빠른 동작에서 cutoff 상승 속도. 높을수록 지연 감소. 권장: 0.5~2.0")] [Range(0f, 5f)] public float filterBeta = 1.5f; [Tooltip("최대 차단 주파수 상한 (Hz). 빠른 동작에서도 이 이상 주파수는 항상 제거됩니다.\n" + "MagicaCloth2 지터가 빠른 동작에서 발생하면 낮추세요. 권장: 10~20 Hz")] [Range(5f, 120f)] public float filterMaxCutoff = 15.0f; private OptitrackSkeletonDefinition m_skeletonDef; private string previousSkeletonName; [HideInInspector] public bool isSkeletonFound = false; private const float k_SkeletonCheckInterval = 0.1f; // 본 ID → 매핑 인덱스 (빠른 룩업) private Dictionary m_boneIdToMappingIndex = new Dictionary(); // 본 이름 → Transform 캐시 (전체 하이어라키) private Dictionary m_allTransforms = new Dictionary(); // FillBoneSnapshot 용 pre-allocated 스냅샷 버퍼 (락 내부에서 채워짐 → torn read 없음) private Dictionary m_snapshotPositions = new Dictionary(); private Dictionary m_snapshotOrientations = new Dictionary(); // OptiTrack 본 이름 → Transform 빠른 캐시 (GetMappedTransform O(n) → O(1)) private Dictionary m_optiNameTransformCache = new Dictionary(); // 스파인/넥 체인 Transform 캐시 (GetSpineChainTransforms 매 호출 List 할당 방지) private List m_spineChainCache = new List(); private List m_neckChainCache = new List(); // 힙 본 ID 캐시 (-1 = 미발견) private int m_hipBoneId = -1; // NatNet 하드웨어 타임스탬프 기반 실제 프레임 간격 (렌더 프레임과 독립) private float m_natNetDt = 1f / 120f; private OptitrackHiResTimer.Timestamp m_lastFrameTimestamp; private bool m_hasLastFrameTimestamp = false; // 1€ 필터 상태 (본 ID → 이전 프레임 필터 상태) private struct BoneFilterState { public Quaternion prevOri; public float dOriMag; // 필터된 각속도 크기 (rad/s) public Vector3 prevPos; public float dPosMag; // 필터된 선속도 크기 (m/s) public bool initialized; } private Dictionary m_filterStates = new Dictionary(); // OptiTrack 본 이름 → FBX 노드 접미사 기본 매핑 public static readonly Dictionary DefaultOptiToFbxSuffix = new Dictionary { // 몸통 (Motive 5본 스파인 체인) // Motive: Hip → Ab → Spine2 → Spine3 → Spine4 → Chest → Neck → Neck2 → Head // FBX: Hips → Spine → Spine1 → Spine2 → Spine3 → Spine4 → Neck → Neck1 → Head {"Hip", "Hips"}, {"Ab", "Spine"}, {"Spine2", "Spine1"}, {"Spine3", "Spine2"}, {"Spine4", "Spine3"}, {"Chest", "Spine4"}, {"Neck", "Neck"}, {"Neck2", "Neck1"}, {"Head", "Head"}, // 왼쪽 팔 {"LShoulder", "LeftShoulder"}, {"LUArm", "LeftArm"}, {"LFArm", "LeftForeArm"}, {"LHand", "LeftHand"}, // 오른쪽 팔 {"RShoulder", "RightShoulder"}, {"RUArm", "RightArm"}, {"RFArm", "RightForeArm"}, {"RHand", "RightHand"}, // 왼쪽 다리 {"LThigh", "LeftUpLeg"}, {"LShin", "LeftLeg"}, {"LFoot", "LeftFoot"}, {"LToe", "LeftToeBase"}, // 오른쪽 다리 {"RThigh", "RightUpLeg"}, {"RShin", "RightLeg"}, {"RFoot", "RightFoot"}, {"RToe", "RightToeBase"}, // 왼쪽 손가락 {"LThumb1", "LeftHandThumb1"}, {"LThumb2", "LeftHandThumb2"}, {"LThumb3", "LeftHandThumb3"}, {"LIndex1", "LeftHandIndex1"}, {"LIndex2", "LeftHandIndex2"}, {"LIndex3", "LeftHandIndex3"}, {"LMiddle1", "LeftHandMiddle1"}, {"LMiddle2", "LeftHandMiddle2"}, {"LMiddle3", "LeftHandMiddle3"}, {"LRing1", "LeftHandRing1"}, {"LRing2", "LeftHandRing2"}, {"LRing3", "LeftHandRing3"}, {"LPinky1", "LeftHandPinky1"}, {"LPinky2", "LeftHandPinky2"}, {"LPinky3", "LeftHandPinky3"}, // 오른쪽 손가락 {"RThumb1", "RightHandThumb1"}, {"RThumb2", "RightHandThumb2"}, {"RThumb3", "RightHandThumb3"}, {"RIndex1", "RightHandIndex1"}, {"RIndex2", "RightHandIndex2"}, {"RIndex3", "RightHandIndex3"}, {"RMiddle1", "RightHandMiddle1"}, {"RMiddle2", "RightHandMiddle2"}, {"RMiddle3", "RightHandMiddle3"}, {"RRing1", "RightHandRing1"}, {"RRing2", "RightHandRing2"}, {"RRing3", "RightHandRing3"}, {"RPinky1", "RightHandPinky1"}, {"RPinky2", "RightHandPinky2"}, {"RPinky3", "RightHandPinky3"}, }; void Start() { BuildTransformCache(); InitializeStreamingClient(); if (StreamingClient != null) { StreamingClient.RegisterSkeleton(this, this.SkeletonAssetName); } // SO에서 본 매핑 + T-포즈 로드 if (RestPoseAsset != null && RestPoseAsset.boneMappings.Count > 0) { // 본 매핑: SO → MB 런타임 복사 (cachedTransform은 아래 RefreshTransformCache에서 채워짐) boneMappings.Clear(); foreach (var src in RestPoseAsset.boneMappings) { boneMappings.Add(new OptiTrackBoneMapping { optiTrackBoneName = src.optiTrackBoneName, fbxNodeName = src.fbxNodeName, applyPosition = src.applyPosition, applyRotation = src.applyRotation }); } RefreshTransformCache(); Debug.Log($"[OptiTrack] 본 매핑 에셋 로드 완료: {boneMappings.Count}개", this); } else if (boneMappings.Count > 0) { RefreshTransformCache(); } else { Debug.LogWarning("[OptiTrack] 본 매핑이 비어있습니다. 설정 창에서 'FBX 분석 → 자동 매핑 생성'을 눌러주세요.", this); } // T-포즈 로드 if (RestPoseAsset != null && RestPoseAsset.restPoseEntries.Count > 0) { RestPoseAsset.LoadRestPoseIntoRuntime(m_restLocalRotations, m_restLocalPositions); m_isRestPoseCached = true; Debug.Log($"[OptiTrack] T-포즈 에셋 로드 완료: {m_restLocalRotations.Count}개 본", this); } previousSkeletonName = SkeletonAssetName; StartCoroutine(CheckSkeletonConnectionPeriodically()); } void Update() { if (StreamingClient == null) { InitializeStreamingClient(); return; } // 스켈레톤 이름 변경 감지 if (previousSkeletonName != SkeletonAssetName) { StreamingClient.RegisterSkeleton(this, SkeletonAssetName); m_skeletonDef = StreamingClient.GetSkeletonDefinitionByName(SkeletonAssetName); previousSkeletonName = SkeletonAssetName; if (m_skeletonDef != null) RebuildBoneIdMapping(); return; } if (m_skeletonDef == null) return; // 락 보호 하에 스냅샷 복사 + NatNet 하드웨어 타임스탬프 수신 OptitrackHiResTimer.Timestamp frameTs; if (!StreamingClient.FillBoneSnapshot(m_skeletonDef.Id, m_snapshotPositions, m_snapshotOrientations, out frameTs)) return; // ── NatNet 실제 프레임 간격 계산 (하드웨어 타이머 — 렌더 프레임 등락과 완전 독립) ── if (m_hasLastFrameTimestamp && frameTs.m_ticks != m_lastFrameTimestamp.m_ticks) { float measuredDt = frameTs.SecondsSince(m_lastFrameTimestamp); if (measuredDt > 0.001f && measuredDt < 0.1f) // 1ms~100ms 범위만 신뢰 (이상값 무시) m_natNetDt = Mathf.Lerp(m_natNetDt, measuredDt, 0.1f); } m_lastFrameTimestamp = frameTs; m_hasLastFrameTimestamp = true; // ── 각 본 업데이트 ─────────────────────────────────────────────────────── foreach (var bone in m_skeletonDef.Bones) { if (!m_boneIdToMappingIndex.TryGetValue(bone.Id, out int mappingIdx)) continue; var mapping = boneMappings[mappingIdx]; if (!mapping.isMapped || mapping.cachedTransform == null) continue; if (mapping.applyRotation) { if (!m_snapshotOrientations.TryGetValue(bone.Id, out Quaternion finalOri)) continue; if (enableBoneFilter) finalOri = ApplyOneEuroOri(bone.Id, finalOri, m_natNetDt); mapping.cachedTransform.localRotation = finalOri; } if (mapping.applyPosition) { if (!m_snapshotPositions.TryGetValue(bone.Id, out Vector3 finalPos)) continue; if (enableBoneFilter) finalPos = ApplyOneEuroPos(bone.Id, finalPos, m_natNetDt); mapping.cachedTransform.localPosition = finalPos; } } } /// /// 하이어라키의 모든 Transform을 이름으로 캐싱 /// private void BuildTransformCache() { m_allTransforms.Clear(); var allChildren = GetComponentsInChildren(true); foreach (var t in allChildren) { if (!m_allTransforms.ContainsKey(t.name)) { m_allTransforms[t.name] = t; } } } /// /// 기존 매핑의 Transform 캐시만 갱신 /// private void RefreshTransformCache() { foreach (var mapping in boneMappings) { if (!string.IsNullOrEmpty(mapping.fbxNodeName) && m_allTransforms.TryGetValue(mapping.fbxNodeName, out Transform t)) { mapping.cachedTransform = t; mapping.isMapped = true; } } RebuildOptiNameCache(); } /// /// OptiTrack 본 이름 → Transform 딕셔너리 캐시 구축 (GetMappedTransform O(n) → O(1)) /// private void RebuildOptiNameCache() { m_optiNameTransformCache.Clear(); foreach (var mapping in boneMappings) { if (!string.IsNullOrEmpty(mapping.optiTrackBoneName) && mapping.isMapped && mapping.cachedTransform != null) m_optiNameTransformCache[mapping.optiTrackBoneName] = mapping.cachedTransform; } RebuildChainCaches(); } private void RebuildChainCaches() { m_spineChainCache.Clear(); foreach (var name in SpineChainOptiNames) if (m_optiNameTransformCache.TryGetValue(name, out Transform t)) m_spineChainCache.Add(t); m_neckChainCache.Clear(); foreach (var name in NeckChainOptiNames) if (m_optiNameTransformCache.TryGetValue(name, out Transform t)) m_neckChainCache.Add(t); } /// /// OptiTrack 본 ID → boneMappings 인덱스 매핑 구축 /// private void RebuildBoneIdMapping() { m_boneIdToMappingIndex.Clear(); m_filterStates.Clear(); m_hasLastFrameTimestamp = false; if (m_skeletonDef == null) return; var nameToIdx = new Dictionary(); for (int i = 0; i < boneMappings.Count; i++) { if (!nameToIdx.ContainsKey(boneMappings[i].optiTrackBoneName)) nameToIdx[boneMappings[i].optiTrackBoneName] = i; } int matchCount = 0; m_hipBoneId = -1; foreach (var bone in m_skeletonDef.Bones) { string boneName = bone.Name; string optiName = boneName.Contains("_") ? boneName.Substring(boneName.IndexOf('_') + 1) : boneName; if (nameToIdx.TryGetValue(optiName, out int idx)) { m_boneIdToMappingIndex[bone.Id] = idx; matchCount++; } if (optiName == "Hip") m_hipBoneId = bone.Id; } Debug.Log($"[OptiTrack] 본 ID 매핑 완료: {matchCount}/{m_skeletonDef.Bones.Count} 매칭"); } private void InitializeStreamingClient() { if (StreamingClient == null) { StreamingClient = FindAnyObjectByType(); if (StreamingClient != null) Debug.Log("OptiTrack Streaming Client를 찾았습니다.", this); } } private IEnumerator CheckSkeletonConnectionPeriodically() { while (true) { if (StreamingClient != null) { m_skeletonDef = StreamingClient.GetSkeletonDefinitionByName(SkeletonAssetName); if (m_skeletonDef != null) { OptitrackSkeletonState skelState = StreamingClient.GetLatestSkeletonState(m_skeletonDef.Id); bool wasFound = isSkeletonFound; isSkeletonFound = (skelState != null); if (isSkeletonFound && !wasFound) { StreamingClient.RegisterSkeleton(this, SkeletonAssetName); previousSkeletonName = SkeletonAssetName; RebuildBoneIdMapping(); Debug.Log($"[OptiTrack] 스켈레톤 '{SkeletonAssetName}' 연결 성공"); } } else { isSkeletonFound = false; } } yield return new WaitForSeconds(k_SkeletonCheckInterval); } } // ── 1€ Filter (One Euro Filter) ────────────────────────────────────────── // 참고: Géry Casiez et al., "1€ Filter: A Simple Speed-based Low-pass Filter", CHI 2012 // 속도가 빠를수록 cutoff 상승 → 지연 감소, 속도가 느릴수록 cutoff = minCutoff → 노이즈 제거 // dt초 간격에서의 1차 LP 필터 alpha private static float OE_Alpha(float cutoffHz, float dt) { float r = 2f * Mathf.PI * cutoffHz * dt; return r / (r + 1f); } private Quaternion ApplyOneEuroOri(int boneId, Quaternion raw, float dt) { const float k_DCutoff = 1f; // 도함수 필터의 고정 cutoff (Hz) if (!m_filterStates.TryGetValue(boneId, out BoneFilterState s) || !s.initialized) { m_filterStates[boneId] = new BoneFilterState { prevOri = raw, dOriMag = 0f, prevPos = Vector3.zero, dPosMag = 0f, initialized = true }; return raw; } // 각속도(rad/s) 추정 float angleDeg = Quaternion.Angle(s.prevOri, raw); float speed = angleDeg * Mathf.Deg2Rad / Mathf.Max(dt, 1e-4f); // 도함수를 별도 LP로 스무딩 float dAlpha = OE_Alpha(k_DCutoff, dt); float filtDeriv = s.dOriMag + dAlpha * (speed - s.dOriMag); // 적응 cutoff: 빠르면 cutoff 상승, 단 maxCutoff 이상은 항상 제거 float cutoff = Mathf.Min(filterMinCutoff + filterBeta * filtDeriv, filterMaxCutoff); float alpha = OE_Alpha(cutoff, dt); Quaternion filtered = Quaternion.Slerp(s.prevOri, raw, alpha); s.prevOri = filtered; s.dOriMag = filtDeriv; m_filterStates[boneId] = s; return filtered; } private Vector3 ApplyOneEuroPos(int boneId, Vector3 raw, float dt) { const float k_DCutoff = 1f; if (!m_filterStates.TryGetValue(boneId, out BoneFilterState s) || !s.initialized) { // 회전 필터가 이미 초기화했을 수 있으므로 위치만 패치 s.prevPos = raw; s.dPosMag = 0f; s.initialized = true; m_filterStates[boneId] = s; return raw; } float speed = (raw - s.prevPos).magnitude / Mathf.Max(dt, 1e-4f); float dAlpha = OE_Alpha(k_DCutoff, dt); float filtDeriv = s.dPosMag + dAlpha * (speed - s.dPosMag); float cutoff = Mathf.Min(filterMinCutoff + filterBeta * filtDeriv, filterMaxCutoff); float alpha = OE_Alpha(cutoff, dt); Vector3 filtered = Vector3.Lerp(s.prevPos, raw, alpha); s.prevPos = filtered; s.dPosMag = filtDeriv; m_filterStates[boneId] = s; return filtered; } #region 외부 접근용 헬퍼 // HumanBodyBones → OptiTrack 본 이름 매핑 (Humanoid 호환 레이어) public static readonly Dictionary HumanBoneToOptiName = new Dictionary { { HumanBodyBones.Hips, "Hip" }, { HumanBodyBones.Head, "Head" }, // 스파인/넥은 분배 처리되므로 1:1 매핑은 참고용 { HumanBodyBones.Spine, "Ab" }, { HumanBodyBones.Chest, "Chest" }, { HumanBodyBones.Neck, "Neck" }, // 왼쪽 팔 { HumanBodyBones.LeftShoulder, "LShoulder" }, { HumanBodyBones.LeftUpperArm, "LUArm" }, { HumanBodyBones.LeftLowerArm, "LFArm" }, { HumanBodyBones.LeftHand, "LHand" }, // 오른쪽 팔 { HumanBodyBones.RightShoulder, "RShoulder" }, { HumanBodyBones.RightUpperArm, "RUArm" }, { HumanBodyBones.RightLowerArm, "RFArm" }, { HumanBodyBones.RightHand, "RHand" }, // 왼쪽 다리 { HumanBodyBones.LeftUpperLeg, "LThigh" }, { HumanBodyBones.LeftLowerLeg, "LShin" }, { HumanBodyBones.LeftFoot, "LFoot" }, { HumanBodyBones.LeftToes, "LToe" }, // 오른쪽 다리 { HumanBodyBones.RightUpperLeg, "RThigh" }, { HumanBodyBones.RightLowerLeg, "RShin" }, { HumanBodyBones.RightFoot, "RFoot" }, { HumanBodyBones.RightToes, "RToe" }, // 왼쪽 손가락 { HumanBodyBones.LeftThumbProximal, "LThumb1" }, { HumanBodyBones.LeftThumbIntermediate, "LThumb2" }, { HumanBodyBones.LeftThumbDistal, "LThumb3" }, { HumanBodyBones.LeftIndexProximal, "LIndex1" }, { HumanBodyBones.LeftIndexIntermediate, "LIndex2" }, { HumanBodyBones.LeftIndexDistal, "LIndex3" }, { HumanBodyBones.LeftMiddleProximal, "LMiddle1" }, { HumanBodyBones.LeftMiddleIntermediate, "LMiddle2" }, { HumanBodyBones.LeftMiddleDistal, "LMiddle3" }, { HumanBodyBones.LeftRingProximal, "LRing1" }, { HumanBodyBones.LeftRingIntermediate, "LRing2" }, { HumanBodyBones.LeftRingDistal, "LRing3" }, { HumanBodyBones.LeftLittleProximal, "LPinky1" }, { HumanBodyBones.LeftLittleIntermediate, "LPinky2" }, { HumanBodyBones.LeftLittleDistal, "LPinky3" }, // 오른쪽 손가락 { HumanBodyBones.RightThumbProximal, "RThumb1" }, { HumanBodyBones.RightThumbIntermediate, "RThumb2" }, { HumanBodyBones.RightThumbDistal, "RThumb3" }, { HumanBodyBones.RightIndexProximal, "RIndex1" }, { HumanBodyBones.RightIndexIntermediate, "RIndex2" }, { HumanBodyBones.RightIndexDistal, "RIndex3" }, { HumanBodyBones.RightMiddleProximal, "RMiddle1" }, { HumanBodyBones.RightMiddleIntermediate, "RMiddle2" }, { HumanBodyBones.RightMiddleDistal, "RMiddle3" }, { HumanBodyBones.RightRingProximal, "RRing1" }, { HumanBodyBones.RightRingIntermediate, "RRing2" }, { HumanBodyBones.RightRingDistal, "RRing3" }, { HumanBodyBones.RightLittleProximal, "RPinky1" }, { HumanBodyBones.RightLittleIntermediate, "RPinky2" }, { HumanBodyBones.RightLittleDistal, "RPinky3" }, }; /// /// HumanBodyBones enum으로 OptiTrack 매핑된 Transform 반환 /// (Humanoid 호환 레이어 — sourceAnimator.GetBoneTransform() 대체) /// public Transform GetBoneTransform(HumanBodyBones bone) { if (HumanBoneToOptiName.TryGetValue(bone, out string optiName)) return GetMappedTransform(optiName); return null; } // 스파인 체인 OptiTrack 이름 (Hips 제외, 순서대로) public static readonly string[] SpineChainOptiNames = { "Ab", "Spine2", "Spine3", "Spine4", "Chest" }; // 넥 체인 public static readonly string[] NeckChainOptiNames = { "Neck", "Neck2" }; /// /// OptiTrack 본 이름으로 매핑된 Transform 반환 (캐시 O(1), 미캐시 시 선형 탐색 폴백) /// public Transform GetMappedTransform(string optiTrackBoneName) { if (m_optiNameTransformCache.TryGetValue(optiTrackBoneName, out var cached)) return cached; // 캐시 미구축 상태 폴백 (Start() 전 호출 등) foreach (var mapping in boneMappings) { if (mapping.optiTrackBoneName == optiTrackBoneName && mapping.isMapped) return mapping.cachedTransform; } return null; } /// /// 스파인 체인의 모든 Transform을 순서대로 반환 (Ab → Spine2 → Spine3 → Spine4 → Chest) /// public List GetSpineChainTransforms() => m_spineChainCache; /// /// 넥 체인의 모든 Transform을 순서대로 반환 /// public List GetNeckChainTransforms() => m_neckChainCache; /// /// 체인의 누적 로컬 회전을 계산 (각 본의 localRotation을 순서대로 곱함) /// public static Quaternion ComputeChainRotation(List chain) { Quaternion total = Quaternion.identity; foreach (var t in chain) { total *= t.localRotation; } return total; } // T-포즈 기준 포즈 데이터 (SO에서 로드 또는 CacheRestPose()로 캐싱) private Dictionary m_restLocalRotations = new Dictionary(); private Dictionary m_restLocalPositions = new Dictionary(); private bool m_isRestPoseCached = false; /// /// 현재 포즈를 기준 포즈(T-포즈)로 캐싱 /// public void CacheRestPose() { m_restLocalRotations.Clear(); m_restLocalPositions.Clear(); foreach (var mapping in boneMappings) { if (mapping.isMapped && mapping.cachedTransform != null) { m_restLocalRotations[mapping.optiTrackBoneName] = mapping.cachedTransform.localRotation; m_restLocalPositions[mapping.optiTrackBoneName] = mapping.cachedTransform.localPosition; } } m_isRestPoseCached = true; Debug.Log($"[OptiTrack] 기준 포즈 캐싱 완료: {m_restLocalRotations.Count}개 본"); // SO가 연결되어 있으면 런타임 결과를 SO에도 반영 if (RestPoseAsset != null) { RestPoseAsset.restPoseEntries.Clear(); foreach (var kvp in m_restLocalRotations) { m_restLocalPositions.TryGetValue(kvp.Key, out Vector3 pos); RestPoseAsset.restPoseEntries.Add(new OptitrackRestPoseData.RestPoseEntry { boneName = kvp.Key, localPosition = pos, localRotation = kvp.Value }); } #if UNITY_EDITOR UnityEditor.EditorUtility.SetDirty(RestPoseAsset); UnityEditor.AssetDatabase.SaveAssets(); #endif } } /// /// 캐싱된 기준 포즈로 복원 (T-포즈 복원) /// public void RestoreRestPose() { if (!m_isRestPoseCached) return; foreach (var mapping in boneMappings) { if (mapping.isMapped && mapping.cachedTransform != null) { if (m_restLocalRotations.TryGetValue(mapping.optiTrackBoneName, out Quaternion rot)) mapping.cachedTransform.localRotation = rot; if (m_restLocalPositions.TryGetValue(mapping.optiTrackBoneName, out Vector3 pos)) mapping.cachedTransform.localPosition = pos; } } } /// /// 특정 본의 기준 포즈 로컬 회전 반환 /// public Quaternion GetRestLocalRotation(string optiTrackBoneName) { if (m_restLocalRotations.TryGetValue(optiTrackBoneName, out Quaternion rot)) return rot; return Quaternion.identity; } #endregion }