大家好,本文提出了ECS模式。ECS模式是遊戲引擎中常用的模式,通常用來組織遊戲場景。本文出自我寫的開源書《3D編程模式》,該書的更多內容請詳見:
Github
在線閱讀
普通英雄和超級英雄
需求
我們需要開發一個遊戲,遊戲中有兩種人物:普通英雄和超級英雄,他們具有下面的行爲:
- 普通英雄只能移動
- 超級英雄不僅能夠移動,還能飛行
我們使用下面的方法來渲染:
- 使用Instance技術來一次性批量渲染所有的普通英雄
- 一個一個地渲染每個超級英雄
實現思路
應該有一個遊戲世界,它由多個普通英雄和多個超級英雄組成
一個模塊對應一個普通英雄,一個模塊對應一個超級英雄。模塊應該維護該英雄的數據和實現該英雄的行爲
給出UML
領域模型
總體來看,領域模型分爲用戶、遊戲世界、英雄這三個部分
我們看下用戶、遊戲世界這兩個部分:
Client是用戶
World是遊戲世界,由多個普通英雄和多個超級英雄組成。World負責管理所有的英雄,並且實現了初始化和主循環的邏輯
我們看下英雄這個部分:
一個NormalHero對應一個普通英雄,維護了該英雄的數據,實現了移動的行爲
一個SuperHero對應一個超級英雄, 維護了該英雄的數據,實現了移動、飛行的行爲
給出代碼
首先,我們看下Client的代碼;
然後,我們依次看下Client代碼中前兩個步驟的代碼,它們包括:
- 創建WorldState的代碼
- 創建場景的代碼
然後,因爲創建場景時操作了普通英雄和超級英雄,所以我們看下它們的代碼,它們包括:
- 普通英雄移動的代碼
- 超級英雄移動和飛行的代碼
然後,我們依次看下Client代碼中剩餘的兩個步驟的代碼,它們包括:
- 初始化的代碼
- 主循環的代碼
然後,我們看下主循環的一幀中每個步驟的代碼,它們包括:
- 主循環中更新的代碼
- 主循環中渲染的代碼
最後,我們運行Client的代碼
Client的代碼
Client
let worldState = World.createState()
worldState = _createScene(worldState)
worldState = WorldUtils.init(worldState)
WorldUtils.loop(worldState, [World.update, World.renderOneByOne, World.renderInstances])
Client首先創建了WorldState,用來保存遊戲世界中所有的數據;然後創建了場景;然後進行了初始化;最後開始了主循環
創建WorldState的代碼
World
export let createState = (): worldState => {
return {
normalHeroes: Map(),
superHeroes: Map()
}
}
createState函數創建了WorldState,它包括兩個分別用來保存所有的普通英雄和所有的超級英雄的容器
創建場景的代碼
Client
let _createScene = (worldState: worldState): worldState => {
創建和加入normalHero1到worldState.normalHeroes
創建和加入normalHero2到worldState.normalHeroes
normalHero1移動
創建和加入superHero1到worldState.superHeroes
創建和加入superHero2到worldState.superHeroes
superHero1移動
superHero1飛行
return worldState
}
_createScene函數創建了場景,創建和加入了兩個普通英雄和兩個超級英雄到遊戲世界中。其中第一個普通英雄進行了移動,第一個超級英雄進行了移動和飛行
NormalHero
//創建一個普通英雄
export let create = (): [normalHeroState, normalHero] => {
創建它的state數據:
position設置爲[0,0,0]
velocity設置爲1.0
其中:position爲位置,velocity爲速度
返回該英雄
}
NormalHero的create函數創建了一個普通英雄,初始化了它的數據
SuperHero
//創建一個超級英雄
export let create = (): [superHeroState, superHero] => {
創建它的state數據:
position設置爲[0,0,0]
velocity設置爲1.0
maxVelocity設置爲1.0
其中:position爲位置,velocity爲速度,maxVelocity爲最大速度
返回該英雄
}
SuperHero的create函數創建了一個超級英雄,初始化了它的數據
普通英雄移動的代碼
NormalHero
//一個普通英雄的移動
export let move = (worldState: worldState, normalHero: normalHero): worldState => {
從worldState中獲得該英雄的position和velocity
根據velocity,更新position
更新worldState中該英雄的數據
}
move函數實現了移動的行爲邏輯,更新了位置
超級英雄移動和飛行的代碼
SuperHero
//一個超級英雄的移動
export let move = (worldState: worldState, superHero: superHero): worldState => {
從worldState中獲得該英雄的position和velocity
根據velocity,更新position
更新worldState中該英雄的數據
}
//一個超級英雄的飛行
export let fly = (worldState: worldState, superHero: superHero): worldState => {
從worldState中獲得該英雄的position和velocity、maxVelocity
根據maxVelocity、velocity,更新position
更新worldState中該英雄的數據
}
SuperHero的move函數的邏輯跟NormalHero的move函數的邏輯是一樣的
fly函數實現了飛行的行爲邏輯。它跟move函數一樣,也是更新英雄的position。只是因爲兩者在計算時使用的速度的算法不一樣,所以更新position的幅度不同
初始化的代碼
WorldUtils
export let init = (worldState) => {
console.log("初始化...")
return worldState
}
init函數實現了初始化。這裏沒有任何邏輯,只是進行了打印
主循環的代碼
WorldUtils
export let loop = (worldState, [update, renderOneByOne, renderInstances]) => {
worldState = update(worldState)
renderOneByOne(worldState)
renderInstances(worldState)
...
requestAnimationFrame(
(time) => {
loop(worldState, [update, renderOneByOne, renderInstances])
}
)
}
loop函數實現了主循環。在主循環的一幀中,首先進行了更新;然後一個一個地渲染了所有的超級英雄;然後一次性批量渲染了所有的普通英雄;最後執行下一幀
主循環中更新的代碼
World
export let update = (worldState: worldState): worldState => {
遍歷worldState.normalHeroes:
更新每個normalHero
遍歷worldState.superHeroes:
更新每個superHero
}
update函數實現了更新,它會遍歷所有的normalHero和superHero,調用它們的update函數來更新自己
我們看下NormalHero的update函數的代碼:
//更新一個普通英雄
export let update = (normalHeroState: normalHeroState): normalHeroState => {
更新該英雄的position
}
它更新了自己的position
我們看下SuperHero的update函數的代碼:
//更新一個超級英雄
export let update = (superHeroState: superHeroState): superHeroState => {
更新該英雄的position
}
它的邏輯跟NormalHero的update是一樣的,這是因爲兩者都使用同樣的算法來更新自己的position
主循環中渲染的代碼
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函數實現了超級英雄的渲染,它遍歷每個超級英雄,一個一個地渲染
renderInstances函數實現了普通英雄的渲染,它一次性獲得所有的普通英雄,批量渲染
運行Client的代碼
下面,我們運行Client的代碼,打印的結果如下:
初始化...
更新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}}}
通過打印的數據,可以看到運行的步驟如下:
1.進行了初始化
2.更新了所有的人物,包括兩個普通英雄和兩個超級英雄
3.渲染了2個超級英雄
4.一次性批量渲染了所有的普通英雄
5.打印了WorldState
我們看下打印的WorldState:
- WorldState的normalHeroes中一共有兩個普通英雄的數據,其中有一個普通英雄數據的position爲[2,2,2]而不是初始的[0,0,0],說明該普通英雄進行了移動操作;
- WorldState的superHeroes中一共有兩個超級英雄的數據,其中有一個超級英雄數據的position爲[6,6,6],說明該超級英雄進行了移動和飛行操作
值得注意的是:
因爲WorldState的normalHeroes和superHeroes中的Key是隨機生成的id值,所以每次打印時Key都不一樣
提出問題
-
NormalHero和SuperHero中的update、move函數的邏輯是重複的
-
如果英雄增加更多的行爲,NormalHero和SuperHero模塊會越來越複雜,不容易維護
雖然這兩個問題都可以通過繼承來解決,即最上面是Hero基類,然後不同種類的Hero層層繼承,但是繼承的方式很死板,不夠靈活
基於組件化的思想改進
概述解決方案
-
基於組件化的思想,用組合代替繼承。具體修改如下:
- 將人物抽象爲GameObject;
- 將人物的行爲抽象爲組件,並把人物的相關數據也移到組件中;
- GameObject通過掛載不同的組件,來實現不同的行爲
這樣就通過GameObject組合不同的組件來代替人物層層繼承,從而更加靈活
給出UML
領域模型
總體來看,領域模型分爲用戶、遊戲世界、GameObject、組件這四個部分
我們看下用戶、遊戲世界這兩個部分:
Client是用戶
World是遊戲世界,由多個GameObject組成。World負責管理所有的GameObject,並且實現了初始化和主循環的邏輯
我們看下GameObject這個部分:
一個GameObject對應一個人物。GameObject負責管理掛載的組件,它可以掛載PositionComponent、VelocityComponent、FlyComponent、InstanceComponent這四種組件,每種組件最多掛載一個
我們看下組件這個部分:
組件負責維護自己的數據,實現自己的行爲邏輯。具體來說,是將NormalHero、SuperHero的position數據和move函數、update函數移到了PositionComponent中;將NormalHero、SuperHero的velocity數據移到了VelocityComponent中;將SuperHero的maxVelocity數據和fly函數移到了FlyComponent中
InstanceComponent沒有數據和邏輯,它只是一個標記,用來表示掛載該組件的GameObject使用一次性批量渲染的算法來渲染
結合UML圖,描述如何具體地解決問題
-
現在只需要實現一次Position組件中的update、move函數,然後將它掛載到不同的GameObject中,就可以實現普通英雄和超級英雄的更新、移動的邏輯,從而消除了之前在NormalHero、SuperHero中因共實現了兩次的update、move函數而造成的重複代碼
-
因爲NormalHero、SuperHero都是GameObject,而GameObject本身只負責管理組件,沒有行爲邏輯,所以隨着人物的行爲的增加,GameObject並不會增加邏輯,而只需要增加對應行爲的組件,讓GameObject掛載該組件即可
通過這樣的設計,將行爲的邏輯和數據從人物移到了組件中,從而可以通過組合的方式使人物具有多個行爲,避免了龐大的人物模塊的出現
給出代碼
首先,我們看下Client的代碼;
然後,我們依次看下Client代碼中前兩個步驟的代碼,它們包括:
- 創建WorldState的代碼
- 創建場景的代碼
然後,因爲創建場景時操作了普通英雄和超級英雄,所以我們看下它們的代碼,它們包括:
- 移動的相關代碼
- 飛行的相關代碼
然後,我們依次看下Client代碼中剩餘的兩個步驟的代碼,它們包括:
- 初始化和主循環的代碼
然後,我們看下主循環的一幀中每個步驟的代碼,它們包括:
- 主循環中更新的代碼
- 主循環中渲染的代碼
最後,我們運行Client的代碼
Client的代碼
Client的代碼跟之前的Client的代碼基本上一樣,故省略。不一樣的地方是_createScene函數中創建場景的方式不一樣,這個等會再討論
創建WorldState的代碼
World
export let createState = (): worldState => {
return {
gameObjects: Map()
}
}
createState函數創建了WorldState,它保存了一個用來保存所有的gameObject的容器
創建場景的代碼
Client
let _createScene = (worldState: worldState): worldState => {
創建和加入normalHero1到worldState.gameObjects:
創建gameObject
創建positionComponent
創建velocityComponent
創建instanceComponent
掛載positionComponent、velocityComponent、instanceComponent到gameObject
加入gameObject到worldState.gameObjects
創建和加入normalHero2到worldState.gameObjects
normalHero1移動:
調用normalHero1掛載的positionComponent的move函數
創建和加入superHero1到worldState.gameObjects:
創建gameObject
創建positionComponent
創建velocityComponent
創建flyComponent
掛載positionComponent、velocityComponent、flyComponent到gameObject
加入gameObject到worldState.gameObjects
創建和加入superHero2到worldState.gameObjects
superHero1移動:
調用superHero1掛載的positionComponent的move函數
superHero1飛行:
調用superHero1掛載的flyComponent的fly函數
return worldState
}
_createScene函數創建了場景,場景的內容跟之前一樣,都包括了2個普通英雄和2個超級英雄,只是現在創建一個英雄的方式改變了,具體變爲:首先創建一個GameObject和相關的組件;然後掛載組件到GameObject;最後加入該GameObject到World中
普通英雄對應的GameObject掛載的組件跟超級英雄對應的GameObject掛載的組件也不一樣,其中前者掛載了InstanceComponent(因爲普通英雄需要一次性批量渲染),後者則掛載了FlyComponent(因爲超級英雄多出了飛行的行爲)
另外,現在改爲通過調用對應組件的函數而不是直接操作英雄模塊,從而實現英雄的“移動”、“飛行”
GameObject
//創建一個gameObject
export let create = (): [gameObjectState, gameObject] => {
創建它的state數據:
沒有掛載任何的組件
返回該gameObject
}
GameObject的create函數創建了一個gameObject,初始化了它的數據
PositionComponent
//創建一個positionComponent
export let create = (): positionComponentState => {
創建它的state數據:
gameObject設置爲null
position設置爲[0,0,0]
其中:position爲位置,gameObject爲掛載到的gameObject
返回該組件
}
PositionComponent的create函數創建了一個positionComponent,初始化了它的數據
VelocityComponent
//創建一個velocityComponent
export let create = (): velocityComponentState => {
創建它的state數據:
gameObject設置爲null
velocity設置爲1.0
其中:velocity爲速度,gameObject爲掛載到的gameObject
返回該組件
}
FlyComponent
//創建一個flyComponent
export let create = (): flyComponentState => {
創建它的state數據:
gameObject設置爲null
maxVelocity設置爲1.0
其中:maxVelocity爲最大速度,gameObject爲掛載到的gameObject
返回該組件
}
InstanceComponent
//創建一個instanceComponent
export let create = (): instanceComponentState => {
創建它的state數據:
gameObject設置爲null
其中:gameObject爲掛載到的gameObject
返回該組件
}
這三種組件的create函數的職責跟PositionComponent的create函數的職責一樣,不一樣的是InstanceComponent的state數據中只有掛載到的gameObject,沒有自己的數據
我們可以看到,組件的state數據中都保存了掛載到的gameObject,這樣做的目的是可以通過它來獲得掛載到它上的其它組件,從而一個組件可以操作其它掛載的組件
移動的相關代碼
PositionComponent
...
//獲得一個組件的position
export let getPosition = (positionComponentState: positionComponentState) => {
return positionComponentState.position
}
//設置一個組件的position
export let setPosition = (positionComponentState: positionComponentState, position) => {
return {
...positionComponentState,
position: position
}
}
...
//一個gameObject的移動
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)))
//根據velocity,更新該組件的position
positionComponentState = setPosition(positionComponentState, [x + velocity, y + velocity, z + velocity])
更新worldState中該組件掛載的gameObject中的該組件的數據
}
VelocityComponent
//獲得一個組件的velocity
export let getVelocity = (velocityComponentState: velocityComponentState) => {
return velocityComponentState.velocity
}
PositionComponent維護了position數據,提供了它的get、set函數。VelocityComponent維護了velocity數據,,提供了它的get函數
另外,PositionComponent的move函數實現了移動的行爲邏輯
飛行的相關代碼
FlyComponent
//獲得一個組件的maxVelocity
export let getMaxVelocity = (flyComponentState: flyComponentState) => {
return flyComponentState.maxVelocity
}
//設置一個組件的maxVelocity
export let setMaxVelocity = (flyComponentState: flyComponentState, maxVelocity) => {
return {
...flyComponentState,
maxVelocity: maxVelocity
}
}
//一個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)))
//根據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中的該組件的數據
}
FlyComponent維護了maxVelocity數據,,提供了它的get、set函數。另外,FlyComponent的fly函數實現了飛行的行爲邏輯
初始化和主循環的代碼
初始化和主循環的邏輯跟之前一樣,故省略代碼
主循環中更新的代碼
World
export let update = (worldState: worldState): worldState => {
遍歷worldState.gameObjects:
if(gameObject掛載了positionComponent){
更新positionComponent
}
}
update函數實現了更新,它會遍歷所有的gameObject,調用它掛載的PositionComponent組件的update函數來更新該組件。我們看下PositionComponent的update函數的代碼:
//更新一個組件
export let update = (positionComponentState: positionComponentState): positionComponentState => {
更新該組件的position
}
它的邏輯跟之前的NormalHero和SuperHero中的update函數的邏輯是一樣的
主循環中渲染的代碼
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函數實現了超級英雄的渲染,它首先得到了所有沒有掛載InstanceComponent組件的gameObject;最後遍歷它們,一個一個地渲染
renderInstances函數實現了普通英雄的渲染,它首先得到了所有掛載了InstanceComponent組件的gameObject;最後批量渲染
運行Client的代碼
下面,我們運行Client的代碼,打印的結果如下:
初始化...
更新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}}}
通過打印的數據,可以看到運行的步驟與之前一樣
不同之處在於:
- 更新4個英雄現在變爲更新4個positionComponent
- 打印的WorldState不一樣
我們看下打印的WorldState:
- WorldState的gameObjects包括了4個gameObject的數據,其中有一個gameObject數據的positionComponent的position爲[2,2,2],說明它進行了移動操作;
- 有一個gameObject數據的positionComponent的position爲[6,6,6],說明它進行了移動和飛行操作
值得注意的是:
因爲WorldState的gameObjects中的Key是隨機生成的id值,所以每次打印時Key都不一樣
提出問題
- 組件的數據分散在各個組件中,性能不好
如position數據現在是一對一地分散保存在各個positionComponent組件中(即一個positionComponent組件保存自己的position),那麼如果需要遍歷所有組件的position數據,則需要遍歷所有的positionComponent組件,分別獲得它們的position。因爲每個positionComponent組件的數據並沒有連續地保存在內存中,所以會造成緩存命中丟失,帶來性能損失
- 涉及多種組件的行爲不知道放在哪裏
如果超級英雄增加一個“跳”的行爲,該行爲不僅需要修改position數據,還需要修改velocity數據,那麼實現該行爲的jump函數應該放在哪個組件中呢?
因爲jump函數需要同時修改PositionComponent組件的position數據和VelocityComponent組件的velocity數據,所以將它放在兩者中任何一種組件中都不合適。因此需要增加一種新的組件-JumpComponent,對應“跳”這個行爲,並實現jump函數。該函數會通過JumpComponent掛載到的gameObject來獲得掛載到它上的PositionComponent和VelocityComponent組件,從而修改它們的數據。
如果增加更多的這種涉及多種組件的行爲,就需要爲每個這樣的行爲增加一種組件。因爲組件比較重,既有數據又有邏輯,所以增加組件的成本較高;另外,因爲組件與GameObject是聚合關係,而GameObject和World也是聚合關係,它們都屬於強關聯關係,所以增加組件會較強地影響GameObject和World,這也增加了成本
使用ECS模式來改進
概述解決方案
-
基於Data Oriented的思想進行改進
組件可以按角色分爲Data Oriented組件和其它組件,其中前者的特點是屬於該角色的每個組件都有數據,且組件的數量較多;後者的特點是屬於該角色的每個組件都沒有數據,或者組件的數量很少。這裏具體說明一下各種組件的角色:目前一共有四種組件,它們是PositionComponent、VelocityComponent、FlyComponent、InstanceComponent。其中,InstanceComponent組件因爲沒有組件數據,所以屬於“其它組件”;另外三種組件則都屬於“Data Oriented組件”。
屬於Data Oriented組件的三種組件的所有組件數據將會分別集中起來,保存在各自的一塊連續的地址空間中,具體就是分別保存在三個ArrayBuffer中 -
將GameObject和各個組件扁平化
GameObject不再有數據和邏輯了,而只是一個全局唯一的id。組件也不再有數據和邏輯了,其中屬於“Data Oriented組件”的組件只是一個ArrayBuffer上的索引;屬於“其它組件”的組件只是一個全局唯一的id
-
增加Component+GameObject這一層,將扁平的GameObject和組件放在該層中
-
增加Manager這一層,來管理GameObject和組件的數據
這一層有GameObjectManager和四種組件的Manager,其中GameObjectManager負責管理所有的gameObject;四種組件的Manager負責管理自己的ArrayBuffer,操作屬於該種類的所有組件
- 增加System這一層,來實現行爲的邏輯
一個System實現一個行爲,比如這一層中的MoveSystem、FlySystem分別實現了移動和飛行的行爲邏輯
值得注意的是:
- GameObject和組件的數據被移到了Manager中,邏輯則被移到了Manager和System中。其中只操作自己數據的邏輯(如getPosition、setPosition)被移到了Manager中,其它邏輯(通常爲行爲邏輯,需要操作多種組件)被移到了System中
- 一種組件的Manager只對該種組件進行操作,而一個System可以對多種組件進行操作
給出UML
領域模型
總體來看,領域模型分爲五個部分:用戶、World、System層、Manager層、Component+GameObject層,它們的依賴關係是前者依賴後者
我們看下用戶、World這兩個部分:
Client是用戶
World是遊戲世界,雖然仍然實現了初始化和主循環的邏輯,不過不再管理所有的GameObject了
我們看下System這一層:
有多個System,每個System實現一個行爲邏輯。每個System的職責如下:
- CreateStateSystem實現創建WorldState的邏輯,創建的WorldState包括了所有的Manager的state數據;
- UpdateSystem實現更新所有人物的position的邏輯,具體是更新所有PositionComponent的position;
- MoveSystem實現一個人物的移動的邏輯,具體是根據掛載到該人物gameObject上的一個positionComponent和一個velocityComponent,更新該positionComponent的position;
- FlySystem實現一個人物的飛行的邏輯,具體是根據掛載到該人物gameObject上的一個positionComponent、一個velocityComponent、一個flyComponent,更新該positionComponent的position;
- RenderOneByOneSystem實現渲染所有超級英雄的邏輯;
- RenderInstancesSystem實現渲染所有普通英雄的邏輯
我們看下Manager這一層:
每個Manager都有一個state數據
GameObjectManager負責管理所有的gameObject
PositionComponentManager、VelocityComponentManager、FlyComponentManager、InstanceComponentManager負責管理屬於各自種類的所有的組件
PositionComponentManager的state數據包括一個buffer字段和一個positions字段,其中前者是一個ArrayBuffer,保存了該種組件的所有組件數據;後者是buffer的視圖,用於讀寫buffer中的position數據
PositionComponentManager的batchUpdate函數負責批量更新所有的positionComponent組件的position
因爲VelocityComponentManager、FlyComponentManager與PositionComponentManager一樣,都屬於Data Oriented組件的Manager,所以它們的數據和函數類似(只是沒有batchUpdate函數),故在圖中省略它們的數據和函數
我們看下Component+GameObject這一層:
因爲PositionComponent、VelocityComponent、FlyComponent屬於Data Oriented組件,所以它們是一個index,也就是各自Manager的state的buffer中的索引值
因爲InstanceComponent屬於其它組件,所以它是一個全局唯一的id
GameObject是一個全局唯一的id
我們來看下依賴關係:
System層:
因爲CreateSystem需要調用各個Manager的createState函數來創建它們的state,所以依賴了整個Manager層
因爲UpdateSystem需要調用PositionComponentManager的batchUpdate函數來更新,所以依賴了PositionComponentManager
因爲MoveSystem需要調用PositionComponentManager來獲得和設置position,並且調用VelocityComponentManager來獲得velocity,所以依賴了PositionComponentManager、VelocityComponentManager
因爲FlySystem需要調用PositionComponentManager來獲得和設置position,並且調用VelocityComponentManager、FlyComponentManager來分別獲得velocity和maxVelocity,所以依賴了PositionComponentManager、VelocityComponentManager、FlyComponentManager
因爲RenderOneByOneSystem和RenderInstancesSystem需要調用GameObjectManager來獲得所有的gameObject,並調用各種組件的Manager來獲得組件數據,所以依賴了整個Manager層
Manager層:
因爲GameObjectManager需要操作GameObject,所以依賴了GameObject
因爲各種組件的Manager需要操作所有的該種組件,所以依賴了對應的組件
結合UML圖,描述如何具體地解決問題
-
現在各種組件的數據都集中保存在各自Manager的state的buffer(ArrayBuffer)中,遍歷同一種組件的所有組件數據即是遍歷一個ArrayBuffer。因爲ArrayBuffer的數據是連續地保存在內存中的,所以緩存命中不會丟失,從而提高了性能
-
現在將涉及多種組件的行爲放在對應的System中。因爲System很輕,沒有數據,只有邏輯,所以增加和維護System的成本較低;另外,因爲System位於最上層,所以修改System也不會影響Manager層和Component+GameObject層
給出代碼
首先,我們看下Client的代碼;
然後,我們看下Client代碼中第一步的代碼:
- 創建WorldState的代碼
然後,因爲創建WorldState時會創建Data Oriented組件的Manager的state,其中的關健是創建各自的ArrayBuffer,所以我們看下創建它的代碼;
然後,我們看下Client代碼中第二步的代碼:
- 創建場景的代碼
然後,因爲創建場景時操作了普通英雄和超級英雄,所以我們看下它們的代碼,它們包括:
- 移動的相關代碼
- 飛行的相關代碼
然後,我們依次看下Client代碼中剩餘的兩個步驟的代碼,它們包括:
- 初始化和主循環的代碼
然後,我們看下主循環的一幀中每個步驟的代碼,它們包括:
- 主循環中更新的代碼
- 主循環中渲染的代碼
最後,我們運行Client的代碼
Client的代碼
Client
let worldState = World.createState({ positionComponentCount: 10, velocityComponentCount: 10, flyComponentCount: 10 })
跟之前一樣...
Client的代碼跟之前的Client的代碼基本一樣,除了createState函數的參數和_createScene函數中創建場景的方式不一樣,這個等會再討論
創建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函數創建了WorldState,它保存了各個Manager的state
因爲Data Oriented組件的Manager的state在創建時要創建包括該種組件的所有組件數據的ArrayBuffer,需要知道該種組件的最大個數,所以這裏的createState函數接收了三種Data Oriented組件的最大個數
創建ArrayBuffer的代碼
我們以PositionComponentManager爲例,來看下它的createState函數的相關代碼:
position_component/ManagerStateType
export type state = {
maxIndex: number,
buffer: ArrayBuffer,
positions: Float32Array,
...
}
這是PositionComponentManager的state的類型定義,它的字段解釋如下:
- buffer字段保存了一個ArrayBuffer,它用來保存所有的positionComponent的數據。目前每個positionComponent的數據只有position,它的類型是三個float
- positions字段保存了ArrayBuffer的一個視圖,通過它可以讀寫所有的positionComponent的position
- maxIndex字段是ArrayBuffer上最大的索引值,用於在創建一個positionComponent時生成它的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函數的代碼,其中調用的_initBufferData函數創建了buffer和positions,它的步驟如下:
1.調用BufferUtils的createBuffer函數來創建包括最大組件個數的數據的ArrayBuffer
2.調用CreateTypeArrayUtils的createTypeArrays函數來創建所有的TypeArray,它們是操作ArrayBuffer的視圖。這裏具體是隻創建了一個視圖:positions
3.調用_setAllTypeArrDataToDefault函數來將positions的所有的值寫爲默認值:[0,0,0]
下面是BufferUtils的createBuffer函數和CreateTypeArrayUtils的createTypeArrays函數的相關代碼:
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函數的邏輯跟PositionComponentManager的createState函數的邏輯一樣,故省略相關代碼
創建場景的代碼
Client
let _createScene = (worldState: worldState): worldState => {
創建normalHero1:
創建gameObject
創建positionComponent
創建velocityComponent
創建instanceComponent
掛載positionComponent、velocityComponent、instanceComponent到gameObject
創建normalHero2
normalHero1移動:
調用MoveSystem的move函數,傳入normalHero1的positionComponent、velocityComponent
創建superHero1
創建gameObject
創建positionComponent
創建velocityComponent
創建flyComponent
掛載positionComponent、velocityComponent、flyComponent到gameObject
創建superHero2
superHero1移動:
調用MoveSystem的move函數,傳入superHero1的positionComponent、velocityComponent
superHero1飛行:
調用FlySystem的fly函數,傳入superHero1的positionComponent、velocityComponent、flyComponent
return worldState
}
_createScene函數創建了場景,場景的內容跟之前一樣,都包括了2個普通英雄和2個超級英雄,只是現在創建一個英雄的方式又改變了,具體變爲:現在不需要加入GameObject到World中
另外,現在改爲通過調用MoveSystem和FlySystem的函數來操作對應的組件,從而實現英雄的“移動”、“飛行”
gameObject/Manager
export let createState = (): state => {
return {
maxUID: 0
}
}
//創建一個gameObject
//一個gameObject就是一個uid
export let createGameObject = (state: state): [state, gameObject] => {
let uid = state.maxUID
//生成一個uid
//uid的意思是unique id,即全局唯一的id
let newUID = uid + 1
state = {
...state,
maxUID: newUID
}
return [state, uid]
}
GameObjectManager的createGameObject函數創建了一個gameObject,它就是一個全局唯一的id
position_component/Manager
//創建一個positionComponent
//一個positionComponent就是一個index
export let createComponent = (state: state): [state, component] => {
let index = state.maxIndex
//生成一個index
let newIndex = index + 1
state = {
...state,
maxIndex: newIndex
}
return [state, index]
}
PositionComponentManager的createComponent函數創建了一個positionComponent,它就是一個PositionComponentManager的state的buffer上的索引
VelocityComponentManager、FlyComponentManager的相關代碼跟PositionComponentManager類似,故省略相關代碼
instance_component/Manager
//創建一個instanceComponent
//一個instanceComponent就是一個uid
export let createComponent = (state: state): [state, component] => {
let uid = state.maxUID
//生成一個id
let newUID = uid + 1
state = {
...state,
maxUID: newUID
}
return [state, uid]
}
InstanceComponentManager的createComponent函數創建了一個instanceComponent,因爲InstanceComponent組件屬於“其它組件”,所以它跟GameObject一樣都是一個全局唯一的id而不是一個index
移動的相關代碼
MoveSystem
//一個gameObject的移動
export let move = (worldState: worldState, positionComponent, velocityComponent): worldState => {
let [x, y, z] = PositionComponentManager.getPosition(worldState.positionComponentManagerState, positionComponent)
let velocity = VelocityComponentManager.getVelocity(worldState.velocityComponentManagerState, velocityComponent)
//根據velocity,更新positionComponent的position
let positionComponentManagerState = PositionComponentManager.setPosition(worldState.positionComponentManagerState, positionComponent, [x + velocity, y + velocity, z + velocity])
return {
...worldState,
positionComponentManagerState: positionComponentManagerState
}
}
MoveSystem的move函數實現移動的行爲邏輯。這裏涉及到讀寫Data Oriented組件的ArrayBuffer上的數據。我們來看下讀寫PositionComponentManager的positions的相關代碼:
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()
通過代碼可知,實現“讀寫PositionComponentManager的ArrayBuffer上的數據”的思路是:
因爲一個positionComponent的值是ArrayBuffer的索引,所以使用它來讀寫ArrayBuffer的視圖positions中的對應數據
飛行的相關代碼
FlySystem
//一個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)
//根據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函數實現了飛行的行爲邏輯
初始化和主循環的代碼
初始化和主循環的邏輯跟之前一樣,故省略代碼
主循環中更新的代碼
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函數實現了更新,它調用了PositionComponentManager的batchUpdate函數來批量更新所有的positionComponent組件。我們看下PositionComponentManager的相關代碼:
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函數遍歷所有的positionComponent,更新它們的position。更新的邏輯跟之前一樣
主循環中渲染的代碼
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]的數組
return range(0, maxUID - 1)
}
RenderOneByOneSystem的render函數實現了超級英雄的渲染,它首先得到了所有沒有掛載InstanceComponent組件的gameObject;最後一個一個地渲染
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函數實現了普通英雄的渲染,它首先得到了所有掛載了InstanceComponent組件的gameObject;最後一次性批量渲染
運行Client的代碼
下面,我們運行Client的代碼,打印的結果如下:
初始化...
更新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}}}
通過打印的數據,可以看到運行的步驟與之前一樣
不同之處在於:
- 打印的WorldState不一樣
我們看下打印的WorldState:
- WorldState的gameObjectManagetState的maxUID爲4,說明創建了4個gameObject;
- WorldState的positionComponentManagerState的maxIndex爲4,說明創建了4個positionComponent;
- WorldState的positionComponentManagerState的positions有3個連續的值是2、2、2,說明有一個positionComponent組件進行了移動操作;有另外3個連續的值是6、6、6,說明有另外一個positionComponent組件進行了移動操作和飛行操作;
定義
一句話定義
組合代替繼承;連續地保存組件數據;分離邏輯和數據
補充說明
“組合代替繼承”是指基於組件化思想,通過GameObject組合不同的組件代替GameObject層層繼承
“連續地保存組件數據”是指基於Data Oriented思想,將Data Oriented組件的組件數據集中起來,保存在內存中連續的地址空間
“分離邏輯和數據”是指將GameObject和組件扁平化,將它們的數據放到Manager層,將它們的邏輯放到System層和Manager層。其中,只操作自己數據的邏輯(如getPosition、setPosition)被移到了Manager中,其它邏輯(通常爲行爲邏輯,需要操作多種組件)被移到了System中
通用UML
領域模型
總體來看,領域模型分爲用戶、World、System層、Manager層、Component+GameObject層五個部分,它們的依賴關係是前者依賴後者。其中,System層負責實現行爲的邏輯;Manager層負責管理場景數據,即管理GameObject和組件的數據;Component+GameObject層爲組件和GameObject,它們現在只是有一個number類型的數據的值對象
我們看下用戶這個部分:
- Client
該角色是用戶
我們看下World這個部分:
- World
該角色是門戶,提供了API,實現了初始化和主循環的邏輯
我們看下System這一層:
一個System實現一個行爲的邏輯
-
CreateStateSystem
該角色負責創建WorldState -
OtherSystem
該角色是除了CreateStateSystem以外的所有System,它們各自有一個函數action,用於實現某個行爲
我們看下Manager這一層:
每個Manager都有一個state數據
-
GameObjectManager
該角色負責管理所有的gameObject -
DataOrientedComponentManager
該角色是一種Data Oriented組件的Manager,負責維護和管理該種組件的所有組件數據,將其集中地保存在各自的state的buffer中
DataOrientedComponentManager的state數據包括一個buffer字段和多個“typeArray of buffer”字段,其中前者是一個ArrayBuffer,保存了該種組件的所有組件數據;後者是buffer的多個視圖,用於讀寫buffer中對應的數據 -
OtherComponentManager
該角色是一種其它組件的Manager,負責維護和管理該種組件的所有組件數據
OtherComponentManager的state數據包括多個“value map”字段,它們是多個Hash Map,每個Hash Map保存一類組件數據
我們看下Component+GameObject這一層:
-
DataOrientedComponent
該角色是一種Data Oriented組件中的一個組件,它是一個ArrayBuffer上的索引 -
OtherComponent
該角色是一種其它組件中的一個組件,它是一個全局唯一的id -
GameObject
該角色是一個gameObject,它是一個全局唯一的id
角色之間的關係
-
只有一個CreateStateSystem
-
可以有多個OtherSystem,每個OtherSystem實現一個行爲
-
只有一個GameObjectManager
-
可以有多個DataOrientedComponentManager,每個對應一種Data Oriented組件
-
可以有多個DataOrientedComponent,每個對應一種Data Oriented組件
-
可以有多個OtherComponentManager,每個對應一種其它組件
- 可以有多個OtherComponent,每個對應一種其它組件
System層:
- 因爲CreateSystem需要調用各個Manager的createState函數來創建它們的state,所以依賴了整個Manager層
- 因爲OtherSystem可能需要調用1個GameObjectManager來處理gameObject、調用多個DataOrientedComponentManager和OtherComponentManager來處理各自種類的組件,所以它與GameObjectManager是一對一的依賴關係,與DataOrientedComponentManager和OtherComponentManager都是一對多的依賴關係
Manager層:
- 因爲GameObjectManager需要操作多個GameObject,所以它與GameObject是一對多的依賴關係
-
因爲DataOrientedComponentManager和DataOrientedComponent對應同一種Data Oriented組件,且前者管理所有的後者,所以前者和後者是一對多的依賴關係
-
因爲OtherComponentManager和OtherComponent對應同一種其它組件,且前者管理所有的後者,所以前者和後者是一對多的依賴關係
Component+GameObject層:
- 因爲一個GameObject可以掛載各種組件,其中每種組件只能掛載一個,所以GameObject與DataOrientedComponent、OtherComponent都是一對一的組合關係
角色的抽象代碼
下面我們來看看各個角色的抽象代碼:
我們按照依賴關係,從上往下依次看下領域模型中用戶、World、System層、Manager層、Component+GameObject層這五個部分的抽象代碼:
首先,我們看下屬於用戶的抽象代碼
然後,我們看下World的抽象代碼
然後,我們看下System層的抽象代碼,它們包括:
- CreateStateSystem的抽象代碼
- OtherSystem的抽象代碼
然後,我們看下Manager層的抽象代碼,它們包括:
- GameObjectManager的抽象代碼
- DataOrientedComponentManager的抽象代碼
- OtherComponentManager的抽象代碼
最後,我們看下Component+GameObject層的抽象代碼,它們包括:
- GameObject的抽象代碼
- DataOrientedComponent的抽象代碼
- OtherComponent的抽象代碼
用戶的抽象代碼
Client
let _createScene = (worldState: worldState): worldState => {
創建gameObject1
創建組件
掛載組件
觸發gameObject1的行爲
創建更多的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
}
//假實現
let requestAnimationFrame = (func) => {
}
export let loop = (worldState: worldState) => {
調用OtherSystem來更新
調用OtherSystem來渲染
requestAnimationFrame(
(time) => {
loop(worldState)
}
)
}
CreateStateSystem的抽象代碼
CreateStateSystem
export let createState = ({ dataOrientedComponent1Count }): worldState => {
return {
gameObjectManagerState: GameObjectManager.createState(),
dataOrientedComponent1ManagerState: DataOrientedComponent1Manager.createState(dataOrientedComponent1Count),
otherComponent1ManagerState: OtherComponent1Manager.createState(),
創建更多的DataOrientedManagerState和OtherComponentManagerState...
}
}
OtherSystem的抽象代碼
OtherSystem1
export let action = (worldState: worldState, gameObject?: gameObject, dataOrientedComponentX?: dataOrientedComponentX, otherComponentX?: otherComponentX) => {
行爲的邏輯...
return worldState
}
有多個OtherSystem,這裏只給出一個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數據
buffer: ArrayBuffer,
//該種組件所有的value1數據的視圖
value1s: TypeArrayType,
//該種組件所有的value2數據的視圖
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
// 這裏只給出了兩個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)),
]
}
有多個DataOrientedComponentManager,這裏只給出一個DataOrientedComponentManager的抽象代碼
OtherComponentManager的抽象代碼
other_component1/ManagerStateType
export type state = {
maxUID: number,
//value1Map用來保存該種組件所有的value1數據
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
}
有多個OtherComponentManager,這裏只給出一個OtherComponentManager的抽象代碼
GameObject的抽象代碼
GameObjectType
type id = number
export type gameObject = id
DataOrientedComponent的抽象代碼
DataOrientedComponent1Type
type index = number
export type component = index
有多個DataOrientedComponent,這裏只給出一個DataOrientedComponent的抽象代碼
OtherComponent的抽象代碼
OtherComponent1Type
type id = number
export type component = id
有多個OtherComponent,這裏只給出一個OtherComponent的抽象代碼
遵循的設計原則在UML中的體現
ECS模式主要遵循下面的設計原則:
- 單一職責原則
每個System只實現一個行爲;每個組件的Manager只管理一種組件 - 合成複用原則
GameObject組合了多個組件 - 接口隔離原則
GameObject和組件經過了扁平化處理,移除了數據和邏輯,改爲只是有一個number類型數據的值對象 - 最少知識原則
World、System、Manager、Component+GameObject這幾個層只能上層依賴下層,不能跨層依賴 - 開閉原則
要增加一種行爲,只需要增加一個System,不會影響Manager
應用
優點
-
組件的數據集中連續地保存在ArrayBuffer中,增加了緩存命中,提高了讀寫的性能
-
創建和刪除組件的性能也很好,因爲在這個過程中不會分配或者銷燬內存,所以沒有垃圾回收的開銷
這是因爲在創建ArrayBuffer時就預先按照最大組件個數分配了一塊連續的內存,所以在創建組件時,只是返回一個當前最大索引(maxIndex)的值而已;在刪除組件時,只是將ArrayBuffer中該組件對應的數據還原爲默認值而已 -
職責劃分明確,行爲的邏輯應該放在哪裏很清楚
對於只涉及到操作一種組件的行爲邏輯,則將其放在該組件對應的Manager(如將batchUpdate position的邏輯放到PositionComponentManager的batchUpdate函數中);涉及到多種組件的行爲邏輯則放在對應的System中(如將飛行行爲放到FlySystem中); -
增加行爲很容易
因爲一個行爲對應一個System,所以要增加一個行爲,則只需增加一個對應的System即可,這不會影響到Manager。另外,因爲System只有邏輯沒有數據,所以增加和維護System很容易
缺點
- 需要轉換爲函數式編程的思維
習慣面向對象編程的同學傾向於設計一個包括數據和邏輯的組件類,而ECS模式則將其扁平化爲一個值對象,這符合函數式編程中一切都是數據的思維模式。
另外,ECS中的System其實就只是一個函數而已,本身沒有數據,這也符合函數式編程中函數是第一公民的思維模式。
終上所述,如果使用函數式編程範式的同學能夠更容易地使用ECS模式
使用場景
場景描述
遊戲的場景中有很多種類的人物,人物的行爲很多或者很複雜
具體案例
-
有很多種類的遊戲人物
通過掛載不同的組件到GameObject,來實現不同種類的遊戲人物,代替繼承 -
遊戲人物有很多的行爲,而且還經常會增加新的行爲
因爲每個行爲對應一個System,所以增加一個新的行爲就是增加一個System。不管行爲如何變化,隻影響System層,不會影響作爲下層的Manager層和GameObject、Component層 -
對於引擎而言,ECS模式主要用在場景管理這塊
注意事項
- 因爲組件的ArrayBuffer一旦在創建後,它的大小就不會改動,所以最好在創建時指定足夠大的最大組件個數
結合其它模式
結合多線程模式
如果引擎開了多個線程,那麼可以將組件的ArrayBuffer改爲SharedArrayBuffer。這樣的話就可以將其直接共享到各個線程中而不需要拷貝,從而提高了性能
結合管道模式
如果引擎使用了管道模式,那麼可以去掉System,而使用管道的Job來代替。其中一個Job就是一個System
另外,可以去掉WorldState,而使用PipelineManagerState來代替
最佳實踐
哪些場景不需要使用模式
如果遊戲的人物種類很少,行爲簡單,那麼就可以使用最開始給出的人物模塊的方案,即使用一個人物模塊對應一種人物,並通過繼承實現多種人物,這樣最容易實現
更多資料推薦
ECS的概念最先是由“守望先鋒”遊戲的開發者提出的,詳細資料可以在網上搜索“《守望先鋒》架構設計和網絡同步”
ECS模式是在“組件化”、“Data Oriented”基礎上發展而來,可以在網上搜索更多關於“組件化”、“Data Oriented”、“ECS”的資料