Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

享元模式 (Flyweight)

运用共享技术有效地支持大量细粒度的对象。

— GoF 定义

动机

迷雾散去,一片壮丽的原始森林映入眼帘。古老的铁杉树不计其数,高耸入云,在你头顶形成一座绿色的穹顶。树叶交织成的彩绘玻璃将阳光打碎成一束束金色的雾状光柱。巨干之间,你能辨认出浩渺的森林延伸到远方的尽头。

这就是我们游戏开发者梦寐以求的超凡场景。而这样的场景,往往得益于一个名字质朴到不能再质朴的模式:不起眼的享元(Flyweight)。


只见树木,不见森林

我可以用几句话描述一片广袤的林地。然而,在实时游戏中真正实现它又是另一回事了。当你有一整片森林的树木塞满屏幕时,图形程序员看到的只有数以百万计的多边形——它们必须在每六十分之一秒内被塞进 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 中,你提供两股数据流。第一股是会被多次渲染的公共数据块——在我们这个森林例子中就是网格和纹理。第二股是实例列表及其参数,用来在每次绘制时变换第一股数据。只需一次绘制调用,一整片森林就生长出来了。


享元模式

现在我们有了一个具体例子做铺垫,我可以带你过一遍通用模式了。享元(Flyweight)——顾名思义——用于那些需要变得更“轻“的对象,通常是因为你有太多了

在实例化渲染的例子中,与其说是它们占用了太多内存,不如说是把每棵树逐一推过总线到 GPU 花了太多时间,但基本思路是一样的。

该模式通过将对象的数据分离为两种来解决这个问题。第一种数据是跟特定实例无关、可以在所有实例间共享的东西。GoF 称之为内在状态(intrinsic state),但我更喜欢把它想成“上下文无关“的东西。在树的例子中,这就是几何体和纹理。

其余数据是外在状态(extrinsic state),即每个实例特有的东西。在这里就是每棵树的位置、大小和颜色。就和上方的示例代码一样,该模式通过在对象出现的每个地方共享内在状态的一份拷贝来节省内存。

从目前看到的来说,这看起来像是基本的资源共享,几乎不配被称为一个模式。这部分是因为在这个例子中,我们能为共享状态找出一个清晰的独立身份TreeModel

我发现,在共享对象没有明确定义的独立身份时,这个模式才显得不那么明显(也因此更加巧妙)。在那些情况下,感觉更像是某个对象同时神奇地出现在多个地方。我给你看另一个例子。


落地生根

这些树生长的地面也需要在我们的游戏中呈现。可能有草地、泥土、山丘、湖泊、河流,以及你能想象到的任何其他地形。我们让地面基于瓦片(tile):游戏世界的表面是一个由微小瓦片组成的巨大网格。每个瓦片被一种地形类型覆盖。

每种地形类型有一系列影响游戏玩法的属性:

  • 移动消耗(movement cost):决定玩家通过它的速度
  • 是否为水域(water):能否让船只通过
  • 用于渲染的纹理

因为我们游戏程序员对效率有偏执,我们绝不会把所有这些状态存在世界中的每个瓦片里。相反,一种常见做法是用枚举表示地形类型:

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 里。这样查找和复用共享实例很方便。但在很多情况下,你可能不想提前创建所有的享元。 如果你无法预测实际需要哪些,最好按需创建。为了享受共享的好处,当你请求一个时,先看看是否已经创建过一个完全相同的。如果是,直接返回已有实例。 这通常意味着你需要用一个接口来封装构造过程——先寻找已存在的对象。像这样隐藏构造函数是工厂方法模式的一个例子。

  • 为了返回之前创建过的享元,你需要追踪已经实例化的对象池。顾名思义,对象池模式可能是个存储它们的好地方。

  • 当你在使用状态模式时,经常会遇到那些没有任何特定于“状态所属机器“字段的“状态“对象。状态的身份和方法就足以有用。在这种情况下,你可以应用本模式,在多个状态机中同时复用同一个状态实例,毫无问题。


实践 Demo

享元模式 - 地形瓦片地图


笔记与思考

  • 核心概念:把大量相似对象中不变的部分(内在状态)提取出来共享一份,可变部分(外在状态)各自持有。GoF 叫“运用共享技术有效支持大量细粒度对象“。
  • 两种形态
    • 混合型(树的例子):既有内在状态(TreeModel)也有外在状态(position、color),拆分边界清晰
    • 纯共享型(地形瓦片):对象完全没有外在状态,所有字段都是内在的——此时享元表现为“同一个对象同时存在于多个位置“
  • 跟枚举+switch 的对比:枚举省内存但把数据散落在代码各处,享元用差不多的内存(指针≈枚举大小)换来良好的封装的 API
  • 何时用它:当你发现自己在写一个枚举然后到处 switch 它的时候,考虑用享元替代。性能焦虑先 profile 再改
  • 相关模式:享元的按需创建需要工厂方法;已创建实例的缓存可以用对象池;状态模式里的无状态“状态对象“天然适合享元
  • 额外收获:地形分布的随机阈值本质是“按权重采样“(战利品表),公式就是累积概率:阈值ₙ = p₁ + p₂ + ... + pₙ
  目标分布: 累加 = 阈值:
  草地 60% → 0.60
  山丘 18% → 0.60 + 0.18 = 0.78
  沙地 10% → 0.78 + 0.10 = 0.88
  河流 6% → 0.88 + 0.06 = 0.94
  山峰 6% → 0.94 + 0.06 = 1.00