理论要点
什么是游戏循环模式:将游戏的进行和玩家输入解耦,和处理器速度解耦。即每个游戏必有的主循环update的实现。
要点:关键点就是实现如何控制游戏的速度(帧率)和CPU负荷与游戏效果之间权衡。
使用场合:任何游戏或游戏引擎都拥有自己的游戏循环,因为游戏循环是游戏运行的主心骨。
代码分析
1,下面我们试着一步步来实现一个游戏循环模式,看看我们游戏之所以能一直运行更新,底层到底是怎样驱使的呢?
先来看一个最简单的阻塞式循环:
while(true)
{
//阻塞等待用户输入
Event* event = waitForEvent();
//处理用户输入
dispatchEvent(event);
}
很明显,大多数游戏即使没有玩家输入时也继续运行。 如果你站在那里看着屏幕,游戏不会冻结,动画继续动着。因此真实的游戏循环应该是非阻塞式的:它处理用户输入,但是不等待它。
while(true)
{
//玩家输入,没有输入也往下执行
processInput();
//更新一步游戏数据
update();
//绘制游戏
render();
}
目前我们大部分游戏循环基本原理都是这种结构。
接下来我们就在这个基础框架上一步步完善。首先,第一个问题是这个循环到底运行多快?很明显不同硬件下它们的运行速度是不一样,在快速机器上,循环会运行的太快,玩家看不清发生了什么。 在慢速机器上,游戏慢的跟在爬一样,这肯定影响游戏体验。因此我们游戏循环另一个关键任务:不管潜在的硬件条件,以固定速度运行游戏。
2,我们先来增加一个简单的小修复如何。假设你想要你的游戏以60FPS运行。这样每帧大约16毫秒(1000 / 60)。 只要你用少于这个时长进行游戏所有的处理和渲染,就可以以稳定的帧率运行。 你需要做的就是处理这一帧然后等待,直到处理下一帧的时候,就像这样:
代码看上去像这样:
while (true)
{
double start = getCurrentTime();
processInput();
update();
render();
sleep(start + MS_PER_FRAME - getCurrentTime());
}
如果它很快的处理完一帧,这里的sleep()保证了游戏不会运行太快。 如果你的游戏运行太慢,这无济于事。 如果需要超过16ms来更新并渲染一帧,休眠的时间就变成了负的。 如果计算机能回退时间,很多事情就很容易了,但是它不能。
3,我们接着上面的问题:某帧消耗的时间大于帧率,那么会导致游戏世界时间滞后,而且永远追不上。相当于多余的时间永远被消耗掉了。
如果这样,我们是不是可以考虑不用固定sleep延时,而直接把上帧到本帧的真实时间作为update的更新时间。它像这样:
double lastTime = getCurrentTime();
while (true)
{
double current = getCurrentTime();
double elapsed = current - lastTime;
processInput();
update(elapsed);
render();
lastTime = current;
}
这样的话,不管什么硬件update都是以真实时间来更新数据的,看上去已经没什么问题了,是吧。
但是,悲剧,这里有一个严重的问题: 游戏不再是确定的了(elapsed不确定),也不再稳定。 这是我们给自己挖的一个坑:
假设我们有个双人联网游戏,Fred的游戏机是台性能猛兽,而George正在使用他祖母的老爷机。 在Fred的机器上,游戏跑的超级快,每个时间间隔都很小。 比如,在射击游戏中,我们塞了50帧在子弹穿过屏幕的那一秒。 可怜的George的机器只能塞进大约5帧。
这就意味着在Fred的机器上,物理引擎每秒更新50次位置,但是George的只更新5次。 大多数游戏使用浮点数,它们有舍入误差。 每次你将两个浮点数加在一起,获得的结果就会有点偏差。 Fred的机器做了10倍的操作,所以他的误差要比George的更大。 同样的子弹最终在他们的机器上到了不同的位置。
这是使用变化时间可引起的问题之一,还有更多问题呢。 为了实时运行,游戏物理引擎做的是实际机制法则的近似。 为了避免飞天遁地,物理引擎添加了阻尼。 这个阻尼运算被小心地安排成以固定的时间间隔运行。 改变了它,物理就不再稳定。
4,上面例子这种不稳定性太糟了,它唯一存在原因是作为警示寓言,引领我们到更好的东西……
好,既然上面问题原因是由于更新时间不确定引起,那么我们有么有办法让这个时间稳定下来呢?看看下面这个实现:
double previous = getCurrentTime();
double lag = 0.0;
while (true)
{
double current = getCurrentTime();
double elapsed = current - previous;
previous = current;
lag += elapsed;
processInput();
while (lag >= MS_PER_UPDATE)
{
update();
lag -= MS_PER_UPDATE;
}
render();
}
在每帧的开始,根据过去了多少真实的时间,更新lag。 这个变量表明了游戏世界时钟比真实世界落后了多少,然后我们使用一个固定时间步长的内部循环进行追赶。 一旦我们追上真实时间,我们就渲染然后开始新一轮循环。 示例图类似这样:
5,我们接近完善只差一步了,注意没有,上面示例虽然更新时间已经固定了,但是渲染是任意时刻。这从玩家角度看,就好像这样:
这样我们就不总能在正确的时间点渲染, 看看第三次渲染时间,它发生在两次更新之间。想象一颗子弹飞过屏幕,第一次更新时,它在左边。 第二次更新将它移到了右边。 这个游戏在两次更新之间的时间点渲染,所以玩家期望看到子弹在屏幕的中间。 而现在的实现中,它还在左边。这意味着看上去移动发生了卡顿。
方便的是,我们知道渲染距离两次更新的时间(MS_PER_UPDATE),即lag变量存储。所有我们可以把渲染距离两帧之间时间百分比作为参数传给render:
render(lag / MS_PER_UPDATE);
这样之后,假设子弹在屏幕左边20像素的地方,正在以400像素每帧的速度向右移动。 如果在两帧正中渲染,我们会给render()传0.5。 它绘制了半帧之前的图形,在220像素。是吧,这样就能平滑的移动了。
当然,也许这种推断是错误的。 在我们计算下一帧时,也许会发现子弹碰撞到另一障碍,或者减速,又或者别的什么。 我们只是在上一帧位置和我们认为的下一帧位置之间插值。 但只有在完成物理和AI更新后,我们才能知道真正的位置。所以推断有猜测的成分,有时候结果是错误的。 但是,幸运的,这种修正通常不可感知。 最起码,比你不使用推断导致的卡顿更不明显。
好~游戏循环就先说到这里,结束!