归开头的成语接龙大全:高层游戏引擎——基于OGRE所实现的高层游戏引擎框架(5)

来源:百度文库 编辑:偶看新闻 时间:2024/04/28 06:50:46

高层游戏引擎——基于OGRE所实现的高层游戏引擎框架(5)收藏

第三部分 实作:基于 OGRE 图形引擎的游戏框架

第三部分所有图片


点击放大

场景系统:OGRE场景体系的分离和重新合成

窗体底端

  首先我们发现,OGRE场景系统似乎现在和我们所理解的场景系统有点不合。OGRE是用一种渲染方面的理解来考虑场景的,而作为一个游戏似乎需要考虑得更远。“游戏需要渲染,但游戏不仅仅是渲染”。

  要融入OGRE图形系统,需要程序结构和习惯的调整,而且所写的所有代码都需要受限于OGRE,以至于我们依照OGRE来写的高层游戏引擎很有可能会成为离开了OGRE就什么都做不了的东西。而且即使我们不离开OGRE框架,那么当OGRE以后翻新版本、做大的体系调整的时候,我们所做的高层游戏引擎也需要作极大调整,这当然不是我们想看到的。高层引擎是立足于需求的,OGRE底层改动了,只要需求没有改动,就应该保证高层引擎尽量不要改动,这首先是软件工程的原则。

  怎么办呢?我们先从表面上来推导一下OGRE引擎与我们前面的层次化引擎体系的接合关系。

  在现有的接合下,我们有很多框架安全方面的问题都没有考虑到,如果OGRE中的某个组件迫使我们更改上层架构,那将是危险的事情,因为上层架构即游戏逻辑不是为了OGRE而存在的,应该把这些事情都封装到底层来做。我们最希望的是让最终使用这个框架的人看不到一点跟OGRE相关的东西,他只需要考虑他自己的东西:游戏逻辑。就是为A送B一封情书会怎么样,以及D被C的车撞了一下会怎么样诸如此类的问题,如果在这最高层还迫使使用者考虑OGRE——把C和D的包围盒进行检测——那么只能说我们没有划分好、搭建好我们的引擎,换句直接一点的话说,我们的实作以失败告终了。这是我们对自己所做框架的最起码要求。因为只有当高层引擎留不下底层引擎的一点痕迹,我们最上层的需求和最底层的平台才是被高层引擎完全隔绝的,也就是说,无论底层平台如何变更,具体游戏逻辑是不需要改动的,需要作出改动的只是高层引擎。如图:


如图,理论上,高层引擎将底层和应用层完全隔离,对底层的修改将牵动高层修改,但不会牵动应用层的修改。这对于引擎是很关键的,当引擎改动的时候,如果使用这个引擎的所有应用层都需要修改的话,那么不知道全世界会有多少工作室、甚至是公司会发出鬼哭狼嚎的叫声。因此,模块化、层次化的思路早就是软件工程界的一个共识。

  在我们现有的划分下,高层引擎需要完成下面的工作:

图3-2 基本的的高层引擎结构

  我们把OGRE本身提供的功能列举一下,全部提供的用黑色块,部分提供的用浅绿色块。

图3-3 基本的的高层引擎结构与OGRE的切和关系

  在这个划分中,我所负责的主要是地形系统、地形、场景和规则系统,而GUI和I/O控制系统、物件和物件系统、应用程序主要由另一位同学负责。在这里我主要也只讲述场景、地形、地形系统和规则系统。

场景:游戏的舞台

