课前的话
今天我们要开的这节课,我把它取名叫做 GamePlay Programming Exprience. 今天是第一节. 这是校科协游戏组的大课, 在这套课上, 我不打算专注讲 Unity, UE 或者 CoCos 等游戏引擎的使用, 也不打算精细的教学 C# 或者 C++ 的具体语法, 为什么呢? 原因就在上一句, 游戏引擎太多, 我们授课没有办法面面俱到, 程序语言已经有各个组负责了, 他们会对语法进行详细的教学. 在这套课上, 我想和大家成系统的聊聊游戏编程的算法和游戏设计的心得, 正如其名, 这套课是为了 GamePlay 程序员准备的, 我们不会花很大篇幅描述物理模拟, 图形渲染, 而是作为将他作为 GamePlay 程序员(或许还有技术策划,真是新颖的方向)的入门基础课, 游戏运行的基本逻辑, 良好的输入交互, 游戏功能的实现算法(AI…A*啦,FSM啦,感知,群居…),游戏数学, 2D和3D渲染基础, 物理模拟计算, 摄像机控制, UI设计, 脚本语言(Lua, javaScript…),最后还会聊点网络编程.
看起来饼画得很大,乐, 看起来有点多, 我们计划讲两个学期,这是作为游戏组的大课, 我们在课上会尽量使用 Lua 风格的伪代码, 或许还会夹杂了C# 和 Java, 当然考虑到学校课程的设计, 同时也会给出一份 C/C++ 的代码以供参考. 本课程如果遇到示例, 将会使用 Unity 游戏引擎展示,若有不便之处,还请见谅.
在每节课结束之后,我们会布置一些小题目作为课后的作业,当然这是选做的.届时我会在QQ群里面发布作业,大家感兴趣去做一下就好. 在布置一周后我会在B站上公布解析视频.
如果大家对 Unity 和 C# 特别感兴趣, 或者觉得我大课上讲的太简单,可以来找我听小课,小课基本上会提前一天在群里通知时间和地点,大家可以关注华夜工作室和校科协游戏组群. 我还有一个小课授课前瞻群,群里会投票决定下节小课讲什么, 程序设计能力很强的同学可以向我提交作品入群.
当然,大课和小课都会把录播放出来,所以上面的要求也都不是强制的.
好,我们开始今天的课程吧
游戏循环 loop
游戏循环是游戏的核心控制流程,它是游戏的主线程,它负责游戏的运行,它是游戏的心脏。
在这个循环里,程序不断地去处理玩家的输入,更新游戏世界,然后生成输出(生成到屏幕上的我们会叫作渲染,游戏手柄等的震动也算是生成输出)。
这是非常经典而传统的游戏循环,你可以在任何游戏中见到他。想想看你再玩马里奥的时候,当你当你按下方向键,马里奥就会向你想要的方向移动,当你踩到龟壳上面的时候,龟壳会飞出去,或许你的手柄也会震动一下。
每次迭代游戏循环称为一帧,大部分游戏的帧率在 30-60 帧之间,也就是说,游戏循环每秒会执行 30-60 次。
1 | while game is running: |
当然,循环的每一步都没有他们本身看上去的那么简单,每一步都有许多细节不容忽视。
process inputs
就像 process inputs,他意味着在循环的开始,你需要检查各种各样的设备的输入,键鼠,手柄,麦克风甚至还有触摸屏,陀螺仪,摄像机,GPS。有些是由你游戏中某些功能或者特色决定的,而另一些则来自玩家方便或者高品质游玩的需求。
他们的输入的类型也是多样的。如果你接触过 Unity 的 InputSystem, 那么或许你会更好的理解这一点。我们键盘的WASD,又或者手柄的遥感,他们都是Vector2的输入,而键盘,手柄,鼠标乃至触摸屏的点击,都是 Trigger 或者 Boolean 类型的, 这取决与他们是 Down, Up, Click 又或者是长按,双击。再打个比方,陀螺仪的输入可能是 Vector3的。输入系统的设备和输入的类型都是纷繁多样的,任何外部的输入都要在这一阶段被转换成计算机可以理解的数据结构。
update game world
结束玩家的输入后,就来到或许是大家最关心的一部分了,更新游戏世界。是的,谁不想通过代码构筑一个属于自己的美妙世界呢,或许每个程序员写下第一行 Hello World 的时候,都会自豪于即将成为一个小小代码世界的上帝。然而,一个游戏世界的构筑显然不会像 Hello World 那么简单, 他需要构筑世界的实例
和法则
,这听来有点像炼金术师又或者魔法师,当然,也许游戏程序员在某种程度上就是在没有魔力的世界里施法的人。好了让我们回到构筑世界的具体方法上来,我们通常把构筑世界的实例称为Game Object
, 他们是构成游戏世界的最基本的物质元素和法则的载体。这个我们后半节课会详细讲解。而法则
也不难理解,那是世界运转的基本原理,或许他的本质是计算,而在宏观上的体现可以是物理模拟,游戏玩法,甚至是游戏的时间与空间。想象一下吧,在一款玩家可以肆意控制重力的世界,那里的法则就是重力会不定期改变
,又或者是雨世界
中精彩的生态系统,Minecraft
里的方块世界和史蒂夫能锤爆钻石的手,游戏的法则
是塑造你的游戏世界的基准线,而你将用代码去实现他。
上面讲了很多,但是简单概括一下就是一句
更新所有已激活且需要更新的对象
其中,更新
就是法则
, 对象
就是实例
generate outputs
在很多情境中,生成输出是最耗费计算量的一步。想想看最常见的输出是什么?…
2D和3D的渲染,最简单的3D渲染在计算三角形顶点和颜色的时候计算量都是一个天文数字。(这里讲的比较模糊,这节课有空就细讲,没空就下一节课讲)。当然,输出也不知图形渲染,音视频,手柄的力回馈,甚至是网络数据的发送,都是输出的一部分。
讲了这么多,或许我可以给出一个小游戏(Flappy Bird)的例子,让大家更好的理解游戏循环。请看伪代码
1 | while Bird is alive: |
多线程下的游戏循环
不同于早期游戏开发紧巴巴的硬件条件,2005年左右,多核处理器(CPU)开始普及,多线程编程也开始流行起来。在多线程的环境下,游戏循环的结构会有所不同,我们会把游戏循环分为两个线程,一个是游戏逻辑线程,另一个是渲染线程。游戏逻辑线程负责处理玩家的输入,更新游戏世界,而渲染线程则负责把游戏世界渲染到屏幕上。这样做的好处是,游戏逻辑线程和渲染线程可以并行执行,这样就可以充分利用多核处理器的优势,提高游戏的性能。
请你设想,在早期的单核处理器上,程序需要先花费 20ms 在逻辑运算上用来更新游戏世界,而后还需要花费 30ms 在渲染上,这样一来,游戏的帧率就只有 20FPS了。20帧的游戏,相信对于绝大多数玩家来说都是不可接受的,所以当年的游戏程序员绞劲脑汁开发出了大量极为优秀的优化算法————这点我们暂且不展开说,来提升游戏的帧率。然而,如果我们再新建一条渲染线程用来处理图形计算,那么游戏逻辑和图形渲染就可以并行执行,最终的帧率是 33.3FPS,卓越的提升!
没错,现代的游戏引擎也是这么做的,无论是 Unity 还是 UE
下面简单展示一下 Unity 的多线程渲染的基本逻辑
CPU 计算出什么需要渲染 -> 渲染命令从主线程传给渲染线程 -> 渲染线程生成渲染指令并提交给图形驱动 -> GPU 执行渲染指令 -> 显示器显示
很有意思,对吧?
当然,这里似乎有些问题
同学们,我们设想一下,主线程消耗20ms,渲染线程消耗30ms,如果主线程不等待渲染线程,那么到每3帧主线程就会领先渲染线程整整1帧,而为了追赶上主线程,渲染线程就要每4帧丢弃一帧(只保留1,2,3帧)的渲染,这会导致什么呢?
相信很多同学已经能报出答案了,画面卡顿。
那么看起来我们只能让主线程等待渲染线程了吗?
难道没有其他更好的办法了吗?
有。我们有一个经典的解决办法:让渲染进程的执行比主线程慢一帧。
但是这种方法是有代价的,玩家的输入需要更久才能体现到画面上,这对 FPS,格斗,竞速等品类的即时竞技游戏来说是致命的。
当然,现在的多线程渲染方案也提出了不少新法门。比如 Unity 的帧同步队列
技术。
我不太清楚同学们对数据结构有多少了解,所以我这里只做简单的解释。
前面我们多线程渲染的基本逻辑可以简化成这个样子:
主线程 -> 渲染线程 -> GPU -> 显示器
主线程不断向GPU下达渲染指令,显然可以类比为生产者,而GPU不断读取着渲染指令,自然是链中的消费者。渲染指令会抵达一个循环队列(RingBuffer).
简单介绍一下循环队列的工作方式:
循环队列的实现,实际上是靠 Head 指针和 Tail 指针的原子操作来实现的。也就是说,生产者线程写入循环队列时,会原子地去后移 Tail 指针;消费者线程读取循环队列时,会原子地去后移 Head 指针。当 Tail 指针再次超过 Head 指针,则说明队列满了,则需要阻塞生产者线程。
- RingBuffer 在写满的时候会阻塞主线程,等待渲染线程。
- RingBuffer 在读空的时候会阻塞渲染线程,等待主线程。
多线程渲染的基础知识太多了,我这里只是简单的介绍一下,如果同学们对这方面感兴趣,可以自行搜索资料或者线下找我沟通。
视窗消息泵 Windows message pump
值得注意的是,在 Windows 平台中,本身逻辑的循环等,游戏还要处理来自 Windows 系统的指令。处理这段来自 Windows 指令的代码就被叫做 message pump
。message pump
从 Windows 消息队列中取出消息,然后分发给游戏程序的窗口。基本的逻辑是:
先处理来自 Windows 的消息,然后在处理引擎(如果你的游戏并不依托某个引擎也是这样)的任务
下面给出一段经典的消息泵的代码:
1 | while (true) |
这带来了一个有趣的现象,当你移动(或者其他什么操作)你游戏的窗体的时候,游戏会卡住不动。比如 Slay the Spire
这款游戏是用 Java
语言,libGDX
框架开发的,不过在 犹格索托斯的庭院
上的实验没有起效,或许这说明了 Unity 并没有默认内置/启用这一段代码,需要开发者自行实现/开启。
对这点我还没有深入研究,希望大家搞明白后可以来和我分享。
Windows API 解释
PeekMessage
: 从消息队列中取出消息,但是不会阻塞- TranslateMessage: 将消息转换为字符消息
- DispatchMessage: 将消息分发给窗口过程
- PM_REMOVE: 从消息队列中移除消息
- WM_QUIT: 指示终止应用程序
回调驱动框架 Callback-driven framework
大多数游戏引擎的子系统或者第三方游戏中间套件都是以library
的方式构成的。请注意,这里要和 Unity Project 文件夹中的 Library
文件夹区分开,前者是指一组可被程序员调用的函数和类,后者是指项目的缓存文件夹。
而另外一些游戏引擎或是中间套件,则是基于 framework
的。或许你可以把框架视为一套半完成的应用程序,你需要也仅能在框架的留白下自定义你的代码或者覆写代码预设的某些行为。但在控制流中,你的程序并不能取得主导地位,大部分流的控制权限被框架牢牢的攥在手中,你只能取得少量的控制。当然,对开源引擎如“Godot”,或者代码可访问的引擎如“Unreal Engine”来说,你当然可以通过自己修改引擎的框架来达成自己自定义程序流程的目的,不过一来这种方法相对危险且对程序员的水平要求很高,并且对“Unity”一类的引擎来说是做不到的。
简单的来说,library
是你调用他,而 framework
是他调用你。
具体的说,library
仅仅是提供了一些库函数供你调用,其灵活性强,侵入性低,不主导控制流,往往针对某个特殊的功能提供解决状态;而 framework
灵活性较差,侵入性强,主导控制流,能够为游戏开发提供一整套的解决方案。
在基于 framework
的游戏引擎中,主游戏循环已经为我们准备好了,他们的调度有一套严谨的顺序,只不过在我们编写回调方法 callback function
去覆写他们之前,他们什么也做不了。
下面给出简单的回调驱动的伪代码
1 | while (true) |
虽然只是一段可以说是简陋的示例,但大家有没有觉得很眼熟?没错,这就是 Unity 的 MonoBehaviour
的生命周期。Unity 有着相当丰富的声明周期函数,他们的本质是事件函数(event functions
), 或者说回调(callback
)。这是一个非常经典的回调驱动的框架。
时间和游戏
现在大多数游戏都会有时间进程(progression of time)的概念。无论是 minecraft 中的日夜交替,还是 moba 游戏中对局时长,甚至是回合制游戏中的回合数,都依赖着时间进展的管理。这是从 gameplay 的角度来说的。在游戏实现的技术层面,或者无论是游戏循环还是渲染循环,也都离不开游戏时间的处理。
真实时间
真实事件顾名思义,就是真实世界流逝的时间,然而他的计算并不容易。显然我们不能直接调用C语言的标准库函数time()
来获取真实时间,这个函数返回的是从1970年1月1日0时0分0秒到现在的秒数,这个数值是一个相当大的整数,而我们需要的是一个足够小的浮点数。毕竟,考虑到游戏中每帧仅耗时数十毫秒,这样的测量分辨率着实太粗糙了。
所以,我们该如何解决这个问题呢?(下面的 CPU 版本都以 Pentium 为例)
答案是使用 CPU 的高分辨率计时器来测量真实时间。这种计时器通常会实现为硬件寄存器,其它以64位无符号整型数的格式,记录了自CPU上电以来所经过的时钟周期数。Intel Pentium 以来所有 x86 处理器上都存在一个叫做 “Time Stamp Counter” 的 64 位寄存器。没错,他们就是上文所说的计时器和硬件寄存器。
在 3GHz 的 Pentium 上,其 Time Stamp Counter 每周期递增一次,也就是说每秒 30 亿次。分辨率是其每秒递增次数的倒数,即 。
在 Pentium 及其以上的 CPU 中,提供了一条机器指令RDTSC(Read Time Stamp Counter)来读取这个时间戳的数字,并将其保存在EDX:EAX寄存器对中。不过C++语言本身是不直接支持汇编指令的,所以我们需要使用内联汇编来调用这条指令。
1 | uint64_t get_tsc() |
当然,我们也可以用_emit伪指令直接嵌入该指令的机器码形式0X0F、0X31
1 | inline unsigned __int64 GetCycleCount() |
如果你使用 MSVC, 你也可以这么写
1 |
|
多核CPU或者“休眠”操作系统或乱序执行等带来的不精确的时间测量
CPU 再也不是从前“单打独斗”的时代了,现在的 CPU 一般都是多核的,而且还有超线程技术,这就意味着,我们不能再简单的使用 Time Stamp Counter 来测量时间了。这超级好理解,假如你的CPU时四核的,他们开始工作的事件都不一样,你该听谁的?
不光如此,现在的操作系统都有节能模式,休眠模式,这些都会导致 CPU 的时钟频率发生变化,时钟频率变化,Time Stamp Counter 的递增速度也会变化,我们测量的结果自然也不准了。
再比如,现代的 CPU 为了提高性能,会对指令进行乱序执行,这也就意味着,我们测量的时间可能是乱序执行的时间,而不是我们想要的真实时间。
当然,后来的 CPU 都提供了一个新的指令,叫做 RDTSCP
,这个指令会在读取时间戳的同时,读取一个标志位,这个标志位会在所有指令执行完毕后才会被置位,这样我们就可以保证测量的时间是正确的了。不过他的开销显然更大,耗时差不多是RDTSC
的两倍。
新版本的 CPU 也对上述的三个问题打了不同程度的补丁,这里也不详细展开了。
当然,在 Windows 平台上,也可以使用更加傻瓜式的方法来测量时间,比如调用 Windows API QueryPerformanceCounter 和 QueryPerformanceFrequency。不过为了跨平台和可拓展性,游戏引擎中是否要使用 QueryPerformanceCounter 和 QueryPerformanceFrequency 还有待商榷。
游戏时间
在大多数情况下,游戏时间和真实事件时一致的,但这并不绝对,因为无论是从技术角度还是设计层面上考虑,游戏时间都是一个相对独立的概念。
如果我们提出一个时间流速的概念,也许会更便于大家理解。或者我们把它叫做时间缩放,这无所谓,只是个名字。
时间缩放
好了,让我们来分类讨论。
当时间缩放为 1 的时候,游戏时间和真实时间是一致的,这是最常见的情况。在游戏 60 Seconds
中,游戏说好给你60s准备应对危机,他就的确只给了你60s,1s不多1s不少。
当时间缩放为 0 的时候,游戏时间就停止了,几乎所有游戏都会设置一个暂停功能。
当时间缩放大于 0 而小于 1 的时候,帅气的 子弹时间
就出现了。除了玩家,几乎所有对象的时间都被减速了,又或者虽然玩家的时间同样被锁死了流速,但是他却凭借高超的技艺在危机前化险为夷或者大杀四方。在塞尔达传说系列游戏中,这种效果也被称为林克时间
。
时间缩放大于 1 的情况也并不罕见,不仅在回合制游戏中有加速战斗的功能,各类即时游戏也同样有出自不同角度考虑设计的加速功能。比如在 Minecraft
中,玩家可以通过睡觉来跳过夜晚,维多利亚3
中的游戏流速也有多档位的设计。
最有趣的是时间缩放也可以为负数,理所当然的,这种情况通常表现为游戏世界的时间倒流。大名鼎鼎的 时空幻境 (Braid)
和 波斯王子:时之沙
在这方面的设计可谓神乎其技。
增量时间与固定时间
前面我们说过,游戏的帧率是指以多快的时间向玩家展示一连串帧。帧率的单位是赫兹 Hz, 即每秒的执行次数,当然也可以用每秒帧数 FPS 来表示游戏的帧率。在传统上,电影的帧率是 24FPS,而游戏的帧率则是 30FPS 或者 60FPS。这通常和电子游戏流行开来时当地硬件设备(如彩色电视等)的刷新频率息息相关。
两帧之间的时间被称为 帧时间 frame time
,当然,还要一个更加通用的的称呼 增量时间 deltatime
, 在数学上写作 。
早期的游戏不曾注意到增量时间的重要性,其经常依赖处理器的速度。毕竟我们每秒执行游戏循环的次数时固定的,即游戏的帧率。可是请大家试想,曾经在 8MHz 的 CPU 上运行的游戏迁移到由 16MHz 的 CPU 负责计算的游戏机上会发生什么事呢?
没错!每秒游戏循环的次数翻了倍,从而角色和敌人的移动速度也翻了倍,大家可以想象开了二倍速的马里奥会有多抽象。这还只是帧率翻了倍,要是把该游戏迁移到频率比过去的处理器高上百倍的机器上运行呢?
咦?这么严重的问题当初的程序员没有意识到吗?欸,我们在课堂上想一想都能考虑到的问题,实际开发游戏的工程师们会考虑不到吗?
如果你们有听过我第一节的导论课,那答案应该就能脱口而出了:当初的游戏是和游戏机绑定的,一台游戏机自带多少游戏就只能玩多少游戏,所以当时开发游戏的工作人员根本不会考虑到这一点,毕竟他们开发的游戏就根本不存在换一台机器运行的可能性,同时,在当年硬件性能捉襟见肘的年代,多节省点计算也是求之不得的。
当然,随着后来游戏产业的发展,大家也意识到了这个问题的严重性。B站上某UP主在使用模拟器测评上古老游戏的适合也曾经遇到过这个问题,游戏速度被加倍了好几十倍。不过也不是说现在的游戏就没有这个问题了,大家打开 TapTap 搜索 几何决斗
,看看他的评论区,就能理解时间增量的正确应用是多么重要。他的问题看起来像是垂直同步 v-sync
和不正确的时间增量的使用共同导致的结果。当然,几何决斗只是一款连社区维护都谈不上的开源项目,但是大型游戏公司维护的 AAA 级或者说大型项目就一定能避免这个问题吗?荒野大镖客
和 原神
也都被反馈过存在高帧率游玩时会导致体力值消耗加快或者人物动作加快的 Bug。
基于增量时间优化的游戏循环
现在几乎所有的游戏引擎都提供了 Time 类的增量时间的 API。Gameplay 程序员可以轻松的调用他们,并且在合适的地方调用他们。
1 | enemy.position.x += 150 * deltaTime |
现在,无论帧率如何这段代码都能正确的工作了。在 30FPS 时,敌人会每帧移动 5 个像素;而在 60FPS 时,敌人会每帧移动 2.5 个像素,每秒都是 150 个像素,只不过在高帧率的设备上,角色的移动会更加的平滑。
很好,这样的方法已经极大程度的让游戏和 CPU 速度脱勾了,但是,我们要怎么实现呢?换句话说,我们怎么取得 呢?这听起来似乎很简单,在一帧开始的时候取一次 RDTSC
, 在结束的时候再取一次,然后取两者之差,再减去在 TSC 上取值所花的时间就能精准度量上一帧的 Δt
了。拿到上一帧的时间增量之后,自然就能使用其来预测当前帧的时间增量了。
1 | var time = GetTime() |
很多游戏引擎都是采用上面的办法,但是该方法存在一个不可忽视的缺陷:我们用上一帧的时间增量预测的下一帧时间并不准确。某些帧可能会由于某些原因(程序的集中处理或者玩家的异常操作)而消耗过长/少的时间,我们称这个现象为 帧率尖峰 frame-rate spike
.
比如在物理模拟中,某帧消耗了过长的时间,那么在下一帧我们就要 步进
两次来遮掩上一帧,这也会导致下一帧变得同样很慢,由此往复只会导致低帧问题越来越严重。
不过,由于游戏循环中帧与帧之间存在的时空相关性,如果我们将多个帧的时间增量的平均值作为下一帧时间增量的指导,那么也能大大缓解帧率尖峰带来的问题。
固定时间
所以,在一些帧率敏感的模块中,我们往往更倾向于向目标帧率靠近,比如我们要达到 33 帧的效果,那么如果本帧在预测的事件前就完成了任务,就让先线程去做别的些什么什么,反之只好等待下一个目标时间。该方法被称为帧率调控 frame-rate governing
。
当然,只有游戏的平均帧率靠近该目标帧率的时候该方法才能尽可能的多发挥出效能。
然而,维持帧率的稳定对游戏各个子系统的意义都是非凡的。在引擎物理模拟的数学积分,以固定的时间更新效果最佳,而且物理效果也会变得更稳定流畅。包括屏幕刷新率和游戏帧率的不匹配,也会导致画面撕裂 tearing
. 甚至是游戏的录播回访,也和游戏稳住是否稳定息息相关。周所周知,游戏的回访并非真的是屏幕的录制,只不过他们存储了游戏时间内的事件和时间戳,在打开回访的时候依次输出而已。有个很有意思的例子就是 王者荣耀
的回放,其实就是开了一局新的本地游戏。
全局时间和局部时间
典型的如动画的时间是游离于整体时间轴之上的,我们可以在编辑面板单独调控动画的时间流速甚至他们的开始和结束。
游戏对象
游戏对象的类型
- 需要渲染不需要处理逻辑
背景,不可交互的物品等 - 既需要渲染也需要处理逻辑
玩家,敌人,子弹等 - 不需要渲染但需要处理逻辑
摄像机,空气墙
游戏“对象”一定是面向对象吗?
早期的游戏引擎和 Gameplay 会使用OOP,但是在复杂的游戏世界中并没有非常清晰的父子继承关系。eg. 水陆两栖单位。
现在更多地使用 Component 的方式,将游戏对象的行为分解为多个组件,每个组件只负责一种行为,并且 Component-Base 的模式也非常利于非程序员理解,因为他就像乐高积木一样的 PnP。
游戏对象的生命周期
在某些早期的游戏引擎中,和上面我们展示的foreach activeObject in activeObjects
一样,是遍历所有的 GO,但是在现代的游戏引擎中,Component 则被更多的采用了。理由是将更多相同的模块放在一起可以最大程度的提高运行效率,也方便采用 Pipeline 的方式来运作。
参考文章
1.Unity 2020.2 优化了 Time.deltaTime,以实现更流畅的游戏体验
2.Unity多线程渲染概述
3.使用CPU时间戳进行高精度计时
4.Time Stamp Counter
5.细说RDTSC的坑