ECS模式

大家好,本文提出了ECS模式。ECS模式是遊戲引擎中常用的模式,通常用來組織遊戲場景。本文出自我寫的開源書《3D編程模式》,該書的更多內容請詳見:
Github
在線閱讀

目錄

普通英雄和超級英雄

需求

我們需要開發一個遊戲,遊戲中有兩種人物:普通英雄和超級英雄,他們具有下面的行爲:

  • 普通英雄只能移動
  • 超級英雄不僅能夠移動,還能飛行

我們使用下面的方法來渲染:

  • 使用Instance技術來一次性批量渲染所有的普通英雄
  • 一個一個地渲染每個超級英雄

實現思路

應該有一個遊戲世界,它由多個普通英雄和多個超級英雄組成

一個模塊對應一個普通英雄,一個模塊對應一個超級英雄。模塊應該維護該英雄的數據和實現該英雄的行爲

給出UML

領域模型

image

總體來看,領域模型分爲用戶、遊戲世界、英雄這三個部分

我們看下用戶、遊戲世界這兩個部分:

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

領域模型

image

總體來看,領域模型分爲用戶、遊戲世界、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

領域模型

image

總體來看,領域模型分爲五個部分:用戶、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

領域模型

image

總體來看,領域模型分爲用戶、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”的資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章