AnimationClip:动画关键帧智能精简优化工具 (AnimationFrameExtractor)

AnimationClip:动画关键帧智能精简优化工具 (AnimationFrameExtractor)

1. 使用流程 (Usage Process)

1.1 安装说明

将下方的 完整源代码 保存为 AnimationFrameExtractor.cs,并放置在 Unity 项目的 Assets/Editor 文件夹中。工具界面已完全适配中文环境。

1.2 操作流程

  • 方法一:通过工具窗口操作
    1. 菜单栏打开 Tools > 优化 > 动画帧提取器
    2. 动画剪辑 (Clip) 槽位拖入要处理的动画资源。
    3. 调整 敏感度阈值(值越小保留帧越多,建议 0.01)。
    4. 点击 分析动画 (Analyze) 预览优化比例。
    5. 点击 执行提取并保存 (Extract) 完成优化。
  • 方法二:右键菜单快速处理
    1. 在 Project 窗口右键点击一个或多个 Animation Clip 资源。
    2. 选择 提取动画关键帧 (Extract Frames),工具将自动按默认阈值完成精简。
  • 方法三:批量处理场景对象
    1. 在场景中选择包含 AnimatorAnimation 组件的游戏对象。
    2. 在工具窗口点击 从选定对象批量提取 (Selection),工具将自动提取并优化关联的所有动画片段。

1.3 验证方法

  1. 体积检查:观察 AnimationClip 在 Inspector 面板显示的 Size 变化(通常可减少 50%-80%)。
  2. 视觉校验:在 Unity 编辑器中播放优化后的动画,确认在关键动作处无肉眼可见的抖动或形变。

2. 制作思路 (Creation Logic)

2.1 核心算法:Ramer-Douglas-Peucker

工具的核心是利用 Ramer-Douglas-Peucker 算法 来简化动画曲线。

  • 原理:在曲线起点和终点之间连线,计算所有中间点到该直线的垂直距离。
  • 判定:找到距离最远的点,若该距离大于设定的阈值(Epsilon),则保留该点并以其为界递归处理左右两段曲线;否则舍弃中间所有点。
  • 优势:相比简单的采样降频,该算法能精准保留曲线的“转折点”,在极大压缩数据的同时维持形状特征。

2.2 多维属性与切线维护

  • 属性适配:由于动画包含位置(Vector3)、旋转(Quaternion)和缩放(Vector3),工具支持针对不同属性应用独立的误差阈值。
  • 平滑度保持:在删除关键帧后,通过 AnimationUtility.GetKeyLeftTangentMode 自动维护剩余帧的切线模式,确保曲线在插值时依然平滑,不会出现机械感。

2.3 安全机制设计

  • 自动备份:执行优化前,工具会在同级目录下生成 [FileName]_backup.anim,防止误操作导致不可逆的数据丢失。
  • Undo 集成:通过 Undo.RecordObject 注册撤销操作,支持 Ctrl+Z 快速回滚。

3. 注意事项 (Precautions)

  • 原始文件风险:该工具会直接修改原始资源文件。虽然有备份机制,但仍建议在进行大规模批量处理前完成版本控制(Git/SVN)提交。
  • 循环动画衔接:工具强制保留了第一帧和最后一帧(保留首尾帧),以确保 Loop 动画在循环点不会出现跳变。
  • 阈值权衡
    • 高精度需求(如主角面部表情):建议阈值设为 0.001-0.005。
    • 低精度需求(如远景环境动画):阈值可放宽至 0.05-0.1 以换取极致的包体压缩。

4. 完整源代码 (Full Source Code)

using UnityEngine;
using UnityEditor;
using System.Collections.Generic;
using System.Linq;
using System.IO;

namespace Exploder.Editor
{
    /// <summary>
    /// 动画帧提取器 - 专门用于精简 AnimationClip 中的冗余关键帧
    /// </summary>
    public class AnimationFrameExtractor : EditorWindow
    {
        private AnimationClip selectedClip;
        private float threshold = 0.01f;
        private bool showAdvanced = false;
        private float positionThreshold = 0.001f;
        private float rotationThreshold = 0.1f;
        private float scaleThreshold = 0.001f;
        private bool keepFirstAndLast = true;
        private int originalKeyCount;
        private int optimizedKeyCount;
        private float reductionPercent;

        [MenuItem("Tools/优化/动画帧提取器")]
        public static void ShowWindow()
        {
            var window = GetWindow<AnimationFrameExtractor>("动画帧提取器");
            window.Show();
        }

