游戏编程模式:轻量级(Flyweight)模式(Part III)

4、A Place To Put Down Roots

        这些树所生长的地面有需要在我们的游戏中表现出来。可以有由小草、灰土、丘陵、湖泊、河流以及其他任何你可以梦想到的地形(terrain)所组成的补丁。我们将要让这地面是基于砖块的(tile-based):世界的表面是由小砖块所组成的巨大格栅(grid)。每一个砖块由一种地形所覆盖。

        每一个地形类型有若干影响到游戏性(gameplay)的性质:

  • 一个移动成本(movement cost),决定玩家穿过其中的速度。
  • 一个标记位表示是否是可以行船的湿地(watery terrain)。
  • 一个用于渲染之的贴图。

        因为我们游戏程序员是效率的偏执狂,让我们在游戏中的每一个砖块中都储存上面的所有数据肯定是没门的。相反,一个常用的方法是使用一个枚举类型来表示地形类型:

        毕竟,我们已经从那些树木里吸取教训了。

enum Terrain
{
  TERRAIN_GRASS ,
  TERRAIN_HILL ,
  TERRAIN_RIVER
  // Other terrains...
};
        然后游戏世界保存上述枚举类型的一个巨大格栅:

class World
{
private:
  Terrain tiles_[WIDTH][HEIGHT];
};

        在这里我使用一个嵌套的数组来存储一个2D格栅。这在C/C++中是高效的,因为它会把所有的元素打包在一起。在Java或其他的内存托管的语言中,这样做的话实际上会给你一个行数组,其中每一个元素是一个列数组的一个引用,而这可能并不像你喜欢的那样对内存友好。

        不管是哪种情况,真实的代码会通过把这个实现隐藏在一个优美的2D格栅数据结构后面而更加好看(better served)。而我在这里这样做是为了保持简单。

        为了实际地得到关于一个砖块的有用数据,我们做类似这样的事情:

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

        你理解意思了吧。这个管用,但是我觉得它丑陋。我把移动成本和潮湿程度看成是地形的数据,但是在这里它们被嵌入到代码中了。更糟糕的是,一个地形类型的数据is smeared across一堆的方法里头了。如果能够把所有这些数据封装在一起的话,那便是极好的了。毕竟,那就是设计对象的目的。

        如果我们可以有一个像这样的真正的地形类,那就很棒了:

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的。这不是巧合。因为同样的对象会用于很多个场合,如果你可以修改它的话,那么这样的改变会同时出现在多个地方的。

        这很可能不是你所想要的。共享对象以节省内存的做法应该是一种不影响app的视觉行为的优化。正因为此,轻量级对象几乎总是immutable

        但是我们并不想要付出这样的代价:为世界中的每一个砖块创建这个类的一个实例。如果你看看这个类的话,你会注意到实际上没有与该砖块在哪儿有关的东西。用轻量级的术语来说,一个地形的所有状态都是“内在的”或者说是“与上下文无关的”。

        说明了这一点之后,就没有理由为每个地形类型创建多于一个的实例了。地面上的每个草地砖块彼此间是等同的。并不是让世界成为由枚举类型或者Terrain对象组成的一个格栅,而是成为由指向Terrain对象的指针所组成的格栅:

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

        使用同一地形的每一个砖块将会指向同一个地形实例。

游戏编程模式:轻量级(Flyweight)模式(Part III)_第1张图片

        由于这些地形实例会被使用多次,如果你要动态地分配它们的话,那么其生命周期管理起来有点复杂。相反,我们就直接在世界中储存它们好了:

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对象、而不是使用World的方法来访问地形的属性了:
const Terrain& World::getTile(int x, int y) const
{
  return *tiles_[x][y];
}
        这样的话,World不再与各种地形相关的细节耦合在一起了。如果你想要得到砖块的某个属性,你可以直接从那个对象中获得:

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

        我们回到了那个操作真实对象的令人愉悦的API了,而且我们做到这一点的时候几乎没有引起额外的开销——一个指针经常不会大于一个枚举值。


5、性能怎么样?

        我在这里说“几乎”,因为性能统计专家(bean counters)会正当地想要知道这与使用枚举类型相比到底怎么样。通过指针来引用地形意味着一个间接的查询。为了得到某个地形的数据,比如说移动成本,你首先得跟随着格栅中的指针找到对应的地形对象,然后在那里找到移动成本。像这样追踪一个指针可能会引起一个高速缓存缺失(cache miss),而这会拖慢速度。

        想知道关于指针追踪和cachemiss的更多信息,可以看看关于Data Locality这一章。

        一如往常,优化的指导原则是profile first()。现代的电脑硬件太复杂了,以至于难以让性能分析成为一个纯粹逻辑推理的游戏。(Modern computer hardware is too complex for performance to be a game of pure reason anymore.)在我对本章的测试中,使用轻量级来代替枚举类型并没有penalty。实际上轻量级模式还要显著地快。但是这完全依赖于其他东西在内存中的分布。

        我信心的是,不应该不经大脑思索便摈弃使用轻量级对象(using flyweight objects shouldn't be dismissed out of hand)。它们给你了一个面向对象的优势,而不会有成吨的对象的成本。如果你发现你自己在创建一个枚举类型,并且在对它做很多的switch测试,那么考虑使用这个模式吧。如果你担心性能,在把你的代码变得具有不太容易维护的风格之前,至少profile first。


你可能感兴趣的:(游戏开发,C++,设计模式)