游戏编程算法与技巧笔记(2)——2D图形

本章将介绍2D游戏相关的图形知识。

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

2D渲染基础

虽然我们现在是在学3D,但如果跳过2D的话,反而会有些得不偿失。在本章的开头,我还是推荐没看过的各位去看一看我写过的《Cocos2dx学习笔记》,粗略地了解一遍还是很有帮助的。

CRT显示器基础

阴极射线管(CRT)显示器曾是多年前的主流产品,而CRT里的图像元素就是像素。一般来说,彩色显示器的颜色由RGB(红、绿、蓝)构成,而显示器的分辨率就决定了像素的数量。比如一个300X200的显示器有200行像素,叫做扫描线,每个扫描线可以有300个像素,所以总共会有60000个像素。

如上图,绘制图像是由电子枪发射电子流完成的。电子枪从左上角开始沿第一条扫描线进行绘制,当它完成之后就继续下一条扫描线,然后不断的重复,直到完成所有扫描线就算是一帧。当电子枪刚刚完成一帧时,它的枪头在右下角,而从右下角移动到左上角的路径便被称为垂直回归线,所花费的时间叫做场消隐期

像素缓冲区和垂直同步

早期的游戏硬件比较差,没有足够的内存存储完整的像素数据。现代硬件已经拥有了足够的内存容量,完全可以将所有颜色保存在像素缓冲区中,比之旧硬件要方便许多。但这并不代表游戏循环就可以完全无视CRT喷枪。假设喷枪在屏幕上绘制到一半时,刚好到了要输出画面的阶段。它开始为新的一帧往像素缓冲区中写像素时,CRT还在上一帧的绘制过程中。这就导致了画面撕裂,具体来说就是屏幕上同时显示了不同两帧的各自一半画面。

当然,更有可能出现的是新一帧的数据提交时,上一帧还没有开始,那么游戏将会出现画面丢失的情况。

解决方法有两个,第一个是同步游戏循环,等到场消隐期期间才开始渲染,但这样限制了游戏帧数,对于现在的游戏是行不通的。第二个就是双缓冲技术,游戏交替地在两块像素缓冲区中绘制。在1帧内,游戏循环可能会将颜色写入A区,而CRT正在显示B区。到了下一帧,CRT显示A区,而游戏循环写入B区。这样,就不会出现CRT绘制不完整的风险。

那么为了完全消灭画面撕裂,缓冲区的交换就必须在场消隐期进行,而这就是游戏中常见的垂直同步。相比于渲染一帧所花费的时间,缓冲区的交换要快上很多,所以在场消隐期交换缓冲区完全消除了画面撕裂的风险。值得注意的是,垂直同步虽然防止了画面撕裂,但同时也会降低游戏的帧数,玩家应该根据自己的电脑配置进行取舍。

现在虽然已经淘汰了CRT显示器,但双缓冲甚至是三缓冲技术都在沿用中,可以有效地解决画面撕裂问题,而与之伴随的则是无法忽视的输入延迟。

精灵

看过Cocos2dx的人应该会有所了解,精灵就是游戏中所有可见的2D图像(当然看不见的也可以算作是精灵,但那样就失去了精灵的存在意义,只能算作是对象)。精灵采取图片的形式存储,而图片的格式一般取决于平台需求。PNG格式占用空间小,但是通常硬件都不支持PNG格式直接绘制,因此加载到游戏内存中会被转换成其他格式。TGA格式可以直接回执,但占用空间大。在IOS设备上,一般用PVR。

绘制精灵

绘制精灵需要按照一定的顺序,而这个步骤叫做画家算法。在画家算法中,所有的精灵都是从底层开始往上排列,依序渲染,与Cocos2D中的节点渲染类似(先父节点,再子节点)。对于Cocos2D之类的2D库来说,允许场景中的层次相互组合,比如一个游戏有背景层、角色层、UI层,而每个层中又会有多种元素,绘制时只需按序绘制即可。

每一个精灵最少有一个绘制顺序,另外还要有图像数据和位置数据:

1
2
3
4
5
6
7
8
9
10
class Sprite
ImageFile image
int drawOrder
int x, y
function Draw()
// 绘制图像
...
end
end

动画精灵

2D游戏的动画其实是由一张张图片快速播放而成。方便起见,我们一般用一组图片去表示一个角色的所有状态。但另一个问题又随之出来,如何分辨出哪些帧表示哪个动画?方法很简单,将所有动画信息封装为一个AnimFrameData结构体,指定开始帧和帧长度去表示一个动画:

1
2
3
4
5
6
struct AnimFrameData
// 第1帧动画的索引
int startFrame
// 动画的所有帧数
int numFrames
end

用AnimData保存所有动画信息:

