前言

物理周期与渲染帧

Godot 中有一个需要理解的关键概念,那就是物理周期(也叫迭代或者物理帧)和渲染帧的区别。物理处理的周期率是固定的(通过 ProjectSettings.physics/common/physics_fps 设置),默认为每秒 60 次。

然而,引擎不必以相同的频率进行渲染。虽然很多显示器的刷新率是 60 Hz(每秒的循环数),但是也有很多使用的是完全不同的频率(例如 75 Hz、144 Hz、240 Hz 或者更高)。即便显示器可能可以每秒显示 60 个新帧,也无法保证 CPU 和 GPU 能够按照这个频率来提供帧。例如,使用垂直同步运行时,计算机可能太慢了,无法达到 60 FPS,只能达到 30 FPS,那么你能看到的就变化就是 30 FPS 的(造成卡顿)。

但问题来了。物理周期与帧不一致会怎样?物理周期率与帧率不同步会怎样?或者更夸张一点,物理周期率低于渲染帧率会怎样?

如果我们考虑比较极端的情况,就能够更方便地理解这个问题。如果你在某个简单的游戏里把物理周期率设为每秒 10 次,而渲染帧率是 60 FPS。如果我们绘制渲染帧与某个对象位置的关系图,你应该可以看到位置是每十分之一秒“跳动”一次的,进行的不是平滑的运动。物理计算出新对象的新位置时,并不是在这个位置渲染一帧,而是要持续 6 帧。

../../../_images/fti_graph_fixed_ticks.png

这种跳动也可以在其他周期/帧率的组合中见到,表现为这种台阶效应所产生的闪烁或者抖动,究其原因还是物理周期的时间与渲染帧的时间的差异。

帧和周期不同步,我们能做什么?

把周期/帧率锁死?

最明显的解决方案就是消灭问题本身,方法是保证物理周期与帧一致。一些古早的主机和固定硬件的计算机使用的就是这种做法。如果你知道所有玩家使用的都是相同的硬件,那么你就可以保证周期和帧的计算都能在,例如 50 FPS 里处理掉,这样你就可以确定大家都能够得到很好的体验。

然而,现代的游戏通常都不是为固定的硬件开发的。你通常会计划在桌面计算机、移动设备等多处发布,在性能和显示器刷新率上都会有很大差异。我们需要找到更好的解决方法。

适应周期率?

既然不能按照固定的物理帧率来设计游戏了,那么我们可以根据终端用户的硬件来缩放周期率呀。例如我们可以使用在那种硬件上能够固定达到的周期率,甚至可以改变每一个物理周期的长度来匹配特定的帧长度。

确实可行,但还是会有问题。物理(游戏逻辑也一样,通常会在 _physics_process 里执行)只有在以固定的预定义的周期运行时,效果才最佳、最稳定。如果某个赛车游戏的物理系统是按照 60 TPS(每秒周期数)设计的,而你尝试按照 10 TPS 运行,物理系统的行为会大幅改变。控制的响应速度会变慢,碰撞/轨迹会完全不同。你可能在 60 TPS 下完善地测试了你的游戏,但一到终端用户的机器上,用不同的周期率运行就会完蛋。

这样就会造成品控方面的困难,Bug 难以重现,尤其是 AAA 游戏,这种问题非常费时费力。这也会在多人游戏时造成公平竞争问题,因为在特定帧率下运行游戏可能相对更有优势。

锁定周期率,但在两个物理周期之间通过插值来平滑帧

这是此类问题最受欢迎的解决方案。Godot 从 3.5 其支持在 3D 中使用(虽然是可选的,默认关闭)。

我们已经认识到,要让物理/游戏逻辑安排达到最理想、稳定、可预知的状态,就需要在设计时使用固定的物理周期率。问题在于这两个位置是不同的:物理对象记录的物理位置,以及我们为了达到平滑的运动而“想要”让它在特定的帧中显示的位置。

答案非常简单,不过第一次听起来可能会有点难以理解。

除了记录引擎里物理对象的当前位置之外,我们还要去记录它在上一个物理周期时的位置

为什么需要上一个位置(实际是完整的变换,还包括旋转和缩放)?通过一点神奇的数学知识,我们就可以通过插值来算出,如果在这两个点之间做理想的平滑连续运动的话,这个对象的变换应该是什么。

../../../_images/fti_graph_interpolated.png

线性插值

实现起来最简单的方法就是做线性插值,或者叫 lerp,你可能之前用过。

