游戏引擎架构 (Jason Gregory 著)

第一部分 基础

第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();
}
View Code

  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();
    }
}
View Code

  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();
}
View Code

以上这种实现游戏循环的方式,其副作用是设置了任务的优先次序,处理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);
        // 等等
    }
}
View Code

    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<U32>(tEnd - tBegin);

// 现在可以使用或存储dyCycles的值
View Code

注意我们仍然使用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;
}
View Code

再次注意我们必须先使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;
}
View Code

  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;
        }
    }
};
View Code

  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()) {
        // 玩家能看见敌人
        // .....
    }
    // ......
}
View Code

而使用异步设计,提出光线投射请求时,调用的函数只会建立一个光线投射作业,并把该作业加到队列中,然后该函数就会立即返回.主线程可继续做其他跟该作业无关的工作,同一时间另一个CPU或核就会处理那个作业.之后,当作业完成,主线程就能提取并处理光线投射的结果

while (true) {    // 游戏主循环
    // ......
    // 投射一条光线以判断玩家能否看见敌人
    RayCastResult r;
    requestRayCast(playerPos, enemyPos, &r);

    // 当等待其他核做光线投射时, 我们做其他无关的工作
    // ......
    
    // 好吧,我们不能再做更多有用的事情了,等待光线投射作业的结果
    // 若作业完毕, 此函数会立即返回. 否则,主线程会闲置直至有结果
    waitForRayCastResults(&r);

    // 处理结果
    if (r.hitSomething() && isEnemy(r.getHitObject())) {
        // 玩家能看见敌人
        // ......
    }
    // ......
}
View Code

许多时候,异步代码可以在某帧启动请求,而在下一帧才提取结果.这种情况的代码可能是这样的

RayCastResult r;
bool rayJobPendign = false;

while (true) {    // 游戏主循环
    // ......
    // 等待上一帧的光线投射结果
    if (rayJobPending) {
        waitForRayCastResults(&r);
    
        // 处理结果
        if (r.hitSomething() && isEnemy(r.getHitObject())) {
            // 玩家能看见敌人
            // ......
        }
    }


    // 为下一帧投射一条光线
    rayJobPending = true;
    requestRayCast(playerPos, enemyPos, &r);

    // 做其他事情
    // ......
}
View Code

  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;
}
View Code

    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);

    // 这里忽略一些细节......
}
View Code

第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 采用深度缓冲的三角形光栅化基础

三维场景渲染的本质涉及以下这几个基本步骤

  • 描述一个虚拟场景(virtual scene).这些场景一般是以某数学形式表示的三维表面
  • 定位及定向一个虚拟摄像机(virtual camera),为场景取景.摄像机的常见模型是这样的: 摄像机位于一个理想化的焦点(focal point),在焦点前的近处悬浮着一个影像面(image surface),而此影响面由多个虚拟感光元件(virtual light sensor)所组成,每个感光元件对应着目标显示设备的像素(picture element/pixel)
  • 设置光源(light source).光源产生的光线会与环境中的物体交互作用并反射,最终会到达虚拟摄像机的感光像面
  • 描述场景中物体表面的视觉特性(visual property).这些视觉特性决定光线如何与物体表面产生交互作用
  • 对于每个位于影像矩形内的像素,渲染引擎会找出经过该像素而聚集于虚拟摄像机焦点的(一条或多条)光线,并计算其颜色及强度(intensity).此过程称为求解渲染方程(solving the rendering equation),也叫作着色方程(shading equation)

有多种不同的技术可运行上述的基本渲染步骤.游戏图形一般是以照相写实主义(photorealism)为主要目标,但也有些游戏以特别风格为目标(如卡通,炭笔素描等).因此,渲染工程师和美术人员通常会把场景的属性描述得尽量真实,并使用尽量接近物理现实的光传输模型(light transport model).在此语境下,整个渲染技术的领域,包含为了视觉流畅而设计的实时渲染技术,以及为照相写实主义而设计但非实时运行的渲染技术

实时渲染引擎重复地进行上述的步骤,以每秒30,50或60帧的速度显示渲染出来的影像,从而产生运动的错觉.换句话说,实时渲染引擎以最长33.3ms内产生每幅影像(以达至30FPS的帧率).通常实际上可用的时间更少,因为其他如动画,人工智能,碰撞检测,物理模拟,音频,玩家机制,其他游戏性等引擎系统都会耗费时间资源.对比电影渲染引擎通常要花许多分钟以至于许多小时来渲染1帧,现时实时计算机图形的品质可谓非常惊人

    10.1.1 场景描述

现实世界的场景由物体所组成.有些物体是固态的,例如一块砖头,有些物体无固定形状,例如一缕烟,但所有物体都占据三维空间的体积.物体可以是不透明的(opaque),即光不能通过该物体;也可以是透明的(transparent),即光能通过该物体,过程中不被散射(scatter),因此可以看见物体后面的清晰影像,还可以是半透明的(translucent),即光能通过该物体,但过程中会被散射至各个方向,使物体背后的影像变得朦胧

渲染不透明的物体时,只需要考虑其表面(surface).我们无须知道不透明的物体内部是怎样的,便足以渲染该物体,因为光能不穿越其表面.当渲染透明或者半透明物体时,便需要为光线通过物体时所造成的反射,折射,散射,吸收行为建模,此模型需要该物体内部结构及属性的知识.然而,多数游戏引擎不会达至这么麻烦的地步.游戏引擎通常只会跟渲染不透明物体差不多的方法,去渲染透明和半透明物体.游戏引擎通常会采用名为alpha的简单不透明度(opacity)量度数值表达物体表面有多不透明或透明.此方法能导致多种视觉异常情况(例如,物体离摄像机较远的一面可能渲染得不正确),但可采用近似法来使大部分情况看上去都足够真实.就算是烟这种无固定形状的物体,通常也会用粒子效果去表现,而这些效果实际上是由大量半透明的矩形卡板所合成的.因此,我们完全可以说,大多数游戏渲染引擎主要着重于渲染物体的表面

      10.1.1.1 高端渲染软件所用的表示法

理论上,一块表面是由无数三维空间中的点所组成的一张二维薄片.然而,此描述显然无实际用途.为了让计算机处理及渲染任意的表面,我们需要以一个紧凑的方式用数学表示表面

有些表面可用分析式来精确表示.例如,位于原点的球体表面可用x2 + y2 + z2 = r2 表示.然而,为任意形状建模时,分析式的方程并非十分有用

