游戏编程算法与技巧GameProgrammingAlgorithmsandTechniques(SanjayMadhav著)void87

随着"Atari2600"在1977年推出,开发者们有了一个开发游戏的平台.这正是游戏开发变得更加软件化的时候,再也不用设计复杂的硬件了.从Atari时期一直到现在,仍然有一些游戏技术保留着

家用机推出的时候,它的硬件就会被锁定5年多,称为一个"世代".家用机的优点也在于其锁定了硬件,使得程序员可以有效利用机能.

Atari时期(1977-1985年)

这个时期的程序员需要对底层硬件有一定的理解.CPU运行在1.1MHz,内存只有128字节.这些限制使得用C语言开发不太实际.大多数游戏都是完全用汇编语言开发的.更糟糕的是,调试是完全看个人能力的.没有任何开发工具和SDK.

NES和SNES时期(1985-1995年)

然而到了1983年,北美游戏市场崩溃了.主要原因在于,市场上充斥着大量质量低下的游戏.

直到1985年推出的红白机(NES)才把产业带回正轨.

到了超级任天堂(SNES)时代,开发团队进一步扩大.随着开发团队的扩大,不可避免地会变得更加专业化.

NES和SNES的游戏仍然完全用汇编语言开发,因为内存依然不足.幸运的是任天堂有提供SDK和开发工具,开发者不再像Atari时期那么痛苦.

PS和PS2时期(1995-2005年)

由于高级语言带来了生产力提升,开发团队规模的增长在这个时期刚开始有所减缓.大部分早期游戏仍然只需要8~10位程序员.即使最复杂的游戏,比如2001年推出的GTA3,工程师团队也是那样的规模

虽然本时期早期开发团队规模跟NES时期差不多,可是到了后期就变庞大了不少.比如2004年在Xbox推出的FullSpectrumWarrior总共有15名程序员参与开发,大部分都是专门开发某个功能的.但这个增加比起下个时期可以说微不足道

Xbox360,PS3和Wii时期(2005-2013年)

家用机的高画质表现导致游戏开发进入了两种境地.AAA游戏变得更加庞大,也有着相应庞大的团队和费用.而独立游戏则跟过去的小团队开发规模相仿

游戏编程的另一个大的开发趋势就是中间件及开源的出现.有的中间件是完整的游戏引擎,比如Unreal,Unity.有的则是专门做某个系统,比如物理引擎HavokPhysics.这样就节省了大量的人力,财力.但是缺点就是某个策划功能可能会受到中间件的限制

游戏的未来

游戏循环

整个游戏程序的核心流程控制称为游戏循环.之所以是一个循环,是因为游戏总是不断地执行一系列动作直到玩家退出.每迭代一次游戏循环称为1帧.大部分实时游戏每秒钟更新30~60帧.如果一个游戏跑60FPS(帧/秒),那么这个游戏循环每秒要执行60次.

游戏开发中有着各种各样的游戏循环,选择上需要考虑许多因素,其中以硬件因素为主

传统的游戏循环

一个传统的游戏循环可以分成3部分:处理输入,更新游戏世界,生成输出.一个基本的游戏循环可能是这样的:

whilegameisrunningprocessinputsupdategameworldgenerateoutputsloopViewCodeprocessinputs会检查各种输入设备,比如键盘,鼠标,手柄.但是这些输入设备并不只输入已经想到的,任何外部的输入在这一阶段都要处理完成

updategameworld会执行所有激活并且需要更新的对象.这可能会有成千上万个游戏对象.

对于generateoutputs,最耗费计算量的输出通常就是图形渲染成2D或3D.另外还有其他一些输出,比如音频,涵盖了音效,背景音乐,对话,跟图形输出同样重要.

whileplayer.lives>0//处理输入JoystickDataj=grabrawdatafromjoystick//游戏世界更新updateplayer.positionbasedonjforeachGhostginworldifplayercolliderswithgkilleitherplayerorgelseupdateAIforgbasedonplayer.positionendloop//Pac-Man吃到药丸...//输出结果drawgraphicsupdateaudioloopViewCode多线程下的游戏循环

对于enemy.position.x+=516MHz的处理器比8MHz的处理器要快1倍.

广义上的游戏对象是每一帧都需要更新或者绘制的对象.虽然叫作"游戏对象",但并不意味着就必须用传统的面向对象.有的游戏采用传统的对象,也有的用组合或者其他复杂的方法.不管如何实现,游戏都需要跟踪管理这些对象,在游戏循环中把它们整合起来.

游戏对象的类型

更新和绘制都需要的对象.任何角色,生物或者可以移动的物体都需要在游戏循环中的updategameworld阶段更新,还要在generateoutputs阶段渲染.在<<超级马里奥兄弟>>中,任何敌人及所有会动的方块都是这种游戏对象

只绘制不更新的对象,称为静态对象.这些对象就是那些玩家可以看到,但是永远不需要更新的对象.他可以是游戏背景中的建筑.一栋建筑不会移动也不会攻击玩家,但是需要绘制

需要更新但不需要绘制的对象.一个例子就是摄像机.技术上来讲,你是看不到摄像机的,但是很多游戏都会移动摄像机.另一个例子就是触发器.触发器会检测玩家的位置,然后触发合适的行为.所以触发器j就是一个看出见的检测与玩家碰撞的盒子.

游戏循环中的游戏对象

Gregory,Jason.GameEngineArchitecture.BocaRaton:AKPeters,2009.这本书用了几节篇幅讲了多种多线程下的游戏循环,包括在PS3的非对称CPU架构上使用的情况.

游戏对象

第2章2D图形

2D渲染基础

CRT显示器基础

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

像素缓冲区和垂直同步

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

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

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

有些游戏确实允许缓冲区交换在绘制完成前尽快进行,这就会导致屏幕撕裂的可能.这种情况通常是因为玩家想要获得远比屏幕刷新速度快的频率.如果一款显示器有60Hz的刷新率,同步缓冲区交换到场消隐期最多只有60Hz.但是玩家为了减少输入延迟(或者有很快的机器相应速度),可能会消除同步以达到更高的帧率

精灵

精灵是使用图片中的一个方块绘制而成的2D图像.通常精灵用来表示角色和其他动态对象.对于简单的游戏来讲,精灵也可能用于背景,虽然有更加有效的方式来完成,特别是静态的背景.大多数2D游戏运用大量的精灵,对于移动端来说,精灵通常就是游戏体积(磁盘空间占用)的主要部分.所以,高效利用精灵是非常重要的

绘制精灵

最简单的绘制场景的方式是,先画背景后画角色.这就像画家在画布上画画一样,也因为这样,这个算法叫作画家算法

classSpriteImageFileimageintdrawOrderintx,yfunctionDraw()//把图片在正确的(x,y)上绘制出来...endendSortedListspriteList//创建新的精灵...SpritenewSprite=specifyimageanddesiredx/ynewSprite.drawOrder=setdesireddrawordervalue//根据渲染顺序添加到排序列表spriteList.Add(newSprite.drawOrder,newSprite)//准备绘制foreachSpritesinspriteLists.Draw()loopViewCode画家算法也可以运用在3D环境下,但它有很多缺陷.而在2D场景中,画家算法工作得很号

动画精灵

使用单张图片区存储所有精灵,称之为精灵表单.

在精灵表单中,可以让精灵打包尽可能地靠近,从而减少浪费的无用空间

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

取决与于游戏中精灵的数量,把所有的精灵都放入一张纹理是不现实的.大多数硬件都有纹理最大尺寸限制

滚屏

单轴滚屏

在单轴滚屏游戏中,游戏只沿x轴或者y轴滚动.

最简单的方法j就是把关卡背景按屏幕大小进行切割

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

平行滚屏

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

四向滚屏

在四向滚屏中,游戏世界会在水平和垂直方向上滚动

砖块地图(TileMap)

砖块地图通过把游戏世界分割成等分的方块(或者其他多边形)来解决这个问题.每个方块代表的精灵占据着某一块网格位置.这些引用的精灵可以放在多张或者一张砖块集合里.所以如果树木在砖块集合中的索引号为0,每个表示树木的方块都可以用0表示.虽然正方形是砖块地图的最常见形式,但这不是必须的.一些游戏采用六边形,有的则采用平行四边形.这主要取决于你希望的视角

简单的砖块地图

//基本关卡文件格式5x55,50,0,1,0,00,1,1,1,01,1,2,1,10,1,1,1,00,0,1,0,0classLevelconstinttileSize=32intwidth,heightinttiles[][]functionDraw()endfunctionDraw()forintrow=0,row

斜视等视角砖块地图

在斜视等视角中,视角通过旋转,让场景更具深度感

Cocos2D

Itterheim,Stephen.LearnCocos2d2:GameDevelopmentforiOS.NewYork:Apress,2012.虽然有很多本书讲Cocos2D,但这本书是相对强的

SDL

第3章游戏中的线性代数

向量

向量表示了n维空间下的长度和方向,每个维度都用一个实数去表示

当解决向量问题的时候,你会发现在不同位置都可以绘制向量是非常有帮助的.因为改变向量绘制的位置并不会改变向量本身,这个常用的技巧要铭记在心

加法

减法

长度,单位向量和正规化

把非单位向量转换成单位向量,这个转换叫作正规化(normalize).

一个经验法则就是,那些只关心方向的向量,你可以将它们正规化.如果你关心方向和长度,那么向量不应该正规化

标量乘积

点乘

问题举例:向量反射

叉乘

两个向量叉乘会得到第3个向量.给定两个向量,可以确定一个平面.叉乘得到的向量就会垂直于这个平面,称之为平面的法线

值得注意的是,技术上来讲,平面的垂直向量有两个:与c反方向的向量.所以你怎么知道叉乘结果向量的朝向结果取决于坐标系的手系

跟点乘一样,叉乘也有要注意的特殊情况.如果叉乘返回的3个分量都为0,意味着两个向量共线,也就是在一条直线上.两个共线的向量不能确定一个平面,这就是为什么无法返回该平面的法线.

问题举例:旋转一个2D角色

线性插值

线性插值能够计算两个值中间的数值.举个例子,如果a=0而且b=10,从a到b线性插值20%就是2.线性插值不仅作用在实数上,它能够作用在任意维度的值上.可以对点,向量,矩阵,四元数等数值进行插值.不管值的维度是什么,都能用一个公式表达:

Lerp(a,b,f)=(1-f)*a+f*b在公式中,a和b都是即将插值的变量,而f则是介于[0,1]的因子

在游戏中,插值的常见应用就是将两个顶点插值.假设有一个角色在a点,他需要平滑地移动到b点.Lerp通过f值从0增加到1,既可做到将a平滑过渡到b点

坐标系

矩阵

加法/减法

标量乘法

乘法

逆矩阵

转置

用矩阵变换3D向量

Lengyel,Eric.Mathematicsfor3DGameProgrammingandComputerGraphics(ThirdEdition).Boston:CourseTechnology,2012.这本书讨论了本章很多概念的细节,有完整的计算和证明.它也涵盖了超出本书范畴的更加复杂的数学,但是对于一些游戏程序员还是很有用的(特别是专注于图形学领域的程序员).

第4章3D图形

基础

由此,现代游戏不再需要开发实现软件光栅化了.而焦点则转变为将需要渲染的3D场景数据以正确的方式传递给显卡,一般都通过像OpenGL和DirectX这样的库完成.如果需要进一步自定义这个需求,可以编写一段运行在显卡上称之为着色器的小程序来应用传入的数据.在这些数据都传递完成之后,显卡就会将这些数据绘制在屏幕上.编写Bresenham画线算法的日子一去不复返

多边形

3D对象在计算机程序中有多种显示方法,在游戏中最广泛应用的就是通过多边形显示,更具体一点来说是三角形

为什么是三角形首先,它们是最简单的多边形,它们可以仅用3个顶点表示.第二点就是三角形总是在一个平面上,而多个顶点的多边形则有可能在多个平面上.最后,任何3D对象都k可以简单地用细分三角面表示,且不会l留下漏洞或者进行变形

单个模型,我们称为网格,是由多个三角片组成

一个坐标系空间有不同的参考系.比如说,在笛卡尔坐标系中,原点在世界的中间,所有坐标都相对于中心点.与之类似,还有很多坐标系有不同的原点.在3D渲染管线中,渲染3D模型到2D显示器,必须经历4个主要的坐标系空间

模型坐标系/局部坐标系

世界坐标系

视角坐标系/摄像机坐标系

投影坐标系

模型坐标系

当我们在建模的时候,比如像在Maya这样的软件里面,所有模型顶点的表示都是相对于模型原点的.模型坐标系就是那个相对于模型自身的坐标系.在模型坐标系中,原点通常就在模型中心,角色模型的原点在角色两脚之间.这是因为对象的中心点会更好处理

现在假设游戏场景中有100个不同的对象.如果游戏只是加载它们然后以模型坐标系绘制会发生什么由于所有对象都在模型空间创建,所有对象,包括玩家,都会在原点.相信这样的关卡会很无趣.为了让这个关卡加载正确,需要另一个坐标系

有一个新得坐标系称为世界坐标系.在世界坐标系中,所有对象都相对于世界的原点偏移

就像之前说过的,经常会有3D游戏使用4D向量.当4D坐标系应用在3D空间中时,它们被称为齐次坐标,而第4个分量被称为w分量

在大多数情况下,w分量要么是0,要么是1.如果w=0,表示这个齐次坐标是3D向量.而w=1,则表示齐次坐标是3D的点.但很容易让人疑惑的是,Vector4类同时用于表示向量和顶点.因此,通过命名规范来保持语义是很重要的

Vector4playerPosition//这是个点Vector4playerFacing//这是个向量

用于变换的矩阵通常是4x4矩阵.为了与4x4矩阵相乘,同时也需要4D向量.

矩阵变换就是矩阵用某种方法来影响向量或者顶点.矩阵变换使得我们可以将模型坐标系变换为世界坐标系

WorldTransform=ScalexRotationxTranslation

视角/摄像机坐标系

在所有对象放置到世界坐标系上正确的位置之后,下一件要考虑的事情就是摄像机的位置.一个场景或者关卡可以完全静止,但是如果摄像机的位置改变,就完全改变了屏幕上的显示.这个称为视角/摄像机坐标系

所以还需要另外一个矩阵告诉显卡如何将世界坐标系的模型变换到相对于摄像机的位置上.最常见的矩阵是观察矩阵.在观察矩阵当中,摄像机的位置通过3个轴的额外分量来表示

