c++多边形扫描线填充算法_游戏编程算法与技巧读书笔记第二章·2D图形

随着Web、移动游戏、独立游戏的爆发式增长,2D图形进入了文艺复兴时期。开发者选择2D通常是因为预算和团队规模。玩家选择2D则是因为游戏简洁而纯粹。

一、2D渲染基础

为了更加全面地认识2D渲染,了解这种技术诞生时显示设备的情况是很有必要的。虽然现在基本都用LCD或者等离子显示器,但是有很多在老设备上诞生的技术到今天仍然使用。

1. CRT显示器基础

在多年前,阴极射线管(CRT)显示器是显示器的主流。CRT里图像的元素就是像素。对于彩色显示器,每个颜色由红、绿、蓝组成。显示器的分辨率决定了像素的数量。比如一个300x200的显示器有200行像素,叫做扫描线,每个扫描线可以有300个像素,所以总共有60000个像素之多。

CRT内部,绘制图像是通过电子枪发射电子流完成的。这把抢从左上角开始沿第一条扫描线进行绘制。当它完成之后就继续下一条扫描线,然后不断地重复,直到所有扫描线都画完。

当电子枪刚刚完成一帧的绘制的时候,它的枪头在右下角。喷枪从右下角移动到左上角所花费的时间,我们称为场消隐期(VBLANK)。这个间隔以ms计,间隔不是由CRT、计算机或者电视机决定的,而是由用途决定的。

2. 像素缓冲区和垂直同步

新的硬件使得有足够的内存将所有颜色保存在像素缓冲区。但这不是说游戏循环就可以完全无视CRT喷枪。假设喷枪在屏幕上绘制到一半时,刚好游戏循环到了“generate outputs”阶段。它开始为新的一帧往像素缓冲区写像素时,CRT还在上一帧的绘制过程中。这就导致了屏幕撕裂,具体表现就是屏幕上同时显示了不同的两帧的各自一半画面。

b0b238de07367f496baa8a0ad4e304ce.png

图 画面撕裂

一个解决方案就是同步游戏循环,等到场消隐期再开始渲染。这样会消除分裂图像的问题,但是它限制了游戏循环的间隔,只有场消隐期期间才能进行渲染,对于现在的游戏来说是不行的。

另一个解决方案叫作双缓冲技术。双缓冲技术里,有两块像素缓冲区。游戏交替地绘制在这两块缓冲区里。在1帧内,游戏循环可能将颜色写入缓冲区A,而CRT正在显示缓存区B。到了下一帧,CRT显示缓冲区A,而游戏循环写入缓冲区B。由于CRT和游戏循环都在使用不同的缓冲区,所以没有CRT绘制不完整的风险。

为了完全消灭屏幕撕裂,缓冲区交换必须在场消隐期进行,这就是游戏中常见的垂直同步设置。技术上来讲这是不恰当的,因为垂直同步是显示器在场消隐期刚结束时才告诉你的信号。不管怎样,缓冲区交换是一个相对快速的操作,游戏渲染一帧花费的时间则长得多。所以在场消隐期交换缓冲区完全消除了屏幕撕裂风险。

b1882dc63d49a931efb18185bc7484a6.png

图 垂直同步

这就是双缓冲区绘制函数的样子:

function RenderWorld()
    //绘制游戏世界中所有对象
    ...
    wait for VBLANK
    swap color buffers
end

当然有些游戏允许缓冲区交换绘制完成前尽快进行,这种情况通常是因为玩家想要获得远比屏幕刷新速度快的帧率。虽然CRT显示器今天几乎不再使用,但是双缓冲技术在正确的时间交换还是能在LCD上消除屏幕撕裂的问题。一些游戏甚至使用了三缓冲技术,使用3个缓冲区,能使帧率在一些特殊情况下更加平滑,但也增加了输入延迟。

二、精灵

精灵是使用图片中的一个方块绘制而成的2D图像。通常精灵用来表示角色和其他动态对象。对于简单的游戏来讲,精灵也可能用于背景。大多数2D游戏运用大量的精灵,对于移动端游戏来说,精灵通常就是游戏体积的主要部分。所以,高效利用精灵是非常重要的。

精灵使用哪种图片格式?这在很大程度上取决于用什么图片在哪些平台比较省内存。PNG格式可能空间占用小,但是通常硬件都不支持以PNG格式直接绘制,因此加载到游戏内存的过程中会被转换成其他格式。TGA格式通常可以直接绘制,可是空间占用比较大。在IOS设备上,比较好的格式时PVR,因为它不仅被压缩过,而且还能够直接绘制。

