-潘宏
-2012年12月
-本人水平有限,疏忽错误在所难免,还请各位高手不吝赐教
-email: [email protected]
-weibo.com/panhong101
地图系统
地图系统是游戏开发永恒不变的一个主题。在大多数游戏开发中,我们都需要和地图打交道。不同的游戏可能采用不同的地图系统,比如对于2D游戏来说,可能的地图类型包括:
1)Rectangular tile,即矩形tile。
2)Isometric,即等测地图。
3)Hexagonal tile,即六边形tile。
等等。
而对于3D游戏来说,一般包括两种主要类型:
1)基于3D空间的2D地图
2)完全3D空间地图
对于第一种类型,实际上是2D地图系统的一种3D推广,游戏中的地图还是在一个2D平面上,但是在3D空间的一个平面上。场景中的游戏对象可以都是3D模型。但地图本身仍然通过2D地图的方式进行处理。
第二种一般在3D图形学中叫做场景管理(Scene Management)。这是对真3D场景的一种处理方式:地图信息不再处于某个平面上,而是具有任意的3D几何信息。对于处理这种非规则的3D几何地图系统,我们一般采用分类的方式进行单独处理,比如:
1)室外地形系统
2)室内地图系统
3)基于portal的系统
等等。针对不同的类型,3D场景系统一般采用不同的管理方法,比如quadtree、octree、bsp、portal等等场景管理方式。可以说这个领域很广泛、也很复杂。
我们当然不可能具体地讨论所有这些地图系统。那么我们讨论什么呢?我们将讨论一种思想,这种思想贯穿于所有的这些地图管理方法之中,以提供一种健壮、合理、便利的代码结构。而这种结构不论是在游戏产品还是在开发工具中,都扮演着举足轻重的作用。这就是层次体结构。
层次体结构
对于复杂的问题以及结构,我们都倾向于一种称为“分治法”的方式进行处理。层次体结构实际上就是一种分治法,它通过一种树形结构来表示“整体-部分”的层次概念。基于这种方法,开发者可以很容易地对系统进行不同等级的灵活控制,同时获得一种复用性优势。
我们来举一个简单例子,来让大家更好地理解这些抽象概念。对于最简单的2D矩形tile引擎来说,开发者们经常会把场景分成多个层(layer)和一个根(root),如下图所示:
这就是一个最简单的层次体结构。它表明了一种整体和局部的从属关系:root可以看作这个地图本身,它代表整体。而它的子结点,则看作是这个地图的每个层,他们代表局部,属于整体。我们再具体一点,假设我们把地图分成了3层:
Layer 1 - 地面层
Layer 2 - 中间层
Layer 3 - 天空层
地面层是永远处于”最下面“的层,地图上的任何东西都会处于它的上面,这一层包括普通土地、草坪、沙土地、公路等类型的tile。中间层就是地图上的对象层,它永远处于地面层之上,这一层包括:房子、树木、玩家角色、NPC等活动对象,这些对象之间的位置关系会随时变化,因此互相遮挡的关系也会变化,需要实时进行排序。天空层包括天上飞的鸟、飘浮的云朵等对象,它们永远处于地面层之上。
更进一步地,我们可以在每个layer上再增加子layer——这可以根据具体游戏对象的类型,比如把所有沙土地对象,都放到一个layer上,当玩家走到上面的时候,会减慢速度。如下图所示:
如我们所看到的,对这些对象进行3个layer分类,我们获得了很多好处:
1)分区渲染-渲染的顺序可以通过layer自然的处理:地面、中间、天空,这样的顺序保证了渲染结果的可视关系的正确。
2)分区更新-只有中间层的对象需要实时排序,这避免了对无需排序对象进行排序的无效计算。另外,可以直接用沙土层和玩家做碰撞检测——避免了不必要的计算。
3)总体控制-在对地图进行编辑的时候,我们可以非常方便地显示、隐藏、移动、删除不同的层。被操作的每个层上的对象都得到统一的处理。
而这些好处也不仅仅局限于矩形2D tile地图,任何地图系统都可以从中得到这样的好处。比如在3D的场景管理中,quadtree、octree、bsp本身就是一种层次体结构。它们通过树形结构对场景进行划分,从而以一定的规则将场景中的多边形安排到树中。另外,对于3D的所谓保留模式(Retained Mode)渲染,实际上就是通过把场景安排成一颗场景树来进行处理。系统一般会提供一个类似这样的接口:
void GraphicsEngine::render( SceneNode& sceneTreeRoot );
sceneTreeRoot就是场景树的根结点,从该结点向下就可以遍历整个树形结构,并进行任何你希望的操作——这当然以渲染为主。
在一些支持保留模式渲染的引擎中,一般设计师或美工会通过一些所见即所得的工具进行场景编辑,放置模型,虚拟物体,架设相机等等。最后导出一个文件,供引擎使用。而导出的往往就是一颗场景树,连相机都会作为一个结点存在于该结构中。
复用性
层次体结构的另一个巨大优势在于复用性,而这往往体现在设计和实现两个层面上。想象我们在地图里面将几块石头摆放成了一种特殊形状,这种形状暗示了某种游戏中的宗教意义。而根据设计需求,该形状可能会摆在地图中的很多位置。由于整个地图通过组合模式被建立成了一棵场景树,我们则可以把这个形状的几个石头,单独的做成一个子layer。然后在不同的地方(结点)放置这些子layer。而在代码级别上,这些layer中的石头实际上是通过类对象指针保存的,因此存放开销完全可以接受。所以,我们每次给一个结点增加一个这样的layer,就添加了这些石头所组成的一个形状——我们实际上是通过一种组合的方式进行特定结构的复用。请注意,这些复用是可以嵌套的!这意味着你可以用这些子layer再组合更复杂的层,然后服用这些更复杂的layer。这种复用带来的威力是巨大的,在两方面提供优势:
1)设计开销 -通过定义一个子layer,我们可以反复使用该layer,还可以嵌套!
2)内存开销 -只通过指针存储对象,实际对象内存只有一份。
这种复用性带来的利益驱使我们开发了一套2D动画系统——用场景树的方式组织动画。允许对不同复杂组件(可以看作这里的layer)的嵌套。以后如果条件允许,我会写一篇关于该系统的文章,帮助一下初学游戏开发的朋友理解2D动画系统。
层次体的简单实现
层次体结构实际上是一种组合(Composite)模式。根据GoF的著作《设计模式》所描述:组合模式通过一种树形结构来体现一种“整体和部分”的观念,从而让处理单一对象和对象聚合存在一种统一的形式。下图所示即组合模式:
Leaf是单一对象,composite是组合对象,也就是一个对象聚合体。它们都继承自component类,拥有相同的操作方式Operation——这是关键,也是组合模式的核心思想:在客户(client)看来,操作一个leaf和一个composite没什么区别,都调用它的Operation,但composite是个聚合体,它会在自己的Operation中调用每一个聚合对象的Operation。
这里单一对象就可以理解为地图中的一个具体对象(树木、房子、NPC等等),而组合对象则可以看成是地图的一个layer——它聚合其它对象(树木、房子、NPC,或者是另一个layer)。
下面我们实现一个简单的层次体结构,从代码级别来深入认识该系统:
class MapNode { public: virtual ~MapNode() {} virtual void cycle() = 0; virtual void draw( GraphicsContext& g ) = 0; }; class Node_Layer : public MapNode { public: void addChild( SceneNode* child ) { m_children.push_back( child ); } void removeChild( SceneNode* child ) { for( std::vector< SceneNode* >::iterator it = m_children.begin(); it != m_children.end(); ++it ) { if( *it == child ) { m_children.erase( it ); break; } } } virtual void cycle() { for( int i = 0; i < m_children.size(); ++i ) { m_children[i]->cycle(); } } virtual void draw( GraphicsContext& g ) { for( int i = 0; i < m_children.size(); ++i ) { m_children[i]->draw( g ); } } protected: std::vector< SceneNode* > m_children; }; class Node_Tree : public MapNode { public: virtual void cycle() { Update the state of the tree... } virtual void draw( GraphicsContext& g ) { Draw the tree via g... } };
以上是一个非常简单的组合代码结构。 MapNode是地图结点基类,我们用它的指针或引用来操作整个系统。它提供两个抽象方法
void MapNode::cycle
void MapNode::draw
分别用来进行帧更新和渲染,具体实现由子类决定。Node_Layer就是地图层类,它就是所谓的聚合体。Node_Tree是一个单一对象,它表示地图上的树这种对象。可以看到它们对具体操作的实现清晰地表达了整体-部分的思路:Node_Layer把操作分散到各个子对象中,而Node_Tree则是老老实实作自己的事情。这便是一个最简单的层次体结构实现。一个简单的游戏和可能会如此使用上述代码:
class Node_Root : public Node_Layer {}; class Node_Player : public MapNode { public: virtual void cycle() { Update the state of the player... } virtual void draw( GraphicsContext& g ) { Draw the player via g... } }; Node_Root root; Node_Player player; Node_Tree tree; root.addChild( &player ); root.addChild( &tree ); for(;;) { root.cycle(); root.draw( g ); }
我们建立了一个root类作为世界的根结点。然后生成了一个玩家和一棵树,都放进了场景里,接着进入了我们的游戏循环。还有比这更简单的么?
以该结构作为一个开始,开发者可以进行扩展。包括加入MapNode的共性成员:
位置
可见性标志
等等。对于非聚合类型的MapNode子类的设计,那就和你的游戏相关了。比如对于Node_Player来说,可以增加移动控制、生命、动画等等。这里提供的只是一种设计上的思路,而经过开发者的细节设计和扩展,该系统将发挥实际功效。
总结
上面我们介绍了层次体概念以及该结构的简单实现。它是一种优秀的地图系统、场景管理方法。不论是在2D还是3D中,都可以发挥巨大的威力。不论对于游戏开发初学者还是专家,在实际的游戏代码中你都会看到它的影子。因此,独立地考察和分析该系统对于开发者来说都是有很必要的。