《游戏程序设计模式》 1.2 - 享元模式

    浓雾消散,一片雄伟古老、勃勃生机的森林浮现眼前。不可计数的古铁杉高耸入云形成一座巨大的绿色教堂。阳光透过枝叶构成的彩绘玻璃穹顶分散成金色的迷雾。在巨大的树干中间,你可以看到广阔的森林延伸的远方。

    这就是作为游戏开发者想象出的一种虚拟世界的设定,像这种场景经常会用到一种设计模式,这种设计模式的名字不会更适当:享元模式。

Forest for the Trees

    我可以用几句话就描述了广阔的森林,但是实际上在实时游戏中实现它们就是另外一回事了。当你把整个森林填充到屏幕时,图形程序员看到的是数以万计的多边形,他们会以每秒60次的速度把这些多边形送进GPU。我们正在讨论的数以千计的树木,每一棵都具有包含数千个多边形的几何体。即便你有足够的内存来描述森林,为了渲染它,它的数据不得不占用总线从CPU传到GPU。

    每棵树都有一堆相关的数据:

  • 一个多边形构成的网格,定义树干、树枝和树叶。

  • 树皮和树叶的纹理贴图。

  • 它在森林的位置和朝向。

  • 一些调整参数像尺寸和色调,以让每棵树看起来不一样。

    如果你想用代码把它勾画出来,那么会像这样:

class Tree
{
private:
  Mesh mesh_;
  Texture bark_;
  Texture leaves_;
  Vector position_;
  double height_;
  double thickness_;
  Color barkTint_;
  Color leafTint_;
};

    这有很多数据,而且网格和纹理数据尤其地大。一个包含这些数据的森林太大了,根本不可能在一帧之内传给GPU。很幸运,有个历史悠久的方法来解决这个问题。

    关键点就是即使有成千上万棵树,但是它们都长得差不多。它们很可能使用相同的网格和纹理。这意味着这些对象实例的大部分字段是相同的。

    31162846_uosL.png

    我们可以这样构建模型,显式地把对象分成两半。首先,我们把所有树的共同部分提取出来,放到一个单独的类中:

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_;
};

    你可以看到它是这样:

    31162847_FNEe.png

    这是很好的对于在主内存中存储数据,但是对于渲染没有帮助。在森林出现在屏幕上之前,必须先把数据传到GPU。我们需要以GPU能够理解的方式表达资源共享。

A Thousand Instances

    为了把要传给GPU的数据最小化,我们想传递共享数据-TreeModel-只传一次。然后,我们再单独传递每棵树的独有的部分,位置、色调、缩放大小。最后我们告诉GPU,使用同一个TreeModel来渲染所有的树。

    幸运的是,现代的图形api和图形卡完全支持上述操作。具体细节要求比较高,已经超出了本书的讲解范围,Direct3D与OpenGL都可以做这件事称为多实例渲染(instanced rendering)。

    在两种api中,你都是传递两个数据流。第一个是要被渲染很多次的公共数据-上面树木的例子中的网格和纹理数据。第二个就是一串实例独有的参数,那些用来改变第一个公共数据的参数。用一个draw call,整个森林就出现了。

the flyweight pattern

    现在我们已经有了一个具体的例子,那么,我就可以带你走进这个模式了。享元模式,就像他的名字表明的,会用在当你有大量的对象实例,想减少数据量的情况。

    这个模式可以解决这个问题通过把对象分成两部分。第一部分是并不是特定于实例,而是所有实例都具有的相同的数据。“四人帮”称其为“固有的”数据,我更喜欢称其为“上下文无关”的数据。在这里的例子中,就是树的网格和纹理数据。

    另一个部分就是“外在的”,这部分数据时特定于实例的。在这个例子中,就是树的位置,缩放和色调。就像上面的代码,这个模式可以节省大量内存通过使所有的实例共享一份“固有的”数据。

    从目前我们的例子看来,这几乎很难称为一种模式。部分原因是在这个例子中,我们可以为共享的数据定义一个清晰的本体:TreeModel。

    我发现这个模式不是很明显(而且更聪明)当使用在那种你很难为共享数据定义一个清晰的本体的情况。在这种情况下,感觉就像一个对象神奇地在同一时间出现在不同的地方。让我来展示另一个例子。

