游戏设计模式——面向数据编程
注:面向數(shù)據(jù)編程文章已更新成markdown形式,并添加修改了一些內(nèi)容,而本文則作為舊文不再更新維護(hù)。
最新版博文如下:
【游戲設(shè)計(jì)模式——面向數(shù)據(jù)編程(新)】 https://www.cnblogs.com/KillerAery/p/11746639.html
前言:隨著軟件需求的日益復(fù)雜發(fā)展,遠(yuǎn)古時(shí)期面的向過程編程思想才漸漸萌生了面向?qū)ο缶幊趟枷搿?/p>
當(dāng)人們發(fā)現(xiàn)面向?qū)ο笤趹?yīng)對高層軟件的種種好處時(shí),越來越沉醉于面向?qū)ο螅瑹嶂杂谘芯咳绾胃觾?yōu)雅地抽象出對象。
然而現(xiàn)代開發(fā)中漸漸發(fā)現(xiàn)面向?qū)ο缶幊虒訉映橄笤斐捎纺[,導(dǎo)致運(yùn)行效率降低,而這是性能要求高的游戲編程領(lǐng)域不想看到的。
于是現(xiàn)代游戲編程中,面向數(shù)據(jù)編程的思想越來越被接受(例如Unity2018更新的ECS框架就是一種面向數(shù)據(jù)思想的框架)。
面向數(shù)據(jù)編程是什么?
先來一個(gè)簡單的比較:
面向過程思想:考慮解決問題所需的各個(gè)步驟(函數(shù))。
面向?qū)ο笏枷耄嚎紤]解決問題所需的各個(gè)模型(類)。
面向數(shù)據(jù)思想:考慮數(shù)據(jù)的存取及布局為核心思想(數(shù)據(jù))。
那么所謂的考慮數(shù)據(jù)存儲(chǔ)/布局是什么意思呢?
先引入一個(gè)有關(guān)CPU處理數(shù)據(jù)的概念:CPU多級(jí)緩存。
CPU多級(jí)緩存(CPU cache)
在組裝電腦購買CPU的時(shí)候,不知道大家是否留意過CPU的一個(gè)參數(shù):N級(jí)緩存(N一般有1/2/3)
什么是CPU緩存:
更詳細(xì)來說,結(jié)構(gòu)應(yīng)該是:CPU<---->寄存器<---->CPU緩存<---->內(nèi)存
可以看到CPU緩存是介于內(nèi)存和寄存器之間的一個(gè)存儲(chǔ)區(qū)域,此外它存儲(chǔ)空間比內(nèi)存小,比寄存器大。
為什么需要CPU多級(jí)緩存:
CPU的運(yùn)行頻率太快了,而CPU訪問內(nèi)存的速度很慢,這樣在處理器時(shí)鐘周期內(nèi),CPU常常需要等待寄存器讀取內(nèi)存,浪費(fèi)時(shí)間。
而CPU訪問CPU緩存則速度快很多。為了緩解CPU和內(nèi)存之間速度的不匹配問題,CPU緩存則預(yù)先存儲(chǔ)好潛在可能會(huì)訪問的內(nèi)存數(shù)據(jù)。
CPU多級(jí)緩存預(yù)先存的是什么:
時(shí)間局部性:如果某個(gè)數(shù)據(jù)被訪問,那么在不久的將來它很可能再次被訪問。
空間局部性:如果某個(gè)數(shù)據(jù)被訪問,那么與它相鄰的數(shù)據(jù)很快也能被訪問。
CPU多級(jí)緩存根據(jù)這兩個(gè)特點(diǎn),一般存儲(chǔ)的是訪問過的數(shù)據(jù)+訪問數(shù)據(jù)的相鄰數(shù)據(jù)。
CPU緩存命中/未命中:
CPU把待處理的數(shù)據(jù)或已處理的數(shù)據(jù)存入緩存指定的地址中,如果即將要處理的數(shù)據(jù)已經(jīng)存在此地址了,就叫作CPU緩存命中。
如果CPU緩存未命中,就轉(zhuǎn)到內(nèi)存地址訪問。
提高CPU緩存命中率
要盡可能提高CPU緩存命中率,就是要盡量讓使用的數(shù)據(jù)連續(xù)在一起。
由于面向數(shù)據(jù)編程技巧很多,本文篇幅有限,只介紹部分。
使用連續(xù)數(shù)組存儲(chǔ)要批處理的對象
1,傳統(tǒng)的組件模式,往往讓游戲?qū)ο蟪钟幸粋€(gè)或多個(gè)組件的引用數(shù)據(jù)(指針數(shù)據(jù))。
(一個(gè)典型的游戲?qū)ο箢悾?種組件的指針)
class GameObject {
//....GameObject的屬性
Component1* m_component1;
Component2* m_component2;
};
下面一幅圖顯示了這種傳統(tǒng)模式的結(jié)構(gòu):
游戲?qū)ο?組件往往是批處理操作較多(每幀更新/渲染/或其他操作)的對象。
這個(gè)傳統(tǒng)結(jié)構(gòu)相應(yīng)的每幀更新代碼:
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();
}
而根據(jù)圖中可以看到,這種指來指去的結(jié)構(gòu)對CPU緩存極其不友好:為了訪問組件總是跳轉(zhuǎn)到不相鄰的內(nèi)存。
倘若游戲?qū)ο蠛徒M件的更新順序不影響游戲邏輯,則一個(gè)可行的辦法是將他們都以連續(xù)數(shù)組形式存在。
注意是對象數(shù)組,而不是指針數(shù)組。如果是指針數(shù)組的話,這對CPU緩存命中沒有意義(因?yàn)橐ㄟ^指針跳轉(zhuǎn)到不相鄰的內(nèi)存)。
GameObject g[MAX_GAMEOBJECT_NUM]; Component1 a[MAX_COMPONENT_NUM]; Component2 b[MAX_COMPONENT_NUM];
(連續(xù)數(shù)組存儲(chǔ)能讓下面的批處理中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,這是一個(gè)簡單的粒子系統(tǒng):
const int MAX_PARTICLE_NUM = 3000;
//粒子類
class Particle {
private:
bool active;
Vec3 position;
Vec3 velocity;
//....其它粒子所需方法
};
Particle particles[MAX_PARTICLE_NUM];
int particleNum;
它使用了典型的lazy策略,當(dāng)要?jiǎng)h除一個(gè)粒子時(shí),只需改變active標(biāo)記,無需移動(dòng)內(nèi)存。
然后利用標(biāo)記判斷,每幀更新的時(shí)候可以略過刪除掉的粒子。
當(dāng)需要?jiǎng)?chuàng)建新粒子時(shí),只需要找到第一個(gè)被刪除掉的粒子,更改其屬性即可。
for (int i = 0; i < particleNum; ++i) {
if (particles[i].isActive()) {
particles[i].update();
}
}
表面上看這很科學(xué),實(shí)際上這樣做CPU緩存命中率不高:每次批處理CPU緩存都加載過很多不會(huì)用到的粒子數(shù)據(jù)(標(biāo)記被刪除的粒子)。
一個(gè)可行的方法是:當(dāng)要?jiǎng)h除粒子時(shí),將隊(duì)列尾的粒子內(nèi)存復(fù)制到該粒子的位置,并記錄減少后的粒子數(shù)量。
(移動(dòng)內(nèi)存(復(fù)制內(nèi)存)操作是程序員最不想看到的,但是實(shí)際執(zhí)行批處理帶來的速度提升相比刪除的開銷多的非常多,除非你移動(dòng)的內(nèi)存對象大小實(shí)在大到令人發(fā)指)
particles[i] = particles[particleNum]; particleNum--;
這樣我們就可以保證在這個(gè)粒子批量更新操作中,CPU緩存總是能以高命中率擊中。
for (int i = 0; i < particleNum; ++i) {
particles[i].update();
}
冷數(shù)據(jù)/熱數(shù)據(jù)分割
有人可能認(rèn)為這樣能最大程度利用CPU緩存:把一個(gè)對象所有要用的數(shù)據(jù)(包括組件數(shù)據(jù))都塞進(jìn)一個(gè)類里,而沒有任何用指針或引用的形式間接存儲(chǔ)數(shù)據(jù)。
實(shí)際上這個(gè)想法是錯(cuò)誤的,我們不能忽視一個(gè)問題:CPU緩存的存儲(chǔ)空間是有限的
于是我們希望CPU緩存存儲(chǔ)的是經(jīng)常使用的數(shù)據(jù),而不是那些少用的數(shù)據(jù)。這就引入了冷數(shù)據(jù)/熱數(shù)據(jù)分割的概念了。
熱數(shù)據(jù):經(jīng)常要操作使用的數(shù)據(jù),我們一般可以直接作為可直接訪問的成員變量。
冷數(shù)據(jù):比較少用的數(shù)據(jù),我們一般以引用/指針來間接訪問(即存儲(chǔ)的是指針或者引用)。
一個(gè)栗子:對于人類來說,生命值位置速度都是經(jīng)常需要操作的變量,是熱數(shù)據(jù);
而掉落物對象只有人類死亡的時(shí)候才需要用到,所以是冷數(shù)據(jù);
class Human {
private:
float health;
float power;
Vec3 position;
Vec3 velocity;
LootDrop* drop;
//....
};
class LootDrop{
Item[2] itemsToDrop;
float chance;
//....
};
頻繁調(diào)用的函數(shù)盡可能不要做成虛函數(shù)
C++的虛函數(shù)機(jī)制,簡單來說是兩次地址跳轉(zhuǎn)的函數(shù)調(diào)用,這對CPU緩存十分不友好,往往命中失敗。
實(shí)際上虛函數(shù)可以優(yōu)雅解決很多面向?qū)ο蟮膯栴},然而在游戲程序如果有很多虛函數(shù)都要頻繁調(diào)用(例如每幀調(diào)用),很容易引發(fā)性能問題。
解決方法是,把這些頻繁調(diào)用的虛函數(shù)盡可能去除virtual特性(即做成普通成員函數(shù)),并避免調(diào)用基類對象的成員函數(shù),代價(jià)是這樣一改得改很多與之牽連代碼。
所以最好一開始設(shè)計(jì)程序時(shí),需要先想好哪些最好不要寫成virtual函數(shù)。
這實(shí)際上就是在優(yōu)雅與性能之間尋求一個(gè)平衡。
更多小細(xì)節(jié)(不常用)
面向數(shù)據(jù)編程還有更多小細(xì)節(jié),但是這些都不常用,就只作為一種思考面向數(shù)據(jù)編程的另類角度。
對多維數(shù)組的遍歷: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];
內(nèi)循環(huán)應(yīng)該是對x遞增還是對y遞增比較快?答案是:對y遞增比較快。
因?yàn)閷?y 的遞增,結(jié)果是一個(gè)int大小的跳轉(zhuǎn),也就是說容易訪問到相鄰的內(nèi)存,即容易擊中CPU緩存。
而對 x 的遞增,結(jié)果是100個(gè)int大小的跳轉(zhuǎn),不容易擊中CPU。
而內(nèi)循環(huán)如果是y的話,那么就能內(nèi)外循環(huán)總共遞增100*100次y。
但內(nèi)循環(huán)如果是x的話,那么就內(nèi)外循環(huán)總共只能遞增100次y,相比上者,CPU擊中比較少。
額外
該更新一下我對面向?qū)ο蠛兔嫦驍?shù)據(jù)的看法:
先說結(jié)論:應(yīng)該兼有。因?yàn)橛螒虺绦蚴且粋€(gè)既需要高性能又復(fù)雜的工程。
使用面向?qū)ο蟮挠螒虺绦蛐率郑3>陀幸粋€(gè)問題:過度設(shè)計(jì)/過度抽象,什么都想用設(shè)計(jì)模式封裝一下抽象一下。
這就很容易導(dǎo)致一些過度設(shè)計(jì)/過度抽象導(dǎo)致游戲性能太差。
博主現(xiàn)在的項(xiàng)目風(fēng)格都比較偏向面向數(shù)據(jù)思想,盡量減少虛函數(shù)的使用,多利用數(shù)據(jù)組合成對象,而不是重寫各種基類虛函數(shù)。
對于一些數(shù)據(jù)結(jié)構(gòu)的考量,也盡量偏多使用連續(xù)存儲(chǔ)的結(jié)構(gòu)(例如數(shù)組)。
如何兼有兩種思想,這種玄學(xué)的問題可能得靠自己去感悟,多嘗試和測試性能差別。
游戲設(shè)計(jì)模式系列-其他文章:
https://www.cnblogs.com/KillerAery/category/1307176.html
總結(jié)
以上是生活随笔為你收集整理的游戏设计模式——面向数据编程的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: cocos2d-x3.6 连连看连通画线
- 下一篇: 7z命令行简单使用