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")}"); } }