发表于: 5/20/2023
:::info 本文又名 游戏开发日志 (其3𝒾) :::
敷衍的介绍
ECS 即 Entity-Component-System 的缩写,是一种将世界抽象为: 组件 (Component): 持有完成特定功能所需的数据的结构 系统 (System): 通过查询、修改{数据^组件}实现想要的行为的“函数” 实体 (Entity): 持有一系列组件的个体的标识
一个简单的例子:对于一个实体 “Car”,要实现移动、载人这两样功能的话,则需要组件 “Movable”、“Loadable” 其中可以存储 “速度 (Movable)” 或 “乘员数 (Loadable)” 诸如此类的数据,这样就可以通过系统获取所有 “Movable” 和某种“目标”、“方向”一类的数据,进而移动实体的 “Transform” ;载人功能亦如此,你也可以多查询一个 “Movable” ,配合 “Loadable” 在乘员变化时影响 “速度 (Movable)” 让空车更快。
显而易见,这样的范式与主流的面向对象的思路大相径庭,将数据与行为隔离开,编写行为来影响不特定范围的数据,数据既是行为的结果又是行为的原因,此为面向数据编程 (Data Oriented Programing - DOP)。
其显著的特点是设计思路反人类
是高性能,得益于其数据组织方式,{系统^System}在运行时访问整块连续的同种数据,欢呼吧,缓存!
:::default
有这样的一种说法:对现代CPU而言,整体运行速度的瓶颈在于存储访问——你的某算法的优化版本很可能不如其对缓存友好的暴力循环版本
:::
而且还有一种潜藏的优化空间——批量重复的数据可以很好的被GPU处理,或许你能更轻松的利用到在看戏的GPU。
“反人类”的另一面
确实有相当的声音:ECS 的代码组织方式有悖人类直觉。
而这也确实不是空穴来风,不过如果使用ECS的优势仅仅只是在性能方面的话,那么也不会有本文出现了。性能,它值几个钱? !!也不是很配谈性能的说!!
回到上面的例子,直觉来想,“Car” 需要能跑能载人,有了 “载具” 这一概念,我实现一个载具,能跑能装人,写完收工,也没什么不好的,轻松又愉快,而仅仅写一个 “Loadable” 可不能实现载人,你很可能需要给人添加一个 “TakeCar” 组件再交给某个系统处理而不是 car.Load(people)
,何异于褪裤而放屁者?
不过生活未必总是会一帆风顺,问题总是会来的,让我们更进一步。
“Car” 是要来开的,在此先假设载具与玩家本人的控制方式一致,这里就需要先解除控制玩家单位转而控制载具,稍有不慎,可能两套控制代码就来了,而将玩家身上的 Controllable
或者叫别的什么移动到载具上是很自然的一件事情——你总需要一个方式在茫茫的Entities中找出你要控制的那个。
如果有很多载具的话,还是让一部分动起来比较自然,有没有想过要怎么用car.MoveTo(..)
来组织一个交通网?我有一计,不如放弃思考,看我用一个{系统^System}查询 AutoRun
,给没有MoveTo
的实体添加一个新目标MoveTo
,随后等着掌管 “Movable” 的系统移动到位删除MoveTo
便好。日后若是想给玩家的载具添加自动驾驶也不必如现实里一样苦苦研发一番。
很不巧,刚刚拍脑袋想到我们应该加入地堡:单位进入可以获得庇护。哎呀,这不是没有 “Movable” 的 “Car” 么,这下我们比拼一下下班速度吧 ;)。
到此,不知各位是否已经有些许既视感,ECS-way真的就与我们的直觉——或者说我们已经熟悉的某种范式想去甚远吗?比较擅长抽象(狭义)多少已经主动或被动的接触到“组合优于继承”、“Is A 与 Has A 的纠缠”一类的纷纭。 在此暴论:DOP就是在完全控制的场面下适合于大量广泛相互作用场景的OOP的极限值。
当我们想到 “载具” 的概念,再进一步,切至不可分的 “Movable”、“Loadable” 以至于更多需要的部分,这一过程实际上就是输出代码的前置准备工作,即便并未拆分出组件也是如此。 得益于数据完全被拆分出去,行为代码可以完全复用,再也不需要纠结涉及到多个组件时到底把功能实现到什么地方,需要改动时通常只需要涉及有限的区域——平铺的线性流程,而不是柳暗花明的嵌套调用,更是可以通过玩弄{标签^组件}来实现新功能。
能够如此拆分数据的一个原因是游戏的世界有一个很重要的性质:这里面的一切都是完全封闭的,完全由我们——程序员来控制,而玩家,则仅能通过有限的输入事件来操作,而不是几十个API,实在只能算作外人要被骗到底裤都不剩的。
正是因为封闭,不会有访问到不该访问的数据这种情况,而恰好这里面的数据随时有可能彼此产生联系——灵感来啦!
限制访问——在数据堆在一起时不得已而为之的手段,终于得以摆脱,于是给予我们极大的灵活性。
:::default no-icon 突然有了一个邪恶的想法,通过C#的扩展方法也可以实现类似的效果。
public static class ExtMethods {
public static void DoSometing<T>(this T entity) where T: Interface1, Interface2, .. {
// ...
}
}
避开了接口不方便带实现的限制,一些工具方法完全可以通过给T
添加约束来实现,我干,感觉还真有那么些可行性。
:::
纯粹的目的
所以回到思考方式上来,ECS 或者说 DOP,相比于“直觉”,只是在抽象上更进了一步——将我们的想法,分解成一个个简单的逻辑,再组合起来。 就如前文所举的 “Movable” ,其实玩家的角色、自动行动的 NPC、乃至联机中其他玩家操控的角色,它们的移动都没有什么不同,其真正的区别在于 “行动意图” 的来源:
- 玩家的角色的移动由玩家的输入驱动
- 自动行动的 NPC 的移动由 AI 的决策驱动
- 联机中其他玩家操控的角色的移动由网络读取的数据驱动
于是我们真正要做的就是区分这些类型的单位,分别为它们实现产生 “MoveTo” 的系统。并且借助这个控制 Movable 的系统,在之后的开发中我们都不必再考虑一个实体要如何移动的问题,即使是子弹,即使是进行优美曲线运动的弹幕,只有如何产生 “MoveTo” ,以及它如何变化的问题。