Files
Colosseum/Assets/_Game/Scripts/Editor/AnimationSectionSpeedEditor.cs
dal4segno 08b1e3d95a fix: Section Speed Editor에서 본 곡선 없는 클립 length 0으로 표시되는 문제 수정
- Mixamo export 클립 등 m_FloatCurves만 있는 경우 bone transform 곡선이 없어 length가 0으로 계산되던 문제 수정
- bone 곡선이 없으면 m_StopTime을 기준으로 폴백하도록 effectiveLength 로직 추가
2026-04-02 13:45:06 +09:00

453 lines
18 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);
bool hasBoneCurves = actualLength > 0f;
// 본 트랜스폼 곡선이 없으면 m_StopTime을 기준으로 사용
// (Mixamo export 클립 등 m_FloatCurves만 있는 경우)
float effectiveLength = hasBoneCurves ? actualLength : clip.length;
EditorGUILayout.LabelField("Clip Info", EditorStyles.boldLabel);
EditorGUI.indentLevel++;
EditorGUILayout.LabelField("Length", $"{effectiveLength:F3}s");
if (hasBoneCurves && Mathf.Abs(clip.length - actualLength) > 0.01f)
{
EditorGUILayout.LabelField("m_StopTime", $"{clip.length:F3}s");
EditorGUILayout.HelpBox($"m_StopTime({clip.length:F3}s)이 실제 키프레임 길이({actualLength:F3}s)와 다릅니다.\n" +
"스피드 변경 시 실제 키프레임 길이를 기준으로 계산합니다.", MessageType.Warning);
}
else if (!hasBoneCurves)
{
EditorGUILayout.LabelField(" (본 곡선 없음, m_StopTime 기준)", EditorStyles.miniLabel);
}
EditorGUILayout.LabelField("Total Frames ({fps}fps)", $"{Mathf.Max(1, Mathf.FloorToInt(effectiveLength * fps))}");
EditorGUI.indentLevel--;
EditorGUILayout.Space();
// ── FPS 설정 ──
fps = EditorGUILayout.IntField("FPS", fps);
fps = Mathf.Max(1, fps);
int totalFrames = Mathf.Max(1, Mathf.FloorToInt(effectiveLength * 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, effectiveLength);
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 = effectiveLength + 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);
float effectiveLength = actualLength > 0f ? actualLength : clip.length;
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 = effectiveLength + 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: {effectiveLength:F3}s → {newStopTime:F3}s " +
$"| curves: {modifiedCurves}, keyframes: {modifiedKeyframes}, skipped: {skippedCurves}" +
$" | events: {(eventsModified ? "modified" : "none")}");
}
}