每个游戏都需要一些底层支持,以管理一些例行却关键的任务。例如启动及终止引擎、存取(多个)文件系统、存取各种不同的资产类型(网格、纹理、动画、音频等),以及为游戏团队提供调试工具。
5.1 子系统的启动和终止
游戏引擎是复杂软件,由多个互相合作的子系统结合而成。当引擎启动时,必须一次配置及初始化每个子系统。各子系统间相互依赖关系,隐含地定义了每个子系统所需要的启动次序。例如子系统B依赖子系统A,那么在启动B之前,必须先启动A。各子系统的终止通常采取反向次序,即先终止B,再终止A。
5.1.1 C++的静态初始化次序(是不可用的)
由于多数新式游戏引擎皆采用C++为编程语言,我们应该考虑一下,C++原生的启动和终止语意是否可作为启动及终止引擎子系统之用。C++中,在调用程序进入主函数或者Windows的WinMain())之前,全局及静态对象已被构建。然而,我们完全不可预知这些构造函数的调用次序。在Main或WinMain结束之后,会调用全局及静态对象的析构函数,而这些函数的调用次序也是无法预知的。显而易见,此C++行为并不适合用来初始化及终止游戏引擎的子系统,实际上,对任何含互相依赖全局对象的软件都不适合。
这很令人失望,因为要实现各主要子系统,例如游戏引擎中的子系统,常见的设计模式是为每个子系统定义单例类(singleton class)(通常称作管理器/manager)。若c++能给予我们更多的控制能力,指明全局或静态实例的建构、析构次序,那么我们就可以把单例定义为全局变量,而不必使用动态内存分配。例如,各子系统可写为以下形式:
calss RenderManager
{
public:
RenderManager()
{
//启动管理器....
}
~RenderManager()
{
//终止管理器.....
}
};
static RenderManager gRenderManager;
可惜,由于没法直接控制建构和析构次序,此方法行不通。
5.1.1.1 按需架构
对付此问题,可使出一个C++的花招:函数内声明的静态变量并不会于main()之前建构,而是在第一次调用该函数时才建构。因此,若把全局单例改为静态变量,我们就可以控制全局单例的建构次序。
class RenderManager
{
public:
static RenderManager& get()
{
static RenderManager sSingleton;
return sSingleton;
}
RenderManager()
{
//对于需要依赖的管理器,先通过调用它们的get()启动它们
VideoManager::get();
TextureManager::get();
//现在启动渲染管理器
//.....
}
~RenderManager()
{
//终止管理器
};
你会发现,许多的工程教科书都会建议此方法,或以下这种含动态分配单例的变种:
static RenderManager& get()
{
static RenderManager* gpSingleton=NULL;
if (gpSingleton==NULL)
{
gpSingleton=new RenderManager;
}
ASSERT(gpSingleton);
return *gpSingleton;
}
但是,此方法不可控制析构的次序。例如,在RenderManager析构之前,其依赖的单例可能已被析构。而且,很难预计RenderManager单例的确切析构时间,因为第一次调用RenderManager::get()时,单例就会进行建构,天知道那是什么时候!此外,使用该类的程序员可能不会预期,貌似无伤大雅的get()函数可能会有很高的开销,例如,分配及初始化一个重量级的实例。此方法是难以预计且危险的设计。这促使我们诉诸更直接、更大控制权的方法。
5.1.2行之有效的简单方法
假设我们对于子系统继续采用单例管理器的概念。最简单的“蛮力”方法就是,明确地为各个单例管理类定义启动和终止函数。这些函数取代构造和析构函数,实际上,我们会让构造和析构函数完全不做任何事情。这样的话,就可以在Main()中(或某个管理整个引擎的实例中),按所需的明确次序调用各种启动和终止函数。例如:
class RenderManager
{
public:
RenderManager()
{
//不做任何事情
}
~RenderManager()
{
//不做任何事情
}
void startUp()
{
//启动管理器
}
void shutDown()
{
//终止管理器
}
};
class PhysicsManager{
/*
同上类似内容
*/
};
class AnimationManager{
/*
同上类似内容
*/
};
class MemoryManager{
/*
同上类似内容
*/
};
class FileSystemManager{
/*
同上类似内容
*/
};
//......
RenderManager gRenderManager;
PhysicsManager gPhysicsManager;
AnimationManager gAnimationManger;
TextureManager gTextureManager;
VideoManager gVideoManager;
FileSystemManager gFileSystemManager;
//.....
int main(int argc,const char*argv)
{
//以正确次序启动各引擎系统
gMemoryManager.startUp();
gFileSystemManager.startUp();
gVideoManager.startUp();
gTextureManager.startUp();
gRenderManager.startUp();
gAnimationManager.startUp();
gPhysicManager.startUp();
//......
//运行游戏
gSimulationManage.run();
//以反向次序终止各引擎系统
//......
gPhysicsManager.shutDown();
gAnimationManager.shutDown();
gRenderManager.shutDown();
gTextureManager.shutDown();
gVideoManager.shutDown();
gFileSystemManager.shutDown();
gMemoryManager.shutDown();
return 0;
}
此法还有“更优雅的”实现方式。例如,可以让各管理器把自己登记在一个全局的优先队列中,之后再恰当次序逐一启动所有管理器。此外,也可以通过每个管理器列举其依赖的管理器,定义一个管理器间的依赖图(dependency graph),然后按互相依赖关系计算最优的启动次序。(以上两种建议确实比较有用,不过需要你的逻辑清晰)。根据《游戏引擎架构》作者本人的认为,蛮力方法总是优于其它方法。原因如下:
此方法既简单又容易实现。
此方法明确代码的执行次序(强制自定义次序)
此方法容易调试和维护。若某子系统启动时机不够早或者过早,只需要移动一行代码。
当然,用蛮力方法手动启动及终止子系统,还有各小缺点,就是程序员有可能意外地终止一些子系统,而非按启动的相反次序。只要能够成功启动及终止引擎的各子系统,你的任务就完成了。
书中译注说:要解决按需建构方式的析构次序问题,可以在单例建构时,把自己登记在一个全局堆栈中,在Main()结束之前,逐一 把堆栈弹出并调用其终止函数。此方法假设单例的终止次序可以为启动次序的相反,但理论上不能解决所有情况。
以上内容来自《游戏引擎架构》p185-p189