diff --git a/Assets/02_Scripts/Character/HeadLookAt.cs b/Assets/02_Scripts/Character/HeadLookAt.cs index 12489b5..01df453 100644 --- a/Assets/02_Scripts/Character/HeadLookAt.cs +++ b/Assets/02_Scripts/Character/HeadLookAt.cs @@ -56,6 +56,9 @@ public class HeadLookAt : MonoBehaviour Transform neck; Transform root; + // 기준(정지) 자세. 회전 누적을 막기 위해 매 프레임 여기로 되돌린 뒤 적용한다. + Quaternion restHead, restNeck; + Vector2 currentAngles; // x = yaw, y = pitch float lastSeenTime = -999f; @@ -66,11 +69,15 @@ void Awake() if (animator == null) animator = GetComponentInChildren(); if (viewCamera == null) viewCamera = Camera.main; if (window == null) window = FindFirstObjectByType(); - - ResolveBones(); } - void ResolveBones() + // Awake 가 아니라 첫 LateUpdate 에서 해석한다. VRM 컨트롤 리그가 만들어지는 + // 시점에 Animator.avatar 가 교체되므로, 그보다 먼저 캐시하면 곧 무효가 될 + // 실제 메시 본을 잡게 된다. + bool resolved; + bool warned; + + bool TryResolveBones() { if (headOverride != null) { @@ -82,17 +89,26 @@ void ResolveBones() neck = animator.GetBoneTransform(HumanBodyBones.Neck); // 없는 리그도 있다 } - if (head == null) + if (head != null) { - Debug.LogWarning("[HeadLookAt] 머리 본을 찾지 못했습니다. " + - "FBX 임포트 설정에서 Animation Type = Humanoid 인지 확인하거나 " + - "Head Override 를 직접 지정하세요."); - enabled = false; + restHead = head.localRotation; + if (neck != null) restNeck = neck.localRotation; + resolved = true; + return true; } + + if (!warned) + { + warned = true; + Debug.LogWarning("[HeadLookAt] 머리 본을 찾지 못했습니다. " + + "휴머노이드 리그인지, Head Override 지정이 필요한지 확인하세요."); + } + return false; } void LateUpdate() { + if (!resolved && !TryResolveBones()) return; if (head == null) return; Vector2 desired; @@ -165,6 +181,11 @@ void ApplyRotation() float neckPart = neck != null ? neckShare : 0f; float headPart = 1f - neckPart; + // 매 프레임 기준 자세로 되돌린다. 이걸 빼면 델타가 누적되어 목이 계속 돌아간다. + // (Animator 가 포즈를 써주면 불필요하지만, 클립이 없는 지금은 되돌릴 주체가 없다) + head.localRotation = restHead; + if (neck != null) neck.localRotation = restNeck; + if (neck != null) { neck.rotation = Delta(yaw * neckPart, pitch * neckPart) * neck.rotation; diff --git a/Assets/02_Scripts/Character/ProceduralIdle.cs b/Assets/02_Scripts/Character/ProceduralIdle.cs index b906002..e8571fd 100644 --- a/Assets/02_Scripts/Character/ProceduralIdle.cs +++ b/Assets/02_Scripts/Character/ProceduralIdle.cs @@ -13,7 +13,7 @@ /// 구현 노트 2: VRM 컨트롤 리그는 Vrm10Instance.LateUpdate 에서 실제 본으로 반영되므로 /// 그보다 먼저 실행돼야 한다. HeadLookAt 과 같은 이유로 실행 순서를 앞당긴다. /// -[DefaultExecutionOrder(-100)] +[DefaultExecutionOrder(-110)] // 몸통을 먼저, 그다음 HeadLookAt(-100) 이 머리를 처리 public class ProceduralIdle : MonoBehaviour { [Header("참조")] @@ -56,18 +56,41 @@ public class ProceduralIdle : MonoBehaviour Transform root; Transform hips, chest, leftUpperArm, rightUpperArm, leftLowerArm, rightLowerArm; + // 각 본의 기준(정지) 자세. 매 프레임 여기로 되돌린 뒤 다시 계산한다. + Quaternion restHips, restChest, restLUpper, restRUpper, restLLower, restRLower; + float phaseOffset; + bool resolved; + bool warned; + void Awake() { root = transform; if (animator == null) animator = GetComponentInChildren(); + // 여러 캐릭터가 있어도 동시에 같은 박자로 숨쉬지 않게 한다. + phaseOffset = Random.Range(0f, 100f); + } + + /// + /// 본 해석을 Awake 가 아니라 첫 LateUpdate 로 미룬다. + /// + /// VRM 컨트롤 리그는 Vrm10Instance.Runtime 에 처음 접근할 때 생성되고, 그때 + /// Animator.avatar 가 컨트롤 리그용으로 교체된다. 그 전에 GetBoneTransform 을 + /// 부르면 실제 메시 본이 잡히는데, 그 본은 매 프레임 ControlRig.Process() 가 + /// 덮어쓰므로 여기서 가한 회전이 전부 지워진다. + /// + bool TryResolveBones() + { if (animator == null || !animator.isHuman) { - Debug.LogWarning("[ProceduralIdle] 휴머노이드 Animator 가 없어 비활성화합니다."); - enabled = false; - return; + if (!warned) + { + warned = true; + Debug.LogWarning("[ProceduralIdle] 휴머노이드 Animator 를 찾지 못했습니다."); + } + return false; } hips = animator.GetBoneTransform(HumanBodyBones.Hips); @@ -79,12 +102,63 @@ void Awake() leftLowerArm = animator.GetBoneTransform(HumanBodyBones.LeftLowerArm); rightLowerArm = animator.GetBoneTransform(HumanBodyBones.RightLowerArm); - // 여러 캐릭터가 있어도 동시에 같은 박자로 숨쉬지 않게 한다. - phaseOffset = Random.Range(0f, 100f); + if (leftUpperArm == null || rightUpperArm == null) + { + if (!warned) + { + warned = true; + Debug.LogWarning("[ProceduralIdle] 위팔 본을 찾지 못했습니다."); + } + return false; + } + + CaptureRest(); + + resolved = true; + Debug.Log($"[ProceduralIdle] 본 해석 완료. 위팔 경로: {GetPath(leftUpperArm)}"); + return true; + } + + void CaptureRest() + { + if (hips != null) restHips = hips.localRotation; + if (chest != null) restChest = chest.localRotation; + if (leftUpperArm != null) restLUpper = leftUpperArm.localRotation; + if (rightUpperArm != null) restRUpper = rightUpperArm.localRotation; + if (leftLowerArm != null) restLLower = leftLowerArm.localRotation; + if (rightLowerArm != null) restRLower = rightLowerArm.localRotation; + } + + /// + /// 매 프레임 기준 자세로 되돌린다. + /// + /// 이 컴포넌트는 "애니메이션 클립이 없을 때"를 위한 것이다. 클립이 있으면 + /// Animator 가 매 프레임 포즈를 새로 써주므로 델타를 얹기만 하면 되지만, + /// 지금은 되돌려 주는 주체가 없어 회전이 무한 누적된다. 그래서 직접 되돌린다. + /// 나중에 실제 클립을 넣으면 이 컴포넌트를 끄면 된다. + /// + void ResetToRest() + { + if (hips != null) hips.localRotation = restHips; + if (chest != null) chest.localRotation = restChest; + if (leftUpperArm != null) leftUpperArm.localRotation = restLUpper; + if (rightUpperArm != null) rightUpperArm.localRotation = restRUpper; + if (leftLowerArm != null) leftLowerArm.localRotation = restLLower; + if (rightLowerArm != null) rightLowerArm.localRotation = restRLower; + } + + static string GetPath(Transform t) + { + var sb = new System.Text.StringBuilder(t.name); + for (var p = t.parent; p != null; p = p.parent) sb.Insert(0, p.name + "/"); + return sb.ToString(); } void LateUpdate() { + if (!resolved && !TryResolveBones()) return; + + ResetToRest(); ApplyArms(); ApplyBody(); } diff --git a/Assets/02_Scripts/Character/VrmCharacterLoader.cs b/Assets/02_Scripts/Character/VrmCharacterLoader.cs index 8798a38..23b558f 100644 --- a/Assets/02_Scripts/Character/VrmCharacterLoader.cs +++ b/Assets/02_Scripts/Character/VrmCharacterLoader.cs @@ -183,6 +183,13 @@ void Setup(Vrm10Instance vrm) ? Quaternion.Euler(0f, 180f, 0f) // VRM 은 +Z 를 향하므로 돌려세운다 : Quaternion.identity; + // 중요: Vrm10Instance.Runtime 은 지연 생성 프로퍼티다. 여기서 한 번 접근해 + // 컨트롤 리그를 미리 만들어 둔다. 컨트롤 리그가 생성될 때 Animator.avatar 가 + // 교체되는데, 그 전에 붙은 컴포넌트들은 GetBoneTransform() 으로 실제 메시 본을 + // 잡아버린다. 그 본은 매 프레임 ControlRig.Process() 가 덮어쓰므로 우리가 + // 가한 회전이 전부 지워진다. 반드시 컴포넌트 부착보다 먼저 접근해야 한다. + _ = vrm.Runtime; + var avatar = currentRoot.AddComponent(); avatar.Bind(vrm); Current = avatar;