这里的“游戏”我只讨论一般的小游戏,这里的渲染器也特指游戏中负责所有(或者说大部分)渲染工作的那个对象。而我这里说所的“架构”则是指如何安排这个 “渲染器”与游戏中其他对象(类)的关系,之所以要讨论这种关系,是因为很多时候我们都需要改善游戏中逻辑部分和渲染部分之间的关系。如果这种关系藕荷度太高,最直接的问题就是导致代码可扩展性不高。
渲染代码往往依赖于开发所使用的开发包。假使你直接使用DirectX或者OpenGL,你的渲染代码很有可能会直接涉及到各种DirectX或OpenGL中的概念。在代码级别,就是会牵扯进很多跟开发包相关的类,函数,结构体等等之类。
也许你会对DirectX或OpenGL做二次封装,开发出一些游戏引擎(或者只是单纯的图形引擎)。于是,现在的渲染代码就可以只跟你的游戏引擎相关(代码级别)。
无论如何,如果直接使用DirectX或OpenGL(一次封装都没做这是很烂的方式),当你移植游戏时你很有可能就会面临从DirectX到 OpenGL代码转化的问题(反之亦然)。而使用游戏引擎呢?游戏引擎隔离了底层具体的开发包(DirectX或OpenGL),但是如果要换引擎呢?换引擎这样的情况我认为是完全存在的,例如现在在Windows下开发了一个基于HGE的游戏,现在我想把这个游戏移植到Linux 下。而HGE是基于DirectX的,要移植到Linux下,你当然可以保持HGE的接口不变,而使用OpenGL重写HGE。这里,你就需要重新开发一个引擎!这很浪费时间!所以你可能会使用另一个在Linux 下运行的引擎(跨平台的引擎也可以),例如ClanLib,于是,这里就涉及到了“换引擎”的问题。
从上文我们可以看出,基于很多原因(不仅仅是要把代码做移植,好的架构还利于代码的扩展,利于开发过程---特指编码过程----的平稳进行等等之类),游戏中渲染器的架构需要得到关注。
接下来我将讨论几种不同的架构:
一.这也是最烂的方式,游戏中无论在什么地方,只要需要渲染了,就加渲染代码。其结果就是各种与图形开发包相关的东西铺天盖地。这样的代码根本没任何幽雅性可讨论。事实上,在很多游戏开发相关的书籍中早就提到过类似“将游戏逻辑和渲染分离开来”的观点。
二.这是我使用了很多次的架构方式,类图为:
Game类是一个Manager类,Renderer类里包含了游戏中所有的渲染代码,Monster是一个具体的怪物类,它没有类似于Render或者 Draw之类的接口,这些接口都被放在Renderer类里,例如RenderMonster()。当游戏需要渲染这个Monster对象时,Game对象就调用Renderer类的RenderMonster接口。
于是,现在Monster类里(基于这种架构,其他所有的精灵类以及各种需要渲染的非精灵类)就没有任何与图形开发包相关的东西。而与图形开发包相关的东西则都集中到了Renderer类里。当你要移植代码时,只需要重写Renderer类即可。
但是这种架构还是有很多问题:
1.随着游戏越做越大,游戏中需要得到渲染的物体就会越来越多。这样,每次都给Renderer类添加一个RenderSomething之类的接口(注意,除了添加接口外很有可能还要添加一些渲染所需要的对象,例如Surface之类的)。Renderer类最终将成为一个非常巨大的类,这违背了面向对象设计的原则(巨类导致的最直接的坏处就是改代码痛苦-----鼠标滚轮滚半天才找到目标代码。just a joke。)
2.事实上,Renderer类的RenderSomething 之类的接口在被调用时还需要一些渲染参数。例如渲染一个精灵类的话,就需要知道要渲染哪一帧,以及渲染到哪里之类的信息。在某些时候,这些参数会让这些接口的declaration 变的很恶心!(代码要幽雅,先把格式写好看点------当然你愿意去参加“混乱代码大赛”的话倒可以朝这个方向发展,another joke)
三.我始终记得一句关于面向对象设计的话:“面向对象是用来模拟世界的”(大致是这样的)。这句话其实真的可以作为面向对象开发的一个指导性原则,它会让你在架构系统时变得很容易。因此,套用这句话的话,上面那种架构方式就出现一些让人觉得别扭的地方了。怪物对象为什么不能自己渲染自己?(难道一个人表现自己的能力都没有?烂比喻!)按照那条原则,怪物类理所应该拥有一个Render之类的接口!
于是,架构二稍微做了些变化:
我们让每个需要渲染的物体,这里是Monster,都有一个Render接口以及一个Renderer对象指针。在创建该精灵对象时,Game类把自己创建的Renderer对象指针传给该精灵对象。该精灵对象再保存该指针到mRender成员。当渲染时,Game类不再直接调用Renderer对象里的 RenderSomething之类的接口,而是很自然地调用Monster的Render接口。然后Monster的Render接口再通过自己保存的 Renderer对象指针调用其RenderMonster接口。
这里,我们为了让设计更贴近真实世界,就多加了一条依赖关系。(这其实纯碎是某些完美主义程序员的癖好-----for instance, me :D)
四.这可能是种被普遍使用的方式:
类图为:
这里不再有Renderer类了,精灵也不再象方式三中的那种“虚假”的Render了。取而代之的是,精灵类(以及其他需要渲染对象)自己直接使用图形引擎来渲染自己,自己也保存渲染所需要的信息,例如那个mImage。
这种方式的缺点在于:每个需要渲染的物体就都会与图形开发包相关。当然如果代码组织得当,在移植时也不是很费劲。但是它造成了代码层次上的相关性,让我觉得不是很好。(代码相关性:例如要涉及到图形引擎的类(例如Image ),还要包含一些图形引擎头文件,这样一折腾就让我觉得很不爽)
总结了以上四种方式,我在这里试着提出一种个人觉得更为好的架构方式(因为还没在实际的项目中实验过,所以只能说“试着”)。
关于渲染所需要的渲染信息,其最基本的信息就是类似于Surface,Image之类的用来代表该物体外观图片的对象。又由于图片资源的一些属性,很有可能还会涉及到一些辅助加工资源的变量(例如对于一个精灵,某一帧对应于图片上的矩形区域)。
这里涉及的问题就变为:一个需要被渲染的物体,除了其渲染代码与特定的图形开发包相关外,其渲染信息数据也与图形开发包相关。如何尽可能地把这些代码与逻辑部分代码分离?
于是我得到了如下的架构形式:
每一个需要渲染的物体都拥有一个其自己的渲染器,然后每个特定的渲染器还有一个其特有的资源管理器(这里主要管理图片资源-----事实上只是作为一种资源加工器而已)。
这种方式其实就是上面提到的方式二的改进版本,把方式二中的那个巨类分割开来。而为了更方便地进行渲染,Monster在创建MonsterRenderer对象时可以把自身作为参数传给MonsterRenderer并让其保存。这样,又多了条依赖关系:
有时候,对于一个精灵而言,为了灵活地配置其部分属性(这部分属性如果硬编码的话很有可能就是一些常量或者宏),我们还要为精灵增加一个属性读取器,它会从外部配置文件读入属性-----事实上这不是本文讨论的范围-----例如使用一种XML解析库来从XML配置文件中读取数据,可以这样设计:
如果精灵渲染信息数据也需要由外部文件配置,并且与那些属性被放在同一个配置文件中(这可能不是种好的习惯),这样,PropertiesReader和MonsterResMgr就会从同一个XML配置文件中读取数据。我们可以这样解决这个问题:
所有配置数据都由PropertiesReader读取,Monster负责创建PropertiesReader对象,然后把该对象指针传给 MonsterRenderer对象。事实上,因为MonsterRenderer对象保存有Monster对象指针,所以完全可以访问到 PropertiesReader对象