A place to put down roots

    游戏中的长着树的土地也需要实现。土地上可能会有成块的草地,沙地,山丘,湖泊,河流等所有你能想到的地形。我们会把地面做成分块的:世界的表面就是这些分块组成的大网格。每一个分块覆盖着一种地形。

    每一种地形都一些属性来影响游戏:

  • 移动阻力,决定着玩家能多快地通过这块地形。

  • 一个标志,标明这块地形是否有水能不能让船通过。

  • 一个纹理,用来绘制这块地形。

    由于我们游戏开发者对效率很偏执,所以不可能,我们会为每一块地形都存一份属性数据。一个通用的方法是把地形用枚举分类:

enum Terrain
{
    TERRAIN_GRASS,
    TERRAIN_HILL,
    TERRAIN_RIVER
    // Other terrains...
};

    然后世界定义一个包含地形巨大的网格:

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...
  }
}

    你做到了。这个可以工作,但是我觉得他很丑。我认为应该把阻力跟是否有水作为地形的属性数据,但是现在他们被写死到代码里了。更糟糕的是,地形的属性数据被分散到不同的函数中了。把这些属性封装到一起才是更好的做法。毕竟,这是对象设计的目的。

    如果我们有一个实际的terrain类,将是极好的,就像这样:

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对象,而是指向Terrain的指针:

class World
{
private:
  Terrain* tiles_[WIDTH][HEIGHT];
  // Other stuff...
};

    每一块具有相同地形的分块都指向同一个Terrain实例。

    31162849_HHMM.png

    一旦多个分块指向了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实例了:

const Terrain& World::getTile(int x, int y) const
{
  return *tiles_[x][y];
}

    这样,World不再与Terrain的属性耦合了。如果你想获取某个分块地形的属性,你可以直接从Terrain实例中获取:

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

    我们又回到了利用对象的愉快的api的状态,但是却几乎没有花费多少开销-一个指针一般并不比一个枚举大。

what about performance?

    我刚才说“几乎”是因为性能统计专家理所当然想知道与使用枚举相比性能如何。使用指针就表示有个间接的查找。为了获取一个分块的阻力,你要先跟着指针找到Terrain实例,然后才能找到阻力。追逐一个指针,可能会导致缓存未命中,从而减慢速度。

    通常,优化的黄金法则是框架优先。现代计算机硬件非常复杂已经不再是限制游戏性能的唯一理由。经过我的测试,我用享元模式没有任何性能损失相比使用枚举。享元模式实际上明显很快。但是,完全依赖于其他数据在内存中如何安排。

    我相信的是,享元模式不应该被忽视。它给你带来面向对象方式的好处,还不会造成产生大量实例的开销。如果你发现你使用了枚举并且对其使用了大量的switch语句,考虑使用这个模式吧。如果你担心性能,至少先把框架建好,而不是经常修改代码导致不可维护。

see also

  • 在分块的那个例子中,我们只是急切地创建了每种Terrain的实例并且存在World中。这使得我们很容易找到并复用这些实例。但是,在很多情况下,你不会想预先创建所有类型的Terrain实例。

    如果你不能预料你会使用哪个,那么根据需要创建实例是比较好的。为了利用共享,当你需求一个时,你先看看是否已经创建了实例,如果创建了就直接返回即可。

    这意味着,你需要在先查找已存在实例的接口后面封装一个构造器。把构造器隐藏起来的例子实际上是工厂模式的应用。

  • 为了返回一个已经存在的实例,你不得不遍历已经实例化的对象池。就像名字表明的,一个对象池会是一个好的地方来存放实例后的对象。

  • 当你使用状态模式时,你经常会发现“state”对象没有特定于状态机的属性数据。“state”的本体和函数会非常有用。在这种情况下,你可以使用此模式在不同的状态机中共用“state”,没有任何问题。


转载于:https://my.oschina.net/xunxun/blog/486193

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值