游戲編程設(shè)計(jì)模式 -- 享元模式

Game Programming Patterns -- Flyweight

原文地址:http://gameprogrammingpatterns.com/flyweight.html
原作者:Robert Nystrom

原創(chuàng)翻譯,轉(zhuǎn)載請(qǐng)注明出處

迷霧散盡,一片古老宏偉的森林出現(xiàn)在你的面前。無數(shù)古老的鐵杉林立,形成了一座綠色的大教堂。陽光穿過樹葉,仿佛從斑駁的玻璃穹頂灑落下來,形成一道道金色朦朧的光束。從巨大的樹干中間眺眼望去,這片森林濃密得一眼望不到邊際。

這是我們游戲開發(fā)者夢(mèng)想中超凡脫俗的場景設(shè)定,而類似這種的場景經(jīng)常用一個(gè)名字低調(diào)到不能再低調(diào)的模式來實(shí)現(xiàn):這就是低調(diào)的享元模式(Flyweight)。

有樹才有森林

我可以用幾句話就形容出一片茂密的森林,但是要在一個(gè)實(shí)時(shí)運(yùn)行的游戲中實(shí)現(xiàn)它就是另外一回事了。當(dāng)你要把整片由各不相同的樹木形成的森林呈現(xiàn)在屏幕上時(shí),一個(gè)圖形程序員所想到的是他在每個(gè)60分之一秒(1幀)都得把這成千上萬的多邊形塞到GPU中去。

我們?cè)谟懻摰氖浅汕先f棵樹,每棵樹都有著詳細(xì)的包含了上千個(gè)多邊形的幾何結(jié)構(gòu)。即使你有足夠的內(nèi)存去存放這片森林,但是如果要在屏幕上渲染它的話,這些數(shù)據(jù)還需要從CPU通過總線傳輸?shù)紾PU中。

每棵樹都包含了以下這些部分:

  • 用來規(guī)定樹的主干、分支和樹葉的形狀的多邊形網(wǎng)格模型,。
  • 樹皮和樹葉的紋理。
  • 這棵樹在樹林中的位置和朝向。
  • 用來調(diào)整尺寸和色調(diào)的參數(shù),以使得每棵樹看起來都不一樣。

如果用代碼來概述的話,差不多就像下面這樣:

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

如此多的數(shù)據(jù)、網(wǎng)格模型和紋理真的是非常龐大。用這些去構(gòu)成一個(gè)森林的話,GPU在一幀內(nèi)所需處理的東西就太多了。幸運(yùn)的是,有一個(gè)備受推崇的小技巧可以解決這個(gè)問題。

這個(gè)技巧最關(guān)鍵的觀點(diǎn)是,雖然森林里有成千上萬棵樹,但是其實(shí)它們看起來都差不多。它們可能使用了相同的網(wǎng)格模型和紋理。這意味著這些樹對(duì)象中的大部分屬性在它們的實(shí)例中都是相同的。

如果你讓美術(shù)們給森林中的每棵樹都做一個(gè)不同的模型的話,你不是瘋了就是個(gè)億萬富翁。

***注意,在下方那些小方框中的東西對(duì)每棵樹來說都是完全一樣的。 ***

因此我們可以明確地把書對(duì)象分成兩個(gè)部分來建模。首先,我們?nèi)〕鏊械臉鋵?duì)象共有的屬性并把它們轉(zhuǎn)移到一個(gè)單獨(dú)的類中:

這看起來很像是類型對(duì)象(Type Object)模式。 它們都是把一個(gè)對(duì)象的部分屬性委托給另外一個(gè)對(duì)象,然后把這部分屬性給很多實(shí)例共享。 然而,這兩個(gè)模式的意圖卻是不同的。
類型對(duì)象模式是通過把類型提取到你自己的對(duì)象模型中,以達(dá)到減少你所需要定義類的數(shù)量的目的。其帶來的內(nèi)存共享,只是額外的好處。而享元模式則純粹是關(guān)于效率的考量。

