注:面向數據編程文章已更新成markdown形式,並添加修改了一些內容,而本文則做爲舊文再也不更新維護。html
最新版博文以下:程序員
【遊戲設計模式——面向數據編程(新)】 https://www.cnblogs.com/KillerAery/p/11746639.html
編程
前言:隨着軟件需求的日益複雜發展,遠古時期面的向過程編程思想才漸漸萌生了面向對象編程思想。設計模式
當人們發現面向對象在應對高層軟件的種種好處時,愈來愈沉醉於面向對象,熱衷於研究如何更加優雅地抽象出對象。數組
然而現代開發中漸漸發現面向對象編程層層抽象形成臃腫,致使運行效率下降,而這是性能要求高的遊戲編程領域不想看到的。緩存
因而現代遊戲編程中,面向數據編程的思想愈來愈被接受(例如Unity2018更新的ECS框架就是一種面向數據思想的框架)。markdown
先來一個簡單的比較:數據結構
那麼所謂的考慮數據存儲/佈局是什麼意思呢?框架
先引入一個有關CPU處理數據的概念:CPU多級緩存。函數
要儘量提升CPU緩存命中率,就是要儘可能讓使用的數據連續在一塊兒。
因爲面向數據編程技巧不少,本文篇幅有限,只介紹部分。
1,傳統的組件模式,每每讓遊戲對象持有一個或多個組件的引用數據(指針數據)。
(一個典型的遊戲對象類,包含了2種組件的指針)
class GameObject {
//....GameObject的屬性
Component1* m_component1; Component2* m_component2; };
下面一幅圖顯示了這種傳統模式的結構:
遊戲對象/組件每每是批處理操做較多(每幀更新/渲染/或其餘操做)的對象。
這個傳統結構相應的每幀更新代碼:
GameObject g[MAX_GAMEOBJECT_NUM]; for(int i = 0; i < GameObjectsNum; ++i) { g[i].update(); if(g[i].componet1 != nullptr)g[i].componet1->update(); if(g[i].componet2 != nullptr)g[i].componet2->update(); }
而根據圖中能夠看到,這種指來指去的結構對CPU緩存極其不友好:爲了訪問組件老是跳轉到不相鄰的內存。
假若遊戲對象和組件的更新順序不影響遊戲邏輯,則一個可行的辦法是將他們都以連續數組形式存在。
注意是對象數組,而不是指針數組。若是是指針數組的話,這對CPU緩存命中沒有意義(由於要經過指針跳轉到不相鄰的內存)。
GameObject g[MAX_GAMEOBJECT_NUM];
Component1 a[MAX_COMPONENT_NUM];
Component2 b[MAX_COMPONENT_NUM];
(連續數組存儲能讓下面的批處理中CPU緩存命中率較高)
for (int i = 0; i < GameObjectsNum; ++i) { g[i].update(); } for (int i = 0; i < Componet1Num; ++i) { a[i].update(); } for (int i = 0; i < Componet2Num; ++i) { b[i].update(); }
2,這是一個簡單的粒子系統:
const int MAX_PARTICLE_NUM = 3000; //粒子類 class Particle { private: bool active; Vec3 position; Vec3 velocity; //....其它粒子所需方法 }; Particle particles[MAX_PARTICLE_NUM]; int particleNum;
它使用了典型的lazy策略,當要刪除一個粒子時,只需改變active標記,無需移動內存。
而後利用標記判斷,每幀更新的時候能夠略過刪除掉的粒子。
當須要建立新粒子時,只須要找到第一個被刪除掉的粒子,更改其屬性便可。
for (int i = 0; i < particleNum; ++i) { if (particles[i].isActive()) { particles[i].update(); } }
表面上看這很科學,實際上這樣作CPU緩存命中率不高:每次批處理CPU緩存都加載過不少不會用到的粒子數據(標記被刪除的粒子)。
一個可行的方法是:當要刪除粒子時,將隊列尾的粒子內存複製到該粒子的位置,並記錄減小後的粒子數量。
(移動內存(複製內存)操做是程序員最不想看到的,可是實際執行批處理帶來的速度提高相比刪除的開銷多的很是多,除非你移動的內存對象大小實在大到使人髮指)
particles[i] = particles[particleNum];
particleNum--;
這樣咱們就能夠保證在這個粒子批量更新操做中,CPU緩存老是能以高命中率擊中。
for (int i = 0; i < particleNum; ++i) { particles[i].update(); }
有人可能認爲這樣能最大程度利用CPU緩存:把一個對象全部要用的數據(包括組件數據)都塞進一個類裏,而沒有任何用指針或引用的形式間接存儲數據。
實際上這個想法是錯誤的,咱們不能忽視一個問題:CPU緩存的存儲空間是有限的
因而咱們但願CPU緩存存儲的是常用的數據,而不是那些少用的數據。這就引入了冷數據/熱數據分割的概念了。
熱數據:常常要操做使用的數據,咱們通常能夠直接做爲可直接訪問的成員變量。
冷數據:比較少用的數據,咱們通常以引用/指針來間接訪問(即存儲的是指針或者引用)。
一個栗子:對於人類來講,生命值位置速度都是常常須要操做的變量,是熱數據;
而掉落物對象只有人類死亡的時候才須要用到,因此是冷數據;
class Human { private: float health; float power; Vec3 position; Vec3 velocity; LootDrop* drop; //.... }; class LootDrop{ Item[2] itemsToDrop; float chance; //.... };
C++的虛函數機制,簡單來講是兩次地址跳轉的函數調用,這對CPU緩存十分不友好,每每命中失敗。
實際上虛函數能夠優雅解決不少面向對象的問題,然而在遊戲程序若是有不少虛函數都要頻繁調用(例如每幀調用),很容易引起性能問題。
解決方法是,把這些頻繁調用的虛函數儘量去除virtual特性(即作成普通成員函數),並避免調用基類對象的成員函數,代價是這樣一改得改不少與之牽連代碼。
因此最好一開始設計程序時,須要先想好哪些最好不要寫成virtual函數。
這實際上就是在優雅與性能之間尋求一個平衡。
面向數據編程還有更多小細節,可是這些都不經常使用,就只做爲一種思考面向數據編程的另類角度。
對多維數組的遍歷:int a[100][100];
for(int x=0;x<100;++x) for(int y=0;y<100;++y) a[x][y]; for(int y=0;y<100;++y) for(int x=0;x<100;++x) a[x][y];
內循環應該是對x遞增仍是對y遞增比較快?答案是:對y遞增比較快。
由於對 y 的遞增,結果是一個int大小的跳轉,也就是說容易訪問到相鄰的內存,即容易擊中CPU緩存。
而對 x 的遞增,結果是100個int大小的跳轉,不容易擊中CPU。
而內循環若是是y的話,那麼就能內外循環總共遞增100*100次y。
但內循環若是是x的話,那麼就內外循環總共只能遞增100次y,相比上者,CPU擊中比較少。
該更新一下我對面向對象和麪向數據的見解:
先說結論:應該兼有。由於遊戲程序是一個既須要高性能又複雜的工程。
使用面向對象的遊戲程序新手,經常就有一個問題:過分設計/過分抽象,什麼都想用設計模式封裝一下抽象一下。
這就很容易致使一些過分設計/過分抽象致使遊戲性能太差。
博主如今的項目風格都比較偏向面向數據思想,儘可能減小虛函數的使用,多利用數據組合成對象,而不是重寫各類基類虛函數。
對於一些數據結構的考量,也儘可能偏多使用連續存儲的結構(例如數組)。
如何兼有兩種思想,這種玄學的問題可能得靠本身去感悟,多嘗試和測試性能差異。
遊戲設計模式系列-其餘文章: