目录
前言
什么是游戏循环?
游戏循环的意义
从结构上来看:
从功能上来看:
正文
一个简单的游戏循环
阻塞游戏循环
现代基础游戏循环
“时间”
固定帧率游戏循环
时间驱动不固定帧率游戏循环
灵活帧率更新
之后的新问题
总结
一些设计决策
谁来控制游戏循环?
能量损耗
14天学习训练营导师课程:
李宁《Python Pygame游戏开发入门与实战》
李宁《计算机视觉OpenCV Python项目实战》1
李宁《计算机视觉OpenCV Python项目实战》2
李宁《计算机视觉OpenCV Python项目实战》3
游戏循环对于我们来说很常见了,就是所有游戏里面都有的一个东西。如果真要解释一下的话,就是让我们的CPU和GPU持续不断地更新游戏里面的内容并且将其渲染到屏幕上。相比于其他有交互的软件的循环,游戏内的循环逻辑是更难的,因为我们的目标是:让游戏不断地更新,并且在“获取输入”、“计算”、“渲染”这三个步骤都不造成大量的阻塞。那么知道这样的话,就让我们从游戏之“创世之初”——像先人一样思考游戏循环的有关始末吧!
PS:由于现在的很多游戏都是直接套用的游戏引擎,所以看起来游戏循环这个模式并不是那么必须了,因为有引擎来帮助我们考虑这些问题。但是我们都不能保证如果有一天我们自己的游戏项目需要用到自己的引擎,总之还是略微了解一点比较好,因此写作本文。
游戏大体可以拆分为两大块:
游戏循环的开始就意味着游戏的开始。
#创建游戏主窗口 screen = pygame.display.set_mode((480,700)) #游戏循环 while True: pass
PS:游戏窗口无需重复创建
关于这种游戏循环我并没有找到一个官方的名字,但是由于它会对线程进行阻塞,所以我就将其称为此名。我相信我们小时候没少想过这种场景:
// 在电脑上的例子 > 你走到了一个老房子的门口,老房子看起来很破旧,门半遮半掩,等待着你的进入... > E: 进入;L:离开 > E > 你进入了老房子......
或是在纸上画一个地图去找小伙伴,他们会将他们的行为告诉你,然后你按照产生的想法来进行下一步,这就是最简单的游戏逻辑的计算。
以上两种方式都是一样的,和传统的软件并没有什么区别,在游戏循环上都会被用户的输入而阻塞。比如说上面命令行的文字游戏,当它提示让玩家输入的时候,你如果不输入东西的话,它就会一直保持不动,直到接收到玩家的指令。
这种最基础的游戏循环用代码来实现是这样的:
bool isRunning = true; while (isRunning) { char* command = getCommand(); // 请求玩家输入并阻塞线程 handleCommand(command); }
PS:这对于那些没有动画、没有其他东西的文字游戏来说是很棒的方式,因为它足够简单。但是如果将其运用到我们现在的游戏上呢?除非你想搞点新花样,否则这就是没有意义的。因为现在的游戏,即使你在挂机也需要播放动画、音效、音乐、渲染等等。那么我们就需要针对于现代游戏的基础游戏循环了。
这种游戏循环也很简单,不过多赘述了,用代码实现是这样的:
bool isRunning = true; while (isRunning) { processInput(); // 不阻塞线程,即使没有输入也继续。 update(); render(); }
这样的话是不是看起来很棒了?即使没有用户的输入,游戏也正在一刻不停的运行——执行你在所有update里面写进去的逻辑。
这当然很棒,但是在这里有一个很容易发现的致命问题——时间。
对于上面的代码而言,我们无时无刻在压榨着电脑的各种资源,我们的代码一刻不停的运行,之后就会出现这种场面。
假设A电脑每秒可以执行200次这样的循环,B电脑每秒可以执行30次这样的循环。那么,A电脑每秒会执行200次的Update(),而B电脑每秒只会执行30次。这就导致了在A电脑和B电脑上,游戏运行的速度不一样。比如说这个操作:“玩家按住W的时候,每一个Update向前移动0.5Unit的距离”。之后,在A电脑上每秒玩家会移动100Unit,在B电脑上只会移动15Unit。但是游戏的地图是不变的,于是就会有在两台机器上游戏体验不同的问题。(如果你用过变速齿轮的话,相信你一定可以很清晰的理解这段话的意思)。但是我们不想要这个问题出现,我们想让它在每台电脑上都运行都是差不多的。
所以从这里开始,我们引入“时间”这一概念。
PS:所有的关于游戏循环的问题大多都是关于“时间”的问题,所以“时间”这块一定要好好把握。
那如果我们将其定死呢?
无论什么运行速度的电脑,我们一律按照每秒30帧的频率更新游戏内的逻辑并且渲染?
这当然可行,对于好电脑来说(上面例子的电脑A),只要我们让其在每帧更新、渲染结束之后Sleep一会儿就可以了。用图来说是这样的:
用代码实现是这样的:
float MS_PER_FRAME = 33.33f; // 每一帧应该消耗多久,用毫秒为单位,计算方法为 1000/FPS。 bool isRunning = true; while (isRunning) { double start = getCurrentTime(); processInput(); update(); render(); sleep(start + MS_PER_FRAME - getCurrentTime()); }
PS:这实际上来说已经可用了,因为如果一台电脑非常慢的话,它也只会按照它能力的最大刷新率来更新游戏(因为你不能sleep一个负值)。但是,虽然能用,这个方法还是不够好。如果一台电脑很厉害,我们应该让其拥有能力去显示更棒的画面、更流畅的动画,而不是单纯地给它定死在一个值上面,于是我们就有了下面的想法。
在这个方法中,我们想要的是“正常的游戏时间”(指并不因为电脑太快而让游戏内时间飞快变得没法玩)和“尽可能的更多细节”(用更多的update()和render()来丰富细节)。于是就有了这一种游戏循环:
double lastTime = getCurrentTime(); bool isRunning = true; while (isRunning) { double current = getCurrentTime(); double elapsed = current - lastTime; processInput(); update(elapsed); // 因为要随着现实时间去更新游戏,所以在update方法中加入一个“经过时间”的参数,方便其在不影响游戏逻辑时间时更新更多的游戏细节。 render(); lastTime = current; }
这个方法完美地解决了以上两个问题,现在游戏可以在不同的硬件上以同样的速率运行了,而且在好电脑上玩家的体验也更丰富了。但是这个方法有着潜在的问题:不确定性。
这可能就会导致这个问题:在好电脑和坏电脑上我们的输入最后造成的结果不一致了。
PS:举个栗子:如A和B在联机打一款游戏,是用我们刚才说的这个方法做的游戏循环。
A的电脑很棒,能达到每秒50帧的帧率,而B的电脑不太好,帧率只有5。A在游戏中射出了一发子弹,数据传输到B的电脑中,于是这两颗子弹在他们的电脑中被同时发射出去了(在同一帧中)。假设这个子弹会在现实世界中的一秒之后消失,那么从它被射出到消失之前,子弹的位置在A的电脑中被更新了50次,但是在B的电脑中只被更新了5次。由于计算机操作浮点数带来的偏差,这颗子弹最后消失的位置在他们两台电脑中是不一样的!
因为A的电脑累积的误差量是B的电脑的10倍!
所以说,这个方法对于简单的单人游戏来说还可行,但是对于多人游戏来说就不是很可行了。(但是这个方法导致的物理有关的计算的误差仍然有可能让游戏变得不可玩,即使是在单人游戏中)。
我们需要对上述继续进行改进。
我称之为灵活帧率刷新——这个方法的名字确实很难想,因为FPS并不固定,所以就起了个这个名字。以下,请看我对其的解释吧:
PS:由于渲染和更新这两个步骤并没有明确的关联,我们在渲染的时候并不需要考虑两次更新之间经过了多少时间,所以我们可以把其单独拎出来,并不必须要求在每一帧更新之后渲染。
对于这个方法,我们首先钦定一个“最优更新间隔时间”,这是实现游戏中最棒的效果所需要的两帧之间的现实时间间隔。之后对于那些能跑到这个速度的电脑,我们让其按照这个方式跑,对于那些不能的,我们则让其按照自己的节奏去跑。这样做既保证了“好电脑”上的流畅性,也保证了“坏电脑”能玩,还保证了所有电脑上游戏速率是相等的!(而且相比于上一个办法,这个方法去除了让时间直接参与更新的函数,从而让物理等模块更稳定了)。
具体代码实现如下:
float MS_PER_UPDATE = 16.66f; // 60帧时候的每帧间隔。(注意,和之前例子里面的不一样,这个并不是两帧渲染之间的间隔,而是更新的间隔) double previous = getCurrentTime(); double lag = 0.0f; bool isRunning = true; while (isRunning) { double current = getCurrentTime(); double elapsed = current - previous; previous = current; lap += elapsed; processInput(); while (lab >= MS_PER_UPDATE) { update(); lag -= MS_PER_UPDATE; } render(); }
用图画出来的话是这样的:
具体来讲,这个的实现思路是这样的:
在允许一定误差的情况下(每秒+/-1次更新),以固定的速率更新游戏。这样使得各种模块更稳定,至少不会在不同的机器上累积不同的误差而导致同一速率的游戏结果相差甚远。用代码来看的话,我们在每一帧的开始更新lag变量,代表了游戏时钟相对于现实时间所落后的差量,之后使用一个内部循环来更新游戏,让其追赶上现实中的时间(让lag小于一次更新的间隔),之后渲染,进行下一次循环。
PS:这个方法很好,在保证了在好电脑上的游戏效果的同时也保证了“坏电脑”和“好电脑”更新的结果是一样的,虽然他们的帧率可能不一样(渲染的速度)。但是这个方法仍然有弊端,就是这个每帧之间的间隔需要被规范地设置。如果在最不好的机器上,每一次调用update()所需要的时间超过我们规定的时间,它就会陷入死亡循环。每一次循环都需要做比上一次更多的update()来追上时间,结果反而是所需要追的时间更多了。
// 模拟一下,假如我们的MS_PER_UPDATE被设定为16,但是在某一台电脑上它需要20毫秒去完成一次update()。渲染、获取输入需要的时间是10毫秒。 开始(第一轮循环): current = 0 elapsed = 0 previous = NULL lag = 0 第二轮循环: current = 10 elapsed = 10 previous = 0 lag = 10 第三轮循环: current = 20 elapsed = 10 previous = 10 lag = 20 // 进入内循环,调用一次update() 之后的lag = 4 第四轮循环: current = 50 // 上一次的一次update 20 + 渲染、获取输入 10 = 30 elapsed = 30 previous = 20 lag = 34 // 进入内循环,调用两次update() 第一次使lag降为18,第二次使lag降为2 之后的lag = 2 第五轮循环: current = 100 // 上一次的两次update 40 + 渲染、获取输入 10 = 50 elapsed = 50 previous = 50 lag = 50 // 进入内循环,调用3次update()... 第N轮循环... 每次所需要的循环越来越多...导致游戏越来越卡。
所以,这就是为什么要好好规定MS_PER_UPDATE的原因了。我们也可以通过一些其他方法来消除这个问题,比如说增加内循环上限之类的。虽然这样会让游戏变慢,但是也比卡死好,万一真的有那些“一帧不卡两帧电竞”的玩家呢(严肃?
由于我们的这个方法中存在残留的延迟,我们会在“随机的时间点”进行渲染(渲染和更新并没有直接的关系了),所以在游戏常常会在两帧之间展现出完全相同的画面,或者说一次渲染和上一次渲染差了好几帧。用时间轴来看就是这样的:
这也就出现了这个问题:
比如说一个子弹在上一次更新在左侧,下一次更新在右侧。这样的话单看这个片段,我们的子弹就会“瞬移”,因为我们并没有在子弹路过面前的时候渲染。但是我们既然已经有了lag这个变量,就代表了我们进入下一帧的时间间隔,所以我们就可以在渲染的时候做点文章了。
假如说我们将渲染改成这样:
render(lag / MS_PER_UPDATE);
PS:我们在其渲染的时候给它这个时间间隔,让其在这种情况下可以“推算”物体在这时候应该在什么位置,并且将其渲染出来,这会提升很多的流畅性。
当然,这个方法有的时候会预测失败,但是由于只有一帧,下一次渲染就会纠正这帧的错误了,所以也还好,相比于没有预测的硬生生的卡顿、瞬移,这个结果还算是可以接受的。
主要是一些比较细枝末节的东西。
你有几种选择:
对于手机来说尤甚。通过这章的讲解,有两种循环方式:
以上就是关于游戏循环的基础内容了,像是现在的游戏引擎的游戏循环是十分复杂的,比如说这是Unity的游戏循环:
Unity - Manual: Order of execution for event functionshttps://docs.unity3d.com/Manual/ExecutionOrder.html链接放在上面了,看上去很是复杂,这就和我们刚才说的不是一个量级的了。但是基础性是归一的,你看那些跑来跑去的箭头,是不是感到一丝丝的熟悉哈哈哈哈。
总之,希望这些知识可以帮到你!