class TreeModel
{
private:
  Mesh mesh_;
  Texture bark_;
  Texture leaves_;
};

游戲中只需要一個(gè)這個(gè)類的實(shí)例就可以了,因?yàn)闆]有理由把相同的網(wǎng)格模型和紋理在內(nèi)存中保存上好幾千份。接下來,森林中每棵樹的實(shí)例所要做的僅僅是對(duì)這個(gè)共享的TreeModel實(shí)例進(jìn)行一次引用。而Tree類中所剩下來的,就只有那些每個(gè)樹實(shí)例都不同的屬性:

class Tree
{
private:
  TreeModel* model_;
  
  Vector position_;
  double height_;
  double thickness_;
  Color barkTint_;
  Color leafTint_;
};

你可以想象成這樣:

這對(duì)于在內(nèi)存中存儲(chǔ)這些樹是非常有幫助的,但是這對(duì)渲染卻沒什么作用。在樹林出現(xiàn)在屏幕上之前,它首先需要從內(nèi)存進(jìn)入GPU。我們需要用一種顯卡可以理解的方式來表示我們的這種資源共享方式。

一千個(gè)實(shí)例

為了減少傳輸?shù)紾PU的數(shù)據(jù)數(shù)量,我們想要把共享部分的數(shù)據(jù)--那個(gè)TreeModel--只發(fā)送一次。然后,我們把每棵樹不同的數(shù)據(jù)發(fā)送過去--它們的位置、顏色和尺寸。最后,我們告訴GPU,“就用那一個(gè)模型去渲染所有的樹吧?!?/p>

幸運(yùn)的是,如今的圖形編程接口和顯卡硬件已經(jīng)支持這種方式了。雖然具體實(shí)現(xiàn)的細(xì)節(jié)是很繁瑣的,已經(jīng)超出了本書的范疇,但是Direct3D和OpenGL是都可以做到這種被稱為實(shí)例渲染(instanced rendering)的功能的。

***這個(gè)API是由顯卡硬件直接實(shí)現(xiàn)的,這意味著享元模式可能是GOF提出的設(shè)計(jì)模式中唯一實(shí)際被硬件支持的。 ***

在它們的API中,你需要提供兩部分?jǐn)?shù)據(jù)流。第一部分是需要渲染很多次的共同數(shù)據(jù)塊--樹的網(wǎng)格模型和紋理。第二部分是實(shí)例的列表和這些實(shí)例的參數(shù),它們被用來在每次繪制時(shí)對(duì)第一部分的數(shù)據(jù)進(jìn)行調(diào)整。這樣只需要一次繪制調(diào)用(draw call),整個(gè)森林就出現(xiàn)了。

享元模式

現(xiàn)在我們已經(jīng)有了一個(gè)實(shí)際的例子了,接下來我將帶你通覽一下這個(gè)模式。享元,就像它的名字喻示的那樣,是在你需要把一些對(duì)象更加輕量化的時(shí)候發(fā)揮作用的,而這些對(duì)象需要輕量化的原因通常是因?yàn)樗鼈兊臄?shù)量實(shí)在是太多了。

通過實(shí)例渲染技術(shù),這些對(duì)象所占用的內(nèi)存是沒有其花費(fèi)在總線上把每棵不同的樹傳輸?shù)紾PU里的時(shí)間多的,不過其基本原理是一樣的。

在享元模式中,是通過把對(duì)象的數(shù)據(jù)分成兩類來解決這個(gè)問題的。第一類數(shù)據(jù)是對(duì)于對(duì)象的每個(gè)實(shí)例來說相同并且可以共享的部分。GOF把這部分?jǐn)?shù)據(jù)稱作固有屬性,而我更喜歡把它稱作“上下文無關(guān)”屬性。在我們的例子中,就是樹的網(wǎng)格模型和紋理。

