每秒 420 帧






4.91/5 (31投票s)
本文介绍了一个用于管理 WPF 中基于枚举器的动画的类,该类可以独立于硬件帧率处理不同帧率的动画。
新读者须知
本文介绍了一个小型类和一个技术,用于创建您仍然认为有用的基于帧的动画。但如果您想要一个更完整的解决方案,我建议您阅读文章 Fluent and Imperative Animations[^],因为它更完整,并且同时支持基于时间和基于帧的动画。
背景
在文章 Writing a Multiplayer Game (in WPF) 中,我介绍了使用枚举器(通过 `yield return`)作为一种简单的动画方法。看到像 BubbleShot 这样的游戏很受欢迎,我决定自己创建一个。它在家中已经更成功了,因为我妈妈从来没有玩过其他游戏,但她非常喜欢现在的这款。
我保留了使用枚举器进行动画的想法,但这次动画不是由服务器控制,而且我为其他游戏使用的 `HighPrecisionTimer` 对处理器来说过于耗费资源,无法提供每秒 60 帧的良好动画。此外,由于它在另一个线程中运行,因此需要 `Invoke`,这使得事情更加复杂,所以我决定做得更好,使用 WPF 组件来执行基于帧的动画。
CompositionTarget
此组件是 WPF 和 Silverlight 中基于帧的动画的核心。它并没有做太多事情。它只有一个 `Rendering` 事件,每次需要处理新帧时都会触发。
对于我的第一个测试,它的效果比 `HighPrecisionTimer` 好得多,没有占用所有 CPU,而且已经是每秒 60 帧了。所以,它完成了。
游戏和我的懒惰 - 420 帧/秒
好吧……它已经是每秒 60 帧了,但这还不是全部。顺便说一下,我的代码写的动画很慢。流畅但慢。
我决定将速度乘以 7。动画然后有了正确的速度,但有时气泡只是“穿过”了其他气泡,因为它们从位置 1 跳到了位置 8,而碰撞只发生在位置 4 或 5。
当然,我本可以使用更好的数学方法来发现路径中有碰撞,但我偷懒了。我没有将速度乘以 7,而是每次实际帧处理 7 帧。考虑到游戏以每秒 60 帧的速度运行,这使我总共获得了每秒 420 帧,即使我的显示器仍然只显示 60 帧(或更少)。
每次更新 7 帧不等同于每秒 420 帧
虽然代码每次更新处理 7 帧,但它并没有真正以每秒 420 帧的速度运行。
首先,不保证 `CompositionTarget` 每秒会触发事件 60 次。它可能只会每秒触发 30 帧。在这种情况下,整个动画将以一半的速度运行。
也有可能它被调用的次数超过每秒 60 次(UI 更新会触发事件,我不知道是否存在使它运行更频繁的计算机配置)。在这些情况下,动画将简单地运行得比应该的快。要真正达到每秒 420 帧,游戏应该补偿额外的帧或缺失的帧。
补偿
由于我使用固定的帧率来编程我的动画(使用枚举器),所以我应该通过多次调用更新来补偿减速。如果动画应该每毫秒执行一次,而上次执行以来经过的时间是 15 毫秒,那么动画应该更新 15 次。如果下一个 `Rendering` 在需要的时间之前到来,那么甚至不应该进行一次更新。
对于真正以每秒 60 帧运行的游戏,这种技术可能会因为微小的减速而丢失一整帧。但是,由于我使用的是每秒 420 帧而不是 60 帧,微小的减速可能意味着在显示动画之前处理 8 帧而不是 7 帧。所以,我的懒惰仍然给了我一个非常流畅的动画。
将补偿放入类中
如前所述,`CompositionTarget` 有一个简单的 `Rendering` 事件。
但是这样的事件不会告诉我们每秒处理多少帧,或者距离上次调用过去了多少时间。
使用 `DateTime.Now` 的精度不高,并且如果用户在游戏运行时更改计算机时间,可能会导致灾难性的后果。所以我使用 `Stopwatch` 来获取经过的时间。我真的以为我可以在 Silverlight 中毫无问题地编译这个类,但令我惊讶的是,Silverlight 没有 `Stopwatch` 类。
但 Silverlight 现在不是重点,所以我真正做的是将补偿代码放入一个动画管理器类中。即使目前我只有一种动画来动画单个对象,该类也准备好处理具有不同帧率的多种动画。
CompositionTargetAnimationManager
类是这样构建的
- 它有一个 `Stopwatch` 来测量精确的经过时间。
- 它有一个 `AnimationInfo` 列表。这些信息包含对动画本身的引用、上次运行的时间、预期的间隔(帧率)以及可能的用于使 UI 失效的调用操作。
AddAnimation
方法构建此动画信息,将其放入动画列表中,并且如果这是第一个动画,则启动 `Stopwatch` 并向 `CompositionTarget.Rendering` 事件注册。- 当动画结束时,它会从列表中删除,如果它是最后一个动画,则 `Stopwatch` 会停止,并且处理程序会从 `CompositionTarget.Render` 事件中注销。
- 当调用 `Rendering` 事件时,每个动画都会运行必要的次数来补偿经过的时间。这意味着动画可能不运行(如果事件触发得太早),或者它可能运行很多次。只有一个例外:如果经过了一秒钟以上,则只处理一帧。这是为了避免应用程序在调试后挂起(毕竟,如果您花三分钟调试应用程序,计算这三分钟的错过帧将非常糟糕)。
示例
示例是我仍在开发的 BubbleShot 游戏。它目前充满了不良实践并且不完整。所以您不需要告诉我应该使用 MVVM,游戏中有缺失的东西,它存在 bug 或类似的东西。但我认为这是一个很好的例子,说明 CompositionTargetAnimationManager
类是有效的。
更新 - 第 1 部分 - 尝试保持同步
代码的第一个版本没有按顺序运行动画。如果您有 2 个动画,并且需要跳过 10 帧,它将运行动画 2 十次,然后运行动画 1 十次。
新代码将按动画的下一次执行时间排序,即使它们具有不同的帧率。也就是说,在正常情况下,如果动画 1 运行两次,然后动画 2 运行,它在补偿时将完全这样做。
这样,如果您需要两个动画,一个以另一个的两倍速度运行,并且它们可能会发生碰撞,您可以
- 使一个动画的位置增加 *一*,另一个动画的位置增加 *二*,使用相同的帧率。
- 使两者都只增加 *一* 的位置,但让其中一个动画具有另一个动画两倍的帧率。
我强烈建议您测量时间,因为过高的帧率可能会扼杀您的应用程序或游戏的性能,但它仍然是作为一种 **选择** 存在。
更新 - 第 2 部分 - 创建动画
我说我使用基于枚举器的动画,如果您查看代码或我的其他文章,您可能会明白它是如何工作的。但最好将事情集中起来,所以,这是
要创建一个动画,您应该创建一个 `bool` 类型的枚举器。C# 允许您在使用返回 `IEnumerator<bool>` 的方法时使用 `yield return`,因此您可以使用类似这样的内容创建一个简单的动画
private static void IEnumerator<bool> Animation()
{
for(int i=0; i<100; i++)
{
button.Width++;
yield return true;
}
for(int i=0; i<100; i++)
{
button.Width--;
yield return false;
}
}
您可以通过执行以下操作来运行此动画
CompositionTargetAnimationManager.AddAnimation(_Animation, TimeSpan.FromMilliseconds(100));
几点说明
yield return
表示帧已结束。`true` 或 `false` 的值不重要,会被忽略。- 动画只运行一次。如果您想让动画永远运行,则两个 `for` 循环都必须在循环内,例如 `while(true)`。
- 动画直接更改 UI 属性,因此不需要渲染委托。为了在补偿丢失的帧时使动画更快,最好更改实例变量并使用渲染委托将这些变量放到正确的属性中(渲染委托必须作为最后一个参数传递给 `AddAnimation` 方法)。
AddAnimation
使用 `TimeSpan` 而不是 FPS 来测量帧之间的时间。如果您需要 FPS,只需将 1000.0 毫秒除以您想要的每秒帧数,然后再调用 `TimeSpan.FromMilliseconds()`。
版本历史
- 2013 年 3 月 29 日 - 修正了文本中的一些格式;
- 2012 年 4 月 24 日 - 使动画在补偿时也能按正确顺序运行,并在文章中添加了示例;
- 2012 年 4 月 20 日 - 初始版本。