Game Programming Patterns -- Flyweight
原文地址:http://gameprogrammingpatterns.com/flyweight.html
原作者:Robert Nystrom
原創(chuàng)翻譯赤嚼,轉(zhuǎn)載請(qǐng)注明出處
迷霧散盡,一片古老宏偉的森林出現(xiàn)在你的面前。無(wú)數(shù)古老的鐵杉林立缚俏,形成了一座綠色的大教堂婚陪。陽(yáng)光穿過(guò)樹葉茎活,仿佛從斑駁的玻璃穹頂灑落下來(lái)仍劈,形成一道道金色朦朧的光束娄猫。從巨大的樹干中間眺眼望去椅您,這片森林濃密得一眼望不到邊際外冀。
這是我們游戲開發(fā)者夢(mèng)想中超凡脫俗的場(chǎng)景設(shè)定,而類似這種的場(chǎng)景經(jīng)常用一個(gè)名字低調(diào)到不能再低調(diào)的模式來(lái)實(shí)現(xiàn):這就是低調(diào)的享元模式(Flyweight)掀泳。
有樹才有森林
我可以用幾句話就形容出一片茂密的森林雪隧,但是要在一個(gè)實(shí)時(shí)運(yùn)行的游戲中實(shí)現(xiàn)它就是另外一回事了。當(dāng)你要把整片由各不相同的樹木形成的森林呈現(xiàn)在屏幕上時(shí)员舵,一個(gè)圖形程序員所想到的是他在每個(gè)60分之一秒(1幀)都得把這成千上萬(wàn)的多邊形塞到GPU中去脑沿。
我們?cè)谟懻摰氖浅汕先f(wàn)棵樹,每棵樹都有著詳細(xì)的包含了上千個(gè)多邊形的幾何結(jié)構(gòu)马僻。即使你有足夠的內(nèi)存去存放這片森林庄拇,但是如果要在屏幕上渲染它的話,這些數(shù)據(jù)還需要從CPU通過(guò)總線傳輸?shù)紾PU中韭邓。
每棵樹都包含了以下這些部分:
- 用來(lái)規(guī)定樹的主干措近、分支和樹葉的形狀的多邊形網(wǎng)格模型,女淑。
- 樹皮和樹葉的紋理瞭郑。
- 這棵樹在樹林中的位置和朝向。
- 用來(lái)調(diào)整尺寸和色調(diào)的參數(shù)鸭你,以使得每棵樹看起來(lái)都不一樣屈张。
如果用代碼來(lái)概述的話擒权,差不多就像下面這樣:
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è)問(wèn)題纳鼎。
這個(gè)技巧最關(guān)鍵的觀點(diǎn)是,雖然森林里有成千上萬(wàn)棵樹裳凸,但是其實(shí)它們看起來(lái)都差不多贱鄙。它們可能使用了相同的網(wǎng)格模型和紋理贺拣。這意味著這些樹對(duì)象中的大部分屬性在它們的實(shí)例中都是相同的略步。
如果你讓美術(shù)們給森林中的每棵樹都做一個(gè)不同的模型的話,你不是瘋了就是個(gè)億萬(wàn)富翁嫉到。
***注意梦湘,在下方那些小方框中的東西對(duì)每棵樹來(lái)說(shuō)都是完全一樣的瞎颗。 ***
因此我們可以明確地把書對(duì)象分成兩個(gè)部分來(lái)建模。首先捌议,我們?nèi)〕鏊械臉鋵?duì)象共有的屬性并把它們轉(zhuǎn)移到一個(gè)單獨(dú)的類中:
這看起來(lái)很像是類型對(duì)象(Type Object)模式哼拔。 它們都是把一個(gè)對(duì)象的部分屬性委托給另外一個(gè)對(duì)象,然后把這部分屬性給很多實(shí)例共享瓣颅。 然而倦逐,這兩個(gè)模式的意圖卻是不同的。
類型對(duì)象模式是通過(guò)把類型提取到你自己的對(duì)象模型中宫补,以達(dá)到減少你所需要定義類的數(shù)量的目的檬姥。其帶來(lái)的內(nèi)存共享,只是額外的好處粉怕。而享元模式則純粹是關(guān)于效率的考量健民。
class TreeModel
{
private:
Mesh mesh_;
Texture bark_;
Texture leaves_;
};
游戲中只需要一個(gè)這個(gè)類的實(shí)例就可以了,因?yàn)闆]有理由把相同的網(wǎng)格模型和紋理在內(nèi)存中保存上好幾千份贫贝。接下來(lái)秉犹,森林中每棵樹的實(shí)例所要做的僅僅是對(duì)這個(gè)共享的TreeModel實(shí)例進(jìn)行一次引用。而Tree類中所剩下來(lái)的稚晚,就只有那些每個(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。我們需要用一種顯卡可以理解的方式來(lái)表示我們的這種資源共享方式俺驶。
一千個(gè)實(shí)例
為了減少傳輸?shù)紾PU的數(shù)據(jù)數(shù)量幸逆,我們想要把共享部分的數(shù)據(jù)--那個(gè)TreeModel--只發(fā)送一次棍辕。然后,我們把每棵樹不同的數(shù)據(jù)發(fā)送過(guò)去--它們的位置还绘、顏色和尺寸楚昭。最后,我們告訴GPU拍顷,“就用那一個(gè)模型去渲染所有的樹吧抚太。”
幸運(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ù)彰亥,它們被用來(lái)在每次繪制時(shí)對(duì)第一部分的數(shù)據(jù)進(jìn)行調(diào)整。這樣只需要一次繪制調(diào)用(draw call)衰齐,整個(gè)森林就出現(xiàn)了任斋。
享元模式
現(xiàn)在我們已經(jīng)有了一個(gè)實(shí)際的例子了,接下來(lái)我將帶你通覽一下這個(gè)模式娇斩。享元仁卷,就像它的名字喻示的那樣,是在你需要把一些對(duì)象更加輕量化的時(shí)候發(fā)揮作用的犬第,而這些對(duì)象需要輕量化的原因通常是因?yàn)樗鼈兊臄?shù)量實(shí)在是太多了锦积。
通過(guò)實(shí)例渲染技術(shù),這些對(duì)象所占用的內(nèi)存是沒有其花費(fèi)在總線上把每棵不同的樹傳輸?shù)紾PU里的時(shí)間多的歉嗓,不過(guò)其基本原理是一樣的丰介。
在享元模式中,是通過(guò)把對(duì)象的數(shù)據(jù)分成兩類來(lái)解決這個(gè)問(wèn)題的鉴分。第一類數(shù)據(jù)是對(duì)于對(duì)象的每個(gè)實(shí)例來(lái)說(shuō)相同并且可以共享的部分哮幢。GOF把這部分?jǐn)?shù)據(jù)稱作固有屬性,而我更喜歡把它稱作“上下文無(wú)關(guān)”屬性志珍。在我們的例子中橙垢,就是樹的網(wǎng)格模型和紋理。
另一類數(shù)據(jù)是外部屬性伦糯,它對(duì)于每個(gè)實(shí)例來(lái)說(shuō)都是不同的柜某。在我們的例子中嗽元,就是樹的位置、尺寸和顏色這些喂击。就像上面的代碼示例里一樣剂癌,這個(gè)模式通過(guò)在每個(gè)對(duì)象出現(xiàn)的地方共享一份固有屬性的拷貝,來(lái)達(dá)到節(jié)約內(nèi)存的目的翰绊。
看到這里我們會(huì)覺得佩谷,這不過(guò)是基本的資源共享,很難被稱為一種模式监嗜。這種觀點(diǎn)是片面的谐檀,因?yàn)樵谖覀兊睦又校梢郧逦匕研枰蚕淼膶傩詤^(qū)別出來(lái):就是TreeModel類秤茅。
我發(fā)現(xiàn)這個(gè)模式被使用在一些無(wú)法清楚定義共享對(duì)象的情況下時(shí)稚补,會(huì)顯得不那么顯眼(而因此顯得更加巧妙)。在這些情況下框喳,感覺起來(lái)更像是一個(gè)對(duì)象神奇地在同一時(shí)間出現(xiàn)在了多個(gè)地方课幕。下面讓我來(lái)給你們展示另一個(gè)例子吧。
根之所在
這些樹生長(zhǎng)所需要的地面在我們的游戲中同樣需要被展示出來(lái)五垮。地面可以通過(guò)諸如草地乍惊、泥地、山丘放仗、湖泊润绎、河流以及任何你能想象出來(lái)的地形拼接出來(lái)。我們所要做的地面是基于分塊的(tile-based):世界的表面是一個(gè)由小分塊構(gòu)成的巨大網(wǎng)格诞挨。每一個(gè)分塊都用一種地形來(lái)覆蓋莉撇。
每種地形類型都會(huì)有一些影響游戲體驗(yàn)的屬性:
- 移動(dòng)消耗,決定了玩家在這種地形上移動(dòng)速度的快慢惶傻。
- 是否是水面的標(biāo)記棍郎,用來(lái)判斷船只是否可以通過(guò)。
- 紋理银室,用來(lái)渲染地形涂佃。
因?yàn)槲覀冇螒蜷_發(fā)者對(duì)效率的高低都是偏執(zhí)狂,所以我們不會(huì)允許把這些屬性存儲(chǔ)在游戲中的每一個(gè)地形分塊里蜈敢。因此辜荠,一個(gè)通用的解決方案是為地形類型創(chuàng)建一個(gè)枚舉:
畢竟,我們已經(jīng)在之前的那些樹身上獲得過(guò)教訓(xùn)了抓狭。
enum Terrain
{
TERRAIN_GRASS,
TERRAIN_HILL,
TERRAIN_RIVER
// Other terrains...
};
然后游戲世界為此保存一個(gè)巨大的二維網(wǎng)格:
class World
{
private:
Terrain tiles_[WIDTH][HEIGHT];
};
這里我用了一個(gè)二維嵌套數(shù)組來(lái)儲(chǔ)存這個(gè)2D網(wǎng)格伯病。這在C/C++中是非常高效的,因?yàn)檫@兩種語(yǔ)言中會(huì)把數(shù)組里的所有元素打包在一起否过。而在Java或者其他有內(nèi)存管理的語(yǔ)言中狱从,這樣做的話得到的將是其行數(shù)組中每一個(gè)元素都是一個(gè)對(duì)列數(shù)組的引用的數(shù)組膨蛮,而這對(duì)于內(nèi)存使用就不大友好了。
不管在哪種語(yǔ)言中季研,真正寫代碼的時(shí)候都是把這些實(shí)現(xiàn)細(xì)節(jié)隱藏在一個(gè)好用的2D網(wǎng)格數(shù)據(jù)結(jié)構(gòu)里要更好一些。我在這里這么寫只是為了讓它看起來(lái)好理解一些誉察。
為了實(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ì)出來(lái)的原因。
那么我們?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)該是固有的抽莱,或者說(shuō)是上下文無(wú)關(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)存的話象泵,它們的生命周期管理起來(lá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...
};
接下來(lái)我們就可以用這些類來(lái)繪制地面了:
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)在我們可以不用再通過(guò)訪問(wè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ì)算性能的人來(lái)說(shuō)出革,他們想要知道使用指針和枚舉比起來(lái)到底會(huì)消耗多少性能造壮。通過(guò)指針來(lái)引用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)召廷。
通常來(lái)說(shuō),優(yōu)化的黃金法則是“profile first”≌穗剩現(xiàn)代計(jì)算機(jī)硬件的復(fù)雜程度已經(jīng)達(dá)到不會(huì)因?yàn)槟硞€(gè)單純的原因而造成性能上的問(wèn)題竞慢。在我對(duì)本章內(nèi)容的測(cè)試中,是沒有發(fā)現(xiàn)使用享元來(lái)代替枚舉有什么影響性能的地方治泥。享元對(duì)速度有非常顯著的提高筹煮。不過(guò)這完全依賴于內(nèi)存上的其他內(nèi)容是如何分布的。
我所確信的時(shí)居夹,使用享元對(duì)象不會(huì)脫離我們的控制败潦。它給你帶來(lái)了面向?qū)ο笮问降暮锰幎]有一堆對(duì)象的額外消耗。如果你發(fā)現(xiàn)自己正在創(chuàng)建一個(gè)枚舉類型准脂,并且正在對(duì)它使用switch方法劫扒,你就可以考慮使用享元來(lái)代替它了。如果你擔(dān)心性能的話狸膏,至少在把你的代碼變成難以維護(hù)的類型之前沟饥,進(jìn)行一下性能分析吧。
參見
在上面那個(gè)tile的例子中,我們一上來(lái)就為每一種terrain類型創(chuàng)建了一個(gè)實(shí)例贤旷,然后把它保存在了World中广料。這讓使得查找和使用共享實(shí)例變得很簡(jiǎn)單。不過(guò)在很多情況下幼驶,你可能并不想在一開始就去創(chuàng)建所有的享元艾杏。
如果你不能保證哪些享元是你確實(shí)會(huì)用到的,那就最好在需要的時(shí)候再去創(chuàng)建它們盅藻。而為了利用到共享的好處糜颠,當(dāng)你請(qǐng)求一個(gè)實(shí)例的時(shí)候,你可以先看看自己是否已經(jīng)創(chuàng)建過(guò)一個(gè)萧求。如果是的話,你只需要返回那個(gè)已經(jīng)創(chuàng)建好的實(shí)例顶瞒。
這通常是意味著你需要將構(gòu)造函數(shù)封裝在一些首先會(huì)查找已存在對(duì)象的接口下夸政。像這樣來(lái)隱藏構(gòu)造函數(shù)的例子使用到了工廠模式。為了可以返回一個(gè)之前創(chuàng)建過(guò)的享元榴徐,你需要跟蹤一個(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ì)有任何問(wèn)題的嗽仪。
因?yàn)樗接邢蓿g的文字會(huì)有不妥之處柒莉,歡迎大家指正
“本譯文僅供個(gè)人研習(xí)闻坚、欣賞語(yǔ)言之用,謝絕任何轉(zhuǎn)載及用于任何商業(yè)用途兢孝。本譯文所涉法律后果均由本人承擔(dān)窿凤。本人同意簡(jiǎn)書平臺(tái)在接獲有關(guān)著作權(quán)人的通知后,刪除文章跨蟹■ㄊ猓”