另一類數(shù)據(jù)是外部屬性,它對(duì)于每個(gè)實(shí)例來說都是不同的。在我們的例子中,就是樹的位置、尺寸和顏色這些。就像上面的代碼示例里一樣,這個(gè)模式通過在每個(gè)對(duì)象出現(xiàn)的地方共享一份固有屬性的拷貝,來達(dá)到節(jié)約內(nèi)存的目的。

看到這里我們會(huì)覺得,這不過是基本的資源共享,很難被稱為一種模式。這種觀點(diǎn)是片面的,因?yàn)樵谖覀兊睦又校梢郧逦匕研枰蚕淼膶傩詤^(qū)別出來:就是TreeModel類。

我發(fā)現(xiàn)這個(gè)模式被使用在一些無法清楚定義共享對(duì)象的情況下時(shí),會(huì)顯得不那么顯眼(而因此顯得更加巧妙)。在這些情況下,感覺起來更像是一個(gè)對(duì)象神奇地在同一時(shí)間出現(xiàn)在了多個(gè)地方。下面讓我來給你們展示另一個(gè)例子吧。

根之所在

這些樹生長所需要的地面在我們的游戲中同樣需要被展示出來。地面可以通過諸如草地、泥地、山丘、湖泊、河流以及任何你能想象出來的地形拼接出來。我們所要做的地面是基于分塊的(tile-based):世界的表面是一個(gè)由小分塊構(gòu)成的巨大網(wǎng)格。每一個(gè)分塊都用一種地形來覆蓋。

每種地形類型都會(huì)有一些影響游戲體驗(yàn)的屬性:

  • 移動(dòng)消耗,決定了玩家在這種地形上移動(dòng)速度的快慢。
  • 是否是水面的標(biāo)記,用來判斷船只是否可以通過。
  • 紋理,用來渲染地形。

因?yàn)槲覀冇螒蜷_發(fā)者對(duì)效率的高低都是偏執(zhí)狂,所以我們不會(huì)允許把這些屬性存儲(chǔ)在游戲中的每一個(gè)地形分塊里。因此,一個(gè)通用的解決方案是為地形類型創(chuàng)建一個(gè)枚舉:

畢竟,我們已經(jīng)在之前的那些樹身上獲得過教訓(xùn)了。

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

然后游戲世界為此保存一個(gè)巨大的二維網(wǎng)格:

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

這里我用了一個(gè)二維嵌套數(shù)組來儲(chǔ)存這個(gè)2D網(wǎng)格。這在C/C++中是非常高效的,因?yàn)檫@兩種語言中會(huì)把數(shù)組里的所有元素打包在一起。而在Java或者其他有內(nèi)存管理的語言中,這樣做的話得到的將是其行數(shù)組中每一個(gè)元素都是一個(gè)對(duì)列數(shù)組的引用的數(shù)組,而這對(duì)于內(nèi)存使用就不大友好了。
不管在哪種語言中,真正寫代碼的時(shí)候都是把這些實(shí)現(xiàn)細(xì)節(jié)隱藏在一個(gè)好用的2D網(wǎng)格數(shù)據(jù)結(jié)構(gòu)里要更好一些。我在這里這么寫只是為了讓它看起來好理解一些。

為了實(shí)際得到每個(gè)分塊的有用數(shù)據(jù),我們會(huì)像下面這樣做:

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

你應(yīng)該明白大概的意思了。雖然這樣是可行的,但是我覺得這樣寫很不好看。我認(rèn)為移動(dòng)消耗和是否是水面應(yīng)該是一個(gè)地形的數(shù)據(jù),但是這里卻嵌入到了代碼里。更糟糕的是,一種地形類型的數(shù)據(jù)卻分布在了一堆不同的方法中。如果把這些屬性封裝在一起的話應(yīng)該是更好的。畢竟,這就是對(duì)象被設(shè)計(jì)出來的原因。