场景中,舞台是地形系统所支持的,而赋予场景生机活力的则是物件系统。物件系统和场景系统间的组织是有所联系的,例如超大场景管理器和普通室外场景管理器所要求的物件系统数据结构也是不一样的,前者由于可能存储海量的物件,因此可能对物件做分区处理;而后者则不同,因此可能会用统一的一张表(Map)或者哈希表(HashMap)来管理。物件和地形系统的相关性,可以在场景这一层次来解决,当场景调入的是这样的地形系统,它就需要调入合适的物件系统。什么样的物件系统最适配于某某地形系统?这是一个仁者见仁智者见智的问题,没有唯一的答案。物件系统最耗费效率的无非两点:自身逻辑和搜索算法,物件系统每一帧都会走自己的逻辑,而且外界经常会从物件数据结构里索引某一个具体物件,甚至是一帧索引十几遍物件,这两者对于物件系统的数据结构都有很高要求。

  OGRE对于地形的支持比较庞大,实际上OGRE本身是没有具体的地形系统的,但我们可以通过写Plugin为原有的OGRE系统增添帮助。现有的几个Plugin包括:BSP管理器(plugin_BspSceneManager)、超大场景管理器(plugin_NatureSceneManager)、和我们这次用来作试验的四叉树室外场景管理器(plugin_OctreeSceneManager)。OGRE由于抽象度很高,因此在高层的代码层面上几乎察觉不到各个之间的区别,这当然方便了我们的抽象。只是OGRE地形系统是集成在Root里面的,没法随便打破,这样,我们所提供的地形系统相当于一个“壳”,只是重新封装了OGRE的场景管理功能,这就是设计模式中的Adapter(适配器)模式。

  因此,这次所写出的Terrain就相当于OGRE::SceneManager之上的一层Adapter,基本上没有什么新的功能,这也是在图2-3中说这个系统已经是OGRE完全处理的原因。

  Scene的一个功能是用来管理Terrain的,这一般发生在多Terrain的情况下,需要对诸多的Terrain资源统一管理,Scene掌管Terrain的生杀大权,正如舞台的形态决定了布景如何摆放一样。实际上OGRE::SceneManager中也有一部分功能是用来做这些事情的。由于需要的功能比较少,因此Scene掌握了下面这些基本方法:包括载入Terrain、销毁Terrain和更换Terrain等。

  利用Adapter模式,将Terrain上升为一个接口类,以后无论OGRE内部对于SceneManager的变动有多大,Terrain由于是接口只需要更改接口的实现就可以了。而Scene则成为了这一部分的管理类,与底层OGRE在逻辑上无关。至此我们Scene-Terrain结构的简单场景系统就算是构架完毕,现有的这一部分类和接口如下:

  关于Scene的另一个重要部分物件系统,由另一位同学向大家细细说明,这里只是稍稍提一下一些基本的物件设计思路。前面说过,物件是一个比较难于划分的体系,因为物件的属性比较多,而且无论何种属性都可以成体系。例如“生物体还是非生物体”、“生命期长还是短”等等。举个例子来说,对于一般生命期比较长的物件来说,可以按照Map或者Vector来存储,这样由于不会经常从数据结构中调入调出,而且查询算法又相对要快,使得这种数据结构显得比较有优势;但是生命期非常短的物件就不同了,例如子弹碰到墙上溅出的火花,火花的存在时间往往在1/10秒一级的,而且同时可能出现很多火花,如果用Vector或者Map,那将是一件非常恐怖的事情,且不说疯狂调入调出会有多大的时间损耗,本身火花根本就没必要对其进行查询操作,Map和Vector相对于List的唯一优势就此不复。因此对于这种生命期非常短的物件,用List就比用Map等数据结构优势要明显。这个划分仅仅是来自于“生命期长短”这个属性,而物件所具有的属性何止着一种呢?!即便是都按照Map或者MultiMap存储,也有按物件名称存储、按物件属性存储,等等很多种存储方式。如何抽象一个适用于游戏的物件系统,这是很多人心目中共同的问题。关于这个系统也有很多现行的方法,但是很难统一,毕竟物件的规则体系太复杂了。

规则:脚本系统

  规则系统虽然并不难划分,但却是一个比较难于把握的系统,如前所述,规则系统是一个肉体所无法感觉到的世界,这样,只能用意识去感知的这个世界就充满了诸多变数。实际上规则系统并不是一个成形的系统,而是所有“游戏逻辑”的统称。这些逻辑或自成系统,或分布在其它系统内,构成了一个游戏严密而严谨的逻辑体系。

  从功能上理解这个系统是一个普遍的方法,因为无论规则是多么多变,最终我们需要关注的那些总是会对感官世界产生影响,这个影响就是这些规则的功能。但这种划分办法并非是规则系统构建的全部,而仅仅是一种方向。用白话文说就是:“无论你怎样划分这个系统,最后只要完成这个功能就可以了。”

