大部分二维或三维的电子游戏,可以被称为软实时、互动、基于代理、计算机模拟的例子
在大部分电子游戏中会用数学方式来为一些真实世界(或想象世界)的子集建模,从而使这些模型能在计算机中运行。数学模型是现实或虚拟世界的模拟,运行近似话和简化是游戏开发者的有力工具。
基于代理模拟是指:模拟中多个独立的实体(称为代理)一起互动。
所有互动电子游戏都是时间性模拟,即游戏世界是动态的随着游戏事件和故事的展开,游戏世界状态随着时间改变。游戏也必须回应人类玩家的输入,这些输入是游戏本身不可预知的,因而也说明游戏是互动时间性模拟。最后,多数游戏会描绘游戏的故事,并实时回应玩家输入,这使游戏成为互动实时模拟
时限是所有实时模拟的核心概念,明显的例子是需要屏幕每秒最少刷新24次,以制造运动的错觉
软实时系统是指一些系统,即使错过时限却不会造成灾难性的后果,因此所有游戏都是软实时系统,如果帧数不足,人类玩家也不会在现实中受伤甚至死亡
相对的硬实时系统错误时限可能会导致操作者损伤甚至死亡,比如直升机的航空电子系统
数据驱动框架或许可以用来分辨一个软件的哪些部分是引擎,哪些部分是游戏。若一个游戏包含硬编码逻辑或游戏规则,或使用特例代码去渲染特定种类的游戏对象,则服用该软件去制作新游戏就会变得困难甚至不可行。因此,这里说的“游戏引擎”是指可扩展的软件,而且不需要大量修改就能成为多款游戏软件的基础。
游戏引擎或中间件组件越通用,在特定平台运行特定游戏的性能越一般
设计高效的软件总是需要取舍,而这些取舍是基于一些假设,像是一个软件会如何使用及在那个硬件上运行等。
随着计算机硬件速度的提高及专用显卡的应用,再加上高效的渲染算法及数据结构,不同游戏类型的图形引擎差异已经缩小。但是,通用性和最优性仍然需要取舍。按照游戏/硬件平台的特定需求及限制,经常可以通过微调引擎制作更精美的游戏。
大部分现代3D游戏都是由虚拟世界里的三维物体组成的。游戏引擎须记录这些物体的位置(position)、定向(orientation)和比例(scale),不断改变这些属性以产生动画,并把这些属性变换(transform)至屏幕空间,使物体能渲染在屏幕上
在游戏中,三维物体几乎都是由三角形组成的,其中的三角形顶点(vertex)则以点(point)表示
点和迪卡儿坐标
虽然笛卡尔坐标系是游戏编程中最广泛使用的坐标系,但是应为当前问题选择最适合的坐标系
左手坐标系和右手坐标系
左右手坐标系相互转换十分容易,只需要把其中一个轴反转,并保留另外两个轴不变即可。
左右手坐标系的切换不影响数学公式的计算,左右手约定只应用在可视化过程中,并不影响底层里的数学。
矢量
矢量指n维空间中包括模和方向的量。矢量可绘画成有向线段,线段自一点(尾)延申至另一头(头)。矢量和标量比较,标量有模但没有方向
严格地说,矢量是相对于某已知点的偏移
矢量也可以用来表示点,只要把其尾固定在坐标系的原点。这些矢量有时候被称为位置矢量或矢径
。。。
C++原生的启动和终止语意,在程序启动进入main之前,我们需要全局静态对象构建,但是这些对象的构建顺序我们完全无法确定,因此常见的设计模式为每个子系统定义单例类,通常称作管理器
class RenderManager{
RenderManager()
{
// 启动管理器
}
~RenderManager()
{
// 终止管理器
}
};
static RenderManager gRenderManager;
上述为全局静态管理器的定义和创建,不能确定多个静态对象管理器的创建顺序
class RenderManager
{
static RenderManager& get()
{
static RenderManager sSingleton;
return sSingleton;
}
RenderManager()
{
// 对于渲染管理器需要的其他管理器进行初始化,启动
VideoManager::get();
TextureManager::get();
// 启动渲染管理器
}
~RenderManager()
{
// 终止管理器
}
}
一般推荐上述方法初始化构建方法
static RenderManager& get()
{
static RenderManager* gpSingleton = NULL;
if(gpSingleton == NULL)
{
gpSingleton = new RenderManager();
}
ASSERT(gpSingleton);
return *gpSngleton;
}
上述两种方法可以控制构造顺序,但是无法确定析构顺序,有可能其他构造器提前析构掉了
RenderManager
需要的管理器
行之有效的解决方法:在构建和析构的不做任何事情,这样的话可以在管理列表中按所需的明确次序调用各自的启动和中止函数
class RenderManager
{
public:
RenderManager() {}
~RenderManager() {}
void startUp() { // 启动管理器 }
void shutDown() { // 中止管理器 }
}
class PhysicsManager { // 上述类似 }
class AnimationManager { // 上述类似 }
class AMemoryManager { // 上述类似 }
class FileSystemManager { // 上述类似 }
RenderManager g_renderManager;
PhysicsManager g_physicsManager;
AMemoryManager g_amemoryManager;
FileSystemManager g_fileSystemManager;
int main()
{
// 按正确顺序调用启动函数
g_amemoryManager.startUp();
g_fileSystemManager.startUp();
g_renderManager.startUp();
g_physicsManager.startUp();
// 运行游戏
game.exec()
// 反向顺序中止引擎
g_physicsManager.shutDown();
g_renderManager.shutDown();
g_fileSystemManager.shutDown();
g_amemoryManager.shutDown();
return 0;
}
蛮力方法实现管理器的构建和析构
除了蛮力方法外,还有通过将管理器登记到全局优先队列中,然后再按恰当次序逐一启动管理器和关闭管理器。
也可以通过每个管理器举例其依赖的管理器,定义一个管理期间的依赖图,然后按依赖关系计算最优的启动次序
推荐使用蛮力方法构建和析构
内存对效能的影响有两方面
优化动态内存分配
通过malloc()/free()或者C++的new/delete运算符动态分配内存,又称为堆分配——通常是非常慢的
首先,堆分配器是通用的设置,它必须处理任何大小的分配请求,从1字节至1000兆字节。这需要大量的管理开销,导致malloc()/free函数变得缓慢
其次,在多数操作系统上,malloc()/free()必然会从用户模式切换到内核模式->处理请求->再切换回原来的程序。这些上下文切换可能会耗费非常多的时间。
因此维持最低限度的堆分配,并且永不在紧凑循环中使用堆分配
任何游戏引擎都无法完全避免动态内存分配,所以多数游戏引擎会实现一个或多个定制分配器。定制分配器能享有比操作系统分配器更优的性能特征。
第一,定制分配器从顶分配的内存中完成分配请求(预分配的内存来自maloc(),new,或声明为全局变量)。这样分配过程都在用户模式下执行,完全避免了进入操作系统的上下文切换。
第二,通过对定制分配器的使用模式做出多个假设,定制分配器可以比通用的堆分配器高效的多
基于堆栈的分配器
许多游戏会以堆栈般的形式分配内存,当载入游戏关卡时,就会为关卡分配内存;关卡载入后,就会很少甚至不会动态分配内存。再玩家完成关卡之际,关卡的数据会被卸下,所有关卡占用的内存也可被释放。对于这类内存分配,非常适合采用堆栈形式的数据结构
堆栈分配器我们要分配一大块连续内存,可简单地是使用malloc()、全局new、或是声明一个全局字节数组(最后的办法,实际上会从可执行文件的BSS段里分配内存)。另外要安排一个指针指向堆栈的顶端,指针以下的内存是已分配的,指针以上的内存则是未分配的。
对于每个分配亲求,仅需把指针网上移动请求所需的字节数量。要释放最后的分配的内存块,也只需要把指针向下移动该内存块的字节数量。
注意使用对阵分配器时,不能以任意次序释放内存,必须以分配时相反的次序释放内存。有一个方法可简单的实现此限制,这就是完全不容许释放个别的内存块。取而代之,我们提供一个函数,改函数可以把堆栈顶端指针回滚至之前标记了的位置,那么其实际上的意义就是,释放从回滚点至目前堆栈顶端之间的所有内存。
class StackAllocator
{
public:
// 堆栈标记:表示堆栈的当前顶端
// 用户只可以回滚至一个标记,而不是堆栈的任意位置
typedef U32 Marker;
// 给定总大小,构建一个对战分配器
explicit StackAllocator(U32 stackSize_bytes);
// 给定内存块大小,从堆栈顶端分配一个新的内存块
void* alloc(U32 size_bytes);
// 取得指向当前堆栈顶端的标记
Marker getMarker();
// 把堆栈回滚至之前的标记
void freeToMarker(Marker marker);
// 清空整个堆栈(把堆栈归零)
void clear();
private:
// 其他
}
双端堆栈分配器一块内存其实可以给两个堆栈分配器使用,一个从内存块的底端向上分配,另一个从内存块的顶端向下分配。双端堆栈分配器很实用,因为它容许权衡底端堆栈和顶端堆栈的使用,使它更有效地运行内存。
在Midwat地《Hydro Thunder》街机游戏中,所有内存都是分配自单个巨大内存块,以双端堆栈分配器管理的。底堆栈用来载入及卸下游戏关卡;而顶堆栈用来分配临时内存块,这些临时内存会在每帧中分配及释放。
池分配器
在普遍的软件工程中,常会分配大量同等尺寸的小块内存,例如:分配及释放矩阵、迭代器、链表中的节点、可渲染的网络实例等。池分配器是此类分配模式的完美选择
池分配器的工作方式:
存储自由资源的链表可实现为单链,即每个自由元素需要存储有一个指针(多数机器上为4字节);或者自由列表内的内存块按定义来说是可用内存,可以用这些内存本身来储存自由列表的”next“指针
含对齐功能的分配器
所有内存分配器都必须能传回对齐的内存块。要实现这个功能十分容易,只要在分配内存时,分配比请求所需多一点的内存,再向上调整其内存地址至适当的对齐,最后传回调整后的地址。右与我们分配了多一点的内存,即使把地址往上调整,传回的内块仍够大
没搞懂!
单帧和双缓冲内存分配器
几乎所有游戏都会在游戏循环中分配一些临时用数据。这些数据要么在循环迭代结束时丢弃,要么可在下一迭代结束时丢弃。很多游戏引擎都支持这两种分配模式,分别称为单帧分配器和双缓冲分配器
单帧分配器
要实现单帧分配器,先预留一块内存,并以前面说的简单对阵分配管理。在每帧开始时,都把堆栈的顶端指针重置到内存块的底端地址。在该帧中,分配要求会使堆栈向上成长。此过程不断重复。
StackAllocator g_singleFramAllocator;
// 主游戏循环
while(true)
{
// 单帧清除单帧分配器的缓冲区
g_singleFramAllocator.clear();
// 其他操作、
……
// 从单帧分配器分配内存
// 我们用不需要手动释放这些内存!但要确定这些内存仅在本帧中使用
void*p = g_singleFramAllocator.alloc(nBytes);
// ...
}
单帧分配器的主要益处是,分配了的内存永不用手动释放,我们依赖于每帧开始时分配器会自动清除所有内存。 单帧分配器也及其高效
但是单帧分配器的最大缺点在于,程序员必须有不做的自制能力。程序员需要意识到,从单帧分配器分配的内存块只在目前的帧里有效。程序员绝不能把指向单帧内存块的指针跨帧使用
双缓冲分配器
双缓冲分配器容许在第i帧分配的内存块用于第(i+1)帧。实现方法就是建立两个相同尺寸的单帧退栈分配器,并在每帧较低使用
class DoubleBufferedAllocator
{
U32 m_curStack;
StackAlllocator m_stock[2];
public:
void swapBuffers()
{
m_ucrStack = (U32)!m_curStack;
}
void clearCurrentBuffer()
{
m_stack[m_curStack].clear();
}
void* alloc(U32 mBytes)
{
return m_stack[m_curStack].alloc(nBytes);
}
// ...
}
内存碎片
内存堆分配的另一问题在于,会随时产生内存碎片。当程序启动时,其整个堆空间都是自由的。当分配一块内存时,一块合适尺寸的连续内存便会被交际为”使用中“,而其余的内存仍然时自由的。当释放内存块时,该内存块便会与相邻的内存块合并。形成单个更大的自由内存块。随着时间的推移,鉴于以随机次序分配及释放不同尺寸的内存块,推内存开始变成自由块和使用中块所拼砌而成的拼布模样。我们可视自由区域为使用内存块之间的洞,如果洞的数量增多,并且洞的尺寸相对很小,就会称之为内存碎片状态
内存碎片的问题在于,就算有足够的自由内存,分配请求仍然可能会失败,因为分配的内存必须是连续的
在支持虚拟内存的操作系统上,虚拟内存系统把不连续的物理内存块——每块称为内存页——映射至虚拟地址空间,使内存页对于应用程序来说,看上去是连续的。
在物理内存不足时,久未使用的内存页便会写入磁盘,有需要时再重载到物理内存中。
多数嵌入式设备并不能负担得起虚拟内存的实现,有些游戏机,虽然技术上能支持虚拟内存,但右与其导致的开销,多数游戏引擎不会使用虚拟内存。(该句子来源2014年《游戏引擎架构》)
以堆栈和池分配器避免内存碎片
碎片整理及重定位
若要分配及释放不同大小的对象,并以随即次序进行,那么对阵和池分配器也不适用。对付这种情况,可以对堆定期进行碎片整理。碎片整理把所有自由的”洞“合并,其方法是把内存从高位移至低位
按照上面的图片是很容易理解的,但是如果我们事实上移动了已分配的内存块,若有指针指向这些内存块,移动内存便会使这些内存块失效
为了解决移动内存块导致指针失效的问题,一种解决方案是把指向内这些内存块的指针逐一更新,使移动内存块后这些指针能指到新的地址。此过程称之为重定向,但是C/C++没有这种功能,程序员需要小心手动维护所有指针;另一种方法是舍弃指针,取而代之,使用更容易重定向时修改的构建,例如职能指针或句柄
智能指针是细小的类,它包含一个指针,并且其实际行为几乎和普通指针完全相同,但是由于之恩那个指针是用类实现的,可以编写代码正确处理内存重定位。其中一个办法就是让所有职能指针把自己加进一个全局链表中。当要移动某块内存,便可以扫描该全局链表,更新每个指向该块内存的智能指针。
句柄通常实现为索引,这些索引指向句柄表内的元素,每个元素储存指针。句柄表本身不能被重定位。当要移动某已分配内存块时,就可以扫描句柄表,并自动 修改对应的指针。由于句柄只是句柄表的索引,无论如何移动内存块,句柄的值都是不变的。因此,使用句柄的对象用不受内存重定位影响。
对于某些可能不能被重定向的内存块——例如第三方库,该库不使用智能指针或句柄——让这些库在另一个特别缓冲区里分配内存,此缓冲区位于可重定向内存范围以外;或者干脆容许一些内存不能被重定向。
分摊碎片整理成本
因为碎片整理要复制内存块,所以操作过程可能很慢。然而我们无须一次性把碎片完全整理。取而代之,我们可以把碎片整理成本分摊至多个帧。规定每帧进行多大N次内存块移动,N是个小数目,如8或16
此方法只对细小的内存块有效,时移动内存块的时间短语每帧配合的重定位时间。如果重定位非常大的内存块,有时候可以把它拆分为两个或更多的小块,而每个小块可以独立被重定向。
缓存一致性
容器操作
迭代器
迭代器是一种细小的类,它知道如何高效的访问某类容器中的元素。迭代器像是数组索引或指针——每次它都会指向容器中某个元素,可以移至下一个元素,并能用某种方式表示是否已访问完容器中所有元素
建立自定义的容器类
C++程序员通常不直接处理字符数组,而比较喜欢使用字符串类。那么,该用哪一个字符串类呢?STL提供了不错的字符串,但如果决定启用STL,便免不了要自己重新实现
另一个字符串相关问题就是本地化——更改软件以发布其他语言的过程,也称作国际化。对每个向用户显示的字符串,都要实现翻译为需要支持的语言。除了通过使用合适的字体,为所有支持语言准备字符字形,游戏还需要处理不同的文本方向(比如希伯来文是由右至左阅读的)
如何处理内部字符串(不显示给用户看的字符串),对游戏的性能举足轻重。因为在运行期操作字符串本身的开销花费不小。比较或复制int、float数组,可以使用简单的机器语言指令完成。然而,比较字符串需要O(n)的的字符数组遍历,还要考虑为复制分配内存的开销。
字符串类
字符串类方便了程序员使用字符串,但是字符串类含有隐形成本,在性能分析之前难以预料。在传递字符串对象时,若函数的声明或使用不恰当,可能会引起一个或多个拷贝构造函数的开销。复制字符串时可能涉及动态内存分配,这会导致一个看似无伤大雅的函数调用,最终可能花费几千个机器周期
游戏编程时一般注意便面字符串类,如果需要使用务必查明其运行性能特性在可接受的范围,并让所有使用它的程序员知悉其开销。了解字符串类:是否把所有字符串缓冲区当作只读的?它是否使用了写入时复制优化?一个经验法则是,经常以参考引用形式传递对象,而不是以值传递
唯一标识符
在任何虚拟游戏中,游戏对象都需要某种唯一标识方法。使用唯一标识符,游戏设计师能逐一记录组成游戏世界的无数个对象,而在运行时,游戏引擎也能借唯一标识符寻找和操控游戏对象。此外,组成游戏对象的资产:网格、材质、纹理、音效片段、动画等,也需要唯一标识符。字符串似乎是唯一标识符的合理选择,一些或许可以使用menu使用,但是类似路径这种必须是字符串。但是字符串在比较标识符的速度在游戏中可能极有影响。
字符串散列标识符可以帮助既有字符串的表达能力和弹性,也有整数操作的速度。字符串散列标识符能把字符串映射至半唯一整数。游戏程序员常使用字符串标识符。但是如同许多散列系统,字符串散列也有散列碰撞的机会。然而,若有恰当的散列函数,则可以保证,游戏中可能用到的合理字符串输入不会做成碰撞。
本地化
这种任务最好在项目开始时就规划好,指定每个开发阶段的本地化工作。
Unicode、UTF-8、UTF-16、Windows下的Unicode和游戏机上的Unicode
字符集 | 特点 |
---|---|
Unicode | |
UTF-8 | 在UTF-8编码中,每个字符占1~3字节。因此UTF-8字符串所占的字节数量不一定等于其长度。此称为多字节字符集,因为每个字符占一个至多个字符的储存空间。UTF-8的优点之一是向后兼容ASCII编码,可以向后兼容 |
UTF-16 | UTF-16标准采用更简单但较昂贵的方法进行编码。UTF-16中每个字符都确切地使用16位。因此把UTF-16字符串所占的字节除以2,便可得到字符个数 |
游戏引擎非常复杂,总是跟随着大量的可调校选项。有些选项通过游戏中的选项菜单提供给玩家调校,例如:图形质量、音乐和音效的音量、控制等。而另一些选项,则只为游戏开发团队而设置,在游戏发行时,这些选项会被隐去。
读写配置
可配置选项可简单实现为全局变量或单例中的成员变量。然而,可配置选项必须供用户配置,并可以进行本地存储,否则这些配置选项的用途不大。
[SomeSetion]
Key1=Value1
Key2=Value2
[AnotherSection]
Key3=Value3
Key4=Value4
Key5=Value5
经压缩二进制文件:有些主机不享有硬盘,而是有专门的记忆卡用作读/写。使用记忆卡时,常使用经压缩的二进制文件格式
Windows注册表:微软Windows操作系统提供一个全局选项数据库,名为注册表。注册表以树形式储存,当中的内部节点称为注册表项,作用如文件夹,而叶节点则以键值对储存个别选项。任何应用程序、游戏或其他软件都可以预留一个子树(即注册表项),供改软件专用,并在该子树下存储任意的选项集。Windows注册表好像一个信息管理INI文件几何,并且实际上,Windows引进注册表的目的时取缔操作系统和应用程序所使用的无限膨胀的INI文件
命令行选项:可扫描命令行取取得选项设置。引擎可提供机制,使所有游戏中的选项都能经命令行设置;或者,引擎只向命令行显露所有选项中的一小子集
环境变量:在运行Windows、Linux或MacOS的个人电脑中,环境变量有时候也用于存储一些配置选项
线上用户设定档:每个用户都建立设定档,并用它来存储成就、已购买或解锁的游戏内容、游戏选项及其他信息。由于这些数据存储在中央服务器中,只要连上互联网,无论何地玩家都唔那个存取数据
个别用户选项
多数游戏引擎会区分全局选项和个别用户选项。这是有需要的,因为多数游戏容许每个玩家配置其喜欢的选项。此概念对游戏开发期间也十分有用,因为每位程序员、美术设计师、游戏设计师都能自定义其工作环境,而不影响其他队员。
显然,存储个别用户选项必须小心,每个文件只能看见自己的选项,而不会遇见其他玩家在同一计算机或游戏主机上的选项。在游戏主机上,用户通常可以把游戏进度以及如控制器等个别用户选项,一并储存至记忆卡或硬盘的位置(slot)中。这些位置通常实现位储存媒体上的文件。
游戏本质上是多媒体校验。因此,载入及管理多种媒体,是游戏引擎必须具备的能力。这些媒体包括纹理位图、三维网格数据、动画、音频片段、碰撞和物理数据、游戏世界布局等许多种类。除此之外,多余内存空间通常不足,游戏引擎要确保在同一时间,每个媒体文件只可在内存中存在一份。
多数游戏引擎会采用某种类型的资源管理器(又称作资产管理器、媒体管理器),载入并管理构成现代三维游戏所需的无数资源
每个资源管理器都会大量使用文件系统。游戏引擎有时候会“分装”原生的文件系统API,称为引擎私有的API。原因一是,引擎可能需要跨平台,在此需求下,引擎自己的文件系统API就能对系统其他部分产生隔离作用,引擎不同目标平台之间的区别。原因二是,操作系统的文件系统API能提供游戏引擎所需的功能。例如许多引擎支持串流(即能在游戏运行中,同时载入数据),但多数操作系统不直接提供流功能文件系统API。多媒体之间的区别也同样可以用游戏引擎自身的文件系统API加以”隐藏“
游戏引擎的文件系统API通常提供一下几类功能
文件名和路径
路径是一种字符串,用来描述文件系统层次中文件或目录的位置。每个操作系统都有少许不同的路径格式,但所有操作系统的路径本质上有相同的结构。路径一般是卷/目录1/目录2/…/文件
或者卷/目录1/目录2/…/目录N
换言之,路径通常包括一个可选的卷指示符紧接以传路径成分,他们之间以路径分隔符分隔(正斜线分隔符/
和反斜线分隔符\
)。每个路径惩罚呢是从根目录至目标目录或文件之间的目录名称。若路径指向文件,则最后的是文件名,否则最后的是目标目录名称。
操作系统之间的区别
操作系统 | 路径 |
---|---|
UNIX | 使用正斜线符(/)作为路径分隔符,而DOS及早期版本的Windows则采用反斜线符(\)。较新版本的Windows容许以正反斜线符分隔路径成分,然而仍有些应用程序不接受正斜线符 |
Mac OS | MacOS8和9采用冒号(:)作为路径分隔符。而MacOSX是基于UNIX的,因此它支持UNIX的正斜线符记号法 |
UNIX及其变种 | 不支持以卷发你开目录层次。整个文件系统都是以单一庞大的层次所组成的。本机磁盘、网络磁盘以及其他资源都是挂接为主层次中的某个子树 |
Windows | 在Windows上,可以用两个方法定义卷。本机磁盘以单英文字母加冒号指明(C: )。远端网络分享则可以挂接成为像本机磁盘一样,或是可以用双斜线号加上远端计算机名称和分项目录/资源名字指明(\\some-computer\some-share ) |
/dev_bdvd/
前缀去指明蓝光驱动,而/dev_hddX
则代表多个硬盘(X为设备索引)相对和绝对路径
所有路径都对应文件系统中的某个位置。当路径相对于根目录,我们称之为绝对路径,当路径相对于文件系统层次架构中的其他目录,则称之为相对路径
在UNIX和Windows下,绝对路径的首字符为路径分隔符(/或\),而相对路径则不会以路径分隔符作为首字符。Windows中,绝对路径和相对路径都可以加入卷指示符,不加入卷指示符代表使用当前工作卷
路径 | 系统 | 样例 |
---|---|---|
绝对路径 | Windows | C:\Windows\System32 |
Windows | D:\ (D:卷的根目录) | |
Windows | \ (当前工作卷的根目录) | |
Unix | /usr/local/bin/grep | |
Unix | / (根目录) | |
相对路径 | Windows | \Windwos\System32 |
Windows | anmition\walk.anim | |
Unix | bin/grep | |
Windows | src/audio/effect.cpp |
搜寻路径
不要混淆路径和搜寻路径。路径是代表文件系统下某文件或目录的字符串。搜寻路径是一串含路径的字符串,各路径之间以特殊字符(如冒号或分号)分隔,找文件时就会从这些路径进行搜寻。
有些游戏引擎会使用搜寻路径找资源文件。比如cocos2dx项目中的resources或者unity项目中asset。然而,在运行时期搜寻资产,可能是费时的做法。通常,资产路径没理由会在运行时期之前无法得知。
路径API
路径显然比简单字符串复杂得多。程序员需要对路径进行多种操作,例如,从路径分离 目录/文件名/扩展名、使路径规范化、绝对路径和相对路径之间进行转换等。含丰富功能的路径API对这些任务非常有用。
基本文件I/O
C标准程序库提供两组API以开启、读取及写入文件内容,两组API中一组有缓冲功能,另一组无缓冲功能。每次调用输入/输出,都需要称为缓冲区的数据区块,以供程序和磁盘之间传送来源或目的字节。当API负责管理所需的输入/输出数据缓冲,就称之为有缓冲功能的I/OAPI。相反,若需要有程序员负责管理数据缓冲,则称为无缓冲功能的API。
C标准库中,有I/O缓冲功能的函数有时候会成为流输入/输出API
操作 | 有缓冲功能 | 无缓冲功能 |
---|---|---|
开启文件 | fopen() | open() |
关闭文件 | fclose() | close() |
读取文件 | fread() | read() |
写入文件 | fwrite() | write() |
移动访问位置 | fseek() | seek() |
返回当前位置 | ftell() | tell() |
读写单行 | fgets() | 无 |
写入单行 | fputs() | 无 |
格式化读取 | fscanf() | 无 |
格式化写入 | fprintf() | 无 |
查询文件状态 | fstat() | stat() |
有些游戏开发团队认为,管理自己的缓冲区是有帮助的。例如《红色警戒3》团队观察到,往日志文件里写数据会显著降低性能。他们更改日志系统,先把数据累积在内存缓冲中,满溢后才写进盘内。之后再把缓冲输出函数置于另一线程里,以避免令主游戏循环发生流水线停顿
包装还是不包装
开发游戏引擎时,可使用C标准库和I/O函数,或是操作系统的原生API。然而,许多游戏引擎会把文件I/OAPI包装成自定义的I/O函数。包装操作系统I/O API最少有3个好处
同步文件I/O
C标准库的两种文件I/O库都是同步的,也就是说程序发出I/O请求以后,必须等待读/写数据完毕,程序才继续运行。
bool syncReadFile(const char* filePath, U8* buffer, size_t bufferSize, size_t& rBytesRead)
{
File* handle = fopen(filepath, "rb");
if(handle)
{
// 在这里阻塞,直至所有数据读取完毕
size_t bytesRead = fread(buffer, 1, bufferSize, handle);
int err = ferror(handle); // 若过程出错,取得错误码
fclose(handle);
if(0 == err)
{
rBytesRead = bytesRead;
return true;
}
}
fclose(handle)
return false;
}
异步文件I/O
串流是指在背景载入数据,而主程序同时继续运行。为了让玩家领略无缝、无载入画面的游戏体验,许多游戏在游戏进行的同时使用串流从银盘读取即将来临的关卡数据。最常见的串流数据类型可能是音频和纹理,但其他数据也可以串流,例如几何图形、关卡布局、动画片段等
为了支持串流,必须使用异步文件I/O库。这种库能让程序在请求I/O后,不需要等待读写完成,程序便立即继续运行。有些程序系统自带提供异步文件
AsyncRequestHandle g_hRequest; // 一步IO请求的句柄
U8 g_sayncBuffer[512]; // 输入缓冲
static void asyncReadComplete(AsyncRequestHandle hRequest);
void main()
{
// 注意:再次调用asyncOpen 可能本身使异步的,但这里忽略此细节
// 假设改函数是阻塞的
AsyncFileHandle hFile = asyncOpen("c:\\testfile.bin");
if(hFile)
{
// 此函数读取亲求,然后立即返回
g_hRequest = asyncReadFile(hFile, // 文件句柄
g_asyncBuffer, // 输入缓冲
sizeof(g_asyncBuffer). // 缓冲大小
asyncReadComplete); // 回调函数
}
}
// 当数据都读入时
static void asyncReadComplete(AsyncRequestHandle hRequest)
{
if(hRequest == g_hRequest && asyncWasSuccessfule(hReqeust))
{
// 现在数据已经全部读进g_asyncBuffer[]
}
}
U8 g_asyncBuffer[256]; // 输入缓冲
void main()
{
AysncRequestHandle hReuest = ASYNC_INVALID_HANDLE;
AsyncFileHandle hFile = asyncOpen("c:\\testfile.bin");
if(hFile)
{
// 此函数做读取请求,然后立即返回
g_hRequest = asyncReadFile(hFile, // 文件句柄
g_asyncBuffer, // 输入缓冲
sizeof(g_asyncBuffer). // 缓冲大小
NULL); // 回调函数
}
// 做其他事情
for(int i =0; i<10; i++)
{
// 其他操作
}
// 直至数据预备好之前,我们不继续下去,等待
asyncWait(hRequest);
if(asyncWasSuccessful(hRequest))
{
// 现在数据已读进g_asncBuffer[]
}
}
优先权
必须谨记文件I/O是实时系统,如同游戏的其他部分页要遵循时限。因此,异步I/O操作常有不同的优先权。异步I/O系统必须能够在听较低优先权的请求,才可以让较高优先权的I/O请求有机会在时限前完成
异步文件I/O如何工作
异步文件I/O是利用另一线程处理I/O请求的。主线程调用异步函数时,会把请求放入一个队列,并立刻传回。同时,I/O线程从队列中取出请求,并以阻塞I/O函数如read()或fread()处理这些请求。请求的工作完成后,就会调用主线程之前提供的回调函数,告知该操作已完成。若主线程选择等待完成I/O请求,就会使用信号量处理
任何任何可以想象到的同步操作,都能通过把代码置于另一线程而转变为异步操作。除了下次呢很难过,也可以将代码移至物理上独立的的处理器
每个游戏都是由种类繁多的资源(有时称为资产或媒体)构成的,例如网格、材质、纹理、着色器程序、动画、音频片段、关卡布局、碰撞数据等。游戏资源必须妥善管理,这包括两方面,一方面时建立资源的离线工具,另一方面时在执行期载入、卸下及操作资源。因此每个游戏都有某种形式的资源管理器
每个资源管理器都由两个元件组成,这两个元件即独立又互相整合。其一负责管理离线工具链,用来创建资源及把它们转换成引擎可用的形式。另一元件在执行期管理资源,确保资源在使用之前已载入内存,并在不需要的时候把它们从内存卸下
在某些引擎中,资源管理器是一个具有清晰设计、统一、中心化的子系统,负责管理游戏中用到的所有资源类型。其他引擎的资源管理器本身不是单独子系统,而是散布于不同的子系统中,或许这些子系统是由不同作者,经历过引擎漫长的历史而写成的。但无论资源管理器是如何实现的,它总是要负起责任,并解决一些有明确定义的问题
离线资源管理及工具链
资源的版本控制
游戏是实时的,动态的,互动的计算机模拟。由此可知,时间在电子游戏中担当非常重要的角色。游戏中有不同种类的时间——实时、游戏时间、动画的本地时间线、某函数实际消耗的CPU周期等
每个引擎系统中,定义及操作时间的方法各有不同。
在图形用户界面(GUI)中,画面上大部分的内容是静止不动的。在某一时刻,只有少部分的视窗会置动更新其外贸。因此传统上绘画CUI界面利用这一个称为矩形失效的技术,仅让屏幕中有改动的内容重绘
实时三维计算机图形以完全另一方式实现。当摄像机在三维场景中移动时,屏幕或视窗上的一切内容都会不断改变,因此再不能使用失效矩形法,取而代之,计算机图形采用和电影相同的方式产生运动的错觉和互动性——对观众快速连续的显示一连串静止影像
要在屏幕上快速连续地显示一连串静止影响,显然需要一个循环。在实时渲染应用中,此循环又称为渲染循环
while(!quit)
{
// 基于输入或预设的路径更新摄像机变换
updateCamera();
// 更新场景中所有动态元素的位置,定向及其他相关的视觉状态
updateSceneElements();
// 把静止的场景渲染至屏幕外的帧缓冲中
renderScene();
// 交换背景缓冲和前景缓冲,令最近渲染的影像显示于屏幕之上
// 或是在视窗模式下,把背景缓冲复制至前景缓冲
swapBuffers();
}
游戏由许多互动的子系统所构成,包括输入/输出设备、渲染、动画、碰撞检测及决议、可选的刚体动力学模拟、多玩家网络、音频等。在游戏运行时,多数游戏引擎子系统都需要周期性的提供服务。然而,这些子系统所需的服务频率各有不同。动画子系统通常需要30HZ、60HZ甚至120HZ的更新率,此更新率是为了和渲染子系统同步。然而,动力学模拟可能实际需要更加频繁的更新。更高级的系统,例如人工智能、就可能只需要每秒1、2次更新,并且完全不需要和渲染循环同步
有许多不同的方法能实现游戏引擎子系统的周期性更新。
void main()
{
initGame(); // 各个子系统的设置
while(true) // 游戏循环
{
readHumanInterfaceDevices(); // 读取人体工程学接口
if(quitButtonPreadded()) // 如果退出按钮点击
{
break; // 离开游戏循环
}
movePaddles(); // 根据旋转按钮的偏移,向上或向下调整球拍位置
moveBall(); // 计算球的位置
collideAndBounceBall(); // 碰撞检测
if(ballImpactedSize(LEFT_PLAYER)) // 碰到哪边给哪边加分
{
incrementScore(RIGHT_PLAYER);
resetBall();
}
else if(ballImpactedSize(RIGHT_PLAYER))
{
incrementScore(LEFT_PLAYER);
resetBall();
}
renderPalyefield(); // 渲染
}
}
上述代码模拟一个打乒乓球游戏,球在两块垂直球拍和上下两幅拱顶的横枪之间来回反弹。玩家使用旋转按钮控制球拍的位置
视窗消息泵
在Windows平台,游戏除了要服务引擎本身的子系统,还要处理来自Windows操作系统的消息。因此,Windows上的游戏都会有一段代码称为消息泵。其基本原理时处理来自Windows的消息,无消息时才执行引擎的任务
whlie(true)
{
// 处理所有待处理的windows信息
MSG msg;
while(PeekMessage(&msg, NULL, 0, 0) > 0)
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
// 再无Windows消息需要处理,执行真正的游戏循环迭代一次
RunOneIterationOfGameLoop();
}
以上实现游戏循环的方式,其副作用是设置了任务的优先次序,处理Windows消息为先,渲染和模拟游戏为后
这带来的结果是,当玩家再桌面上改变游戏的视窗大小或移动视窗时,游戏就会愣住不动
回调驱动框架