那么我們?nèi)绻幸粋€(gè)地形的類就好了,就像這樣:

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類型的。這并不是巧合。因?yàn)橥粋€(gè)對(duì)象是要在很多不同的環(huán)境中使用的,如果你要修改它的話,那很多地方都會(huì)同時(shí)發(fā)生改變。
這可能不是你想要的效果。分享對(duì)象的內(nèi)存占用的優(yōu)化不應(yīng)該影響到應(yīng)用的可見行為(visible behavior)。因此,享元對(duì)象幾乎都是不可改變的。

但是我們并不想為游戲世界里的每一個(gè)分塊都保存一個(gè)實(shí)例。如果你有用心觀察上面那個(gè)類的話,你會(huì)注意到?jīng)]有任何關(guān)于這個(gè)分塊的位置信息。在享元模式中,所有地形的狀態(tài)都應(yīng)該是固有的,或者說是上下文無關(guān)的。

因此,我們沒有理由去給每種地形保存一個(gè)以上的實(shí)例。地面上的每個(gè)草地的分塊和其他的沒有什么不同。這樣就可以把之前那些枚舉或者Terrain對(duì)象的二維數(shù)組替換成一個(gè)指向Terrain對(duì)象指針的二維數(shù)組:

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

所有使用相同地形的分塊都會(huì)指向同一個(gè)地形實(shí)例。

因?yàn)檫@些Terrain實(shí)例要在很多地方使用,所以如果你要給它們動(dòng)態(tài)分配內(nèi)存的話,它們的生命周期管理起來會(huì)比較復(fù)雜。所以,我們把它們直接存儲(chǔ)在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_;
  }
}

我承認(rèn)這確實(shí)不是世界上最好的地形生成算法。

現(xiàn)在我們可以不用再通過訪問World類中的方法去獲取Terrain的屬性了,而是可以直接獲取到Terrain對(duì)象:

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

這樣的話,World類就不再和Terrain的細(xì)節(jié)有任何耦合。如果你想獲取某個(gè)分塊的屬性的話,你可以從它的對(duì)象中獲取到:

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

我們回到了愉快的與真實(shí)對(duì)象互動(dòng)的API操作上,而且這也幾乎沒有任何額外消耗--一個(gè)指針通常是不會(huì)比一個(gè)枚舉類型大的。

性能如何呢?

注意上面我用的是“幾乎”,因?yàn)閷?duì)善于計(jì)算性能的人來說,他們想要知道使用指針和枚舉比起來到底會(huì)消耗多少性能。通過指針來引用terrain意味著間接的查詢。如果想要獲取一些terrain的數(shù)據(jù),諸如movement cost之類的,你必須首先跟隨grid中的指針去找到terrain對(duì)象,然后才能獲取到這個(gè)movement cost數(shù)值。像這樣跟蹤指針會(huì)導(dǎo)致高速緩存缺失(cache miss),而這是會(huì)導(dǎo)致性能變差的。

更多有關(guān)指針追蹤和高速緩存缺失的細(xì)節(jié),請(qǐng)參見章節(jié) 數(shù)據(jù)本地化(Data Locality)。

通常來說,優(yōu)化的黃金法則是“profile first”?,F(xiàn)代計(jì)算機(jī)硬件的復(fù)雜程度已經(jīng)達(dá)到不會(huì)因?yàn)槟硞€(gè)單純的原因而造成性能上的問題。在我對(duì)本章內(nèi)容的測(cè)試中,是沒有發(fā)現(xiàn)使用享元來代替枚舉有什么影響性能的地方。享元對(duì)速度有非常顯著的提高。不過這完全依賴于內(nèi)存上的其他內(nèi)容是如何分布的。