在电影产业里,表面通常由一些矩形的面片(patch)所组成,而每个面片则是由小量的控制点定义的三维样条(spline)所构成的.可使用多种样条,包括各Bezier曲面(如双三次面片/bicubic patch, 是一种三阶Bezierq曲面),非均匀有理B样条(nonuniform rational B-spline/NURBS),N面片(N-patches,又名为normal patches).用面片建模,有点像用小块的长方形或纸糊去遮盖一个雕像

高端电影渲染引擎如Pixar的RenderMan,采用细分曲面(subdivison surface)定义几何形状.每个表面由控制多边形网格(如同样条)表示表面,但这些多边形会使用Catmull-Clark算法逐步细分成更小的多边形.细分过程通常会进行至每个多边形小于像素的大小.此方法的优点是,无论摄像机距离表面有多接近,都能再细分多边形,使轮廓边线显得圆滑

      10.1.1.2 三角形网格

传统来说,游戏开发者会使用三角形网格来为表面建模.三角形是表面的分段线性逼近(piecewise linear approximation),如同用多条相连的线段分段逼近一个函数或曲线

在各种多边形中,实时渲染之所以选用三角形,是因为三角形有以下的优点

  • 三角形是最简单的多边形.少于3个顶点就不能成为一个表面
  • 三角形必然是平坦的. 含4个或以上顶点的多边形不一定是平坦的,因为其前3个顶点能定义一个平面,第4个顶点或许会位于该平面之上或之下
  • 三角形经多种转换之后仍然维持是三角形,这对于仿射转换和透视转换也成立.最坏的情况下,从三角形的边去观看,三角形会退化为线段.在其他角度观察,仍能维持是三角形
  • 几乎所有商用图形加速硬件都是为三角形光栅化而设计的.从最早期的PC三维图形加速器开始,渲染硬件一直几乎只专注为三角形光栅化而设计.此决策还可追溯至最早期使用软件光栅化的三维游戏,如《德军司令部》和《毁灭战士》.无论个人喜恶,基于三角形的技术已牢牢确立在游戏业界,在未来几年应该还不会有大转变

镶嵌

镶嵌(tessellation)是指把表面分割为一组离散多边形的过程,这些多边形通常是三角形或四边形(quadrilateral, 简称quad).三角化(triangulation)专指把表面镶嵌为三角形

这种三角形网格在游戏中有一常见问题,就是其镶嵌程度是由制作的美术人员决定的,不能中途改变.固定的镶嵌会使物体的轮廓边缘显得不圆滑,此问题的摄像机接近物体的时候更加明显

理想地,我们希望有一方案能按物体与虚拟摄像机距离的缩减而增加密辅程度.换句话说,我们希望无论物体是远是近,都能有一致的三角形对像素密度.细分曲面能满足此愿望,表面能根据与摄像机的距离来进行镶嵌,使每个三角形的尺寸都少于一个像素

游戏开发者经常尝试以一串不同版本的三角形网格链去逼近此理想的三角形对像素密度,每一版本称为一个层次细节(level-of-detail, LOD).第一个LOD通常称为LOD 0,代表最高程度的镶嵌,在物体非常接近摄像机时使用.后续的LOD的镶嵌程度不断降低.当物体逐渐远离摄像机,引擎就会把网格从LOD 0 换为 LOD 1, LOD 2等.这样渲染引擎便可以花费更多时间在接近摄像机的物体上(即占据屏幕中更多像素的物体),进行顶点的转换和光照运算

有些游戏引擎会应用动态镶嵌(dynamic tesselation)技术到可扩展的网格上,例如水面和地形.在这种技术中,网格通常以高度场(height field)来表示,而高度场则在某种规则栅格模式上定义.最接近摄像机的网格区域会以栅格的最高分辨率来镶嵌,距摄像机较远的区域则会使用更少的栅格点来进行镶嵌

渐进网格(progressive mesh)是另一种动态镶嵌及层次细节技术.运用此技术时,当物体很接近摄像机时采用单个最高分辨率网格.(这个本质上就是LOD 0网格).当物体自摄像机远离,这个网格就会自动密辅,其方法是把某些棱收缩为点.此过程能自动生成半连续的LOD链.

      10.1.1.3 构造三角形网格

缠绕顺序

三角形由3个顶点的位置矢量定义,此3个矢量设为p1, p2, p3.每条棱(edge)的相邻顶点的位置矢量相减,就能求得3条棱得矢量.例如:

  e12 = p2 - p1

  e23 = p3 - p2

  e13 = p3 - p1

任何两棱的叉积, 归一化后就能定义为三角形的单位面法线(face normal)N:

  N = e12 x e13 / | e12 x e 13 |

图10.5描绘了这些推导.要知道面法线的方向(即棱叉积的目的),我们需要定义哪一面才是三角形的正面(即物体表面),哪一面是背面(即表面之内).这个可以简单用缠绕顺序(winding order)来定义,缠绕顺序用来定义表面方向有两种方式,分别是顺时针方向(clockwise, CW)和逆时针方向(counterclockwise, CCW)

多数底层图形API提供基于缠绕顺序来剔除背面三角形(backface triangle culling).例如,若在Direct3D内把剔除模式参数(D3DRS_CULL)设置为D3DCULLMODEL_CW,那么所有在屏幕空间里缠绕顺序为顺时针方向的三角形就会视为背面,不被渲染

背面剔除的重要性在于,我们通常不需要浪费时间渲染看不见的三角形.而且,渲染透明物体的背面还会做成视觉异常,可以随意选择两种缠绕顺序之一,只要整个游戏的资产都是一致的就行. 不一致的缠绕顺序是三维建模新手的常见错误

三角形表

定义网格的最简单方法是以每3个顶点为一组列举,当中每3个顶点对应一个三角形.此数据结构称为三角形表(triangle list),如图10.6所示

索引化三角形表

读者可能注意到,在图10.6的三角形表中有许多重复的顶点,而且经常重复多次.之后在10.1.2.1节会谈及,每个顶点要储存颇多的元数据,因此在三角形表中重复的数据会浪费内存.这同时也会浪费GPU的资源,因为重复的顶点会计算变换及光照多次

由于上述原因,多数渲染引擎会采用更有效率的数据结构----索引化三角形表(indexed triangle list).其基本思想就是每个顶点仅列举一次,然后用轻量级的顶点索引(通常每个索引只占16位)来定义组成三角形的3个顶点.在DirectX下顶点储存于顶点缓冲(vertex buffer),在OpenGL下则称其为顶点数组(vertex array).而索引会储存于另一单独缓冲,称为索引缓冲(index buffer)或索引数组(index array).图10.7展示了此数据结构

三角形带及三角形扇