在做引擎的时候,很少有人会知道这个引擎会用到哪里,更不用说引擎应该满足哪些逻辑和哪些功能了。因此这些功能大部分是最后开发者拿到了引擎开始写游戏程序的时候才会考虑到的。对于引擎开发人员来说,它无形、充满变数,因此这是规则系统难于把握的重要原因。大部分游戏逻辑都是在引擎之外写的,而且中国很多游戏DEMO的逻辑都是靠硬编码实现的。

但是这并不表明引擎的开发人员就无事可做,因为你要对规则系统予以底层支持,有些东西是缺不了的。这主要包括:消息系统、游戏脚本、寻路算法和状态机等等,其中大部分是人工智能的标志性研究课题。这中间我认为最为重要的是脚本系统和消息系统。对于国外游戏引擎来说,强大的脚本系统早已成为了一个必备的利器,而国内的开发者还是处于脚本系统的教材和资料都很难找的阶段。

  这里我们的引擎将为规则系统提供一套脚本支持,在后面的组装中你将会看到这个脚本是如何作为规则应用在游戏中的。对于规则系统也有其他很多种支持,例如状态机等等,好在各个逻辑体系之间是相对独立的,因此以后可以陆续增加。

  脚本分为编译型脚本和解释型教本,对于外国很多游戏引擎所提供的都是编译型脚本分析器。我这里所提供的是一套解释型的脚本分析器,一是因为开发一个编译型分析器往往所需时间过长;二是对于我们的DEMO,解释型的已经足够用了,而且速度不慢。

脚本分析器提供的基本功能就是分析脚本,这就牵扯到了编译原理的词法分析和语法分析。在读入一行并对本行文本中的注释和空格成分予以消除后,剩下的部分转入词法分析,进一步被断为一个个独立的有意义的单词,最后通过语法分析来解释这些单词的意义。这里我们的语法比较简单,每一个独立的语句都类如下面的语句:

    Index:
    Funciton( param1 , “string param 2” );

