游戏编程算法与技巧笔记(1)——游戏编程概述

本章将开始学习游戏编程相关的各种知识。

本文仅供个人记录和复习,不用于其他用途

前言

其实在不久之前,我还只是一个懂得些C++、能够用Cocos2dx做出游戏原型的普通学生。当然了,我并不是说那些游戏做的不好,像是飞机大战这种游戏,在外人看来已经算是真正的游戏作品了。不过高兴之余,我的内心里也有些惴惴不安,毕竟这些游戏都算是我的“临摹品”,并不能够作为我自豪的资本。思来想去,我决定更深入地学习游戏开发的相关知识,权且将制作新游戏的念头放在一旁。

PS:从本文的Tag就能看出来,这本《游戏编程算法与技巧》只能算入门级,内容浅显易懂,但对于从没接触过游戏开发的人来说(哪怕只是一时兴起想要做游戏)是非常有用的。

游戏循环

这个很好理解,控制整个游戏程序的就是游戏循环。可以简单地理解为以下步骤:

  • 检测玩家输入
  • 更新游戏世界
  • 游戏输出
  • 重复上述步骤直至游戏结束

首先要注意一点,游戏是不断地在更新的,它会执行一系列动作直到玩家结束游戏。每一次游戏循环被称为1帧,一般的游戏帧率都在30~60帧每秒,过少会影响游戏体验,过多则没有太大的意义。至于循环的步骤,每一个阶段都有各自的含义:

  • 玩家输入:不仅仅是键盘、鼠标,还有可能是其他的诸如手柄、方向盘乃至VR设备。数据的种类多种多样,所有的输入都得在这一步完成。
  • 更新游戏世界:会执行所有激活并需要更新的对象,这个数量可能会达到成千上万个。
  • 游戏输出:是游戏中最为重要的一部分。除了将图形渲染为2D和3D,还有音频、震动等输出。关于图形渲染的部分,我会在《3D游戏编程》以及之后会更新的《OpenGL编程》中介绍。

多线程

传统的游戏循环虽然简单明了,但由于如今的CPU已经具有多个核,这也意味着游戏必须采用多线程的模式来运行。举个简单的例子,某一台游戏机的CPU有3个核,而游戏仅仅只是用了其中的一个核,效率上就远远不及将三个核充分利用的多线程循环。大型游戏(3A级游戏)的图形渲染是十分耗时的,如果将渲染和更新同时放到一个线程里,最终也将导致低帧率,这也可以解释说为什么有的游戏优化好,而有的优化很差。当然,其中的原因有很多,这里只是为了说明多线程的重要性。

基于这个想法,如果主线程必须处理输入、更新游戏世界、处理图形以外的输出,那么它可以让第二条线程来渲染所有的图像。

同样,这里也有个问题:当其他线程在渲染时,主线程应该干什么?如果只是简单地等待,那这样比单线程还要慢。解决办法之一就是让渲染线程比主线程慢一帧。

假设玩家在某一帧按下攻击键,按照多线程的游戏循环,输入会在下一帧时才开始处理,之后再过一帧才能播放攻击动画。这种情况会导致输入延迟,对于即使战斗类游戏来说会有不小的影响,不过对于其他的类型影响不大。而且基于其他的因素(显示器延迟等),同样也会造成输入延迟,但这就不是我们要关心的范围了。

时间概念

大部分的游戏都会有时间的概念,一般可分为真实时间游戏时间

真实时间与游戏时间

所谓的真实时间,就是现实世界的时间,而游戏时间则是指游戏世界所流逝的时间。你可以认为它们是1:1的关系,但一般来说都不是这样的。

举几个例子,足球比赛的时长为90分钟,而足球游戏会将这个时间缩小到10-20分钟,让玩家体验到一场紧张刺激的体育竞技。其他类型的游戏像是《时间幻境》,主角能够自由地操控时间,甚至可以做到时间倒流。

综上所述,真实时间与游戏时间其实是对不上的,不同的游戏类型对于时间的要求也不太一样。