在游戏渲染中,有时候还会用到两种特殊网格数据结构,分别为三角形带(triangle strip)及三角形扇(triangle fan).这两种数据结构不需要索引缓冲,但同时能降低某程度的顶点重复.它们之所以有这些特性,其实是通过预先定义顶点出现的次序,并预先定义顶点组合成三角形的规则

在三角形带中,前3个顶点定义了第一个三角形,之后的每个顶点都会连接其前两个顶点,产生全新的三角形.为了统一三角形带的缠绕顺序,产生每个新三角形时,其前两个相邻顶点会互换次序.图10.8展示了一个三角形带的例子

在三角形扇中,前3个顶点定义了第一个三角形,之后每个顶点与前一顶点及该三角形扇的首顶点组成三角形.图10.9是三角形扇的例子

顶点缓存优化

当GPU处理索引化三角形表时,每个三角形能引用顶点缓冲内的任何顶点.为了在光栅化阶段保持三角形的完整性,顶点必须按照其位于三角形中的次序来处理.当顶点着色器处理每个顶点后,其结果会被缓存以供重复使用.若之后的图元引用到存于缓存的顶点,就能直接使用结果,而无须重复处理该顶点

使用三角形带及三角形扇,一个原因是能节省内存(无须索引缓冲),另一原因是基于它们往往能改善GPU存取显存时的缓存一致性(cache coherency).我们甚至可以使用索引化三角形带索引化三角形扇以消除所有顶点重复(这样通常比不用索引缓冲更省内存),而同时仍能受益于三角形带及三角形扇次序所带来的缓存一致性

除了次序受限的三角形带及三角形扇,我们也可以优化索引化三角形表以提升缓存一致性.顶点缓存优化器(vertex cache optimizer)就是为此而设的一种离线几何处理工具,它能重新排列三角形的次序,以优化缓存内的顶点复用.顶点缓存优化器一般会根据多种因素来进行优化,例如个别GPU类型的顶点缓存大小,GPU选择缓存或舍弃顶点的算法等.以Sony的Edge几何处理库为例.其顶点缓存优化器能使三角形表的渲染吞吐量达至高于三角形带的4%

      10.1.1.4 模型空间

三角形网格的位置矢量,通常会被指定于一个便利的局部坐标系,此坐标系可称为模型空间(model space), 局部空间(local space)或物体空间(object space).模型空间的原点一般不是物体中心,便是某个便利的位置.例如,角色脚掌所在地板的位置,车辆轮子的地上的水平质心(centroid)

模型空间的轴可随意设置,但这些轴通常会和自然的"前方","左/右方"及"上方"对齐.在数学上再严谨一些的话,可以定义3个单位矢量F,L(或R),U,并把这3个矢量映射至模型空间的单位基矢量i,j,k(及各自对应x,y,z轴).例如,一个常见的映射为L = i, U = j, F = k.这些映射可以随意设定,只要引擎中所有模型的映射都是始终如一的

      10.1.1.5 世界空间及网格实例化

使用网格组成完整场景时,会在一个共同的坐标系里放置及定向多个网格,此坐标系称为世界空间(world space).每个网格可以在场景中多次出现,例如,街上排列着同款街灯,一堆看不见面目的士兵,攻击玩家的一大群蜘蛛等等.每个这些物体称为网格实例(mesh instance)

每个网格实例含共享网格的引用,此外也包含一个变换矩阵,用以把个别实例的网格顶点从模型空间转换至世界空间.此矩阵名为模型至世界矩阵(model-to-world matrix),有时候仅简单称为世界矩阵(world matrix).若采用4.3.10.2的表示方式,此矩阵可写成:

当中左上的 3 x 3矩阵(RS)M->W 用来旋转和缩放模型空间顶点至世界空间,而tM则是模型空间轴在世界空间的位移.若用世界空间坐标表示单位模型空间的基矢量iM,jM,kM,则该矩阵也可写成:

给定一个模型空间的顶点坐标,渲染引擎会用以下的方程计算其世界空间坐标:

MM->W可以看成是模型空间轴的位置和定向的描述,此描述是以世界空间坐标表示的.或是把它看成是把顶点从模型空间变换至世界空间的矩阵

当渲染模型时,模型至世界矩阵也可用来变换网格的表面法矢量,为了正确变换法矢量,必须把法矢量乘以模型至世界矩阵的逆转置矩阵.若矩阵不含缩放及切边,可简单地把法矢量的w设为0,再乘以模型至世界矩阵完成变换

有些网格是完全静止及独一无二的,例如建筑物,地形,以及其他背景元素.这些网格的顶点通常以世界空间表示.因此其模型至世界矩阵是单位矩阵,可以忽略

    10.1.2 描述表面的视觉性质

为了正确地渲染及照明表面,我们需要有描述表面的视觉性质(visual property).表面性质包括几何信息,例如表面上不同位置的法矢量.表面性质也包括描述光和表面交互作用的方式,包括漫反射颜色(diffuse color),粗糙度(roughness)/光滑度(shininess),反射率(reflectivity),纹理,透明度/不透明度,折射率(refractive index)等.表面性质也可能含有表面随时间变化的描述(例如,有动画的角色皮肤应如何追踪其骨骼的关节,水面如何移动等)

渲染照相写实影像的关键在于,正确地模拟光和场景中物体交互作用时的行为.因此渲染工程师需要理解光如何工作,光如何在环境中传递,以及虚拟摄像机如何"感光",并把结果转换成屏幕上像素的颜色

      10.1.2.1 光和颜色的概论

光是电磁辐射,在不同情况下其行为既像波也像粒子.光的颜色是由其强度(intensity)I和波长(wavelength)λ(或频率 f = 1 / λ)所决定.可见光的波长范围是740nm~380nm(频率是430THz~750THz).一束光线可能含单一纯波长,这即是彩虹的颜色,又称为光谱颜色(spectral color).或是,一束光线可能由多种波长的光混合而成.我们可以把一束光线中各波长的强度绘成图表,这种图称为光谱图(spectral plot).白光含所有波长,因此其光谱图大约像一个矩形,横跨整个可见光波段.纯绿光则只有一个波长,因此其光谱图会显示在570THz有一个极窄的尖峰

光和物体的交互作用

光和物体之间能有许多复杂的交互作用(interaciton).光的行为,部分是由其穿过的介质(medium)所控制的,部分是由两种不同介质(如空气/固体,空气/水,水/玻璃等)之间的界面(interface)所控制的.从技术上来说,一个表面只不过是两种不同介质的界面

不管光的行为有多复杂,其实光只能做4件事

  • 光可被吸收(absorb)
  • 光可被反射(reflect)
  • 光可在物体中传播(transmit),过程中通常会被折射(refract)
  • 通过很窄的缺口时,光会被衍射(diffract)

