// ForearmHingeBaker.cs // Unity Editor 스크립트 — Assets/Editor/ 폴더에 배치 // // Humanoid 애니메이션 클립에서 Forearm의 비-힌지 회전 성분을 제거하고, // UpperArm 회전을 보정하여 Hand가 원래 방향을 유지하도록 합니다. // Biped의 단축(힌지) Forearm과 호환되는 Generic 클립을 생성합니다. // // 알고리즘: // 1. 원본 포즈에서 Hand 월드 위치/회전을 기록 // 2. Forearm 힌지각을 해석적으로 풀이 — Hand가 그리는 원 위에서 원본 Hand에 가장 가까운 점 // 3. UpperArm을 최소한으로 보정하여 Hand가 원래 방향을 가리키도록 // 4. Hand 월드 회전을 원본으로 복원 // // 사용법: // 1. 씬에 Animator + Avatar가 설정된 캐릭터 배치 // 2. Tools > Animation > Forearm Hinge Baker // 3. Animator와 소스 클립 지정 // 4. 힌지축 설정 (Forearm이 구부러지는 로컬 축) // 5. Bake 실행 → _hinged.anim 파일 생성 using UnityEngine; using UnityEditor; using System.Collections.Generic; public class ForearmHingeBaker : EditorWindow { Animator animator; AnimationClip sourceClip; int sampleRate = 30; enum HingeAxis { X, Y, Z } HingeAxis hingeAxis = HingeAxis.Z; [MenuItem("Tools/Animation/Forearm Hinge Baker")] static void Open() { var win = GetWindow("Forearm Hinge Baker"); win.minSize = new Vector2(350, 280); } void OnGUI() { EditorGUILayout.Space(4); animator = EditorGUILayout.ObjectField("Animator (씬)", animator, typeof(Animator), true) as Animator; sourceClip = EditorGUILayout.ObjectField("소스 클립", sourceClip, typeof(AnimationClip), false) as AnimationClip; EditorGUILayout.Space(4); sampleRate = EditorGUILayout.IntSlider("샘플레이트 (fps)", sampleRate, 1, 120); hingeAxis = (HingeAxis)EditorGUILayout.EnumPopup("Forearm 힌지축 (로컬)", hingeAxis); EditorGUILayout.Space(4); EditorGUILayout.HelpBox( "Forearm 힌지각을 해석적으로 풀이합니다.\n" + "Hand가 힌지 회전으로 그리는 원 위에서 원본 위치에 가장 가까운 점을 찾고,\n" + "UpperArm을 최소한으로 보정합니다.\n\n" + "출력은 Generic 클립 (bone localRotation 기반)입니다.", MessageType.Info); EditorGUILayout.Space(4); GUI.enabled = animator != null && sourceClip != null; if (GUILayout.Button("Bake", GUILayout.Height(30))) Bake(); GUI.enabled = true; } // ============================================================ // Core // ============================================================ void Bake() { var go = animator.gameObject; // 양쪽 팔 트리플렛: upper, lower, hand var armTriplets = new (HumanBodyBones upper, HumanBodyBones lower, HumanBodyBones hand)[] { (HumanBodyBones.LeftUpperArm, HumanBodyBones.LeftLowerArm, HumanBodyBones.LeftHand), (HumanBodyBones.RightUpperArm, HumanBodyBones.RightLowerArm, HumanBodyBones.RightHand), }; // 본 계층 수집 (루트 자식부터, path = 상대경로) var allBones = new List(); var bonePaths = new Dictionary(); foreach (Transform child in go.transform) CollectHierarchy(child, child.name, allBones, bonePaths); if (allBones.Count == 0) { EditorUtility.DisplayDialog("오류", "Animator 하위에 본이 없습니다.", "OK"); return; } int frameCount = Mathf.CeilToInt(sourceClip.length * sampleRate) + 1; // 프레임별 저장소 var rotations = new Dictionary(); var positions = new Dictionary(); foreach (var bone in allBones) { rotations[bone] = new Quaternion[frameCount]; positions[bone] = new Vector3[frameCount]; } // 힌지축 벡터 Vector3 axisVec = hingeAxis switch { HingeAxis.X => Vector3.right, HingeAxis.Y => Vector3.up, HingeAxis.Z => Vector3.forward, _ => Vector3.forward }; // 수정 대상 본 세트 (arm triplet에 포함된 본들) var modifiedBones = new HashSet(); // 샘플링 + 힌지 제약 적용 AnimationMode.StartAnimationMode(); try { for (int i = 0; i < frameCount; i++) { float t = Mathf.Min((float)i / sampleRate, sourceClip.length); AnimationMode.SampleAnimationClip(go, sourceClip, t); // 모든 본의 원본 로컬 트랜스폼 기록 foreach (var bone in allBones) { rotations[bone][i] = bone.localRotation; positions[bone][i] = bone.localPosition; } // 각 팔에 힌지 제약 적용 foreach (var (upperBone, lowerBone, handBone) in armTriplets) { var upper = animator.GetBoneTransform(upperBone); var lower = animator.GetBoneTransform(lowerBone); var hand = animator.GetBoneTransform(handBone); if (upper == null || lower == null || hand == null) continue; // 첫 프레임에서 수정 대상 등록 if (i == 0) { modifiedBones.Add(upper); modifiedBones.Add(lower); modifiedBones.Add(hand); } // --- 0. 원본 월드 트랜스폼 기록 --- Vector3 origHandPos = hand.position; Quaternion origHandRot = hand.rotation; Vector3 shoulderPos = upper.position; Vector3 elbowPos = lower.position; // --- 1. 최적 힌지 각도 해석적 풀이 --- // Forearm이 힌지축으로만 회전하면 Hand는 팔꿈치 중심 원 위를 이동. // 이 원 위에서 원본 Hand 위치에 가장 가까운 점의 각도를 구함. // θ=0, θ=90 에서 Hand 위치를 샘플링하여 원의 기하 정의 lower.localRotation = Quaternion.identity; Vector3 h0 = hand.position - elbowPos; lower.localRotation = Quaternion.AngleAxis(90f, axisVec); Vector3 h90 = hand.position - elbowPos; // 월드 힌지축 Quaternion parentRot = lower.parent != null ? lower.parent.rotation : Quaternion.identity; Vector3 worldAxis = (parentRot * axisVec).normalized; // 원의 중심 (elbow 기준 오프셋) Vector3 centerOffset = Vector3.Dot(h0, worldAxis) * worldAxis; // 원 위의 기준 방향 Vector3 r0 = h0 - centerOffset; Vector3 r90 = h90 - centerOffset; // 타겟을 원 평면에 투영 Vector3 targetOffset = origHandPos - elbowPos - centerOffset; Vector3 targetInPlane = targetOffset - Vector3.Dot(targetOffset, worldAxis) * worldAxis; float theta = 0f; if (targetInPlane.sqrMagnitude > 1e-10f && r0.sqrMagnitude > 1e-10f) { theta = Mathf.Atan2( Vector3.Dot(targetInPlane.normalized, r90.normalized), Vector3.Dot(targetInPlane.normalized, r0.normalized) ) * Mathf.Rad2Deg; } // --- 2. 최적 힌지 각도 적용 --- lower.localRotation = Quaternion.AngleAxis(theta, axisVec); // --- 3. UpperArm 최소 보정 --- // 최적 힌지라도 원본 Hand가 원 밖이면 오차 존재. // FromToRotation으로 어깨→손 방향을 일치시켜 잔여 오차 보정. Vector3 currentHandPos = hand.position; Vector3 curDir = (currentHandPos - shoulderPos); Vector3 tgtDir = (origHandPos - shoulderPos); if (curDir.sqrMagnitude > 1e-8f && tgtDir.sqrMagnitude > 1e-8f) { Quaternion correction = Quaternion.FromToRotation(curDir.normalized, tgtDir.normalized); upper.rotation = correction * upper.rotation; } // --- 4. Hand 월드 회전 복원 --- hand.rotation = origHandRot; // 수정된 로컬 회전 저장 rotations[upper][i] = upper.localRotation; rotations[lower][i] = lower.localRotation; rotations[hand][i] = hand.localRotation; } // 진행률 if (i % 100 == 0) EditorUtility.DisplayProgressBar("Forearm Hinge Baker", $"샘플링 {i}/{frameCount}", (float)i / frameCount); } } finally { AnimationMode.StopAnimationMode(); EditorUtility.ClearProgressBar(); } // 새 클립 생성 var newClip = new AnimationClip(); newClip.frameRate = sampleRate; foreach (var bone in allBones) { string path = bonePaths[bone]; var rots = rotations[bone]; var poss = positions[bone]; // 회전 커브 (Quaternion) var cx = new AnimationCurve(); var cy = new AnimationCurve(); var cz = new AnimationCurve(); var cw = new AnimationCurve(); for (int j = 0; j < frameCount; j++) { float time = (float)j / sampleRate; cx.AddKey(time, rots[j].x); cy.AddKey(time, rots[j].y); cz.AddKey(time, rots[j].z); cw.AddKey(time, rots[j].w); } newClip.SetCurve(path, typeof(Transform), "localRotation.x", cx); newClip.SetCurve(path, typeof(Transform), "localRotation.y", cy); newClip.SetCurve(path, typeof(Transform), "localRotation.z", cz); newClip.SetCurve(path, typeof(Transform), "localRotation.w", cw); // 위치 커브 (변화가 있는 본만) bool posAnimated = false; for (int j = 1; j < frameCount; j++) { if ((poss[j] - poss[0]).sqrMagnitude > 1e-6f) { posAnimated = true; break; } } if (posAnimated) { var px = new AnimationCurve(); var py = new AnimationCurve(); var pz = new AnimationCurve(); for (int j = 0; j < frameCount; j++) { float time = (float)j / sampleRate; px.AddKey(time, poss[j].x); py.AddKey(time, poss[j].y); pz.AddKey(time, poss[j].z); } newClip.SetCurve(path, typeof(Transform), "localPosition.x", px); newClip.SetCurve(path, typeof(Transform), "localPosition.y", py); newClip.SetCurve(path, typeof(Transform), "localPosition.z", pz); } } newClip.EnsureQuaternionContinuity(); // 저장 string srcPath = AssetDatabase.GetAssetPath(sourceClip); string newPath; if (!string.IsNullOrEmpty(srcPath)) { string dir = System.IO.Path.GetDirectoryName(srcPath); string name = System.IO.Path.GetFileNameWithoutExtension(srcPath); newPath = $"{dir}/{name}_hinged.anim"; } else { newPath = "Assets/hinged_clip.anim"; } newPath = AssetDatabase.GenerateUniqueAssetPath(newPath); AssetDatabase.CreateAsset(newClip, newPath); AssetDatabase.SaveAssets(); Debug.Log($"[ForearmHingeBaker] 저장 완료: {newPath} ({frameCount}프레임, {allBones.Count}본)"); EditorUtility.DisplayDialog("완료", $"저장: {newPath}\n프레임: {frameCount}\n본: {allBones.Count}", "OK"); Selection.activeObject = newClip; EditorGUIUtility.PingObject(newClip); } // ============================================================ // Swing-Twist 분해 // q = swing * twist // twist = 지정 축 주위 회전 (힌지 성분, forearm에 유지) // swing = 나머지 회전 (제거 대상) // ============================================================ static void SwingTwist(Quaternion q, Vector3 twistAxis, out Quaternion swing, out Quaternion twist) { Vector3 r = new Vector3(q.x, q.y, q.z); Vector3 proj = Vector3.Dot(r, twistAxis) * twistAxis; twist = new Quaternion(proj.x, proj.y, proj.z, q.w); float mag = Mathf.Sqrt(twist.x * twist.x + twist.y * twist.y + twist.z * twist.z + twist.w * twist.w); if (mag < 1e-6f) { twist = Quaternion.identity; swing = q; } else { twist.x /= mag; twist.y /= mag; twist.z /= mag; twist.w /= mag; swing = q * Quaternion.Inverse(twist); } } // ============================================================ // 본 계층 수집 // ============================================================ void CollectHierarchy(Transform t, string path, List bones, Dictionary paths) { bones.Add(t); paths[t] = path; for (int i = 0; i < t.childCount; i++) { var child = t.GetChild(i); CollectHierarchy(child, path + "/" + child.name, bones, paths); } } }