时间增量

我们在编写游戏程序时,可能会用到下面这种代码:

1
enemy.position.x += 10

游戏如果运行在一个8MHZ的处理器上,那么敌人的移动速度将取决于处理器的速度。但当玩家把它搬到16MHZ的处理器上后,我们可以简单地认为游戏循环会多执行一次,也就是说敌人的移动速度会快上一倍。显然,这并不是我们想要的结果,敌人的移动速度不应该与帧率挂钩。

为了解决这个问题,我们需要引入增量时间的概念,也就是从上一帧起流逝的时间。如果游戏的帧率提高,那么每帧敌人移动的距离必须要短一点;若是游戏的帧率降低,那么敌人的移动距离应该更多一点。假设理想的移动速度是每秒20像素,那么代码可以这样:

1
enemy.position.x += 20 * deltaTime

运行上面的代码,敌人的速度将固定在20像素/每秒,不会因为帧率的变化而做出改变。虽然从视觉的角度来说,高帧率下的敌人移动得更加流畅,但无论哪种情况,敌人的移动速度都是一样的。

那么如何获得增量时间呢?这个主要取决于游戏框架,每帧间隔的时间其实都是可以计算的。只要得到这个数值,我们便能固定住敌人的移动速度。

然而,增量时间并不能够解决所有问题。首先是所有与物理相关的游戏在帧率上的表现都不一样,比如低帧率跳得高等等,这些会在“物理”一节中详细解释。

解决办法当然也有,比如最粗暴的方法:限制帧率。PS4上面的游戏基本都锁定为30帧,原因有很多,除了机能上的限制,还有就是上面所说的物理问题。如果游戏的循环本身只用了30ms,那么还要等待额外的3.3ms才能开始下一次游戏循环。注意,哪怕限制了帧数也需要处理增量时间。

还有一种情况:游戏突然遇上了复杂的情形,导致某帧的渲染时间延长,这时候怎么办呢?其实很简单,直接丢掉这一帧就好,而这种做法导致的结果就是卡顿。视觉上的卡顿会给玩家带来极其糟糕的体验。

游戏对象

相信各位在看过我之前写的《Cocos2dx学习笔记》后,应该会对游戏对象有一个清晰的认知。游戏对象就是每一帧都需要更新或者绘制的对象,不管它是以什么形式存在,游戏都必须跟踪管理这些对象。

游戏对象的类型

第一种常见的就是需要更新和绘制的对象,比如游戏中的角色、任何敌人以及会动的方块。

第二种是只绘制不更新的对象,称为静态对象。它可以是游戏背景之类的东西。

第三种游戏对象是那些需要更新,但不需要绘制的对象。比较常见的例子就是摄像机。一般来说,游戏需要移动摄像机来改变视角,但玩家并不需要看到摄像机。另一种情况就是触发器。许多游戏都会有这样的设计,当玩家走到某个位置时,就会触发某件事情。触发器需要不断地检测与玩家的碰撞行为,但并不需要生成实体。

游戏循环中的游戏对象

游戏对象有很多种实现方式,其中一种就是面向对象中的接口。如果某个类需要使用这个接口,那么就必须要按照规定去实现这个接口中的所有方法。

类似C++之类的语言,都会有继承的概念。需要注意的是,继承虽然好用,但我们应该尽量避免菱形继承,同时善用虚函数的概念。当游戏对象被创建出来之后,需要将它添加到相应的列表之中,统一进行管理,不用时再从列表中删除:

1
2
3
4
class GameWorld
List updateableObjects
List drawableObjects
end

这样,我们就完成了游戏循环中的更新世界

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
while game is running
realDeltaTime = time since last time
gameDeltaTime = realDeltaTime * gameTimeFactor
// 处理输入
...
// 更新游戏世界
foreach Updateable o in GameWorld.updateableObjects
o.Update(gameDeltaTime)
loop
// 渲染输出
foreach Drawable o in GameWorld.drawableObjects
o.Draw()
loop
// 帧数限制代码
...
loop