遊戲編程模式:輕量級(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...
};

        使用同一地形的每一個磚塊將會指向同一個地形實例。


        由於這些地形實例會被使用多次,如果你要動態地分配它們的話,那麼其生命週期管理起來有點複雜。相反,我們就直接在世界中儲存它們好了:

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。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章