多数照相写实渲染引擎会处理以上前3项行为,而衍射通常会被忽略,因为在多数场景中衍射的效果并不明显

一个平面只会吸收某些波长的光,其他波长的光会被反射.这个特性形成我们对物体颜色的感知(perception).例如,若白光照射一个物体,红色以外的所有波长被吸收,那么该物体就显得是红色的.同样的感知效果会出现在红光照射在白色物体上,我们的眼睛无法区分这两种情况

光的反射可以是漫反射(diffuse),这是指入射光会往所有方向平均散射.而反射也可以是镜面反射(specular),这是指入射光会直接被反射,或在反射时展开成很窄的锥形.反射可以是各向异性(anisotropic)的,这是指在不同角度观察表面时光的反射有所不同

当光穿过物体时,光可能会被散射(如半透明物质),部分被吸收(如彩色玻璃),或被折射(如三棱镜).不同波长的光折射角度会有差异,产生散开的光谱.这就是光经过雨点或三棱镜能产生彩虹的原因.光也能进入半固态的表面,在表面下反弹,再从另一个位置离开表面.这个现象称为次表面散射(subsurface scattering, SSS).次效果能使皮肤,蜡,大理石等物质显示其柔和特性

颜色空间和颜色模型

颜色模型(color model)是量度颜色的三维坐标系统.而颜色空间(color space)是一个具体标准,描述某颜色空间内的数值化颜色如何映射至人类在真实世界中看到的颜色.颜色模型通常是三维的,原因是我们眼睛里有3种颜色感应器(锥状细胞),每种感应器对不同波长的光敏感

计算机图形学中最常用的颜色模型是RGB模型.此模型中,由一个单位立方体表示颜色空间,其3个轴分别代表红,绿,蓝光的量度.这些红,绿,蓝分量称为颜色通道(color channel).在标准的RGB颜色模型中,每个颜色通道的范围都是0~1.因此,颜色(0,0,0)代表黑色,(1,1,1)则代表白色

当颜色存储与位图时,可使用多种不同的颜色格式(color format).颜色格式的定义,部分由每像素位数(bits per pixel, BPP)决定,更具体地说,是由表示每颜色通道的位数决定的.RGB888格式使用每颜色通道8位,共24位/像素.此格式中,每个通道的范围是0~255,而非0~1.在RGB565中,红色和蓝色使用5位,绿色使用6位,总共16位/像素.调色板格式(paletted format)可使用每像素8位储存索引,再用这些索引查找一个含256色的调色板,调色板的每笔记录可能存储为RGB888或其他合适的格式

在三维渲染中,还会用到其他一些颜色模型. 在10.3.1.5节会介绍如何使用对数LUV颜色模型做高动态范围(high dynamic range, HDR)渲染

不透明度和alpha通道

常会在RGB颜色矢量之后再补上一个名为alpha的通道.alpha值用来量度物体的不透明度.当存储为像素时,alpha代表该像素的不透明度

RGB颜色格式可扩展以包含alpha通道,那时候就会称为RGBA或ARGB颜色格式.例如,RGBA8888是每像素32位的格式,红,绿,蓝,alpha都使用8位.又例如,RGBA5551是16位格式,含1位alpha.此格式中,颜色只能指定位完全不透明或完全透明

      10.1.2.2 顶点属性

要描述表面的视觉特性,最简单的方法就是把这些特性记录在表面的离散点上.网格的顶点是存储表面特性的便利位置,这种存储方式称为顶点属性(vertex attribute)

一个典型的三角形网格中,每个顶点包含部分或全部以下所列举的属性.身为渲染工程师,我们当然能自由地定义额外所需的属性,达致在屏幕上想要的视觉效果

  • 位置矢量 (position vector) Pi = [pix piy piz]: 这是网格中第i个顶点的三维位置.位置矢量通常以物体局部空间的坐标表示,此空间名为模型空间(model space).
  • 顶点法矢量 (vertex normal) ni  = [nix niy niz]: 这是顶点i位置上的表面单位矢量.顶点法矢量用于每顶点动态光照(per-vertex dynamic lighting)的计算
  • 顶点切线矢量 (vertex tangent) ti = [tix tiy tiz]: 这是和顶点副切线矢量(vertex bitangent) bi = [bix biy biz]互相垂直的单位矢量,它们也同时垂直于顶点法矢量ni.这3个矢量ni,ti,bi能一起定义称为切线空间(tangent space)的坐标轴.此空间能用于计算多种逐像素光照(per-pixel lighting),例如法线贴图(normal mapping)及环境贴图(environment mapping).(副切线矢量有时候被称为副法矢量(binormal),尽管它并非垂直于表面)
  • 漫反射颜色 (diffuse color) di = [dRi dGi dBi dAi]: 漫反射颜色是一个四元素矢量,以RGB颜色空间描述表面的漫反射颜色.此顶点属性通常附有不透明度,即alpha(A).此颜色可能在脱机时计算(静态光照),或运行时计算(动态光照)
  • 镜面颜色 (specular color) si = [sRi sGi sBi sAi]: 当光线由光滑表面反射至虚拟摄像机影像平面,这个矢量就是描述其镜面高光的颜色
  • 纹理坐标 (texture coordinates) uij = [uij vij]: 用来把二维(有时候三维)的位图"收缩包裹"网格的表面,此过程称为纹理贴图(texture mapping).纹理坐标(u,v)描述某顶点在纹理二维正规化坐标空间里的位置.每个三角形可贴上多张纹理,因此网格可以有超过一组纹理坐标.我们采用下标j去表示不同的纹理坐标组
  • 蒙皮权重 (skinning weight) (kij , wij): 在骨骼动画里,网格的顶点依附在骨骼的个别关节之上.这种情况下,每个顶点需指明其依附着的关节索引k.另一种情况是,一个顶点受多个关节所影响,最终的顶点位置变为这些影响的加权平均(weighted average).我们把每个关节的影响以权重因子w表示.概括地说,顶点i可由多个关节j所影响,每个影响关系可存储为两个数值[kij , wij]

      10.1.2.3 顶点格式

顶点属性通常储存于如C struct或C++ class的数据结构.这样的数据结构的布局称为顶点格式(vertex format).不同的网格需要不同的属性组合,因而需要不同的顶点格式.以下是一些常见的顶点格式例子:

// 最简单的顶点,只含位置(可用于阴影体伸展/shadow volume extrusion,
// 卡通渲染中的轮廓棱检测/silhouette edge detection, z预渲染/z-prepass等

