Attention: Here be dragons

This is the latest (unstable) version of this documentation, which may document features not available in or compatible with released stable versions of Godot.

介绍

物理周期与渲染帧

在 Godot 中,一个关键概念是区分物理周期(有时称为迭代或物理帧)与渲染帧。物理运算以固定的周期速率运行(在项目设置 > 物理 > 通用 > 每秒物理周期数中设置),默认值为每秒 60 个周期。

然而,引擎并不一定会以相同的速率渲染。尽管许多显示器的刷新率为 60 Hz(每秒周期数),但还有许多显示器的刷新率完全不同(例如 75 Hz、144 Hz、240 Hz 或更高)。再者,即使显示器能够每秒显示 60 次新的帧,也不能保证 CPU 和 GPU 能够以这个速率提供帧。例如,在使用垂直同步运行时,计算机可能太慢而无法达到 60,只能勉强达到 30 FPS,那么在这种情况下,你看到的帧将以 30 FPS 的速度变化(导致画面卡顿)。

但这里存在一个问题。如果物理周期与帧不同步会发生什么?如果物理周期速率与帧速率不同步会怎样?更糟糕的是,如果物理周期速率低于渲染帧速率会发生什么?

这个问题在考虑极端场景时更容易理解。假设在一个渲染帧率为 60 FPS 的简单游戏中,将物理周期速率设置为每秒 10 个周期。如果我们绘制一个物体位置随渲染帧数变化的图表,你会看到位置似乎每隔 1/10 秒就会“跳变”一次,而不是呈现出平滑的运动。每当物理系统计算出物体的新位置时,这个位置不是只用于一帧渲染,而是六帧。

../../../_images/fti_graph_fixed_ticks.webp

这种跳变在其他周期/帧率组合中可能会被视为故障或抖动,这是物理周期时间和渲染帧时间之间的差异所导致的阶梯效应引起的。

渲染帧和物理周期不同步怎么办?

是否要将物理周期和渲染帧锁定?

最直接的解决方案是通过确保每个帧都有一个物理周期来消除这个问题。这在旧游戏机和固定硬件电脑上是常用的方法。如果你知道每个玩家都会使用相同的硬件,你可以确保它足够快,能够以例如 50 FPS 的速度计算物理周期和帧,这样你就能确保它在所有人的设备上都能正常工作。

然而,现代游戏通常不再为固定硬件开发。你经常会计划在台式电脑、移动设备等多个平台上发布游戏。这些平台的性能差异很大,显示器的刷新率也不尽相同。我们需要找到一种更好的方法来解决这个问题。

适应物理周期率?

我们可以不采用固定的物理周期速率来设计游戏,而是根据最终用户的硬件来调整周期速率。例如,我们可以使用适合该硬件的固定周期速率,甚至可以调整每个物理周期的持续时间以匹配特定的帧时长。

这种方法可行,但存在一个问题。物理(以及游戏逻辑,也经常在 _physics_process 中运行)在固定、预设的帧率下运行时效果最好且最稳定。如果你尝试以 10 TPS(每秒周期数)运行一个设计为 60 TPS 的赛车游戏物理系统,物理行为将完全不同。控制可能会变得不灵敏,碰撞/轨迹可能完全不同。你可能在 60 TPS 下彻底测试了你的游戏,却发现它在终端用户的机器上以不同的周期速率运行时出现问题。

这可能导致难以重现的 bug,难以进行质量保证,尤其是在 AAA 游戏中,这类问题可能会带来非常高昂的成本。对于多人游戏来说,这也可能影响竞技公平性,因为以特定的周期速率运行游戏可能会比其他速率更具优势。

锁定物理周期率,但在物理周期之间使用插值让渲染帧平滑

这已成为处理该问题最流行的方法之一,尽管它是可选的且默认情况下处于禁用状态。

我们已经确定,为了实现一致性和可预测性,最理想的物理/游戏逻辑方案是在设计时固定物理周期速率。但问题在于,记录的物理位置,与我们为了实现平滑运动“希望”物理对象在某一帧上显示的位置,两者之间存在差异。

答案其实很简单,但一开始可能会让你觉得有点难以理解。

在引擎中,我们不再记录物理对象的当前位置,还是记录对象的当前位置以及先前位置(上一物理周期的)。

为什么我们需要之前的位置(实际上是完整变换,包括旋转和缩放)?通过使用一些数学技巧,我们可以在理想的平滑连续运动世界中使用插值来计算物体在这两个点之间的变换。

../../../_images/fti_graph_interpolated.webp

线性插值

实现这一点的最简单方法是线性插值,或者称为 lerp,你可能之前已经使用过它。

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

备注

虽然这里解释了数学原理,但你无需担心细节问题,因为这一步将由引擎自动完成。在底层,Godot 可能会使用更复杂的插值形式,但从解释的角度来说,线性插值是最简单的。

物理插值比例

(在这个例子中)如果物理周期是每秒 10 次,那么渲染帧位于 0.12 秒的话会发生什么呢?要实现两个周期之间的平滑运动,我们来解一个数学题就知道物体所处的位置了。

首先,我们要计算物体在物理周期中经过了多久。如果上一个物理周期在 0.1 秒,那么我们就在物理周期中经过了 (0.12 - 0.1) 即 0.02 秒,因为一个周期总共 0.1 秒(每秒 10 个周期)。所以周期中的比例就是:

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 中,整个系统被称为物理插值(physics interpolation),但你也可以听到它被称为“固定时间步长插值”(fixed timestep interpolation),因为它是在以固定时间步长(每秒物理帧数)移动的对象之间进行插值。从某些方面来说,第二个术语更准确,因为它也可以用于插值那些不由物理驱动的对象。

小技巧

尽管物理插值通常是个不错的选择,但在某些情况下你可能选择不使用 Godot 内置的物理插值(或以有限的方式使用)。一个典型的例子是互联网多人游戏。多人游戏经常从其他玩家或服务器接收基于物理周期或时间的信息,这些信息可能与本地物理周期不同步,因此自定义的插值技术通常更适合这种情况。