ECS模式
大家好,本文提出了ECS模式。ECS模式是游戲引擎中常用的模式,通常用來組織游戲場(chǎng)景。本文出自我寫的開源書《3D編程模式》,該書的更多內(nèi)容請(qǐng)?jiān)斠姡?br>
Github
在線閱讀
普通英雄和超級(jí)英雄
需求
我們需要開發(fā)一個(gè)游戲,游戲中有兩種人物:普通英雄和超級(jí)英雄,他們具有下面的行為:
- 普通英雄只能移動(dòng)
- 超級(jí)英雄不僅能夠移動(dòng),還能飛行
我們使用下面的方法來渲染:
- 使用Instance技術(shù)來一次性批量渲染所有的普通英雄
- 一個(gè)一個(gè)地渲染每個(gè)超級(jí)英雄
實(shí)現(xiàn)思路
應(yīng)該有一個(gè)游戲世界,它由多個(gè)普通英雄和多個(gè)超級(jí)英雄組成
一個(gè)模塊對(duì)應(yīng)一個(gè)普通英雄,一個(gè)模塊對(duì)應(yīng)一個(gè)超級(jí)英雄。模塊應(yīng)該維護(hù)該英雄的數(shù)據(jù)和實(shí)現(xiàn)該英雄的行為
給出UML
領(lǐng)域模型

總體來看,領(lǐng)域模型分為用戶、游戲世界、英雄這三個(gè)部分
我們看下用戶、游戲世界這兩個(gè)部分:
Client是用戶
World是游戲世界,由多個(gè)普通英雄和多個(gè)超級(jí)英雄組成。World負(fù)責(zé)管理所有的英雄,并且實(shí)現(xiàn)了初始化和主循環(huán)的邏輯
我們看下英雄這個(gè)部分:
一個(gè)NormalHero對(duì)應(yīng)一個(gè)普通英雄,維護(hù)了該英雄的數(shù)據(jù),實(shí)現(xiàn)了移動(dòng)的行為
一個(gè)SuperHero對(duì)應(yīng)一個(gè)超級(jí)英雄, 維護(hù)了該英雄的數(shù)據(jù),實(shí)現(xiàn)了移動(dòng)、飛行的行為
給出代碼
首先,我們看下Client的代碼;
然后,我們依次看下Client代碼中前兩個(gè)步驟的代碼,它們包括:
- 創(chuàng)建WorldState的代碼
- 創(chuàng)建場(chǎng)景的代碼
然后,因?yàn)閯?chuàng)建場(chǎng)景時(shí)操作了普通英雄和超級(jí)英雄,所以我們看下它們的代碼,它們包括:
- 普通英雄移動(dòng)的代碼
- 超級(jí)英雄移動(dòng)和飛行的代碼
然后,我們依次看下Client代碼中剩余的兩個(gè)步驟的代碼,它們包括:
- 初始化的代碼
- 主循環(huán)的代碼
然后,我們看下主循環(huán)的一幀中每個(gè)步驟的代碼,它們包括:
- 主循環(huán)中更新的代碼
- 主循環(huán)中渲染的代碼
最后,我們運(yùn)行Client的代碼
Client的代碼
Client
let worldState = World.createState()
worldState = _createScene(worldState)
worldState = WorldUtils.init(worldState)
WorldUtils.loop(worldState, [World.update, World.renderOneByOne, World.renderInstances])
Client首先創(chuàng)建了WorldState,用來保存游戲世界中所有的數(shù)據(jù);然后創(chuàng)建了場(chǎng)景;然后進(jìn)行了初始化;最后開始了主循環(huán)
創(chuàng)建WorldState的代碼
World
export let createState = (): worldState => {
return {
normalHeroes: Map(),
superHeroes: Map()
}
}
createState函數(shù)創(chuàng)建了WorldState,它包括兩個(gè)分別用來保存所有的普通英雄和所有的超級(jí)英雄的容器
創(chuàng)建場(chǎng)景的代碼
Client
let _createScene = (worldState: worldState): worldState => {
創(chuàng)建和加入normalHero1到worldState.normalHeroes
創(chuàng)建和加入normalHero2到worldState.normalHeroes
normalHero1移動(dòng)
創(chuàng)建和加入superHero1到worldState.superHeroes
創(chuàng)建和加入superHero2到worldState.superHeroes
superHero1移動(dòng)
superHero1飛行
return worldState
}
_createScene函數(shù)創(chuàng)建了場(chǎng)景,創(chuàng)建和加入了兩個(gè)普通英雄和兩個(gè)超級(jí)英雄到游戲世界中。其中第一個(gè)普通英雄進(jìn)行了移動(dòng),第一個(gè)超級(jí)英雄進(jìn)行了移動(dòng)和飛行
NormalHero
//創(chuàng)建一個(gè)普通英雄
export let create = (): [normalHeroState, normalHero] => {
創(chuàng)建它的state數(shù)據(jù):
position設(shè)置為[0,0,0]
velocity設(shè)置為1.0
其中:position為位置,velocity為速度
返回該英雄
}
NormalHero的create函數(shù)創(chuàng)建了一個(gè)普通英雄,初始化了它的數(shù)據(jù)
SuperHero
//創(chuàng)建一個(gè)超級(jí)英雄
export let create = (): [superHeroState, superHero] => {
創(chuàng)建它的state數(shù)據(jù):
position設(shè)置為[0,0,0]
velocity設(shè)置為1.0
maxVelocity設(shè)置為1.0
其中:position為位置,velocity為速度,maxVelocity為最大速度
返回該英雄
}
SuperHero的create函數(shù)創(chuàng)建了一個(gè)超級(jí)英雄,初始化了它的數(shù)據(jù)
普通英雄移動(dòng)的代碼
NormalHero
//一個(gè)普通英雄的移動(dòng)
export let move = (worldState: worldState, normalHero: normalHero): worldState => {
從worldState中獲得該英雄的position和velocity
根據(jù)velocity,更新position
更新worldState中該英雄的數(shù)據(jù)
}
move函數(shù)實(shí)現(xiàn)了移動(dòng)的行為邏輯,更新了位置
超級(jí)英雄移動(dòng)和飛行的代碼
SuperHero
//一個(gè)超級(jí)英雄的移動(dòng)
export let move = (worldState: worldState, superHero: superHero): worldState => {
從worldState中獲得該英雄的position和velocity
根據(jù)velocity,更新position
更新worldState中該英雄的數(shù)據(jù)
}
//一個(gè)超級(jí)英雄的飛行
export let fly = (worldState: worldState, superHero: superHero): worldState => {
從worldState中獲得該英雄的position和velocity、maxVelocity
根據(jù)maxVelocity、velocity,更新position
更新worldState中該英雄的數(shù)據(jù)
}
SuperHero的move函數(shù)的邏輯跟NormalHero的move函數(shù)的邏輯是一樣的
fly函數(shù)實(shí)現(xiàn)了飛行的行為邏輯。它跟move函數(shù)一樣,也是更新英雄的position。只是因?yàn)閮烧咴谟?jì)算時(shí)使用的速度的算法不一樣,所以更新position的幅度不同
初始化的代碼
WorldUtils
export let init = (worldState) => {
console.log("初始化...")
return worldState
}
init函數(shù)實(shí)現(xiàn)了初始化。這里沒有任何邏輯,只是進(jìn)行了打印
主循環(huán)的代碼
WorldUtils
export let loop = (worldState, [update, renderOneByOne, renderInstances]) => {
worldState = update(worldState)
renderOneByOne(worldState)
renderInstances(worldState)
...
requestAnimationFrame(
(time) => {
loop(worldState, [update, renderOneByOne, renderInstances])
}
)
}
loop函數(shù)實(shí)現(xiàn)了主循環(huán)。在主循環(huán)的一幀中,首先進(jìn)行了更新;然后一個(gè)一個(gè)地渲染了所有的超級(jí)英雄;然后一次性批量渲染了所有的普通英雄;最后執(zhí)行下一幀
主循環(huán)中更新的代碼
World
export let update = (worldState: worldState): worldState => {
遍歷worldState.normalHeroes:
更新每個(gè)normalHero
遍歷worldState.superHeroes:
更新每個(gè)superHero
}
update函數(shù)實(shí)現(xiàn)了更新,它會(huì)遍歷所有的normalHero和superHero,調(diào)用它們的update函數(shù)來更新自己
我們看下NormalHero的update函數(shù)的代碼:
//更新一個(gè)普通英雄
export let update = (normalHeroState: normalHeroState): normalHeroState => {
更新該英雄的position
}
它更新了自己的position
我們看下SuperHero的update函數(shù)的代碼:
//更新一個(gè)超級(jí)英雄
export let update = (superHeroState: superHeroState): superHeroState => {
更新該英雄的position
}
它的邏輯跟NormalHero的update是一樣的,這是因?yàn)閮烧叨际褂猛瑯拥乃惴▉砀伦约旱膒osition
主循環(huán)中渲染的代碼
World
export let renderOneByOne = (worldState: worldState): void => {
worldState.superHeroes.forEach(superHeroState => {
console.log("OneByOne渲染 SuperHero...")
})
}
export let renderInstances = (worldState: worldState): void => {
let normalHeroStates = worldState.normalHeroes
console.log("批量Instance渲染 NormalHeroes...")
}
renderOneByOne函數(shù)實(shí)現(xiàn)了超級(jí)英雄的渲染,它遍歷每個(gè)超級(jí)英雄,一個(gè)一個(gè)地渲染
renderInstances函數(shù)實(shí)現(xiàn)了普通英雄的渲染,它一次性獲得所有的普通英雄,批量渲染
運(yùn)行Client的代碼
下面,我們運(yùn)行Client的代碼,打印的結(jié)果如下:
初始化...
更新NormalHero
更新NormalHero
更新SuperHero
更新SuperHero
OneByOne渲染 SuperHero...
OneByOne渲染 SuperHero...
批量Instance渲染 NormalHeroes...
{"normalHeroes":{"144891":{"position":[0,0,0],"velocity":1},"648575":{"position":[2,2,2],"velocity":1}},"superHeroes":{"497069":{"position":[6,6,6],"velocity":1,"maxFlyVelocity":10},"783438":{"position":[0,0,0],"velocity":1,"maxFlyVelocity":10}}}
通過打印的數(shù)據(jù),可以看到運(yùn)行的步驟如下:
1.進(jìn)行了初始化
2.更新了所有的人物,包括兩個(gè)普通英雄和兩個(gè)超級(jí)英雄
3.渲染了2個(gè)超級(jí)英雄
4.一次性批量渲染了所有的普通英雄
5.打印了WorldState
我們看下打印的WorldState:
- WorldState的normalHeroes中一共有兩個(gè)普通英雄的數(shù)據(jù),其中有一個(gè)普通英雄數(shù)據(jù)的position為[2,2,2]而不是初始的[0,0,0],說明該普通英雄進(jìn)行了移動(dòng)操作;
- WorldState的superHeroes中一共有兩個(gè)超級(jí)英雄的數(shù)據(jù),其中有一個(gè)超級(jí)英雄數(shù)據(jù)的position為[6,6,6],說明該超級(jí)英雄進(jìn)行了移動(dòng)和飛行操作
值得注意的是:
因?yàn)閃orldState的normalHeroes和superHeroes中的Key是隨機(jī)生成的id值,所以每次打印時(shí)Key都不一樣
提出問題
-
NormalHero和SuperHero中的update、move函數(shù)的邏輯是重復(fù)的
-
如果英雄增加更多的行為,NormalHero和SuperHero模塊會(huì)越來越復(fù)雜,不容易維護(hù)
雖然這兩個(gè)問題都可以通過繼承來解決,即最上面是Hero基類,然后不同種類的Hero層層繼承,但是繼承的方式很死板,不夠靈活
基于組件化的思想改進(jìn)
概述解決方案
-
基于組件化的思想,用組合代替繼承。具體修改如下:
- 將人物抽象為GameObject;
- 將人物的行為抽象為組件,并把人物的相關(guān)數(shù)據(jù)也移到組件中;
- GameObject通過掛載不同的組件,來實(shí)現(xiàn)不同的行為
這樣就通過GameObject組合不同的組件來代替人物層層繼承,從而更加靈活
給出UML
領(lǐng)域模型

