Game Programming Patterns -- Flyweight
原文地址:http://gameprogrammingpatterns.com/flyweight.html
原作者:Robert Nystrom
原创翻译,转载请注明出处
迷雾散尽,一片古老宏伟的森林出现在你的面前。无数古老的铁杉林立,形成了一座绿色的大教堂。阳光穿过树叶,仿佛从斑驳的玻璃穹顶洒落下来,形成一道道金色朦胧的光束。从巨大的树干中间眺眼望去,这片森林浓密得一眼望不到边际。
这是我们游戏开发者梦想中超凡脱俗的场景设定,而类似这种的场景经常用一个名字低调到不能再低调的模式来实现:这就是低调的享元模式(Flyweight)。
有树才有森林
我可以用几句话就形容出一片茂密的森林,但是要在一个实时运行的游戏中实现它就是另外一回事了。当你要把整片由各不相同的树木形成的森林呈现在屏幕上时,一个图形程序员所想到的是他在每个60分之一秒(1帧)都得把这成千上万的多边形塞到GPU中去。
我们在讨论的是成千上万棵树,每棵树都有着详细的包含了上千个多边形的几何结构。即使你有足够的内存去存放这片森林,但是如果要在屏幕上渲染它的话,这些数据还需要从CPU通过总线传输到GPU中。
每棵树都包含了以下这些部分:
- 用来规定树的主干、分支和树叶的形状的多边形网格模型,。
- 树皮和树叶的纹理。
- 这棵树在树林中的位置和朝向。
- 用来调整尺寸和色调的参数,以使得每棵树看起来都不一样。
如果用代码来概述的话,差不多就像下面这样:
class Tree
{
private:
Mesh mesh_;
Texture bark_;
Texture leaves_;
Vector position_;
double height_;
double thickness_;
Color barkTint_;
Color leafTint_;
};
如此多的数据、网格模型和纹理真的是非常庞大。用这些去构成一个森林的话,GPU在一帧内所需处理的东西就太多了。幸运的是,有一个备受推崇的小技巧可以解决这个问题。
这个技巧最关键的观点是,虽然森林里有成千上万棵树,但是其实它们看起来都差不多。它们可能使用了相同的网格模型和纹理。这意味着这些树对象中的大部分属性在它们的实例中都是相同的。
如果你让美术们给森林中的每棵树都做一个不同的模型的话,你不是疯了就是个亿万富翁。
***注意,在下方那些小方框中的东西对每棵树来说都是完全一样的。 ***
因此我们可以明确地把书对象分成两个部分来建模。首先,我们取出所有的树对象共有的属性并把它们转移到一个单独的类中:
这看起来很像是类型对象(Type Object)模式。 它们都是把一个对象的部分属性委托给另外一个对象,然后把这部分属性给很多实例共享。 然而,这两个模式的意图却是不同的。
类型对象模式是通过把类型提取到你自己的对象模型中,以达到减少你所需要定义类的数量的目的。其带来的内存共享,只是额外的好处。而享元模式则纯粹是关于效率的考量。
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,“就用那一个模型去渲染所有的树吧。”
幸运的是,如今的图形编程接口和显卡硬件已经支持这种方式了。虽然具体实现的细节是很繁琐的,已经超出了本书的范畴,但是Direct3D和OpenGL是都可以做到这种被称为实例渲染(instanced rendering)的功能的。
***这个API是由显卡硬件直接实现的,这意味着享元模式可能是GOF提出的设计模式中唯一实际被硬件支持的。 ***
在它们的API中,你需要提供两部分数据流。第一部分是需要渲染很多次的共同数据块--树的网格模型和纹理。第二部分是实例的列表和这些实例的参数,它们被用来在每次绘制时对第一部分的数据进行调整。这样只需要一次绘制调用(draw call),整个森林就出现了。
享元模式
现在我们已经有了一个实际的例子了,接下来我将带你通览一下这个模式。享元,就像它的名字喻示的那样,是在你需要把一些对象更加轻量化的时候发挥作用的,而这些对象需要轻量化的原因通常是因为它们的数量实在是太多了。
通过实例渲染技术,这些对象所占用的内存是没有其花费在总线上把每棵不同的树传输到GPU里的时间多的,不过其基本原理是一样的。
在享元模式中,是通过把对象的数据分成两类来解决这个问题的。第一类数据是对于对象的每个实例来说相同并且可以共享的部分。GOF把这部分数据称作固有属性,而我更喜欢把它称作“上下文无关”属性。在我们的例子中,就是树的网格模型和纹理。
另一类数据是外部属性,它对于每个实例来说都是不同的。在我们的例子中,就是树的位置、尺寸和颜色这些。就像上面的代码示例里一样,这个模式通过在每个对象出现的地方共享一份固有属性的拷贝,来达到节约内存的目的。
看到这里我们会觉得,这不过是基本的资源共享,很难被称为一种模式。这种观点是片面的,因为在我们的例子中,可以清晰地把需要共享的属性区别出来:就是TreeModel类。
我发现这个模式被使用在一些无法清楚定义共享对象的情况下时,会显得不那么显眼(而因此显得更加巧妙)。在这些情况下,感觉起来更像是一个对象神奇地在同一时间出现在了多个地方。下面让我来给你们展示另一个例子吧。
根之所在
这些树生长所需要的地面在我们的游戏中同样需要被展示出来。地面可以通过诸如草地、泥地、山丘、湖泊、河流以及任何你能想象出来的地形拼接出来。我们所要做的地面是基于分块的(tile-based):世界的表面是一个由小分块构成的巨大网格。每一个分块都用一种地形来覆盖。
每种地形类型都会有一些影响游戏体验的属性:
- 移动消耗,决定了玩家在这种地形上移动速度的快慢。
- 是否是水面的标记,用来判断船只是否可以通过。
- 纹理,用来渲染地形。
因为我们游戏开发者对效率的高低都是偏执狂,所以我们不会允许把这些属性存储在游戏中的每一个地形分块里。因此,一个通用的解决方案是为地形类型创建一个枚举:
毕竟,我们已经在之前的那些树身上获得过教训了。
enum Terrain
{
TERRAIN_GRASS,
TERRAIN_HILL,
TERRAIN_RIVER
// Other terrains...
};
然后游戏世界为此保存一个巨大的二维网格:
class World
{
private:
Terrain tiles_[WIDTH][HEIGHT];
};
这里我用了一个二维嵌套数组来储存这个2D网格。这在C/C++中是非常高效的,因为这两种语言中会把数组里的所有元素打包在一起。而在Java或者其他有内存管理的语言中,这样做的话得到的将是其行数组中每一个元素都是一个对列数组的引用的数组,而这对于内存使用就不大友好了。
不管在哪种语言中,真正写代码的时候都是把这些实现细节隐藏在一个好用的2D网格数据结构里要更好一些。我在这里这么写只是为了让它看起来好理解一些。
为了实际得到每个分块的有用数据,我们会像下面这样做:
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_;
};
你可能注意到了,这里所有的方法都是const类型的。这并不是巧合。因为同一个对象是要在很多不同的环境中使用的,如果你要修改它的话,那很多地方都会同时发生改变。
这可能不是你想要的效果。分享对象的内存占用的优化不应该影响到应用的可见行为(visible behavior)。因此,享元对象几乎都是不可改变的。
但是我们并不想为游戏世界里的每一个分块都保存一个实例。如果你有用心观察上面那个类的话,你会注意到没有任何关于这个分块的位置信息。在享元模式中,所有地形的状态都应该是固有的,或者说是上下文无关的。
因此,我们没有理由去给每种地形保存一个以上的实例。地面上的每个草地的分块和其他的没有什么不同。这样就可以把之前那些枚举或者Terrain对象的二维数组替换成一个指向Terrain对象指针的二维数组:
class World
{
private:
Terrain* tiles_[WIDTH][HEIGHT];
// Other stuff...
};
所有使用相同地形的分块都会指向同一个地形实例。
因为这些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_;
}
}
我承认这确实不是世界上最好的地形生成算法。
现在我们可以不用再通过访问World类中的方法去获取Terrain的属性了,而是可以直接获取到Terrain对象:
const Terrain& World::getTile(int x, int y) const
{
return *tiles_[x][y];
}
这样的话,World类就不再和Terrain的细节有任何耦合。如果你想获取某个分块的属性的话,你可以从它的对象中获取到:
int cost = world.getTile(2, 3).getMovementCost();
我们回到了愉快的与真实对象互动的API操作上,而且这也几乎没有任何额外消耗--一个指针通常是不会比一个枚举类型大的。
性能如何呢?
注意上面我用的是“几乎”,因为对善于计算性能的人来说,他们想要知道使用指针和枚举比起来到底会消耗多少性能。通过指针来引用terrain意味着间接的查询。如果想要获取一些terrain的数据,诸如movement cost之类的,你必须首先跟随grid中的指针去找到terrain对象,然后才能获取到这个movement cost数值。像这样跟踪指针会导致高速缓存缺失(cache miss),而这是会导致性能变差的。
更多有关指针追踪和高速缓存缺失的细节,请参见章节 数据本地化(Data Locality)。
通常来说,优化的黄金法则是“profile first”。现代计算机硬件的复杂程度已经达到不会因为某个单纯的原因而造成性能上的问题。在我对本章内容的测试中,是没有发现使用享元来代替枚举有什么影响性能的地方。享元对速度有非常显著的提高。不过这完全依赖于内存上的其他内容是如何分布的。
我所确信的时,使用享元对象不会脱离我们的控制。它给你带来了面向对象形式的好处而并没有一堆对象的额外消耗。如果你发现自己正在创建一个枚举类型,并且正在对它使用switch方法,你就可以考虑使用享元来代替它了。如果你担心性能的话,至少在把你的代码变成难以维护的类型之前,进行一下性能分析吧。
参见
在上面那个tile的例子中,我们一上来就为每一种terrain类型创建了一个实例,然后把它保存在了World中。这让使得查找和使用共享实例变得很简单。不过在很多情况下,你可能并不想在一开始就去创建所有的享元。
如果你不能保证哪些享元是你确实会用到的,那就最好在需要的时候再去创建它们。而为了利用到共享的好处,当你请求一个实例的时候,你可以先看看自己是否已经创建过一个。如果是的话,你只需要返回那个已经创建好的实例。
这通常是意味着你需要将构造函数封装在一些首先会查找已存在对象的接口下。像这样来隐藏构造函数的例子使用到了工厂模式。为了可以返回一个之前创建过的享元,你需要跟踪一个存储池,这里保存了所有的已创建对象。就像池这个名字暗示的那样,对象池可能会是一个对于保存这些对象很有帮助的模式。
当你使用状态模式时,会经常有一些和使用它们的状态机没有特定关联的状态。而这些状态的特性和方法对你是有一定作用的。在这种情况下,你就可以使用享元模式去在多个状态机中同时重用同一个状态实例,而这样是不会有任何问题的。
因为水平有限,翻译的文字会有不妥之处,欢迎大家指正
“本译文仅供个人研习、欣赏语言之用,谢绝任何转载及用于任何商业用途。本译文所涉法律后果均由本人承担。本人同意简书平台在接获有关著作权人的通知后,删除文章。”