大家好,本文提出了ECS模式。ECS模式是游戏引擎中常用的模式,通常用来组织游戏场景。本文出自我写的开源书《3D编程模式》,该书的更多内容请详见:
Github
在线阅读
我们需要开发一个游戏,游戏中有两种人物:普通英雄和超级英雄,他们具有下面的行为:
我们使用下面的方法来渲染:
应该有一个游戏世界,它由多个普通英雄和多个超级英雄组成
一个模块对应一个普通英雄,一个模块对应一个超级英雄。模块应该维护该英雄的数据和实现该英雄的行为
领域模型
总体来看,领域模型分为用户、游戏世界、英雄这三个部分
我们看下用户、游戏世界这两个部分:
Client是用户
World是游戏世界,由多个普通英雄和多个超级英雄组成。World负责管理所有的英雄,并且实现了初始化和主循环的逻辑
我们看下英雄这个部分:
一个NormalHero对应一个普通英雄,维护了该英雄的数据,实现了移动的行为
一个SuperHero对应一个超级英雄, 维护了该英雄的数据,实现了移动、飞行的行为
首先,我们看下Client的代码;
然后,我们依次看下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,用来保存游戏世界中所有的数据;然后创建了场景;然后进行了初始化;最后开始了主循环
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的代码,打印的结果如下:
初始化... 更新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和superHeroes中的Key是随机生成的id值,所以每次打印时Key都不一样
NormalHero和SuperHero中的update、move函数的逻辑是重复的
如果英雄增加更多的行为,NormalHero和SuperHero模块会越来越复杂,不容易维护
虽然这两个问题都可以通过继承来解决,即最上面是Hero基类,然后不同种类的Hero层层继承,但是继承的方式很死板,不够灵活
基于组件化的思想,用组合代替继承。具体修改如下:
这样就通过GameObject组合不同的组件来代替人物层层继承,从而更加灵活
领域模型
总体来看,领域模型分为用户、游戏世界、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使用一次性批量渲染的算法来渲染
首先,我们看下Client的代码;
然后,我们依次看下Client代码中前两个步骤的代码,它们包括:
然后,因为创建场景时操作了普通英雄和超级英雄,所以我们看下它们的代码,它们包括:
然后,我们依次看下Client代码中剩余的两个步骤的代码,它们包括:
然后,我们看下主循环的一帧中每个步骤的代码,它们包括:
最后,我们运行Client的代码
Client的代码跟之前的Client的代码基本上一样,故省略。不一样的地方是_createScene函数中创建场景的方式不一样,这个等会再讨论
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 =