1
2
3
4
5
6
struct AnimData
// 所有动画用到的短片
ImageFile images[]
// 所有动画用到的帧
AnimFrameData frameInfo[]
end

然后需要 AnimatedSprite 类从 Sprite 类继承下来,除了位置和根据绘制顺序进行绘制外,还要能够跟踪当前的动画数量,知道当前帧属于哪一个动画及当前动画需要用到多长时间。另外,FPS 同样也作为一个成员变量被存储了,用于控制动画的播放速度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class AnimatedSprite inherits Sprite
// 所有动画数据
AnimData animData
// 当前运行中的动画
int animNum
// 当前运行中的动画的帧数
int frameNum
// 当前帧播放了多长时间
float frameTime
// 动画的FPS
float animFPS = 24.0f
function Initialize(AnimData myData, int startingAnimNum)
function UpdateAnim(float deltaTime)
function ChangeAnim(int num)
end

Initialize 函数会为 AnimatedSprite 引用 AnimData,这样多个动画精灵能够共享同一份数据,这样也大大节省了内存。函数要求传入需要播放的动画,而后续初始化动作则由 ChangeAnim 函数完成。

1
2
3
4
function AnimatedSprite.Initialize(AnimData myData, int startingAnimNum)
animData = myData
ChangeAnim(startingAnimNum)
end

ChangeAnim 函数主要是用于切换动画,将帧数和时间设为 0,并且设置当前图片为第 1 帧。

1
2
3
4
5
6
7
8
9
function AnimatedSprite.ChangeAnim(int num)
animNum = num
// 当前动画为第0帧的0.0f时间
frameNum = 0
animTime = 0.0f
// 设置当前图像,设置为startFrame
int imageNum = animData.frameInfo[animNum].startFrame
image = animData.images[imageNum]
end

UpdateAnim 是非常重要的一个函数。原因之一就是不能够假设动画帧率比游戏帧率慢。例如,游戏可以在 30FPS 下运行,某个动画的运行帧率为 48FPS,那就意味着 UpdateAnim 必须得跳过某些帧。这也就是说,如果某一帧比某一动画帧所花费的时间要长,那么就要计算到底要跳过多少帧。像是动画的播放速度太快,就得从第1帧或者第2帧开始。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function AnimatedSprite.UpdateAnim(float deltaTime)
// 更新当前帧播放时间
frameTime += deltaTime
// 根据frameTime判断是否为下一帧
if frameTime > (1 / animFPS)
// 更新当前播放到第几帧
// frameTime / (1 / animFPS)就相当于frameTime * animFPS
frameNum += frameTime * animFps
// 检查是否跳过最后一帧
if frameNum >= animData.frameInfo[animNum].numFrames
// 取模能够保证帧数循环正确
// (比如,if numFrames == 10 and frameNum == 11,
// frameNum会得到11 % 10 = 1
frameNum = frameNum % animData.frameInfo[animNum].numFrames
end
// 更新当前显示图片
// (startFrame是相对于所有图片来决定的,而frameNum相对于某个动画来决定)
int imageNum = animData.frameInfo[animNum].startFrame + frameNum
image = animData.images[imageNum]
// 我们用fmod(浮点数运算),相当于取模法
frameTime = fmod(frameTime, 1 / animFPS)
end
end

上面的代码可以参考一下,但切换动画的功能其实并没有实现。Cocos2D 提供了 Animation 类来处理动画,并将其作为一个 Animate 类(动作类)的分支,可以说又是一种不错的模式。

精灵表单

为了保证精灵能够对齐,将该精灵所有的图片尺寸设置为一致是可行的(相信我,尺寸不同的图片保证能让你抓狂)。在过去,许多库都要求图片的尺寸为 2 的幂次方,虽然现在的显卡没了这个要求,但在 mipmap 中还是适用的。

初学者在使用游戏引擎时通常是调用一张张单独的图片。这种做法有助于新手理解引擎的操作方式,但并不利于真正的游戏开发。为了保证尺寸一致,图片中常常会有大面积的空白区域,正确的做法其实是将所有要用到的图片整合到一张大图中,让每个精灵之间的距离尽可能地靠近,节省空间。

比较流行的精灵表单打包工具是 TexturePacker。它支持很多 2D 库格式,其中就有 Cocos2D。TexturePacker 会将所有导入的图片整合为一张大图,并同时生成一个 .plist 格式的文件,里面记录了每张单独图片的位置信息。

当然,每张图片的尺寸是有限制的,你不可能把所有的元素全部都整合成一张大图。根据不同的设备,图片的尺寸限制也有所不同。

滚屏

滚屏在 2D 游戏中是经常出现的,而如何实现滚屏并达到相应的效果则是我们需要学习的。

