引言
游戏开发一直是热门的领域,掌握良好的游戏编程模式是开发人员的应备技能,本书细致地讲解了游戏开发需要用到的各种编程模式,并提供了丰富的示例。本章关于序列模式的介绍。
电子游戏之所以有趣,很大程度上归功于它们会将我们带到别的地方。 几分钟后(或者,诚实点,可能会更长),我们活在一个虚拟的世界。 创造那样的世界是游戏程序员至上的欢愉。
大多数游戏世界都有的特性是时间——虚构世界以其特定的节奏运行。 作为世界的架构师,我们必须发明时间,制造推动游戏时间运作的齿轮。
本篇的模式是建构这些的工具。 游戏循环是时钟的中心轴。 对象通过更新方法来聆听时钟的滴答声。 我们可以用双缓冲模式存储快照来隐藏计算机的顺序执行,这样看起来世界可以进行同步更新。
双缓冲模式
意图
用序列的操作模拟瞬间或者同时发生的事情。
动机
电脑具有强大的序列化处理能力。 它的力量来自于将大的任务分解为小的步骤,这样可以一步接一步的完成。 但是,通常用户需要看到事情发生在瞬间或者让多个任务同时进行。
使用线程和多核架构让这种说法不那么正确了,但哪怕使用多核,也只有一些操作可以同步运行。
一个典型的例子,也是每个游戏引擎都得掌控的问题,渲染。 当游戏渲染玩家所见的世界时,它同时需要处理一堆东西——远处的山,起伏的丘陵,树木,每个都在各自的循环中处理。 如果在用户观察时增量做这些,连续世界的幻觉就会被打破。 场景必须快速流畅地更新,显示一系列完整的帧,每帧都是立即出现的。
双缓冲解决了这个问题,但是为了理解其原理,让我们首先的复习下计算机是如何显示图形的。
计算机图形系统是如何工作的(概述)
在电脑屏幕上显示图像是一次绘制一个像素点。 它从左到右扫描每行像素点,然后移动至下一行。 当抵达了右下角,它退回左上角重新开始。 它做得飞快——每秒六十次——因此我们的眼睛无法察觉。 对我们来说,这是一整张静态的彩色像素——一张图像。
你可以将整个过程想象为软管向屏幕喷洒像素。 独特的像素从软管的后面流入,然后在屏幕上喷洒,每次对一个像素涂一点颜色。 所以软管怎么知道哪种颜色要喷到哪里?
这个解释是“简化过的”。 如果你是底层软件开发人员,跳过下一节吧。 你对这章的其余部分已经了解得够多了。 如果你不是,这部分的目标是给你足够的背景知识,理解等下要讨论的设计模式。
最终,为了让游戏显示在屏幕中,我们需要做的就是写入这个数组。 我们疯狂摆弄的图形算法最终都到了这里:设置帧缓冲中的字节值。 但这里有个小问题。
在字节值和颜色之间的映射通常由系统的像素格式和色深来指定。 在今日多数游戏主机上,每个像素都有32位,红绿蓝三个各占八位,剩下的八位保留作其他用途。
早先,我说过计算机是顺序处理的。 如果机器在运行一块渲染代码,我们不指望它同时还能做些别的什么事。 这通常是没啥问题,但是有些事确实在程序运行时发生。 其中一件是,当游戏运行时,视频输出正在不断从帧缓冲中读取数据。 这可能会为我们带来问题。
假设我们要在屏幕上显示一张笑脸。 程序在帧缓冲上开始循环,为像素点涂色。 我们没有意识到的是,在写入的同时,视频驱动正在读取它。 当它扫描过已写的像素时,笑脸开始浮现,但是之后它进入了未写的部分,就将没有写的像素绘制到了屏幕上。结果就是撕裂,你在屏幕上看到了绘制到一半的图像,这是可怕的视觉漏洞。
这就是我们需要这个设计模式的原因。 程序一次渲染一个像素,但是显示需要一次全部看到——在这帧中啥也没有,下一帧笑脸全部出现。 双缓冲解决了这个问题。我会用类比来解释。
显卡设备读取的缓冲帧正是我们绘制像素的那块(Fig. 1)。 显卡最终追上了渲染器,然后越过它,读取了还没有写入的像素(Fig. 2)。 我们完成了绘制,但驱动没有收到那些新像素。
结果(Fig. 4)是用户只看到了一半的绘制结果。 我称它为“哭脸”,笑脸看上去下半部是撕裂的。
表演1,场景1
想象玩家正在观看我们的表演。 在场景一结束而场景二开始时,我们需要改变舞台设置。 如果让场务在场景结束后进去拖动东西,场景的连贯性就被打破了。 我们可以减弱灯光(这是剧院实际上的做法),但是观众还是知道有什么在进行,而我们想在场景间毫无跳跃地转换。
通过消耗一些地皮,我们想到了一个聪明的解决方案:建两个舞台,观众两个都能看到。 每个有它自己的一组灯光。我们称这些舞台为舞台A和舞台B。 场景一在舞台A上。同时场务在处于黑暗之中的舞台B布置场景二。 当场景一完成后,将切断场景A的灯光,打开场景B的灯光。观众看向新舞台,场景二立即开始。
同时,场务到了黑咕隆咚的舞台A,收拾了场景一然后布置场景三。 一旦场景二结束,将灯光转回舞台A。 我们在整场表演中进行这样的活动,使用黑暗的舞台作为布置下一场景的工作区域。 每一次场景转换,只是在两个舞台间切换灯光。 观众获得了连续的体验,场景转换时没有感到任何中断。他们从来没有见到场务。
重新回到图形
这就是双缓冲的工作原理, 这就是你看到的几乎每个游戏背后的渲染系统。 不只用一个帧缓冲,我们用两个。其中一个代表现在的帧,即类比中的舞台A,也就是说是显卡读取的那一个。 GPU可以想什么时候扫就什么时候扫。
使用单面镜以及其他的巧妙布置,你可以真正地在同一位置布置两个舞台。 随着灯光切换,观众看到了不同的舞台,无需看向不同的地方。 如何这样布置舞台就留给读者做练习吧。
同时,我们的渲染代码正在写入另一个帧缓冲。 即黑暗中的舞台B。当渲染代码完成了场景的绘制,它将通过交换缓存来切换灯光。 这告诉图形硬件开始从第二块缓存中读取而不是第一块。 只要在刷新之前交换,就不会有任何撕裂出现,整个场景都会一下子出现。
但不是所有的游戏主机都是这么做的。 更老的简单主机中,内存有限,需要小心地同步绘制和渲染。那很需要技巧。
这时可以使用以前的帧缓冲了。我们可以将下一帧渲染在它上面了。超棒!
模式
定义缓冲类封装了缓冲:一段可改变的状态。 这个缓冲被增量地修改,但我们想要外部的代码将修改视为单一的原子操作。 为了实现这点,类保存了两个缓冲的实例:下一缓冲和当前缓冲。
当信息从缓冲区中读取,它总是读取当前的缓冲区。 当信息需要写到缓存,它总是在下一缓冲区上操作。 当改变完成后,一个交换操作会立刻将当前缓冲区和下一缓冲区交换, 这样新缓冲区就是公共可见的了。旧的缓冲区成为下一个重用的缓冲区。
何时使用
这是那种你需要它时自然会想起的模式。 如果你有一个系统需要双缓冲,它可能有可见的错误(撕裂之类的)或者行为不正确。 但是,“当你需要时自然会想起”没提提供太多有效信息。 更加特殊地,以下情况都满足时,使用这个模式就很恰当:
- 我们需要维护一些被增量修改的状态。
- 在修改到一半的时候,状态可能会被外部请求。
- 我们想要防止请求状态的外部代码知道内部的工作方式。
- 我们想要读取状态,而且不想等着修改完成。
记住
不像其他较大的架构模式,双缓冲模式位于底层。 正因如此,它对代码库的其他部分影响较小——大多数游戏甚至不会感到有区别。 尽管这里还是有几个警告。
交换本身需要时间
在状态被修改后,双缓冲需要一个swap步骤。 这个操作必须是原子的——在交换时,没有代码可以接触到任何一个状态。 通常,这就是修改一个指针那么快,但是如果交换消耗的时间长于修改状态的时间,那可是毫无助益。
我们得保存两个缓冲区
这个模式的另一个结果是增加了内存的使用。 正如其名,这个模式需要你在内存中一直保留两个状态的拷贝。 在内存受限的设备上,你可能要付出惨痛的代价。 如果你不能接受使用两份内存,你需要使用别的方法保证状态在修改时不会被请求。
示例代码
我们知道了理论,现在看看它在实践中如何应用。 我们编写了一个非常基础的图形系统,允许我们在缓冲帧上描绘像素。 在大多数主机和电脑上,显卡驱动提供了这种底层的图形系统, 但是在这里手动实现有助于理解发生了什么。首先是缓冲区本身:
class Framebuffer |
它有将整个缓存设置成默认的颜色的操作,也将其中一个像素设置为特定颜色的操作。 它也有函数getPixels()
,读取保存像素数据的数组。 虽然在这个例子中没有出现,但在实际中,显卡驱动会频繁调用这个函数,将缓存中的数据输送到屏幕上。
我们将整个缓冲区封装在Scene
类中。渲染某物需要做的是在这块缓冲区上调用一系列draw()
。
class Scene |
每一帧,游戏告诉场景去绘制。场景清空缓冲区然后一个接一个绘制一大堆像素。 它也提供了getBuffer()
获得缓冲区,这样显卡可以接触到它。
这看起来直截了当,但是如果就这样做,我们会遇到麻烦。 显卡驱动可以在任何时间调用getBuffer()
,甚至在这个时候:
buffer_.draw(1, 1); |
特别地,它画出来这幅旷世杰作:
当上面的情况发生时,用户就会看到脸的眼睛,但是这一帧中嘴却消失了。 下一帧,又可能在某些别的地方发生冲突。最终结果是糟糕的闪烁图形。我们会用双缓冲修复这点:
class Scene |
现在Scene
有存储在buffers_
数组中的两个缓冲区。 我们并不从数组中直接引用它们。而是通过两个成员,next_
和current_
,指向这个数组。 当绘制时,我们绘制在next_
指向的缓冲区上。 当显卡驱动需要获得像素信息时,它总是通过current_
获取另一个缓冲区。
通过这种方式,显卡驱动永远看不到我们正在施工的缓冲区。 解决方案的的最后一部分就是在场景完成绘制一帧的时候调用swap()
。 它通过交换next_
和current_
的引用完成这一点。 下一次显卡驱动调用getBuffer()
,它会获得我们刚刚完成渲染的新缓冲区, 然后将刚刚描绘好的缓冲区放在屏幕上。没有撕裂,也没有不美观的问题。
不仅是图形
双缓冲解决的核心问题是状态有可能在被修改的同时被请求。 这通常有两种原因。图形的例子覆盖了第一种原因——另一线程的代码或者另一个中断的代码直接访问了状态。
但是,还有一个同样常见的原因:负责修改的 代码试图访问同样正在修改状态。 这可能发生在很多地方,特别是实体的物理部分和AI部分,实体在相互交互。 双缓冲在那里也十分有用。
人工不智能
假设我们正在构建一个关于趣味喜剧的游戏的行为系统。 这个游戏包括一堆跑来跑去寻欢作乐的角色。这里是我们的基础角色:
class Actor |
每一帧,游戏要在角色身上调用update()
,让角色做些事情。 特别地,从玩家的角度,所有的角色都应该看上去同时更新。
角色也可以相互交互,这里的“交互”,我指“可以互相扇对方巴掌”。 当更新时,角色可以在另一个角色身上调用slap()
来扇它一巴掌,然后调用wasSlapped()
看看自己是不是被扇了。
角色需要一个可以交互的舞台,让我们来布置一下:
class Stage |
Stage
允许我们向其中增加角色, 然后使用简单的update()
调用来更新每个角色。 在用户看来,角色是同时移动的,但是实际上,它们是依次更新的。
这里需要注意的另一点是,每个角色的“被扇”状态在更新后就立刻被清除。 这样才能保证一个角色对一巴掌只反应一次。
作为一切的开始,让我们定义一个具体的角色子类。 这里的喜剧演员很简单。 他只面向一个角色。当他被扇时——无论是谁扇的他——他的反应是扇他面前的人一巴掌。
class Comedian : public Actor |
现在我们把一些喜剧演员丢到舞台上看看发生了什么。 我们设置三个演员,第一个面朝第二个,第二个面朝第三个,第三个面对第一个,形成一个环:
Stage stage; |
最终舞台布置如下图。箭头代表角色的朝向,然后数字代表角色在舞台数组中的索引。
我们扇哈利一巴掌,为表演拉开序幕,看看之后会发生什么:
harry->slap(); |
记住Stage
中的update()
函数轮流更新每个角色, 因此如果检视整个代码,我们会发现事件这样发生:
Stage updates actor 0 (Harry) |
在单独的一帧中,初始给哈利的一巴掌传给了所有的喜剧演员。 现在,让事物复杂起来,让我们重新排列舞台数组中角色的排序, 但是继续保持面向对方的方式。
我们不动舞台的其余部分,只是将添加角色到舞台的代码块改为如下:
stage.add(harry, 2); |
让我们看看再次运行时会发生什么:
Stage updates actor 0 (Chump) |
哦不。完全不一样了。问题很明显。 更新角色时,我们修改了他们的“被扇”状态,这也是我们在更新时读取的状态。 因此,在更新中早先的状态修改会影响之后同一状态的修改的步骤。
如果你继续更新舞台,你会看到巴掌在角色间逐渐传递,每帧传递一个。 在第一帧 Harry扇了Baldy。下一帧,Baldy扇了Chump,如此类推。
而最终的结果是,一个角色对被扇作出反应可能是在被扇的同一帧或者下一帧, 这完全取决于两个角色在舞台上是如何排序的。 这没能满足我让角色同时反应的需求——它们在同一帧中更新的顺序不该对结果有影响。
缓存巴掌
幸运的是,双缓冲模式可以帮忙。 这次,不是保存两大块“缓冲”,我们缓冲更小粒度的事物:每个角色的“被扇”状态。
class Actor |
不再使用一个slapped_
状态,每个演员现在使用两个。 就像我们之前图形的例子一样,当前状态为读准备,下一状态为写准备。
reset()
函数被替换为swap()
。 现在,就在清除交换状态前,它将下一状态拷贝到当前状态上, 使其成为新的当前状态,这还需要在Stage
中进行小小的改变:
void Stage::update() |
update()
函数现在更新所有的角色,然后 交换它们的状态。 最终结果是,角色在实际被扇之后的那帧才能看到巴掌。 这样一来,角色无论在舞台数组中如何排列,都会保持相同的行为。 无论外部的代码如何调用,所有的角色在一帧内同时更新。
设计决策
双缓冲很直观,我们上面看到的例子也覆盖了大多数你需要的场景。 使用这个模式之前,还需要做两个主要的设计决策。
缓冲区是如何被交换的?
交换操作是整个过程的最重要的一步, 因为在其发生时,我们必须锁住两个缓冲区上的读取和修改。 为了让性能最优,我们需要它进行得越快越好。
交换缓冲区的指针或者引用: 这是我们图形例子中的做法,这也是大多数双缓冲图形通用的解决方法。
速度快。 不管缓冲区有多大,交换都只需赋值一对指针。很难在速度和简易性上超越它。
外部代码不能存储对缓存的永久指针。 这是主要限制。 由于我们没有真正地移动数据,本质上做的是周期性地通知代码库的其他部分到别处去寻找缓存, 就像前面的舞台类比一样。这就意味着代码库的其他部分不能存储指向缓冲区中数据的指针—— 它一段时间后可能就指向了错误的部分。
这会严重误导那些期待缓冲帧永远在内存中的固定地址的显卡驱动。在这种情况下,我们不能这么做。
缓冲区中的数据是两帧之前的数据,而不是上一帧的数据。 接下来的那帧绘制在帧缓冲区上,而不是在它们之间拷贝数据,就像这样:
Frame 1 drawn on buffer A
Frame 2 drawn on buffer B
Frame 3 drawn on buffer A
...你会注意到,当我们绘制第三帧时,缓冲区上的数据是第一帧的,而不是第二帧的。大多数情况下,这不是什么问题——我们通常在绘制之前清空整个帧。但如果想沿用某些缓存中已有的数据,就需要考虑数据其实比期望的更旧。
旧帧中缓存数据的经典用法是模拟动态模糊。 当前的帧混合一点之前的帧,看起来更像真实的相机捕获的图景。
- 在缓冲区之间拷贝数据: 如果我们不能重定向到其他缓存,唯一的选项就是将下帧的数据实实在在的拷贝到现在这帧上。 这是我们的扇巴掌喜剧的工作方法。 这种情况下,使用这种方法是因为拷贝状态——一个简单的布尔标识——不比修改指向缓存的指针开销大。
- 下一帧的数据和之前的数据相差一帧。 拷贝数据与在两块缓冲区间跳来跳去正相反。 如果我们需要前一帧的数据,这样我们可以处理更新的数据。
- 交换也许更花时间。 这个当然是最大的缺点。交换操作现在意味着在内存中拷贝整个缓冲区。 如果缓冲区很大,比如一整个缓冲帧,这需要花费可观的时间。 由于交换时没有东西可以读取或者写入任何一个缓冲区,这是一个巨大的限制。
缓冲的粒度如何?
这里的另一个问题是缓冲区本身是如何组织的——是单个数据块还是散布在对象集合中? 图形例子是前一种,而角色例子是后一种。
大多数情况下,你缓存的方式自然而然会引导你找到答案,但是这里也有些灵活度。 比如,角色总能将消息存在独立的消息块中,使用索引来引用。
如果缓存是一整块:
- 交换操作更简单。 由于只有一对缓存,一个简单的交换就完成了。 如果可以改变指针来交换,那么不必在意缓冲区大小,只需几部操作就可以交换整个缓冲区。
如果很多对象都持有一块数据:
交换操作更慢。 为了交换,需要遍历整个对象集合,通知每个对象交换。
在喜剧的例子中,这没问题,因为反正需要清除被扇状态 ——每块缓存的数据每帧都需要接触。 如果不需要接触较旧的帧,可以用通过在多个对象间分散状态来优化,获得使用整块缓存一样的性能。
思路是将“当前”和“下一”指针概念,将它们改为对象相关的偏移量。就像这样:
class Actor
{
public:
static void init() { current_ = 0; }
static void swap() { current_ = next(); }
void slap() { slapped_[next()] = true; }
bool wasSlapped() { return slapped_[current_]; }
private:
static int current_;
static int next() { return 1 - current_; }
bool slapped_[2];
};角色使用
current_
在状态数组中查询,获得当前的被扇状态, 下一状态总是数组中的另一索引,这样可以用next()
来计算。 交换状态只需改动current_
索引。 聪明之处在于swap()
现在是静态函数,它只需被调用一次,每个 角色的状态都会被交换。
参见
你可以在几乎每个图形API中找到双缓冲模式。举个例子,OpenGL有swapBuffers()
,Direct3D有”swap chains”, Microsoft的XNA框架有endDraw()
方法。
游戏循环
意图
将游戏的进行和玩家的输入解耦,和处理器速度解耦。
动机
如果本书中有一个模式不可或缺,那非这个模式莫属了。 游戏循环是“游戏编程模式”的精髓。 几乎每个游戏都有,两两不同,而在非游戏的程序几乎没有使用。
为了看看它多有用,让我们快速缅怀一遍往事。 在每个编写计算机程序的人都留着胡子的时代,程序像洗碗机一样工作。 你输入一堆代码,按个按钮,等待,然后获得结果,完成。 程序全都是批处理模式的——一旦工作完成,程序就停止了。
Ada Lovelace和Rear Admiral Grace Hopper是女程序员,并没有胡子。
你在今日仍然能看到这些程序,虽然感谢上天,我们不必在打孔纸上面编写它们了。 终端脚本,命令行程序,甚至将Markdown翻译成这本书的Python脚本都是批处理程序。
采访CPU
最终,程序员意识到将批处理代码留在计算办公室,等几个小时后拿到结果才能开始找程序漏洞的方式实在低效。 他们想要立即的反馈。交互式 程序诞生了。 第一批交互式程序中就有游戏:
YOU ARE STANDING AT THE END OF A ROAD BEFORE A SMALL BRICK |
这是Colossal Cave Adventure,史上首个冒险游戏。
你可以和这个程序进行实时交互。 它等待你的输入,然后进行响应。 你再输入,这样一唱一和,就像相声一样。 当轮到你时,它停在那里啥也不做。像这样:
while (true) |
这程序会永久循环,所以没法退出游戏。 真实的游戏会做些
while (!done)
进行检查,然后通过设置done
为真来退出游戏。 我省去了那些内容,保持简明。
事件循环
如果你剥开现代的图形UI的外皮,会惊讶地发现它们与老旧的冒险游戏差不多。 文本处理器通常呆在那里什么也不做,直到你按了个键或者点了什么东西:
while (true) |
这与冒险游戏主要的不同是,程序不是等待文本指令,而是等待用户输入事件——鼠标点击、按键按下之类的。 其他部分还是和以前的老式文本冒险游戏一样,程序阻塞等待用户的输入,这是个问题。
不像其他大多数软件,游戏即使在没有玩家输入时也继续运行。 如果你站在那里看着屏幕,游戏不会冻结。动画继续动着。视觉效果继续闪烁。 如果运气不好的话,怪物会继续吞噬英雄。
事件循环有“空转”事件,这样你可以无需用户输入间歇地做些事情。 这对于闪烁的光标或者进度条已经足够了,但对于游戏就太原始了。
这是真实游戏循环的第一个关键部分:它处理用户输入,但是不等待它。循环总是继续旋转:
while (true) |
我们之后会改善它,但是基本的部分都在这里了。 processInput()
处理上次调用到现在的任何输入。 然后update()
让游戏模拟一步。 运行AI和物理(通常是这种顺序)。 最终,render()
绘制游戏,这样玩家可以看到发生了什么。
时间之外的世界
如果这个循环没有因为输入而阻塞,这就带来了明显的问题,要运转多快呢? 每次进行游戏循环都会推动一定的游戏状态的发展。 在游戏世界的居民看来,他们手上的表就会滴答一下。
运行游戏循环一次的常用术语就是“滴答”(tick)和“帧”(frame)。
同时,玩家的真实手表也在滴答着。 如果我们用实际时间来测算游戏循环运行的速度,就得到了游戏的“帧率”(FPS)。 如果游戏循环的更快,FPS就更高,游戏运行得更流畅、更快。 如果循环得过慢,游戏看上去就像是慢动作电影。
我们现在写的这个循环是能转多快转多快,两个因素决定了帧率。 一个是每帧要做多少工作。复杂的物理,众多游戏对象,图形细节都让CPU和GPU繁忙,这决定了需要多久能完成一帧。
另一个是底层平台的速度。 更快的芯片可以在同样的时间里执行更多的代码。 多核,GPU组,独立声卡,以及系统的调度都影响了在一次滴答中能够做多少东西。
每秒的帧数
在早期的视频游戏中,第二个因素是固定的。 如果你为NES或者Apple IIe写游戏,你明确知道游戏运行在什么CPU上。 你可以(也必须)为它特制代码。 你只需担忧第一个因素:每次滴答要做多少工作。
早期的游戏被仔细地编码,一帧只做一定的工作,开发者可以让游戏以想要的速率运行。 但是如果你想要在快些或者慢些的机器上运行同一游戏,游戏本身就会加速或减速。
这就是为什么老式计算机通常有“turbo”按钮。 新的计算机运行得太快了,无法玩老游戏,因为游戏也会运行得过快。 关闭 turbo按钮,会减慢计算机的运行速度,就可以运行老游戏了。
现在,很少有开发者可以奢侈地知道游戏运行的硬件条件。游戏必须自动适应多种设备。
这就是游戏循环的另一个关键任务:不管潜在的硬件条件,以固定速度运行游戏。
模式
一个游戏循环在游玩中不断运行。 每一次循环,它无阻塞地处理玩家输入,更新游戏状态,渲染游戏。 它追踪时间的消耗并控制游戏的速度。
何时使用
使用错误的模式比不使用模式更糟,所以这节通常告诫你不要过于热衷设计模式。 设计模式的目标不是往代码库里尽可能的塞东西。
但是这个模式有所不同。我可以很自信的说你会使用这个模式。 如果你使用游戏引擎,你不需要自己编写,但是它还在那里。
对于我而言,这是“引擎”与“库”的不同之处。 使用库时,你拥有游戏循环,调用库代码。 使用引擎时,引擎拥有游戏循环,调用你的代码。
你可能认为在做回合制游戏时不需要它。 但是哪怕是那里,就算游戏状态到玩家回合才改变,视觉和听觉 状态仍会改变。 哪怕游戏在“等待”你进行你的回合,动画和音乐也会继续运行。
记住
我们这里谈到的循环是游戏代码中最重要的部分。 有人说程序会花费90%的时间在10%的代码上。 游戏循环代码肯定在这10%中。 你必须小心谨慎,时时注意效率。
“真正的”工程师,比如机械或电子工程师,不把我们当回事,大概就是因为我们像这样使用统计学。
你也许需要与平台的事件循环相协调
如果你在操作系统的顶层或者有图形UI和内建事件循环的平台上构建游戏, 那你就有了两个应用循环在同时运作。 它们需要很好地协调。
有时候,你可以进行控制,只运行你的游戏循环。 举个例子,如果舍弃了Windows的珍贵API,main()
可以只用游戏循环。 其中你可以调用PeekMessage()
来处理和分发系统的事件。 不像GetMessage()
,PeekMessage()
不会阻塞等待用户输入, 因此你的游戏循环会保持运作。
其他的平台不会让你这么轻松地摆脱事件循环。 如果你使用网页浏览器作为平台,事件循环已被内建在浏览器的执行模型深处。 这样,你得用事件循环作为游戏循环。 你会调用requestAnimationFrame()
之类的函数,它会回调你的代码,保持游戏继续运行。
示例代码
在如此长的介绍之后,游戏循环的代码实际上很直观。 我们会浏览一堆变种,比较它们的好处和坏处。
游戏循环驱动了AI,渲染和其他游戏系统,但这些不是模式的要点, 所以我们会调用虚构的方法。在实现了render()
,update()
之后, 剩下的作为给读者的练习(挑战!)。
跑,能跑多快跑多快
我们已经见过了可能是最简单的游戏循环:
while (true) |
它的问题是你不能控制游戏运行得有多快。 在快速机器上,循环会运行得太快,玩家看不清发生了什么。 在慢速机器上,游戏慢的跟在爬一样。 如果游戏的一部分有大量内容或者做了很多AI或物理运算,游戏就会慢一些。
休息一下
我们看看增加一个简单的小修正如何。 假设你想要你的游戏以60FPS运行。这样每帧大约16毫秒。 只要你用少于这个的时长进行游戏所有的处理和渲染,就可以以稳定的帧率运行。 你需要做的就是处理这一帧然后等待,直到处理下一帧的时候,就像这样:
代码看上去像这样:
1000 毫秒 / 帧率 = 毫秒每帧.
while (true) |
如果它很快地处理完一帧,这里的sleep()保证了游戏不会运行太快。 如果你的游戏运行太慢,这无济于事。 如果需要超过16ms来更新并渲染一帧,休眠的时间就变成了负的。 如果计算机能回退时间,很多事情就很容易了,但是它不能。
相反,游戏变慢了。 可以通过每帧少做些工作来解决这个问题——减少物理效果和绚丽光影,或者把AI变笨。 但是这影响了那些有快速机器的玩家的游玩体验。
一小步,一大步
让我们尝试一些更加复杂的东西。我们拥有的问题基本上是:
- 每次更新将游戏时间推动一个固定量。
- 这消耗一定量的真实时间来处理它。
如果第二步消耗的时间超过第一步,游戏就变慢了。 如果它需要超过16ms来推动游戏时间16ms,那它永远也跟不上。 但是如果一步中推动游戏时间超过16ms,那我们可以减少更新频率,就可以跟得上了。
接着的思路是基于上帧到现在有多少真实时间流逝来选择前进的时间。 这一帧花费的时间越长,游戏的间隔越大。 它总能跟上真实时间,因为它走的步子越来越大。 有人称之为变化的或者流动的时间间隔。它看上去像是:
double lastTime = getCurrentTime(); |
每一帧,我们计算上次游戏更新到现在有多少真实时间过去了(即变量elapsed
)。 当我们更新游戏状态时将其传入。 然后游戏引擎让游戏世界推进一定的时间量。
假设有一颗子弹跨过屏幕。 使用固定的时间间隔,在每一帧中,你根据它的速度移动它。 使用变化的时间间隔,你根据过去的时间拉伸速度。 随着时间间隔增加,子弹在每帧间移动得更远。 无论是二十个快的小间隔还是四个慢的大间隔,子弹在真实时间里移动同样多的距离。 这看上去成功了:
- 游戏在不同的硬件上以固定的速度运行。
- 使用高端机器的玩家获得了更流畅的游戏体验。
但悲剧的是,这里有一个严重的问题: 游戏不再是确定的了,也不再稳定。 这是我们给自己挖的一个坑:
“确定的”代表每次你运行程序,如果给了它同样的输入,就获得同样的输出。 可以想得到,在确定的程序中追踪漏洞更容易——一旦找到造成漏洞的输入,每次你都能重现之。
计算机本身是确定的;它们机械地执行程序。 在纷乱的真实世界搀合进来,非确定性就出现了。 例如,网络,系统时钟,线程调度都依赖于超出程序控制的外部世界。
假设我们有个双人联网游戏,Fred的游戏机是台性能猛兽,而George正在使用他祖母的老爷机。 前面提到的子弹在他们的屏幕上飞行。 在Fred的机器上,游戏跑得超级快,每个时间间隔都很小。 比如,我们塞了50帧在子弹穿过屏幕的那一秒。 可怜的George的机器只能塞进大约5帧。
这就意味着在Fred的机器上,物理引擎每秒更新50次位置,但是George的只更新5次。 大多数游戏使用浮点数,它们有舍入误差。 每次你将两个浮点数加在一起,获得的结果就会有点偏差。 Fred的机器做了10倍的操作,所以他的误差要比George的更大。 同样 的子弹最终在他们的机器上到了不同的位置。
这是使用变化时间可引起的问题之一,还有更多问题呢。 为了实时运行,游戏物理引擎做的是实际机制法则的近似。 为了避免飞天遁地,物理引擎添加了阻尼。 这个阻尼运算被小心地安排成以固定的时间间隔运行。 改变了它,物理就不再稳定。
“飞天遁地”在这里使用的是它的字面意思。当物理引擎卡住,对象获得了完全错误的速度,就会飞到天上或者掉入地底。
这种不稳定性太糟了,这个例子在这里的唯一原因是作为警示寓言,引领我们到更好的东西……
追逐时间
游戏中渲染通常不会被动态时间间隔影响到。 由于渲染引擎表现的是时间上的一瞬间,它不会计算上次到现在过了多久。 它只是将当前事物渲染在所在的地方。
这或多或少是成立的。像动态模糊的东西会被时间间隔影响,但如果有一点延迟,玩家通常也不会注意到。
我们可以利用这点。 以固定的时间间隔更新游戏,因为这让所有事情变得简单,物理和AI也更加稳定。 但是我们允许灵活调整渲染的时刻,释放一些处理器时间。
它像这样运作:自上一次游戏循环过去了一定量的真实时间。 需要为游戏的“当前时间”模拟推进相同长度的时间,以追上玩家的时间。 我们使用一系列的固定时间步长。 代码大致如下:
double previous = getCurrentTime(); |
这里有几个部分。 在每帧的开始,根据过去了多少真实的时间,更新lag
。 这个变量表明了游戏世界时钟比真实世界落后了多少,然后我们使用一个固定时间步长的内部循环进行追赶。 一旦我们追上真实时间,我们就渲染然后开始新一轮循环。 你可以将其画成这样:
注意这里的时间步长不是视觉上的帧率了。 MS_PER_UPDATE
只是我们更新游戏的间隔。 这个间隔越短,就需要越多的处理次数来追上真实时间。 它越长,游戏抖动得越厉害。 理想上,你想要它足够短,通常快过60FPS,这样游戏在高速机器上会有高效的表现。
但是小心不要把它整得太短了。 你需要保证即使在最慢的机器上,这个时间步长也超过处理一次update()
的时间。 否则,你的游戏就跟不上现实时间了。
我不会详谈这个,但你可以通过限定内层循环的最大次数来保证这一点。 游戏会变慢,但是比完全卡死要好。
幸运的是,我们给自己了一些喘息的空间。 技巧在于我们将渲染拉出了更新循环。 这释放了一大块CPU时间。 最终结果是游戏以固定时间步长模拟,该时间步长与硬件不相关。 只是使用低端硬件的玩家看到的内容会有抖动。
卡在中间
我们还剩一个问题,就是剩下的延迟。 以固定的时间步长更新游戏,在任意时刻渲染。 这就意味着从玩家的角度看,游戏经常在两次更新之间时显示。
这是时间线:
就像你看到的那样,我们以紧凑固定的时间步长进行更新。 同时,我们在任何可能的时候渲染。 它比更新发生得要少,而且也不稳定。 两者都没问题。糟糕的是,我们不总能在正确的时间点渲染。 看看第三次渲染时间。它发生在两次更新之间。
想象一颗子弹飞过屏幕。第一次更新时,它在左边。 第二次更新将它移到了右边。 这个游戏在两次更新之间的时间点渲染,所以玩家期望看到子弹在屏幕的中间。 而现在的实现中,它还在左边。这意味着看上去移动发生了卡顿。
方便的是,我们实际知道渲染时距离两次更新的时间:它被存储在lag
中。 我们在lag
比更新时间间隔小时,而不是lag
是零时,跳出循环进行渲染。 lag
的剩余量?那就是到下一帧的时间。
当我们要渲染时,我们将它传入:
render(lag / MS_PER_UPDATE); |
我们在这里除以
MS_PER_UPDATE
来归一化值。 不管更新的时间步长是多少,传给render()
的值总在0(恰巧在前一帧)到1.0(恰巧在下一帧)之间。 这样,渲染引擎不必担心帧率。它只需处理0到1的值。
渲染器知道每个游戏对象以及它当前的速度。 假设子弹在屏幕左边20像素的地方,正在以400像素每帧的速度向右移动。 如果在两帧正中渲染,我们会给render()
传0.5。 它绘制了半帧之前的图形,在220像素,啊哈,平滑的移动。
当然,也许这种推断是错误的。 在我们计算下一帧时,也许会发现子弹碰撞到另一障碍,或者减速,又或者别的什么。 我们只是在上一帧位置和我们认为的下一帧位置之间插值。 但只有在完成物理和AI更新后,我们才能知道真正的位置。
所以推断有猜测的成分,有时候结果是错误的。 但是,幸运地,这种修正通常不可感知。 最起码,比你不使用推断导致的卡顿更不明显。
设计决策
虽然这章我讲了很多,但是有更多的东西我没讲。 一旦你考虑显示刷新频率的同步,多线程,多GPU,真正的游戏循环会变得更加复杂。 即使在高层,这里还有一些问题需要你回答:
拥有游戏循环的是你,还是平台?
这个选择通常是已经由平台决定的。 如果你在做浏览器中的游戏,很可能你不能编写自己的经典游戏循环。 浏览器本身的事件驱动机制阻碍了这一点。 类似地,如果你使用现存的游戏引擎,你很可能依赖于它的游戏循环而不是自己写一个。
- 使用平台的事件循环:
- 简单。你不必担心编写和优化自己的游戏核心循环。
- 平台友好。 你不必明确地给平台一段时间让它处理它自己的事件,不必缓存事件,不必管理任何平台输入模型和你的不匹配之处。
- 你失去了对时间的控制。 平台会在它方便时调用代码。 如果这不如你想要的那样平滑或者频繁,太糟了。 更糟的是,大多数应用的事件循环并未为游戏设计,通常是又慢又卡顿。
- 使用游戏引擎的循环:
- 不必自己编写。 编写游戏循环非常需要技巧。 由于是每帧都要执行的核心代码,小小的漏洞或者性能问题就对游戏有巨大的影响。 稳固的游戏循环是使用现有引擎的原因之一。
- 不必自己编写。 当然,硬币的另一面是,如果引擎无法满足你真正的需求,你也没法获得控制权。
- 自己写:
- 完全的控制。 你可以做任何想做的事情。你可以为游戏的需求订制开发。
- 你需要与平台交互。 应用框架和操作系统通常需要时间片去处理自己的事件和其他工作。 如果你拥有应用的核心循环,平台就没有这些时间片了。 你得显式定期检查,保证框架没有挂起或者混乱。
如何管理能量消耗?
在五年前这还不是问题。 游戏运行在插到插座上的机器上或者专用的手持设备上。 但是随着智能手机,笔记本以及移动游戏的发展,现在需要关注这个问题了。 画面绚丽,但会耗干三十分钟前充的电,并将手机变成空间加热器的游戏,可不能让人开心。
现在,你需要考虑的不仅仅是让游戏看上去很棒,同时也要尽可能少地使用CPU。 你需要设置一个性能的上限:完成一帧之内所需的工作后,让CPU休眠。
尽可能快地运行:
这是PC游戏的常态(即使越来越多的人在笔记本上运行游戏)。 游戏循环永远不会显式告诉系统休眠。相反,空闲的循环被划在提升FPS或者图像显示效果上了。
这会给你最好的游戏体验。 但是,也会尽可能多地使用电量。如果玩家在笔记本电脑上游玩,他们就得到了一个很好的加热器。
固定帧率
移动游戏更加注意游戏的体验质量,而不是最大化图像画质。 很多这种游戏都会设置最大帧率(通常是30或60FPS)。 如果游戏循环在分配的时间片消耗完之前完成,剩余的时间它会休眠。
这给了玩家“足够好的”游戏体验,也让电池轻松了一点。
你如何控制游戏速度?
游戏循环有两个关键部分:不阻塞用户输入和自适应的帧时间步长。 输入部分很直观。关键在于你如何处理时间。 这里有数不尽的游戏可运行的平台, 每个游戏都需要在其中一些平台上运行。 如何适应平台的变化就是关键。
创作游戏看来是人类的天性,因为每当我们建构可以计算的机器,首先做的就是在上面编游戏。 PDP-1是一个仅有4096字内存的2kHz机器,但是Steve Russell和他的朋友还是在上面创建了Spacewar!。
固定时间步长,没有同步:
见我们第一个样例中的代码。你只需尽可能快地运行游戏。
- 简单。这是主要的(好吧,唯一的)好处。
- 游戏速度直接受到硬件和游戏复杂度影响。 主要的缺点是,如果有所变化,会直接影响游戏速度。游戏速度与游戏循环紧密相关。
固定时间步长,有同步:
对复杂度控制的下一步是使用固定的时间间隔,但在循环的末尾增加同步点,保证游戏不会运行得过快。
- 还是很简单。 这比过于简单以至于不可行的例子只多了一行代码。 在多数游戏循环中,你可能总需要做一些同步。 你可能需要双缓冲图形并将缓冲块与更新显示的频率同步。
- 电量友好。 这对移动游戏至关重要。你不想消耗不必要的电量。 通过简单地休眠几个毫秒而不是试图每帧塞入更多的处理,你就节约了电量。
- 游戏不会运行得太快。 这解决了固定循环速度的一半问题。
- 游戏可能运行的太慢。 如果花了太多时间更新和渲染一帧,播放也会减缓。 因为这种方案没有分离更新和渲染,它比更高级的方案更容易遇到这点。 没法扔掉渲染帧来追上真实时间,游戏本身会变慢。
动态时间步长:
我把这个方案放在这里作为问题的解决办法之一,附加警告:大多数我认识的游戏开发者反对它。 不过记住为什么反对它是很有价值的。
- 能适应并调整,避免运行得太快或者太慢。 如果游戏不能追上真实时间,它用越来越长的时间步长更新,直到追上。
- 让游戏不确定而且不稳定。 这是真正的问题,当然。在物理和网络部分使用动态时间步长会遇见更多的困难。
固定更新时间步长,动态渲染:
在示例代码中提到的最后一个选项是最复杂的,但是也是最有适应性的。 它以固定时间步长更新,但是如果需要赶上玩家的时间,可以扔掉一些渲染帧。
- 能适应并调整,避免运行得太快或者太慢。 只要能实时更新,游戏状态就不会落后于真实时间。如果玩家用高端的机器,它会回以更平滑的游戏体验。
- 更复杂。 主要负面问题是需要在实现中写更多东西。 你需要将更新的时间步长调整得尽可能小来适应高端机,同时不至于在低端机上太慢。
参见
- 关于游戏循环的经典文章是Glenn Fiedler的”Fix Your Timestep“。如果没有这篇文章,这章就不会是这个样子。
- Witters关于game loops的文章也值得阅读。
- Unity框架有一个复杂的游戏循环,细节在这里有详尽的解释。
更新方法
意图
通过每次处理一帧的行为模拟一系列独立对象。
动机
玩家操作强大的女武神完成考验:从死亡巫王的栖骨之处偷走华丽的珠宝。 她尝试接近巫王华丽的地宫门口,然后遇到了……啥也没遇到。 没有诅咒雕像向她发射闪电,没有不死战士巡逻入口。 她直捣黄龙,拿走了珠宝。游戏结束。你赢了。
好吧,这可不行。
地宫需要守卫——一些英雄可以杀死的敌人。 首先,我们需要一个骷髅战士在门口前后移动巡逻。 如果无视任何关于游戏编程的知识, 让骷髅蹒跚着来回移动的最简单的代码大概是这样的:
如果巫王想表现得更加智慧,它应创造一些仍有脑子的东西。
while (true) |
这里的问题,当然,是骷髅来回打转,可玩家永远看不到。 程序锁死在一个无限循环,那可不是有趣的游戏体验。 我们事实上想要的是骷髅每帧移动一步。
我们得移除这些循环,依赖外层游戏循环来迭代。 这保证了在卫士来回巡逻时,游戏能响应玩家的输入并进行渲染。如下:
Entity skeleton; |
在这里前后两个版本展示了代码是如何变得复杂的。 左右巡逻需要两个简单的for
循环。 通过指定哪个循环在执行,我们追踪了骷髅在移向哪个方向。 现在我们每帧跳出到外层的游戏循环,然后再跳回继续我们之前所做的,我们使用patrollingLeft
显式地追踪了方向。
但或多或少这能行,所以我们继续。 一堆无脑的骨头不会对你的女武神提出太多挑战, 我们下一个添加的是魔法雕像。它们一直会向她发射闪电球,这样可让她保持移动。
继续我们的“用最简单的方式编码”的风格,我们得到了:
// 骷髅的变量…… |
你会发现这代码渐渐滑向失控。 变量数目不断增长,代码都在游戏循环中,每段代码处理一个特殊的游戏实体。 为了同时访问并运行它们,我们将它们的代码混杂在了一起。
一旦能用“混杂”一词描述你的架构,你就有麻烦了。
你也许已经猜到了修复这个所用的简单模式了: 每个游戏实体应该封装它自己的行为。 这保持了游戏循环的整洁,便于添加和移除实体。
为了做到这点需要抽象层,我们通过定义抽象的update()
方法来完成。 游戏循环管理对象的集合,但是不知道对象的具体类型。 它只知道这些对象可以被更新。 这样,每个对象的行为与游戏循环分离,与其他对象分离。
每一帧,游戏循环遍历集合,在每个对象上调用update()
。 这给了我们在每帧上更新一次行为的机会。 在所有对象上每帧调用它,对象就能同时行动。
死抠细节的人会在这点上揪着我不放,是的,它们没有真的同步。 当一个对象更新时,其他的都不在更新中。 我们等会儿再说这点。
游戏循环维护动态的对象集合,所以从关卡添加和移除对象是很容易的——只需要将它们从集合中添加和移除。 不必再用硬编码,我们甚至可以用数据文件构成这个关卡,那正是我们的关卡设计者需要的。
模式
游戏世界管理对象集合。 每个对象实现一个更新方法模拟对象在一帧内的行为。每一帧,游戏循环更新集合中的每一个对象。
何时使用
如果游戏循环模式是切片面包, 那么更新方法模式就是它的奶油。 很多玩家交互的游戏实体都以这样或那样的方式实现了这个模式。 如果游戏有太空陆战队,火龙,火星人,鬼魂或者运动员,很有可能它使用了这个模式。
但是如果游戏更加抽象,移动部分不太像活动的角色而更加像棋盘上的棋子, 这个模式通常就不适用了。 在棋类游戏中,你不需要同时模拟所有的部分, 你可能也不需要告诉棋子每帧都更新它们自己。
你也许不需要每帧更新它们的行为,但即使是棋类游戏, 你可能也需要每帧更新动画。 这个设计模式也可以帮到你。
更新方法适应以下情况:
- 你的游戏有很多对象或系统需要同时运行。
- 每个对象的行为都与其他的大部分独立。
- 对象需要跟着时间进行模拟。
记住
这个模式很简单,所以没有太多值得发现的惊喜。当然,每行代码还是有利有弊。
将代码划分到一帧帧中会让它更复杂
当你比较前面两块代码时,第二块看上去更加复杂。 两者都只是让骷髅守卫来回移动,但与此同时,第二块代码将控制权交给了游戏循环的一帧帧中。
几乎 这个改变是游戏循环处理用户输入,渲染等几乎必须要注意的事项,所以第一个例子不大实用。 但是很有必要记住,将你的行为切片会增加很高的复杂性。
当离开每帧时,你需要存储状态,以备将来继续。
在第一个示例代码中,我们不需要用任何变量表明守卫在向左还是向右移动。 这显式的依赖于哪块代码正在运行。
我在这里说几乎是因为有时候鱼和熊掌可以兼得。 你可以直接为对象编码而不进行返回, 保持很多对象同时运行并与游戏循环保持协调。
当我们将其变为一次一帧的形式,我们需要创建patrollingLeft
变量来追踪行走的方向。 当从代码中返回时,就丢失了行走的方向,所以为了下帧继续,我们需要显式存储足够的信息。
你需要的是允许你同时拥有多个“线程”执行的系统。 如果对象的代码可以在执行中暂停和继续,而不是总得返回, 你可以用更加命令式的方式编码。
状态模式通常可以在这里帮忙。 状态机在游戏中频繁出现的部分原因是(就像名字暗示的),它能在你离开时为你存储各种你需要的状态。
真实的线程太过重量级而不能这么做, 但如果你的语言支持轻量协同架构比如generators,coroutines或者fibers,那你也许可以使用它们。
对象逐帧模拟,但并非真的同步
在这个模式中,游戏遍历对象集合,更新每一个对象。 在update()
调用中,大多数对象都能够接触到游戏世界的其他部分, 包括现在正在更新的其他对象。这就意味着你更新对象的顺序至关重要。
如果对象更新列表中,A在B之前,当A更新时,它会看到B之前的状态。 但是当B更新时,由于A已经在这帧更新了,它会看见A的新状态。 哪怕按照玩家的视角,所有对象都是同时运转的,游戏的核心还是回合制的。 只是完整的“回合”只有一帧那么长。
如果,由于某些原因,你决定不让游戏按这样的顺序更新,你需要双缓冲模式。 那么AB更新的顺序就没有关系了,因为双方都会看对方之前那帧的状态。
当关注游戏逻辑时,这通常是件好事。 同时更新所有对象将把你带到一些不愉快的语义角落。 想象如果国际象棋中,黑白双方同时移动会发生什么。 双方都试图同时往同一个空格子中放置棋子。这怎么解决?
序列更新解决了这点——每次更新都让游戏世界从一个合法状态增量更新到下一个,不会出现引发歧义而需要协调的部分。
在更新时修改对象列表需小心
这对在线游戏也有用,因为你有了可以在网上发送的行动指令序列。
当你使用这个模式时,很多游戏行为在更新方法中纠缠在一起。 这些行为通常包括增加和删除可更新对象。
举个例子,假设骷髅守卫被杀死时掉落物品。 使用新对象,你通常可以将其增加到列表尾部,而不引起任何问题。 你会继续遍历这张链表,最终找到新的那个,然后也更新了它。
但这确实表明新对象在它产生的那帧就有机会活动,甚至有可能在玩家看到它之前。 如果你不想发生那种情况,简单的修复方法就是在游戏循环中缓存列表对象的数目,然后只更新那么多数目的对象就停止:
int numObjectsThisTurn = numObjects_; |
这里,objects_
是可更新游戏对象的数组,而numObjects_
是数组的长度。 当添加新对象时,这个数组长度变量就增加。 在循环的一开始,我们在numObjectsThisTurn
中存储数组的长度, 这样这帧的遍历循环会停在新添加的对象之前。
一个更麻烦的问题是在遍历时移除对象。 你击败了邪恶的野兽,现在它需要被移出对象列表。 如果它正好位于你当前更新对象之前,你会意外地跳过一个对象:
for (int i = 0; i < numObjects_; i++) |
这个简单的循环通过增加索引值来遍历每个对象。 下图的左侧展示了在我们更新英雄时,数组看上去是什么样的:
我们在更新她时,索引值i
是1。 邪恶野兽被她杀了,因此需要从数组移除。 英雄移到了位置0,倒霉的乡下人移到了位置1。 在更新英雄之后,i
增加到了2。 就像你在右图看到的,倒霉的乡下人被跳过了,没有更新。
一种解决方案是小心地移除对象,任何对象被移除时,更新索引。 另一种是在遍历完列表后再移除对象。 将对象标为“死亡”,但是把它放在那里。 在更新时跳过任何死亡的对象。然后,在完成遍历后,遍历列表并删除尸体。
一种简单的解决方案是在更新时从后往前遍历列表。 这种方式只会移动已经被更新的对象。
如果在更新循环中有多个线程处理对象, 那么你可能更喜欢推迟任何修改,避免更新时同步线程的开销。
示例代码
这个模式太直观了,代码几乎只是在重复说明要点。 这不意味着这个模式没有用。它因为简单而有用:这是一个无需装饰的干净解决方案。
但是为了让事情更具体些,让我们看看一个基础的实现。 我们会从代表骷髅和雕像的Entity
类开始:
class Entity |
我在这里只呈现了我们后面所需东西的最小集合。 可以推断在真实代码中,会有很多图形和物理这样的其他东西。 上面这部分代码最重要的部分是它有抽象的update()
方法。
游戏管理实体的集合。在我们的示例中,我会把它放在一个代表游戏世界的类中。
class World |
在真实的世界程序中,你可能真的要使用集合类,我在这里使用数组来保持简单
现在,万事俱备,游戏通过每帧更新每个实体来实现模式:
void World::gameLoop() |
子类化实体?!
有很多读者刚刚起了鸡皮疙瘩,因为我在Entity
主类中使用继承来定义不同的行为。 如果你在这里还没有看出问题,我会提供一些线索。
当游戏业界从6502汇编代码和VBLANKs转向面向对象的语言时, 开发者陷入了对软件架构的狂热之中。 其中之一就是使用继承。他们建立了遮天蔽日的高耸的拜占庭式对象层次。
最终证明这是个糟点子,没人可以不拆解它们来管理庞杂的对象层次。 哪怕在1994年的GoF都知道这点,并写道:
多用“对象组合”,而非“类继承”。
只在你我间聊聊,我认为这已经是一朝被蛇咬十年怕井绳了。 我通常避免使用它,但教条地不用和教条地使用一样糟。 你可以适度使用,不必完全禁用。
当游戏业界都明白了这一点,解决方案是使用组件模式。 使用它,update()
是实体的组件而不是在Entity
中。 这让你避开了为了定义和重用行为而创建实体所需的复杂类继承层次。相反,你只需混合和组装组件。
如果我真正在做游戏,我也许也会那么做。 但是这章不是关于组件的, 而是关于update()
方法,最简单,最少牵连其他部分的介绍方法, 就是把更新方法放在Entity
中然后创建一些子类。
定义实体
好了,回到任务中。 我们原先的动机是定义巡逻的骷髅守卫和释放闪电的魔法雕像。 让我们从我们的骷髅朋友开始吧。 为了定义它的巡逻行为,我们定义恰当地实现了update()
的新实体:
class Skeleton : public Entity |
如你所见,几乎就是从早先的游戏循环中剪切代码,然后粘贴到Skeleton
的update()
方法中。 唯一的小小不同是patrollingLeft_
被定义为字段而不是本地变量。 通过这种方式,它的值在update()
两次调用间保持不变。
让我们对雕像如法炮制:
class Statue : public Entity |
又一次,大部分改动是将代码从游戏循环中移动到类中,然后重命名一些东西。 但是,在这个例子中,我们真的让代码库变简单了。 先前讨厌的命令式代码中,存在存储每个雕像的帧计数器和开火的速率的分散的本地变量。
现在那些都被移动到了Statue
类中,你可以想创建多少就创建多少实例了, 每个实例都有它自己的小计时器。 这是这章背后的真实动机——现在为游戏世界增加新实体会更加简单, 因为每个实体都带来了它需要的全部东西。
这个模式让我们分离了游戏世界的构建和实现。 这同样能让我们灵活地使用分散的数据文件或关卡编辑器来构建游戏世界。
还有人关心UML吗?如果还有,那就是我们刚刚建的。
传递时间
这是模式的关键,但是我只对常用的部分进行了细化。 到目前为止,我们假设每次对update()
的调用都推动游戏世界前进一个固定的时间。
我更喜欢那样,但是很多游戏使用可变时间步长。 在那种情况下,每次游戏循环推进的时间长度或长或短, 具体取决于它需要多长时间处理和渲染前一帧。
游戏循环一章讨论了更多关于固定和可变时间步长的优劣。
这意味着每次update()
调用都需要知道虚拟的时钟转动了多少, 所以你经常可以看到传入消逝的时间。 举个例子,我们可以让骷髅卫士像这样处理变化的时间步长:
void Skeleton::update(double elapsed) |
现在,骷髅卫士移动的距离随着消逝时间的增长而增长。 也可以看出,处理变化时间步长需要的额外复杂度。 如果一次需要更新的时间步长过长,骷髅卫士也许就超过了其巡逻的范围,因此需要小心的处理。
设计决策
在这样简单的模式中,没有太多的调控之处,但是这里仍有两个你需要决策的地方:
更新方法在哪个类中?
最明显和最重要的决策就是决定将update()
放在哪个类中。
实体类中:
如果你已经有实体类了,这是最简单的选项, 因为这不会带来额外的类。如果你需要的实体种类不多,这也许可行,但是业界已经逐渐远离这种做法了。
当类的种类很多时,一有新行为就建
Entity
子类来实现是痛苦的。 当你最终发现你想要用单一继承的方法重用代码时,你就卡住了。组件类:
如果你已经使用了组件模式,你知道这个该怎么做。 这让每个组件独立更新它自己。 更新方法用了同样的方法解耦游戏中的实体,组件让你进一步解耦了单一实体中的各部分。 渲染,物理,AI都可以自顾自了。
委托类:
还可将类的部分行为委托给其他的对象。 状态模式可以这样做,你可以通过改变它委托的对象来改变它的行为。 类型对象模式也这样做了,这样你可以在同“种”实体间分享行为。
如果你使用了这些模式,将
update()
放在委托类中是很自然的。 在那种情况下,也许主类中仍有update()
方法,但是它不是虚方法,可以简单地委托给委托对象。就像这样:void Entity::update()
{
// 转发给状态对象
state_->update();
}这样做允许你改变委托对象来定义新行为。就像使用组件,这给了你无须定义全新的子类就能改变行为的灵活性。
如何处理隐藏对象?
游戏中的对象,不管什么原因,可能暂时无需更新。 它们可能是停用了,或者超出了屏幕,或者还没有解锁。 如果状态中的这种对象很多,每帧遍历它们却什么都不做是在浪费CPU循环。
一种方法是管理单独的“活动”对象集合,它存储真正需要更新的对象。 当一个对象停用时,从那个集合中移除它。当它启用时,再把它添加回来。 用这种方式,你只需要迭代那些真正需要更新的东西:
- 如果你使用单个包括了所有不活跃对象的集合:
- 浪费时间。对于不活跃对象,你要么检查一些“是否启用”的标识,要么调用一些啥都不做的方法。
检查对象启用与否然后跳过它,不但消耗了CPU循环,还报销了你的数据缓存。 CPU通过从RAM上读取数据到缓存上来优化读取。 这样做是基于刚刚读取内存之后的内存部分很可能等会儿也会被读取到这个假设。
当你跳过对象,你可能越过了缓存的尾部,强迫它从缓慢的主存中再取一块。
如果你使用单独的集合保存活动对象:
- 使用了额外的内存管理第二个集合。 当你需要所有实体时,通常又需要一个巨大的集合。在那种情况下,这集合是多余的。 在速度比内存要求更高的时候(通常如此),这取舍仍是值得的。
另一个权衡后的选择是使用两个集合,除了活动对象集合的另一个集合只包含不活跃实体而不是全部实体。
- 得保持集合同步。 当对象创建或完全销毁时(不是暂时停用),你得修改全部对象集合和活跃对象集合。
方法选择的度量标准是不活跃对象的可能数量。 数量越多,用分离的集合避免在核心游戏循环中用到它们就更有用。
参见
- 这个模式,以及游戏循环模式和组件模式,是构建游戏引擎核心的三位一体。
- 当你关注在每帧中更新实体或组件的缓存性能时,数据局部性模式可以让它跑到更快。
- Unity框架在多个类中使用了这个模式,包括
MonoBehaviour
。 - 微软的XNA平台在
Game
和GameComponent
类中使用了这个模式。 - Quintus,一个JavaScript游戏引擎在它的主
Sprite
类中使用了这个模式。