本期为GAMES104《现代游戏引擎:从入门到实践》视频公开课文字实录第9期。本课程由GAMES(图形学与混合现实研讨会)发起,游戏引擎技术专家王希携手游戏引擎一线开发者共同研发。
课程共计22个课时,将介绍现代游戏引擎所涉及的系统架构,技术点,引擎系统相关的知识。为配合学习实践,课程组在 GitHub 上开源了小引擎Piccolo,上线1个月即获得了2900+star, 累计下载量已超过20000+。
以下内容为公开课视频转文字版本,为阅读通顺,有删减
上节课我们讲到,现代游戏引擎为了追求效率,逐渐地会转向按照每个系统或者每一种组件进行Tick,就是要造个流水线进行批处理,这样效率会特别高。所以这样的架构,坦克也能发动了,飞机能飞了,但是这个世界,还缺了一点什么呢?也就是说坦克与飞机还有老兵是各顾各的表演,他们之间没有任何关系。
举个最简单的例子,比如说我现在跳上了辆坦克,然后我朝远处的敌人开了一炮,把远处的敌人击倒了。如果每个游戏对象都是自己独立的Tick,我这开的一炮,怎么样让敌方被我打伤呢?这里面其实就需要游戏对象之间是相互关联的。
这件事情最简单的做法是坦克开了一炮,当我开炮这个行为发生的时候,我的组件再去生成一个新的游戏对象,那个游戏对象叫什么?叫炮弹。
炮弹它每个Tick往前跑,它突然在某一个Tick的时候,它发现自己上一个Tick的位置和即将要跑的位置这条线好像和地面有一个交点,这说明炮弹要爆炸了。当然,炮弹光炸了没有用,得炸死旁边的敌人对不对?好,他就开始寻找了,在爆炸的逻辑里写到说查询一下周边的对象,如果他是人就降血量,如果是飞机就坠机,如果是坦克毁机,如果是石头就飞石乱蹿,对吧?
这样去写的逻辑我们叫做hard code,最早期的游戏引擎真就是这么写的。
但是随着游戏世界变得越来越复杂的时候,大家会发现hard code其实是不合理的。于是就有了现代游戏引擎最重要的一个机制 —— event机制或者事件机制。
举个例子,我们不要那么粗暴的直接“敲”别人的门,直接告诉别人你被我打了一下。而且更麻烦的是,我还需要知道我所有“邻居”的家,登门认识所有的人。那怎么办呢?你们给每个人家都放个邮箱,我只需要给方圆20米之内所有的对象写邮件。告诉每一个人不好意思,你需要扣一百点血,我把这个游戏邮件放在这个Tick,等到下一个Tick的时候,假设是一个健康的小兵,打开邮件,查到自己被扣了一百点血,而他一共只有七十点血,那这个小兵就只能倒在地上死掉了。
所以一个非常复杂的问题,通过一个简单的事件机制就会变得非常的清晰而且明确。这个在系统架构中叫做解耦合,相当于把各个游戏对象之间的通讯,统一的变成了事件机制。你只要发出一个事件给对应的游戏对象,让它来处理就可以了。这样的话,各个对象和对象内部的组件逻辑就解耦和了,非常的干净。
在现代游戏引擎里面,基本上所有的商业级引擎都是这样的机制。举个例子,比如Unity里面,你可以简单注册一个事件,然后发送事件,最后销毁事件。如果物体的核心组件收到了这个事件后,有一个回调函数会激活,然后做相应的处理。这一切只需要一个字符串符合就可以了。
大家可以看下图的UE也是这样做的(其实我个人觉得UE做的太复杂了),它就是用C++源码去处理比较复杂的反射机制。当你要注册这样的一个事件,当这个事件发生的时候,每个组件哪个函数被毁掉,基本道理都是大同小异的。
为什么UE会做的这么复杂呢?因为当你注册一个事件的时候,你们会发现有个蓝图,它增加了一种消息类型,比如你做了炸弹行为,当它撞到地面的时候,蓝图里面就会说,炸弹这个事件出去了。在你做的另外一个东西的时候,比如在满血的组件里面,蓝图里面就会加一条选项说如果收到了炸弹碰撞的消息该怎么去处理。
它的本质就是说让接口的消息不断扩展定义,其实大家做游戏引擎核心的就是要做一个可扩展的消息系统,让游戏开发者可以在我们的引擎之上不断的定制玩法和相关的各种各样的消息类型,然后他们可以定制各种各样自己想要的组件去对这些消息,让事件进行按照自己想要的逻辑的处理,这就是现在游戏引擎的核心的一个工作。
讲到这里基本上大家就知道怎么去做游戏了,但是先不要急,这里面还缺一个东西,在我们的游戏世界里面有那么多的游戏对象,在一个单机游戏里面,一般是几百个游戏对象,但是有很多游戏可能到几千甚至上万个,那游戏里发生的每一件事情该怎么去通知到每一个游戏对象呢。
回到刚才的这个例子里,游戏里面有那么多的大兵、飞机、坦克、大炮,我怎么去管理呢?这个时候就可以讲游戏里面两个比较基本的管理概念了,第一在我们的游戏实践中,我们每个游戏对象都会给它一个唯一的编号,叫全球唯一标识符Global Unique ID (GUID),这个对于游戏引擎非常重要,因为我们很多时候去标识定位这个物体的时候,需要一个“门牌号”。另外,每个物体它在空间上基本都是有位置的,我们需要知道它的具体位置,不再是NPC 12345了,而是说物体的具体经纬度的位置。这个时候就可以进行场景的管理。
最简单的管理是什么?最简单的管理就是不管理。假设那个炮弹发生爆炸的时候,给场景里所有的游戏对象全部发消息,或者查询每一个游戏对象的位置,然后判断它的位置是不是小于爆炸半径,再进一步做伤害,如果大家做一些小游戏的话,没有问题,这样完全能跑得动。
但是,当我们场景中有几千到上万个对象的时候,这就会是一场灾难,为什么呢?
这就是游戏引擎经常讲的N平方的挑战,什么意思?就是说每一个物体都可能和其他的物体发生互动,如果每一次我都要和其余的所有对象去问一遍,那就是nx(n-1)≈n²。如果我有1万个物体的话,n²是多少?是一个亿对不对?这样会对计算机造成巨大的负载,尤其如果这些数据分散在内存的各个地方的话,效率是非常低的。
那这个时候该如何处理呢?
最简单最直觉的做法的方法就是对世界画格子,分而治之法(Divide and conquer)。
我们把这个世界画成均匀的格子,如果场景需求不是特别大,那么通过简单的画格子并搜寻临近的格子就能找到目标的门牌号,这个方法不仅听上去很简单,而且在很多场景里面都是实用的,那么它唯一会出问题的是什么情况呢?是当场景分布不够均匀的时候。
以大家特别喜欢的3A大作主机游戏为例,虽然在地图上看是广阔的大地,但实际上作为玩家能走的地方非常受限制。以前我在国外做游戏研发的时候,把这种设计叫做Trench(打仗的时候挖的战壕),游戏设计师会给玩家挖了很多战壕,玩家只能在战壕里面走来走去,所以沿着战壕武器与物资会放得非常详细和密集,当然战壕之外是非常稀疏的。如果这个时候你把这么大的世界均匀的打格子的时候,同学们会发现又慢又浪费。
那这个时候一个非常简单的算法就来了,也就是用一个层级结构去管理整个场景。
其实它的思想非常简单,就像世界地图一样,当我们把世界分成国家,国家分成行省,行省分成城市,城市分成街区,区再分成街道,那么如果有任意一件事件发生,比如北京海淀区的某一条大街发生的事情,我只需要在北京海淀区里面去找就可以了。
在实际应用中,这样的层级管理方法是非常有效的,我们可以根据地图上的对象分布以空间的四叉树不断的进行划分。如果这个地方的人数足够少,只有一个到两个,那就不用再划分了。如果人数很多,我就不停的划分,这样就形成了一个树状结构。
同学们如果学过数据结构的话,就知道这是个典型的四叉树。当任何一个事件发生的时候,只要我知道在四叉树的哪个节点,当我去寻找周边的时候,只需要向上或者向下去寻找我的兄弟节点,或者我的父节点,或者是我的子节点。同学们不用钻得太深,因为实际上在游戏中的场景管理里有很多流派,比如说,最简单的二叉树的流派,我们把世界一刀一刀地切分掉,那把二叉树的划分往三维做得更精致一点,那么就变成了经典的八叉树,对吧?
八叉树是经典的对空间不断细分的方法。现在游戏引擎比较流行的是BVH,也就是我们常用的Bounding Box,每一个物体有一个小bonding box,我们把它从小往大慢慢的做一些计算,比如说最常见的视锥裁剪,用BVH快速的把很多东西全部扔掉,当我们打出一条子弹,一个弹道的时候,其实我们也需要用这样的技术去帮我们去快速定位,所以空间上的数据管理在BVH的计算中是场景管理的核心。
在不同的游戏产品中我们会采取不同的场景管理方式来解决,如果同学们做游戏引擎的时候想做超级玛丽类的游戏,可能就不需要这么复杂的场景管理了。但是如果同学们想做像Quake这样的游戏,那一定得花心思去仔细设计场景管理的方法,尽量节约计算资源。所以对于小明来讲,基本上用四叉树或者BVH能够完成他的需求。
到这里,我们课程的核心内容基本上就讲完了。总结一下就是在游戏中,所有的物体都是用组件的形式去描述,用一个个组件整合出不同的行为,那么所有的组件用Tick的方法不断得去Tick它内在的逻辑去往下一个时刻前去。
组件里面所有的游戏对象之间通讯是通过了一套复杂的消息机制来彼此进行通讯,在那么多物体需要管理的时候,一套非常高效的层次结构的场景管理机制就很有必要了,基本上听到这儿,我觉得如果动手能力比较强的同学,已经可以动手去构建一个自己想要的游戏引擎了。
但实际上的商业引擎其实很复杂。接下来开始跟大家讲一些比较高能的东西,举个例子,比如说在游戏中最常见的东西是什么呢?叫物体之间的绑定,比如大家都特别喜欢的神海游戏里面的飙车场景,当你开车的时候你是个游戏对象,车本身也是个游戏对象,但那一刻你和车的关系被锁定了,那你们两个之间该怎么在一起联动呢?
实际上这个时候我们的游戏对象去Tick时就有一个先后关系的问题,比如说我们一般会要求父节点先Tick,然后人被挂在车上所以后Tick。这样的话,当我自己被Tick的时候,我会去问,我的车在哪里?我的车已经往前移了30厘米。那好吧,我要往前移30厘米。
但是这样的Tick时序,当一个个组件去Tick的时候会变得相对复杂,但时序为什么这么重要呢?因为在实际游戏中,当我们去Tick一个组件的时候,很多时候是要分散到多CPU上去执行的,很多的Tick是并行执行的,所以并行执行的时序是非常重要的。
这件事情就引入了我们另外一个难题,就是我们的游戏对象之间的通讯是通过一个个事件传递的。事件就相当于我给你写完信,冲到你家门口,把邮件塞到你们家门口的邮箱。但是这件事情是一个逻辑悖论,这个悖论是什么呢?举个例子,在大学跟女朋友分手的时候,当你兴冲冲的写了一封分手信,打算投送到你女朋友寝室的时候,突然发现你的女朋友也急匆匆穿好衣服从寝室出来,手上拿着另外一封分手信,你那时候是不是马上在怀疑那到底是你甩了你女朋友还是女朋友甩了你?这件事情就讲不清楚了,对不对?其实在游戏世界里面,如果让游戏对象直接彼此写信的话,会产生很多逻辑上的混乱性,这个混乱性在很多时候你会注意不到。但实际上的话,对游戏的影响是非常大的。
因为在游戏中,我们希望当用户是一模一样的输入的时候,游戏世界里发生的行为它是确定的、一致的,不会变的,我们叫做Deterministic,确定性。举个例子,比如说同学们很喜欢玩这种对战游戏,对不对?当你打完了对战游戏之后,你最喜欢看的东西是什么?是精彩回放,而精彩回放本身就是引擎提供的一种核心的能力。
那一般的精彩回放是怎么做的呢?它并不是把刚才整个游戏过程全部都录了。那个录像的文件就会非常大,它是存不下来的,其实它是录了每一个小伙伴的输入,然后把这个游戏又重新跑了一遍,但是重新跑了一遍后,因为都是一样的输入,那肯定跑出一样的结果。反正假设这个顺序是你负责先去送信,接下来才是你的女朋友,那一定是你女朋友先收到这封信。所以可以确保是你先甩了她。但是如果这个时候你们是并行的,就叫做多线程的运行,它的结果就会完全不一样。大家能理解这里面的这个复杂度吗?这个时候我们就要引入“邮局”,这是个关键的第三方了。
比如你要给你女朋友写信,你先把信寄到邮局去,然后邮局在统一时间把信发到每一个人,每个人第二天能收到自己的信。然后你第二天收到这个信后再决定下一步的工作,比如你女朋友看到你的这封分手信很生气,打算写一封信来骂你,她也只能当天晚上写完然后寄到邮局,第二天早上你才能收到。这样的话,我们就能确保它的时序是严格一致的,所以在游戏引擎中真实的消息传递比大家想象的要复杂的多.
刚才我讲的Tick,是最简单的一种Tick,实际上在所有的Tick实现中还有更复杂的,比如Pre Tick和Post Tick这两个函数,它就是为了解决各种各样的持续性的问题,这种持续性的问题在基于组件的游戏世界构建中非常重要。
再举一个例子我们这些组件其实是有依赖关系的,比如说我要先动一下,我的发动机就会告诉我说现在的移动速度是50cm/s,状态从走路变成了跑步,那这个时候我Tick我的动画。动画就会说好的,然后从走路动画变成了跑步动画。接下来动画又会触发物理,当我的脚迈出去了之后,撞到某个东西后,物理碰撞就被触发了对吧?当我的状态发生变化的时候会影响到这些系统,而这些系统又会反过来影响到我的位置。
所以在真实的游戏引擎开发的时候,会经常遇到它们彼此之间好像有那么一点点循环依赖。大家看到很多游戏如果写得不够好的时候,会经常发现它有大概一帧到两帧的延迟,这种延迟很多时候就是因为时序问题导致的。
今天为什么我们要花大量篇幅讲时序性的问题呢?因为当大家理解了什么叫做组件,什么叫做Tick,什么叫事件机制的时候,当每一个组件它去Tick,发消息,它的对象是在哪一帧,是它当前帧还是下一帧收到消息,收到消息之后是当场做动作,还是在等着做动作,这对游戏的核心逻辑影响是非常非常大的。
我希望同学们,当大家热血沸腾得开始做属于自己的引擎的时候,一定要注意时序性问题。因为这个是游戏引擎最精妙的地方。
下节课我们将开始分享渲染相关知识,敬请期待。
本文编辑:Piccolo 社区编委会 张嘉瑶
如对本节课有任何问题,欢迎加入我们的社群或给我们发送邮件:
Piccolo社区是中国开源游戏引擎分享、学习的非营利性平台,由游戏引擎行业大佬、共创官、学习者共同建立。你可以在我们的社区里交流技术、互助问答、参加活动,你也可以参与Piccolo 的共建,如撰写贡献代码、撰写技术文章、参与技术挑战等。
由中国游戏引擎社区Piccolo开源的一款Mini游戏引擎。采用世界-关卡-游戏对象-组件的简洁架构,便于理解游戏引擎架构思想,它不仅能有效的帮助开发者学习游戏引擎架构知识,也能帮助一线开发者实验引擎算法与第三方库、辅助个人项目快速启动。截止目前,Github点赞已突破3600+,累计下载量已超过20000+
Piccolo GitHub地址:https://github.com/BoomingTech/Piccolo/discussions
GAMES104课程官网
GAMES104课程视频