单轴滚屏

单轴滚屏意味着游戏只沿 x 轴或者 y 轴滚动,像魂斗罗就是比较经典的单轴滚屏游戏。由于游戏世界要比屏幕大很多,所以一般不会选择加载整张地图。

最简单的做法就是把关卡背景按照屏幕大小进行切割,然后将这一系列的片段加入到一张链表,并依序摆放到游戏世界中。链表加载完成后,需要决定那些背景需要绘制及绘制到什么地方。如果背景片段的尺寸与屏幕尺寸一致,那么同时最多只有两张背景图需要绘制。不过按照上述的方法,我们就需要一种能够跟踪背景的手段,方便游戏能够显示正确的背景片段。

一种常见的方法就是让摄像机也在游戏世界中拥有坐标。关于摄像机,它并不能算是实体,也不需要去绘制它,它的作用仅仅在于控制玩家所能见到的视角。摄像机最开始放在第一张背景的位置。在水平滚屏的过程中,摄像机的 x 位置设置为玩家的 x 位置,只要位置不超过第一张背景和最后一张背景的范围就没问题。

这种方法其实有点麻烦,我们需要计算精灵相对于摄像机的偏移量。而对 Cocos2D 这种具有层级概念的引擎来说,常用的方法是让背景移动。实现效果一样,但代码要简洁很多(当然你可以让游戏角色原地做一些动作,这样看起来就要更加生动一点)。

无限滚屏

无限滚屏就是当玩家失败才会停止滚屏的游戏。像《飞机大战》、《Flippy Bird》、《一个都不能死》,都是属于这种类型的游戏。无限滚屏并不代表游戏的背景图片是无限长的,游戏开发者通常会将四五张不同的图片排成一个序列,之后再不断地打乱顺序,即可达到背景图片的连续性和随机性。关于如何用一张背景图实现无限滚屏,有兴趣的可以看看微信飞机大战的那篇实战文章。

平行滚屏

平行滚屏是将背景拆分成几个不同深度的层级,每一层都以不同的速度滚动以制造空间感。例如游戏中的云朵和地面,如果云朵比地面滚动得慢,那么就会造成云朵比地面远的感觉。

四向滚屏

四向滚屏中,游戏世界会在水平和垂直方向上滚动,这样同一时刻内会显示4个背景片段。这类型的游戏会遇到的问题就是原点的放置,因为大部分 2D 框架坐标系是从左上角开始的(我在 Cocos2dx 学习笔记中讨论过这个问题),那么如果左上角的游戏世界坐标就是 (0,0) 的话,会让问题简单点。

由于是四个背景片段同时绘制,那么就不可能用一维数组来记录所有的片段。比较好的方法是用二维数组,然后判断哪一行那一列在屏幕上显示。一个算出来了,另外三个也很简单。

1
2
3
4
5
6
7
8
9
10
11
for int i = 0, i < vCount, i++
// 判断是否是正确的行
if (camera.y - segments[i][0].y) < screenHeight
for int j = 0, j < hCount, j++
// 是否是正确的列
if (camera.x - segments[i][j].x) < screenWidth
// 绘制左上角可见片段
end
loop
end
loop

砖块地图

砖块地图,又称瓦片地图。不管你用何种说法,这类型的地图都有一个共同点,那就是背景图都是由一个个等尺寸的小方块组成。像《口袋妖怪》、《牧场物语》、《火焰纹章》等 2D 俯视视角的游戏,会有着大面积的开放式地图。不用说我也知道,大部分人的想法就是做许多张尺寸非常大的地图,到了哪个地方就加载哪张图,简单粗暴。但这样一来,就会产生大量的重复,比如石头和树木就会在一个场景中重复多次出现,造成了大量的内存浪费。

为什么不将这些相同的精灵统一做成一张图片呢?像是石头和树木,只需要单独做出几张图片,然后根据需要把它们随意组合在一起,就能够形成一座石山或者一片树林,而做到这一点仅仅只需要几张小图片。另外,我们可以事先将所有方块加入到精灵表单中,这样更易于管理。像是树木在砖块集合中的索引为0,那么所有表示树木的方块都可以用0表示。

虽然正方形是砖块地图的常见形式,但某些游戏也会采用六边形和平行四边形来形成不同的视角。

斜视等视角砖块地图

上图是游戏《死神来了》,一款非常不错的 2D 像素风格解密游戏。该游戏采用的视角是斜视,比起传统的俯视视角更具有立体感。与正方形不同的是,这些砖块要么是六边形,要么是平行四边形。当然,为了整体的效果,斜视视角砖块地图需要使用多个层次去组织砖块,把相邻的砖块作为一组。