C/C++教程

ECS模式

本文主要是介绍ECS模式,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

大家好,本文提出了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 =
这篇关于ECS模式的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!