        void OnGUI()
        {
            GUILayout.Label("动画帧提取优化", EditorStyles.boldLabel);
            
            selectedClip = (AnimationClip)EditorGUILayout.ObjectField("动画剪辑 (Clip)", selectedClip, typeof(AnimationClip), false);
            
            EditorGUILayout.Space();
            GUILayout.Label("提取设置", EditorStyles.boldLabel);
            
            threshold = EditorGUILayout.Slider("敏感度阈值", threshold, 0.001f, 0.1f);
            keepFirstAndLast = EditorGUILayout.Toggle("保留首尾帧", keepFirstAndLast);
            
            showAdvanced = EditorGUILayout.Foldout(showAdvanced, "高级设置 (针对不同属性)");
            if (showAdvanced)
            {
                EditorGUI.indentLevel++;
                positionThreshold = EditorGUILayout.FloatField("位置阈值 (Position)", positionThreshold);
                rotationThreshold = EditorGUILayout.FloatField("旋转阈值 (Rotation)", rotationThreshold);
                scaleThreshold = EditorGUILayout.FloatField("缩放阈值 (Scale)", scaleThreshold);
                EditorGUI.indentLevel--;
            }
            
            EditorGUILayout.Space();
            
            if (selectedClip != null)
            {
                if (GUILayout.Button("分析动画 (Analyze)"))
                {
                    AnalyzeAnimation();
                }
                
                if (originalKeyCount > 0)
                {
                    EditorGUILayout.Space();
                    EditorGUILayout.BeginVertical(EditorStyles.helpBox);
                    GUILayout.Label($"原始关键帧总数: {originalKeyCount}", EditorStyles.label);
                    GUILayout.Label($"优化后关键帧数: {optimizedKeyCount}", EditorStyles.label);
                    GUI.color = Color.green;
                    GUILayout.Label($"预计减少比例: {reductionPercent:F1}%", EditorStyles.boldLabel);
                    GUI.color = Color.white;
                    EditorGUILayout.EndVertical();
                    
                    EditorGUILayout.Space();
                    if (GUILayout.Button("执行提取并保存 (Extract)"))
                    {
                        ExtractAnimationFrames();
                    }
                }
            }
            
            EditorGUILayout.Space();
            if (GUILayout.Button("从选定对象批量提取 (Selection)"))
            {
                ExtractFromSelection();
            }
            
            EditorGUILayout.Space();
            EditorGUILayout.HelpBox("提示:该工具使用 Douglas-Peucker 算法移除对动画形状影响不大的冗余帧。操作前会自动创建 _backup 备份文件。", MessageType.Info);
        }

        private void AnalyzeAnimation()
        {
            if (selectedClip == null) return;

            EditorCurveBinding[] bindings = AnimationUtility.GetCurveBindings(selectedClip);
            originalKeyCount = 0;
            optimizedKeyCount = 0;

            foreach (var binding in bindings)
            {
                AnimationCurve curve = AnimationUtility.GetEditorCurve(selectedClip, binding);
                if (curve == null) continue;

                originalKeyCount += curve.length;
                
                // 模拟提取后的关键帧数量
                AnimationCurve optimized = ExtractKeyframes(curve, selectedClip.length);
                optimizedKeyCount += optimized.length;
            }

            reductionPercent = originalKeyCount > 0 ? (1f - (float)optimizedKeyCount / originalKeyCount) * 100f : 0f;
        }

        private void ExtractAnimationFrames()
        {
            if (selectedClip == null) return;

            string backupPath = CreateBackup(selectedClip);
            
            try
            {
                Undo.RecordObject(selectedClip, "Extract Animation Frames");
                
                EditorCurveBinding[] bindings = AnimationUtility.GetCurveBindings(selectedClip);
                bool hasChanges = false;

                foreach (var binding in bindings)
                {
                    AnimationCurve originalCurve = AnimationUtility.GetEditorCurve(selectedClip, binding);
                    if (originalCurve == null || originalCurve.length <= 2) continue;

                    AnimationCurve optimizedCurve = ExtractKeyframes(originalCurve, selectedClip.length);
                    
                    if (optimizedCurve.length < originalCurve.length)
                    {
                        AnimationUtility.SetEditorCurve(selectedClip, binding, optimizedCurve);
                        hasChanges = true;
                    }
                }

                if (hasChanges)
                {
                    EditorUtility.SetDirty(selectedClip);
                    AssetDatabase.SaveAssets();
                    AssetDatabase.Refresh();
                    
                    Debug.Log($"[动画优化] 成功提取帧: {selectedClip.name}");
                    Debug.Log($"[动画优化] 关键帧精简: {originalKeyCount} → {optimizedKeyCount} (减少了 {reductionPercent:F1}%)");
                    
                    // 重新分析显示最新数据
                    AnalyzeAnimation();
                }
                else
                {
                    Debug.Log("[动画优化] 当前设置下没有可进一步精简的帧。");
                }
            }
            catch (System.Exception e)
            {
                RestoreBackup(selectedClip, backupPath);
                Debug.LogError($"[动画优化] 提取失败: {e.Message}");
            }
            finally
            {
                CleanupBackup(backupPath);
            }
        }