第一个语句是标号语句,主要用于跳转的,例如Goto(Index)就可以从程序的任何一个位置跳转到Index,因此在我们的语法分析中,当发现了单词“:”之前有独立存在的单词时,就把这个独立的单词存储到一张Index表里面,以备跳转。而如果发现了“(”则把之前的独立单词作为Function,每一个Function唯一对应一段C++程序,从“(”到“)”之间的部分按“,”断开做多个Param,不带””的看作是常数参数,被””所包裹的是字符串参数,这些参数用做执行Function时的一些必须数据。语法分析的关键就是Function与C++代码的一一对应,即函数匹配,这里我们可以使用if来处理:

strCmd = ParseLine() //分析一行
if(strCmd == “Function”)
{
    doFunction( getIntParam1() , getStrParam2() );
}

使用if可读性最好,但是比较慢,因为String比较会比常数比较要慢得多。因此也有的方法就是通过把脚本函数映射为唯一的数字,再通过数字来做比较。

例如我们建立如下的对应关系:Function : 101,并把这个对应关系存储到脚本解释器里面,这样,当解释器发现用作函数的单词Function的时候,就会把他翻译为101,然后再进行匹配:

nCmd = ParseLine() //分析一行,注意返回值不同了

switch(nCmd)
{
    case 101:
    {
        doFunction( getIntParam1() , getStrParam2() );
    }
}

这样就比原来快了很多。只是麻烦的一步就是需要一个个为脚本预先对应上这些数字。这些实际上都是在解释器的Run()函数里面运行的,在需要的地方,只需要调用Run(脚本文件名),Run就会自己去检测不同的脚本名称,然后实现各自的功能。

  脚本分析器还需要有一个功能就是“功能注册”。开发引擎的时候我们几乎没有办法写出具体的脚本功能,做游戏的人拿到引擎后需要写一些具体的脚本功能,这时候需要提供给他们一个注册机制,来把脚本函数名称和功能一一对应起来。这里我们提供的唯一的注册机制就是这个switch(nCmd),如果添加了什么新的脚本,就需要为对应的编号增添新的实现。例如我们除了Function以外又添加了一个新的函数Walkto,对应编号102,文法是Walkto(param1 , param2 , param3),那么我们需要做的就是:

switch(nCmd)
{
    case 101: // Function
    {
        doFunction( getIntParam1() , getStrParam2() );
    }
    case 102: // Walkto
    {
        WalkTo( getIntParam1() , getIntParam2() , getIntParam3() );
    }
}

现在的注册机由于是隶属于引擎代码层面的,每一次添加新的脚本都会引起引擎更改和变动,前面我们说了,应该尽量避免引擎变动,怎么解决这个问题呢?关于脚本注册机的更好实现就是通过C++的多态。这样我们可以把Command实现为一个抽象类,各个具体Commond继承之并实现相应接口。例如:

class Command{ virtual void do() = 0; }; //抽象类

class Function : public Command{ //具体的一个Command

    virtual void do()
    {
        doFunction( getIntParam1() , getStrParam2() );
    }
};

但这些新加的类如何注册到解释器里面呢?因为在做解释器的时候我不可能知道会加哪些类进来啊!有办法,设计模式的工厂模式(Factory)给我们提供了明确的行动指南。我们可以另外实现一套Factory并指明Factory类型:

class CommandFactory{virtual string getType() = 0; virtual Command* create() = 0;};

class FunctionFactory : public CommandFactory{
    virtual string getType() { return “Function”; }

    virtual void create(){ return new Function;} //用Factory生成具体的Command

};

最后,我们需要在Run里面通过std::Map注册Factory,把Type和具体的Factory关联起来。这里我们就通过std::Map把“Function”和FunctionFactory关联起来。最后在进行函数匹配的时候,我们只需要:(伪代码)

//分析一行,得到strCmd

strCmd = ParseLine();

//从map里寻找对应strCmd的Factory,假设strCmd是”Function”,it->second里面就会存放FunctionFactory

iterator it = Map.find(strCmd);

if(it != Map.end() )
{
    // 通过Factory生成Command对象
    Command* Cmd = it->second->create();
    // 执行Command对象的do方法
    Cmd->do( );
    // 销毁Command对象
    Delete(Cmd);
}

OK,现在无论怎么往里面加Factory和具体的Command,这段属于引擎层的解释器代码都不需要改动了!Great。我们把这套流程画成图:


图3-6 利用工厂模式解决的解释器函数匹配图,会发生改变的用黑色标出,可见现在这套引擎几乎不会发生变动,以后需要的在别的地方写就可以了,健壮性相当高!


好了,到这里,脚本解释器本身就基本解决了。剩下的工作就是不断根据需要注册新的脚本功能了。

脚本怎样最终应用于规则呢?在我们现有的解释器下,谁想用脚本,就保留脚本文件的名称,然后调用CScript::Run( const string& filename )就可以完成任务。这样,我们需要为需要走脚本的每一个类都挂接一个成员:std::string m_strFile,来存储脚本文件的名称。而且,在这些类的Logic里面,我们需要手动调用CScript::Run来运行所储存的脚本:

if( m_strFile != “” )
{
    CScript::getSingleton().Run(m_strFile);
}

如果一切无误,脚本就会运行。对于我们这个脚本机,有一个提速的手段。我们的脚本机每一次Run都会调入一次文件,分析后再关闭文件。如果一帧需要有10个物件走脚本逻辑,那么每一帧就起码会有10次磁盘操作,对于大量物件尤其是触发器(后面会提到)存在的情况,这是件严重的事情。提速的手段就是在最开始就按照文件名把文件内容一次提取到一个缓冲区内,这样走脚本逻辑的时候就不会走磁盘操作了,而是从内存缓冲区读取数据,对于动辄一个场景几十个物件的游戏来说,这种提速已经是普遍的做法。但每一帧都进行磁盘操作也并非一无是处,在调试的时候有时候需要经常改动脚本文件,对于一次调入的情况,每一次修改后必须重新启动游戏,而每帧重新读一次磁盘就不会遇到这种问题。如何选择合适的运行方式,这就需要看是在什么情况下运行了。