关于游戏中的动画优化

最近在做游戏开发,发现游戏中的动画优化是一个很重要的方面,于是我就去网上找了一些资料,总结了一下关于游戏中的动画优化的一些方法。看到了一个动画关键帧智能精简优化工具,所以准备记录一下这个功能的使用方法。

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)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
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);
}
}
}
}