        private AnimationCurve ExtractKeyframes(AnimationCurve curve, float clipLength)
        {
            if (curve.length <= 2) return curve;

            List<Keyframe> keyframes = new List<Keyframe>(curve.keys);
            List<Keyframe> result = new List<Keyframe>();
            
            // 总是保留第一帧
            if (keepFirstAndLast && keyframes.Count > 0)
            {
                result.Add(keyframes[0]);
            }

            // 使用 Douglas-Peucker 算法简化曲线
            SimplifyCurve(keyframes, result, 0, keyframes.Count - 1, GetPropertyThreshold(curve));

            // 总是保留最后一帧
            if (keepFirstAndLast && keyframes.Count > 1 && !result.Any(k => Mathf.Approximately(k.time, keyframes.Last().time)))
            {
                result.Add(keyframes[keyframes.Count - 1]);
            }

            // 确保时间顺序
            result = result.OrderBy(k => k.time).ToList();

            // 重新计算切线以保持曲线平滑
            AnimationCurve newCurve = new AnimationCurve(result.ToArray()) { preWrapMode = curve.preWrapMode, postWrapMode = curve.postWrapMode };
            for (int i = 0; i < newCurve.length; i++)
            {
                AnimationUtility.SetKeyLeftTangentMode(newCurve, i, AnimationUtility.GetKeyLeftTangentMode(curve, 0));
                AnimationUtility.SetKeyRightTangentMode(newCurve, i, AnimationUtility.GetKeyRightTangentMode(curve, 0));
            }
            return newCurve;
        }

        private void SimplifyCurve(List<Keyframe> points, List<Keyframe> result, int startIndex, int endIndex, float epsilon)
        {
            if (endIndex <= startIndex + 1) return;

            // 找到距离起点和终点连线最远的点
            float maxDistance = 0f;
            int maxIndex = startIndex;
            
            Keyframe start = points[startIndex];
            Keyframe end = points[endIndex];
            
            for (int i = startIndex + 1; i < endIndex; i++)
            {
                float distance = PointToLineDistance(start, end, points[i]);
                if (distance > maxDistance)
                {
                    maxDistance = distance;
                    maxIndex = i;
                }
            }

            // 如果最大距离大于阈值,递归处理
            if (maxDistance > epsilon)
            {
                SimplifyCurve(points, result, startIndex, maxIndex, epsilon);
                if (!result.Any(k => Mathf.Approximately(k.time, points[maxIndex].time)))
                {
                    result.Add(points[maxIndex]);
                }
                SimplifyCurve(points, result, maxIndex, endIndex, epsilon);
            }
        }

        private float PointToLineDistance(Keyframe lineStart, Keyframe lineEnd, Keyframe point)
        {
            // 计算点到直线的距离
            float lineLength = Vector2.Distance(new Vector2(lineStart.time, lineStart.value), 
                                              new Vector2(lineEnd.time, lineEnd.value));
            
            if (lineLength == 0) 
                return Vector2.Distance(new Vector2(lineStart.time, lineStart.value), 
                                      new Vector2(point.time, point.value));
            
            float t = Mathf.Clamp01(Vector2.Dot(
                new Vector2(point.time - lineStart.time, point.value - lineStart.value),
                new Vector2(lineEnd.time - lineStart.time, lineEnd.value - lineStart.value)
            ) / (lineLength * lineLength));
            
            Vector2 projection = new Vector2(lineStart.time, lineStart.value) + 
                               t * new Vector2(lineEnd.time - lineStart.time, lineEnd.value - lineStart.value);
            
            return Vector2.Distance(new Vector2(point.time, point.value), projection);
        }

