第1章 游戏编程概述 (已看)
第2章 2D图形 (已看)
第3章 游戏中的线性代数 (已看)
第4章 3D图形 (已看)
第5章 游戏输入 (已看)
第6章 声音 (已看)
第7章 物理 (已看)
第8章 摄像机 (已看)
第9章 人工智能 (已看)
第10章 用户界面 (已看)
第11章 脚本语言和数据格式 (已看)
第12章 网络游戏 (已看)
第1章 游戏编程概述
游戏编程的发展
世界上第一款商业游戏Computer Space在1971年推出.是后来Atari的创办人Nolan Bushnell 和 Ted Dabney 开发的.这款游戏当时并不是运行在传统计算机上的.它的硬件甚至没有处理器和内存.它只是一个简单的状态机,在多个状态中转换.Computer Space的所有逻辑必须在硬件上完成.
随着"Atari 2600"在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推出的Full Spectrum Warrior 总共有15名程序员参与开发,大部分都是专门开发某个功能的.但这个增加比起下个时期可以说微不足道
Xbox360,PS3和Wii时期(2005-2013年)
家用机的高画质表现导致游戏开发进入了两种境地.AAA游戏变得更加庞大,也有着相应庞大的团队和费用.而独立游戏则跟过去的小团队开发规模相仿
游戏编程的另一个大的开发趋势就是中间件及开源的出现.有的中间件是完整的游戏引擎,比如Unreal,Unity.有的则是专门做某个系统,比如物理引擎Havok Physics. 这样就节省了大量的人力,财力.但是缺点就是某个策划功能可能会受到中间件的限制
游戏的未来
尽管游戏开发多年来有许多变迁,有趣的是,许多早期概念到现在仍然适用,20年都没变的核心概念的基础部分: 游戏循环, 游戏时间管理和游戏对象模型
游戏循环
整个游戏程序的核心流程控制称为游戏循环.之所以是一个循环,是因为游戏总是不断地执行一系列动作直到玩家退出.每迭代一次游戏循环称为1帧.大部分实时游戏每秒钟更新30~60帧.如果一个游戏跑60FPS(帧/秒),那么这个游戏循环每秒要执行60次.
游戏开发中有着各种各样的游戏循环,选择上需要考虑许多因素,其中以硬件因素为主
传统的游戏循环
一个传统的游戏循环可以分成3部分: 处理输入,更新游戏世界,生成输出.一个基本的游戏循环可能是这样的:
while game is running process inputs update game world generate outputs loop
process inputs会检查各种输入设备,比如键盘,鼠标,手柄.但是这些输入设备并不只输入已经想到的,任何外部的输入在这一阶段都要处理完成
update game world 会执行所有激活并且需要更新的对象.这可能会有成千上万个游戏对象.
对于generate outputs, 最耗费计算量的输出通常就是图形渲染成2D或3D.另外还有其他一些输出,比如音频,涵盖了音效,背景音乐,对话,跟图形输出同样重要.
// 处理输入 JoystickData j = grab raw data from joystick // 游戏世界更新 update player.position based on j foreach Ghost g in world if player colliders with g kill either player or g else update AI for g based on player.position end loop // Pac-Man 吃到药丸 ... // 输出结果 draw graphics update audio loop
多线程下的游戏循环
时间和游戏
大多数游戏都有关于时间进展(progression of time)的概念.对于实时游戏,时间进展通常都很快.比如30FPS(Frame Per Second)的游戏,每帧大约用时33ms.即使是回合制游戏也是通过时间进展来完成功能的,只不过它们使用回合来计数
真实时间和游戏时间
真实时间,就是从真实世界流逝的时间;游戏时间,就是从游戏世界流逝的时间.区分它们很重要.虽然通常都是1:1,但不总是这样.比如说,游戏处于暂停状态.虽然真实时间流逝了,但是游戏时间没有发生变化
马克思 佩恩 运用了"子弹时间"的概念去减慢游戏时间.这种情况下,游戏时间比实际时间要慢得多.与减速相反的例子就是,很多体育类游戏都提升了游戏速度.在足球游戏中,不要求玩家完完全全地经历15分钟,而是通常都会让时钟拨快1倍.还有一些游戏甚至会有时间倒退的情形,比如<<波斯王子:时之沙>>就可以让玩家回到之前的时刻
通过处理时间增量来表示游戏逻辑
对于 enemy.position.x += 5 16MHz的处理器比8MHz的处理器要快1倍.
为了解决这样的问题,需要引入增量时间的概念: 从上一帧起流逝的时间 enemy.position.x += 150 * deltaTime
while game is running realDeltaTime = time since last frame gameDeltaTime = realDeltaTime * gameTimeFactor // 进程输入 ... update game world with gameDeltaTime // 渲染输出 ... loop
targetFrameTime = 33.3f while game is running realDeltaTime = time since last frame gameDeltaTime = realDeltaTime * gameTimeFactor // 处理输入 ... update game world with gameDeltaTime // 渲染输出 ... while (time spent this frame) < targetFrameTime // 做一些事情将多出的时间用完 ... loop loop
游戏对象
广义上的游戏对象是每一帧都需要更新或者绘制的对象.虽然叫作"游戏对象",但并不意味着就必须用传统的面向对象.有的游戏采用传统的对象,也有的用组合或者其他复杂的方法.不管如何实现,游戏都需要跟踪管理这些对象,在游戏循环中把它们整合起来.
游戏对象的类型
更新和绘制都需要的对象. 任何角色,生物或者可以移动的物体都需要在游戏循环中的 update game world 阶段更新,还要在 generate outputs 阶段渲染.在<<超级马里奥兄弟>>中,任何敌人及所有会动的方块都是这种游戏对象
只绘制不更新的对象, 称为静态对象. 这些对象就是那些玩家可以看到,但是永远不需要更新的对象.他可以是游戏背景中的建筑.一栋建筑不会移动也不会攻击玩家,但是需要绘制
需要更新但不需要绘制的对象. 一个例子就是摄像机.技术上来讲,你是看不到摄像机的,但是很多游戏都会移动摄像机.另一个例子就是触发器.触发器会检测玩家的位置,然后触发合适的行为.所以触发器j就是一个看出见的检测与玩家碰撞的盒子.
游戏循环中的游戏对象
class GameObject // 成员变量/函数 ... end interface Drawable function Draw() end interface Updateable function Update(float deltaTime) end // 只更新的游戏对象 class UGameObject inherits GameObject, implements Updateable // 重载更新函数 ... end // 只渲染的游戏对象 class DGameObject inherits GameObject, implements Drawable // 重载绘制函数 ... end class DUGameObject inherits UGameObject, implements Drawable // 重载绘制和更新函数 ... end class GameWorld List updateableObjects List drawableObjects end while game is running realDeltaTime = time since last frame gameDeltaTime = realDeltaTime * gameTimeFactor // 处理输入 ... // 更新游戏世界 foreach Updateable o in GameWorld.updateableObjects o.Update(gameDeltaTime) loop // 渲染输出 foreach Drawable o in GameWorld.drawableObjects o.Draw() loop // 帧数限制代码 ... loop
相关资料
游戏编程的发展
Crane,David. "GDC 2011 Classic Postmortem on Pitfall!" (https://www.youtube.com/watch?v=tfAnxaWiSeE). Pitfall! 的作者 David Crane 的1小时演讲,关于在Atari上开发的心得.
游戏循环
Gregory,Jason. Game Engine Architecture. Boca Raton: A K Peters, 2009. 这本书用了几节篇幅讲了多种多线程下的游戏循环,包括在PS3的非对称CPU架构上使用的情况.
West,Mick "Programming Responsiveness" 和 "Measuring Responsiveness"(http://tinyurl.com/594f6r和http://tinyurl.com/5qv5zt).这些是Mick West在Gamasutra上些的文章,讨论了那些导致输入延迟的因素,同时也对游戏中的输入延迟进行了测量
游戏对象
Dickheiser, Michael,Ed. Game Programming Gems 6. Boston: Charles River Media, 2006. 这卷书中的一篇文章 "Game Object Component System" 讲了一种与传统面向对象游戏对象模型所不同的方法.虽然实现上有点复杂,但是越来越多的商业游戏中的游戏对象通过组合的形式来实现.
第2章 2D图形
2D渲染基础
CRT显示器基础
在多年前,阴极射线管(Cathode Ray Tube)显示器是显示器的主流.CRT里图像的元素就是像素.对于彩色显示器,每个颜色由红,绿,蓝组成.显示器的分辨率决定了像素的数量.比如一个300x200的显示器由200行像素,叫作扫描线.每个扫描线可以有300个元素,所以总共有60000个像素之多.位于(0, 0)的像素通常在左上角,但不是所有显示器都这样.
CRT内部,绘制图像是通过电子枪发射电子流完成的.这把枪从左上角开始沿第一条扫描线进行绘制.当它完成之后就继续下一条扫描线,然后不断地重复,直到所有扫描都画完
当电子枪刚刚完成一帧的绘制的时候,它的枪头在右下角.喷枪从右下角移动到左上角所花费的时间,我们称为场消隐期(VBLANK).这个间隔以ms计,间隔不是由CRT,计算机或者电视机决定的,而是由用途决定的
像素缓冲区和垂直同步
新的硬件使得由足够的内存将所有颜色保存在像素缓冲区中.但这不是说游戏循环就可以完全无视CRT喷枪.假设喷枪在屏幕上绘制到一半时,刚好游戏循环到了"generate outputs"阶段.它开始为新的一帧往像素缓冲区写像素时,CRT还在上一帧的绘制过程中.这就导致了屏幕撕裂,具体表现就是屏幕上同时显示了不同的两帧的各自一半画面.更糟糕的是,新一帧的数据提交时,上一帧还没开始.这就不仅是一帧中有两半不同的画面了,而是连画面都没有
一个解决方案就是同步游戏循环,等到场消隐期再开始渲染.这样会消除分裂图像的问题,但是它限制了游戏循环的间隔,只有场消隐期期间才能进行渲染,对于现在的游戏来说是不行的
另一个解决方案叫作双缓冲技术.双缓冲技术里,有两块像素缓冲区.游戏交替地绘制在这两块缓冲区里.在1帧内,游戏循环可能将颜色写入缓冲区A,而CRT正在显示缓冲区B.到了下一帧,CRT显示缓冲区A,而游戏循环写入缓冲区B.由于CRT和游戏循环都在使用不同的缓冲区,所以没有CRT绘制不完整的风险
为了完全消灭屏幕撕裂,缓冲区交换必须在场消隐期进行.这就是游戏中常见的垂直同步设置.技术上来讲这是不恰当的,因为垂直同步是显示器在场消隐期刚结束时才告诉你的信号.不管怎样,缓冲区交换是y一个相对快速的操作,游戏渲染一帧花费的时间则长的多(尽管理想中要比CRT绘制一帧快).所以在场消隐期交换缓冲区完全消除了屏幕撕裂风险
有些游戏确实允许缓冲区交换在绘制完成前尽快进行,这就会导致屏幕撕裂的可能.这种情况通常是因为玩家想要获得远比屏幕刷新速度快的频率.如果一款显示器有60Hz的刷新率,同步缓冲区交换到场消隐期最多只有60Hz.但是玩家为了减少输入延迟(或者有很快的机器相应速度),可能会消除同步以达到更高的帧率
function RenderWorld() { // 绘制游戏世界中所有对象 ... wait for VBLANK swap color buffers end
虽然CRT显示器今天几乎不再使用,但是双缓冲技术在正确的时间交换还是能在LCD上消除屏幕撕裂的问题.一些游戏甚至使用了三缓冲技术,使用3个缓冲区而不是两个.三缓冲区能使帧率在一些特殊情况下更加平滑,但也增加了输入延迟
精灵
精灵是使用图片中的一个方块绘制而成的2D图像.通常精灵用来表示角色和其他动态对象.对于简单的游戏来讲,精灵也可能用于背景,虽然有更加有效的方式来完成,特别是静态的背景.大多数2D游戏运用大量的精灵,对于移动端来说,精灵通常就是游戏体积(磁盘空间占用)的主要部分.所以,高效利用精灵是非常重要的
绘制精灵
最简单的绘制场景的方式是,先画背景后画角色.这就像画家在画布上画画一样,也因为这样,这个算法叫作画家算法
class Sprite ImageFile image int drawOrder int x, y function Draw() // 把图片在正确的(x, y)上绘制出来 ... end end 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
画家算法也可以运用在3D环境下,但它有很多缺陷.而在2D场景中,画家算法工作得很号
动画精灵
struct AnimFrameData // 第1帧动画的索引 int startFrame // 动画的所有帧数 int numFrames end struct AnimData // 所有动画用到的图片 ImageFile images[] // 所有动画用到的帧 AnimFrameData frameInfo[] end 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 function AnimatedSprite.Initialize(AnimData myData, int startingAnimNum) animData = myData ChangeAnim(startingAnimNum) end function AnimatedSprite.ChangeAnim(int num) animNum = num // 当前动画为第0帧的0.0f时间 frameNum = animTime = 0.0f // 设置当前图像, 设置为startFrame int imageNum = animData.frameInfo[animNum].startFrame image = animData.images[imageNum] end function AnimatedSprite.UpdateAnim(float deltaTime) // 更新当前帧播放时间 frameTime += deltaTime // 根据frameTime判断是否播放下一帧 / 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, / animFPS) end end
精灵表单(SpriteSheet)
使用单张图片区存储所有精灵,称之为精灵表单.
在精灵表单中,可以让精灵打包尽可能地靠近,从而减少浪费的无用空间
精灵表单的另一个优势就是很多GPU要纹理加载后才能绘制.如果绘制过程中频繁地切换纹理,会造成相当多的性能损耗,特别是对于大一点的精灵.但是如果所有精灵到在一张纹理中,是可以消除切换产生的损耗的
取决与于游戏中精灵的数量,把所有的精灵都放入一张纹理是不现实的.大多数硬件都有纹理最大尺寸限制
滚屏
单轴滚屏
在单轴滚屏游戏中,游戏只沿x轴或者y轴滚动.
最简单的方法j就是把关卡背景按屏幕大小进行切割
// 一台iPhone 4/4S 屏幕大小为960 x 640 ; // 所有屏幕大小的背景图 string backgrounds[] = { "bg1.png", "bg2.png", /* ... */ } // 所有水平摆放的屏幕大小的背景图数量 ; 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 = bgSpriteList.Add(bgSprite) hCount++ loop
// camera.x就是player.x在区间中经过clamp的值 camera.x = clamp(player.x, screenWidth / , hCount * screenWidth - screenWidth / ) 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 // 0 - 480 + 480 = 0 draw s at (s.x - camera.x + screenWidth / , ) i++ s = i.value() draw s at (s.x - camera.x + screenWidth / , ) break end i++ loop
无限滚屏
无限滚屏就是当玩家失败才停止滚屏的游戏.当然,这里不可能有无限多个背景来滚屏.因此游戏中的背景会重复出现.当然,大部分无限滚屏游戏都拥有大量的背景图片和很好的随机性来产生丰富的背景.通常游戏都会用连续的四五张图片组成序列,有了不同系列的图片可选后再打乱重组
平行滚屏
在平行滚屏中,背景拆分成几个不同深度的层级.每一层都用不同的速度来滚动以制造不同深度的假象
四向滚屏
在四向滚屏中,游戏世界会在水平和垂直方向上滚动
// 这里假设用二维数据array[row][column]记录所有片段 , i < vCount, i++ // 这是正确的行吗 ].y) < screenHeight , j < hCount, j++ // 这是正确的列吗 if (camera.x - segments[i][j].x) < screenWidth // 这是左上角的可见片段 end loop end loop
砖块地图(TileMap)
砖块地图通过把游戏世界分割成等分的方块(或者其他多边形)来解决这个问题.每个方块代表的精灵占据着某一块网格位置.这些引用的精灵可以放在多张或者一张砖块集合里.所以如果树木在砖块集合中的索引号为0,每个表示树木的方块都可以用0表示.虽然正方形是砖块地图的最常见形式,但这不是必须的.一些游戏采用六边形,有的则采用平行四边形.这主要取决于你希望的视角
不管什么情况,砖块地图是一种很好的节省内存的方式,这让策划和美工更容易工作.那些动态创建内容的2D游戏,比如Spelunky,没有砖块地图将很难实现
简单的砖块地图
// 基本关卡文件格式 5 x 5 , , , , , , , , , , , , , , , , , , , , , class Level int width, height int tiles[][] function Draw() end function Draw() , row < height, row++ , col < width, col++ // 在 (col * tileSize, row * tileSize)绘制tiles[row][col] loop loop end
虽然这种基于文本的砖块地图在简单关卡中运行顺利,但是在商业游戏中,会采用更加健壮的格式
斜视等视角砖块地图
在斜视等视角中,视角通过旋转,让场景更具深度感
相关资料
Cocos2D
iOS游戏开发最流行的2D代码库,在http://cocos2d.org/可以获取
Itterheim, Stephen. Learn Cocos2d 2: Game Development for iOS. New York: Apress, 2012. 虽然有很多本书讲Cocos2D,但这本书是相对强的
SDL
Simple DirectMedia Layer(SDL) 是另一种可供选择的代码库.这是用C语言开发的流行的跨平台2D游戏代码库,同时也可移植到其他语言.SDL可以在www.libsdl.org获取.
第3章 游戏中的线性代数
向量
向量表示了n维空间下的长度和方向,每个维度都用一个实数去表示
当解决向量问题的时候,你会发现在不同位置都可以绘制向量是非常有帮助的.因为改变向量绘制的位置并不会改变向量本身,这个常用的技巧要铭记在心
加法
减法
长度,单位向量和正规化
向量长度等于1的时候就是单位向量.单位向量(unit vector)的符号就是上方加一顶帽子,就像这样
把非单位向量转换成单位向量,这个转换叫作正规化(normalize).
一个经验法则就是,那些只关心方向的向量,你可以将它们正规化.如果你关心方向和长度,那么向量不应该正规化
标量乘积
点乘
问题举例:向量反射
叉乘
两个向量叉乘会得到第3个向量.给定两个向量,可以确定一个平面.叉乘得到的向量就会垂直于这个平面,称之为平面的法线
因为它能得到平面的法线,所以叉乘只能在3D向量中使用.这就意味着,为了在2D向量中使用叉乘,要先通过将z分量设为0的方式转换成3D向量
值得注意的是,技术上来讲,平面的垂直向量有两个:与c反方向的向量.所以你怎么知道叉乘结果向量的朝向?结果取决于坐标系的手系
0
你可能会注意到,如果你讲将食指对准b,中指对准a,拇指的朝向就会是反方向.这是因为叉乘不满足交换律,实际上它满足反交换律:
跟点乘一样,叉乘也有要注意的特殊情况.如果叉乘返回的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点
坐标系
有时候x轴,y轴和z轴用轴向量.轴向量用于定义坐标系
矩阵
加法/减法
标量乘法
乘法
逆矩阵
转置
用矩阵变换3D向量
相关资料
Lengyel, Eric. Mathematics for 3D Game Programming and Computer Graphics (Third Edition). Boston: Course Technology, 2012. 这本书讨论了本章很多概念的细节,有完整的计算和证明.它也涵盖了超出本书范畴的更加复杂的数学,但是对于一些游戏程序员还是很有用的(特别是专注于图形学领域的程序员).
第4章 3D图形
基础
第一款3D游戏中的渲染是完全以软件方式实现的(即没有硬件支持).这意味着即使是画线这种基础功能都要图形程序员去完成.这套3D模型正确渲染到2D颜色缓冲的算法称为软件光栅化,大部分计算机图形学会花费大量时间在这些算法上.但是现代的计算机已经有了称之为图形处理单元(GPU)的图形硬件,这些硬件实现了绘制点,线,三角形等功能
由此,现代游戏不再需要开发实现软件光栅化了.而焦点则转变为将需要渲染的3D场景数据以正确的方式传递给显卡,一般都通过像OpenGL和DirectX这样的库完成.如果需要进一步自定义这个需求,可以编写一段运行在显卡上称之为着色器的小程序来应用传入的数据.在这些数据都传递完成之后,显卡就会将这些数据绘制在屏幕上.编写Bresenham画线算法的日子一去不复返
在3D图形中需要注意的是,经常需要计算近似值.这只是因为计算机没有足够的时间去计算真实的光照.游戏不像CG电影那样,可以花上几个小时就为了渲染一帧的画面.一个游戏通常需要每秒画30帧或者60帧,所以精确度需要在性能上做出折中.由于近似模拟而产生的显示错误称之为图形失真,没有游戏可以完全避免失真
多边形
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类同时用于表示向量和顶点.因此,通过命名规范来保持语义是很重要的
Vector4 playerPosition // 这是个点
Vector4 playerFacing // 这是个向量
用于变换的矩阵通常是4 x 4矩阵.为了与4 x 4矩阵相乘,同时也需要4D向量.
矩阵变换就是矩阵用某种方法来影响向量或者顶点.矩阵变换使得我们可以将模型坐标系变换为世界坐标系
WorldTransform = Scale x Rotation x Translation
视角/摄像机坐标系
在所有对象放置到世界坐标系上正确的位置之后,下一件要考虑的事情就是摄像机的位置.一个场景或者关卡可以完全静止,但是如果摄像机的位置改变,就完全改变了屏幕上的显示.这个称为视角/摄像机坐标系
所以还需要另外一个矩阵告诉显卡如何将世界坐标系的模型变换到相对于摄像机的位置上.最常见的矩阵是观察矩阵.在观察矩阵当中,摄像机的位置通过3个轴的额外分量来表示
function CreateLookAt(Vector3 eye, Vector3 target, Vector3 Up) { Vector3 F = Normalize(target - eye) Vector3 L = Normalize(CrossProduct(Up, F)) Vector3 U = CrossProduct(F, L) Vector3 T T.x = -DotProduct(L, eye) T.y = -DotProduct(U, eye) T.z = -DotProduct(F, eye) // 通过F, L, U和T创建并返回观察矩阵 end
投影坐标系
投影坐标系有时候也叫作屏幕坐标系,是一种将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种分量都会影响物体的颜色
环境光分量用于整体照亮场景.由于它均匀地作用域整个场景,所以环境光分量与光源的位置和摄像机的位置无关
漫反射分量是光源作用于物体表面的主要反射.它会被所有方向光,点光源和聚光灯影响.为了计算漫反射分量,你同时需要物体表面的法线和物体表面到光源方向的向量.但是跟环境光分量一样,漫反射同样也不被摄像机的位置影响
高光分量,表示物体表面上闪亮的高光.一些有强高光的物体,比如光滑的金属,会比涂上暗色涂料的物体光亮很多.类似漫反射分量,高光分量也同时取决于光源的位置和物体表面的法线.但它还取决于摄像机的位置,因为高光会随着视角方向变换而变换
// Vector3 N = 物体表面的法线 // Vector3 eye = 摄像机的位置 // Vector3 pos = 物体表面的位置 // float a = 高光量 Vector3 V = Normalize(eye - pos) // 从物体表面到光源 Vector3 Phong = AmbientColor foreach Light light in scene if light affects surface Vector3 L = Normalize(light.pos - pos) // 从物体表面到光源 Phong += DiffuseColor * DotProduct(N, L) Vector3 R = Normalize(Reflect(-L, N)) // 计算-L关于N的反射 Phong += SpecularColor * pow(DotProduct(R, V), a) end end
着色
着色就是计算表面的三角片如何填充.最基础的着色类型是平面着色,就是整个三角片只有一种颜色.使用平面着色,只需每个三角片进行一次光照计算(通常在三角片的中心),然后把通过计算得到的颜色赋予整个三角片.这样做基本能实现着色,可是不好看
有一种稍微复杂一点的着色方法,我们称之为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游戏中非常少用
深度缓冲区
在深度缓冲区中,会有一块额外的缓冲区仅在渲染的过程作用中使用.这块额外的缓冲区称为深度缓冲区,为场景中的每个像素存储数据,就像颜色缓冲一样.但是跟颜色缓冲区存储颜色不同,深度缓冲区存储的是像素到摄像机的距离(或深度).更准确地讲,每帧用到的缓冲区(包括颜色缓冲区,深度缓冲区,模板缓冲区等)统称为帧缓冲
在使用深度缓冲区的每一帧渲染开始之前,深度缓冲区都会清空,确保当前深度缓冲区中的所有像素都无限远.然后,在渲染的过程中,像素的深度会在绘制之前先计算出来.如果该像素的深度比当前深度缓冲区存储的深度小,那么这个像素就进行绘制,然后新的深度值会写入深度缓冲区.所以每帧第一个绘制的对象总是能够将它的所有颜色和深度分别写入颜色缓冲区和深度缓冲区.但是当绘制第二个对象的时候,那些比已有像素更远的像素都不会进行绘制.
// zBuffer[x][y]存储像素深度 foreach Object o in scene foreach Pixel p in o float depth = calculate depth at p if zBuffer[p.x][p.y] > depth draw p zBuffer[p.x][p.y] = depth end end end
通过深度缓冲,场景可以以任意顺序绘制,如果场景中没有透明的物体,那么绘制结果一定正确.这也不是说顺序不相关,比如说,如果场景从后往前绘制,你每帧会绘制很多无用的像素.只是深度缓冲方法背后的思想是无序对场景进行排序,这样会显著提升性能.而且由于深度缓冲针对像素工作,而不是每个对象,因此哪怕遇到三角片重叠的情况也没问题
但也不是说深度缓冲可以解决所有可见性问题.比如说,透明对象在深度缓冲中就不太使用.假设有一滩半透明的水,水底有石头.如果使用纯深度缓冲的方法先画水体,那么水体会写入深度缓冲,这样就会影响石头的绘制.为了解决这个问题,应用深度缓冲先画所有不透明的物体.然后可以关掉深度缓冲写入功能,渲染所有透明物体.但为了确保在不透明物体的背后的对象不进行渲染仍需进行深度检查
像颜色一样,深度缓冲的表示方法也有固定的几种.最小的深度缓冲区为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向量和浮点标量一样,物体的旋转也可以存储为四元数
不管哪种用途,游戏中的四元数全部都是标准四元数,就像单位向量一样,四元数的长度为1.一个四元数有着一个向量和一个标量,经常都写为q = [qv, qs].向量和标量的计算由旋转的轴a和旋转的角度θ决定:
要注意的是旋转的轴是必须正规化的.如果没有这样做,你可能会发现物体以奇怪的方式缩放.如果你刚开始使用四元数,物体以奇怪的方式拉扯,很可能就是因为四元数没有以正规化的轴来创建
还可以一个接一个地使用四元数运算.为了这么做,四元数应该以相反的顺序乘在一起.所以如果一个物体先旋转q然后旋转p,那么乘积j就是pq.两个四元数相乘采用Grassmann积:
与矩阵一样,四元数也存在逆四元数.幸运的是,计算标准四元数的逆只需要将分量取逆即可.将向量部分取反也称为共轭四元数.
由于由逆四元数,那么也有单位四元数.定义如下:
3D游戏对象的表示
class 3DGameObject Quaternion rotation Vector3 position float scale function GetWorldTransform() // 先缩放, 后旋转, 最后平移, 顺序很重要 Matrix temp = CreateScale(scale) * CreateFromQuaternion(rotation) * CreateTranslation(position) return temp end end
相关资料
Akenine-Moller, Tomas, et. al. Real-Time Rendering (3rd Edition). Wellesley: AK Peters, 2008. 这是一本图形渲染的资料大全.虽然最新的版本也是几年前出版的,但是它始终是迄今为止对图形程序员而言最佳的渲染读物
Luna,Frank. Introduction to 3D Game Programming with DirectX 11. Dulles: Mercury Learning & Information, 2012. 讲渲染的书经常都会关注到特定的API,所以有很多DirectX和OpenGL的书.Luna从2003年起就开始写DirectX相关的书,在我刚开始称为游戏程序员的时候就从它的早期作品中学到了很多内容.所以如果你打算使用DirectX来开发游戏(只为PC平台),我会推荐这本书
第5章 游戏输入
输入设备
绝大多数的输入形式可以分成两种: 数字与模拟. 数字形式的输入就是那些只有两种状态的"按下"和"没有按".举个例子,键盘上的按键是数字的,空格键要么按下要么没按下.绝大多数的键盘不存在两种状态的中间状态.模拟形式的输入就是设备可以返回某个数字的范围.一个常见的模拟设备就是摇杆,它会总是返回二维空间的范围值
输入系统在其他方面要考虑的是游戏中对同时按键和序列按键的支持.这种需求在格斗游戏中很流行,玩家会经常同时使用按键和序列按键来搓招.处理同时按键和序列按键超出了本章的主题,但是可以通过状态机的方式来识别玩家的各种输入
数字输入
enum KeyState StillReleased, JustPressed, JustReleased, StillPressed end function UpdateKeyboard() // lastState和currentState是在其他地方定义好的数组 // 记录了完整的键盘信息 lastState = currentState currentState = get keyboard state end function GetKeyState(int keyCode) if lastState[keyCode] == true if currentState[keyCode] == true return StillPressed else return JustReleased end else if currentState[keyCode] == true return JustPressed else return StillReleased end end
模拟输入
因为模拟设备有一组范围值,有输入偏差是很常见的.假设一个摇杆有x值和y值用16位有符号整形表示.这意味着x和y都可以表示-32768到+32768范围的值.如果一个摇杆放在平面上,玩家不去碰它,理论上由于摇杆没有被控制,这个x和y应该都为0.但是,实际上该值会在0的附近
由于这个原因,很少会让模拟输入直接应用到角色的移动上,这会让角色永远停不下来.这意味着即使你放下手柄,角色还会在屏幕上到处乱走.为了解决这个问题,大多数游戏都会采用某种模拟输入过滤的方式,用于消除输入的偏差值
一个简单的无效区域实现就是在x和y值接近0的时候直接设置为0.如果值的范围在+/-32K,也就是无效区域范围大约10%的区间
Vector2 joy = get joystick input if joy.x >= -deadZone && joy.x <= deadZone joy.x = end if joy.y >= -deadZone && joy.y <= deadZone joy.y = end
但是这个方法有两个问题.首先无效区域是一个正方形而不是圆形.也就是说,如果摇杆的x和y都稍微比无效区域小一点,角色还是不会移动,哪怕实际上已经超过了10%的阈值
另一个问题就是没有完全利用所有有效范围值.从10%到100%都有速度,但是小于10%就什么都没有了.我们采用了一种解决方案,使得0%到100%都有速度.换句话说,我们希望有效输入映射到0%的速度,而不是映射到10%.这样会将原来的10%到100%的中值55%映射到50%
为了解决这个问题,我们不应该将x和y看作独立的分量,而是将摇杆的输入视为一个2D向量.然后就可以通过向量运算来完成无效区域的过滤
Vector2 joy = get joystick input float length = joy.length() // 如果长度小于无效区域, 那么认为没有输入 if length < deadzone joy.x = joy.y = else // 计算无效区域到最大值之间的百分比 float pct = (length - deadZone) / (maxValue - deadZone) // 正规化向量,然后相乘得到最终正确结果 joy = joy / length joy = joy * maxValue * pct end
基于事件的输入系统
想象你开车带小孩去儿童乐园.小朋友非常兴奋,所以每分钟都问一次"我们到了吗?".她不断地问直到你最终到达目的地,这个问题的答案才为"是".现在想象不止一个小孩,你载着一车小孩去儿童乐园,所以每个小孩都来不断地问这个问题.这不仅影响驾驶,还很浪费精力
这个场景本质上是个轮询系统,每个小孩都来轮询结果.对于输入系统来说,很多代码都想轮询输入,比如主动去检查空格键是否"刚刚按下".每一帧的多个地方都不断用GetKeyState查询K_SPACE.这不仅导致需要多写代码,而且还会容易产生更多的Bug
我们回到儿童乐园的例子,想象你再次面对一车的孩子.比起让孩子不断地重复提问,不如雇佣一个事件机制或者推送系统.在基于事件的系统中,小孩需要注册它们关心的事件(这个例子中,关心的事件就是到达儿童乐园),然后会在事件发生的时候通知到他们.现在就可以安静地开车到儿童公园了,只要你到达之后通知所有小孩已经达到就可以了
输入系统同样也可以设计为基于事件的.可以设计一个接受特定输入事件的系统.当事件发生的时候,系统就通知所有已经注册的代码.所以所有需要知道空格状态的系统都可以注册关于空格键"刚刚按下"的事件,它们会在事件发生后马上收到通知
要注意的是,基于事件的输入系统还是要轮询输入的.就好像你作为一个司机,必须在行车的过程中时刻了解是否达到目的地,然后通知孩子们一样.输入系统要不断地轮询空格键,这样就可以正确地发出通知.与之前不同的地方就是,现在我们只有一个地方进行轮询
基础事件系统
class MouseManager List functions // 接受那些将传递的参数(int, int)作为信号的函数 function RegisterToMouseClick(function handler(int, int)) functions.Add(handler) end function Update(float deltaTime) bool mouseClicked = false , mouseY = // 轮询鼠标点击 ... if mouseClicked foreach function f in functions f(mosueX, mouseY) end end end end // 为鼠标点击注册myFunction MouseManager.RegisterToMouseClick(myFunction)
一个更复杂的系统
// KeyState枚举 (JustPressed, JustReleased等) ... // GetKeyState ... // BindInfo就是字典中的值 struct BindInfo int keyCode KeyState stateType end class InputManager // 存储了所有绑定 Map keyBindings // 只存储那些激活的绑定 Map activeBindings // 使按键绑定更加方便 function AddBinding(string name, int code, KeyState type) keyBindings.Add(name, BindInfo(code, type)) end // 初始化所有绑定 function InitializeBindings() // 可以从文件解析 // 然后调用AddBinding进行绑定 // 比如说, "Fire"可以映射到刚刚释放的回车键 // AddBinding("Fire", K_ENTER, JustReleased) ... end function Update(float deltaTime) // 清除所有上一帧中的激活绑定 activeBindings.Clear() // KeyValuePair有key和value成员,分别是来自字典键和值 foreach KeyValuePair k in keyBindings // 使用前面定义的GetKeyState得到某个按键的状态 if GetKeyState(k.value.keyCode) == k.value.stateType // 如果与绑定一致,则认为绑定是被激活的 activeBindings.Add(k.key, k.value) end end // 如果有任何的激活绑定, 那么优先发送给UI系统 // 发送激活绑定给UI ... // 发送激活绑定给游戏的剩余部分 ... end end end
移动设备输入
触屏和手势
大多数移动设备都支持多点触摸,就是说允许用户同时多个手指进行操作
一些游戏通过多点触摸实现虚拟手柄,这会有一个虚拟摇杆和虚拟按键让玩家交互.这个做法在家用机版本的游戏上很流行,但有时候会让玩家很困惑,因为没有真实手柄的反馈
有一些操作只有触摸屏才能实现,称之为手势操作,就是一系列的触摸引发的行动.一个很流行的手势操作就是iOS设备上的"两指缩放",用户可以用食指和拇指同时靠近来进行缩小,同时远离进行放大
检测手势有很多种方法,其中一种流行的算法就是Rubine算法,它在Dean Rubine在1991年的论文"Specifying gestures by example."中提出.虽然Rubine算法是由笔画识别发展出来的,但是不管是单个手指的手势识别还是多个手指的手势识别,它都可以实现
加速器和陀螺仪
其他移动设备输入
相关资料
Abrash, Michael. Ramblings in Valve Time. http://blogs.valvesoftware.com/abrash/. 这个博客的博主是游戏产业的传奇人物Michael Abrash, 讲了很多有洞见的虚拟实现和现实增强的文章
Rubine, Dean. "Specifying gestrues by example." In SIGGRAPH'91: Proceedings of the 18th annual conference on computer graphics and interactive techniques. (1991): 329-337. 这是Rubine手势识别算法的原版论文.
第6章 声音
基本声音
原始数据
原始数据是指音效设计师使用类似Audacity(http://audacity.sourceforge.net)这样的工具来创建的最原始的音频文件.http://kcat.strangesoft.net/openal.html
声音事件
声音事件映射了一个或者多个原始数据文件.声音事件事实上是由代码触发的.所以比起直接播放fs1.wav文件,可能调用一个"footstep"的声音事件更好.这个想法就是声音可以包含多个声音文件还能有元数据,将这些声音文件作为整体
举个例子,假设有一个爆炸声音事件.这个事件应该随机选择5个WAV文件中的一个来播放.另外地,由于爆炸是可以在远处就听见的,所以应该有能听见的距离的元数据.而且爆炸声音事件应该具有高优先级,这样即使所有频道都用完了,爆炸还是能播放
游戏中的元数据有多种实现方式.一个方法是使用JSON文件格式,爆炸声音事件大概会像下面这样
{ "name": “explosion", , , "sources": [ "explosion1.wav", "explosion2.wav", "explosion3.wav" ] }
在任何情况下,在解析数据的时候,都能够直接将数据映射到声音事件类:
class SoundCue string name int falloff int priority // 所有源文件的字符串链表 List sources; function Play() // 随机选择一个源文件来播放 .. end end
之前提到的系统可能很多游戏已经够用了,但是对于脚步声的例子来说就可能不够.如果角色可以在不同的路面行走----石头,沙地,草地等,声音需要根据当前的地面来判定.在这种情况下,系统需要一些方法知道当前地面所属声音的分类,然后从中随机选择来播放.换句话来说,系统需要一些方法根据当前地面而切换不同的音频集合
{ "name": "footstep", , "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" ] } ] }
我们可以在原有的SoundCue类上增加功能.一个更好的方案是提取出ISoundCue接口,然后实现SoundCue类和新的SwitchalbeSoundCue类:
interface ISoundCue function Play() end class SwitchableSoundCue implements ISoundCue string name int falloff int priority string switch_name // 存储(string, List)键值对的哈希表 // 比如说("sand", ["fs_sand1.wav", "fs_sand2.wav", "fs_sand3.wav"]) HasMap sources function Play() // 得到当前值赋值到switch_name // 然后在哈希表中查找链表, 然后随机播放一个 ... end end
为了让这个实现顺利工作,我们需要以全局的方式获取和设置切换.通过这样的方法,角色跑动的代码就可以判断地面然后切换到相应的脚步声.然后脚步声的声音事件Play函数就可以知道当前所切换的值,然后播放正确的脚步声
最终,实现声音事件系统的关键就是有足够的配置信息去判断播放什么和怎么播放.有足够多的变量数量能够让音频设计师得到更多的灵活性从而更好地设计真实的交互
3D声音
虽然不是绝对,但是大多数2D音效都是位置无关的.这意味着对于大多数2D游戏,声音的输出在左右两个喇叭都是一样的.有些游戏也会考虑音源的位置,比如音量随着距离衰减,但还是少数2D游戏才这么做
对于3D音效和3D游戏来说,音源的位置就特别重要.大多数音效都有自己独特的随着监听者距离增大衰减的方式,一个例子就是游戏中的虚拟麦克风能听到附近的音效
但也不是说3D游戏不使用2D音效.一些要素比如用户界面,解说,背景音等还在使用2D音效.但是出现在游戏世界中的音效通常都是3D音效
监听者和发射者
不管监听者怎么监听游戏世界中的音效,发射者就是发射特定音效的物体.比如说,如果有一堆柴火发出噼里啪啦的声音,就会有一个声音发射器放在那个位置然后放出噼里啪啦的声音.然后基于监听者和火柴声的发射者之间的距离就可以算出音量的大小.发射者相对于监听者的朝向决定了哪个喇叭有声音
衰减
衰减描述了音效的音量随着远离监听者会如何减小.可以用任何可能的函数去表达衰减.但是,由于音量的单位分贝是一个对数刻度,线性衰减会产生对数变换关系.这种线性分贝衰减函数通常都是默认方法,但是显然不是唯一的方法
就像点光源一样,可以增加更多的参数.我们可以设定在内半径之内衰减函数不起组用,而外半径之外就完全听不到声音.这样的声音系统允许音频设计师创建多种衰减函数来展现出随着不同距离有不同的音效
环绕声
不少平台都不支持环绕声的概念----大多数移动设备最多只支持立体声.但是,PC和家用机游戏是可以有两个以上喇叭的.在5.1环绕系统中,会有总共5个正式的喇叭和1个用于表现低频效果的低音炮
传统的5.1配置是放置3个喇叭在前面,两个在后面.前面的喇叭放置在左,中,右.而后面的喇叭只有左,右.低音炮的位置不太重要,不过放在室内角落会让低频效果更好
数字信号处理
广义上讲,数字信号处理(DSP)是计算机中表示的信号.在音频领域中,数字信号处理说的是加载音频文件然后在修改之后得到不同的效果.举个数字信号处理相对简单的例子,加载音频文件然后增加或者减小它的音高
看起来没必要在运行时进行处理,先离线处理好这些效果,然后在游戏中播放也许会更好.但是运行时使用数字信号处理理由就是它能够节省大量内存.假设有一款剑击游戏,有20种不同的音效在武器相互碰撞的时候发出.这些音效听起来就像在野外开阔的场地发出.现在想象一下,如果游戏有多种场地会发出声音,除了野外之外,还有洞穴,大教堂,以及大量的其他地方
问题在于,在洞穴中发出的声音和在野外发出的声音听起来完全不一样.特别是当刀剑在洞穴中发生碰撞时,会有很大的回声.不用数字信号处理效果,唯一的方法就是为二十多种刀剑声再针对不同的场地配置.如果有5种不同的场景,就意味着有5倍的增长,从而有一百多种的刀剑音效.现在如果需要所有战斗音效,而不仅仅是刀剑音效,游戏内存很快就被用尽.但是如果有了数字信号处理效果,同样的20个刀剑音效会用在不同的地方,它们只要根据场所调整成相应的音效即可
常见数字信号处理效果
游戏中常见的数字信号处理效果就是之前提到的回声.任何想在狭窄空间创建回声效果的游戏都会琢磨如何实现.一个非常流行的回声效果库叫作Freeverb3, http://freeverb3.sourceforge.net有提供.Freeverb3是一个冲量驱动系统,就是说为了对任何音效实现回声效果,需要一个音频文件表达在特殊场景播放的数据
另一个大量使用的数字信号处理效果就是音高偏移,特别是多普勒偏移.音高偏移会通过调整频率增加或者减小音效的音高.虽然多普勒偏移会很常用.但赛车游戏中引擎的音高会随着速度的变化而变化
游戏中大多数的数字信号处理效果通常都会修改频率的范围或者输出分贝的级别.举个例子,一个压缩机缩小了音量的范围,导致很小的声音得到了增强,同时很大的声音得到了减小.通常用于统一多个音频文件,让它们保持相似的范围
另一个例子是低通滤波器,通过删减频率的方式减小音量.在游戏中很常见的就是当玩家附近发生爆炸时的嗡鸣声.为了实现效果,时间会拉长,然后应用低通滤波器,接着播放嗡鸣声
游戏中还有很多其他效果,但以上4种是游戏中最常见的
区域标记
有一些效果很少会在整个关卡一直使用,比如说回响效果.通常只有关卡的一些区域才需要使用回响效果.比如说,如果一关里有野外区域和洞穴,回响效果可能只有在洞穴才会有.有很多种方法可以添加区域,最简单的方式就是在地面上标记凸多边形
其他声音话题
多普勒效应
如果你站在街上,同时一辆警车打开警报器向你靠近,音高会随着警车的靠近而提高.相对地,在警车远离之后,声音的音高也会降低.
多普勒效应(或者称之为多普勒偏移)的出现是由于声波在空气中传播需要时间.在警车靠近的时候,意味着连续的声波都比前一个要早到.这就导致了频率的增加,就会有更高的音高.警车就在你身边的时候,你可以听到声音的真实音高.最后,在警车远离你的时候,声波会需要越来越长的时间到达你的位置,导致了低音高的出现.
有趣的是多普勒效应不仅在音波中才会出现,而是所有与波相关的情况都会出现.最有名的就数光波,如果物体靠近会出现红光,如果物体远离会出现蓝光.但是为了产生能够注意得到的偏移,对象要在非常大的空间里走的非常快.这在地球上的普通速度上不会出现,所以这效果在天文学上才能观测到
在游戏中,动态多普勒效应只会在告诉移动的对象身上应用,比如汽车.技术上来讲,也可以在子弹上应用,但是由于它们太快,我们通常只播子弹飞走的声音.由于多普勒效应会让音高增加或者降低,只有在支持数字信号处理效果处理音高的时候才能用
声音遮挡
相关资料
Boulanger, Richard and Victor Lazzarini, Eds. The Audio Programming Book. Boston: MIT Press, 2010. 这本书是数字信号处理领域的著作, 涵盖了大量的示例代码
Lane,John. DSP Filter Cookbook. Stamford: Cengage Learning, 2000. 这本书比上一本高级,它实现了很多特殊效果,包括压缩机和低通道滤波
第7章 物理
平面,射线和线段
平面
平面是平的,在二维上无限延伸,就如同线可以在一维空间无限延伸一样.在游戏中,我们通常用平面作为地面和墙体的抽象,虽然可能还有其他用法.一个平面可以有多种表示方法,但是通常游戏程序员会倾向于用以下表示
P是平面上任意一点,n是平面法线,d是平面到原点的最小距离
回忆一下,三角形是可以确保在一个平面上.上面的平面表达式会采用的一个原因就是,给定一个三角形,很容易根据三角形构造出该平面,.在我们计算出n和d之后,可以测试如果任意点P,也同样满足等式,那么P也在平面上
假设我们有ABC以顺时针顶点序排列.为了得到三角形所在平面的表达式,我们需要先计算三角形的法线
不要忘记了,叉乘的顺序是很关键的,根据顶点序进行叉乘很重要.由于ABC顶点序以顺时针排列.我们希望构造向量从A到B和从B到C.在我们有了两个向量之后,我们对向量进行叉乘,然后对结果进行正规化得到n.
在得到n之后,我们需要使用平面上的顶点解出d,由于.
幸运的是,我们已经知道三角形上的3个点都在平面上,为A,B和C.我们可以将这些点与n点乘,就会得到d的值
平面上的所有顶点都会得到相同的d值,那是因为这是一个投影: n是正规化过的,P是未正规化过的,所以我们会得到平面到原点在n方向上的最小距离.这个最小距离不管你采用哪个顶点都一样,因为它们都在一个平面上
在得到n和d之后,我们可以将这些值存储在我们的Plane数据结构体内:
struct Plane Vecotr3 normal float d end
射线和线段
射线就是从某个点开始出发,朝某个方向无限延伸.在游戏中,通常用参数方程表示射线.回忆一下,参数方程是借助其他参数来表达的,一般称之为t.对于射线来说,参数方程如下:.R0就是起点,而v就是射线穿越的方向.由于射线是从某点开始,然后朝着某个方向无限延伸,为了让射线表达式顺利工作,t必须大于等于0.就是说当t为0时,这个参数方程在起点R0就停了
线段与射线类似,除了既有起点又有终点之外.我们可以使用完全相同的参数方程来表示线段.唯一不同的地方就是现在t有了上限,因为线段必须有一个终点
技术上来讲,光线投射就是射出一条射线,然后检查是否打到某个对象.但是,大多数物理引擎都会由于实际上使用的是线段做检测的方法让人迷惑,包括Havok和Box2D.这么做的原因是游戏世界通常会有一定的约束,所以使用线段更加合理.我承认这个术语会让人困惑.但是,这是游戏产业的惯用术语,我想保持这个术语的一致性.所以请记得本书中的光线投射实际上使用的都是线段
struct RayCast Vector3 startPoint Vector3 endPoint end
将startPoint和endPoint转换成线段参数方程的形式相当简单.R0就是startPoint,v就是endPoint - startPoint.当以这种方式转换之后,t的值从0到1会对应到光线投射.也就是说,t为0就在startPoint,而t为1则在endPoint
碰撞几何体
值得一提的是,游戏对象拥有多个不同级别的碰撞几何体也是很常见的.这样,简单的碰撞体可以先进行第一轮碰撞检测.在简单的碰撞体发生了碰撞之后,再选择更精细的碰撞体进一步检测碰撞
包围球
最简单的碰撞体就是包围球(在2D游戏中则是包围圈).一个球体可以通过两个变量定义----向量表示球体的中心点,标量表示球体的半径
class BoundingSphere Vector3 center float radius end
轴对齐包围盒
对于2D游戏来说,一个轴对齐包围盒(AABB, axis-aligned bounding box)就是一个每条边都平行于x轴或者y轴的矩形.类似地,在3D游戏中,AABB就是长方体,而且每条棱都与对应的轴平行.不管2D还是3D,AABB都可以用两个点表示:最大点和最小点.在2D中,最小点就是左下角的点,而最大点则是右上角的点
class AABB2D Vector2 min Vector2 max end
由于AABB必须与对应的轴平行,如果一个对象旋转,那么AABB就需要拉长.但是对于3D游戏来说,人形角色通常只绕向上的轴旋转,这种旋转并不会让AABB有太多的变化.因此,使用AABB作为人形角色的包围体是很常见的,特别是AABB和球体之间的碰撞计算量很小
朝向包围盒
一个朝向包围盒(或者OBB)类似于轴对齐包围盒,只是不再要求与轴平行.就是说,这是一个长方形(2D)或者长方体(3D),而且每条轴不再需要与对应的坐标轴平行.OBB的优点就是可以随着游戏对象旋转,因此不管游戏对象的朝向如何,OBB的精准度都很高.但是这个精准度的提升是有代价的,与AABB相比,OBB的计算花费高太多了.
胶囊体
在2D游戏中,胶囊体可以看作是一个AABB加上两端各一个半圆.之所以叫胶囊体是因为看上去就跟药物一样.如果我们把胶囊体扩展到3D,就会变成一个圆柱加上两端各一个半球.胶囊体在人形角色的碰撞体表示中是很流行的,因为他们比AABB精准一些.胶囊体还以看作带半径的线段,在游戏引擎中就是这么表示的
struct Capsule2D Vector2 startPoint Vector2 endPoint float radius end
凸多边形
另一个碰撞几何体表示的选择就是使用凸多边形(在3D领域称之为凸包).与你所想的差不多,凸多边形比其他方式效率都要低,但是比它们都精准
组合碰撞集合体
最后一个增加精准度的选择就是使用组合碰撞几何体进行碰撞检测.在人形的例子中,我们以在头部使用球形,身干用AABB,凸多边形用于手脚等.通过不同的碰撞几何体组合,我们几乎可以消灭漏报
虽然检测碰撞几何体组合比检测模型的三角片要快,但还是慢得让你不想用.在人形的例子中,应该先用AABB或者胶囊体进行第一轮碰撞检测.然后通过之后再进行更精确的测试,比如组合碰撞几何体.这种方法取决于你是否需要将精准度分级别.在检测子弹是否打中角色的时候会用到,但是阻挡玩家走进墙里就没必要了
碰撞检测
球与球的交叉
如果两个球得半径之和小于两个球之间的距离,那么就发生了交叉,但是,计算距离会用到平方根,为了避免平方根的比较,通常都会使用距离的平方与半径之和的平方进行比较
function SphereIntersection(BoundingSphere a, BoundingSphere b) { // 构造两个中心点的向量,然后求长度的平方 Vector3 centerVector = b.center - a.center; // 回忆一下, v的长度平方等于v点乘v float distSquared = DotProduct(centerVector, centerVector); // distSquared是否小于等于半径和的平方? if distSquared < ((a.radius + b.radius) * (a.radius + b.radius)) return true else return false end end
AABB与AABB交叉
如同球体交叉一样,AABB的交叉计算即使在3D游戏中也是很廉价的
function AABBIntersection(AABB2D a, AABB2D b) { bool test = (a.max.x < b.min.x) || (b.max.x < a.min.x) || (a.max.y < b.min.y) || (b.max.y < a.min.y) return !test end
线段与平面交叉
首先,我们有线段和平面的两个等式:我们想判断是否存在一个值t,使得点落在平面上.话句话说,我们想判断是否存在t值,使得R(t)满足平面等式中P的值.所以可以将R(t)带入P:接下来的问题就是解出t的值:.
回忆一下线段,起点则对应于t = 0,而终点则对应于t = 1.所以当我们解出t时,如果t的值在这个范围外,那么可以忽略它.特别的是,负值表示线段朝向远离平面的方向.
同样值得注意的是,如果v与n点乘结果为0,会产生除0异常.回想一下,如果两个向量点乘结果为0,意味着两个向量垂直.在这种情况下,表示v与平面平行,因此不会有交叉,唯一交叉的情况就是线段就在平面上.在任何情况下,除0异常是必须要考虑的.还有一点就是,如果线段与平面真的相交,我们可以将t替换为交点的值
// 返回值就是这个结构体 struct LSPlaneReturn bool intersects Vector3 point end // 记住光线投射实际上就是线段 function LSPlaneIntersection(RayCast r, Plane p) { LSPlaneReturn retVal retVal.intersects = false // 计算线段方程的v Vector3 v = r.endPoint - r.startPoint // 检查线段是否与平面平行 float vDotn = DotProduct(v, p.normal) t = - * (DotProduct(r.startPoint, p.normal) + p.d) t /= vDotn // t应该介于起点与终点(0到1)之间 && t <= retVal.intersects = true // 结算交点 retVal.point = r.startPoint + v * t end else // 测试起点是否在平面上 ... end return retVal end
线段与三角片交叉
假设你需要算出用线段表示的子弹与某个三角片之间是否发生碰撞.第一步就是算出三角片所在的平面.在有了这个平面之后,你可以看看这个平面是否与线段相交.如果它们相交,你就会得到与三角形所在平面相交的交点.最后,由于平面是无限大的,我们要检测该点是否在三角片之内
如果三角形ABC以顺时针顶点序表达,第一步就是构造一个从A到B的向量.然后构造一个向量从A到P,P就是交点的位置.如果旋转向量AB到AP是顺时针,P就在三角形一侧.这个检测对每一条边(BC和CA)进行计算,如果每个都是顺时针,也就是每条边算出P都在三角形一侧,就可以得出结论说P就在三角形内部
但是我们怎么判断是顺时针还是逆时针?如果我们在右手坐标系中计算AB x AP,得到的向量会朝向书页内.回想一下右手坐标系中顺时针序三角形的法线也是朝向书页内部的.这种情况下,叉乘向量的方向就与三角形法线方向一致.如果两个正规化过的向量点乘值为正值,它们朝向大致一致.所以如果你将AB x AP的结果正规化后再与法线点乘结果为正数,那么P就在AB所在的三角形一侧
这个算法可以用于三角形的其他边.它看起来不仅能够作用于三角形,对于任意在同一平面上的多边形也同样适合
// 这个函数只能在顶点为顺时针顶点序及共面下正常工作 function PointInPolygon(Vector3[] verts, int numSides, Vector3 point) { // 计算多边形的法线 Vector3 normal = CrossProduct(Vector3(verts[] - verts[]), Vector3(verts[] - verts[])) normal.Normalize(); // 临时变量 Vector3 side, to, cross; , i < numSides, i++ // 从上一个顶点到当前顶点 side = verts[i] - verts[i - ]; // 从上一个顶点到point to = point - verts[i - ] cross = CrossProduct(side, to); cross.Normalize(); // 表示在多边形外部 return false end loop // 必须检测最后一条边,就是最后一个顶点到第一个顶点 side = verts[] - verts[numSize - ] to = point - verts[numSize - ] cross = CrossProduct(side, to) cross.Normalize() return false end // 在所有边内部 return true end
球与平面交叉
在游戏中球可以与墙发生碰撞,为了对这个碰撞准确建模,可以使用球与平面的交叉.给定平面的n和d,碰撞检测最简单的方法就是建立一个新得平面,对齐球心并且与原有平面平行.如果两个平面距离比球的半径要小,那么就发生了交叉.就像球与球的交叉一样,球与平面的交叉也不复杂
function SpherePlaneIntersection(BoundingSphere s, Plane p) // 通过平面的法线p.normal及平面的点s.center计算平面的d float dSphere = -DotProduct(p.normal, s.center) // 检查是否在范围之内 return (abs(d - dSphere) < s.radius) end
球形扫掠体检测
到目前为止,我们讲了即时碰撞检测算法.就是说那些算法只能检查当前帧中发生的碰撞.虽然很多情况下都有效,但是也有很多不适用的时候
如果子弹朝着纸张发射,不存在子弹与纸张交错在一起的准确的一帧.这是因为子弹速度很快,而纸张很薄.这个问题通常被称为子弹穿过纸张问题.为了解决这个问题,能够进行连续碰撞检查(CCD)的能力是必要的
在球形扫掠体检测中,有两个移动中的球体.而输入则是两个球在上一帧的位置(t = 0)和这一帧的位置(t = 1).给定这些数据,我们可以判断两帧之间两个球是否发生了碰撞
所以不像即时碰撞检测的球与球交叉那样,它是不会因为不同帧而丢失交叉
你可能注意到,球形扫掠体看上去和胶囊体差不多.那是因为球形扫掠体确实就是胶囊体.球形扫掠体有起点,终点及半径,完全就是一个胶囊体.所以胶囊体与胶囊体的碰撞完全可以在这里使用
如同线段与平面交叉问题一样,先解等式会对我们有帮助.而且解决胶囊体的碰撞本身也是一个非常流行的问题,所以经常会在面试的时候被问到.由于它涉及很多游戏程序员需要掌握的线性代数概念
给定球体的上一帧和这一帧的位置,就可以将球的位置转换为参数方程.整个转换得到的函数可以用于光线投射.所以给定球P和球Q,我们可以用两个参数方程表示:
我们想要求的就是t,t就是两个球距离等于半径之和的时候,因为那就是发生交叉的时候.数学表示如下:
这个等式的问题就是我们需要一些方法来避开长度运算.诀窍j就在于向量v的长度平方就等于自身进行点乘:
因此,如果对公式两边取平方,我们可以得到如下等式:
现在我们有了这个等式,就可以对t求解.这个过程有点复杂.首先,将P(t)h和Q(t)替换进来
然后,我们可以提取因子整理公式:
为了更进一步简化,可以继续替换:
由于点乘被加法隔开了,我们可以对(A + Bt)项使用了FOIL(first, outside, inside, last)法则:
如果我们将(rp + rq)2 移到等式左边,然后再做一下替换,会的到更加熟悉的等式:
你可能会想起这种二次方程可以用很久没用过的方法来解:平方根下的值,我们称之为判别式,是非常重要的.有3种情况: 小于0, 大于0, 等于0,以及大于0
如果判别式小于0,t就没有实根,就是说没有交叉发生.如果判别式等于0,意味着两个球相切.如果判别式大于0,意味着两个球完全交叉在一起,两个根比较小的就是最早发生交叉的时候
在我们解出t的值以后,我们可以看一下如果值介于0和1之间,编码的时候就要注意了.记住t值如果大于1则是这一帧之后,如果小于0则是这一帧之前.因此,t值超出范围的情况不是这个函数接受的范围
// p0 / q0 是上一帧的球体 // p1 / q1 是这一帧的球体 function SweptSphere(BoundingSphere p0, BoundingSphere q0, BoundingSphere p1, BoundingSphere q1) { // 首先计算v用于参数方程 Vector3 vp = p1.center - p0.center Vector3 vq = q1.center - q0.center // 计算A和B // A = P0 - Q0 Vector3 A = p0.center - q0.center // B = vp - vq Vector3 B = vp - vq // 计算a, b和c // a = B dot B float a = DotProduct(B, B) // b = 2(A dot B) * (DotProduct(A, B)) // c = (A dot A) - (rp + rq) * (rp + rq) float c = DotProduct(A, A) - ((q0.radius + p0.radius) * (q0.radius + p0.radius)) // 现在计算判别式(b * b - 4ac) * a * c // 如果我们需要t的值,我们可以用以下的方式解出 // t = (-b - sqrt(disc)) / (2a) // 但是, 这个函数只要回答真就可以了 return true else // 没有实数解,所以没有交叉发生 return false end end
响应碰撞
我们可以使用前面提到的各种算法来检测碰撞.但是在检测结果出来之前,游戏应该如何处理呢?这就是响应碰撞的问题.一些情况下,响应会很简单:一个或多个对象可能会死亡然后从游戏世界中移除.稍微复杂一点的响应就是一些比如火简这样的物体会减少敌人的生命值
但是如果两个对象需要相互弹开呢?比如两个小行星碰撞.一个简单的解决方法就是根据碰撞的方向让速度反向.但这么做会有很多问题.一个问题就是行星会被卡住.假设两个行星在某一帧发生碰撞,那么就会引起速度取反.但是如果它们速度很慢,导致下一帧还继续碰撞呢?那么速度就会无限地循环变化下去,然后就会卡住
为了解决第一个问题,我们需要找到两个行星发生碰撞的准确位置,尽管它们每帧都会发生.由于行星使用包围球,我们可以使用球与球之间的交叉来找出碰撞发生的时间.在我们算出时间之后,我们要将回滚到那个时间点的位置.然后我们就可以根据情况添加速度了(我们初步方案是取反).然后用新的速度在剩余的时间力更新对象的行为.
但是,这里的碰撞响应还是有一个大问题:将速度取反不是一个正确的行为.我们可以看一下为什么,假设你有两个行星朝着同一个方向运动,一个在另一个前面.前面的行星比后面的行星速度要慢一些,所以最终后面的行星会跟上前面的行星.就是说当两个行星碰撞的时候,它们会徒然朝另一个方向运动,这肯定是不对的
所以比起将速度取反,我们实际上是想根据发生碰撞的平面的法线将速度进行反射.如果行星与墙面碰撞,计算碰撞的墙面是很简单的,墙面就是我们要的平面.但是两个球在某个点碰撞的例子,我们所要的平面就是碰撞点的切线平面
为了构造切线平面,我们首先要得到发生碰撞的点.这个可以用线性插值算出.如果有两个球体在某个点发生碰撞,这个点肯定就在两个球心所连成的线段上.它所在的位置就取决于两个球的半径.如果我们有两个BoundingSphere的实例A和B在某个点发生碰撞,这个点可以通过线性插值计算出来 Vector3 pointOfIntersection = 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是加速度.由于力等于质量点成加速度,所以加速度可以通过力除以质量得到.给定一个力,这个等式就可以计算出加速度
通常而言,我们想表示加速度为一个接受时间的函数, a(t). 现在加速度有了与速度和位置的关系.这个关系就是位置函数(r(t))的导数就是速度函数(v(t)),而速度函数的导数就是加速度函数.这里的符号表达式: v(t) = dr / dt, a(t) = dv / dt
但是这个公式在游戏中求值不是很方便.在游戏中,我们希望给对象一个作用力,然后这个力就持续产生加速度.在我们有了加速度之后,我们就可以得到这段时间内的速度.最终,得到了速度,我们就可以判断物体的位置.所有这些都要在这段时间的每一帧都进行计算.换句话说,我们要的是对导数反向操作,你可能会想到积分.但不是所有积分我们都感兴趣.关于积分你可能会想到我们最熟悉的不定积分:.但是在游戏中,不定积分没有什么用,首先因为它不能直接使用.还有就是,我们希望每一帧都通过加速度算出速度和位置.意味着使用数值积分,一种可以每帧都使用其计算积分近似值的方法.如果你要求曲线下的面积,可以通过梯形法则,算出数值积分
可变时间步长带来的问题
在我们讨论数值积分之前,需要先解决基于物理的移动问题.在使用数值积分之后,你就或多或少地不能使用可变的时间帧.这是因为数值积分的准确性取决于时间步长.步长越短就越精确
这意味着如果每帧的时间不长都改变,近似值也会每帧变动.如果准确性改变,行为也会有显著的变化.想象你正在玩一款角色可以跳跃的游戏,就像<<超级马里奥兄弟>>.平时玩游戏的时候,角色的起跳速度都一样.但是突然间,帧率降低,然后你看到马里奥跳得更高了.这种情况是由于数值积分的百分比误差在低帧率的时候放大了,所以跳得更高了
由于这个原因,任何游戏使用物理计算位置得时候,都不要使用可变的时间步长.物理计算用可变步长当然是可以的,但是这样就会很复杂
力的计算
数值积分让我们可以由加速度计算出速度,然后由速度算出位置.但是为了算出加速度,我们需要力和质量.这里有多种多样的力需要考虑.有些力,比如重力,一直作用在物体身上.而有些力可以用冲量代替,就是那些只在一帧起作用的力
举个例子,跳跃可能最先受到冲量的作用而起跳.但是跳跃开始之后,重力的作用下,角色就会回到地面.由于多个力可以同时作用在物体上,在游戏中最常见的做法就是算出所有力的合力,然后除以质量算出加速度: 加速度 = 合力 / 质量
欧拉和半隐式欧拉积分
最简单的数值积分就是欧拉积分,以瑞士著名数学家命名.在欧拉积分中,新的位置是由旧的位置加上速度乘以时间步长得到.然后速度以类似的方式通过加速计算出来
class PhysicsObject // 物体上所有作用力 List forces Vector3 acceleration, velocity, position float mass function Update(float deltaTime) Vector3 sumOfForces = sum of forces in forces acceleration = sumOfForces / mass // 欧拉积分 position += velocity * deltaTime velocity += acceleration * deltaTime end end
虽然欧拉积分很简单,它并没有真正表现得非常准确.一个大问题就是位置是用旧得速度算出来的,而不是时间步长之后的新速度.这样会随着时间的推移让误差不断地积累
一个简单的改法就是将欧拉积分的位置和速度更新顺序调换.就是说现在位置是使用新的速度来计算.这就是半隐式欧拉积分,它会更加合理,更加稳定,著名的游戏物理引擎Box2D就用了这种方法.但是,如果我们想要更加精确,我们就要使用更加复杂的数值积分方法
Verlet积分法
在Verlet积分法中,首先算出本次时间步长中点的速度值.然后将它看作平均速度计算整个步长的位置.然后,加速度根据力和质量计算出来,最终利用新的加速度在步长结束的时候计算出速度.
function Update(float deltaTime) Vector3 sumOfForces = sum of forces in forces // Verlet积分法 Vector3 avgVelocity = velocity + acceleration * deltaTime / 2.0f // 位置用平均速度算出来 position += avgVelocity * deltaTime // 计算新的加速度和位置 acceleration = sumOfForces / mass velocity = avgVelocity + acceleration * deltaTime / 2.0f end
本质上Verlet积分法使用平均速度计算位置.这比起两种欧拉积分都要准确得多.同时计算也更加昂贵,虽然显然比欧拉方法要好,但还不够
其他积分方法
还有不少其他积分方法可能会在游戏中用到,但是它们有点复杂.它们当中最受欢迎的方法是四阶Runge-Kutta方法.它本质上是使用泰勒近似求解的结果表示运动的微分方程的近似解.这个方法无可争议地比欧拉和Verlet方法都要准确,但也更慢.对于那些需要高准确度的游戏(比如汽车模拟)来说是有意义的,但是对于多数游戏而言都过重
角力学
角力学是关于旋转的力学研究.比如说,你可能需要这种物理效果,就是物体围绕另一个物体旋转.就像线性力学有质量,作用力,加速度,速度,位置一样,角力学有转动惯量,力矩,角加速度,角速度和角度.角力学的机制比线性力学还要复杂一些,但也不会复杂很多.就像线性力学一样,角力学也会在旋转的时候用到积分.注重效果的游戏大多会用到角力学,但是由于大多数游戏只用线性力学,所以这里选择只讲线性力学
相关资料
Ericson, Christer. Real-time Collision Detection. San Francisco: Morgan Kaufmann, 2005. 这本书是碰撞检测大全.书中有各种几何体的碰撞及排列组合.要注意的是,本书设计大量数学知识,所以在看之前最好先适应数学表达的方法
Fielder, Glenn. Gaffer on Games - Game Physics. http://gafferongames.com/game-physics/. 这个博客讲了很多话题,但是物理学的话题更加吸引人注目.涵盖了四阶Runge-Kutta 积分,角力学,弹簧等话题
第8章 摄像机
摄像机的类型
固定摄像机
严格来讲,固定摄像机就是那种永远在同一个位置的摄像机.这种固定的摄像机通常只用于非常简单的3D游戏.术语"固定摄像机"也可以扩展为根据玩家的位置而摆放在预先定义好的位置.
第一人称摄像机
第一人称摄像机是以玩家的视角来体验游戏世界的.由于摄像机是角色视角,第一人称摄像机是最让人身临其境的摄像机类型.第一人称摄像机在第一人称涉及游戏中非常流行,但是在其他游戏比如<<上古卷轴: 天际>>也会用到
第一人称游戏最常见的做法就是在眼睛附近放摄像机,这样其他角色和物体才会有相应的高度.但是,问题是很多第一人称游戏都希望能够显示角色手部.如果摄像机在眼睛位置,当角色向前看的时候是看不到手的.还有一个问题就是,如果玩家的角色模型绘制出来,你会从接近眼睛位置的摄像机看到很奇怪的效果
为了解决以上问题,大多数第一人称游戏都不会使用普通模型.取而代之的是,使用一个特殊的只有手臂(可能还有腿)的对解剖来讲不正确的位置.这样,就算向前看,玩家总能看到手上有什么.如果使用了这个方法,一些特殊情形,比如看到自己的倒影这种情况就需要考虑了.否则,玩家看到空气中悬挂的手臂,就会被吓到
跟随摄像机
跟随摄像机会在一个或者多个方向上跟在目标后面.
有的跟随摄像机与目标始终保持固定距离,而有的则与目标保持弹性距离.有的在跟随角色的过程中会旋转,而有的不会.有的甚至允许玩家突然转身看背后有什么.有大量的这种类型的摄像机的各种各样的实现
场景切换摄像机
越来越多的游戏会用到场景切换,就是在播放游戏剧情的时候,从玩家的摄像机切过去的一种手法.在3D游戏中实现场景切换,要预先在场景中放置动画中用到的固定摄像机.很多场景切换会使用电影设备,比如移动镜头.为了达到效果,会用到样条系统
透视投影
视场
观看世界视野的广度及角度, 称为视场(FOV).对人类来说,我们的眼睛提供了180°的视野,但是并不是每个角度都有等量的清晰度.双目并视的时候,两只眼都可以同时看到大约120°的视场.而剩余的市场在边缘处,能够快速地发现运动,但是不够清晰
推荐的观看高清电视的距离很大程度取决于向你推荐的人,THX推荐的观看距离为取对角线长度乘以1.2.所以50英寸的电视应该从60英寸的距离观看.这个距离,电视机会有大约40°的视角度,就是说电视机会占了观看者40°的视场.在这种条件下,只要给游戏留下多于40°的视场,几乎所有的角色都能看得一清二楚.这就是为什么家用机游戏需要大约65°的视场
但是如果家用机游戏替换成PC游戏会怎样?在PC的条件下,显示器会占用玩家更多的视场.这种条件下通常有90°以上的视场,这个视场及视角度的差异会让一些玩家感到不舒服.这就是为什么游戏世界需要把视场收窄,让大脑回到90°的视场感知.由于这个问题,让玩家选择自己习惯的视场是一个不错的选项
如果视场变得太大,就会有鱼眼效果,屏幕的边缘变得弯曲.就类似于摄影中使用了广角镜头一样.大多数游戏不允许玩家选择太高的视场
宽高比
宽高比就是观看世界视口的宽度和高度的比率
标准的高清分辨率720p(就是1280 x 720)就是16:9宽高比的例子
摄像机的实现
基础的跟随摄像机
在基础的跟随摄像机中,摄像机总是直接跟随在某个对象后面,而且保持固定的距离
回忆一下为摄像机创建观察矩阵,需要3个参数:眼睛的位置(摄像机的位置),摄像机观察的目标,以及摄像机的上方向量.在基础跟随摄像机中,眼的位置可以设置为目标的水平和垂直偏移.在计算出位置之后,就可以计算其他参数,然后传递给CreateLookAt函数:
// tPos, tUp, tForward = 位置,上方和前方向量 // hDist = 水平跟随距离 // vDist = 垂直跟随距离 function BasicFollowCamera(Vector3 tPos, Vecotr3 tUp, Vector3 tForward, float hDist, float vDist) { // 眼睛就是目标位置的偏移量 Vector3 eye = tPos - tForward * hDist + tUp * vDist // 摄像机向前的方向是从眼睛到目标 Vector3 cameraForward = tPos - eye cameraForward.Normalize() // 叉乘计算出摄像机的左边及上方向量 Vector3 cameraLeft = CrossProduct(tUp, cameraForward) cameraLeft.Normalize() Vector3 cameraUp = CrossProduct(cameraForward, cameraLeft) cameraUp.Normalize() // CreateLookAt的参数为eye, target, 以及up return CreateLookAt(eye, tPos, cameraUp) end
虽然基础跟随摄像机会跟着目标在游戏中移动,看起来非常僵硬.摄像机总是保持固定距离,没有弹性.当旋转的时候,这个基础跟随行为会让人不知道是世界在转还是人在转.而且,基础跟随摄像机没有一个合理的速度,它的速度就是目标的速度.由于以上种种原因,基础跟随摄像机很少在游戏中使用.虽然它提供了简单的解决方案,但是显得很不优雅
一个简单的改善方法就是让摄像机有一个跟随目标速度调整跟随距离的函数.比如说平时跟随的时候速度为100,但是当目标全速移动的时候,这个距离为200.这个简单改变能够提升基础跟随摄像机的速度感,但是还有很多问题没有办法解决
弹性跟随摄像机
有了弹性跟随摄像机,就不会由于目标的朝向或者位置改变而突然变化,而是摄像机会在几帧的过程中逐渐变化.实现方式是同时设定好理想位置与现实位置.理想位置每帧立刻变化,就跟基础跟随摄像机一样(可能会有跟随距离调整函数).然后真正的摄像机位置在后续几帧慢慢跟随到理想位置上,这样就能够创造平滑的镜头效果
这种实现方式是通过虚拟弹簧将理想摄像机和真实摄像机连接到一起实现的.每当理想摄像机位置变化时,弹簧都被拉伸.如果理想摄像机位置不变,随着时间的推移,真实位置总会被调整到理想位置.
弹簧的效果可以由弹性常量来控制.这个常量越大,弹簧就越僵硬,就是说摄像机归为得越快.实现弹性跟随摄像机,需要确定每帧的摄像机速度和真实摄像机位置.因此最简单的实现就是使用一个类来实现.这个算法大概的工作方式是首先基于这个弹性常量计算出加速度.然后将加速度通过数值积分计算出摄像机的速度,然后再进一步计算位置
class SpringCamera // 水平和垂直跟随距离 float hDist, fDist // 弹性常量: 越高表示越僵硬 // 一个好的初始值很大程度取决于你想要的效果 float springConstant // 阻尼常量由上面的值决定 float dampConstant // 速度和摄像机真实位置向量 Vector3 velocity, actualPosition // 摄像机跟随的目标 // (有目标的位置, 向前的向量, 向上向量) GameObject target // 最终的摄像机矩阵 Matrix cameraMatrix // 这个帮助函数从真实位置及目标计算出摄像机矩阵 function ComputeMatrix() // 摄像机的前向是从真实位置到目标位置 Vector3 cameraForward = target.position - actualPosition cameraForward.Normalize() // 叉乘计算出摄像机左边,然后计算出上方 Vector3 cameraLeft = CrossProduct(target.up, cameraForward) cameraLeft.Normalize() Vector3 cameraUp = CrossProduct(cameraForward, cameraLeft) cameraUp.Normalize() // CreateLookAt参数为 eye, target 及 up cameraMatrix = CreateLookAt(actualPosition, target.position, cameraUp) end // 初始化常量及摄像机,然后初始化朝向 function Initialize(GameObject myTarget, float mySpringConstant, float myHDist, float myVDist) target = myTarget springConstant = mySpringConstant hDist = myHDist vDist = myVDist // 阻尼常量来自于弹性常量 dampConstant = 2.0f * sqrt(springConstant) // 起初,设置位置为理想位置 // 就跟基础跟随摄像机的眼睛位置一样 actualPosition = target.position - target.forward * hDist + target.up * vDist // 初始化摄像机速度为0 velocity = Vector3.Zero // 设置摄像机矩阵 ComputeMatrix() end function Update(float deltaTime) // 首先计算理想位置 Vector3 idealPosition = target.position - target.forward * hDist + target.up * vDist // 计算从理想位置到真实位置的向量 Vector3 displacement = actualPosition - idealPosition // 根据弹簧计算加速度, 然后积分 Vector3 springAccel = (-springConstant * displacement) - (dampConstant * velocity) velocity += springAccel * deltaTime actualPosition += velocity * deltaTime // 更新摄像机系统 ComputeMatrix() end end
旋转摄像机
旋转摄像机会在目标附近旋转.旋转摄像机最简单的实现方法就是存储摄像机的位置及与目标的偏移,而不是直接记录摄像机的世界坐标系位置.这是因为旋转总是关于原点旋转的.所以如果摄像机位置作为偏移记录下来,旋转j就可以以目标对象为原点进行旋转,这样就得到了我们想要的旋转效果
class OrbitCamera // 摄像机向上的向量 Vector3 up // 目标偏移 Vector3 offset // 目标对象 GameObject target // 最终的摄像机矩阵 Matrix cameraMatrix // 初始化摄像机状态 function Initialize(GameObject myTarget, Vector3 myOffset) // 在y轴朝上的世界里,up向量就是Y轴 up = Vector3(, , ) offset = myOffset target = myTarget // CreateLookAt参数为eye, target和up cameraMatrix = CreateLookAt(target.position + offset, target.position, up) end // 根据这一帧的yaw/pitch 增加角度进行更新 function Update(float yaw, float pitch) // 创建一个关于世界向上的四元数 Quaternion quatYaw = CreateFromAxisAngle(Vector3(, , ), yaw) // 通过这个四元数变换摄像机偏移 offset = Transform(offset, quatYaw) up = Transform(up, quatYaw) // 向前就是target.position - (target.position + offset) // 刚好就是-offset Vector3 forward = -offset forward.Normalize() Vector3 left = CrossProduct(up, forward) left.Normalize() // 创建关于摄像机左边旋转的四元数值 Quaternion quatPitch = CreateFromAxisAngle(left, pitch) // 通过这个四元数变换摄像机偏移 offset = Transform(offset, quatPitch) up = Transform(up, quatPitch) // 现在计算矩阵 cameraMatrix = CreateLookAt(target.position + offset, target.position, up) end end
第一人称摄像机
使用第一人称摄像机,摄像机的位置总是放在角色的相对位置上.所以当角色在世界中移动的时候,摄像机依然是玩家的位置加上偏移.虽然摄像机偏移总是不变,目标位置可以不断变化.这是因为大多数第一人称游戏都支持到处看但是不改变位置的功能
class FirstPersonCamera // 以角色位置为原点的摄像机偏移 // 对于y轴向上的世界,向上就是(0, value, 0) Vector3 verticalOffset // 以摄像机为原点的目标位置偏移 // 对于z轴向前的世界,向前就是(0, 0, value) Vector3 targetOffset // 总的偏航和俯仰角度 float totalYaw, totalPitch // 摄像机所在的玩家 GameObject player // 最终的摄像机矩阵 Matrix cameraMatrix // 初始化所有摄像机参数 function Initialize(GameObject myPlayer, Vector3 myVerticalOffset, Vector3 myTargetOffset) player = myPlayer verticalOffset = myVerticalOffset targetOffset = myTargetOffset // 最开始,没有任何偏航和俯仰 totalYaw = totalPitch = // 计算摄像机矩阵 Vector3 eye = player.position + verticalOffset Vector3 target = eye + targetOffset // 在y轴向上的世界里 Vector3 up = Vector3(, , ) cameraMatrix = CreateLookAt(eye, target, up) end // 根据这一帧的增量偏航和俯仰进行更新 function Update(float yaw, float pitch) totalYaw += yaw totalPitch += pitch // 对俯仰进行Clamp // 在这种情况下,范围为角度40°(弧度约为0.78) totalPitch = Clamp(totalPtich, -0.78, 0.78) // 目标在旋转之前偏移 // 真实偏移则是在旋转之后 Vector3 actualOffset = targetOffset // 关于y轴进行偏航旋转,旋转真实偏移 Quaternion quatYaw = CreateFromAxisAngle(Vector3(, , ), totalYaw) actualOffset = Transform(actualOffset, quatYaw) // 为了俯仰计算左边向量 // 前向就是偏航之后的真实偏移(经过正规化) Vector3 forward = actualOffset forward.Normalize() Vector3 left = CrossProduct(Vector3(, , ), forward) left.Normalize() // 关于左边进行俯仰,旋转真实偏移 Quaternion quatPitch = CreateFromAxisAngle(left, totalPitch) actualOffset = Transform(actualOffset, quatPitch) // 现在构造摄像机矩阵 Vector3 eye = player.position + verticalOffset Vector3 target = eye + actualPosition // 在这种情况下, 我们可以传递向上向量,因为我们永远向上 cameraMatrix = CreateLookAt(eye, target, Vector3(, , )) end end
样条摄像机
讲解数学定义就会有点过多了,简单来讲,样条可以看作为曲线,用线上的点定义的.样条在游戏中很常见,因为使用它进行插值能够在整条曲线上得到平滑的效果
有很多种不同的样条,最简单的一种是Catmull-Rom样条.这种样条允许邻近的点插值,这些点里面有一个控制点在前,两个激活点在后.P1和P2就是激活点(分别在t = 0和t = 1处),而P0和P3就是在前面和后面的控制点.尽管图中只有4个点,但实际上是没有限制的.只要在前后加上控制点,曲线就可以无限延长下去
给定4个控制点,这样就可以计算t值介于0和1的所有样条
class CRSpline // Vector3s的数组(动态数组) Vector controlPoints // 第一个参数为t=0对应的控制点 // 第二个参数为t值 function Compute(int start, float t) // 检查start - 1, start, start + 1以及start + 2都要存在 ... Vector3 P0 = controlPoints[starts -] Vector3 P1 = controlPoints[start] Vector3 P2 = controlPoints[start + ] Vector3 P3 = controlPoints[start + ] // 使用Catmull-Rom公式计算位置 Vector3 position = * P1) + (-P0 + P2) * t + ( * P0 - * P1 + * P2 - P3) * t * t + (-P0 + * P1 - * P2 + P3) * t * t *t) return position end end
这个公式也可以用于计算t介于0到1之间的切线.首先,计算任意你想要的t的位置.然后,计算t加很小的增量Δt的位置.在有了两个位置之后,就可以构造一个P(t)到P(t + Δt)的向量并且正规化,这样就能够近似地得到切线.这种方式可以看作是数值微分
class SplineCamera // 摄像机跟随的样条路径 CRSpline path // 当前控制点索引及t值 int index float t // speed是t每秒变化率 float speed // 摄像机矩阵 Matrix cameraMatrix // 给定当期索引和t,计算摄像机矩阵 function ComputeMatrix() // eye就是样条所在的t及index对应的位置 Vector3 eye = path.Compute(index, t) // 给出一个稍微前一点的点 Vector3 target = path.Compute(index, t + 0.05f) // 假定y轴朝上 Vector3 up = Vector3(, , ) cameraMatrix = CreateLookAt(eye, target, up) end function Initialize(float mySpeed) // 初始index应该为1(因为0是最初的P0) index = t = 0.0f speed = mySpeed ComputeMatrix() end function Update(float deltaTime) t += speed * deltaTime // 如果t >= 1.0f, 我们可以移动到下一个控制点 // 这里代码假设速度不会太快,以至于一帧就超过两个控制点 if t >= 1.0f index++ t = t - 1.0f end // 应该检查index + 1和index + 2是否为有效点 // 如果不是,这条样条就完成了 ... ComputeMatrix() end end
摄像机支持算法
摄像机碰撞
摄像机碰撞致力于解决很多类型摄像机都有的问题,那是在摄像机与目标之间有一个不透明的物体的时候.最简单的方法(但不是最佳的)就是从目标位置向摄像机位置进行光线投射.如果光线碰撞到任何物体,可以让摄像机移动到阻挡摄像机的物体前面.
另一个要考虑的问题是在摄像机太过靠近目标的时候,回忆一下,*面就在摄像机前面一点点,意味着太近的摄像机会让对象消失一部分.一个流行的解决方案是让对象在摄像机太过靠近的时候完全消失或者淡出
淡出方案有时候也常用于当摄像机与目标之间有阻挡的时候.很多第三人称动作游戏都使用这种方法
拣选
拣选就是通过点击的方式选择3D世界中物体的能力.拣选在RTS游戏中很常见.虽然拣选不是摄像机算法,但是它与摄像机和投影紧密相关
回忆一下,将一个点从世界空间变换到投影空间,它必须乘以一个摄像机矩阵,再乘以一个投影矩阵.但是,鼠标的位置是屏幕空间上的一个2D点.我们要做是将这个2D点从屏幕空间变换回到世界空间去,称之为反投影.为了实现反投影,我们需要一个矩阵可以做逆向矩阵变换操作.d对于以行为主的表示,我们需要摄像机矩阵乘以投影矩阵的逆矩阵:
但是2D点不能乘以4x4矩阵,所以在这个点乘以矩阵之前,我们必须将其转换到齐次坐标系.这就需要将z和w分量添加到2D点上.z分量通常设为0或1,取决于该点是放置在*面还是远平面.而作为一个顶点,w分量总为1
function Update(Vector4 screenPoint, Matrix camera, Matrix projection) // 计算 camera * projection的逆矩阵 Matrix unprojection = camera * projection unprojection.Invert() return Transform(screenPoint, unprojection) end
Unproject可以用来计算两个点: 鼠标位置反投影到*面(z = 0)和鼠标位置反投影到远平面(z = 1).这两个点可以作为光线投射的起点和终点.由于光线投射有可能与多个物体交叉,游戏应该选择最近的那个
相关资料
Haigh-Hutchinson, Mark. Real-Time Cameras. Burlington: Morgan Kaufmann,2009. 这本书广泛地讲了各种不同的游戏摄像机,作者在<<银河战士>>中实现了各种优秀的摄像机系统
第9章 人工智能
"真"AI与游戏AI
在传统计算机科学中,许多人工智能的研究都趋向于复杂形式的AI,包括遗传算法和神经算法.但是这些复杂的算法在计算机和游戏中应用还存在限制.这存在两个主要原因,第一个原因是复杂的算法需要大量的计算时间.大多数游戏只能分出它们每帧的小部分时间在AI上,这意味着高效比复杂重要.另外一个主要原因就是,游戏AI通常都有良好的行为定义,通常都是在策划的控制之下,而传统的AI专注于解决更加模糊而广泛的问题
在很多游戏中,AI行为只是一种随机变化的状态机制组合,但还是有几个主要的例外.AI对于复杂的棋牌游戏,比如象棋和围棋,需要决策树支持,这是传统游戏理论的基石.但是棋牌游戏在某一时刻的行动选择相比起其他游戏来讲都不会这么奢侈.也有一些游戏实时做出很复杂的算法,令人印象深刻,但那是特例.一般来讲,游戏中的AI就是智能感知.如果玩家觉得敌人的AI或者队友的AI行为很聪明,这个AI系统就已经成功了
但也不是每个游戏都需要AI算法.一些简单的游戏,比如单人跳棋和俄罗斯方块就没有这样的算法.哪怕是一些复杂的游戏也可能没有AI,比如<<摇滚乐队>>.对于那些100%多人对战没有NPC的游戏来说也一样.但是对于任意一款设计指定NPC与玩家交互的游戏来说,AI算法是必须的
寻路
探索空间的表示
最简单的寻路算法设计就是将图作为数据结构.一个图包含了多个节点,连接任意邻近的点组成边.在内存中表示图有很多种方法,但是最简单的是邻接表.在这种表示中,每个节点包含了一系列指向任意邻近节点的指针.图中的完整节点集合可以存储在标准的数据结构容器里
这意味着在游戏中实现寻路的第一步是如何将游戏世界用图来表示.这里有多种方法.一种最简单的方法就是将世界分为一个个正方形的格子(或者六边形).在这种情况下,邻近节点就是格子中邻近的正方形.这个方法在回合制策略游戏中很流行,比如<<文明>>或者XCOM
但是,对于实时动作游戏,NPC通常不是在网格上一个正方形一个正方形地走.由此,在主流游戏中要么使用路点要么使用导航网格.上面两种方法,都可以手工在场景编辑器中构造数据
但是手工输入数据不仅繁琐而且容易出错,所以大多数引擎都会让这个过程自动化
寻路节点最早在第一人称射击游戏(FPS)中使用,由id Software在20世纪90年代早期推出.通过这种表示方法,关卡设计师可以在游戏世界中摆放那些AI可以达到的位置.这些路点直接被解释为图中的节点.而边则可以自动生成.比如让设计师手动将节点组合在一起,可以自动处理判断两个点之间是否由障碍.如果没有障碍,那么边就会在两点之间生成
路点的主要缺点是AI只能在节点和边缘的位置移动.这是因为即使路点组成三角形,也不能保证三角形内部就是可以行走的.通常会有很多不能走的区域,所以寻路算法需要认为不在节点和边缘上的区域都是不可走的
实际上,当部署路点之后,游戏世界中就会要么有很多不可到达的区域要么有很多路点.前者是不希望出现的情况,因为这样会让AI的行为显得不可信而且不自然.而后者缺乏效率.越多的节点就会有越多的边缘,寻路算法花费的时间就会越长.通过路点,在性能和精确度上需要折中
一个可选的解决方案就是使用导航网格.在这种方法中,图上的节点实际上就是凸多边形.邻近节点就是简单的任意邻近的凸多边形.这意味着整个游戏世界区域可以通过很少数量的凸多边形表示,结果就是图上的节点特别少
通过导航网格,在凸多边形内部的任意位置都认为是可走的.这意味着AI有了大量的空间可以行走,因此寻路可返回更自然的路径
导航网格还有其他一些优点.假设游戏中有牛和小鸡在农场行走.由于小鸡比牛小很多,因此有一些区域只有小鸡可到达,而牛却不行.如果这个游戏使用路点,它通常需要两份图:每份图对应一种生物.这样,牛只能在自己相应的路点行走.与之相反,由于导航网格中每个节点都是凸多边形,计算牛能否进入不会花太多时间.因此,我们可以只用一份导航网格,并且计算哪些地方牛可以到达
还有一点就是导航网格完全可以自动生成,这也是今天为什么使用路点的游戏越来越少的原因.比如说,多年来虚幻引擎使用路点作为寻路空间的表示.其中一款使用路点的虚幻引擎的游戏就是<<战争机器>>.而且,最近几年的虚幻引擎已经使用导航网格代替路点.再后来的<<战争机器>>系列,比如<<战争机器3>>就使用的是导航网格,这个转变引起工业上大量转用导航网格
话虽这么说,但是寻路空间的表示并不完全会影响寻路算法的实现.在本节中的后续例子中,我们会使用正方形格子来简化问题.但是寻路算法仍不关心数据是表示为正方形格子,路点,或是导航网格
可接受的启发式算法
所有寻路算法都需要一种方法以数学的方式估算某个节点是否应该被选择.大多数游戏都会使用启发式,以h(x)表示,就是估算从某个位置到目标位置的开销.理想情况下,启发式结果越接近真实越好.如果它的估算总是保证小于等于真实开销,那么这个启发式是可接受的.如果启发式高估了实际的开销,这个寻路算法就会有一定概率无法发现最佳路径
对于正方形格子,有两种方式计算启发式.曼哈顿距离是一种在大都市估算城市距离的方法.某个建筑可以有5个街区远,但不必真的有一条路长度刚好为5个街区.曼哈顿距离认为不能沿对角线方向移动,因此也只有这种情况下才能使用启发式.如果对角线移动是被允许的,则曼哈顿距离会经常高估真实开销.在2D格子中,曼哈顿距离的计算如下:
第二种计算启发式的方法就是欧几里得距离.这种启发式的计算使用标准距离公式然后估算直线路径.不像曼哈顿j距离,欧几里得距离可以用在其他寻路表示中计算启发式,比如路点或者导航网格.在我们的2D格子中,欧几里得距离为
贪婪最佳优先算法
在有了启发式之后,可以开始实现一个相对简单的算法:贪婪最佳优先算法.一个算法如果没有做任何长期计划而且只是马上选择最佳答案的话,则可以被认为是贪婪算法.在贪婪最佳优先算法的每一步,算法会先看所有邻近节点,然后选择最低开销的启发式
虽然这样看起来理由充足,但是最佳优先算法通常得到的都是次优的路径.
路径上存在不必要的向右移动,这是因为这在当时就是最佳的访问节点.一个理想的路径应该是一开始往下走,但是这要求一定程度的计划,这是贪婪算法所不具备的.大多数游戏都需要比贪婪最佳优先算法所能提供的更好的寻路.但是本章后续的寻路算法都基于贪婪最佳优先算法,所以先理解贪婪算法才能往下继续,先看看如何实现这个贪婪算法
struct Node
Node parent
float h
end
那个parent成员变量用于跟踪哪个节点是当前访问的.parent成员的价值在于构造链表,能够从终点回到起点.当算法完成的时候,parent链表就可以通过遍历得到最终路径
浮点数h存储了某个节点的h(x)的值,这个值导致在选择节点的时候会偏向于h值最小的节点
算法的下一个组件就是用于临时存储节点的容器:开放集合和封闭集合.开放集合存储了所有目前需要考虑的节点.由于找到最低h(x)值开销节点的操作是很常见的,所以对于开放集合可以采用某种类似于二叉堆或者优先级队列的容器
而封闭集合则包含了所有已经被算法估值的节点.一旦节点在封闭集合中,算法不再对其进行考虑.由于经常会检查一个节点是否存在于封闭集合里,故会使用搜索的时间复杂度优于O(n)的数据结构,比如二叉搜索树
假设有开始节点和结束节点,而且我们需要计算两点之间的路径.算法的主要部分在循环中处理,但是,在进入循环之前,我们需要先初始化一些数据
currentNode = startNode
add currentNode to closedSet
当前节点只是跟踪哪个邻居节点是下一个估值的节点.在算法开始的时候,我们除了开始节点没有任何节点,所以需要先对开始节点的邻居进行估值
在主循环里,我们首先要做的事情就是查看所有与当前节点相邻的节点,而且把一部分加到开放集合里:
do
foreach Node n adjacent to currentNode
if closeSet contains n
continue
else
n.parent = currentNode
if openSet does not contains n
compute n.h
add n to openSet
end
end
loop
注意任意已经在封闭集合里的节点都会被忽略.在封闭集合里的节点都在之前进行了估值,所以不需要再进一步估值了.对于其他相邻节点,这个算法会把parent设置为当前节点.然后,如果节点不在开放集合中,我们计算h(x)的值并且把节点加入开放集合
在邻近节点处理完之后,我们再看看开放集合.如果开放集合中再也没有节点存在.意味着我们把所有节点都估算过了,这就会导致寻路失败.实际上也不能保证总有路径可走,所以算法必须考虑这种情况
if openSet is empty
break; // 退出主循环
end
但是,如果开放集合中还有节点,我们就可以继续.接下来要做的事情就是在开放集合中找到最低h(x)值开销节点,然后移到封闭集合中.在新一轮迭代中,我们依旧将其设为当前节点
currentNode = Node with lowest h in openSet
remove currentNode from openSet
add currentNode to closedSet
最后,我们要有循环退出的情况.在找到有效路径之后,当前节点等于终点,这样就能够退出循环了
until currentNode == endNode // end main do...until loop
图9.7显示了贪婪最佳优先算法作用在示例数据集的开始两次迭代.在图9.7(a)中,起点加入封闭集合,而邻接节点则加入开放集合.每个邻接节点(蓝色)都有用曼哈顿距离算出来的自己达到终点的h(x)开销.箭头表示子节点指向父节点. 这个算法的下一步就是选择最低h(x)值节点,在这里选择h=3的节点.然后这个节点就会作为当前节点,放到封闭集合里.图9.7(b)显示了下一步的迭代,将当前节点(黄色)的邻接节点放入开放集合中
currentNode = startNode add currentNode to closedSet do // 把邻接节点加入开放集合 foreach Node n adjacent to currentNode if closedSet contains n continue else n.parent = currentNode if openSet does not contain n compute n.h add n to openSet end end loop // 所有可能性都尝试过了 if openSet is empty break; end // 选择新的当前节点 currentNode = Node with lowest h in openSet remove currentNode from openSet add currentNode to closedSet until currentNode == endNode // 如果路径解出,通过栈重新构造路径 if currentNode == endNode Stack path Node n = endNode while n is not null push n onto path n = n.parent loop else // 寻路失败 end
A*寻路
在讲了贪婪最佳优先算法之后,我们就可以考虑怎么提升路径的质量.比起单一地依赖于h(x)作为寻路的估价,A*算法增加了路径开销分量.路径开销就是从起点到当前节点的实际开销,通过g(x)计算
A*中访问一个节点的开销等式为:
为了能够使用A*算法,Node结构体需要增加f(x)和g(x)的值,如下:
struct Node
Node parent
float f
float g
float h
end
当一个节点加入开放集合之后,我们需要计算所有的3个分量,而不仅仅是启发式.而且,开放集合会根据f(x)的值来排序,因为在A*中每次迭代都会选择f(x)值最低的节点
对于A*算法只有一个主要变化,那就是节点选用的概念.在最佳优先算法中,总是把邻接节点作为父节点.但是在A*算法中,已经放在开放集合中的邻接节点需要估值之后才能决定哪个当前节点是父节点
在图9.8(a)中,我们可以看到当前节点正在检查邻近节点.这个节点到左边节点的g=2,如果那个点以当前节点为父节点,g=4,结果会更糟.所以在这种情况下,当前节点的路径应该拒绝选用.
currentNode = startNode add currentNode to closedSet do foreach Node n adjacent to currentNode if closedSet contains n continue else if openSet contains n // 选用检查 compute new_g // n节点以当前节点为父节点的g(x)值 if new_g < n.g n.parent = currentNode n.g = new_g n.f = n.g + n.h // 该节点的n.h是不变的 end else n.parent = currentNode compute n.h compute n.g n.f = n.g + n.h add n to openSet end loop if openSet is empty break end currentNode = Node with lowest f in openSet remove currentNode from openSet add currentNode to closeSet until currentNode == endNode // 清单9.1 的路径构造 ...
Dijkstra算法
最后一个寻路算法可以通过稍微修改A*算法得到.在Dijkstra算法中,没有启发式的估计----或者换个角度
这意味着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中的状态机就可以像下面这样定义枚举
enum AIState
Patrol,
Death,
Attack
end
然后可以用AIController类以AIState类型作为成员变量.在我们的AIController的Update函数中,可以根据当前状态来执行不同的行为:
function AIController.Update(float deltaTime)
if state == Patrol
// 执行巡逻行为
else if state == Death
// 执行死亡行为
else if state == Attack
// 执行攻击行为
end
end
状态的变化和进入/退出行为可以在第二个函数中实现
function AIController.SetState(AIState newState)
// 退出行为
if state == Patrol
// 退出巡逻行为
else if state == Death
// 退出死亡行为
else if state == Attack
// 退出攻击行为
end
state = newState
// 进入行为
if state == Patrol
// 进入巡逻行为
else if state == Death
// 进入死亡行为
else if state == Attack
// 进入攻击行为
end
end
这个实现有几个问题.首先很明显的一点就是,首先很明显的一点就是,随着状态机的增加,Update和SetState的可读性会减弱.如果我们的例子中有20个状态而不是3个,代码看上去就像意大利面条.第二个主要问题是缺乏灵活性.加入我们有两个AI,它们有不同的状态机.这样我们就需要为不同的AI实现不同的枚举和控制器.现在,假设两个AI之间会公用一些状态,比如说巡逻状态.以我们目前的基础代码结构是无法在AI之间共享状态的.
一个方法是将巡逻的代码复制到两个类中,但是有着两份同样的重复代码是非常不好的实践.另一个方法就是写一个共同的基类,然后把公有的行为"冒泡"上去.但是这样,还是有很多缺点:意味着任何需要巡逻行为的AI都要从这里继承
所以虽然这个基础的实现能工作,但是除非AI状态机非常简单,否则完全不推荐
状态机设计模式
状态机模式, 允许"一个对象通过改变内在状态来切换行为"
这可以通过类组合的方式完成.所以AIController"有一个"AIState作为成员变量.每个特定的状态都是AIState的子类
基类AIState的定义如下:
class AIState
AIController parent
function Update(float deltaTime)
function Enter()
function Exit()
end
父引用使得任何AIState的实例都可以让AIController拥有它.这是必要的,如果我们想要切换到新的状态,需要有一些方法通知AIController这些事情.每个AIState都有自己的Update,Enter,Exit函数,可以为某个特定有需求的状态所实现
AIController类会保留一个当前AIState的引用,而且需要Update和SetState函数
class AIController
AIState state
function Update(float deltaTime)
function SetState(AIState newState)
end
这样,AIController的Update函数只是简单地调用AIState的Update函数即可
function AIController.Update(float deltaTime)
state.Update(deltaTime)
end
通过设计模式,SetState函数也变得清晰多了:
function AIController.SetState(AIState newState)
state.Exit()
state = newState
state.Enter()
end
通过状态设计模式,所有状态相关的行为都移到AIState的子类当中去了.这使得AIController的代码比之前清晰多了.状态机模式也使得系统更加模块化.
策略和计划
很多游戏都需要比基于状态的敌人更复杂的AI.比如即时战略游戏,AI期望看上去与人类玩家相差无几.这个AI需要有一个大局观,知道自己要做什么,然后尽力去做.这就是策略和计划的工作方式
策略
策略就是从AI的视角来完成游戏.比如说,它要思考的是应该更具侵略性还是防守性.微观策略由单位行为组成.这通常可以用状态机来完成,所以就不必深入讨论了.相对而言,宏观策略复杂得多.它是AI的大局观,而且会决定如何完成游戏.当为像<<星际争霸>>那样的游戏开发AI的时候,开发者通常根据*玩家的思维进行建模.一个宏观策略在RTS游戏中可能会是"突击"(尝试尽快攻击玩家)
策略有时候看上去就像很模糊的使命描述,而且模糊的策略是很难开发的.为了让问题更加形象,策略通常被认为是一系列的特定目标.比如说,如果策略是"科技"(增加装备科技),一个特定目标可能就是"扩张"(建立第二个基地)
一个策略通常都不止一个目标,也就是说,我们需要有一个优先级系统来让AI选择哪个目标更加重要.所有其他目标如果优先级不是最高,那么会先搁在后面不管.其他目标会在最重要的目标完成时重新参与选择.一个实现目标系统的方式就是像这样写一个AIGoal类:
class AIGoal
function CalculatePriority()
function ConstructPlan()
end
每个特定目标都会作为AIGoal的子类实现.所以当策略进行目标选择之后,所有策略的目标会放到一个根据优先级排序的容器里.注意,真正高级的策略系统应该支持同时选用多个目标的功能.因为如果两个目标不是互斥的,那么是没有理由不同时选择两个目标的
计算优先级的启发式函数是CalculatePriority,可能会相当复杂,而且根据游戏规则不同而变化.比如说,一个目标是"建立空中单位",可能会在发现敌人正在建造能够消灭空军的单位时降低优先级.另一方面,如果AI发现敌人没有能够伤害空军的单位,那么就会增加这一目标的优先级
AIGoal中的ConstructPlan函数就是用于构造计划的: 一系列为了达到目标而计划出来的步骤
计划
每个目标都需要一个相应的计划.比如说,如果目标是扩张,那么计划可能如下:
1. 为扩张侦察合适的地点
2. 建立足够多的单位来保护扩张
3. 派遣工人和战斗单位去扩张点
4. 开始建造扩张点
特定目标的计划可以用状态机来实现.计划中的每一步都可以是状态机中的一个状态,而状态机持续为该步骤行动直到达到条件.但是,实践中的计划很少是线性的.根据计划某个步骤的成功或者失败,AI可能会调整步骤的顺序
一个需要考虑的事情是计划需要定期查看目标的可行性.如果扩张计划中发现没有适合扩张的位置,那么目标就是不可行的.一旦目标被标记为不可行,大局观需要重新估算.最终,必须要有一个"指挥官"来决定策略的改变
总结
AI游戏程序员的目标就是让系统看上去比较聪明.这可能不是研究人员眼中的"真"AI,但是游戏通常都不需要这么复杂的AI.与我们讨论的一样,游戏AI领域的一个大问题就是找到从点A到点B的路径.游戏中最常用的寻路算法就是A*算法,而且它可以用于任何搜索空间表达为图(比如格子,路点,导航网格)的问题.大多数游戏都有某种方式实现的通过状态机控制的行为,而实现状态机的最佳方式就是状态机设计模式.最后,策略和计划可能会在RTS游戏中创造更加可信真实的行为
相关资料
通用AI
Game/AI(http://ai-blog.net/):这个博客中有很多业界经验丰富的程序员谈论了关于AI编程相关的话题
Millington,Ian and John Funge. Artificial Intelligence for Games (2nd Edition). Burlington: Morgan Kaufmann, 2009: 这本书主要以算法的方式讲了很多常见的游戏AI问题
Game Programming Gems (Series): 这个系列的每一卷都有不少AI编程相关的文章.老版本已经绝版了,但新的还在
AI Programming Wisdom (Series): 与Game Programming Gems 系列类似, 但是完全专注于游戏AI,其中一些已经绝版了
寻路
Recast and Detour (http://code.google.com/p/recastnavigation/): 这是一个优秀的开源寻路库,由Mikko Mononen 开发,他开发过多款游戏,比如<<孤岛危机>>. Recast 能够生成导航网格,而Detour实现了在这些网格上的寻路
状态
Gamma, Eric et. al. Design Patterns: Elements of Reusable Object-Oriented Software. Boston: Addison-Wesley, 1995. 这本书讲设计模式的同时描述了状态机设计模式,对于所有程序员都很有用
Buckland, Mat. Programming Game AI By Example. Plano: Wordware Publishing, 2005. 这是一本通用的游戏AI书籍,他有一个非常好的基于状态的行为实现
第10章 用户界面
菜单系统
菜单栈
典型家用机游戏的菜单系统都以"点击开始"作为开始界面.在用户按键之后,就会进入主菜单界面.也许你还可以通过点击选项进入选项界面,也可以点击开发组或者新手教程.通常来讲,玩家都能够退出当前菜单然后返回之前的界面.
一个确保菜单总能回退到基本界面的方法就是使用栈这种数据结构.栈最上层的元素就是当前活跃的菜单,而打开新菜单就是往栈中压入新的菜单.回退到之前的菜单就是将当前的菜单弹出栈.这个机制还可以改进为支持多个菜单同时可见,比如说,如果需要接受/拒绝某个请求,一个弹出框可以在某个菜单之前.为了达到目标,菜单系统需要对栈的底部到顶部全部引用
按钮
打字
function KeyCodeToChar(int keyCode) // 确保这是字母键 if keyCode >= K_A && keyCode <= K_Z // 现在,假设大写的情况 // 这个映射取决于语言 return ('A' + (char)(keyCode - K_A)) else if keyCode == K_SPACE return ' ' else return '' end end
HUD元素
最基础的HUB(平视显示器)元素就是玩家得分和剩余生命值.这种HUB实现起来相对琐碎----在主要游戏场景渲染之后,我们只要在顶层绘制文字或者图标以展示相应的信息即可.但是很多游戏都使用了更加复杂的元素,包括路点箭头,雷达,指南和准心
路点箭头
class WaypointArrow // 记录箭头的朝向 Vector3 facing // 箭头在屏幕上的2D位置 Vector2 screenPosition // 箭头指向的当前路点 Vector3 waypoint // 用于渲染箭头的世界变换矩阵 Matrix worldTransform // 通过给定位置/旋转计算世界变换矩阵 function ComputeMatrix(Vector3 worldPosition, Quaternion rotation) // 缩放,旋转,平移(但是这次我们没有缩放) worldTransform = CreateFromQuaternion(rotation) * CreateTranslation(worldPosition) end // 根据屏幕位置计算3D箭头的世界坐标 function ComputeWorldPosition() // 为了计算反投影,我们需要一个3D向量 // z分量是一个在*面和远平面之间的百分比 // 在这种情况下,我选择二者之间10%的一个点(z = 0.1) Vector3 unprojectPos = Vector3(screenPosition.x, screenPosition.y, 0.1) // 得到摄像机和投影矩阵 ... // 调整第8章的反投影函数 return Unproject(unprojectPos, cameraMatrix, projectionMatrix) end function Initialize(Vector2 myScreenPos, Vector3 myWaypoint) screenPosition = myScreenPos // 对于Y轴向上的左手坐标系 facing = Vector3(, , ) SetNewWaypoint(myWaypoint) // 初始化世界变换坐标系 ComputeMatrix(ComputeWorldPosition(), Quaternion.Identity) end function SetNewWaypoint(Vector3 myWaypoint) waypoint = myWaypoint end function Update(float deltaTime) // 得到箭头的当前世界坐标 Vector3 worldPos = ComputeWorldPosition() // 得到玩家位置 ... // 箭头的新朝向是一个正规化向量 // 从玩家的位置指向路点 facing = waypoint - playPosition facing.Normalize() // 使用点乘得到原始朝向(0, 0, 1)和新朝向之间的夹角 , , ), facing)) // 使用叉乘得到轴的旋转轴 Vector3 axis = CrossProduct(Vector3(, , ), facing) Quaternion quat // 如果长度为0,意味着平行 // 意味着不需要旋转 if axis.Length() < 0.01f quat = Quaternion.Identity else // 计算用来表示旋转的四元数 axis.Normalize() quat = CreateFromAxisAngle(axis, angle) end // 现在设置箭头最后的世界变换 ComputeMatrix(worldPos, quat) end end
准心
就像鼠标光标一样,准心是一个在屏幕上的坐标.我们拿到这个2D坐标,然后执行两个反投影:一个在*面,一个在远平原.得到这两个点之后,可以在这两点之间执行光线投射.
雷达
有的游戏会有雷达系统用来显示雷达范围附近的敌方(或者友方).还有几种雷达变种,一种是任何人都会在相应的雷达中显示,另外一种是只显示最近开枪的敌人.不管是哪一种,实现原理几乎都一样
为了让雷达顺利工作,需要完成两件事情.首先,我们有一种方式可以遍历能够在雷达上显示的所有对象.然后,所有在雷达范围内的对象都需要根据UI中心做出的相应的偏移.计算距离和转换为2D偏移,我们都希望忽视高度,这意味着我们必须投影雷达对象到雷达面板上
在我们计算之前,应该先定义雷达光点结构体,就是那些在雷达上显示的点.这样,就可以根据实际情况让这些点有不同的大小和颜色
struct RadarBlip
// 雷达光点的颜色
Color color = Color.Red
// 雷达光点的位置
Vector2 position
// 雷达光点的缩放
float scale = 1.0f
end
对于Radar类来说,需要有两个参数: 游戏世界中的对象能够被探测出来的最大距离,以及屏幕上显示的雷达半径.通过这两个参数,在我们得到光点位置之后,就可以转换到屏幕上正确的位置
class Radar // 雷达在游戏世界单位中的范围 float range // 雷达在屏幕中的位置(x, y) Vector2 position // 雷达在屏幕中的半径 float radius // 雷达的背景图片 ImageFile radarImage // 所有活跃的雷达光点 List blips // 初始化函数设置range, center, radius及image ... function Update(float deltaTime) // 清除上一帧的光点 blips.Clear() // 获取玩家位置 ... // 将playerPosition转换为2D坐标 // 以下假设y轴向上 Vector2 playerPos2D = Vector2(playerPosition.x, playerPosition.z) // 计算需要添加到blip上的旋转 // 得到正规化后的玩家朝向向量 ... // 将playerFacing转换为2D Vector2 playerFacing2D = Vector2(playerFacing.x, playerFacing.z) // 计算雷达前向玩家与玩家朝向之间的夹角 , ))) // 为了使用叉乘, 需要转换为3D向量 Vector3 playerFacing3D = Vector3(playerFacing2D.x, playerFacing2D.y, ) // 使用叉乘判定旋转的方向 Vector3 crossResult = CrossProduct(playerFacing3D, Vector2D(, , )) // 顺时针为-z,意味着角度应该取负 angle *= - end // 判定哪些敌人在范围之内 foreach Enemy e in gameWorld // 将敌人的位置转换为2D坐标 Vector2D enemyPos2D = Vector2D(e.position.x, e.position.z) // 构造从玩家到敌人的向量 Vector2 playerToEnemy = enemyPos2D - playerPos2D // 检查长度, 看看是否在距离之内 if playerToEnemy.Length() <= range // 旋转playerToEnemy, 使得它相对于玩家朝向旋转(使用2D旋转矩阵) playerToEnemy = Rotate2D(angle) // 为敌人创建雷达光点 RadarBlip blip // 取playerToEnemy向量,转换为相对于屏幕上雷达中心点的偏移 blip.position = playerToEnemy blip.position /= range blip.position *= radius // 将blip添加到blips中 blips.Add(blip) end loop end function Draw(float deltaTime) // 绘制雷达图片 ... foreach RadarBlip r in blips // 在position + blip.position的位置绘制r // 因为blip中存放的是偏移量 ... loop end end
其他需要考虑的UI问题
支持多套分辨率
一个解决多套分辨率问题的办法就是避免使用像素坐标,也称为绝对坐标.一个绝对坐标的例子是让UI绘制在(1900, 1000)像素点.使用这种坐标的问题就是如果显示器只有一种1680 x 1050像素的分辨率,在(1900, 1000)位置的UI就会完全在屏幕之外
这种问题的另一个解决方法是使用相对坐标,就是坐标是一个相对值.比如说,如果你想让某些东西在屏幕的右下角显示,可能会放置元素在相对于右下角位置的(-100, 100).就是说在1920 x 1080像素分辨率下,它的坐标会是(1820, 980),而在1680 x 1050像素分辨率下,坐标会是(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中间件
中间件就是一些外部代码库用与简化开发工作
用户体验
相关资料
Komppa, Jari. "Sol on Immediate Mode GUIs." http://iki.fi/sol/imgui/:这是一个通过C和SDL实现游戏UI不同元素的深入教程
Quintans,Desi. "Game UI By Example: A Crash Course in the Good and the Bad." http://tinyurl.com/d6wy2yg:这是一篇相对简短的文章,通过游戏例子讲解了什么是好UI什么是坏UI,还讨论了在设计游戏UI的时候要考虑的事情
Spolsky, Joel. User Interface Design for Programmers. 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++开发,程序员需要很多工具去定位问题,而且在玩游戏的过程中通常没法解决.虽然Visual Studio的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 = { , , , , } -- 输出4 print( t[] ) -- 这是一个字典 t = { M = "Monday", T = "Tuesday", W = "Wednesday" } -- 输出Tuesday print( t[T] )
虽然Lua不是面向对象语言,但是通过表格完全可以做到面向对象.这种技术经常会用到,因为面向对象在游戏中非常重要
UnrealScript
UnrealScript是Epic为Unreal引擎专门设计的严格的面向对象语言.不像很多脚本语言,UnrealScript是编译型的.由于是编译型的,它有着比脚本语言更好的性能.但也意味着不支持的运行时重新加载.用Unreal开发的大部分游戏逻辑都用UnrealScript完成.对于使用完整引擎的游戏来说(不是免费版的UDK),UnrealScript的绑定允许使用C++实现
在语法上,UnrealScript看上去非常像C++或者Java.因为它是严格面向对象的,每个类都继承自Object或者Object的子类,而几乎每个类都表示场景中派生自Actor的一个角色.UnrealScript非常特别的功能是内建对状态的支持.可以根据状态有不同的函数重载,这样对于AI行为会更加容易设置.以下的代码片段会根据该类当前状态调用不同的Tick函数(Unreal对象的更新函数)
// Auto表示进入的默认状态 auto state Idle { function Tick(float DeltaTime) { // 更新Idel状态 ... // 如果发现敌人,那么进入Alert状态 GotoState("Alert") } Begin: `log("Entering Idle State") } state Alert { function Tick(float DeltaTime) // 更新Alert状态 ... } Begin: `log("Entering Alert State") }
可视化脚本系统
实现一门脚本语言
实现自定义脚本语言与创建一个通用编译器类似.学习编译器如何工作是很重要的,哪怕你不需要实现它,因为它能使编程变得高级.没有编译原理,每个人仍会用汇编语言写代码,这样就会导致有能力编程的人大大减少
标记化
我们要做的第一步就是将代码文本加载进来,然后分成一块块的标记,比如标识符,关键词,操作符h和符号.这个过程被称为标记化,更正式一点叫作词法分析.
正则表达式
语法分析
语法分析的任务就是遍历所有标记,然后确保它们符合语法规则.比如说,if表达式需要有适当数目和位置的括号,大括号,测试表达式和表达式来执行.在检测脚本语言的过程中,会生成抽象语法树(AST),它是基于树的数据结构,定义了整个程序的布局
注意,图11.3中的树是以后序遍历(左孩子, 右孩子, 父节点)的方式遍历的,结果会是 5 6 10 * +, 这个结果就是中序表达式以后序表达式的方式表示的结果.这不是随意决定的,后序遍历在栈上计算非常方便.最后,所有AST(不管是否是数学表达式)在语法分析之后都会被以后序的方式遍历
在遍历AST之前,我们必须先生成一份AST.生成AST的第一步就是定义一份语法.计算机语言定义语法的经典方式就通过巴科斯范式,一般缩写为BNF.BNF的设计是相对简洁的.能够做整型加法和减法的运算子语法可以像下面这样定义:
这个::==操作符表示"定义为",|操作符表示"或者",<>操作符用于表示语法规则的名字.所以上面的BNF语法的意思,expression要么是expression加另一个expression,要么是expression减另一个expression.要么是一个integer.这样5+6是有效的,因为5和6都是integer,所以它们都是expression,所以它们可以相加
就像标记化一样,语法分析也有可以使用的工具.其中之一就是bison,它可以在语法规则匹配的时候执行C/C++动作.动作的一般用法就是让它读取AST的时候创建合适的节点.比如说,如果加法表达式匹配上了,就会为加法节点创建两个孩子:左右操作数各一个
最好能有一个类能对应一种类型的节点.所以加/减语法会需要4种不同的类:一个抽象的表达式类,一个整型节点,一个加法节点和一个减法节点.
代码的执行和生成
abstract class Expression function Execute() end class Integer inherits Expression // 存储整数 int value // 构造函数 ... function Execute() // 将结果压到运算符的栈顶 ... end end class Addition inherits Expression // 左右操作数 Expression lhs, rhs // 构造函数 ... function Execute() // 后续表示先访问左孩子, 然后右孩子, 最后到自己 lhs.Execute() rhs.Execute() // 将栈顶的两个值相加,再将结果压到栈里 ... end end // 减法节点和加法一样,除了取反之外 ...
数据格式
另外一个游戏开发中要做的决定就是如何通过数据描述像关卡,游戏属性等游戏元素.对于非常简单的游戏来说,你可能不会管那么多,直接将数据硬编码,但这不是一个理想的解决方案.通过将数据存储在外部文件,就可以让非程序员来编辑.同时还使得创建编辑工具(比如关卡编辑器)来处理数据变得可能
当你确定数据格式的时候,第一个要决定的就是数据是二进制格式还是文本格式.一个二进制文件通常都不具备可读性.如果你用文本编辑器打开二进制文件,可以看到一大串乱码.一个二进制格式的例子就是PNG格式或者其他的图片文件格式.一个文本文件,通常会用ASCII码表示,因此具备可读性.就像判断是否使用脚本语言一样,两种方法之间有一种折中.最后,有的情况用文本格式合理,而有的情况用二进制格式合理
折中
使用二进制文件的第一个优点是体积更小,加载更快.比起花费时间解析文本并转换到内存格式,通常可以直接把整块加载到内存,而不需要任何转换.因为对于游戏来说,效率是非常重要的,所以对于携带大量信息的文件来说,通常都会采用二进制格式
但是,效率的提升不是没有代价的.二进制文件的一个大缺点就是不支持版本控制系统.原因是很难分辨出了两个二进制文件到底有什么不同
对于文本格式而言,查看两个版本的不同就非常容易了.
还有最后一个方案,对于数据文本和二进制表达式都适用.在开发的时候,检查变动是很重要的,所以所有关卡和游戏数据都可以存成文本.然后,在发布的时候,我们可以加入一个烘焙步骤,将所有文本格式转换为二进制格式.这些二进制文件不会进入版本控制系统,而且开发者只修改文本文件.这样,我们在开发的时候就能够得到文本格式带来的便利,同时又能在发布的时候获得二进制格式的优点.由于两种优点都达到了,因此这种方法是非常流行的.唯一要关心的就是测试----开发组需要确保文件的变动不会导致二进制出问题.
二进制格式
对于存储游戏数据来说,二进制格式通常都没有自定义格式.这是因为有很多方式存储数据,这些很大程度上取决于语言和框架.如果是C++游戏,有时候最简单的方法就是将类的数据直接输出到外部文件.这个过程被称为序列化.但是有一些问题需要考虑,比如说,任何类种的动态数据都以指针形式存在.
INI
最简单的文本格式就是INI,经常在用户需要改配置的时候使用.INI文件是分为几节的,而每一节有一系列的键和值.比如说,INI文件的图形设置可能会是这样的:
[Graphics]
Width=1680
Height=1050
FullScreen=true
Vsync=false
虽然对于简单数据来讲,INI能工作得很好,但是对于复杂得数据而言就显得有些笨重.这对于关卡这种有布局的数据结构而言不太适合,比如说,INI不支持嵌套的参数和节
由于INI简单而且使用广泛,所有有着大量容易使用的库可供选择.minIni
XML
XML,全称Extensible Markup Language,是一种HTML概念扩展出来的文件格式.
《巫师2》使用XML存储所有能够在游戏种找到的物品的配置.比如说,下面是某一项,其存储了某个剑的状态
<ability name="Forgotten Sword of Vrans _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_chance display_perc="true" mult="false" always_random="false" min="0.02" max="0.02" type="bonus" />
<vitality_regen mult="false" always_random="false" min="2" max="2" />
</ability>
关于XML的一个批评就是需要很多额外的字符来表示数据,有很多<和>符号,而且总是需要用名字和引号等修饰每个参数,总是需要确保有配对的标签,所有的组合导致文件比较大
但是XML的一大优势就是可以使用模式,就是强制要求哪些字段是必须要有的.这就是说,很容易验证XML文件和确保它声明了必要的参数
像其他常见文件格式一样,有很多解析器都支持XML.用于C/C++最流行的解析器毫无疑问就是TinyXML(C++会附带ticpp).一些语言会有内建的XML解析,比如,C#有System.Xml命名空间用于处理XML文件
JSON
JSON,全称JavaScript Object Notation,比起INI和XML这种新型的文件格式,JSON在近几年非常流行.虽然JSON在互联网交换数据中应用比较多,但在游戏中用于轻量级数据格式也是可以的.有大量的第三方库可以解析JSON,包括C++的libjson和C#的JSON.NET
根据存储数据的类型,JSON可能与XML相比速度更快,体积更小.但也不总是这样
"ability": {
"name": "Forgotten Sword of Vrans _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 }
}
案例学习:<<魔兽世界>>中的UI Mod
《魔兽世界》中的两个主要空间是布局和行为.布局就是界面中图片,按钮,控件的放置,存储为XML.而UI的行为则使用了Lua脚本语言.
布局和事件
界面的布局完全是XML驱动的,用于设置基本的控件,比如框架,按钮,滑动条,复选框等,UI开发者都可以使用.同样在XML文件里,插件指出哪里有事件可以注册
行为
每个插件的行为都通过Lua实现,这样可以快速实现原型,而且可以在游戏运行中重新加载UI.由于使用了基于表格的继承系统,可以修改父类的函数实现重载.大多数的插件代码专注于处理事件和完成表现.每个注册了的事件都需要相应的Lua代码来处理
问题:玩家自动操作
问题:UI兼容性
结论
相关资料
Aho, Alfred, et. al. Compilers: Principles, Techniques, and Tools (2nd Edition). Boston: Addison-Wesley,2006. 这本是经典书籍"龙书"的改进版,深入讲解了很多编译器背后的概念.其中的很多知识都可以用于实现自定义脚本语言
"The SCUMM Diary: Stories behind one of the greatest game engines ever made." Gamasutra. http://tinyurl.com/pypbhp8:这是一篇关于SCUMM引擎和脚本语言的非常有趣的文章,这个引擎几乎开发了所有LucasArts经典冒险游戏
第12章 网络游戏
协议
想象通过快递服务寄出真实的邮件.我们至少需要一个信封,上面写着这封邮件的寄信地址和收信地址.通常,还会需要贴上邮票.在信封里面放着的才是你真正需要传递的信息----邮件本身.数据包可以想象成是在网络上传输的电子信封.在它的数据头中有地址和其他相关信息,然后才发出真正的数据帧
对于信封来说,地址有较标准的格式.寄信地址在左上角,目的地址在右边中间,而邮票在右上角.这是大多数国家最常见的格式.但是对于网络数据传输,有着很多不同的协议或规则来定义数据包以什么格式以及为了发送必须做什么.网络游戏现在通常会让游戏逻辑使用两种协议之一:TCP, UDP. 有的游戏会使用第三种协议,ICMP,常用于一些非游戏逻辑的功能.
IP
IP,全称Internet Protocol(网际网络协议),要通过网路发送数据,这是需要遵守的最基本的协议.本章提到的每一个协议,不管是ICMP,TCP还是UDP,都必须附加IP协议才能够传输数据.哪怕数据只是在本地网络上传输.这就造就了一个事实,那就是现在所有在本地网络中上的机器都需要一个特定的本地地址,只有这样才能通过IP标识某个特定的地址
有两个广泛使用的IP协议版本:IPv4和IPv6.IPv4地址是32位的,IPv6是128位的
ICMP
ICMP,全称Internet Control Message Protocol(网际网络控制消息协议),并不用于在网络上传输大量数据.因此,它不能用于发送游戏数据.这就是说,在编写多人游戏时ICMP有一个方面是相关的:发送回声包的能力.通过ICMP,可以发送数据包给特定地址,然后直接让数据包返回到发送者,这是通过ICMP的回声请求和回声响应机制完成的,这个回声功能可以用于测量两台计算机之间发包所需要的时间
行程往返的时间,就是延迟,在玩家有多台服务器可以选择连接的时候特别有用.在游戏测量所有可以连接的服务器的延迟之后,就可以选择连接延迟最低的服务器.这个测量某个地址的延迟的过程称为ping
TCP
传输控制协议(TCP)是游戏在网络上用来传输数据最常用的两个协议之一.TCP是一个基于连接的,可靠的,保证顺序的协议.可靠传递听起来很好,但随后我们会进一步讨论,TCP协议在游戏上的应用通常没有UDP流行
TCP是基于连接的,就是说两台计算机在任何数据传输之前,必须先建立好彼此的连接.连接完成的方法是通过握手.请求连接的计算机发送一个连接请求到目标计算机,告诉它自己想要如何连接,然后接收者确认这个请求.这个确认在被最初的请求者再次确认之后,三次握手的过程就完成了.
一旦在TCP连接建立之后,就可以在两台计算机之间传输数据.之前提到,所有通过TCP发送的数据包都是可靠的.可靠的工作原理就是在每当数据包通过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,或者maximum transmission unit(最大传输单元),它会决定数据包的大小限制.如果你尝试发送大于MTU的数据包,它会没法通过.幸运的是,TCP在设计上会由操作系统自动将大的数据块分成合适大小的数据包.所以如果你需要从网站上下载1MB的文件,如果使用了TCP,那么分解为合适大小的数据包以及保证接收顺序的事情,程序员就不用操心了
在大多数场景中,通常不需要在游戏中传输那么大量的数据.但是还是会有用到的情况,比如说,如果游戏支持自定义地图,就会有一个问题,那就是新玩家试图加入游戏会话的时候是没有这张自定义地图的.通过TCP,就可以轻松地将自定义地图发送给试图进入游戏的新玩家,而且不用管地图的大小
也就是说,对于真正的游戏逻辑来说,只有小部分游戏类型需要用到TCP.对于回合制游戏,使用TCP是有意义的,因为通过网络发送的所有信息都是相关的,它定义了某个回合中玩家执行的动作.另一个常用TCP的游戏类型是MMO,特别是《魔兽世界》,它的所有数据都用TCP传送.由于《魔兽世界》中的所有数据都是要保证顺序的,所以使用内部就能保证顺序的协议是很合理的.但是对于那些有更加实时动作的游戏来说,比如FPS或者动作游戏,通常都不会使用TCP/IP.这是因为对所有数据包的保证会影响游戏性能
UDP
数据包协议(UDP)是一种无连接,不可靠的协议.就是说你可以直接发UDP数据包,就是说你可以直接发UDP数据包,而不需要与指定目标建立连接.由于它是一个不可靠协议,所以不会有保证数据包到达的手段,也不会保证数据包到达的顺序,也没有接收者应答的功能.由于UDP是一种更加简单的协议,数据头比TCP要小得多
像TCP一样,UDP也支持大约65000个端口.UDP端口和TCP端口是独立的,所以如果TCP和UDP使用同一个端口是不会冲突的.由于UDP是不可靠的,UDP的传输比TCP要高效很多.但是由于不知道数据是否到达,也会造成一些问题.虽然有些数据不太重要(比如对手的位置信息),但还是会有一些重要的保证游戏状态一致的数据.如果玩多人FPS游戏,你发射了子弹,这个信息就很重要,要保证它被服务器或者其他玩家所接收
一个尝试的解决方案就是将TCP用于关键数据,使用UDP传输不太重要的数据.但是INET 97 proceeding paper指出(在相关资料中有提及),在TCP保证系统运行的同时,使用UDP会导致UDP丢包增加.一个更严重的问题是:虽然移动数据不是那么重要,用UDP传送很合理,但我们还是需要数据包的顺序.没有顺序的信息,如果位置B在位置C之后收到,玩家就会被不正确地移动到旧位置
大多数游戏处理这个问题都是使用UDP,然后在所需的数据包里增加一些自定义的可靠层来完成.这个额外的层在UDP数据段的开始位置添加----可以认为是自定义的协议数据头.最基本的可靠性数据是顺序号,可以跟踪哪个数据包号是哪个,然后通过设置位域来应答.通过使用位域,某个数据包可以同时应答多个数据包,而不需要每个数据包都应答一次.这个系统同时还有灵活性,就是如果某个系统不需要可靠性和顺序信息,那么可以不添加数据头直接发送.
就像之前所提到的,UDP在实时游戏领域中是占主导地位的协议.几乎所有FPS,动作类,RTS以及其他网络游戏中对时间敏感的信息都会使用UDP.这也是为什么几乎所有为游戏设计的网络中间件(比如RakNet)只支持UDP
网络拓扑
拓扑决定了不同的计算机在网络游戏会话中是如何相互连接的.虽然配置上有很多种不同的方式,但大多数游戏都支持一种或两种模型:服务器/客户端或是点对点的.对于很多情况,两种方法各有优劣
服务器/客户端
在服务器/客户端模型中,有一个中心计算机(也就是服务器),所有的其他计算机(也就是客户端)都会与之通信.因为服务器与每一台客户端通信,所以在这个模型中,会需要一台有着比客户端更高带宽和处理能力的机器.比如说,如果客户端发送10Kbps数据,在8人游戏中,意味着服务器需要接收80Kbps的数据,这类模型通常也叫作中心型结构,因为服务器是所有客户端的中心节点
服务器/客户端模型是今天最流行的网络游戏拓扑结构.大多数FPS,动作游戏,MMO,策略游戏,回合制游戏等都使用服务器/客户端模型.当然当中会有一些例外,但是确实很多网络游戏都使用服务器/客户端模型
在常见的服务器/客户端模型的实现中,服务器会被认为是权威的,就是说它需要验证大多数客户端行为.假设网络游戏允许玩家向另一名玩家投掷闪避球,在另一名玩家被闪避球打中之后,投掷的玩家会得分.在权威服务器中,当玩家想投掷闪避球的时候,会先向服务器发起请求,服务器会检查这是否是一个合法动作.然后服务器会模拟闪避球的弹道,在每一帧检查这个球是否与某个客户端发生碰撞.如果客户端被击中,服务器会通知客户端被打败
服务器验证的理由有两个.第一个理由就是服务器会拥有所有客户端的最新位置信息.一个客户端投出闪避球,可能会认为自己投中了,但这可能是因为当时位置不是最新的.而如果客户端能够用闪避球淘汰其他玩家而无须经过服务器验证的话,就很容易有外挂程序作弊淘汰其他玩家.
因为服务器需要认证,服务器/客户端模型的游戏逻辑实现起来就比单人游戏更加复杂.在单人游戏中,如果用空格键发射导弹,相同的代码会检测空格键可以创建和发射导弹.但是在服务器/客户端游戏中,空格键代码必须创建发射请求数据包到服务器,然后服务器通知所有其他玩家导弹的存在.
因为这是两种完全不同的方法,对于想要实现两种模式(多人与单人)的游戏来说,最好的方法就是将单人模式当作特殊的多人模式.这在许多游戏引擎中是默认的实现方式,包括id Softwave引擎.就是说单人模式实际上是将服务器和客户端都在一台机器上运行.将单人模式看作多人模式的一种特例的优点在于,只需要一套游戏逻辑代码.否则,网络程序员要在权威服务器的游戏逻辑开发上花很多时间
如果我们回到闪避球游戏的例子,想象一下如果玩家可以选择目标.就是说,它们需要用一些方法以知道对手玩家的运动方向才能够预判出成功的投掷.在最好的情况下,客户端可以以四分之一秒一次地收到服务器下发的对手玩家的位置更新数据.现在想象如果客户端只在收到服务器数据的时候才更新对手位置,就是说每隔四分之一秒,对手玩家都会上传新的位置而对手位置总是闪来闪去.如果在这种情况下试着去击中一个闪避球----当然听上去游戏似乎不太好玩
为了解决这个问题,大多数游戏都会实现某种客户端预测,也就是客户端会在两次服务器下发数据之间猜测中间的过渡情况.在移动的例子中,如果服务器在下发对手玩家的速度的同时一起下发位置,那么客户端预测就可以工作.然后,在服务器更新之间的几帧里,客户端可以根据最后收到的速度和位置,推算对手玩家的位置
只要更新足够频繁,客户端就能让对手玩家在所有时刻都有足够准确的表现.但是,如果由于连接问题导致更新不够频繁,客户端预测就会变得不准确.由于服务器是最权威的存在,客户端必须修复预测位置与真实位置之间的差异.但是如果预测工作得足够好,就能看起来非常顺畅
这个概念还可以延伸到本地终端上执行的动作.如果我们想要一个游戏逻辑很顺畅,就要在玩家按下空格键投掷闪避球的时候,屏幕上立刻有投掷动画.如果客户端要等到服务器确认闪避球投掷之后才开始,游戏会非常卡顿.背后的解决方案就是在服务器确认投掷有效的同时,本地客户端就可以开始播放投掷闪避球动画.如果结果是投掷是非法的,客户端会修复这个问题.只要玩家正确同步,投掷闪避球的时候,游戏的感觉就会非常顺畅
尽管服务器/客户端模型是一种非常流行的方法,但还是有一些问题需要考虑.首先,有些游戏允许一台计算机同时运行服务器和客户端,但还是有一些问题需要考虑.首先,有些游戏允许一台计算机同时运行服务器和客户端.在这种情况下,就会有主机优势,就是说服务器,客户端同时跑的玩家,会得到最快速的服务器响应
另一个服务器/客户端模型的问题就是,如果服务器崩溃,游戏立刻就结束,而所有客户端都失去与服务器的通信.而连接到新的服务器是很困难的(因为所有客户端的信息都不完整),所以不可能修复这个问题.就是说如果玩家是主机,而且马上要失败了,玩家只要退出游戏就可以重启所有玩家,这样就让游戏变得没意思了.但是从服务器/客户端模型来看,如果一个客户端延迟非常多,对其他玩家的体验影响不是特别大
在任何事件中,为了防止主机带来的问题,许多服务器/客户端游戏只支持专用服务器.在大多数例子下,这意味着服务器是安装在一个特别的位置的(通常在数据中心),而所有玩家都需要连接到这些服务器(没有玩家可以做主机).虽然网速快的玩家还是比网速慢的玩家有优势,但是通过将服务器放在第三方的方法将这种绝对优势大大减弱了.可是,运行专用服务器的缺点就是部署得越多,费用就越高
点对点
在点对点模型中,每个客户端都连接到其他客户端.这意味着对于所有客户端都要求同样的性能和带宽.由于点对点模型中没有中心的权威服务器,会有很多种可能:每个客户端只认证自己的动作,或者每个客户端都认证其他客户端,又或者每个客户端都模拟整个世界
RTS类型种经常会用到点对点模型.正式一点的名称为帧同步模型,就是网络更新被分为每次150ms到200ms的回合更新.每当执行一次输入动作,这个命令都会保存到队列里,在每轮结束的时候执行.这就是为什么在你玩多人游戏《星际争霸2》的时候,控制单位的命令没有立刻执行----在单位回应命令之前有明显的延迟,因为它们都在等待帧同步回合的结束
因为输入命令通过网络传递,RTS游戏中的每个客户端实际上是在模拟所有单位,它们像本地玩家一样处理输入.这也使得记录下所有对手的指令并在游戏结束后查看即时回放成为可能
客户端的帧同步方法会使所有客户端都紧密地同步,没有任何玩家能够比其他玩家先走.当然,这种同步方式的缺点就是----如果一个客户端开始延迟,其他客户端都要等待,一直到这个玩家赶上来.但是帧同步方法在RTS游戏里面非常流行,因为它通过网络传输的数据相对来讲会更少.比起发送所有单位的信息,游戏只在每分钟发送相对小数量的动作,即使是最好的RTS玩家每分钟最多也就发送300-400次指令
因为在点对点配置中,每个点都要模拟游戏世界的一部分,游戏状态必须保证100%的确定,这使得基于变化的游戏逻辑不太好做.如果《星际争霸2》的*者可以根据掷骰来决定攻击伤害,这可能会导致这一点的模拟与另一点的模拟不一致.但这也不是说在点对点模型中完全不能有随机要素(比如《英雄连》),但是要付出更大的努力保证各点同步一致
虽然RTS是将点对点模型用得最多的游戏类型,但还有其他游戏也这么做.一个著名的例子就是《光晕》系列,在多人对战中使用点对点模型.但是可以说,点对点没有服务器/客户端模型使用那么广泛
作弊
任何网络游戏主要考虑的一点就是要为玩家打造公平的游戏环境.但不幸的是,有的玩家为了胜利会不择手段,哪怕打破游戏的规则.在网络多人游戏中,有很多规则都有可能会被打破,所以不管是否可能,网络游戏需要防止玩家作弊
信息作弊
信息作弊是一种让玩家获得本不该拥有的额外信息的手段.
游戏状态作弊
信息作弊能够让玩家获得不对称的优势,而游戏状态作弊则可以完全破坏游戏
中间人攻击
影响最坏的一种作弊就是在两台通信机器之间设立一台机器,用于拦截所有数据包.这种称为中间人攻击,在通过网络发送明文消息的时候特别容易被人篡改.这种攻击是为什么我们连接到金融机构网站的时候应该使用HTTPS而不是HTTP的主要原因
使用HTTP,所有数据都以明文的方式通过网络发送.就是说,如果在网站上你用HTTP提交你的用户名和密码,容易遇到中间人窃取信息.而通过HTTPS,所有数据都是加密的,这使得中间人几乎不可能访问到这些信息.虽然HTTPS协议暴露出几个脆弱的地方,但是对于大多数用户而言这都不是大问题
但是,在游戏的情况下,中间人攻击最大的优点在于它允许作弊发生在不需要玩游戏的机器.这就是说几乎所有的外挂检测程序都不起作用----它们根本就不知道数据包被拦截而且被篡改了.还有一个问题是,一般通过访问数据包信息可能允许黑客更加深入地发掘其他能够渗透游戏的漏洞
一种防止这类攻击的方法就是对所有数据包进行加密,这样只有服务器才能解密并理解数据包.但这对于大多数游戏消息来说都过重了,对所有信息加密实际上增加了负荷.但最少,我们可以在玩家登录(比如在MMO)的时候进行一次加密,通过某种加密算法来有效避免玩家账号被盗
相关资料
Frohnmayer, Mark and Tim Gift. "The TRIBES Engine Networking Model." 这份经典的论文列出了如何实现为UDP增加可靠性和数据流功能,而且应用到了Tribes上
Sawashima, Hidenari et al. "Characteristics of UDP Packet Loss: Effect of TCP Traffic." Proceedings of INET 97. 这篇文章从技术的角度讨论了为什么TCP应答会让UDP丢包
Steed, Anthony and Manuel Oliveria. Networked Graphics. Burlington: Morgan Kaufmann, 2009. 这本书深入地讲解了游戏网络编程的方方面面
第13章 游戏示例:横向滚屏者(iOS)
概览
Objective-C
Cocos2D
代码分析
AppDelegate
MainMenuLayer
GameplayScene
ScrollingLayer
Ship
Projectile
Enemy
ObjectLayer
第14章 游戏示例:塔防(PC/Mac)
概览
C#
XNA
MonoGame
代码分析
设置
单件
游戏类
游戏状态
游戏对象
关卡
计时器
寻路
摄像机和投影
输入
物理
本地化
图形
声音
用户界面