想象一下问题:您有一个游戏,并且需要它在60 Hz的显示器上以60 fps的速度工作。 您的计算机足够快地进行渲染和更新,因此花费的时间很少,因此打开vsync并编写以下游戏循环:
while(running) { update(); render(); display(); }
很简单! 现在,游戏可以60fps的速度运行,一切都像发条一样。 做完了 感谢您阅读这篇文章。
好吧,显然,一切都不是很好。 如果某人的计算机性能较弱,无法以足以提供60fps的速度渲染游戏该怎么办? 如果有人购买了那些酷炫的144赫兹新显示器之一,该怎么办? 如果他在驱动程序设置中关闭了vsync怎么办?
您可能会想:我需要测量某个地方的时间,并以正确的频率提供更新。 这非常简单-只需在每个周期中累积时间,并在每次超过阈值1/60秒时进行更新。
while(running) { deltaTime = CurrentTime()-OldTime; oldTime = CurrentTime(); accumulator += deltaTime; while(accumulator > 1.0/60.0){ update(); accumulator -= 1.0/60.0; } render(); display(); }
做完了,再容易不过了。 实际上,有一堆游戏中的代码本质上是这样的。 但这是错误的。 这适合于调整时间,但会导致跳动(卡顿)和其他不匹配问题。 这样的问题非常普遍:帧的显示时间不能精确到1/60秒。 即使在打开vsync的情况下,它们的显示时间(以及OS计时器的准确性)始终会有些杂音。 因此,渲染框架时会出现这种情况,并且游戏认为重新更新的时间尚未到来(因为电池滞后了很小的一部分),因此它只重复了同一帧,但是现在游戏在该框架中处于延迟状态,因此翻倍更新。 这是抽搐!
使用Google搜索,您可以找到几种现成的解决方案来消除这种颤动。 例如,游戏可以使用可变的时间步长而不是恒定的时间步长,而只是完全放弃计时代码中的电池。 或者,您可以使用插值渲染器实现恒定的时间步长,这在Glenn Fielder的相当著名的文章“
Fix Your Timestep ”中进行了描述。 或者,您可以重新制作计时器代码,使其更具灵活性,如Slick Entertainment的《
框架时序问题》中所述 (不幸的是,此博客不再存在)。
模糊计时
我的引擎中带有“模糊计时”的Slick Entertainment方法是最容易实现的,因为它不需要更改游戏逻辑和渲染。 因此,在《
The End is Nigh》中,我使用了它。 仅将其插入引擎就足够了。 实际上,它只是允许游戏“提前一点”进行更新,以避免计时不匹配的问题。 如果游戏包含vsync,则仅允许您将vsync用作游戏的主要计时器,并提供流畅的画面。
这就是更新代码的外观(游戏可以62 fps“运行”,但是仍然可以像处理60 fps一样处理每个时间步。我不太明白为什么要限制它,以便电池值不会降到0以下,但是此代码无效)。 您可以这样解释:“如果以60fps至62fps的间隔渲染游戏,则会以固定的步骤更新游戏”:
while(accumulator > 1.0/62.0){ update(); accumulator -= 1.0/60.0; if(accumulator < 0) accumulator = 0; }
如果启用了vsync,则基本上可以使游戏以固定的螺距工作,该螺距与监视器的刷新率匹配,并提供平滑的画面。 这里的主要问题是,当禁用vsync时,游戏将运行得更快一些,但是差异是如此之
小 ,以至于没人会注意到它。
速度赛跑者。 速跑者会注意到。 游戏发布后不久,他们注意到speedran高分榜上的某些人出行时间较差,但事实证明比其他人更好。 造成这种情况的直接原因是时间不明确,游戏中的vsync(或144 Hz监视器)断开了连接。 因此,很明显,在断开vsync的连接时,您需要关闭这种模糊性。
哦,但是我们仍然无法检查是否已禁用vsync。 在操作系统中没有对此的要求,尽管我们可以从应用程序请求启用或禁用vsync,但实际上它完全取决于操作系统和图形驱动程序。 唯一可以做的就是渲染一堆帧,尝试测量此任务的执行时间,然后比较它们是否花费大约相同的时间。 这正是我为
The End is Nigh做的 。 如果游戏不包含频率为60 Hz的垂直同步,则它将以“严格60 fps”回滚到原始帧计时器。 此外,我在配置文件中添加了一个参数,该参数强制游戏不要使用模糊性(主要用于需要准确时间的速跑者),并为其添加了确切的游戏内计时器处理程序,该处理程序允许使用自动拆分器(这是一个与原子时间计时器一起使用的脚本)。
一些用户仍然抱怨个别帧偶尔会发生抖动,但是它们似乎很少见,可以通过操作系统事件或其他外部原因来解释。 没什么大不了的。 对不对
通过最近的计时器代码,我发现了一些奇怪的事情。 更换了电池,每个帧花费的时间比1/60秒稍长,因此游戏时不时地认为该帧已经太晚了,并进行了两次更新。 原来,我的显示器的工作频率为59.94 Hz,而不是60 Hz。 这意味着他必须每1000帧执行两次更新才能“追上”。 但是,这很容易解决-只需更改允许的帧频率间隔即可(不是从60到62,而是从59到61)。
while(accumulator > 1.0/61.0){ update(); accumulator -= 1.0/59.0; if(accumulator < 0) accumulator = 0; }
上面描述的与vsync和高频监视器断开连接的问题仍然存在,并且仍适用相同的解决方案(如果监视器
未通过60同步vsync,则回滚到严格计时器)。
但是您如何知道这是否是正确的解决方案? 如何确保在启用和不启用vsync的情况下,具有不同类型的监视器的所有计算机组合都能正常运行? 很难在头脑中跟踪所有这些计时器问题,并且很难理解是什么原因导致了不同步,奇怪的循环等。
监控模拟器
为了解决“ 59.94赫兹监视器的问题”,我想出一个可靠的解决方案,但我意识到,我不能只是进行试错检查,而是希望找到一个可靠的解决方案。 我需要一种简便的方法来测试编写高质量计时器的各种尝试,并需要一种简便的方法来检查它是否在不同的监视器配置中引起抖动或时移。
Monitor Simulator出现在场景中。 这是我编写的“肮脏而快速”的代码,模拟了“监视器操作”,并从本质上向我展示了一些数字,这些数字可以让您了解每个测试计时器的稳定性。
例如,对于最简单的计时器,从文章开头显示以下值:
20211012021011202111020211102012012102012[...]
TOTAL UPDATES: 10001
TOTAL VSYNCS: 10002
TOTAL DOUBLE UPDATES: 2535
TOTAL SKIPPED RENDERS: 0
GAME TIME: 166.683
SYSTEM TIME: 166.7
首先,代码为每个模拟的vsync显示上一个vsync之后游戏周期的“更新”次数。 实心1以外的任何值都将导致图像抖动。 最后,代码显示累积的统计信息。
在59.94-赫兹监视器上使用“模糊计时器”(间隔为60-62fps)时,代码显示以下内容:
111111111111111111111111111111111111111111111[...]
TOTAL UPDATES: 10000
TOTAL VSYNCS: 9991
TOTAL DOUBLE UPDATES: 10
TOTAL SKIPPED RENDERS: 0
GAME TIME: 166.667
SYSTEM TIME: 166.683
帧的抽动非常少见,因此很难以1表示。但是显示的统计数据清楚地表明,游戏在此处进行了几次双重更新,从而导致抽动。 在固定版本(间隔为59–61 fps)中,跳过0或进行两次更新。
您也可以禁用vsync。 其余的统计数据变得无关紧要,但这清楚地向我显示了“时间偏移”的大小(系统时间相对于游戏时间的偏移)。
GAME TIME: 166.667
SYSTEM TIME: 169.102
这就是为什么禁用vsync时,您需要切换到严格计时器的原因,否则这些差异会随着时间累积。
如果将渲染时间设置为.02(即,需要“多于一帧”的渲染时间),那么我会抽搐。 理想情况下,游戏模式应看起来像202020202020,但有点不平衡。
在这种情况下,此计时器的行为要比前一个计时器好一些,但是它变得更加混乱,并且更加难以确定其工作方式和原因。 但是我只是可以将测试放入此模拟器中,并检查它们的行为,以后可以找出原因。 反复试验,宝贝!
while(accumulator >= 1.0/61.0){ simulate_update(); accumulator -= 1.0/60.0; if(accumulator < 1.0/59.0–1.0/60.0) accumulator = 0; }
您可以下载
监视器模拟器,并独立检查不同的时序计算方法。
给我发电子邮件,如果您发现任何更好的东西。
我对我的决定不是100%满意(它仍然需要使用“ vsync识别”进行破解,并且在不同步过程中可能偶尔发生抽动),但是我认为这几乎与尝试以固定步骤实现游戏周期一样好。 之所以会出现部分问题,是因为很难确定此处认为“可接受”的参数。 主要困难在于时移和双倍/跳帧之间的折衷。 如果您在50 Hz PAL监视器上运行60 Hz游戏……那么正确的决定是什么? 您要疯狂地玩耍还是玩游戏明显慢一些? 两种选择似乎都不好。
单独渲染
在以前的方法中,我描述了所谓的“锁步渲染”。 游戏会更新其状态,然后进行渲染,并且在渲染时,它始终显示游戏的最新状态。 渲染和更新连接在一起。
但是您可以将它们分开。 这正是“
修复您的时间步长 ”一文中描述的方法。 我不会重复自己,您绝对应该阅读这篇文章。 (据我所知)这是AAA游戏和Unity和Unreal等引擎中使用的“行业标准”(但是,在激烈的2D活跃游戏中,他们通常更喜欢使用固定步长(锁定步长),因为有时精确度会给您此方法)。
但是,如果我们简要描述Glenn的帖子,则仅描述具有固定帧频的更新方法,但是在渲染时,在游戏的“当前”和“先前”状态之间执行插值,并且将当前电池值用作插值。 使用此方法,您可以以任何帧频进行渲染并以任何频率更新游戏,并且图片将始终保持平滑。 不会抽搐,普遍适用。
while(running){ computeDeltaTimeSomehow(); accumulator += deltaTime; while(accumulator >= 1.0/60.0){ previous_state = current_state; current_state = update(); accumulator -= 1.0/60.0; } render_interpolated_somehow(previous_state, current_state, accumulator/(1.0/60.0)); display(); }
所以,小学。 问题已解决。
现在,您只需要确保游戏可以渲染插值状态即可,但是请稍等片刻,这确实不容易。 在Glenn的帖子中,仅假定可以做到这一点。 缓存游戏对象的先前位置并内插其运动非常容易,但是游戏状态远不止于此。 必须考虑其中的动画状态,对象的创建和销毁以及一堆东西。
另外,按照游戏的逻辑,您需要考虑对象是否被传送或是否需要平滑移动,以使插值器不会对游戏对象到其当前位置的路径做出错误的假设。 真正的混乱可能随转弯而发生,尤其是在一帧物体的转弯变化超过180度时。 以及如何正确处理创建和销毁的对象?
目前,我正在引擎中从事这项任务。 实际上,我只是对运动进行插值,而其他一切都保持不变。 如果对象不能平滑移动,您将不会注意到抖动,因此,如果其他所有操作都顺利进行,则跳过动画帧并将对象的创建/销毁同步到一帧将不会成为问题。
但是,奇怪的是,实际上,此方法使游戏处于比模拟现在所在位置晚1个游戏状态的状态。 这并不明显,但可以与其他延迟源(例如输入延迟和监视器刷新率)相关联,因此需要最快速响应游戏玩法的人(我是在谈论您,速跑者)将很可能在游戏中使用锁步。
在我的引擎中,我只是选择。 如果您有一台60赫兹的显示器和一台快速的计算机,则最好在启用vsync的情况下使用锁步。 如果显示器的刷新率不符合标准,或者您的计算机性能不佳,无法持续每秒渲染60帧,请启用帧插值。 我想将此选项称为“解锁帧率”,但是人们可能会认为它只是意味着“如果您有一台好的计算机,请启用此选项”。 但是,此问题可以稍后解决。
实际上,有
一种解决此问题的方法。
可变时间步长更新
许多人问我为什么不只是以可变的时间步长更新游戏,而且理论上的程序员经常说:“如果游戏是正确编写的,那么您可以简单地以任意时间步长来更新游戏”。
while(running) { deltaTime = CurrentTime()-OldTime; oldTime = CurrentTime(); update(deltaTime); render(); display(); }
时间上没有奇怪。 没有奇怪的插值渲染。 一切都很简单,一切正常。
所以,小学。 问题已解决。 现在永远! 不可能取得更好的结果!
现在,它非常简单,可以使游戏逻辑在任意时间步长上运行。 很简单,只需替换所有以下代码:
position += speed;
在此:
position += speed * deltaTime;
并替换以下代码:
speed += acceleration; position += speed;
在此:
speed += acceleration * deltaTime; position += speed * deltaTime;
并替换以下代码:
speed += acceleration; speed *= friction; position += speed;
在此:
Vec3D p0 = position; Vec3D v0 = velocity; Vec3D a = acceleration*(1.0/60.0); double f = friction; double n = dt*60; double fN = pow(friction, n); position = p0 + ((f*(a*(f*fN-f*(n+1)+n)+(f-1)*v0*(fN-1)))/((f-1)*(f-1)))*(1.0/60.0); velocity = v0*fN+a*(f*(fN-1)/(f-1));
...等等
这些都是从哪里来的?
最后一部分实际上是从我的引擎的辅助代码复制而来的,该代码执行“真正正确的,与帧速率无关的运动并具有摩擦限制速度”。 里面有一些垃圾(这些乘除60)。 但这是代码的“正确”版本,其中前一个片段的时间步长可变。 我和
Wolfram Alpha一起解决了一个多小时。
现在他们可能会问我为什么不这样做:
speed += acceleration * deltaTime; speed *= pow(friction, deltaTime); position += speed * deltaTime;
尽管这似乎可行,但这样做实际上是错误的。 您可以自己检查。 使用deltaTime = 1执行两次更新,然后使用deltaTime = 2执行一次更新,结果将有所不同。 通常我们会努力使游戏协同工作,因此不欢迎此类差异。 如果您确定deltaTime始终近似等于一个值,那么这可能是一个足够好的解决方案,但是您需要编写代码以确保以一定的恒定频率执行更新,是的。 没错,现在我们正在尝试“正确”地进行所有操作。
如果这样的一小段代码变成了可怕的数学计算,那么请想象更复杂的运动模式,其中许多相互作用的对象都会参与其中,等等。 现在您可以清楚地看到“正确”的解决方案是无法实现的。 我们可以达到的最大值是一个“近似值”。 现在让我们忘记它,并假设我们实际上具有运动功能的“真正正确”版本。 太好了吧?
不,实际上。 这是我在
Bombernauts中遇到的这个问题的真实示例。 玩家可以弹跳约1个图块,并且游戏以1个图块中的块状网格进行。 要降落在积木上,角色的腿必须高出积木的上表面。
但是由于此处的碰撞识别是通过不连续的步骤进行的,因此如果游戏以较低的帧速率运行,则尽管它们遵循相同的运动曲线,但有时它们的脚不会到达瓷砖的表面,并且代替提起,玩家也会滑出墙面。
显然,这个问题是可以解决的。 但这说明了我们尝试以可变的时间步长正确实施游戏周期工作时遇到的问题类型。 我们失去了连贯性和确定性,因此我们必须通过记录玩家的输入,确定性多人游戏等来摆脱游戏重播的功能。 对于基于反射的快速2D游戏,一致性非常重要(再次向速跑者打招呼)。
如果您尝试调整时间步长,使其既不会太大也不会太小,那么您将失去从可变时间步长获得的主要优势,并且可以安全地使用此处介绍的其他两种方法。 这场比赛不值得。 游戏逻辑(正确的运动数学的实现)将投入过多的精力,在确定性和一致性领域将需要太多的受害者。 我只会将这种方法用于音乐节奏游戏(运动方程很简单,需要最大的响应度和平滑度)。 在所有其他情况下,我将选择一个固定更新。
结论
现在,您知道如何使游戏以60fps的恒定频率运行。 这很简单,没有人应该对此有任何疑问。
没有其他问题使此任务复杂化。