享元模式
游戏设计模式Design Patterns Revisited
迷雾散尽,露出了一片壮丽的原始森林。数不清的古老铁杉高耸在头顶, 交织成一座绿色的大教堂。树叶如同彩色玻璃般的天篷,把阳光打碎成一缕缕金色的雾中光柱。 透过巨大的树干,你能辨认出广袤的森林一直延伸到远方。
这是我们作为游戏开发者所梦想的那种超脱尘世的场景,而这样的场景往往离不开一个名字再朴素不过的模式:低调的享元模式。
见树又见林
我用几句话就能描述一片绵延的林地,但在实时游戏中真正实现它就是另一回事了。 当屏幕上要填满一整片由独立树木组成的森林时,图形程序员看到的只有数百万个多边形—— 他们得想办法每六十分之一秒就把它们铲进GPU。
我们说的可是成千上万棵树,每棵树都有包含上千个多边形的精细几何体。 就算你有足够的内存来描述这片森林,要渲染它,这些数据还得通过总线从CPU传到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:“用那一个模型渲染这些实例中的每一个。”
幸运的是,如今的图形API和显卡正好支持这一点。 细节很琐碎,也超出了本书的范围,但Direct3D和OpenGL都支持一种叫做实例渲染的技术。
在这两种API中,你都要提供两路数据流。 第一路是要被渲染多次的公共数据块——在我们这个关于树的例子中就是网格和纹理。 第二路是实例及其参数的列表,每次绘制时用它们来让第一块数据产生变化。 只需一次绘制调用,整片森林就长出来了。
享元模式
好,我们已经掌握了一个具体例子,下面带你过一遍这个模式的一般形式。 享元,顾名思义,会在对象需要变得更轻量时派上用场—— 通常是因为对象数量太多了。
在使用实例渲染时,问题与其说是它们占用了太多内存,不如说是把每棵独立的树通过总线送到GPU花了太多时间,但基本思路是一样的。
这个模式通过把对象的数据分成两类来解决这个问题。 第一类数据并不专属于对象的某一个实例,可以在所有实例之间共享。 GoF把它叫做内在状态,不过我更喜欢把它想成“与上下文无关”的东西。 在这个例子里,就是树的几何体和纹理。
剩下的数据是外在状态,即每个实例所独有的东西。 在这个例子里,就是每棵树的位置、缩放和颜色。 正如上面的示例代码块那样,这种模式通过在对象出现的每个地方共享同一份内在状态来节省内存。
就我们目前所见,这看上去像是基本的资源共享,几乎不配被称为一种模式。
部分原因在于,这个例子里我们可以为共享状态找出一个清晰独立的身份:TreeModel。
我发现,当共享对象没有一个定义清晰的身份时,这个模式就没那么显而易见(因而也就更巧妙)。 在那些情况下,感觉更像是一个对象神奇地同时存在于多个地方。 让我再给你看一个例子。
扎根之所
这些树所生长的地面也需要在我们的游戏中表示出来。 这里可能有草、泥土、丘陵、湖泊、河流,以及其它任何你能想到的地形。 我们基于区块建立地表:世界的表面被划分为由微小区块组成的巨大网格。 每个区块都由一种地形覆盖。
每种地形类型都有一系列特性会影响游戏玩法:
- 决定玩家能以多快的速度穿过它的移动开销。
- 一个标志,表明它是否是船只可以通过的水域地形。
- 用来渲染它的纹理。
因为我们游戏程序员对效率偏执到不行,我们绝不会把所有这些状态存进世界里的每一个区块中。 相反,一种常见的做法是为每种地形类型使用一个枚举。
enum Terrain
{
TERRAIN_GRASS,
TERRAIN_HILL,
TERRAIN_RIVER
// 其他地形
};
然后,世界维护一个由它们组成的巨大网格:
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;
// 其他地形……
}
}
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;
// 其他地形……
}
}
你明白我的意思了。这能行,但我觉得它很丑。 我把移动开销和湿润度看作是关于地形的数据,但在这里它们被硬编码在了代码中。 更糟的是,单一地形类型的数据散落在一堆方法里。 如果能把所有这些封装在一起就好了。毕竟,对象正是为此而设计的。
如果我们能有一个真正的地形类就好了,像这样:
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_;
};
但我们不想为世界里的每个区块都负担一个实例的开销。 如果你看看这个类的内部,会发现里面实际上没有任何与区块在哪里有关的东西。 用享元的术语来说,地形的所有状态都是“内在的”,或者说“与上下文无关的”。
既然如此,每种地形类型都没有理由存在多于一个实例。
地面上每个草区块都与其它草区块完全相同。
世界不再是由枚举或Terrain对象组成的网格,而是由指向Terrain对象的指针组成的网格:
class World
{
private:
Terrain* tiles_[WIDTH][HEIGHT];
// 其他代码……
};
使用相同地形的每个区块都会指向同一个地形实例。

