实时游戏的时间模拟

游戏是实时的、动态的、互动的计算机模拟,所以时间在电子游戏中担当非常重要的角色。游戏中有不同种类的时间——实时、游戏时间、动画的本地时间线、某函数实际消耗的CPU周期等。本文谈及实时、动态模拟软件如何运作,并探讨这类模拟中运用时间的常见方法。

1. 抽象时间线

时间线是连续的一维轴,其原点(t=0)可以设置为系统中其他时间线的任何相对位置。时间线可以用简单的时钟变量实现,以整数或浮点数格式储存绝对时间值。时间线可能存在如下几种形式。

1.1 真实时间线

可以直接使用CPU的高分辨率计时寄存器来量度时间。此时间线的原点定义为计算机上次启动或重置之时。这种时间的度量单位是CPU周期(或其倍数),但其实只要简单地乘以CPU的高分辨率计时器频率,此单位便可以转换为秒数。

1.2 游戏时间线

在正常情况下,游戏时间和真实时间是一致的。但若希望暂停游戏,就可以简单地临时停止对游戏时间的更新,若要把游戏变成慢动作,可以把游戏时钟更新得慢于实时时钟。

控制游戏时间是很有用的调试工具。例如在追查不正常的渲染时,可以暂停游戏时间,冻结所有动作,然后使用另外时钟的渲染引擎及调试用飞行摄像机继续运作,就能以任意角度观察问题所在。也可以在暂停模式下,手动“逐步更新”推前帧率来调试。要注意这种调试方法暂停游戏时,游戏循环是持续进行的,仅仅是游戏时钟停止,而通过对暂停的时钟加上1/30s去实现单步更新。

1.3 局部与全局时间线

可以想象每个动画或音频片段都含有一个局部时间线,其原点定义为片段的开始。在游戏中播放片段时,正常播放、加速、减速、反转等效果可以视觉化为局部到全局时间线的映射。下图给出了几种映射的情况,其中在做缩放播放时,播放速率记为R。

局部到全局时间线映射的各种情况

2. 测量及处理时间

2.1 帧率与时间增量

时间增量Δt(delta time)在游戏中非常重要,其值为FPS的倒数(若为60FPS则Δt=16.6ms/f)。假设一个游戏物体以恒定速率v(单位m/s)运行,乘以一帧的时间增量(单位s/f),就能得出该物体每帧的位置变化Δx=vΔt(单位m/f)。游戏中物体的感知速度依赖于帧时间Δt,因此计算Δt的值是游戏编程的核心问题之一。

2.1.1 受CPU速度影响

早期的游戏中,程序员不会尝试在游戏循环中准确量度真实经过的时间,即忽略Δt,以Δx=v计算位置变化。这样的后果是,游戏中物体看上去的速度完全依赖于CPU的速度所产生的帧率。若在较快的CPU上运行这类游戏,游戏看上去就会像快速进带一样。

2.1.2 基于经过时间的更新

要开发和CPU速度脱钩的游戏,就要以某种方法度量Δt,只需将CPU的高分辨率计时器取值两次——一次于帧开始时,一次于帧结束时,然后取二者之差就能精确度量上一帧的Δt。绝大部分的游戏引擎都是用这种方法,但它最大的问题是:使用第k帧度量出的Δt去估计第k+1帧的所需时间,这么做不一定准确。有时甚至会产生非常坏的效果,某一帧特别慢导致预测的Δt越来越大,进入低帧率的恶性循环。

2.1.3 使用移动平均

游戏循环中每帧之间是有一些连贯性的。例如,若本帧中摄像机对着含许多渲染耗时物体的走廊,那么下一帧有很大机会仍然对着该走廊。因此可以计算连续几帧的平均时间,用来估计下一帧的Δt。此方法能使游戏适应转变中的帧率,同时缓和瞬间帧率尖峰所带来的影响。

2.1.4 调控帧率

上述方法都是预测下一帧Δt的做法,难免有误差。预期尝试估算下一帧的经过时间,不如尝试保证每帧都准确耗时固定时间,即帧率调控。首先仍然要度量本帧的耗时,若耗时比目标时间短,则让主线程休眠,直至到达目的时间;若耗时比目标时间长,那么只好白等下一个目标时间

当游戏的平均帧率接近目标帧率,此方法才有效。若因经常遇到“慢”帧,就会明显降低游戏质量。因此,仍然需要让将引擎系统设计成能接受任意的Δt。在开发时,引擎停留在“可变帧率”模式,实际运行中,游戏若能一贯地达到目标帧率,就开启帧率调控获其好处。使帧率连续维持稳定对游戏多方面都很重要,例如物理模拟使用的数值积分以固定时间更新运作最佳,或者使游戏录播功能更可靠。

【题外】游戏录播的实现方式:需要记录游戏进行时的所有相关事件,并把这些事件及其时间戳储存下来。然后在播放时,使用相同的初始条件和随机种子,就能准确地按时间重播那些事件。理论上,这么做能产生和原来游戏过程一模一样的重播。然而,若帧率不稳定,事情可能以不完全相同的次序发生。此问题的简单解决方法是,同时记录每帧的Δt,使游戏性的逻辑模拟部分能完全重播录制时的状态。若播放时的帧率不能维持原来的速度,可选择以较慢的速度播放,或选择略过渲染一些帧。