加载图片到内存的过程也是很大程度上取决于平台和框架的。对于SDL,XNA和Cocos2D那样的框架,内建了大量图片格式。如果你想从头开始开发2D游戏,可以用一个相对简单的库stb_image.c(https://github.com/nothings/stb)。

1. 绘制精灵

假设你需要绘制一个场景,最简单的绘制方式是,先画背景后画角色。这个算法叫作画家算法。在画家算法中,所有精灵是从后往前排序的,当它绘制场景时,预先排好序的场景可以直接遍历渲染,得到正确的结果。

e33341449136407a1eb6cbcf35dfcf57.png

图 首先绘制远山,然后绘制较近的草地,最后绘制场景中最近的树木等

这个方法在2D游戏中没有什么问题(在3D环境下,则有很多缺陷)。每个精灵最少由一个绘制顺序,另外还要有图像数据和位置数据。

class Sprite
    ImageFile image
    int drawOrder
    int x,y
    function Draw()
        //把图片在正确的(x,y)上绘制出来
        ...
    end
end

然后,有些世界中的Drawable列表就可以根据绘制顺序排序。这样,在绘制期间,排好序的对象就可以线性遍历下去,然后绘制出正确的结果。

SortedList spriteList

//创建新的精灵······
Sprite newSprite = specify image and desired x/y
newSprite.drawOrder = set desired draw order value
//根据渲染顺序添加到排序列表
spriteList.Add(newSprite.drawOrder, newSprite)
//准备绘制
foreach Sprite s in spriteList
    s.Draw()
loop

一些2D库,比如Cocos2D,允许场景中的层次任意组合,而每个层次都有一个顺序。

2. 动画精灵

对于大多数2D游戏,动画原理就跟连环画一样:快速切换静态图片从而产生动画的幻觉。为了保证动画的流畅性,帧率最少要达到24FPS。这就说动画的每1秒,都24张不同的图片。有的游戏类型,比如格斗游戏,可以将动画帧率提高到60FPS。

一个常见的方法就是用一组图片去表示一个角色的所有状态,而不是某个特定动画。比如说,一个有走动和跑步的角色,每个用10帧表示,总共用了20张图片。为了让问题保持简单,这些图片顺序存储,就是说0-9帧表示走路,10-19帧表示跑步。

这就意味着需要一些方法配置哪些帧表示哪个动画。一个简单的方法就是将这些动画信息封装为一个AnimFrameData结构体,指定开始帧和帧长度去表示一个动画:

struct AnimFrameData
    //第1帧动画的索引
    int startFrame
    //动画的所有帧数
    int numFrames
end

我们用AnimData结构体去存储所有图片的同时,用FrameData保存所有动画信息:

struct AnimData
    //所有动画用到的图片
    ImageFile image[]
    //所有动画用到的帧
    AnimFrameData frameInfo[]
end

然后需要AnimatedSprite类从Sprite类继承下来。因为它继承了Sprite,它已经有位置和根据绘制顺序进行绘制的功能,简单来讲就是绘制一张图片的能力。当然AnimatedSprite还需要额外的变量来完成功能。

AnimatedSprite要能够跟踪当前的动画数量,直到当前帧属于哪一个动画及当前动画需要用到多长时间。其中FPS也作为一个成员变量被存储了。这样能够让动画动态加速或者减速。

class AnimatedSprite inherits Sprite
    //所有动画数据(包括ImageFiles和FrameData)
    AnimData animData
    //当前运行中的动画
    int animNum
    //当前运行中的动画的帧数
    int frameNum
    //当前帧播放了多长时间
    float frameTime
    //动画的FPS(默认24FPS)
    float animFPS = 24.0f

    function Initialize(AnimData myData, int startingAnimNum)
    function UpdateAnim(float deltaTime)
    function ChangeAnim(int num)
end

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

function AnimatedSprite.Initialize(AnimData myData, int startingAnimNum)
    animData = myData
    ChangeAnim(startingAnimNum)
end

ChangeAnim函数在AnimatedSprite切换动画的时候调用。它设置帧数和时间都为0,而后设置当前图片为动画的第1帧。在ChangeAnim和UpdateAnim函数中,使用umage去表示图片。这是因为基类Sprite使用image绘制。

function AnimatedSprite.ChangeAnim(int num)
    animNum = num
    //当前动画为第0帧的0.0f时间
    frameNum = 0
    animTime = 0.0f
    //设置当前图像,设置为startFrame
    int imageNum = animData.frameInfo(animNum).startFrame
    image = animData.image[imageNum]
end

UpdateAnim函数是AnimatedSprite最重要的函数。一部分复杂的原因是不能假设动画帧率比游戏帧率满。比如说,一个游戏可以以30FPS的帧率运行,但我们想让动画以48FPS运行,这就是说UpdateAnim得跳过某些动画帧。

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
            //取模能保证帧数循环正确
            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

虽然这个AnimatedSprite实现在循环动画下工作正常,但还不支持动画切换。如果需要这样的功能,还是建议用动画状态机来实现。

3. 精灵表单(图集)

为了保证精灵完全对齐,让角色的所有动画用同一个尺寸是尚可接受的。在过去,许多库要求所有图片尺寸为2的幂次方。

如果把每帧图片都单独称为一张图片文件(或纹理),通常都会浪费大量资源。一个解决方案就是使用单张图片去存储所有精灵,称之为精灵表单图集。在精灵表单中,可以让精灵打包尽可能靠近,从而减少浪费的无用空间。

精灵表单的另一个优势就是很多GPU要纹理加载后才能绘制。如果绘制过程中频繁地切换纹理,会造成相当多的性能消耗,特别是对于大一点的精灵。但是如果所有精灵都在一张纹理中,是可以消除切换产生的损耗的。

ba85a8fcb88784139128fdaba8bb8bb2.png

47d367b1628034647edaa9ac94c369c4.png

图 单独的精灵和打包好的精灵表单

三、滚屏

在相对简单的2D游戏里,比如《吃豆人》和《俄罗斯方块》,所有元素都在一个屏幕里。在更复杂的2D游戏里,游戏世界经常比单个屏幕大。对于这些游戏,一个更常见的方法就是关卡随着角色移动而滚动。

1. 单轴滚屏

单轴滚屏游戏中,游戏只沿x轴或者y轴滚动。最简单的方法就是把关卡背景按屏幕大小进行切割。一关可能有20-30个片段,绝对比单张图片大得多。加载的时候,图片可以以片段为单位放置游戏世界中。如果背景精灵从左上角开始画,那么每个片段的坐标计算也相当容易。在每个背景放到正确位置后,可以将其加载到一张链表中。水平滚动的初始化代码可以像下面这样:

1e17ce0d0758d106fc2dd50d4c18417e.png

图 单轴滚动的Jetpack Joyride

const int screenWidth = 960 //一台iPhone 4/4s屏幕大小为960x640
//所有屏幕大小的背景图
string backgrounds[] = {"bg1.png", "bg2.png", /*...*/}
//所有水平拜访的屏幕大小的背景图数量
int hCount = 0
foreach string s in backgrounds
    Sprite bgSprite
    bgSprite.image.Load(s)
    //第1个屏幕在x=0处,第2个在x=960处,第3个在x=1920处......
    bgSprite.x = hCount * screenWidth
    bgSprite.y = 0
    bgSpriteList.Add(bgSprite)
    screenCount++
loop

在bgSpriteList装载完毕后,需要决定哪些背景需要绘制及绘制在什么地方。如果背景片段屏幕尺寸相同,那么同时最多只有两张背景需要绘制。需要一种方法去跟踪屏幕显示正确的背景。

一种常见的方法就是让摄像机也在游戏世界中拥有坐标。摄像机最开始放在第一张背景的位置。在水平滚屏的过程中,摄像机的x位置设置为玩家的x位置,只要位置不超过第一张背景和最后一张背景的范围就没问题。

虽然这样可以通过if判断各种情况,但是随着这样的判断越来越多,游戏会变得很复杂。一个不错的方案就是使用clamp函数,它能让某个值在最大值和最小值之间。在这种情况下,可以设置摄像机x位置等于玩家x位置,然后把x值维护在最大值和最小值之间。

//camera.x就是player.x在区间中经过clamp的值
camera.x = clamp(player.x, screenWidth / 2, bCount * screenWidth - screenWidth / 2)

Iterator i = bgSpriteList.begin()
while i != bgSpriteList.end()
    Sprite s = i.value()
    //找到第1张图片来绘制
    if (camera.x - s.x) < screenWidth
        // 第1张图: s.x = 0, camera.x = 480, screenWidth/2 = 480
        draw s at (s.x - camera.x + screenWidth/2, 0)
        //绘制第1张背景图后,找第2张
        i++
        s= i.value()
        draw s at (s.x - camera.x + screenWidth/2, 0)
        break
    end
    i++
loop

上面的代码实现了摄像机和玩家在关卡中的前进和后退。如果希望让摄像机只朝前移动,只需在玩家x位置比摄像机x位置大的时候更新即可。

2. 无限滚屏

无限滚屏就是当玩家失败才停止滚屏的游戏。当然,这里不可能有无限多个背景来滚屏。因此游戏中的背景会重复出现。当然,大部分无限滚屏游戏都拥有大量的背景图片和很好的随机性来产生丰富的背景。通常游戏都会用连续的四五张图片组成序列,有了不同系列的图片可选后再打乱重组。

3. 平行滚屏

在平行滚屏中,背景拆分成几个不同深度的层级。每一层都用不同的速度来滚动以制造不同深度的假象。

单轴滚屏中的伪代码可以替换成多个背景同时滚屏。为了实现平行滚屏效果,当计算精灵相对于摄像机偏移时,需要每层都给出一个额外的因子。

//一个比较靠近的层级以1/5的速度移动
float speedFactor = 0.2f
draw s at (s.x - (camera.x - screenWidth/2) * speedFactor, 0)

4. 四向滚屏

在四向滚屏中,游戏世界会在水平和垂直方向上滚动。由于两个方向上都滚屏,同一时刻屏幕会显示4个背景片段。

为了让之前的水平滚屏代码在两个方向上都能动,需要检查摄像机的y位置是否需要更新。但在此之前,需要声明一些变量,记录屏幕高度和关卡垂直方向上有多少个片段:

const int screenHeight = 640 //iPhone 4/4s的水平方向上的高度
int vCount = 2

我们固定左上角的游戏世界坐标为(0,0)。由于可以垂直滚屏,故需要更新摄像机的y位置:

camera.y = clamp(player.y, screenHeight / 2, vCount * screenHeight - screenHeight / 2)

计算精灵相对于摄像机的新位置要考虑y位置:

draw s at (s.x - camera.x + screenWidth / 2, s.y - camera.y - screenHeight / 2)

要判定哪些背景片段需要绘制,不能再用水平滚屏的方式去做。那是因为不再是更新单个精灵列表。一个基本的方法就是使用二维数组记录片段,然后判断哪一行哪一列需要在屏幕上显示。一旦片段计算出来,计算其他3个也很简单:

四、砖块地图

如果你想要开发类似《塞尔达传说》那样的2D俯视角色扮演游戏,会有着大量的野外区域和地下城,你可使用砖块地图来绘制这些关卡。砖块地图通过把游戏世界分割成等分的方块(或者其他多边形),每个方块代表的精灵占据着某一块网格位置。这些引用的精灵可以放在多张或者一张砖块集合里。所以如果树木在砖块集合中的索引号为0,每个表示树木的方块都可以用0表示。

虽然正方形是砖块地图的最常见形式,但这不是必需的。一些游戏采用六边形,有的则采用平行四边形。这取决与你希望的视角。

1. 简单的砖块地图

首先要确定砖块的尺寸。虽然这在很大程度上取决于设备,但是很多智能手机上的游戏都采用32x32像素,这就意味着在分辨率960x640的手机上,单个屏幕都能显示30x20个砖块。

设置好砖块的尺寸后,下一步就是创建游戏使用的砖块。所有砖块都放进一张精灵表单中。渲染程序需要通过砖块ID得到真正的图片。一个简单的方法是让左上角的砖块ID为0,往右就加1,以此类推。

在设计好砖块集合之后,就可以利用砖块集合涉及关卡数据了。虽然关卡数据可以硬编码为二维数组,但是存储在外部文件会更好一些。这个文件最常见的格式就是按照屏幕显示的方式,用砖块ID去表示。因此5x5的砖块关卡会像这样:

//基本关卡文件格式
5,5
0,0,1,0,0
0,1,1,1,0
1,1,2,1,1
0,1,1,1,0
0,0,1,0,0

关卡可以用下面的类表示:

class Level
    const int tileSize = 32
    int width, height
    int tiles[][]
    function Draw()
end

tiles数组存储了关卡的ID,而宽高对应了数组的行列。虽然读取关卡数据的代码是由具体的程序语言决定的,但是解析这个文件非常简单。在完成解析程序之后,下一步就是实现关卡的绘制函数:

function Draw()
    for int row =0, row < height, row++
        for int col = 0, col < width, col++
            //在(col*tileSize, row*tileSize)绘制tiles[row][col]
        end
    loop
end

值得注意的是,可以为同一个砖块ID使用不同图片。这就可以实现游戏中常见的季节性的“皮肤”功能。

2. 斜视等视角砖块地图

斜视等视角中,视角通过旋转,让场景更具深度感。《暗黑破坏神》和《辐射》都应用了这种视角。

f10ea5534d4a13cac20c9eea280be0d6.png

图 斜视视角场景

实现斜视等视角与普通视角有一些不同。与正方形不同的是,这些砖块要么是六边形要么是平行四边形。运用斜视等视角砖块地图时,会使用多个层次把相邻的砖块作为一组。为了支持多层次,需要更新数据,使其能够表达多个层次。即,渲染代码也需要支持正确的顺序。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值