361 lines
14 KiB
C#
361 lines
14 KiB
C#
// 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<ForearmHingeBaker>("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<Transform>();
|
|
var bonePaths = new Dictionary<Transform, string>();
|
|
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<Transform, Quaternion[]>();
|
|
var positions = new Dictionary<Transform, Vector3[]>();
|
|
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<Transform>();
|
|
|
|
// 샘플링 + 힌지 제약 적용
|
|
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<Transform> bones, Dictionary<Transform, string> 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);
|
|
}
|
|
}
|
|
}
|