« ECS 中的概念缺失 | 返回首页 | skynet 版的 cache server 改进 »

不适合采用 System 的一种情况

ECS 框架中,System 通常是对某种 Component 的集中处理。大部分情况下,一次性对大量对象中的简单的数据结构做简单的处理,比每次对一个对象的复杂数据结构(通常是简单数据结构的组合)做复杂处理,依次处理所有的对象要更好。

凡事总有例外。我们最近就发现有一类情况不适合这样做。那就是骨骼动画的骨骼计算。

骨骼计算会分为若干步骤,有骨骼数据的展开,骨骼姿态的混合,IK 计算等等。一开始,我们遵循着 ECS 的设计原则,把这些步骤分到不同 System 种。例如并非所有的对象都需要做 IK 计算,这样 IK System 就只用遍历一个子集;同样,也并非所有的动画都需要做多个姿态的混合,等等。

但我们在实现时却发现了很大的问题。

骨骼相关运算是需要在一个临时短生命期的 Buffer 上进行的。骨骼本身的持久化数据结构占内存不大,但是如果需要做中间运算,就需要把紧凑的数据展开到一个中间 Buffer 中,有关混合、IK 这些运算都是基于这个中间 Buffer 的,而当这些运算完毕,到了蒙皮阶段,中间 Buffer 又没有存在的必要了。

如果遵循 ECS 的原则,我们把这些步骤分离,每个步骤都遍历所有的对象各做一次,就会生成大量的中间 Buffer ,徒耗内存。其实本质上,一个线程只需要一个中间 Buffer 就够了。因为对一个对象做完处理,这个 Buffer 就不再使用,可以被下一组运算复用。

所以,我们近期做的调整就是把这一系列关于骨骼的算法全部集中在一个 System 中处理。

下面再谈谈 Lua 封装代码的处理。

如果整个过程是在 C/C++ 层面完成,那么,这个中间 Buffer 完全可以开在 stack 上。等一系列操作完成后,就会自动回收。但是将 C 函数封装为 Lua 函数后, C stackframe 无法保留任何私有数据。如果你把中间 Buffer 封装成一个 userdata 扔回 Lua ,就面临被滥用的可能。一旦滥用,对 gc 带来额外负担。

我们的做法是把它封装成 userdata 后,隐藏在 C api 的 upvalue 里面,应用层无法直接访问。这一系列针对骨骼数据的 API 就好像 opengl 的 api 那样,有个内在的状态维持在 vm 中。每组调用都从一个显式的调用开始:对于骨骼运算,就是将骨骼数据展开到临时 Buffer 中。

相比昂贵的骨骼运算操作步骤,一次 Lua 到 C 的调用开销算不得什么,所以我们没有将它封装成 [数学库] (https://blog.codingnow.com/2019/03/improved_math3d.html) 的样子(允许单次数学库调用进行足够多的数学运算),还是保留成一个个独立的 Lua API 调用。


我认为以后会有很多类似的情况不适合分 System 分步处理,值得注意。

总结一下,当针对单个对象进行的一系列操作,需要的中间临时资源相对昂贵时,不应该拆分成多步 System 。昂贵的资源可能是内存,也可能是显卡资源。

Comments

就像其它网友说的 ECS 使用在逻辑层非常好。但是不应该把它提升到引擎的层面,不利于各种优化。
在我的理解中 ECS 应该作用于比较高的抽象层面,以骨骼动画为例,C 只需要引用骨骼动画的一个实例,而 S 只是对需要更新的实体进行 Update 对不需要更新的实体(冰冻 buff)直接跳过。至于怎么 update 一个骨骼动画,则交给引擎层面去处理,利如用龙骨或者spine。
同理,物理引擎、UI 等等,都不适合用 ECS 来实现。

建议云风在引擎层远离ECS,真心建议,否则你会陷入到自己的大坑中,ECS的适用环境是逻辑层开发,如果你在引擎层依赖ECS将带来的是灾难

可是 ECS 的设计初衷并不是为了并行化。是为了解耦/简化问题。

System 本质上是对 Component 的分步 transforming ,拆分的越细就越不适合并行化。

简化并行的前提是按对象切割,让并行流程所处理的数据互不相关。ECS 的设计是违背这个原则的。越精密的设计越不容易并行。这么做势必将问题复杂化。

两个system如果依赖的是完全正交的组件,那这两个system应该是可以并发执行的;再者一个system如果是无状态,纯函数,那这个system本身应该也能并发处理多个entity。
有没有可能根据system对具体组件的依赖,把多个system函数组织成一个拓扑,只要符合拓扑顺序,就可以并发执行里面的任一system。
-----
buffer那个,如果buffer被第一个步骤占用,再被最后一个步骤释放,就可以解决这个问题了吧。
串行的方式,多个step的情况下必须是 step1(all entities)->step2(all entities)->... 所以要么只设计一个step,要么使用很多个buffer;
如果是并发的方式,每个step操作每个entity是一个调度单元,实际执行可以是 step1(e1)->step2(e1)->step_last(e1)->step1(e2)->...

看来云大的引擎也在进展中,期待基于ECS设计的表现。
感觉Unity的ECS发展还要一段时间 :)

@ruihong

buffer 并不是单个步骤所需的临时资源,buffer 维持了状态。

system 也不是并行的,system 间有状态依赖时,就必须串行处理,且必须保证次序。

考虑一下这种情况,如果system执行步骤本身是一种多并发的设计,这种中间buffer设计为一种需要抢占的资源。
这时候如果能提供无限个buffer,那就可以让同一个步骤处理完多个实体再进行下一个步骤;
也可以只给一个buffer,那这个system的多个步骤就会对同一个实体连续的执行完再处理下一个实体(因为第一个步骤就要等待buffer资源);
同理也可以随意指定内存允许的数量。

厉害了我风哥

Post a comment

非这个主题相关的留言请到:留言本