假设我们只考虑位置,我们知道上一个物理周期 X 坐标为 10 个单位,当前物理周期 X 坐标为 30 个单位。

备注

虽然这里会解释一下数学只是,你不用担心细节,因为你不用自己来做这一步。Godot 可能会在底层使用更复杂的插值,但是线性插值是解释起来最简单的。

物理插值分数

如果我们的物理周期正好是每秒钟 10 次(针对这个例子),如果渲染帧发生在 0.12 秒处会发生什么呢?我们可以通过数学得到,假如该物体在两次物理周期间存在平滑运动,那么它应该位于哪个地方。

首先,我们得算出我们希望该物体经过了该物理周期多久。如果上一个物理周期发生在 0.1 秒,那么我们就是经过了一个周期 0.1 秒(每秒 10 个周期)之中的 0.02 秒(0.12 - 0.1)。因此在该周期中的分数就是:

fraction = 0.02 / 0.10
fraction = 0.2

这就叫做物理插值分数,Godot 会好心地帮你算出来。任何帧中都能通过调用 Engine.get_physics_interpolation_fraction 得到。

计算插值位置

有了插值分数之后,我们就可以把它带入标准线性插值公式中。X 坐标就是:

x_interpolated = x_prev + ((x_curr - x_prev) * 0.2)

那么把我们的 x_prev 替换为 10、x_curr 替换为 30:

x_interpolated = 10 + ((30 - 10) * 0.2)
x_interpolated = 10 + 4
x_interpolated = 14

我们来仔细看看:

  • 我们知道 X 是从上一周期的位置开始的(x_prev)也就是 10 个单位。

  • 我们知道在完整的周期后,会加上当前周期与上一周期的差(x_curr - x_prev)(也就是 20 个单位)。

  • 我们唯一需要变化的就是根据我们经过了这个物理周期的多少,来加上这个差值的多少。

备注

尽管这个例子里我们插值的是位置,对象的旋转和缩放也是可以做插值的。不必了解细节,Godot 会帮你做好这些事情。

在物理周期之间平滑变换?

综上所述,通过对象在当前及上一物理周期时的变换,就应该能够估算得到平滑的变换。

但是等一下,你可能注意到了什么。如果我们插值的是当前周期和上一周期,那么我们并不是在估算该对象现在的位置,而是在估算该对象过去的位置。确切地说,我们是在估算该对象过去1 个周期和 2 个周期之间的位置。

过去

这是什么意思?这种做法确实是有效的,但是我们本质上确实也是在屏幕上看到的位置和对象应该在的位置之间引入了延迟。

在实践中,大多数人不会注意到这个延迟,或者说,通常不会引起反感。游戏里本就有显著的延迟,只是我们通常不会注意到。最显著的就是输入会有少量的延迟,快节奏的游戏里就会感觉到。在类似这种快速输入的情况下,你可能会想要关闭物理插值,使用别的反感,或者提高周期率,缓和这种延迟。

为什么要往过去看呢?为什么不预测未来?

这种方案还有另一种变体,那就是:不在上周期和本周期之间插值,而是利用数学外插出未来的值。我们要尝试预测对象将会在哪里,而不是去显示它曾经在哪个位置。未来可能可以这么做,作为可选项提供,但还是有显著的问题的:

  • 预测可能是不准确的,尤其是在物理周期中该对象与另一个对象发生碰撞的时候。

  • 预测错误的时候,该对象就可能外插到“不可能”的位置,类似于墙体的内部。

  • 如果运动速度比较慢,这些错误的预测可能不会造成太大的问题。

  • 预测错误的时候,该对象可能必须跳跃或者吸附回正确路径。看起来就会非常扎眼。

固定步长插值

在 Godot 中,这样的整个系统就叫做物理插值,不过你可能还听说过“固定步长插值”这样的叫法,这是因为在做插值的是在固定时间步长(每秒物理周期数)中移动前后的对象。某种意义上第二种术语更加准确,因为还可以被插值的对象也可以不是由物理驱动移动的。

小技巧

尽管物理插值通常是不错的选择,但还是存在例外情况的,你可能不会想要使用 Godot 内置的物理插值(或者进行有限的使用)。互联网多人游戏就是一种例子。多人游戏经常会从其他玩家或者服务器获取周期和基于时间的信息,这些信息可以与本地物理周期是不同步的,所以做自定义插值可能更加合适。