struct Vertex1P {

  Vector3  m_p;  // 位置

};

// 典型的顶点格式,含位置,顶点法线及一组纹理坐标

struct Vertex1P1N1UV {

  Vector3  m_p;  // 位置

  Vector3  m_n;  // 顶点法矢量

  F32   m_uv[2];  // (u, v) 纹理坐标

};

// 蒙皮用的顶点,含位置,漫反射颜色,镜面反射颜色及4个关节权重

struct Vertex1P1D1S2UV4J {

  Vector3  m_p;  // 位置

  Color4  m_d;  // 漫反射颜色及透明度

  Color4  m_S;  // 镜面反射颜色

  F32  m_uv0[2];  // 第1组纹理坐标

  F32  m_uv1[2];  // 第2组纹理坐标

  U8  m_k[4];  // 蒙皮用的4个关节索引及

  F32  m_w[3];  // 3个关节权重(第4个由其他3个求得)

};

显然,顶点属性的可行排列数目,以至于不同的顶点格式数目,都可增长至非常庞大.(实际上,若能使用任意数目的纹理坐标或关节权重,格式数目在理论上是无上限的)管理所有这些顶点格式,经常是图形程序员的头痛之源

      10.1.2.4 属性插值

三角形顶点的属性仅仅是整个表面的视觉特性的粗糙,离散近似值.当渲染三角形时,重要的是三角形内点的视觉特性,这些内点最终成为屏幕上的像素.换言之,我们需要取得每像素(per-pixel)的属性,而非每顶点(per-vertex)的

要取得网格表面的每像素属性,最简单的方法是对每顶点属性进行线性插值(linear interpolation).当把线性插值施于顶点颜色,这种属性插值便称为高氏着色法(Gouraud shading).图10.11是以高氏着色法渲染三角形的例子,图10.12则是把它应用在三角形网格时的效果.插值法也会应用至其他各种顶点属性,例如,顶点法矢量,纹理坐标,深度等

顶点法线及圆滑化

光照(lighting)是基于物体表面的视觉特性以及到达该表面的光线特性,来计算物体表面上各点的颜色的过程.光照网格的最简单方法就是,逐顶点(per-vertex)计算表面的颜色.换句话说,我们使用表面特性及入射光计算每个顶点的漫反射颜色(di).然后,这些顶点颜色会经由高氏着色法,在网格的三角形上插值

为了计算表面某点的光线反射量,多数光照模型会利用在该点垂直于表面的法矢量.由于我们以逐顶点方式计算光照,所以此处可使用顶点法矢量ni.也因此,顶点法矢量的方向对于网格的最终外观有重要影响

例如,假设有一个高瘦的长方体.若我们想使长方体的边缘显得更锐利,那么可以使每个顶点法矢量与长方体的面垂直.计算每个三角形的光照时,3个顶点的法矢量是一模一样的,因此光照的结果显示为平面,并且在长方体顶点上的光照会如同顶点法矢量一样地做出突然转变

我们也可以令相同的长方体网格显得更像一个圆滑的圆柱体,方法是把顶点法矢量改为自长方体的中线向外发散.此情况下,每个三角形上的顶点法矢量变得不同,引致其光照结果也不一样.利用高氏做色法为这些顶点颜色进行插值时,会使光照效果顺滑地在表面上过渡

      10.1.2.5 纹理

若三角形比较大,以逐顶点方式设置表面性质可能太过粗糙.线性的属性插值也非总是我们想要的,并且这种插值会引起一些视觉上的问题

例如,渲染光滑物体的镜面高光(specular highlight)时,使用逐顶点光照会出现问题.通过把网格高度进行镶嵌,再使用逐顶点光照配合高氏着色法,可以做出相当不错的效果.然而,当三角形太大时,对镜面高光做线性插值所形成的误差便会非常明显

要克服逐顶点表面属性的限制,渲染工程师通常会使用称为纹理贴图(texture map)的位图影像.纹理通常含有颜色信息,并且一般会投射在网格的三角形上.这种使用纹理的方法,就好像我们小时候把那些假纹身印在手臂上.但其实纹理也可以存储颜色以外的视觉特性.而且纹理也不一定用来投射于网格上,例如,可以把纹理当作存储数据的独立表格.纹理中的每个单独像素称为纹素(texel),用以区分于屏幕上的像素

在某些图形硬件上,纹理位图的尺寸必须为2的幂.虽然纹理通常只能塞进显存,多数硬件没有对其尺寸设限,但一般纹理的尺寸会是256 x 256, 512 x 512, 1024 x 1024 及 2048 x 2048等.有些图形硬件会加一些额外限制,例如要求纹理必须为正方形;有些硬件会接触一些限制,例如能接受2的幂以外的尺寸

纹理种类

最常见的纹理种类为漫反射贴图(diffuse map),又称作反照率贴图(albedo map). 漫反射贴图的纹素存储了表面的漫反射颜色,这好比在表面上贴上贴纸或涂上漆油

计算机图形学里也会使用其他种类的纹理,包括法线贴图(normal map)----每个纹素用来存储以RGB值编码后的法矢量,光泽贴图(gloss map)----在每个纹素上描述表面的光泽程度,环境贴图(environment map)----含周围环境的图像以渲染反射效果,此外还有各种各样的纹理种类.

事实上,纹理贴图可以存储任何在计算着色时所需的信息.例如,可以用一维的纹理存储复杂数学函数的采样值,颜色对颜色的映射表,或其他查找表(lookup table, LUT)

纹理坐标

我们现在讨论如何将二维的纹理投射至网格上.首先,我们要定义一个称为纹理空间(texture space)的二维坐标系.纹理坐标通常以两个归一化的数值(u, v)表示.这些坐标的范围是从纹理的左下角(0,0)伸展至右上角(1,1).使用这样的归一化纹理坐标,好处是这些坐标不会受纹理尺寸影响

要把三角形映射至二维纹理,只需要在每个顶点i上设置纹理坐标(ui,vi).这样实际上就是把三角形映射至纹理空间的影像平面上.

纹理寻址模式

纹理坐标可以延伸至[0,1]范围之外.图形硬件可用以下几种方式处理范围以外的纹理坐标.这些处理方式称为纹理寻址模式(texture addressing mode),可供用户选择

  • 缠绕模式 (wrap mode): 此模式中,纹理在各方向重复又重复.所有形式为(ju, kv)的纹理坐标等价于(u,v),当中j和k是任何整数
  • 镜像模式 (mirror mode):  此模式和缠绕模式相似,不同之处在于,在u为奇数倍数上的纹理会在v轴方向形成镜像,在v为奇数倍数上的纹理会在u轴方向形成镜像
  • 截取模式 (clamp mode): 此模式中,当纹理坐标在正常范围之外,纹理的边缘纹素会简单地延伸
  • 边缘颜色模式 (border color mode): 此模式下用户能指定一个颜色,当纹理坐标在[0,1]范围以外时使用

