diff --git a/Assets/_Game/Scripts/Editor/AnimationSectionSpeedEditor.cs b/Assets/_Game/Scripts/Editor/AnimationSectionSpeedEditor.cs new file mode 100644 index 00000000..830adba7 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/AnimationSectionSpeedEditor.cs @@ -0,0 +1,441 @@ +using UnityEngine; + +using UnityEditor; + +/// +/// 애니메이션 클립의 특정 프레임 구간 재생 속도를 조절하는 에디터 윈도우입니다. +/// 지정한 프레임 범위의 키프레임 간격을 늘리거나 줄여 특정 동작을 강조할 수 있습니다. +/// +/// 사용법: +/// 1. Tools → Animation Section Speed Editor 열기 +/// 2. Project 창에서 .anim 파일 선택 (FBX 내부 클립은 Extract 후 사용) +/// 3. 프레임 범위와 속도 배율 설정 +/// 4. Apply 클릭 +/// +/// ※ AssetDatabase.ImportAsset()을 호출하지 않고 AnimationUtility API로 메모리에서 수정 후 +/// SetDirty + SaveAssets로 저장합니다. Model Importer 파이프라인을 우회하여 +/// FBX 전체 타임라인이 끌려오는 문제를 방지합니다. +/// +public class AnimationSectionSpeedEditor : EditorWindow +{ + [MenuItem("Tools/Animation Section Speed Editor")] + public static void ShowWindow() + { + GetWindow("Section Speed Editor"); + } + + private AnimationClip clip; + private int fps = 30; + private int startFrame; + private int endFrame = 10; + private float speedMultiplier = 1f; + private Vector2 scrollPos; + + private void OnGUI() + { + scrollPos = EditorGUILayout.BeginScrollView(scrollPos); + + // ── 클립 선택 ── + EditorGUI.BeginChangeCheck(); + clip = (AnimationClip)EditorGUILayout.ObjectField("Animation Clip", clip, typeof(AnimationClip), false); + if (EditorGUI.EndChangeCheck() && clip != null) + { + endFrame = Mathf.FloorToInt(clip.length * fps); + } + + EditorGUILayout.Space(); + + if (clip == null) + { + EditorGUILayout.HelpBox("Animation Clip을 선택하세요.\n(FBX 내부 클립은 미리 Extract 해야 합니다)", MessageType.Info); + EditorGUILayout.EndScrollView(); + return; + } + + // ── 클립 정보 (실제 키프레임 기반) ── + float actualLength = GetActualLastKeyframeTime(clip); + EditorGUILayout.LabelField("Clip Info", EditorStyles.boldLabel); + EditorGUI.indentLevel++; + EditorGUILayout.LabelField("Length (keyframes)", $"{actualLength:F3}s"); + EditorGUILayout.LabelField("m_StopTime", $"{clip.length:F3}s"); + + if (Mathf.Abs(clip.length - actualLength) > 0.01f) + { + EditorGUILayout.HelpBox($"m_StopTime({clip.length:F3}s)이 실제 키프레임 길이({actualLength:F3}s)와 다릅니다.\n" + + "스피드 변경 시 실제 키프레임 길이를 기준으로 계산합니다.", MessageType.Warning); + } + + EditorGUILayout.LabelField("Total Frames ({fps}fps)", $"{Mathf.Max(1, Mathf.FloorToInt(actualLength * fps))}"); + EditorGUI.indentLevel--; + + EditorGUILayout.Space(); + + // ── FPS 설정 ── + fps = EditorGUILayout.IntField("FPS", fps); + fps = Mathf.Max(1, fps); + + int totalFrames = Mathf.Max(1, Mathf.FloorToInt(actualLength * fps)); + + // ── 프레임 범위 ── + EditorGUILayout.LabelField("Target Frame Range", EditorStyles.boldLabel); + EditorGUI.indentLevel++; + + int newStart = EditorGUILayout.IntField("Start Frame", startFrame); + int newEnd = EditorGUILayout.IntField("End Frame", endFrame); + + startFrame = Mathf.Clamp(newStart, 0, totalFrames - 1); + endFrame = Mathf.Clamp(newEnd, 0, totalFrames); + + // 슬라이더 + float startFloat = startFrame; + float endFloat = endFrame; + EditorGUILayout.MinMaxSlider(ref startFloat, ref endFloat, 0, totalFrames); + startFrame = Mathf.RoundToInt(startFloat); + endFrame = Mathf.RoundToInt(endFloat); + EditorGUI.indentLevel--; + + if (startFrame >= endFrame) + { + EditorGUILayout.HelpBox("시작 프레임이 끝 프레임보다 크거나 같습니다.", MessageType.Warning); + EditorGUILayout.EndScrollView(); + return; + } + + float startTime = (float)startFrame / fps; + float endTime = (float)endFrame / fps; + float duration = endTime - startTime; + + EditorGUILayout.Space(); + + // ── 타임라인 시각화 ── + DrawTimeline(startTime, endTime, actualLength); + + EditorGUILayout.Space(); + + // ── 속도 배율 ── + EditorGUILayout.LabelField("Speed", EditorStyles.boldLabel); + EditorGUI.indentLevel++; + + speedMultiplier = EditorGUILayout.FloatField("Speed Multiplier", speedMultiplier); + speedMultiplier = Mathf.Max(0.1f, speedMultiplier); + + // 프리셋 버튼 + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("0.25x")) speedMultiplier = 0.25f; + if (GUILayout.Button("0.5x")) speedMultiplier = 0.5f; + if (GUILayout.Button("1.0x")) speedMultiplier = 1f; + if (GUILayout.Button("2.0x")) speedMultiplier = 2f; + EditorGUILayout.EndHorizontal(); + EditorGUI.indentLevel--; + + EditorGUILayout.Space(); + + // ── 미리보기 ── + float newDuration = duration / speedMultiplier; + float timeDelta = newDuration - duration; + float newLength = actualLength + timeDelta; + + EditorGUILayout.LabelField("Preview", EditorStyles.boldLabel); + EditorGUI.indentLevel++; + + EditorGUIUtility.labelWidth = 160; + EditorGUILayout.LabelField("Original Section", $"{duration:F3}s (frame {startFrame}~{endFrame})"); + EditorGUILayout.LabelField("New Section", $"{newDuration:F3}s"); + EditorGUILayout.LabelField("Time Delta", $"{(timeDelta >= 0 ? "+" : "")}{timeDelta:F3}s"); + EditorGUILayout.LabelField("New Clip Length", $"{newLength:F3}s"); + EditorGUIUtility.labelWidth = 0; + EditorGUI.indentLevel--; + + EditorGUILayout.Space(); + + // ── 적용 버튼 ── + if (speedMultiplier == 1f) + { + EditorGUILayout.HelpBox("속도 배율이 1.0이면 변경 사항이 없습니다.", MessageType.Info); + } + + EditorGUI.BeginDisabledGroup(speedMultiplier == 1f); + if (GUILayout.Button("Apply", GUILayout.Height(40))) + { + ApplySpeedChange(); + } + EditorGUI.EndDisabledGroup(); + + EditorGUILayout.EndScrollView(); + } + + /// + /// 타임라인을 간단한 바 형태로 시각화합니다. + /// + private void DrawTimeline(float startTime, float endTime, float clipLength) + { + float barHeight = 24f; + float padding = 4f; + Rect barRect = GUILayoutUtility.GetRect(EditorGUIUtility.currentViewWidth - 20, barHeight * 2 + padding * 3); + + // 전체 타임라인 배경 + EditorGUI.DrawRect(barRect, new Color(0.2f, 0.2f, 0.2f, 1f)); + + float startNorm = Mathf.Clamp01(startTime / clipLength); + float endNorm = Mathf.Clamp01(endTime / clipLength); + + // ── 원본 구간 (위쪽 바) ── + float topY = barRect.y + padding; + Rect origSectionRect = new Rect( + barRect.x + barRect.width * startNorm, + topY, + barRect.width * (endNorm - startNorm), + barHeight); + EditorGUI.DrawRect(origSectionRect, new Color(1f, 0.5f, 0.2f, 0.8f)); + + // 원본 구간 레이블 + GUIStyle labelStyle = new GUIStyle(EditorStyles.miniLabel); + labelStyle.alignment = TextAnchor.MiddleCenter; + labelStyle.normal.textColor = Color.white; + EditorGUI.LabelField(origSectionRect, $"Original ({startFrame}~{endFrame})", labelStyle); + + // ── 변경 후 구간 (아래쪽 바) ── + float newDuration = (endTime - startTime) / speedMultiplier; + float newClipLength = clipLength + (newDuration - (endTime - startTime)); + float newEndNorm = Mathf.Clamp01((startTime + newDuration) / newClipLength); + + float botY = topY + barHeight + padding; + Rect newSectionRect = new Rect( + barRect.x + barRect.width * startNorm, + botY, + barRect.width * (newEndNorm - startNorm), + barHeight); + EditorGUI.DrawRect(newSectionRect, new Color(0.2f, 0.8f, 0.4f, 0.8f)); + + EditorGUI.LabelField(newSectionRect, $"New ({speedMultiplier}x)", labelStyle); + + // 바 테두리 + EditorGUI.DrawRect(barRect, new Color(0.4f, 0.4f, 0.4f, 1f)); + } + + /// + /// 본 트랜스폼 커브에서 실제 마지막 키프레임 시간을 구합니다. + /// clip.length는 m_StopTime 기반이라 FBX Extract 클립에서는 실제 데이터와 다를 수 있습니다. + /// IK 커브나 모션 플로트 커브는 제외하고, 본의 m_LocalRotation/Position/Scale만 참조합니다. + /// + private static readonly string[] BoneTransformProperties = + { + "m_LocalRotation.x", "m_LocalRotation.y", "m_LocalRotation.z", "m_LocalRotation.w", + "m_LocalPosition.x", "m_LocalPosition.y", "m_LocalPosition.z", + "m_LocalScale.x", "m_LocalScale.y", "m_LocalScale.z", + "localEulerAnglesRaw.x", "localEulerAnglesRaw.y", "localEulerAnglesRaw.z", + }; + + private static float GetActualLastKeyframeTime(AnimationClip clip) + { + float maxTime = 0f; + EditorCurveBinding[] bindings = AnimationUtility.GetCurveBindings(clip); + foreach (EditorCurveBinding binding in bindings) + { + // 모션 플로트 커브: path가 "0"이면 Humanoid muscle curve → 건너뛰기 + if (binding.path == "0") + continue; + + // 본 트랜스폼 프로퍼티만 참조 (IK 커브 등 제외) + bool isBoneTransform = false; + for (int i = 0; i < BoneTransformProperties.Length; i++) + { + if (binding.propertyName == BoneTransformProperties[i]) + { + isBoneTransform = true; + break; + } + } + if (!isBoneTransform) + continue; + + AnimationCurve curve = AnimationUtility.GetEditorCurve(clip, binding); + if (curve == null || curve.length == 0) continue; + + float lastTime = curve.keys[curve.length - 1].time; + if (lastTime > maxTime) + maxTime = lastTime; + } + + // 이벤트도 고려 + AnimationEvent[] events = AnimationUtility.GetAnimationEvents(clip); + foreach (var evt in events) + { + if (evt.time > maxTime) + maxTime = evt.time; + } + + return maxTime; + } + + /// + /// Humanoid muscle curve인지 확인합니다. path가 "0"이면 Humanoid muscle curve입니다. + /// bone transform, IK, prop, eyeLight 모두 수정 대상에 포함합니다. + /// + private static bool IsHumanoidMuscleCurve(string path) + { + return path == "0"; + } + + /// + /// AnimationUtility API를 사용하여 메모리에서 특정 구간의 재생 속도를 조절합니다. + /// AssetDatabase.ImportAsset()을 호출하지 않으므로 Model Importer 파이프라인을 우회합니다. + /// + /// 처리 순서: + /// 1. AnimationUtility.GetAllCurves로 모든 커브를 가져옴 + /// 2. 본 경로(IsBonePath) 필터링 + /// 3. 대상 구간 내 키프레임 시간 재배열 + 접선 스케일링 + /// 4. 대상 구간 외 후속 키프레임 시간 이동 + /// 5. AnimationUtility.SetEditorCurve로 커브 덮어쓰기 + /// 6. SerializedObject로 m_StopTime 갱신 + /// 7. AnimationEvent 시간 이동 + /// 8. SetDirty + SaveAssets (ImportAsset 호출 금지) + /// + private void ApplySpeedChange() + { + if (clip == null) return; + + float startTime = (float)startFrame / fps; + float endTime = (float)endFrame / fps; + float duration = endTime - startTime; + float newDuration = duration / speedMultiplier; + float timeDelta = newDuration - duration; + float actualLength = GetActualLastKeyframeTime(clip); + + Undo.RegisterCompleteObjectUndo(clip, $"Section Speed {speedMultiplier}x (frames {startFrame}~{endFrame})"); + + // ── 1. 모든 커브 가져오기 ── + EditorCurveBinding[] bindings = AnimationUtility.GetCurveBindings(clip); + AnimationCurve[] curves = new AnimationCurve[bindings.Length]; + for (int i = 0; i < bindings.Length; i++) + { + curves[i] = AnimationUtility.GetEditorCurve(clip, bindings[i]); + } + + int modifiedCurves = 0; + int modifiedKeyframes = 0; + int skippedCurves = 0; + + // ── 2~4. 커브별 키프레임 시간 수정 ── + for (int i = 0; i < bindings.Length; i++) + { + EditorCurveBinding binding = bindings[i]; + AnimationCurve curve = curves[i]; + + // Humanoid muscle curve만 건너뛰기 (IK, prop, eyeLight 모두 수정 대상) + if (IsHumanoidMuscleCurve(binding.path)) + { + skippedCurves++; + continue; + } + + if (curve == null || curve.length == 0) + continue; + + // 새 키프레임 배열 생성 + Keyframe[] newKeys = new Keyframe[curve.length]; + bool anyModified = false; + + for (int k = 0; k < curve.length; k++) + { + Keyframe oldKey = curve.keys[k]; + float t = oldKey.time; + + if (t >= startTime - 0.0001f && t <= endTime + 0.0001f) + { + // 구간 내 키프레임: 시간 재배열 + 접선 스케일링 + float normalizedPos = (t - startTime) / duration; + float newTime = startTime + normalizedPos * newDuration; + + // 접선 스케일링: 시간축이 늘어나면 기울기는 줄어야 함 + // dy/dt' = dy/dt * speedMultiplier (t' = t/speedMultiplier 이므로) + float tangentScale = speedMultiplier; + + newKeys[k] = new Keyframe( + newTime, + oldKey.value, + oldKey.inTangent * tangentScale, + oldKey.outTangent * tangentScale, + oldKey.inWeight, + oldKey.outWeight); + + anyModified = true; + modifiedKeyframes++; + } + else if (t > endTime + 0.0001f) + { + // 구간 이후 키프레임: 시간만 이동 (접선은 그대로) + newKeys[k] = new Keyframe( + t + timeDelta, + oldKey.value, + oldKey.inTangent, + oldKey.outTangent, + oldKey.inWeight, + oldKey.outWeight); + + anyModified = true; + modifiedKeyframes++; + } + else + { + // 구간 이전 키프레임: 그대로 + newKeys[k] = oldKey; + } + } + + if (anyModified) + { + curves[i] = new AnimationCurve(newKeys); + AnimationUtility.SetEditorCurve(clip, binding, curves[i]); + modifiedCurves++; + } + } + + // ── 5. m_StopTime 갱신 (SerializedObject로) ── + float newStopTime = actualLength + timeDelta; + var serializedClip = new SerializedObject(clip); + SerializedProperty stopTimeProp = serializedClip.FindProperty("m_StopTime"); + if (stopTimeProp != null) + { + stopTimeProp.floatValue = newStopTime; + serializedClip.ApplyModifiedProperties(); + } + + // ── 6. AnimationEvent 시간 이동 ── + AnimationEvent[] events = AnimationUtility.GetAnimationEvents(clip); + bool eventsModified = false; + for (int i = 0; i < events.Length; i++) + { + float evtTime = events[i].time; + + if (evtTime >= startTime - 0.0001f && evtTime <= endTime + 0.0001f) + { + float normalizedPos = (evtTime - startTime) / duration; + events[i].time = startTime + normalizedPos * newDuration; + eventsModified = true; + } + else if (evtTime > endTime + 0.0001f) + { + events[i].time = evtTime + timeDelta; + eventsModified = true; + } + } + + if (eventsModified) + { + AnimationUtility.SetAnimationEvents(clip, events); + } + + // ── 7. 저장 (ImportAsset 호출 금지) ── + EditorUtility.SetDirty(clip); + AssetDatabase.SaveAssets(); + Repaint(); + + Debug.Log($"[SectionSpeedEditor] {clip.name}: frames {startFrame}~{endFrame} → {speedMultiplier}x " + + $"({duration:F3}s → {newDuration:F3}s) " + + $"| clip: {actualLength:F3}s → {newStopTime:F3}s " + + $"| curves: {modifiedCurves}, keyframes: {modifiedKeyframes}, skipped: {skippedCurves}" + + $" | events: {(eventsModified ? "modified" : "none")}"); + } + +} diff --git a/Assets/_Game/Scripts/Editor/AnimationSectionSpeedEditor.cs.meta b/Assets/_Game/Scripts/Editor/AnimationSectionSpeedEditor.cs.meta new file mode 100644 index 00000000..527bf54a --- /dev/null +++ b/Assets/_Game/Scripts/Editor/AnimationSectionSpeedEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 11af7b19aec6eff43b6fa837908b7fe6 \ No newline at end of file diff --git a/Assets/_Game/Scripts/Editor/ReverseAnimation.cs b/Assets/_Game/Scripts/Editor/ReverseAnimation.cs new file mode 100644 index 00000000..40cc276b --- /dev/null +++ b/Assets/_Game/Scripts/Editor/ReverseAnimation.cs @@ -0,0 +1,83 @@ +using UnityEngine; + +using UnityEditor; + +using System.Collections.Generic; + +/// +/// 선택한 AnimationClip의 키프레임 순서를 반전합니다. +/// FBX 임포트 클립에는 적용 불가 — 우선 Extract 후 사용하세요. +/// +public static class ReverseAnimation +{ + public static AnimationClip GetSelectedClip() + { + var clips = Selection.GetFiltered(typeof(AnimationClip), SelectionMode.Assets); + if (clips.Length > 0) + return clips[0] as AnimationClip; + + return null; + } + + [MenuItem("Tools/ReverseAnimation")] + public static void Reverse() + { + var clip = GetSelectedClip(); + if (clip == null) + return; + + float clipLength = clip.length; + + List curves = new List(); + EditorCurveBinding[] editorCurveBindings = AnimationUtility.GetCurveBindings(clip); + + foreach (EditorCurveBinding i in editorCurveBindings) + { + var curve = AnimationUtility.GetEditorCurve(clip, i); + curves.Add(curve); + } + + clip.ClearCurves(); + + for (int i = 0; i < curves.Count; i++) + { + var curve = curves[i]; + var binding = editorCurveBindings[i]; + var keys = curve.keys; + int keyCount = keys.Length; + + var postWrapmode = curve.postWrapMode; + curve.postWrapMode = curve.preWrapMode; + curve.preWrapMode = postWrapmode; + + for (int j = 0; j < keyCount; j++) + { + Keyframe K = keys[j]; + K.time = clipLength - K.time; + + var tmp = -K.inTangent; + K.inTangent = -K.outTangent; + K.outTangent = tmp; + + keys[j] = K; + } + + curve.keys = keys; + clip.SetCurve(binding.path, binding.type, binding.propertyName, curve); + } + + // AnimationEvent 시간도 반전 + var events = AnimationUtility.GetAnimationEvents(clip); + if (events.Length > 0) + { + for (int i = 0; i < events.Length; i++) + { + events[i].time = clipLength - events[i].time; + } + AnimationUtility.SetAnimationEvents(clip, events); + } + + Debug.Log("[ReverseAnimation] Animation reversed: " + clip.name); + EditorUtility.SetDirty(clip); + } +} diff --git a/Assets/_Game/Scripts/Editor/ReverseAnimation.cs.meta b/Assets/_Game/Scripts/Editor/ReverseAnimation.cs.meta new file mode 100644 index 00000000..760e3877 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/ReverseAnimation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cf6abf78150fb14409bc53815b9f432f \ No newline at end of file