using UnityEngine; /// /// 애니메이션 클립 없이 T포즈를 자연스러운 대기 자세로 바꾼다. /// /// 왜 필요한가: VRM 은 모델만 담고 애니메이션은 담지 않는다. 사용자가 임의의 VRM 을 /// 넣는 구조에서는 클립이 딸려 온다는 보장이 없으므로, 최소한의 대기 자세는 /// 코드로 만들어 두어야 한다. 나중에 실제 클립을 넣으면 이 컴포넌트를 끄면 된다. /// /// 구현 노트 1: 본의 로컬 축은 리그마다 제각각이라 로컬 오일러로 돌리면 모델에 따라 /// 엉뚱하게 꺾인다. 캐릭터 루트의 축을 기준으로 월드 회전을 덧씌워 리그에 무관하게 만든다. /// /// 구현 노트 2: VRM 컨트롤 리그는 Vrm10Instance.LateUpdate 에서 실제 본으로 반영되므로 /// 그보다 먼저 실행돼야 한다. HeadLookAt 과 같은 이유로 실행 순서를 앞당긴다. /// [DefaultExecutionOrder(-110)] // 몸통을 먼저, 그다음 HeadLookAt(-100) 이 머리를 처리 public class ProceduralIdle : MonoBehaviour { [Header("참조")] [Tooltip("비우면 자식에서 자동 탐색")] [SerializeField] Animator animator; [Header("팔 내리기 (T포즈 해소)")] [Tooltip("위팔을 몸쪽으로 내리는 각도. 70 전후가 자연스럽다")] [Range(0f, 90f)] [SerializeField] float upperArmDown = 70f; [Tooltip("아래팔을 조금 더 내려 팔이 늘어지게 한다")] [Range(0f, 40f)] [SerializeField] float lowerArmDown = 10f; [Tooltip("팔을 몸에서 살짝 띄운다. 0 이면 몸에 붙는다")] [Range(0f, 20f)] [SerializeField] float armOutward = 5f; [Header("호흡")] [SerializeField] bool breathing = true; [Tooltip("가슴이 오르내리는 각도")] [Range(0f, 5f)] [SerializeField] float breathAmplitude = 1.2f; [Tooltip("한 번 호흡하는 데 걸리는 시간(초)")] [SerializeField] float breathPeriod = 4f; [Header("체중 이동")] [SerializeField] bool weightShift = true; [Tooltip("좌우로 기우는 각도")] [Range(0f, 5f)] [SerializeField] float swayAmplitude = 1.2f; [Tooltip("한 번 왕복하는 데 걸리는 시간(초). 호흡과 주기를 다르게 해야 기계적으로 안 보인다")] [SerializeField] float swayPeriod = 7f; 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) { if (!warned) { warned = true; Debug.LogWarning("[ProceduralIdle] 휴머노이드 Animator 를 찾지 못했습니다."); } return false; } hips = animator.GetBoneTransform(HumanBodyBones.Hips); chest = animator.GetBoneTransform(HumanBodyBones.Chest) ?? animator.GetBoneTransform(HumanBodyBones.Spine); leftUpperArm = animator.GetBoneTransform(HumanBodyBones.LeftUpperArm); rightUpperArm = animator.GetBoneTransform(HumanBodyBones.RightUpperArm); leftLowerArm = animator.GetBoneTransform(HumanBodyBones.LeftLowerArm); rightLowerArm = animator.GetBoneTransform(HumanBodyBones.RightLowerArm); 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(); } /// /// T포즈에서 팔은 좌우로 뻗어 있다. 캐릭터의 forward 축을 중심으로 돌려 내린다. /// 왼팔은 -right 방향이라 +각도, 오른팔은 +right 방향이라 -각도가 아래로 향한다. /// void ApplyArms() { RotateArm(leftUpperArm, +upperArmDown, +armOutward); RotateArm(rightUpperArm, -upperArmDown, -armOutward); // 위팔을 돌리면 아래팔도 따라오므로, 여기서는 추가분만 얹는다. RotateArm(leftLowerArm, +lowerArmDown, 0f); RotateArm(rightLowerArm, -lowerArmDown, 0f); } void RotateArm(Transform bone, float downAngle, float outwardAngle) { if (bone == null) return; Quaternion delta = Quaternion.AngleAxis(downAngle, root.forward); if (!Mathf.Approximately(outwardAngle, 0f)) { delta = Quaternion.AngleAxis(outwardAngle, root.up) * delta; } bone.rotation = delta * bone.rotation; } void ApplyBody() { float t = Time.time + phaseOffset; if (breathing && chest != null && breathPeriod > 0.01f) { float breath = Mathf.Sin(t * Mathf.PI * 2f / breathPeriod); chest.rotation = Quaternion.AngleAxis(-breath * breathAmplitude, root.right) * chest.rotation; } if (weightShift && hips != null && swayPeriod > 0.01f) { float sway = Mathf.Sin(t * Mathf.PI * 2f / swayPeriod); hips.rotation = Quaternion.AngleAxis(sway * swayAmplitude, root.forward) * hips.rotation; } } }