#游戏编程模式- 再探Flyweight模式 Tags: 游戏编程 设计模式 游戏开发
本系列博客是:Game Programming Patterns 的中文翻译版本。
翻译的github地址: cyh24. 如有兴趣,可联系博主共同翻译,一起造(wu)福(dao)他人。
博客虽然水分很足,但是也算是博主的苦劳了,
如需转载,请附上本文链接,不甚感激!
本系列博客 目录,可点击进入。
迷雾消散,一个古老而壮丽的原始森林呈现在了我们的面前。不计其数的古老的铁杉,像一座塔尖林立的绿色大教堂。在巨大的树干面前,以至于,你只有往后拉开一段距离,才能从树干之间的缝隙中辨认出这是一个巨大的森林。 这是游戏开发者梦想中的世外桃源般的设计,而正是一个设计模式使得这一梦想中的场景得以在现实中得到实现。而这个模式的名字却再低调不过了:Flayweight(享元模式)。 #化木为林 我可以很轻描淡写地就描绘了一个无边的森林场景,但是在一个实时的游戏中实现起来就是另一回事了。你在电脑前看到的是满屏的树木,而在图形程序员的严重,他们却是数以百万计的多边形,这些多边形必须以1/60秒的速度载入到GPU中。
每棵树都有如下的一些数据结构:
- 用于定义树干、枝杈、绿色植被等网格多边形;
- 树皮和树叶的纹理;
- 树木在森林中的位置和朝向;
- 还有一些用来使得树木跟其他看起来不同的一些微调的参数:大小,色彩等;
写成代码,就是如下结构了:
class Tree
{
private:
Mesh mesh_;
Texture bark_;
Texture leaves_;
Vector position_;
double height_;
double thickness_;
Color barkTint_;
Color leafTint_;
};
这些将是很大的一堆数据,并且模型和纹理贴图也是特别的大。一棵树尚且如此,更不用说一整个森林了,我们无法在一帧内把这些数据传送给GPU。幸运的是,有一个老字号的秘诀来处理这个事情。 这个秘诀的关键点是,森林里虽然有很多树,但是这些树看起来长得都差不多。他们完全可以使用相同的模型和纹理贴图。这就意味着所有树实例中有很大一部分是相同的。
我们可以试着把对象分成两份(公有和私有)。首先,我们先把所有树木公用的数据搬到一个单独的类中:
class TreeModel
{
private:
Mesh mesh_;
Texture bark_;
Texture leaves_;
};
游戏中只需要一个这样的对象就够了,因为没有必要把相同的模型和纹理贴图在内存中保存上千份。然后,在游戏中,每一个树木的实例都引用这个共享的TreeModel。这样之后,Tree这个类中就只剩下一些个性化的数据了:
class Tree
{
private:
**TreeModel*** model_;
Vector position_;
double height_;
double thickness_;
Color barkTint_;
Color leafTint_;
};
对于上面的代码,你可以想象成如下方式:
这样处理之后,就很好的解决了在内存中的存储问题,但是这个方式对渲染于事无补。在森林被画到屏幕上之前,它必须要先被传入GPU,我们需要把共享的资源展开成显卡能够识别的格式。
#一千个实例 为了使我们传入GPU的数据量最小化,我们希望能够将共享数据——TreeModel传入到GPU中。然后,分别把每一棵树的独有数据如位置、颜色、大小传进去。最后我们告诉GPU,你用这个模型渲染所有的实例吧。
幸运的是,现在的图形API和显卡都已经支持这种方式了。具体的细节已经超出了本书的讨论范围,但是Direct3D和OpenGL都可以使用这个叫做instance rendering的技术实现。
在这两个平台提供的API中,你需要提供两个数据流。第一个是将被渲染多次的公用数据块——我们例子中的模型和纹理。第二个数据流包含了一个实例列表以及它们个性化的参数,它们可以将每棵树都区分处理。这样,经过一次绘制,整个森林就长出来了。
#Flyweiht模式 现在,我们手里有了一个例子,接下来让我们来演练一下,看看你是否真的掌握了这个设计思想。Flyweight,就像它的名字一样,主要适用于当我们有大量的对象需要被缩减的时候。
通过instance rendering 技术,我们可以不再占用过多的内存,就像不再占用过多的总线传送时间一样。其基本思路是一致的。
Flayweight模式通过将一个对象的数据分成两类来解决问题。第一类不是单个实例个性化的数据,他们可以在所有实例间共享。GoF把这类数据叫做固有属性,但我更喜欢称之为上下文无关。在这个例子里,就是那些树的几何模型和纹理贴图。
剩下的数据就是外部属性,这部分每一个实例都是独一无饿=二的。在这个例子中,就是树的位置、大小、颜色。就像上面那段代码展示的那样,这个模式通过在每一个出现的对象中共享固定属性,来达到节省内存的目的。
到现在为止,这个方法就像是最基本的资源共享,其实很难被称之为一个设计模式。这可能使因为在这个例子中,我们很容易地分辨出哪一部分应该共享:TreeModel
我开始发现这个模式的不同寻常(其实很聪明),是在一些共享对象不那么好定义的例子中。在这些情况下,你会感觉像是在玩分身魔术。口说无凭,让我展示另一个例子。
在游戏中,我们常常需要为这些树木定制它们生长的地方。地点可能是草丛、泥地、山峦、湖面、大河,或者是你能想到的其他地形。我们制作的地形是基于分片的:大地的表面由大量小的片段组成。每一个片段都覆盖了一种类型的地面。
每一种地表类型都会有其特有的属性:
- 决定玩家移动速度的移动消耗。
- 一个标明是否是水域的标识,它决定了这块地形可不可以过船。
- 一张渲染时候需要的纹理贴图。
由于游戏开发人员对性能一般都比较偏执,所以我们不可能把所有的这些属性保存在世界的每一个面片中。一个常用的方法是用枚举的方式定义一个地形类型:
enum Terrain
{
TERRAIN_GRASS,
TERRAIN_HILL,
TERRAIN_RIVER
// Other terrains...
};
然后,由World类来维护这些大量的面片:
class World
{
private:
Terrain tiles_[WIDTH][HEIGHT];
};
为了能够从一个面片中得到有用的数据,我们通常是这么做的:
int World::getMovementCost(int x, int y)
{
switch (tiles_[x][y])
{
case TERRAIN_GRASS: return 1;
case TERRAIN_HILL: return 3;
case TERRAIN_RIVER: return 2;
// Other terrains...
}
}
bool World::isWater(int x, int y)
{
switch (tiles_[x][y])
{
case TERRAIN_GRASS: return false;
case TERRAIN_HILL: return false;
case TERRAIN_RIVER: return true;
// Other terrains...
}
}
你应该看得懂这个思想,而且它确实是可行的。不过,我认为这样的做法一点也不优雅。我觉的移动带来的消耗和该面片是不是水域应该是与地形相关的数据,但是这里的代码并没有体现出来。更糟糕的是,一个地形相关的数据被分散到一堆其他函数里面。所以,我认为最理想的状况是把他们整合到一起,这个才是我们设计这些对象的目的。
如果我们有如下这样一个地形的定义就太棒了:
class Terrain
{
public:
Terrain(int movementCost,
bool isWater,
Texture texture)
: movementCost_(movementCost),
isWater_(isWater),
texture_(texture)
{}
int getMovementCost() const { return movementCost_; }
bool isWater() const { return isWater_; }
const Texture& getTexture() const { return texture_; }
private:
int movementCost_;
bool isWater_;
Texture texture_;
};
但是如果所有地形面片都有一个实例,那会带来我们不可承受的负担。如果你仔细观察这个类,你会发现其实里面并没有哪个属性是某个面片特有的。在Flyweight模式中,所有的这些属性都应该被划为“固有属性”或者“上下文无关”。
考虑到这些,就没有理由创建多个Terrain对象了。一块青草地的面片都跟其他面片并没有什么区别。我们不再使用地形枚举或者地形对象构成的网格了,我们用指向Terrain对象的指针构成网格来代替:
class World
{
private:
Terrain* tiles_[WIDTH][HEIGHT];
// Other stuff...
};
所有用相同地形的面片都指向同一个Terrain实例。
由于这些Terrain实例在多个场合中被用到,如果它使用的内存是动态申请的,那他们的生命周期管理就会比较复杂。在这里,我们就直接在World中使用静态存储了。
class World
{
public:
World()
: grassTerrain_(1, false, GRASS_TEXTURE),
hillTerrain_(3, false, HILL_TEXTURE),
riverTerrain_(2, true, RIVER_TEXTURE)
{}
private:
Terrain grassTerrain_;
Terrain hillTerrain_;
Terrain riverTerrain_;
// Other stuff...
};
然后我们可以使用如下方式把地面绘制出来:
void World::generateTerrain()
{
// Fill the ground with grass.
for (int x = 0; x < WIDTH; x++){
for (int y = 0; y < HEIGHT; y++){
// Sprinkle some hills.
if (random(10) == 0){
tiles_[x][y] = &hillTerrain_;
}
else{
tiles_[x][y] = &grassTerrain_;
}
}
}
// Lay a river.
int x = random(WIDTH);
for (int y = 0; y < HEIGHT; y++) {
tiles_[x][y] = &riverTerrain_;
}
}
现在我们可以直接得到Terrain对象,而不用再通过World的方法来访问Terrain的数据了:
const Terrain& World::getTile(int x, int y) const
{
return *tiles_[x][y];
}
这样,World 类就不再跟Terrain 的实现细节耦合在一起了。如果你想得到一个面片的属性,你可以直接从Terrain 对象中得到:
int cost = world.getTile(2, 3).getMovementCost();
我们终于通过优秀的API找回了使用真正对象的快感,并且这没有带来额外的消耗——一个指针通常 不会比一个枚举带来更大的消耗。 #性能如何 我这里说通常, 是因为,有些对性能极致追求的人,会仔细地比较这种方法和使用枚举到底哪个性能更优。毕竟,通过指针引用,Terrain 的确经过了一次间接寻址。而为了能获得一块地形的属性如移动消耗,你必须先经过数组中的指针找到Terrain 对象,然后才能在那里得到移动消耗。这种指针寻址可能会有高速缓存不命中的情况,从而造成运行变慢。
我们经常说,做优化的黄金法则是先验证。当今的计算机硬件在性能方面已经足够复杂,以至于它不再受单一因素影响。在我对本章的测试结果中,Flyweight 并没有比枚举方式存在更多消耗。事实上,Flyweight 反而明显地快一些。不过,这取决于内存中其他部分是如何分配的。
我唯一能够确信的是使用Flyweight,不会造成你程序的失控,它通常会带来更易维护的特点。他在不带来额外开销的前提下,给了你一个使用面向对象的优点。如果你发现,你的代码里有大量的枚举或者switch语句,你就可以考虑用这种模式来代替。而如果你担心效率问题,那么你至少要在你将代码改的更难维护之前,做一下性能测试,看看使用Flyweight 造成的性能消耗是否真的如你所想的那么大。