纹理格式

纹理位图可在磁盘上储存为任何格式的文件,只要你的游戏引擎含有读取该文件至内存的代码便可.常见的文件格式有Targa(TGA),便携式网络图形(Portable Network Graphics, PNG),视窗位图(Windows bitmap, BMP), 标记图像文件格式(Tagged Image File Format, TIFF).纹理存于内存时,通常会表示为二维像素数组,当中像素使用某种颜色格式,例如,RGB888, RGBA8888, RGB565, RGBA5551等

多数现在的显卡及图形API都会支持压缩纹理(compressed texture)

纹素密度及多级渐远纹理

想象我们要渲染一个满屏的四边形(两个三角形组成的长方形),此四边形还贴上一张纹理,其尺寸刚好配合屏幕的分辨率.在这种情况下,每个纹素刚好对应一个屏幕像素,我们称其纹素密度(texel density,即纹素和像素之比)为1.当在较远距离观看该四边形时,其屏幕上的面积就会变小.由于纹理的尺寸不变,该四边形的纹素密度就会大于1,即每个像素会受多于一个纹素所影像

显然纹素密度并不是一个常量,它会随物体相对摄像机的距离而改变.纹素密度影响内存使用量,也影响三维场景的视觉品质.当纹素密度远低于1,每个纹素就会显著比屏幕像素大,那么就会开始察觉到纹素的边缘.这会毁灭游戏的真实感.当纹素密度远高于1,许多纹素会影响单个屏幕像素.这会产生如图10.7所示的莫列波纹(moire banding pattern).更甚者,由于像素边缘内的多个纹素会按细微的摄像机移动而不断改变像素的颜色,像素的颜色就会显得浮动不定及闪烁.而且,若玩家永不会接近一些远距离的物体,用非常高的纹素密度渲染那些物体只是浪费内存.毕竟若无人能看见其细节,在内存保留高分辨率纹理又有何用?

理想地,我们希望无论物体是近是远,仍然维持纹素密度接近于1.要准确地维持此约束是不可能的,但可以使用多级渐远纹理(mipmapping)技术来逼近.其方法是,对于每张纹理,我们建立较低分辨率位图的序列,当中每张位图的宽度和高度都是前一张位图的一半.我们称这些影像为多级渐远纹理(mipmap)或渐远纹理级数(mip level).

 

世界空间纹素密度

纹素密度一词,也可用于描述纹素和贴图表面的世界空间面积之比.例如,2米宽的正方形贴上256 x 256纹理,其纹素密度就是2562 / 22  = 16384.为了和之前所谈的屏幕空间纹素密度区别,笔者把此密度称为世界空间纹素密度(world space texel density)

纹理过滤

当渲染纹理三角形上的像素时,图形硬件会计算像素中心落入纹理空间的位置,来对纹理贴图采样.通常纹素和像素之间并没有一对一的映射,像素中心可以落入纹理空间的任何位置,包括在两个或以上纹素之间的边缘.因此,图形硬件通常需要采样出多于一个纹素,并把采样结果混合以得出实际的采样纹素颜色.此过程称为纹理过滤(texture filtering)

多数显卡支持以下的纹理过滤种类

  • 最近邻 (nearest neighbor): 这种粗糙方法会挑选最接近像素中心的纹素.当使用多级渐远纹理时,此方法会挑选一个渐远纹理级数,该级数最接近但高于理想的分辨率.理想分辨率是指达到屏幕空间纹素密度为1
  • 双线性 (bilinear): 此方法会对围绕像素中心的4个纹素采样,并计算该4个颜色的加权平均(权重是基于纹素和像素中心的距离).当使用多级渐远纹理时,也是选择最接近的级数
  • 三线性 (trilinear): 此方法把双线性过滤法施于最接近的两个渐远纹理级数(一个高于理想分辨率,一个低于理想分辨率),然后把两个采样结果线性插值.这样便能消除屏幕上碍眼的,相邻渐远纹理级数之间的边界
  • 各向异性 (anisotropic): 双线性和三线性过滤都是对2 x 2的纹素块采样.如果纹理表面是刚好面对着摄像机的,这样是正确的做法.然而,若表面倾斜于虚拟屏幕表面,这就不太正确了.各向异性过滤法会根据视角,对一个梯形范围内的纹理采样,借以提高非正对屏幕的纹理表面的视觉品质

      10.1.2.6 材质

材质(material)是网格视觉特性的完整描述.这包括贴到网格表面的纹理设置.

 

 

 

    10.1.3 光照基础

    

      10.1.3.1 局部及全局光照模型

 

 

 

      10.1.3.2 Phong氏光照模型

 

Blinn-Phong

 

BRDF图表

 

 

 

 

      10.1.3.3 光源模型

 

静态光照

 

环境光

 

平行光

 

点光/全向光

 

聚光

 

面积光

 

发光物体

 

 

 

 

    10.1.4 虚拟摄像机

 

 

 

      10.1.4.1 观察空间

 

 

      10.1.4.2 投影

 

      10.1.4.3 观察体积及平截头体

 

      10.1.4.4 投影及齐次裁剪空间

 

透视投影

 

除以Z

 

透视正确的顶点属性插值

 

正射投影

 

 

 

 

      10.1.4.5 屏幕空间及长宽比

 

      10.1.4.6 帧缓冲

 

渲染目标

 

 

 

      10.1.4.7 三角形光栅化及片段

 

抗锯齿

 

 

 

      10.1.4.8 遮挡及深度缓冲

 

深度冲突及W缓冲

 

 

            

 

  10.2 渲染管道

  

    10.2.1 渲染管道概观

 

      10.2.1.1 渲染管道如何变换数据

 

      10.2.1.2 管道的实现  

 

    10.2.2 工具阶段

 

    10.2.3 资产调节阶段

 

    10.2.4 GPU简史

 

    10.2.5 GPU管道

 

      10.2.5.1 顶点着色器

 

      10.2.5.2 几何着色器

 

      10.2.5.3 流输出

 

      10.2.5.4 裁剪

 

      10.2.5.5 屏幕映射

 

      10.2.5.6 三角形建立

 

      10.2.5.7 三角形遍历

 

      10.2.5.8 提前深度测试

 

      10.2.5.9 像素着色器

 

      10.2.5.10 合并/光栅运算阶段

 

    10.2.6 可编程着色器

 

      10.2.6.1 内存访问

 

