第一部分 基础
第1章 导论
第2章 专业工具
第3章 游戏软件工程基础
第4章 游戏所需的三维数学
第二部分 低阶引擎系统
第5章 游戏支持系统
第6章 资源及文件系统
第7章 游戏循环及实时模拟 (已看)
第8章 人体学接口设备(HID)
第9章 调试及开发工具
第三部分 图形及动画
第10章 渲染引擎
第11章 动画系统
第12章 碰撞及刚体动力学
第四部分 游戏性
第13章 游戏性系统简介 (已看)
第14章 运行时游戏性基础系统
第五部分 总结
第15章 还有更多内容吗
参考文献
第一部分 基础
第1章 导论
1.1 典型游戏团队的结构
1.2 游戏是什么
1.3 游戏引擎是什么
1.4 不同游戏类型中的引擎差异
1.5 游戏引擎改观
1.6 运行时引擎架构
1.7 工具及资产管道
第2章 专业工具
2.1 版本控制
2.2 微软Visual Studio
2.3 剖析工具
2.4 内存泄露和损坏检测
2.5 其他工具
第3章 游戏软件工程基础
3.1 重温C++及最佳实践
3.2 C/C++的数据,代码及内存
3.3 捕捉及错误处理
第4章 游戏所需的三维数学
4.1 在二维中解决三维问题
4.2 点和矢量
4.3 矩阵
4.4 四元数
4.5 比较各种旋转表达方式
4.6 其他数学对象
4.7 硬件加速的SIMD运算
4.8 产生随机数
第二部分 低阶引擎系统
第5章 游戏支持系统
5.1 子系统的启动和终止
5.2 内存管理
5.3 容器
5.4 字符串
5.5 引擎配置
第6章 资源及文件系统
6.1 文件系统
6.2 资源管理器
第7章 游戏循环及实时模拟
游戏是实时的, 动态的, 互动的计算机模拟. 由此可知, 时间在点在游戏中担当非常重要的角色. 游戏中有不同种类的时间----实时, 游戏时间, 动画的本地时间线, 某函数实际消耗的CPU周期时间等.
每个引擎系统中, 定义及操作时间的方法各有所不同.我们必须透彻理解游戏中所有时间的使用方法.
7.1 渲染循环
在图形用户界面(graphical user interface, GUI)中, 例如Windows和Macintosh的机器上的GUI, 画面上大部分的内存是静止不动的.在某一时刻,只有少部分的视窗会主动更新其外貌.因此, 传统上绘画GUI界面会利用一个称为矩形失效(rectangle invalidation)的技术, 仅让屏幕中有改动的内容重绘.较老的二维游戏也会采用相似的技术,尽量降低需重画的像素数目
实时三维计算机图形以完全另一方式实现.当摄像机在三维场景中移动时, 屏幕或视窗上的一切内容都会不断改变,因此再不能使用失效矩形法.取而代之,计算机图形采用和电影相同的方式产生运动的错觉和互动性----对观众快速连续地显示一连串静止影像
要在屏幕上快速连续地显示一连串静止影像, 显然需要一个循环. 在实时渲染应用中, 此循环又称为渲染循环(render loop).渲染循环的最简单结构如下:
while (!quit) { // 基于输入或预设的路径更新摄像机变换 updateCamera(); // 更新场景中所有动态元素的位置, 定向及其他相关的视觉状态 updateSceneElements(); // 把静止的场景渲染至屏幕外的帧缓冲(称为"背景缓冲") renderScene(); // 交换背景缓冲和前景缓冲, 令最近渲染的影像显示于屏幕之上 // (或是在视窗模式下, 把背景缓冲复制至前景缓冲) swapBuffers(); }
7.2 游戏循环
游戏由许多互动的子系统所构成, 包括输入/输出设备, 渲染, 动画, 碰撞检测及决议,可选的刚体动力学模拟,多玩家网络, 音频等. 在游戏运行时, 多数游戏引擎子系统都需要周期性地提供服务.然而, 这些子系统所需的服务频率各有不同.动画子系统通常需要30Hz或60Hz的更新率,此更新率是为了和渲染子系统同步.然而, 动力学模拟可能实际需要更频繁地更新(如120Hz).更高级的系统, 例如人工智能,就可能只需要每秒1,2次更新, 并且完全不需要和渲染循环同步
有许多不同方法能实现游戏引擎子系统的周期性更新.我们即将探讨一些可行的架构方案.但首先, 我们会以最简单的方法更新引擎子系统----采用单一循环更新所有子系统.这种循环常称为游戏循环(game loop),因为他是整个游戏的主循环,更新引擎中所有子系统.
7.2.1 简单例子: 《乒》
void main() { initGame(); while (true) { readHumanInterfaceDevices(); if (quitButtonPressed()) { break; } movePaddles(); moveBall(); collideAndBounceBall(); if (ballImpactedSide(LEFT_PLAYER) { incrementScore(RIGHT_PLAYER); resetBall(); } else if (ballImpactedSide(RIGHT_PLAYER) { inrementScore(LEFG_PLAYER); resetBall(); } renderPlayerfield(); } }
7.3 游戏循环的架构风格
有多种方式可以实现游戏循环,但其核心通常都会有一个或多个简单循环,再加上不同的修饰.
7.3.1 视窗消息泵
在Windows平台,游戏除了要服务引擎本身的子系统,还要处理来自Windows操作系统的消息.因此, Windows上的游戏会有一段代码称为消息泵(message pump).其基本原理是先处理来自Windows的消息,无消息时才执行引擎任务.典型的消息泵的代码如下:
while (true) { // 处理所有待处理的Windows消息 MSG msg; while (PeekMessage(&msg, NULL, 0, 0) > 0) { TranslateMessage(&msg); DispatchMessage(&msg); } // 再无Windows消息需要处理, 执行我们"真正"的游戏循环迭代一次 RunOneIterationOfGameLoop(); }
以上这种实现游戏循环的方式,其副作用是设置了任务的优先次序,处理Windows消息为先, 渲染和模拟游戏为后.这带来的结果是, 当玩家在桌面上改变游戏的视窗大小或移动视窗时,游戏就会愣住不动
7.3.2 回调驱动框架
多数游戏引擎子系统和第三方游戏中间套件都是以程序库(library)的方式构成的.程序库是一组函数及/或类,这些函数和类能被应用程序员随意调用.程序库对程序员提供最大限度的自由.但程序库有时候比较难用,因为程序员必须理解如何正确使用那些函数和类
相比之下,有些游戏引擎和游戏中间套件则是以框架(framework)构成的.框架是半完成的应用软件----程序员需要提供框架中空缺的自定义实现(或覆写框架的预设行为).但程序员对应用软件的控制流程只有少量控制(甚至完全不能控制),因为那些都是由框架控制的
在基于框架的渲染引擎或游戏引擎之下,主游戏循环已为我们准备好了,但该循环里大部分是空的.游戏程序员可以编写回调函数(callback function)以"填充"当中缺少的细节. 例如, ORGE渲染引擎本身是一个以框架包装的库. 在底层, ORGE提供给程序员直接调用的函数.然而, ORGE也提供了一套框架, 框架封装了如何有效地运用底层ORGE库的知识.若选择使用ORGE框架, 程序员便需要自Orge::FrameListener派生一个类,并覆写两个虚函数: frameStarted()和frameEnded().读者可能已猜出来,ORGE在渲染主三维场景的前后会调用这两个函数.ORGE框架对游戏循环的实现方式像以下的伪代码
while (true) { for (each frameListener) { frameListener.frameStarted(); } renderCurrentScene(); for (each frameListener) { frameListener.frameEnded(); } finalizeSceneAndSwapBuffers(); } class GameFrameListener: public Orge::FrameListener { public: virtual void frameStarted(const FrameEvent & event) { // 于三维场景渲染前所需执行的事情(如执行所有游戏引擎子系统) pollJoypad(event); updatePlayerControls(event); updateDynamicSimulation(event); resolveCollisions(event); updateCamera(event); // 等等 } virtual void frameEnded(const FrameEvent & event) { // 于三维场景渲染后所需执行的事情 drawHud(event); // 等等 } }
7.3.3 基于事件的更新
在游戏中,事件(event)是指游戏状态或游戏环境状态的有趣改变.事件的例子有:人类玩家按下手柄上的按钮,发生爆炸,敌方角色发现玩家等.多数游戏引擎都有一个事件系统,让各个引擎子系统登记其关注的某类型事件,当那些事件发生时就可以一一回应.游戏的事件系统通常和图形用户界面里的事件/消息系统非常相似(如微软的Windows视窗消息,Java AWT的事件处理,C#的delegate和event关键字)
有些游戏引擎会使用事件系统来对所有或部分子系统进行周期性更新.要实现这种方式,事件系统必须容许发送未来的事件.换句话说,事件可以先置于队列,稍后才取出处理.那么,游戏引擎在实现周期性更新时,只需要简单地加入事件.在事件处理器里,代码便能以任何所需的周期进行更新.接着,该代码可以发送一个新事件,并设定该事件在未来1/30s或1/60s生效,那么这个周期性更新就能根据需要一直延续下去
7.4 抽象时间线
游戏编程中,使用抽象时间线(abstract timeline)思考问题有时候极为有用.时间线是连续的一维轴,其原点(t = 0)可以设置为系统中其他时间线的任何相对位置.时间线可以用简单的时钟变量实现,该变量以整数或浮点数格式储存绝对时间值
7.4.1 真实时间
我们可以直接使用CPU的高分辨率计时寄存器来量度时间,这种时间在所谓的真实时间线(real timeline)上.此时间线的原点定义为计算机上次启动或重置之时.这种时间的量度单位是CPU周期(或其倍数),但其实只要简单地乘以CPU的高分辨率计时器频率,此单位便可以转换为秒数
7.4.2 游戏时间
我们不应该限制自己只使用真实时间线.我们可以为解决问题定义许多所需的时间线.例如,我们可以定义游戏时间线(game timeline),此时间线在技术上来说独立于真实时间.在正常情况下,游戏时间和真实时间是一致的.若希望暂停游戏,就可以简单地临时停止对游戏时间的更新.若要把游戏变成慢动作,可以把游戏时钟更新得慢于实时时钟.通过相对某时间线取缩放和扭曲另一时间线,就可以实现许多不同效果
7.4.3 局部及全局时间线
我们可以想象其他各种时间线.例如,每个动画片段或者音频片段都可以含有一个局部时间线(local timeline),该时间线的原点(t = 0)定义为片段的开始.局部时间线能按原来制作或录制片段的时间量度播放时的进展时间.当在游戏中播放片段时,我们可以用原来以外的速率来播放.例如,我们可以加速一个动画,或减慢一个音频片段.甚至可以反向播放动画,只要把时间逆转就行了
所有这些效果都可以视觉化为局部和全局时间线之间的映射,如同真实时间和游戏时间的关系
7.5 测量及处理时间
7.5.1 帧率即时间增量
实时游戏的帧率(frame rate)是指一连串三维帧以多快的速度向观众显示.帧率的单位为赫兹(Hertz, Hz),即每秒的周期数量,这个单位可以用来描述任何周期性过程的速率.在游戏和电影里,帧率通常以每秒帧数(frame per second, FPS)来量度,其意义与赫兹完全相等.
两帧之间所经过的时间称为帧时间(frrame time),时间增量(time delta)或增量时间(delta time).最后一个英文写法(delta time)很常见,因为两帧之间的持续时间在数学上常写成Δt.(技术上来说,Δt应该称为帧周期(frame period),因为它是帧频率(frame frequencey)的倒数: T = 1/f.但是,在这种语境中,游戏程序员鲜会使用周期这个术语)毫秒是游戏中常用的时间单位
7.5.2 从帧率到速率
假设我们想造一艘太空船,让它在游戏世界中以恒定速率每秒40M飞翔.(在二维游戏中,我们可能用每秒40个像素来设定速率!)实现此目标的简单方法是,把船的速率v(单位为米每秒)乘以一帧的经过时间Δt(单位为秒),就会得出该船的位置变化Δx = vΔt(单位为米每帧).之后,此位置增量就能加到船的目前位置x1,求得其次帧的位置: x2 = x1 + Δx = x1 + vΔt.
以上例子其实是数值积分(numerical integration)的简单形式,名为显示欧拉法(explicit Euler method).若速率大致维持常数,此法可以正常运作.但是对于可变的速率,我们需要一些更复杂一点的积分方法.不过所有数值积分技术都需要使用帧时间Δt.一个安全的说法是,游戏中物体的感知速度(perceived speed)依赖于帧时间Δt.因此,计算Δt的值仍是游戏编程的核心问题之一.
7.5.2.1 受CPU速度影响的早期游戏
在许多早期的电视游戏中,并不会尝试在游戏循环中准确量度真实经过的时间.实质上,程序员会完全忽略Δt,取而代之,以米(或像素等其他距离单位)每帧设定速率.换言之,那些程序员可能在不知不觉下,以Δx = vΔt设定速率,而非使用v.
此简单方法造成的后果是,游戏中物体看上去的速度完全依赖于运行机器能产生的帧率.若在较快的CPU上运行这类游戏,游戏看上去就会像快速进带一样.因此,笔者称这类游戏位受CPU速度影响的游戏
有些旧式PC带有"turbo"按钮,用来支持这类游戏.按下turbo按钮后,PC就会以其最高速度运行,但受CPU速度影响的游戏这时可能运行称快速进带的样子.当关上turbo按钮,PC就会模拟成上一代处理器的运行速度,使那些位上一代PC而设计的游戏能正常运行
7.5.2.2 基于经过时间的更新
要开发和CPU速度脱钩的游戏,我们必须以某些方法度量Δt,而非简单地忽略它.量度Δt并非难事,只需读取CPU的高分辨率计时器取值两次----一次于帧开始之时,一次于结束之时.然后,取二者之差,就能精确度量上一帧的Δt.之后,Δt就能供所有引擎子系统使用,或可把此值传给游戏循环中调用到的函数,或把此值变成全局变量,或把此值包装进某种单例里
许多游戏引擎都会使用以上所说的方法.事实上,笔者大胆预测,绝大部分游戏引擎都使用以上的方法.然而,此方法有一大问题: 我们使用第k帧量度出来的Δt取估计接着的第k + 1帧的所需时间.这么做不一定准确.(如投资中常说: 过往表现不能作为日后表现的指标).下一帧可能因为某些原因,比本帧消耗更多(或更少)时间.我们称此类事件位帧率尖峰(frame-rate spike)
使用上一帧的Δt来估计下一帧的时间,会产生非常坏的效果.例如,万一不小心,就会使游戏进入低帧率的"恶性循环".此情况可举例解释.假设当游戏以每33.3ms更新一次(即30Hz)时,物理模拟最为稳定.若然遇到有一帧特别慢,假设是57ms,那么我们便要在下一帧对物理系统步进两次,用以"演示"刚才经过57ms.但步进两次会比正常消耗大约多一倍时间,导致下一帧变成如本帧那么慢,甚至更慢.这样只会使问题加剧及延长
7.5.2.3 使用移动平均
事实上,游戏循环中每帧之间是有一些连贯性的.例如,若本帧中摄像机对着某走廊,走廊出口含许多耗时渲染的物体,那么下一帧有很大机会仍然指向该走廊.因此,其中一个合理的方法是,计算连续几帧的平均时间,用来估计下一帧的Δt.此方法能使游戏适应转变中的帧率,同时缓和瞬间效能尖峰所带来的影响.平均的帧数越多,游戏对帧率转变的应变能力就越小,但受尖峰的影响也会变得越小
7.5.2.4 调控帧率
使用上一帧的Δt估计本帧的经过时间,此做法带来的误差问题是可以避免的,只要我们把问题反转过来考虑.与其尝试估算下一帧的经过时间,不如尝试保证每帧都准确耗时33.3ms(若以60FPS运行就是16.7ms).为达到此目标,我们仍然需要量度本帧的耗时.若耗时比理想时间还要短,我们只需让主线程休眠,直至到达目的时间.若度量到的耗时比理想时间长,那么只好白等下一个目标时间.此方法称为帧率调控(frame-rate govering)
显然,只当游戏的平均帧率接近目标帧率,此方法才有效.若因经常遇到"慢"帧,而导致游戏不断在30FPS和15FPS之间徘徊,那么就会明显降低游戏质量.因此,我们仍然需要让所有引擎系统设计成能接受任意的Δt.在开发时,可以把引擎停留在"可变帧率"模式,一切如常运作.之后,游戏能一贯地达到目标帧率,这样就能开启帧率调控,获其好处
使帧率连续维持稳定,对游戏多方面都很重要,有些引擎系统,例如物理模拟中使用的数值积分,以固定时间更新运作最佳.稳定帧率也会较好看,因为如下一节的详述,更新视频的速率若不配合屏幕的刷新率会导致画面撕裂(tearing),而稳定帧率则可避免画面撕裂发生
除此之外,当帧率连续维持稳定,一些如游戏录播功能会变得更可靠,游戏录播功能,如字面所指,能把玩家的游戏过程录制下来,之后再精确地回放出来.此功能既是供玩家用的有趣功能,也是非常有用的测试和调试工具.例如,一些难以找到的缺陷,可以通过游戏录播功能轻易重视
为了实现游戏录播功能,需要记录游戏进行时的所有相关事件,并把这些事件及其时间戳(timestamp)存储下来.然后在播放时,使用相同的初始条件和随机种子,就能准确地按时间重播那些事件.理论上,这么做能产生和原来游戏过程一模一样的重播.然而,若帧率不稳定,事情可能以不完全相同的次序发生.因而造成一些"漂移",很快就会使原来应在后退的AI角色变成在攻击状态中.
7.5.2.5 垂直消隐区间
有一种显示异常现象, 称为画面撕裂(tearing).此现象的成因,是由于CRT显示器的电子枪在扫描中途交换背景缓冲区和前景缓冲区所引致.当发生画面撕裂,屏幕上半部分显示了旧的影响,而下半部分则显示了新的影响.为避免画面撕裂,许多渲染引擎会在交换缓冲区之前,等待显示器的垂直消隐区间(vertical blanking interval, 即电子枪重归到屏幕上角的时间区间)
等待垂直消隐区间是另一种帧率调控.实际上它能限制主游戏循环的帧率,使其必然为屏幕刷新率的倍数.例如,在以60Hz刷新的NTSC显示器上,游戏的真实更新率实际会被量化为1/60s的倍数.若两帧之间的时间超过1/60s,便必须等待下一次垂直消隐区间,即该帧共花了2/60s(30FPS).若错过两次垂直消隐,那么该帧共花了3/60s(20FPS),以此类推.此外,就算与垂直消隐同步,也不要假设游戏会以某特定帧率运行;谨记PAL和SECAM标准是基于大约50Hz的刷新率,而非60Hz
7.5.3 使用高分辨率计时器测量实时
大多数操作系统都提供获取系统时间的函数,例如标准C程序库函数time(),然而,因为这类函数所提供的量度分辨率不足,所以并不适合用在实时游戏中量度经过时间.再以time()为例,其传回值为整数,该值代表自1970年1月1日午夜至今的秒数,因此time()的分辨率为秒.考虑到游戏中每帧仅耗时数十毫秒,此量度分辨率实在太粗糙
所有现代CPU都含有高分辨率计时器(high-resolution timer).这种计时器通常会实现为硬件寄存器,计算自启动或重置计算机之后总共经过的CPU周期数目(或周期的倍数).量度游戏中经过的时间该使用这种计时器,因为其分辨率通常是几个CPU周期时间的级数.例如,在3GHz奔腾处理器上,其高分辨率计时器每周期递增一次,也就是每秒30亿次.因此其分辨率是30亿分之一 = 3.33 x 10-10s = 0.333ns(纳秒/nanosecond).此分辨率对于游戏中所有时间测量已绰绰有余
各微处理器及操作系统中,查询分辨率计时器的方法各有差异.奔腾的特殊指令rdtsc(read time-stamp counter/读取时戳计数器)可供使用.但也可以使用经Windows封装的Win32 API函数: QueryPerformanceCounter()读取本地CPU的64计数寄存器,以及QueryPerformanceFrequency()传回本CPU的每秒计数器递增次数.一些PowerPC架构中(如Xbox 360及PS3)提供mftb(move from time base register/ 读取时间基寄存器)指令,用来读取两个32位时间基寄存器.另一些PowerPC架构则以mfspr(move from special-purpose register/读取特殊用途寄存器)代替
大都数CPU的高分辨率计时器都是64位的,以免经常造成计时器溢出归零.64位无符号整数的最大值是0xFFFFFFFFFFFFFFFF,大约是1.8 x 1019个周期.因此,以每CPU周期更新高分辨率计时器的3GHz奔腾处理器来说,其寄存器每次约195年才会溢出归零----肯定不是我们需要为此而失眠的问题.对比之下,32位整数时钟在3GHz下约每1.4s就会溢出归零
7.5.3.1 高分辨率计时器的漂移
要注意,在某些情况下高分辨率计时器也会造成不精确的时间测量.例如,在一些多核处理器中,每个核都有其独立高分辨率计时器,这些计时器可能(实际上会)彼此漂移(drift).若比较不同核读取的绝对计算器读数,可能会出现一些奇异情况----甚至是负数的经过时间.对于这种问题必须加倍留神
7.5.4 时间单位和时钟变量
每当要量度或指定持续时间,我们需要做两个决定
1. 应使用什么时间单位?我们要把时间储存为秒,毫秒,机器周期,或是其他单位?
2. 应使用什么数据类型储存时间?应使用64位整数,32位整数,还是32位浮点数变量?
这些问题的答案在于量度时间的目的.这样又会引申两个问题: 我们需要多少精度?以及我们期望能表示多大的范围?
7.5.4.1 64位整数时钟
我们之前已谈及以机器周期量度的64位无符号整数时钟,它同时支持非常高的精度(3GHz CPU上每周期是0.333ns)及很大的数值范围(3GHz CPU需约195年才循环一次). 因此这种时钟是最具弹性的表示法,只要你能负担得起64位的存储
7.5.4.2 32位整数时钟
当要量度高精度但较短的时间,就可以用以机器周期量度的32位整数时钟.例如,要剖析一段代码的效能,可以这么做:
// 抓取一个时间值 U64 tBegin = readHiResTimer(); // 以下是我们想量度性能的代码 doSomething(); doSomethingElse(); nowReallyDoSomething(); // 量度经过时间 U64 tEnd = readHiResTimer(); U32 dtCycles = static_cast(tEnd - tBegin); // 现在可以使用或存储dyCycles的值
注意我们仍然使用64位整数变量存储原始的时间量度.只有持续时间dt才用32位变量存储.这么做可以避免一些整数溢出的问题.例如, 若tBegin = 0x12345678FFFFFFB7及tEnd = 0x12345678900000039,如果在相减之前先把这两个时间缩短位32位整数,那么就会得到一个负值的时间量度
7.5.4.3 32位浮点时钟
另一常见方法是把较小的持续时间以秒位单位存储为浮点数.实现方法就是把以CPU周期为单位的时间量度除以CPU时钟频率(单位是每秒周期次数).例如:
// 开始时假设为理想的帧时间 (30 FPS) F32 dtSeconds = 1.0f / 30.0f; // 在循环开始前先读取当前时间 U64 tBegin = readHiResTimer(); while (true) { // 主游戏循环 runOneIterationOfGameLoop(dtSeconds); // 再读取当前时间,计算增量 U64 tEnd = readHiResTimer(); dtSeconds = (F32)(tEnd - tBegin) / (F32)getHiResTimerFrequency(); // 把tEnd用作下一帧新的tBegin tBegin = tEnd; }
再次注意我们必须先使64位的时间相减,之后才把两者之差转换为浮点格式,这样能避免把很大的数值存进32位浮点数变量里
7.5.4.4 浮点时钟的极限
回想在32位IEEE浮点数中,能通过指数把23位尾数动态地分配给整数和小数部分.小数值中,整数部分占用较少位,于是便留下更多位精确地表示小数部分.但当时钟的值变得很大,其整数部分就会占用更多的位,小数部分剩下更少的位.最终,甚至整数部分的较低有效位都变成零.换言之,我们必须小心,避免用浮点时钟变量存储很长的持续时间.若使用浮点变量存储自游戏开始至今的秒数,最后会变得极不准确,无法使用
浮点时钟只适合存储相对较短的持续时间,最多能量度几分钟,但更常见的是用来存储单帧或更短的时间.若在游戏中使用存储绝对值的浮点时钟,便需要定期将其重置为零,以免累加至很大的数值
7.5.4.5 其他时间单位
有些游戏引擎支持把时间设定为游戏自定义单位,使32位时钟既有足够的精度,也不会很快就溢出循环.其中一个常见的选择是1/300s为时间单位.此选择也有几个优点:(a)在许多情况之下也足够精确,(b)约165.7天才会溢出,(c)同时是NTSC和PAL刷新率的倍数.在60FPS下,每帧就是5个这种单位;在50FPS下,每帧就是6个这种单位
显然1/300s时间单位并不足够精确地处理一些细微的效果,例如动画的时间缩放(若尝试把30FPS的动画减慢至正常的1/10速度,这种单位产生的精度就已经不行了!)所以对很多用途来说,浮点数或机器周期仍是比较合适之选. 而1/300s这种单位,能有效应用于诸如自动枪械每次发射之间的空挡时间,由AI控制的角色要等多久才开始巡逻,或玩家留在硫酸池里能存活的时间期限
7.5.5 应付断点
当游戏在运行时遇到断电,游戏循环便会暂停,由调试器接手控制.然而,这时候CPU还在运行,实时时钟仍然会继续累积周期次数,当程序员在断点里查看代码时,挂钟时间同时大量流逝.直至程序员继续执行程序时,该帧的持续时间才可能会量度为几秒,几分钟,甚至几小时!
显然,若把这么大的增量时间传到引擎中各子系统,必然有坏事发生.若我们幸运,游戏在一帧里蹒跚地执行很多秒的事情后,仍可继续运作.更糟的情况是导致游戏崩溃
有一个简单的方法可以避开此问题,在主游戏循环中,若量度到某帧的持续时间超过预设的上限(如1/10s),则可假设游戏刚从断点恢复执行,于是我们把增量时间人工地设为1/30s或1/60s(或其他目标帧率).其结果是,游戏在一帧里锁定了增量时间,从而避免一个巨大的帧时间量度尖峰
// 开始时假设为理想的帧时间(30 FPS) F32 dtSeconds = 1.0f / 30.0f; // 在循环开始前先读取当前时间 U64 tBegin = readHiResTimer(); while (true) { // 游戏主循环 updateSubSystemA(dt); updateSubSystemB(dt); // ... renderScene(); swapBuffers(); // 再读取当前时间,估算下帧的时间增量 U64 tEnd = readHiResTimer(); dtSeconds = (F32)(tEnd - tBegin)/(F32)getHiResTimerFrequency(); // 若dt过大,一定是从断点中恢复过来的,那么我们锁定dt至目标帧率 if (dt > 1.0f / 30.0f) { dt = 1.0f / 30.0f; } // 把tEnd用作下一帧新的tBegin tBegin = tEnd; }
7.5.6 一个简单的时钟类
有些游戏引擎会把时间变量封装为一个类.引擎可能含此类的数个实例----一个作用表示真实"挂钟时间",另一个表示"游戏时间"(此时间可以暂停,或相对真实时间减慢/加快),另一个记录全动视频的时间等.实现时钟类很简单直接.以下笔者将介绍一个简单实现,并提示当中几个常见窍门,技巧及陷阱
时钟类通常含有一个变量,负责记录自时钟创建以来经过的绝对时间.如上文所述,选择合适的数据类型和单位存储此变量,至关重要.在以下的例子中,笔者使用和CPU相同的存储绝对时间方法----以机器周期为单位的64位无符号整数.当然,可以有其他各种实现,但此例子大概是最简单的
时钟类也可以支持一些很棒的特性,例如时间缩放.实现此功能并不困难,只需把量度得来的时间增量先乘以时间缩放因子,然后才进时钟变量.我们也可以暂停时间,只要在暂停时忽略更新便可以了.要实现单步时钟,只需要在按下某按钮或键时,把固定的时间区间加到暂停中的时钟.以下的Clock类能示范所有这些特性:
class Clock { U64 m_timeCycles; F32 m_timeScale; bool m_isPaused; static F32 s_cyclesPerSecond; static inline U64 secondsToCyle(F32 timeSeconds) { return (U64)(timeSecond * s_cyclesPerSecond); } // 警告: 危险----只能转换很短的经过时间至秒 static inline F32 cyclesToSeconds(U64 timeCycles) { return (U64)(timeCycles / s_cyclesPerSecond); } public: // 在游戏启动时调用此函数 static void init() { s_cyclesPerSecond = (F32)readHiResTimerFrequency(); } // 构建一个时钟 explicit Clock(F32 startTimeSeconds = 0.0f) : m_timeCycles(secondToCycles(startTimeSeconds)), m_timeScale(1.0f), // 默认为无缩放 m_isPaused(false) // 默认为运行中 { } // 以周期为单位返回当前时间,注意我们并不是返回以浮点秒表示的绝对时间,因为32位浮点没有足够的精确度. 参考calcDeltaSeconds() U64 getTimeCycles() const { return m_timeCycles; } // 以秒为单位,计算此时钟与另一时钟的绝对时间差,由于32位浮点的精度所限,传回的时间差是以秒表示的 F32 calcDeltaSeconds(const Clock & other) { U64 dt = m_timeCycles - other.m_timeCycles; return cyclesToSeconds(dt); } // 应在每帧调此函数一次,并给予真实量度帧时间(以秒为单位) void update(F32 dtRealSeconds) { if (!m_isPaused) { U64 dtScaleCycles = secondsToCycles(dtRealSeconds * m_timeScale); m_timeCycles += dtScaledCycles; } } void setPaused(bool isPaused) { m_isPaused = isPaused; } bool isPaused() const { return m_isPaused; } void setTimeScale(F32 scale) { m_timeScale = scale; } F32 getTimeScale() const { return m_timeScale; } void singleStep() { if (m_isPaused) { // 加上理想帧时间: 别忘记把它缩放至我们当前的时间缩放率 U64 dtScaledCycles = secondToCycles((1.0f / 30.0f) * m_timeScale); m_timeCycles += dtScaleCycles; } } };
7.6 多处理器的游戏循环
从单核到多核的转变是个痛苦的过程.设计多线程程序比单线程的难得多.多数游戏公司逐步进行此转变,其做法是选择几个引擎子系统做并行化,并保留用旧的单线程主循环控制余下的子系统.至2008年,多数游戏工作室已完成引擎大部分的转变,对每个引擎带来不同程度的并行性
7.6.1 多处理器游戏机的架构
7.6.1.1 Xbox 360
Xbox 360游戏机含3个完全相同的PowerPC处理器核.每个核有其专用的L1指令缓存和L1数据缓存,而3个核则共用一个L2缓存.此3个核和图形处理器(graphics processing unit, GPU)共用一个统一的512MB内存.这些内存可用来存放可执行代码,应用数据,纹理,显存等.关于Xbox 360架构的更详尽说明,可参考Xbox半导体技术组的Jeff Andrews和Nick Baker所写的"Xbox 360 System Architecture/Xbox 360系统机构".
7.6.1.2 PlayStation 3
PlayStation 3硬件采用由索尼,东芝和IBM共同开发的Cell Broadband Engine(CBE)架构.PS3 采用了跟Xbox 360彻底不同的架构设计.PS3 不采用3个相同处理器,而是提供不同种类处理器,每种处理各为特定任务而设计.PS3也不采用统一内存架构(unified memory architecture, UMA),而是把内存切割为多个区块,每块为提升系统中特定处理器的效率而设计.
PS3的主CPU称为Power处理部件(Power Processing Unit, PPU).此乃一个PowerPC处理器,和Xbox 360中的分别不大.除此处理器之外,PS3还有6个副处理器,名为协同处理部件(Synergistic Processing Unit, SPU).这些副处理器是基于PowerPC指令集的,但它们经特别设计以提供最大效能
PS3的GPU含专用的256MB显存,而PPU则能存取256MB系统内存.此外,每个SPU含专用高速的256KB内存区,称为SPU的局部存储(local store, LS).局部存储内存如L1缓存那么高效,使SPU运作得极其之快
SPU不能直接读取主内存数据.取而代之,要使用直接内存访问(direct memroy access, DMA)控制器来回复制主内存和SPU局部存储的数据块.这些数据传输是并行执行的,因此PPU和SPU在等待数据到达前仍能进行运算
7.6.2 SIMD
多数现代的CPU(包括Xbox 360中3个PowerPC处理器,PS3的PPU和SPU)都会提供单指令多数据(single instruction multiple data, SIMD)指令集,这类指令集能让一个运算同时执行于多个数据之上,此乃一种细粒度形式的硬件并行.CPU一般提供几类不同的SIMD指令,然而游戏中最常用的是并行操作4个32位浮点数值的指令,因为相比单指令数据(single instruction single data, SISD)指令,这种SIMD指令能使三维矢量和矩阵数学的运算加速至4倍
7.6.3 分叉及汇合
另一种利用多核或多处理器硬件的方法是, 采用并行的分治(divide-and-comquer)算法. 这通常称为分叉/汇合(fork/join)法.其基本原理是把一个单位的工作分割成更小的子单位,再把这些工作量分配到多个核或硬件线程(分叉), 最后待所有工作完成后再合并结果(回合).把分叉/汇合法应用至游戏循环时,其产生的架构看上去和单线程游戏循环很相似,但是更新循环的几个主要阶段都能并行化.
我们再看一个实际例子,若动画混合(animation belnding)使用线性插值(linear interpolation, LERP),其操作可以独立地施于骨骼上所有关节.假设有5个角色,要混合每个角色的一对骨骼姿势(skeletal pose),当中每个骨骼有100个关节(joint),那么总共需要处理500对关节姿势(joint pose)
要把此工作并行化,可以切割工作至N个批次,每批次含约500/N个关节姿势对,而N是按可用的处理器资源来设定的.(在Xbox360上, N应该会是3或6,因为该游戏机有3个核,每个核有两个硬件线程.而在PS3上,N可以是1~6,视乎有多少个SPU可以使用).然后我们"分叉"(即建立)N个线程,让每个线程各自执行分组后的姿势对.主线程可以选择继续工作,做一些和该次动画混合无关的事情;主线程也可选择等待信号量(semaphore)直至所有其他线程完成工作.最后,我们把各个关节姿势结果"汇合"成整体结果----在这例子里,这就是要计算成5个骨骼的最终全局姿势(每个骨骼计算全局姿势时,需要上所有关节的局部姿势,因此,对单个骨骼进行这种计算并不能并行化.然而,我们可以考虑再次分叉计算全局姿势,不过这次每线程负责计算一个或多个完整的骨骼)
7.6.4 每个子系统运行于独立线程
另一个多任务方法是把每个引擎子系统置于独立线程上运行.主控线程(master thread)负责控制即同步这些子系统的次级子系统,并继续应付游戏的大部分高级逻辑(游戏主循环).对于包含多个物理CPU或物理线程的硬件平台来说,此设计能让这些子系统并行执行.此设计适合某些子系统,那些子系统需重复地执行较有隔离性的功能,例如渲染引擎,物理模拟,动画管道,音频引擎等.
多线程架构通常需要由目标硬件平台上的线程库所支持.在运行Windows的个人计算机上,通常会使用Win32的线程API.在基于UNIX的平台上,类似pthread的库可能是最佳选择.在PlayStation3上,有一个名叫SPURS的库,可把工作运行于6个SPU之上.SPURS提供两种在SPU运行代码的基本方法----任务模型(task model)和作业模型(job model).任务模型可用来把引擎子系统分离为粗颗粒度的独立执行单位,运作上与线程相似
7.6.5 作业模型
使用多线程的问题之一就是,每个线程都代表相对较粗粒度的工作量(例如,把所有动画任务都置于一个线程,把所有碰撞和物理任务置于另一线程),这么做会限制系统中多个处理器的利用率.若某个子系统线程未完成其工作,就可能会阻塞主线程和其他线程
为充分利用并行硬件架构,另一种方法是让游戏引擎把工作分割成多个细小,比较独立的作业(job).作业最好理解为,一组数据与操作该组数据的代码结合成对.作业准备就绪后,就可以加入队列中,待至有闲置的处理器,作业才会从队列取出执行.PS3的SPURS库的作业模型就是实现这种方法.使用该模型时,游戏主循环在PPU上执行,而6个SPU则为作业处理器.每个作业的代码和数据通过DMA传送至SPU的局部存储,然后SPU执行作业,并把结果以DMA传回主内存
如图7.8所示,作业较为细粒度且独立,因而有助于最大化处理器的利用率.相比"每个子系统运行于独立线程"的设计,这种方法也可减少或消除对主线程的一些限制.此架构也能自然地对任何数量的处理单元向上扩展(scale up)或向下缩减(scale down)("每个子系统运行于独立线程"的架构就不太能做到)
7.6.6 异步程序设计
为了利用多处理器硬件而编写或更新游戏引擎,程序员必须小心设计异步方式的代码.这里所谓i的异步,指发出操作请求之后,通常不能立刻得到结果.而平时的同步设计,就是程序等待结果之后才继续运行.例如,某游戏可能会通过向世界进行光线投射(ray cast),以得知玩家角色是否能看见敌人.使用同步设计时,提出光线投射请求后便会立即执行,当光线投射函数执行完毕,就会传回结果
while (true) { // 游戏主循环 // ...... // 投射一条光线以判断玩家能否看见敌人 RayCastResult r = castRay(playerPos, enemyPos); // 现在处理结果 if (r.hitSomething() && isEnemy(r.getHitObject()) { // 玩家能看见敌人 // ..... } // ...... }
而使用异步设计,提出光线投射请求时,调用的函数只会建立一个光线投射作业,并把该作业加到队列中,然后该函数就会立即返回.主线程可继续做其他跟该作业无关的工作,同一时间另一个CPU或核就会处理那个作业.之后,当作业完成,主线程就能提取并处理光线投射的结果
while (true) { // 游戏主循环 // ...... // 投射一条光线以判断玩家能否看见敌人 RayCastResult r; requestRayCast(playerPos, enemyPos, &r); // 当等待其他核做光线投射时, 我们做其他无关的工作 // ...... // 好吧,我们不能再做更多有用的事情了,等待光线投射作业的结果 // 若作业完毕, 此函数会立即返回. 否则,主线程会闲置直至有结果 waitForRayCastResults(&r); // 处理结果 if (r.hitSomething() && isEnemy(r.getHitObject())) { // 玩家能看见敌人 // ...... } // ...... }
许多时候,异步代码可以在某帧启动请求,而在下一帧才提取结果.这种情况的代码可能是这样的
RayCastResult r; bool rayJobPendign = false; while (true) { // 游戏主循环 // ...... // 等待上一帧的光线投射结果 if (rayJobPending) { waitForRayCastResults(&r); // 处理结果 if (r.hitSomething() && isEnemy(r.getHitObject())) { // 玩家能看见敌人 // ...... } } // 为下一帧投射一条光线 rayJobPending = true; requestRayCast(playerPos, enemyPos, &r); // 做其他事情 // ...... }
7.7 网络多人游戏循环
7.7.1 主从式模型
在主从式模型(client-server model)中,大部分游戏逻辑运行在单个服务器(server)上.因此服务器的代码和非网络的单人游戏很相似.多个客户端(client)可连接至服务器,以一起参与线上游戏.客户端基本上只是一个"非智能(dumb)"渲染引擎,客户端会读取人体学接口设备数据,以及控制本地的玩家角色,但除此以外,客户端要渲染什么都是由服务器告之.但这么做最痛苦的是,客户端代码需要即时把玩家的输入转换成玩家角色在屏幕上的动作.不然,玩家会觉得他控制的游戏角色反应非常缓慢,非常恼人.除了这些称为玩家预测(player prediction)的代码,客户端通常仅为渲染和音频引擎,加上一些网络代码
服务器可以单独运行于一个机器上,此运行方式称为专属服务模式(dedicated server mode).然而,客户端和服务器不一定要运行于两个独立的机器上,其实客户端机器同时运行服务器也是十分普遍的.实际上,在许多主从式多人游戏中,单人游戏模式其实是退化的多人游戏----当中只有一个客户端,并且把客户端和服务器运行在同一个机器上.这种运行方式又称为客户端于服务器之上模式(client-on-top-of-server mode)
主从多人游戏的游戏循环又多种不同的实现方法.由于客户端和服务器理论上是独立的实体,两者可分别实现为完全独立的行程(process)(即不同的应用程序).另一种实现方式是, 把两者置于同一行程内的两个独立线程.但是,当采用客户端置于服务器之上模式时,以上两个方法都会带来不少本地通信方面的额外开销.因此,许多多人游戏会把客户端和服务器都置于单个线程中,并由单个游戏循环控制
必须注意,客户端和服务器的代码可能以不同频率进行更新.例如,在《雷神之锤》中,服务器以20FPS运行(每帧50ms),而客户端通常以60FPS运行(每帧16.6ms).其实现方式是,把主游戏循环以两帧中较快的频率(60FPS)运行,并让服务器代码大约每3帧才运行一次.真正实现时,会计算上次服务器更新至今的经过时间,若超过50ms,服务器就会运行一帧,然后重置计时器.这种游戏循环大概是以下这样的:
F32 dtReal = 1.0f / 30.0f; // 真实的帧时间增量 F32 dtServer = 0.0f; // 服务器的时间增量 U64 tBegin = readHitResTimer(); while (true) { // 主游戏循环 // 以50ms区间运行服务器 dtServer += dtReal; if (dtServer >= 0.05f) { // 50ms runServerFrame(0.05f); dtServer -= 0.05f; // 重置供下次更新 } // 以最大帧率执行客户端 runClientFrame(dtReal); // 再读取当前时间,估算下帧的时间增量 U64 tEnd = readHiResTimer(); dtReal = (F32)(tEnd - tBegin)/(F32)getHiResTimerFrequency(); // 把tEnd用作下一帧新的tBegin tBegin = tEnd; }
7.7.2 点对点模型
在点对点(peer-to-peer)多人架构中,线上世界中的每部机器都有点像服务器,也有点像客户端.游戏中每个动态对象,都由其对应的单一机器所管辖.因此,每个机器对其拥有管辖权(authority)的对象就如同服务器.对于其他无管辖权的对象,机器就如同是客户端,只负责渲染由对象的远端管辖者所提供的状态
点对点多人游戏循环的结构比主从游戏的简单得多.从最高级的角度来看,点对点多人游戏循环的结构和单人游戏的相似.然而,其内部代码细节可能较难理解.在主从模型中,较能清楚知道哪些代码运行于服务器,哪些运行于客户端.但在点对点架构里,许多代码都要处理两个可能情况: 本地机器拥有某些对象状态的管辖权,或者本地某对象只是有其管辖权远端机器的哑代理(dumb proxy).此两种模式通常实现为两种游戏对象,一种是本机有管辖权的完整"真实"游戏对象,另一种是"代理版本",仅含远程对象状态的最小子集
点对点架构可以设计得更复杂,因为有时候需要把对象得管辖权从某机器转移至另一机器.例如,若其中一部计算机离开游戏,该计算机所有对象的管辖权必须转移至其他参与该游戏的机器.相似地,若有新机器加入游戏,理想地该机器应该接管其他机器的一些游戏对象,以平衡每部机器的工作量.
7.7.3 案例分析: 《雷神之锤II》
以下是《雷神之锤II》游戏循环的节录.《雷神之锤》《雷神之锤II》《雷神之锤III竞技场》的源代码都可以在id Software的网站取得.读者可以看到,本章谈及的元素都会出现在以下的代码节录中,包括Windows消息泵(在游戏的Win32版本中),计算两帧之间的真实时间增量,操作固定时间和时间缩放模式,以及更新服务器端和客户端的引擎系统
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { MSG msg; int time, oldtime, newtime; char *cddir; ParseCommandLine(lpCmdLine); Qcommon_Init(argc, argv); oldtime = Sys_Milliseconds(); /* Windows 主消息循环 */ while (1) { // Windows 消息泵 while (PeekMessage(&msg, NULL, 0, 0, PM_NOREMOVE)) { if (!GetMessage(&msg, NULL, 0, 0)) { Com_Quit(); sys_msg_time = msg.time; TranslateMessage(&msg); DisptachMessage(&msg); } // 以毫秒为单位量度真实的时间增量 do { newtime = Sys_Milliseconds(); time = newtime - oldtime; } while (time < 1); // 执行1帧游戏 Qcommon_Frame(time); oldtime = newtime; } // 永远不会到达这里 return TRUE; } void Qcommon_Frame(int msec) { char *s; int time_before, time_between, time_after; // 这里忽略一些细节...... // 处理固定时间模式及时间缩放 if (fixedtime->value) { msec = fixedtime->value; } else if (timescale->value) { msec *= timescale->value; if (msec < 1) { msec = 1; } } // 处理游戏中的主控台 do { s = Sys_ConsoleInput(); if (s) { Cbuf_AddText(va("%s\n", s)); } while (s); Cbuf_Execute(); // 执行1帧服务器 SV_Frame(msec); // 执行1帧客户都安 CL_Frame(msec); // 这里忽略一些细节...... }
第8章 人体学接口设备(HID)
8.1 各种人体学接口设备
8.2 人体学接口设备的接口技术
8.3 输入类型
8.4 输出类型
8.5 游戏引擎的人体学接口设备系统
8.6 人体学接口设备使用实践
第9章 调试及开发工具
9.1 日志及跟踪
9.2 调试用的绘图功能
9.3 游戏内置菜单
9.4 游戏内置主控台
9.5 调试用摄像机和游戏暂停
9.6 作弊
9.7 屏幕截图及录像
9.8 游戏内置性能剖析
9.9 游戏内置的内存统计和泄露检测
第三部分 图形及动画
第10章 渲染引擎
10.1 采用深度缓冲的三角形光栅化基础
10.2 渲染管道
10.3 高级光照及全局光照
10.4 视觉效果和覆盖层
10.5 延伸阅读
第11章 动画系统
11.1 角色动画的类型
11.2 骨骼
11.3 姿势
11.4 动画片段
11.5 蒙皮及生成矩阵调色版
11.6 动画混合
11.7 后期处理
11.8 压缩技术
11.9 动画系统架构
11.10 动画管道
11.11 动作状态机
11.12 动画控制器
第12章 碰撞及刚体动力学
12.1 你想在游戏中加入物理吗
12.2 碰撞/物理中间件
12.3 碰撞检测系统
12.4 刚体动力学
12.5 整合物理引擎至游戏
12.6 展望: 高级物理功能
第四部分 游戏性
第13章 游戏性系统简介
游戏的本质,并非在于其使用的技术,乃是其游戏性(gameplay).所谓游戏性,可定义为玩游戏的整体体验.游戏机制(game mechanics)一词,把游戏性这个概念变得更为具体.游戏机制通常定义为一些规则,这些规则主宰了游戏中多个实体之间的互动.游戏机制也定义了玩家(们)的目标(objective),成败的准则(criteria),玩家角色的各种能力(ability),游戏虚拟世界中非玩家实体(non-player entity)的数量及类型,以及游戏体验的整体流程(overall flow).在许多游戏中,扣人心弦的故事和丰富的角色,与这些游戏机制元素交织在一起.然而,并非所有游戏都必须有故事及角色,从极为成功的解谜游戏如《俄罗斯方块(Tetris)》可见一斑.谢菲尔德大学(University of Sheffield)的Ahmed BinSubaih, Steve Maddock及Daniela Romano曾发表一篇论文, 题目为《"游戏"可移植性研究(A Survey of "Game" Portability)》,文中把实现游戏性的软件系统集合称为游戏的G因子(G-factor).
13.1 剖析游戏世界
13.1.1 世界元素
多数电子游戏都会在二维或三维虚拟游戏世界(game world)中进行.这些世界通常是由多个离散的元素所构成的.一般来说,这些元素可分为两类----静态元素和动态元素.静态元素包括地形,建筑物,道路,桥梁,以及几乎任何不会动或不会主动与游戏性互动的物体.而动态元素则包括角色,车辆,武器,补血包,能力提升包,可收集物品,粒子发射器,动态光源,用来检测游戏中重要事件的隐形区域,定义物体移动路径的曲线样条等.
动态和静态元素之间,在各游戏中有所不同.多数三维游戏只有相对少量的动态元素,这些元素在相对广大的静态背景范围中移动.另一些游戏,如经典的街机游戏《爆破彗星(Asteroids)》或Xbox360上的复古热作《几何战争(Geometry Wars)》,就完全没有静态元素可言(除了空白的屏幕).通常,游戏的动态元素比静态元素更耗CPU资源,因此多数三维游戏被迫使用有限的动态元素.然而,动态元素的比例越高,玩家感受到的世界越"生动".随着游戏硬件性能的进步,游戏的动态元素比例也在不断提升
有一点要留意,游戏世界的动态及静态元素时常并非黑白分明.例如,在街机游戏《迅雷赛艇(Hydro Thunder)》中,瀑布的纹理有动画效果,其底下有薄雾效果,而且游戏设计师可以独立于地形及水体外随意放置这些瀑布,在这个意义上这些瀑布是动态的,然而,从工程的角度看,瀑布是以静态元素方式处理的,因为它们并不会以任何形式与赛艇互动(除了会阻碍玩家看到加速包及秘密通道).各游戏引擎会以不同基准区分静态和动态元素,有些引擎甚至不做区分(即所有东西都可能成为动态元素)
分开静态与动态元素的目的,主要是做优化之用----若物体的状态不变,我们就可以减少对它的处理.例如,静态三角形网格的顶点可使用世界空间坐标,借以省去对每顶点的矩阵乘法,而正常渲染时是需要用矩阵乘法把模型空间变换为世界空间的.光照也可以预计算,其结果i可存于顶点,光照贴图,阴影贴图,静态环境遮挡(ambient occlusion)信息,或预计算辐射传输(precomputed radiance transfer, PRT)的球谐系数(spherical harmonics coefficient).在运行时游戏世界中动态元素所需的运算,对于静态元素来说,都可以预先计算或忽略
有一些游戏含有可破坏环境,这算是模糊静态和动态元素之分界的例子.例如,我们可能给予每个静态元素3个版本,完好的,受损的,完全被破坏的.这些背景元素在大部分时间中是静态的,但在爆炸中可能被替换至不同版本,以产生其受到破坏的视觉效果.实际上,静态和动态世界元素只是许多可能性的两个极端.我们为两者定分界(如果真的这么做),只是用作改变优化方法即跟随游戏设计所需
13.1.1.1 静态几何体
静态世界元素通常在Maya之类的工具中制作.这些元素可能是一个巨形的三角形网格,或是拆分为多个细块.场景中的静态部分有时候会采用实例化几何体(instanced geometry)制作.实例化是一个节省内存的技术,当中,较少数目的三角形网格会在不同位置及定向被渲染多次,以产生一个丰富的游戏场景.例如,三维建模师可能制作了5款矮墙,然后以随机方式把它们拼砌成数里长,独一无异的城墙
静态视觉元素及碰撞数据也可以用笔刷j几何图形(brush geometry)方式构建.这种几何体源自于雷神之锤(Quake)系列引擎.所谓笔刷,是指多个凸体积组成的形状,每个凸体积由一组平面所包围.建构笔刷几何图形是容易快捷的,而且这种几何体能很好地整合至基于BSP树的渲染引擎.笔刷非常适合于快速堆砌游戏内容的初形.由于这么做成本不高,可以在初始阶段就测试游戏性.如果证实了关卡的布局恰当,美术团队便可以加入纹理及微调那些笔刷几何图形,或是用更细致的网格资源取代它们.相反,若关卡需要重新设计,那些笔刷几何图形可以简单地修改,而无须美术团队大量重做资源
13.1.2 世界组块
当游戏在非常巨大的虚拟世界中进行,这些世界通常会被拆分成为独立可玩的区域,我们称之为世界组块(world chunk).有时候组块也成为关卡(level), 地图(map),舞台(stage)或地区(area). 玩家在进行游戏时,通常同时只能见到一个,或最多几个组块.随着游戏的发展,玩家从一个组块进入另一个组块
起初,发明"关卡"的概念是为了在内存有限的游戏硬件上提供更多游戏性的变化.同时间只会有一个关卡存于内存,但随着玩家从一个关卡到达另一个关卡,可以获得更丰富的整体体验.从那时候开始,游戏设计形成多个分支,到现在这种基于线性关卡的游戏少了很多.有些游戏实质上仍然是线性的,但对玩家来说,世界组块之间已没像以前那般地明显分界.另一些游戏使用星状拓扑(star topology),其中玩家在一个中央枢纽地区,并可以在那里选择前往其他的地区(可能需要先为那些地区解锁).还有一些游戏使用图状拓扑,即地区之间以随意方式连接.也有y一些游戏会提供一个貌似广大,开放的世界
无论现代游戏设计如何丰富,除了最小型的游戏世界,多数游戏世界都仍然会分割为某形式的组块.这么做有几个原因.首先,内存限制仍然是一个重要的约束(直至有无限内存的游戏机充斥市面).世界组块也是一个控制游戏整体流程的方便机制.组块作为一个分工的单位,每个组块可以由较小的欧系设计师即美术团队分别建构及管理.
13.1.3 高级游戏流程
游戏的高级流程(high-level flow)是指由玩家目标(objective)所组成的序列,树或图.目标有时候也称作任务(task),舞台(stage)或关卡(level)(此术语和世界组块相同),又或是波(wave)(若游戏的主要目标是击败一波接一波敌人).高级流程也会定义每个目标的胜利条件(如肃清所有敌人并取得钥匙),以及失败的惩罚(如回到当前地区的起点,当中可能会扣减一条"生命").在故事驱动的游戏中,流程可能也包含多个游戏内置电影,使玩家得知故事的进展,这些连续镜头段有时候称为过场动画(cut-scene),游戏内置电影(in-game cinematics,IGC)或非交互连续镜头(noninteractive sequence, NIS).若这些镜头是在脱机时渲染的,然后以全屏电影方式播放,则会称之为全动视频(full-motion video, FMV)
早期游戏中,玩家的目标会一一对应至某个世界组块(也因此"关卡”一词具有双重含义).例如,在《大金刚(Donkey Kong)》中,每个关卡给与马里奥一个新的目标(即走到天台达至下一关).然而,这种目标和组块一一对应的关系在现代游戏设计中已式微.每个目标可能与一个或多个世界组块有所关联,但目标和组块的耦合会被刻意减弱.这种设计提供弹性,可以独立地改动游戏的目标j及世界组块,这样从游戏开发的后勤及实践角度上来说都是极为有用的.许多游戏把目标归类为更初粗略的游戏性段落,例如称之为章(chapter)或幕(act)
13.2 实现动态元素: 游戏对象
游戏的动态元素通常会以面向对象方式设计.此方式不但直观自然,而且能很好地对应至游戏设计师建构世界的概念.游戏设计师能想象出游戏中的角色,载具,悬浮血包,爆炸木桶,以及无数的动态对象在游戏世界中移动.因此,很自然会想到在游戏世界编辑器中创建及处理这些元素.相似地,程序员通常也会觉得,把动态元素实现为运行时的自动代理人是十分自然的事情.本数书会使用游戏对象(game object, GO)这一术语,去描述游戏世界中几乎任何的动态元素.然而,此术语在业界并非标准,有时候也称作实体(entity),演员(actor)或代理人(agent)等
如面向对象的习惯,游戏对象本质上是属性(attribute,对象当前的状态)及行为(behavior,状态如何应对事件,随事件变化)的集合.游戏对象通常以类型(type)做分类.不同类型的对象有不同的属性及行为.某类型的所有实例(instance)都共享相同的属性及行为,但每个实例的属性的值(value)可以不相同(注意,若游戏对象的行为是数据驱动的,例如,用脚本代码,或由一组数据驱动的规则回应事件,那么行为也可以按实例有所差异)
类型和实例的分别是十分关键的.例如,《吃豆人(Pac-Man)》中有4个游戏对象类型: 鬼魂,豆子,大力丸和吃豆人.然而,在某时刻,只会最多有4个鬼魂实例,50~100个豆子实例,4个大力丸实例和1个吃豆人的实例
13.2.1 游戏对象模型
在计算机科学中,对象模型(object model)一词有两个相关但不一样的意思.它可以是指某编程语言或形式设计语言所提供的特性集.例如,我们可以说C++对象模型或OMT对象模型.对象模型的另一个意思是指,某面向对象编程接口(如类和方法的集合,以及为解决特定问题所设计的相互关系).这个意义的一个例子是微软Excel对象模型,此模型供外在程序以多种方式控制Excel
本书中,游戏对象模型(game object model)一词专指由游戏引擎所提供的,为虚拟世界中动态实体建模及模拟的设施.按此意义,游戏对象模型含有前面所及的两方面定义
游戏的对象模型是一种特定的面向对象编程接口,用于解决开发某个游戏中一些具体实体的个别模拟问题
此外,游戏的对象模型常会扩展编写引擎本身的编程语言.若游戏是以非面向对象语言(如C)实现的,程序员可自行加入面向对象的设施.即使游戏是以面向对象语言(如C++)实现的,通常也会加入一些高级功能,例如反射(reflection),持久性(persistence)及网络复制(network replication)等.游戏对象模型有时候会融合多个语言的功能.例如,某游戏引擎可能会合并编译式语言(如C/C++)和脚本语言(如Python, Lua或Pawn)来使用,并提供统一的对象模型供这两类语言访问
13.2.2 工具方的设计和运行时的设计
以世界编辑器(以下详述)呈现给设计师的对象模型,不必和用于实现运行时游戏的对象模型相同
- 工具方的游戏对象模型,当要实现为运行时的模型时,可以使用无原生面向对象功能的语言(如C)
- 工具方的某单个游戏对象,在运行时可能被实现为一组类(而非预期的一个类)
- 每个工具方的游戏对象,在运行时可能仅是唯一标识符,其全部状态则储存至多个表或一组松耦合的对象
因此,一个游戏实在是有两个虽不同但密切相关的对象模型
- 工具方对象模型(tool-side object model)是一组设计师在世界编辑器里看到的游戏对象类型
- 运行时对象模型(runtime object model)是程序员用任何语言构成成分或软件系统把工具方对象模型实现于运行时的对象模型.运行时对象模型可能和工具方模型相同,或有直接映射,又或是完全不同的实现
有些游戏引擎对两种模型并没有很清晰的分界,甚至没有分别,其他游戏引擎则会清楚地划定分界.在一些引擎中,工具和运行时会共享游戏对象模型的实现.其他引擎中,运行时的游戏对象模型看上去完全和工具方的实现相异,有些模型的实现会偏重于工具方,游戏设计师需要知悉他们所设计的游戏性规则和对象行为对性能和内存消耗的影响.然而,几乎所有游戏引擎都会有某形式的工具方对象模型及对应的运行时实现
13.3 数据驱动游戏引擎
在游戏开发的早期年代,游戏的大部分内容都是由程序员硬编码而成的,就算有工具,也都是非常简陋的.这样之所以行得通,是因为当时典型的游戏只有少量内容,而且当时游戏的标准并不高,部分能归咎于早期游戏硬件对图形及声音性能的限制
今天,游戏的复杂性以数量级增长,而且品质要求很高,甚至经常要和好莱坞大片的计算机特效比较,游戏团队也变大许多,但游戏内容量比团队增长得更快.把这一代游戏机(Wii, Xbox 360, PS3)的游戏对比上一代,游戏团队需要产出约10倍的内容,但团队最多只增加了25%.此趋势意味着,团队必须以极高效的方式生产非常大量的内容
工程方面的人力资源通常是制作的瓶颈,因为优秀的工程师非常有限的昂贵,而且工程师产出内容的速度通常比美术设计师及游戏设计师慢(源于计算机编程的复杂性).现在多数团队相信,应该尽量把生产内容的权力交予负责该内容的制作者之手----即美术设计师和游戏设计师.当游戏的行为可以全部或部分由美术设计师及游戏设计师所提供的数据所控制,而不是由程序员所编写的软件完全控制,该引擎就称为是数据驱动(data-driven)的.
通过发挥所有员工的全部潜能,并为工程团队工作降温,数据驱动架构因而能改善团队的影响.数据驱动也可以促进迭代次数.当开发者想要微调游戏的内容或完全重制整个关卡时,数据驱动的设计能令开发者迅速看到改动的效果,理想的情况下无须或仅需工程师的少量帮助.这样能节省宝贵的时间,并促使团队把游戏打磨至最高品质
然而必须注意到,数据驱动通常有较大的代价.我们必须为游戏设计师及美术设计师提供工具,以使用数据驱动的方式制作游戏内容.也必须更改运行时代码,以健壮地处理更大的输入范围.在游戏内也要提供工具,让美术设计师及游戏设计师预览工作成果及解决问题.这些软件都需要花大量时间及精力去编写,测试及维护
可惜,许多团队匆忙地采用数据驱动架构,而没有静心下来研究这项工作对他们的游戏设计,甚至团队成员个别需求的影响.这种急进的方式,使他们有时候会走得太过火,制作出过于复杂的工具及引擎系统,这些软件可能难以使用,臭虫满载,并且几乎无法适应项目的需求变动.讽刺的是,为了实现数据驱动设计的好处,团队很容易变得比老式硬编码方式生产力更低
每个游戏引擎都应该有些数据驱动的部件,但是游戏团队必须非常谨慎地选择把哪些引擎部分设为数据驱动的.我们需要衡量制作数据驱动或迅速迭代功能的版本,对比该功能预期可以节省团队在整个项目过程的时间.在设计及实现数据驱动的工具和引擎时,要牢记KISS咒语("Keep it simple, stupid").改述爱因斯坦名言: 游戏引擎中的一切应尽量简单,至不能再简化为止.
13.4 游戏世界编辑器
我们曾讨论过数据驱动的资产创作工具,例如Maya,Photoshop,Havok内容工具等.这些工具产生的资产(asset),会供渲染引擎,动画系统,音频系统,物理系统等使用.对游戏性内容来说,对应的工具便是游戏世界编辑器(game world editor),这些编辑器用于定义世界组块,并填入静态及动态元素
所有商用游戏引擎都有某种形式的世界编辑工具.当中闻名于世的有Radiant,它是用来制作雷神之锤和毁灭展战士引擎系列的地图,见图3.14.Valve公司的Source引擎(即《半条命2(Half Life 2)》,《橙盒(The Orange Box)》,《军团要塞2(Team Fortress 2)》所使用的引擎)也提供了一个名为Hammer的编辑器(曾命名作Worldcraft和The Forge),见图13.5
游戏世界编辑器通常可以设置游戏对象的初始状态(即其属性值).多数游戏世界编辑器也会以某种形式,让用户控制游戏世界中动态对象的行为.控制行为的方式k可以是通过修改数据驱动的组态参数(例如,对象A最初应是隐形状态,对象B在诞生后应立即攻击玩家,对象C是可燃的),又或是使用脚本语言,从而让游戏设计师的工作进入编程境界,有些世界编辑器甚至能定义全新的游戏对象类型,过程无须或只需少许程序员介入
13.4.1 游戏世界编辑器的典型功能
各个游戏世界编辑器的设计及布局又很大差异,但大部分都会提供一组相当标准的功能集.这些功能包括但不限于以下之列
13.4.1.1 世界组块创建及管理
世界创建的单位通常是组块(chunk, 或称为关卡/level或地图/map).游戏世界编辑器通常可以创建多个新的组块,以及把现有组块更名,分割,合并及删除.每个组合可以连接至一个或多个静态网格,以及其他静态数据元素,例如人工智能用的导航地图,玩家可攀抓边缘信息,掩护点等.有些引擎的组块必须以一个背景网格来定义,不能缺少.而另一些引擎则可以独立存在,或许是用一个包围体(如AABB,OBB或任意多边形区域)来定义,并可填入零至多个网格及/或笔刷几何
有些世界编辑器提供专门的工具制作地形,水体,以及其他专门的静态元素.在另一些引擎中,这些元素可能都是用标准的DCC应用程序l来制作的,但会以某种方式加入标签,以对资产调节管道及/或运行时引擎说明它们是特别的元素(例如,在《神秘海域:德雷克船长的宝藏》中,水体是以普通三角形网格方式制作的,但会贴上特殊的材质,以说明它们应以水体方式处理)有时候,我们会使用另一独立工具来创建及编辑特殊的世界元素.例如, 《荣誉勋章:血战太平洋》的高度场地形,其制作工具便是来自艺电另一团队的自定义化版本.由于项目当时使用了Radiant引擎,比起在Radiant中集成一个地形编辑器,这么做更为合适
13.4.1.2 可视化游戏世界
世界编辑器把游戏世界的内容可视化(visualize),对用户来说是很重要的功能.因此,几乎所有游戏编辑器都提供世界的三维透视视角,及/或二位的正射视角.很常见的方式是把视图面板分割为4部分,3个用作上,侧,前方的正射正视图(orthographic elevation),另一个用作三维透视视图
有些编辑器直接整合自制的渲染引擎至工具中,去提供这些世界视图.另一些编辑器则是把自身整合至三维软件,如Maya或3ds Max, 因而可以简单地利用这些工具的视区.也有些编辑器的设计,会通过与实际的有些引擎通信,利用游戏引擎来渲染三维视图.更甚者,有些引擎会整合至引擎本身
13.4.1.3 导航
若用户不能在世界编辑器的世界中到处移动,这个编辑器显然无所用.在正射视图中,必须能够滚动及缩小放大.而三维视图则可使用数个摄像机控制方式.例如可以聚焦某个对象,然后绕它旋转.也可以切换至"飞行"模式,当中,摄像机以自身的焦点旋转,并可向前后上下左右移动
有些编辑器提供许多方便导航的功能,包括y用单个按键就可以选取及聚焦对象,存储多个相关的摄像机位置,在那些位置中跳转,多个摄像机移动速率模式,如网页浏览器的导航历史记录般在游戏世界中跳转等
13.4.1.4 选取
游戏世界编辑器的主要设计目的是,供用户利用静态及动态元素填充游戏世界.因此让用户选择个别元素来编辑,是很重要的功能.有些引擎只容许同时间选取一个对象,而更先进的编辑器则可以多选.用户可以使用方形橡皮筋在正射视图中选取对象,或在三维视图中用光线投射方式进行选取.多数编辑器也会以滚动表或树视图展示世界中的元素列表.有些编辑器也可以把选取集命名及存储,供以后取回使用
游戏世界通常填充了很密集的内容,因而有时候可能难以选取心中的对象.此问题有几个解决方法.当使用光线投射方式选取三维中的对象时,编辑器可让用户循环选取与光线相交的所有对象,而不是总选取最近者.许多编辑器可以在视图中暂时隐藏当前所选的对象.那么,若用户选不到所需的对象,可以先把选取的对象隐藏再试.
13.4.1.5 图层
在一些编辑器中,可以把对象用预设或用户自定义的图层来分组.此功能非常有用,此功能非常有用,能把游戏世界中的内容有条理地组织起来.可以把整个图层隐藏或显示整理凌乱的屏幕内容,也可以把图层设置色彩,令图层内容更易识别.图层也是分工的重要工具,例如,负责灯光的同事在某个世界组块上工作时,他们可以隐藏所有和灯光无关的元素
更重要的是,若编辑器能独立地载入及储存图层,就能避免多人在同一世界组块上工作所产生的冲突.例如,所有光源可能储存在一个图层里,背景几何体在另一图层,所有AI角色又至于另外一图层.由于每个图层完全独立,灯光,背景及NPC小组k可以同时在同一世界组块上工作
13.4.1.6 属性网格
填充游戏世界组块的静态和动态元素,通常会有多个能让用户编辑的属性(property,也称作attribute).属性可以是简单的键值对,并仅限使用简单的原子数据类型,如布尔,整数,浮点数及字符串.有些编辑器支持更复杂的属性,包括数组,嵌套的符合数据结构
13.4.1.7 安放对象及对齐辅助工具
世界编辑器对一些对象属性会采取不同的处理方式.对象的位置,定向及缩放通常如同在Maya和Max中,可利用正射或透视视图中的特殊锚点(handle)操控.此外,资产的i连接通常需要用特殊方式处理.例如,若我们修改了世界中某对象所使用到的网格,编辑器应该在正射及三维透视视区中显示该网格,因此,游戏世界编辑器必须知悉这些属性需要特殊处理,而不能把它们当作其他属性般统一处理
许多世界编辑器会除了提供基本的平移,旋转,缩放工具外,还会提供一篮子的对象安放及对齐辅助工具.这些功能中,大部分都借鉴自商用图形及三维建模工具,如Photoshop,Maya,Visio等.这些功能的例子有对齐(snap)至网格,对齐至地形,对齐至对象等
13.4.1.8 特殊对象类型
如同世界编辑器对于一些属性需要特殊处理,某些对象类型也需要特殊处理.例如:
光源(light source): 世界编辑器通常使用特殊的图标来表示光源,因为它们本身并无网格,编辑器可能会尝试显示光源对场景中几何体的近似效果,令设计师可以实时移动光源并能看到场景最终效果的大概
粒子发射器(particle emitter): 如果编辑器是建立在独立的渲染引擎之上的,那么在编辑器中可视化粒子的发射器也可能会遇到问题.在此情况下,粒子发射器可简单地用图标显示,或是在编辑器中尝试模拟粒子效果.当然,若编辑器是置于游戏内的,或是能与运行中的游戏通信的,这便不是问题
区域(region): 区域是空间中的体积,供游戏侦测相关事件,诸如对象进入或离开体积,或是就某些目的做分区.有些游戏引擎限制了区域,只能为球体或定向盒,而另一些引擎可能支持一些形状,其俯瞰图是任意的凸多边形,而其边必须是水平的.还有另一些引擎支持用更复杂的形状构建区域,例如k-DOP.若区域总是球形的,设计师可能只需要在属性网格中修改"半径"属性,但要定义或修改任意形状的范围,就几乎必须要有特设的编辑工具了
样条(spline): 样条是由控制点集所定义的立体曲线,在某些数学曲线中,还会加入控制点上的切线来定义样条.Catmull-Rom是常用样条之一,因为它只需一组顶点来定义(无须切线),而且样条会经过所有控制点.但无论支持哪一种样条类型,类型编辑器通常都需要在视区中显示样条,以及该用户选取及操控个别控制点.有些世界编码实际上还支持两种选取模式----"粗略"模式用于选取场景中的对象,以及"细致"模式用于选择已选对象的个别组件,例如样条的控制点或区域的顶点
13.4.1.9 读/写世界组块
当然,无法读/写世界组块的世界编辑器并不完整,不同的引擎对于世界组块的读/写粒度,差异很大.有些引擎把每个组块储存为单个文件,而另一些引擎则可以独立读/写个别的图层.数据格式也有很多选择.有些引擎使用自定义二进制文件格式,有些则使用如XML的文本格式.每个设计都有其优缺点,但所有编辑器都必须提供某形式的世界组块读/写功能,而每个游戏引擎都能够读取世界组块,从而能在运行时于这些组块中进行游戏
13.4.1.10 快速迭代
优秀的游戏世界编辑器通常都会支持某程度的动态微调功能,供快速迭代(rapid iteration)之用.有些编辑器在游戏本身内执行,让用户即时看到改动的效果.另一些编辑器能连接至运行中的游戏.也有一些世界编辑器完全在脱机状态下运行,它可能是一个独立的工具,或是某DCC工具(如LightWave或Maya)的插件.这些工具有时可以令运行中的游戏动态更新被修改的数据.具体的机制并不重要,最重要的是给用户足够短的往返迭代时间(round-trip iteration time, 即修改游戏世界,与该改动在游戏中显示效果之间的时间).迭代并非必须是即时见到结果的.迭代时间应与改动的范围及频率相符.例如,我们或许会期望调整角色的最大血量是一个非常快的操作,但当改动影响整个世界组块光照环境时,就可忍受更长的迭代时间
13.4.2 集成的资产管理工具
13.4.2.1 数据处理成本
第14章 运行时游戏性基础系统
14.1 游戏性基础系统的组件
多数游戏引擎都会带有一套运行时软件组件,它们合作提供一套框架实现游戏独特的规则,目标,动态世界元素.游戏业界对这些组件并无标准命名.但我们把它们总称为引擎的游戏性基础系统(gameplay foundation system).如果我们可以合理地画出游戏与游戏引擎的分界线,那么游戏性基础系统就是刚刚位于该线之下.理论上,我们可以建立一个游戏性基础系统,其大部分是各个游戏皆通用的.然而,实践中这些系统几乎总是包含一些跟游戏类型或具体游戏相关的细节.事实上,引擎和游戏之间的分界,或应视为一大片的模糊区域----这些组件构成的网络一点一点把游戏和引擎连接在一起.有一些游戏引擎更会把游戏性基础系统完全置于引擎/游戏分界线之上.游戏引擎之间的重要差异,莫过于其游戏性组件设计与实现的差别.然而,不同引擎之间也有出奇多的共有模式,而这些共有部分正是本章的主要讨论题目
每个引擎的游戏性软件设计方法都有点不同.然而,多数引擎都会以某种形式提供这些主要的子系统
运行时游戏对象模型(runtime game object model): 抽象游戏对象模型的实现,供游戏设计师在世界编辑器中使用
关卡管理及串流(level management and streaming): 此系统负责载入及释放下游戏性用到的虚拟世界内容.许多引擎会在游戏进行时,把关卡数据串流至内存中,从而产生一个巨大无缝世界的感觉(但实际上关卡被分拆成多个小块)
更新实时对象模型(real-time object model updating): 为了令世界中的游戏对象能有自主(autonomous)的行为,必须定期更新每个对象,这里就是令游戏引擎中所有浑然不同的系统真正合而为一的地方
消息及事件处理(messaging and event handling): 大多数游戏对象需与其他对象通信.对象间的消息(message)许多时候是用来发出世界状态改变的信号的.此时就会称这种消息为事件(event).因此,许多工作室会把消息系统称为事件系统
脚本(scripting): 使用C/C++等语言来编写高级的游戏逻辑,或会过于累赘.为了提高生产力,提倡快速迭代,以及把团队中更多工作放到非程序员之手,游戏引擎通常会整合一个脚本语言.这些语言可能是基于文本的,如Python或Lua,也可以是图形语言,如虚幻的Kismet
目标及游戏流程管理(objectives and game flow management): 此子系统管理玩家的目标及游戏的整体流程.这些目标及流程通常是以玩家目标构成的序列(sequence),树(tree),或图(graph)所定义的.目标又常会以章(chapter)的方式分组,尤其是一些主要以故事驱动的游戏,许多现代的游戏都是这般.游戏流程管理系统负责管理游戏的整体流程,追踪玩家对目标的完成程度,并且在目标未完成之前阻挡玩家进入另一游戏世界区域.有些设计师称这些为游戏的"脊柱(spine)"
在这些主要系统之中,运行时对象模型可能是最复杂的.通常它要提供以下大部分(或是全部)功能
动态地产生(spawn)及消灭(destroy)游戏对象: 游戏世界中的动态元素经常需要随游戏性创建及消去.拾起补血包后便会消失: 爆炸发生后就会灰飞烟灭; 当你以为肃清了整个关卡后,敌方增援从某个角落神不知鬼不觉地出现.许多游戏引擎会提供一个系统,为动态产生的游戏对象管理内存及相关资源.另一些引擎简单地完全禁止动态地创建,销毁游戏对象
联系底层引擎系统: 每个游戏对象都会联系至一个或多个下层的引擎系统.多数游戏对象在视觉上以可渲染的三角形网格表示,有些游戏对象有粒子效果,有些有声音,有些有动画.多数游戏对象有碰撞信息,有些需要物理引擎做动力学模拟.游戏基础系统的重要功能之一就是,确保每个游戏对象能访问它们所需的引擎系统服务
实时模拟对象行为: 游戏引擎的核心,仍基于代理人模型的实时动态计算机模拟.这句话只不过是花哨地说出,游戏引擎需要随时间更动态地更新所有游戏对象的状态.对象可能需要以某特定次序进行更新.此次序部分由对象间的依赖性所支配,部分基于它们对多个引擎子系统的依赖性,也有部分基于那些子系统本身的相互依赖性
定义新游戏对象模型: 游戏在开发过程中,伴随着每个游戏需求的改变及演进.游戏对象模型必须有足够的弹性,可以容易地加入新的对象类型,并在世界编辑器中显示这些新对象类型.理想地,新的游戏类型应可以完全用数据驱动方式定义.然而,在许多引擎中,新增游戏类型需要程序员的参与
唯一的对象标识符(unique object id): 典型的游戏世界包含数百上千的不同类型游戏对象.在运行时,必须能够识别或找到想要的对象.这意味着,每个对象需要有某种唯一标识符.人类可读的名称是最方便的标识符类型,但我们必须警惕在运行时使用字符串所带来的性能成本.整数标识符是最高性能之选,但对人类游戏开发者来说最难使用.也许使用字符串散列标识符(hashed string id)作为对象标识符是最好的方案,因为它们的性能如整数标识符,但又能转化为字符串,容易供人类辨识
游戏对象查询(query): 游戏性基础系统必须提供一些方法去搜寻游戏世界中的对象.我们可能希望以唯一标识符取得某个对象,或是取得某类型的所有对象,或是基于随意的条件做高级查询(例如寻找玩家角色20m以内的所有敌人)
游戏对象引用(reference): 当找到了所需的对象,我们需要以某种机制保留其引用,或许只是在单个函数内做短期保留,也有可能需要保留更长的时间.对象引用可能简单到只是一个C++类实例指针,也可能使用更高级的机制,例如句柄或带引用计数的智能指针
有限状态机(finite state machine, FSM)的支持: 许多游戏对象类型的最佳建模方式是使用有限状态机.有些游戏引擎可以令游戏对象处于多个状态之一,而每个状态下有其属性及行为特性
网络复制(network replication): 在网络多人游戏中,多个游戏机器通过局域网或互联网连接在一起.某个对象的状态通常是由其中一台机器所拥有及管理的.然而,对象的状态也必须复制(通信)至其他参与该多人游戏的机器,使所有玩家能见到一致的对象
存档及载入游戏,对象持久性(object persistence): 许多游戏引擎能把世界中游戏对象的当前状态储存至磁盘,供以后读入,引擎可以实现"任何地方存档"的游戏存档系统,或实现网络复制的方式对象持久性通常需要一些编程语言的功能,例如,运行时类型识别(runtime type identification, RTTI),反射(reflection),以及抽象构造(abstract construction).RTTI及反射令软件在运行时能动态地判断对象的类型,以及类里有哪些属性及方法.抽象构造可以在不硬编码类的名称的同时,创建该类的实例.此功能在把对象从磁盘序列化一个对象至内存时是什么有用.若你所选用的语言没有RTTI,反射或抽象构造的原生支持,可以手工加入这些功能
14.2 各种运行时对象模型架构
游戏设计师使用世界编辑器时,会面对一个抽象的游戏对象模型.该模型定义了游戏世界中能出现的多种动态元素,指定它们的行为是怎样的,它们有哪些属性.在运行时,游戏性基础系统必须提供这些对象模型的具体实现.此模型是任何游戏性基础系统中最巨大的组件
运行时对象模型的实现,可能与工具方的抽象对象模型相似,也可能不相似.例如,运行时对象模型可能完全不是用面向对象编程语言来实现的,它也可能是用一组互相连接的实例表示的单个抽象游戏对象.无论设计是怎样的,运行时对象模型必须忠实地复制出世界编辑器所展示的对象类型,属性及行为
相对设计师所见的工具方抽象对象模型,运行时对象模型是其游戏中的表现.运行时对象模型有不同设计,但多数游戏引擎会采用以下两种基本架构风格之一
以对象为中心(object-centric): 此风格中,每个工具方游戏对象,在运行时是以单个类实例或数个相连的实例所表示.每个对象含一组属性及行为,这些都会封装在那些对象实例的类(或多个类)之中.游戏世界只不过是游戏对象的集合
以属性为中心(property-centric): 此风格中,每个工具方游戏对象仅以唯一标识符表示(可实现为整数,字符串散列标识符或字符串).每个对象的属性分布于多个数据表,每种属性类型对应一个表,这些属性以对象标识符为键(而非集中在单个类实例或相连的实例集合).属性本身通常是实现为硬编码的类之实例.而游戏对象的行为,则是隐含地由它组成的属性集合所定义的.例如,若某对象含"血量"属性,该对象就能被攻击,扣血,并最终死亡.若对象含"网格实例"属性,那么它就能在三维中渲染为三角形网格的实例
以上两个架构风格都有其独特的优缺点.我们将逐一探究它们的一些细节,当在某方面其中一个风格可能极优于另一风格时,我们会特别指明
14.2.1 以对象为中心的各种架构
在以对象为中心的游戏世界对象架构中,每个逻辑游戏对象会实现为类的实例,或一组互相连接的实例.在此广阔的定义下,可做出多种不同的设计.以下我们介绍几种最常见的设计
14.2.1.1 一个简单以C实现的基于对象的模型: 《迅雷赛艇》
游戏对象模型并不一定要使用如C++等面向对象语言来实现.例如,圣迭戈Midway公司的街机游戏《迅雷赛艇》就是完全用C写成的.《迅》采用了一个非常简单的游戏对象模型,当中只含几个对象类型
- 赛艇(玩家及人工智能所控制的)
- 漂浮的红,蓝加速图标
- 背景具动画的物体(如赛道旁的动物)
- 水面
- 斜台
- 瀑布
- 粒子效果
- 赛道板块(多个二维多边区域连接在一起,用于定义赛艇能跑的水域)
- 静态几何(地形,植皮,赛道旁的建筑物等)
- 二维平视头显示器(HUD)元素
《迅》中有一个名为World_t的C struct,用于储存及管理游戏世界的内容(即一个赛道).世界内包含各种游戏对象的指针.当中,静态几何仅仅是单个网格实例.而水面,瀑布,粒子效果各有自己的数据结构.赛艇,加速图标即游戏中其他动态对象则表示为WorldOb_t(即世界对象)这个通用struct的实例.《讯》中的这种对象就是本章所定义的游戏对象的例子
WorldOb_t数据结构内的数据成员包括对象的位置和定向,用于渲染该对象的三维网格,一组碰撞球体,简单的动画状态信息(《迅》只支持刚体层次动画),物理属性(速度,质量,浮力),以及其他动态对象都会拥有的数据.此外,每个WorldOb_t还含有3个指针: 一个void*"用户数据(user data)"指针,一个指向"update"函数的指针及一个"draw"函数的指针.因此,虽然《迅》并不是严格意义上的面向对象,但《迅》的引擎实质上扩展了非面向对象语言(C),基本地实践两个重要的OOP特征: 继承(inheritance)和多态(polymorphism),也同时能令所有世界对象继承一些共有的功能.例如"Banshee"赛艇的加速机制不同于"Rad Hazard",并且每种加速机制需要不同的状态信息区管理其起动及结束动画.这两个函数指针的用途如同虚函数,使世界对象有多态的行为(通过"update"函数),以及多态的视觉外观(通过"draw"函数)
struct WorldOb_s { Oreint_t m_transform; // 位置/定向 Mesh3d* m_pMesh; // 三维网格 /* ... */ void * m_pUserData; // 自定义状态 void (*m_pUpdate)(); // 多态更新 void (*m_pDraw)(); // 多态绘制 }; typedef struct WorldOb_s WorldOb_t;
14.2.1.2 单一庞大的类层次结构
很自然地我们会用分类学的方式把游戏对象类型归类.此思考方式会促使游戏程序员选择一个支持继承功能的面向对象语言.表示一组互相有关联的游戏对象类型,最直观,明确的方式就是使用类层次结构.因此,大部分商业游戏引擎都采用类层次结构,这是完全意料中的事.
图14.2展示了一个可用于实现《吃豆人》的简单类层次结构.此层次结构(如同许多游戏引擎)都是以名为GameObject的类为根的,它可能提供所有对象都共同需要的功能,例如RTTI或序列化.而MovableObject类则用于表示所有含位置及定向的对象.RenderableObject给予对象获渲染的能力(如果是传统的《吃豆人》,就会使用精灵/sprite;如果是现代三维的版本,就可能是使用三角形网格).从RenderableObject派生了鬼,吃豆人,豆子及大力丸等等,构成了整个游戏,这只是一个假想例子,但它展示了多数游戏对象类层次结构背后的基本概念----共有的,通用的功能会接近层次结构的根,而越接近层次结构叶端的类则会加入越多的专门功能
一开始时,游戏对象类层次结构通常是简单,轻盈的,在这种情况下的层次结构可能是一个十分强大而且直觉的游戏对象类型描述方式.然而,随着类层次结构的成长,它会倾向同时往纵,横方向发展,形成笔者称之为单一庞大的类层次结构(monolithic class hierarchy).当游戏对象模型中几乎所有的类都是继承自单个共通的基类时,就会产生这种层次结构.虚幻引擎的游戏对象模型就是一个经典例子(图14.3)
14.2.1.3 深宽层次结构的问题
单一庞大的类层次结构对游戏开发团队来说,可导致很多不同类型的问题.类层次结构成长得越深越宽,这些问题就变得越极端.我们利用以下几部分探讨深宽层次结构的最常见问题
类的理解,维护及修改
一个类越是在类层次结果中越深的地方,就越难理解,维护及修改.因为要理解一个类,就需要理解其所有父类.例如,在派生类中修改一个看似无害的虚函数,就可能会违背了众基类中某个基类的假设,从而产生微妙又难以找到的bug
不能表达多维的分类
每个层次结构多使用了某种标准分类对象,这些标准称为分类学(taxonomy).例如,生物分类学(biological taxonomy,又称作alpha taxonomy)基于遗传的相似性分类所有生物,它使用了8层的树: 域(domain), 界(kingdom), 门(phylum), 纲(class), 目(order), 科(family), 属(genus), 种(species). 在树中的每一层,会采用不同的指标把地球上无数的生命形式分割成越来越仔细的群组
任何层次结构的最大问题之一就是,它只能把对象在每层中用单个"轴"分类----即基于某单一特定的标准做分类. 当设计层次结构时选择了某个标准,就很难,甚至不可能用另一个完全不同的"轴"分类.例如,生物分类学是基于遗传特性分类生物的,它并没有说明生物的颜色.若要以颜色为生物分类,则需要另一个完全不同的树结构
在面向对象编程中,层次结构分类所形成的这种限制很多时候会展现在深,宽,令人迷惘的类层次结构中.当分析一个真实游戏的类层次结构时,许多时候我们会发现它会把多种不同的分类标准尝试合并在单一的类树中.在另一些情况下,若某个新对象类型的特性是在原有层次结构设计的预料之外,就可能会做出一些让步令该新类型可于置于层次结构中.例如,图14.4所展示的类层次结构,好像能合乎逻辑地把不同的载具(vehicle)分类.
那么,当游戏设计师对程序员宣布,他们要在游戏中加入水陆两用载具(amphibious vehicle)时,可以怎么办?那种载具不能套进现有的分类系统,这可能会令程序员惊惶失措,或更有可能的是把该类结构"强行修改(hack)"成丑陋,易错的方式
多重继承: 致命钻石
水陆两用载具的问题,解决方法之一是利用C++的多重继承(multiple inheritance,MI)功能,如图14.5所示,然而,C++的多重继承又会引致一些实践上的问题.例如,多重继承会令对象拥有基类成员的多个版本----此情况称为"致命钻石(deadly diamond)"或"死亡钻石(diamond of death)"
实现一个又可工作,又易理解,又能维护的多重继承类层次结构,其难度通常超过其得益.因此,多数游戏工作室禁止或严格限制在类层次结构中使用多重继承
mix-in类
有些团队容许使用多重继承的一种形式----一个类可以有任意数量的父类但只能有一个祖父类.换言之,一个类可以派生自主要继承层次结构中的一个且仅一个类,但也可以继承任意数量的mix-in类(无基类的独立类).那么共用的功能就能抽出来,形成mix-in类,并把这些功能在需要的时候定点插入主要继承层次结构中.图14.6显示了一个例子.然而,下面将提及,通常更好的做法是合成(composition)或聚合(aggregation)那些类,而不是继承它们
冒泡效应
在设计庞大类层次结构之初,其一个或多个根类通常非常简单,每个根类有最低限度的功能集.然而,当游戏加入越来越多的功能,就可能越容易尝试共享两个或更多个无关类的代码,这种欲望会令功能沿层次结构往上移,笔者称之为"冒泡效应(bubble up effect)"
例如,开始时我们可能做出这么一个设计,只有木箱浮于水面.然而,当游戏设计师见到那些很酷的漂浮箱子,他们就会要求加入更多的漂浮对象,例如角色,纸张,载具等.因为"可浮与不可浮"并非原来设计时的分类标准,程序员们很快就会发现有需要把漂浮功能加至类层次结构中毫不相关的类之中.由于不想使用多重继承,程序员们就决定把漂浮相关的代码往层次结构上方搬移,那些代码会置于全部漂浮对象所共有的基类之中.事实上一些派生自该基类的对象并不能漂浮,但此问题的程度不及把代码在多个类各复制一次的问题.(也可加入如m_bCanFloat这种布尔成员变量以分开两种情况).最后,漂浮功能(以及许多其他游戏功能)会置于继承层次结构的根类
虚幻引擎的Actor(演员)类可说是此"冒泡效应"的经典例子.它包含的数据成员即及代码涵盖管理渲染,动画,物理,世界互动,音效,多人游戏的网络复制,对象创建及销毁,演员更新(即基于某些条件迭代所有演员,并对他们进行一些操作),以及消息广播.当我们容许一些功能在单一庞大的层次结构中像泡沫般上移,多个引擎子系统的封装工作会变得困难
14.2.1.4 使用合成简化层次结构
或许,单一庞大层次结构的最常见成因就是,在面向对象设计中过度使用"是一个(is-a)"关系.例如,在游戏的GUI中,程序员可能基于GUI视窗总是长方形的逻辑,把Window类派生子自Rectangle类.然而,一个视窗并不是一个长方形,它只是拥有一个长方形,用于定义其边界.因此,这个设计问题的更好解决方法是把Rectangle的实例安置于Windows类之中,或是令Windows拥有一个Rectangle的指针或参考
在面向对象设计中,"有一个"关系称为合成(composition).在合成中,A类不是直接拥有B类实例,便是拥有B类实例的指针或者参考.严格来说,使用"合成"一词时,必须指A类拥有B类.这即是说,当构造A类实例时,它也会自动创建B类的实例;当销毁A类的实例时,也会自动销毁B类的实例.我们也可以用指针或参考把两个类连接起来,而当中的一个类并不管理另一个类的生命周期,这种技术称之为聚合(aggregation)
把"是一个"改为"有一个"
要降低游戏类层次结构的宽度,深度,复杂度,一个十分有用的方法是把"是一个"关系改为"有一个"关系.我们使用图14.7中的单一层次结构假想例子说明比技巧.GameObject根类提供所有游戏对象所需的共有功能(如RTTI, 反射,通过序列化实现持久性,网络复制等).MovableObject类用于表示任何含空间变换(即位置,定向,以及可选的比例)的对象.RenderableObject加入了在屏幕上渲染的功能.(非所有游戏对象都需要被渲染,例如,隐形的TriggerRegion类就可以直接继承自MovableObject).CollidableObject类对其实例提供碰撞信息.AnimatingObject类给予其实例一个通过骨骼关节结构播放动画的能力.最后,PhysicalObject类给予其实例被物理模拟的能力(例如,一个刚体能受引力影响往下掉,并被游戏世界反弹)
此类继承结构的一大问题在于,它限制了我们创造新游戏类型的设计选择.若我们想定义一个能受物理模拟的对象类型,我们被迫把该类派生自PhysicalObject, 即使它并不需要骨骼动画.若我们希望一个游戏对象类有碰撞功能,它必须要派生自CollidableObject,即使它可能是隐形的,并不需要RenderableObject的功能
图14.7中的类继承结构的第2个问题在于,难以扩展现存类的功能.例如,假设我们希望支持变形目标动画,那么我们会令AnimatingObject派生两个新类,SkeletalObject即MorphTargetObject.若我们要令这两个类都支持物理模拟,就必须重构Physical-Object成为两个近乎相同的类,一个派生自SkeletalObject, 一个派生自MorphTarget-Object,或是改用多重继承.
这些问题的一个解决方法是,把GameObject不同的功能分离成为独立的类,每个类负责单一,定义清楚的服务.这些类有时候称为组件(component)或服务对象(service object).组件化的设计令我们可以只选择游戏对象所需的功能.此外,每项功能可以独立地维护,扩充或重构,而不影响其他功能.这些独立的组件也更易理解及测试,因为它们和其他组件没有耦合.有些组件类直接对应单个引擎子系统,例如渲染,动画,碰撞,物理,音频等.当某个游戏对象整合多个子系统时,这些子系统能互相保持距离及良好的封装
图14.8展示了把类层次结构重构为组件后的可行设计.在此设计中,GameObject变成一个枢纽(hub),含有每个可选组件的指针.MeshInstance组件取代了RenderableObject类,他表示一个三角形网格的实例,并封装了如何渲染该网格的知识.类似地,AnimationController组件代替了AnimationObject,把骨骼动画服务提供给GameObject.Transform类取代MovableObject维护对象的位置,定向及比例.RigidBody类展示游戏对象的碰撞几何,并为GameObject提供对底层碰撞及物理系统的接口,从而代替了CollidableObject及PhysicalObject.
组件的创建及拥有权
在这种设计中,通常"枢纽"类拥有其组件,即是说它管理其组件的生命周期.但GameObject怎么知道要创建哪些组件?对此有多个解决方案,最简单的就是令GameObject根类拥有所有可能组件的指针.每个游戏对象类型都派生自GameObject类.GameObject的构造函数把所有组件初始化为NULL.而在派生类的构造函数中,就能自由选择创建其所需的组件.方便起见,默认的GameObject析构函数可以自动地清理所有组件.在这种设计中,派生自GameObject类的层次结构成为了游戏对象的主要分类法,而组件类则作为可选的增值功能
以下展示了一个组件创建销毁逻辑的可行实现.然而,记住这段代码仅是作为例子之用,实现细节可能会有许多细节变化,甚至采用实质相同类层次结构的引擎也会有许多实现上的出入
class GameObject { protected: // 我的变换(位置,定向,比例) Transform m_transform; // 标准组件 MeshInstance * m_pMeshInst; AnimationController * m_pAnimController; RigidBody * m_pRigidBody; public: GameObject() { // 默认无组件.派生类可以覆写 m_pMeshInst = NULL; m_pAnimController = NULL; m_pRigidBody = NULL; } ~GameObject() { // 自动删除被派生类创建的组件 delete m_pMeshInst; delete m_pAnimController; delete m_pRigidBody; } // ...... }; class Vehicle: public GameObject { protected: // 加入载具的专门组件 Chassis * m_pChassis; Engine * m_pEngine; // ...... public: Vehicle() { // 构建标准GameObject组件 m_pMeshInst = new MeshInstance; m_pRigidBody = new RigidBody; // 注意: 我们假设动画控制器必须引用网格实例, // 才能令控制器取得矩阵调色板 m_pAnimController = new AnimationController(*m_pMeshInst); // 构建载具的专门组件 m_pChassis = new Chassis(*this, *m_pAnimController); m_pEngine = new Engine(*this); } ~Vehicle() { // 只需析构载具的专门组件,因为GameObject会为我们析构标准组件 delete m_pChassis; delete m_pEngine; } };
14.2.1.5 通用组件
另一个更有弹性(但实现起来更棘手)的方法是,于根游戏对象类加入通用组件的链表.在这种设计中,组件通常都会继承自一个共有的基类,使迭代链表时能利用该基类的多态操作,例如,查询该类的类型,或逐一向组件传送事件以供处理.此设计令根游戏对象类几乎不用关心有哪些组件类型,因而在大部分情况下,可以无须修改游戏对象就能创建新的组件类型.此设计也能让每个游戏对象拥有任意数量的同类型组件实例.(硬编码的设计只容许固定的数量,具体视乎游戏对象类里每个组件类型有多少个指针)
图14.9展示了这种设计.相比硬编码的组件模型,这种设计较难实现,因为我们必须以完全通用的方式来编写游戏对象的代码.同样地,组件类型也不可以假设在某游戏对象中有哪些组件.是使用硬编码组件指针的设计,还是使用通用组件的链表,并不是一个简单的决策.两者各有优缺点,各游戏团队会有不同之选
14.2.1.6 纯组件模型
若我们把组件的概念发挥至极致,会是如何的呢?我们可以把GameObject根类的几乎所有功能移动到多个组件类中.那么,游戏对象类就差不多变成一个无行为的容器,它含有唯一标识符及一些组件的指针,但自己却不含任何逻辑.既然如此,何不删去那个根类呢?要这么做,其中一个方法是把游戏对象的标识符复制至每个组件中.那么组件就能逻辑地以标识符分组方式连接起来.若能提供一个以标识符查找组件的快速方法,我们便无须GameObject这个枢纽.笔者称这种架构为纯组件模型(pure component model),如图14.10所示
刚开始时,可能会觉得纯组件模型并不简单,而且它也带有一些问题.例如,我们仍要定义游戏所需的具体游戏对象类型,并且在创建那些对象时安插正确的组件实例.之前的GameObject的层次结构可以帮助我们处理组件创建.若使用纯组件模型,取而代之,我们可以用工厂模式(factory pattern),对每个游戏对象定义一个工厂类(factory class),内含一个虚拟建构函数创建该对象类型所需的组件.又或者,我们可以改用数据驱动模型,通过由引擎读取文本文件所定义的游戏对象类型,决定为游戏对象创建哪些组件
另一个纯组件模型的问题,在于组件间的通信,我们的中央GameObject当作"枢纽",可编排多个组件间的通信.在纯组件架构中,我们需要一个高效的方法,令单个对象中的组件能互相通信.当然,组件可以使用游戏对象的唯一标识符来查找该对象的其他组件.然而,我们很可能需要更高效的机制,例如,预先把组件连接成循环链表
在这种意义上,纯组件模型中,某游戏对象与另一个游戏对象的通信也面对相同困难.我们不能再通过GameObject实例做通信媒介,而必须事前知道我们要与哪一个组件通信,又或是对目标游戏对象的所有组件广播信息,这两种方法都不甚理想
纯组件模型可以在真实游戏项目实施,或许也有成功的实例.这类模型有其优缺点,再次,我们不能清楚确定这些设计是否比其他设计更好.除非读者是研发团队的成员,那么应该会选择自己最方便且最有信息的架构,而该架构又是最能配合开发中的游戏的
14.2.2 以属性为中心的各种架构
惯用面向对象语言的程序员,常会自然地使用对象属性(数据成员)和行为(方法,成员函数)去思考问题.这称为以对象为中心的视图(object-centric view):
对象1
位置 = (0, 3, 15)
定向 = (0, 43, 0)
对象2
位置 = (-12, 0, 8)
血量 = 15
对象3
位置 = (0, -87, 10)
然而,我们也可以属性为中心来思考,而不是对象.我们先定义游戏对象可能含有的属性集合,然后为每个属性建表,每个表含有各个对象对应该属性的值,这些属性值以对象唯一标识符为键.这称为以属性为中心的视图(property-centric view)
位置
对象1 = (0, 3, 15)
对象2 = (-12, 0, 8)
定向
对象1 = (0, 43, 0)
对象3 = (0, -87, 0)
血量
对象2 = 15
以属性为中心的对象模型曾成功地应用在许多商业游戏中,包括《杀出重围2(DeusEx 2)》及《神偷(Thief)》系列
相对于对象模型,以属性为中心的设计更类似关系数据库.每个属性像是数据库表的一列(或独立的表),以游戏对象的唯一标识符为主键(primary key).当然,在面向对象模型中,对象不仅以属性定义,还需要定义其行为.若我们有了属性的表,如何实现行为呢?各游戏引擎给出不同的答案,但最常见的方法是把行为实现在两个地方: (a) 在属性本身,及/或 (b) 脚本.
14.2.2.1 通过属性类实现行为
每种属性可以实现为属性类(property class). 属性可以是简单的单值,如布尔值或浮点数,也可以复杂到如一个渲染用的三角形网格,或是一个人工智能"脑".每个属性类可以通过硬编码方法(成员函数)来产生行为.某游戏对象的整体行为仍是由其全部属性的行为结集而得
例如,若游戏对象含有Health(血量)属性的实例,该对象就能受损,并最终被毁或被杀.对于游戏对象的任何攻击,Health对象都能扣减适当的血量作为回应.属性对象也可以与该游戏对象中的其他属性对象交流,以产生合作行为.例如,当Health属性检测并回应了一个攻击,它可以发一个消息给AnimatedSkeleton(带动画的骨骼)属性,从而令游戏对象播放一个合适的受击动画.相似地,当Health属性检测到游戏对象快要死去或被毁,它能告诉RigidBodyDynamics(属性)触发物理驱动的自爆,或是"布娃娃"模拟.
14.2.2.2 通过脚本实现行为
另一选择,是把属性值以原始方式储存于一个或多个如数据库的表里,然后用脚本代码实现对象的行为.每个游戏对象可能有一个名为ScriptId的特殊属性,若对象含该属性,那么它就是用来指定管理对象行为的脚本部分(指脚本函数,若脚本支持面向对象则是指脚本对象).脚本代码也可能用于回应游戏世界中的事件
在一些以属性为中心的引擎里,核心属性是由工程师硬编码的类,但引擎还会提供一些机制给游戏设计师及程序员,以完全使用脚本实现一些新的属性.这种方法曾成功应用到一些游戏,例如《末日危城(Dungeon Siege)》
14.2.2.3 对比属性与组件
笔者需要交代一下,14.2.2.5节所参考的文章中,许多作者使用"组件"一词去代表笔者在此所指的"属性对象".在14.2.1.4节中,笔者使用"组件"一词指以对象为中心的设计中的子对象,而这个"组件"和属性对象并不怎么相似
然而,属性对象和组件在很多方面都是密切相关的,在两种设计之中,单个逻辑游戏对象都是由多个子对象所组成的.主要的区别在于子对象的角色,在以属性为中心的设计中,每个子对象定义游戏对象本身的某个属性(如血量,视觉表示方式,物品清单,某种魔法能量等); 而在以组件为中心(以对象为中心)的设计中,子对象通常用作表示某底层引擎子系统(渲染器,动画,碰撞及动力学等).这个区别如此细微,在许多情况下这个区别的存在都几乎无所谓了.读者可称自己的设计为纯组件模型,或是以属性为中心模型,看你觉得哪一个名称较为合适.但是到了最后,读者应会得到实质上相同的结果----一个由一组子对象所合成而成的逻辑游戏对象,并从这组子对象中获取所需的行为
14.2.2.4 以属性为中心的设计的优缺点
static const U32 MAX_GAME_OBJECTS = 1024; // 传统结构之数组(AoS)方式 struct GameObject { U32 m_uniqueID; Vector m_pos; Quaternion m_rot; float m_health; // ...... }; GameObject g_aAllGameObejcts[MAX_GAME_OBJECTS]; // 对缓存更友好的数组之结构(SoA)方式 struct AllGameObjects { U32 m_aUniqueId[MAX_GAME_OBJECTS]; Vector m_aPos[MAX_GAME_OBJECTS]; Quaternion m_aRot[MAX_GAME_OBJECTS]; float m_aHealth[MAX_GAME_OBJECTS]; // ...... };
14.2.2.5 延伸阅读
一些游戏业界的杰出工程师曾在各个游戏开发会议上发表过有关属性为中心的架构的简报,这些简报可以通过以下网址取得
Rob Fermier, "Creating a Data Driven Engine",Game Developer's Conference, 2002
Scott Bilas, "A Data-Driven Game Obejct System", Game Developer's Conference, 2002
Alex Duran, "Building Object Systems: Features , Tradeoffs, and Pitfalls", Game Develolper's Conference, 2003
Jeremy Chatelaine, "Enabling Data Driven Tunning via Existing Tools", Game Developer's Conference, 2003
Doug Church, "Object Systems", 于2003年韩国首尔的一个游戏开发会议发表: 会议由Chris Hecker, Casey Muratori, Jon Blow和Doug Church组织
14.3 世界组块的数据格式
14.3.1 二进制对象映像
14.3.2 游戏对象描述的序列化
14.3.3 生成器及类型架构
14.3.3.1 对象类型架构
14.3.3.2 属性默认值
14.3.3.3 生成器及类型架构的好处
14.4 游戏世界的加载和串流
14.4.1 简单的关卡加载
14.4.2 往无缝加载进发: 阻隔室
14.4.3 游戏世界的串流
14.4.3.1 判断要加载哪些资源
14.4.4 对象生成的内存管理
14.4.4.1 对象生成的离线内存分配
14.4.4.2 对象生成的动态内存管理
14.4.5 游戏存档
14.4.5.1 存储点
14.4.5.2 任何地方皆可存档
14.5 对象引用与世界查询
14.5.1 指针
14.5.2 智能指针
14.5.3 句柄
14.5.4 游戏对象查询
14.6 实时更新游戏对象
14.6.1 一个简单(但不可行)的方式
14.6.1.1 管理所有对象的集合
14.6.1.2 Update()函数的责任
14.6.2 性能限制及批次式更新
14.6.3 对象及子系统的相互依赖
14.6.3.1 分阶段更新
14.6.3.2 桶式更新
14.6.3.3 对象状态及"差一帧"延迟
14.6.3.4 对象状态缓存
14.6.3.5 加上时间戳
14.6.4 为并行设计
14.6.4.1 使游戏对象模型本身并行
14.6.4.2 与并发的引擎子系统接口
14.7 事件与消息泵
14.7.1 静态函数类型绑定带来的问题
14.7.2 把事件封装成对象
14.7.3 事件类型
14.7.4 事件参数
14.7.4.1 以键值对作为事件参数
14.7.5 事件处理器
14.7.6 取出事件参数
14.7.7 职责链
14.7.8 登记对事件的关注
14.7.9 要排队还是不要排队
14.7.9.1 事件排队的好处
14.7.9.2 事件排队带来的问题
14.7.10 即时传递事件带来的问题
14.7.11 数据驱动事件/消息传递系统
14.7.11.1 数据路径通信系统
14.7.11.2 视觉化编程的优缺点
14.8 脚本
14.9 高层次的游戏流程
第五部分 总结
第15章 还有更多内容吗
15.1 一些未谈及的引擎系统
15.2 游戏性系统
参考文献
[1] Tomas Akenine-Moller, Eric Haines, and Naty Hoffman. Real-Time Rendering(3rd Edition). Wellesley, MA: A K Peters, 2008. 中译本: 《实时计算机图形学(第2版)》,普建涛译,北京大学出版社,2004
[2] Andrei Alexandrescu. Mordern C++ Design: Generic Programming and Design Patterns Applied. Resding, MA: Addison-Wesley, 2001. 中译本:《C++设计新思维:泛型编程与设计模式之应用》,侯捷/於春景译,华中科技大学出版社,2003
[3] Grenville Armitage, Mark Claypool and Philip Branch. Networking and Online Games: Understanding and Engineering Multiplayer Internet Games. New York, NY: John Wiley and Sons, 2006.
[4] James Arvo (editor). Graphcis Gems II. San Diego, CA: Academic Press, 1991.
[5] Grady Booch, Robert A. Maksimchuk, Michael W. Engel, Bobbi J. Young, Jim Conallen, and Kelli A. Houston. Object-Oriented Analysis and Design with Applications (3rd Edition). Reading, MA: Addison-Wesley, 2007. 中译本《面向对象分析与设计(第3版)》, 王海鹏/潘加宇译, 电子工业出版社,2012.
[6] Mark DeLoura (editor). Game Programming Gems. Hingham, MA: Charles River Media, 2000. 中译本: 《游戏编程精粹1》,王淑礼译,人民邮电出版社,2004.
[7] Mark DeLoura (editor). Game Programming Gems 2. Hingham, MA: Charles River Media, 2001. 中译本:《游戏编程精粹2》,袁国衷译, 人民邮电出版社, 2003.
[8] Philip Dutre, Kavita Bala and Philippe Bekaert. Advanced Global Illumination (2nd Edition). Wellesley, MA: A K Peters, 20006.
[9] David H. Eberly. 3D Game Engine Design: A Pratical Approach to Real-Time Computer Graphics. San Francisco, CA: Morgan Kaufmann, 2001. 国内英文版: 《3D游戏引擎设计: 实时计算机图形学的应用方法(第2版)》, 人民邮电出版社, 2009.
[10] David H Eberly. 3D Game Engine Architecture: Engineering Real-Time Applications with Wild Magic. San Francisco, CA: Morgan Kaufmann, 2005.
[11] David H. Eberly. Game Physics. San Francisco, CA: Morgan Kaufmann, 2003.
[12] Christer Ericson. Real-Time Collision Detection. San Francisco, CA: Morgan Kaufmann, 2005. 中译本:《实时碰撞检测算法技术》, 刘天慧译,清华大学出版社,2010.
[13] Randima Fernando (editor). GPU Gems: Programming Techniques, Tips and Tricks for Real-Time Graphics. Reading, MA: Addison-Wesley, 2004. 中译本:《GPU精粹: 实时图形编程的技术, 技巧和技艺》,姚勇译,人民邮电出版社,2006.
[14] James D. Foley, Andries van Dam, Steven K. Feiner, and John F. Hughes. Computer Graphics: Principles and Practice in C (2nd Edition). Reading, MA: Addison-Wesley, 1995. 中译本:《计算机图形学原理及实践----C语言描述》,唐泽圣/董士海/李华/吴恩华/汪国平译,机械工业出版社,2004.
[15] Grant R. Fowles and George L. Cassiday. Analytical Mechanics (7th Edition). Pacific Grove, CA: Brooks Cole, 2005.
[16] John David Funge. AI for Games and Animations: A Cognitive Modeling Approach Wellesley, MA: A K Peters, 1999.
[17] Erich Gamma, Richard Helm, Ralph Johnson, and John M. Vlissiddes. Desgin Patterns: Elements of Reusable Object-Oriented Software. Reading, MA: Addison-Wesley, 1994. 中译本《设计模式: 可复用面向对象软件的基础》,李英军/马晓星/蔡敏/刘建中译,机械工业出版本,2005.
[18] Andrew S. Glassner (editor). Graphcis Gems I. San Francisco, CA: Morgan Kaufmann, 1990.
[19] Paul S. Heckbert (editor). Graphics Gems IV. San Diego, CA: Academic Press, 1994
[20] Maurice Herlihy, Nir Shavit. The Art of Multiprocessor Programming. San Francisco, CA: Morgan Kaufmann, 2008. 中译本:《多处理器编程的艺术》,金海/胡侃译, 机械工业出版社,2009
[21] Roberto Ierusalimschy, Luiz Henrique de Figueiredo and Waldemar Celes. Lua 5.1 Reference Manual. Lua.org, 2006.
[22] Roberto Ierusalimschy. Programming in Lua, 2nd Edition. Lua.org, 2006. 中译本:《Lua程序设计(第2版)》,周惟迪译, 电子工业出版社, 2008.
[23] Issac Victor Kerlow. The Art of 3-D Computer Animation and Imaging(2nd Edition). New York, NY: John Wiley and Sons, 2000.
[24] David Kirk (editor). Graphics Gems III. San Francisco, CA: Morgan Kaufmann, 1994.
[25] Danny Kodicek. Mathematics and Physcis for Game Programmers. Hingham, MA: Charles River Media, 2005.
[26] Raph Koster. A Theory of Fun for Game Design, Phoenix, AZ: Paraglyph, 2004. 中译本:《快乐之道: 游戏设计的黄金法则》,姜文斌等译,百家出版社,2005.
[27] John Lakos. Large-Scale C++ Software Design. Reading, MA: Addison-Wesley, 1995. 中译本:《大规模C++程序设计》,李师贤/明仲/曾新红/刘显明译,中国电力出版社,2003
[28] Eric Lengyel. Mathematics for 3D Game Programming and Computer Graphics(2nd Edition). Hingham, MA: Charles River Media, 2003.
[29] Touc V. Luong, James S.H.Lok, David J. Taylor and Kevin Driscoll. Internationalization: Developing Software for Global Markets. New York, NY: John Wiley & Sons, 1995.
[30] Steve Maguire. Writing Solid Code: Microsoft's Techniques for Developing Bug Free C Programs. Bellevue, WA: Microsoft Press, 1993. 国内英文版: 《编程精粹: 编写高质量C语言代码》,人民邮电出版社,2009
[31] Scott Meyers. Effective C++: 55 Specific Ways to Improve Your Programs and Designs (3rd Editon). Reading,MA: Addison-Wesley, 2005. 中译本:《Effective C++: 改善程序与设计的55个具体做法 (第3版本)》,侯捷译,电子工业出版社, 2011.
[32] Scott Meyers. More Effective C++: 35 New Ways to Improve Your Programs and Designs. Reading, MA: Addison-Wesley, 1996. 中译本: 《More Effective C++: 35个改善编程与设计的有效方法(中文版)》, 侯捷译, 电子工业出版社,2011
[33] Scott Meyers. Effective STL: 50 Specific Ways to Improve Your Use of the Standard Template Library. Reading, MA: Addison-Wesley, 2001. 中译本: 《Effective STL: 50条有效使用STL的经验》,潘爱民/陈铭/邹开红译,电子工业出版社,2013.
[34] Ian Millington. Game Physics Engine Development. San Francisco, CA: Morgan Kaufmann, 2007.
[35] Hubert Nguyen (editor). GPU Gems 3. Reading, MA: Addison-Wesley, 2007. 中译本: 《GPU精粹3》,杨柏林/陈根浪/王聪译, 清华大学出版社,2010
[36] Alan W. Paeth (editor). Graphics Gems V. San Francisco, CA: Morgan Kaufmann, 1995.
[37] C. Michael Pilato, Ben Collins-Sussman, and Brian W. Fitzpatrick. Version Control with Subversion (2nd Edition). Sebastopol, CA: O'Reilly Media, 2008. (常被称作"The Subversion Book", 线上版本http://svnbook.red-bean.com) 国内英文版: 《使用Subversion进行版本控制》,开明出版社, 2009
[38] Matt Pharr (editor). GPU Gems 2: Programming Techniques for High-Performance Graphics and General-Purpose Computation. Reading, MA: Addison-Wesley, 2005. 中译本: 《GPU精粹2: 高性能图形芯片和通用计算编程技巧》,龚敏敏译,清华大学出版社,2007
[39] Bjarne Stroustrup. The C++ Programming Language, Special Edition (3rd Edition). Reading, MA: Addison-Wesley, 2000. 中译本《C++程序设计语言(特别版)》,裘宗燕译, 机械工业出版社, 2010.
[40] Dante Treglia (editor). Game Programming Gems 3. Hingham, MA: Charles River Media, 2002. 中译本:《游戏编程精粹3》,张磊译,人民邮电出版社,2003.
[41] Gino van den Bergen. Collision Detection in Interaction 3D Environments. San Francisco, CA: Morgan Kaufmann, 2003.
[42] Alan Watt. 3D Computer Graphics (3rd Edition). Reading, MA: Addison Wesley, 1999
[43] James Whitehead II, Bryan McLemore and Matthew Orlando. World of Warcraft Programming: A Guide and Reference for Creating WoW Addons. New York, NY: John Wiley & Sons, 2008. 中译本: 《魔兽世界编程宝典: World of Warcraft Addons 完全参考手册》,杨柏林/张卫星/王聪译, 清华大学出版社, 2010.
[44] Richard Williams. The Animator's Survival Kit. London, England: Faber & Faber, 2002. 中译本: 《原动画基础教程: 动画人的生存手册》,邓晓娥译, 中国青年出版社,2006