2.1.5 垂直消隐区间

画面撕裂这种显示异常现象,是指由于CRT显示器的电子枪在扫描中途交换背景和前景缓冲区,导致屏幕上半部分显示了旧的影像,而下半部分则显示了新的影像。为避免画面撕裂,许多渲染引擎会在交换缓冲区之前,等待显示器的垂直消隐区间。等待垂直消隐区间是另一种帧率调控,实际上能限制主游戏循环的帧率,使其必然为屏幕刷新率的倍数。例如,在以60Hz刷新的NTSC显示器上,游戏的真实更新率实际会被量化为1/60s的倍数。若两帧之间的时间超过1/60s,便必须等待下一次垂直消隐区间,即该帧共花了2/60s(30FPS)。

2.2 高分辨率计时器

标准C程序库函数time()分辨率为秒,不适合度量游戏帧率。度量时间游戏使用的是现代CPU的高分辨率计时器,这种计时器通常会实现
为硬件寄存器,其分辨率为纳秒,如3GHz的奔腾处理器上,计时器每秒递增30亿次。奔腾的rdtsc指令,Win32 API的QueryPerformanceCounter(),一些PowerPC架构的mftb指令等等,都可以查询分辨率计时器。

要特别注意在一些多核处理器中,每个核有其独立的高分辨率计时器,这些计时器可能会彼此漂移。若比较不同核读取的绝对计算器读数,可能会出现一些奇异情况——甚至是负数的经过时间。

2.3 时间单位和时钟变量

大多数计时器都是64位的无符号整数时钟,可以支持非常高的精度及很大的数值范围(3GHz CPU每周期0.333ns,约195年才循环一次),这是最具弹性的表示法。当要度量高精度但较短的时间,例如剖析一段代码的性能,可用32位整数时钟。注意仍然用64位整数变量储存起始和结束时刻,中间的差值才用32位整数变量。

另一常见方法是把较小的持续时间以秒为单位储存为浮点数,即把以CPU周期为单位的时间度量除以CPU时钟频率。由于32位IEEE浮点数的限制(整数部分占用较少位),应小心避免用浮点时钟变量储存很长的持续时间,最多度量几分钟。若要储存绝对值的浮点时钟,需要定期将其重置为零,以免累加至很大的数值。

有些游戏引擎支持把时间值设定为自定义单位,如以1/300秒为时间单位,优点是:许多情况下足够精确;约165.7天才会溢出;同时是NTSC和PAL制刷新率的倍数。不过这种时间单位对处理动画时间缩放精度仍不够,但对于处理像枪械每次发射间的空档时间、由AI控制的角色要等多久才开始巡逻这些问题就足够了。

2.4 应付断点

当游戏在运行时遇到调试断点,游戏循环会暂停,但CPU实时时钟仍在继续累加,当程序员继续执行程序时,该帧的持续时间会度量出一个巨大的值,显然不适合传到引擎各子系统。最简单的方法就是,在主循环中,若度量到某帧的持续时间超过阈值(如1/10s),则可假定游戏刚从断点恢复执行,于是把增量时间人工设为1/30s或1/60s(或其他目标帧率)。

2.5 一个简单的时钟类

以下给出一个简单时钟类的实现。

class Clock {
    U64 m_timeCycles;    // 时钟周期
    F32 m_timeScale;    // 时间缩放因子
    bool m_isPaused;    // 是否暂停
    static F32 s_cyclesPerSecond;    // CPU每秒的周期数

    // 将秒数转换为周期数
    static inline U64 secondsToCycle(F32 timeSeconds) {
        return (U64) (timeSeconds * s_cyclesPerSecond);
    }

public:
    // 游戏启动时调用此初始化
    static void init() {
        s_cyclesPerSecond = (F32)readHiResTimerFrequency();
    }

    // 构建一个时钟
    explicit Clock(F32 startTimeSeconds = 0.0f) :
        m_timeCycles(secondToCycles(startTimeSeconds)), m_timeScale(1.Of), m_isPaused(false)
    { }

    // 以周期为单位返回当前时间
    U64 getTimeCycles() const {
        return m_timeCycles;
    }

    // 应在每帧调此函数一次,并给予真实度量帧时间(以秒为单位)
    void update(F32 dtRealSeconds) {
        // 非暂停才更新时钟变量
        if (!m_isPaused) {
            U64 dtScaledCycles = secondsToCycles(dtRealSeconds * m_timeScale);    // 乘以缩放因子,实现时间缩放
            m_timeCycles += dtScaledCycles;
        }
    }

    /* 省略其余简单成员函数 */
}

参考文献:电子工业出版社《游戏引擎架构》第7.4、7.5节

当前网速较慢或者你使用的浏览器不支持博客特定功能,请尝试刷新或换用Chrome、Firefox等现代浏览器