着色器寄存器

 

纹理

 

      10.2.6.2 高级着色器语言的语法入门

 

      10.2.6.3 效果文件

 

      10.2.6.4 延伸阅读

 

    10.2.7 应用程序阶段

    

      10.2.7.1 可见性判断

 

平截头体剔除

 

遮挡及潜在可见集

 

入口

 

遮挡体积(反入口)

 

    10.2.7.2 提交图元

 

渲染状态

 

状态泄露

 

GPU命令表

 

    10.2.7.3 几何排序

 

深度预渲染步骤是救星

 

    10.2.7.4 场景图

 

四叉树和八叉树

 

包围球树

 

BSP树

 

    10.2.7.5 选择场景图

 

 

 

  10.3 高级光照及全局光照

 

    10.3.1 基于图像的光照

 

      10.3.1.1 发现贴图

 

      10.3.1.2 高度贴图: 视差贴图和浮雕贴图

 

      10.3.1.3 镜面/光泽贴图

 

      10.3.1.4 环境贴图

     

      10.3.1.5 三维纹理

 

    10.3.2 高动态范围光照

 

    10.3.3 全局光照

 

      10.3.3.1 阴影渲染

 

阴影体积

 

阴影贴图

 

      10.3.3.2 环境遮挡

 

      10.3.3.3 镜像

 

      10.3.3.4 焦散

 

      10.3.3.5 次表面散射

 

      10.3.3.6 预计算辐射传输

 

    10.3.4 延迟渲染

 

 

  10.4 视觉效果和覆盖层

 

    10.4.1 粒子效果

 

    10.4.2 贴花

 

    10.4.3 环境效果

 

      10.4.3.1 天空

 

      10.4.3.2 地形

 

      10.4.3.3 水体

 

    10.4.4 覆盖层

 

      10.4.4.1 归一化屏幕坐标

 

      10.4.4.2 屏幕相对坐标

 

      10.4.4.3 文字及字体

 

    10.4.5 伽马校正

 

    10.4.6 全屏后期处理效果

 

    

 

  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的编辑器(曾命名作WorldcraftThe 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;
View Code

      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;
    }
};
View Code 

      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];
    // ......
};
View Code

    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 世界组块的数据格式

如前所述,世界组块通常同时包含了静态动态世界元素.静态几何体可能使用一个巨型三角形网格表示,或是由许多较小的网格所组合而成.每个网格可产生多个实例,例如,一道门的网格会重复地用于组块中所有门.静态数据通常包含了碰撞信息,其形式可以是三角形汤,凸形状集,及/或其他更简单的几何形状,如平面,长方体,胶囊体和球体.静态元素还有体积区域(volumetric region),用于侦测事件或勾画游戏中不同地域.另外,静态元素也可能包含人工智能导航网格(navigation mesh),这些导航网格是一组线段,勾画出背景几何中角色可行走的路段

世界组块里的动态部分包含该组块内游戏对象的某种表现形式.游戏对象以其属性行为来定义,而对象的行为则是直接或间接地取决于它的类型.在以对象为中心的设计中,游戏对象的类型直接决定要实例化哪一个类(或哪些类),以在运行时表示该游戏对象.而在以属性为中心的设计中,游戏对象的行为是由其属性的行为融合而成的,但其类型仍然决定了哪个对象应含什么属性(另一种讲法是对象的属性定义其类型).因此,一般对每个游戏对象而言,世界组块数据文件包含了:

  对象属性的初始值: 世界组块定义了每个对象在游戏世界中诞生时应有的状态.对象的属性数据可储存为多种格式.

  对象类型的某种规格: 在以对象为中心的引擎中,此规格可能是字符串,字符串散列标识符,或其他唯一的类型标识符.而在以属性为中心的设计中,类型可能会显示储存,或是定义为组成对象的属性集合

    14.3.1 二进制对象映像

要把一组游戏对象储存于磁盘,其中一个方法是把每个对象的二进制映像(binary image)写入文件,映像和对象在运行时于内存中的样子完全相同.这么做,产生对象似乎是极简单的工作.当游戏世界组块读入内存后,我们已获得所有对象已预备好的映像,所以简单地令它们工作

嗯,实际上并非如此简单.把"现场"的C++类实例储存为二进制映像,会遇到几个难题.例如需要对指针和虚表做特殊处理,也有可能要为字节序问题交换实例中的数据.而且,二进制对象映像并无弹性,难以恰当地对其内容进行修改.游戏性是游戏项目中最充满变数,不稳定的部分,因此,选择能支持快速开发及能健壮经常修改的数据格式最为明智.所以,二进制映像格式通常并不是储存游戏对象的最佳之选(虽然此格式可能适合更稳定的数据结构,例如网格数据或者碰撞几何)

    14.3.2 游戏对象描述的序列化

序列化(serialization)是另一种把游戏对象内部状态表示方式储存至磁盘文件的方法.此方法相比二进制对象技术,往往更可携及更容易实现,要把某对象序列化至磁盘,就需要该对象产生一个数据流,当中要包含足够的细节,供日后重建原本的对象.要从磁盘的数据反序列化至内存时,首先要创建适当的类的实例,然后读入属性数据流,以初始化新对象的内部状态.若序列化数据是完整的,那么以我们所需的用途来说,新建对象应该等同于原本的对象

有些编程语言原生支持序列化.例如,C#和Java都提供标准机制序列化对象至XML文本格式,以及其反序列化.可惜C++语言并没有标准化的序列化机制.然而,在游戏业行内或行外,也开发了许多成功的C++序列化系统.我们不会在此讨论如何编写C++对象序列化系统的细节,但我们会讨论一下关于数据格式及开发C++序列化系统所必需的几个主要系统

序列化数据并不是对象的二进制映像.取而代之,序列化数据通常会储存为更方便及更可携的格式.XML是流行的对象序列化格式,因为它既有良好的支持也获标准化,又较易于供人阅读.XML对层次数据结构有非常优秀的支持,这是序列化游戏对象集合时经常需要的.然而,解析XML之慢众所周知,这可能增加世界组块的加载时间.因此,有些游戏引擎采用自定义的二进制格式,解析时比XML快而且j紧凑

把对象序列化至磁盘,以及从磁盘反序列化,通常可以实现为以下两种机制之一

  在基类加入一对虚函数,如SerializeOut()和SerializeIn(),然后在每个派生类是实现这两个函数,说明如何序列化该类

  实现一个C++类的反射(reflection)系统.那么就可以开发一个通用的系统去自动序列化任何包含反射信息的C++对象

