游戏编程模式笔记——第三章 享元模式

“使用共享以高效地支持大量的细粒度对象。”
游戏中的森林有成千上万的树木,但它们大部分看起来是相似的。它们可能会全部使用相同的网络和纹理数据。这意味着在这些对象实例中,大多数字段都是相同的。
很明显,我们可以将对象分割成两个独立的类。首先,我们将所有树木通用的数据放到一个单独的类中:

1
2
3
4
5
6
7
class TreeModel
{
private:
Mesh mesh_;
Texture bark_;
Texture leaves_;
};

整个游戏只需要一份这样的数据,因为没有理由为相同的网格和纹理分配成千上万份内存。然后,游戏世界中每一颗树的实例都有一个指向共享的TreeModel的引用。Tree类中的其他数据成员用来形成树木之间的差异:

1
2
3
4
5
6
7
8
9
10
class Tree
{
private:
TreeModel* model_;
Vector position_;
double height_;
double thickness_;
Color barkTint_;
Color leafTint_;
};

为了最大程度地减少发送到GPU上的数据量,我们希望只发送一次共享数据——TreeModel。然后我们再单独地将每棵树实例的特有数据——位置、颜色和缩放比推送到GPU。最后,我们告诉GPU,“使用那个共享的模型来渲染每个实例”。

享元模式
顾名思义,一般来说当你有太多对象并考虑对其进行轻量化时它便能派上用场。
享元模式通过将对象数据分成两种类型来解决问题。第一种类型数据是那些不属于单一实例对象并且能够被所有对象共享的数据。GoF将其称为内部状态(the intrinsic state),但我更喜欢将它认为是“上下文无关”的状态。
其他数据便是外部状态(the extrinsic state),对于每一个实例它们都是唯一的。

使用瓦片(Tile-based)的技术来构建地面:游戏世界的地面是由许多细小的瓦片组成的巨大的网格。每一个瓦片都由某种地形所覆盖。

1
2
3
4
5
6
7
enum Terrain
{
TERRAIN_GRASS,
TERRAIN_HILL,
TERRAIN_RIVER
// Other terrains...
};

像下面代码这样实现地形类,是非常值得肯定的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Terrain
{
public:
Terrain(int movementCost, bool isWater, Texture texture) :
moveCost_(moveCost),
isWater_(isWater),
texture_(texture)
{}
int getMoveCost() const { return moveCost_; }
bool isWater() const { return isWater_; }
const Texture& getTexture() const { return texture_; }
private:
int moveCost_;
bool isWater_;
Texture texture_;
};

你会发现,瓦片中并没有标志其位置的特殊代码。在享元术语中,地形的所有状态都是“内在的”或者“上下文无关的”。
地形实例会被多处使用,如果你是动态地分配它们的话,则它们的生命周期会有些复杂。因为我们直接将他们存储在游戏世界中。

1
2
3
4
5
6
7
8
9
10
11
12
13
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...
};

现在我们可以像下面一样直接暴露地形对象,而无需访问World类的地形属性。

1
2
3
4
const Terrain& World:: getTitle(int x, int y) const
{
return *titles_[x][y];
}

这样一来,World就不再和地形的各种细节耦合。如果你想得到砖块的某些属性,你可以从砖块对象来获得它。

1
int cost = world.getTile(2, 3).getMovementCost();

性能
你需要通过网格中的指针来找到地形对象,然后访问其移动开销。跟踪这样的指针会引起缓存未命中,从而会拖慢速度。
参考
如果你不能预测哪些是你真正需要的,则最好按需创建它们。
这通常意味着在一些用来查找现有对象的接口背后,你必须做些结构上的封装。像这样隐藏构造函数,其中一个例子就是工厂方法模式。

  • 为了找到以前创建的享元,你必须追踪哪些你已经实例化过的对象的池(pool)。正如其名,这意味着对象池模式对于存储他们会很有用。
  • 在使用状态模式时,你经常会拥有一些”状态”对象,对于状态所处的状态机而言它们没有特定的字段。状态的标识和方法也足够有用。在这种情况下,你可以同时在多个状态机中使用这种模式,并且重用这个相同的状态实例并不会带来任何问题。