functionCreateLookAt(Vector3eye,Vector3target,Vector3Up){Vector3F=Normalize(target-eye)Vector3L=Normalize(CrossProduct(Up,F))Vector3U=CrossProduct(F,L)Vector3TT.x=-DotProduct(L,eye)T.y=-DotProduct(U,eye)T.z=-DotProduct(F,eye)//通过F,L,U和T创建并返回观察矩阵endViewCode投影坐标系

投影坐标系有时候也叫作屏幕坐标系,是一种将3D场景平铺到2D平面上得到的坐标系.一个3D场景可以通过多种方式平铺在2D平面上,两种最常见的方法分别是正交投影和透视投影

在正交投影中,整个世界挤在2D图像中,完全没有深度的感觉.就是说离摄像机远的对象与离摄像机近的对象在视觉上是一样的.任何纯2D的游戏都可以看作使用了正交投影

另一种常见的投影则是透视投影.在这种投影中,对象在摄像机中会显得近大远小.大部分3D游戏都采用这种投影

两种投影都有近平面和远平面.近平面是靠近摄像机的平面,而介于摄像机和近平面之间的物体不参与绘制.游戏中如果某个人物太过于靠近摄像机会突然消失,就是被近平面剔除的原因.与之类似,远平面就是远离摄像机的平面,任何物体超过这个平面就不参与绘制了.

正交投影矩阵由4个参数构成,视口的宽和高,还有远平面和近平面到眼睛的距离

透视投影则多了一个参数视场(FOV).就是摄像机的可见角度.视场决定了你能看到多少内容,加了视场之后就可以计算出透视矩阵.这个透视矩阵还有一样要考虑的事情.那就是当顶点与矩阵相乘之后,w分量不再是1.透视分割需要让每一个变换后的顶点分量除以w分量,使得w分量再一次为1.这过程真正使得透视变换具有了深度感

光照与着色

颜色

顶点属性

为了让模型有颜色,需要在顶点上存储额外的信息.这些信息被称为顶点属性,在大多数现代游戏中每个顶点都会带多个属性.当然,额外的内存负担会影响模型的顶点数

有很多参数可以做顶点属性.在纹理映射中,2D的图片可以映射到3D的三角形中.每个顶点上都有一个纹理坐标指定纹理的特定部分与之对应.最常见的纹理坐标系是UV坐标系,纹理上的x坐标称为u,而y坐标称为v

仅通过纹理,就可以让场景看起来充满色彩.但是会看起来不够真实,因为场景中没有真实的光照.大部分光照模型依赖于另一种顶点属性:顶点法线

要记住,一个三角形从技术角度来讲有两个法线,取决于叉乘的顺序.对于三角形来说,这个顺序取决于顶点序,可以是顺时针,也可以是逆时针.假设一个三角形的顶点A,B,C有顺时针顶点序,就是说从A到B到C的顺序是顺时针方向,而这些顶点的叉乘结果在右手坐标系中朝书页方向.如果A到B到C的顺序是逆时针方向,那么法线就朝书页外

顶点序的另一个作用就是用于渲染优化,称为背面剔除,就是说没有朝向摄像机的三角片不进行渲染

光照

一个没有光照的游戏看起来会容易单调枯燥,所以大多数3D游戏必须实现某种光照.3D游戏中使用的光照有好几种类型.一些光照会全局作用于整个场景,而一些光照只作用于光照范围内的区域

环境光就是一种添加到场景中每一个物体上的固定光照.因为环境光提供了一些光照,环境光对每个物体来说作用都一样.所以可以将环境光想象成多云天气的时候提供最基本的光照

方向光是一种没有位置的光,只指定光照的方向.跟环境光一样,方向光作用于整个场景.但是,由于方向光是带方向的,所以它们会照亮物体的正面,而背面则没有光照

点光源就是某个点向四面八方射出的光照.由于它们从某个点射出,点光源也只会照亮物体的正面.在大多数情况下,不希望点光源无限远.在小范围内有光,但是超了范围光照就马上衰减.点光源不会简单地一直向前.为了模拟这种情形,可以增加衰减半径来控制随着距离的增加光照衰减的方式

聚光灯跟点光源很像,除了点光源向所有方向发射,聚光灯只在椎体内有光.为了计算椎体范围,需要一个角度作为聚光灯的参数.跟点光源一样,聚光灯只会照亮物体的正面.一个聚光灯的经典例子就是聚光灯,另一个例子就是手电筒

Phong光照模型

Phong光照模型是一种局部光照模型,因为它不考虑光的二次反射.换句话说,每个物体都认为在场景中只有自己被渲染.在物理世界中,如果一个红光打到白色的墙上,红光会有少量反射到房屋里其他地方.但是在局部光照模型中是不会发生的

在Phong光照模型中,光被分为几种分量:环境光,漫反射和高光.这3种分量都会影响物体的颜色

环境光分量用于整体照亮场景.由于它均匀地作用域整个场景,所以环境光分量与光源的位置和摄像机的位置无关

漫反射分量是光源作用于物体表面的主要反射.它会被所有方向光,点光源和聚光灯影响.为了计算漫反射分量,你同时需要物体表面的法线和物体表面到光源方向的向量.但是跟环境光分量一样,漫反射同样也不被摄像机的位置影响

高光分量,表示物体表面上闪亮的高光.一些有强高光的物体,比如光滑的金属,会比涂上暗色涂料的物体光亮很多.类似漫反射分量,高光分量也同时取决于光源的位置和物体表面的法线.但它还取决于摄像机的位置,因为高光会随着视角方向变换而变换

//Vector3N=物体表面的法线//Vector3eye=摄像机的位置//Vector3pos=物体表面的位置//floata=高光量Vector3V=Normalize(eye-pos)//从物体表面到光源Vector3Phong=AmbientColorforeachLightlightinsceneiflightaffectssurfaceVector3L=Normalize(light.pos-pos)//从物体表面到光源Phong+=DiffuseColor*DotProduct(N,L)Vector3R=Normalize(Reflect(-L,N))//计算-L关于N的反射Phong+=SpecularColor*pow(DotProduct(R,V),a)endendViewCode着色

着色就是计算表面的三角片如何填充.最基础的着色类型是平面着色,就是整个三角片只有一种颜色.使用平面着色,只需每个三角片进行一次光照计算(通常在三角片的中心),然后把通过计算得到的颜色赋予整个三角片.这样做基本能实现着色,可是不好看

有一种稍微复杂一点的着色方法,我们称之为Gouraud着色.在这种着色方法中,光照模型的计算需要逐个顶点进行一次.这就使得每个顶点有不同的颜色.而三角片的剩余部分则通过顶点颜色插值填充.举个例子,如果一个顶点为红色,而另一个顶点为蓝色,两点之间的颜色是从红到蓝慢慢混合过渡的.

虽然Gouraud着色比较近似自然色了,但还是有不少问题.首先,着色的质量取决于模型的多边形数量.在低多边形模型上,着色结果会有不少有棱角的边,虽然Gouraud着色在高多边形模型上能达到不错的效果,但是会占用不少内存

另一个问题是高光用在低多边形模型上效果极差.而在低多边形模型上高光有可能会完全消失,因为只依据顶点来计算光照.虽然Gouraud着色流行了好几年,但是随着GPU性能的提升,就再也没人使用了

Phong着色是计算三角片上每个像素的光照模型.为了完成这种效果,顶点的法线需要在三角片表面进行插值,然后利用插值得到的法线进行光照计算

正如大家想象的那样,Phong着色的计算量比Gouraud着色昂贵得多,特别是在场景中有很多灯光的时候.但是,大多数现代硬件都可以轻松处理这部分额外的计算.Phong着色可以认为是逐像素光照,因为光照结果是针对每个像素进行单独计算的

有趣的是不管选用哪种着色方法,轮廓都是一样的.所以哪怕使用了Phong着色,低多边形对象的外轮廓还是很明显

可见性

在你了解了网格,矩阵,灯光,光照模型,着色方法之后,3D渲染里最重要的最后一点就是可见性判断.哪个对象可见,哪个不可见在这个问题上,3D游戏要比2D游戏复杂得多

再探画家算法

在第2章中讨论了画家算法(将场景从后往前绘制),它在2D游戏中很好用.这是因为2D精灵之间的顺序关系清晰,而且大多数2D引擎都内在支持层级的概念.对于3D游戏而言,这个顺序很少是静止的,因为摄像机的透视可以改变

这意味着在3D场景中使用画家算法的话,所有场景中的三角片都必须排序,可能每一帧都需要排序,摄像机在场景中移动一次就得排一次序.如果场景中有10000个对象,那么依据场景中的深度进行排序的计算会非常昂贵

这样看上去画家算法非常低效,但是还有更糟的.考虑同屏显示多个视角的分屏游戏.如果玩家A和玩家B面对面,两个玩家从后往前的顺序是完全不同的.为了解决这个问题,不仅要每帧排序两次,而且内存中还要有两个排序队列,两个都不是好的解决方案

另一个画家算法的问题就是会引起大量的重绘,有的像素每帧会绘制多次.如果你想做第2章的太空场景,一些像素每帧都会绘制很多次:一个是星星区域,一个是太阳,一个是小行星,一个是太空船

在现代3D游戏中,计算最终光照和贴图的过程是渲染管线最昂贵的部分.像素重绘意味着前一次绘制的花费被浪费了.因此,大多数游戏都尽力避免重绘的发生.而在画家算法中这是不可能完成的任务

而且,三角片重叠的时候也有问题.图中的3个三角片.哪个在前,哪个在后

答案就是没有一个三角片在最后.在这种情况下,画家算法唯一的方法就是将这个三角片按照正确的方式切割成更小的三角片

由于以上各种问题,画家算法在3D游戏中非常少用

深度缓冲区

在深度缓冲区中,会有一块额外的缓冲区仅在渲染的过程作用中使用.这块额外的缓冲区称为深度缓冲区,为场景中的每个像素存储数据,就像颜色缓冲一样.但是跟颜色缓冲区存储颜色不同,深度缓冲区存储的是像素到摄像机的距离(或深度).更准确地讲,每帧用到的缓冲区(包括颜色缓冲区,深度缓冲区,模板缓冲区等)统称为帧缓冲

在使用深度缓冲区的每一帧渲染开始之前,深度缓冲区都会清空,确保当前深度缓冲区中的所有像素都无限远.然后,在渲染的过程中,像素的深度会在绘制之前先计算出来.如果该像素的深度比当前深度缓冲区存储的深度小,那么这个像素就进行绘制,然后新的深度值会写入深度缓冲区.所以每帧第一个绘制的对象总是能够将它的所有颜色和深度分别写入颜色缓冲区和深度缓冲区.但是当绘制第二个对象的时候,那些比已有像素更远的像素都不会进行绘制.

但也不是说深度缓冲可以解决所有可见性问题.比如说,透明对象在深度缓冲中就不太使用.假设有一滩半透明的水,水底有石头.如果使用纯深度缓冲的方法先画水体,那么水体会写入深度缓冲,这样就会影响石头的绘制.为了解决这个问题,应用深度缓冲先画所有不透明的物体.然后可以关掉深度缓冲写入功能,渲染所有透明物体.但为了确保在不透明物体的背后的对象不进行渲染仍需进行深度检查

像颜色一样,深度缓冲的表示方法也有固定的几种.最小的深度缓冲区为16位,可是在节省内存的同时也带来了一些副作用.在深度值冲突的时候,两个来自不同对象的像素挨得非常近,而且离摄像机很远,会产生每帧交替出现前后像素切换得情况.16位浮点数j精度不够高导致在第1帧时像素A比像素B得深度要低,而在第2帧比像素B深度要大.为了避免这种情况,大多数现代游戏都会采用24位或者32位的深度缓冲区

还有很重要的一点是,仅靠深度缓冲并不能解决像素重绘的问题.如果你先画了一个像素,然后才发现该像素不该画,那么前一个像素的渲染就纯属浪费.一个解决方案就是采用先进行深度的pass(一帧画面可以多次渲染,每次为一个pass,如果某个pass什么颜色都没有输出,只输出深度,那么它就是这里说的先进行深度的pass).将深度计算与着色分开,在最终的光照计算pass之前完成深度计算.

深度缓冲的检查是基于像素的,这一点要铭记于心.举个例子,如果有一棵树完全被建筑遮挡,深度缓冲还是会对这棵树的每个像素进行测试.为了解决这类问题,商业游戏经常使用复杂的剔除或者遮挡算法去消除那些在某些帧完全看不到的对象.类似的算法有二叉树分区算法(BSP),入口算法和遮挡体积

再探世界变换

将旋转存储成欧拉角会有个大问题,主要是因为它们不够灵活.假设有一艘太空飞船在模型坐标系中头部朝向z轴,想将它旋转到朝任意点P.为了在欧拉角中完成这个旋转,你需要判断这个旋转的角度.可是这个旋转不只影响一个轴,需要多个轴配合旋转才能完成.这就导致计算比较困难

另一个问题就是,如果欧拉角关于某个轴旋转90,同时也会影响到原来的轴的朝向.举个例子,如果一个对象关于z轴旋转90,那么x轴,y轴会重叠,原有角度关系就会丢失.这就称为万向锁

最后一个是关于将两个朝向进行插值的问题.假设游戏有指向下一个目标的箭头,一旦这个目标达成,这个箭头就应该转向下一个节点.由于视觉原因,箭头不应该突然就指向新节点,它应该个一两秒的过渡,虽然通过欧拉角可以完成,但很难使得整个插值好看

由于种种限制,用欧拉角表示世界空间的旋转不是一个好方法.可以采用其他的数学表示方式来做这件事

四元数

对于游戏程序员而言,只要认为四元数表示关于任意轴旋转就可以了.因此有了四元数,再也不是受限于关于某个轴旋转,而是关于任意你想要的轴旋转

四元数的另外一个优点就是很容易在两个四元数之间进行插值.有两种插值方式:线性插值和球形插值.球形插值比起线性插值更加准确,同时计算上也可能更加昂贵,取决于系统.

四元数只需要用4个浮点数来存储信息,这样更加节省内存.所以就像位置和统一缩放可以分别存储为3D向量和浮点标量一样,物体的旋转也可以存储为四元数

要注意的是旋转的轴是必须正规化的.如果没有这样做,你可能会发现物体以奇怪的方式缩放.如果你刚开始使用四元数,物体以奇怪的方式拉扯,很可能就是因为四元数没有以正规化的轴来创建

与矩阵一样,四元数也存在逆四元数.幸运的是,计算标准四元数的逆只需要将分量取逆即可.将向量部分取反也称为共轭四元数.

3D游戏对象的表示

Akenine-Moller,Tomas,et.al.Real-TimeRendering(3rdEdition).Wellesley:AKPeters,2008.这是一本图形渲染的资料大全.虽然最新的版本也是几年前出版的,但是它始终是迄今为止对图形程序员而言最佳的渲染读物

第5章游戏输入

输入设备

绝大多数的输入形式可以分成两种:数字与模拟.数字形式的输入就是那些只有两种状态的"按下"和"没有按".举个例子,键盘上的按键是数字的,空格键要么按下要么没按下.绝大多数的键盘不存在两种状态的中间状态.模拟形式的输入就是设备可以返回某个数字的范围.一个常见的模拟设备就是摇杆,它会总是返回二维空间的范围值

输入系统在其他方面要考虑的是游戏中对同时按键和序列按键的支持.这种需求在格斗游戏中很流行,玩家会经常同时使用按键和序列按键来搓招.处理同时按键和序列按键超出了本章的主题,但是可以通过状态机的方式来识别玩家的各种输入

数字输入

enumKeyStateStillReleased,JustPressed,JustReleased,StillPressedendfunctionUpdateKeyboard()//lastState和currentState是在其他地方定义好的数组//记录了完整的键盘信息lastState=currentStatecurrentState=getkeyboardstateendfunctionGetKeyState(intkeyCode)iflastState[keyCode]==trueifcurrentState[keyCode]==truereturnStillPressedelsereturnJustReleasedendelseifcurrentState[keyCode]==truereturnJustPressedelsereturnStillReleasedendendViewCode模拟输入

因为模拟设备有一组范围值,有输入偏差是很常见的.假设一个摇杆有x值和y值用16位有符号整形表示.这意味着x和y都可以表示-32768到+32768范围的值.如果一个摇杆放在平面上,玩家不去碰它,理论上由于摇杆没有被控制,这个x和y应该都为0.但是,实际上该值会在0的附近

由于这个原因,很少会让模拟输入直接应用到角色的移动上,这会让角色永远停不下来.这意味着即使你放下手柄,角色还会在屏幕上到处乱走.为了解决这个问题,大多数游戏都会采用某种模拟输入过滤的方式,用于消除输入的偏差值

一个简单的无效区域实现就是在x和y值接近0的时候直接设置为0.如果值的范围在+/-32K,也就是无效区域范围大约10%的区间

intdeadZone=3000Vector2joy=getjoystickinputifjoy.x>=-deadZone&&joy.x<=deadZonejoy.x=0endifjoy.y>=-deadZone&&joy.y<=deadZonejoy.y=0endViewCode但是这个方法有两个问题.首先无效区域是一个正方形而不是圆形.也就是说,如果摇杆的x和y都稍微比无效区域小一点,角色还是不会移动,哪怕实际上已经超过了10%的阈值

另一个问题就是没有完全利用所有有效范围值.从10%到100%都有速度,但是小于10%就什么都没有了.我们采用了一种解决方案,使得0%到100%都有速度.换句话说,我们希望有效输入映射到0%的速度,而不是映射到10%.这样会将原来的10%到100%的中值55%映射到50%

为了解决这个问题,我们不应该将x和y看作独立的分量,而是将摇杆的输入视为一个2D向量.然后就可以通过向量运算来完成无效区域的过滤

floatdeadZone=3000floatmaxValue=32677Vector2joy=getjoystickinputfloatlength=joy.length()//如果长度小于无效区域,那么认为没有输入iflength

想象你开车带小孩去儿童乐园.小朋友非常兴奋,所以每分钟都问一次"我们到了吗".她不断地问直到你最终到达目的地,这个问题的答案才为"是".现在想象不止一个小孩,你载着一车小孩去儿童乐园,所以每个小孩都来不断地问这个问题.这不仅影响驾驶,还很浪费精力

这个场景本质上是个轮询系统,每个小孩都来轮询结果.对于输入系统来说,很多代码都想轮询输入,比如主动去检查空格键是否"刚刚按下".每一帧的多个地方都不断用GetKeyState查询K_SPACE.这不仅导致需要多写代码,而且还会容易产生更多的Bug

我们回到儿童乐园的例子,想象你再次面对一车的孩子.比起让孩子不断地重复提问,不如雇佣一个事件机制或者推送系统.在基于事件的系统中,小孩需要注册它们关心的事件(这个例子中,关心的事件就是到达儿童乐园),然后会在事件发生的时候通知到他们.现在就可以安静地开车到儿童公园了,只要你到达之后通知所有小孩已经达到就可以了

输入系统同样也可以设计为基于事件的.可以设计一个接受特定输入事件的系统.当事件发生的时候,系统就通知所有已经注册的代码.所以所有需要知道空格状态的系统都可以注册关于空格键"刚刚按下"的事件,它们会在事件发生后马上收到通知

要注意的是,基于事件的输入系统还是要轮询输入的.就好像你作为一个司机,必须在行车的过程中时刻了解是否达到目的地,然后通知孩子们一样.输入系统要不断地轮询空格键,这样就可以正确地发出通知.与之前不同的地方就是,现在我们只有一个地方进行轮询

基础事件系统

classMouseManagerListfunctions//接受那些将传递的参数(int,int)作为信号的函数functionRegisterToMouseClick(functionhandler(int,int))functions.Add(handler)endfunctionUpdate(floatdeltaTime)boolmouseClicked=falseintmouseX=0,mouseY=0//轮询鼠标点击...ifmouseClickedforeachfunctionfinfunctionsf(mosueX,mouseY)endendendend//为鼠标点击注册myFunctionMouseManager.RegisterToMouseClick(myFunction)ViewCode一个更复杂的系统

//KeyState枚举(JustPressed,JustReleased等)...//GetKeyState...//BindInfo就是字典中的值structBindInfointkeyCodeKeyStatestateTypeendclassInputManager//存储了所有绑定MapkeyBindings//只存储那些激活的绑定MapactiveBindings//使按键绑定更加方便functionAddBinding(stringname,intcode,KeyStatetype)keyBindings.Add(name,BindInfo(code,type))end//初始化所有绑定functionInitializeBindings()//可以从文件解析//然后调用AddBinding进行绑定//比如说,"Fire"可以映射到刚刚释放的回车键//AddBinding("Fire",K_ENTER,JustReleased)...endfunctionUpdate(floatdeltaTime)//清除所有上一帧中的激活绑定activeBindings.Clear()//KeyValuePair有key和value成员,分别是来自字典键和值foreachKeyValuePairkinkeyBindings//使用前面定义的GetKeyState得到某个按键的状态ifGetKeyState(k.value.keyCode)==k.value.stateType//如果与绑定一致,则认为绑定是被激活的activeBindings.Add(k.key,k.value)endend//如果有任何的激活绑定,那么优先发送给UI系统ifactiveBindings.Count()!=0//发送激活绑定给UI...//发送激活绑定给游戏的剩余部分...endendendViewCode移动设备输入

触屏和手势

大多数移动设备都支持多点触摸,就是说允许用户同时多个手指进行操作

一些游戏通过多点触摸实现虚拟手柄,这会有一个虚拟摇杆和虚拟按键让玩家交互.这个做法在家用机版本的游戏上很流行,但有时候会让玩家很困惑,因为没有真实手柄的反馈

有一些操作只有触摸屏才能实现,称之为手势操作,就是一系列的触摸引发的行动.一个很流行的手势操作就是iOS设备上的"两指缩放",用户可以用食指和拇指同时靠近来进行缩小,同时远离进行放大

检测手势有很多种方法,其中一种流行的算法就是Rubine算法,它在DeanRubine在1991年的论文"Specifyinggesturesbyexample."中提出.虽然Rubine算法是由笔画识别发展出来的,但是不管是单个手指的手势识别还是多个手指的手势识别,它都可以实现

加速器和陀螺仪

其他移动设备输入

Rubine,Dean."Specifyinggestruesbyexample."InSIGGRAPH'91:Proceedingsofthe18thannualconferenceoncomputergraphicsandinteractivetechniques.(1991):329-337.这是Rubine手势识别算法的原版论文.

第6章声音

基本声音

原始数据

声音事件

声音事件映射了一个或者多个原始数据文件.声音事件事实上是由代码触发的.所以比起直接播放fs1.wav文件,可能调用一个"footstep"的声音事件更好.这个想法就是声音可以包含多个声音文件还能有元数据,将这些声音文件作为整体

举个例子,假设有一个爆炸声音事件.这个事件应该随机选择5个WAV文件中的一个来播放.另外地,由于爆炸是可以在远处就听见的,所以应该有能听见的距离的元数据.而且爆炸声音事件应该具有高优先级,这样即使所有频道都用完了,爆炸还是能播放

游戏中的元数据有多种实现方式.一个方法是使用JSON文件格式,爆炸声音事件大概会像下面这样

{"name":“explosion","falloff":150,"priority":10,"sources":["explosion1.wav","explosion2.wav","explosion3.wav"]}ViewCode在任何情况下,在解析数据的时候,都能够直接将数据映射到声音事件类:

classSoundCuestringnameintfalloffintpriority//所有源文件的字符串链表Listsources;functionPlay()//随机选择一个源文件来播放..endendViewCode之前提到的系统可能很多游戏已经够用了,但是对于脚步声的例子来说就可能不够.如果角色可以在不同的路面行走----石头,沙地,草地等,声音需要根据当前的地面来判定.在这种情况下,系统需要一些方法知道当前地面所属声音的分类,然后从中随机选择来播放.换句话来说,系统需要一些方法根据当前地面而切换不同的音频集合

{"name":"footstep","falloff":25,"priority":"low","switch_name":"foot_surface","sources":[{"switch":"sand","sources":["fs_sand1.wav","fs_sand2.wav","fs_sand3.wav"]},{"switch":"grass","sources":["fs_grass1.wav","fs_grass2.wav","fs_grass3.wav"]}]}ViewCode我们可以在原有的SoundCue类上增加功能.一个更好的方案是提取出ISoundCue接口,然后实现SoundCue类和新的SwitchalbeSoundCue类:

interfaceISoundCuefunctionPlay()endclassSwitchableSoundCueimplementsISoundCuestringnameintfalloffintprioritystringswitch_name//存储(string,List)键值对的哈希表//比如说("sand",["fs_sand1.wav","fs_sand2.wav","fs_sand3.wav"])HasMapsourcesfunctionPlay()//得到当前值赋值到switch_name//然后在哈希表中查找链表,然后随机播放一个...endendViewCode为了让这个实现顺利工作,我们需要以全局的方式获取和设置切换.通过这样的方法,角色跑动的代码就可以判断地面然后切换到相应的脚步声.然后脚步声的声音事件Play函数就可以知道当前所切换的值,然后播放正确的脚步声

最终,实现声音事件系统的关键就是有足够的配置信息去判断播放什么和怎么播放.有足够多的变量数量能够让音频设计师得到更多的灵活性从而更好地设计真实的交互

3D声音

虽然不是绝对,但是大多数2D音效都是位置无关的.这意味着对于大多数2D游戏,声音的输出在左右两个喇叭都是一样的.有些游戏也会考虑音源的位置,比如音量随着距离衰减,但还是少数2D游戏才这么做

对于3D音效和3D游戏来说,音源的位置就特别重要.大多数音效都有自己独特的随着监听者距离增大衰减的方式,一个例子就是游戏中的虚拟麦克风能听到附近的音效

但也不是说3D游戏不使用2D音效.一些要素比如用户界面,解说,背景音等还在使用2D音效.但是出现在游戏世界中的音效通常都是3D音效

监听者和发射者

不管监听者怎么监听游戏世界中的音效,发射者就是发射特定音效的物体.比如说,如果有一堆柴火发出噼里啪啦的声音,就会有一个声音发射器放在那个位置然后放出噼里啪啦的声音.然后基于监听者和火柴声的发射者之间的距离就可以算出音量的大小.发射者相对于监听者的朝向决定了哪个喇叭有声音

衰减

衰减描述了音效的音量随着远离监听者会如何减小.可以用任何可能的函数去表达衰减.但是,由于音量的单位分贝是一个对数刻度,线性衰减会产生对数变换关系.这种线性分贝衰减函数通常都是默认方法,但是显然不是唯一的方法

就像点光源一样,可以增加更多的参数.我们可以设定在内半径之内衰减函数不起组用,而外半径之外就完全听不到声音.这样的声音系统允许音频设计师创建多种衰减函数来展现出随着不同距离有不同的音效

环绕声

不少平台都不支持环绕声的概念----大多数移动设备最多只支持立体声.但是,PC和家用机游戏是可以有两个以上喇叭的.在5.1环绕系统中,会有总共5个正式的喇叭和1个用于表现低频效果的低音炮

传统的5.1配置是放置3个喇叭在前面,两个在后面.前面的喇叭放置在左,中,右.而后面的喇叭只有左,右.低音炮的位置不太重要,不过放在室内角落会让低频效果更好

数字信号处理

广义上讲,数字信号处理(DSP)是计算机中表示的信号.在音频领域中,数字信号处理说的是加载音频文件然后在修改之后得到不同的效果.举个数字信号处理相对简单的例子,加载音频文件然后增加或者减小它的音高

看起来没必要在运行时进行处理,先离线处理好这些效果,然后在游戏中播放也许会更好.但是运行时使用数字信号处理理由就是它能够节省大量内存.假设有一款剑击游戏,有20种不同的音效在武器相互碰撞的时候发出.这些音效听起来就像在野外开阔的场地发出.现在想象一下,如果游戏有多种场地会发出声音,除了野外之外,还有洞穴,大教堂,以及大量的其他地方

问题在于,在洞穴中发出的声音和在野外发出的声音听起来完全不一样.特别是当刀剑在洞穴中发生碰撞时,会有很大的回声.不用数字信号处理效果,唯一的方法就是为二十多种刀剑声再针对不同的场地配置.如果有5种不同的场景,就意味着有5倍的增长,从而有一百多种的刀剑音效.现在如果需要所有战斗音效,而不仅仅是刀剑音效,游戏内存很快就被用尽.但是如果有了数字信号处理效果,同样的20个刀剑音效会用在不同的地方,它们只要根据场所调整成相应的音效即可

常见数字信号处理效果

另一个大量使用的数字信号处理效果就是音高偏移,特别是多普勒偏移.音高偏移会通过调整频率增加或者减小音效的音高.虽然多普勒偏移会很常用.但赛车游戏中引擎的音高会随着速度的变化而变化

游戏中大多数的数字信号处理效果通常都会修改频率的范围或者输出分贝的级别.举个例子,一个压缩机缩小了音量的范围,导致很小的声音得到了增强,同时很大的声音得到了减小.通常用于统一多个音频文件,让它们保持相似的范围

游戏中还有很多其他效果,但以上4种是游戏中最常见的

区域标记

有一些效果很少会在整个关卡一直使用,比如说回响效果.通常只有关卡的一些区域才需要使用回响效果.比如说,如果一关里有野外区域和洞穴,回响效果可能只有在洞穴才会有.有很多种方法可以添加区域,最简单的方式就是在地面上标记凸多边形

其他声音话题

多普勒效应

如果你站在街上,同时一辆警车打开警报器向你靠近,音高会随着警车的靠近而提高.相对地,在警车远离之后,声音的音高也会降低.

在游戏中,动态多普勒效应只会在告诉移动的对象身上应用,比如汽车.技术上来讲,也可以在子弹上应用,但是由于它们太快,我们通常只播子弹飞走的声音.由于多普勒效应会让音高增加或者降低,只有在支持数字信号处理效果处理音高的时候才能用

声音遮挡

Boulanger,RichardandVictorLazzarini,Eds.TheAudioProgrammingBook.Boston:MITPress,2010.这本书是数字信号处理领域的著作,涵盖了大量的示例代码

Lane,John.DSPFilterCookbook.Stamford:CengageLearning,2000.这本书比上一本高级,它实现了很多特殊效果,包括压缩机和低通道滤波

第7章物理

平面,射线和线段

平面

P是平面上任意一点,n是平面法线,d是平面到原点的最小距离

回忆一下,三角形是可以确保在一个平面上.上面的平面表达式会采用的一个原因就是,给定一个三角形,很容易根据三角形构造出该平面,.在我们计算出n和d之后,可以测试如果任意点P,也同样满足等式,那么P也在平面上

假设我们有ABC以顺时针顶点序排列.为了得到三角形所在平面的表达式,我们需要先计算三角形的法线

不要忘记了,叉乘的顺序是很关键的,根据顶点序进行叉乘很重要.由于ABC顶点序以顺时针排列.我们希望构造向量从A到B和从B到C.在我们有了两个向量之后,我们对向量进行叉乘,然后对结果进行正规化得到n.

幸运的是,我们已经知道三角形上的3个点都在平面上,为A,B和C.我们可以将这些点与n点乘,就会得到d的值

平面上的所有顶点都会得到相同的d值,那是因为这是一个投影:n是正规化过的,P是未正规化过的,所以我们会得到平面到原点在n方向上的最小距离.这个最小距离不管你采用哪个顶点都一样,因为它们都在一个平面上

在得到n和d之后,我们可以将这些值存储在我们的Plane数据结构体内:

structPlaneVecotr3normalfloatdendViewCode射线和线段

线段与射线类似,除了既有起点又有终点之外.我们可以使用完全相同的参数方程来表示线段.唯一不同的地方就是现在t有了上限,因为线段必须有一个终点

技术上来讲,光线投射就是射出一条射线,然后检查是否打到某个对象.但是,大多数物理引擎都会由于实际上使用的是线段做检测的方法让人迷惑,包括Havok和Box2D.这么做的原因是游戏世界通常会有一定的约束,所以使用线段更加合理.我承认这个术语会让人困惑.但是,这是游戏产业的惯用术语,我想保持这个术语的一致性.所以请记得本书中的光线投射实际上使用的都是线段

structRayCastVector3startPointVector3endPointendViewCode将startPoint和endPoint转换成线段参数方程的形式相当简单.R0就是startPoint,v就是endPoint-startPoint.当以这种方式转换之后,t的值从0到1会对应到光线投射.也就是说,t为0就在startPoint,而t为1则在endPoint

碰撞几何体

值得一提的是,游戏对象拥有多个不同级别的碰撞几何体也是很常见的.这样,简单的碰撞体可以先进行第一轮碰撞检测.在简单的碰撞体发生了碰撞之后,再选择更精细的碰撞体进一步检测碰撞

包围球

最简单的碰撞体就是包围球(在2D游戏中则是包围圈).一个球体可以通过两个变量定义----向量表示球体的中心点,标量表示球体的半径

轴对齐包围盒

对于2D游戏来说,一个轴对齐包围盒(AABB,axis-alignedboundingbox)就是一个每条边都平行于x轴或者y轴的矩形.类似地,在3D游戏中,AABB就是长方体,而且每条棱都与对应的轴平行.不管2D还是3D,AABB都可以用两个点表示:最大点和最小点.在2D中,最小点就是左下角的点,而最大点则是右上角的点

classAABB2DVector2minVector2maxendViewCode由于AABB必须与对应的轴平行,如果一个对象旋转,那么AABB就需要拉长.但是对于3D游戏来说,人形角色通常只绕向上的轴旋转,这种旋转并不会让AABB有太多的变化.因此,使用AABB作为人形角色的包围体是很常见的,特别是AABB和球体之间的碰撞计算量很小

朝向包围盒

一个朝向包围盒(或者OBB)类似于轴对齐包围盒,只是不再要求与轴平行.就是说,这是一个长方形(2D)或者长方体(3D),而且每条轴不再需要与对应的坐标轴平行.OBB的优点就是可以随着游戏对象旋转,因此不管游戏对象的朝向如何,OBB的精准度都很高.但是这个精准度的提升是有代价的,与AABB相比,OBB的计算花费高太多了.

胶囊体

在2D游戏中,胶囊体可以看作是一个AABB加上两端各一个半圆.之所以叫胶囊体是因为看上去就跟药物一样.如果我们把胶囊体扩展到3D,就会变成一个圆柱加上两端各一个半球.胶囊体在人形角色的碰撞体表示中是很流行的,因为他们比AABB精准一些.胶囊体还以看作带半径的线段,在游戏引擎中就是这么表示的

structCapsule2DVector2startPointVector2endPointfloatradiusendViewCode凸多边形

另一个碰撞几何体表示的选择就是使用凸多边形(在3D领域称之为凸包).与你所想的差不多,凸多边形比其他方式效率都要低,但是比它们都精准

组合碰撞集合体

最后一个增加精准度的选择就是使用组合碰撞几何体进行碰撞检测.在人形的例子中,我们以在头部使用球形,身干用AABB,凸多边形用于手脚等.通过不同的碰撞几何体组合,我们几乎可以消灭漏报

虽然检测碰撞几何体组合比检测模型的三角片要快,但还是慢得让你不想用.在人形的例子中,应该先用AABB或者胶囊体进行第一轮碰撞检测.然后通过之后再进行更精确的测试,比如组合碰撞几何体.这种方法取决于你是否需要将精准度分级别.在检测子弹是否打中角色的时候会用到,但是阻挡玩家走进墙里就没必要了

碰撞检测

球与球的交叉

如果两个球得半径之和小于两个球之间的距离,那么就发生了交叉,但是,计算距离会用到平方根,为了避免平方根的比较,通常都会使用距离的平方与半径之和的平方进行比较

functionSphereIntersection(BoundingSpherea,BoundingSphereb){//构造两个中心点的向量,然后求长度的平方Vector3centerVector=b.center-a.center;//回忆一下,v的长度平方等于v点乘vfloatdistSquared=DotProduct(centerVector,centerVector);//distSquared是否小于等于半径和的平方ifdistSquared<((a.radius+b.radius)*(a.radius+b.radius))returntrueelsereturnfalseendendViewCodeAABB与AABB交叉

如同球体交叉一样,AABB的交叉计算即使在3D游戏中也是很廉价的

functionAABBIntersection(AABB2Da,AABB2Db){booltest=(a.max.x

回忆一下线段,起点则对应于t=0,而终点则对应于t=1.所以当我们解出t时,如果t的值在这个范围外,那么可以忽略它.特别的是,负值表示线段朝向远离平面的方向.

同样值得注意的是,如果v与n点乘结果为0,会产生除0异常.回想一下,如果两个向量点乘结果为0,意味着两个向量垂直.在这种情况下,表示v与平面平行,因此不会有交叉,唯一交叉的情况就是线段就在平面上.在任何情况下,除0异常是必须要考虑的.还有一点就是,如果线段与平面真的相交,我们可以将t替换为交点的值

//返回值就是这个结构体structLSPlaneReturnboolintersectsVector3pointend//记住光线投射实际上就是线段functionLSPlaneIntersection(RayCastr,Planep){LSPlaneReturnretValretVal.intersects=false//计算线段方程的vVector3v=r.endPoint-r.startPoint//检查线段是否与平面平行floatvDotn=DotProduct(v,p.normal)ifvDotnisnotapproximately0t=-1*(DotProduct(r.startPoint,p.normal)+p.d)t/=vDotn//t应该介于起点与终点(0到1)之间ift>=0&&t<=1retVal.intersects=true//结算交点retVal.point=r.startPoint+v*tendelse//测试起点是否在平面上...endreturnretValendViewCode线段与三角片交叉

假设你需要算出用线段表示的子弹与某个三角片之间是否发生碰撞.第一步就是算出三角片所在的平面.在有了这个平面之后,你可以看看这个平面是否与线段相交.如果它们相交,你就会得到与三角形所在平面相交的交点.最后,由于平面是无限大的,我们要检测该点是否在三角片之内

如果三角形ABC以顺时针顶点序表达,第一步就是构造一个从A到B的向量.然后构造一个向量从A到P,P就是交点的位置.如果旋转向量AB到AP是顺时针,P就在三角形一侧.这个检测对每一条边(BC和CA)进行计算,如果每个都是顺时针,也就是每条边算出P都在三角形一侧,就可以得出结论说P就在三角形内部

但是我们怎么判断是顺时针还是逆时针如果我们在右手坐标系中计算ABxAP,得到的向量会朝向书页内.回想一下右手坐标系中顺时针序三角形的法线也是朝向书页内部的.这种情况下,叉乘向量的方向就与三角形法线方向一致.如果两个正规化过的向量点乘值为正值,它们朝向大致一致.所以如果你将ABxAP的结果正规化后再与法线点乘结果为正数,那么P就在AB所在的三角形一侧

这个算法可以用于三角形的其他边.它看起来不仅能够作用于三角形,对于任意在同一平面上的多边形也同样适合

//这个函数只能在顶点为顺时针顶点序及共面下正常工作functionPointInPolygon(Vector3[]verts,intnumSides,Vector3point){//计算多边形的法线Vector3normal=CrossProduct(Vector3(verts[1]-verts[0]),Vector3(verts[2]-verts[1]))normal.Normalize();//临时变量Vector3side,to,cross;forinti=1,i

在游戏中球可以与墙发生碰撞,为了对这个碰撞准确建模,可以使用球与平面的交叉.给定平面的n和d,碰撞检测最简单的方法就是建立一个新得平面,对齐球心并且与原有平面平行.如果两个平面距离比球的半径要小,那么就发生了交叉.就像球与球的交叉一样,球与平面的交叉也不复杂

functionSpherePlaneIntersection(BoundingSpheres,Planep)//通过平面的法线p.normal及平面的点s.center计算平面的dfloatdSphere=-DotProduct(p.normal,s.center)//检查是否在范围之内return(abs(d-dSphere)

到目前为止,我们讲了即时碰撞检测算法.就是说那些算法只能检查当前帧中发生的碰撞.虽然很多情况下都有效,但是也有很多不适用的时候

如果子弹朝着纸张发射,不存在子弹与纸张交错在一起的准确的一帧.这是因为子弹速度很快,而纸张很薄.这个问题通常被称为子弹穿过纸张问题.为了解决这个问题,能够进行连续碰撞检查(CCD)的能力是必要的

在球形扫掠体检测中,有两个移动中的球体.而输入则是两个球在上一帧的位置(t=0)和这一帧的位置(t=1).给定这些数据,我们可以判断两帧之间两个球是否发生了碰撞

所以不像即时碰撞检测的球与球交叉那样,它是不会因为不同帧而丢失交叉

你可能注意到,球形扫掠体看上去和胶囊体差不多.那是因为球形扫掠体确实就是胶囊体.球形扫掠体有起点,终点及半径,完全就是一个胶囊体.所以胶囊体与胶囊体的碰撞完全可以在这里使用

如同线段与平面交叉问题一样,先解等式会对我们有帮助.而且解决胶囊体的碰撞本身也是一个非常流行的问题,所以经常会在面试的时候被问到.由于它涉及很多游戏程序员需要掌握的线性代数概念

如果判别式小于0,t就没有实根,就是说没有交叉发生.如果判别式等于0,意味着两个球相切.如果判别式大于0,意味着两个球完全交叉在一起,两个根比较小的就是最早发生交叉的时候

在我们解出t的值以后,我们可以看一下如果值介于0和1之间,编码的时候就要注意了.记住t值如果大于1则是这一帧之后,如果小于0则是这一帧之前.因此,t值超出范围的情况不是这个函数接受的范围

//p0/q0是上一帧的球体//p1/q1是这一帧的球体functionSweptSphere(BoundingSpherep0,BoundingSphereq0,BoundingSpherep1,BoundingSphereq1){//首先计算v用于参数方程Vector3vp=p1.center-p0.centerVector3vq=q1.center-q0.center//计算A和B//A=P0-Q0Vector3A=p0.center-q0.center//B=vp-vqVector3B=vp-vq//计算a,b和c//a=BdotBfloata=DotProduct(B,B)//b=2(AdotB)floatb=2*(DotProduct(A,B))//c=(AdotA)-(rp+rq)*(rp+rq)floatc=DotProduct(A,A)-((q0.radius+p0.radius)*(q0.radius+p0.radius))//现在计算判别式(b*b-4ac)floatdisc=b*b-4*a*cifdisc>=0//如果我们需要t的值,我们可以用以下的方式解出//t=(-b-sqrt(disc))/(2a)//但是,这个函数只要回答真就可以了returntrueelse//没有实数解,所以没有交叉发生returnfalseendendViewCode响应碰撞

我们可以使用前面提到的各种算法来检测碰撞.但是在检测结果出来之前,游戏应该如何处理呢这就是响应碰撞的问题.一些情况下,响应会很简单:一个或多个对象可能会死亡然后从游戏世界中移除.稍微复杂一点的响应就是一些比如火简这样的物体会减少敌人的生命值

但是如果两个对象需要相互弹开呢比如两个小行星碰撞.一个简单的解决方法就是根据碰撞的方向让速度反向.但这么做会有很多问题.一个问题就是行星会被卡住.假设两个行星在某一帧发生碰撞,那么就会引起速度取反.但是如果它们速度很慢,导致下一帧还继续碰撞呢那么速度就会无限地循环变化下去,然后就会卡住

但是,这里的碰撞响应还是有一个大问题:将速度取反不是一个正确的行为.我们可以看一下为什么,假设你有两个行星朝着同一个方向运动,一个在另一个前面.前面的行星比后面的行星速度要慢一些,所以最终后面的行星会跟上前面的行星.就是说当两个行星碰撞的时候,它们会徒然朝另一个方向运动,这肯定是不对的

所以比起将速度取反,我们实际上是想根据发生碰撞的平面的法线将速度进行反射.如果行星与墙面碰撞,计算碰撞的墙面是很简单的,墙面就是我们要的平面.但是两个球在某个点碰撞的例子,我们所要的平面就是碰撞点的切线平面

为了构造切线平面,我们首先要得到发生碰撞的点.这个可以用线性插值算出.如果有两个球体在某个点发生碰撞,这个点肯定就在两个球心所连成的线段上.它所在的位置就取决于两个球的半径.如果我们有两个BoundingSphere的实例A和B在某个点发生碰撞,这个点可以通过线性插值计算出来Vector3pointOfIntersection=Lerp(A.position,B.position,A.radius/(A.radius+B.radius))

而找出切线平面很简单,就是一个球心指向另一个球心的向量,然后正规化.有了平面上的点和平面的法线,我们就可以创建在这个碰撞点上的切线平面了.虽然碰撞响应需要对速度进行反射,但是我们只要有平面的法线就可以了

有了这个反射之后的速度,行星碰撞看上去好多了,虽然看上去还是很奇怪,因为行星的反射前后都会保持恒定速度.在现实中,两个对象碰撞的时候,有一个恢复系数,衡量两个物体在碰撞后的反弹程度:CR=碰撞后的相对速度/碰撞前的相对速度

在弹性碰撞(CR>1)的情况下,碰撞后的相对速度大于碰撞前的相对速度.在另一方面,在无弹性碰撞(CR<1)就会导致碰撞后相对速度更低.在行星的例子中,我们更倾向于无弹性碰撞,除非它们是魔法行星

优化碰撞

我们讨论的所有碰撞检测算法都只能检测一对物体间的碰撞.一个可能会遇到的问题是,如果有大量的物体需要进行碰撞检测呢假设我们有10000个对象在游戏世界中,然后想检测我们的角色与任意一个物体是否发生碰撞.原始的方法需要进行10000次碰撞检测:将角色和每一个物体进行检测.这样就非常没有效率,特别是在检测距离很远的对象的时候

所以必须对游戏世界进行分区,这样主角只要跟所在区域的对象进行碰撞检测就可以了.2D游戏中的一种分区方法就是四叉树,游戏世界会递归切割成矩形,直到每一个叶子节点只引用一个对象

在进行碰撞检测的时候,程序会优先检测最外层的四叉树矩形中的玩家所在象限的对象是否与玩家发生了碰撞,这样就立刻剔除了3/4的对象.然后这个递归算法会不断进行下去,直到找到所有潜在与玩家发生碰撞的对象.在只剩下少数的对象之后,就可以对每个对象进行碰撞体检测了.

四叉树不是唯一的分区方法.还有很多方法,比如二进制空间分割(BSP)及八叉树(3D版的四叉树).大多数算法都是基于空间的,还有一些是启发式分组的.

基于物理的移动

如果一个游戏对象在游戏世界中移动,就有一些物理会用于模拟这种运动.牛顿物理(也叫作经典物理)在17世纪被牛顿用公式表示出来.游戏中会大量使用牛顿物理,这是一个很好的模型,因为游戏对象不会以光速运动.牛顿物理由多个不同部分组成,但是本节聚焦最基础的部分:线性力学.就是没有旋转的运动

线性力学概览

线性力学的两个基石是力与质量.力是一种相互作用,可以导致物体运动.力有着方向和大小,因此可以用向量表示.质量表示物体所含物质的量.对于力学来说,主要的关系是质量越大,物体就越难运动

如果一个足够大的力作用到物体上,理论上它会开始加速.这个想法就是牛顿第二定律:F=m*a

这里,F是力,m是质量,a是加速度.由于力等于质量点成加速度,所以加速度可以通过力除以质量得到.给定一个力,这个等式就可以计算出加速度

力的计算

数值积分让我们可以由加速度计算出速度,然后由速度算出位置.但是为了算出加速度,我们需要力和质量.这里有多种多样的力需要考虑.有些力,比如重力,一直作用在物体身上.而有些力可以用冲量代替,就是那些只在一帧起作用的力

举个例子,跳跃可能最先受到冲量的作用而起跳.但是跳跃开始之后,重力的作用下,角色就会回到地面.由于多个力可以同时作用在物体上,在游戏中最常见的做法就是算出所有力的合力,然后除以质量算出加速度:加速度=合力/质量

欧拉和半隐式欧拉积分

一个简单的改法就是将欧拉积分的位置和速度更新顺序调换.就是说现在位置是使用新的速度来计算.这就是半隐式欧拉积分,它会更加合理,更加稳定,著名的游戏物理引擎Box2D就用了这种方法.但是,如果我们想要更加精确,我们就要使用更加复杂的数值积分方法

Verlet积分法

functionUpdate(floatdeltaTime)Vector3sumOfForces=sumofforcesinforces//Verlet积分法Vector3avgVelocity=velocity+acceleration*deltaTime/2.0f//位置用平均速度算出来position+=avgVelocity*deltaTime//计算新的加速度和位置acceleration=sumOfForces/massvelocity=avgVelocity+acceleration*deltaTime/2.0fendViewCode本质上Verlet积分法使用平均速度计算位置.这比起两种欧拉积分都要准确得多.同时计算也更加昂贵,虽然显然比欧拉方法要好,但还不够

其他积分方法

还有不少其他积分方法可能会在游戏中用到,但是它们有点复杂.它们当中最受欢迎的方法是四阶Runge-Kutta方法.它本质上是使用泰勒近似求解的结果表示运动的微分方程的近似解.这个方法无可争议地比欧拉和Verlet方法都要准确,但也更慢.对于那些需要高准确度的游戏(比如汽车模拟)来说是有意义的,但是对于多数游戏而言都过重

角力学

角力学是关于旋转的力学研究.比如说,你可能需要这种物理效果,就是物体围绕另一个物体旋转.就像线性力学有质量,作用力,加速度,速度,位置一样,角力学有转动惯量,力矩,角加速度,角速度和角度.角力学的机制比线性力学还要复杂一些,但也不会复杂很多.就像线性力学一样,角力学也会在旋转的时候用到积分.注重效果的游戏大多会用到角力学,但是由于大多数游戏只用线性力学,所以这里选择只讲线性力学

Ericson,Christer.Real-timeCollisionDetection.SanFrancisco:MorganKaufmann,2005.这本书是碰撞检测大全.书中有各种几何体的碰撞及排列组合.要注意的是,本书设计大量数学知识,所以在看之前最好先适应数学表达的方法

第8章摄像机

摄像机的类型

固定摄像机

严格来讲,固定摄像机就是那种永远在同一个位置的摄像机.这种固定的摄像机通常只用于非常简单的3D游戏.术语"固定摄像机"也可以扩展为根据玩家的位置而摆放在预先定义好的位置.

第一人称摄像机

第一人称摄像机是以玩家的视角来体验游戏世界的.由于摄像机是角色视角,第一人称摄像机是最让人身临其境的摄像机类型.第一人称摄像机在第一人称涉及游戏中非常流行,但是在其他游戏比如<<上古卷轴:天际>>也会用到

第一人称游戏最常见的做法就是在眼睛附近放摄像机,这样其他角色和物体才会有相应的高度.但是,问题是很多第一人称游戏都希望能够显示角色手部.如果摄像机在眼睛位置,当角色向前看的时候是看不到手的.还有一个问题就是,如果玩家的角色模型绘制出来,你会从接近眼睛位置的摄像机看到很奇怪的效果

为了解决以上问题,大多数第一人称游戏都不会使用普通模型.取而代之的是,使用一个特殊的只有手臂(可能还有腿)的对解剖来讲不正确的位置.这样,就算向前看,玩家总能看到手上有什么.如果使用了这个方法,一些特殊情形,比如看到自己的倒影这种情况就需要考虑了.否则,玩家看到空气中悬挂的手臂,就会被吓到

跟随摄像机

跟随摄像机会在一个或者多个方向上跟在目标后面.

有的跟随摄像机与目标始终保持固定距离,而有的则与目标保持弹性距离.有的在跟随角色的过程中会旋转,而有的不会.有的甚至允许玩家突然转身看背后有什么.有大量的这种类型的摄像机的各种各样的实现

场景切换摄像机

越来越多的游戏会用到场景切换,就是在播放游戏剧情的时候,从玩家的摄像机切过去的一种手法.在3D游戏中实现场景切换,要预先在场景中放置动画中用到的固定摄像机.很多场景切换会使用电影设备,比如移动镜头.为了达到效果,会用到样条系统

透视投影

视场

观看世界视野的广度及角度,称为视场(FOV).对人类来说,我们的眼睛提供了180°的视野,但是并不是每个角度都有等量的清晰度.双目并视的时候,两只眼都可以同时看到大约120°的视场.而剩余的市场在边缘处,能够快速地发现运动,但是不够清晰

推荐的观看高清电视的距离很大程度取决于向你推荐的人,THX推荐的观看距离为取对角线长度乘以1.2.所以50英寸的电视应该从60英寸的距离观看.这个距离,电视机会有大约40°的视角度,就是说电视机会占了观看者40°的视场.在这种条件下,只要给游戏留下多于40°的视场,几乎所有的角色都能看得一清二楚.这就是为什么家用机游戏需要大约65°的视场

但是如果家用机游戏替换成PC游戏会怎样在PC的条件下,显示器会占用玩家更多的视场.这种条件下通常有90°以上的视场,这个视场及视角度的差异会让一些玩家感到不舒服.这就是为什么游戏世界需要把视场收窄,让大脑回到90°的视场感知.由于这个问题,让玩家选择自己习惯的视场是一个不错的选项

如果视场变得太大,就会有鱼眼效果,屏幕的边缘变得弯曲.就类似于摄影中使用了广角镜头一样.大多数游戏不允许玩家选择太高的视场

宽高比

宽高比就是观看世界视口的宽度和高度的比率

标准的高清分辨率720p(就是1280x720)就是16:9宽高比的例子

摄像机的实现

基础的跟随摄像机

在基础的跟随摄像机中,摄像机总是直接跟随在某个对象后面,而且保持固定的距离

回忆一下为摄像机创建观察矩阵,需要3个参数:眼睛的位置(摄像机的位置),摄像机观察的目标,以及摄像机的上方向量.在基础跟随摄像机中,眼的位置可以设置为目标的水平和垂直偏移.在计算出位置之后,就可以计算其他参数,然后传递给CreateLookAt函数:

//tPos,tUp,tForward=位置,上方和前方向量//hDist=水平跟随距离//vDist=垂直跟随距离functionBasicFollowCamera(Vector3tPos,Vecotr3tUp,Vector3tForward,floathDist,floatvDist){//眼睛就是目标位置的偏移量Vector3eye=tPos-tForward*hDist+tUp*vDist//摄像机向前的方向是从眼睛到目标Vector3cameraForward=tPos-eyecameraForward.Normalize()//叉乘计算出摄像机的左边及上方向量Vector3cameraLeft=CrossProduct(tUp,cameraForward)cameraLeft.Normalize()Vector3cameraUp=CrossProduct(cameraForward,cameraLeft)cameraUp.Normalize()//CreateLookAt的参数为eye,target,以及upreturnCreateLookAt(eye,tPos,cameraUp)endViewCode虽然基础跟随摄像机会跟着目标在游戏中移动,看起来非常僵硬.摄像机总是保持固定距离,没有弹性.当旋转的时候,这个基础跟随行为会让人不知道是世界在转还是人在转.而且,基础跟随摄像机没有一个合理的速度,它的速度就是目标的速度.由于以上种种原因,基础跟随摄像机很少在游戏中使用.虽然它提供了简单的解决方案,但是显得很不优雅

一个简单的改善方法就是让摄像机有一个跟随目标速度调整跟随距离的函数.比如说平时跟随的时候速度为100,但是当目标全速移动的时候,这个距离为200.这个简单改变能够提升基础跟随摄像机的速度感,但是还有很多问题没有办法解决

弹性跟随摄像机

有了弹性跟随摄像机,就不会由于目标的朝向或者位置改变而突然变化,而是摄像机会在几帧的过程中逐渐变化.实现方式是同时设定好理想位置与现实位置.理想位置每帧立刻变化,就跟基础跟随摄像机一样(可能会有跟随距离调整函数).然后真正的摄像机位置在后续几帧慢慢跟随到理想位置上,这样就能够创造平滑的镜头效果

弹簧的效果可以由弹性常量来控制.这个常量越大,弹簧就越僵硬,就是说摄像机归为得越快.实现弹性跟随摄像机,需要确定每帧的摄像机速度和真实摄像机位置.因此最简单的实现就是使用一个类来实现.这个算法大概的工作方式是首先基于这个弹性常量计算出加速度.然后将加速度通过数值积分计算出摄像机的速度,然后再进一步计算位置

旋转摄像机会在目标附近旋转.旋转摄像机最简单的实现方法就是存储摄像机的位置及与目标的偏移,而不是直接记录摄像机的世界坐标系位置.这是因为旋转总是关于原点旋转的.所以如果摄像机位置作为偏移记录下来,旋转j就可以以目标对象为原点进行旋转,这样就得到了我们想要的旋转效果

使用第一人称摄像机,摄像机的位置总是放在角色的相对位置上.所以当角色在世界中移动的时候,摄像机依然是玩家的位置加上偏移.虽然摄像机偏移总是不变,目标位置可以不断变化.这是因为大多数第一人称游戏都支持到处看但是不改变位置的功能

讲解数学定义就会有点过多了,简单来讲,样条可以看作为曲线,用线上的点定义的.样条在游戏中很常见,因为使用它进行插值能够在整条曲线上得到平滑的效果

有很多种不同的样条,最简单的一种是Catmull-Rom样条.这种样条允许邻近的点插值,这些点里面有一个控制点在前,两个激活点在后.P1和P2就是激活点(分别在t=0和t=1处),而P0和P3就是在前面和后面的控制点.尽管图中只有4个点,但实际上是没有限制的.只要在前后加上控制点,曲线就可以无限延长下去

classCRSpline//Vector3s的数组(动态数组)VectorcontrolPoints//第一个参数为t=0对应的控制点//第二个参数为t值functionCompute(intstart,floatt)//检查start-1,start,start+1以及start+2都要存在...Vector3P0=controlPoints[starts-1]Vector3P1=controlPoints[start]Vector3P2=controlPoints[start+1]Vector3P3=controlPoints[start+2]//使用Catmull-Rom公式计算位置Vector3position=0.5*((2*P1)+(-P0+P2)*t+(2*P0-5*P1+4*P2-P3)*t*t+(-P0+3*P1-3*P2+P3)*t*t*t)returnpositionendendViewCode这个公式也可以用于计算t介于0到1之间的切线.首先,计算任意你想要的t的位置.然后,计算t加很小的增量Δt的位置.在有了两个位置之后,就可以构造一个P(t)到P(t+Δt)的向量并且正规化,这样就能够近似地得到切线.这种方式可以看作是数值微分

classSplineCamera//摄像机跟随的样条路径CRSplinepath//当前控制点索引及t值intindexfloatt//speed是t每秒变化率floatspeed//摄像机矩阵MatrixcameraMatrix//给定当期索引和t,计算摄像机矩阵functionComputeMatrix()//eye就是样条所在的t及index对应的位置Vector3eye=path.Compute(index,t)//给出一个稍微前一点的点Vector3target=path.Compute(index,t+0.05f)//假定y轴朝上Vector3up=Vector3(0,1,0)cameraMatrix=CreateLookAt(eye,target,up)endfunctionInitialize(floatmySpeed)//初始index应该为1(因为0是最初的P0)index=1t=0.0fspeed=mySpeedComputeMatrix()endfunctionUpdate(floatdeltaTime)t+=speed*deltaTime//如果t>=1.0f,我们可以移动到下一个控制点//这里代码假设速度不会太快,以至于一帧就超过两个控制点ift>=1.0findex++t=t-1.0fend//应该检查index+1和index+2是否为有效点//如果不是,这条样条就完成了...ComputeMatrix()endendViewCode摄像机支持算法

摄像机碰撞

摄像机碰撞致力于解决很多类型摄像机都有的问题,那是在摄像机与目标之间有一个不透明的物体的时候.最简单的方法(但不是最佳的)就是从目标位置向摄像机位置进行光线投射.如果光线碰撞到任何物体,可以让摄像机移动到阻挡摄像机的物体前面.

另一个要考虑的问题是在摄像机太过靠近目标的时候,回忆一下,近平面就在摄像机前面一点点,意味着太近的摄像机会让对象消失一部分.一个流行的解决方案是让对象在摄像机太过靠近的时候完全消失或者淡出

淡出方案有时候也常用于当摄像机与目标之间有阻挡的时候.很多第三人称动作游戏都使用这种方法

拣选

但是2D点不能乘以4x4矩阵,所以在这个点乘以矩阵之前,我们必须将其转换到齐次坐标系.这就需要将z和w分量添加到2D点上.z分量通常设为0或1,取决于该点是放置在近平面还是远平面.而作为一个顶点,w分量总为1

functionUpdate(Vector4screenPoint,Matrixcamera,Matrixprojection)//计算camera*projection的逆矩阵Matrixunprojection=camera*projectionunprojection.Invert()returnTransform(screenPoint,unprojection)endViewCodeUnproject可以用来计算两个点:鼠标位置反投影到近平面(z=0)和鼠标位置反投影到远平面(z=1).这两个点可以作为光线投射的起点和终点.由于光线投射有可能与多个物体交叉,游戏应该选择最近的那个

Haigh-Hutchinson,Mark.Real-TimeCameras.Burlington:MorganKaufmann,2009.这本书广泛地讲了各种不同的游戏摄像机,作者在<<银河战士>>中实现了各种优秀的摄像机系统

第9章人工智能

"真"AI与游戏AI

在很多游戏中,AI行为只是一种随机变化的状态机制组合,但还是有几个主要的例外.AI对于复杂的棋牌游戏,比如象棋和围棋,需要决策树支持,这是传统游戏理论的基石.但是棋牌游戏在某一时刻的行动选择相比起其他游戏来讲都不会这么奢侈.也有一些游戏实时做出很复杂的算法,令人印象深刻,但那是特例.一般来讲,游戏中的AI就是智能感知.如果玩家觉得敌人的AI或者队友的AI行为很聪明,这个AI系统就已经成功了

但也不是每个游戏都需要AI算法.一些简单的游戏,比如单人跳棋和俄罗斯方块就没有这样的算法.哪怕是一些复杂的游戏也可能没有AI,比如<<摇滚乐队>>.对于那些100%多人对战没有NPC的游戏来说也一样.但是对于任意一款设计指定NPC与玩家交互的游戏来说,AI算法是必须的

寻路

探索空间的表示

最简单的寻路算法设计就是将图作为数据结构.一个图包含了多个节点,连接任意邻近的点组成边.在内存中表示图有很多种方法,但是最简单的是邻接表.在这种表示中,每个节点包含了一系列指向任意邻近节点的指针.图中的完整节点集合可以存储在标准的数据结构容器里

这意味着在游戏中实现寻路的第一步是如何将游戏世界用图来表示.这里有多种方法.一种最简单的方法就是将世界分为一个个正方形的格子(或者六边形).在这种情况下,邻近节点就是格子中邻近的正方形.这个方法在回合制策略游戏中很流行,比如<<文明>>或者XCOM

但是,对于实时动作游戏,NPC通常不是在网格上一个正方形一个正方形地走.由此,在主流游戏中要么使用路点要么使用导航网格.上面两种方法,都可以手工在场景编辑器中构造数据

但是手工输入数据不仅繁琐而且容易出错,所以大多数引擎都会让这个过程自动化

寻路节点最早在第一人称射击游戏(FPS)中使用,由idSoftware在20世纪90年代早期推出.通过这种表示方法,关卡设计师可以在游戏世界中摆放那些AI可以达到的位置.这些路点直接被解释为图中的节点.而边则可以自动生成.比如让设计师手动将节点组合在一起,可以自动处理判断两个点之间是否由障碍.如果没有障碍,那么边就会在两点之间生成

路点的主要缺点是AI只能在节点和边缘的位置移动.这是因为即使路点组成三角形,也不能保证三角形内部就是可以行走的.通常会有很多不能走的区域,所以寻路算法需要认为不在节点和边缘上的区域都是不可走的

一个可选的解决方案就是使用导航网格.在这种方法中,图上的节点实际上就是凸多边形.邻近节点就是简单的任意邻近的凸多边形.这意味着整个游戏世界区域可以通过很少数量的凸多边形表示,结果就是图上的节点特别少

通过导航网格,在凸多边形内部的任意位置都认为是可走的.这意味着AI有了大量的空间可以行走,因此寻路可返回更自然的路径

还有一点就是导航网格完全可以自动生成,这也是今天为什么使用路点的游戏越来越少的原因.比如说,多年来虚幻引擎使用路点作为寻路空间的表示.其中一款使用路点的虚幻引擎的游戏就是<<战争机器>>.而且,最近几年的虚幻引擎已经使用导航网格代替路点.再后来的<<战争机器>>系列,比如<<战争机器3>>就使用的是导航网格,这个转变引起工业上大量转用导航网格

话虽这么说,但是寻路空间的表示并不完全会影响寻路算法的实现.在本节中的后续例子中,我们会使用正方形格子来简化问题.但是寻路算法仍不关心数据是表示为正方形格子,路点,或是导航网格

可接受的启发式算法

所有寻路算法都需要一种方法以数学的方式估算某个节点是否应该被选择.大多数游戏都会使用启发式,以h(x)表示,就是估算从某个位置到目标位置的开销.理想情况下,启发式结果越接近真实越好.如果它的估算总是保证小于等于真实开销,那么这个启发式是可接受的.如果启发式高估了实际的开销,这个寻路算法就会有一定概率无法发现最佳路径

贪婪最佳优先算法

在有了启发式之后,可以开始实现一个相对简单的算法:贪婪最佳优先算法.一个算法如果没有做任何长期计划而且只是马上选择最佳答案的话,则可以被认为是贪婪算法.在贪婪最佳优先算法的每一步,算法会先看所有邻近节点,然后选择最低开销的启发式

虽然这样看起来理由充足,但是最佳优先算法通常得到的都是次优的路径.

路径上存在不必要的向右移动,这是因为这在当时就是最佳的访问节点.一个理想的路径应该是一开始往下走,但是这要求一定程度的计划,这是贪婪算法所不具备的.大多数游戏都需要比贪婪最佳优先算法所能提供的更好的寻路.但是本章后续的寻路算法都基于贪婪最佳优先算法,所以先理解贪婪算法才能往下继续,先看看如何实现这个贪婪算法

structNode

Nodeparent

floath

end

那个parent成员变量用于跟踪哪个节点是当前访问的.parent成员的价值在于构造链表,能够从终点回到起点.当算法完成的时候,parent链表就可以通过遍历得到最终路径

浮点数h存储了某个节点的h(x)的值,这个值导致在选择节点的时候会偏向于h值最小的节点

算法的下一个组件就是用于临时存储节点的容器:开放集合和封闭集合.开放集合存储了所有目前需要考虑的节点.由于找到最低h(x)值开销节点的操作是很常见的,所以对于开放集合可以采用某种类似于二叉堆或者优先级队列的容器

假设有开始节点和结束节点,而且我们需要计算两点之间的路径.算法的主要部分在循环中处理,但是,在进入循环之前,我们需要先初始化一些数据

currentNode=startNode

addcurrentNodetoclosedSet

当前节点只是跟踪哪个邻居节点是下一个估值的节点.在算法开始的时候,我们除了开始节点没有任何节点,所以需要先对开始节点的邻居进行估值

在主循环里,我们首先要做的事情就是查看所有与当前节点相邻的节点,而且把一部分加到开放集合里:

do

foreachNodenadjacenttocurrentNode

ifcloseSetcontainsn

continue

else

n.parent=currentNode

ifopenSetdoesnotcontainsn

computen.h

addntoopenSet

loop

注意任意已经在封闭集合里的节点都会被忽略.在封闭集合里的节点都在之前进行了估值,所以不需要再进一步估值了.对于其他相邻节点,这个算法会把parent设置为当前节点.然后,如果节点不在开放集合中,我们计算h(x)的值并且把节点加入开放集合

在邻近节点处理完之后,我们再看看开放集合.如果开放集合中再也没有节点存在.意味着我们把所有节点都估算过了,这就会导致寻路失败.实际上也不能保证总有路径可走,所以算法必须考虑这种情况

ifopenSetisempty

break;//退出主循环

但是,如果开放集合中还有节点,我们就可以继续.接下来要做的事情就是在开放集合中找到最低h(x)值开销节点,然后移到封闭集合中.在新一轮迭代中,我们依旧将其设为当前节点

currentNode=NodewithlowesthinopenSet

removecurrentNodefromopenSet

最后,我们要有循环退出的情况.在找到有效路径之后,当前节点等于终点,这样就能够退出循环了

untilcurrentNode==endNode//endmaindo...untilloop

图9.7显示了贪婪最佳优先算法作用在示例数据集的开始两次迭代.在图9.7(a)中,起点加入封闭集合,而邻接节点则加入开放集合.每个邻接节点(蓝色)都有用曼哈顿距离算出来的自己达到终点的h(x)开销.箭头表示子节点指向父节点.这个算法的下一步就是选择最低h(x)值节点,在这里选择h=3的节点.然后这个节点就会作为当前节点,放到封闭集合里.图9.7(b)显示了下一步的迭代,将当前节点(黄色)的邻接节点放入开放集合中

currentNode=startNodeaddcurrentNodetoclosedSetdo//把邻接节点加入开放集合foreachNodenadjacenttocurrentNodeifclosedSetcontainsncontinueelsen.parent=currentNodeifopenSetdoesnotcontainncomputen.haddntoopenSetendendloop//所有可能性都尝试过了ifopenSetisemptybreak;end//选择新的当前节点currentNode=NodewithlowesthinopenSetremovecurrentNodefromopenSetaddcurrentNodetoclosedSetuntilcurrentNode==endNode//如果路径解出,通过栈重新构造路径ifcurrentNode==endNodeStackpathNoden=endNodewhilenisnotnullpushnontopathn=n.parentloopelse//寻路失败endViewCodeA*寻路

在讲了贪婪最佳优先算法之后,我们就可以考虑怎么提升路径的质量.比起单一地依赖于h(x)作为寻路的估价,A*算法增加了路径开销分量.路径开销就是从起点到当前节点的实际开销,通过g(x)计算

为了能够使用A*算法,Node结构体需要增加f(x)和g(x)的值,如下:

floatf

floatg

当一个节点加入开放集合之后,我们需要计算所有的3个分量,而不仅仅是启发式.而且,开放集合会根据f(x)的值来排序,因为在A*中每次迭代都会选择f(x)值最低的节点

对于A*算法只有一个主要变化,那就是节点选用的概念.在最佳优先算法中,总是把邻接节点作为父节点.但是在A*算法中,已经放在开放集合中的邻接节点需要估值之后才能决定哪个当前节点是父节点

在图9.8(a)中,我们可以看到当前节点正在检查邻近节点.这个节点到左边节点的g=2,如果那个点以当前节点为父节点,g=4,结果会更糟.所以在这种情况下,当前节点的路径应该拒绝选用.

currentNode=startNodeaddcurrentNodetoclosedSetdoforeachNodenadjacenttocurrentNodeifclosedSetcontainsncontinueelseifopenSetcontainsn//选用检查computenew_g//n节点以当前节点为父节点的g(x)值ifnew_g

这意味着Dijkstra算法可以使用与A*算法一样的代码,除了启发式为0之外.如果将Dijkstra算法用于我们的例子,能够得到与A*算法一样的路径.如果只有A*才使用启发式,Dijkstra总能得到与A*算法同样的路径.但是,Dijkstra算法通常会访问更多的节点,所以Dijkstra效率更低

唯一使用Dijkstra代替A*的场景就是场景中同时存在多个有效目标节点的时候,但是你不会知道哪个更近.但在那种场景下,大多数游戏都不会使用Dijkstra.这个算法被讨论基本上都是处于历史原因,因为Dijkstra比A*早10年提出.A*的创新在于结合了贪婪最佳优先和Dijkstra算法.所以虽然本书通过A*讨论Dijkstra,但这不是它被开发出来的原因

基于状态的行为

大多数基础的AI行为无非就是不同的状态.以<<乒乓>>的AI举例,它只需要跟踪球的位置.这个行为在整个游戏的过程中都没有改变,所以这样的AI可以被认为是无状态的.但是当游戏有点复杂度的时候,AI就需要在不同的时候有不同的行为.大多数现代游戏的NPC在不同的位置d都有不同的行为.

AI的状态机

有限状态机可以完美地表达基于状态的AI.它有着一组可能的状态,由一定的条件控制状态转换,而在状态切入切出的时候可以执行动作

基础的状态机实现

状态机有多种实现方式.最简单的需求就是当AI更新的时候,正确的更新行为必须根据当前状态来完成.理想状态下,我们还想让状态机有进入和退出行为.

如果AI只有两种状态,我们可以在AI的Update函数中用一个布尔值来判断.但是这个方案不够健壮.一个稍微灵活的方式是通过枚举值来表示不同的状态,这经常在简单的游戏中可以看到

图9.9中的状态机就可以像下面这样定义枚举

enumAIState

Patrol,

Death,

Attack

然后可以用AIController类以AIState类型作为成员变量.在我们的AIController的Update函数中,可以根据当前状态来执行不同的行为:

functionAIController.Update(floatdeltaTime)

ifstate==Patrol

//执行巡逻行为

elseifstate==Death

//执行死亡行为

elseifstate==Attack

//执行攻击行为

状态的变化和进入/退出行为可以在第二个函数中实现

functionAIController.SetState(AIStatenewState)

//退出行为

//退出巡逻行为

//退出死亡行为

//退出攻击行为

state=newState

//进入行为

//进入巡逻行为

//进入死亡行为

//进入攻击行为

这个实现有几个问题.首先很明显的一点就是,首先很明显的一点就是,随着状态机的增加,Update和SetState的可读性会减弱.如果我们的例子中有20个状态而不是3个,代码看上去就像意大利面条.第二个主要问题是缺乏灵活性.加入我们有两个AI,它们有不同的状态机.这样我们就需要为不同的AI实现不同的枚举和控制器.现在,假设两个AI之间会公用一些状态,比如说巡逻状态.以我们目前的基础代码结构是无法在AI之间共享状态的.

一个方法是将巡逻的代码复制到两个类中,但是有着两份同样的重复代码是非常不好的实践.另一个方法就是写一个共同的基类,然后把公有的行为"冒泡"上去.但是这样,还是有很多缺点:意味着任何需要巡逻行为的AI都要从这里继承

所以虽然这个基础的实现能工作,但是除非AI状态机非常简单,否则完全不推荐

状态机设计模式

状态机模式,允许"一个对象通过改变内在状态来切换行为"

这可以通过类组合的方式完成.所以AIController"有一个"AIState作为成员变量.每个特定的状态都是AIState的子类

基类AIState的定义如下:

classAIState

AIControllerparent

functionUpdate(floatdeltaTime)

functionEnter()

functionExit()

父引用使得任何AIState的实例都可以让AIController拥有它.这是必要的,如果我们想要切换到新的状态,需要有一些方法通知AIController这些事情.每个AIState都有自己的Update,Enter,Exit函数,可以为某个特定有需求的状态所实现

AIController类会保留一个当前AIState的引用,而且需要Update和SetState函数

classAIController

AIStatestate

functionSetState(AIStatenewState)

这样,AIController的Update函数只是简单地调用AIState的Update函数即可

state.Update(deltaTime)

通过设计模式,SetState函数也变得清晰多了:

state.Exit()

state.Enter()

策略和计划

很多游戏都需要比基于状态的敌人更复杂的AI.比如即时战略游戏,AI期望看上去与人类玩家相差无几.这个AI需要有一个大局观,知道自己要做什么,然后尽力去做.这就是策略和计划的工作方式

策略

策略就是从AI的视角来完成游戏.比如说,它要思考的是应该更具侵略性还是防守性.微观策略由单位行为组成.这通常可以用状态机来完成,所以就不必深入讨论了.相对而言,宏观策略复杂得多.它是AI的大局观,而且会决定如何完成游戏.当为像<<星际争霸>>那样的游戏开发AI的时候,开发者通常根据顶级玩家的思维进行建模.一个宏观策略在RTS游戏中可能会是"突击"(尝试尽快攻击玩家)

策略有时候看上去就像很模糊的使命描述,而且模糊的策略是很难开发的.为了让问题更加形象,策略通常被认为是一系列的特定目标.比如说,如果策略是"科技"(增加装备科技),一个特定目标可能就是"扩张"(建立第二个基地)

一个策略通常都不止一个目标,也就是说,我们需要有一个优先级系统来让AI选择哪个目标更加重要.所有其他目标如果优先级不是最高,那么会先搁在后面不管.其他目标会在最重要的目标完成时重新参与选择.一个实现目标系统的方式就是像这样写一个AIGoal类:

classAIGoal

functionCalculatePriority()

functionConstructPlan()

每个特定目标都会作为AIGoal的子类实现.所以当策略进行目标选择之后,所有策略的目标会放到一个根据优先级排序的容器里.注意,真正高级的策略系统应该支持同时选用多个目标的功能.因为如果两个目标不是互斥的,那么是没有理由不同时选择两个目标的

计算优先级的启发式函数是CalculatePriority,可能会相当复杂,而且根据游戏规则不同而变化.比如说,一个目标是"建立空中单位",可能会在发现敌人正在建造能够消灭空军的单位时降低优先级.另一方面,如果AI发现敌人没有能够伤害空军的单位,那么就会增加这一目标的优先级

AIGoal中的ConstructPlan函数就是用于构造计划的:一系列为了达到目标而计划出来的步骤

计划

每个目标都需要一个相应的计划.比如说,如果目标是扩张,那么计划可能如下:

1.为扩张侦察合适的地点

2.建立足够多的单位来保护扩张

3.派遣工人和战斗单位去扩张点

4.开始建造扩张点

特定目标的计划可以用状态机来实现.计划中的每一步都可以是状态机中的一个状态,而状态机持续为该步骤行动直到达到条件.但是,实践中的计划很少是线性的.根据计划某个步骤的成功或者失败,AI可能会调整步骤的顺序

一个需要考虑的事情是计划需要定期查看目标的可行性.如果扩张计划中发现没有适合扩张的位置,那么目标就是不可行的.一旦目标被标记为不可行,大局观需要重新估算.最终,必须要有一个"指挥官"来决定策略的改变

总结

AI游戏程序员的目标就是让系统看上去比较聪明.这可能不是研究人员眼中的"真"AI,但是游戏通常都不需要这么复杂的AI.与我们讨论的一样,游戏AI领域的一个大问题就是找到从点A到点B的路径.游戏中最常用的寻路算法就是A*算法,而且它可以用于任何搜索空间表达为图(比如格子,路点,导航网格)的问题.大多数游戏都有某种方式实现的通过状态机控制的行为,而实现状态机的最佳方式就是状态机设计模式.最后,策略和计划可能会在RTS游戏中创造更加可信真实的行为

通用AI

Millington,IanandJohnFunge.ArtificialIntelligenceforGames(2ndEdition).Burlington:MorganKaufmann,2009:这本书主要以算法的方式讲了很多常见的游戏AI问题

AIProgrammingWisdom(Series):与GameProgrammingGems系列类似,但是完全专注于游戏AI,其中一些已经绝版了

状态

Gamma,Ericet.al.DesignPatterns:ElementsofReusableObject-OrientedSoftware.Boston:Addison-Wesley,1995.这本书讲设计模式的同时描述了状态机设计模式,对于所有程序员都很有用

Buckland,Mat.ProgrammingGameAIByExample.Plano:WordwarePublishing,2005.这是一本通用的游戏AI书籍,他有一个非常好的基于状态的行为实现

第10章用户界面

菜单系统

菜单栈

典型家用机游戏的菜单系统都以"点击开始"作为开始界面.在用户按键之后,就会进入主菜单界面.也许你还可以通过点击选项进入选项界面,也可以点击开发组或者新手教程.通常来讲,玩家都能够退出当前菜单然后返回之前的界面.

一个确保菜单总能回退到基本界面的方法就是使用栈这种数据结构.栈最上层的元素就是当前活跃的菜单,而打开新菜单就是往栈中压入新的菜单.回退到之前的菜单就是将当前的菜单弹出栈.这个机制还可以改进为支持多个菜单同时可见,比如说,如果需要接受/拒绝某个请求,一个弹出框可以在某个菜单之前.为了达到目标,菜单系统需要对栈的底部到顶部全部引用

按钮

打字

functionKeyCodeToChar(intkeyCode)//确保这是字母键ifkeyCode>=K_A&&keyCode<=K_Z//现在,假设大写的情况//这个映射取决于语言return('A'+(char)(keyCode-K_A))elseifkeyCode==K_SPACEreturn''elsereturn''endendViewCodeHUD元素

最基础的HUB(平视显示器)元素就是玩家得分和剩余生命值.这种HUB实现起来相对琐碎----在主要游戏场景渲染之后,我们只要在顶层绘制文字或者图标以展示相应的信息即可.但是很多游戏都使用了更加复杂的元素,包括路点箭头,雷达,指南和准心

路点箭头

就像鼠标光标一样,准心是一个在屏幕上的坐标.我们拿到这个2D坐标,然后执行两个反投影:一个在近平面,一个在远平原.得到这两个点之后,可以在这两点之间执行光线投射.

雷达

有的游戏会有雷达系统用来显示雷达范围附近的敌方(或者友方).还有几种雷达变种,一种是任何人都会在相应的雷达中显示,另外一种是只显示最近开枪的敌人.不管是哪一种,实现原理几乎都一样

为了让雷达顺利工作,需要完成两件事情.首先,我们有一种方式可以遍历能够在雷达上显示的所有对象.然后,所有在雷达范围内的对象都需要根据UI中心做出的相应的偏移.计算距离和转换为2D偏移,我们都希望忽视高度,这意味着我们必须投影雷达对象到雷达面板上

在我们计算之前,应该先定义雷达光点结构体,就是那些在雷达上显示的点.这样,就可以根据实际情况让这些点有不同的大小和颜色

structRadarBlip

//雷达光点的颜色

Colorcolor=Color.Red

//雷达光点的位置

Vector2position

//雷达光点的缩放

floatscale=1.0f

对于Radar类来说,需要有两个参数:游戏世界中的对象能够被探测出来的最大距离,以及屏幕上显示的雷达半径.通过这两个参数,在我们得到光点位置之后,就可以转换到屏幕上正确的位置

支持多套分辨率

一个解决多套分辨率问题的办法就是避免使用像素坐标,也称为绝对坐标.一个绝对坐标的例子是让UI绘制在(1900,1000)像素点.使用这种坐标的问题就是如果显示器只有一种1680x1050像素的分辨率,在(1900,1000)位置的UI就会完全在屏幕之外

这种问题的另一个解决方法是使用相对坐标,就是坐标是一个相对值.比如说,如果你想让某些东西在屏幕的右下角显示,可能会放置元素在相对于右下角位置的(-100,100).就是说在1920x1080像素分辨率下,它的坐标会是(1820,980),而在1680x1050像素分辨率下,坐标会是(1580,950)

一个细微的改良就是根据分辨率进行缩放.原因是如果在非常高分辨率的情况下,UI元素可能因为太小而导致不可用.所以分辨率越高,UI就应该越是放大,这样玩家才能看清楚.一些MMORPG游戏甚至允许玩家控制UI控件的缩放.如果要支持伸缩,使用相对位置就尤为重要

本地化

虽然一款游戏只支持一种语言(通常是英语)也是可行的,但是大多数商业游戏都需要支持多语言.本地化就是支持更多语言的过程.由于许多菜单和HUD都有文本显示,在设计UI系统的时候就需要重视.哪怕一款游戏不需要本地化,将文本硬编码到代码里面本身就非常不好,这样不利于非程序员修改.但是如果游戏需要本地化,将硬编码移除就特别重要

最简单的文本本地化方法就是将游戏中这些文本存储到外部文件中.这个外部文件可以使用XML,JSON或者类似的格式.这样j紧接着就可以通过字典映射键来访问特定的字符串.因此不管代码是否需要在屏幕上显示文本,我们都需要用对应的键来使用字典.这意味着比起直接在按钮上显示文本"Cancel",更应该使用"ui_cancel"键来从字典取得字符串.在这种情况下,支持新语言只需要创建新的字典文件

大多数游戏都通过Unicode字符集来支持不同的编码系统.有多种方式来对Unicode字符进行编码,最流行的方法就是UTF-8,这是使用最广的方法.标准的ASCII字符集在UTF-8中就是一个字节,而Unicode字符可以有6字节.由于宽度变化,UTF-8字符串不能只根据内存长度而断定字符个数,每种语言都不一样

但是改变文本字符串和字符编码不是唯一要考虑的事情.还有一个问题是一个单词在不同语言中的长度不一样

UI中间件

中间件就是一些外部代码库用与简化开发工作

用户体验

Spolsky,Joel.UserInterfaceDesignforProgrammers.Berkeley:Apress,2001.这个程序员设计UI的方法不是针对游戏,但是他在关于创造高效UI方面提供了有趣的视角

第11章脚本语言和数据格式

脚本语言

多年前,游戏全部使用汇编语言开发.这是因为早期的机器需要汇编级别的优化才能运行.但是随着计算能力的提升,而游戏又变得很复杂,使用汇编开发就变得越来越没有意义了.直到某一天,使用汇编语言开发游戏带来的优点被完全抵消了,这就是为什么现在所有游戏引擎都使用像C++那样的高级语言开发

同样,随着计算机性能的提升,越来越多的游戏逻辑开始从C++或者类似的语言转移.现在许多游戏逻辑使用脚本语言开发,常用的脚本语言有Lua,Python,UnrealScript等

由于脚本代码更加容易编写,所以策划写脚本是完全可行的,这就让他们得到了开发原型的能力,而不用钻进引擎里面.虽然AAA游戏的相当部分比如物理引擎或者渲染系统依然使用引擎语言开发,但是其他系统比如摄像机,AI行为可能会使用脚本开发

折中

脚本语言并不是万灵药,在使用之前必须考虑很多折中.第一个要考虑的就是脚本语言的性能远不如编译型语言,比如C++.尽管比起JIT或者基于VM的语言,比如Java,C#,那些脚本语言,比如Lua,Python,在性能上都不具备可比性.这是因为解释性语言按需加载文本代码,而不是提前编译好.多数脚本语言都提供了编译为中间格式的选项.虽然始终达不到编译型语言的速度,但还是会比解释型语言要快

由于这个性能差异的存在,性能敏感的代码不应该使用脚本语言开发.以AI系统为例,寻路算法(比如A*)应该是高效的,因此不应该用脚本开发.但是由状态机驱动的AI行为应该完全用脚本开发,因为那不需要复杂的计算.

使用脚本语言的巨大优势就是使得开发时迭代更加快速.假设某个游戏的AI状态机必须以C++开发,在玩游戏的过程中,AI程序员发现某个敌人的行为不正确.如果状态机使用C++开发,程序员需要很多工具去定位问题,而且在玩游戏的过程中通常没法解决.虽然VisualStudio的C++确实有"编辑和继续"功能,但实际上它只有在某些情况下才能使用.这意味着通常程序员必须暂停游戏,修改代码,重新生成可执行文件,重新开始游戏,最后才能看到问题是否解决

但是同样的场景如果出现在AI状态机是用脚本语言开发的时候,就可以动态重新加载脚本,然后在游戏仍在运行的时候就把问题解决了.运行中动态加载脚本的能力可以很大程度地提升生产力

回到C++版本的AI行为例子中,假设在警卫AI中有BUG,是由访问野指针引起的,那么通常都会引发崩溃.如果bug总是出现,游戏就会经常崩溃.但是如果状态机是使用脚本语言开发的,那可能只会让某个特定AI的角色行动不正常,而游戏的其他部分都是正常的.第二种情况要比第一种友好得多

进一步来讲,由于脚本与可执行文件是分开的文件,使得提交工作更加简单.在大型项目中,生成可执行文件需要好几分钟,而且最终文件可能会有100MB.这意味着如果有新版本,需要的人要下载整个文件.但是,如果是用了脚本语言,用户只要下载几KB的文件就可以了,这样会快很多.这不仅对发售后更新补丁非常有帮助,在开发中也同样有用

由于生产力的优势,一个最好的经验法则就是,只要系统不是性能敏感的,都能从脚本语言中受益.当然,为游戏增加脚本系统本身也要成本,但是如果多个团队因此受益,那么很轻松就能回收成本

脚本语言的类型

Lua

Lua是一门通用脚本语言,大概是现在游戏领域最流行的脚本语言.使用Lua的游戏的例子包括:<<魔兽世界>>,<<英雄连>>,<<冥界狂想曲>>等.Lua这么流行的一个原因是它的解释器非常轻量----纯C实现大概占用内存150KB.另外一个原因就是它非常容易做绑定,也就是在Lua中调用C/C++代码的能力.它同时支持多任务,所以它可以让许多Lua函数同时运行

语法上,这门语言有点像C族语言,但同时也有一些不同点.表达式结尾的分号是可选的,不再使用大括号控制程序流程.Lua迷人的一个方面j就是它的复杂数据结构只有一种,那就是表格,它可以以很多不同的方式使用,包括数组,链表,集合等.

--这样注意----这是一个数组--数组从1索引开始t={1,2,3,4,5}--输出4print(t[4])--这是一个字典t={M="Monday",T="Tuesday",W="Wednesday"}--输出Tuesdayprint(t[T])ViewCode虽然Lua不是面向对象语言,但是通过表格完全可以做到面向对象.这种技术经常会用到,因为面向对象在游戏中非常重要

UnrealScript

UnrealScript是Epic为Unreal引擎专门设计的严格的面向对象语言.不像很多脚本语言,UnrealScript是编译型的.由于是编译型的,它有着比脚本语言更好的性能.但也意味着不支持的运行时重新加载.用Unreal开发的大部分游戏逻辑都用UnrealScript完成.对于使用完整引擎的游戏来说(不是免费版的UDK),UnrealScript的绑定允许使用C++实现

在语法上,UnrealScript看上去非常像C++或者Java.因为它是严格面向对象的,每个类都继承自Object或者Object的子类,而几乎每个类都表示场景中派生自Actor的一个角色.UnrealScript非常特别的功能是内建对状态的支持.可以根据状态有不同的函数重载,这样对于AI行为会更加容易设置.以下的代码片段会根据该类当前状态调用不同的Tick函数(Unreal对象的更新函数)

//Auto表示进入的默认状态autostateIdle{functionTick(floatDeltaTime){//更新Idel状态...//如果发现敌人,那么进入Alert状态GotoState("Alert")}Begin:`log("EnteringIdleState")}stateAlert{functionTick(floatDeltaTime)//更新Alert状态...}Begin:`log("EnteringAlertState")}ViewCode可视化脚本系统

实现一门脚本语言

实现自定义脚本语言与创建一个通用编译器类似.学习编译器如何工作是很重要的,哪怕你不需要实现它,因为它能使编程变得高级.没有编译原理,每个人仍会用汇编语言写代码,这样就会导致有能力编程的人大大减少

标记化

我们要做的第一步就是将代码文本加载进来,然后分成一块块的标记,比如标识符,关键词,操作符h和符号.这个过程被称为标记化,更正式一点叫作词法分析.

正则表达式

语法分析

语法分析的任务就是遍历所有标记,然后确保它们符合语法规则.比如说,if表达式需要有适当数目和位置的括号,大括号,测试表达式和表达式来执行.在检测脚本语言的过程中,会生成抽象语法树(AST),它是基于树的数据结构,定义了整个程序的布局

注意,图11.3中的树是以后序遍历(左孩子,右孩子,父节点)的方式遍历的,结果会是5610*+,这个结果就是中序表达式以后序表达式的方式表示的结果.这不是随意决定的,后序遍历在栈上计算非常方便.最后,所有AST(不管是否是数学表达式)在语法分析之后都会被以后序的方式遍历

在遍历AST之前,我们必须先生成一份AST.生成AST的第一步就是定义一份语法.计算机语言定义语法的经典方式就通过巴科斯范式,一般缩写为BNF.BNF的设计是相对简洁的.能够做整型加法和减法的运算子语法可以像下面这样定义:

这个::==操作符表示"定义为",|操作符表示"或者",<>操作符用于表示语法规则的名字.所以上面的BNF语法的意思,expression要么是expression加另一个expression,要么是expression减另一个expression.要么是一个integer.这样5+6是有效的,因为5和6都是integer,所以它们都是expression,所以它们可以相加

就像标记化一样,语法分析也有可以使用的工具.其中之一就是bison,它可以在语法规则匹配的时候执行C/C++动作.动作的一般用法就是让它读取AST的时候创建合适的节点.比如说,如果加法表达式匹配上了,就会为加法节点创建两个孩子:左右操作数各一个

最好能有一个类能对应一种类型的节点.所以加/减语法会需要4种不同的类:一个抽象的表达式类,一个整型节点,一个加法节点和一个减法节点.

代码的执行和生成

abstractclassExpressionfunctionExecute()endclassIntegerinheritsExpression//存储整数intvalue//构造函数...functionExecute()//将结果压到运算符的栈顶...endendclassAdditioninheritsExpression//左右操作数Expressionlhs,rhs//构造函数...functionExecute()//后续表示先访问左孩子,然后右孩子,最后到自己lhs.Execute()rhs.Execute()//将栈顶的两个值相加,再将结果压到栈里...endend//减法节点和加法一样,除了取反之外...ViewCode数据格式

另外一个游戏开发中要做的决定就是如何通过数据描述像关卡,游戏属性等游戏元素.对于非常简单的游戏来说,你可能不会管那么多,直接将数据硬编码,但这不是一个理想的解决方案.通过将数据存储在外部文件,就可以让非程序员来编辑.同时还使得创建编辑工具(比如关卡编辑器)来处理数据变得可能

当你确定数据格式的时候,第一个要决定的就是数据是二进制格式还是文本格式.一个二进制文件通常都不具备可读性.如果你用文本编辑器打开二进制文件,可以看到一大串乱码.一个二进制格式的例子就是PNG格式或者其他的图片文件格式.一个文本文件,通常会用ASCII码表示,因此具备可读性.就像判断是否使用脚本语言一样,两种方法之间有一种折中.最后,有的情况用文本格式合理,而有的情况用二进制格式合理

但是,效率的提升不是没有代价的.二进制文件的一个大缺点就是不支持版本控制系统.原因是很难分辨出了两个二进制文件到底有什么不同

对于文本格式而言,查看两个版本的不同就非常容易了.

还有最后一个方案,对于数据文本和二进制表达式都适用.在开发的时候,检查变动是很重要的,所以所有关卡和游戏数据都可以存成文本.然后,在发布的时候,我们可以加入一个烘焙步骤,将所有文本格式转换为二进制格式.这些二进制文件不会进入版本控制系统,而且开发者只修改文本文件.这样,我们在开发的时候就能够得到文本格式带来的便利,同时又能在发布的时候获得二进制格式的优点.由于两种优点都达到了,因此这种方法是非常流行的.唯一要关心的就是测试----开发组需要确保文件的变动不会导致二进制出问题.

二进制格式

对于存储游戏数据来说,二进制格式通常都没有自定义格式.这是因为有很多方式存储数据,这些很大程度上取决于语言和框架.如果是C++游戏,有时候最简单的方法就是将类的数据直接输出到外部文件.这个过程被称为序列化.但是有一些问题需要考虑,比如说,任何类种的动态数据都以指针形式存在.

INI

最简单的文本格式就是INI,经常在用户需要改配置的时候使用.INI文件是分为几节的,而每一节有一系列的键和值.比如说,INI文件的图形设置可能会是这样的:

[Graphics]

Width=1680

Height=1050

FullScreen=true

Vsync=false

虽然对于简单数据来讲,INI能工作得很好,但是对于复杂得数据而言就显得有些笨重.这对于关卡这种有布局的数据结构而言不太适合,比如说,INI不支持嵌套的参数和节

XML

XML,全称ExtensibleMarkupLanguage,是一种HTML概念扩展出来的文件格式.

《巫师2》使用XML存储所有能够在游戏种找到的物品的配置.比如说,下面是某一项,其存储了某个剑的状态

关于XML的一个批评就是需要很多额外的字符来表示数据,有很多<和>符号,而且总是需要用名字和引号等修饰每个参数,总是需要确保有配对的标签,所有的组合导致文件比较大

JSON

JSON,全称JavaScriptObjectNotation,比起INI和XML这种新型的文件格式,JSON在近几年非常流行.虽然JSON在互联网交换数据中应用比较多,但在游戏中用于轻量级数据格式也是可以的.有大量的第三方库可以解析JSON,包括C++的libjson和C#的JSON.NET

根据存储数据的类型,JSON可能与XML相比速度更快,体积更小.但也不总是这样

"ability":{

"name":"ForgottenSwordofVrans_Stats",

"damage_min":{"mult":false,"always_random":false,"min":50,"max":50},

"damage_max":{"mult":false,"always_random":false,"min":55,"max":55},

"endurance":{"mult":false,"always_random":false,"min":1,"max":1},

"crt_freeze":{"display_perc":true,"mult":false,"always_random":false,"min":0.2,"max":0.2,"type":"critical"},

"instant_kill_change":{"display_perc":true,"mult":false,"always_random":false,"min":0.2,"max":0.2,"type":"bonus"},

"vitality_regen":{"mult":false,"always_random":false,"min":2,"max":2}

}

案例学习:<<魔兽世界>>中的UIMod

《魔兽世界》中的两个主要空间是布局和行为.布局就是界面中图片,按钮,控件的放置,存储为XML.而UI的行为则使用了Lua脚本语言.

布局和事件

界面的布局完全是XML驱动的,用于设置基本的控件,比如框架,按钮,滑动条,复选框等,UI开发者都可以使用.同样在XML文件里,插件指出哪里有事件可以注册

行为

每个插件的行为都通过Lua实现,这样可以快速实现原型,而且可以在游戏运行中重新加载UI.由于使用了基于表格的继承系统,可以修改父类的函数实现重载.大多数的插件代码专注于处理事件和完成表现.每个注册了的事件都需要相应的Lua代码来处理

问题:玩家自动操作

问题:UI兼容性

结论

Aho,Alfred,et.al.Compilers:Principles,Techniques,andTools(2ndEdition).Boston:Addison-Wesley,2006.这本是经典书籍"龙书"的改进版,深入讲解了很多编译器背后的概念.其中的很多知识都可以用于实现自定义脚本语言

第12章网络游戏

协议

对于信封来说,地址有较标准的格式.寄信地址在左上角,目的地址在右边中间,而邮票在右上角.这是大多数国家最常见的格式.但是对于网络数据传输,有着很多不同的协议或规则来定义数据包以什么格式以及为了发送必须做什么.网络游戏现在通常会让游戏逻辑使用两种协议之一:TCP,UDP.有的游戏会使用第三种协议,ICMP,常用于一些非游戏逻辑的功能.

IP

IP,全称InternetProtocol(网际网络协议),要通过网路发送数据,这是需要遵守的最基本的协议.本章提到的每一个协议,不管是ICMP,TCP还是UDP,都必须附加IP协议才能够传输数据.哪怕数据只是在本地网络上传输.这就造就了一个事实,那就是现在所有在本地网络中上的机器都需要一个特定的本地地址,只有这样才能通过IP标识某个特定的地址

有两个广泛使用的IP协议版本:IPv4和IPv6.IPv4地址是32位的,IPv6是128位的

ICMP

TCP

传输控制协议(TCP)是游戏在网络上用来传输数据最常用的两个协议之一.TCP是一个基于连接的,可靠的,保证顺序的协议.可靠传递听起来很好,但随后我们会进一步讨论,TCP协议在游戏上的应用通常没有UDP流行

TCP是基于连接的,就是说两台计算机在任何数据传输之前,必须先建立好彼此的连接.连接完成的方法是通过握手.请求连接的计算机发送一个连接请求到目标计算机,告诉它自己想要如何连接,然后接收者确认这个请求.这个确认在被最初的请求者再次确认之后,三次握手的过程就完成了.

结果就是TCP不仅保证所有数据包的接收是可靠的,还会保证它们的顺序.举个例子,假设现在有3个数据包A,B,C按顺序发送.如果A和C到达,而B没有到达,接收者不能处理C,除非B到达之后才能往下走.所以需要等待B重传,在收到之后,才可以继续.由于数据包丢失,或者数据包传不过去的百分比,会大大减慢数据的传输

对于游戏来说,保证顺序很容易成为不必要的瓶颈.如果之前例子中的A,B,C包含了某个玩家的位置信息:就是最开始玩家在A位置,然后在B位置,最后在C位置.在位置C收到之后,游戏就不用关心位置B,因为玩家不在那个地方了.但是使用TCP,游戏在收到位置B以前是无法使用位置C的,这对于TCP显然不是理想的场景

还有一个TCP需要考虑的有用的方面.所有网络都有MTC,或者maximumtransmissionunit(最大传输单元),它会决定数据包的大小限制.如果你尝试发送大于MTU的数据包,它会没法通过.幸运的是,TCP在设计上会由操作系统自动将大的数据块分成合适大小的数据包.所以如果你需要从网站上下载1MB的文件,如果使用了TCP,那么分解为合适大小的数据包以及保证接收顺序的事情,程序员就不用操心了

在大多数场景中,通常不需要在游戏中传输那么大量的数据.但是还是会有用到的情况,比如说,如果游戏支持自定义地图,就会有一个问题,那就是新玩家试图加入游戏会话的时候是没有这张自定义地图的.通过TCP,就可以轻松地将自定义地图发送给试图进入游戏的新玩家,而且不用管地图的大小

UDP

数据包协议(UDP)是一种无连接,不可靠的协议.就是说你可以直接发UDP数据包,就是说你可以直接发UDP数据包,而不需要与指定目标建立连接.由于它是一个不可靠协议,所以不会有保证数据包到达的手段,也不会保证数据包到达的顺序,也没有接收者应答的功能.由于UDP是一种更加简单的协议,数据头比TCP要小得多

像TCP一样,UDP也支持大约65000个端口.UDP端口和TCP端口是独立的,所以如果TCP和UDP使用同一个端口是不会冲突的.由于UDP是不可靠的,UDP的传输比TCP要高效很多.但是由于不知道数据是否到达,也会造成一些问题.虽然有些数据不太重要(比如对手的位置信息),但还是会有一些重要的保证游戏状态一致的数据.如果玩多人FPS游戏,你发射了子弹,这个信息就很重要,要保证它被服务器或者其他玩家所接收

大多数游戏处理这个问题都是使用UDP,然后在所需的数据包里增加一些自定义的可靠层来完成.这个额外的层在UDP数据段的开始位置添加----可以认为是自定义的协议数据头.最基本的可靠性数据是顺序号,可以跟踪哪个数据包号是哪个,然后通过设置位域来应答.通过使用位域,某个数据包可以同时应答多个数据包,而不需要每个数据包都应答一次.这个系统同时还有灵活性,就是如果某个系统不需要可靠性和顺序信息,那么可以不添加数据头直接发送.

网络拓扑

拓扑决定了不同的计算机在网络游戏会话中是如何相互连接的.虽然配置上有很多种不同的方式,但大多数游戏都支持一种或两种模型:服务器/客户端或是点对点的.对于很多情况,两种方法各有优劣

服务器/客户端

在服务器/客户端模型中,有一个中心计算机(也就是服务器),所有的其他计算机(也就是客户端)都会与之通信.因为服务器与每一台客户端通信,所以在这个模型中,会需要一台有着比客户端更高带宽和处理能力的机器.比如说,如果客户端发送10Kbps数据,在8人游戏中,意味着服务器需要接收80Kbps的数据,这类模型通常也叫作中心型结构,因为服务器是所有客户端的中心节点

服务器/客户端模型是今天最流行的网络游戏拓扑结构.大多数FPS,动作游戏,MMO,策略游戏,回合制游戏等都使用服务器/客户端模型.当然当中会有一些例外,但是确实很多网络游戏都使用服务器/客户端模型

在常见的服务器/客户端模型的实现中,服务器会被认为是权威的,就是说它需要验证大多数客户端行为.假设网络游戏允许玩家向另一名玩家投掷闪避球,在另一名玩家被闪避球打中之后,投掷的玩家会得分.在权威服务器中,当玩家想投掷闪避球的时候,会先向服务器发起请求,服务器会检查这是否是一个合法动作.然后服务器会模拟闪避球的弹道,在每一帧检查这个球是否与某个客户端发生碰撞.如果客户端被击中,服务器会通知客户端被打败

服务器验证的理由有两个.第一个理由就是服务器会拥有所有客户端的最新位置信息.一个客户端投出闪避球,可能会认为自己投中了,但这可能是因为当时位置不是最新的.而如果客户端能够用闪避球淘汰其他玩家而无须经过服务器验证的话,就很容易有外挂程序作弊淘汰其他玩家.

因为服务器需要认证,服务器/客户端模型的游戏逻辑实现起来就比单人游戏更加复杂.在单人游戏中,如果用空格键发射导弹,相同的代码会检测空格键可以创建和发射导弹.但是在服务器/客户端游戏中,空格键代码必须创建发射请求数据包到服务器,然后服务器通知所有其他玩家导弹的存在.

如果我们回到闪避球游戏的例子,想象一下如果玩家可以选择目标.就是说,它们需要用一些方法以知道对手玩家的运动方向才能够预判出成功的投掷.在最好的情况下,客户端可以以四分之一秒一次地收到服务器下发的对手玩家的位置更新数据.现在想象如果客户端只在收到服务器数据的时候才更新对手位置,就是说每隔四分之一秒,对手玩家都会上传新的位置而对手位置总是闪来闪去.如果在这种情况下试着去击中一个闪避球----当然听上去游戏似乎不太好玩

为了解决这个问题,大多数游戏都会实现某种客户端预测,也就是客户端会在两次服务器下发数据之间猜测中间的过渡情况.在移动的例子中,如果服务器在下发对手玩家的速度的同时一起下发位置,那么客户端预测就可以工作.然后,在服务器更新之间的几帧里,客户端可以根据最后收到的速度和位置,推算对手玩家的位置

只要更新足够频繁,客户端就能让对手玩家在所有时刻都有足够准确的表现.但是,如果由于连接问题导致更新不够频繁,客户端预测就会变得不准确.由于服务器是最权威的存在,客户端必须修复预测位置与真实位置之间的差异.但是如果预测工作得足够好,就能看起来非常顺畅

这个概念还可以延伸到本地终端上执行的动作.如果我们想要一个游戏逻辑很顺畅,就要在玩家按下空格键投掷闪避球的时候,屏幕上立刻有投掷动画.如果客户端要等到服务器确认闪避球投掷之后才开始,游戏会非常卡顿.背后的解决方案就是在服务器确认投掷有效的同时,本地客户端就可以开始播放投掷闪避球动画.如果结果是投掷是非法的,客户端会修复这个问题.只要玩家正确同步,投掷闪避球的时候,游戏的感觉就会非常顺畅

尽管服务器/客户端模型是一种非常流行的方法,但还是有一些问题需要考虑.首先,有些游戏允许一台计算机同时运行服务器和客户端,但还是有一些问题需要考虑.首先,有些游戏允许一台计算机同时运行服务器和客户端.在这种情况下,就会有主机优势,就是说服务器,客户端同时跑的玩家,会得到最快速的服务器响应

另一个服务器/客户端模型的问题就是,如果服务器崩溃,游戏立刻就结束,而所有客户端都失去与服务器的通信.而连接到新的服务器是很困难的(因为所有客户端的信息都不完整),所以不可能修复这个问题.就是说如果玩家是主机,而且马上要失败了,玩家只要退出游戏就可以重启所有玩家,这样就让游戏变得没意思了.但是从服务器/客户端模型来看,如果一个客户端延迟非常多,对其他玩家的体验影响不是特别大

在任何事件中,为了防止主机带来的问题,许多服务器/客户端游戏只支持专用服务器.在大多数例子下,这意味着服务器是安装在一个特别的位置的(通常在数据中心),而所有玩家都需要连接到这些服务器(没有玩家可以做主机).虽然网速快的玩家还是比网速慢的玩家有优势,但是通过将服务器放在第三方的方法将这种绝对优势大大减弱了.可是,运行专用服务器的缺点就是部署得越多,费用就越高

点对点

在点对点模型中,每个客户端都连接到其他客户端.这意味着对于所有客户端都要求同样的性能和带宽.由于点对点模型中没有中心的权威服务器,会有很多种可能:每个客户端只认证自己的动作,或者每个客户端都认证其他客户端,又或者每个客户端都模拟整个世界

RTS类型种经常会用到点对点模型.正式一点的名称为帧同步模型,就是网络更新被分为每次150ms到200ms的回合更新.每当执行一次输入动作,这个命令都会保存到队列里,在每轮结束的时候执行.这就是为什么在你玩多人游戏《星际争霸2》的时候,控制单位的命令没有立刻执行----在单位回应命令之前有明显的延迟,因为它们都在等待帧同步回合的结束

因为输入命令通过网络传递,RTS游戏中的每个客户端实际上是在模拟所有单位,它们像本地玩家一样处理输入.这也使得记录下所有对手的指令并在游戏结束后查看即时回放成为可能

客户端的帧同步方法会使所有客户端都紧密地同步,没有任何玩家能够比其他玩家先走.当然,这种同步方式的缺点就是----如果一个客户端开始延迟,其他客户端都要等待,一直到这个玩家赶上来.但是帧同步方法在RTS游戏里面非常流行,因为它通过网络传输的数据相对来讲会更少.比起发送所有单位的信息,游戏只在每分钟发送相对小数量的动作,即使是最好的RTS玩家每分钟最多也就发送300-400次指令

因为在点对点配置中,每个点都要模拟游戏世界的一部分,游戏状态必须保证100%的确定,这使得基于变化的游戏逻辑不太好做.如果《星际争霸2》的狂热者可以根据掷骰来决定攻击伤害,这可能会导致这一点的模拟与另一点的模拟不一致.但这也不是说在点对点模型中完全不能有随机要素(比如《英雄连》),但是要付出更大的努力保证各点同步一致

虽然RTS是将点对点模型用得最多的游戏类型,但还有其他游戏也这么做.一个著名的例子就是《光晕》系列,在多人对战中使用点对点模型.但是可以说,点对点没有服务器/客户端模型使用那么广泛

作弊

任何网络游戏主要考虑的一点就是要为玩家打造公平的游戏环境.但不幸的是,有的玩家为了胜利会不择手段,哪怕打破游戏的规则.在网络多人游戏中,有很多规则都有可能会被打破,所以不管是否可能,网络游戏需要防止玩家作弊

信息作弊

信息作弊是一种让玩家获得本不该拥有的额外信息的手段.

游戏状态作弊

信息作弊能够让玩家获得不对称的优势,而游戏状态作弊则可以完全破坏游戏

中间人攻击

影响最坏的一种作弊就是在两台通信机器之间设立一台机器,用于拦截所有数据包.这种称为中间人攻击,在通过网络发送明文消息的时候特别容易被人篡改.这种攻击是为什么我们连接到金融机构网站的时候应该使用HTTPS而不是HTTP的主要原因

使用HTTP,所有数据都以明文的方式通过网络发送.就是说,如果在网站上你用HTTP提交你的用户名和密码,容易遇到中间人窃取信息.而通过HTTPS,所有数据都是加密的,这使得中间人几乎不可能访问到这些信息.虽然HTTPS协议暴露出几个脆弱的地方,但是对于大多数用户而言这都不是大问题

但是,在游戏的情况下,中间人攻击最大的优点在于它允许作弊发生在不需要玩游戏的机器.这就是说几乎所有的外挂检测程序都不起作用----它们根本就不知道数据包被拦截而且被篡改了.还有一个问题是,一般通过访问数据包信息可能允许黑客更加深入地发掘其他能够渗透游戏的漏洞

Frohnmayer,MarkandTimGift."TheTRIBESEngineNetworkingModel."这份经典的论文列出了如何实现为UDP增加可靠性和数据流功能,而且应用到了Tribes上

Steed,AnthonyandManuelOliveria.NetworkedGraphics.Burlington:MorganKaufmann,2009.这本书深入地讲解了游戏网络编程的方方面面

THE END
1.成为算法工程师需要掌握哪些技术?行业专家详解在当今科技飞速发展的时代,算法工程师作为技术驱动的重要角色,正受到越来越多的关注。要成为一名优秀的算法工程师,需要掌握多方面的技术,包括编程技能、数学基础、机器学习和深度学习理论等。本文将详细探讨这些技能要求,并分享一些实用的建议。一、编程技能编程技能是算法工程师的基石。熟练掌握至少一种编程语言,如https://baijiahao.baidu.com/s?id=1812949519252325344&wfr=spider&for=pc
2.游戏编程算法与技巧(豆瓣)《游戏编程算法与技巧》介绍了大量今天在游戏行业中用到的算法与技术。《游戏编程算法与技巧》是为广大熟悉面向对象编程以及基础数据结构的游戏开发者所设计的。作者采用了一种独立于平台框架的方法来展示开发,包括2D 和3D 图形学、物理、人工智能、摄像机等多个方面的技术。《游戏编程算法与技巧》中内容几乎兼容所有游https://book.douban.com/subject/26906838/
3.算法与程序设计系列课程教学团队介绍内蒙古工业大学算法与程序设计系列课程教学团队,面向计算机类专业,承担数据结构与算法、程序设计基础、数据结构与算法综合设计、Python程序设计系列等专业核心课程与实践课程,团队教学任务年均约600学时,以培养学生的计算思维与编程能力为目标。“数据结构与算法”线上线下混合式课程为自治区一流课程,“数据结构与算法”在线https://dsj.imut.edu.cn/info/1029/3977.htm
4.程序设计与算法Coursera本专项课程旨在系统培养你的程序设计与编写能力。系列课程从计算机的基础知识讲起,无论你来自任何学科和行业背景,都能快速理解;同时我们又系统性地介绍了C程序设计,C++程序设计,算法基础,数据结构与算法相关的内容,各门课之间联系紧密,循序渐进,能够帮你奠定坚实的程序开发基础;课程全部配套在线编程测试,将有效地训练和https://www.coursera.org/specializations/biancheng-suanfa
5.做算法能不写代码吗?实现算法:编程让我们能够将算法转化为具体的代码实现。通过编写代码,我们可以将算法的思想转化为计算机可执行的指令,从而解决实际的问题。 调试和测试:编程使我们能够调试和测试算法的实现。通过编写测试用例和调试代码,我们可以验证算法的正确性,发现潜在的错误和问题,并进行修复。 https://m.w3cschool.cn/article/36256828.html
6.机器学习算法原理与编程实践(郑捷)完整pdf扫描版[126MB]电子书下机器学习算法原理与编程实践是机器学习原理和算法编码实现的基础性读物,内容分为两大主线:单个算法的原理讲解和机器学习理论的发展变迁。算法除包含传统的分类、聚类、预测等常用算法之外,还新增了深度学习、贝叶斯网、隐马尔科夫模型等内容。对于每个算法,均包括提出问题、解决策略、数学推导、编码实现、结果评估几部分。https://www.jb51.net/books/527823.html
7.算法与程序设计教学(精选十篇)本节教学内容选自广东教育出版社信息技术选修模块教材《算法与程序设计》。面对初学程序和算法的高中二年级学生而言, 本节内容偏理论、较抽象。如果直接讲算法, 学生很难建立新旧知识的联系, 更难真正理解算法的含义。笔者遵循认知规律, 从学生的感性认识入手, 从他们的兴趣出发, 通过对现实生活具体问题的讨论, 使他们https://www.360wenmi.com/f/cnkeypujd664.html
8.0x11浅谈RSA加密算法的数学原理与编程实现贝祖等式和编程实现涉及的坑比较深,此外数论背景部分要加上实例说明,待更新。 1 RSA加密算法简介 1.1 RSA算法实现步骤[3] RSA算法是一种典型的非对称加密算法,本小节将简要介绍RSA算法的实现步骤,对RSA算法原理的分析则留到第3章叙述。RSA算法实现通信的加、解密分为6个步骤,如下: 1) 比如p与q越大,越安全。https://www.jianshu.com/p/17e683cbd9f2
9.C++程序设计基础编程抽象与算法策略内容简介: 本书是一本关于C++语言的经典书籍,全书共计20章,主要介绍了C++的基本知识、函数和库、字符串、流、集合、类的设计、递归、递归策略、回溯算法、算法分析、指针与数组、动态内存管理、效率与表示、线性结构、映射、树、图、继承、迭代的策略等内容。本书重点图突出,全面讲解了C++语言的基本概念,深入剖析了https://download.eeworld.com.cn/detail/toothache/632605
10.算法与程序设计20211127163548.pdf第二部分算法与程序设计(选修) 主题1算法与程序设计 1.1算法 1.1.1计算机解决问题的过程 知识点 1:人是如何解决问题的 【知识链接】 本考点要求学生达到“了解”水平。 解决问题的过程可以总结为:观察、分析问题,收集必要的信息,尝试按照一定的方法和步骤 解决问题。一般来说,同一个问题可以有多种解决方法,但不https://max.book118.com/html/2021/1127/6034242232004101.shtm
11.《C++程序设计:基础编程抽象与算法策略》((美)埃里克S·罗伯茨当当网图书频道在线销售正版《C++程序设计:基础、编程抽象与算法策略》,作者:(美)埃里克S·罗伯茨(Eric S. Roberts),出版社:机械工业出版社。最新《C++程序设计:基础、编程抽象与算法策略》简介、书评、试读、价格、图片等相关信息,尽在DangDang.com,网购《C++http://product.dangdang.com/24102274.html
12.编程竞赛宝典C++语言和算法入门相应地,各类以算法为主的编程竞赛也层出不穷:在国内,有全国青少年信息学奥林匹克联赛(National Olympiad in Informatics in Provinces,NOIP),该联赛与全国中学生生物学联赛、全国中学生物理竞赛、全国高中数学联赛、全国高中学生化学竞赛并称为国内影响力最大的“五大奥赛”;在国际上,有面向中学生的国际信息学奥林匹克https://www.epubit.com/bookDetails?id=UB77a9ce8133887
13.程序=数据结构+算法《禅与计算机程序设计艺术》/陈光剑多数元件具有两个稳定状态,二进制运算也比较简单,而且能节省设备,二进制与处理机逻辑运算能协调一致,且便于用逻辑代数简化处理机逻辑设计。二进制遂得到广泛应用。 逻辑代数 布尔创建了逻辑代数,也称布尔代数,在很大程度上, 为后来的电路设计及其简化,做出了很大的贡献。现在很多编程语言中都内部了布尔类型,以纪念这位先https://cloud.tencent.com/developer/article/1815180
14.初识C语言之算法设计篇——带你走进编程世界的小院!也就是说,能够对一定规范的输入?,在有限时间内获得所要求的输出。如果一个算法有缺陷,或不适合于某个问题,执行这个算法将不会解决这个问题。不同的算法可能用不同的时间、空间或效率来完成同样的任务。一个算法的优劣可以用空间复杂度?与时间复杂度来衡量。https://blog.51cto.com/u_15172991/5614704
15.什么是编程和建模?Worktile社区在线建模和在线编程通常与领域特定语言(DSL)和自动化工具相结合,以增强系统的灵活性和适应性。 编程和建模是计算机科学和工程领域中最基本的技能之一。掌握编程和建模技巧,可以帮助我们更好地理解和利用计算机,从而解决现实世界中的各种问题。无论是开发软件、设计算法、进行数据分析还是实现机器学习模型,都需要编程和https://worktile.com/kb/ask/2025634.html
16.oj刷题西安理工大学学生在线实验系统编程题答案(超级详细)对于不理解的部分,可以通过查阅相关书籍、在线教程或者与其他同学讨论来深化理解。在熟悉了这些基础题目后,可以挑战更高难度的OJ题目,提升编程能力和算法水平。 西安理工大学的在线实验系统编程题答案集是一个极好的学习资源,可以帮助学生巩固基础知识,提高编程技能,为参加各类编程竞赛和未来的工作做好准备。利用好这个https://download.csdn.net/download/weixin_45594995/12283838