總體來看,領(lǐng)域模型分為用戶、游戲世界、GameObject、組件這四個(gè)部分
我們看下用戶、游戲世界這兩個(gè)部分:
Client是用戶
World是游戲世界,由多個(gè)GameObject組成。World負(fù)責(zé)管理所有的GameObject,并且實(shí)現(xiàn)了初始化和主循環(huán)的邏輯
我們看下GameObject這個(gè)部分:
一個(gè)GameObject對(duì)應(yīng)一個(gè)人物。GameObject負(fù)責(zé)管理掛載的組件,它可以掛載PositionComponent、VelocityComponent、FlyComponent、InstanceComponent這四種組件,每種組件最多掛載一個(gè)
我們看下組件這個(gè)部分:
組件負(fù)責(zé)維護(hù)自己的數(shù)據(jù),實(shí)現(xiàn)自己的行為邏輯。具體來說,是將NormalHero、SuperHero的position數(shù)據(jù)和move函數(shù)、update函數(shù)移到了PositionComponent中;將NormalHero、SuperHero的velocity數(shù)據(jù)移到了VelocityComponent中;將SuperHero的maxVelocity數(shù)據(jù)和fly函數(shù)移到了FlyComponent中
InstanceComponent沒有數(shù)據(jù)和邏輯,它只是一個(gè)標(biāo)記,用來表示掛載該組件的GameObject使用一次性批量渲染的算法來渲染
結(jié)合UML圖,描述如何具體地解決問題
-
現(xiàn)在只需要實(shí)現(xiàn)一次Position組件中的update、move函數(shù),然后將它掛載到不同的GameObject中,就可以實(shí)現(xiàn)普通英雄和超級(jí)英雄的更新、移動(dòng)的邏輯,從而消除了之前在NormalHero、SuperHero中因共實(shí)現(xiàn)了兩次的update、move函數(shù)而造成的重復(fù)代碼
-
因?yàn)镹ormalHero、SuperHero都是GameObject,而GameObject本身只負(fù)責(zé)管理組件,沒有行為邏輯,所以隨著人物的行為的增加,GameObject并不會(huì)增加邏輯,而只需要增加對(duì)應(yīng)行為的組件,讓GameObject掛載該組件即可
通過這樣的設(shè)計(jì),將行為的邏輯和數(shù)據(jù)從人物移到了組件中,從而可以通過組合的方式使人物具有多個(gè)行為,避免了龐大的人物模塊的出現(xiàn)
給出代碼
首先,我們看下Client的代碼;
然后,我們依次看下Client代碼中前兩個(gè)步驟的代碼,它們包括:
- 創(chuàng)建WorldState的代碼
- 創(chuàng)建場(chǎng)景的代碼
然后,因?yàn)閯?chuàng)建場(chǎng)景時(shí)操作了普通英雄和超級(jí)英雄,所以我們看下它們的代碼,它們包括:
- 移動(dòng)的相關(guān)代碼
- 飛行的相關(guān)代碼
然后,我們依次看下Client代碼中剩余的兩個(gè)步驟的代碼,它們包括:
- 初始化和主循環(huán)的代碼
然后,我們看下主循環(huán)的一幀中每個(gè)步驟的代碼,它們包括:
- 主循環(huán)中更新的代碼
- 主循環(huán)中渲染的代碼
最后,我們運(yùn)行Client的代碼
Client的代碼
Client的代碼跟之前的Client的代碼基本上一樣,故省略。不一樣的地方是_createScene函數(shù)中創(chuàng)建場(chǎng)景的方式不一樣,這個(gè)等會(huì)再討論
創(chuàng)建WorldState的代碼
World
export let createState = (): worldState => {
return {
gameObjects: Map()
}
}
createState函數(shù)創(chuàng)建了WorldState,它保存了一個(gè)用來保存所有的gameObject的容器
創(chuàng)建場(chǎng)景的代碼
Client
let _createScene = (worldState: worldState): worldState => {
創(chuàng)建和加入normalHero1到worldState.gameObjects:
創(chuàng)建gameObject
創(chuàng)建positionComponent
創(chuàng)建velocityComponent
創(chuàng)建instanceComponent
掛載positionComponent、velocityComponent、instanceComponent到gameObject
加入gameObject到worldState.gameObjects
創(chuàng)建和加入normalHero2到worldState.gameObjects
normalHero1移動(dòng):
調(diào)用normalHero1掛載的positionComponent的move函數(shù)
創(chuàng)建和加入superHero1到worldState.gameObjects:
創(chuàng)建gameObject
創(chuàng)建positionComponent
創(chuàng)建velocityComponent
創(chuàng)建flyComponent
掛載positionComponent、velocityComponent、flyComponent到gameObject
加入gameObject到worldState.gameObjects
創(chuàng)建和加入superHero2到worldState.gameObjects
superHero1移動(dòng):
調(diào)用superHero1掛載的positionComponent的move函數(shù)
superHero1飛行:
調(diào)用superHero1掛載的flyComponent的fly函數(shù)
return worldState
}
_createScene函數(shù)創(chuàng)建了場(chǎng)景,場(chǎng)景的內(nèi)容跟之前一樣,都包括了2個(gè)普通英雄和2個(gè)超級(jí)英雄,只是現(xiàn)在創(chuàng)建一個(gè)英雄的方式改變了,具體變?yōu)椋菏紫葎?chuàng)建一個(gè)GameObject和相關(guān)的組件;然后掛載組件到GameObject;最后加入該GameObject到World中
普通英雄對(duì)應(yīng)的GameObject掛載的組件跟超級(jí)英雄對(duì)應(yīng)的GameObject掛載的組件也不一樣,其中前者掛載了InstanceComponent(因?yàn)槠胀ㄓ⑿坌枰淮涡耘夸秩荆笳邉t掛載了FlyComponent(因?yàn)槌?jí)英雄多出了飛行的行為)
另外,現(xiàn)在改為通過調(diào)用對(duì)應(yīng)組件的函數(shù)而不是直接操作英雄模塊,從而實(shí)現(xiàn)英雄的“移動(dòng)”、“飛行”
GameObject
//創(chuàng)建一個(gè)gameObject
export let create = (): [gameObjectState, gameObject] => {
創(chuàng)建它的state數(shù)據(jù):
沒有掛載任何的組件
返回該gameObject
}
GameObject的create函數(shù)創(chuàng)建了一個(gè)gameObject,初始化了它的數(shù)據(jù)
PositionComponent
//創(chuàng)建一個(gè)positionComponent
export let create = (): positionComponentState => {
創(chuàng)建它的state數(shù)據(jù):
gameObject設(shè)置為null
position設(shè)置為[0,0,0]
其中:position為位置,gameObject為掛載到的gameObject
返回該組件
}
PositionComponent的create函數(shù)創(chuàng)建了一個(gè)positionComponent,初始化了它的數(shù)據(jù)
VelocityComponent
//創(chuàng)建一個(gè)velocityComponent
export let create = (): velocityComponentState => {
創(chuàng)建它的state數(shù)據(jù):
gameObject設(shè)置為null
velocity設(shè)置為1.0
其中:velocity為速度,gameObject為掛載到的gameObject
返回該組件
}
FlyComponent
//創(chuàng)建一個(gè)flyComponent
export let create = (): flyComponentState => {
創(chuàng)建它的state數(shù)據(jù):
gameObject設(shè)置為null
maxVelocity設(shè)置為1.0
其中:maxVelocity為最大速度,gameObject為掛載到的gameObject
返回該組件
}
InstanceComponent
//創(chuàng)建一個(gè)instanceComponent
export let create = (): instanceComponentState => {
創(chuàng)建它的state數(shù)據(jù):
gameObject設(shè)置為null
其中:gameObject為掛載到的gameObject
返回該組件
}
這三種組件的create函數(shù)的職責(zé)跟PositionComponent的create函數(shù)的職責(zé)一樣,不一樣的是InstanceComponent的state數(shù)據(jù)中只有掛載到的gameObject,沒有自己的數(shù)據(jù)
我們可以看到,組件的state數(shù)據(jù)中都保存了掛載到的gameObject,這樣做的目的是可以通過它來獲得掛載到它上的其它組件,從而一個(gè)組件可以操作其它掛載的組件
移動(dòng)的相關(guān)代碼
PositionComponent
...
//獲得一個(gè)組件的position
export let getPosition = (positionComponentState: positionComponentState) => {
return positionComponentState.position
}
//設(shè)置一個(gè)組件的position
export let setPosition = (positionComponentState: positionComponentState, position) => {
return {
...positionComponentState,
position: position
}
}
...
//一個(gè)gameObject的移動(dòng)
export let move = (worldState: worldState, positionComponentState: positionComponentState): worldState => {
//獲得該組件的position、gameObject
let [x, y, z] = getPosition(positionComponentState)
//通過該組件的gameObject,獲得掛載到該gameObject的velocityComponent組件
//獲得它的velocity
let gameObject = getExnFromStrictNull(positionComponentState.gameObject)
let velocity = VelocityComponent.getVelocity(GameObject.getVelocityComponentExn(getGameObjectStateExn(worldState, gameObject)))
//根據(jù)velocity,更新該組件的position
positionComponentState = setPosition(positionComponentState, [x + velocity, y + velocity, z + velocity])
更新worldState中該組件掛載的gameObject中的該組件的數(shù)據(jù)
}
VelocityComponent
//獲得一個(gè)組件的velocity
export let getVelocity = (velocityComponentState: velocityComponentState) => {
return velocityComponentState.velocity
}
PositionComponent維護(hù)了position數(shù)據(jù),提供了它的get、set函數(shù)。VelocityComponent維護(hù)了velocity數(shù)據(jù),,提供了它的get函數(shù)
另外,PositionComponent的move函數(shù)實(shí)現(xiàn)了移動(dòng)的行為邏輯
飛行的相關(guān)代碼
FlyComponent
//獲得一個(gè)組件的maxVelocity
export let getMaxVelocity = (flyComponentState: flyComponentState) => {
return flyComponentState.maxVelocity
}
//設(shè)置一個(gè)組件的maxVelocity
export let setMaxVelocity = (flyComponentState: flyComponentState, maxVelocity) => {
return {
...flyComponentState,
maxVelocity: maxVelocity
}
}
//一個(gè)gameObject的飛行
export let fly = (worldState: worldState, flyComponentState: flyComponentState): worldState => {
//獲得該組件的maxVelocity、gameObject
let maxVelocity = getMaxVelocity(flyComponentState)
let gameObject = getExnFromStrictNull(flyComponentState.gameObject)
//通過該組件的gameObject,獲得掛載到該gameObject的positionComponent組件
//獲得它的position
let [x, y, z] = PositionComponent.getPosition(GameObject.getPositionComponentExn(getGameObjectStateExn(worldState, gameObject)))
//通過該組件的gameObject,獲得掛載到該gameObject的velocityComponent組件
//獲得它的velocity
let velocity = VelocityComponent.getVelocity(GameObject.getVelocityComponentExn(getGameObjectStateExn(worldState, gameObject)))
//根據(jù)maxVelocity、velocity,更新positionComponent組件的position
velocity = velocity < maxVelocity ? (velocity * 2.0) : maxVelocity
let positionComponentState = PositionComponent.setPosition(GameObject.getPositionComponentExn(getGameObjectStateExn(worldState, gameObject)), [x + velocity, y + velocity, z + velocity])
更新worldState中該組件掛載的gameObject中的該組件的數(shù)據(jù)
}
FlyComponent維護(hù)了maxVelocity數(shù)據(jù),,提供了它的get、set函數(shù)。另外,F(xiàn)lyComponent的fly函數(shù)實(shí)現(xiàn)了飛行的行為邏輯
初始化和主循環(huán)的代碼
初始化和主循環(huán)的邏輯跟之前一樣,故省略代碼
主循環(huán)中更新的代碼
World
export let update = (worldState: worldState): worldState => {
遍歷worldState.gameObjects:
if(gameObject掛載了positionComponent){
更新positionComponent
}
}
update函數(shù)實(shí)現(xiàn)了更新,它會(huì)遍歷所有的gameObject,調(diào)用它掛載的PositionComponent組件的update函數(shù)來更新該組件。我們看下PositionComponent的update函數(shù)的代碼:
//更新一個(gè)組件
export let update = (positionComponentState: positionComponentState): positionComponentState => {
更新該組件的position
}
它的邏輯跟之前的NormalHero和SuperHero中的update函數(shù)的邏輯是一樣的
主循環(huán)中渲染的代碼
World
export let renderOneByOne = (worldState: worldState): void => {
let superHeroGameObjects = worldState.gameObjects.filter(gameObjectState => {
//判斷gameObject是不是沒有掛載InstanceComponent
return !GameObject.hasInstanceComponent(gameObjectState)
})
superHeroGameObjects.forEach(gameObjectState => {
console.log("OneByOne渲染 SuperHero...")
})
}
export let renderInstances = (worldState: worldState): void => {
let normalHeroGameObejcts = worldState.gameObjects.filter(gameObjectState => {
//判斷gameObject是不是掛載了InstanceComponent
return GameObject.hasInstanceComponent(gameObjectState)
})
console.log("批量Instance渲染 NormalHeroes...")
}
renderOneByOne函數(shù)實(shí)現(xiàn)了超級(jí)英雄的渲染,它首先得到了所有沒有掛載InstanceComponent組件的gameObject;最后遍歷它們,一個(gè)一個(gè)地渲染
renderInstances函數(shù)實(shí)現(xiàn)了普通英雄的渲染,它首先得到了所有掛載了InstanceComponent組件的gameObject;最后批量渲染
運(yùn)行Client的代碼
下面,我們運(yùn)行Client的代碼,打印的結(jié)果如下:
初始化...
更新PositionComponent
更新PositionComponent
更新PositionComponent
更新PositionComponent
OneByOne渲染 SuperHero...
OneByOne渲染 SuperHero...
批量Instance渲染 NormalHeroes...
{"gameObjects":{"304480":{"positionComponent":{"gameObject":304480,"position":[0,0,0]},"velocityComponent":{"gameObject":304480,"velocity":1},"flyComponent":{"gameObject":304480,"maxVelocity":10},"instanceComponent":null},"666533":{"positionComponent":{"gameObject":666533,"position":[2,2,2]},"velocityComponent":{"gameObject":666533,"velocity":1},"flyComponent":null,"instanceComponent":{"gameObject":666533}},"838392":{"positionComponent":{"gameObject":838392,"position":[0,0,0]},"velocityComponent":{"gameObject":838392,"velocity":1},"flyComponent":null,"instanceComponent":{"gameObject":838392}},"936933":{"positionComponent":{"gameObject":936933,"position":[6,6,6]},"velocityComponent":{"gameObject":936933,"velocity":1},"flyComponent":{"gameObject":936933,"maxVelocity":10},"instanceComponent":null}}}
通過打印的數(shù)據(jù),可以看到運(yùn)行的步驟與之前一樣
不同之處在于:
- 更新4個(gè)英雄現(xiàn)在變?yōu)楦?個(gè)positionComponent
- 打印的WorldState不一樣
我們看下打印的WorldState:
- WorldState的gameObjects包括了4個(gè)gameObject的數(shù)據(jù),其中有一個(gè)gameObject數(shù)據(jù)的positionComponent的position為[2,2,2],說明它進(jìn)行了移動(dòng)操作;
- 有一個(gè)gameObject數(shù)據(jù)的positionComponent的position為[6,6,6],說明它進(jìn)行了移動(dòng)和飛行操作
值得注意的是:
因?yàn)閃orldState的gameObjects中的Key是隨機(jī)生成的id值,所以每次打印時(shí)Key都不一樣
提出問題
- 組件的數(shù)據(jù)分散在各個(gè)組件中,性能不好
如position數(shù)據(jù)現(xiàn)在是一對(duì)一地分散保存在各個(gè)positionComponent組件中(即一個(gè)positionComponent組件保存自己的position),那么如果需要遍歷所有組件的position數(shù)據(jù),則需要遍歷所有的positionComponent組件,分別獲得它們的position。因?yàn)槊總€(gè)positionComponent組件的數(shù)據(jù)并沒有連續(xù)地保存在內(nèi)存中,所以會(huì)造成緩存命中丟失,帶來性能損失
- 涉及多種組件的行為不知道放在哪里
如果超級(jí)英雄增加一個(gè)“跳”的行為,該行為不僅需要修改position數(shù)據(jù),還需要修改velocity數(shù)據(jù),那么實(shí)現(xiàn)該行為的jump函數(shù)應(yīng)該放在哪個(gè)組件中呢?
因?yàn)閖ump函數(shù)需要同時(shí)修改PositionComponent組件的position數(shù)據(jù)和VelocityComponent組件的velocity數(shù)據(jù),所以將它放在兩者中任何一種組件中都不合適。因此需要增加一種新的組件-JumpComponent,對(duì)應(yīng)“跳”這個(gè)行為,并實(shí)現(xiàn)jump函數(shù)。該函數(shù)會(huì)通過JumpComponent掛載到的gameObject來獲得掛載到它上的PositionComponent和VelocityComponent組件,從而修改它們的數(shù)據(jù)。
如果增加更多的這種涉及多種組件的行為,就需要為每個(gè)這樣的行為增加一種組件。因?yàn)榻M件比較重,既有數(shù)據(jù)又有邏輯,所以增加組件的成本較高;另外,因?yàn)榻M件與GameObject是聚合關(guān)系,而GameObject和World也是聚合關(guān)系,它們都屬于強(qiáng)關(guān)聯(lián)關(guān)系,所以增加組件會(huì)較強(qiáng)地影響GameObject和World,這也增加了成本
使用ECS模式來改進(jìn)
概述解決方案
-
基于Data Oriented的思想進(jìn)行改進(jìn)
組件可以按角色分為Data Oriented組件和其它組件,其中前者的特點(diǎn)是屬于該角色的每個(gè)組件都有數(shù)據(jù),且組件的數(shù)量較多;后者的特點(diǎn)是屬于該角色的每個(gè)組件都沒有數(shù)據(jù),或者組件的數(shù)量很少。這里具體說明一下各種組件的角色:目前一共有四種組件,它們是PositionComponent、VelocityComponent、FlyComponent、InstanceComponent。其中,InstanceComponent組件因?yàn)闆]有組件數(shù)據(jù),所以屬于“其它組件”;另外三種組件則都屬于“Data Oriented組件”。
屬于Data Oriented組件的三種組件的所有組件數(shù)據(jù)將會(huì)分別集中起來,保存在各自的一塊連續(xù)的地址空間中,具體就是分別保存在三個(gè)ArrayBuffer中 -
將GameObject和各個(gè)組件扁平化
GameObject不再有數(shù)據(jù)和邏輯了,而只是一個(gè)全局唯一的id。組件也不再有數(shù)據(jù)和邏輯了,其中屬于“Data Oriented組件”的組件只是一個(gè)ArrayBuffer上的索引;屬于“其它組件”的組件只是一個(gè)全局唯一的id
-
增加Component+GameObject這一層,將扁平的GameObject和組件放在該層中
-
增加Manager這一層,來管理GameObject和組件的數(shù)據(jù)
這一層有GameObjectManager和四種組件的Manager,其中GameObjectManager負(fù)責(zé)管理所有的gameObject;四種組件的Manager負(fù)責(zé)管理自己的ArrayBuffer,操作屬于該種類的所有組件
- 增加System這一層,來實(shí)現(xiàn)行為的邏輯
一個(gè)System實(shí)現(xiàn)一個(gè)行為,比如這一層中的MoveSystem、FlySystem分別實(shí)現(xiàn)了移動(dòng)和飛行的行為邏輯
值得注意的是:
- GameObject和組件的數(shù)據(jù)被移到了Manager中,邏輯則被移到了Manager和System中。其中只操作自己數(shù)據(jù)的邏輯(如getPosition、setPosition)被移到了Manager中,其它邏輯(通常為行為邏輯,需要操作多種組件)被移到了System中
- 一種組件的Manager只對(duì)該種組件進(jìn)行操作,而一個(gè)System可以對(duì)多種組件進(jìn)行操作
給出UML
領(lǐng)域模型

