using System.Collections.Generic; using System.IO; using UnityEngine; using UnityEditor; /// /// 두 AnimationClip의 경계 포즈를 샘플링해 브릿지용 AnimationClip을 생성합니다. /// 독립 .anim 클립 기준으로 사용하며, 생성 후 수동 미세 조정을 권장합니다. /// public class AnimationBridgeClipGeneratorWindow : EditorWindow { private const string MenuPath = "Tools/Animation/Generate Bridge Clip"; private AnimationClip sourceClip; private AnimationClip targetClip; private int sourceEndTrimFrames; private int targetStartTrimFrames; private int bridgeDurationFrames = 4; private string outputName = string.Empty; private string outputDirectory = "Assets/_Game/Animations"; [MenuItem(MenuPath, false, 22)] public static void ShowWindow() { AnimationClip[] selectedClips = Selection.GetFiltered(SelectionMode.Assets); AnimationBridgeClipGeneratorWindow window = GetWindow("Bridge Clip"); window.minSize = new Vector2(380f, 280f); window.InitializeFromSelection(selectedClips); window.Show(); } private void InitializeFromSelection(AnimationClip[] selectedClips) { if (selectedClips == null || selectedClips.Length < 2) return; sourceClip = selectedClips[0]; targetClip = selectedClips[1]; string sourcePath = AssetDatabase.GetAssetPath(sourceClip); if (!string.IsNullOrEmpty(sourcePath)) outputDirectory = Path.GetDirectoryName(sourcePath)?.Replace("\\", "/") ?? outputDirectory; RefreshOutputName(); } private void OnGUI() { EditorGUILayout.Space(8f); EditorGUILayout.LabelField("브릿지 클립 생성", EditorStyles.boldLabel); EditorGUILayout.HelpBox( "앞 클립의 끝 포즈와 뒤 클립의 시작 포즈를 샘플링해 짧은 브릿지 클립을 생성합니다.\n" + "Humanoid 클립은 생성 후 수동 미세 조정을 권장합니다.", MessageType.Info); EditorGUI.BeginChangeCheck(); sourceClip = (AnimationClip)EditorGUILayout.ObjectField("앞 클립", sourceClip, typeof(AnimationClip), false); targetClip = (AnimationClip)EditorGUILayout.ObjectField("뒤 클립", targetClip, typeof(AnimationClip), false); if (EditorGUI.EndChangeCheck()) RefreshOutputName(); using (new EditorGUI.DisabledScope(sourceClip == null)) { sourceEndTrimFrames = Mathf.Max(0, EditorGUILayout.IntField("앞 클립 끝에서 제외 프레임", sourceEndTrimFrames)); } using (new EditorGUI.DisabledScope(targetClip == null)) { targetStartTrimFrames = Mathf.Max(0, EditorGUILayout.IntField("뒤 클립 시작에서 제외 프레임", targetStartTrimFrames)); } bridgeDurationFrames = Mathf.Max(1, EditorGUILayout.IntField("브릿지 길이(프레임)", bridgeDurationFrames)); outputName = EditorGUILayout.TextField("출력 이름", outputName); outputDirectory = EditorGUILayout.TextField("출력 폴더", outputDirectory); EditorGUILayout.Space(8f); if (sourceClip != null) DrawClipSummary("앞 클립 샘플", sourceClip, sourceEndTrimFrames, useEndSample: true); if (targetClip != null) DrawClipSummary("뒤 클립 샘플", targetClip, targetStartTrimFrames, useEndSample: false); EditorGUILayout.Space(12f); using (new EditorGUI.DisabledScope(!CanGenerate())) { if (GUILayout.Button("브릿지 클립 생성", GUILayout.Height(32f))) GenerateBridgeClip(); } } private void DrawClipSummary(string label, AnimationClip clip, int trimFrames, bool useEndSample) { float sampleTime = GetSampleTime(clip, trimFrames, useEndSample); int totalFrames = Mathf.RoundToInt(clip.length * clip.frameRate); int sampleFrame = Mathf.RoundToInt(sampleTime * clip.frameRate); EditorGUILayout.LabelField(label, EditorStyles.boldLabel); EditorGUILayout.LabelField( $" {clip.name} / {clip.frameRate:F0} FPS / {clip.length:F3}초 / {totalFrames}프레임"); EditorGUILayout.LabelField($" 샘플 프레임: {sampleFrame} / 샘플 시간: {sampleTime:F3}초"); } private bool CanGenerate() { if (sourceClip == null || targetClip == null) return false; if (string.IsNullOrWhiteSpace(outputName) || string.IsNullOrWhiteSpace(outputDirectory)) return false; return AssetDatabase.IsValidFolder(outputDirectory); } private void RefreshOutputName() { if (sourceClip == null || targetClip == null) return; outputName = $"{sourceClip.name}_to_{targetClip.name}_Bridge"; } private void GenerateBridgeClip() { float frameRate = sourceClip != null ? sourceClip.frameRate : 30f; float duration = bridgeDurationFrames / frameRate; float sourceSampleTime = GetSampleTime(sourceClip, sourceEndTrimFrames, useEndSample: true); float targetSampleTime = GetSampleTime(targetClip, targetStartTrimFrames, useEndSample: false); AnimationClip bridgeClip = new AnimationClip { name = outputName, frameRate = frameRate, legacy = sourceClip.legacy, wrapMode = WrapMode.Once, localBounds = sourceClip.localBounds }; HashSet floatBindings = new HashSet(new EditorCurveBindingComparer()); AddCurveBindings(floatBindings, AnimationUtility.GetCurveBindings(sourceClip)); AddCurveBindings(floatBindings, AnimationUtility.GetCurveBindings(targetClip)); foreach (EditorCurveBinding binding in floatBindings) { AnimationCurve sourceCurve = AnimationUtility.GetEditorCurve(sourceClip, binding); AnimationCurve targetCurve = AnimationUtility.GetEditorCurve(targetClip, binding); if (sourceCurve == null && targetCurve == null) continue; float startValue = EvaluateCurveOrDefault(sourceCurve, sourceSampleTime); float endValue = EvaluateCurveOrDefault(targetCurve, targetSampleTime); AnimationCurve bridgeCurve = CreateBridgeCurve(startValue, endValue, duration); AnimationUtility.SetEditorCurve(bridgeClip, binding, bridgeCurve); } HashSet objectBindings = new HashSet(new EditorCurveBindingComparer()); AddCurveBindings(objectBindings, AnimationUtility.GetObjectReferenceCurveBindings(sourceClip)); AddCurveBindings(objectBindings, AnimationUtility.GetObjectReferenceCurveBindings(targetClip)); foreach (EditorCurveBinding binding in objectBindings) { ObjectReferenceKeyframe[] sourceKeys = AnimationUtility.GetObjectReferenceCurve(sourceClip, binding); ObjectReferenceKeyframe[] targetKeys = AnimationUtility.GetObjectReferenceCurve(targetClip, binding); Object startValue = SampleObjectReferenceValue(sourceKeys, sourceSampleTime); Object endValue = SampleObjectReferenceValue(targetKeys, targetSampleTime); if (startValue == null && endValue == null) continue; ObjectReferenceKeyframe[] bridgeKeys = CreateBridgeObjectCurve(startValue, endValue, duration); AnimationUtility.SetObjectReferenceCurve(bridgeClip, binding, bridgeKeys); } string savePath = AssetDatabase.GenerateUniqueAssetPath( Path.Combine(outputDirectory, outputName + ".anim").Replace("\\", "/")); AssetDatabase.CreateAsset(bridgeClip, savePath); CopyClipSettings(sourceClip, bridgeClip, duration); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); EditorUtility.FocusProjectWindow(); Selection.activeObject = bridgeClip; Debug.Log( $"[AnimationBridgeClipGenerator] 브릿지 생성 완료: {sourceClip.name} -> {targetClip.name} " + $"(앞-{sourceEndTrimFrames}f / 뒤+{targetStartTrimFrames}f / 길이 {bridgeDurationFrames}f) => {savePath}"); } private static void AddCurveBindings(HashSet destination, EditorCurveBinding[] bindings) { if (bindings == null) return; for (int i = 0; i < bindings.Length; i++) destination.Add(bindings[i]); } private static float GetSampleTime(AnimationClip clip, int trimFrames, bool useEndSample) { if (clip == null) return 0f; float frameRate = Mathf.Max(1f, clip.frameRate); int totalFrames = Mathf.Max(0, Mathf.RoundToInt(clip.length * frameRate)); int clampedTrim = Mathf.Clamp(trimFrames, 0, totalFrames); int sampleFrame = useEndSample ? Mathf.Max(0, totalFrames - clampedTrim) : clampedTrim; float sampleTime = sampleFrame / frameRate; return Mathf.Clamp(sampleTime, 0f, clip.length); } private static float EvaluateCurveOrDefault(AnimationCurve curve, float time) { return curve != null ? curve.Evaluate(time) : 0f; } private static AnimationCurve CreateBridgeCurve(float startValue, float endValue, float duration) { AnimationCurve curve = new AnimationCurve( new Keyframe(0f, startValue), new Keyframe(duration, endValue)); for (int i = 0; i < curve.keys.Length; i++) { AnimationUtility.SetKeyLeftTangentMode(curve, i, AnimationUtility.TangentMode.ClampedAuto); AnimationUtility.SetKeyRightTangentMode(curve, i, AnimationUtility.TangentMode.ClampedAuto); } return curve; } private static Object SampleObjectReferenceValue(ObjectReferenceKeyframe[] keys, float time) { if (keys == null || keys.Length == 0) return null; Object value = keys[0].value; for (int i = 0; i < keys.Length; i++) { if (keys[i].time > time) break; value = keys[i].value; } return value; } private static ObjectReferenceKeyframe[] CreateBridgeObjectCurve(Object startValue, Object endValue, float duration) { return new[] { new ObjectReferenceKeyframe { time = 0f, value = startValue }, new ObjectReferenceKeyframe { time = duration, value = endValue } }; } /// /// 원본 클립의 루프/미러/루트 모션 설정을 유지합니다. /// private static void CopyClipSettings(AnimationClip source, AnimationClip target, float duration) { if (source == null || target == null) return; SerializedObject sourceSerializedObject = new SerializedObject(source); SerializedObject targetSerializedObject = new SerializedObject(target); SerializedProperty sourceSettings = sourceSerializedObject.FindProperty("m_AnimationClipSettings"); SerializedProperty targetSettings = targetSerializedObject.FindProperty("m_AnimationClipSettings"); if (sourceSettings == null || targetSettings == null) return; string[] relativePropertyNames = { "m_AdditiveReferencePoseClip", "m_AdditiveReferencePoseTime", "m_OrientationOffsetY", "m_Level", "m_CycleOffset", "m_HasAdditiveReferencePose", "m_LoopTime", "m_LoopBlend", "m_LoopBlendOrientation", "m_LoopBlendPositionY", "m_LoopBlendPositionXZ", "m_KeepOriginalOrientation", "m_KeepOriginalPositionY", "m_KeepOriginalPositionXZ", "m_HeightFromFeet", "m_Mirror" }; for (int i = 0; i < relativePropertyNames.Length; i++) { SerializedProperty sourceProperty = sourceSettings.FindPropertyRelative(relativePropertyNames[i]); SerializedProperty targetProperty = targetSettings.FindPropertyRelative(relativePropertyNames[i]); if (sourceProperty == null || targetProperty == null) continue; CopySerializedPropertyValue(sourceProperty, targetProperty); } SerializedProperty startTimeProperty = targetSettings.FindPropertyRelative("m_StartTime"); if (startTimeProperty != null) startTimeProperty.floatValue = 0f; SerializedProperty stopTimeProperty = targetSettings.FindPropertyRelative("m_StopTime"); if (stopTimeProperty != null) stopTimeProperty.floatValue = Mathf.Max(0f, duration); targetSerializedObject.ApplyModifiedPropertiesWithoutUndo(); } private static void CopySerializedPropertyValue(SerializedProperty source, SerializedProperty target) { switch (source.propertyType) { case SerializedPropertyType.Integer: target.intValue = source.intValue; break; case SerializedPropertyType.Boolean: target.boolValue = source.boolValue; break; case SerializedPropertyType.Float: target.floatValue = source.floatValue; break; case SerializedPropertyType.ObjectReference: target.objectReferenceValue = source.objectReferenceValue; break; } } private sealed class EditorCurveBindingComparer : IEqualityComparer { public bool Equals(EditorCurveBinding x, EditorCurveBinding y) { return x.path == y.path && x.propertyName == y.propertyName && x.type == y.type && x.isPPtrCurve == y.isPPtrCurve; } public int GetHashCode(EditorCurveBinding obj) { unchecked { int hash = 17; hash = (hash * 31) + (obj.path != null ? obj.path.GetHashCode() : 0); hash = (hash * 31) + (obj.propertyName != null ? obj.propertyName.GetHashCode() : 0); hash = (hash * 31) + (obj.type != null ? obj.type.GetHashCode() : 0); hash = (hash * 31) + obj.isPPtrCurve.GetHashCode(); return hash; } } } }