我所確信的時(shí),使用享元對(duì)象不會(huì)脫離我們的控制。它給你帶來了面向?qū)ο笮问降暮锰幎]有一堆對(duì)象的額外消耗。如果你發(fā)現(xiàn)自己正在創(chuàng)建一個(gè)枚舉類型,并且正在對(duì)它使用switch方法,你就可以考慮使用享元來代替它了。如果你擔(dān)心性能的話,至少在把你的代碼變成難以維護(hù)的類型之前,進(jìn)行一下性能分析吧。

參見

  • 在上面那個(gè)tile的例子中,我們一上來就為每一種terrain類型創(chuàng)建了一個(gè)實(shí)例,然后把它保存在了World中。這讓使得查找和使用共享實(shí)例變得很簡單。不過在很多情況下,你可能并不想在一開始就去創(chuàng)建所有的享元。
    如果你不能保證哪些享元是你確實(shí)會(huì)用到的,那就最好在需要的時(shí)候再去創(chuàng)建它們。而為了利用到共享的好處,當(dāng)你請(qǐng)求一個(gè)實(shí)例的時(shí)候,你可以先看看自己是否已經(jīng)創(chuàng)建過一個(gè)。如果是的話,你只需要返回那個(gè)已經(jīng)創(chuàng)建好的實(shí)例。
    這通常是意味著你需要將構(gòu)造函數(shù)封裝在一些首先會(huì)查找已存在對(duì)象的接口下。像這樣來隱藏構(gòu)造函數(shù)的例子使用到了工廠模式。

  • 為了可以返回一個(gè)之前創(chuàng)建過的享元,你需要跟蹤一個(gè)存儲(chǔ)池,這里保存了所有的已創(chuàng)建對(duì)象。就像池這個(gè)名字暗示的那樣,對(duì)象池可能會(huì)是一個(gè)對(duì)于保存這些對(duì)象很有幫助的模式。

  • 當(dāng)你使用狀態(tài)模式時(shí),會(huì)經(jīng)常有一些和使用它們的狀態(tài)機(jī)沒有特定關(guān)聯(lián)的狀態(tài)。而這些狀態(tài)的特性和方法對(duì)你是有一定作用的。在這種情況下,你就可以使用享元模式去在多個(gè)狀態(tài)機(jī)中同時(shí)重用同一個(gè)狀態(tài)實(shí)例,而這樣是不會(huì)有任何問題的。


因?yàn)樗接邢蓿g的文字會(huì)有不妥之處,歡迎大家指正

“本譯文僅供個(gè)人研習(xí)、欣賞語言之用,謝絕任何轉(zhuǎn)載及用于任何商業(yè)用途。本譯文所涉法律后果均由本人承擔(dān)。本人同意簡書平臺(tái)在接獲有關(guān)著作權(quán)人的通知后,刪除文章。”

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 1 場景問題# 1.1 加入權(quán)限控制## 考慮這樣一個(gè)問題,給系統(tǒng)加入權(quán)限控制,這基本上是所有的應(yīng)用系統(tǒng)都有的功能...
    七寸知架構(gòu)閱讀 2,569評(píng)論 1 57
  • Spring Cloud為開發(fā)人員提供了快速構(gòu)建分布式系統(tǒng)中一些常見模式的工具(例如配置管理,服務(wù)發(fā)現(xiàn),斷路器,智...
    卡卡羅2017閱讀 136,506評(píng)論 19 139
  • Android 自定義View的各種姿勢(shì)1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 178,812評(píng)論 25 709
  • 定義 Flyweight在拳擊比賽中指最輕量級(jí),即“蠅量級(jí)”或“雨量級(jí)”。這里選擇使用“享元模式”的意譯,是因?yàn)檫@...
    步積閱讀 1,961評(píng)論 0 2
  • 漸漸的我開始不喜歡把矯情的話掛在嘴邊 不再對(duì)喜歡的人說我還在等你 不再對(duì)親密的朋友說我特別珍惜你 不再說舍不得也不...
    常樂丶閱讀 489評(píng)論 0 0

友情鏈接更多精彩內(nèi)容