AnimationClip:动画关键帧智能精简优化工具 (AnimationFrameExtractor)
AnimationClip:动画关键帧智能精简优化工具 (AnimationFrameExtractor)
1. 使用流程 (Usage Process)
1.1 安装说明
将下方的 完整源代码 保存为 AnimationFrameExtractor.cs,并放置在 Unity 项目的 Assets/Editor 文件夹中。工具界面已完全适配中文环境。
1.2 操作流程
- 方法一:通过工具窗口操作
- 菜单栏打开
Tools > 优化 > 动画帧提取器。 - 在 动画剪辑 (Clip) 槽位拖入要处理的动画资源。
- 调整 敏感度阈值(值越小保留帧越多,建议 0.01)。
- 点击 分析动画 (Analyze) 预览优化比例。
- 点击 执行提取并保存 (Extract) 完成优化。
- 菜单栏打开
- 方法二:右键菜单快速处理
- 在 Project 窗口右键点击一个或多个 Animation Clip 资源。
- 选择 提取动画关键帧 (Extract Frames),工具将自动按默认阈值完成精简。
- 方法三:批量处理场景对象
- 在场景中选择包含
Animator或Animation组件的游戏对象。 - 在工具窗口点击 从选定对象批量提取 (Selection),工具将自动提取并优化关联的所有动画片段。
- 在场景中选择包含
1.3 验证方法
- 体积检查:观察 AnimationClip 在 Inspector 面板显示的 Size 变化(通常可减少 50%-80%)。
- 视觉校验:在 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);
}
}
}
}
本文来自博客园,作者:星空探险家,转载请注明原文链接:https://www.cnblogs.com/PuppetLazy/p/19169548

浙公网安备 33010602011771号