        private float GetPropertyThreshold(AnimationCurve curve)
        {
            return threshold;
        }

        private void ExtractFromSelection()
        {
            List<AnimationClip> clips = new List<AnimationClip>();
            
            // 从选中的对象中获取动画组件
            foreach (GameObject obj in Selection.gameObjects)
            {
                Animation animation = obj.GetComponent<Animation>();
                if (animation != null)
                {
                    foreach (AnimationState state in animation)
                    {
                        if (state.clip != null && !clips.Contains(state.clip))
                        {
                            clips.Add(state.clip);
                        }
                    }
                }
                
                Animator animator = obj.GetComponent<Animator>();
                if (animator != null && animator.runtimeAnimatorController != null)
                {
                    foreach (AnimationClip clip in animator.runtimeAnimatorController.animationClips)
                    {
                        if (!clips.Contains(clip))
                        {
                            clips.Add(clip);
                        }
                    }
                }
            }
            
            // 从选中的资源中获取动画片段
            foreach (Object obj in Selection.objects)
            {
                if (obj is AnimationClip clip && !clips.Contains(clip))
                {
                    clips.Add(clip);
                }
            }
            
            if (clips.Count == 0)
            {
                EditorUtility.DisplayDialog("未找到动画", "请选择包含 Animation/Animator 组件的游戏对象或动画剪辑资源。", "确定");
                return;
            }
            
            int processed = 0;
            foreach (AnimationClip clip in clips)
            {
                selectedClip = clip;
                AnalyzeAnimation();
                
                if (reductionPercent > 5f) // 只有能减少5%以上才处理
                {
                    ExtractAnimationFrames();
                    processed++;
                }
            }
            
            Debug.Log($"[动画优化] 批量处理完成,共处理 {processed} 个动画剪辑。");
        }

        private string CreateBackup(AnimationClip clip)
        {
            string assetPath = AssetDatabase.GetAssetPath(clip);
            string backupPath = Path.Combine(Path.GetDirectoryName(assetPath), 
                Path.GetFileNameWithoutExtension(assetPath) + "_backup.anim");
            
            AssetDatabase.CopyAsset(assetPath, backupPath);
            AssetDatabase.SaveAssets();
            AssetDatabase.Refresh();
            
            return backupPath;
        }

        private void RestoreBackup(AnimationClip clip, string backupPath)
        {
            if (File.Exists(backupPath))
            {
                string assetPath = AssetDatabase.GetAssetPath(clip);
                AssetDatabase.DeleteAsset(assetPath);
                AssetDatabase.CopyAsset(backupPath, assetPath);
                AssetDatabase.SaveAssets();
                AssetDatabase.Refresh();
            }
        }

        private void CleanupBackup(string backupPath)
        {
            if (AssetDatabase.LoadAssetAtPath<AnimationClip>(backupPath) != null)
            {
                AssetDatabase.DeleteAsset(backupPath);
                AssetDatabase.SaveAssets();
                AssetDatabase.Refresh();
            }
        }
    }

    // 右键菜单扩展
    public class AnimationExtractorContextMenu
    {
        [MenuItem("Assets/提取动画关键帧 (Extract Frames)", true)]
        static bool ValidateExtractFrames()
        {
            return Selection.activeObject is AnimationClip;
        }

        [MenuItem("Assets/提取动画关键帧 (Extract Frames)")]
        static void ExtractFrames(MenuCommand command)
        {
            AnimationClip clip = Selection.activeObject as AnimationClip;
            if (clip != null)
            {
                AnimationFrameExtractor window = EditorWindow.GetWindow<AnimationFrameExtractor>("动画帧提取器");
                
                // 设置选中的 clip
                var field = typeof(AnimationFrameExtractor).GetField("selectedClip", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
                if (field != null) field.SetValue(window, clip);
                
                // 执行分析和提取
                var analyzeMethod = typeof(AnimationFrameExtractor).GetMethod("AnalyzeAnimation", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
                var extractMethod = typeof(AnimationFrameExtractor).GetMethod("ExtractAnimationFrames", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
                
                if (analyzeMethod != null) analyzeMethod.Invoke(window, null);
                if (extractMethod != null) extractMethod.Invoke(window, null);
            }
        }
    }
}
posted @ 2026-02-02 16:16  星空探险家  阅读(0)  评论(0)    收藏  举报