反射是C#及其他一些语言的术语.概括地说,反射数据描述了类在运行时的内容.这些数据所储存的信息包括类的名称,类中的数据成员,每个数据成员的类型,每个成员位于对象内存映像的偏移(offset),此外,它也包含类的所有成员函数信息.若能获取任何一个C++类的反射信息,开发通用的对象序列化系统是挺简单的一回事

然而,C++反射系统中最棘手的地方在于,生成所有相关类的反射数据.其中一个方法是,使用#define对类中每个数据成员抽取相关的反射数据,然后让每个派生类重载一个虚函数以返回该类相关的反射数据.也可以手工地为每个类编写反射的数据结构,又或是使用其他别出心裁的方法

除了属性信息,序列化数据流中的每个对象总是会包含该类/类型的名字或唯一标识符.类标识符的作用是,当把对象反序列化至内存时,用来实例化适当的类.类标识符可以是字符串,字符串散列标识符,或是其他种类唯一标识符

遗憾的是,C++并没有提供以字符串或标识符去实例化的方法.类的名称必须在编译时决定,因此程序员必须要硬编码类的名称(如new ConcreteClass).为了绕过此语言限制,C++对象序列化系统总是含有某种形式的类工厂(class factory).工厂可以用任何方式实现,但最简单的方法是建立一个数据表,当中把类的名称/标识符映射至一个函数或仿函数对象(functor object),后者用硬编码方式去实例化该类.给定一个类的名称或标识符,我们可以在那个表里简单地查找到对应的函数或仿函数,并调用它来实例化该类

    14.3.3 生成器及类型架构

二进制对象映像和序列化格式都有一个致命要害.这两种储存格式都是由对象类型的运行时实现所定义的,因此世界编辑器需要深入知道游戏引擎运行时实现才能运作.例如,为了令世界编辑器写出由多种游戏对象组成的集合,世界编辑器必须直接链接运行时游戏引擎代码,或是费尽苦心硬编码,以生成和游戏对象运行时完全相同的数据块.序列化数据与游戏对象实现之间的耦合比较低一点,但同样地,世界编辑器不与运行时游戏对象代码链接以使用其SerializeIn()及SerializeOut()函数,便需要以某种方式取得类的反射信息

为了解耦游戏世界编辑器和运行时引擎代码,我们可以把实现无关的游戏对象描述抽象出来.对于世界组块数据文件中的每个游戏对象,我们储存多一点数据,这组数据常称为生成器(spawner).生成器是游戏对象的轻量,仅含数据的表示方式,可用于在运行时实例化及初始化游戏对象,它含有游戏对象在工具方的类型标识符,也包含有一个简单键值对表描述游戏对象的属性初始值.这些属性通常包含了模型至世界变换,因为大多数游戏对象都有明确界定的世界空间位置,定向及缩放比例.当要生成对象时,就可以凭生成器的类型来决定实例化哪一个或多个类.然后这些运行时对象通过查表合适地初始化其数据成员

我们可以设置生成器在载入后立即生成对象,或是休眠等待,直至稍后需要时才生成对象.生成器可以实现为第一类对象(first-class object),令它能有一个方便的功能接口,又能在对象属性以外再储存一些有用的元数据.生成器甚至还有生成对象以外的用途.例如,在《神秘海域: 德雷克船长的宝藏》中,设计师采用生成器定义一些游戏中重要的点或坐标轴.我们称这些为位置生成器(position spawner)或定位器生成器(locator spawner).定位器在游戏中有多种用途,例如:

  • 定义人工智能角色的兴趣点
  • 定义一组坐标轴令多个动画能完美地同步播放
  • 定义粒子特效或音效的起始位置
  • 定义赛道中的航点(waypoint)
  • 等等

      14.3.3.1 对象类型架构

游戏对象的类型定义了其属性和行为.在基于生成器设计的游戏世界编辑器中,游戏对象类型可以由数据驱动的schema所表示.schema定义了哪些属性会在创建或修改对象时显露于用户.要在运行时生成某个类型的游戏对象,其工具方的对象类型可以用硬编码或数据驱动的方式,映射至一个或多个需实例化的类型

类型schema可存储为简单的文本文件,以供世界编辑器读取,并可供用户检视及编辑,以下是一个schema文件的样子:

enum LightType {
    Ambient, Directional, Point, Spot
}

type Light {
    String    UniqueId,
    LightType    Type;
    Vector    Pos;
    Quaternion    Rot;
    Float    Intensity: min(0.0), max(1.0);
    ColorARGB    DiffuseColor;
    ColorARGB    SpecularColor;
    ...
}

type Vehicle {
    String    UniqueId;
    Vector    Pos;
    Quaternion    Rot;
    MeshReference    Mesh;
    Int    NumWheels:    min(2), max(4);
    Float    TurnRadius;
    Float    TopSpeed: min(0.0);
    ...
}
View Code

有些引擎容许对象类型schema采用继承,和类的继承相似.例如,所有游戏对象需要知道其类型,并对应一个唯一标识符,以便在运行时和其他游戏对象区分.这些属性可以在顶级schema中指定,其他schema则可以继承这个顶级schema

      14.3.3.2 属性默认值

      14.3.3.3 生成器及类型架构的好处

把生成器和游戏对象分开实现,其主要优点就是简单,富弹性健壮性(robustness).从数据管理的角度来说,处理键值对组成的表.相比管理需指针修正的二进制对象映像,或是自定义的对象序列化格式都简单得多.采用键值对也可为数据格式带来极大的弹性,而且可以健壮地做出改动.若游戏对象遇到预料之外的键值对,可以简单地忽略它们.相似地,若游戏对象未能找到所需的键值对,可选择使用默认值.因此,游戏设计师和程序员改动游戏对象类型时,键值对的数据格式仍可以极健壮地配合

生成器也简化了游戏世界编辑器的设计和实现,因为世界编辑器仅需要知道如何管理键值对及对象类型schema.它不需要与游戏引擎运行时共享代码,并且和引擎实现的细节维持非常松的耦合

生成器和原型(archetype)令游戏设计师及程序员拥有高度弹性及强大力量.设计师可以在世界编辑器中定义新的游戏对象类型schema,过程中无须或只需少许程序员的介入.而程序员可以按自己的时间表实现运行时的对象.程序员无须为了防止游戏不能运行,每次加入新对象类型时便立即实现该对象.无论有没有运行时实现,新对象的数据都可以存于世界组块文件中;无论世界组块中有没有相关数据,运行时的实现都可以存在

  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

转载于:https://www.cnblogs.com/revoid/p/10978382.html

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值