C#中ECS的探索与发现(一)
ECS从本质上来说是一种设计模式.而不是某个语言的特性.该系列文章主要是探索ECS在C#中实现时遇到的各种纠结的问题与方案.同时设计一个unity为前端 .net core做服务器的分布式开发框架.但是水平很菜,难免有错.望批评指正,与君共勉.
ECS是什么
ECS是Entity Component System的缩写,实例由组件聚合而成.实例本身不包含任何数据,全部的数据都来自它所拥有的组件.也就是说实例有什么数据,取决它有什么组件.系统则是功能的实现.可以理解为系统关注某些组件,当组件存在时.系统将会进行某些操作.
上面的解读来自各种能百度到的文章上讲的.但是我想在这里用一个实际开发的角度去看待ECS.
先举个例子:有一件物品在地上,玩家A拾取这个物品.
按照ECS的做法会给物品挂上一个"被拾取"的组件.然后某个系统会处理这个组件.他会把所有带着这个组件的物品,根据组件上面描述的拾取者,将这个物品移动至该拾取者的背包中.这里系统关注的是"被拾取"组件,但是他同时会使用其他组件,比如这里的背包组件.任何一个持有"被拾取"组件的实例,都会被那个系统给处理移动.如果不加控制,你甚至可以将一个NPC或者玩家挂上这个组件,结果就是玩家变成了宠物小精灵.(这个功能可以有)这显然是不合理.所以你可以将那个物品挂上一个"可以被拾取"的组件,这样在挂组件之前可以判断是否可以拾取.
通过组件于实例的分离.使得实例在持有不同的组件时,会有不同的功能.同时由于相同的组件在同一容器内存储,这使得他们的内存地址是连续的.这样在系统更新某组组件时,会有非常好的CPU缓存命中率.这也是Unity目前主推ECS的一个重要原因.组件分离会使得每个组件所包含的数据很少.每次系统执行更新时,所要遍历的内存也会少的很多.加上上面说的连续的内存排列,性能会有显著的提升.
好啦,现在看起来一切都那么美好.通过组件的分离,系统的独立.使得你可以给某个实例挂上某个组件就可以实现某个功能.但是....实际上并没有想象中的那么美好.
ECS可以实现么
上面把ECS说的那么美好,那我们可以用C#实现这样的效果么.我们先一一的分析一下.
Cpu Cache
这是ECS模型最显著的一个优势.但是事实上,对于业务上的开发.你很难设计CPU Cache友好的代码.因为我们在系统中处理的逻辑通常都大量使用了其他组件,这时CPU在加载数据的时候就会重新寻找其他组件的内存数据.于是Cache就被破坏了.而且.更重要的是.我们使用C#做开发.那么做到内存连续分配与存储就变得更麻烦.
想让内存变的连续存储我们首先想到的是结构体.如果采用结构体作为组件,那么标识一个组件就不能用一个抽象类继承,只能用一个接口来继承.实际上Unity也是这么做的.选了结构体之后会带来超级多的问题,这让你在设计一个ECS框架的时候捉襟见肘.后面我们在设计的时候会一一讨论.而且你在使用组件的时候,组件的属性也不能是引用类型的.否则Cpu会跳转到引用的地址上去寻找数据.同样破坏cache.
到了这里我们该怎么办.两个方向,第一.努力去按照cache友好去设计框架与代码.这里就难免的要直接去处理内存,否则很大值类型在使用的时候会拷贝传递引起性能上的开销.第二,为了方便设计于使用.放弃掉cache友好.
在我实际的使用过程中发现.想要做到完全Cache友好,难度高到基本做不到.能覆盖的程度也很难衡量.只有在有大量单位重复做同样的动作时,才会覆盖一点.而且即使是覆盖了,代码编写上也非常麻烦.时刻都要注意内存排列的问题.这里编码设计的付出,与性能提升一点点的回报完全不能正比.所以我们这里就不在兼顾cache友好.(其实Cache友好要做的工作非常大,后面设计的时候就能逐一体现出来)
业务变的更清晰
当我们挥泪砍掉一个特性之后,在回来看上面的那个例子.我们已经注意到了,在使用实例时.实例的多态是来自于不同的组件聚合而成.想让业务解耦,系统复用.则组件必须要切的特别细.而且,更重要的是"一个系统最好只使用一个组件".假设我们按照标准的ECS模型去实现上面的例子,那么简单的看应该是这样.一个"可以被拾取"组件用来标记某个实例可以被拾取,一个"被拾取"组件标记一个实例已经被拾取了.如有有其他人去拾取,则会失败.返回XXX已经拾取了该物品.一个"背包"组件,内部有一个物品容器.可以接收一个标记为物品的实例.
我们粗略的一看这里已经出现了非常多的组件了,而且每个动作都会有相关的系统实现.实际开发过程中.一个非ECS的业务,比如说有1W行代码,使用ECS来实现要1.3W行左右,类的数量会显著膨胀,每个类(组件,系统)的代码都很少.这会产生两个极端.
1.每个功能都会实现的很干净.错误会局限在一个很小的范围内,对扩展跟修改超级友好.复用效率很高.
2.因为类的膨胀导致管理不便,这个膨胀的程度很高.系统功能越单一,则组件就越多,结果就是类就更多.有冗余代码和运算.因为系统的独立性,导致某些相关的运算要在不同的系统中重复实现.虽然我们可以抬杠说抽出重复部分做成方法来复用,但是状态运算的中间值其他系统需要时该怎么处理.采用一个组件来传递数据?这部分其实跟微服务的概念一样.在开发某个系统的时候,你不能让他依赖另外一个系统,否则就不是一个独立的系统了.
就拿上面的例子来看.一个"可以被拾取"的组件标识一个实例可以被拾取,但是要如何标识他可以被谁拾取呢?首先我们可以添加一个属性,用来标示可以被谁来拾取(比如player,Npc).但是同样我们也可以设计不同的拾取权限组件.比如"可以被Player拾取","可以被Npc拾取"的组件.说实话他们两个的效果基本没有区别.虽然你可以说如果分离了,他们可以独立向下演化下去,各自又会有自己独立的属性.但是实际上这么设计下去很容易就过度了.设计组件的时候都是独立的组件,到了最后发现其实他们并没有独立的必要.因为如果产生了不同的属性时,可以设计不同的组件来补充.
实际上的"效果"
根据上面举得情景来看.设计过多的组件会导致类的极具膨胀.某一个流程的实现要不断的挂载移除数个甚至十数个组件.而且更坑的是,这些组件的基本上都是按照这个顺序挂载的.虽然看起来代码上并不耦合,但是使用起来他们从来都是耦合使用的.在我使用的过程中,为了解决这个问题.我引入一个叫做"模块"的概念.一个模块组织若干个组件与对应的系统.这样就不会出现在某处忘记挂载某个组件或者挂错某个组件(因为组件的名字都非常相似).后来我想要么就不要组件了吧,都用模块来代替.哪怕一个模块里只有一个组件.当我刚想着手改造的时候发现,那我把模块直接叫做组件不就好了么,于是又回到原点.之所以出现这种情况,是因为组件过于松散了.有了教训之后就开始大开大合,大量的组件合并到一起.带来的后果就是大量的系统也要合并到一起.这就离CPU Cache越来越远了.这也是后面决定砍掉Cache友好的重要理由.
扯了这么多,只是想说明几个问题.因为ECS是反OOP的,因为我经验不足技术很菜,所以设计起来很容易过耦合或者过松散.在就是很难保证组件的"纯净性",后面在设计的时候会面对这个棘手的问题.在这么多问题存在的情况下,我没有办法在兼顾开发速度的同时,又那么兼顾ECS的设计模式.所以只能设计一个符合在.net平台下的,看起来很像ECS的框架.(其实我还是觉得ECS最核心的是EC)
后面我们就逐一的开始设计.