由于地形实例被用在很多地方,如果动态分配它们,管理它们的生命周期会有点复杂。
因此,我们直接把它们存储在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_;
// 其他代码……
};
然后我们可以像这样用它们来绘制地面:
void World::generateTerrain()
{
// 将地面填满草皮.
for (int x = 0; x < WIDTH; x++)
{
for (int y = 0; y < HEIGHT; y++)
{
// 加入一些丘陵
if (random(10) == 0)
{
tiles_[x][y] = &hillTerrain_;
}
else
{
tiles_[x][y] = &grassTerrain_;
}
}
}
// 放置河流
int x = random(WIDTH);
for (int y = 0; y < HEIGHT; y++) {
tiles_[x][y] = &riverTerrain_;
}
}
现在,我们不再需要World中用来访问地形属性的方法,可以直接暴露Terrain对象:
const Terrain& World::getTile(int x, int y) const
{
return *tiles_[x][y];
}
用这种方式,World不再与各种地形的细节耦合。
如果你想要某一区块的属性,可直接从那个对象获得:
int cost = world.getTile(2, 3).getMovementCost();
我们回到了与真实对象打交道的那种宜人的API,而且几乎没有额外开销——指针通常并不比枚举大。
性能如何?
我在这里说“几乎”,是因为那些对性能锱铢必较的人理所当然地想知道它和使用枚举相比如何。 通过指针引用地形意味着一次间接查找。 要拿到移动开销这样的地形数据,你得先顺着网格里的指针找到地形对象, 再在那里找到移动开销。这样追逐指针可能会导致缓存未命中,从而拖慢速度。
和往常一样,优化的金科玉律是先做性能分析。 现代计算机硬件太复杂,性能已经不再是一个纯靠推理就能搞定的游戏。 在我为本章所做的测试中,使用享元相比枚举没有任何性能损失。 享元实际上还明显更快。但这完全取决于内存中其它数据是如何布局的。
我能肯定的是,使用享元对象不应被轻易否定。 它让你获得面向对象风格的优势,却不必付出大量对象的开销。 如果你发现自己创建了一个枚举,又在它上面写了很多switch分支,不妨改用这个模式。 如果你担心性能,那么至少在把代码改成更难维护的风格之前,先做性能分析。
参见
-
在区块的例子中,我们只是为每种地形事先创建一个实例,并存储在
World中。 这样更容易找到并重用这些共享实例。 但在多数情况下,你不会想一开始就创建所有享元。如果你无法预料自己实际需要哪些,最好按需创建它们。 为了获得共享的优势,当你请求一个实例时,先看看是否已经创建过一个相同的实例。 如果是,就只需返回那个实例。
这通常意味着你得把构造过程封装在某个能先查找现有对象的接口之后。 像这样隐藏构造函数就是工厂方法模式的一个例子。
-
为了返回一个先前创建的享元,你需要追踪已经实例化的那些享元所组成的池子。 顾名思义,对象池可能是存储它们的好地方。
-
当你使用状态模式时, 经常会遇到一些“状态对象”,它们没有任何专属于使用该状态的那台状态机的字段。 状态的标识和方法就足以派上用场了。 在这种情况下,你可以应用享元模式,同时在多个状态机中重用同一个状态实例而不会有任何问题。