- AnimationClipMerger: 여러 클립을 순서 조정 후 이어 붙이는 에디터 윈도우 추가 - AnimationClipTrimmerWindow: 시작/종료 프레임 지정으로 클립 잘라내기 에디터 윈도우 추가 - 기존 애니메이션 에디터 툴 메뉴를 Tools/Animation/ 서브메뉴로 통합 - Merge Clips, Trim Clip, Reverse Clip, Section Speed Editor
442 lines
17 KiB
C#
442 lines
17 KiB
C#
using UnityEngine;
|
|
|
|
using UnityEditor;
|
|
|
|
/// <summary>
|
|
/// 애니메이션 클립의 특정 프레임 구간 재생 속도를 조절하는 에디터 윈도우입니다.
|
|
/// 지정한 프레임 범위의 키프레임 간격을 늘리거나 줄여 특정 동작을 강조할 수 있습니다.
|
|
///
|
|
/// 사용법:
|
|
/// 1. Tools → Animation Section Speed Editor 열기
|
|
/// 2. Project 창에서 .anim 파일 선택 (FBX 내부 클립은 Extract 후 사용)
|
|
/// 3. 프레임 범위와 속도 배율 설정
|
|
/// 4. Apply 클릭
|
|
///
|
|
/// ※ AssetDatabase.ImportAsset()을 호출하지 않고 AnimationUtility API로 메모리에서 수정 후
|
|
/// SetDirty + SaveAssets로 저장합니다. Model Importer 파이프라인을 우회하여
|
|
/// FBX 전체 타임라인이 끌려오는 문제를 방지합니다.
|
|
/// </summary>
|
|
public class AnimationSectionSpeedEditor : EditorWindow
|
|
{
|
|
[MenuItem("Tools/Animation/Section Speed Editor")]
|
|
public static void ShowWindow()
|
|
{
|
|
GetWindow<AnimationSectionSpeedEditor>("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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 타임라인을 간단한 바 형태로 시각화합니다.
|
|
/// </summary>
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 본 트랜스폼 커브에서 실제 마지막 키프레임 시간을 구합니다.
|
|
/// clip.length는 m_StopTime 기반이라 FBX Extract 클립에서는 실제 데이터와 다를 수 있습니다.
|
|
/// IK 커브나 모션 플로트 커브는 제외하고, 본의 m_LocalRotation/Position/Scale만 참조합니다.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Humanoid muscle curve인지 확인합니다. path가 "0"이면 Humanoid muscle curve입니다.
|
|
/// bone transform, IK, prop, eyeLight 모두 수정 대상에 포함합니다.
|
|
/// </summary>
|
|
private static bool IsHumanoidMuscleCurve(string path)
|
|
{
|
|
return path == "0";
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 호출 금지)
|
|
/// </summary>
|
|
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")}");
|
|
}
|
|
|
|
}
|