Fix : 옵티 미러 스켈레톤을 월드 공간 기준으로 재구현

- FillBoneSnapshot의 스켈레톤 미러 로직 제거
  (로컬 좌표 + 좌우 본 교체 방식은 부모 본 좌표계 차이로 꼬임 발생)
- OptitrackSkeletonAnimator_Mingle에 ApplyWorldSpaceMirror() 추가
  - 본 데이터 적용 후 월드 공간에서 전체 포즈 캐시 → 미러 적용
  - L/R 본은 상대방 월드 포즈로 교체 + YZ 평면 반사
  - 대칭 본은 자기 포즈에 YZ 평면 반사 적용
  - 로컬 축 컨벤션 독립적 → 어떤 스켈레톤에서도 정확히 동작
- RigidBody 미러는 기존 GetLatestRigidBodyState() 방식 유지

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
qsxft258@gmail.com 2026-03-22 17:20:12 +09:00
parent 1af29e6256
commit dbf2ac91f3
2 changed files with 70 additions and 16 deletions

View File

@ -67,6 +67,11 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
// OptiTrack 본 이름 → Transform 빠른 캐시 (GetMappedTransform O(n) → O(1))
private Dictionary<string, Transform> m_optiNameTransformCache = new Dictionary<string, Transform>();
// MirrorMode용: boneId → mirrorBoneId 매핑 (null이면 미구축)
private Dictionary<Int32, Int32> m_mirrorBoneIdMap;
// MirrorMode용: 월드 포즈 캐시 (매 프레임 재사용, GC 없음)
private Dictionary<Int32, (Vector3 pos, Quaternion rot)> m_mirrorWorldPoseCache = new Dictionary<Int32, (Vector3, Quaternion)>();
// 스파인/넥 체인 Transform 캐시 (GetSpineChainTransforms 매 호출 List 할당 방지)
private List<Transform> m_spineChainCache = new List<Transform>();
private List<Transform> m_neckChainCache = new List<Transform>();
@ -282,6 +287,66 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
mapping.cachedTransform.localPosition = finalPos;
}
}
// ── MirrorMode: 본 데이터 적용 후 월드 공간 기준 좌우 반전 ─────────────────
if (StreamingClient != null && StreamingClient.MirrorMode)
ApplyWorldSpaceMirror();
}
/// <summary>
/// 전체 본을 월드 공간에서 좌우 반전합니다.
/// 로컬 축 컨벤션에 독립적이므로 어떤 스켈레톤 구조에서도 올바르게 동작합니다.
/// </summary>
private void ApplyWorldSpaceMirror()
{
if (m_mirrorBoneIdMap == null)
BuildMirrorBoneIdMapLocal();
// Step 1: 현재 월드 포즈 전체 캐시 (수정 전 상태 보존)
m_mirrorWorldPoseCache.Clear();
foreach (var bone in m_skeletonDef.Bones)
{
if (!m_boneIdToMappingIndex.TryGetValue(bone.Id, out int idx)) continue;
var t = boneMappings[idx].cachedTransform;
if (t == null) continue;
m_mirrorWorldPoseCache[bone.Id] = (t.position, t.rotation);
}
// Step 2: 미러된 월드 포즈 적용
foreach (var bone in m_skeletonDef.Bones)
{
if (!m_boneIdToMappingIndex.TryGetValue(bone.Id, out int idx)) continue;
var mapping = boneMappings[idx];
if (!mapping.isMapped || mapping.cachedTransform == null) continue;
if (!m_mirrorBoneIdMap.TryGetValue(bone.Id, out Int32 mirrorId)) continue;
if (!m_mirrorWorldPoseCache.TryGetValue(mirrorId, out var src)) continue;
// 월드 X 반전 (YZ 평면 반사), 회전은 Y·Z 성분 부호 반전
if (mapping.applyPosition)
mapping.cachedTransform.position = new Vector3(-src.pos.x, src.pos.y, src.pos.z);
if (mapping.applyRotation)
mapping.cachedTransform.rotation = new Quaternion(src.rot.x, -src.rot.y, -src.rot.z, src.rot.w);
}
}
/// <summary>본 이름 기반 L/R 미러 ID 맵 구축 (월드 공간 미러용).</summary>
private void BuildMirrorBoneIdMapLocal()
{
m_mirrorBoneIdMap = new Dictionary<Int32, Int32>();
var nameToId = new Dictionary<string, Int32>();
foreach (var bone in m_skeletonDef.Bones)
nameToId[bone.Name] = bone.Id;
foreach (var bone in m_skeletonDef.Bones)
{
string n = bone.Name;
string mirrorName = null;
if (n.Length >= 2 && n[0] == 'L' && char.IsUpper(n[1])) mirrorName = "R" + n.Substring(1);
else if (n.Length >= 2 && n[0] == 'R' && char.IsUpper(n[1])) mirrorName = "L" + n.Substring(1);
m_mirrorBoneIdMap[bone.Id] = (mirrorName != null && nameToId.TryGetValue(mirrorName, out Int32 mid))
? mid : bone.Id;
}
}
/// <summary>
@ -351,6 +416,7 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
m_boneIdToMappingIndex.Clear();
m_filterStates.Clear();
m_hasLastFrameTimestamp = false;
m_mirrorBoneIdMap = null; // 스켈레톤 재구성 시 미러 맵 초기화
if (m_skeletonDef == null) return;
var nameToIdx = new Dictionary<string, int>();

View File

@ -934,23 +934,11 @@ public class OptitrackStreamingClient : MonoBehaviour
posOut.Clear();
oriOut.Clear();
if ( MirrorMode )
// 스켈레톤 미러는 OptitrackSkeletonAnimator_Mingle에서 월드 공간 기준으로 처리
foreach ( var kvp in state.BonePoses )
{
Dictionary<Int32, Int32> mirrorMap = GetOrBuildMirrorBoneIdMap( skeletonId );
foreach ( var kvp in state.BonePoses )
{
Int32 targetId = mirrorMap != null && mirrorMap.TryGetValue( kvp.Key, out Int32 mid ) ? mid : kvp.Key;
posOut[targetId] = MirrorPosition( kvp.Value.Position );
oriOut[targetId] = MirrorOrientation( kvp.Value.Orientation );
}
}
else
{
foreach ( var kvp in state.BonePoses )
{
posOut[kvp.Key] = kvp.Value.Position;
oriOut[kvp.Key] = kvp.Value.Orientation;
}
posOut[kvp.Key] = kvp.Value.Position;
oriOut[kvp.Key] = kvp.Value.Orientation;
}
return true;
}