using System.Collections.Generic; using System.IO; using System.Linq; using UnityEditor; using UnityEngine; namespace Streamingle.AnimationCleanup.Editor { /// /// Removes contaminated keys at the boundaries of shared blend-shape recording gaps. /// No keys are generated and the original clip is never modified. /// public sealed class BlendShapeSpikeCleanupWindow : EditorWindow { private const float TimeEpsilon = 0.00001f; private AnimationClip sourceClip; private int minimumGapFrames = 6; private int keysBeforeGap = 2; private int keysAfterGap = 2; private int maximumBoundaryDistanceFrames = 3; private bool linearizeGapBridge = true; private string summary = "애니메이션 클립을 선택하세요."; [MenuItem("Tools/Streamingle Utilities/Animation/Blend Shape Gap Cleanup")] private static void Open() { GetWindow("공백 경계 튐 정리"); } private void OnEnable() { minSize = new Vector2(500f, 330f); } private void OnGUI() { EditorGUILayout.LabelField("블렌드셰이프 공백 경계 튐 정리", EditorStyles.boldLabel); EditorGUILayout.HelpBox( "모든 블렌드셰이프 키가 함께 비는 녹화 누락 구간을 찾습니다. " + "그 구간의 바로 앞·뒤에 남은 오염 키만 제거합니다. 새 키를 만들거나 스무스를 주지 않으며, 원본 대신 복제본을 만듭니다.", MessageType.Info); EditorGUI.BeginChangeCheck(); sourceClip = (AnimationClip)EditorGUILayout.ObjectField("대상 애니메이션 클립", sourceClip, typeof(AnimationClip), false); if (EditorGUI.EndChangeCheck()) { summary = sourceClip == null ? "애니메이션 클립을 선택하세요." : "분석 버튼을 눌러 공백 경계 키 수를 확인하세요."; } using (new EditorGUI.DisabledScope(sourceClip == null)) { float frameRate = sourceClip == null ? 30f : Mathf.Max(1f, sourceClip.frameRate); EditorGUILayout.LabelField(string.Format("클립 프레임레이트: {0:F2}", frameRate), EditorStyles.miniLabel); minimumGapFrames = Mathf.Max(1, EditorGUILayout.IntField(new GUIContent("최소 공백 길이 (프레임)", "이 프레임 이상 모든 블렌드셰이프 키가 비는 구간만 처리합니다."), minimumGapFrames)); keysBeforeGap = Mathf.Clamp(EditorGUILayout.IntField(new GUIContent("공백 직전 제거 키 수", "공백 시작 지점에서 과거 방향으로 제거할 키 수입니다."), keysBeforeGap), 0, 8); keysAfterGap = Mathf.Clamp(EditorGUILayout.IntField(new GUIContent("공백 직후 제거 키 수", "공백 종료 지점에서 미래 방향으로 제거할 키 수입니다."), keysAfterGap), 0, 8); int requiredDistance = Mathf.Max(keysBeforeGap, keysAfterGap); maximumBoundaryDistanceFrames = Mathf.Max(requiredDistance, EditorGUILayout.IntField(new GUIContent("최대 경계 거리 (프레임)", "키가 공백 경계에서 이 거리보다 멀면 안전하게 건너뜁니다."), maximumBoundaryDistanceFrames)); linearizeGapBridge = EditorGUILayout.Toggle(new GUIContent("공백 구간 부드럽게 연결", "경계 키 제거 후 남은 양끝의 탄젠트를 같은 기울기로 맞춰, 공백 구간을 오버슈트 없이 직선으로 연결합니다."), linearizeGapBridge); EditorGUILayout.BeginHorizontal(); if (GUILayout.Button("적용 대상 분석")) { Analyze(); } if (GUILayout.Button("복제본 만들기", GUILayout.Height(22f))) { CreateCopy(); } EditorGUILayout.EndHorizontal(); } EditorGUILayout.Space(8f); EditorGUILayout.LabelField("작업 상태", EditorStyles.miniBoldLabel); EditorGUILayout.LabelField(summary, EditorStyles.wordWrappedMiniLabel); } private void Analyze() { float frameRate = Mathf.Max(1f, sourceClip.frameRate); List gaps = FindSharedBlendShapeGaps(sourceClip, minimumGapFrames / frameRate); RemovalPlan plan = BuildPlan(sourceClip, gaps, frameRate); summary = string.Format( "공백 {0}개를 찾았습니다. {1}개 커브에서 경계 키 {2}개를 제거할 수 있습니다.", gaps.Count, plan.curveCount, plan.keyCount); } private void CreateCopy() { string sourcePath = AssetDatabase.GetAssetPath(sourceClip); if (string.IsNullOrEmpty(sourcePath)) { EditorUtility.DisplayDialog("복제본 생성 불가", "프로젝트에 저장된 AnimationClip을 선택해야 합니다.", "확인"); return; } float frameRate = Mathf.Max(1f, sourceClip.frameRate); List gaps = FindSharedBlendShapeGaps(sourceClip, minimumGapFrames / frameRate); RemovalPlan plan = BuildPlan(sourceClip, gaps, frameRate); if (plan.keyCount == 0) { summary = string.Format("공백 {0}개를 찾았지만 제거할 경계 키가 없습니다.", gaps.Count); return; } string directory = Path.GetDirectoryName(sourcePath)?.Replace('\\', '/') ?? "Assets"; string destinationPath = AssetDatabase.GenerateUniqueAssetPath(directory + "/" + sourceClip.name + "_GapEdgeCleaned.anim"); if (!AssetDatabase.CopyAsset(sourcePath, destinationPath)) { EditorUtility.DisplayDialog("복제 실패", "선택한 AnimationClip의 복제본을 만들지 못했습니다.", "확인"); return; } AnimationClip cleanedClip = AssetDatabase.LoadAssetAtPath(destinationPath); int removedKeyCount = ApplyPlan(cleanedClip, plan, gaps, linearizeGapBridge); EditorUtility.SetDirty(cleanedClip); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); Selection.activeObject = cleanedClip; EditorGUIUtility.PingObject(cleanedClip); summary = string.Format( "{0} 생성 완료: 공백 {1}개에서 {2}개 커브의 경계 키 {3}개를 제거{4}.", Path.GetFileName(destinationPath), gaps.Count, plan.curveCount, removedKeyCount, linearizeGapBridge ? "하고 부드럽게 연결했습니다" : "했습니다"); } private RemovalPlan BuildPlan(AnimationClip clip, List gaps, float frameRate) { Dictionary> indicesByBinding = new Dictionary>(); float maximumDistance = maximumBoundaryDistanceFrames / Mathf.Max(1f, frameRate); foreach (EditorCurveBinding binding in AnimationUtility.GetCurveBindings(clip)) { if (!IsBlendShape(binding)) { continue; } AnimationCurve curve = AnimationUtility.GetEditorCurve(clip, binding); if (curve == null || curve.length < 3) { continue; } Keyframe[] keys = curve.keys; HashSet indices = new HashSet(); foreach (GapRange gap in gaps) { AddBeforeGapKeys(keys, indices, gap.startTime, maximumDistance); AddAfterGapKeys(keys, indices, gap.endTime, maximumDistance); } if (indices.Count > 0) { indicesByBinding.Add(binding, indices.OrderByDescending(index => index).ToList()); } } return new RemovalPlan(indicesByBinding); } private void AddBeforeGapKeys(Keyframe[] keys, HashSet indices, float startTime, float maximumDistance) { int index = FindLastKeyAtOrBefore(keys, startTime); if (index < 0 || startTime - keys[index].time > maximumDistance) { return; } for (int count = 0; count < keysBeforeGap; count++, index--) { if (index <= 0 || startTime - keys[index].time > maximumDistance || keys.Length - indices.Count <= 2) { break; } indices.Add(index); } } private void AddAfterGapKeys(Keyframe[] keys, HashSet indices, float endTime, float maximumDistance) { int index = FindFirstKeyAtOrAfter(keys, endTime); if (index < 0 || keys[index].time - endTime > maximumDistance) { return; } for (int count = 0; count < keysAfterGap; count++, index++) { if (index >= keys.Length - 1 || keys[index].time - endTime > maximumDistance || keys.Length - indices.Count <= 2) { break; } indices.Add(index); } } private static int ApplyPlan(AnimationClip clip, RemovalPlan plan, List gaps, bool linearizeGaps) { int removedKeyCount = 0; foreach (KeyValuePair> entry in plan.indicesByBinding) { AnimationCurve curve = AnimationUtility.GetEditorCurve(clip, entry.Key); if (curve == null) { continue; } foreach (int index in entry.Value) { if (index > 0 && index < curve.length - 1) { curve.RemoveKey(index); removedKeyCount++; } } if (linearizeGaps) { foreach (GapRange gap in gaps) { LinearizeGapBridge(curve, gap); } } AnimationUtility.SetEditorCurve(clip, entry.Key, curve); } return removedKeyCount; } private static void LinearizeGapBridge(AnimationCurve curve, GapRange gap) { Keyframe[] keys = curve.keys; int startIndex = FindLastKeyAtOrBefore(keys, gap.startTime); int endIndex = FindFirstKeyAtOrAfter(keys, gap.endTime); if (startIndex < 0 || endIndex < 0 || startIndex >= endIndex) { return; } float duration = keys[endIndex].time - keys[startIndex].time; if (duration <= TimeEpsilon) { return; } float slope = (keys[endIndex].value - keys[startIndex].value) / duration; AnimationUtility.SetKeyBroken(curve, startIndex, true); AnimationUtility.SetKeyBroken(curve, endIndex, true); AnimationUtility.SetKeyRightTangentMode(curve, startIndex, AnimationUtility.TangentMode.Free); AnimationUtility.SetKeyLeftTangentMode(curve, endIndex, AnimationUtility.TangentMode.Free); keys = curve.keys; Keyframe startKey = keys[startIndex]; Keyframe endKey = keys[endIndex]; startKey.outTangent = slope; endKey.inTangent = slope; keys[startIndex] = startKey; keys[endIndex] = endKey; curve.keys = keys; } private static List FindSharedBlendShapeGaps(AnimationClip clip, float minimumGapDuration) { List keyTimes = new List(); foreach (EditorCurveBinding binding in AnimationUtility.GetCurveBindings(clip)) { if (!IsBlendShape(binding)) { continue; } AnimationCurve curve = AnimationUtility.GetEditorCurve(clip, binding); if (curve != null) { keyTimes.AddRange(curve.keys.Select(key => key.time)); } } keyTimes.Sort(); List gaps = new List(); for (int index = 1; index < keyTimes.Count; index++) { float previous = keyTimes[index - 1]; float current = keyTimes[index]; if (current - previous >= minimumGapDuration) { gaps.Add(new GapRange(previous, current)); } } return gaps; } private static int FindLastKeyAtOrBefore(Keyframe[] keys, float time) { for (int index = keys.Length - 1; index >= 0; index--) { if (keys[index].time <= time + TimeEpsilon) { return index; } } return -1; } private static int FindFirstKeyAtOrAfter(Keyframe[] keys, float time) { for (int index = 0; index < keys.Length; index++) { if (keys[index].time >= time - TimeEpsilon) { return index; } } return -1; } private static bool IsBlendShape(EditorCurveBinding binding) { return binding.propertyName.IndexOf("blendShape.", System.StringComparison.OrdinalIgnoreCase) >= 0; } private readonly struct GapRange { public readonly float startTime; public readonly float endTime; public GapRange(float startTime, float endTime) { this.startTime = startTime; this.endTime = endTime; } } private sealed class RemovalPlan { public readonly Dictionary> indicesByBinding; public readonly int curveCount; public readonly int keyCount; public RemovalPlan(Dictionary> indicesByBinding) { this.indicesByBinding = indicesByBinding; curveCount = indicesByBinding.Count; keyCount = indicesByBinding.Sum(pair => pair.Value.Count); } } } }