總體來看,領(lǐng)域模型分為五個(gè)部分:用戶、World、System層、Manager層、Component+GameObject層,它們的依賴關(guān)系是前者依賴后者
我們看下用戶、World這兩個(gè)部分:
Client是用戶
World是游戲世界,雖然仍然實(shí)現(xiàn)了初始化和主循環(huán)的邏輯,不過不再管理所有的GameObject了
我們看下System這一層:
有多個(gè)System,每個(gè)System實(shí)現(xiàn)一個(gè)行為邏輯。每個(gè)System的職責(zé)如下:
- CreateStateSystem實(shí)現(xiàn)創(chuàng)建WorldState的邏輯,創(chuàng)建的WorldState包括了所有的Manager的state數(shù)據(jù);
- UpdateSystem實(shí)現(xiàn)更新所有人物的position的邏輯,具體是更新所有PositionComponent的position;
- MoveSystem實(shí)現(xiàn)一個(gè)人物的移動(dòng)的邏輯,具體是根據(jù)掛載到該人物gameObject上的一個(gè)positionComponent和一個(gè)velocityComponent,更新該positionComponent的position;
- FlySystem實(shí)現(xiàn)一個(gè)人物的飛行的邏輯,具體是根據(jù)掛載到該人物gameObject上的一個(gè)positionComponent、一個(gè)velocityComponent、一個(gè)flyComponent,更新該positionComponent的position;
- RenderOneByOneSystem實(shí)現(xiàn)渲染所有超級(jí)英雄的邏輯;
- RenderInstancesSystem實(shí)現(xiàn)渲染所有普通英雄的邏輯
我們看下Manager這一層:
每個(gè)Manager都有一個(gè)state數(shù)據(jù)
GameObjectManager負(fù)責(zé)管理所有的gameObject
PositionComponentManager、VelocityComponentManager、FlyComponentManager、InstanceComponentManager負(fù)責(zé)管理屬于各自種類的所有的組件
PositionComponentManager的state數(shù)據(jù)包括一個(gè)buffer字段和一個(gè)positions字段,其中前者是一個(gè)ArrayBuffer,保存了該種組件的所有組件數(shù)據(jù);后者是buffer的視圖,用于讀寫buffer中的position數(shù)據(jù)
PositionComponentManager的batchUpdate函數(shù)負(fù)責(zé)批量更新所有的positionComponent組件的position
因?yàn)閂elocityComponentManager、FlyComponentManager與PositionComponentManager一樣,都屬于Data Oriented組件的Manager,所以它們的數(shù)據(jù)和函數(shù)類似(只是沒有batchUpdate函數(shù)),故在圖中省略它們的數(shù)據(jù)和函數(shù)
我們看下Component+GameObject這一層:
因?yàn)镻ositionComponent、VelocityComponent、FlyComponent屬于Data Oriented組件,所以它們是一個(gè)index,也就是各自Manager的state的buffer中的索引值
因?yàn)镮nstanceComponent屬于其它組件,所以它是一個(gè)全局唯一的id
GameObject是一個(gè)全局唯一的id
我們來看下依賴關(guān)系:
System層:
因?yàn)镃reateSystem需要調(diào)用各個(gè)Manager的createState函數(shù)來創(chuàng)建它們的state,所以依賴了整個(gè)Manager層
因?yàn)閁pdateSystem需要調(diào)用PositionComponentManager的batchUpdate函數(shù)來更新,所以依賴了PositionComponentManager
因?yàn)镸oveSystem需要調(diào)用PositionComponentManager來獲得和設(shè)置position,并且調(diào)用VelocityComponentManager來獲得velocity,所以依賴了PositionComponentManager、VelocityComponentManager
因?yàn)镕lySystem需要調(diào)用PositionComponentManager來獲得和設(shè)置position,并且調(diào)用VelocityComponentManager、FlyComponentManager來分別獲得velocity和maxVelocity,所以依賴了PositionComponentManager、VelocityComponentManager、FlyComponentManager
因?yàn)镽enderOneByOneSystem和RenderInstancesSystem需要調(diào)用GameObjectManager來獲得所有的gameObject,并調(diào)用各種組件的Manager來獲得組件數(shù)據(jù),所以依賴了整個(gè)Manager層
Manager層:
因?yàn)镚ameObjectManager需要操作GameObject,所以依賴了GameObject
因?yàn)楦鞣N組件的Manager需要操作所有的該種組件,所以依賴了對(duì)應(yīng)的組件
結(jié)合UML圖,描述如何具體地解決問題
-
現(xiàn)在各種組件的數(shù)據(jù)都集中保存在各自Manager的state的buffer(ArrayBuffer)中,遍歷同一種組件的所有組件數(shù)據(jù)即是遍歷一個(gè)ArrayBuffer。因?yàn)锳rrayBuffer的數(shù)據(jù)是連續(xù)地保存在內(nèi)存中的,所以緩存命中不會(huì)丟失,從而提高了性能
-
現(xiàn)在將涉及多種組件的行為放在對(duì)應(yīng)的System中。因?yàn)镾ystem很輕,沒有數(shù)據(jù),只有邏輯,所以增加和維護(hù)System的成本較低;另外,因?yàn)镾ystem位于最上層,所以修改System也不會(huì)影響Manager層和Component+GameObject層
給出代碼
首先,我們看下Client的代碼;
然后,我們看下Client代碼中第一步的代碼:
- 創(chuàng)建WorldState的代碼
然后,因?yàn)閯?chuàng)建WorldState時(shí)會(huì)創(chuàng)建Data Oriented組件的Manager的state,其中的關(guān)健是創(chuàng)建各自的ArrayBuffer,所以我們看下創(chuàng)建它的代碼;
然后,我們看下Client代碼中第二步的代碼:
- 創(chuàng)建場(chǎng)景的代碼
然后,因?yàn)閯?chuàng)建場(chǎng)景時(shí)操作了普通英雄和超級(jí)英雄,所以我們看下它們的代碼,它們包括:
- 移動(dòng)的相關(guān)代碼
- 飛行的相關(guān)代碼
然后,我們依次看下Client代碼中剩余的兩個(gè)步驟的代碼,它們包括:
- 初始化和主循環(huán)的代碼
然后,我們看下主循環(huán)的一幀中每個(gè)步驟的代碼,它們包括:
- 主循環(huán)中更新的代碼
- 主循環(huán)中渲染的代碼
最后,我們運(yùn)行Client的代碼
Client的代碼
Client
let worldState = World.createState({ positionComponentCount: 10, velocityComponentCount: 10, flyComponentCount: 10 })
跟之前一樣...
Client的代碼跟之前的Client的代碼基本一樣,除了createState函數(shù)的參數(shù)和_createScene函數(shù)中創(chuàng)建場(chǎng)景的方式不一樣,這個(gè)等會(huì)再討論
創(chuàng)建WorldState的代碼
World
export let createState = CreateStateSystem.createState
CreateStateSystem
export let createState = ({ positionComponentCount, velocityComponentCount, flyComponentCount }): worldState => {
return {
gameObjectManagerState: GameObjectManager.createState(),
positionComponentManagerState: PositionComponentManager.createState(positionComponentCount),
velocityComponentManagerState: VelocityComponentManager.createState(velocityComponentCount),
flyComponentManagerState: FlyComponentManager.createState(flyComponentCount),
instanceComponentManagerState: InstanceComponentManager.createState()
}
}
CreateStateSystem的createState函數(shù)創(chuàng)建了WorldState,它保存了各個(gè)Manager的state
因?yàn)镈ata Oriented組件的Manager的state在創(chuàng)建時(shí)要?jiǎng)?chuàng)建包括該種組件的所有組件數(shù)據(jù)的ArrayBuffer,需要知道該種組件的最大個(gè)數(shù),所以這里的createState函數(shù)接收了三種Data Oriented組件的最大個(gè)數(shù)
創(chuàng)建ArrayBuffer的代碼
我們以PositionComponentManager為例,來看下它的createState函數(shù)的相關(guān)代碼:
position_component/ManagerStateType
export type state = {
maxIndex: number,
buffer: ArrayBuffer,
positions: Float32Array,
...
}
這是PositionComponentManager的state的類型定義,它的字段解釋如下:
- buffer字段保存了一個(gè)ArrayBuffer,它用來保存所有的positionComponent的數(shù)據(jù)。目前每個(gè)positionComponent的數(shù)據(jù)只有position,它的類型是三個(gè)float
- positions字段保存了ArrayBuffer的一個(gè)視圖,通過它可以讀寫所有的positionComponent的position
- maxIndex字段是ArrayBuffer上最大的索引值,用于在創(chuàng)建一個(gè)positionComponent時(shí)生成它的index值
position_component/Manager
let _setAllTypeArrDataToDefault = ([positions]: Array<Float32Array>, count, [defaultPosition]) => {
range(0, count - 1).forEach(index => {
OperateTypeArrayUtils.setPosition(index, defaultPosition, positions)
})
return [positions]
}
let _initBufferData = (count, defaultDataTuple): [ArrayBuffer, Array<Float32Array>] => {
let buffer = BufferUtils.createBuffer(count)
let typeArrData = _setAllTypeArrDataToDefault(CreateTypeArrayUtils.createTypeArrays(buffer, count), count, defaultDataTuple)
return [buffer, typeArrData]
}
export let createState = (positionComponentCount: number): state => {
let defaultPosition = [0, 0, 0]
let [buffer, [positions]] = _initBufferData(positionComponentCount, [defaultPosition])
return {
maxIndex: 0,
buffer,
positions,
...
}
}
這是PositionComponentManager的createState函數(shù)的代碼,其中調(diào)用的_initBufferData函數(shù)創(chuàng)建了buffer和positions,它的步驟如下:
1.調(diào)用BufferUtils的createBuffer函數(shù)來創(chuàng)建包括最大組件個(gè)數(shù)的數(shù)據(jù)的ArrayBuffer
2.調(diào)用CreateTypeArrayUtils的createTypeArrays函數(shù)來創(chuàng)建所有的TypeArray,它們是操作ArrayBuffer的視圖。這里具體是只創(chuàng)建了一個(gè)視圖:positions
3.調(diào)用_setAllTypeArrDataToDefault函數(shù)來將positions的所有的值寫為默認(rèn)值:[0,0,0]
下面是BufferUtils的createBuffer函數(shù)和CreateTypeArrayUtils的createTypeArrays函數(shù)的相關(guān)代碼:
position_component/BufferUtils
let _getPositionSize = () => 3
export let getPositionOffset = (count) => 0
export let getPositionLength = (count) => count * _getPositionSize()
export let getPositionIndex = index => index * _getPositionSize()
let _getTotalByteLength = (count) => {
return count * Float32Array.BYTES_PER_ELEMENT * _getPositionSize()
}
export let createBuffer = (count) => {
return new ArrayBuffer(_getTotalByteLength(count))
}
position_component/CreateTypeArrayUtils
export let createTypeArrays = (buffer, count) => {
return [
new Float32Array(buffer, BufferUtils.getPositionOffset(count), BufferUtils.getPositionLength(count))
]
}
另外兩種Data Oriented組件的Manager(VelocityComponentManager、FlyComponentManager)的createState函數(shù)的邏輯跟PositionComponentManager的createState函數(shù)的邏輯一樣,故省略相關(guān)代碼
創(chuàng)建場(chǎng)景的代碼
Client
let _createScene = (worldState: worldState): worldState => {
創(chuàng)建normalHero1:
創(chuàng)建gameObject
創(chuàng)建positionComponent
創(chuàng)建velocityComponent
創(chuàng)建instanceComponent
掛載positionComponent、velocityComponent、instanceComponent到gameObject
創(chuàng)建normalHero2
normalHero1移動(dòng):
調(diào)用MoveSystem的move函數(shù),傳入normalHero1的positionComponent、velocityComponent
創(chuàng)建superHero1
創(chuàng)建gameObject
創(chuàng)建positionComponent
創(chuàng)建velocityComponent
創(chuàng)建flyComponent
掛載positionComponent、velocityComponent、flyComponent到gameObject
創(chuàng)建superHero2
superHero1移動(dòng):
調(diào)用MoveSystem的move函數(shù),傳入superHero1的positionComponent、velocityComponent
superHero1飛行:
調(diào)用FlySystem的fly函數(shù),傳入superHero1的positionComponent、velocityComponent、flyComponent
return worldState
}
_createScene函數(shù)創(chuàng)建了場(chǎng)景,場(chǎng)景的內(nèi)容跟之前一樣,都包括了2個(gè)普通英雄和2個(gè)超級(jí)英雄,只是現(xiàn)在創(chuàng)建一個(gè)英雄的方式又改變了,具體變?yōu)椋含F(xiàn)在不需要加入GameObject到World中
另外,現(xiàn)在改為通過調(diào)用MoveSystem和FlySystem的函數(shù)來操作對(duì)應(yīng)的組件,從而實(shí)現(xiàn)英雄的“移動(dòng)”、“飛行”
gameObject/Manager
export let createState = (): state => {
return {
maxUID: 0
}
}
//創(chuàng)建一個(gè)gameObject
//一個(gè)gameObject就是一個(gè)uid
export let createGameObject = (state: state): [state, gameObject] => {
let uid = state.maxUID
//生成一個(gè)uid
//uid的意思是unique id,即全局唯一的id
let newUID = uid + 1
state = {
...state,
maxUID: newUID
}
return [state, uid]
}
GameObjectManager的createGameObject函數(shù)創(chuàng)建了一個(gè)gameObject,它就是一個(gè)全局唯一的id
position_component/Manager
//創(chuàng)建一個(gè)positionComponent
//一個(gè)positionComponent就是一個(gè)index
export let createComponent = (state: state): [state, component] => {
let index = state.maxIndex
//生成一個(gè)index
let newIndex = index + 1
state = {
...state,
maxIndex: newIndex
}
return [state, index]
}
PositionComponentManager的createComponent函數(shù)創(chuàng)建了一個(gè)positionComponent,它就是一個(gè)PositionComponentManager的state的buffer上的索引
VelocityComponentManager、FlyComponentManager的相關(guān)代碼跟PositionComponentManager類似,故省略相關(guān)代碼
instance_component/Manager
//創(chuàng)建一個(gè)instanceComponent
//一個(gè)instanceComponent就是一個(gè)uid
export let createComponent = (state: state): [state, component] => {
let uid = state.maxUID
//生成一個(gè)id
let newUID = uid + 1
state = {
...state,
maxUID: newUID
}
return [state, uid]
}
InstanceComponentManager的createComponent函數(shù)創(chuàng)建了一個(gè)instanceComponent,因?yàn)镮nstanceComponent組件屬于“其它組件”,所以它跟GameObject一樣都是一個(gè)全局唯一的id而不是一個(gè)index
移動(dòng)的相關(guān)代碼
MoveSystem
//一個(gè)gameObject的移動(dòng)
export let move = (worldState: worldState, positionComponent, velocityComponent): worldState => {
let [x, y, z] = PositionComponentManager.getPosition(worldState.positionComponentManagerState, positionComponent)
let velocity = VelocityComponentManager.getVelocity(worldState.velocityComponentManagerState, velocityComponent)
//根據(jù)velocity,更新positionComponent的position
let positionComponentManagerState = PositionComponentManager.setPosition(worldState.positionComponentManagerState, positionComponent, [x + velocity, y + velocity, z + velocity])
return {
...worldState,
positionComponentManagerState: positionComponentManagerState
}
}
MoveSystem的move函數(shù)實(shí)現(xiàn)移動(dòng)的行為邏輯。這里涉及到讀寫Data Oriented組件的ArrayBuffer上的數(shù)據(jù)。我們來看下讀寫PositionComponentManager的positions的相關(guān)代碼:
position_component/Manager
export let getPosition = (state: state, component: component) => {
return OperateTypeArrayUtils.getPosition(component, state.positions)
}
export let setPosition = (state: state, component: component, position) => {
OperateTypeArrayUtils.setPosition(component, position, state.positions)
return state
}
position_component/OperateTypeArrayUtils
export let getPosition = (index, typeArr) => {
return TypeArrayUtils.getFloat3Tuple(BufferUtils.getPositionIndex(index), typeArr)
}
export let setPosition = (index, data, typeArr) => {
TypeArrayUtils.setFloat3(BufferUtils.getPositionIndex(index), data, typeArr)
}
TypeArrayUtils
export let getFloat3Tuple = (index, typeArray) => {
return [
typeArray[index],
typeArray[index + 1],
typeArray[index + 2]
]
}
export let setFloat3 = (index, param, typeArray) => {
typeArray[index] = param[0]
typeArray[index + 1] = param[1]
typeArray[index + 2] = param[2]
}
position_component/BufferUtils
let _getPositionSize = () => 3
...
export let getPositionIndex = index => index * _getPositionSize()
通過代碼可知,實(shí)現(xiàn)“讀寫PositionComponentManager的ArrayBuffer上的數(shù)據(jù)”的思路是:
因?yàn)橐粋€(gè)positionComponent的值是ArrayBuffer的索引,所以使用它來讀寫ArrayBuffer的視圖positions中的對(duì)應(yīng)數(shù)據(jù)
飛行的相關(guān)代碼
FlySystem
//一個(gè)gameObject的飛行
export let fly = (worldState: worldState, positionComponent, velocityComponent, flyComponent): worldState => {
let [x, y, z] = PositionComponentManager.getPosition(worldState.positionComponentManagerState, positionComponent)
let velocity = VelocityComponentManager.getVelocity(worldState.velocityComponentManagerState, velocityComponent)
let maxVelocity = FlyComponentManager.getMaxVelocity(worldState.flyComponentManagerState, flyComponent)
//根據(jù)maxVelociy、velocity,更新positionComponent的position
velocity = velocity < maxVelocity ? (velocity * 2.0) : maxVelocity
let positionComponentManagerState = PositionComponentManager.setPosition(worldState.positionComponentManagerState, positionComponent, [x + velocity, y + velocity, z + velocity])
return {
...worldState,
positionComponentManagerState: positionComponentManagerState
}
}
FlySystem的fly函數(shù)實(shí)現(xiàn)了飛行的行為邏輯
初始化和主循環(huán)的代碼
初始化和主循環(huán)的邏輯跟之前一樣,故省略代碼
主循環(huán)中更新的代碼
World
export let update = UpdateSystem.update
UpdateSystem
export let update = (worldState: worldState): worldState => {
let positionComponentManagerState = PositionComponentManager.batchUpdate(worldState.positionComponentManagerState)
return {
...worldState,
positionComponentManagerState: positionComponentManagerState
}
}
UpdateSystem的update函數(shù)實(shí)現(xiàn)了更新,它調(diào)用了PositionComponentManager的batchUpdate函數(shù)來批量更新所有的positionComponent組件。我們看下PositionComponentManager的相關(guān)代碼:
position_component/Manager
export let getAllComponents = (state: state): Array<component> => {
從state中獲得所有的positionComponents
}
export let batchUpdate = (state: state) => {
return getAllComponents(state).reduce((state, component) => {
更新position
}, state)
}
batchUpdate函數(shù)遍歷所有的positionComponent,更新它們的position。更新的邏輯跟之前一樣
主循環(huán)中渲染的代碼
World
export let renderOneByOne = RenderOneByOneSystem.render
RenderOneByOneSystem
export let render = (worldState: worldState): void => {
let superHeroGameObjects = GameObjectManager.getAllGameObjects(worldState.gameObjectManagerState).filter(gameObject => {
//判斷gameObject是不是沒有掛載InstanceComponent
return !InstanceComponentManager.hasComponent(worldState.instanceComponentManagerState, gameObject)
})
superHeroGameObjects.forEach(gameObjectState => {
console.log("OneByOne渲染 SuperHero...")
})
}
gameObject/Manager
export let getAllGameObjects = (state: state): Array<gameObject> => {
let { maxUID } = state
//返回[0, 1, ..., maxUID-1]的數(shù)組
return range(0, maxUID - 1)
}
RenderOneByOneSystem的render函數(shù)實(shí)現(xiàn)了超級(jí)英雄的渲染,它首先得到了所有沒有掛載InstanceComponent組件的gameObject;最后一個(gè)一個(gè)地渲染
World
export let renderInstances = RenderInstancesSystem.render
RenderInstancesSystem
export let render = (worldState: worldState): void => {
let normalHeroGameObejcts = GameObjectManager.getAllGameObjects(worldState.gameObjectManagerState).filter(gameObject => {
//判斷gameObject是不是掛載了InstanceComponent
return InstanceComponentManager.hasComponent(worldState.instanceComponentManagerState, gameObject)
})
console.log("批量Instance渲染 NormalHeroes...")
}
RenderInstancesSystem的render函數(shù)實(shí)現(xiàn)了普通英雄的渲染,它首先得到了所有掛載了InstanceComponent組件的gameObject;最后一次性批量渲染
運(yùn)行Client的代碼
下面,我們運(yùn)行Client的代碼,打印的結(jié)果如下:
初始化...
更新PositionComponent: 0
更新PositionComponent: 1
更新PositionComponent: 2
更新PositionComponent: 3
OneByOne渲染 SuperHero...
OneByOne渲染 SuperHero...
批量Instance渲染 NormalHeroes...
{"gameObjectManagerState":{"maxUID":4},"positionComponentManagerState":{"maxIndex":4,"buffer":{},"positions":{"0":2,"1":2,"2":2,"3":0,"4":0,"5":0,"6":6,"7":6,"8":6,"9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,"16":0,"17":0,"18":0,"19":0,"20":0,"21":0,"22":0,"23":0,"24":0,"25":0,"26":0,"27":0,"28":0,"29":0},"gameObjectMap":{"0":0,"1":1,"2":2,"3":3},"gameObjectPositionMap":{"0":0,"1":1,"2":2,"3":3}},"velocityComponentManagerState":{"maxIndex":4,"buffer":{},"velocitys":{"0":1,"1":1,"2":1,"3":1,"4":1,"5":1,"6":1,"7":1,"8":1,"9":1},"gameObjectMap":{"0":0,"1":1,"2":2,"3":3},"gameObjectVelocityMap":{"0":0,"1":1,"2":2,"3":3}},"flyComponentManagerState":{"maxIndex":1,"buffer":{},"maxVelocitys":{"0":10,"1":10,"2":10,"3":10,"4":10,"5":10,"6":10,"7":10,"8":10,"9":10},"gameObjectMap":{"0":2,"1":3},"gameObjectFlyMap":{"2":0,"3":1}},"instanceComponentManagerState":{"maxUID":1,"gameObjectMap":{"0":0,"1":1},"gameObjectInstanceMap":{"0":0,"1":1}}}
通過打印的數(shù)據(jù),可以看到運(yùn)行的步驟與之前一樣
不同之處在于:
- 打印的WorldState不一樣
我們看下打印的WorldState:
- WorldState的gameObjectManagetState的maxUID為4,說明創(chuàng)建了4個(gè)gameObject;
- WorldState的positionComponentManagerState的maxIndex為4,說明創(chuàng)建了4個(gè)positionComponent;
- WorldState的positionComponentManagerState的positions有3個(gè)連續(xù)的值是2、2、2,說明有一個(gè)positionComponent組件進(jìn)行了移動(dòng)操作;有另外3個(gè)連續(xù)的值是6、6、6,說明有另外一個(gè)positionComponent組件進(jìn)行了移動(dòng)操作和飛行操作;
定義
一句話定義
組合代替繼承;連續(xù)地保存組件數(shù)據(jù);分離邏輯和數(shù)據(jù)
補(bǔ)充說明
“組合代替繼承”是指基于組件化思想,通過GameObject組合不同的組件代替GameObject層層繼承
“連續(xù)地保存組件數(shù)據(jù)”是指基于Data Oriented思想,將Data Oriented組件的組件數(shù)據(jù)集中起來,保存在內(nèi)存中連續(xù)的地址空間
“分離邏輯和數(shù)據(jù)”是指將GameObject和組件扁平化,將它們的數(shù)據(jù)放到Manager層,將它們的邏輯放到System層和Manager層。其中,只操作自己數(shù)據(jù)的邏輯(如getPosition、setPosition)被移到了Manager中,其它邏輯(通常為行為邏輯,需要操作多種組件)被移到了System中
通用UML
領(lǐng)域模型

總體來看,領(lǐng)域模型分為用戶、World、System層、Manager層、Component+GameObject層五個(gè)部分,它們的依賴關(guān)系是前者依賴后者。其中,System層負(fù)責(zé)實(shí)現(xiàn)行為的邏輯;Manager層負(fù)責(zé)管理場(chǎng)景數(shù)據(jù),即管理GameObject和組件的數(shù)據(jù);Component+GameObject層為組件和GameObject,它們現(xiàn)在只是有一個(gè)number類型的數(shù)據(jù)的值對(duì)象
我們看下用戶這個(gè)部分:
- Client
該角色是用戶
我們看下World這個(gè)部分:
- World
該角色是門戶,提供了API,實(shí)現(xiàn)了初始化和主循環(huán)的邏輯
我們看下System這一層:
一個(gè)System實(shí)現(xiàn)一個(gè)行為的邏輯
-
CreateStateSystem
該角色負(fù)責(zé)創(chuàng)建WorldState -
OtherSystem
該角色是除了CreateStateSystem以外的所有System,它們各自有一個(gè)函數(shù)action,用于實(shí)現(xiàn)某個(gè)行為
我們看下Manager這一層:
每個(gè)Manager都有一個(gè)state數(shù)據(jù)
-
GameObjectManager
該角色負(fù)責(zé)管理所有的gameObject -
DataOrientedComponentManager
該角色是一種Data Oriented組件的Manager,負(fù)責(zé)維護(hù)和管理該種組件的所有組件數(shù)據(jù),將其集中地保存在各自的state的buffer中
DataOrientedComponentManager的state數(shù)據(jù)包括一個(gè)buffer字段和多個(gè)“typeArray of buffer”字段,其中前者是一個(gè)ArrayBuffer,保存了該種組件的所有組件數(shù)據(jù);后者是buffer的多個(gè)視圖,用于讀寫buffer中對(duì)應(yīng)的數(shù)據(jù) -
OtherComponentManager
該角色是一種其它組件的Manager,負(fù)責(zé)維護(hù)和管理該種組件的所有組件數(shù)據(jù)
OtherComponentManager的state數(shù)據(jù)包括多個(gè)“value map”字段,它們是多個(gè)Hash Map,每個(gè)Hash Map保存一類組件數(shù)據(jù)
我們看下Component+GameObject這一層:
-
DataOrientedComponent
該角色是一種Data Oriented組件中的一個(gè)組件,它是一個(gè)ArrayBuffer上的索引 -
OtherComponent
該角色是一種其它組件中的一個(gè)組件,它是一個(gè)全局唯一的id -
GameObject
該角色是一個(gè)gameObject,它是一個(gè)全局唯一的id
角色之間的關(guān)系
-
只有一個(gè)CreateStateSystem
-
可以有多個(gè)OtherSystem,每個(gè)OtherSystem實(shí)現(xiàn)一個(gè)行為
-
只有一個(gè)GameObjectManager
-
可以有多個(gè)DataOrientedComponentManager,每個(gè)對(duì)應(yīng)一種Data Oriented組件
-
可以有多個(gè)DataOrientedComponent,每個(gè)對(duì)應(yīng)一種Data Oriented組件
-
可以有多個(gè)OtherComponentManager,每個(gè)對(duì)應(yīng)一種其它組件
- 可以有多個(gè)OtherComponent,每個(gè)對(duì)應(yīng)一種其它組件
System層:
- 因?yàn)镃reateSystem需要調(diào)用各個(gè)Manager的createState函數(shù)來創(chuàng)建它們的state,所以依賴了整個(gè)Manager層
- 因?yàn)镺therSystem可能需要調(diào)用1個(gè)GameObjectManager來處理gameObject、調(diào)用多個(gè)DataOrientedComponentManager和OtherComponentManager來處理各自種類的組件,所以它與GameObjectManager是一對(duì)一的依賴關(guān)系,與DataOrientedComponentManager和OtherComponentManager都是一對(duì)多的依賴關(guān)系
Manager層:
- 因?yàn)镚ameObjectManager需要操作多個(gè)GameObject,所以它與GameObject是一對(duì)多的依賴關(guān)系
-
因?yàn)镈ataOrientedComponentManager和DataOrientedComponent對(duì)應(yīng)同一種Data Oriented組件,且前者管理所有的后者,所以前者和后者是一對(duì)多的依賴關(guān)系
-
因?yàn)镺therComponentManager和OtherComponent對(duì)應(yīng)同一種其它組件,且前者管理所有的后者,所以前者和后者是一對(duì)多的依賴關(guān)系
Component+GameObject層:
- 因?yàn)橐粋€(gè)GameObject可以掛載各種組件,其中每種組件只能掛載一個(gè),所以GameObject與DataOrientedComponent、OtherComponent都是一對(duì)一的組合關(guān)系
角色的抽象代碼
下面我們來看看各個(gè)角色的抽象代碼:
我們按照依賴關(guān)系,從上往下依次看下領(lǐng)域模型中用戶、World、System層、Manager層、Component+GameObject層這五個(gè)部分的抽象代碼:
首先,我們看下屬于用戶的抽象代碼
然后,我們看下World的抽象代碼
然后,我們看下System層的抽象代碼,它們包括:
- CreateStateSystem的抽象代碼
- OtherSystem的抽象代碼
然后,我們看下Manager層的抽象代碼,它們包括:
- GameObjectManager的抽象代碼
- DataOrientedComponentManager的抽象代碼
- OtherComponentManager的抽象代碼
最后,我們看下Component+GameObject層的抽象代碼,它們包括:
- GameObject的抽象代碼
- DataOrientedComponent的抽象代碼
- OtherComponent的抽象代碼
用戶的抽象代碼
Client
let _createScene = (worldState: worldState): worldState => {
創(chuàng)建gameObject1
創(chuàng)建組件
掛載組件
觸發(fā)gameObject1的行為
創(chuàng)建更多的gameObjects...
return worldState
}
let worldState = World.createState({ dataOrientedComponent1Count: xx })
worldState = _createScene(worldState)
worldState = World.init(worldState)
World.loop(worldState)
World的抽象代碼
World
export let createState = CreateStateSystem.createState
export let action1 = OtherSystem1.action
export let init = (worldState: worldState): worldState => {
初始化...
return worldState
}
//假實(shí)現(xiàn)
let requestAnimationFrame = (func) => {
}
export let loop = (worldState: worldState) => {
調(diào)用OtherSystem來更新
調(diào)用OtherSystem來渲染
requestAnimationFrame(
(time) => {
loop(worldState)
}
)
}
CreateStateSystem的抽象代碼
CreateStateSystem
export let createState = ({ dataOrientedComponent1Count }): worldState => {
return {
gameObjectManagerState: GameObjectManager.createState(),
dataOrientedComponent1ManagerState: DataOrientedComponent1Manager.createState(dataOrientedComponent1Count),
otherComponent1ManagerState: OtherComponent1Manager.createState(),
創(chuàng)建更多的DataOrientedManagerState和OtherComponentManagerState...
}
}
OtherSystem的抽象代碼
OtherSystem1
export let action = (worldState: worldState, gameObject?: gameObject, dataOrientedComponentX?: dataOrientedComponentX, otherComponentX?: otherComponentX) => {
行為的邏輯...
return worldState
}
有多個(gè)OtherSystem,這里只給出一個(gè)OtherSystem的抽象代碼
GameObjectManager的抽象代碼
gameObject/ManagerStateType
export type state = {
maxUID: number
}
gameObject/Manager
export let createState = (): state => {
return {
maxUID: 0
}
}
export let createGameObject = (state: state): [state, gameObject] => {
let uid = state.maxUID
let newUID = uid + 1
state = {
...state,
maxUID: newUID
}
return [state, uid]
}
export let getAllGameObjects = (state: state): Array<gameObject> => {
let { maxUID } = state
return range(0, maxUID - 1)
}
DataOrientedComponentManager的抽象代碼
dataoriented_component1/ManagerStateType
export type TypeArrayType = Float32Array | Uint8Array | Uint16Array | Uint32Array
export type state = {
maxIndex: number,
//buffer保存了該種組件所有的value1、value2、...、valueX數(shù)據(jù)
buffer: ArrayBuffer,
//該種組件所有的value1數(shù)據(jù)的視圖
value1s: TypeArrayType,
//該種組件所有的value2數(shù)據(jù)的視圖
value2s: TypeArrayType,
更多valueXs...,
...
}
dataoriented_component1/Manager
let _setAllTypeArrDataToDefault = ([value1s, value2s]: Array<Float32Array>, count, [defaultValue1, defaultValue2]) => {
range(0, count - 1).forEach(index => {
OperateTypeArrayUtils.setValue1(index, defaultValue1, value1s)
OperateTypeArrayUtils.setValue2(index, defaultValue2, value2s)
})
return [value1s, value2s]
}
let _initBufferData = (count, defaultDataTuple): [ArrayBuffer, Array<TypeArrayType>] => {
let buffer = BufferUtils.createBuffer(count)
let typeArrData = _setAllTypeArrDataToDefault(CreateTypeArrayUtils.createTypeArrays(buffer, count), count, defaultDataTuple)
return [buffer, typeArrData]
}
export let createState = (dataorientedComponentCount: number): state => {
let defaultValue1 = default value1
let defaultValue2 = default value2
let [buffer, [value1s, value2s]] = _initBufferData(dataorientedComponentCount, [defaultValue1, defaultValue2])
return {
maxIndex: 0,
buffer,
value1s,
value2s,
...
}
}
export let createComponent = (state: state): [state, component] => {
let index = state.maxIndex
let newIndex = index + 1
state = {
...state,
maxIndex: newIndex
}
return [state, index]
}
...
export let getAllComponents = (state: state): Array<component> => {
從state中獲得所有的dataorientedComponent1s
}
export let getValue1 = (state: state, component: component) => {
return OperateTypeArrayUtils.getValue1(component, state.value1s)
}
export let setValue1 = (state: state, component: component, position) => {
OperateTypeArrayUtils.setValue1(component, position, state.value1s)
return state
}
get/set value2...
export let batchOperate = (state: state) => {
let allComponents = getAllComponents(state)
console.log("批量操作")
return state
}
dataoriented_component1/BufferUtils
// 這里只給出了兩個(gè)value的情況
// 更多的value也以此類推...
let _getValue1Size = () => value1 size
let _getValue2Size = () => value2 size
export let getValue1Offset = () => 0
export let getValue2Offset = (count) => getValue1Offset() + getValue1Length(count) * TypeArray2.BYTES_PER_ELEMENT
export let getValue1Length = (count) => count * _getValue1Size()
export let getValue2Length = (count) => count * _getValue2Size()
export let getValue1Index = index => index * _getValue1Size()
export let getValue2Index = index => index * _getValue2Size()
let _getTotalByteLength = (count) => {
return count * (TypeArray1.BYTES_PER_ELEMENT * (_getValue1Size() + TypeArray2.BYTES_PER_ELEMENT * (_getValue2Size())))
}
export let createBuffer = (count) => {
return new ArrayBuffer(_getTotalByteLength(count))
}
dataoriented_component1/CreateTypeArrayUtils
export let createTypeArrays = (buffer, count) => {
return [
new Float32Array(buffer, BufferUtils.getValue1Offset(), BufferUtils.getValue1Length(count)),
new Float32Array(buffer, BufferUtils.getValue2Offset(count), BufferUtils.getValue2Length(count)),
]
}
有多個(gè)DataOrientedComponentManager,這里只給出一個(gè)DataOrientedComponentManager的抽象代碼
OtherComponentManager的抽象代碼
other_component1/ManagerStateType
export type state = {
maxUID: number,
//value1Map用來保存該種組件所有的value1數(shù)據(jù)
value1Map: Map<component, value1 type>,
更多valueXMap...,
...
}
other_component1/Manager
export let createState = (): state => {
return {
maxUID: 0,
value1Map: Map(),
...
}
}
export let createComponent = (state: state): [state, component] => {
let uid = state.maxUID
let newUID = uid + 1
state = {
...state,
maxUID: newUID
}
return [state, uid]
}
...
export let getAllComponents = (state: state): Array<component> => {
從state中獲得所有的otherComponent1s
}
export let getValue1 = (state: state, component: component) => {
return getExnFromStrictUndefined(state.value1Map.get(component))
}
export let setValue1 = (state: state, component: component, value1) => {
return {
...state,
value1Map: state.value1Map.set(component, value1)
}
}
export let batchOperate = (state: state) => {
let allComponents = getAllComponents(state)
console.log("批量操作")
return state
}
有多個(gè)OtherComponentManager,這里只給出一個(gè)OtherComponentManager的抽象代碼
GameObject的抽象代碼
GameObjectType
type id = number
export type gameObject = id
DataOrientedComponent的抽象代碼
DataOrientedComponent1Type
type index = number
export type component = index
有多個(gè)DataOrientedComponent,這里只給出一個(gè)DataOrientedComponent的抽象代碼
OtherComponent的抽象代碼
OtherComponent1Type
type id = number
export type component = id
有多個(gè)OtherComponent,這里只給出一個(gè)OtherComponent的抽象代碼
遵循的設(shè)計(jì)原則在UML中的體現(xiàn)
ECS模式主要遵循下面的設(shè)計(jì)原則:
- 單一職責(zé)原則
每個(gè)System只實(shí)現(xiàn)一個(gè)行為;每個(gè)組件的Manager只管理一種組件 - 合成復(fù)用原則
GameObject組合了多個(gè)組件 - 接口隔離原則
GameObject和組件經(jīng)過了扁平化處理,移除了數(shù)據(jù)和邏輯,改為只是有一個(gè)number類型數(shù)據(jù)的值對(duì)象 - 最少知識(shí)原則
World、System、Manager、Component+GameObject這幾個(gè)層只能上層依賴下層,不能跨層依賴 - 開閉原則
要增加一種行為,只需要增加一個(gè)System,不會(huì)影響Manager
應(yīng)用
優(yōu)點(diǎn)
-
組件的數(shù)據(jù)集中連續(xù)地保存在ArrayBuffer中,增加了緩存命中,提高了讀寫的性能
-
創(chuàng)建和刪除組件的性能也很好,因?yàn)樵谶@個(gè)過程中不會(huì)分配或者銷毀內(nèi)存,所以沒有垃圾回收的開銷
這是因?yàn)樵趧?chuàng)建ArrayBuffer時(shí)就預(yù)先按照最大組件個(gè)數(shù)分配了一塊連續(xù)的內(nèi)存,所以在創(chuàng)建組件時(shí),只是返回一個(gè)當(dāng)前最大索引(maxIndex)的值而已;在刪除組件時(shí),只是將ArrayBuffer中該組件對(duì)應(yīng)的數(shù)據(jù)還原為默認(rèn)值而已 -
職責(zé)劃分明確,行為的邏輯應(yīng)該放在哪里很清楚
對(duì)于只涉及到操作一種組件的行為邏輯,則將其放在該組件對(duì)應(yīng)的Manager(如將batchUpdate position的邏輯放到PositionComponentManager的batchUpdate函數(shù)中);涉及到多種組件的行為邏輯則放在對(duì)應(yīng)的System中(如將飛行行為放到FlySystem中); -
增加行為很容易
因?yàn)橐粋€(gè)行為對(duì)應(yīng)一個(gè)System,所以要增加一個(gè)行為,則只需增加一個(gè)對(duì)應(yīng)的System即可,這不會(huì)影響到Manager。另外,因?yàn)镾ystem只有邏輯沒有數(shù)據(jù),所以增加和維護(hù)System很容易
缺點(diǎn)
- 需要轉(zhuǎn)換為函數(shù)式編程的思維
習(xí)慣面向?qū)ο缶幊痰耐瑢W(xué)傾向于設(shè)計(jì)一個(gè)包括數(shù)據(jù)和邏輯的組件類,而ECS模式則將其扁平化為一個(gè)值對(duì)象,這符合函數(shù)式編程中一切都是數(shù)據(jù)的思維模式。
另外,ECS中的System其實(shí)就只是一個(gè)函數(shù)而已,本身沒有數(shù)據(jù),這也符合函數(shù)式編程中函數(shù)是第一公民的思維模式。
終上所述,如果使用函數(shù)式編程范式的同學(xué)能夠更容易地使用ECS模式
使用場(chǎng)景
場(chǎng)景描述
游戲的場(chǎng)景中有很多種類的人物,人物的行為很多或者很復(fù)雜
具體案例
-
有很多種類的游戲人物
通過掛載不同的組件到GameObject,來實(shí)現(xiàn)不同種類的游戲人物,代替繼承 -
游戲人物有很多的行為,而且還經(jīng)常會(huì)增加新的行為
因?yàn)槊總€(gè)行為對(duì)應(yīng)一個(gè)System,所以增加一個(gè)新的行為就是增加一個(gè)System。不管行為如何變化,只影響System層,不會(huì)影響作為下層的Manager層和GameObject、Component層 -
對(duì)于引擎而言,ECS模式主要用在場(chǎng)景管理這塊
注意事項(xiàng)
- 因?yàn)榻M件的ArrayBuffer一旦在創(chuàng)建后,它的大小就不會(huì)改動(dòng),所以最好在創(chuàng)建時(shí)指定足夠大的最大組件個(gè)數(shù)
結(jié)合其它模式
結(jié)合多線程模式
如果引擎開了多個(gè)線程,那么可以將組件的ArrayBuffer改為SharedArrayBuffer。這樣的話就可以將其直接共享到各個(gè)線程中而不需要拷貝,從而提高了性能
結(jié)合管道模式
如果引擎使用了管道模式,那么可以去掉System,而使用管道的Job來代替。其中一個(gè)Job就是一個(gè)System
另外,可以去掉WorldState,而使用PipelineManagerState來代替
最佳實(shí)踐
哪些場(chǎng)景不需要使用模式
如果游戲的人物種類很少,行為簡(jiǎn)單,那么就可以使用最開始給出的人物模塊的方案,即使用一個(gè)人物模塊對(duì)應(yīng)一種人物,并通過繼承實(shí)現(xiàn)多種人物,這樣最容易實(shí)現(xiàn)
更多資料推薦
ECS的概念最先是由“守望先鋒”游戲的開發(fā)者提出的,詳細(xì)資料可以在網(wǎng)上搜索“《守望先鋒》架構(gòu)設(shè)計(jì)和網(wǎng)絡(luò)同步”
ECS模式是在“組件化”、“Data Oriented”基礎(chǔ)上發(fā)展而來,可以在網(wǎng)上搜索更多關(guān)于“組件化”、“Data Oriented”、“ECS”的資料
浙公網(wǎng)安備 33010602011771號(hào)