在右侧:5,000个立方体。每个立方体都有自己的位置动画。由于一个位置使用了3个轴,代表了15,000个曲线评估每帧在4ms中。
在左侧:一个单独的立方体在4.6ms内播放10,000个同时的位置动画,这相当于每帧30,000个曲线评估。
今天,我想分享一些我实现的优化,以便它们可能有助于您的项目……当然,优化总是有背景的。我将解释我的目标,以便您可以评估这些技术是否与您的情况相关。
你看,我正在开发一个动画工具,重点是 Unity 中快速集成视觉反馈(游戏感觉)。我的目标是能够对 GameObject 的任何属性进行动画,而不违背三个核心原则:可加性、可逆性和比例。
为了实现这种比例,我工具需要每帧评估成千上万个动画曲线,执行所需的计算,并应用所有变换。直到最近,我工具的架构才指向这一方向,但两周前,我开始仔细查看 profiler,以下是我能够实现的优化:
1:烘焙动画曲线
我需要每帧评估大量的动画曲线:
public AnimationCurve Curve;
float Evaluate(float t)
{
return Curve.Evaluate(t);
}
一种优化方法是烘焙动画曲线以实现更快的评估:
public AnimationCurve Curve;
float[] BakedCurve;
int Resolution = 512;
float Evaluate(float t)
{
t = Mathf.Repeat(t, 1f);
int index = (int)(t * (Resolution - 1));
return BakedCurve[index];
}
void Bake(AnimationCurve curve)
{
float[] baked = new float[Resolution];
for (int i = 0; i < Resolution; i++)
{
float t = (float)i / (Resolution - 1);
baked[i] = curve.Evaluate(t);
}
}
噢,巨大的问题。这个变化本应给我带来显著的性能提升,但我的 profiler 却突然死了……而这正是背景的重要性所在。在游戏中,视觉反馈很少活跃一段时间。相反,我们倾向于触发许多短暂的效果。结果是,我每帧都烘焙曲线!
幸运的是,解决方案很简单:使用一个唯一的哈希生成每次在 inspector 中修改曲线时,我可以缓存烘焙曲线以便快速访问:
Dictionary<int, float[]> BakedCurves = new Dictionary<int, float[]>();
public int GetHashForCurve()
{
HashCode hash = new HashCode();
foreach (Keyframe k in Curve.keys)
{
hash.Add(k.time);
hash.Add(k.value);
hash.Add(k.inTangent);
hash.Add(k.outTangent);
hash.Add(k.inWeight);
hash.Add(k.outWeight);
hash.Add((int)k.weightedMode);
}
hash.Add((int)Curve.preWrapMode);
hash.Add((int)Curve.postWrapMode);
return hash.ToHashCode();
}
public float[] Fetch(int hash, AnimationCurve curve)
{
if (BakedCurves.TryGetValue(hash, out float[] baked))
return baked;
return Store(hash, curve);
}
float[] Store(int hash, AnimationCurve curve)
{
float[] baked = new float[Resolution];
for (int i = 0; i < Resolution; i++)
{
float t = (float)i / (Resolution - 1);
baked[i] = curve.Evaluate(t);
}
BakedCurves.Add(hash, baked);
return baked;
}
2:避免不必要的比较
在应用更改之前,我需要确保目标组件仍然存在:
Transform transform = GetComponent(); // 缓存
Vector3 Offset = Vector3.zero;
foreach (Layer l in Layers)
{
// Offset 计算逻辑
}
if (transform != null)
transform.position = Offset;
您可以看到,比较和位置赋值仍然发生,即使没有动画层实际修改了值。在孤立的情况下,这是微不足道的,但在十万次的调用中,成本开始累积。
解决方案很简单:添加一个布尔变量 IsDirty。这使我只在修改实际发生时检查和应用更改:
Transform transform = GetComponent(); // 缓存
Vector3 Offset = Vector3.zero;
bool IsDirty = false;
foreach (Layer l in Layers)
{
// Offset 计算逻辑
IsDirty = true;
}
if (IsDirty && transform != null)
transform.position = Offset;
3:优化迭代
我没有意识到我的 foreach 循环有多么昂贵。
public List<string> List = new List<string>();
foreach (string s in List)
{
// Do something
}
每个 foreach 首先创建一个枚举器,然后在每个 MoveNext 上执行多个操作。并且我在代码中有成吨这样的循环…
目标很明确。我创建了一个小型工具类,保持枚举器的状态并允许我迭代得更有效:
namespace FeelCraft.Core.Trackers.Utils
{
public class EnumerableList<T>
{
public bool Active = false;
public IEnumerator<T> Enumerator;
public List<T> List;
public int Index = -1;
public int Count = 0;
public EnumerableList()
{
List = new List<T>();
}
public void Refresh()
{
Index = -1;
Count = List.Count;
Active = Count > 0;
Enumerator = List.GetEnumerator();
}
public bool MoveNext()
{
Index++;
if (Index >= Count)
{
Index = -1;
return false;
}
return true;
}
public T Current
{
get { return List[Index]; }
}
}
}
public EnumerableList<string> MyList = new EnumerableList<string>();
string s = "";
for (int i = 0; i < Count; i++)
{
while (MyList.MoveNext())
{
s = MyList.Current;
// Do something
}
}
在 1,000,000 次迭代中, foreach 版本花费了 147ms,而我的自定义类减少了此时间至 55ms。
但 不要 使用此代码:它是垃圾……因为在试图聪明时,我掉入了一个推理偏见……我太专注于枚举器的想法了,没有考虑最简单的解决方案……直到重构所有我的迭代,我才意识到一个更简单更快的方法存在:
for (int j = 0; j < List.Count; j++)
{
s = List[j];
}
就像那样。 8ms 在 1,000,000 次迭代中。
所有这些优化在我的工具 v2.2.0 中发挥了作用,堆叠了十万个同时的动画并提供了线性比例在多个对象扩展和多个动画堆叠中。对于创建小型独立项目或完全装备游戏竞赛的项目,或者创建项目时满足玩家和开发人员体验 非常 满意的项目,这是完美的!
如果你感兴趣,请随时查看工具页面在 itch 或在 资产商店。
感谢阅读!:)
评论 (0)