GAMES104课程笔记03-How to Build a Game World
这个系列是GAMES104-现代游戏引擎:从入门到实践(GAMES 104: Modern Game Engine-Theory and Practice)的同步课程笔记。本课程会介绍现代游戏引擎所涉及的系统架构、技术点以及引擎系统相关的知识。本节课主要介绍现代游戏引擎的对象设计和管理方法。
在上一节课中我们介绍了现代游戏引擎的分层架构,而在本节课中我们会开始构造整个游戏世界。
How to Describe the World?
Game Object
首先我们要考虑游戏世界是由哪些组件构成的。以《战地2042》为例,游戏中包含了大量的可互动对象包括坦克、无人机、火炮、士兵等。
除此之外,游戏中还包含了很多静态的对象比如说各种建筑物。
这些动态和静态的游戏对象都依附于游戏场景,一般来说场景包括天空、植被以及地形等。
在玩家看不到的地方还有一些其它类型的游戏对象,它们为整个玩法提供支持。
总结一下,游戏世界是由各种各样的游戏对象(game object, GO)组成的。
Components
那么如何描述一个游戏对象呢?最直观的方法是使用面向对象编程的思路把GO划分为属性和行为。
同时,不同GO之间的依赖关系还可以通过继承的方式来加以描述。
但在实践中人们发现GO之间的关系往往是非常复杂的,仅通过继承的方式无法完整地描述不同对象之间的关系。
因此在现代游戏引擎中一般是通过组件化(components)的方式来描述一个对象,这样每个GO都可以拆分成若干个相互独立的组件。
以无人机为例,我们可以把任意形式的无人机拆分成Transform、Motor、Model、AI等组件,然后单独实现每个组件的功能。
这样当我们想要取定义新的无人机类型时只需要替换相应的组件即可。
目前市面上常见的商业游戏引擎,包括Unity和虚幻等都使用了组件化的设计思想。我们在设计自己的游戏引擎时也应遵循这样的设计理念。
How to Make the World Alive?
Tick
接下来我们考虑如何让整个游戏世界动起来。回忆我们在上节课介绍过的tick机制,我们可以在每个时钟周期中分别对每个GO调用tick()函数,这种方式称为object-based tick。
另一种调用tick()函数的方式是以组件作为基本单位,每个时钟周期内我们依次tick不同GO的同一类组件。这种方式称为component-based tick。
component-based tick相对要反直觉一点,但却有着更高的性能。component-based tick相当于把系统的tick()函数分解成流水线,这样可以提高系统的并行性并重复利用缓存。
Events
在调用tick()函数时不同的GO需要进行通信以确定自身的行为。早期的游戏引擎通过硬编码的方式来编写所有可能的通信行为。
当然在现代游戏引擎中已经抛弃了这种过于低效的通信方式,而是使用事件(event)机制实现GO之间的交互。当某个GO需要和其他对象进行通信时,它会直接向相应的对象发送一个event,而接收到这个event的对象则会在下个时钟周期进行处理。这样我们无需考虑系统中每种GO可能的通信方式,通过event机制实现了GO之间的解耦。
在商业游戏引擎中就使用了event机制来实现GO之间的通信。
How to Manage Game Objects?
当场景中的GO数量不断增加时我们就需要考虑如何高效地管理它们。一般来说对于场景中的每个GO我们会为它赋予一个识别号UID以及位置坐标,这样当GO之间就可以根据空间位置进行交互。
最简单的管理方式是不管理,当GO需要进行交互时通过直接遍历的方式来查找对象。显然这种方式是过于低效的,随着GO数量的增加遍历的代价会按平方函数进行增长。稍微好一点的管理方式是把场景划分成一个均匀的网格,这样需要进行查询时只考虑相邻格子中的GO即可。但如果GO在网格上不是均匀分布,这种管理方式的效率仍然很低。
更合理的管理方法是使用带层级的网格,这种方式的本质是使用一个四叉树进行管理。这样进行查询时只需要对树进行遍历即可。
除了四叉树之外,现代游戏引擎进行场景管理时还会使用BVH、BSP、octree、scene graph等不同类型的数据结构。
本节课最后讨论了一下商业游戏引擎中event机制的实现。由于不同GO之间的依赖关系,直接使用event进行消息传递可能是不合理的。同时为了高效的利用多核CPU,在商业游戏引擎中会使用「邮局」的方式来代替GO之间的直接通信。「邮局」的意义在于可以保证系统的严格时序,从而确保整个系统是确定的(deterministic),不会由于多核并行产生不同的运行结果。